├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-反馈.md │ ├── pt-plugin-plus-功能迁移.md │ ├── 功能请求.md │ └── 站点适配.md ├── dependabot.yml └── workflows │ └── action_build.yml ├── .gitignore ├── .husky └── pre-commit ├── .vscode └── extensions.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package.json ├── pnpm-lock.yaml ├── privacy-statement.md ├── public └── icons │ ├── backupServer │ ├── CookieCloud.png │ ├── DropBox.png │ ├── Gist.png │ ├── GoogleDrive.png │ ├── OWSS.png │ └── WebDAV.png │ ├── downloader │ ├── Aria2.png │ ├── Deluge.png │ ├── Flood.png │ ├── Transmission.png │ ├── qBittorrent.png │ ├── ruTorrent.png │ ├── synologyDownloadStation.png │ └── uTorrent.png │ ├── logo │ ├── 128.png │ ├── 16.png │ ├── 19.png │ └── 64.png │ ├── mediaServer │ ├── emby.png │ └── jellyfin.png │ ├── movie_placeholder.png │ ├── site │ ├── audiences.ico │ ├── btschool.ico │ ├── chdbits.ico │ ├── discfan.ico │ ├── fsm.jpg │ ├── hdchina.jpg │ ├── kamept.ico │ ├── morethantv.ico │ ├── mteam.ico │ ├── opencd.ico │ ├── ourbits.ico │ ├── pthome.ico │ ├── ptzone.ico │ ├── qingwa.svg │ ├── rousi.ico │ ├── soulvoice.ico │ ├── torrentleech.ico │ ├── ubits.ico │ ├── yemapt.png │ └── zhixing.ico │ └── social │ ├── anidb.png │ ├── bangumi.png │ ├── douban.png │ ├── imdb.png │ ├── tmdb.png │ └── tvdb.png ├── src ├── entries │ ├── background │ │ ├── ff_main.ts │ │ ├── main.ts │ │ └── utils │ │ │ ├── alarms.ts │ │ │ ├── contextMenus.ts │ │ │ ├── cookies.ts │ │ │ ├── offscreen.ts │ │ │ ├── omnibox.ts │ │ │ └── webRequest.ts │ ├── messages.ts │ ├── offscreen │ │ ├── adapter │ │ │ └── indexdb.ts │ │ ├── offscreen.html │ │ ├── offscreen.ts │ │ └── utils │ │ │ ├── backup.ts │ │ │ ├── download.ts │ │ │ ├── logger.ts │ │ │ ├── search.ts │ │ │ ├── site.ts │ │ │ ├── socialInformation.ts │ │ │ └── userInfo.ts │ ├── options │ │ ├── App.vue │ │ ├── components │ │ │ ├── CheckSwitchButton.vue │ │ │ ├── ConnectCheckButton.vue │ │ │ ├── DeleteDialog.vue │ │ │ ├── DownloaderLabel.vue │ │ │ ├── NavButton.vue │ │ │ ├── ResultParseStatus.vue │ │ │ ├── SiteFavicon.vue │ │ │ ├── SiteName.vue │ │ │ ├── SolutionDetail.vue │ │ │ └── TorrentTitleTd.vue │ │ ├── directives │ │ │ ├── useAdvanceFilter.ts │ │ │ └── useResetableRef.ts │ │ ├── index.html │ │ ├── main.ts │ │ ├── plugins │ │ │ ├── i18n.ts │ │ │ ├── pinia.ts │ │ │ ├── router.ts │ │ │ └── vuetify.ts │ │ ├── stores │ │ │ ├── config.ts │ │ │ ├── metadata.ts │ │ │ └── runtime.ts │ │ ├── style.css │ │ ├── utils.ts │ │ └── views │ │ │ ├── About │ │ │ ├── Logger.vue │ │ │ ├── SpecialThank.vue │ │ │ └── TechnologyStack.vue │ │ │ ├── Devtools │ │ │ └── Debugger.vue │ │ │ ├── Layout │ │ │ ├── Navigation.vue │ │ │ └── Topbar.vue │ │ │ ├── Overview │ │ │ ├── DownloadHistory │ │ │ │ ├── AdvanceFilterGenerateDialog.vue │ │ │ │ ├── Index.vue │ │ │ │ ├── ReDownloadSelectDialog.vue │ │ │ │ └── utils.ts │ │ │ ├── MediaServerEntity │ │ │ │ ├── Index.vue │ │ │ │ ├── ItemInformationDialog.vue │ │ │ │ └── utils.ts │ │ │ ├── MyData │ │ │ │ ├── HistoryDataViewDialog.vue │ │ │ │ ├── Index.vue │ │ │ │ ├── UserDataStatistic │ │ │ │ │ ├── Index.vue │ │ │ │ │ └── utils.ts │ │ │ │ ├── UserDataTimeline │ │ │ │ │ ├── Index.vue │ │ │ │ │ └── utils.ts │ │ │ │ ├── UserLevelRequirementsTd.vue │ │ │ │ ├── UserLevelShowSpan.vue │ │ │ │ ├── UserLevelsComponent.vue │ │ │ │ ├── UserNextLevelUnMet.vue │ │ │ │ └── utils.ts │ │ │ ├── SearchEntity │ │ │ │ ├── ActionTd.vue │ │ │ │ ├── AdvanceFilterGenerateDialog.vue │ │ │ │ ├── Index.vue │ │ │ │ ├── SaveSnapshotDialog.vue │ │ │ │ ├── SearchStatusDialog.vue │ │ │ │ ├── SentToDownloaderDialog.vue │ │ │ │ ├── TorrentProcessTd.vue │ │ │ │ └── utils.ts │ │ │ └── SearchResultSnapshot │ │ │ │ ├── EditNameDialog.vue │ │ │ │ └── Index.vue │ │ │ └── Settings │ │ │ ├── SetBackup │ │ │ ├── AddDialog.vue │ │ │ ├── EditDialog.vue │ │ │ ├── Editor.vue │ │ │ ├── HistoryDialog.vue │ │ │ ├── Index.vue │ │ │ ├── LocalExportConfirmDialog.vue │ │ │ └── RestoreDialog.vue │ │ │ ├── SetBase │ │ │ ├── BackupWindow.vue │ │ │ ├── DownloadWindow.vue │ │ │ ├── Index.vue │ │ │ ├── RestorePtppUserDataDialog.vue │ │ │ ├── SearchEntityWindow.vue │ │ │ ├── SocialInformationWindow.vue │ │ │ ├── UiWindow.vue │ │ │ └── UserInfoWindow.vue │ │ │ ├── SetDownloader │ │ │ ├── AddDialog.vue │ │ │ ├── EditDialog.vue │ │ │ ├── Editor.vue │ │ │ ├── Index.vue │ │ │ └── PathAndTagSuggestDialog.vue │ │ │ ├── SetMediaServer │ │ │ ├── AddDialog.vue │ │ │ ├── EditDialog.vue │ │ │ ├── Editor.vue │ │ │ └── Index.vue │ │ │ ├── SetSearchSolution │ │ │ ├── EditDialog.vue │ │ │ ├── Index.vue │ │ │ ├── SiteCategoryPanel.vue │ │ │ └── SolutionLabel.vue │ │ │ └── SetSite │ │ │ ├── AddDialog.vue │ │ │ ├── EditDialog.vue │ │ │ ├── EditSearchEntryList.vue │ │ │ ├── Editor.vue │ │ │ ├── Index.vue │ │ │ ├── OneClickImportDialog.vue │ │ │ └── utils.ts │ ├── shared │ │ ├── types.ts │ │ └── types │ │ │ └── storages │ │ │ ├── config.ts │ │ │ ├── indexdb.ts │ │ │ ├── metadata.ts │ │ │ ├── other.ts │ │ │ └── runtime.ts │ └── storage.ts ├── extends │ ├── axios │ │ └── replaceUnsafeHeader.ts │ └── pinia │ │ └── webExtPersistence.ts ├── helper.ts ├── locales │ ├── en.json │ └── zh_CN.json ├── packages │ ├── backupServer │ │ ├── AbstractBackupServer.ts │ │ ├── entity │ │ │ ├── CookieCloud.ts │ │ │ ├── DropBox.ts │ │ │ ├── Gist.ts │ │ │ ├── GoogleDrive.ts │ │ │ ├── OWSS.ts │ │ │ └── WebDAV.ts │ │ ├── index.ts │ │ ├── type.ts │ │ └── utils.ts │ ├── downloader │ │ ├── entity │ │ │ ├── Aria2.ts │ │ │ ├── Deluge.ts │ │ │ ├── Flood.ts │ │ │ ├── Transmission.ts │ │ │ ├── qBittorrent.ts │ │ │ ├── ruTorrent.ts │ │ │ ├── synologyDownloadStation.ts │ │ │ └── uTorrent.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── mediaServer │ │ ├── entity │ │ │ ├── emby.ts │ │ │ └── jellyfin.ts │ │ ├── index.ts │ │ └── types.ts │ ├── site │ │ ├── definitions │ │ │ ├── agsvpt.ts │ │ │ ├── audiences.ts │ │ │ ├── btschool.ts │ │ │ ├── byrbt.ts │ │ │ ├── chdbits.ts │ │ │ ├── cyanbug.ts │ │ │ ├── discfan.ts │ │ │ ├── fsm.ts │ │ │ ├── hdchina.ts │ │ │ ├── hddolby.ts │ │ │ ├── hdfans.ts │ │ │ ├── hdhome.ts │ │ │ ├── hdkylin.ts │ │ │ ├── hdsky.ts │ │ │ ├── hdtime.ts │ │ │ ├── hdupt.ts │ │ │ ├── hudbt.ts │ │ │ ├── iptorrents.ts │ │ │ ├── joyhd.ts │ │ │ ├── jpopsuki.ts │ │ │ ├── kamept.ts │ │ │ ├── keepfrds.ts │ │ │ ├── morethantv.ts │ │ │ ├── mteam.ts │ │ │ ├── nanyangpt.ts │ │ │ ├── nicept.ts │ │ │ ├── opencd.ts │ │ │ ├── ourbits.ts │ │ │ ├── piggo.ts │ │ │ ├── pter.ts │ │ │ ├── pthome.ts │ │ │ ├── ptlgs.ts │ │ │ ├── ptsbao.ts │ │ │ ├── ptzone.ts │ │ │ ├── qingwa.ts │ │ │ ├── redleaves.ts │ │ │ ├── rousi.ts │ │ │ ├── sjtu.ts │ │ │ ├── skyeysnow.ts │ │ │ ├── soulvoice.ts │ │ │ ├── springsunday.ts │ │ │ ├── starspace.ts │ │ │ ├── tccf.ts │ │ │ ├── tjupt.ts │ │ │ ├── torrentleech.ts │ │ │ ├── totheglory.ts │ │ │ ├── u2.ts │ │ │ ├── ubits.ts │ │ │ ├── uhdbits.ts │ │ │ ├── xingtan.ts │ │ │ ├── yemapt.ts │ │ │ └── zhixing.ts │ │ ├── index.ts │ │ ├── schemas │ │ │ ├── AbstractBittorrentSite.ts │ │ │ ├── AbstractPrivateSite.ts │ │ │ ├── Gazelle.ts │ │ │ ├── GazelleJSONAPI.ts │ │ │ ├── NexusPHP.ts │ │ │ └── Unit3D.ts │ │ ├── types.ts │ │ ├── types │ │ │ ├── base.ts │ │ │ ├── search.ts │ │ │ ├── site.ts │ │ │ ├── torrent.ts │ │ │ └── userinfo.ts │ │ ├── utils.ts │ │ └── utils │ │ │ ├── datetime.ts │ │ │ ├── favicon.ts │ │ │ ├── filesize.ts │ │ │ ├── filter.ts │ │ │ ├── html.ts │ │ │ └── level.ts │ └── social │ │ ├── entity │ │ ├── anidb.ts │ │ ├── bangumi.ts │ │ ├── douban.ts │ │ └── imdb.ts │ │ ├── index.ts │ │ └── types.ts ├── shim.d.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── vite ├── plugin └── generateWebextLocales.ts └── sendToTgChannel.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-反馈.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug 反馈 3 | about: 发起一个Bug反馈 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 13 | 14 | - PT 助手版本: 15 | - 问题描述: 16 | 17 | 18 | - 相关截图: 19 | 20 | 21 | - 重现步骤: 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/pt-plugin-plus-功能迁移.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: PT Plugin Plus 功能迁移 3 | about: 对原PTPP中已有功能,但PTD中尚未实现的提出需求 4 | title: "[PTPP]" 5 | labels: enhancement 6 | assignees: Rhilip 7 | 8 | --- 9 | 10 | **需要实现迁移的功能及原因?** 11 | 12 | 13 | 14 | **这一功能在PTPP中是怎么展现的?(提供截图)** 15 | 16 | 17 | **你对这一功能在PTD中实现有什么更好的建议?** 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/功能请求.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 功能请求 3 | about: 发起一个新功能请求 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 您的功能请求是否与问题有关? 请描述一下。 11 | 12 | 13 | 14 | ## 描述你想要的解决方案 15 | 16 | 17 | 18 | ## 描述您考虑过的替代方案 19 | 20 | 21 | 22 | ## 其他附加信息 23 | 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/站点适配.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 站点适配 3 | about: 发起一个新站点支持请求 4 | title: '' 5 | labels: adapter/site 6 | assignees: '' 7 | 8 | --- 9 | 10 | 18 | 19 | - 站点名称: 20 | - 站点地址: 21 | - 站点描述: 22 | - 资源类型: 23 | - 开放注册:是/否 24 | - 站点规则: 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/action_build.yml: -------------------------------------------------------------------------------- 1 | # 这是一个临时性用来构建测试包的action 2 | 3 | name: Build Action Release 4 | 5 | on: 6 | push: 7 | branches: ["master"] 8 | pull_request: 9 | branches: ["master"] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: ${{ github.event_name == 'push' && '0' || '1' }} # 根据事件类型动态设置 fetch-depth 19 | 20 | - uses: pnpm/action-setup@v4 21 | 22 | - name: Setup Node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ">=23.7.0" 26 | cache: "pnpm" 27 | 28 | - name: Install dependencies 29 | run: pnpm install 30 | 31 | - name: vue-tsc check 32 | run: pnpm check 33 | continue-on-error: true 34 | 35 | - name: Build Output 36 | run: | 37 | pnpm build:dist 38 | pnpm build:dist-firefox 39 | 40 | - name: Upload Chrome Built to action 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: build-dist-chrome 44 | path: dist-chrome 45 | 46 | - name: Upload Firefox Built to action 47 | uses: actions/upload-artifact@v4 48 | with: 49 | name: build-dist-firefox 50 | path: dist-firefox 51 | 52 | - run: mkdir -p build-zip 53 | 54 | - id: zip_chrome 55 | name: Build Zip For Chrome 56 | uses: cardinalby/webext-buildtools-pack-extension-dir-action@v1 57 | with: 58 | extensionDir: "dist-chrome" 59 | zipFilePath: "build/extension-chrome.zip" 60 | 61 | - id: zip_firefox 62 | name: Build Zip For Firefox 63 | uses: cardinalby/webext-buildtools-pack-extension-dir-action@v1 64 | with: 65 | extensionDir: "dist-firefox" 66 | zipFilePath: "build/extension-firefox.zip" 67 | 68 | - name: Upload Built Zip to action 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: build-zip-${{ steps.zip_chrome.outputs.extensionVersion }} 72 | path: build/*.zip 73 | 74 | - name: Build Self-Sign Crx For Chrome 75 | uses: cardinalby/webext-buildtools-chrome-crx-action@v2 76 | env: 77 | CHROME_SELF_SIGN_CRX_PRIVATE_KEY: ${{ secrets.CHROME_SELF_SIGN_CRX_PRIVATE_KEY }} 78 | if: ${{ env.CHROME_SELF_SIGN_CRX_PRIVATE_KEY != '' }} 79 | with: 80 | zipFilePath: "build/extension-chrome.zip" 81 | crxFilePath: "build/extension.crx" 82 | privateKey: "${{ secrets.CHROME_SELF_SIGN_CRX_PRIVATE_KEY }}" 83 | 84 | - name: Upload Built Crx to action 85 | uses: actions/upload-artifact@v4 86 | with: 87 | path: build/extension.crx 88 | name: build-crx-${{ steps.zip_chrome.outputs.extensionVersion }} 89 | 90 | - name: Upload build folder to Telegram Channel 91 | env: 92 | BUILD_VERSION: ${{ steps.zip_chrome.outputs.extensionVersion }} 93 | TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} 94 | TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} 95 | if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} 96 | run: pnpm action:upload_telegram 97 | continue-on-error: true 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | ## Build files 4 | dist 5 | dist-* 6 | *.local 7 | public/_locales 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | lerna-debug.log* 17 | 18 | # Editor directories and files 19 | .vscode/* 20 | !.vscode/extensions.json 21 | .idea 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | 28 | # local env files 29 | .env.local 30 | .env.*.local 31 | 32 | # Vue Browser Extension Output 33 | *.pem 34 | *.pub 35 | *.zip 36 | 37 | ### Windows template 38 | # Windows thumbnail cache files 39 | Thumbs.db 40 | Thumbs.db:encryptable 41 | ehthumbs.db 42 | ehthumbs_vista.db 43 | 44 | # Dump file 45 | *.stackdump 46 | 47 | # Folder config file 48 | [Dd]esktop.ini 49 | 50 | # Recycle Bin used on file shares 51 | $RECYCLE.BIN/ 52 | 53 | # Windows Installer files 54 | *.cab 55 | *.msi 56 | *.msix 57 | *.msm 58 | *.msp 59 | 60 | # Windows shortcuts 61 | *.lnk 62 | 63 | ### macOS template 64 | # General 65 | .DS_Store 66 | .AppleDouble 67 | .LSOverride 68 | 69 | # Icon must end with two \r 70 | Icon 71 | 72 | # Thumbnails 73 | ._* 74 | 75 | # Files that might appear in the root of a volume 76 | .DocumentRevisions-V100 77 | .fseventsd 78 | .Spotlight-V100 79 | .TemporaryItems 80 | .Trashes 81 | .VolumeIcon.icns 82 | .com.apple.timemachine.donotpresent 83 | 84 | # Directories potentially created on remote AFP share 85 | .AppleDB 86 | .AppleDesktop 87 | Network Trash Folder 88 | Temporary Items 89 | .apdisk 90 | 91 | # Diagnostic reports (https://nodejs.org/api/report.html) 92 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 93 | 94 | # Runtime data 95 | pids 96 | *.pid 97 | *.seed 98 | *.pid.lock 99 | 100 | # Directory for instrumented libs generated by jscoverage/JSCover 101 | lib-cov 102 | 103 | # Coverage directory used by tools like istanbul 104 | coverage 105 | *.lcov 106 | 107 | # nyc test coverage 108 | .nyc_output 109 | 110 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 111 | .grunt 112 | 113 | # Bower dependency directory (https://bower.io/) 114 | bower_components 115 | 116 | # node-waf configuration 117 | .lock-wscript 118 | 119 | # Compiled binary addons (https://nodejs.org/api/addons.html) 120 | build/Release 121 | 122 | # Dependency directories 123 | node_modules/ 124 | jspm_packages/ 125 | 126 | # Snowpack dependency directory (https://snowpack.dev/) 127 | web_modules/ 128 | 129 | # TypeScript cache 130 | *.tsbuildinfo 131 | 132 | # Optional npm cache directory 133 | .npm 134 | .npmrc 135 | 136 | # Optional eslint cache 137 | .eslintcache 138 | 139 | # Microbundle cache 140 | .rpt2_cache/ 141 | .rts2_cache_cjs/ 142 | .rts2_cache_es/ 143 | .rts2_cache_umd/ 144 | 145 | # Optional REPL history 146 | .node_repl_history 147 | 148 | # Output of 'npm pack' 149 | *.tgz 150 | 151 | # Yarn Integrity file 152 | .yarn-integrity 153 | 154 | # dotenv environment variables file 155 | .env 156 | .env.test 157 | 158 | # parcel-bundler cache (https://parceljs.org/) 159 | .cache 160 | .parcel-cache 161 | 162 | # Next.js build output 163 | .next 164 | out 165 | 166 | # Nuxt.js build / generate output 167 | .nuxt 168 | 169 | # Gatsby files 170 | .cache/ 171 | # Comment in the public line in if your project uses Gatsby and not Next.js 172 | # https://nextjs.org/blog/next-9-1#public-directory-support 173 | # public 174 | 175 | # vuepress build output 176 | .vuepress/dist 177 | 178 | # Serverless directories 179 | .serverless/ 180 | 181 | # FuseBox cache 182 | .fusebox/ 183 | 184 | # DynamoDB Local files 185 | .dynamodb/ 186 | 187 | # TernJS port file 188 | .tern-port 189 | 190 | # Stores VSCode versions used for testing VSCode extensions 191 | .vscode-test 192 | 193 | # yarn v2 194 | .yarn/cache 195 | .yarn/unplugged 196 | .yarn/build-state.yml 197 | .yarn/install-state.gz 198 | .pnp.* 199 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm exec lint-staged 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 pt-plugins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /privacy-statement.md: -------------------------------------------------------------------------------- 1 | 感谢使用PT助手(下称『助手』),为了让您能够安心的使用助手,特此向您说明助手的隐私权保护政策,以保障您的权益,请您详阅下列内容: 2 | 3 | ## 一、隐私权保护政策的适用范围 4 | - 隐私权保护政策仅适用于助手,不适用于助手以外的相关网站; 5 | 6 | ## 二、个人信息的收集、处理及利用方式 7 | - 在您使用助手的过程中,助手会使用您的个人信息进行展示,展示内容包括: 8 | - 您在已配置站点里的个人信息; 9 | - 已保存的个人信息历史记录生成图表; 10 | - 个人信息仅用于在助手里展示,不会用于其他用途; 11 | 12 | ## 三、信息存储和交换 13 | - 在您使用助手的过程中,会产生一些配置数据和历史记录,这些信息全部存储于当前浏览器; 14 | - 当您已配置了备份服务器,这些信息会由您决定是否上传至这些服务器; 15 | - 除此之外,助手不会将这些数据上传至任何第三方服务器; 16 | - 如果您选择了使用备份服务器,我们强烈建议您使用以下方式进行配置: 17 | - 使用 `https` 的方式配置服务器地址; 18 | - 启用备份数据加密(采用 [AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard))功能后再进行备份操作; 19 | 20 | ## 四、与第三人共用个人信息之政策 21 | - 助手不会收集任何相关的个人信息,所以助手绝不会提供、交换、出租或出售任何您的个人信息给其他个人、团体、私人企业或公务机关; 22 | 23 | ## 五、Cookie之使用 24 | - 当您进行搜索操作时,助手会访问您已配置的站点,Cookie 由浏览器提供,助手无权访问也不会对内容进行探测和修改,一切内容由浏览器和站点进行相互验证; 25 | - 当您授权助手访问 Cookie 信息时,这些信息仅用于备份和恢复操作,助手不会在除此之外的任何其他操作中使用它们; 26 | 27 | ## 六、隐私权保护政策之修正 28 | - 助手隐私权保护政策将按用户需求变更而随时进行修正,修正后的条款将在本页面显示。 29 | -------------------------------------------------------------------------------- /public/icons/backupServer/CookieCloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/backupServer/CookieCloud.png -------------------------------------------------------------------------------- /public/icons/backupServer/DropBox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/backupServer/DropBox.png -------------------------------------------------------------------------------- /public/icons/backupServer/Gist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/backupServer/Gist.png -------------------------------------------------------------------------------- /public/icons/backupServer/GoogleDrive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/backupServer/GoogleDrive.png -------------------------------------------------------------------------------- /public/icons/backupServer/OWSS.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/backupServer/OWSS.png -------------------------------------------------------------------------------- /public/icons/backupServer/WebDAV.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/backupServer/WebDAV.png -------------------------------------------------------------------------------- /public/icons/downloader/Aria2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/Aria2.png -------------------------------------------------------------------------------- /public/icons/downloader/Deluge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/Deluge.png -------------------------------------------------------------------------------- /public/icons/downloader/Flood.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/Flood.png -------------------------------------------------------------------------------- /public/icons/downloader/Transmission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/Transmission.png -------------------------------------------------------------------------------- /public/icons/downloader/qBittorrent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/qBittorrent.png -------------------------------------------------------------------------------- /public/icons/downloader/ruTorrent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/ruTorrent.png -------------------------------------------------------------------------------- /public/icons/downloader/synologyDownloadStation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/synologyDownloadStation.png -------------------------------------------------------------------------------- /public/icons/downloader/uTorrent.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/downloader/uTorrent.png -------------------------------------------------------------------------------- /public/icons/logo/128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/logo/128.png -------------------------------------------------------------------------------- /public/icons/logo/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/logo/16.png -------------------------------------------------------------------------------- /public/icons/logo/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/logo/19.png -------------------------------------------------------------------------------- /public/icons/logo/64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/logo/64.png -------------------------------------------------------------------------------- /public/icons/mediaServer/emby.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/mediaServer/emby.png -------------------------------------------------------------------------------- /public/icons/mediaServer/jellyfin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/mediaServer/jellyfin.png -------------------------------------------------------------------------------- /public/icons/movie_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/movie_placeholder.png -------------------------------------------------------------------------------- /public/icons/site/audiences.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/audiences.ico -------------------------------------------------------------------------------- /public/icons/site/btschool.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/btschool.ico -------------------------------------------------------------------------------- /public/icons/site/chdbits.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/chdbits.ico -------------------------------------------------------------------------------- /public/icons/site/discfan.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/discfan.ico -------------------------------------------------------------------------------- /public/icons/site/fsm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/fsm.jpg -------------------------------------------------------------------------------- /public/icons/site/hdchina.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/hdchina.jpg -------------------------------------------------------------------------------- /public/icons/site/kamept.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/kamept.ico -------------------------------------------------------------------------------- /public/icons/site/morethantv.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/morethantv.ico -------------------------------------------------------------------------------- /public/icons/site/mteam.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/mteam.ico -------------------------------------------------------------------------------- /public/icons/site/opencd.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/opencd.ico -------------------------------------------------------------------------------- /public/icons/site/ourbits.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/ourbits.ico -------------------------------------------------------------------------------- /public/icons/site/pthome.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/pthome.ico -------------------------------------------------------------------------------- /public/icons/site/ptzone.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/ptzone.ico -------------------------------------------------------------------------------- /public/icons/site/rousi.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/rousi.ico -------------------------------------------------------------------------------- /public/icons/site/soulvoice.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/soulvoice.ico -------------------------------------------------------------------------------- /public/icons/site/torrentleech.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/torrentleech.ico -------------------------------------------------------------------------------- /public/icons/site/ubits.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/ubits.ico -------------------------------------------------------------------------------- /public/icons/site/yemapt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/yemapt.png -------------------------------------------------------------------------------- /public/icons/site/zhixing.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/site/zhixing.ico -------------------------------------------------------------------------------- /public/icons/social/anidb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/social/anidb.png -------------------------------------------------------------------------------- /public/icons/social/bangumi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/social/bangumi.png -------------------------------------------------------------------------------- /public/icons/social/douban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/social/douban.png -------------------------------------------------------------------------------- /public/icons/social/imdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/social/imdb.png -------------------------------------------------------------------------------- /public/icons/social/tmdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/social/tmdb.png -------------------------------------------------------------------------------- /public/icons/social/tvdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pt-plugins/PT-depiler/c8499c375ebeec1c5a8bde8e511d7fe86b237683/public/icons/social/tvdb.png -------------------------------------------------------------------------------- /src/entries/background/ff_main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Firefox 的 Manifest 仍然支持 background script, 这意味着我们不需要像 Chrome 一样拆分为 server worker + offscreen 3 | * 这是一个中转的脚本,将 Chrome 中分别注册的方法,统一注册到 Firefox 的 background script 中 4 | * 5 | * 在绝大多数情况下,你不需要编辑这个文件 6 | */ 7 | import "./main.ts"; 8 | import "../offscreen/offscreen.ts"; 9 | -------------------------------------------------------------------------------- /src/entries/background/main.ts: -------------------------------------------------------------------------------- 1 | import { onMessage } from "@/messages.ts"; 2 | import { extStorage } from "@/storage.ts"; 3 | 4 | import "./utils/cookies.ts"; 5 | import "./utils/offscreen.ts"; 6 | import "./utils/contextMenus.ts"; 7 | import "./utils/omnibox.ts"; 8 | import "./utils/alarms.ts"; 9 | import "./utils/webRequest.ts"; 10 | 11 | // 监听 点击图标 事件 12 | chrome.action.onClicked.addListener(async () => { 13 | await chrome.runtime.openOptionsPage(); 14 | }); 15 | 16 | chrome.runtime.onInstalled.addListener(() => { 17 | console.log("Installed!"); 18 | }); 19 | 20 | onMessage("ping", async ({ data }) => { 21 | console.log("ping", data); 22 | return data ?? "pong"; 23 | }); 24 | 25 | onMessage("downloadFile", async ({ data: downloadOptions }) => { 26 | return await chrome.downloads.download(downloadOptions); 27 | }); 28 | 29 | // @ts-ignore 30 | onMessage("getExtStorage", async ({ data: key }) => { 31 | return await extStorage.getItem(key); 32 | }); 33 | 34 | onMessage("setExtStorage", async ({ data: { key, value } }) => { 35 | await extStorage.setItem(key, value); 36 | }); 37 | -------------------------------------------------------------------------------- /src/entries/background/utils/contextMenus.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { onMessage } from "@/messages.ts"; 3 | import { stringify } from "urlencode"; 4 | import { extStorage } from "@/storage.ts"; 5 | 6 | const contextMenusId = "PT-Depiler-Context-Menus"; 7 | 8 | const contextMenusClickEventBus = new Map< 9 | string | number, 10 | (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => void 11 | >(); 12 | 13 | chrome.contextMenus.onClicked.addListener((info, tab) => { 14 | if (!info.menuItemId || !contextMenusClickEventBus.has(info.menuItemId)) { 15 | return; 16 | } 17 | const clickHandler = contextMenusClickEventBus.get(info.menuItemId); 18 | if (clickHandler) { 19 | clickHandler(info, tab!); 20 | } 21 | }); 22 | 23 | function addContextMenu(data: chrome.contextMenus.CreateProperties) { 24 | if (!data.id) { 25 | data.id = nanoid(); 26 | } 27 | 28 | if (data.onclick) { 29 | // 如果有 onclick 事件,则将其存入事件总线 30 | contextMenusClickEventBus.set(data.id, data.onclick); 31 | delete data.onclick; // 删除 onclick 属性,因为 chrome.contextMenus.create 不支持直接传入 onclick 32 | } 33 | 34 | chrome.contextMenus.create(data); 35 | return data.id; 36 | } 37 | 38 | onMessage("addContextMenu", async ({ data }) => addContextMenu(data)); 39 | 40 | function removeContextMenu(id: string) { 41 | chrome.contextMenus.remove(id).catch(); 42 | contextMenusClickEventBus.delete(id); 43 | } 44 | 45 | onMessage("removeContextMenu", async ({ data }) => removeContextMenu(data)); 46 | 47 | function clearContextMenus() { 48 | chrome.contextMenus.removeAll().catch(); 49 | contextMenusClickEventBus.clear(); 50 | } 51 | 52 | onMessage("clearContextMenus", async () => clearContextMenus()); 53 | 54 | async function initContextMenus() { 55 | const configStore = (await extStorage.getItem("config"))!; 56 | 57 | // 清除原来的菜单 58 | clearContextMenus(); 59 | 60 | // 创建关键字搜索菜单,所有页面可用 61 | if (configStore.contextMenus?.allowSelectionTextSearch ?? true) { 62 | addContextMenu({ 63 | id: contextMenusId, 64 | title: '搜索 "%s" 相关的种子', 65 | contexts: ["selection"], 66 | onclick: (info: chrome.contextMenus.OnClickData, tab: chrome.tabs.Tab) => { 67 | chrome.tabs.create({ 68 | url: 69 | "/src/entries/options/index.html#/search-entity?" + 70 | stringify({ 71 | search: info.selectionText, 72 | flush: 1, 73 | }), 74 | }); 75 | }, 76 | }); 77 | } 78 | } 79 | 80 | chrome.tabs.onActivated.addListener((actionInfo: chrome.tabs.TabActiveInfo) => { 81 | chrome.tabs.get(actionInfo.tabId, (tab: chrome.tabs.Tab) => { 82 | initContextMenus().then(); 83 | }); 84 | chrome.tabs.onUpdated.addListener(() => { 85 | initContextMenus().then(); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/entries/background/utils/cookies.ts: -------------------------------------------------------------------------------- 1 | import { onMessage, sendMessage } from "@/messages.ts"; 2 | 3 | export async function getCookiesByDomain(domain: string): Promise { 4 | return await chrome.cookies.getAll({ domain }); 5 | } 6 | 7 | onMessage("getCookiesByDomain", async ({ data }) => { 8 | return await getCookiesByDomain(data); 9 | }); 10 | 11 | function buildUrl(secure: boolean, domain: string, path: string) { 12 | if (domain.startsWith(".")) { 13 | domain = domain.substring(1); 14 | } 15 | return `http${secure ? "s" : ""}://${domain}${path}`; 16 | } 17 | 18 | export async function setCookie(cookie: chrome.cookies.SetDetails): Promise { 19 | let new_cookie = {} as chrome.cookies.SetDetails; 20 | 21 | ( 22 | [ 23 | "name", 24 | "value", 25 | "domain", 26 | "path", 27 | "secure", 28 | "httpOnly", 29 | "sameSite", 30 | "expirationDate", 31 | ] as (keyof chrome.cookies.SetDetails)[] 32 | ).forEach((key) => { 33 | if (key == "sameSite" && cookie[key] && cookie[key].toLowerCase() == "unspecified" && __BROWSER__ === "firefox") { 34 | // firefox 下 unspecified 会导致cookie无法设置 35 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/cookies/SameSiteStatus 36 | new_cookie["sameSite"] = "no_restriction"; 37 | } else { 38 | // @ts-ignore 39 | new_cookie[key] = cookie[key]; 40 | } 41 | }); 42 | 43 | new_cookie.url = buildUrl(cookie.secure!, cookie.domain!, cookie.path!); 44 | 45 | let allowSet = false; 46 | const now = new Date().getTime() / 1000; 47 | 48 | // 尝试获取当前站点已存在的Cookie 49 | const exist_cookie = await chrome.cookies.get({ url: new_cookie.url, name: new_cookie.name! }); 50 | if (exist_cookie === null) { 51 | // 如果当前站点没有这个Cookies,则允许设置 52 | allowSet = true; 53 | } else if ((exist_cookie.expirationDate ?? 0) < now) { 54 | // 如果站点存在这个Cookies,但已过期,允许设置 55 | allowSet = true; 56 | } 57 | 58 | if (allowSet) { 59 | await chrome.cookies.set(new_cookie); 60 | sendMessage("logger", { msg: `Set a new cookie ${new_cookie.name} to ${new_cookie.url}` }).catch(); 61 | } 62 | } 63 | 64 | onMessage("setCookie", async ({ data }) => { 65 | return await setCookie(data); 66 | }); 67 | -------------------------------------------------------------------------------- /src/entries/background/utils/offscreen.ts: -------------------------------------------------------------------------------- 1 | let creating: Promise | null; // A global promise to avoid concurrency issues 2 | 3 | const offscreenPath = "src/entries/offscreen/offscreen.html"; 4 | 5 | export async function setupOffscreenDocument() { 6 | // Firefox 环境下不构建 offscreen 7 | if (__BROWSER__ == "firefox") { 8 | return; 9 | } 10 | 11 | // Check all windows controlled by the service worker to see if one 12 | // of them is the offscreen document with the given path 13 | const offscreenUrl = chrome.runtime.getURL(offscreenPath); 14 | const existingContexts = await chrome.runtime.getContexts({ 15 | contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], 16 | documentUrls: [offscreenUrl], 17 | }); 18 | 19 | if (existingContexts.length > 0) { 20 | return; 21 | } 22 | 23 | // create offscreen document for DOM_PARSER and other reason ( f**k google ) 24 | if (creating) { 25 | await creating; 26 | } else { 27 | creating = chrome.offscreen.createDocument({ 28 | url: offscreenPath, 29 | reasons: [chrome.offscreen.Reason.DOM_PARSER], 30 | justification: "Allow DOM_PARSER, CLIPBOARD, BLOBS in background.", 31 | }); 32 | await creating; 33 | creating = null; 34 | } 35 | } 36 | 37 | // noinspection JSIgnoredPromiseFromCall 38 | setupOffscreenDocument(); 39 | -------------------------------------------------------------------------------- /src/entries/background/utils/omnibox.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from "urlencode"; 2 | 3 | import { extStorage } from "@/storage.ts"; 4 | import type { IMetadataPiniaStorageSchema } from "@/shared/types.ts"; 5 | 6 | const splitString = " → "; 7 | 8 | interface ISearchSolution { 9 | name: string; 10 | value: string; 11 | } 12 | 13 | const allSolution = { 14 | name: chrome.i18n.getMessage("searchPlanAll"), 15 | value: "all", 16 | }; 17 | 18 | async function getSearchSolution(getAll = false) { 19 | const { defaultSolutionId = "default", solutions = {} } = ((await extStorage.getItem("metadata")) ?? 20 | {}) as IMetadataPiniaStorageSchema; 21 | 22 | let solutionsList: ISearchSolution[] = Object.values(solutions) 23 | .filter((x) => !!x.enabled) // 过滤掉未启用的搜索方案 24 | .sort((a, b) => b.sort - a.sort) // 按照 sort 降序排序 25 | .map((x) => ({ 26 | name: x.name, 27 | value: x.id, 28 | })); 29 | 30 | if (getAll || defaultSolutionId !== "default") { 31 | solutionsList = [allSolution, ...solutionsList]; 32 | } 33 | 34 | if (!getAll) { 35 | solutionsList = solutionsList.slice(0, 5); // 只显示前 5 个搜索方案 36 | } 37 | 38 | return solutionsList; 39 | } 40 | 41 | chrome.omnibox.onInputChanged.addListener(async (text, suggest) => { 42 | if (!text) return; 43 | 44 | const solutions = await getSearchSolution(); 45 | let result: chrome.omnibox.SuggestResult[] = solutions.map((x) => ({ 46 | content: `${x.name}${splitString}${text}`, 47 | description: chrome.i18n.getMessage("omniboxSearch", [x.name, text]), 48 | })); 49 | 50 | suggest(result); 51 | }); 52 | 53 | // 当用户接收关键字建议时触发 54 | chrome.omnibox.onInputEntered.addListener(async (text) => { 55 | let solutionName = ""; 56 | let solutionId = "default"; 57 | let key = ""; 58 | 59 | if (text.indexOf(splitString) != -1) { 60 | const solutions = await getSearchSolution(true); 61 | 62 | [solutionName, key] = text.split(splitString); 63 | 64 | let solution = solutions.find((item: ISearchSolution) => { 65 | return item.name == solutionName; 66 | }); 67 | if (solution) { 68 | solutionId = solution.value; 69 | } 70 | } else { 71 | key = text; 72 | } 73 | 74 | // 按关键字进行搜索 75 | // noinspection ES6MissingAwait 76 | chrome.tabs.create({ 77 | url: 78 | "/src/entries/options/index.html#/search-entity?" + 79 | stringify({ 80 | search: key, 81 | plan: solutionId, 82 | flush: 1, 83 | }), 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/entries/background/utils/webRequest.ts: -------------------------------------------------------------------------------- 1 | import { onMessage, sendMessage } from "@/messages.ts"; 2 | 3 | onMessage("updateDNRSessionRules", async ({ data: { rule, extOnly = true } }) => { 4 | // 不影响其他非本扩展的网络请求规则 5 | if (extOnly) { 6 | const tabs = await chrome.tabs.query({}); 7 | const excludedTabIds: number[] = []; 8 | tabs.forEach((tab) => { 9 | if (tab.id && tab.url) { 10 | excludedTabIds.push(tab.id); 11 | } 12 | }); 13 | rule.condition.excludedTabIds ??= excludedTabIds; 14 | } 15 | 16 | sendMessage("logger", { 17 | msg: `Update DNR session rules ${rule.id} for url: ${rule.condition?.urlFilter}`, 18 | data: rule, 19 | }).catch(); 20 | 21 | return await chrome.declarativeNetRequest.updateSessionRules({ 22 | removeRuleIds: [rule.id], 23 | addRules: [rule], 24 | }); 25 | }); 26 | 27 | onMessage("removeDNRSessionRuleById", async ({ data: ruleId }) => { 28 | sendMessage("logger", { msg: `Remove DNR session rule by ID: ${ruleId}` }).catch(); 29 | return await chrome.declarativeNetRequest.updateSessionRules({ 30 | removeRuleIds: [ruleId], 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/entries/offscreen/adapter/indexdb.ts: -------------------------------------------------------------------------------- 1 | import { openDB, type IDBPDatabase } from "idb"; 2 | import { IPtdDBSchemaV1, IPtdDBSchema, IPtdDBSchemaV2 } from "@/shared/types.ts"; 3 | 4 | export const ptdIndexDb = openDB("ptd", 3, { 5 | upgrade(db, oldVersion) { 6 | if (oldVersion < 1) { 7 | const dbV1 = db as unknown as IDBPDatabase; 8 | dbV1.createObjectStore("social_information"); 9 | } 10 | if (oldVersion < 2) { 11 | const dbV2 = db as unknown as IDBPDatabase; 12 | dbV2.createObjectStore("download_history", { keyPath: "id", autoIncrement: true }); 13 | } 14 | if (oldVersion < 3) { 15 | db.createObjectStore("favicon"); 16 | } 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/entries/offscreen/offscreen.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/entries/offscreen/offscreen.ts: -------------------------------------------------------------------------------- 1 | import "./adapter/indexdb.ts"; 2 | 3 | import "./utils/logger.ts"; 4 | import "./utils/site.ts"; 5 | import "./utils/search.ts"; 6 | import "./utils/download.ts"; 7 | import "./utils/userInfo.ts"; 8 | import "./utils/backup.ts"; 9 | import "./utils/socialInformation.ts"; 10 | -------------------------------------------------------------------------------- /src/entries/offscreen/utils/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 关于 logger 方法记录 3 | * 在 background 等其他页面中, 请使用 sendMessage("logger", {}).catch(); 4 | * 在 offscreen 中, 请使用 logger({}) 直接调用 5 | */ 6 | import { nanoid } from "nanoid"; 7 | import { useSessionStorage } from "@vueuse/core"; 8 | 9 | import { onMessage } from "@/messages.ts"; 10 | import type { ILoggerItem } from "@/shared/types.ts"; 11 | 12 | const MAX_LOGGER_LENGTH = 500; 13 | export const loggerStorage = useSessionStorage("logger", []); 14 | 15 | export function logger(data: ILoggerItem) { 16 | data.id ??= nanoid(); 17 | data.time ??= new Date().getTime(); 18 | data.msg = data.msg?.trim(); 19 | 20 | loggerStorage.value.push(data); 21 | if (loggerStorage.value.length > MAX_LOGGER_LENGTH) { 22 | loggerStorage.value.shift(); 23 | } 24 | } 25 | 26 | onMessage("logger", ({ data }) => logger(data)); 27 | onMessage("getLogger", async () => loggerStorage.value); 28 | onMessage("clearLogger", async () => { 29 | loggerStorage.value = []; 30 | }); 31 | -------------------------------------------------------------------------------- /src/entries/offscreen/utils/search.ts: -------------------------------------------------------------------------------- 1 | import { getMediaServer } from "@ptd/mediaServer"; 2 | 3 | import { onMessage, sendMessage } from "@/messages.ts"; 4 | import type { IMetadataPiniaStorageSchema, TSearchResultSnapshotStorageSchema } from "@/shared/types.ts"; 5 | 6 | import { logger } from "./logger.ts"; 7 | import { getSiteInstance } from "./site.ts"; 8 | 9 | onMessage("getSiteSearchResult", async ({ data: { siteId, keyword = "", searchEntry = {} } }) => { 10 | logger({ 11 | msg: `getSiteSearchResult For site: ${siteId} with keyword: ${keyword}`, 12 | data: { siteId, keyword, searchEntry }, 13 | }); 14 | const site = await getSiteInstance<"public">(siteId); 15 | return await site.getSearchResult(keyword, searchEntry); 16 | }); 17 | 18 | onMessage("getMediaServerSearchResult", async ({ data: { mediaServerId, keywords = "", options = {} } }) => { 19 | logger({ 20 | msg: `getMediaServerSearchResult For mediaServer: ${mediaServerId} with: ${keywords}`, 21 | data: { mediaServerId, keywords, options }, 22 | }); 23 | const metadataStore = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema; 24 | const mediaServerConfig = metadataStore.mediaServers[mediaServerId]; 25 | const mediaServer = await getMediaServer(mediaServerConfig); 26 | return await mediaServer.getSearchResult(keywords ?? "", options); 27 | }); 28 | 29 | async function getSnapshotData() { 30 | return ((await sendMessage("getExtStorage", "searchResultSnapshot")) ?? {}) as TSearchResultSnapshotStorageSchema; 31 | } 32 | 33 | onMessage("getSearchResultSnapshotData", async ({ data: snapshotId }) => { 34 | const snapshotData = await getSnapshotData(); 35 | return snapshotData?.[snapshotId]; 36 | }); 37 | 38 | onMessage("saveSearchResultSnapshotData", async ({ data: { snapshotId, data } }) => { 39 | const snapshotData = await getSnapshotData(); 40 | snapshotData[snapshotId] = data; 41 | logger({ msg: `A new SearchResult Snapshot will be add at: ${snapshotId}`, data }); 42 | await sendMessage("setExtStorage", { key: "searchResultSnapshot", value: snapshotData }); 43 | }); 44 | 45 | onMessage("removeSearchResultSnapshotData", async ({ data: snapshotId }) => { 46 | const snapshotData = await getSnapshotData(); 47 | delete snapshotData[snapshotId]; 48 | await sendMessage("setExtStorage", { key: "searchResultSnapshot", value: snapshotData }); 49 | logger({ msg: `SearchResult Snapshot ${snapshotId} is removed.` }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/entries/offscreen/utils/site.ts: -------------------------------------------------------------------------------- 1 | import { uniq } from "es-toolkit"; 2 | import { isEmpty } from "es-toolkit/compat"; 3 | import { 4 | getDefinedSiteMetadata, 5 | getFavicon, 6 | getFaviconMetadata, 7 | getSite as createSiteInstance, 8 | ISiteUserConfig, 9 | NO_IMAGE, 10 | type TSiteID, 11 | } from "@ptd/site"; 12 | 13 | import { onMessage, sendMessage } from "@/messages.ts"; 14 | import type { IMetadataPiniaStorageSchema } from "@/shared/types.ts"; 15 | 16 | import { logger } from "./logger.ts"; 17 | import { ptdIndexDb } from "../adapter/indexdb.ts"; 18 | 19 | export async function getSiteUserConfig(siteId: TSiteID, flush = false) { 20 | const metadataStore = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema; 21 | const storedSiteUserConfig = metadataStore?.sites?.[siteId] ?? {}; 22 | 23 | const siteMetaData = await getDefinedSiteMetadata(siteId); 24 | 25 | if (flush || isEmpty(storedSiteUserConfig)) { 26 | const isDeadSite = siteMetaData.isDead ?? false; 27 | storedSiteUserConfig.isOffline ??= isDeadSite; 28 | storedSiteUserConfig.sortIndex ??= 100; 29 | storedSiteUserConfig.allowSearch ??= !isDeadSite && Object.hasOwn(siteMetaData, "search"); 30 | storedSiteUserConfig.allowQueryUserInfo ??= !isDeadSite && Object.hasOwn(siteMetaData, "userInfo"); 31 | storedSiteUserConfig.timeout ??= 30e3; 32 | storedSiteUserConfig.inputSetting ??= {}; 33 | storedSiteUserConfig.groups ??= siteMetaData.tags ?? []; 34 | storedSiteUserConfig.downloadInterval ??= siteMetaData?.download?.interval ?? 0; 35 | storedSiteUserConfig.merge ??= {}; 36 | } 37 | 38 | logger({ msg: `getSiteUserConfig for ${siteId}`, data: storedSiteUserConfig }); 39 | return storedSiteUserConfig; 40 | } 41 | 42 | onMessage("getSiteUserConfig", async ({ data: { siteId, flush } }) => await getSiteUserConfig(siteId, flush)); 43 | 44 | export async function getSiteInstance( 45 | siteId: TSiteID, 46 | options: { mergeUserConfig?: boolean } = {}, 47 | ) { 48 | const { mergeUserConfig = true } = options; 49 | let storedSiteUserConfig: ISiteUserConfig = {}; 50 | if (mergeUserConfig) { 51 | storedSiteUserConfig = await getSiteUserConfig(siteId); 52 | } 53 | 54 | logger({ msg: `getSiteInstance for ${siteId}`, data: storedSiteUserConfig }); 55 | return await createSiteInstance(siteId, storedSiteUserConfig); 56 | } 57 | 58 | export async function getSiteFavicon(site: TSiteID | getFaviconMetadata, flush: boolean = false): Promise { 59 | const siteId = typeof site === "string" ? site : site.id; 60 | let siteFavicon = (await (await ptdIndexDb).get("favicon", siteId)) ?? false; 61 | if (flush || !siteFavicon) { 62 | const siteInstance = await getSiteInstance(siteId); 63 | if (siteInstance) { 64 | siteFavicon = await getFavicon({ 65 | id: siteId, 66 | urls: uniq([siteInstance.url, ...siteInstance.metadata.urls].filter(Boolean)), 67 | favicon: siteInstance.metadata.favicon, 68 | }); 69 | 70 | await (await ptdIndexDb).put("favicon", siteFavicon, siteId); 71 | } 72 | } 73 | 74 | if (!siteFavicon) { 75 | siteFavicon = NO_IMAGE; 76 | logger({ msg: `getSiteFavicon for ${siteId} failed, use default NO_IMAGE.`, level: "warn" }); 77 | } 78 | 79 | return siteFavicon; 80 | } 81 | 82 | onMessage("getSiteFavicon", async ({ data: { site, flush } }) => (await getSiteFavicon(site, flush))!); 83 | 84 | export async function clearSiteFaviconCache() { 85 | logger({ msg: `clearSiteFaviconCache` }); 86 | return await (await ptdIndexDb).clear("favicon"); 87 | } 88 | 89 | onMessage("clearSiteFaviconCache", async () => await clearSiteFaviconCache()); 90 | -------------------------------------------------------------------------------- /src/entries/offscreen/utils/socialInformation.ts: -------------------------------------------------------------------------------- 1 | import type { ISocialInformation, TSupportSocialSite$1 } from "@ptd/social"; 2 | import { getSocialSiteInformation } from "@ptd/social"; 3 | 4 | import { onMessage, sendMessage } from "@/messages.ts"; 5 | import type { IConfigPiniaStorageSchema } from "@/shared/types.ts"; 6 | 7 | import { ptdIndexDb } from "../adapter/indexdb.ts"; 8 | import { logger } from "../utils/logger.ts"; 9 | 10 | export async function getSocialInformation(site: TSupportSocialSite$1, sid: string): Promise { 11 | const configStoreRaw = (await sendMessage("getExtStorage", "config")) as IConfigPiniaStorageSchema; 12 | const socialInformationConfig = configStoreRaw.socialSiteInformation ?? {}; 13 | 14 | const key = `${site}:${sid}`; 15 | let stored = await (await ptdIndexDb).get("social_information", key); 16 | 17 | if (!stored || stored.createAt < Date.now() - 86400000 * (socialInformationConfig.cacheDay ?? 3)) { 18 | stored = await getSocialSiteInformation(site, sid, socialInformationConfig); 19 | if (stored && (stored.title !== "" || stored.poster !== "")) { 20 | await setSocialInformation(site, sid, stored); 21 | } 22 | logger({ msg: `getSocialInformation for ${site} with sid: ${sid}`, data: stored }); 23 | } 24 | 25 | return stored as ISocialInformation; 26 | } 27 | 28 | onMessage("getSocialInformation", async ({ data: { site, sid } }) => await getSocialInformation(site, sid)); 29 | 30 | export async function setSocialInformation(site: TSupportSocialSite$1, sid: string, val: ISocialInformation) { 31 | const key = `${site}:${sid}`; 32 | return await (await ptdIndexDb).put("social_information", val, key); 33 | } 34 | 35 | export async function deleteSocialInformation(site: TSupportSocialSite$1, sid: string) { 36 | const key = `${site}:${sid}`; 37 | return await (await ptdIndexDb).delete("social_information", key); 38 | } 39 | 40 | export async function clearSocialInformation() { 41 | return await (await ptdIndexDb).clear("social_information"); 42 | } 43 | 44 | onMessage("clearSocialInformationCache", async () => { 45 | await clearSocialInformation(); 46 | }); 47 | -------------------------------------------------------------------------------- /src/entries/offscreen/utils/userInfo.ts: -------------------------------------------------------------------------------- 1 | import PQueue from "p-queue"; 2 | import { format } from "date-fns"; 3 | import type { IUserInfo } from "@ptd/site"; 4 | import { EResultParseStatus } from "@ptd/site"; 5 | 6 | import { onMessage, sendMessage } from "@/messages.ts"; 7 | import type { IMetadataPiniaStorageSchema, IConfigPiniaStorageSchema, TUserInfoStorageSchema } from "@/shared/types.ts"; 8 | 9 | import { logger } from "./logger.ts"; 10 | import { getSiteInstance } from "./site.ts"; 11 | 12 | const flushQueue = new PQueue({ concurrency: 1 }); // 默认设置为 1,避免并发搜索 13 | 14 | flushQueue.on("active", async () => { 15 | const configStoreRaw = (await sendMessage("getExtStorage", "config")) as IConfigPiniaStorageSchema; 16 | const queueConcurrency = configStoreRaw?.userInfo?.queueConcurrency ?? 1; 17 | 18 | if (flushQueue.concurrency != queueConcurrency) { 19 | flushQueue.concurrency = queueConcurrency; 20 | logger({ 21 | msg: `The concurrency of the user information refresh queue has been updated to ${flushQueue.concurrency}`, 22 | }); 23 | } 24 | }); 25 | 26 | onMessage("cancelUserInfoQueue", () => { 27 | flushQueue.clear(); 28 | }); 29 | 30 | export async function getSiteUserInfoResult(siteId: string) { 31 | return (await flushQueue.add(async () => { 32 | logger({ msg: `getSiteUserInfoResult for ${siteId}` }); 33 | // 获取站点实例 34 | const site = await getSiteInstance<"private">(siteId); 35 | 36 | // 获取历史信息 37 | const metadataStoreRaw = (await sendMessage("getExtStorage", "metadata")) as IMetadataPiniaStorageSchema; 38 | let lastUserInfo = metadataStoreRaw?.lastUserInfo?.[siteId as string] ?? {}; 39 | if ((lastUserInfo as IUserInfo).status !== EResultParseStatus.success) { 40 | lastUserInfo = {} as IUserInfo; 41 | } 42 | 43 | let userInfo = lastUserInfo; 44 | if (site.allowQueryUserInfo) { 45 | // 调用站点实例获取用户信息 46 | userInfo = await site.getUserInfoResult(lastUserInfo); 47 | await setSiteLastUserInfo(userInfo); 48 | } 49 | 50 | return userInfo!; 51 | }))!; 52 | } 53 | 54 | onMessage("getSiteUserInfoResult", async ({ data: siteId }) => await getSiteUserInfoResult(siteId)); 55 | 56 | export async function setSiteLastUserInfo(userData: IUserInfo) { 57 | logger({ msg: `setSiteLastUserInfo for ${userData.site}`, data: userData }); 58 | const site = userData.site; 59 | 60 | // 存储用户信息到 metadata 中( pinia/webExtPersistence 会自动同步该部分信息 ) 61 | const metadataStore = ((await sendMessage("getExtStorage", "metadata")) ?? {}) as IMetadataPiniaStorageSchema; 62 | (metadataStore as IMetadataPiniaStorageSchema).lastUserInfo ??= {}; 63 | (metadataStore as IMetadataPiniaStorageSchema).lastUserInfo[site] = userData; 64 | await sendMessage("setExtStorage", { key: "metadata", value: metadataStore }); 65 | 66 | // 存储用户信息到 userInfo 中(仅当获取成功时) 67 | if (userData.status === EResultParseStatus.success) { 68 | const userInfoStore = ((await sendMessage("getExtStorage", "userInfo")) ?? {}) as TUserInfoStorageSchema; 69 | userInfoStore[site] ??= {}; 70 | const dateTime = format(userData.updateAt, "yyyy-MM-dd"); 71 | userInfoStore[site][dateTime] = userData; 72 | await sendMessage("setExtStorage", { key: "userInfo", value: userInfoStore }); 73 | } 74 | } 75 | 76 | onMessage("setSiteLastUserInfo", async ({ data: userData }) => await setSiteLastUserInfo(userData)); 77 | 78 | onMessage("getSiteUserInfo", async ({ data: siteId }) => { 79 | const userInfoStore = ((await sendMessage("getExtStorage", "userInfo")) ?? {}) as TUserInfoStorageSchema; 80 | return userInfoStore?.[siteId] ?? {}; 81 | }); 82 | 83 | onMessage("removeSiteUserInfo", async ({ data: { siteId, date } }) => { 84 | const userInfoStore = ((await sendMessage("getExtStorage", "userInfo")) ?? {}) as TUserInfoStorageSchema; 85 | delete userInfoStore?.[siteId]?.[date]; 86 | await sendMessage("setExtStorage", { key: "userInfo", value: userInfoStore! }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/entries/options/App.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/entries/options/components/CheckSwitchButton.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/entries/options/components/ConnectCheckButton.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/entries/options/components/DeleteDialog.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/entries/options/components/DownloaderLabel.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/entries/options/components/NavButton.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | 28 | 33 | -------------------------------------------------------------------------------- /src/entries/options/components/ResultParseStatus.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/entries/options/components/SiteFavicon.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/entries/options/components/SiteName.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/entries/options/components/SolutionDetail.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/entries/options/directives/useResetableRef.ts: -------------------------------------------------------------------------------- 1 | import { type Ref, type MaybeRefOrGetter, ref as deepRef, shallowRef, toValue } from "vue"; 2 | 3 | interface IResetableRefOptions { 4 | shallow?: boolean; 5 | } 6 | 7 | export function useResetableRef(initialValue: MaybeRefOrGetter, options: IResetableRefOptions = {}) { 8 | const { shallow = false } = options; 9 | const originValue = toValue(initialValue); 10 | 11 | const ref = (shallow ? shallowRef(originValue) : deepRef(originValue)) as Ref; 12 | const reset = (newVal: MaybeRefOrGetter = initialValue) => { 13 | ref.value = toValue(newVal); 14 | }; 15 | 16 | return { ref, reset }; 17 | } 18 | -------------------------------------------------------------------------------- /src/entries/options/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Options 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/entries/options/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | 4 | // Vue Plugins 5 | import { vuetifyInstance as vuetify } from "./plugins/vuetify"; 6 | import { piniaInstance as pinia } from "./plugins/pinia"; 7 | import { routerInstance as router } from "./plugins/router"; 8 | import { i18nInstance as i18n } from "./plugins/i18n"; 9 | import VueKonva from "vue-konva"; 10 | 11 | createApp(App).use(pinia).use(i18n).use(router).use(vuetify).use(VueKonva, { prefix: "Vk" }).mount("#app"); 12 | -------------------------------------------------------------------------------- /src/entries/options/plugins/i18n.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from "vue-i18n"; 2 | 3 | import en from "~/locales/en.json"; 4 | import zh_CN from "~/locales/zh_CN.json"; 5 | 6 | export type TLangCode = "en" | "zh_CN"; 7 | 8 | interface ILangMetaData { 9 | title: string; 10 | value: TLangCode; 11 | authors: readonly string[]; 12 | } 13 | 14 | /** 15 | * 由于 Vue-i18n v11 在 CSP 环境中无法进行编译操作,所以所有语言文件需要在此处预注册, 16 | * 不然不会在插件页面显示,也不能实现像 v1.x 中的”临时添加新语言功能“ 17 | */ 18 | export const definedLangMetaData: readonly ILangMetaData[] = [ 19 | { 20 | title: "English (Beta)", 21 | value: "en", 22 | authors: ["ronggang", "Rhilip", "ylxb2016", "xiongqiwei", "jackson008"], 23 | }, 24 | { 25 | title: "简体中文 Chinese (Simplified)", 26 | value: "zh_CN", 27 | authors: ["栽培者", "Rhilip"], 28 | }, 29 | ] as const; 30 | 31 | export const i18nInstance = createI18n({ 32 | locale: "zh_CN", 33 | fallbackLocale: "en", 34 | messages: { 35 | en, 36 | zh_CN, 37 | }, 38 | }); 39 | 40 | export const i18n = i18nInstance.global; 41 | -------------------------------------------------------------------------------- /src/entries/options/plugins/pinia.ts: -------------------------------------------------------------------------------- 1 | import { createPinia } from "pinia"; 2 | import { createStatePersistence } from "pinia-plugin-state-persistence"; 3 | 4 | import { piniaWebExtPersistencePlugin } from "~/extends/pinia/webExtPersistence.ts"; 5 | 6 | export const piniaInstance = createPinia(); 7 | 8 | piniaInstance.use(piniaWebExtPersistencePlugin); 9 | piniaInstance.use(createStatePersistence()); 10 | -------------------------------------------------------------------------------- /src/entries/options/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import "@mdi/font/css/materialdesignicons.css"; 2 | import "vuetify/styles"; 3 | 4 | import { createVuetify } from "vuetify"; 5 | import { en, zhHans } from "vuetify/locale"; 6 | 7 | import { type TLangCode } from "./i18n.ts"; 8 | 9 | export const vuetifyLangMap: Record = { 10 | en: "en", 11 | zh_CN: "zhHans", 12 | }; 13 | 14 | export const vuetifyInstance = createVuetify({ 15 | locale: { 16 | locale: "zhHans", 17 | fallback: "en", 18 | messages: { zhHans, en }, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/entries/options/stores/runtime.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 此处放置一些其他数据,这些数据一般具有以下特征: 3 | * 1. 不需要persist 4 | * 2. 不需要跨tab共享的 5 | * 3. 可以在不同component中共享的 6 | */ 7 | 8 | import { defineStore } from "pinia"; 9 | import type { IRuntimePiniaStorageSchema, ISearchData, SnackbarMessageOptions } from "@/shared/types.ts"; 10 | 11 | const initialSearchData: () => ISearchData = () => ({ 12 | isSearching: false, 13 | startAt: 0, 14 | searchKey: "", 15 | searchPlanKey: "default", 16 | searchPlan: {}, 17 | searchResult: [], 18 | }); 19 | 20 | const initialMediaServerSearchData = () => ({ 21 | isSearching: false, 22 | searchKey: "", 23 | searchStatus: {}, 24 | searchResult: [], 25 | }); 26 | 27 | export const useRuntimeStore = defineStore("runtime", { 28 | persist: { 29 | storage: sessionStorage, 30 | }, 31 | persistWebExt: false, 32 | state: (): IRuntimePiniaStorageSchema => ({ 33 | search: initialSearchData(), 34 | userInfo: { 35 | flushPlan: {}, 36 | }, 37 | mediaServerSearch: initialMediaServerSearchData(), 38 | uiGlobalSnakebar: [], 39 | }), 40 | 41 | getters: { 42 | searchCostTime(state) { 43 | return Object.values(state.search.searchPlan).reduce((acc, cur) => acc + (cur.costTime ?? 0), 0); 44 | }, 45 | 46 | isUserInfoFlush(state) { 47 | return Object.values(state.userInfo.flushPlan).some((v) => v); 48 | }, 49 | }, 50 | 51 | actions: { 52 | resetSearchData() { 53 | this.search = initialSearchData(); 54 | }, 55 | 56 | resetMediaServerSearchData() { 57 | this.mediaServerSearch = initialMediaServerSearchData(); 58 | }, 59 | 60 | showSnakebar(text: string, options: SnackbarMessageOptions = {}) { 61 | // @ts-ignore 62 | this.uiGlobalSnakebar.push({ text, ...options }); 63 | }, 64 | }, 65 | }); 66 | -------------------------------------------------------------------------------- /src/entries/options/style.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss" prefix(tw); 2 | 3 | /* 在表头过滤器中不显示 v-combobox 的输入框 */ 4 | .v-combobox.table-header-filter-clear { 5 | .v-field__input { 6 | margin-top: 4px; 7 | } 8 | 9 | input[size="1"][type="text"] { 10 | display: none; 11 | } 12 | 13 | @media (max-width: 600px) { 14 | .v-field { 15 | flex: none; 16 | grid-template-areas: "prepend-inner field clear append-inner"; 17 | grid-template-columns: min-content 0 min-content min-content; 18 | } 19 | 20 | .v-field__field { 21 | display: none; 22 | } 23 | } 24 | } 25 | 26 | .table-header-no-wrap { 27 | .v-data-table-header__content { 28 | span { 29 | white-space: nowrap !important; 30 | } 31 | } 32 | } 33 | 34 | .table-stripe { 35 | tr.v-data-table__tr:nth-child(even) { 36 | background-color: #f1f1f1; 37 | } 38 | } 39 | 40 | .table-switch-btn { 41 | .v-selection-control { 42 | justify-content: center; 43 | } 44 | } 45 | 46 | /* 表格中 density-compact 的按钮组中添加间距 */ 47 | .table-action.v-btn-group--density-compact > button:not(:last-child) { 48 | margin-right: 4px; 49 | } 50 | 51 | .list-item-none-spacer > .v-list-item__prepend > .v-icon ~ .v-list-item__spacer { 52 | display: none; 53 | } 54 | 55 | .list-item-half-spacer > .v-list-item__prepend > .v-icon ~ .v-list-item__spacer { 56 | width: 12px; 57 | } 58 | 59 | /* 暗黑模式覆写 */ 60 | .v-theme--dark { 61 | .table-stripe { 62 | tr.v-data-table__tr:nth-child(even) { 63 | background-color: #2a2a2a; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/entries/options/utils.ts: -------------------------------------------------------------------------------- 1 | import { toRaw, isRef, isReactive, isProxy } from "vue"; 2 | import { filesize, type FileSizeOptions } from "filesize"; 3 | import { 4 | differenceInDays, 5 | differenceInHours, 6 | differenceInMonths, 7 | differenceInWeeks, 8 | differenceInYears, 9 | differenceInMinutes, 10 | format as dateFormat, 11 | } from "date-fns"; 12 | import { i18n } from "@/options/plugins/i18n.ts"; 13 | 14 | export function deepToRaw>(sourceObj: T): T { 15 | const objectIterator = (input: any): any => { 16 | if (Array.isArray(input)) { 17 | return input.map((item) => objectIterator(item)); 18 | } 19 | if (isRef(input) || isReactive(input) || isProxy(input)) { 20 | return objectIterator(toRaw(input)); 21 | } 22 | if (input && typeof input === "object") { 23 | return Object.keys(input).reduce((acc, key) => { 24 | acc[key as keyof typeof acc] = objectIterator(input[key]); 25 | return acc; 26 | }, {} as T); 27 | } 28 | return input; 29 | }; 30 | 31 | return objectIterator(sourceObj); 32 | } 33 | 34 | export const formValidateRules: Record (v: any) => boolean | string> = { 35 | require: (args: string = "Item is required") => { 36 | return (v: any) => !!v || args; 37 | }, 38 | url: (args: string = "Not url") => { 39 | return (v: any) => /^(https?):\/\/[-A-Za-z0-9+&@#/%?=~_|!:,.;[\]]+[-A-Za-z0-9+&@#/%=~_|]$/.test(v) || args; 40 | }, 41 | }; 42 | 43 | export const formatSize = (size: number | string, options?: FileSizeOptions) => { 44 | try { 45 | return filesize(size, { base: 2, ...(options ?? {}) }); 46 | } catch (e) { 47 | return size; 48 | } 49 | }; 50 | 51 | export const formatDate = (date: Date | number | string, format: string = "yyyy-MM-dd HH:mm:ss") => { 52 | try { 53 | return dateFormat(date, format); 54 | } catch (e) { 55 | return date; 56 | } 57 | }; 58 | 59 | export const formatTimeAgo = (sourceDate: Date | number | string, weekOnly: boolean = false): string => { 60 | const nowDate = new Date(); 61 | 62 | if (weekOnly) { 63 | const weeks = differenceInWeeks(nowDate, sourceDate); 64 | if (weeks < 1) { 65 | return i18n.t("common.time.lessThanAWeek"); 66 | } 67 | return `${weeks}${i18n.t("common.time.week")}`; 68 | } 69 | 70 | const years = differenceInYears(nowDate, sourceDate); 71 | const months = differenceInMonths(nowDate, sourceDate) % 12; 72 | const days = differenceInDays(nowDate, sourceDate) % 30; 73 | const hours = differenceInHours(nowDate, sourceDate) % 24; 74 | const mins = differenceInMinutes(nowDate, sourceDate) % 60; 75 | 76 | let result; 77 | if (years > 0) { 78 | result = `${years}${i18n.t("common.time.year")}${months}${i18n.t("common.time.month")}`; 79 | } else if (months > 0) { 80 | result = `${months}${i18n.t("common.time.month")}${days}${i18n.t("common.time.day")}`; 81 | } else if (days > 0) { 82 | result = `${days}${i18n.t("common.time.day")}${hours}${i18n.t("common.time.hour")}`; 83 | } else if (hours > 0) { 84 | result = `${hours}${i18n.t("common.time.hour")}${mins}${i18n.t("common.time.minute")}`; 85 | } else if (mins > 0) { 86 | result = `${mins}${i18n.t("common.time.minute")}`; 87 | } else { 88 | result = `< 1${i18n.t("common.time.minute")}`; 89 | } 90 | return result + i18n.t("common.time.ago"); 91 | }; 92 | 93 | export const formatNumber = (num: number, options: Intl.NumberFormatOptions = {}) => 94 | Number(num).toLocaleString("en-US", { maximumFractionDigits: 2, minimumFractionDigits: 2, ...options }); 95 | -------------------------------------------------------------------------------- /src/entries/options/views/About/Logger.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/entries/options/views/About/SpecialThank.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/entries/options/views/Layout/Navigation.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/DownloadHistory/utils.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from "es-toolkit"; 2 | import { computed, ref, reactive } from "vue"; 3 | import { sendMessage } from "@/messages.ts"; 4 | import { useTableCustomFilter } from "@/options/directives/useAdvanceFilter.ts"; 5 | 6 | import type { ITorrentDownloadMetadata, TTorrentDownloadKey } from "@/shared/types.ts"; 7 | 8 | export const downloadHistory = ref>({}); 9 | export const downloadHistoryList = computed(() => Object.values(downloadHistory.value)); 10 | 11 | // 使用 setTimeout 监听下载状态变化 12 | const watchingMap = reactive>({}); 13 | function watchDownloadHistory(downloadHistoryId: TTorrentDownloadKey) { 14 | watchingMap[downloadHistoryId] = setTimeout(async () => { 15 | const history = await sendMessage("getDownloadHistoryById", downloadHistoryId); 16 | downloadHistory.value[downloadHistoryId] = history; 17 | if (history.downloadStatus == "downloading" || history.downloadStatus == "pending") { 18 | watchDownloadHistory(downloadHistoryId); 19 | } else { 20 | delete watchingMap[downloadHistoryId]; 21 | } 22 | }, 1e3) as unknown as number; 23 | } 24 | 25 | function loadDownloadHistory() { 26 | // 首先清除所有的下载状态监听 27 | for (const key of Object.keys(watchingMap)) { 28 | clearTimeout(watchingMap[key as unknown as number]); 29 | delete watchingMap[key as unknown as number]; 30 | } 31 | 32 | sendMessage("getDownloadHistory", undefined).then((history: ITorrentDownloadMetadata[]) => { 33 | downloadHistory.value = {}; // 清空目前的下载记录 34 | history.forEach((item) => { 35 | downloadHistory.value[item.id!] = item; 36 | if (item.downloadStatus == "downloading" || item.downloadStatus == "pending") { 37 | watchDownloadHistory(item.id!); 38 | } 39 | }); 40 | }); 41 | } 42 | 43 | export const throttleLoadDownloadHistory = throttle(loadDownloadHistory, 1e3); 44 | 45 | export const tableCustomFilter = useTableCustomFilter({ 46 | parseOptions: { 47 | keywords: ["siteId", "downloaderId", "downloadStatus"], 48 | ranges: ["downloadAt"], 49 | }, 50 | titleFields: ["title", "subTitle"], 51 | initialItems: downloadHistoryList, 52 | format: { 53 | downloadAt: "date", 54 | }, 55 | }); 56 | 57 | export const downloadStatusMap: Record< 58 | ITorrentDownloadMetadata["downloadStatus"], 59 | { title: string; icon: string; color: string } 60 | > = { 61 | downloading: { title: "下载中", icon: "mdi-download", color: "blue" }, 62 | pending: { title: "等待中", icon: "mdi-clock", color: "orange" }, 63 | completed: { title: "已完成", icon: "mdi-check", color: "green" }, 64 | failed: { title: "错误", icon: "mdi-alert", color: "red" }, 65 | }; 66 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/MediaServerEntity/utils.ts: -------------------------------------------------------------------------------- 1 | import PQueue from "p-queue"; 2 | import { ref } from "vue"; 3 | import { omit } from "es-toolkit"; 4 | import { type IMediaServerSearchOptions } from "@ptd/mediaServer"; 5 | 6 | import { sendMessage } from "@/messages.ts"; 7 | import { useRuntimeStore } from "@/options/stores/runtime.ts"; 8 | import { useConfigStore } from "@/options/stores/config.ts"; 9 | import { useMetadataStore } from "@/options/stores/metadata.ts"; 10 | import type { TMediaServerKey } from "@/shared/types.ts"; 11 | import { EResultParseStatus } from "@ptd/site"; 12 | const runtimeStore = useRuntimeStore(); 13 | const configStore = useConfigStore(); 14 | const metadataStore = useMetadataStore(); 15 | 16 | export const searchMediaServerIds = ref( 17 | metadataStore.getEnabledMediaServers.map((mediaServer) => mediaServer.id) ?? [], 18 | ); 19 | 20 | export const searchQueue = new PQueue({ concurrency: 1 }); // 默认设置为 1,避免并发搜索 21 | 22 | searchQueue.on("active", () => { 23 | runtimeStore.mediaServerSearch.isSearching = true; 24 | // 启动后,根据 configStore 的值,自动更新 searchQueue 的并发数 25 | if (searchQueue.concurrency != configStore.mediaServerEntity.queueConcurrency) { 26 | searchQueue.concurrency = configStore.mediaServerEntity.queueConcurrency; 27 | sendMessage("logger", { msg: `Search queue concurrency changed to: ${searchQueue.concurrency}` }).catch(); 28 | } 29 | }); 30 | 31 | searchQueue.on("idle", () => { 32 | runtimeStore.mediaServerSearch.isSearching = false; 33 | }); 34 | 35 | export async function doSearch(option: { searchKey?: string; loadMore?: boolean } = {}) { 36 | const { searchKey = "", loadMore = false } = option; 37 | 38 | if (searchKey != runtimeStore.mediaServerSearch.searchKey) { 39 | runtimeStore.resetMediaServerSearchData(); 40 | } 41 | 42 | runtimeStore.mediaServerSearch.searchKey = searchKey; 43 | 44 | for (const mediaServerId of searchMediaServerIds.value) { 45 | // noinspection ES6MissingAwait 46 | searchQueue.add(async () => { 47 | let searchOptions: IMediaServerSearchOptions = { limit: configStore.mediaServerEntity.searchLimit ?? 50 }; 48 | if (loadMore) { 49 | searchOptions = runtimeStore.mediaServerSearch.searchStatus[mediaServerId]?.options ?? {}; 50 | searchOptions.startIndex = (searchOptions.startIndex ?? 0) + (searchOptions.limit ?? 0); 51 | } 52 | 53 | const searchResult = await sendMessage("getMediaServerSearchResult", { 54 | mediaServerId, 55 | keywords: searchKey, 56 | options: searchOptions, 57 | }); 58 | 59 | runtimeStore.mediaServerSearch.searchStatus[mediaServerId] = { 60 | ...omit(searchResult, ["items"]), 61 | canLoadMore: false, 62 | }; 63 | 64 | if (searchResult.status !== EResultParseStatus.success) { 65 | const mediaServerDetail = metadataStore.mediaServers[mediaServerId]; 66 | runtimeStore.showSnakebar( 67 | `媒体服务器 ${mediaServerDetail.name} [${mediaServerDetail.address}] 更新失败,请检查认证信息`, 68 | { color: "error" }, 69 | ); 70 | return; 71 | } 72 | 73 | for (const item of searchResult.items) { 74 | // 根据 url 去重 75 | const isDuplicate = runtimeStore.mediaServerSearch.searchResult.some((result) => result.url == item.url); 76 | if (!isDuplicate) { 77 | runtimeStore.mediaServerSearch.searchResult.push(item); 78 | // 如果本次有成功添加的,则认为可以加载更多 79 | runtimeStore.mediaServerSearch.searchStatus[mediaServerId].canLoadMore = true; 80 | } 81 | } 82 | }); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/MyData/UserDataStatistic/utils.ts: -------------------------------------------------------------------------------- 1 | import { eachDayOfInterval, subDays, min as minDateFn, max as maxDateFn, format as formatDate } from "date-fns"; 2 | import { EResultParseStatus, TSiteID } from "@ptd/site"; 3 | 4 | import { sendMessage } from "@/messages.ts"; 5 | import { type IStoredUserInfo, TUserInfoStorageSchema } from "@/shared/types.ts"; 6 | 7 | export type TUserDataStatistic = Record>; 8 | 9 | export async function loadFullData(): Promise { 10 | const rawData = (await sendMessage("getExtStorage", "userInfo")) as TUserInfoStorageSchema; 11 | 12 | // 提取所有日期 13 | const allDates = []; 14 | for (const key in rawData) { 15 | const perSiteUserInfoHistory = rawData[key]; 16 | for (const dateStr in perSiteUserInfoHistory) { 17 | allDates.push(dateStr); 18 | } 19 | } 20 | 21 | // 找出最小和最大日期,并生成最小到最大日期之间的所有日期 22 | const minDate = minDateFn(allDates); 23 | const maxDate = maxDateFn(allDates); 24 | const datesInRange = eachDayOfInterval({ start: minDate, end: maxDate }).map((x) => formatDate(x, "yyyy-MM-dd")); 25 | const retData: Record = Object.fromEntries(datesInRange.map((x) => [x, {}])); 26 | 27 | // 遍历每个站点 28 | for (const key in rawData) { 29 | const perSiteUserInfoHistory = rawData[key]; 30 | const siteId = key as TSiteID; 31 | const thisSiteDateRange = Object.keys(perSiteUserInfoHistory); 32 | const thisSiteMinDate = minDateFn(thisSiteDateRange); 33 | const thisSiteMaxDate = maxDateFn(thisSiteDateRange); 34 | const thisSiteDateRangeInInterval = eachDayOfInterval({ start: thisSiteMinDate, end: thisSiteMaxDate }).map((x) => 35 | formatDate(x, "yyyy-MM-dd"), 36 | ); 37 | 38 | let dateData; 39 | for (const dateStr of thisSiteDateRangeInInterval) { 40 | // 如果该日期有值,则覆盖,否则使用上一个有效日期的值 41 | if (perSiteUserInfoHistory[dateStr]) { 42 | const thisDateData = perSiteUserInfoHistory[dateStr]; 43 | if (thisDateData.status === EResultParseStatus.success) { 44 | dateData = thisDateData; 45 | } 46 | } 47 | if (dateData) { 48 | retData[dateStr][siteId] = dateData; 49 | } 50 | } 51 | } 52 | 53 | return retData; 54 | } 55 | 56 | export function setSubDate(days: number) { 57 | const today = new Date(); 58 | const subDay = subDays(today, days); 59 | 60 | console.log(today, subDay); 61 | 62 | return eachDayOfInterval({ start: subDay, end: today }).map((x) => formatDate(x, "yyyy-MM-dd")); 63 | } 64 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/MyData/UserLevelsComponent.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/MyData/UserNextLevelUnMet.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/MyData/utils.ts: -------------------------------------------------------------------------------- 1 | import { isEmpty } from "es-toolkit/compat"; 2 | import { definitionList, fixRatio, ISiteMetadata, IUserInfo, NO_IMAGE, TSiteID } from "@ptd/site"; 3 | 4 | import { sendMessage } from "@/messages.ts"; 5 | import { useRuntimeStore } from "@/options/stores/runtime.ts"; 6 | import { useMetadataStore } from "@/options/stores/metadata.ts"; 7 | 8 | const metadataStore = useMetadataStore(); 9 | const runtimeStore = useRuntimeStore(); 10 | 11 | // 对 siteUserInfoData 进行一些预处理(不涉及渲染格式) 12 | export function fixUserInfo(userInfo: Partial): Required { 13 | userInfo.ratio = fixRatio(userInfo); 14 | userInfo.trueRatio = fixRatio(userInfo, "trueRatio"); 15 | userInfo.messageCount ??= 0; 16 | return userInfo as Required; 17 | } 18 | 19 | export function realFormatRatio(ratio: number): string | "∞" | "" { 20 | if (ratio > 10000 || ratio === -1 || ratio === Infinity || ratio === null) { 21 | return "∞"; 22 | } 23 | 24 | if (isNaN(ratio) || ratio === -Infinity) { 25 | return "-"; 26 | } 27 | 28 | return ratio.toFixed(2); 29 | } 30 | 31 | export function formatRatio( 32 | userInfo: Partial, 33 | ratioKey: "ratio" | "trueRatio" = "ratio", 34 | ): string | "∞" | "" { 35 | let ratio = userInfo[ratioKey] ?? -1; 36 | return realFormatRatio(ratio); 37 | } 38 | 39 | export function flushSiteLastUserInfo(sites: TSiteID[]) { 40 | for (const site of sites) { 41 | runtimeStore.userInfo.flushPlan[site] = true; 42 | 43 | sendMessage("getSiteUserInfoResult", site) 44 | .catch((e) => { 45 | // 首先检查是否还在刷新,如果没有,则说明队列已经取消了,此时不报错 46 | if (!runtimeStore.userInfo.flushPlan[site]) { 47 | runtimeStore.showSnakebar(`获取站点 [${site}] 用户信息失败`, { color: "error" }); 48 | console.error(e); 49 | } 50 | }) 51 | .finally(() => { 52 | runtimeStore.userInfo.flushPlan[site] = false; 53 | }); 54 | } 55 | } 56 | 57 | export async function cancelFlushSiteLastUserInfo() { 58 | for (const runtimeStoreKey in runtimeStore.userInfo.flushPlan) { 59 | runtimeStore.userInfo.flushPlan[runtimeStoreKey] = false; 60 | } 61 | 62 | await sendMessage("cancelUserInfoQueue", undefined); 63 | 64 | runtimeStore.showSnakebar(`用户信息刷新队列已取消`, { color: "error" }); 65 | } 66 | 67 | export interface ITimelineSiteMetadata extends Pick { 68 | siteName: string; // 解析后的站点名称 69 | hasUserInfo: boolean; // 是否有用户配置 70 | faviconSrc: string; 71 | faviconElement: HTMLImageElement; // 站点的图片 72 | } 73 | 74 | export type TOptionSiteMetadatas = Record; 75 | 76 | export const allAddedSiteMetadata: TOptionSiteMetadatas = {}; 77 | 78 | export async function loadAllAddedSiteMetadata(): Promise { 79 | if (isEmpty(allAddedSiteMetadata)) { 80 | for (const siteId of definitionList) { 81 | const siteMetadata = await metadataStore.getSiteMetadata(siteId); 82 | const siteFaviconUrl = await sendMessage("getSiteFavicon", { site: siteId }); 83 | 84 | // 加载站点图标 85 | const siteFavicon = new Image(); 86 | siteFavicon.src = siteFaviconUrl; 87 | try { 88 | await siteFavicon.decode(); 89 | } catch (e) { 90 | siteFavicon.src = NO_IMAGE; 91 | await siteFavicon.decode(); 92 | } 93 | 94 | (allAddedSiteMetadata as TOptionSiteMetadatas)[siteId] = { 95 | id: siteId, 96 | siteName: await metadataStore.getSiteName(siteId), 97 | hasUserInfo: Object.hasOwn(siteMetadata, "userInfo"), 98 | faviconSrc: siteFaviconUrl, 99 | faviconElement: siteFavicon, 100 | }; 101 | } 102 | } 103 | 104 | return allAddedSiteMetadata; 105 | } 106 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/SearchEntity/ActionTd.vue: -------------------------------------------------------------------------------- 1 | 73 | 74 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/SearchEntity/SaveSnapshotDialog.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/SearchEntity/TorrentProcessTd.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /src/entries/options/views/Overview/SearchResultSnapshot/EditNameDialog.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBackup/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBackup/LocalExportConfirmDialog.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBase/DownloadWindow.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBase/Index.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBase/SearchEntityWindow.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBase/SocialInformationWindow.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBase/UiWindow.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetBase/UserInfoWindow.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 118 | 119 | 120 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetDownloader/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetMediaServer/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetSearchSolution/SolutionLabel.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetSite/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 72 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetSite/EditSearchEntryList.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/entries/options/views/Settings/SetSite/utils.ts: -------------------------------------------------------------------------------- 1 | import { computedAsync } from "@vueuse/core"; 2 | import { definitionList, ISiteMetadata, type ISiteUserConfig, TSiteID } from "@ptd/site"; 3 | 4 | import { useMetadataStore } from "@/options/stores/metadata.ts"; 5 | 6 | export async function getCanAddedSiteMetadata() { 7 | const canAddedSiteMetadata: Record = {}; 8 | const metadataStore = useMetadataStore(); 9 | const canAddedSiteList = definitionList.filter((x) => !metadataStore.getAddedSiteIds.includes(x)); 10 | for (const siteId of canAddedSiteList) { 11 | canAddedSiteMetadata[siteId] = await metadataStore.getSiteMetadata(siteId); 12 | } 13 | return canAddedSiteMetadata; 14 | } 15 | 16 | export interface ISiteTableItem { 17 | id: TSiteID; 18 | metadata: ISiteMetadata; 19 | userConfig: ISiteUserConfig; 20 | } 21 | 22 | export const allAddedSiteInfo = computedAsync(async () => { 23 | const metadataStore = useMetadataStore(); 24 | // noinspection BadExpressionStatementJS 25 | Object.values(metadataStore.sites).map((x) => x); 26 | 27 | const sitesReturn = []; 28 | for (const [siteId, siteUserConfig] of Object.entries(metadataStore.sites)) { 29 | sitesReturn.push({ 30 | id: siteId, 31 | metadata: await metadataStore.getSiteMetadata(siteId), 32 | userConfig: siteUserConfig, 33 | }); 34 | } 35 | 36 | return sitesReturn; 37 | }); 38 | -------------------------------------------------------------------------------- /src/entries/shared/types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 此处存放一些 共享的类型定义,但是又不好归类到其他模块的类型定义 3 | */ 4 | 5 | import type { TBackupFields } from "./types/storages/metadata.ts"; 6 | 7 | // 代理转发所有 types 导出 8 | export * from "./types/storages/config.ts"; 9 | export * from "./types/storages/indexdb.ts"; 10 | export * from "./types/storages/metadata.ts"; 11 | export * from "./types/storages/runtime.ts"; 12 | export * from "./types/storages/other.ts"; 13 | 14 | // https://github.com/pt-plugins/PT-Plugin-Plus/blob/70761980a72351397e19e188bead3289d36b4f83/src/interface/common.ts#L648-L726 15 | export interface IPtppUserInfo { 16 | id: number | string; // 用户ID 17 | name: string; // 用户名 18 | uploaded?: number; // 上传量 19 | uploads?: number; // 发布数 20 | downloaded?: number; // 下载量 21 | trueDownloaded?: string | number; // 真实下载量 22 | totalTraffic?: string | number; // 总流量 23 | snatches?: number; // 完成数 24 | ratio?: number; // 分享率 25 | seeding?: number; // 当前做种数量 26 | seedingSize?: number; // 做种体积 27 | seedingList?: string[]; // 做种列表 28 | leeching?: number; // 当前下载数量 29 | levelName?: string; // 等级名称 30 | bonus?: number; // 魔力值/积分 31 | seedingPoints?: number; // 保种积分 32 | seedingTime?: number; // 做种时间要求 33 | averageSeedtime?: number; // 平均保种时间 34 | totalSeedtime?: number; // 总保种时间 35 | bonusPerHour?: number; // 时魔 36 | bonusPage?: string; // 积分页面 37 | unsatisfiedsPage?: string; // H&R未达标页面 38 | joinTime?: number; // 入站时间 39 | classPoints?: number; // 等级积分 40 | unsatisfieds?: string | number; // H&R未达标 41 | prewarn?: number; // H&R预警 42 | lastUpdateTime?: number; // 最后更新时间 43 | lastUpdateStatus?: "needLogin" | "notSupported" | "unknown" | "success"; // 最后更新状态 EUserDataRequestStatus 44 | invites?: number; // 邀请数量 45 | avatar?: string; // 头像 46 | isLogged?: boolean; // 是否已登录 47 | isLoading?: boolean; // 正在加载 48 | lastErrorMsg?: string; // 最后错误信息 49 | messageCount?: number; // 消息数量 50 | uniqueGroups?: number; // 独特分组 51 | perfectFLAC?: number; // “完美”FLAC 52 | posts?: number; // 论坛发帖 53 | } 54 | 55 | export interface IPtppDumpUserInfo { 56 | [key: string]: { 57 | latest: IPtppUserInfo; 58 | [key: `${string}-${string}-${string}`]: IPtppUserInfo; 59 | }; 60 | } 61 | 62 | export interface IRestoreOptions { 63 | fields?: TBackupFields[]; // 需要恢复的字段 64 | expandCookieMinutes?: number; // 是否延长 cookie 过期时间(单位:分钟),(小于0)表示不延长 65 | keepExistUserInfo?: boolean; // 是否保留现有的用户信息 66 | } 67 | 68 | export interface ILoggerItem { 69 | id?: string; // 日志 ID(自动生成) 70 | time?: number; // 日志时间(自动生成) 71 | level?: "log" | "trace" | "debug" | "info" | "warn" | "error"; // 日志级别(不传入时默认为 log) 72 | module?: string; // 产生该日志的模块 73 | msg: string; // 日志内容 74 | data?: any; 75 | } 76 | -------------------------------------------------------------------------------- /src/entries/shared/types/storages/indexdb.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 存放一些不需要持久化(丢失没有关系的)的结构性数据,包括: 3 | * 1. 种子列表页面的多媒体数据 4 | * 2. 种子下载记录 5 | */ 6 | 7 | import type { DBSchema } from "idb"; 8 | import type { ISocialInformation } from "@ptd/social"; 9 | import type { ITorrent, TSiteID as TSiteKey } from "@ptd/site"; 10 | import type { CAddTorrentOptions } from "@ptd/downloader"; 11 | 12 | import type { TDownloaderKey } from "./metadata.ts"; 13 | import type { ISearchResultTorrent } from "./runtime.ts"; 14 | 15 | export type TTorrentDownloadKey = number; 16 | 17 | export interface ITorrentDownloadMetadata extends Pick { 18 | id?: TTorrentDownloadKey; // 每个下载任务生成的唯一id 19 | siteId: TSiteKey; // 站点id 20 | torrentId: ITorrent["id"]; // 种子id 21 | downloaderId: TDownloaderKey | "local"; // 下载器id,注意 local 是一个特殊的关键词,表示本地下载 22 | downloadAt: number; // 下载时间 23 | downloadStatus: "pending" | "downloading" | "completed" | "failed"; // 下载状态 24 | torrent: ISearchResultTorrent; // 种子信息 25 | addTorrentOptions: Partial; 26 | } 27 | 28 | export interface IPtdDBSchemaV1 extends DBSchema { 29 | social_information: { 30 | key: string; 31 | value: ISocialInformation; 32 | }; 33 | } 34 | 35 | export interface IPtdDBSchemaV2 extends IPtdDBSchemaV1 { 36 | download_history: { 37 | key: TTorrentDownloadKey; 38 | value: ITorrentDownloadMetadata; 39 | }; 40 | } 41 | 42 | export interface IPtdDBSchema extends IPtdDBSchemaV2 { 43 | favicon: { 44 | key: TSiteKey; 45 | value: string; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /src/entries/shared/types/storages/metadata.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ISearchCategories, 3 | ISearchEntryRequestConfig, 4 | ISiteUserConfig, 5 | IUserInfo, 6 | TSiteHost, 7 | TSiteID as TSiteKey, 8 | } from "@ptd/site"; 9 | import type { TSelectSearchCategoryValue } from "@ptd/site"; 10 | import type { CAddTorrentOptions, DownloaderBaseConfig } from "@ptd/downloader"; 11 | import type { IMediaServerBaseConfig } from "@ptd/mediaServer"; 12 | import type { IBackupConfig } from "@ptd/backupServer"; 13 | 14 | export interface ISearchSolution { 15 | id: string; 16 | siteId: TSiteKey; 17 | selectedCategories?: Record; 18 | searchEntries: Record; 19 | } 20 | 21 | export type TSolutionKey = string; 22 | export interface ISearchSolutionMetadata { 23 | id: TSolutionKey; 24 | name: string; 25 | sort: number; 26 | enabled: boolean; 27 | isDefault: boolean; 28 | createdAt: number; 29 | solutions: ISearchSolution[]; 30 | } 31 | 32 | export type TSearchSnapshotKey = string; 33 | export interface ISearchSnapshotMetadata { 34 | id: TSearchSnapshotKey; 35 | name: string; // [搜索方案] 搜索词 (搜索时间) 36 | createdAt: number; 37 | recordCount: number; // 记录数 38 | } 39 | 40 | export interface IStoredUserInfo extends IUserInfo {} 41 | 42 | export type TDownloaderKey = string; 43 | 44 | export interface IDownloaderMetadata extends DownloaderBaseConfig { 45 | id: TDownloaderKey; 46 | enabled: boolean; 47 | 48 | suggestFolders?: string[]; 49 | suggestTags?: string[]; 50 | 51 | [key: string]: any; // 其他配置项 52 | } 53 | 54 | export type TMediaServerKey = string; 55 | export interface IMediaServerMetadata extends IMediaServerBaseConfig { 56 | id: TMediaServerKey; 57 | enabled: boolean; 58 | [key: string]: any; // 其他配置项 59 | } 60 | 61 | export const BackupFields = [ 62 | "cookies", // 备份已添加站点的Cookie 63 | "config", // 备份插件基本配置 64 | "metadata", // 备份插件元数据(站点、搜索方案、下载器、媒体服务器等配置) 65 | "userInfo", // 备份插件历史获取的用户信息 66 | "searchResultSnapshot", // 备份搜索结果快照 67 | "downloadHistory", // 备份下载历史 68 | ] as const; 69 | export type TBackupFields = (typeof BackupFields)[number]; 70 | 71 | export type TBackupServerKey = string; 72 | export interface IBackupServerMetadata extends IBackupConfig { 73 | id: TBackupServerKey; 74 | enabled: boolean; // 此处仅影响自动备份 75 | backupFields: TBackupFields[]; // 备份的字段 76 | 77 | lastBackupAt?: number; // 上次备份时间 78 | } 79 | 80 | export interface IMetadataPiniaStorageSchema { 81 | // 站点配置(用户配置) 82 | sites: Record; 83 | 84 | // 搜索方案配置 85 | solutions: Record; 86 | 87 | // 默认搜索方案 88 | defaultSolutionId: TSolutionKey | "default"; 89 | 90 | /** 91 | * 搜索快照配置(元信息) 92 | * 具体快照内容需要通过 getSearchResultSnapshotData() 方法获取 93 | */ 94 | snapshots: Record; 95 | 96 | // 下载器配置 97 | downloaders: Record; 98 | 99 | // 媒体服务器配置 100 | mediaServers: Record; 101 | 102 | // 备份服务器配置 103 | backupServers: Record; 104 | 105 | // 上一次搜索时在结果页面的筛选词,需要启用 configStore.searchEntity.saveLastFilter 106 | lastSearchFilter?: string; 107 | 108 | /** 109 | * 此处仅存储站点最近一次的记录,如果需要获取历史记录,需要使用 storage 方法获取 110 | */ 111 | lastUserInfo: Record; 112 | 113 | lastDownloader?: { 114 | id?: TDownloaderKey; 115 | options?: Omit; 116 | }; 117 | 118 | // 上一次自动刷新的时间戳 119 | lastUserInfoAutoFlushAt: number; 120 | 121 | // 站点 host 映射表 122 | siteHostMap: Record; 123 | } 124 | -------------------------------------------------------------------------------- /src/entries/shared/types/storages/other.ts: -------------------------------------------------------------------------------- 1 | import type { TSiteID } from "@ptd/site"; 2 | import type { IStoredUserInfo, TSearchSnapshotKey } from "./metadata.ts"; 3 | import type { ISearchData } from "./runtime.ts"; 4 | 5 | export type TUserInfoStorageSchema = Record>; // 用于存储用户信息 6 | export type TSearchResultSnapshotStorageSchema = Record; // 用于存储搜索结果快照 7 | -------------------------------------------------------------------------------- /src/entries/shared/types/storages/runtime.ts: -------------------------------------------------------------------------------- 1 | import type { VNodeProps } from "vue"; 2 | import type { VSnackbar } from "vuetify/components"; 3 | import type { EResultParseStatus, ITorrent, TSiteID } from "@ptd/site"; 4 | import type { IMediaServerItem, IMediaServerSearchResult } from "@ptd/mediaServer"; 5 | 6 | import type { TMediaServerKey, TSearchSnapshotKey, TSolutionKey } from "./metadata.ts"; 7 | 8 | export type TSearchSolutionKey = `${TSiteID}|$|${TSolutionKey}`; 9 | 10 | export interface ISearchResultTorrent extends ITorrent { 11 | uniqueId: string; // 每个种子的uniqueId,由 `${site}-${id}` 组成 12 | solutionId: TSolutionKey; // 对应搜索方案的id 13 | solutionKey: TSearchSolutionKey; // 对应搜索方案的key,由 `${site}-${solutionId}` 组成 14 | } 15 | 16 | export interface ISearchPlanStatus { 17 | siteId: TSiteID; 18 | searchEntryName: string; 19 | searchEntry: Record; 20 | status: EResultParseStatus; 21 | queueAt?: number; 22 | queuePriority?: number; 23 | startAt?: number; 24 | costTime?: number; 25 | count?: number; 26 | } 27 | 28 | export interface ISearchData { 29 | snapshot?: TSearchSnapshotKey; // 是否是一个搜索快照 30 | isSearching: boolean; // 是否正在搜索 31 | // 该搜索相关时间情况 32 | startAt: number; 33 | 34 | // 该搜索相关的搜索条件 35 | searchKey: string; 36 | searchPlanKey: string; 37 | 38 | // 该搜索相关的搜索结果 39 | searchPlan: Record; 40 | searchResult: ISearchResultTorrent[]; 41 | } 42 | 43 | export type SnackbarMessageOptions = Partial< 44 | Omit< 45 | VSnackbar["$props"], 46 | | "modelValue" 47 | | "onUpdate:modelValue" 48 | | "activator" 49 | | "activatorProps" 50 | | "closeDelay" 51 | | "openDelay" 52 | | "openOnClick" 53 | | "openOnFocus" 54 | | "openOnHover" 55 | | "$children" 56 | | "v-slots" 57 | | `v-slot:${string}` 58 | | keyof VNodeProps 59 | > 60 | >; 61 | 62 | export interface IRuntimePiniaStorageSchema { 63 | search: ISearchData; 64 | userInfo: { 65 | flushPlan: Record; 66 | }; 67 | mediaServerSearch: { 68 | isSearching: boolean; // 是否正在搜索 69 | searchKey: string; 70 | searchStatus: Record & { canLoadMore?: boolean }>; // 搜索状态 71 | searchResult: IMediaServerItem[]; 72 | }; 73 | uiGlobalSnakebar: SnackbarMessageOptions[]; // https://vuetifyjs.com/en/components/snackbar-queue/#props-model-value 74 | } 75 | -------------------------------------------------------------------------------- /src/entries/storage.ts: -------------------------------------------------------------------------------- 1 | import { defineExtensionStorage } from "@webext-core/storage"; 2 | 3 | import { 4 | IConfigPiniaStorageSchema, 5 | IMetadataPiniaStorageSchema, 6 | TUserInfoStorageSchema, 7 | TSearchResultSnapshotStorageSchema, 8 | } from "@/shared/types.ts"; 9 | 10 | export interface IExtensionStorageSchema { 11 | // 既可以被 pinia 使用,也可以被其他地方使用 12 | config: IConfigPiniaStorageSchema; 13 | 14 | metadata: IMetadataPiniaStorageSchema; 15 | 16 | userInfo: TUserInfoStorageSchema; // 用于存储用户信息 17 | searchResultSnapshot: TSearchResultSnapshotStorageSchema; // 用于存储搜索结果快照 18 | } 19 | 20 | export type TExtensionStorageKey = keyof IExtensionStorageSchema; 21 | 22 | /** 23 | * 注意 extStore 不能在 offscreen 中使用,如果在 offscreen 中有需要,请使用 sw 提供的 sendMessage('getExtStorage' | 'setExtStorage') 24 | */ 25 | export const extStorage = defineExtensionStorage(chrome.storage.local); 26 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | // 此处放置一些全局都可以用的助手函数、常量定义 2 | 3 | // 仓库相关 4 | export const REPO_NAME = "pt-plugins/PT-depiler"; 5 | export const REPO_URL = `https://github.com/${REPO_NAME}`; 6 | export const REPO_API = `https://api.github.com/repos/${REPO_NAME}`; 7 | 8 | export const GROUP_TELEGRAM = "https://t.me/joinchat/NZ9NCxPKXyby8f35rn_QTw"; 9 | export const GROUP_QQ = "https://jq.qq.com/?_wv=1027&k=7d6xEo0L"; 10 | 11 | // 环境相关 12 | export const isProd = import.meta.env.PROD; 13 | export const isDebug = !isProd; 14 | 15 | // 插件相关 16 | export const EXT_BASEURL = chrome.runtime?.getURL(""); 17 | 18 | export function sleep(ms: number) { 19 | return new Promise((resolve) => setTimeout(resolve, ms)); 20 | } 21 | -------------------------------------------------------------------------------- /src/packages/backupServer/AbstractBackupServer.ts: -------------------------------------------------------------------------------- 1 | import { IBackupConfig, IBackupData, IBackupFileInfo, IBackupFileListOption } from "./type.ts"; 2 | import { backupDataToJSZipBlob, decryptData, encryptData, jsZipBlobToBackupData } from "./utils.ts"; 3 | 4 | export default abstract class AbstractBackupServer { 5 | protected abstract version: string; 6 | 7 | protected config: T; 8 | protected _encryptionKey?: string; 9 | 10 | protected constructor(config: T) { 11 | this.config = config; 12 | } 13 | 14 | get userConfig(): T["config"] { 15 | return this.config.config; 16 | } 17 | 18 | // 默认情况下,我们使用 外部设置的加密密钥, subclass 可以覆写 从而使用 userConfig 等其他地方的值 19 | get encryptionKey() { 20 | return this._encryptionKey; 21 | } 22 | 23 | public setEncryptionKey(key: string): void { 24 | this._encryptionKey = key; 25 | } 26 | 27 | /** 28 | * 验证服务器可用性 29 | */ 30 | public abstract ping(): Promise; 31 | 32 | /** 33 | * 获取资源列表 34 | * @param options 35 | */ 36 | public abstract list(options?: IBackupFileListOption): Promise; 37 | 38 | public abstract addFile(fileName: string, file: IBackupData): Promise; 39 | 40 | /** 41 | * 获取(下载)一个文件 42 | * @param path 43 | * @returns 返回一个 binary 数据 44 | */ 45 | public abstract getFile(path: string): Promise; 46 | 47 | public abstract deleteFile(path: string): Promise; 48 | 49 | protected encryptData(data: any): string { 50 | return encryptData(data, this.encryptionKey); 51 | } 52 | 53 | protected decryptData(data: string): T { 54 | return decryptData(data, this.encryptionKey); 55 | } 56 | 57 | protected async backupDataToJSZipBlob(data: IBackupData): Promise { 58 | return await backupDataToJSZipBlob(data, this.encryptionKey); 59 | } 60 | 61 | protected async jsZipBlobToBackupData(blob: Blob): Promise { 62 | return await jsZipBlobToBackupData(blob, this.encryptionKey); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/packages/backupServer/entity/WebDAV.ts: -------------------------------------------------------------------------------- 1 | import { AuthType, createClient, type FileStat, type WebDAVClient } from "webdav"; 2 | 3 | import AbstractBackupServer from "../AbstractBackupServer.ts"; 4 | import { localSort } from "../utils"; 5 | import type { IBackupConfig, IBackupData, IBackupFileInfo, IBackupFileListOption, IBackupMetadata } from "../type"; 6 | 7 | interface WebDAVConfig extends IBackupConfig { 8 | config: { 9 | address: string; 10 | loginName: string; 11 | loginPwd: string; 12 | digest?: boolean; 13 | }; 14 | } 15 | 16 | export const serverConfig: WebDAVConfig = { 17 | name: "WebDAV", 18 | type: "WebDAV", 19 | config: { address: "http://127.0.0.1/webdav", loginName: "", loginPwd: "", digest: false }, 20 | }; 21 | 22 | export const serverMetaData: IBackupMetadata = { 23 | description: "WebDAV 是一种基于 HTTP 协议的文件传输协议,支持文件存储和共享功能。", 24 | requiredField: [ 25 | { name: "地址", key: "address", type: "string" }, 26 | { name: "用户名", key: "loginName", type: "string" }, 27 | { name: "密码", key: "loginPwd", type: "string" }, 28 | { name: "Digest", key: "digest", type: "boolean" }, 29 | ], 30 | }; 31 | 32 | export default class WebDAV extends AbstractBackupServer { 33 | protected version = "1.0.0"; 34 | 35 | private server?: WebDAVClient; 36 | 37 | private getServer(): WebDAVClient { 38 | if (!this.server) { 39 | this.server = createClient(this.userConfig.address, { 40 | username: this.userConfig.loginName, 41 | password: this.userConfig.loginPwd, 42 | authType: this.userConfig.digest ? AuthType.Digest : undefined, 43 | }); 44 | } 45 | return this.server; 46 | } 47 | 48 | async ping(): Promise { 49 | try { 50 | await this.getServer().getDirectoryContents("/"); 51 | return true; 52 | } catch {} 53 | return false; 54 | } 55 | 56 | async list(options: IBackupFileListOption = {}): Promise { 57 | const retFileList: IBackupFileInfo[] = []; 58 | 59 | const fileList = (await this.getServer().getDirectoryContents("/", { 60 | glob: "*.zip", 61 | })) as FileStat[]; 62 | fileList.forEach((item) => { 63 | retFileList.push({ 64 | filename: item.basename, 65 | path: item.filename, 66 | size: item.size, 67 | time: +new Date(item.lastmod), 68 | } as IBackupFileInfo); 69 | }); 70 | 71 | return localSort(retFileList, options); 72 | } 73 | 74 | async addFile(fileName: string, file: IBackupData): Promise { 75 | const fileBlob = await this.backupDataToJSZipBlob(file); 76 | const fileBuffer = (await new Promise((resolve) => { 77 | const fr = new FileReader(); 78 | fr.onload = function () { 79 | resolve(this.result as ArrayBuffer); 80 | }; 81 | fr.readAsArrayBuffer(fileBlob); 82 | })) as ArrayBuffer; 83 | 84 | return await this.getServer().putFileContents(fileName, fileBuffer); 85 | } 86 | 87 | async getFile(path: string): Promise { 88 | const fileBuffer = await this.getServer().getFileContents(`/${path}`); 89 | const data = new Blob([fileBuffer as Buffer]); 90 | 91 | return await this.jsZipBlobToBackupData(data); 92 | } 93 | 94 | async deleteFile(path: string): Promise { 95 | await this.getServer().deleteFile(`/${path}`); 96 | return true; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/packages/backupServer/index.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import { cloneDeep } from "es-toolkit"; 3 | 4 | import type AbstractBackupServer from "./AbstractBackupServer.ts"; 5 | import type { IBackupConfig, IBackupMetadata } from "./type"; 6 | 7 | export * from "./type"; 8 | 9 | interface backupServerEntity { 10 | default: AbstractBackupServer; 11 | serverConfig: IBackupConfig; 12 | serverMetaData: IBackupMetadata; 13 | } 14 | 15 | const requireContext = import.meta.glob("./entity/*.ts"); 16 | 17 | export const entityList = Object.keys(requireContext).map((value: string) => { 18 | return value.replace(/^\.\/entity\//, "").replace(/\.ts$/, ""); 19 | }) as string[]; 20 | 21 | async function getServerModule(configType: string): Promise { 22 | return await requireContext[`./entity/${configType}.ts`](); 23 | } 24 | 25 | export async function getBackupServerMetaData(configType: string): Promise> { 26 | return (await getServerModule(configType)).serverMetaData; 27 | } 28 | 29 | export async function getBackupServerDefaultConfig(configType: string): Promise { 30 | const config = cloneDeep((await getServerModule(configType)).serverConfig); 31 | 32 | config.id = nanoid(); 33 | 34 | return config; 35 | } 36 | 37 | export async function getBackupServer(config: IBackupConfig): Promise> { 38 | const ServerClass = (await getServerModule(config.type)).default; 39 | 40 | // @ts-ignore 41 | return new ServerClass(config); 42 | } 43 | 44 | export function getBackupServerIcon(type: string) { 45 | return `/icons/backupServer/${type}.png`; 46 | } 47 | -------------------------------------------------------------------------------- /src/packages/backupServer/type.ts: -------------------------------------------------------------------------------- 1 | export interface IBackupConfig { 2 | id?: string; 3 | type: string; 4 | name: string; 5 | 6 | config: Record; 7 | } 8 | 9 | export interface IBackupMetadata { 10 | description?: string; // 客户端介绍 11 | requiredField: { 12 | name?: `i18n.${string}` | string; // 显示名称,可以是一个 vue-i18n 键值,如果缺失,则直接显示为 key 的值 13 | key: keyof T["config"]; 14 | type: "strings" /* textarea */ | "string" /* input */ | "boolean" /* switch */; 15 | description?: string; 16 | }[]; 17 | } 18 | 19 | export interface IBackupFileInfo { 20 | filename: string; 21 | path: string; 22 | time: number; 23 | size: number | "N/A"; // 文件大小 N/A 表示后端在 list 时不支持 24 | } 25 | 26 | export enum EListOrderBy { 27 | time = "time", 28 | name = "name", 29 | size = "size", 30 | } 31 | 32 | export enum EListOrderMode { 33 | desc = "desc", 34 | asc = "asc", 35 | } 36 | 37 | export interface IBackupFileListOption { 38 | orderBy?: EListOrderBy; 39 | orderMode?: EListOrderMode; 40 | } 41 | 42 | export interface IBackupFileManifest { 43 | time: number; 44 | version: string; 45 | encryption: boolean; 46 | files: Record; 47 | 48 | [key: string]: any; 49 | } 50 | 51 | export interface IBackupData { 52 | manifest?: Partial; 53 | 54 | cookies?: Record; 55 | 56 | [key: string]: any; 57 | } 58 | -------------------------------------------------------------------------------- /src/packages/downloader/index.ts: -------------------------------------------------------------------------------- 1 | import { AbstractBittorrentClient, type DownloaderBaseConfig, type TorrentClientMetaData } from "./types"; 2 | import { cloneDeep } from "es-toolkit"; 3 | 4 | export * from "./types"; 5 | export { getRemoteTorrentFile } from "./utils"; 6 | 7 | interface downloaderEntity { 8 | default: AbstractBittorrentClient; 9 | clientConfig: DownloaderBaseConfig; 10 | clientMetaData: TorrentClientMetaData; 11 | } 12 | 13 | export const requireContext = import.meta.glob("./entity/*.ts"); 14 | export const entityList = Object.keys(requireContext).map((value: string) => { 15 | return value.replace(/^\.\/entity\//, "").replace(/\.ts$/, ""); 16 | }) as string[]; 17 | 18 | // 从 requireContext 中获取对应模块 19 | export async function getDownloaderModule(configType: string): Promise { 20 | return await requireContext[`./entity/${configType}.ts`](); 21 | } 22 | 23 | export async function getDownloaderDefaultConfig(type: string): Promise { 24 | const config = cloneDeep((await getDownloaderModule(type)).clientConfig); 25 | // 填入/覆盖 缺失项 26 | config.feature ??= {}; 27 | config.feature.DefaultAutoStart ??= false; 28 | 29 | return config; 30 | } 31 | 32 | export async function getDownloaderMetaData(type: string): Promise { 33 | return cloneDeep((await getDownloaderModule(type)).clientMetaData); 34 | } 35 | 36 | export async function getDownloader(config: DownloaderBaseConfig): Promise { 37 | const DownloaderClass = (await getDownloaderModule(config.type)).default; 38 | 39 | // @ts-ignore 40 | return new DownloaderClass(config); 41 | } 42 | 43 | export function getDownloaderIcon(type: string) { 44 | return `/icons/downloader/${type}.png`; 45 | } 46 | -------------------------------------------------------------------------------- /src/packages/downloader/utils.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from "buffer"; 2 | import axios, { AxiosRequestConfig } from "axios"; 3 | import parseTorrent, { Instance as TorrentInstance } from "parse-torrent"; 4 | import isValidFilename from "valid-filename"; 5 | import { decode } from "urlencode"; 6 | 7 | export interface ParsedTorrent { 8 | name: string; 9 | metadata: { 10 | arraybuffer: ArrayBuffer; 11 | buffer: Buffer; 12 | blob: () => Blob; 13 | base64: () => string; 14 | }; 15 | info: TorrentInstance; 16 | } 17 | 18 | const utf8FilenameRegex = /filename\*=UTF-8''([\w%\-\.]+)(?:; ?|$)/i; 19 | const asciiFilenameRegex = /^filename=(["']?)(.*?[^\\])\1(?:; ?|$)/i; 20 | 21 | const magnetUriV1Pattern = /xt(?:\.1)?=urn:btih:(?[a-z0-9]{32}(?:[a-z0-9]{8})?)/i; 22 | const magnetUriV2Pattern = /xt(?:\.1)?=urn:btmh:1220(?[a-z0-9]{64})/i; 23 | 24 | export function extractMagnetHash(magnetUri: string): string | null { 25 | // 先尝试使用 v1 模式匹配 26 | const v1Match = magnetUri.match(magnetUriV1Pattern); 27 | if (v1Match) { 28 | return v1Match.groups?.hash || null; 29 | } 30 | // 若 v1 匹配失败,再尝试使用 v2 模式匹配 31 | const v2Match = magnetUri.match(magnetUriV2Pattern); 32 | return v2Match?.groups?.hash || null; 33 | } 34 | 35 | export async function getRemoteTorrentFile(options: AxiosRequestConfig = {}): Promise { 36 | const req = await axios.request({ 37 | ...options, 38 | responseType: "arraybuffer", // 统一以 ArrayBuffer 形式获取,方便后面转化 39 | }); 40 | 41 | /** 42 | * 如果服务器设置了 content-type 响应头, 43 | * 但响应头值不是 application/x-bittorrent 或 application/octet-stream, 44 | * 则我们认为非正常的种子: 45 | */ 46 | if (req.headers["content-type"] && !/octet-stream|x-bittorrent/gi.test(req.headers["content-type"])) { 47 | throw new Error("Invalid Torrent From Server"); 48 | } 49 | 50 | // 将获取到的 ArrayBuffer 转成 Buffer 51 | const metaDataBuffer = Buffer.from(req.data, "binary"); 52 | const parsedInfo = (await parseTorrent(metaDataBuffer)) as TorrentInstance; 53 | 54 | /** 55 | * 设置种子名字 56 | * 如果服务器显式设置 content-disposition 头,则我们尊重服务器设置 57 | * 不然,文件名会被设置为解析后的种子名,缺省为 `1.torrent` 58 | */ 59 | let torrentName = parsedInfo.name || "1.torrent"; 60 | 61 | const disposition: string | null = req.headers["content-disposition"]; 62 | if (disposition && disposition.includes("filename")) { 63 | let dispositionName = ""; 64 | if (utf8FilenameRegex.test(disposition)) { 65 | dispositionName = decode(utf8FilenameRegex.exec(disposition)![1]); 66 | } else { 67 | // prevent ReDos attacks by anchoring the ascii regex to string start and 68 | // slicing off everything before 'filename=' 69 | const filenameStart = disposition.toLowerCase().indexOf("filename="); 70 | if (filenameStart >= 0) { 71 | const partialDisposition = disposition.slice(filenameStart); 72 | const matches = asciiFilenameRegex.exec(partialDisposition); 73 | if (matches != null && matches[2]) { 74 | dispositionName = decode(matches[2], "ascii"); // 按照规范使用 ascii 转换 75 | } 76 | } 77 | } 78 | 79 | /** 80 | * hdsky 返回 filename="xxxxxxx.torrent" ; charset=utf-8 需要额外处理,同时此处包含了 trim 81 | * 注意,由于上面对该情况使用 ascii 转换,这样仍然会导致文件名出现异常 82 | */ 83 | dispositionName = dispositionName.replace(/^[ "']+/, "").replace(/[ "']+$/, ""); 84 | // 检查 dispositionName 是否合法 85 | if (isValidFilename(dispositionName)) torrentName = dispositionName; 86 | } 87 | 88 | if (!/\.torrent$/i.test(torrentName)) { 89 | torrentName = `${torrentName}.torrent`; 90 | } 91 | 92 | return { 93 | name: torrentName, 94 | metadata: { 95 | arraybuffer: req.data, 96 | buffer: metaDataBuffer, 97 | base64: () => metaDataBuffer.toString("base64"), 98 | blob: () => new Blob([req.data], { type: "application/x-bittorrent" }), 99 | }, 100 | info: parsedInfo, 101 | } as ParsedTorrent; 102 | } 103 | -------------------------------------------------------------------------------- /src/packages/mediaServer/index.ts: -------------------------------------------------------------------------------- 1 | import { AbstractMediaServer, IMediaServerBaseConfig, IMediaServerMetadata } from "./types"; 2 | import { cloneDeep } from "es-toolkit"; 3 | 4 | export * from "./types"; 5 | 6 | interface MediaServerEntity { 7 | default: AbstractMediaServer; 8 | mediaServerConfig: IMediaServerBaseConfig; 9 | mediaServerMetaData: IMediaServerMetadata; 10 | } 11 | 12 | export const requireContext = import.meta.glob("./entity/*.ts"); 13 | export const entityList = Object.keys(requireContext).map((value: string) => { 14 | return value.replace(/^\.\/entity\//, "").replace(/\.ts$/, ""); 15 | }) as string[]; 16 | 17 | // 从 requireContext 中获取对应模块 18 | export async function getMediaServerModule(configType: string): Promise { 19 | return await requireContext[`./entity/${configType}.ts`](); 20 | } 21 | 22 | export async function getMediaServerMetaData(type: string): Promise { 23 | return cloneDeep((await getMediaServerModule(type)).mediaServerMetaData); 24 | } 25 | 26 | export async function getMediaServerDefaultConfig(type: string): Promise { 27 | const config = cloneDeep((await getMediaServerModule(type)).mediaServerConfig); 28 | // 填入/覆盖 缺失项 29 | config.defaultSearchExtraRequestConfig ??= { params: {} }; 30 | 31 | return config; 32 | } 33 | 34 | export async function getMediaServer(config: IMediaServerBaseConfig): Promise { 35 | const mediaServerClass = (await getMediaServerModule(config.type)).default; 36 | 37 | // @ts-ignore 38 | return new mediaServerClass(config); 39 | } 40 | 41 | export function getMediaServerIcon(type: string) { 42 | return `/icons/mediaServer/${type}.png`; 43 | } 44 | -------------------------------------------------------------------------------- /src/packages/mediaServer/types.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from "axios"; 2 | import { EResultParseStatus } from "@ptd/site"; 3 | 4 | export type IMediaServerId = string; 5 | export type TAuthType = "user" | "apikey"; 6 | 7 | export interface IMediaServerBaseConfig { 8 | // 系统使用这个信息判断并生成唯一的客户端 9 | id?: IMediaServerId; 10 | // 客户端类型,与文件名相同 11 | type: string; 12 | // 客户端名称,用于用户辨识 13 | name: string; 14 | 15 | // 媒体服务器地址 16 | address: string; 17 | // 媒体服务器认证方式 18 | auth: Record; 19 | // 媒体服务器请求超时 20 | timeout?: number; 21 | 22 | defaultSearchExtraRequestConfig?: AxiosRequestConfig; // 默认的搜索请求配置 23 | 24 | [key: string]: any; 25 | } 26 | 27 | export interface IMediaServerMetadata { 28 | // 客户端介绍 29 | description?: string; 30 | // 用于配置时显示的警告信息,要用于一些特殊提示 31 | warning?: string[]; 32 | /** 33 | * 该客户端的认证的字段,对应字段会被放入 config.auth 中 34 | */ 35 | auth_field: Array< 36 | | string // 等同于 { name: string; required: true } 37 | // name: 字段名称 required: 是否必填 message: 提示信息 38 | | { name: string; required: boolean; message?: string } 39 | >; 40 | } 41 | 42 | export interface IMediaServerItem { 43 | // 所在的媒体服务器id 44 | server: IMediaServerId; 45 | // 名称 46 | name: string; 47 | // 对应服务器浏览地址 48 | url: string; 49 | 50 | // 媒体信息 51 | type: "Movie" | string; // 影片类型 52 | description?: string; // 影片描述 53 | format?: string; // 容器格式 如 MP4, MKV 54 | size?: number; // 文件大小 55 | duration?: number; // 时长 56 | poster?: string; // 封面图 57 | tags?: Array<{ name: string; url?: string }>; // 标签 58 | rating?: number | "-"; // 评分 59 | 60 | streams?: { 61 | title: string; 62 | type: "Video" | "Audio" | "Subtitle" | string; // 流类型 63 | format: string; // 流格式 64 | }[]; 65 | 66 | // 用户对该媒体的情况 67 | user: { 68 | IsFavorite?: boolean; // 是否喜欢 69 | IsPlayed?: boolean; // 是否已播放过 70 | }; 71 | 72 | // 服务器的原始返回 73 | raw: RAW; 74 | } 75 | 76 | export interface IMediaServerSearchOptions { 77 | startIndex?: number; // 起始索引 78 | limit?: number; // 限制数量 79 | } 80 | 81 | export interface IMediaServerSearchResult { 82 | status: EResultParseStatus; // 状态码 83 | 84 | // 搜索结果 85 | items: IMediaServerItem[]; 86 | options?: IMediaServerSearchOptions; 87 | } 88 | 89 | export abstract class AbstractMediaServer { 90 | readonly config: T; 91 | 92 | protected constructor(options: T) { 93 | this.config = options as T; 94 | } 95 | 96 | // 检查客户端是否可以连接 97 | public abstract ping(): Promise; 98 | 99 | /** 100 | * 获取搜索数据 101 | * 注意:我们对 keywords 同样约定了 ${advanceField}|${keywords} 的高级搜索方式,但不同的服务器进行实现不一致 102 | */ 103 | public abstract getSearchResult( 104 | keywords?: string, 105 | options?: IMediaServerSearchOptions, 106 | ): Promise; 107 | } 108 | -------------------------------------------------------------------------------- /src/packages/site/definitions/agsvpt.ts: -------------------------------------------------------------------------------- 1 | import { type ISiteMetadata } from "../types"; 2 | import { SchemaMetadata } from "../schemas/NexusPHP"; 3 | 4 | export const siteMetadata: ISiteMetadata = { 5 | ...SchemaMetadata, 6 | version: 1, 7 | id: "agsvpt", 8 | name: "AGSVPT", 9 | aka: ["末日种子库"], 10 | description: "Arctic Global Seed Vault", // 站点说明 11 | tags: ["综合", "短剧", "影视"], 12 | timezoneOffset: "+0800", 13 | collaborator: ["0X0000005"], 14 | type: "private", 15 | schema: "NexusPHP", 16 | urls: ["aHR0cHM6Ly9wdC5hZ3N2cHQuY24=", "aHR0cHM6Ly93d3cuYWdzdnB0LmNvbQ==", "aHR0cHM6Ly9uZXcuYWdzdnB0LmNu"], 17 | userInfo: { 18 | ...SchemaMetadata.userInfo!, 19 | selectors: { 20 | ...SchemaMetadata.userInfo!.selectors!, 21 | bonus: { 22 | selector: ["td.rowhead:contains('冰晶') + td, td.rowhead:contains('Karma Points') + td"], 23 | filters: [{ name: "parseNumber" }], 24 | }, 25 | }, 26 | }, 27 | levelRequirements: [ 28 | { 29 | id: 1, 30 | name: "Power User(北冰珍珠熊)", 31 | downloaded: "50GB", 32 | ratio: 1.05, 33 | seedingBonus: 40000, 34 | privilege: "可以进入银行贷款", 35 | }, 36 | { 37 | id: 2, 38 | name: "Elite User(深渊蔚蓝熊)", 39 | interval: "P8W", 40 | downloaded: "120GB", 41 | ratio: 1.55, 42 | seedingBonus: 80000, 43 | }, 44 | { 45 | id: 3, 46 | name: "Crazy User(翡翠森林熊)", 47 | interval: "P12W", 48 | downloaded: "300GB", 49 | ratio: 2.05, 50 | seedingBonus: 150000, 51 | }, 52 | { 53 | id: 4, 54 | name: "Insane User(神秘紫晶熊)", 55 | interval: "P20W", 56 | downloaded: "500GB", 57 | ratio: 2.55, 58 | seedingBonus: 400000, 59 | privilege: "查看普通日志", 60 | }, 61 | { 62 | id: 5, 63 | name: "Veteran User(寒冰白金熊)", 64 | interval: "P28W", 65 | downloaded: "750GB", 66 | ratio: 4.05, 67 | seedingBonus: 800000, 68 | privilege: "永远保留账号;查看其它用户的评论、帖子历史", 69 | }, 70 | { 71 | id: 6, 72 | name: "Extreme User(皇家金辉熊)", 73 | interval: "P40W", 74 | downloaded: "1TB", 75 | ratio: 5.05, 76 | seedingBonus: 1400000, 77 | }, 78 | { 79 | id: 7, 80 | name: "Ultimate User(永恒铂金熊)", 81 | interval: "P52W", 82 | downloaded: "1.5TB", 83 | ratio: 6.05, 84 | seedingBonus: 2200000, 85 | privilege: "首次升级至此等级的用户将获得2个邀请名额。", 86 | }, 87 | { 88 | id: 8, 89 | name: "Nexus Master(钻石之冠北极熊)", 90 | interval: "P70W", 91 | downloaded: "3TB", 92 | ratio: 7.05, 93 | seedingBonus: 3200000, 94 | privilege: "首次升级至此等级的用户将获得2个邀请名额。", 95 | }, 96 | ], 97 | }; 98 | -------------------------------------------------------------------------------- /src/packages/site/definitions/hdchina.ts: -------------------------------------------------------------------------------- 1 | import type { ISiteMetadata } from "../types"; 2 | 3 | export const siteMetadata: ISiteMetadata = { 4 | version: 1, 5 | id: "hdchina", 6 | 7 | name: "HDChina", 8 | description: "高清影音人士分享乐园", 9 | tags: ["影视", "音乐", "纪录片", "综合"], 10 | timezoneOffset: "+0800", 11 | collaborator: ["IITII"], 12 | favicon: "./hdchina.jpg", 13 | 14 | type: "private", 15 | schema: "NexusPHP", 16 | 17 | urls: ["https://hdchina.org/"], 18 | 19 | isDead: true, 20 | 21 | levelRequirements: [ 22 | { 23 | id: 1, 24 | name: "Power User", 25 | interval: "P5W", 26 | downloaded: "200GB", 27 | ratio: 1.5, 28 | privilege: "可以使用道具,可以打开签名和个性化称号", 29 | }, 30 | { 31 | id: 2, 32 | name: "Elite User", 33 | interval: "P10W", 34 | downloaded: "500GB", 35 | ratio: 2.0, 36 | privilege: "可以在候选区投票,可以在论坛建议区发帖,可以上传字幕,可以删除自己上传的字幕。", 37 | }, 38 | { 39 | id: 3, 40 | name: "Crazy User", 41 | interval: "P15W", 42 | downloaded: "1TB", 43 | ratio: 2.5, 44 | privilege: "可以进入邀请区。", 45 | }, 46 | { 47 | id: 4, 48 | name: "Insane User", 49 | interval: "P20W", 50 | downloaded: "1.5TB", 51 | ratio: 3.0, 52 | privilege: "并可以直接发布种子,无需候选。", 53 | }, 54 | { 55 | id: 5, 56 | name: "Veteran User", 57 | interval: "P25W", 58 | downloaded: "2TB", 59 | ratio: 4.0, 60 | privilege: "可以在个人资料内隐藏个人信息,可以匿名做种。", 61 | }, 62 | { 63 | id: 6, 64 | name: "Extreme User", 65 | interval: "P30W", 66 | downloaded: "3TB", 67 | ratio: 5.0, 68 | privilege: "发送邀请,可以查看其它会员种子历史,可以更新IMDb信息。", 69 | }, 70 | { 71 | id: 7, 72 | name: "Ultimate User", 73 | interval: "P40W", 74 | downloaded: "4TB", 75 | ratio: 6.0, 76 | privilege: "账号挂起永久保留。取消一个月只能发送一个邀请的限制。", 77 | }, 78 | { 79 | id: 8, 80 | name: "Nexus Master", 81 | interval: "P50W", 82 | downloaded: "5TB", 83 | ratio: 8.0, 84 | privilege: "账号永久保存(无需挂起)", 85 | }, 86 | ], 87 | }; 88 | -------------------------------------------------------------------------------- /src/packages/site/definitions/hdtime.ts: -------------------------------------------------------------------------------- 1 | import { type ISiteMetadata } from "../types"; 2 | import { SchemaMetadata } from "../schemas/NexusPHP.ts"; 3 | 4 | export const siteMetadata: ISiteMetadata = { 5 | ...SchemaMetadata, 6 | 7 | version: 1, 8 | id: "hdtime", 9 | name: "HDTime", 10 | description: "HDTime, time to forever!", 11 | tags: ["影视", "综合"], 12 | 13 | type: "private", 14 | schema: "NexusPHP", 15 | 16 | urls: ["https://hdtime.org/"], 17 | 18 | category: [ 19 | { 20 | name: "类别", 21 | key: "cat", 22 | options: [ 23 | { value: 401, name: "电影" }, 24 | { value: 424, name: "Blu-Ray原盘" }, 25 | { value: 402, name: "剧集" }, 26 | { value: 403, name: "综艺" }, 27 | { value: 405, name: "动漫" }, 28 | { value: 414, name: "软件" }, 29 | { value: 407, name: "体育" }, 30 | { value: 404, name: "纪录片" }, 31 | { value: 406, name: "MV" }, 32 | { value: 408, name: "音乐" }, 33 | { value: 410, name: "游戏" }, 34 | { value: 411, name: "文档" }, 35 | { value: 409, name: "其他" }, 36 | ], 37 | cross: { mode: "append" }, 38 | }, 39 | ], 40 | 41 | levelRequirements: [ 42 | { 43 | id: 1, 44 | name: "User", 45 | privilege: `新用户的默认级别。`, 46 | }, 47 | { 48 | id: 2, 49 | name: "感冒(Power User)", 50 | interval: "P4W", 51 | downloaded: "50GB", 52 | ratio: 1.05, 53 | seedingBonus: 40000, 54 | privilege: 55 | "得到一个邀请名额;可以直接发布种子;可以查看NFO文档;可以查看用户列表;可以请求续种; 可以发送邀请;" + 56 | ' 可以查看排行榜;可以查看其它用户的种子历史(如果用户隐私等级未设置为"强"); 可以删除自己上传的字幕。', 57 | }, 58 | { 59 | id: 3, 60 | name: "发热(Elite User)", 61 | interval: "P8W", 62 | downloaded: "150GB", 63 | ratio: 1.55, 64 | seedingBonus: 80000, 65 | privilege: "Elite User及以上用户封存账号后不会被删除。", 66 | }, 67 | { 68 | id: 4, 69 | name: "低烧(Crazy User)", 70 | interval: "P15W", 71 | downloaded: "500GB", 72 | ratio: 2.05, 73 | seedingBonus: 150000, 74 | privilege: "得到两个邀请名额;可以在做种/下载/发布的时候选择匿名模式。", 75 | }, 76 | { 77 | id: 5, 78 | name: "中烧(Insane User)", 79 | interval: "P25W", 80 | downloaded: "750GB", 81 | ratio: 2.55, 82 | seedingBonus: 250000, 83 | privilege: "可以查看普通日志。", 84 | }, 85 | { 86 | id: 6, 87 | name: "高烧(Veteran User)", 88 | interval: "P40W", 89 | downloaded: "1.5TB", 90 | ratio: 3.05, 91 | seedingBonus: 400000, 92 | privilege: 93 | "免除增量考核;得到三个邀请名额;可以查看其它用户的评论、帖子历史。Veteran User及以上用户会永远保留账号。", 94 | }, 95 | { 96 | id: 7, 97 | name: "烧糊涂(Extreme User)", 98 | interval: "P60W", 99 | downloaded: "3TB", 100 | ratio: 3.55, 101 | seedingBonus: 600000, 102 | privilege: "可以更新过期的外部信息;可以查看Extreme User论坛。", 103 | }, 104 | { 105 | id: 8, 106 | name: "走火入魔(Ultimate User)", 107 | interval: "P80W", 108 | downloaded: "5TB", 109 | ratio: 4.05, 110 | seedingBonus: 800000, 111 | privilege: "得到五个邀请名额。", 112 | }, 113 | { 114 | id: 9, 115 | name: "骨灰(HDtime Master)", 116 | interval: "P100W", 117 | downloaded: "10TB", 118 | ratio: 4.55, 119 | seedingBonus: 1000000, 120 | privilege: "得到十个邀请名额。", 121 | }, 122 | ], 123 | }; 124 | -------------------------------------------------------------------------------- /src/packages/site/definitions/joyhd.ts: -------------------------------------------------------------------------------- 1 | import { type ISiteMetadata } from "../types"; 2 | import { CategoryInclbookmarked, CategorySpstate, SchemaMetadata } from "../schemas/NexusPHP.ts"; 3 | 4 | export const siteMetadata: ISiteMetadata = { 5 | ...SchemaMetadata, 6 | 7 | version: 1, 8 | id: "joyhd", 9 | name: "JoyHD", 10 | description: "JoyHD成立於2013年,發佈藍光原碟,藍光DIY和原抓音樂。", 11 | tags: ["影视", "综合"], 12 | 13 | collaborator: ["ylxb2016"], 14 | 15 | type: "private", 16 | schema: "NexusPHP", 17 | 18 | urls: ["https://www.joyhd.net/"], 19 | 20 | category: [ 21 | { 22 | name: "类别", 23 | key: "cat", 24 | options: [ 25 | { name: "Movie", value: 401 }, 26 | { name: "TV Series", value: 402 }, 27 | { name: "Entertainment", value: 403 }, 28 | { name: "Anime", value: 405 }, 29 | { name: "Music", value: 414 }, 30 | { name: "Sport", value: 407 }, 31 | { name: "Documentaries", value: 404 }, 32 | { name: "MV", value: 406 }, 33 | { name: "Software", value: 408 }, 34 | { name: "Game", value: 410 }, 35 | { name: "e-Learn", value: 411 }, 36 | { name: "Other", value: 409 }, 37 | ], 38 | cross: { mode: "append" }, 39 | }, 40 | CategorySpstate, 41 | CategoryInclbookmarked, 42 | ], 43 | 44 | userInfo: { 45 | ...SchemaMetadata.userInfo!, 46 | selectors: { 47 | ...SchemaMetadata.userInfo!.selectors!, 48 | bonus: { 49 | selector: ["td.rowhead:contains('银元') + td"], 50 | filters: [{ name: "parseNumber" }], 51 | }, 52 | }, 53 | }, 54 | 55 | levelRequirements: [ 56 | { 57 | id: 1, 58 | name: "User", 59 | privilege: `新用户的默认级别。`, 60 | }, 61 | { 62 | id: 2, 63 | name: "正兵/Power User", 64 | interval: "P4W", 65 | downloaded: "50GB", 66 | ratio: 1.2, 67 | privilege: 68 | "可以直接发布种子;可以查看NFO文档;可以查看用户列表;" + 69 | "可以查看其它用户的种子历史(如果用户隐私等级未设置为“强”); 可以删除自己上传的字幕。", 70 | }, 71 | { 72 | id: 3, 73 | name: "军士/Elite User", 74 | interval: "P8W", 75 | downloaded: "100GB", 76 | ratio: 1.5, 77 | privilege: "军士及以上用户封存账号后不会被删除; 可以发送邀请;可以请求续种。", 78 | }, 79 | { 80 | id: 4, 81 | name: "副军校/Crazy User", 82 | interval: "P15W", 83 | downloaded: "200GB", 84 | ratio: 2.5, 85 | privilege: "可以在做种/下载/发布的时候选择匿名模式。", 86 | }, 87 | { 88 | id: 5, 89 | name: "正军校/Insane User", 90 | interval: "P25W", 91 | downloaded: "400GB", 92 | ratio: 3.5, 93 | privilege: "可以查看普通日志。得到一个邀请名额。", 94 | }, 95 | { 96 | id: 6, 97 | name: "副参领/Veteran User", 98 | interval: "P25W", 99 | downloaded: "600GB", 100 | ratio: 4.5, 101 | privilege: "可以查看其它用户的评论、帖子历史。副参领及以上用户会永远保留账号。得到二个邀请名额。", 102 | }, 103 | { 104 | id: 7, 105 | name: "正参领/Extreme User", 106 | interval: "P25W", 107 | downloaded: "1000GB", 108 | ratio: 5.5, 109 | privilege: "可以更新过期的外部信息;得到二个邀请名额。", 110 | }, 111 | { 112 | id: 8, 113 | name: "副都统/Ultimate User", 114 | interval: "P30W", 115 | downloaded: "2000GB", 116 | ratio: 6.0, 117 | privilege: "得到三个邀请名额。", 118 | }, 119 | { 120 | id: 9, 121 | name: "大将军/Nexus Master", 122 | interval: "P50W", 123 | downloaded: "5000GB", 124 | ratio: 6.0, 125 | privilege: "得到五个邀请名额。", 126 | }, 127 | ], 128 | }; 129 | -------------------------------------------------------------------------------- /src/packages/site/types.ts: -------------------------------------------------------------------------------- 1 | export * from "./types/base"; 2 | export * from "./types/torrent"; 3 | export * from "./types/userinfo"; 4 | export * from "./types/search"; 5 | export * from "./types/site"; 6 | -------------------------------------------------------------------------------- /src/packages/site/types/base.ts: -------------------------------------------------------------------------------- 1 | // Error classes 2 | export class NeedLoginError extends Error {} 3 | export class NoTorrentsError extends Error {} 4 | 5 | export type TSiteID = string; // should match regexp /[0-9a-z]+/ 6 | export type TSiteHost = string; 7 | 8 | export type TSiteFullUrl = `${"http" | "https"}://${TSiteHost}/`; 9 | export type TSiteFullUrlProtect = `aHR0c${string}`; // btoa('http') -> "aHR0cA==" 10 | export type TSiteUrl = TSiteFullUrl | TSiteFullUrlProtect; 11 | 12 | /** 13 | * 解析状态 14 | */ 15 | export enum EResultParseStatus { 16 | unknownError, // 未知错误 17 | waiting, // 队列等待中 18 | working, // 正在搜索中 19 | success, // 搜索成功 20 | parseError, // 解析错误 21 | passParse, // 跳过解析 22 | needLogin, // 需要登录 23 | noResults, // 等同于原先的 noTorrents 和 torrentTableIsEmpty ,这两个在结果上没有区别 24 | } 25 | -------------------------------------------------------------------------------- /src/packages/site/types/torrent.ts: -------------------------------------------------------------------------------- 1 | import type { TAdvanceSearchKeyword } from "@ptd/site"; 2 | import type { TSiteID } from "./base"; 3 | 4 | // 种子当前状态 5 | export enum ETorrentStatus { 6 | unknown, // 状态不明 7 | downloading, // 正在下载 8 | seeding, // 正在做种 9 | inactive, // 未活动(曾经下载过,但未完成) 10 | completed, // 已完成,未做种, 旧版值 255 11 | } 12 | 13 | // 比较基础的种子 Tag 14 | export type TBaseTorrentTagName = 15 | | "Free" // 免费下载 "blue", 16 | | "2xFree" // 免费下载 + 2x 上传 "green", 17 | | "2xUp" // 2x 上传 "lime", 18 | | "2x50%" // 2x 上传 + 50% 下载 "light-green", 19 | | "25%" // 25% 下载 "purple", 20 | | "30%" // 30% 下载 "indigo", 21 | | "35%" // 35% 下载 "indigo-darken-3", 22 | | "50%" // 50% 下载 "orange", 23 | | "70%" // 70% 下载 "blue-grey", 24 | | "75%" // 75% 下载 "lime-darken-3", 25 | | "VIP" // 仅 VIP 可下载 "orange-darken-2", 26 | | "Excl." // 禁止转载 "deep-orange-darken-1", 27 | | string; 28 | 29 | export interface ITorrentTag { 30 | name: TBaseTorrentTagName; 31 | color?: string; 32 | } 33 | 34 | // 作为一个种子最基本应该有的属性 35 | export interface ITorrent { 36 | site: TSiteID; // 所在站点id 37 | 38 | id: number | string; // 该种子id 39 | title: string; // 主标题 40 | subTitle?: string; // 次标题 41 | 42 | /** 43 | * 特别注意: link 和 url 两个的含义在 ptpp 和 ptd 中是完全相反的 44 | * url: detail 页面链接 45 | * link: 种子下载链接,特别的: 46 | * - 对于 PT站点 应该是种子的链接 47 | * - 对于 BT站点 应尽可能为种子链接,只有不存在种子链接或种子链接经过某些种子生成站时,才使用 magnet 链接 48 | */ 49 | url?: string; 50 | link?: string; 51 | 52 | time?: number; // 发布时间戳(毫秒级) 53 | size?: number; // 大小 54 | author?: number | string; // 发布人 55 | 56 | seeders?: number; // 上传数量 57 | leechers?: number; // 下载数量 58 | completed?: number; // 完成数量 59 | comments?: number; // 评论数量 60 | 61 | category?: string | number; 62 | tags?: ITorrentTag[]; 63 | 64 | [key: `ext_${TAdvanceSearchKeyword}`]: string | number | null; // 外部资源id 65 | 66 | // 对于PT种子才 获取以下部分 67 | progress?: number | null; // 进度(100表示完成) 68 | status?: ETorrentStatus; // 状态 69 | } 70 | -------------------------------------------------------------------------------- /src/packages/site/types/userinfo.ts: -------------------------------------------------------------------------------- 1 | // noinspection ES6PreferShortImport 2 | 3 | import { type TSiteID, EResultParseStatus } from "./base"; 4 | import type { ITorrent } from "./torrent"; 5 | import type { isoDuration } from "../utils/datetime"; 6 | import type { TSize } from "../utils/filesize"; 7 | 8 | /** 9 | * user 组别 0-99 10 | * vip 组别 100-199 11 | * manager 组别 200-299 12 | */ 13 | export type TLevelId = number; 14 | export type TLevelName = string; 15 | export type TLevelGroupType = "user" | "vip" | "manager"; 16 | 17 | // 以下为对应等级需求,如果不指定的话,则表示不需要该需求 18 | export interface IImplicitUserInfo { 19 | interval?: isoDuration; // 需要等待的日期需求(ISO 8601 - 时间段表示法) 如 P5W 代表等待五周,P2M 代表等待二个月 20 | /** 21 | * 对 涉及体积的 其 number 类型的需求, 22 | * - 使用 utils/filesize 提供的单位明确真实 Byte 数值 23 | * - 使用 string 类型,如 "1.5 TB",会自动实现转换 24 | */ 25 | 26 | totalTraffic?: number | TSize; // 总流量需求 27 | downloaded?: number | TSize; // 下载量需求 28 | trueDownloaded?: number | TSize; // 真实下载量需求 29 | uploaded?: number | TSize; // 上传量需求 30 | trueUploaded?: number | TSize; // 真实上传量需求 31 | ratio?: number | [number, number]; // 分享率需求 32 | trueRatio?: number | [number, number]; // 真实分享率需求 33 | 34 | seeding?: number; // 做种数量需求 35 | seedingSize?: number | TSize; // 做种体积需求 36 | seedingTime?: number | isoDuration; // 做种时间(秒)需求,如果是 string 则类似 isoDuration,可以定义 30天 为 "30D" 37 | averageSeedingTime?: number | isoDuration; // 平均做种时间(秒)需求 38 | 39 | bonus?: number; // 魔力值/积分需求 40 | seedingBonus?: number; // 做种积分需求 41 | bonusPerHour?: number; // 魔力值/积分每小时需求 42 | 43 | /** 44 | * bonusNeededInterval 和 seedingBonusNeededInterval 是一个由 levelRequirementUnMet 计算得到的结果, 45 | * 用于表示 下一等级魔力差值与 bonusPerHour 相除的结果 46 | * 此处仅作示例,表示 getNextLevelUnMet 的结果中 可能会有这个键值,!!请不要在 levelRequirements 中定义该值!! 47 | */ 48 | // bonusNeededInterval?: `${number}H`; 49 | // seedingBonusNeededInterval?: `${number}H`; 50 | 51 | uploads?: number; // 发布种子数需求 52 | leeching?: number; // 下载数量需求 53 | snatches?: number; // 完成种子数需求 54 | posts?: number; // 发布帖子数需求 55 | 56 | hnrUnsatisfied?: number; // H&R 未满足的数量需求 57 | hnrPerWarning?: number; // H&R 预警 58 | 59 | [key: string]: any; // 其他需求 60 | } 61 | 62 | export const MinNonUserLevelId = 100; // 最大等级ID 63 | 64 | export interface ILevelRequirement extends IImplicitUserInfo { 65 | id: TLevelId; // 等级序列,应该是一个递增的序列,不可重复,应当小于 MaxUserLevelId - 1 66 | name: TLevelName; // 需要与 IUserInfo中对应的 levelName 相同 67 | groupType?: TLevelGroupType; // 等级组别,不指定的话,默认为 user 68 | privilege?: string; // 获得的特权说明 69 | 70 | alternative?: IImplicitUserInfo[]; // 可选要求 71 | } 72 | 73 | export interface IUserInfo extends Omit { 74 | status: EResultParseStatus; 75 | updateAt: number; // 更新时间 76 | site: TSiteID; 77 | 78 | id?: number | string; // 用户ID 79 | name?: string; // 用户名 80 | levelId?: TLevelId; // 等级ID 81 | levelName?: TLevelName; // 等级名称 82 | joinTime?: number; // 入站时间 83 | 84 | messageCount?: number; // 消息数量 85 | invites?: number; // 邀请数量 86 | avatar?: string; // 头像 87 | 88 | // 此处仅对变化项进行覆写,其他项不再累述 89 | totalTraffic?: number; // 总流量 90 | downloaded?: number; // 下载量 91 | trueDownloaded?: number; // 真实下载量 92 | uploaded?: number; // 上传量 93 | trueUploaded?: number; // 真实上传量 94 | ratio?: number; // 分享率 95 | trueRatio?: number; // 真实分享率 96 | seedingSize?: number; // 做种体积 97 | 98 | [key: string]: any; // 其他信息 99 | } 100 | 101 | export type IUserSeedingTorrent = Pick; 102 | 103 | export interface IUserSeedingInfo { 104 | torrents: IUserSeedingTorrent[]; // 只有 id 和 size 的种子信息 105 | updateAt: number; // 更新时间 106 | } 107 | -------------------------------------------------------------------------------- /src/packages/site/utils.ts: -------------------------------------------------------------------------------- 1 | export * from "./utils/html"; 2 | export * from "./utils/datetime"; 3 | export * from "./utils/filesize"; 4 | export * from "./utils/filter"; 5 | export * from "./utils/favicon"; 6 | export * from "./utils/level"; 7 | -------------------------------------------------------------------------------- /src/packages/site/utils/datetime.ts: -------------------------------------------------------------------------------- 1 | import { add, sub, parse, isValid, format, type DurationUnit } from "date-fns"; 2 | 3 | export type timezoneOffset = `${"UTC" | ""}${"-" | "+"}${number}`; 4 | export type isoDuration = `P${string}`; 5 | 6 | export const dateUnit: Array = [ 7 | "years", 8 | "quarters", 9 | "months", 10 | "weeks", 11 | "days", 12 | "hours", 13 | "minutes", 14 | "seconds", 15 | ] as const; 16 | 17 | export const nonStandDateUnitMap: Record<(typeof dateUnit)[number], string[]> = { 18 | years: ["年", "year", "yr"], 19 | quarters: ["季度", "quarter", "qtr"], 20 | months: ["月", "month", "mo"], 21 | weeks: ["周", "week", "wk"], 22 | days: ["天", "day"], 23 | hours: ["时", "hour", "hr"], 24 | minutes: ["分", "minute", "min"], 25 | seconds: ["秒", "second", "sec"], 26 | }; 27 | 28 | export function parseTimeToLive(ttl: string): number { 29 | // 处理原始字符串中的非标准Unit 30 | for (const [k, v] of Object.entries(nonStandDateUnitMap)) { 31 | for (const unit of v) { 32 | ttl = ttl.replace(unit, k); 33 | } 34 | } 35 | 36 | let nowDate = new Date(); 37 | dateUnit.forEach((v) => { 38 | const matched = ttl.match(new RegExp(`([.\\d]+) ?(${v}s?)`)); 39 | if (matched) { 40 | const amount = parseFloat(matched[1]); 41 | switch (v) { 42 | case "quarters": 43 | nowDate = sub(nowDate, { months: amount * 3 }); 44 | break; 45 | case "years": 46 | case "months": 47 | case "weeks": 48 | case "days": 49 | case "hours": 50 | case "minutes": 51 | case "seconds": 52 | nowDate = sub(nowDate, { [v]: amount }); 53 | break; 54 | } 55 | } 56 | }); 57 | 58 | return +nowDate; 59 | } 60 | 61 | export function parseValidTimeString(query: string, formatString: string[] = []): number | string { 62 | for (const f of [...formatString, "yyyy-MM-dd'T'HH:mm:ssXXX", "yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm:ss.SSS"]) { 63 | let time = parse(query, f, new Date()); 64 | if (isValid(time)) { 65 | return +time; 66 | } 67 | } 68 | return query; 69 | } 70 | 71 | export function parseTimeWithZone(time: number | string, timezoneOffset: timezoneOffset = "+0000"): number { 72 | let result = time; 73 | // 标准时间戳需要 * 1000 74 | if (/^(\d){10}$/.test(result + "")) { 75 | result = parseInt(result + "") * 1000; 76 | } 77 | // 时间格式按 ISO 8601 标准设置,如:2020-01-01T00:00:01+0800 78 | const datetime = format(new Date(result), "yyyy-MM-dd'T'HH:mm:ss"); 79 | return +new Date(`${datetime}${timezoneOffset}`); 80 | } 81 | 82 | export function convertIsoDurationToDate(duration: isoDuration, timestamp: number): number { 83 | let date = new Date(timestamp); 84 | const regex = /P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?/; 85 | const match = duration.match(regex); 86 | if (match) { 87 | const [, years, months, weeks, days, hours, minutes, seconds] = match; 88 | const timeDelta = { 89 | years: years ? parseInt(years, 10) : 0, 90 | months: months ? parseInt(months, 10) : 0, 91 | weeks: weeks ? parseInt(weeks, 10) : 0, 92 | days: days ? parseInt(days, 10) : 0, 93 | hours: hours ? parseInt(hours, 10) : 0, 94 | minutes: minutes ? parseInt(minutes, 10) : 0, 95 | seconds: seconds ? parseInt(seconds, 10) : 0, 96 | }; 97 | date = add(date, timeDelta); 98 | } 99 | return date.getTime(); 100 | } 101 | -------------------------------------------------------------------------------- /src/packages/site/utils/filesize.ts: -------------------------------------------------------------------------------- 1 | export const sizePattern = /^(\d*\.?\d+)(.*[^ZEPTGMK])?([ZEPTGMK](B|iB))s?$/i; 2 | 3 | export type TSizeUnit = `${"K" | "M" | "G" | "T" | "P" | "E" | "Z"}${"i" | ""}B`; 4 | export type TSize = `${number}${" " | ""}${TSizeUnit}`; 5 | 6 | export const KB = Math.pow(2, 10); 7 | export const MB = Math.pow(2, 20); 8 | export const GB = Math.pow(2, 30); 9 | export const TB = Math.pow(2, 40); 10 | export const PB = Math.pow(2, 50); 11 | export const EB = Math.pow(2, 60); 12 | export const ZB = Math.pow(2, 70); 13 | 14 | export function parseSizeString(size: string): number { 15 | size = size.replace(/,/g, ""); // 建议在传入前就替换掉,但是以防万一还是在这里再做一次替换 16 | const sizeRawMatch = size.match(sizePattern); 17 | if (sizeRawMatch) { 18 | const sizeNumber = parseFloat(sizeRawMatch[1]); 19 | const sizeType = sizeRawMatch[3]; 20 | switch (true) { 21 | case /Zi?B/i.test(sizeType): 22 | return sizeNumber * ZB; 23 | case /Ei?B/i.test(sizeType): 24 | return sizeNumber * EB; 25 | case /Pi?B/i.test(sizeType): 26 | return sizeNumber * PB; 27 | case /Ti?B/i.test(sizeType): 28 | return sizeNumber * TB; 29 | case /Gi?B/i.test(sizeType): 30 | return sizeNumber * GB; 31 | case /Mi?B/i.test(sizeType): 32 | return sizeNumber * MB; 33 | case /Ki?B/i.test(sizeType): 34 | return sizeNumber * KB; 35 | default: 36 | return sizeNumber; 37 | } 38 | } 39 | return 0; 40 | } 41 | -------------------------------------------------------------------------------- /src/packages/site/utils/html.ts: -------------------------------------------------------------------------------- 1 | import type { TSiteFullUrl, TSiteHost } from "../types"; 2 | 3 | /** 4 | * cloudflare Email 解码方法,来自 https://usamaejaz.com/cloudflare-email-decoding/ 5 | * @param {*} encodedString 6 | */ 7 | export function cfDecodeEmail(encodedString: string) { 8 | let email = ""; 9 | const r = parseInt(encodedString.slice(0, 2), 16); 10 | for (let n = 2; encodedString.length - n; n += 2) { 11 | const i = parseInt(encodedString.slice(n, 2), 16) ^ r; 12 | email += String.fromCharCode(i); 13 | } 14 | return email; 15 | } 16 | 17 | // From: https://stackoverflow.com/a/28899585/8824471 18 | export function extractContent(s: string): string { 19 | const span = document.createElement("span"); 20 | span.innerHTML = s; 21 | return span.textContent || span.innerText; 22 | } 23 | 24 | export function createDocument(str: string, type: DOMParserSupportedType = "text/html"): Document { 25 | return new DOMParser().parseFromString(str, type); 26 | } 27 | 28 | export function restoreSecureLink(url: string): TSiteFullUrl { 29 | return (url.startsWith("aHR0c") ? atob(url) : url) as TSiteFullUrl; 30 | } 31 | 32 | export function getHostFromUrl(url: string): TSiteHost { 33 | let host = url; 34 | try { 35 | const urlObj = new URL(url); 36 | host = urlObj.host; 37 | } catch (e) {} 38 | 39 | return host; 40 | } 41 | -------------------------------------------------------------------------------- /src/packages/social/entity/anidb.ts: -------------------------------------------------------------------------------- 1 | import type { IFetchSocialSiteInformationConfig, ISocialInformation } from "../types"; 2 | import axios from "axios"; 3 | import Sizzle from "sizzle"; 4 | import { uniq } from "es-toolkit"; 5 | 6 | export function build(id: string): string { 7 | return `https://anidb.net/anime/${id}`; 8 | } 9 | 10 | const anidbRegexList = [ 11 | /(?:https?:\/\/)?(?:www\.)?anidb\.net\/anime\/(\d+)/, 12 | /(?:https?:\/\/)?(?:www\.)?anidb\.net\/a(\d+)/, 13 | ]; 14 | 15 | export function parse(query: string): string { 16 | for (const regExp of anidbRegexList) { 17 | const match = query.match(regExp); 18 | if (match) { 19 | return match[1] as string; 20 | } 21 | } 22 | 23 | return query; 24 | } 25 | 26 | export async function fetchInformation( 27 | id: string, 28 | config: IFetchSocialSiteInformationConfig = {}, 29 | ): Promise { 30 | const realId = parse(String(id)); 31 | const resDict = { 32 | site: "anidb", 33 | id: realId, 34 | title: "", 35 | poster: "", 36 | ratingScore: 0, 37 | ratingCount: 0, 38 | createAt: 0, 39 | } as ISocialInformation; 40 | 41 | try { 42 | // 如果提供了 anidb 的 client 信息,则优先使用 anidb 的 API 获取信息 43 | if (config.socialSite?.anidb?.client) { 44 | // 我们默认 client 为 a-zA-Z 组成的字符串, clientver 默认为 1,如果 client 中包含了 /,则依次表示 client 和 clientver 45 | let client = config.socialSite.anidb.client; 46 | let clientver = 1; 47 | if (client.includes("/")) { 48 | const [clientName, clientVersion] = client.split("/"); 49 | client = clientName; 50 | clientver = parseInt(clientVersion, 10); 51 | } 52 | 53 | const apiReq = await axios.get("http://api.anidb.net:9001/httpapi", { 54 | params: { request: "anime", client, clientver, protover: 1, aid: realId }, 55 | timeout: config.timeout ?? 10e3, 56 | responseType: "document", 57 | }); 58 | resDict.title = uniq( 59 | Sizzle("titles > title", apiReq.data) 60 | .map((x) => x.textContent) 61 | .filter(Boolean), 62 | ).join(" / "); 63 | const poster = Sizzle("picture", apiReq.data)[0]?.textContent; 64 | if (poster) { 65 | resDict.poster = "https://cdn.anidb.net/images/main/" + poster; 66 | } 67 | const ratingAnother = Sizzle("ratings permanent, temporary", apiReq.data)?.[0]; 68 | if (ratingAnother) { 69 | resDict.ratingScore = parseFloat(ratingAnother.textContent ?? "0"); 70 | resDict.ratingCount = parseInt(ratingAnother.getAttribute("count") ?? "0", 10); 71 | } 72 | } else { 73 | const htmlReq = await axios.get(build(realId), { timeout: config.timeout ?? 10e3, responseType: "document" }); 74 | resDict.title = uniq( 75 | Sizzle("div.titles span[itemprop], label[itemprop]", htmlReq.data) 76 | .map((x) => x.textContent) 77 | .filter(Boolean), 78 | ).join(" / "); 79 | resDict.poster = Sizzle("meta[property='og:image'][content]", htmlReq.data)?.[0]?.getAttribute("content") ?? ""; 80 | resDict.ratingScore = parseFloat(Sizzle('span[itemprop="ratingValue"]', htmlReq.data)?.[0]?.textContent ?? "0"); 81 | resDict.ratingCount = parseInt( 82 | Sizzle('span[itemprop="ratingCount"]', htmlReq.data)?.[0]?.getAttribute("content") ?? "0", 83 | 10, 84 | ); 85 | } 86 | } catch (error) { 87 | console.warn(error); 88 | } finally { 89 | resDict.createAt = +Date.now(); 90 | } 91 | 92 | return resDict; 93 | } 94 | -------------------------------------------------------------------------------- /src/packages/social/entity/douban.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import Sizzle from "sizzle"; 3 | import type { IFetchSocialSiteInformationConfig, IPtgenApiResponse, ISocialInformation } from "../types"; 4 | import { uniq } from "es-toolkit"; 5 | 6 | export function build(id: string): string { 7 | return `https://movie.douban.com/subject/${id}/`; 8 | } 9 | 10 | export function parse(query: string): string { 11 | const doubanUrlMatch = query.match(/(?:https?:\/\/)?(?:(?:movie|www)\.)?douban\.com\/(?:subject|movie)\/(\d+)\/?/); 12 | if (doubanUrlMatch) { 13 | return doubanUrlMatch[1] as string; 14 | } 15 | 16 | return query; 17 | } 18 | 19 | // 这里只列出了我们需要的部分 20 | interface IDoubanPtGen extends IPtgenApiResponse { 21 | aka: string[]; 22 | this_title: string[]; 23 | chinese_title: string; 24 | foreign_title: string; 25 | poster: string; 26 | douban_votes: string; 27 | douban_rating_average: string | number; 28 | } 29 | 30 | export function transformPtGen(data: IDoubanPtGen): ISocialInformation { 31 | const uniqueTitles = new Set([ 32 | data.chinese_title ?? "", 33 | data.foreign_title ?? "", 34 | ...(data.this_title ?? []), 35 | ...(data.aka ?? []), 36 | ]); 37 | const titles = Array.from(uniqueTitles).filter(Boolean); 38 | 39 | return { 40 | site: "douban", 41 | id: data.sid, 42 | title: titles.join(" / "), 43 | poster: data.poster ?? "", 44 | ratingScore: Number(data.douban_rating_average ?? 0), 45 | ratingCount: Number(data.douban_votes ?? 0), 46 | createAt: +Date.now(), 47 | }; 48 | } 49 | 50 | // TODO 解析页面获取信息 51 | export async function fetchInformation( 52 | id: string, 53 | config: IFetchSocialSiteInformationConfig = {}, 54 | ): Promise { 55 | const realId = parse(String(id)); 56 | const resDict = { 57 | site: "douban", 58 | id: realId, 59 | title: "", 60 | poster: "", 61 | ratingScore: 0, 62 | ratingCount: 0, 63 | createAt: 0, 64 | } as ISocialInformation; 65 | 66 | try { 67 | const { data } = await axios.get(build(realId), { 68 | responseType: "document", 69 | timeout: config.timeout ?? 10e3, 70 | }); 71 | let ld_json = JSON.parse( 72 | (data.querySelector('head > script[type="application/ld+json"]')?.textContent ?? "{}").replace( 73 | /(\r\n|\n|\r|\t)/gm, 74 | "", 75 | ), 76 | ); 77 | 78 | const chinese_title = (data.querySelector("title")?.textContent ?? "").replace("(豆瓣)", "").trim(); 79 | const foreign_title = (data.querySelector('span[property="v:itemreviewed"]')?.textContent ?? "") 80 | .replace(chinese_title, "") 81 | .trim(); 82 | const aka_anchor = Sizzle('#info span.pl:contains("又名")', data); 83 | let aka_title: string[] = []; 84 | if (aka_anchor.length > 0) { 85 | aka_title = (aka_anchor[0].nextSibling?.nodeValue ?? "") 86 | .trim() 87 | .split(" / ") 88 | .sort((a, b) => a.localeCompare(b)); //首字(母)排序 89 | } 90 | resDict.title = uniq([chinese_title, foreign_title, ...aka_title]) 91 | .filter(Boolean) 92 | .join(" / "); 93 | 94 | resDict.poster = (ld_json.image ?? "") 95 | .replace(/s(_ratio_poster|pic)/g, "l$1") 96 | .replace(/img\d(.doubanio.com)/g, "img1$1"); 97 | 98 | resDict.ratingScore = ld_json?.aggregateRating?.ratingValue ?? 0; 99 | resDict.ratingCount = ld_json?.aggregateRating?.ratingCount ?? 0; 100 | } catch (error) { 101 | console.warn(error); 102 | } finally { 103 | resDict.createAt = +Date.now(); 104 | } 105 | return resDict; 106 | } 107 | -------------------------------------------------------------------------------- /src/packages/social/entity/imdb.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import type { IFetchSocialSiteInformationConfig, IPtgenApiResponse, ISocialInformation } from "../types"; 3 | 4 | export function build(id: string): string { 5 | return `https://www.imdb.com/title/${id}/`; 6 | } 7 | 8 | export function parse(query: string): string { 9 | // Extract the IMDb ID from the URL. 10 | const imdbUrlMatch = query.match(/(?:https?:\/\/)?(?:www\.)?imdb\.com\/title\/(tt\d+)\/?/); 11 | if (imdbUrlMatch) { 12 | return imdbUrlMatch[1] as string; 13 | } 14 | 15 | if (/tt(\d+)/.test(query)) { 16 | return query; 17 | } 18 | 19 | // 如果是纯数字的字符串,则补齐并返回 20 | if (/^\d+$/.test(query)) { 21 | return "tt" + (query.length < 7 ? query.padStart(7, "0") : query); 22 | } 23 | 24 | return query; 25 | } 26 | 27 | interface IImdbPtGen extends IPtgenApiResponse { 28 | name: string; 29 | aka: string[]; 30 | poster: string; 31 | imdb_votes: number; 32 | imdb_rating_average: number; 33 | details?: { 34 | "Also known as": string[]; 35 | }; 36 | } 37 | 38 | export function transformPtGen(data: IImdbPtGen): ISocialInformation { 39 | const uniqueTitles = new Set([data.name ?? "", ...(data.aka ?? []), ...(data.details?.["Also known as"] ?? [])]); 40 | const titles = Array.from(uniqueTitles).filter(Boolean); 41 | 42 | return { 43 | site: "imdb", 44 | id: parse(data.sid), 45 | title: titles.join(" / "), 46 | poster: data.poster ?? "", 47 | ratingScore: data.imdb_rating_average ?? 0, 48 | ratingCount: data.imdb_votes ?? 0, 49 | createAt: +Date.now(), 50 | }; 51 | } 52 | 53 | interface IIMDbApiResp { 54 | "@meta": any; 55 | resource: { 56 | "@type": "imdb.api.title.ratings"; 57 | id: string; 58 | title: string; 59 | titleType: "movie"; 60 | year: number; 61 | otherRanks: any[]; 62 | rating: number; 63 | ratingCount: number; 64 | }; 65 | } 66 | 67 | export async function fetchInformation( 68 | id: string, 69 | config: IFetchSocialSiteInformationConfig = {}, 70 | ): Promise { 71 | const realId = parse(String(id)); 72 | const resDict = { 73 | site: "imdb", 74 | id: realId, 75 | title: "", 76 | poster: "", 77 | ratingScore: 0, 78 | ratingCount: 0, 79 | createAt: 0, 80 | } as ISocialInformation; 81 | 82 | /** 83 | * 使用浏览器直接请求 imdb 页面会遇到 waf 问题,暂时没能力解决。。 84 | * 此处走 https://p.media-imdb.com/static-content/documents/v1/title/{id}/ratings%3Fjsonp=imdb.rating.run:imdb.api.title.ratings/data.json 接口 85 | * 获取除 poster 外的 title, ratingScore, ratingCount 信息 86 | */ 87 | try { 88 | const { data } = await axios.get( 89 | `https://p.media-imdb.com/static-content/documents/v1/title/${realId}/ratings%3Fjsonp=imdb.rating.run:imdb.api.title.ratings/data.json`, 90 | { 91 | timeout: config.timeout ?? 10e3, 92 | responseType: "text", 93 | }, 94 | ); 95 | const jsonDataText = data.replace(/\n/gi, "").match(/[^(]+\((.+)\)/)[1]; 96 | const imdbRatingData = JSON.parse(jsonDataText) as IIMDbApiResp; 97 | if (imdbRatingData.resource) { 98 | resDict.title = imdbRatingData.resource.title ?? ""; 99 | resDict.ratingScore = imdbRatingData.resource.rating ?? 0; 100 | resDict.ratingCount = imdbRatingData.resource.ratingCount ?? 0; 101 | } 102 | } catch (error) { 103 | console.warn(error); 104 | } finally { 105 | resDict.createAt = +Date.now(); 106 | } 107 | 108 | return resDict; 109 | } 110 | -------------------------------------------------------------------------------- /src/packages/social/index.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { IFetchSocialSiteInformationConfig, ISocialInformation, TSupportSocialSite } from "./types.ts"; 3 | 4 | export * from "./types.ts"; 5 | 6 | // From https://github.com/ourbits/PtGen#usage 7 | export const buildInPtGenApi = [ 8 | { provider: "Github Pages", url: "https://ourbits.github.io/PtGen//.json" }, 9 | { provider: "OurHelp CDN", url: "https://cdn.ourhelp.club/ptgen//.json" }, 10 | { provider: "OurHelp API", url: "https://api.ourhelp.club/infogen?site=&sid=" }, 11 | ]; 12 | 13 | interface socialEntity { 14 | parse: (query: string) => string; 15 | build: (id: string) => string; 16 | fetchInformation: (id: string, config: IFetchSocialSiteInformationConfig) => Promise; 17 | transformPtGen?: (data: any) => ISocialInformation; 18 | } 19 | 20 | export const socialContent = import.meta.glob("./entity/*.ts", { eager: true }); 21 | export const socialEntityList = Object.keys(socialContent).map((value: string) => { 22 | return value.replace(/^\.\/entity\//, "").replace(/\.ts$/, ""); 23 | }) as TSupportSocialSite[]; 24 | 25 | export type TSupportSocialSite$1 = (typeof socialEntityList)[number]; 26 | 27 | const PtGenApiSupportSite: TSupportSocialSite$1[] = [] as const; 28 | export const socialBuildUrlMap = {} as Record; 29 | export const socialParseUrlMap = {} as Record; 30 | 31 | export function getSocialModule(site: TSupportSocialSite$1): socialEntity { 32 | return socialContent[`./entity/${site}.ts`]; 33 | } 34 | 35 | for (const socialEntity of socialEntityList) { 36 | const socialModule = getSocialModule(socialEntity); 37 | socialBuildUrlMap[socialEntity] = socialModule.build; 38 | socialParseUrlMap[socialEntity] = socialModule.parse; 39 | 40 | if (socialModule.transformPtGen) { 41 | PtGenApiSupportSite.push(socialEntity); 42 | } 43 | } 44 | 45 | export async function getSocialSiteInformation( 46 | site: TSupportSocialSite$1, 47 | id: string, 48 | config: IFetchSocialSiteInformationConfig = {}, 49 | // @ts-ignore 50 | ): Promise { 51 | const socialModule = getSocialModule(site); 52 | const { preferPtGen = true, ptGenEndpoint = buildInPtGenApi[0].url, timeout = 5e3 } = config; 53 | 54 | if (preferPtGen && PtGenApiSupportSite.includes(site)) { 55 | console?.log("Use PtGen API to fetch social site information ", { site, id }); 56 | 57 | for (const ptGenEndpointElement of new Set([ptGenEndpoint, buildInPtGenApi.at(-1)!.url].filter(Boolean))) { 58 | const ptGenUrl = ptGenEndpointElement.replace("", site).replace("", id); 59 | try { 60 | const req = await axios.get(ptGenUrl, { timeout, responseType: "json" }); 61 | if (req.status === 200) { 62 | const data = req.data as any; 63 | if (data.success !== false) { 64 | return socialModule.transformPtGen!(data); 65 | } 66 | } 67 | } catch (error) {} 68 | } 69 | } 70 | 71 | // 如果没有使用 PtGen API 或者 PtGen API 获取失败,则使用内置的解析方法 72 | console?.log("Use build-in API to fetch social site information:", { site, id }); 73 | return await socialModule.fetchInformation(id, config); 74 | } 75 | -------------------------------------------------------------------------------- /src/packages/social/types.ts: -------------------------------------------------------------------------------- 1 | export const supportSocialSite = ["imdb", "douban", "bangumi", "anidb"] as const; 2 | export type TSupportSocialSite = (typeof supportSocialSite)[number]; 3 | 4 | export interface ISocialSiteMetadata { 5 | site: TSupportSocialSite; 6 | } 7 | 8 | export interface ISocialInformation { 9 | site: TSupportSocialSite; 10 | id: string; 11 | title: string; 12 | poster?: string; // 海报图 13 | ratingScore?: number; // 按10分满分的评分 14 | ratingCount?: number; // 评分人数 15 | createAt: number; // 创建时间 16 | } 17 | 18 | export interface IFetchSocialSiteInformationConfig { 19 | // 是否优先使用 ptgen 接口获取信息 20 | preferPtGen?: boolean; 21 | // 只是最优先而已,如果失败,则会从默认的 buildInPtGenApi 中依次尝试 22 | ptGenEndpoint?: string; 23 | // 请求超时时间(毫秒) 24 | timeout?: number; 25 | // 缓存时间(天) 26 | cacheDay?: number; 27 | 28 | socialSite?: Record>; 29 | } 30 | 31 | export interface IPtgenApiResponse { 32 | site: TSupportSocialSite; 33 | sid: string; 34 | 35 | [key: string]: any; 36 | } 37 | -------------------------------------------------------------------------------- /src/shim.d.ts: -------------------------------------------------------------------------------- 1 | // 使用 axios-cache-interceptor 拓展 axios 库 2 | // https://axios-cache-interceptor.js.org/config 3 | import type { AxiosRequestConfig as BaseAxiosRequestConfig, AxiosResponse as BaseAxiosResponse } from "axios"; 4 | import type { CacheProperties, InternalCacheRequestConfig } from "axios-cache-interceptor"; 5 | 6 | declare module "axios" { 7 | interface AxiosRequestConfig extends BaseAxiosRequestConfig { 8 | id?: string; 9 | cache?: false | Partial; 10 | } 11 | interface AxiosResponse extends BaseAxiosResponse { 12 | config: InternalCacheRequestConfig; 13 | id: string; 14 | cached: boolean; 15 | stale?: boolean; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // 在 vite.config.ts 中定义的常量,这些常量一般都是编译中产生的 4 | declare const __BROWSER__: "chrome" | "firefox"; 5 | declare const __EXT_VERSION__: string; 6 | declare const __GIT_VERSION__: { short: string; long: string; date: number; count: number; branch: string }; 7 | declare const __BUILD_TIME__: string; 8 | declare const __RESOURCE_SITE_ICONS__: string[]; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2023", 4 | "module": "ESNext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "noImplicitReturns": true, 13 | "noImplicitOverride": true, 14 | "useDefineForClassFields": true, 15 | "allowImportingTsExtensions": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "noFallthroughCasesInSwitch": true, 21 | "allowSyntheticDefaultImports": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "experimentalDecorators": true, 24 | "resolveJsonModule": true, 25 | "types": ["node", "chrome"], 26 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 27 | "baseUrl": ".", 28 | "paths": { 29 | "~/*": ["src/*"], 30 | "@/*": ["src/entries/*"], 31 | "@ptd/*": ["src/packages/*"] 32 | } 33 | }, 34 | "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], 35 | "references": [{ "path": "./tsconfig.node.json" }] 36 | } 37 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "resolveJsonModule": true, 9 | "allowImportingTsExtensions": true, 10 | "baseUrl": "." 11 | }, 12 | "include": ["package.json", "vite.config.ts", "vite/**/*.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /vite/plugin/generateWebextLocales.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | 4 | export function vitePluginGenerateWebextLocales() { 5 | return { 6 | name: "vite-plugin-generate-webext-locales", 7 | buildStart() { 8 | const localesDir = path.resolve(process.cwd(), "src/locales"); 9 | const publicLocalesDir = path.resolve(process.cwd(), "public/_locales"); 10 | 11 | const localeFiles = fs.readdirSync(localesDir).filter((file) => file.endsWith(".json")); 12 | 13 | localeFiles.forEach((file) => { 14 | const localeName = path.basename(file, ".json"); 15 | const localeFilePath = path.join(localesDir, file); 16 | const localeOutputDir = path.join(publicLocalesDir, localeName); 17 | 18 | try { 19 | // 读取 json 文件内容 20 | const fileContent = fs.readFileSync(localeFilePath, "utf8"); 21 | const jsonData = JSON.parse(fileContent); 22 | 23 | // 提取 manifest 字段 24 | const manifest = jsonData.manifest; 25 | if (!manifest) { 26 | console.warn(`No manifest field found in ${file}.`); 27 | return; 28 | } 29 | 30 | // 生成 messages.json 文件内容 31 | const messages = {}; 32 | for (const [key, value] of Object.entries(manifest)) { 33 | messages[key] = { 34 | message: value, 35 | }; 36 | } 37 | 38 | // 创建 public/_locales/ 目录 39 | if (!fs.existsSync(localeOutputDir)) { 40 | fs.mkdirSync(localeOutputDir, { recursive: true }); 41 | } 42 | 43 | // 写入 messages.json 文件 44 | const outputFilePath = path.join(localeOutputDir, "messages.json"); 45 | fs.writeFileSync(outputFilePath, JSON.stringify(messages, null, 2)); 46 | console.log(`Generated ${outputFilePath}`); 47 | } catch (error) { 48 | console.error(`Error processing ${file}:`, error); 49 | } 50 | }); 51 | }, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /vite/sendToTgChannel.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | import path from "node:path"; 3 | import process from "node:process"; 4 | import { execSync } from "node:child_process"; 5 | 6 | import axios from "axios"; 7 | 8 | const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; 9 | const CHAT_ID = process.env.TELEGRAM_CHAT_ID; 10 | const FILES_DIR = "build"; 11 | 12 | function getCommitInfo() { 13 | try { 14 | const commitHash = execSync("git rev-parse --short HEAD").toString().trim(); 15 | const author = execSync('git log -1 --pretty=format:"%an"').toString().trim(); 16 | const message = execSync('git log -1 --pretty=format:"%s"').toString().trim(); 17 | const moreMessage = execSync('git log -1 --pretty=format:"%b"').toString().trim(); 18 | const timestamp = execSync('git log -1 --pretty=format:"%cd" --date=format:"%Y-%m-%d %H:%M:%S"').toString().trim(); 19 | 20 | return { commitHash, author, message, moreMessage, timestamp }; 21 | } catch (err) { 22 | console.error("获取 commit 信息失败:", err); 23 | return { 24 | commitHash: "unknown", 25 | author: "unknown", 26 | message: "unknown", 27 | timestamp: new Date().toISOString(), 28 | }; 29 | } 30 | } 31 | 32 | function escapeLegacyMarkdown(text: string): string { 33 | return text.replace(/([*_])/g, "\\$1"); // 转义星号和下划线 34 | } 35 | 36 | async function main() { 37 | if (!BOT_TOKEN || !CHAT_ID) { 38 | console.error("缺少 TELEGRAM_BOT_TOKEN 或 TELEGRAM_CHAT_ID 环境变量"); 39 | process.exit(1); 40 | } 41 | 42 | const triggerInfo = { 43 | eventName: process.env.GITHUB_EVENT_NAME || "unknown", 44 | workflow: process.env.GITHUB_WORKFLOW || "unknown", 45 | actor: process.env.GITHUB_ACTOR || "unknown", 46 | repository: process.env.GITHUB_REPOSITORY || "unknown", 47 | ref: process.env.GITHUB_REF || "unknown", 48 | }; 49 | const commitInfo = getCommitInfo(); 50 | let buildVersion = process.env.BUILD_VERSION || "unknown"; 51 | 52 | let message = ` 53 | #${triggerInfo.eventName} #${commitInfo.author} #${commitInfo.commitHash} 54 | 55 | \`\`\` 56 | ${escapeLegacyMarkdown(commitInfo.message)} 57 | |$|moreMessage|$| 58 | \`\`\` 59 | 60 | 🔢 \`v${buildVersion}\` 61 | 📅 \`${commitInfo.timestamp}\` 62 | 📦 *GitHub Action 自动构建* 63 | `; 64 | 65 | message = message.replace( 66 | "|$|moreMessage|$|", 67 | commitInfo.moreMessage 68 | ? ` 69 | ${escapeLegacyMarkdown(commitInfo.moreMessage)}` 70 | : "", 71 | ); 72 | 73 | console.log(message); 74 | 75 | const files = fs 76 | .readdirSync(FILES_DIR) 77 | .map((fileName: string) => path.join(FILES_DIR, fileName)) 78 | .filter((filePath: string) => fs.statSync(filePath).isFile()); 79 | 80 | if (files.length === 0) { 81 | console.error(`目录为空: ${FILES_DIR}`); 82 | process.exit(1); 83 | } 84 | 85 | const formData = new FormData(); 86 | formData.append("chat_id", CHAT_ID); 87 | 88 | const mediaJson = files.map((filePath, index) => ({ 89 | type: "document", 90 | media: `attach://file${index}`, 91 | })); 92 | 93 | // @ts-expect-error 为最后一个文件添加 caption 94 | mediaJson.at(-1).caption = message; 95 | // @ts-expect-error 为最后一个文件添加 parse_mode 96 | mediaJson.at(-1).parse_mode = "Markdown"; 97 | 98 | formData.append("media", JSON.stringify(mediaJson)); 99 | 100 | files.forEach((filePath, index) => { 101 | formData.append( 102 | `file${index}`, 103 | new Blob([fs.readFileSync(filePath)], { type: "application/octet-stream" }), 104 | path.basename(filePath), 105 | ); 106 | }); 107 | 108 | const response = await axios.post(`https://api.telegram.org/bot${BOT_TOKEN}/sendMediaGroup`, formData); 109 | 110 | if (!response.data.ok) { 111 | throw new Error(`Telegram API 错误: ${response.status}`); 112 | } 113 | } 114 | 115 | // noinspection JSIgnoredPromiseFromCall 116 | main(); 117 | --------------------------------------------------------------------------------