From 1b80a8483598ee919c7bcd871e170f19f3241f97 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:33:50 +0800 Subject: [PATCH 001/130] =?UTF-8?q?Refactor:=20=E5=BB=BA=E7=AB=8B=20v6.5?= =?UTF-8?q?=20=E6=9E=B6=E6=9E=84=E8=BF=81=E7=A7=BB=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 完成单入口 ESM、Composition Root、严格依赖注入、Ports/Adapters、Services、Feature/Integration Runtime 与 HostAdapter 基础 - 增加架构债务、Integration Quality、checkJs、性能预算、Dev Build 和 Browser Smoke 门禁 - 建立 Playwright Node 22 子项目,使用真实站点 origin fixture,并保留 Node 20 根项目兼容 - 补充 12 项 ADR、职责 ownership、兼容迁移与生命周期测试 修改原因: - 为 v6.5 后续 Feature/Contribution 迁移建立可回归、可 bisect 的稳定 checkpoint - 提前固化 URL 信任、宿主 DOM 边界、请求合并和兼容数据契约,避免后续阶段产生隐式耦合 验证: - npm run check 通过:73 个测试文件、470 项测试 - npm run check:browser 通过:本机 Edge,12/12,无浏览器下载 - git diff --check 通过 - codegraph sync 通过 --- .github/workflows/ci.yml | 51 +- .gitignore | 1 + JHS.user.js | 27213 ++++++++-------- architecture-baseline.json | 3425 ++ .../architecture/adr/001-module-boundaries.md | 5 + .../adr/002-feature-contribution.md | 5 + .../adr/003-integration-manifest.md | 5 + .../architecture/adr/004-provider-registry.md | 5 + docs/architecture/adr/005-detail-surface.md | 5 + docs/architecture/adr/006-settings-profile.md | 5 + .../architecture/adr/007-request-key-cache.md | 5 + .../adr/008-lifecycle-cancellation.md | 5 + .../architecture/adr/009-storage-migration.md | 5 + docs/architecture/adr/010-url-trust.md | 5 + docs/architecture/adr/011-vendor-bridge.md | 5 + .../adr/012-browser-release-gate.md | 5 + docs/architecture/adr/README.md | 16 + docs/architecture/ownership.md | 22 + package-lock.json | 15 + package.json | 10 +- performance-budget.json | 15 + scripts/architecture-check.mjs | 152 + scripts/build-dev.mjs | 33 + scripts/build.mjs | 179 +- scripts/check-refactor-sync.mjs | 23 + scripts/checkjs-coverage.mjs | 28 + scripts/integration-quality-check.mjs | 56 + scripts/performance-budget-check.mjs | 11 + scripts/regression-check.mjs | 20 +- scripts/ui-consistency-audit.mjs | 17 +- src/app/bootstrap.js | 133 + src/app/command-registry.js | 42 + src/app/compatibility-facade.js | 10 + src/app/create-app-context.js | 78 + src/app/dependency-container.js | 41 + src/app/feature-runtime.js | 96 + src/app/integration-catalog.js | 13 + src/app/integration-registry.js | 48 + src/app/provider-registry.js | 55 + src/app/settings-registry.js | 15 + src/contracts/manifests.js | 43 + src/contracts/models.js | 15 + src/contracts/ports.js | 18 + src/contracts/tokens.js | 46 + src/core/cache-policy.js | 6 +- src/core/constants.js | 22 +- src/core/css-injection.js | 4 + src/core/detail-state-controller.js | 6 +- src/core/event-bus.js | 25 +- src/core/feature-helpers.js | 22 +- src/core/http.js | 9 +- src/core/javdb-api.js | 13 +- src/core/jhs-error.js | 34 + src/core/legacy-plugin-contributions.js | 36 + src/core/lifecycle-scope.js | 101 + src/core/logger.js | 41 +- src/core/migration.js | 12 +- src/core/movie-identity.js | 15 + src/core/plugin-manager.js | 28 +- src/core/selection-model.js | 20 + src/core/site-context.js | 6 +- src/core/state-model.js | 16 +- src/core/state-service.js | 8 +- src/core/storage-index.js | 11 +- src/core/storage.js | 23 +- src/core/theme.js | 6 +- src/core/ui-primitives.js | 8 +- src/core/utils.js | 5 +- src/features/catalog.js | 6 + src/features/detail/detail-controller.js | 21 + src/features/detail/manifest.js | 17 + .../history/history-selection-model.js | 8 + src/features/system/catalog.js | 26 + src/integrations/av123/manifest.js | 11 + src/integrations/av123/parser.js | 60 + src/integrations/dmm/manifest.js | 4 + src/integrations/dmm/parser.js | 10 + src/integrations/fc2ppvdb/manifest.js | 4 + src/integrations/fc2ppvdb/parser.js | 11 + src/integrations/host-list/manifest.js | 9 + src/integrations/host-list/parser.js | 14 + src/integrations/javbus/manifest.js | 12 + src/integrations/javbus/parser.js | 12 + src/integrations/javdb/manifest.js | 11 + src/integrations/javdb/parser.js | 35 + src/integrations/javstore/manifest.js | 10 + src/integrations/javstore/parser.js | 39 + src/integrations/javtrailers/manifest.js | 4 + src/integrations/javtrailers/parser.js | 8 + src/integrations/subtitlecat/manifest.js | 4 + src/integrations/subtitlecat/parser.js | 8 + src/main.js | 49 +- src/parsers/third-party-parsers.js | 127 - .../browser/browser-navigation-adapter.js | 18 + src/platform/browser/browser-style-adapter.js | 17 + src/platform/hosts/javbus-host-adapter.js | 16 + src/platform/hosts/javdb-host-adapter.js | 20 + .../userscript/indexeddb-storage-adapter.js | 12 + .../userscript/layer-dialog-adapter.js | 10 + .../userscript/userscript-http-adapter.js | 45 + src/platform/userscript/vendor-runtime.js | 17 + src/plugins/avatar/actress-info.js | 5 +- src/plugins/avatar/search-by-image.js | 5 +- src/plugins/backup/resource-settings.js | 12 +- src/plugins/backup/setting-backup.js | 14 +- src/plugins/backup/setting-forms.js | 32 +- src/plugins/backup/setting-panels.js | 35 +- src/plugins/backup/setting-styles.js | 6 +- src/plugins/backup/setting-templates.js | 18 +- src/plugins/backup/setting.js | 42 +- src/plugins/backup/webdav-client.js | 2 +- src/plugins/blacklist/blacklist.js | 31 +- src/plugins/blacklist/filter-title-keyword.js | 6 +- src/plugins/dependency-map.js | 46 + src/plugins/external-search/fc2-by-123av.js | 28 +- src/plugins/external-search/fc2.js | 33 +- src/plugins/external-search/hit-show.js | 16 +- src/plugins/external-search/javtrailers.js | 6 +- src/plugins/external-search/magnet-hub.js | 11 +- .../external-search/magnet-source-registry.js | 18 +- src/plugins/external-search/other-site.js | 7 +- src/plugins/external-search/related.js | 8 +- src/plugins/external-search/review.js | 14 +- src/plugins/external-search/top250.js | 11 +- src/plugins/favorite/favorite-actresses.js | 5 +- src/plugins/image-viewer/bus-img.js | 5 +- src/plugins/image-viewer/bus-preview-video.js | 15 +- src/plugins/image-viewer/cover-button.js | 15 +- src/plugins/image-viewer/preview-video.js | 17 +- .../screenshot-provider-registry.js | 2 +- src/plugins/image-viewer/screenshot.js | 9 +- src/plugins/new-video/new-video.js | 30 +- src/plugins/new-video/task.js | 22 +- src/plugins/offline/unified-offline.js | 13 +- src/plugins/one-one-five/client.js | 13 +- src/plugins/one-one-five/plugins.js | 8 +- src/plugins/one-two-three/offline.js | 4 +- src/plugins/registry.js | 49 +- src/plugins/stats/stats.js | 15 +- src/plugins/status/auto-page.js | 7 +- src/plugins/status/bus-detail-page.js | 4 +- src/plugins/status/bus-nav-bar.js | 6 +- src/plugins/status/compat-enhancements.js | 8 +- src/plugins/status/detail-page-button.js | 13 +- src/plugins/status/detail-page.js | 4 +- src/plugins/status/detail-workspace.js | 13 +- src/plugins/status/fold-category.js | 5 +- src/plugins/status/highlight-magnet.js | 7 +- src/plugins/status/history.js | 18 +- src/plugins/status/list-page-button.js | 16 +- src/plugins/status/list-page.js | 30 +- src/plugins/status/mobile-bottom-bar.js | 27 +- src/plugins/status/nav-bar.js | 11 +- src/plugins/status/want-and-watched-videos.js | 6 +- src/plugins/subtitle/subtitle-cat.js | 4 +- src/plugins/translate/translate.js | 6 +- src/services/cache-service.js | 50 + src/services/diagnostics-service.js | 54 + src/services/dialog-service.js | 12 + src/services/external-url-policy.js | 48 + src/services/http-service.js | 140 + src/services/magnet-service.js | 18 + src/services/movie-identity-service.js | 16 + src/services/navigation-service.js | 14 + src/services/offline-service.js | 22 + src/services/profile-service.js | 38 + src/services/related-service.js | 14 + src/services/review-service.js | 14 + src/services/screenshot-service.js | 17 + src/services/settings-service.js | 31 + src/services/storage-service.js | 14 + src/services/style-registry.js | 12 + src/ui/detail/hosted-detail-surface.js | 31 + src/ui/detail/owned-detail-surface.js | 23 + src/ui/table/create-jhs-table.js | 11 + tests/123av-adapter.test.js | 3 +- tests/activity-retention.test.js | 3 +- tests/actress-decoration.test.js | 3 +- tests/architecture-runtime.test.js | 91 + tests/audit-remediation.test.js | 5 +- tests/blacklist-filter-ui.test.js | 8 +- tests/blacklist-parser-boundary.test.js | 5 +- tests/browser/fixtures/javbus-detail.html | 11 + tests/browser/fixtures/javdb-detail.html | 11 + tests/browser/harness/runtime.js | 93 + tests/browser/package-lock.json | 123 + tests/browser/package.json | 18 + tests/browser/playwright.config.js | 30 + tests/browser/scripts/check-local-browser.mjs | 23 + tests/browser/specs/host-routing.spec.js | 20 + tests/clipboard.test.js | 3 +- tests/compat-enhancements.test.js | 7 +- tests/detail-car-num.test.js | 21 +- tests/detail-host-boundary.test.js | 7 +- tests/detail-panel-lifecycle.test.js | 5 +- tests/detail-state-controller.test.js | 5 +- tests/event-bus-v2.test.js | 5 +- tests/external-rendering.test.js | 3 +- tests/fc2-owned-workspace.test.js | 31 +- tests/feature-helpers.test.js | 3 +- tests/fixtures/integrations/av123/valid.html | 1 + tests/fixtures/integrations/dmm/preview.html | 1 + .../integrations/fc2ppvdb/detail.html | 1 + .../integrations/host-list/valid.html | 1 + .../fixtures/integrations/javbus/detail.html | 1 + tests/fixtures/integrations/javdb/valid.html | 1 + .../fixtures/integrations/javstore/valid.html | 1 + .../integrations/javtrailers/preview.html | 1 + .../integrations/subtitlecat/results.html | 1 + tests/helpers/read-test-file.js | 18 + tests/history-multistate.test.js | 3 +- tests/history-selection.test.js | 3 +- tests/hit-show-page.test.js | 3 +- tests/hit-show.test.js | 3 +- tests/http-request-options.test.js | 9 +- tests/http-service.test.js | 57 + tests/image-hover-preview.test.js | 10 +- tests/integrations/av123.contract.test.js | 11 + tests/integrations/av123.malformed.test.js | 9 + tests/integrations/dmm.contract.test.js | 3 + tests/integrations/dmm.malformed.test.js | 3 + tests/integrations/fc2ppvdb.contract.test.js | 3 + tests/integrations/fc2ppvdb.malformed.test.js | 3 + tests/integrations/host-list.contract.test.js | 9 + tests/integrations/javbus.contract.test.js | 14 + tests/integrations/javbus.malformed.test.js | 12 + tests/integrations/javdb.contract.test.js | 9 + tests/integrations/javdb.malformed.test.js | 9 + tests/integrations/javstore.contract.test.js | 9 + tests/integrations/javstore.malformed.test.js | 9 + .../integrations/javtrailers.contract.test.js | 3 + .../integrations/subtitlecat.contract.test.js | 3 + tests/javdb-status-import.test.js | 3 +- tests/legacy-storage-adapter.test.js | 3 +- tests/list-sorting.test.js | 3 +- tests/list-visibility.test.js | 3 +- tests/list-workflow.test.js | 3 +- tests/media-parser.test.js | 5 +- tests/media-playback.test.js | 7 +- tests/migration-v2.test.js | 3 +- tests/mobile-fab.test.js | 3 +- tests/models-profile.test.js | 41 + tests/new-video-inbox.test.js | 3 +- tests/new-video-workspace.test.js | 3 +- tests/offline-provider.test.js | 5 +- tests/one-one-five.test.js | 5 +- tests/parser-fixtures.test.js | 5 +- tests/performance-hotpaths.test.js | 12 +- tests/provider-registries.test.js | 3 +- tests/release-ui-contracts.test.js | 3 +- tests/resource-settings.test.js | 7 +- tests/settings-invalidation.test.js | 5 +- tests/site-context.test.js | 3 +- tests/startup-performance.test.js | 23 +- tests/state-model.test.js | 5 +- tests/state-service.test.js | 5 +- tests/stats-scope.test.js | 3 +- tests/storage-last-publish.test.js | 3 +- tests/task-scheduler.test.js | 3 +- tests/ui-architecture.test.js | 33 +- tests/utils.test.js | 3 +- tests/webdav-compat.test.js | 3 +- tsconfig.architecture.json | 31 + 263 files changed, 21439 insertions(+), 14106 deletions(-) create mode 100644 architecture-baseline.json create mode 100644 docs/architecture/adr/001-module-boundaries.md create mode 100644 docs/architecture/adr/002-feature-contribution.md create mode 100644 docs/architecture/adr/003-integration-manifest.md create mode 100644 docs/architecture/adr/004-provider-registry.md create mode 100644 docs/architecture/adr/005-detail-surface.md create mode 100644 docs/architecture/adr/006-settings-profile.md create mode 100644 docs/architecture/adr/007-request-key-cache.md create mode 100644 docs/architecture/adr/008-lifecycle-cancellation.md create mode 100644 docs/architecture/adr/009-storage-migration.md create mode 100644 docs/architecture/adr/010-url-trust.md create mode 100644 docs/architecture/adr/011-vendor-bridge.md create mode 100644 docs/architecture/adr/012-browser-release-gate.md create mode 100644 docs/architecture/adr/README.md create mode 100644 docs/architecture/ownership.md create mode 100644 performance-budget.json create mode 100644 scripts/architecture-check.mjs create mode 100644 scripts/build-dev.mjs create mode 100644 scripts/check-refactor-sync.mjs create mode 100644 scripts/checkjs-coverage.mjs create mode 100644 scripts/integration-quality-check.mjs create mode 100644 scripts/performance-budget-check.mjs create mode 100644 src/app/bootstrap.js create mode 100644 src/app/command-registry.js create mode 100644 src/app/compatibility-facade.js create mode 100644 src/app/create-app-context.js create mode 100644 src/app/dependency-container.js create mode 100644 src/app/feature-runtime.js create mode 100644 src/app/integration-catalog.js create mode 100644 src/app/integration-registry.js create mode 100644 src/app/provider-registry.js create mode 100644 src/app/settings-registry.js create mode 100644 src/contracts/manifests.js create mode 100644 src/contracts/models.js create mode 100644 src/contracts/ports.js create mode 100644 src/contracts/tokens.js create mode 100644 src/core/jhs-error.js create mode 100644 src/core/legacy-plugin-contributions.js create mode 100644 src/core/lifecycle-scope.js create mode 100644 src/core/movie-identity.js create mode 100644 src/core/selection-model.js create mode 100644 src/features/catalog.js create mode 100644 src/features/detail/detail-controller.js create mode 100644 src/features/detail/manifest.js create mode 100644 src/features/history/history-selection-model.js create mode 100644 src/features/system/catalog.js create mode 100644 src/integrations/av123/manifest.js create mode 100644 src/integrations/av123/parser.js create mode 100644 src/integrations/dmm/manifest.js create mode 100644 src/integrations/dmm/parser.js create mode 100644 src/integrations/fc2ppvdb/manifest.js create mode 100644 src/integrations/fc2ppvdb/parser.js create mode 100644 src/integrations/host-list/manifest.js create mode 100644 src/integrations/host-list/parser.js create mode 100644 src/integrations/javbus/manifest.js create mode 100644 src/integrations/javbus/parser.js create mode 100644 src/integrations/javdb/manifest.js create mode 100644 src/integrations/javdb/parser.js create mode 100644 src/integrations/javstore/manifest.js create mode 100644 src/integrations/javstore/parser.js create mode 100644 src/integrations/javtrailers/manifest.js create mode 100644 src/integrations/javtrailers/parser.js create mode 100644 src/integrations/subtitlecat/manifest.js create mode 100644 src/integrations/subtitlecat/parser.js delete mode 100644 src/parsers/third-party-parsers.js create mode 100644 src/platform/browser/browser-navigation-adapter.js create mode 100644 src/platform/browser/browser-style-adapter.js create mode 100644 src/platform/hosts/javbus-host-adapter.js create mode 100644 src/platform/hosts/javdb-host-adapter.js create mode 100644 src/platform/userscript/indexeddb-storage-adapter.js create mode 100644 src/platform/userscript/layer-dialog-adapter.js create mode 100644 src/platform/userscript/userscript-http-adapter.js create mode 100644 src/platform/userscript/vendor-runtime.js create mode 100644 src/plugins/dependency-map.js create mode 100644 src/services/cache-service.js create mode 100644 src/services/diagnostics-service.js create mode 100644 src/services/dialog-service.js create mode 100644 src/services/external-url-policy.js create mode 100644 src/services/http-service.js create mode 100644 src/services/magnet-service.js create mode 100644 src/services/movie-identity-service.js create mode 100644 src/services/navigation-service.js create mode 100644 src/services/offline-service.js create mode 100644 src/services/profile-service.js create mode 100644 src/services/related-service.js create mode 100644 src/services/review-service.js create mode 100644 src/services/screenshot-service.js create mode 100644 src/services/settings-service.js create mode 100644 src/services/storage-service.js create mode 100644 src/services/style-registry.js create mode 100644 src/ui/detail/hosted-detail-surface.js create mode 100644 src/ui/detail/owned-detail-surface.js create mode 100644 src/ui/table/create-jhs-table.js create mode 100644 tests/architecture-runtime.test.js create mode 100644 tests/browser/fixtures/javbus-detail.html create mode 100644 tests/browser/fixtures/javdb-detail.html create mode 100644 tests/browser/harness/runtime.js create mode 100644 tests/browser/package-lock.json create mode 100644 tests/browser/package.json create mode 100644 tests/browser/playwright.config.js create mode 100644 tests/browser/scripts/check-local-browser.mjs create mode 100644 tests/browser/specs/host-routing.spec.js create mode 100644 tests/fixtures/integrations/av123/valid.html create mode 100644 tests/fixtures/integrations/dmm/preview.html create mode 100644 tests/fixtures/integrations/fc2ppvdb/detail.html create mode 100644 tests/fixtures/integrations/host-list/valid.html create mode 100644 tests/fixtures/integrations/javbus/detail.html create mode 100644 tests/fixtures/integrations/javdb/valid.html create mode 100644 tests/fixtures/integrations/javstore/valid.html create mode 100644 tests/fixtures/integrations/javtrailers/preview.html create mode 100644 tests/fixtures/integrations/subtitlecat/results.html create mode 100644 tests/helpers/read-test-file.js create mode 100644 tests/http-service.test.js create mode 100644 tests/integrations/av123.contract.test.js create mode 100644 tests/integrations/av123.malformed.test.js create mode 100644 tests/integrations/dmm.contract.test.js create mode 100644 tests/integrations/dmm.malformed.test.js create mode 100644 tests/integrations/fc2ppvdb.contract.test.js create mode 100644 tests/integrations/fc2ppvdb.malformed.test.js create mode 100644 tests/integrations/host-list.contract.test.js create mode 100644 tests/integrations/javbus.contract.test.js create mode 100644 tests/integrations/javbus.malformed.test.js create mode 100644 tests/integrations/javdb.contract.test.js create mode 100644 tests/integrations/javdb.malformed.test.js create mode 100644 tests/integrations/javstore.contract.test.js create mode 100644 tests/integrations/javstore.malformed.test.js create mode 100644 tests/integrations/javtrailers.contract.test.js create mode 100644 tests/integrations/subtitlecat.contract.test.js create mode 100644 tests/models-profile.test.js create mode 100644 tsconfig.architecture.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82427015..37061dc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, refactor/6.5] pull_request: - branches: [main] + branches: [main, refactor/6.5] workflow_dispatch: permissions: @@ -30,9 +30,9 @@ jobs: name: Node 22 full check runs-on: ubuntu-latest outputs: - should_release: ${{ steps.push_release.outputs.should_release || steps.manual_release.outputs.should_release }} - version: ${{ steps.push_release.outputs.version || steps.manual_release.outputs.version }} - tag: ${{ steps.push_release.outputs.tag || steps.manual_release.outputs.tag }} + should_release: ${{ steps.push_release.outputs.should_release }} + version: ${{ steps.push_release.outputs.version }} + tag: ${{ steps.push_release.outputs.tag }} steps: - uses: actions/checkout@v4 with: @@ -42,6 +42,9 @@ jobs: node-version: 22 cache: npm - run: npm ci + - name: Verify 6.5 integration branch contains current main + if: github.ref == 'refs/heads/refactor/6.5' || github.base_ref == 'refactor/6.5' + run: node scripts/check-refactor-sync.mjs - run: npm run check - name: Verify tracked userscript is current run: git diff --exit-code -- JHS.user.js @@ -52,15 +55,37 @@ jobs: id: push_release if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: node scripts/release-contract.mjs --base-ref "${{ github.event.before }}" --github-output "$GITHUB_OUTPUT" - - name: Select recovery release - id: manual_release - if: github.event_name == 'workflow_dispatch' - run: node scripts/release-contract.mjs --force-release --github-output "$GITHUB_OUTPUT" + + browser-smoke: + name: Browser smoke + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - name: Install browser test dependencies + working-directory: tests/browser + run: npm ci + - name: Install ephemeral Chromium + working-directory: tests/browser + run: npx playwright install chromium --with-deps + - name: Run deterministic browser smoke + run: npm run check:browser + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: output/playwright/ + retention-days: 14 release: name: Publish immutable release - needs: [node20, check] - if: needs.check.outputs.should_release == 'true' + needs: [node20, check, browser-smoke] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check.outputs.should_release == 'true' runs-on: ubuntu-latest permissions: contents: write @@ -76,11 +101,7 @@ jobs: with: node-version: 22 - name: Revalidate release identity and extract notes - if: github.event_name == 'push' run: node scripts/release-contract.mjs --base-ref "${{ github.event.before }}" --notes-file /tmp/release-body.md - - name: Revalidate recovery identity and extract notes - if: github.event_name == 'workflow_dispatch' - run: node scripts/release-contract.mjs --force-release --notes-file /tmp/release-body.md - name: Validate immutable tag and release state id: release_state env: diff --git a/.gitignore b/.gitignore index 2ce027b1..2e4e2302 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +output/playwright/ outputs/ .learnings/ CLAUDE.md diff --git a/JHS.user.js b/JHS.user.js index 27945aa5..377d95a9 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -75,7 +75,7 @@ var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); - // src/main.js + // src/core/site-context.js var JAVDB_HOST_PATTERN = /^(?:[a-z0-9-]+\.)?javdb(?:[a-z0-9-]*)\.com$/i; var JAVBUS_HOST_MARKERS = ["javbus", "javsee", "seejav"]; function normalizeLocation(locationLike = window.location) { @@ -101,129 +101,8 @@ return "/advanced_search" === locationUrl.pathname && "1" === locationUrl.searchParams.get("handlePlayback"); } __name(isHitShowPage, "isHitShowPage"); - function isNormalListPage(locationLike = window.location, hasMovieList = null) { - const locationUrl = normalizeLocation(locationLike); - const hasList = null === hasMovieList ? "undefined" != typeof $ && $(".movie-list").length > 0 : Boolean(hasMovieList); - return !isHitShowPage(locationUrl) && (hasList || locationUrl.pathname.includes("advanced_search")); - } - __name(isNormalListPage, "isNormalListPage"); - function isListPage(locationLike = window.location, hasMovieList = null) { - return isHitShowPage(locationLike) || isNormalListPage(locationLike, hasMovieList); - } - __name(isListPage, "isListPage"); - var HOUR = 60 * 60 * 1e3; - var DAY = 24 * HOUR; - var CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); - var _ProviderError = class _ProviderError extends Error { - constructor(provider, code, message, options = {}) { - super(message, { cause: options.cause }); - this.name = "ProviderError"; - this.provider = provider; - this.code = code; - this.status = options.status || 0; - this.url = options.url || ""; - this.retryable = Boolean(options.retryable); - } - }; - __name(_ProviderError, "ProviderError"); - var ProviderError = _ProviderError; - async function mapLimit(items, concurrency = 4, mapper) { - const results = new Array(items.length); - let cursor = 0; - const worker = /* @__PURE__ */ __name(async () => { - while (cursor < items.length) { - const index = cursor++; - results[index] = await mapper(items[index], index); - } - }, "worker"); - await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, worker)); - return results; - } - __name(mapLimit, "mapLimit"); - function parseNumberSetting(value, fallback, { min = -Infinity, max = Infinity } = {}) { - if (null == value || "" === String(value).trim()) return fallback; - const number = Number(value); - return Number.isFinite(number) && number >= min && number <= max ? number : fallback; - } - __name(parseNumberSetting, "parseNumberSetting"); - function parseTaskTimestamp(value) { - if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.getTime() : null; - if ("number" == typeof value) return Number.isFinite(value) && value >= 0 ? value : null; - if ("string" != typeof value) return null; - const text = value.trim(); - if (!text) return null; - if (/^\d{13,16}$/.test(text)) { - const timestamp2 = Number(text); - return Number.isFinite(timestamp2) ? timestamp2 : null; - } - const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/); - if (match) { - const parts = match.slice(1).map(Number), date = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]); - return date.getFullYear() === parts[0] && date.getMonth() === parts[1] - 1 && date.getDate() === parts[2] && date.getHours() === parts[3] && date.getMinutes() === parts[4] && date.getSeconds() === parts[5] ? date.getTime() : null; - } - const timestamp = new Date(text).getTime(); - return Number.isFinite(timestamp) ? timestamp : null; - } - __name(parseTaskTimestamp, "parseTaskTimestamp"); - function shouldSkipStopped(lastPublishTime, ruleHours, now = Date.now()) { - const hours = parseNumberSetting(ruleHours, 0, { min: 0 }), publishedAt = parseTaskTimestamp(lastPublishTime), nowAt = parseTaskTimestamp(now); - return hours > 0 && null != publishedAt && null != nowAt && nowAt >= publishedAt && nowAt - publishedAt >= 36e5 * hours; - } - __name(shouldSkipStopped, "shouldSkipStopped"); - function selectLatestPublishTime(values) { - let latestValue = null, latestAt = -Infinity; - for (const value of values) { - const timestamp = parseTaskTimestamp(value); - null != timestamp && timestamp > latestAt && (latestAt = timestamp, latestValue = value); - } - return latestValue; - } - __name(selectLatestPublishTime, "selectLatestPublishTime"); - function normalizeHttpUrl(value, baseUrl = window.location.href) { - if (!value) return null; - try { - const url = new URL(String(value), baseUrl); - return ["http:", "https:"].includes(url.protocol) ? url.href : null; - } catch (error) { - clog.debug("外部 URL 无效,已忽略", error); - return null; - } - } - __name(normalizeHttpUrl, "normalizeHttpUrl"); - function normalizeBtihHash(value) { - const hash = String(value || "").trim(); - return /^(?:[a-f\d]{40}|[a-z2-7]{32})$/i.test(hash) ? hash.toUpperCase() : null; - } - __name(normalizeBtihHash, "normalizeBtihHash"); - function parseCarNumberText(text) { - const tokens = String(text || "").split(/[\s,,;;]+/).map(((item) => normalizeCarNum(item))).filter(Boolean); - const valid = tokens.filter(((item) => /^(?:FC2-)?[A-Z\d]+(?:-[A-Z\d]+)+$/i.test(item))); - return { recognized: tokens.length, values: [...new Set(valid.map(((item) => item.toUpperCase())))], invalid: tokens.filter(((item) => !valid.includes(item))) }; - } - __name(parseCarNumberText, "parseCarNumberText"); - function buildFallbackCarUrl(carNum, baseUrl = "https://javdb.com") { - return `${baseUrl}/search?q=${encodeURIComponent(carNum)}`; - } - __name(buildFallbackCarUrl, "buildFallbackCarUrl"); - async function safePlay(mediaElement, { context = "视频", notify = false, message = "当前视频源无法播放" } = {}) { - if (!mediaElement || "function" != typeof mediaElement.play) { - clog.warn(`${context}播放失败:媒体元素不可用`); - notify && show.error(message); - return false; - } - try { - await mediaElement.play(); - return true; - } catch (error) { - clog.warn(`${context}播放失败`, error); - const name = error?.name || ""; - notify && !["NotAllowedError", "AbortError"].includes(name) && show.error(message); - return false; - } - } - __name(safePlay, "safePlay"); - var e; - var t; + + // src/core/constants.js var n = Object.defineProperty; var a = /* @__PURE__ */ __name((e2) => { throw TypeError(e2); @@ -359,3094 +238,1863 @@ window.location.href.includes("hideNav=1") && (M = "\n .navbar-default {\n display: none !important;\n }\n body {\n padding-top:0px!important;\n }\n "); var j = ""; window.location.href.includes("hideNav=1") && (j = "\n .main-nav,#search-bar-container {\n display: none !important;\n }\n \n html {\n padding-top:0px!important;\n }\n "); - function H(e2) { - if (e2) if (e2.includes(" -`; + __name(createEmptyStateFlags, "createEmptyStateFlags"); + function stateFlagsFromLegacyStatus(status) { + const flags = createEmptyStateFlags(), flag = LEGACY_STATUS_TO_FLAG[status]; + return flag && (flags[flag] = true), flags; } - __name(buildThemeCss, "buildThemeCss"); - async function applyTheme() { - const mode = await storageManager.getSetting("themeMode", "light"); - let resolved = "light"; - if ("dark" === mode) resolved = "dark"; - else if ("auto" === mode) resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; - document.documentElement.setAttribute("data-jhs-theme", resolved); + __name(stateFlagsFromLegacyStatus, "stateFlagsFromLegacyStatus"); + function normalizeStateFlags(flags) { + const normalized = createEmptyStateFlags(); + return STATE_FLAG_NAMES.forEach(((name) => normalized[name] = true === flags?.[name])), normalized; } - __name(applyTheme, "applyTheme"); - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (() => { - storageManager.getSetting("themeMode", "light").then(((e2) => { - "auto" === e2 && applyTheme(); + __name(normalizeStateFlags, "normalizeStateFlags"); + function projectLegacyStatus(flags) { + const normalized = normalizeStateFlags(flags); + return normalized.blocked ? d : normalized.watched ? p : normalized.downloaded ? g : normalized.favorite ? h : ""; + } + __name(projectLegacyStatus, "projectLegacyStatus"); + function syncLegacyStatus(record) { + return record.stateFlags = normalizeStateFlags(record.stateFlags), record.status = projectLegacyStatus(record.stateFlags), record; + } + __name(syncLegacyStatus, "syncLegacyStatus"); + function hasAnyState(flags) { + const normalized = normalizeStateFlags(flags); + return STATE_FLAG_NAMES.some(((name) => normalized[name])); + } + __name(hasAnyState, "hasAnyState"); + function legacyActionToFlag(actionType) { + return LEGACY_STATUS_TO_FLAG[actionType] || null; + } + __name(legacyActionToFlag, "legacyActionToFlag"); + function mergeCanonicalCarRecords(records) { + const groups = /* @__PURE__ */ new Map(), collisions = [], unknownStatuses = []; + records.filter(Boolean).forEach(((record) => { + const original = record.carNum, carNum = normalizeCarNum(original); + if (!carNum) return; + const item = { ...record, carNum, stateFlags: record.stateFlags ? normalizeStateFlags(record.stateFlags) : stateFlagsFromLegacyStatus(record.status) }; + record.stateFlags || !record.status || LEGACY_STATUS_TO_FLAG[record.status] || unknownStatuses.push({ carNum, status: record.status }); + const group = groups.get(carNum) || []; + group.push({ original, item }), groups.set(carNum, group); })); - })); - function buildUiPrimitivesCss() { - return ` -`; + return version; } - __name(buildUiPrimitivesCss, "buildUiPrimitivesCss"); - function renderStateView(container, { type = "empty", title = "", description = "", actionLabel = "", onAction = null } = {}) { - const root = container?.jquery ? container : $(container), state = $('
').addClass(`jhs-state--${type}`).attr("role", "error" === type ? "alert" : "status"), content = $('
'); - title && content.append($('

').text(title)), description && content.append($('

').text(description)); - if (actionLabel && "function" == typeof onAction) content.append($('').text(actionLabel).on("click", onAction)); - return state.append(content), root.empty().append(state), state; + __name(runDataMigrations, "runDataMigrations"); + + // src/core/legacy-plugin-contributions.js + var LEGACY_PLUGIN_CONTRIBUTION_MAP = Object.freeze({ + ReviewPlugin: "detail.reviews", + RelatedPlugin: "detail.related", + ScreenShotPlugin: "detail.screenshot", + ScreenshotPlugin: "detail.screenshot", + MagnetHubPlugin: "detail.external-magnets", + PreviewVideoPlugin: "detail.gallery", + CoverButtonPlugin: "detail.state-actions", + DetailPageButtonPlugin: "detail.state-actions", + HighlightMagnetPlugin: "detail.native-magnets", + OtherSitePlugin: "detail.external-sites", + SubTitleCatPlugin: "detail.subtitle" + }); + function migrateDisabledPlugins(value) { + const input = Array.isArray(value) ? value : []; + const mapping = ( + /** @type {Record} */ + LEGACY_PLUGIN_CONTRIBUTION_MAP + ); + return [...new Set(input.filter((id) => typeof id === "string").map((id) => mapping[id] ?? id))]; } - __name(renderStateView, "renderStateView"); - function initializeUiAccessibility() { - const selector = "button.jhs-btn, a.jhs-btn[role='button'], .card-btn, .jhs-icon-btn, [class*='jhs-'] button, [class*='jhs-'] a[role='button']"; - const enhance = /* @__PURE__ */ __name((e2) => { - const t2 = e2.nodeType === Node.ELEMENT_NODE && e2.matches?.(selector) ? [e2] : []; - const n2 = e2.querySelectorAll ? [...e2.querySelectorAll(selector)] : []; - [...t2, ...n2].forEach(((e3) => { - if (e3.hasAttribute("aria-label") || e3.hasAttribute("aria-labelledby") || e3.textContent.trim()) return; - const t3 = e3.getAttribute("title") || e3.getAttribute("data-tip"); - t3 && e3.setAttribute("aria-label", t3); - })); - }, "enhance"); - enhance(document); - const pending = /* @__PURE__ */ new Set(); - let scheduled = false; - const flush = /* @__PURE__ */ __name(() => { - scheduled = false; - const all = [...pending], roots = all.filter(((e2) => !all.some(((t2) => t2 !== e2 && t2.contains?.(e2))))); - pending.clear(), roots.forEach(enhance); - }, "flush"); - new MutationObserver(((records) => { - records.forEach(((record) => record.addedNodes.forEach(((node) => { - node.nodeType === Node.ELEMENT_NODE && pending.add(node); - })))); - pending.size && !scheduled && (scheduled = true, queueMicrotask(flush)); - })).observe(document.documentElement, { - childList: true, - subtree: true - }); + __name(migrateDisabledPlugins, "migrateDisabledPlugins"); + function disabledIdForPlugin(pluginName) { + return ( + /** @type {Record} */ + LEGACY_PLUGIN_CONTRIBUTION_MAP[pluginName] ?? pluginName + ); } - __name(initializeUiAccessibility, "initializeUiAccessibility"); - var _JhsSelect = class _JhsSelect { - constructor(select) { - this.source = $(select); - if (!this.source.length || _JhsSelect.instances.has(this.source[0])) return _JhsSelect.instances.get(this.source[0]); - const initiallyHidden = this.source.hasClass("jhs-is-hidden") || "none" === this.source[0].style.display; - this.control = $('
'); - this.trigger = $(''); - this.menu = $(''); - this.source.wrap(this.control), this.control = this.source.parent(), this.control.append(this.trigger, this.menu), this.source.addClass("jhs-select-source-native").attr({ "aria-hidden": "true", tabindex: "-1" }), initiallyHidden && this.control.addClass("jhs-is-hidden"), _JhsSelect.instances.set(this.source[0], this), this.render(), this.bind(), this.refresh(); + __name(disabledIdForPlugin, "disabledIdForPlugin"); + function parseDisabledPlugins(serialized) { + try { + const value = typeof serialized === "string" ? JSON.parse(serialized) : serialized; + return Array.isArray(value) ? value.filter((id) => typeof id === "string") : []; + } catch { + return []; } - static enhance(root = document) { - const scope = $(root), selects = scope.is("select.jhs-select-source") ? scope : scope.find("select.jhs-select-source"); - selects.each(((_2, select) => new _JhsSelect(select))); - return selects; + } + __name(parseDisabledPlugins, "parseDisabledPlugins"); + + // src/core/plugin-manager.js + var _PluginManager = class _PluginManager { + constructor() { + this.plugins = /* @__PURE__ */ new Map(); + this._errorLog = []; + this._lastTimings = []; + this._startedAt = performance.now(); + this._registrationMs = 0; + this._cssMs = 0; + this._immediateMs = 0; + this._readyMs = 0; + this._idlePending = 0; + this._idleCompleted = 0; + this._disabledPluginsPromise = null; + this._dependencyDeclarations = Object.freeze({}); } - static get(select) { - const element = $(select)[0]; - return element ? _JhsSelect.instances.get(element) || new _JhsSelect(element) : null; + setDependencyDeclarations(declarations) { + if (this.plugins.size) throw new Error("依赖声明必须在插件注册前配置"); + this._dependencyDeclarations = declarations || Object.freeze({}); } - static setValue(select, value, emit = false) { - const instance = _JhsSelect.get(select); - if (!instance) return; - instance.source.val(value), emit ? instance.emitChange() : instance.refresh(); + register(e2) { + if ("function" != typeof e2) throw new Error("插件必须是一个类"); + const a2 = performance.now(); + const t2 = new e2(); + t2.pluginManager = this; + const n2 = t2.getName(); + if (this.plugins.has(n2)) throw new Error(`插件"${n2}"已注册`); + t2.declaredDependencies = new Set(this._dependencyDeclarations[n2] || []); + this.plugins.set(n2, t2); + this._registrationMs += performance.now() - a2; } - static refresh(select) { - _JhsSelect.get(select)?.refresh(); + // 仅供 6.6 前的外部 Compatibility Facade 使用。 + getBean(e2) { + return this.plugins.get(e2); } - static refreshAll(root = document) { - _JhsSelect.enhance(root), $(root).find("select.jhs-select-source").each(((_2, select) => _JhsSelect.refresh(select))); + resolveDeclaredPlugin(e2) { + return this.plugins.get(e2); } - static setVisible(select, visible) { - const instance = _JhsSelect.get(select); - instance?.control.toggleClass("jhs-is-hidden", !visible); + _addError(e2, t2, n2) { + this._errorLog.push({ + time: (/* @__PURE__ */ new Date()).toISOString(), + plugin: e2, + phase: t2, + message: n2?.message || String(n2), + stack: n2?.stack || "" + }); + this._errorLog.length > 200 && this._errorLog.shift(); } - static closeAll(except = null) { - $(".jhs-select-control.is-open").each(((_2, control) => { - const source = $(control).children("select.jhs-select-source")[0], instance = source && _JhsSelect.instances.get(source); - instance && instance !== except && instance.close(); - })); + getErrorLog() { + return [...this._errorLog]; } - render() { - this.menu.empty(); - const appendOption = /* @__PURE__ */ __name((option, target) => { - const button = $(''); - button.attr({ "data-value": option.value, "aria-checked": option.selected ? "true" : "false" }).prop("disabled", option.disabled).text(option.text), target.append(button); - }, "appendOption"); - this.source.children().each(((_2, child) => { - if ("OPTGROUP" === child.tagName) { - const group = $('
').attr("aria-label", child.label), label = $('
').text(child.label); - group.append(label), $(child).children("option").each(((_3, option) => appendOption(option, group))), this.menu.append(group); - } else "OPTION" === child.tagName && appendOption(child, this.menu); - })); + clearErrorLog() { + this._errorLog = []; } - bind() { - this.trigger.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(), this.source.prop("disabled") || (this.control.hasClass("is-open") ? this.close() : this.open()); - })).on("keydown", ((event) => { - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(), this.open("ArrowUp" === event.key || "End" === event.key ? "last" : "selected"); - })); - this.menu.on("click", ".jhs-select-option", ((event) => { - event.preventDefault(), this.choose($(event.currentTarget)); - })).on("keydown", ".jhs-select-option", ((event) => { - const items = this.options(), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), this.close(true); - if ("Tab" === event.key) return void this.close(); - if (["Enter", " "].includes(event.key)) return event.preventDefault(), this.choose($(event.currentTarget)); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - this.source.on("change.jhsSelect", (() => this.refresh())), $(document).on("click.jhsSelect", ((event) => { - $(event.target).closest(this.control).length || this.close(); - })); + getTimings() { + return [...this._lastTimings]; } - options() { - return this.menu.find(".jhs-select-option:not(:disabled)"); + getPluginNames() { + return Array.from(this.plugins.keys()); } - open(focus = "selected") { - _JhsSelect.closeAll(this), this.control.addClass("is-open"), this.menu.addClass("is-open"), this.trigger.attr("aria-expanded", "true"); - const items = this.options(), selected = items.filter('[aria-checked="true"]'); - ("last" === focus ? items.last() : selected.length ? selected.first() : items.first()).trigger("focus"); + getStartupReport() { + return { + registeredPlugins: this.plugins.size, + registrationMs: this._registrationMs, + cssMs: this._cssMs, + immediateMs: this._immediateMs, + readyMs: this._readyMs, + idlePending: this._idlePending, + idleCompleted: this._idleCompleted + }; } - close(focus = false) { - this.control.removeClass("is-open"), this.menu.removeClass("is-open"), this.trigger.attr("aria-expanded", "false"), focus && this.trigger.trigger("focus"); + async _getDisabledPlugins() { + return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { + try { + const e2 = await storageManager.getSetting("disabledPlugins", "[]"); + const configured = new Set(parseDisabledPlugins(e2)); + return new Set(this.getPluginNames().filter(((name) => !["SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin"].includes(name) && configured.has(disabledIdForPlugin(name))))); + } catch (e2) { + return /* @__PURE__ */ new Set(); + } + })()); } - choose(item) { - if (item.prop("disabled")) return; - this.source.val(item.attr("data-value")), this.emitChange(), this.close(true); + async processCss() { + const a2 = performance.now(); + const t2 = await this._getDisabledPlugins(); + const m2 = utils.isMobileMode(); + const s2 = await Promise.all(Array.from(this.plugins).map((async ([e2, n2]) => { + try { + if (t2.has(e2)) return { name: e2, status: "disabled" }; + if (m2 && "function" == typeof n2.shouldSkipOnMobile && n2.shouldSkipOnMobile()) return { name: e2, status: "skipped" }; + if ("function" == typeof n2.initCss) { + const t3 = await n2.initCss(); + return { + name: e2, + status: "fulfilled", + css: t3 + }; + } + return { name: e2, status: "skipped" }; + } catch (a3) { + return clog.error(`插件 ${e2} 加载 CSS 失败`, a3), this._addError(e2, "initCss", a3), { + name: e2, + status: "rejected", + error: a3 + }; + } + }))); + const o2 = s2.map(((e2) => e2.css)).filter(Boolean); + o2.length > 0 && utils.insertStyle(o2); + this._cssMs = performance.now() - a2; } - /** 派发一次真实原生 change,同时兼容宿主与 jQuery 监听器。 */ - emitChange() { - this.source[0]?.dispatchEvent(new Event("change", { - bubbles: true - })); + async _runPlugin(e2) { + const t2 = performance.now(); + try { + if ("function" == typeof e2.plugin.handle) await e2.plugin.handle(); + e2.timing.elapsed = performance.now() - t2; + e2.timing.status = "ok"; + } catch (n2) { + e2.timing.elapsed = performance.now() - t2; + e2.timing.status = "error"; + e2.timing.error = n2?.message || String(n2); + clog.error(`插件 ${e2.name} 执行失败`, n2); + this._addError(e2.name, "idle" === e2.mode ? "handle-idle" : "handle", n2); + } } - refresh() { - const selected = this.source.find("option:selected").first(), value = this.source.val(); - this.trigger.find(".jhs-select-value").text(selected.text()), this.trigger.prop("disabled", this.source.prop("disabled")), this.menu.find(".jhs-select-option").attr("aria-checked", "false").filter(((_2, item) => $(item).attr("data-value") === String(value ?? ""))).attr("aria-checked", "true"); + _scheduleIdle(e2) { + const t2 = /* @__PURE__ */ __name(() => e2().catch(((e3) => { + clog.error("[JHS] 空闲插件执行失败:", e3); + })), "t"); + "function" == typeof requestIdleCallback ? requestIdleCallback(t2, { timeout: 1500 }) : setTimeout(t2, 100); } - }; - __name(_JhsSelect, "JhsSelect"); - __publicField(_JhsSelect, "instances", /* @__PURE__ */ new WeakMap()); - var JhsSelect = _JhsSelect; - var N = ` -`; - var E = ` -`; - var F = ` -`; - H(buildThemeCss()); - l && H(N), r && H(E); - H(F); - H(buildUiPrimitivesCss()); - initializeUiAccessibility(); - function createIndexedMap(items, key) { - return new Map(items.filter(((item) => item && item[key])).map(((item) => [item[key], item]))); - } - __name(createIndexedMap, "createIndexedMap"); - function createStatusMap(items) { - const statusMap = { - [d]: /* @__PURE__ */ new Set(), - [h]: /* @__PURE__ */ new Set(), - [g]: /* @__PURE__ */ new Set(), - [p]: /* @__PURE__ */ new Set() - }; - items.forEach(((item) => { - if (!item?.carNum) return; - const flags = normalizeStateFlags(item.stateFlags); - flags.blocked && statusMap[d].add(item.carNum), flags.favorite && statusMap[h].add(item.carNum), flags.downloaded && statusMap[g].add(item.carNum), flags.watched && statusMap[p].add(item.carNum); - })); - return statusMap; - } - __name(createStatusMap, "createStatusMap"); - function groupDuplicateItems(items, key) { - const counts = /* @__PURE__ */ new Map(); - items.forEach(((item) => { - const value = item && item[key]; - value && counts.set(value, (counts.get(value) || 0) + 1); - })); - return Array.from(counts.entries()).filter(((item) => item[1] > 1)); - } - __name(groupDuplicateItems, "groupDuplicateItems"); - function dedupeByKey(items, key) { - const seen = /* @__PURE__ */ new Map(), list = []; - let changed = false; - for (const item of items) { - const value = item && item[key]; - if (!value) { - list.push(item); - continue; + async _runIdlePlugins(e2) { + for (const t2 of e2) { + t2.startedAt = performance.now(); + await this._runPlugin(t2); + this._idlePending--; + this._idleCompleted++; } - if (seen.has(value)) { - Object.assign(seen.get(value), item), changed = true; - } else seen.set(value, item), list.push(item); - } - return { - list, - changed - }; - } - __name(dedupeByKey, "dedupeByKey"); - var STATE_FLAG_NAMES = Object.freeze(["favorite", "downloaded", "watched", "blocked"]); - var LEGACY_STATUS_TO_FLAG = Object.freeze({ [h]: "favorite", [g]: "downloaded", [p]: "watched", [d]: "blocked" }); - function createEmptyStateFlags() { - return { favorite: false, downloaded: false, watched: false, blocked: false }; - } - __name(createEmptyStateFlags, "createEmptyStateFlags"); - function stateFlagsFromLegacyStatus(status) { - const flags = createEmptyStateFlags(), flag = LEGACY_STATUS_TO_FLAG[status]; - return flag && (flags[flag] = true), flags; - } - __name(stateFlagsFromLegacyStatus, "stateFlagsFromLegacyStatus"); - function normalizeStateFlags(flags) { - const normalized = createEmptyStateFlags(); - return STATE_FLAG_NAMES.forEach(((name) => normalized[name] = true === flags?.[name])), normalized; - } - __name(normalizeStateFlags, "normalizeStateFlags"); - function projectLegacyStatus(flags) { - const normalized = normalizeStateFlags(flags); - return normalized.blocked ? d : normalized.watched ? p : normalized.downloaded ? g : normalized.favorite ? h : ""; - } - __name(projectLegacyStatus, "projectLegacyStatus"); - function syncLegacyStatus(record) { - return record.stateFlags = normalizeStateFlags(record.stateFlags), record.status = projectLegacyStatus(record.stateFlags), record; - } - __name(syncLegacyStatus, "syncLegacyStatus"); - function hasAnyState(flags) { - const normalized = normalizeStateFlags(flags); - return STATE_FLAG_NAMES.some(((name) => normalized[name])); - } - __name(hasAnyState, "hasAnyState"); - function legacyActionToFlag(actionType) { - return LEGACY_STATUS_TO_FLAG[actionType] || null; - } - __name(legacyActionToFlag, "legacyActionToFlag"); - function mergeCanonicalCarRecords(records) { - const groups = /* @__PURE__ */ new Map(), collisions = [], unknownStatuses = []; - records.filter(Boolean).forEach(((record) => { - const original = record.carNum, carNum = normalizeCarNum(original); - if (!carNum) return; - const item = { ...record, carNum, stateFlags: record.stateFlags ? normalizeStateFlags(record.stateFlags) : stateFlagsFromLegacyStatus(record.status) }; - record.stateFlags || !record.status || LEGACY_STATUS_TO_FLAG[record.status] || unknownStatuses.push({ carNum, status: record.status }); - const group = groups.get(carNum) || []; - group.push({ original, item }), groups.set(carNum, group); - })); - const list = []; - for (const [carNum, group] of groups) { - const sorted = group.map(((entry) => entry.item)).sort(((left, right) => String(left.updateDate || "").localeCompare(String(right.updateDate || "")))); - const merged = {}, flags = createEmptyStateFlags(); - sorted.forEach(((record) => { - Object.entries(record).forEach((([key, value]) => null != value && "" !== value && "stateFlags" !== key && "status" !== key && (merged[key] = value))); - STATE_FLAG_NAMES.forEach(((name) => flags[name] = flags[name] || record.stateFlags[name])); - })); - const createDates = sorted.map(((item) => item.createDate)).filter(Boolean).sort(), updateDates = sorted.map(((item) => item.updateDate)).filter(Boolean).sort(); - createDates.length && (merged.createDate = createDates[0]), updateDates.length && (merged.updateDate = updateDates.at(-1)); - merged.carNum = carNum, merged.stateFlags = flags, syncLegacyStatus(merged), list.push(merged); - const originals = [...new Set(group.map(((entry) => entry.original)).filter(Boolean))]; - originals.length > 1 && collisions.push({ carNum, originals, count: group.length }); } - return { list, collisions, unknownStatuses }; - } - __name(mergeCanonicalCarRecords, "mergeCanonicalCarRecords"); - e = /* @__PURE__ */ new WeakSet(), t = /* @__PURE__ */ __name(async function(e2, t2, n2) { - let a2; - if (Array.isArray(e2)) a2 = [...e2]; - else { - if (a2 = await this.forage.getItem(t2) || [], a2.includes(e2)) { - const t3 = `${e2} ${n2}已存在`; - throw show.error(t3), new Error(t3); + async processPlugins() { + const e2 = await this._getDisabledPlugins(), t2 = utils.isMobileMode(), n2 = [], a2 = [], i2 = []; + for (const [s3, o2] of this.plugins) { + const r2 = "function" == typeof o2.getStartupMode && "idle" === o2.getStartupMode() ? "idle" : "immediate"; + const l2 = { name: s3, elapsed: 0, status: "pending", startupMode: r2 }; + if (e2.has(s3)) l2.status = "disabled", i2.push(l2); + else if (t2 && "function" == typeof o2.shouldSkipOnMobile && o2.shouldSkipOnMobile()) l2.status = "skipped-mobile", i2.push(l2); + else { + const e3 = { name: s3, plugin: o2, mode: r2, timing: l2, startedAt: 0 }; + "idle" === r2 ? (l2.status = "pending-idle", a2.push(e3)) : n2.push(e3), i2.push(l2); + } } - a2.push(e2); + this._lastTimings = i2; + const s2 = performance.now(); + await Promise.all(n2.map(((e3) => { + e3.startedAt = performance.now(); + return this._runPlugin(e3); + }))); + this._immediateMs = performance.now() - s2; + for (const e3 of n2) { + if ("function" != typeof e3.plugin.afterPluginsReady) continue; + try { + await e3.plugin.afterPluginsReady(); + } catch (t3) { + clog.error(`插件 ${e3.name} 完成初始化后执行失败`, t3), this._addError(e3.name, "after-ready", t3); + } + } + this._readyMs = performance.now() - this._startedAt; + this._idlePending = a2.length; + a2.length && this._scheduleIdle((() => this._runIdlePlugins(a2))); } - return await this._setItemAndInvalidate(t2, a2), a2; - }, "t"); - var _StorageManager = class _StorageManager { + }; + __name(_PluginManager, "PluginManager"); + var PluginManager = _PluginManager; + var _BasePlugin = class _BasePlugin { constructor() { - var t2, s2, o2; - if (t2 = this, (s2 = e).has(t2) ? a("Cannot add the same private member more than once") : s2 instanceof WeakSet ? s2.add(t2) : s2.set(t2, o2), i(this, "car_list_key", "car_list"), i(this, "filter_keyword_title_key", "filter_keyword_title"), i(this, "filter_keyword_review_key", "filter_keyword_review"), i(this, "setting_key", "setting"), i(this, "blacklist_key", "blacklist"), i(this, "blacklist_car_list_key", "blacklist_car_list"), i(this, "third_party_cache_key", "third_party_ttl_cache"), i(this, "favorite_actresses_key", "favorite_actresses"), i(this, "highlighted_tags_key", "highlighted_tags"), i(this, "_actressLock", Promise.resolve()), i(this, "forage", localforage.createInstance({ - driver: localforage.INDEXEDDB, - name: "JAV-JHS", - version: 1, - storeName: "appData" - })), i(this, "cacheCarList", null), i(this, "cacheBlacklist", null), i(this, "cacheTitleFilterKeyword", null), i(this, "cacheFavoriteActresses", null), i(this, "cache_filter_actor_actress_car_list", null), i(this, "cacheSettingObj", null), i(this, "cacheCarMap", null), i(this, "cacheStatusMap", null), i(this, "cacheBlacklistMap", null), i(this, "_pendingReads", /* @__PURE__ */ new Map()), i(this, "_cacheGenerations", /* @__PURE__ */ new Map()), i(this, "_cacheStats", { hits: 0, misses: 0 }), _StorageManager.instance) throw new Error("StorageManager已被实例化过了!"); - _StorageManager.instance = this; - } - async getDataVersion() { - return await this.forage.getItem("data_version") || 0; + if (!_BasePlugin._sharedIcons) { + i(this, "pluginManager", null), i(this, "settingSvg", ''), i(this, "editSvg", ''), i(this, "deleteSvg", ''), i(this, "checkSvg", ''), i(this, "actressSvg", ''), i(this, "newSvg", ''), i(this, "refreshSvg", ''), i(this, "blacklistSvg", ''), i(this, "copySvg", ''), i(this, "titleSvg", ''), i(this, "carNumSvg", ''), i(this, "downSvg", ''), i(this, "handleSvg", ''), i(this, "siteSvg", ''), i(this, "videoSvg", ''), i(this, "screenSvg", ''), i(this, "recoveryVideoSvg", ''); + const e2 = ["settingSvg", "editSvg", "deleteSvg", "checkSvg", "actressSvg", "newSvg", "refreshSvg", "blacklistSvg", "copySvg", "titleSvg", "carNumSvg", "downSvg", "handleSvg", "siteSvg", "videoSvg", "screenSvg", "recoveryVideoSvg"]; + _BasePlugin._sharedIcons = Object.fromEntries(e2.map(((e3) => [e3, this[e3]]))); + Object.assign(_BasePlugin.prototype, _BasePlugin._sharedIcons); + e2.forEach(((e3) => delete this[e3])); + } + i(this, "pluginManager", null); + i(this, "declaredDependencies", /* @__PURE__ */ new Set()); } - async setDataVersion(e2) { - await this.forage.setItem("data_version", e2); + getName() { + throw new Error(`${this.constructor.name} 未显示getName()`); } - _getCacheGeneration(e2) { - return this._cacheGenerations.get(e2) || 0; + getDependency(e2) { + if (!this.declaredDependencies.has(e2)) { + throw new Error(`${this.getName()} 未声明依赖 ${e2}`); + } + return this.pluginManager.resolveDeclaredPlugin(e2); } - _invalidateRead(e2) { - this._cacheGenerations.set(e2, this._getCacheGeneration(e2) + 1); - this._pendingReads.delete(e2); + async initCss() { + return ""; } - /** 合并同一存储键的并发读取,并阻止失效前的旧读取回填缓存。 */ - async _readCached(e2, t2, n2) { - if (null !== this[e2]) return this[e2]; - const a2 = this._pendingReads.get(t2); - if (a2) return a2; - const i2 = this._getCacheGeneration(t2), s2 = this.forage.getItem(t2).then((a3) => { - const s3 = a3 || n2; - return i2 === this._getCacheGeneration(t2) && null === this[e2] && (this[e2] = s3), s3; - }).finally(() => { - this._pendingReads.get(t2) === s2 && this._pendingReads.delete(t2); - }); - return this._pendingReads.set(t2, s2), s2; + async handle() { } - _invalidateCache(e2 = null) { - const t2 = [this.car_list_key, this.blacklist_key, this.filter_keyword_title_key, this.favorite_actresses_key, this.blacklist_car_list_key, this.setting_key]; - (null === e2 ? t2 : t2.includes(e2) ? [e2] : []).forEach(((e3) => this._invalidateRead(e3))); - (!e2 || e2 === this.car_list_key) && (this.cacheCarList = null, this.cacheCarMap = null, this.cacheStatusMap = null); - (!e2 || e2 === this.blacklist_key) && (this.cacheBlacklist = null, this.cacheBlacklistMap = null); - (!e2 || e2 === this.filter_keyword_title_key) && (this.cacheTitleFilterKeyword = null); - (!e2 || e2 === this.favorite_actresses_key) && (this.cacheFavoriteActresses = null); - (!e2 || e2 === this.blacklist_car_list_key) && (this.cache_filter_actor_actress_car_list = null); - (!e2 || e2 === this.setting_key) && (this.cacheSettingObj = null); + getStartupMode() { + return "immediate"; } - async _setItemAndInvalidate(e2, t2) { - await this.forage.setItem(e2, t2); - this._invalidateCache(e2); + shouldSkipOnMobile() { + return false; } - async withActressLock(fn) { - let release; - const lock = new Promise((r2) => release = r2); - const prev = this._actressLock; - this._actressLock = this._actressLock.then(() => lock); - await prev; - try { - return await fn(); - } finally { - release(); + getPageInfo() { + let carNum = null, t2, n2, a2, i2, s2 = window.location.href; + if (r) { + const params = new URL(s2).searchParams, injectedCarNum = normalizeCarNum(params.get("jhsCarNum")); + const copyCarNum = normalizeCarNum($('.column-video-info a[data-clipboard-text][title*="番"], .video-detail a[data-clipboard-text][title*="番"]').first().attr("data-clipboard-text")) || normalizeCarNum($('a[title="複製番號"]').attr("data-clipboard-text")); + let panelCarNum = null; + $(".column-video-info .panel-block, .video-detail .panel-block").each((function() { + const label = $(this).find("strong, .label").first().text().trim(); + if (panelCarNum || !/(?:番号|番號|^ID)\s*[::]?/i.test(label)) return; + panelCarNum = normalizeCarNum($(this).find("[data-clipboard-text]").first().attr("data-clipboard-text")) || normalizeCarNum($(this).find(".value").first().text()); + })); + const fallbackCarNum = normalizeCarNum($("#video_id, .video-id, .video-title strong").first().text()); + carNum = firstValidCarNum(injectedCarNum, copyCarNum, panelCarNum, fallbackCarNum); + t2 = s2.split("?")[0].split("#")[0], n2 = $(".female").prev().map(((e2, t3) => $(t3).text())).get().join(" "), a2 = $(".male").prev().map(((e2, t3) => $(t3).text())).get().join(" "), i2 = $('strong:contains("日期:")').parent(".panel-block").find(".value").text().trim(); } + l && (t2 = s2.split("?")[0], carNum = normalizeCarNum(t2.split("/").filter(Boolean).pop().replace(/_\d{4}-\d{2}-\d{2}$/, "")), n2 = $('span[onmouseover*="star_"] a').map(((e2, t3) => $(t3).text())).get().join(" "), a2 = "", i2 = $('span.header:contains("發行日期:")').parent("p").text().trim().replace("發行日期:", "").trim()); + return assertPageInfoContract({ + carNum, + url: t2, + actress: n2, + actors: a2, + publishTime: i2 + }); } - async _rawUpdateFavoriteActress(e2) { - const t2 = await this.getFavoriteActressList(); - const { starId: n2, name: a2, allName: i2, avatar: s2, lastCheckTime: o2, newVideoList: r2, lastPublishTime: l2, actressType: c2, remark: d2 } = e2; - if (!n2) throw new Error("缺失starId"); - let h2 = t2.find(((e3) => e3.starId === n2)); - if (!h2) return clog.error("未找到演员信息", n2, a2), false; - a2 && (h2.name = a2), i2 && (h2.allName = i2), s2 && (h2.avatar = s2), null != c2 && (h2.actressType = c2), o2 && (h2.lastCheckTime = o2), r2 && (h2.newVideoList = r2), l2 && (h2.lastPublishTime = l2), d2 && (h2.remark = d2), h2.updateDate = utils.getNowStr(), await this._setItemAndInvalidate(this.favorite_actresses_key, t2); - return true; - } - async getCarList() { - return this._readCached("cacheCarList", this.car_list_key, []); + getActressId() { + const e2 = o.match(/\/actors\/([^/?]+)/); + return e2 && e2.length > 1 ? e2[1] : null; } - async getCarMap() { - if (null === this.cacheCarMap) { - const e2 = await this.getCarList(); - this.cacheCarMap = createIndexedMap(e2, "carNum"); + getActressPageInfo() { + let e2 = window.location.href; + if (!e2.includes("/actors/") && !e2.includes("/star/")) throw new Error("接口调用错误, 非演员详情页"); + let t2 = [], n2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + n2.length && n2.text().trim().split(",").forEach(((e3) => { + t2.push(e3.trim()); + })); + let a2 = $(".section-meta:not(:contains('影片'))"); + a2.length && a2.text().trim().split(",").forEach(((e3) => { + t2.push(e3.trim()); + })); + let i2 = $(".section-meta:contains('男優')").length > 0 ? B : P, s2 = D; + t2.some(((e3) => e3.includes("無碼"))) && (s2 = A), e2.includes("uncensored") && (s2 = A); + let o2 = null, c2 = null; + const d2 = new URL(e2); + if (r) { + c2 = d2.pathname.split("/").filter(((e4) => "" !== e4.trim())).pop(); + const e3 = d2.searchParams; + e3.delete("sort_type"), e3.delete("page"), o2 = d2.toString(); + } else if (l) { + const t3 = "/star/", n3 = e2.split(t3); + if (n3.length < 2) throw new Error("提取演员url失败"); + const a3 = n3[0]; + c2 = n3[1].split("/")[0], o2 = a3 + t3 + c2; } - return this.cacheCarMap; + return { + starId: c2, + name: t2[0], + allName: t2, + role: i2, + movieType: s2, + blacklistUrl: o2 + }; } - async getStatusMap() { - if (null === this.cacheStatusMap) { - const e2 = await this.getCarList(); - this.cacheStatusMap = createStatusMap(e2); - } - return this.cacheStatusMap; + getSelector(e2) { + const t2 = e2 || (r ? T : l ? I : null), n2 = { + javdb: { + boxSelector: ".movie-list", + itemSelector: ".movie-list .item", + coverImgSelector: ".cover img", + requestDomItemSelector: ".movie-list .item", + nextPageSelector: ".pagination-next" + }, + javbus: { + boxSelector: ".masonry", + itemSelector: ".masonry .item", + coverImgSelector: ".masonry .movie-box .photo-frame img", + requestDomItemSelector: "#waterfall .item", + nextPageSelector: "#next" + } + }; + if (!t2 || !n2[t2]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); + return n2[t2]; } - async getCar(e2) { - return (await this.getCarMap()).get(normalizeCarNum(e2)); + parseMovieId(e2) { + return e2.split("/").pop().split(/[?#]/)[0]; } - _saveSingleCar(e2, t2) { - let { carNum: n2, url: a2, names: i2, actionType: s2, publishTime: o2, starId: r2 } = e2; - if (!n2) throw show.error("番号为空!"), new Error("番号为空!"); - if (!a2) throw show.error("url为空!"), new Error("url为空!"); - a2.includes("http") || (a2 = window.location.origin + a2), i2 && (i2 = i2.trim()); - let l2 = t2.find(((e3) => e3.carNum === n2)); - if (l2) i2 && (l2.names = i2), a2 && (l2.url = a2), o2 && (l2.publishTime = o2), l2.updateDate = utils.getNowStr(); - else { - let e3 = utils.getNowStr(); - l2 = { - carNum: n2, - url: a2, - names: i2, - status: "", - createDate: e3, - updateDate: e3, - publishTime: o2 - }, r2 && (l2.starId = r2), t2.push(l2); + getBoxCarInfo(e2) { + var t2, n2, a2; + const i2 = e2.find("a"), s2 = i2.attr("href"); + let o2 = null, r2 = null, l2 = null; + const c2 = e2.find(".video-title"); + if (c2.length > 0) { + const n3 = c2.find("strong"); + if (n3.length > 0 && (o2 = n3.text().trim()), r2 = null == (t2 = i2.attr("title")) ? void 0 : t2.trim(), !r2) { + const e3 = c2.text().trim(); + r2 = o2 && e3.includes(o2) ? e3.replace(o2, "").trim() : e3; + } + l2 = e2.find(".meta").text().trim(); } - switch (s2) { - case d: - if (l2.status === d) { - const e4 = `${n2} 已在屏蔽列表中`; - throw show.error(e4), new Error(e4); - } - l2.status = d; - break; - case h: - if (l2.status === h) { - const e4 = `${n2} 已在收藏列表中`; - throw show.error(e4), new Error(e4); - } - l2.status = h; - break; - case g: - if (l2.status === g) { - const e4 = `${n2} 已标记为已下载`; - throw show.error(e4), new Error(e4); - } - l2.status = g; - break; - case p: - if (l2.status === p) { - const e4 = `${n2} 已标记为已观看`; - throw show.error(e4), new Error(e4); - } - l2.status = p; - break; - default: - const e3 = "actionType错误, 请联系作者更正: " + s2; - throw show.error(e3), new Error(e3); + if (!o2) { + const t3 = e2.find("img"); + t3.length > 0 && (r2 = (null == (n2 = t3.attr("title")) ? void 0 : n2.trim()) || (null == (a2 = t3.attr("data-title")) ? void 0 : a2.trim())); + const i3 = e2.find("date").map(((e3, t4) => $(t4).text().trim())).get(), s3 = /* @__PURE__ */ __name((e3) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e3), "s"); + l2 = i3.find(s3) || null, o2 = i3.find(((e3) => !s3(e3))) || null; + } + if (!o2) { + const t3 = "提取番号信息失败: carNum 为空"; + throw clog.error("Error in getBoxCarInfo:", t3, "Box Element:", e2.get(0)), show.error(t3), new Error(t3); } + return { + carNum: o2, + url: s2 || "", + title: r2 || "", + publishTime: l2 || "" + }; } - async saveCar(e2) { - const carNum = normalizeCarNum(e2.carNum), flag = legacyActionToFlag(e2.actionType); - if (!carNum) throw show.error("番号为空!"), new Error("番号为空!"); - if (!e2.url) throw show.error("url为空!"), new Error("url为空!"); - if (!flag) throw show.error("actionType错误, 请联系作者更正: " + e2.actionType), new Error("actionType错误, 请联系作者更正: " + e2.actionType); - const existing = await this.getCar(carNum); - if (normalizeStateFlags(existing?.stateFlags)[flag]) { - const messages = { blocked: "已在屏蔽列表中", favorite: "已在收藏列表中", downloaded: "已标记为已下载", watched: "已标记为已观看" }, message = `${carNum} ${messages[flag]}`; - throw show.error(message), new Error(message); - } - return stateService.patch(carNum, { [flag]: true }, { type: "legacy-save", record: { ...e2, carNum } }); - } - async updateCarInfo(e2) { - let { carNum: t2, url: n2, names: a2, actionType: i2, publishTime: s2, remark: o2 } = e2; - if (!t2) throw show.error("番号为空!"), new Error("番号为空!"); - if (!n2) throw show.error("url为空!"), new Error("url为空!"); - a2 && (a2 = a2.trim()); - t2 = normalizeCarNum(t2); - const l2 = await this.getCar(t2); - if (!l2) { - const e3 = "数据不存在: " + t2; - throw show.error(e3), new Error(e3); - } - const flag = legacyActionToFlag(i2); - if (!flag) { - const e3 = "actionType错误, 请联系作者更正: " + i2; - throw show.error(e3), new Error(e3); - } - return stateService.patch(t2, { [flag]: true }, { type: "legacy-update", record: { carNum: t2, names: a2, url: n2, remark: o2, publishTime: s2 } }); + getBoxCarInfoList(e2 = null) { + if (e2 || (e2 = $(this.getSelector().itemSelector)), 0 === e2.length) return clog.error("获取当前列表页所有item的番号信息失败!"), []; + const t2 = []; + return e2.each(((e3, n2) => { + const a2 = $(n2); + try { + const e4 = this.getBoxCarInfo(a2); + t2.push(e4); + } catch (i2) { + clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", i2.message, "元素索引:", e3); + } + })), t2; } - async saveCarList(e2) { - if (!e2 || !Array.isArray(e2) || 0 === e2.length) throw show.error("记录列表为空!"), new Error("记录列表为空!"); - const existing = await this.getCarMap(), seen = /* @__PURE__ */ new Set(), groups = /* @__PURE__ */ new Map(); - for (const item of e2) { - const carNum = normalizeCarNum(item.carNum), flag = legacyActionToFlag(item.actionType); - if (!carNum) throw show.error("番号为空!"), new Error("番号为空!"); - if (!item.url) throw show.error("url为空!"), new Error("url为空!"); - if (!flag) throw new Error("actionType错误, 请联系作者更正: " + item.actionType); - const duplicateKey = `${carNum}:${flag}`; - if (seen.has(duplicateKey) || normalizeStateFlags(existing.get(carNum)?.stateFlags)[flag]) throw new Error(`${carNum} 状态已存在`); - seen.add(duplicateKey); - const group = groups.get(flag) || []; - group.push({ ...item, carNum }), groups.set(flag, group); + checkDuplicateCarNumbers(e2, t2) { + if (!e2 || 0 === e2.length || !t2 || 0 === t2.length) return false; + const n2 = new Set(e2.map(((e3) => e3.carNum)).filter(((e3) => e3))); + if (0 === n2.size) return false; + let a2 = 0; + for (let i2 = 0; i2 < t2.length; i2++) { + const e3 = t2[i2] ? t2[i2].carNum : null; + if (e3 && n2.has(e3)) { + if (a2++, a2 >= 2) return clog.warn("警告: 检测到连续番号信息重复, 该类别可能已被限制页码。"), true; + } else a2 = 0; } - for (const [flag, records] of groups) await stateService.patch(records.map(((item) => item.carNum)), { [flag]: true }, { type: "legacy-batch-save", records }); + return false; } - async removeNewVideoList(e2) { - return this.withActressLock(async () => { - const targets = new Set((Array.isArray(e2) ? e2 : [e2]).map(normalizeCarNum).filter(Boolean)); - const t2 = await this.getFavoriteActressList(), changed = /* @__PURE__ */ new Set(); - const a2 = t2.map(((t3) => { - if (!t3.newVideoList || !Array.isArray(t3.newVideoList)) return t3; - const a3 = t3.newVideoList.filter(((t4) => { - const a4 = normalizeCarNum("string" == typeof t4 ? t4 : t4.carNum), i2 = targets.has(a4); - return i2 && (clog.log("移除关联女优新作品", t4.name, a4), changed.add(a4)), !i2; - })); - return { ...t3, newVideoList: a3 }; - })); - changed.size && await this._setItemAndInvalidate(this.favorite_actresses_key, a2); - return { changed: [...changed] }; - }); + }; + __name(_BasePlugin, "BasePlugin"); + var BasePlugin = _BasePlugin; + + // src/core/event-bus.js + var _JhsEventBus = class _JhsEventBus { + /** @param {string} [channelName] */ + constructor(channelName = "channel-refresh") { + this.originId = globalThis.crypto?.randomUUID?.() || `tab_${Date.now()}_${Math.random().toString(36).slice(2)}`; + this.listeners = /* @__PURE__ */ new Map(), this.seen = /* @__PURE__ */ new Set(), this.channel = new BroadcastChannel(channelName); + this.channel.addEventListener("message", ((event) => this._receive(event.data))); } - async removeCar(e2) { - const result = await stateService.remove(e2); - return result.changed.length ? true : (show.error(`${e2} 不存在`), false); + /** @param {string} type @param {(payload: any, event: any) => unknown} handler */ + on(type, handler) { + const handlers = this.listeners.get(type) || /* @__PURE__ */ new Set(); + return handlers.add(handler), this.listeners.set(type, handlers), () => handlers.delete(handler); } - async batchRemoveCars(e2) { - const result = await stateService.remove(e2); - return result.changed.length || false; + /** @param {any} event */ + async _dispatch(event) { + for (const handler of [...this.listeners.get(event.type) || []]) await handler(event.payload, event); } - async getBlacklist() { - return this._readCached("cacheBlacklist", this.blacklist_key, []); + /** @param {string} eventId */ + _remember(eventId) { + this.seen.add(eventId), this.seen.size > 256 && this.seen.delete(this.seen.values().next().value); } - async getBlacklistMap() { - if (null === this.cacheBlacklistMap) { - const e2 = await this.getBlacklist(); - this.cacheBlacklistMap = createIndexedMap(e2, "starId"); - } - return this.cacheBlacklistMap; + /** @param {string} type @param {any} [payload] @param {{broadcast?: boolean}} [options] */ + async emit(type, payload = {}, options = {}) { + const event = { eventId: globalThis.crypto?.randomUUID?.() || `event_${Date.now()}_${Math.random().toString(36).slice(2)}`, originId: this.originId, type, payload, timestamp: Date.now() }; + this._remember(event.eventId), await this._dispatch(event), false !== options.broadcast && this.channel.postMessage(event); + return event; } - async addBlacklistItem(e2) { - let { starId: t2, name: n2, allName: a2, role: i2, movieType: s2, url: o2 } = e2; - if (!t2) throw new Error("缺失starId"); - if (!n2) throw new Error("缺失name"); - if (!i2) throw new Error("缺失role"); - const r2 = await this.getBlacklist(), l2 = r2.find(((e3) => e3.starId === t2)); - if (l2) l2.url = o2, l2.role = i2, l2.movieType = s2, clog.log("更新黑名单演员信息", l2); - else { - const e3 = { - starId: t2, - name: n2, - allName: a2 || [n2], - createTime: utils.getNowStr(), - role: i2, - movieType: s2, - url: o2 - }; - r2.push(e3), clog.log("增加黑名单演员信息", e3); + /** @param {any} event */ + async _receive(event) { + if (!event || event.originId === this.originId || event.eventId && this.seen.has(event.eventId)) return; + if (!event.eventId) { + const legacyType = "refresh" === event.type ? "legacy-refresh" : event.type; + return this._dispatch({ ...event, type: legacyType, payload: event.payload || {}, eventId: `legacy_${Date.now()}_${Math.random()}`, originId: "legacy", timestamp: Date.now() }); } - await this._setItemAndInvalidate(this.blacklist_key, r2); + this._remember(event.eventId), await this._dispatch(event); } - async updateBlacklistItem(e2) { - if (!e2 || !e2.starId) throw new Error("参数不全"); - const t2 = await this.getBlacklist(), n2 = t2.find(((t3) => t3.starId === e2.starId)); - if (!n2) throw new Error(`未找到黑名单演员信息:${e2.name} ${e2.starId}`); - e2.checkTime && (n2.checkTime = e2.checkTime), e2.lastPublishTime && (n2.lastPublishTime = e2.lastPublishTime), await this._setItemAndInvalidate(this.blacklist_key, t2); + }; + __name(_JhsEventBus, "JhsEventBus"); + var JhsEventBus = _JhsEventBus; + var jhsEventBus; + function initializeEventBus() { + if (jhsEventBus) return jhsEventBus; + jhsEventBus = new JhsEventBus(); + const runtimeWindow = ( + /** @type {any} */ + window + ); + runtimeWindow.refresh = () => jhsEventBus?.emit("legacy-refresh"); + runtimeWindow.cleanCache_filter_actor_actress_car_list = () => jhsEventBus?.emit("blacklist-rules-changed"); + runtimeWindow.clean_cacheSettingObj = () => jhsEventBus?.emit("settings-changed"); + return jhsEventBus; + } + __name(initializeEventBus, "initializeEventBus"); + + // src/core/theme.js + var JHS_Z_INDEX = Object.freeze({ + content: 10, + elevated: 20, + localPopover: 30, + popover: 100, + dropdown: 1e3, + fabBackdrop: 1e4, + fabMenu: 10001, + fab: 10002, + debugLow: 12345678, + hostNav: 12345679, + hostTopbar: 12345689, + modal: 12345699, + sheetBackdrop: 12345789, + sheet: 12345790, + loading: 99999999, + viewer: 999999990, + layer: 999999991, + debug: 999999999, + tooltip: 9999999999 + }); + async function applyTheme() { + const mode = await storageManager.getSetting("themeMode", "light"); + let resolved = "light"; + if ("dark" === mode) resolved = "dark"; + else if ("auto" === mode) resolved = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + document.documentElement.setAttribute("data-jhs-theme", resolved); + } + __name(applyTheme, "applyTheme"); + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (() => { + storageManager.getSetting("themeMode", "light").then(((e2) => { + "auto" === e2 && applyTheme(); + })); + })); + + // src/core/utils.js + var _Utils = class _Utils { + constructor() { + return i(this, "intervalContainer", {}), i(this, "waitSequence", 0), i(this, "mimeTypes", { + txt: "text/plain", + html: "text/html", + css: "text/css", + csv: "text/csv", + json: "application/json", + xml: "application/xml", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + pdf: "application/pdf", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + zip: "application/zip", + rar: "application/x-rar-compressed", + "7z": "application/x-7z-compressed", + mp3: "audio/mpeg", + wav: "audio/wav", + mp4: "video/mp4", + webm: "video/webm", + ogg: "audio/ogg" + }), i(this, "timers", /* @__PURE__ */ new Map()), i(this, "insertStyle", ((e2) => { + const t2 = (Array.isArray(e2) ? e2 : [e2]).filter(Boolean); + if (0 === t2.length) return; + const n2 = t2.map(((e3) => e3.replace(/^\s*]*>/i, "").replace(/<\/style>?\s*$/i, ""))).filter(Boolean).join("\n"), a2 = document.createElement("style"); + n2 && (a2.textContent = n2, document.head.append(a2)); + })), i(this, "layerIndexStack", []), _Utils.instance || (_Utils.instance = this), _Utils.instance; } - async deleteBlacklistItem(e2) { - const t2 = await this.getBlacklist(), n2 = t2.filter(((t3) => t3.starId !== e2)); - t2.length !== n2.length && await this._setItemAndInvalidate(this.blacklist_key, n2); + importResource(e2) { + let t2; + e2.indexOf("css") >= 0 ? (t2 = document.createElement("link"), t2.setAttribute("rel", "stylesheet"), t2.href = e2) : (t2 = document.createElement("script"), t2.setAttribute("type", "text/javascript"), t2.src = e2), document.documentElement.appendChild(t2); } - async getBlacklistCarList() { - return this._readCached("cache_filter_actor_actress_car_list", this.blacklist_car_list_key, []); + openPage(e2, t2, n2, a2) { + n2 = n2 ?? true; + const navigation = a2 && (Object.prototype.hasOwnProperty.call(a2, "event") || Object.prototype.hasOwnProperty.call(a2, "newTab")) ? a2 : { event: a2 }, event = navigation.event; + const destination = new URL(e2, window.location.origin), carNum = normalizeCarNum(t2), isMovieDetail = /^\/v\/[^/]+/.test(destination.pathname); + isMovieDetail && carNum && destination.searchParams.set("jhsCarNum", carNum); + if (navigation.newTab || event && (event.ctrlKey || event.metaKey || 1 === event.button)) return void GM_openInTab(destination.href, { + insert: 0 + }); + destination.pathname.includes("/actors/") || destination.pathname.includes("/star/") || destination.searchParams.set("hideNav", "1"); + layer.open({ + type: 2, + title: t2, + content: destination.href, + scrollbar: false, + shadeClose: n2, + area: this.getDialogArea("workspace"), + isOutAnim: false, + anim: -1, + success: /* @__PURE__ */ __name((e3, t3) => { + this.setupEscClose(t3); + }, "success") + }); } - async batchSaveBlacklistCarList(e2) { - const t2 = await this.getBlacklistCarList(), n2 = JSON.parse(JSON.stringify(t2)); - let a2 = false, i2 = []; - for (const s2 of e2) { - n2.find(((e3) => e3.carNum === s2.carNum)) || (this._saveSingleCar(s2, n2), clog.log(`屏蔽演员番号: ${escapeHtml(s2.names)} ${escapeHtml(s2.carNum)}`), a2 = true, i2.push(s2.carNum)); - } - if (a2) { - await this._setItemAndInvalidate(this.blacklist_car_list_key, n2); - const result = await this.removeNewVideoList(i2); - result.changed.length && await window.jhsEventBus?.emit?.("new-video-changed", { - reason: "blacklist-car-removed", - carNums: result.changed - }); - await window.cleanCache_filter_actor_actress_car_list(); + _handleGlobalEscKey(e2) { + if ("Escape" !== e2.key && 27 !== e2.keyCode) return; + if (0 === this.layerIndexStack.length) return; + const t2 = this.layerIndexStack[this.layerIndexStack.length - 1], n2 = $(`#layui-layer${t2}`); + let a2 = false; + if (n2.find(".viewer-container").length > 0) a2 = true; + else { + const e3 = n2.find(`#layui-layer-iframe${t2}`)[0]; + if (e3 && e3.contentDocument) try { + $(e3.contentDocument).find(".viewer-container").length > 0 && (a2 = true); + } catch (i2) { + clog.warn("无法检查跨域 iframe 内的 .viewer-container"); + } } - return { changed: i2.map(normalizeCarNum).filter(Boolean) }; + a2 || (this.layerIndexStack.pop(), layer.close(t2)); } - async removeBlacklistCarList(e2) { - const t2 = await this.getBlacklistCarList(), n2 = t2.filter(((t3) => t3.starId !== e2)); - n2.length !== t2.length && (await this._setItemAndInvalidate(this.blacklist_car_list_key, n2), window.cleanCache_filter_actor_actress_car_list()); + setupEscClose(e2) { + var t2; + this._boundHandler || (this._boundHandler = this._handleGlobalEscKey.bind(this), $(document).off("keydown.globalLayerEsc"), $(document).on("keydown.globalLayerEsc", this._boundHandler)), -1 === this.layerIndexStack.indexOf(e2) && this.layerIndexStack.push(e2); + const n2 = $(`#layui-layer-iframe${e2}`), a2 = `keydown.layerEsc${e2}`; + try { + const e3 = null == (t2 = n2[0]) ? void 0 : t2.contentDocument; + if (e3) { + if ("yes" === n2.attr("data-esc-bound")) return; + $(e3).off(a2), $(e3).on(a2, this._boundHandler), n2.attr("data-esc-bound", "yes"); + } + } catch (i2) { + clog.error("iframe监听失败 (跨域或未加载完毕):", i2); + } } - async getFavoriteActressList() { - return this._readCached("cacheFavoriteActresses", this.favorite_actresses_key, []); + async closePage(options = {}) { + if ("yes" !== await storageManager.getSetting("needClosePage", "yes")) return false; + const root = options?.root, parseIndex = /* @__PURE__ */ __name((element) => { + const id = element?.id || "", match = /^layui-layer(\d+)$/.exec(id); + return match ? Number(match[1]) : null; + }, "parseIndex"); + let layerIndex = Number.isInteger(options?.layerIndex) ? options.layerIndex : null; + if (null === layerIndex && root) { + const element = root.jquery ? root[0] : root.nodeType ? root : null, layerElement = element?.matches?.(".layui-layer") ? element : element?.closest?.(".layui-layer"); + layerIndex = parseIndex(layerElement); + } + if (null === layerIndex && window.frameElement) layerIndex = parseIndex(window.frameElement.closest?.(".layui-layer")); + const ownerWindow = window.parent && window.parent !== window ? window.parent : window, ownerLayer = ownerWindow.layer || globalThis.layer; + if (null !== layerIndex && "function" == typeof ownerLayer?.close) return ownerLayer.close(layerIndex), true; + if (window.opener && !window.opener.closed) return window.close(), true; + return false; } - async addFavoriteActressList(e2) { - return this.withActressLock(async () => { - const t2 = await this.getFavoriteActressList(); - let n2 = 0; - for (const a2 of e2) { - let { starId: e3, name: i2, allName: s2, avatar: o2, lastCheckTime: r2, lastPublishTime: l2, actressType: c2 } = a2; - if (!e3) throw new Error("缺失starId"); - if (!i2) throw new Error("缺失name"); - s2 || (s2 = [i2]); - const d2 = "(無碼)"; - if (!c2) { - c2 = i2.includes(d2) || s2.some(((e4) => e4.includes(d2))) ? A : D; - } - i2 = i2.replace(d2, ""), s2 = s2.map(((e4) => e4.replace(d2, ""))); - let h2 = t2.find(((t3) => t3.starId === e3)); - if (h2) { - h2.avatar && h2.avatar.includes("https") || o2 && (clog.log(o2), h2.avatar = o2, clog.log(`补全女优头像: ${escapeHtml(i2)}`), n2++), !h2.actressType && c2 && (h2.actressType = c2, clog.log(`补全女优类别: ${escapeHtml(i2)} ${escapeHtml(c2)}`), n2++), h2.name.includes(d2) && (h2.name = i2, h2.allName = s2, clog.log(`更正女优名字: ${escapeHtml(i2)} ${escapeHtml(s2)}`), n2++); - continue; - } - const g2 = utils.getNowStr(); - t2.push({ - starId: e3, - name: i2, - allName: s2, - avatar: o2, - lastCheckTime: r2, - lastPublishTime: l2, - createDate: g2, - updateDate: g2, - actressType: c2 - }), clog.log(`同步JavDB已收藏的演员: ${escapeHtml(i2)}`), n2++; + loopDetector(e2, t2, n2 = 20, a2 = 1e4, i2 = true) { + const s2 = ++this.waitSequence; + let o2 = null, r2 = null, l2 = null, c2 = false; + const d2 = /* @__PURE__ */ __name(() => { + o2?.disconnect(), clearTimeout(r2), clearTimeout(l2), clearInterval(this.intervalContainer[s2]?.fallback), delete this.intervalContainer[s2]; + }, "d"), h2 = /* @__PURE__ */ __name((e3) => { + if (c2) return; + c2 = true, d2(), e3 && t2 && t2(); + }, "h"), g2 = /* @__PURE__ */ __name(() => { + if (c2) return; + e2() && h2(true); + }, "g"), p2 = /* @__PURE__ */ __name(() => { + c2 || (clearTimeout(r2), r2 = setTimeout(g2, Math.max(0, n2))); + }, "p"); + this.intervalContainer[s2] = {}; + if (e2()) return void h2(true); + if ("function" == typeof MutationObserver && document.documentElement) o2 = new MutationObserver(p2), o2.observe(document.documentElement, { childList: true, subtree: true, characterData: true }); + else this.intervalContainer[s2].fallback = setInterval(g2, Math.max(100, n2)); + l2 = setTimeout((() => { + if (c2) return; + let t3 = false; + try { + t3 = e2(); + } catch (e3) { + clog.error("DOM 等待条件执行失败", e3); } - return n2 > 0 ? await this._setItemAndInvalidate(this.favorite_actresses_key, t2) : clog.log("信息已记录, 无需要进行同步收藏的演员"), n2; - }); + h2(t3 || i2); + }), Math.max(0, a2)); } - async removeFavoriteActress(e2) { - return this.withActressLock(async () => { - const t2 = await this.getFavoriteActressList(), n2 = t2.length, a2 = t2.filter(((t3) => t3.starId !== e2)); - return a2.length === n2 ? (clog.error(`移除演员失败, ${e2} 不存在`), false) : (await this._setItemAndInvalidate(this.favorite_actresses_key, a2), true); - }); + rightClick(e2, t2, n2) { + let a2; + "string" == typeof e2 ? a2 = document.querySelector(e2) : e2 instanceof HTMLElement && (a2 = e2), a2 || (clog.warn("rightClick(), 容器无效或未提供,将使用 document.body 进行全局委托。"), a2 = document.body), "string" == typeof t2 && "" !== t2.trim() ? a2.addEventListener("contextmenu", ((e3) => { + const a3 = e3.target.closest(t2); + a3 && n2(e3, a3); + })) : clog.error("rightClick(), 必须提供有效的 targetSelector。"); } - async updateFavoriteActress(e2) { - return this.withActressLock(() => this._rawUpdateFavoriteActress(e2)); + q(e2, t2, n2, a2) { + let o2; + if (this.isMobileMode()) { + o2 = layer.confirm(t2, { + title: "提示", + btn: ["确定", "取消"], + shade: 0, + zIndex: JHS_Z_INDEX.layer + }, (function() { + n2 && n2(), layer.close(o2); + }), (function() { + a2 && a2(); + })); + } else { + let i2, s2; + e2 ? (i2 = e2.clientX - 130, s2 = e2.clientY - 120) : (i2 = window.innerWidth / 2 - 120, s2 = window.innerHeight / 2 - 120); + o2 = layer.confirm(t2, { + offset: [s2, i2], + title: "提示", + btn: ["确定", "取消"], + shade: 0, + zIndex: JHS_Z_INDEX.layer + }, (function() { + n2 && n2(), layer.close(o2); + }), (function() { + a2 && a2(); + })); + } } - async getHighlightedTags() { - return await this.forage.getItem(this.highlighted_tags_key) || []; + getNowStr(e2 = "-", t2 = ":", n2 = null) { + let a2; + a2 = n2 ? new Date(n2) : /* @__PURE__ */ new Date(); + const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); + return `${[i2, s2, o2].join(e2)} ${[r2, l2, c2].join(t2)}`; } - async setHighlightedTags(e2) { - return await this.forage.setItem(this.highlighted_tags_key, e2); + formatDate(e2, t2 = "-", n2 = ":") { + let a2; + if (e2 instanceof Date) a2 = e2; + else { + if ("string" != typeof e2) throw new Error("Invalid date input: must be Date object or date string"); + if (a2 = new Date(e2), isNaN(a2.getTime())) throw new Error("Invalid date string"); + } + const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); + return `${[i2, s2, o2].join(t2)} ${[r2, l2, c2].join(n2)}`; } - async saveTitleFilterKeyword(n2) { - if (await s(this, e, t).call(this, n2, this.filter_keyword_title_key, "标题关键词"), Array.isArray(n2)) return null; - return this.withActressLock(async () => { - const a2 = await this.getFavoriteActressList(); - let i2 = false; - const o2 = a2.map(((e2) => { - if (!e2.newVideoList || !Array.isArray(e2.newVideoList)) return e2; - const t2 = e2.newVideoList.filter(((t3) => { - const s2 = "string" == typeof t3 ? t3 : t3.carNum, a3 = s2.startsWith(n2); - return a3 && (clog.log("移除关联女优新作品", e2.name, s2), i2 = true), !a3; - })); - return { - ...e2, - newVideoList: t2 - }; - })); - i2 && await this._setItemAndInvalidate(this.favorite_actresses_key, o2); + getHourDifference(e2, t2) { + const n2 = e2.getTime(), a2 = t2.getTime(), i2 = Math.abs(a2 - n2) / 36e5; + return Math.floor(i2); + } + download(e2, t2) { + show.info("开始请求下载..."); + const n2 = t2.split(".").pop().toLowerCase(); + let a2, i2 = this.mimeTypes[n2] || "application/octet-stream"; + if (e2 instanceof Blob) a2 = e2; + else if (e2 instanceof ArrayBuffer || ArrayBuffer.isView(e2)) a2 = new Blob([e2], { + type: i2 + }); + else if ("string" == typeof e2 && e2.startsWith("data:")) { + const t3 = atob(e2.split(",")[1]), n3 = new ArrayBuffer(t3.length), s3 = new Uint8Array(n3); + for (let e3 = 0; e3 < t3.length; e3++) s3[e3] = t3.charCodeAt(e3); + a2 = new Blob([s3], { + type: i2 + }); + } else a2 = new Blob([e2], { + type: i2 }); + const s2 = URL.createObjectURL(a2), o2 = document.createElement("a"); + o2.href = s2, o2.download = t2, document.body.appendChild(o2), o2.click(), setTimeout((() => { + document.body.removeChild(o2), URL.revokeObjectURL(s2); + }), 100); } - async getTitleFilterKeyword() { - return this._readCached("cacheTitleFilterKeyword", this.filter_keyword_title_key, []); + smoothScrollToTop(e2 = 500) { + return new Promise(((t2) => { + const n2 = performance.now(), a2 = window.pageYOffset; + window.requestAnimationFrame(/* @__PURE__ */ __name((function i2(s2) { + const o2 = s2 - n2, r2 = Math.min(o2 / e2, 1), l2 = r2 < 0.5 ? 4 * r2 * r2 * r2 : 1 - Math.pow(-2 * r2 + 2, 3) / 2; + window.scrollTo(0, a2 * (1 - l2)), r2 < 1 ? window.requestAnimationFrame(i2) : t2(); + }), "i")); + })); } - async getReviewFilterKeywordList() { - return await this.forage.getItem(this.filter_keyword_review_key) || []; + isUrl(e2) { + try { + return new URL(e2), true; + } catch (t2) { + return false; + } } - async saveReviewFilterKeyword(n2) { - return s(this, e, t).call(this, n2, this.filter_keyword_review_key, "评论关键词"); + setHrefParam(e2, t2) { + const n2 = new URL(window.location.href); + n2.searchParams.set(e2, t2), window.history.pushState({}, "", n2.toString()); } - async getSetting(e2 = null, t2) { - let n2 = await this._readCached("cacheSettingObj", this.setting_key, {}); - if (null === e2) return n2; - if (!Object.prototype.hasOwnProperty.call(n2, e2)) return t2; - const a2 = n2[e2]; - return "true" === a2 || "false" === a2 ? "true" === a2.toLowerCase() : "string" != typeof a2 || "" === a2.trim() || isNaN(Number(a2)) ? a2 : Number(a2); + getUrlParam(e2, t2) { + const n2 = e2.split("?")[1]; + if (!n2) return null; + const a2 = new RegExp(`(?:^|&)${t2}=([^&]*)`), i2 = n2.match(a2); + let s2 = ""; + return i2 && i2[1] && (s2 = decodeURIComponent(i2[1].replace(/\+/g, " "))), s2 ? "true" === s2 || "false" === s2 ? "true" === s2.toLowerCase() : "string" != typeof s2 || "" === s2.trim() || isNaN(Number(s2)) ? s2 : Number(s2) : s2; } - getSettingSync(e2, t2) { - if (!this.cacheSettingObj) return t2; - if (!Object.prototype.hasOwnProperty.call(this.cacheSettingObj, e2)) return t2; - const n2 = this.cacheSettingObj[e2]; - return "true" === n2 || "false" === n2 ? "true" === n2.toLowerCase() : "string" != typeof n2 || "" === n2.trim() || isNaN(Number(n2)) ? n2 : Number(n2); + getResponsiveArea(e2) { + const t2 = window.innerWidth; + return this.isMobileMode() ? ["100%", "90%"] : t2 >= 1200 ? e2 || this.getDefaultArea() : ["70%", "90%"]; } - async saveSetting(e2) { - e2 ? (await this._setItemAndInvalidate(this.setting_key, e2), await window.clean_cacheSettingObj()) : show.error("设置对象为空"); + /** 按用途返回具有固定上限和安全边距的弹窗尺寸。 */ + getDialogArea(e2 = "md") { + const t2 = { + sm: [480, 640], + md: [720, 700], + lg: [1040, 760], + xl: [1320, 860], + workspace: [1440, 960] + }, n2 = t2[e2] || t2.md, a2 = window.innerWidth <= 768 ? 16 : "workspace" === e2 ? 32 : 64, i2 = Math.max(320, Math.min(n2[0], window.innerWidth - a2)), s2 = Math.max(320, Math.min(n2[1], window.innerHeight - a2)); + return [`${i2}px`, `${s2}px`]; } - async saveSettingItem(e2, t2) { - if (!e2) return void show.error("key 不能为空"); - await navigator.locks.request("jhs_setting_lock", async () => { - let n2 = await this.getSetting(); - n2[e2] = t2, await this.saveSetting(n2); - }); + getDefaultArea() { + return ["85%", "90%"]; } - async importData(e2) { - validatePortableData(e2); - await hasPortableUserData(this) && await this.createSnapshot("导入前自动备份", "auto-import"); - const validKeys = /* @__PURE__ */ new Set([...PORTABLE_DATA_KEYS, "data_version"]), writes = []; - for (const key in e2) { - if (!validKeys.has(key)) { - clog.warn(`[导入] 跳过未知数据键: ${key}`); - continue; + isMobile() { + const e2 = navigator.userAgent.toLowerCase(); + return ["iphone", "ipod", "ipad", "android", "blackberry", "windows phone", "nokia", "webos", "opera mini", "mobile", "mobi", "tablet"].some(((t2) => e2.includes(t2))); + } + isMobileMode() { + const e2 = storageManager.getSettingSync("mobileMode", "auto"); + return "on" === e2 || "off" !== e2 && (this.isMobile() || window.innerWidth < 768); + } + async copyToClipboard(e2, t2) { + const text = String(t2 ?? ""); + let copied = false; + try { + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text), copied = true; + else throw new Error("Clipboard API unavailable"); + } catch (clipboardError) { + const textarea = document.createElement("textarea"), activeElement = document.activeElement; + textarea.value = text, textarea.setAttribute("readonly", ""), textarea.style.position = "fixed", textarea.style.opacity = "0", document.body.appendChild(textarea), textarea.select(); + try { + copied = true === document.execCommand("copy"); + if (!copied) throw clipboardError; + } catch (fallbackError) { + clog.error("复制失败:", fallbackError), show.error("复制失败,请手动复制"); + } finally { + textarea.remove(), activeElement?.focus?.(); } - writes.push("data_version" === key ? this.setDataVersion(e2[key]) : this._setItemAndInvalidate(key, e2[key])); } - await Promise.all(writes), this._invalidateCache(), await runDataMigrations(this), await window.stateService?.recoverPendingTransaction(); + return copied && show.info(`${e2}已复制到剪贴板, ${text}`), copied; } - async exportPortableData() { - const data = { data_version: await this.getDataVersion() }; - for (const key of PORTABLE_DATA_KEYS) { - const value = await this.forage.getItem(key); - null != value && (data[key] = value); + htmlTo$dom(e2) { + const t2 = new DOMParser(); + return $(t2.parseFromString(e2, "text/html")); + } + isHidden(e2) { + const t2 = e2.jquery ? e2[0] : e2; + return !t2 || (t2.offsetWidth <= 0 && t2.offsetHeight <= 0 || "none" === window.getComputedStyle(t2).display); + } + time(e2 = "default", t2 = "s", n2 = 2) { + if (this.timers.has(e2)) { + const t3 = this.timers.get(e2), n3 = performance.now() - t3.startTime; + let a2, i2; + return "s" === t3.unit ? (a2 = (n3 / 1e3).toFixed(t3.precision), i2 = "秒") : (a2 = n3.toFixed(t3.precision), i2 = "毫秒"), this.timers.delete(e2), `${e2}: ${a2}${i2}`; } - return data; + this.timers.set(e2, { + startTime: performance.now(), + unit: t2, + precision: n2 + }); } - async exportData() { - const data = await this.exportPortableData(); - if (Object.keys(data).length <= 1) throw new Error("没有可导出的数据"); - return data; + sleep(e2 = 1e3) { + return new Promise(((t2) => setTimeout(t2, e2))); } - async getThirdPartyCache() { - return await this.forage.getItem(this.third_party_cache_key) || {}; + /** 创建可取消的尾沿防抖函数。 */ + debounce(callback, wait = 200) { + let timer = null; + const debounced = /* @__PURE__ */ __name(function(...args) { + const context = this; + clearTimeout(timer), timer = setTimeout((() => { + timer = null, callback.apply(context, args); + }), Math.max(0, wait)); + }, "debounced"); + return debounced.cancel = () => { + clearTimeout(timer), timer = null; + }, debounced; } - async setThirdPartyCache(e2) { - await this.forage.setItem(this.third_party_cache_key, e2 || {}); + genericSort(e2, t2, n2 = true) { + if (!Array.isArray(e2) || 0 === e2.length) return []; + if (!Array.isArray(t2) || 0 === t2.length) return [...e2]; + const i2 = /* @__PURE__ */ __name((e3) => { + if (e3 instanceof Date) return e3; + if ("string" == typeof e3) { + const t3 = new Date(e3); + if (!isNaN(t3.getTime())) return t3; + } + return e3; + }, "i"); + const getVal = /* @__PURE__ */ __name((e3, t3) => null != t3 ? "function" == typeof t3 ? t3(e3) : e3 && "object" == typeof e3 ? e3[t3] : void 0 : e3, "getVal"); + const nulls = [], nonNulls = []; + for (const item of e2) { + let hasNull = false; + for (const s2 of t2) { + const val = getVal(item, s2.key); + if (null == val || void 0 === val) { + hasNull = true; + break; + } + } + hasNull ? nulls.push(item) : nonNulls.push(item); + } + return nonNulls.sort(((e3, a2) => { + for (const s2 of t2) { + const { key: t3, order: o2 = "asc" } = s2; + let r2 = getVal(e3, t3), l2 = getVal(a2, t3); + const c2 = i2(r2), d2 = i2(l2); + let h2 = c2 instanceof Date && d2 instanceof Date ? c2.getTime() - d2.getTime() : "number" == typeof r2 && "number" == typeof l2 ? r2 - l2 : "string" == typeof r2 && "string" == typeof l2 ? r2.localeCompare(l2) : String(r2).localeCompare(String(l2)); + "desc" === o2 && (h2 *= -1); + if (0 !== h2) return h2; + } + return 0; + })), n2 ? [...nonNulls, ...nulls] : [...nulls, ...nonNulls]; } - async clearThirdPartyCache() { - await this.forage.removeItem(this.third_party_cache_key); + async retry(e2, t2 = 3) { + let n2 = 0; + for (; n2 < t2; ) try { + const t3 = await e2(); + return n2 > 0 && clog.debug(`[重试] 请求成功,共发起 ${n2 + 1} 次。`), t3; + } catch (a2) { + const e3 = a2 instanceof Error ? a2.message : "object" == typeof a2 ? JSON.stringify(a2) : String(a2); + if (a2?._cfBlocked || a2?._circuitBroken || e3.includes("Just a moment") || e3.includes("重定向") || e3.toLowerCase().includes("404 not found")) throw a2; + if (n2++, n2 === t2) throw clog.debug(`[重试] 达到最大重试次数 (${t2}),最终失败:`, a2), a2; + await this.sleep(500 * n2), clog.debug(`[重试] 请求失败,准备第 ${n2 + 1} 次重试, 错误信息: ${e3}`); + } } - async deleteCachedRequest(key) { - const cache = await this.getThirdPartyCache(); - Object.prototype.hasOwnProperty.call(cache, key) && (delete cache[key], await this.setThirdPartyCache(cache)); + }; + __name(_Utils, "Utils"); + var Utils = _Utils; + + // src/core/storage-index.js + function createIndexedMap(items, key) { + return new Map(items.filter(((item) => item && item[key])).map(((item) => [item[key], item]))); + } + __name(createIndexedMap, "createIndexedMap"); + function createStatusMap(items) { + const statusMap = { + [d]: /* @__PURE__ */ new Set(), + [h]: /* @__PURE__ */ new Set(), + [g]: /* @__PURE__ */ new Set(), + [p]: /* @__PURE__ */ new Set() + }; + items.forEach(((item) => { + if (!item?.carNum) return; + const flags = normalizeStateFlags(item.stateFlags); + flags.blocked && statusMap[d].add(item.carNum), flags.favorite && statusMap[h].add(item.carNum), flags.downloaded && statusMap[g].add(item.carNum), flags.watched && statusMap[p].add(item.carNum); + })); + return statusMap; + } + __name(createStatusMap, "createStatusMap"); + function groupDuplicateItems(items, key) { + const counts = /* @__PURE__ */ new Map(); + items.forEach(((item) => { + const value = item && item[key]; + value && counts.set(value, (counts.get(value) || 0) + 1); + })); + return Array.from(counts.entries()).filter(((item) => item[1] > 1)); + } + __name(groupDuplicateItems, "groupDuplicateItems"); + function dedupeByKey(items, key) { + const seen = /* @__PURE__ */ new Map(), list = []; + let changed = false; + for (const item of items) { + const value = item && item[key]; + if (!value) { + list.push(item); + continue; + } + if (seen.has(value)) { + Object.assign(seen.get(value), item), changed = true; + } else seen.set(value, item), list.push(item); } - async cachedRequest(e2, t2, n2) { - const a2 = Date.now(), i2 = await this.getThirdPartyCache(), s2 = i2[e2]; - if (s2 && s2.time && a2 - s2.time < (s2.ttl || t2)) return this._cacheStats.hits++, s2.data; - this._cacheStats.misses++; - const loaded = await n2(), customTtl = loaded && "object" === typeof loaded && "__jhsCacheTtl" in loaded ? loaded.__jhsCacheTtl : t2; - const o2 = loaded && "object" === typeof loaded && "__jhsCacheTtl" in loaded ? loaded.data : loaded; - if (void 0 === o2 || null === o2) return o2; - return i2[e2] = { - time: a2, - ttl: customTtl, - data: o2 - }, await this.setThirdPartyCache(i2), o2; + return { + list, + changed + }; + } + __name(dedupeByKey, "dedupeByKey"); + + // src/core/storage.js + var e = /* @__PURE__ */ new WeakSet(); + var t = /* @__PURE__ */ __name(async function(e2, t2, n2) { + let a2; + if (Array.isArray(e2)) a2 = [...e2]; + else { + if (a2 = await this.forage.getItem(t2) || [], a2.includes(e2)) { + const t3 = `${e2} ${n2}已存在`; + throw show.error(t3), new Error(t3); + } + a2.push(e2); } - getCacheHitStats() { - const e2 = this._cacheStats.hits + this._cacheStats.misses; - return { - hits: this._cacheStats.hits, - misses: this._cacheStats.misses, - total: e2, - rate: e2 > 0 ? (this._cacheStats.hits / e2 * 100).toFixed(1) + "%" : "N/A" - }; + return await this._setItemAndInvalidate(t2, a2), a2; + }, "t"); + var _StorageManager = class _StorageManager { + constructor() { + this.stateService = globalThis.stateService ?? null; + var t2, s2, o2; + if (t2 = this, (s2 = e).has(t2) ? a("Cannot add the same private member more than once") : s2 instanceof WeakSet ? s2.add(t2) : s2.set(t2, o2), i(this, "car_list_key", "car_list"), i(this, "filter_keyword_title_key", "filter_keyword_title"), i(this, "filter_keyword_review_key", "filter_keyword_review"), i(this, "setting_key", "setting"), i(this, "blacklist_key", "blacklist"), i(this, "blacklist_car_list_key", "blacklist_car_list"), i(this, "third_party_cache_key", "third_party_ttl_cache"), i(this, "favorite_actresses_key", "favorite_actresses"), i(this, "highlighted_tags_key", "highlighted_tags"), i(this, "_actressLock", Promise.resolve()), i(this, "forage", localforage.createInstance({ + driver: localforage.INDEXEDDB, + name: "JAV-JHS", + version: 1, + storeName: "appData" + })), i(this, "cacheCarList", null), i(this, "cacheBlacklist", null), i(this, "cacheTitleFilterKeyword", null), i(this, "cacheFavoriteActresses", null), i(this, "cache_filter_actor_actress_car_list", null), i(this, "cacheSettingObj", null), i(this, "cacheCarMap", null), i(this, "cacheStatusMap", null), i(this, "cacheBlacklistMap", null), i(this, "_pendingReads", /* @__PURE__ */ new Map()), i(this, "_cacheGenerations", /* @__PURE__ */ new Map()), i(this, "_cacheStats", { hits: 0, misses: 0 }), _StorageManager.instance) throw new Error("StorageManager已被实例化过了!"); + _StorageManager.instance = this; } - _groupDuplicateItems(e2, t2) { - return groupDuplicateItems(e2, t2); + async getDataVersion() { + return await this.forage.getItem("data_version") || 0; } - _dedupeByKey(e2, t2) { - return dedupeByKey(e2, t2); + async setDataVersion(e2) { + await this.forage.setItem("data_version", e2); } - async inspectDataHealth() { - const [e2, t2, n2, a2] = await Promise.all([this.getCarList(), this.getFavoriteActressList(), this.getBlacklist(), this.getBlacklistCarList()]), i2 = await this.getCarMap(), s2 = { - checkedAt: utils.getNowStr(), - totals: { - carList: e2.length, - favoriteActresses: t2.length, - blacklist: n2.length, - blacklistCarList: a2.length - }, - fixable: [], - readonly: [] - }, o2 = /* @__PURE__ */ __name((e3, t3, n3) => s2.fixable.push({ - type: e3, - message: t3, - count: n3 - }), "o"), r2 = /* @__PURE__ */ __name((e3, t3, n3) => s2.readonly.push({ - type: e3, - message: t3, - count: n3 - }), "r"); - const l2 = this._groupDuplicateItems(e2, "carNum"), c2 = this._groupDuplicateItems(t2, "starId"), d2 = this._groupDuplicateItems(n2, "starId"); - l2.length && o2("duplicate-car", "重复番号记录", l2.length), c2.length && o2("duplicate-actress", "重复收藏演员", c2.length), d2.length && o2("duplicate-blacklist", "重复黑名单演员", d2.length); - const h2 = e2.filter(((e3) => e3 && e3.actress)).length, g2 = a2.filter(((e3) => e3 && e3.actress)).length, p2 = t2.filter(((e3) => e3 && Object.prototype.hasOwnProperty.call(e3, "dbId"))).length, m2 = n2.filter(((e3) => e3 && ("key" in e3 || "recordTime" in e3 || "isActor" in e3))).length; - h2 + g2 + p2 + m2 > 0 && o2("legacy-fields", "旧字段残留", h2 + g2 + p2 + m2); - const u2 = t2.filter(((e3) => e3 && e3.name && !Array.isArray(e3.allName))).length + n2.filter(((e3) => e3 && e3.name && !Array.isArray(e3.allName))).length; - u2 && o2("invalid-all-name", "演员别名不是数组", u2); - let f = 0; - t2.forEach(((e3) => { - Array.isArray(e3?.newVideoList) && (f += e3.newVideoList.filter(((e4) => { - const t3 = "string" == typeof e4 ? e4 : e4.carNum; - return i2.has(t3); - })).length); - })), f && o2("stored-new-video", "新作品列表中已有鉴定记录", f); - const v2 = e2.filter(((e3) => e3 && e3.carNum && !e3.url)).length; - v2 && r2("missing-url", "番号记录缺失 url,需要人工确认来源", v2); - const b2 = t2.filter(((e3) => e3 && !e3.starId)).length + n2.filter(((e3) => e3 && !e3.starId)).length; - b2 && r2("missing-star-id", "演员缺失 starId,需要人工确认身份", b2); - const w = new Set(n2.map(((e3) => e3 && e3.starId)).filter(Boolean)), y2 = a2.filter(((e3) => e3 && e3.starId && !w.has(e3.starId))).length; - return y2 && r2("orphan-blacklist-car", "黑名单作品找不到关联演员", y2), s2; - } - async repairDataHealth() { - let e2 = 0, t2 = await this.getCarList(), n2 = await this.getFavoriteActressList(), a2 = await this.getBlacklist(), i2 = await this.getBlacklistCarList(); - const s2 = this._dedupeByKey(t2, "carNum"); - s2.changed && (t2 = s2.list, e2++); - const o2 = this._dedupeByKey(n2, "starId"); - o2.changed && (n2 = o2.list, e2++); - const r2 = this._dedupeByKey(a2, "starId"); - r2.changed && (a2 = r2.list, e2++); - t2 = t2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return void 0 !== t3.actress && (t3.names = t3.actress, delete t3.actress, n3 = true), n3 && e2++, t3; - })), i2 = i2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return void 0 !== t3.actress && (t3.names = t3.actress, delete t3.actress, n3 = true), Object.prototype.hasOwnProperty.call(t3, "type") && (delete t3.type, n3 = true), n3 && e2++, t3; - })), n2 = n2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return Object.prototype.hasOwnProperty.call(t3, "dbId") && (t3.starId || (t3.starId = t3.dbId), delete t3.dbId, n3 = true), t3.name && !Array.isArray(t3.allName) && (t3.allName = t3.allName ? [t3.allName] : [t3.name], n3 = true), n3 && e2++, t3; - })), a2 = a2.map(((t3) => { - if (!t3) return t3; - let n3 = false; - return Object.prototype.hasOwnProperty.call(t3, "isActor") && (t3.role || (t3.role = t3.isActor ? B : P), delete t3.isActor, n3 = true), Object.prototype.hasOwnProperty.call(t3, "recordTime") && (t3.createTime || (t3.createTime = t3.recordTime), delete t3.recordTime, n3 = true), Object.prototype.hasOwnProperty.call(t3, "key") && (delete t3.key, n3 = true), t3.name && !Array.isArray(t3.allName) && (t3.allName = t3.allName ? [t3.allName] : [t3.name], n3 = true), n3 && e2++, t3; - })); - const l2 = new Set(t2.filter(((e3) => e3 && e3.carNum)).map(((e3) => e3.carNum))); - n2 = n2.map(((t3) => { - if (!Array.isArray(t3?.newVideoList)) return t3; - const n3 = t3.newVideoList.filter(((e3) => { - const n4 = "string" == typeof e3 ? e3 : e3.carNum; - return !l2.has(n4); - })); - return n3.length !== t3.newVideoList.length && (t3 = { - ...t3, - newVideoList: n3 - }, e2++), t3; - })), await this._setItemAndInvalidate(this.car_list_key, t2), await this._setItemAndInvalidate(this.favorite_actresses_key, n2), await this._setItemAndInvalidate(this.blacklist_key, a2), await this._setItemAndInvalidate(this.blacklist_car_list_key, i2); - return { - fixedGroups: e2, - report: await this.inspectDataHealth() - }; - } - /** 数据迁移: 将旧版扁平键名重命名为新格式 */ - async merge_table_name() { - let e2 = "filter_actor_actress_info_list", t2 = await this.forage.getItem(e2) || []; - t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_key, t2)), await this.forage.removeItem(e2), e2 = "favorite_actresses_info_list", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.favorite_actresses_key, t2)), await this.forage.removeItem(e2), e2 = "car_list_filter_actor_actress", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_car_list_key, t2)), await this.forage.removeItem(e2), e2 = "title_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_title_key, t2)), await this.forage.removeItem(e2), e2 = "review_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_review_key, t2)), await this.forage.removeItem(e2), e2 = "highlightedTags", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.highlighted_tags_key, t2)), await this.forage.removeItem(e2); - } - async clean_no_url_blacklist() { - const [e2, t2] = await Promise.all([this.getBlacklistCarList(), this.getBlacklist()]); - if (e2.length && !e2[0].actress) return; - const n2 = new Set(t2.map(((e3) => e3.name))), a2 = e2.filter(((e3) => !e3.actress || n2.has(e3.actress))); - e2.length !== a2.length && (clog.debug("清理 blacklistCarList 前", e2.length), clog.debug("清理 blacklistCarList 后", a2.length), await this._setItemAndInvalidate(this.blacklist_car_list_key, a2)); - const i2 = new Set(a2.map(((e3) => e3.actress))); - let s2 = t2.filter(((e3) => i2.has(e3.name))); - s2 = s2.map(((e3) => { - const { key: t3, recordTime: n3, ...a3 } = e3, i3 = a3; - return void 0 !== n3 && (i3.createTime = n3), i3; - })), (t2.length !== s2.length || t2.some(((e3) => "key" in e3 || "recordTime" in e3))) && (clog.debug("清理 Blacklist 前", t2.length), clog.debug("清理 Blacklist 后", s2.length), await this._setItemAndInvalidate(this.blacklist_key, s2)); - } - async async_merge_other() { - const e2 = await this.getSetting(); - let t2 = false; - const n2 = { - enableCheckFilterActorActress: "enableCheckBlacklist", - checkIntervalTime_filterActorActress: "checkBlacklist_intervalTime", - checkIntervalTime_ruleTime: "checkNewVideo_ruleTime", - checkIntervalTime_newVideo: "checkNewVideo_intervalTime", - checkIntervalTime_favoriteActress: "checkFavoriteActress_IntervalTime" - }; - for (const a2 in n2) { - const i2 = n2[a2]; - Object.prototype.hasOwnProperty.call(e2, a2) && (e2[i2] = e2[a2], delete e2[a2], t2 = true); - } - e2.checkFilterTime && (delete e2.checkFilterTime, t2 = true), e2.checkFilterConcurrencyCount && (delete e2.checkFilterConcurrencyCount, t2 = true), e2.checkFilterSleep && (delete e2.checkFilterSleep, t2 = true), t2 && (await this.saveSetting(e2), clog.debug("配置数据已更正")); + _getCacheGeneration(e2) { + return this._cacheGenerations.get(e2) || 0; } - /** 数据迁移: 补全黑名单条目缺失的 role/starId/allName/movieType 字段 */ - async merge_blacklist() { - const e2 = await this.getBlacklist(); - if (!e2 || 0 === e2.length) return; - let t2 = false; - const n2 = e2.map(((e3) => { - let n3 = false; - if (Object.prototype.hasOwnProperty.call(e3, "isActor") && !e3.role && (e3.role = e3.isActor ? B : P, delete e3.isActor, n3 = true), !e3.starId && e3.url) try { - const t3 = new URL(e3.url).pathname, a3 = t3.split("/").filter(((e4) => "" !== e4.trim())).pop(); - e3.starId !== a3 && (e3.starId = a3, n3 = true); - } catch (a3) { - clog.error("提取url-starId发生错误", e3.url, a3); - } - if (e3.allName || (e3.allName = e3.name ? [e3.name] : [], n3 = true), e3.movieType || (e3.movieType = D, n3 = true), e3.url && e3.url.includes("sort_type")) { - const t3 = new URL(e3.url); - t3.searchParams.delete("sort_type"), e3.url = t3.toString(), clog.debug("去除黑名单地址sort_type参数"); - } - return n3 && (t2 = true), e3; - })); - t2 && (clog.debug("更正 Blacklist 数据结构"), await this._setItemAndInvalidate(this.blacklist_key, n2)); - const a2 = await this.getBlacklistCarList(); - t2 = false; - const i2 = a2.map(((n3) => { - if (!n3.starId) { - let a3 = e2.find(((e3) => e3.name === n3.actress)); - a3 && (n3.starId = a3.starId), t2 = true; - } - return n3.type && (delete n3.type, t2 = true), n3; - })); - t2 && (clog.debug("更正 blacklistCarList 数据结构"), await this._setItemAndInvalidate(this.blacklist_car_list_key, i2)); + _invalidateRead(e2) { + this._cacheGenerations.set(e2, this._getCacheGeneration(e2) + 1); + this._pendingReads.delete(e2); } - async merge_favoriteActress() { - const e2 = await this.getFavoriteActressList(); - if (!e2 || 0 === e2.length) return; - let t2 = false; - const n2 = e2.map(((e3) => { - let n3 = false; - return e3.dbId && (e3.starId = e3.dbId, delete e3.dbId, n3 = true), n3 && (t2 = true), e3; - })); - t2 && (clog.debug("更正 favoriteActressesInfoList 数据结构"), await this._setItemAndInvalidate(this.favorite_actresses_key, n2)); + /** 合并同一存储键的并发读取,并阻止失效前的旧读取回填缓存。 */ + async _readCached(e2, t2, n2) { + if (null !== this[e2]) return this[e2]; + const a2 = this._pendingReads.get(t2); + if (a2) return a2; + const i2 = this._getCacheGeneration(t2), s2 = this.forage.getItem(t2).then((a3) => { + const s3 = a3 || n2; + return i2 === this._getCacheGeneration(t2) && null === this[e2] && (this[e2] = s3), s3; + }).finally(() => { + this._pendingReads.get(t2) === s2 && this._pendingReads.delete(t2); + }); + return this._pendingReads.set(t2, s2), s2; } - async merge_tow_car_list_table() { - const e2 = await this.getBlacklistCarList(), t2 = await this.getCarList(); - let n2 = false; - const a2 = e2.map(((e3) => { - let t3 = false; - return void 0 !== e3.actress && (e3.names = e3.actress, delete e3.actress, t3 = true), t3 && (n2 = true), e3; - })); - n2 && (clog.debug("更正 blacklistCarList 数据结构 actress->names"), await this._setItemAndInvalidate(this.blacklist_car_list_key, a2)), n2 = false; - const i2 = t2.map(((e3) => { - let t3 = false; - return void 0 !== e3.actress && (e3.names = e3.actress, delete e3.actress, t3 = true), t3 && (n2 = true), e3; - })); - n2 && (clog.debug("更正 carList 数据结构 actress->names"), await this._setItemAndInvalidate(this.car_list_key, i2)); + _invalidateCache(e2 = null) { + const t2 = [this.car_list_key, this.blacklist_key, this.filter_keyword_title_key, this.favorite_actresses_key, this.blacklist_car_list_key, this.setting_key]; + (null === e2 ? t2 : t2.includes(e2) ? [e2] : []).forEach(((e3) => this._invalidateRead(e3))); + (!e2 || e2 === this.car_list_key) && (this.cacheCarList = null, this.cacheCarMap = null, this.cacheStatusMap = null); + (!e2 || e2 === this.blacklist_key) && (this.cacheBlacklist = null, this.cacheBlacklistMap = null); + (!e2 || e2 === this.filter_keyword_title_key) && (this.cacheTitleFilterKeyword = null); + (!e2 || e2 === this.favorite_actresses_key) && (this.cacheFavoriteActresses = null); + (!e2 || e2 === this.blacklist_car_list_key) && (this.cache_filter_actor_actress_car_list = null); + (!e2 || e2 === this.setting_key) && (this.cacheSettingObj = null); } - /* ───── 快照管理 ───── */ - _snapshotKey() { - return "snapshots"; + async _setItemAndInvalidate(e2, t2) { + await this.forage.setItem(e2, t2); + this._invalidateCache(e2); } - _snapshotMetaKeys() { - return ["snapshots", "data_version"]; + async withActressLock(fn) { + let release; + const lock = new Promise((r2) => release = r2); + const prev = this._actressLock; + this._actressLock = this._actressLock.then(() => lock); + await prev; + try { + return await fn(); + } finally { + release(); + } } - async _getSnapshots() { - return await this.forage.getItem(this._snapshotKey()) || []; + async _rawUpdateFavoriteActress(e2) { + const t2 = await this.getFavoriteActressList(); + const { starId: n2, name: a2, allName: i2, avatar: s2, lastCheckTime: o2, newVideoList: r2, lastPublishTime: l2, actressType: c2, remark: d2 } = e2; + if (!n2) throw new Error("缺失starId"); + let h2 = t2.find(((e3) => e3.starId === n2)); + if (!h2) return clog.error("未找到演员信息", n2, a2), false; + a2 && (h2.name = a2), i2 && (h2.allName = i2), s2 && (h2.avatar = s2), null != c2 && (h2.actressType = c2), o2 && (h2.lastCheckTime = o2), r2 && (h2.newVideoList = r2), l2 && (h2.lastPublishTime = l2), d2 && (h2.remark = d2), h2.updateDate = utils.getNowStr(), await this._setItemAndInvalidate(this.favorite_actresses_key, t2); + return true; } - async _saveSnapshots(e2) { - await this._setItemAndInvalidate(this._snapshotKey(), e2); + async getCarList() { + return this._readCached("cacheCarList", this.car_list_key, []); } - async _withSnapshotLock(e2) { - return await navigator.locks.request("jhs_snapshot_lock", async () => await e2()); + async getCarMap() { + if (null === this.cacheCarMap) { + const e2 = await this.getCarList(); + this.cacheCarMap = createIndexedMap(e2, "carNum"); + } + return this.cacheCarMap; } - async createSnapshot(e2 = "", t2 = "manual") { - return this._withSnapshotLock(async () => { - const n2 = await this._getSnapshots(), a2 = await this.exportData(); - for (const i3 of this._snapshotMetaKeys()) delete a2[i3]; - let i2 = 0; - for (const s3 of Object.values(a2)) Array.isArray(s3) ? i2 += s3.length : "object" == typeof s3 && s3 && i2++; - const s2 = { - id: "snap_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8), - name: e2 || utils.getNowStr(), - source: t2, - time: utils.getNowStr(), - itemCount: i2, - data: a2, - kind: "snapshot" - }; - n2.push(s2), n2.length > 10 && n2.splice(0, n2.length - 10); - return await this._saveSnapshots(n2), clog.log(`创建快照: ${s2.name} (${t2})`), s2; - }); + async getStatusMap() { + if (null === this.cacheStatusMap) { + const e2 = await this.getCarList(); + this.cacheStatusMap = createStatusMap(e2); + } + return this.cacheStatusMap; } - async getSnapshotList() { - return (await this._getSnapshots()).map(((e2) => ({ id: e2.id, name: e2.name, source: e2.source, time: e2.time, itemCount: e2.itemCount, kind: e2.kind || "snapshot", targetDataVersion: e2.targetDataVersion, appVersion: e2.appVersion }))); + async getCar(e2) { + return (await this.getCarMap()).get(normalizeCarNum(e2)); } - async getSnapshot(e2) { - return (await this._getSnapshots()).find(((t2) => t2.id === e2)) || null; + _saveSingleCar(e2, t2) { + let { carNum: n2, url: a2, names: i2, actionType: s2, publishTime: o2, starId: r2 } = e2; + if (!n2) throw show.error("番号为空!"), new Error("番号为空!"); + if (!a2) throw show.error("url为空!"), new Error("url为空!"); + a2.includes("http") || (a2 = window.location.origin + a2), i2 && (i2 = i2.trim()); + let l2 = t2.find(((e3) => e3.carNum === n2)); + if (l2) i2 && (l2.names = i2), a2 && (l2.url = a2), o2 && (l2.publishTime = o2), l2.updateDate = utils.getNowStr(); + else { + let e3 = utils.getNowStr(); + l2 = { + carNum: n2, + url: a2, + names: i2, + status: "", + createDate: e3, + updateDate: e3, + publishTime: o2 + }, r2 && (l2.starId = r2), t2.push(l2); + } + switch (s2) { + case d: + if (l2.status === d) { + const e4 = `${n2} 已在屏蔽列表中`; + throw show.error(e4), new Error(e4); + } + l2.status = d; + break; + case h: + if (l2.status === h) { + const e4 = `${n2} 已在收藏列表中`; + throw show.error(e4), new Error(e4); + } + l2.status = h; + break; + case g: + if (l2.status === g) { + const e4 = `${n2} 已标记为已下载`; + throw show.error(e4), new Error(e4); + } + l2.status = g; + break; + case p: + if (l2.status === p) { + const e4 = `${n2} 已标记为已观看`; + throw show.error(e4), new Error(e4); + } + l2.status = p; + break; + default: + const e3 = "actionType错误, 请联系作者更正: " + s2; + throw show.error(e3), new Error(e3); + } } - async deleteSnapshot(e2) { - return this._withSnapshotLock(async () => { - const t2 = await this._getSnapshots(), n2 = t2.filter(((t3) => t3.id !== e2)); - if (n2.length === t2.length) return void clog.warn("删除快照失败, ID不存在: " + e2); - await this._saveSnapshots(n2), clog.log("删除快照: " + e2); + async saveCar(e2) { + const carNum = normalizeCarNum(e2.carNum), flag = legacyActionToFlag(e2.actionType); + if (!carNum) throw show.error("番号为空!"), new Error("番号为空!"); + if (!e2.url) throw show.error("url为空!"), new Error("url为空!"); + if (!flag) throw show.error("actionType错误, 请联系作者更正: " + e2.actionType), new Error("actionType错误, 请联系作者更正: " + e2.actionType); + const existing = await this.getCar(carNum); + if (normalizeStateFlags(existing?.stateFlags)[flag]) { + const messages = { blocked: "已在屏蔽列表中", favorite: "已在收藏列表中", downloaded: "已标记为已下载", watched: "已标记为已观看" }, message = `${carNum} ${messages[flag]}`; + throw show.error(message), new Error(message); + } + return this.stateService.patch(carNum, { [flag]: true }, { type: "legacy-save", record: { ...e2, carNum } }); + } + async updateCarInfo(e2) { + let { carNum: t2, url: n2, names: a2, actionType: i2, publishTime: s2, remark: o2 } = e2; + if (!t2) throw show.error("番号为空!"), new Error("番号为空!"); + if (!n2) throw show.error("url为空!"), new Error("url为空!"); + a2 && (a2 = a2.trim()); + t2 = normalizeCarNum(t2); + const l2 = await this.getCar(t2); + if (!l2) { + const e3 = "数据不存在: " + t2; + throw show.error(e3), new Error(e3); + } + const flag = legacyActionToFlag(i2); + if (!flag) { + const e3 = "actionType错误, 请联系作者更正: " + i2; + throw show.error(e3), new Error(e3); + } + return this.stateService.patch(t2, { [flag]: true }, { type: "legacy-update", record: { carNum: t2, names: a2, url: n2, remark: o2, publishTime: s2 } }); + } + async saveCarList(e2) { + if (!e2 || !Array.isArray(e2) || 0 === e2.length) throw show.error("记录列表为空!"), new Error("记录列表为空!"); + const existing = await this.getCarMap(), seen = /* @__PURE__ */ new Set(), groups = /* @__PURE__ */ new Map(); + for (const item of e2) { + const carNum = normalizeCarNum(item.carNum), flag = legacyActionToFlag(item.actionType); + if (!carNum) throw show.error("番号为空!"), new Error("番号为空!"); + if (!item.url) throw show.error("url为空!"), new Error("url为空!"); + if (!flag) throw new Error("actionType错误, 请联系作者更正: " + item.actionType); + const duplicateKey = `${carNum}:${flag}`; + if (seen.has(duplicateKey) || normalizeStateFlags(existing.get(carNum)?.stateFlags)[flag]) throw new Error(`${carNum} 状态已存在`); + seen.add(duplicateKey); + const group = groups.get(flag) || []; + group.push({ ...item, carNum }), groups.set(flag, group); + } + for (const [flag, records] of groups) await this.stateService.patch(records.map(((item) => item.carNum)), { [flag]: true }, { type: "legacy-batch-save", records }); + } + async removeNewVideoList(e2) { + return this.withActressLock(async () => { + const targets = new Set((Array.isArray(e2) ? e2 : [e2]).map(normalizeCarNum).filter(Boolean)); + const t2 = await this.getFavoriteActressList(), changed = /* @__PURE__ */ new Set(); + const a2 = t2.map(((t3) => { + if (!t3.newVideoList || !Array.isArray(t3.newVideoList)) return t3; + const a3 = t3.newVideoList.filter(((t4) => { + const a4 = normalizeCarNum("string" == typeof t4 ? t4 : t4.carNum), i2 = targets.has(a4); + return i2 && (clog.log("移除关联女优新作品", t4.name, a4), changed.add(a4)), !i2; + })); + return { ...t3, newVideoList: a3 }; + })); + changed.size && await this._setItemAndInvalidate(this.favorite_actresses_key, a2); + return { changed: [...changed] }; }); } - async restoreSnapshot(e2) { - const t2 = await this.getSnapshot(e2); - if (!t2) throw new Error("快照不存在: " + e2); - if (!t2.data || "object" != typeof t2.data) throw new Error("快照数据损坏"); - const n2 = { ...t2.data }; - for (const a2 of this._snapshotMetaKeys()) delete n2[a2]; - await this.importData(n2); - clog.log("已恢复快照: " + t2.name); - return t2; + async removeCar(e2) { + const result = await this.stateService.remove(e2); + return result.changed.length ? true : (show.error(`${e2} 不存在`), false); } - /* ───── 差异对比引擎 ───── */ - _stableStringify(e2) { - if (null === e2 || "object" != typeof e2) return JSON.stringify(e2); - if (Array.isArray(e2)) return "[" + e2.map(((e3) => this._stableStringify(e3))).join(",") + "]"; - return "{" + Object.keys(e2).sort().map(((t2) => JSON.stringify(t2) + ":" + this._stableStringify(e2[t2]))).join(",") + "}"; + async batchRemoveCars(e2) { + const result = await this.stateService.remove(e2); + return result.changed.length || false; } - diffData(e2, t2) { - const n2 = /* @__PURE__ */ new Set([...Object.keys(e2), ...Object.keys(t2)]), a2 = {}, i2 = { added: 0, removed: 0, modified: 0, unchanged: 0 }; - for (const o2 of n2) { - const r2 = e2[o2], l2 = t2[o2]; - if (void 0 === r2 && void 0 === l2) continue; - if (void 0 === r2) { - const e3 = Array.isArray(l2) ? l2.length : 1; - a2[o2] = { status: "added", oldCount: 0, newCount: e3, added: Array.isArray(l2) ? l2 : [], removed: [], modified: [] }, i2.added++; - } else if (void 0 === l2) { - const t3 = Array.isArray(r2) ? r2.length : 1; - a2[o2] = { status: "removed", oldCount: t3, newCount: 0, added: [], removed: Array.isArray(r2) ? r2 : [], modified: [] }, i2.removed++; - } else if (Array.isArray(r2) && Array.isArray(l2)) { - const e3 = this._diffArrays(r2, l2, o2); - a2[o2] = { status: e3.status, oldCount: r2.length, newCount: l2.length, ...e3 }, i2[e3.status]++; - } else if ("object" == typeof r2 && "object" == typeof l2 && !Array.isArray(r2) && !Array.isArray(l2)) { - const e3 = this._diffObjects(r2, l2); - a2[o2] = { status: e3.status, oldCount: Object.keys(r2).length, newCount: Object.keys(l2).length, added: [], removed: [], modified: e3.changes }, i2[e3.status]++; - } else r2 === l2 ? (a2[o2] = { status: "unchanged", oldCount: 1, newCount: 1, added: [], removed: [], modified: [] }, i2.unchanged++) : (a2[o2] = { status: "modified", oldCount: 1, newCount: 1, added: [], removed: [], modified: [{ key: o2, changes: { _value: [r2, l2] } }] }, i2.modified++); + async getBlacklist() { + return this._readCached("cacheBlacklist", this.blacklist_key, []); + } + async getBlacklistMap() { + if (null === this.cacheBlacklistMap) { + const e2 = await this.getBlacklist(); + this.cacheBlacklistMap = createIndexedMap(e2, "starId"); } - return { summary: i2, stores: a2 }; + return this.cacheBlacklistMap; } - _getArrayKey(e2) { - return "car_list" === e2 || "blacklist_car_list" === e2 ? "carNum" : "blacklist" === e2 || "favorite_actresses" === e2 ? "starId" : null; + async addBlacklistItem(e2) { + let { starId: t2, name: n2, allName: a2, role: i2, movieType: s2, url: o2 } = e2; + if (!t2) throw new Error("缺失starId"); + if (!n2) throw new Error("缺失name"); + if (!i2) throw new Error("缺失role"); + const r2 = await this.getBlacklist(), l2 = r2.find(((e3) => e3.starId === t2)); + if (l2) l2.url = o2, l2.role = i2, l2.movieType = s2, clog.log("更新黑名单演员信息", l2); + else { + const e3 = { + starId: t2, + name: n2, + allName: a2 || [n2], + createTime: utils.getNowStr(), + role: i2, + movieType: s2, + url: o2 + }; + r2.push(e3), clog.log("增加黑名单演员信息", e3); + } + await this._setItemAndInvalidate(this.blacklist_key, r2); } - _diffArrays(e2, t2, n2) { - const a2 = this._getArrayKey(n2); - if (!a2) { - const n3 = this._stableStringify(e2), i3 = this._stableStringify(t2); - if (n3 === i3) return { status: "unchanged", added: [], removed: [], modified: [] }; - const s3 = new Set(e2.map(((e3) => this._stableStringify(e3)))), o3 = new Set(t2.map(((e3) => this._stableStringify(e3)))); - return { status: "modified", added: t2.filter(((e3) => !s3.has(this._stableStringify(e3)))), removed: e2.filter(((e3) => !o3.has(this._stableStringify(e3)))), modified: [] }; + async updateBlacklistItem(e2) { + if (!e2 || !e2.starId) throw new Error("参数不全"); + const t2 = await this.getBlacklist(), n2 = t2.find(((t3) => t3.starId === e2.starId)); + if (!n2) throw new Error(`未找到黑名单演员信息:${e2.name} ${e2.starId}`); + e2.checkTime && (n2.checkTime = e2.checkTime), e2.lastPublishTime && (n2.lastPublishTime = e2.lastPublishTime), await this._setItemAndInvalidate(this.blacklist_key, t2); + } + async deleteBlacklistItem(e2) { + const t2 = await this.getBlacklist(), n2 = t2.filter(((t3) => t3.starId !== e2)); + t2.length !== n2.length && await this._setItemAndInvalidate(this.blacklist_key, n2); + } + async getBlacklistCarList() { + return this._readCached("cache_filter_actor_actress_car_list", this.blacklist_car_list_key, []); + } + async batchSaveBlacklistCarList(e2) { + const t2 = await this.getBlacklistCarList(), n2 = JSON.parse(JSON.stringify(t2)); + let a2 = false, i2 = []; + for (const s2 of e2) { + n2.find(((e3) => e3.carNum === s2.carNum)) || (this._saveSingleCar(s2, n2), clog.log(`屏蔽演员番号: ${escapeHtml(s2.names)} ${escapeHtml(s2.carNum)}`), a2 = true, i2.push(s2.carNum)); } - const i2 = new Map(e2.map(((e3) => [e3[a2], e3]))), s2 = new Map(t2.map(((e3) => [e3[a2], e3]))), o2 = [], r2 = [], l2 = []; - for (const [c3, d2] of s2) { - const t3 = i2.get(c3); - if (!t3) o2.push(d2); - else { - const e3 = this._diffObjects(t3, d2); - "unchanged" !== e3.status && l2.push({ key: c3, changes: e3.changes }); - } + if (a2) { + await this._setItemAndInvalidate(this.blacklist_car_list_key, n2); + const result = await this.removeNewVideoList(i2); + result.changed.length && await window.jhsEventBus?.emit?.("new-video-changed", { + reason: "blacklist-car-removed", + carNums: result.changed + }); + await window.cleanCache_filter_actor_actress_car_list(); } - for (const [c3] of i2) s2.has(c3) || r2.push(i2.get(c3)); - const c2 = o2.length + r2.length + l2.length; - return { status: 0 === c2 ? "unchanged" : "modified", added: o2, removed: r2, modified: l2 }; + return { changed: i2.map(normalizeCarNum).filter(Boolean) }; } - _diffObjects(e2, t2) { - const n2 = /* @__PURE__ */ new Set([...Object.keys(e2), ...Object.keys(t2)]), a2 = {}; - let i2 = 0; - for (const s2 of n2) { - const o2 = e2[s2], r2 = t2[s2]; - this._stableStringify(o2) !== this._stableStringify(r2) && (a2[s2] = [o2, r2], i2++); - } - return { status: 0 === i2 ? "unchanged" : "modified", changes: a2 }; + async removeBlacklistCarList(e2) { + const t2 = await this.getBlacklistCarList(), n2 = t2.filter(((t3) => t3.starId !== e2)); + n2.length !== t2.length && (await this._setItemAndInvalidate(this.blacklist_car_list_key, n2), window.cleanCache_filter_actor_actress_car_list()); } - }; - __name(_StorageManager, "StorageManager"); - var StorageManager = _StorageManager; - var PORTABLE_DATA_KEYS = Object.freeze(["car_list", "filter_keyword_title", "filter_keyword_review", "setting", "blacklist", "blacklist_car_list", "third_party_ttl_cache", "favorite_actresses", "highlighted_tags", "activity_log", "offline_history", "new_video_decisions"]); - var PORTABLE_ARRAY_KEYS = /* @__PURE__ */ new Set(["car_list", "filter_keyword_title", "filter_keyword_review", "blacklist", "blacklist_car_list", "favorite_actresses", "highlighted_tags", "offline_history"]); - var PORTABLE_OBJECT_KEYS = /* @__PURE__ */ new Set(["setting", "third_party_ttl_cache", "activity_log", "new_video_decisions"]); - function validatePortableData(data) { - if (!data || "object" != typeof data || Array.isArray(data)) throw new TypeError("备份数据格式无效"); - const version = Number(data.data_version || 0); - if (!Number.isInteger(version) || version < 0) throw new TypeError("备份数据版本无效"); - if (version > CURRENT_DATA_VERSION) throw new Error("数据来自更新版本的 JHS,当前版本无法安全读取"); - for (const key of PORTABLE_ARRAY_KEYS) if (null != data[key] && !Array.isArray(data[key])) throw new TypeError(`备份字段 ${key} 必须为数组`); - for (const key of PORTABLE_OBJECT_KEYS) if (null != data[key] && ("object" != typeof data[key] || Array.isArray(data[key]))) throw new TypeError(`备份字段 ${key} 必须为对象`); - return version; - } - __name(validatePortableData, "validatePortableData"); - async function hasPortableUserData(storage) { - for (const key of PORTABLE_DATA_KEYS) { - const value = await storage.forage.getItem(key); - if (Array.isArray(value) ? value.length : value && "object" == typeof value ? Object.keys(value).length : null != value) return true; + async getFavoriteActressList() { + return this._readCached("cacheFavoriteActresses", this.favorite_actresses_key, []); } - return false; - } - __name(hasPortableUserData, "hasPortableUserData"); - async function ensureV2MigrationSnapshot(storage) { - if (!await hasPortableUserData(storage)) return null; - const snapshots = await storage._getSnapshots(), existing = snapshots.find(((item) => "migration-snapshot" === item.kind && 2 === item.targetDataVersion)); - if (existing) return existing; - const data = await storage.exportPortableData(), snapshot = { - id: "migration_v2_" + Date.now(), - name: "6.4.0-migration-backup", - source: "migration", - kind: "migration-snapshot", - targetDataVersion: 2, - appVersion: "6.4.0", - createdAt: (/* @__PURE__ */ new Date()).toISOString(), - time: utils.getNowStr(), - itemCount: Object.values(data).reduce(((sum, value) => sum + (Array.isArray(value) ? value.length : value && "object" == typeof value ? 1 : 0)), 0), - data - }; - return snapshots.push(snapshot), snapshots.length > 10 && snapshots.splice(0, snapshots.length - 10), await storage._saveSnapshots(snapshots), snapshot; - } - __name(ensureV2MigrationSnapshot, "ensureV2MigrationSnapshot"); - async function migrateLegacyStorage(storage) { - await storage.merge_table_name(), await storage.clean_no_url_blacklist(), await storage.async_merge_other(), await storage.merge_blacklist(), await storage.merge_favoriteActress(), await storage.merge_tow_car_list_table(); - } - __name(migrateLegacyStorage, "migrateLegacyStorage"); - async function migrateStateFlags(storage) { - await ensureV2MigrationSnapshot(storage); - const cars = await storage.forage.getItem(storage.car_list_key) || [], result = mergeCanonicalCarRecords(cars); - await storage._setItemAndInvalidate(storage.car_list_key, result.list); - const actresses = await storage.forage.getItem(storage.favorite_actresses_key) || []; - const migratedActresses = actresses.map(((actress) => { - if (!Array.isArray(actress.newVideoList)) return actress; - const newVideoList = actress.newVideoList.map(((item) => "string" == typeof item ? normalizeCarNum(item) : { - ...item, - carNum: normalizeCarNum(item.carNum) - })).filter(((item) => "string" == typeof item ? item : item.carNum)); - return { ...actress, newVideoList }; - })); - await storage._setItemAndInvalidate(storage.favorite_actresses_key, migratedActresses); - const warnings = await storage.forage.getItem("data_health_warnings") || []; - result.collisions.length && warnings.push({ type: "canonical-collision", createdAt: (/* @__PURE__ */ new Date()).toISOString(), items: result.collisions }); - result.unknownStatuses.length && warnings.push({ type: "unknown-legacy-status", createdAt: (/* @__PURE__ */ new Date()).toISOString(), items: result.unknownStatuses }); - warnings.length && await storage.forage.setItem("data_health_warnings", warnings); - } - __name(migrateStateFlags, "migrateStateFlags"); - var DATA_MIGRATIONS = Object.freeze({ 1: migrateLegacyStorage, 2: migrateStateFlags }); - async function runDataMigrations(storage) { - let version = await storage.getDataVersion(); - if (version > CURRENT_DATA_VERSION) throw new Error("数据来自更新版本的 JHS,当前版本无法安全读取"); - for (let target = version + 1; target <= CURRENT_DATA_VERSION; target++) { - const migration = DATA_MIGRATIONS[target]; - if (!migration) throw new Error(`缺少数据迁移: ${target - 1} → ${target}`); - await migration(storage), await storage.setDataVersion(target), version = target; + async addFavoriteActressList(e2) { + return this.withActressLock(async () => { + const t2 = await this.getFavoriteActressList(); + let n2 = 0; + for (const a2 of e2) { + let { starId: e3, name: i2, allName: s2, avatar: o2, lastCheckTime: r2, lastPublishTime: l2, actressType: c2 } = a2; + if (!e3) throw new Error("缺失starId"); + if (!i2) throw new Error("缺失name"); + s2 || (s2 = [i2]); + const d2 = "(無碼)"; + if (!c2) { + c2 = i2.includes(d2) || s2.some(((e4) => e4.includes(d2))) ? A : D; + } + i2 = i2.replace(d2, ""), s2 = s2.map(((e4) => e4.replace(d2, ""))); + let h2 = t2.find(((t3) => t3.starId === e3)); + if (h2) { + h2.avatar && h2.avatar.includes("https") || o2 && (clog.log(o2), h2.avatar = o2, clog.log(`补全女优头像: ${escapeHtml(i2)}`), n2++), !h2.actressType && c2 && (h2.actressType = c2, clog.log(`补全女优类别: ${escapeHtml(i2)} ${escapeHtml(c2)}`), n2++), h2.name.includes(d2) && (h2.name = i2, h2.allName = s2, clog.log(`更正女优名字: ${escapeHtml(i2)} ${escapeHtml(s2)}`), n2++); + continue; + } + const g2 = utils.getNowStr(); + t2.push({ + starId: e3, + name: i2, + allName: s2, + avatar: o2, + lastCheckTime: r2, + lastPublishTime: l2, + createDate: g2, + updateDate: g2, + actressType: c2 + }), clog.log(`同步JavDB已收藏的演员: ${escapeHtml(i2)}`), n2++; + } + return n2 > 0 ? await this._setItemAndInvalidate(this.favorite_actresses_key, t2) : clog.log("信息已记录, 无需要进行同步收藏的演员"), n2; + }); } - return version; - } - __name(runDataMigrations, "runDataMigrations"); - var U = "https://jdforrepam.com/api"; - var javDbMovieIdRequests = /* @__PURE__ */ new Map(); - function O() { - const e2 = "jhs_review_ts", t2 = "jhs_review_sign", n2 = Math.floor(Date.now() / 1e3); - if (n2 - (localStorage.getItem(e2) || 0) <= 20) return localStorage.getItem(t2); - const a2 = `${n2}.lpw6vgqzsp.${md5(`${n2}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; - return localStorage.setItem(e2, n2), localStorage.setItem(t2, a2), a2; - } - __name(O, "O"); - async function resolveJavDbMovieId(carNum) { - const normalized = normalizeCarNum(carNum); - if (!normalized) return null; - if (javDbMovieIdRequests.has(normalized)) return javDbMovieIdRequests.get(normalized); - const request = storageManager.cachedRequest(`javdb-movie-id:${normalized}`, 7 * 864e5, (async () => { - const response = await gmHttp.get(`${U}/v2/search`, { - q: normalized, - page: 1, - type: "movie", - limit: 20, - movie_type: "all", - from_recent: "false", - movie_filter_by: "all", - movie_sort_by: "relevance" - }, { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - host: "jdforrepam.com", - jdsignature: await O() + async removeFavoriteActress(e2) { + return this.withActressLock(async () => { + const t2 = await this.getFavoriteActressList(), n2 = t2.length, a2 = t2.filter(((t3) => t3.starId !== e2)); + return a2.length === n2 ? (clog.error(`移除演员失败, ${e2} 不存在`), false) : (await this._setItemAndInvalidate(this.favorite_actresses_key, a2), true); }); - if (!Array.isArray(response?.data?.movies)) throw new Error(response?.message || "JavDB 番号解析失败"); - const match = response.data.movies.find(((movie) => normalizeCarNum(movie.number) === normalized)); - return match?.id ? { __jhsCacheTtl: 7 * 864e5, data: { movieId: String(match.id) } } : { __jhsCacheTtl: 6 * 36e5, data: { miss: true } }; - })).then(((value) => value?.miss ? null : value?.movieId || null)); - javDbMovieIdRequests.set(normalized, request); - try { - return await request; - } finally { - javDbMovieIdRequests.get(normalized) === request && javDbMovieIdRequests.delete(normalized); } - } - __name(resolveJavDbMovieId, "resolveJavDbMovieId"); - async function markJavDbWantWatch(movieId) { - const id = String(movieId || "").trim(), encryptedToken = localStorage.getItem("jhs_appAuthorization"), token = encryptedToken ? await decryptData(encryptedToken) : ""; - if (!token) { - const error = new Error("请先登录 JavDB 账号"); - throw error.code = "LOGIN_REQUIRED", error; + async updateFavoriteActress(e2) { + return this.withActressLock(() => this._rawUpdateFavoriteActress(e2)); } - if (!id) throw new Error("JavDB 影片 ID 无效"); - const boundary = "----jhs-javdb-want-watch", body = [["status", "want_watch"], ["score", "0"], ["content", ""]].map((([name, value]) => `--${boundary}\r -Content-Disposition: form-data; name="${name}"\r -\r -${value}\r -`)).join("") + `--${boundary}--\r -`; - try { - const response = await gmHttp.gmRequest("POST", `${U}/v1/movies/${encodeURIComponent(id)}/reviews`, body, {}, { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - authorization: `Bearer ${token}`, - jdsignature: await O(), - "content-type": `multipart/form-data; boundary=${boundary}` + async getHighlightedTags() { + return await this.forage.getItem(this.highlighted_tags_key) || []; + } + async setHighlightedTags(e2) { + return await this.forage.setItem(this.highlighted_tags_key, e2); + } + async saveTitleFilterKeyword(n2) { + if (await s(this, e, t).call(this, n2, this.filter_keyword_title_key, "标题关键词"), Array.isArray(n2)) return null; + return this.withActressLock(async () => { + const a2 = await this.getFavoriteActressList(); + let i2 = false; + const o2 = a2.map(((e2) => { + if (!e2.newVideoList || !Array.isArray(e2.newVideoList)) return e2; + const t2 = e2.newVideoList.filter(((t3) => { + const s2 = "string" == typeof t3 ? t3 : t3.carNum, a3 = s2.startsWith(n2); + return a3 && (clog.log("移除关联女优新作品", e2.name, s2), i2 = true), !a3; + })); + return { + ...e2, + newVideoList: t2 + }; + })); + i2 && await this._setItemAndInvalidate(this.favorite_actresses_key, o2); }); - if (0 === response?.success) throw response; - await storageManager.deleteCachedRequest(`movie-detail:${id}`); - return response; - } catch (error) { - if (401 === error?.status || "JWTVerificationError" === error?.action || /未登录|登录|unauthorized|jwt/i.test(error?.message || "")) { - localStorage.removeItem("jhs_appAuthorization"); - const loginError = new Error("JavDB 登录已失效,请重新登录"); - throw loginError.code = "LOGIN_REQUIRED", loginError; + } + async getTitleFilterKeyword() { + return this._readCached("cacheTitleFilterKeyword", this.filter_keyword_title_key, []); + } + async getReviewFilterKeywordList() { + return await this.forage.getItem(this.filter_keyword_review_key) || []; + } + async saveReviewFilterKeyword(n2) { + return s(this, e, t).call(this, n2, this.filter_keyword_review_key, "评论关键词"); + } + async getSetting(e2 = null, t2) { + let n2 = await this._readCached("cacheSettingObj", this.setting_key, {}); + if (null === e2) return n2; + if (!Object.prototype.hasOwnProperty.call(n2, e2)) return t2; + const a2 = n2[e2]; + return "true" === a2 || "false" === a2 ? "true" === a2.toLowerCase() : "string" != typeof a2 || "" === a2.trim() || isNaN(Number(a2)) ? a2 : Number(a2); + } + getSettingSync(e2, t2) { + if (!this.cacheSettingObj) return t2; + if (!Object.prototype.hasOwnProperty.call(this.cacheSettingObj, e2)) return t2; + const n2 = this.cacheSettingObj[e2]; + return "true" === n2 || "false" === n2 ? "true" === n2.toLowerCase() : "string" != typeof n2 || "" === n2.trim() || isNaN(Number(n2)) ? n2 : Number(n2); + } + async saveSetting(e2) { + e2 ? (await this._setItemAndInvalidate(this.setting_key, e2), await window.clean_cacheSettingObj()) : show.error("设置对象为空"); + } + async saveSettingItem(e2, t2) { + if (!e2) return void show.error("key 不能为空"); + await navigator.locks.request("jhs_setting_lock", async () => { + let n2 = await this.getSetting(); + n2[e2] = t2, await this.saveSetting(n2); + }); + } + async importData(e2) { + validatePortableData(e2); + await hasPortableUserData(this) && await this.createSnapshot("导入前自动备份", "auto-import"); + const validKeys = /* @__PURE__ */ new Set([...IMPORTABLE_DATA_KEYS, "data_version"]), writes = []; + for (const key in e2) { + if (!validKeys.has(key)) { + clog.warn(`[导入] 跳过未知数据键: ${key}`); + continue; + } + if ("third_party_ttl_cache" === key) continue; + writes.push("data_version" === key ? this.setDataVersion(e2[key]) : this._setItemAndInvalidate(key, e2[key])); } - throw error instanceof Error ? error : new Error(error?.message || "加入 JavDB 想看失败"); + await Promise.all(writes), this._invalidateCache(), await runDataMigrations(this), await window.stateService?.recoverPendingTransaction(); } - } - __name(markJavDbWantWatch, "markJavDbWantWatch"); - var R = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { - let a2 = `${U}/v1/movies/${e2}/reviews`, i2 = { - jdSignature: await O() - }; - return await storageManager.cachedRequest(`reviews:${e2}:${t2}:${n2}`, 864e5, (async () => { - const e3 = await gmHttp.get(a2, { - page: t2, - sort_by: "hotly", - limit: n2 - }, i2); - if (!e3?.data?.reviews) throw new Error(e3?.message || "获取评论失败"); - return e3.data.reviews; - })); - }, "R"); - var V = /* @__PURE__ */ __name(async (e2) => { - let t2 = `${U}/v4/movies/${e2}`, n2 = { - jdSignature: await O() - }; - const a2 = await storageManager.cachedRequest(`movie-detail:${e2}`, 6048e5, (async () => { - const e3 = await gmHttp.get(t2, null, n2); - if (!e3.data) throw new Error(e3.message || "获取视频详情失败"); - return e3; - })); - if (!a2.data) throw show.error("获取视频详情失败: " + a2.message), new Error(a2.message); - const i2 = a2.data.movie, s2 = i2.preview_images, o2 = []; - return s2.forEach(((e3) => { - o2.push(e3.large_url.replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); - })), { - movieId: i2.id, - actors: i2.actors, - duration: i2.duration, - title: i2.origin_title, - carNum: i2.number, - score: i2.score, - releaseDate: i2.release_date, - watchedCount: i2.watched_count, - imgList: o2 - }; - }, "V"); - var K = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { - let a2 = `${U}/v1/lists/related?movie_id=${e2}&page=${t2}&limit=${n2}`, i2 = { - jdSignature: await O() - }; - const s2 = await storageManager.cachedRequest(`related:${e2}:${t2}:${n2}`, 864e5, (async () => { - const e3 = await gmHttp.get(a2, null, i2); - if (!e3?.data?.lists) throw new Error(e3?.message || "获取相关清单失败"); - return e3; - })), o2 = []; - return s2.data.lists.forEach(((e3) => { - o2.push({ - relatedId: e3.id, - name: e3.name, - movieCount: e3.movies_count, - collectionCount: e3.collections_count, - viewCount: e3.views_count, - createTime: utils.formatDate(e3.created_at) - }); - })), o2; - }, "K"); - var W = /* @__PURE__ */ __name(async (e2 = "daily", t2 = "high_score") => { - let n2 = `${U}/v1/rankings/playback?period=${e2}&filter_by=${t2}`, a2 = { - jdSignature: await O() - }; - return (await gmHttp.get(n2, null, a2)).data.movies; - }, "W"); - var q = /* @__PURE__ */ __name(async (e2 = "all", t2 = "", n2 = 1, a2 = 40) => { - let i2 = `${U}/v1/movies/top?start_rank=1&type=${e2}&type_value=${t2}&ignore_watched=false&page=${n2}&limit=${a2}`; - const l2 = localStorage.getItem("jhs_appAuthorization"), c2 = l2 ? await decryptData(l2) : ""; - let s2 = { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - host: "jdforrepam.com", - authorization: "Bearer " + c2, - jdsignature: await O() - }; - return await gmHttp.get(i2, null, s2); - }, "q"); - var _Utils = class _Utils { - constructor() { - return i(this, "intervalContainer", {}), i(this, "waitSequence", 0), i(this, "mimeTypes", { - txt: "text/plain", - html: "text/html", - css: "text/css", - csv: "text/csv", - json: "application/json", - xml: "application/xml", - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", - pdf: "application/pdf", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - zip: "application/zip", - rar: "application/x-rar-compressed", - "7z": "application/x-7z-compressed", - mp3: "audio/mpeg", - wav: "audio/wav", - mp4: "video/mp4", - webm: "video/webm", - ogg: "audio/ogg" - }), i(this, "timers", /* @__PURE__ */ new Map()), i(this, "insertStyle", ((e2) => { - const t2 = (Array.isArray(e2) ? e2 : [e2]).filter(Boolean); - if (0 === t2.length) return; - const n2 = t2.map(((e3) => e3.replace(/^\s*]*>/i, "").replace(/<\/style>?\s*$/i, ""))).filter(Boolean).join("\n"), a2 = document.createElement("style"); - n2 && (a2.textContent = n2, document.head.append(a2)); - })), i(this, "layerIndexStack", []), _Utils.instance || (_Utils.instance = this), _Utils.instance; + async exportPortableData() { + const data = { data_version: await this.getDataVersion() }; + for (const key of PORTABLE_DATA_KEYS) { + const value = await this.forage.getItem(key); + null != value && (data[key] = value); + } + return data; } - importResource(e2) { - let t2; - e2.indexOf("css") >= 0 ? (t2 = document.createElement("link"), t2.setAttribute("rel", "stylesheet"), t2.href = e2) : (t2 = document.createElement("script"), t2.setAttribute("type", "text/javascript"), t2.src = e2), document.documentElement.appendChild(t2); + async exportData() { + const data = await this.exportPortableData(); + if (Object.keys(data).length <= 1) throw new Error("没有可导出的数据"); + return data; } - openPage(e2, t2, n2, a2) { - n2 = n2 ?? true; - const navigation = a2 && (Object.prototype.hasOwnProperty.call(a2, "event") || Object.prototype.hasOwnProperty.call(a2, "newTab")) ? a2 : { event: a2 }, event = navigation.event; - const destination = new URL(e2, window.location.origin), carNum = normalizeCarNum(t2), isMovieDetail = /^\/v\/[^/]+/.test(destination.pathname); - isMovieDetail && carNum && destination.searchParams.set("jhsCarNum", carNum); - if (navigation.newTab || event && (event.ctrlKey || event.metaKey || 1 === event.button)) return void GM_openInTab(destination.href, { - insert: 0 - }); - destination.pathname.includes("/actors/") || destination.pathname.includes("/star/") || destination.searchParams.set("hideNav", "1"); - layer.open({ - type: 2, - title: t2, - content: destination.href, - scrollbar: false, - shadeClose: n2, - area: this.getDialogArea("workspace"), - isOutAnim: false, - anim: -1, - success: /* @__PURE__ */ __name((e3, t3) => { - this.setupEscClose(t3); - }, "success") - }); + async getThirdPartyCache() { + return await this.forage.getItem(this.third_party_cache_key) || {}; } - _handleGlobalEscKey(e2) { - if ("Escape" !== e2.key && 27 !== e2.keyCode) return; - if (0 === this.layerIndexStack.length) return; - const t2 = this.layerIndexStack[this.layerIndexStack.length - 1], n2 = $(`#layui-layer${t2}`); - let a2 = false; - if (n2.find(".viewer-container").length > 0) a2 = true; - else { - const e3 = n2.find(`#layui-layer-iframe${t2}`)[0]; - if (e3 && e3.contentDocument) try { - $(e3.contentDocument).find(".viewer-container").length > 0 && (a2 = true); - } catch (i2) { - clog.warn("无法检查跨域 iframe 内的 .viewer-container"); - } - } - a2 || (this.layerIndexStack.pop(), layer.close(t2)); + async setThirdPartyCache(e2) { + await this.forage.setItem(this.third_party_cache_key, e2 || {}); } - setupEscClose(e2) { - var t2; - this._boundHandler || (this._boundHandler = this._handleGlobalEscKey.bind(this), $(document).off("keydown.globalLayerEsc"), $(document).on("keydown.globalLayerEsc", this._boundHandler)), -1 === this.layerIndexStack.indexOf(e2) && this.layerIndexStack.push(e2); - const n2 = $(`#layui-layer-iframe${e2}`), a2 = `keydown.layerEsc${e2}`; - try { - const e3 = null == (t2 = n2[0]) ? void 0 : t2.contentDocument; - if (e3) { - if ("yes" === n2.attr("data-esc-bound")) return; - $(e3).off(a2), $(e3).on(a2, this._boundHandler), n2.attr("data-esc-bound", "yes"); - } - } catch (i2) { - clog.error("iframe监听失败 (跨域或未加载完毕):", i2); - } + async clearThirdPartyCache() { + await this.forage.removeItem(this.third_party_cache_key); } - async closePage(options = {}) { - if ("yes" !== await storageManager.getSetting("needClosePage", "yes")) return false; - const root = options?.root, parseIndex = /* @__PURE__ */ __name((element) => { - const id = element?.id || "", match = /^layui-layer(\d+)$/.exec(id); - return match ? Number(match[1]) : null; - }, "parseIndex"); - let layerIndex = Number.isInteger(options?.layerIndex) ? options.layerIndex : null; - if (null === layerIndex && root) { - const element = root.jquery ? root[0] : root.nodeType ? root : null, layerElement = element?.matches?.(".layui-layer") ? element : element?.closest?.(".layui-layer"); - layerIndex = parseIndex(layerElement); - } - if (null === layerIndex && window.frameElement) layerIndex = parseIndex(window.frameElement.closest?.(".layui-layer")); - const ownerWindow = window.parent && window.parent !== window ? window.parent : window, ownerLayer = ownerWindow.layer || globalThis.layer; - if (null !== layerIndex && "function" == typeof ownerLayer?.close) return ownerLayer.close(layerIndex), true; - if (window.opener && !window.opener.closed) return window.close(), true; - return false; + async deleteCachedRequest(key) { + const cache = await this.getThirdPartyCache(); + Object.prototype.hasOwnProperty.call(cache, key) && (delete cache[key], await this.setThirdPartyCache(cache)); } - loopDetector(e2, t2, n2 = 20, a2 = 1e4, i2 = true) { - const s2 = ++this.waitSequence; - let o2 = null, r2 = null, l2 = null, c2 = false; - const d2 = /* @__PURE__ */ __name(() => { - o2?.disconnect(), clearTimeout(r2), clearTimeout(l2), clearInterval(this.intervalContainer[s2]?.fallback), delete this.intervalContainer[s2]; - }, "d"), h2 = /* @__PURE__ */ __name((e3) => { - if (c2) return; - c2 = true, d2(), e3 && t2 && t2(); - }, "h"), g2 = /* @__PURE__ */ __name(() => { - if (c2) return; - e2() && h2(true); - }, "g"), p2 = /* @__PURE__ */ __name(() => { - c2 || (clearTimeout(r2), r2 = setTimeout(g2, Math.max(0, n2))); - }, "p"); - this.intervalContainer[s2] = {}; - if (e2()) return void h2(true); - if ("function" == typeof MutationObserver && document.documentElement) o2 = new MutationObserver(p2), o2.observe(document.documentElement, { childList: true, subtree: true, characterData: true }); - else this.intervalContainer[s2].fallback = setInterval(g2, Math.max(100, n2)); - l2 = setTimeout((() => { - if (c2) return; - let t3 = false; - try { - t3 = e2(); - } catch (e3) { - clog.error("DOM 等待条件执行失败", e3); - } - h2(t3 || i2); - }), Math.max(0, a2)); + async cachedRequest(e2, t2, n2) { + const a2 = Date.now(), i2 = await this.getThirdPartyCache(), s2 = i2[e2]; + if (s2 && s2.time && a2 - s2.time < (s2.ttl || t2)) return this._cacheStats.hits++, s2.data; + this._cacheStats.misses++; + const loaded = await n2(), customTtl = loaded && "object" === typeof loaded && "__jhsCacheTtl" in loaded ? loaded.__jhsCacheTtl : t2; + const o2 = loaded && "object" === typeof loaded && "__jhsCacheTtl" in loaded ? loaded.data : loaded; + if (void 0 === o2 || null === o2) return o2; + return i2[e2] = { + time: a2, + ttl: customTtl, + data: o2 + }, await this.setThirdPartyCache(i2), o2; } - rightClick(e2, t2, n2) { - let a2; - "string" == typeof e2 ? a2 = document.querySelector(e2) : e2 instanceof HTMLElement && (a2 = e2), a2 || (clog.warn("rightClick(), 容器无效或未提供,将使用 document.body 进行全局委托。"), a2 = document.body), "string" == typeof t2 && "" !== t2.trim() ? a2.addEventListener("contextmenu", ((e3) => { - const a3 = e3.target.closest(t2); - a3 && n2(e3, a3); - })) : clog.error("rightClick(), 必须提供有效的 targetSelector。"); + getCacheHitStats() { + const e2 = this._cacheStats.hits + this._cacheStats.misses; + return { + hits: this._cacheStats.hits, + misses: this._cacheStats.misses, + total: e2, + rate: e2 > 0 ? (this._cacheStats.hits / e2 * 100).toFixed(1) + "%" : "N/A" + }; } - q(e2, t2, n2, a2) { - let o2; - if (this.isMobileMode()) { - o2 = layer.confirm(t2, { - title: "提示", - btn: ["确定", "取消"], - shade: 0, - zIndex: JHS_Z_INDEX.layer - }, (function() { - n2 && n2(), layer.close(o2); - }), (function() { - a2 && a2(); - })); - } else { - let i2, s2; - e2 ? (i2 = e2.clientX - 130, s2 = e2.clientY - 120) : (i2 = window.innerWidth / 2 - 120, s2 = window.innerHeight / 2 - 120); - o2 = layer.confirm(t2, { - offset: [s2, i2], - title: "提示", - btn: ["确定", "取消"], - shade: 0, - zIndex: JHS_Z_INDEX.layer - }, (function() { - n2 && n2(), layer.close(o2); - }), (function() { - a2 && a2(); + _groupDuplicateItems(e2, t2) { + return groupDuplicateItems(e2, t2); + } + _dedupeByKey(e2, t2) { + return dedupeByKey(e2, t2); + } + async inspectDataHealth() { + const [e2, t2, n2, a2] = await Promise.all([this.getCarList(), this.getFavoriteActressList(), this.getBlacklist(), this.getBlacklistCarList()]), i2 = await this.getCarMap(), s2 = { + checkedAt: utils.getNowStr(), + totals: { + carList: e2.length, + favoriteActresses: t2.length, + blacklist: n2.length, + blacklistCarList: a2.length + }, + fixable: [], + readonly: [] + }, o2 = /* @__PURE__ */ __name((e3, t3, n3) => s2.fixable.push({ + type: e3, + message: t3, + count: n3 + }), "o"), r2 = /* @__PURE__ */ __name((e3, t3, n3) => s2.readonly.push({ + type: e3, + message: t3, + count: n3 + }), "r"); + const l2 = this._groupDuplicateItems(e2, "carNum"), c2 = this._groupDuplicateItems(t2, "starId"), d2 = this._groupDuplicateItems(n2, "starId"); + l2.length && o2("duplicate-car", "重复番号记录", l2.length), c2.length && o2("duplicate-actress", "重复收藏演员", c2.length), d2.length && o2("duplicate-blacklist", "重复黑名单演员", d2.length); + const h2 = e2.filter(((e3) => e3 && e3.actress)).length, g2 = a2.filter(((e3) => e3 && e3.actress)).length, p2 = t2.filter(((e3) => e3 && Object.prototype.hasOwnProperty.call(e3, "dbId"))).length, m2 = n2.filter(((e3) => e3 && ("key" in e3 || "recordTime" in e3 || "isActor" in e3))).length; + h2 + g2 + p2 + m2 > 0 && o2("legacy-fields", "旧字段残留", h2 + g2 + p2 + m2); + const u2 = t2.filter(((e3) => e3 && e3.name && !Array.isArray(e3.allName))).length + n2.filter(((e3) => e3 && e3.name && !Array.isArray(e3.allName))).length; + u2 && o2("invalid-all-name", "演员别名不是数组", u2); + let f = 0; + t2.forEach(((e3) => { + Array.isArray(e3?.newVideoList) && (f += e3.newVideoList.filter(((e4) => { + const t3 = "string" == typeof e4 ? e4 : e4.carNum; + return i2.has(t3); + })).length); + })), f && o2("stored-new-video", "新作品列表中已有鉴定记录", f); + const v2 = e2.filter(((e3) => e3 && e3.carNum && !e3.url)).length; + v2 && r2("missing-url", "番号记录缺失 url,需要人工确认来源", v2); + const b2 = t2.filter(((e3) => e3 && !e3.starId)).length + n2.filter(((e3) => e3 && !e3.starId)).length; + b2 && r2("missing-star-id", "演员缺失 starId,需要人工确认身份", b2); + const w = new Set(n2.map(((e3) => e3 && e3.starId)).filter(Boolean)), y2 = a2.filter(((e3) => e3 && e3.starId && !w.has(e3.starId))).length; + return y2 && r2("orphan-blacklist-car", "黑名单作品找不到关联演员", y2), s2; + } + async repairDataHealth() { + let e2 = 0, t2 = await this.getCarList(), n2 = await this.getFavoriteActressList(), a2 = await this.getBlacklist(), i2 = await this.getBlacklistCarList(); + const s2 = this._dedupeByKey(t2, "carNum"); + s2.changed && (t2 = s2.list, e2++); + const o2 = this._dedupeByKey(n2, "starId"); + o2.changed && (n2 = o2.list, e2++); + const r2 = this._dedupeByKey(a2, "starId"); + r2.changed && (a2 = r2.list, e2++); + t2 = t2.map(((t3) => { + if (!t3) return t3; + let n3 = false; + return void 0 !== t3.actress && (t3.names = t3.actress, delete t3.actress, n3 = true), n3 && e2++, t3; + })), i2 = i2.map(((t3) => { + if (!t3) return t3; + let n3 = false; + return void 0 !== t3.actress && (t3.names = t3.actress, delete t3.actress, n3 = true), Object.prototype.hasOwnProperty.call(t3, "type") && (delete t3.type, n3 = true), n3 && e2++, t3; + })), n2 = n2.map(((t3) => { + if (!t3) return t3; + let n3 = false; + return Object.prototype.hasOwnProperty.call(t3, "dbId") && (t3.starId || (t3.starId = t3.dbId), delete t3.dbId, n3 = true), t3.name && !Array.isArray(t3.allName) && (t3.allName = t3.allName ? [t3.allName] : [t3.name], n3 = true), n3 && e2++, t3; + })), a2 = a2.map(((t3) => { + if (!t3) return t3; + let n3 = false; + return Object.prototype.hasOwnProperty.call(t3, "isActor") && (t3.role || (t3.role = t3.isActor ? B : P), delete t3.isActor, n3 = true), Object.prototype.hasOwnProperty.call(t3, "recordTime") && (t3.createTime || (t3.createTime = t3.recordTime), delete t3.recordTime, n3 = true), Object.prototype.hasOwnProperty.call(t3, "key") && (delete t3.key, n3 = true), t3.name && !Array.isArray(t3.allName) && (t3.allName = t3.allName ? [t3.allName] : [t3.name], n3 = true), n3 && e2++, t3; + })); + const l2 = new Set(t2.filter(((e3) => e3 && e3.carNum)).map(((e3) => e3.carNum))); + n2 = n2.map(((t3) => { + if (!Array.isArray(t3?.newVideoList)) return t3; + const n3 = t3.newVideoList.filter(((e3) => { + const n4 = "string" == typeof e3 ? e3 : e3.carNum; + return !l2.has(n4); })); - } + return n3.length !== t3.newVideoList.length && (t3 = { + ...t3, + newVideoList: n3 + }, e2++), t3; + })), await this._setItemAndInvalidate(this.car_list_key, t2), await this._setItemAndInvalidate(this.favorite_actresses_key, n2), await this._setItemAndInvalidate(this.blacklist_key, a2), await this._setItemAndInvalidate(this.blacklist_car_list_key, i2); + return { + fixedGroups: e2, + report: await this.inspectDataHealth() + }; } - getNowStr(e2 = "-", t2 = ":", n2 = null) { - let a2; - a2 = n2 ? new Date(n2) : /* @__PURE__ */ new Date(); - const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); - return `${[i2, s2, o2].join(e2)} ${[r2, l2, c2].join(t2)}`; + /** 数据迁移: 将旧版扁平键名重命名为新格式 */ + async merge_table_name() { + let e2 = "filter_actor_actress_info_list", t2 = await this.forage.getItem(e2) || []; + t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_key, t2)), await this.forage.removeItem(e2), e2 = "favorite_actresses_info_list", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.favorite_actresses_key, t2)), await this.forage.removeItem(e2), e2 = "car_list_filter_actor_actress", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_car_list_key, t2)), await this.forage.removeItem(e2), e2 = "title_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_title_key, t2)), await this.forage.removeItem(e2), e2 = "review_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_review_key, t2)), await this.forage.removeItem(e2), e2 = "highlightedTags", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.highlighted_tags_key, t2)), await this.forage.removeItem(e2); } - formatDate(e2, t2 = "-", n2 = ":") { - let a2; - if (e2 instanceof Date) a2 = e2; - else { - if ("string" != typeof e2) throw new Error("Invalid date input: must be Date object or date string"); - if (a2 = new Date(e2), isNaN(a2.getTime())) throw new Error("Invalid date string"); + async clean_no_url_blacklist() { + const [e2, t2] = await Promise.all([this.getBlacklistCarList(), this.getBlacklist()]); + if (e2.length && !e2[0].actress) return; + const n2 = new Set(t2.map(((e3) => e3.name))), a2 = e2.filter(((e3) => !e3.actress || n2.has(e3.actress))); + e2.length !== a2.length && (clog.debug("清理 blacklistCarList 前", e2.length), clog.debug("清理 blacklistCarList 后", a2.length), await this._setItemAndInvalidate(this.blacklist_car_list_key, a2)); + const i2 = new Set(a2.map(((e3) => e3.actress))); + let s2 = t2.filter(((e3) => i2.has(e3.name))); + s2 = s2.map(((e3) => { + const { key: t3, recordTime: n3, ...a3 } = e3, i3 = a3; + return void 0 !== n3 && (i3.createTime = n3), i3; + })), (t2.length !== s2.length || t2.some(((e3) => "key" in e3 || "recordTime" in e3))) && (clog.debug("清理 Blacklist 前", t2.length), clog.debug("清理 Blacklist 后", s2.length), await this._setItemAndInvalidate(this.blacklist_key, s2)); + } + async async_merge_other() { + const e2 = await this.getSetting(); + let t2 = false; + const n2 = { + enableCheckFilterActorActress: "enableCheckBlacklist", + checkIntervalTime_filterActorActress: "checkBlacklist_intervalTime", + checkIntervalTime_ruleTime: "checkNewVideo_ruleTime", + checkIntervalTime_newVideo: "checkNewVideo_intervalTime", + checkIntervalTime_favoriteActress: "checkFavoriteActress_IntervalTime" + }; + for (const a2 in n2) { + const i2 = n2[a2]; + Object.prototype.hasOwnProperty.call(e2, a2) && (e2[i2] = e2[a2], delete e2[a2], t2 = true); } - const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); - return `${[i2, s2, o2].join(t2)} ${[r2, l2, c2].join(n2)}`; + e2.checkFilterTime && (delete e2.checkFilterTime, t2 = true), e2.checkFilterConcurrencyCount && (delete e2.checkFilterConcurrencyCount, t2 = true), e2.checkFilterSleep && (delete e2.checkFilterSleep, t2 = true), t2 && (await this.saveSetting(e2), clog.debug("配置数据已更正")); } - getHourDifference(e2, t2) { - const n2 = e2.getTime(), a2 = t2.getTime(), i2 = Math.abs(a2 - n2) / 36e5; - return Math.floor(i2); + /** 数据迁移: 补全黑名单条目缺失的 role/starId/allName/movieType 字段 */ + async merge_blacklist() { + const e2 = await this.getBlacklist(); + if (!e2 || 0 === e2.length) return; + let t2 = false; + const n2 = e2.map(((e3) => { + let n3 = false; + if (Object.prototype.hasOwnProperty.call(e3, "isActor") && !e3.role && (e3.role = e3.isActor ? B : P, delete e3.isActor, n3 = true), !e3.starId && e3.url) try { + const t3 = new URL(e3.url).pathname, a3 = t3.split("/").filter(((e4) => "" !== e4.trim())).pop(); + e3.starId !== a3 && (e3.starId = a3, n3 = true); + } catch (a3) { + clog.error("提取url-starId发生错误", e3.url, a3); + } + if (e3.allName || (e3.allName = e3.name ? [e3.name] : [], n3 = true), e3.movieType || (e3.movieType = D, n3 = true), e3.url && e3.url.includes("sort_type")) { + const t3 = new URL(e3.url); + t3.searchParams.delete("sort_type"), e3.url = t3.toString(), clog.debug("去除黑名单地址sort_type参数"); + } + return n3 && (t2 = true), e3; + })); + t2 && (clog.debug("更正 Blacklist 数据结构"), await this._setItemAndInvalidate(this.blacklist_key, n2)); + const a2 = await this.getBlacklistCarList(); + t2 = false; + const i2 = a2.map(((n3) => { + if (!n3.starId) { + let a3 = e2.find(((e3) => e3.name === n3.actress)); + a3 && (n3.starId = a3.starId), t2 = true; + } + return n3.type && (delete n3.type, t2 = true), n3; + })); + t2 && (clog.debug("更正 blacklistCarList 数据结构"), await this._setItemAndInvalidate(this.blacklist_car_list_key, i2)); } - download(e2, t2) { - show.info("开始请求下载..."); - const n2 = t2.split(".").pop().toLowerCase(); - let a2, i2 = this.mimeTypes[n2] || "application/octet-stream"; - if (e2 instanceof Blob) a2 = e2; - else if (e2 instanceof ArrayBuffer || ArrayBuffer.isView(e2)) a2 = new Blob([e2], { - type: i2 - }); - else if ("string" == typeof e2 && e2.startsWith("data:")) { - const t3 = atob(e2.split(",")[1]), n3 = new ArrayBuffer(t3.length), s3 = new Uint8Array(n3); - for (let e3 = 0; e3 < t3.length; e3++) s3[e3] = t3.charCodeAt(e3); - a2 = new Blob([s3], { - type: i2 - }); - } else a2 = new Blob([e2], { - type: i2 - }); - const s2 = URL.createObjectURL(a2), o2 = document.createElement("a"); - o2.href = s2, o2.download = t2, document.body.appendChild(o2), o2.click(), setTimeout((() => { - document.body.removeChild(o2), URL.revokeObjectURL(s2); - }), 100); + async merge_favoriteActress() { + const e2 = await this.getFavoriteActressList(); + if (!e2 || 0 === e2.length) return; + let t2 = false; + const n2 = e2.map(((e3) => { + let n3 = false; + return e3.dbId && (e3.starId = e3.dbId, delete e3.dbId, n3 = true), n3 && (t2 = true), e3; + })); + t2 && (clog.debug("更正 favoriteActressesInfoList 数据结构"), await this._setItemAndInvalidate(this.favorite_actresses_key, n2)); } - smoothScrollToTop(e2 = 500) { - return new Promise(((t2) => { - const n2 = performance.now(), a2 = window.pageYOffset; - window.requestAnimationFrame(/* @__PURE__ */ __name((function i2(s2) { - const o2 = s2 - n2, r2 = Math.min(o2 / e2, 1), l2 = r2 < 0.5 ? 4 * r2 * r2 * r2 : 1 - Math.pow(-2 * r2 + 2, 3) / 2; - window.scrollTo(0, a2 * (1 - l2)), r2 < 1 ? window.requestAnimationFrame(i2) : t2(); - }), "i")); + async merge_tow_car_list_table() { + const e2 = await this.getBlacklistCarList(), t2 = await this.getCarList(); + let n2 = false; + const a2 = e2.map(((e3) => { + let t3 = false; + return void 0 !== e3.actress && (e3.names = e3.actress, delete e3.actress, t3 = true), t3 && (n2 = true), e3; + })); + n2 && (clog.debug("更正 blacklistCarList 数据结构 actress->names"), await this._setItemAndInvalidate(this.blacklist_car_list_key, a2)), n2 = false; + const i2 = t2.map(((e3) => { + let t3 = false; + return void 0 !== e3.actress && (e3.names = e3.actress, delete e3.actress, t3 = true), t3 && (n2 = true), e3; })); + n2 && (clog.debug("更正 carList 数据结构 actress->names"), await this._setItemAndInvalidate(this.car_list_key, i2)); } - isUrl(e2) { - try { - return new URL(e2), true; - } catch (t2) { - return false; - } + /* ───── 快照管理 ───── */ + _snapshotKey() { + return "snapshots"; } - setHrefParam(e2, t2) { - const n2 = new URL(window.location.href); - n2.searchParams.set(e2, t2), window.history.pushState({}, "", n2.toString()); + _snapshotMetaKeys() { + return ["snapshots", "data_version"]; } - getUrlParam(e2, t2) { - const n2 = e2.split("?")[1]; - if (!n2) return null; - const a2 = new RegExp(`(?:^|&)${t2}=([^&]*)`), i2 = n2.match(a2); - let s2 = ""; - return i2 && i2[1] && (s2 = decodeURIComponent(i2[1].replace(/\+/g, " "))), s2 ? "true" === s2 || "false" === s2 ? "true" === s2.toLowerCase() : "string" != typeof s2 || "" === s2.trim() || isNaN(Number(s2)) ? s2 : Number(s2) : s2; + async _getSnapshots() { + return await this.forage.getItem(this._snapshotKey()) || []; } - getResponsiveArea(e2) { - const t2 = window.innerWidth; - return this.isMobileMode() ? ["100%", "90%"] : t2 >= 1200 ? e2 || this.getDefaultArea() : ["70%", "90%"]; + async _saveSnapshots(e2) { + await this._setItemAndInvalidate(this._snapshotKey(), e2); } - /** 按用途返回具有固定上限和安全边距的弹窗尺寸。 */ - getDialogArea(e2 = "md") { - const t2 = { - sm: [480, 640], - md: [720, 700], - lg: [1040, 760], - xl: [1320, 860], - workspace: [1440, 960] - }, n2 = t2[e2] || t2.md, a2 = window.innerWidth <= 768 ? 16 : "workspace" === e2 ? 32 : 64, i2 = Math.max(320, Math.min(n2[0], window.innerWidth - a2)), s2 = Math.max(320, Math.min(n2[1], window.innerHeight - a2)); - return [`${i2}px`, `${s2}px`]; + async _withSnapshotLock(e2) { + return await navigator.locks.request("jhs_snapshot_lock", async () => await e2()); } - getDefaultArea() { - return ["85%", "90%"]; + async createSnapshot(e2 = "", t2 = "manual") { + return this._withSnapshotLock(async () => { + const n2 = await this._getSnapshots(), a2 = await this.exportData(); + for (const i3 of this._snapshotMetaKeys()) delete a2[i3]; + let i2 = 0; + for (const s3 of Object.values(a2)) Array.isArray(s3) ? i2 += s3.length : "object" == typeof s3 && s3 && i2++; + const s2 = { + id: "snap_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8), + name: e2 || utils.getNowStr(), + source: t2, + time: utils.getNowStr(), + itemCount: i2, + data: a2, + kind: "snapshot" + }; + n2.push(s2), n2.length > 10 && n2.splice(0, n2.length - 10); + return await this._saveSnapshots(n2), clog.log(`创建快照: ${s2.name} (${t2})`), s2; + }); } - isMobile() { - const e2 = navigator.userAgent.toLowerCase(); - return ["iphone", "ipod", "ipad", "android", "blackberry", "windows phone", "nokia", "webos", "opera mini", "mobile", "mobi", "tablet"].some(((t2) => e2.includes(t2))); + async getSnapshotList() { + return (await this._getSnapshots()).map(((e2) => ({ id: e2.id, name: e2.name, source: e2.source, time: e2.time, itemCount: e2.itemCount, kind: e2.kind || "snapshot", targetDataVersion: e2.targetDataVersion, appVersion: e2.appVersion }))); } - isMobileMode() { - const e2 = storageManager.getSettingSync("mobileMode", "auto"); - return "on" === e2 || "off" !== e2 && (this.isMobile() || window.innerWidth < 768); + async getSnapshot(e2) { + return (await this._getSnapshots()).find(((t2) => t2.id === e2)) || null; } - async copyToClipboard(e2, t2) { - const text = String(t2 ?? ""); - let copied = false; - try { - if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text), copied = true; - else throw new Error("Clipboard API unavailable"); - } catch (clipboardError) { - const textarea = document.createElement("textarea"), activeElement = document.activeElement; - textarea.value = text, textarea.setAttribute("readonly", ""), textarea.style.position = "fixed", textarea.style.opacity = "0", document.body.appendChild(textarea), textarea.select(); - try { - copied = true === document.execCommand("copy"); - if (!copied) throw clipboardError; - } catch (fallbackError) { - clog.error("复制失败:", fallbackError), show.error("复制失败,请手动复制"); - } finally { - textarea.remove(), activeElement?.focus?.(); - } - } - return copied && show.info(`${e2}已复制到剪贴板, ${text}`), copied; + async deleteSnapshot(e2) { + return this._withSnapshotLock(async () => { + const t2 = await this._getSnapshots(), n2 = t2.filter(((t3) => t3.id !== e2)); + if (n2.length === t2.length) return void clog.warn("删除快照失败, ID不存在: " + e2); + await this._saveSnapshots(n2), clog.log("删除快照: " + e2); + }); } - htmlTo$dom(e2) { - const t2 = new DOMParser(); - return $(t2.parseFromString(e2, "text/html")); + async restoreSnapshot(e2) { + const t2 = await this.getSnapshot(e2); + if (!t2) throw new Error("快照不存在: " + e2); + if (!t2.data || "object" != typeof t2.data) throw new Error("快照数据损坏"); + const n2 = { ...t2.data }; + for (const a2 of this._snapshotMetaKeys()) delete n2[a2]; + await this.importData(n2); + clog.log("已恢复快照: " + t2.name); + return t2; } - isHidden(e2) { - const t2 = e2.jquery ? e2[0] : e2; - return !t2 || (t2.offsetWidth <= 0 && t2.offsetHeight <= 0 || "none" === window.getComputedStyle(t2).display); + /* ───── 差异对比引擎 ───── */ + _stableStringify(e2) { + if (null === e2 || "object" != typeof e2) return JSON.stringify(e2); + if (Array.isArray(e2)) return "[" + e2.map(((e3) => this._stableStringify(e3))).join(",") + "]"; + return "{" + Object.keys(e2).sort().map(((t2) => JSON.stringify(t2) + ":" + this._stableStringify(e2[t2]))).join(",") + "}"; } - time(e2 = "default", t2 = "s", n2 = 2) { - if (this.timers.has(e2)) { - const t3 = this.timers.get(e2), n3 = performance.now() - t3.startTime; - let a2, i2; - return "s" === t3.unit ? (a2 = (n3 / 1e3).toFixed(t3.precision), i2 = "秒") : (a2 = n3.toFixed(t3.precision), i2 = "毫秒"), this.timers.delete(e2), `${e2}: ${a2}${i2}`; + diffData(e2, t2) { + const n2 = /* @__PURE__ */ new Set([...Object.keys(e2), ...Object.keys(t2)]), a2 = {}, i2 = { added: 0, removed: 0, modified: 0, unchanged: 0 }; + for (const o2 of n2) { + const r2 = e2[o2], l2 = t2[o2]; + if (void 0 === r2 && void 0 === l2) continue; + if (void 0 === r2) { + const e3 = Array.isArray(l2) ? l2.length : 1; + a2[o2] = { status: "added", oldCount: 0, newCount: e3, added: Array.isArray(l2) ? l2 : [], removed: [], modified: [] }, i2.added++; + } else if (void 0 === l2) { + const t3 = Array.isArray(r2) ? r2.length : 1; + a2[o2] = { status: "removed", oldCount: t3, newCount: 0, added: [], removed: Array.isArray(r2) ? r2 : [], modified: [] }, i2.removed++; + } else if (Array.isArray(r2) && Array.isArray(l2)) { + const e3 = this._diffArrays(r2, l2, o2); + a2[o2] = { status: e3.status, oldCount: r2.length, newCount: l2.length, ...e3 }, i2[e3.status]++; + } else if ("object" == typeof r2 && "object" == typeof l2 && !Array.isArray(r2) && !Array.isArray(l2)) { + const e3 = this._diffObjects(r2, l2); + a2[o2] = { status: e3.status, oldCount: Object.keys(r2).length, newCount: Object.keys(l2).length, added: [], removed: [], modified: e3.changes }, i2[e3.status]++; + } else r2 === l2 ? (a2[o2] = { status: "unchanged", oldCount: 1, newCount: 1, added: [], removed: [], modified: [] }, i2.unchanged++) : (a2[o2] = { status: "modified", oldCount: 1, newCount: 1, added: [], removed: [], modified: [{ key: o2, changes: { _value: [r2, l2] } }] }, i2.modified++); } - this.timers.set(e2, { - startTime: performance.now(), - unit: t2, - precision: n2 - }); - } - sleep(e2 = 1e3) { - return new Promise(((t2) => setTimeout(t2, e2))); + return { summary: i2, stores: a2 }; } - /** 创建可取消的尾沿防抖函数。 */ - debounce(callback, wait = 200) { - let timer = null; - const debounced = /* @__PURE__ */ __name(function(...args) { - const context = this; - clearTimeout(timer), timer = setTimeout((() => { - timer = null, callback.apply(context, args); - }), Math.max(0, wait)); - }, "debounced"); - return debounced.cancel = () => { - clearTimeout(timer), timer = null; - }, debounced; + _getArrayKey(e2) { + return "car_list" === e2 || "blacklist_car_list" === e2 ? "carNum" : "blacklist" === e2 || "favorite_actresses" === e2 ? "starId" : null; } - genericSort(e2, t2, n2 = true) { - if (!Array.isArray(e2) || 0 === e2.length) return []; - if (!Array.isArray(t2) || 0 === t2.length) return [...e2]; - const i2 = /* @__PURE__ */ __name((e3) => { - if (e3 instanceof Date) return e3; - if ("string" == typeof e3) { - const t3 = new Date(e3); - if (!isNaN(t3.getTime())) return t3; - } - return e3; - }, "i"); - const getVal = /* @__PURE__ */ __name((e3, t3) => null != t3 ? "function" == typeof t3 ? t3(e3) : e3 && "object" == typeof e3 ? e3[t3] : void 0 : e3, "getVal"); - const nulls = [], nonNulls = []; - for (const item of e2) { - let hasNull = false; - for (const s2 of t2) { - const val = getVal(item, s2.key); - if (null == val || void 0 === val) { - hasNull = true; - break; - } - } - hasNull ? nulls.push(item) : nonNulls.push(item); + _diffArrays(e2, t2, n2) { + const a2 = this._getArrayKey(n2); + if (!a2) { + const n3 = this._stableStringify(e2), i3 = this._stableStringify(t2); + if (n3 === i3) return { status: "unchanged", added: [], removed: [], modified: [] }; + const s3 = new Set(e2.map(((e3) => this._stableStringify(e3)))), o3 = new Set(t2.map(((e3) => this._stableStringify(e3)))); + return { status: "modified", added: t2.filter(((e3) => !s3.has(this._stableStringify(e3)))), removed: e2.filter(((e3) => !o3.has(this._stableStringify(e3)))), modified: [] }; } - return nonNulls.sort(((e3, a2) => { - for (const s2 of t2) { - const { key: t3, order: o2 = "asc" } = s2; - let r2 = getVal(e3, t3), l2 = getVal(a2, t3); - const c2 = i2(r2), d2 = i2(l2); - let h2 = c2 instanceof Date && d2 instanceof Date ? c2.getTime() - d2.getTime() : "number" == typeof r2 && "number" == typeof l2 ? r2 - l2 : "string" == typeof r2 && "string" == typeof l2 ? r2.localeCompare(l2) : String(r2).localeCompare(String(l2)); - "desc" === o2 && (h2 *= -1); - if (0 !== h2) return h2; + const i2 = new Map(e2.map(((e3) => [e3[a2], e3]))), s2 = new Map(t2.map(((e3) => [e3[a2], e3]))), o2 = [], r2 = [], l2 = []; + for (const [c3, d2] of s2) { + const t3 = i2.get(c3); + if (!t3) o2.push(d2); + else { + const e3 = this._diffObjects(t3, d2); + "unchanged" !== e3.status && l2.push({ key: c3, changes: e3.changes }); } - return 0; - })), n2 ? [...nonNulls, ...nulls] : [...nulls, ...nonNulls]; + } + for (const [c3] of i2) s2.has(c3) || r2.push(i2.get(c3)); + const c2 = o2.length + r2.length + l2.length; + return { status: 0 === c2 ? "unchanged" : "modified", added: o2, removed: r2, modified: l2 }; } - async retry(e2, t2 = 3) { - let n2 = 0; - for (; n2 < t2; ) try { - const t3 = await e2(); - return n2 > 0 && clog.debug(`[重试] 请求成功,共发起 ${n2 + 1} 次。`), t3; - } catch (a2) { - const e3 = a2 instanceof Error ? a2.message : "object" == typeof a2 ? JSON.stringify(a2) : String(a2); - if (a2?._cfBlocked || a2?._circuitBroken || e3.includes("Just a moment") || e3.includes("重定向") || e3.toLowerCase().includes("404 not found")) throw a2; - if (n2++, n2 === t2) throw clog.debug(`[重试] 达到最大重试次数 (${t2}),最终失败:`, a2), a2; - await this.sleep(500 * n2), clog.debug(`[重试] 请求失败,准备第 ${n2 + 1} 次重试, 错误信息: ${e3}`); + _diffObjects(e2, t2) { + const n2 = /* @__PURE__ */ new Set([...Object.keys(e2), ...Object.keys(t2)]), a2 = {}; + let i2 = 0; + for (const s2 of n2) { + const o2 = e2[s2], r2 = t2[s2]; + this._stableStringify(o2) !== this._stableStringify(r2) && (a2[s2] = [o2, r2], i2++); } + return { status: 0 === i2 ? "unchanged" : "modified", changes: a2 }; } }; - __name(_Utils, "Utils"); - var Utils = _Utils; - unsafeWindow.utils = window.utils = new Utils(), unsafeWindow.gmHttp = window.gmHttp = new class { + __name(_StorageManager, "StorageManager"); + var StorageManager = _StorageManager; + + // src/core/http.js + var utils2 = globalThis.utils ?? new Utils(); + var gmHttp2 = new class { constructor() { this._circuitBreakers = /* @__PURE__ */ new Map(); this._domainStats = /* @__PURE__ */ new Map(); @@ -3531,7 +2179,7 @@ ${value}\r const e3 = new URLSearchParams(a2).toString(); t2 += (t2.includes("?") ? "&" : "?") + e3; } - const o2 = this._getDomain(t2), [m2, r2, b2, k2] = await Promise.all([storageManager.getSetting("httpTimeout", 5e3), storageManager.getSetting("httpRetryCount", 3), storageManager.getSetting("circuitBreakerThreshold", 3), storageManager.getSetting("circuitBreakerCooldown", 6e4)]); + const o2 = this._getDomain(t2), [m2, r2, b2, k2] = await Promise.all([storageManager2.getSetting("httpTimeout", 5e3), storageManager2.getSetting("httpRetryCount", 3), storageManager2.getSetting("circuitBreakerThreshold", 3), storageManager2.getSetting("circuitBreakerCooldown", 6e4)]); let u2 = this._circuitBreakers.get(o2); u2 || (u2 = { state: "closed", failCount: 0, openTime: 0, cooldownMs: k2, threshold: b2, probing: false }, this._circuitBreakers.set(o2, u2)); const w = this._checkCircuitBreaker(o2); @@ -3539,7 +2187,7 @@ ${value}\r const e3 = new Error(`站点 ${o2} 已熔断,${w.remaining}秒后重试`); throw e3._circuitBroken = true, e3; } - return n2 || (n2 = void 0), await utils.retry(() => { + return n2 || (n2 = void 0), await utils2.retry(() => { const c2 = this._checkCircuitBreaker(o2); if (c2) { const t3 = new Error(`站点 ${o2} 已熔断,${c2.remaining}秒后重试`); @@ -3601,44 +2249,10 @@ ${value}\r })); }, r2); } - }(), unsafeWindow.storageManager = window.storageManager = new StorageManager(); - var _JhsEventBus = class _JhsEventBus { - constructor(channelName = "channel-refresh") { - this.originId = globalThis.crypto?.randomUUID?.() || `tab_${Date.now()}_${Math.random().toString(36).slice(2)}`; - this.listeners = /* @__PURE__ */ new Map(), this.seen = /* @__PURE__ */ new Set(), this.channel = new BroadcastChannel(channelName); - this.channel.addEventListener("message", ((event) => this._receive(event.data))); - } - on(type, handler) { - const handlers = this.listeners.get(type) || /* @__PURE__ */ new Set(); - return handlers.add(handler), this.listeners.set(type, handlers), () => handlers.delete(handler); - } - async _dispatch(event) { - for (const handler of [...this.listeners.get(event.type) || []]) await handler(event.payload, event); - } - _remember(eventId) { - this.seen.add(eventId), this.seen.size > 256 && this.seen.delete(this.seen.values().next().value); - } - async emit(type, payload = {}, options = {}) { - const event = { eventId: globalThis.crypto?.randomUUID?.() || `event_${Date.now()}_${Math.random().toString(36).slice(2)}`, originId: this.originId, type, payload, timestamp: Date.now() }; - this._remember(event.eventId), await this._dispatch(event), false !== options.broadcast && this.channel.postMessage(event); - return event; - } - async _receive(event) { - if (!event || event.originId === this.originId || event.eventId && this.seen.has(event.eventId)) return; - if (!event.eventId) { - const legacyType = "refresh" === event.type ? "legacy-refresh" : event.type; - return this._dispatch({ ...event, type: legacyType, payload: event.payload || {}, eventId: `legacy_${Date.now()}_${Math.random()}`, originId: "legacy", timestamp: Date.now() }); - } - this._remember(event.eventId), await this._dispatch(event); - } - }; - __name(_JhsEventBus, "JhsEventBus"); - var JhsEventBus = _JhsEventBus; - var jhsEventBus = unsafeWindow.jhsEventBus = window.jhsEventBus = new JhsEventBus(); - var G = jhsEventBus.channel; - window.refresh = () => jhsEventBus.emit("legacy-refresh"); - window.cleanCache_filter_actor_actress_car_list = () => jhsEventBus.emit("blacklist-rules-changed"); - window.clean_cacheSettingObj = () => jhsEventBus.emit("settings-changed"); + }(); + var storageManager2 = globalThis.storageManager ?? new StorageManager(); + + // src/core/state-service.js var ACTIVITY_SOFT_LIMIT = 1e3; var ACTIVITY_HARD_LIMIT = 1e4; var ACTIVITY_RETENTION_MS = 30 * 864e5; @@ -3910,215 +2524,268 @@ ${value}\r }; __name(_StateService, "StateService"); var StateService = _StateService; - var stateService = unsafeWindow.stateService = window.stateService = new StateService(storageManager, jhsEventBus); - var DETAIL_STATE_BUTTONS = { - blocked: { selector: "#filterBtn", inactive: /* @__PURE__ */ __name(() => m, "inactive"), active: /* @__PURE__ */ __name(() => u, "active") }, - favorite: { selector: "#favoriteBtn", inactive: /* @__PURE__ */ __name(() => v, "inactive"), active: /* @__PURE__ */ __name(() => b, "active") }, - downloaded: { selector: "#hasDownBtn", inactive: /* @__PURE__ */ __name(() => y, "inactive"), active: /* @__PURE__ */ __name(() => "已标记下载", "active") }, - watched: { selector: "#hasWatchBtn", inactive: /* @__PURE__ */ __name(() => k, "inactive"), active: /* @__PURE__ */ __name(() => "已标记观看", "active") } - }; - var _DetailStateController = class _DetailStateController { - bind({ root = document, layerIndex = null, carNum, getRecord, activityType = "detail-state", selectors = {} }) { - const config = { root, layerIndex, carNum: normalizeCarNum(carNum), getRecord, activityType, selectors }; - for (const [flag, definition] of Object.entries(DETAIL_STATE_BUTTONS)) { - const selector = selectors[flag] || definition.selector; - $(root).find(selector).off("click.jhsDetailState").on("click.jhsDetailState", ((event) => { - event.preventDefault(), event.stopPropagation(), void this.requestToggle(config, flag, event); - })); - } - void this.render(config); - return config; + var stateService = new StateService(storageManager2, jhsEventBus); + storageManager2.stateService = stateService; + + // src/core/lifecycle-scope.js + var _LifecycleScope = class _LifecycleScope { + /** @param {string} id @param {{onChange?: (snapshot: ReturnType) => void}} [options] */ + constructor(id, options = {}) { + if (!id) throw new TypeError("LifecycleScope id is required"); + this.id = id; + this.controller = new AbortController(); + this.cleanups = /* @__PURE__ */ new Set(); + this.requestConsumers = /* @__PURE__ */ new Set(); + this.listenerCount = 0; + this.observerCount = 0; + this.disposed = false; + this.generation = 0; + this.onChange = options.onChange ?? null; + } + get signal() { + return this.controller.signal; + } + /** @param {EventTarget} target @param {string} type @param {EventListenerOrEventListenerObject} listener @param {AddEventListenerOptions | boolean} [options] */ + listen(target, type, listener, options) { + this.assertActive(); + target.addEventListener(type, listener, options); + this.listenerCount += 1; + return this.addCleanup(() => { + target.removeEventListener(type, listener, options); + this.listenerCount -= 1; + }); } - async requestToggle(config, flag, event = null) { - if (!config.carNum) return void show.error("番号不可用,无法更新状态"); - const current = await storageManager.getCar(config.carNum), flags = normalizeStateFlags(current?.stateFlags); - if ("blocked" === flag && !flags.blocked) return void utils.q(event, `是否屏蔽${config.carNum}?`, (() => this.toggle(config, flag, event))); - return this.toggle(config, flag, event); + /** @param {{disconnect: () => void}} observer */ + ownObserver(observer) { + this.assertActive(); + this.observerCount += 1; + return this.addCleanup(() => { + observer.disconnect(); + this.observerCount -= 1; + }); } - async toggle(config, flag, event = null) { - const selector = config.selectors[flag] || DETAIL_STATE_BUTTONS[flag].selector, button = event?.currentTarget ? $(event.currentTarget) : $(config.root).find(selector); - if (button.prop("disabled")) return; - button.prop("disabled", true).attr("aria-busy", "true"); - try { - const record = "function" == typeof config.getRecord ? await config.getRecord() : config.getRecord || { carNum: config.carNum }; - await stateService.toggle(config.carNum, flag, { type: config.activityType, record }), await this.render(config), await utils.closePage({ layerIndex: config.layerIndex, root: config.root }); - } catch (error) { - clog.error("详情状态更新失败", error), show.error("操作失败"); - } finally { - button[0]?.isConnected && button.prop("disabled", false).removeAttr("aria-busy"); - } + /** @param {number} timerId */ + ownTimeout(timerId) { + return this.addCleanup(() => clearTimeout(timerId)); + } + /** @param {{release: () => void}} consumer */ + ownRequestConsumer(consumer) { + this.assertActive(); + this.requestConsumers.add(consumer); + this.emitChange(); + return this.addCleanup(() => { + if (!this.requestConsumers.delete(consumer)) return; + consumer.release(); + }); } - async render({ root = document, carNum, selectors = {} }) { - const record = await storageManager.getCar(normalizeCarNum(carNum)), flags = normalizeStateFlags(record?.stateFlags); - for (const [flag, definition] of Object.entries(DETAIL_STATE_BUTTONS)) { - const button = $(root).find(selectors[flag] || definition.selector), active = !!flags[flag]; - button.attr("aria-pressed", String(active)).find("span").first().text(active ? definition.active() : definition.inactive()); - } - return flags; + /** @param {() => void} cleanup */ + addCleanup(cleanup) { + this.assertActive(); + let active = true; + const wrapped = /* @__PURE__ */ __name(() => { + if (!active) return; + active = false; + this.cleanups.delete(wrapped); + cleanup(); + this.emitChange(); + }, "wrapped"); + this.cleanups.add(wrapped); + this.emitChange(); + return wrapped; + } + nextGeneration() { + this.assertActive(); + this.generation += 1; + return this.generation; + } + /** @param {number} generation */ + canCommit(generation) { + return !this.disposed && generation === this.generation; + } + dispose() { + if (this.disposed) return; + this.disposed = true; + this.controller.abort(); + for (const cleanup of [...this.cleanups].reverse()) cleanup(); + this.emitChange(); + } + assertActive() { + if (this.disposed) throw new DOMException(`LifecycleScope ${this.id} is disposed`, "AbortError"); + } + snapshot() { + return Object.freeze({ id: this.id, disposed: this.disposed, listeners: this.listenerCount, observers: this.observerCount, requestConsumers: this.requestConsumers.size, generation: this.generation }); + } + emitChange() { + this.onChange?.(this.snapshot()); } }; - __name(_DetailStateController, "DetailStateController"); - var DetailStateController = _DetailStateController; - var detailStateController = new DetailStateController(); - document.head.insertAdjacentHTML("beforeend", '\n \n '); - unsafeWindow.loading = window.loading = function() { - const e2 = document.createElement("div"); - e2.className = "loading-container"; - const t2 = document.createElement("div"); - return t2.className = "loading-animation", e2.appendChild(t2), document.body.appendChild(e2), { - close: /* @__PURE__ */ __name(() => { - e2 && e2.parentNode && e2.parentNode.removeChild(e2); - }, "close") - }; - }, (function() { - const e2 = /* @__PURE__ */ __name((e3, t2, n2, a2, i2) => { - let s2; - "object" == typeof n2 ? s2 = n2 : (s2 = "object" == typeof a2 ? a2 : i2 || {}, s2.gravity = n2 || "top", s2.position = "string" == typeof a2 ? a2 : "center"), s2.gravity && "center" !== s2.gravity || (s2.offset = { - y: "calc(50vh - 150px)" - }); - const o2 = "var(--jhs-status-fav)", r2 = "var(--jhs-status-fav-tint)", l2 = "var(--jhs-status-down)", c2 = "var(--jhs-status-down-tint)", d2 = "var(--jhs-status-filter)", h2 = "var(--jhs-status-filter-tint)", g2 = { - borderRadius: "12px", - padding: "12px 16px", - boxShadow: "0 4px 6px rgba(0,0,0,0.1)", - minWidth: "150px", - textAlign: "center", - zIndex: JHS_Z_INDEX.debug - }, p2 = { - text: e3, - duration: 2500, - close: false, - gravity: "top", - position: "center", - style: { - info: { - ...g2, - background: `${o2}`, - color: "var(--jhs-status-fav-on)" + __name(_LifecycleScope, "LifecycleScope"); + var LifecycleScope = _LifecycleScope; + + // src/core/logger.js + var loggerRuntime; + function initializeLoggerRuntime(scope) { + if (loggerRuntime) return loggerRuntime; + document.head.insertAdjacentHTML("beforeend", '\n \n '); + window.loading = function() { + const e2 = document.createElement("div"); + e2.className = "loading-container"; + const t2 = document.createElement("div"); + return t2.className = "loading-animation", e2.appendChild(t2), document.body.appendChild(e2), { + close: /* @__PURE__ */ __name(() => { + e2 && e2.parentNode && e2.parentNode.removeChild(e2); + }, "close") + }; + }, (function() { + const e2 = /* @__PURE__ */ __name((e3, t2, n2, a2, i2) => { + let s2; + "object" == typeof n2 ? s2 = n2 : (s2 = "object" == typeof a2 ? a2 : i2 || {}, s2.gravity = n2 || "top", s2.position = "string" == typeof a2 ? a2 : "center"), s2.gravity && "center" !== s2.gravity || (s2.offset = { + y: "calc(50vh - 150px)" + }); + const o2 = "var(--jhs-status-fav)", r2 = "var(--jhs-status-fav-tint)", l2 = "var(--jhs-status-down)", c2 = "var(--jhs-status-down-tint)", d2 = "var(--jhs-status-filter)", h2 = "var(--jhs-status-filter-tint)", g2 = { + borderRadius: "12px", + padding: "12px 16px", + boxShadow: "0 4px 6px rgba(0,0,0,0.1)", + minWidth: "150px", + textAlign: "center", + zIndex: JHS_Z_INDEX.debug + }, p2 = { + text: e3, + duration: 2500, + close: false, + gravity: "top", + position: "center", + style: { + info: { + ...g2, + background: `${o2}`, + color: "var(--jhs-status-fav-on)" + }, + success: { + ...g2, + background: `${l2}`, + color: "var(--jhs-status-down-on)" + }, + error: { + ...g2, + background: `${d2}`, + color: "var(--jhs-status-filter-on)" + } + }[t2], + stopOnFocus: true, + oldestFirst: false, + ...s2 + }; + -1 === p2.duration && (p2.close = true); + const m2 = Toastify(p2); + return m2.showToast(), m2.closeShow = () => { + m2.toastElement.remove(); + }, m2; + }, "e"); + window.show = { + ok: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "success", n2, a2, i2), "ok"), + error: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "error", n2, a2, i2), "error"), + info: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "info", n2, a2, i2), "info") + }; + })(), (function() { + function e2(e3 = 10) { + setTimeout((() => { + const e4 = document.querySelectorAll(".layui-layer-shade").length; + document.documentElement.style.overflow = e4 > 0 ? "hidden" : ""; + }), e3); + } + __name(e2, "e"); + document.head.insertAdjacentHTML("beforeend", "\n \n "), window.showImageViewer = function(t2, n2 = "", options = {}) { + let a2 = null, i2 = false; + "string" == typeof t2 || t2 instanceof String ? (a2 = $('
').append(`${n2}`).appendTo("body"), i2 = true) : a2 = $(t2); + const galleryRoot = options.galleryRoot ? $(options.galleryRoot) : null, viewerHost = galleryRoot?.length ? galleryRoot : a2, selectedImage = "string" == typeof t2 || t2 instanceof String ? a2.find("img")[0] : a2[0], galleryImages = viewerHost.find("img").addBack("img").toArray(), initialViewIndex = Math.max(0, galleryImages.indexOf(selectedImage)), hasGallery = galleryImages.length > 1; + const s2 = { + zIndex: JHS_Z_INDEX.viewer, + navbar: false, + initialViewIndex, + zoomOnWheel: false, + zoomRatio: 0.1, + toggleOnDblclick: false, + toolbar: { + prev: hasGallery ? 1 : 0, + zoomIn: 1, + zoomOut: 1, + reset: 1, + next: hasGallery ? 1 : 0, + rotateLeft: 0, + rotateRight: 0, + flipHorizontal: 0, + flipVertical: 0 }, - success: { - ...g2, - background: `${l2}`, - color: "var(--jhs-status-down-on)" + title: false, + keyboard: false, + viewed() { + o2.zoomTo(1.4); + const x = (o2.viewerData.width - o2.imageData.width) / 2, y2 = (o2.viewerData.height - o2.imageData.height) / 2; + o2.moveTo(x, y2); }, - error: { - ...g2, - background: `${d2}`, - color: "var(--jhs-status-filter-on)" + shown() { + i2 && a2.remove(), document.documentElement.style.overflow = "hidden", document.body.style.overflow = "hidden", o2.handleKeydown = function(t3) { + if (hasGallery && "ArrowLeft" === t3.key) return t3.preventDefault(), t3.stopPropagation(), void o2.prev(); + if (hasGallery && "ArrowRight" === t3.key) return t3.preventDefault(), t3.stopPropagation(), void o2.next(); + "Escape" !== t3.key && " " !== t3.key || (t3.preventDefault(), t3.stopPropagation(), o2.destroy(), document.removeEventListener("keydown", o2.handleKeydown), document.documentElement.style.overflow = "", document.body.style.overflow = "", e2()); + }, scope.listen(document, "keydown", o2.handleKeydown); + }, + hidden() { + o2 && o2.handleKeydown && document.removeEventListener("keydown", o2.handleKeydown), o2.destroy(), document.documentElement.style.overflow = "", document.body.style.overflow = "", e2(); } - }[t2], - stopOnFocus: true, - oldestFirst: false, - ...s2 - }; - -1 === p2.duration && (p2.close = true); - const m2 = Toastify(p2); - return m2.showToast(), m2.closeShow = () => { - m2.toastElement.remove(); - }, m2; - }, "e"); - unsafeWindow.show = window.show = { - ok: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "success", n2, a2, i2), "ok"), - error: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "error", n2, a2, i2), "error"), - info: /* @__PURE__ */ __name((t2, n2 = "center", a2, i2) => e2(t2, "info", n2, a2, i2), "info") - }; - })(), (function() { - function e2(e3 = 10) { - setTimeout((() => { - const e4 = document.querySelectorAll(".layui-layer-shade").length; - document.documentElement.style.overflow = e4 > 0 ? "hidden" : ""; - }), e3); - } - __name(e2, "e"); - document.head.insertAdjacentHTML("beforeend", "\n \n "), window.showImageViewer = function(t2, n2 = "", options = {}) { - let a2 = null, i2 = false; - "string" == typeof t2 || t2 instanceof String ? (a2 = $('
').append(`${n2}`).appendTo("body"), i2 = true) : a2 = $(t2); - const galleryRoot = options.galleryRoot ? $(options.galleryRoot) : null, viewerHost = galleryRoot?.length ? galleryRoot : a2, selectedImage = "string" == typeof t2 || t2 instanceof String ? a2.find("img")[0] : a2[0], galleryImages = viewerHost.find("img").addBack("img").toArray(), initialViewIndex = Math.max(0, galleryImages.indexOf(selectedImage)), hasGallery = galleryImages.length > 1; - const s2 = { - zIndex: JHS_Z_INDEX.viewer, - navbar: false, - initialViewIndex, - zoomOnWheel: false, - zoomRatio: 0.1, - toggleOnDblclick: false, - toolbar: { - prev: hasGallery ? 1 : 0, - zoomIn: 1, - zoomOut: 1, - reset: 1, - next: hasGallery ? 1 : 0, - rotateLeft: 0, - rotateRight: 0, - flipHorizontal: 0, - flipVertical: 0 - }, - title: false, - keyboard: false, - viewed() { - o2.zoomTo(1.4); - const x = (o2.viewerData.width - o2.imageData.width) / 2, y2 = (o2.viewerData.height - o2.imageData.height) / 2; - o2.moveTo(x, y2); - }, - shown() { - i2 && a2.remove(), document.documentElement.style.overflow = "hidden", document.body.style.overflow = "hidden", o2.handleKeydown = function(t3) { - if (hasGallery && "ArrowLeft" === t3.key) return t3.preventDefault(), t3.stopPropagation(), void o2.prev(); - if (hasGallery && "ArrowRight" === t3.key) return t3.preventDefault(), t3.stopPropagation(), void o2.next(); - "Escape" !== t3.key && " " !== t3.key || (t3.preventDefault(), t3.stopPropagation(), o2.destroy(), document.removeEventListener("keydown", o2.handleKeydown), document.documentElement.style.overflow = "", document.body.style.overflow = "", e2()); - }, document.addEventListener("keydown", o2.handleKeydown); - }, - hidden() { - o2 && o2.handleKeydown && document.removeEventListener("keydown", o2.handleKeydown), o2.destroy(), document.documentElement.style.overflow = "", document.body.style.overflow = "", e2(); - } - }, o2 = new Viewer(viewerHost[0], s2); - o2.show(); - }; - })(), window.ImageHoverPreview = class { - constructor(config = {}) { - this.config = { - selector: ".hover-preview", - dataAttribute: "data-full", - maxWidth: 1e3, - maxHeight: 1e3, - offsetX: 20, - offsetY: 20, - zIndex: JHS_Z_INDEX.tooltip, - transition: 0.2, - hideDelay: 100, - loadedUrlLimit: 128, - autoAdjustPosition: true, - ...config + }, o2 = new Viewer(viewerHost[0], s2); + o2.show(); }; - this.config.loadedUrlLimit = Math.max(1, Number(this.config.loadedUrlLimit) || 128); - this.preview = null; - this.currentTarget = null; - this.timer = null; - this.imgElement = null; - this.pendingImage = null; - this.placement = null; - this.pointer = null; - this.animationFrame = null; - this.loadGeneration = 0; - this.destroyed = false; - this.currentUrl = null; - this.pendingUrl = null; - this.loadedUrls = /* @__PURE__ */ new Map(); - this.eventsBound = false; - this.onMouseEnter = (event) => this.handleMouseEnter(event); - this.onMouseLeave = (event) => this.handleMouseLeave(event); - this.onMouseMove = (event) => this.handleMouseMove(event); - this.onDocumentOver = (event) => this.handleDocumentOver(event); - this.onDocumentOut = (event) => this.handleDocumentOut(event); - this.onDocumentMove = (event) => this.handleDocumentMove(event); - this.init(); - } - init() { - if (utils.isMobileMode()) return; - this.injectStyles(), this.createPreviewElement(), this.bindEvents(); - } - injectStyles() { - if (document.getElementById("jhs-image-hover-preview-style")) return; - const style = document.createElement("style"); - style.id = "jhs-image-hover-preview-style"; - style.textContent = ` + })(), window.ImageHoverPreview = class { + constructor(config = {}) { + this.scope = new LifecycleScope(`ui:image-hover:${Date.now()}`); + this.config = { + selector: ".hover-preview", + dataAttribute: "data-full", + maxWidth: 1e3, + maxHeight: 1e3, + offsetX: 20, + offsetY: 20, + zIndex: JHS_Z_INDEX.tooltip, + transition: 0.2, + hideDelay: 100, + loadedUrlLimit: 128, + autoAdjustPosition: true, + ...config + }; + this.config.loadedUrlLimit = Math.max(1, Number(this.config.loadedUrlLimit) || 128); + this.preview = null; + this.currentTarget = null; + this.timer = null; + this.imgElement = null; + this.pendingImage = null; + this.placement = null; + this.pointer = null; + this.animationFrame = null; + this.loadGeneration = 0; + this.destroyed = false; + this.currentUrl = null; + this.pendingUrl = null; + this.loadedUrls = /* @__PURE__ */ new Map(); + this.eventsBound = false; + this.onMouseEnter = (event) => this.handleMouseEnter(event); + this.onMouseLeave = (event) => this.handleMouseLeave(event); + this.onMouseMove = (event) => this.handleMouseMove(event); + this.onDocumentOver = (event) => this.handleDocumentOver(event); + this.onDocumentOut = (event) => this.handleDocumentOut(event); + this.onDocumentMove = (event) => this.handleDocumentMove(event); + this.init(); + } + init() { + if (utils.isMobileMode()) return; + this.injectStyles(), this.createPreviewElement(), this.bindEvents(); + } + injectStyles() { + if (document.getElementById("jhs-image-hover-preview-style")) return; + const style = document.createElement("style"); + style.id = "jhs-image-hover-preview-style"; + style.textContent = ` .image-hover-preview { position: fixed; display: block; @@ -4152,166 +2819,166 @@ ${value}\r } .image-hover-preview__error { padding: var(--jhs-space-3); color: var(--jhs-danger); } `; - document.head.appendChild(style); - } - createPreviewElement() { - this.preview = document.createElement("div"); - this.preview.className = "image-hover-preview"; - this.preview.style.zIndex = String(this.config.zIndex); - this.preview.style.setProperty("--jhs-hover-transition", `${this.config.transition}s`); - document.body.appendChild(this.preview); - } - bindEvents() { - if (this.eventsBound || this.destroyed) return; - document.addEventListener("mouseover", this.onDocumentOver), document.addEventListener("mouseout", this.onDocumentOut), document.addEventListener("mousemove", this.onDocumentMove), this.eventsBound = true; - } - findTarget(event) { - const target = event.target; - return target?.closest ? target.closest(this.config.selector) : null; - } - handleDocumentOver(event) { - const target = this.findTarget(event); - target && (!event.relatedTarget || !target.contains(event.relatedTarget)) && this.handleMouseEnter(event, target); - } - handleDocumentOut(event) { - const target = this.findTarget(event); - target && (!event.relatedTarget || !target.contains(event.relatedTarget)) && this.handleMouseLeave(); - } - handleDocumentMove(event) { - if (!this.currentTarget) return; - (event.target === this.currentTarget || this.currentTarget.contains(event.target)) && this.handleMouseMove(event); - } - handleMouseEnter(event, delegatedTarget = event.currentTarget) { - if (this.destroyed || !this.preview) return; - clearTimeout(this.timer); - this.timer = null; - this.currentTarget = delegatedTarget; - this.pointer = { - x: event.clientX, - y: event.clientY - }; - this.placement = null; - const source = this.currentTarget.getAttribute(this.config.dataAttribute) || this.currentTarget.src; - if (!source) return; - if (source === this.currentUrl && this.imgElement) return this.showCurrentPreview(); - if (source === this.pendingUrl) return void this.preview.classList.add("active"); - const cached = this.loadedUrls.get(source); - if (cached) return this.loadedUrls.delete(source), this.loadedUrls.set(source, cached), void this.commitPreview(source, cached); - const generation = ++this.loadGeneration; - this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null); - this.pendingUrl = source; - this.currentUrl ? this.showCurrentPreview() : (this.preview.replaceChildren(), this.preview.classList.add("loading", "active")); - const image = new Image(); - this.pendingImage = image; - image.onload = () => { - if (this.destroyed || generation !== this.loadGeneration || !this.preview) return; - const { width, height } = this.calculateImageSize(image); - const cachedImage = { width, height }; - this.loadedUrls.set(source, cachedImage); - for (; this.loadedUrls.size > this.config.loadedUrlLimit; ) this.loadedUrls.delete(this.loadedUrls.keys().next().value); - this.pendingImage = null, this.pendingUrl = null, this.commitPreview(source, cachedImage); - }; - image.onerror = () => { - if (this.destroyed || generation !== this.loadGeneration || !this.preview) return; - this.pendingImage = null, this.pendingUrl = null; - if (this.currentUrl) return void this.preview.classList.add("active"); - const error = document.createElement("div"); - error.className = "image-hover-preview__error"; - error.textContent = "图片加载失败"; - this.preview.replaceChildren(error); - this.preview.classList.remove("loading"); - this.preview.classList.add("active"); - }; - image.src = source; - } - commitPreview(source, cached) { - if (!this.preview) return; - const previewImage = document.createElement("img"); - previewImage.src = source, previewImage.alt = "预览图", this.preview.replaceChildren(previewImage), this.imgElement = previewImage, this.currentUrl = source, this.preview.style.width = `${cached.width}px`, this.preview.style.height = `${cached.height}px`, this.preview.classList.remove("loading"), this.showCurrentPreview(); - } - showCurrentPreview() { - if (!this.preview || !this.pointer || !this.imgElement) return; - const width = this.preview.offsetWidth, height = this.preview.offsetHeight; - this.placement = this.choosePlacement(this.pointer.x, this.pointer.y, width, height), this.preview.classList.add("active"), this.schedulePosition(); - } - calculateImageSize(image) { - const viewportPadding = 8; - const availableWidth = Math.max(1, window.innerWidth - 2 * (viewportPadding + this.config.offsetX)); - const availableHeight = Math.max(1, window.innerHeight - 2 * (viewportPadding + this.config.offsetY)); - const maxWidth = Math.min(this.config.maxWidth, availableWidth); - const maxHeight = Math.min(this.config.maxHeight, availableHeight); - const scale = Math.min(1, maxWidth / image.naturalWidth, maxHeight / image.naturalHeight); - return { - width: Math.max(1, Math.round(image.naturalWidth * scale)), - height: Math.max(1, Math.round(image.naturalHeight * scale)) - }; - } - choosePlacement(x, y2, width, height) { - if (!this.config.autoAdjustPosition) return { - horizontal: "right", - vertical: "bottom" - }; - const rightSpace = window.innerWidth - x - this.config.offsetX; - const leftSpace = x - this.config.offsetX; - const bottomSpace = window.innerHeight - y2 - this.config.offsetY; - const topSpace = y2 - this.config.offsetY; - return { - horizontal: rightSpace >= width || rightSpace >= leftSpace ? "right" : "left", - vertical: bottomSpace >= height || bottomSpace >= topSpace ? "bottom" : "top" - }; - } - handleMouseMove(event) { - if (!this.currentTarget || !this.preview?.classList.contains("active")) return; - this.pointer = { - x: event.clientX, - y: event.clientY - }; - this.schedulePosition(); - } - schedulePosition() { - if (this.animationFrame || !this.pointer || !this.placement) return; - this.animationFrame = requestAnimationFrame((() => { + document.head.appendChild(style); + } + createPreviewElement() { + this.preview = document.createElement("div"); + this.preview.className = "image-hover-preview"; + this.preview.style.zIndex = String(this.config.zIndex); + this.preview.style.setProperty("--jhs-hover-transition", `${this.config.transition}s`); + document.body.appendChild(this.preview); + } + bindEvents() { + if (this.eventsBound || this.destroyed) return; + this.scope.listen(document, "mouseover", this.onDocumentOver), this.scope.listen(document, "mouseout", this.onDocumentOut), this.scope.listen(document, "mousemove", this.onDocumentMove), this.eventsBound = true; + } + findTarget(event) { + const target = event.target; + return target?.closest ? target.closest(this.config.selector) : null; + } + handleDocumentOver(event) { + const target = this.findTarget(event); + target && (!event.relatedTarget || !target.contains(event.relatedTarget)) && this.handleMouseEnter(event, target); + } + handleDocumentOut(event) { + const target = this.findTarget(event); + target && (!event.relatedTarget || !target.contains(event.relatedTarget)) && this.handleMouseLeave(); + } + handleDocumentMove(event) { + if (!this.currentTarget) return; + (event.target === this.currentTarget || this.currentTarget.contains(event.target)) && this.handleMouseMove(event); + } + handleMouseEnter(event, delegatedTarget = event.currentTarget) { + if (this.destroyed || !this.preview) return; + clearTimeout(this.timer); + this.timer = null; + this.currentTarget = delegatedTarget; + this.pointer = { + x: event.clientX, + y: event.clientY + }; + this.placement = null; + const source = this.currentTarget.getAttribute(this.config.dataAttribute) || this.currentTarget.src; + if (!source) return; + if (source === this.currentUrl && this.imgElement) return this.showCurrentPreview(); + if (source === this.pendingUrl) return void this.preview.classList.add("active"); + const cached = this.loadedUrls.get(source); + if (cached) return this.loadedUrls.delete(source), this.loadedUrls.set(source, cached), void this.commitPreview(source, cached); + const generation = ++this.loadGeneration; + this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null); + this.pendingUrl = source; + this.currentUrl ? this.showCurrentPreview() : (this.preview.replaceChildren(), this.preview.classList.add("loading", "active")); + const image = new Image(); + this.pendingImage = image; + image.onload = () => { + if (this.destroyed || generation !== this.loadGeneration || !this.preview) return; + const { width, height } = this.calculateImageSize(image); + const cachedImage = { width, height }; + this.loadedUrls.set(source, cachedImage); + for (; this.loadedUrls.size > this.config.loadedUrlLimit; ) this.loadedUrls.delete(this.loadedUrls.keys().next().value); + this.pendingImage = null, this.pendingUrl = null, this.commitPreview(source, cachedImage); + }; + image.onerror = () => { + if (this.destroyed || generation !== this.loadGeneration || !this.preview) return; + this.pendingImage = null, this.pendingUrl = null; + if (this.currentUrl) return void this.preview.classList.add("active"); + const error = document.createElement("div"); + error.className = "image-hover-preview__error"; + error.textContent = "图片加载失败"; + this.preview.replaceChildren(error); + this.preview.classList.remove("loading"); + this.preview.classList.add("active"); + }; + image.src = source; + } + commitPreview(source, cached) { + if (!this.preview) return; + const previewImage = document.createElement("img"); + previewImage.src = source, previewImage.alt = "预览图", this.preview.replaceChildren(previewImage), this.imgElement = previewImage, this.currentUrl = source, this.preview.style.width = `${cached.width}px`, this.preview.style.height = `${cached.height}px`, this.preview.classList.remove("loading"), this.showCurrentPreview(); + } + showCurrentPreview() { + if (!this.preview || !this.pointer || !this.imgElement) return; + const width = this.preview.offsetWidth, height = this.preview.offsetHeight; + this.placement = this.choosePlacement(this.pointer.x, this.pointer.y, width, height), this.preview.classList.add("active"), this.schedulePosition(); + } + calculateImageSize(image) { + const viewportPadding = 8; + const availableWidth = Math.max(1, window.innerWidth - 2 * (viewportPadding + this.config.offsetX)); + const availableHeight = Math.max(1, window.innerHeight - 2 * (viewportPadding + this.config.offsetY)); + const maxWidth = Math.min(this.config.maxWidth, availableWidth); + const maxHeight = Math.min(this.config.maxHeight, availableHeight); + const scale = Math.min(1, maxWidth / image.naturalWidth, maxHeight / image.naturalHeight); + return { + width: Math.max(1, Math.round(image.naturalWidth * scale)), + height: Math.max(1, Math.round(image.naturalHeight * scale)) + }; + } + choosePlacement(x, y2, width, height) { + if (!this.config.autoAdjustPosition) return { + horizontal: "right", + vertical: "bottom" + }; + const rightSpace = window.innerWidth - x - this.config.offsetX; + const leftSpace = x - this.config.offsetX; + const bottomSpace = window.innerHeight - y2 - this.config.offsetY; + const topSpace = y2 - this.config.offsetY; + return { + horizontal: rightSpace >= width || rightSpace >= leftSpace ? "right" : "left", + vertical: bottomSpace >= height || bottomSpace >= topSpace ? "bottom" : "top" + }; + } + handleMouseMove(event) { + if (!this.currentTarget || !this.preview?.classList.contains("active")) return; + this.pointer = { + x: event.clientX, + y: event.clientY + }; + this.schedulePosition(); + } + schedulePosition() { + if (this.animationFrame || !this.pointer || !this.placement) return; + this.animationFrame = requestAnimationFrame((() => { + this.animationFrame = null; + this.applyPosition(); + })); + } + applyPosition() { + if (!this.preview || !this.pointer || !this.placement) return; + const padding = 8; + const width = this.preview.offsetWidth; + const height = this.preview.offsetHeight; + let left = "right" === this.placement.horizontal ? this.pointer.x + this.config.offsetX : this.pointer.x - width - this.config.offsetX; + let top = "bottom" === this.placement.vertical ? this.pointer.y + this.config.offsetY : this.pointer.y - height - this.config.offsetY; + left = Math.min(Math.max(padding, left), Math.max(padding, window.innerWidth - width - padding)); + top = Math.min(Math.max(padding, top), Math.max(padding, window.innerHeight - height - padding)); + this.preview.style.left = `${left}px`; + this.preview.style.top = `${top}px`; + } + handleMouseLeave() { + if (!this.preview) return; + clearTimeout(this.timer), this.timer = setTimeout((() => this.hidePreview()), this.config.hideDelay); + } + hidePreview() { + if (!this.preview) return; + ++this.loadGeneration, this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null), this.pendingImage = null, this.pendingUrl = null, this.preview.classList.remove("active", "loading"), this.currentTarget = null, this.pointer = null, this.placement = null, this.timer = null; + } + destroy() { + if (this.destroyed) return; + this.destroyed = true; + ++this.loadGeneration; + clearTimeout(this.timer); + this.animationFrame && cancelAnimationFrame(this.animationFrame); this.animationFrame = null; - this.applyPosition(); - })); - } - applyPosition() { - if (!this.preview || !this.pointer || !this.placement) return; - const padding = 8; - const width = this.preview.offsetWidth; - const height = this.preview.offsetHeight; - let left = "right" === this.placement.horizontal ? this.pointer.x + this.config.offsetX : this.pointer.x - width - this.config.offsetX; - let top = "bottom" === this.placement.vertical ? this.pointer.y + this.config.offsetY : this.pointer.y - height - this.config.offsetY; - left = Math.min(Math.max(padding, left), Math.max(padding, window.innerWidth - width - padding)); - top = Math.min(Math.max(padding, top), Math.max(padding, window.innerHeight - height - padding)); - this.preview.style.left = `${left}px`; - this.preview.style.top = `${top}px`; - } - handleMouseLeave() { - if (!this.preview) return; - clearTimeout(this.timer), this.timer = setTimeout((() => this.hidePreview()), this.config.hideDelay); - } - hidePreview() { - if (!this.preview) return; - ++this.loadGeneration, this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null), this.pendingImage = null, this.pendingUrl = null, this.preview.classList.remove("active", "loading"), this.currentTarget = null, this.pointer = null, this.placement = null, this.timer = null; - } - destroy() { - if (this.destroyed) return; - this.destroyed = true; - ++this.loadGeneration; - clearTimeout(this.timer); - this.animationFrame && cancelAnimationFrame(this.animationFrame); - this.animationFrame = null; - this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null); - this.pendingImage = null; - this.eventsBound && (document.removeEventListener("mouseover", this.onDocumentOver), document.removeEventListener("mouseout", this.onDocumentOut), document.removeEventListener("mousemove", this.onDocumentMove), this.eventsBound = false); - this.loadedUrls.clear(); - this.preview?.remove(); - this.preview = null; - this.currentTarget = null; - } - }, (async function() { - document.head.insertAdjacentHTML("beforeend", ` + this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null); + this.pendingImage = null; + this.eventsBound && (this.scope.dispose(), this.eventsBound = false); + this.loadedUrls.clear(); + this.preview?.remove(); + this.preview = null; + this.currentTarget = null; + } + }, (async function() { + document.head.insertAdjacentHTML("beforeend", ` `); - document.head.insertAdjacentHTML("beforeend", ``); - const e2 = { - base: { - label: "信息", - background: "var(--jhs-status-fav-tint)", - borderLeftColor: "var(--jhs-accent)" - }, - warn: { - label: "警告", - background: "var(--jhs-status-watch-tint)", - borderLeftColor: "var(--jhs-status-watch)" - }, - error: { - label: "错误", - background: "var(--jhs-status-filter-tint)", - borderLeftColor: "var(--jhs-status-filter)" - }, - debug: { - label: "调试", - background: "var(--jhs-surface-2)", - borderLeftColor: "var(--jhs-border-strong)" - } - }, t2 = { - base: ["base", "warn", "error"], - warn: ["warn"], - error: ["error"], - debug: ["base", "warn", "error", "debug"] - }, n2 = await storageManager.getSetting("clogMsgCount", 2e3), a2 = "jhs_clog_maximize", i2 = "jhs_clog_expand", s2 = "jhs_clog_filter"; - const _o = class _o { - constructor() { - const t3 = localStorage.getItem(s2); - this.currentFilter = t3 && e2[t3] ? t3 : "base", this.logs = [], this.isInitialized = false, this.userScrolledUp = false; - } - tryInitialize() { - return "loading" !== document.readyState && (this.isInitialized || (this.init(), this.isInitialized = true), true); - } - init() { - this.createContainer(), this.bindEvents(), this.checkInitialMaximizeState(), this.checkInitialCollapseState(); - } - createContainer() { - this.container = document.createElement("div"), this.container.className = "console-logger-container jhs-ui", this.container.style.display = "none", this.toggleBtn = document.createElement("button"), this.toggleBtn.type = "button", this.toggleBtn.setAttribute("aria-label", "展开或收起运行日志"), this.toggleBtn.className = "console-logger-toggle collapsed", this.container.appendChild(this.toggleBtn), window.matchMedia?.("(max-width: 768px)").matches && (this.toggleBtn.hidden = true, this.toggleBtn.style.display = "none"), this.window = document.createElement("div"), this.window.className = "console-logger-window collapsed"; - const t3 = document.createElement("div"); - t3.className = "console-logger-header"; - const n3 = document.createElement("div"); - n3.className = "console-logger-title", n3.textContent = "JHS Console"; - const a3 = document.createElement("div"); - a3.className = "console-logger-controls", this.maximizeBtn = document.createElement("button"), this.maximizeBtn.textContent = "", this.maximizeBtn.classList.add("console-logger-maximize-toggle"), a3.appendChild(this.maximizeBtn); - const i3 = document.createElement("button"); - i3.textContent = "清空", i3.addEventListener("click", (() => this.clear())), a3.appendChild(i3), t3.appendChild(n3), t3.appendChild(a3), this.filtersContainer = document.createElement("div"), this.filtersContainer.className = "console-logger-filters", this.filterButtonGroup = document.createElement("div"), this.filterButtonGroup.className = "console-logger-filter-group", this.filtersContainer.appendChild(this.filterButtonGroup), this.scrollToBottomBtn = document.createElement("button"), this.scrollToBottomBtn.className = "console-logger-scroll-to-bottom", this.scrollToBottomBtn.textContent = "到底部", this.filtersContainer.appendChild(this.scrollToBottomBtn), this.content = document.createElement("div"), this.content.className = "console-logger-content jhs-scrollbar", this.window.appendChild(t3), this.window.appendChild(this.filtersContainer), this.window.appendChild(this.content), this.container.appendChild(this.window), document.body.appendChild(this.container), Object.keys(e2).forEach(((t4) => { - const n4 = document.createElement("button"); - n4.type = "button", n4.className = "jhs-btn console-logger-filter", t4 === this.currentFilter && n4.classList.add("active"), n4.textContent = e2[t4].label, n4.dataset.type = t4, n4.addEventListener("click", (() => this.setFilter(t4))), this.filterButtonGroup.appendChild(n4); - })); - } - bindEvents() { - this.toggleBtn.addEventListener("click", (() => { - this.toggleExpandCollapsed(); - })), this.maximizeBtn.addEventListener("click", (() => this.toggleMaximize())), this.scrollToBottomBtn.addEventListener("click", (() => { - this.content.scrollTop = this.content.scrollHeight, this.userScrolledUp = false; - })), this.content.addEventListener("scroll", (() => { - const e3 = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 5; - this.userScrolledUp = !e3; - })), this.content.addEventListener("wheel", ((e3) => { - const t3 = 0 === this.content.scrollTop, n3 = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 1; - (t3 && e3.deltaY < 0 || n3 && e3.deltaY > 0) && (e3.preventDefault(), e3.stopPropagation()); - }), { - passive: false - }); - } - toggleExpandCollapsed() { - const e3 = this.window.classList.toggle("collapsed"); - this.toggleBtn.classList.toggle("collapsed"), e3 ? localStorage.setItem(i2, "no") : (localStorage.setItem(i2, "yes"), this.reRenderAllLogs()); - } - checkInitialCollapseState() { - const e3 = localStorage.getItem(i2); - e3 && "no" !== e3 ? (this.window.classList.toggle("collapsed"), this.toggleBtn.classList.toggle("collapsed"), setTimeout((() => { - this.content.scrollTop = this.content.scrollHeight; - }), 0)) : (this.window.classList.add("collapsed"), this.toggleBtn.classList.add("collapsed")); - } - checkInitialMaximizeState() { - "maximized" === localStorage.getItem(a2) && (this.window.classList.add("maximized"), this.maximizeBtn.classList.add("active")); - } - toggleMaximize() { - const e3 = this.window.classList.toggle("maximized"); - this.maximizeBtn.classList.toggle("active", e3), e3 ? localStorage.setItem(a2, "maximized") : localStorage.setItem(a2, "minimized"), this.window.classList.contains("collapsed") || (this.content.scrollTop = this.content.scrollHeight); - } - addLog(t3, a3 = "base", ...i3) { - const s3 = this.tryInitialize(); - let o3, r2 = []; - e2[a3] ? (o3 = a3, r2 = i3) : (o3 = "base", r2 = [a3, ...i3]), o3 = e2[o3] ? o3 : "base"; - const l2 = [t3, ...r2]; - let c2 = "msg"; - const d2 = []; - l2.forEach(((e3) => { - if ("[object Error]" === Object.prototype.toString.call(e3)) d2.push(String(e3)); - else if ("object" == typeof e3 && null !== e3) try { - d2.push("
" + JSON.stringify(e3, null, 2)), c2 = "json"; - } catch (t4) { - d2.push(String(e3)), c2 = "msg"; - } - else d2.push(String(e3)); - })); - let h2 = d2.join(" "); - h2 = h2.replace(/(?:(?:https?|ftp):\/\/|www\.|(?:\/\/))[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/gi, ((e3) => { - const t4 = e3.startsWith("http") || e3.startsWith("ftp"), n3 = e3.startsWith("//"), a4 = e3.startsWith("www."); - let i4 = e3; - return n3 ? i4 = `http:${e3}` : !t4 && a4 && (i4 = `http://${e3}`), `${escapeHtml(e3)}`; - })); - const g2 = { - message: h2, - messageType: c2, - type: o3, - timestamp: /* @__PURE__ */ new Date(), - id: Date.now() + Math.random() - }; - if (this.logs.push(g2), this.logs.length > n2) { - const e3 = this.logs[0]; - if (s3) { - const t4 = this.content.querySelector(`.console-logger-entry[data-id="${e3.id}"]`); - t4 && (this.logs.shift(), this.content.removeChild(t4)); - } + const e2 = { + base: { + label: "信息", + background: "var(--jhs-status-fav-tint)", + borderLeftColor: "var(--jhs-accent)" + }, + warn: { + label: "警告", + background: "var(--jhs-status-watch-tint)", + borderLeftColor: "var(--jhs-status-watch)" + }, + error: { + label: "错误", + background: "var(--jhs-status-filter-tint)", + borderLeftColor: "var(--jhs-status-filter)" + }, + debug: { + label: "调试", + background: "var(--jhs-surface-2)", + borderLeftColor: "var(--jhs-border-strong)" } - s3 && this.renderLog(g2); - } - log(...e3) { - const [t3, ...n3] = e3; - setTimeout((() => { - this.addLog(t3, "base", ...n3); - }), 0); - } - error(...e3) { - const [t3, ...n3] = e3; - console.error(...e3), setTimeout((() => { - this.addLog(t3, "error", ...n3); - }), 0); - } - warn(...e3) { - const [t3, ...n3] = e3; - setTimeout((() => { - this.addLog(t3, "warn", ...n3); - }), 0); - } - debug(...e3) { - const [t3, ...n3] = e3; - setTimeout((() => { - this.addLog(t3, "debug", ...n3); - }), 0); - } - renderLog(e3) { - if ("none" === this.container.style.display) return; - if (this.window.classList.contains("collapsed")) return; - if (!(t2[this.currentFilter] || []).includes(e3.type)) return; - const n3 = this._createLogElement(e3); - this.content.appendChild(n3), this.window.classList.contains("collapsed") || this.userScrolledUp || (this.content.scrollTop = this.content.scrollHeight); - } - reRenderAllLogs() { - "none" !== this.container.style.display && (this.window.classList.contains("collapsed") || setTimeout((() => { - if (this.content.innerHTML = "", 0 === this.logs.length) return; - const e3 = t2[this.currentFilter] || [], n3 = document.createDocumentFragment(); - this.logs.forEach(((t3) => { - if (e3.includes(t3.type)) { - const e4 = this._createLogElement(t3); - n3.appendChild(e4); + }, t2 = { + base: ["base", "warn", "error"], + warn: ["warn"], + error: ["error"], + debug: ["base", "warn", "error", "debug"] + }, n2 = await storageManager.getSetting("clogMsgCount", 2e3), a2 = "jhs_clog_maximize", i2 = "jhs_clog_expand", s2 = "jhs_clog_filter"; + const _o = class _o { + constructor() { + const t3 = localStorage.getItem(s2); + this.currentFilter = t3 && e2[t3] ? t3 : "base", this.logs = [], this.isInitialized = false, this.userScrolledUp = false; + } + tryInitialize() { + return "loading" !== document.readyState && (this.isInitialized || (this.init(), this.isInitialized = true), true); + } + init() { + this.createContainer(), this.bindEvents(), this.checkInitialMaximizeState(), this.checkInitialCollapseState(); + } + createContainer() { + this.container = document.createElement("div"), this.container.className = "console-logger-container jhs-ui", this.container.style.display = "none", this.toggleBtn = document.createElement("button"), this.toggleBtn.type = "button", this.toggleBtn.setAttribute("aria-label", "展开或收起运行日志"), this.toggleBtn.className = "console-logger-toggle collapsed", this.container.appendChild(this.toggleBtn), window.matchMedia?.("(max-width: 768px)").matches && (this.toggleBtn.hidden = true, this.toggleBtn.style.display = "none"), this.window = document.createElement("div"), this.window.className = "console-logger-window collapsed"; + const t3 = document.createElement("div"); + t3.className = "console-logger-header"; + const n3 = document.createElement("div"); + n3.className = "console-logger-title", n3.textContent = "JHS Console"; + const a3 = document.createElement("div"); + a3.className = "console-logger-controls", this.maximizeBtn = document.createElement("button"), this.maximizeBtn.textContent = "", this.maximizeBtn.classList.add("console-logger-maximize-toggle"), a3.appendChild(this.maximizeBtn); + const i3 = document.createElement("button"); + i3.textContent = "清空", i3.addEventListener("click", (() => this.clear())), a3.appendChild(i3), t3.appendChild(n3), t3.appendChild(a3), this.filtersContainer = document.createElement("div"), this.filtersContainer.className = "console-logger-filters", this.filterButtonGroup = document.createElement("div"), this.filterButtonGroup.className = "console-logger-filter-group", this.filtersContainer.appendChild(this.filterButtonGroup), this.scrollToBottomBtn = document.createElement("button"), this.scrollToBottomBtn.className = "console-logger-scroll-to-bottom", this.scrollToBottomBtn.textContent = "到底部", this.filtersContainer.appendChild(this.scrollToBottomBtn), this.content = document.createElement("div"), this.content.className = "console-logger-content jhs-scrollbar", this.window.appendChild(t3), this.window.appendChild(this.filtersContainer), this.window.appendChild(this.content), this.container.appendChild(this.window), document.body.appendChild(this.container), Object.keys(e2).forEach(((t4) => { + const n4 = document.createElement("button"); + n4.type = "button", n4.className = "jhs-btn console-logger-filter", t4 === this.currentFilter && n4.classList.add("active"), n4.textContent = e2[t4].label, n4.dataset.type = t4, n4.addEventListener("click", (() => this.setFilter(t4))), this.filterButtonGroup.appendChild(n4); + })); + } + bindEvents() { + this.toggleBtn.addEventListener("click", (() => { + this.toggleExpandCollapsed(); + })), this.maximizeBtn.addEventListener("click", (() => this.toggleMaximize())), this.scrollToBottomBtn.addEventListener("click", (() => { + this.content.scrollTop = this.content.scrollHeight, this.userScrolledUp = false; + })), this.content.addEventListener("scroll", (() => { + const e3 = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 5; + this.userScrolledUp = !e3; + })), this.content.addEventListener("wheel", ((e3) => { + const t3 = 0 === this.content.scrollTop, n3 = this.content.scrollHeight - this.content.clientHeight <= this.content.scrollTop + 1; + (t3 && e3.deltaY < 0 || n3 && e3.deltaY > 0) && (e3.preventDefault(), e3.stopPropagation()); + }), { + passive: false + }); + } + toggleExpandCollapsed() { + const e3 = this.window.classList.toggle("collapsed"); + this.toggleBtn.classList.toggle("collapsed"), e3 ? localStorage.setItem(i2, "no") : (localStorage.setItem(i2, "yes"), this.reRenderAllLogs()); + } + checkInitialCollapseState() { + const e3 = localStorage.getItem(i2); + e3 && "no" !== e3 ? (this.window.classList.toggle("collapsed"), this.toggleBtn.classList.toggle("collapsed"), setTimeout((() => { + this.content.scrollTop = this.content.scrollHeight; + }), 0)) : (this.window.classList.add("collapsed"), this.toggleBtn.classList.add("collapsed")); + } + checkInitialMaximizeState() { + "maximized" === localStorage.getItem(a2) && (this.window.classList.add("maximized"), this.maximizeBtn.classList.add("active")); + } + toggleMaximize() { + const e3 = this.window.classList.toggle("maximized"); + this.maximizeBtn.classList.toggle("active", e3), e3 ? localStorage.setItem(a2, "maximized") : localStorage.setItem(a2, "minimized"), this.window.classList.contains("collapsed") || (this.content.scrollTop = this.content.scrollHeight); + } + addLog(t3, a3 = "base", ...i3) { + const s3 = this.tryInitialize(); + let o3, r2 = []; + e2[a3] ? (o3 = a3, r2 = i3) : (o3 = "base", r2 = [a3, ...i3]), o3 = e2[o3] ? o3 : "base"; + const l2 = [t3, ...r2]; + let c2 = "msg"; + const d2 = []; + l2.forEach(((e3) => { + if ("[object Error]" === Object.prototype.toString.call(e3)) d2.push(String(e3)); + else if ("object" == typeof e3 && null !== e3) try { + d2.push("
" + JSON.stringify(e3, null, 2)), c2 = "json"; + } catch (t4) { + d2.push(String(e3)), c2 = "msg"; + } + else d2.push(String(e3)); + })); + let h2 = d2.join(" "); + h2 = h2.replace(/(?:(?:https?|ftp):\/\/|www\.|(?:\/\/))[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|]/gi, ((e3) => { + const t4 = e3.startsWith("http") || e3.startsWith("ftp"), n3 = e3.startsWith("//"), a4 = e3.startsWith("www."); + let i4 = e3; + return n3 ? i4 = `http:${e3}` : !t4 && a4 && (i4 = `http://${e3}`), `${escapeHtml(e3)}`; + })); + const g2 = { + message: h2, + messageType: c2, + type: o3, + timestamp: /* @__PURE__ */ new Date(), + id: Date.now() + Math.random() + }; + if (this.logs.push(g2), this.logs.length > n2) { + const e3 = this.logs[0]; + if (s3) { + const t4 = this.content.querySelector(`.console-logger-entry[data-id="${e3.id}"]`); + t4 && (this.logs.shift(), this.content.removeChild(t4)); } - })), this.content.appendChild(n3), this.content.scrollTop = this.content.scrollHeight; - }), 0)); - } - _createLogElement(t3) { - const n3 = document.createElement("div"); - n3.className = "console-logger-entry", n3.dataset.type = t3.type, n3.dataset.id = t3.id; - const a3 = e2[t3.type] || e2.base; - n3.style.borderLeft = "3px solid " + a3.borderLeftColor, n3.style.background = a3.background; - const i3 = (t3.timestamp instanceof Date ? t3.timestamp : new Date(t3.timestamp)).toTimeString().split(" ")[0]; - return n3.innerHTML = ` + } + s3 && this.renderLog(g2); + } + log(...e3) { + const [t3, ...n3] = e3; + setTimeout((() => { + this.addLog(t3, "base", ...n3); + }), 0); + } + error(...e3) { + const [t3, ...n3] = e3; + console.error(...e3), setTimeout((() => { + this.addLog(t3, "error", ...n3); + }), 0); + } + warn(...e3) { + const [t3, ...n3] = e3; + setTimeout((() => { + this.addLog(t3, "warn", ...n3); + }), 0); + } + debug(...e3) { + const [t3, ...n3] = e3; + setTimeout((() => { + this.addLog(t3, "debug", ...n3); + }), 0); + } + renderLog(e3) { + if ("none" === this.container.style.display) return; + if (this.window.classList.contains("collapsed")) return; + if (!(t2[this.currentFilter] || []).includes(e3.type)) return; + const n3 = this._createLogElement(e3); + this.content.appendChild(n3), this.window.classList.contains("collapsed") || this.userScrolledUp || (this.content.scrollTop = this.content.scrollHeight); + } + reRenderAllLogs() { + "none" !== this.container.style.display && (this.window.classList.contains("collapsed") || setTimeout((() => { + if (this.content.innerHTML = "", 0 === this.logs.length) return; + const e3 = t2[this.currentFilter] || [], n3 = document.createDocumentFragment(); + this.logs.forEach(((t3) => { + if (e3.includes(t3.type)) { + const e4 = this._createLogElement(t3); + n3.appendChild(e4); + } + })), this.content.appendChild(n3), this.content.scrollTop = this.content.scrollHeight; + }), 0)); + } + _createLogElement(t3) { + const n3 = document.createElement("div"); + n3.className = "console-logger-entry", n3.dataset.type = t3.type, n3.dataset.id = t3.id; + const a3 = e2[t3.type] || e2.base; + n3.style.borderLeft = "3px solid " + a3.borderLeftColor, n3.style.background = a3.background; + const i3 = (t3.timestamp instanceof Date ? t3.timestamp : new Date(t3.timestamp)).toTimeString().split(" ")[0]; + return n3.innerHTML = ` [${i3}] ${t3.message} `, n3; + } + setFilter(e3) { + if (this.currentFilter === e3) return; + this.currentFilter = e3, localStorage.setItem(s2, e3); + this.filterButtonGroup.querySelectorAll(".console-logger-filter").forEach(((t3) => { + t3.dataset.type === e3 ? t3.classList.add("active") : t3.classList.remove("active"); + })), this.reRenderAllLogs(); + } + clear() { + this.logs = [], this.content.innerHTML = ""; + } + openDialog() { + if (!this.tryInitialize() || !this.window || "undefined" == typeof layer) return; + const host = document.createElement("div"), originalParent = this.window.parentNode, originalStyle = this.window.getAttribute("style"); + host.className = "jhs-ui jhs-logger-dialog", host.style.height = "100%", this.window.classList.remove("collapsed", "maximized"), Object.assign(this.window.style, { width: "100%", height: "100%", border: "0", borderRadius: "0", boxShadow: "none" }), this.reRenderAllLogs(); + layer.open({ + type: 1, + title: "JHS 运行日志", + content: host.outerHTML, + area: utils.getDialogArea("md"), + anim: -1, + success: /* @__PURE__ */ __name((layerElement) => { + $(layerElement).find(".jhs-logger-dialog")[0]?.appendChild(this.window), this.content.scrollTop = this.content.scrollHeight; + }, "success"), + end: /* @__PURE__ */ __name(() => { + originalParent?.appendChild(this.window), null == originalStyle ? this.window.removeAttribute("style") : this.window.setAttribute("style", originalStyle), this.window.classList.add("collapsed"), this.toggleBtn.classList.add("collapsed"); + }, "end") + }); + } + show() { + (this.isInitialized && this.container || this.tryInitialize() && this.container) && (this.container.style.display = "", this.reRenderAllLogs()); + } + hide() { + this.isInitialized && this.container && (this.container.style.display = "none"); + } + lowZIndex() { + this.isInitialized && this.container && (this.container.style.zIndex = String(JHS_Z_INDEX.debugLow)); + } + highZIndex() { + this.isInitialized && this.container && (this.container.style.zIndex = String(JHS_Z_INDEX.debug)); + } + }; + __name(_o, "o"); + let o2 = _o; + try { + unsafeWindow.parent !== unsafeWindow && unsafeWindow.parent.clog && "function" == typeof unsafeWindow.parent.clog.log ? window.clog = unsafeWindow.parent.clog : window.clog = new o2(); + } catch (r2) { + console.error("创建日志控制台出现异常", r2), window.clog = new o2(); } - setFilter(e3) { - if (this.currentFilter === e3) return; - this.currentFilter = e3, localStorage.setItem(s2, e3); - this.filterButtonGroup.querySelectorAll(".console-logger-filter").forEach(((t3) => { - t3.dataset.type === e3 ? t3.classList.add("active") : t3.classList.remove("active"); - })), this.reRenderAllLogs(); - } - clear() { - this.logs = [], this.content.innerHTML = ""; - } - openDialog() { - if (!this.tryInitialize() || !this.window || "undefined" == typeof layer) return; - const host = document.createElement("div"), originalParent = this.window.parentNode, originalStyle = this.window.getAttribute("style"); - host.className = "jhs-ui jhs-logger-dialog", host.style.height = "100%", this.window.classList.remove("collapsed", "maximized"), Object.assign(this.window.style, { width: "100%", height: "100%", border: "0", borderRadius: "0", boxShadow: "none" }), this.reRenderAllLogs(); - layer.open({ - type: 1, - title: "JHS 运行日志", - content: host.outerHTML, - area: utils.getDialogArea("md"), - anim: -1, - success: /* @__PURE__ */ __name((layerElement) => { - $(layerElement).find(".jhs-logger-dialog")[0]?.appendChild(this.window), this.content.scrollTop = this.content.scrollHeight; - }, "success"), - end: /* @__PURE__ */ __name(() => { - originalParent?.appendChild(this.window), null == originalStyle ? this.window.removeAttribute("style") : this.window.setAttribute("style", originalStyle), this.window.classList.add("collapsed"), this.toggleBtn.classList.add("collapsed"); - }, "end") - }); - } - show() { - (this.isInitialized && this.container || this.tryInitialize() && this.container) && (this.container.style.display = "", this.reRenderAllLogs()); - } - hide() { - this.isInitialized && this.container && (this.container.style.display = "none"); - } - lowZIndex() { - this.isInitialized && this.container && (this.container.style.zIndex = String(JHS_Z_INDEX.debugLow)); - } - highZIndex() { - this.isInitialized && this.container && (this.container.style.zIndex = String(JHS_Z_INDEX.debug)); + !(function() { + const e3 = window.clog || console; + scope.listen(window, "error", (function(t3) { + const n3 = t3.filename, a3 = t3.message; + n3.includes("javdb") || n3.includes("javbus") || e3.error(`[全局 Error 异常捕获] ${a3} 来源: ${n3}`); + })), scope.listen(window, "unhandledrejection", (function(t3) { + const n3 = t3.reason, a3 = (null == n3 ? void 0 : n3.message) ?? ""; + if (["NotAllowedError", "AbortError", "NotSupportedError"].includes(n3?.name) || a3.includes("play()") || a3.includes("The element has no supported sources")) return e3.warn("[全局媒体播放异常] 当前媒体源无法播放", n3), void t3.preventDefault(); + if (a3.includes("1005") && a3.includes("fc2ppvdb")) return; + const i3 = `[全局 Promise 异常捕获] ${n3.message || n3}`; + e3.error(i3, n3), t3.preventDefault(); + })); + })(), scope.listen(document, "mousedown", ((e3) => { + const t3 = window.clog; + if (!t3.isInitialized || !t3.container) return; + const n3 = e3.target, a3 = [".console-logger-container", ".layui-layer-shade", ".loading-container"].join(","); + n3.closest(a3) ? t3.highZIndex() : t3.lowZIndex(); + })); + })(), (function() { + function e2(e3, t3, n2) { + const a2 = (function(e4) { + const t4 = document.createElement("div"); + t4.classList.add("js-tooltip"); + const n3 = document.createElement("div"); + return n3.innerHTML = escapeHtml(e4), t4.appendChild(n3), document.body.appendChild(t4), t4; + })(t3); + a2.style.display = "block"; + const i2 = e3.getBoundingClientRect(), s2 = a2.getBoundingClientRect(); + a2.style.display = "none"; + const o2 = window.innerWidth, r2 = window.innerHeight; + let l2, c2, d2 = n2; + const h2 = /* @__PURE__ */ __name((e4) => e4 >= 8 && e4 + s2.height <= r2 - 8, "h"), g2 = /* @__PURE__ */ __name((e4) => e4 >= 8 && e4 + s2.width <= o2 - 8, "g"), p2 = i2.left + i2.width / 2 - s2.width / 2, m2 = i2.top + i2.height / 2 - s2.height / 2; + switch (n2) { + case "top": + c2 = i2.top - s2.height - 0, c2 < 8 && h2(i2.bottom + 0) && (c2 = i2.bottom + 0, d2 = "bottom"); + break; + case "bottom": + c2 = i2.bottom + 0, c2 + s2.height > r2 - 8 && h2(i2.top - s2.height - 0) && (c2 = i2.top - s2.height - 0, d2 = "top"); + break; + case "left": + l2 = i2.left - s2.width - 0, l2 < 8 && g2(i2.right + 0) && (l2 = i2.right + 0, d2 = "right"); + break; + case "right": + l2 = i2.right + 0, l2 + s2.width > o2 - 8 && g2(i2.left - s2.width - 0) && (l2 = i2.left - s2.width - 0, d2 = "left"); + } + const u2 = "left" === d2 || "right" === d2; + "top" === d2 || "bottom" === d2 ? (l2 = p2, l2 < 8 ? l2 = 8 : l2 + s2.width > o2 - 8 && (l2 = o2 - s2.width - 8)) : u2 && (c2 = m2, c2 < 8 ? c2 = 8 : c2 + s2.height > r2 - 8 && (c2 = r2 - s2.height - 8)), a2.style.left = `${l2}px`, a2.style.top = `${c2}px`, a2.classList.add("is-active"), e3.tooltipElement = a2; } + __name(e2, "e"); + document.head.insertAdjacentHTML("beforeend", "\n \n "); + const t2 = "[data-tip-top], [data-tip-bottom], [data-tip-left], [data-tip-right], [data-tip]"; + scope.listen(document, "mouseover", ((n2) => { + const a2 = n2.target.closest(t2); + if (a2 && !a2.tooltipElement) { + let t3, n3 = "top"; + if (a2.hasAttribute("data-tip-bottom") ? (t3 = a2.getAttribute("data-tip-bottom"), n3 = "bottom") : a2.hasAttribute("data-tip-left") ? (t3 = a2.getAttribute("data-tip-left"), n3 = "left") : a2.hasAttribute("data-tip-right") ? (t3 = a2.getAttribute("data-tip-right"), n3 = "right") : a2.hasAttribute("data-tip-top") ? (t3 = a2.getAttribute("data-tip-top"), n3 = "top") : a2.hasAttribute("data-tip") && (t3 = a2.getAttribute("data-tip"), n3 = "top"), !t3) return; + a2.hoverTimeout = setTimeout((() => { + a2.matches(":hover") && !a2.tooltipElement && e2(a2, t3, n3); + }), 50); + } + })), scope.listen(document, "mouseout", ((e3) => { + const n2 = e3.target.closest(t2); + var a2; + n2 && (n2.hoverTimeout && (clearTimeout(n2.hoverTimeout), n2.hoverTimeout = null), n2.contains(e3.relatedTarget) || n2.tooltipElement && ((a2 = n2.tooltipElement) && a2.parentNode && a2.remove(), n2.tooltipElement = null)); + })); + })(); + loggerRuntime = Object.freeze({ loading: window.loading, show: window.show, clog: window.clog }); + return loggerRuntime; + } + __name(initializeLoggerRuntime, "initializeLoggerRuntime"); + + // src/platform/userscript/vendor-runtime.js + function getVendorRuntime(runtimeWindow = ( + /** @type {any} */ + window + )) { + const values = { + $: runtimeWindow.jQuery, + Tabulator: runtimeWindow.Tabulator, + layer: runtimeWindow.layer, + md5: runtimeWindow.md5, + Toastify: runtimeWindow.Toastify, + localforage: runtimeWindow.localforage, + Viewer: runtimeWindow.Viewer }; - __name(_o, "o"); - let o2 = _o; - try { - unsafeWindow.parent !== unsafeWindow && unsafeWindow.parent.clog && "function" == typeof unsafeWindow.parent.clog.log ? window.clog = unsafeWindow.clog = unsafeWindow.parent.clog : window.clog = unsafeWindow.clog = new o2(); - } catch (r2) { - console.error("创建日志控制台出现异常", r2), window.clog = unsafeWindow.clog = new o2(); - } - !(function() { - const e3 = window.clog || console; - window.addEventListener("error", (function(t3) { - const n3 = t3.filename, a3 = t3.message; - n3.includes("javdb") || n3.includes("javbus") || e3.error(`[全局 Error 异常捕获] ${a3} 来源: ${n3}`); - })), window.addEventListener("unhandledrejection", (function(t3) { - const n3 = t3.reason, a3 = (null == n3 ? void 0 : n3.message) ?? ""; - if (["NotAllowedError", "AbortError", "NotSupportedError"].includes(n3?.name) || a3.includes("play()") || a3.includes("The element has no supported sources")) return e3.warn("[全局媒体播放异常] 当前媒体源无法播放", n3), void t3.preventDefault(); - if (a3.includes("1005") && a3.includes("fc2ppvdb")) return; - const i3 = `[全局 Promise 异常捕获] ${n3.message || n3}`; - e3.error(i3, n3), t3.preventDefault(); - })); - })(), document.addEventListener("mousedown", ((e3) => { - const t3 = window.clog; - if (!t3.isInitialized || !t3.container) return; - const n3 = e3.target, a3 = [".console-logger-container", ".layui-layer-shade", ".loading-container"].join(","); - n3.closest(a3) ? t3.highZIndex() : t3.lowZIndex(); - })); - })(), (function() { - function e2(e3, t3, n2) { - const a2 = (function(e4) { - const t4 = document.createElement("div"); - t4.classList.add("js-tooltip"); - const n3 = document.createElement("div"); - return n3.innerHTML = escapeHtml(e4), t4.appendChild(n3), document.body.appendChild(t4), t4; - })(t3); - a2.style.display = "block"; - const i2 = e3.getBoundingClientRect(), s2 = a2.getBoundingClientRect(); - a2.style.display = "none"; - const o2 = window.innerWidth, r2 = window.innerHeight; - let l2, c2, d2 = n2; - const h2 = /* @__PURE__ */ __name((e4) => e4 >= 8 && e4 + s2.height <= r2 - 8, "h"), g2 = /* @__PURE__ */ __name((e4) => e4 >= 8 && e4 + s2.width <= o2 - 8, "g"), p2 = i2.left + i2.width / 2 - s2.width / 2, m2 = i2.top + i2.height / 2 - s2.height / 2; - switch (n2) { - case "top": - c2 = i2.top - s2.height - 0, c2 < 8 && h2(i2.bottom + 0) && (c2 = i2.bottom + 0, d2 = "bottom"); - break; - case "bottom": - c2 = i2.bottom + 0, c2 + s2.height > r2 - 8 && h2(i2.top - s2.height - 0) && (c2 = i2.top - s2.height - 0, d2 = "top"); - break; - case "left": - l2 = i2.left - s2.width - 0, l2 < 8 && g2(i2.right + 0) && (l2 = i2.right + 0, d2 = "right"); - break; - case "right": - l2 = i2.right + 0, l2 + s2.width > o2 - 8 && g2(i2.left - s2.width - 0) && (l2 = i2.left - s2.width - 0, d2 = "left"); - } - const u2 = "left" === d2 || "right" === d2; - "top" === d2 || "bottom" === d2 ? (l2 = p2, l2 < 8 ? l2 = 8 : l2 + s2.width > o2 - 8 && (l2 = o2 - s2.width - 8)) : u2 && (c2 = m2, c2 < 8 ? c2 = 8 : c2 + s2.height > r2 - 8 && (c2 = r2 - s2.height - 8)), a2.style.left = `${l2}px`, a2.style.top = `${c2}px`, a2.classList.add("is-active"), e3.tooltipElement = a2; - } - __name(e2, "e"); - document.head.insertAdjacentHTML("beforeend", "\n \n "); - const t2 = "[data-tip-top], [data-tip-bottom], [data-tip-left], [data-tip-right], [data-tip]"; - document.addEventListener("mouseover", ((n2) => { - const a2 = n2.target.closest(t2); - if (a2 && !a2.tooltipElement) { - let t3, n3 = "top"; - if (a2.hasAttribute("data-tip-bottom") ? (t3 = a2.getAttribute("data-tip-bottom"), n3 = "bottom") : a2.hasAttribute("data-tip-left") ? (t3 = a2.getAttribute("data-tip-left"), n3 = "left") : a2.hasAttribute("data-tip-right") ? (t3 = a2.getAttribute("data-tip-right"), n3 = "right") : a2.hasAttribute("data-tip-top") ? (t3 = a2.getAttribute("data-tip-top"), n3 = "top") : a2.hasAttribute("data-tip") && (t3 = a2.getAttribute("data-tip"), n3 = "top"), !t3) return; - a2.hoverTimeout = setTimeout((() => { - a2.matches(":hover") && !a2.tooltipElement && e2(a2, t3, n3); - }), 50); - } - })), document.addEventListener("mouseout", ((e3) => { - const n2 = e3.target.closest(t2); - var a2; - n2 && (n2.hoverTimeout && (clearTimeout(n2.hoverTimeout), n2.hoverTimeout = null), n2.contains(e3.relatedTarget) || n2.tooltipElement && ((a2 = n2.tooltipElement) && a2.parentNode && a2.remove(), n2.tooltipElement = null)); - })); - })(); - var _PluginManager = class _PluginManager { - constructor() { - this.plugins = /* @__PURE__ */ new Map(); - this._errorLog = []; - this._lastTimings = []; - this._startedAt = performance.now(); - this._registrationMs = 0; - this._cssMs = 0; - this._immediateMs = 0; - this._readyMs = 0; - this._idlePending = 0; - this._idleCompleted = 0; - this._disabledPluginsPromise = null; + const missing = Object.entries(values).filter(([, value]) => value == null).map(([name]) => name); + if (missing.length) throw new Error(`Missing userscript vendor runtime: ${missing.join(", ")}`); + return Object.freeze(values); + } + __name(getVendorRuntime, "getVendorRuntime"); + + // src/platform/hosts/javbus-host-adapter.js + var _JavBusHostAdapter = class _JavBusHostAdapter { + /** @param {Document} [documentRuntime] @param {Location} [locationRuntime] */ + constructor(documentRuntime = document, locationRuntime = window.location) { + this.document = documentRuntime; + this.location = locationRuntime; } - register(e2) { - if ("function" != typeof e2) throw new Error("插件必须是一个类"); - const a2 = performance.now(); - const t2 = new e2(); - t2.pluginManager = this; - const n2 = t2.getName(); - if (this.plugins.has(n2)) throw new Error(`插件"${n2}"已注册`); - this.plugins.set(n2, t2); - this._registrationMs += performance.now() - a2; + detectRoute() { + return this.locateNativeMagnets() ? "detail" : this.locateListRoot() ? "list" : "other"; } - getBean(e2) { - return this.plugins.get(e2); + readMovieRef() { + const carNum = this.document.querySelector(".info p span, [data-car-number]")?.textContent?.trim() ?? null; + return carNum ? Object.freeze({ carNum, url: this.location.href, site: "javbus" }) : null; } - _addError(e2, t2, n2) { - this._errorLog.push({ - time: (/* @__PURE__ */ new Date()).toISOString(), - plugin: e2, - phase: t2, - message: n2?.message || String(n2), - stack: n2?.stack || "" - }); - this._errorLog.length > 200 && this._errorLog.shift(); + locateListRoot() { + return this.document.querySelector(".masonry"); } - getErrorLog() { - return [...this._errorLog]; + locateDetailRoot() { + return this.document.querySelector(".container .row.movie"); } - clearErrorLog() { - this._errorLog = []; + locateDetailSlots() { + return Object.freeze({ summary: this.locateDetailRoot(), resources: this.locateNativeMagnets(), reviews: this.document.querySelector("#reviews") }); } - getTimings() { - return [...this._lastTimings]; + locateNativeGallery() { + return this.document.querySelector("#sample-waterfall, .sample-box"); } - getPluginNames() { - return Array.from(this.plugins.keys()); + locateNativeMagnets() { + return this.document.querySelector("#magnet-table"); } - getStartupReport() { - return { - registeredPlugins: this.plugins.size, - registrationMs: this._registrationMs, - cssMs: this._cssMs, - immediateMs: this._immediateMs, - readyMs: this._readyMs, - idlePending: this._idlePending, - idleCompleted: this._idleCompleted - }; + }; + __name(_JavBusHostAdapter, "JavBusHostAdapter"); + var JavBusHostAdapter = _JavBusHostAdapter; + + // src/platform/hosts/javdb-host-adapter.js + var _JavDbHostAdapter = class _JavDbHostAdapter { + /** @param {Document} [documentRuntime] @param {Location} [locationRuntime] */ + constructor(documentRuntime = document, locationRuntime = window.location) { + this.document = documentRuntime; + this.location = locationRuntime; } - async _getDisabledPlugins() { - return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { - try { - const e2 = await storageManager.getSetting("disabledPlugins", "[]"); - return new Set(JSON.parse(e2).filter(((name) => !["SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin"].includes(name)))); - } catch (e2) { - return /* @__PURE__ */ new Set(); - } - })()); + detectRoute() { + if (this.location.pathname.startsWith("/v/") || this.location.pathname.startsWith("/movies/")) return "detail"; + const listPath = /^\/(?:$|search|tags|actors|users\/|lists|series)/.test(this.location.pathname); + return listPath && this.locateListRoot() ? "list" : "other"; } - async processCss() { - const a2 = performance.now(); - const t2 = await this._getDisabledPlugins(); - const m2 = utils.isMobileMode(); - const s2 = await Promise.all(Array.from(this.plugins).map((async ([e2, n2]) => { - try { - if (t2.has(e2)) return { name: e2, status: "disabled" }; - if (m2 && "function" == typeof n2.shouldSkipOnMobile && n2.shouldSkipOnMobile()) return { name: e2, status: "skipped" }; - if ("function" == typeof n2.initCss) { - const t3 = await n2.initCss(); - return { - name: e2, - status: "fulfilled", - css: t3 - }; - } - return { name: e2, status: "skipped" }; - } catch (a3) { - return clog.error(`插件 ${e2} 加载 CSS 失败`, a3), this._addError(e2, "initCss", a3), { - name: e2, - status: "rejected", - error: a3 - }; - } - }))); - const o2 = s2.map(((e2) => e2.css)).filter(Boolean); - o2.length > 0 && utils.insertStyle(o2); - this._cssMs = performance.now() - a2; + readMovieRef() { + const carNum = this.document.querySelector(".panel-block.first-block .value, [data-car-number]")?.textContent?.trim() ?? null; + return carNum ? Object.freeze({ carNum, url: this.location.href, site: "javdb" }) : null; } - async _runPlugin(e2) { - const t2 = performance.now(); - try { - if ("function" == typeof e2.plugin.handle) await e2.plugin.handle(); - e2.timing.elapsed = performance.now() - t2; - e2.timing.status = "ok"; - } catch (n2) { - e2.timing.elapsed = performance.now() - t2; - e2.timing.status = "error"; - e2.timing.error = n2?.message || String(n2); - clog.error(`插件 ${e2.name} 执行失败`, n2); - this._addError(e2.name, "idle" === e2.mode ? "handle-idle" : "handle", n2); - } + locateListRoot() { + return this.document.querySelector(".movie-list"); } - _scheduleIdle(e2) { - const t2 = /* @__PURE__ */ __name(() => e2().catch(((e3) => { - clog.error("[JHS] 空闲插件执行失败:", e3); - })), "t"); - "function" == typeof requestIdleCallback ? requestIdleCallback(t2, { timeout: 1500 }) : setTimeout(t2, 100); + locateDetailRoot() { + return this.document.querySelector(".movie-panel-info")?.closest(".container") ?? this.document.querySelector("main"); } - async _runIdlePlugins(e2) { - for (const t2 of e2) { - t2.startedAt = performance.now(); - await this._runPlugin(t2); - this._idlePending--; - this._idleCompleted++; - } + locateDetailSlots() { + return Object.freeze({ summary: this.document.querySelector(".movie-panel-info"), resources: this.document.querySelector("#magnets-content"), reviews: this.document.querySelector("#reviews") }); } - async processPlugins() { - const e2 = await this._getDisabledPlugins(), t2 = utils.isMobileMode(), n2 = [], a2 = [], i2 = []; - for (const [s3, o2] of this.plugins) { - const r2 = "function" == typeof o2.getStartupMode && "idle" === o2.getStartupMode() ? "idle" : "immediate"; - const l2 = { name: s3, elapsed: 0, status: "pending", startupMode: r2 }; - if (e2.has(s3)) l2.status = "disabled", i2.push(l2); - else if (t2 && "function" == typeof o2.shouldSkipOnMobile && o2.shouldSkipOnMobile()) l2.status = "skipped-mobile", i2.push(l2); - else { - const e3 = { name: s3, plugin: o2, mode: r2, timing: l2, startedAt: 0 }; - "idle" === r2 ? (l2.status = "pending-idle", a2.push(e3)) : n2.push(e3), i2.push(l2); - } - } - this._lastTimings = i2; - const s2 = performance.now(); - await Promise.all(n2.map(((e3) => { - e3.startedAt = performance.now(); - return this._runPlugin(e3); - }))); - this._immediateMs = performance.now() - s2; - for (const e3 of n2) { - if ("function" != typeof e3.plugin.afterPluginsReady) continue; - try { - await e3.plugin.afterPluginsReady(); - } catch (t3) { - clog.error(`插件 ${e3.name} 完成初始化后执行失败`, t3), this._addError(e3.name, "after-ready", t3); - } - } - this._readyMs = performance.now() - this._startedAt; - this._idlePending = a2.length; - a2.length && this._scheduleIdle((() => this._runIdlePlugins(a2))); + locateNativeGallery() { + return this.document.querySelector(".tile-images, .preview-images"); + } + locateNativeMagnets() { + return this.document.querySelector("#magnets-content"); } }; - __name(_PluginManager, "PluginManager"); - var PluginManager = _PluginManager; - var _BasePlugin = class _BasePlugin { - constructor() { - if (!_BasePlugin._sharedIcons) { - i(this, "pluginManager", null), i(this, "settingSvg", ''), i(this, "editSvg", ''), i(this, "deleteSvg", ''), i(this, "checkSvg", ''), i(this, "actressSvg", ''), i(this, "newSvg", ''), i(this, "refreshSvg", ''), i(this, "blacklistSvg", ''), i(this, "copySvg", ''), i(this, "titleSvg", ''), i(this, "carNumSvg", ''), i(this, "downSvg", ''), i(this, "handleSvg", ''), i(this, "siteSvg", ''), i(this, "videoSvg", ''), i(this, "screenSvg", ''), i(this, "recoveryVideoSvg", ''); - const e2 = ["settingSvg", "editSvg", "deleteSvg", "checkSvg", "actressSvg", "newSvg", "refreshSvg", "blacklistSvg", "copySvg", "titleSvg", "carNumSvg", "downSvg", "handleSvg", "siteSvg", "videoSvg", "screenSvg", "recoveryVideoSvg"]; - _BasePlugin._sharedIcons = Object.fromEntries(e2.map(((e3) => [e3, this[e3]]))); - Object.assign(_BasePlugin.prototype, _BasePlugin._sharedIcons); - e2.forEach(((e3) => delete this[e3])); - } - i(this, "pluginManager", null); + __name(_JavDbHostAdapter, "JavDbHostAdapter"); + var JavDbHostAdapter = _JavDbHostAdapter; + + // src/contracts/manifests.js + var FEATURE_KINDS = /* @__PURE__ */ new Set(["system", "feature"]); + var STARTUP_MODES = /* @__PURE__ */ new Set(["eager", "idle", "on-command", "on-demand"]); + var TRUST_CLASSES = /* @__PURE__ */ new Set(["builtin-public", "custom-public", "user-local"]); + var QUALITY_LEVELS = /* @__PURE__ */ new Set(["bronze", "silver"]); + function requireNonEmptyString(value, field) { + if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${field} must be a non-empty string`); + } + __name(requireNonEmptyString, "requireNonEmptyString"); + function requireArray(value, field) { + if (!Array.isArray(value)) throw new TypeError(`${field} must be an array`); + } + __name(requireArray, "requireArray"); + function defineFeature(manifest) { + requireNonEmptyString(manifest.id, "Feature id"); + if (!FEATURE_KINDS.has(String(manifest.kind))) throw new TypeError("Feature kind must be system or feature"); + if (!STARTUP_MODES.has(String(manifest.startup))) throw new TypeError("Feature startup mode is invalid"); + for (const field of ["sites", "routes", "requires", "contributes", "providesCommands"]) requireArray(manifest[field], field); + if (typeof manifest.activate !== "function") throw new TypeError("Feature activate must be a function"); + if (manifest.kind === "system" && manifest.disableable !== false) throw new TypeError("System features cannot be disableable"); + return Object.freeze({ ...manifest }); + } + __name(defineFeature, "defineFeature"); + function defineIntegration(manifest) { + requireNonEmptyString(manifest.id, "Integration id"); + if (!TRUST_CLASSES.has(String(manifest.trustClass))) throw new TypeError("Integration trustClass is invalid"); + if (!QUALITY_LEVELS.has(String(manifest.quality))) throw new TypeError("Integration quality is invalid"); + for (const field of ["hosts", "capabilities", "requires"]) requireArray(manifest[field], field); + const hosts = ( + /** @type {unknown[]} */ + manifest.hosts + ); + const capabilities = ( + /** @type {unknown[]} */ + manifest.capabilities + ); + if (hosts.length === 0 || capabilities.length === 0) throw new TypeError("Integration hosts and capabilities cannot be empty"); + if (manifest.cachePolicy === void 0) throw new TypeError("Integration cachePolicy must be explicit"); + if (typeof manifest.createClient !== "function" || typeof manifest.createAdapter !== "function") { + throw new TypeError("Integration client and adapter factories are required"); + } + return Object.freeze({ ...manifest }); + } + __name(defineIntegration, "defineIntegration"); + + // src/contracts/tokens.js + function createToken(category, id) { + return /* @__PURE__ */ Symbol.for(`jhs.${category}.${id}`); + } + __name(createToken, "createToken"); + var PORT = Object.freeze({ + navigation: createToken("port", "navigation"), + http: createToken("port", "http"), + storage: createToken("port", "storage"), + dialog: createToken("port", "dialog"), + style: createToken("port", "style"), + host: createToken("port", "host") + }); + var SERVICE = Object.freeze({ + diagnostics: createToken("service", "diagnostics"), + urlPolicy: createToken("service", "url-policy"), + navigation: createToken("service", "navigation"), + http: createToken("service", "http"), + storage: createToken("service", "storage"), + dialog: createToken("service", "dialog"), + settings: createToken("service", "settings"), + movie: createToken("service", "movie"), + review: createToken("service", "review"), + related: createToken("service", "related"), + magnet: createToken("service", "magnet"), + screenshot: createToken("service", "screenshot"), + offline: createToken("service", "offline"), + cache: createToken("service", "cache"), + state: createToken("service", "state"), + profile: createToken("service", "profile") + }); + var REGISTRY = Object.freeze({ + command: createToken("registry", "command"), + feature: createToken("registry", "feature"), + provider: createToken("registry", "provider"), + settings: createToken("registry", "settings"), + integration: createToken("registry", "integration") + }); + var CACHE = Object.freeze({ + externalDetail: "external-detail-v1" + }); + + // src/ui/detail/hosted-detail-surface.js + var _HostedDetailSurface = class _HostedDetailSurface { + /** @param {{locateDetailRoot: () => Element | null, locateDetailSlots: () => Record}} hostAdapter */ + constructor(hostAdapter) { + this.hostAdapter = hostAdapter; + this.root = null; + this.slots = Object.freeze({}); + this.mounted = /* @__PURE__ */ new Map(); + } + mount() { + this.root = this.hostAdapter.locateDetailRoot(); + this.slots = Object.freeze({ ...this.hostAdapter.locateDetailSlots() }); + return this; } - getName() { - throw new Error(`${this.constructor.name} 未显示getName()`); + /** @param {string} slot @param {Element} element */ + mountPanel(slot, element) { + const target = this.slots[slot]; + if (!target) return false; + target.append(element); + this.mounted.set(slot, element); + return true; } - getBean(e2) { - return this.pluginManager.getBean(e2); + dispose() { + for (const element of this.mounted.values()) element.remove(); + this.mounted.clear(); + this.root = null; + this.slots = Object.freeze({}); } - async initCss() { - return ""; + }; + __name(_HostedDetailSurface, "HostedDetailSurface"); + var HostedDetailSurface = _HostedDetailSurface; + + // src/features/detail/detail-controller.js + var _DetailController = class _DetailController { + /** @param {{hostAdapter: any, scope: import("../../core/lifecycle-scope.js").LifecycleScope, enabledContributions: readonly string[]}} options */ + constructor(options) { + this.hostAdapter = options.hostAdapter; + this.scope = options.scope; + this.enabledContributions = new Set(options.enabledContributions); + this.surface = new HostedDetailSurface(this.hostAdapter); + this.movieRef = null; + } + start() { + this.scope.assertActive(); + this.surface.mount(); + this.movieRef = this.hostAdapter.readMovieRef(); + return Object.freeze({ movieRef: this.movieRef, contributions: [...this.enabledContributions] }); + } + dispose() { + this.surface.dispose(); + this.scope.dispose(); + } + }; + __name(_DetailController, "DetailController"); + var DetailController = _DetailController; + + // src/features/detail/manifest.js + var manifest_default = defineFeature({ + id: "detail", + kind: "feature", + disableable: true, + sites: ["javdb", "javbus"], + routes: ["detail"], + startup: "eager", + requires: [PORT.host, SERVICE.movie, SERVICE.review, SERVICE.related, SERVICE.magnet, SERVICE.screenshot], + contributes: ["detail.state-actions", "detail.gallery", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], + providesCommands: [], + activate: /* @__PURE__ */ __name((deps, runtime) => { + const controller = new DetailController({ hostAdapter: deps[PORT.host], scope: runtime.scope, enabledContributions: runtime.enabledContributions }); + controller.start(); + return { dispose: /* @__PURE__ */ __name(() => controller.dispose(), "dispose") }; + }, "activate") + }); + + // src/features/system/catalog.js + var systemFeatureManifests = Object.freeze([ + defineFeature({ + id: "settings", + kind: "system", + disableable: false, + sites: [], + routes: [], + startup: "on-command", + requires: [SERVICE.diagnostics], + contributes: [], + providesCommands: ["settings.open"], + activate: /* @__PURE__ */ __name(() => ({ commands: { "settings.open": /* @__PURE__ */ __name(() => document.querySelector("#setting-btn, #mini-setting-btn")?.dispatchEvent(new MouseEvent("click", { bubbles: true })), "settings.open") } }), "activate") + }), + defineFeature({ + id: "diagnostics", + kind: "system", + disableable: false, + sites: [], + routes: [], + startup: "eager", + requires: [SERVICE.diagnostics], + contributes: [], + providesCommands: ["diagnostics.export"], + activate: /* @__PURE__ */ __name((deps) => ({ commands: { "diagnostics.export": /* @__PURE__ */ __name(() => deps[SERVICE.diagnostics].exportSnapshot(), "diagnostics.export") } }), "activate") + }), + defineFeature({ + id: "responsive-shell", + kind: "system", + disableable: false, + sites: [], + routes: [], + startup: "eager", + requires: [SERVICE.profile], + contributes: [], + providesCommands: [], + activate: /* @__PURE__ */ __name((deps) => ({ profile: deps[SERVICE.profile].current() }), "activate") + }), + defineFeature({ + id: "stats", + kind: "system", + disableable: false, + sites: ["javdb", "javbus"], + routes: [], + startup: "idle", + requires: [SERVICE.diagnostics], + contributes: [], + providesCommands: [], + activate: /* @__PURE__ */ __name((deps) => ({ diagnostics: deps[SERVICE.diagnostics] }), "activate") + }) + ]); + + // src/features/catalog.js + var featureManifests = Object.freeze([...systemFeatureManifests, manifest_default]); + + // src/plugins/dependency-map.js + var LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ + AutoPagePlugin: ["ListPagePlugin"], + BlacklistPlugin: ["TaskPlugin", "SettingPlugin", "ListPagePlugin"], + BusNavBarPlugin: ["SearchByImagePlugin"], + CompatibilityEnhancementsPlugin: ["ListPagePlugin"], + CoverButtonPlugin: ["ListPagePlugin", "ScreenShotPlugin", "OtherSitePlugin"], + DetailPageButtonPlugin: ["DetailWorkspacePlugin", "MagnetHubPlugin", "HighlightMagnetPlugin"], + Fc2By123AvPlugin: ["OtherSitePlugin", "Fc2Plugin", "TranslatePlugin"], + Fc2Plugin: [ + "DetailPageButtonPlugin", + "MagnetHubPlugin", + "FilterTitleKeywordPlugin", + "OtherSitePlugin", + "ScreenShotPlugin", + "Fc2By123AvPlugin", + "TOP250Plugin", + "TranslatePlugin", + "HighlightMagnetPlugin", + "ReviewPlugin", + "RelatedPlugin" + ], + HistoryPlugin: ["UnifiedOfflinePlugin", "ListPagePlugin", "Fc2Plugin"], + HitShowPlugin: ["ListPageButtonPlugin", "ListPagePlugin", "CoverButtonPlugin"], + ListPageButtonPlugin: ["NewVideoPlugin", "BlacklistPlugin", "ListPagePlugin"], + ListPagePlugin: [ + "HistoryPlugin", + "ListPageButtonPlugin", + "CoverButtonPlugin", + "AutoPagePlugin", + "BusImgPlugin", + "Fc2Plugin" + ], + MobileBottomBarPlugin: [ + "ListPagePlugin", + "DetailPageButtonPlugin", + "ListPageButtonPlugin", + "NewVideoPlugin", + "BlacklistPlugin", + "SettingPlugin" + ], + NavBarPlugin: ["SearchByImagePlugin"], + NewVideoPlugin: ["TaskPlugin", "SettingPlugin", "OtherSitePlugin"], + PreviewVideoPlugin: ["DetailPageButtonPlugin"], + RelatedPlugin: ["DetailWorkspacePlugin"], + ReviewPlugin: ["DetailWorkspacePlugin", "RelatedPlugin"], + SettingPlugin: [ + "CoverButtonPlugin", + "TaskPlugin", + "OtherSitePlugin", + "ListPagePlugin", + "TranslatePlugin", + "ActressInfoPlugin", + "ScreenShotPlugin", + "NewVideoPlugin", + "BlacklistPlugin", + "BusImgPlugin" + ], + StatsPlugin: ["OtherSitePlugin", "NewVideoPlugin", "ListPagePlugin"], + TaskPlugin: ["OtherSitePlugin", "BlacklistPlugin", "ListPagePlugin"], + TOP250Plugin: ["HitShowPlugin"], + UnifiedOfflinePlugin: ["OneTwoThreeOfflinePlugin", "ListPagePlugin"] + }); + + // src/plugins/avatar/actress-info.js + var _ActressInfoPlugin = class _ActressInfoPlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "apiUrl", "https://ja.wikipedia.org/wiki/"); + } + getName() { + return "ActressInfoPlugin"; } async handle() { + "yes" === await storageManager.getSetting("enableLoadActressInfo", "yes") && await this.loadActressInfo(); } - getStartupMode() { - return "immediate"; + async loadActressInfo() { + await Promise.all([this.handleDetailPage(), this.handleStarPage()]); } - shouldSkipOnMobile() { - return false; + async initCss() { + return "\n \n "; } - getPageInfo() { - let carNum = null, t2, n2, a2, i2, s2 = window.location.href; - if (r) { - const params = new URL(s2).searchParams, injectedCarNum = normalizeCarNum(params.get("jhsCarNum")); - const copyCarNum = normalizeCarNum($('.column-video-info a[data-clipboard-text][title*="番"], .video-detail a[data-clipboard-text][title*="番"]').first().attr("data-clipboard-text")) || normalizeCarNum($('a[title="複製番號"]').attr("data-clipboard-text")); - let panelCarNum = null; - $(".column-video-info .panel-block, .video-detail .panel-block").each((function() { - const label = $(this).find("strong, .label").first().text().trim(); - if (panelCarNum || !/(?:番号|番號|^ID)\s*[::]?/i.test(label)) return; - panelCarNum = normalizeCarNum($(this).find("[data-clipboard-text]").first().attr("data-clipboard-text")) || normalizeCarNum($(this).find(".value").first().text()); - })); - const fallbackCarNum = normalizeCarNum($("#video_id, .video-id, .video-title strong").first().text()); - carNum = firstValidCarNum(injectedCarNum, copyCarNum, panelCarNum, fallbackCarNum); - t2 = s2.split("?")[0].split("#")[0], n2 = $(".female").prev().map(((e2, t3) => $(t3).text())).get().join(" "), a2 = $(".male").prev().map(((e2, t3) => $(t3).text())).get().join(" "), i2 = $('strong:contains("日期:")').parent(".panel-block").find(".value").text().trim(); + async handleDetailPage() { + if ($(".actress-info").length > 0) return; + let e2 = $(".female").prev().map(((e3, t3) => $(t3).text().trim())).get(); + if (!e2.length) return; + const t2 = "jhs_actress_info", n2 = localStorage.getItem(t2) ? JSON.parse(localStorage.getItem(t2)) : {}; + let a2 = null, i2 = ""; + for (let o2 = 0; o2 < e2.length; o2++) { + let t3 = e2[o2]; + if (a2 = n2[t3], !a2) try { + a2 = await this.searchInfo(t3), a2 && (n2[t3] = a2); + } catch (s2) { + clog.error("该名称查询失败,尝试其它名称"); + } + let r2 = ""; + r2 = a2 ? ` + + ` : ` `, i2 += r2; } - l && (t2 = s2.split("?")[0], carNum = normalizeCarNum(t2.split("/").filter(Boolean).pop().replace(/_\d{4}-\d{2}-\d{2}$/, "")), n2 = $('span[onmouseover*="star_"] a').map(((e2, t3) => $(t3).text())).get().join(" "), a2 = "", i2 = $('span.header:contains("發行日期:")').parent("p").text().trim().replace("發行日期:", "").trim()); - return assertPageInfoContract({ - carNum, - url: t2, - actress: n2, - actors: a2, - publishTime: i2 - }); - } - getActressId() { - const e2 = o.match(/\/actors\/([^/?]+)/); - return e2 && e2.length > 1 ? e2[1] : null; + $('strong:contains("演員")').parent().after(i2), localStorage.setItem(t2, JSON.stringify(n2)); } - getActressPageInfo() { - let e2 = window.location.href; - if (!e2.includes("/actors/") && !e2.includes("/star/")) throw new Error("接口调用错误, 非演员详情页"); - let t2 = [], n2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - n2.length && n2.text().trim().split(",").forEach(((e3) => { - t2.push(e3.trim()); - })); - let a2 = $(".section-meta:not(:contains('影片'))"); - a2.length && a2.text().trim().split(",").forEach(((e3) => { - t2.push(e3.trim()); + async handleStarPage() { + if ($(".actress-info").length > 0) return; + let e2 = [], t2 = $(".actor-section-name"); + t2.length && t2.text().trim().split(",").forEach(((t3) => { + e2.push(t3.trim()); })); - let i2 = $(".section-meta:contains('男優')").length > 0 ? B : P, s2 = D; - t2.some(((e3) => e3.includes("無碼"))) && (s2 = A), e2.includes("uncensored") && (s2 = A); - let o2 = null, c2 = null; - const d2 = new URL(e2); - if (r) { - c2 = d2.pathname.split("/").filter(((e4) => "" !== e4.trim())).pop(); - const e3 = d2.searchParams; - e3.delete("sort_type"), e3.delete("page"), o2 = d2.toString(); - } else if (l) { - const t3 = "/star/", n3 = e2.split(t3); - if (n3.length < 2) throw new Error("提取演员url失败"); - const a3 = n3[0]; - c2 = n3[1].split("/")[0], o2 = a3 + t3 + c2; + let n2 = $(".section-meta:not(:contains('影片'))"); + if (n2.length && n2.text().trim().split(",").forEach(((t3) => { + e2.push(t3.trim()); + })), !e2.length) return; + const a2 = "jhs_actress_info", i2 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}; + let s2 = null; + for (let l2 = 0; l2 < e2.length; l2++) { + let t3 = e2[l2]; + if (s2 = i2[t3], s2) break; + try { + s2 = await this.searchInfo(t3); + } catch (r2) { + clog.error("该名称查询失败,尝试其它名称"); + } + if (s2) break; } - return { - starId: c2, - name: t2[0], - allName: t2, - role: i2, - movieType: s2, - blacklistUrl: o2 - }; + s2 && e2.forEach(((e3) => { + i2[e3] = s2; + })); + let o2 = '
无此相关演员信息
'; + s2 && (o2 = ` + +
+
+ 出生日期: ${s2.birthday} + 年龄: ${s2.age} + 身高: ${s2.height} +
+
+ 体重: ${s2.weight} + 三围: ${s2.threeSizeText} + 罩杯: ${s2.braSize} +
+
+
+ `), t2.parent().append(o2), localStorage.setItem(a2, JSON.stringify(i2)); } - getSelector(e2) { - const t2 = e2 || (r ? T : l ? I : null), n2 = { - javdb: { - boxSelector: ".movie-list", - itemSelector: ".movie-list .item", - coverImgSelector: ".cover img", - requestDomItemSelector: ".movie-list .item", - nextPageSelector: ".pagination-next" - }, - javbus: { - boxSelector: ".masonry", - itemSelector: ".masonry .item", - coverImgSelector: ".masonry .movie-box .photo-frame img", - requestDomItemSelector: "#waterfall .item", - nextPageSelector: "#next" - } + async searchInfo(e2) { + "三上悠亞" === e2 && (e2 = "三上悠亜"); + let t2 = this.apiUrl + e2; + const n2 = await gmHttp.get(t2), a2 = new DOMParser(), i2 = $(a2.parseFromString(n2, "text/html")); + let s2 = i2.find('a[title="誕生日"]').parent().parent().find("td").text().trim(), o2 = i2.find("th:contains('現年齢')").parent().find("td").text().trim() ? parseInt(i2.find("th:contains('現年齢')").parent().find("td").text().trim()) + "岁" : "", r2 = i2.find('tr:has(a[title="身長"]) td').text().trim().split(" ")[0] + "cm", l2 = i2.find('tr:has(a[title="体重"]) td').text().trim().split("/")[1].trim(); + return "― kg" === l2 && (l2 = ""), { + birthday: s2, + age: o2, + height: r2, + weight: l2, + threeSizeText: i2.find('a[title="スリーサイズ"]').closest("tr").find("td").text().replace("cm", "").trim(), + braSize: i2.find('th:contains("ブラサイズ")').next("td").contents().first().text().trim(), + url: t2 }; - if (!t2 || !n2[t2]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); - return n2[t2]; } - parseMovieId(e2) { - return e2.split("/").pop().split(/[?#]/)[0]; + }; + __name(_ActressInfoPlugin, "ActressInfoPlugin"); + var ActressInfoPlugin = _ActressInfoPlugin; + + // src/plugins/avatar/search-by-image.js + var _SearchByImagePlugin = class _SearchByImagePlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "siteList", [{ + name: "Google旧版", + url: "https://www.google.com/searchbyimage?image_url={占位符}&client=firefox-b-d", + ico: "https://www.google.com/favicon.ico" + }, { + name: "Google", + url: "https://lens.google.com/uploadbyurl?url={占位符}", + ico: "https://www.google.com/favicon.ico" + }, { + name: "Yandex", + url: "https://yandex.ru/images/search?rpt=imageview&url={占位符}", + ico: "https://yandex.ru/favicon.ico" + }]), i(this, "isUploading", false); } - getBoxCarInfo(e2) { - var t2, n2, a2; - const i2 = e2.find("a"), s2 = i2.attr("href"); - let o2 = null, r2 = null, l2 = null; - const c2 = e2.find(".video-title"); - if (c2.length > 0) { - const n3 = c2.find("strong"); - if (n3.length > 0 && (o2 = n3.text().trim()), r2 = null == (t2 = i2.attr("title")) ? void 0 : t2.trim(), !r2) { - const e3 = c2.text().trim(); - r2 = o2 && e3.includes(o2) ? e3.replace(o2, "").trim() : e3; - } - l2 = e2.find(".meta").text().trim(); - } - if (!o2) { - const t3 = e2.find("img"); - t3.length > 0 && (r2 = (null == (n2 = t3.attr("title")) ? void 0 : n2.trim()) || (null == (a2 = t3.attr("data-title")) ? void 0 : a2.trim())); - const i3 = e2.find("date").map(((e3, t4) => $(t4).text().trim())).get(), s3 = /* @__PURE__ */ __name((e3) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e3), "s"); - l2 = i3.find(s3) || null, o2 = i3.find(((e3) => !s3(e3))) || null; - } - if (!o2) { - const t3 = "提取番号信息失败: carNum 为空"; - throw clog.error("Error in getBoxCarInfo:", t3, "Box Element:", e2.get(0)), show.error(t3), new Error(t3); - } - return { - carNum: o2, - url: s2 || "", - title: r2 || "", - publishTime: l2 || "" - }; + getName() { + return "SearchByImagePlugin"; } - getBoxCarInfoList(e2 = null) { - if (e2 || (e2 = $(this.getSelector().itemSelector)), 0 === e2.length) return clog.error("获取当前列表页所有item的番号信息失败!"), []; - const t2 = []; - return e2.each(((e3, n2) => { - const a2 = $(n2); - try { - const e4 = this.getBoxCarInfo(a2); - t2.push(e4); - } catch (i2) { - clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", i2.message, "元素索引:", e3); + async initCss() { + return "\n \n "; + } + open(e2) { + layer.open({ + type: 1, + title: "以图识图", + content: '\n
\n
\n
\n

拖拽图片到此处 或 点击按钮选择图片

\n

也可以直接 Ctrl+V 粘贴图片或 图片URL

\n
\n \n \n
\n \n
\n \n
\n \n
\n \n
\n \n \n
\n \n
\n

请选择识图网站:

\n
\n
\n
\n \n
\n ', + area: utils.isMobileMode() ? utils.getResponsiveArea() : ["40%", "80%"], + success: /* @__PURE__ */ __name(async (t2) => { + this.initEventListeners(), e2 && e2(); + }, "success"), + end: /* @__PURE__ */ __name(() => { + $(document).off("paste.searchImg"); + }, "end") + }); + } + initEventListeners() { + const e2 = $("#upload-area"), t2 = $("#image-file"), n2 = $("#select-image-btn"), a2 = $("#preview-area"), i2 = $("#preview-image"), s2 = $("#action-btns"), o2 = $("#handle-btn"), r2 = $("#cancel-btn"), l2 = $("#url-input-container"), c2 = $("#image-url"), d2 = $("#search-results"), h2 = $("#search-img-site-btns-container"); + e2.on("dragover", ((t3) => { + t3.preventDefault(), e2.addClass("highlight"); + })).on("dragleave", (() => { + e2.removeClass("highlight"); + })).on("drop", ((t3) => { + t3.preventDefault(), e2.removeClass("highlight"), t3.originalEvent.dataTransfer.files && t3.originalEvent.dataTransfer.files[0] && (this.handleImageFile(t3.originalEvent.dataTransfer.files[0]), this.resetSearchUI()); + })), n2.on("click", (() => { + t2.trigger("click"); + })), t2.on("change", ((e3) => { + e3.target.files && e3.target.files[0] && (this.handleImageFile(e3.target.files[0]), this.resetSearchUI()); + })), $(document).on("paste.searchImg", (async (e3) => { + const t3 = e3.originalEvent.clipboardData.items; + for (let a3 = 0; a3 < t3.length; a3++) if (-1 !== t3[a3].type.indexOf("image")) { + const e4 = t3[a3].getAsFile(); + return this.handleImageFile(e4), void this.resetSearchUI(); } - })), t2; + const n3 = e3.originalEvent.clipboardData.getData("text"); + n3 && utils.isUrl(n3) && (l2.show(), c2.val(n3), i2.attr("src", n3), a2.show(), this.resetSearchUI()); + })), o2.on("click", (async () => { + const e3 = i2.attr("src"); + if (e3) { + if (!this.isUploading) { + this.isUploading = true; + try { + const t3 = await this.searchByImage(e3); + s2.hide(), d2.show(), h2.empty(); + const n3 = "jhs_selectedSites", a3 = JSON.parse(localStorage.getItem(n3) || "{}"); + this.siteList.forEach(((e4) => { + const n4 = e4.url.replace("{占位符}", encodeURIComponent(t3)), i3 = false !== a3[e4.name]; + h2.append(` + + + ${e4.name} + ${e4.name} + + `); + })), h2.on("change", ".site-checkbox", (function() { + const e4 = $(this).data("site-name"); + a3[e4] = $(this).is(":checked"), localStorage.setItem(n3, JSON.stringify(a3)); + })), h2.show(); + } finally { + this.isUploading = false; + } + } + } else show.info("请粘贴或上传图片"); + })), r2.on("click", (() => { + a2.hide(), l2.hide(), t2.val(""), c2.val(""); + })), c2.on("change", (() => { + utils.isUrl(c2.val()) && (i2.attr("src", c2.val()), a2.show()); + })), $("#openAll").on("click", (() => { + $(".search-img-site-btn").each((function() { + $(this).find(".site-checkbox").is(":checked") && window.open($(this).attr("href")); + })); + })); } - checkDuplicateCarNumbers(e2, t2) { - if (!e2 || 0 === e2.length || !t2 || 0 === t2.length) return false; - const n2 = new Set(e2.map(((e3) => e3.carNum)).filter(((e3) => e3))); - if (0 === n2.size) return false; - let a2 = 0; - for (let i2 = 0; i2 < t2.length; i2++) { - const e3 = t2[i2] ? t2[i2].carNum : null; - if (e3 && n2.has(e3)) { - if (a2++, a2 >= 2) return clog.warn("警告: 检测到连续番号信息重复, 该类别可能已被限制页码。"), true; - } else a2 = 0; + resetSearchUI() { + $("#action-btns").show(), $("#search-results").hide(), $("#search-img-site-btns-container").hide().empty(); + } + handleImageFile(e2) { + const t2 = document.getElementById("preview-image"), n2 = document.getElementById("preview-area"), a2 = document.getElementById("url-input-container"); + if (!e2.type.match("image.*")) return void show.info("请选择图片文件"); + const i2 = new FileReader(); + i2.onload = (e3) => { + t2.src = e3.target.result, n2.style.display = "block", a2.style.display = "none", $("#handle-btn")[0].click(); + }, i2.readAsDataURL(e2); + } + async searchByImage(e2) { + let t2 = loading(); + try { + let t3 = e2; + if (e2.startsWith("data:")) { + show.info("开始上传图片..."); + const n2 = await (async function(e3) { + var t4; + const n3 = e3.match(/^data:(.+);base64,(.+)$/); + if (!n3 || n3.length < 3) throw new Error("无效的Base64图片数据"); + const a2 = n3[1], i2 = n3[2], s2 = atob(i2), o2 = new Array(s2.length); + for (let g2 = 0; g2 < s2.length; g2++) o2[g2] = s2.charCodeAt(g2); + const r2 = new Uint8Array(o2), l2 = new Blob([r2], { + type: a2 + }), c2 = new FormData(); + c2.append("image", l2); + const d2 = await fetch("https://api.imgur.com/3/image", { + method: "POST", + headers: { + Authorization: "Client-ID d70305e7c3ac5c6" + }, + body: c2 + }), h2 = await d2.json(); + if (h2.success && h2.data && h2.data.link) return h2.data.link; + throw new Error((null == (t4 = h2.data) ? void 0 : t4.error) || "上传到Imgur失败"); + })(e2); + if (!n2) return void show.error("上传失败"); + t3 = n2; + } + return t3; + } catch (n2) { + show.error(`搜索失败: ${n2.message}`), clog.error("搜索失败:", n2); + } finally { + t2.close(); } - return false; } }; - __name(_BasePlugin, "BasePlugin"); - var BasePlugin = _BasePlugin; - function normalizeJavStoreAssetUrl(value, baseUrl = "https://javstore.net") { - if (!value) return null; - try { - const url = new URL(String(value), baseUrl), hostname = url.hostname.toLowerCase(); - if (!["http:", "https:"].includes(url.protocol)) return null; - "http:" === url.protocol && ("javstore.net" === hostname || hostname.endsWith(".javstore.net")) && (url.protocol = "https:"); - return url.href; - } catch { - return null; - } - } - __name(normalizeJavStoreAssetUrl, "normalizeJavStoreAssetUrl"); - function parseJavStoreSearch($searchPage, carNum, baseUrl = "https://javstore.net") { - const normalizedCarNum = normalizeCarNum(carNum); - if (!normalizedCarNum) return []; - return $searchPage.find('a[href$="-pn.html"]').filter(((index, element) => $(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()))).map(((index, element) => new URL($(element).attr("href"), baseUrl).href)).get(); + __name(_SearchByImagePlugin, "SearchByImagePlugin"); + var SearchByImagePlugin = _SearchByImagePlugin; + + // src/core/feature-helpers.js + async function mapLimit(items, concurrency = 4, mapper) { + const results = new Array(items.length); + let cursor = 0; + const worker = /* @__PURE__ */ __name(async () => { + while (cursor < items.length) { + const index = cursor++; + results[index] = await mapper(items[index], index); + } + }, "worker"); + await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), items.length) }, worker)); + return results; } - __name(parseJavStoreSearch, "parseJavStoreSearch"); - function parseJavStorePreview($detailPage, detailUrl) { - const previewHref = $detailPage.find("a").filter(((index, element) => "CLICK HERE!" === $(element).text().trim())).first().attr("href"); - if (!previewHref) return null; - const previewUrl = normalizeJavStoreAssetUrl(previewHref, detailUrl); - return previewUrl ? previewUrl.replace(".th", "") : null; + __name(mapLimit, "mapLimit"); + function parseNumberSetting(value, fallback, { min = -Infinity, max = Infinity } = {}) { + if (null == value || "" === String(value).trim()) return fallback; + const number = Number(value); + return Number.isFinite(number) && number >= min && number <= max ? number : fallback; } - __name(parseJavStorePreview, "parseJavStorePreview"); - function parseJavDbActorList($page, baseUrl) { - const challengeText = $page.find("title, body").text(); - if (/Just a moment|cf-chl-|Cloudflare/i.test(challengeText)) return { state: "challenge", isEmpty: false, actors: [], nextUrl: null }; - const container = $page.find("#actors").first(); - if (!container.length) return { state: "invalid", isEmpty: false, actors: [], nextUrl: null }; - const actors = [], boxes = container.find(".actor-box").toArray(); + __name(parseNumberSetting, "parseNumberSetting"); + function parseTaskTimestamp(value) { + if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.getTime() : null; + if ("number" == typeof value) return Number.isFinite(value) && value >= 0 ? value : null; + if ("string" != typeof value) return null; + const text = value.trim(); + if (!text) return null; + if (/^\d{13,16}$/.test(text)) { + const timestamp2 = Number(text); + return Number.isFinite(timestamp2) ? timestamp2 : null; + } + const match = text.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/); + if (match) { + const parts = match.slice(1).map(Number), date = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]); + return date.getFullYear() === parts[0] && date.getMonth() === parts[1] - 1 && date.getDate() === parts[2] && date.getHours() === parts[3] && date.getMinutes() === parts[4] && date.getSeconds() === parts[5] ? date.getTime() : null; + } + const timestamp = new Date(text).getTime(); + return Number.isFinite(timestamp) ? timestamp : null; + } + __name(parseTaskTimestamp, "parseTaskTimestamp"); + function shouldSkipStopped(lastPublishTime, ruleHours, now = Date.now()) { + const hours = parseNumberSetting(ruleHours, 0, { min: 0 }), publishedAt = parseTaskTimestamp(lastPublishTime), nowAt = parseTaskTimestamp(now); + return hours > 0 && null != publishedAt && null != nowAt && nowAt >= publishedAt && nowAt - publishedAt >= 36e5 * hours; + } + __name(shouldSkipStopped, "shouldSkipStopped"); + function selectLatestPublishTime(values) { + let latestValue = null, latestAt = -Infinity; + for (const value of values) { + const timestamp = parseTaskTimestamp(value); + null != timestamp && timestamp > latestAt && (latestAt = timestamp, latestValue = value); + } + return latestValue; + } + __name(selectLatestPublishTime, "selectLatestPublishTime"); + function normalizeHttpUrl(value, baseUrl = window.location.href) { + if (!value) return null; try { - for (const box of boxes) { - const $actor = $(box).find("a").first(), title = $actor.attr("title"), href = $actor.attr("href"); - if (!title || !href) throw new Error("演员卡缺少身份字段"); - const allName = title.split(",").map(((name) => name.trim())).filter(Boolean), actorUrl = new URL(href, baseUrl); - const starId = actorUrl.pathname.split("/").filter(Boolean).pop() || ""; - if (!starId || !allName.length) throw new Error("演员卡身份字段无效"); - actors.push({ - starId, - name: allName[0], - allName, - avatar: $actor.find("img").attr("src"), - actressType: $actor.find(".info").text().trim().includes("無碼") ? A : D, - lastCheckTime: null, - lastUpdateTime: null - }); - } - const nextHref = $page.find(".pagination-next").attr("href"), nextUrl = nextHref ? new URL(nextHref, baseUrl).href : null; - return { state: "valid", isEmpty: 0 === boxes.length, actors, nextUrl }; + const url = new URL(String(value), baseUrl); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch (error) { - return { state: "invalid", isEmpty: false, actors: [], nextUrl: null }; + clog.debug("外部 URL 无效,已忽略", error); + return null; } } - __name(parseJavDbActorList, "parseJavDbActorList"); - function parseDetailPage($page, selectors) { - const challengeText = $page.find("title, body").text(); - const isChallenge = /Just a moment|cf-chl-|Cloudflare/i.test(challengeText); - const hasContainer = $page.find(selectors.boxSelector).length > 0; - return { - state: isChallenge ? "challenge" : hasContainer ? "valid" : "invalid", - items: hasContainer ? $page.find(selectors.requestDomItemSelector) : null, - isEmpty: hasContainer && 0 === $page.find(selectors.requestDomItemSelector).length - }; + __name(normalizeHttpUrl, "normalizeHttpUrl"); + function normalizeBtihHash(value) { + const hash = String(value || "").trim(); + return /^(?:[a-f\d]{40}|[a-z2-7]{32})$/i.test(hash) ? hash.toUpperCase() : null; } - __name(parseDetailPage, "parseDetailPage"); - function parse123AvCards($page, baseUrl = "https://123av.com") { - const items = /* @__PURE__ */ new Map(); - $page.find(".card").each(((index, element) => { - const $card = $(element); - const $link = $card.find('a.card__link[href*="/cn/v/fc2-ppv-"]').first(); - const href = $link.attr("href"), text = $link.text().trim(); - const numberMatch = text.match(/FC2-PPV-(\d+)/i); - if (!href || !numberMatch) return; - const carNum = `FC2-${numberMatch[1]}`; - const detailUrl = new URL(href, baseUrl); - detailUrl.hash = ""; - items.set(carNum, { - imgSrc: $card.find("img.card__img[src]").first().attr("src") || "", - carNum, - href: detailUrl.href, - title: text.replace(/^FC2-PPV-\d+\s*[—–-]\s*/i, "").trim(), - preview: $card.find(".card__poster[data-preview]").first().attr("data-preview") || null - }); - })); - return [...items.values()]; + __name(normalizeBtihHash, "normalizeBtihHash"); + function parseCarNumberText(text) { + const tokens = String(text || "").split(/[\s,,;;]+/).map(((item) => normalizeCarNum(item))).filter(Boolean); + const valid = tokens.filter(((item) => /^(?:FC2-)?[A-Z\d]+(?:-[A-Z\d]+)+$/i.test(item))); + return { recognized: tokens.length, values: [...new Set(valid.map(((item) => item.toUpperCase())))], invalid: tokens.filter(((item) => !valid.includes(item))) }; } - __name(parse123AvCards, "parse123AvCards"); - function merge123AvCards(cardLists) { - const items = /* @__PURE__ */ new Map(); - cardLists.flat().forEach(((item) => items.set(item.carNum, item))); - return [...items.values()]; + __name(parseCarNumberText, "parseCarNumberText"); + function buildFallbackCarUrl(carNum, baseUrl = "https://javdb.com") { + return `${baseUrl}/search?q=${encodeURIComponent(carNum)}`; } - __name(merge123AvCards, "merge123AvCards"); - function parse123AvSourceMaxPage($page, baseUrl = "https://123av.com") { - const lastHref = $page.find('a[rel="last"]').first().attr("href"); - if (lastHref) { - const page = Number.parseInt(new URL(lastHref, baseUrl).searchParams.get("page"), 10); - if (Number.isFinite(page)) return page; + __name(buildFallbackCarUrl, "buildFallbackCarUrl"); + async function safePlay(mediaElement, { context = "视频", notify = false, message = "当前视频源无法播放" } = {}) { + if (!mediaElement || "function" != typeof mediaElement.play) { + clog.warn(`${context}播放失败:媒体元素不可用`); + notify && show.error(message); + return false; + } + try { + await mediaElement.play(); + return true; + } catch (error) { + clog.warn(`${context}播放失败`, error); + const name = error?.name || ""; + notify && !["NotAllowedError", "AbortError"].includes(name) && show.error(message); + return false; } - const inputMax = Number.parseInt($page.find("input.pager__input[max]").first().attr("max"), 10); - if (Number.isFinite(inputMax)) return inputMax; - const totalMatch = $page.find(".pager__total").first().text().match(/(\d[\d,]*)/); - return totalMatch ? Number.parseInt(totalMatch[1].replaceAll(",", ""), 10) : null; } - __name(parse123AvSourceMaxPage, "parse123AvSourceMaxPage"); - function parse123AvVideoInfo($page, url) { - const idMatch = new URL(url).pathname.match(/fc2-ppv-(\d+)/i); - const id = idMatch ? idMatch[1] : null; - const rawTitle = $page.find("h1.watch__title").first().text().trim(); - const title = id ? rawTitle.replace(new RegExp(`^FC2-PPV-${id}\\s*[—–-]\\s*`, "i"), "").trim() : rawTitle; - const pageText = $page.find("body").text(); - const publishDate = pageText.match(/发布日期\s*(\d{4}-\d{2}-\d{2})/)?.[1] || ""; - return { id, publishDate, title, moviePoster: null }; + __name(safePlay, "safePlay"); + + // src/core/ui-primitives.js + function renderStateView(container, { type = "empty", title = "", description = "", actionLabel = "", onAction = null } = {}) { + const root = container?.jquery ? container : $(container), state = $('
').addClass(`jhs-state--${type}`).attr("role", "error" === type ? "alert" : "status"), content = $('
'); + title && content.append($('

').text(title)), description && content.append($('

').text(description)); + if (actionLabel && "function" == typeof onAction) content.append($('').text(actionLabel).on("click", onAction)); + return state.append(content), root.empty().append(state), state; } - __name(parse123AvVideoInfo, "parse123AvVideoInfo"); - var _DetailPagePlugin = class _DetailPagePlugin extends BasePlugin { - getName() { - return "DetailPagePlugin"; + __name(renderStateView, "renderStateView"); + var _JhsSelect = class _JhsSelect { + constructor(select) { + this.source = $(select); + if (!this.source.length || _JhsSelect.instances.has(this.source[0])) return _JhsSelect.instances.get(this.source[0]); + const initiallyHidden = this.source.hasClass("jhs-is-hidden") || "none" === this.source[0].style.display; + this.control = $('
'); + this.trigger = $(''); + this.menu = $(''); + this.source.wrap(this.control), this.control = this.source.parent(), this.control.append(this.trigger, this.menu), this.source.addClass("jhs-select-source-native").attr({ "aria-hidden": "true", tabindex: "-1" }), initiallyHidden && this.control.addClass("jhs-is-hidden"), _JhsSelect.instances.set(this.source[0], this), this.render(), this.bind(), this.refresh(); } - constructor() { - super(); + static enhance(root = document) { + const scope = $(root), selects = scope.is("select.jhs-select-source") ? scope : scope.find("select.jhs-select-source"); + selects.each(((_2, select) => new _JhsSelect(select))); + return selects; } - handle() { - window.isDetailPage && $(".video-meta-panel a").each((function() { - const e2 = $(this).attr("href"); - e2 && (e2.startsWith("http://") || e2.startsWith("https://") || e2.startsWith("/")) && $(this).attr("target", "_blank"); - })); + static get(select) { + const element = $(select)[0]; + return element ? _JhsSelect.instances.get(element) || new _JhsSelect(element) : null; } - }; - __name(_DetailPagePlugin, "DetailPagePlugin"); - var DetailPagePlugin = _DetailPagePlugin; - var Z = /* @__PURE__ */ __name((e2, t2) => { - if (!e2 || 0 === e2.length) return null; - const n2 = new Set(e2); - if (n2.has(t2)) return t2; - const a2 = L.map(((e3) => e3.quality)).reverse(); - for (const i2 of a2) if (n2.has(i2)) return i2; - return e2[0]; - }, "Z"); - var ee = "jhs_dmm_video"; - var _DmmPreviewParser = class _DmmPreviewParser { - constructor(e2) { - this.carNum = e2, this.lastError = null; + static setValue(select, value, emit = false) { + const instance = _JhsSelect.get(select); + if (!instance) return; + instance.source.val(value), emit ? instance.emitChange() : instance.refresh(); } - _checkCache() { - const e2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; - return e2[this.carNum] ? (clog.debug("缓存中存在预览视频信息", e2[this.carNum]), e2[this.carNum]) : null; + static refresh(select) { + _JhsSelect.get(select)?.refresh(); } - _updateCache(e2) { - const t2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; - t2[this.carNum] = e2, clog.debug("成功解析出预览视频并已缓存:", e2), localStorage.setItem(ee, JSON.stringify(t2)); + static refreshAll(root = document) { + _JhsSelect.enhance(root), $(root).find("select.jhs-select-source").each(((_2, select) => _JhsSelect.refresh(select))); } - async _searchContentIds() { - const e2 = this.carNum, t2 = e2.replace(/-/g, ""), n2 = [{ - keyword: e2.replace("-", "00"), - name: "00-替换关键词" - }, { - keyword: e2, - name: "原始番号关键词" - }, { - keyword: t2, - name: "无连字符关键词" - }], a2 = e2.toLowerCase(); - let hadSuccessfulRequest = false; - for (const o2 of n2) { - const { keyword: e3, name: n3 } = o2, i3 = e3.toLowerCase(); - clog.debug(`--- 尝试使用 ${n3} (${e3}) 进行 API 搜索 ---`); - const r2 = `https://api.dmm.com/affiliate/v3/ItemList?${new URLSearchParams({ - api_id: "UrwskPfkqQ0DuVry2gYL", - affiliate_id: "10278-996", - output: "json", - site: "FANZA", - sort: "match", - keyword: e3 - }).toString()}`; - let l2; - try { - l2 = await gmHttp.get(r2); - hadSuccessfulRequest = true; - } catch (s2) { - this.lastError = new ProviderError("dmm", "HTTP_ERROR", `DMM API 请求失败: ${s2.message || s2}`, { - cause: s2, - url: r2, - status: s2?.status, - retryable: true - }), clog.error(`API 请求失败,跳过 ${n3}:`, this.lastError); - continue; - } - if (!l2 || !l2.result || !l2.result.result_count) { - clog.debug("API 返回无结果,尝试下一个关键词。"); - continue; - } - const c2 = []; - for (const s2 of l2.result.items) { - if (c2.length >= 2) break; - const e4 = s2.content_id || "", o3 = s2.maker_product || ""; - (e4.includes(i3.replace("-", "")) || a2 === o3.toLowerCase() || e4.includes(t2.toLowerCase())) && (c2.push({ - serviceCode: s2.service_code, - floorCode: s2.floor_code, - contentId: e4, - pageUrl: s2.URL - }), clog.debug(`[${n3}] cid|makerProduct 匹配成功:`, e4, o3)); - } - if (c2.length > 0) { - clog.debug(`--- 成功通过 ${n3} 找到 Content IDs ---`); - const t3 = $("#fanzaBtn"); - let a3 = `https://www.dmm.co.jp/search/=/searchstr=${e3}`, i4 = "single"; - c2.length > 1 ? (t3.attr("href", a3), t3.append('多结果'), t3.css("backgroundColor", "var(--jhs-status-down)"), i4 = "multiple") : (a3 = c2[0].pageUrl, t3.attr("href", a3), t3.css("backgroundColor", "var(--jhs-status-down)")); - const s2 = "jhs_other_site_dmm", o3 = localStorage.getItem(s2) ? JSON.parse(localStorage.getItem(s2)) : {}; - return o3[this.carNum] = { - type: i4, - url: a3 - }, localStorage.setItem(s2, JSON.stringify(o3)), c2; - } - clog.debug(`[${n3}] API 返回结果数 ${l2.result.result_count},但无精确匹配的 Content ID。`); - } - hadSuccessfulRequest && (this.lastError = null); - clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"); - const i2 = $("#fanzaBtn"); - return i2.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), i2.attr("title", "未查询到, 点击前往搜索页"), i2.css("backgroundColor", "var(--jhs-status-filter)"), null; + static setVisible(select, visible) { + const instance = _JhsSelect.get(select); + instance?.control.toggleClass("jhs-is-hidden", !visible); } - async _extractTrailerLinks({ contentId: e2, serviceCode: t2, floorCode: n2 }) { - const a2 = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e2}/mtype=AhRVShI_/service=${t2}/floor=${n2}/mode=/`, i2 = await gmHttp.get(a2, null, { - "accept-language": "ja-JP,ja;q=0.9", - Cookie: "age_check_done=1" - }); - if ("string" != typeof i2) throw clog.error(i2), new ProviderError("dmm", "PARSE_ERROR", "解析播放页内容失败, 非文本内容", { - url: a2 - }); - if (i2.includes("このサービスはお住まいの地域からは")) throw new ProviderError("dmm", "REGION_BLOCKED", "DMM 预览源不可用,请将 DMM 域名分流到日本 IP", { - url: a2 - }); - const s2 = i2.match(/const\s+args\s+=\s+(.*);/); - if (!s2) throw new ProviderError("dmm", "PARSE_ERROR", "未在脚本中找到 const args = ... 变量", { - url: a2 - }); - let o2; - try { - ({ bitrates: o2 } = JSON.parse(s2[1])); - } catch (d2) { - throw new ProviderError("dmm", "PARSE_ERROR", `解析播放器脚本 JSON 失败: ${d2.message}`, { - cause: d2, - url: a2 - }); - } - const r2 = {}, l2 = L.map(((e3) => e3.quality)).join("|"), c2 = new RegExp(`(${l2})\\.mp4$`); - if (!Array.isArray(o2)) throw clog.error("解析画质链接失败: bitrates 字段不是一个数组或不存在"), new ProviderError("dmm", "PARSE_ERROR", "解析画质链接失败: bitrates 字段不是一个数组或不存在", { - url: a2 - }); - clog.debug("原始数据返回:", o2); - for (const h2 of o2) { - const e3 = null == h2 ? void 0 : h2.src; - if (!e3 || "string" != typeof e3 || !e3.endsWith(".mp4")) continue; - const t3 = e3.match(c2); - let n3 = ""; - t3 && t3[1] && (n3 = t3[1]), n3 && !r2[n3] && (r2[n3] = e3); - } - if (0 === Object.keys(r2).length) throw new ProviderError("dmm", "PARSE_ERROR", "未找到匹配要求的预览画质视频", { - url: a2 - }); - return r2; + static closeAll(except = null) { + $(".jhs-select-control.is-open").each(((_2, control) => { + const source = $(control).children("select.jhs-select-source")[0], instance = source && _JhsSelect.instances.get(source); + instance && instance !== except && instance.close(); + })); } - async fetchVideo() { - const carNum = normalizeCarNum(this.carNum); - if (!carNum) return clog.warn("跳过 DMM 解析:番号不可用"), null; - this.carNum = carNum; - const e2 = this._checkCache(); - if (e2) return e2; - let t2; - try { - const e3 = this.carNum.toLowerCase(); - if (e3.startsWith("heyzo") || /^(n\d+|\d+(-\d+)*)$/.test(e3) || /^n\d+$/.test(e3)) return clog.debug("无码番号类型,取消 DMM 解析"), null; - if (this.carNum.includes("VR-")) return clog.debug("VR 类型,取消 DMM 解析"), null; - t2 = await this._searchContentIds(); - } catch (n2) { - this.lastError = n2 instanceof ProviderError ? n2 : new ProviderError("dmm", "PARSE_ERROR", n2.message || String(n2), { - cause: n2 - }), clog.error("DMM API 搜索失败:", this.lastError); - const e3 = $("#fanzaBtn"); - return e3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), e3.attr("title", "未查询到, 点击前往搜索页"), e3.css("backgroundColor", "var(--jhs-status-filter)"), null; - } - if (!t2 || 0 === t2.length) return null; - try { - const e3 = await Promise.any(t2.map(((e4) => this._extractTrailerLinks(e4)))); - return this._updateCache(e3), e3; - } catch (a2) { - const e3 = a2.errors || [a2]; - this.lastError = e3.find(((e4) => "REGION_BLOCKED" === e4?.code)) || e3.find(((e4) => e4 instanceof ProviderError)) || new ProviderError("dmm", "PARSE_ERROR", e3[0]?.message || String(e3[0]), { - cause: e3[0] - }), clog.error(`解析失败: ${this.lastError.message}`, e3); - const t3 = $("#fanzaBtn"); - return t3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), t3.attr("title", "未查询到, 点击前往搜索页"), t3.css("backgroundColor", "var(--jhs-status-filter)"), null; - } + render() { + this.menu.empty(); + const appendOption = /* @__PURE__ */ __name((option, target) => { + const button = $(''); + button.attr({ "data-value": option.value, "aria-checked": option.selected ? "true" : "false" }).prop("disabled", option.disabled).text(option.text), target.append(button); + }, "appendOption"); + this.source.children().each(((_2, child) => { + if ("OPTGROUP" === child.tagName) { + const group = $('
').attr("aria-label", child.label), label = $('
').text(child.label); + group.append(label), $(child).children("option").each(((_3, option) => appendOption(option, group))), this.menu.append(group); + } else "OPTION" === child.tagName && appendOption(child, this.menu); + })); } - }; - __name(_DmmPreviewParser, "DmmPreviewParser"); - var DmmPreviewParser = _DmmPreviewParser; - async function fetchDmmPreview(carNum) { - const parser = new DmmPreviewParser(carNum), sources = await parser.fetchVideo(); - return { - sources, - error: parser.lastError - }; - } - __name(fetchDmmPreview, "fetchDmmPreview"); - var _PreviewVideoPlugin = class _PreviewVideoPlugin extends BasePlugin { - getName() { - return "PreviewVideoPlugin"; + bind() { + this.trigger.on("click", ((event) => { + event.preventDefault(), event.stopPropagation(), this.source.prop("disabled") || (this.control.hasClass("is-open") ? this.close() : this.open()); + })).on("keydown", ((event) => { + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(), this.open("ArrowUp" === event.key || "End" === event.key ? "last" : "selected"); + })); + this.menu.on("click", ".jhs-select-option", ((event) => { + event.preventDefault(), this.choose($(event.currentTarget)); + })).on("keydown", ".jhs-select-option", ((event) => { + const items = this.options(), index = items.index(event.currentTarget); + if ("Escape" === event.key) return event.preventDefault(), this.close(true); + if ("Tab" === event.key) return void this.close(); + if (["Enter", " "].includes(event.key)) return event.preventDefault(), this.choose($(event.currentTarget)); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); + })); + this.source.on("change.jhsSelect", (() => this.refresh())), $(document).on("click.jhsSelect", ((event) => { + $(event.target).closest(this.control).length || this.close(); + })); } - async initCss() { - return ".jhs-dmm-preview-player{display:none;width:100%;height:auto}.jhs-dmm-preview-player.is-active{display:block}.jhs-native-preview-hidden{display:none!important}"; + options() { + return this.menu.find(".jhs-select-option:not(:disabled)"); } - async handle() { - if (!isDetailPage) return; - const trigger = $(".preview-video-container"), openVideo = /* @__PURE__ */ __name(() => { - utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => { - this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))); - })); - }, "openVideo"); - trigger.off("click.jhsVideo").on("click.jhsVideo", openVideo); - await storageManager.getSetting("enableLoadPreviewVideo", _) !== _ || o.includes("autoPlay=1") || this.initDmm(); - const url = window.location.href; - (url.includes("gallery-1") || url.includes("gallery-2")) && openVideo(), url.includes("autoPlay=1") && trigger.length > 0 && trigger[0].click(); + open(focus = "selected") { + _JhsSelect.closeAll(this), this.control.addClass("is-open"), this.menu.addClass("is-open"), this.trigger.attr("aria-expanded", "true"); + const items = this.options(), selected = items.filter('[aria-checked="true"]'); + ("last" === focus ? items.last() : selected.length ? selected.first() : items.first()).trigger("focus"); } - async initDmm() { - try { - const { sources } = await this.getDmmPreview(); - if (!sources) return; - const $video = $("#preview-video"), video = $video[0]; - if (video) return; - clog.debug("JavDB没有视频播放元素, 开始创建..."); - const cover = $(".column-video-cover img").attr("src"); - $(".preview-images").prepend(` - - 预告片 - - - `), $(".preview-video-container").off("click.jhsVideo").on("click.jhsVideo", (() => { - utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))))); - })); - } catch (error) { - clog.error("预加载 DMM 失败:", error); - } + close(focus = false) { + this.control.removeClass("is-open"), this.menu.removeClass("is-open"), this.trigger.attr("aria-expanded", "false"), focus && this.trigger.trigger("focus"); } - /** 复用单次 DMM 请求,避免预加载和点击处理重复抓取。 */ - getDmmPreview() { - if (this.dmmPreviewPromise) return this.dmmPreviewPromise; - this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum).then(((result) => { - (result.error?.retryable || "HTTP_ERROR" === result.error?.code) && (this.dmmPreviewPromise = null); - return result; - }), ((error) => { - this.dmmPreviewPromise = null; - throw error; - })); - return this.dmmPreviewPromise; + choose(item) { + if (item.prop("disabled")) return; + this.source.val(item.attr("data-value")), this.emitChange(), this.close(true); } - /** 创建与 JavDB HLS 生命周期完全隔离的 DMM 播放器。 */ - createDmmPlayer($nativeVideo) { - const $host = $nativeVideo.parent(), existing = $host.find("#jhs-preview-video"); - if (existing.length) return existing; - const $player = $(''); - return $nativeVideo.after($player), $player; + /** 派发一次真实原生 change,同时兼容宿主与 jQuery 监听器。 */ + emitChange() { + this.source[0]?.dispatchEvent(new Event("change", { + bubbles: true + })); } - /** 销毁 JHS 播放器并把播放权完整交回 JavDB。 */ - async restoreNativePlayer($nativeVideo, nativeVideo, notify = false) { - const $dmmVideo = $nativeVideo.parent().find("#jhs-preview-video"), dmmVideo = $dmmVideo[0]; - dmmVideo && (dmmVideo.pause(), $dmmVideo.removeAttr("src"), dmmVideo.load(), $dmmVideo.remove()); - $nativeVideo.removeClass("jhs-native-preview-hidden"); - return safePlay(nativeVideo, { - context: "JavDB 原生预览回退", - notify - }); - } - async handleVideo() { - const $nativeVideo = $("#preview-video"); - if (!$nativeVideo.length) return; - const $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = localStorage.getItem("jhs_videoMuted"); - void safePlay(nativeVideo, { - context: "JavDB 原生预览", - notify: false - }); - const dmmEnabled = await storageManager.getSetting("enableLoadPreviewVideo", _) !== C, dmmResult = dmmEnabled ? await this.getDmmPreview() : { - sources: null, - error: null - }, { sources, error } = dmmResult, $toolbar = $("
").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
").addClass("jhs-video-quality-list").attr({ - role: "group", - "aria-label": "视频画质" - }); - $host.find("#video-bottom-toolbar").remove(); - let dmmPlayed = false, $dmmVideo = null, dmmVideo = null; - if (sources) { - const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; - const currentTime = nativeVideo.currentTime; - $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = !muted || "yes" === muted, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { - localStorage.setItem("jhs_videoMuted", dmmVideo.muted ? "yes" : "no"); - })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); - dmmPlayed = await safePlay(dmmVideo, { - context: "JavDB 高画质预览", - notify: false - }); - if (!dmmPlayed && !dmmVideo.muted) dmmVideo.muted = true, dmmPlayed = await safePlay(dmmVideo, { - context: "JavDB 高画质预览静音重试", - notify: false - }); - dmmPlayed ? (nativeVideo.pause(), $nativeVideo.addClass("jhs-native-preview-hidden")) : ($dmmVideo.removeClass("is-active"), await this.restoreNativePlayer($nativeVideo, nativeVideo, true)); - dmmPlayed && L.forEach(((quality) => { - const qualitySource = sources[quality.quality]; - if (!qualitySource) return; - const active = dmmPlayed && selectedQuality === quality.quality; - $qualityList.append($(``)); - })); - } - $toolbar.append($qualityList); - const $actions = $("
").addClass("jhs-toolbar"); - $actions.append('', '', ''), $toolbar.append($actions), $host.append($toolbar), sources || await safePlay(nativeVideo, { - context: "JavDB 预览视频", - notify: true, - message: "REGION_BLOCKED" === error?.code ? error.message : "当前视频源无法播放" - }); - $toolbar.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (event) => { - const $button = $(event.currentTarget); - if ($button.hasClass("active")) return; - try { - if (!dmmVideo) return; - const currentTime = dmmVideo.currentTime, previousSource = $dmmVideo.attr("src"); - $dmmVideo.attr("src", $button.data("video-src")), dmmVideo.load(), dmmVideo.currentTime = currentTime; - const played = await safePlay(dmmVideo, { - context: "JavDB 画质切换", - notify: false - }); - if (played) $toolbar.find(".jhs-video-quality-btn").removeClass("active").attr("aria-pressed", "false"), $button.addClass("active").attr("aria-pressed", "true"); - else { - previousSource && ($dmmVideo.attr("src", previousSource), dmmVideo.load(), dmmVideo.currentTime = currentTime); - const restored = previousSource && await safePlay(dmmVideo, { - context: "JavDB 画质切换回退", - notify: false - }); - restored || await this.restoreNativePlayer($nativeVideo, nativeVideo, true); - } - } catch (playbackError) { - clog.error("切换画质失败:", playbackError); - } - })), $("#speed-btn").off("click.jhsVideo").on("click.jhsVideo", (() => { - dmmVideo && (dmmVideo.currentTime += 10); - })), $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", ((event) => (event.preventDefault(), this.getBean("DetailPageButtonPlugin").filterOne(event)))), $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getBean("DetailPageButtonPlugin").filterOne(event))), $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getBean("DetailPageButtonPlugin").favoriteOne(event))); + refresh() { + const selected = this.source.find("option:selected").first(), value = this.source.val(); + this.trigger.find(".jhs-select-value").text(selected.text()), this.trigger.prop("disabled", this.source.prop("disabled")), this.menu.find(".jhs-select-option").attr("aria-checked", "false").filter(((_2, item) => $(item).attr("data-value") === String(value ?? ""))).attr("aria-checked", "true"); } }; - __name(_PreviewVideoPlugin, "PreviewVideoPlugin"); - var PreviewVideoPlugin = _PreviewVideoPlugin; - var _JavTrailersPlugin = class _JavTrailersPlugin extends BasePlugin { - getName() { - return "JavTrailersPlugin"; + __name(_JhsSelect, "JhsSelect"); + __publicField(_JhsSelect, "instances", /* @__PURE__ */ new WeakMap()); + var JhsSelect = _JhsSelect; + + // src/plugins/external-search/magnet-source-registry.js + var MAGNET_SOURCE_IDS = Object.freeze(["native-javdb", "native-javbus", "u9a9", "u3c3", "sukebei", "btsow"]); + function normalizeMagnetResult(result, source) { + if (!result || !String(result.magnet || "").startsWith("magnet:")) return null; + return { title: String(result.title || ""), magnet: result.magnet, size: result.size || "", date: result.date || "", seeders: Number(result.seeders) || 0, leechers: Number(result.leechers) || 0, source, files: Array.isArray(result.files) ? result.files : [] }; + } + __name(normalizeMagnetResult, "normalizeMagnetResult"); + function extractInfoHash(magnet) { + const hash = new URL(magnet).searchParams.get("xt")?.match(/^urn:btih:([a-z2-7]{32}|[a-f\d]{40})$/i)?.[1]; + return hash ? hash.toUpperCase() : null; + } + __name(extractInfoHash, "extractInfoHash"); + function deduplicateMagnetResults(results) { + const unique = /* @__PURE__ */ new Map(); + results.forEach(((result) => { + const key = extractInfoHash(result.magnet) || `${result.source}:${result.magnet}`; + const existing = unique.get(key); + existing ? existing.sources = [.../* @__PURE__ */ new Set([...existing.sources || [existing.source], result.source])] : unique.set(key, { ...result, sources: [result.source] }); + })); + return [...unique.values()]; + } + __name(deduplicateMagnetResults, "deduplicateMagnetResults"); + var _MagnetSourceRegistry = class _MagnetSourceRegistry { + constructor(sources = []) { + this.sources = /* @__PURE__ */ new Map(); + sources.forEach(((source) => this.register(source))); } - constructor() { - super(), this.hasBand = false; + register(source) { + if (!source?.id || !source.name || "function" !== typeof source.search || "function" !== typeof source.targetUrl) throw new TypeError("Invalid magnet provider"); + this.sources.set(source.id, { enabled: true, priority: 100, ...source }); + return this; } - handle() { - let e2 = window.location.href; - if (!e2.includes("handle=1")) return; - if ($("h1:contains('Page not found')").length) { - clog.log("番号无法匹配, 跳搜索"); - let t3 = e2.split("?")[0].split("video/")[1].toLowerCase().replace("00", "-"); - return void (window.location.href = "/search/" + encodeURIComponent(t3) + window.location.search); - } - let t2 = $(".videos-list .video-link").toArray(); - if (t2.length) { - const n2 = e2.split("?")[0].split("search/")[1].toLowerCase(), a2 = t2.find(((e3) => $(e3).find(".vid-title").text().toLowerCase().includes(n2))); - if (a2) return void (window.location.href = $(a2).attr("href") + window.location.search); - } - this.handlePlayJavTrailers(), $("#videoPlayerContainer").on("click", (() => { - this.handlePlayJavTrailers(); - })), window.addEventListener("message", ((e3) => { - let t3 = document.getElementById("vjs_video_3_html5_api"); - t3 && (t3.currentTime += 5); - })); + get(id) { + return this.sources.get(id) || null; } - handlePlayJavTrailers() { - this.hasBand || (utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { - setTimeout((() => { - this.hasBand = true; - let e2 = document.getElementById("vjs_video_3_html5_api"); - clog.debug(e2), safePlay(e2, { - context: "JavTrailers 预览" - }), e2.currentTime = 5, e2.addEventListener("timeupdate", (function() { - e2.currentTime >= 14 && e2.currentTime < 16 && (e2.currentTime += 2); - })), $("#vjs_video_3_html5_api").css({ - position: "fixed", - width: "100vw", - height: "100vh", - objectFit: "cover", - zIndex: String(JHS_Z_INDEX.debug) - }), $(".vjs-control-bar").css({ - position: "fixed", - bottom: "20px", - zIndex: String(JHS_Z_INDEX.debug) - }); - }), 100); - })), utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { - 0 !== $("#vjs_video_3 canvas").length && $("#vjs_video_3 canvas").css({ - position: "fixed", - width: "100vw", - height: "100vh", - objectFit: "cover", - top: "0", - right: "0", - zIndex: String(JHS_Z_INDEX.debug - 1) - }); - }))); + getEnabledSources() { + return [...this.sources.values()].filter(((source) => source.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); } }; - __name(_JavTrailersPlugin, "JavTrailersPlugin"); - var JavTrailersPlugin = _JavTrailersPlugin; - var _SubTitleCatPlugin = class _SubTitleCatPlugin extends BasePlugin { - getName() { - return "SubTitleCatPlugin"; - } - handle() { - $(".t-banner-inner").hide(), $("#navbar").hide(); - let e2 = new URLSearchParams(window.location.search).get("search").toLowerCase(), t2 = $(".sub-table tr td a").toArray(), n2 = 0; - t2.forEach(((t3) => { - let a3 = $(t3); - a3.text().toLowerCase().includes(e2) ? n2++ : a3.parent().parent().hide(); - })), 0 === n2 && show.error("该番号无字幕!"); - const a2 = $(".sec-title"), i2 = a2.html().replace(/^\d+/, n2); - a2.html(i2); + __name(_MagnetSourceRegistry, "MagnetSourceRegistry"); + var MagnetSourceRegistry = _MagnetSourceRegistry; + function validateHttpsBaseUrl(value) { + const url = new URL(value); + if ("https:" !== url.protocol) throw new TypeError("Source URL must use https"); + return url.origin; + } + __name(validateHttpsBaseUrl, "validateHttpsBaseUrl"); + function validateCustomMagnetSource(config) { + const allowed = ["id", "name", "enabled", "priority", "searchUrlTemplate", "targetUrlTemplate", "parserType", "rowSelector", "titleSelector", "magnetSelector", "sizeSelector", "dateSelector", "seedersSelector", "leechersSelector", "resultsPath", "titlePath", "hashPath", "magnetPath", "sizePath", "datePath", "seedersPath"]; + if (Object.keys(config).some(((key) => !allowed.includes(key)))) throw new TypeError("Unsupported custom source field"); + if (!["torrent-table", "magnet-links", "json"].includes(config.parserType)) throw new TypeError("Unsupported parser type"); + if (!String(config.name || "").trim()) throw new TypeError("Source name is required"); + validateHttpsBaseUrl(config.searchUrlTemplate.replace("{keyword}", "test")); + validateHttpsBaseUrl((config.targetUrlTemplate || config.searchUrlTemplate).replace("{keyword}", "test")); + if ("torrent-table" === config.parserType && (!config.rowSelector?.trim() || !config.magnetSelector?.trim())) throw new TypeError("表格来源必须填写结果行选择器和磁力选择器"); + if ("json" === config.parserType && (!config.resultsPath?.trim() || !config.magnetPath?.trim() && !config.hashPath?.trim())) throw new TypeError("JSON 来源必须填写结果数组路径,并填写磁力路径或哈希路径"); + return { ...config, name: config.name.trim(), targetUrlTemplate: config.targetUrlTemplate || config.searchUrlTemplate }; + } + __name(validateCustomMagnetSource, "validateCustomMagnetSource"); + function applyMagnetRules(result, tagRules = [], titleFilters = [], fileFilters = []) { + const text = `${result.title || ""} ${(result.files || []).join(" ")}`; + const matches = /* @__PURE__ */ __name((rule, value) => "regex" === rule.type ? new RegExp(rule.pattern, "i").test(value) : value.toLowerCase().includes(rule.pattern.toLowerCase()), "matches"); + const tags = tagRules.filter(((rule) => rule.enabled && matches(rule, text))); + let hidden = false, penalty = 0; + const filteredReasons = []; + [...titleFilters.map(((rule) => ({ ...rule, value: result.title || "" }))), ...fileFilters.map(((rule) => ({ ...rule, value: (result.files || []).join(" ") })))].filter(((rule) => rule.enabled)).forEach(((rule) => { + if (!matches(rule, rule.value)) return; + filteredReasons.push(rule.id || rule.pattern); + "hide" === rule.action ? hidden = true : penalty += Number(rule.penalty) || 0; + })); + return { ...result, tags: tags.map(((rule) => rule.name)), customTagWeight: tags.reduce(((sum, rule) => sum + (Number(rule.weight) || 0)), 0), hidden, filterPenalty: penalty, filteredReasons }; + } + __name(applyMagnetRules, "applyMagnetRules"); + function parseNativeMagnets(root, source) { + const results = /* @__PURE__ */ new Map(); + $(root).find('a[href^="magnet:"],[data-clipboard-text^="magnet:"]').each(((index, element) => { + const node = $(element), magnet = node.attr("href") || node.attr("data-clipboard-text"); + if (!magnet) return; + const container = node.closest(".item, .magnet-name, tr, .panel-block"), title = container.find(".name, .magnet-name, .title").first().text().trim() || node.text().trim() || "本站磁力"; + const result = normalizeMagnetResult({ title, magnet, size: container.find(".meta, .size").first().text().trim() }, source); + result && results.set(extractInfoHash(magnet) || magnet, result); + })); + return [...results.values()]; + } + __name(parseNativeMagnets, "parseNativeMagnets"); + function readJsonPath(value, path) { + return String(path || "").split(".").filter(Boolean).reduce(((current, key) => current?.[key]), value); + } + __name(readJsonPath, "readJsonPath"); + function parseCustomMagnetResponse(config, payload, sourceId) { + config = validateCustomMagnetSource(config); + if ("json" === config.parserType) return (readJsonPath(payload, config.resultsPath) || []).map(((item) => normalizeMagnetResult({ title: readJsonPath(item, config.titlePath), magnet: config.magnetPath ? readJsonPath(item, config.magnetPath) : `magnet:?xt=urn:btih:${readJsonPath(item, config.hashPath)}`, size: readJsonPath(item, config.sizePath), date: readJsonPath(item, config.datePath), seeders: readJsonPath(item, config.seedersPath) }, `custom:${sourceId}`))).filter(Boolean); + const root = utils.htmlTo$dom(payload), rows = "magnet-links" === config.parserType ? root.find('a[href^="magnet:"]') : root.find(config.rowSelector); + return rows.map(((index, element) => { + const row = $(element), magnetNode = "magnet-links" === config.parserType ? row : row.find(config.magnetSelector).first(); + return normalizeMagnetResult({ title: "magnet-links" === config.parserType ? row.text().trim() : row.find(config.titleSelector).first().text().trim(), magnet: magnetNode.attr("href") || magnetNode.attr("data-magnet"), size: row.find(config.sizeSelector).text().trim(), date: row.find(config.dateSelector).text().trim(), seeders: row.find(config.seedersSelector).text().trim(), leechers: row.find(config.leechersSelector).text().trim() }, `custom:${sourceId}`); + })).get().filter(Boolean); + } + __name(parseCustomMagnetResponse, "parseCustomMagnetResponse"); + + // src/plugins/backup/resource-settings.js + var BUILT_IN_MAGNET_SOURCES = Object.freeze([ + { id: "native-javdb", name: "JavDB 本站", type: "本站资源", domain: "javdb.com", priority: 10, enabled: true }, + { id: "native-javbus", name: "JavBus 本站", type: "本站资源", domain: "javbus.com", priority: 11, enabled: true }, + { id: "u9a9", name: "U9A9", type: "网页来源", domain: "u9a9.com", baseUrl: "https://u9a9.com", priority: 20, enabled: true }, + { id: "u3c3", name: "U3C3", type: "网页来源", domain: "u3c3.com", baseUrl: "https://u3c3.com", priority: 30, enabled: true }, + { id: "sukebei", name: "Sukebei", type: "网页来源", domain: "sukebei.nyaa.si", baseUrl: "https://sukebei.nyaa.si", priority: 40, enabled: true }, + { id: "btsow", name: "BTSOW", type: "API 来源", domain: "btsow.lol", baseUrl: "https://btsow.lol", priority: 50, enabled: true } + ]); + var BUILT_IN_SCREENSHOT_SOURCES = Object.freeze([ + { id: "javstore", name: "JavStore", domain: "javstore.net", priority: 10, enabled: true }, + { id: "projectjav", name: "ProjectJav", domain: "projectjav.com", priority: 20, enabled: false, implemented: false }, + { id: "18av", name: "18AV", domain: "18av.mm-cg.com", priority: 30, enabled: false, implemented: false } + ]); + function validateRule(rule) { + if (!rule.name?.trim() || !rule.pattern?.trim()) throw new TypeError("规则名称和匹配内容不能为空"); + if ("regex" === rule.type) try { + new RegExp(rule.pattern); + } catch { + throw new TypeError("正则表达式无效"); } - }; - __name(_SubTitleCatPlugin, "SubTitleCatPlugin"); - var SubTitleCatPlugin = _SubTitleCatPlugin; - var _Fc2Plugin = class _Fc2Plugin extends BasePlugin { - getName() { - return "Fc2Plugin"; + return { ...rule, name: rule.name.trim(), pattern: rule.pattern.trim() }; + } + __name(validateRule, "validateRule"); + function buildCustomMagnetSource(form, existing = null) { + const parserType = form.parserType || "magnet-links"; + const config = { id: existing?.id || `source-${Date.now()}`, name: String(form.name || "").trim(), enabled: Boolean(form.enabled), priority: Number(form.priority) || 100, searchUrlTemplate: String(form.searchUrlTemplate || "").trim(), targetUrlTemplate: String(form.targetUrlTemplate || form.searchUrlTemplate || "").trim(), parserType }; + if (!config.name) throw new TypeError("来源名称不能为空"); + if ("torrent-table" === parserType) Object.assign(config, { rowSelector: form.rowSelector, titleSelector: form.titleSelector, magnetSelector: form.magnetSelector, sizeSelector: form.sizeSelector, dateSelector: form.dateSelector, seedersSelector: form.seedersSelector, leechersSelector: form.leechersSelector }); + if ("json" === parserType) Object.assign(config, { resultsPath: form.resultsPath, titlePath: form.titlePath, magnetPath: form.magnetPath, hashPath: form.hashPath, sizePath: form.sizePath, datePath: form.datePath, seedersPath: form.seedersPath }); + return validateCustomMagnetSource(config); + } + __name(buildCustomMagnetSource, "buildCustomMagnetSource"); + var _ResourceSettingsService = class _ResourceSettingsService { + constructor(storage = storageManager) { + this.storage = storage; } - async initCss() { - return ``; + async getArray(key) { + const value = await this.storage.getSetting(key, "[]"); + if (Array.isArray(value)) return value; + try { + const parsed = JSON.parse(value || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } } - async handle() { - const fc2Href = "/advanced_search?type=3&score_min=0&d=1"; - $('.navbar-item:contains("FC2")').attr("href", fc2Href), $('.tabs a:contains("FC2")').attr("href", fc2Href); - if (o.includes("advanced_search?type=3")) $("h2.section-title").contents().first().replaceWith("Fc2PPV"), $(".section .container > .box").remove(); - if (!o.includes("collection_codes?movieId")) return; - const params = new URLSearchParams(window.location.search), movieId = params.get("movieId"), carNum = params.get("carNum"), url = params.get("url"), explicitSource = params.get("source"), source = ["fc2", "123av"].includes(explicitSource) ? explicitSource : await this.resolveFc2Source({ url }), host = $("section").first().empty(); - if (!carNum || !url) return void host.append($('
').text("FC2 详情参数不完整")); - const context = this.mountFc2Detail(host, { movieId, carNum, url, source, mode: "page" }); - $(window).off("pagehide.jhsFc2Detail").one("pagehide.jhsFc2Detail", (() => context.destroy())); + async saveArray(key, value) { + if (!Array.isArray(value)) throw new TypeError("配置必须是数组"); + await this.storage.saveSettingItem(key, JSON.stringify(value)); + return value; } - openFc2Dialog(movieId, carNum, url, { source = "fc2" } = {}) { - let context = null; - return layer.open({ - type: 1, - title: carNum, - content: '
', - area: utils.getDialogArea("workspace"), - skin: "movie-detail-layer", - scrollbar: false, - shadeClose: true, - success: /* @__PURE__ */ __name((layerRoot, layerIndex) => { - context = this.mountFc2Detail($(layerRoot).find(".jhs-fc2-dialog-host"), { movieId, carNum, url, source, layerIndex, mode: "dialog" }), utils.setupEscClose(layerIndex); - }, "success"), - end: /* @__PURE__ */ __name(() => context?.destroy(), "end") - }); + getMagnetSources() { + return this.getArray("customMagnetSources"); } - /** 统一挂载 FC2 与 123AV-FC2 详情。 */ - mountFc2Detail(host, options) { - const target = $(host), previous = target.data("jhsFc2Context"); - previous?.destroy?.(), target.empty(); - const shell = createFc2DetailShell(options).appendTo(target), context = createFc2DetailContext(shell, options); - target.data("jhsFc2Context", context), this.initializeWorkspace(context); - return context; + saveMagnetSources(value) { + value.forEach(validateCustomMagnetSource); + return this.saveArray("customMagnetSources", value); } - initializeWorkspace(context) { - const summary = $('
正在加载影片信息…
'), toolbar = $(''); - [["filterBtn", "jhs-btn--filter", m], ["favoriteBtn", "jhs-btn--fav", v], ["hasDownBtn", "jhs-btn--down", y], ["hasWatchBtn", "jhs-btn--watch", k]].forEach((([id, className, label]) => toolbar.append($('').attr("id", id).addClass(className).find("span").text(label).end()))); - toolbar.append('', '', ''), summary.find(".jhs-fc2-summary__body").append(toolbar), context.getSlot("summary").append(summary); - const gallery = $(''), screenshot = $('
'); - gallery.on(`click${context.namespace}`, ".jhs-fc2-gallery-item", ((event) => { - const image = $(event.currentTarget).find("img")[0]; - image && showImageViewer(image, "", { galleryRoot: gallery[0] }); - })); - context.getSlot("gallery").append(gallery, screenshot); - const resources = $('
'), nativeGroup = this.createResourceGroup("站内磁力", "native-magnets"), sitesGroup = this.createResourceGroup("第三方站点", "other-sites"), hubGroup = this.createResourceGroup("更多磁力来源", "magnet-hub"), hubButton = $(''); - let magnetHubPromise = null; - hubGroup.find('[data-jhs-role="magnet-hub"]').append(hubButton, '
'), resources.append(nativeGroup, sitesGroup, hubGroup), context.getSlot("resources").append(resources); - toolbar.on(`click${context.namespace}`, '[data-jhs-action="subtitlecat"]', ((event) => utils.openPage(`https://subtitlecat.com/index.php?search=${encodeURIComponent(context.carNum)}`, context.carNum, false, event))), toolbar.on(`click${context.namespace}`, '[data-jhs-action="xunlei"]', (() => this.getBean("DetailPageButtonPlugin").searchXunLeiSubtitle(context.carNum))); - hubButton.on(`click${context.namespace}`, (async () => { - if (!context.isAlive()) return; - const box = hubGroup.find('[data-jhs-role="magnet-hub-content"]'), expanded = "true" === hubButton.attr("aria-expanded"); - if (expanded) return hubGroup.addClass("is-collapsed"), void hubButton.attr("aria-expanded", "false").text("展开磁力搜索"); - magnetHubPromise || (magnetHubPromise = this.getBean("MagnetHubPlugin").createMagnetHub(context.carNum, { root: context.root })); - const hub = await magnetHubPromise; - if (context.isAlive() && !box.children().length) box.append(hub); - if (context.isAlive()) hubGroup.removeClass("is-collapsed"), hubButton.attr("aria-expanded", "true").text("收起磁力搜索"), box[0]?.scrollIntoView?.({ block: "nearest" }); - })); - detailStateController.bind({ root: context.root, layerIndex: context.layerIndex ?? null, carNum: context.carNum, activityType: "fc2-state", getRecord: /* @__PURE__ */ __name(() => ({ carNum: context.carNum, url: context.url, fc2Source: context.source, names: context.root.find('[data-jhs-role="actress-data"]').text(), publishTime: context.root.find('[data-jhs-role="publish-time"]').text() }), "getRecord") }); - void this.getBean("FilterTitleKeywordPlugin").bindDetailRoot(context.root, { layerIndex: context.layerIndex ?? null }); - void this.getBean("OtherSitePlugin").loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: false, isActive: context.isAlive }).then(((box) => { - if (context.isAlive() && !box) sitesGroup.remove(); - })).catch(((error) => { - context.isAlive() && sitesGroup.remove(), clog.error("FC2 外部站点加载失败", error); - })); - void this.getBean("ScreenShotPlugin").loadInto(screenshot, context.carNum.replace("FC2-", ""), { isActive: context.isAlive }).then(((result) => { - if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); - })); - "123av" === context.source ? void this.getBean("Fc2By123AvPlugin").loadDetail(context, context.url) : void this.loadNativeDetail(context); + getMagnetTagRules() { + return this.getArray("magnetTagRules"); } - createResourceGroup(title, role) { - return $('

').find("h3").text(title).end().find("div").attr("data-jhs-role", role).end(); + saveMagnetTagRules(value) { + value.forEach(validateRule); + return this.saveArray("magnetTagRules", value); } - setState(target, message, retry = null) { - const host = $(target).empty(), state = $('
').text(message); - retry && state.addClass("is-error").append(" ", $('').on("click", retry)), host.append(state); + getMagnetFilterRules() { + return this.getArray("magnetFilterRules"); } - async loadNativeDetail(context) { - const movieIdPromise = Promise.resolve(context.movieId); - this.configureJavDbWantButton(context, movieIdPromise), await Promise.allSettled([this.fetchAndRenderNativeDetail(context), this.fetchAndRenderNativeMagnets(context), this.mountPanels(context, movieIdPromise)]); + saveMagnetFilterRules(value) { + value.forEach(validateRule); + return this.saveArray("magnetFilterRules", value); } - /** 绑定当前工作区自己的 JavDB“想看”操作。 */ - async configureJavDbWantButton(context, movieIdPromise) { - const button = context.root.find('[data-jhs-action="javdb-want"]'); - try { - const movieId = await movieIdPromise; - if (!context.isAlive()) return; - if (!movieId) return void button.prop("disabled", true).text("JavDB 暂无对应作品"); - button.prop("disabled", false).text("JavDB 想看").off(`click${context.namespace}`).on(`click${context.namespace}`, (() => void this.submitJavDbWant(context, movieId, button))); - } catch (error) { - context.isAlive() && button.prop("disabled", false).text("JavDB 关联失败,重试").off(`click${context.namespace}`).on(`click${context.namespace}`, (() => void this.configureJavDbWantButton(context, resolveJavDbMovieId(context.carNum)))), clog.error("FC2 JavDB 想看关联失败", error); - } + async getBuiltInSources() { + return await this.getArray("magnetBuiltInSources"); } - async submitJavDbWant(context, movieId, button) { - if (!context.isAlive() || button.data("jhsBusy") || "true" === button.attr("aria-pressed")) return; - button.data("jhsBusy", true).attr({ "aria-busy": "true", "aria-disabled": "true" }).text("正在加入想看…"); - try { - await markJavDbWantWatch(movieId); - if (!context.isAlive()) return; - button.attr({ "aria-pressed": "true", "aria-disabled": "false" }).text("已加入 JavDB 想看"), show.ok("已加入 JavDB 想看"); - } catch (error) { - if (!context.isAlive()) return; - if ("LOGIN_REQUIRED" === error?.code) { - button.attr("aria-disabled", "false").text("JavDB 想看"); - const loginPlugin = this.getBean("TOP250Plugin"); - return loginPlugin?.openLoginDialog({ onSuccess: /* @__PURE__ */ __name(() => this.submitJavDbWant(context, movieId, button), "onSuccess") }); - } - button.attr("aria-disabled", "false").text("JavDB 想看"), show.error(error?.message || "加入 JavDB 想看失败"), clog.error("加入 JavDB 想看失败", error); - } finally { - context.isAlive() && button.removeData("jhsBusy").removeAttr("aria-busy"); - } + saveBuiltInSources(value) { + value.forEach(((source) => { + if (!MAGNET_SOURCE_IDS.includes(source.id)) throw new TypeError("未知的内置磁力源"); + if (source.baseUrl) validateHttpsBaseUrl(source.baseUrl); + if (source.priority != null && (!Number.isFinite(Number(source.priority)) || Number(source.priority) < 1)) throw new TypeError("来源优先级无效"); + })); + return this.saveArray("magnetBuiltInSources", value); } - async fetchAndRenderNativeDetail(context) { - try { - const movie = await V(context.movieId); - if (!context.isAlive()) return; - this.renderSummary(context, movie), this.renderGallery(context, movie.imgList || []), await this.getBean("TranslatePlugin").translate(movie.carNum || context.carNum, false, { root: context.root }); - } catch (error) { - context.isAlive() && this.setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); - } + async getScreenshotSettings() { + return { mode: await this.storage.getSetting("screenshotMode", "auto"), providers: await this.getArray("screenshotProviders") }; } - renderSummary(context, movie) { - const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

'); - title.find("strong").text(movie.title || "无标题"), body.append(title); - const meta = $('
'); - [`番号:${movie.carNum || context.carNum}`, `发行:${movie.releaseDate || "未知"}`, `评分:${Number.isFinite(Number(movie.score)) ? movie.score : "无"}`, `时长:${Number.isFinite(Number(movie.duration)) ? movie.duration + " 分钟" : "无"}`].forEach(((value) => meta.append($("").text(value)))), body.append(meta); - const actors = $('
主演:
'), actressNames = []; - (movie.actors || []).forEach(((actor) => { - actors.append($("").addClass("jhs-fc2-actor").attr({ href: `/actors/${encodeURIComponent(actor.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(actor.name || "未知演员")), 0 === actor.gender && actressNames.push(actor.name); - })); - movie.actors?.length || actors.append($("").text("暂无演员信息")), body.append(actors, this.createSourceLinks(context), $('').text(actressNames.join(" ")), $('').text(movie.releaseDate || "")); + async saveScreenshotSettings(value) { + await this.storage.saveSettingItem("screenshotMode", value.mode); + await this.saveArray("screenshotProviders", value.providers); } - createSourceLinks(context) { - const id = String(context.carNum || "").replace(/^FC2-(?:PPV-)?/i, ""), links = $(''), values = [["123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeHttpUrl(context.url)], ["FC2PPVDB", `https://fc2ppvdb.com/articles/${encodeURIComponent(id)}`], ["FC2 市场", `https://adult.contents.fc2.com/article/${encodeURIComponent(id)}/`]]; - values.forEach((([label, href]) => href && links.append($("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label)))); - return links; + async getCloudSettings() { + return { enable123Offline: Boolean(await this.storage.getSetting("enable123Offline", true)), enable115Offline: Boolean(await this.storage.getSetting("enable115Offline", false)), enable115Match: Boolean(await this.storage.getSetting("enable115Match", false)), enable115LoginRedirect: Boolean(await this.storage.getSetting("enable115LoginRedirect", false)), providerMode: await this.storage.getSetting("offlineProviderMode", "ask"), concurrency: Number(await this.storage.getSetting("oneOneFiveConcurrency", 4)), cacheMinutes: Number(await this.storage.getSetting("oneOneFiveCacheMinutes", 60)) }; } - renderGallery(context, images) { - const urls = [...new Set((images || []).map(((url) => normalizeHttpUrl(url))).filter(Boolean))], grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); - if (!urls.length) return this.setState(grid, "暂无剧照"); - preview.append($("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); - urls.forEach(((url, index) => grid.append($('').attr("aria-label", `查看剧照 ${index + 1}`).append($("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" }))))); + async saveCloudSettings(value) { + for (const [key, item] of Object.entries({ enable123Offline: value.enable123Offline, enable115Offline: value.enable115Offline, enable115Match: value.enable115Match, enable115LoginRedirect: value.enable115LoginRedirect, offlineProviderMode: value.providerMode || "ask", oneOneFiveConcurrency: value.concurrency, oneOneFiveCacheMinutes: value.cacheMinutes })) await this.storage.saveSettingItem(key, item); } - async fetchAndRenderNativeMagnets(context, movieId = context.movieId) { - const host = context.root.find('[data-jhs-role="native-magnets"]'); - this.setState(host, "正在加载站内磁力…"); + async exportConfig() { + return { customMagnetSources: await this.getMagnetSources(), magnetTagRules: await this.getMagnetTagRules(), magnetFilterRules: await this.getMagnetFilterRules(), magnetBuiltInSources: await this.getBuiltInSources(), screenshot: await this.getScreenshotSettings() }; + } + async importConfig(text) { + let value; try { - if (!movieId) return this.setState(host, "JavDB 暂无对应作品"); - const response = await gmHttp.get(`${U}/v1/movies/${movieId}/magnets`, null, { jdSignature: await O() }), magnets = response?.data?.magnets || []; - if (!context.isAlive()) return; - host.empty(); - if (!magnets.length) return this.setState(host, "暂无站内磁力"); - const highlighter = this.getBean("HighlightMagnetPlugin"), assessments = []; - magnets.forEach(((item) => { - const hash = normalizeBtihHash(item.hash); - if (!hash) return; - const magnet = `magnet:?xt=urn:btih:${hash}`, assessment = highlighter.assessMagnet({ title: item.name, hasHdTag: !!item.hd, hasSubtitleTag: !!item.cnsub, date: item.created_at, seeders: Number(item.seeders) || 0 }), row = $('
').attr("data-jhs-high-quality", String(assessment.highQuality)), info = $('
'), actions = $('
'), tags = $('
'); - assessments.push(assessment), tags.append($("").addClass("jhs-badge").attr("title", `磁力质量评分 ${assessment.score.total}`).text(`${assessment.grade} ${assessment.score.total}`)), item.hd && tags.append('高清'), item.cnsub && tags.append('字幕'); - info.append($("").attr("href", magnet).text(item.name || magnet), $("
").addClass("jhs-fc2-meta").text(`${(Number(item.size || 0) / 1024).toFixed(2)} GB · ${Number(item.files_count) || 0} 个文件${item.created_at ? ` · ${item.created_at}` : ""}`), tags), actions.append($('').attr("data-clipboard-text", magnet), $('').attr({ "data-resource": magnet, "data-jhs-offline-owner": "fc2" })), host.append(row.append(info, actions)); - })); - await this.bindNativeMagnetFilter(context, host, assessments.some(((item) => item.highQuality))); + value = JSON.parse(text); } catch (error) { - context.isAlive() && this.setState(host, "站内磁力加载失败", (() => void this.fetchAndRenderNativeMagnets(context, movieId))), clog.error("FC2 磁力加载失败", error); + throw new TypeError(`配置格式错误:${error.message}`); } - } - async bindNativeMagnetFilter(context, host, hasMatch) { - const section = context.getSection("resources"), actions = section.find(".jhs-fc2-section__actions"), old = actions.find('[data-jhs-action="filter-native-magnets"]'); - old.remove(); - const button = $(''), apply = /* @__PURE__ */ __name((enabled) => { - host.find(".jhs-fc2-magnet-item").show(); - enabled && hasMatch && host.find('.jhs-fc2-magnet-item[data-jhs-high-quality="false"]').hide(); - button.attr("aria-pressed", String(enabled && hasMatch)).text(hasMatch ? enabled ? "显示全部磁力" : "过滤低质量" : "暂无可过滤项").prop("disabled", !hasMatch); - }, "apply"); - actions.append(button), apply(await storageManager.getSetting("enableMagnetsFilter", _) === _), button.on(`click${context.namespace}`, (async () => { - const enabled = "true" !== button.attr("aria-pressed"); - apply(enabled), await storageManager.saveSettingItem("enableMagnetsFilter", enabled ? _ : C); - })); - } - async mountPanels(context, movieIdPromise) { - try { - const movieId = await movieIdPromise; - if (!context.isAlive()) return; - if (!movieId) return this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"), this.setState(context.getSlot("reviews"), "JavDB 暂无对应作品"), this.setState(context.getSlot("related"), "JavDB 暂无对应作品"); - this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); - await Promise.allSettled([this.getBean("ReviewPlugin").showReview(movieId, context.getSlot("reviews"), { ownedSection: context.getSection("reviews"), isActive: context.isAlive }), this.getBean("RelatedPlugin").showRelated(context.getSlot("related"), movieId, { ownedSection: context.getSection("related"), isActive: context.isAlive })]); - } catch (error) { - if (!context.isAlive()) return; - this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); - const retry = /* @__PURE__ */ __name(() => void this.mountPanels(context, resolveJavDbMovieId(context.carNum)), "retry"); - this.setState(context.getSlot("reviews"), "评论关联失败", retry), this.setState(context.getSlot("related"), "相关清单关联失败", retry), clog.error("FC2 JavDB 关联失败", error); + if (!value || "object" !== typeof value || Array.isArray(value)) throw new TypeError("配置格式错误:根节点必须是对象"); + const operations = []; + if (Object.hasOwn(value, "customMagnetSources")) { + if (!Array.isArray(value.customMagnetSources)) throw new TypeError("自定义磁力源必须是数组"); + value.customMagnetSources.forEach(validateCustomMagnetSource); + operations.push(() => this.saveMagnetSources(value.customMagnetSources)); } - } - clearOwnedPanel(context, name) { - context.getSlot(name).empty(), context.getSection(name).find(".jhs-fc2-section__actions").empty(); - } - async resolveFc2Source(record = {}) { - if (["fc2", "123av"].includes(record.fc2Source)) return record.fc2Source; - try { - return new URL(record.url, window.location.origin).hostname === new URL(await this.getBean("OtherSitePlugin").getAv123Url()).hostname ? "123av" : "fc2"; - } catch (error) { - return "fc2"; + if (Object.hasOwn(value, "magnetTagRules")) { + if (!Array.isArray(value.magnetTagRules)) throw new TypeError("标签规则必须是数组"); + value.magnetTagRules.forEach(validateRule); + operations.push(() => this.saveMagnetTagRules(value.magnetTagRules)); } - } - async openFc2Page(movieId, carNum, url, navigation = { newTab: true }, { source = "fc2" } = {}) { - const baseUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); - utils.openPage(`${baseUrl}/users/collection_codes?movieId=${movieId || ""}&carNum=${encodeURIComponent(carNum)}&url=${encodeURIComponent(url)}&source=${encodeURIComponent(source)}`, carNum, true, navigation); + if (Object.hasOwn(value, "magnetFilterRules")) { + if (!Array.isArray(value.magnetFilterRules)) throw new TypeError("过滤规则必须是数组"); + value.magnetFilterRules.forEach(validateRule); + operations.push(() => this.saveMagnetFilterRules(value.magnetFilterRules)); + } + if (Object.hasOwn(value, "magnetBuiltInSources")) { + if (!Array.isArray(value.magnetBuiltInSources)) throw new TypeError("内置磁力源配置必须是数组"); + operations.push(() => this.saveBuiltInSources(value.magnetBuiltInSources)); + } + if (Object.hasOwn(value, "screenshot")) { + if (!value.screenshot || !["auto", "manual"].includes(value.screenshot.mode) || !Array.isArray(value.screenshot.providers)) throw new TypeError("截图配置无效"); + operations.push(() => this.saveScreenshotSettings(value.screenshot)); + } + for (const operation of operations) await operation(); + return value; } }; - __name(_Fc2Plugin, "Fc2Plugin"); - var Fc2Plugin = _Fc2Plugin; - var _HighlightMagnetPlugin = class _HighlightMagnetPlugin extends BasePlugin { - async handle() { - window.isDetailPage && jhsEventBus.on("magnet-items-updated", (() => { - void storageManager.getSetting("enableMagnetsFilter", _).then(((enabled) => enabled === _ ? this.doFilterMagnet() : this.showAll())); - })); - } - async initCss() { - return ``; + __name(_ResourceSettingsService, "ResourceSettingsService"); + var ResourceSettingsService = _ResourceSettingsService; + + // src/plugins/backup/webdav-client.js + var _WebDavClient = class _WebDavClient { + constructor(e2, t2, n2) { + this.davUrl = e2.endsWith("/") ? e2 : e2 + "/", this.username = t2, this.password = n2, this.folderName = null; } - getName() { - return "HighlightMagnetPlugin"; + _getAuthHeaders() { + return { + Authorization: `Basic ${btoa(`${this.username}:${this.password}`)}`, + Depth: "1" + }; } - doFilterMagnet() { - this.handleDb(), this.handleBus(); + _sendRequest(e2, t2, n2 = {}, a2) { + return new Promise(((i2, s2) => { + const o2 = this.davUrl + t2, r2 = { + ...this._getAuthHeaders(), + ...n2 + }; + GM_xmlhttpRequest({ + method: e2, + url: o2, + headers: r2, + data: a2, + onload: /* @__PURE__ */ __name((e3) => { + e3.status >= 200 && e3.status < 300 ? i2(e3) : (clog.error(e3), s2(new Error(`请求失败 ${e3.status}: ${e3.statusText}`))); + }, "onload"), + onerror: /* @__PURE__ */ __name((e3) => { + clog.error("请求WebDav发生错误:", e3), s2(new Error("请求WebDav失败, 请检查服务是否启动, 凭证是否正确")); + }, "onerror") + }); + })); } - /** 给磁力行注入评分徽章(幂等:已有则跳过) */ - injectScoreBadge(el, title) { + async _ensureFolder(e2) { try { - if (el.find(".jhs-magnet-score").length > 0) return; - const score = calcMagnetScore({ title: title || "", seeders: 0 }); - const total = score.total; - const label = total >= 70 ? "高" : total >= 40 ? "中" : "低"; - const color = total >= 70 ? "var(--jhs-status-down)" : total >= 40 ? "var(--jhs-status-watch)" : "var(--jhs-surface-2)"; - const onColor = total >= 70 ? "var(--jhs-status-down-on)" : total >= 40 ? "var(--jhs-status-watch-on)" : "var(--jhs-text-muted)"; - const tip = `分辨率:${score.resolution}/25 字幕:${score.subtitle}/20 做种:${score.seeders}/35 新鲜度:${score.freshness}/15`; - const badge = $(`${label} ${total}`).css({ color: onColor, backgroundColor: color }); - el.append(badge); - } catch (e2) { - clog.debug("磁力评分徽章注入失败,已忽略", e2); + await this._sendRequest("MKCOL", e2); + } catch (t2) { + if (!/请求失败 (405|409):/.test(t2.message)) throw t2; } } - getQualitySignals(title, hasSubtitleTag = false) { - const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); - return { resolution, subtitle, recognized: !!resolution || subtitle, highQuality: "4k" === resolution || "2160p" === resolution || subtitle }; + async backup(e2, t2, n2) { + await this._ensureFolder(e2); + const a2 = e2 + "/" + t2; + await this._sendRequest("PUT", a2, { + "Content-Type": "text/plain" + }, n2); } - /** 返回与 DOM 无关的磁力质量结果,供自有详情工作区复用。 */ - assessMagnet({ title = "", hasHdTag = false, hasSubtitleTag = false, date = null, seeders = 0 } = {}) { - const signals = this.getQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; - const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); - return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; + async getFileList(e2) { + var t2, n2, a2; + const i2 = (await this._sendRequest("PROPFIND", e2, { + "Content-Type": "application/xml" + }, '\n \n \n \n \n \n \n \n \n \n ')).responseText, s2 = new DOMParser().parseFromString(i2, "text/xml").getElementsByTagNameNS("DAV:", "response"), o2 = []; + for (let r2 = 0; r2 < s2.length; r2++) { + if (0 === r2) continue; + let e3 = s2[r2]; + const displayNameNode = e3.getElementsByTagNameNS("DAV:", "displayname")[0]; + const href = e3.getElementsByTagNameNS("DAV:", "href")[0]?.textContent || ""; + const fallbackName = decodeURIComponent(href.replace(/\/$/, "").split("/").pop() || ""); + const i3 = displayNameNode?.textContent || fallbackName, l2 = (null == (t2 = e3.getElementsByTagNameNS("DAV:", "getcontentlength")[0]) ? void 0 : t2.textContent) || "0", c2 = (null == (n2 = e3.getElementsByTagNameNS("DAV:", "creationdate")[0]) ? void 0 : n2.textContent) || (null == (a2 = e3.getElementsByTagNameNS("DAV:", "getlastmodified")[0]) ? void 0 : a2.textContent) || ""; + "0" !== l2 && o2.push({ + fileId: i3, + name: i3, + size: Number(l2), + createTime: c2 + }); + } + return o2.reverse(), o2; } - updateFilterHint(hasMatch) { - $("#enable-magnets-filter").removeClass("do-hide").attr("data-tip", hasMatch ? "仅显示识别到的高质量或字幕磁力" : "未识别到可过滤项,当前未隐藏磁力"); + async deleteFile(e2) { + let t2 = this.folderName + "/" + encodeURI(e2); + await this._sendRequest("DELETE", t2, { + "Cache-Control": "no-cache" + }); } - handleDb() { - if (!r) return; - let e2 = $("#magnets-content .name"); - if (0 === e2.length) return void this.updateFilterHint(false); - let n2 = false; - e2.each(((e3, a2) => { - const i2 = $(a2), s2 = i2.text().toLowerCase(), o2 = this.getQualitySignals(s2); - const row = i2.parent().parent().parent(); - row.removeClass("high-quality").show(); - row.addClass("magnet-row"), s2.includes("4k") && i2.css("color", "var(--jhs-status-filter-text)"), o2.highQuality && (n2 = true, row.addClass("high-quality")); - this.injectScoreBadge(i2, i2.text()); - })), n2 && $("#magnets-content .magnet-row").not(".high-quality").hide(), this.updateFilterHint(n2); + async getBackupList(e2) { + return this.folderName = e2, await this._ensureFolder(e2), this.getFileList(e2); } - handleBus() { - if (l && isDetailPage) { - const e2 = $("#magnet-table tr"); - let n2 = false; - e2.each(((e3, a2) => { - const i2 = $(a2), s2 = i2.find("td:first-child"), o2 = s2.find("a:first-child"), r2 = s2.find("a:nth-child(2)"), l2 = o2.text().toLowerCase(); - i2.removeClass("high-quality").show(); - l2.includes("4k") && o2.css("color", "var(--jhs-status-filter-text)"); - this.getQualitySignals(l2, r2.length > 0 && r2.text().includes("字幕")).highQuality && (n2 = true, i2.addClass("high-quality")); - this.injectScoreBadge(o2, o2.text()); - })); - n2 && e2.each(((e3, t2) => { - const n3 = $(t2); - n3.hasClass("high-quality") || n3.hide(); - })), this.updateFilterHint(n2); - } + async getFileContent(e2) { + let t2 = this.folderName + "/" + e2; + return (await this._sendRequest("GET", t2, { + Accept: "application/octet-stream" + })).responseText; } - showAll() { - $("#enable-magnets-filter").removeClass("do-hide").removeAttr("data-tip"); - if (r) { - $("#magnets-content .item").toArray().forEach(((e2) => $(e2).show())); - } - l && $("#magnet-table tr").toArray().forEach(((e2) => $(e2).show())); + }; + __name(_WebDavClient, "WebDavClient"); + var WebDavClient = _WebDavClient; + + // src/core/cache-policy.js + var HOUR = 60 * 60 * 1e3; + var DAY = 24 * HOUR; + var CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); + var _ProviderError = class _ProviderError extends Error { + /** @param {string} provider @param {string} code @param {string} message @param {{cause?: unknown, status?: number, url?: string, retryable?: boolean}} [options] */ + constructor(provider, code, message, options = {}) { + super(message, { cause: options.cause }); + this.name = "ProviderError"; + this.provider = provider; + this.code = code; + this.status = options.status || 0; + this.url = options.url || ""; + this.retryable = Boolean(options.retryable); } }; - __name(_HighlightMagnetPlugin, "HighlightMagnetPlugin"); - var HighlightMagnetPlugin = _HighlightMagnetPlugin; - var _FoldCategoryPlugin = class _FoldCategoryPlugin extends BasePlugin { - getName() { - return "FoldCategoryPlugin"; + __name(_ProviderError, "ProviderError"); + var ProviderError = _ProviderError; + + // src/plugins/image-viewer/preview-video.js + var Z = /* @__PURE__ */ __name((e2, t2) => { + if (!e2 || 0 === e2.length) return null; + const n2 = new Set(e2); + if (n2.has(t2)) return t2; + const a2 = L.map(((e3) => e3.quality)).reverse(); + for (const i2 of a2) if (n2.has(i2)) return i2; + return e2[0]; + }, "Z"); + var ee = "jhs_dmm_video"; + var _DmmPreviewParser = class _DmmPreviewParser { + constructor(e2) { + this.carNum = e2, this.lastError = null; } - async initCss() { - const e2 = await storageManager.getSetting(); - return ` - - `; + _checkCache() { + const e2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; + return e2[this.carNum] ? (clog.debug("缓存中存在预览视频信息", e2[this.carNum]), e2[this.carNum]) : null; } - async handle() { - window.isListPage && (o.includes("advanced_search") || (this.highlightTag(), utils.loopDetector((() => $("#waitCheckBtn").length), (() => { - this.createFoldBtn(); - }), 1, 1e4, true), $("#tags .tag-category .tag-expand").each(((e2, t2) => { - $(t2).parent().hasClass("collapse") && t2.click(); - })))); + _updateCache(e2) { + const t2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; + t2[this.carNum] = e2, clog.debug("成功解析出预览视频并已缓存:", e2), localStorage.setItem(ee, JSON.stringify(t2)); } - highlightTag() { - (async () => { - const e2 = await storageManager.getHighlightedTags(); - e2 && e2.forEach(((e3) => { - $(`#tags a.tag:contains(${e3})`).addClass("highlighted"), $(`.tags a.tag:contains(${e3})`).addClass("highlighted"); - })); - })().catch(((error) => clog.error("分类高亮恢复失败", error))), $("#tags a.tag, .tags a.tag").hover((function() { - const e2 = $(this), t2 = $(''); - e2.append(t2), t2.fadeIn(0); - }), (function() { - $(this).find(".highlight-btn").fadeOut(0, (function() { - $(this).remove(); - })); - })), $(document).on("click", ".highlight-btn", (async function(e2) { - e2.stopPropagation(), e2.preventDefault(); - const t2 = $(this).closest("a.tag"), n2 = t2.clone(); - n2.find(".highlight-btn").remove(); - const a2 = n2.text().trim().replace(/\s*\(\d+\)$/, ""); - let i2 = await storageManager.getHighlightedTags(); - i2.includes(a2) ? (i2 = i2.filter(((e3) => e3 !== a2)), t2.removeClass("highlighted")) : (i2.push(a2), t2.addClass("highlighted")), await storageManager.setHighlightedTags(i2); - })); + async _searchContentIds() { + const e2 = this.carNum, t2 = e2.replace(/-/g, ""), n2 = [{ + keyword: e2.replace("-", "00"), + name: "00-替换关键词" + }, { + keyword: e2, + name: "原始番号关键词" + }, { + keyword: t2, + name: "无连字符关键词" + }], a2 = e2.toLowerCase(); + let hadSuccessfulRequest = false; + for (const o2 of n2) { + const { keyword: e3, name: n3 } = o2, i3 = e3.toLowerCase(); + clog.debug(`--- 尝试使用 ${n3} (${e3}) 进行 API 搜索 ---`); + const r2 = `https://api.dmm.com/affiliate/v3/ItemList?${new URLSearchParams({ + api_id: "UrwskPfkqQ0DuVry2gYL", + affiliate_id: "10278-996", + output: "json", + site: "FANZA", + sort: "match", + keyword: e3 + }).toString()}`; + let l2; + try { + l2 = await gmHttp.get(r2); + hadSuccessfulRequest = true; + } catch (s2) { + this.lastError = new ProviderError("dmm", "HTTP_ERROR", `DMM API 请求失败: ${s2.message || s2}`, { + cause: s2, + url: r2, + status: s2?.status, + retryable: true + }), clog.error(`API 请求失败,跳过 ${n3}:`, this.lastError); + continue; + } + if (!l2 || !l2.result || !l2.result.result_count) { + clog.debug("API 返回无结果,尝试下一个关键词。"); + continue; + } + const c2 = []; + for (const s2 of l2.result.items) { + if (c2.length >= 2) break; + const e4 = s2.content_id || "", o3 = s2.maker_product || ""; + (e4.includes(i3.replace("-", "")) || a2 === o3.toLowerCase() || e4.includes(t2.toLowerCase())) && (c2.push({ + serviceCode: s2.service_code, + floorCode: s2.floor_code, + contentId: e4, + pageUrl: s2.URL + }), clog.debug(`[${n3}] cid|makerProduct 匹配成功:`, e4, o3)); + } + if (c2.length > 0) { + clog.debug(`--- 成功通过 ${n3} 找到 Content IDs ---`); + const t3 = $("#fanzaBtn"); + let a3 = `https://www.dmm.co.jp/search/=/searchstr=${e3}`, i4 = "single"; + c2.length > 1 ? (t3.attr("href", a3), t3.append('多结果'), t3.css("backgroundColor", "var(--jhs-status-down)"), i4 = "multiple") : (a3 = c2[0].pageUrl, t3.attr("href", a3), t3.css("backgroundColor", "var(--jhs-status-down)")); + const s2 = "jhs_other_site_dmm", o3 = localStorage.getItem(s2) ? JSON.parse(localStorage.getItem(s2)) : {}; + return o3[this.carNum] = { + type: i4, + url: a3 + }, localStorage.setItem(s2, JSON.stringify(o3)), c2; + } + clog.debug(`[${n3}] API 返回结果数 ${l2.result.result_count},但无精确匹配的 Content ID。`); + } + hadSuccessfulRequest && (this.lastError = null); + clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"); + const i2 = $("#fanzaBtn"); + return i2.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), i2.attr("title", "未查询到, 点击前往搜索页"), i2.css("backgroundColor", "var(--jhs-status-filter)"), null; } - async createFoldBtn() { - let t2 = $("#tags"), n2 = $("#tags dl div.tag.is-info").map((function() { - return $(this).text().replaceAll("\n", "").replaceAll(" ", ""); - })).get().join(" "); - if (!n2) return; - $(".tabs").append(` -
-
已选分类: ${n2}
- - -
- `); - let a2 = $("h2.section-title"); - if (a2.length > 0 && (a2.append('\n
\n \n
\n '), t2 = $("section > div > div.box")), !t2) return; - let i2 = $("#foldCategoryBtn"), s2 = localStorage.getItem("jhs_foldCategory") === _, [o2, r2] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; - i2.find("span").text(o2).end().find("i").attr("class", r2), window.location.href.includes("noFold=1") || t2[s2 ? "hide" : "show"](), i2.on("click", (async (e2) => { - e2.preventDefault(), s2 = !s2, localStorage.setItem("jhs_foldCategory", s2 ? _ : C); - const [n3, a3] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; - i2.find("span").text(n3).end().find("i").attr("class", a3), t2[s2 ? "hide" : "show"](); - })); + async _extractTrailerLinks({ contentId: e2, serviceCode: t2, floorCode: n2 }) { + const a2 = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e2}/mtype=AhRVShI_/service=${t2}/floor=${n2}/mode=/`, i2 = await gmHttp.get(a2, null, { + "accept-language": "ja-JP,ja;q=0.9", + Cookie: "age_check_done=1" + }); + if ("string" != typeof i2) throw clog.error(i2), new ProviderError("dmm", "PARSE_ERROR", "解析播放页内容失败, 非文本内容", { + url: a2 + }); + if (i2.includes("このサービスはお住まいの地域からは")) throw new ProviderError("dmm", "REGION_BLOCKED", "DMM 预览源不可用,请将 DMM 域名分流到日本 IP", { + url: a2 + }); + const s2 = i2.match(/const\s+args\s+=\s+(.*);/); + if (!s2) throw new ProviderError("dmm", "PARSE_ERROR", "未在脚本中找到 const args = ... 变量", { + url: a2 + }); + let o2; + try { + ({ bitrates: o2 } = JSON.parse(s2[1])); + } catch (d2) { + throw new ProviderError("dmm", "PARSE_ERROR", `解析播放器脚本 JSON 失败: ${d2.message}`, { + cause: d2, + url: a2 + }); + } + const r2 = {}, l2 = L.map(((e3) => e3.quality)).join("|"), c2 = new RegExp(`(${l2})\\.mp4$`); + if (!Array.isArray(o2)) throw clog.error("解析画质链接失败: bitrates 字段不是一个数组或不存在"), new ProviderError("dmm", "PARSE_ERROR", "解析画质链接失败: bitrates 字段不是一个数组或不存在", { + url: a2 + }); + clog.debug("原始数据返回:", o2); + for (const h2 of o2) { + const e3 = null == h2 ? void 0 : h2.src; + if (!e3 || "string" != typeof e3 || !e3.endsWith(".mp4")) continue; + const t3 = e3.match(c2); + let n3 = ""; + t3 && t3[1] && (n3 = t3[1]), n3 && !r2[n3] && (r2[n3] = e3); + } + if (0 === Object.keys(r2).length) throw new ProviderError("dmm", "PARSE_ERROR", "未找到匹配要求的预览画质视频", { + url: a2 + }); + return r2; } - }; - __name(_FoldCategoryPlugin, "FoldCategoryPlugin"); - var FoldCategoryPlugin = _FoldCategoryPlugin; - var _ActressInfoPlugin = class _ActressInfoPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "apiUrl", "https://ja.wikipedia.org/wiki/"); + async fetchVideo() { + const carNum = normalizeCarNum(this.carNum); + if (!carNum) return clog.warn("跳过 DMM 解析:番号不可用"), null; + this.carNum = carNum; + const e2 = this._checkCache(); + if (e2) return e2; + let t2; + try { + const e3 = this.carNum.toLowerCase(); + if (e3.startsWith("heyzo") || /^(n\d+|\d+(-\d+)*)$/.test(e3) || /^n\d+$/.test(e3)) return clog.debug("无码番号类型,取消 DMM 解析"), null; + if (this.carNum.includes("VR-")) return clog.debug("VR 类型,取消 DMM 解析"), null; + t2 = await this._searchContentIds(); + } catch (n2) { + this.lastError = n2 instanceof ProviderError ? n2 : new ProviderError("dmm", "PARSE_ERROR", n2.message || String(n2), { + cause: n2 + }), clog.error("DMM API 搜索失败:", this.lastError); + const e3 = $("#fanzaBtn"); + return e3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), e3.attr("title", "未查询到, 点击前往搜索页"), e3.css("backgroundColor", "var(--jhs-status-filter)"), null; + } + if (!t2 || 0 === t2.length) return null; + try { + const e3 = await Promise.any(t2.map(((e4) => this._extractTrailerLinks(e4)))); + return this._updateCache(e3), e3; + } catch (a2) { + const e3 = a2.errors || [a2]; + this.lastError = e3.find(((e4) => "REGION_BLOCKED" === e4?.code)) || e3.find(((e4) => e4 instanceof ProviderError)) || new ProviderError("dmm", "PARSE_ERROR", e3[0]?.message || String(e3[0]), { + cause: e3[0] + }), clog.error(`解析失败: ${this.lastError.message}`, e3); + const t3 = $("#fanzaBtn"); + return t3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), t3.attr("title", "未查询到, 点击前往搜索页"), t3.css("backgroundColor", "var(--jhs-status-filter)"), null; + } } + }; + __name(_DmmPreviewParser, "DmmPreviewParser"); + var DmmPreviewParser = _DmmPreviewParser; + async function fetchDmmPreview(carNum) { + const parser = new DmmPreviewParser(carNum), sources = await parser.fetchVideo(); + return { + sources, + error: parser.lastError + }; + } + __name(fetchDmmPreview, "fetchDmmPreview"); + var _PreviewVideoPlugin = class _PreviewVideoPlugin extends BasePlugin { getName() { - return "ActressInfoPlugin"; - } - async handle() { - "yes" === await storageManager.getSetting("enableLoadActressInfo", "yes") && await this.loadActressInfo(); - } - async loadActressInfo() { - await Promise.all([this.handleDetailPage(), this.handleStarPage()]); + return "PreviewVideoPlugin"; } async initCss() { - return "\n \n "; - } - async handleDetailPage() { - if ($(".actress-info").length > 0) return; - let e2 = $(".female").prev().map(((e3, t3) => $(t3).text().trim())).get(); - if (!e2.length) return; - const t2 = "jhs_actress_info", n2 = localStorage.getItem(t2) ? JSON.parse(localStorage.getItem(t2)) : {}; - let a2 = null, i2 = ""; - for (let o2 = 0; o2 < e2.length; o2++) { - let t3 = e2[o2]; - if (a2 = n2[t3], !a2) try { - a2 = await this.searchInfo(t3), a2 && (n2[t3] = a2); - } catch (s2) { - clog.error("该名称查询失败,尝试其它名称"); - } - let r2 = ""; - r2 = a2 ? ` - - ` : ` `, i2 += r2; - } - $('strong:contains("演員")').parent().after(i2), localStorage.setItem(t2, JSON.stringify(n2)); - } - async handleStarPage() { - if ($(".actress-info").length > 0) return; - let e2 = [], t2 = $(".actor-section-name"); - t2.length && t2.text().trim().split(",").forEach(((t3) => { - e2.push(t3.trim()); - })); - let n2 = $(".section-meta:not(:contains('影片'))"); - if (n2.length && n2.text().trim().split(",").forEach(((t3) => { - e2.push(t3.trim()); - })), !e2.length) return; - const a2 = "jhs_actress_info", i2 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}; - let s2 = null; - for (let l2 = 0; l2 < e2.length; l2++) { - let t3 = e2[l2]; - if (s2 = i2[t3], s2) break; - try { - s2 = await this.searchInfo(t3); - } catch (r2) { - clog.error("该名称查询失败,尝试其它名称"); - } - if (s2) break; - } - s2 && e2.forEach(((e3) => { - i2[e3] = s2; - })); - let o2 = '
无此相关演员信息
'; - s2 && (o2 = ` - -
-
- 出生日期: ${s2.birthday} - 年龄: ${s2.age} - 身高: ${s2.height} -
-
- 体重: ${s2.weight} - 三围: ${s2.threeSizeText} - 罩杯: ${s2.braSize} -
-
-
- `), t2.parent().append(o2), localStorage.setItem(a2, JSON.stringify(i2)); - } - async searchInfo(e2) { - "三上悠亞" === e2 && (e2 = "三上悠亜"); - let t2 = this.apiUrl + e2; - const n2 = await gmHttp.get(t2), a2 = new DOMParser(), i2 = $(a2.parseFromString(n2, "text/html")); - let s2 = i2.find('a[title="誕生日"]').parent().parent().find("td").text().trim(), o2 = i2.find("th:contains('現年齢')").parent().find("td").text().trim() ? parseInt(i2.find("th:contains('現年齢')").parent().find("td").text().trim()) + "岁" : "", r2 = i2.find('tr:has(a[title="身長"]) td').text().trim().split(" ")[0] + "cm", l2 = i2.find('tr:has(a[title="体重"]) td').text().trim().split("/")[1].trim(); - return "― kg" === l2 && (l2 = ""), { - birthday: s2, - age: o2, - height: r2, - weight: l2, - threeSizeText: i2.find('a[title="スリーサイズ"]').closest("tr").find("td").text().replace("cm", "").trim(), - braSize: i2.find('th:contains("ブラサイズ")').next("td").contents().first().text().trim(), - url: t2 - }; - } - }; - __name(_ActressInfoPlugin, "ActressInfoPlugin"); - var ActressInfoPlugin = _ActressInfoPlugin; - var _HitShowPlugin = class _HitShowPlugin extends BasePlugin { - constructor() { - super(), i(this, "$contentBox", $(".section .container")), i(this, "loadGeneration", 0); - } - getName() { - return "HitShowPlugin"; - } - async initCss() { - return ``; + return ".jhs-dmm-preview-player{display:none;width:100%;height:auto}.jhs-dmm-preview-player.is-active{display:block}.jhs-native-preview-hidden{display:none!important}"; } async handle() { - $('a[href*="rankings/playback"]').on("click", ((e2) => { - e2.preventDefault(), e2.stopPropagation(), window.location.href = "/advanced_search?handlePlayback=1&period=daily"; - })), await this.handlePlayback(); - } - hookPage() { - let e2 = $("h2.section-title"); - e2.contents().first().replaceWith("热播"), e2.addClass("jhs-hitshow-title"), e2.parent(".jhs-hitshow-heading").length || e2.wrap('
'), $(".empty-message").remove(), $(".section .container .box").remove(), $(".movie-list.jhs-hitshow-list").remove(), this.$contentBox.append('
'); + if (!isDetailPage) return; + const trigger = $(".preview-video-container"), openVideo = /* @__PURE__ */ __name(() => { + utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => { + this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))); + })); + }, "openVideo"); + trigger.off("click.jhsVideo").on("click.jhsVideo", openVideo); + await storageManager.getSetting("enableLoadPreviewVideo", _) !== _ || o.includes("autoPlay=1") || this.initDmm(); + const url = window.location.href; + (url.includes("gallery-1") || url.includes("gallery-2")) && openVideo(), url.includes("autoPlay=1") && trigger.length > 0 && trigger[0].click(); } - async handlePlayback() { - if (!isHitShowPage()) return; - const period = new URLSearchParams(window.location.search).get("period"), generation = ++this.loadGeneration; - this.hookPage(), this.toolBar(period); - const loadingObj = loading(); - let loadingClosed = false; + async initDmm() { try { - const movies = await this.fetchPlaybackWithRetry(period); - if (generation !== this.loadGeneration) return; - $(".movie-list").html(this.markDataListHtml(movies)); - await this.initializeRenderedList(); - await this.getBean("ListPageButtonPlugin").sortItems(); - loadingObj.close(), loadingClosed = true; - void this.loadScore(movies, generation).then((async () => { - if (generation === this.loadGeneration && "rateCount" === localStorage.getItem("jhs_sortMethod")) await this.getBean("ListPageButtonPlugin").sortItems(); - })).catch(((error) => clog.error("热播评分补全失败", error))); - } catch (error) { - clog.error("所有重试尝试均失败,无法获取数据。", error); - } finally { - loadingClosed || loadingObj.close(); - } - } - async fetchPlaybackWithRetry(period) { - let lastError; - for (let attempt = 1; attempt <= 3; attempt++) try { - return await W(period); + const { sources } = await this.getDmmPreview(); + if (!sources) return; + const $video = $("#preview-video"), video = $video[0]; + if (video) return; + clog.debug("JavDB没有视频播放元素, 开始创建..."); + const cover = $(".column-video-cover img").attr("src"); + $(".preview-images").prepend(` + + 预告片 + + + `), $(".preview-video-container").off("click.jhsVideo").on("click.jhsVideo", (() => { + utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))))); + })); } catch (error) { - lastError = error; - if (attempt < 3) clog.error(`获取热播数据失败 (第 ${attempt} 次重试)`, error), await new Promise(((resolve) => setTimeout(resolve, 1e3))); + clog.error("预加载 DMM 失败:", error); } - throw lastError; } - async initializeRenderedList() { - const listPage = this.getBean("ListPagePlugin"); - listPage.replaceHdImg(), await listPage.doFilter(), listPage.applyVisibility(), listPage.bindMovieDetailNavigation(listPage.getSelector().boxSelector); - this.getBean("CoverButtonPlugin").addSvgBtn(); + /** 复用单次 DMM 请求,避免预加载和点击处理重复抓取。 */ + getDmmPreview() { + if (this.dmmPreviewPromise) return this.dmmPreviewPromise; + this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum).then(((result) => { + (result.error?.retryable || "HTTP_ERROR" === result.error?.code) && (this.dmmPreviewPromise = null); + return result; + }), ((error) => { + this.dmmPreviewPromise = null; + throw error; + })); + return this.dmmPreviewPromise; } - toolBar(e2) { - $("#jhs-hitshow-period").remove(); - let t2 = ` - - `; - $(".jhs-hitshow-heading").append(t2); + /** 创建与 JavDB HLS 生命周期完全隔离的 DMM 播放器。 */ + createDmmPlayer($nativeVideo) { + const $host = $nativeVideo.parent(), existing = $host.find("#jhs-preview-video"); + if (existing.length) return existing; + const $player = $(''); + return $nativeVideo.after($player), $player; } - getStarRating(e2) { - let t2 = ""; - const n2 = Math.floor(e2); - for (let a2 = 0; a2 < n2; a2++) t2 += ''; - for (let a2 = 0; a2 < 5 - n2; a2++) t2 += ''; - return t2; + /** 销毁 JHS 播放器并把播放权完整交回 JavDB。 */ + async restoreNativePlayer($nativeVideo, nativeVideo, notify = false) { + const $dmmVideo = $nativeVideo.parent().find("#jhs-preview-video"), dmmVideo = $dmmVideo[0]; + dmmVideo && (dmmVideo.pause(), $dmmVideo.removeAttr("src"), dmmVideo.load(), $dmmVideo.remove()); + $nativeVideo.removeClass("jhs-native-preview-hidden"); + return safePlay(nativeVideo, { + context: "JavDB 原生预览回退", + notify + }); } - async loadScore(movies, generation = this.loadGeneration) { - if (0 === movies.length) return; - const cacheKey = "jhs_score_info"; - let cache = {}; - try { - cache = JSON.parse(localStorage.getItem(cacheKey) || "{}"); - } catch (error) { - clog.warn("评分缓存解析失败,将重新建立缓存", error); + async handleVideo() { + const $nativeVideo = $("#preview-video"); + if (!$nativeVideo.length) return; + const $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = localStorage.getItem("jhs_videoMuted"); + void safePlay(nativeVideo, { + context: "JavDB 原生预览", + notify: false + }); + const dmmEnabled = await storageManager.getSetting("enableLoadPreviewVideo", _) !== C, dmmResult = dmmEnabled ? await this.getDmmPreview() : { + sources: null, + error: null + }, { sources, error } = dmmResult, $toolbar = $("
").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
").addClass("jhs-video-quality-list").attr({ + role: "group", + "aria-label": "视频画质" + }); + $host.find("#video-bottom-toolbar").remove(); + let dmmPlayed = false, $dmmVideo = null, dmmVideo = null; + if (sources) { + const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; + const currentTime = nativeVideo.currentTime; + $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = !muted || "yes" === muted, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { + localStorage.setItem("jhs_videoMuted", dmmVideo.muted ? "yes" : "no"); + })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); + dmmPlayed = await safePlay(dmmVideo, { + context: "JavDB 高画质预览", + notify: false + }); + if (!dmmPlayed && !dmmVideo.muted) dmmVideo.muted = true, dmmPlayed = await safePlay(dmmVideo, { + context: "JavDB 高画质预览静音重试", + notify: false + }); + dmmPlayed ? (nativeVideo.pause(), $nativeVideo.addClass("jhs-native-preview-hidden")) : ($dmmVideo.removeClass("is-active"), await this.restoreNativePlayer($nativeVideo, nativeVideo, true)); + dmmPlayed && L.forEach(((quality) => { + const qualitySource = sources[quality.quality]; + if (!qualitySource) return; + const active = dmmPlayed && selectedQuality === quality.quality; + $qualityList.append($(``)); + })); } - const queue = [...movies], workers = Array.from({ length: Math.min(4, queue.length) }, (() => this.scoreWorker(queue, cache, generation))); - await Promise.all(workers), localStorage.setItem(cacheKey, JSON.stringify(cache)); - } - async scoreWorker(queue, cache, generation) { - for (; ; ) { - const movie = queue.shift(); - if (!movie) return; + $toolbar.append($qualityList); + const $actions = $("
").addClass("jhs-toolbar"); + $actions.append('', '', ''), $toolbar.append($actions), $host.append($toolbar), sources || await safePlay(nativeVideo, { + context: "JavDB 预览视频", + notify: true, + message: "REGION_BLOCKED" === error?.code ? error.message : "当前视频源无法播放" + }); + $toolbar.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (event) => { + const $button = $(event.currentTarget); + if ($button.hasClass("active")) return; try { - if (generation !== this.loadGeneration) return; - const id = movie.id; - if (!$(`#score_${id}`).length || $(`#${id}`).is(":hidden")) continue; - if (cache[id]) { - const cached = this.normalizeScoreData(cache[id]); - this.appendScore(id, cached.score, cached.watchedCount); - continue; - } - const result = await V(id); - if (generation !== this.loadGeneration) return; - const score = Number(result.score), watchedCount = Number(result.watchedCount); - this.appendScore(id, score, watchedCount), cache[id] = { score: Number.isFinite(score) ? score : 0, watchedCount: Number.isFinite(watchedCount) ? watchedCount : 0 }; - } catch (error) { - $(`#${movie.id}`).attr("data-jhs-rate-count", "0"), clog.error(`解析评分数据失败 | 编号: ${movie.number} -`, `错误详情: ${error.message} -`, error.stack ? `调用栈: -${error.stack}` : ""); + if (!dmmVideo) return; + const currentTime = dmmVideo.currentTime, previousSource = $dmmVideo.attr("src"); + $dmmVideo.attr("src", $button.data("video-src")), dmmVideo.load(), dmmVideo.currentTime = currentTime; + const played = await safePlay(dmmVideo, { + context: "JavDB 画质切换", + notify: false + }); + if (played) $toolbar.find(".jhs-video-quality-btn").removeClass("active").attr("aria-pressed", "false"), $button.addClass("active").attr("aria-pressed", "true"); + else { + previousSource && ($dmmVideo.attr("src", previousSource), dmmVideo.load(), dmmVideo.currentTime = currentTime); + const restored = previousSource && await safePlay(dmmVideo, { + context: "JavDB 画质切换回退", + notify: false + }); + restored || await this.restoreNativePlayer($nativeVideo, nativeVideo, true); + } + } catch (playbackError) { + clog.error("切换画质失败:", playbackError); } - } - } - normalizeScoreData(value) { - const html = "string" == typeof value ? value : String(value?.html || ""), score = Number(value?.score ?? (html.match(/([\d.]+)分/) || [0, 0])[1]), watchedCount = Number(value?.watchedCount ?? (html.match(/由(\d+)人/) || [0, 0])[1]); - return { score: Number.isFinite(score) ? score : 0, watchedCount: Number.isFinite(watchedCount) ? watchedCount : 0 }; + })), $("#speed-btn").off("click.jhsVideo").on("click.jhsVideo", (() => { + dmmVideo && (dmmVideo.currentTime += 10); + })), $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", ((event) => (event.preventDefault(), this.getDependency("DetailPageButtonPlugin").filterOne(event)))), $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getDependency("DetailPageButtonPlugin").filterOne(event))), $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getDependency("DetailPageButtonPlugin").favoriteOne(event))); } - appendScore(e2, score, watchedCount = 0) { - const safeScore = Math.min(5, Math.max(0, Number(score) || 0)), safeCount = Math.max(0, Number(watchedCount) || 0), card = $(`#${e2}`), target = $(`#score_${e2}`); - card.attr("data-jhs-rate-count", String(safeCount)); - if (!target.length || "" !== target.text().trim()) return; - const value = $(''), stars = $('').html(this.getStarRating(safeScore)); - value.append(stars, document.createTextNode(` ${safeScore}分,由${safeCount}人评价`)), target.hide().empty().append(value).slideDown(500); + }; + __name(_PreviewVideoPlugin, "PreviewVideoPlugin"); + var PreviewVideoPlugin = _PreviewVideoPlugin; + + // src/plugins/image-viewer/bus-preview-video.js + var ENCRYPTION_SALT = "x7k9p3"; + async function getEncryptionKey() { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(ENCRYPTION_SALT + ".jhs.v1"), { + name: "PBKDF2" + }, false, ["deriveKey"]); + return crypto.subtle.deriveKey({ + name: "PBKDF2", + salt: enc.encode("jhs-backup"), + iterations: 1e5, + hash: "SHA-256" + }, keyMaterial, { + name: "AES-GCM", + length: 256 + }, false, ["encrypt", "decrypt"]); + } + __name(getEncryptionKey, "getEncryptionKey"); + function arrayBufferToBase64(e2) { + const t2 = new Uint8Array(e2), n2 = 32768; + let a2 = ""; + for (let i2 = 0; i2 < t2.length; i2 += n2) a2 += String.fromCharCode.apply(null, t2.subarray(i2, i2 + n2)); + return btoa(a2); + } + __name(arrayBufferToBase64, "arrayBufferToBase64"); + function base64ToArrayBuffer(e2) { + const t2 = atob(e2), n2 = new Uint8Array(t2.length); + for (let a2 = 0; a2 < t2.length; a2++) n2[a2] = t2.charCodeAt(a2); + return n2; + } + __name(base64ToArrayBuffer, "base64ToArrayBuffer"); + async function encryptData(e2) { + const t2 = await getEncryptionKey(), n2 = crypto.getRandomValues(new Uint8Array(12)), a2 = new TextEncoder(), i2 = await crypto.subtle.encrypt({ + name: "AES-GCM", + iv: n2 + }, t2, a2.encode(e2)), s2 = new Uint8Array(n2.length + i2.byteLength); + return s2.set(n2), s2.set(new Uint8Array(i2), n2.length), arrayBufferToBase64(s2); + } + __name(encryptData, "encryptData"); + async function decryptData(e2) { + const t2 = await getEncryptionKey(), n2 = base64ToArrayBuffer(e2), a2 = n2.slice(0, 12), i2 = n2.slice(12), s2 = await crypto.subtle.decrypt({ + name: "AES-GCM", + iv: a2 + }, t2, i2); + return new TextDecoder().decode(s2); + } + __name(decryptData, "decryptData"); + var CREDENTIAL_PREFIX = "AES:"; + async function encryptCredential(e2) { + return e2 && !e2.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(e2) : e2; + } + __name(encryptCredential, "encryptCredential"); + async function decryptCredential(e2) { + return e2 && e2.startsWith(CREDENTIAL_PREFIX) ? await decryptData(e2.slice(CREDENTIAL_PREFIX.length)) : e2; + } + __name(decryptCredential, "decryptCredential"); + var _BusPreviewVideoPlugin = class _BusPreviewVideoPlugin extends BasePlugin { + getName() { + return "BusPreviewVideoPlugin"; } - markDataListHtml(e2) { - let t2 = ""; - return e2.forEach(((e3, index) => { - const coverUrl = normalizeHttpUrl(String(e3.cover_url || "").replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); - t2 += ` - - `; - })), t2; + async initCss() { + return "\n .bus-preview-modal { position:fixed; inset:0; z-index:var(--jhs-z-modal); display:flex; align-items:center; justify-content:center; visibility:hidden; opacity:0; background:rgba(0,0,0,.95); transition:opacity var(--jhs-motion-base) var(--jhs-ease); }\n .bus-preview-modal.is-open { visibility:visible; opacity:1; }\n .bus-preview-modal-content { position:relative; display:flex; max-width:95%; max-height:95%; flex-direction:column; align-items:center; gap:var(--jhs-space-3); }\n .video-player-wrapper { position:relative; width:80vw; max-width:100%; max-height:85vh; aspect-ratio:16/9; background:#000; }\n .video-player-wrapper #preview-video { position:absolute; inset:0; }\n "; } - }; - __name(_HitShowPlugin, "HitShowPlugin"); - var HitShowPlugin = _HitShowPlugin; - var me = "jhs_appAuthorization"; - var _Top250Plugin = class _Top250Plugin extends BasePlugin { - constructor() { - super(), i(this, "has_cnsub", ""), i(this, "$contentBox", $(".section .container")), i(this, "movies", []); + initModal() { + if (0 === $("#bus-preview-modal").length) { + $("body").append('\n
\n
\n
\n
\n '); + const e2 = $("#bus-preview-modal"); + e2.on("click", ((e3) => { + "bus-preview-modal" === e3.target.id && this.closeVideoModal(); + })), $(document).on("keydown", ((t2) => { + "Escape" === t2.key && e2.hasClass("is-open") && this.closeVideoModal(); + })); + } } - getName() { - return "TOP250Plugin"; + closeVideoModal() { + const e2 = $("#preview-video"); + e2.length > 0 && e2[0].pause(), $("#bus-preview-modal").removeClass("is-open"); } async handle() { - $('.main-tabs ul li:contains("猜你喜歡")').html('Top250'), $('a[href*="rankings/top"]').on("click", ((e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.target), n2 = (t2.is("a") ? t2 : t2.closest("a")).attr("href"); - let a2 = n2.includes("?") ? n2.split("?")[1] : n2; - const i2 = new URLSearchParams(a2); - this.checkLogin(e2, i2); - })), await this.handleTop(); + if (!isDetailPage) return; + this.initModal(); + const e2 = $("#sample-waterfall .sample-box .photo-frame img:first").attr("src"), t2 = $(` + `); + $("#sample-waterfall").prepend(t2); + "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum).catch(((e3) => clog.warn("预加载 DMM 失败", e3))); + let n2 = false, a2 = $(".preview-video-container"); + a2.on("click", (async (e3) => { + if (e3.preventDefault(), e3.stopPropagation(), n2) show.info("正在加载中, 勿重复点击"); + else { + n2 = true; + try { + await this.handleVideo(); + } finally { + n2 = false; + } + } + })), window.location.href.includes("autoPlay=1") && a2.trigger("click"); } - hookPage() { - $("h2.section-title").contents().first().replaceWith("Top250"), $(".empty-message").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('
'), this.$contentBox.append('
'), this.renderPagination(); + async handleVideo() { + const e2 = $("#bus-preview-modal"), t2 = e2.find(".bus-preview-modal-content"); + let n2 = $("#preview-video"); + if (n2.length > 0) return e2.addClass("is-open"), void await safePlay(n2[0], { + context: "JavBus 预览视频", + notify: true + }); + let a2 = this.getPageInfo().carNum; + const { sources: i2, error: previewError } = await fetchDmmPreview(a2); + i2 && 0 !== Object.keys(i2).length ? (await this.createVideoPlayerAndControls(i2, t2), n2 = $("#preview-video"), n2.length > 0 ? (e2.addClass("is-open"), await safePlay(n2[0], { + context: "JavBus 预览视频", + notify: true, + message: "REGION_BLOCKED" === previewError?.code ? previewError.message : "当前视频源无法播放" + })) : show.error("视频播放器创建失败。")) : show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未找到可用的视频源。"); } - renderPagination() { - const e2 = new URLSearchParams(window.location.search); - let t2 = parseInt(e2.get("page")) || 1; - this.$contentBox.append(((e3) => { - const t3 = e3 >= 5; - let n2 = ""; - for (let a2 = 1; a2 <= 5; a2++) { - n2 += `
  • `; + async createVideoPlayerAndControls(e2, t2) { + let n2 = await storageManager.getSetting("videoQuality"); + n2 = Z(Object.keys(e2), n2); + let a2 = e2[n2]; + t2.html(` +
    + +
    +
    +
    + `); + const i2 = $("#preview-video"), s2 = i2.find("source"), o2 = t2.find(".jhs-video-quality-list"); + if (!i2.length || !s2.length) return; + const r2 = i2[0], l2 = localStorage.getItem("jhs_videoMuted"); + r2.muted = !l2 || "yes" === l2, i2.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (function() { + localStorage.setItem("jhs_videoMuted", r2.muted ? "yes" : "no"); + })); + let c2 = ""; + L.forEach(((t3) => { + let a3 = e2[t3.quality]; + if (a3) { + const e3 = n2 === t3.quality; + c2 += ` + + `; + } + })), o2.html(c2); + const d2 = o2.find(".jhs-video-quality-btn"); + o2.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (e3) => { + try { + const t3 = $(e3.currentTarget); + if (t3.hasClass("active")) return; + let n3 = t3.attr("data-video-src"); + s2.attr("src", n3); + const a3 = r2.currentTime; + r2.load(), r2.currentTime = a3, await safePlay(r2, { + context: "JavBus 画质切换", + notify: true + }) && (d2.removeClass("active").attr("aria-pressed", "false"), t3.addClass("active").attr("aria-pressed", "true")); + } catch (t3) { + clog.error("切换画质失败:", t3); } - return ` - - `; - })(t2)), this.$contentBox.on("click", ".pagination-link, .pagination-previous, .pagination-next", ((t3) => { - t3.preventDefault(); - const n2 = parseInt($(t3.currentTarget).data("page")); - !isNaN(n2) && n2 > 0 && ((t4) => { - e2.set("page", t4), window.history.pushState({}, "", "?" + e2.toString()), window.location.reload(); - })(n2); })); } - async handleTop() { - if (!window.location.href.includes("handleTop=1")) return; - const e2 = new URLSearchParams(window.location.search); - let t2 = e2.get("handleType") || "all", n2 = e2.get("type_value") || ""; - this.has_cnsub = e2.get("has_cnsub") || ""; - let a2 = e2.get("page") || 1; - this.toolBar(t2, n2, a2), this.hookPage(); - let i2 = $(".movie-list"); - i2.html(""); - let s2 = loading(); - let o2 = false; - for (let l2 = 1; l2 <= 3 && !o2; l2++) try { - const e3 = await q(t2, n2, a2, 50); - let r2 = e3.success, l3 = e3.message, c2 = e3.action; - if (1 === r2) { - let t3 = e3.data.movies; - if (0 === t3.length) return show.error("无数据"), void s2.close(); - this.movies = t3; - const n3 = t3.filter(((e4) => "1" === this.has_cnsub ? e4.has_cnsub : "0" !== this.has_cnsub || !e4.has_cnsub)), a3 = this.getBean("HitShowPlugin"); - let r3 = a3.markDataListHtml(n3); - i2.html(r3), await a3.initializeRenderedList(), await a3.loadScore(n3), o2 = true; - } else clog.error(e3), i2.html(`

    ${escapeHtml(l3)}

    `), show.error(l3), "JWTVerificationError" === c2 && (await localStorage.removeItem(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o2 = true; - } catch (r2) { - l2 < 3 ? (clog.error(`获取Top数据失败 (第 ${l2} 次重试):`, r2), await new Promise(((e3) => setTimeout(e3, 1e3)))) : (clog.error("所有重试尝试均失败,无法获取Top数据。", r2), i2.html("

    无法加载数据,请稍后再试。

    ")); - } finally { - (o2 || 3 === l2) && s2.close(); - } + }; + __name(_BusPreviewVideoPlugin, "BusPreviewVideoPlugin"); + var BusPreviewVideoPlugin = _BusPreviewVideoPlugin; + + // src/plugins/backup/setting-backup.js + async function importSettingData(showDiffPreviewFn) { + try { + const input = document.createElement("input"); + input.type = "file", input.accept = ".json"; + const cleanup = /* @__PURE__ */ __name(() => input.remove(), "cleanup"); + input.onchange = async (e2) => { + const t2 = e2.target.files[0]; + if (!t2) return void cleanup(); + const n2 = new FileReader(); + n2.onload = async (e3) => { + cleanup(); + try { + const t3 = e3.target.result.toString(), n3 = JSON.parse(t3); + if (!n3 || "object" != typeof n3 || Array.isArray(n3)) throw new Error("文件内容不是有效的数据对象"); + const a2 = loading(); + try { + const e4 = await storageManager.exportData(), t4 = await storageManager.diffData(e4, n3); + a2.close(), showDiffPreviewFn(t4, n3, null); + } catch (i2) { + a2.close(), clog.error(i2), show.error("差异分析失败: " + i2.message); + } + } catch (t3) { + clog.error(t3), show.error("导入失败:文件内容不是有效的JSON格式 " + t3.message); + } + }, n2.onerror = () => { + cleanup(), show.error("读取文件时出错"); + }, n2.readAsText(t2); + }, document.body.appendChild(input), input.click(); + setTimeout(cleanup, 3e5); + } catch (e2) { + clog.error(e2), show.error("导入数据时出错: " + e2.message); } - toolBar(e2, t2, n2) { - "5" === n2.toString() && $(".pagination-next").remove(), $(".pagination-ellipsis").closest("li").remove(), $(".pagination-list li .pagination-link").each((function() { - parseInt($(this).text()) > 5 && $(this).closest("li").remove(); - })); - let years = ""; - for (let year = (/* @__PURE__ */ new Date()).getFullYear(); year >= 2008; year--) years += `${year}`; - const typeLink = /* @__PURE__ */ __name((value, label, type = "video_type") => `${label}`, "typeLink"); - const html = `
    `; - this.$contentBox.append(html), $("button[data-cnsub-value]").on("click", (async (event) => { - const value = $(event.currentTarget).data("cnsub-value"); - this.has_cnsub = value.toString(), $("button[data-cnsub-value]").removeClass("active").attr("aria-pressed", "false"), $(event.currentTarget).addClass("active").attr("aria-pressed", "true"), $(".jhs-top250-filters a").each(((index, element) => { - const link = $(element), url = new URL(link.attr("href"), window.location.origin); - url.searchParams.set("has_cnsub", value), link.attr("href", url.toString()); - })); - const movies = this.movies.filter(((movie) => "1" === this.has_cnsub ? movie.has_cnsub : "0" !== this.has_cnsub || !movie.has_cnsub)), hitShow = this.getBean("HitShowPlugin"); - $(".movie-list").html(hitShow.markDataListHtml(movies)), await hitShow.initializeRenderedList(), void hitShow.loadScore(movies).catch(((error) => clog.error("Top250 评分加载失败", error))); - })); + } + __name(importSettingData, "importSettingData"); + async function backupDataByWebDav(folderName) { + const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; + if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); + const a2 = t2.webDavUsername; + if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); + const i2 = await decryptCredential(t2.webDavPassword); + if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); + let s2 = utils.getNowStr("_", "_") + ".json", o2 = JSON.stringify(await storageManager.exportData()); + o2 = await encryptData(o2); + let r2 = loading(); + try { + const e2 = new WebDavClient(n2, a2, i2); + await e2.backup(folderName, s2, o2), show.ok("备份完成"); + } catch (l2) { + clog.error(l2), show.error(l2.toString()); + } finally { + r2.close(); } - async checkLogin(e2, t2) { - if (!localStorage.getItem(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); - let n2 = "all", a2 = "", i2 = t2.get("t") || ""; - /^y\d+$/.test(i2) ? (n2 = "year", a2 = i2.substring(1)) : "" !== i2 && (n2 = "video_type", a2 = i2); - let s2 = `/advanced_search?handleTop=1&handleType=${n2}&type_value=${a2}`; - e2 && (e2.ctrlKey || e2.metaKey) ? GM_openInTab(window.location.origin + s2, { - insert: 0 - }) : window.location.href = s2; + } + __name(backupDataByWebDav, "backupDataByWebDav"); + async function backupListBtnByWebDav(folderName, openFileListDialogFn) { + const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; + if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); + const a2 = t2.webDavUsername; + if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); + const i2 = await decryptCredential(t2.webDavPassword); + if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); + let s2 = loading(); + try { + const e2 = new WebDavClient(n2, a2, i2), t3 = await e2.getBackupList(folderName); + openFileListDialogFn(t3, e2, "WebDav"); + } catch (o2) { + clog.error(o2), show.error(`发生错误: ${o2 ? o2.message : o2}`); + } finally { + s2.close(); } - openLoginDialog({ onSuccess = null } = {}) { - layer.open({ - type: 1, - title: "JavDB", - closeBtn: 1, - area: utils.getResponsiveArea(["360px", "auto"]), - shadeClose: false, - content: ` - -
    -
    - -
    - -
    - + } + __name(backupListBtnByWebDav, "backupListBtnByWebDav"); + function openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn) { + const formatSize = /* @__PURE__ */ __name((size) => { + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let i2 = 0, s2 = size; + for (; s2 >= 1024 && i2 < units.length - 1; ) s2 /= 1024, i2++; + return `${s2 % 1 == 0 ? s2.toFixed(0) : s2.toFixed(2)} ${units[i2]}`; + }, "formatSize"); + const renderCards = /* @__PURE__ */ __name((files) => { + if (!files || files.length === 0) { + return '
    暂无数据
    '; + } + return files.map((file, idx) => ` +
    +
    ${escapeHtml(file.name)}
    +
    ${formatSize(file.size)} · ${utils.getNowStr("-", ":", file.createTime)}
    +
    + + +
    - -
    - `, - success: /* @__PURE__ */ __name((e2, t2) => { - $("#loginBtn").click((function() { - const e3 = $("#username").val(), n2 = $("#password").val(); - if (!e3 || !n2) return void show.error("请输入用户名和密码"); - let a2 = loading(); - (async (e4, t3) => { - let n3 = `${U}/v1/sessions?username=${encodeURIComponent(e4)}&password=${encodeURIComponent(t3)}&device_uuid=04b9534d-5118-53de-9f87-2ddded77111e&device_name=iPhone&device_model=iPhone&platform=ios&system_version=17.4&app_version=official&app_version_number=1.9.29&app_channel=official`, a3 = { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - "content-type": "multipart/form-data; boundary=--dio-boundary-2210433284", - jdsignature: await O() - }; - return await gmHttp.post(n3, null, a3); - })(e3, n2).then((async (e4) => { - let n3 = e4.success; - if (0 === n3) show.error(e4.message); - else { - if (1 !== n3) throw clog.error("登录失败", e4), new Error(e4.message); - { - let n4 = e4.data.token; - await localStorage.setItem(me, await encryptData(n4)), show.ok("登录成功"), layer.close(t2), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; - } + `).join(""); + }, "renderCards"); + const containerId = "jhs-backup-card-list"; + layer.open({ + type: 1, + title: n2 + "备份文件", + content: `
    ${renderCards(e2)}
    `, + area: utils.getResponsiveArea(["800px", "70%"]), + anim: -1, + success: /* @__PURE__ */ __name((layerEl) => { + const container = $(layerEl).find(`#${containerId}`); + container.on("click", ".jhs-backup-btn", async (ev) => { + const btn = $(ev.currentTarget); + const action = btn.data("action"); + const idx = btn.data("idx"); + const file = e2[idx]; + if (!file) return; + if (action === "delete") { + layer.confirm(`是否删除 ${file.name} ?`, { + icon: 3, + title: "提示", + btn: ["确定", "取消"] + }, async (confirmIdx) => { + layer.close(confirmIdx); + let load = loading(); + try { + await t2.deleteFile(file.fileId); + e2 = await t2.getBackupList(folderName); + container.html(renderCards(e2)); + layer.alert("删除成功"); + } catch (err) { + clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); + } finally { + load.close(); } - })).catch(((e4) => { - clog.error("登录异常:", e4), show.error(e4.message); - })).finally((() => { - a2.close(); - })); - })); - }, "success") - }); - } - }; - __name(_Top250Plugin, "Top250Plugin"); - var Top250Plugin = _Top250Plugin; - var _NavBarPlugin = class _NavBarPlugin extends BasePlugin { - getName() { - return "NavBarPlugin"; - } - async initCss() { - return "\n .highlight-red {\n /* 核心要求:高亮红色文本 */\n color: var(--jhs-status-filter) !important;\n \n /* 建议:增加字体加粗,效果更明显 */\n font-weight: bold;\n \n /* 建议:增加背景色,效果更突出 */\n /* background-color: yellow; */ \n}\n "; - } - handle() { - if (this.margeNav(), this.hookSearch(), this.hookOldSearch(), this.toggleOtherNavItem(), $(window).resize(this.toggleOtherNavItem), JhsSelect.enhance("#search-box"), window.location.href.includes("/search")) { - const e2 = new URLSearchParams(window.location.search); - let t2 = e2.get("q"), n2 = e2.get("f"); - $("#search-keyword").val(t2), n2 && JhsSelect.setValue("#search-type", n2), t2 && this.highlightKeyword(t2); - } - } - highlightKeyword(e2) { - const t2 = e2.trim(); - if (!t2) return; - const n2 = t2.toLowerCase(); - $(".video-title strong, .actor-box strong").each((function() { - const e3 = $(this); - e3.text().toLowerCase().includes(n2) && e3.addClass("highlight-red"); - })); - } - hookSearch() { - $("#navbar-menu-hero").after('\n \n '), $("#search-keyword").on("paste", ((e2) => { - const t2 = e2.originalEvent.clipboardData.items; - for (let n2 = 0; n2 < t2.length; n2++) if (-1 !== t2[n2].type.indexOf("image")) { - const e3 = t2[n2].getAsFile(); - $("#search-keyword").blur(); - const a2 = this.getBean("SearchByImagePlugin"); - return void a2.open((() => { - a2.handleImageFile(e3), a2.resetSearchUI(); - })); - } - })).on("keypress", ((e2) => { - "Enter" === e2.key && setTimeout((() => { - $("#search-btn").click(); - }), 0); - })), $("#search-btn").on("click", ((e2) => { - let t2 = $("#search-keyword").val(), n2 = $("#search-type").val(); - "" !== t2 && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t2 + "&f=" + n2 : window.open("/search?q=" + t2 + "&f=" + n2)); - })), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); - })); - } - hookOldSearch() { - const e2 = document.querySelector(".search-image"); - if (!e2) return; - const t2 = e2.cloneNode(true); - e2.parentNode.replaceChild(t2, e2), $("#button-search-image").attr("data-tooltip", "以图识图"), $(".search-image").on("click", ((e3) => { - this.getBean("SearchByImagePlugin").open(); - })); - } - margeNav() { - $('a[href*="/feedbacks/new"]').remove(), $('a[href*="theporndude.com"]').remove(), $('a.navbar-link[href="/makers"]').parent().after('\n \n '); - } - toggleOtherNavItem() { - let e2 = $("#search-box"), t2 = $("#search-bar-container"); - $(window).width() < 1600 && $(window).width() > 1023 && (e2.hide(), t2.show()), $(window).width() > 1600 && (e2.show(), t2.hide()); - } - }; - __name(_NavBarPlugin, "NavBarPlugin"); - var NavBarPlugin = _NavBarPlugin; - var _StorageQueue = class _StorageQueue { - constructor() { - this.queue = Promise.resolve(); + }); + } else if (action === "download") { + let load = loading(); + try { + const data = await decryptData(await t2.getFileContent(file.fileId)); + utils.download(data, file.name); + } catch (err) { + clog.error(err), show.error("下载失败: " + err); + } finally { + load.close(); + } + } else if (action === "import") { + let load = loading(); + try { + let data = await t2.getFileContent(file.fileId); + data = await decryptData(data); + const parsed = JSON.parse(data); + const currentData = await storageManager.exportData(); + const diff = await storageManager.diffData(currentData, parsed); + load.close(); + showDiffPreviewFn(diff, null, parsed); + } catch (err) { + load.close(), clog.error(err), show.error("预览失败: " + (err ? err.message : err)); + } + } + }); + }, "success") + }); + } + __name(openFileListDialogMobile, "openFileListDialogMobile"); + function openFileListDialog(e2, t2, n2, folderName, showDiffPreviewFn) { + if (utils.isMobileMode()) { + openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn); + return; } - addTask(e2) { - const task = this.queue.then((() => e2())); - return this.queue = task.catch(((e3) => { - clog.error("执行异步队列任务失败:", e3); - })), task; + layer.open({ + type: 1, + title: n2 + "备份文件", + content: '\n
    \n
    \n
    \n ', + area: utils.getResponsiveArea(["800px", "70%"]), + anim: -1, + success: /* @__PURE__ */ __name((a2) => { + const i2 = new Tabulator("#table-container", { + layout: "fitColumns", + placeholder: "暂无数据", + virtualDom: true, + data: e2, + responsiveLayout: "collapse", + responsiveLayoutCollapse: true, + columnDefaults: { + headerHozAlign: "center", + hozAlign: "center" + }, + columns: [{ + title: "文件名", + field: "name", + width: 200, + headerSort: false, + responsive: 0 + }, { + title: "文件大小", + field: "size", + responsive: 1, + headerSort: false, + formatter: /* @__PURE__ */ __name((e3, t3, n3) => { + const a3 = ["B", "KB", "MB", "GB", "TB", "PB"]; + let i3 = 0, s2 = e3.getData().size; + for (; s2 >= 1024 && i3 < a3.length - 1; ) s2 /= 1024, i3++; + return `${s2 % 1 == 0 ? s2.toFixed(0) : s2.toFixed(2)} ${a3[i3]}`; + }, "formatter") + }, { + title: "备份日期", + field: "createTime", + responsive: 2, + headerSort: false, + formatter: /* @__PURE__ */ __name((e3, t3, n3) => { + const a3 = e3.getData(); + return `${utils.getNowStr("-", ":", a3.createTime)}`; + }, "formatter") + }, { + title: "操作", + minWidth: 250, + responsive: 0, + headerSort: false, + formatter: /* @__PURE__ */ __name((e3, a3, s2) => { + const o2 = e3.getData(); + return s2((() => { + const a4 = e3.getElement().querySelector(".backup-delete"), s3 = e3.getElement().querySelector(".backup-download"), r2 = e3.getElement().querySelector(".backup-import"); + a4 && a4.addEventListener("click", ((e4) => { + layer.confirm(`是否删除 ${o2.name} ?`, { + icon: 3, + title: "提示", + btn: ["确定", "取消"] + }, (async (e5) => { + layer.close(e5); + let a5 = loading(); + try { + await t2.deleteFile(o2.fileId); + let e6 = await t2.getBackupList(folderName); + i2.replaceData(e6), layer.alert("删除成功"); + } catch (s4) { + clog.error(s4), show.error(`发生错误: ${s4 ? s4.message : s4}`); + } finally { + a5.close(); + } + })); + })), s3 && s3.addEventListener("click", (async (e4) => { + let a5 = loading(); + try { + const e5 = await decryptData(await t2.getFileContent(o2.fileId)); + utils.download(e5, o2.name); + } catch (i3) { + clog.error(i3), show.error("下载失败: " + i3); + } finally { + a5.close(); + } + })), r2 && r2.addEventListener("click", (async (e4) => { + let a5 = loading(); + try { + let e5 = await t2.getFileContent(o2.fileId); + e5 = await decryptData(e5); + const n3 = JSON.parse(e5), i3 = await storageManager.exportData(), s4 = await storageManager.diffData(i3, n3); + a5.close(), showDiffPreviewFn(s4, null, n3); + } catch (i3) { + a5.close(), clog.error(i3), show.error("预览失败: " + (i3 ? i3.message : i3)); + } + })); + })), '\n \n \n \n '; + }, "formatter") + }], + locale: "zh-cn", + langs: { + "zh-cn": { + pagination: { + first: "首页", + first_title: "首页", + last: "尾页", + last_title: "尾页", + prev: "上一页", + prev_title: "上一页", + next: "下一页", + next_title: "下一页", + all: "所有", + page_size: "每页行数" + } + } + } + }); + }, "success") + }); + } + __name(openFileListDialog, "openFileListDialog"); + async function exportSettingData() { + try { + const e2 = JSON.stringify(await storageManager.exportData()), t2 = `${utils.getNowStr("_", "_")}.json`; + utils.download(e2, t2), show.ok("数据导出成功"); + } catch (t2) { + clog.error(t2), show.error("导出数据时出错: " + t2.message); } - async waitAllFinished() { - return this.queue; + } + __name(exportSettingData, "exportSettingData"); + + // src/plugins/backup/setting-styles.js + function buildSettingCss(containerWidth, containerColumns, isJavBus, isJavDB) { + let base; + if (isJavBus) { + base = ` + .container-fluid .row{ + max-width: 1000px !important; + min-width: ${containerWidth}%; + margin: auto auto; + } + + .container { + max-width: 1000px !important; + min-width: 80%; + margin: auto auto; + } + + .masonry { + grid-template-columns: repeat(${containerColumns}, minmax(0, 1fr)); + } + `; + } else { + base = ` + section .container{ + max-width: 1000px !important; + min-width: ${containerWidth}%; + } + .movie-list, .movie-list.v{ + grid-template-columns: repeat(${containerColumns}, minmax(0, 1fr)); + } + `; } - }; - __name(_StorageQueue, "StorageQueue"); - var StorageQueue = _StorageQueue; - var _OtherSitePlugin = class _OtherSitePlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "siteConfigs", [{ - id: "javTrailersBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavTrailersUrl(), "getBaseUrl"), - itemSelector: ".videos-list .video-link", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find("p.card-text").text(), "findCarNumOrTitle") - }, { - id: "123AvBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => `${await this.getAv123Url()}/cn`, "getBaseUrl"), - itemSelector: ".card", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search?keyword=${encodeURIComponent(t2)}`, "searchPath"), - requestOptions: { cookiePartitionTopLevelSite: "https://123av.com" }, - getDetailPageHref: /* @__PURE__ */ __name((e2, t2) => { - const href = e2.find('a.card__link[href*="/cn/v/"]').first().attr("href"); - return href ? new URL(href, t2).href : null; - }, "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find(".card__link").first().text(), "findCarNumOrTitle"), - matches: /* @__PURE__ */ __name((text, carNum) => text.replace(/FC2-PPV-/gi, "FC2-").toLowerCase().includes(carNum.toLowerCase()), "matches") - }, { - id: "jableBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getjableUrl(), "getBaseUrl"), - itemSelector: "#list_videos_videos_list_search_result .detail .title a", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}/`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") - }, { - id: "avgleBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getAvgleUrl(), "getBaseUrl"), - itemSelector: ".text-secondary", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/vod/search.html?wd=${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") - }, { - id: "missAvBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getMissAvUrl(), "getBaseUrl"), - itemSelector: ".text-secondary", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") - }, { - id: "supJavBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getSupJavUrl(), "getBaseUrl"), - itemSelector: ".posts post", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/?s=${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2, t2, n2) => e2.attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.attr("title"), "findCarNumOrTitle") - }, { - id: "javDbBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavDbUrl(), "getBaseUrl"), - itemSelector: ".movie-list .item", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search?q=${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.find("a").attr("href"), "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find(".video-title").text(), "findCarNumOrTitle"), - condition: /* @__PURE__ */ __name((e2) => l, "condition") - }, { - id: "javBusBtn", - getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavBusUrl(), "getBaseUrl"), - itemSelector: ".container h3", - searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/${t2}`, "searchPath"), - getDetailPageHref: /* @__PURE__ */ __name((e2, t2, n2) => `${t2}/${n2}`, "getDetailPageHref"), - findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle"), - condition: /* @__PURE__ */ __name((e2) => r && e2 && !e2.includes("FC2"), "condition") - }, { - id: "fanzaBtn", - noHandle: true, - initUrl: /* @__PURE__ */ __name((e2) => `https://www.dmm.co.jp/search/=/searchstr=${e2}`, "initUrl"), - condition: /* @__PURE__ */ __name((e2) => e2 && !e2.includes("FC2"), "condition") - }]), i(this, "settingCache", null), i(this, "lastFetchTime", 0), i(this, "CACHE_DURATION", 1e4); - } - getName() { - return "OtherSitePlugin"; - } - async initCss() { - return ` + return ` `; - } - async handle() { - isDetailPage && await this.loadOtherSite(null, null, { - autoDetect: false - }); - } - async loadOtherSite(e2, t2, n2 = {}) { - if ("yes" !== await storageManager.getSetting("enableLoadOtherSite", "yes")) return; - const root = n2.root ? $(n2.root) : $(document), target = n2.target ? $(n2.target) : root.find(".movie-panel-info,.container .info").first(); - if (!target.length || n2.isActive && !n2.isActive()) return; - root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); - e2 = normalizeCarNum(e2) || this.getPageInfo().carNum; - const enabled = this.getEnabledSites(), configs = this.siteConfigs.map(((config) => ({ ...config, sourceCarNum: t2 }))), view = { root, target, configs, carNum: e2, isActive: "function" === typeof n2.isActive ? n2.isActive : () => true }; - const box = $('
    '), list = box.find(".jhs-site-list"), settings = $('
    '); - configs.forEach(((config) => { - if (config.condition && false === config.condition(config.sourceCarNum)) return; - const button = $('').attr("data-jhs-site-id", config.id).toggleClass("jhs-is-hidden", !enabled.includes(config.id)); - button.find("span").text(config.id.replace("Btn", "")), list.append(button); - })); - list.append('', ''), target.append(box, settings), view.box = box, view.settings = settings; - if (!e2) return box.find(".site-btn").removeAttr("href").attr({ "aria-disabled": "true", title: "番号不可用" }), box.find('[data-jhs-role="detect-sites"]').prop("disabled", true), this.renderSettingsArea(view), this.setupEventListeners(view), void clog.warn("跳过第三方站点解析:番号不可用"); - box.find('[data-jhs-site-id="javTrailersBtn"]').on("click", ((event) => { - event.preventDefault(); - const original = $(event.currentTarget).attr("href"), destination = event.ctrlKey || event.metaKey ? original : original + "?handle=1"; - utils.openPage(destination, e2, false, event); - })), await Promise.all(configs.map((async (config) => { - config.condition && false === config.condition(config.sourceCarNum) || await this.prepareSiteLink(e2, config, view); - }))), this.renderSettingsArea(view), this.setupEventListeners(view), box.find('[data-jhs-role="detect-sites"]').off("click").on("click", ((event) => { - event.preventDefault(), this.detectOtherSites(e2, view); - })), n2.autoDetect && await this.detectOtherSites(e2, view); - return box; - } - async prepareSiteLink(e2, t2, view = { root: $(document), isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { - const n2 = view.root.find(`[data-jhs-site-id="${t2.id}"],#${t2.id}`).first(); - if (!(e2 = normalizeCarNum(e2))) return n2.removeAttr("href").attr({ "aria-disabled": "true", title: "番号不可用" }), void this.setSiteState(n2, "idle"); - if (t2.initUrl) return void (n2.attr("href", t2.initUrl(e2)), this.setSiteState(n2, "idle"), n2.attr("title", "点击前往外部搜索页")); - try { - const a2 = await t2.getBaseUrl(), i2 = t2.searchPath(a2, e2); - view.isActive() && (n2.attr("href", i2), n2.attr("title", "点击前往外部搜索页;点击检测按钮后才自动检测"), this.setSiteState(n2, "idle")); - } catch (a2) { - view.isActive() && (n2.attr("title", "外部站点地址未配置或不可用"), this.setSiteState(n2, "domain-error")); - } - } - async detectOtherSites(e2, view = { root: $(document), configs: this.siteConfigs, isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { - const t2 = view.root.find('[data-jhs-role="detect-sites"],#detectOtherSiteBtn').first(), n2 = t2.text(); - if (!(e2 = normalizeCarNum(e2))) return t2.prop("disabled", true), void clog.warn("跳过第三方站点检测:番号不可用"); - return t2.text("检测中").prop("disabled", true).addClass("is-checking"), await Promise.all(view.configs.map((async (t3) => { - t3.condition && false === t3.condition(t3.sourceCarNum) || await this.handleSite(e2, t3, view); - }))), view.isActive() && t2.text(n2).prop("disabled", false).removeClass("is-checking"); - } - setSiteState(e2, t2) { - e2.removeClass("is-checking is-available is-unavailable is-domain-error"), "idle" !== t2 && e2.addClass(`is-${t2}`); - } - async handleSite(e2, t2, view = { root: $(document), isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { - const n2 = view.root.find(`[data-jhs-site-id="${t2.id}"],#${t2.id}`).first(); - n2.removeAttr("href").find(".site-tag").remove(), this.setSiteState(n2, "checking"); - if (t2.initUrl && n2.attr("href", t2.initUrl(e2)), t2.noHandle && true === t2.noHandle) { - const t3 = "jhs_other_site_dmm", a2 = (localStorage.getItem(t3) ? JSON.parse(localStorage.getItem(t3)) : {})[e2]; - a2 ? (n2.attr("href", a2.url), "multiple" === a2.type && n2.append('多结果'), this.setSiteState(n2, "available")) : this.setSiteState(n2, "idle"); - } else try { - if (n2.attr("href")) return void this.setSiteState(n2, "idle"); - if (utils.isHidden(n2)) return; - const a2 = "jhs_other_site", i2 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}, s2 = e2 + "_" + t2.id.replace("Btn", ""), o2 = i2[s2], m2 = Date.now(); - if (o2 && o2.time && m2 - o2.time < 864e5) return void (n2.attr("href", o2.url), "multiple" === o2.type && n2.append('多结果'), this.setSiteState(n2, "available")); - const r2 = await t2.getBaseUrl(), l2 = t2.searchPath(r2, e2); - n2.attr("href", l2); - const _breaker = gmHttp.isDomainCircuitBroken(l2); - if (_breaker) { - n2.attr("title", `站点已熔断,${_breaker.remaining}秒后重试`), this.setSiteState(n2, "domain-error"); - return; - } - const c2 = await storageManager.cachedRequest(`other-site:${t2.id}:${e2}`, 864e5, (() => gmHttp.get(l2, null, t2.headers, true, t2.requestOptions || {}))); - if (!view.isActive()) return; - const d2 = utils.htmlTo$dom(c2), h2 = []; - d2.find(t2.itemSelector).each(((n3, a3) => { - const i3 = $(a3); - const itemText = t2.findCarNumOrTitle(i3); - if (t2.matches ? !t2.matches(itemText, e2) : !itemText.toLowerCase().includes(e2.toLowerCase())) return; - let s3 = t2.getDetailPageHref(i3, r2, e2); - if (!s3) throw new Error("解析href失败"); - s3.includes("http") || (s3 = r2 + (s3.startsWith("/") ? s3 : "/" + s3)), h2.push(s3); - })); - let g2 = "", p2 = null; - if (1 === h2.length) { - let e3 = h2[0]; - n2.attr("href", e3), this.setSiteState(n2, "available"), p2 = { - type: "single", - url: e3, - time: m2 - }; - } else h2.length > 1 ? (n2.attr("href", l2), g2 += '多结果', this.setSiteState(n2, "available"), p2 = { - type: "multiple", - url: l2, - time: m2 - }) : (n2.attr("href", l2), n2.attr("title", "未查询到, 点击前往搜索页"), this.setSiteState(n2, "unavailable")); - if (p2) { - const e3 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}; - e3[s2] = p2, localStorage.setItem(a2, JSON.stringify(e3)); - } - g2 && n2.append(g2); - } catch (a2) { - const e3 = String(a2), i2 = t2.id.replace("Btn", ""); - a2._circuitBroken ? (n2.attr("title", e3), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源跳过, ${i2} 已熔断`)) : a2?._cfBlocked ? (n2.attr("title", "请求失败:Cloudflare 安全检查。"), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源失败, ${i2} 需Cloudflare安全检查`)) : e3.includes("重定向") ? (n2.attr("title", "域名失效"), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源失败, ${i2} 域名被重定向`)) : e3.includes("404 Page Not Found") ? (n2.attr("title", "未查询到, 点击前往搜索页"), this.setSiteState(n2, "unavailable")) : (clog.error(a2), n2.attr("title", "请求失败。"), this.setSiteState(n2, "unavailable"), clog.warn(`检测第三方资源失败, ${i2}`)); - } - } - async getSettingCache() { - const e2 = Date.now(); - return (!this.settingCache || e2 - this.lastFetchTime > this.CACHE_DURATION) && (this.settingCache = await storageManager.getSetting(), this.lastFetchTime = e2), this.settingCache; - } - async getMissAvUrl() { - return (await this.getSettingCache()).missAvUrl || "https://missav.live"; - } - async getjableUrl() { - return (await this.getSettingCache()).jableUrl || "https://jable.tv"; - } - async getAvgleUrl() { - return (await this.getSettingCache()).avgleUrl || "https://jav.rs"; - } - async getJavTrailersUrl() { - return (await this.getSettingCache()).javTrailersUrl || "https://javtrailers.com"; - } - async getAv123Url() { - return (await this.getSettingCache()).av123Url || "https://123av.com"; - } - async getJavDbUrl() { - return (await this.getSettingCache()).javDbUrl || "https://javdb.com"; - } - async getJavBusUrl() { - return (await this.getSettingCache()).javBusUrl || "https://www.javbus.com"; - } - async getSupJavUrl() { - return (await this.getSettingCache()).supJavUrl || "https://supjav.com"; - } - getEnabledSites() { - const fallback = this.siteConfigs.map(((site) => site.id)); - try { - const raw = localStorage.getItem("jhs_enabled_sites"); - if (!raw) return fallback; - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed.filter(((id) => fallback.includes(id))) : fallback; - } catch (error) { - return clog.warn("外部站点配置损坏,已回退默认值", error), fallback; - } - } - saveEnabledSites(e2) { - localStorage.setItem("jhs_enabled_sites", JSON.stringify(e2)); - } - renderSettingsArea(view = { root: $(document), configs: this.siteConfigs }) { - const enabled = this.getEnabledSites(), target = view.root.find('[data-jhs-role="site-checkboxes"],#siteCheckboxes').first().empty(); - view.configs.forEach(((config) => { - const input = $('').attr("data-site-id", config.id).prop("checked", enabled.includes(config.id)); - target.append($('').append(input, $("").text(config.id.replace("Btn", "")))); - })); - } - setupEventListeners(view = { root: $(document), configs: this.siteConfigs, carNum: null }) { - const $settingsArea = view.root.find("[data-jhs-other-site-settings],#settingsArea").first(); - view.root.find('[data-jhs-role="site-settings"],#settingSiteBtn').off("click.jhsOtherSite").on("click.jhsOtherSite", (() => { - $settingsArea.toggleClass("jhs-is-hidden"); - })), $settingsArea.off("change.jhsOtherSite").on("change.jhsOtherSite", 'input[type="checkbox"]', (async (event) => { - const siteId = $(event.currentTarget).attr("data-site-id"); - try { - if (event.currentTarget.checked) { - view.root.find(`[data-jhs-site-id="${siteId}"],#${siteId}`).removeClass("jhs-is-hidden"); - const carNum = view.carNum || this.getPageInfo().carNum, site = view.configs.find(((item) => item.id === siteId)); - site && await this.prepareSiteLink(carNum, site, view); - } else view.root.find(`[data-jhs-site-id="${siteId}"],#${siteId}`).addClass("jhs-is-hidden"); - this.saveEnabledSites($settingsArea.find('input[type="checkbox"]:checked').map(((index, input) => $(input).attr("data-site-id"))).get()); - } catch (error) { - clog.warn(`外部站点 ${siteId || "unknown"} 状态更新失败`, error); - } - })); - } - }; - __name(_OtherSitePlugin, "OtherSitePlugin"); - var OtherSitePlugin = _OtherSitePlugin; - var _BusDetailPagePlugin = class _BusDetailPagePlugin extends BasePlugin { - getName() { - return "BusDetailPagePlugin"; - } - async initCss() { - if (!window.isDetailPage) return ""; - $("h4:contains('推薦')").hide(); - } - async handle() { - if (window.location.href.includes("/star/")) { - const e2 = $(".avatar-box"); - if (e2.length > 0) { - let t2 = e2.parent(); - t2.css("position", "initial"), t2.insertBefore(t2.parent()); - } - } - $(".genre a").each((function() { - const e2 = $(this).attr("href"); - e2 && (e2.startsWith("http://") || e2.startsWith("https://") || e2.startsWith("/")) && $(this).attr("target", "_blank"); - })), this.addCopyCarNumBtn(); - } - addCopyCarNumBtn() { - let e2 = null; - const t2 = document.querySelectorAll("span.header"); - for (const n2 of t2) if ("識別碼:" === n2.textContent.trim()) { - e2 = n2; - break; - } - if (e2) { - const t3 = e2.nextElementSibling; - if (t3 && "SPAN" === t3.tagName) { - const e3 = t3.textContent.trim(), n2 = document.createElement("button"); - n2.type = "button", n2.className = "jhs-btn jhs-btn--secondary jhs-copy-car-number", n2.textContent = "复制", n2.addEventListener("click", (async function(t4) { - t4.preventDefault(); - await utils.copyToClipboard("番号", e3) && (() => { - this.textContent = "已复制", setTimeout((() => { - this.textContent = "复制"; - }), 1500); - })(); - })), t3.parentNode.insertBefore(n2, t3.nextSibling); - } - } - } - }; - __name(_BusDetailPagePlugin, "BusDetailPagePlugin"); - var BusDetailPagePlugin = _BusDetailPagePlugin; - var _DetailPageButtonPlugin = class _DetailPageButtonPlugin extends BasePlugin { - getName() { - return "DetailPageButtonPlugin"; - } - constructor() { - super(), this.answerCount = 1, this.stateBinding = null; - } - async handle() { - this.hideVideoControls(), window.isDetailPage && (await this.createMenuBtn(), await this.autoRemoveNewVideoMark()); - } - async autoRemoveNewVideoMark() { - try { - const e2 = await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse", C); - if (e2 !== _) return; - const t2 = this.getPageInfo(); - if (!t2.carNum) return; - await stateService.removeFromNewVideoList([t2.carNum], "browse"); - } catch (e2) { - clog.error("自动移除新作品标记失败:", e2); - } - } - async createMenuBtn() { - const e2 = this.getPageInfo(), t2 = e2.carNum, n2 = ` -
    -
    - - - - -
    + ${base} + .nav-btn::after { + content:none !important; + } -
    - - - - -
    -
    - `; - const workspaceSlot = this.getBean("DetailWorkspacePlugin")?.getSlot("summary-actions"); - workspaceSlot?.length ? workspaceSlot.append(n2) : r ? $(".tabs").after(n2) : l && $("#mag-submit-show").before(n2), $("#magnetSearchBtn").on("click", (async () => { - let t3 = await this.getBean("MagnetHubPlugin").createMagnetHub(e2.carNum); - layer.open({ - type: 1, - title: "磁力搜索 " + e2.carNum, - content: '
    ', - area: utils.getResponsiveArea(["60%", "80%"]), - scrollbar: false, - success: /* @__PURE__ */ __name(() => { - $("#magnetHubBox").append(t3); - }, "success") - }); - })); - const a2 = this.getBean("HighlightMagnetPlugin"), i2 = await storageManager.getSetting("enableMagnetsFilter", _); - $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2.doFilterMagnet(), $("#enable-magnets-filter").on("click", ((e3) => { - let t3 = $("#magnets-span"); - "关闭磁力过滤" === t3.text() ? (a2.showAll(), t3.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a2.doFilterMagnet(), t3.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); - })), $("#search-subtitle-btn").on("click", ((e3) => utils.openPage(`https://subtitlecat.com/index.php?search=${t2}`, t2, false, e3))), $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(t2))); - if (!t2) { - $("#filterBtn, #favoriteBtn, #hasDownBtn, #hasWatchBtn, #magnetSearchBtn, #xunLeiSubtitleBtn, #search-subtitle-btn").prop("disabled", true).attr("title", "番号不可用"); - return void clog.warn("详情操作不可用:番号不可用"); - } - this.stateBinding = detailStateController.bind({ root: document, carNum: t2, activityType: "detail-state", getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord") }); - } - async showStatus(e2) { - return detailStateController.render({ root: document, carNum: e2 }); - } - getStateRecord() { - const info = this.getPageInfo(); - return { carNum: info.carNum, url: info.url, names: info.actress, publishTime: info.publishTime }; - } - getStateBinding() { - if (this.stateBinding) return this.stateBinding; - const info = this.getPageInfo(); - return this.stateBinding = { root: document, layerIndex: null, carNum: normalizeCarNum(info.carNum), getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord"), activityType: "detail-state", selectors: {} }; - } - async favoriteOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "favorite", event); - } - async hasDownOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "downloaded", event); - } - async hasWatchOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "watched", event); - } - searchXunLeiSubtitle(e2) { - let t2 = loading(); - gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e2}`).then(((t3) => { - let n2 = t3.data; - n2 && 0 !== n2.length ? layer.open({ - type: 1, - title: "迅雷字幕", - content: '\n
    \n
    \n
    \n ', - scrollbar: false, - area: utils.getResponsiveArea(["60%", "70%"]), - anim: -1, - success: /* @__PURE__ */ __name((t4, a2) => { - new Tabulator("#xunlei-table-container", { - layout: "fitColumns", - placeholder: "暂无数据", - virtualDom: true, - data: n2, - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - columns: [{ - title: "文件名", - field: "name", - headerSort: false, - responsive: 0 - }, { - title: "类型", - field: "ext", - headerSort: false, - responsive: 0 - }, { - title: "操作", - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((t5, n3, a3) => { - const i2 = t5.getData(); - return a3((() => { - const n4 = t5.getElement().querySelector(".subtitle-preview-btn"), a4 = t5.getElement().querySelector(".subtitle-download-btn"); - n4 && n4.addEventListener("click", (async (t6) => { - let n5 = i2.url, a5 = e2 + "." + i2.ext; - this.previewSubtitle(n5, a5); - })), a4 && a4.addEventListener("click", (async (t6) => { - let n5 = i2.url, a5 = e2 + "." + i2.ext, s2 = await gmHttp.get(n5); - utils.download(s2, a5); - })); - })), '\n \n \n '; - }, "formatter") - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } - } - } - }), utils.setupEscClose(a2); - }, "success") - }) : show.error("迅雷中找不到相关字幕!"); - })).catch(((e3) => { - clog.error(e3), show.error(e3); - })).finally((() => { - t2.close(); - })); - } - async filterOne(e2, t2) { - e2 && e2.preventDefault(); - return detailStateController.requestToggle(this.getStateBinding(), "blocked", e2); - } - hideVideoControls() { - $(document).on("mouseenter", "#preview-video", (function() { - $(this).prop("controls", true); - })); - } - async previewSubtitle(e2, t2) { - if (!e2) return void clog.error("未提供文件URL"); - const n2 = e2.split(".").pop().toLowerCase(); - if ("ass" === n2 || "srt" === n2) try { - let a2 = await gmHttp.get(e2), i2 = "字幕预览"; - "ass" === n2 ? i2 = "ASS字幕预览 - " + t2 : "srt" === n2 && (i2 = "SRT字幕预览 - " + t2); - const s2 = a2.split("\n"); - let o2 = ""; - const r2 = String(s2.length).length; - s2.forEach(((e3, t3) => { - const n3 = String(t3 + 1).padStart(r2, " "); - o2 += `${n3}. ${e3} -`; - })); - const l2 = o2; - layer.open({ - type: 1, - title: i2, - area: utils.getResponsiveArea(["80%", "80%"]), - scrollbar: false, - content: `
    ${l2}
    `, - btn: ["下载", "关闭"], - btn1: /* @__PURE__ */ __name(function(e3, n3, i3) { - return utils.download(a2, t2), false; - }, "btn1") - }); - } catch (a2) { - show.error(`预览失败: ${a2.message}`), clog.error("预览字幕文件出错:", a2); - } - else show.error("仅支持预览ASS和SRT字幕文件"); - } - }; - __name(_DetailPageButtonPlugin, "DetailPageButtonPlugin"); - var DetailPageButtonPlugin = _DetailPageButtonPlugin; - var _HistoryPlugin = class _HistoryPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectAll", false), i(this, "historyExcludedCarNums", /* @__PURE__ */ new Set()), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); - } - getName() { - return "HistoryPlugin"; - } - async initCss() { - return ` - `; - } - handleResize() { - $(".navbar-search").is(":hidden") ? ($(".historyBtnBox").show(), $(".miniHistoryBtnBox").hide()) : ($(".historyBtnBox").hide(), $(".miniHistoryBtnBox").show()); - } - async handle() { - r && ($(".navbar-end").prepend(''), $(".navbar-search").css("margin-left", "0").before('\n \n '), this.handleResize(), $(window).resize((() => { - this.handleResize(); - })), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory()))), l && await this.createBusButton(); - } - async createBusButton() { - const ready = await new Promise(((resolve) => { - const startedAt = Date.now(), timer = setInterval((() => { - if ($("#setting-btn").length && $("#top-right-box").length) return clearInterval(timer), resolve(true); - Date.now() - startedAt >= 2500 && (clearInterval(timer), resolve(false)); - }), 25); - })); - if (!ready) return void clog.warn("鉴定记录入口未创建:JavBus 顶部工具区未就绪"); - $("#top-right-box").append(''), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory())); - } - openHistory() { - let e2 = ` -
    -
    - - - -
    -
    - - - - - -
    -
    -
    - `; - e2 = e2.replace('
    { - const root = $(e3); - this.historyRoot = root, root.find("#allSelectBox").prepend(''), this.resetHistorySelection(false), JhsSelect.enhance(root); - await this.loadTableData(), root.on("click.jhsHistory", "#clearSearchbtn", (async (e4) => { - root.find("#searchCarNum").val(""), JhsSelect.setValue(root.find("#dataType"), "all"), await this.reloadTable(), root.find("#allSelectBox").hide(); - })).on("focusout keydown", "#searchCarNum", (async (e4) => { - if ("focusout" === e4.type || "Enter" === e4.key) { - if ("Enter" === e4.key && e4.preventDefault(), "keydown" === e4.type && "Enter" !== e4.key) return; - await this.reloadTable(); - } - })).on("click", ".table-link-param", (async (e4) => { - let t2 = $(e4.currentTarget); - root.find("#searchCarNum").val(t2.text()), await this.reloadTable(); - })).on("change", "#dataType", (async () => { - await this.reloadTable(); - })).on("click", "[data-history-view]", (async (event) => { - const view = $(event.currentTarget).data("history-view"); - root.find("[data-history-view]").removeClass("active"), $(event.currentTarget).addClass("active"), await this.showHistoryView(view); - })).on("click", ".jhs-undo-activity", (async (event) => { - const result = await stateService.undoTransaction($(event.currentTarget).data("transaction")); - show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); - })).on("click", ".jhs-copy-offline", (async (event) => { - await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); - })).on("click", ".jhs-retry-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)); - item && await this.getBean("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); - })).on("click", ".jhs-open-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)), provider = this.getBean("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); - url && window.open(url, "_blank", "noopener,noreferrer"); - })).on("click", ".jhs-delete-offline", (async (event) => { - await stateService.removeOfflineHistory($(event.currentTarget).data("id")), await this.renderOfflineHistory(); - })), this.bindHistoryActions(root); - }, "success"), - end: /* @__PURE__ */ __name(() => { - this.resetHistorySelection(), this.historyRoot?.off(".jhsHistory"), this.historyRoot = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null); - }, "end") - }); - } - async showHistoryView(view) { - const stateView = "state" === view; - this.resetHistorySelection(), this.historyRoot?.find("#filterBox,#allSelectBox").toggle(stateView), this.tableObj?.destroy(), this.tableObj = null; - return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); - } - async renderActivityHistory() { - const log = await stateService.getActivityLog(), host = this.historyRoot.find("#table-container").empty(); - if (!log.entries.length) return void host.html('
    暂无操作记录
    '); - log.entries.slice().reverse().forEach(((entry) => { - const reverted = entry.changes.filter(((change) => "reverted" === change.undoState)).length, conflicts = entry.changes.filter(((change) => "conflict" === change.undoState)).length; - host.append($('
    ').append($("").text(`${entry.type} · ${entry.changes.length} 项`), $("

    ").text(`${new Date(entry.createdAt).toLocaleString()} · 已撤销 ${reverted} · 冲突 ${conflicts}`), $("

    ").text(entry.changes.map(((change) => change.carNum)).join("、")), $('').attr("data-transaction", entry.id).prop("disabled", "committed" !== entry.commitState || reverted === entry.changes.length))); - })); - } - async renderOfflineHistory() { - const history = await stateService.getOfflineHistory(), host = this.historyRoot.find("#table-container").empty(); - if (!history.length) return void host.html('
    暂无离线任务
    '); - history.slice().reverse().forEach(((item) => { - const actions = $('
    ').append($('').attr("data-resource", item.resource), $('').attr("data-id", item.id), $('').attr("data-id", item.id), $('').attr("data-id", item.id)); - host.append($('
    ').append($("").text(`${item.providerName || item.providerId} · ${item.status}`), $("

    ").text(`${item.carNum || "未关联番号"} · ${new Date(item.createdAt).toLocaleString()}${item.retryOf ? ` · 重试自 ${item.retryOf}` : ""}`), $("

    ").text(item.errorMessage || item.resource), actions)); - })); - } - async reloadTable(resetSelection = true) { - resetSelection && this.resetHistorySelection(), await this.tableObj?.setPage(1); - } - normalizeHistoryCarNum(carNum) { - return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); - } - /** 清空 History 的全选、排除项和当前页选择。 */ - resetHistorySelection(deselectRows = true) { - this.historySelectAll = false, this.historyExcludedCarNums.clear(); - if (deselectRows && this.tableObj) { - this.historySelectionSyncing = true; - try { - this.tableObj.deselectRow(); - } finally { - this.historySelectionSyncing = false; - } - } - this.updateHistorySelectionUi(); - } - createHistorySelectAllCheckbox() { - const checkbox = document.createElement("input"); - return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { - checkbox.checked ? (this.historySelectAll = true, this.historyExcludedCarNums.clear(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); - })), checkbox; - } - syncHistoryPageSelection() { - if (!this.tableObj || !this.historySelectAll) return void this.updateHistorySelectionUi(); - const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.map(((item) => item.carNum)).filter(((carNum) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(carNum)))); - this.historySelectionSyncing = true; - try { - this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); - } finally { - this.historySelectionSyncing = false; - } - this.updateHistorySelectionUi(); - } - updateHistoryRowSelection(row, selected) { - if (!this.historySelectAll || this.historySelectionSyncing) return void this.updateHistorySelectionUi(); - const carNum = this.normalizeHistoryCarNum(row?.getData?.().carNum); - carNum && (selected ? this.historyExcludedCarNums.delete(carNum) : this.historyExcludedCarNums.add(carNum)), this.updateHistorySelectionUi(); - } - getHistorySelectionSummary() { - if (this.historySelectAll) { - const excluded = this.historyExcludedCarNums.size, selected = Math.max(0, this.historyFilteredCount - excluded); - return { - count: selected, - text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` - }; - } - const count = this.tableObj?.getSelectedData?.().length || 0; - return { - count, - text: `已选择当前页 ${count} 条` - }; - } - updateHistorySelectionUi() { - if (!this.historyRoot) return; - const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); - this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); - this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { - element.checked = this.historySelectAll, element.indeterminate = this.historySelectAll ? this.historyExcludedCarNums.size > 0 : summary.count > 0; - })); - } - /** 解析批量操作实际要处理的当前页或完整筛选结果。 */ - async getHistoryBatchSelection() { - if (!this.historySelectAll) return this.tableObj?.getSelectedData?.() || []; - const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map(((item) => this.normalizeHistoryCarNum(item.carNum)))); - for (const carNum of this.historyExcludedCarNums) available.has(carNum) || this.historyExcludedCarNums.delete(carNum); - return this.historyFilteredCount = rows.length, rows.filter(((item) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(item.carNum)))); - } - bindHistoryActions(root) { - root.on("click.jhsHistory", (function(e2) { - if (e2.target.closest(".sub-btns-toggle")) { - const button = e2.target.closest(".sub-btns-toggle"), t2 = button.closest(".sub-btns").querySelector(".sub-btns-menu"); - root.find(".sub-btns-menu.show").each(((index, e3) => { - e3 !== t2 && (e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false")); - })), t2.classList.toggle("show"), button.setAttribute("aria-expanded", String(t2.classList.contains("show"))); - } else root.find(".sub-btns-menu.show").each(((index, e3) => { - e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false"); - })); - })), root.on("keydown.jhsHistory", ".sub-btns", ((e2) => { - const menu = $(e2.currentTarget).find(".sub-btns-menu"), items = menu.find('[role="menuitem"]'), current = items.index(document.activeElement); - if ("Escape" === e2.key) return e2.preventDefault(), menu.removeClass("show"), $(e2.currentTarget).find(".sub-btns-toggle").attr("aria-expanded", "false").trigger("focus"); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e2.key) || !menu.hasClass("show")) return; - e2.preventDefault(); - const next = "Home" === e2.key ? 0 : "End" === e2.key ? items.length - 1 : "ArrowDown" === e2.key ? (current + 1 + items.length) % items.length : (current - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })), root.on("click.jhsHistory", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", ((e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.currentTarget), n2 = t2.closest(".action-btns"), a2 = n2.attr("data-car-num"), i2 = n2.attr("data-href"), s2 = /* @__PURE__ */ __name(async (actionType) => { - try { - const flag = legacyActionToFlag(actionType); - await stateService.toggle(a2, flag, { type: "history-state", record: { carNum: a2, url: i2 } }), await this.reloadTable(); - } catch (s3) { - clog.error("历史记录操作失败:", s3), show.error("操作失败"); - } - }, "s"); - if (t2.hasClass("history-filterBtn")) { - const record = this.tableObj?.getRow(a2)?.getData(), isBlocked = normalizeStateFlags(record?.stateFlags).blocked; - isBlocked ? void s2(d) : utils.q(e2, `是否屏蔽${a2}?`, (() => s2(d))); - } else t2.hasClass("history-favoriteBtn") ? void s2(h) : t2.hasClass("history-hasDownBtn") ? void s2(g) : t2.hasClass("history-hasWatchBtn") ? void s2(p) : t2.hasClass("history-deleteBtn") ? this.handleDelete(e2, a2) : t2.hasClass("history-detailBtn") && void this.handleClickDetail(e2, { - carNum: a2, - url: i2 - }).catch(((error) => clog.error("历史详情打开失败", error))); - })), root.on("click.jhsHistory", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (async (e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.currentTarget); - let n2 = await this.getHistoryBatchSelection(), a2 = "", i2 = ""; - t2.hasClass("multiple-history-filterBtn") ? (a2 = "屏蔽", i2 = d) : t2.hasClass("multiple-history-favoriteBtn") ? (a2 = "收藏", i2 = h) : t2.hasClass("multiple-history-hasDownBtn") ? (a2 = "已下载", i2 = g) : t2.hasClass("multiple-history-hasWatchBtn") ? (a2 = "已观看", i2 = p) : t2.hasClass("multiple-history-deleteBtn") && (a2 = "移除", i2 = "delete"); - if (!n2.length) return void show.info("请先选择要处理的记录"); - const selectionText = this.historySelectAll ? this.historyExcludedCarNums.size ? `当前筛选结果中已选择 ${n2.length} 条,排除 ${this.historyExcludedCarNums.size} 条` : `当前筛选结果中已选择全部 ${n2.length} 条` : `当前页已选择 ${n2.length} 条`; - utils.q(e2, `${selectionText},是否标记为${a2}?`, (async () => { - let e3 = loading(); - try { - if ("delete" === i2) { - const e4 = n2.map(((e5) => e5.carNum)), t3 = await stateService.remove(e4); - if (!t3.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); - show.ok(`已成功删除 ${t3.changed.length} 个番号`); - } else { - const flag = legacyActionToFlag(i2); - await stateService.patch(n2.map(((item) => item.carNum)), { [flag]: true }, { type: "history-batch-state", records: n2 }), show.ok("操作成功"); - } - this.resetHistorySelection(), await this.reloadTable(false); - } catch (t3) { - clog.error(t3), show.error("操作失败,请稍后重试"); - } finally { - e3.close(); - } - })); - })); - } - /** 返回应用当前搜索、状态筛选和排序后的完整 History 数据。 */ - async getFilteredHistoryData(sorters = this.historySorters) { - let a2 = await storageManager.getCarList(); - this.allCount = a2.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a2.forEach(((e2) => { - const flags = normalizeStateFlags(e2.stateFlags); - flags.blocked && this.filterCount++, flags.favorite && this.favoriteCount++, flags.downloaded && this.hasDownCount++, flags.watched && this.hasWatchCount++, hasAnyState(flags) || this.waitCheckCount++; - })), this.historyRoot.find('#dataType option[value="all"]').text(`所有 (${this.allCount})`), this.historyRoot.find('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), this.historyRoot.find('#dataType option[value="filter"]').text(`${u} (${this.filterCount})`), this.historyRoot.find('#dataType option[value="favorite"]').text(`${b} (${this.favoriteCount})`), this.historyRoot.find('#dataType option[value="hasDown"]').text(`${y} (${this.hasDownCount})`), this.historyRoot.find('#dataType option[value="hasWatch"]').text(`${k} (${this.hasWatchCount})`); - const i2 = this.historyRoot.find("#dataType").val(); - const flagByFilter = { filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" }; - let s2 = "all" === i2 ? a2 : "waitCheck" === i2 ? a2.filter(((e2) => !hasAnyState(e2.stateFlags))) : a2.filter(((e2) => normalizeStateFlags(e2.stateFlags)[flagByFilter[i2]])); - const o2 = this.historyRoot.find("#searchCarNum").val().trim(); - if (o2) { - let e2 = o2.toLowerCase().replace("-c", "").replace("-uc", "").replace("-4k", ""); - s2 = s2.filter(((t2) => { - const n2 = t2.carNum.toLowerCase().includes(e2); - const a3 = (t2.names ? t2.names : "").toLowerCase().includes(e2); - return n2 || a3; - })); - } - if (sorters && sorters.length > 0) { - const e2 = sorters[0], t2 = e2.field, a3 = e2.dir; - s2.sort(((e3, n2) => { - const i3 = e3[t2], s3 = n2[t2], o3 = null == i3 || "" === i3, r2 = null == s3 || "" === s3; - return o3 && !r2 ? 1 : !o3 && r2 ? -1 : o3 && r2 ? 0 : i3 < s3 ? "asc" === a3 ? -1 : 1 : i3 > s3 ? "asc" === a3 ? 1 : -1 : 0; - })); - } - return s2; - } - async getDataList(e2, t2, n2) { - this.historySorters = Array.isArray(n2) ? n2.map(((sorter) => ({ - ...sorter - }))) : []; - const rows = await this.getFilteredHistoryData(this.historySorters), r2 = rows.length, l2 = Math.ceil(r2 / t2), c2 = (e2 - 1) * t2, m2 = c2 + t2; - return this.historyFilteredCount = r2, { - maxPage: l2, - dataList: rows.slice(c2, m2), - totalCount: r2 - }; - } - async loadTableData() { - this.tableObj = new Tabulator(this.historyRoot.find("#table-container").get(0), { - layout: "fitColumns", - placeholder: "暂无数据", - virtualDom: true, - pagination: true, - paginationMode: "remote", - sortMode: "remote", - ajaxURL: "queryRealm", - dataLoader: false, - ajaxRequestFunc: /* @__PURE__ */ __name(async (e2, t2, n2) => { - const a2 = n2.page, i2 = n2.size, s2 = n2.sort; - return await this.getDataList(a2, i2, s2); - }, "ajaxRequestFunc"), - dataReceiveParams: { - last_page: "maxPage", - last_row: "totalCount", - data: "dataList" - }, - paginationSize: 50, - paginationSizeSelector: [50, 100, 1e3], - paginationCounter: /* @__PURE__ */ __name((e2, t2, n2, a2, i2) => `共 ${a2} 条记录`, "paginationCounter"), - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - selectableRowsPersistence: false, - index: "carNum", - columns: [{ - formatter: "rowSelection", - titleFormatter: /* @__PURE__ */ __name(() => this.createHistorySelectAllCheckbox(), "titleFormatter"), - hozAlign: "center", - headerSort: false, - responsive: 0, - width: 40, - cellClick: /* @__PURE__ */ __name((e2, t2) => { - t2.getRow().toggleSelect(); - }, "cellClick") - }, { - title: "番号", - field: "carNum", - width: 120, - sorter: "string", - responsive: 0, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const a2 = e2.getData().carNum, i2 = a2.indexOf("-"); - if (-1 === i2) return a2; - return `${a2.substring(i2 + 1)}`; - }, "formatter") - }, { - title: "演员", - field: "names", - minWidth: 200, - sorter: "string", - responsive: 5, - headerSort: true, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => (e2.getData().names || "").split(" ").filter(((e3) => "" !== e3.trim())).map(((e3) => ``)).join(" "), "formatter") - }, { - title: "创建时间", - field: "createDate", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "修改时间", - field: "updateDate", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "发行时间", - field: "publishTime", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "来源", - field: "url", - width: 80, - sorter: "string", - responsive: 5, - hozAlign: "left", - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - let a2 = e2.getData().url; - return a2 ? `${a2.includes("javdb") ? "JavDB" : a2.includes("javbus") ? "JavBus" : a2.includes("123av") ? "123AV" : "其他"}` : ""; - }, "formatter") - }, { - title: "状态", - field: "stateFlags", - width: 220, - sorter: false, - responsive: 1, - headerSort: false, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const flags = normalizeStateFlags(e2.getData().stateFlags), badges = [[flags.blocked, "filter", u], [flags.favorite, "fav", b], [flags.downloaded, "down", y], [flags.watched, "watch", k]].filter(((item) => item[0])).map(((item) => `${item[2]}`)); - return badges.join(" ") || '待鉴定'; - }, "formatter") - }, { - title: "备注", - field: "remark", - width: 100, - sorter: "string", - responsive: 6 - }, { - title: "操作", - sorter: "string", - minWidth: 150, - cssClass: "action-cell-dropdown", - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const a2 = e2.getData(); - return n2((() => { - var t3; - null == (t3 = e2.getElement().querySelector(".history-editBtn")) || t3.addEventListener("click", ((e3) => { - this.editRecord(a2); - })); - })), ` -
    - -
    - - -
    -
    `; - }, "formatter") - }], - initialSort: [{ - column: "updateDate", - dir: "desc" - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } - } - } - }), this.tableObj.on("dataProcessed", (() => { - this.historySelectAll ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); - })), this.tableObj.on("rowSelected", ((row) => { - this.updateHistoryRowSelection(row, true); - })), this.tableObj.on("rowDeselected", ((row) => { - this.updateHistoryRowSelection(row, false); - })), this.tableObj.on("rowSelectionChanged", (() => { - this.updateHistorySelectionUi(); - })), this.tableObj.on("rowDblClick", (function(e2, t2) { - t2.toggleSelect(); - })); - } - handleDelete(e2, t2) { - utils.q(e2, `是否移除${t2}?`, (async () => { - await stateService.remove(t2), this.getBean("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); - })); - } - async handleClickDetail(e2, t2) { - if (r) if (t2.carNum.includes("FC2-")) { - const e3 = this.parseMovieId(t2.url); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); - plugin.openFc2Dialog(e3, t2.carNum, t2.url, { source }); - } else { - if (!t2.url) return void window.open("/search?q=" + t2.carNum, "_blank"); - utils.openPage(t2.url, t2.carNum, false, e2); - } - if (l) { - let n2 = t2.url; - if (n2.includes("javdb")) if (t2.carNum.includes("FC2-")) { - const e3 = this.parseMovieId(n2); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); - await plugin.openFc2Page(e3, t2.carNum, n2, { newTab: true }, { source }); - } else window.open(n2, "_blank"); - else utils.openPage(t2.url, t2.carNum, false, e2); - } - } - async editRecord(e2) { - const t2 = e2.carNum, n2 = e2.names || "", a2 = e2.url || "", flags = normalizeStateFlags(e2.stateFlags), s2 = e2.remark || ""; - let editRoot = $(); - const c2 = ` -
    -
    - - -
    -
    - - -
    -
    状态: - - - - -
    -
    - - -
    -
    - - -
    -
    - `; - layer.open({ - type: 1, - title: `编辑记录: ${t2}`, - area: utils.getDialogArea("sm"), - content: c2, - btn: ["保存", "取消"], - success: /* @__PURE__ */ __name((e3, t3) => { - editRoot = $(e3); - const n3 = /* @__PURE__ */ __name((e4) => { - e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); - }, "n"), a3 = editRoot.find("#edit-names"); - a3.on("input", (function() { - n3($(this)); - })), n3(a3); - const i2 = editRoot.find("#edit-remark"); - i2.on("input", (function() { - n3($(this)); - })), n3(i2); - }, "success"), - yes: /* @__PURE__ */ __name(async (t3) => { - const n3 = editRoot.find("#edit-names").val().trim(), i2 = editRoot.find("#edit-url").val().trim(), s3 = editRoot.find("#edit-remark").val().trim(), nextFlags = { - favorite: editRoot.find("#edit-favorite").prop("checked"), - downloaded: editRoot.find("#edit-downloaded").prop("checked"), - watched: editRoot.find("#edit-watched").prop("checked"), - blocked: editRoot.find("#edit-blocked").prop("checked") - }; - const save = /* @__PURE__ */ __name(async () => { - await stateService.patch(e2.carNum, nextFlags, { type: "history-edit", replaceMetadata: true, record: { ...e2, names: n3, url: i2, remark: s3 } }), this.tableObj.setData(), layer.close(t3); - }, "save"); - if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e2.carNum}?`, (() => void save())), false; - await save(); - }, "yes") - }); - } - }; - __name(_HistoryPlugin, "HistoryPlugin"); - var HistoryPlugin = _HistoryPlugin; - var _ReviewPlugin = class _ReviewPlugin extends BasePlugin { - getName() { - return "ReviewPlugin"; - } - async initCss() { - return ` - `; - } - async handle() { - if (!window.isDetailPage) return; - if (r) { - const movieId = this.parseMovieId(window.location.href); - const workspace = this.getBean("DetailWorkspacePlugin"); - await Promise.all([this.showReview(movieId, workspace?.getSlot("reviews")), this.getBean("RelatedPlugin").showRelated(workspace?.getSlot("related"), movieId)]); - } - if (l) { - const carNumber = this.getPageInfo().carNum; - if (!carNumber) return void clog.warn("跳过 JavBus 评论解析:番号不可用"); - const movieId = await resolveJavDbMovieId(carNumber); - movieId && await this.showReview(movieId, this.getBean("DetailWorkspacePlugin")?.getSlot("reviews")); - } - } - async showReview(movieId, target, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const enabled = await storageManager.getSetting("enableLoadReview", _), host = target?.length ? target : this.getBean("DetailWorkspacePlugin")?.getSlot("reviews") || $("#magnets-content"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="reviews"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('
    ').attr("data-jhs-movie-id", String(movieId)); - const header = $('

    评论

    '); - const toggle = $(''); - const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle) : panel.append(header), panel.append('
    ', ''), host.append(panel), this.bindRightClickFilter(); - state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-review-container, .jhs-review-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayReviews(state), storageManager.saveSettingItem("enableLoadReview", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayReviews(state) : panel.find(".jhs-review-container, .jhs-review-footer").hide(); - return panel; - } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); - } - async fetchAndDisplayReviews(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = true; - const { movieId, panel } = state, container = panel.find(".jhs-review-container"), footer = panel.find(".jhs-review-footer"); - container.empty().append($('
    ').text("获取评论中...")), footer.empty(); - const pageSize = await storageManager.getSetting("reviewCount", 20); - let reviews; - try { - reviews = await R(movieId, 1, pageSize); - } catch (error) { - error.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", error), clog.error("获取评论失败:", error); - state.loading = false; - return void this.renderRetry(container, "获取评论失败", (() => this.fetchAndDisplayReviews(state))); - } - if (!state.isActive?.()) return void (state.loading = false); - state.loading = false, state.loaded = true; - container.empty(); - if (!reviews.length) return void container.append($('
    ').text("无评论")); - const keywords = await storageManager.getReviewFilterKeywordList(); - await this.displayReviews(state, reviews, container, keywords); - reviews.length === pageSize ? this.bindLoadMore(state, pageSize, keywords, container, footer) : footer.append($('
    ').text("已加载全部评论")); - } - renderRetry(container, message, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode(`${message} `)); - const button = $('').on("click", retry); - state.append(button), container.append(state); - } - bindLoadMore(state, pageSize, keywords, container, footer) { - const button = $(''), end = $('
    已加载全部评论
    ').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - button.text("加载中...").prop("disabled", true); - try { - const reviews = await R(state.movieId, nextPage, pageSize); - if (!state.isActive?.()) return; - state.page = nextPage; - await this.displayReviews(state, reviews, container, keywords), reviews.length < pageSize ? (button.remove(), end.show()) : button.text("加载更多评论").prop("disabled", false); - } catch (error) { - clog.error("加载更多评论失败:", error), button.text("加载失败,请重试").prop("disabled", false); - } - })); - } - async displayReviews(state, reviews, container, keywords) { - if (!reviews.length) return; - const filter = keywords.length > 0 ? new RegExp(keywords.map(((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("|")) : null; - for (const review of reviews) { - const content = String(review.content || ""); - if (filter?.test(content)) continue; - const item = $('
    '), meta = $('
    '), body = $('
    '); - meta.append($("").addClass("jhs-review-author").text(review.username || "匿名用户")); - const stars = $(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); - for (let index = 0; index < score; index++) stars.append(''); - meta.append( - stars, - $("").text(utils.formatDate(review.created_at)), - $("").text(`点赞:${Number(review.likes_count) || 0}`), - $("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`) - ); - await this.appendReviewContent(body, content), item.append(meta, body), container.append(item); - } - } - appendReviewContent(container, content) { - const linkPattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; - let cursor = 0, match; - while (match = linkPattern.exec(content)) { - match.index > cursor && container.append(document.createTextNode(content.slice(cursor, match.index))); - this.appendLinkControls(container, match[0]), cursor = match.index + match[0].length; - } - cursor < content.length && container.append(document.createTextNode(content.slice(cursor))); - } - appendLinkControls(container, value) { - const isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接"; - const isResource = isMagnet || isEd2k, wrapper = $(isResource ? '' : ''); - const main = $(''); - const open = isEd2k ? $('').text(label).on("click", (() => utils.copyToClipboard(label, value))) : $("").addClass("jhs-review-link").attr({ - href: value, - target: "_blank", - rel: "noopener noreferrer" - }).text(label); - const copy = $('').on("click", (() => utils.copyToClipboard(label, value))); - main.append(open, copy), wrapper.append(main); - if (isResource) { - const actions = $(''); - actions.append(``); - wrapper.append(actions); - } - container.append(wrapper); - } - bindRightClickFilter() { - $(document).off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", (async (event) => { - if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; - const text = window.getSelection().toString(); - text && (event.preventDefault(), await utils.q(event, `是否将 '${text}' 加入评论区关键词?`, (async () => { - await storageManager.saveReviewFilterKeyword(text), show.ok("操作成功, 刷新页面后生效"); - }))); - })); - } - }; - __name(_ReviewPlugin, "ReviewPlugin"); - var ReviewPlugin = _ReviewPlugin; - var _FilterTitleKeywordPlugin = class _FilterTitleKeywordPlugin extends BasePlugin { - getName() { - return "FilterTitleKeywordPlugin"; - } - async handle() { - if (!isDetailPage) return; - await this.bindDetailRoot(document); - } - async bindDetailRoot(root, { layerIndex = null } = {}) { - if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; - const host = $(root), selector = r ? ".title strong, .current-title" : l ? "h3" : ".current-title, .origin-title, .jhs-detail-title"; - host.off("contextmenu.jhsTitleFilter", selector).on("contextmenu.jhsTitleFilter", selector, ((e2) => { - const t2 = window.getSelection().toString(); - if (t2) { - e2.preventDefault(); - let n2 = { - clientX: e2.clientX, - clientY: e2.clientY + 80 - }; - utils.q(n2, `是否屏蔽标题关键词 ${t2}?`, (async () => { - await storageManager.saveTitleFilterKeyword(t2), await jhsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }), utils.closePage({ root: host, layerIndex }); - })); - } - })); - } - }; - __name(_FilterTitleKeywordPlugin, "FilterTitleKeywordPlugin"); - var FilterTitleKeywordPlugin = _FilterTitleKeywordPlugin; - var _BlacklistPlugin = class _BlacklistPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "blacklistSearchDebounced", null), i(this, "taskStatusUnsubscribe", null); - } - getName() { - return "BlacklistPlugin"; - } - async initCss() { - return ``; - } - async addBlacklist(e2) { - let t2 = { - clientX: e2.clientX, - clientY: e2.clientY + 80 - }; - const n2 = $("#addBlacklistBtn span").text().includes("已加入"); - let a2, i2; - if (o.includes("/tags")) { - const e3 = new URL(o); - e3.searchParams.delete("page"); - const t3 = $("#jhs-check-tag").text().trim(); - a2 = { - starId: "no-" + t3, - name: "虚拟演员-" + t3, - allName: ["虚拟演员"], - role: "虚拟演员", - movieType: t3, - blacklistUrl: e3.toString() - }, i2 = `是否将分类 ${t3} 加入到黑名单中?`, n2 && (i2 = `分类 ${t3} 已在黑名单中, 是否从当前页开始追加屏蔽?`); - } else a2 = this.getActressPageInfo(), i2 = `是否将该演员 ${a2.name} 加入到黑名单中?`, n2 && (i2 = `演员 ${a2.name} 已在黑名单中, 是否从当前页开始追加屏蔽?`); - const { starId: s2, name: r2, allName: c2, role: d2, movieType: h2, blacklistUrl: g2 } = a2; - if (o.includes("page") && !o.includes("page=1") && (i2 += "
    注意: 当前页面非第一页, 屏蔽数据将从此页面开始"), l) { - const e3 = o.split("/star/")[1].split("/"); - if (e3.length > 1) { - parseInt(e3[1]) > 1 && (i2 += "
    注意: 当前页面非第一页, 屏蔽数据将从此页面开始"); - } - } - utils.q(t2, i2, (async () => { - const e3 = this.getBean("TaskPlugin"); - navigator.locks.request(e3.singleTaskKey, { - ifAvailable: true - }, (async (e4) => { - if (clog.debug("获取锁", e4), e4) { - this.loadObj = loading(); - try { - await storageManager.addBlacklistItem({ - starId: s2, - name: r2, - allName: c2, - role: d2, - movieType: h2, - url: g2 - }), await this.filterActorVideo(r2, s2, null, l ? I : T); - const e5 = show.ok(`屏蔽结束,是否跳转到最后一页: ${this.lastPageLink}`, { - duration: -1, - close: true, - onClick: /* @__PURE__ */ __name(() => { - e5.closeShow(), window.location.href = this.lastPageLink; - }, "onClick") - }); - } catch (t3) { - clog.error(t3); - const e5 = show.error("发生错误, 是否填转到解析失败的那一页? (点击并跳转)", { - duration: -1, - close: true, - onClick: /* @__PURE__ */ __name(() => { - e5.closeShow(), window.location.href = this.nextPageLink; - }, "onClick") - }); - } finally { - this.loadObj.close(); - } - } else show.error("当前有定时任务在后台执行中, 无法发起此操作"); - })).catch(((e4) => { - clog.error("锁任务出现错误:", e4); - })); - })); - } - async resetBtnTip() { - const e2 = this.getBean("TaskPlugin"), t2 = localStorage.getItem(e2.lastCheckBlacklistTimeKey) || "无", n2 = await storageManager.getSetting("checkBlacklist_intervalTime", 12); - this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime", 8760), $("#checkBlacklistBtn").attr("data-tip", `上次整批检测: ${t2}; 检测间隔时间: ${n2}小时`); - } - async openBlacklistDialog() { - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getSetting(); - let n2 = ` -
    -
    -
    - - -
    -
    - - - - - -
    - -
    -
    -
    - `; - layer.open({ - type: 1, - title: "演员黑名单", - content: n2, - scrollbar: false, - area: utils.getDialogArea("xl"), - anim: -1, - success: /* @__PURE__ */ __name(async (t3) => { - const dialog = $(t3).find(".layui-layer-content > div").first().addClass("jhs-blacklist-layout").removeAttr("style"), toolbar = dialog.children("div").first().addClass("jhs-blacklist-toolbar").removeAttr("style"); - toolbar.children("div").addClass("jhs-blacklist-toolbar__group").removeAttr("style"), toolbar.find("select,input,a").removeAttr("style"), dialog.find("#table-container").removeAttr("style"); - dialog.find("#table-container").before('
    '), JhsSelect.enhance(t3); - this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.renderTaskStatus())); - await this.loadTableData(); - const content = $(t3).find(".layui-layer-content"), search = content.find("#searchValue"); - this.blacklistSearchDebounced = utils.debounce((() => void this.reloadTable()), 200), content.on("click", "#cleanQueryBtn", (async () => { - search.val(""), JhsSelect.setValue("#dataType", "", false), JhsSelect.setValue("#statusType", "", false), JhsSelect.setValue("#urlType", "", false), await this.reloadTable(); - })).on("input", "#searchValue", this.blacklistSearchDebounced).on("change", "#dataType,#statusType,#urlType", (async () => { - await this.reloadTable(); - })).on("click", "#toSetting", (() => { - this.getBean("SettingPlugin").openSettingDialog("task-panel", (() => { - $("#setting-blacklist").css({ - border: "1px solid var(--jhs-status-filter)" - }); - })); - })).on("click", ".open-url", ((e3) => { - e3.preventDefault(); - const t4 = $(e3.currentTarget), n3 = t4.attr("data-url"), a2 = t4.attr("data-name"); - utils.openPage(n3, a2, true, e3); - })).on("click", "#checkBlacklistBtn", ((event) => { - const button = $(event.currentTarget), label = button.find("span").last(), previous = label.text(); - if (button.attr("aria-busy") === "true") return; - button.attr("aria-busy", "true").prop("disabled", true), label.text("检测中…"), navigator.locks.request(e2.singleTaskKey, { ifAvailable: true }, (async (lock) => { - lock ? await e2.checkBlacklist(true) : show.error("后台任务正在运行,请稍后再试"); - })).catch(((error) => { - clog.error("锁任务出现错误:", error), e2.isNetworkBlocked(error) && show.error(error.message || "任务执行失败"); - })).finally((() => { - button.removeAttr("aria-busy").prop("disabled", false), label.text(previous); - })); - })); - }, "success"), - end: /* @__PURE__ */ __name(async () => { - this.blacklistSearchDebounced?.cancel?.(), this.blacklistSearchDebounced = null, this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null), await jhsEventBus.emit("blacklist-rules-changed"); - }, "end") - }); - } - renderTaskStatus() { - const container = $("#blacklist-task-status"); - if (!container.length) return; - const snapshot = this.getBean("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); - container.empty().append($('').text(`黑名单:${labels[snapshot.state]}`), $('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); - } - async reloadTable() { - if (!this.tableObj) return; - const e2 = await this.getTableData(); - this.tableObj.setData(e2); - } - async getTableData() { - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getBlacklist(), n2 = await storageManager.getBlacklistCarList(), a2 = String($("#searchValue").val() || "").trim().toLocaleLowerCase(), i2 = $("#statusType").val(), s2 = $("#dataType"), o2 = s2.val(), r2 = $("#urlType").val(), l2 = t2.length; - let c2 = 0, d2 = 0; - const h2 = t2.map(((t3) => { - t3.role === B ? c2++ : t3.role === P && d2++; - let n3 = false; - return n3 = shouldSkipStopped(t3.lastPublishTime, this.checkBlacklist_ruleTime), { - ...t3, - isUnCheck: n3 - }; - })).filter(((item) => { - const aliases = Array.isArray(item.allName) ? item.allName.join(" ") : item.allName || "", searchable = `${item.name || ""} ${aliases} ${item.starId || ""}`.toLocaleLowerCase(), searchMatch = !a2 || searchable.includes(a2), statusMatch = !i2 || "normal" === i2 && !item.isUnCheck || "stop" === i2 && item.isUnCheck, roleMatch = !o2 || item.role === o2, hasCategory = item.url.includes("t="), urlMatch = !r2 || "hasT" === r2 && hasCategory || "noT" === r2 && !hasCategory; - return searchMatch && statusMatch && roleMatch && urlMatch; - })); - s2.html(` - - - - `), JhsSelect.setValue(s2, o2, false); - const g2 = /* @__PURE__ */ new Map(); - for (const m2 of n2) { - const e3 = m2.starId; - g2.has(e3) || g2.set(e3, []), g2.get(e3).push(m2); - } - const p2 = h2.map(((e3) => { - const t3 = e3.starId, n3 = g2.get(t3) || []; - return { - ...e3, - carList: n3, - count: n3.length - }; - })); - return this.currentCarCount = p2.reduce(((e3, t3) => e3 + (t3.count || 0)), 0), p2; - } - async loadTableData() { - this.checkBlacklist_ruleTime = parseNumberSetting(await storageManager.getSetting("checkBlacklist_ruleTime"), 8760, { min: 0 }); - const e2 = await this.getTableData(), placeholder = document.createElement("div"); - renderStateView(placeholder, { type: "empty", title: "没有符合当前筛选条件的黑名单记录" }); - this.tableObj = new Tabulator("#table-container", { - layout: "fitColumns", - placeholder, - virtualDom: true, - data: e2, - pagination: true, - paginationMode: "local", - paginationSize: 20, - paginationSizeSelector: [20, 50, 100, 1e3, true], - paginationCounter: /* @__PURE__ */ __name((e3, t2, n2, a2, i2) => `演员: ${a2}    番号总数: ${this.currentCarCount} `, "paginationCounter"), - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - index: "starId", - columns: [{ - title: "演员", - field: "name", - sorter: "string", - minWidth: 100, - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData(), url = normalizeHttpUrl(a2.url), link = document.createElement("a"); - link.className = "open-url", link.textContent = String(a2.name || ""), link.dataset.name = String(a2.name || ""); - return url ? (link.href = url, link.dataset.url = url, link.target = "_blank", link.rel = "noopener noreferrer") : (link.href = "#", link.setAttribute("aria-disabled", "true")), link; - }, "formatter") - }, { - title: "性别角色", - field: "role", - sorter: "string", - width: 120, - responsive: 5, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData().role; - let i2 = a2; - return a2 === B ? i2 = "男演员" : a2 === P && (i2 = "女演员"), i2; - }, "formatter") - }, { - title: "影视类别", - field: "movieType", - sorter: "string", - width: 120, - responsive: 5, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData().movieType; - let i2 = a2; - return a2 === D ? i2 = "有码" : a2 === A && (i2 = "无码"), i2; - }, "formatter") - }, { - title: "屏蔽类型", - field: "url", - sorter: "string", - minWidth: 120, - responsive: 4, - visible: r, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - let a2 = e3.getData().url.includes("t="); - return `${a2 ? "按所选分类屏蔽" : "未筛选分类"}`; - }, "formatter") - }, { - title: "番号数量", - field: "count", - sorter: "number", - width: 170, - responsive: 1 - }, { - title: "创建时间", - field: "createTime", - sorter: "string", - width: 170, - responsive: 5 - }, { - title: "最后发行时间", - field: "lastPublishTime", - sorter: "string", - width: 170, - responsive: 1 - }, { - title: "状态", - field: "isUnCheck", - sorter: "string", - width: 120, - responsive: 1, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - let a2 = "", i2 = "继续检测"; - return e3.getData().isUnCheck && (a2 = `停更${this.checkBlacklist_ruleTime / 24 / 365}年以上, 下轮任务不再进行检测`, i2 = "停更跳过"), `${i2}`; - }, "formatter") - }, { - title: "操作", - sorter: "string", - cssClass: "action-cell-dropdown", - minWidth: 150, - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, t2, n2) => { - const a2 = e3.getData(); - return n2((() => { - var t3, n3; - null == (t3 = e3.getElement().querySelector(".delete-btn")) || t3.addEventListener("click", ((e4) => { - const t4 = a2.name, n4 = a2.starId; - t4 ? n4 ? utils.q(e4, `是否移除对 ${t4} 的屏蔽?`, (async () => { - await storageManager.removeBlacklistCarList(n4), await storageManager.deleteBlacklistItem(n4), show.info("操作成功"), await this.reloadTable(); - })) : show.error("获取starId失败") : show.error("获取名称失败"); - })), null == (n3 = e3.getElement().querySelector(".keyword-btn")) || n3.addEventListener("click", ((e4) => { - const t4 = a2.carList.reduce(((e5, t5) => { - const n5 = t5.carNum.split("-")[0] + "-"; - return e5[n5] = (e5[n5] || 0) + 1, e5; - }), {}), n4 = Object.entries(t4).map((([e5, t5]) => ({ - prefix: e5, - count: t5 - }))).sort(((e5, t5) => t5.count - e5.count)); - clog.debug(n4); - })); - })), ''; - }, "formatter") - }], - initialSort: [{ - column: "createTime", - dir: "desc" - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "全部", - page_size: "每页行数" - } - } - } - }); - } - async filterAllVideo(e2, t2, page = 1, processed = 0) { - let n2, a2; - if (t2 ? (l && t2.find(".avatar-box").length > 0 && t2.find(".avatar-box").parent().remove(), n2 = t2.find(this.getSelector().requestDomItemSelector), a2 = t2.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")), a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); - for (const s2 of n2) { - const t3 = $(s2), { carNum: n3, url: a3, publishTime: o2 } = this.getBean("ListPagePlugin").findCarNumAndHref(t3); - if (a3 && n3) try { - await stateService.patch(n3, { blocked: true }, { type: "actor-page-block", record: { carNum: n3, url: a3, names: e2, publishTime: o2 } }), clog.log("屏蔽演员番号", e2, n3); - } catch (i2) { - clog.error(`保存失败 [${n3}]:`, i2); - } - } - processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); - if (a2) { - clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); - const t3 = await gmHttp.get(a2), n3 = new DOMParser(), i2 = $(n3.parseFromString(t3, "text/html")); - await this.filterAllVideo(e2, i2, page + 1, processed); - } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); - } - async batchSaveAllVideos(e2, t2, pageDom = null, page = 1, processed = 0) { - let n2, a2; - pageDom ? (n2 = pageDom.find(this.getSelector().requestDomItemSelector), a2 = pageDom.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")); - if (a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); - for (const i2 of n2) { - const n3 = $(i2), { carNum: a3, url: o2, publishTime: r2 } = this.getBean("ListPagePlugin").findCarNumAndHref(n3); - if (o2 && a3) try { - const flag = legacyActionToFlag(t2); - flag && await stateService.patch(a3, { [flag]: true }, { type: "actor-page-batch-state", record: { carNum: a3, url: o2, names: e2, publishTime: r2 } }), clog.log("批量操作", e2, a3, t2); - } catch (s2) { - clog.error(`保存失败 [${a3}]:`, s2); - } - } - processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); - if (a2) { - clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); - const i2 = await gmHttp.get(a2), s2 = new DOMParser(), o2 = $(s2.parseFromString(i2, "text/html")); - await this.batchSaveAllVideos(e2, t2, o2, page + 1, processed); - } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); - } - async filterActorVideo(e2, t2, n2, site = l ? I : T, page = 1, processed = 0) { - let { nextPageLink: a2, recordCount } = await this.parseAndSaveFilterInfo(n2, e2, t2, site); - processed += recordCount, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已屏蔽 ${processed} 个番号`); - if (this.nextPageLink = a2, a2) { - let n3; - this.lastPageLink = a2; - clog.log("正在请求下一页内容:", a2); - const i2 = await gmHttp.get(a2); - n3 = utils.htmlTo$dom(i2); - await this.filterActorVideo(e2, t2, n3, site, page + 1, processed); - } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 新增 ${processed} 个番号`); - } - async parseAndSaveFilterInfo(e2, t2, n2, site) { - if (![T, I].includes(site)) throw new Error(`未知黑名单来源站点: ${site}`); - const page = e2 || $(document), selector = this.getSelector(site); - site === I && page.find(".avatar-box").length > 0 && page.find(".avatar-box").parent().remove(); - const pageState = parseDetailPage(page, { - boxSelector: site === I ? `${selector.boxSelector}, #waterfall` : selector.boxSelector, - requestDomItemSelector: e2 ? selector.requestDomItemSelector : selector.itemSelector - }), nextPageLink = page.find(selector.nextPageSelector).attr("href"); - if ("valid" !== pageState.state) throw new Error(`黑名单作品页面无效: ${pageState.state}`); - if (pageState.isEmpty && nextPageLink) throw new Error("黑名单作品空页面包含下一页"); - const records = [], publishTimes = []; - for (const item of pageState.items) { - const element = $(item), { carNum, url, publishTime } = this.getBean("ListPagePlugin").findCarNumAndHref(element); - publishTime && publishTimes.push(publishTime), url && carNum && records.push({ - carNum, - url, - names: t2, - actionType: d, - starId: n2, - publishTime - }); - } - await storageManager.batchSaveBlacklistCarList(records); - return { - nextPageLink, - lastPublishTime: selectLatestPublishTime(publishTimes), - recordCount: records.length - }; - } - }; - __name(_BlacklistPlugin, "BlacklistPlugin"); - var BlacklistPlugin = _BlacklistPlugin; - var _ListPageButtonPlugin = class _ListPageButtonPlugin extends BasePlugin { - getName() { - return "ListPageButtonPlugin"; - } - async handle() { - if (!window.isListPage) return; - await this.createMenuBtn(), this.bindEvent(); - const e2 = await storageManager.getSetting("autoPage"), t2 = this.supportsLiveSorting(); - $("#sort-toggle-btn").prop("disabled", e2 === _ && !t2).attr("title", e2 === _ && !t2 ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"), (e2 !== _ || t2) && await this.sortItems(); - } - async createMenuBtn() { - if (r) { - const e2 = o.includes("/actors/"); - let t2 = $(".main-tabs, .tabs"), n2 = "加入黑名单", a2 = "jhs-btn--filter", s2 = null; - if (e2) { - t2 = $(".toolbar, .section-addition").filter(":last"); - const e3 = await storageManager.getBlacklist(), i2 = this.getActressPageInfo(); - e3.find(((e4) => e4.starId === i2.starId)) && (n2 = "已加入黑名单", a2 = "jhs-btn--muted"); - } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { - const e3 = $("#addBlacklistBtn"); - e3.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); - const t3 = $("#jhs-check-tag").text().trim(); - if (!t3) return; - const n3 = "no-" + t3, a3 = await storageManager.getBlacklist(); - s2 = a3.find(((e4) => e4.starId === n3)), s2 && (e3.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); - })); - const r2 = o.includes("advanced_search"); - r2 && (t2 = $("h2.section-title")); - const l2 = localStorage.getItem("jhs_sortMethod"), d2 = "当前排序方式: " + ("rateCount" === l2 ? "评价人数" : "date" === l2 ? "时间" : "默认"); - t2.append(` -
    - - ${e2 ? ` - - - - - ` : ""} - ${o.includes("/tags") ? ` - - ` : ""} -
    -
    - - - ${c ? "" : this.sortMenuHtml(l2 || "default", d2)} -
    - `); - } - if (l) { - const e2 = o.includes("/star/"); - let t2 = "加入黑名单", n2 = "jhs-btn--filter"; - if (e2) { - const e3 = await storageManager.getBlacklist(), a3 = this.getActressPageInfo(); - e3.find(((e4) => e4.starId === a3.starId)) && (t2 = "已加入黑名单", n2 = "jhs-btn--muted"); - } - const a2 = localStorage.getItem("jhs_sortMethod") || "default"; - $(".masonry").parent().prepend(` -
    - - ${e2 ? ` - - - - - ` : ''} - ${this.sortMenuHtml(a2)} -
    - `); - } - $("#waitCheckBtn > span").text("开始鉴定"); - const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); - newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); - } - /** 构建与原排序值兼容的 JHS 菜单。 */ - sortMenuHtml(method, title = "选择列表排序方式") { - const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, current = labels[method] || labels.default; - return `
    `; - } - bindEvent() { - $("#waitCheckBtn").on("click", ((e3) => { - void this.openWaitCheck(e3).catch(((error) => clog.error("待鉴定列表打开失败", error))); - })), $("#newVideoBtn").on("click", ((e3) => { - this.getBean("NewVideoPlugin").openDialog(); - })), $("#blacklistBtn").on("click", ((e3) => { - this.getBean("BlacklistPlugin").openBlacklistDialog(); - })), this.bindSortMenu(); - const e2 = this.getBean("BlacklistPlugin"); - $("#addBlacklistBtn").on("click", (async (t2) => { - await e2.addBlacklist(t2); - })), $("#filterAllVideo").on("click", (async (t2) => { - let n2 = { - clientX: t2.clientX, - clientY: t2.clientY + 80 - }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键屏蔽视频列表?", (async () => { - this.loadObj = loading(); - try { - await e2.filterAllVideo(i2); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })), $("#favoriteAllVideo").on("click", (async (t2) => { - let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键收藏所有可见作品?", (async () => { - this.loadObj = loading(); - try { - await e2.batchSaveAllVideos(i2, h); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })), $("#hasDownAllVideo").on("click", (async (t2) => { - let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键已下载所有可见作品?", (async () => { - this.loadObj = loading(); - try { - await e2.batchSaveAllVideos(i2, g); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })); - } - /** 绑定排序 popover 的选择与键盘交互。 */ - bindSortMenu() { - const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = /* @__PURE__ */ __name((focus = false) => { - menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); - }, "close"); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const open = !menu.hasClass("is-open"); - menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); - })); - menu.on("click", ".jhs-sort-option", ((event) => { - const item = $(event.currentTarget), method = item.data("sort-method"); - localStorage.setItem("jhs_sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(true), void this.sortItems().catch(((error) => clog.error("列表排序失败", error))); - })).on("keydown", ".jhs-sort-option", ((event) => { - const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), close(true); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", ((event) => { - $(event.target).closest(control).length || close(); - })); - } - async sortItems() { - const e2 = this.supportsLiveSorting(); - if (!e2 && (o.includes("handle") || o.includes("advanced_search"))) return; - const s2 = await storageManager.getSetting("autoPage"); - if (c || s2 === _ && !e2) return; - const t2 = localStorage.getItem("jhs_sortMethod"); - if (!t2) return; - const i2 = this.getSelector(), d2 = $(i2.boxSelector), h2 = $(i2.itemSelector); - h2.each((function(e3) { - $(this).attr("data-original-index") || $(this).attr("data-original-index", e3); - })); - const items = h2.get().map(((element, index) => { - const card = $(element), originalIndex = Number(card.attr("data-original-index")) || 0; - if ("default" === t2) return { element, key: originalIndex, originalIndex, index }; - if ("rateCount" === t2) { - const explicit = Number(card.attr("data-jhs-rate-count")), match = card.find(".score").text().replaceAll(",", "").match(/(?:由\s*)?(\d+)\s*人(?:评价)?/); - return { element, key: Number.isFinite(explicit) ? explicit : match ? Number(match[1]) : 0, originalIndex, index }; - } - const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter((function() { - return /^\d{4}-\d{1,2}-\d{1,2}$/.test($(this).text().trim()); - })).first().text().trim(), timestamp = Date.parse(value); - return { element, key: Number.isFinite(timestamp) ? timestamp : 0, originalIndex, index }; - })); - items.sort(((e3, n2) => "default" === t2 ? e3.key - n2.key : n2.key - e3.key || e3.originalIndex - n2.originalIndex || e3.index - n2.index)); - const sortedElements = items.map(((item) => item.element)); - "default" === t2 ? $(sortedElements).appendTo(d2) : d2.empty().append(sortedElements); - } - isHitShowPage() { - return isHitShowPage(window.location); - } - isFc2ListPage() { - return r && "/advanced_search" === window.location.pathname && "3" === new URLSearchParams(window.location.search).get("type"); - } - supportsLiveSorting() { - return this.isHitShowPage() || this.isFc2ListPage(); - } - async openWaitCheck() { - let e2 = this.getSelector(); - const t2 = await storageManager.getSetting("waitCheckCount", 5); - let a2 = 0; - const listPage = this.getBean("ListPagePlugin"); - for (const element of $(e2.itemSelector).toArray()) { - if (a2 >= t2) break; - const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); - if (hasAnyState(flags) || isHardHidden(flags, visibilityReasons)) continue; - await listPage.openMovieDetail(item, { autoplay: true, newTab: false }), a2++; - } - 0 === a2 && show.info("没有需鉴定的视频"); - } - }; - __name(_ListPageButtonPlugin, "ListPageButtonPlugin"); - var ListPageButtonPlugin = _ListPageButtonPlugin; - var _e = /* @__PURE__ */ __name(async (e2, t2 = "ja", n2 = "zh-CN") => { - if (!e2) throw new Error("翻译文本不能为空"); - const a2 = "https://translate-pa.googleapis.com/v1/translate?" + new URLSearchParams({ - "params.client": "gtx", - dataTypes: "TRANSLATION", - key: "AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA", - "query.sourceLanguage": t2, - "query.targetLanguage": n2, - "query.text": e2 - }), i2 = await fetch(a2); - if (!i2.ok) throw new Error(`${i2.status} ${i2.statusText}`); - return (await i2.json()).translation; - }, "_e"); - var Te = { - IS_FILTERED: { - text: u, - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "单番号屏蔽", - isCounted: true, - countKey: "currentPageFilterCount" - }, - IS_FAVORITE: { - text: b, - color: "var(--jhs-status-fav)", - on: "var(--jhs-status-fav-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageFavoriteCount" - }, - IS_HAS_DOWN: { - text: y, - color: "var(--jhs-status-down)", - on: "var(--jhs-status-down-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageHasDownCount" - }, - IS_HAS_WATCH: { - text: k, - color: "var(--jhs-status-watch)", - on: "var(--jhs-status-watch-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageHasWatchCount" - }, - IS_KEYWORD_FILTER: { - text: "关键词屏蔽", - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageKeywordFilterCount" - }, - IS_ACTOR_FILTER: { - text: "男演员屏蔽", - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageActorFilterCount" - }, - IS_ACTRESS_FILTER: { - text: "女演员屏蔽", - color: "var(--jhs-status-filter)", - on: "var(--jhs-status-filter-on)", - reasonType: "", - isCounted: true, - countKey: "currentPageActorFilterCount" - }, - IS_WAIT_CHECK: { - text: "", - color: "", - on: "", - reasonType: "", - isCounted: true, - countKey: "currentPageWaitCheckCount" - } - }; - var QUICK_FILTER_LABELS = Object.freeze({ - all: "全部", - waitCheck: "待鉴定", - favorite: "收藏", - hasDown: "下载", - hasWatch: "已看", - blockedItems: "屏蔽项", - favoriteUndownloaded: "收藏未下载", - favoriteUnwatched: "收藏未观看", - downloadedUnwatched: "下载未观看", - recent7d: "最近 7 天" - }); - var PRIMARY_QUICK_FILTERS = Object.freeze(["all", "waitCheck", "favorite", "hasDown", "hasWatch"]); - var SECONDARY_QUICK_FILTERS = Object.freeze(["blockedItems", "favoriteUndownloaded", "favoriteUnwatched", "downloadedUnwatched", "recent7d"]); - var VALID_QUICK_FILTERS = /* @__PURE__ */ new Set([...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS]); - function normalizeQuickFilterKey(value) { - if ("filter" === value) return "blockedItems"; - return VALID_QUICK_FILTERS.has(value) ? value : "waitCheck"; - } - __name(normalizeQuickFilterKey, "normalizeQuickFilterKey"); - function isHardHidden(flags, visibilityReasons = {}) { - return Boolean(flags.blocked || visibilityReasons.keyword || visibilityReasons.actorBlacklist || visibilityReasons.actressBlacklist); - } - __name(isHardHidden, "isHardHidden"); - function matchesQuickFilter(filter, flags, { visibilityReasons = {}, recent = false } = {}) { - const normalizedFilter = normalizeQuickFilterKey(filter), hardHidden = isHardHidden(flags, visibilityReasons); - if ("blockedItems" === normalizedFilter) return hardHidden; - if (hardHidden) return false; - if ("all" === normalizedFilter) return true; - if ("waitCheck" === normalizedFilter) return !hasAnyState(flags); - if ("favorite" === normalizedFilter) return !!flags.favorite; - if ("hasDown" === normalizedFilter) return !!flags.downloaded; - if ("hasWatch" === normalizedFilter) return !!flags.watched; - if ("favoriteUndownloaded" === normalizedFilter) return !!flags.favorite && !flags.downloaded; - if ("favoriteUnwatched" === normalizedFilter) return !!flags.favorite && !flags.watched; - if ("downloadedUnwatched" === normalizedFilter) return !!flags.downloaded && !flags.watched; - return "recent7d" === normalizedFilter && recent; - } - __name(matchesQuickFilter, "matchesQuickFilter"); - function shouldShowItem({ filter, flags, visibilityReasons, recent }) { - return matchesQuickFilter(filter, flags, { visibilityReasons, recent }); - } - __name(shouldShowItem, "shouldShowItem"); - var _ListPagePlugin = class _ListPagePlugin extends BasePlugin { - async initCss() { - return ``; - } - constructor() { - super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "cache", null), i(this, "translationPending", /* @__PURE__ */ new Map()), i(this, "filterContext", null), i(this, "pendingItems", /* @__PURE__ */ new Set()), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), i(this, "_debouncedTranslateWrite", null), i(this, "itemIndex", /* @__PURE__ */ new Map()), i(this, "recountFrame", null); - } - getName() { - return "ListPagePlugin"; - } - async handle() { - if (!window.isListPage || isHitShowPage()) return; - const refreshAll = /* @__PURE__ */ __name(async () => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); - const e2 = this.getBean("HistoryPlugin"); - e2.tableObj && e2.tableObj.setData(); - }, "refreshAll"); - jhsEventBus.on("legacy-refresh", refreshAll), jhsEventBus.on("blacklist-rules-changed", refreshAll), jhsEventBus.on("filter-rules-changed", refreshAll), jhsEventBus.on("settings-changed", refreshAll), jhsEventBus.on("car-state-changed", (async (payload) => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); - const items = this.getIndexedItems(payload.carNums || []); - items.length && (await this.doFilterItems(items), this.applyVisibility(items)); - const history = this.getBean("HistoryPlugin"); - history.tableObj && history.tableObj.setData(); - })), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(); - } - async createQuickFilter() { - if ($("#jhs-quick-filter").length) return; - const e2 = this.getSelector(), primaryHtml = PRIMARY_QUICK_FILTERS.map(((filter) => ``)).join(""), secondaryHtml = SECONDARY_QUICK_FILTERS.map(((filter, index) => `${1 === index ? '' : ""}`)).join(""), t2 = `
    -
    ${primaryHtml}
    -
    - - -
    -
    `; - r ? $(e2.boxSelector).before(t2) : l && $(".masonry").before(t2); - const root = $("#jhs-quick-filter"), toggle = root.find(".jhs-quick-filter__toggle"), menu = root.find(".jhs-quick-filter__menu"), closeMenu = /* @__PURE__ */ __name((restoreFocus = false) => { - menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), restoreFocus && toggle.trigger("focus"); - }, "closeMenu"); - root.on("click", ".jhs-segmented__item", ((event) => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))).on("keydown", ".jhs-segmented__item", ((event) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const tabs = root.find(".jhs-segmented__item"), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; - tabs.eq(next).trigger("click").trigger("focus"); - })).on("click", ".jhs-filter-option", ((event) => { - this.setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); - })).on("keydown", ".jhs-filter-option", ((event) => { - const items = menu.find(".jhs-filter-option"), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), closeMenu(true); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const open = !menu.hasClass("is-open"); - menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && (menu.find('[aria-checked="true"]').first().length ? menu.find('[aria-checked="true"]').first() : menu.find(".jhs-filter-option").first()).trigger("focus"); - })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", ((event) => { - $(event.target).closest(root).length || closeMenu(); - })); - this.setQuickFilter(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck")); - } - applyVisibility(items = null) { - const e2 = this.activeQuickFilter || "waitCheck", t2 = this.getSelector().itemSelector; - (items ? $(items) : $(t2)).each((function() { - const t3 = $(this), flags = normalizeStateFlags(JSON.parse(t3.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t3.attr("data-jhs-visibility") || "{}"), recent = "yes" === t3.attr("data-jhs-recent"); - shouldShowItem({ filter: e2, flags, visibilityReasons, recent }) ? t3.show() : t3.hide(); - })); - } - setQuickFilter(filter, { syncUi = true } = {}) { - this.activeQuickFilter = normalizeQuickFilterKey(filter), this.applyVisibility(), syncUi && this.syncQuickFilterUi(); - } - syncQuickFilterUi() { - const filter = normalizeQuickFilterKey(this.activeQuickFilter), isPrimary = PRIMARY_QUICK_FILTERS.includes(filter), root = $("#jhs-quick-filter"), tabs = root.find(".jhs-segmented__item"), options = root.find(".jhs-filter-option"); - tabs.removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }); - isPrimary ? tabs.filter(`[data-jhs-filter="${filter}"]`).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }) : tabs.first().attr("tabindex", "0"); - options.attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); - root.find(".jhs-quick-filter__label").text(isPrimary ? "更多筛选" : `筛选:${QUICK_FILTER_LABELS[filter]}`); - $(".jhs-mobile-filter-label").text(`筛选:${QUICK_FILTER_LABELS[filter]}`), $(".jhs-mobile-filter-option").attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); - } - rememberTagExpand() { - if (!window.location.href.includes("actors")) return; - const e2 = $(".tag-expand"); - if (0 === e2.length) return; - const t2 = "jhs_tag_expand", n2 = "true" === localStorage.getItem(t2), a2 = $(".actor-tags .content"); - n2 && a2.hasClass("collapse") && e2[0].click(), e2.on("click", (function() { - const e3 = !$(".actor-tags .content").hasClass("collapse"); - clog.debug("触发"), localStorage.setItem(t2, e3.toString()); - })); - } - checkDom() { - if (!window.isListPage || isHitShowPage()) return; - const e2 = this.getSelector(), t2 = document.querySelector(e2.boxSelector); - if (!t2) return void clog.error("没有找到容器节点!"); - const a2 = new MutationObserver(((records) => { - for (const record of records) { - this.removeIndexedItems(record.removedNodes); - for (const node of record.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - node.matches?.(e2.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), node.querySelectorAll?.(e2.itemSelector).forEach(((item) => { - "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); - })); - } - } - this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { - const items = [...this.pendingItems].filter(((item) => item.isConnected && "true" !== item.dataset.jhsProcessed)); - this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch(((error) => clog.error("列表增量处理失败", error))); - }), 100)); - })); - a2.observe(t2, { - childList: true, - subtree: false - }); - } - async processAddedItems(items) { - const selector = this.getSelector(), covers = items.flatMap(((item) => [...item.querySelectorAll(selector.coverImgSelector)])); - this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getBean("ListPageButtonPlugin").sortItems(), await this.getBean("CoverButtonPlugin").addSvgBtn(items), items.forEach(((item) => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: false }), this.getBean("AutoPagePlugin").checkLoad(); - } - rebuildItemIndex() { - this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); - } - indexItems(items) { - items.forEach(((item) => { - try { - const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); - if (!key) return; - const indexed = this.itemIndex.get(key) || /* @__PURE__ */ new Set(); - indexed.add(item), this.itemIndex.set(key, indexed); - } catch (error) { - clog.debug("列表项索引跳过无效卡片", error); - } - })); - } - removeIndexedItems(nodes) { - const removed = /* @__PURE__ */ new Set(); - Array.from(nodes || []).forEach(((node) => { - node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach(((item) => removed.add(item)))); - })); - if (!removed.size) return; - this.itemIndex.forEach(((items, key) => { - items.forEach(((item) => { - removed.has(item) && items.delete(item); - })), items.size || this.itemIndex.delete(key); - })); - } - getIndexedItems(carNums) { - const result = /* @__PURE__ */ new Set(); - carNums.map(normalizeCarNum).forEach(((key) => { - const items = this.itemIndex.get(key); - items?.forEach(((item) => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); - })); - return [...result]; - } - fixBusTitleBox(items = null) { - if (!l) return; - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((e2) => { - var t2; - let n2 = $(e2); - if (n2.find(".avatar-box").length > 0) return; - const a2 = (null == (t2 = n2.find("img").attr("title")) ? void 0 : t2.trim()) || ""; - n2.find(".photo-info span:first").contents().first().wrap(`${a2}`), n2.find("br").remove(); - })); - } - cleanRepeatId() { - if (!l) return; - $("#waterfall_h").removeAttr("id").attr("id", "no-page"); - const e2 = $('[id="waterfall"]'); - 0 !== e2.length && e2.each((function() { - const e3 = $(this); - if (!e3.hasClass("masonry")) { - e3.children().insertAfter(e3), e3.remove(); - } - })); - } - async doFilter() { - return this.doFilterItems(); - } - async doFilterItems(items = null) { - if (!window.isListPage) return; - let e2 = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); - e2.length && (await this.filterMovieList(e2), l && setTimeout((() => { - this.getBean("BusImgPlugin").logImageHeightsByRow().catch(((e3) => clog.error("JavBus图片高度修正失败", e3))); - }))); - } - async yieldListFrame() { - await new Promise(((e2) => { - window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e2))) : setTimeout(e2); - })); - } - findMatchedTitleKeyword(e2, t2, n2) { - for (const a2 of e2) if (t2.includes(a2) || n2.startsWith(a2)) return a2; - return null; - } - async getFilterContext() { - if (this.filterContext) return this.filterContext; - const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), stateService.getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); - const cutoff = Date.now() - 7 * 864e5; - activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState && change.fields?.some(((field) => field.startsWith("stateFlags."))))).forEach(((change) => recentCarNums.add(change.carNum))))); - for (const item of blacklistCars) { - const role = blacklistMap.get(item.starId)?.role; - if (!role) { - clog.error("黑名单数据源丢失演员信息", item); - continue; - } - const target = role === B ? actorCarNumToNameMap : actressCarNumToNameMap, carNum = normalizeCarNum(item.carNum); - target.has(carNum) || target.set(carNum, item.names); - } - return this.filterContext = { titleKeywords, settings, carMap, recentCarNums, actorCarNumToNameMap, actressCarNumToNameMap }; - } - collectCurrentPageSummary() { - const summary = { total: 0, pending: 0, blockedItems: 0, favorite: 0, downloaded: 0, watched: 0, debug: { manualBlocked: 0, keywordBlocked: 0, actorBlocked: 0, actressBlocked: 0 } }; - $(this.getSelector().itemSelector).each(((e2, item) => { - const card = $(item); - if (l && card.find(".avatar-box").length > 0) return; - const flags = normalizeStateFlags(JSON.parse(card.attr("data-jhs-flags") || "{}")), reasons = JSON.parse(card.attr("data-jhs-visibility") || "{}"), hardHidden = isHardHidden(flags, reasons); - summary.total++, flags.favorite && summary.favorite++, flags.downloaded && summary.downloaded++, flags.watched && summary.watched++, hardHidden && summary.blockedItems++, !hasAnyState(flags) && !hardHidden && summary.pending++, flags.blocked && summary.debug.manualBlocked++, reasons.keyword && summary.debug.keywordBlocked++, reasons.actorBlacklist && summary.debug.actorBlocked++, reasons.actressBlacklist && summary.debug.actressBlocked++; - })); - return summary; - } - getCurrentPageSummary() { - return this.collectCurrentPageSummary(); - } - recountStatuses() { - const summary = this.collectCurrentPageSummary(); - this.currentPageFilterCount = summary.debug.manualBlocked, this.currentPageFavoriteCount = summary.favorite, this.currentPageHasDownCount = summary.downloaded, this.currentPageHasWatchCount = summary.watched, this.currentPageKeywordFilterCount = summary.debug.keywordBlocked, this.currentPageActorFilterCount = summary.debug.actorBlocked + summary.debug.actressBlocked, this.currentPageWaitCheckCount = summary.pending, this.currentPageTotalCount = summary.total; - return summary; - } - scheduleRecount() { - if (this.recountFrame) return; - const schedule = window.requestAnimationFrame || ((callback) => setTimeout(callback, 0)); - this.recountFrame = schedule((() => { - this.recountFrame = null, this.recountStatuses(); - })); - } - async translateListItems(e2) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; - await mapLimit(e2, 3, (async (item, index) => { - try { - index > 0 && index % 8 == 0 && await this.yieldListFrame(), await this.translate(item); - } catch (error) { - clog.error("列表标题翻译失败", error); - } - })); - } - async filterMovieList(e2) { - utils.time("累计耗费时间"), utils.time("读取数据耗时"); - const { titleKeywords: n2, settings: s2, carMap: m2, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v2 } = await this.getFilterContext(), o2 = utils.time("读取数据耗时"); - utils.time("组装数据耗时"); - const b2 = utils.time("组装数据耗时"), P2 = (null == s2 ? void 0 : s2.tagPosition) || "rightTop"; - const O2 = n2.filter(((e3) => e3)); - this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); - const R2 = []; - for (let n3 = 0; n3 < e2.length; n3++) { - n3 > 0 && n3 % 12 == 0 && await this.yieldListFrame(); - let t2 = $(e2[n3]); - if (l && t2.find(".avatar-box").length > 0) continue; - const { carNum: a2, title: i2 } = this.findCarNumAndHref(t2), record = m2.get(a2), flags = normalizeStateFlags(record?.stateFlags), actorFiltered = f.has(a2), actressFiltered = v2.has(a2), keyword = this.findMatchedTitleKeyword(O2, i2, a2), visibilityReasons = { keyword: !!keyword, actorBlacklist: actorFiltered, actressBlacklist: actressFiltered }; - const hardHidden = isHardHidden(flags, visibilityReasons); - t2.attr("data-jhs-flags", JSON.stringify(flags)).attr("data-jhs-visibility", JSON.stringify(visibilityReasons)).attr("data-jhs-recent", recent.has(a2) ? _ : C).attr("data-jhs-tag-position", P2); - const signature = JSON.stringify({ flags, visibilityReasons, P: P2 }); - if (t2.attr("data-jhs-state-signature") !== signature) { - t2.attr("data-jhs-state-signature", signature), t2.find(".jhs-status-tags").remove(); - const badgeDefs = [ - [flags.blocked, Te.IS_FILTERED, "单番号屏蔽"], - [flags.favorite, Te.IS_FAVORITE, ""], - [flags.downloaded, Te.IS_HAS_DOWN, ""], - [flags.watched, Te.IS_HAS_WATCH, ""], - [visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知"], - [visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a2) || ""], - [visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v2.get(a2) || ""] - ].filter(((item) => item[0])); - if (badgeDefs.length) { - const box = $(``); - badgeDefs.forEach((([, definition, tip]) => { - const badge = $(`${escapeHtml(definition.text)}`); - badge.css({ color: definition.on, backgroundColor: definition.color }), box.append(badge); - })); - if (r) t2.find(".tags").append(box); - else if (l) { - const host = t2.find(".item-tag"); - host.length ? host.append(box) : t2.find(".photo-info > span > div").append(box); - } - } - } - hardHidden || R2.push(t2); - } - this.scheduleRecount(), void this.translateListItems(R2).catch(((e3) => clog.error("列表页翻译任务失败", e3))); - const D2 = utils.time("处理页面耗时"), A2 = utils.time("累计耗费时间"); - clog.log(` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ${o2}${b2}
    ${D2}${A2}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    待鉴定${this.currentPageWaitCheckCount}
    总数${this.currentPageTotalCount}
    - `); - } - async bindClick() { - let e2 = this.getSelector(); - this.bindMovieDetailNavigation(e2.boxSelector), $(e2.boxSelector).on("click", ".item video", (async (e3) => { - const t2 = e3.currentTarget; - t2.paused ? await safePlay(t2, { - context: "列表视频", - notify: true - }) : t2.pause(), e3.preventDefault(), e3.stopPropagation(); - })), $(e2.boxSelector).on("contextmenu", ".item img, .item video", (async (e3) => { - try { - e3.preventDefault(); - const t2 = $(e3.target).closest(".item"), { carNum: n2, url: a2, publishTime: i2, fc2Source } = this.findCarNumAndHref(t2); - let s2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"), o2 = ""; - s2.length && (o2 = s2.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e3, `是否屏蔽番号 ${n2}?`, (async () => { - try { - o2 || (o2 = await this.parseActressName(a2)), await stateService.patch(n2, { blocked: true }, { record: { carNum: n2, url: a2, names: o2, publishTime: i2, fc2Source } }), show.ok("操作成功"); - } catch (s3) { - clog.error("屏蔽操作失败:", s3), show.error("操作失败"); - } - })); - } catch (t2) { - clog.error("右键菜单处理失败:", t2); - } - })); - } - /** 从任意列表卡片进入统一详情导航。 */ - async openMovieDetail(item, { event = null, autoplay = false, newTab = false } = {}) { - const card = item?.jquery ? item : $(item), { carNum, aHref, fc2Source } = this.findCarNumAndHref(card); - if (!carNum || !aHref) return; - const shouldOpenTab = newTab || !!event && (event.ctrlKey || event.metaKey || 1 === event.button); - if (carNum.includes("FC2-")) { - const movieId = this.parseMovieId(aHref); - return shouldOpenTab ? this.getBean("Fc2Plugin").openFc2Page(movieId, carNum, aHref, { event, newTab: true }, { source: fc2Source }) : this.getBean("Fc2Plugin").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source }); - } - const destination = new URL(aHref, window.location.origin); - autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, true, { event, newTab: shouldOpenTab }), this.$currentImage = null; - } - /** 为宿主与合成列表统一绑定左键、修饰键和中键导航。 */ - bindMovieDetailNavigation(container) { - const root = $(container), selector = ".item img, .item .video-title"; - root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, ((event) => { - if ("auxclick" === event.type && 1 !== event.button || "click" === event.type && event.button && 0 !== event.button) return; - if (event.shiftKey || event.altKey || $(event.target).closest("div.meta-buttons,[class^='jhs-match-']").length) return; - event.preventDefault(), event.stopPropagation(); - void this.openMovieDetail($(event.currentTarget).closest(".item"), { event }).catch(((error) => clog.error("打开影片详情失败", error))); - })); - } - async parseActressName(e2) { - let t2 = null; - if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { - clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e2); - const n2 = await gmHttp.get(e2), a2 = utils.htmlTo$dom(n2); - r ? t2 = a2.find(".female").prev().map(((e3, t3) => $(t3).text())).get().join(" ") : l && (t2 = a2.find('span[onmouseover*="star_"] a').map(((e3, t3) => $(t3).text())).get().join(" ")), clog.debug("解析到名称:", t2); - } - return t2; - } - findCarNumAndHref(e2) { - var t2, n2; - let a2, i2, s2, o2 = e2.find("a"), r2 = o2.attr("href"), l2 = e2.find(".video-title"); - if (l2.length > 0) { - let t3 = l2.find("strong"); - t3.length > 0 && (a2 = t3.text().trim()), i2 = o2.attr("title") ? o2.attr("title").trim() : a2 ? l2.text().replace(a2, "").trim() : l2.text().trim(), s2 = e2.find(".meta").text().trim(); - } - if (!a2) { - let o3 = e2.find("img"); - r2 && o3.length > 0 && (i2 = (null == (t2 = o3.attr("title")) ? void 0 : t2.trim()) || (null == (n2 = o3.attr("data-title")) ? void 0 : n2.trim())); - const l3 = /* @__PURE__ */ __name((e3) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e3), "l"); - s2 = e2.find("date").map(((e3, t3) => $(t3).text().trim())).get().find(l3), a2 = e2.find("date").map(((e3, t3) => $(t3).text().trim())).get().find(((e3) => !l3(e3))); - } - if (!a2) { - const e3 = "提取番号信息失败"; - throw show.error(e3), new Error(e3); - } - return { - carNum: normalizeCarNum(a2), - aHref: r2, - url: r2, - title: i2, - publishTime: s2, - fc2Source: ["fc2", "123av"].includes(e2.attr("data-jhs-fc2-source")) ? e2.attr("data-jhs-fc2-source") : "fc2" - }; - } - showCarNumBox(e2) { - const t2 = $(".movie-list .item").toArray().find(((t3) => $(t3).find(".video-title strong").text() === e2)); - if (t2) { - const n2 = $(t2); - n2.attr("data-hide") === "yes" && (n2.show(), n2.removeAttr("data-hide")); - } - } - _replaceSingleHdImg(e2) { - if ("true" === e2.dataset.hdReplaced) return; - if (r) { - const isJavdbCdn = /jdbstatic\.com|javdb\.com/i.test(e2.src); - if (isJavdbCdn) { - const originalSrc = e2.src; - e2.src = e2.src.replace("thumbs", "covers"); - e2.dataset.hdReplaced = "true"; - e2.title = ""; - e2.onerror = function() { - if (this.src !== originalSrc) { - this.src = originalSrc; - this.onerror = null; - } - }; - } - } else if (l) { - const t2 = /\/(imgs|pics)\/(thumb|thumbs)\//, n2 = /(\.jpg|\.jpeg|\.png)$/i; - t2.test(e2.src) ? (e2.src = e2.src.replace(t2, "/$1/cover/").replace(n2, "_b$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = "") : /ps(\.jpg|\.jpeg|\.png)$/i.test(e2.src) && (e2.src = e2.src.replace(/ps(\.jpg|\.jpeg|\.png)$/i, "pl$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = ""); - } - } - replaceHdImg(e2) { - if (e2 && "string" == typeof e2.jquery && (e2 = e2.toArray()), e2 || (e2 = document.querySelectorAll(this.getSelector().coverImgSelector)), !e2.length) return; - const t2 = Array.from(e2).filter(((e3) => "true" !== e3.dataset.hdReplaced && "true" !== e3.dataset.jhsHdObserved)); - if ("IntersectionObserver" in window && !this.hdImageObserver) this.hdImageObserver = new IntersectionObserver(((entries) => { - entries.forEach(((entry) => { - entry.isIntersecting && (this.hdImageObserver.unobserve(entry.target), delete entry.target.dataset.jhsHdObserved, this._replaceSingleHdImg(entry.target)); - })); - }), { rootMargin: "200px" }); - for (const image of t2) this.hdEagerRemaining > 0 ? (this.hdEagerRemaining--, this._replaceSingleHdImg(image)) : this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", this.hdImageObserver.observe(image)) : this._replaceSingleHdImg(image); - storageManager.getSetting("hoverBigImg", C).then(((e3) => { - e3 === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: this.getSelector().coverImgSelector - })); - })); - } - getTranslationCache() { - if (this.cache && "object" == typeof this.cache && !Array.isArray(this.cache)) return this.cache; - try { - this.cache = JSON.parse(localStorage.getItem("jhs_translate") || "{}"); - } catch (error) { - clog.warn("列表翻译缓存无法解析,已忽略旧缓存", error), this.cache = {}; - } - return this.cache && "object" == typeof this.cache && !Array.isArray(this.cache) ? this.cache : this.cache = {}; - } - scheduleTranslationWrite() { - this._debouncedTranslateWrite && clearTimeout(this._debouncedTranslateWrite), this._debouncedTranslateWrite = setTimeout((() => { - localStorage.setItem("jhs_translate", JSON.stringify(this.getTranslationCache())); - }), 500); - } - applyTranslatedTitle(e2, t2, n2) { - const a2 = e2.find(".video-title"); - r ? (a2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n2) || (this.textContent = " " + t2 + " "); - })), a2.attr("title", t2)) : a2.text(t2), e2.attr("data-jhs-translation-key", n2); - } - async translate(e2) { - let t2, n2, a2 = e2.find(".video-title"); - if (r ? (t2 = a2.contents().filter(((e3, t3) => 3 === t3.nodeType && "" !== t3.textContent.trim())).text().trim(), n2 = e2.find(".video-title strong").text().trim()) : (t2 = (e2.find("img").attr("data-title") || "").trim(), n2 = (e2.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t2 || !n2) return; - const cache = this.getTranslationCache(); - if (cache[n2]) return void this.applyTranslatedTitle(e2, cache[n2], n2); - let pending = this.translationPending.get(n2); - if (!pending) { - pending = _e(t2).then(((translated) => (cache[n2] = translated, this.scheduleTranslationWrite(), translated))).finally((() => this.translationPending.delete(n2))), this.translationPending.set(n2, pending); - } - this.applyTranslatedTitle(e2, await pending, n2); - } - async revertTranslation() { - $(this.getSelector().itemSelector).toArray().forEach(((e2) => { - let t2 = $(e2); - const n2 = t2.find(".box").attr("title") || t2.find(".video-title").attr("title") || t2.find("img").attr("data-title"); - let a2; - r && (a2 = t2.find(".video-title strong").text().trim()); - const i2 = t2.find(".video-title"); - i2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a2) || (this.textContent = " " + n2 + " "); - })), i2.removeAttr("title"); - })); - } - addJumpPageControl() { - const e2 = "gemini-jump-page-control"; - if ($("#" + e2).length > 0) return; - if (0 === $(".pagination-link.is-current").length) return; - const t2 = utils.getUrlParam(o, "page") || 1, n2 = $('', { - id: "jumpPageInput", - placeholder: "页码", - min: "1", - value: t2 + 1 - }), a2 = $('`)).join(""), secondaryHtml = SECONDARY_QUICK_FILTERS.map(((filter, index) => `${1 === index ? '' : ""}`)).join(""), t2 = `
    +
    ${primaryHtml}
    +
    + + +
    +
    `; + r ? $(e2.boxSelector).before(t2) : l && $(".masonry").before(t2); + const root = $("#jhs-quick-filter"), toggle = root.find(".jhs-quick-filter__toggle"), menu = root.find(".jhs-quick-filter__menu"), closeMenu = /* @__PURE__ */ __name((restoreFocus = false) => { + menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), restoreFocus && toggle.trigger("focus"); + }, "closeMenu"); + root.on("click", ".jhs-segmented__item", ((event) => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))).on("keydown", ".jhs-segmented__item", ((event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const tabs = root.find(".jhs-segmented__item"), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; + tabs.eq(next).trigger("click").trigger("focus"); + })).on("click", ".jhs-filter-option", ((event) => { + this.setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); + })).on("keydown", ".jhs-filter-option", ((event) => { + const items = menu.find(".jhs-filter-option"), index = items.index(event.currentTarget); + if ("Escape" === event.key) return event.preventDefault(), closeMenu(true); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); + })); + toggle.on("click", ((event) => { + event.preventDefault(), event.stopPropagation(); + const open = !menu.hasClass("is-open"); + menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && (menu.find('[aria-checked="true"]').first().length ? menu.find('[aria-checked="true"]').first() : menu.find(".jhs-filter-option").first()).trigger("focus"); + })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", ((event) => { + $(event.target).closest(root).length || closeMenu(); + })); + this.setQuickFilter(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck")); + } + applyVisibility(items = null) { + const e2 = this.activeQuickFilter || "waitCheck", t2 = this.getSelector().itemSelector; + (items ? $(items) : $(t2)).each((function() { + const t3 = $(this), flags = normalizeStateFlags(JSON.parse(t3.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t3.attr("data-jhs-visibility") || "{}"), recent = "yes" === t3.attr("data-jhs-recent"); + shouldShowItem({ filter: e2, flags, visibilityReasons, recent }) ? t3.show() : t3.hide(); + })); + } + setQuickFilter(filter, { syncUi = true } = {}) { + this.activeQuickFilter = normalizeQuickFilterKey(filter), this.applyVisibility(), syncUi && this.syncQuickFilterUi(); + } + syncQuickFilterUi() { + const filter = normalizeQuickFilterKey(this.activeQuickFilter), isPrimary = PRIMARY_QUICK_FILTERS.includes(filter), root = $("#jhs-quick-filter"), tabs = root.find(".jhs-segmented__item"), options = root.find(".jhs-filter-option"); + tabs.removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }); + isPrimary ? tabs.filter(`[data-jhs-filter="${filter}"]`).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }) : tabs.first().attr("tabindex", "0"); + options.attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); + root.find(".jhs-quick-filter__label").text(isPrimary ? "更多筛选" : `筛选:${QUICK_FILTER_LABELS[filter]}`); + $(".jhs-mobile-filter-label").text(`筛选:${QUICK_FILTER_LABELS[filter]}`), $(".jhs-mobile-filter-option").attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); + } + rememberTagExpand() { + if (!window.location.href.includes("actors")) return; + const e2 = $(".tag-expand"); + if (0 === e2.length) return; + const t2 = "jhs_tag_expand", n2 = "true" === localStorage.getItem(t2), a2 = $(".actor-tags .content"); + n2 && a2.hasClass("collapse") && e2[0].click(), e2.on("click", (function() { + const e3 = !$(".actor-tags .content").hasClass("collapse"); + clog.debug("触发"), localStorage.setItem(t2, e3.toString()); + })); + } + checkDom() { + if (!window.isListPage || isHitShowPage()) return; + const e2 = this.getSelector(), t2 = document.querySelector(e2.boxSelector); + if (!t2) return void clog.error("没有找到容器节点!"); + const a2 = new MutationObserver(((records) => { + for (const record of records) { + this.removeIndexedItems(record.removedNodes); + for (const node of record.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + node.matches?.(e2.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), node.querySelectorAll?.(e2.itemSelector).forEach(((item) => { + "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); + })); + } + } + this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { + const items = [...this.pendingItems].filter(((item) => item.isConnected && "true" !== item.dataset.jhsProcessed)); + this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch(((error) => clog.error("列表增量处理失败", error))); + }), 100)); + })); + a2.observe(t2, { + childList: true, + subtree: false + }); + } + async processAddedItems(items) { + const selector = this.getSelector(), covers = items.flatMap(((item) => [...item.querySelectorAll(selector.coverImgSelector)])); + this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), items.forEach(((item) => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: false }), this.getDependency("AutoPagePlugin").checkLoad(); + } + rebuildItemIndex() { + this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); + } + indexItems(items) { + items.forEach(((item) => { + try { + const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); + if (!key) return; + const indexed = this.itemIndex.get(key) || /* @__PURE__ */ new Set(); + indexed.add(item), this.itemIndex.set(key, indexed); + } catch (error) { + clog.debug("列表项索引跳过无效卡片", error); + } + })); + } + removeIndexedItems(nodes) { + const removed = /* @__PURE__ */ new Set(); + Array.from(nodes || []).forEach(((node) => { + node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach(((item) => removed.add(item)))); + })); + if (!removed.size) return; + this.itemIndex.forEach(((items, key) => { + items.forEach(((item) => { + removed.has(item) && items.delete(item); + })), items.size || this.itemIndex.delete(key); + })); + } + getIndexedItems(carNums) { + const result = /* @__PURE__ */ new Set(); + carNums.map(normalizeCarNum).forEach(((key) => { + const items = this.itemIndex.get(key); + items?.forEach(((item) => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); + })); + return [...result]; + } + fixBusTitleBox(items = null) { + if (!l) return; + (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((e2) => { + var t2; + let n2 = $(e2); + if (n2.find(".avatar-box").length > 0) return; + const a2 = (null == (t2 = n2.find("img").attr("title")) ? void 0 : t2.trim()) || ""; + n2.find(".photo-info span:first").contents().first().wrap(`${a2}`), n2.find("br").remove(); + })); + } + cleanRepeatId() { + if (!l) return; + $("#waterfall_h").removeAttr("id").attr("id", "no-page"); + const e2 = $('[id="waterfall"]'); + 0 !== e2.length && e2.each((function() { + const e3 = $(this); + if (!e3.hasClass("masonry")) { + e3.children().insertAfter(e3), e3.remove(); + } + })); + } + async doFilter() { + return this.doFilterItems(); + } + async doFilterItems(items = null) { + if (!window.isListPage) return; + let e2 = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); + e2.length && (await this.filterMovieList(e2), l && setTimeout((() => { + this.getDependency("BusImgPlugin").logImageHeightsByRow().catch(((e3) => clog.error("JavBus图片高度修正失败", e3))); + }))); + } + async yieldListFrame() { + await new Promise(((e2) => { + window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e2))) : setTimeout(e2); + })); + } + findMatchedTitleKeyword(e2, t2, n2) { + for (const a2 of e2) if (t2.includes(a2) || n2.startsWith(a2)) return a2; + return null; + } + async getFilterContext() { + if (this.filterContext) return this.filterContext; + const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), stateService.getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); + const cutoff = Date.now() - 7 * 864e5; + activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState && change.fields?.some(((field) => field.startsWith("stateFlags."))))).forEach(((change) => recentCarNums.add(change.carNum))))); + for (const item of blacklistCars) { + const role = blacklistMap.get(item.starId)?.role; + if (!role) { + clog.error("黑名单数据源丢失演员信息", item); + continue; + } + const target = role === B ? actorCarNumToNameMap : actressCarNumToNameMap, carNum = normalizeCarNum(item.carNum); + target.has(carNum) || target.set(carNum, item.names); + } + return this.filterContext = { titleKeywords, settings, carMap, recentCarNums, actorCarNumToNameMap, actressCarNumToNameMap }; + } + collectCurrentPageSummary() { + const summary = { total: 0, pending: 0, blockedItems: 0, favorite: 0, downloaded: 0, watched: 0, debug: { manualBlocked: 0, keywordBlocked: 0, actorBlocked: 0, actressBlocked: 0 } }; + $(this.getSelector().itemSelector).each(((e2, item) => { + const card = $(item); + if (l && card.find(".avatar-box").length > 0) return; + const flags = normalizeStateFlags(JSON.parse(card.attr("data-jhs-flags") || "{}")), reasons = JSON.parse(card.attr("data-jhs-visibility") || "{}"), hardHidden = isHardHidden(flags, reasons); + summary.total++, flags.favorite && summary.favorite++, flags.downloaded && summary.downloaded++, flags.watched && summary.watched++, hardHidden && summary.blockedItems++, !hasAnyState(flags) && !hardHidden && summary.pending++, flags.blocked && summary.debug.manualBlocked++, reasons.keyword && summary.debug.keywordBlocked++, reasons.actorBlacklist && summary.debug.actorBlocked++, reasons.actressBlacklist && summary.debug.actressBlocked++; + })); + return summary; + } + getCurrentPageSummary() { + return this.collectCurrentPageSummary(); + } + recountStatuses() { + const summary = this.collectCurrentPageSummary(); + this.currentPageFilterCount = summary.debug.manualBlocked, this.currentPageFavoriteCount = summary.favorite, this.currentPageHasDownCount = summary.downloaded, this.currentPageHasWatchCount = summary.watched, this.currentPageKeywordFilterCount = summary.debug.keywordBlocked, this.currentPageActorFilterCount = summary.debug.actorBlocked + summary.debug.actressBlocked, this.currentPageWaitCheckCount = summary.pending, this.currentPageTotalCount = summary.total; + return summary; + } + scheduleRecount() { + if (this.recountFrame) return; + const schedule = window.requestAnimationFrame || ((callback) => setTimeout(callback, 0)); + this.recountFrame = schedule((() => { + this.recountFrame = null, this.recountStatuses(); + })); + } + async translateListItems(e2) { + if (await storageManager.getSetting("translateTitle", _) !== _) return; + await mapLimit(e2, 3, (async (item, index) => { + try { + index > 0 && index % 8 == 0 && await this.yieldListFrame(), await this.translate(item); + } catch (error) { + clog.error("列表标题翻译失败", error); + } + })); + } + async filterMovieList(e2) { + utils.time("累计耗费时间"), utils.time("读取数据耗时"); + const { titleKeywords: n2, settings: s2, carMap: m2, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v2 } = await this.getFilterContext(), o2 = utils.time("读取数据耗时"); + utils.time("组装数据耗时"); + const b2 = utils.time("组装数据耗时"), P2 = (null == s2 ? void 0 : s2.tagPosition) || "rightTop"; + const O2 = n2.filter(((e3) => e3)); + this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); + const R2 = []; + for (let n3 = 0; n3 < e2.length; n3++) { + n3 > 0 && n3 % 12 == 0 && await this.yieldListFrame(); + let t2 = $(e2[n3]); + if (l && t2.find(".avatar-box").length > 0) continue; + const { carNum: a2, title: i2 } = this.findCarNumAndHref(t2), record = m2.get(a2), flags = normalizeStateFlags(record?.stateFlags), actorFiltered = f.has(a2), actressFiltered = v2.has(a2), keyword = this.findMatchedTitleKeyword(O2, i2, a2), visibilityReasons = { keyword: !!keyword, actorBlacklist: actorFiltered, actressBlacklist: actressFiltered }; + const hardHidden = isHardHidden(flags, visibilityReasons); + t2.attr("data-jhs-flags", JSON.stringify(flags)).attr("data-jhs-visibility", JSON.stringify(visibilityReasons)).attr("data-jhs-recent", recent.has(a2) ? _ : C).attr("data-jhs-tag-position", P2); + const signature = JSON.stringify({ flags, visibilityReasons, P: P2 }); + if (t2.attr("data-jhs-state-signature") !== signature) { + t2.attr("data-jhs-state-signature", signature), t2.find(".jhs-status-tags").remove(); + const badgeDefs = [ + [flags.blocked, Te.IS_FILTERED, "单番号屏蔽"], + [flags.favorite, Te.IS_FAVORITE, ""], + [flags.downloaded, Te.IS_HAS_DOWN, ""], + [flags.watched, Te.IS_HAS_WATCH, ""], + [visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知"], + [visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a2) || ""], + [visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v2.get(a2) || ""] + ].filter(((item) => item[0])); + if (badgeDefs.length) { + const box = $(``); + badgeDefs.forEach((([, definition, tip]) => { + const badge = $(`${escapeHtml(definition.text)}`); + badge.css({ color: definition.on, backgroundColor: definition.color }), box.append(badge); + })); + if (r) t2.find(".tags").append(box); + else if (l) { + const host = t2.find(".item-tag"); + host.length ? host.append(box) : t2.find(".photo-info > span > div").append(box); + } + } + } + hardHidden || R2.push(t2); + } + this.scheduleRecount(), void this.translateListItems(R2).catch(((e3) => clog.error("列表页翻译任务失败", e3))); + const D2 = utils.time("处理页面耗时"), A2 = utils.time("累计耗费时间"); + clog.log(` + + + + + + + + + + + + + + + + - @media (max-width: 768px) { - .jhs-setting-layout { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto minmax(0, 1fr); - } - .jhs-mobile-sidebar { - flex-direction: row; - padding: var(--jhs-space-2); - border-right: 0; - border-bottom: 1px solid var(--jhs-border); - overflow-x: auto; - overflow-y: hidden; - } - .side-menu-item { - width: auto; - min-width: max-content; - min-height: var(--jhs-touch-target); - } - .jhs-setting-body { - padding-inline: var(--jhs-space-3); - } - .jhs-cache-grid { - grid-template-columns: minmax(0, 1fr); - } - .jhs-summary-grid, .jhs-health-columns { grid-template-columns:minmax(0,1fr); } - .jhs-resource-card .jhs-setting-row { align-items:stretch; } - .jhs-resource-card .jhs-toolbar { flex-wrap:wrap; } - } + + + + + + - input[type="checkbox"]:disabled { - opacity: 0.6; - cursor: default !important; - } - - `; - } - __name(buildSettingCss, "buildSettingCss"); - async function applyImageMode() { - $("#verticalImgStyle").remove(); - if (await storageManager.getSetting("enableVerticalModel", C) === _) { - let e2 = "100% 50% !important"; - window.location.href.includes("/advanced_search?type=100") && (e2 = "50% 50% !important"); - const t2 = ` - .cover { - min-height: 350px !important; - overflow: hidden !important; - padding-top: 142% !important; - } + + + + + + - .cover img { - object-fit: cover !important; - object-position: ${e2}; - } + + + + + + - /* bus的 */ - .masonry .movie-box img { - min-height: 500px !important; - object-fit: cover !important; - object-position: top right; - } - `; - $("`; + } + async addBlacklist(e2) { + let t2 = { + clientX: e2.clientX, + clientY: e2.clientY + 80 + }; + const n2 = $("#addBlacklistBtn span").text().includes("已加入"); + let a2, i2; + if (o.includes("/tags")) { + const e3 = new URL(o); + e3.searchParams.delete("page"); + const t3 = $("#jhs-check-tag").text().trim(); + a2 = { + starId: "no-" + t3, + name: "虚拟演员-" + t3, + allName: ["虚拟演员"], + role: "虚拟演员", + movieType: t3, + blacklistUrl: e3.toString() + }, i2 = `是否将分类 ${t3} 加入到黑名单中?`, n2 && (i2 = `分类 ${t3} 已在黑名单中, 是否从当前页开始追加屏蔽?`); + } else a2 = this.getActressPageInfo(), i2 = `是否将该演员 ${a2.name} 加入到黑名单中?`, n2 && (i2 = `演员 ${a2.name} 已在黑名单中, 是否从当前页开始追加屏蔽?`); + const { starId: s2, name: r2, allName: c2, role: d2, movieType: h2, blacklistUrl: g2 } = a2; + if (o.includes("page") && !o.includes("page=1") && (i2 += "
    注意: 当前页面非第一页, 屏蔽数据将从此页面开始"), l) { + const e3 = o.split("/star/")[1].split("/"); + if (e3.length > 1) { + parseInt(e3[1]) > 1 && (i2 += "
    注意: 当前页面非第一页, 屏蔽数据将从此页面开始"); + } + } + utils.q(t2, i2, (async () => { + const e3 = this.getDependency("TaskPlugin"); + navigator.locks.request(e3.singleTaskKey, { + ifAvailable: true + }, (async (e4) => { + if (clog.debug("获取锁", e4), e4) { + this.loadObj = loading(); + try { + await storageManager.addBlacklistItem({ + starId: s2, + name: r2, + allName: c2, + role: d2, + movieType: h2, + url: g2 + }), await this.filterActorVideo(r2, s2, null, l ? I : T); + const e5 = show.ok(`屏蔽结束,是否跳转到最后一页: ${this.lastPageLink}`, { + duration: -1, + close: true, + onClick: /* @__PURE__ */ __name(() => { + e5.closeShow(), window.location.href = this.lastPageLink; + }, "onClick") + }); + } catch (t3) { + clog.error(t3); + const e5 = show.error("发生错误, 是否填转到解析失败的那一页? (点击并跳转)", { + duration: -1, + close: true, + onClick: /* @__PURE__ */ __name(() => { + e5.closeShow(), window.location.href = this.nextPageLink; + }, "onClick") + }); + } finally { + this.loadObj.close(); + } + } else show.error("当前有定时任务在后台执行中, 无法发起此操作"); + })).catch(((e4) => { + clog.error("锁任务出现错误:", e4); + })); + })); + } + async resetBtnTip() { + const e2 = this.getDependency("TaskPlugin"), t2 = localStorage.getItem(e2.lastCheckBlacklistTimeKey) || "无", n2 = await storageManager.getSetting("checkBlacklist_intervalTime", 12); + this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime", 8760), $("#checkBlacklistBtn").attr("data-tip", `上次整批检测: ${t2}; 检测间隔时间: ${n2}小时`); + } + async openBlacklistDialog() { + const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getSetting(); + let n2 = ` +
    +
    +
    + + +
    +
    + + + + + +
    -
    -

    外部网站

    配置外部资源来源、域名与网络。

    -
    - 域名 - MissAv: -
    - -
    -
    -
    - 域名 - Jable: -
    - -
    -
    -
    - 域名 - Avgle: -
    - -
    -
    -
    - 域名 - JavTrailer: -
    - -
    -
    -
    - 域名 - 123Av: -
    - -
    -
    -
    - 域名 - JavDb: -
    - -
    -
    -
    - 域名 - JavBus: -
    - -
    -
    -
    - 域名 - SupJav: -
    - -
    -
    -
    -
    +
    +
    +
    + `; + layer.open({ + type: 1, + title: "演员黑名单", + content: n2, + scrollbar: false, + area: utils.getDialogArea("xl"), + anim: -1, + success: /* @__PURE__ */ __name(async (t3) => { + const dialog = $(t3).find(".layui-layer-content > div").first().addClass("jhs-blacklist-layout").removeAttr("style"), toolbar = dialog.children("div").first().addClass("jhs-blacklist-toolbar").removeAttr("style"); + toolbar.children("div").addClass("jhs-blacklist-toolbar__group").removeAttr("style"), toolbar.find("select,input,a").removeAttr("style"), dialog.find("#table-container").removeAttr("style"); + dialog.find("#table-container").before('
    '), JhsSelect.enhance(t3); + this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.renderTaskStatus())); + await this.loadTableData(); + const content = $(t3).find(".layui-layer-content"), search = content.find("#searchValue"); + this.blacklistSearchDebounced = utils.debounce((() => void this.reloadTable()), 200), content.on("click", "#cleanQueryBtn", (async () => { + search.val(""), JhsSelect.setValue("#dataType", "", false), JhsSelect.setValue("#statusType", "", false), JhsSelect.setValue("#urlType", "", false), await this.reloadTable(); + })).on("input", "#searchValue", this.blacklistSearchDebounced).on("change", "#dataType,#statusType,#urlType", (async () => { + await this.reloadTable(); + })).on("click", "#toSetting", (() => { + this.getDependency("SettingPlugin").openSettingDialog("task-panel", (() => { + $("#setting-blacklist").css({ + border: "1px solid var(--jhs-status-filter)" + }); + })); + })).on("click", ".open-url", ((e3) => { + e3.preventDefault(); + const t4 = $(e3.currentTarget), n3 = t4.attr("data-url"), a2 = t4.attr("data-name"); + utils.openPage(n3, a2, true, e3); + })).on("click", "#checkBlacklistBtn", ((event) => { + const button = $(event.currentTarget), label = button.find("span").last(), previous = label.text(); + if (button.attr("aria-busy") === "true") return; + button.attr("aria-busy", "true").prop("disabled", true), label.text("检测中…"), navigator.locks.request(e2.singleTaskKey, { ifAvailable: true }, (async (lock) => { + lock ? await e2.checkBlacklist(true) : show.error("后台任务正在运行,请稍后再试"); + })).catch(((error) => { + clog.error("锁任务出现错误:", error), e2.isNetworkBlocked(error) && show.error(error.message || "任务执行失败"); + })).finally((() => { + button.removeAttr("aria-busy").prop("disabled", false), label.text(previous); + })); + })); + }, "success"), + end: /* @__PURE__ */ __name(async () => { + this.blacklistSearchDebounced?.cancel?.(), this.blacklistSearchDebounced = null, this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null), await jhsEventBus.emit("blacklist-rules-changed"); + }, "end") + }); + } + renderTaskStatus() { + const container = $("#blacklist-task-status"); + if (!container.length) return; + const snapshot = this.getDependency("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); + container.empty().append($('').text(`黑名单:${labels[snapshot.state]}`), $('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); + } + async reloadTable() { + if (!this.tableObj) return; + const e2 = await this.getTableData(); + this.tableObj.setData(e2); + } + async getTableData() { + const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getBlacklist(), n2 = await storageManager.getBlacklistCarList(), a2 = String($("#searchValue").val() || "").trim().toLocaleLowerCase(), i2 = $("#statusType").val(), s2 = $("#dataType"), o2 = s2.val(), r2 = $("#urlType").val(), l2 = t2.length; + let c2 = 0, d2 = 0; + const h2 = t2.map(((t3) => { + t3.role === B ? c2++ : t3.role === P && d2++; + let n3 = false; + return n3 = shouldSkipStopped(t3.lastPublishTime, this.checkBlacklist_ruleTime), { + ...t3, + isUnCheck: n3 + }; + })).filter(((item) => { + const aliases = Array.isArray(item.allName) ? item.allName.join(" ") : item.allName || "", searchable = `${item.name || ""} ${aliases} ${item.starId || ""}`.toLocaleLowerCase(), searchMatch = !a2 || searchable.includes(a2), statusMatch = !i2 || "normal" === i2 && !item.isUnCheck || "stop" === i2 && item.isUnCheck, roleMatch = !o2 || item.role === o2, hasCategory = item.url.includes("t="), urlMatch = !r2 || "hasT" === r2 && hasCategory || "noT" === r2 && !hasCategory; + return searchMatch && statusMatch && roleMatch && urlMatch; + })); + s2.html(` + + + + `), JhsSelect.setValue(s2, o2, false); + const g2 = /* @__PURE__ */ new Map(); + for (const m2 of n2) { + const e3 = m2.starId; + g2.has(e3) || g2.set(e3, []), g2.get(e3).push(m2); + } + const p2 = h2.map(((e3) => { + const t3 = e3.starId, n3 = g2.get(t3) || []; + return { + ...e3, + carList: n3, + count: n3.length + }; + })); + return this.currentCarCount = p2.reduce(((e3, t3) => e3 + (t3.count || 0)), 0), p2; + } + async loadTableData() { + this.checkBlacklist_ruleTime = parseNumberSetting(await storageManager.getSetting("checkBlacklist_ruleTime"), 8760, { min: 0 }); + const e2 = await this.getTableData(), placeholder = document.createElement("div"); + renderStateView(placeholder, { type: "empty", title: "没有符合当前筛选条件的黑名单记录" }); + this.tableObj = new Tabulator("#table-container", { + layout: "fitColumns", + placeholder, + virtualDom: true, + data: e2, + pagination: true, + paginationMode: "local", + paginationSize: 20, + paginationSizeSelector: [20, 50, 100, 1e3], + paginationCounter: /* @__PURE__ */ __name((e3, t2, n2, a2, i2) => `演员: ${a2}    番号总数: ${this.currentCarCount} `, "paginationCounter"), + responsiveLayout: "collapse", + responsiveLayoutCollapse: true, + columnDefaults: { + headerHozAlign: "center", + hozAlign: "center" + }, + index: "starId", + columns: [{ + title: "演员", + field: "name", + sorter: "string", + minWidth: 100, + responsive: 0, + headerSort: false, + formatter: /* @__PURE__ */ __name((e3, t2, n2) => { + const a2 = e3.getData(), url = normalizeHttpUrl(a2.url), link = document.createElement("a"); + link.className = "open-url", link.textContent = String(a2.name || ""), link.dataset.name = String(a2.name || ""); + return url ? (link.href = url, link.dataset.url = url, link.target = "_blank", link.rel = "noopener noreferrer") : (link.href = "#", link.setAttribute("aria-disabled", "true")), link; + }, "formatter") + }, { + title: "性别角色", + field: "role", + sorter: "string", + width: 120, + responsive: 5, + formatter: /* @__PURE__ */ __name((e3, t2, n2) => { + const a2 = e3.getData().role; + let i2 = a2; + return a2 === B ? i2 = "男演员" : a2 === P && (i2 = "女演员"), i2; + }, "formatter") + }, { + title: "影视类别", + field: "movieType", + sorter: "string", + width: 120, + responsive: 5, + formatter: /* @__PURE__ */ __name((e3, t2, n2) => { + const a2 = e3.getData().movieType; + let i2 = a2; + return a2 === D ? i2 = "有码" : a2 === A && (i2 = "无码"), i2; + }, "formatter") + }, { + title: "屏蔽类型", + field: "url", + sorter: "string", + minWidth: 120, + responsive: 4, + visible: r, + formatter: /* @__PURE__ */ __name((e3, t2, n2) => { + let a2 = e3.getData().url.includes("t="); + return `${a2 ? "按所选分类屏蔽" : "未筛选分类"}`; + }, "formatter") + }, { + title: "番号数量", + field: "count", + sorter: "number", + width: 170, + responsive: 1 + }, { + title: "创建时间", + field: "createTime", + sorter: "string", + width: 170, + responsive: 5 + }, { + title: "最后发行时间", + field: "lastPublishTime", + sorter: "string", + width: 170, + responsive: 1 + }, { + title: "状态", + field: "isUnCheck", + sorter: "string", + width: 120, + responsive: 1, + formatter: /* @__PURE__ */ __name((e3, t2, n2) => { + let a2 = "", i2 = "继续检测"; + return e3.getData().isUnCheck && (a2 = `停更${this.checkBlacklist_ruleTime / 24 / 365}年以上, 下轮任务不再进行检测`, i2 = "停更跳过"), `${i2}`; + }, "formatter") + }, { + title: "操作", + sorter: "string", + cssClass: "action-cell-dropdown", + minWidth: 150, + responsive: 0, + headerSort: false, + formatter: /* @__PURE__ */ __name((e3, t2, n2) => { + const a2 = e3.getData(); + return n2((() => { + var t3, n3; + null == (t3 = e3.getElement().querySelector(".delete-btn")) || t3.addEventListener("click", ((e4) => { + const t4 = a2.name, n4 = a2.starId; + t4 ? n4 ? utils.q(e4, `是否移除对 ${t4} 的屏蔽?`, (async () => { + await storageManager.removeBlacklistCarList(n4), await storageManager.deleteBlacklistItem(n4), show.info("操作成功"), await this.reloadTable(); + })) : show.error("获取starId失败") : show.error("获取名称失败"); + })), null == (n3 = e3.getElement().querySelector(".keyword-btn")) || n3.addEventListener("click", ((e4) => { + const t4 = a2.carList.reduce(((e5, t5) => { + const n5 = t5.carNum.split("-")[0] + "-"; + return e5[n5] = (e5[n5] || 0) + 1, e5; + }), {}), n4 = Object.entries(t4).map((([e5, t5]) => ({ + prefix: e5, + count: t5 + }))).sort(((e5, t5) => t5.count - e5.count)); + clog.debug(n4); + })); + })), ''; + }, "formatter") + }], + initialSort: [{ + column: "createTime", + dir: "desc" + }], + locale: "zh-cn", + langs: { + "zh-cn": { + pagination: { + first: "首页", + first_title: "首页", + last: "尾页", + last_title: "尾页", + prev: "上一页", + prev_title: "上一页", + next: "下一页", + next_title: "下一页", + all: "全部", + page_size: "每页行数" + } + } + } + }); + } + async filterAllVideo(e2, t2, page = 1, processed = 0) { + let n2, a2; + if (t2 ? (l && t2.find(".avatar-box").length > 0 && t2.find(".avatar-box").parent().remove(), n2 = t2.find(this.getSelector().requestDomItemSelector), a2 = t2.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")), a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); + for (const s2 of n2) { + const t3 = $(s2), { carNum: n3, url: a3, publishTime: o2 } = this.getDependency("ListPagePlugin").findCarNumAndHref(t3); + if (a3 && n3) try { + await stateService.patch(n3, { blocked: true }, { type: "actor-page-block", record: { carNum: n3, url: a3, names: e2, publishTime: o2 } }), clog.log("屏蔽演员番号", e2, n3); + } catch (i2) { + clog.error(`保存失败 [${n3}]:`, i2); + } + } + processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); + if (a2) { + clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); + const t3 = await gmHttp.get(a2), n3 = new DOMParser(), i2 = $(n3.parseFromString(t3, "text/html")); + await this.filterAllVideo(e2, i2, page + 1, processed); + } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); + } + async batchSaveAllVideos(e2, t2, pageDom = null, page = 1, processed = 0) { + let n2, a2; + pageDom ? (n2 = pageDom.find(this.getSelector().requestDomItemSelector), a2 = pageDom.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")); + if (a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); + for (const i2 of n2) { + const n3 = $(i2), { carNum: a3, url: o2, publishTime: r2 } = this.getDependency("ListPagePlugin").findCarNumAndHref(n3); + if (o2 && a3) try { + const flag = legacyActionToFlag(t2); + flag && await stateService.patch(a3, { [flag]: true }, { type: "actor-page-batch-state", record: { carNum: a3, url: o2, names: e2, publishTime: r2 } }), clog.log("批量操作", e2, a3, t2); + } catch (s2) { + clog.error(`保存失败 [${a3}]:`, s2); + } + } + processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); + if (a2) { + clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); + const i2 = await gmHttp.get(a2), s2 = new DOMParser(), o2 = $(s2.parseFromString(i2, "text/html")); + await this.batchSaveAllVideos(e2, t2, o2, page + 1, processed); + } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); + } + async filterActorVideo(e2, t2, n2, site = l ? I : T, page = 1, processed = 0) { + let { nextPageLink: a2, recordCount } = await this.parseAndSaveFilterInfo(n2, e2, t2, site); + processed += recordCount, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已屏蔽 ${processed} 个番号`); + if (this.nextPageLink = a2, a2) { + let n3; + this.lastPageLink = a2; + clog.log("正在请求下一页内容:", a2); + const i2 = await gmHttp.get(a2); + n3 = utils.htmlTo$dom(i2); + await this.filterActorVideo(e2, t2, n3, site, page + 1, processed); + } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 新增 ${processed} 个番号`); + } + async parseAndSaveFilterInfo(e2, t2, n2, site) { + if (![T, I].includes(site)) throw new Error(`未知黑名单来源站点: ${site}`); + const page = e2 || $(document), selector = this.getSelector(site); + site === I && page.find(".avatar-box").length > 0 && page.find(".avatar-box").parent().remove(); + const pageState = parseDetailPage(page, { + boxSelector: site === I ? `${selector.boxSelector}, #waterfall` : selector.boxSelector, + requestDomItemSelector: e2 ? selector.requestDomItemSelector : selector.itemSelector + }), nextPageLink = page.find(selector.nextPageSelector).attr("href"); + if ("valid" !== pageState.state) throw new Error(`黑名单作品页面无效: ${pageState.state}`); + if (pageState.isEmpty && nextPageLink) throw new Error("黑名单作品空页面包含下一页"); + const records = [], publishTimes = []; + for (const item of pageState.items) { + const element = $(item), { carNum, url, publishTime } = this.getDependency("ListPagePlugin").findCarNumAndHref(element); + publishTime && publishTimes.push(publishTime), url && carNum && records.push({ + carNum, + url, + names: t2, + actionType: d, + starId: n2, + publishTime + }); + } + await storageManager.batchSaveBlacklistCarList(records); + return { + nextPageLink, + lastPublishTime: selectLatestPublishTime(publishTimes), + recordCount: records.length + }; + } + }; + __name(_BlacklistPlugin, "BlacklistPlugin"); + var BlacklistPlugin = _BlacklistPlugin; + // src/plugins/blacklist/filter-title-keyword.js + var _FilterTitleKeywordPlugin = class _FilterTitleKeywordPlugin extends BasePlugin { + getName() { + return "FilterTitleKeywordPlugin"; + } + async handle() { + if (!isDetailPage) return; + await this.bindDetailRoot(document); + } + async bindDetailRoot(root, { layerIndex = null } = {}) { + if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; + const host = $(root), selector = r ? ".title strong, .current-title" : l ? "h3" : ".current-title, .origin-title, .jhs-detail-title"; + host.off("contextmenu.jhsTitleFilter", selector).on("contextmenu.jhsTitleFilter", selector, ((e2) => { + const t2 = window.getSelection().toString(); + if (t2) { + e2.preventDefault(); + let n2 = { + clientX: e2.clientX, + clientY: e2.clientY + 80 + }; + utils.q(n2, `是否屏蔽标题关键词 ${t2}?`, (async () => { + await storageManager.saveTitleFilterKeyword(t2), await jhsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }), utils.closePage({ root: host, layerIndex }); + })); + } + })); + } + }; + __name(_FilterTitleKeywordPlugin, "FilterTitleKeywordPlugin"); + var FilterTitleKeywordPlugin = _FilterTitleKeywordPlugin; -
    -

    屏蔽配置

    配置文本、演员和类别筛选规则。

    -
    - - 启用划词屏蔽 - -
    - -
    -
    + // src/core/javdb-api.js + var U = "https://jdforrepam.com/api"; + var javDbMovieIdRequests = /* @__PURE__ */ new Map(); + function O() { + const e2 = "jhs_review_ts", t2 = "jhs_review_sign", n2 = Math.floor(Date.now() / 1e3); + if (n2 - (localStorage.getItem(e2) || 0) <= 20) return localStorage.getItem(t2); + const a2 = `${n2}.lpw6vgqzsp.${md5(`${n2}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; + return localStorage.setItem(e2, n2), localStorage.setItem(t2, a2), a2; + } + __name(O, "O"); + async function resolveJavDbMovieId(carNum) { + const normalized = normalizeCarNum(carNum); + if (!normalized) return null; + if (javDbMovieIdRequests.has(normalized)) return javDbMovieIdRequests.get(normalized); + const request = storageManager.cachedRequest(`javdb-movie-id:${normalized}`, 7 * 864e5, (async () => { + const response = await gmHttp.get(`${U}/v2/search`, { + q: normalized, + page: 1, + type: "movie", + limit: 20, + movie_type: "all", + from_recent: "false", + movie_filter_by: "all", + movie_sort_by: "relevance" + }, { + "user-agent": "Dart/3.5 (dart:io)", + "accept-language": "zh-TW", + host: "jdforrepam.com", + jdsignature: await O() + }); + if (!Array.isArray(response?.data?.movies)) throw new Error(response?.message || "JavDB 番号解析失败"); + const match = response.data.movies.find(((movie) => normalizeCarNum(movie.number) === normalized)); + return match?.id ? { __jhsCacheTtl: 7 * 864e5, data: { movieId: String(match.id) } } : { __jhsCacheTtl: 6 * 36e5, data: { miss: true } }; + })).then(((value) => value?.miss ? null : value?.movieId || null)); + javDbMovieIdRequests.set(normalized, request); + try { + return await request; + } finally { + javDbMovieIdRequests.get(normalized) === request && javDbMovieIdRequests.delete(normalized); + } + } + __name(resolveJavDbMovieId, "resolveJavDbMovieId"); + async function markJavDbWantWatch(movieId) { + const id = String(movieId || "").trim(), encryptedToken = localStorage.getItem("jhs_appAuthorization"), token = encryptedToken ? await decryptData(encryptedToken) : ""; + if (!token) { + const error = new Error("请先登录 JavDB 账号"); + throw error.code = "LOGIN_REQUIRED", error; + } + if (!id) throw new Error("JavDB 影片 ID 无效"); + const boundary = "----jhs-javdb-want-watch", body = [["status", "want_watch"], ["score", "0"], ["content", ""]].map((([name, value]) => `--${boundary}\r +Content-Disposition: form-data; name="${name}"\r +\r +${value}\r +`)).join("") + `--${boundary}--\r +`; + try { + const response = await gmHttp.gmRequest("POST", `${U}/v1/movies/${encodeURIComponent(id)}/reviews`, body, {}, { + "user-agent": "Dart/3.5 (dart:io)", + "accept-language": "zh-TW", + authorization: `Bearer ${token}`, + jdsignature: await O(), + "content-type": `multipart/form-data; boundary=${boundary}` + }); + if (0 === response?.success) throw response; + await storageManager.deleteCachedRequest(`movie-detail:${id}`); + return response; + } catch (error) { + if (401 === error?.status || "JWTVerificationError" === error?.action || /未登录|登录|unauthorized|jwt/i.test(error?.message || "")) { + localStorage.removeItem("jhs_appAuthorization"); + const loginError = new Error("JavDB 登录已失效,请重新登录"); + throw loginError.code = "LOGIN_REQUIRED", loginError; + } + throw error instanceof Error ? error : new Error(error?.message || "加入 JavDB 想看失败"); + } + } + __name(markJavDbWantWatch, "markJavDbWantWatch"); + var R = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { + let a2 = `${U}/v1/movies/${e2}/reviews`, i2 = { + jdSignature: await O() + }; + return await storageManager.cachedRequest(`reviews:${e2}:${t2}:${n2}`, 864e5, (async () => { + const e3 = await gmHttp.get(a2, { + page: t2, + sort_by: "hotly", + limit: n2 + }, i2); + if (!e3?.data?.reviews) throw new Error(e3?.message || "获取评论失败"); + return e3.data.reviews; + })); + }, "R"); + var V = /* @__PURE__ */ __name(async (e2) => { + let t2 = `${U}/v4/movies/${e2}`, n2 = { + jdSignature: await O() + }; + const a2 = await storageManager.cachedRequest(`movie-detail:${e2}`, 6048e5, (async () => { + const e3 = await gmHttp.get(t2, null, n2); + if (!e3.data) throw new Error(e3.message || "获取视频详情失败"); + return e3; + })); + if (!a2.data) throw show.error("获取视频详情失败: " + a2.message), new Error(a2.message); + const i2 = a2.data.movie, s2 = i2.preview_images, o2 = []; + return s2.forEach(((e3) => { + o2.push(e3.large_url.replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); + })), { + movieId: i2.id, + actors: i2.actors, + duration: i2.duration, + title: i2.origin_title, + carNum: i2.number, + score: i2.score, + releaseDate: i2.release_date, + watchedCount: i2.watched_count, + imgList: o2 + }; + }, "V"); + var K = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { + let a2 = `${U}/v1/lists/related?movie_id=${e2}&page=${t2}&limit=${n2}`, i2 = { + jdSignature: await O() + }; + const s2 = await storageManager.cachedRequest(`related:${e2}:${t2}:${n2}`, 864e5, (async () => { + const e3 = await gmHttp.get(a2, null, i2); + if (!e3?.data?.lists) throw new Error(e3?.message || "获取相关清单失败"); + return e3; + })), o2 = []; + return s2.data.lists.forEach(((e3) => { + o2.push({ + relatedId: e3.id, + name: e3.name, + movieCount: e3.movies_count, + collectionCount: e3.collections_count, + viewCount: e3.views_count, + createTime: utils.formatDate(e3.created_at) + }); + })), o2; + }, "K"); + var W = /* @__PURE__ */ __name(async (e2 = "daily", t2 = "high_score") => { + let n2 = `${U}/v1/rankings/playback?period=${e2}&filter_by=${t2}`, a2 = { + jdSignature: await O() + }; + return (await gmHttp.get(n2, null, a2)).data.movies; + }, "W"); + var q = /* @__PURE__ */ __name(async (e2 = "all", t2 = "", n2 = 1, a2 = 40) => { + let i2 = `${U}/v1/movies/top?start_rank=1&type=${e2}&type_value=${t2}&ignore_watched=false&page=${n2}&limit=${a2}`; + const l2 = localStorage.getItem("jhs_appAuthorization"), c2 = l2 ? await decryptData(l2) : ""; + let s2 = { + "user-agent": "Dart/3.5 (dart:io)", + "accept-language": "zh-TW", + host: "jdforrepam.com", + authorization: "Bearer " + c2, + jdsignature: await O() + }; + return await gmHttp.get(i2, null, s2); + }, "q"); + // src/integrations/av123/parser.js + function parse123AvCards($page, baseUrl = "https://123av.com") { + const items = /* @__PURE__ */ new Map(); + const wrap = ( + /** @type {any} */ + globalThis.jQuery ?? /** @type {any} */ + globalThis.$ ?? $page.constructor + ); + $page.find(".card").each(((index, element) => { + const $card = wrap(element); + const $link = $card.find('a.card__link[href*="/cn/v/fc2-ppv-"]').first(); + const href = $link.attr("href"), text = $link.text().trim(); + const numberMatch = text.match(/FC2-PPV-(\d+)/i); + if (!href || !numberMatch) return; + const carNum = `FC2-${numberMatch[1]}`; + const detailUrl = new URL(href, baseUrl); + detailUrl.hash = ""; + items.set(carNum, { + imgSrc: $card.find("img.card__img[src]").first().attr("src") || "", + carNum, + href: detailUrl.href, + title: text.replace(/^FC2-PPV-\d+\s*[—–-]\s*/i, "").trim(), + preview: $card.find(".card__poster[data-preview]").first().attr("data-preview") || null + }); + })); + return [...items.values()]; + } + __name(parse123AvCards, "parse123AvCards"); + function merge123AvCards(cardLists) { + const items = /* @__PURE__ */ new Map(); + cardLists.flat().forEach( + /** @type {Record} */ + ((item) => items.set(item.carNum, item)) + ); + return [...items.values()]; + } + __name(merge123AvCards, "merge123AvCards"); + function parse123AvSourceMaxPage($page, baseUrl = "https://123av.com") { + const lastHref = $page.find('a[rel="last"]').first().attr("href"); + if (lastHref) { + const page = Number.parseInt(new URL(lastHref, baseUrl).searchParams.get("page") ?? "", 10); + if (Number.isFinite(page)) return page; + } + const inputMax = Number.parseInt($page.find("input.pager__input[max]").first().attr("max"), 10); + if (Number.isFinite(inputMax)) return inputMax; + const totalMatch = $page.find(".pager__total").first().text().match(/(\d[\d,]*)/); + return totalMatch ? Number.parseInt(totalMatch[1].replaceAll(",", ""), 10) : null; + } + __name(parse123AvSourceMaxPage, "parse123AvSourceMaxPage"); + function parse123AvVideoInfo($page, url) { + const idMatch = new URL(url).pathname.match(/fc2-ppv-(\d+)/i); + const id = idMatch ? idMatch[1] : null; + const rawTitle = $page.find("h1.watch__title").first().text().trim(); + const title = id ? rawTitle.replace(new RegExp(`^FC2-PPV-${id}\\s*[—–-]\\s*`, "i"), "").trim() : rawTitle; + const pageText = $page.find("body").text(); + const publishDate = pageText.match(/发布日期\s*(\d{4}-\d{2}-\d{2})/)?.[1] || ""; + return { id, publishDate, title, moviePoster: null }; + } + __name(parse123AvVideoInfo, "parse123AvVideoInfo"); + // src/plugins/external-search/fc2-by-123av.js + var AV123_REQUEST_OPTIONS = Object.freeze({ cookiePartitionTopLevelSite: "https://123av.com" }); + var _Fc2By123AvPlugin = class _Fc2By123AvPlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "$contentBox", $(".section .container")), i(this, "urlParams", new URLSearchParams(window.location.search)), i(this, "currentPage", this.urlParams.get("page") ? parseInt(this.urlParams.get("page")) : 1), i(this, "maxPage", null), i(this, "keyword", this.urlParams.get("keyword") || null); + } + getName() { + return "Fc2By123AvPlugin"; + } + async getBaseUrl() { + return await this.getDependency("OtherSitePlugin").getAv123Url(); + } + request123Av(e2, requestOptions = {}) { + return gmHttp.get(e2, {}, {}, false, { ...AV123_REQUEST_OPTIONS, ...requestOptions }); + } + async handle() { + $("#navbar-menu-hero > div > div:nth-child(1) > div > a:nth-child(4)").after('123Av-Fc2'), $('.tabs li:contains("FC2")').after('
  • 123Av-Fc2
  • '), o.includes("/advanced_search?type=100") && (this.hookPage(), await this.handleQuery()); + } + hookPage() { + let e2 = $("h2.section-title"); + e2.contents().first().replaceWith("123Av"), e2.css("marginBottom", "0"), e2.append('\n
    \n \n \n \n
    \n '), $("#search-123av-keyword").val(this.keyword), $("#search-123av-btn").on("click", (async () => { + let e3 = $("#search-123av-keyword").val().trim(); + e3 && (this.keyword = e3, utils.setHrefParam("keyword", e3), await this.handleQuery()); + })), $("#clear-123av-btn").on("click", (async () => { + $("#search-123av-keyword").val(""), this.keyword = "", utils.setHrefParam("keyword", ""), $(".page-box").show(), await this.handleQuery(); + })), $(".empty-message").remove(), $("#foldCategoryBtn").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('
    '), this.$contentBox.append('
    '); + utils.setHrefParam("page", this.currentPage); + $(".page-box").append('\n \n '), $(document).on("click", ".pagination-link", ((e3) => { + e3.preventDefault(), this.currentPage = parseInt($(e3.target).data("page")), utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery(); + })), $(".pagination-previous").on("click", ((e3) => { + e3.preventDefault(), this.currentPage > 1 && (this.currentPage--, utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery()); + })), $(".pagination-next").on("click", ((e3) => { + e3.preventDefault(), this.currentPage < this.maxPage && (this.currentPage++, utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery()); + })); + } + renderPagination() { + const e2 = $(".pagination-list"); + e2.empty(); + let t2 = Math.max(1, this.currentPage - 2), n2 = Math.min(this.maxPage, this.currentPage + 2); + this.currentPage <= 3 ? n2 = Math.min(6, this.maxPage) : this.currentPage >= this.maxPage - 2 && (t2 = Math.max(this.maxPage - 5, 1)), t2 > 1 && (e2.append('
  • '), t2 > 2 && e2.append('
  • …
  • ')); + for (let a2 = t2; a2 <= n2; a2++) { + const t3 = a2 === this.currentPage ? " is-current" : ""; + e2.append(`
  • `); + } + n2 < this.maxPage && (n2 < this.maxPage - 1 && e2.append('
  • …
  • '), e2.append(`
  • `)); + } + async handleQuery() { + let e2 = loading(); + try { + let e3 = [2 * this.currentPage - 1, 2 * this.currentPage]; + this.keyword && (e3 = [1], $(".page-box").hide()); + const t2 = await this.getBaseUrl(); + const requests = e3.map(((sourcePage) => this.request123Av(this.keyword ? `${t2}/cn/search?keyword=${encodeURIComponent(this.keyword)}` : `${t2}/cn/makers/fc2?page=${sourcePage}`))); + const pages = (await Promise.all(requests)).map(((html) => utils.htmlTo$dom(html))); + const i2 = merge123AvCards(pages.map((($page) => parse123AvCards($page, t2)))); + if (!this.keyword && !this.maxPage && pages.length) { + const sourceMaxPage = parse123AvSourceMaxPage(pages[0], t2); + sourceMaxPage && (this.maxPage = Math.ceil(sourceMaxPage / 2), this.renderPagination()); + } + if (0 === i2.length) { + clog.log(i2), show.error("无结果"); + const e4 = this.keyword ? `${t2}/cn/search?keyword=${encodeURIComponent(this.keyword)}` : `${t2}/cn/makers/fc2`; + clog.error("获取数据失败!", e4); + } + let s2 = this.markDataListHtml(i2); + $(".movie-list").html(s2), await utils.smoothScrollToTop(); + } catch (t2) { + clog.error(t2); + } finally { + e2.close(); + } + } + async open123AvFc2Dialog(carNum, url) { + return this.getDependency("Fc2Plugin").openFc2Dialog(null, carNum, url, { source: "123av" }); + } + /** 将 123AV 数据填入 Fc2Plugin 创建的固定工作区。 */ + async loadDetail(context, url) { + const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum), movieIdPromise = resolveJavDbMovieId(context.carNum), fc2Plugin = this.getDependency("Fc2Plugin"); + void fc2Plugin.configureJavDbWantButton(context, movieIdPromise), void fc2Plugin.mountPanels(context, movieIdPromise), void movieIdPromise.then(((movieId) => context.isAlive() && fc2Plugin.fetchAndRenderNativeMagnets(context, movieId))).catch(((error) => { + context.isAlive() && fc2Plugin.setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))), clog.error("123AV 磁力关联失败", error); + })); + imagesPromise.then(((images) => context.isAlive() && this.getDependency("Fc2Plugin").renderGallery(context, images))).catch(((error) => context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))))); + actressPromise.then((async (data) => { + await infoPromise.catch((() => null)); + context.isAlive() && this.render123AvActress(context, data); + })).catch(((error) => clog.error("FC2 演员信息加载失败", error))); + await Promise.allSettled([infoPromise, imagesPromise, actressPromise, movieIdPromise]); + } + async loadSummary(context, url) { + try { + const info = await this.get123AvVideoInfo(url); + if (!context.isAlive()) return null; + this.render123AvSummary(context, info), await this.getDependency("TranslatePlugin").translate(context.carNum, false, { root: context.root }); + return info; + } catch (error) { + context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); + throw error; + } + } + async retryResolvedMagnets(context) { + try { + return await this.getDependency("Fc2Plugin").fetchAndRenderNativeMagnets(context, await resolveJavDbMovieId(context.carNum)); + } catch (error) { + context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))); + } + } + render123AvSummary(context, info) { + const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

    '); + title.find("strong").text(info.title || "无标题"), body.append(title, $('
    ').append($("").text(`番号:${context.carNum}`), $("").text(`发行:${info.publishDate || "未知"}`)), '
    主演:正在加载演员…
    ', '
    ', this.getDependency("Fc2Plugin").createSourceLinks(context), $('').text(info.publishDate || "")); + } + async get123AvVideoInfo(e2) { + const t2 = await this.request123Av(e2); + return parse123AvVideoInfo(utils.htmlTo$dom(t2), e2); + } + async getActressInfo(e2) { + let t2 = `https://fc2ppvdb.com/articles/${e2.replace("FC2-", "")}`; + const n2 = await gmHttp.get(t2), a2 = $(n2), i2 = a2.find("div").filter((function() { + return 0 === $(this).text().trim().indexOf("女優:"); + })); + if (0 === i2.length || i2.length > 1) return { actors: [], seller: null }; + const s2 = $(i2[0]).find("a"); + const actors = []; + if (s2.length > 0) { + s2.each(((t3, n3) => { + const link = $(n3), name = link.text().trim(), url = normalizeHttpUrl(link.attr("href"), "https://fc2ppvdb.com"); + name && actors.push({ name, url }); + })); + } + const r2 = a2.find("div").filter((function() { + return 0 === $(this).text().trim().indexOf("販売者:"); + })); + let seller = null; + if (r2.length > 0) { + const link = $(r2[0]).find("a").first(); + link.length && (seller = { name: link.text().trim(), url: normalizeHttpUrl(link.attr("href"), "https://fc2ppvdb.com") }); + } + return { actors, seller }; + } + async getImgList(e2) { + let t2 = e2.replace("FC2-", ""), n2 = `https://adult.contents.fc2.com/article/${e2.replace("FC2-", "")}/`; + const a2 = await gmHttp.get(n2, null, { + referer: n2 + }); + return $(a2).find(".items_article_SampleImagesArea img").map((function() { + return normalizeHttpUrl($(this).attr("src"), n2); + })).get().filter(Boolean); + } + async reloadImages(context) { + try { + const images = await this.getImgList(context.carNum); + context.isAlive() && this.getDependency("Fc2Plugin").renderGallery(context, images); + } catch (error) { + context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))); + } + } + render123AvActress(context, data) { + const host = context.root.find('[data-jhs-role="actors"]').empty().append("主演:"); + data.actors.length ? data.actors.forEach(((actor) => host.append($("").addClass("jhs-fc2-actor").attr({ href: actor.url, target: "_blank", rel: "noopener noreferrer" }).text(actor.name)))) : host.append($("").text("暂无演员信息")); + context.root.find('[data-jhs-role="actress-data"]').remove(), context.root.find(".jhs-fc2-summary__body").append($('').text(data.actors.map(((actor) => actor.name)).join(" "))); + if (data.seller) context.root.find('[data-jhs-role="seller"]').empty().append("卖家:", data.seller.url ? $("").attr({ href: data.seller.url, target: "_blank", rel: "noopener noreferrer" }).text(data.seller.name) : document.createTextNode(data.seller.name)); + } + markDataListHtml(e2) { + let t2 = ""; + return e2.forEach(((e3) => { + const href = normalizeHttpUrl(e3.href, "https://123av.com"), imageUrl = normalizeHttpUrl(e3.imgSrc, "https://123av.com"); + if (!href) return; + t2 += ` + + `; + })), t2; + } + }; + __name(_Fc2By123AvPlugin, "Fc2By123AvPlugin"); + var Fc2By123AvPlugin = _Fc2By123AvPlugin; -
    -
    - 评论区屏蔽词: -
    - - -
    -
    -
    -
    + // src/core/detail-state-controller.js + var DETAIL_STATE_BUTTONS = { + blocked: { selector: "#filterBtn", inactive: /* @__PURE__ */ __name(() => m, "inactive"), active: /* @__PURE__ */ __name(() => u, "active") }, + favorite: { selector: "#favoriteBtn", inactive: /* @__PURE__ */ __name(() => v, "inactive"), active: /* @__PURE__ */ __name(() => b, "active") }, + downloaded: { selector: "#hasDownBtn", inactive: /* @__PURE__ */ __name(() => y, "inactive"), active: /* @__PURE__ */ __name(() => "已标记下载", "active") }, + watched: { selector: "#hasWatchBtn", inactive: /* @__PURE__ */ __name(() => k, "inactive"), active: /* @__PURE__ */ __name(() => "已标记观看", "active") } + }; + var _DetailStateController = class _DetailStateController { + bind({ root = document, layerIndex = null, carNum, getRecord, activityType = "detail-state", selectors = {} }) { + const config = { root, layerIndex, carNum: normalizeCarNum(carNum), getRecord, activityType, selectors }; + for (const [flag, definition] of Object.entries(DETAIL_STATE_BUTTONS)) { + const selector = selectors[flag] || definition.selector; + $(root).find(selector).off("click.jhsDetailState").on("click.jhsDetailState", ((event) => { + event.preventDefault(), event.stopPropagation(), void this.requestToggle(config, flag, event); + })); + } + void this.render(config); + return config; + } + async requestToggle(config, flag, event = null) { + if (!config.carNum) return void show.error("番号不可用,无法更新状态"); + const current = await storageManager.getCar(config.carNum), flags = normalizeStateFlags(current?.stateFlags); + if ("blocked" === flag && !flags.blocked) return void utils.q(event, `是否屏蔽${config.carNum}?`, (() => this.toggle(config, flag, event))); + return this.toggle(config, flag, event); + } + async toggle(config, flag, event = null) { + const selector = config.selectors[flag] || DETAIL_STATE_BUTTONS[flag].selector, button = event?.currentTarget ? $(event.currentTarget) : $(config.root).find(selector); + if (button.prop("disabled")) return; + button.prop("disabled", true).attr("aria-busy", "true"); + try { + const record = "function" == typeof config.getRecord ? await config.getRecord() : config.getRecord || { carNum: config.carNum }; + await stateService.toggle(config.carNum, flag, { type: config.activityType, record }), await this.render(config), await utils.closePage({ layerIndex: config.layerIndex, root: config.root }); + } catch (error) { + clog.error("详情状态更新失败", error), show.error("操作失败"); + } finally { + button[0]?.isConnected && button.prop("disabled", false).removeAttr("aria-busy"); + } + } + async render({ root = document, carNum, selectors = {} }) { + const record = await storageManager.getCar(normalizeCarNum(carNum)), flags = normalizeStateFlags(record?.stateFlags); + for (const [flag, definition] of Object.entries(DETAIL_STATE_BUTTONS)) { + const button = $(root).find(selectors[flag] || definition.selector), active = !!flags[flag]; + button.attr("aria-pressed", String(active)).find("span").first().text(active ? definition.active() : definition.inactive()); + } + return flags; + } + }; + __name(_DetailStateController, "DetailStateController"); + var DetailStateController = _DetailStateController; + var detailStateController = new DetailStateController(); + // src/plugins/status/detail-workspace.js + function getDetailResourceAdapter() { + if (!window.isDetailPage) return null; + if (r) { + const hostRoot = $(".video-detail").first(), controller = hostRoot.find('[data-controller="magnet-sort"]').first(), resourceRoot = controller.find("#magnets-content").first(); + if (!hostRoot.length || !controller.length || !resourceRoot.length) return null; + const resourceRegion = controller.closest(hostRoot.children()).first(); + return { + site: "javdb", + hostRoot, + controller, + observeRoot: controller, + resourceRoot, + resourceRegion, + rows: /* @__PURE__ */ __name(() => resourceRoot.children(".item").toArray(), "rows"), + sortSelect: controller.find('select[data-action*="magnet-sort#sort"]').first(), + getResource(row) { + const item = $(row); + return item.find('.copy-to-clipboard[data-clipboard-text^="magnet:"]').first().attr("data-clipboard-text") || item.find('.magnet-name a[href^="magnet:"]').first().attr("href") || ""; + }, + getActionTarget: /* @__PURE__ */ __name((row) => $(row).children(".buttons").first(), "getActionTarget") + }; + } + if (l) { + const hostRoot = $(".container").filter(((_2, element) => $(element).find("#magnet-table").length > 0)).first(), resourceRoot = hostRoot.find("#magnet-table").first(); + if (!hostRoot.length || !resourceRoot.length) return null; + const resourceRegion = resourceRoot.closest(hostRoot.children()).first(), observeRoot = resourceRoot.parent(); + return { + site: "javbus", + hostRoot, + controller: resourceRoot, + observeRoot, + resourceRoot, + resourceRegion, + rows: /* @__PURE__ */ __name(() => resourceRoot.find("tr").filter(((_2, row) => $(row).find('td a[href^="magnet:"],td a[href^="ed2k:"]').length > 0)).toArray(), "rows"), + sortSelect: $(), + getResource: /* @__PURE__ */ __name((row) => $(row).find('td a[href^="magnet:"],td a[href^="ed2k:"]').first().attr("href") || "", "getResource"), + getActionTarget(row) { + const item = $(row), stableActions = item.find(".buttons,.actions,.btn-group").filter(((_2, element) => $(element).closest("td").length > 0)).last(); + if (stableActions.length) return stableActions; + const resourceCell = item.find('td:has(a[href^="magnet:"]),td:has(a[href^="ed2k:"])').first(); + let actions = resourceCell.children(".jhs-offline-actions").first(); + return actions.length || (actions = $('').appendTo(resourceCell)), actions; + } + }; + } + return null; + } + __name(getDetailResourceAdapter, "getDetailResourceAdapter"); + var _DetailWorkspacePlugin = class _DetailWorkspacePlugin extends BasePlugin { + constructor() { + super(), this.hostRoot = null, this.resourceObserver = null, this.scheduledResourceFrame = null; + } + getName() { + return "DetailWorkspacePlugin"; + } + async initCss() { + return ``; + } + async handle() { + if (!window.isDetailPage) return; + utils.loopDetector((() => !!this.getHostAdapter()), (() => this.ensureWorkspace()), 40, 2500, true); + } + getHostAdapter() { + if (r) { + const root = $(".video-detail").first(); + return root.length ? { site: "javdb", root } : null; + } + if (l) { + const root = $(".container").filter(((_2, element) => $(element).find("#magnet-table,.screencap,.info").length > 0)).first(); + return root.length ? { site: "javbus", root } : null; + } + return null; + } + ensureWorkspace() { + const adapter = this.getHostAdapter(); + if (!adapter) return $(); + const root = adapter.root; + if (!root.attr("data-jhs-workspace-ready")) { + root.attr({ "data-jhs-workspace-ready": "true", "data-jhs-workspace-site": adapter.site }).addClass("jhs-detail-host-workspace jhs-ui"); + if ("javdb" === adapter.site) { + root.children("h2,.video-meta-panel").attr("data-jhs-host-region", "summary"); + root.children(".columns").filter(((_2, element) => $(element).find(".tile-images,.preview-images").length > 0)).attr("data-jhs-host-region", "gallery"); + root.children(".columns").filter(((_2, element) => $(element).find("#magnets-content").length > 0)).attr("data-jhs-host-region", "resources"); + this.normalizeHostActions(root.find(".video-meta-panel").first()); + } else { + root.children("h3,.row.movie").attr("data-jhs-host-region", "summary"); + root.children().filter(((_2, element) => $(element).is("#mag-submit-show,#mag-submit") || $(element).find("#magnet-table").length > 0)).attr("data-jhs-host-region", "resources"); + root.children().filter(((_2, element) => $(element).is("#sample-waterfall") || $(element).find("#sample-waterfall").length > 0)).attr("data-jhs-host-region", "gallery"); + this.normalizeHostActions(root.find(".info").first()); + } + this.ensureOwnedSlots(root), this.adoptExistingOwnedPanels(root); + } + this.hostRoot = root, this.ensureOwnedSlots(root), this.placeOwnedSlots(), this.bindResourceLifecycle(); + return root; + } + getSlot(name) { + return this.ensureWorkspace().find(`[data-jhs-slot="${name}"]`).first(); + } + ensureOwnedSlots(root = this.hostRoot) { + if (!root?.length) return; + root.children('[data-jhs-slot="summary-actions"]').length || root.append('
    '); + let group = root.children('[data-jhs-slot-group="post-resource"]').first(); + group.length || (group = $('
    ').appendTo(root)); + group.children('[data-jhs-slot="reviews"]').length || group.append('
    '); + group.children('[data-jhs-slot="related"]').length || group.append(''); + } + /** 只移动 JHS 自有插槽,将其固定在稳定宿主锚点旁。 */ + placeOwnedSlots() { + const root = this.hostRoot, resource = getDetailResourceAdapter(); + if (!root?.length) return; + this.ensureOwnedSlots(root); + const summaryActions = root.children('[data-jhs-slot="summary-actions"]').first(), postResource = root.children('[data-jhs-slot-group="post-resource"]').first(); + const summaryRegion = "javdb" === root.attr("data-jhs-workspace-site") ? root.children(".video-meta-panel").first() : root.children(".row.movie").first(); + summaryRegion.length && summaryActions.insertAfter(summaryRegion); + resource?.resourceRegion?.length && postResource.insertAfter(resource.resourceRegion); + } + adoptExistingOwnedPanels(root) { + [[".jhs-detail-btn-row", "summary-actions"], [".jhs-related-panel", "related"], [".jhs-review-panel", "reviews"]].forEach((([selector, slot]) => { + const target = root.find(`[data-jhs-slot="${slot}"]`).first(); + root.find(selector).filter(((_2, element) => !$(element).closest("[data-jhs-slot]").length)).each(((_2, element) => target.append(element))); + })); + } + normalizeHostActions(info) { + const labels = /* @__PURE__ */ new Set(["想看", "看过", "看過", "存入清单", "存入清單", "下载", "下載", "订正", "訂正"]); + info.find("a, button").filter((function() { + return !$(this).is(".jhs-btn, [id^='jhs-']") && labels.has($(this).text().replace(/\s+/g, " ").trim()); + })).addClass("jhs-detail-host-action"); + } + isJhsOnlyMutation(record) { + if ($(record.target).closest(".jhs-offline-actions,.jhs-select-control,.jhs-magnet-score").length) return true; + const nodes = [...record.addedNodes, ...record.removedNodes].filter(((node) => node.nodeType === Node.ELEMENT_NODE)); + return nodes.length > 0 && nodes.every(((node) => node.matches?.(".jhs-offline-btn,.jhs-offline-actions,.jhs-magnet-score,.jhs-select-control") || node.closest?.(".jhs-offline-actions,.jhs-select-control"))); + } + bindResourceLifecycle() { + const adapter = getDetailResourceAdapter(); + if (!adapter) return; + if (this.resourceObserver && this.resourceObserver.root === adapter.observeRoot[0]) return void this.scheduleResourceUpdate(); + this.resourceObserver?.disconnect?.(); + const observer = new MutationObserver(((records) => { + records.every(((record) => this.isJhsOnlyMutation(record))) || this.scheduleResourceUpdate(); + })); + observer.root = adapter.observeRoot[0], observer.observe(adapter.observeRoot[0], { childList: true, subtree: true }), this.resourceObserver = observer, adapter.sortSelect.length && adapter.sortSelect.addClass("jhs-select-source") && JhsSelect.enhance(adapter.controller), this.scheduleResourceUpdate(); + } + scheduleResourceUpdate() { + if (this.scheduledResourceFrame) return; + const schedule = window.requestAnimationFrame || ((callback) => setTimeout(callback)); + this.scheduledResourceFrame = schedule((() => { + this.scheduledResourceFrame = null; + const adapter = getDetailResourceAdapter(); + if (!adapter) return; + this.placeOwnedSlots(); + adapter.sortSelect.length && (adapter.sortSelect.addClass("jhs-select-source"), JhsSelect.enhance(adapter.controller), JhsSelect.refresh(adapter.sortSelect)); + void jhsEventBus.emit("magnet-items-updated", { site: adapter.site, resourceRoot: adapter.resourceRoot[0], rows: adapter.rows() }, { broadcast: false }); + })); + } + }; + __name(_DetailWorkspacePlugin, "DetailWorkspacePlugin"); + var DetailWorkspacePlugin = _DetailWorkspacePlugin; + function createFc2DetailShell({ carNum = "", source = "fc2", mode = "dialog" } = {}) { + const workspace = $('
    ').attr({ + "data-jhs-fc2-source": source, + "data-jhs-fc2-mode": mode, + "data-jhs-car-num": normalizeCarNum(carNum) || "" + }); + const definitions = [["summary", "影片概览"], ["gallery", "预览与剧照"], ["resources", "资源"], ["reviews", "评论"], ["related", "相关清单"]]; + definitions.forEach((([name, title]) => { + const section = $('
    ').attr("data-jhs-section", name); + const header = $('
    '), heading = $("

    ").text(title), actions = $('
    ').attr("data-jhs-section-actions", name); + section.append(header.append(heading, actions), $('
    ').attr("data-jhs-slot", name)), workspace.append(section); + })); + return workspace; + } + __name(createFc2DetailShell, "createFc2DetailShell"); + function createFc2DetailContext(root, options = {}) { + const workspace = $(root).is(".jhs-fc2-workspace") ? $(root) : $(root).find(".jhs-fc2-workspace").first(); + let destroyed = false; + const namespace = `.jhsFc2Detail${Date.now()}${Math.random().toString(36).slice(2)}`, observers = /* @__PURE__ */ new Set(); + const context = { + ...options, + root: workspace, + workspace, + namespace, + observers, + getSlot: /* @__PURE__ */ __name((name) => workspace.find(`[data-jhs-slot="${name}"]`).first(), "getSlot"), + getSection: /* @__PURE__ */ __name((name) => workspace.find(`[data-jhs-section="${name}"]`).first(), "getSection"), + isAlive: /* @__PURE__ */ __name(() => !destroyed && workspace[0]?.isConnected !== false, "isAlive"), + addObserver(observer) { + observer && observers.add(observer); + return observer; + }, + destroy() { + if (destroyed) return; + destroyed = true, workspace.off(namespace).find("*").off(namespace), observers.forEach(((observer) => observer.disconnect?.())), observers.clear(), workspace.removeData("jhsFc2Context"); + } + }; + workspace.data("jhsFc2Context", context); + return context; + } + __name(createFc2DetailContext, "createFc2DetailContext"); + // src/plugins/external-search/fc2.js + var _Fc2Plugin = class _Fc2Plugin extends BasePlugin { + getName() { + return "Fc2Plugin"; + } + async initCss() { + return ``; + } + async handle() { + const fc2Href = "/advanced_search?type=3&score_min=0&d=1"; + $('.navbar-item:contains("FC2")').attr("href", fc2Href), $('.tabs a:contains("FC2")').attr("href", fc2Href); + if (o.includes("advanced_search?type=3")) $("h2.section-title").contents().first().replaceWith("Fc2PPV"), $(".section .container > .box").remove(); + if (!o.includes("collection_codes?movieId")) return; + const params = new URLSearchParams(window.location.search), movieId = params.get("movieId"), carNum = params.get("carNum"), url = params.get("url"), explicitSource = params.get("source"), source = ["fc2", "123av"].includes(explicitSource) ? explicitSource : await this.resolveFc2Source({ url }), host = $("section").first().empty(); + if (!carNum || !url) return void host.append($('
    ').text("FC2 详情参数不完整")); + const context = this.mountFc2Detail(host, { movieId, carNum, url, source, mode: "page" }); + $(window).off("pagehide.jhsFc2Detail").one("pagehide.jhsFc2Detail", (() => context.destroy())); + } + openFc2Dialog(movieId, carNum, url, { source = "fc2" } = {}) { + let context = null; + return layer.open({ + type: 1, + title: carNum, + content: '
    ', + area: utils.getDialogArea("workspace"), + skin: "movie-detail-layer", + scrollbar: false, + shadeClose: true, + success: /* @__PURE__ */ __name((layerRoot, layerIndex) => { + context = this.mountFc2Detail($(layerRoot).find(".jhs-fc2-dialog-host"), { movieId, carNum, url, source, layerIndex, mode: "dialog" }), utils.setupEscClose(layerIndex); + }, "success"), + end: /* @__PURE__ */ __name(() => context?.destroy(), "end") + }); + } + /** 统一挂载 FC2 与 123AV-FC2 详情。 */ + mountFc2Detail(host, options) { + const target = $(host), previous = target.data("jhsFc2Context"); + previous?.destroy?.(), target.empty(); + const shell = createFc2DetailShell(options).appendTo(target), context = createFc2DetailContext(shell, options); + target.data("jhsFc2Context", context), this.initializeWorkspace(context); + return context; + } + initializeWorkspace(context) { + const summary = $('
    正在加载影片信息…
    '), toolbar = $(''); + [["filterBtn", "jhs-btn--filter", m], ["favoriteBtn", "jhs-btn--fav", v], ["hasDownBtn", "jhs-btn--down", y], ["hasWatchBtn", "jhs-btn--watch", k]].forEach((([id, className, label]) => toolbar.append($('').attr("id", id).addClass(className).find("span").text(label).end()))); + toolbar.append('', '', ''), summary.find(".jhs-fc2-summary__body").append(toolbar), context.getSlot("summary").append(summary); + const gallery = $(''), screenshot = $('
    '); + gallery.on(`click${context.namespace}`, ".jhs-fc2-gallery-item", ((event) => { + const image = $(event.currentTarget).find("img")[0]; + image && showImageViewer(image, "", { galleryRoot: gallery[0] }); + })); + context.getSlot("gallery").append(gallery, screenshot); + const resources = $('
    '), nativeGroup = this.createResourceGroup("站内磁力", "native-magnets"), sitesGroup = this.createResourceGroup("第三方站点", "other-sites"), hubGroup = this.createResourceGroup("更多磁力来源", "magnet-hub"), hubButton = $(''); + let magnetHubPromise = null; + hubGroup.find('[data-jhs-role="magnet-hub"]').append(hubButton, '
    '), resources.append(nativeGroup, sitesGroup, hubGroup), context.getSlot("resources").append(resources); + toolbar.on(`click${context.namespace}`, '[data-jhs-action="subtitlecat"]', ((event) => utils.openPage(`https://subtitlecat.com/index.php?search=${encodeURIComponent(context.carNum)}`, context.carNum, false, event))), toolbar.on(`click${context.namespace}`, '[data-jhs-action="xunlei"]', (() => this.getDependency("DetailPageButtonPlugin").searchXunLeiSubtitle(context.carNum))); + hubButton.on(`click${context.namespace}`, (async () => { + if (!context.isAlive()) return; + const box = hubGroup.find('[data-jhs-role="magnet-hub-content"]'), expanded = "true" === hubButton.attr("aria-expanded"); + if (expanded) return hubGroup.addClass("is-collapsed"), void hubButton.attr("aria-expanded", "false").text("展开磁力搜索"); + magnetHubPromise || (magnetHubPromise = this.getDependency("MagnetHubPlugin").createMagnetHub(context.carNum, { root: context.root })); + const hub = await magnetHubPromise; + if (context.isAlive() && !box.children().length) box.append(hub); + if (context.isAlive()) hubGroup.removeClass("is-collapsed"), hubButton.attr("aria-expanded", "true").text("收起磁力搜索"), box[0]?.scrollIntoView?.({ block: "nearest" }); + })); + detailStateController.bind({ root: context.root, layerIndex: context.layerIndex ?? null, carNum: context.carNum, activityType: "fc2-state", getRecord: /* @__PURE__ */ __name(() => ({ carNum: context.carNum, url: context.url, fc2Source: context.source, names: context.root.find('[data-jhs-role="actress-data"]').text(), publishTime: context.root.find('[data-jhs-role="publish-time"]').text() }), "getRecord") }); + void this.getDependency("FilterTitleKeywordPlugin").bindDetailRoot(context.root, { layerIndex: context.layerIndex ?? null }); + void this.getDependency("OtherSitePlugin").loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: false, isActive: context.isAlive }).then(((box) => { + if (context.isAlive() && !box) sitesGroup.remove(); + })).catch(((error) => { + context.isAlive() && sitesGroup.remove(), clog.error("FC2 外部站点加载失败", error); + })); + void this.getDependency("ScreenShotPlugin").loadInto(screenshot, context.carNum.replace("FC2-", ""), { isActive: context.isAlive }).then(((result) => { + if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); + })); + "123av" === context.source ? void this.getDependency("Fc2By123AvPlugin").loadDetail(context, context.url) : void this.loadNativeDetail(context); + } + createResourceGroup(title, role) { + return $('

    ').find("h3").text(title).end().find("div").attr("data-jhs-role", role).end(); + } + setState(target, message, retry = null) { + const host = $(target).empty(), state = $('
    ').text(message); + retry && state.addClass("is-error").append(" ", $('').on("click", retry)), host.append(state); + } + async loadNativeDetail(context) { + const movieIdPromise = Promise.resolve(context.movieId); + this.configureJavDbWantButton(context, movieIdPromise), await Promise.allSettled([this.fetchAndRenderNativeDetail(context), this.fetchAndRenderNativeMagnets(context), this.mountPanels(context, movieIdPromise)]); + } + /** 绑定当前工作区自己的 JavDB“想看”操作。 */ + async configureJavDbWantButton(context, movieIdPromise) { + const button = context.root.find('[data-jhs-action="javdb-want"]'); + try { + const movieId = await movieIdPromise; + if (!context.isAlive()) return; + if (!movieId) return void button.prop("disabled", true).text("JavDB 暂无对应作品"); + button.prop("disabled", false).text("JavDB 想看").off(`click${context.namespace}`).on(`click${context.namespace}`, (() => void this.submitJavDbWant(context, movieId, button))); + } catch (error) { + context.isAlive() && button.prop("disabled", false).text("JavDB 关联失败,重试").off(`click${context.namespace}`).on(`click${context.namespace}`, (() => void this.configureJavDbWantButton(context, resolveJavDbMovieId(context.carNum)))), clog.error("FC2 JavDB 想看关联失败", error); + } + } + async submitJavDbWant(context, movieId, button) { + if (!context.isAlive() || button.data("jhsBusy") || "true" === button.attr("aria-pressed")) return; + button.data("jhsBusy", true).attr({ "aria-busy": "true", "aria-disabled": "true" }).text("正在加入想看…"); + try { + await markJavDbWantWatch(movieId); + if (!context.isAlive()) return; + button.attr({ "aria-pressed": "true", "aria-disabled": "false" }).text("已加入 JavDB 想看"), show.ok("已加入 JavDB 想看"); + } catch (error) { + if (!context.isAlive()) return; + if ("LOGIN_REQUIRED" === error?.code) { + button.attr("aria-disabled", "false").text("JavDB 想看"); + const loginPlugin = this.getDependency("TOP250Plugin"); + return loginPlugin?.openLoginDialog({ onSuccess: /* @__PURE__ */ __name(() => this.submitJavDbWant(context, movieId, button), "onSuccess") }); + } + button.attr("aria-disabled", "false").text("JavDB 想看"), show.error(error?.message || "加入 JavDB 想看失败"), clog.error("加入 JavDB 想看失败", error); + } finally { + context.isAlive() && button.removeData("jhsBusy").removeAttr("aria-busy"); + } + } + async fetchAndRenderNativeDetail(context) { + try { + const movie = await V(context.movieId); + if (!context.isAlive()) return; + this.renderSummary(context, movie), this.renderGallery(context, movie.imgList || []), await this.getDependency("TranslatePlugin").translate(movie.carNum || context.carNum, false, { root: context.root }); + } catch (error) { + context.isAlive() && this.setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); + } + } + renderSummary(context, movie) { + const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

    '); + title.find("strong").text(movie.title || "无标题"), body.append(title); + const meta = $('
    '); + [`番号:${movie.carNum || context.carNum}`, `发行:${movie.releaseDate || "未知"}`, `评分:${Number.isFinite(Number(movie.score)) ? movie.score : "无"}`, `时长:${Number.isFinite(Number(movie.duration)) ? movie.duration + " 分钟" : "无"}`].forEach(((value) => meta.append($("").text(value)))), body.append(meta); + const actors = $('
    主演:
    '), actressNames = []; + (movie.actors || []).forEach(((actor) => { + actors.append($("").addClass("jhs-fc2-actor").attr({ href: `/actors/${encodeURIComponent(actor.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(actor.name || "未知演员")), 0 === actor.gender && actressNames.push(actor.name); + })); + movie.actors?.length || actors.append($("").text("暂无演员信息")), body.append(actors, this.createSourceLinks(context), $('').text(actressNames.join(" ")), $('').text(movie.releaseDate || "")); + } + createSourceLinks(context) { + const id = String(context.carNum || "").replace(/^FC2-(?:PPV-)?/i, ""), links = $(''), values = [["123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeHttpUrl(context.url)], ["FC2PPVDB", `https://fc2ppvdb.com/articles/${encodeURIComponent(id)}`], ["FC2 市场", `https://adult.contents.fc2.com/article/${encodeURIComponent(id)}/`]]; + values.forEach((([label, href]) => href && links.append($("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label)))); + return links; + } + renderGallery(context, images) { + const urls = [...new Set((images || []).map(((url) => normalizeHttpUrl(url))).filter(Boolean))], grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); + if (!urls.length) return this.setState(grid, "暂无剧照"); + preview.append($("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); + urls.forEach(((url, index) => grid.append($('').attr("aria-label", `查看剧照 ${index + 1}`).append($("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" }))))); + } + async fetchAndRenderNativeMagnets(context, movieId = context.movieId) { + const host = context.root.find('[data-jhs-role="native-magnets"]'); + this.setState(host, "正在加载站内磁力…"); + try { + if (!movieId) return this.setState(host, "JavDB 暂无对应作品"); + const response = await gmHttp.get(`${U}/v1/movies/${movieId}/magnets`, null, { jdSignature: await O() }), magnets = response?.data?.magnets || []; + if (!context.isAlive()) return; + host.empty(); + if (!magnets.length) return this.setState(host, "暂无站内磁力"); + const highlighter = this.getDependency("HighlightMagnetPlugin"), assessments = []; + magnets.forEach(((item) => { + const hash = normalizeBtihHash(item.hash); + if (!hash) return; + const magnet = `magnet:?xt=urn:btih:${hash}`, assessment = highlighter.assessMagnet({ title: item.name, hasHdTag: !!item.hd, hasSubtitleTag: !!item.cnsub, date: item.created_at, seeders: Number(item.seeders) || 0 }), row = $('
    ').attr("data-jhs-high-quality", String(assessment.highQuality)), info = $('
    '), actions = $('
    '), tags = $('
    '); + assessments.push(assessment), tags.append($("").addClass("jhs-badge").attr("title", `磁力质量评分 ${assessment.score.total}`).text(`${assessment.grade} ${assessment.score.total}`)), item.hd && tags.append('高清'), item.cnsub && tags.append('字幕'); + info.append($("").attr("href", magnet).text(item.name || magnet), $("
    ").addClass("jhs-fc2-meta").text(`${(Number(item.size || 0) / 1024).toFixed(2)} GB · ${Number(item.files_count) || 0} 个文件${item.created_at ? ` · ${item.created_at}` : ""}`), tags), actions.append($('').attr("data-clipboard-text", magnet), $('').attr({ "data-resource": magnet, "data-jhs-offline-owner": "fc2" })), host.append(row.append(info, actions)); + })); + await this.bindNativeMagnetFilter(context, host, assessments.some(((item) => item.highQuality))); + } catch (error) { + context.isAlive() && this.setState(host, "站内磁力加载失败", (() => void this.fetchAndRenderNativeMagnets(context, movieId))), clog.error("FC2 磁力加载失败", error); + } + } + async bindNativeMagnetFilter(context, host, hasMatch) { + const section = context.getSection("resources"), actions = section.find(".jhs-fc2-section__actions"), old = actions.find('[data-jhs-action="filter-native-magnets"]'); + old.remove(); + const button = $(''), apply = /* @__PURE__ */ __name((enabled) => { + host.find(".jhs-fc2-magnet-item").show(); + enabled && hasMatch && host.find('.jhs-fc2-magnet-item[data-jhs-high-quality="false"]').hide(); + button.attr("aria-pressed", String(enabled && hasMatch)).text(hasMatch ? enabled ? "显示全部磁力" : "过滤低质量" : "暂无可过滤项").prop("disabled", !hasMatch); + }, "apply"); + actions.append(button), apply(await storageManager.getSetting("enableMagnetsFilter", _) === _), button.on(`click${context.namespace}`, (async () => { + const enabled = "true" !== button.attr("aria-pressed"); + apply(enabled), await storageManager.saveSettingItem("enableMagnetsFilter", enabled ? _ : C); + })); + } + async mountPanels(context, movieIdPromise) { + try { + const movieId = await movieIdPromise; + if (!context.isAlive()) return; + if (!movieId) return this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"), this.setState(context.getSlot("reviews"), "JavDB 暂无对应作品"), this.setState(context.getSlot("related"), "JavDB 暂无对应作品"); + this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); + await Promise.allSettled([this.getDependency("ReviewPlugin").showReview(movieId, context.getSlot("reviews"), { ownedSection: context.getSection("reviews"), isActive: context.isAlive }), this.getDependency("RelatedPlugin").showRelated(context.getSlot("related"), movieId, { ownedSection: context.getSection("related"), isActive: context.isAlive })]); + } catch (error) { + if (!context.isAlive()) return; + this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); + const retry = /* @__PURE__ */ __name(() => void this.mountPanels(context, resolveJavDbMovieId(context.carNum)), "retry"); + this.setState(context.getSlot("reviews"), "评论关联失败", retry), this.setState(context.getSlot("related"), "相关清单关联失败", retry), clog.error("FC2 JavDB 关联失败", error); + } + } + clearOwnedPanel(context, name) { + context.getSlot(name).empty(), context.getSection(name).find(".jhs-fc2-section__actions").empty(); + } + async resolveFc2Source(record = {}) { + if (["fc2", "123av"].includes(record.fc2Source)) return record.fc2Source; + try { + return new URL(record.url, window.location.origin).hostname === new URL(await this.getDependency("OtherSitePlugin").getAv123Url()).hostname ? "123av" : "fc2"; + } catch (error) { + return "fc2"; + } + } + async openFc2Page(movieId, carNum, url, navigation = { newTab: true }, { source = "fc2" } = {}) { + const baseUrl = await this.getDependency("OtherSitePlugin").getJavDbUrl(); + utils.openPage(`${baseUrl}/users/collection_codes?movieId=${movieId || ""}&carNum=${encodeURIComponent(carNum)}&url=${encodeURIComponent(url)}&source=${encodeURIComponent(source)}`, carNum, true, navigation); + } + }; + __name(_Fc2Plugin, "Fc2Plugin"); + var Fc2Plugin = _Fc2Plugin; -
    -
    - 视频标题屏蔽词: -
    - - -
    -
    -
    -
    -
    -
    -
    -

    缓存管理

    查看并清理非核心缓存数据。

    -

    以下操作不会影响核心数据

    -
    -
    - ${n2} -
    -
    -
    
    -                            
    -
    -
    + // src/plugins/external-search/hit-show.js + var _HitShowPlugin = class _HitShowPlugin extends BasePlugin { + constructor() { + super(), i(this, "$contentBox", $(".section .container")), i(this, "loadGeneration", 0); + } + getName() { + return "HitShowPlugin"; + } + async initCss() { + return ``; + } + async handle() { + $('a[href*="rankings/playback"]').on("click", ((e2) => { + e2.preventDefault(), e2.stopPropagation(), window.location.href = "/advanced_search?handlePlayback=1&period=daily"; + })), await this.handlePlayback(); + } + hookPage() { + let e2 = $("h2.section-title"); + e2.contents().first().replaceWith("热播"), e2.addClass("jhs-hitshow-title"), e2.parent(".jhs-hitshow-heading").length || e2.wrap('
    '), $(".empty-message").remove(), $(".section .container .box").remove(), $(".movie-list.jhs-hitshow-list").remove(), this.$contentBox.append('
    '); + } + async handlePlayback() { + if (!isHitShowPage()) return; + const period = new URLSearchParams(window.location.search).get("period"), generation = ++this.loadGeneration; + this.hookPage(), this.toolBar(period); + const loadingObj = loading(); + let loadingClosed = false; + try { + const movies = await this.fetchPlaybackWithRetry(period); + if (generation !== this.loadGeneration) return; + $(".movie-list").html(this.markDataListHtml(movies)); + await this.initializeRenderedList(); + await this.getDependency("ListPageButtonPlugin").sortItems(); + loadingObj.close(), loadingClosed = true; + void this.loadScore(movies, generation).then((async () => { + if (generation === this.loadGeneration && "rateCount" === localStorage.getItem("jhs_sortMethod")) await this.getDependency("ListPageButtonPlugin").sortItems(); + })).catch(((error) => clog.error("热播评分补全失败", error))); + } catch (error) { + clog.error("所有重试尝试均失败,无法获取数据。", error); + } finally { + loadingClosed || loadingObj.close(); + } + } + async fetchPlaybackWithRetry(period) { + let lastError; + for (let attempt = 1; attempt <= 3; attempt++) try { + return await W(period); + } catch (error) { + lastError = error; + if (attempt < 3) clog.error(`获取热播数据失败 (第 ${attempt} 次重试)`, error), await new Promise(((resolve) => setTimeout(resolve, 1e3))); + } + throw lastError; + } + async initializeRenderedList() { + const listPage = this.getDependency("ListPagePlugin"); + listPage.replaceHdImg(), await listPage.doFilter(), listPage.applyVisibility(), listPage.bindMovieDetailNavigation(listPage.getSelector().boxSelector); + this.getDependency("CoverButtonPlugin").addSvgBtn(); + } + toolBar(e2) { + $("#jhs-hitshow-period").remove(); + let t2 = ` + + `; + $(".jhs-hitshow-heading").append(t2); + } + getStarRating(e2) { + let t2 = ""; + const n2 = Math.floor(e2); + for (let a2 = 0; a2 < n2; a2++) t2 += ''; + for (let a2 = 0; a2 < 5 - n2; a2++) t2 += ''; + return t2; + } + async loadScore(movies, generation = this.loadGeneration) { + if (0 === movies.length) return; + const cacheKey = "jhs_score_info"; + let cache = {}; + try { + cache = JSON.parse(localStorage.getItem(cacheKey) || "{}"); + } catch (error) { + clog.warn("评分缓存解析失败,将重新建立缓存", error); + } + const queue = [...movies], workers = Array.from({ length: Math.min(4, queue.length) }, (() => this.scoreWorker(queue, cache, generation))); + await Promise.all(workers), localStorage.setItem(cacheKey, JSON.stringify(cache)); + } + async scoreWorker(queue, cache, generation) { + for (; ; ) { + const movie = queue.shift(); + if (!movie) return; + try { + if (generation !== this.loadGeneration) return; + const id = movie.id; + if (!$(`#score_${id}`).length || $(`#${id}`).is(":hidden")) continue; + if (cache[id]) { + const cached = this.normalizeScoreData(cache[id]); + this.appendScore(id, cached.score, cached.watchedCount); + continue; + } + const result = await V(id); + if (generation !== this.loadGeneration) return; + const score = Number(result.score), watchedCount = Number(result.watchedCount); + this.appendScore(id, score, watchedCount), cache[id] = { score: Number.isFinite(score) ? score : 0, watchedCount: Number.isFinite(watchedCount) ? watchedCount : 0 }; + } catch (error) { + $(`#${movie.id}`).attr("data-jhs-rate-count", "0"), clog.error(`解析评分数据失败 | 编号: ${movie.number} +`, `错误详情: ${error.message} +`, error.stack ? `调用栈: +${error.stack}` : ""); + } + } + } + normalizeScoreData(value) { + const html = "string" == typeof value ? value : String(value?.html || ""), score = Number(value?.score ?? (html.match(/([\d.]+)分/) || [0, 0])[1]), watchedCount = Number(value?.watchedCount ?? (html.match(/由(\d+)人/) || [0, 0])[1]); + return { score: Number.isFinite(score) ? score : 0, watchedCount: Number.isFinite(watchedCount) ? watchedCount : 0 }; + } + appendScore(e2, score, watchedCount = 0) { + const safeScore = Math.min(5, Math.max(0, Number(score) || 0)), safeCount = Math.max(0, Number(watchedCount) || 0), card = $(`#${e2}`), target = $(`#score_${e2}`); + card.attr("data-jhs-rate-count", String(safeCount)); + if (!target.length || "" !== target.text().trim()) return; + const value = $(''), stars = $('').html(this.getStarRating(safeScore)); + value.append(stars, document.createTextNode(` ${safeScore}分,由${safeCount}人评价`)), target.hide().empty().append(value).slideDown(500); + } + markDataListHtml(e2) { + let t2 = ""; + return e2.forEach(((e3, index) => { + const coverUrl = normalizeHttpUrl(String(e3.cover_url || "").replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); + t2 += ` + - - `; - } - __name(buildSettingDialogHtml, "buildSettingDialogHtml"); - function injectHealthPanel() { - const e2 = $(".side-menu-item").parent(); - e2.length && !e2.find('[data-panel="health-panel"]').length && e2.append(''); - const t2 = $(".content-panel").parent(); - t2.length && !$("#health-panel").length && t2.append(` -
    -

    数据体检

    检查本地数据并在备份后修复异常。

    -
    - - -
    -
    点击重新体检查看结果
    -
    -
    - `); - } - __name(injectHealthPanel, "injectHealthPanel"); - function injectPluginMgmtPanel() { - const e2 = $(".side-menu-item").parent(); - e2.length && !e2.find('[data-panel="plugin-mgmt-panel"]').length && e2.append(''); - const t2 = $(".content-panel").parent(); - if (!t2.length || $("#plugin-mgmt-panel").length) return; - const i2 = `
    -

    插件管理

    按功能查看插件状态、耗时和错误记录。

    -
    -
    0总插件数
    -
    0已启用
    -
    0已禁用
    -
    -

    禁用插件后需刷新页面生效。核心插件不可禁用。

    -
    -
    诊断信息
    -

    插件执行耗时

    页面加载时各插件 handle() 的执行时间。

    -

    错误日志

    无错误记录
    -

    缓存命中率

    -
    -
    `; - t2.append(i2); - } - __name(injectPluginMgmtPanel, "injectPluginMgmtPanel"); - function injectSnapshotPanel() { - const e2 = $(".side-menu-item").parent(); - e2.length && !e2.find('[data-panel="snapshot-panel"]').length && e2.append(''); - const t2 = $(".content-panel").parent(); - if (!t2.length || $("#snapshot-panel").length) return; - const n2 = '

    恢复点

    创建、下载或恢复本地数据快照。

    快照保存当前全部数据状态,可用于恢复。最多保留 10 个,超出自动清理最旧的。

    '; - t2.append(n2); - } - __name(injectSnapshotPanel, "injectSnapshotPanel"); - function injectNetworkPanel() { - const e2 = $(".side-menu-item").parent(); - e2.length && !e2.find('[data-panel="network-panel"]').length && e2.append(''); - const t2 = $(".content-panel").parent(); - if (!t2.length || $("#network-panel").length) return; - const n2 = `
    -

    外部请求

    配置熔断规则并查看站点健康状态。

    -

    熔断器配置

    连续请求失败达到阈值后,自动停止对该站点的请求,避免拖慢整体体验。

    -
    熔断阈值(次):
    -
    冷却时间(秒):
    -
    -

    站点健康状态

    各外部站点的熔断状态和请求统计。

    -

    域名使用统计

    脚本实际请求过的域名及次数。

    -
    `; - t2.append(n2); - } - __name(injectNetworkPanel, "injectNetworkPanel"); - function injectResourceSourcesPanel() { - if ($("#resource-sources-panel").length) return; - $(".content-panel").last().after(`
    -

    磁力来源

    聚合多个来源搜索磁力结果,优先级数字越小越靠前。

    自定义来源

    -

    磁力规则

    使用可视化规则为结果添加标签或过滤低质量内容。

    标签规则

    过滤规则

    -

    截图来源

    自动选择会按优先级依次尝试可用来源。

    -
    高级 · 导入 / 导出配置

    高级功能:错误修改可能导致自定义来源不可用,保存前会校验配置。

    -
    -

    统一离线服务

    115 状态:未知

    服务不可用时会在提交前显示原因。
    -

    番号列表导入

    支持换行、空格、逗号分隔番号。必须先解析预览,再确认导入。

    `); - const sidebar = $(".jhs-mobile-sidebar,.setting-sidebar").first(); - sidebar.append(''); - } - __name(injectResourceSourcesPanel, "injectResourceSourcesPanel"); - function buildQuickSettingHtml() { - const rows = [ - ["鉴定后立即关闭", "needClosePage", "完成鉴定后关闭当前详情窗口。"], - ["瀑布流", "autoPage", "连续加载列表;启用后普通列表只支持默认排序。"], - ["标题翻译", "translateTitle", "翻译列表和详情页标题。"], - ["悬浮大图", "hoverBigImg", "鼠标悬停封面时显示大图。"], - ["外部站点", "enableLoadOtherSite", "在详情页提供第三方站点入口。"], - ["长缩略图", "enableLoadScreenShot", "在详情页图片区加载长缩略图。"], - ["高画质预览", "enableLoadPreviewVideo", "解析更高画质的预览视频。"] - ]; - return ` -
    `; - } - __name(buildQuickSettingHtml, "buildQuickSettingHtml"); - async function renderNetworkPanel() { - const e2 = gmHttp.getCircuitBreakerStatus(), t2 = gmHttp.getDomainStats(), n2 = await storageManager.getSetting("circuitBreakerThreshold", 3), a2 = await storageManager.getSetting("circuitBreakerCooldown", 6e4); - $("#circuitBreakerThreshold").val(n2), $("#circuitBreakerCooldownSec").val(Math.round(a2 / 1e3)); - const i2 = Object.entries(e2); - if (i2.length) { - let t3 = '
    ${o2}${b2}
    ${D2}${A2}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    '; - for (const [n3, a3] of i2) { - const i3 = "open" === a3.state ? "熔断" : "half-open" === a3.state ? "半开" : "正常", s3 = "open" === a3.state ? `剩余${Math.ceil((a3.cooldownMs - (Date.now() - a3.openTime)) / 1e3)}秒` : ""; - t3 += ``; + `; + })), t2; + } + }; + __name(_HitShowPlugin, "HitShowPlugin"); + var HitShowPlugin = _HitShowPlugin; + + // src/plugins/external-search/javtrailers.js + var _JavTrailersPlugin = class _JavTrailersPlugin extends BasePlugin { + getName() { + return "JavTrailersPlugin"; + } + constructor() { + super(), this.hasBand = false; + } + handle() { + let e2 = window.location.href; + if (!e2.includes("handle=1")) return; + if ($("h1:contains('Page not found')").length) { + clog.log("番号无法匹配, 跳搜索"); + let t3 = e2.split("?")[0].split("video/")[1].toLowerCase().replace("00", "-"); + return void (window.location.href = "/search/" + encodeURIComponent(t3) + window.location.search); } - t3 += "
    域名状态失败次数操作
    ${escapeHtml(n3)}${i3} ${s3}${Number(a3.failCount) || 0}
    ", $("#site-health-table").html(t3); - } else $("#site-health-table").html('

    暂无熔断记录

    '); - const s2 = Object.entries(t2).sort(((e3, t3) => t3[1].count - e3[1].count)); - if (s2.length) { - let e3 = ''; - for (const [t3, n3] of s2) { - const a3 = n3.lastUsed ? new Date(n3.lastUsed).toLocaleTimeString() : "-"; - e3 += ``; + let t2 = $(".videos-list .video-link").toArray(); + if (t2.length) { + const n2 = e2.split("?")[0].split("search/")[1].toLowerCase(), a2 = t2.find(((e3) => $(e3).find(".vid-title").text().toLowerCase().includes(n2))); + if (a2) return void (window.location.href = $(a2).attr("href") + window.location.search); } - e3 += "
    域名请求数错误数最后使用
    ${escapeHtml(t3)}${n3.count}${n3.errors}${a3}
    ", e3 += `

    共 ${s2.length} 个域名

    `, $("#domain-stats-table").html(e3); - } else $("#domain-stats-table").html('

    暂无统计数据

    '); - $(".reset-breaker").off("click").on("click", ((e3) => { - const t3 = $(e3.target).data("domain"); - gmHttp.resetCircuitBreaker(t3), show.ok(`已重置 ${t3} 的熔断状态`), renderNetworkPanel(); - })), $("#resetAllBreakersBtn").off("click").on("click", (() => { - gmHttp.resetAllCircuitBreakers(), show.ok("已重置全部熔断状态"), renderNetworkPanel(); - })), $("#clearDomainStatsBtn").off("click").on("click", (() => { - gmHttp.clearDomainStats(), show.ok("已清空域名统计"), renderNetworkPanel(); - })); + this.handlePlayJavTrailers(), $("#videoPlayerContainer").on("click", (() => { + this.handlePlayJavTrailers(); + })), window.addEventListener("message", ((e3) => { + let t3 = document.getElementById("vjs_video_3_html5_api"); + t3 && (t3.currentTime += 5); + })); + } + handlePlayJavTrailers() { + this.hasBand || (utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { + setTimeout((() => { + this.hasBand = true; + let e2 = document.getElementById("vjs_video_3_html5_api"); + clog.debug(e2), safePlay(e2, { + context: "JavTrailers 预览" + }), e2.currentTime = 5, e2.addEventListener("timeupdate", (function() { + e2.currentTime >= 14 && e2.currentTime < 16 && (e2.currentTime += 2); + })), $("#vjs_video_3_html5_api").css({ + position: "fixed", + width: "100vw", + height: "100vh", + objectFit: "cover", + zIndex: String(JHS_Z_INDEX.debug) + }), $(".vjs-control-bar").css({ + position: "fixed", + bottom: "20px", + zIndex: String(JHS_Z_INDEX.debug) + }); + }), 100); + })), utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { + 0 !== $("#vjs_video_3 canvas").length && $("#vjs_video_3 canvas").css({ + position: "fixed", + width: "100vw", + height: "100vh", + objectFit: "cover", + top: "0", + right: "0", + zIndex: String(JHS_Z_INDEX.debug - 1) + }); + }))); + } + }; + __name(_JavTrailersPlugin, "JavTrailersPlugin"); + var JavTrailersPlugin = _JavTrailersPlugin; + + // src/plugins/external-search/magnet-hub.js + function calcMagnetScore(e2) { + let t2 = 0; + const n2 = e2.seeders || 0; + const seedersScore = n2 >= 50 ? 35 : n2 >= 10 ? 25 : n2 >= 1 ? 15 : 3; + t2 += seedersScore; + const a2 = (e2.title || "").toLowerCase(), resolution = String(e2.resolution || "").toLowerCase(); + const resolutionScore = /4k|2160p/.test(resolution) || /4k|2160p/.test(a2) ? 25 : /1080p/.test(resolution) || /1080p/.test(a2) ? 20 : /720p/.test(resolution) || /720p/.test(a2) ? 15 : 5; + t2 += resolutionScore; + const subtitleScore = e2.hasSubtitle || /-c\b|-uc\b|chinese|中字|字幕/.test(a2) ? 20 : 0; + t2 += subtitleScore; + const i2 = e2.date ? _daysSince(e2.date) : 999; + const freshnessScore = i2 <= 7 ? 15 : i2 <= 30 ? 12 : i2 <= 90 ? 8 : 3; + t2 += freshnessScore; + const completenessScore = /sample|预告|trailer/.test(a2) ? -15 : 0; + t2 += completenessScore; + return { total: Math.max(0, Math.min(100, t2)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; + } + __name(calcMagnetScore, "calcMagnetScore"); + function _daysSince(e2) { + try { + const t2 = new Date(e2); + if (isNaN(t2.getTime())) return 999; + return Math.max(0, Math.floor((Date.now() - t2.getTime()) / 864e5)); + } catch (t2) { + return 999; + } } - __name(renderNetworkPanel, "renderNetworkPanel"); - async function renderSnapshotPanel() { - const e2 = await storageManager.getSnapshotList(), t2 = { - "manual": "手动创建", - "auto-import": "导入前自动", - "auto-repair": "修复前自动", - "auto-restore": "恢复前自动" - }; - if (0 === e2.length) return void $("#snapshot-list").html('
    暂无快照,点击上方按钮创建
    '); - $("#snapshot-list").find(".tabulator").length && $("#snapshot-list").empty(); - const n2 = new Tabulator("#snapshot-list", { - layout: "fitColumns", - placeholder: "暂无数据", - data: e2, - columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, - columns: [ - { title: "名称", field: "name", width: 200, headerSort: false }, - { title: "来源", field: "source", width: 100, headerSort: false, formatter: /* @__PURE__ */ __name((e3) => t2[e3.getValue()] || e3.getValue(), "formatter") }, - { title: "时间", field: "time", width: 170, headerSort: false }, - { title: "数据量", field: "itemCount", width: 80, headerSort: false }, + __name(_daysSince, "_daysSince"); + var _MagnetHubPlugin = class _MagnetHubPlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "sourceRegistry", new MagnetSourceRegistry()), i(this, "searchEngines", []); + } + async initializeSources() { + const settings = new ResourceSettingsService(), overrides = await settings.getBuiltInSources(), custom = await settings.getMagnetSources(); + const configured = /* @__PURE__ */ __name((id) => ({ ...BUILT_IN_MAGNET_SOURCES.find(((source) => source.id === id)) || {}, ...overrides.find(((source) => source.id === id)) || {} }), "configured"); + const baseUrl = /* @__PURE__ */ __name((id, fallback) => String(configured(id).baseUrl || fallback).replace(/\/$/, ""), "baseUrl"); + this.sourceRegistry = new MagnetSourceRegistry([ { - title: "操作", - minWidth: 220, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, t3, a2) => { - const i2 = e3.getData(); - return a2((() => { - const t4 = e3.getElement().querySelector(".snap-restore"), a3 = e3.getElement().querySelector(".snap-download"), s2 = e3.getElement().querySelector(".snap-delete"); - t4 && t4.addEventListener("click", (async (e4) => { - utils.q(e4, `恢复到快照「${escapeHtml(i2.name)}」? 当前数据会自动备份。`, (async () => { - let e5 = loading(); - try { - await storageManager.restoreSnapshot(i2.id), show.ok("恢复成功, 页面将刷新"), setTimeout(() => location.reload(), 1e3); - } catch (t5) { - clog.error(t5), show.error("恢复失败: " + t5.message); - } finally { - e5.close(); - } - })); - })), a3 && a3.addEventListener("click", (async (e4) => { - let t5 = loading(); - try { - const e5 = await storageManager.getSnapshot(i2.id); - if (!e5) throw new Error("快照不存在"); - utils.download(JSON.stringify(e5.data), `snapshot_${escapeHtml(i2.name)}.json`), show.ok("下载成功"); - } catch (n3) { - show.error("下载失败: " + n3.message); - } finally { - t5.close(); - } - })), s2 && s2.addEventListener("click", (async (e4) => { - utils.q(e4, `删除快照「${escapeHtml(i2.name)}」?`, (async () => { - try { - await storageManager.deleteSnapshot(i2.id), show.ok("已删除"), renderSnapshotPanel(); - } catch (t5) { - clog.error(t5), show.error("删除失败: " + t5.message); - } - })); - })); - })), ' '; - }, "formatter") - } - ], - locale: "zh-cn" - }); - } - __name(renderSnapshotPanel, "renderSnapshotPanel"); - function showDiffPreview(e2, t2, n2 = null) { - const a2 = e2.summary, i2 = []; - for (const [s3, o2] of Object.entries(e2.stores)) { - if ("unchanged" === o2.status) continue; - const e3 = { store: s3, status: o2.status, oldCount: o2.oldCount, newCount: o2.newCount, added: o2.added.length, removed: o2.removed.length, modified: o2.modified.length }; - i2.push(e3); + name: "JavDB 本站", + id: "native-javdb", + applicable: r, + enabled: r, + priority: 1, + search: /* @__PURE__ */ __name(async (keyword, root = document) => parseNativeMagnets(root?.jquery ? root[0] : root, "javdb"), "search"), + targetUrl: /* @__PURE__ */ __name(() => window.location.href, "targetUrl") + }, + { + name: "JavBus 本站", + id: "native-javbus", + applicable: l, + enabled: l, + priority: 2, + search: /* @__PURE__ */ __name(async (keyword, root = document) => parseNativeMagnets(root?.jquery ? root[0] : root, "javbus"), "search"), + targetUrl: /* @__PURE__ */ __name(() => window.location.href, "targetUrl") + }, + { + name: "U9A9", + id: "u9a9", + url: "https://u9a9.com/?type=2&search={keyword}", + targetPage: "https://u9a9.com/?type=2&search={keyword}", + priority: 10, + search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("u9a9", `${baseUrl("u9a9", "https://u9a9.com")}/?type=2&search={keyword}`, keyword), "search"), + targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("u9a9", "https://u9a9.com")}/?type=2&search=${encodeURIComponent(keyword)}`, "targetUrl") + }, + { + name: "U3C3", + id: "u3c3", + url: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", + targetPage: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", + priority: 20, + search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("u3c3", `${baseUrl("u3c3", "https://u3c3.com")}/?search2=a8lr16lo&search={keyword}`, keyword), "search"), + targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("u3c3", "https://u3c3.com")}/?search2=a8lr16lo&search=${encodeURIComponent(keyword)}`, "targetUrl") + }, + { + name: "Sukebei", + id: "sukebei", + url: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", + targetPage: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", + priority: 30, + search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("sukebei", `${baseUrl("sukebei", "https://sukebei.nyaa.si")}/?f=0&c=0_0&q={keyword}`, keyword), "search"), + targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("sukebei", "https://sukebei.nyaa.si")}/?f=0&c=0_0&q=${encodeURIComponent(keyword)}`, "targetUrl") + }, + { name: "BTSOW", id: "btsow", priority: 40, search: /* @__PURE__ */ __name((keyword) => this.searchBtsow(keyword, baseUrl("btsow", "https://btsow.lol")), "search"), targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("btsow", "https://btsow.lol")}/search/${encodeURIComponent(keyword)}`, "targetUrl") } + ].map(((source) => { + const config = configured(source.id), applicable = source.applicable ?? true; + return { ...source, ...config, enabled: applicable && (config.enabled ?? source.enabled ?? true), search: source.search, targetUrl: source.targetUrl }; + }))); + custom.filter(((source) => source.enabled)).forEach(((config) => this.sourceRegistry.register({ ...config, id: `custom:${config.id}`, search: /* @__PURE__ */ __name((keyword) => this.searchCustomSource(config, keyword), "search"), targetUrl: /* @__PURE__ */ __name((keyword) => config.targetUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)), "targetUrl") }))); + const enabled = this.sourceRegistry.getEnabledSources().map(((source) => ({ ...source, targetPage: source.targetUrl("{keyword}").replace("%7Bkeyword%7D", "{keyword}") }))); + this.searchEngines = enabled.length ? [{ id: "all", name: "全部", priority: 0, targetPage: "#", search: /* @__PURE__ */ __name((keyword) => this.searchAllSources(enabled, keyword), "search") }, ...enabled] : []; } - let s2 = '
    '; - s2 += '
    '; - s2 += `
    ${a2.added}新增数据源
    `; - s2 += `
    ${a2.removed}缺失数据源
    `; - s2 += `
    ${a2.modified}有变更
    `; - s2 += `
    ${a2.unchanged}无变化
    `; - s2 += "
    "; - if (i2.length > 0) { - s2 += '
    '; - const o2 = { added: "新增", removed: "缺失", modified: "变更", unchanged: "无变化" }; - for (const r3 of i2) s2 += ``; - s2 += "
    数据源状态当前导入新增删除修改
    ${escapeHtml(r3.store)}${o2[r3.status] || r3.status}${r3.oldCount}${r3.newCount}${r3.added || "-"}${r3.removed || "-"}${r3.modified || "-"}
    "; - } else { - s2 += '
    数据完全一致,无需导入
    '; + getName() { + return "MagnetHubPlugin"; } - s2 += '
    导入将覆盖当前数据,建议先创建快照备份
    '; - s2 += "
    "; - const r2 = layer.open({ - type: 1, - title: "数据差异预览", - content: s2, - area: utils.getResponsiveArea(["700px", "auto"]), - btn: ["确认导入", "取消"], - anim: -1, - yes: /* @__PURE__ */ __name(async (s3) => { - layer.close(s3); - let o2 = loading(); + async initCss() { + return ``; + } + async createMagnetHub(e2, options = {}) { + await this.initializeSources(); + e2 = e2.replace("FC2-", ""); + const root = options.root ? $(options.root) : $(document), engines = [...this.searchEngines]; + const t2 = $('
    '), n2 = $('
    '), a2 = "jhs_magnetHub_selectedEngine", i2 = localStorage.getItem(a2); + const o2 = $('
    '); + let currentEngine = engines.find(((engine) => engine.id === i2)) || engines[0] || null; + if (!currentEngine) return t2.append($('
    ').text("暂无可用磁力来源,请前往设置启用来源")); + engines.forEach(((engine) => o2.append($('').attr("data-engine", engine.id).text(engine.name).toggleClass("active", engine.id === currentEngine.id)))); + const target = $('原网页').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id); + n2.append(o2), n2.append(target), o2.find(".magnet-tab.active").attr({ "aria-selected": "true", tabindex: "0" }), t2.append(n2); + const r2 = $('
    '); + return t2.append(r2), t2.on("click", ".magnet-tab", ((n3) => { + const i3 = $(n3.target).data("engine"); + currentEngine = engines.find(((e3) => e3.id === i3)), t2.find('[data-jhs-role="magnet-target"]').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id), localStorage.setItem(a2, i3), t2.find(".magnet-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }), $(n3.target).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }), this.searchEngine(r2, currentEngine, e2, root); + })), t2.on("keydown", ".magnet-tab", ((e3) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e3.key)) return; + e3.preventDefault(); + const n3 = t2.find(".magnet-tab"), a3 = n3.index(e3.currentTarget); + let i3 = "Home" === e3.key ? 0 : "End" === e3.key ? n3.length - 1 : "ArrowRight" === e3.key ? (a3 + 1) % n3.length : (a3 - 1 + n3.length) % n3.length; + n3.eq(i3).trigger("click").trigger("focus"); + })), this.searchEngine(r2, currentEngine, e2, root), t2; + } + async searchEngine(e2, t2, n2, root = $(document)) { + e2.html(`
    正在从 ${escapeHtml(t2.name)} 搜索 "${escapeHtml(n2)}"...
    `); + const a2 = `${t2.name}_${n2}`; + if (t2.search) try { + return void this.displayResults(e2, await this.applyRuntimeRules(deduplicateMagnetResults(await t2.search(n2, root))), t2.name); + } catch (error) { + clog.error(`磁力源 ${t2.name} 请求失败`, error); + return void e2.html(`
    ${escapeHtml(t2.name)} 请求失败
    `); + } + if (t2.parseHtml) try { + const i2 = t2.url.replace("{keyword}", encodeURIComponent(n2)), s2 = await storageManager.cachedRequest(`magnet:${t2.id}:${n2}`, 216e5, (() => gmHttp.get(i2).then(((e3) => t2.parseHtml.call(this, e3, n2))))); + return void this.displayResults(e2, s2, t2.name); + } catch (s2) { + return void e2.html(`
    解析 ${escapeHtml(t2.name)} 结果失败: ${escapeHtml(s2.message)}
    `); + } + t2.parseJson && await t2.parseJson.call(this, e2, t2, n2, a2); + } + async searchTorrentSource(source, template, keyword) { + const url = template.replace("{keyword}", encodeURIComponent(keyword)); + return storageManager.cachedRequest(`magnet:${source}:${keyword}`, CACHE_TTL.magnet, (async () => { + const html = await gmHttp.get(url); + return this.parseTorrentList(html, keyword).map(((item) => ({ ...item, source, files: [] }))); + })); + } + async searchCustomSources(keyword) { + const configs = JSON.parse(await storageManager.getSetting("customMagnetSources", "[]")); + const enabled = configs.filter(((config) => config.enabled)).map(validateCustomMagnetSource); + const groups = await mapLimit(enabled, 4, (async (config) => { + const url = config.searchUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)); try { - await storageManager.createSnapshot("导入前自动备份", "auto-import"), n2 ? (await storageManager.importData(n2), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)) : t2 && (await storageManager.importData(t2), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)); - } catch (r3) { - clog.error(r3), show.error("导入失败: " + r3.message); - } finally { - o2.close(); - } - }, "yes") - }); - } - __name(showDiffPreview, "showDiffPreview"); - async function renderPluginMgmtPanel() { - const disabled = JSON.parse(await storageManager.getSetting("disabledPlugins", "[]")); - const allNames = unsafeWindow.pluginManager.getPluginNames(); - const { categories, corePlugins, pluginMeta } = getPluginCategories(); - const registeredSet = new Set(allNames); - let html = ""; - for (const [catKey, cat] of Object.entries(categories)) { - const visiblePlugins = cat.plugins.filter((p2) => registeredSet.has(p2)); - if (!visiblePlugins.length) continue; - html += '
    '; - html += `

    ${escapeHtml(cat.label)}

    `; - for (const pName of visiblePlugins) { - const isCore = corePlugins.includes(pName); - const isDisabled = disabled.includes(pName); - const productName = pluginMeta[pName]?.[0] || pName; - html += '
    '; - html += `${escapeHtml(productName)}`; - if (isCore) { - html += '核心'; - } else { - html += ``; + const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url))); + const parsed = "json" === config.parserType && "string" === typeof payload ? JSON.parse(payload) : payload; + return parseCustomMagnetResponse(config, parsed, config.id); + } catch (cause) { + clog.error(`自定义磁力源 ${config.name} 失败`, new ProviderError(config.id, cause._cfBlocked ? "CF_BLOCKED" : "HTTP_ERROR", cause.message, { cause, url, status: cause.status })); + return []; } - html += `
    `; - } - html += `
    `; + })); + return deduplicateMagnetResults(groups.flat()); } - $("#plugin-mgmt-list").html(html); - const enabledCount = allNames.length - disabled.length; - $("#pm-total").text(allNames.length); - $("#pm-enabled").text(enabledCount); - $("#pm-disabled").text(disabled.length); - $(".pm-toggle").off("change").on("change", async (e2) => { - const name = $(e2.target).data("plugin"); - let list = JSON.parse(await storageManager.getSetting("disabledPlugins", "[]")); - if ($(e2.target).is(":checked")) { - list = list.filter((x) => x !== name); - } else { - if (!list.includes(name)) list.push(name); - } - await storageManager.saveSettingItem("disabledPlugins", JSON.stringify(list)); - const all = unsafeWindow.pluginManager.getPluginNames(); - $("#pm-total").text(all.length); - $("#pm-enabled").text(all.length - list.length); - $("#pm-disabled").text(list.length); - show.ok(`插件 "${name}" 已${$(e2.target).is(":checked") ? "启用" : "禁用"},刷新后生效`); - }); - const startup = unsafeWindow.pluginManager.getStartupReport?.(), timings = unsafeWindow.pluginManager.getTimings(); - const formatMs = /* @__PURE__ */ __name((value) => Number.isFinite(value) ? value.toFixed(1) : "0.0", "formatMs"); - let startupHtml = startup ? `
    就绪: ${formatMs(startup.readyMs)} ms注册: ${formatMs(startup.registrationMs)} ms样式: ${formatMs(startup.cssMs)} ms即时插件: ${formatMs(startup.immediateMs)} ms空闲任务: ${startup.idleCompleted}/${startup.idleCompleted + startup.idlePending}

    就绪耗时不包含 @require 下载及浏览器脚本解析时间。

    ` : ""; - if (timings.length) { - const sorted = [...timings].sort((a2, b2) => b2.elapsed - a2.elapsed); - let tHtml = ''; - for (const t2 of sorted) { - const stateClass = t2.status === "disabled" ? "is-muted" : t2.elapsed > 500 ? "is-slow" : t2.elapsed > 200 ? "is-warning" : ""; - const statusText = t2.status === "disabled" ? "已禁用" : t2.status === "error" ? "错误" : t2.status === "pending-idle" ? "等待空闲" : t2.status === "skipped-mobile" ? "移动端跳过" : "正常"; - tHtml += ``; + async searchCustomSource(config, keyword) { + const url = config.searchUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)); + const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url))); + return parseCustomMagnetResponse(config, "json" === config.parserType && "string" === typeof payload ? JSON.parse(payload) : payload, config.id); + } + async searchAllSources(sources, keyword) { + const groups = await mapLimit(sources, 3, (async (source) => { + try { + return await source.search(keyword); + } catch (error) { + clog.warn(`磁力源 ${source.name} 聚合失败`, error); + return []; + } + })); + return deduplicateMagnetResults(groups.flat()); + } + async searchBtsow(keyword, baseUrl = "https://btsow.lol") { + const payload = await storageManager.cachedRequest(`magnet:btsow:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.gmRequest("POST", `${baseUrl}/search`, JSON.stringify([{ search: keyword }, 50, 1]), {}, { "Content-Type": "application/json" }))); + const value = "string" === typeof payload ? JSON.parse(payload) : payload; + return (value?.data || []).map(((item) => normalizeMagnetResult({ title: item.name, magnet: `magnet:?xt=urn:btih:${item.hash}`, size: `${(Number(item.size) / 1073741824).toFixed(2)} GB`, date: utils.formatDate(new Date(1e3 * item.lastUpdateTime)) }, "btsow"))).filter(Boolean); + } + async applyRuntimeRules(results) { + const service = new ResourceSettingsService(), [tags, filters] = await Promise.all([service.getMagnetTagRules(), service.getMagnetFilterRules()]); + return results.map(((result) => applyMagnetRules(result, tags, filters.filter(((rule) => (rule.target || "title") === "title")), filters.filter(((rule) => rule.target === "file"))))).filter(((result) => !result.hidden)); + } + async displayResults(e2, t2, n2) { + function a2(e3) { + const t3 = e3.text(); + e3.addClass("copied").text("已复制"), setTimeout((() => { + e3.removeClass("copied").text(t3); + }), 2e3); } - tHtml += "
    插件阶段耗时(ms)状态
    ${escapeHtml(t2.name)}${t2.startupMode === "idle" ? "空闲" : "即时"}${t2.elapsed.toFixed(1)}${statusText}
    "; - $("#plugin-timing-table").html(startupHtml + tHtml); - } else { - $("#plugin-timing-table").html(startupHtml + '

    暂无数据,刷新页面后自动采集。

    '); + __name(a2, "a"); + e2.empty(), 0 !== t2.length ? (t2.forEach(((e3) => { + const base = this.calcMagnetScore(e3); + e3._score = { ...base, total: Math.max(0, Math.min(100, base.total + (e3.customTagWeight || 0) + (e3.filterPenalty || 0))) }; + })), t2.sort(((e3, t3) => t3._score.total - e3._score.total)), t2.forEach(((t3) => { + const n3 = t3._score ? t3._score.total : 0, a3 = n3 >= 80 ? "高" : n3 >= 60 ? "中" : "低", i2 = t3._score ? `做种:${t3._score.seeders}/35 分辨率:${t3._score.resolution}/25 字幕:${t3._score.subtitle}/20 新鲜度:${t3._score.freshness}/15 完整性:${t3._score.completeness}/5` : ""; + const safeTitle = escapeHtml(t3.title), safeMagnet = escapeHtml(t3.magnet), safeSize = escapeHtml(String(t3.size || "未知")), safeDate = escapeHtml(String(t3.date || "未知")); + const item = $(` +
    +
    + ${a3} ${n3} + ${safeTitle} +
    +
    + 大小: ${safeSize} + 做种: ${t3.seeders || "—"} + 日期: ${safeDate} +
    +
    + +
    +
    + `); + t3.tags?.length && item.find(".magnet-info").after($("
    ").addClass("magnet-tags").append(t3.tags.map(((tag) => $("").addClass("jhs-badge").text(tag))))); + const copyBox = item.find(".magnet-copy"); + item.find(".copy-btn").removeClass("magnet-hub-btn").addClass("jhs-btn--secondary"); + copyBox.append(``); + item.appendTo(e2); + })), e2.on("click", ".copy-btn", (async function() { + const e3 = $(this), t3 = e3.data("magnet"); + await utils.copyToClipboard("磁力链接", t3) && a2(e3); + }))) : e2.append('
    没有找到相关结果
    '); + } + parseTorrentList(e2, t2) { + const n2 = utils.htmlTo$dom(e2), a2 = []; + return n2.find(".torrent-list tbody tr").each(((e3, n3) => { + const i2 = $(n3); + if (i2.text().includes("置顶")) return; + const s2 = i2.find("td:nth-child(2) a").attr("title") || i2.find("td:nth-child(2) a").text().trim(); + if (!s2.toLowerCase().includes(t2.toLowerCase())) return; + const o2 = i2.find("td:nth-child(3) a[href^='magnet:']").attr("href"), r2 = i2.find("td:nth-child(4)").text().trim(), l2 = i2.find("td:nth-child(5)").text().trim(), c2 = parseInt(i2.find("td:nth-child(6)").text().trim()) || 0, d2 = parseInt(i2.find("td:nth-child(7)").text().trim()) || 0; + o2 && a2.push({ + title: s2, + magnet: o2, + size: r2, + date: l2, + seeders: c2, + leechers: d2 + }); + })), a2; + } + calcMagnetScore(e2) { + return calcMagnetScore(e2); + } + }; + __name(_MagnetHubPlugin, "MagnetHubPlugin"); + var MagnetHubPlugin = _MagnetHubPlugin; + + // src/plugins/external-search/other-site.js + var _StorageQueue = class _StorageQueue { + constructor() { + this.queue = Promise.resolve(); + } + addTask(e2) { + const task = this.queue.then((() => e2())); + return this.queue = task.catch(((e3) => { + clog.error("执行异步队列任务失败:", e3); + })), task; + } + async waitAllFinished() { + return this.queue; + } + }; + __name(_StorageQueue, "StorageQueue"); + var StorageQueue = _StorageQueue; + var _OtherSitePlugin = class _OtherSitePlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "siteConfigs", [{ + id: "javTrailersBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavTrailersUrl(), "getBaseUrl"), + itemSelector: ".videos-list .video-link", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}`, "searchPath"), + getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find("p.card-text").text(), "findCarNumOrTitle") + }, { + id: "123AvBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => `${await this.getAv123Url()}/cn`, "getBaseUrl"), + itemSelector: ".card", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search?keyword=${encodeURIComponent(t2)}`, "searchPath"), + requestOptions: { cookiePartitionTopLevelSite: "https://123av.com" }, + getDetailPageHref: /* @__PURE__ */ __name((e2, t2) => { + const href = e2.find('a.card__link[href*="/cn/v/"]').first().attr("href"); + return href ? new URL(href, t2).href : null; + }, "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find(".card__link").first().text(), "findCarNumOrTitle"), + matches: /* @__PURE__ */ __name((text, carNum) => text.replace(/FC2-PPV-/gi, "FC2-").toLowerCase().includes(carNum.toLowerCase()), "matches") + }, { + id: "jableBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => await this.getjableUrl(), "getBaseUrl"), + itemSelector: "#list_videos_videos_list_search_result .detail .title a", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}/`, "searchPath"), + getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") + }, { + id: "avgleBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => await this.getAvgleUrl(), "getBaseUrl"), + itemSelector: ".text-secondary", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/vod/search.html?wd=${t2}`, "searchPath"), + getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") + }, { + id: "missAvBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => await this.getMissAvUrl(), "getBaseUrl"), + itemSelector: ".text-secondary", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search/${t2}`, "searchPath"), + getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.attr("href"), "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle") + }, { + id: "supJavBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => await this.getSupJavUrl(), "getBaseUrl"), + itemSelector: ".posts post", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/?s=${t2}`, "searchPath"), + getDetailPageHref: /* @__PURE__ */ __name((e2, t2, n2) => e2.attr("href"), "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.attr("title"), "findCarNumOrTitle") + }, { + id: "javDbBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavDbUrl(), "getBaseUrl"), + itemSelector: ".movie-list .item", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/search?q=${t2}`, "searchPath"), + getDetailPageHref: /* @__PURE__ */ __name((e2) => e2.find("a").attr("href"), "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.find(".video-title").text(), "findCarNumOrTitle"), + condition: /* @__PURE__ */ __name((e2) => l, "condition") + }, { + id: "javBusBtn", + getBaseUrl: /* @__PURE__ */ __name(async () => await this.getJavBusUrl(), "getBaseUrl"), + itemSelector: ".container h3", + searchPath: /* @__PURE__ */ __name((e2, t2) => `${e2}/${t2}`, "searchPath"), + getDetailPageHref: /* @__PURE__ */ __name((e2, t2, n2) => `${t2}/${n2}`, "getDetailPageHref"), + findCarNumOrTitle: /* @__PURE__ */ __name((e2) => e2.text(), "findCarNumOrTitle"), + condition: /* @__PURE__ */ __name((e2) => r && e2 && !e2.includes("FC2"), "condition") + }, { + id: "fanzaBtn", + noHandle: true, + initUrl: /* @__PURE__ */ __name((e2) => `https://www.dmm.co.jp/search/=/searchstr=${e2}`, "initUrl"), + condition: /* @__PURE__ */ __name((e2) => e2 && !e2.includes("FC2"), "condition") + }]), i(this, "settingCache", null), i(this, "lastFetchTime", 0), i(this, "CACHE_DURATION", 1e4); + } + getName() { + return "OtherSitePlugin"; + } + async initCss() { + return ` + `; + } + async handle() { + isDetailPage && await this.loadOtherSite(null, null, { + autoDetect: false + }); + } + async loadOtherSite(e2, t2, n2 = {}) { + if ("yes" !== await storageManager.getSetting("enableLoadOtherSite", "yes")) return; + const root = n2.root ? $(n2.root) : $(document), target = n2.target ? $(n2.target) : root.find(".movie-panel-info,.container .info").first(); + if (!target.length || n2.isActive && !n2.isActive()) return; + root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); + e2 = normalizeCarNum(e2) || this.getPageInfo().carNum; + const enabled = this.getEnabledSites(), configs = this.siteConfigs.map(((config) => ({ ...config, sourceCarNum: t2 }))), view = { root, target, configs, carNum: e2, isActive: "function" === typeof n2.isActive ? n2.isActive : () => true }; + const box = $('
    '), list = box.find(".jhs-site-list"), settings = $('
    '); + configs.forEach(((config) => { + if (config.condition && false === config.condition(config.sourceCarNum)) return; + const button = $('').attr("data-jhs-site-id", config.id).toggleClass("jhs-is-hidden", !enabled.includes(config.id)); + button.find("span").text(config.id.replace("Btn", "")), list.append(button); + })); + list.append('', ''), target.append(box, settings), view.box = box, view.settings = settings; + if (!e2) return box.find(".site-btn").removeAttr("href").attr({ "aria-disabled": "true", title: "番号不可用" }), box.find('[data-jhs-role="detect-sites"]').prop("disabled", true), this.renderSettingsArea(view), this.setupEventListeners(view), void clog.warn("跳过第三方站点解析:番号不可用"); + box.find('[data-jhs-site-id="javTrailersBtn"]').on("click", ((event) => { + event.preventDefault(); + const original = $(event.currentTarget).attr("href"), destination = event.ctrlKey || event.metaKey ? original : original + "?handle=1"; + utils.openPage(destination, e2, false, event); + })), await Promise.all(configs.map((async (config) => { + config.condition && false === config.condition(config.sourceCarNum) || await this.prepareSiteLink(e2, config, view); + }))), this.renderSettingsArea(view), this.setupEventListeners(view), box.find('[data-jhs-role="detect-sites"]').off("click").on("click", ((event) => { + event.preventDefault(), this.detectOtherSites(e2, view); + })), n2.autoDetect && await this.detectOtherSites(e2, view); + return box; } - const errorLog = unsafeWindow.pluginManager.getErrorLog(); - if (errorLog.length) { - let eHtml = ''; - for (const err of [...errorLog].reverse()) { - eHtml += ``; + async prepareSiteLink(e2, t2, view = { root: $(document), isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { + const n2 = view.root.find(`[data-jhs-site-id="${t2.id}"],#${t2.id}`).first(); + if (!(e2 = normalizeCarNum(e2))) return n2.removeAttr("href").attr({ "aria-disabled": "true", title: "番号不可用" }), void this.setSiteState(n2, "idle"); + if (t2.initUrl) return void (n2.attr("href", t2.initUrl(e2)), this.setSiteState(n2, "idle"), n2.attr("title", "点击前往外部搜索页")); + try { + const a2 = await t2.getBaseUrl(), i2 = t2.searchPath(a2, e2); + view.isActive() && (n2.attr("href", i2), n2.attr("title", "点击前往外部搜索页;点击检测按钮后才自动检测"), this.setSiteState(n2, "idle")); + } catch (a2) { + view.isActive() && (n2.attr("title", "外部站点地址未配置或不可用"), this.setSiteState(n2, "domain-error")); } - eHtml += "
    时间插件阶段错误信息
    ${escapeHtml(err.time.substring(11, 19))}${escapeHtml(err.plugin)}${escapeHtml(err.phase)}${escapeHtml(err.message)}
    "; - $("#plugin-error-log").html(eHtml); - } else { - $("#plugin-error-log").text("无错误记录"); } - const cacheStats = storageManager.getCacheHitStats(); - $("#cache-hit-stats").html(`
    命中: ${cacheStats.hits}未命中: ${cacheStats.misses}总计: ${cacheStats.total}命中率: ${cacheStats.rate}
    `); - } - __name(renderPluginMgmtPanel, "renderPluginMgmtPanel"); - async function renderDataHealthPanel() { - const e2 = $("#health-data-display"); - if (!e2.length) return; - e2.text("体检中..."); - try { - const t2 = await storageManager.inspectDataHealth(), n2 = t2.fixable.reduce(((e3, t3) => e3 + t3.count), 0), a2 = t2.readonly.reduce(((e3, t3) => e3 + t3.count), 0), i2 = /* @__PURE__ */ __name((t3) => t3.length ? t3.map(((e3) => `
  • ${escapeHtml(e3.message)}:${e3.count}
  • `)).join("") : "
  • 无
  • ", "i"); - e2.html(` -
    -
    番号记录:${t2.totals.carList}
    -
    收藏演员:${t2.totals.favoriteActresses}
    -
    黑名单演员:${t2.totals.blacklist}
    -
    黑名单作品:${t2.totals.blacklistCarList}
    -
    -
    体检时间:${escapeHtml(t2.checkedAt)};可修复问题 ${n2} 项,只读问题 ${a2} 项。
    -
    -

    可安全修复

      ${i2(t2.fixable)}
    -

    仅报告

      ${i2(t2.readonly)}
    -
    - `); - } catch (t2) { - clog.error(t2), e2.text("体检失败: " + t2); + async detectOtherSites(e2, view = { root: $(document), configs: this.siteConfigs, isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { + const t2 = view.root.find('[data-jhs-role="detect-sites"],#detectOtherSiteBtn').first(), n2 = t2.text(); + if (!(e2 = normalizeCarNum(e2))) return t2.prop("disabled", true), void clog.warn("跳过第三方站点检测:番号不可用"); + return t2.text("检测中").prop("disabled", true).addClass("is-checking"), await Promise.all(view.configs.map((async (t3) => { + t3.condition && false === t3.condition(t3.sourceCarNum) || await this.handleSite(e2, t3, view); + }))), view.isActive() && t2.text(n2).prop("disabled", false).removeClass("is-checking"); } - } - __name(renderDataHealthPanel, "renderDataHealthPanel"); - async function repairDataHealthWithBackup() { - const e2 = JSON.stringify(await storageManager.exportData()), t2 = `health-backup-${utils.getNowStr("_", "_")}.json`; - utils.download(e2, t2); - const n2 = await storageManager.repairDataHealth(); - show.ok(`已修复 ${n2.fixedGroups} 组数据问题,修复前备份已下载`), await renderDataHealthPanel(); - } - __name(repairDataHealthWithBackup, "repairDataHealthWithBackup"); - async function loadSettingForm(getBean) { - let e2 = await storageManager.getSetting(); - $("#videoQuality").val(e2.videoQuality), $("#reviewCount").val(e2.reviewCount || 20), $("#tagPosition").val(e2.tagPosition || "rightTop"), $("#defaultQuickFilterTab").val(normalizeQuickFilterKey(e2.defaultQuickFilterTab)), $("#needClosePageBasic").prop("checked", !e2.needClosePage || e2.needClosePage === _), $("#autoRemoveNewVideoMarkAfterBrowse").prop("checked", !!e2.autoRemoveNewVideoMarkAfterBrowse && e2.autoRemoveNewVideoMarkAfterBrowse === _), $("#waitCheckCount").val(e2.waitCheckCount || 5), $("#checkConcurrencyCount").val(parseNumberSetting(e2.checkConcurrencyCount, 2, { min: 2, max: 5 })), $("#checkRequestSleep").val(parseNumberSetting(e2.checkRequestSleep, 100, { min: 0, max: 3e3 })), $("#enableCheckBlacklist").val(e2.enableCheckBlacklist || _), $("#checkBlacklist_intervalTime").val(e2.checkBlacklist_intervalTime || 12), $("#checkBlacklist_ruleTime").val(parseNumberSetting(e2.checkBlacklist_ruleTime, 8760, { min: 0 })), $("#enableCheckFavoriteActress").val(e2.enableCheckFavoriteActress || _), $("#checkFavoriteActress_IntervalTime").val(e2.checkFavoriteActress_IntervalTime || 24), $("#enableCheckNewVideo").val(e2.enableCheckNewVideo || _), $("#checkNewVideo_intervalTime").val(e2.checkNewVideo_intervalTime || 12), $("#checkNewVideo_ruleTime").val(parseNumberSetting(e2.checkNewVideo_ruleTime, 8760, { min: 0 })); - const t2 = e2.highlightedTagNumber || 1, n2 = e2.highlightedTagColor || "#ce2222"; - $("#highlightedTagNumber").val(e2.highlightedTagNumber || 1), $("#highlightedTagColor").val(e2.highlightedTagColor || "#ce2222"), $("#highlightedTagLabel").css("border", `${t2}px solid ${n2}`), $("#enableClog").val(e2.enableClog || _), $("#clogMsgCount").val(e2.clogMsgCount || 2e3), $("#mobileMode").val(e2.mobileMode || "auto"), $("#themeMode").val(e2.themeMode || "light"), $("#httpTimeout").val(e2.httpTimeout || 5e3), $("#httpRetryCount").val(e2.httpRetryCount || 3), $("#webDavUrl").val(e2.webDavUrl || ""), $("#webDavUsername").val(e2.webDavUsername || ""), $("#webDavPassword").val(await decryptCredential(e2.webDavPassword) || ""), $("#enableTitleSelectFilter").prop("checked", !e2.enableTitleSelectFilter || e2.enableTitleSelectFilter === _), $("#enableFavoriteActresses").prop("checked", !e2.enableFavoriteActresses || e2.enableFavoriteActresses === _), $("#enableSaveActressCarInfo").prop("checked", !!e2.enableSaveActressCarInfo && e2.enableSaveActressCarInfo === _), $("#enableScreenSvg").prop("checked", !e2.enableScreenSvg || e2.enableScreenSvg === _), $("#enableVideoSvg").prop("checked", !e2.enableVideoSvg || e2.enableVideoSvg === _), $("#enableHandleSvg").prop("checked", !e2.enableHandleSvg || e2.enableHandleSvg === _), $("#enableSiteSvg").prop("checked", !e2.enableSiteSvg || e2.enableSiteSvg === _), $("#enableCopySvg").prop("checked", !e2.enableCopySvg || e2.enableCopySvg === _), $("#enableLoadActressInfo").prop("checked", !e2.enableLoadActressInfo || e2.enableLoadActressInfo === _), $("#enableVerticalModel").prop("checked", !!e2.enableVerticalModel && e2.enableVerticalModel === _), $("#containerColumns").val(e2.containerColumns || 5), $("#showContainerColumns").text(e2.containerColumns || 5), $("#containerWidth").val((e2.containerWidth || 100) - 70), $("#showContainerWidth").text((e2.containerWidth || 100) + "%"); - const a2 = getBean("OtherSitePlugin"), i2 = await a2.getMissAvUrl(), s2 = await a2.getjableUrl(), o2 = await a2.getAvgleUrl(), r2 = await a2.getJavTrailersUrl(), l2 = await a2.getAv123Url(), c2 = await a2.getJavDbUrl(), d2 = await a2.getJavBusUrl(), h2 = await a2.getSupJavUrl(); - $("#missAvUrl").val(i2), $("#jableUrl").val(s2), $("#avgleUrl").val(o2), $("#javTrailersUrl").val(r2), $("#av123Url").val(l2), $("#javDbUrl").val(c2), $("#javBusUrl").val(d2), $("#supJavUrl").val(h2); - let g2 = await storageManager.getReviewFilterKeywordList(), p2 = await storageManager.getTitleFilterKeyword(); - g2 && g2.forEach(((e3) => { - addLabelTag("#reviewKeywordContainer", e3); - })), p2 && p2.forEach(((e3) => { - addLabelTag("#filterKeywordContainer", e3); - })), ["#reviewKeywordContainer", "#filterKeywordContainer"].forEach(((e3) => { - $(`${e3} .add-tag-btn`).on("click", ((t3) => addKeyword(t3, e3))), $(`${e3} .keyword-input`).on("keypress", ((t3) => { - "Enter" === t3.key && addKeyword(t3, e3); - })); - })); - bindLayoutRangeEvents(); - } - __name(loadSettingForm, "loadSettingForm"); - function bindLayoutRangeEvents() { - $("#containerColumns").off(".jhsSetting").on("input.jhsSetting", (() => { - const columns = $("#containerColumns").val(); - $("#showContainerColumns").text(columns); - const movieList = document.querySelector(".movie-list"), masonry = document.querySelector(".masonry"); - movieList && (movieList.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); - masonry && (masonry.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); - })).on("change.jhsSetting", (async (event) => { - await storageManager.saveSettingItem("containerColumns", $(event.currentTarget).val()), await applyImageMode(); - })); - $("#containerWidth").off(".jhsSetting").on("input.jhsSetting", ((event) => { - const width = parseInt($(event.target).val()) + 70, widthText = `${width}%`; - $("#showContainerWidth").text(widthText); - const javdbContainer = document.querySelector("section .container"), javbusContainer = document.querySelector(".container-fluid .row"); - javdbContainer && (javdbContainer.style.minWidth = widthText); - javbusContainer && (javbusContainer.style.minWidth = widthText); - })).on("change.jhsSetting", ((event) => storageManager.saveSettingItem("containerWidth", parseInt($(event.currentTarget).val()) + 70))); - } - __name(bindLayoutRangeEvents, "bindLayoutRangeEvents"); - async function initQuickSettingForm(getBean, getSelector, openSettingDialogFn) { - let e2 = await storageManager.getSetting(); - $("#needClosePage").prop("checked", !e2.needClosePage || e2.needClosePage === _), $("#autoPage").prop("checked", !e2.autoPage || e2.autoPage === _), $("#translateTitle").prop("checked", !e2.translateTitle || e2.translateTitle === _), $("#enableLoadActressInfo").prop("checked", !e2.enableLoadActressInfo || e2.enableLoadActressInfo === _), $("#enableLoadOtherSite").prop("checked", !e2.enableLoadOtherSite || e2.enableLoadOtherSite === _), $("#needClosePage").on("change", (async (t2) => { - await storageManager.saveSettingItem("needClosePage", $("#needClosePage").is(":checked") ? _ : C), await jhsEventBus.emit("filter-rules-changed"); - })), $("#autoPage").on("change", (async (t2) => { - const n2 = $("#autoPage").is(":checked") ? _ : C; - await storageManager.saveSettingItem("autoPage", n2), $("#sort-toggle-btn").prop("disabled", n2 === _).attr("title", n2 === _ ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"); - })), $("#translateTitle").on("change", (async (t2) => { - const n2 = $("#translateTitle").is(":checked") ? _ : C; - await storageManager.saveSettingItem("translateTitle", n2), n2 === _ ? (await getBean("ListPagePlugin").doFilter(), isDetailPage && await getBean("TranslatePlugin").translate()) : (await getBean("ListPagePlugin").revertTranslation(), $(".translated-title").remove()); - })), $("#hoverBigImg").prop("checked", !!e2.hoverBigImg && e2.hoverBigImg === _), $("#hoverBigImg").on("change", (async (t2) => { - const n2 = $("#hoverBigImg").is(":checked") ? _ : C; - await storageManager.saveSettingItem("hoverBigImg", n2), window.imageHoverPreviewObj && (window.imageHoverPreviewObj.destroy(), window.imageHoverPreviewObj = null), n2 === _ && (window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: getSelector().coverImgSelector - })); - })), $("#enableLoadActressInfo").on("change", (async (t2) => { - const n2 = $("#enableLoadActressInfo").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadActressInfo", n2), n2 === _ ? getBean("ActressInfoPlugin").loadActressInfo() : $(".actress-info").remove(); - })), $("#enableLoadOtherSite").on("change", (async (t2) => { - const n2 = $("#enableLoadOtherSite").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadOtherSite", n2), n2 === _ ? await getBean("OtherSitePlugin").loadOtherSite() : $("#otherSiteBox").remove(); - })), $("#enableLoadScreenShot").prop("checked", !e2.enableLoadScreenShot || e2.enableLoadScreenShot === _), $("#enableLoadScreenShot").on("change", (async (t2) => { - const n2 = $("#enableLoadScreenShot").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadScreenShot", n2), n2 === _ ? await getBean("ScreenShotPlugin").loadScreenShot() : $(".screen-container").remove(); - })), $("#enableLoadPreviewVideo").prop("checked", !e2.enableLoadPreviewVideo || e2.enableLoadPreviewVideo === _), $("#enableLoadPreviewVideo").on("change", (async (t2) => { - const n2 = $("#enableLoadPreviewVideo").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadPreviewVideo", n2); - })), $("#enableVerticalModel").prop("checked", !!e2.enableVerticalModel && e2.enableVerticalModel === _), $("#enableVerticalModel").on("change", (async (t2) => { - const n2 = $("#enableVerticalModel").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableVerticalModel", n2), applyImageMode(); - })), $("#moreBtn").on("click", (() => { - $(".simple-setting, .mini-simple-setting").html("").hide(), openSettingDialogFn("base-panel"); - })); - } - __name(initQuickSettingForm, "initQuickSettingForm"); - async function saveSettingForm(getBean) { - let e2 = await storageManager.getSetting(); - e2.videoQuality = $("#videoQuality").val(), e2.reviewCount = $("#reviewCount").val(), e2.tagPosition = $("#tagPosition").val(), e2.defaultQuickFilterTab = normalizeQuickFilterKey($("#defaultQuickFilterTab").val()), e2.needClosePage = $("#needClosePageBasic").is(":checked") ? _ : C, e2.autoRemoveNewVideoMarkAfterBrowse = $("#autoRemoveNewVideoMarkAfterBrowse").is(":checked") ? _ : C, e2.waitCheckCount = $("#waitCheckCount").val(), e2.highlightedTagNumber = $("#highlightedTagNumber").val(), e2.highlightedTagColor = $("#highlightedTagColor").val(), e2.checkConcurrencyCount = $("#checkConcurrencyCount").val(), e2.checkRequestSleep = $("#checkRequestSleep").val(), e2.enableCheckBlacklist = $("#enableCheckBlacklist").val(), e2.checkBlacklist_intervalTime = $("#checkBlacklist_intervalTime").val(), e2.checkBlacklist_ruleTime = $("#checkBlacklist_ruleTime").val(), e2.enableCheckFavoriteActress = $("#enableCheckFavoriteActress").val(), e2.checkFavoriteActress_IntervalTime = $("#checkFavoriteActress_IntervalTime").val(), e2.enableCheckNewVideo = $("#enableCheckNewVideo").val(), e2.checkNewVideo_intervalTime = $("#checkNewVideo_intervalTime").val(), e2.checkNewVideo_ruleTime = $("#checkNewVideo_ruleTime").val(), e2.httpTimeout = Number($("#httpTimeout").val()) || 5e3, e2.httpRetryCount = Number($("#httpRetryCount").val()) || 3, e2.circuitBreakerThreshold = Number($("#circuitBreakerThreshold").val()) || 3, e2.circuitBreakerCooldown = Number($("#circuitBreakerCooldownSec").val()) * 1e3, e2.enableClog = $("#enableClog").val(), e2.enableClog === _ ? clog.show() : clog.hide(), e2.clogMsgCount = $("#clogMsgCount").val(), e2.mobileMode = $("#mobileMode").val(), e2.themeMode = $("#themeMode").val(), e2.webDavUrl = $("#webDavUrl").val(), e2.webDavUsername = $("#webDavUsername").val(), e2.webDavPassword = await encryptCredential($("#webDavPassword").val()), e2.missAvUrl = $("#missAvUrl").val().replace(/\/$/, ""), e2.jableUrl = $("#jableUrl").val().replace(/\/$/, ""), e2.avgleUrl = $("#avgleUrl").val().replace(/\/$/, ""), e2.javTrailersUrl = $("#javTrailersUrl").val().replace(/\/$/, ""), e2.av123Url = $("#av123Url").val().replace(/\/$/, ""), e2.javDbUrl = $("#javDbUrl").val().replace(/\/$/, ""), e2.javBusUrl = $("#javBusUrl").val().replace(/\/$/, ""), e2.supJavUrl = $("#supJavUrl").val().replace(/\/$/, ""), e2.enableTitleSelectFilter = $("#enableTitleSelectFilter").is(":checked") ? _ : C, e2.enableFavoriteActresses = $("#enableFavoriteActresses").is(":checked") ? _ : C, e2.enableSaveActressCarInfo = $("#enableSaveActressCarInfo").is(":checked") ? _ : C, e2.enableScreenSvg = $("#enableScreenSvg").is(":checked") ? _ : C, e2.enableVideoSvg = $("#enableVideoSvg").is(":checked") ? _ : C, e2.enableHandleSvg = $("#enableHandleSvg").is(":checked") ? _ : C, e2.enableSiteSvg = $("#enableSiteSvg").is(":checked") ? _ : C, e2.enableCopySvg = $("#enableCopySvg").is(":checked") ? _ : C, e2.enableLoadActressInfo = $("#enableLoadActressInfo").is(":checked") ? _ : C, e2.enableVerticalModel = $("#enableVerticalModel").is(":checked") ? _ : C, e2.containerColumns = Number($("#containerColumns").val()) || 5, e2.containerWidth = Number($("#containerWidth").val()) + 70 || 100, await storageManager.saveSetting(e2); - let t2 = []; - $("#reviewKeywordContainer .keyword-label").toArray().forEach(((e3) => { - let n3 = $(e3).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); - t2.push(n3); - })), await storageManager.saveReviewFilterKeyword(t2); - let n2 = []; - $("#filterKeywordContainer .keyword-label").toArray().forEach(((e3) => { - let t3 = $(e3).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); - n2.push(t3); - })), await storageManager.saveTitleFilterKeyword(n2), show.ok("保存成功"), await jhsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }); - const a2 = getBean("NewVideoPlugin"); - a2 && a2.resetBtnTip(), getBean("BlacklistPlugin").resetBtnTip(), getBean("BlacklistPlugin").reloadTable(); - } - __name(saveSettingForm, "saveSettingForm"); - function addLabelTag(e2, t2) { - const n2 = $(`${e2} .tag-box`); - let a2; - /^[a-z]{2,}-/i.test(t2) && r ? a2 = $(` - - ${t2} - × - - `) : a2 = $(` -
    - ${t2} - × -
    - `), a2.find(".keyword-remove").click(((e3) => { - e3.stopPropagation(), e3.preventDefault(); - const t3 = $(e3.currentTarget); - const n3 = t3.closest(".keyword-label").attr("data-keyword").split(" ")[0]; - utils.q(e3, `是否移除屏蔽词 ${n3}?`, (async () => { - t3.parent().remove(); - })); - })), n2.append(a2); - } - __name(addLabelTag, "addLabelTag"); - function addKeyword(e2, t2) { - let n2 = $(`${t2} .keyword-input`); - const a2 = n2.val().trim(); - a2 && (addLabelTag(t2, a2), n2.val("")); - } - __name(addKeyword, "addKeyword"); - async function importSettingData(showDiffPreviewFn) { - try { - const input = document.createElement("input"); - input.type = "file", input.accept = ".json"; - const cleanup = /* @__PURE__ */ __name(() => input.remove(), "cleanup"); - input.onchange = async (e2) => { - const t2 = e2.target.files[0]; - if (!t2) return void cleanup(); - const n2 = new FileReader(); - n2.onload = async (e3) => { - cleanup(); - try { - const t3 = e3.target.result.toString(), n3 = JSON.parse(t3); - if (!n3 || "object" != typeof n3 || Array.isArray(n3)) throw new Error("文件内容不是有效的数据对象"); - const a2 = loading(); - try { - const e4 = await storageManager.exportData(), t4 = await storageManager.diffData(e4, n3); - a2.close(), showDiffPreviewFn(t4, n3, null); - } catch (i2) { - a2.close(), clog.error(i2), show.error("差异分析失败: " + i2.message); - } - } catch (t3) { - clog.error(t3), show.error("导入失败:文件内容不是有效的JSON格式 " + t3.message); - } - }, n2.onerror = () => { - cleanup(), show.error("读取文件时出错"); - }, n2.readAsText(t2); - }, document.body.appendChild(input), input.click(); - setTimeout(cleanup, 3e5); - } catch (e2) { - clog.error(e2), show.error("导入数据时出错: " + e2.message); + setSiteState(e2, t2) { + e2.removeClass("is-checking is-available is-unavailable is-domain-error"), "idle" !== t2 && e2.addClass(`is-${t2}`); + } + async handleSite(e2, t2, view = { root: $(document), isActive: /* @__PURE__ */ __name(() => true, "isActive") }) { + const n2 = view.root.find(`[data-jhs-site-id="${t2.id}"],#${t2.id}`).first(); + n2.removeAttr("href").find(".site-tag").remove(), this.setSiteState(n2, "checking"); + if (t2.initUrl && n2.attr("href", t2.initUrl(e2)), t2.noHandle && true === t2.noHandle) { + const t3 = "jhs_other_site_dmm", a2 = (localStorage.getItem(t3) ? JSON.parse(localStorage.getItem(t3)) : {})[e2]; + a2 ? (n2.attr("href", a2.url), "multiple" === a2.type && n2.append('多结果'), this.setSiteState(n2, "available")) : this.setSiteState(n2, "idle"); + } else try { + if (n2.attr("href")) return void this.setSiteState(n2, "idle"); + if (utils.isHidden(n2)) return; + const a2 = "jhs_other_site", i2 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}, s2 = e2 + "_" + t2.id.replace("Btn", ""), o2 = i2[s2], m2 = Date.now(); + if (o2 && o2.time && m2 - o2.time < 864e5) return void (n2.attr("href", o2.url), "multiple" === o2.type && n2.append('多结果'), this.setSiteState(n2, "available")); + const r2 = await t2.getBaseUrl(), l2 = t2.searchPath(r2, e2); + n2.attr("href", l2); + const _breaker = gmHttp.isDomainCircuitBroken(l2); + if (_breaker) { + n2.attr("title", `站点已熔断,${_breaker.remaining}秒后重试`), this.setSiteState(n2, "domain-error"); + return; + } + const c2 = await storageManager.cachedRequest(`other-site:${t2.id}:${e2}`, 864e5, (() => gmHttp.get(l2, null, t2.headers, true, t2.requestOptions || {}))); + if (!view.isActive()) return; + const d2 = utils.htmlTo$dom(c2), h2 = []; + d2.find(t2.itemSelector).each(((n3, a3) => { + const i3 = $(a3); + const itemText = t2.findCarNumOrTitle(i3); + if (t2.matches ? !t2.matches(itemText, e2) : !itemText.toLowerCase().includes(e2.toLowerCase())) return; + let s3 = t2.getDetailPageHref(i3, r2, e2); + if (!s3) throw new Error("解析href失败"); + s3.includes("http") || (s3 = r2 + (s3.startsWith("/") ? s3 : "/" + s3)), h2.push(s3); + })); + let g2 = "", p2 = null; + if (1 === h2.length) { + let e3 = h2[0]; + n2.attr("href", e3), this.setSiteState(n2, "available"), p2 = { + type: "single", + url: e3, + time: m2 + }; + } else h2.length > 1 ? (n2.attr("href", l2), g2 += '多结果', this.setSiteState(n2, "available"), p2 = { + type: "multiple", + url: l2, + time: m2 + }) : (n2.attr("href", l2), n2.attr("title", "未查询到, 点击前往搜索页"), this.setSiteState(n2, "unavailable")); + if (p2) { + const e3 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}; + e3[s2] = p2, localStorage.setItem(a2, JSON.stringify(e3)); + } + g2 && n2.append(g2); + } catch (a2) { + const e3 = String(a2), i2 = t2.id.replace("Btn", ""); + a2._circuitBroken ? (n2.attr("title", e3), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源跳过, ${i2} 已熔断`)) : a2?._cfBlocked ? (n2.attr("title", "请求失败:Cloudflare 安全检查。"), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源失败, ${i2} 需Cloudflare安全检查`)) : e3.includes("重定向") ? (n2.attr("title", "域名失效"), this.setSiteState(n2, "domain-error"), clog.warn(`检测第三方资源失败, ${i2} 域名被重定向`)) : e3.includes("404 Page Not Found") ? (n2.attr("title", "未查询到, 点击前往搜索页"), this.setSiteState(n2, "unavailable")) : (clog.error(a2), n2.attr("title", "请求失败。"), this.setSiteState(n2, "unavailable"), clog.warn(`检测第三方资源失败, ${i2}`)); + } } - } - __name(importSettingData, "importSettingData"); - async function backupDataByWebDav(folderName) { - const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; - if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); - const a2 = t2.webDavUsername; - if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); - const i2 = await decryptCredential(t2.webDavPassword); - if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); - let s2 = utils.getNowStr("_", "_") + ".json", o2 = JSON.stringify(await storageManager.exportData()); - o2 = await encryptData(o2); - let r2 = loading(); - try { - const e2 = new WebDavClient(n2, a2, i2); - await e2.backup(folderName, s2, o2), show.ok("备份完成"); - } catch (l2) { - clog.error(l2), show.error(l2.toString()); - } finally { - r2.close(); + async getSettingCache() { + const e2 = Date.now(); + return (!this.settingCache || e2 - this.lastFetchTime > this.CACHE_DURATION) && (this.settingCache = await storageManager.getSetting(), this.lastFetchTime = e2), this.settingCache; } - } - __name(backupDataByWebDav, "backupDataByWebDav"); - async function backupListBtnByWebDav(folderName, openFileListDialogFn) { - const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; - if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); - const a2 = t2.webDavUsername; - if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); - const i2 = await decryptCredential(t2.webDavPassword); - if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); - let s2 = loading(); - try { - const e2 = new WebDavClient(n2, a2, i2), t3 = await e2.getBackupList(folderName); - openFileListDialogFn(t3, e2, "WebDav"); - } catch (o2) { - clog.error(o2), show.error(`发生错误: ${o2 ? o2.message : o2}`); - } finally { - s2.close(); + async getMissAvUrl() { + return (await this.getSettingCache()).missAvUrl || "https://missav.live"; } - } - __name(backupListBtnByWebDav, "backupListBtnByWebDav"); - function openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn) { - const formatSize = /* @__PURE__ */ __name((size) => { - const units = ["B", "KB", "MB", "GB", "TB", "PB"]; - let i2 = 0, s2 = size; - for (; s2 >= 1024 && i2 < units.length - 1; ) s2 /= 1024, i2++; - return `${s2 % 1 == 0 ? s2.toFixed(0) : s2.toFixed(2)} ${units[i2]}`; - }, "formatSize"); - const renderCards = /* @__PURE__ */ __name((files) => { - if (!files || files.length === 0) { - return '
    暂无数据
    '; + async getjableUrl() { + return (await this.getSettingCache()).jableUrl || "https://jable.tv"; + } + async getAvgleUrl() { + return (await this.getSettingCache()).avgleUrl || "https://jav.rs"; + } + async getJavTrailersUrl() { + return (await this.getSettingCache()).javTrailersUrl || "https://javtrailers.com"; + } + async getAv123Url() { + return (await this.getSettingCache()).av123Url || "https://123av.com"; + } + async getJavDbUrl() { + return (await this.getSettingCache()).javDbUrl || "https://javdb.com"; + } + async getJavBusUrl() { + return (await this.getSettingCache()).javBusUrl || "https://www.javbus.com"; + } + async getSupJavUrl() { + return (await this.getSettingCache()).supJavUrl || "https://supjav.com"; + } + getEnabledSites() { + const fallback = this.siteConfigs.map(((site) => site.id)); + try { + const raw = localStorage.getItem("jhs_enabled_sites"); + if (!raw) return fallback; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter(((id) => fallback.includes(id))) : fallback; + } catch (error) { + return clog.warn("外部站点配置损坏,已回退默认值", error), fallback; } - return files.map((file, idx) => ` -
    -
    ${escapeHtml(file.name)}
    -
    ${formatSize(file.size)} · ${utils.getNowStr("-", ":", file.createTime)}
    -
    - - - -
    -
    - `).join(""); - }, "renderCards"); - const containerId = "jhs-backup-card-list"; - layer.open({ - type: 1, - title: n2 + "备份文件", - content: `
    ${renderCards(e2)}
    `, - area: utils.getResponsiveArea(["800px", "70%"]), - anim: -1, - success: /* @__PURE__ */ __name((layerEl) => { - const container = $(layerEl).find(`#${containerId}`); - container.on("click", ".jhs-backup-btn", async (ev) => { - const btn = $(ev.currentTarget); - const action = btn.data("action"); - const idx = btn.data("idx"); - const file = e2[idx]; - if (!file) return; - if (action === "delete") { - layer.confirm(`是否删除 ${file.name} ?`, { - icon: 3, - title: "提示", - btn: ["确定", "取消"] - }, async (confirmIdx) => { - layer.close(confirmIdx); - let load = loading(); - try { - await t2.deleteFile(file.fileId); - e2 = await t2.getBackupList(folderName); - container.html(renderCards(e2)); - layer.alert("删除成功"); - } catch (err) { - clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); - } finally { - load.close(); - } - }); - } else if (action === "download") { - let load = loading(); - try { - const data = await decryptData(await t2.getFileContent(file.fileId)); - utils.download(data, file.name); - } catch (err) { - clog.error(err), show.error("下载失败: " + err); - } finally { - load.close(); - } - } else if (action === "import") { - let load = loading(); - try { - let data = await t2.getFileContent(file.fileId); - data = await decryptData(data); - const parsed = JSON.parse(data); - const currentData = await storageManager.exportData(); - const diff = await storageManager.diffData(currentData, parsed); - load.close(); - showDiffPreviewFn(diff, null, parsed); - } catch (err) { - load.close(), clog.error(err), show.error("预览失败: " + (err ? err.message : err)); - } - } - }); - }, "success") - }); - } - __name(openFileListDialogMobile, "openFileListDialogMobile"); - function openFileListDialog(e2, t2, n2, folderName, showDiffPreviewFn) { - if (utils.isMobileMode()) { - openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn); - return; } - layer.open({ - type: 1, - title: n2 + "备份文件", - content: '\n
    \n
    \n
    \n ', - area: utils.getResponsiveArea(["800px", "70%"]), - anim: -1, - success: /* @__PURE__ */ __name((a2) => { - const i2 = new Tabulator("#table-container", { - layout: "fitColumns", - placeholder: "暂无数据", - virtualDom: true, - data: e2, - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - columns: [{ - title: "文件名", - field: "name", - width: 200, - headerSort: false, - responsive: 0 - }, { - title: "文件大小", - field: "size", - responsive: 1, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, t3, n3) => { - const a3 = ["B", "KB", "MB", "GB", "TB", "PB"]; - let i3 = 0, s2 = e3.getData().size; - for (; s2 >= 1024 && i3 < a3.length - 1; ) s2 /= 1024, i3++; - return `${s2 % 1 == 0 ? s2.toFixed(0) : s2.toFixed(2)} ${a3[i3]}`; - }, "formatter") - }, { - title: "备份日期", - field: "createTime", - responsive: 2, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, t3, n3) => { - const a3 = e3.getData(); - return `${utils.getNowStr("-", ":", a3.createTime)}`; - }, "formatter") - }, { - title: "操作", - minWidth: 250, - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e3, a3, s2) => { - const o2 = e3.getData(); - return s2((() => { - const a4 = e3.getElement().querySelector(".backup-delete"), s3 = e3.getElement().querySelector(".backup-download"), r2 = e3.getElement().querySelector(".backup-import"); - a4 && a4.addEventListener("click", ((e4) => { - layer.confirm(`是否删除 ${o2.name} ?`, { - icon: 3, - title: "提示", - btn: ["确定", "取消"] - }, (async (e5) => { - layer.close(e5); - let a5 = loading(); - try { - await t2.deleteFile(o2.fileId); - let e6 = await t2.getBackupList(folderName); - i2.replaceData(e6), layer.alert("删除成功"); - } catch (s4) { - clog.error(s4), show.error(`发生错误: ${s4 ? s4.message : s4}`); - } finally { - a5.close(); - } - })); - })), s3 && s3.addEventListener("click", (async (e4) => { - let a5 = loading(); - try { - const e5 = await decryptData(await t2.getFileContent(o2.fileId)); - utils.download(e5, o2.name); - } catch (i3) { - clog.error(i3), show.error("下载失败: " + i3); - } finally { - a5.close(); - } - })), r2 && r2.addEventListener("click", (async (e4) => { - let a5 = loading(); - try { - let e5 = await t2.getFileContent(o2.fileId); - e5 = await decryptData(e5); - const n3 = JSON.parse(e5), i3 = await storageManager.exportData(), s4 = await storageManager.diffData(i3, n3); - a5.close(), showDiffPreviewFn(s4, null, n3); - } catch (i3) { - a5.close(), clog.error(i3), show.error("预览失败: " + (i3 ? i3.message : i3)); - } - })); - })), '\n \n \n \n '; - }, "formatter") - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } - } - } - }); - }, "success") - }); - } - __name(openFileListDialog, "openFileListDialog"); - async function exportSettingData() { - try { - const e2 = JSON.stringify(await storageManager.exportData()), t2 = `${utils.getNowStr("_", "_")}.json`; - utils.download(e2, t2), show.ok("数据导出成功"); - } catch (t2) { - clog.error(t2), show.error("导出数据时出错: " + t2.message); + saveEnabledSites(e2) { + localStorage.setItem("jhs_enabled_sites", JSON.stringify(e2)); + } + renderSettingsArea(view = { root: $(document), configs: this.siteConfigs }) { + const enabled = this.getEnabledSites(), target = view.root.find('[data-jhs-role="site-checkboxes"],#siteCheckboxes').first().empty(); + view.configs.forEach(((config) => { + const input = $('').attr("data-site-id", config.id).prop("checked", enabled.includes(config.id)); + target.append($('').append(input, $("").text(config.id.replace("Btn", "")))); + })); + } + setupEventListeners(view = { root: $(document), configs: this.siteConfigs, carNum: null }) { + const $settingsArea = view.root.find("[data-jhs-other-site-settings],#settingsArea").first(); + view.root.find('[data-jhs-role="site-settings"],#settingSiteBtn').off("click.jhsOtherSite").on("click.jhsOtherSite", (() => { + $settingsArea.toggleClass("jhs-is-hidden"); + })), $settingsArea.off("change.jhsOtherSite").on("change.jhsOtherSite", 'input[type="checkbox"]', (async (event) => { + const siteId = $(event.currentTarget).attr("data-site-id"); + try { + if (event.currentTarget.checked) { + view.root.find(`[data-jhs-site-id="${siteId}"],#${siteId}`).removeClass("jhs-is-hidden"); + const carNum = view.carNum || this.getPageInfo().carNum, site = view.configs.find(((item) => item.id === siteId)); + site && await this.prepareSiteLink(carNum, site, view); + } else view.root.find(`[data-jhs-site-id="${siteId}"],#${siteId}`).addClass("jhs-is-hidden"); + this.saveEnabledSites($settingsArea.find('input[type="checkbox"]:checked').map(((index, input) => $(input).attr("data-site-id"))).get()); + } catch (error) { + clog.warn(`外部站点 ${siteId || "unknown"} 状态更新失败`, error); + } + })); + } + }; + __name(_OtherSitePlugin, "OtherSitePlugin"); + var OtherSitePlugin = _OtherSitePlugin; + + // src/plugins/external-search/related.js + var _RelatedPlugin = class _RelatedPlugin extends BasePlugin { + getName() { + return "RelatedPlugin"; + } + async initCss() { + return ` + `; + } + async showRelated(target, movieId, options = {}) { + const isActive = "function" === typeof options.isActive ? options.isActive : () => true; + const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getDependency("DetailWorkspacePlugin")?.getSlot("related"); + if (!movieId) return void show.error("未传入movieId"); + if (!isActive() || !host?.length) return $(); + const existing = host.children('[data-jhs-panel="related"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); + if (existing.length) return existing; + const panel = $('').attr("data-jhs-movie-id", String(movieId)), header = $('

    相关清单

    '), toggle = $(''), state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; + header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle) : panel.append(header), panel.append('', ''), host.append(panel), state.isActive = isActive; + this.updateToggle(toggle, enabled === _); + toggle.on("click", ((event) => { + event.preventDefault(), event.stopPropagation(); + const expanded = "展开" === toggle.find(".toggle-text").text(); + this.updateToggle(toggle, expanded), panel.find(".jhs-related-container, .jhs-related-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayRelateds(state), storageManager.saveSettingItem("enableLoadRelated", expanded ? _ : C); + })); + enabled === _ ? await this.fetchAndDisplayRelateds(state) : panel.find(".jhs-related-container, .jhs-related-footer").hide(); + return panel; + } + updateToggle(toggle, expanded) { + toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); + } + async fetchAndDisplayRelateds(state) { + if (state.loading || !state.isActive?.()) return; + state.loading = true; + const { movieId, panel } = state, container = panel.find(".jhs-related-container"), footer = panel.find(".jhs-related-footer"); + container.empty().append($('
    ').text("获取清单中...")), footer.empty(); + let related; + try { + related = await K(movieId, 1, 20); + } catch (error) { + clog.error("获取清单失败:", error); + state.loading = false; + return void this.renderRetry(container, (() => this.fetchAndDisplayRelateds(state))); + } + if (!state.isActive?.()) return void (state.loading = false); + state.loading = false, state.loaded = true; + container.empty(); + if (!related.length) return void container.append($('
    ').text("无清单")); + this.displayRelateds(state, related, container), 20 === related.length ? this.bindLoadMore(state, container, footer) : footer.append($('
    ').text("已加载全部清单")); + } + renderRetry(container, retry) { + container.empty(); + const state = $('
    ').append(document.createTextNode("获取清单失败 ")); + state.append($('').on("click", retry)), container.append(state); } - } - __name(exportSettingData, "exportSettingData"); - var _SettingPlugin = class _SettingPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "folderName", "JHS-数据备份"), i(this, "resourceSettings", new ResourceSettingsService()), i(this, "pendingCarImport", null), i(this, "taskStatusUnsubscribe", null), i(this, "cacheItems", [{ - key: "jhs_dmm_video", - text: "预览视频缓存", - title: "预览视频缓存" - }, { - key: "jhs_other_site", - text: "第三方站点缓存", - title: "第三方站点资源检测结果, 如missav,123Av等" - }, { - key: "jhs_screenShot", - text: "缩略图缓存", - title: "缩略图缓存" - }, { - key: "jhs_translate", - text: "标题翻译", - title: "标题翻译" - }, { - key: "jhs_actress_info", - text: "演员信息", - title: "演员的年龄三围等数据信息" - }, { - key: "jhs_score_info", - text: "Top250|热播 评分数据", - title: "Top250及热播的评分数据" - }, { - key: "third_party_ttl_cache", - text: "第三方TTL缓存", - title: "评论、相关清单、磁力搜索、缩略图等请求缓存" - }, { - key: "_circuitBreaker", - text: "熔断状态", - title: "各站点的熔断计数和状态" - }, { - key: "_domainStats", - text: "域名请求统计", - title: "各域名的请求次数和错误统计" - }]); + bindLoadMore(state, container, footer) { + const button = $(''), end = $('').hide(); + footer.empty().append(button, end); + button.on("click", (async () => { + const nextPage = state.page + 1; + button.text("加载中...").prop("disabled", true); + try { + const related = await K(state.movieId, nextPage, 20); + if (!state.isActive?.()) return; + state.page = nextPage; + this.displayRelateds(state, related, container), related.length < 20 ? (button.remove(), end.show()) : button.text("加载更多清单").prop("disabled", false); + } catch (error) { + clog.error("加载更多清单失败:", error), button.text("加载失败,请重试").prop("disabled", false); + } + })); + } + displayRelateds(state, related, container) { + related.forEach(((item) => { + const row = $(''), title = $("").addClass("jhs-related-title").attr({ + href: `/lists/${encodeURIComponent(item.relatedId)}`, + target: "_blank", + rel: "noopener noreferrer" + }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createTime || "未知"}`); + meta.append( + $("").text(`视频:${Number(item.movieCount) || 0}`), + $("").text(`收藏:${Number(item.collectionCount) || 0}`), + $("").text(`查看:${Number(item.viewCount) || 0}`), + time + ), row.append(heading, meta), container.append(row); + })); } + }; + __name(_RelatedPlugin, "RelatedPlugin"); + var RelatedPlugin = _RelatedPlugin; + + // src/plugins/external-search/review.js + var _ReviewPlugin = class _ReviewPlugin extends BasePlugin { getName() { - return "SettingPlugin"; + return "ReviewPlugin"; + } + async initCss() { + return ` + `; + } + async handle() { + if (!window.isDetailPage) return; + if (r) { + const movieId = this.parseMovieId(window.location.href); + const workspace = this.getDependency("DetailWorkspacePlugin"); + await Promise.all([this.showReview(movieId, workspace?.getSlot("reviews")), this.getDependency("RelatedPlugin").showRelated(workspace?.getSlot("related"), movieId)]); + } + if (l) { + const carNumber = this.getPageInfo().carNum; + if (!carNumber) return void clog.warn("跳过 JavBus 评论解析:番号不可用"); + const movieId = await resolveJavDbMovieId(carNumber); + movieId && await this.showReview(movieId, this.getDependency("DetailWorkspacePlugin")?.getSlot("reviews")); + } } - async initCss() { - const e2 = await storageManager.getSetting(); - let t2 = (null == e2 ? void 0 : e2.containerWidth) ?? "100"; - utils.isMobileMode() && (t2 = "100"); - let n2 = utils.isMobileMode() ? 1 : (null == e2 ? void 0 : e2.containerColumns) ?? 5; - window.getBeanForSetting = this.getBean.bind(this); - applyImageMode().catch(((e3) => clog.error("[JHS] applyImageMode failed:", e3))); - return buildSettingCss(t2, n2, l, r); + async showReview(movieId, target, options = {}) { + const isActive = "function" === typeof options.isActive ? options.isActive : () => true; + const enabled = await storageManager.getSetting("enableLoadReview", _), host = target?.length ? target : this.getDependency("DetailWorkspacePlugin")?.getSlot("reviews") || $("#magnets-content"); + if (!isActive() || !host?.length) return $(); + const existing = host.children('[data-jhs-panel="reviews"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); + if (existing.length) return existing; + const panel = $('
    ').attr("data-jhs-movie-id", String(movieId)); + const header = $('

    评论

    '); + const toggle = $(''); + const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; + header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle) : panel.append(header), panel.append('
    ', ''), host.append(panel), this.bindRightClickFilter(); + state.isActive = isActive; + this.updateToggle(toggle, enabled === _); + toggle.on("click", ((event) => { + event.preventDefault(), event.stopPropagation(); + const expanded = "展开" === toggle.find(".toggle-text").text(); + this.updateToggle(toggle, expanded), panel.find(".jhs-review-container, .jhs-review-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayReviews(state), storageManager.saveSettingItem("enableLoadReview", expanded ? _ : C); + })); + enabled === _ ? await this.fetchAndDisplayReviews(state) : panel.find(".jhs-review-container, .jhs-review-footer").hide(); + return panel; } - async handle() { - await storageManager.getSetting("enableClog", _) === _ && clog.show(); - if (utils.isMobileMode()) return; - if (r) { - let e2 = /* @__PURE__ */ __name(function() { - $(".navbar-search").is(":hidden") ? ($(".mini-setting-box").hide(), $(".setting-box").show()) : ($(".mini-setting-box").show(), $(".setting-box").hide()); - }, "e"); - $("#navbar-menu-user .navbar-end").prepend(''), utils.loopDetector((() => $("#miniHistoryBtn").length > 0), (() => { - $(".miniHistoryBtnBox").before('\n \n '), e2(); - })), $(window).resize(e2); + updateToggle(toggle, expanded) { + toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); + } + async fetchAndDisplayReviews(state) { + if (state.loading || !state.isActive?.()) return; + state.loading = true; + const { movieId, panel } = state, container = panel.find(".jhs-review-container"), footer = panel.find(".jhs-review-footer"); + container.empty().append($('
    ').text("获取评论中...")), footer.empty(); + const pageSize = await storageManager.getSetting("reviewCount", 20); + let reviews; + try { + reviews = await R(movieId, 1, pageSize); + } catch (error) { + error.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", error), clog.error("获取评论失败:", error); + state.loading = false; + return void this.renderRetry(container, "获取评论失败", (() => this.fetchAndDisplayReviews(state))); } - l && (isDetailPage ? $("h3").before('\n
    \n
    \n
    \n \n
    \n
    \n
    \n
    \n ') : window.isListPage && utils.loopDetector((() => $("#waitCheckBtn").length), (() => { - $("#waitCheckBtn").parent().append('\n
    \n
    \n \n
    \n
    \n
    \n '); - }), 1, 1e4, false)), $(".main-nav, .container-fluid").on("click", "#setting-btn, #mini-setting-btn", (() => { - $(".simple-setting, .mini-simple-setting").html("").hide(), clog.lowZIndex(), void this.openSettingDialog().catch(((error) => clog.error("设置中心打开失败", error))); - })), $(".main-nav, .container-fluid").on("mouseenter", ".setting-box", (async () => { - $(".simple-setting").html(buildQuickSettingHtml()).show(); + if (!state.isActive?.()) return void (state.loading = false); + state.loading = false, state.loaded = true; + container.empty(); + if (!reviews.length) return void container.append($('
    ').text("无评论")); + const keywords = await storageManager.getReviewFilterKeywordList(); + await this.displayReviews(state, reviews, container, keywords); + reviews.length === pageSize ? this.bindLoadMore(state, pageSize, keywords, container, footer) : footer.append($('
    ').text("已加载全部评论")); + } + renderRetry(container, message, retry) { + container.empty(); + const state = $('
    ').append(document.createTextNode(`${message} `)); + const button = $('').on("click", retry); + state.append(button), container.append(state); + } + bindLoadMore(state, pageSize, keywords, container, footer) { + const button = $(''), end = $('
    已加载全部评论
    ').hide(); + footer.empty().append(button, end); + button.on("click", (async () => { + const nextPage = state.page + 1; + button.text("加载中...").prop("disabled", true); try { - await initQuickSettingForm(this.getBean.bind(this), this.getSelector.bind(this), this.openSettingDialog.bind(this)); + const reviews = await R(state.movieId, nextPage, pageSize); + if (!state.isActive?.()) return; + state.page = nextPage; + await this.displayReviews(state, reviews, container, keywords), reviews.length < pageSize ? (button.remove(), end.show()) : button.text("加载更多评论").prop("disabled", false); } catch (error) { - clog.warn("桌面快捷设置初始化失败", error); + clog.error("加载更多评论失败:", error), button.text("加载失败,请重试").prop("disabled", false); } - clog.lowZIndex(); - })).on("mouseleave", ".setting-box", (() => { - $(".simple-setting").html("").hide(); - })), $(".main-nav, .container-fluid").on("mouseenter", ".mini-setting-box", (async () => { - $(".mini-simple-setting").html(buildQuickSettingHtml()).show(); - try { - await initQuickSettingForm(this.getBean.bind(this), this.getSelector.bind(this), this.openSettingDialog.bind(this)); - } catch (error) { - clog.warn("迷你快捷设置初始化失败", error); + })); + } + async displayReviews(state, reviews, container, keywords) { + if (!reviews.length) return; + const filter = keywords.length > 0 ? new RegExp(keywords.map(((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("|")) : null; + for (const review of reviews) { + const content = String(review.content || ""); + if (filter?.test(content)) continue; + const item = $('
    '), meta = $('
    '), body = $('
    '); + meta.append($("").addClass("jhs-review-author").text(review.username || "匿名用户")); + const stars = $(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); + for (let index = 0; index < score; index++) stars.append(''); + meta.append( + stars, + $("").text(utils.formatDate(review.created_at)), + $("").text(`点赞:${Number(review.likes_count) || 0}`), + $("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`) + ); + await this.appendReviewContent(body, content), item.append(meta, body), container.append(item); + } + } + appendReviewContent(container, content) { + const linkPattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; + let cursor = 0, match; + while (match = linkPattern.exec(content)) { + match.index > cursor && container.append(document.createTextNode(content.slice(cursor, match.index))); + this.appendLinkControls(container, match[0]), cursor = match.index + match[0].length; + } + cursor < content.length && container.append(document.createTextNode(content.slice(cursor))); + } + appendLinkControls(container, value) { + const isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接"; + const isResource = isMagnet || isEd2k, wrapper = $(isResource ? '' : ''); + const main = $(''); + const open = isEd2k ? $('').text(label).on("click", (() => utils.copyToClipboard(label, value))) : $("").addClass("jhs-review-link").attr({ + href: value, + target: "_blank", + rel: "noopener noreferrer" + }).text(label); + const copy = $('').on("click", (() => utils.copyToClipboard(label, value))); + main.append(open, copy), wrapper.append(main); + if (isResource) { + const actions = $(''); + actions.append(``); + wrapper.append(actions); + } + container.append(wrapper); + } + bindRightClickFilter() { + $(document).off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", (async (event) => { + if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; + const text = window.getSelection().toString(); + text && (event.preventDefault(), await utils.q(event, `是否将 '${text}' 加入评论区关键词?`, (async () => { + await storageManager.saveReviewFilterKeyword(text), show.ok("操作成功, 刷新页面后生效"); + }))); + })); + } + }; + __name(_ReviewPlugin, "ReviewPlugin"); + var ReviewPlugin = _ReviewPlugin; + + // src/plugins/external-search/top250.js + var me = "jhs_appAuthorization"; + var _Top250Plugin = class _Top250Plugin extends BasePlugin { + constructor() { + super(), i(this, "has_cnsub", ""), i(this, "$contentBox", $(".section .container")), i(this, "movies", []); + } + getName() { + return "TOP250Plugin"; + } + async handle() { + $('.main-tabs ul li:contains("猜你喜歡")').html('Top250'), $('a[href*="rankings/top"]').on("click", ((e2) => { + e2.preventDefault(), e2.stopPropagation(); + const t2 = $(e2.target), n2 = (t2.is("a") ? t2 : t2.closest("a")).attr("href"); + let a2 = n2.includes("?") ? n2.split("?")[1] : n2; + const i2 = new URLSearchParams(a2); + this.checkLogin(e2, i2); + })), await this.handleTop(); + } + hookPage() { + $("h2.section-title").contents().first().replaceWith("Top250"), $(".empty-message").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('
    '), this.$contentBox.append('
    '), this.renderPagination(); + } + renderPagination() { + const e2 = new URLSearchParams(window.location.search); + let t2 = parseInt(e2.get("page")) || 1; + this.$contentBox.append(((e3) => { + const t3 = e3 >= 5; + let n2 = ""; + for (let a2 = 1; a2 <= 5; a2++) { + n2 += `
  • `; } - clog.lowZIndex(); - })).on("mouseleave", ".mini-setting-box", (() => { - $(".mini-simple-setting").html("").hide(); + return ` + + `; + })(t2)), this.$contentBox.on("click", ".pagination-link, .pagination-previous, .pagination-next", ((t3) => { + t3.preventDefault(); + const n2 = parseInt($(t3.currentTarget).data("page")); + !isNaN(n2) && n2 > 0 && ((t4) => { + e2.set("page", t4), window.history.pushState({}, "", "?" + e2.toString()), window.location.reload(); + })(n2); })); } - /** Open shared quick settings in the mobile bottom sheet. */ - async openQuickSetting() { - $("#jhs-quick-setting-backdrop, #jhs-quick-setting-sheet").remove(); - const previousFocus = document.activeElement; - let closed = false; - const closeQuickSetting = /* @__PURE__ */ __name((restoreFocus = true) => { - if (closed) return; - closed = true, $(document).off("keydown.jhsQuickSetting"), $("#jhs-quick-setting-backdrop, #jhs-quick-setting-sheet").remove(); - restoreFocus && previousFocus?.isConnected && "function" == typeof previousFocus.focus && previousFocus.focus(); - }, "closeQuickSetting"); - const backdrop = $('
    '); - const sheet = $(``); - sheet.find(".jhs-quick-setting").html(buildQuickSettingHtml()), $("body").append(backdrop, sheet), clog.lowZIndex(); - backdrop.on("click.jhsQuickSetting", (() => closeQuickSetting())), sheet.on("click.jhsQuickSetting", ".jhs-quick-setting__close", (() => closeQuickSetting())), $(document).off("keydown.jhsQuickSetting").on("keydown.jhsQuickSetting", ((event) => { - if ("Escape" === event.key) return event.preventDefault(), closeQuickSetting(); - if ("Tab" !== event.key) return; - const focusable = sheet.find('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])').filter(((index, element) => !element.hidden && "true" !== element.getAttribute("aria-hidden"))); - if (!focusable.length) return void event.preventDefault(); - const first = focusable[0], last = focusable[focusable.length - 1]; - event.shiftKey && document.activeElement === first ? (event.preventDefault(), last.focus()) : !event.shiftKey && document.activeElement === last && (event.preventDefault(), first.focus()); + async handleTop() { + if (!window.location.href.includes("handleTop=1")) return; + const e2 = new URLSearchParams(window.location.search); + let t2 = e2.get("handleType") || "all", n2 = e2.get("type_value") || ""; + this.has_cnsub = e2.get("has_cnsub") || ""; + let a2 = e2.get("page") || 1; + this.toolBar(t2, n2, a2), this.hookPage(); + let i2 = $(".movie-list"); + i2.html(""); + let s2 = loading(); + let o2 = false; + for (let l2 = 1; l2 <= 3 && !o2; l2++) try { + const e3 = await q(t2, n2, a2, 50); + let r2 = e3.success, l3 = e3.message, c2 = e3.action; + if (1 === r2) { + let t3 = e3.data.movies; + if (0 === t3.length) return show.error("无数据"), void s2.close(); + this.movies = t3; + const n3 = t3.filter(((e4) => "1" === this.has_cnsub ? e4.has_cnsub : "0" !== this.has_cnsub || !e4.has_cnsub)), a3 = this.getDependency("HitShowPlugin"); + let r3 = a3.markDataListHtml(n3); + i2.html(r3), await a3.initializeRenderedList(), await a3.loadScore(n3), o2 = true; + } else clog.error(e3), i2.html(`

    ${escapeHtml(l3)}

    `), show.error(l3), "JWTVerificationError" === c2 && (await localStorage.removeItem(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o2 = true; + } catch (r2) { + l2 < 3 ? (clog.error(`获取Top数据失败 (第 ${l2} 次重试):`, r2), await new Promise(((e3) => setTimeout(e3, 1e3)))) : (clog.error("所有重试尝试均失败,无法获取Top数据。", r2), i2.html("

    无法加载数据,请稍后再试。

    ")); + } finally { + (o2 || 3 === l2) && s2.close(); + } + } + toolBar(e2, t2, n2) { + "5" === n2.toString() && $(".pagination-next").remove(), $(".pagination-ellipsis").closest("li").remove(), $(".pagination-list li .pagination-link").each((function() { + parseInt($(this).text()) > 5 && $(this).closest("li").remove(); + })); + let years = ""; + for (let year = (/* @__PURE__ */ new Date()).getFullYear(); year >= 2008; year--) years += `${year}`; + const typeLink = /* @__PURE__ */ __name((value, label, type = "video_type") => `${label}`, "typeLink"); + const html = `
    `; + this.$contentBox.append(html), $("button[data-cnsub-value]").on("click", (async (event) => { + const value = $(event.currentTarget).data("cnsub-value"); + this.has_cnsub = value.toString(), $("button[data-cnsub-value]").removeClass("active").attr("aria-pressed", "false"), $(event.currentTarget).addClass("active").attr("aria-pressed", "true"), $(".jhs-top250-filters a").each(((index, element) => { + const link = $(element), url = new URL(link.attr("href"), window.location.origin); + url.searchParams.set("has_cnsub", value), link.attr("href", url.toString()); + })); + const movies = this.movies.filter(((movie) => "1" === this.has_cnsub ? movie.has_cnsub : "0" !== this.has_cnsub || !movie.has_cnsub)), hitShow = this.getDependency("HitShowPlugin"); + $(".movie-list").html(hitShow.markDataListHtml(movies)), await hitShow.initializeRenderedList(), void hitShow.loadScore(movies).catch(((error) => clog.error("Top250 评分加载失败", error))); })); - try { - await initQuickSettingForm(this.getBean.bind(this), this.getSelector.bind(this), ((panel) => { - closeQuickSetting(false), void this.openSettingDialog(panel).catch(((error) => clog.error("完整设置打开失败", error))); - })), sheet.find(".jhs-quick-setting__close").trigger("focus"); - } catch (error) { - closeQuickSetting(), clog.error("快捷设置初始化失败", error), show.error("快捷设置加载失败"); - } } - async openSettingDialog(e2 = "backup-panel", t2) { - const a2 = this.getBean("CoverButtonPlugin"); - const s2 = buildSettingDialogHtml(e2, this.cacheItems, a2); + async checkLogin(e2, t2) { + if (!localStorage.getItem(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); + let n2 = "all", a2 = "", i2 = t2.get("t") || ""; + /^y\d+$/.test(i2) ? (n2 = "year", a2 = i2.substring(1)) : "" !== i2 && (n2 = "video_type", a2 = i2); + let s2 = `/advanced_search?handleTop=1&handleType=${n2}&type_value=${a2}`; + e2 && (e2.ctrlKey || e2.metaKey) ? GM_openInTab(window.location.origin + s2, { + insert: 0 + }) : window.location.href = s2; + } + openLoginDialog({ onSuccess = null } = {}) { layer.open({ type: 1, - title: "设置", - content: s2, - area: utils.getDialogArea("lg"), - scrollbar: false, - success: /* @__PURE__ */ __name(async (e3, n2) => { - $(e3).find(".layui-layer-content").css("position", "relative"), this.renderTaskStatuses(), injectHealthPanel(), injectPluginMgmtPanel(), injectSnapshotPanel(), injectNetworkPanel(), injectResourceSourcesPanel(), await loadSettingForm(this.getBean.bind(this)), await this.loadResourceSettings(), JhsSelect.enhance(e3), this.bindClick(), $(".side-menu-item.active").attr("aria-current", "page"), utils.setupEscClose(n2), t2 && t2(); - this.renderTaskStatuses(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.renderTaskStatuses())); - if (utils.isMobileMode()) { - this.collapseAdvancedTabs(); - } - }, "success"), - end: /* @__PURE__ */ __name(() => { - this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null; - this.getBean("CoverButtonPlugin").enableSvgBtn(); - }, "end") + title: "JavDB", + closeBtn: 1, + area: utils.getResponsiveArea(["360px", "auto"]), + shadeClose: false, + content: ` + +
    +
    + +
    + +
    + +
    + + +
    + `, + success: /* @__PURE__ */ __name((e2, t2) => { + $("#loginBtn").click((function() { + const e3 = $("#username").val(), n2 = $("#password").val(); + if (!e3 || !n2) return void show.error("请输入用户名和密码"); + let a2 = loading(); + (async (e4, t3) => { + let n3 = `${U}/v1/sessions?username=${encodeURIComponent(e4)}&password=${encodeURIComponent(t3)}&device_uuid=04b9534d-5118-53de-9f87-2ddded77111e&device_name=iPhone&device_model=iPhone&platform=ios&system_version=17.4&app_version=official&app_version_number=1.9.29&app_channel=official`, a3 = { + "user-agent": "Dart/3.5 (dart:io)", + "accept-language": "zh-TW", + "content-type": "multipart/form-data; boundary=--dio-boundary-2210433284", + jdsignature: await O() + }; + return await gmHttp.post(n3, null, a3); + })(e3, n2).then((async (e4) => { + let n3 = e4.success; + if (0 === n3) show.error(e4.message); + else { + if (1 !== n3) throw clog.error("登录失败", e4), new Error(e4.message); + { + let n4 = e4.data.token; + await localStorage.setItem(me, await encryptData(n4)), show.ok("登录成功"), layer.close(t2), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; + } + } + })).catch(((e4) => { + clog.error("登录异常:", e4), show.error(e4.message); + })).finally((() => { + a2.close(); + })); + })); + }, "success") }); } - renderTaskStatuses() { - const container = $("#setting-task-status-list"); - if (!container.length) return; - const taskPlugin = this.getBean("TaskPlugin"); - if (!taskPlugin?.getTaskStatusSnapshot) return void container.empty(); - const names = { blacklist: "黑名单", favoriteActress: "演员同步", newVideo: "新作品" }, labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); - container.empty(), ["blacklist", "favoriteActress", "newVideo"].forEach(((name) => { - const snapshot = taskPlugin.getTaskStatusSnapshot(name), row = $('
    '); - row.append($('').text(`${names[name]}:${labels[snapshot.state]}`)), row.append($('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)), container.append(row); - })); + }; + __name(_Top250Plugin, "Top250Plugin"); + var Top250Plugin = _Top250Plugin; + + // src/plugins/favorite/favorite-actresses.js + var _FavoriteActressesPlugin = class _FavoriteActressesPlugin extends BasePlugin { + getName() { + return "FavoriteActressesPlugin"; } - collapseAdvancedTabs() { - const advancedPanels = [ - { id: "health-panel", label: "数据体检", render: renderDataHealthPanel }, - { id: "plugin-mgmt-panel", label: "插件管理", render: renderPluginMgmtPanel }, - { id: "snapshot-panel", label: "恢复点", render: renderSnapshotPanel }, - { id: "network-panel", label: "外部请求", render: renderNetworkPanel } - ]; - const sidebar = $(".jhs-mobile-sidebar"); - const contentParent = $(".content-panel").parent(); - if (!sidebar.length || !contentParent.length) return; - advancedPanels.forEach((p2) => { - sidebar.find(`[data-panel="${p2.id}"]`).remove(); - }); - if (!sidebar.find('[data-panel="more-tools-panel"]').length) { - sidebar.append(''); - } - if ($("#more-tools-panel").length) return; - let subTabsHtml = advancedPanels.map( - (p2, i2) => `` - ).join(""); - let subPanelsHtml = advancedPanels.map( - (p2, i2) => `
    ` - ).join(""); - const wrapperHtml = ` -
    -
    ${subTabsHtml}
    -
    ${subPanelsHtml}
    -
    - `; - contentParent.append(wrapperHtml); - advancedPanels.forEach((p2) => { - const src = $(`#${p2.id}`); - if (src.length) { - src.children().appendTo(`#sub-${p2.id}`); - src.remove(); - $(`#sub-${p2.id}`).attr("data-rendered", "true"); - } - }); - sidebar.on("click", '[data-panel="more-tools-panel"]', function() { - $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); - $("#more-tools-panel").show(), $("#saveBtn").show(), $("#clean-all").addClass("jhs-is-hidden"); - }); - $("#more-tools-panel").on("click", ".jhs-sub-tab", function() { - const target = $(this).data("sub-panel"); - $("#more-tools-panel .jhs-sub-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }); - $(this).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }); - $("#more-tools-panel .jhs-sub-panel").removeClass("active"); - $(`#sub-${target}`).addClass("active"); - if ($(`#sub-${target}`).attr("data-rendered") !== "true") { - $(`#sub-${target}`).attr("data-rendered", "true"); - } - const panel = advancedPanels.find((p2) => p2.id === target); - if (panel && panel.render) { - panel.render(); - } - }).on("keydown", ".jhs-sub-tab", function(e2) { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e2.key)) return; - e2.preventDefault(); - const tabs = $("#more-tools-panel .jhs-sub-tab"), current = tabs.index(this); - const next = e2.key === "Home" ? 0 : e2.key === "End" ? tabs.length - 1 : e2.key === "ArrowRight" ? (current + 1) % tabs.length : (current - 1 + tabs.length) % tabs.length; - tabs.eq(next).trigger("click").trigger("focus"); - }); + async handle() { + this.bindEvent(), await this.highlightActress(), this.replaceActressAvatar(); } - bindClick() { - const settingPlugin = this; - $(".side-menu-item").on("click", (function() { - $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); - const e3 = $(this).data("panel"); - $("#" + e3).show(), "cache-panel" === e3 ? ($("#saveBtn").hide(), $("#clean-all").removeClass("jhs-is-hidden")) : ($("#saveBtn").show(), $("#clean-all").addClass("jhs-is-hidden")), "health-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderDataHealthPanel()), "plugin-mgmt-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel()), "snapshot-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), "network-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel()); - })), $("#importBtn").on("click", ((e3) => importSettingData(showDiffPreview))), $("#exportBtn").on("click", ((e3) => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async (e3) => this.confirmCarNumbers(e3))), $("#webdavBackupBtn").on("click", ((e3) => backupDataByWebDav(this.folderName))), $("#webdavBackupListBtn").on("click", ((e3) => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, showDiffPreview)))), $("#saveBtn").on("click", (() => saveSettingForm(this.getBean.bind(this)))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", ((e3) => { - utils.q(e3, "修复前会自动下载备份,是否继续?", (() => repairDataHealthWithBackup())); - })), $("#pm-clear-log").on("click", (() => { - unsafeWindow.pluginManager.clearErrorLog(), $("#plugin-error-log").text("无错误记录"), show.ok("错误日志已清空"); - })), $("#createSnapshotBtn").on("click", (async () => { - let e3 = loading(); - try { - await storageManager.createSnapshot("手动快照", "manual"), show.ok("快照创建成功"), renderSnapshotPanel(); - } catch (t3) { - clog.error(t3), show.error("创建快照失败: " + t3.message); - } finally { - e3.close(); - } - })), $(".clean-btn").on("click", (async (e3) => { - const t3 = $(e3.currentTarget).data("key"), n3 = this.cacheItems.find(((e4) => e4.key === t3)); - t3 === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : "_circuitBreaker" === t3 ? gmHttp.resetAllCircuitBreakers() : "_domainStats" === t3 ? gmHttp.clearDomainStats() : localStorage.removeItem(t3), show.ok(`${n3.text} 清理成功`), $("#cache-data-display").addClass("jhs-is-hidden"), "jhs_dmm_video" === t3 && localStorage.removeItem("jhs_other_site_dmm"); - })), $("#clean-all").on("click", (async () => { - this.cacheItems.forEach(((e3) => localStorage.removeItem(e3.key))), show.ok("全部缓存已清理"), $("#cache-data-display").addClass("jhs-is-hidden"), localStorage.removeItem("jhs_other_site_dmm"), await storageManager.clearThirdPartyCache(); - })), $(".view-btn").on("click", (async (e3) => { - const t3 = $(e3.currentTarget).data("key"); - let n3; - if (t3 === storageManager.third_party_cache_key) n3 = JSON.stringify(await storageManager.getThirdPartyCache()); - else if ("_circuitBreaker" === t3) n3 = JSON.stringify(gmHttp.getCircuitBreakerStatus()); - else if ("_domainStats" === t3) n3 = JSON.stringify(gmHttp.getDomainStats()); - else n3 = localStorage.getItem(t3); - const a3 = $("#cache-data-display"), i2 = a3.find("pre"); - if (a3.removeClass("jhs-is-hidden"), n3) try { - const e4 = JSON.parse(n3); - i2.text(JSON.stringify(e4, null, 2)); - } catch { - i2.text(n3); + async highlightActress() { + if (!isDetailPage) return; + if (await storageManager.getSetting("enableFavoriteActresses", _) !== _) return; + const e2 = await storageManager.getFavoriteActressList(); + if (!e2 || 0 === e2.length) return; + const t2 = /* @__PURE__ */ new Set(); + e2.forEach(((e3) => { + e3.starId && t2.add(String(e3.starId).trim()); + })), 0 !== t2.size && $(".female").prev().each(((e3, n2) => { + const a2 = $(n2), i2 = a2.attr("href"); + let s2 = null; + if (i2) { + const e4 = (i2.endsWith("/") ? i2.slice(0, -1) : i2).split("/"), t3 = e4[e4.length - 1]; + t3 && (s2 = t3.trim()); } - else i2.text("无数据"); + let o2 = false; + s2 && (o2 = t2.has(s2)), o2 && (a2.addClass("highlighted"), a2.attr("title", "高亮已收藏演员, 可在设置-基础配置中关闭")); + })); + } + async removeActorFromStorage(e2) { + await storageManager.removeFavoriteActress(e2) && (clog.log("移除演员成功"), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(e2) } }))); + } + bindEvent() { + const e2 = /\/actors\/(\w+)\/(collect|uncollect)/; + $(document).on("confirm:complete", 'a[href*="/actors/"][href*="/uncollect"]', (async (t2) => { + const [n2] = t2.detail; + if (!n2) return; + const a2 = $(t2.currentTarget).attr("href").match(e2), i2 = a2 ? a2[1] : null; + i2 && await this.removeActorFromStorage(i2); + })), $("#button-collect-actor").click((async (t2) => { + const n2 = $("#button-collect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; + let i2 = [], s2 = $(".actor-section-name"); + s2.length && s2.text().trim().split(",").forEach(((e3) => { + i2.push(e3.trim()); + })); + let o2 = $(".section-meta:not(:contains('影片'))"); + if (o2.length && o2.text().trim().split(",").forEach(((e3) => { + i2.push(e3.trim()); + })), !i2.length) return void clog.error("获取演员名称失败"); + const r2 = i2[0]; + if (!a2) return void clog.error("无法获取演员ID进行收藏操作。"); + const l2 = ($(".avatar").first().css("background-image") || "").replace(/^url\(["']?|["']?\)$/g, ""), c2 = { + starId: a2, + name: r2, + allName: i2, + avatar: l2 + }; + 1 === await storageManager.addFavoriteActressList([c2]) ? (clog.log(`收藏演员成功: ${r2} (ID: ${a2})`), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(a2) } }))) : clog.log(`收藏演员失败: ${r2} (ID: ${a2})`); + })), $("#button-uncollect-actor").click((async (t2) => { + const n2 = $("#button-uncollect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; + a2 ? await this.removeActorFromStorage(a2) : clog.error("无法获取演员ID进行取消收藏操作。"); })); - const e2 = $("#highlightedTagNumber"), t2 = $("#highlightedTagColor"), n2 = $("#highlightedTagLabel"); - function a2() { - const a3 = e2.val(), i2 = t2.val(); - n2.css("border", `${a3}px solid ${i2}`); + } + async replaceActressAvatar() { + const e2 = this.getActressId(); + if (!e2) return; + const t2 = (await storageManager.getFavoriteActressList()).find(((t3) => t3.starId === e2)); + if (t2 && t2.avatar) { + const e3 = `url('${t2.avatar}')`; + let n2 = $(".avatar").first(); + if (0 === n2.length) { + const e4 = '
    '; + $(".section-columns").prepend(e4), n2 = $(".avatar").first(); + } + if (0 === n2.length) return; + n2.css("background-image").trim().toLowerCase() !== e3.trim().toLowerCase() && (n2.css("background-image", e3), n2.css("background-size", "cover"), n2.css("background-position", "top center"), n2.css("background-repeat", "no-repeat")); } - __name(a2, "a"); - e2.on("input", a2), t2.on("input", a2); - $("#themeMode").on("change", (async function() { - await storageManager.saveSettingItem("themeMode", $(this).val()), applyTheme(); - })); } - async loadResourceSettings() { - const [custom, tags, filters, builtInOverrides, screenshot, cloud] = await Promise.all([this.resourceSettings.getMagnetSources(), this.resourceSettings.getMagnetTagRules(), this.resourceSettings.getMagnetFilterRules(), this.resourceSettings.getBuiltInSources(), this.resourceSettings.getScreenshotSettings(), this.resourceSettings.getCloudSettings()]); - this.resourceState = { custom, tags, filters, builtIn: BUILT_IN_MAGNET_SOURCES.map(((source) => ({ ...source, ...builtInOverrides.find(((item) => item.id === source.id)) || {} }))), screenshot: { mode: screenshot.mode, providers: BUILT_IN_SCREENSHOT_SOURCES.map(((source) => { - const merged = { ...source, ...screenshot.providers.find(((item) => item.id === source.id)) || {} }; - return false === source.implemented ? { ...merged, enabled: false } : merged; - })) } }; - this.renderResourceSettings(); - $("#enable123Offline").prop("checked", cloud.enable123Offline); - $("#enable115Offline").prop("checked", cloud.enable115Offline); - $("#offlineProviderMode").val(cloud.providerMode); - $("#enable115Match").prop("checked", cloud.enable115Match); - $("#enable115LoginRedirect").prop("checked", cloud.enable115LoginRedirect); - $("#oneOneFiveConcurrency").val(cloud.concurrency); - $("#oneOneFiveCacheMinutes").val(cloud.cacheMinutes); - $("#cloud-services-panel").off("change.jhsResource", "input, select").on("change.jhsResource", "input, select", (() => void this.saveCloudSettings())); - $("#resource-sources-panel").off("change.jhsResource", 'input[name="screenshotMode"]').on("change.jhsResource", 'input[name="screenshotMode"]', ((event) => { - this.resourceState.screenshot.mode = event.currentTarget.value; - this.resourceSettings.saveScreenshotSettings(this.resourceState.screenshot); - })); - $("#add-custom-magnet-source").off("click.jhsResource").on("click.jhsResource", (() => this.openSourceDialog())); - $("#add-magnet-tag-rule").off("click.jhsResource").on("click.jhsResource", (() => this.openRuleDialog("tag"))); - $("#add-magnet-filter-rule").off("click.jhsResource").on("click.jhsResource", (() => this.openRuleDialog("filter"))); - $("#export-resource-config").off("click.jhsResource").on("click.jhsResource", (async () => $("#advanced-resource-json").val(JSON.stringify(await this.resourceSettings.exportConfig(), null, 2)).prop("readonly", true))); - $("#edit-resource-config").off("click.jhsResource").on("click.jhsResource", (() => $("#advanced-resource-json").prop("readonly", false).trigger("focus"))); - $("#import-resource-config").off("click.jhsResource").on("click.jhsResource", (async () => { - try { - await this.resourceSettings.importConfig($("#advanced-resource-json").val()); - show.ok("资源配置导入成功"); - await this.loadResourceSettings(); - } catch (error) { - show.error(error.message); + }; + __name(_FavoriteActressesPlugin, "FavoriteActressesPlugin"); + var FavoriteActressesPlugin = _FavoriteActressesPlugin; + + // src/plugins/image-viewer/bus-img.js + var _BusImgPlugin = class _BusImgPlugin extends BasePlugin { + getName() { + return "BusImgPlugin"; + } + handle() { + } + async getVisibleImageItems(e2, t2) { + let n2 = []; + const a2 = document.querySelectorAll(e2); + for (const i2 of a2) { + if (!utils.isHidden(i2)) { + const e3 = i2.querySelector(t2); + if (!(e3 instanceof HTMLImageElement)) continue; + e3.style.removeProperty("height"); + let a3 = e3.offsetHeight; + a3 > 0 && n2.push({ + element: i2, + imgElement: e3, + height: a3 + }); } - })); - $("#car-number-import,#car-number-import-status").off("input.jhsResource change.jhsResource").on("input.jhsResource change.jhsResource", (() => { - this.pendingCarImport = null; - $("#confirm-car-number-import").prop("disabled", true).text("确认导入"); - })); - $("#check-one-one-five-login").off("click.jhsResource").on("click.jhsResource", (() => this.checkOneOneFiveLogin())); + } + return n2; } - renderResourceSettings() { - const card = /* @__PURE__ */ __name((source, custom, kind) => { - const node = $('
    '); - node.append($('
    ').append($("
    ").append($("").text(source.name), source.experimental ? '实验性' : "", $("").text(`${source.type || "截图来源"} · ${source.domain || (() => { - try { - return new URL(source.searchUrlTemplate).hostname; - } catch { - return "未配置域名"; + async logImageHeightsByRow() { + if (await storageManager.getSetting("enableVerticalModel", C) === _) return; + const e2 = this.getSelector().itemSelector, t2 = await storageManager.getSetting("containerColumns", 5), n2 = await this.getVisibleImageItems(e2, "img"); + if (0 === n2.length) return; + const a2 = []; + for (let i2 = 0; i2 < n2.length; i2++) { + const e3 = Math.floor(i2 / t2); + a2[e3] || (a2[e3] = []), a2[e3].push(n2[i2]); + } + a2.forEach(((e3, t3) => { + const n3 = e3.map(((e4) => e4.height)); + if (n3.length < 2) return; + const a3 = Math.min(...n3), i2 = Math.max(...n3); + let s2 = 0; + i2 - a3 > 50 && (s2 = a3, e3.forEach(((e4) => { + if (e4.height !== s2) { + const t4 = `${s2}px`; + e4.imgElement.style.setProperty("height", t4, "important"); } - })()} · 优先级 ${source.priority}`)), $('').prop("checked", source.enabled))); - const actions = $('
    ').append(''); - if (custom) actions.append(''); - node.append(actions); - node.on("change", ".jhs-source-toggle", async (event) => { - source.enabled = event.currentTarget.checked; - "screenshot" === kind ? await this.resourceSettings.saveScreenshotSettings(this.resourceState.screenshot) : custom ? await this.resourceSettings.saveMagnetSources(this.resourceState.custom) : await this.resourceSettings.saveBuiltInSources(this.resourceState.builtIn); - }); - node.on("click", ".jhs-source-test", (event) => this.testSource(event.currentTarget, source.baseUrl || source.searchUrlTemplate?.replace("{keyword}", "test"))); - custom && node.on("click", ".jhs-source-edit", (() => this.openSourceDialog(source))).on("click", ".jhs-source-delete", ((event) => utils.q(event, `确认删除来源「${source.name}」?`, (async () => { - this.resourceState.custom = this.resourceState.custom.filter(((item) => item.id !== source.id)); - await this.resourceSettings.saveMagnetSources(this.resourceState.custom); - this.renderResourceSettings(); - })))); - return node; - }, "card"); - $("#builtin-magnet-source-list").empty().append(this.resourceState.builtIn.map(((source) => card(source, false, "magnet")))); - $("#custom-magnet-source-list").empty().append(this.resourceState.custom.length ? this.resourceState.custom.map(((source) => card(source, true, "magnet"))) : '

    暂无自定义来源

    '); - $("#screenshot-source-list").empty().append(this.resourceState.screenshot.providers.map(((source) => card(source, false, "screenshot")))); - this.resourceState.screenshot.providers.forEach(((source, index) => { - if (false === source.implemented) $("#screenshot-source-list .jhs-resource-card").eq(index).find(".jhs-source-toggle").prop("disabled", true).end().find("strong").after('未实现').end().find(".jhs-source-test").remove(); + }))); })); - $(`input[name="screenshotMode"][value="${this.resourceState.screenshot.mode}"]`).prop("checked", true); - this.renderRules("tag"); - this.renderRules("filter"); } - renderRules(kind) { - const list = "tag" === kind ? this.resourceState.tags : this.resourceState.filters, host = $("#magnet-" + kind + "-rule-list").empty(); - if (!list.length) host.append('

    暂无规则

    '); - list.forEach(((rule) => { - const node = $('
    ').append($("").text(rule.name), $("

    ").text(`${"regex" === rule.type ? "正则" : "包含"}:${rule.pattern}${"tag" === kind ? ` · 权重 ${Number(rule.weight) >= 0 ? "+" : ""}${rule.weight || 0}` : ` · ${"hide" === rule.action ? "隐藏" : `降权 ${rule.penalty || -20}`}`}`), '
    '); - node.on("click", ".jhs-rule-edit", (() => this.openRuleDialog(kind, rule))).on("click", ".jhs-rule-delete", ((event) => utils.q(event, `确认删除规则「${rule.name}」?`, (async () => { - const key = "tag" === kind ? "tags" : "filters"; - this.resourceState[key] = this.resourceState[key].filter(((item) => item.id !== rule.id)); - await ("tag" === kind ? this.resourceSettings.saveMagnetTagRules(this.resourceState[key]) : this.resourceSettings.saveMagnetFilterRules(this.resourceState[key])); - this.renderRules(kind); - })))); - host.append(node); - })); + }; + __name(_BusImgPlugin, "BusImgPlugin"); + var BusImgPlugin = _BusImgPlugin; + + // src/plugins/image-viewer/cover-button.js + var _CoverButtonPlugin = class _CoverButtonPlugin extends BasePlugin { + getName() { + return "CoverButtonPlugin"; } - openSourceDialog(existing = null) { - const fields = ["rowSelector", "titleSelector", "magnetSelector", "sizeSelector", "dateSelector", "seedersSelector", "leechersSelector", "resultsPath", "titlePath", "magnetPath", "hashPath", "sizePath", "datePath", "seedersPath"]; - const content = $(`
    `); - const renderFields = /* @__PURE__ */ __name(() => { - const type = content.find('[name="parserType"]').val(), names = "torrent-table" === type ? fields.slice(0, 7) : "json" === type ? fields.slice(7) : []; - content.find(".jhs-parser-fields").html(names.map(((name) => ``)).join("")); - names.forEach(((name) => content.find(`[name="${name}"]`).val(existing?.[name] || ""))); - }, "renderFields"); - Object.entries(existing || { enabled: true, priority: 100, parserType: "magnet-links" }).forEach(([key, value]) => { - const input = content.find(`[name="${key}"]`); - "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); - }); - content.on("change", '[name="parserType"]', renderFields); - renderFields(); - content.appendTo("body").hide(); - layer.open({ type: 1, title: existing ? "编辑自定义磁力源" : "添加自定义磁力源", content, area: utils.getDialogArea("md"), btn: ["保存", "取消"], success: /* @__PURE__ */ __name(() => content.show(), "success"), end: /* @__PURE__ */ __name(() => content.remove(), "end"), yes: /* @__PURE__ */ __name(async (index) => { - const form = Object.fromEntries(content.find("input,select").map(((i2, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); - try { - const source = buildCustomMagnetSource(form, existing); - const target = existing ? this.resourceState.custom.findIndex(((item) => item.id === existing.id)) : -1; - target >= 0 ? this.resourceState.custom.splice(target, 1, source) : this.resourceState.custom.push(source); - await this.resourceSettings.saveMagnetSources(this.resourceState.custom); - layer.close(index); - this.renderResourceSettings(); - } catch (error) { - show.error(error.message); - } - }, "yes") }); + async initCss() { + return ` + `; } - openRuleDialog(kind, existing = null) { - const isTag = "tag" === kind, content = $(`
    ${isTag ? "" : ''}${isTag ? '' : ''}
    `); - Object.entries(existing || { enabled: true, type: "contains", weight: 0, action: "hide", penalty: -20 }).forEach(([key, value]) => { - const input = content.find(`[name="${key}"]`); - "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); - }); - content.appendTo("body").hide(); - layer.open({ type: 1, title: `${existing ? "编辑" : "新建"}${isTag ? "标签" : "过滤"}规则`, content, area: utils.getDialogArea("sm"), btn: ["保存", "取消"], success: /* @__PURE__ */ __name(() => content.show(), "success"), end: /* @__PURE__ */ __name(() => content.remove(), "end"), yes: /* @__PURE__ */ __name(async (index) => { - const rule = Object.fromEntries(content.find("input,select").map(((i2, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); - rule.id = existing?.id || `rule-${Date.now()}`; - rule.weight = Number(rule.weight); - rule.penalty = Number(rule.penalty); - try { - validateRule(rule); - const key = isTag ? "tags" : "filters", target = existing ? this.resourceState[key].findIndex(((item) => item.id === existing.id)) : -1; - target >= 0 ? this.resourceState[key].splice(target, 1, rule) : this.resourceState[key].push(rule); - await (isTag ? this.resourceSettings.saveMagnetTagRules(this.resourceState[key]) : this.resourceSettings.saveMagnetFilterRules(this.resourceState[key])); - layer.close(index); - this.renderRules(kind); - } catch (error) { - show.error(error.message); - } - }, "yes") }); + async handle() { + window.isListPage && (this.addSvgBtn(), await this.bindClick()); } - async saveCloudSettings() { - await this.resourceSettings.saveCloudSettings({ enable123Offline: $("#enable123Offline").is(":checked"), enable115Offline: $("#enable115Offline").is(":checked"), providerMode: $("#offlineProviderMode").val(), enable115Match: $("#enable115Match").is(":checked"), enable115LoginRedirect: $("#enable115LoginRedirect").is(":checked"), concurrency: Number($("#oneOneFiveConcurrency").val()), cacheMinutes: Number($("#oneOneFiveCacheMinutes").val()) }); + /** 构建卡片工具和三个卡片内 popover。 */ + buildToolBox() { + return ` +
    + + +
    + + +
    +
    + + +
    +
    + + +
    +
    `; } - async checkOneOneFiveLogin() { - const badge = $("#one-one-five-state").text("检测中"); - try { - badge.text(await new OneOneFiveClient().checkLogin() ? "已登录" : "未登录"); - } catch { - badge.text("检测失败"); - } + async addSvgBtn(items = null) { + (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((element) => { + const item = $(element); + if (item.find(".tool-box").length || l && item.find(".avatar-box").length) return; + const host = r ? item.find(".tags").first() : item.find(".photo-info").first(); + host.length && host.append(this.buildToolBox()); + })), this.enableSvgBtn(items); } - async testSource(button, url) { - if (!url) return show.info("本站来源无需跨站测试"); - const node = $(button).prop("disabled", true), badge = node.siblings(".jhs-source-test-state").length ? node.siblings(".jhs-source-test-state") : $('').insertAfter(node); - badge.text("检测中"); - try { - const response = await gmHttp.get(url); - badge.text(response ? "200 · 可解析" : "空响应"); - } catch (error) { - badge.text(error?._cfBlocked ? "Cloudflare 拦截" : error?.status === 404 ? "404" : error?._circuitBreaker ? "熔断" : "请求失败"); - } finally { - node.prop("disabled", false).text("测试"); - } + async enableSvgBtn(items = null) { + const e2 = await storageManager.getSetting(), { enableScreenSvg: t2 = _, enableVideoSvg: n2 = _, enableHandleSvg: a2 = _, enableSiteSvg: i2 = _, enableCopySvg: s2 = _ } = e2; + const scope = items ? $(items) : $(document); + [{ selector: ".screenSvg", enabled: t2 }, { selector: ".videoSvg", enabled: n2 }, { selector: ".handleSvg", enabled: a2 }, { selector: ".siteSvg", enabled: i2 }, { selector: ".copySvg", enabled: s2 }].forEach((({ selector: e3, enabled: t3 }) => { + scope.find(e3).toggle(t3 === _); + })); } - previewCarNumbers() { - const parsed = parseCarNumberText($("#car-number-import").val()), actionType = $("#car-number-import-status").val(); - this.pendingCarImport = actionType && parsed.values.length ? { ...parsed, actionType } : null; - $("#car-number-import-preview").text(`识别 ${parsed.recognized} 条 · 有效 ${parsed.values.length} · 重复 ${Math.max(0, parsed.recognized - parsed.values.length - parsed.invalid.length)} · 无效 ${parsed.invalid.length}${parsed.invalid.length ? ` · 异常示例:${parsed.invalid.slice(0, 5).join("、")}` : ""}`); - $("#confirm-car-number-import").prop("disabled", !this.pendingCarImport).text(this.pendingCarImport ? `确认导入 ${parsed.values.length} 条` : "确认导入"); - if (!actionType) show.info("请选择导入状态"); + closeCardMenus(focus = false) { + const openMenus = $(".jhs-card-menu.is-open"), triggers = openMenus.siblings(".jhs-card-menu-trigger"); + openMenus.removeClass("is-open"), triggers.attr("aria-expanded", "false"), focus && triggers.first().trigger("focus"); } - async confirmCarNumbers(event) { - if (!this.pendingCarImport) return show.info("请先解析预览"); - const pending = this.pendingCarImport; - utils.q(event, `确认导入 ${pending.values.length} 条记录?`, (async () => { - const existing = new Map((await storageManager.getCarList()).map(((item) => [normalizeCarNum(item.carNum), item]))), summary = { added: 0, updated: 0, failed: 0 }, flag = legacyActionToFlag(pending.actionType); - for (const rawCarNum of pending.values) { - const carNum = normalizeCarNum(rawCarNum); - try { - const current = existing.get(carNum); - await stateService.patch(carNum, { [flag]: true }, { type: "manual-car-number-import", record: { carNum, url: current?.url || buildFallbackCarUrl(carNum), names: current?.names || "", publishTime: current?.publishTime || "" } }); - current ? summary.updated++ : summary.added++; - } catch (error) { - summary.failed++; - clog.warn(`番号 ${carNum} 导入失败`, error); - } + async bindClick() { + this.getSelector(); + const e2 = this.getDependency("ListPagePlugin"); + $(document).on("click", ".jhs-card-menu-trigger", ((event) => { + event.preventDefault(), event.stopPropagation(); + const trigger = $(event.currentTarget), menu = trigger.siblings(".jhs-card-menu"), open = !menu.hasClass("is-open"); + this.closeCardMenus(), menu.toggleClass("is-open", open), trigger.attr("aria-expanded", String(open)), open && menu.children().first().trigger("focus"); + })).on("keydown", ".jhs-card-menu [role='menuitem']", ((event) => { + const menu = $(event.currentTarget).closest(".jhs-card-menu"), items = menu.find("[role='menuitem']"), index = items.index(event.currentTarget); + if ("Escape" === event.key) return event.preventDefault(), this.closeCardMenus(true); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); + })).on("click", ((event) => { + $(event.target).closest(".more-tools-container").length || this.closeCardMenus(); + })), $(document).on("click", ".videoSvg", ((t3) => { + t3.preventDefault(), $('.videoSvg[title!="播放视频"]').each(((t4, n4) => { + const a4 = $(n4); + let i3 = a4.closest(".item"), s3 = i3.find("img"), { carNum: o2 } = e2.findCarNumAndHref(i3); + this.showImg(a4, s3, o2), a4.html(this.videoSvg).attr({ title: "播放视频", "aria-label": "播放视频" }); + })); + const n3 = $(t3.target).closest(".item"), a3 = n3.find(".videoSvg"); + if ("播放视频" === a3.attr("title")) { + a3.html(this.recoveryVideoSvg).attr({ title: "切回封面", "aria-label": "切回封面" }); + const { carNum: t4 } = e2.findCarNumAndHref(n3); + let i3 = n3.find("img"); + if (!i3.length) return void show.error("没有找到图片"); + void this.showVideo(a3, i3, t4).catch(((error) => clog.error("卡片预览视频打开失败", error))); } - this.pendingCarImport = null; - $("#confirm-car-number-import").prop("disabled", true); - show.ok(`导入完成:新增 ${summary.added},更新 ${summary.updated},失败 ${summary.failed}`); + })), $(document).on("click", ".screenSvg", (async (t3) => { + t3.preventDefault(); + let n3 = loading(); + try { + const a3 = $(t3.currentTarget).closest(".item"); + let { carNum: i3 } = e2.findCarNumAndHref(a3); + i3 = i3.replace("FC2-", ""); + const s3 = await this.getDependency("ScreenShotPlugin").getScreenshot(i3); + n3.close(), showImageViewer(s3); + } catch (a3) { + clog.error("图片预览出错:", a3), show.error("图片预览出错:" + a3); + } finally { + n3.close(); + } + })), $(document).on("click", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", ((t3) => { + t3.preventDefault(), t3.stopPropagation(); + try { + const n3 = $(t3.currentTarget), a3 = n3.closest(".item"), { carNum: i3, url: s3, publishTime: o2, fc2Source } = e2.findCarNumAndHref(a3), r2 = /* @__PURE__ */ __name(async (t4) => { + try { + let n4 = await e2.parseActressName(s3); + const flag = legacyActionToFlag(t4); + if (!flag) throw new Error("不支持的状态操作"); + await stateService.patch(i3, { [flag]: true }, { type: "list-card-state", record: { carNum: i3, url: s3, names: n4, publishTime: o2, fc2Source } }), show.ok("操作成功"); + } catch (r3) { + clog.error("保存操作失败:", r3), show.error("操作失败"); + } + }, "r"); + n3.hasClass("filterBtn") ? utils.q(t3, `是否屏蔽${i3}?`, (() => r2(d))) : n3.hasClass("favoriteBtn") ? void r2(h) : n3.hasClass("hasDownBtn") ? void r2(g) : n3.hasClass("hasWatchBtn") && void r2(p), this.closeCardMenus(); + } catch (t4) { + clog.error("按钮点击处理失败:", t4); + } + })); + const t2 = this.getDependency("OtherSitePlugin"), n2 = await t2.getMissAvUrl(), a2 = await t2.getjableUrl(), i2 = await t2.getAvgleUrl(), s2 = await t2.getAv123Url(); + $(this.getSelector().itemSelector).each(((t3, o2) => { + const r2 = $(o2), { carNum: l2 } = e2.findCarNumAndHref(r2); + r2.find(".site-jable").attr({ href: `${a2}/search/${l2}/`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-avgle").attr({ href: `${i2}/vod/search.html?wd=${l2}`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-miss-av").attr({ href: `${n2}/search/${l2}`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-123-av").attr({ href: `${s2}/cn/search?keyword=${encodeURIComponent(l2)}`, target: "_blank", rel: "noopener noreferrer" }); + })); + $(document).on("click", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", ((t3) => { + try { + t3.preventDefault(), t3.stopPropagation(); + const o2 = $(t3.currentTarget), r2 = o2.closest(".item"), { carNum: l2 } = e2.findCarNumAndHref(r2); + let c2 = null; + o2.hasClass("site-jable") ? c2 = `${a2}/search/${l2}/` : o2.hasClass("site-avgle") ? c2 = `${i2}/vod/search.html?wd=${l2}` : o2.hasClass("site-miss-av") ? c2 = `${n2}/search/${l2}` : o2.hasClass("site-123-av") && (c2 = `${s2}/cn/search?keyword=${encodeURIComponent(l2)}`), t3 && (t3.ctrlKey || t3.metaKey) ? GM_openInTab(c2, { insert: 0 }) : window.open(c2), this.closeCardMenus(); + } catch (t4) { + clog.error("站点按钮处理失败:", t4); + } + })), $(document).on("click", ".titleSvg, .carNumSvg, .downSvg", ((t3) => { + t3.preventDefault(), t3.stopPropagation(); + const n3 = $(t3.currentTarget).closest(".item"), { carNum: a3, title: i3 } = e2.findCarNumAndHref(n3), s3 = n3.find(l ? ".photo-frame img" : ".cover img"); + $(t3.currentTarget).hasClass("titleSvg") ? utils.copyToClipboard("标题", i3) : $(t3.currentTarget).hasClass("carNumSvg") ? utils.copyToClipboard("番号", a3) : $(t3.currentTarget).hasClass("downSvg") && fetch(s3.attr("src")).then(((e3) => e3.blob())).then(((e3) => utils.download(e3, a3 + " " + i3 + ".jpg"))), this.closeCardMenus(); })); } + showImg(e2, t2, n2) { + e2.html(this.videoSvg).attr({ title: "播放视频", "aria-label": "播放视频" }); + let a2 = $(`#${`${n2}_preview_video`}`); + a2.length > 0 && (a2[0].pause(), a2.parent().hide()), t2.show(), t2.removeClass("loading"), t2.next(".loading-spinner").remove(); + } + async showVideo(e2, t2, n2) { + const a2 = `${n2}_preview_video`; + let i2 = $(`#${a2}`); + if (i2.length > 0) return i2.parent().show(), await safePlay(i2[0], { + context: "列表卡片预览", + notify: true + }), void t2.hide(); + t2.addClass("loading"), t2.after('
    '); + const s2 = t2.attr("src"), { sources: o2, error: previewError } = await fetchDmmPreview(n2); + if (!o2) return show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未解析到视频"), void this.showImg(e2, t2, n2); + let r2 = await storageManager.getSetting("videoQuality"); + r2 = Z(Object.keys(o2), r2); + let c2 = o2[r2], d2 = ` +
    + +
    `; + l && (d2 = `
    `), t2.parent().append(d2), t2.hide(), t2.removeClass("loading"), t2.next(".loading-spinner").remove(), i2 = $(`#${a2}`); + let h2 = i2[0]; + h2.load(), h2.muted = false, await safePlay(h2, { + context: "列表卡片预览", + notify: true, + message: "REGION_BLOCKED" === previewError?.code ? previewError.message : "当前视频源无法播放" + }), i2.trigger("focus"); + } }; - __name(_SettingPlugin, "SettingPlugin"); - var SettingPlugin = _SettingPlugin; - var ENCRYPTION_SALT = "x7k9p3"; - async function getEncryptionKey() { - const enc = new TextEncoder(); - const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(ENCRYPTION_SALT + ".jhs.v1"), { - name: "PBKDF2" - }, false, ["deriveKey"]); - return crypto.subtle.deriveKey({ - name: "PBKDF2", - salt: enc.encode("jhs-backup"), - iterations: 1e5, - hash: "SHA-256" - }, keyMaterial, { - name: "AES-GCM", - length: 256 - }, false, ["encrypt", "decrypt"]); - } - __name(getEncryptionKey, "getEncryptionKey"); - function arrayBufferToBase64(e2) { - const t2 = new Uint8Array(e2), n2 = 32768; - let a2 = ""; - for (let i2 = 0; i2 < t2.length; i2 += n2) a2 += String.fromCharCode.apply(null, t2.subarray(i2, i2 + n2)); - return btoa(a2); - } - __name(arrayBufferToBase64, "arrayBufferToBase64"); - function base64ToArrayBuffer(e2) { - const t2 = atob(e2), n2 = new Uint8Array(t2.length); - for (let a2 = 0; a2 < t2.length; a2++) n2[a2] = t2.charCodeAt(a2); - return n2; - } - __name(base64ToArrayBuffer, "base64ToArrayBuffer"); - async function encryptData(e2) { - const t2 = await getEncryptionKey(), n2 = crypto.getRandomValues(new Uint8Array(12)), a2 = new TextEncoder(), i2 = await crypto.subtle.encrypt({ - name: "AES-GCM", - iv: n2 - }, t2, a2.encode(e2)), s2 = new Uint8Array(n2.length + i2.byteLength); - return s2.set(n2), s2.set(new Uint8Array(i2), n2.length), arrayBufferToBase64(s2); + __name(_CoverButtonPlugin, "CoverButtonPlugin"); + var CoverButtonPlugin = _CoverButtonPlugin; + + // src/core/movie-identity.js + var SIMPLE_CAR_PREFIXES2 = /* @__PURE__ */ new Set(["ABC", "ABP", "ADN", "ATID", "BF", "CAWD", "DLDSS", "DVAJ", "FSDSS", "HEYZO", "HMN", "IPX", "IPZZ", "JUQ", "JUL", "JUX", "MEYD", "MIAA", "MIDE", "MIDV", "MIMK", "MIRD", "NIMA", "PRED", "RBD", "SDDE", "SONE", "SSIS", "SSNI", "STARS", "URE", "VEC", "WAAA", "WANZ", "XVSR"]); + function normalizeMovieCarNum(value) { + if (typeof value !== "string") return null; + let carNum = value.trim(); + if (!carNum || ["undefined", "null"].includes(carNum.toLowerCase())) return null; + carNum = carNum.normalize("NFKC").replace(/[‐‑‒–—―﹘﹣-]/g, "-").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toUpperCase(); + const match = carNum.match(/^([A-Z]{2,8})(\d{2,7})$/); + return match && SIMPLE_CAR_PREFIXES2.has(match[1]) ? `${match[1]}-${match[2]}` : carNum || null; } - __name(encryptData, "encryptData"); - async function decryptData(e2) { - const t2 = await getEncryptionKey(), n2 = base64ToArrayBuffer(e2), a2 = n2.slice(0, 12), i2 = n2.slice(12), s2 = await crypto.subtle.decrypt({ - name: "AES-GCM", - iv: a2 - }, t2, i2); - return new TextDecoder().decode(s2); + __name(normalizeMovieCarNum, "normalizeMovieCarNum"); + + // src/integrations/javstore/parser.js + function normalizeJavStoreAssetUrl(value, baseUrl = "https://javstore.net") { + if (!value) return null; + try { + const url = new URL(String(value), baseUrl), hostname = url.hostname.toLowerCase(); + if (!["http:", "https:"].includes(url.protocol)) return null; + "http:" === url.protocol && ("javstore.net" === hostname || hostname.endsWith(".javstore.net")) && (url.protocol = "https:"); + return url.href; + } catch { + return null; + } } - __name(decryptData, "decryptData"); - var CREDENTIAL_PREFIX = "AES:"; - async function encryptCredential(e2) { - return e2 && !e2.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(e2) : e2; + __name(normalizeJavStoreAssetUrl, "normalizeJavStoreAssetUrl"); + function parseJavStoreSearch($searchPage, carNum, baseUrl = "https://javstore.net") { + const normalizedCarNum = normalizeMovieCarNum(carNum); + if (!normalizedCarNum) return []; + const wrap = ( + /** @type {any} */ + globalThis.jQuery ?? /** @type {any} */ + globalThis.$ ?? $searchPage.constructor + ); + return $searchPage.find('a[href$="-pn.html"]').filter(((index, element) => wrap(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()))).map(((index, element) => new URL(wrap(element).attr("href"), baseUrl).href)).get(); } - __name(encryptCredential, "encryptCredential"); - async function decryptCredential(e2) { - return e2 && e2.startsWith(CREDENTIAL_PREFIX) ? await decryptData(e2.slice(CREDENTIAL_PREFIX.length)) : e2; + __name(parseJavStoreSearch, "parseJavStoreSearch"); + function parseJavStorePreview($detailPage, detailUrl) { + const wrap = ( + /** @type {any} */ + globalThis.jQuery ?? /** @type {any} */ + globalThis.$ ?? $detailPage.constructor + ); + const isPreviewLink = /* @__PURE__ */ __name((index, element) => "CLICK HERE!" === wrap(element).text().trim(), "isPreviewLink"); + const previewHref = $detailPage.find("a").filter(isPreviewLink).first().attr("href"); + if (!previewHref) return null; + const previewUrl = normalizeJavStoreAssetUrl(previewHref, detailUrl); + return previewUrl ? previewUrl.replace(".th", "") : null; } - __name(decryptCredential, "decryptCredential"); - var _BusPreviewVideoPlugin = class _BusPreviewVideoPlugin extends BasePlugin { - getName() { - return "BusPreviewVideoPlugin"; + __name(parseJavStorePreview, "parseJavStorePreview"); + + // src/plugins/image-viewer/screenshot-provider-registry.js + var _ScreenshotProviderRegistry = class _ScreenshotProviderRegistry { + constructor(providers = []) { + this.providers = /* @__PURE__ */ new Map(); + providers.forEach(((provider) => this.register(provider))); } - async initCss() { - return "\n .bus-preview-modal { position:fixed; inset:0; z-index:var(--jhs-z-modal); display:flex; align-items:center; justify-content:center; visibility:hidden; opacity:0; background:rgba(0,0,0,.95); transition:opacity var(--jhs-motion-base) var(--jhs-ease); }\n .bus-preview-modal.is-open { visibility:visible; opacity:1; }\n .bus-preview-modal-content { position:relative; display:flex; max-width:95%; max-height:95%; flex-direction:column; align-items:center; gap:var(--jhs-space-3); }\n .video-player-wrapper { position:relative; width:80vw; max-width:100%; max-height:85vh; aspect-ratio:16/9; background:#000; }\n .video-player-wrapper #preview-video { position:absolute; inset:0; }\n "; + register(provider) { + if (!provider?.id || !provider.name || "function" !== typeof provider.getScreenshot) throw new TypeError("Invalid screenshot provider"); + this.providers.set(provider.id, { enabled: true, priority: 100, ...provider }); + return this; } - initModal() { - if (0 === $("#bus-preview-modal").length) { - $("body").append('\n
    \n
    \n
    \n
    \n '); - const e2 = $("#bus-preview-modal"); - e2.on("click", ((e3) => { - "bus-preview-modal" === e3.target.id && this.closeVideoModal(); - })), $(document).on("keydown", ((t2) => { - "Escape" === t2.key && e2.hasClass("is-open") && this.closeVideoModal(); - })); + get(id) { + return this.providers.get(id) || null; + } + getEnabledProviders() { + return [...this.providers.values()].filter(((provider) => provider.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); + } + async first(carNum) { + for (const provider of this.getEnabledProviders()) { + try { + const result = await provider.getScreenshot(carNum); + if (result?.url) return result; + } catch (error) { + clog.warn(`截图源 ${provider.name} 请求失败`, error); + } } + return null; } - closeVideoModal() { - const e2 = $("#preview-video"); - e2.length > 0 && e2[0].pause(), $("#bus-preview-modal").removeClass("is-open"); + }; + __name(_ScreenshotProviderRegistry, "ScreenshotProviderRegistry"); + var ScreenshotProviderRegistry = _ScreenshotProviderRegistry; + + // src/plugins/image-viewer/screenshot.js + var _ScreenShotPlugin = class _ScreenShotPlugin extends BasePlugin { + constructor() { + super(...arguments), this.providerRegistry = new ScreenshotProviderRegistry(); + } + async initializeProviders() { + const settings = await new ResourceSettingsService().getScreenshotSettings(), configured = /* @__PURE__ */ __name((id) => settings.providers.find(((provider) => provider.id === id)) || {}, "configured"); + this.providerRegistry = new ScreenshotProviderRegistry([ + { id: "javstore", name: "JavStore", priority: 10, getScreenshot: /* @__PURE__ */ __name((carNum) => this.getCachedProviderScreenshot("javstore", carNum, (() => this.getJavStoreScreenShot(carNum))), "getScreenshot") }, + { id: "projectjav", name: "ProjectJav", enabled: false, priority: 20, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") }, + { id: "18av", name: "18AV", enabled: false, priority: 30, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") } + ].map(((provider) => ({ ...provider, ...configured(provider.id), enabled: !["projectjav", "18av"].includes(provider.id) && (configured(provider.id).enabled ?? provider.enabled ?? true), getScreenshot: provider.getScreenshot })))); + return settings.mode; + } + getName() { + return "ScreenShotPlugin"; + } + async initCss() { + return ``; } async handle() { - if (!isDetailPage) return; - this.initModal(); - const e2 = $("#sample-waterfall .sample-box .photo-frame img:first").attr("src"), t2 = $(` - `); - $("#sample-waterfall").prepend(t2); - "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum).catch(((e3) => clog.warn("预加载 DMM 失败", e3))); - let n2 = false, a2 = $(".preview-video-container"); - a2.on("click", (async (e3) => { - if (e3.preventDefault(), e3.stopPropagation(), n2) show.info("正在加载中, 勿重复点击"); - else { - n2 = true; - try { - await this.handleVideo(); - } finally { - n2 = false; - } - } - })), window.location.href.includes("autoPlay=1") && a2.trigger("click"); + await this.loadScreenShot(); } - async handleVideo() { - const e2 = $("#bus-preview-modal"), t2 = e2.find(".bus-preview-modal-content"); - let n2 = $("#preview-video"); - if (n2.length > 0) return e2.addClass("is-open"), void await safePlay(n2[0], { - context: "JavBus 预览视频", - notify: true - }); - let a2 = this.getPageInfo().carNum; - const { sources: i2, error: previewError } = await fetchDmmPreview(a2); - i2 && 0 !== Object.keys(i2).length ? (await this.createVideoPlayerAndControls(i2, t2), n2 = $("#preview-video"), n2.length > 0 ? (e2.addClass("is-open"), await safePlay(n2[0], { - context: "JavBus 预览视频", - notify: true, - message: "REGION_BLOCKED" === previewError?.code ? previewError.message : "当前视频源无法播放" - })) : show.error("视频播放器创建失败。")) : show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未找到可用的视频源。"); + async loadScreenShot() { + if (!isDetailPage) return; + if ("yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return; + let e2 = this.getPageInfo().carNum; + r && $(".preview-images .tile-item").first().before('
    正在加载缩略图
    '), l && $("#sample-waterfall .sample-box:first").after('
    正在加载缩略图
    '); + const mode = await this.initializeProviders(); + if ("manual" === mode) return $(".screen-container").text("请选择截图来源"), void this.renderProviderTabs(e2); + try { + const t2 = await this.getScreenshotFromInitializedProviders(e2); + t2 ? (this.addImg("缩略图", t2), clog.log("加载缩略图:", t2)) : this.showErrorFallback(e2, null); + } catch (t2) { + this.showErrorFallback(e2, t2); + } } - async createVideoPlayerAndControls(e2, t2) { - let n2 = await storageManager.getSetting("videoQuality"); - n2 = Z(Object.keys(e2), n2); - let a2 = e2[n2]; - t2.html(` -
    - -
    -
    -
    - `); - const i2 = $("#preview-video"), s2 = i2.find("source"), o2 = t2.find(".jhs-video-quality-list"); - if (!i2.length || !s2.length) return; - const r2 = i2[0], l2 = localStorage.getItem("jhs_videoMuted"); - r2.muted = !l2 || "yes" === l2, i2.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (function() { - localStorage.setItem("jhs_videoMuted", r2.muted ? "yes" : "no"); - })); - let c2 = ""; - L.forEach(((t3) => { - let a3 = e2[t3.quality]; - if (a3) { - const e3 = n2 === t3.quality; - c2 += ` - - `; - } - })), o2.html(c2); - const d2 = o2.find(".jhs-video-quality-btn"); - o2.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (e3) => { + renderProviderTabs(carNum) { + const tabs = $('
    '); + this.providerRegistry.providers.forEach(((provider) => tabs.append($('').prop("disabled", !provider.enabled).attr("data-provider", provider.id).text(provider.name)))); + $(".screen-container").before(tabs); + tabs.on("click", "button:not(:disabled)", (async (event) => { + const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); + $(".screen-container").text(`${provider.name} 加载中…`); try { - const t3 = $(e3.currentTarget); - if (t3.hasClass("active")) return; - let n3 = t3.attr("data-video-src"); - s2.attr("src", n3); - const a3 = r2.currentTime; - r2.load(), r2.currentTime = a3, await safePlay(r2, { - context: "JavBus 画质切换", - notify: true - }) && (d2.removeClass("active").attr("aria-pressed", "false"), t3.addClass("active").attr("aria-pressed", "true")); - } catch (t3) { - clog.error("切换画质失败:", t3); + const result = await provider.getScreenshot(carNum); + result?.url ? this.addImg(`${provider.name} 缩略图`, result.url) : $(".screen-container").text(`${provider.name} 无结果`); + } catch (error) { + $(".screen-container").text(`${provider.name} 请求失败`); + clog.error("截图源请求失败", error); } })); } - }; - __name(_BusPreviewVideoPlugin, "BusPreviewVideoPlugin"); - var BusPreviewVideoPlugin = _BusPreviewVideoPlugin; - var _SearchByImagePlugin = class _SearchByImagePlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "siteList", [{ - name: "Google旧版", - url: "https://www.google.com/searchbyimage?image_url={占位符}&client=firefox-b-d", - ico: "https://www.google.com/favicon.ico" - }, { - name: "Google", - url: "https://lens.google.com/uploadbyurl?url={占位符}", - ico: "https://www.google.com/favicon.ico" - }, { - name: "Yandex", - url: "https://yandex.ru/images/search?rpt=imageview&url={占位符}", - ico: "https://yandex.ru/favicon.ico" - }]), i(this, "isUploading", false); + async getScreenshot(e2) { + e2 = normalizeCarNum(e2); + if (!e2) throw clog.warn("跳过缩略图解析:番号不可用"), new Error("缩略图番号不可用"); + await this.initializeProviders(); + return this.getScreenshotFromInitializedProviders(e2); } - getName() { - return "SearchByImagePlugin"; + async getScreenshotFromInitializedProviders(e2) { + e2 = normalizeCarNum(e2); + if (!e2) throw new Error("缩略图番号不可用"); + localStorage.removeItem("jhs_screenShot"); + let n2; + try { + n2 = await this.providerRegistry.first(e2); + } catch (i2) { + throw clog.error("获取缩略图资源失败:", n2, i2), i2; + } + if (!n2) return null; + let url = n2.url, a2 = url.indexOf("https://"); + return -1 !== a2 && (url = url.substring(a2)), clog.log(`缩略图获取成功 (${n2.source}):`, url), url; } - async initCss() { - return "\n \n "; + async getCachedProviderScreenshot(provider, carNum, loader) { + const value = await storageManager.cachedRequest(`screenshot:${provider}:${carNum}`, CACHE_TTL.screenshot, (async () => { + const loadedUrl = await loader(), url2 = "javstore" === provider ? normalizeJavStoreAssetUrl(loadedUrl) : loadedUrl; + return url2 ? { __jhsCacheTtl: CACHE_TTL.screenshot, data: { url: url2 } } : { __jhsCacheTtl: CACHE_TTL.screenshotNegative, data: { miss: true } }; + })); + if (!value || value.miss) return null; + const cachedUrl = "string" === typeof value ? value : value.url, url = "javstore" === provider ? normalizeJavStoreAssetUrl(cachedUrl) : cachedUrl; + return url ? { url, source: provider, detailUrl: null } : null; } - open(e2) { - layer.open({ - type: 1, - title: "以图识图", - content: '\n
    \n
    \n
    \n

    拖拽图片到此处 或 点击按钮选择图片

    \n

    也可以直接 Ctrl+V 粘贴图片或 图片URL

    \n
    \n \n \n
    \n \n
    \n \n
    \n \n
    \n \n
    \n \n \n
    \n \n
    \n

    请选择识图网站:

    \n
    \n
    \n
    \n \n
    \n ', - area: utils.isMobileMode() ? utils.getResponsiveArea() : ["40%", "80%"], - success: /* @__PURE__ */ __name(async (t2) => { - this.initEventListeners(), e2 && e2(); - }, "success"), - end: /* @__PURE__ */ __name(() => { - $(document).off("paste.searchImg"); - }, "end") - }); + async getJavStoreScreenShot(e2) { + const t2 = `https://javstore.net/search?q=${encodeURIComponent(e2)}`; + clog.debug("JavStore 搜索地址:", t2); + let n2 = await gmHttp.get(t2, {}, {}, false, { ignoreNotFound: true }); + if (!n2) return clog.debug("JavStore 搜索页未获取:", t2), null; + const a2 = utils.htmlTo$dom(n2); + const i2 = parseJavStoreSearch(a2, e2); + if (!i2.length) return clog.debug("JavStore, 查询番号无结果:", t2), null; + for (const e3 of i2) { + const t3 = e3; + clog.debug("JavStore 候选详情:", t3); + const n3 = await gmHttp.get(t3, {}, {}, false, { ignoreNotFound: true }); + if (!n3) { + clog.debug("JavStore 详情页未获取:", t3); + continue; + } + const a3 = parseJavStorePreview(utils.htmlTo$dom(n3), t3); + if (!a3) { + clog.debug("JavStore 详情页没有 CLICK HERE!:", t3); + continue; + } + return clog.debug("JavStore 预览图:", a3), a3; + } + return clog.debug("JavStore, 所有候选均无有效预览图:", t2), null; } - initEventListeners() { - const e2 = $("#upload-area"), t2 = $("#image-file"), n2 = $("#select-image-btn"), a2 = $("#preview-area"), i2 = $("#preview-image"), s2 = $("#action-btns"), o2 = $("#handle-btn"), r2 = $("#cancel-btn"), l2 = $("#url-input-container"), c2 = $("#image-url"), d2 = $("#search-results"), h2 = $("#search-img-site-btns-container"); - e2.on("dragover", ((t3) => { - t3.preventDefault(), e2.addClass("highlight"); - })).on("dragleave", (() => { - e2.removeClass("highlight"); - })).on("drop", ((t3) => { - t3.preventDefault(), e2.removeClass("highlight"), t3.originalEvent.dataTransfer.files && t3.originalEvent.dataTransfer.files[0] && (this.handleImageFile(t3.originalEvent.dataTransfer.files[0]), this.resetSearchUI()); - })), n2.on("click", (() => { - t2.trigger("click"); - })), t2.on("change", ((e3) => { - e3.target.files && e3.target.files[0] && (this.handleImageFile(e3.target.files[0]), this.resetSearchUI()); - })), $(document).on("paste.searchImg", (async (e3) => { - const t3 = e3.originalEvent.clipboardData.items; - for (let a3 = 0; a3 < t3.length; a3++) if (-1 !== t3[a3].type.indexOf("image")) { - const e4 = t3[a3].getAsFile(); - return this.handleImageFile(e4), void this.resetSearchUI(); + addImg(e2, t2) { + const url = normalizeJavStoreAssetUrl(t2); + url && (r && $(".screen-container").html(`${e2}`), l && $(".screen-container").html(`
    ${e2}
    `), $(".screen-container").on("click", ((e3) => { + e3.stopPropagation(), e3.preventDefault(), showImageViewer(e3.currentTarget); + }))); + } + showErrorFallback(e2, t2) { + var n2; + clog.error("获取缩略图失败:", null == (n2 = null == t2 ? void 0 : t2.message) ? void 0 : n2.substring(0, 100)); + const a2 = `jhs-screenshot-message${l ? " jhs-screenshot-message--bus" : ""}`; + if (!(e2 = normalizeCarNum(e2))) return void $(".screen-container").empty().append($("
    ").addClass(a2).text("无法获取番号,缩略图未加载")); + const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(e2)}`; + $(".screen-container").html(`
    获取缩略图失败

    点击重试 或 前往确认`).off("click", ".retry-link").off("click", ".check-link").on("click", ".retry-link", (async (t3) => { + t3.stopPropagation(), t3.preventDefault(), $(".screen-container").html(`
    正在重新加载...
    `); + try { + const t4 = await this.getScreenshot(e2); + this.addImg("缩略图", t4); + } catch (n3) { + this.showErrorFallback(e2, n3); } - const n3 = e3.originalEvent.clipboardData.getData("text"); - n3 && utils.isUrl(n3) && (l2.show(), c2.val(n3), i2.attr("src", n3), a2.show(), this.resetSearchUI()); - })), o2.on("click", (async () => { - const e3 = i2.attr("src"); - if (e3) { - if (!this.isUploading) { - this.isUploading = true; - try { - const t3 = await this.searchByImage(e3); - s2.hide(), d2.show(), h2.empty(); - const n3 = "jhs_selectedSites", a3 = JSON.parse(localStorage.getItem(n3) || "{}"); - this.siteList.forEach(((e4) => { - const n4 = e4.url.replace("{占位符}", encodeURIComponent(t3)), i3 = false !== a3[e4.name]; - h2.append(` - - - ${e4.name} - ${e4.name} - - `); - })), h2.on("change", ".site-checkbox", (function() { - const e4 = $(this).data("site-name"); - a3[e4] = $(this).is(":checked"), localStorage.setItem(n3, JSON.stringify(a3)); - })), h2.show(); - } finally { - this.isUploading = false; - } + })).on("click", ".check-link", (async (t3) => { + t3.stopPropagation(), t3.preventDefault(), window.open(searchUrl, "_blank"); + })); + } + /** 将截图状态与结果限制在指定容器内,供自有详情工作区使用。 */ + async loadInto(target, carNum, { isActive = /* @__PURE__ */ __name(() => true, "isActive") } = {}) { + const host = $(target); + if (!host.length || "yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return host.empty(), null; + const mode = await this.initializeProviders(), renderMessage = /* @__PURE__ */ __name((message) => isActive() && host.empty().append($("
    ").addClass("jhs-panel-state").text(message)), "renderMessage"); + if ("manual" === mode) { + host.empty(); + const tabs = $('
    '), result = $('
    ').text("请选择截图来源"); + this.providerRegistry.providers.forEach(((provider) => tabs.append($('').prop("disabled", !provider.enabled).attr("data-provider", provider.id).text(provider.name)))); + host.append(tabs, result), tabs.on("click", "button:not(:disabled)", (async (event) => { + const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); + result.text(`${provider.name} 加载中…`); + try { + const loaded = await provider.getScreenshot(normalizeCarNum(carNum)); + if (!isActive()) return; + loaded?.url ? this.renderInto(result, loaded.url, `${provider.name} 缩略图`) : result.text(`${provider.name} 无结果`); + } catch (error) { + isActive() && result.text(`${provider.name} 请求失败`), clog.error("截图源请求失败", error); } - } else show.info("请粘贴或上传图片"); - })), r2.on("click", (() => { - a2.hide(), l2.hide(), t2.val(""), c2.val(""); - })), c2.on("change", (() => { - utils.isUrl(c2.val()) && (i2.attr("src", c2.val()), a2.show()); - })), $("#openAll").on("click", (() => { - $(".search-img-site-btn").each((function() { - $(this).find(".site-checkbox").is(":checked") && window.open($(this).attr("href")); })); + return host; + } + renderMessage("正在加载缩略图…"); + try { + const url = await this.getScreenshotFromInitializedProviders(carNum); + if (!isActive()) return null; + if (!url) return host.empty(), null; + return this.renderInto(host, url, "缩略图"), url; + } catch (error) { + if (!isActive()) return null; + host.empty(); + const state = $('
    ').text("缩略图加载失败 "), retry = $(''); + retry.on("click", (() => void this.loadInto(host, carNum, { isActive }))), host.append(state.append(retry)); + return clog.error("缩略图加载失败", error), null; + } + } + renderInto(target, url, alt) { + const host = $(target), image = $("").attr({ src: normalizeJavStoreAssetUrl(url), alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"), button = $('').attr("aria-label", `查看${alt}大图`).append(image); + host.empty().append(button).off("click.jhsScreenshot").on("click.jhsScreenshot", ".jhs-fc2-screenshot-thumbnail", ((event) => { + event.preventDefault(), event.stopPropagation(), showImageViewer(image[0]); })); } - resetSearchUI() { - $("#action-btns").show(), $("#search-results").hide(), $("#search-img-site-btns-container").hide().empty(); + }; + __name(_ScreenShotPlugin, "ScreenShotPlugin"); + var ScreenShotPlugin = _ScreenShotPlugin; + + // src/plugins/new-video/new-video.js + var tt = [{ + name: "jsDelivr (全球CDN)", + json: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Filetree.json", + base: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Content/" + }, { + name: "GitHub Raw (备用)", + json: "https://raw.githubusercontent.com/gfriends/gfriends/master/Filetree.json", + base: "https://raw.githubusercontent.com/gfriends/gfriends/master/Content/" + }]; + var nt = "jhs_img_cdn_index"; + var at = parseInt(localStorage.getItem(nt) || "0", 10); + (at >= tt.length || at < 0) && (at = 0); + var it = tt[at].json; + var st = tt[at].base; + var ot = "filetreeStore"; + var rt = "filetree_data"; + var lt = { + db: null, + async open() { + return this.db ? this.db : new Promise(((e2, t2) => { + const n2 = indexedDB.open("GfriendsAvatarDB", 1); + n2.onupgradeneeded = (e3) => { + this.db = e3.target.result, this.db.objectStoreNames.contains(ot) || this.db.createObjectStore(ot); + }, n2.onsuccess = (t3) => { + this.db = t3.target.result, e2(this.db); + }, n2.onerror = (e3) => { + clog.error("IndexedDB open error:", e3.target.errorCode), t2(new Error("Failed to open IndexedDB")); + }; + })); + }, + async get(e2) { + return await this.open(), new Promise(((t2) => { + const n2 = this.db.transaction([ot], "readonly").objectStore(ot).get(e2); + n2.onsuccess = () => t2(n2.result), n2.onerror = () => t2(null); + })); + }, + async set(e2, t2) { + return await this.open(), new Promise(((n2, a2) => { + const i2 = this.db.transaction([ot], "readwrite").objectStore(ot).put(t2, e2); + i2.onsuccess = () => n2(), i2.onerror = (e3) => { + clog.error("IndexedDB set error:", e3.target.errorCode), a2(new Error("Failed to write to IndexedDB")); + }; + })); } - handleImageFile(e2) { - const t2 = document.getElementById("preview-image"), n2 = document.getElementById("preview-area"), a2 = document.getElementById("url-input-container"); - if (!e2.type.match("image.*")) return void show.info("请选择图片文件"); - const i2 = new FileReader(); - i2.onload = (e3) => { - t2.src = e3.target.result, n2.style.display = "block", a2.style.display = "none", $("#handle-btn")[0].click(); - }, i2.readAsDataURL(e2); + }; + var ct = null; + var dt = null; + function ht(e2) { + if (!e2 || !e2.Content) return null; + const t2 = {}, n2 = e2.Content; + for (const a2 in n2) { + const e3 = encodeURIComponent(a2); + for (const i2 in n2[a2]) { + let s2 = i2.replace(/\.jpg$/i, "").split("-")[0]; + s2.startsWith("AI-Fix-") && (s2 = s2.substring(7)); + const o2 = s2.toLowerCase().trim(); + if (o2.length > 0) { + const s3 = n2[a2][i2], r2 = s3.indexOf("?"); + let l2, c2 = ""; + r2 > -1 ? (l2 = encodeURIComponent(s3.substring(0, r2)), c2 = s3.substring(r2)) : l2 = encodeURIComponent(s3); + const d2 = `${st}${e3}/${l2}${c2}`; + t2[o2] || (t2[o2] = []), t2[o2].includes(d2) || t2[o2].push(d2); + } + } } - async searchByImage(e2) { - let t2 = loading(); - try { - let t3 = e2; - if (e2.startsWith("data:")) { - show.info("开始上传图片..."); - const n2 = await (async function(e3) { - var t4; - const n3 = e3.match(/^data:(.+);base64,(.+)$/); - if (!n3 || n3.length < 3) throw new Error("无效的Base64图片数据"); - const a2 = n3[1], i2 = n3[2], s2 = atob(i2), o2 = new Array(s2.length); - for (let g2 = 0; g2 < s2.length; g2++) o2[g2] = s2.charCodeAt(g2); - const r2 = new Uint8Array(o2), l2 = new Blob([r2], { - type: a2 - }), c2 = new FormData(); - c2.append("image", l2); - const d2 = await fetch("https://api.imgur.com/3/image", { - method: "POST", - headers: { - Authorization: "Client-ID d70305e7c3ac5c6" - }, - body: c2 - }), h2 = await d2.json(); - if (h2.success && h2.data && h2.data.link) return h2.data.link; - throw new Error((null == (t4 = h2.data) ? void 0 : t4.error) || "上传到Imgur失败"); - })(e2); - if (!n2) return void show.error("上传失败"); - t3 = n2; + return t2; + } + __name(ht, "ht"); + async function gt(e2) { + let t2 = loading(); + try { + await (async function() { + if (ct && dt) return ct; + let e3 = null; + try { + e3 = await lt.get(rt); + } catch (a3) { + clog.error("读取 IndexedDB 失败:", a3); + } + if (e3 && e3.Content && (ct = e3, dt = ht(e3), dt)) return ct; + show.info("正在载入头像数据源..."); + const t3 = await fetch(it); + if (!t3.ok) throw new Error(`请求头像源失败: ${t3.status}`); + const n3 = await t3.json(); + if (n3 && n3.Content) { + ct = n3, dt = ht(n3); + try { + await lt.set(rt, n3), clog.debug("载入头像数据源并写入缓存成功!"); + } catch (a3) { + clog.error(a3), show.error("头像数据源写入缓存失败,可能磁盘已满或其他权限问题。"); + } + return ct; } - return t3; - } catch (n2) { - show.error(`搜索失败: ${n2.message}`), clog.error("搜索失败:", n2); - } finally { - t2.close(); - } + clog.error(n3); + throw new Error("解析头像数据源失败"); + })(); + } catch (i2) { + return show.error(i2), []; + } finally { + t2.close(); } - }; - __name(_SearchByImagePlugin, "SearchByImagePlugin"); - var SearchByImagePlugin = _SearchByImagePlugin; - var _BusNavBarPlugin = class _BusNavBarPlugin extends BasePlugin { - getName() { - return "BusNavBarPlugin"; + if (!dt) return []; + const n2 = /* @__PURE__ */ new Set(), a2 = e2.map(((e3) => e3.toLowerCase().trim())).filter(((e3) => e3.length > 0)); + if (0 === a2.length) return []; + for (const s2 of a2) { + const e3 = dt[s2]; + e3 && e3.forEach(((e4) => n2.add(e4))); } - handle() { - $("#navbar > div > div > span").append('\n \n '), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); - })); + return Array.from(n2); + } + __name(gt, "gt"); + function aggregateNewVideoRecords(actresses, carMap, decisions, now = Date.now()) { + const grouped = /* @__PURE__ */ new Map(); + for (const actress of actresses) { + if (!Array.isArray(actress.newVideoList)) continue; + for (const raw of actress.newVideoList) { + const item = "object" == typeof raw ? raw : {}, carNum = normalizeCarNum("string" == typeof raw ? raw : raw.carNum); + if (!carNum) continue; + const existing = grouped.get(carNum) || { carNum, coverUrl: "", title: "", publishTime: "", actresses: [], starIds: [], categories: /* @__PURE__ */ new Set(), score: 0, voteCount: 0, url: "", isVr: false }; + existing.coverUrl || (existing.coverUrl = item.coverUrl || ""), existing.title || (existing.title = item.title || ""), existing.publishTime = [existing.publishTime, item.publishTime || ""].sort().at(-1), existing.score = Math.max(existing.score, Number(item.score) || 0), existing.voteCount = Math.max(existing.voteCount, Number(item.voteCount) || 0), existing.url || (existing.url = item.url || ""); + existing.isVr || (existing.isVr = true === item.isVr || /(^|[^A-Z])VR([^A-Z]|$)/i.test(`${item.title || ""} ${(item.tags || []).join?.(" ") || ""} ${(item.categories || []).join?.(" ") || ""}`)); + actress.name && !existing.actresses.includes(actress.name) && existing.actresses.push(actress.name), actress.starId && !existing.starIds.includes(actress.starId) && existing.starIds.push(actress.starId), actress.actressType && existing.categories.add(actress.actressType), grouped.set(carNum, existing); + } + } + return [...grouped.values()].map(((item) => { + const record = carMap.get(item.carNum), flags = normalizeStateFlags(record?.stateFlags), decision = decisions[item.carNum] || null, decisionState = !decision ? "pending" : "snoozed" === decision.action && decision.until && Date.parse(decision.until) <= now ? "pending" : decision.action; + return { ...item, actressName: item.actresses.join("、"), starId: item.starIds[0] || "", categories: [...item.categories], flags, decision, decisionState }; + })); + } + __name(aggregateNewVideoRecords, "aggregateNewVideoRecords"); + var _NewVideoPlugin = class _NewVideoPlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "currentPage", 1), i(this, "pageSize", 30), i(this, "nvCurrentPage", 1), i(this, "nvPageSize", 60), i(this, "nvFlatListCache", []), i(this, "nvAllItemsMap", /* @__PURE__ */ new Map()), i(this, "nvActressesCache", []), i(this, "nvCarMapCache", /* @__PURE__ */ new Map()), i(this, "nvSortBy", "publishTime_desc"), i(this, "nvSelected", /* @__PURE__ */ new Set()), i(this, "nvDecisionsCache", {}), i(this, "nvCoverCache", /* @__PURE__ */ new Map()), i(this, "nvActorCoverRequests", /* @__PURE__ */ new Map()), i(this, "nvRenderGeneration", 0), i(this, "nvSearchDebounced", null), i(this, "nvInvalidationTimer", null), i(this, "nvWorkspaceReloadPromise", null), i(this, "nvWorkspaceReloadDirty", false), i(this, "nvWorkspaceMounted", false), i(this, "nvEventUnsubscribe", null), i(this, "taskStatusUnsubscribe", null), i(this, "nvJavDbUrl", ""), i(this, "nvRuleTime", 8760); } - }; - __name(_BusNavBarPlugin, "BusNavBarPlugin"); - var BusNavBarPlugin = _BusNavBarPlugin; - var _RelatedPlugin = class _RelatedPlugin extends BasePlugin { getName() { - return "RelatedPlugin"; + return "NewVideoPlugin"; + } + getStartupMode() { + return "idle"; } async initCss() { return ` `; + .newVideoToolBox { display:flex; flex-direction:column; width:100%; height:100%; min-width:0; min-height:0; box-sizing:border-box; overflow:hidden; padding:var(--jhs-space-3); } + .jhs-new-video-toolbar { display:flex; align-items:center; justify-content:space-between; gap:var(--jhs-space-3); margin-bottom:var(--jhs-space-3); } + .jhs-new-video-toolbar__actions, .jhs-new-video-toolbar__filters { display:flex; align-items:center; flex-wrap:wrap; gap:var(--jhs-space-2); } + .jhs-new-video-toolbar select { min-width:150px; } + #actress-card-container { display:grid; grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr)); gap:var(--jhs-space-3); width:100%; min-width:0; max-width:1680px; box-sizing:border-box; margin:0 auto; padding:var(--jhs-space-1); overflow-x:hidden; overflow-y:auto; } + .actress-card { position:relative; display:flex; flex-direction:column; min-width:0; padding:var(--jhs-space-4); border:1px solid var(--jhs-border); border-radius:var(--jhs-radius-md); background:var(--jhs-surface); } + .actress-card.is-paused { background:var(--jhs-surface-2); } + .actress-card__badges { display:flex; align-items:center; gap:var(--jhs-space-1); margin-bottom:var(--jhs-space-3); } + .actress-card__profile { display:grid; grid-template-columns:64px minmax(0,1fr); align-items:center; gap:var(--jhs-space-3); color:inherit; text-decoration:none; } + .actress-card-avatar { width:64px; height:64px; border-radius:50%; object-fit:cover; background:var(--jhs-surface-2); } + .actress-card-name { overflow:hidden; color:var(--jhs-text); font-size:var(--jhs-font-size-lg); font-weight:700; text-overflow:ellipsis; white-space:nowrap; } + .actress-card-allname { overflow:hidden; margin-top:var(--jhs-space-1); color:var(--jhs-text-muted); font-size:var(--jhs-font-size-sm); text-overflow:ellipsis; white-space:nowrap; } + .actress-card__meta { display:grid; gap:var(--jhs-space-2); margin:var(--jhs-space-3) 0; } + .actress-card__meta-row { display:grid; grid-template-columns:76px minmax(0,1fr); gap:var(--jhs-space-2); color:var(--jhs-text-muted); font-size:var(--jhs-font-size-sm); } + .actress-card__meta-row dt { color:var(--jhs-text-faint); } + .actress-card__meta-row dd { overflow:hidden; margin:0; color:var(--jhs-text); text-overflow:ellipsis; white-space:nowrap; } + .actress-card__note { min-height:20px; margin-bottom:var(--jhs-space-3); color:var(--jhs-text-muted); font-size:var(--jhs-font-size-sm); } + .actress-card__actions { display:flex; align-items:center; gap:var(--jhs-space-2); margin-top:auto; } + .actress-card__actions .btn-check-actress { flex:1; } + .actress-card__menu { position:relative; } + .actress-card__menu summary { list-style:none; } + .actress-card__menu summary::-webkit-details-marker { display:none; } + .actress-card__menu-popover { position:absolute; right:0; bottom:calc(100% + var(--jhs-space-1)); z-index:var(--jhs-z-elevated); min-width:128px; padding:var(--jhs-space-1); border:1px solid var(--jhs-border); border-radius:var(--jhs-radius-md); background:var(--jhs-surface); box-shadow:var(--jhs-shadow-md); } + .actress-card__menu-popover button { width:100%; justify-content:flex-start; } + .card-tag.is-uncensored { color:var(--jhs-status-down); background:var(--jhs-status-down-tint); } + .card-tag.is-censored { color:var(--jhs-status-watch); background:var(--jhs-status-watch-tint); } + .card-tag.is-unknown { color:var(--jhs-text-muted); background:var(--jhs-surface-2); } + #new-video-list-container { display:none; flex:1; min-width:0; min-height:0; overflow-x:hidden; overflow-y:auto; } + #new-video-list-footer { display:none; padding:var(--jhs-space-2) 0; border-top:1px solid var(--jhs-border); color:var(--jhs-text-muted); font-size:var(--jhs-font-size-sm); } + .jhs-new-video-view { flex:0 0 auto; } + .jhs-new-video-batch { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--jhs-space-2); width:100%; } + .jhs-new-video-batch__actions { display:flex; align-items:center; flex-wrap:wrap; gap:var(--jhs-space-2); } + .jhs-new-video-batch__more { position:relative; } + .jhs-new-video-batch__more summary { list-style:none; } + .jhs-new-video-batch__more summary::-webkit-details-marker { display:none; } + .jhs-new-video-batch__menu { top:auto; bottom:calc(100% + var(--jhs-space-1)); } + .jhs-task-status-list { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:var(--jhs-space-2); margin-bottom:var(--jhs-space-3); } + .jhs-task-status { padding:var(--jhs-space-2) var(--jhs-space-3); border:1px solid var(--jhs-border); border-radius:var(--jhs-radius-sm); background:var(--jhs-surface-2); } + .jhs-task-status__name { color:var(--jhs-text); font-weight:700; } + .jhs-task-status__meta { display:block; margin-top:var(--jhs-space-1); color:var(--jhs-text-muted); font-size:var(--jhs-font-size-xs); } + .jhs-new-video-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr)); gap:var(--jhs-space-3); width:100%; min-width:0; box-sizing:border-box; padding:var(--jhs-space-1); } + .nv-card__link { display:block; color:inherit; text-decoration:none; } + .nv-card__cover { position:relative; width:100%; overflow:hidden; aspect-ratio:3/2; border-radius:var(--jhs-radius-sm); background:var(--jhs-surface-2); } + .nv-cover-img { width:100%; height:100%; object-fit:cover; cursor:zoom-in; } + .nv-card__empty { display:flex; align-items:center; justify-content:center; height:100%; color:var(--jhs-text-faint); font-size:var(--jhs-font-size-xs); } + .nv-card__rating { position:absolute; top:var(--jhs-space-1); right:var(--jhs-space-1); } + .nv-card__body { padding:var(--jhs-space-2) var(--jhs-space-1); } + .nv-card__title, .nv-card__actress { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .nv-card__title { color:var(--jhs-text); font-size:var(--jhs-font-size-sm); font-weight:700; } + .nv-card__actress, .nv-card__date { color:var(--jhs-text-muted); font-size:var(--jhs-font-size-xs); } + .jhs-new-video-pagination { padding:var(--jhs-space-3) 0; border-top:1px solid var(--jhs-border); text-align:center; } + .jhs-form-dialog { display:grid; gap:var(--jhs-space-3); padding:var(--jhs-space-4); } + .jhs-avatar-editor { display:grid; grid-template-columns:100px minmax(0,1fr); gap:var(--jhs-space-3); align-items:start; } + .jhs-avatar-editor__preview { width:100px; height:100px; border:2px solid var(--jhs-border); border-radius:50%; object-fit:cover; } + .jhs-avatar-editor__actions { margin-top:var(--jhs-space-2); } + #gfriends-image-list-container { height:100%; box-sizing:border-box; padding:var(--jhs-space-4); background:var(--jhs-surface-2); } + #gfriends-prompt { margin:0 0 var(--jhs-space-3); padding-bottom:var(--jhs-space-2); border-bottom:1px solid var(--jhs-border); color:var(--jhs-text-muted); font-weight:600; } + #gfriends-image-list { display:grid; grid-template-columns:repeat(auto-fill,minmax(144px,1fr)); gap:var(--jhs-space-3); } + .gfriends-image-item-wrapper { display:grid; grid-template-rows:200px auto; min-height:44px; overflow:hidden; padding:0; border:1px solid var(--jhs-border); border-radius:var(--jhs-radius-md); background:var(--jhs-surface); color:var(--jhs-text); cursor:pointer; } + .gfriends-image-item-wrapper:hover { border-color:var(--jhs-border-strong); background:var(--jhs-surface-hover); } + .gfriends-image-item-wrapper:focus-visible { outline:2px solid var(--jhs-focus); outline-offset:2px; } + .gfriends-image-item-wrapper[aria-pressed="true"] { border-color:var(--jhs-accent); box-shadow:0 0 0 2px var(--jhs-accent-tint); } + .gfriends-selectable-img { width:100%; height:200px; object-fit:cover; } + .gfriends-size-tag { min-height:28px; padding:var(--jhs-space-1) var(--jhs-space-2); border-top:1px solid var(--jhs-border); color:var(--jhs-text-muted); font-size:var(--jhs-font-size-xs); line-height:20px; text-align:center; } + .jhs-form-dialog__body, .jhs-form-field { display:grid; gap:var(--jhs-space-1); } + .jhs-form-label, .jhs-form-dialog__title { color:var(--jhs-text); font-size:var(--jhs-font-size-sm); font-weight:600; } + .jhs-form-dialog :where(.jhs-field,.jhs-select,.jhs-textarea) { width:100%; } + .jhs-form-dialog .jhs-textarea { min-height:60px; overflow-y:hidden; } + .jhs-option-row { display:flex; align-items:center; gap:var(--jhs-space-2); min-height:36px; } + #actress-pagination { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:var(--jhs-space-1); } + @media (max-width:767px) { .jhs-new-video-toolbar { align-items:stretch; flex-direction:column; } .jhs-new-video-toolbar select, .jhs-new-video-toolbar .jhs-btn { min-height:44px; } .jhs-task-status-list { grid-template-columns:1fr; } .page-number-btn { display:none !important; } } + @media (prefers-reduced-motion:reduce) { .gfriends-image-item-wrapper { transition:none; } } + + `; } - async showRelated(target, movieId, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getBean("DetailWorkspacePlugin")?.getSlot("related"); - if (!movieId) return void show.error("未传入movieId"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="related"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('').attr("data-jhs-movie-id", String(movieId)), header = $('

    相关清单

    '), toggle = $(''), state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle) : panel.append(header), panel.append('', ''), host.append(panel), state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-related-container, .jhs-related-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayRelateds(state), storageManager.saveSettingItem("enableLoadRelated", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayRelateds(state) : panel.find(".jhs-related-container, .jhs-related-footer").hide(); - return panel; + async handle() { + this.nvEventUnsubscribe || (this.nvEventUnsubscribe = jhsEventBus.on("new-video-changed", (() => this.scheduleWorkspaceReload()))); + this.taskStatusUnsubscribe || (this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.isWorkspaceMounted() && this.renderTaskStatuses()))); + await this.showNewVideoCount(); } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); + isWorkspaceMounted() { + return this.nvWorkspaceMounted && $(".newVideoToolBox").length > 0; } - async fetchAndDisplayRelateds(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = true; - const { movieId, panel } = state, container = panel.find(".jhs-related-container"), footer = panel.find(".jhs-related-footer"); - container.empty().append($('
    ').text("获取清单中...")), footer.empty(); - let related; - try { - related = await K(movieId, 1, 20); - } catch (error) { - clog.error("获取清单失败:", error); - state.loading = false; - return void this.renderRetry(container, (() => this.fetchAndDisplayRelateds(state))); - } - if (!state.isActive?.()) return void (state.loading = false); - state.loading = false, state.loaded = true; - container.empty(); - if (!related.length) return void container.append($('
    ').text("无清单")); - this.displayRelateds(state, related, container), 20 === related.length ? this.bindLoadMore(state, container, footer) : footer.append($('
    ').text("已加载全部清单")); + scheduleWorkspaceReload() { + this.nvWorkspaceReloadDirty = true; + this.nvInvalidationTimer || (this.nvInvalidationTimer = setTimeout((() => { + this.nvInvalidationTimer = null, void this.flushWorkspaceReload(); + }), 0)); } - renderRetry(container, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode("获取清单失败 ")); - state.append($('').on("click", retry)), container.append(state); + async flushWorkspaceReload() { + if (this.nvWorkspaceReloadPromise) return this.nvWorkspaceReloadPromise; + return this.nvWorkspaceReloadPromise = (async () => { + do { + this.nvWorkspaceReloadDirty = false, await this.showNewVideoCount(); + this.isWorkspaceMounted() && await this.reloadNewVideoWorkspaceData({ preservePage: true }); + } while (this.nvWorkspaceReloadDirty); + })().catch(((error) => clog.error("新作品数据刷新失败", error))).finally((() => { + this.nvWorkspaceReloadPromise = null; + })), this.nvWorkspaceReloadPromise; } - bindLoadMore(state, container, footer) { - const button = $(''), end = $('').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - button.text("加载中...").prop("disabled", true); - try { - const related = await K(state.movieId, nextPage, 20); - if (!state.isActive?.()) return; - state.page = nextPage; - this.displayRelateds(state, related, container), related.length < 20 ? (button.remove(), end.show()) : button.text("加载更多清单").prop("disabled", false); - } catch (error) { - clog.error("加载更多清单失败:", error), button.text("加载失败,请重试").prop("disabled", false); - } - })); + getPendingNewVideoCount(e2, t2) { + return Array.isArray(e2?.newVideoList) ? new Set(e2.newVideoList.map(((item) => normalizeCarNum("string" == typeof item ? item : item.carNum))).filter(((carNum) => carNum && !t2.has(carNum) && !this.isDecisionHidden(carNum)))).size : 0; } - displayRelateds(state, related, container) { - related.forEach(((item) => { - const row = $(''), title = $("").addClass("jhs-related-title").attr({ - href: `/lists/${encodeURIComponent(item.relatedId)}`, - target: "_blank", - rel: "noopener noreferrer" - }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createTime || "未知"}`); - meta.append( - $("").text(`视频:${Number(item.movieCount) || 0}`), - $("").text(`收藏:${Number(item.collectionCount) || 0}`), - $("").text(`查看:${Number(item.viewCount) || 0}`), - time - ), row.append(heading, meta), container.append(row); - })); + isDecisionHidden(carNum) { + const decision = this.nvDecisionsCache[normalizeCarNum(carNum)]; + if (!decision) return false; + return "ignored" === decision.action || "snoozed" === decision.action && (!decision.until || Date.parse(decision.until) > Date.now()); + } + async getPendingNewVideoTotal() { + const e2 = await storageManager.getCarMap(), keys = /* @__PURE__ */ new Set(); + this.nvDecisionsCache = await stateService.getNewVideoDecisions(); + (await storageManager.getFavoriteActressList()).forEach(((actress) => Array.isArray(actress.newVideoList) && actress.newVideoList.forEach(((item) => { + const carNum = normalizeCarNum("string" == typeof item ? item : item.carNum); + carNum && !e2.has(carNum) && !this.isDecisionHidden(carNum) && keys.add(carNum); + })))); + return keys.size; + } + async showNewVideoCount() { + const e2 = await this.getPendingNewVideoTotal(); + $("#newVideoCount").text(`${e2}`); } - }; - __name(_RelatedPlugin, "RelatedPlugin"); - var RelatedPlugin = _RelatedPlugin; - var _WantAndWatchedVideosPlugin = class _WantAndWatchedVideosPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "flag", null); + async resetBtnTip() { + const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = localStorage.getItem(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = localStorage.getItem(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; + $("#checkFavoriteActress").attr("data-tip", `上次完整同步: ${n2}; 检测间隔时间: ${a2}小时`), $("#checkNewVideo").attr("data-tip", `上次整批检测: ${i2}; 检测间隔时间: ${s2}小时`); } - getName() { - return "WantAndWatchedVideosPlugin"; + async openDialog() { + this.cleanupNewVideoWorkspace(), this._viewMode = "list" === localStorage.getItem("jhs_newVideoViewMode") ? "list" : "actress", this.currentPage = 1, this.nvCurrentPage = 1, this.nvSelected = /* @__PURE__ */ new Set(), this.nvCoverCache = /* @__PURE__ */ new Map(), this.nvActorCoverRequests = /* @__PURE__ */ new Map(), this.nvRenderGeneration++; + const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = localStorage.getItem(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = localStorage.getItem(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; + let o2 = ` +
    + +
    +
    +
    + +
    +
    `; + layer.open({ + type: 1, + title: '新作品检测', + content: o2, + scrollbar: false, + area: utils.getDialogArea("workspace"), + anim: -1, + success: /* @__PURE__ */ __name(async (e3, t3) => { + this.nvWorkspaceMounted = true, JhsSelect.enhance(e3), this.bindClick(), this.applyViewMode(), this.renderTaskStatuses(), await this.reloadNewVideoWorkspaceData(), utils.setupEscClose(t3); + }, "success"), + end: /* @__PURE__ */ __name(() => this.cleanupNewVideoWorkspace(), "end") + }); } - async handle() { - window.location.href.includes("/want_watch_videos") && ($("h3").append(''), $("#wantWatchBtn").on("click", ((e2) => { - this.flag = "favorite", this.importWantWatchVideos(e2, "是否将想看的影片导入到 JHS 收藏?"); - }))), window.location.href.includes("/watched_videos") && ($("h3").append(''), $("#wantWatchBtn").on("click", ((e2) => { - this.flag = "watched", this.importWantWatchVideos(e2, "是否将看过的影片导入到 JHS 已观看?"); - }))); + cleanupNewVideoWorkspace() { + this.nvSearchDebounced?.cancel?.(), this.nvSearchDebounced = null, this.nvWorkspaceMounted = false, this.nvRenderGeneration++, this.nvSelected.clear(), this.nvCoverCache = /* @__PURE__ */ new Map(), this.nvActorCoverRequests = /* @__PURE__ */ new Map(), this.nvAllItemsMap.clear(), this.nvFlatListCache = [], this.nvActressesCache = [], this.nvCarMapCache = /* @__PURE__ */ new Map(), this.nvDecisionsCache = {}, this.nvCurrentPageItems = []; } - importWantWatchVideos(e2, t2) { - utils.q(null, `${t2}
    执行此功能前请记得备份数据`, (async () => { - let e3 = loading(); + bindClick() { + const taskPlugin = this.getDependency("TaskPlugin"); + $("#reLoad").on("click", (() => { + void this.reloadNewVideoWorkspaceData(), $("#checkNewVideoMsg").text(""); + })), $("#new-video-list-container").on("click", ".nv-card__link", (async (e2) => { + const t2 = $(e2.currentTarget).closest(".nv-card").attr("data-car"); + if (!t2) return; try { - const result = await this.parseMovieList(); - show.ok(`导入完成:成功 ${result.imported},失败 ${result.failed},共 ${result.pages} 页`); - } catch (t3) { - clog.error(t3), show.error(`导入失败:${t3.message || t3}`); - } finally { - e3.close(); + const enabled = await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse", C); + if (enabled !== _) return; + await stateService.removeFromNewVideoList([t2], "browse"); + } catch (n2) { + clog.error("移除新作品标记失败:", n2); } + })), $("#toSetting").on("click", ((e2) => { + this.getDependency("SettingPlugin").openSettingDialog("task-panel", (() => { + $("#setting-checkFavoriteActress").css({ + border: "1px solid var(--jhs-status-filter)" + }), $("#setting-checkNewVideo").css({ + border: "1px solid var(--jhs-status-filter)" + }); + })); })); + $("#checkFavoriteActress").on("click", ((event) => { + void this.runManualTask($(event.currentTarget), "同步中…", (async () => { + if (!$('a[href*="/users/profile"]').length) return void show.error("未登录 JavDB,同步失败"); + await taskPlugin.checkFavoriteActress(true); + })); + })), $("#checkNewVideo").on("click", ((event) => { + void this.runManualTask($(event.currentTarget), "检测中…", (() => taskPlugin.checkNewVideo(true))); + })), $("#paramActressType").on("change", ((e2) => { + this.currentPage = 1, this.nvRenderGeneration++, "actress" === this._viewMode && this.renderActressCards(); + })), $("#paramSortBy").on("change", ((e2) => { + this.currentPage = 1, this.nvRenderGeneration++, "actress" === this._viewMode && this.renderActressCards(); + })), $("#nvSortBy").on("change", ((e2) => { + this.nvSortBy = $("#nvSortBy").val(), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); + })), $("#nvCategoryFilter,#nvStateFilter,#nvDecisionFilter").on("change", ((e2) => { + this.nvCurrentPage = 1, this.nvRenderGeneration++, "list" === this._viewMode && this.renderNewVideoList(); + })), this.nvSearchDebounced = utils.debounce((() => { + this.nvCurrentPage = 1, this.nvRenderGeneration++, "list" === this._viewMode && this.renderNewVideoList(); + }), 200), $("#nvSearch").on("input", this.nvSearchDebounced), $(".jhs-new-video-view").on("click", '[role="tab"]', ((event) => { + this.setViewMode($(event.currentTarget).data("view")); + })).on("keydown", '[role="tab"]', ((event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const tabs = $(event.delegateTarget).find('[role="tab"]'), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; + tabs.eq(next).trigger("click").trigger("focus"); + })), this.bindBatchActions(); } - async parseMovieList(e2 = null, result = { imported: 0, failed: 0, pages: 0 }) { - let t2, n2; - e2 ? (t2 = e2.find(this.getSelector().itemSelector), n2 = e2.find(".pagination-next").attr("href")) : (t2 = $(this.getSelector().itemSelector), n2 = $(".pagination-next").attr("href")); - result.pages++, show.info(`正在导入第 ${result.pages} 页`); - for (const i2 of t2) { - const e3 = $(i2), t3 = e3.find("a").attr("href"), n3 = e3.find(".video-title strong").text().trim(), s2 = e3.find(".meta").text().trim(); - if (t3 && n3) try { - this.flag && await stateService.patch(n3, { [this.flag]: true }, { type: "javdb-list-import", record: { carNum: n3, url: t3, names: "", publishTime: s2 } }), result.imported++; - } catch (a2) { - result.failed++, clog.error(`保存失败 [${n3}]:`, a2); - } + async runManualTask(button, busyLabel, runner) { + if (button.attr("aria-busy") === "true") return; + const label = button.find("span").last(), previous = label.text(); + button.attr("aria-busy", "true").prop("disabled", true), label.text(busyLabel); + try { + await navigator.locks.request(this.getDependency("TaskPlugin").singleTaskKey, { ifAvailable: true }, (async (lock) => { + if (!lock) return void show.error("后台任务正在运行,请稍后再试"); + await runner(); + })); + } catch (error) { + clog.error("手动任务执行失败", error), this.getDependency("TaskPlugin").isNetworkBlocked(error) && show.error(error.message || "任务执行失败"); + } finally { + button.removeAttr("aria-busy").prop("disabled", false), label.text(previous), this.renderTaskStatuses(); } - if (!n2) return result; - await utils.sleep(1e3); - const html = await gmHttp.get(new URL(n2, window.location.href).href), nextPage = utils.htmlTo$dom(html); - return this.parseMovieList(nextPage, result); } - }; - __name(_WantAndWatchedVideosPlugin, "WantAndWatchedVideosPlugin"); - var WantAndWatchedVideosPlugin = _WantAndWatchedVideosPlugin; - var _CoverButtonPlugin = class _CoverButtonPlugin extends BasePlugin { - getName() { - return "CoverButtonPlugin"; + setViewMode(mode) { + if (!["actress", "list"].includes(mode) || mode === this._viewMode) return; + this._viewMode = mode, localStorage.setItem("jhs_newVideoViewMode", mode), "list" === mode ? this.nvCurrentPage = 1 : this.currentPage = 1, this.nvRenderGeneration++, this.applyViewMode(), this.renderCurrentView(); } - async initCss() { - return ` - `; + applyViewMode() { + const list = "list" === this._viewMode; + $("#actress-card-container").toggle(!list), $("#actress-pagination").toggle(!list), $("#new-video-list-container").toggle(list), $("#new-video-list-footer").toggle(list), JhsSelect.setVisible("#paramSortBy", !list), JhsSelect.setVisible("#nvSortBy", list), JhsSelect.setVisible("#paramActressType", !list), JhsSelect.setVisible("#nvCategoryFilter", list), JhsSelect.setVisible("#nvStateFilter", list), JhsSelect.setVisible("#nvDecisionFilter", list), $("#nvSearch").toggleClass("jhs-is-hidden", !list), $(".jhs-new-video-view [role='tab']").each(((index, tab) => { + const active = $(tab).data("view") === this._viewMode; + $(tab).attr({ "aria-selected": String(active), tabindex: active ? "0" : "-1" }).toggleClass("active", active); + })); } - async handle() { - window.isListPage && (this.addSvgBtn(), await this.bindClick()); + renderCurrentView() { + return "list" === this._viewMode ? this.renderNewVideoList() : this.renderActressCards(); } - /** 构建卡片工具和三个卡片内 popover。 */ - buildToolBox() { - return ` -
    - - -
    - - -
    -
    - - -
    -
    - - -
    -
    `; + loadData() { + this.currentPage = 1; + return this.reloadNewVideoWorkspaceData(); } - async addSvgBtn(items = null) { - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((element) => { - const item = $(element); - if (item.find(".tool-box").length || l && item.find(".avatar-box").length) return; - const host = r ? item.find(".tags").first() : item.find(".photo-info").first(); - host.length && host.append(this.buildToolBox()); - })), this.enableSvgBtn(items); + async reloadNewVideoWorkspaceData({ preservePage = false } = {}) { + if (!this.isWorkspaceMounted()) return; + const generation = ++this.nvRenderGeneration, container = "list" === this._viewMode ? $("#new-video-list-container") : $("#actress-card-container"); + renderStateView(container, { type: "loading", title: "加载中" }); + try { + const [actresses, carMap, decisions, javDbUrl, ruleTime] = await Promise.all([storageManager.getFavoriteActressList(), storageManager.getCarMap(), stateService.getNewVideoDecisions(), this.getDependency("OtherSitePlugin").getJavDbUrl(), storageManager.getSetting("checkNewVideo_ruleTime", 8760)]); + if (!this.isWorkspaceMounted() || generation !== this.nvRenderGeneration) return; + this.nvActressesCache = actresses, this.nvCarMapCache = carMap, this.nvDecisionsCache = decisions, this.nvJavDbUrl = javDbUrl, this.nvRuleTime = parseNumberSetting(ruleTime, 8760, { min: 0 }); + const items = aggregateNewVideoRecords(actresses, carMap, decisions), nextMap = /* @__PURE__ */ new Map(); + items.forEach(((item) => nextMap.set(normalizeCarNum(item.carNum), { ...item, carNum: normalizeCarNum(item.carNum) }))), this.nvAllItemsMap = nextMap; + for (const carNum of [...this.nvSelected]) nextMap.has(carNum) || this.nvSelected.delete(carNum); + preservePage || (this.currentPage = 1, this.nvCurrentPage = 1), await this.renderCurrentView(), this.renderTaskStatuses(); + } catch (error) { + if (generation !== this.nvRenderGeneration || !this.isWorkspaceMounted()) return; + clog.error("加载新作品工作区失败", error), renderStateView(container, { type: "error", title: "加载失败", description: error.message || String(error), actionLabel: "重试", onAction: /* @__PURE__ */ __name(() => this.reloadNewVideoWorkspaceData({ preservePage: true }), "onAction") }); + } } - async enableSvgBtn(items = null) { - const e2 = await storageManager.getSetting(), { enableScreenSvg: t2 = _, enableVideoSvg: n2 = _, enableHandleSvg: a2 = _, enableSiteSvg: i2 = _, enableCopySvg: s2 = _ } = e2; - const scope = items ? $(items) : $(document); - [{ selector: ".screenSvg", enabled: t2 }, { selector: ".videoSvg", enabled: n2 }, { selector: ".handleSvg", enabled: a2 }, { selector: ".siteSvg", enabled: i2 }, { selector: ".copySvg", enabled: s2 }].forEach((({ selector: e3, enabled: t3 }) => { - scope.find(e3).toggle(t3 === _); + renderTaskStatuses() { + const container = $("#jhs-task-status-list"); + if (!container.length) return; + const taskPlugin = this.getDependency("TaskPlugin"), names = { favoriteActress: "演员同步", newVideo: "新作品", blacklist: "黑名单" }, labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); + container.empty(), ["favoriteActress", "newVideo", "blacklist"].forEach(((name) => { + const snapshot = taskPlugin.getTaskStatusSnapshot(name), item = $('
    '); + item.append($('').text(`${names[name]}:${labels[snapshot.state]}`)), item.append($('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)), container.append(item); + })); + } + async renderActressCards() { + const e2 = $("#actress-card-container"); + if (!e2.length) return; + let t2 = [...this.nvActressesCache]; + const n2 = $("#paramActressType").val(); + "all" !== n2 && (t2 = t2.filter(((e3) => e3.actressType === n2))); + const _carSet = this.nvCarMapCache; + const _newVideoCount = /* @__PURE__ */ __name((e3) => this.getPendingNewVideoCount(e3, _carSet), "_newVideoCount"); + const sortBy = $("#paramSortBy").val(); + const sortMap = { + "lastPublishTime_desc": [{ key: "lastPublishTime", order: "desc" }], + "lastPublishTime_asc": [{ key: "lastPublishTime", order: "asc" }], + "lastCheckTime_desc": [{ key: "lastCheckTime", order: "desc" }], + "lastCheckTime_asc": [{ key: "lastCheckTime", order: "asc" }], + "newVideoCount_desc": [{ key: _newVideoCount, order: "desc" }], + "newVideoCount_asc": [{ key: _newVideoCount, order: "asc" }] + }; + const defaultSort = [{ + key: _newVideoCount, + order: "desc" + }, { + key: "lastPublishTime", + order: "desc" + }]; + const sortedActresses = utils.genericSort(t2, sortMap[sortBy] || defaultSort); + const totalCount = sortedActresses.length, totalPages = Math.ceil(totalCount / this.pageSize), pageStart = (this.currentPage - 1) * this.pageSize, pageEnd = pageStart + this.pageSize; + totalPages > 0 && this.currentPage > totalPages && (this.currentPage = totalPages); + const safePageStart = (this.currentPage - 1) * this.pageSize, pageActresses = sortedActresses.slice(safePageStart, safePageStart + this.pageSize), javDbUrl = this.nvJavDbUrl, taskPlugin = this.getDependency("TaskPlugin"), ruleTime = this.nvRuleTime; + if (0 === pageActresses.length) { + renderStateView(e2, { type: "empty", title: this.nvActressesCache.length ? "没有符合当前筛选条件的演员" : "暂无收藏演员" }); + return void this.renderPagination(totalCount, totalPages); + } + const cards = pageActresses.map(((actress) => { + const allNames = Array.isArray(actress.allName) ? actress.allName.join(",") : ""; + const name = String(actress.name || ""), remark = String(actress.remark || ""), starId = String(actress.starId || ""); + const newVideoCount = this.getPendingNewVideoCount(actress, _carSet), latestPublishTime = actress.lastPublishTime || ""; + const profileUrl = normalizeHttpUrl(`/actors/${encodeURIComponent(starId)}?t=d`, javDbUrl), avatarUrl = normalizeHttpUrl(actress.avatar, javDbUrl) || "https://c0.jdbstatic.com/images/actor_unknow.jpg"; + const isPaused = shouldSkipStopped(latestPublishTime, ruleTime); + let typeLabel = "未知", typeClass = "is-unknown"; + actress.actressType === A ? (typeLabel = "无码", typeClass = "is-uncensored") : actress.actressType === D && (typeLabel = "有码", typeClass = "is-censored"); + const publishText = String(latestPublishTime || "暂无记录"), noteText = isPaused ? `停更 ${ruleTime / 24 / 365} 年以上,下轮任务不再检测` : remark || "暂无备注"; + const card = $('
    ').toggleClass("is-paused", isPaused).attr("data-starId", starId), badges = $('
    '); + badges.append($('').attr("data-tip", `最新作品数量: ${newVideoCount}`).text(`${newVideoCount} 新`), $('').addClass(typeClass).text(typeLabel)), isPaused && badges.append('停更'); + const profile = $('').attr("href", profileUrl || "#"), identity = $("").append($('').text(name), $('').attr("title", allNames).text(allNames || "暂无别名")); + profile.append($('').attr({ src: avatarUrl, alt: allNames }), identity); + const meta = $('
    ').append($('
    最近作品
    ').append($("
    ").attr("title", publishText).text(publishText)), $('
    上次检测
    ').append($("
    ").text(String(actress.lastCheckTime || "暂无记录")))), actions = $('
    '); + const check = $('').attr("data-starId", starId).prepend(this.checkSvg), remove = $('').attr({ "aria-label": `取消收藏 ${name}`, "data-starId": starId }).append(this.deleteSvg), edit = $('').attr("data-starId", starId).prepend(this.editSvg); + actions.append(check, remove, $('
    •••
    ').find(".actress-card__menu-popover").append(edit).end()); + return card.append(badges, profile, meta, $('

    ').attr("title", noteText).text(noteText), actions)[0]; })); + e2.empty().append(cards), $(".btn-delete-actress").off("click").on("click", ((e3) => { + e3.preventDefault(); + const t3 = $(e3.currentTarget).attr("data-starId"), n3 = sortedActresses.find(((e4) => e4.starId === t3)); + utils.q(e3, `是否取消收藏 ${n3.name}?`, (async () => { + let e4 = `${await this.getDependency("OtherSitePlugin").getJavDbUrl()}/actors/${t3}/uncollect`; + const n4 = document.querySelector("meta[name=csrf-token]").content, a2 = await gmHttp.post(e4, null, { + "x-csrf-token": n4 + }); + a2.includes("removeClass") ? (await storageManager.removeFavoriteActress(t3), await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-removed" })) : (show.error("移除失败"), clog.error("移除失败,返回值:", a2)); + })); + })), $(".btn-edit-actress").off("click").on("click", ((e3) => { + e3.preventDefault(); + const t3 = $(e3.currentTarget).attr("data-starId"), n3 = sortedActresses.find(((e4) => e4.starId === t3)); + n3 ? this.editActress(n3) : show.error(`未找到 starId 为 ${t3} 的女优记录。`); + })), $(".btn-check-actress").off("click").on("click", ((e3) => { + e3.preventDefault(); + const button = $(e3.currentTarget), starId = button.attr("data-starId"), actress = sortedActresses.find(((item) => item.starId === starId)); + void this.runManualTask(button, "检测中…", (() => taskPlugin.checkOneNewVideo(actress))); + })), $(".actress-card__menu").on("keydown", ((event) => { + if ("Escape" !== event.key) return; + event.preventDefault(); + const details = $(event.currentTarget); + details.prop("open", false).find("summary").trigger("focus"); + })).on("click", "[role='menuitem']", ((event) => { + $(event.currentTarget).closest("details").prop("open", false); + })), this.renderPagination(totalCount, totalPages); } - closeCardMenus(focus = false) { - const openMenus = $(".jhs-card-menu.is-open"), triggers = openMenus.siblings(".jhs-card-menu-trigger"); - openMenus.removeClass("is-open"), triggers.attr("aria-expanded", "false"), focus && triggers.first().trigger("focus"); + async getNewVideoFlatList() { + const category = $("#nvCategoryFilter").val() || "all", stateFilter = $("#nvStateFilter").val() || "pending", decisionFilter = $("#nvDecisionFilter").val() || "pending", query = String($("#nvSearch").val() || "").trim().toUpperCase(); + return [...this.nvAllItemsMap.values()].filter(((item) => { + const categoryMatch = "all" === category || "vr" === category ? "all" === category || item.isVr : "unknown" === category ? 0 === item.categories.length : item.categories.includes(category); + const stateMatch = "all" === stateFilter || "pending" === stateFilter ? "all" === stateFilter || !hasAnyState(item.flags) : !!item.flags[stateFilter]; + const decisionMatch = "all" === decisionFilter || item.decisionState === decisionFilter; + const searchMatch = !query || `${item.carNum} ${item.title} ${item.actressName}`.toUpperCase().includes(query); + return categoryMatch && stateMatch && decisionMatch && searchMatch; + })).sort(((left, right) => (right.publishTime || "").localeCompare(left.publishTime || ""))); } - async bindClick() { - this.getSelector(); - const e2 = this.getBean("ListPagePlugin"); - $(document).on("click", ".jhs-card-menu-trigger", ((event) => { - event.preventDefault(), event.stopPropagation(); - const trigger = $(event.currentTarget), menu = trigger.siblings(".jhs-card-menu"), open = !menu.hasClass("is-open"); - this.closeCardMenus(), menu.toggleClass("is-open", open), trigger.attr("aria-expanded", String(open)), open && menu.children().first().trigger("focus"); - })).on("keydown", ".jhs-card-menu [role='menuitem']", ((event) => { - const menu = $(event.currentTarget).closest(".jhs-card-menu"), items = menu.find("[role='menuitem']"), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), this.closeCardMenus(true); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })).on("click", ((event) => { - $(event.target).closest(".more-tools-container").length || this.closeCardMenus(); - })), $(document).on("click", ".videoSvg", ((t3) => { - t3.preventDefault(), $('.videoSvg[title!="播放视频"]').each(((t4, n4) => { - const a4 = $(n4); - let i3 = a4.closest(".item"), s3 = i3.find("img"), { carNum: o2 } = e2.findCarNumAndHref(i3); - this.showImg(a4, s3, o2), a4.html(this.videoSvg).attr({ title: "播放视频", "aria-label": "播放视频" }); + getActorCoverRequest(starId, requestMap) { + const existing = requestMap.get(starId); + if (existing) return existing; + const request = gmHttp.get(`${this.nvJavDbUrl}/actors/${starId}?t=d`).then(((html) => { + const page = utils.htmlTo$dom(html), covers = /* @__PURE__ */ new Map(); + page.find(".movie-list .item").each(((index, element) => { + const item = $(element), rawCarNum = item.find(".video-title strong").text().trim(), carNum = normalizeCarNum(rawCarNum), rawCover = item.find("img").attr("src") || ""; + if (!carNum || !rawCover) return; + const coverUrl = new URL(rawCover, this.nvJavDbUrl).href.replace("thumbs", "covers"), title = item.find(".video-title").text().replace(rawCarNum, "").trim(); + covers.set(carNum, { coverUrl, title }); })); - const n3 = $(t3.target).closest(".item"), a3 = n3.find(".videoSvg"); - if ("播放视频" === a3.attr("title")) { - a3.html(this.recoveryVideoSvg).attr({ title: "切回封面", "aria-label": "切回封面" }); - const { carNum: t4 } = e2.findCarNumAndHref(n3); - let i3 = n3.find("img"); - if (!i3.length) return void show.error("没有找到图片"); - void this.showVideo(a3, i3, t4).catch(((error) => clog.error("卡片预览视频打开失败", error))); - } - })), $(document).on("click", ".screenSvg", (async (t3) => { - t3.preventDefault(); - let n3 = loading(); - try { - const a3 = $(t3.currentTarget).closest(".item"); - let { carNum: i3 } = e2.findCarNumAndHref(a3); - i3 = i3.replace("FC2-", ""); - const s3 = await this.getBean("ScreenShotPlugin").getScreenshot(i3); - n3.close(), showImageViewer(s3); - } catch (a3) { - clog.error("图片预览出错:", a3), show.error("图片预览出错:" + a3); - } finally { - n3.close(); - } - })), $(document).on("click", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", ((t3) => { - t3.preventDefault(), t3.stopPropagation(); - try { - const n3 = $(t3.currentTarget), a3 = n3.closest(".item"), { carNum: i3, url: s3, publishTime: o2, fc2Source } = e2.findCarNumAndHref(a3), r2 = /* @__PURE__ */ __name(async (t4) => { - try { - let n4 = await e2.parseActressName(s3); - const flag = legacyActionToFlag(t4); - if (!flag) throw new Error("不支持的状态操作"); - await stateService.patch(i3, { [flag]: true }, { type: "list-card-state", record: { carNum: i3, url: s3, names: n4, publishTime: o2, fc2Source } }), show.ok("操作成功"); - } catch (r3) { - clog.error("保存操作失败:", r3), show.error("操作失败"); - } - }, "r"); - n3.hasClass("filterBtn") ? utils.q(t3, `是否屏蔽${i3}?`, (() => r2(d))) : n3.hasClass("favoriteBtn") ? void r2(h) : n3.hasClass("hasDownBtn") ? void r2(g) : n3.hasClass("hasWatchBtn") && void r2(p), this.closeCardMenus(); - } catch (t4) { - clog.error("按钮点击处理失败:", t4); - } - })); - const t2 = this.getBean("OtherSitePlugin"), n2 = await t2.getMissAvUrl(), a2 = await t2.getjableUrl(), i2 = await t2.getAvgleUrl(), s2 = await t2.getAv123Url(); - $(this.getSelector().itemSelector).each(((t3, o2) => { - const r2 = $(o2), { carNum: l2 } = e2.findCarNumAndHref(r2); - r2.find(".site-jable").attr({ href: `${a2}/search/${l2}/`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-avgle").attr({ href: `${i2}/vod/search.html?wd=${l2}`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-miss-av").attr({ href: `${n2}/search/${l2}`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-123-av").attr({ href: `${s2}/cn/search?keyword=${encodeURIComponent(l2)}`, target: "_blank", rel: "noopener noreferrer" }); + return covers; + })).finally((() => { + requestMap.get(starId) === request && requestMap.delete(starId); })); - $(document).on("click", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", ((t3) => { + return requestMap.set(starId, request), request; + } + async loadCoverForItems(items, generation = this.nvRenderGeneration) { + return this.hydrateVisibleCovers(items, generation); + } + async hydrateVisibleCovers(items, generation) { + const coverCache = this.nvCoverCache, requestMap = this.nvActorCoverRequests, starIds = [...new Set(items.filter(((item) => !item.coverUrl && !coverCache.has(normalizeCarNum(item.carNum)) && item.starId)).map(((item) => item.starId)))]; + await mapLimit(starIds, 3, (async (starId) => { try { - t3.preventDefault(), t3.stopPropagation(); - const o2 = $(t3.currentTarget), r2 = o2.closest(".item"), { carNum: l2 } = e2.findCarNumAndHref(r2); - let c2 = null; - o2.hasClass("site-jable") ? c2 = `${a2}/search/${l2}/` : o2.hasClass("site-avgle") ? c2 = `${i2}/vod/search.html?wd=${l2}` : o2.hasClass("site-miss-av") ? c2 = `${n2}/search/${l2}` : o2.hasClass("site-123-av") && (c2 = `${s2}/cn/search?keyword=${encodeURIComponent(l2)}`), t3 && (t3.ctrlKey || t3.metaKey) ? GM_openInTab(c2, { insert: 0 }) : window.open(c2), this.closeCardMenus(); - } catch (t4) { - clog.error("站点按钮处理失败:", t4); + const covers = await this.getActorCoverRequest(starId, requestMap); + if (generation !== this.nvRenderGeneration || coverCache !== this.nvCoverCache || !this.isWorkspaceMounted()) return; + covers.forEach(((value, carNum) => { + coverCache.set(normalizeCarNum(carNum), value.coverUrl); + const card = $(".nv-card").filter(((_2, element) => normalizeCarNum($(element).attr("data-car")) === normalizeCarNum(carNum))); + if (!card.length) return; + const image = $('').attr({ src: value.coverUrl, "data-full": value.coverUrl }).on("error", (function() { + $(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden"); + })); + card.find(".nv-placeholder").replaceWith(image), card.find(".nv-card__empty").addClass("jhs-is-hidden"), value.title && card.attr("title", value.title); + })); + window.imageHoverPreviewObj?.bindEvents?.(); + } catch (error) { + clog.warn(`获取演员封面失败: ${starId}`, error); } - })), $(document).on("click", ".titleSvg, .carNumSvg, .downSvg", ((t3) => { - t3.preventDefault(), t3.stopPropagation(); - const n3 = $(t3.currentTarget).closest(".item"), { carNum: a3, title: i3 } = e2.findCarNumAndHref(n3), s3 = n3.find(l ? ".photo-frame img" : ".cover img"); - $(t3.currentTarget).hasClass("titleSvg") ? utils.copyToClipboard("标题", i3) : $(t3.currentTarget).hasClass("carNumSvg") ? utils.copyToClipboard("番号", a3) : $(t3.currentTarget).hasClass("downSvg") && fetch(s3.attr("src")).then(((e3) => e3.blob())).then(((e3) => utils.download(e3, a3 + " " + i3 + ".jpg"))), this.closeCardMenus(); })); } - showImg(e2, t2, n2) { - e2.html(this.videoSvg).attr({ title: "播放视频", "aria-label": "播放视频" }); - let a2 = $(`#${`${n2}_preview_video`}`); - a2.length > 0 && (a2[0].pause(), a2.parent().hide()), t2.show(), t2.removeClass("loading"), t2.next(".loading-spinner").remove(); - } - async showVideo(e2, t2, n2) { - const a2 = `${n2}_preview_video`; - let i2 = $(`#${a2}`); - if (i2.length > 0) return i2.parent().show(), await safePlay(i2[0], { - context: "列表卡片预览", - notify: true - }), void t2.hide(); - t2.addClass("loading"), t2.after('
    '); - const s2 = t2.attr("src"), { sources: o2, error: previewError } = await fetchDmmPreview(n2); - if (!o2) return show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未解析到视频"), void this.showImg(e2, t2, n2); - let r2 = await storageManager.getSetting("videoQuality"); - r2 = Z(Object.keys(o2), r2); - let c2 = o2[r2], d2 = ` -
    - -
    `; - l && (d2 = `
    `), t2.parent().append(d2), t2.hide(), t2.removeClass("loading"), t2.next(".loading-spinner").remove(), i2 = $(`#${a2}`); - let h2 = i2[0]; - h2.load(), h2.muted = false, await safePlay(h2, { - context: "列表卡片预览", - notify: true, - message: "REGION_BLOCKED" === previewError?.code ? previewError.message : "当前视频源无法播放" - }), i2.trigger("focus"); - } - }; - __name(_CoverButtonPlugin, "CoverButtonPlugin"); - var CoverButtonPlugin = _CoverButtonPlugin; - var AV123_REQUEST_OPTIONS = Object.freeze({ cookiePartitionTopLevelSite: "https://123av.com" }); - var _Fc2By123AvPlugin = class _Fc2By123AvPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "$contentBox", $(".section .container")), i(this, "urlParams", new URLSearchParams(window.location.search)), i(this, "currentPage", this.urlParams.get("page") ? parseInt(this.urlParams.get("page")) : 1), i(this, "maxPage", null), i(this, "keyword", this.urlParams.get("keyword") || null); - } - getName() { - return "Fc2By123AvPlugin"; - } - async getBaseUrl() { - return await this.getBean("OtherSitePlugin").getAv123Url(); - } - request123Av(e2, requestOptions = {}) { - return gmHttp.get(e2, {}, {}, false, { ...AV123_REQUEST_OPTIONS, ...requestOptions }); - } - async handle() { - $("#navbar-menu-hero > div > div:nth-child(1) > div > a:nth-child(4)").after('123Av-Fc2'), $('.tabs li:contains("FC2")').after('
  • 123Av-Fc2
  • '), o.includes("/advanced_search?type=100") && (this.hookPage(), await this.handleQuery()); + async renderNewVideoList() { + const container = $("#new-video-list-container"), generation = this.nvRenderGeneration; + if (!container.length) return; + const items = await this.getNewVideoFlatList(); + if (generation !== this.nvRenderGeneration || !this.isWorkspaceMounted()) return; + this.nvFlatListCache = items, this.nvSortBy = $("#nvSortBy").val() || this.nvSortBy; + const totalPages = Math.ceil(items.length / this.nvPageSize); + this.nvCurrentPage = totalPages ? Math.min(this.nvCurrentPage, totalPages) : 1; + if (!items.length) { + const query = String($("#nvSearch").val() || "").trim(), filtered = ["nvCategoryFilter", "nvStateFilter", "nvDecisionFilter"].some(((id) => $("#" + id).val() !== ("nvCategoryFilter" === id ? "all" : "pending"))); + if (!this.nvAllItemsMap.size) renderStateView(container, { type: "empty", title: "暂无新作品记录" }); + else if (query) renderStateView(container, { type: "empty", title: `没有找到“${query}”的作品`, actionLabel: "清除搜索", onAction: /* @__PURE__ */ __name(() => { + $("#nvSearch").val(""), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); + }, "onAction") }); + else if (filtered) renderStateView(container, { type: "empty", title: "没有符合当前筛选条件的作品", actionLabel: "清除筛选", onAction: /* @__PURE__ */ __name(() => { + JhsSelect.setValue("#nvCategoryFilter", "all", false), JhsSelect.setValue("#nvStateFilter", "pending", false), JhsSelect.setValue("#nvDecisionFilter", "pending", false), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); + }, "onAction") }); + else renderStateView(container, { type: "empty", title: "暂无待处理的新作品" }); + return void this.renderBatchBar(); + } + this.nvRenderPage(generation), this.renderBatchBar(); } - hookPage() { - let e2 = $("h2.section-title"); - e2.contents().first().replaceWith("123Av"), e2.css("marginBottom", "0"), e2.append('\n
    \n \n \n \n
    \n '), $("#search-123av-keyword").val(this.keyword), $("#search-123av-btn").on("click", (async () => { - let e3 = $("#search-123av-keyword").val().trim(); - e3 && (this.keyword = e3, utils.setHrefParam("keyword", e3), await this.handleQuery()); - })), $("#clear-123av-btn").on("click", (async () => { - $("#search-123av-keyword").val(""), this.keyword = "", utils.setHrefParam("keyword", ""), $(".page-box").show(), await this.handleQuery(); - })), $(".empty-message").remove(), $("#foldCategoryBtn").remove(), $(".section .container .box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append('
    '), this.$contentBox.append('
    '); - utils.setHrefParam("page", this.currentPage); - $(".page-box").append('\n \n '), $(document).on("click", ".pagination-link", ((e3) => { - e3.preventDefault(), this.currentPage = parseInt($(e3.target).data("page")), utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery(); - })), $(".pagination-previous").on("click", ((e3) => { - e3.preventDefault(), this.currentPage > 1 && (this.currentPage--, utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery()); - })), $(".pagination-next").on("click", ((e3) => { - e3.preventDefault(), this.currentPage < this.maxPage && (this.currentPage++, utils.setHrefParam("page", this.currentPage), this.renderPagination(), this.handleQuery()); - })); + selectedItems() { + return [...this.nvSelected].map(((carNum) => this.nvAllItemsMap.get(normalizeCarNum(carNum)))).filter(Boolean); } - renderPagination() { - const e2 = $(".pagination-list"); - e2.empty(); - let t2 = Math.max(1, this.currentPage - 2), n2 = Math.min(this.maxPage, this.currentPage + 2); - this.currentPage <= 3 ? n2 = Math.min(6, this.maxPage) : this.currentPage >= this.maxPage - 2 && (t2 = Math.max(this.maxPage - 5, 1)), t2 > 1 && (e2.append('
  • '), t2 > 2 && e2.append('
  • …
  • ')); - for (let a2 = t2; a2 <= n2; a2++) { - const t3 = a2 === this.currentPage ? " is-current" : ""; - e2.append(`
  • `); - } - n2 < this.maxPage && (n2 < this.maxPage - 1 && e2.append('
  • …
  • '), e2.append(`
  • `)); + applyProcessedSelection(result) { + (result?.changed || []).map(normalizeCarNum).filter(Boolean).forEach(((carNum) => this.nvSelected.delete(carNum))), this.renderBatchBar(); } - async handleQuery() { - let e2 = loading(); + async runBatchMutation(mutate, successLabel) { + const items = this.selectedItems(); + if (!items.length) return void show.info("请先选择作品"); try { - let e3 = [2 * this.currentPage - 1, 2 * this.currentPage]; - this.keyword && (e3 = [1], $(".page-box").hide()); - const t2 = await this.getBaseUrl(); - const requests = e3.map(((sourcePage) => this.request123Av(this.keyword ? `${t2}/cn/search?keyword=${encodeURIComponent(this.keyword)}` : `${t2}/cn/makers/fc2?page=${sourcePage}`))); - const pages = (await Promise.all(requests)).map(((html) => utils.htmlTo$dom(html))); - const i2 = merge123AvCards(pages.map((($page) => parse123AvCards($page, t2)))); - if (!this.keyword && !this.maxPage && pages.length) { - const sourceMaxPage = parse123AvSourceMaxPage(pages[0], t2); - sourceMaxPage && (this.maxPage = Math.ceil(sourceMaxPage / 2), this.renderPagination()); - } - if (0 === i2.length) { - clog.log(i2), show.error("无结果"); - const e4 = this.keyword ? `${t2}/cn/search?keyword=${encodeURIComponent(this.keyword)}` : `${t2}/cn/makers/fc2`; - clog.error("获取数据失败!", e4); - } - let s2 = this.markDataListHtml(i2); - $(".movie-list").html(s2), await utils.smoothScrollToTop(); - } catch (t2) { - clog.error(t2); - } finally { - e2.close(); + const result = await mutate(items); + this.applyProcessedSelection(result), result?.changed?.length ? show.ok(`${successLabel} ${result.changed.length} 个番号`) : show.info("没有需要更新的项目"); + } catch (error) { + clog.error("批量操作失败", error), show.error(`批量操作失败: ${error.message || error}`); } } - async open123AvFc2Dialog(carNum, url) { - return this.getBean("Fc2Plugin").openFc2Dialog(null, carNum, url, { source: "123av" }); + bindBatchActions() { + const footer = $("#new-video-list-footer"); + footer.off(".jhsNvBatch").on("click.jhsNvBatch", "#nvSelectPage", (() => { + (this.nvCurrentPageItems || []).forEach(((item) => this.nvSelected.add(normalizeCarNum(item.carNum)))), $("#new-video-list-container .nv-select").prop("checked", true), this.renderBatchBar(); + })).on("click.jhsNvBatch", "#nvClearSelection", (() => { + this.nvSelected.clear(), $("#new-video-list-container .nv-select").prop("checked", false), this.renderBatchBar(); + })).on("click.jhsNvBatch", "#batchMarkFavorite,#batchMarkWatched,#batchMarkDownloaded", ((event) => { + const flag = { batchMarkFavorite: "favorite", batchMarkWatched: "watched", batchMarkDownloaded: "downloaded" }[event.currentTarget.id]; + void this.runBatchMutation(((items) => stateService.patch(items.map(((item) => item.carNum)), { [flag]: true }, { type: "new-video-batch-state", records: items.map(((item) => ({ carNum: item.carNum, url: item.url || `/search?q=${encodeURIComponent(item.carNum)}`, names: item.actressName, publishTime: item.publishTime }))) })), "已处理"); + })).on("click.jhsNvBatch", "#batchIgnore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "ignored")), "已忽略"))).on("click.jhsNvBatch", "#batchSnooze", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "snoozed", new Date(Date.now() + 7 * 864e5).toISOString())), "已暂缓"))).on("click.jhsNvBatch", "#batchRestore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), null)), "已恢复"))).on("click.jhsNvBatch", "#batchRemoveFromNewVideo", ((event) => { + const items = this.selectedItems(); + items.length && utils.q(event, `确认将 ${items.length} 个作品从新作列表移除?
    不会删除作品状态记录。`, (() => void this.runBatchMutation(((selected) => stateService.removeFromNewVideoList(selected.map(((item) => item.carNum)), "manual")), "已移除"))); + })); } - /** 将 123AV 数据填入 Fc2Plugin 创建的固定工作区。 */ - async loadDetail(context, url) { - const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum), movieIdPromise = resolveJavDbMovieId(context.carNum), fc2Plugin = this.getBean("Fc2Plugin"); - void fc2Plugin.configureJavDbWantButton(context, movieIdPromise), void fc2Plugin.mountPanels(context, movieIdPromise), void movieIdPromise.then(((movieId) => context.isAlive() && fc2Plugin.fetchAndRenderNativeMagnets(context, movieId))).catch(((error) => { - context.isAlive() && fc2Plugin.setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))), clog.error("123AV 磁力关联失败", error); + renderBatchBar() { + const footer = $("#new-video-list-footer"); + if (!footer.length) return; + const actresses = new Set(this.nvFlatListCache.flatMap(((item) => item.actresses || [item.actressName])).filter(Boolean)), selected = this.nvSelected.size, visibleSelected = this.nvFlatListCache.filter(((item) => this.nvSelected.has(normalizeCarNum(item.carNum)))).length; + if (!selected) return void footer.html(`
    共 ${this.nvFlatListCache.length} 个番号,涉及 ${actresses.size} 位演员
    `); + footer.html(`
    已选择 ${selected} 项${visibleSelected !== selected ? `,当前结果中 ${visibleSelected} 项` : ""}
    更多 ▾
    `); + } + nvSortList(e2) { + const t2 = this.nvSortBy || "publishTime_desc", [n2, a2] = t2.split("_"); + return e2.slice().sort(((e3, t3) => { + let i2 = 0; + switch (n2) { + case "publishTime": + i2 = (e3.publishTime || "").localeCompare(t3.publishTime || ""); + break; + case "actress": + i2 = (e3.actressName || "").localeCompare(t3.actressName || ""); + break; + case "carNum": + i2 = (e3.carNum || "").localeCompare(t3.carNum || ""); + break; + case "voteCount": + i2 = (e3.voteCount || 0) - (t3.voteCount || 0); + break; + } + return "desc" === a2 ? -i2 : i2; })); - imagesPromise.then(((images) => context.isAlive() && this.getBean("Fc2Plugin").renderGallery(context, images))).catch(((error) => context.isAlive() && this.getBean("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))))); - actressPromise.then((async (data) => { - await infoPromise.catch((() => null)); - context.isAlive() && this.render123AvActress(context, data); - })).catch(((error) => clog.error("FC2 演员信息加载失败", error))); - await Promise.allSettled([infoPromise, imagesPromise, actressPromise, movieIdPromise]); } - async loadSummary(context, url) { - try { - const info = await this.get123AvVideoInfo(url); - if (!context.isAlive()) return null; - this.render123AvSummary(context, info), await this.getBean("TranslatePlugin").translate(context.carNum, false, { root: context.root }); - return info; - } catch (error) { - context.isAlive() && this.getBean("Fc2Plugin").setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); - throw error; + nvRenderPage(generation = this.nvRenderGeneration) { + const e2 = this.nvFlatListCache; + if (!e2 || 0 === e2.length) return; + const t2 = this.nvSortList(e2), n2 = this.nvPageSize, a2 = (this.nvCurrentPage - 1) * n2, i2 = a2 + n2, s2 = t2.slice(a2, i2), o2 = Math.ceil(t2.length / n2), r2 = this.nvJavDbUrl; + this.nvCurrentPageItems = s2; + if (generation !== this.nvRenderGeneration) return; + const l2 = $("#new-video-list-container"); + let c2 = ""; + c2 += '
    '; + for (const n3 of s2) { + const key = normalizeCarNum(n3.carNum), e3 = escapeHtml(key), t3 = escapeHtml(n3.title || key), cachedCover = this.nvCoverCache.get(key), a3 = escapeHtml((cachedCover || n3.coverUrl || "").replace("thumbs", "covers")), i3 = escapeHtml(n3.url || `${r2}/search?q=${encodeURIComponent(key)}`); + let o3 = `番号: ${e3}\\n演员: ${escapeHtml(n3.actressName)}\\n发行: ${n3.publishTime || "未知"}`; + n3.voteCount && (o3 += `\\n评价人数: ${n3.voteCount}`); + const l3 = n3.voteCount ? `${n3.voteCount}人评价` : ""; + c2 += ``; } - } - async retryResolvedMagnets(context) { - try { - return await this.getBean("Fc2Plugin").fetchAndRenderNativeMagnets(context, await resolveJavDbMovieId(context.carNum)); - } catch (error) { - context.isAlive() && this.getBean("Fc2Plugin").setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))); + c2 += "
    "; + if (o2 > 1) { + c2 += '
    '; + this.nvCurrentPage > 1 && (c2 += ``); + let e3 = Math.max(1, this.nvCurrentPage - 2), n3 = Math.min(o2, e3 + 4); + n3 - e3 < 4 && (e3 = Math.max(1, n3 - 4)); + for (let t3 = e3; t3 <= n3; t3++) c2 += ``; + this.nvCurrentPage < o2 && (c2 += ``), c2 += `第 ${this.nvCurrentPage}/${o2} 页,共 ${t2.length} 条`, c2 += "
    "; } + l2.html(c2), l2.find(".nv-cover-img").on("error", (function() { + $(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden"); + })), l2.find(".nv-select").on("change", ((event) => { + const carNum = normalizeCarNum(event.currentTarget.value); + event.currentTarget.checked ? this.nvSelected.add(carNum) : this.nvSelected.delete(carNum), this.renderBatchBar(); + })), l2.find(".pagination-btn").off("click").on("click", ((e3) => { + const n3 = parseInt($(e3.currentTarget).data("nvpage")); + n3 >= 1 && n3 <= o2 && n3 !== this.nvCurrentPage && (this.nvCurrentPage = n3, this.nvRenderGeneration++, this.nvRenderPage(this.nvRenderGeneration), this.renderBatchBar(), l2.scrollTop(0)); + })), window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ + selector: ".nv-cover-img", + dataAttribute: "data-full" + }), void this.hydrateVisibleCovers(s2, generation); } - render123AvSummary(context, info) { - const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

    '); - title.find("strong").text(info.title || "无标题"), body.append(title, $('
    ').append($("").text(`番号:${context.carNum}`), $("").text(`发行:${info.publishDate || "未知"}`)), '
    主演:正在加载演员…
    ', '
    ', this.getBean("Fc2Plugin").createSourceLinks(context), $('').text(info.publishDate || "")); - } - async get123AvVideoInfo(e2) { - const t2 = await this.request123Av(e2); - return parse123AvVideoInfo(utils.htmlTo$dom(t2), e2); + async editActress(e2) { + const t2 = String(e2.name || ""), n2 = normalizeHttpUrl(e2.avatar, this.nvJavDbUrl) || "", a2 = String(e2.remark || ""), i2 = Array.isArray(e2.allName) ? e2.allName.join(",") : "", s2 = Array.isArray(e2.newVideoList) ? e2.newVideoList.map(((e3) => "string" == typeof e3 ? e3 : e3.carNum)).join(",") : "", o2 = String(e2.starId || ""), l2 = e2.actressType || "", safe = /* @__PURE__ */ __name((value) => escapeHtml(String(value || "")), "safe"), c2 = ` +
    +
    + Avatar Preview +
    + + +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + `; + layer.open({ + type: 1, + title: `编辑女优: ${safe(t2)} (${safe(o2)})`, + area: utils.getDialogArea("sm"), + content: c2, + btn: ["保存", "取消"], + success: /* @__PURE__ */ __name((e3, t3) => { + JhsSelect.enhance(e3); + const n3 = /* @__PURE__ */ __name((e4) => { + e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); + }, "n"); + $("#edit-actress-avatar").on("input", (function() { + const e4 = $(this).val(); + $("#edit-avatar-preview").attr("src", e4); + })); + const a3 = $("#edit-actress-allname"); + a3.on("input", (function() { + n3($(this)); + })), n3(a3); + const i3 = $("#edit-actress-newvideolist"); + i3.on("input", (function() { + n3($(this)); + })), n3(i3), $("#search-avatar-btn").on("click", (async () => { + await this.searchAvatar(); + })), $("#select-cdn-btn").on("click", (async () => { + await (async function() { + const e4 = at, t4 = tt.map(((t5, n5) => ` + + `)).join(""), n4 = ` +
    +

    请选择头像数据源 (当前: ${tt[e4].name}):

    + ${t4} +

    切换源会清除本地缓存的数据,并在下次搜索时重新加载。

    +
    + `; + layer.open({ + type: 1, + title: "选择 CDN 源", + area: utils.getResponsiveArea(["400px", "auto"]), + content: n4, + btn: ["确定", "取消"], + success: /* @__PURE__ */ __name((e5, t5) => { + utils.setupEscClose(t5); + }, "success"), + yes: /* @__PURE__ */ __name(async (e5) => { + const t5 = $('input[name="cdn-source"]:checked').val(), n5 = parseInt(t5, 10); + if (n5 !== at) { + at = n5, localStorage.setItem(nt, n5.toString()), it = tt[n5].json, st = tt[n5].base, ct = null, dt = null; + try { + await lt.set(rt, null); + } catch (a4) { + clog.error("清除 IndexedDB 缓存失败:", a4); + } + show.ok(`CDN 源已切换为: ${tt[n5].name}`), layer.close(e5); + } else layer.close(e5); + }, "yes") + }); + })(); + })), utils.setupEscClose(t3); + }, "success"), + yes: /* @__PURE__ */ __name(async (t3) => { + const n3 = $("#edit-actress-avatar").val().trim(), a3 = $("#edit-actress-name").val().trim(), i3 = $("#edit-actress-allname").val().trim(), s3 = $("#edit-actress-newvideolist").val().trim(), o3 = $("#edit-remark").val().trim(), r2 = $("#actressType").val(); + if (!a3) return show.error("主名称不能为空"), false; + const l3 = i3.split(/[\uff0c,]/).map(((e3) => e3.trim())).filter(((e3) => e3.length > 0)), c3 = s3.split(/[\uff0c,]/).map(((e3) => e3.trim())).filter(((e3) => e3.length > 0)); + e2.avatar = n3, e2.name = a3, e2.allName = l3, e2.newVideoList = c3, e2.actressType = r2, e2.remark = o3; + try { + await storageManager.updateFavoriteActress(e2); + await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-edited" }); + show.ok(`女优 ${a3} 信息已更新`); + layer.close(t3); + } catch (err) { + show.error("修改失败: " + (err.message || err)); + } + }, "yes") + }); } - async getActressInfo(e2) { - let t2 = `https://fc2ppvdb.com/articles/${e2.replace("FC2-", "")}`; - const n2 = await gmHttp.get(t2), a2 = $(n2), i2 = a2.find("div").filter((function() { - return 0 === $(this).text().trim().indexOf("女優:"); - })); - if (0 === i2.length || i2.length > 1) return { actors: [], seller: null }; - const s2 = $(i2[0]).find("a"); - const actors = []; - if (s2.length > 0) { - s2.each(((t3, n3) => { - const link = $(n3), name = link.text().trim(), url = normalizeHttpUrl(link.attr("href"), "https://fc2ppvdb.com"); - name && actors.push({ name, url }); - })); + renderPagination(e2, t2) { + const n2 = this.currentPage; + let a2 = ""; + const i2 = $("#actress-pagination"); + if (0 === t2) return a2 = '共 0 条记录', void i2.html(a2); + n2 > 1 && t2 > 5 && (a2 += ''), n2 > 1 && (a2 += ``); + let s2 = Math.max(1, n2 - Math.floor(2.5)), o2 = Math.min(t2, s2 + 5 - 1); + o2 - s2 < 4 && (s2 = Math.max(1, o2 - 5 + 1)); + for (let r2 = s2; r2 <= o2; r2++) { + a2 += ``; } - const r2 = a2.find("div").filter((function() { - return 0 === $(this).text().trim().indexOf("販売者:"); + n2 < t2 && (a2 += ``), n2 < t2 && t2 > 5 && (a2 += ``), a2 += `共 ${e2} 条记录 (第 ${n2}/${t2} 页)`, i2.html(a2), i2.find(".pagination-btn").off("click").on("click", ((e3) => { + if ($(e3.currentTarget).is("[disabled]")) return; + const n3 = parseInt($(e3.currentTarget).data("page")); + n3 >= 1 && n3 <= t2 && n3 !== this.currentPage && (this.currentPage = n3, this.renderActressCards()); })); - let seller = null; - if (r2.length > 0) { - const link = $(r2[0]).find("a").first(); - link.length && (seller = { name: link.text().trim(), url: normalizeHttpUrl(link.attr("href"), "https://fc2ppvdb.com") }); - } - return { actors, seller }; - } - async getImgList(e2) { - let t2 = e2.replace("FC2-", ""), n2 = `https://adult.contents.fc2.com/article/${e2.replace("FC2-", "")}/`; - const a2 = await gmHttp.get(n2, null, { - referer: n2 - }); - return $(a2).find(".items_article_SampleImagesArea img").map((function() { - return normalizeHttpUrl($(this).attr("src"), n2); - })).get().filter(Boolean); } - async reloadImages(context) { + async searchAvatar() { + const e2 = $("#edit-actress-name"), t2 = $("#edit-actress-allname"), n2 = e2.val().trim(), a2 = t2.val().trim().split(/[\uff0c,]/).map(((e3) => e3.trim())).filter(((e3) => e3.length > 0)); + if (n2 && a2.unshift(n2), 0 === a2.length) return void show.error("请先填写女优主名称或别名进行搜索。"); + const i2 = loading("正在搜索头像..."); + let s2 = []; try { - const images = await this.getImgList(context.carNum); - context.isAlive() && this.getBean("Fc2Plugin").renderGallery(context, images); - } catch (error) { - context.isAlive() && this.getBean("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))); + s2 = await gt(a2); + } catch (c2) { + return void show.error(`头像数据加载或搜索失败: ${c2.message || c2}`); + } finally { + i2.close(); } - } - render123AvActress(context, data) { - const host = context.root.find('[data-jhs-role="actors"]').empty().append("主演:"); - data.actors.length ? data.actors.forEach(((actor) => host.append($("").addClass("jhs-fc2-actor").attr({ href: actor.url, target: "_blank", rel: "noopener noreferrer" }).text(actor.name)))) : host.append($("").text("暂无演员信息")); - context.root.find('[data-jhs-role="actress-data"]').remove(), context.root.find(".jhs-fc2-summary__body").append($('').text(data.actors.map(((actor) => actor.name)).join(" "))); - if (data.seller) context.root.find('[data-jhs-role="seller"]').empty().append("卖家:", data.seller.url ? $("").attr({ href: data.seller.url, target: "_blank", rel: "noopener noreferrer" }).text(data.seller.name) : document.createTextNode(data.seller.name)); - } - markDataListHtml(e2) { - let t2 = ""; - return e2.forEach(((e3) => { - const href = normalizeHttpUrl(e3.href, "https://123av.com"), imageUrl = normalizeHttpUrl(e3.imgSrc, "https://123av.com"); - if (!href) return; - t2 += ` - - `; - })), t2; - } - }; - __name(_Fc2By123AvPlugin, "Fc2By123AvPlugin"); - var Fc2By123AvPlugin = _Fc2By123AvPlugin; - var MAGNET_SOURCE_IDS = Object.freeze(["native-javdb", "native-javbus", "u9a9", "u3c3", "sukebei", "btsow"]); - function normalizeMagnetResult(result, source) { - if (!result || !String(result.magnet || "").startsWith("magnet:")) return null; - return { title: String(result.title || ""), magnet: result.magnet, size: result.size || "", date: result.date || "", seeders: Number(result.seeders) || 0, leechers: Number(result.leechers) || 0, source, files: Array.isArray(result.files) ? result.files : [] }; - } - __name(normalizeMagnetResult, "normalizeMagnetResult"); - function extractInfoHash(magnet) { - const hash = new URL(magnet).searchParams.get("xt")?.match(/^urn:btih:([a-z2-7]{32}|[a-f\d]{40})$/i)?.[1]; - return hash ? hash.toUpperCase() : null; - } - __name(extractInfoHash, "extractInfoHash"); - function deduplicateMagnetResults(results) { - const unique = /* @__PURE__ */ new Map(); - results.forEach(((result) => { - const key = extractInfoHash(result.magnet) || `${result.source}:${result.magnet}`; - const existing = unique.get(key); - existing ? existing.sources = [.../* @__PURE__ */ new Set([...existing.sources || [existing.source], result.source])] : unique.set(key, { ...result, sources: [result.source] }); - })); - return [...unique.values()]; - } - __name(deduplicateMagnetResults, "deduplicateMagnetResults"); - var _MagnetSourceRegistry = class _MagnetSourceRegistry { - constructor(sources = []) { - this.sources = /* @__PURE__ */ new Map(); - sources.forEach(((source) => this.register(source))); - } - register(source) { - if (!source?.id || !source.name || "function" !== typeof source.search || "function" !== typeof source.targetUrl) throw new TypeError("Invalid magnet provider"); - this.sources.set(source.id, { enabled: true, priority: 100, ...source }); - return this; - } - get(id) { - return this.sources.get(id) || null; - } - getEnabledSources() { - return [...this.sources.values()].filter(((source) => source.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); - } - }; - __name(_MagnetSourceRegistry, "MagnetSourceRegistry"); - var MagnetSourceRegistry = _MagnetSourceRegistry; - function validateHttpsBaseUrl(value) { - const url = new URL(value); - if ("https:" !== url.protocol) throw new TypeError("Source URL must use https"); - return url.origin; - } - __name(validateHttpsBaseUrl, "validateHttpsBaseUrl"); - function validateCustomMagnetSource(config) { - const allowed = ["id", "name", "enabled", "priority", "searchUrlTemplate", "targetUrlTemplate", "parserType", "rowSelector", "titleSelector", "magnetSelector", "sizeSelector", "dateSelector", "seedersSelector", "leechersSelector", "resultsPath", "titlePath", "hashPath", "magnetPath", "sizePath", "datePath", "seedersPath"]; - if (Object.keys(config).some(((key) => !allowed.includes(key)))) throw new TypeError("Unsupported custom source field"); - if (!["torrent-table", "magnet-links", "json"].includes(config.parserType)) throw new TypeError("Unsupported parser type"); - if (!String(config.name || "").trim()) throw new TypeError("Source name is required"); - validateHttpsBaseUrl(config.searchUrlTemplate.replace("{keyword}", "test")); - validateHttpsBaseUrl((config.targetUrlTemplate || config.searchUrlTemplate).replace("{keyword}", "test")); - if ("torrent-table" === config.parserType && (!config.rowSelector?.trim() || !config.magnetSelector?.trim())) throw new TypeError("表格来源必须填写结果行选择器和磁力选择器"); - if ("json" === config.parserType && (!config.resultsPath?.trim() || !config.magnetPath?.trim() && !config.hashPath?.trim())) throw new TypeError("JSON 来源必须填写结果数组路径,并填写磁力路径或哈希路径"); - return { ...config, name: config.name.trim(), targetUrlTemplate: config.targetUrlTemplate || config.searchUrlTemplate }; - } - __name(validateCustomMagnetSource, "validateCustomMagnetSource"); - function applyMagnetRules(result, tagRules = [], titleFilters = [], fileFilters = []) { - const text = `${result.title || ""} ${(result.files || []).join(" ")}`; - const matches = /* @__PURE__ */ __name((rule, value) => "regex" === rule.type ? new RegExp(rule.pattern, "i").test(value) : value.toLowerCase().includes(rule.pattern.toLowerCase()), "matches"); - const tags = tagRules.filter(((rule) => rule.enabled && matches(rule, text))); - let hidden = false, penalty = 0; - const filteredReasons = []; - [...titleFilters.map(((rule) => ({ ...rule, value: result.title || "" }))), ...fileFilters.map(((rule) => ({ ...rule, value: (result.files || []).join(" ") })))].filter(((rule) => rule.enabled)).forEach(((rule) => { - if (!matches(rule, rule.value)) return; - filteredReasons.push(rule.id || rule.pattern); - "hide" === rule.action ? hidden = true : penalty += Number(rule.penalty) || 0; - })); - return { ...result, tags: tags.map(((rule) => rule.name)), customTagWeight: tags.reduce(((sum, rule) => sum + (Number(rule.weight) || 0)), 0), hidden, filterPenalty: penalty, filteredReasons }; - } - __name(applyMagnetRules, "applyMagnetRules"); - function parseNativeMagnets(root, source) { - const results = /* @__PURE__ */ new Map(); - $(root).find('a[href^="magnet:"],[data-clipboard-text^="magnet:"]').each(((index, element) => { - const node = $(element), magnet = node.attr("href") || node.attr("data-clipboard-text"); - if (!magnet) return; - const container = node.closest(".item, .magnet-name, tr, .panel-block"), title = container.find(".name, .magnet-name, .title").first().text().trim() || node.text().trim() || "本站磁力"; - const result = normalizeMagnetResult({ title, magnet, size: container.find(".meta, .size").first().text().trim() }, source); - result && results.set(extractInfoHash(magnet) || magnet, result); - })); - return [...results.values()]; - } - __name(parseNativeMagnets, "parseNativeMagnets"); - function readJsonPath(value, path) { - return String(path || "").split(".").filter(Boolean).reduce(((current, key) => current?.[key]), value); - } - __name(readJsonPath, "readJsonPath"); - function parseCustomMagnetResponse(config, payload, sourceId) { - config = validateCustomMagnetSource(config); - if ("json" === config.parserType) return (readJsonPath(payload, config.resultsPath) || []).map(((item) => normalizeMagnetResult({ title: readJsonPath(item, config.titlePath), magnet: config.magnetPath ? readJsonPath(item, config.magnetPath) : `magnet:?xt=urn:btih:${readJsonPath(item, config.hashPath)}`, size: readJsonPath(item, config.sizePath), date: readJsonPath(item, config.datePath), seeders: readJsonPath(item, config.seedersPath) }, `custom:${sourceId}`))).filter(Boolean); - const root = utils.htmlTo$dom(payload), rows = "magnet-links" === config.parserType ? root.find('a[href^="magnet:"]') : root.find(config.rowSelector); - return rows.map(((index, element) => { - const row = $(element), magnetNode = "magnet-links" === config.parserType ? row : row.find(config.magnetSelector).first(); - return normalizeMagnetResult({ title: "magnet-links" === config.parserType ? row.text().trim() : row.find(config.titleSelector).first().text().trim(), magnet: magnetNode.attr("href") || magnetNode.attr("data-magnet"), size: row.find(config.sizeSelector).text().trim(), date: row.find(config.dateSelector).text().trim(), seeders: row.find(config.seedersSelector).text().trim(), leechers: row.find(config.leechersSelector).text().trim() }, `custom:${sourceId}`); - })).get().filter(Boolean); - } - __name(parseCustomMagnetResponse, "parseCustomMagnetResponse"); - function calcMagnetScore(e2) { - let t2 = 0; - const n2 = e2.seeders || 0; - const seedersScore = n2 >= 50 ? 35 : n2 >= 10 ? 25 : n2 >= 1 ? 15 : 3; - t2 += seedersScore; - const a2 = (e2.title || "").toLowerCase(), resolution = String(e2.resolution || "").toLowerCase(); - const resolutionScore = /4k|2160p/.test(resolution) || /4k|2160p/.test(a2) ? 25 : /1080p/.test(resolution) || /1080p/.test(a2) ? 20 : /720p/.test(resolution) || /720p/.test(a2) ? 15 : 5; - t2 += resolutionScore; - const subtitleScore = e2.hasSubtitle || /-c\b|-uc\b|chinese|中字|字幕/.test(a2) ? 20 : 0; - t2 += subtitleScore; - const i2 = e2.date ? _daysSince(e2.date) : 999; - const freshnessScore = i2 <= 7 ? 15 : i2 <= 30 ? 12 : i2 <= 90 ? 8 : 3; - t2 += freshnessScore; - const completenessScore = /sample|预告|trailer/.test(a2) ? -15 : 0; - t2 += completenessScore; - return { total: Math.max(0, Math.min(100, t2)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; - } - __name(calcMagnetScore, "calcMagnetScore"); - function _daysSince(e2) { + if (0 === s2.length) return void show.error(`未找到与 '${a2.join("、")}' 相关的头像。请检查名称。`); + const r2 = $('

    '); + r2.find("#gfriends-prompt").text(`点击图片即可选择(初始共 ${s2.length} 张)`); + const avatarList = r2.find("#gfriends-image-list"); + s2.forEach(((url, index) => { + const candidate = $('').attr({ "data-url": url, "aria-label": `选择第 ${index + 1} 张头像` }); + candidate.append($('').attr("src", url)), candidate.append($('载入中')), avatarList.append(candidate); + })); + let l2 = 0; + layer.open({ + type: 1, + title: `选择女优头像 (${s2.length} 张)`, + area: utils.getResponsiveArea(["900px", "85%"]), + content: r2[0], + btn: ["关闭"], + success: /* @__PURE__ */ __name((e3, t3) => { + const n3 = $(e3), a3 = n3.find(".gfriends-selectable-img"), i3 = n3.find("#gfriends-prompt"), candidates = n3.find(".gfriends-image-item-wrapper"); + a3.each((function() { + const image = $(this), wrapper = image.closest(".gfriends-image-item-wrapper"), size = wrapper.find(".gfriends-size-tag"); + image.on("load", (function() { + size.text(`${this.naturalWidth} x ${this.naturalHeight}`); + })), image.on("error", (function() { + wrapper.remove(), l2++; + const e4 = s2.length - l2; + i3.text(`点击图片即可选择(已移除 ${l2} 张错误图片,剩余 ${e4} 张)`), 0 === e4 && (show.error("所有搜索到的头像链接均已失效,无法选择。"), layer.close(t3)); + })), this.complete && (this.naturalWidth > 0 ? image.trigger("load") : image.trigger("error")); + })), candidates.on("click", (function() { + const candidate = $(this), url = candidate.attr("data-url"); + $("#edit-actress-avatar").val(url), $("#edit-avatar-preview").attr("src", url), candidates.attr("aria-pressed", "false"), candidate.attr("aria-pressed", "true"), setTimeout((() => { + layer.close(t3); + }), 150); + })), utils.setupEscClose(t3); + }, "success") + }); + } + }; + __name(_NewVideoPlugin, "NewVideoPlugin"); + var NewVideoPlugin = _NewVideoPlugin; + + // src/integrations/javdb/parser.js + function parseJavDbActorList($page, baseUrl) { + const challengeText = $page.find("title, body").text(); + if (/Just a moment|cf-chl-|Cloudflare/i.test(challengeText)) return { state: "challenge", isEmpty: false, actors: [], nextUrl: null }; + const container = $page.find("#actors").first(); + if (!container.length) return { state: "invalid", isEmpty: false, actors: [], nextUrl: null }; + const actors = [], boxes = container.find(".actor-box").toArray(); + const wrap = ( + /** @type {any} */ + globalThis.jQuery ?? /** @type {any} */ + globalThis.$ ?? $page.constructor + ); + const normalizeName = /* @__PURE__ */ __name((name) => name.trim(), "normalizeName"); try { - const t2 = new Date(e2); - if (isNaN(t2.getTime())) return 999; - return Math.max(0, Math.floor((Date.now() - t2.getTime()) / 864e5)); - } catch (t2) { - return 999; + for (const box of boxes) { + const $actor = wrap(box).find("a").first(), title = $actor.attr("title"), href = $actor.attr("href"); + if (!title || !href) throw new Error("演员卡缺少身份字段"); + const allName = title.split(",").map(normalizeName).filter(Boolean), actorUrl = new URL(href, baseUrl); + const starId = actorUrl.pathname.split("/").filter(Boolean).pop() || ""; + if (!starId || !allName.length) throw new Error("演员卡身份字段无效"); + actors.push({ + starId, + name: allName[0], + allName, + avatar: $actor.find("img").attr("src"), + actressType: $actor.find(".info").text().trim().includes("無碼") ? "uncensored" : "censored", + lastCheckTime: null, + lastUpdateTime: null + }); + } + const nextHref = $page.find(".pagination-next").attr("href"), nextUrl = nextHref ? new URL(nextHref, baseUrl).href : null; + return { state: "valid", isEmpty: 0 === boxes.length, actors, nextUrl }; + } catch (error) { + return { state: "invalid", isEmpty: false, actors: [], nextUrl: null }; } } - __name(_daysSince, "_daysSince"); - var _MagnetHubPlugin = class _MagnetHubPlugin extends BasePlugin { + __name(parseJavDbActorList, "parseJavDbActorList"); + + // src/plugins/new-video/task.js + var _TaskPlugin = class _TaskPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "sourceRegistry", new MagnetSourceRegistry()), i(this, "searchEngines", []); - } - async initializeSources() { - const settings = new ResourceSettingsService(), overrides = await settings.getBuiltInSources(), custom = await settings.getMagnetSources(); - const configured = /* @__PURE__ */ __name((id) => ({ ...BUILT_IN_MAGNET_SOURCES.find(((source) => source.id === id)) || {}, ...overrides.find(((source) => source.id === id)) || {} }), "configured"); - const baseUrl = /* @__PURE__ */ __name((id, fallback) => String(configured(id).baseUrl || fallback).replace(/\/$/, ""), "baseUrl"); - this.sourceRegistry = new MagnetSourceRegistry([ - { - name: "JavDB 本站", - id: "native-javdb", - applicable: r, - enabled: r, - priority: 1, - search: /* @__PURE__ */ __name(async (keyword, root = document) => parseNativeMagnets(root?.jquery ? root[0] : root, "javdb"), "search"), - targetUrl: /* @__PURE__ */ __name(() => window.location.href, "targetUrl") - }, - { - name: "JavBus 本站", - id: "native-javbus", - applicable: l, - enabled: l, - priority: 2, - search: /* @__PURE__ */ __name(async (keyword, root = document) => parseNativeMagnets(root?.jquery ? root[0] : root, "javbus"), "search"), - targetUrl: /* @__PURE__ */ __name(() => window.location.href, "targetUrl") - }, - { - name: "U9A9", - id: "u9a9", - url: "https://u9a9.com/?type=2&search={keyword}", - targetPage: "https://u9a9.com/?type=2&search={keyword}", - priority: 10, - search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("u9a9", `${baseUrl("u9a9", "https://u9a9.com")}/?type=2&search={keyword}`, keyword), "search"), - targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("u9a9", "https://u9a9.com")}/?type=2&search=${encodeURIComponent(keyword)}`, "targetUrl") - }, - { - name: "U3C3", - id: "u3c3", - url: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", - targetPage: "https://u3c3.com/?search2=a8lr16lo&search={keyword}", - priority: 20, - search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("u3c3", `${baseUrl("u3c3", "https://u3c3.com")}/?search2=a8lr16lo&search={keyword}`, keyword), "search"), - targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("u3c3", "https://u3c3.com")}/?search2=a8lr16lo&search=${encodeURIComponent(keyword)}`, "targetUrl") - }, - { - name: "Sukebei", - id: "sukebei", - url: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", - targetPage: "https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}", - priority: 30, - search: /* @__PURE__ */ __name((keyword) => this.searchTorrentSource("sukebei", `${baseUrl("sukebei", "https://sukebei.nyaa.si")}/?f=0&c=0_0&q={keyword}`, keyword), "search"), - targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("sukebei", "https://sukebei.nyaa.si")}/?f=0&c=0_0&q=${encodeURIComponent(keyword)}`, "targetUrl") - }, - { name: "BTSOW", id: "btsow", priority: 40, search: /* @__PURE__ */ __name((keyword) => this.searchBtsow(keyword, baseUrl("btsow", "https://btsow.lol")), "search"), targetUrl: /* @__PURE__ */ __name((keyword) => `${baseUrl("btsow", "https://btsow.lol")}/search/${encodeURIComponent(keyword)}`, "targetUrl") } - ].map(((source) => { - const config = configured(source.id), applicable = source.applicable ?? true; - return { ...source, ...config, enabled: applicable && (config.enabled ?? source.enabled ?? true), search: source.search, targetUrl: source.targetUrl }; - }))); - custom.filter(((source) => source.enabled)).forEach(((config) => this.sourceRegistry.register({ ...config, id: `custom:${config.id}`, search: /* @__PURE__ */ __name((keyword) => this.searchCustomSource(config, keyword), "search"), targetUrl: /* @__PURE__ */ __name((keyword) => config.targetUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)), "targetUrl") }))); - const enabled = this.sourceRegistry.getEnabledSources().map(((source) => ({ ...source, targetPage: source.targetUrl("{keyword}").replace("%7Bkeyword%7D", "{keyword}") }))); - this.searchEngines = enabled.length ? [{ id: "all", name: "全部", priority: 0, targetPage: "#", search: /* @__PURE__ */ __name((keyword) => this.searchAllSources(enabled, keyword), "search") }, ...enabled] : []; + super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue()), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), i(this, "taskTimer", null), i(this, "taskRunning", false), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "settingsHandler", null), i(this, "taskConfigDirty", false), i(this, "configLoadPromise", null), i(this, "configLoadQueued", false), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", false), i(this, "activeTasks", /* @__PURE__ */ new Set()); } getName() { - return "MagnetHubPlugin"; + return "TaskPlugin"; } - async initCss() { - return ``; + getStartupMode() { + return "idle"; } - async createMagnetHub(e2, options = {}) { - await this.initializeSources(); - e2 = e2.replace("FC2-", ""); - const root = options.root ? $(options.root) : $(document), engines = [...this.searchEngines]; - const t2 = $('
    '), n2 = $('
    '), a2 = "jhs_magnetHub_selectedEngine", i2 = localStorage.getItem(a2); - const o2 = $('
    '); - let currentEngine = engines.find(((engine) => engine.id === i2)) || engines[0] || null; - if (!currentEngine) return t2.append($('
    ').text("暂无可用磁力来源,请前往设置启用来源")); - engines.forEach(((engine) => o2.append($('').attr("data-engine", engine.id).text(engine.name).toggleClass("active", engine.id === currentEngine.id)))); - const target = $('原网页').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id); - n2.append(o2), n2.append(target), o2.find(".magnet-tab.active").attr({ "aria-selected": "true", tabindex: "0" }), t2.append(n2); - const r2 = $('
    '); - return t2.append(r2), t2.on("click", ".magnet-tab", ((n3) => { - const i3 = $(n3.target).data("engine"); - currentEngine = engines.find(((e3) => e3.id === i3)), t2.find('[data-jhs-role="magnet-target"]').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id), localStorage.setItem(a2, i3), t2.find(".magnet-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }), $(n3.target).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }), this.searchEngine(r2, currentEngine, e2, root); - })), t2.on("keydown", ".magnet-tab", ((e3) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e3.key)) return; - e3.preventDefault(); - const n3 = t2.find(".magnet-tab"), a3 = n3.index(e3.currentTarget); - let i3 = "Home" === e3.key ? 0 : "End" === e3.key ? n3.length - 1 : "ArrowRight" === e3.key ? (a3 + 1) % n3.length : (a3 - 1 + n3.length) % n3.length; - n3.eq(i3).trigger("click").trigger("focus"); - })), this.searchEngine(r2, currentEngine, e2, root), t2; + async limitConcurrency(e2, t2, n2, a2) { + let i2 = 0, s2 = false; + const o2 = Math.max(1, Math.min(t2, e2.length)), r2 = Array.from({ length: o2 }, (async () => { + for (; !s2; ) { + const t3 = i2++; + if (t3 >= e2.length) return; + try { + await a2(e2[t3]); + } catch (e3) { + if (this.isNetworkBlocked(e3)) throw s2 = true, e3; + throw e3; + } + const o3 = e2.length - i2; + o3 > 0 && (clog.debug(`剩余任务数: ${o3}`), await utils.sleep(n2)); + } + })); + const l2 = await Promise.allSettled(r2), c2 = l2.find(((e3) => "rejected" === e3.status)); + if (c2) throw c2.reason; } - async searchEngine(e2, t2, n2, root = $(document)) { - e2.html(`
    正在从 ${escapeHtml(t2.name)} 搜索 "${escapeHtml(n2)}"...
    `); - const a2 = `${t2.name}_${n2}`; - if (t2.search) try { - return void this.displayResults(e2, await this.applyRuntimeRules(deduplicateMagnetResults(await t2.search(n2, root))), t2.name); + isNetworkBlocked(e2) { + return true === e2?._cfBlocked || true === e2?._circuitBroken; + } + createTaskResult(extra = {}) { + return { attempted: false, completed: false, fatal: false, success: 0, networkFailed: 0, parseFailed: 0, aborted: 0, skippedInterval: 0, skippedStopped: 0, ...extra }; + } + async runBackgroundTask(name, runner) { + try { + return await runner(); } catch (error) { - clog.error(`磁力源 ${t2.name} 请求失败`, error); - return void e2.html(`
    ${escapeHtml(t2.name)} 请求失败
    `); - } - if (t2.parseHtml) try { - const i2 = t2.url.replace("{keyword}", encodeURIComponent(n2)), s2 = await storageManager.cachedRequest(`magnet:${t2.id}:${n2}`, 216e5, (() => gmHttp.get(i2).then(((e3) => t2.parseHtml.call(this, e3, n2))))); - return void this.displayResults(e2, s2, t2.name); - } catch (s2) { - return void e2.html(`
    解析 ${escapeHtml(t2.name)} 结果失败: ${escapeHtml(s2.message)}
    `); + if (this.isNetworkBlocked(error)) throw error; + return clog.error(`${name}执行失败,继续后续后台任务`, error), this.createTaskResult({ attempted: true, error }); } - t2.parseJson && await t2.parseJson.call(this, e2, t2, n2, a2); } - async searchTorrentSource(source, template, keyword) { - const url = template.replace("{keyword}", encodeURIComponent(keyword)); - return storageManager.cachedRequest(`magnet:${source}:${keyword}`, CACHE_TTL.magnet, (async () => { - const html = await gmHttp.get(url); - return this.parseTorrentList(html, keyword).map(((item) => ({ ...item, source, files: [] }))); - })); + isUnnecessaryCheck(e2, t2) { + if (!t2) throw new Error("未传入checkIntervalTime"); + t2 = parseInt(t2); + return utils.getHourDifference(new Date(e2), /* @__PURE__ */ new Date()) < t2; } - async searchCustomSources(keyword) { - const configs = JSON.parse(await storageManager.getSetting("customMagnetSources", "[]")); - const enabled = configs.filter(((config) => config.enabled)).map(validateCustomMagnetSource); - const groups = await mapLimit(enabled, 4, (async (config) => { - const url = config.searchUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)); - try { - const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url))); - const parsed = "json" === config.parserType && "string" === typeof payload ? JSON.parse(payload) : payload; - return parseCustomMagnetResponse(config, parsed, config.id); - } catch (cause) { - clog.error(`自定义磁力源 ${config.name} 失败`, new ProviderError(config.id, cause._cfBlocked ? "CF_BLOCKED" : "HTTP_ERROR", cause.message, { cause, url, status: cause.status })); - return []; - } - })); - return deduplicateMagnetResults(groups.flat()); + getTaskSchedule(name) { + const schedules = { + blacklist: { completedKey: this.lastCheckBlacklistTimeKey, attemptKey: this.lastCheckBlacklistAttemptKey, nextKey: this.lastCheckBlacklistNextKey, intervalSetting: "checkBlacklist_intervalTime", defaultInterval: 12 }, + favoriteActress: { completedKey: this.lastCheckFavoriteActressTimeKey, attemptKey: this.lastCheckFavoriteActressAttemptKey, nextKey: this.lastCheckFavoriteActressNextKey, intervalSetting: "checkFavoriteActress_IntervalTime", defaultInterval: 24 }, + newVideo: { completedKey: this.lastCheckNewVideoTimeKey, attemptKey: this.lastCheckNewVideoAttemptKey, nextKey: this.lastCheckNewVideoNextKey, intervalSetting: "checkNewVideo_intervalTime", defaultInterval: 12 } + }; + if (!schedules[name]) throw new Error(`未知任务调度: ${name}`); + return schedules[name]; } - async searchCustomSource(config, keyword) { - const url = config.searchUrlTemplate.replaceAll("{keyword}", encodeURIComponent(keyword)); - const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url))); - return parseCustomMagnetResponse(config, "json" === config.parserType && "string" === typeof payload ? JSON.parse(payload) : payload, config.id); + getTaskScheduleState(name) { + const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); + return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; } - async searchAllSources(sources, keyword) { - const groups = await mapLimit(sources, 3, (async (source) => { - try { - return await source.search(keyword); - } catch (error) { - clog.warn(`磁力源 ${source.name} 聚合失败`, error); - return []; - } - })); - return deduplicateMagnetResults(groups.flat()); + /** 返回当前标签页可证明的任务状态,不写入调度数据。 */ + getTaskStatusSnapshot(name) { + const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey)); + const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); + let status = "idle"; + if (this.activeTasks.has(name)) status = "running"; + else if (state.pending && now < nextAt) status = "pending"; + else if (null == storedNext || now >= nextAt) status = "due"; + return { name, state: status, completedAt: state.completed, attemptAt: state.attempt, nextAt, isPending: state.pending, pendingUntil: state.pending ? nextAt : null }; } - async searchBtsow(keyword, baseUrl = "https://btsow.lol") { - const payload = await storageManager.cachedRequest(`magnet:btsow:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.gmRequest("POST", `${baseUrl}/search`, JSON.stringify([{ search: keyword }, 50, 1]), {}, { "Content-Type": "application/json" }))); - const value = "string" === typeof payload ? JSON.parse(payload) : payload; - return (value?.data || []).map(((item) => normalizeMagnetResult({ title: item.name, magnet: `magnet:?xt=urn:btih:${item.hash}`, size: `${(Number(item.size) / 1073741824).toFixed(2)} GB`, date: utils.formatDate(new Date(1e3 * item.lastUpdateTime)) }, "btsow"))).filter(Boolean); + async emitTaskStatus(name, phase) { + try { + await globalThis.jhsEventBus?.emit?.("task-status-changed", { taskName: name, phase }, { broadcast: false }); + } catch (error) { + clog.error(`任务状态通知失败: ${name}/${phase}`, error); + } } - async applyRuntimeRules(results) { - const service = new ResourceSettingsService(), [tags, filters] = await Promise.all([service.getMagnetTagRules(), service.getMagnetFilterRules()]); - return results.map(((result) => applyMagnetRules(result, tags, filters.filter(((rule) => (rule.target || "title") === "title")), filters.filter(((rule) => rule.target === "file"))))).filter(((result) => !result.hidden)); + async emitNewVideoChanged(reason, carNums = []) { + try { + await globalThis.jhsEventBus?.emit?.("new-video-changed", { reason, carNums: carNums.map(normalizeCarNum).filter(Boolean) }); + } catch (error) { + clog.error("新作品状态通知失败", error); + } } - async displayResults(e2, t2, n2) { - function a2(e3) { - const t3 = e3.text(); - e3.addClass("copied").text("已复制"), setTimeout((() => { - e3.removeClass("copied").text(t3); - }), 2e3); + async withActiveTask(name, runner) { + this.activeTasks.add(name); + try { + await this.emitTaskStatus(name, "started"); + return await runner(); + } finally { + this.activeTasks.delete(name), await this.emitTaskStatus(name, "finished"); } - __name(a2, "a"); - e2.empty(), 0 !== t2.length ? (t2.forEach(((e3) => { - const base = this.calcMagnetScore(e3); - e3._score = { ...base, total: Math.max(0, Math.min(100, base.total + (e3.customTagWeight || 0) + (e3.filterPenalty || 0))) }; - })), t2.sort(((e3, t3) => t3._score.total - e3._score.total)), t2.forEach(((t3) => { - const n3 = t3._score ? t3._score.total : 0, a3 = n3 >= 80 ? "高" : n3 >= 60 ? "中" : "低", i2 = t3._score ? `做种:${t3._score.seeders}/35 分辨率:${t3._score.resolution}/25 字幕:${t3._score.subtitle}/20 新鲜度:${t3._score.freshness}/15 完整性:${t3._score.completeness}/5` : ""; - const safeTitle = escapeHtml(t3.title), safeMagnet = escapeHtml(t3.magnet), safeSize = escapeHtml(String(t3.size || "未知")), safeDate = escapeHtml(String(t3.date || "未知")); - const item = $(` -
    -
    - ${a3} ${n3} - ${safeTitle} -
    -
    - 大小: ${safeSize} - 做种: ${t3.seeders || "—"} - 日期: ${safeDate} -
    -
    - -
    -
    - `); - t3.tags?.length && item.find(".magnet-info").after($("
    ").addClass("magnet-tags").append(t3.tags.map(((tag) => $("").addClass("jhs-badge").text(tag))))); - const copyBox = item.find(".magnet-copy"); - item.find(".copy-btn").removeClass("magnet-hub-btn").addClass("jhs-btn--secondary"); - copyBox.append(``); - item.appendTo(e2); - })), e2.on("click", ".copy-btn", (async function() { - const e3 = $(this), t3 = e3.data("magnet"); - await utils.copyToClipboard("磁力链接", t3) && a2(e3); - }))) : e2.append('
    没有找到相关结果
    '); } - parseTorrentList(e2, t2) { - const n2 = utils.htmlTo$dom(e2), a2 = []; - return n2.find(".torrent-list tbody tr").each(((e3, n3) => { - const i2 = $(n3); - if (i2.text().includes("置顶")) return; - const s2 = i2.find("td:nth-child(2) a").attr("title") || i2.find("td:nth-child(2) a").text().trim(); - if (!s2.toLowerCase().includes(t2.toLowerCase())) return; - const o2 = i2.find("td:nth-child(3) a[href^='magnet:']").attr("href"), r2 = i2.find("td:nth-child(4)").text().trim(), l2 = i2.find("td:nth-child(5)").text().trim(), c2 = parseInt(i2.find("td:nth-child(6)").text().trim()) || 0, d2 = parseInt(i2.find("td:nth-child(7)").text().trim()) || 0; - o2 && a2.push({ - title: s2, - magnet: o2, - size: r2, - date: l2, - seeders: c2, - leechers: d2 - }); - })), a2; + async getLatestTaskInterval(name) { + const schedule = this.getTaskSchedule(name); + storageManager._invalidateCache?.(storageManager.setting_key); + return parseNumberSetting(await storageManager.getSetting(schedule.intervalSetting, schedule.defaultInterval), schedule.defaultInterval, { min: Number.EPSILON }); } - calcMagnetScore(e2) { - return calcMagnetScore(e2); + async shouldStartTask(name, force = false) { + if (force) return true; + const schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); + const completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); + let next = parseTaskTimestamp(localStorage.getItem(schedule.nextKey)); + if (null == next) { + const pending = null != attempt && (null == completed || attempt > completed); + next = pending ? attempt + 3e5 : null == completed ? 0 : completed + 36e5 * interval; + localStorage.setItem(schedule.nextKey, String(next)); + } + return Date.now() >= next; } - }; - __name(_MagnetHubPlugin, "MagnetHubPlugin"); - var MagnetHubPlugin = _MagnetHubPlugin; - var _ScreenshotProviderRegistry = class _ScreenshotProviderRegistry { - constructor(providers = []) { - this.providers = /* @__PURE__ */ new Map(); - providers.forEach(((provider) => this.register(provider))); + beginTaskAttempt(name) { + const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)); + const now = Math.floor(Date.now() / 1e3) * 1e3, attempt = null == completed ? now : Math.max(now, completed + 1e3); + localStorage.setItem(schedule.attemptKey, String(attempt)), localStorage.setItem(schedule.nextKey, String(attempt + 3e5)); + return attempt; } - register(provider) { - if (!provider?.id || !provider.name || "function" !== typeof provider.getScreenshot) throw new TypeError("Invalid screenshot provider"); - this.providers.set(provider.id, { enabled: true, priority: 100, ...provider }); - return this; + async finalizeTask(name, completed) { + const schedule = this.getTaskSchedule(name); + if (!completed) return localStorage.setItem(schedule.nextKey, String(Date.now() + 3e5)); + const attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); + localStorage.setItem(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), localStorage.setItem(schedule.nextKey, String(completedAt + 36e5 * interval)); } - get(id) { - return this.providers.get(id) || null; + async recalculateSchedules() { + await this.loadConfig(); + for (const [name, interval] of [["blacklist", this.taskConfig.checkBlacklist_intervalTime], ["favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime], ["newVideo", this.taskConfig.checkNewVideo_intervalTime]]) { + const state = this.getTaskScheduleState(name); + if (state.pending) continue; + localStorage.setItem(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); + } } - getEnabledProviders() { - return [...this.providers.values()].filter(((provider) => provider.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); + async invalidateConfig(recalculate = false) { + this.taskConfigDirty = true; + if (!recalculate) return; + if (this.configRefreshPromise) return this.configRefreshQueued = true, this.configRefreshPromise; + return this.configRefreshPromise = (async () => { + do { + this.configRefreshQueued = false, await this.recalculateSchedules(); + } while (this.configRefreshQueued); + })().finally((() => { + this.configRefreshPromise = null; + })), this.configRefreshPromise; } - async first(carNum) { - for (const provider of this.getEnabledProviders()) { + handle() { + if (!window.isListPage) return; + this.visibilityHandler || (this.visibilityHandler = () => { + document.hidden ? this.clearSchedule() : this.scheduleTask(0); + }, this.pageHideHandler = () => this.clearSchedule(), document.addEventListener("visibilitychange", this.visibilityHandler), window.addEventListener("pagehide", this.pageHideHandler)); + this.settingsHandler || (this.settingsHandler = async () => { try { - const result = await provider.getScreenshot(carNum); - if (result?.url) return result; + storageManager._invalidateCache?.(storageManager.setting_key), await this.invalidateConfig(true), this.scheduleTask(0); } catch (error) { - clog.warn(`截图源 ${provider.name} 请求失败`, error); + clog.error("任务设置刷新失败", error); } - } - return null; + }, globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler)); + return document.hidden ? void 0 : this.runAndSchedule(); } - }; - __name(_ScreenshotProviderRegistry, "ScreenshotProviderRegistry"); - var ScreenshotProviderRegistry = _ScreenshotProviderRegistry; - var _ScreenShotPlugin = class _ScreenShotPlugin extends BasePlugin { - constructor() { - super(...arguments), this.providerRegistry = new ScreenshotProviderRegistry(); + clearSchedule() { + this.taskTimer && (clearTimeout(this.taskTimer), this.taskTimer = null); } - async initializeProviders() { - const settings = await new ResourceSettingsService().getScreenshotSettings(), configured = /* @__PURE__ */ __name((id) => settings.providers.find(((provider) => provider.id === id)) || {}, "configured"); - this.providerRegistry = new ScreenshotProviderRegistry([ - { id: "javstore", name: "JavStore", priority: 10, getScreenshot: /* @__PURE__ */ __name((carNum) => this.getCachedProviderScreenshot("javstore", carNum, (() => this.getJavStoreScreenShot(carNum))), "getScreenshot") }, - { id: "projectjav", name: "ProjectJav", enabled: false, priority: 20, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") }, - { id: "18av", name: "18AV", enabled: false, priority: 30, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") } - ].map(((provider) => ({ ...provider, ...configured(provider.id), enabled: !["projectjav", "18av"].includes(provider.id) && (configured(provider.id).enabled ?? provider.enabled ?? true), getScreenshot: provider.getScreenshot })))); - return settings.mode; + scheduleTask(e2 = 3e5) { + if (!window.isListPage || document.hidden) return void this.clearSchedule(); + this.clearSchedule(), this.taskTimer = setTimeout((() => { + this.taskTimer = null, void this.runAndSchedule(); + }), e2); } - getName() { - return "ScreenShotPlugin"; + async runAndSchedule() { + if (this.taskRunning || !window.isListPage || document.hidden) return; + this.taskRunning = true; + try { + await this.doTask(); + } finally { + this.taskRunning = false, this.scheduleTask(); + } } - async initCss() { - return ``; + async doTask() { + if (!window.isListPage) return; + await this.loadConfig(), this.javDbUrl = await this.getDependency("OtherSitePlugin").getJavDbUrl(); + return navigator.locks.request(this.singleTaskKey, { + ifAvailable: true + }, (async (e2) => { + if (e2) { + if (window.isListPage && (this.taskConfig.enableCheckBlacklist === _ ? await this.runBackgroundTask("黑名单整批检测", (() => this.checkBlacklist())) : clog.warn("自动检测屏蔽黑名单-禁用"), !l)) { + if (this.taskConfig.enableCheckFavoriteActress === _) { + $('a[href*="/users/profile"]').length > 0 ? await this.runBackgroundTask("演员收藏同步", (() => this.checkFavoriteActress())) : clog.debug("未登录 JavDB,跳过自动同步演员"); + } else clog.warn("自动同步已收藏的演员-禁用"); + this.taskConfig.enableCheckNewVideo === _ ? await this.runBackgroundTask("新作品整批检测", (() => this.checkNewVideo())) : clog.warn("自动检测已收藏演员的最新作品-禁用"); + } + } else clog.debug("争夺任务锁失败, 跳过执行"); + })).catch(((e2) => { + this.isNetworkBlocked(e2) ? clog.warn(`后台检测已停止: ${e2.message}`) : clog.error("锁任务出现错误:", e2); + })); } - async handle() { - await this.loadScreenShot(); + async loadConfig() { + if (this.configLoadPromise) return this.configLoadQueued = true, this.configLoadPromise; + return this.configLoadPromise = (async () => { + do { + this.configLoadQueued = false; + const e2 = await storageManager.getSetting(), nextConfig = { + checkConcurrencyCount: parseNumberSetting(e2.checkConcurrencyCount, 2, { min: 2, max: 5 }), + checkRequestSleep: parseNumberSetting(e2.checkRequestSleep, 100, { min: 0, max: 3e3 }), + enableCheckBlacklist: e2.enableCheckBlacklist || _, + checkBlacklist_intervalTime: parseNumberSetting(e2.checkBlacklist_intervalTime, 12, { min: Number.EPSILON }), + checkBlacklist_ruleTime: parseNumberSetting(e2.checkBlacklist_ruleTime, 8760, { min: 0 }), + enableCheckFavoriteActress: e2.enableCheckFavoriteActress || _, + checkFavoriteActress_IntervalTime: parseNumberSetting(e2.checkFavoriteActress_IntervalTime, 24, { min: Number.EPSILON }), + enableCheckNewVideo: e2.enableCheckNewVideo || _, + checkNewVideo_intervalTime: parseNumberSetting(e2.checkNewVideo_intervalTime, 12, { min: Number.EPSILON }), + checkNewVideo_ruleTime: parseNumberSetting(e2.checkNewVideo_ruleTime, 8760, { min: 0 }) + }; + this.taskConfig = nextConfig, this.taskConfigDirty = false; + } while (this.configLoadQueued || this.taskConfigDirty); + return this.taskConfig; + })().finally((() => { + this.configLoadPromise = null; + })), this.configLoadPromise; } - async loadScreenShot() { - if (!isDetailPage) return; - if ("yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return; - let e2 = this.getPageInfo().carNum; - r && $(".preview-images .tile-item").first().before('
    正在加载缩略图
    '), l && $("#sample-waterfall .sample-box:first").after('
    正在加载缩略图
    '); - const mode = await this.initializeProviders(); - if ("manual" === mode) return $(".screen-container").text("请选择截图来源"), void this.renderProviderTabs(e2); + /** 确保所有任务入口均已具备配置和站点地址。 */ + async ensureReady() { + (!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getDependency("OtherSitePlugin").getJavDbUrl()); + if (!this.javDbUrl) throw new Error("JavDB 地址未配置"); + } + async resolveBlacklistSite(url) { try { - const t2 = await this.getScreenshotFromInitializedProviders(e2); - t2 ? (this.addImg("缩略图", t2), clog.log("加载缩略图:", t2)) : this.showErrorFallback(e2, null); - } catch (t2) { - this.showErrorFallback(e2, t2); + const target = new URL(url), otherSite = this.getDependency("OtherSitePlugin"), [javDbUrl, javBusUrl] = await Promise.all([otherSite?.getJavDbUrl?.(), otherSite?.getJavBusUrl?.()]); + for (const [configuredUrl, site2] of [[javDbUrl, T], [javBusUrl, I]]) { + try { + if (configuredUrl && target.hostname === new URL(configuredUrl).hostname) return site2; + } catch (error) { + clog.warn(`忽略无效的站点配置: ${configuredUrl}`, error); + } + } + const { site } = detectSite(url); + return [T, I].includes(site) ? site : null; + } catch (error) { + return null; } } - renderProviderTabs(carNum) { - const tabs = $('
    '); - this.providerRegistry.providers.forEach(((provider) => tabs.append($('').prop("disabled", !provider.enabled).attr("data-provider", provider.id).text(provider.name)))); - $(".screen-container").before(tabs); - tabs.on("click", "button:not(:disabled)", (async (event) => { - const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); - $(".screen-container").text(`${provider.name} 加载中…`); + async checkBlacklist(force = false) { + await this.ensureReady(); + const result = this.createTaskResult({ skippedHost: 0 }); + if (!await this.shouldStartTask("blacklist", force)) return clog.debug("检测黑名单未到整批执行时间"), result; + this.beginTaskAttempt("blacklist"), result.attempted = true; + return this.withActiveTask("blacklist", async () => { + let blockedError = null, finalized = false; try { - const result = await provider.getScreenshot(carNum); - result?.url ? this.addImg(`${provider.name} 缩略图`, result.url) : $(".screen-container").text(`${provider.name} 无结果`); + let items = await storageManager.getBlacklist(); + items = items.sort(((left, right) => left.createTime < right.createTime ? 1 : left.createTime > right.createTime ? -1 : 0)); + const concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkBlacklist_intervalTime, rule = this.taskConfig.checkBlacklist_ruleTime; + const eligible = [], currentHostname = new URL(window.location.href).hostname; + for (const item of items) { + let itemUrl; + try { + itemUrl = new URL(item.url); + } catch (error) { + result.parseFailed++, clog.error("黑名单地址无效:", item.url, error); + continue; + } + if (currentHostname !== itemUrl.hostname) { + result.skippedHost++; + continue; + } + if (!force && item.checkTime && this.isUnnecessaryCheck(item.checkTime, interval)) result.skippedInterval++; + else if (shouldSkipStopped(item.lastPublishTime, rule)) result.skippedStopped++; + else { + const site = await this.resolveBlacklistSite(itemUrl.href); + site ? eligible.push({ item, site }) : (result.parseFailed++, clog.error(`不支持的黑名单来源站点: ${itemUrl.hostname}`)); + } + } + clog.log(`检测屏蔽黑名单, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); + const blacklistPlugin = this.getDependency("BlacklistPlugin"); + try { + await this.limitConcurrency(eligible, concurrency, sleep, (async (entry) => { + const { item, site } = entry; + const { starId, name, url } = item; + let responseText; + try { + clog.log("正在检屏黑名单演员:", name, url), $("#checkBlacklistMsg").text(`正在检屏黑名单演员: ${name} ${url}`), responseText = await gmHttp.get(url); + } catch (error) { + result.networkFailed++; + if (this.isNetworkBlocked(error)) throw error; + return void clog.error("检测屏蔽演员网络错误:", url, error); + } + try { + const page = utils.htmlTo$dom(responseText); + await this.storageQueue.addTask((async () => { + const parsed = await blacklistPlugin.parseAndSaveFilterInfo(page, name, starId, site); + await storageManager.updateBlacklistItem({ starId, name, checkTime: utils.getNowStr(), lastPublishTime: parsed.lastPublishTime }); + })), result.success++; + } catch (error) { + result.parseFailed++, clog.error("解析或保存黑名单演员失败:", url, error); + } + })), await this.storageQueue.waitAllFinished(); + } catch (error) { + if (!this.isNetworkBlocked(error)) throw error; + blockedError = error, result.aborted = Math.max(0, eligible.length - result.success - result.parseFailed - result.networkFailed); + } + const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; + result.completed = completed, result.fatal = !!blockedError, await this.finalizeTask("blacklist", completed), finalized = true, this.renderBlacklistResult(result, completed); + try { + await this.getDependency("BlacklistPlugin").resetBtnTip(); + } catch (error) { + clog.error("刷新黑名单检测提示失败", error); + } + if (blockedError) throw blockedError; + return result; } catch (error) { - $(".screen-container").text(`${provider.name} 请求失败`); - clog.error("截图源请求失败", error); + finalized || await this.finalizeTask("blacklist", false), result.completed = false, result.fatal = this.isNetworkBlocked(error); + if (result.fatal) throw error; + return result.parseFailed++, clog.error("黑名单整批检测失败", error), result; } - })); - } - async getScreenshot(e2) { - e2 = normalizeCarNum(e2); - if (!e2) throw clog.warn("跳过缩略图解析:番号不可用"), new Error("缩略图番号不可用"); - await this.initializeProviders(); - return this.getScreenshotFromInitializedProviders(e2); - } - async getScreenshotFromInitializedProviders(e2) { - e2 = normalizeCarNum(e2); - if (!e2) throw new Error("缩略图番号不可用"); - localStorage.removeItem("jhs_screenShot"); - let n2; - try { - n2 = await this.providerRegistry.first(e2); - } catch (i2) { - throw clog.error("获取缩略图资源失败:", n2, i2), i2; - } - if (!n2) return null; - let url = n2.url, a2 = url.indexOf("https://"); - return -1 !== a2 && (url = url.substring(a2)), clog.log(`缩略图获取成功 (${n2.source}):`, url), url; + }); } - async getCachedProviderScreenshot(provider, carNum, loader) { - const value = await storageManager.cachedRequest(`screenshot:${provider}:${carNum}`, CACHE_TTL.screenshot, (async () => { - const loadedUrl = await loader(), url2 = "javstore" === provider ? normalizeJavStoreAssetUrl(loadedUrl) : loadedUrl; - return url2 ? { __jhsCacheTtl: CACHE_TTL.screenshot, data: { url: url2 } } : { __jhsCacheTtl: CACHE_TTL.screenshotNegative, data: { miss: true } }; - })); - if (!value || value.miss) return null; - const cachedUrl = "string" === typeof value ? value : value.url, url = "javstore" === provider ? normalizeJavStoreAssetUrl(cachedUrl) : cachedUrl; - return url ? { url, source: provider, detailUrl: null } : null; + renderBlacklistResult(result, completed) { + const retry = completed ? "" : ",5 分钟后补偿未完成项", message = `黑名单整批检测:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval},异站跳过 ${result.skippedHost}${result.aborted ? `,未执行 ${result.aborted}` : ""}${retry}`; + $("#checkBlacklistMsg").text(message), clog.log(message); } - async getJavStoreScreenShot(e2) { - const t2 = `https://javstore.net/search?q=${encodeURIComponent(e2)}`; - clog.debug("JavStore 搜索地址:", t2); - let n2 = await gmHttp.get(t2, {}, {}, false, { ignoreNotFound: true }); - if (!n2) return clog.debug("JavStore 搜索页未获取:", t2), null; - const a2 = utils.htmlTo$dom(n2); - const i2 = parseJavStoreSearch(a2, e2); - if (!i2.length) return clog.debug("JavStore, 查询番号无结果:", t2), null; - for (const e3 of i2) { - const t3 = e3; - clog.debug("JavStore 候选详情:", t3); - const n3 = await gmHttp.get(t3, {}, {}, false, { ignoreNotFound: true }); - if (!n3) { - clog.debug("JavStore 详情页未获取:", t3); - continue; + async checkFavoriteActress(force = false) { + await this.ensureReady(); + const result = this.createTaskResult({ pages: 0, actorCount: 0 }); + if (!await this.shouldStartTask("favoriteActress", force)) return clog.debug("同步收藏演员未到整批执行时间"), result; + this.beginTaskAttempt("favoriteActress"), result.attempted = true; + return this.withActiveTask("favoriteActress", async () => { + try { + const actors = [], sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); + actors.length > 0 && await storageManager.addFavoriteActressList(actors), result.success = actors.length, result.actorCount = actors.length, result.pages = sync.pages; + await this.finalizeTask("favoriteActress", true), result.completed = true, clog.log("所有演员信息已收集, 总计数量:", actors.length), $("#checkNewVideoMsg").text(`完整同步完成:演员 ${actors.length},页面 ${sync.pages}`); + actors.length > 0 && await this.emitNewVideoChanged("favorite-actress-sync"); + return result; + } catch (error) { + result.completed = false, error?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(error), await this.finalizeTask("favoriteActress", false), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", error); + if (result.fatal) throw error; + return result; } - const a3 = parseJavStorePreview(utils.htmlTo$dom(n3), t3); - if (!a3) { - clog.debug("JavStore 详情页没有 CLICK HERE!:", t3); - continue; + }); + } + async scrapeActorInfo(startUrl, target = []) { + const expected = new URL("/users/collection_actors", this.javDbUrl), visitedUrls = /* @__PURE__ */ new Set(); + let currentUrl = new URL(startUrl, this.javDbUrl), pages = 0; + for (; currentUrl; ) { + try { + if (currentUrl.origin !== expected.origin || currentUrl.pathname !== expected.pathname) throw new Error(`收藏演员分页地址越界: ${currentUrl.href}`); + if (visitedUrls.has(currentUrl.href)) throw new Error(`收藏演员分页循环: ${currentUrl.href}`); + if (pages >= 200) throw new Error("收藏演员分页超过 200 页"); + visitedUrls.add(currentUrl.href), pages++, clog.log(`正在抓取页面: ${currentUrl.href}`), $("#checkNewVideoMsg").text(`正在解析已收藏的演员: ${currentUrl.href}`); + let responseText; + try { + responseText = await gmHttp.get(currentUrl.href); + } catch (error) { + throw error._taskNetwork = true, error; + } + const page = utils.htmlTo$dom(responseText), parsed = parseJavDbActorList(page, currentUrl.href); + if ("valid" !== parsed.state) throw Object.assign(new Error(`收藏演员页面无效: ${parsed.state}`), { _taskParse: true }); + if (parsed.isEmpty && parsed.nextUrl) throw Object.assign(new Error("收藏演员空页面包含下一页"), { _taskParse: true }); + target.push(...parsed.actors), currentUrl = parsed.nextUrl ? new URL(parsed.nextUrl, currentUrl.href) : null; + } catch (error) { + error._taskNetwork || Object.prototype.hasOwnProperty.call(error, "_taskParse") || (error._taskParse = true); + throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, error), error; } - return clog.debug("JavStore 预览图:", a3), a3; } - return clog.debug("JavStore, 所有候选均无有效预览图:", t2), null; - } - addImg(e2, t2) { - const url = normalizeJavStoreAssetUrl(t2); - url && (r && $(".screen-container").html(`${e2}`), l && $(".screen-container").html(`
    ${e2}
    `), $(".screen-container").on("click", ((e3) => { - e3.stopPropagation(), e3.preventDefault(), showImageViewer(e3.currentTarget); - }))); + return { actors: target, pages }; } - showErrorFallback(e2, t2) { - var n2; - clog.error("获取缩略图失败:", null == (n2 = null == t2 ? void 0 : t2.message) ? void 0 : n2.substring(0, 100)); - const a2 = `jhs-screenshot-message${l ? " jhs-screenshot-message--bus" : ""}`; - if (!(e2 = normalizeCarNum(e2))) return void $(".screen-container").empty().append($("
    ").addClass(a2).text("无法获取番号,缩略图未加载")); - const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(e2)}`; - $(".screen-container").html(`
    获取缩略图失败

    点击重试 或 前往确认`).off("click", ".retry-link").off("click", ".check-link").on("click", ".retry-link", (async (t3) => { - t3.stopPropagation(), t3.preventDefault(), $(".screen-container").html(`
    正在重新加载...
    `); + async checkNewVideo(force = false) { + await this.ensureReady(); + const result = this.createTaskResult(); + if (!await this.shouldStartTask("newVideo", force)) return clog.debug("检测新作品未到整批执行时间"), result; + this.beginTaskAttempt("newVideo"), result.attempted = true; + return this.withActiveTask("newVideo", async () => { + let blockedError = null, finalized = false; try { - const t4 = await this.getScreenshot(e2); - this.addImg("缩略图", t4); - } catch (n3) { - this.showErrorFallback(e2, n3); - } - })).on("click", ".check-link", (async (t3) => { - t3.stopPropagation(), t3.preventDefault(), window.open(searchUrl, "_blank"); - })); + const actresses = await storageManager.getFavoriteActressList(), sorted = utils.genericSort(actresses, [{ + key: /* @__PURE__ */ __name((actress) => actress.newVideoList?.length ?? 0, "key"), + order: "desc" + }, { + key: "lastPublishTime", + order: "desc" + }]), concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkNewVideo_intervalTime, rule = this.taskConfig.checkNewVideo_ruleTime; + const eligible = []; + for (const actress of sorted) { + if (!force && actress.lastCheckTime && this.isUnnecessaryCheck(actress.lastCheckTime, interval)) result.skippedInterval++; + else if (shouldSkipStopped(actress.lastPublishTime, rule)) result.skippedStopped++; + else eligible.push(actress); + } + clog.log(`检测最新作品, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); + if (eligible.length > 0) { + const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map(((item) => item.carNum))); + try { + await this.limitConcurrency(eligible, concurrency, sleep, (async (actress) => { + const { name, starId } = actress, url = `${this.javDbUrl}/actors/${starId}?t=d`; + try { + clog.log("正在检测最新作品, 演员:", name, url), $("#checkNewVideoMsg").text(`正在检测最新作品, 演员: ${name}`); + const responseText = await gmHttp.get(url), page = utils.htmlTo$dom(responseText); + try { + await this.storageQueue.addTask((async () => this.parsePage(page, T, starId, name, titleKeywords, blacklistSet))), result.success++; + } catch (error) { + result.parseFailed++, clog.error("解析或保存演员作品失败:", url, error); + } + } catch (error) { + if (this.isNetworkBlocked(error)) throw result.networkFailed++, error; + result.networkFailed++, clog.error("检测演员信息发生网络错误:", url, error); + } + })), await this.storageQueue.waitAllFinished(); + } catch (error) { + if (!this.isNetworkBlocked(error)) throw error; + blockedError = error, result.fatal = true, result.aborted = Math.max(0, eligible.length - result.success - result.parseFailed - result.networkFailed), clog.warn(`网络阻断,本轮停止,未执行 ${result.aborted}`); + } + } + const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; + result.completed = completed, await this.finalizeTask("newVideo", completed), finalized = true, clog.log('检测最新作品---结束'), this.renderCheckResult(result, completed ? actresses.length ? "整批检测结束" : "收藏为空,整批检测完成" : "整批检测未完成,5 分钟后补偿未完成项"); + result.success > 0 && await this.emitNewVideoChanged("task-completed"); + if (blockedError) throw blockedError; + return result; + } catch (error) { + finalized || await this.finalizeTask("newVideo", false), result.completed = false, result.fatal = this.isNetworkBlocked(error); + if (result.fatal) throw error; + return result.parseFailed++, this.renderCheckResult(result, "整批检测失败,5 分钟后补偿"), clog.error("新作品整批检测失败", error), result; + } + }); } - /** 将截图状态与结果限制在指定容器内,供自有详情工作区使用。 */ - async loadInto(target, carNum, { isActive = /* @__PURE__ */ __name(() => true, "isActive") } = {}) { - const host = $(target); - if (!host.length || "yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return host.empty(), null; - const mode = await this.initializeProviders(), renderMessage = /* @__PURE__ */ __name((message) => isActive() && host.empty().append($("
    ").addClass("jhs-panel-state").text(message)), "renderMessage"); - if ("manual" === mode) { - host.empty(); - const tabs = $('
    '), result = $('
    ').text("请选择截图来源"); - this.providerRegistry.providers.forEach(((provider) => tabs.append($('').prop("disabled", !provider.enabled).attr("data-provider", provider.id).text(provider.name)))); - host.append(tabs, result), tabs.on("click", "button:not(:disabled)", (async (event) => { - const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); - result.text(`${provider.name} 加载中…`); - try { - const loaded = await provider.getScreenshot(normalizeCarNum(carNum)); - if (!isActive()) return; - loaded?.url ? this.renderInto(result, loaded.url, `${provider.name} 缩略图`) : result.text(`${provider.name} 无结果`); - } catch (error) { - isActive() && result.text(`${provider.name} 请求失败`), clog.error("截图源请求失败", error); + renderCheckResult(result, prefix = "检测结束") { + const message = `${prefix}:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval}${result.aborted ? `,未执行 ${result.aborted}` : ""}`; + $("#checkNewVideoMsg").text(message), clog.log(message); + } + async parsePage(e2, site, t2, n2, a2, i2) { + const selector = this.getSelector(site); + site === I && e2.find(".avatar-box").length > 0 && e2.find(".avatar-box").parent().remove(); + const pageState = parseDetailPage(e2, { + boxSelector: site === I ? `${selector.boxSelector}, #waterfall` : selector.boxSelector, + requestDomItemSelector: selector.requestDomItemSelector + }); + if ("valid" !== pageState.state) throw clog.error("新作品检测-解析列表失败"), new Error("新作品检测-解析列表失败"); + const s2 = pageState.items, o2 = e2.find(selector.nextPageSelector).attr("href"); + if (0 === s2.length && o2) throw new Error("新作品检测-空列表包含下一页"); + if (0 === s2.length) return await storageManager.updateFavoriteActress({ + starId: t2, + lastCheckTime: utils.getNowStr(), + newVideoList: [] + }), 0; + let c2 = []; + const publishTimes = []; + for (const m2 of s2) { + const e3 = $(m2), { carNum: s3, url: o3, title: r2, publishTime: l2 } = this.getDependency("ListPagePlugin").findCarNumAndHref(e3); + l2 && publishTimes.push(l2); + if (!s3) continue; + a2.find(((e4) => r2.includes(e4) || s3.includes(e4))) || (i2.has(s3) || (() => { + let coverUrl = e3.find("img").attr("src") || ""; + if (coverUrl && !coverUrl.startsWith("http")) { + coverUrl = coverUrl.startsWith("/") ? this.javDbUrl + coverUrl : this.javDbUrl + "/" + coverUrl; } - })); - return host; + let url = o3 || ""; + if (url && !url.startsWith("http")) { + url = url.startsWith("/") ? this.javDbUrl + url : this.javDbUrl + "/" + url; + } + const scoreText = e3.find(".score .value, .score").text().trim(); + const score = parseFloat(scoreText) || 0; + const voteMatch = scoreText.match(/由(\d+)人/); + let voteCount = 0; + if (voteMatch) { + voteCount = parseInt(voteMatch[1]); + } else { + const voteText = e3.find(".score .count, .meta .count").text().trim(); + voteCount = parseInt(voteText.replace(/[^\d]/g, "")) || 0; + } + c2.push({ carNum: s3, coverUrl, title: r2 || "", publishTime: l2 || "", score, voteCount, url }); + })()); } - renderMessage("正在加载缩略图…"); + const d2 = selectLatestPublishTime(publishTimes), h2 = await storageManager.getCarMap(), p2 = c2.filter(((e3) => !h2.has(e3.carNum))); + p2.length > 0 && clog.log(`检测出新作品, ${n2}, 共${p2.length}部`), await storageManager.updateFavoriteActress({ + starId: t2, + lastCheckTime: utils.getNowStr(), + newVideoList: p2, + lastPublishTime: d2 + }); + return p2.length; + } + async checkOneNewVideo(e2) { + await this.ensureReady(); + const t2 = await storageManager.getTitleFilterKeyword(), n2 = await storageManager.getBlacklistCarList(), a2 = new Set(n2.map(((e3) => e3.carNum))), { lastCheckTime: i2, name: s2, starId: o2 } = e2; + let r2 = `${this.javDbUrl}/actors/${o2}?t=d`; + const l2 = $("#checkNewVideoMsg"); try { - const url = await this.getScreenshotFromInitializedProviders(carNum); - if (!isActive()) return null; - if (!url) return host.empty(), null; - return this.renderInto(host, url, "缩略图"), url; - } catch (error) { - if (!isActive()) return null; - host.empty(); - const state = $('
    ').text("缩略图加载失败 "), retry = $(''); - retry.on("click", (() => void this.loadInto(host, carNum, { isActive }))), host.append(state.append(retry)); - return clog.error("缩略图加载失败", error), null; + clog.log("正在检测最新作品, 演员:", s2, r2), l2.text(`正在检测最新作品, 演员: ${s2}`); + const e3 = await gmHttp.get(r2), n3 = utils.htmlTo$dom(e3); + await this.parsePage(n3, T, o2, s2, t2, a2), clog.log('检测最新作品---结束'), l2.text("检测完毕"); + await this.emitNewVideoChanged("single-actress-check"); + } catch (c2) { + clog.error("检测屏蔽演员信息, 发生错误:", r2, c2), show.error("检测屏蔽演员信息, 发生错误:" + c2, "bottom", "right"), l2.text(`检测屏蔽演员信息, 发生错误: ${r2}`); } } - renderInto(target, url, alt) { - const host = $(target), image = $("").attr({ src: normalizeJavStoreAssetUrl(url), alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"), button = $('').attr("aria-label", `查看${alt}大图`).append(image); - host.empty().append(button).off("click.jhsScreenshot").on("click.jhsScreenshot", ".jhs-fc2-screenshot-thumbnail", ((event) => { - event.preventDefault(), event.stopPropagation(), showImageViewer(image[0]); - })); - } }; - __name(_ScreenShotPlugin, "ScreenShotPlugin"); - var ScreenShotPlugin = _ScreenShotPlugin; - var _FavoriteActressesPlugin = class _FavoriteActressesPlugin extends BasePlugin { - getName() { - return "FavoriteActressesPlugin"; - } - async handle() { - this.bindEvent(), await this.highlightActress(), this.replaceActressAvatar(); + __name(_TaskPlugin, "TaskPlugin"); + var TaskPlugin = _TaskPlugin; + + // src/plugins/offline/unified-offline.js + var _OfflineProviderRegistry = class _OfflineProviderRegistry { + constructor() { + this.providers = /* @__PURE__ */ new Map(), this.availabilityCache = /* @__PURE__ */ new Map(), this.positiveTtl = 3e5, this.negativeTtl = 2e4; } - async highlightActress() { - if (!isDetailPage) return; - if (await storageManager.getSetting("enableFavoriteActresses", _) !== _) return; - const e2 = await storageManager.getFavoriteActressList(); - if (!e2 || 0 === e2.length) return; - const t2 = /* @__PURE__ */ new Set(); - e2.forEach(((e3) => { - e3.starId && t2.add(String(e3.starId).trim()); - })), 0 !== t2.size && $(".female").prev().each(((e3, n2) => { - const a2 = $(n2), i2 = a2.attr("href"); - let s2 = null; - if (i2) { - const e4 = (i2.endsWith("/") ? i2.slice(0, -1) : i2).split("/"), t3 = e4[e4.length - 1]; - t3 && (s2 = t3.trim()); - } - let o2 = false; - s2 && (o2 = t2.has(s2)), o2 && (a2.addClass("highlighted"), a2.attr("title", "高亮已收藏演员, 可在设置-基础配置中关闭")); - })); + register(provider) { + if (!provider?.id || !Array.isArray(provider.capabilities) || "function" != typeof provider.submit || "function" != typeof provider.getAvailability) throw new TypeError("Invalid offline provider"); + return this.providers.set(provider.id, provider), provider; } - async removeActorFromStorage(e2) { - await storageManager.removeFavoriteActress(e2) && (clog.log("移除演员成功"), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(e2) } }))); + async getCandidates(resource, { force = false } = {}) { + const type = /^ed2k:/i.test(resource) ? "ed2k" : /^magnet:/i.test(resource) ? "magnet" : "unknown", candidates = []; + for (const provider of this.providers.values()) { + if (!provider.capabilities.includes(type) || !await provider.isEnabled()) continue; + const availability = await this.getAvailability(provider, force); + ["ready", "unknown"].includes(availability.authState) && candidates.push({ provider, availability }); + } + return candidates; } - bindEvent() { - const e2 = /\/actors\/(\w+)\/(collect|uncollect)/; - $(document).on("confirm:complete", 'a[href*="/actors/"][href*="/uncollect"]', (async (t2) => { - const [n2] = t2.detail; - if (!n2) return; - const a2 = $(t2.currentTarget).attr("href").match(e2), i2 = a2 ? a2[1] : null; - i2 && await this.removeActorFromStorage(i2); - })), $("#button-collect-actor").click((async (t2) => { - const n2 = $("#button-collect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; - let i2 = [], s2 = $(".actor-section-name"); - s2.length && s2.text().trim().split(",").forEach(((e3) => { - i2.push(e3.trim()); - })); - let o2 = $(".section-meta:not(:contains('影片'))"); - if (o2.length && o2.text().trim().split(",").forEach(((e3) => { - i2.push(e3.trim()); - })), !i2.length) return void clog.error("获取演员名称失败"); - const r2 = i2[0]; - if (!a2) return void clog.error("无法获取演员ID进行收藏操作。"); - const l2 = ($(".avatar").first().css("background-image") || "").replace(/^url\(["']?|["']?\)$/g, ""), c2 = { - starId: a2, - name: r2, - allName: i2, - avatar: l2 - }; - 1 === await storageManager.addFavoriteActressList([c2]) ? (clog.log(`收藏演员成功: ${r2} (ID: ${a2})`), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(a2) } }))) : clog.log(`收藏演员失败: ${r2} (ID: ${a2})`); - })), $("#button-uncollect-actor").click((async (t2) => { - const n2 = $("#button-uncollect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; - a2 ? await this.removeActorFromStorage(a2) : clog.error("无法获取演员ID进行取消收藏操作。"); - })); + async getAvailability(provider, force = false) { + const cached = this.availabilityCache.get(provider.id); + const ttl = ["ready", "unknown"].includes(cached?.value?.authState) ? this.positiveTtl : this.negativeTtl; + if (!force && cached && Date.now() - cached.time < ttl) return cached.value; + const value = await provider.getAvailability({ force }); + return this.availabilityCache.set(provider.id, { time: Date.now(), value }), value; } - async replaceActressAvatar() { - const e2 = this.getActressId(); - if (!e2) return; - const t2 = (await storageManager.getFavoriteActressList()).find(((t3) => t3.starId === e2)); - if (t2 && t2.avatar) { - const e3 = `url('${t2.avatar}')`; - let n2 = $(".avatar").first(); - if (0 === n2.length) { - const e4 = '
    '; - $(".section-columns").prepend(e4), n2 = $(".avatar").first(); - } - if (0 === n2.length) return; - n2.css("background-image").trim().toLowerCase() !== e3.trim().toLowerCase() && (n2.css("background-image", e3), n2.css("background-size", "cover"), n2.css("background-position", "top center"), n2.css("background-repeat", "no-repeat")); - } + updateAvailability(id, value) { + this.availabilityCache.set(id, { time: Date.now(), value }); } }; - __name(_FavoriteActressesPlugin, "FavoriteActressesPlugin"); - var FavoriteActressesPlugin = _FavoriteActressesPlugin; - var _BusImgPlugin = class _BusImgPlugin extends BasePlugin { - getName() { - return "BusImgPlugin"; + __name(_OfflineProviderRegistry, "OfflineProviderRegistry"); + var OfflineProviderRegistry = _OfflineProviderRegistry; + var _UnifiedOfflinePlugin = class _UnifiedOfflinePlugin extends BasePlugin { + constructor() { + super(), this.registry = new OfflineProviderRegistry(), this.BUTTON_COOLDOWN_MS = 1800; } - handle() { + getName() { + return "UnifiedOfflinePlugin"; } - async getVisibleImageItems(e2, t2) { - let n2 = []; - const a2 = document.querySelectorAll(e2); - for (const i2 of a2) { - if (!utils.isHidden(i2)) { - const e3 = i2.querySelector(t2); - if (!(e3 instanceof HTMLImageElement)) continue; - e3.style.removeProperty("height"); - let a3 = e3.offsetHeight; - a3 > 0 && n2.push({ - element: i2, - imgElement: e3, - height: a3 - }); - } - } - return n2; + async initCss() { + return ""; } - async logImageHeightsByRow() { - if (await storageManager.getSetting("enableVerticalModel", C) === _) return; - const e2 = this.getSelector().itemSelector, t2 = await storageManager.getSetting("containerColumns", 5), n2 = await this.getVisibleImageItems(e2, "img"); - if (0 === n2.length) return; - const a2 = []; - for (let i2 = 0; i2 < n2.length; i2++) { - const e3 = Math.floor(i2 / t2); - a2[e3] || (a2[e3] = []), a2[e3].push(n2[i2]); - } - a2.forEach(((e3, t3) => { - const n3 = e3.map(((e4) => e4.height)); - if (n3.length < 2) return; - const a3 = Math.min(...n3), i2 = Math.max(...n3); - let s2 = 0; - i2 - a3 > 50 && (s2 = a3, e3.forEach(((e4) => { - if (e4.height !== s2) { - const t4 = `${s2}px`; - e4.imgElement.style.setProperty("height", t4, "important"); - } - }))); + async handle() { + if (!(r || l)) return; + this.registerProviders(), this.bindSubmit(), window.isDetailPage && (this.injectNativeButtons(), jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); + } + registerProviders() { + const one23 = this.getDependency("OneTwoThreeOfflinePlugin"); + one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: ["magnet"], retryPolicy: { automaticAttempts: 0 }, isEnabled: /* @__PURE__ */ __name(() => storageManager.getSetting("enable123Offline", true), "isEnabled"), getAvailability: /* @__PURE__ */ __name(async () => one23.getStoredToken() ? { available: true, authState: "ready", reason: "授权已同步" } : { available: false, authState: "token-missing", reason: "尚未同步 123 授权" }, "getAvailability"), submit: /* @__PURE__ */ __name(async (resource) => { + const token = one23.getStoredToken(); + if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); + const resolved = await one23.resolveMagnet(resource, token); + return one23.submitTask(resolved, token); + }, "submit"), openUrl: /* @__PURE__ */ __name(() => "https://yun.123pan.com", "openUrl") }); + this.registry.register({ id: "115", name: "115", capabilities: ["magnet", "ed2k"], retryPolicy: { automaticAttempts: 0 }, isEnabled: /* @__PURE__ */ __name(() => storageManager.getSetting("enable115Offline", false), "isEnabled"), getAvailability: /* @__PURE__ */ __name(async () => ({ available: true, authState: "unknown", reason: "提交时确认登录状态" }), "getAvailability"), submit: /* @__PURE__ */ __name((resource) => new OneOneFiveClient().addOffline(resource), "submit"), openUrl: /* @__PURE__ */ __name(() => "https://115.com", "openUrl") }); + window.offlineProviderRegistry = this.registry; + } + bindSubmit() { + $(document).off("click.jhsUnifiedOffline", ".jhs-offline-btn").on("click.jhsUnifiedOffline", ".jhs-offline-btn", (async (event) => { + event.preventDefault(), event.stopPropagation(); + const button = $(event.currentTarget), resource = button.attr("data-resource") || button.attr("data-magnet") || button.closest(".magnet-result,.item,td").find('a[href^="magnet:"],a[href^="ed2k:"]').first().attr("href"); + resource ? await this.submitResource(event, resource, button) : show.error("未找到可提交资源"); })); } - }; - __name(_BusImgPlugin, "BusImgPlugin"); - var BusImgPlugin = _BusImgPlugin; - var _TranslatePlugin = class _TranslatePlugin extends BasePlugin { - getName() { - return "TranslatePlugin"; + injectNativeButtons() { + const adapter = getDetailResourceAdapter(); + if (!adapter) return; + adapter.rows().forEach(((row) => { + const resource = adapter.getResource(row), target = adapter.getActionTarget(row); + if (!resource || !target?.length || $(row).closest(".magnet-container,.jhs-review-panel,.movie-detail-container").length) return; + const owner = `native-${adapter.site}`; + let button = $(row).find(`.jhs-offline-btn[data-jhs-offline-owner="${owner}"]`).first(); + $(row).find(`.jhs-offline-btn[data-jhs-offline-owner="${owner}"]`).not(button).remove(); + button.length || (button = $('').attr("data-jhs-offline-owner", owner)), button.attr("data-resource", resource), target.append(button); + })); } - async initCss() { - return "\n "; + async chooseCandidate(event, candidates) { + if (1 === candidates.length) return candidates[0]; + const mode = await storageManager.getSetting("offlineProviderMode", "ask"), preferred = candidates.find(((candidate) => candidate.provider.id === mode)); + if (preferred) return preferred; + return new Promise(((resolve) => { + const content = $('

    选择离线服务

    '), toolbar = content.find(".jhs-toolbar"); + candidates.forEach(((candidate) => toolbar.append($('').text(`${candidate.provider.name} · ${"ready" === candidate.availability.authState ? "已就绪" : "状态未知"}`).on("click", (() => { + layer.close(index), resolve(candidate); + }))))); + const index = layer.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: /* @__PURE__ */ __name(() => resolve(null), "cancel") }); + })); } - handle() { - isDetailPage && this.translate(); + getVideoInfo(button) { + if (window.isDetailPage) return this.getPageInfo(); + const item = button?.closest?.(".item"); + return item?.length ? this.getDependency("ListPagePlugin").findCarNumAndHref(item) : this.getPageInfo(); } - async translate(e2, t2 = true, options = {}) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; - l && (t2 = false); - const root = options.root ? $(options.root) : $(document); - let n2 = root.find(".origin-title").first(); - if (n2.length || (n2 = root.find(".current-title").first()), n2.length || (n2 = root.find("h3").first()), !n2.length) return; - const a2 = n2.text().trim(); - if (!a2) return void show.error("获取标题失败, 无法进行翻译"); - let i2 = n2.nextAll(".translated-title").first(); - i2.length || (i2 = $('
    ').insertAfter(n2)), i2.removeClass("is-error").text("翻译中..."); - e2 || (e2 = this.getPageInfo().carNum); - const s2 = "string" == typeof e2 ? e2.trim() : "", o2 = s2 && "undefined" !== s2 ? s2 : a2; - let r2 = {}; - try { - const e3 = localStorage.getItem("jhs_translate"); - e3 && (r2 = JSON.parse(e3) || {}); - } catch (l2) { - clog.warn("翻译缓存无法解析,已忽略旧缓存", l2); - } - if (r2[o2]) return void i2.text(r2[o2]); + async submitResource(event, resource, button = $(), context = null, retryOf = null, options = {}) { + if (button.hasClass("loading")) return; + const candidates = await this.registry.getCandidates(resource, { force: !!options.forceAvailabilityRefresh }); + if (!candidates.length) return void show.error("没有已启用且支持该资源的离线服务,请检查授权与设置"); + const selected = candidates.find(((candidate) => candidate.provider.id === options.preferredProviderId)) || await this.chooseCandidate(event, candidates); + if (!selected) return; + const info = context || this.getVideoInfo(button), original = button.text(), restoreButton = /* @__PURE__ */ __name(() => { + if (!button[0]?.isConnected) return; + button.removeClass("loading").removeAttr("aria-busy aria-disabled").text(original); + }, "restoreButton"); + let submitted = false; try { - const e3 = await _e(a2, "ja", "zh-CN"); - if (!n2[0]?.isConnected) return; - i2.text(e3), r2[o2] = e3, localStorage.setItem("jhs_translate", JSON.stringify(r2)); - } catch (l2) { - clog.error("翻译失败:", l2), i2.addClass("is-error").text(`翻译失败: ${l2.message || String(l2)}`); + button.addClass("loading").attr({ "aria-busy": "true", "aria-disabled": "true" }).text("提交中"), await selected.provider.submit(resource, info), this.registry.updateAvailability(selected.provider.id, { available: true, authState: "ready", reason: "最近提交成功" }); + await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "submitted", retryOf }), submitted = true, button.text("已提交"), show.ok(`${selected.provider.name} 离线任务已创建`), utils.q(event, "是否将该作品标记为已下载?", (async () => { + info?.carNum && await stateService.patch(info.carNum, { downloaded: true }, { type: "offline-mark-downloaded", record: { ...info, names: info.actress || info.names || "" } }); + })); + } catch (error) { + const code = error?.code || ("TOKEN_EXPIRED" === error ? "TOKEN_EXPIRED" : "SUBMIT_FAILED"); + ["LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING"].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: false, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: error.message || String(error) }); + restoreButton(); + submitted || await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: error?.message || String(error), retryOf }), show.error(`${selected.provider.name} 离线失败:${error?.message || error}`); + } finally { + submitted ? setTimeout(restoreButton, this.BUTTON_COOLDOWN_MS) : restoreButton(); } } }; - __name(_TranslatePlugin, "TranslatePlugin"); - var TranslatePlugin = _TranslatePlugin; - var _TaskPlugin = class _TaskPlugin extends BasePlugin { + __name(_UnifiedOfflinePlugin, "UnifiedOfflinePlugin"); + var UnifiedOfflinePlugin = _UnifiedOfflinePlugin; + + // src/plugins/one-one-five/plugins.js + var _OneOneFiveMatchPlugin = class _OneOneFiveMatchPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue()), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), i(this, "taskTimer", null), i(this, "taskRunning", false), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "settingsHandler", null), i(this, "taskConfigDirty", false), i(this, "configLoadPromise", null), i(this, "configLoadQueued", false), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", false), i(this, "activeTasks", /* @__PURE__ */ new Set()); + super(), this.observer = null, this.unsubscribeItems = null, this.pendingCards = /* @__PURE__ */ new Set(), this.flushTimer = null, this.client = new OneOneFiveClient(), this.concurrency = 4, this.cacheMinutes = 60; } getName() { - return "TaskPlugin"; + return "OneOneFiveMatchPlugin"; } - getStartupMode() { - return "idle"; + async handle() { + if (!await storageManager.getSetting("enable115Match", false)) return; + if (!isDetailPage) return this.setupListMatching(); + const carNum = this.getPageInfo().carNum, keyword = normalize115Keyword(carNum); + if (!keyword) return; + const host = $(".movie-panel-info,.container .info").first(); + host.append('
    115匹配:匹配中
    '); + try { + const cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60), matches = await storageManager.cachedRequest(`115match:${carNum}`, cacheMinutes * 6e4, (() => new OneOneFiveClient().search(keyword))); + const box = $(".jhs-115-match").empty().append("115匹配:"); + if (!matches.length) return void box.append(document.createTextNode("未匹配 "), $('').on("click", (() => location.reload()))); + matches.forEach(((match) => { + const row = $(''), playUrl = build115PlayUrl(match); + playUrl ? row.append($("").addClass("jhs-btn jhs-btn--secondary").attr({ href: playUrl, target: "_blank" }).text(`${match.name} (${format115Size(match.size)})`)) : row.append($("").text(`${match.name} (${format115Size(match.size)}) · 不可播放`)); + match.fileId && row.append($('').data("match", match)); + box.append(row); + })); + box.on("click", ".jhs-115-rename", ((event) => this.renameWithPreview(event, $(event.currentTarget).data("match"), carNum))); + } catch (error) { + const box = $(".jhs-115-match").empty().append("115匹配:", document.createTextNode("未登录或请求失败 ")); + box.append('去登录', $('').on("click", (() => location.reload()))); + clog.error("115 匹配失败", error); + } } - async limitConcurrency(e2, t2, n2, a2) { - let i2 = 0, s2 = false; - const o2 = Math.max(1, Math.min(t2, e2.length)), r2 = Array.from({ length: o2 }, (async () => { - for (; !s2; ) { - const t3 = i2++; - if (t3 >= e2.length) return; - try { - await a2(e2[t3]); - } catch (e3) { - if (this.isNetworkBlocked(e3)) throw s2 = true, e3; - throw e3; - } - const o3 = e2.length - i2; - o3 > 0 && (clog.debug(`剩余任务数: ${o3}`), await utils.sleep(n2)); - } - })); - const l2 = await Promise.allSettled(r2), c2 = l2.find(((e3) => "rejected" === e3.status)); - if (c2) throw c2.reason; + async setupListMatching() { + this.concurrency = Math.max(1, Math.min(10, Number(await storageManager.getSetting("oneOneFiveConcurrency", 4)) || 4)), this.cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60); + this.observer = new IntersectionObserver(((entries) => { + entries.forEach(((entry) => entry.isIntersecting && (this.observer.unobserve(entry.target), this.pendingCards.add(entry.target)))); + this.pendingCards.size && this.scheduleFlush(); + }), { rootMargin: "200px" }); + this.registerCards($(".movie-list .item,.masonry .item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", ((payload) => this.registerCards(payload.items || []))); } - isNetworkBlocked(e2) { - return true === e2?._cfBlocked || true === e2?._circuitBroken; + registerCards(cards) { + cards.forEach(((card) => { + "true" !== card.dataset.jhs115Observed && "matched" !== card.dataset.jhs115State && (card.dataset.jhs115Observed = "true", this.observer.observe(card)); + })); } - createTaskResult(extra = {}) { - return { attempted: false, completed: false, fatal: false, success: 0, networkFailed: 0, parseFailed: 0, aborted: 0, skippedInterval: 0, skippedStopped: 0, ...extra }; + scheduleFlush() { + this.flushTimer || (this.flushTimer = setTimeout((async () => { + const cards = [...this.pendingCards]; + this.pendingCards.clear(), this.flushTimer = null, await mapLimit(cards, this.concurrency, ((card) => this.matchCard(card))); + }), 50)); } - async runBackgroundTask(name, runner) { + async matchCard(element, force = false) { + const card = $(element), carNum = normalizeCarNum(card.find(".video-title strong").first().text()); + if (!carNum || "pending" === element.dataset.jhs115State && !force) return; + const cacheKey = `115match:${carNum}`; try { - return await runner(); + element.dataset.jhs115State = "pending", force && await storageManager.deleteCachedRequest(cacheKey); + const matches = await storageManager.cachedRequest(cacheKey, this.cacheMinutes * 6e4, (() => this.client.search(normalize115Keyword(carNum)))); + card.find(".jhs-115-list-match").remove(); + const badge = $('').text(matches.length ? `匹配${matches.length}个` : "未匹配").data("matches", matches); + card.find(".video-title").first().prepend(badge), element.dataset.jhs115State = "matched"; + badge.on("click", (() => { + if (!matches.length) return this.matchCard(element, true); + if (1 === matches.length) return window.open(build115PlayUrl(matches[0]), "_blank"); + const links = matches.map(((match) => `${escapeHtml(match.name)}`)).join("
    "); + layer.open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea(["560px", "auto"]) }); + })); } catch (error) { - if (this.isNetworkBlocked(error)) throw error; - return clog.error(`${name}执行失败,继续后续后台任务`, error), this.createTaskResult({ attempted: true, error }); + element.dataset.jhs115State = "failed", card.find(".jhs-115-list-match").remove(), card.find(".video-title").first().prepend($('').one("click", (() => this.matchCard(element, true)))), clog.warn("115 单卡匹配失败", error); } } - isUnnecessaryCheck(e2, t2) { - if (!t2) throw new Error("未传入checkIntervalTime"); - t2 = parseInt(t2); - return utils.getHourDifference(new Date(e2), /* @__PURE__ */ new Date()) < t2; + destroy() { + this.unsubscribeItems?.(), this.observer?.disconnect(), this.flushTimer && clearTimeout(this.flushTimer), this.pendingCards.clear(); } - getTaskSchedule(name) { - const schedules = { - blacklist: { completedKey: this.lastCheckBlacklistTimeKey, attemptKey: this.lastCheckBlacklistAttemptKey, nextKey: this.lastCheckBlacklistNextKey, intervalSetting: "checkBlacklist_intervalTime", defaultInterval: 12 }, - favoriteActress: { completedKey: this.lastCheckFavoriteActressTimeKey, attemptKey: this.lastCheckFavoriteActressAttemptKey, nextKey: this.lastCheckFavoriteActressNextKey, intervalSetting: "checkFavoriteActress_IntervalTime", defaultInterval: 24 }, - newVideo: { completedKey: this.lastCheckNewVideoTimeKey, attemptKey: this.lastCheckNewVideoAttemptKey, nextKey: this.lastCheckNewVideoNextKey, intervalSetting: "checkNewVideo_intervalTime", defaultInterval: 12 } - }; - if (!schedules[name]) throw new Error(`未知任务调度: ${name}`); - return schedules[name]; + renameWithPreview(event, match, carNum) { + const nextName = preview115Rename(match.name, carNum, { uppercase: true, keepSuffix: true }); + utils.q(event, `确认重命名?
    ${escapeHtml(match.name)}
    → ${escapeHtml(nextName)}`, (async () => { + await new OneOneFiveClient().rename(match.fileId, nextName); + show.ok("重命名完成"); + })); } - getTaskScheduleState(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); - return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; + }; + __name(_OneOneFiveMatchPlugin, "OneOneFiveMatchPlugin"); + var OneOneFiveMatchPlugin = _OneOneFiveMatchPlugin; + + // src/plugins/one-two-three/offline.js + var _OneTwoThreeOfflinePlugin = class _OneTwoThreeOfflinePlugin extends BasePlugin { + constructor() { + super(...arguments), this.tokenKey = "jhs_123pan_author_token", this.tokenMetaKey = "jhs_123pan_author_token_meta", this.syncTimer = null, this.syncFallbackMs = 3e5; } - /** 返回当前标签页可证明的任务状态,不写入调度数据。 */ - getTaskStatusSnapshot(name) { - const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey)); - const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); - let status = "idle"; - if (this.activeTasks.has(name)) status = "running"; - else if (state.pending && now < nextAt) status = "pending"; - else if (null == storedNext || now >= nextAt) status = "due"; - return { name, state: status, completedAt: state.completed, attemptAt: state.attempt, nextAt, isPending: state.pending, pendingUntil: state.pending ? nextAt : null }; + getName() { + return "OneTwoThreeOfflinePlugin"; } - async emitTaskStatus(name, phase) { - try { - await globalThis.jhsEventBus?.emit?.("task-status-changed", { taskName: name, phase }, { broadcast: false }); - } catch (error) { - clog.error(`任务状态通知失败: ${name}/${phase}`, error); - } + async handle() { + "yun.123pan.com" === window.location.hostname && this.startTokenSync(); } - async emitNewVideoChanged(reason, carNums = []) { - try { - await globalThis.jhsEventBus?.emit?.("new-video-changed", { reason, carNums: carNums.map(normalizeCarNum).filter(Boolean) }); - } catch (error) { - clog.error("新作品状态通知失败", error); - } + startTokenSync() { + this.syncTokenOnce(), this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = setInterval((() => this.syncTokenOnce()), this.syncFallbackMs); + const e2 = /* @__PURE__ */ __name(() => this.syncTokenOnce(), "e"); + window.addEventListener("storage", e2), window.addEventListener("focus", e2), document.addEventListener("visibilitychange", (() => { + document.hidden || this.syncTokenOnce(); + })); } - async withActiveTask(name, runner) { - this.activeTasks.add(name); + getTokenFrom123Pan() { + let e2 = (localStorage.getItem("authorToken") || "").trim(); + if (e2) return { + token: e2, + source: "authorToken" + }; try { - await this.emitTaskStatus(name, "started"); - return await runner(); - } finally { - this.activeTasks.delete(name), await this.emitTaskStatus(name, "finished"); + const t3 = JSON.parse(localStorage.getItem("userInfo") || "{}"); + if (t3.authorToken || t3.token) return { + token: (t3.authorToken || t3.token || "").trim(), + source: t3.authorToken ? "userInfo.authorToken" : "userInfo.token" + }; + } catch (t3) { + clog.debug("123 云盘历史用户信息解析失败,继续尝试其他凭证来源", t3); } - } - async getLatestTaskInterval(name) { - const schedule = this.getTaskSchedule(name); - storageManager._invalidateCache?.(storageManager.setting_key); - return parseNumberSetting(await storageManager.getSetting(schedule.intervalSetting, schedule.defaultInterval), schedule.defaultInterval, { min: Number.EPSILON }); - } - async shouldStartTask(name, force = false) { - if (force) return true; - const schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); - const completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); - let next = parseTaskTimestamp(localStorage.getItem(schedule.nextKey)); - if (null == next) { - const pending = null != attempt && (null == completed || attempt > completed); - next = pending ? attempt + 3e5 : null == completed ? 0 : completed + 36e5 * interval; - localStorage.setItem(schedule.nextKey, String(next)); + const t2 = document.cookie.split(";"); + for (const n2 of t2) { + const e3 = n2.indexOf("="); + if (e3 < 0) continue; + const t3 = n2.substring(0, e3).trim(), a2 = n2.substring(e3 + 1); + if (t3 && /token/i.test(t3) && a2) return { + token: decodeURIComponent(a2).trim(), + source: `cookie.${t3}` + }; } - return Date.now() >= next; - } - beginTaskAttempt(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)); - const now = Math.floor(Date.now() / 1e3) * 1e3, attempt = null == completed ? now : Math.max(now, completed + 1e3); - localStorage.setItem(schedule.attemptKey, String(attempt)), localStorage.setItem(schedule.nextKey, String(attempt + 3e5)); - return attempt; + return { + token: "", + source: "" + }; } - async finalizeTask(name, completed) { - const schedule = this.getTaskSchedule(name); - if (!completed) return localStorage.setItem(schedule.nextKey, String(Date.now() + 3e5)); - const attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); - localStorage.setItem(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), localStorage.setItem(schedule.nextKey, String(completedAt + 36e5 * interval)); + syncTokenOnce() { + const e2 = this.getTokenFrom123Pan(); + if (!e2.token) return; + const t2 = GM_getValue(this.tokenKey, ""), n2 = GM_getValue(this.tokenMetaKey, null); + if (t2 === e2.token && n2 && n2.source === e2.source) return; + GM_setValue(this.tokenKey, e2.token), GM_setValue(this.tokenMetaKey, { + source: e2.source, + updatedAt: (/* @__PURE__ */ new Date()).toISOString() + }), t2 !== e2.token && show.info(`123 云盘授权已更新:${e2.source}`); } - async recalculateSchedules() { - await this.loadConfig(); - for (const [name, interval] of [["blacklist", this.taskConfig.checkBlacklist_intervalTime], ["favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime], ["newVideo", this.taskConfig.checkNewVideo_intervalTime]]) { - const state = this.getTaskScheduleState(name); - if (state.pending) continue; - localStorage.setItem(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); - } + getStoredToken() { + return GM_getValue(this.tokenKey, ""); } - async invalidateConfig(recalculate = false) { - this.taskConfigDirty = true; - if (!recalculate) return; - if (this.configRefreshPromise) return this.configRefreshQueued = true, this.configRefreshPromise; - return this.configRefreshPromise = (async () => { - do { - this.configRefreshQueued = false, await this.recalculateSchedules(); - } while (this.configRefreshQueued); - })().finally((() => { - this.configRefreshPromise = null; - })), this.configRefreshPromise; + clearStoredToken(e2) { + GM_setValue(this.tokenKey, ""), GM_setValue(this.tokenMetaKey, { + source: "cleared", + reason: e2, + updatedAt: (/* @__PURE__ */ new Date()).toISOString() + }); } - handle() { - if (!window.isListPage) return; - this.visibilityHandler || (this.visibilityHandler = () => { - document.hidden ? this.clearSchedule() : this.scheduleTask(0); - }, this.pageHideHandler = () => this.clearSchedule(), document.addEventListener("visibilitychange", this.visibilityHandler), window.addEventListener("pagehide", this.pageHideHandler)); - this.settingsHandler || (this.settingsHandler = async () => { - try { - storageManager._invalidateCache?.(storageManager.setting_key), await this.invalidateConfig(true), this.scheduleTask(0); - } catch (error) { - clog.error("任务设置刷新失败", error); - } - }, globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler)); - return document.hidden ? void 0 : this.runAndSchedule(); + isTokenExpiredError(e2) { + const msg = e2 instanceof Error ? e2.message : "object" == typeof e2 && e2 ? e2.message || "" : String(e2 || ""); + return "TOKEN_EXPIRED" === e2 || "TOKEN_EXPIRED" === msg || msg.toLowerCase().includes("token is expired"); } - clearSchedule() { - this.taskTimer && (clearTimeout(this.taskTimer), this.taskTimer = null); + assertApiResult(e2, t2) { + if (0 === e2.code) return; + const n2 = e2.message || e2.msg || t2 || "请求失败"; + throw /token is expired/i.test(n2) ? "TOKEN_EXPIRED" : n2; } - scheduleTask(e2 = 3e5) { - if (!window.isListPage || document.hidden) return void this.clearSchedule(); - this.clearSchedule(), this.taskTimer = setTimeout((() => { - this.taskTimer = null, void this.runAndSchedule(); - }), e2); + /* 依赖 gmRequest 在非 2xx 时 reject 对象上附加 status 属性 */ + async resolveMagnet(e2, t2) { + try { + const n2 = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/resolve"), { urls: e2 }, { + Authorization: "Bearer " + t2, + "App-Version": "3", + platform: "web", + Origin: "https://yun.123pan.com", + Referer: "https://yun.123pan.com/" + }); + return this.assertApiResult(n2, "解析失败"), n2.data && n2.data.list && n2.data.list.length > 0 ? n2.data.list[0] : Promise.reject(n2.message || `解析失败 (${n2.code})`); + } catch (a2) { + if (a2 && 401 === a2.status) throw "TOKEN_EXPIRED"; + throw this.isTokenExpiredError(a2) ? "TOKEN_EXPIRED" : a2.message ? "响应解析失败: " + a2.message : String(a2); + } } - async runAndSchedule() { - if (this.taskRunning || !window.isListPage || document.hidden) return; - this.taskRunning = true; + async submitTask(e2, t2) { + if (!e2.files || 0 === e2.files.length) throw "没有可建立离线的文件"; + const n2 = e2.files.map(((e3) => e3.id)), a2 = e2.files.reduce(((e3, t3) => e3 + (t3.size || 0)), 0); try { - await this.doTask(); - } finally { - this.taskRunning = false, this.scheduleTask(); + const i2 = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/submit"), { + resource_list: [{ resource_id: e2.id, select_file_id: n2 }] + }, { + Authorization: "Bearer " + t2, + "App-Version": "3", + platform: "web" + }); + return this.assertApiResult(i2, "提交失败"), { fileCount: n2.length, totalSize: a2 }; + } catch (i2) { + if (i2 && 401 === i2.status) throw "TOKEN_EXPIRED"; + throw this.isTokenExpiredError(i2) ? "TOKEN_EXPIRED" : i2.message ? "响应解析失败: " + i2.message : String(i2); } } - async doTask() { - if (!window.isListPage) return; - await this.loadConfig(), this.javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); - return navigator.locks.request(this.singleTaskKey, { - ifAvailable: true - }, (async (e2) => { - if (e2) { - if (window.isListPage && (this.taskConfig.enableCheckBlacklist === _ ? await this.runBackgroundTask("黑名单整批检测", (() => this.checkBlacklist())) : clog.warn("自动检测屏蔽黑名单-禁用"), !l)) { - if (this.taskConfig.enableCheckFavoriteActress === _) { - $('a[href*="/users/profile"]').length > 0 ? await this.runBackgroundTask("演员收藏同步", (() => this.checkFavoriteActress())) : clog.debug("未登录 JavDB,跳过自动同步演员"); - } else clog.warn("自动同步已收藏的演员-禁用"); - this.taskConfig.enableCheckNewVideo === _ ? await this.runBackgroundTask("新作品整批检测", (() => this.checkNewVideo())) : clog.warn("自动检测已收藏演员的最新作品-禁用"); - } - } else clog.debug("争夺任务锁失败, 跳过执行"); - })).catch(((e2) => { - this.isNetworkBlocked(e2) ? clog.warn(`后台检测已停止: ${e2.message}`) : clog.error("锁任务出现错误:", e2); - })); + /** CRC32-IEEE (poly 0xEDB88320) — 与 Go crc32.ChecksumIEEE 一致 */ + _crc32(e2) { + const t2 = new Array(256); + for (let n3 = 0; n3 < 256; n3++) { + let a2 = n3; + for (let i2 = 0; i2 < 8; i2++) a2 = 1 & a2 ? 3988292384 ^ a2 >>> 1 : a2 >>> 1; + t2[n3] = a2; + } + let n2 = 4294967295; + for (let a2 = 0; a2 < e2.length; a2++) n2 = t2[(n2 ^ e2.charCodeAt(a2)) & 255] ^ n2 >>> 8; + return (n2 ^ 4294967295) >>> 0; } - async loadConfig() { - if (this.configLoadPromise) return this.configLoadQueued = true, this.configLoadPromise; - return this.configLoadPromise = (async () => { - do { - this.configLoadQueued = false; - const e2 = await storageManager.getSetting(), nextConfig = { - checkConcurrencyCount: parseNumberSetting(e2.checkConcurrencyCount, 2, { min: 2, max: 5 }), - checkRequestSleep: parseNumberSetting(e2.checkRequestSleep, 100, { min: 0, max: 3e3 }), - enableCheckBlacklist: e2.enableCheckBlacklist || _, - checkBlacklist_intervalTime: parseNumberSetting(e2.checkBlacklist_intervalTime, 12, { min: Number.EPSILON }), - checkBlacklist_ruleTime: parseNumberSetting(e2.checkBlacklist_ruleTime, 8760, { min: 0 }), - enableCheckFavoriteActress: e2.enableCheckFavoriteActress || _, - checkFavoriteActress_IntervalTime: parseNumberSetting(e2.checkFavoriteActress_IntervalTime, 24, { min: Number.EPSILON }), - enableCheckNewVideo: e2.enableCheckNewVideo || _, - checkNewVideo_intervalTime: parseNumberSetting(e2.checkNewVideo_intervalTime, 12, { min: Number.EPSILON }), - checkNewVideo_ruleTime: parseNumberSetting(e2.checkNewVideo_ruleTime, 8760, { min: 0 }) - }; - this.taskConfig = nextConfig, this.taskConfigDirty = false; - } while (this.configLoadQueued || this.taskConfigDirty); - return this.taskConfig; - })().finally((() => { - this.configLoadPromise = null; - })), this.configLoadPromise; + /** 为 123 云盘 API URL 附加签名查询参数(与 Go signPath 算法一致) */ + _signUrl(e2) { + const t2 = ["a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z"]; + const n2 = Math.round(1e7 * Math.random()); + const a2 = /* @__PURE__ */ new Date(); + const i2 = new Date(a2.getTime() + 6e4 * a2.getTimezoneOffset() + 288e5); + const s2 = `${i2.getFullYear()}${String(i2.getMonth() + 1).padStart(2, "0")}${String(i2.getDate()).padStart(2, "0")}${String(i2.getHours()).padStart(2, "0")}${String(i2.getMinutes()).padStart(2, "0")}`; + let o2 = ""; + for (let r3 = 0; r3 < s2.length; r3++) o2 += t2[parseInt(s2[r3])]; + const r2 = this._crc32(o2), l2 = Math.floor(i2.getTime() / 1e3); + const c2 = `${l2}|${n2}|${new URL(e2).pathname}|web|3|${r2}`; + const u2 = this._crc32(c2), d2 = new URL(e2); + return d2.searchParams.set(String(r2), `${l2}-${n2}-${u2}`), d2.toString(); } - /** 确保所有任务入口均已具备配置和站点地址。 */ - async ensureReady() { - (!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl()); - if (!this.javDbUrl) throw new Error("JavDB 地址未配置"); + }; + __name(_OneTwoThreeOfflinePlugin, "OneTwoThreeOfflinePlugin"); + var OneTwoThreeOfflinePlugin = _OneTwoThreeOfflinePlugin; + + // src/plugins/stats/stats.js + var _StatsPlugin = class _StatsPlugin extends BasePlugin { + getName() { + return "StatsPlugin"; } - async resolveBlacklistSite(url) { - try { - const target = new URL(url), otherSite = this.getBean("OtherSitePlugin"), [javDbUrl, javBusUrl] = await Promise.all([otherSite?.getJavDbUrl?.(), otherSite?.getJavBusUrl?.()]); - for (const [configuredUrl, site2] of [[javDbUrl, T], [javBusUrl, I]]) { - try { - if (configuredUrl && target.hostname === new URL(configuredUrl).hostname) return site2; - } catch (error) { - clog.warn(`忽略无效的站点配置: ${configuredUrl}`, error); - } - } - const { site } = detectSite(url); - return [T, I].includes(site) ? site : null; - } catch (error) { - return null; - } + async initCss() { + return ` + `; } - async checkBlacklist(force = false) { - await this.ensureReady(); - const result = this.createTaskResult({ skippedHost: 0 }); - if (!await this.shouldStartTask("blacklist", force)) return clog.debug("检测黑名单未到整批执行时间"), result; - this.beginTaskAttempt("blacklist"), result.attempted = true; - return this.withActiveTask("blacklist", async () => { - let blockedError = null, finalized = false; - try { - let items = await storageManager.getBlacklist(); - items = items.sort(((left, right) => left.createTime < right.createTime ? 1 : left.createTime > right.createTime ? -1 : 0)); - const concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkBlacklist_intervalTime, rule = this.taskConfig.checkBlacklist_ruleTime; - const eligible = [], currentHostname = new URL(window.location.href).hostname; - for (const item of items) { - let itemUrl; - try { - itemUrl = new URL(item.url); - } catch (error) { - result.parseFailed++, clog.error("黑名单地址无效:", item.url, error); - continue; - } - if (currentHostname !== itemUrl.hostname) { - result.skippedHost++; - continue; - } - if (!force && item.checkTime && this.isUnnecessaryCheck(item.checkTime, interval)) result.skippedInterval++; - else if (shouldSkipStopped(item.lastPublishTime, rule)) result.skippedStopped++; - else { - const site = await this.resolveBlacklistSite(itemUrl.href); - site ? eligible.push({ item, site }) : (result.parseFailed++, clog.error(`不支持的黑名单来源站点: ${itemUrl.hostname}`)); - } - } - clog.log(`检测屏蔽黑名单, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); - const blacklistPlugin = this.getBean("BlacklistPlugin"); - try { - await this.limitConcurrency(eligible, concurrency, sleep, (async (entry) => { - const { item, site } = entry; - const { starId, name, url } = item; - let responseText; - try { - clog.log("正在检屏黑名单演员:", name, url), $("#checkBlacklistMsg").text(`正在检屏黑名单演员: ${name} ${url}`), responseText = await gmHttp.get(url); - } catch (error) { - result.networkFailed++; - if (this.isNetworkBlocked(error)) throw error; - return void clog.error("检测屏蔽演员网络错误:", url, error); - } - try { - const page = utils.htmlTo$dom(responseText); - await this.storageQueue.addTask((async () => { - const parsed = await blacklistPlugin.parseAndSaveFilterInfo(page, name, starId, site); - await storageManager.updateBlacklistItem({ starId, name, checkTime: utils.getNowStr(), lastPublishTime: parsed.lastPublishTime }); - })), result.success++; - } catch (error) { - result.parseFailed++, clog.error("解析或保存黑名单演员失败:", url, error); - } - })), await this.storageQueue.waitAllFinished(); - } catch (error) { - if (!this.isNetworkBlocked(error)) throw error; - blockedError = error, result.aborted = Math.max(0, eligible.length - result.success - result.parseFailed - result.networkFailed); - } - const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; - result.completed = completed, result.fatal = !!blockedError, await this.finalizeTask("blacklist", completed), finalized = true, this.renderBlacklistResult(result, completed); - try { - await this.getBean("BlacklistPlugin").resetBtnTip(); - } catch (error) { - clog.error("刷新黑名单检测提示失败", error); - } - if (blockedError) throw blockedError; - return result; - } catch (error) { - finalized || await this.finalizeTask("blacklist", false), result.completed = false, result.fatal = this.isNetworkBlocked(error); - if (result.fatal) throw error; - return result.parseFailed++, clog.error("黑名单整批检测失败", error), result; - } - }); + async handle() { + window.isListPage && this.createBtn(); } - renderBlacklistResult(result, completed) { - const retry = completed ? "" : ",5 分钟后补偿未完成项", message = `黑名单整批检测:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval},异站跳过 ${result.skippedHost}${result.aborted ? `,未执行 ${result.aborted}` : ""}${retry}`; - $("#checkBlacklistMsg").text(message), clog.log(message); + createBtn() { + const e2 = ''; + $("#newVideoBtn").after(e2), $("#statsBtn").on("click", (() => this.openDialog())); } - async checkFavoriteActress(force = false) { - await this.ensureReady(); - const result = this.createTaskResult({ pages: 0, actorCount: 0 }); - if (!await this.shouldStartTask("favoriteActress", force)) return clog.debug("同步收藏演员未到整批执行时间"), result; - this.beginTaskAttempt("favoriteActress"), result.attempted = true; - return this.withActiveTask("favoriteActress", async () => { - try { - const actors = [], sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); - actors.length > 0 && await storageManager.addFavoriteActressList(actors), result.success = actors.length, result.actorCount = actors.length, result.pages = sync.pages; - await this.finalizeTask("favoriteActress", true), result.completed = true, clog.log("所有演员信息已收集, 总计数量:", actors.length), $("#checkNewVideoMsg").text(`完整同步完成:演员 ${actors.length},页面 ${sync.pages}`); - actors.length > 0 && await this.emitNewVideoChanged("favorite-actress-sync"); - return result; - } catch (error) { - result.completed = false, error?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(error), await this.finalizeTask("favoriteActress", false), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", error); - if (result.fatal) throw error; - return result; - } - }); + async openDialog() { + const cars = await storageManager.getCarList(), actresses = await storageManager.getFavoriteActressList(), blacklist = await storageManager.getBlacklist(), activity = await stateService.getActivityLog(), total = cars.length; + const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; + cars.forEach(((car) => { + const flags = normalizeStateFlags(car.stateFlags); + flags.blocked && counts.manualBlocked++, flags.favorite && counts.favorite++, flags.downloaded && counts.hasDown++, flags.watched && counts.hasWatch++, hasAnyState(flags) || counts.pending++; + })); + const actressCounts = /* @__PURE__ */ new Map(); + cars.forEach(((car) => { + const names = String(car.names || "").split(/[\s,,、]+/).filter(Boolean); + if (car.starId) { + const key = `id:${car.starId}`, current = actressCounts.get(key) || { starId: car.starId, name: names[0] || car.starId, count: 0 }; + current.count++, actressCounts.set(key, current); + } else names.forEach(((name) => { + const key = `name:${name}`, current = actressCounts.get(key) || { starId: "", name, count: 0 }; + current.count++, actressCounts.set(key, current); + })); + })); + const topActresses = [...actressCounts.values()].sort(((left, right) => right.count - left.count || left.name.localeCompare(right.name))).slice(0, 10), topValue = topActresses[0]?.count || 1, javDbUrl = await this.getDependency("OtherSitePlugin").getJavDbUrl(); + const pending = counts.pending, counter = this.getDependency("NewVideoPlugin"), newVideos = counter ? await counter.getPendingNewVideoTotal() : 0, pageSummary = this.getDependency("ListPagePlugin").getCurrentPageSummary(); + const metrics = [ + { label: "总记录", value: total, action: null }, + { label: "收藏", value: counts.favorite, action: null }, + { label: "下载", value: counts.hasDown, action: null }, + { label: "已看", value: counts.hasWatch, action: null }, + { label: "手动屏蔽", value: counts.manualBlocked, action: null }, + { label: "未鉴定", value: pending, action: null }, + { label: "收藏演员", value: actresses.length, action: null }, + { label: "黑名单演员", value: blacklist.length, action: null }, + { label: "新作品待处理", value: newVideos, action: "new-video" } + ]; + const statusRows = [["收藏", counts.favorite, "var(--jhs-status-fav)"], ["下载", counts.hasDown, "var(--jhs-status-down)"], ["已看", counts.hasWatch, "var(--jhs-status-watch)"], ["手动屏蔽", counts.manualBlocked, "var(--jhs-status-filter)"], ["未鉴定", pending, "var(--jhs-border-strong)"]]; + const row = /* @__PURE__ */ __name((label, value, max, color, href = "") => `
    ${href ? `${escapeHtml(label)}` : `${escapeHtml(label)}`}${value}${max === total && total ? ` (${Math.round(value / total * 100)}%)` : ""}
    `, "row"); + const trend = /* @__PURE__ */ __name((days) => { + const cutoff = Date.now() - days * 864e5, result = { identified: 0, downloaded: 0, watched: 0 }; + activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState)).forEach(((change) => { + const before = normalizeStateFlags(change.before?.stateFlags), after = normalizeStateFlags(change.after?.stateFlags); + !hasAnyState(before) && hasAnyState(after) && result.identified++, !before.downloaded && after.downloaded && result.downloaded++, !before.watched && after.watched && result.watched++; + })))); + return result; + }, "trend"), trend7 = trend(7), trend30 = trend(30); + const coverageNote = activity.coverageStart ? `活动记录仅覆盖自 ${escapeHtml(activity.coverageStart)} 起` : "仅统计 6.4.0 及之后产生的操作记录"; + const renderMetric = /* @__PURE__ */ __name((metric) => metric.action ? `` : `
    ${metric.value}${metric.label}
    `, "renderMetric"); + const dialogHtml = `
    +

    全库概览

    ${metrics.map(renderMetric).join("")}
    +

    当前页面

    ${renderMetric({ label: "屏蔽项", value: pageSummary.blockedItems, action: "filter", filter: "blockedItems" })}
    +

    状态分布

    ${statusRows.map(((item) => row(item[0], item[1], total, item[2]))).join("")}
    +

    活动趋势

    ${coverageNote}

    ${trend7.identified}近 7 天新增鉴定
    ${trend7.downloaded}近 7 天标记下载
    ${trend7.watched}近 7 天标记观看
    ${trend30.identified}近 30 天新增鉴定
    ${trend30.downloaded}近 30 天标记下载
    ${trend30.watched}近 30 天标记观看
    + ${topActresses.length ? `

    Top 10 演员

    ${topActresses.map(((item) => row(item.name, item.count, topValue, "var(--jhs-accent)", new URL(item.starId ? `/actors/${encodeURIComponent(item.starId)}` : `/search?q=${encodeURIComponent(item.name)}`, javDbUrl).href))).join("")}
    ` : ""} +
    `; + layer.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: false, area: utils.getDialogArea("lg"), anim: -1, success: /* @__PURE__ */ __name((layerElement, layerIndex) => { + $(layerElement).find(".jhs-stats__bar").each((function() { + $(this).css({ "--jhs-value": `${$(this).data("width")}%`, "--jhs-bar": $(this).data("color") }); + })); + $(layerElement).find("button.jhs-stats__metric[data-action]").on("click", ((event) => { + const metric = $(event.currentTarget), action = metric.data("action"); + layer.close(layerIndex); + if ("new-video" === action) return this.getDependency("NewVideoPlugin").openDialog(); + if ("filter" === action) this.getDependency("ListPagePlugin").setQuickFilter(metric.data("filter")); + })); + utils.setupEscClose(layerIndex); + }, "success") }); } - async scrapeActorInfo(startUrl, target = []) { - const expected = new URL("/users/collection_actors", this.javDbUrl), visitedUrls = /* @__PURE__ */ new Set(); - let currentUrl = new URL(startUrl, this.javDbUrl), pages = 0; - for (; currentUrl; ) { - try { - if (currentUrl.origin !== expected.origin || currentUrl.pathname !== expected.pathname) throw new Error(`收藏演员分页地址越界: ${currentUrl.href}`); - if (visitedUrls.has(currentUrl.href)) throw new Error(`收藏演员分页循环: ${currentUrl.href}`); - if (pages >= 200) throw new Error("收藏演员分页超过 200 页"); - visitedUrls.add(currentUrl.href), pages++, clog.log(`正在抓取页面: ${currentUrl.href}`), $("#checkNewVideoMsg").text(`正在解析已收藏的演员: ${currentUrl.href}`); - let responseText; - try { - responseText = await gmHttp.get(currentUrl.href); - } catch (error) { - throw error._taskNetwork = true, error; - } - const page = utils.htmlTo$dom(responseText), parsed = parseJavDbActorList(page, currentUrl.href); - if ("valid" !== parsed.state) throw Object.assign(new Error(`收藏演员页面无效: ${parsed.state}`), { _taskParse: true }); - if (parsed.isEmpty && parsed.nextUrl) throw Object.assign(new Error("收藏演员空页面包含下一页"), { _taskParse: true }); - target.push(...parsed.actors), currentUrl = parsed.nextUrl ? new URL(parsed.nextUrl, currentUrl.href) : null; - } catch (error) { - error._taskNetwork || Object.prototype.hasOwnProperty.call(error, "_taskParse") || (error._taskParse = true); - throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, error), error; - } - } - return { actors: target, pages }; + }; + __name(_StatsPlugin, "StatsPlugin"); + var StatsPlugin = _StatsPlugin; + + // src/plugins/status/auto-page.js + var _AutoPagePlugin = class _AutoPagePlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "preloadDistance", 500), i(this, "currentPage", this.getInitialPageNumber()), i(this, "pageItems", []); } - async checkNewVideo(force = false) { - await this.ensureReady(); - const result = this.createTaskResult(); - if (!await this.shouldStartTask("newVideo", force)) return clog.debug("检测新作品未到整批执行时间"), result; - this.beginTaskAttempt("newVideo"), result.attempted = true; - return this.withActiveTask("newVideo", async () => { - let blockedError = null, finalized = false; - try { - const actresses = await storageManager.getFavoriteActressList(), sorted = utils.genericSort(actresses, [{ - key: /* @__PURE__ */ __name((actress) => actress.newVideoList?.length ?? 0, "key"), - order: "desc" - }, { - key: "lastPublishTime", - order: "desc" - }]), concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkNewVideo_intervalTime, rule = this.taskConfig.checkNewVideo_ruleTime; - const eligible = []; - for (const actress of sorted) { - if (!force && actress.lastCheckTime && this.isUnnecessaryCheck(actress.lastCheckTime, interval)) result.skippedInterval++; - else if (shouldSkipStopped(actress.lastPublishTime, rule)) result.skippedStopped++; - else eligible.push(actress); - } - clog.log(`检测最新作品, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); - if (eligible.length > 0) { - const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map(((item) => item.carNum))); - try { - await this.limitConcurrency(eligible, concurrency, sleep, (async (actress) => { - const { name, starId } = actress, url = `${this.javDbUrl}/actors/${starId}?t=d`; - try { - clog.log("正在检测最新作品, 演员:", name, url), $("#checkNewVideoMsg").text(`正在检测最新作品, 演员: ${name}`); - const responseText = await gmHttp.get(url), page = utils.htmlTo$dom(responseText); - try { - await this.storageQueue.addTask((async () => this.parsePage(page, T, starId, name, titleKeywords, blacklistSet))), result.success++; - } catch (error) { - result.parseFailed++, clog.error("解析或保存演员作品失败:", url, error); - } - } catch (error) { - if (this.isNetworkBlocked(error)) throw result.networkFailed++, error; - result.networkFailed++, clog.error("检测演员信息发生网络错误:", url, error); - } - })), await this.storageQueue.waitAllFinished(); - } catch (error) { - if (!this.isNetworkBlocked(error)) throw error; - blockedError = error, result.fatal = true, result.aborted = Math.max(0, eligible.length - result.success - result.parseFailed - result.networkFailed), clog.warn(`网络阻断,本轮停止,未执行 ${result.aborted}`); - } - } - const completed = 0 === result.parseFailed + result.networkFailed + result.aborted; - result.completed = completed, await this.finalizeTask("newVideo", completed), finalized = true, clog.log('检测最新作品---结束'), this.renderCheckResult(result, completed ? actresses.length ? "整批检测结束" : "收藏为空,整批检测完成" : "整批检测未完成,5 分钟后补偿未完成项"); - result.success > 0 && await this.emitNewVideoChanged("task-completed"); - if (blockedError) throw blockedError; - return result; - } catch (error) { - finalized || await this.finalizeTask("newVideo", false), result.completed = false, result.fatal = this.isNetworkBlocked(error); - if (result.fatal) throw error; - return result.parseFailed++, this.renderCheckResult(result, "整批检测失败,5 分钟后补偿"), clog.error("新作品整批检测失败", error), result; - } - }); + getName() { + return "AutoPagePlugin"; } - renderCheckResult(result, prefix = "检测结束") { - const message = `${prefix}:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval}${result.aborted ? `,未执行 ${result.aborted}` : ""}`; - $("#checkNewVideoMsg").text(message), clog.log(message); + async initCss() { + return "\n \n "; + } + async handle() { + await this.waterfall(); } - async parsePage(e2, site, t2, n2, a2, i2) { - const selector = this.getSelector(site); - site === I && e2.find(".avatar-box").length > 0 && e2.find(".avatar-box").parent().remove(); - const pageState = parseDetailPage(e2, { - boxSelector: site === I ? `${selector.boxSelector}, #waterfall` : selector.boxSelector, - requestDomItemSelector: selector.requestDomItemSelector - }); - if ("valid" !== pageState.state) throw clog.error("新作品检测-解析列表失败"), new Error("新作品检测-解析列表失败"); - const s2 = pageState.items, o2 = e2.find(selector.nextPageSelector).attr("href"); - if (0 === s2.length && o2) throw new Error("新作品检测-空列表包含下一页"); - if (0 === s2.length) return await storageManager.updateFavoriteActress({ - starId: t2, - lastCheckTime: utils.getNowStr(), - newVideoList: [] - }), 0; - let c2 = []; - const publishTimes = []; - for (const m2 of s2) { - const e3 = $(m2), { carNum: s3, url: o3, title: r2, publishTime: l2 } = this.getBean("ListPagePlugin").findCarNumAndHref(e3); - l2 && publishTimes.push(l2); - if (!s3) continue; - a2.find(((e4) => r2.includes(e4) || s3.includes(e4))) || (i2.has(s3) || (() => { - let coverUrl = e3.find("img").attr("src") || ""; - if (coverUrl && !coverUrl.startsWith("http")) { - coverUrl = coverUrl.startsWith("/") ? this.javDbUrl + coverUrl : this.javDbUrl + "/" + coverUrl; - } - let url = o3 || ""; - if (url && !url.startsWith("http")) { - url = url.startsWith("/") ? this.javDbUrl + url : this.javDbUrl + "/" + url; - } - const scoreText = e3.find(".score .value, .score").text().trim(); - const score = parseFloat(scoreText) || 0; - const voteMatch = scoreText.match(/由(\d+)人/); - let voteCount = 0; - if (voteMatch) { - voteCount = parseInt(voteMatch[1]); - } else { - const voteText = e3.find(".score .count, .meta .count").text().trim(); - voteCount = parseInt(voteText.replace(/[^\d]/g, "")) || 0; - } - c2.push({ carNum: s3, coverUrl, title: r2 || "", publishTime: l2 || "", score, voteCount, url }); - })()); + getInitialPageNumber() { + if (l) { + const e2 = o.match(/\/(page|star\/[^/]+)\/(\d+)/); + return e2 ? parseInt(e2[2], 10) : 1; } - const d2 = selectLatestPublishTime(publishTimes), h2 = await storageManager.getCarMap(), p2 = c2.filter(((e3) => !h2.has(e3.carNum))); - p2.length > 0 && clog.log(`检测出新作品, ${n2}, 共${p2.length}部`), await storageManager.updateFavoriteActress({ - starId: t2, - lastCheckTime: utils.getNowStr(), - newVideoList: p2, - lastPublishTime: d2 - }); - return p2.length; + if (r) { + const e2 = o.match(/[?&]page=(\d+)/); + return e2 ? parseInt(e2[1], 10) : 1; + } + return 1; } - async checkOneNewVideo(e2) { - await this.ensureReady(); - const t2 = await storageManager.getTitleFilterKeyword(), n2 = await storageManager.getBlacklistCarList(), a2 = new Set(n2.map(((e3) => e3.carNum))), { lastCheckTime: i2, name: s2, starId: o2 } = e2; - let r2 = `${this.javDbUrl}/actors/${o2}?t=d`; - const l2 = $("#checkNewVideoMsg"); + async waterfall() { + if (await this.shouldDisablePaging()) return; + const e2 = this.getSelector(); + if (this.container = document.querySelector(e2.boxSelector), !this.container) return void clog.error("没有找到容器节点,停止瀑布流!"); + this.loader = document.createElement("div"), this.loader.className = "jhs-scroll", this.container.parentNode.insertBefore(this.loader, this.container.nextSibling), this.pageItems.push({ + page: this.currentPage, + top: 0, + url: window.location.href + }), this.loader.addEventListener("click", (() => { + this.loader.classList.contains("waterfall-error") && void this.loadNextPage().catch(((error) => clog.error("瀑布流重试失败", error))); + })), (() => { + let t3 = false; + window.addEventListener("scroll", (() => { + t3 || (t3 = true, requestAnimationFrame((() => { + this.checkLoad(), this.checkScrollPosition(), t3 = false; + }))); + })); + })(); + const t2 = document.querySelector(e2.nextPageSelector); + this.nextUrl = null == t2 ? void 0 : t2.href, this.hasMore = !!this.nextUrl, setTimeout((() => { + this.checkLoad(); + }), 1e3), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); + } + async loadNextPage() { + var e2; + if (await storageManager.getSetting("autoPage", _) === C) return void this.setState("waterfall-loading", ""); + if (this.isLoading || !this.nextUrl) return; + this.isLoading = true, this.setState("waterfall-loading", "加载中..."); + const t2 = this.getSelector(); try { - clog.log("正在检测最新作品, 演员:", s2, r2), l2.text(`正在检测最新作品, 演员: ${s2}`); - const e3 = await gmHttp.get(r2), n3 = utils.htmlTo$dom(e3); - await this.parsePage(n3, T, o2, s2, t2, a2), clog.log('检测最新作品---结束'), l2.text("检测完毕"); - await this.emitNewVideoChanged("single-actress-check"); - } catch (c2) { - clog.error("检测屏蔽演员信息, 发生错误:", r2, c2), show.error("检测屏蔽演员信息, 发生错误:" + c2, "bottom", "right"), l2.text(`检测屏蔽演员信息, 发生错误: ${r2}`); + const i2 = await gmHttp.get(this.nextUrl); + clog.log("请求下一页内容:", this.nextUrl); + const s2 = utils.htmlTo$dom(i2); + l && s2.find(".avatar-box").length > 0 && s2.find(".avatar-box").parent().remove(); + let c2 = s2.find(this.getSelector().requestDomItemSelector); + const d2 = this.getBoxCarInfoList(), h2 = this.getBoxCarInfoList(c2); + if (this.checkDuplicateCarNumbers(d2, h2)) return this.nextUrl = null, this.hasMore = false, void this.setState("waterfall-error", "翻页内容出现重复数据, 页码受JavDB限制, 已停止瀑布流"); + const g2 = this.container.scrollHeight; + this.pageItems.push({ + page: this.currentPage + 1, + top: g2, + url: this.nextUrl + }); + const p2 = this.getDependency("ListPagePlugin"); + let m2 = s2.find(this.getSelector().coverImgSelector); + p2.replaceHdImg(m2), $(this.getSelector().boxSelector).append(c2), this.nextUrl = null == (e2 = s2.find(t2.nextPageSelector)) ? void 0 : e2.attr("href"), this.hasMore = !!this.nextUrl; + let u2 = s2.find(".pagination"); + $(".pagination").replaceWith(u2), this.setState("waterfall-loading", ""), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); + } catch (n2) { + clog.error("加载失败:", n2), this.setState("waterfall-error", "加载失败,点击重试"); + } finally { + this.isLoading = false; } } - }; - __name(_TaskPlugin, "TaskPlugin"); - var TaskPlugin = _TaskPlugin; - var tt = [{ - name: "jsDelivr (全球CDN)", - json: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Filetree.json", - base: "https://cdn.jsdelivr.net/gh/gfriends/gfriends/Content/" - }, { - name: "GitHub Raw (备用)", - json: "https://raw.githubusercontent.com/gfriends/gfriends/master/Filetree.json", - base: "https://raw.githubusercontent.com/gfriends/gfriends/master/Content/" - }]; - var nt = "jhs_img_cdn_index"; - var at = parseInt(localStorage.getItem(nt) || "0", 10); - (at >= tt.length || at < 0) && (at = 0); - var it = tt[at].json; - var st = tt[at].base; - var ot = "filetreeStore"; - var rt = "filetree_data"; - var lt = { - db: null, - async open() { - return this.db ? this.db : new Promise(((e2, t2) => { - const n2 = indexedDB.open("GfriendsAvatarDB", 1); - n2.onupgradeneeded = (e3) => { - this.db = e3.target.result, this.db.objectStoreNames.contains(ot) || this.db.createObjectStore(ot); - }, n2.onsuccess = (t3) => { - this.db = t3.target.result, e2(this.db); - }, n2.onerror = (e3) => { - clog.error("IndexedDB open error:", e3.target.errorCode), t2(new Error("Failed to open IndexedDB")); - }; - })); - }, - async get(e2) { - return await this.open(), new Promise(((t2) => { - const n2 = this.db.transaction([ot], "readonly").objectStore(ot).get(e2); - n2.onsuccess = () => t2(n2.result), n2.onerror = () => t2(null); - })); - }, - async set(e2, t2) { - return await this.open(), new Promise(((n2, a2) => { - const i2 = this.db.transaction([ot], "readwrite").objectStore(ot).put(t2, e2); - i2.onsuccess = () => n2(), i2.onerror = (e3) => { - clog.error("IndexedDB set error:", e3.target.errorCode), a2(new Error("Failed to write to IndexedDB")); - }; - })); - } - }; - var ct = null; - var dt = null; - function ht(e2) { - if (!e2 || !e2.Content) return null; - const t2 = {}, n2 = e2.Content; - for (const a2 in n2) { - const e3 = encodeURIComponent(a2); - for (const i2 in n2[a2]) { - let s2 = i2.replace(/\.jpg$/i, "").split("-")[0]; - s2.startsWith("AI-Fix-") && (s2 = s2.substring(7)); - const o2 = s2.toLowerCase().trim(); - if (o2.length > 0) { - const s3 = n2[a2][i2], r2 = s3.indexOf("?"); - let l2, c2 = ""; - r2 > -1 ? (l2 = encodeURIComponent(s3.substring(0, r2)), c2 = s3.substring(r2)) : l2 = encodeURIComponent(s3); - const d2 = `${st}${e3}/${l2}${c2}`; - t2[o2] || (t2[o2] = []), t2[o2].includes(d2) || t2[o2].push(d2); + checkScrollPosition() { + const e2 = window.scrollY; + for (let t2 = this.pageItems.length - 1; t2 >= 0; t2--) { + const n2 = this.pageItems[t2]; + if (e2 >= n2.top) { + this.currentPage !== n2.page && (this.currentPage = n2.page, this.updatePageUrl(n2.url)); + break; } } } - return t2; - } - __name(ht, "ht"); - async function gt(e2) { - let t2 = loading(); - try { - await (async function() { - if (ct && dt) return ct; - let e3 = null; - try { - e3 = await lt.get(rt); - } catch (a3) { - clog.error("读取 IndexedDB 失败:", a3); - } - if (e3 && e3.Content && (ct = e3, dt = ht(e3), dt)) return ct; - show.info("正在载入头像数据源..."); - const t3 = await fetch(it); - if (!t3.ok) throw new Error(`请求头像源失败: ${t3.status}`); - const n3 = await t3.json(); - if (n3 && n3.Content) { - ct = n3, dt = ht(n3); - try { - await lt.set(rt, n3), clog.debug("载入头像数据源并写入缓存成功!"); - } catch (a3) { - clog.error(a3), show.error("头像数据源写入缓存失败,可能磁盘已满或其他权限问题。"); - } - return ct; - } - clog.error(n3); - throw new Error("解析头像数据源失败"); - })(); - } catch (i2) { - return show.error(i2), []; - } finally { - t2.close(); + checkLoad() { + if (!this.loader) return; + this.loader.getBoundingClientRect().top < window.innerHeight + this.preloadDistance && void this.loadNextPage().catch(((error) => clog.error("瀑布流自动加载失败", error))); } - if (!dt) return []; - const n2 = /* @__PURE__ */ new Set(), a2 = e2.map(((e3) => e3.toLowerCase().trim())).filter(((e3) => e3.length > 0)); - if (0 === a2.length) return []; - for (const s2 of a2) { - const e3 = dt[s2]; - e3 && e3.forEach(((e4) => n2.add(e4))); + async shouldDisablePaging() { + if (!window.isListPage) return true; + const enabled = await storageManager.getSetting("autoPage", _); + return enabled !== _ || ["search?q", "handlePlayback=1", "handleTop=1", "/want_watch_videos", "/watched_videos", "/advanced_search?type=100"].some(((e2) => o.includes(e2))); + } + updatePageUrl(e2) { + window.history.replaceState({}, "", e2), l && (document.title = document.title.replace(/第\d+頁/, `第${this.currentPage}頁`)); + } + setState(e2, t2) { + this.loader.className = `jhs-scroll ${e2}`, this.loader.textContent = t2; + } + }; + __name(_AutoPagePlugin, "AutoPagePlugin"); + var AutoPagePlugin = _AutoPagePlugin; + + // src/plugins/status/bus-detail-page.js + var _BusDetailPagePlugin = class _BusDetailPagePlugin extends BasePlugin { + getName() { + return "BusDetailPagePlugin"; + } + async initCss() { + if (!window.isDetailPage) return ""; + $("h4:contains('推薦')").hide(); } - return Array.from(n2); - } - __name(gt, "gt"); - function aggregateNewVideoRecords(actresses, carMap, decisions, now = Date.now()) { - const grouped = /* @__PURE__ */ new Map(); - for (const actress of actresses) { - if (!Array.isArray(actress.newVideoList)) continue; - for (const raw of actress.newVideoList) { - const item = "object" == typeof raw ? raw : {}, carNum = normalizeCarNum("string" == typeof raw ? raw : raw.carNum); - if (!carNum) continue; - const existing = grouped.get(carNum) || { carNum, coverUrl: "", title: "", publishTime: "", actresses: [], starIds: [], categories: /* @__PURE__ */ new Set(), score: 0, voteCount: 0, url: "", isVr: false }; - existing.coverUrl || (existing.coverUrl = item.coverUrl || ""), existing.title || (existing.title = item.title || ""), existing.publishTime = [existing.publishTime, item.publishTime || ""].sort().at(-1), existing.score = Math.max(existing.score, Number(item.score) || 0), existing.voteCount = Math.max(existing.voteCount, Number(item.voteCount) || 0), existing.url || (existing.url = item.url || ""); - existing.isVr || (existing.isVr = true === item.isVr || /(^|[^A-Z])VR([^A-Z]|$)/i.test(`${item.title || ""} ${(item.tags || []).join?.(" ") || ""} ${(item.categories || []).join?.(" ") || ""}`)); - actress.name && !existing.actresses.includes(actress.name) && existing.actresses.push(actress.name), actress.starId && !existing.starIds.includes(actress.starId) && existing.starIds.push(actress.starId), actress.actressType && existing.categories.add(actress.actressType), grouped.set(carNum, existing); + async handle() { + if (window.location.href.includes("/star/")) { + const e2 = $(".avatar-box"); + if (e2.length > 0) { + let t2 = e2.parent(); + t2.css("position", "initial"), t2.insertBefore(t2.parent()); + } } + $(".genre a").each((function() { + const e2 = $(this).attr("href"); + e2 && (e2.startsWith("http://") || e2.startsWith("https://") || e2.startsWith("/")) && $(this).attr("target", "_blank"); + })), this.addCopyCarNumBtn(); } - return [...grouped.values()].map(((item) => { - const record = carMap.get(item.carNum), flags = normalizeStateFlags(record?.stateFlags), decision = decisions[item.carNum] || null, decisionState = !decision ? "pending" : "snoozed" === decision.action && decision.until && Date.parse(decision.until) <= now ? "pending" : decision.action; - return { ...item, actressName: item.actresses.join("、"), starId: item.starIds[0] || "", categories: [...item.categories], flags, decision, decisionState }; - })); - } - __name(aggregateNewVideoRecords, "aggregateNewVideoRecords"); - var _NewVideoPlugin = class _NewVideoPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "currentPage", 1), i(this, "pageSize", 30), i(this, "nvCurrentPage", 1), i(this, "nvPageSize", 60), i(this, "nvFlatListCache", []), i(this, "nvAllItemsMap", /* @__PURE__ */ new Map()), i(this, "nvActressesCache", []), i(this, "nvCarMapCache", /* @__PURE__ */ new Map()), i(this, "nvSortBy", "publishTime_desc"), i(this, "nvSelected", /* @__PURE__ */ new Set()), i(this, "nvDecisionsCache", {}), i(this, "nvCoverCache", /* @__PURE__ */ new Map()), i(this, "nvActorCoverRequests", /* @__PURE__ */ new Map()), i(this, "nvRenderGeneration", 0), i(this, "nvSearchDebounced", null), i(this, "nvInvalidationTimer", null), i(this, "nvWorkspaceReloadPromise", null), i(this, "nvWorkspaceReloadDirty", false), i(this, "nvWorkspaceMounted", false), i(this, "nvEventUnsubscribe", null), i(this, "taskStatusUnsubscribe", null), i(this, "nvJavDbUrl", ""), i(this, "nvRuleTime", 8760); + addCopyCarNumBtn() { + let e2 = null; + const t2 = document.querySelectorAll("span.header"); + for (const n2 of t2) if ("識別碼:" === n2.textContent.trim()) { + e2 = n2; + break; + } + if (e2) { + const t3 = e2.nextElementSibling; + if (t3 && "SPAN" === t3.tagName) { + const e3 = t3.textContent.trim(), n2 = document.createElement("button"); + n2.type = "button", n2.className = "jhs-btn jhs-btn--secondary jhs-copy-car-number", n2.textContent = "复制", n2.addEventListener("click", (async function(t4) { + t4.preventDefault(); + await utils.copyToClipboard("番号", e3) && (() => { + this.textContent = "已复制", setTimeout((() => { + this.textContent = "复制"; + }), 1500); + })(); + })), t3.parentNode.insertBefore(n2, t3.nextSibling); + } + } } + }; + __name(_BusDetailPagePlugin, "BusDetailPagePlugin"); + var BusDetailPagePlugin = _BusDetailPagePlugin; + + // src/plugins/status/bus-nav-bar.js + var _BusNavBarPlugin = class _BusNavBarPlugin extends BasePlugin { getName() { - return "NewVideoPlugin"; + return "BusNavBarPlugin"; } - getStartupMode() { - return "idle"; + handle() { + $("#navbar > div > div > span").append('\n \n '), $("#search-img-btn").on("click", (() => { + this.getDependency("SearchByImagePlugin").open(); + })); + } + }; + __name(_BusNavBarPlugin, "BusNavBarPlugin"); + var BusNavBarPlugin = _BusNavBarPlugin; + + // src/plugins/status/compat-enhancements.js + var _CompatibilityEnhancementsPlugin = class _CompatibilityEnhancementsPlugin extends BasePlugin { + getName() { + return "CompatibilityEnhancementsPlugin"; } async initCss() { - return ` - - `; + if (!siteContext.isJavDB) return ""; + return ``; } async handle() { - this.nvEventUnsubscribe || (this.nvEventUnsubscribe = jhsEventBus.on("new-video-changed", (() => this.scheduleWorkspaceReload()))); - this.taskStatusUnsubscribe || (this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.isWorkspaceMounted() && this.renderTaskStatuses()))); - await this.showNewVideoCount(); + await this.decorateActresses(); + $(document).off("actress-state-changed.jhsActress").on("actress-state-changed.jhsActress", (async () => { + $(".jhs-actress-state-container").remove(); + await this.decorateActresses(); + })); + if (isDetailPage) await this.addRemoveRecord(); + this.linkCommentImages(); + } + async addRemoveRecord() { + const carNum = this.getPageInfo().carNum; + if (!carNum) return; + if (!(await storageManager.getCarList()).some(((item) => item.carNum === carNum))) return; + const button = $(''); + $(".jhs-detail-btn-row,.movie-info-container,.container .info").first().append(button); + button.on("click", ((event) => utils.q(event, `确定移除 ${carNum} 的鉴定记录?`, (async () => { + await stateService.remove(carNum); + button.remove(); + this.getDependency("ListPagePlugin")?.showCarNumBox?.(carNum); + show.ok("鉴定记录已移除"); + })))); + } + async decorateActresses() { + const favorites = new Set((await storageManager.getFavoriteActressList()).map(((item) => String(item.starId)))); + const blacklist = new Set((await storageManager.getBlacklist()).map(((item) => String(item.starId || item.id)))); + this.decorateCurrentActressProfile(favorites, blacklist); + this.decorateActressCards(favorites, blacklist); + } + decorateCurrentActressProfile(favorites, blacklist) { + const match = window.location.pathname.match(/^\/(?:actors|star)\/([^/?#]+)\/?$/); + if (!match) return; + const host = $(".actor-section-name,.star-name,h1.title").first(); + if (!host.length) return; + this.renderActressState(host, decodeURIComponent(match[1]), favorites, blacklist, "jhs-actress-profile-state"); + } + decorateActressCards(favorites, blacklist) { + $(".actor-box a[href], .actress-card a[href], [data-actress-card] a[href]").each(((index, element) => { + const identity = getActressIdentityFromLink(element); + if (!identity) return; + const card = $(element).closest(".actor-box,.actress-card,[data-actress-card]"); + this.renderActressState(card, identity.starId, favorites, blacklist, "jhs-actress-card-state"); + })); + } + renderActressState(host, starId, favorites, blacklist, className) { + if (host.children(".jhs-actress-state-container").length) return; + const container = $(``); + favorites.has(starId) && container.append('已关注'); + blacklist.has(starId) && container.append('已拉黑'); + container.children().length && host.append(container); + } + linkCommentImages() { + const images = $(".preview-images img,#sample-waterfall img,.movie-gallery img"); + if (!images.length) return; + $(".review-content").each(((index, element) => this.linkCommentImageTextNodes(element, images.length))); + $(document).off("click.jhsCommentImage", ".jhs-comment-image-link").on("click.jhsCommentImage", ".jhs-comment-image-link", ((event) => { + event.preventDefault(); + const image = images.eq(Number($(event.currentTarget).data("image-index"))); + image.length && showImageViewer(image[0]); + })); + } + linkCommentImageTextNodes(element, imageCount) { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT), nodes = []; + while (walker.nextNode()) if (!$(walker.currentNode.parentElement).closest("a,button,code,pre,textarea,.jhs-comment-image-link").length && /(?:图|圖片|图片)\s*[一二三四五六七八九十\d]+/i.test(walker.currentNode.nodeValue || "")) nodes.push(walker.currentNode); + nodes.forEach(((textNode) => { + const text = textNode.nodeValue || "", pattern = /(?:图|圖片|图片)\s*([一二三四五六七八九十\d]+)/gi, fragment = document.createDocumentFragment(); + let cursor = 0, match; + while (match = pattern.exec(text)) { + match.index > cursor && fragment.append(document.createTextNode(text.slice(cursor, match.index))); + const chinese = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }, index = (chinese[match[1]] || Number(match[1])) - 1; + if (index >= 0 && index < imageCount) { + const link = document.createElement("a"); + link.href = "#"; + link.className = "jhs-comment-image-link"; + link.dataset.imageIndex = String(index); + link.textContent = match[0]; + fragment.append(link); + } else fragment.append(document.createTextNode(match[0])); + cursor = match.index + match[0].length; + } + cursor < text.length && fragment.append(document.createTextNode(text.slice(cursor))); + textNode.replaceWith(fragment); + })); + } + }; + __name(_CompatibilityEnhancementsPlugin, "CompatibilityEnhancementsPlugin"); + var CompatibilityEnhancementsPlugin = _CompatibilityEnhancementsPlugin; + function getActressIdentityFromLink(element) { + const link = $(element); + if (link.closest('.toolbar,.tabs,.buttons,.pagination,.filter,.filters,nav,header,[role="tablist"]').length) return null; + const url = new URL(link.attr("href"), window.location.href); + if (url.search || url.hash) return null; + const match = url.pathname.match(/^\/(?:actors|star)\/([^/]+)\/?$/); + return match ? { starId: decodeURIComponent(match[1]), url: url.href } : null; + } + __name(getActressIdentityFromLink, "getActressIdentityFromLink"); + + // src/plugins/status/detail-page-button.js + var _DetailPageButtonPlugin = class _DetailPageButtonPlugin extends BasePlugin { + getName() { + return "DetailPageButtonPlugin"; } - isWorkspaceMounted() { - return this.nvWorkspaceMounted && $(".newVideoToolBox").length > 0; + constructor() { + super(), this.answerCount = 1, this.stateBinding = null; } - scheduleWorkspaceReload() { - this.nvWorkspaceReloadDirty = true; - this.nvInvalidationTimer || (this.nvInvalidationTimer = setTimeout((() => { - this.nvInvalidationTimer = null, void this.flushWorkspaceReload(); - }), 0)); + async handle() { + this.hideVideoControls(), window.isDetailPage && (await this.createMenuBtn(), await this.autoRemoveNewVideoMark()); } - async flushWorkspaceReload() { - if (this.nvWorkspaceReloadPromise) return this.nvWorkspaceReloadPromise; - return this.nvWorkspaceReloadPromise = (async () => { - do { - this.nvWorkspaceReloadDirty = false, await this.showNewVideoCount(); - this.isWorkspaceMounted() && await this.reloadNewVideoWorkspaceData({ preservePage: true }); - } while (this.nvWorkspaceReloadDirty); - })().catch(((error) => clog.error("新作品数据刷新失败", error))).finally((() => { - this.nvWorkspaceReloadPromise = null; - })), this.nvWorkspaceReloadPromise; + async autoRemoveNewVideoMark() { + try { + const e2 = await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse", C); + if (e2 !== _) return; + const t2 = this.getPageInfo(); + if (!t2.carNum) return; + await stateService.removeFromNewVideoList([t2.carNum], "browse"); + } catch (e2) { + clog.error("自动移除新作品标记失败:", e2); + } } - getPendingNewVideoCount(e2, t2) { - return Array.isArray(e2?.newVideoList) ? new Set(e2.newVideoList.map(((item) => normalizeCarNum("string" == typeof item ? item : item.carNum))).filter(((carNum) => carNum && !t2.has(carNum) && !this.isDecisionHidden(carNum)))).size : 0; + async createMenuBtn() { + const e2 = this.getPageInfo(), t2 = e2.carNum, n2 = ` +
    +
    + + + + +
    + +
    + + + + +
    +
    + `; + const workspaceSlot = this.getDependency("DetailWorkspacePlugin")?.getSlot("summary-actions"); + workspaceSlot?.length ? workspaceSlot.append(n2) : r ? $(".tabs").after(n2) : l && $("#mag-submit-show").before(n2), $("#magnetSearchBtn").on("click", (async () => { + let t3 = await this.getDependency("MagnetHubPlugin").createMagnetHub(e2.carNum); + layer.open({ + type: 1, + title: "磁力搜索 " + e2.carNum, + content: '
    ', + area: utils.getResponsiveArea(["60%", "80%"]), + scrollbar: false, + success: /* @__PURE__ */ __name(() => { + $("#magnetHubBox").append(t3); + }, "success") + }); + })); + const a2 = this.getDependency("HighlightMagnetPlugin"), i2 = await storageManager.getSetting("enableMagnetsFilter", _); + $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2.doFilterMagnet(), $("#enable-magnets-filter").on("click", ((e3) => { + let t3 = $("#magnets-span"); + "关闭磁力过滤" === t3.text() ? (a2.showAll(), t3.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a2.doFilterMagnet(), t3.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); + })), $("#search-subtitle-btn").on("click", ((e3) => utils.openPage(`https://subtitlecat.com/index.php?search=${t2}`, t2, false, e3))), $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(t2))); + if (!t2) { + $("#filterBtn, #favoriteBtn, #hasDownBtn, #hasWatchBtn, #magnetSearchBtn, #xunLeiSubtitleBtn, #search-subtitle-btn").prop("disabled", true).attr("title", "番号不可用"); + return void clog.warn("详情操作不可用:番号不可用"); + } + this.stateBinding = detailStateController.bind({ root: document, carNum: t2, activityType: "detail-state", getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord") }); } - isDecisionHidden(carNum) { - const decision = this.nvDecisionsCache[normalizeCarNum(carNum)]; - if (!decision) return false; - return "ignored" === decision.action || "snoozed" === decision.action && (!decision.until || Date.parse(decision.until) > Date.now()); + async showStatus(e2) { + return detailStateController.render({ root: document, carNum: e2 }); } - async getPendingNewVideoTotal() { - const e2 = await storageManager.getCarMap(), keys = /* @__PURE__ */ new Set(); - this.nvDecisionsCache = await stateService.getNewVideoDecisions(); - (await storageManager.getFavoriteActressList()).forEach(((actress) => Array.isArray(actress.newVideoList) && actress.newVideoList.forEach(((item) => { - const carNum = normalizeCarNum("string" == typeof item ? item : item.carNum); - carNum && !e2.has(carNum) && !this.isDecisionHidden(carNum) && keys.add(carNum); - })))); - return keys.size; + getStateRecord() { + const info = this.getPageInfo(); + return { carNum: info.carNum, url: info.url, names: info.actress, publishTime: info.publishTime }; } - async showNewVideoCount() { - const e2 = await this.getPendingNewVideoTotal(); - $("#newVideoCount").text(`${e2}`); + getStateBinding() { + if (this.stateBinding) return this.stateBinding; + const info = this.getPageInfo(); + return this.stateBinding = { root: document, layerIndex: null, carNum: normalizeCarNum(info.carNum), getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord"), activityType: "detail-state", selectors: {} }; } - async resetBtnTip() { - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = localStorage.getItem(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = localStorage.getItem(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; - $("#checkFavoriteActress").attr("data-tip", `上次完整同步: ${n2}; 检测间隔时间: ${a2}小时`), $("#checkNewVideo").attr("data-tip", `上次整批检测: ${i2}; 检测间隔时间: ${s2}小时`); + async favoriteOne(event) { + return detailStateController.requestToggle(this.getStateBinding(), "favorite", event); } - async openDialog() { - this.cleanupNewVideoWorkspace(), this._viewMode = "list" === localStorage.getItem("jhs_newVideoViewMode") ? "list" : "actress", this.currentPage = 1, this.nvCurrentPage = 1, this.nvSelected = /* @__PURE__ */ new Set(), this.nvCoverCache = /* @__PURE__ */ new Map(), this.nvActorCoverRequests = /* @__PURE__ */ new Map(), this.nvRenderGeneration++; - const e2 = this.getBean("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = localStorage.getItem(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = localStorage.getItem(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; - let o2 = ` -
    - -
    -
    -
    - -
    -
    `; - layer.open({ - type: 1, - title: '新作品检测', - content: o2, - scrollbar: false, - area: utils.getDialogArea("workspace"), - anim: -1, - success: /* @__PURE__ */ __name(async (e3, t3) => { - this.nvWorkspaceMounted = true, JhsSelect.enhance(e3), this.bindClick(), this.applyViewMode(), this.renderTaskStatuses(), await this.reloadNewVideoWorkspaceData(), utils.setupEscClose(t3); - }, "success"), - end: /* @__PURE__ */ __name(() => this.cleanupNewVideoWorkspace(), "end") - }); + async hasDownOne(event) { + return detailStateController.requestToggle(this.getStateBinding(), "downloaded", event); } - cleanupNewVideoWorkspace() { - this.nvSearchDebounced?.cancel?.(), this.nvSearchDebounced = null, this.nvWorkspaceMounted = false, this.nvRenderGeneration++, this.nvSelected.clear(), this.nvCoverCache = /* @__PURE__ */ new Map(), this.nvActorCoverRequests = /* @__PURE__ */ new Map(), this.nvAllItemsMap.clear(), this.nvFlatListCache = [], this.nvActressesCache = [], this.nvCarMapCache = /* @__PURE__ */ new Map(), this.nvDecisionsCache = {}, this.nvCurrentPageItems = []; + async hasWatchOne(event) { + return detailStateController.requestToggle(this.getStateBinding(), "watched", event); } - bindClick() { - const taskPlugin = this.getBean("TaskPlugin"); - $("#reLoad").on("click", (() => { - void this.reloadNewVideoWorkspaceData(), $("#checkNewVideoMsg").text(""); - })), $("#new-video-list-container").on("click", ".nv-card__link", (async (e2) => { - const t2 = $(e2.currentTarget).closest(".nv-card").attr("data-car"); - if (!t2) return; - try { - const enabled = await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse", C); - if (enabled !== _) return; - await stateService.removeFromNewVideoList([t2], "browse"); - } catch (n2) { - clog.error("移除新作品标记失败:", n2); - } - })), $("#toSetting").on("click", ((e2) => { - this.getBean("SettingPlugin").openSettingDialog("task-panel", (() => { - $("#setting-checkFavoriteActress").css({ - border: "1px solid var(--jhs-status-filter)" - }), $("#setting-checkNewVideo").css({ - border: "1px solid var(--jhs-status-filter)" - }); - })); + searchXunLeiSubtitle(e2) { + let t2 = loading(); + gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e2}`).then(((t3) => { + let n2 = t3.data; + n2 && 0 !== n2.length ? layer.open({ + type: 1, + title: "迅雷字幕", + content: '\n
    \n
    \n
    \n ', + scrollbar: false, + area: utils.getResponsiveArea(["60%", "70%"]), + anim: -1, + success: /* @__PURE__ */ __name((t4, a2) => { + new Tabulator("#xunlei-table-container", { + layout: "fitColumns", + placeholder: "暂无数据", + virtualDom: true, + data: n2, + responsiveLayout: "collapse", + responsiveLayoutCollapse: true, + columnDefaults: { + headerHozAlign: "center", + hozAlign: "center" + }, + columns: [{ + title: "文件名", + field: "name", + headerSort: false, + responsive: 0 + }, { + title: "类型", + field: "ext", + headerSort: false, + responsive: 0 + }, { + title: "操作", + responsive: 0, + headerSort: false, + formatter: /* @__PURE__ */ __name((t5, n3, a3) => { + const i2 = t5.getData(); + return a3((() => { + const n4 = t5.getElement().querySelector(".subtitle-preview-btn"), a4 = t5.getElement().querySelector(".subtitle-download-btn"); + n4 && n4.addEventListener("click", (async (t6) => { + let n5 = i2.url, a5 = e2 + "." + i2.ext; + this.previewSubtitle(n5, a5); + })), a4 && a4.addEventListener("click", (async (t6) => { + let n5 = i2.url, a5 = e2 + "." + i2.ext, s2 = await gmHttp.get(n5); + utils.download(s2, a5); + })); + })), '\n \n \n '; + }, "formatter") + }], + locale: "zh-cn", + langs: { + "zh-cn": { + pagination: { + first: "首页", + first_title: "首页", + last: "尾页", + last_title: "尾页", + prev: "上一页", + prev_title: "上一页", + next: "下一页", + next_title: "下一页", + all: "所有", + page_size: "每页行数" + } + } + } + }), utils.setupEscClose(a2); + }, "success") + }) : show.error("迅雷中找不到相关字幕!"); + })).catch(((e3) => { + clog.error(e3), show.error(e3); + })).finally((() => { + t2.close(); })); - $("#checkFavoriteActress").on("click", ((event) => { - void this.runManualTask($(event.currentTarget), "同步中…", (async () => { - if (!$('a[href*="/users/profile"]').length) return void show.error("未登录 JavDB,同步失败"); - await taskPlugin.checkFavoriteActress(true); - })); - })), $("#checkNewVideo").on("click", ((event) => { - void this.runManualTask($(event.currentTarget), "检测中…", (() => taskPlugin.checkNewVideo(true))); - })), $("#paramActressType").on("change", ((e2) => { - this.currentPage = 1, this.nvRenderGeneration++, "actress" === this._viewMode && this.renderActressCards(); - })), $("#paramSortBy").on("change", ((e2) => { - this.currentPage = 1, this.nvRenderGeneration++, "actress" === this._viewMode && this.renderActressCards(); - })), $("#nvSortBy").on("change", ((e2) => { - this.nvSortBy = $("#nvSortBy").val(), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); - })), $("#nvCategoryFilter,#nvStateFilter,#nvDecisionFilter").on("change", ((e2) => { - this.nvCurrentPage = 1, this.nvRenderGeneration++, "list" === this._viewMode && this.renderNewVideoList(); - })), this.nvSearchDebounced = utils.debounce((() => { - this.nvCurrentPage = 1, this.nvRenderGeneration++, "list" === this._viewMode && this.renderNewVideoList(); - }), 200), $("#nvSearch").on("input", this.nvSearchDebounced), $(".jhs-new-video-view").on("click", '[role="tab"]', ((event) => { - this.setViewMode($(event.currentTarget).data("view")); - })).on("keydown", '[role="tab"]', ((event) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const tabs = $(event.delegateTarget).find('[role="tab"]'), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; - tabs.eq(next).trigger("click").trigger("focus"); - })), this.bindBatchActions(); - } - async runManualTask(button, busyLabel, runner) { - if (button.attr("aria-busy") === "true") return; - const label = button.find("span").last(), previous = label.text(); - button.attr("aria-busy", "true").prop("disabled", true), label.text(busyLabel); - try { - await navigator.locks.request(this.getBean("TaskPlugin").singleTaskKey, { ifAvailable: true }, (async (lock) => { - if (!lock) return void show.error("后台任务正在运行,请稍后再试"); - await runner(); - })); - } catch (error) { - clog.error("手动任务执行失败", error), this.getBean("TaskPlugin").isNetworkBlocked(error) && show.error(error.message || "任务执行失败"); - } finally { - button.removeAttr("aria-busy").prop("disabled", false), label.text(previous), this.renderTaskStatuses(); - } } - setViewMode(mode) { - if (!["actress", "list"].includes(mode) || mode === this._viewMode) return; - this._viewMode = mode, localStorage.setItem("jhs_newVideoViewMode", mode), "list" === mode ? this.nvCurrentPage = 1 : this.currentPage = 1, this.nvRenderGeneration++, this.applyViewMode(), this.renderCurrentView(); + async filterOne(e2, t2) { + e2 && e2.preventDefault(); + return detailStateController.requestToggle(this.getStateBinding(), "blocked", e2); } - applyViewMode() { - const list = "list" === this._viewMode; - $("#actress-card-container").toggle(!list), $("#actress-pagination").toggle(!list), $("#new-video-list-container").toggle(list), $("#new-video-list-footer").toggle(list), JhsSelect.setVisible("#paramSortBy", !list), JhsSelect.setVisible("#nvSortBy", list), JhsSelect.setVisible("#paramActressType", !list), JhsSelect.setVisible("#nvCategoryFilter", list), JhsSelect.setVisible("#nvStateFilter", list), JhsSelect.setVisible("#nvDecisionFilter", list), $("#nvSearch").toggleClass("jhs-is-hidden", !list), $(".jhs-new-video-view [role='tab']").each(((index, tab) => { - const active = $(tab).data("view") === this._viewMode; - $(tab).attr({ "aria-selected": String(active), tabindex: active ? "0" : "-1" }).toggleClass("active", active); + hideVideoControls() { + $(document).on("mouseenter", "#preview-video", (function() { + $(this).prop("controls", true); })); } - renderCurrentView() { - return "list" === this._viewMode ? this.renderNewVideoList() : this.renderActressCards(); + async previewSubtitle(e2, t2) { + if (!e2) return void clog.error("未提供文件URL"); + const n2 = e2.split(".").pop().toLowerCase(); + if ("ass" === n2 || "srt" === n2) try { + let a2 = await gmHttp.get(e2), i2 = "字幕预览"; + "ass" === n2 ? i2 = "ASS字幕预览 - " + t2 : "srt" === n2 && (i2 = "SRT字幕预览 - " + t2); + const s2 = a2.split("\n"); + let o2 = ""; + const r2 = String(s2.length).length; + s2.forEach(((e3, t3) => { + const n3 = String(t3 + 1).padStart(r2, " "); + o2 += `${n3}. ${e3} +`; + })); + const l2 = o2; + layer.open({ + type: 1, + title: i2, + area: utils.getResponsiveArea(["80%", "80%"]), + scrollbar: false, + content: `
    ${l2}
    `, + btn: ["下载", "关闭"], + btn1: /* @__PURE__ */ __name(function(e3, n3, i3) { + return utils.download(a2, t2), false; + }, "btn1") + }); + } catch (a2) { + show.error(`预览失败: ${a2.message}`), clog.error("预览字幕文件出错:", a2); + } + else show.error("仅支持预览ASS和SRT字幕文件"); } - loadData() { - this.currentPage = 1; - return this.reloadNewVideoWorkspaceData(); + }; + __name(_DetailPageButtonPlugin, "DetailPageButtonPlugin"); + var DetailPageButtonPlugin = _DetailPageButtonPlugin; + + // src/plugins/status/detail-page.js + var _DetailPagePlugin = class _DetailPagePlugin extends BasePlugin { + getName() { + return "DetailPagePlugin"; } - async reloadNewVideoWorkspaceData({ preservePage = false } = {}) { - if (!this.isWorkspaceMounted()) return; - const generation = ++this.nvRenderGeneration, container = "list" === this._viewMode ? $("#new-video-list-container") : $("#actress-card-container"); - renderStateView(container, { type: "loading", title: "加载中" }); - try { - const [actresses, carMap, decisions, javDbUrl, ruleTime] = await Promise.all([storageManager.getFavoriteActressList(), storageManager.getCarMap(), stateService.getNewVideoDecisions(), this.getBean("OtherSitePlugin").getJavDbUrl(), storageManager.getSetting("checkNewVideo_ruleTime", 8760)]); - if (!this.isWorkspaceMounted() || generation !== this.nvRenderGeneration) return; - this.nvActressesCache = actresses, this.nvCarMapCache = carMap, this.nvDecisionsCache = decisions, this.nvJavDbUrl = javDbUrl, this.nvRuleTime = parseNumberSetting(ruleTime, 8760, { min: 0 }); - const items = aggregateNewVideoRecords(actresses, carMap, decisions), nextMap = /* @__PURE__ */ new Map(); - items.forEach(((item) => nextMap.set(normalizeCarNum(item.carNum), { ...item, carNum: normalizeCarNum(item.carNum) }))), this.nvAllItemsMap = nextMap; - for (const carNum of [...this.nvSelected]) nextMap.has(carNum) || this.nvSelected.delete(carNum); - preservePage || (this.currentPage = 1, this.nvCurrentPage = 1), await this.renderCurrentView(), this.renderTaskStatuses(); - } catch (error) { - if (generation !== this.nvRenderGeneration || !this.isWorkspaceMounted()) return; - clog.error("加载新作品工作区失败", error), renderStateView(container, { type: "error", title: "加载失败", description: error.message || String(error), actionLabel: "重试", onAction: /* @__PURE__ */ __name(() => this.reloadNewVideoWorkspaceData({ preservePage: true }), "onAction") }); - } + constructor() { + super(); } - renderTaskStatuses() { - const container = $("#jhs-task-status-list"); - if (!container.length) return; - const taskPlugin = this.getBean("TaskPlugin"), names = { favoriteActress: "演员同步", newVideo: "新作品", blacklist: "黑名单" }, labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); - container.empty(), ["favoriteActress", "newVideo", "blacklist"].forEach(((name) => { - const snapshot = taskPlugin.getTaskStatusSnapshot(name), item = $('
    '); - item.append($('').text(`${names[name]}:${labels[snapshot.state]}`)), item.append($('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)), container.append(item); + handle() { + window.isDetailPage && $(".video-meta-panel a").each((function() { + const e2 = $(this).attr("href"); + e2 && (e2.startsWith("http://") || e2.startsWith("https://") || e2.startsWith("/")) && $(this).attr("target", "_blank"); })); } - async renderActressCards() { - const e2 = $("#actress-card-container"); - if (!e2.length) return; - let t2 = [...this.nvActressesCache]; - const n2 = $("#paramActressType").val(); - "all" !== n2 && (t2 = t2.filter(((e3) => e3.actressType === n2))); - const _carSet = this.nvCarMapCache; - const _newVideoCount = /* @__PURE__ */ __name((e3) => this.getPendingNewVideoCount(e3, _carSet), "_newVideoCount"); - const sortBy = $("#paramSortBy").val(); - const sortMap = { - "lastPublishTime_desc": [{ key: "lastPublishTime", order: "desc" }], - "lastPublishTime_asc": [{ key: "lastPublishTime", order: "asc" }], - "lastCheckTime_desc": [{ key: "lastCheckTime", order: "desc" }], - "lastCheckTime_asc": [{ key: "lastCheckTime", order: "asc" }], - "newVideoCount_desc": [{ key: _newVideoCount, order: "desc" }], - "newVideoCount_asc": [{ key: _newVideoCount, order: "asc" }] - }; - const defaultSort = [{ - key: _newVideoCount, - order: "desc" - }, { - key: "lastPublishTime", - order: "desc" - }]; - const sortedActresses = utils.genericSort(t2, sortMap[sortBy] || defaultSort); - const totalCount = sortedActresses.length, totalPages = Math.ceil(totalCount / this.pageSize), pageStart = (this.currentPage - 1) * this.pageSize, pageEnd = pageStart + this.pageSize; - totalPages > 0 && this.currentPage > totalPages && (this.currentPage = totalPages); - const safePageStart = (this.currentPage - 1) * this.pageSize, pageActresses = sortedActresses.slice(safePageStart, safePageStart + this.pageSize), javDbUrl = this.nvJavDbUrl, taskPlugin = this.getBean("TaskPlugin"), ruleTime = this.nvRuleTime; - if (0 === pageActresses.length) { - renderStateView(e2, { type: "empty", title: this.nvActressesCache.length ? "没有符合当前筛选条件的演员" : "暂无收藏演员" }); - return void this.renderPagination(totalCount, totalPages); - } - const cards = pageActresses.map(((actress) => { - const allNames = Array.isArray(actress.allName) ? actress.allName.join(",") : ""; - const name = String(actress.name || ""), remark = String(actress.remark || ""), starId = String(actress.starId || ""); - const newVideoCount = this.getPendingNewVideoCount(actress, _carSet), latestPublishTime = actress.lastPublishTime || ""; - const profileUrl = normalizeHttpUrl(`/actors/${encodeURIComponent(starId)}?t=d`, javDbUrl), avatarUrl = normalizeHttpUrl(actress.avatar, javDbUrl) || "https://c0.jdbstatic.com/images/actor_unknow.jpg"; - const isPaused = shouldSkipStopped(latestPublishTime, ruleTime); - let typeLabel = "未知", typeClass = "is-unknown"; - actress.actressType === A ? (typeLabel = "无码", typeClass = "is-uncensored") : actress.actressType === D && (typeLabel = "有码", typeClass = "is-censored"); - const publishText = String(latestPublishTime || "暂无记录"), noteText = isPaused ? `停更 ${ruleTime / 24 / 365} 年以上,下轮任务不再检测` : remark || "暂无备注"; - const card = $('
    ').toggleClass("is-paused", isPaused).attr("data-starId", starId), badges = $('
    '); - badges.append($('').attr("data-tip", `最新作品数量: ${newVideoCount}`).text(`${newVideoCount} 新`), $('').addClass(typeClass).text(typeLabel)), isPaused && badges.append('停更'); - const profile = $('').attr("href", profileUrl || "#"), identity = $("").append($('').text(name), $('').attr("title", allNames).text(allNames || "暂无别名")); - profile.append($('').attr({ src: avatarUrl, alt: allNames }), identity); - const meta = $('
    ').append($('
    最近作品
    ').append($("
    ").attr("title", publishText).text(publishText)), $('
    上次检测
    ').append($("
    ").text(String(actress.lastCheckTime || "暂无记录")))), actions = $('
    '); - const check = $('').attr("data-starId", starId).prepend(this.checkSvg), remove = $('').attr({ "aria-label": `取消收藏 ${name}`, "data-starId": starId }).append(this.deleteSvg), edit = $('').attr("data-starId", starId).prepend(this.editSvg); - actions.append(check, remove, $('
    •••
    ').find(".actress-card__menu-popover").append(edit).end()); - return card.append(badges, profile, meta, $('

    ').attr("title", noteText).text(noteText), actions)[0]; - })); - e2.empty().append(cards), $(".btn-delete-actress").off("click").on("click", ((e3) => { - e3.preventDefault(); - const t3 = $(e3.currentTarget).attr("data-starId"), n3 = sortedActresses.find(((e4) => e4.starId === t3)); - utils.q(e3, `是否取消收藏 ${n3.name}?`, (async () => { - let e4 = `${await this.getBean("OtherSitePlugin").getJavDbUrl()}/actors/${t3}/uncollect`; - const n4 = document.querySelector("meta[name=csrf-token]").content, a2 = await gmHttp.post(e4, null, { - "x-csrf-token": n4 - }); - a2.includes("removeClass") ? (await storageManager.removeFavoriteActress(t3), await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-removed" })) : (show.error("移除失败"), clog.error("移除失败,返回值:", a2)); - })); - })), $(".btn-edit-actress").off("click").on("click", ((e3) => { - e3.preventDefault(); - const t3 = $(e3.currentTarget).attr("data-starId"), n3 = sortedActresses.find(((e4) => e4.starId === t3)); - n3 ? this.editActress(n3) : show.error(`未找到 starId 为 ${t3} 的女优记录。`); - })), $(".btn-check-actress").off("click").on("click", ((e3) => { - e3.preventDefault(); - const button = $(e3.currentTarget), starId = button.attr("data-starId"), actress = sortedActresses.find(((item) => item.starId === starId)); - void this.runManualTask(button, "检测中…", (() => taskPlugin.checkOneNewVideo(actress))); - })), $(".actress-card__menu").on("keydown", ((event) => { - if ("Escape" !== event.key) return; - event.preventDefault(); - const details = $(event.currentTarget); - details.prop("open", false).find("summary").trigger("focus"); - })).on("click", "[role='menuitem']", ((event) => { - $(event.currentTarget).closest("details").prop("open", false); - })), this.renderPagination(totalCount, totalPages); + }; + __name(_DetailPagePlugin, "DetailPagePlugin"); + var DetailPagePlugin = _DetailPagePlugin; + + // src/plugins/status/fold-category.js + var _FoldCategoryPlugin = class _FoldCategoryPlugin extends BasePlugin { + getName() { + return "FoldCategoryPlugin"; } - async getNewVideoFlatList() { - const category = $("#nvCategoryFilter").val() || "all", stateFilter = $("#nvStateFilter").val() || "pending", decisionFilter = $("#nvDecisionFilter").val() || "pending", query = String($("#nvSearch").val() || "").trim().toUpperCase(); - return [...this.nvAllItemsMap.values()].filter(((item) => { - const categoryMatch = "all" === category || "vr" === category ? "all" === category || item.isVr : "unknown" === category ? 0 === item.categories.length : item.categories.includes(category); - const stateMatch = "all" === stateFilter || "pending" === stateFilter ? "all" === stateFilter || !hasAnyState(item.flags) : !!item.flags[stateFilter]; - const decisionMatch = "all" === decisionFilter || item.decisionState === decisionFilter; - const searchMatch = !query || `${item.carNum} ${item.title} ${item.actressName}`.toUpperCase().includes(query); - return categoryMatch && stateMatch && decisionMatch && searchMatch; - })).sort(((left, right) => (right.publishTime || "").localeCompare(left.publishTime || ""))); + async initCss() { + const e2 = await storageManager.getSetting(); + return ` + + `; + } + async handle() { + window.isListPage && (o.includes("advanced_search") || (this.highlightTag(), utils.loopDetector((() => $("#waitCheckBtn").length), (() => { + this.createFoldBtn(); + }), 1, 1e4, true), $("#tags .tag-category .tag-expand").each(((e2, t2) => { + $(t2).parent().hasClass("collapse") && t2.click(); + })))); } - getActorCoverRequest(starId, requestMap) { - const existing = requestMap.get(starId); - if (existing) return existing; - const request = gmHttp.get(`${this.nvJavDbUrl}/actors/${starId}?t=d`).then(((html) => { - const page = utils.htmlTo$dom(html), covers = /* @__PURE__ */ new Map(); - page.find(".movie-list .item").each(((index, element) => { - const item = $(element), rawCarNum = item.find(".video-title strong").text().trim(), carNum = normalizeCarNum(rawCarNum), rawCover = item.find("img").attr("src") || ""; - if (!carNum || !rawCover) return; - const coverUrl = new URL(rawCover, this.nvJavDbUrl).href.replace("thumbs", "covers"), title = item.find(".video-title").text().replace(rawCarNum, "").trim(); - covers.set(carNum, { coverUrl, title }); + highlightTag() { + (async () => { + const e2 = await storageManager.getHighlightedTags(); + e2 && e2.forEach(((e3) => { + $(`#tags a.tag:contains(${e3})`).addClass("highlighted"), $(`.tags a.tag:contains(${e3})`).addClass("highlighted"); })); - return covers; - })).finally((() => { - requestMap.get(starId) === request && requestMap.delete(starId); + })().catch(((error) => clog.error("分类高亮恢复失败", error))), $("#tags a.tag, .tags a.tag").hover((function() { + const e2 = $(this), t2 = $(''); + e2.append(t2), t2.fadeIn(0); + }), (function() { + $(this).find(".highlight-btn").fadeOut(0, (function() { + $(this).remove(); + })); + })), $(document).on("click", ".highlight-btn", (async function(e2) { + e2.stopPropagation(), e2.preventDefault(); + const t2 = $(this).closest("a.tag"), n2 = t2.clone(); + n2.find(".highlight-btn").remove(); + const a2 = n2.text().trim().replace(/\s*\(\d+\)$/, ""); + let i2 = await storageManager.getHighlightedTags(); + i2.includes(a2) ? (i2 = i2.filter(((e3) => e3 !== a2)), t2.removeClass("highlighted")) : (i2.push(a2), t2.addClass("highlighted")), await storageManager.setHighlightedTags(i2); })); - return requestMap.set(starId, request), request; } - async loadCoverForItems(items, generation = this.nvRenderGeneration) { - return this.hydrateVisibleCovers(items, generation); + async createFoldBtn() { + let t2 = $("#tags"), n2 = $("#tags dl div.tag.is-info").map((function() { + return $(this).text().replaceAll("\n", "").replaceAll(" ", ""); + })).get().join(" "); + if (!n2) return; + $(".tabs").append(` +
    +
    已选分类: ${n2}
    + + +
    + `); + let a2 = $("h2.section-title"); + if (a2.length > 0 && (a2.append('\n
    \n \n
    \n '), t2 = $("section > div > div.box")), !t2) return; + let i2 = $("#foldCategoryBtn"), s2 = localStorage.getItem("jhs_foldCategory") === _, [o2, r2] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; + i2.find("span").text(o2).end().find("i").attr("class", r2), window.location.href.includes("noFold=1") || t2[s2 ? "hide" : "show"](), i2.on("click", (async (e2) => { + e2.preventDefault(), s2 = !s2, localStorage.setItem("jhs_foldCategory", s2 ? _ : C); + const [n3, a3] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; + i2.find("span").text(n3).end().find("i").attr("class", a3), t2[s2 ? "hide" : "show"](); + })); } - async hydrateVisibleCovers(items, generation) { - const coverCache = this.nvCoverCache, requestMap = this.nvActorCoverRequests, starIds = [...new Set(items.filter(((item) => !item.coverUrl && !coverCache.has(normalizeCarNum(item.carNum)) && item.starId)).map(((item) => item.starId)))]; - await mapLimit(starIds, 3, (async (starId) => { - try { - const covers = await this.getActorCoverRequest(starId, requestMap); - if (generation !== this.nvRenderGeneration || coverCache !== this.nvCoverCache || !this.isWorkspaceMounted()) return; - covers.forEach(((value, carNum) => { - coverCache.set(normalizeCarNum(carNum), value.coverUrl); - const card = $(".nv-card").filter(((_2, element) => normalizeCarNum($(element).attr("data-car")) === normalizeCarNum(carNum))); - if (!card.length) return; - const image = $('').attr({ src: value.coverUrl, "data-full": value.coverUrl }).on("error", (function() { - $(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden"); - })); - card.find(".nv-placeholder").replaceWith(image), card.find(".nv-card__empty").addClass("jhs-is-hidden"), value.title && card.attr("title", value.title); - })); - window.imageHoverPreviewObj?.bindEvents?.(); - } catch (error) { - clog.warn(`获取演员封面失败: ${starId}`, error); - } + }; + __name(_FoldCategoryPlugin, "FoldCategoryPlugin"); + var FoldCategoryPlugin = _FoldCategoryPlugin; + + // src/plugins/status/highlight-magnet.js + var _HighlightMagnetPlugin = class _HighlightMagnetPlugin extends BasePlugin { + async handle() { + window.isDetailPage && jhsEventBus.on("magnet-items-updated", (() => { + void storageManager.getSetting("enableMagnetsFilter", _).then(((enabled) => enabled === _ ? this.doFilterMagnet() : this.showAll())); })); } - async renderNewVideoList() { - const container = $("#new-video-list-container"), generation = this.nvRenderGeneration; - if (!container.length) return; - const items = await this.getNewVideoFlatList(); - if (generation !== this.nvRenderGeneration || !this.isWorkspaceMounted()) return; - this.nvFlatListCache = items, this.nvSortBy = $("#nvSortBy").val() || this.nvSortBy; - const totalPages = Math.ceil(items.length / this.nvPageSize); - this.nvCurrentPage = totalPages ? Math.min(this.nvCurrentPage, totalPages) : 1; - if (!items.length) { - const query = String($("#nvSearch").val() || "").trim(), filtered = ["nvCategoryFilter", "nvStateFilter", "nvDecisionFilter"].some(((id) => $("#" + id).val() !== ("nvCategoryFilter" === id ? "all" : "pending"))); - if (!this.nvAllItemsMap.size) renderStateView(container, { type: "empty", title: "暂无新作品记录" }); - else if (query) renderStateView(container, { type: "empty", title: `没有找到“${query}”的作品`, actionLabel: "清除搜索", onAction: /* @__PURE__ */ __name(() => { - $("#nvSearch").val(""), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); - }, "onAction") }); - else if (filtered) renderStateView(container, { type: "empty", title: "没有符合当前筛选条件的作品", actionLabel: "清除筛选", onAction: /* @__PURE__ */ __name(() => { - JhsSelect.setValue("#nvCategoryFilter", "all", false), JhsSelect.setValue("#nvStateFilter", "pending", false), JhsSelect.setValue("#nvDecisionFilter", "pending", false), this.nvCurrentPage = 1, this.nvRenderGeneration++, this.renderNewVideoList(); - }, "onAction") }); - else renderStateView(container, { type: "empty", title: "暂无待处理的新作品" }); - return void this.renderBatchBar(); - } - this.nvRenderPage(generation), this.renderBatchBar(); + async initCss() { + return ``; } - selectedItems() { - return [...this.nvSelected].map(((carNum) => this.nvAllItemsMap.get(normalizeCarNum(carNum)))).filter(Boolean); + getName() { + return "HighlightMagnetPlugin"; } - applyProcessedSelection(result) { - (result?.changed || []).map(normalizeCarNum).filter(Boolean).forEach(((carNum) => this.nvSelected.delete(carNum))), this.renderBatchBar(); + doFilterMagnet() { + this.handleDb(), this.handleBus(); } - async runBatchMutation(mutate, successLabel) { - const items = this.selectedItems(); - if (!items.length) return void show.info("请先选择作品"); + /** 给磁力行注入评分徽章(幂等:已有则跳过) */ + injectScoreBadge(el, title) { try { - const result = await mutate(items); - this.applyProcessedSelection(result), result?.changed?.length ? show.ok(`${successLabel} ${result.changed.length} 个番号`) : show.info("没有需要更新的项目"); - } catch (error) { - clog.error("批量操作失败", error), show.error(`批量操作失败: ${error.message || error}`); + if (el.find(".jhs-magnet-score").length > 0) return; + const score = calcMagnetScore({ title: title || "", seeders: 0 }); + const total = score.total; + const label = total >= 70 ? "高" : total >= 40 ? "中" : "低"; + const color = total >= 70 ? "var(--jhs-status-down)" : total >= 40 ? "var(--jhs-status-watch)" : "var(--jhs-surface-2)"; + const onColor = total >= 70 ? "var(--jhs-status-down-on)" : total >= 40 ? "var(--jhs-status-watch-on)" : "var(--jhs-text-muted)"; + const tip = `分辨率:${score.resolution}/25 字幕:${score.subtitle}/20 做种:${score.seeders}/35 新鲜度:${score.freshness}/15`; + const badge = $(`${label} ${total}`).css({ color: onColor, backgroundColor: color }); + el.append(badge); + } catch (e2) { + clog.debug("磁力评分徽章注入失败,已忽略", e2); } } - bindBatchActions() { - const footer = $("#new-video-list-footer"); - footer.off(".jhsNvBatch").on("click.jhsNvBatch", "#nvSelectPage", (() => { - (this.nvCurrentPageItems || []).forEach(((item) => this.nvSelected.add(normalizeCarNum(item.carNum)))), $("#new-video-list-container .nv-select").prop("checked", true), this.renderBatchBar(); - })).on("click.jhsNvBatch", "#nvClearSelection", (() => { - this.nvSelected.clear(), $("#new-video-list-container .nv-select").prop("checked", false), this.renderBatchBar(); - })).on("click.jhsNvBatch", "#batchMarkFavorite,#batchMarkWatched,#batchMarkDownloaded", ((event) => { - const flag = { batchMarkFavorite: "favorite", batchMarkWatched: "watched", batchMarkDownloaded: "downloaded" }[event.currentTarget.id]; - void this.runBatchMutation(((items) => stateService.patch(items.map(((item) => item.carNum)), { [flag]: true }, { type: "new-video-batch-state", records: items.map(((item) => ({ carNum: item.carNum, url: item.url || `/search?q=${encodeURIComponent(item.carNum)}`, names: item.actressName, publishTime: item.publishTime }))) })), "已处理"); - })).on("click.jhsNvBatch", "#batchIgnore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "ignored")), "已忽略"))).on("click.jhsNvBatch", "#batchSnooze", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "snoozed", new Date(Date.now() + 7 * 864e5).toISOString())), "已暂缓"))).on("click.jhsNvBatch", "#batchRestore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), null)), "已恢复"))).on("click.jhsNvBatch", "#batchRemoveFromNewVideo", ((event) => { - const items = this.selectedItems(); - items.length && utils.q(event, `确认将 ${items.length} 个作品从新作列表移除?
    不会删除作品状态记录。`, (() => void this.runBatchMutation(((selected) => stateService.removeFromNewVideoList(selected.map(((item) => item.carNum)), "manual")), "已移除"))); - })); + getQualitySignals(title, hasSubtitleTag = false) { + const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); + return { resolution, subtitle, recognized: !!resolution || subtitle, highQuality: "4k" === resolution || "2160p" === resolution || subtitle }; } - renderBatchBar() { - const footer = $("#new-video-list-footer"); - if (!footer.length) return; - const actresses = new Set(this.nvFlatListCache.flatMap(((item) => item.actresses || [item.actressName])).filter(Boolean)), selected = this.nvSelected.size, visibleSelected = this.nvFlatListCache.filter(((item) => this.nvSelected.has(normalizeCarNum(item.carNum)))).length; - if (!selected) return void footer.html(`
    共 ${this.nvFlatListCache.length} 个番号,涉及 ${actresses.size} 位演员
    `); - footer.html(`
    已选择 ${selected} 项${visibleSelected !== selected ? `,当前结果中 ${visibleSelected} 项` : ""}
    更多 ▾
    `); + /** 返回与 DOM 无关的磁力质量结果,供自有详情工作区复用。 */ + assessMagnet({ title = "", hasHdTag = false, hasSubtitleTag = false, date = null, seeders = 0 } = {}) { + const signals = this.getQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; + const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); + return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; } - nvSortList(e2) { - const t2 = this.nvSortBy || "publishTime_desc", [n2, a2] = t2.split("_"); - return e2.slice().sort(((e3, t3) => { - let i2 = 0; - switch (n2) { - case "publishTime": - i2 = (e3.publishTime || "").localeCompare(t3.publishTime || ""); - break; - case "actress": - i2 = (e3.actressName || "").localeCompare(t3.actressName || ""); - break; - case "carNum": - i2 = (e3.carNum || "").localeCompare(t3.carNum || ""); - break; - case "voteCount": - i2 = (e3.voteCount || 0) - (t3.voteCount || 0); - break; - } - return "desc" === a2 ? -i2 : i2; - })); + updateFilterHint(hasMatch) { + $("#enable-magnets-filter").removeClass("do-hide").attr("data-tip", hasMatch ? "仅显示识别到的高质量或字幕磁力" : "未识别到可过滤项,当前未隐藏磁力"); } - nvRenderPage(generation = this.nvRenderGeneration) { - const e2 = this.nvFlatListCache; - if (!e2 || 0 === e2.length) return; - const t2 = this.nvSortList(e2), n2 = this.nvPageSize, a2 = (this.nvCurrentPage - 1) * n2, i2 = a2 + n2, s2 = t2.slice(a2, i2), o2 = Math.ceil(t2.length / n2), r2 = this.nvJavDbUrl; - this.nvCurrentPageItems = s2; - if (generation !== this.nvRenderGeneration) return; - const l2 = $("#new-video-list-container"); - let c2 = ""; - c2 += '
    '; - for (const n3 of s2) { - const key = normalizeCarNum(n3.carNum), e3 = escapeHtml(key), t3 = escapeHtml(n3.title || key), cachedCover = this.nvCoverCache.get(key), a3 = escapeHtml((cachedCover || n3.coverUrl || "").replace("thumbs", "covers")), i3 = escapeHtml(n3.url || `${r2}/search?q=${encodeURIComponent(key)}`); - let o3 = `番号: ${e3}\\n演员: ${escapeHtml(n3.actressName)}\\n发行: ${n3.publishTime || "未知"}`; - n3.voteCount && (o3 += `\\n评价人数: ${n3.voteCount}`); - const l3 = n3.voteCount ? `${n3.voteCount}人评价` : ""; - c2 += ``; + handleDb() { + if (!r) return; + let e2 = $("#magnets-content .name"); + if (0 === e2.length) return void this.updateFilterHint(false); + let n2 = false; + e2.each(((e3, a2) => { + const i2 = $(a2), s2 = i2.text().toLowerCase(), o2 = this.getQualitySignals(s2); + const row = i2.parent().parent().parent(); + row.removeClass("high-quality").show(); + row.addClass("magnet-row"), s2.includes("4k") && i2.css("color", "var(--jhs-status-filter-text)"), o2.highQuality && (n2 = true, row.addClass("high-quality")); + this.injectScoreBadge(i2, i2.text()); + })), n2 && $("#magnets-content .magnet-row").not(".high-quality").hide(), this.updateFilterHint(n2); + } + handleBus() { + if (l && isDetailPage) { + const e2 = $("#magnet-table tr"); + let n2 = false; + e2.each(((e3, a2) => { + const i2 = $(a2), s2 = i2.find("td:first-child"), o2 = s2.find("a:first-child"), r2 = s2.find("a:nth-child(2)"), l2 = o2.text().toLowerCase(); + i2.removeClass("high-quality").show(); + l2.includes("4k") && o2.css("color", "var(--jhs-status-filter-text)"); + this.getQualitySignals(l2, r2.length > 0 && r2.text().includes("字幕")).highQuality && (n2 = true, i2.addClass("high-quality")); + this.injectScoreBadge(o2, o2.text()); + })); + n2 && e2.each(((e3, t2) => { + const n3 = $(t2); + n3.hasClass("high-quality") || n3.hide(); + })), this.updateFilterHint(n2); } - c2 += "
    "; - if (o2 > 1) { - c2 += '
    '; - this.nvCurrentPage > 1 && (c2 += ``); - let e3 = Math.max(1, this.nvCurrentPage - 2), n3 = Math.min(o2, e3 + 4); - n3 - e3 < 4 && (e3 = Math.max(1, n3 - 4)); - for (let t3 = e3; t3 <= n3; t3++) c2 += ``; - this.nvCurrentPage < o2 && (c2 += ``), c2 += `第 ${this.nvCurrentPage}/${o2} 页,共 ${t2.length} 条`, c2 += "
    "; + } + showAll() { + $("#enable-magnets-filter").removeClass("do-hide").removeAttr("data-tip"); + if (r) { + $("#magnets-content .item").toArray().forEach(((e2) => $(e2).show())); } - l2.html(c2), l2.find(".nv-cover-img").on("error", (function() { - $(this).addClass("jhs-is-hidden").siblings(".nv-card__empty").removeClass("jhs-is-hidden"); - })), l2.find(".nv-select").on("change", ((event) => { - const carNum = normalizeCarNum(event.currentTarget.value); - event.currentTarget.checked ? this.nvSelected.add(carNum) : this.nvSelected.delete(carNum), this.renderBatchBar(); - })), l2.find(".pagination-btn").off("click").on("click", ((e3) => { - const n3 = parseInt($(e3.currentTarget).data("nvpage")); - n3 >= 1 && n3 <= o2 && n3 !== this.nvCurrentPage && (this.nvCurrentPage = n3, this.nvRenderGeneration++, this.nvRenderPage(this.nvRenderGeneration), this.renderBatchBar(), l2.scrollTop(0)); - })), window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: ".nv-cover-img", - dataAttribute: "data-full" - }), void this.hydrateVisibleCovers(s2, generation); + l && $("#magnet-table tr").toArray().forEach(((e2) => $(e2).show())); } - async editActress(e2) { - const t2 = String(e2.name || ""), n2 = normalizeHttpUrl(e2.avatar, this.nvJavDbUrl) || "", a2 = String(e2.remark || ""), i2 = Array.isArray(e2.allName) ? e2.allName.join(",") : "", s2 = Array.isArray(e2.newVideoList) ? e2.newVideoList.map(((e3) => "string" == typeof e3 ? e3 : e3.carNum)).join(",") : "", o2 = String(e2.starId || ""), l2 = e2.actressType || "", safe = /* @__PURE__ */ __name((value) => escapeHtml(String(value || "")), "safe"), c2 = ` -
    -
    - Avatar Preview -
    - - -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    - - + + + + + + +
    -
    - - -
    -
    - - +
    + + + + +
    +
    `; + e2 = e2.replace('
    { - JhsSelect.enhance(e3); - const n3 = /* @__PURE__ */ __name((e4) => { - e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); - }, "n"); - $("#edit-actress-avatar").on("input", (function() { - const e4 = $(this).val(); - $("#edit-avatar-preview").attr("src", e4); - })); - const a3 = $("#edit-actress-allname"); - a3.on("input", (function() { - n3($(this)); - })), n3(a3); - const i3 = $("#edit-actress-newvideolist"); - i3.on("input", (function() { - n3($(this)); - })), n3(i3), $("#search-avatar-btn").on("click", (async () => { - await this.searchAvatar(); - })), $("#select-cdn-btn").on("click", (async () => { - await (async function() { - const e4 = at, t4 = tt.map(((t5, n5) => ` - - `)).join(""), n4 = ` -
    -

    请选择头像数据源 (当前: ${tt[e4].name}):

    - ${t4} -

    切换源会清除本地缓存的数据,并在下次搜索时重新加载。

    -
    - `; - layer.open({ - type: 1, - title: "选择 CDN 源", - area: utils.getResponsiveArea(["400px", "auto"]), - content: n4, - btn: ["确定", "取消"], - success: /* @__PURE__ */ __name((e5, t5) => { - utils.setupEscClose(t5); - }, "success"), - yes: /* @__PURE__ */ __name(async (e5) => { - const t5 = $('input[name="cdn-source"]:checked').val(), n5 = parseInt(t5, 10); - if (n5 !== at) { - at = n5, localStorage.setItem(nt, n5.toString()), it = tt[n5].json, st = tt[n5].base, ct = null, dt = null; - try { - await lt.set(rt, null); - } catch (a4) { - clog.error("清除 IndexedDB 缓存失败:", a4); - } - show.ok(`CDN 源已切换为: ${tt[n5].name}`), layer.close(e5); - } else layer.close(e5); - }, "yes") - }); - })(); - })), utils.setupEscClose(t3); + title: "鉴定记录", + content: e2, + scrollbar: false, + shadeClose: true, + area: utils.getDialogArea("xl"), + anim: -1, + success: /* @__PURE__ */ __name(async (e3) => { + const root = $(e3); + this.historyRoot = root, root.find("#allSelectBox").prepend(''), this.resetHistorySelection(false), JhsSelect.enhance(root); + await this.loadTableData(), root.on("click.jhsHistory", "#clearSearchbtn", (async (e4) => { + root.find("#searchCarNum").val(""), JhsSelect.setValue(root.find("#dataType"), "all"), await this.reloadTable(), root.find("#allSelectBox").hide(); + })).on("focusout keydown", "#searchCarNum", (async (e4) => { + if ("focusout" === e4.type || "Enter" === e4.key) { + if ("Enter" === e4.key && e4.preventDefault(), "keydown" === e4.type && "Enter" !== e4.key) return; + await this.reloadTable(); + } + })).on("click", ".table-link-param", (async (e4) => { + let t2 = $(e4.currentTarget); + root.find("#searchCarNum").val(t2.text()), await this.reloadTable(); + })).on("change", "#dataType", (async () => { + await this.reloadTable(); + })).on("click", "[data-history-view]", (async (event) => { + const view = $(event.currentTarget).data("history-view"); + root.find("[data-history-view]").removeClass("active"), $(event.currentTarget).addClass("active"), await this.showHistoryView(view); + })).on("click", ".jhs-undo-activity", (async (event) => { + const result = await stateService.undoTransaction($(event.currentTarget).data("transaction")); + show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); + })).on("click", ".jhs-copy-offline", (async (event) => { + await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); + })).on("click", ".jhs-retry-offline", (async (event) => { + const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)); + item && await this.getDependency("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); + })).on("click", ".jhs-open-offline", (async (event) => { + const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); + url && window.open(url, "_blank", "noopener,noreferrer"); + })).on("click", ".jhs-delete-offline", (async (event) => { + await stateService.removeOfflineHistory($(event.currentTarget).data("id")), await this.renderOfflineHistory(); + })), this.bindHistoryActions(root); }, "success"), - yes: /* @__PURE__ */ __name(async (t3) => { - const n3 = $("#edit-actress-avatar").val().trim(), a3 = $("#edit-actress-name").val().trim(), i3 = $("#edit-actress-allname").val().trim(), s3 = $("#edit-actress-newvideolist").val().trim(), o3 = $("#edit-remark").val().trim(), r2 = $("#actressType").val(); - if (!a3) return show.error("主名称不能为空"), false; - const l3 = i3.split(/[\uff0c,]/).map(((e3) => e3.trim())).filter(((e3) => e3.length > 0)), c3 = s3.split(/[\uff0c,]/).map(((e3) => e3.trim())).filter(((e3) => e3.length > 0)); - e2.avatar = n3, e2.name = a3, e2.allName = l3, e2.newVideoList = c3, e2.actressType = r2, e2.remark = o3; - try { - await storageManager.updateFavoriteActress(e2); - await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-edited" }); - show.ok(`女优 ${a3} 信息已更新`); - layer.close(t3); - } catch (err) { - show.error("修改失败: " + (err.message || err)); - } - }, "yes") + end: /* @__PURE__ */ __name(() => { + this.resetHistorySelection(), this.historyRoot?.off(".jhsHistory"), this.historyRoot = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null); + }, "end") }); } - renderPagination(e2, t2) { - const n2 = this.currentPage; - let a2 = ""; - const i2 = $("#actress-pagination"); - if (0 === t2) return a2 = '共 0 条记录', void i2.html(a2); - n2 > 1 && t2 > 5 && (a2 += ''), n2 > 1 && (a2 += ``); - let s2 = Math.max(1, n2 - Math.floor(2.5)), o2 = Math.min(t2, s2 + 5 - 1); - o2 - s2 < 4 && (s2 = Math.max(1, o2 - 5 + 1)); - for (let r2 = s2; r2 <= o2; r2++) { - a2 += ``; - } - n2 < t2 && (a2 += ``), n2 < t2 && t2 > 5 && (a2 += ``), a2 += `共 ${e2} 条记录 (第 ${n2}/${t2} 页)`, i2.html(a2), i2.find(".pagination-btn").off("click").on("click", ((e3) => { - if ($(e3.currentTarget).is("[disabled]")) return; - const n3 = parseInt($(e3.currentTarget).data("page")); - n3 >= 1 && n3 <= t2 && n3 !== this.currentPage && (this.currentPage = n3, this.renderActressCards()); + async showHistoryView(view) { + const stateView = "state" === view; + this.resetHistorySelection(), this.historyRoot?.find("#filterBox,#allSelectBox").toggle(stateView), this.tableObj?.destroy(), this.tableObj = null; + return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); + } + async renderActivityHistory() { + const log = await stateService.getActivityLog(), host = this.historyRoot.find("#table-container").empty(); + if (!log.entries.length) return void host.html('
    暂无操作记录
    '); + log.entries.slice().reverse().forEach(((entry) => { + const reverted = entry.changes.filter(((change) => "reverted" === change.undoState)).length, conflicts = entry.changes.filter(((change) => "conflict" === change.undoState)).length; + host.append($('
    ').append($("").text(`${entry.type} · ${entry.changes.length} 项`), $("

    ").text(`${new Date(entry.createdAt).toLocaleString()} · 已撤销 ${reverted} · 冲突 ${conflicts}`), $("

    ").text(entry.changes.map(((change) => change.carNum)).join("、")), $('').attr("data-transaction", entry.id).prop("disabled", "committed" !== entry.commitState || reverted === entry.changes.length))); })); } - async searchAvatar() { - const e2 = $("#edit-actress-name"), t2 = $("#edit-actress-allname"), n2 = e2.val().trim(), a2 = t2.val().trim().split(/[\uff0c,]/).map(((e3) => e3.trim())).filter(((e3) => e3.length > 0)); - if (n2 && a2.unshift(n2), 0 === a2.length) return void show.error("请先填写女优主名称或别名进行搜索。"); - const i2 = loading("正在搜索头像..."); - let s2 = []; - try { - s2 = await gt(a2); - } catch (c2) { - return void show.error(`头像数据加载或搜索失败: ${c2.message || c2}`); - } finally { - i2.close(); - } - if (0 === s2.length) return void show.error(`未找到与 '${a2.join("、")}' 相关的头像。请检查名称。`); - const r2 = $('

    '); - r2.find("#gfriends-prompt").text(`点击图片即可选择(初始共 ${s2.length} 张)`); - const avatarList = r2.find("#gfriends-image-list"); - s2.forEach(((url, index) => { - const candidate = $('').attr({ "data-url": url, "aria-label": `选择第 ${index + 1} 张头像` }); - candidate.append($('').attr("src", url)), candidate.append($('载入中')), avatarList.append(candidate); + async renderOfflineHistory() { + const history = await stateService.getOfflineHistory(), host = this.historyRoot.find("#table-container").empty(); + if (!history.length) return void host.html('
    暂无离线任务
    '); + history.slice().reverse().forEach(((item) => { + const actions = $('
    ').append($('').attr("data-resource", item.resource), $('').attr("data-id", item.id), $('').attr("data-id", item.id), $('').attr("data-id", item.id)); + host.append($('
    ').append($("").text(`${item.providerName || item.providerId} · ${item.status}`), $("

    ").text(`${item.carNum || "未关联番号"} · ${new Date(item.createdAt).toLocaleString()}${item.retryOf ? ` · 重试自 ${item.retryOf}` : ""}`), $("

    ").text(item.errorMessage || item.resource), actions)); })); - let l2 = 0; - layer.open({ - type: 1, - title: `选择女优头像 (${s2.length} 张)`, - area: utils.getResponsiveArea(["900px", "85%"]), - content: r2[0], - btn: ["关闭"], - success: /* @__PURE__ */ __name((e3, t3) => { - const n3 = $(e3), a3 = n3.find(".gfriends-selectable-img"), i3 = n3.find("#gfriends-prompt"), candidates = n3.find(".gfriends-image-item-wrapper"); - a3.each((function() { - const image = $(this), wrapper = image.closest(".gfriends-image-item-wrapper"), size = wrapper.find(".gfriends-size-tag"); - image.on("load", (function() { - size.text(`${this.naturalWidth} x ${this.naturalHeight}`); - })), image.on("error", (function() { - wrapper.remove(), l2++; - const e4 = s2.length - l2; - i3.text(`点击图片即可选择(已移除 ${l2} 张错误图片,剩余 ${e4} 张)`), 0 === e4 && (show.error("所有搜索到的头像链接均已失效,无法选择。"), layer.close(t3)); - })), this.complete && (this.naturalWidth > 0 ? image.trigger("load") : image.trigger("error")); - })), candidates.on("click", (function() { - const candidate = $(this), url = candidate.attr("data-url"); - $("#edit-actress-avatar").val(url), $("#edit-avatar-preview").attr("src", url), candidates.attr("aria-pressed", "false"), candidate.attr("aria-pressed", "true"), setTimeout((() => { - layer.close(t3); - }), 150); - })), utils.setupEscClose(t3); - }, "success") - }); } - }; - __name(_NewVideoPlugin, "NewVideoPlugin"); - var NewVideoPlugin = _NewVideoPlugin; - var _OneTwoThreeOfflinePlugin = class _OneTwoThreeOfflinePlugin extends BasePlugin { - constructor() { - super(...arguments), this.tokenKey = "jhs_123pan_author_token", this.tokenMetaKey = "jhs_123pan_author_token_meta", this.syncTimer = null, this.syncFallbackMs = 3e5; + async reloadTable(resetSelection = true) { + resetSelection && this.resetHistorySelection(), await this.tableObj?.setPage(1); } - getName() { - return "OneTwoThreeOfflinePlugin"; + normalizeHistoryCarNum(carNum) { + return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); } - async handle() { - "yun.123pan.com" === window.location.hostname && this.startTokenSync(); + /** 清空 History 的全选、排除项和当前页选择。 */ + resetHistorySelection(deselectRows = true) { + this.historySelectAll = false, this.historyExcludedCarNums.clear(); + if (deselectRows && this.tableObj) { + this.historySelectionSyncing = true; + try { + this.tableObj.deselectRow(); + } finally { + this.historySelectionSyncing = false; + } + } + this.updateHistorySelectionUi(); } - startTokenSync() { - this.syncTokenOnce(), this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = setInterval((() => this.syncTokenOnce()), this.syncFallbackMs); - const e2 = /* @__PURE__ */ __name(() => this.syncTokenOnce(), "e"); - window.addEventListener("storage", e2), window.addEventListener("focus", e2), document.addEventListener("visibilitychange", (() => { - document.hidden || this.syncTokenOnce(); - })); + createHistorySelectAllCheckbox() { + const checkbox = document.createElement("input"); + return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { + checkbox.checked ? (this.historySelectAll = true, this.historyExcludedCarNums.clear(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); + })), checkbox; } - getTokenFrom123Pan() { - let e2 = (localStorage.getItem("authorToken") || "").trim(); - if (e2) return { - token: e2, - source: "authorToken" - }; + syncHistoryPageSelection() { + if (!this.tableObj || !this.historySelectAll) return void this.updateHistorySelectionUi(); + const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.map(((item) => item.carNum)).filter(((carNum) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(carNum)))); + this.historySelectionSyncing = true; try { - const t3 = JSON.parse(localStorage.getItem("userInfo") || "{}"); - if (t3.authorToken || t3.token) return { - token: (t3.authorToken || t3.token || "").trim(), - source: t3.authorToken ? "userInfo.authorToken" : "userInfo.token" - }; - } catch (t3) { - clog.debug("123 云盘历史用户信息解析失败,继续尝试其他凭证来源", t3); + this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); + } finally { + this.historySelectionSyncing = false; } - const t2 = document.cookie.split(";"); - for (const n2 of t2) { - const e3 = n2.indexOf("="); - if (e3 < 0) continue; - const t3 = n2.substring(0, e3).trim(), a2 = n2.substring(e3 + 1); - if (t3 && /token/i.test(t3) && a2) return { - token: decodeURIComponent(a2).trim(), - source: `cookie.${t3}` + this.updateHistorySelectionUi(); + } + updateHistoryRowSelection(row, selected) { + if (!this.historySelectAll || this.historySelectionSyncing) return void this.updateHistorySelectionUi(); + const carNum = this.normalizeHistoryCarNum(row?.getData?.().carNum); + carNum && (selected ? this.historyExcludedCarNums.delete(carNum) : this.historyExcludedCarNums.add(carNum)), this.updateHistorySelectionUi(); + } + getHistorySelectionSummary() { + if (this.historySelectAll) { + const excluded = this.historyExcludedCarNums.size, selected = Math.max(0, this.historyFilteredCount - excluded); + return { + count: selected, + text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` }; } + const count = this.tableObj?.getSelectedData?.().length || 0; return { - token: "", - source: "" + count, + text: `已选择当前页 ${count} 条` }; } - syncTokenOnce() { - const e2 = this.getTokenFrom123Pan(); - if (!e2.token) return; - const t2 = GM_getValue(this.tokenKey, ""), n2 = GM_getValue(this.tokenMetaKey, null); - if (t2 === e2.token && n2 && n2.source === e2.source) return; - GM_setValue(this.tokenKey, e2.token), GM_setValue(this.tokenMetaKey, { - source: e2.source, - updatedAt: (/* @__PURE__ */ new Date()).toISOString() - }), t2 !== e2.token && show.info(`123 云盘授权已更新:${e2.source}`); - } - getStoredToken() { - return GM_getValue(this.tokenKey, ""); - } - clearStoredToken(e2) { - GM_setValue(this.tokenKey, ""), GM_setValue(this.tokenMetaKey, { - source: "cleared", - reason: e2, - updatedAt: (/* @__PURE__ */ new Date()).toISOString() - }); - } - isTokenExpiredError(e2) { - const msg = e2 instanceof Error ? e2.message : "object" == typeof e2 && e2 ? e2.message || "" : String(e2 || ""); - return "TOKEN_EXPIRED" === e2 || "TOKEN_EXPIRED" === msg || msg.toLowerCase().includes("token is expired"); - } - assertApiResult(e2, t2) { - if (0 === e2.code) return; - const n2 = e2.message || e2.msg || t2 || "请求失败"; - throw /token is expired/i.test(n2) ? "TOKEN_EXPIRED" : n2; + updateHistorySelectionUi() { + if (!this.historyRoot) return; + const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); + this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); + this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { + element.checked = this.historySelectAll, element.indeterminate = this.historySelectAll ? this.historyExcludedCarNums.size > 0 : summary.count > 0; + })); } - /* 依赖 gmRequest 在非 2xx 时 reject 对象上附加 status 属性 */ - async resolveMagnet(e2, t2) { - try { - const n2 = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/resolve"), { urls: e2 }, { - Authorization: "Bearer " + t2, - "App-Version": "3", - platform: "web", - Origin: "https://yun.123pan.com", - Referer: "https://yun.123pan.com/" - }); - return this.assertApiResult(n2, "解析失败"), n2.data && n2.data.list && n2.data.list.length > 0 ? n2.data.list[0] : Promise.reject(n2.message || `解析失败 (${n2.code})`); - } catch (a2) { - if (a2 && 401 === a2.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(a2) ? "TOKEN_EXPIRED" : a2.message ? "响应解析失败: " + a2.message : String(a2); - } + /** 解析批量操作实际要处理的当前页或完整筛选结果。 */ + async getHistoryBatchSelection() { + if (!this.historySelectAll) return this.tableObj?.getSelectedData?.() || []; + const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map(((item) => this.normalizeHistoryCarNum(item.carNum)))); + for (const carNum of this.historyExcludedCarNums) available.has(carNum) || this.historyExcludedCarNums.delete(carNum); + return this.historyFilteredCount = rows.length, rows.filter(((item) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(item.carNum)))); } - async submitTask(e2, t2) { - if (!e2.files || 0 === e2.files.length) throw "没有可建立离线的文件"; - const n2 = e2.files.map(((e3) => e3.id)), a2 = e2.files.reduce(((e3, t3) => e3 + (t3.size || 0)), 0); - try { - const i2 = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/submit"), { - resource_list: [{ resource_id: e2.id, select_file_id: n2 }] - }, { - Authorization: "Bearer " + t2, - "App-Version": "3", - platform: "web" - }); - return this.assertApiResult(i2, "提交失败"), { fileCount: n2.length, totalSize: a2 }; - } catch (i2) { - if (i2 && 401 === i2.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(i2) ? "TOKEN_EXPIRED" : i2.message ? "响应解析失败: " + i2.message : String(i2); - } + bindHistoryActions(root) { + root.on("click.jhsHistory", (function(e2) { + if (e2.target.closest(".sub-btns-toggle")) { + const button = e2.target.closest(".sub-btns-toggle"), t2 = button.closest(".sub-btns").querySelector(".sub-btns-menu"); + root.find(".sub-btns-menu.show").each(((index, e3) => { + e3 !== t2 && (e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false")); + })), t2.classList.toggle("show"), button.setAttribute("aria-expanded", String(t2.classList.contains("show"))); + } else root.find(".sub-btns-menu.show").each(((index, e3) => { + e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false"); + })); + })), root.on("keydown.jhsHistory", ".sub-btns", ((e2) => { + const menu = $(e2.currentTarget).find(".sub-btns-menu"), items = menu.find('[role="menuitem"]'), current = items.index(document.activeElement); + if ("Escape" === e2.key) return e2.preventDefault(), menu.removeClass("show"), $(e2.currentTarget).find(".sub-btns-toggle").attr("aria-expanded", "false").trigger("focus"); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e2.key) || !menu.hasClass("show")) return; + e2.preventDefault(); + const next = "Home" === e2.key ? 0 : "End" === e2.key ? items.length - 1 : "ArrowDown" === e2.key ? (current + 1 + items.length) % items.length : (current - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); + })), root.on("click.jhsHistory", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", ((e2) => { + e2.preventDefault(), e2.stopPropagation(); + const t2 = $(e2.currentTarget), n2 = t2.closest(".action-btns"), a2 = n2.attr("data-car-num"), i2 = n2.attr("data-href"), s2 = /* @__PURE__ */ __name(async (actionType) => { + try { + const flag = legacyActionToFlag(actionType); + await stateService.toggle(a2, flag, { type: "history-state", record: { carNum: a2, url: i2 } }), await this.reloadTable(); + } catch (s3) { + clog.error("历史记录操作失败:", s3), show.error("操作失败"); + } + }, "s"); + if (t2.hasClass("history-filterBtn")) { + const record = this.tableObj?.getRow(a2)?.getData(), isBlocked = normalizeStateFlags(record?.stateFlags).blocked; + isBlocked ? void s2(d) : utils.q(e2, `是否屏蔽${a2}?`, (() => s2(d))); + } else t2.hasClass("history-favoriteBtn") ? void s2(h) : t2.hasClass("history-hasDownBtn") ? void s2(g) : t2.hasClass("history-hasWatchBtn") ? void s2(p) : t2.hasClass("history-deleteBtn") ? this.handleDelete(e2, a2) : t2.hasClass("history-detailBtn") && void this.handleClickDetail(e2, { + carNum: a2, + url: i2 + }).catch(((error) => clog.error("历史详情打开失败", error))); + })), root.on("click.jhsHistory", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (async (e2) => { + e2.preventDefault(), e2.stopPropagation(); + const t2 = $(e2.currentTarget); + let n2 = await this.getHistoryBatchSelection(), a2 = "", i2 = ""; + t2.hasClass("multiple-history-filterBtn") ? (a2 = "屏蔽", i2 = d) : t2.hasClass("multiple-history-favoriteBtn") ? (a2 = "收藏", i2 = h) : t2.hasClass("multiple-history-hasDownBtn") ? (a2 = "已下载", i2 = g) : t2.hasClass("multiple-history-hasWatchBtn") ? (a2 = "已观看", i2 = p) : t2.hasClass("multiple-history-deleteBtn") && (a2 = "移除", i2 = "delete"); + if (!n2.length) return void show.info("请先选择要处理的记录"); + const selectionText = this.historySelectAll ? this.historyExcludedCarNums.size ? `当前筛选结果中已选择 ${n2.length} 条,排除 ${this.historyExcludedCarNums.size} 条` : `当前筛选结果中已选择全部 ${n2.length} 条` : `当前页已选择 ${n2.length} 条`; + utils.q(e2, `${selectionText},是否标记为${a2}?`, (async () => { + let e3 = loading(); + try { + if ("delete" === i2) { + const e4 = n2.map(((e5) => e5.carNum)), t3 = await stateService.remove(e4); + if (!t3.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); + show.ok(`已成功删除 ${t3.changed.length} 个番号`); + } else { + const flag = legacyActionToFlag(i2); + await stateService.patch(n2.map(((item) => item.carNum)), { [flag]: true }, { type: "history-batch-state", records: n2 }), show.ok("操作成功"); + } + this.resetHistorySelection(), await this.reloadTable(false); + } catch (t3) { + clog.error(t3), show.error("操作失败,请稍后重试"); + } finally { + e3.close(); + } + })); + })); } - /** CRC32-IEEE (poly 0xEDB88320) — 与 Go crc32.ChecksumIEEE 一致 */ - _crc32(e2) { - const t2 = new Array(256); - for (let n3 = 0; n3 < 256; n3++) { - let a2 = n3; - for (let i2 = 0; i2 < 8; i2++) a2 = 1 & a2 ? 3988292384 ^ a2 >>> 1 : a2 >>> 1; - t2[n3] = a2; + /** 返回应用当前搜索、状态筛选和排序后的完整 History 数据。 */ + async getFilteredHistoryData(sorters = this.historySorters) { + let a2 = await storageManager.getCarList(); + this.allCount = a2.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a2.forEach(((e2) => { + const flags = normalizeStateFlags(e2.stateFlags); + flags.blocked && this.filterCount++, flags.favorite && this.favoriteCount++, flags.downloaded && this.hasDownCount++, flags.watched && this.hasWatchCount++, hasAnyState(flags) || this.waitCheckCount++; + })), this.historyRoot.find('#dataType option[value="all"]').text(`所有 (${this.allCount})`), this.historyRoot.find('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), this.historyRoot.find('#dataType option[value="filter"]').text(`${u} (${this.filterCount})`), this.historyRoot.find('#dataType option[value="favorite"]').text(`${b} (${this.favoriteCount})`), this.historyRoot.find('#dataType option[value="hasDown"]').text(`${y} (${this.hasDownCount})`), this.historyRoot.find('#dataType option[value="hasWatch"]').text(`${k} (${this.hasWatchCount})`); + const i2 = this.historyRoot.find("#dataType").val(); + const flagByFilter = { filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" }; + let s2 = "all" === i2 ? a2 : "waitCheck" === i2 ? a2.filter(((e2) => !hasAnyState(e2.stateFlags))) : a2.filter(((e2) => normalizeStateFlags(e2.stateFlags)[flagByFilter[i2]])); + const o2 = this.historyRoot.find("#searchCarNum").val().trim(); + if (o2) { + let e2 = o2.toLowerCase().replace("-c", "").replace("-uc", "").replace("-4k", ""); + s2 = s2.filter(((t2) => { + const n2 = t2.carNum.toLowerCase().includes(e2); + const a3 = (t2.names ? t2.names : "").toLowerCase().includes(e2); + return n2 || a3; + })); } - let n2 = 4294967295; - for (let a2 = 0; a2 < e2.length; a2++) n2 = t2[(n2 ^ e2.charCodeAt(a2)) & 255] ^ n2 >>> 8; - return (n2 ^ 4294967295) >>> 0; - } - /** 为 123 云盘 API URL 附加签名查询参数(与 Go signPath 算法一致) */ - _signUrl(e2) { - const t2 = ["a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z"]; - const n2 = Math.round(1e7 * Math.random()); - const a2 = /* @__PURE__ */ new Date(); - const i2 = new Date(a2.getTime() + 6e4 * a2.getTimezoneOffset() + 288e5); - const s2 = `${i2.getFullYear()}${String(i2.getMonth() + 1).padStart(2, "0")}${String(i2.getDate()).padStart(2, "0")}${String(i2.getHours()).padStart(2, "0")}${String(i2.getMinutes()).padStart(2, "0")}`; - let o2 = ""; - for (let r3 = 0; r3 < s2.length; r3++) o2 += t2[parseInt(s2[r3])]; - const r2 = this._crc32(o2), l2 = Math.floor(i2.getTime() / 1e3); - const c2 = `${l2}|${n2}|${new URL(e2).pathname}|web|3|${r2}`; - const u2 = this._crc32(c2), d2 = new URL(e2); - return d2.searchParams.set(String(r2), `${l2}-${n2}-${u2}`), d2.toString(); - } - }; - __name(_OneTwoThreeOfflinePlugin, "OneTwoThreeOfflinePlugin"); - var OneTwoThreeOfflinePlugin = _OneTwoThreeOfflinePlugin; - var _OneOneFiveClient = class _OneOneFiveClient { - constructor(http = gmHttp) { - this.http = http; - } - async checkLogin() { - try { - const result = await this.http.get("https://webapi.115.com/offine/downpath"); - return Boolean(result?.state && result?.data?.length); - } catch (cause) { - throw new ProviderError("115", "LOGIN_REQUIRED", "115 未登录", { cause }); + if (sorters && sorters.length > 0) { + const e2 = sorters[0], t2 = e2.field, a3 = e2.dir; + s2.sort(((e3, n2) => { + const i3 = e3[t2], s3 = n2[t2], o3 = null == i3 || "" === i3, r2 = null == s3 || "" === s3; + return o3 && !r2 ? 1 : !o3 && r2 ? -1 : o3 && r2 ? 0 : i3 < s3 ? "asc" === a3 ? -1 : 1 : i3 > s3 ? "asc" === a3 ? 1 : -1 : 0; + })); } + return s2; } - async search(keyword, offset = 0, limit = 50) { - const result = await this.http.get(`https://webapi.115.com/files/search?search_value=${encodeURIComponent(keyword)}&offset=${offset}&limit=${limit}`); - return (result?.data || []).map(((item) => ({ folderId: item.pid || item.cid || "", fileId: item.fid || null, videoId: item.pc || item.pick_code || "", name: item.n || item.file_name || "", size: Number(item.s || item.size) || 0, createTime: item.t || item.create_time || "", isVideo: /\.(mp4|mkv|avi|mov|flv|wmv|ts|m2ts)$/i.test(item.n || item.file_name || "") }))).filter(((item) => item.isVideo)); - } - async getOfflineInfo() { - return this.http.get(`https://115.com/?ct=offline&ac=space&_=${Date.now()}`); + async getDataList(e2, t2, n2) { + this.historySorters = Array.isArray(n2) ? n2.map(((sorter) => ({ + ...sorter + }))) : []; + const rows = await this.getFilteredHistoryData(this.historySorters), r2 = rows.length, l2 = Math.ceil(r2 / t2), c2 = (e2 - 1) * t2, m2 = c2 + t2; + return this.historyFilteredCount = r2, { + maxPage: l2, + dataList: rows.slice(c2, m2), + totalCount: r2 + }; } - async addOffline(magnet, folderId = "") { - if (!/^magnet:/i.test(magnet) && !/^ed2k:/i.test(magnet)) throw new TypeError("Unsupported offline URL"); - const info = await this.getOfflineInfo(); - if (!info || !info.sign) throw new ProviderError("115", "LOGIN_REQUIRED", "115 未登录或离线空间信息获取失败"); - const body = new URLSearchParams({ url: magnet, wp_path_id: folderId, uid: String(info.uid || ""), sign: info.sign || "", time: String(info.time || "") }).toString(); - const result = await this.http.gmRequest("POST", "https://115.com/web/lixian/?ct=lixian&ac=add_task_url", body, {}, { "Content-Type": "application/x-www-form-urlencoded" }); - const parsed = "string" == typeof result ? (() => { - try { - return JSON.parse(result); - } catch { - return { state: false, error_msg: /login|登录|sign in|未授权|授权|expire|expired|token|cookie/i.test(result) ? "115 未登录" : "115 返回异常响应" }; + async loadTableData() { + this.tableObj = new Tabulator(this.historyRoot.find("#table-container").get(0), { + layout: "fitColumns", + placeholder: "暂无数据", + virtualDom: true, + pagination: true, + paginationMode: "remote", + sortMode: "remote", + ajaxURL: "queryRealm", + dataLoader: false, + ajaxRequestFunc: /* @__PURE__ */ __name(async (e2, t2, n2) => { + const a2 = n2.page, i2 = n2.size, s2 = n2.sort; + return await this.getDataList(a2, i2, s2); + }, "ajaxRequestFunc"), + dataReceiveParams: { + last_page: "maxPage", + last_row: "totalCount", + data: "dataList" + }, + paginationSize: 50, + paginationSizeSelector: [50, 100, 1e3], + paginationCounter: /* @__PURE__ */ __name((e2, t2, n2, a2, i2) => `共 ${a2} 条记录`, "paginationCounter"), + responsiveLayout: "collapse", + responsiveLayoutCollapse: true, + columnDefaults: { + headerHozAlign: "center", + hozAlign: "center" + }, + selectableRowsPersistence: false, + index: "carNum", + columns: [{ + formatter: "rowSelection", + titleFormatter: /* @__PURE__ */ __name(() => this.createHistorySelectAllCheckbox(), "titleFormatter"), + hozAlign: "center", + headerSort: false, + responsive: 0, + width: 40, + cellClick: /* @__PURE__ */ __name((e2, t2) => { + t2.getRow().toggleSelect(); + }, "cellClick") + }, { + title: "番号", + field: "carNum", + width: 120, + sorter: "string", + responsive: 0, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + const a2 = e2.getData().carNum, i2 = a2.indexOf("-"); + if (-1 === i2) return a2; + return `${a2.substring(i2 + 1)}`; + }, "formatter") + }, { + title: "演员", + field: "names", + minWidth: 200, + sorter: "string", + responsive: 5, + headerSort: true, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => (e2.getData().names || "").split(" ").filter(((e3) => "" !== e3.trim())).map(((e3) => ``)).join(" "), "formatter") + }, { + title: "创建时间", + field: "createDate", + width: 170, + sorter: "string", + responsive: 4 + }, { + title: "修改时间", + field: "updateDate", + width: 170, + sorter: "string", + responsive: 4 + }, { + title: "发行时间", + field: "publishTime", + width: 170, + sorter: "string", + responsive: 4 + }, { + title: "来源", + field: "url", + width: 80, + sorter: "string", + responsive: 5, + hozAlign: "left", + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + let a2 = e2.getData().url; + return a2 ? `${a2.includes("javdb") ? "JavDB" : a2.includes("javbus") ? "JavBus" : a2.includes("123av") ? "123AV" : "其他"}` : ""; + }, "formatter") + }, { + title: "状态", + field: "stateFlags", + width: 220, + sorter: false, + responsive: 1, + headerSort: false, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + const flags = normalizeStateFlags(e2.getData().stateFlags), badges = [[flags.blocked, "filter", u], [flags.favorite, "fav", b], [flags.downloaded, "down", y], [flags.watched, "watch", k]].filter(((item) => item[0])).map(((item) => `${item[2]}`)); + return badges.join(" ") || '待鉴定'; + }, "formatter") + }, { + title: "备注", + field: "remark", + width: 100, + sorter: "string", + responsive: 6 + }, { + title: "操作", + sorter: "string", + minWidth: 150, + cssClass: "action-cell-dropdown", + responsive: 0, + headerSort: false, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + const a2 = e2.getData(); + return n2((() => { + var t3; + null == (t3 = e2.getElement().querySelector(".history-editBtn")) || t3.addEventListener("click", ((e3) => { + this.editRecord(a2); + })); + })), ` +
    + +
    + + +
    +
    `; + }, "formatter") + }], + initialSort: [{ + column: "updateDate", + dir: "desc" + }], + locale: "zh-cn", + langs: { + "zh-cn": { + pagination: { + first: "首页", + first_title: "首页", + last: "尾页", + last_title: "尾页", + prev: "上一页", + prev_title: "上一页", + next: "下一页", + next_title: "下一页", + all: "所有", + page_size: "每页行数" + } + } } - })() : result; - if (!parsed || parsed.state === false) { - const message = String(parsed?.error_msg || parsed?.error || parsed?.msg || ""); - const code = this.classifyAddOfflineError(message); - throw new ProviderError("115", code, message || "115 离线任务创建失败", { response: parsed }); - } - return parsed; - } - classifyAddOfflineError(message) { - const text = String(message).toLowerCase(); - if (/未登录|请登录|登录|login|sign|授权|过期|token|cookie|uid|身份|auth|expire|needlogin|need login/i.test(text)) return "LOGIN_REQUIRED"; - if (/已存在|重复|exists|duplicate|already|same|conflict|exist/i.test(text)) return "TASK_EXISTS"; - return "ADD_TASK_FAILED"; - } - async rename(fileId, newName) { - const body = new URLSearchParams({ fid: fileId, file_name: newName }).toString(); - return this.http.gmRequest("POST", "https://webapi.115.com/files/edit", body, {}, { "Content-Type": "application/x-www-form-urlencoded" }); - } - }; - __name(_OneOneFiveClient, "OneOneFiveClient"); - var OneOneFiveClient = _OneOneFiveClient; - function normalize115Keyword(carNum) { - const normalized = normalizeCarNum(carNum); - return normalized?.replace(/^FC2-/i, "") || null; - } - __name(normalize115Keyword, "normalize115Keyword"); - function build115PlayUrl(match) { - return match?.videoId ? `https://115.com/?ct=play&pickcode=${encodeURIComponent(match.videoId)}` : null; - } - __name(build115PlayUrl, "build115PlayUrl"); - function format115Size(bytes) { - const value = Number(bytes) || 0; - if (!value) return "0 B"; - const units = ["B", "KB", "MB", "GB", "TB"], index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); - return `${(value / 1024 ** index).toFixed(index ? 2 : 0)} ${units[index]}`; - } - __name(format115Size, "format115Size"); - function preview115Rename(fileName, carNum, options = {}) { - const extension = fileName.match(/\.[^.]+$/)?.[0] || "", tags = fileName.match(/-(?:U|UC|C|4K|8K|H265|HEVC|CN|CHS|CHT)\b/gi) || []; - let base = options.uppercase === false ? carNum : carNum.toUpperCase(); - if (options.keepTitle) base += ` ${fileName.replace(extension, "").replace(/^.*?\s+/, "")}`; - if (options.keepSuffix !== false) base += [...new Set(tags.map(((tag) => tag.toUpperCase())))].join(""); - return `${base.slice(0, options.maxLength || 180)}${extension}`; - } - __name(preview115Rename, "preview115Rename"); - var _OneOneFiveMatchPlugin = class _OneOneFiveMatchPlugin extends BasePlugin { - constructor() { - super(), this.observer = null, this.unsubscribeItems = null, this.pendingCards = /* @__PURE__ */ new Set(), this.flushTimer = null, this.client = new OneOneFiveClient(), this.concurrency = 4, this.cacheMinutes = 60; - } - getName() { - return "OneOneFiveMatchPlugin"; - } - async handle() { - if (!await storageManager.getSetting("enable115Match", false)) return; - if (!isDetailPage) return this.setupListMatching(); - const carNum = this.getPageInfo().carNum, keyword = normalize115Keyword(carNum); - if (!keyword) return; - const host = $(".movie-panel-info,.container .info").first(); - host.append('
    115匹配:匹配中
    '); - try { - const cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60), matches = await storageManager.cachedRequest(`115match:${carNum}`, cacheMinutes * 6e4, (() => new OneOneFiveClient().search(keyword))); - const box = $(".jhs-115-match").empty().append("115匹配:"); - if (!matches.length) return void box.append(document.createTextNode("未匹配 "), $('').on("click", (() => location.reload()))); - matches.forEach(((match) => { - const row = $(''), playUrl = build115PlayUrl(match); - playUrl ? row.append($("").addClass("jhs-btn jhs-btn--secondary").attr({ href: playUrl, target: "_blank" }).text(`${match.name} (${format115Size(match.size)})`)) : row.append($("").text(`${match.name} (${format115Size(match.size)}) · 不可播放`)); - match.fileId && row.append($('').data("match", match)); - box.append(row); - })); - box.on("click", ".jhs-115-rename", ((event) => this.renameWithPreview(event, $(event.currentTarget).data("match"), carNum))); - } catch (error) { - const box = $(".jhs-115-match").empty().append("115匹配:", document.createTextNode("未登录或请求失败 ")); - box.append('去登录', $('').on("click", (() => location.reload()))); - clog.error("115 匹配失败", error); - } - } - async setupListMatching() { - this.concurrency = Math.max(1, Math.min(10, Number(await storageManager.getSetting("oneOneFiveConcurrency", 4)) || 4)), this.cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60); - this.observer = new IntersectionObserver(((entries) => { - entries.forEach(((entry) => entry.isIntersecting && (this.observer.unobserve(entry.target), this.pendingCards.add(entry.target)))); - this.pendingCards.size && this.scheduleFlush(); - }), { rootMargin: "200px" }); - this.registerCards($(".movie-list .item,.masonry .item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", ((payload) => this.registerCards(payload.items || []))); - } - registerCards(cards) { - cards.forEach(((card) => { - "true" !== card.dataset.jhs115Observed && "matched" !== card.dataset.jhs115State && (card.dataset.jhs115Observed = "true", this.observer.observe(card)); + }), this.tableObj.on("dataProcessed", (() => { + this.historySelectAll ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); + })), this.tableObj.on("rowSelected", ((row) => { + this.updateHistoryRowSelection(row, true); + })), this.tableObj.on("rowDeselected", ((row) => { + this.updateHistoryRowSelection(row, false); + })), this.tableObj.on("rowSelectionChanged", (() => { + this.updateHistorySelectionUi(); + })), this.tableObj.on("rowDblClick", (function(e2, t2) { + t2.toggleSelect(); })); } - scheduleFlush() { - this.flushTimer || (this.flushTimer = setTimeout((async () => { - const cards = [...this.pendingCards]; - this.pendingCards.clear(), this.flushTimer = null, await mapLimit(cards, this.concurrency, ((card) => this.matchCard(card))); - }), 50)); - } - async matchCard(element, force = false) { - const card = $(element), carNum = normalizeCarNum(card.find(".video-title strong").first().text()); - if (!carNum || "pending" === element.dataset.jhs115State && !force) return; - const cacheKey = `115match:${carNum}`; - try { - element.dataset.jhs115State = "pending", force && await storageManager.deleteCachedRequest(cacheKey); - const matches = await storageManager.cachedRequest(cacheKey, this.cacheMinutes * 6e4, (() => this.client.search(normalize115Keyword(carNum)))); - card.find(".jhs-115-list-match").remove(); - const badge = $('').text(matches.length ? `匹配${matches.length}个` : "未匹配").data("matches", matches); - card.find(".video-title").first().prepend(badge), element.dataset.jhs115State = "matched"; - badge.on("click", (() => { - if (!matches.length) return this.matchCard(element, true); - if (1 === matches.length) return window.open(build115PlayUrl(matches[0]), "_blank"); - const links = matches.map(((match) => `${escapeHtml(match.name)}`)).join("
    "); - layer.open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea(["560px", "auto"]) }); - })); - } catch (error) { - element.dataset.jhs115State = "failed", card.find(".jhs-115-list-match").remove(), card.find(".video-title").first().prepend($('').one("click", (() => this.matchCard(element, true)))), clog.warn("115 单卡匹配失败", error); - } - } - destroy() { - this.unsubscribeItems?.(), this.observer?.disconnect(), this.flushTimer && clearTimeout(this.flushTimer), this.pendingCards.clear(); - } - renameWithPreview(event, match, carNum) { - const nextName = preview115Rename(match.name, carNum, { uppercase: true, keepSuffix: true }); - utils.q(event, `确认重命名?
    ${escapeHtml(match.name)}
    → ${escapeHtml(nextName)}`, (async () => { - await new OneOneFiveClient().rename(match.fileId, nextName); - show.ok("重命名完成"); + handleDelete(e2, t2) { + utils.q(e2, `是否移除${t2}?`, (async () => { + await stateService.remove(t2), this.getDependency("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); })); } - }; - __name(_OneOneFiveMatchPlugin, "OneOneFiveMatchPlugin"); - var OneOneFiveMatchPlugin = _OneOneFiveMatchPlugin; - var _OfflineProviderRegistry = class _OfflineProviderRegistry { - constructor() { - this.providers = /* @__PURE__ */ new Map(), this.availabilityCache = /* @__PURE__ */ new Map(), this.positiveTtl = 3e5, this.negativeTtl = 2e4; - } - register(provider) { - if (!provider?.id || !Array.isArray(provider.capabilities) || "function" != typeof provider.submit || "function" != typeof provider.getAvailability) throw new TypeError("Invalid offline provider"); - return this.providers.set(provider.id, provider), provider; - } - async getCandidates(resource, { force = false } = {}) { - const type = /^ed2k:/i.test(resource) ? "ed2k" : /^magnet:/i.test(resource) ? "magnet" : "unknown", candidates = []; - for (const provider of this.providers.values()) { - if (!provider.capabilities.includes(type) || !await provider.isEnabled()) continue; - const availability = await this.getAvailability(provider, force); - ["ready", "unknown"].includes(availability.authState) && candidates.push({ provider, availability }); + async handleClickDetail(e2, t2) { + if (r) if (t2.carNum.includes("FC2-")) { + const e3 = this.parseMovieId(t2.url); + const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); + plugin.openFc2Dialog(e3, t2.carNum, t2.url, { source }); + } else { + if (!t2.url) return void window.open("/search?q=" + t2.carNum, "_blank"); + utils.openPage(t2.url, t2.carNum, false, e2); + } + if (l) { + let n2 = t2.url; + if (n2.includes("javdb")) if (t2.carNum.includes("FC2-")) { + const e3 = this.parseMovieId(n2); + const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); + await plugin.openFc2Page(e3, t2.carNum, n2, { newTab: true }, { source }); + } else window.open(n2, "_blank"); + else utils.openPage(t2.url, t2.carNum, false, e2); } - return candidates; - } - async getAvailability(provider, force = false) { - const cached = this.availabilityCache.get(provider.id); - const ttl = ["ready", "unknown"].includes(cached?.value?.authState) ? this.positiveTtl : this.negativeTtl; - if (!force && cached && Date.now() - cached.time < ttl) return cached.value; - const value = await provider.getAvailability({ force }); - return this.availabilityCache.set(provider.id, { time: Date.now(), value }), value; } - updateAvailability(id, value) { - this.availabilityCache.set(id, { time: Date.now(), value }); + async editRecord(e2) { + const t2 = e2.carNum, n2 = e2.names || "", a2 = e2.url || "", flags = normalizeStateFlags(e2.stateFlags), s2 = e2.remark || ""; + let editRoot = $(); + const c2 = ` +
    +
    + + +
    +
    + + +
    +
    状态: + + + + +
    +
    + + +
    +
    + + +
    +
    + `; + layer.open({ + type: 1, + title: `编辑记录: ${t2}`, + area: utils.getDialogArea("sm"), + content: c2, + btn: ["保存", "取消"], + success: /* @__PURE__ */ __name((e3, t3) => { + editRoot = $(e3); + const n3 = /* @__PURE__ */ __name((e4) => { + e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); + }, "n"), a3 = editRoot.find("#edit-names"); + a3.on("input", (function() { + n3($(this)); + })), n3(a3); + const i2 = editRoot.find("#edit-remark"); + i2.on("input", (function() { + n3($(this)); + })), n3(i2); + }, "success"), + yes: /* @__PURE__ */ __name(async (t3) => { + const n3 = editRoot.find("#edit-names").val().trim(), i2 = editRoot.find("#edit-url").val().trim(), s3 = editRoot.find("#edit-remark").val().trim(), nextFlags = { + favorite: editRoot.find("#edit-favorite").prop("checked"), + downloaded: editRoot.find("#edit-downloaded").prop("checked"), + watched: editRoot.find("#edit-watched").prop("checked"), + blocked: editRoot.find("#edit-blocked").prop("checked") + }; + const save = /* @__PURE__ */ __name(async () => { + await stateService.patch(e2.carNum, nextFlags, { type: "history-edit", replaceMetadata: true, record: { ...e2, names: n3, url: i2, remark: s3 } }), this.tableObj.setData(), layer.close(t3); + }, "save"); + if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e2.carNum}?`, (() => void save())), false; + await save(); + }, "yes") + }); } }; - __name(_OfflineProviderRegistry, "OfflineProviderRegistry"); - var OfflineProviderRegistry = _OfflineProviderRegistry; - var _UnifiedOfflinePlugin = class _UnifiedOfflinePlugin extends BasePlugin { - constructor() { - super(), this.registry = new OfflineProviderRegistry(), this.BUTTON_COOLDOWN_MS = 1800; + __name(_HistoryPlugin, "HistoryPlugin"); + var HistoryPlugin = _HistoryPlugin; + + // src/plugins/status/list-page-button.js + var _ListPageButtonPlugin = class _ListPageButtonPlugin extends BasePlugin { + getName() { + return "ListPageButtonPlugin"; } - getName() { - return "UnifiedOfflinePlugin"; + async handle() { + if (!window.isListPage) return; + await this.createMenuBtn(), this.bindEvent(); + const e2 = await storageManager.getSetting("autoPage"), t2 = this.supportsLiveSorting(); + $("#sort-toggle-btn").prop("disabled", e2 === _ && !t2).attr("title", e2 === _ && !t2 ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"), (e2 !== _ || t2) && await this.sortItems(); } - async initCss() { - return ""; + async createMenuBtn() { + if (r) { + const e2 = o.includes("/actors/"); + let t2 = $(".main-tabs, .tabs"), n2 = "加入黑名单", a2 = "jhs-btn--filter", s2 = null; + if (e2) { + t2 = $(".toolbar, .section-addition").filter(":last"); + const e3 = await storageManager.getBlacklist(), i2 = this.getActressPageInfo(); + e3.find(((e4) => e4.starId === i2.starId)) && (n2 = "已加入黑名单", a2 = "jhs-btn--muted"); + } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { + const e3 = $("#addBlacklistBtn"); + e3.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); + const t3 = $("#jhs-check-tag").text().trim(); + if (!t3) return; + const n3 = "no-" + t3, a3 = await storageManager.getBlacklist(); + s2 = a3.find(((e4) => e4.starId === n3)), s2 && (e3.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); + })); + const r2 = o.includes("advanced_search"); + r2 && (t2 = $("h2.section-title")); + const l2 = localStorage.getItem("jhs_sortMethod"), d2 = "当前排序方式: " + ("rateCount" === l2 ? "评价人数" : "date" === l2 ? "时间" : "默认"); + t2.append(` +
    + + ${e2 ? ` + + + + + ` : ""} + ${o.includes("/tags") ? ` + + ` : ""} +
    +
    + + + ${c ? "" : this.sortMenuHtml(l2 || "default", d2)} +
    + `); + } + if (l) { + const e2 = o.includes("/star/"); + let t2 = "加入黑名单", n2 = "jhs-btn--filter"; + if (e2) { + const e3 = await storageManager.getBlacklist(), a3 = this.getActressPageInfo(); + e3.find(((e4) => e4.starId === a3.starId)) && (t2 = "已加入黑名单", n2 = "jhs-btn--muted"); + } + const a2 = localStorage.getItem("jhs_sortMethod") || "default"; + $(".masonry").parent().prepend(` +
    + + ${e2 ? ` + + + + + ` : ''} + ${this.sortMenuHtml(a2)} +
    + `); + } + $("#waitCheckBtn > span").text("开始鉴定"); + const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); + newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); } - async handle() { - if (!(r || l)) return; - this.registerProviders(), this.bindSubmit(), window.isDetailPage && (this.injectNativeButtons(), jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); + /** 构建与原排序值兼容的 JHS 菜单。 */ + sortMenuHtml(method, title = "选择列表排序方式") { + const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, current = labels[method] || labels.default; + return `
    `; } - registerProviders() { - const one23 = this.getBean("OneTwoThreeOfflinePlugin"); - one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: ["magnet"], retryPolicy: { automaticAttempts: 0 }, isEnabled: /* @__PURE__ */ __name(() => storageManager.getSetting("enable123Offline", true), "isEnabled"), getAvailability: /* @__PURE__ */ __name(async () => one23.getStoredToken() ? { available: true, authState: "ready", reason: "授权已同步" } : { available: false, authState: "token-missing", reason: "尚未同步 123 授权" }, "getAvailability"), submit: /* @__PURE__ */ __name(async (resource) => { - const token = one23.getStoredToken(); - if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); - const resolved = await one23.resolveMagnet(resource, token); - return one23.submitTask(resolved, token); - }, "submit"), openUrl: /* @__PURE__ */ __name(() => "https://yun.123pan.com", "openUrl") }); - this.registry.register({ id: "115", name: "115", capabilities: ["magnet", "ed2k"], retryPolicy: { automaticAttempts: 0 }, isEnabled: /* @__PURE__ */ __name(() => storageManager.getSetting("enable115Offline", false), "isEnabled"), getAvailability: /* @__PURE__ */ __name(async () => ({ available: true, authState: "unknown", reason: "提交时确认登录状态" }), "getAvailability"), submit: /* @__PURE__ */ __name((resource) => new OneOneFiveClient().addOffline(resource), "submit"), openUrl: /* @__PURE__ */ __name(() => "https://115.com", "openUrl") }); - window.offlineProviderRegistry = this.registry; + bindEvent() { + $("#waitCheckBtn").on("click", ((e3) => { + void this.openWaitCheck(e3).catch(((error) => clog.error("待鉴定列表打开失败", error))); + })), $("#newVideoBtn").on("click", ((e3) => { + this.getDependency("NewVideoPlugin").openDialog(); + })), $("#blacklistBtn").on("click", ((e3) => { + this.getDependency("BlacklistPlugin").openBlacklistDialog(); + })), this.bindSortMenu(); + const e2 = this.getDependency("BlacklistPlugin"); + $("#addBlacklistBtn").on("click", (async (t2) => { + await e2.addBlacklist(t2); + })), $("#filterAllVideo").on("click", (async (t2) => { + let n2 = { + clientX: t2.clientX, + clientY: t2.clientY + 80 + }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + if (0 === a2.length) return void show.error("获取演员名称失败"); + let i2 = a2.text().trim().split(",")[0]; + utils.q(n2, "一键屏蔽视频列表?", (async () => { + this.loadObj = loading(); + try { + await e2.filterAllVideo(i2); + } catch (t3) { + clog.error(t3); + } finally { + this.loadObj.close(); + } + })); + })), $("#favoriteAllVideo").on("click", (async (t2) => { + let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + if (0 === a2.length) return void show.error("获取演员名称失败"); + let i2 = a2.text().trim().split(",")[0]; + utils.q(n2, "一键收藏所有可见作品?", (async () => { + this.loadObj = loading(); + try { + await e2.batchSaveAllVideos(i2, h); + } catch (t3) { + clog.error(t3); + } finally { + this.loadObj.close(); + } + })); + })), $("#hasDownAllVideo").on("click", (async (t2) => { + let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + if (0 === a2.length) return void show.error("获取演员名称失败"); + let i2 = a2.text().trim().split(",")[0]; + utils.q(n2, "一键已下载所有可见作品?", (async () => { + this.loadObj = loading(); + try { + await e2.batchSaveAllVideos(i2, g); + } catch (t3) { + clog.error(t3); + } finally { + this.loadObj.close(); + } + })); + })); } - bindSubmit() { - $(document).off("click.jhsUnifiedOffline", ".jhs-offline-btn").on("click.jhsUnifiedOffline", ".jhs-offline-btn", (async (event) => { + /** 绑定排序 popover 的选择与键盘交互。 */ + bindSortMenu() { + const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = /* @__PURE__ */ __name((focus = false) => { + menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); + }, "close"); + toggle.on("click", ((event) => { event.preventDefault(), event.stopPropagation(); - const button = $(event.currentTarget), resource = button.attr("data-resource") || button.attr("data-magnet") || button.closest(".magnet-result,.item,td").find('a[href^="magnet:"],a[href^="ed2k:"]').first().attr("href"); - resource ? await this.submitResource(event, resource, button) : show.error("未找到可提交资源"); + const open = !menu.hasClass("is-open"); + menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); })); - } - injectNativeButtons() { - const adapter = getDetailResourceAdapter(); - if (!adapter) return; - adapter.rows().forEach(((row) => { - const resource = adapter.getResource(row), target = adapter.getActionTarget(row); - if (!resource || !target?.length || $(row).closest(".magnet-container,.jhs-review-panel,.movie-detail-container").length) return; - const owner = `native-${adapter.site}`; - let button = $(row).find(`.jhs-offline-btn[data-jhs-offline-owner="${owner}"]`).first(); - $(row).find(`.jhs-offline-btn[data-jhs-offline-owner="${owner}"]`).not(button).remove(); - button.length || (button = $('').attr("data-jhs-offline-owner", owner)), button.attr("data-resource", resource), target.append(button); + menu.on("click", ".jhs-sort-option", ((event) => { + const item = $(event.currentTarget), method = item.data("sort-method"); + localStorage.setItem("jhs_sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(true), void this.sortItems().catch(((error) => clog.error("列表排序失败", error))); + })).on("keydown", ".jhs-sort-option", ((event) => { + const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); + if ("Escape" === event.key) return event.preventDefault(), close(true); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); })); - } - async chooseCandidate(event, candidates) { - if (1 === candidates.length) return candidates[0]; - const mode = await storageManager.getSetting("offlineProviderMode", "ask"), preferred = candidates.find(((candidate) => candidate.provider.id === mode)); - if (preferred) return preferred; - return new Promise(((resolve) => { - const content = $('

    选择离线服务

    '), toolbar = content.find(".jhs-toolbar"); - candidates.forEach(((candidate) => toolbar.append($('').text(`${candidate.provider.name} · ${"ready" === candidate.availability.authState ? "已就绪" : "状态未知"}`).on("click", (() => { - layer.close(index), resolve(candidate); - }))))); - const index = layer.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: /* @__PURE__ */ __name(() => resolve(null), "cancel") }); + $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", ((event) => { + $(event.target).closest(control).length || close(); })); } - getVideoInfo(button) { - if (window.isDetailPage) return this.getPageInfo(); - const item = button?.closest?.(".item"); - return item?.length ? this.getBean("ListPagePlugin").findCarNumAndHref(item) : this.getPageInfo(); - } - async submitResource(event, resource, button = $(), context = null, retryOf = null, options = {}) { - if (button.hasClass("loading")) return; - const candidates = await this.registry.getCandidates(resource, { force: !!options.forceAvailabilityRefresh }); - if (!candidates.length) return void show.error("没有已启用且支持该资源的离线服务,请检查授权与设置"); - const selected = candidates.find(((candidate) => candidate.provider.id === options.preferredProviderId)) || await this.chooseCandidate(event, candidates); - if (!selected) return; - const info = context || this.getVideoInfo(button), original = button.text(), restoreButton = /* @__PURE__ */ __name(() => { - if (!button[0]?.isConnected) return; - button.removeClass("loading").removeAttr("aria-busy aria-disabled").text(original); - }, "restoreButton"); - let submitted = false; - try { - button.addClass("loading").attr({ "aria-busy": "true", "aria-disabled": "true" }).text("提交中"), await selected.provider.submit(resource, info), this.registry.updateAvailability(selected.provider.id, { available: true, authState: "ready", reason: "最近提交成功" }); - await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "submitted", retryOf }), submitted = true, button.text("已提交"), show.ok(`${selected.provider.name} 离线任务已创建`), utils.q(event, "是否将该作品标记为已下载?", (async () => { - info?.carNum && await stateService.patch(info.carNum, { downloaded: true }, { type: "offline-mark-downloaded", record: { ...info, names: info.actress || info.names || "" } }); - })); - } catch (error) { - const code = error?.code || ("TOKEN_EXPIRED" === error ? "TOKEN_EXPIRED" : "SUBMIT_FAILED"); - ["LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING"].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: false, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: error.message || String(error) }); - restoreButton(); - submitted || await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: error?.message || String(error), retryOf }), show.error(`${selected.provider.name} 离线失败:${error?.message || error}`); - } finally { - submitted ? setTimeout(restoreButton, this.BUTTON_COOLDOWN_MS) : restoreButton(); - } - } - }; - __name(_UnifiedOfflinePlugin, "UnifiedOfflinePlugin"); - var UnifiedOfflinePlugin = _UnifiedOfflinePlugin; - var _StatsPlugin = class _StatsPlugin extends BasePlugin { - getName() { - return "StatsPlugin"; - } - async initCss() { - return ` - `; + async sortItems() { + const e2 = this.supportsLiveSorting(); + if (!e2 && (o.includes("handle") || o.includes("advanced_search"))) return; + const s2 = await storageManager.getSetting("autoPage"); + if (c || s2 === _ && !e2) return; + const t2 = localStorage.getItem("jhs_sortMethod"); + if (!t2) return; + const i2 = this.getSelector(), d2 = $(i2.boxSelector), h2 = $(i2.itemSelector); + h2.each((function(e3) { + $(this).attr("data-original-index") || $(this).attr("data-original-index", e3); + })); + const items = h2.get().map(((element, index) => { + const card = $(element), originalIndex = Number(card.attr("data-original-index")) || 0; + if ("default" === t2) return { element, key: originalIndex, originalIndex, index }; + if ("rateCount" === t2) { + const explicit = Number(card.attr("data-jhs-rate-count")), match = card.find(".score").text().replaceAll(",", "").match(/(?:由\s*)?(\d+)\s*人(?:评价)?/); + return { element, key: Number.isFinite(explicit) ? explicit : match ? Number(match[1]) : 0, originalIndex, index }; + } + const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter((function() { + return /^\d{4}-\d{1,2}-\d{1,2}$/.test($(this).text().trim()); + })).first().text().trim(), timestamp = Date.parse(value); + return { element, key: Number.isFinite(timestamp) ? timestamp : 0, originalIndex, index }; + })); + items.sort(((e3, n2) => "default" === t2 ? e3.key - n2.key : n2.key - e3.key || e3.originalIndex - n2.originalIndex || e3.index - n2.index)); + const sortedElements = items.map(((item) => item.element)); + "default" === t2 ? $(sortedElements).appendTo(d2) : d2.empty().append(sortedElements); } - async handle() { - window.isListPage && this.createBtn(); + isHitShowPage() { + return isHitShowPage(window.location); } - createBtn() { - const e2 = ''; - $("#newVideoBtn").after(e2), $("#statsBtn").on("click", (() => this.openDialog())); + isFc2ListPage() { + return r && "/advanced_search" === window.location.pathname && "3" === new URLSearchParams(window.location.search).get("type"); } - async openDialog() { - const cars = await storageManager.getCarList(), actresses = await storageManager.getFavoriteActressList(), blacklist = await storageManager.getBlacklist(), activity = await stateService.getActivityLog(), total = cars.length; - const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; - cars.forEach(((car) => { - const flags = normalizeStateFlags(car.stateFlags); - flags.blocked && counts.manualBlocked++, flags.favorite && counts.favorite++, flags.downloaded && counts.hasDown++, flags.watched && counts.hasWatch++, hasAnyState(flags) || counts.pending++; - })); - const actressCounts = /* @__PURE__ */ new Map(); - cars.forEach(((car) => { - const names = String(car.names || "").split(/[\s,,、]+/).filter(Boolean); - if (car.starId) { - const key = `id:${car.starId}`, current = actressCounts.get(key) || { starId: car.starId, name: names[0] || car.starId, count: 0 }; - current.count++, actressCounts.set(key, current); - } else names.forEach(((name) => { - const key = `name:${name}`, current = actressCounts.get(key) || { starId: "", name, count: 0 }; - current.count++, actressCounts.set(key, current); - })); - })); - const topActresses = [...actressCounts.values()].sort(((left, right) => right.count - left.count || left.name.localeCompare(right.name))).slice(0, 10), topValue = topActresses[0]?.count || 1, javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); - const pending = counts.pending, counter = this.getBean("NewVideoPlugin"), newVideos = counter ? await counter.getPendingNewVideoTotal() : 0, pageSummary = this.getBean("ListPagePlugin").getCurrentPageSummary(); - const metrics = [ - { label: "总记录", value: total, action: null }, - { label: "收藏", value: counts.favorite, action: null }, - { label: "下载", value: counts.hasDown, action: null }, - { label: "已看", value: counts.hasWatch, action: null }, - { label: "手动屏蔽", value: counts.manualBlocked, action: null }, - { label: "未鉴定", value: pending, action: null }, - { label: "收藏演员", value: actresses.length, action: null }, - { label: "黑名单演员", value: blacklist.length, action: null }, - { label: "新作品待处理", value: newVideos, action: "new-video" } - ]; - const statusRows = [["收藏", counts.favorite, "var(--jhs-status-fav)"], ["下载", counts.hasDown, "var(--jhs-status-down)"], ["已看", counts.hasWatch, "var(--jhs-status-watch)"], ["手动屏蔽", counts.manualBlocked, "var(--jhs-status-filter)"], ["未鉴定", pending, "var(--jhs-border-strong)"]]; - const row = /* @__PURE__ */ __name((label, value, max, color, href = "") => `
    ${href ? `${escapeHtml(label)}` : `${escapeHtml(label)}`}${value}${max === total && total ? ` (${Math.round(value / total * 100)}%)` : ""}
    `, "row"); - const trend = /* @__PURE__ */ __name((days) => { - const cutoff = Date.now() - days * 864e5, result = { identified: 0, downloaded: 0, watched: 0 }; - activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState)).forEach(((change) => { - const before = normalizeStateFlags(change.before?.stateFlags), after = normalizeStateFlags(change.after?.stateFlags); - !hasAnyState(before) && hasAnyState(after) && result.identified++, !before.downloaded && after.downloaded && result.downloaded++, !before.watched && after.watched && result.watched++; - })))); - return result; - }, "trend"), trend7 = trend(7), trend30 = trend(30); - const coverageNote = activity.coverageStart ? `活动记录仅覆盖自 ${escapeHtml(activity.coverageStart)} 起` : "仅统计 6.4.0 及之后产生的操作记录"; - const renderMetric = /* @__PURE__ */ __name((metric) => metric.action ? `` : `
    ${metric.value}${metric.label}
    `, "renderMetric"); - const dialogHtml = `
    -

    全库概览

    ${metrics.map(renderMetric).join("")}
    -

    当前页面

    ${renderMetric({ label: "屏蔽项", value: pageSummary.blockedItems, action: "filter", filter: "blockedItems" })}
    -

    状态分布

    ${statusRows.map(((item) => row(item[0], item[1], total, item[2]))).join("")}
    -

    活动趋势

    ${coverageNote}

    ${trend7.identified}近 7 天新增鉴定
    ${trend7.downloaded}近 7 天标记下载
    ${trend7.watched}近 7 天标记观看
    ${trend30.identified}近 30 天新增鉴定
    ${trend30.downloaded}近 30 天标记下载
    ${trend30.watched}近 30 天标记观看
    - ${topActresses.length ? `

    Top 10 演员

    ${topActresses.map(((item) => row(item.name, item.count, topValue, "var(--jhs-accent)", new URL(item.starId ? `/actors/${encodeURIComponent(item.starId)}` : `/search?q=${encodeURIComponent(item.name)}`, javDbUrl).href))).join("")}
    ` : ""} -
    `; - layer.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: false, area: utils.getDialogArea("lg"), anim: -1, success: /* @__PURE__ */ __name((layerElement, layerIndex) => { - $(layerElement).find(".jhs-stats__bar").each((function() { - $(this).css({ "--jhs-value": `${$(this).data("width")}%`, "--jhs-bar": $(this).data("color") }); - })); - $(layerElement).find("button.jhs-stats__metric[data-action]").on("click", ((event) => { - const metric = $(event.currentTarget), action = metric.data("action"); - layer.close(layerIndex); - if ("new-video" === action) return this.getBean("NewVideoPlugin").openDialog(); - if ("filter" === action) this.getBean("ListPagePlugin").setQuickFilter(metric.data("filter")); - })); - utils.setupEscClose(layerIndex); - }, "success") }); + supportsLiveSorting() { + return this.isHitShowPage() || this.isFc2ListPage(); + } + async openWaitCheck() { + let e2 = this.getSelector(); + const t2 = await storageManager.getSetting("waitCheckCount", 5); + let a2 = 0; + const listPage = this.getDependency("ListPagePlugin"); + for (const element of $(e2.itemSelector).toArray()) { + if (a2 >= t2) break; + const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); + if (hasAnyState(flags) || isHardHidden(flags, visibilityReasons)) continue; + await listPage.openMovieDetail(item, { autoplay: true, newTab: false }), a2++; + } + 0 === a2 && show.info("没有需鉴定的视频"); } }; - __name(_StatsPlugin, "StatsPlugin"); - var StatsPlugin = _StatsPlugin; + __name(_ListPageButtonPlugin, "ListPageButtonPlugin"); + var ListPageButtonPlugin = _ListPageButtonPlugin; + + // src/plugins/status/mobile-bottom-bar.js var _MobileBottomBarPlugin = class _MobileBottomBarPlugin extends BasePlugin { constructor() { super(...arguments); @@ -15576,12 +15034,12 @@ ${error.stack}` : ""); $(document).off("click.jhsCommandbar").on("click.jhsCommandbar", ((event) => { $(event.target).closest(".jhs-commandbar__more, .jhs-commandbar__batch").length || (commandbar.find(".jhs-commandbar__menu").removeClass("is-open"), commandbar.find(".jhs-commandbar__menu-toggle").attr("aria-expanded", "false")); })); - this.getBean("ListPagePlugin")?.syncQuickFilterUi(); + this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); } /** 获取详情页番号 */ getCarNum() { try { - const basePlugin = this.getBean("DetailPageButtonPlugin"); + const basePlugin = this.getDependency("DetailPageButtonPlugin"); if (basePlugin?.parseMovieId) return basePlugin.parseMovieId(location.href); const el = document.querySelector(".header, #video_id, .video-id"); if (el) return el.textContent.trim(); @@ -15594,7 +15052,7 @@ ${error.stack}` : ""); const item = /* @__PURE__ */ __name((action, label, attributes = "") => ``, "item"), group = /* @__PURE__ */ __name((content) => `
    ${content}
    `, "group"), divider = ''; let items; if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getBean("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); + const sortMethod = localStorage.getItem("jhs_sortMethod") || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; } else if (window.isDetailPage) { const statusDefs = [{ action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" }]; @@ -15656,7 +15114,7 @@ ${error.stack}` : ""); const sortMethod = localStorage.getItem("jhs_sortMethod") || "default"; const sortLabel = { default: "默认", rateCount: "评价人数", date: "时间" }[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); - this.getBean("ListPagePlugin")?.syncQuickFilterUi(); + this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); } if (window.isDetailPage) void this.refreshDetailStatus().catch(((error) => clog.warn("移动端详情状态刷新失败", error))); const gen = ++this._fabGeneration; @@ -15689,7 +15147,7 @@ ${error.stack}` : ""); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })).on("click", ".jhs-mobile-filter-option", ((event) => { - event.stopPropagation(), this.getBean("ListPagePlugin").setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); + event.stopPropagation(), this.getDependency("ListPagePlugin").setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); })); sortMenu.on("keydown", ".jhs-mobile-sort-option", ((event) => { const items = sortMenu.find(".jhs-mobile-sort-option"), index = items.index(event.currentTarget); @@ -15701,7 +15159,7 @@ ${error.stack}` : ""); })).on("click", ".jhs-mobile-sort-option", ((event) => { event.stopPropagation(); const value = $(event.currentTarget).data("jhs-sort"); - localStorage.setItem("jhs_sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), void this.getBean("ListPageButtonPlugin")?.sortItems?.(), closeMenu(true); + localStorage.setItem("jhs_sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), void this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(true); })); menu.on("click", ".jhs-fab-menu-item", (e2) => { const action = $(e2.currentTarget).data("action"); @@ -15723,13 +15181,13 @@ ${error.stack}` : ""); switch (action) { // 列表页操作 case "check": - await this.getBean("ListPageButtonPlugin")?.openWaitCheck?.(); + await this.getDependency("ListPageButtonPlugin")?.openWaitCheck?.(); break; case "newVideo": - this.getBean("NewVideoPlugin")?.openDialog(); + this.getDependency("NewVideoPlugin")?.openDialog(); break; case "blacklist": - this.getBean("BlacklistPlugin")?.openBlacklistDialog(); + this.getDependency("BlacklistPlugin")?.openBlacklistDialog(); break; case "sort": break; @@ -15757,7 +15215,7 @@ ${error.stack}` : ""); break; // 通用 case "setting": - await this.getBean("SettingPlugin")?.openQuickSetting(); + await this.getDependency("SettingPlugin")?.openQuickSetting(); break; case "logger": clog.openDialog?.(); @@ -15767,330 +15225,184 @@ ${error.stack}` : ""); }; __name(_MobileBottomBarPlugin, "MobileBottomBarPlugin"); var MobileBottomBarPlugin = _MobileBottomBarPlugin; - function getDetailResourceAdapter() { - if (!window.isDetailPage) return null; - if (r) { - const hostRoot = $(".video-detail").first(), controller = hostRoot.find('[data-controller="magnet-sort"]').first(), resourceRoot = controller.find("#magnets-content").first(); - if (!hostRoot.length || !controller.length || !resourceRoot.length) return null; - const resourceRegion = controller.closest(hostRoot.children()).first(); - return { - site: "javdb", - hostRoot, - controller, - observeRoot: controller, - resourceRoot, - resourceRegion, - rows: /* @__PURE__ */ __name(() => resourceRoot.children(".item").toArray(), "rows"), - sortSelect: controller.find('select[data-action*="magnet-sort#sort"]').first(), - getResource(row) { - const item = $(row); - return item.find('.copy-to-clipboard[data-clipboard-text^="magnet:"]').first().attr("data-clipboard-text") || item.find('.magnet-name a[href^="magnet:"]').first().attr("href") || ""; - }, - getActionTarget: /* @__PURE__ */ __name((row) => $(row).children(".buttons").first(), "getActionTarget") - }; - } - if (l) { - const hostRoot = $(".container").filter(((_2, element) => $(element).find("#magnet-table").length > 0)).first(), resourceRoot = hostRoot.find("#magnet-table").first(); - if (!hostRoot.length || !resourceRoot.length) return null; - const resourceRegion = resourceRoot.closest(hostRoot.children()).first(), observeRoot = resourceRoot.parent(); - return { - site: "javbus", - hostRoot, - controller: resourceRoot, - observeRoot, - resourceRoot, - resourceRegion, - rows: /* @__PURE__ */ __name(() => resourceRoot.find("tr").filter(((_2, row) => $(row).find('td a[href^="magnet:"],td a[href^="ed2k:"]').length > 0)).toArray(), "rows"), - sortSelect: $(), - getResource: /* @__PURE__ */ __name((row) => $(row).find('td a[href^="magnet:"],td a[href^="ed2k:"]').first().attr("href") || "", "getResource"), - getActionTarget(row) { - const item = $(row), stableActions = item.find(".buttons,.actions,.btn-group").filter(((_2, element) => $(element).closest("td").length > 0)).last(); - if (stableActions.length) return stableActions; - const resourceCell = item.find('td:has(a[href^="magnet:"]),td:has(a[href^="ed2k:"])').first(); - let actions = resourceCell.children(".jhs-offline-actions").first(); - return actions.length || (actions = $('').appendTo(resourceCell)), actions; - } - }; - } - return null; - } - __name(getDetailResourceAdapter, "getDetailResourceAdapter"); - var _DetailWorkspacePlugin = class _DetailWorkspacePlugin extends BasePlugin { - constructor() { - super(), this.hostRoot = null, this.resourceObserver = null, this.scheduledResourceFrame = null; - } + + // src/plugins/status/nav-bar.js + var _NavBarPlugin = class _NavBarPlugin extends BasePlugin { getName() { - return "DetailWorkspacePlugin"; + return "NavBarPlugin"; } async initCss() { - return ``; - } - async handle() { - if (!window.isDetailPage) return; - utils.loopDetector((() => !!this.getHostAdapter()), (() => this.ensureWorkspace()), 40, 2500, true); - } - getHostAdapter() { - if (r) { - const root = $(".video-detail").first(); - return root.length ? { site: "javdb", root } : null; - } - if (l) { - const root = $(".container").filter(((_2, element) => $(element).find("#magnet-table,.screencap,.info").length > 0)).first(); - return root.length ? { site: "javbus", root } : null; - } - return null; + return "\n .highlight-red {\n /* 核心要求:高亮红色文本 */\n color: var(--jhs-status-filter) !important;\n \n /* 建议:增加字体加粗,效果更明显 */\n font-weight: bold;\n \n /* 建议:增加背景色,效果更突出 */\n /* background-color: yellow; */ \n}\n "; } - ensureWorkspace() { - const adapter = this.getHostAdapter(); - if (!adapter) return $(); - const root = adapter.root; - if (!root.attr("data-jhs-workspace-ready")) { - root.attr({ "data-jhs-workspace-ready": "true", "data-jhs-workspace-site": adapter.site }).addClass("jhs-detail-host-workspace jhs-ui"); - if ("javdb" === adapter.site) { - root.children("h2,.video-meta-panel").attr("data-jhs-host-region", "summary"); - root.children(".columns").filter(((_2, element) => $(element).find(".tile-images,.preview-images").length > 0)).attr("data-jhs-host-region", "gallery"); - root.children(".columns").filter(((_2, element) => $(element).find("#magnets-content").length > 0)).attr("data-jhs-host-region", "resources"); - this.normalizeHostActions(root.find(".video-meta-panel").first()); - } else { - root.children("h3,.row.movie").attr("data-jhs-host-region", "summary"); - root.children().filter(((_2, element) => $(element).is("#mag-submit-show,#mag-submit") || $(element).find("#magnet-table").length > 0)).attr("data-jhs-host-region", "resources"); - root.children().filter(((_2, element) => $(element).is("#sample-waterfall") || $(element).find("#sample-waterfall").length > 0)).attr("data-jhs-host-region", "gallery"); - this.normalizeHostActions(root.find(".info").first()); - } - this.ensureOwnedSlots(root), this.adoptExistingOwnedPanels(root); + handle() { + if (this.margeNav(), this.hookSearch(), this.hookOldSearch(), this.toggleOtherNavItem(), $(window).resize(this.toggleOtherNavItem), JhsSelect.enhance("#search-box"), window.location.href.includes("/search")) { + const e2 = new URLSearchParams(window.location.search); + let t2 = e2.get("q"), n2 = e2.get("f"); + $("#search-keyword").val(t2), n2 && JhsSelect.setValue("#search-type", n2), t2 && this.highlightKeyword(t2); } - this.hostRoot = root, this.ensureOwnedSlots(root), this.placeOwnedSlots(), this.bindResourceLifecycle(); - return root; - } - getSlot(name) { - return this.ensureWorkspace().find(`[data-jhs-slot="${name}"]`).first(); - } - ensureOwnedSlots(root = this.hostRoot) { - if (!root?.length) return; - root.children('[data-jhs-slot="summary-actions"]').length || root.append('
    '); - let group = root.children('[data-jhs-slot-group="post-resource"]').first(); - group.length || (group = $('
    ').appendTo(root)); - group.children('[data-jhs-slot="reviews"]').length || group.append('
    '); - group.children('[data-jhs-slot="related"]').length || group.append(''); - } - /** 只移动 JHS 自有插槽,将其固定在稳定宿主锚点旁。 */ - placeOwnedSlots() { - const root = this.hostRoot, resource = getDetailResourceAdapter(); - if (!root?.length) return; - this.ensureOwnedSlots(root); - const summaryActions = root.children('[data-jhs-slot="summary-actions"]').first(), postResource = root.children('[data-jhs-slot-group="post-resource"]').first(); - const summaryRegion = "javdb" === root.attr("data-jhs-workspace-site") ? root.children(".video-meta-panel").first() : root.children(".row.movie").first(); - summaryRegion.length && summaryActions.insertAfter(summaryRegion); - resource?.resourceRegion?.length && postResource.insertAfter(resource.resourceRegion); - } - adoptExistingOwnedPanels(root) { - [[".jhs-detail-btn-row", "summary-actions"], [".jhs-related-panel", "related"], [".jhs-review-panel", "reviews"]].forEach((([selector, slot]) => { - const target = root.find(`[data-jhs-slot="${slot}"]`).first(); - root.find(selector).filter(((_2, element) => !$(element).closest("[data-jhs-slot]").length)).each(((_2, element) => target.append(element))); - })); - } - normalizeHostActions(info) { - const labels = /* @__PURE__ */ new Set(["想看", "看过", "看過", "存入清单", "存入清單", "下载", "下載", "订正", "訂正"]); - info.find("a, button").filter((function() { - return !$(this).is(".jhs-btn, [id^='jhs-']") && labels.has($(this).text().replace(/\s+/g, " ").trim()); - })).addClass("jhs-detail-host-action"); } - isJhsOnlyMutation(record) { - if ($(record.target).closest(".jhs-offline-actions,.jhs-select-control,.jhs-magnet-score").length) return true; - const nodes = [...record.addedNodes, ...record.removedNodes].filter(((node) => node.nodeType === Node.ELEMENT_NODE)); - return nodes.length > 0 && nodes.every(((node) => node.matches?.(".jhs-offline-btn,.jhs-offline-actions,.jhs-magnet-score,.jhs-select-control") || node.closest?.(".jhs-offline-actions,.jhs-select-control"))); + highlightKeyword(e2) { + const t2 = e2.trim(); + if (!t2) return; + const n2 = t2.toLowerCase(); + $(".video-title strong, .actor-box strong").each((function() { + const e3 = $(this); + e3.text().toLowerCase().includes(n2) && e3.addClass("highlight-red"); + })); } - bindResourceLifecycle() { - const adapter = getDetailResourceAdapter(); - if (!adapter) return; - if (this.resourceObserver && this.resourceObserver.root === adapter.observeRoot[0]) return void this.scheduleResourceUpdate(); - this.resourceObserver?.disconnect?.(); - const observer = new MutationObserver(((records) => { - records.every(((record) => this.isJhsOnlyMutation(record))) || this.scheduleResourceUpdate(); + hookSearch() { + $("#navbar-menu-hero").after('\n \n '), $("#search-keyword").on("paste", ((e2) => { + const t2 = e2.originalEvent.clipboardData.items; + for (let n2 = 0; n2 < t2.length; n2++) if (-1 !== t2[n2].type.indexOf("image")) { + const e3 = t2[n2].getAsFile(); + $("#search-keyword").blur(); + const a2 = this.getDependency("SearchByImagePlugin"); + return void a2.open((() => { + a2.handleImageFile(e3), a2.resetSearchUI(); + })); + } + })).on("keypress", ((e2) => { + "Enter" === e2.key && setTimeout((() => { + $("#search-btn").click(); + }), 0); + })), $("#search-btn").on("click", ((e2) => { + let t2 = $("#search-keyword").val(), n2 = $("#search-type").val(); + "" !== t2 && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t2 + "&f=" + n2 : window.open("/search?q=" + t2 + "&f=" + n2)); + })), $("#search-img-btn").on("click", (() => { + this.getDependency("SearchByImagePlugin").open(); })); - observer.root = adapter.observeRoot[0], observer.observe(adapter.observeRoot[0], { childList: true, subtree: true }), this.resourceObserver = observer, adapter.sortSelect.length && adapter.sortSelect.addClass("jhs-select-source") && JhsSelect.enhance(adapter.controller), this.scheduleResourceUpdate(); } - scheduleResourceUpdate() { - if (this.scheduledResourceFrame) return; - const schedule = window.requestAnimationFrame || ((callback) => setTimeout(callback)); - this.scheduledResourceFrame = schedule((() => { - this.scheduledResourceFrame = null; - const adapter = getDetailResourceAdapter(); - if (!adapter) return; - this.placeOwnedSlots(); - adapter.sortSelect.length && (adapter.sortSelect.addClass("jhs-select-source"), JhsSelect.enhance(adapter.controller), JhsSelect.refresh(adapter.sortSelect)); - void jhsEventBus.emit("magnet-items-updated", { site: adapter.site, resourceRoot: adapter.resourceRoot[0], rows: adapter.rows() }, { broadcast: false }); + hookOldSearch() { + const e2 = document.querySelector(".search-image"); + if (!e2) return; + const t2 = e2.cloneNode(true); + e2.parentNode.replaceChild(t2, e2), $("#button-search-image").attr("data-tooltip", "以图识图"), $(".search-image").on("click", ((e3) => { + this.getDependency("SearchByImagePlugin").open(); })); } + margeNav() { + $('a[href*="/feedbacks/new"]').remove(), $('a[href*="theporndude.com"]').remove(), $('a.navbar-link[href="/makers"]').parent().after('\n \n '); + } + toggleOtherNavItem() { + let e2 = $("#search-box"), t2 = $("#search-bar-container"); + $(window).width() < 1600 && $(window).width() > 1023 && (e2.hide(), t2.show()), $(window).width() > 1600 && (e2.show(), t2.hide()); + } }; - __name(_DetailWorkspacePlugin, "DetailWorkspacePlugin"); - var DetailWorkspacePlugin = _DetailWorkspacePlugin; - function createFc2DetailShell({ carNum = "", source = "fc2", mode = "dialog" } = {}) { - const workspace = $('
    ').attr({ - "data-jhs-fc2-source": source, - "data-jhs-fc2-mode": mode, - "data-jhs-car-num": normalizeCarNum(carNum) || "" - }); - const definitions = [["summary", "影片概览"], ["gallery", "预览与剧照"], ["resources", "资源"], ["reviews", "评论"], ["related", "相关清单"]]; - definitions.forEach((([name, title]) => { - const section = $('
    ').attr("data-jhs-section", name); - const header = $('
    '), heading = $("

    ").text(title), actions = $('
    ').attr("data-jhs-section-actions", name); - section.append(header.append(heading, actions), $('
    ').attr("data-jhs-slot", name)), workspace.append(section); - })); - return workspace; - } - __name(createFc2DetailShell, "createFc2DetailShell"); - function createFc2DetailContext(root, options = {}) { - const workspace = $(root).is(".jhs-fc2-workspace") ? $(root) : $(root).find(".jhs-fc2-workspace").first(); - let destroyed = false; - const namespace = `.jhsFc2Detail${Date.now()}${Math.random().toString(36).slice(2)}`, observers = /* @__PURE__ */ new Set(); - const context = { - ...options, - root: workspace, - workspace, - namespace, - observers, - getSlot: /* @__PURE__ */ __name((name) => workspace.find(`[data-jhs-slot="${name}"]`).first(), "getSlot"), - getSection: /* @__PURE__ */ __name((name) => workspace.find(`[data-jhs-section="${name}"]`).first(), "getSection"), - isAlive: /* @__PURE__ */ __name(() => !destroyed && workspace[0]?.isConnected !== false, "isAlive"), - addObserver(observer) { - observer && observers.add(observer); - return observer; - }, - destroy() { - if (destroyed) return; - destroyed = true, workspace.off(namespace).find("*").off(namespace), observers.forEach(((observer) => observer.disconnect?.())), observers.clear(), workspace.removeData("jhsFc2Context"); - } - }; - workspace.data("jhsFc2Context", context); - return context; - } - __name(createFc2DetailContext, "createFc2DetailContext"); - var _CompatibilityEnhancementsPlugin = class _CompatibilityEnhancementsPlugin extends BasePlugin { - getName() { - return "CompatibilityEnhancementsPlugin"; + __name(_NavBarPlugin, "NavBarPlugin"); + var NavBarPlugin = _NavBarPlugin; + + // src/plugins/status/want-and-watched-videos.js + var _WantAndWatchedVideosPlugin = class _WantAndWatchedVideosPlugin extends BasePlugin { + constructor() { + super(...arguments), i(this, "flag", null); } - async initCss() { - if (!siteContext.isJavDB) return ""; - return ``; + getName() { + return "WantAndWatchedVideosPlugin"; } async handle() { - await this.decorateActresses(); - $(document).off("actress-state-changed.jhsActress").on("actress-state-changed.jhsActress", (async () => { - $(".jhs-actress-state-container").remove(); - await this.decorateActresses(); + window.location.href.includes("/want_watch_videos") && ($("h3").append(''), $("#wantWatchBtn").on("click", ((e2) => { + this.flag = "favorite", this.importWantWatchVideos(e2, "是否将想看的影片导入到 JHS 收藏?"); + }))), window.location.href.includes("/watched_videos") && ($("h3").append(''), $("#wantWatchBtn").on("click", ((e2) => { + this.flag = "watched", this.importWantWatchVideos(e2, "是否将看过的影片导入到 JHS 已观看?"); + }))); + } + importWantWatchVideos(e2, t2) { + utils.q(null, `${t2}
    执行此功能前请记得备份数据`, (async () => { + let e3 = loading(); + try { + const result = await this.parseMovieList(); + show.ok(`导入完成:成功 ${result.imported},失败 ${result.failed},共 ${result.pages} 页`); + } catch (t3) { + clog.error(t3), show.error(`导入失败:${t3.message || t3}`); + } finally { + e3.close(); + } })); - if (isDetailPage) await this.addRemoveRecord(); - this.linkCommentImages(); } - async addRemoveRecord() { - const carNum = this.getPageInfo().carNum; - if (!carNum) return; - if (!(await storageManager.getCarList()).some(((item) => item.carNum === carNum))) return; - const button = $(''); - $(".jhs-detail-btn-row,.movie-info-container,.container .info").first().append(button); - button.on("click", ((event) => utils.q(event, `确定移除 ${carNum} 的鉴定记录?`, (async () => { - await stateService.remove(carNum); - button.remove(); - this.getBean("ListPagePlugin")?.showCarNumBox?.(carNum); - show.ok("鉴定记录已移除"); - })))); + async parseMovieList(e2 = null, result = { imported: 0, failed: 0, pages: 0 }) { + let t2, n2; + e2 ? (t2 = e2.find(this.getSelector().itemSelector), n2 = e2.find(".pagination-next").attr("href")) : (t2 = $(this.getSelector().itemSelector), n2 = $(".pagination-next").attr("href")); + result.pages++, show.info(`正在导入第 ${result.pages} 页`); + for (const i2 of t2) { + const e3 = $(i2), t3 = e3.find("a").attr("href"), n3 = e3.find(".video-title strong").text().trim(), s2 = e3.find(".meta").text().trim(); + if (t3 && n3) try { + this.flag && await stateService.patch(n3, { [this.flag]: true }, { type: "javdb-list-import", record: { carNum: n3, url: t3, names: "", publishTime: s2 } }), result.imported++; + } catch (a2) { + result.failed++, clog.error(`保存失败 [${n3}]:`, a2); + } + } + if (!n2) return result; + await utils.sleep(1e3); + const html = await gmHttp.get(new URL(n2, window.location.href).href), nextPage = utils.htmlTo$dom(html); + return this.parseMovieList(nextPage, result); } - async decorateActresses() { - const favorites = new Set((await storageManager.getFavoriteActressList()).map(((item) => String(item.starId)))); - const blacklist = new Set((await storageManager.getBlacklist()).map(((item) => String(item.starId || item.id)))); - this.decorateCurrentActressProfile(favorites, blacklist); - this.decorateActressCards(favorites, blacklist); + }; + __name(_WantAndWatchedVideosPlugin, "WantAndWatchedVideosPlugin"); + var WantAndWatchedVideosPlugin = _WantAndWatchedVideosPlugin; + + // src/plugins/subtitle/subtitle-cat.js + var _SubTitleCatPlugin = class _SubTitleCatPlugin extends BasePlugin { + getName() { + return "SubTitleCatPlugin"; } - decorateCurrentActressProfile(favorites, blacklist) { - const match = window.location.pathname.match(/^\/(?:actors|star)\/([^/?#]+)\/?$/); - if (!match) return; - const host = $(".actor-section-name,.star-name,h1.title").first(); - if (!host.length) return; - this.renderActressState(host, decodeURIComponent(match[1]), favorites, blacklist, "jhs-actress-profile-state"); + handle() { + $(".t-banner-inner").hide(), $("#navbar").hide(); + let e2 = new URLSearchParams(window.location.search).get("search").toLowerCase(), t2 = $(".sub-table tr td a").toArray(), n2 = 0; + t2.forEach(((t3) => { + let a3 = $(t3); + a3.text().toLowerCase().includes(e2) ? n2++ : a3.parent().parent().hide(); + })), 0 === n2 && show.error("该番号无字幕!"); + const a2 = $(".sec-title"), i2 = a2.html().replace(/^\d+/, n2); + a2.html(i2); } - decorateActressCards(favorites, blacklist) { - $(".actor-box a[href], .actress-card a[href], [data-actress-card] a[href]").each(((index, element) => { - const identity = getActressIdentityFromLink(element); - if (!identity) return; - const card = $(element).closest(".actor-box,.actress-card,[data-actress-card]"); - this.renderActressState(card, identity.starId, favorites, blacklist, "jhs-actress-card-state"); - })); + }; + __name(_SubTitleCatPlugin, "SubTitleCatPlugin"); + var SubTitleCatPlugin = _SubTitleCatPlugin; + + // src/plugins/translate/translate.js + var _TranslatePlugin = class _TranslatePlugin extends BasePlugin { + getName() { + return "TranslatePlugin"; } - renderActressState(host, starId, favorites, blacklist, className) { - if (host.children(".jhs-actress-state-container").length) return; - const container = $(``); - favorites.has(starId) && container.append('已关注'); - blacklist.has(starId) && container.append('已拉黑'); - container.children().length && host.append(container); + async initCss() { + return "\n "; } - linkCommentImages() { - const images = $(".preview-images img,#sample-waterfall img,.movie-gallery img"); - if (!images.length) return; - $(".review-content").each(((index, element) => this.linkCommentImageTextNodes(element, images.length))); - $(document).off("click.jhsCommentImage", ".jhs-comment-image-link").on("click.jhsCommentImage", ".jhs-comment-image-link", ((event) => { - event.preventDefault(); - const image = images.eq(Number($(event.currentTarget).data("image-index"))); - image.length && showImageViewer(image[0]); - })); + handle() { + isDetailPage && this.translate(); } - linkCommentImageTextNodes(element, imageCount) { - const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT), nodes = []; - while (walker.nextNode()) if (!$(walker.currentNode.parentElement).closest("a,button,code,pre,textarea,.jhs-comment-image-link").length && /(?:图|圖片|图片)\s*[一二三四五六七八九十\d]+/i.test(walker.currentNode.nodeValue || "")) nodes.push(walker.currentNode); - nodes.forEach(((textNode) => { - const text = textNode.nodeValue || "", pattern = /(?:图|圖片|图片)\s*([一二三四五六七八九十\d]+)/gi, fragment = document.createDocumentFragment(); - let cursor = 0, match; - while (match = pattern.exec(text)) { - match.index > cursor && fragment.append(document.createTextNode(text.slice(cursor, match.index))); - const chinese = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }, index = (chinese[match[1]] || Number(match[1])) - 1; - if (index >= 0 && index < imageCount) { - const link = document.createElement("a"); - link.href = "#"; - link.className = "jhs-comment-image-link"; - link.dataset.imageIndex = String(index); - link.textContent = match[0]; - fragment.append(link); - } else fragment.append(document.createTextNode(match[0])); - cursor = match.index + match[0].length; - } - cursor < text.length && fragment.append(document.createTextNode(text.slice(cursor))); - textNode.replaceWith(fragment); - })); + async translate(e2, t2 = true, options = {}) { + if (await storageManager.getSetting("translateTitle", _) !== _) return; + l && (t2 = false); + const root = options.root ? $(options.root) : $(document); + let n2 = root.find(".origin-title").first(); + if (n2.length || (n2 = root.find(".current-title").first()), n2.length || (n2 = root.find("h3").first()), !n2.length) return; + const a2 = n2.text().trim(); + if (!a2) return void show.error("获取标题失败, 无法进行翻译"); + let i2 = n2.nextAll(".translated-title").first(); + i2.length || (i2 = $('
    ').insertAfter(n2)), i2.removeClass("is-error").text("翻译中..."); + e2 || (e2 = this.getPageInfo().carNum); + const s2 = "string" == typeof e2 ? e2.trim() : "", o2 = s2 && "undefined" !== s2 ? s2 : a2; + let r2 = {}; + try { + const e3 = localStorage.getItem("jhs_translate"); + e3 && (r2 = JSON.parse(e3) || {}); + } catch (l2) { + clog.warn("翻译缓存无法解析,已忽略旧缓存", l2); + } + if (r2[o2]) return void i2.text(r2[o2]); + try { + const e3 = await _e(a2, "ja", "zh-CN"); + if (!n2[0]?.isConnected) return; + i2.text(e3), r2[o2] = e3, localStorage.setItem("jhs_translate", JSON.stringify(r2)); + } catch (l2) { + clog.error("翻译失败:", l2), i2.addClass("is-error").text(`翻译失败: ${l2.message || String(l2)}`); + } } }; - __name(_CompatibilityEnhancementsPlugin, "CompatibilityEnhancementsPlugin"); - var CompatibilityEnhancementsPlugin = _CompatibilityEnhancementsPlugin; - function getActressIdentityFromLink(element) { - const link = $(element); - if (link.closest('.toolbar,.tabs,.buttons,.pagination,.filter,.filters,nav,header,[role="tablist"]').length) return null; - const url = new URL(link.attr("href"), window.location.href); - if (url.search || url.hash) return null; - const match = url.pathname.match(/^\/(?:actors|star)\/([^/]+)\/?$/); - return match ? { starId: decodeURIComponent(match[1]), url: url.href } : null; - } - __name(getActressIdentityFromLink, "getActressIdentityFromLink"); + __name(_TranslatePlugin, "TranslatePlugin"); + var TranslatePlugin = _TranslatePlugin; + + // src/plugins/registry.js var DEFAULT_JAVDB_PLUGINS = [ ListPagePlugin, AutoPagePlugin, @@ -16172,54 +15484,1319 @@ ${error.stack}` : ""); plugins: [SubTitleCatPlugin] } ]; - function registerPluginGroup(pluginManager2, plugins) { - plugins.forEach(((pluginClass) => pluginManager2.register(pluginClass))); + function registerPluginGroup(pluginManager, plugins) { + plugins.forEach(((pluginClass) => pluginManager.register(pluginClass))); } __name(registerPluginGroup, "registerPluginGroup"); - function registerSitePlugins(pluginManager2, locationLike = window.location) { + function registerSitePlugins(pluginManager, locationLike = window.location) { + pluginManager.setDependencyDeclarations(LEGACY_PLUGIN_DEPENDENCY_MAP); const context = detectSite(locationLike); DEFAULT_SHARED_PLUGIN_RULES.forEach(((rule) => { - rule.shouldRegister(context) && registerPluginGroup(pluginManager2, rule.plugins); + rule.shouldRegister(context) && registerPluginGroup(pluginManager, rule.plugins); })); - context.isJavDB && registerPluginGroup(pluginManager2, DEFAULT_JAVDB_PLUGINS); - context.isJavBus && registerPluginGroup(pluginManager2, DEFAULT_JAVBUS_PLUGINS); + context.isJavDB && registerPluginGroup(pluginManager, DEFAULT_JAVDB_PLUGINS); + context.isJavBus && registerPluginGroup(pluginManager, DEFAULT_JAVBUS_PLUGINS); } __name(registerSitePlugins, "registerSitePlugins"); - var originalLayerClose = layer.close; - layer.close = function(e2) { - const t2 = originalLayerClose.call(this, e2); - return (function(e3 = 10) { - setTimeout((() => { - const e4 = document.querySelectorAll(".layui-layer-shade").length; - document.documentElement.style.overflow = e4 > 0 ? "hidden" : ""; - }), e3); - })(), t2; + + // src/app/compatibility-facade.js + function attachCompatibilityFacade(values, target) { + for (const name of ["pluginManager", "utils", "gmHttp", "storageManager", "stateService", "jhsEventBus", "clog", "show", "loading"]) { + if (values[name] == null) throw new Error(`Compatibility facade is missing ${name}`); + target[name] = values[name]; + } + return Object.freeze(Object.fromEntries(Object.keys(values).map((name) => [name, target[name]]))); + } + __name(attachCompatibilityFacade, "attachCompatibilityFacade"); + + // src/contracts/ports.js + function assertPort(implementation, name, methods) { + if (!implementation || typeof implementation !== "object") throw new TypeError(`${name} implementation is required`); + for (const method of methods) { + if (typeof /** @type {Record} */ + /** @type {unknown} */ + implementation[method] !== "function") throw new TypeError(`${name}.${method} is required`); + } + return implementation; + } + __name(assertPort, "assertPort"); + var PORT_METHODS = Object.freeze({ + navigation: ["open", "assign", "replace"], + http: ["request"], + storage: ["get", "set", "remove"], + dialog: ["open", "close"], + style: ["register", "remove"] + }); + + // src/platform/browser/browser-navigation-adapter.js + var _BrowserNavigationAdapter = class _BrowserNavigationAdapter { + /** @param {{location?: Location, open?: typeof window.open}} [runtime] */ + constructor(runtime = {}) { + this.location = runtime.location ?? window.location; + this.openWindow = runtime.open ?? window.open.bind(window); + assertPort(this, "NavigationPort", PORT_METHODS.navigation); + } + /** @param {string} url @param {{newTab?: boolean}} [options] */ + open(url, options = {}) { + return options.newTab ? this.openWindow(url, "_blank", "noopener") : this.assign(url); + } + /** @param {string} url */ + assign(url) { + this.location.assign(url); + } + /** @param {string} url */ + replace(url) { + this.location.replace(url); + } }; - var originalLayerOpen = layer.open; - layer.open = function(e2) { - const t2 = (e2 = e2 || {}).success; - return e2.success = function(e3, n2) { - "function" == typeof t2 && t2.call(this, e3, n2), utils.setupEscClose(n2); - }, originalLayerOpen.call(this, e2); - }, utils.importResource("https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/layer.min.css"), utils.importResource("https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.css"), utils.importResource("https://cdn.jsdelivr.net/npm/viewerjs@1.11.1/dist/viewer.min.css"), utils.importResource("https://cdn.jsdelivr.net/npm/tabulator-tables@6.3.1/dist/css/tabulator_semanticui.min.css"); - var pluginManager = (function() { - const manager = new PluginManager(); - unsafeWindow.pluginManager = manager; - registerSitePlugins(manager); - return manager; - })(); - (async function() { - window.isDetailPage = (function() { - let e3 = window.location.href; - return r ? e3.split("?")[0].includes("/v/") : !!l && $("#magnet-table").length > 0; - })(), window.isListPage = r ? isListPage(window.location, $(".movie-list").length > 0) : !!l && $(".masonry > div .item").length > 0; - const e2 = (async () => { - await runDataMigrations(storageManager), await stateService.recoverPendingTransaction(); - })(); - await e2, await Promise.all([pluginManager.processCss(), applyTheme()]), r && /(^|;)\s*locale\s*=\s*en\s*($|;)/i.test(document.cookie) && show.error("请切换到中文语言下才可正常使用本脚本", { - duration: -1 - }), await pluginManager.processPlugins(); - })().catch(((e2) => { - console.error("[JHS] bootstrap failed:", e2), show.error(e2?.message || "JHS 启动失败", { duration: -1 }); - })); + __name(_BrowserNavigationAdapter, "BrowserNavigationAdapter"); + var BrowserNavigationAdapter = _BrowserNavigationAdapter; + + // src/platform/browser/browser-style-adapter.js + var _BrowserStyleAdapter = class _BrowserStyleAdapter { + /** @param {Document} [documentRuntime] */ + constructor(documentRuntime = document) { + this.document = documentRuntime; + } + /** @param {string} id @param {string} css */ + register(id, css) { + if (this.document.getElementById(id)) throw new Error(`Duplicate style: ${id}`); + const style = this.document.createElement("style"); + style.id = id; + style.textContent = css; + this.document.head.append(style); + return () => style.remove(); + } + /** @param {string} id */ + remove(id) { + this.document.getElementById(id)?.remove(); + } + }; + __name(_BrowserStyleAdapter, "BrowserStyleAdapter"); + var BrowserStyleAdapter = _BrowserStyleAdapter; + + // src/platform/userscript/indexeddb-storage-adapter.js + var _IndexedDbStorageAdapter = class _IndexedDbStorageAdapter { + /** @param {{getItem: (key: string) => Promise, setItem: (key: string, value: unknown) => Promise, removeItem: (key: string) => Promise}} forage */ + constructor(forage) { + this.forage = forage; + } + /** @param {string} key */ + get(key) { + return this.forage.getItem(key); + } + /** @param {string} key @param {unknown} value */ + async set(key, value) { + await this.forage.setItem(key, value); + } + /** @param {string} key */ + async remove(key) { + await this.forage.removeItem(key); + } + }; + __name(_IndexedDbStorageAdapter, "IndexedDbStorageAdapter"); + var IndexedDbStorageAdapter = _IndexedDbStorageAdapter; + + // src/platform/userscript/layer-dialog-adapter.js + var _LayerDialogAdapter = class _LayerDialogAdapter { + /** @param {{open: (options: Record) => number, close: (id: number) => void}} layerRuntime */ + constructor(layerRuntime) { + this.layer = layerRuntime; + } + /** @param {Record} options */ + open(options) { + return this.layer.open(options); + } + /** @param {number} id */ + close(id) { + this.layer.close(id); + } + }; + __name(_LayerDialogAdapter, "LayerDialogAdapter"); + var LayerDialogAdapter = _LayerDialogAdapter; + + // src/platform/userscript/userscript-http-adapter.js + var _UserscriptHttpAdapter = class _UserscriptHttpAdapter { + /** @param {(options: Record) => {abort?: () => void} | void} requestImplementation */ + constructor(requestImplementation) { + if (typeof requestImplementation !== "function") throw new TypeError("GM_xmlhttpRequest is required"); + this.requestImplementation = requestImplementation; + } + /** @param {{url: string, method?: string, headers?: Record, body?: unknown, responseType?: string, timeout?: number, signal?: AbortSignal}} options */ + request(options) { + return new Promise((resolve, reject) => { + let settled = false; + let handle; + const finish = /* @__PURE__ */ __name((callback, value) => { + if (settled) return; + settled = true; + options.signal?.removeEventListener("abort", onAbort); + callback(value); + }, "finish"); + const onAbort = /* @__PURE__ */ __name(() => { + handle?.abort?.(); + finish(reject, new JhsError("ABORTED", "网络请求已取消", { source: "UserscriptHttpAdapter" })); + }, "onAbort"); + handle = this.requestImplementation({ + method: options.method ?? "GET", + url: options.url, + headers: options.headers, + data: options.body, + responseType: options.responseType, + timeout: options.timeout, + onload: /* @__PURE__ */ __name((response) => finish(resolve, { + status: response.status, + data: response.response ?? response.responseText, + responseText: response.responseText ?? "", + finalUrl: response.finalUrl || options.url, + responseHeaders: response.responseHeaders ?? "" + }), "onload"), + onerror: /* @__PURE__ */ __name((cause) => finish(reject, new JhsError("NETWORK_ERROR", "网络请求失败", { source: "UserscriptHttpAdapter", cause, retryable: true })), "onerror"), + ontimeout: /* @__PURE__ */ __name(() => finish(reject, new JhsError("TIMEOUT", "网络请求超时", { source: "UserscriptHttpAdapter", retryable: true })), "ontimeout"), + onabort: /* @__PURE__ */ __name(() => finish(reject, new JhsError("ABORTED", "网络请求已取消", { source: "UserscriptHttpAdapter" })), "onabort") + }); + if (options.signal?.aborted) onAbort(); + else options.signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + }; + __name(_UserscriptHttpAdapter, "UserscriptHttpAdapter"); + var UserscriptHttpAdapter = _UserscriptHttpAdapter; + + // src/services/cache-service.js + var _CacheService = class _CacheService { + /** @param {{namespaceVersion?: number, diagnostics?: import("./diagnostics-service.js").DiagnosticsService}} [options] */ + constructor(options = {}) { + this.namespaceVersion = options.namespaceVersion ?? 1; + this.diagnostics = options.diagnostics ?? null; + this.publicCache = /* @__PURE__ */ new Map(); + this.sessionCaches = /* @__PURE__ */ new Map(); + } + /** @param {string} scope @param {string} sessionScopeId */ + storeFor(scope, sessionScopeId = "") { + if (scope === "public") return this.publicCache; + if (scope === "session") { + if (!sessionScopeId) throw new TypeError("sessionScopeId is required for session cache"); + let cache = this.sessionCaches.get(sessionScopeId); + if (!cache) this.sessionCaches.set(sessionScopeId, cache = /* @__PURE__ */ new Map()); + return cache; + } + return null; + } + /** @param {string} key @param {{scope: string, sessionScopeId?: string}} policy */ + get(key, policy) { + const store = this.storeFor(policy.scope, policy.sessionScopeId); + const entry = store?.get(`${this.namespaceVersion}:${key}`); + if (!entry || entry.expiresAt <= Date.now()) { + if (entry) store?.delete(`${this.namespaceVersion}:${key}`); + this.diagnostics?.recordCache(false); + return { hit: false, value: void 0, negative: false }; + } + this.diagnostics?.recordCache(true); + return { hit: true, value: entry.value, negative: entry.negative }; + } + /** @param {string} key @param {unknown} value @param {{scope: string, sessionScopeId?: string, ttlMs: number, negative?: boolean}} policy */ + set(key, value, policy) { + const store = this.storeFor(policy.scope, policy.sessionScopeId); + if (!store) return; + store.set(`${this.namespaceVersion}:${key}`, { value, expiresAt: Date.now() + Math.max(0, policy.ttlMs), negative: policy.negative ?? false }); + } + invalidateNamespace() { + this.publicCache.clear(); + this.sessionCaches.clear(); + } + }; + __name(_CacheService, "CacheService"); + var CacheService = _CacheService; + + // src/services/diagnostics-service.js + var _DiagnosticsService = class _DiagnosticsService { + constructor() { + this.startedAt = performance.now(); + this.activeFeatures = /* @__PURE__ */ new Set(); + this.activeContributions = /* @__PURE__ */ new Set(); + this.startupTimings = /* @__PURE__ */ new Map(); + this.scopes = /* @__PURE__ */ new Map(); + this.requestStats = { consumers: 0, underlying: 0 }; + this.cacheStats = { hits: 0, misses: 0 }; + this.providerHealth = /* @__PURE__ */ new Map(); + this.errors = []; + this.browserMetadata = null; + } + /** @param {string} id @param {number} durationMs */ + recordStartup(id, durationMs) { + this.startupTimings.set(id, durationMs); + } + /** @param {string} id @param {boolean} active */ + setFeature(id, active) { + active ? this.activeFeatures.add(id) : this.activeFeatures.delete(id); + } + /** @param {string} id @param {boolean} active */ + setContribution(id, active) { + active ? this.activeContributions.add(id) : this.activeContributions.delete(id); + } + /** @param {{id: string}} snapshot */ + updateScope(snapshot) { + this.scopes.set(snapshot.id, Object.freeze({ ...snapshot })); + } + /** @param {number} consumers @param {number} underlying */ + updateRequests(consumers, underlying) { + this.requestStats = { consumers, underlying }; + } + /** @param {boolean} hit */ + recordCache(hit) { + hit ? this.cacheStats.hits += 1 : this.cacheStats.misses += 1; + } + /** @param {string} id @param {Record} health */ + updateProvider(id, health) { + this.providerHealth.set(id, Object.freeze({ ...health })); + } + /** @param {unknown} error */ + recordError(error) { + const value = error && typeof error === "object" && "toJSON" in error && typeof error.toJSON === "function" ? error.toJSON() : { message: error instanceof Error ? error.message : String(error) }; + this.errors.push({ ...value, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); + if (this.errors.length > 100) this.errors.shift(); + } + /** @param {Record} metadata */ + setBrowserMetadata(metadata) { + this.browserMetadata = Object.freeze({ ...metadata }); + } + exportSnapshot() { + return Object.freeze({ + activeFeatures: [...this.activeFeatures], + activeContributions: [...this.activeContributions], + startupTimings: Object.fromEntries(this.startupTimings), + activeScopes: [...this.scopes.values()], + globalListeners: 0, + observers: [...this.scopes.values()].reduce((sum, scope) => sum + Number(scope.observers ?? 0), 0), + requestConsumers: this.requestStats.consumers, + underlyingRequests: this.requestStats.underlying, + cacheHits: this.cacheStats.hits, + cacheMisses: this.cacheStats.misses, + providerHealth: Object.fromEntries(this.providerHealth), + errors: this.errors.map((error) => ({ ...error })), + browser: this.browserMetadata, + uptimeMs: performance.now() - this.startedAt + }); + } + }; + __name(_DiagnosticsService, "DiagnosticsService"); + var DiagnosticsService = _DiagnosticsService; + + // src/services/dialog-service.js + var _DialogService = class _DialogService { + /** @param {any} dialogPort */ + constructor(dialogPort) { + this.port = assertPort(dialogPort, "DialogPort", PORT_METHODS.dialog); + } + /** @param {Record} options */ + open(options) { + return this.port.open(options); + } + /** @param {number} id */ + close(id) { + return this.port.close(id); + } + }; + __name(_DialogService, "DialogService"); + var DialogService = _DialogService; + + // src/services/external-url-policy.js + var PRIVATE_IPV4 = [ + /^10\./, + /^127\./, + /^169\.254\./, + /^192\.168\./, + /^172\.(?:1[6-9]|2\d|3[01])\./, + /^0\./ + ]; + function isPrivateLiteral(hostname) { + const value = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (value === "localhost" || value.endsWith(".localhost") || value === "::1") return true; + if (value.includes(":")) return value.startsWith("fc") || value.startsWith("fd") || value.startsWith("fe8") || value.startsWith("fe9") || value.startsWith("fea") || value.startsWith("feb"); + return PRIVATE_IPV4.some((pattern) => pattern.test(value)); + } + __name(isPrivateLiteral, "isPrivateLiteral"); + var _ExternalUrlPolicy = class _ExternalUrlPolicy { + /** @param {{localOrigins?: string[]}} [options] */ + constructor(options = {}) { + this.localOrigins = new Set((options.localOrigins ?? []).map((origin) => new URL(origin).origin)); + } + /** @param {string} origin */ + authorizeLocalOrigin(origin) { + this.localOrigins.add(new URL(origin).origin); + } + /** @param {string | URL} input @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ + assertAllowed(input, policy) { + let url; + try { + url = input instanceof URL ? input : new URL(input); + } catch (cause) { + throw new JhsError("INVALID_URL", "外部地址无效", { source: "ExternalUrlPolicy", cause }); + } + if (!(/* @__PURE__ */ new Set(["http:", "https:"])).has(url.protocol)) throw new JhsError("INVALID_URL", "仅允许 HTTP/HTTPS 地址", { source: "ExternalUrlPolicy" }); + if (policy.trustClass === "builtin-public") { + if (url.protocol !== "https:" || !(policy.hosts ?? []).some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`))) { + throw new JhsError("INVALID_URL", "地址不在 Integration manifest 允许范围", { source: "ExternalUrlPolicy" }); + } + } else if (policy.trustClass === "custom-public") { + if (url.protocol !== "https:" || isPrivateLiteral(url.hostname)) throw new JhsError("INVALID_URL", "自定义公开来源必须使用公网 HTTPS", { source: "ExternalUrlPolicy" }); + } else if (policy.trustClass === "user-local") { + const expectedOrigin = policy.expectedOrigin ? new URL(policy.expectedOrigin).origin : url.origin; + if (!this.localOrigins.has(expectedOrigin) || url.origin !== expectedOrigin) throw new JhsError("INVALID_URL", "本地来源未获得精确 origin 授权", { source: "ExternalUrlPolicy" }); + } else throw new JhsError("INVALID_URL", "未知 URL trust class", { source: "ExternalUrlPolicy" }); + return url; + } + /** @param {string | URL} finalUrl @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ + assertFinalUrl(finalUrl, policy) { + return this.assertAllowed(finalUrl, policy); + } + }; + __name(_ExternalUrlPolicy, "ExternalUrlPolicy"); + var ExternalUrlPolicy = _ExternalUrlPolicy; + + // src/services/http-service.js + function stableSerialize(value) { + if (value == null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`; + const object = ( + /** @type {Record} */ + value + ); + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(object[key])}`).join(",")}}`; + } + __name(stableSerialize, "stableSerialize"); + async function sha256(value) { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((item) => item.toString(16).padStart(2, "0")).join(""); + } + __name(sha256, "sha256"); + function canonicalizeUrl(input) { + const url = new URL(input); + const entries = [...url.searchParams.entries()].sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)); + url.search = ""; + for (const [key, value] of entries) url.searchParams.append(key, value); + url.hash = ""; + return url.href; + } + __name(canonicalizeUrl, "canonicalizeUrl"); + async function createRequestKey(options) { + const method = String(options.method ?? "GET").toUpperCase(); + const headers = Object.fromEntries(Object.entries(options.headers ?? {}).map(([key2, value]) => [key2.toLowerCase(), String(value)])); + const varyHeaders = [...new Set((options.varyHeaders ?? []).map((header) => String(header).toLowerCase()))].sort(); + const varyValues = Object.fromEntries(varyHeaders.map((header) => [header, headers[header] ?? ""])); + const key = { + providerId: String(options.providerId), + method, + canonicalUrl: canonicalizeUrl(options.url), + bodyHash: await sha256(stableSerialize(options.body ?? null)), + responseType: String(options.responseType ?? "text"), + cacheScope: String(options.cacheScope ?? "none"), + varyHeadersHash: await sha256(stableSerialize(varyValues)), + sessionScopeId: options.cacheScope === "session" ? String(options.sessionScopeId ?? "") : "" + }; + if (key.cacheScope === "session" && !key.sessionScopeId) throw new TypeError("sessionScopeId is required for session requests"); + return Object.freeze(key); + } + __name(createRequestKey, "createRequestKey"); + var _HttpService = class _HttpService { + /** @param {{request: (options: any) => Promise}} port @param {import("./external-url-policy.js").ExternalUrlPolicy} urlPolicy @param {{diagnostics?: import("./diagnostics-service.js").DiagnosticsService, cache?: import("./cache-service.js").CacheService}} [options] */ + constructor(port, urlPolicy, options = {}) { + this.port = port; + this.urlPolicy = urlPolicy; + this.diagnostics = options.diagnostics ?? null; + this.cache = options.cache ?? null; + this.inflight = /* @__PURE__ */ new Map(); + } + /** @param {Record} options @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ + async request(options, scope) { + const method = String(options.method ?? "GET").toUpperCase(); + const cacheScope = options.cacheScope ?? "none"; + if (method !== "GET" && cacheScope !== "none") throw new TypeError("Mutation requests cannot use generic cache/dedupe"); + const urlPolicy = ( + /** @type {{trustClass: string, hosts?: string[], expectedOrigin?: string}} */ + options.urlPolicy + ); + const initialUrl = this.urlPolicy.assertAllowed(options.url, urlPolicy); + const requestKey = await createRequestKey({ ...options, method, url: initialUrl.href, cacheScope }); + const serializedKey = stableSerialize(requestKey); + const cachePolicy = { scope: cacheScope, sessionScopeId: options.sessionScopeId }; + if (method === "GET" && cacheScope !== "none" && this.cache) { + const cached = this.cache.get(serializedKey, cachePolicy); + if (cached.hit) return cached.value; + } + if (method !== "GET" || cacheScope === "none") return this.executeUnderlying({ ...options, method, url: initialUrl.href }, urlPolicy); + let entry = this.inflight.get(serializedKey); + if (!entry) { + const controller = new AbortController(); + entry = { controller, consumers: 0, promise: Promise.resolve() }; + entry.promise = this.executeUnderlying({ ...options, method, url: initialUrl.href, signal: controller.signal }, urlPolicy).then((response) => { + this.cache?.set(serializedKey, response, { ...cachePolicy, ttlMs: options.ttlMs ?? 0, negative: options.negative === true }); + return response; + }).finally(() => { + this.inflight.delete(serializedKey); + this.updateDiagnostics(); + }); + this.inflight.set(serializedKey, entry); + } + return this.consume(entry, scope); + } + /** @param {Record} options @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} urlPolicy */ + async executeUnderlying(options, urlPolicy) { + try { + const response = await this.port.request(options); + this.urlPolicy.assertFinalUrl(response.finalUrl || options.url, urlPolicy); + if (response.status >= 400) throw new JhsError(response.status === 404 ? "NOT_FOUND" : "NETWORK_ERROR", `HTTP ${response.status}`, { source: options.providerId, retryable: response.status >= 500 }); + return response; + } catch (error) { + const normalized = JhsError.from(error, options.providerId); + this.diagnostics?.recordError(normalized); + throw normalized; + } + } + /** @param {{promise: Promise, controller: AbortController, consumers: number}} entry @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ + consume(entry, scope) { + entry.consumers += 1; + let released = false; + const consumer = { release: /* @__PURE__ */ __name(() => { + if (released) return; + released = true; + entry.consumers -= 1; + if (entry.consumers === 0 && !this.isSettled(entry)) entry.controller.abort(); + this.updateDiagnostics(); + }, "release") }; + const removeFromScope = scope?.ownRequestConsumer(consumer); + this.updateDiagnostics(); + let removeAbortListener = /* @__PURE__ */ __name(() => { + }, "removeAbortListener"); + const result = scope ? Promise.race([ + entry.promise, + new Promise((_2, reject) => { + const onAbort = /* @__PURE__ */ __name(() => reject(new JhsError("ABORTED", "LifecycleScope 已释放", { source: scope.id })), "onAbort"); + scope.signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = /* @__PURE__ */ __name(() => scope.signal.removeEventListener("abort", onAbort), "removeAbortListener"); + }) + ]) : entry.promise; + return result.finally(() => { + removeAbortListener(); + removeFromScope?.(); + consumer.release(); + }); + } + /** @param {{promise: Promise}} entry */ + isSettled(entry) { + return ![...this.inflight.values()].includes( + /** @type {any} */ + entry + ); + } + updateDiagnostics() { + const consumers = [...this.inflight.values()].reduce((sum, entry) => sum + entry.consumers, 0); + this.diagnostics?.updateRequests(consumers, this.inflight.size); + } + }; + __name(_HttpService, "HttpService"); + var HttpService = _HttpService; + + // src/services/navigation-service.js + var _NavigationService = class _NavigationService { + /** @param {any} navigationPort */ + constructor(navigationPort) { + this.port = assertPort(navigationPort, "NavigationPort", PORT_METHODS.navigation); + } + /** @param {string | URL} url @param {{newTab?: boolean}} [options] */ + open(url, options) { + return this.port.open(String(url), options); + } + /** @param {string | URL} url */ + assign(url) { + return this.port.assign(String(url)); + } + /** @param {string | URL} url */ + replace(url) { + return this.port.replace(String(url)); + } + }; + __name(_NavigationService, "NavigationService"); + var NavigationService = _NavigationService; + + // src/services/magnet-service.js + var _MagnetService = class _MagnetService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ + constructor(providers) { + this.providers = providers; + } + /** @param {Record} movieRef @param {Record} [context] */ + async search(movieRef, context = {}) { + const results = []; + for (const provider of await this.providers.getAvailable("magnet", context)) { + try { + const items = await provider.search(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (Array.isArray(items)) results.push(...items); + } catch (error) { + this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + return results; + } + }; + __name(_MagnetService, "MagnetService"); + var MagnetService = _MagnetService; + + // src/services/movie-identity-service.js + var _MovieIdentityService = class _MovieIdentityService { + /** @param {unknown} value */ + normalize(value) { + return normalizeMovieCarNum(value); + } + /** @param {...unknown} candidates */ + firstValid(...candidates) { + for (const candidate of candidates) { + const carNum = this.normalize(candidate); + if (carNum) return carNum; + } + return null; + } + }; + __name(_MovieIdentityService, "MovieIdentityService"); + var MovieIdentityService = _MovieIdentityService; + + // src/services/offline-service.js + var _OfflineService = class _OfflineService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ + constructor(providers) { + this.providers = providers; + } + /** @param {Record} resource @param {Record} [context] */ + async submit(resource, context = {}) { + const providers = await this.providers.getAvailable("offline", context); + const failures = []; + for (const provider of providers) { + try { + const result = await provider.submit(resource, context); + this.providers.updateHealth(provider.id, { ok: true }); + return result; + } catch (error) { + failures.push(error); + this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + throw new AggregateError(failures, providers.length ? "所有离线 Provider 均失败" : "没有可用的离线 Provider"); + } + }; + __name(_OfflineService, "OfflineService"); + var OfflineService = _OfflineService; + + // src/services/profile-service.js + var _ProfileService = class _ProfileService extends EventTarget { + /** @param {{windowRuntime?: Window, scope: import("../core/lifecycle-scope.js").LifecycleScope}} options */ + constructor(options) { + super(); + this.window = options.windowRuntime ?? window; + this.scope = options.scope; + this.coarseQuery = this.window.matchMedia("(any-pointer: coarse)"); + this.profile = this.calculate(); + this.started = false; + } + calculate() { + const width = this.window.innerWidth; + const shortSide = Math.min(this.window.innerWidth, this.window.innerHeight); + if (width <= 767 || this.coarseQuery.matches && shortSide <= 480) return "compact"; + if (width >= 1200) return "wide"; + return "regular"; + } + start() { + if (this.started) return; + this.started = true; + const update = /* @__PURE__ */ __name(() => { + const next = this.calculate(); + if (next === this.profile) return; + const previous = this.profile; + this.profile = next; + this.dispatchEvent(new CustomEvent("profile.changed", { detail: Object.freeze({ previous, profile: next }) })); + }, "update"); + this.scope.listen(this.window, "resize", update, { passive: true }); + this.scope.listen(this.window, "orientationchange", update, { passive: true }); + this.scope.listen(this.coarseQuery, "change", update); + } + current() { + return this.profile; + } + }; + __name(_ProfileService, "ProfileService"); + var ProfileService = _ProfileService; + + // src/services/related-service.js + var _RelatedService = class _RelatedService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { + this.integrations = integrations; + } + /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + async list(movieRef, options = {}) { + for (const manifest of this.integrations.list("movie.related")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.listRelated === "function") return adapter.listRelated(movieRef, options); + } + return []; + } + }; + __name(_RelatedService, "RelatedService"); + var RelatedService = _RelatedService; + + // src/services/review-service.js + var _ReviewService = class _ReviewService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { + this.integrations = integrations; + } + /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + async list(movieRef, options = {}) { + for (const manifest of this.integrations.list("movie.reviews")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.listReviews === "function") return adapter.listReviews(movieRef, options); + } + return []; + } + }; + __name(_ReviewService, "ReviewService"); + var ReviewService = _ReviewService; + + // src/services/screenshot-service.js + var _ScreenshotService = class _ScreenshotService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ + constructor(providers) { + this.providers = providers; + } + /** @param {Record} movieRef @param {Record} [context] */ + async resolve(movieRef, context = {}) { + for (const provider of await this.providers.getAvailable("screenshot", context)) { + try { + const result = await provider.resolve(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (result) return result; + } catch (error) { + this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + return null; + } + }; + __name(_ScreenshotService, "ScreenshotService"); + var ScreenshotService = _ScreenshotService; + + // src/services/settings-service.js + var _SettingsService = class _SettingsService extends EventTarget { + /** @param {{get: (key: string) => Promise, set: (key: string, value: unknown) => Promise}} storage @param {{validators?: Record boolean>}} [options] */ + constructor(storage, options = {}) { + super(); + this.storage = storage; + this.validators = options.validators ?? {}; + this.snapshotValue = Object.freeze({}); + } + /** @param {string} key */ + async load(key = "setting") { + const stored = await this.storage.get(key); + this.snapshotValue = Object.freeze(stored && typeof stored === "object" && !Array.isArray(stored) ? { ...stored } : {}); + return this.snapshotValue; + } + snapshot() { + return this.snapshotValue; + } + /** @param {string} name @param {unknown} value @param {string} [storageKey] */ + async set(name, value, storageKey = "setting") { + const validator = this.validators[name]; + if (validator && !validator(value)) throw new TypeError(`Invalid setting: ${name}`); + const next = Object.freeze({ ...this.snapshotValue, [name]: value }); + await this.storage.set(storageKey, next); + this.snapshotValue = next; + this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name, value, snapshot: next }) })); + return next; + } + }; + __name(_SettingsService, "SettingsService"); + var SettingsService = _SettingsService; + + // src/services/storage-service.js + var _StorageService = class _StorageService { + /** @param {any} storagePort */ + constructor(storagePort) { + this.port = assertPort(storagePort, "StoragePort", PORT_METHODS.storage); + } + /** @param {string} key */ + get(key) { + return this.port.get(key); + } + /** @param {string} key @param {unknown} value */ + set(key, value) { + return this.port.set(key, value); + } + /** @param {string} key */ + remove(key) { + return this.port.remove(key); + } + }; + __name(_StorageService, "StorageService"); + var StorageService = _StorageService; + + // src/services/style-registry.js + var _StyleRegistry = class _StyleRegistry { + /** @param {any} stylePort */ + constructor(stylePort) { + this.port = assertPort(stylePort, "StylePort", PORT_METHODS.style); + } + /** @param {string} id @param {string} css */ + register(id, css) { + return this.port.register(id, css); + } + /** @param {string} id */ + remove(id) { + return this.port.remove(id); + } + }; + __name(_StyleRegistry, "StyleRegistry"); + var StyleRegistry = _StyleRegistry; + + // src/app/command-registry.js + var _CommandRegistry = class _CommandRegistry { + constructor() { + this.owners = /* @__PURE__ */ new Map(); + this.handlers = /* @__PURE__ */ new Map(); + this.activateOwner = null; + } + /** @param {(featureId: string) => Promise} activator */ + setActivator(activator) { + this.activateOwner = activator; + } + /** @param {string} command @param {string} featureId */ + registerOwner(command, featureId) { + const owner = this.owners.get(command); + if (owner && owner !== featureId) throw new Error(`Duplicate command owner: ${command}`); + this.owners.set(command, featureId); + } + /** @param {string} command @param {(...args: any[]) => any} handler @param {string} featureId */ + registerHandler(command, handler, featureId) { + if (this.owners.get(command) !== featureId) throw new Error(`Feature ${featureId} does not own command ${command}`); + if (this.handlers.has(command)) throw new Error(`Duplicate command handler: ${command}`); + this.handlers.set(command, handler); + } + /** @param {string} command @param {...any} args */ + async execute(command, ...args) { + const owner = this.owners.get(command); + if (!owner) throw new Error(`Unknown command: ${command}`); + if (!this.handlers.has(command)) { + if (!this.activateOwner) throw new Error("Command activator is not configured"); + await this.activateOwner(owner); + } + const handler = this.handlers.get(command); + if (!handler) throw new Error(`Feature ${owner} did not register ${command}`); + return handler(...args); + } + }; + __name(_CommandRegistry, "CommandRegistry"); + var CommandRegistry = _CommandRegistry; + + // src/app/dependency-container.js + var _DependencyContainer = class _DependencyContainer { + /** @param {{recordError?: (error: unknown) => void} | null} [diagnostics] */ + constructor(diagnostics = null) { + this.values = /* @__PURE__ */ new Map(); + this.diagnostics = diagnostics; + } + /** @param {symbol} token @param {unknown} value */ + register(token, value) { + if (typeof token !== "symbol") throw new TypeError("Dependency token must be a symbol"); + if (this.values.has(token)) { + const error = new JhsError("DUPLICATE_TOKEN", `Duplicate dependency token: ${String(token)}`, { source: "DependencyContainer" }); + this.diagnostics?.recordError?.(error); + throw error; + } + this.values.set(token, value); + return this; + } + /** @param {readonly symbol[]} requiredTokens */ + resolveDeclared(requiredTokens) { + const dependencies = /* @__PURE__ */ Object.create(null); + for (const token of requiredTokens) { + if (!this.values.has(token)) { + const error = new JhsError("MISSING_DEPENDENCY", `Missing declared dependency: ${String(token)}`, { source: "DependencyContainer" }); + this.diagnostics?.recordError?.(error); + throw error; + } + Object.defineProperty(dependencies, token, { + value: this.values.get(token), + enumerable: true + }); + } + return Object.freeze(dependencies); + } + }; + __name(_DependencyContainer, "DependencyContainer"); + var DependencyContainer = _DependencyContainer; + + // src/app/feature-runtime.js + var _FeatureRuntime = class _FeatureRuntime { + /** @param {{container: import("./dependency-container.js").DependencyContainer, commands: import("./command-registry.js").CommandRegistry, diagnostics: import("../services/diagnostics-service.js").DiagnosticsService, disabled?: string[], site?: string, route?: string}} options */ + constructor(options) { + this.container = options.container; + this.commands = options.commands; + this.diagnostics = options.diagnostics; + this.disabled = new Set(migrateDisabledPlugins(options.disabled)); + this.site = options.site ?? "unknown"; + this.route = options.route ?? "unknown"; + this.manifests = /* @__PURE__ */ new Map(); + this.activations = /* @__PURE__ */ new Map(); + this.commands.setActivator((featureId) => this.activate(featureId).then(() => void 0)); + } + /** @param {Record} manifest */ + register(manifest) { + const validated = ( + /** @type {Record} */ + defineFeature(manifest) + ); + if (this.manifests.has(validated.id)) throw new Error(`Duplicate feature: ${validated.id}`); + this.manifests.set(validated.id, validated); + for (const command of validated.providesCommands) this.commands.registerOwner(command, validated.id); + } + /** @param {Record} manifest */ + isEligible(manifest) { + if (manifest.kind !== "system" && this.disabled.has(manifest.id)) return false; + if (manifest.sites.length && !manifest.sites.includes(this.site)) return false; + if (manifest.routes.length && !manifest.routes.includes(this.route)) return false; + return true; + } + /** @param {string} id */ + activate(id) { + const existing = this.activations.get(id); + if (existing) return existing; + const manifest = this.manifests.get(id); + if (!manifest) return Promise.reject(new Error(`Unknown feature: ${id}`)); + if (!this.isEligible(manifest)) return Promise.reject(new Error(`Feature is disabled or ineligible: ${id}`)); + const activation = this.activateManifest(manifest); + this.activations.set(id, activation); + activation.catch(() => this.activations.delete(id)); + return activation; + } + /** @param {Record} manifest */ + async activateManifest(manifest) { + const started = performance.now(); + const scope = new LifecycleScope(`feature:${manifest.id}`, { onChange: /* @__PURE__ */ __name((snapshot) => this.diagnostics.updateScope(snapshot), "onChange") }); + try { + const dependencies = this.container.resolveDeclared(manifest.requires); + const enabledContributions = Object.freeze(manifest.contributes.filter((id) => !this.disabled.has(id))); + const result = await manifest.activate(dependencies, Object.freeze({ scope, enabledContributions })); + for (const command of manifest.providesCommands) { + const handler = result?.commands?.[command]; + if (typeof handler !== "function") throw new Error(`Feature ${manifest.id} did not provide command ${command}`); + this.commands.registerHandler(command, handler, manifest.id); + } + this.diagnostics.setFeature(manifest.id, true); + enabledContributions.forEach((id) => this.diagnostics.setContribution(id, true)); + this.diagnostics.recordStartup(manifest.id, performance.now() - started); + return Object.freeze({ manifest, scope, enabledContributions, dispose: /* @__PURE__ */ __name(() => { + result?.dispose?.(); + scope.dispose(); + this.diagnostics.setFeature(manifest.id, false); + enabledContributions.forEach((id) => this.diagnostics.setContribution(id, false)); + this.activations.delete(manifest.id); + }, "dispose") }); + } catch (error) { + scope.dispose(); + this.diagnostics.recordError(error); + throw error; + } + } + async start() { + for (const manifest of this.manifests.values()) { + if (!this.isEligible(manifest)) continue; + if (manifest.startup === "eager") await this.activate(manifest.id); + else if (manifest.startup === "idle") { + const schedule = globalThis.requestIdleCallback ?? ((callback) => setTimeout(callback, 0)); + schedule(() => void this.activate(manifest.id).catch(() => void 0)); + } + } + } + getActiveFeatureIds() { + return [...this.activations.keys()]; + } + }; + __name(_FeatureRuntime, "FeatureRuntime"); + var FeatureRuntime = _FeatureRuntime; + + // src/app/provider-registry.js + var _ProviderRegistry = class _ProviderRegistry { + /** @param {import("../services/diagnostics-service.js").DiagnosticsService | null} [diagnostics] */ + constructor(diagnostics = null) { + this.providers = /* @__PURE__ */ new Map(); + this.health = /* @__PURE__ */ new Map(); + this.diagnostics = diagnostics; + } + /** @param {Record} provider */ + register(provider) { + if (!provider || typeof provider.id !== "string" || !Array.isArray(provider.capabilities)) { + throw new TypeError("Provider requires id and capabilities"); + } + if (this.providers.has(provider.id)) throw new Error(`Duplicate provider: ${provider.id}`); + this.providers.set(provider.id, Object.freeze({ priority: 0, enabled: true, ...provider })); + return provider; + } + /** @param {string} id */ + get(id) { + return this.providers.get(id) ?? null; + } + /** @param {string} capability */ + list(capability) { + return [...this.providers.values()].filter((provider) => provider.enabled !== false && provider.capabilities.includes(capability)).sort((left, right) => Number(right.priority ?? 0) - Number(left.priority ?? 0)); + } + /** @param {string} capability @param {unknown} context */ + async getAvailable(capability, context) { + const result = []; + for (const provider of this.list(capability)) { + if (typeof provider.isAvailable !== "function" || await provider.isAvailable(context)) result.push(provider); + } + return result; + } + /** @param {string} id @param {Record} result */ + updateHealth(id, result) { + if (!this.providers.has(id)) throw new Error(`Unknown provider: ${id}`); + this.health.set(id, Object.freeze({ ...result, updatedAt: Date.now() })); + this.diagnostics?.updateProvider(id, result); + } + /** @param {string} id */ + getHealth(id) { + return this.health.get(id) ?? null; + } + }; + __name(_ProviderRegistry, "ProviderRegistry"); + var ProviderRegistry = _ProviderRegistry; + + // src/app/integration-registry.js + var _IntegrationRegistry = class _IntegrationRegistry { + /** @param {import("./dependency-container.js").DependencyContainer} container @param {import("../services/diagnostics-service.js").DiagnosticsService} diagnostics */ + constructor(container, diagnostics) { + this.container = container; + this.diagnostics = diagnostics; + this.manifests = /* @__PURE__ */ new Map(); + this.adapters = /* @__PURE__ */ new Map(); + } + /** @param {Record} manifest */ + register(manifest) { + try { + const validated = ( + /** @type {Record} */ + defineIntegration(manifest) + ); + if (this.manifests.has(validated.id)) throw new Error(`Duplicate integration: ${validated.id}`); + this.manifests.set(validated.id, validated); + } catch (error) { + this.diagnostics.recordError(error); + throw error; + } + } + /** @param {string} capability */ + list(capability) { + return [...this.manifests.values()].filter((manifest) => manifest.capabilities.includes(capability)); + } + /** @param {string} id */ + getAdapter(id) { + const cached = this.adapters.get(id); + if (cached) return cached; + const manifest = this.manifests.get(id); + if (!manifest) throw new Error(`Unknown integration: ${id}`); + try { + const dependencies = this.container.resolveDeclared(manifest.requires); + const client = manifest.createClient(dependencies); + const adapter = manifest.createAdapter(client, dependencies); + this.adapters.set(id, adapter); + return adapter; + } catch (error) { + this.diagnostics.recordError(error); + throw error; + } + } + }; + __name(_IntegrationRegistry, "IntegrationRegistry"); + var IntegrationRegistry = _IntegrationRegistry; + + // src/app/settings-registry.js + var _SettingsRegistry = class _SettingsRegistry { + constructor() { + this.descriptors = /* @__PURE__ */ new Map(); + } + /** @param {{id: string, owner: string, contribution?: string, validate?: (value: unknown) => boolean} & Record} descriptor */ + register(descriptor) { + if (!descriptor?.id || !descriptor.owner) throw new TypeError("Setting descriptor requires id and owner"); + if (this.descriptors.has(descriptor.id)) throw new Error(`Duplicate setting descriptor: ${descriptor.id}`); + this.descriptors.set(descriptor.id, Object.freeze({ ...descriptor })); + } + /** @param {{disabledContributions?: Set}} [options] */ + list(options = {}) { + return [...this.descriptors.values()].filter((descriptor) => !descriptor.contribution || !options.disabledContributions?.has(descriptor.contribution)); + } + /** @param {string} id */ + get(id) { + return this.descriptors.get(id) ?? null; + } + }; + __name(_SettingsRegistry, "SettingsRegistry"); + var SettingsRegistry = _SettingsRegistry; + + // src/app/create-app-context.js + function createAppContext(runtime) { + const diagnostics = new DiagnosticsService(); + const rootScope = new LifecycleScope("app:root", { onChange: /* @__PURE__ */ __name((snapshot) => diagnostics.updateScope(snapshot), "onChange") }); + const container = new DependencyContainer(diagnostics); + const navigationPort = new BrowserNavigationAdapter(); + const httpPort = new UserscriptHttpAdapter(runtime.gmRequest); + const storagePort = new IndexedDbStorageAdapter(runtime.storageForage); + const dialogPort = new LayerDialogAdapter(runtime.layer); + const stylePort = new BrowserStyleAdapter(); + const urlPolicy = new ExternalUrlPolicy({ localOrigins: runtime.localOrigins }); + const cache = new CacheService({ diagnostics }); + const navigation = new NavigationService(navigationPort); + const storage = new StorageService(storagePort); + const dialog = new DialogService(dialogPort); + const styles = new StyleRegistry(stylePort); + const http = new HttpService(httpPort, urlPolicy, { diagnostics, cache }); + const settings = new SettingsService(storage); + const profile = new ProfileService({ scope: rootScope }); + profile.start(); + const commands = new CommandRegistry(); + const providers = new ProviderRegistry(diagnostics); + const settingsRegistry = new SettingsRegistry(); + const integrations = new IntegrationRegistry(container, diagnostics); + const movie = new MovieIdentityService(); + const review = new ReviewService(integrations); + const related = new RelatedService(integrations); + const magnet = new MagnetService(providers); + const screenshot = new ScreenshotService(providers); + const offline = new OfflineService(providers); + container.register(PORT.navigation, navigationPort).register(PORT.http, httpPort).register(PORT.storage, storagePort).register(PORT.dialog, dialogPort).register(PORT.style, stylePort).register(SERVICE.diagnostics, diagnostics).register(SERVICE.urlPolicy, urlPolicy).register(SERVICE.navigation, navigation).register(SERVICE.http, http).register(SERVICE.storage, storage).register(SERVICE.dialog, dialog).register(SERVICE.settings, settings).register(SERVICE.cache, cache).register(SERVICE.profile, profile).register(SERVICE.movie, movie).register(SERVICE.review, review).register(SERVICE.related, related).register(SERVICE.magnet, magnet).register(SERVICE.screenshot, screenshot).register(SERVICE.offline, offline).register(REGISTRY.command, commands).register(REGISTRY.provider, providers).register(REGISTRY.integration, integrations).register(REGISTRY.settings, settingsRegistry); + if (runtime.hostAdapter) container.register(PORT.host, runtime.hostAdapter); + const features = new FeatureRuntime({ container, commands, diagnostics, disabled: runtime.disabled, site: runtime.site, route: runtime.route }); + container.register(REGISTRY.feature, features); + return Object.freeze({ rootScope, container, ports: Object.freeze({ navigationPort, httpPort, storagePort, dialogPort, stylePort }), services: Object.freeze({ diagnostics, urlPolicy, navigation, http, storage, dialog, styles, settings, cache, profile, movie, review, related, magnet, screenshot, offline }), registries: Object.freeze({ commands, providers, integrations, settings: settingsRegistry, features }) }); + } + __name(createAppContext, "createAppContext"); + + // src/integrations/av123/manifest.js + var manifest_default2 = defineIntegration({ + id: "av123", + trustClass: "builtin-public", + hosts: ["123av.com"], + capabilities: ["movie.search", "movie.detail", "movie.images", "actor.lookup"], + requires: [PORT.http, SERVICE.urlPolicy], + createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "av123" }), "createClient"), + createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieRef", "MovieDetail"] }), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "movie.detail": CACHE.externalDetail }, + quality: "silver" + }); + + // src/integrations/dmm/manifest.js + var manifest_default3 = defineIntegration({ id: "dmm", trustClass: "builtin-public", hosts: ["dmm.co.jp"], capabilities: ["movie.preview"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "dmm" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MoviePreview"] }), "createAdapter"), createHostAdapter: null, cachePolicy: { "movie.preview": CACHE.externalDetail }, quality: "silver" }); + + // src/integrations/fc2ppvdb/manifest.js + var manifest_default4 = defineIntegration({ id: "fc2ppvdb", trustClass: "builtin-public", hosts: ["fc2ppvdb.com"], capabilities: ["movie.detail", "movie.images"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "fc2ppvdb" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieDetail"] }), "createAdapter"), createHostAdapter: null, cachePolicy: { "movie.detail": CACHE.externalDetail }, quality: "silver" }); + + // src/integrations/host-list/manifest.js + var manifest_default5 = defineIntegration({ + id: "host-list", + trustClass: "builtin-public", + hosts: ["javdb.com", "javbus.com"], + capabilities: ["host.list.parse"], + requires: [], + createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "host-list" }), "createClient"), + createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieRef"] }), "createAdapter"), + createHostAdapter: null, + cachePolicy: "none", + quality: "bronze" + }); + + // src/integrations/javdb/manifest.js + var manifest_default6 = defineIntegration({ + id: "javdb", + trustClass: "builtin-public", + hosts: ["javdb.com"], + capabilities: ["movie.search", "movie.detail", "movie.state", "actor.lookup"], + requires: [PORT.http, SERVICE.urlPolicy], + createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javdb" }), "createClient"), + createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieRef", "MovieDetail", "Actor"] }), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "movie.detail": CACHE.externalDetail }, + quality: "silver" + }); + + // src/integrations/javbus/manifest.js + var manifest_default7 = defineIntegration({ + id: "javbus", + trustClass: "builtin-public", + hosts: ["javbus.com"], + capabilities: ["movie.search", "movie.detail", "movie.images"], + requires: [PORT.http, SERVICE.urlPolicy], + createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javbus" }), "createClient"), + createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieRef", "MovieDetail"] }), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "movie.detail": CACHE.externalDetail }, + quality: "silver" + }); + + // src/integrations/javstore/manifest.js + var manifest_default8 = defineIntegration({ + id: "javstore", + trustClass: "builtin-public", + hosts: ["javstore.net"], + capabilities: ["movie.images"], + requires: [PORT.http, SERVICE.urlPolicy], + createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javstore" }), "createClient"), + createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["Screenshot"] }), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "movie.images": CACHE.externalDetail }, + quality: "silver" + }); + + // src/integrations/javtrailers/manifest.js + var manifest_default9 = defineIntegration({ id: "javtrailers", trustClass: "builtin-public", hosts: ["javtrailers.com"], capabilities: ["movie.preview"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javtrailers" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MoviePreview"] }), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); + + // src/integrations/subtitlecat/manifest.js + var manifest_default10 = defineIntegration({ id: "subtitlecat", trustClass: "builtin-public", hosts: ["subtitlecat.com"], capabilities: ["subtitle.search"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "subtitlecat" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["Subtitle"] }), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); + + // src/app/integration-catalog.js + var integrationManifests = Object.freeze([manifest_default2, manifest_default3, manifest_default4, manifest_default5, manifest_default6, manifest_default7, manifest_default8, manifest_default9, manifest_default10]); + + // src/app/bootstrap.js + function patchLayerRuntime(layerRuntime) { + const originalClose = layerRuntime.close; + layerRuntime.close = function(id) { + const result = originalClose.call(this, id); + setTimeout(() => { + const shades = document.querySelectorAll(".layui-layer-shade").length; + document.documentElement.style.overflow = shades > 0 ? "hidden" : ""; + }, 10); + return result; + }; + const originalOpen = layerRuntime.open; + layerRuntime.open = function(options = {}) { + const success = options.success; + return originalOpen.call(this, { ...options, success(element, id) { + if (typeof success === "function") success.call(this, element, id); + globalThis.utils.setupEscClose(id); + } }); + }; + } + __name(patchLayerRuntime, "patchLayerRuntime"); + function importVendorStyles() { + for (const url of [ + "https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/layer.min.css", + "https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.css", + "https://cdn.jsdelivr.net/npm/viewerjs@1.11.1/dist/viewer.min.css", + "https://cdn.jsdelivr.net/npm/tabulator-tables@6.3.1/dist/css/tabulator_semanticui.min.css" + ]) utils2.importResource(url); + } + __name(importVendorStyles, "importVendorStyles"); + async function migrateDisabledPluginSettings() { + const raw = await storageManager2.getSetting("disabledPlugins", "[]"); + const previous = parseDisabledPlugins(raw); + const migrated = migrateDisabledPlugins(previous); + if (JSON.stringify(previous) === JSON.stringify(migrated)) return migrated; + const journalKey = "jhs_settings_migration_journal"; + await storageManager2.forage.setItem(journalKey, { status: "pending", previousValues: { disabledPlugins: raw }, createdAt: (/* @__PURE__ */ new Date()).toISOString() }); + try { + await storageManager2.saveSettingItem("disabledPlugins", JSON.stringify(migrated)); + await storageManager2.forage.removeItem(journalKey); + return migrated; + } catch (error) { + await storageManager2.saveSettingItem("disabledPlugins", raw); + throw error; + } + } + __name(migrateDisabledPluginSettings, "migrateDisabledPluginSettings"); + async function prepareLocalOrigins() { + const settings = await storageManager2.getSetting(); + const origins = new Set(Array.isArray(settings.trustedLocalOrigins) ? settings.trustedLocalOrigins : []); + let legacyOrigin = null; + try { + if (settings.webDavUrl) legacyOrigin = new URL(settings.webDavUrl).origin; + } catch { + } + if (legacyOrigin && !origins.has(legacyOrigin)) { + origins.add(legacyOrigin); + await storageManager2.saveSetting({ ...settings, trustedLocalOrigins: [...origins], localOriginTrustNoticeV1: true }); + return { origins: [...origins], notice: `已按现有 WebDAV 配置授权本地来源:${legacyOrigin}` }; + } else if (legacyOrigin && !settings.localOriginTrustNoticeV1) { + await storageManager2.saveSetting({ ...settings, localOriginTrustNoticeV1: true }); + return { origins: [...origins], notice: `WebDAV 仅信任精确来源:${legacyOrigin}` }; + } + return { origins: [...origins], notice: null }; + } + __name(prepareLocalOrigins, "prepareLocalOrigins"); + async function bootstrapJhs() { + try { + const vendors = getVendorRuntime(); + const jhsEventBus2 = initializeEventBus(); + Object.assign(globalThis, { utils: utils2, gmHttp: gmHttp2, storageManager: storageManager2, stateService, jhsEventBus: jhsEventBus2 }); + patchLayerRuntime(vendors.layer); + importVendorStyles(); + const disabled = await migrateDisabledPluginSettings(); + const localOriginSettings = await prepareLocalOrigins(); + const hostAdapter = r ? new JavDbHostAdapter() : l ? new JavBusHostAdapter() : null; + const route = hostAdapter?.detectRoute() ?? "other"; + const context = createAppContext({ + gmRequest: globalThis.GM_xmlhttpRequest, + storageForage: storageManager2.forage, + layer: vendors.layer, + hostAdapter, + site: r ? "javdb" : l ? "javbus" : "other", + route, + disabled, + localOrigins: localOriginSettings.origins + }); + const logger = initializeLoggerRuntime(context.rootScope); + Object.assign(globalThis, logger); + if (localOriginSettings.notice) globalThis.show.info(localOriginSettings.notice); + const pluginManager = new PluginManager(); + registerSitePlugins(pluginManager); + for (const manifest of integrationManifests) context.registries.integrations.register(manifest); + for (const manifest of featureManifests) context.registries.features.register(manifest); + await context.registries.features.start(); + attachCompatibilityFacade({ + pluginManager, + utils: utils2, + gmHttp: gmHttp2, + storageManager: storageManager2, + stateService, + jhsEventBus: jhsEventBus2, + clog: globalThis.clog, + show: globalThis.show, + loading: globalThis.loading + }, globalThis.unsafeWindow); + window.isDetailPage = route === "detail"; + window.isListPage = route === "list"; + await runDataMigrations(storageManager2); + await stateService.recoverPendingTransaction(); + await Promise.all([pluginManager.processCss(), applyTheme()]); + if (r && /(^|;)\s*locale\s*=\s*en\s*($|;)/i.test(document.cookie)) globalThis.show.error("请切换到中文语言下才可正常使用本脚本", { duration: -1 }); + await pluginManager.processPlugins(); + return context; + } catch (cause) { + throw cause instanceof JhsError ? cause : new JhsError("BOOTSTRAP_FAILED", cause instanceof Error ? cause.message : "JHS 启动失败", { source: "bootstrap", cause }); + } + } + __name(bootstrapJhs, "bootstrapJhs"); + function mountBootstrapError(error) { + console.error("[JHS] bootstrap failed:", error); + if (globalThis.show?.error) return globalThis.show.error(error?.message || "JHS 启动失败", { duration: -1 }); + const surface = document.createElement("div"); + surface.id = "jhs-bootstrap-error"; + surface.setAttribute("role", "alert"); + surface.textContent = `JHS 启动失败:${error?.message || "未知错误"}`; + Object.assign(surface.style, { position: "fixed", inset: "12px 12px auto", zIndex: "2147483647", padding: "12px", background: "#7f1d1d", color: "white" }); + document.documentElement.append(surface); + } + __name(mountBootstrapError, "mountBootstrapError"); + + // src/main.js + void bootstrapJhs().catch(mountBootstrapError); })(); diff --git a/architecture-baseline.json b/architecture-baseline.json new file mode 100644 index 00000000..d45a71b6 --- /dev/null +++ b/architecture-baseline.json @@ -0,0 +1,3425 @@ +{ + "version": 1, + "exceptions": [ + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "}, document.addEventListener(\"keydown\", o.handleKeydown);", + "fingerprint": "3bfb7e9325a4b639", + "line": 111, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "document.addEventListener(\"mouseover\", this.onDocumentOver), document.addEventListener(\"mouseout\", this.onDocumentOut),", + "fingerprint": "32a2b1f6eb64f684", + "line": 213, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "document.addEventListener(\"mousemove\", this.onDocumentMove), this.eventsBound = !0;", + "fingerprint": "dd5d5075645cc366", + "line": 214, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "window.addEventListener(\"error\", (function(t) {", + "fingerprint": "c89d7b926573ecdd", + "line": 624, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "})), window.addEventListener(\"unhandledrejection\", (function(t) {", + "fingerprint": "7d7b6f3a1d3b6111", + "line": 627, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "}(), document.addEventListener(\"mousedown\", (e => {", + "fingerprint": "e090df0a1f03961b", + "line": 635, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "document.addEventListener(\"mouseover\", (n => {", + "fingerprint": "2a5024e7a64ba034", + "line": 680, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/core/logger.js", + "symbol": "})), document.addEventListener(\"mouseout\", (e => {", + "fingerprint": "7ef48c6700bcc40a", + "line": 694, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/plugins/external-search/javtrailers.js", + "symbol": "})), window.addEventListener(\"message\", (e => {", + "fingerprint": "ad020f08430ef16a", + "line": 23, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/plugins/new-video/task.js", + "symbol": "}, this.pageHideHandler = () => this.clearSchedule(), document.addEventListener(\"visibilitychange\", this.visibilityHandler),", + "fingerprint": "75e11321103ee8c8", + "line": 156, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/plugins/new-video/task.js", + "symbol": "window.addEventListener(\"pagehide\", this.pageHideHandler));", + "fingerprint": "3a7bbe9f88716d53", + "line": 157, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "window.addEventListener(\"storage\", e), window.addEventListener(\"focus\", e), document.addEventListener(\"visibilitychange\", (() => {", + "fingerprint": "1115bd93e856f38b", + "line": 15, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-listener", + "file": "src/plugins/status/auto-page.js", + "symbol": "window.addEventListener(\"scroll\", (() => {", + "fingerprint": "22b31e197f23cbe6", + "line": 40, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-observer", + "file": "src/core/ui-primitives.js", + "symbol": "new MutationObserver((records => {", + "fingerprint": "f8474357b81160c8", + "line": 1026, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-observer", + "file": "src/core/utils.js", + "symbol": "if (\"function\" == typeof MutationObserver && document.documentElement) o = new MutationObserver(p),", + "fingerprint": "8591b16446796e5a", + "line": 132, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-observer", + "file": "src/plugins/status/detail-workspace.js", + "symbol": "const observer = new MutationObserver((records => { records.every((record => this.isJhsOnlyMutation(record))) || this.scheduleResourceUpdate(); }));", + "fingerprint": "fd8a1adaf2aaa5fa", + "line": 147, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "app-global-observer", + "file": "src/plugins/status/list-page.js", + "symbol": "const a = new MutationObserver((records => {", + "fingerprint": "9a1cac839bd70ac6", + "line": 226, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 7 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/avatar/actress-info.js", + "symbol": "const n = await gmHttp.get(t), a = new DOMParser, i = $(a.parseFromString(n, \"text/html\"));", + "fingerprint": "0691262e536eaf34", + "line": 68, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting-panels.js", + "symbol": "gmHttp.resetCircuitBreaker(t), show.ok(`已重置 ${t} 的熔断状态`), renderNetworkPanel();", + "fingerprint": "b2b9b40b00fba974", + "line": 25, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting-panels.js", + "symbol": "gmHttp.resetAllCircuitBreakers(), show.ok(\"已重置全部熔断状态\"), renderNetworkPanel();", + "fingerprint": "b8425d57e0867d4c", + "line": 27, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting-panels.js", + "symbol": "gmHttp.clearDomainStats(), show.ok(\"已清空域名统计\"), renderNetworkPanel();", + "fingerprint": "70b0f3ce4875454a", + "line": 29, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting-panels.js", + "symbol": "const e = gmHttp.getCircuitBreakerStatus(), t = gmHttp.getDomainStats(), n = await storageManager.getSetting(\"circuitBreakerThreshold\", 3), a = await storageManager.getSetting(\"circuitBreakerCooldown\", 6e4);", + "fingerprint": "ebc4626f8a86188a", + "line": 3, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting.js", + "symbol": "t === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : \"_circuitBreaker\" === t ? gmHttp.resetAllCircuitBreakers() : \"_domainStats\" === t ? gmHttp.clearDomainStats() : localStorage.removeItem(t),", + "fingerprint": "73a08277f5dc9a8b", + "line": 243, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting.js", + "symbol": "else if (\"_circuitBreaker\" === t) n = JSON.stringify(gmHttp.getCircuitBreakerStatus());", + "fingerprint": "8582ffbc14ef45af", + "line": 253, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting.js", + "symbol": "else if (\"_domainStats\" === t) n = JSON.stringify(gmHttp.getDomainStats());", + "fingerprint": "922621f002eb6b2c", + "line": 254, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/backup/setting.js", + "symbol": "async testSource(button, url) { if (!url) return show.info(\"本站来源无需跨站测试\"); const node = $(button).prop(\"disabled\", true), badge = node.siblings(\".jhs-source-test-state\").length ? node.siblings(\".jhs-source-test-state\") : $('').insertAfter(node); badge.text(\"检测中\"); try { const response = await gmHttp.get(url); badge.text(response ? \"200 · 可解析\" : \"空响应\"); } catch (error) { badge.text(error?._cfBlocked ? \"Cloudflare 拦截\" : error?.status === 404 ? \"404\" : error?._circuitBreaker ? \"熔断\" : \"请求失败\"); } finally { node.prop(\"disabled\", false).text(\"测试\"); } }", + "fingerprint": "3dfe6dbfb23fc2a9", + "line": 293, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const t = await gmHttp.get(a), n = new DOMParser, i = $(n.parseFromString(t, \"text/html\"));", + "fingerprint": "44c8c04d36dd61c1", + "line": 351, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const i = await gmHttp.get(a), s = new DOMParser, o = $(s.parseFromString(i, \"text/html\"));", + "fingerprint": "62712ff0e65a1271", + "line": 368, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const i = await gmHttp.get(a);", + "fingerprint": "ce7658a6de35723a", + "line": 379, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "const n = await gmHttp.get(t), a = $(n), i = a.find(\"div\").filter((function() {", + "fingerprint": "289e1d24575dc2ea", + "line": 128, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "const a = await gmHttp.get(n, null, {", + "fingerprint": "7bfd3bd43911bafa", + "line": 152, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "return gmHttp.get(e, {}, {}, !1, { ...AV123_REQUEST_OPTIONS, ...requestOptions });", + "fingerprint": "5758e1a12e3fe710", + "line": 16, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/fc2.js", + "symbol": "const response = await gmHttp.get(`${U}/v1/movies/${movieId}/magnets`, null, { jdSignature: await O() }), magnets = response?.data?.magnets || [];", + "fingerprint": "692b147da8fa5bc9", + "line": 186, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "const i = t.url.replace(\"{keyword}\", encodeURIComponent(n)), s = await storageManager.cachedRequest(`magnet:${t.id}:${n}`, 216e5, (() => gmHttp.get(i).then((e => t.parseHtml.call(this, e, n)))));", + "fingerprint": "134583319ee1dda8", + "line": 116, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "const html = await gmHttp.get(url);", + "fingerprint": "610d8cb1d4555620", + "line": 126, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url)));", + "fingerprint": "b85a719aa8296e29", + "line": 136, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "const payload = await storageManager.cachedRequest(`magnet:custom:${config.id}:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.get(url)));", + "fingerprint": "b85a719aa8296e29", + "line": 145, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "const payload = await storageManager.cachedRequest(`magnet:btsow:${keyword}`, CACHE_TTL.magnet, (() => gmHttp.gmRequest(\"POST\", `${baseUrl}/search`, JSON.stringify([{ search: keyword }, 50, 1]), {}, { \"Content-Type\": \"application/json\" })));", + "fingerprint": "6ef62cf6a2171020", + "line": 150, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/other-site.js", + "symbol": "const _breaker = gmHttp.isDomainCircuitBroken(l);", + "fingerprint": "1cd1b9efe4e55c5c", + "line": 176, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/other-site.js", + "symbol": "const c = await storageManager.cachedRequest(`other-site:${t.id}:${e}`, 864e5, (() => gmHttp.get(l, null, t.headers, !0, t.requestOptions || {})));", + "fingerprint": "c3df07562a0664b3", + "line": 181, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/external-search/top250.js", + "symbol": "return await gmHttp.post(n, null, a);", + "fingerprint": "afb44cbdd8a11852", + "line": 123, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "l = await gmHttp.get(r);", + "fingerprint": "a2d19013e3c26b23", + "line": 47, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "const a = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e}/mtype=AhRVShI_/service=${t}/floor=${n}/mode=/`, i = await gmHttp.get(a, null, {", + "fingerprint": "336ca0939a808e5f", + "line": 95, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/image-viewer/screenshot.js", + "symbol": "let n = await gmHttp.get(t, {}, {}, !1, {ignoreNotFound: !0});", + "fingerprint": "99649bd60dc010d8", + "line": 80, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/image-viewer/screenshot.js", + "symbol": "const n = await gmHttp.get(t, {}, {}, !1, {ignoreNotFound: !0});", + "fingerprint": "89fb3721368c69ef", + "line": 88, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const n = document.querySelector(\"meta[name=csrf-token]\").content, a = await gmHttp.post(e, null, {", + "fingerprint": "30d8eb44e2345117", + "line": 485, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const request = gmHttp.get(`${this.nvJavDbUrl}/actors/${starId}?t=d`).then((html => {", + "fingerprint": "d224de889c95103e", + "line": 521, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/new-video/task.js", + "symbol": "clog.log(\"正在检屏黑名单演员:\", name, url), $(\"#checkBlacklistMsg\").text(`正在检屏黑名单演员: ${name} ${url}`), responseText = await gmHttp.get(url);", + "fingerprint": "05f525581acad1e9", + "line": 286, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/new-video/task.js", + "symbol": "try { responseText = await gmHttp.get(currentUrl.href); } catch (error) { throw error._taskNetwork = !0, error; }", + "fingerprint": "c41bf0233ba2e489", + "line": 351, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/new-video/task.js", + "symbol": "const responseText = await gmHttp.get(url), page = utils.htmlTo$dom(responseText);", + "fingerprint": "60736c1442680fb2", + "line": 392, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/new-video/task.js", + "symbol": "const e = await gmHttp.get(r), n = utils.htmlTo$dom(e);", + "fingerprint": "6f6cfbbce544dce2", + "line": 484, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "const n = await gmHttp.post(this._signUrl(\"https://yun.123pan.com/b/api/v2/offline_download/task/resolve\"), { urls: e }, {", + "fingerprint": "9688a1e00a29ea15", + "line": 79, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "const i = await gmHttp.post(this._signUrl(\"https://yun.123pan.com/b/api/v2/offline_download/task/submit\"), {", + "fingerprint": "4bc72541487528c8", + "line": 96, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/status/auto-page.js", + "symbol": "const i = await gmHttp.get(this.nextUrl);", + "fingerprint": "24dece03649c2e3c", + "line": 58, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "let n = i.url, a = e + \".\" + i.ext, s = await gmHttp.get(n);", + "fingerprint": "8d18d19755f9265e", + "line": 116, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "let a = await gmHttp.get(e), i = \"字幕预览\";", + "fingerprint": "167647372803889e", + "line": 161, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e}`).then((t => {", + "fingerprint": "06229348e4e9c84b", + "line": 73, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/status/list-page.js", + "symbol": "const n = await gmHttp.get(e), a = utils.htmlTo$dom(n);", + "fingerprint": "1a31815c0f821a16", + "line": 465, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-gmhttp", + "file": "src/plugins/status/want-and-watched-videos.js", + "symbol": "const html = await gmHttp.get(new URL(n, window.location.href).href), nextPage = utils.htmlTo$dom(html);", + "fingerprint": "cfab4ea978325d19", + "line": 45, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 24, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 101, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "layer.confirm(`是否删除 ${file.name} ?`, {", + "fingerprint": "806a71d0a9ef17bb", + "line": 116, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "layer.close(confirmIdx);", + "fingerprint": "c214cda0996fe1fd", + "line": 119, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "layer.alert(\"删除成功\");", + "fingerprint": "af79cca5ba04f8e8", + "line": 125, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 163, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "layer.confirm(`是否删除 ${o.name} ?`, {", + "fingerprint": "40eb4fb192a3994e", + "line": 217, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "layer.close(e);", + "fingerprint": "2f0ac0d46632c601", + "line": 222, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-backup.js", + "symbol": "i.replaceData(e), layer.alert(\"删除成功\");", + "fingerprint": "c49b966dbea3aa33", + "line": 227, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-forms.js", + "symbol": "/** Initialize quick settings in either the desktop popover or mobile layer. */", + "fingerprint": "d7deb3b5aee528b7", + "line": 67, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-panels.js", + "symbol": "const r = layer.open({", + "fingerprint": "cb3fc752ff65df73", + "line": 114, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting-panels.js", + "symbol": "layer.close(s);", + "fingerprint": "72f0cf315346e2c0", + "line": 122, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 122, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting.js", + "symbol": "openSourceDialog(existing = null) { const fields = [\"rowSelector\",\"titleSelector\",\"magnetSelector\",\"sizeSelector\",\"dateSelector\",\"seedersSelector\",\"leechersSelector\",\"resultsPath\",\"titlePath\",\"magnetPath\",\"hashPath\",\"sizePath\",\"datePath\",\"seedersPath\"]; const content = $(`
    `); const renderFields = () => { const type = content.find('[name=\"parserType\"]').val(), names = \"torrent-table\" === type ? fields.slice(0, 7) : \"json\" === type ? fields.slice(7) : []; content.find(\".jhs-parser-fields\").html(names.map((name => ``)).join(\"\")); names.forEach((name => content.find(`[name=\"${name}\"]`).val(existing?.[name] || \"\"))); }; Object.entries(existing || { enabled: true, priority: 100, parserType: \"magnet-links\" }).forEach(([key, value]) => { const input = content.find(`[name=\"${key}\"]`); \"checkbox\" === input.attr(\"type\") ? input.prop(\"checked\", value) : input.val(value); }); content.on(\"change\", '[name=\"parserType\"]', renderFields); renderFields(); content.appendTo(\"body\").hide(); layer.open({ type: 1, title: existing ? \"编辑自定义磁力源\" : \"添加自定义磁力源\", content, area: utils.getDialogArea(\"md\"), btn: [\"保存\", \"取消\"], success: () => content.show(), end: () => content.remove(), yes: async index => { const form = Object.fromEntries(content.find(\"input,select\").map(((i, element) => [element.name, \"checkbox\" === element.type ? element.checked : element.value])).get()); try { const source = buildCustomMagnetSource(form, existing); const target = existing ? this.resourceState.custom.findIndex((item => item.id === existing.id)) : -1; target >= 0 ? this.resourceState.custom.splice(target, 1, source) : this.resourceState.custom.push(source); await this.resourceSettings.saveMagnetSources(this.resourceState.custom); layer.close(index); this.renderResourceSettings(); } catch (error) { show.error(error.message); } } }); }", + "fingerprint": "94ee1c6e345e4281", + "line": 289, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/backup/setting.js", + "symbol": "openRuleDialog(kind, existing = null) { const isTag = \"tag\" === kind, content = $(`
    ${isTag ? \"\" : ''}${isTag ? '' : ''}
    `); Object.entries(existing || { enabled: true, type: \"contains\", weight: 0, action: \"hide\", penalty: -20 }).forEach(([key, value]) => { const input = content.find(`[name=\"${key}\"]`); \"checkbox\" === input.attr(\"type\") ? input.prop(\"checked\", value) : input.val(value); }); content.appendTo(\"body\").hide(); layer.open({ type: 1, title: `${existing ? \"编辑\" : \"新建\"}${isTag ? \"标签\" : \"过滤\"}规则`, content, area: utils.getDialogArea(\"sm\"), btn: [\"保存\", \"取消\"], success: () => content.show(), end: () => content.remove(), yes: async index => { const rule = Object.fromEntries(content.find(\"input,select\").map(((i, element) => [element.name, \"checkbox\" === element.type ? element.checked : element.value])).get()); rule.id = existing?.id || `rule-${Date.now()}`; rule.weight = Number(rule.weight); rule.penalty = Number(rule.penalty); try { validateRule(rule); const key = isTag ? \"tags\" : \"filters\", target = existing ? this.resourceState[key].findIndex((item => item.id === existing.id)) : -1; target >= 0 ? this.resourceState[key].splice(target, 1, rule) : this.resourceState[key].push(rule); await (isTag ? this.resourceSettings.saveMagnetTagRules(this.resourceState[key]) : this.resourceSettings.saveMagnetFilterRules(this.resourceState[key])); layer.close(index); this.renderRules(kind); } catch (error) { show.error(error.message); } } }); }", + "fingerprint": "3523dc723da1a098", + "line": 290, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 98, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/external-search/fc2.js", + "symbol": ".movie-detail-layer .layui-layer-content { min-height:0; overflow:hidden; background:var(--jhs-bg); }", + "fingerprint": "27442ab5ae873af3", + "line": 5, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/external-search/fc2.js", + "symbol": ".movie-detail-layer .jhs-fc2-dialog-host { height:100%; min-height:0; }", + "fingerprint": "7d64189e4a99640c", + "line": 6, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/external-search/fc2.js", + "symbol": "return layer.open({ type: 1, title: carNum, content: '
    ', area: utils.getDialogArea(\"workspace\"), skin: \"movie-detail-layer\", scrollbar: !1, shadeClose: !0,", + "fingerprint": "7d43a7a97f33c065", + "line": 68, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/external-search/top250.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 104, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/external-search/top250.js", + "symbol": "await localStorage.setItem(me, await encryptData(n)), show.ok(\"登录成功\"), layer.close(t), \"function\" === typeof onSuccess ? await onSuccess() : window.location.href = \"/advanced_search?handleTop=1&period=daily\";", + "fingerprint": "f69139139d1e92bb", + "line": 130, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 305, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 680, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 707, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "show.ok(`CDN 源已切换为: ${tt[n].name}`), layer.close(e);", + "fingerprint": "616206d823aaa492", + "line": 726, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "} else layer.close(e);", + "fingerprint": "4beed1d0f3715dd8", + "line": 727, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "layer.close(t);", + "fingerprint": "36cfbc2c3ef76c4f", + "line": 743, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 792, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "layer.close(t));", + "fingerprint": "9010d87ed23c8380", + "line": 808, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/new-video/new-video.js", + "symbol": "layer.close(t);", + "fingerprint": "36cfbc2c3ef76c4f", + "line": 814, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/offline/unified-offline.js", + "symbol": "candidates.forEach((candidate => toolbar.append($(\"\").text(`${candidate.provider.name} · ${\"ready\" === candidate.availability.authState ? \"已就绪\" : \"状态未知\"}`).on(\"click\", (() => { layer.close(index), resolve(candidate); })))));", + "fingerprint": "727e34019ecc0701", + "line": 66, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/offline/unified-offline.js", + "symbol": "const index = layer.open({ type: 1, title: \"选择离线服务\", content, area: utils.getDialogArea(\"sm\"), cancel: () => resolve(null) });", + "fingerprint": "b48a837893a77092", + "line": 67, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/one-one-five/plugins.js", + "symbol": "badge.on(\"click\", (() => { if (!matches.length) return this.matchCard(element, !0); if (1 === matches.length) return window.open(build115PlayUrl(matches[0]), \"_blank\"); const links = matches.map((match => `${escapeHtml(match.name)}`)).join(\"
    \"); layer.open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea([ \"560px\", \"auto\" ]) }); }));", + "fingerprint": "4aef62e674d4df13", + "line": 44, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/stats/stats.js", + "symbol": "layer.open({ type: 1, title: \"统计\", content: dialogHtml, scrollbar: !1, area: utils.getDialogArea(\"lg\"), anim: -1, success: (layerElement, layerIndex) => {", + "fingerprint": "028ba9accc86e4de", + "line": 67, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/stats/stats.js", + "symbol": "layer.close(layerIndex);", + "fingerprint": "488f4f97fd5e147b", + "line": 71, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 171, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 25, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "n && 0 !== n.length ? layer.open({", + "fingerprint": "8a99ecc82da8140f", + "line": 75, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/status/history.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 495, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/status/history.js", + "symbol": "await stateService.patch(e.carNum, nextFlags, { type: \"history-edit\", replaceMetadata: !0, record: { ...e, names: n, url: i, remark: s } }), this.tableObj.setData(), layer.close(t);", + "fingerprint": "99d1eae140312652", + "line": 519, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-layer", + "file": "src/plugins/status/history.js", + "symbol": "layer.open({", + "fingerprint": "6af6fcac65742c25", + "line": 54, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/avatar/actress-info.js", + "symbol": "const t = \"jhs_actress_info\", n = localStorage.getItem(t) ? JSON.parse(localStorage.getItem(t)) : {};", + "fingerprint": "c6257deb3978963a", + "line": 21, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/avatar/actress-info.js", + "symbol": "$('strong:contains(\"演員\")').parent().after(i), localStorage.setItem(t, JSON.stringify(n));", + "fingerprint": "c9ba9ebb651a1f68", + "line": 34, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/avatar/actress-info.js", + "symbol": "const a = \"jhs_actress_info\", i = localStorage.getItem(a) ? JSON.parse(localStorage.getItem(a)) : {};", + "fingerprint": "2f359ddc1164d091", + "line": 46, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/avatar/actress-info.js", + "symbol": "t.parent().append(o), localStorage.setItem(a, JSON.stringify(i));", + "fingerprint": "65bcdf9f245d33ff", + "line": 63, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "const n = \"jhs_selectedSites\", a = JSON.parse(localStorage.getItem(n) || \"{}\");", + "fingerprint": "b3933547d3b0ddff", + "line": 67, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "a[e] = $(this).is(\":checked\"), localStorage.setItem(n, JSON.stringify(a));", + "fingerprint": "a7956578187106c3", + "line": 73, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/backup/setting.js", + "symbol": "t === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : \"_circuitBreaker\" === t ? gmHttp.resetAllCircuitBreakers() : \"_domainStats\" === t ? gmHttp.clearDomainStats() : localStorage.removeItem(t),", + "fingerprint": "5d9664f604c96185", + "line": 243, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/backup/setting.js", + "symbol": "\"jhs_dmm_video\" === t && localStorage.removeItem(\"jhs_other_site_dmm\");", + "fingerprint": "51f29c251dc234ed", + "line": 245, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/backup/setting.js", + "symbol": "this.cacheItems.forEach((e => localStorage.removeItem(e.key))), show.ok(\"全部缓存已清理\"),", + "fingerprint": "4300128f4dd0dcb5", + "line": 247, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/backup/setting.js", + "symbol": "$(\"#cache-data-display\").addClass(\"jhs-is-hidden\"), localStorage.removeItem(\"jhs_other_site_dmm\"), await storageManager.clearThirdPartyCache();", + "fingerprint": "b438dc47dbd878d7", + "line": 248, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/backup/setting.js", + "symbol": "else n = localStorage.getItem(t);", + "fingerprint": "3f9a343c9b9d96ad", + "line": 255, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = localStorage.getItem(e.lastCheckBlacklistTimeKey) || \"无\", n = await storageManager.getSetting(\"checkBlacklist_intervalTime\", 12);", + "fingerprint": "740313f706170f5b", + "line": 91, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "let n = `\\n
    \\n
    \\n
    \\n \\n \\n
    \\n
    \\n \\n \\n \\n \\n \\n
    \\n\\n
    \\n
    \\n
    \\n `;", + "fingerprint": "456e8063e46e02a6", + "line": 97, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "if (generation === this.loadGeneration && \"rateCount\" === localStorage.getItem(\"jhs_sortMethod\")) await this.getBean(\"ListPageButtonPlugin\").sortItems();", + "fingerprint": "56f3a0db911529fc", + "line": 35, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "try { cache = JSON.parse(localStorage.getItem(cacheKey) || \"{}\"); } catch (error) { clog.warn(\"评分缓存解析失败,将重新建立缓存\", error); }", + "fingerprint": "79681cb1afa46aeb", + "line": 74, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "await Promise.all(workers), localStorage.setItem(cacheKey, JSON.stringify(cache));", + "fingerprint": "a415c0de952fc81b", + "line": 76, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "const t = $('
    '), n = $('
    '), a = \"jhs_magnetHub_selectedEngine\", i = localStorage.getItem(a);", + "fingerprint": "85acc4643f7b34d0", + "line": 83, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "localStorage.setItem(a, i), t.find(\".magnet-tab\").removeClass(\"active\").attr({ \"aria-selected\": \"false\", tabindex: \"-1\" }), $(n.target).addClass(\"active\").attr({ \"aria-selected\": \"true\", tabindex: \"0\" }),", + "fingerprint": "e8dea75360a3c7d4", + "line": 96, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/other-site.js", + "symbol": "const t = \"jhs_other_site_dmm\", a = (localStorage.getItem(t) ? JSON.parse(localStorage.getItem(t)) : {})[e];", + "fingerprint": "32d88a5d15aac66f", + "line": 166, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/other-site.js", + "symbol": "const a = \"jhs_other_site\", i = localStorage.getItem(a) ? JSON.parse(localStorage.getItem(a)) : {}, s = e + \"_\" + t.id.replace(\"Btn\", \"\"), o = i[s], m = Date.now();", + "fingerprint": "032e2954115be317", + "line": 171, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/other-site.js", + "symbol": "const e = localStorage.getItem(a) ? JSON.parse(localStorage.getItem(a)) : {};", + "fingerprint": "3c9e65e8297f0d16", + "line": 206, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/other-site.js", + "symbol": "e[s] = p, localStorage.setItem(a, JSON.stringify(e));", + "fingerprint": "1c6c1009a070a045", + "line": 207, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/other-site.js", + "symbol": "const raw = localStorage.getItem(\"jhs_enabled_sites\");", + "fingerprint": "ec41183da558ce10", + "line": 251, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/other-site.js", + "symbol": "localStorage.setItem(\"jhs_enabled_sites\", JSON.stringify(e));", + "fingerprint": "324af1117dd09348", + "line": 260, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/top250.js", + "symbol": "await localStorage.setItem(me, await encryptData(n)), show.ok(\"登录成功\"), layer.close(t), \"function\" === typeof onSuccess ? await onSuccess() : window.location.href = \"/advanced_search?handleTop=1&period=daily\";", + "fingerprint": "95d31864cf9ec906", + "line": 130, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/top250.js", + "symbol": "} else clog.error(e), i.html(`

    ${escapeHtml(l)}

    `), show.error(l), \"JWTVerificationError\" === c && (await localStorage.removeItem(me),", + "fingerprint": "d201430c5cca0b3f", + "line": 66, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/external-search/top250.js", + "symbol": "if (!localStorage.getItem(me)) return show.error(\"该类别依赖移动端接口,请先完成登录\"), void this.openLoginDialog();", + "fingerprint": "84da3e5ee1f9dd4b", + "line": 94, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/bus-preview-video.js", + "symbol": "const r = i[0], l = localStorage.getItem(\"jhs_videoMuted\");", + "fingerprint": "ea30320a354be9e3", + "line": 121, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/bus-preview-video.js", + "symbol": "localStorage.setItem(\"jhs_videoMuted\", r.muted ? \"yes\" : \"no\");", + "fingerprint": "7b16e6f537606bbc", + "line": 123, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "const e = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {};", + "fingerprint": "db10485e1761ec09", + "line": 15, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "const t = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {};", + "fingerprint": "f80ce126793657cc", + "line": 19, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "t[this.carNum] = e, clog.debug(\"成功解析出预览视频并已缓存:\", e), localStorage.setItem(ee, JSON.stringify(t));", + "fingerprint": "a0c3440832530d2c", + "line": 20, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "const $host = $nativeVideo.parent().css(\"position\", \"relative\"), nativeVideo = $nativeVideo[0], muted = localStorage.getItem(\"jhs_videoMuted\");", + "fingerprint": "e7c922d982f2bd1f", + "line": 246, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "localStorage.setItem(\"jhs_videoMuted\", dmmVideo.muted ? \"yes\" : \"no\");", + "fingerprint": "93b3d9d533aaf13c", + "line": 265, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "const s = \"jhs_other_site_dmm\", o = localStorage.getItem(s) ? JSON.parse(localStorage.getItem(s)) : {};", + "fingerprint": "4d3fd345ed74b536", + "line": 80, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "}, localStorage.setItem(s, JSON.stringify(o)), c;", + "fingerprint": "f89a090a16f81eee", + "line": 84, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/image-viewer/screenshot.js", + "symbol": "localStorage.removeItem(\"jhs_screenShot\");", + "fingerprint": "02912c905d8547a6", + "line": 57, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/new-video.js", + "symbol": "let at = parseInt(localStorage.getItem(nt) || \"0\", 10);", + "fingerprint": "7a4dbcb1718ef3d9", + "line": 11, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = await storageManager.getSetting(), n = localStorage.getItem(e.lastCheckFavoriteActressTimeKey) || \"无\", a = t.checkFavoriteActress_IntervalTime, i = localStorage.getItem(e.lastCheckNewVideoTimeKey) || \"无\", s = t.checkNewVideo_intervalTime;", + "fingerprint": "744c2065d7431bef", + "line": 267, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/new-video.js", + "symbol": "this.cleanupNewVideoWorkspace(), this._viewMode = \"list\" === localStorage.getItem(\"jhs_newVideoViewMode\") ? \"list\" : \"actress\", this.currentPage = 1, this.nvCurrentPage = 1, this.nvSelected = new Set, this.nvCoverCache = new Map, this.nvActorCoverRequests = new Map, this.nvRenderGeneration++;", + "fingerprint": "d94dcf54b5aa6d94", + "line": 271, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = await storageManager.getSetting(), n = localStorage.getItem(e.lastCheckFavoriteActressTimeKey) || \"无\", a = t.checkFavoriteActress_IntervalTime, i = localStorage.getItem(e.lastCheckNewVideoTimeKey) || \"无\", s = t.checkNewVideo_intervalTime;", + "fingerprint": "744c2065d7431bef", + "line": 272, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/new-video.js", + "symbol": "this._viewMode = mode, localStorage.setItem(\"jhs_newVideoViewMode\", mode), \"list\" === mode ? this.nvCurrentPage = 1 : this.currentPage = 1, this.nvRenderGeneration++, this.applyViewMode(), this.renderCurrentView();", + "fingerprint": "d841861d7fd003b3", + "line": 388, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/new-video.js", + "symbol": "at = n, localStorage.setItem(nt, n.toString()), it = tt[n].json, st = tt[n].base,", + "fingerprint": "7519f5d515572b33", + "line": 719, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "const completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey));", + "fingerprint": "98d0caa1c244c34d", + "line": 111, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "let next = parseTaskTimestamp(localStorage.getItem(schedule.nextKey));", + "fingerprint": "7e1629493f0e5450", + "line": 112, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "localStorage.setItem(schedule.nextKey, String(next));", + "fingerprint": "bb211dab6d31d97d", + "line": 116, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey));", + "fingerprint": "817e9c8e734fed07", + "line": 121, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "localStorage.setItem(schedule.attemptKey, String(attempt)), localStorage.setItem(schedule.nextKey, String(attempt + 3e5));", + "fingerprint": "d36da23a50987386", + "line": 123, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "if (!completed) return localStorage.setItem(schedule.nextKey, String(Date.now() + 3e5));", + "fingerprint": "31475e587a9775d3", + "line": 128, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "const attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name);", + "fingerprint": "50e2ac69daf98190", + "line": 129, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "localStorage.setItem(schedule.completedKey, utils.getNowStr(\"-\", \":\", completedAt)), localStorage.setItem(schedule.nextKey, String(completedAt + 36e5 * interval));", + "fingerprint": "633af6055557b036", + "line": 130, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "localStorage.setItem(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval));", + "fingerprint": "4f8673bb90e7ae71", + "line": 137, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey));", + "fingerprint": "b465691fc53b28ec", + "line": 67, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/new-video/task.js", + "symbol": "const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey));", + "fingerprint": "cdfea8992a89960b", + "line": 72, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "let e = (localStorage.getItem(\"authorToken\") || \"\").trim();", + "fingerprint": "24bb66b6bf745dd8", + "line": 20, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "const t = JSON.parse(localStorage.getItem(\"userInfo\") || \"{}\");", + "fingerprint": "1873e4d4702cc5ec", + "line": 26, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/fold-category.js", + "symbol": "let i = $(\"#foldCategoryBtn\"), s = localStorage.getItem(\"jhs_foldCategory\") === _, [o, r] = s ? [ \"展开\", \"icon-angle-double-down\" ] : [ \"折叠\", \"icon-angle-double-up\" ];", + "fingerprint": "a1c7d482605e41ae", + "line": 48, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/fold-category.js", + "symbol": "e.preventDefault(), s = !s, localStorage.setItem(\"jhs_foldCategory\", s ? _ : C);", + "fingerprint": "89d4f9133d8753d6", + "line": 51, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page-button.js", + "symbol": "localStorage.setItem(\"jhs_sortMethod\", method), menu.find(\".jhs-sort-option\").attr(\"aria-checked\", \"false\"), item.attr(\"aria-checked\", \"true\"),", + "fingerprint": "9f5dd9e64db87a3d", + "line": 108, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page-button.js", + "symbol": "const t = localStorage.getItem(\"jhs_sortMethod\");", + "fingerprint": "2562963bf024e780", + "line": 127, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page-button.js", + "symbol": "const l = localStorage.getItem(\"jhs_sortMethod\"), d = \"当前排序方式: \" + (\"rateCount\" === l ? \"评价人数\" : \"date\" === l ? \"时间\" : \"默认\");", + "fingerprint": "7168db24546cd786", + "line": 30, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page-button.js", + "symbol": "const a = localStorage.getItem(\"jhs_sortMethod\") || \"default\";", + "fingerprint": "687627ec01ddc154", + "line": 40, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page.js", + "symbol": "const t = \"jhs_tag_expand\", n = \"true\" === localStorage.getItem(t), a = $(\".actor-tags .content\");", + "fingerprint": "e9894a21bb7970e8", + "line": 216, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page.js", + "symbol": "clog.debug(\"触发\"), localStorage.setItem(t, e.toString());", + "fingerprint": "4572912b32ae140c", + "line": 219, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page.js", + "symbol": "try { this.cache = JSON.parse(localStorage.getItem(\"jhs_translate\") || \"{}\"); } catch (error) {", + "fingerprint": "7d2a3e64c69848f7", + "line": 548, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/list-page.js", + "symbol": "localStorage.setItem(\"jhs_translate\", JSON.stringify(this.getTranslationCache()));", + "fingerprint": "14e05a57682becb1", + "line": 555, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "const sortMethod = localStorage.getItem(\"jhs_sortMethod\") || \"default\", sortLabels = { default: \"默认\", rateCount: \"评价人数\", date: \"时间\" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getBean(\"ListPagePlugin\")?.activeQuickFilter),", + "fingerprint": "918bf817451bcfa4", + "line": 277, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "const sortMethod = localStorage.getItem(\"jhs_sortMethod\") || \"default\";", + "fingerprint": "b4611a10d16942f3", + "line": 334, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "localStorage.setItem(\"jhs_sortMethod\", value), sortMenu.find(\".jhs-mobile-sort-option\").attr(\"aria-checked\", \"false\"), $(event.currentTarget).attr(\"aria-checked\", \"true\"), void this.getBean(\"ListPageButtonPlugin\")?.sortItems?.(), closeMenu(!0);", + "fingerprint": "1d2d2fce6ca30c90", + "line": 386, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/translate/translate.js", + "symbol": "const e = localStorage.getItem(\"jhs_translate\");", + "fingerprint": "7cf892b741411893", + "line": 25, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-direct-localstorage", + "file": "src/plugins/translate/translate.js", + "symbol": "i.text(e), r[o] = e, localStorage.setItem(\"jhs_translate\", JSON.stringify(r));", + "fingerprint": "317d5c2fd7e60941", + "line": 34, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 5 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/actress-info.js", + "symbol": "super(...arguments), i(this, \"apiUrl\", \"https://ja.wikipedia.org/wiki/\");", + "fingerprint": "ae1b58119a51efd9", + "line": 3, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "ico: \"https://www.google.com/favicon.ico\"", + "fingerprint": "b188e8e5c25a6a2d", + "line": 10, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "const d = await fetch(\"https://api.imgur.com/3/image\", {", + "fingerprint": "d8bb5f07855e5a3f", + "line": 117, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "url: \"https://yandex.ru/images/search?rpt=imageview&url={占位符}\",", + "fingerprint": "4845ccd6eeb090a4", + "line": 13, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "ico: \"https://yandex.ru/favicon.ico\"", + "fingerprint": "d49a7d525446c3df", + "line": 14, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "url: \"https://www.google.com/searchbyimage?image_url={占位符}&client=firefox-b-d\",", + "fingerprint": "b6ea612989010e6f", + "line": 5, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "ico: \"https://www.google.com/favicon.ico\"", + "fingerprint": "b188e8e5c25a6a2d", + "line": 6, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/avatar/search-by-image.js", + "symbol": "url: \"https://lens.google.com/uploadbyurl?url={占位符}\",", + "fingerprint": "dbd285cc26e96cce", + "line": 9, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/backup/resource-settings.js", + "symbol": "{ id: \"u9a9\", name: \"U9A9\", type: \"网页来源\", domain: \"u9a9.com\", baseUrl: \"https://u9a9.com\", priority: 20, enabled: true },", + "fingerprint": "5d3423e6638bd4d3", + "line": 4, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/backup/resource-settings.js", + "symbol": "{ id: \"u3c3\", name: \"U3C3\", type: \"网页来源\", domain: \"u3c3.com\", baseUrl: \"https://u3c3.com\", priority: 30, enabled: true },", + "fingerprint": "aeb2cde0a4a786ab", + "line": 5, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/backup/resource-settings.js", + "symbol": "{ id: \"sukebei\", name: \"Sukebei\", type: \"网页来源\", domain: \"sukebei.nyaa.si\", baseUrl: \"https://sukebei.nyaa.si\", priority: 40, enabled: true },", + "fingerprint": "ede14991e54fbaf5", + "line": 6, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/backup/resource-settings.js", + "symbol": "{ id: \"btsow\", name: \"BTSOW\", type: \"API 来源\", domain: \"btsow.lol\", baseUrl: \"https://btsow.lol\", priority: 50, enabled: true }", + "fingerprint": "8f48fcf8757bc96e", + "line": 7, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "const AV123_REQUEST_OPTIONS = Object.freeze({ cookiePartitionTopLevelSite: \"https://123av.com\" });", + "fingerprint": "0f8a96a348d49bed", + "line": 1, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "let t = `https://fc2ppvdb.com/articles/${e.replace(\"FC2-\", \"\")}`;", + "fingerprint": "d76a91c4f17d8d58", + "line": 127, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "const link = $(n), name = link.text().trim(), url = normalizeHttpUrl(link.attr(\"href\"), \"https://fc2ppvdb.com\");", + "fingerprint": "fc06869335a5ca3f", + "line": 136, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "link.length && (seller = { name: link.text().trim(), url: normalizeHttpUrl(link.attr(\"href\"), \"https://fc2ppvdb.com\") });", + "fingerprint": "109fad8c7e9abb2d", + "line": 146, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "let t = e.replace(\"FC2-\", \"\"), n = `https://adult.contents.fc2.com/article/${e.replace(\"FC2-\", \"\")}/`;", + "fingerprint": "096445e21e7355c1", + "line": 151, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "const href = normalizeHttpUrl(e.href, \"https://123av.com\"), imageUrl = normalizeHttpUrl(e.imgSrc, \"https://123av.com\");", + "fingerprint": "88acd81a4f4e5ba4", + "line": 176, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2.js", + "symbol": "const id = String(context.carNum || \"\").replace(/^FC2-(?:PPV-)?/i, \"\"), links = $('
    '), values = [ [ \"123av\" === context.source ? \"123AV 原页面\" : \"JavDB 原页面\", normalizeHttpUrl(context.url) ], [ \"FC2PPVDB\", `https://fc2ppvdb.com/articles/${encodeURIComponent(id)}` ], [ \"FC2 市场\", `https://adult.contents.fc2.com/article/${encodeURIComponent(id)}/` ] ];", + "fingerprint": "b907e299a1547d20", + "line": 171, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/fc2.js", + "symbol": "toolbar.on(`click${context.namespace}`, '[data-jhs-action=\"subtitlecat\"]', (event => utils.openPage(`https://subtitlecat.com/index.php?search=${encodeURIComponent(context.carNum)}`, context.carNum, !1, event))), toolbar.on(`click${context.namespace}`, '[data-jhs-action=\"xunlei\"]', (() => this.getBean(\"DetailPageButtonPlugin\").searchXunLeiSubtitle(context.carNum)));", + "fingerprint": "d9970ae0fcf1ce71", + "line": 94, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "const coverUrl = normalizeHttpUrl(String(e.cover_url || \"\").replace(/https:\\/\\/[^/]+\\/rhe951l4q/, \"https://c0.jdbstatic.com\"));", + "fingerprint": "95ca8c6c9abc242f", + "line": 114, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "async searchBtsow(keyword, baseUrl = \"https://btsow.lol\") {", + "fingerprint": "bc100e3f8362b342", + "line": 149, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "url: \"https://u9a9.com/?type=2&search={keyword}\",", + "fingerprint": "1d4e645713822479", + "line": 41, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "targetPage: \"https://u9a9.com/?type=2&search={keyword}\",", + "fingerprint": "07dbb4acf4eac3b4", + "line": 42, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "priority: 10, search: keyword => this.searchTorrentSource(\"u9a9\", `${baseUrl(\"u9a9\", \"https://u9a9.com\")}/?type=2&search={keyword}`, keyword), targetUrl: keyword => `${baseUrl(\"u9a9\", \"https://u9a9.com\")}/?type=2&search=${encodeURIComponent(keyword)}`", + "fingerprint": "4e26121c683919cd", + "line": 43, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "url: \"https://u3c3.com/?search2=a8lr16lo&search={keyword}\",", + "fingerprint": "8ad7b24076ae874d", + "line": 47, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "targetPage: \"https://u3c3.com/?search2=a8lr16lo&search={keyword}\",", + "fingerprint": "dd8401de3b97601b", + "line": 48, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "priority: 20, search: keyword => this.searchTorrentSource(\"u3c3\", `${baseUrl(\"u3c3\", \"https://u3c3.com\")}/?search2=a8lr16lo&search={keyword}`, keyword), targetUrl: keyword => `${baseUrl(\"u3c3\", \"https://u3c3.com\")}/?search2=a8lr16lo&search=${encodeURIComponent(keyword)}`", + "fingerprint": "aefbfa6fcc43132c", + "line": 49, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "url: \"https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}\",", + "fingerprint": "0a36b5e1993742b8", + "line": 53, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "targetPage: \"https://sukebei.nyaa.si/?f=0&c=0_0&q={keyword}\",", + "fingerprint": "f52f8bc08e591b35", + "line": 54, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "priority: 30, search: keyword => this.searchTorrentSource(\"sukebei\", `${baseUrl(\"sukebei\", \"https://sukebei.nyaa.si\")}/?f=0&c=0_0&q={keyword}`, keyword), targetUrl: keyword => `${baseUrl(\"sukebei\", \"https://sukebei.nyaa.si\")}/?f=0&c=0_0&q=${encodeURIComponent(keyword)}`", + "fingerprint": "517da9dbdc64b89c", + "line": 55, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/magnet-hub.js", + "symbol": "}, { name: \"BTSOW\", id: \"btsow\", priority: 40, search: keyword => this.searchBtsow(keyword, baseUrl(\"btsow\", \"https://btsow.lol\")), targetUrl: keyword => `${baseUrl(\"btsow\", \"https://btsow.lol\")}/search/${encodeURIComponent(keyword)}` }", + "fingerprint": "121a7a35bd81c0fe", + "line": 56, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).missAvUrl || \"https://missav.live\";", + "fingerprint": "41422d7bf7b1c21c", + "line": 225, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).jableUrl || \"https://jable.tv\";", + "fingerprint": "a5687787c6a25914", + "line": 228, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).avgleUrl || \"https://jav.rs\";", + "fingerprint": "899d7201e38675f1", + "line": 231, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).javTrailersUrl || \"https://javtrailers.com\";", + "fingerprint": "47ba129781986518", + "line": 234, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).av123Url || \"https://123av.com\";", + "fingerprint": "fdcad0a3208cc533", + "line": 237, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).javDbUrl || \"https://javdb.com\";", + "fingerprint": "8b9ba6d3b2292bce", + "line": 240, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).javBusUrl || \"https://www.javbus.com\";", + "fingerprint": "29fcd55fb9730a5e", + "line": 243, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "return (await this.getSettingCache()).supJavUrl || \"https://supjav.com\";", + "fingerprint": "98e79ee32d4b2050", + "line": 246, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "requestOptions: { cookiePartitionTopLevelSite: \"https://123av.com\" },", + "fingerprint": "0f863893831e98a6", + "line": 30, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/external-search/other-site.js", + "symbol": "initUrl: e => `https://www.dmm.co.jp/search/=/searchstr=${e}`,", + "fingerprint": "7a8f63628dae54ad", + "line": 84, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "return e.attr(\"href\", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`),", + "fingerprint": "24461dc731752ed6", + "line": 152, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "return t.attr(\"href\", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`),", + "fingerprint": "816cccc4a932f260", + "line": 165, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "const r = `https://api.dmm.com/affiliate/v3/ItemList?${new URLSearchParams({", + "fingerprint": "74356afacbae8db1", + "line": 37, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "let a = `https://www.dmm.co.jp/search/=/searchstr=${e}`, i = \"single\";", + "fingerprint": "4ab5e9960b5e709c", + "line": 76, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "return i.attr(\"href\", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`),", + "fingerprint": "c5c68030251c802f", + "line": 91, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "const a = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e}/mtype=AhRVShI_/service=${t}/floor=${n}/mode=/`, i = await gmHttp.get(a, null, {", + "fingerprint": "7af4e622a9e1df41", + "line": 95, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/screenshot.js", + "symbol": "const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(e)}`;", + "fingerprint": "9a9d5bb5fe6df174", + "line": 115, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/screenshot.js", + "symbol": "let url = n.url, a = url.indexOf(\"https://\");", + "fingerprint": "4547b25a79a8d0d9", + "line": 65, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/image-viewer/screenshot.js", + "symbol": "const t = `https://javstore.net/search?q=${encodeURIComponent(e)}`;", + "fingerprint": "bc0e7c3b39c9b9d2", + "line": 78, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/new-video/new-video.js", + "symbol": "json: \"https://cdn.jsdelivr.net/gh/gfriends/gfriends/Filetree.json\",", + "fingerprint": "e0e33117ab22e34f", + "line": 3, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/new-video/new-video.js", + "symbol": "base: \"https://cdn.jsdelivr.net/gh/gfriends/gfriends/Content/\"", + "fingerprint": "a687858fb6f86370", + "line": 4, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const profileUrl = normalizeHttpUrl(`/actors/${encodeURIComponent(starId)}?t=d`, javDbUrl), avatarUrl = normalizeHttpUrl(actress.avatar, javDbUrl) || \"https://c0.jdbstatic.com/images/actor_unknow.jpg\";", + "fingerprint": "a6c873a099438220", + "line": 466, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/new-video/new-video.js", + "symbol": "json: \"https://raw.githubusercontent.com/gfriends/gfriends/master/Filetree.json\",", + "fingerprint": "1b201f77ef86791a", + "line": 7, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/new-video/new-video.js", + "symbol": "base: \"https://raw.githubusercontent.com/gfriends/gfriends/master/Content/\"", + "fingerprint": "dc32b2df19b5df61", + "line": 8, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/offline/unified-offline.js", + "symbol": "one23 && this.registry.register({ id: \"123\", name: \"123 云盘\", capabilities: [ \"magnet\" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting(\"enable123Offline\", !0), getAvailability: async () => one23.getStoredToken() ? { available: !0, authState: \"ready\", reason: \"授权已同步\" } : { available: !1, authState: \"token-missing\", reason: \"尚未同步 123 授权\" }, submit: async resource => { const token = one23.getStoredToken(); if (!token) throw Object.assign(new Error(\"尚未同步 123 授权\"), { code: \"TOKEN_MISSING\" }); const resolved = await one23.resolveMagnet(resource, token); return one23.submitTask(resolved, token); }, openUrl: () => \"https://yun.123pan.com\" });", + "fingerprint": "bab5bf513863361f", + "line": 36, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/offline/unified-offline.js", + "symbol": "this.registry.register({ id: \"115\", name: \"115\", capabilities: [ \"magnet\", \"ed2k\" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting(\"enable115Offline\", !1), getAvailability: async () => ({ available: !0, authState: \"unknown\", reason: \"提交时确认登录状态\" }), submit: resource => new OneOneFiveClient().addOffline(resource), openUrl: () => \"https://115.com\" });", + "fingerprint": "e216b3f7c9f32c26", + "line": 37, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-one-five/client.js", + "symbol": "async getOfflineInfo() { return this.http.get(`https://115.com/?ct=offline&ac=space&_=${Date.now()}`); }", + "fingerprint": "f921f9f3dc8599e0", + "line": 10, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-one-five/client.js", + "symbol": "const result = await this.http.gmRequest(\"POST\", \"https://115.com/web/lixian/?ct=lixian&ac=add_task_url\", body, {}, { \"Content-Type\": \"application/x-www-form-urlencoded\" });", + "fingerprint": "92ec829482378025", + "line": 16, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-one-five/client.js", + "symbol": "return this.http.gmRequest(\"POST\", \"https://webapi.115.com/files/edit\", body, {}, { \"Content-Type\": \"application/x-www-form-urlencoded\" });", + "fingerprint": "628910f51211ae29", + "line": 33, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-one-five/client.js", + "symbol": "function build115PlayUrl(match) { return match?.videoId ? `https://115.com/?ct=play&pickcode=${encodeURIComponent(match.videoId)}` : null; }", + "fingerprint": "b907a95b0dbe9f2b", + "line": 38, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-one-five/client.js", + "symbol": "try { const result = await this.http.get(\"https://webapi.115.com/offine/downpath\"); return Boolean(result?.state && result?.data?.length); } catch (cause) { throw new ProviderError(\"115\", \"LOGIN_REQUIRED\", \"115 未登录\", { cause }); }", + "fingerprint": "953600a918734331", + "line": 4, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-one-five/client.js", + "symbol": "const result = await this.http.get(`https://webapi.115.com/files/search?search_value=${encodeURIComponent(keyword)}&offset=${offset}&limit=${limit}`);", + "fingerprint": "6f3a8f92e27d188f", + "line": 7, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-one-five/plugins.js", + "symbol": "} catch (error) { const box = $(\".jhs-115-match\").empty().append(\"115匹配:\", document.createTextNode(\"未登录或请求失败 \")); box.append('去登录', $('').on(\"click\", (() => location.reload()))); clog.error(\"115 匹配失败\", error); }", + "fingerprint": "285110df3690f294", + "line": 15, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "const n = await gmHttp.post(this._signUrl(\"https://yun.123pan.com/b/api/v2/offline_download/task/resolve\"), { urls: e }, {", + "fingerprint": "6232080e572460e4", + "line": 79, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "Origin: \"https://yun.123pan.com\",", + "fingerprint": "dd262e355d671d24", + "line": 83, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "Referer: \"https://yun.123pan.com/\"", + "fingerprint": "c27a0786e9566b91", + "line": 84, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/one-two-three/offline.js", + "symbol": "const i = await gmHttp.post(this._signUrl(\"https://yun.123pan.com/b/api/v2/offline_download/task/submit\"), {", + "fingerprint": "8d0396b95c779d88", + "line": 96, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/status/bus-detail-page.js", + "symbol": "e && (e.startsWith(\"http://\") || e.startsWith(\"https://\") || e.startsWith(\"/\")) && $(this).attr(\"target\", \"_blank\");", + "fingerprint": "5f02fd95b7c281cc", + "line": 19, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "})), $(\"#search-subtitle-btn\").on(\"click\", (e => utils.openPage(`https://subtitlecat.com/index.php?search=${t}`, t, !1, e))),", + "fingerprint": "dbd0470172709689", + "line": 42, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e}`).then((t => {", + "fingerprint": "9bec89d48da67df2", + "line": 73, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/status/detail-page.js", + "symbol": "e && (e.startsWith(\"http://\") || e.startsWith(\"https://\") || e.startsWith(\"/\")) && $(this).attr(\"target\", \"_blank\");", + "fingerprint": "5f02fd95b7c281cc", + "line": 11, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/status/list-page.js", + "symbol": "const a = \"https://translate-pa.googleapis.com/v1/translate?\" + new URLSearchParams({", + "fingerprint": "7c9c31eb9fe1a9c3", + "line": 3, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "feature-third-party-url", + "file": "src/plugins/status/nav-bar.js", + "symbol": "$('a.navbar-link[href=\"/makers\"]').parent().after('\\n
    \\n 其它\\n
    \\n 反饋\\n ThePornDude\\n
    \\n
    \\n ');", + "fingerprint": "6f417c1ceccd26a4", + "line": 59, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/core/css-injection.js", + "symbol": ".movie-list .item { position:relative!important; }", + "fingerprint": "115bd8681f34bb46", + "line": 32, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/core/css-injection.js", + "symbol": ".movie-list .item .cover img { transform:none!important; transition:none!important; }", + "fingerprint": "ec71d38809bbf6c2", + "line": 33, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/core/plugin-manager.js", + "symbol": "boxSelector: \".movie-list\",", + "fingerprint": "056b0262f4a1a2fc", + "line": 259, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/core/plugin-manager.js", + "symbol": "itemSelector: \".movie-list .item\",", + "fingerprint": "558f7beb9a525915", + "line": 260, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/core/plugin-manager.js", + "symbol": "requestDomItemSelector: \".movie-list .item\",", + "fingerprint": "77f571d3c54946fc", + "line": 262, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/core/site-context.js", + "symbol": "const hasList = null === hasMovieList ? \"undefined\" != typeof $ && $(\".movie-list\").length > 0 : Boolean(hasMovieList);", + "fingerprint": "bfd84db31a3023f7", + "line": 32, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/main.js", + "symbol": "return r ? e.split(\"?\")[0].includes(\"/v/\") : !!l && $(\"#magnet-table\").length > 0;", + "fingerprint": "ecbd94cecd469b95", + "line": 107, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/main.js", + "symbol": "}(), window.isListPage = r ? isListPage(window.location, $(\".movie-list\").length > 0) : !!l && $(\".masonry > div .item\").length > 0;", + "fingerprint": "564a6c5f81d27830", + "line": 108, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/backup/setting-forms.js", + "symbol": "const movieList = document.querySelector(\".movie-list\"), masonry = document.querySelector(\".masonry\");", + "fingerprint": "1ed08e9d355c25dc", + "line": 52, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/backup/setting-styles.js", + "symbol": ".movie-list, .movie-list.v{", + "fingerprint": "1496845b87f7a461", + "line": 28, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "$(\".movie-list\").html(s), await utils.smoothScrollToTop();", + "fingerprint": "1115f55d9cb1c38b", + "line": 83, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "$(\".section .container .box\").remove(), $(\".movie-list.jhs-hitshow-list\").remove(), this.$contentBox.append('
    ');", + "fingerprint": "3ce752d5f6d8e71f", + "line": 19, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "$(\".movie-list\").html(this.markDataListHtml(movies));", + "fingerprint": "c24c7a001f05d600", + "line": 30, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/external-search/other-site.js", + "symbol": "const root = n.root ? $(n.root) : $(document), target = n.target ? $(n.target) : root.find(\".movie-panel-info,.container .info\").first();", + "fingerprint": "3253e9e305fa92d7", + "line": 117, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/external-search/other-site.js", + "symbol": "itemSelector: \".movie-list .item\",", + "fingerprint": "558f7beb9a525915", + "line": 68, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/external-search/top250.js", + "symbol": "let i = $(\".movie-list\");", + "fingerprint": "428fca7a2d3abdd9", + "line": 52, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/external-search/top250.js", + "symbol": "$(\".movie-list\").html(hitShow.markDataListHtml(movies)), await hitShow.initializeRenderedList(), void hitShow.loadScore(movies).catch((error => clog.error(\"Top250 评分加载失败\", error)));", + "fingerprint": "a8f387455b72d71f", + "line": 90, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/new-video/new-video.js", + "symbol": "page.find(\".movie-list .item\").each(((index, element) => {", + "fingerprint": "f81ebadbf8cfc4e8", + "line": 523, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/one-one-five/plugins.js", + "symbol": "this.registerCards($(\".movie-list .item,.masonry .item\").get()), this.unsubscribeItems = jhsEventBus.on(\"list-items-added\", (payload => this.registerCards(payload.items || [])));", + "fingerprint": "28a802d3eadcb9d4", + "line": 23, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/one-one-five/plugins.js", + "symbol": "const host = $(\".movie-panel-info,.container .info\").first(); host.append('
    115匹配:匹配中
    ');", + "fingerprint": "916b01adcc34a558", + "line": 8, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/status/detail-workspace.js", + "symbol": "const hostRoot = $(\".container\").filter(((_, element) => $(element).find(\"#magnet-table\").length > 0)).first(), resourceRoot = hostRoot.find(\"#magnet-table\").first();", + "fingerprint": "290cac73f716b57d", + "line": 20, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/status/detail-workspace.js", + "symbol": "const root = $(\".container\").filter(((_, element) => $(element).find(\"#magnet-table,.screencap,.info\").length > 0)).first();", + "fingerprint": "3d40a3dc5d343ba8", + "line": 79, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/status/detail-workspace.js", + "symbol": "root.children().filter(((_, element) => $(element).is(\"#mag-submit-show,#mag-submit\") || $(element).find(\"#magnet-table\").length > 0)).attr(\"data-jhs-host-region\", \"resources\");", + "fingerprint": "0e875792535a6248", + "line": 97, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/status/highlight-magnet.js", + "symbol": "const e = $(\"#magnet-table tr\");", + "fingerprint": "9cb6907eecd9221e", + "line": 59, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/status/highlight-magnet.js", + "symbol": "l && $(\"#magnet-table tr\").toArray().forEach((e => $(e).show()));", + "fingerprint": "4271e3c9ec389981", + "line": 80, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "host-selector", + "file": "src/plugins/status/list-page.js", + "symbol": "const t = $(\".movie-list .item\").toArray().find((t => $(t).find(\".video-title strong\").text() === e));", + "fingerprint": "8511ff71038c8438", + "line": 499, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 4 + }, + { + "rule": "internal-getbean", + "file": "src/core/plugin-manager.js", + "symbol": "return this.pluginManager.getBean(e);", + "fingerprint": "8920c7fad2b6ca43", + "line": 180, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/backup/setting.js", + "symbol": "const a = this.getBean(\"CoverButtonPlugin\");", + "fingerprint": "36a6626b9f55dc8b", + "line": 120, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/backup/setting.js", + "symbol": "this.getBean(\"CoverButtonPlugin\").enableSvgBtn();", + "fingerprint": "6a9d36aafdbad335", + "line": 138, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/backup/setting.js", + "symbol": "const taskPlugin = this.getBean(\"TaskPlugin\");", + "fingerprint": "b274133597037fac", + "line": 145, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "this.getBean(\"SettingPlugin\").openSettingDialog(\"task-panel\", (() => {", + "fingerprint": "29c461e083a4b6d5", + "line": 117, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const snapshot = this.getBean(\"TaskPlugin\").getTaskStatusSnapshot(\"blacklist\"), labels = { idle: \"正常\", running: \"运行中\", pending: \"等待下一次任务检查\", due: \"待运行\" }, format = value => value ? new Date(value).toLocaleString() : \"无\";", + "fingerprint": "1298acd9577b5f62", + "line": 146, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = await storageManager.getBlacklist(), n = await storageManager.getBlacklistCarList(), a = String($(\"#searchValue\").val() || \"\").trim().toLocaleLowerCase(), i = $(\"#statusType\").val(), s = $(\"#dataType\"), o = s.val(), r = $(\"#urlType\").val(), l = t.length;", + "fingerprint": "d2f2058ca7b5132e", + "line": 155, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const t = $(s), {carNum: n, url: a, publishTime: o} = this.getBean(\"ListPagePlugin\").findCarNumAndHref(t);", + "fingerprint": "250c785b3204ba23", + "line": 341, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const n = $(i), {carNum: a, url: o, publishTime: r} = this.getBean(\"ListPagePlugin\").findCarNumAndHref(n);", + "fingerprint": "2d9d588321a5fa38", + "line": 360, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const element = $(item), {carNum, url, publishTime} = this.getBean(\"ListPagePlugin\").findCarNumAndHref(element);", + "fingerprint": "ff5ead9c8884c6f2", + "line": 396, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const e = this.getBean(\"TaskPlugin\");", + "fingerprint": "a1831a67b4426b56", + "line": 50, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = localStorage.getItem(e.lastCheckBlacklistTimeKey) || \"无\", n = await storageManager.getSetting(\"checkBlacklist_intervalTime\", 12);", + "fingerprint": "17605677fcffe27d", + "line": 91, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/blacklist/blacklist.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = await storageManager.getSetting();", + "fingerprint": "d7c6d7cbb076d669", + "line": 96, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "this.render123AvSummary(context, info), await this.getBean(\"TranslatePlugin\").translate(context.carNum, !1, { root: context.root });", + "fingerprint": "e462c7f990ed866a", + "line": 108, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "context.isAlive() && this.getBean(\"Fc2Plugin\").setState(context.root.find('[data-jhs-role=\"summary-content\"]'), \"影片信息加载失败\", (() => void this.loadSummary(context, url))), clog.error(\"123AV 详情加载失败\", error);", + "fingerprint": "8bd643082a13a7dd", + "line": 111, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "try { return await this.getBean(\"Fc2Plugin\").fetchAndRenderNativeMagnets(context, await resolveJavDbMovieId(context.carNum)); } catch (error) { context.isAlive() && this.getBean(\"Fc2Plugin\").setState(context.root.find('[data-jhs-role=\"native-magnets\"]'), \"站内磁力关联失败\", (() => void this.retryResolvedMagnets(context))); }", + "fingerprint": "0df961fe2528a309", + "line": 116, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "title.find(\"strong\").text(info.title || \"无标题\"), body.append(title, $('
    ').append($(\"\").text(`番号:${context.carNum}`), $(\"\").text(`发行:${info.publishDate || \"未知\"}`)), '
    主演:正在加载演员…
    ', '
    ', this.getBean(\"Fc2Plugin\").createSourceLinks(context), $('').text(info.publishDate || \"\"));", + "fingerprint": "eaf4d12d05e36503", + "line": 120, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "return await this.getBean(\"OtherSitePlugin\").getAv123Url();", + "fingerprint": "29b64da53278e37f", + "line": 13, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "context.isAlive() && this.getBean(\"Fc2Plugin\").renderGallery(context, images);", + "fingerprint": "4f1ad1e6a37e119b", + "line": 162, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "context.isAlive() && this.getBean(\"Fc2Plugin\").setState(context.root.find('[data-jhs-role=\"gallery-grid\"]'), \"剧照加载失败\", (() => void this.reloadImages(context)));", + "fingerprint": "f2c6f404517efe86", + "line": 164, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "async open123AvFc2Dialog(carNum, url) { return this.getBean(\"Fc2Plugin\").openFc2Dialog(null, carNum, url, { source: \"123av\" }); }", + "fingerprint": "dc8c0a65b84dbd0a", + "line": 90, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum), movieIdPromise = resolveJavDbMovieId(context.carNum), fc2Plugin = this.getBean(\"Fc2Plugin\");", + "fingerprint": "554c6aa2f85a5656", + "line": 93, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2-by-123av.js", + "symbol": "imagesPromise.then((images => context.isAlive() && this.getBean(\"Fc2Plugin\").renderGallery(context, images))).catch((error => context.isAlive() && this.getBean(\"Fc2Plugin\").setState(context.root.find('[data-jhs-role=\"gallery-grid\"]'), \"剧照加载失败\", (() => void this.reloadImages(context)))));", + "fingerprint": "0359e6625497a35e", + "line": 97, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "void this.getBean(\"FilterTitleKeywordPlugin\").bindDetailRoot(context.root, { layerIndex: context.layerIndex ?? null });", + "fingerprint": "59383929baa1b289", + "line": 105, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "void this.getBean(\"OtherSitePlugin\").loadOtherSite(context.carNum.replace(\"FC2-\", \"\"), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role=\"other-sites\"]'), autoDetect: !1, isActive: context.isAlive }).then((box => { if (context.isAlive() && !box) sitesGroup.remove(); })).catch((error => {", + "fingerprint": "b63494acfbe4ea22", + "line": 106, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "void this.getBean(\"ScreenShotPlugin\").loadInto(screenshot, context.carNum.replace(\"FC2-\", \"\"), { isActive: context.isAlive }).then((result => { if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); }));", + "fingerprint": "aad5ed6e91339f66", + "line": 109, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "\"123av\" === context.source ? void this.getBean(\"Fc2By123AvPlugin\").loadDetail(context, context.url) : void this.loadNativeDetail(context);", + "fingerprint": "d8b1e4d9b025d092", + "line": 110, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "const loginPlugin = this.getBean(\"TOP250Plugin\");", + "fingerprint": "a4cb795e0fe18915", + "line": 144, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "this.renderSummary(context, movie), this.renderGallery(context, movie.imgList || []), await this.getBean(\"TranslatePlugin\").translate(movie.carNum || context.carNum, !1, { root: context.root });", + "fingerprint": "31725d980bf2eae6", + "line": 156, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "const highlighter = this.getBean(\"HighlightMagnetPlugin\"), assessments = [];", + "fingerprint": "e8ebc91e721959fc", + "line": 190, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "await Promise.allSettled([ this.getBean(\"ReviewPlugin\").showReview(movieId, context.getSlot(\"reviews\"), { ownedSection: context.getSection(\"reviews\"), isActive: context.isAlive }), this.getBean(\"RelatedPlugin\").showRelated(context.getSlot(\"related\"), movieId, { ownedSection: context.getSection(\"related\"), isActive: context.isAlive }) ]);", + "fingerprint": "8cbbdb9f62f54dfa", + "line": 213, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "try { return new URL(record.url, window.location.origin).hostname === new URL(await this.getBean(\"OtherSitePlugin\").getAv123Url()).hostname ? \"123av\" : \"fc2\"; } catch (error) { return \"fc2\"; }", + "fingerprint": "43a8abaaa51a132e", + "line": 224, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "const baseUrl = await this.getBean(\"OtherSitePlugin\").getJavDbUrl();", + "fingerprint": "278a2008e543b34e", + "line": 227, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "toolbar.on(`click${context.namespace}`, '[data-jhs-action=\"subtitlecat\"]', (event => utils.openPage(`https://subtitlecat.com/index.php?search=${encodeURIComponent(context.carNum)}`, context.carNum, !1, event))), toolbar.on(`click${context.namespace}`, '[data-jhs-action=\"xunlei\"]', (() => this.getBean(\"DetailPageButtonPlugin\").searchXunLeiSubtitle(context.carNum)));", + "fingerprint": "951fc431f71208c1", + "line": 94, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/fc2.js", + "symbol": "magnetHubPromise ||= this.getBean(\"MagnetHubPlugin\").createMagnetHub(context.carNum, { root: context.root });", + "fingerprint": "2515016ee3d6b30f", + "line": 99, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "await this.getBean(\"ListPageButtonPlugin\").sortItems();", + "fingerprint": "df87faaa13b41bce", + "line": 32, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "if (generation === this.loadGeneration && \"rateCount\" === localStorage.getItem(\"jhs_sortMethod\")) await this.getBean(\"ListPageButtonPlugin\").sortItems();", + "fingerprint": "e7b16ecc00501479", + "line": 35, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "const listPage = this.getBean(\"ListPagePlugin\");", + "fingerprint": "9c77d94ef788d661", + "line": 54, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/hit-show.js", + "symbol": "this.getBean(\"CoverButtonPlugin\").addSvgBtn();", + "fingerprint": "2c47c491ad61f37e", + "line": 56, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/related.js", + "symbol": "const enabled = await storageManager.getSetting(\"enableLoadRelated\", C), host = target?.length ? target : this.getBean(\"DetailWorkspacePlugin\")?.getSlot(\"related\");", + "fingerprint": "fa41c3743064bde2", + "line": 21, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/review.js", + "symbol": "const workspace = this.getBean(\"DetailWorkspacePlugin\");", + "fingerprint": "2e03a57d79b8db94", + "line": 36, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/review.js", + "symbol": "await Promise.all([ this.showReview(movieId, workspace?.getSlot(\"reviews\")), this.getBean(\"RelatedPlugin\").showRelated(workspace?.getSlot(\"related\"), movieId) ]);", + "fingerprint": "0a21412c748fa88e", + "line": 37, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/review.js", + "symbol": "movieId && await this.showReview(movieId, this.getBean(\"DetailWorkspacePlugin\")?.getSlot(\"reviews\"));", + "fingerprint": "c844849fb83d228d", + "line": 43, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/review.js", + "symbol": "const enabled = await storageManager.getSetting(\"enableLoadReview\", _), host = target?.length ? target : this.getBean(\"DetailWorkspacePlugin\")?.getSlot(\"reviews\") || $(\"#magnets-content\");", + "fingerprint": "b018f4ac963d7ced", + "line": 48, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/top250.js", + "symbol": "const n = t.filter((e => \"1\" === this.has_cnsub ? e.has_cnsub : \"0\" !== this.has_cnsub || !e.has_cnsub)), a = this.getBean(\"HitShowPlugin\");", + "fingerprint": "28a3c4c959449bb5", + "line": 63, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/external-search/top250.js", + "symbol": "const movies = this.movies.filter((movie => \"1\" === this.has_cnsub ? movie.has_cnsub : \"0\" !== this.has_cnsub || !movie.has_cnsub)), hitShow = this.getBean(\"HitShowPlugin\");", + "fingerprint": "39b811fb4053463e", + "line": 89, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/image-viewer/cover-button.js", + "symbol": "const s = await this.getBean(\"ScreenShotPlugin\").getScreenshot(i);", + "fingerprint": "5189f5972f8faed7", + "line": 120, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/image-viewer/cover-button.js", + "symbol": "const t = this.getBean(\"OtherSitePlugin\"), n = await t.getMissAvUrl(), a = await t.getjableUrl(), i = await t.getAvgleUrl(), s = await t.getAv123Url();", + "fingerprint": "3aa53843f1f5edb4", + "line": 139, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/image-viewer/cover-button.js", + "symbol": "const e = this.getBean(\"ListPagePlugin\");", + "fingerprint": "9693785453491111", + "line": 85, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "this.getBean(\"DetailPageButtonPlugin\").filterOne(event)))),", + "fingerprint": "972c2c0754b18ecc", + "line": 319, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "$(\"#video-filterBtn\").off(\"click.jhsVideo\").on(\"click.jhsVideo\", (event => this.getBean(\"DetailPageButtonPlugin\").filterOne(event))),", + "fingerprint": "d0099ef4d7472cd8", + "line": 320, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/image-viewer/preview-video.js", + "symbol": "$(\"#video-favoriteBtn\").off(\"click.jhsVideo\").on(\"click.jhsVideo\", (event => this.getBean(\"DetailPageButtonPlugin\").favoriteOne(event)));", + "fingerprint": "46cf660442f2430a", + "line": 321, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = await storageManager.getSetting(), n = localStorage.getItem(e.lastCheckFavoriteActressTimeKey) || \"无\", a = t.checkFavoriteActress_IntervalTime, i = localStorage.getItem(e.lastCheckNewVideoTimeKey) || \"无\", s = t.checkNewVideo_intervalTime;", + "fingerprint": "4a5e2c5f3161a63e", + "line": 267, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const e = this.getBean(\"TaskPlugin\"), t = await storageManager.getSetting(), n = localStorage.getItem(e.lastCheckFavoriteActressTimeKey) || \"无\", a = t.checkFavoriteActress_IntervalTime, i = localStorage.getItem(e.lastCheckNewVideoTimeKey) || \"无\", s = t.checkNewVideo_intervalTime;", + "fingerprint": "4a5e2c5f3161a63e", + "line": 272, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const taskPlugin = this.getBean(\"TaskPlugin\");", + "fingerprint": "b274133597037fac", + "line": 323, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "this.getBean(\"SettingPlugin\").openSettingDialog(\"task-panel\", (() => {", + "fingerprint": "29c461e083a4b6d5", + "line": 337, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "await navigator.locks.request(this.getBean(\"TaskPlugin\").singleTaskKey, { ifAvailable: !0 }, (async lock => {", + "fingerprint": "23c7ba37fd417f52", + "line": 376, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "clog.error(\"手动任务执行失败\", error), this.getBean(\"TaskPlugin\").isNetworkBlocked(error) && show.error(error.message || \"任务执行失败\");", + "fingerprint": "97aedb7ee3be3900", + "line": 381, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const [ actresses, carMap, decisions, javDbUrl, ruleTime ] = await Promise.all([ storageManager.getFavoriteActressList(), storageManager.getCarMap(), stateService.getNewVideoDecisions(), this.getBean(\"OtherSitePlugin\").getJavDbUrl(), storageManager.getSetting(\"checkNewVideo_ruleTime\", 8760) ]);", + "fingerprint": "c30fe9db1399d4fc", + "line": 409, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const taskPlugin = this.getBean(\"TaskPlugin\"), names = { favoriteActress: \"演员同步\", newVideo: \"新作品\", blacklist: \"黑名单\" }, labels = { idle: \"正常\", running: \"运行中\", pending: \"等待下一次任务检查\", due: \"待运行\" }, format = value => value ? new Date(value).toLocaleString() : \"无\";", + "fingerprint": "406a4ff98add9dd5", + "line": 424, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "const safePageStart = (this.currentPage - 1) * this.pageSize, pageActresses = sortedActresses.slice(safePageStart, safePageStart + this.pageSize), javDbUrl = this.nvJavDbUrl, taskPlugin = this.getBean(\"TaskPlugin\"), ruleTime = this.nvRuleTime;", + "fingerprint": "c03e7c746db973d6", + "line": 457, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/new-video.js", + "symbol": "let e = `${await this.getBean(\"OtherSitePlugin\").getJavDbUrl()}/actors/${t}/uncollect`;", + "fingerprint": "1cc0de22cbae32ac", + "line": 484, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/task.js", + "symbol": "await this.loadConfig(), this.javDbUrl = await this.getBean(\"OtherSitePlugin\").getJavDbUrl();", + "fingerprint": "d3aa61cd6e8a7841", + "line": 185, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/task.js", + "symbol": "(!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getBean(\"OtherSitePlugin\").getJavDbUrl());", + "fingerprint": "950d580a70e9e33d", + "line": 228, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/task.js", + "symbol": "const target = new URL(url), otherSite = this.getBean(\"OtherSitePlugin\"), [ javDbUrl, javBusUrl ] = await Promise.all([ otherSite?.getJavDbUrl?.(), otherSite?.getJavBusUrl?.() ]);", + "fingerprint": "490d690c135f17c2", + "line": 233, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/task.js", + "symbol": "const blacklistPlugin = this.getBean(\"BlacklistPlugin\");", + "fingerprint": "2038db1efbfde15b", + "line": 279, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/task.js", + "symbol": "try { await this.getBean(\"BlacklistPlugin\").resetBtnTip(); } catch (error) { clog.error(\"刷新黑名单检测提示失败\", error); }", + "fingerprint": "14db4bcbc8abde77", + "line": 308, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/new-video/task.js", + "symbol": "const e = $(m), {carNum: s, url: o, title: r, publishTime: l} = this.getBean(\"ListPagePlugin\").findCarNumAndHref(e);", + "fingerprint": "f0bf5d912a4aa983", + "line": 442, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/offline/unified-offline.js", + "symbol": "const one23 = this.getBean(\"OneTwoThreeOfflinePlugin\");", + "fingerprint": "ca2b386a54816016", + "line": 35, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/offline/unified-offline.js", + "symbol": "return item?.length ? this.getBean(\"ListPagePlugin\").findCarNumAndHref(item) : this.getPageInfo();", + "fingerprint": "8fa7662a6ddc35c0", + "line": 73, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/stats/stats.js", + "symbol": "const topActresses = [ ...actressCounts.values() ].sort(((left, right) => right.count - left.count || left.name.localeCompare(right.name))).slice(0, 10), topValue = topActresses[0]?.count || 1, javDbUrl = await this.getBean(\"OtherSitePlugin\").getJavDbUrl();", + "fingerprint": "70396f212c910e2d", + "line": 40, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/stats/stats.js", + "symbol": "const pending = counts.pending, counter = this.getBean(\"NewVideoPlugin\"), newVideos = counter ? await counter.getPendingNewVideoTotal() : 0, pageSummary = this.getBean(\"ListPagePlugin\").getCurrentPageSummary();", + "fingerprint": "a61837d6493cc62f", + "line": 41, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/stats/stats.js", + "symbol": "if (\"new-video\" === action) return this.getBean(\"NewVideoPlugin\").openDialog();", + "fingerprint": "d8f7bd5f59658dde", + "line": 72, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/stats/stats.js", + "symbol": "if (\"filter\" === action) this.getBean(\"ListPagePlugin\").setQuickFilter(metric.data(\"filter\"));", + "fingerprint": "f1f268183c219c43", + "line": 73, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/auto-page.js", + "symbol": "const p = this.getBean(\"ListPagePlugin\");", + "fingerprint": "da1b10394e6efcb2", + "line": 72, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/bus-nav-bar.js", + "symbol": "this.getBean(\"SearchByImagePlugin\").open();", + "fingerprint": "6f59261de6cc3605", + "line": 8, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/compat-enhancements.js", + "symbol": "button.on(\"click\", (event => utils.q(event, `确定移除 ${carNum} 的鉴定记录?`, (async () => { await stateService.remove(carNum); button.remove(); this.getBean(\"ListPagePlugin\")?.showCarNumBox?.(carNum); show.ok(\"鉴定记录已移除\"); }))));", + "fingerprint": "f77e70cd76b8cded", + "line": 20, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "const workspaceSlot = this.getBean(\"DetailWorkspacePlugin\")?.getSlot(\"summary-actions\");", + "fingerprint": "d4143b650f810bc8", + "line": 22, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "let t = await this.getBean(\"MagnetHubPlugin\").createMagnetHub(e.carNum);", + "fingerprint": "c326d700277cd5c9", + "line": 24, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/detail-page-button.js", + "symbol": "const a = this.getBean(\"HighlightMagnetPlugin\"), i = await storageManager.getSetting(\"enableMagnetsFilter\", _);", + "fingerprint": "513d7bc75e07e44e", + "line": 36, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/history.js", + "symbol": "await stateService.remove(t), this.getBean(\"ListPagePlugin\").showCarNumBox(t),", + "fingerprint": "53ff545228b2701b", + "line": 469, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/history.js", + "symbol": "const plugin = this.getBean(\"Fc2Plugin\"), source = await plugin.resolveFc2Source(t);", + "fingerprint": "7229df8f5e3d309e", + "line": 476, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/history.js", + "symbol": "const plugin = this.getBean(\"Fc2Plugin\"), source = await plugin.resolveFc2Source(t);", + "fingerprint": "7229df8f5e3d309e", + "line": 486, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/history.js", + "symbol": "item && await this.getBean(\"UnifiedOfflinePlugin\").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: !0, preferredProviderId: item.providerId }), await this.renderOfflineHistory();", + "fingerprint": "8c2f575c946c437f", + "line": 89, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/history.js", + "symbol": "const id = $(event.currentTarget).data(\"id\"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)), provider = this.getBean(\"UnifiedOfflinePlugin\").registry.providers.get(item?.providerId), url = provider?.openUrl?.();", + "fingerprint": "4251533e18221ede", + "line": 91, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page-button.js", + "symbol": "const listPage = this.getBean(\"ListPagePlugin\");", + "fingerprint": "9c77d94ef788d661", + "line": 160, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page-button.js", + "symbol": "this.getBean(\"NewVideoPlugin\").openDialog();", + "fingerprint": "d208a1b0497178c8", + "line": 56, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page-button.js", + "symbol": "this.getBean(\"BlacklistPlugin\").openBlacklistDialog();", + "fingerprint": "f4bf2ea6165ffb8c", + "line": 58, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page-button.js", + "symbol": "const e = this.getBean(\"BlacklistPlugin\");", + "fingerprint": "f9d826e2c1fd81da", + "line": 60, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page.js", + "symbol": "const e = this.getBean(\"HistoryPlugin\");", + "fingerprint": "de5be54ac4fdfe07", + "line": 138, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page.js", + "symbol": "const history = this.getBean(\"HistoryPlugin\");", + "fingerprint": "ff818bb09f4b1ace", + "line": 146, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page.js", + "symbol": "await this.getBean(\"ListPageButtonPlugin\").sortItems(), await this.getBean(\"CoverButtonPlugin\").addSvgBtn(items),", + "fingerprint": "67957b89d5e43d0e", + "line": 250, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page.js", + "symbol": "items.forEach((item => item.dataset.jhsProcessed = \"true\")), this.indexItems(items), await jhsEventBus.emit(\"list-items-added\", { items }, { broadcast: !1 }), this.getBean(\"AutoPagePlugin\").checkLoad();", + "fingerprint": "d76eb5074cb341d9", + "line": 251, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page.js", + "symbol": "this.getBean(\"BusImgPlugin\").logImageHeightsByRow().catch((e => clog.error(\"JavBus图片高度修正失败\", e)));", + "fingerprint": "bc7ce899a518e4f4", + "line": 315, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/list-page.js", + "symbol": "return shouldOpenTab ? this.getBean(\"Fc2Plugin\").openFc2Page(movieId, carNum, aHref, { event, newTab: !0 }, { source: fc2Source }) : this.getBean(\"Fc2Plugin\").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source });", + "fingerprint": "7f8c1ce0a31df0a0", + "line": 446, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "this.getBean(\"ListPagePlugin\")?.syncQuickFilterUi();", + "fingerprint": "b2851f2cb04e4f23", + "line": 261, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "const basePlugin = this.getBean(\"DetailPageButtonPlugin\");", + "fingerprint": "9031393439607ca4", + "line": 266, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "const sortMethod = localStorage.getItem(\"jhs_sortMethod\") || \"default\", sortLabels = { default: \"默认\", rateCount: \"评价人数\", date: \"时间\" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getBean(\"ListPagePlugin\")?.activeQuickFilter),", + "fingerprint": "875e93e461729a51", + "line": 277, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "this.getBean(\"ListPagePlugin\")?.syncQuickFilterUi();", + "fingerprint": "b2851f2cb04e4f23", + "line": 337, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "event.stopPropagation(), this.getBean(\"ListPagePlugin\").setQuickFilter($(event.currentTarget).data(\"jhs-filter\")), closeMenu(!0);", + "fingerprint": "bb24573e35d4c0f6", + "line": 374, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "localStorage.setItem(\"jhs_sortMethod\", value), sortMenu.find(\".jhs-mobile-sort-option\").attr(\"aria-checked\", \"false\"), $(event.currentTarget).attr(\"aria-checked\", \"true\"), void this.getBean(\"ListPageButtonPlugin\")?.sortItems?.(), closeMenu(!0);", + "fingerprint": "21368a3458151e2b", + "line": 386, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "await this.getBean(\"ListPageButtonPlugin\")?.openWaitCheck?.();", + "fingerprint": "7e4afac91fc1944e", + "line": 409, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "this.getBean(\"NewVideoPlugin\")?.openDialog();", + "fingerprint": "dcc1a139ccfea331", + "line": 412, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "this.getBean(\"BlacklistPlugin\")?.openBlacklistDialog();", + "fingerprint": "a89553fe6202aa36", + "line": 415, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/mobile-bottom-bar.js", + "symbol": "await this.getBean(\"SettingPlugin\")?.openQuickSetting();", + "fingerprint": "41783f61e78a0978", + "line": 443, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/nav-bar.js", + "symbol": "const a = this.getBean(\"SearchByImagePlugin\");", + "fingerprint": "9b575a291e4d44e1", + "line": 32, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/nav-bar.js", + "symbol": "this.getBean(\"SearchByImagePlugin\").open();", + "fingerprint": "6f59261de6cc3605", + "line": 45, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + }, + { + "rule": "internal-getbean", + "file": "src/plugins/status/nav-bar.js", + "symbol": "this.getBean(\"SearchByImagePlugin\").open();", + "fingerprint": "6f59261de6cc3605", + "line": 54, + "reason": "legacy debt recorded before the v6.5 migration", + "removalPhase": 6 + } + ] +} diff --git a/docs/architecture/adr/001-module-boundaries.md b/docs/architecture/adr/001-module-boundaries.md new file mode 100644 index 00000000..dc936e0f --- /dev/null +++ b/docs/architecture/adr/001-module-boundaries.md @@ -0,0 +1,5 @@ +# ADR-001:模块边界与依赖反转 + +状态:Accepted + +采用 `contracts Port → platform Adapter → Service → Feature`。Feature、Service 和 Integration 不得 import platform;Composition Root 创建 AppContext 并只按 manifest 的 `requires` 注入冻结后的依赖子集。缺失、重复或未声明依赖立即失败并写入 Diagnostics。完整 AppContext 不进入业务模块。 diff --git a/docs/architecture/adr/002-feature-contribution.md b/docs/architecture/adr/002-feature-contribution.md new file mode 100644 index 00000000..4c34dad1 --- /dev/null +++ b/docs/architecture/adr/002-feature-contribution.md @@ -0,0 +1,5 @@ +# ADR-002:Feature 与 Contribution + +状态:Accepted + +Feature 表示可激活的用户能力,Contribution 保留旧 Plugin 的独立启停粒度。system Feature 不可禁用。关闭 Contribution 时不得注册其专属 listener、CSS、command 或网络工作,但可复用 Feature 的共享 UI primitive CSS。Command owner 由 manifest 预注册,调用时可懒激活 owner。 diff --git a/docs/architecture/adr/003-integration-manifest.md b/docs/architecture/adr/003-integration-manifest.md new file mode 100644 index 00000000..b072f0a1 --- /dev/null +++ b/docs/architecture/adr/003-integration-manifest.md @@ -0,0 +1,5 @@ +# ADR-003:Integration Manifest 与质量等级 + +状态:Accepted + +每个 Integration 必须声明 id、trustClass、hosts、capabilities、requires、cachePolicy、factory 与 quality。Adapter 只返回 normalized contracts,不返回 DOM、jQuery、HTML fragment 或原始响应。Bronze 要求契约 fixture、错误标准化和 HTML 隔离;Silver 另要求适用的 cache/dedupe、AbortSignal、异常响应、fallback 与 Diagnostics。不适用项必须显式为 `none`。 diff --git a/docs/architecture/adr/004-provider-registry.md b/docs/architecture/adr/004-provider-registry.md new file mode 100644 index 00000000..4a86ec6f --- /dev/null +++ b/docs/architecture/adr/004-provider-registry.md @@ -0,0 +1,5 @@ +# ADR-004:Provider Registry + +状态:Accepted + +ProviderRegistry 仅负责注册、能力过滤、优先级、启停、availability 和健康状态。缓存、认证、重试与 fallback 由 MagnetService、ScreenshotService、OfflineService 等业务 Owner 管理,不建立万能 Provider 基类。 diff --git a/docs/architecture/adr/005-detail-surface.md b/docs/architecture/adr/005-detail-surface.md new file mode 100644 index 00000000..b6b959b9 --- /dev/null +++ b/docs/architecture/adr/005-detail-surface.md @@ -0,0 +1,5 @@ +# ADR-005:Detail Surface 与 HostAdapter + +状态:Accepted + +HostedDetailSurface 只能通过 JavDB/JavBus HostAdapter 读取和定位宿主 DOM;Feature/UI 中宿主 selector 为零。OwnedDetailSurface 统一 FC2/123AV 自有布局。关闭、切换和重挂载必须 dispose,旧异步结果不得写入新 root。 diff --git a/docs/architecture/adr/006-settings-profile.md b/docs/architecture/adr/006-settings-profile.md new file mode 100644 index 00000000..5bbef627 --- /dev/null +++ b/docs/architecture/adr/006-settings-profile.md @@ -0,0 +1,5 @@ +# ADR-006:Settings 与 Profile + +状态:Accepted + +SettingsService 在同一 runtime 内以校验、持久化、原子替换 snapshot、发布 `settings.changed` 的顺序更新。v6.5 不新增跨 Tab 设置热同步,其他 Tab 在下一次加载读取。Profile 为 compact/regular/wide,监听尺寸、方向和 pointer media 变化;底部 UI 使用 safe-area inset。 diff --git a/docs/architecture/adr/007-request-key-cache.md b/docs/architecture/adr/007-request-key-cache.md new file mode 100644 index 00000000..bddabbc5 --- /dev/null +++ b/docs/architecture/adr/007-request-key-cache.md @@ -0,0 +1,5 @@ +# ADR-007:RequestKey 与 Cache + +状态:Accepted + +RequestKey 包含 provider、method、canonical URL、body hash、response type、cache scope、vary headers hash 与 session scope id。公开 GET 可 public cache/dedupe,登录 GET 仅 session 内存范围,修改请求默认不缓存也不通用合并。凭证原值不得进入 key、缓存、日志或 Diagnostics。Cache schema 仅是可丢弃 namespace 版本。 diff --git a/docs/architecture/adr/008-lifecycle-cancellation.md b/docs/architecture/adr/008-lifecycle-cancellation.md new file mode 100644 index 00000000..05b30706 --- /dev/null +++ b/docs/architecture/adr/008-lifecycle-cancellation.md @@ -0,0 +1,5 @@ +# ADR-008:Lifecycle 与 Cancellation + +状态:Accepted + +除 App Root Lifecycle 外,listener、observer、timer 和 request consumer 都必须属于可 dispose 的 LifecycleScope。共享请求使用 consumer 引用计数;scope dispose 后其 consumer 为零,且无其他 live consumer 时才取消底层请求。异步提交前校验 scope 与 mount generation。 diff --git a/docs/architecture/adr/009-storage-migration.md b/docs/architecture/adr/009-storage-migration.md new file mode 100644 index 00000000..a1b0b6b4 --- /dev/null +++ b/docs/architecture/adr/009-storage-migration.md @@ -0,0 +1,5 @@ +# ADR-009:Storage 与最小迁移 + +状态:Accepted + +保留 `JAV-JHS`、既有 key 与 `data_version=2`。Settings migration 只 journal 被修改字段的 previous value,失败时局部恢复;未修改领域不做 DB snapshot。新备份不导出 Cache,旧 Cache 可导入后直接失效。disabledPlugins 写新 Contribution ID,同时保留未知 legacy 值。 diff --git a/docs/architecture/adr/010-url-trust.md b/docs/architecture/adr/010-url-trust.md new file mode 100644 index 00000000..4d2519ad --- /dev/null +++ b/docs/architecture/adr/010-url-trust.md @@ -0,0 +1,5 @@ +# ADR-010:外部 URL 信任 + +状态:Accepted + +所有初始 URL 与响应 finalUrl 均由 ExternalUrlPolicy 校验。builtin-public 限 manifest HTTPS host;custom-public 要求 HTTPS 且拒绝 localhost/private literal;user-local 允许 HTTP/HTTPS 与私网,但必须精确授权 scheme+host+port,redirect 不得离开授权 origin。Phase 4 后 Integration 不得绕过此边界。 diff --git a/docs/architecture/adr/011-vendor-bridge.md b/docs/architecture/adr/011-vendor-bridge.md new file mode 100644 index 00000000..007d4205 --- /dev/null +++ b/docs/architecture/adr/011-vendor-bridge.md @@ -0,0 +1,5 @@ +# ADR-011:Vendor Bridge + +状态:Accepted + +生产运行时只通过 Vendor Bridge 读取 `@require` 注入的 jQuery、Tabulator、layer、md5、Toastify、localforage 与 Viewer。禁止从 npm import 或打包第二份 Vendor。Bootstrap 统一验证依赖;测试可注入 stub 或 npm implementation。 diff --git a/docs/architecture/adr/012-browser-release-gate.md b/docs/architecture/adr/012-browser-release-gate.md new file mode 100644 index 00000000..0467b711 --- /dev/null +++ b/docs/architecture/adr/012-browser-release-gate.md @@ -0,0 +1,5 @@ +# ADR-012:Browser 与发布门禁 + +状态:Accepted + +Playwright 位于 Node 22+ 独立子项目,不进入根项目依赖或默认本地 check。本地只驱动已安装的 msedge(可切 chrome),不得自动下载;CI ephemeral runner 只安装 Chromium。Harness 以真实 host URL 配合本地 `route.fulfill`,其他网络全部 abort。自动 smoke 不替代 Tampermonkey 人工发布 Gate。 diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md new file mode 100644 index 00000000..1f2e7a88 --- /dev/null +++ b/docs/architecture/adr/README.md @@ -0,0 +1,16 @@ +# JHS 6.5 Architecture Decision Records + +本目录记录 v6.5 已冻结的架构决策。实施中只允许补充已定义决策的实现细节;改变决策须新增 superseding ADR。 + +1. [模块边界与依赖反转](001-module-boundaries.md) +2. [Feature 与 Contribution](002-feature-contribution.md) +3. [Integration Manifest 与质量等级](003-integration-manifest.md) +4. [Provider Registry](004-provider-registry.md) +5. [Detail Surface 与 HostAdapter](005-detail-surface.md) +6. [Settings 与 Profile](006-settings-profile.md) +7. [RequestKey 与 Cache](007-request-key-cache.md) +8. [Lifecycle 与 Cancellation](008-lifecycle-cancellation.md) +9. [Storage 与最小迁移](009-storage-migration.md) +10. [外部 URL 信任](010-url-trust.md) +11. [Vendor Bridge](011-vendor-bridge.md) +12. [Browser 与发布门禁](012-browser-release-gate.md) diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md new file mode 100644 index 00000000..0ad08a33 --- /dev/null +++ b/docs/architecture/ownership.md @@ -0,0 +1,22 @@ +# JHS 6.5 代码职责 Ownership + +Ownership 描述的是唯一职责归属,不是人员归属。新增行为必须进入对应 Owner;跨层调用只通过 contracts 中的契约。 + +| 职责 | Owner | +| --- | --- | +| 应用装配、运行时实例生命周期 | App Composition Root | +| Feature 激活、Contribution 启停、Command owner | FeatureRuntime | +| Movie identity | MovieIdentityService | +| JavDB/JavBus 当前页面 selector 与读取 | 对应 HostAdapter | +| JavDB/JavBus 远程协议与解析 | 对应 Integration | +| Review/Related/Magnet/Screenshot/Offline 业务 | 对应 Service | +| Review/Related 等渲染 | 对应 Panel/View | +| Hosted 详情布局 | HostedDetailSurface | +| FC2/123AV 自有详情布局 | OwnedDetailSurface | +| History 跨页选择 | HistorySelectionModel | +| 外部 URL 信任与 redirect 复验 | ExternalUrlPolicy | +| 请求合并、缓存与取消 | HttpService / CacheService / LifecycleScope | +| 设置 snapshot 与本 runtime 事件 | SettingsService | +| 诊断数据和脱敏导出 | DiagnosticsService | + +判断缺陷归属时先确定表中 Owner,再由 Owner 调用下层契约;不得把行为放入“当前碰巧能拿到数据”的 Feature。 diff --git a/package-lock.json b/package-lock.json index 273d67ac..4982ad45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "esbuild": "^0.28.0", "jquery": "^3.7.1", "jsdom": "^26.1.0", + "typescript": "5.9.3", "vitest": "^4.1.8" }, "engines": { @@ -2000,6 +2001,20 @@ "license": "0BSD", "optional": true }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/package.json b/package.json index 2b2a51af..b37d8d1b 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,22 @@ }, "scripts": { "build": "node scripts/build.mjs", + "build:dev": "node scripts/build-dev.mjs", "test": "vitest run", "test:watch": "vitest", - "check": "npm run test && npm run build && node scripts/check-sources.mjs && node scripts/release-contract.mjs && node scripts/regression-check.mjs && node scripts/contrast-audit.mjs && node scripts/ui-consistency-audit.mjs && node --check JHS.user.js && node --check dist/JHS.user.js" + "check:js": "tsc -p tsconfig.architecture.json && node scripts/checkjs-coverage.mjs", + "check:architecture": "node scripts/architecture-check.mjs", + "check:integrations": "node scripts/integration-quality-check.mjs", + "check:performance": "node scripts/performance-budget-check.mjs", + "check": "npm run test && npm run build && npm run check:js && npm run check:architecture && npm run check:integrations && npm run check:performance && node scripts/check-sources.mjs && node scripts/release-contract.mjs && node scripts/regression-check.mjs && node scripts/contrast-audit.mjs && node scripts/ui-consistency-audit.mjs && node --check JHS.user.js && node --check dist/JHS.user.js", + "check:browser": "npm --prefix tests/browser test", + "check:full": "npm run check && npm run check:browser" }, "devDependencies": { "esbuild": "^0.28.0", "jquery": "^3.7.1", "jsdom": "^26.1.0", + "typescript": "5.9.3", "vitest": "^4.1.8" } } diff --git a/performance-budget.json b/performance-budget.json new file mode 100644 index 00000000..afe2742c --- /dev/null +++ b/performance-budget.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "bundle": { + "baselineBytes": 1008305, + "maximumGrowthRatio": 0.1, + "reason": "v6.5 architecture baseline from f73a1e1 before implementation" + }, + "browserFixture": { + "maximumInitialRequests": { + "JavDB": 0, + "JavBus": 3 + }, + "startupRegressionRatio": 0.1 + } +} diff --git a/scripts/architecture-check.mjs b/scripts/architecture-check.mjs new file mode 100644 index 00000000..720759cb --- /dev/null +++ b/scripts/architecture-check.mjs @@ -0,0 +1,152 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const baselinePath = path.join(rootDir, "architecture-baseline.json"); + +const rules = [ + { id: "internal-getbean", pattern: /\.getBean\s*\(/, phase: 6 }, + { id: "feature-direct-gmhttp", pattern: /\bgmHttp\s*\./, phase: 5, featureOnly: true }, + { id: "feature-direct-layer", pattern: /\blayer\s*\./, phase: 5, featureOnly: true }, + { id: "feature-direct-localstorage", pattern: /\blocalStorage\s*\./, phase: 5, featureOnly: true }, + { id: "feature-third-party-url", pattern: /https?:\/\//, phase: 4, featureOnly: true }, + { id: "host-selector", pattern: /(?:\.movie-panel-info|#magnet-table|\.movie-list)/, phase: 4 }, + { id: "app-global-listener", pattern: /(?:window|document)\.addEventListener\s*\(/, phase: 7 }, + { id: "app-global-observer", pattern: /new\s+MutationObserver\s*\(/, phase: 7 }, +]; + +async function listJavaScriptFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name); + if (entry.isDirectory()) return listJavaScriptFiles(fullPath); + return entry.isFile() && entry.name.endsWith(".js") ? [fullPath] : []; + })); + return nested.flat(); +} + +function normalizeLine(line) { + return line.trim() + .replace(/^export\s+/, "") + // Dependency injection is the approved in-place replacement for the + // legacy locator; unrelated debt on the same line keeps its identity. + .replace(/\.getDependency\s*\(/g, ".getBean(") + .replace(/\s+/g, " "); +} + +function fingerprint(rule, normalizedLine) { + return createHash("sha256").update(`${rule}\0${normalizedLine}`).digest("hex").slice(0, 16); +} + +async function scan() { + const findings = []; + const files = await listJavaScriptFiles(path.join(rootDir, "src")); + for (const file of files) { + const relativeFile = path.relative(rootDir, file).replaceAll("\\", "/"); + const isFeature = relativeFile.startsWith("src/plugins/") || relativeFile.startsWith("src/features/"); + const lines = (await readFile(file, "utf8")).split(/\r?\n/); + for (const rule of rules) { + if (rule.featureOnly && !isFeature) continue; + lines.forEach((line, index) => { + if (rule.id === "host-selector" && /host-adapter\.js$/.test(relativeFile)) return; + if (!rule.pattern.test(line)) return; + const symbol = normalizeLine(line); + findings.push({ + rule: rule.id, + file: relativeFile, + symbol, + fingerprint: fingerprint(rule.id, symbol), + line: index + 1, + reason: "legacy debt recorded before the v6.5 migration", + removalPhase: rule.phase, + }); + }); + } + } + return findings.sort((a, b) => `${a.rule}:${a.file}:${a.line}`.localeCompare(`${b.rule}:${b.file}:${b.line}`)); +} + +async function checkImportGraph() { + const files = await listJavaScriptFiles(path.join(rootDir, "src")); + const knownFiles = new Set(files.map((file) => path.resolve(file))); + /** @type {Map} */ + const graph = new Map(); + const boundaryErrors = []; + const allowedLayers = { + features: new Set(["services", "ui", "contracts", "core"]), + services: new Set(["contracts", "core"]), + integrations: new Set(["contracts", "core"]), + ui: new Set(["contracts", "core"]), + platform: new Set(["contracts", "core"]), + contracts: new Set(["core"]), + }; + for (const file of files) { + const source = await readFile(file, "utf8"); + const dependencies = []; + for (const match of source.matchAll(/^import\s+[^;]*?from\s+["']([^"']+)["'];?/gm)) { + if (!match[1].startsWith(".")) continue; + const dependency = path.resolve(path.dirname(file), match[1]); + if (!knownFiles.has(dependency)) continue; + dependencies.push(dependency); + const sourceLayer = path.relative(path.join(rootDir, "src"), file).split(path.sep)[0]; + const targetLayer = path.relative(path.join(rootDir, "src"), dependency).split(path.sep)[0]; + const sourceRelative = path.relative(path.join(rootDir, "src"), file).split(path.sep); + const targetRelative = path.relative(path.join(rootDir, "src"), dependency).split(path.sep); + const sameFeature = sourceLayer === "features" && targetLayer === "features" && sourceRelative[1] === targetRelative[1]; + const featureCatalog = sourceRelative.join("/") === "features/catalog.js" && targetLayer === "features"; + if (allowedLayers[sourceLayer] && !allowedLayers[sourceLayer].has(targetLayer) && !sameFeature && !featureCatalog) { + boundaryErrors.push(`${sourceLayer} -> ${targetLayer}: ${path.relative(rootDir, file)} imports ${path.relative(rootDir, dependency)}`); + } + } + graph.set(file, dependencies); + } + + const visiting = new Set(); + const visited = new Set(); + const stack = []; + const cycles = []; + function visit(file) { + if (visiting.has(file)) { + const index = stack.indexOf(file); + cycles.push([...stack.slice(index), file].map((item) => path.relative(rootDir, item).replaceAll("\\", "/")).join(" -> ")); + return; + } + if (visited.has(file)) return; + visiting.add(file); + stack.push(file); + for (const dependency of graph.get(file) ?? []) visit(dependency); + stack.pop(); + visiting.delete(file); + visited.add(file); + } + for (const file of files) visit(file); + if (boundaryErrors.length || cycles.length) { + [...boundaryErrors, ...cycles.map((cycle) => `import cycle: ${cycle}`)].forEach((error) => console.error(error)); + throw new Error(`Import architecture failed (${boundaryErrors.length} boundary, ${cycles.length} cycle)`); + } + console.log(`Import graph passed (${files.length} modules, 0 cycles)`); +} + +const findings = await scan(); +if (process.argv.includes("--write-baseline")) { + await writeFile(baselinePath, `${JSON.stringify({ version: 1, exceptions: findings }, null, 2)}\n`); + console.log(`Wrote ${findings.length} architecture exceptions to architecture-baseline.json`); + process.exit(0); +} + +const baseline = JSON.parse(await readFile(baselinePath, "utf8")); +const approved = new Set(baseline.exceptions.map((item) => `${item.rule}\0${item.file}\0${item.fingerprint}`)); +const additions = findings.filter((item) => !approved.has(`${item.rule}\0${item.file}\0${item.fingerprint}`)); +if (additions.length) { + for (const item of additions) { + console.error(`${item.rule}: new exception at ${item.file}:${item.line} (${item.symbol})`); + } + throw new Error(`Architecture debt increased by ${additions.length} exception(s)`); +} + +const remaining = findings.length; +const removed = baseline.exceptions.length - remaining; +console.log(`Architecture debt ratchet passed (${remaining} remaining, ${removed} removed)`); +await checkImportGraph(); diff --git a/scripts/build-dev.mjs b/scripts/build-dev.mjs new file mode 100644 index 00000000..be6476fd --- /dev/null +++ b/scripts/build-dev.mjs @@ -0,0 +1,33 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { buildUserscript, distPath, repoRoot, rootPath } from "./build.mjs"; + +async function sha256(path) { + try { + return createHash("sha256").update(await readFile(path)).digest("hex"); + } catch (error) { + if (error.code === "ENOENT") return null; + throw error; + } +} + +const before = new Map([[rootPath, await sha256(rootPath)], [distPath, await sha256(distPath)]]); +const commit = execFileSync("git", ["rev-parse", "--short=12", "HEAD"], { cwd: repoRoot, encoding: "utf8" }).trim(); +const dirty = execFileSync("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: repoRoot, encoding: "utf8" }).trim() ? ".dirty" : ""; +const timestamp = new Date().toISOString().replace(/\D/g, "").slice(0, 12); +const devPath = join(repoRoot, "dist", "dev", "JHS.dev.user.js"); + +const transformMetadata = (metadata) => metadata + .replace(/^\/\/ @name\s+.+$/m, "// @name JHS Dev 6.5") + .replace(/^\/\/ @namespace\s+.+$/m, "// @namespace https://github.com/Teamper/JHS/dev") + .replace(/^\/\/ @version\s+.+$/m, `// @version 6.5.0.dev.${timestamp}`) + .replace(/^\/\/ @description\s+.+$/m, `// @description JHS 6.5 development build ${commit}${dirty}`) + .replace(/^\/\/ @(downloadURL|updateURL).*(?:\r?\n|$)/gm, ""); + +const result = await buildUserscript({ outputPaths: [devPath], transformMetadata }); +for (const [path, hash] of before) { + if (await sha256(path) !== hash) throw new Error(`Dev build modified formal artifact: ${path}`); +} +console.log(`Built dist/dev/JHS.dev.user.js (${result.outputBytes} bytes, ${commit}${dirty})`); diff --git a/scripts/build.mjs b/scripts/build.mjs index ff2196fe..1a0468ad 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,146 +1,67 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import * as esbuild from "esbuild"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); const srcPath = join(repoRoot, "src", "main.js"); const packagePath = join(repoRoot, "package.json"); -const corePaths = [ - "site-context.js", - "cache-policy.js", - "feature-helpers.js", - "constants.js", - "theme.js", - "ui-primitives.js", - "css-injection.js", - "storage-index.js", - "state-model.js", - "storage.js", - "migration.js", - "javdb-api.js", - "utils.js", - "http.js", - "event-bus.js", - "state-service.js", - "detail-state-controller.js", - "logger.js", - "plugin-manager.js" -].map((file) => join(repoRoot, "src", "core", file)); -const pluginPaths = [ - "status/detail-page.js", - "image-viewer/preview-video.js", - "external-search/javtrailers.js", - "subtitle/subtitle-cat.js", - "external-search/fc2.js", - "status/highlight-magnet.js", - "status/fold-category.js", - "avatar/actress-info.js", - "external-search/hit-show.js", - "external-search/top250.js", - "status/nav-bar.js", - "external-search/other-site.js", - "status/bus-detail-page.js", - "status/detail-page-button.js", - "status/history.js", - "external-search/review.js", - "blacklist/filter-title-keyword.js", - "blacklist/blacklist.js", - "status/list-page-button.js", - "status/list-page.js", - "status/auto-page.js", - "backup/webdav-client.js", - "backup/setting-styles.js", - "backup/resource-settings.js", - "backup/setting-templates.js", - "backup/setting-panels.js", - "backup/setting-forms.js", - "backup/setting-backup.js", - "backup/setting.js", - "image-viewer/bus-preview-video.js", - "avatar/search-by-image.js", - "status/bus-nav-bar.js", - "external-search/related.js", - "status/want-and-watched-videos.js", - "image-viewer/cover-button.js", - "external-search/fc2-by-123av.js", - "external-search/magnet-source-registry.js", - "external-search/magnet-hub.js", - "image-viewer/screenshot-provider-registry.js", - "image-viewer/screenshot.js", - "favorite/favorite-actresses.js", - "image-viewer/bus-img.js", - "translate/translate.js", - "new-video/task.js", - "new-video/new-video.js", - "one-two-three/offline.js", - "one-one-five/client.js", - "one-one-five/plugins.js", - "offline/unified-offline.js", - "stats/stats.js", - "status/mobile-bottom-bar.js", - "status/detail-workspace.js", - "status/compat-enhancements.js", - "registry.js" -].map((file) => join(repoRoot, "src", "plugins", file)); -const parserPaths = [ - "third-party-parsers.js" -].map((file) => join(repoRoot, "src", "parsers", file)); const distDir = join(repoRoot, "dist"); const distPath = join(distDir, "JHS.user.js"); const rootPath = join(repoRoot, "JHS.user.js"); -const source = await readFile(srcPath, "utf8"); -const packageJson = JSON.parse(await readFile(packagePath, "utf8")); -const coreSources = await Promise.all(corePaths.map((file) => readFile(file, "utf8"))); -const parserSources = await Promise.all(parserPaths.map((file) => readFile(file, "utf8"))); -const pluginSources = await Promise.all(pluginPaths.map((file) => readFile(file, "utf8"))); -const metadataMatch = source.match(/^\/\/ ==UserScript==[\s\S]*?^\/\/ ==\/UserScript==\r?\n?/m); +export async function buildUserscript({ outputPaths = [distPath, rootPath], transformMetadata = (value) => value } = {}) { + const source = await readFile(srcPath, "utf8"); + const packageJson = JSON.parse(await readFile(packagePath, "utf8")); + const metadataMatch = source.match(/^\/\/ ==UserScript==[\s\S]*?^\/\/ ==\/UserScript==\r?\n?/m); -if (!metadataMatch) { - throw new Error("Missing userscript metadata block in src/main.js"); -} - -const metadata = metadataMatch[0].trimEnd(); -const mainEntry = source.slice(metadataMatch[0].length).trimStart(); -const entry = [ - ...coreSources.map((item) => item.trimEnd()), - ...parserSources.map((item) => item.trimEnd()), - ...pluginSources.map((item) => item.trimEnd()), - mainEntry.trimEnd() -].join("\n\n") + "\n"; -const userscriptVersion = metadata.match(/^\/\/ @version\s+(.+)$/m)?.[1]; + if (!metadataMatch) throw new Error("Missing userscript metadata block in src/main.js"); -if (packageJson.version !== userscriptVersion) { - throw new Error(`Version mismatch: package.json ${packageJson.version}, userscript ${userscriptVersion}`); -} + const sourceMetadata = metadataMatch[0].trimEnd(); + const userscriptVersion = sourceMetadata.match(/^\/\/ @version\s+(.+)$/m)?.[1]; + if (packageJson.version !== userscriptVersion) { + throw new Error(`Version mismatch: package.json ${packageJson.version}, userscript ${userscriptVersion}`); + } -const buildResult = await esbuild.build({ - stdin: { - contents: entry, - loader: "js", - sourcefile: "src/main.js", - resolveDir: repoRoot - }, - bundle: true, - format: "iife", - target: "es2020", - charset: "utf8", - legalComments: "none", - keepNames: true, - minifySyntax: false, - minifyWhitespace: false, - minifyIdentifiers: false, - write: false, - logLevel: "silent" -}); + const metadata = transformMetadata(sourceMetadata); + const buildResult = await esbuild.build({ + entryPoints: [srcPath], + absWorkingDir: repoRoot, + bundle: true, + format: "iife", + target: "es2020", + charset: "utf8", + legalComments: "none", + keepNames: true, + minifySyntax: false, + minifyWhitespace: false, + minifyIdentifiers: false, + write: false, + logLevel: "silent", + plugins: [{ + name: "strip-userscript-metadata", + setup(build) { + build.onLoad({ filter: /src[\\/]main\.js$/ }, async (args) => ({ + contents: (await readFile(args.path, "utf8")).replace(/^\/\/ ==UserScript==[\s\S]*?^\/\/ ==\/UserScript==\r?\n?/m, ""), + loader: "js" + })); + } + }] + }); + const readableBundle = buildResult.outputFiles[0].text.trimStart().split(/\r?\n/).map((line) => line.trimEnd()).join("\n"); + const output = `${metadata}\n\n${readableBundle}`; + const outputBytes = Buffer.byteLength(output, "utf8"); -const readableBundle = buildResult.outputFiles[0].text.trimStart().split(/\r?\n/).map((line) => line.trimEnd()).join("\n"); -const output = `${metadata}\n\n${readableBundle}`; -const outputBytes = Buffer.byteLength(output, "utf8"); + for (const outputPath of outputPaths) { + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, output, "utf8"); + } + return { output, outputBytes, outputPaths }; +} -await mkdir(distDir, { recursive: true }); -await writeFile(distPath, output, "utf8"); -await writeFile(rootPath, output, "utf8"); +export { distPath, repoRoot, rootPath, srcPath }; -console.log(`Built dist/JHS.user.js and JHS.user.js (${outputBytes} bytes)`); +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const result = await buildUserscript(); + console.log(`Built dist/JHS.user.js and JHS.user.js (${result.outputBytes} bytes)`); +} diff --git a/scripts/check-refactor-sync.mjs b/scripts/check-refactor-sync.mjs new file mode 100644 index 00000000..1a8335a6 --- /dev/null +++ b/scripts/check-refactor-sync.mjs @@ -0,0 +1,23 @@ +import { execFileSync } from "node:child_process"; + +const branch = process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || ""; + +if (branch !== "refactor/6.5") { + console.log("6.5 ancestry check skipped outside refactor/6.5"); + process.exit(0); +} + +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +try { + execFileSync("git", ["merge-base", "--is-ancestor", "origin/main", "HEAD"], { stdio: "ignore" }); + console.log("refactor/6.5 contains current origin/main"); +} catch { + const mainTimestamp = Number(git(["show", "-s", "--format=%ct", "origin/main"])); + const ageHours = (Date.now() / 1000 - mainTimestamp) / 3600; + const message = `refactor/6.5 does not contain origin/main; latest main commit is ${ageHours.toFixed(1)} hours old`; + if (ageHours > 24) throw new Error(`${message}, exceeding the 24-hour hotfix sync policy`); + console.warn(`::warning::${message}; sync it before the 24-hour deadline`); +} diff --git a/scripts/checkjs-coverage.mjs b/scripts/checkjs-coverage.mjs new file mode 100644 index 00000000..283a7554 --- /dev/null +++ b/scripts/checkjs-coverage.mjs @@ -0,0 +1,28 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const srcDir = path.join(rootDir, "src"); + +async function listFiles(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const result = await Promise.all(entries.map(async (entry) => { + const fullPath = path.join(directory, entry.name); + return entry.isDirectory() ? listFiles(fullPath) : entry.name.endsWith(".js") ? [fullPath] : []; + })); + return result.flat(); +} + +const files = await listFiles(srcDir); +const checked = []; +for (const file of files) { + const source = await readFile(file, "utf8"); + if (/^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); +} +const coverage = files.length === 0 ? 100 : (checked.length / files.length) * 100; +const required = Number(process.env.JHS_CHECKJS_MIN ?? 50); +if (coverage + Number.EPSILON < required) { + throw new Error(`checkJs coverage ${coverage.toFixed(1)}% is below ${required}%`); +} +console.log(`checkJs coverage: ${checked.length}/${files.length} (${coverage.toFixed(1)}%, required ${required}%)`); diff --git a/scripts/integration-quality-check.mjs b/scripts/integration-quality-check.mjs new file mode 100644 index 00000000..953cc92b --- /dev/null +++ b/scripts/integration-quality-check.mjs @@ -0,0 +1,56 @@ +import { access, readdir } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const integrationsDir = path.join(rootDir, "src", "integrations"); +const allowedTrustClasses = new Set(["builtin-public", "custom-public", "user-local"]); +const allowedQualities = new Set(["bronze", "silver"]); + +async function exists(file) { + try { + await access(file); + return true; + } catch { + return false; + } +} + +if (!(await exists(integrationsDir))) { + console.log("Integration quality gate passed (migration catalog not created yet)"); + process.exit(0); +} + +const entries = (await readdir(integrationsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()); +const errors = []; +for (const entry of entries) { + const directory = path.join(integrationsDir, entry.name); + const manifestPath = path.join(directory, "manifest.js"); + if (!(await exists(manifestPath))) { + errors.push(`${entry.name}: missing manifest.js`); + continue; + } + const module = await import(`${pathToFileURL(manifestPath).href}?quality-check=${Date.now()}`); + const manifest = module.default ?? module.manifest; + for (const field of ["id", "trustClass", "hosts", "capabilities", "requires", "cachePolicy", "quality"]) { + if (manifest?.[field] === undefined) errors.push(`${entry.name}: missing ${field}`); + } + if (!allowedTrustClasses.has(manifest?.trustClass)) errors.push(`${entry.name}: invalid trustClass`); + if (!allowedQualities.has(manifest?.quality)) errors.push(`${entry.name}: invalid quality`); + if (!Array.isArray(manifest?.capabilities) || manifest.capabilities.length === 0) errors.push(`${entry.name}: no capabilities`); + if (manifest?.cachePolicy === undefined) errors.push(`${entry.name}: cachePolicy must be explicit, including none`); + const parserTest = path.join(rootDir, "tests", "integrations", `${entry.name}.contract.test.js`); + const fixture = path.join(rootDir, "tests", "fixtures", "integrations", entry.name); + if (!(await exists(parserTest))) errors.push(`${entry.name}: missing contract test`); + if (!(await exists(fixture))) errors.push(`${entry.name}: missing fixture directory`); + if (manifest?.quality === "silver") { + const malformedTest = path.join(rootDir, "tests", "integrations", `${entry.name}.malformed.test.js`); + if (!(await exists(malformedTest))) errors.push(`${entry.name}: Silver requires malformed response test`); + } +} + +if (errors.length) { + errors.forEach((error) => console.error(error)); + throw new Error(`Integration quality gate failed with ${errors.length} error(s)`); +} +console.log(`Integration quality gate passed (${entries.length} integration(s))`); diff --git a/scripts/performance-budget-check.mjs b/scripts/performance-budget-check.mjs new file mode 100644 index 00000000..8aa36746 --- /dev/null +++ b/scripts/performance-budget-check.mjs @@ -0,0 +1,11 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const budget = JSON.parse(await readFile(path.join(rootDir, "performance-budget.json"), "utf8")); +if (!budget.bundle?.reason) throw new Error("Bundle budget changes require a reason"); +const size = (await stat(path.join(rootDir, "JHS.user.js"))).size; +const maximum = Math.floor(budget.bundle.baselineBytes * (1 + budget.bundle.maximumGrowthRatio)); +if (size > maximum) throw new Error(`Bundle ${size} bytes exceeds the reviewed budget ${maximum} bytes`); +console.log(`Performance budget passed: bundle ${size}/${maximum} bytes; fixture request budgets ${JSON.stringify(budget.browserFixture.maximumInitialRequests)}`); diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index 961d6ec9..95ec8780 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -111,13 +111,13 @@ for (const legacyBrand of legacyBrands) { assertIncludes(ciWorkflow, "npm run check", "CI workflow"); assertIncludes(ciWorkflow, "git diff --exit-code -- JHS.user.js", "CI tracked artifact check"); -assertIncludes(ciWorkflow, "workflow_dispatch:", "release recovery trigger"); +assertIncludes(ciWorkflow, "workflow_dispatch:", "manual CI trigger"); assertIncludes(ciWorkflow, "node-version: 20", "minimum Node compatibility check"); assertIncludes(ciWorkflow, "node-version: 22", "full Node check"); -assertIncludes(ciWorkflow, "needs: [node20, check]", "release check dependency"); +assertIncludes(ciWorkflow, "needs: [node20, check, browser-smoke]", "release check dependency"); assertIncludes(ciWorkflow, "contents: write", "release write permission"); assertIncludes(ciWorkflow, "--base-ref", "version-change release detection"); -assertIncludes(ciWorkflow, "--force-release", "release recovery contract"); +assertIncludes(ciWorkflow, "github.ref == 'refs/heads/main'", "release restricted to main pushes"); assertIncludes(ciWorkflow, "queue: max", "release concurrency queue"); assertIncludes(ciWorkflow, "cancel-in-progress: false", "release concurrency preservation"); assertIncludes(ciWorkflow, "git tag -a", "annotated release tag"); @@ -145,14 +145,12 @@ const stableReleaseChecks = [ ["export format compatibility", storage, "async exportData()"], ["export format compatibility", storage, "exportPortableData"], ["build source chain", buildScript, 'const srcPath = join(repoRoot, "src", "main.js")'], - ["build source chain", buildScript, "const corePaths = ["], - ["build source chain", buildScript, 'join(repoRoot, "src", "core", file)'], - ["build source chain", buildScript, "const pluginPaths = ["], - ["build source chain", buildScript, 'join(repoRoot, "src", "plugins", file)'], + ["build source chain", buildScript, "entryPoints: [srcPath]"], + ["build source chain", buildScript, "bundle: true"], ["build output chain", buildScript, 'const distPath = join(distDir, "JHS.user.js")'], ["build output chain", buildScript, 'const rootPath = join(repoRoot, "JHS.user.js")'], - ["build output chain", buildScript, 'writeFile(distPath, output, "utf8")'], - ["build output chain", buildScript, 'writeFile(rootPath, output, "utf8")'] + ["build output chain", buildScript, "for (const outputPath of outputPaths)"], + ["build output chain", buildScript, 'writeFile(outputPath, output, "utf8")'] ]; for (const [label, source, token] of stableReleaseChecks) { @@ -188,7 +186,7 @@ for (const [label, source] of [["FC2", fc2], ["FC2/123AV", fc2By123Av]]) { assert(!source.includes("stateService.patch("), `${label} state actions must use toggle semantics`); } assertIncludes(fc2, "detailStateController.bind", "shared FC2 detail state controller"); -assertIncludes(fc2By123Av, 'this.getBean("Fc2Plugin").openFc2Dialog', "123AV must reuse FC2 state and shell ownership"); +assertIncludes(fc2By123Av, 'this.getDependency("Fc2Plugin").openFc2Dialog', "123AV must reuse FC2 state and shell ownership"); assert(!uiPrimitives.includes('.trigger("change")'), "JhsSelect must dispatch one native change without jQuery double fire"); for (const [label, source] of [["123", one23Offline], ["115", one115Offline]]) { assert(!source.includes("injectJavDbButtons"), `${label} provider must not inject JavDB UI`); @@ -218,7 +216,7 @@ assertIncludes(statsSource, 'action: "filter", filter: "blockedItems"', "Stats c assertIncludes(statsSource, 'title: "统计"', "Stats dialog title"); assert(!mobileSource.includes("activeQuickFilter ="), "mobile filter actions must use ListPagePlugin.setQuickFilter"); assert(!mobileSource.includes('$("#waitCheckBtn").click()'), "mobile identification must call ListPageButtonPlugin.openWaitCheck directly"); -assertIncludes(mobileSource, 'await this.getBean("ListPageButtonPlugin")?.openWaitCheck?.()', "mobile identification API"); +assertIncludes(mobileSource, 'await this.getDependency("ListPageButtonPlugin")?.openWaitCheck?.()', "mobile identification API"); assert(!/\.jhs-commandbar__filters\s*\{[^}]*overflow-x\s*:\s*auto/.test(mobileSource), "command-bar filters must not clip popovers with horizontal overflow"); assert(!/@media \(max-width:\s*1023px\)[\s\S]*?\.jhs-page-commandbar\s*\{[^}]*overflow-x\s*:\s*auto/.test(mobileSource), "tablet command bar must wrap instead of scrolling horizontally"); assert(/@media \(max-width:\s*1023px\)[\s\S]*?\.jhs-page-commandbar\s*\{[^}]*flex-wrap\s*:\s*wrap[^}]*overflow\s*:\s*visible/.test(mobileSource), "tablet command bar must wrap with visible overflow"); diff --git a/scripts/ui-consistency-audit.mjs b/scripts/ui-consistency-audit.mjs index e83066c9..ed8c9ba7 100644 --- a/scripts/ui-consistency-audit.mjs +++ b/scripts/ui-consistency-audit.mjs @@ -53,7 +53,7 @@ const [theme, primitives, build, injection, magnet, settings, utils, detail, com readFile(join(srcRoot, "core", "constants.js"), "utf8"), readFile(join(srcRoot, "plugins", "image-viewer", "preview-video.js"), "utf8"), readFile(join(srcRoot, "plugins", "image-viewer", "screenshot.js"), "utf8"), - readFile(join(srcRoot, "parsers", "third-party-parsers.js"), "utf8"), + readFile(join(srcRoot, "integrations", "javstore", "parser.js"), "utf8"), readFile(join(srcRoot, "plugins", "external-search", "other-site.js"), "utf8"), readFile(join(repoRoot, "JHS.user.js"), "utf8") ]); @@ -80,10 +80,11 @@ for (const token of ["class JhsSelect", "menuitemradio", "OPTGROUP", "ArrowDown" requireMatch(primitives, /\.jhs-segmented__item[^}]*\{[^}]*display:\s*inline-flex[^}]*align-items:\s*center[^}]*justify-content:\s*center[^}]*line-height:\s*1/, "segmented items must be centered on both axes"); requireMatch(theme, /\.layui-layer-close[\s\S]*::before[\s\S]*::after/, "layer close control must draw its own themed BasePlugin"); -const themeIndex = build.indexOf('"theme.js"'); -const primitivesIndex = build.indexOf('"ui-primitives.js"'); +requireMatch(build, /entryPoints:\s*\[srcPath\]/, "build must use the ESM main entry point"); +const themeIndex = injection.indexOf('from "./theme.js"'); +const primitivesIndex = injection.indexOf('from "./ui-primitives.js"'); if (themeIndex < 0 || primitivesIndex < 0 || primitivesIndex < themeIndex) - failures.push("ui-primitives.js must be bundled after theme.js"); + failures.push("css-injection.js must import theme before UI primitives"); requireMatch(injection, /H\(buildUiPrimitivesCss\(\)\)/, "shared UI CSS is not injected"); requireMatch(injection, /initializeUiAccessibility\(\)/, "dynamic UI accessibility enhancer is not initialized"); requireMatch(injection, /H\(F\)/, "clean global support CSS must be injected"); @@ -144,8 +145,8 @@ forbidMatch(settings, /helpBtn|\(\?\)|tooltip-icon/, "quick settings help and qu forbidMatch(settingForms, /help-container|常见问题|使用说明|helpBtn/, "settings help implementation must be fully removed"); requireMatch(settings, /id="moreBtn" class="jhs-btn jhs-btn--ghost"/, "quick settings footer must only retain the ghost more-settings action"); requireMatch(settingPanels, /html \+= `<\/section>`/, "plugin groups must close with section"); -requireMatch(logger, /document\.addEventListener\("mouseover", this\.onDocumentOver\)/, "image preview must use delegated target handling"); -requireMatch(logger, /document\.removeEventListener\("mouseover", this\.onDocumentOver\)/, "image preview must remove delegated listeners"); +requireMatch(logger, /this\.scope\.listen\(document, "mouseover", this\.onDocumentOver\)/, "image preview must use lifecycle-owned delegated target handling"); +requireMatch(logger, /this\.scope\.dispose\(\)/, "image preview must remove delegated listeners through LifecycleScope"); requireMatch(logger, /currentUrl = null[\s\S]*loadedUrls = new Map/, "image preview must cache loaded URLs"); requireMatch(logger, /hideDelay:\s*100/, "image preview must debounce hiding for 100ms"); requireMatch(logger, /\.image-hover-preview\s*\{[^}]*display:\s*block[^}]*visibility:\s*hidden/, "image preview must remain mounted while hidden"); @@ -227,7 +228,7 @@ requireMatch(parsers, /"javstore\.net" === hostname \|\| hostname\.endsWith\("\. requireMatch(parsers, /previewUrl\.replace\("\.th", ""\)/, "JavStore preview URLs must retain .th compatibility"); requireMatch(screenshot, /"javstore" === provider \? normalizeJavStoreAssetUrl\(cachedUrl\) : cachedUrl/, "legacy JavStore screenshot cache reads must normalize asset URLs"); requireMatch(screenshot, /addImg\(e, t\)[\s\S]{0,100}normalizeJavStoreAssetUrl\(t\)/, "screenshot rendering must normalize JavStore asset URLs at the final boundary"); -requireMatch(parsers, /"CLICK HERE!" === \$\(element\)\.text\(\)\.trim\(\)/, "JavStore detail parsing must retain the CLICK HERE! link contract"); +requireMatch(parsers, /"CLICK HERE!" === wrap\(element\)\.text\(\)\.trim\(\)/, "JavStore detail parsing must retain the CLICK HERE! link contract"); requireMatch(screenshot, /详情页没有 CLICK HERE![\s\S]{0,80}continue/, "JavStore must continue after a candidate without CLICK HERE!"); forbidMatch(screenshot, /javstore\.net\/search\/|img\[src\*=['"]_s\.jpg/, "legacy JavStore search or detail fallback must not return"); requireMatch(otherSite, /跳过第三方站点解析:番号不可用/, "external sites must fail fast without a car number"); @@ -279,7 +280,7 @@ for (const file of sourceFiles) { `${relative(repoRoot, file)} contains a banned ribbon or neumorphic treatment`); if (path !== "src/core/feature-helpers.js") forbidMatch(source, /\.play\s*\(/, `${path} contains a naked media play call`); if (path !== "src/core/utils.js") forbidMatch(source, /navigator\.clipboard|execCommand\s*\(\s*["']copy["']/, `${path} bypasses the clipboard helper`); - if (path !== "src/core/logger.js" && path !== "src/main.js") forbidMatch(source, /\bconsole\.(?:log|warn|error)\s*\(/, `${path} bypasses clog`); + if (!["src/core/logger.js", "src/main.js", "src/app/bootstrap.js"].includes(path)) forbidMatch(source, /\bconsole\.(?:log|warn|error)\s*\(/, `${path} bypasses clog`); for (const reference of source.matchAll(/JHS_Z_INDEX\.([A-Za-z][\w]*)/g)) if (!zIndexKeys.has(reference[1])) failures.push(`${path} references unknown z-index token ${reference[1]}`); forbidMatch(source, /\.then\(\s*\)/, `${path} contains an unobserved empty then call`); forbidMatch(source, /catch\s*\([^)]*\)\s*\{\s*\}/, `${path} contains an empty catch block`); diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js new file mode 100644 index 00000000..b2560a04 --- /dev/null +++ b/src/app/bootstrap.js @@ -0,0 +1,133 @@ +// @ts-check + +import { l, r } from "../core/constants.js"; +import { JhsError } from "../core/jhs-error.js"; +import { runDataMigrations } from "../core/migration.js"; +import { PluginManager } from "../core/plugin-manager.js"; +import { stateService } from "../core/state-service.js"; +import { gmHttp, storageManager, utils } from "../core/http.js"; +import { initializeEventBus } from "../core/event-bus.js"; +import { migrateDisabledPlugins, parseDisabledPlugins } from "../core/legacy-plugin-contributions.js"; +import { initializeLoggerRuntime } from "../core/logger.js"; +import { applyTheme } from "../core/theme.js"; +import { getVendorRuntime } from "../platform/userscript/vendor-runtime.js"; +import { JavBusHostAdapter } from "../platform/hosts/javbus-host-adapter.js"; +import { JavDbHostAdapter } from "../platform/hosts/javdb-host-adapter.js"; +import { featureManifests } from "../features/catalog.js"; +import { registerSitePlugins } from "../plugins/registry.js"; +import { attachCompatibilityFacade } from "./compatibility-facade.js"; +import { createAppContext } from "./create-app-context.js"; +import { integrationManifests } from "./integration-catalog.js"; + +function patchLayerRuntime(layerRuntime) { + const originalClose = layerRuntime.close; + layerRuntime.close = function(id) { + const result = originalClose.call(this, id); + setTimeout(() => { + const shades = document.querySelectorAll(".layui-layer-shade").length; + document.documentElement.style.overflow = shades > 0 ? "hidden" : ""; + }, 10); + return result; + }; + const originalOpen = layerRuntime.open; + layerRuntime.open = function(options = {}) { + const success = options.success; + return originalOpen.call(this, { ...options, success(element, id) { + if (typeof success === "function") success.call(this, element, id); + globalThis.utils.setupEscClose(id); + } }); + }; +} + +function importVendorStyles() { + for (const url of [ + "https://cdn.jsdelivr.net/npm/layui-layer@1.0.9/layer.min.css", + "https://cdn.jsdelivr.net/npm/toastify-js@1.12.0/src/toastify.min.css", + "https://cdn.jsdelivr.net/npm/viewerjs@1.11.1/dist/viewer.min.css", + "https://cdn.jsdelivr.net/npm/tabulator-tables@6.3.1/dist/css/tabulator_semanticui.min.css", + ]) utils.importResource(url); +} + +async function migrateDisabledPluginSettings() { + const raw = await storageManager.getSetting("disabledPlugins", "[]"); + const previous = parseDisabledPlugins(raw); + const migrated = migrateDisabledPlugins(previous); + if (JSON.stringify(previous) === JSON.stringify(migrated)) return migrated; + const journalKey = "jhs_settings_migration_journal"; + await storageManager.forage.setItem(journalKey, { status: "pending", previousValues: { disabledPlugins: raw }, createdAt: new Date().toISOString() }); + try { + await storageManager.saveSettingItem("disabledPlugins", JSON.stringify(migrated)); + await storageManager.forage.removeItem(journalKey); + return migrated; + } catch (error) { + await storageManager.saveSettingItem("disabledPlugins", raw); + throw error; + } +} + +async function prepareLocalOrigins() { + const settings = await storageManager.getSetting(); + const origins = new Set(Array.isArray(settings.trustedLocalOrigins) ? settings.trustedLocalOrigins : []); + let legacyOrigin = null; + try { if (settings.webDavUrl) legacyOrigin = new URL(settings.webDavUrl).origin; } catch { /* existing invalid values remain untouched */ } + if (legacyOrigin && !origins.has(legacyOrigin)) { + origins.add(legacyOrigin); + await storageManager.saveSetting({ ...settings, trustedLocalOrigins: [...origins], localOriginTrustNoticeV1: true }); + return { origins: [...origins], notice: `已按现有 WebDAV 配置授权本地来源:${legacyOrigin}` }; + } else if (legacyOrigin && !settings.localOriginTrustNoticeV1) { + await storageManager.saveSetting({ ...settings, localOriginTrustNoticeV1: true }); + return { origins: [...origins], notice: `WebDAV 仅信任精确来源:${legacyOrigin}` }; + } + return { origins: [...origins], notice: null }; +} + +export async function bootstrapJhs() { + try { + const vendors = getVendorRuntime(); + const jhsEventBus = initializeEventBus(); + Object.assign(globalThis, { utils, gmHttp, storageManager, stateService, jhsEventBus }); + patchLayerRuntime(vendors.layer); + importVendorStyles(); + const disabled = await migrateDisabledPluginSettings(); + const localOriginSettings = await prepareLocalOrigins(); + const hostAdapter = r ? new JavDbHostAdapter() : l ? new JavBusHostAdapter() : null; + const route = hostAdapter?.detectRoute() ?? "other"; + const context = createAppContext({ + gmRequest: globalThis.GM_xmlhttpRequest, storageForage: storageManager.forage, + layer: vendors.layer, hostAdapter, site: r ? "javdb" : l ? "javbus" : "other", route, disabled, localOrigins: localOriginSettings.origins, + }); + const logger = initializeLoggerRuntime(context.rootScope); + Object.assign(globalThis, logger); + if (localOriginSettings.notice) globalThis.show.info(localOriginSettings.notice); + const pluginManager = new PluginManager(); + registerSitePlugins(pluginManager); + for (const manifest of integrationManifests) context.registries.integrations.register(manifest); + for (const manifest of featureManifests) context.registries.features.register(manifest); + await context.registries.features.start(); + attachCompatibilityFacade({ + pluginManager, utils, gmHttp, storageManager, stateService, jhsEventBus, + clog: globalThis.clog, show: globalThis.show, loading: globalThis.loading, + }, globalThis.unsafeWindow); + window.isDetailPage = route === "detail"; + window.isListPage = route === "list"; + await runDataMigrations(storageManager); + await stateService.recoverPendingTransaction(); + await Promise.all([pluginManager.processCss(), applyTheme()]); + if (r && /(^|;)\s*locale\s*=\s*en\s*($|;)/i.test(document.cookie)) globalThis.show.error("请切换到中文语言下才可正常使用本脚本", { duration: -1 }); + await pluginManager.processPlugins(); + return context; + } catch (cause) { + throw cause instanceof JhsError ? cause : new JhsError("BOOTSTRAP_FAILED", cause instanceof Error ? cause.message : "JHS 启动失败", { source: "bootstrap", cause }); + } +} + +export function mountBootstrapError(error) { + console.error("[JHS] bootstrap failed:", error); + if (globalThis.show?.error) return globalThis.show.error(error?.message || "JHS 启动失败", { duration: -1 }); + const surface = document.createElement("div"); + surface.id = "jhs-bootstrap-error"; + surface.setAttribute("role", "alert"); + surface.textContent = `JHS 启动失败:${error?.message || "未知错误"}`; + Object.assign(surface.style, { position: "fixed", inset: "12px 12px auto", zIndex: "2147483647", padding: "12px", background: "#7f1d1d", color: "white" }); + document.documentElement.append(surface); +} diff --git a/src/app/command-registry.js b/src/app/command-registry.js new file mode 100644 index 00000000..800471cd --- /dev/null +++ b/src/app/command-registry.js @@ -0,0 +1,42 @@ +// @ts-check + +export class CommandRegistry { + constructor() { + /** @type {Map} */ + this.owners = new Map(); + /** @type {Map any>} */ + this.handlers = new Map(); + /** @type {((featureId: string) => Promise) | null} */ + this.activateOwner = null; + } + + /** @param {(featureId: string) => Promise} activator */ + setActivator(activator) { this.activateOwner = activator; } + + /** @param {string} command @param {string} featureId */ + registerOwner(command, featureId) { + const owner = this.owners.get(command); + if (owner && owner !== featureId) throw new Error(`Duplicate command owner: ${command}`); + this.owners.set(command, featureId); + } + + /** @param {string} command @param {(...args: any[]) => any} handler @param {string} featureId */ + registerHandler(command, handler, featureId) { + if (this.owners.get(command) !== featureId) throw new Error(`Feature ${featureId} does not own command ${command}`); + if (this.handlers.has(command)) throw new Error(`Duplicate command handler: ${command}`); + this.handlers.set(command, handler); + } + + /** @param {string} command @param {...any} args */ + async execute(command, ...args) { + const owner = this.owners.get(command); + if (!owner) throw new Error(`Unknown command: ${command}`); + if (!this.handlers.has(command)) { + if (!this.activateOwner) throw new Error("Command activator is not configured"); + await this.activateOwner(owner); + } + const handler = this.handlers.get(command); + if (!handler) throw new Error(`Feature ${owner} did not register ${command}`); + return handler(...args); + } +} diff --git a/src/app/compatibility-facade.js b/src/app/compatibility-facade.js new file mode 100644 index 00000000..fb79eca9 --- /dev/null +++ b/src/app/compatibility-facade.js @@ -0,0 +1,10 @@ +// @ts-check + +/** @param {Record} values @param {Record} target */ +export function attachCompatibilityFacade(values, target) { + for (const name of ["pluginManager", "utils", "gmHttp", "storageManager", "stateService", "jhsEventBus", "clog", "show", "loading"]) { + if (values[name] == null) throw new Error(`Compatibility facade is missing ${name}`); + target[name] = values[name]; + } + return Object.freeze(Object.fromEntries(Object.keys(values).map((name) => [name, target[name]]))); +} diff --git a/src/app/create-app-context.js b/src/app/create-app-context.js new file mode 100644 index 00000000..98e6b093 --- /dev/null +++ b/src/app/create-app-context.js @@ -0,0 +1,78 @@ +// @ts-check + +import { PORT, REGISTRY, SERVICE } from "../contracts/tokens.js"; +import { BrowserNavigationAdapter } from "../platform/browser/browser-navigation-adapter.js"; +import { BrowserStyleAdapter } from "../platform/browser/browser-style-adapter.js"; +import { IndexedDbStorageAdapter } from "../platform/userscript/indexeddb-storage-adapter.js"; +import { LayerDialogAdapter } from "../platform/userscript/layer-dialog-adapter.js"; +import { UserscriptHttpAdapter } from "../platform/userscript/userscript-http-adapter.js"; +import { CacheService } from "../services/cache-service.js"; +import { DiagnosticsService } from "../services/diagnostics-service.js"; +import { DialogService } from "../services/dialog-service.js"; +import { ExternalUrlPolicy } from "../services/external-url-policy.js"; +import { HttpService } from "../services/http-service.js"; +import { NavigationService } from "../services/navigation-service.js"; +import { MagnetService } from "../services/magnet-service.js"; +import { MovieIdentityService } from "../services/movie-identity-service.js"; +import { OfflineService } from "../services/offline-service.js"; +import { ProfileService } from "../services/profile-service.js"; +import { RelatedService } from "../services/related-service.js"; +import { ReviewService } from "../services/review-service.js"; +import { ScreenshotService } from "../services/screenshot-service.js"; +import { SettingsService } from "../services/settings-service.js"; +import { StorageService } from "../services/storage-service.js"; +import { StyleRegistry } from "../services/style-registry.js"; +import { CommandRegistry } from "./command-registry.js"; +import { DependencyContainer } from "./dependency-container.js"; +import { FeatureRuntime } from "./feature-runtime.js"; +import { ProviderRegistry } from "./provider-registry.js"; +import { IntegrationRegistry } from "./integration-registry.js"; +import { SettingsRegistry } from "./settings-registry.js"; +import { LifecycleScope } from "../core/lifecycle-scope.js"; + +/** @param {{gmRequest: (options: Record) => any, storageForage: any, layer: any, hostAdapter?: any, disabled?: string[], site?: string, route?: string, localOrigins?: string[]}} runtime */ +export function createAppContext(runtime) { + const diagnostics = new DiagnosticsService(); + const rootScope = new LifecycleScope("app:root", { onChange: (snapshot) => diagnostics.updateScope(snapshot) }); + const container = new DependencyContainer(diagnostics); + const navigationPort = new BrowserNavigationAdapter(); + const httpPort = new UserscriptHttpAdapter(runtime.gmRequest); + const storagePort = new IndexedDbStorageAdapter(runtime.storageForage); + const dialogPort = new LayerDialogAdapter(runtime.layer); + const stylePort = new BrowserStyleAdapter(); + const urlPolicy = new ExternalUrlPolicy({ localOrigins: runtime.localOrigins }); + const cache = new CacheService({ diagnostics }); + const navigation = new NavigationService(navigationPort); + const storage = new StorageService(storagePort); + const dialog = new DialogService(dialogPort); + const styles = new StyleRegistry(stylePort); + const http = new HttpService(httpPort, urlPolicy, { diagnostics, cache }); + const settings = new SettingsService(storage); + const profile = new ProfileService({ scope: rootScope }); + profile.start(); + const commands = new CommandRegistry(); + const providers = new ProviderRegistry(diagnostics); + const settingsRegistry = new SettingsRegistry(); + const integrations = new IntegrationRegistry(container, diagnostics); + const movie = new MovieIdentityService(); + const review = new ReviewService(integrations); + const related = new RelatedService(integrations); + const magnet = new MagnetService(providers); + const screenshot = new ScreenshotService(providers); + const offline = new OfflineService(providers); + + container + .register(PORT.navigation, navigationPort).register(PORT.http, httpPort).register(PORT.storage, storagePort) + .register(PORT.dialog, dialogPort).register(PORT.style, stylePort) + .register(SERVICE.diagnostics, diagnostics).register(SERVICE.urlPolicy, urlPolicy) + .register(SERVICE.navigation, navigation).register(SERVICE.http, http).register(SERVICE.storage, storage) + .register(SERVICE.dialog, dialog).register(SERVICE.settings, settings).register(SERVICE.cache, cache).register(SERVICE.profile, profile) + .register(SERVICE.movie, movie).register(SERVICE.review, review).register(SERVICE.related, related).register(SERVICE.magnet, magnet) + .register(SERVICE.screenshot, screenshot).register(SERVICE.offline, offline) + .register(REGISTRY.command, commands).register(REGISTRY.provider, providers).register(REGISTRY.integration, integrations).register(REGISTRY.settings, settingsRegistry); + if (runtime.hostAdapter) container.register(PORT.host, runtime.hostAdapter); + + const features = new FeatureRuntime({ container, commands, diagnostics, disabled: runtime.disabled, site: runtime.site, route: runtime.route }); + container.register(REGISTRY.feature, features); + return Object.freeze({ rootScope, container, ports: Object.freeze({ navigationPort, httpPort, storagePort, dialogPort, stylePort }), services: Object.freeze({ diagnostics, urlPolicy, navigation, http, storage, dialog, styles, settings, cache, profile, movie, review, related, magnet, screenshot, offline }), registries: Object.freeze({ commands, providers, integrations, settings: settingsRegistry, features }) }); +} diff --git a/src/app/dependency-container.js b/src/app/dependency-container.js new file mode 100644 index 00000000..6724eef8 --- /dev/null +++ b/src/app/dependency-container.js @@ -0,0 +1,41 @@ +// @ts-check + +import { JhsError } from "../core/jhs-error.js"; + +export class DependencyContainer { + /** @param {{recordError?: (error: unknown) => void} | null} [diagnostics] */ + constructor(diagnostics = null) { + /** @type {Map} */ + this.values = new Map(); + this.diagnostics = diagnostics; + } + + /** @param {symbol} token @param {unknown} value */ + register(token, value) { + if (typeof token !== "symbol") throw new TypeError("Dependency token must be a symbol"); + if (this.values.has(token)) { + const error = new JhsError("DUPLICATE_TOKEN", `Duplicate dependency token: ${String(token)}`, { source: "DependencyContainer" }); + this.diagnostics?.recordError?.(error); + throw error; + } + this.values.set(token, value); + return this; + } + + /** @param {readonly symbol[]} requiredTokens */ + resolveDeclared(requiredTokens) { + const dependencies = Object.create(null); + for (const token of requiredTokens) { + if (!this.values.has(token)) { + const error = new JhsError("MISSING_DEPENDENCY", `Missing declared dependency: ${String(token)}`, { source: "DependencyContainer" }); + this.diagnostics?.recordError?.(error); + throw error; + } + Object.defineProperty(dependencies, token, { + value: this.values.get(token), + enumerable: true, + }); + } + return Object.freeze(dependencies); + } +} diff --git a/src/app/feature-runtime.js b/src/app/feature-runtime.js new file mode 100644 index 00000000..7891bc71 --- /dev/null +++ b/src/app/feature-runtime.js @@ -0,0 +1,96 @@ +// @ts-check + +import { LifecycleScope } from "../core/lifecycle-scope.js"; +import { migrateDisabledPlugins } from "../core/legacy-plugin-contributions.js"; +import { defineFeature } from "../contracts/manifests.js"; + +export { LEGACY_PLUGIN_CONTRIBUTION_MAP, migrateDisabledPlugins } from "../core/legacy-plugin-contributions.js"; + +export class FeatureRuntime { + /** @param {{container: import("./dependency-container.js").DependencyContainer, commands: import("./command-registry.js").CommandRegistry, diagnostics: import("../services/diagnostics-service.js").DiagnosticsService, disabled?: string[], site?: string, route?: string}} options */ + constructor(options) { + this.container = options.container; + this.commands = options.commands; + this.diagnostics = options.diagnostics; + this.disabled = new Set(migrateDisabledPlugins(options.disabled)); + this.site = options.site ?? "unknown"; + this.route = options.route ?? "unknown"; + /** @type {Map>} */ + this.manifests = new Map(); + /** @type {Map>>} */ + this.activations = new Map(); + this.commands.setActivator((featureId) => this.activate(featureId).then(() => undefined)); + } + + /** @param {Record} manifest */ + register(manifest) { + const validated = /** @type {Record} */ (defineFeature(manifest)); + if (this.manifests.has(validated.id)) throw new Error(`Duplicate feature: ${validated.id}`); + this.manifests.set(validated.id, validated); + for (const command of validated.providesCommands) this.commands.registerOwner(command, validated.id); + } + + /** @param {Record} manifest */ + isEligible(manifest) { + if (manifest.kind !== "system" && this.disabled.has(manifest.id)) return false; + if (manifest.sites.length && !manifest.sites.includes(this.site)) return false; + if (manifest.routes.length && !manifest.routes.includes(this.route)) return false; + return true; + } + + /** @param {string} id */ + activate(id) { + const existing = this.activations.get(id); + if (existing) return existing; + const manifest = this.manifests.get(id); + if (!manifest) return Promise.reject(new Error(`Unknown feature: ${id}`)); + if (!this.isEligible(manifest)) return Promise.reject(new Error(`Feature is disabled or ineligible: ${id}`)); + const activation = this.activateManifest(manifest); + this.activations.set(id, activation); + activation.catch(() => this.activations.delete(id)); + return activation; + } + + /** @param {Record} manifest */ + async activateManifest(manifest) { + const started = performance.now(); + const scope = new LifecycleScope(`feature:${manifest.id}`, { onChange: (snapshot) => this.diagnostics.updateScope(snapshot) }); + try { + const dependencies = this.container.resolveDeclared(manifest.requires); + const enabledContributions = Object.freeze(manifest.contributes.filter((/** @type {string} */ id) => !this.disabled.has(id))); + const result = await manifest.activate(dependencies, Object.freeze({ scope, enabledContributions })); + for (const command of manifest.providesCommands) { + const handler = result?.commands?.[command]; + if (typeof handler !== "function") throw new Error(`Feature ${manifest.id} did not provide command ${command}`); + this.commands.registerHandler(command, handler, manifest.id); + } + this.diagnostics.setFeature(manifest.id, true); + enabledContributions.forEach((/** @type {string} */ id) => this.diagnostics.setContribution(id, true)); + this.diagnostics.recordStartup(manifest.id, performance.now() - started); + return Object.freeze({ manifest, scope, enabledContributions, dispose: () => { + result?.dispose?.(); + scope.dispose(); + this.diagnostics.setFeature(manifest.id, false); + enabledContributions.forEach((/** @type {string} */ id) => this.diagnostics.setContribution(id, false)); + this.activations.delete(manifest.id); + } }); + } catch (error) { + scope.dispose(); + this.diagnostics.recordError(error); + throw error; + } + } + + async start() { + for (const manifest of this.manifests.values()) { + if (!this.isEligible(manifest)) continue; + if (manifest.startup === "eager") await this.activate(manifest.id); + else if (manifest.startup === "idle") { + const schedule = globalThis.requestIdleCallback ?? ((callback) => setTimeout(callback, 0)); + schedule(() => void this.activate(manifest.id).catch(() => undefined)); + } + } + } + + getActiveFeatureIds() { return [...this.activations.keys()]; } +} diff --git a/src/app/integration-catalog.js b/src/app/integration-catalog.js new file mode 100644 index 00000000..41dcb506 --- /dev/null +++ b/src/app/integration-catalog.js @@ -0,0 +1,13 @@ +// @ts-check + +import av123 from "../integrations/av123/manifest.js"; +import dmm from "../integrations/dmm/manifest.js"; +import fc2ppvdb from "../integrations/fc2ppvdb/manifest.js"; +import hostList from "../integrations/host-list/manifest.js"; +import javdb from "../integrations/javdb/manifest.js"; +import javbus from "../integrations/javbus/manifest.js"; +import javstore from "../integrations/javstore/manifest.js"; +import javtrailers from "../integrations/javtrailers/manifest.js"; +import subtitlecat from "../integrations/subtitlecat/manifest.js"; + +export const integrationManifests = Object.freeze([av123, dmm, fc2ppvdb, hostList, javdb, javbus, javstore, javtrailers, subtitlecat]); diff --git a/src/app/integration-registry.js b/src/app/integration-registry.js new file mode 100644 index 00000000..a6c2e834 --- /dev/null +++ b/src/app/integration-registry.js @@ -0,0 +1,48 @@ +// @ts-check + +import { defineIntegration } from "../contracts/manifests.js"; + +export class IntegrationRegistry { + /** @param {import("./dependency-container.js").DependencyContainer} container @param {import("../services/diagnostics-service.js").DiagnosticsService} diagnostics */ + constructor(container, diagnostics) { + this.container = container; + this.diagnostics = diagnostics; + /** @type {Map>} */ + this.manifests = new Map(); + /** @type {Map} */ + this.adapters = new Map(); + } + + /** @param {Record} manifest */ + register(manifest) { + try { + const validated = /** @type {Record} */ (defineIntegration(manifest)); + if (this.manifests.has(validated.id)) throw new Error(`Duplicate integration: ${validated.id}`); + this.manifests.set(validated.id, validated); + } catch (error) { + this.diagnostics.recordError(error); + throw error; + } + } + + /** @param {string} capability */ + list(capability) { return [...this.manifests.values()].filter((manifest) => manifest.capabilities.includes(capability)); } + + /** @param {string} id */ + getAdapter(id) { + const cached = this.adapters.get(id); + if (cached) return cached; + const manifest = this.manifests.get(id); + if (!manifest) throw new Error(`Unknown integration: ${id}`); + try { + const dependencies = this.container.resolveDeclared(manifest.requires); + const client = manifest.createClient(dependencies); + const adapter = manifest.createAdapter(client, dependencies); + this.adapters.set(id, adapter); + return adapter; + } catch (error) { + this.diagnostics.recordError(error); + throw error; + } + } +} diff --git a/src/app/provider-registry.js b/src/app/provider-registry.js new file mode 100644 index 00000000..77d57225 --- /dev/null +++ b/src/app/provider-registry.js @@ -0,0 +1,55 @@ +// @ts-check + +export class ProviderRegistry { + /** @param {import("../services/diagnostics-service.js").DiagnosticsService | null} [diagnostics] */ + constructor(diagnostics = null) { + /** @type {Map>} */ + this.providers = new Map(); + /** @type {Map>} */ + this.health = new Map(); + this.diagnostics = diagnostics; + } + + /** @param {Record} provider */ + register(provider) { + if (!provider || typeof provider.id !== "string" || !Array.isArray(provider.capabilities)) { + throw new TypeError("Provider requires id and capabilities"); + } + if (this.providers.has(provider.id)) throw new Error(`Duplicate provider: ${provider.id}`); + this.providers.set(provider.id, Object.freeze({ priority: 0, enabled: true, ...provider })); + return provider; + } + + /** @param {string} id */ + get(id) { + return this.providers.get(id) ?? null; + } + + /** @param {string} capability */ + list(capability) { + return [...this.providers.values()] + .filter((provider) => provider.enabled !== false && provider.capabilities.includes(capability)) + .sort((left, right) => Number(right.priority ?? 0) - Number(left.priority ?? 0)); + } + + /** @param {string} capability @param {unknown} context */ + async getAvailable(capability, context) { + const result = []; + for (const provider of this.list(capability)) { + if (typeof provider.isAvailable !== "function" || await provider.isAvailable(context)) result.push(provider); + } + return result; + } + + /** @param {string} id @param {Record} result */ + updateHealth(id, result) { + if (!this.providers.has(id)) throw new Error(`Unknown provider: ${id}`); + this.health.set(id, Object.freeze({ ...result, updatedAt: Date.now() })); + this.diagnostics?.updateProvider(id, result); + } + + /** @param {string} id */ + getHealth(id) { + return this.health.get(id) ?? null; + } +} diff --git a/src/app/settings-registry.js b/src/app/settings-registry.js new file mode 100644 index 00000000..1da252db --- /dev/null +++ b/src/app/settings-registry.js @@ -0,0 +1,15 @@ +// @ts-check + +export class SettingsRegistry { + constructor() { this.descriptors = new Map(); } + /** @param {{id: string, owner: string, contribution?: string, validate?: (value: unknown) => boolean} & Record} descriptor */ + register(descriptor) { + if (!descriptor?.id || !descriptor.owner) throw new TypeError("Setting descriptor requires id and owner"); + if (this.descriptors.has(descriptor.id)) throw new Error(`Duplicate setting descriptor: ${descriptor.id}`); + this.descriptors.set(descriptor.id, Object.freeze({ ...descriptor })); + } + /** @param {{disabledContributions?: Set}} [options] */ + list(options = {}) { return [...this.descriptors.values()].filter((descriptor) => !descriptor.contribution || !options.disabledContributions?.has(descriptor.contribution)); } + /** @param {string} id */ + get(id) { return this.descriptors.get(id) ?? null; } +} diff --git a/src/contracts/manifests.js b/src/contracts/manifests.js new file mode 100644 index 00000000..2313de97 --- /dev/null +++ b/src/contracts/manifests.js @@ -0,0 +1,43 @@ +// @ts-check + +const FEATURE_KINDS = new Set(["system", "feature"]); +const STARTUP_MODES = new Set(["eager", "idle", "on-command", "on-demand"]); +const TRUST_CLASSES = new Set(["builtin-public", "custom-public", "user-local"]); +const QUALITY_LEVELS = new Set(["bronze", "silver"]); + +/** @param {unknown} value @param {string} field */ +function requireNonEmptyString(value, field) { + if (typeof value !== "string" || value.trim() === "") throw new TypeError(`${field} must be a non-empty string`); +} + +/** @param {unknown} value @param {string} field */ +function requireArray(value, field) { + if (!Array.isArray(value)) throw new TypeError(`${field} must be an array`); +} + +/** @param {Record} manifest */ +export function defineFeature(manifest) { + requireNonEmptyString(manifest.id, "Feature id"); + if (!FEATURE_KINDS.has(String(manifest.kind))) throw new TypeError("Feature kind must be system or feature"); + if (!STARTUP_MODES.has(String(manifest.startup))) throw new TypeError("Feature startup mode is invalid"); + for (const field of ["sites", "routes", "requires", "contributes", "providesCommands"]) requireArray(manifest[field], field); + if (typeof manifest.activate !== "function") throw new TypeError("Feature activate must be a function"); + if (manifest.kind === "system" && manifest.disableable !== false) throw new TypeError("System features cannot be disableable"); + return Object.freeze({ ...manifest }); +} + +/** @param {Record} manifest */ +export function defineIntegration(manifest) { + requireNonEmptyString(manifest.id, "Integration id"); + if (!TRUST_CLASSES.has(String(manifest.trustClass))) throw new TypeError("Integration trustClass is invalid"); + if (!QUALITY_LEVELS.has(String(manifest.quality))) throw new TypeError("Integration quality is invalid"); + for (const field of ["hosts", "capabilities", "requires"]) requireArray(manifest[field], field); + const hosts = /** @type {unknown[]} */ (manifest.hosts); + const capabilities = /** @type {unknown[]} */ (manifest.capabilities); + if (hosts.length === 0 || capabilities.length === 0) throw new TypeError("Integration hosts and capabilities cannot be empty"); + if (manifest.cachePolicy === undefined) throw new TypeError("Integration cachePolicy must be explicit"); + if (typeof manifest.createClient !== "function" || typeof manifest.createAdapter !== "function") { + throw new TypeError("Integration client and adapter factories are required"); + } + return Object.freeze({ ...manifest }); +} diff --git a/src/contracts/models.js b/src/contracts/models.js new file mode 100644 index 00000000..e8b955e8 --- /dev/null +++ b/src/contracts/models.js @@ -0,0 +1,15 @@ +// @ts-check + +export const ACTRESS_TYPE = Object.freeze({ censored: "censored", uncensored: "uncensored" }); + +/** @param {Record} value */ +export function defineMovieRef(value) { + if (typeof value.carNum !== "string" || !value.carNum) throw new TypeError("MovieRef.carNum is required"); + return Object.freeze({ ...value }); +} + +/** @param {Record} value */ +export function defineMovieDetail(value) { + if (typeof value.carNum !== "string" || !value.carNum) throw new TypeError("MovieDetail.carNum is required"); + return Object.freeze({ ...value }); +} diff --git a/src/contracts/ports.js b/src/contracts/ports.js new file mode 100644 index 00000000..4f4d6bfa --- /dev/null +++ b/src/contracts/ports.js @@ -0,0 +1,18 @@ +// @ts-check + +/** @template T @param {T} implementation @param {string} name @param {string[]} methods @returns {T} */ +export function assertPort(implementation, name, methods) { + if (!implementation || typeof implementation !== "object") throw new TypeError(`${name} implementation is required`); + for (const method of methods) { + if (typeof /** @type {Record} */ (/** @type {unknown} */ (implementation))[method] !== "function") throw new TypeError(`${name}.${method} is required`); + } + return implementation; +} + +export const PORT_METHODS = Object.freeze({ + navigation: ["open", "assign", "replace"], + http: ["request"], + storage: ["get", "set", "remove"], + dialog: ["open", "close"], + style: ["register", "remove"], +}); diff --git a/src/contracts/tokens.js b/src/contracts/tokens.js new file mode 100644 index 00000000..b41dea2f --- /dev/null +++ b/src/contracts/tokens.js @@ -0,0 +1,46 @@ +// @ts-check + +/** @param {string} category @param {string} id */ +function createToken(category, id) { + return Symbol.for(`jhs.${category}.${id}`); +} + +export const PORT = Object.freeze({ + navigation: createToken("port", "navigation"), + http: createToken("port", "http"), + storage: createToken("port", "storage"), + dialog: createToken("port", "dialog"), + style: createToken("port", "style"), + host: createToken("port", "host"), +}); + +export const SERVICE = Object.freeze({ + diagnostics: createToken("service", "diagnostics"), + urlPolicy: createToken("service", "url-policy"), + navigation: createToken("service", "navigation"), + http: createToken("service", "http"), + storage: createToken("service", "storage"), + dialog: createToken("service", "dialog"), + settings: createToken("service", "settings"), + movie: createToken("service", "movie"), + review: createToken("service", "review"), + related: createToken("service", "related"), + magnet: createToken("service", "magnet"), + screenshot: createToken("service", "screenshot"), + offline: createToken("service", "offline"), + cache: createToken("service", "cache"), + state: createToken("service", "state"), + profile: createToken("service", "profile"), +}); + +export const REGISTRY = Object.freeze({ + command: createToken("registry", "command"), + feature: createToken("registry", "feature"), + provider: createToken("registry", "provider"), + settings: createToken("registry", "settings"), + integration: createToken("registry", "integration"), +}); + +export const CACHE = Object.freeze({ + externalDetail: "external-detail-v1", +}); diff --git a/src/core/cache-policy.js b/src/core/cache-policy.js index c897db76..5379d8e9 100644 --- a/src/core/cache-policy.js +++ b/src/core/cache-policy.js @@ -1,8 +1,9 @@ const HOUR = 60 * 60 * 1000; const DAY = 24 * HOUR; -const CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); +export const CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); -class ProviderError extends Error { +export class ProviderError extends Error { + /** @param {string} provider @param {string} code @param {string} message @param {{cause?: unknown, status?: number, url?: string, retryable?: boolean}} [options] */ constructor(provider, code, message, options = {}) { super(message, { cause: options.cause }); this.name = "ProviderError"; @@ -13,3 +14,4 @@ class ProviderError extends Error { this.retryable = Boolean(options.retryable); } } +// @ts-check diff --git a/src/core/constants.js b/src/core/constants.js index 481d0255..a5f36177 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -1,4 +1,6 @@ -var e, t, n = Object.defineProperty, a = e => { +import { detectSite } from "./site-context.js"; + +export var e, t, n = Object.defineProperty, a = e => { throw TypeError(e); }, i = (e, t, a) => ((e, t, a) => t in e ? n(e, t, { enumerable: !0, @@ -9,7 +11,7 @@ var e, t, n = Object.defineProperty, a = e => { t.has(e) || a("Cannot " + n); })(e, t, "access private method"), n); -const o = window.location.href, siteContext = detectSite(window.location), r = siteContext.isJavDB, l = siteContext.isJavBus, c = o.includes("/search?q") || o.includes("/search/") || o.includes("/users/"), d = "filter", h = "favorite", g = "hasDown", p = "hasWatch", m = "屏蔽", u = "已屏蔽", f = "var(--jhs-status-filter-text)", v = "收藏", b = "已收藏", w = "var(--jhs-status-fav-text)", y = "已下载", x = "var(--jhs-status-down-text)", k = "已观看", S = "var(--jhs-status-watch-text)", C = "no", _ = "yes", T = "javdb", I = "javbus", B = "actor", P = "actress", D = "censored", A = "uncensored", L = [ { +export const o = window.location.href, siteContext = detectSite(window.location), r = siteContext.isJavDB, l = siteContext.isJavBus, c = o.includes("/search?q") || o.includes("/search/") || o.includes("/users/"), d = "filter", h = "favorite", g = "hasDown", p = "hasWatch", m = "屏蔽", u = "已屏蔽", f = "var(--jhs-status-filter-text)", v = "收藏", b = "已收藏", w = "var(--jhs-status-fav-text)", y = "已下载", x = "var(--jhs-status-down-text)", k = "已观看", S = "var(--jhs-status-watch-text)", C = "no", _ = "yes", T = "javdb", I = "javbus", B = "actor", P = "actress", D = "censored", A = "uncensored", L = [ { id: "video-mhb", quality: "dmb_w", text: "旧视频源-中画质宽版 (404p)", @@ -71,12 +73,12 @@ const o = window.location.href, siteContext = detectSite(window.location), r = s canSelect: !0 } ]; -function escapeHtml(e) { const t = document.createElement("span"); return t.textContent = e, t.innerHTML; } +export function escapeHtml(e) { const t = document.createElement("span"); return t.textContent = e, t.innerHTML; } -const CURRENT_DATA_VERSION = 2; +export const CURRENT_DATA_VERSION = 2; /** 规范化内部番号输入,无效值统一返回 null。 */ -function normalizeCarNum(value) { +export function normalizeCarNum(value) { if ("string" != typeof value) return null; let carNum = value.trim(); if (!carNum || [ "undefined", "null" ].includes(carNum.toLowerCase())) return null; @@ -93,7 +95,7 @@ function tryCanonicalizeSimpleCarNum(value) { } /** 按调用方给定的可靠性顺序选择第一个有效番号。 */ -function firstValidCarNum(...candidates) { +export function firstValidCarNum(...candidates) { for (const candidate of candidates) { const carNum = normalizeCarNum(candidate); if (carNum) return carNum; @@ -102,19 +104,19 @@ function firstValidCarNum(...candidates) { } /** 断言详情信息解析器始终履行对象返回契约。 */ -function assertPageInfoContract(pageInfo) { +export function assertPageInfoContract(pageInfo) { if (!pageInfo || "object" != typeof pageInfo || Array.isArray(pageInfo)) throw new TypeError("getPageInfo() contract broken: expected object"); return pageInfo; } -let M = ""; +export let M = ""; window.location.href.includes("hideNav=1") && (M = "\n .navbar-default {\n display: none !important;\n }\n body {\n padding-top:0px!important;\n }\n "); /* N (JavBus CSS) moved to css-injection.js */ -let j = ""; +export let j = ""; window.location.href.includes("hideNav=1") && (j = "\n .main-nav,#search-bar-container {\n display: none !important;\n }\n \n html {\n padding-top:0px!important;\n }\n "); @@ -122,7 +124,7 @@ window.location.href.includes("hideNav=1") && (j = "\n .main-nav,#search- /* F (global UI CSS) moved to css-injection.js */ -function H(e) { +export function H(e) { if (e) if (e.includes("\n '); -unsafeWindow.loading = window.loading = function() { +window.loading = function() { const e = document.createElement("div"); e.className = "loading-container"; const t = document.createElement("div"); @@ -57,7 +67,7 @@ unsafeWindow.loading = window.loading = function() { m.toastElement.remove(); }, m; }; - unsafeWindow.show = window.show = { + window.show = { ok: (t, n = "center", a, i) => e(t, "success", n, a, i), error: (t, n = "center", a, i) => e(t, "error", n, a, i), info: (t, n = "center", a, i) => e(t, "info", n, a, i) @@ -108,7 +118,7 @@ unsafeWindow.loading = window.loading = function() { "Escape" !== t.key && " " !== t.key || (t.preventDefault(), t.stopPropagation(), o.destroy(), document.removeEventListener("keydown", o.handleKeydown), document.documentElement.style.overflow = "", document.body.style.overflow = "", e()); - }, document.addEventListener("keydown", o.handleKeydown); + }, scope.listen(document, "keydown", o.handleKeydown); }, hidden() { o && o.handleKeydown && document.removeEventListener("keydown", o.handleKeydown), @@ -120,6 +130,7 @@ unsafeWindow.loading = window.loading = function() { }; }(), window.ImageHoverPreview = class { constructor(config = {}) { + this.scope = new LifecycleScope(`ui:image-hover:${Date.now()}`); this.config = { selector: ".hover-preview", dataAttribute: "data-full", @@ -210,8 +221,8 @@ unsafeWindow.loading = window.loading = function() { } bindEvents() { if (this.eventsBound || this.destroyed) return; - document.addEventListener("mouseover", this.onDocumentOver), document.addEventListener("mouseout", this.onDocumentOut), - document.addEventListener("mousemove", this.onDocumentMove), this.eventsBound = !0; + this.scope.listen(document, "mouseover", this.onDocumentOver), this.scope.listen(document, "mouseout", this.onDocumentOut), + this.scope.listen(document, "mousemove", this.onDocumentMove), this.eventsBound = !0; } findTarget(event) { const target = event.target; @@ -356,8 +367,7 @@ unsafeWindow.loading = window.loading = function() { this.animationFrame = null; this.pendingImage && (this.pendingImage.onload = null, this.pendingImage.onerror = null); this.pendingImage = null; - this.eventsBound && (document.removeEventListener("mouseover", this.onDocumentOver), document.removeEventListener("mouseout", this.onDocumentOut), - document.removeEventListener("mousemove", this.onDocumentMove), this.eventsBound = !1); + this.eventsBound && (this.scope.dispose(), this.eventsBound = !1); this.loadedUrls.clear(); this.preview?.remove(); this.preview = null; @@ -615,16 +625,16 @@ unsafeWindow.loading = window.loading = function() { } } try { - unsafeWindow.parent !== unsafeWindow && unsafeWindow.parent.clog && "function" == typeof unsafeWindow.parent.clog.log ? window.clog = unsafeWindow.clog = unsafeWindow.parent.clog : window.clog = unsafeWindow.clog = new o; + unsafeWindow.parent !== unsafeWindow && unsafeWindow.parent.clog && "function" == typeof unsafeWindow.parent.clog.log ? window.clog = unsafeWindow.parent.clog : window.clog = new o; } catch (r) { - console.error("创建日志控制台出现异常", r), window.clog = unsafeWindow.clog = new o; + console.error("创建日志控制台出现异常", r), window.clog = new o; } !function() { const e = window.clog || console; - window.addEventListener("error", (function(t) { + scope.listen(window, "error", (function(t) { const n = t.filename, a = t.message; n.includes("javdb") || n.includes("javbus") || e.error(`[全局 Error 异常捕获] ${a} 来源: ${n}`); - })), window.addEventListener("unhandledrejection", (function(t) { + })), scope.listen(window, "unhandledrejection", (function(t) { const n = t.reason, a = (null == n ? void 0 : n.message) ?? ""; if ([ "NotAllowedError", "AbortError", "NotSupportedError" ].includes(n?.name) || a.includes("play()") || a.includes("The element has no supported sources")) return e.warn("[全局媒体播放异常] 当前媒体源无法播放", n), void t.preventDefault(); @@ -632,7 +642,7 @@ unsafeWindow.loading = window.loading = function() { const i = `[全局 Promise 异常捕获] ${n.message || n}`; e.error(i, n), t.preventDefault(); })); - }(), document.addEventListener("mousedown", (e => { + }(), scope.listen(document, "mousedown", (e => { const t = window.clog; if (!t.isInitialized || !t.container) return; const n = e.target, a = [ ".console-logger-container", ".layui-layer-shade", ".loading-container" ].join(","); @@ -677,7 +687,7 @@ unsafeWindow.loading = window.loading = function() { } document.head.insertAdjacentHTML("beforeend", "\n \n "); const t = "[data-tip-top], [data-tip-bottom], [data-tip-left], [data-tip-right], [data-tip]"; - document.addEventListener("mouseover", (n => { + scope.listen(document, "mouseover", (n => { const a = n.target.closest(t); if (a && !a.tooltipElement) { let t, n = "top"; @@ -691,10 +701,13 @@ unsafeWindow.loading = window.loading = function() { a.matches(":hover") && !a.tooltipElement && e(a, t, n); }), 50); } - })), document.addEventListener("mouseout", (e => { + })), scope.listen(document, "mouseout", (e => { const n = e.target.closest(t); var a; n && (n.hoverTimeout && (clearTimeout(n.hoverTimeout), n.hoverTimeout = null), n.contains(e.relatedTarget) || n.tooltipElement && ((a = n.tooltipElement) && a.parentNode && a.remove(), n.tooltipElement = null)); })); }(); +loggerRuntime = Object.freeze({ loading: window.loading, show: window.show, clog: window.clog }); +return loggerRuntime; +} diff --git a/src/core/migration.js b/src/core/migration.js index 03d0d667..0388b744 100644 --- a/src/core/migration.js +++ b/src/core/migration.js @@ -1,8 +1,12 @@ -const PORTABLE_DATA_KEYS = Object.freeze([ "car_list", "filter_keyword_title", "filter_keyword_review", "setting", "blacklist", "blacklist_car_list", "third_party_ttl_cache", "favorite_actresses", "highlighted_tags", "activity_log", "offline_history", "new_video_decisions" ]); +import { CURRENT_DATA_VERSION, normalizeCarNum } from "./constants.js"; +import { mergeCanonicalCarRecords } from "./state-model.js"; + +export const PORTABLE_DATA_KEYS = Object.freeze([ "car_list", "filter_keyword_title", "filter_keyword_review", "setting", "blacklist", "blacklist_car_list", "favorite_actresses", "highlighted_tags", "activity_log", "offline_history", "new_video_decisions" ]); +export const IMPORTABLE_DATA_KEYS = Object.freeze([ ...PORTABLE_DATA_KEYS, "third_party_ttl_cache" ]); const PORTABLE_ARRAY_KEYS = new Set([ "car_list", "filter_keyword_title", "filter_keyword_review", "blacklist", "blacklist_car_list", "favorite_actresses", "highlighted_tags", "offline_history" ]); const PORTABLE_OBJECT_KEYS = new Set([ "setting", "third_party_ttl_cache", "activity_log", "new_video_decisions" ]); -function validatePortableData(data) { +export function validatePortableData(data) { if (!data || "object" != typeof data || Array.isArray(data)) throw new TypeError("备份数据格式无效"); const version = Number(data.data_version || 0); if (!Number.isInteger(version) || version < 0) throw new TypeError("备份数据版本无效"); @@ -12,7 +16,7 @@ function validatePortableData(data) { return version; } -async function hasPortableUserData(storage) { +export async function hasPortableUserData(storage) { for (const key of PORTABLE_DATA_KEYS) { const value = await storage.forage.getItem(key); if (Array.isArray(value) ? value.length : value && "object" == typeof value ? Object.keys(value).length : null != value) return !0; @@ -66,7 +70,7 @@ async function migrateStateFlags(storage) { const DATA_MIGRATIONS = Object.freeze({ 1: migrateLegacyStorage, 2: migrateStateFlags }); -async function runDataMigrations(storage) { +export async function runDataMigrations(storage) { let version = await storage.getDataVersion(); if (version > CURRENT_DATA_VERSION) throw new Error("数据来自更新版本的 JHS,当前版本无法安全读取"); for (let target = version + 1; target <= CURRENT_DATA_VERSION; target++) { diff --git a/src/core/movie-identity.js b/src/core/movie-identity.js new file mode 100644 index 00000000..c8f34e63 --- /dev/null +++ b/src/core/movie-identity.js @@ -0,0 +1,15 @@ +// @ts-check + +const SIMPLE_CAR_PREFIXES = new Set(["ABC", "ABP", "ADN", "ATID", "BF", "CAWD", "DLDSS", "DVAJ", "FSDSS", "HEYZO", "HMN", "IPX", "IPZZ", "JUQ", "JUL", "JUX", "MEYD", "MIAA", "MIDE", "MIDV", "MIMK", "MIRD", "NIMA", "PRED", "RBD", "SDDE", "SONE", "SSIS", "SSNI", "STARS", "URE", "VEC", "WAAA", "WANZ", "XVSR"]); + +/** @param {unknown} value */ +export function normalizeMovieCarNum(value) { + if (typeof value !== "string") return null; + let carNum = value.trim(); + if (!carNum || ["undefined", "null"].includes(carNum.toLowerCase())) return null; + carNum = carNum.normalize("NFKC").replace(/[‐‑‒–—―﹘﹣-]/g, "-").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toUpperCase(); + const match = carNum.match(/^([A-Z]{2,8})(\d{2,7})$/); + return match && SIMPLE_CAR_PREFIXES.has(match[1]) ? `${match[1]}-${match[2]}` : carNum || null; +} + +export { normalizeMovieCarNum as normalizeCarNum }; diff --git a/src/core/plugin-manager.js b/src/core/plugin-manager.js index e6e09ec7..801a4ae2 100644 --- a/src/core/plugin-manager.js +++ b/src/core/plugin-manager.js @@ -1,4 +1,7 @@ -class PluginManager { +import { A, B, D, I, P, T, assertPageInfoContract, firstValidCarNum, i, l, normalizeCarNum, o, r } from "./constants.js"; +import { disabledIdForPlugin, parseDisabledPlugins } from "./legacy-plugin-contributions.js"; + +export class PluginManager { constructor() { this.plugins = new Map; this._errorLog = []; @@ -11,6 +14,11 @@ class PluginManager { this._idlePending = 0; this._idleCompleted = 0; this._disabledPluginsPromise = null; + this._dependencyDeclarations = Object.freeze({}); + } + setDependencyDeclarations(declarations) { + if (this.plugins.size) throw new Error("依赖声明必须在插件注册前配置"); + this._dependencyDeclarations = declarations || Object.freeze({}); } register(e) { if ("function" != typeof e) throw new Error("插件必须是一个类"); @@ -19,12 +27,17 @@ class PluginManager { t.pluginManager = this; const n = t.getName(); if (this.plugins.has(n)) throw new Error(`插件"${n}"已注册`); + t.declaredDependencies = new Set(this._dependencyDeclarations[n] || []); this.plugins.set(n, t); this._registrationMs += performance.now() - a; } + // 仅供 6.6 前的外部 Compatibility Facade 使用。 getBean(e) { return this.plugins.get(e); } + resolveDeclaredPlugin(e) { + return this.plugins.get(e); + } _addError(e, t, n) { this._errorLog.push({ time: new Date().toISOString(), @@ -53,7 +66,8 @@ class PluginManager { async _getDisabledPlugins() { return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { try { const e = await storageManager.getSetting("disabledPlugins", "[]"); - return new Set(JSON.parse(e).filter((name => ![ "SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin" ].includes(name)))); + const configured = new Set(parseDisabledPlugins(e)); + return new Set(this.getPluginNames().filter((name => ![ "SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin" ].includes(name) && configured.has(disabledIdForPlugin(name))))); } catch (e) { return new Set; } })()); } async processCss() { @@ -146,7 +160,7 @@ class PluginManager { } } -class BasePlugin { +export class BasePlugin { constructor() { if (!BasePlugin._sharedIcons) { i(this, "pluginManager", null), i(this, "settingSvg", ''), @@ -172,12 +186,16 @@ class BasePlugin { e.forEach((e => delete this[e])); } i(this, "pluginManager", null); + i(this, "declaredDependencies", new Set); } getName() { throw new Error(`${this.constructor.name} 未显示getName()`); } - getBean(e) { - return this.pluginManager.getBean(e); + getDependency(e) { + if (!this.declaredDependencies.has(e)) { + throw new Error(`${this.getName()} 未声明依赖 ${e}`); + } + return this.pluginManager.resolveDeclaredPlugin(e); } async initCss() { return ""; diff --git a/src/core/selection-model.js b/src/core/selection-model.js new file mode 100644 index 00000000..cc7b5f8e --- /dev/null +++ b/src/core/selection-model.js @@ -0,0 +1,20 @@ +// @ts-check + +export class SelectionModel extends EventTarget { + /** @param {(item: any) => string} keyOf */ + constructor(keyOf) { super(); this.keyOf = keyOf; this.mode = "explicit"; this.selected = new Set(); this.excluded = new Set(); } + /** @param {any} item @param {boolean} selected */ + set(item, selected) { + const key = this.keyOf(item), target = this.mode === "all-filtered" ? this.excluded : this.selected; + this.mode === "all-filtered" ? selected ? target.delete(key) : target.add(key) : selected ? target.add(key) : target.delete(key); + this.emit(); + } + selectAllFiltered() { this.mode = "all-filtered"; this.selected.clear(); this.excluded.clear(); this.emit(); } + clear() { this.mode = "explicit"; this.selected.clear(); this.excluded.clear(); this.emit(); } + /** @param {any} item */ + has(item) { const key = this.keyOf(item); return this.mode === "all-filtered" ? !this.excluded.has(key) : this.selected.has(key); } + /** @param {any[]} filteredItems */ + values(filteredItems) { return this.mode === "all-filtered" ? filteredItems.filter((item) => !this.excluded.has(this.keyOf(item))) : filteredItems.filter((item) => this.selected.has(this.keyOf(item))); } + snapshot() { return Object.freeze({ mode: this.mode, selected: [...this.selected], excluded: [...this.excluded] }); } + emit() { this.dispatchEvent(new CustomEvent("selection.changed", { detail: this.snapshot() })); } +} diff --git a/src/core/site-context.js b/src/core/site-context.js index 6a5ff4ce..b1f053f0 100644 --- a/src/core/site-context.js +++ b/src/core/site-context.js @@ -8,7 +8,7 @@ function normalizeLocation(locationLike = window.location) { return new URL(locationLike.href || `${locationLike.protocol}//${locationLike.hostname}${locationLike.pathname || "/"}${locationLike.search || ""}`); } /** 仅按 hostname 识别脚本运行站点,避免 URL 查询串造成误判。 */ -function detectSite(locationLike = window.location) { +export function detectSite(locationLike = window.location) { const locationUrl = normalizeLocation(locationLike); const hostname = locationUrl.hostname.toLowerCase().replace(/\.$/, ""); const isJavDB = JAVDB_HOST_PATTERN.test(hostname); @@ -21,7 +21,7 @@ function detectSite(locationLike = window.location) { } /** 识别由 JHS 接管渲染的 JavDB 热播榜页面。 */ -function isHitShowPage(locationLike = window.location) { +export function isHitShowPage(locationLike = window.location) { const locationUrl = normalizeLocation(locationLike); return "/advanced_search" === locationUrl.pathname && "1" === locationUrl.searchParams.get("handlePlayback"); } @@ -34,6 +34,6 @@ function isNormalListPage(locationLike = window.location, hasMovieList = null) { } /** 识别所有具备列表页能力的页面,包括 JHS 热播榜。 */ -function isListPage(locationLike = window.location, hasMovieList = null) { +export function isListPage(locationLike = window.location, hasMovieList = null) { return isHitShowPage(locationLike) || isNormalListPage(locationLike, hasMovieList); } diff --git a/src/core/state-model.js b/src/core/state-model.js index a0de8c3e..0b11cd8f 100644 --- a/src/core/state-model.js +++ b/src/core/state-model.js @@ -1,7 +1,9 @@ -const STATE_FLAG_NAMES = Object.freeze([ "favorite", "downloaded", "watched", "blocked" ]); +import { d, g, h, normalizeCarNum, p } from "./constants.js"; + +export const STATE_FLAG_NAMES = Object.freeze([ "favorite", "downloaded", "watched", "blocked" ]); const LEGACY_STATUS_TO_FLAG = Object.freeze({ [h]: "favorite", [g]: "downloaded", [p]: "watched", [d]: "blocked" }); -function createEmptyStateFlags() { +export function createEmptyStateFlags() { return { favorite: !1, downloaded: !1, watched: !1, blocked: !1 }; } @@ -11,7 +13,7 @@ function stateFlagsFromLegacyStatus(status) { return flag && (flags[flag] = !0), flags; } -function normalizeStateFlags(flags) { +export function normalizeStateFlags(flags) { const normalized = createEmptyStateFlags(); return STATE_FLAG_NAMES.forEach((name => normalized[name] = !0 === flags?.[name])), normalized; } @@ -22,20 +24,20 @@ function projectLegacyStatus(flags) { } /** legacy status 只能由此函数生成,避免双源漂移。 */ -function syncLegacyStatus(record) { +export function syncLegacyStatus(record) { return record.stateFlags = normalizeStateFlags(record.stateFlags), record.status = projectLegacyStatus(record.stateFlags), record; } -function hasAnyState(flags) { +export function hasAnyState(flags) { const normalized = normalizeStateFlags(flags); return STATE_FLAG_NAMES.some((name => normalized[name])); } -function legacyActionToFlag(actionType) { +export function legacyActionToFlag(actionType) { return LEGACY_STATUS_TO_FLAG[actionType] || null; } -function mergeCanonicalCarRecords(records) { +export function mergeCanonicalCarRecords(records) { const groups = new Map, collisions = [], unknownStatuses = []; records.filter(Boolean).forEach((record => { const original = record.carNum, carNum = normalizeCarNum(original); diff --git a/src/core/state-service.js b/src/core/state-service.js index baad7fe7..7abeed90 100644 --- a/src/core/state-service.js +++ b/src/core/state-service.js @@ -1,3 +1,8 @@ +import { normalizeCarNum } from "./constants.js"; +import { jhsEventBus } from "./event-bus.js"; +import { storageManager } from "./http.js"; +import { STATE_FLAG_NAMES, createEmptyStateFlags, normalizeStateFlags, syncLegacyStatus } from "./state-model.js"; + const ACTIVITY_SOFT_LIMIT = 1e3, ACTIVITY_HARD_LIMIT = 1e4, ACTIVITY_RETENTION_MS = 30 * 864e5; function cloneStateValue(value) { @@ -265,4 +270,5 @@ class StateService { } } -const stateService = unsafeWindow.stateService = window.stateService = new StateService(storageManager, jhsEventBus); +export const stateService = new StateService(storageManager, jhsEventBus); +storageManager.stateService = stateService; diff --git a/src/core/storage-index.js b/src/core/storage-index.js index 16d73165..3faa4b4f 100644 --- a/src/core/storage-index.js +++ b/src/core/storage-index.js @@ -1,10 +1,13 @@ +import { d, g, h, p } from "./constants.js"; +import { normalizeStateFlags } from "./state-model.js"; + /** 按字段为数组数据构建运行时索引。 */ -function createIndexedMap(items, key) { +export function createIndexedMap(items, key) { return new Map(items.filter((item => item && item[key])).map((item => [ item[key], item ]))); } /** 为番号组合状态构建运行时 Set 索引。 */ -function createStatusMap(items) { +export function createStatusMap(items) { const statusMap = { [d]: new Set, [h]: new Set, @@ -21,7 +24,7 @@ function createStatusMap(items) { } /** 统计指定字段的重复值。 */ -function groupDuplicateItems(items, key) { +export function groupDuplicateItems(items, key) { const counts = new Map; items.forEach((item => { const value = item && item[key]; @@ -31,7 +34,7 @@ function groupDuplicateItems(items, key) { } /** 按字段去重,保留第一条记录并合并后续字段。 */ -function dedupeByKey(items, key) { +export function dedupeByKey(items, key) { const seen = new Map, list = []; let changed = !1; for (const item of items) { diff --git a/src/core/storage.js b/src/core/storage.js index a7728a7b..069e5d15 100644 --- a/src/core/storage.js +++ b/src/core/storage.js @@ -1,4 +1,9 @@ -e = new WeakSet, t = async function(e, t, n) { +import { A, B, D, P, a, d, escapeHtml, g, h, i, normalizeCarNum, p, s } from "./constants.js"; +import { IMPORTABLE_DATA_KEYS, PORTABLE_DATA_KEYS, hasPortableUserData, runDataMigrations, validatePortableData } from "./migration.js"; +import { legacyActionToFlag, normalizeStateFlags } from "./state-model.js"; +import { createIndexedMap, createStatusMap, dedupeByKey, groupDuplicateItems } from "./storage-index.js"; + +let e = new WeakSet, t = async function(e, t, n) { let a; if (Array.isArray(e)) a = [ ...e ]; else { if (a = await this.forage.getItem(t) || [], a.includes(e)) { @@ -10,8 +15,9 @@ e = new WeakSet, t = async function(e, t, n) { return await this._setItemAndInvalidate(t, a), a; }; -class StorageManager { +export class StorageManager { constructor() { + this.stateService = globalThis.stateService ?? null; var t, s, o; if (t = this, (s = e).has(t) ? a("Cannot add the same private member more than once") : s instanceof WeakSet ? s.add(t) : s.set(t, o), i(this, "car_list_key", "car_list"), i(this, "filter_keyword_title_key", "filter_keyword_title"), @@ -167,7 +173,7 @@ class StorageManager { const messages = { blocked: "已在屏蔽列表中", favorite: "已在收藏列表中", downloaded: "已标记为已下载", watched: "已标记为已观看" }, message = `${carNum} ${messages[flag]}`; throw show.error(message), new Error(message); } - return stateService.patch(carNum, { [flag]: !0 }, { type: "legacy-save", record: { ...e, carNum } }); + return this.stateService.patch(carNum, { [flag]: !0 }, { type: "legacy-save", record: { ...e, carNum } }); } async updateCarInfo(e) { let {carNum: t, url: n, names: a, actionType: i, publishTime: s, remark: o} = e; @@ -182,7 +188,7 @@ class StorageManager { } const flag = legacyActionToFlag(i); if (!flag) { const e = "actionType错误, 请联系作者更正: " + i; throw show.error(e), new Error(e); } - return stateService.patch(t, { [flag]: !0 }, { type: "legacy-update", record: { carNum: t, names: a, url: n, remark: o, publishTime: s } }); + return this.stateService.patch(t, { [flag]: !0 }, { type: "legacy-update", record: { carNum: t, names: a, url: n, remark: o, publishTime: s } }); } async saveCarList(e) { if (!e || !Array.isArray(e) || 0 === e.length) throw show.error("记录列表为空!"), new Error("记录列表为空!"); @@ -198,7 +204,7 @@ class StorageManager { const group = groups.get(flag) || []; group.push({ ...item, carNum }), groups.set(flag, group); } - for (const [flag, records] of groups) await stateService.patch(records.map((item => item.carNum)), { [flag]: !0 }, { type: "legacy-batch-save", records }); + for (const [flag, records] of groups) await this.stateService.patch(records.map((item => item.carNum)), { [flag]: !0 }, { type: "legacy-batch-save", records }); } async removeNewVideoList(e) { return this.withActressLock(async () => { @@ -217,11 +223,11 @@ class StorageManager { }); } async removeCar(e) { - const result = await stateService.remove(e); + const result = await this.stateService.remove(e); return result.changed.length ? !0 : (show.error(`${e} 不存在`), !1); } async batchRemoveCars(e) { - const result = await stateService.remove(e); + const result = await this.stateService.remove(e); return result.changed.length || !1; } async getBlacklist() { @@ -402,9 +408,10 @@ class StorageManager { async importData(e) { validatePortableData(e); await hasPortableUserData(this) && await this.createSnapshot("导入前自动备份", "auto-import"); - const validKeys = new Set([ ...PORTABLE_DATA_KEYS, "data_version" ]), writes = []; + const validKeys = new Set([ ...IMPORTABLE_DATA_KEYS, "data_version" ]), writes = []; for (const key in e) { if (!validKeys.has(key)) { clog.warn(`[导入] 跳过未知数据键: ${key}`); continue; } + if ("third_party_ttl_cache" === key) continue; writes.push("data_version" === key ? this.setDataVersion(e[key]) : this._setItemAndInvalidate(key, e[key])); } await Promise.all(writes), this._invalidateCache(), await runDataMigrations(this), await window.stateService?.recoverPendingTransaction(); diff --git a/src/core/theme.js b/src/core/theme.js index c0623a85..4c3c69da 100644 --- a/src/core/theme.js +++ b/src/core/theme.js @@ -1,7 +1,7 @@ /** 设计令牌层 (Design tokens): 全部 --jhs-* 变量, 亮色(:root) + 暗色(:root[data-jhs-theme="dark"])。 * 在 css-injection.js 最先注入, 供所有后续插件 CSS 通过 var(--jhs-*) 消费。 */ -const JHS_Z_INDEX = Object.freeze({ +export const JHS_Z_INDEX = Object.freeze({ content: 10, elevated: 20, localPopover: 30, @@ -23,7 +23,7 @@ const JHS_Z_INDEX = Object.freeze({ tooltip: 9999999999 }); -function buildThemeCss() { +export function buildThemeCss() { return `\n'; } @@ -32,7 +39,7 @@ class UnifiedOfflinePlugin extends BasePlugin { this.registerProviders(), this.bindSubmit(), window.isDetailPage && (this.injectNativeButtons(), jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); } registerProviders() { - const one23 = this.getBean("OneTwoThreeOfflinePlugin"); + const one23 = this.getDependency("OneTwoThreeOfflinePlugin"); one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: [ "magnet" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting("enable123Offline", !0), getAvailability: async () => one23.getStoredToken() ? { available: !0, authState: "ready", reason: "授权已同步" } : { available: !1, authState: "token-missing", reason: "尚未同步 123 授权" }, submit: async resource => { const token = one23.getStoredToken(); if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); const resolved = await one23.resolveMagnet(resource, token); return one23.submitTask(resolved, token); }, openUrl: () => "https://yun.123pan.com" }); this.registry.register({ id: "115", name: "115", capabilities: [ "magnet", "ed2k" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting("enable115Offline", !1), getAvailability: async () => ({ available: !0, authState: "unknown", reason: "提交时确认登录状态" }), submit: resource => new OneOneFiveClient().addOffline(resource), openUrl: () => "https://115.com" }); window.offlineProviderRegistry = this.registry; @@ -70,7 +77,7 @@ class UnifiedOfflinePlugin extends BasePlugin { getVideoInfo(button) { if (window.isDetailPage) return this.getPageInfo(); const item = button?.closest?.(".item"); - return item?.length ? this.getBean("ListPagePlugin").findCarNumAndHref(item) : this.getPageInfo(); + return item?.length ? this.getDependency("ListPagePlugin").findCarNumAndHref(item) : this.getPageInfo(); } async submitResource(event, resource, button = $(), context = null, retryOf = null, options = {}) { if (button.hasClass("loading")) return; diff --git a/src/plugins/one-one-five/client.js b/src/plugins/one-one-five/client.js index b3bbb2a6..078169ca 100644 --- a/src/plugins/one-one-five/client.js +++ b/src/plugins/one-one-five/client.js @@ -1,4 +1,7 @@ -class OneOneFiveClient { +import { ProviderError } from "../../core/cache-policy.js"; +import { normalizeCarNum } from "../../core/constants.js"; + +export class OneOneFiveClient { constructor(http = gmHttp) { this.http = http; } async checkLogin() { try { const result = await this.http.get("https://webapi.115.com/offine/downpath"); return Boolean(result?.state && result?.data?.length); } catch (cause) { throw new ProviderError("115", "LOGIN_REQUIRED", "115 未登录", { cause }); } @@ -34,10 +37,10 @@ class OneOneFiveClient { } } -function normalize115Keyword(carNum) { const normalized = normalizeCarNum(carNum); return normalized?.replace(/^FC2-/i, "") || null; } -function build115PlayUrl(match) { return match?.videoId ? `https://115.com/?ct=play&pickcode=${encodeURIComponent(match.videoId)}` : null; } -function format115Size(bytes) { const value = Number(bytes) || 0; if (!value) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"], index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); return `${(value / 1024 ** index).toFixed(index ? 2 : 0)} ${units[index]}`; } -function preview115Rename(fileName, carNum, options = {}) { +export function normalize115Keyword(carNum) { const normalized = normalizeCarNum(carNum); return normalized?.replace(/^FC2-/i, "") || null; } +export function build115PlayUrl(match) { return match?.videoId ? `https://115.com/?ct=play&pickcode=${encodeURIComponent(match.videoId)}` : null; } +export function format115Size(bytes) { const value = Number(bytes) || 0; if (!value) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"], index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); return `${(value / 1024 ** index).toFixed(index ? 2 : 0)} ${units[index]}`; } +export function preview115Rename(fileName, carNum, options = {}) { const extension = fileName.match(/\.[^.]+$/)?.[0] || "", tags = fileName.match(/-(?:U|UC|C|4K|8K|H265|HEVC|CN|CHS|CHT)\b/gi) || []; let base = options.uppercase === false ? carNum : carNum.toUpperCase(); if (options.keepTitle) base += ` ${fileName.replace(extension, "").replace(/^.*?\s+/, "")}`; diff --git a/src/plugins/one-one-five/plugins.js b/src/plugins/one-one-five/plugins.js index e3b39083..958a01b8 100644 --- a/src/plugins/one-one-five/plugins.js +++ b/src/plugins/one-one-five/plugins.js @@ -1,4 +1,10 @@ -class OneOneFiveMatchPlugin extends BasePlugin { +import { escapeHtml, normalizeCarNum } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { mapLimit } from "../../core/feature-helpers.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { OneOneFiveClient, build115PlayUrl, format115Size, normalize115Keyword, preview115Rename } from "./client.js"; + +export class OneOneFiveMatchPlugin extends BasePlugin { constructor() { super(), this.observer = null, this.unsubscribeItems = null, this.pendingCards = new Set, this.flushTimer = null, this.client = new OneOneFiveClient, this.concurrency = 4, this.cacheMinutes = 60; } getName() { return "OneOneFiveMatchPlugin"; } async handle() { diff --git a/src/plugins/one-two-three/offline.js b/src/plugins/one-two-three/offline.js index f9221964..fdd0df7c 100644 --- a/src/plugins/one-two-three/offline.js +++ b/src/plugins/one-two-three/offline.js @@ -1,4 +1,6 @@ -class OneTwoThreeOfflinePlugin extends BasePlugin { +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class OneTwoThreeOfflinePlugin extends BasePlugin { constructor() { super(...arguments), this.tokenKey = "jhs_123pan_author_token", this.tokenMetaKey = "jhs_123pan_author_token_meta", this.syncTimer = null, this.syncFallbackMs = 3e5; diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 60f261f8..86a42a87 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -1,3 +1,49 @@ +import { detectSite } from "../core/site-context.js"; +import { LEGACY_PLUGIN_DEPENDENCY_MAP } from "./dependency-map.js"; +import { ActressInfoPlugin } from "./avatar/actress-info.js"; +import { SearchByImagePlugin } from "./avatar/search-by-image.js"; +import { SettingPlugin } from "./backup/setting.js"; +import { BlacklistPlugin } from "./blacklist/blacklist.js"; +import { FilterTitleKeywordPlugin } from "./blacklist/filter-title-keyword.js"; +import { Fc2By123AvPlugin } from "./external-search/fc2-by-123av.js"; +import { Fc2Plugin } from "./external-search/fc2.js"; +import { HitShowPlugin } from "./external-search/hit-show.js"; +import { JavTrailersPlugin } from "./external-search/javtrailers.js"; +import { MagnetHubPlugin } from "./external-search/magnet-hub.js"; +import { OtherSitePlugin } from "./external-search/other-site.js"; +import { RelatedPlugin } from "./external-search/related.js"; +import { ReviewPlugin } from "./external-search/review.js"; +import { Top250Plugin } from "./external-search/top250.js"; +import { FavoriteActressesPlugin } from "./favorite/favorite-actresses.js"; +import { BusImgPlugin } from "./image-viewer/bus-img.js"; +import { BusPreviewVideoPlugin } from "./image-viewer/bus-preview-video.js"; +import { CoverButtonPlugin } from "./image-viewer/cover-button.js"; +import { PreviewVideoPlugin } from "./image-viewer/preview-video.js"; +import { ScreenShotPlugin } from "./image-viewer/screenshot.js"; +import { NewVideoPlugin } from "./new-video/new-video.js"; +import { TaskPlugin } from "./new-video/task.js"; +import { UnifiedOfflinePlugin } from "./offline/unified-offline.js"; +import { OneOneFiveMatchPlugin } from "./one-one-five/plugins.js"; +import { OneTwoThreeOfflinePlugin } from "./one-two-three/offline.js"; +import { StatsPlugin } from "./stats/stats.js"; +import { AutoPagePlugin } from "./status/auto-page.js"; +import { BusDetailPagePlugin } from "./status/bus-detail-page.js"; +import { BusNavBarPlugin } from "./status/bus-nav-bar.js"; +import { CompatibilityEnhancementsPlugin } from "./status/compat-enhancements.js"; +import { DetailPageButtonPlugin } from "./status/detail-page-button.js"; +import { DetailPagePlugin } from "./status/detail-page.js"; +import { DetailWorkspacePlugin } from "./status/detail-workspace.js"; +import { FoldCategoryPlugin } from "./status/fold-category.js"; +import { HighlightMagnetPlugin } from "./status/highlight-magnet.js"; +import { HistoryPlugin } from "./status/history.js"; +import { ListPageButtonPlugin } from "./status/list-page-button.js"; +import { ListPagePlugin } from "./status/list-page.js"; +import { MobileBottomBarPlugin } from "./status/mobile-bottom-bar.js"; +import { NavBarPlugin } from "./status/nav-bar.js"; +import { WantAndWatchedVideosPlugin } from "./status/want-and-watched-videos.js"; +import { SubTitleCatPlugin } from "./subtitle/subtitle-cat.js"; +import { TranslatePlugin } from "./translate/translate.js"; + const DEFAULT_JAVDB_PLUGINS = [ ListPagePlugin, AutoPagePlugin, Fc2Plugin, FoldCategoryPlugin, ListPageButtonPlugin, HistoryPlugin, SettingPlugin, NavBarPlugin, HitShowPlugin, Top250Plugin, @@ -37,7 +83,8 @@ function registerPluginGroup(pluginManager, plugins) { plugins.forEach((pluginClass => pluginManager.register(pluginClass))); } -function registerSitePlugins(pluginManager, locationLike = window.location) { +export function registerSitePlugins(pluginManager, locationLike = window.location) { + pluginManager.setDependencyDeclarations(LEGACY_PLUGIN_DEPENDENCY_MAP); const context = detectSite(locationLike); DEFAULT_SHARED_PLUGIN_RULES.forEach((rule => { rule.shouldRegister(context) && registerPluginGroup(pluginManager, rule.plugins); diff --git a/src/plugins/stats/stats.js b/src/plugins/stats/stats.js index 2bb3cd3d..4d6d9140 100644 --- a/src/plugins/stats/stats.js +++ b/src/plugins/stats/stats.js @@ -1,4 +1,9 @@ -class StatsPlugin extends BasePlugin { +import { escapeHtml } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; +import { stateService } from "../../core/state-service.js"; + +export class StatsPlugin extends BasePlugin { getName() { return "StatsPlugin"; } async initCss() { return ` @@ -37,8 +42,8 @@ class StatsPlugin extends BasePlugin { current.count++, actressCounts.set(key, current); } else names.forEach((name => { const key = `name:${name}`, current = actressCounts.get(key) || { starId: "", name, count: 0 }; current.count++, actressCounts.set(key, current); })); })); - const topActresses = [ ...actressCounts.values() ].sort(((left, right) => right.count - left.count || left.name.localeCompare(right.name))).slice(0, 10), topValue = topActresses[0]?.count || 1, javDbUrl = await this.getBean("OtherSitePlugin").getJavDbUrl(); - const pending = counts.pending, counter = this.getBean("NewVideoPlugin"), newVideos = counter ? await counter.getPendingNewVideoTotal() : 0, pageSummary = this.getBean("ListPagePlugin").getCurrentPageSummary(); + const topActresses = [ ...actressCounts.values() ].sort(((left, right) => right.count - left.count || left.name.localeCompare(right.name))).slice(0, 10), topValue = topActresses[0]?.count || 1, javDbUrl = await this.getDependency("OtherSitePlugin").getJavDbUrl(); + const pending = counts.pending, counter = this.getDependency("NewVideoPlugin"), newVideos = counter ? await counter.getPendingNewVideoTotal() : 0, pageSummary = this.getDependency("ListPagePlugin").getCurrentPageSummary(); const metrics = [ { label: "总记录", value: total, action: null }, { label: "收藏", value: counts.favorite, action: null }, @@ -69,8 +74,8 @@ class StatsPlugin extends BasePlugin { $(layerElement).find("button.jhs-stats__metric[data-action]").on("click", (event => { const metric = $(event.currentTarget), action = metric.data("action"); layer.close(layerIndex); - if ("new-video" === action) return this.getBean("NewVideoPlugin").openDialog(); - if ("filter" === action) this.getBean("ListPagePlugin").setQuickFilter(metric.data("filter")); + if ("new-video" === action) return this.getDependency("NewVideoPlugin").openDialog(); + if ("filter" === action) this.getDependency("ListPagePlugin").setQuickFilter(metric.data("filter")); })); utils.setupEscClose(layerIndex); } }); diff --git a/src/plugins/status/auto-page.js b/src/plugins/status/auto-page.js index c48ec2ef..83d03f44 100644 --- a/src/plugins/status/auto-page.js +++ b/src/plugins/status/auto-page.js @@ -1,4 +1,7 @@ -class AutoPagePlugin extends BasePlugin { +import { C, _, i, l, o, r } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class AutoPagePlugin extends BasePlugin { constructor() { super(...arguments), i(this, "preloadDistance", 500), i(this, "currentPage", this.getInitialPageNumber()), i(this, "pageItems", []); @@ -69,7 +72,7 @@ class AutoPagePlugin extends BasePlugin { top: g, url: this.nextUrl }); - const p = this.getBean("ListPagePlugin"); + const p = this.getDependency("ListPagePlugin"); let m = s.find(this.getSelector().coverImgSelector); p.replaceHdImg(m), $(this.getSelector().boxSelector).append(c), this.nextUrl = null == (e = s.find(t.nextPageSelector)) ? void 0 : e.attr("href"), this.hasMore = !!this.nextUrl; diff --git a/src/plugins/status/bus-detail-page.js b/src/plugins/status/bus-detail-page.js index 88d2514a..f2b7a83e 100644 --- a/src/plugins/status/bus-detail-page.js +++ b/src/plugins/status/bus-detail-page.js @@ -1,4 +1,6 @@ -class BusDetailPagePlugin extends BasePlugin { +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class BusDetailPagePlugin extends BasePlugin { getName() { return "BusDetailPagePlugin"; } diff --git a/src/plugins/status/bus-nav-bar.js b/src/plugins/status/bus-nav-bar.js index 5e4bf628..88c930f3 100644 --- a/src/plugins/status/bus-nav-bar.js +++ b/src/plugins/status/bus-nav-bar.js @@ -1,11 +1,13 @@ -class BusNavBarPlugin extends BasePlugin { +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class BusNavBarPlugin extends BasePlugin { getName() { return "BusNavBarPlugin"; } handle() { $("#navbar > div > div > span").append('\n \n '), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); + this.getDependency("SearchByImagePlugin").open(); })); } } diff --git a/src/plugins/status/compat-enhancements.js b/src/plugins/status/compat-enhancements.js index 96c04630..82f85042 100644 --- a/src/plugins/status/compat-enhancements.js +++ b/src/plugins/status/compat-enhancements.js @@ -1,4 +1,8 @@ -class CompatibilityEnhancementsPlugin extends BasePlugin { +import { siteContext } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { stateService } from "../../core/state-service.js"; + +export class CompatibilityEnhancementsPlugin extends BasePlugin { getName() { return "CompatibilityEnhancementsPlugin"; } async initCss() { if (!siteContext.isJavDB) return ""; @@ -17,7 +21,7 @@ class CompatibilityEnhancementsPlugin extends BasePlugin { if (!(await storageManager.getCarList()).some((item => item.carNum === carNum))) return; const button = $(''); $(".jhs-detail-btn-row,.movie-info-container,.container .info").first().append(button); - button.on("click", (event => utils.q(event, `确定移除 ${carNum} 的鉴定记录?`, (async () => { await stateService.remove(carNum); button.remove(); this.getBean("ListPagePlugin")?.showCarNumBox?.(carNum); show.ok("鉴定记录已移除"); })))); + button.on("click", (event => utils.q(event, `确定移除 ${carNum} 的鉴定记录?`, (async () => { await stateService.remove(carNum); button.remove(); this.getDependency("ListPagePlugin")?.showCarNumBox?.(carNum); show.ok("鉴定记录已移除"); })))); } async decorateActresses() { const favorites = new Set((await storageManager.getFavoriteActressList()).map((item => String(item.starId)))); diff --git a/src/plugins/status/detail-page-button.js b/src/plugins/status/detail-page-button.js index 15765fb3..e824b6b2 100644 --- a/src/plugins/status/detail-page-button.js +++ b/src/plugins/status/detail-page-button.js @@ -1,4 +1,9 @@ -class DetailPageButtonPlugin extends BasePlugin { +import { C, _, k, l, m, normalizeCarNum, r, v, y } from "../../core/constants.js"; +import { detailStateController } from "../../core/detail-state-controller.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { stateService } from "../../core/state-service.js"; + +export class DetailPageButtonPlugin extends BasePlugin { getName() { return "DetailPageButtonPlugin"; } @@ -19,9 +24,9 @@ class DetailPageButtonPlugin extends BasePlugin { } async createMenuBtn() { const e = this.getPageInfo(), t = e.carNum, n = `\n
    \n
    \n \n \n \n \n
    \n \n
    \n \n \n \n \n
    \n
    \n `; - const workspaceSlot = this.getBean("DetailWorkspacePlugin")?.getSlot("summary-actions"); + const workspaceSlot = this.getDependency("DetailWorkspacePlugin")?.getSlot("summary-actions"); workspaceSlot?.length ? workspaceSlot.append(n) : r ? $(".tabs").after(n) : l && $("#mag-submit-show").before(n), $("#magnetSearchBtn").on("click", (async () => { - let t = await this.getBean("MagnetHubPlugin").createMagnetHub(e.carNum); + let t = await this.getDependency("MagnetHubPlugin").createMagnetHub(e.carNum); layer.open({ type: 1, title: "磁力搜索 " + e.carNum, @@ -33,7 +38,7 @@ class DetailPageButtonPlugin extends BasePlugin { } }); })); - const a = this.getBean("HighlightMagnetPlugin"), i = await storageManager.getSetting("enableMagnetsFilter", _); + const a = this.getDependency("HighlightMagnetPlugin"), i = await storageManager.getSetting("enableMagnetsFilter", _); $("#magnets-span").text(i === _ ? "关闭磁力过滤" : "开启磁力过滤"), i === _ && a.doFilterMagnet(), $("#enable-magnets-filter").on("click", (e => { let t = $("#magnets-span"); diff --git a/src/plugins/status/detail-page.js b/src/plugins/status/detail-page.js index 739b0f32..207ac16d 100644 --- a/src/plugins/status/detail-page.js +++ b/src/plugins/status/detail-page.js @@ -1,4 +1,6 @@ -class DetailPagePlugin extends BasePlugin { +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class DetailPagePlugin extends BasePlugin { getName() { return "DetailPagePlugin"; } diff --git a/src/plugins/status/detail-workspace.js b/src/plugins/status/detail-workspace.js index 86786a43..3eac4dbb 100644 --- a/src/plugins/status/detail-workspace.js +++ b/src/plugins/status/detail-workspace.js @@ -1,5 +1,10 @@ +import { l, normalizeCarNum, r } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { JhsSelect } from "../../core/ui-primitives.js"; + /** 返回当前详情页的宿主资源边界;调用者不得重挂载这些节点。 */ -function getDetailResourceAdapter() { +export function getDetailResourceAdapter() { if (!window.isDetailPage) return null; if (r) { const hostRoot = $(".video-detail").first(), controller = hostRoot.find('[data-controller="magnet-sort"]').first(), resourceRoot = controller.find("#magnets-content").first(); @@ -38,7 +43,7 @@ function getDetailResourceAdapter() { } /** 非破坏性详情工作区:仅标记宿主稳定块,并为 JHS 自有内容提供固定插槽。 */ -class DetailWorkspacePlugin extends BasePlugin { +export class DetailWorkspacePlugin extends BasePlugin { constructor() { super(), this.hostRoot = null, this.resourceObserver = null, this.scheduledResourceFrame = null; } @@ -163,7 +168,7 @@ class DetailWorkspacePlugin extends BasePlugin { } /** 创建 FC2 自有详情壳,所有异步模块只写入固定插槽。 */ -function createFc2DetailShell({ carNum = "", source = "fc2", mode = "dialog" } = {}) { +export function createFc2DetailShell({ carNum = "", source = "fc2", mode = "dialog" } = {}) { const workspace = $('
    ').attr({ "data-jhs-fc2-source": source, "data-jhs-fc2-mode": mode, @@ -179,7 +184,7 @@ function createFc2DetailShell({ carNum = "", source = "fc2", mode = "dialog" } = } /** 创建只属于单个 FC2 详情实例的生命周期和插槽上下文。 */ -function createFc2DetailContext(root, options = {}) { +export function createFc2DetailContext(root, options = {}) { const workspace = $(root).is(".jhs-fc2-workspace") ? $(root) : $(root).find(".jhs-fc2-workspace").first(); let destroyed = !1; const namespace = `.jhsFc2Detail${Date.now()}${Math.random().toString(36).slice(2)}`, observers = new Set(); diff --git a/src/plugins/status/fold-category.js b/src/plugins/status/fold-category.js index b153ac5d..7feffeb7 100644 --- a/src/plugins/status/fold-category.js +++ b/src/plugins/status/fold-category.js @@ -1,4 +1,7 @@ -class FoldCategoryPlugin extends BasePlugin { +import { C, _, o } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class FoldCategoryPlugin extends BasePlugin { getName() { return "FoldCategoryPlugin"; } diff --git a/src/plugins/status/highlight-magnet.js b/src/plugins/status/highlight-magnet.js index fbd036e5..d3a025a1 100644 --- a/src/plugins/status/highlight-magnet.js +++ b/src/plugins/status/highlight-magnet.js @@ -1,4 +1,9 @@ -class HighlightMagnetPlugin extends BasePlugin { +import { _, l, r } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { calcMagnetScore } from "../external-search/magnet-hub.js"; + +export class HighlightMagnetPlugin extends BasePlugin { async handle() { window.isDetailPage && jhsEventBus.on("magnet-items-updated", (() => { void storageManager.getSetting("enableMagnetsFilter", _).then((enabled => enabled === _ ? this.doFilterMagnet() : this.showAll())); diff --git a/src/plugins/status/history.js b/src/plugins/status/history.js index f56a7c33..2f1af888 100644 --- a/src/plugins/status/history.js +++ b/src/plugins/status/history.js @@ -1,4 +1,10 @@ -class HistoryPlugin extends BasePlugin { +import { b, d, g, h, i, k, l, m, normalizeCarNum, p, r, u, v, y } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { hasAnyState, legacyActionToFlag, normalizeStateFlags } from "../../core/state-model.js"; +import { stateService } from "../../core/state-service.js"; +import { JhsSelect } from "../../core/ui-primitives.js"; + +export class HistoryPlugin extends BasePlugin { constructor() { super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectAll", !1), i(this, "historyExcludedCarNums", new Set), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), @@ -86,9 +92,9 @@ class HistoryPlugin extends BasePlugin { await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); })).on("click", ".jhs-retry-offline", (async event => { const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)); - item && await this.getBean("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: !0, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); + item && await this.getDependency("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: !0, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); })).on("click", ".jhs-open-offline", (async event => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)), provider = this.getBean("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); + const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); url && window.open(url, "_blank", "noopener,noreferrer"); })).on("click", ".jhs-delete-offline", (async event => { await stateService.removeOfflineHistory($(event.currentTarget).data("id")), await this.renderOfflineHistory(); @@ -466,14 +472,14 @@ class HistoryPlugin extends BasePlugin { } handleDelete(e, t) { utils.q(e, `是否移除${t}?`, (async () => { - await stateService.remove(t), this.getBean("ListPagePlugin").showCarNumBox(t), + await stateService.remove(t), this.getDependency("ListPagePlugin").showCarNumBox(t), await this.reloadTable(); })); } async handleClickDetail(e, t) { if (r) if (t.carNum.includes("FC2-")) { const e = this.parseMovieId(t.url); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t); + const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t); plugin.openFc2Dialog(e, t.carNum, t.url, { source }); } else { if (!t.url) return void window.open("/search?q=" + t.carNum, "_blank"); @@ -483,7 +489,7 @@ class HistoryPlugin extends BasePlugin { let n = t.url; if (n.includes("javdb")) if (t.carNum.includes("FC2-")) { const e = this.parseMovieId(n); - const plugin = this.getBean("Fc2Plugin"), source = await plugin.resolveFc2Source(t); + const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t); await plugin.openFc2Page(e, t.carNum, n, { newTab: !0 }, { source }); } else window.open(n, "_blank"); else utils.openPage(t.url, t.carNum, !1, e); } diff --git a/src/plugins/status/list-page-button.js b/src/plugins/status/list-page-button.js index c432e8fa..d5fa9413 100644 --- a/src/plugins/status/list-page-button.js +++ b/src/plugins/status/list-page-button.js @@ -1,4 +1,10 @@ -class ListPageButtonPlugin extends BasePlugin { +import { _, c, g, h, l, o, r } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { isHitShowPage } from "../../core/site-context.js"; +import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; +import { isHardHidden } from "./list-page.js"; + +export class ListPageButtonPlugin extends BasePlugin { getName() { return "ListPageButtonPlugin"; } @@ -53,11 +59,11 @@ class ListPageButtonPlugin extends BasePlugin { $("#waitCheckBtn").on("click", (e => { void this.openWaitCheck(e).catch((error => clog.error("待鉴定列表打开失败", error))); })), $("#newVideoBtn").on("click", (e => { - this.getBean("NewVideoPlugin").openDialog(); + this.getDependency("NewVideoPlugin").openDialog(); })), $("#blacklistBtn").on("click", (e => { - this.getBean("BlacklistPlugin").openBlacklistDialog(); + this.getDependency("BlacklistPlugin").openBlacklistDialog(); })), this.bindSortMenu(); - const e = this.getBean("BlacklistPlugin"); + const e = this.getDependency("BlacklistPlugin"); $("#addBlacklistBtn").on("click", (async t => { await e.addBlacklist(t); })), $("#filterAllVideo").on("click", (async t => { @@ -157,7 +163,7 @@ class ListPageButtonPlugin extends BasePlugin { let e = this.getSelector(); const t = await storageManager.getSetting("waitCheckCount", 5); let a = 0; - const listPage = this.getBean("ListPagePlugin"); + const listPage = this.getDependency("ListPagePlugin"); for (const element of $(e.itemSelector).toArray()) { if (a >= t) break; const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index 2245292b..c60cc834 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -1,4 +1,12 @@ -const _e = async (e, t = "ja", n = "zh-CN") => { +import { B, C, _, b, escapeHtml, i, k, l, normalizeCarNum, o, r, u, y } from "../../core/constants.js"; +import { jhsEventBus } from "../../core/event-bus.js"; +import { mapLimit, safePlay } from "../../core/feature-helpers.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { isHitShowPage } from "../../core/site-context.js"; +import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; +import { stateService } from "../../core/state-service.js"; + +export const _e = async (e, t = "ja", n = "zh-CN") => { if (!e) throw new Error("翻译文本不能为空"); const a = "https://translate-pa.googleapis.com/v1/translate?" + new URLSearchParams({ "params.client": "gtx", @@ -77,7 +85,7 @@ const _e = async (e, t = "ja", n = "zh-CN") => { } }; -const QUICK_FILTER_LABELS = Object.freeze({ +export const QUICK_FILTER_LABELS = Object.freeze({ all: "全部", waitCheck: "待鉴定", favorite: "收藏", hasDown: "下载", hasWatch: "已看", blockedItems: "屏蔽项", favoriteUndownloaded: "收藏未下载", favoriteUnwatched: "收藏未观看", downloadedUnwatched: "下载未观看", recent7d: "最近 7 天" @@ -86,13 +94,13 @@ SECONDARY_QUICK_FILTERS = Object.freeze([ "blockedItems", "favoriteUndownloaded" VALID_QUICK_FILTERS = new Set([ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ]); /** 将旧筛选键收敛为当前唯一业务键。 */ -function normalizeQuickFilterKey(value) { +export function normalizeQuickFilterKey(value) { if ("filter" === value) return "blockedItems"; return VALID_QUICK_FILTERS.has(value) ? value : "waitCheck"; } /** 判断列表卡片是否因状态或规则被硬屏蔽。 */ -function isHardHidden(flags, visibilityReasons = {}) { +export function isHardHidden(flags, visibilityReasons = {}) { return Boolean(flags.blocked || visibilityReasons.keyword || visibilityReasons.actorBlacklist || visibilityReasons.actressBlacklist); } @@ -115,7 +123,7 @@ function shouldShowItem({ filter, flags, visibilityReasons, recent }) { return matchesQuickFilter(filter, flags, { visibilityReasons, recent }); } -class ListPagePlugin extends BasePlugin { +export class ListPagePlugin extends BasePlugin { async initCss() { return ``; } @@ -135,7 +143,7 @@ class ListPagePlugin extends BasePlugin { if (!window.isListPage || isHitShowPage()) return; const refreshAll = async () => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); - const e = this.getBean("HistoryPlugin"); + const e = this.getDependency("HistoryPlugin"); e.tableObj && e.tableObj.setData(); }; jhsEventBus.on("legacy-refresh", refreshAll), jhsEventBus.on("blacklist-rules-changed", refreshAll), jhsEventBus.on("filter-rules-changed", refreshAll), jhsEventBus.on("settings-changed", refreshAll), @@ -143,7 +151,7 @@ class ListPagePlugin extends BasePlugin { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); const items = this.getIndexedItems(payload.carNums || []); items.length && (await this.doFilterItems(items), this.applyVisibility(items)); - const history = this.getBean("HistoryPlugin"); + const history = this.getDependency("HistoryPlugin"); history.tableObj && history.tableObj.setData(); })), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), @@ -247,8 +255,8 @@ class ListPagePlugin extends BasePlugin { async processAddedItems(items) { const selector = this.getSelector(), covers = items.flatMap((item => [ ...item.querySelectorAll(selector.coverImgSelector) ])); this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), - await this.getBean("ListPageButtonPlugin").sortItems(), await this.getBean("CoverButtonPlugin").addSvgBtn(items), - items.forEach((item => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: !1 }), this.getBean("AutoPagePlugin").checkLoad(); + await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), + items.forEach((item => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: !1 }), this.getDependency("AutoPagePlugin").checkLoad(); } rebuildItemIndex() { this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); @@ -312,7 +320,7 @@ class ListPagePlugin extends BasePlugin { if (!window.isListPage) return; let e = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); e.length && (await this.filterMovieList(e), l && setTimeout((() => { - this.getBean("BusImgPlugin").logImageHeightsByRow().catch((e => clog.error("JavBus图片高度修正失败", e))); + this.getDependency("BusImgPlugin").logImageHeightsByRow().catch((e => clog.error("JavBus图片高度修正失败", e))); }))); } async yieldListFrame() { @@ -443,7 +451,7 @@ class ListPagePlugin extends BasePlugin { const shouldOpenTab = newTab || !!event && (event.ctrlKey || event.metaKey || 1 === event.button); if (carNum.includes("FC2-")) { const movieId = this.parseMovieId(aHref); - return shouldOpenTab ? this.getBean("Fc2Plugin").openFc2Page(movieId, carNum, aHref, { event, newTab: !0 }, { source: fc2Source }) : this.getBean("Fc2Plugin").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source }); + return shouldOpenTab ? this.getDependency("Fc2Plugin").openFc2Page(movieId, carNum, aHref, { event, newTab: !0 }, { source: fc2Source }) : this.getDependency("Fc2Plugin").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source }); } const destination = new URL(aHref, window.location.origin); autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, !0, { event, newTab: shouldOpenTab }), this.$currentImage = null; diff --git a/src/plugins/status/mobile-bottom-bar.js b/src/plugins/status/mobile-bottom-bar.js index a077977d..a28f2283 100644 --- a/src/plugins/status/mobile-bottom-bar.js +++ b/src/plugins/status/mobile-bottom-bar.js @@ -1,4 +1,9 @@ -class MobileBottomBarPlugin extends BasePlugin { +import { k, m, r, v, y } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { normalizeStateFlags } from "../../core/state-model.js"; +import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, normalizeQuickFilterKey } from "./list-page.js"; + +export class MobileBottomBarPlugin extends BasePlugin { constructor() { super(...arguments); this._fabGeneration = 0; @@ -258,12 +263,12 @@ class MobileBottomBarPlugin extends BasePlugin { $(document).off("click.jhsCommandbar").on("click.jhsCommandbar", (event => { $(event.target).closest(".jhs-commandbar__more, .jhs-commandbar__batch").length || (commandbar.find(".jhs-commandbar__menu").removeClass("is-open"), commandbar.find(".jhs-commandbar__menu-toggle").attr("aria-expanded", "false")); })); - this.getBean("ListPagePlugin")?.syncQuickFilterUi(); + this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); } /** 获取详情页番号 */ getCarNum() { try { - const basePlugin = this.getBean("DetailPageButtonPlugin"); + const basePlugin = this.getDependency("DetailPageButtonPlugin"); if (basePlugin?.parseMovieId) return basePlugin.parseMovieId(location.href); const el = document.querySelector(".header, #video_id, .video-id"); if (el) return el.textContent.trim(); @@ -274,7 +279,7 @@ class MobileBottomBarPlugin extends BasePlugin { const item = (action, label, attributes = "") => ``, group = content => `
    ${content}
    `, divider = ''; let items; if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getBean("ListPagePlugin")?.activeQuickFilter), + const sortMethod = localStorage.getItem("jhs_sortMethod") || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; @@ -334,7 +339,7 @@ class MobileBottomBarPlugin extends BasePlugin { const sortMethod = localStorage.getItem("jhs_sortMethod") || "default"; const sortLabel = { default: "默认", rateCount: "评价人数", date: "时间" }[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); - this.getBean("ListPagePlugin")?.syncQuickFilterUi(); + this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); } // 刷新详情页状态 if (window.isDetailPage) void this.refreshDetailStatus().catch((error => clog.warn("移动端详情状态刷新失败", error))); @@ -371,7 +376,7 @@ class MobileBottomBarPlugin extends BasePlugin { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })).on("click", ".jhs-mobile-filter-option", (event => { - event.stopPropagation(), this.getBean("ListPagePlugin").setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(!0); + event.stopPropagation(), this.getDependency("ListPagePlugin").setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(!0); })); sortMenu.on("keydown", ".jhs-mobile-sort-option", (event => { const items = sortMenu.find(".jhs-mobile-sort-option"), index = items.index(event.currentTarget); @@ -383,7 +388,7 @@ class MobileBottomBarPlugin extends BasePlugin { })).on("click", ".jhs-mobile-sort-option", (event => { event.stopPropagation(); const value = $(event.currentTarget).data("jhs-sort"); - localStorage.setItem("jhs_sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), void this.getBean("ListPageButtonPlugin")?.sortItems?.(), closeMenu(!0); + localStorage.setItem("jhs_sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), void this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(!0); })); // 菜单项点击 menu.on("click", ".jhs-fab-menu-item", (e) => { @@ -406,13 +411,13 @@ class MobileBottomBarPlugin extends BasePlugin { switch (action) { // 列表页操作 case "check": - await this.getBean("ListPageButtonPlugin")?.openWaitCheck?.(); + await this.getDependency("ListPageButtonPlugin")?.openWaitCheck?.(); break; case "newVideo": - this.getBean("NewVideoPlugin")?.openDialog(); + this.getDependency("NewVideoPlugin")?.openDialog(); break; case "blacklist": - this.getBean("BlacklistPlugin")?.openBlacklistDialog(); + this.getDependency("BlacklistPlugin")?.openBlacklistDialog(); break; case "sort": break; @@ -440,7 +445,7 @@ class MobileBottomBarPlugin extends BasePlugin { break; // 通用 case "setting": - await this.getBean("SettingPlugin")?.openQuickSetting(); + await this.getDependency("SettingPlugin")?.openQuickSetting(); break; case "logger": clog.openDialog?.(); diff --git a/src/plugins/status/nav-bar.js b/src/plugins/status/nav-bar.js index bfc3b0ff..99239506 100644 --- a/src/plugins/status/nav-bar.js +++ b/src/plugins/status/nav-bar.js @@ -1,4 +1,7 @@ -class NavBarPlugin extends BasePlugin { +import { BasePlugin } from "../../core/plugin-manager.js"; +import { JhsSelect } from "../../core/ui-primitives.js"; + +export class NavBarPlugin extends BasePlugin { getName() { return "NavBarPlugin"; } @@ -29,7 +32,7 @@ class NavBarPlugin extends BasePlugin { for (let n = 0; n < t.length; n++) if (-1 !== t[n].type.indexOf("image")) { const e = t[n].getAsFile(); $("#search-keyword").blur(); - const a = this.getBean("SearchByImagePlugin"); + const a = this.getDependency("SearchByImagePlugin"); return void a.open((() => { a.handleImageFile(e), a.resetSearchUI(); })); @@ -42,7 +45,7 @@ class NavBarPlugin extends BasePlugin { let t = $("#search-keyword").val(), n = $("#search-type").val(); "" !== t && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t + "&f=" + n : window.open("/search?q=" + t + "&f=" + n)); })), $("#search-img-btn").on("click", (() => { - this.getBean("SearchByImagePlugin").open(); + this.getDependency("SearchByImagePlugin").open(); })); } hookOldSearch() { @@ -51,7 +54,7 @@ class NavBarPlugin extends BasePlugin { const t = e.cloneNode(!0); e.parentNode.replaceChild(t, e), $("#button-search-image").attr("data-tooltip", "以图识图"), $(".search-image").on("click", (e => { - this.getBean("SearchByImagePlugin").open(); + this.getDependency("SearchByImagePlugin").open(); })); } margeNav() { diff --git a/src/plugins/status/want-and-watched-videos.js b/src/plugins/status/want-and-watched-videos.js index 036b4309..51de9fff 100644 --- a/src/plugins/status/want-and-watched-videos.js +++ b/src/plugins/status/want-and-watched-videos.js @@ -1,4 +1,8 @@ -class WantAndWatchedVideosPlugin extends BasePlugin { +import { i } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { stateService } from "../../core/state-service.js"; + +export class WantAndWatchedVideosPlugin extends BasePlugin { constructor() { super(...arguments), i(this, "flag", null); } diff --git a/src/plugins/subtitle/subtitle-cat.js b/src/plugins/subtitle/subtitle-cat.js index 97d80f03..569ed180 100644 --- a/src/plugins/subtitle/subtitle-cat.js +++ b/src/plugins/subtitle/subtitle-cat.js @@ -1,4 +1,6 @@ -class SubTitleCatPlugin extends BasePlugin { +import { BasePlugin } from "../../core/plugin-manager.js"; + +export class SubTitleCatPlugin extends BasePlugin { getName() { return "SubTitleCatPlugin"; } diff --git a/src/plugins/translate/translate.js b/src/plugins/translate/translate.js index 90ce477d..29fd6761 100644 --- a/src/plugins/translate/translate.js +++ b/src/plugins/translate/translate.js @@ -1,4 +1,8 @@ -class TranslatePlugin extends BasePlugin { +import { _, l } from "../../core/constants.js"; +import { BasePlugin } from "../../core/plugin-manager.js"; +import { _e } from "../status/list-page.js"; + +export class TranslatePlugin extends BasePlugin { getName() { return "TranslatePlugin"; } diff --git a/src/services/cache-service.js b/src/services/cache-service.js new file mode 100644 index 00000000..831c59e8 --- /dev/null +++ b/src/services/cache-service.js @@ -0,0 +1,50 @@ +// @ts-check + +export class CacheService { + /** @param {{namespaceVersion?: number, diagnostics?: import("./diagnostics-service.js").DiagnosticsService}} [options] */ + constructor(options = {}) { + this.namespaceVersion = options.namespaceVersion ?? 1; + this.diagnostics = options.diagnostics ?? null; + /** @type {Map} */ + this.publicCache = new Map(); + /** @type {Map>} */ + this.sessionCaches = new Map(); + } + + /** @param {string} scope @param {string} sessionScopeId */ + storeFor(scope, sessionScopeId = "") { + if (scope === "public") return this.publicCache; + if (scope === "session") { + if (!sessionScopeId) throw new TypeError("sessionScopeId is required for session cache"); + let cache = this.sessionCaches.get(sessionScopeId); + if (!cache) this.sessionCaches.set(sessionScopeId, cache = new Map()); + return cache; + } + return null; + } + + /** @param {string} key @param {{scope: string, sessionScopeId?: string}} policy */ + get(key, policy) { + const store = this.storeFor(policy.scope, policy.sessionScopeId); + const entry = store?.get(`${this.namespaceVersion}:${key}`); + if (!entry || entry.expiresAt <= Date.now()) { + if (entry) store?.delete(`${this.namespaceVersion}:${key}`); + this.diagnostics?.recordCache(false); + return { hit: false, value: undefined, negative: false }; + } + this.diagnostics?.recordCache(true); + return { hit: true, value: entry.value, negative: entry.negative }; + } + + /** @param {string} key @param {unknown} value @param {{scope: string, sessionScopeId?: string, ttlMs: number, negative?: boolean}} policy */ + set(key, value, policy) { + const store = this.storeFor(policy.scope, policy.sessionScopeId); + if (!store) return; + store.set(`${this.namespaceVersion}:${key}`, { value, expiresAt: Date.now() + Math.max(0, policy.ttlMs), negative: policy.negative ?? false }); + } + + invalidateNamespace() { + this.publicCache.clear(); + this.sessionCaches.clear(); + } +} diff --git a/src/services/diagnostics-service.js b/src/services/diagnostics-service.js new file mode 100644 index 00000000..8ff9b301 --- /dev/null +++ b/src/services/diagnostics-service.js @@ -0,0 +1,54 @@ +// @ts-check + +export class DiagnosticsService { + constructor() { + this.startedAt = performance.now(); + this.activeFeatures = new Set(); + this.activeContributions = new Set(); + this.startupTimings = new Map(); + this.scopes = new Map(); + this.requestStats = { consumers: 0, underlying: 0 }; + this.cacheStats = { hits: 0, misses: 0 }; + this.providerHealth = new Map(); + /** @type {Array>} */ + this.errors = []; + this.browserMetadata = null; + } + + /** @param {string} id @param {number} durationMs */ + recordStartup(id, durationMs) { this.startupTimings.set(id, durationMs); } + /** @param {string} id @param {boolean} active */ + setFeature(id, active) { active ? this.activeFeatures.add(id) : this.activeFeatures.delete(id); } + /** @param {string} id @param {boolean} active */ + setContribution(id, active) { active ? this.activeContributions.add(id) : this.activeContributions.delete(id); } + /** @param {{id: string}} snapshot */ + updateScope(snapshot) { this.scopes.set(snapshot.id, Object.freeze({ ...snapshot })); } + /** @param {number} consumers @param {number} underlying */ + updateRequests(consumers, underlying) { this.requestStats = { consumers, underlying }; } + /** @param {boolean} hit */ + recordCache(hit) { hit ? this.cacheStats.hits += 1 : this.cacheStats.misses += 1; } + /** @param {string} id @param {Record} health */ + updateProvider(id, health) { this.providerHealth.set(id, Object.freeze({ ...health })); } + /** @param {unknown} error */ + recordError(error) { + const value = error && typeof error === "object" && "toJSON" in error && typeof error.toJSON === "function" + ? error.toJSON() + : { message: error instanceof Error ? error.message : String(error) }; + this.errors.push({ ...value, timestamp: new Date().toISOString() }); + if (this.errors.length > 100) this.errors.shift(); + } + /** @param {Record} metadata */ + setBrowserMetadata(metadata) { this.browserMetadata = Object.freeze({ ...metadata }); } + + exportSnapshot() { + return Object.freeze({ + activeFeatures: [...this.activeFeatures], activeContributions: [...this.activeContributions], + startupTimings: Object.fromEntries(this.startupTimings), activeScopes: [...this.scopes.values()], + globalListeners: 0, observers: [...this.scopes.values()].reduce((sum, scope) => sum + Number(scope.observers ?? 0), 0), + requestConsumers: this.requestStats.consumers, underlyingRequests: this.requestStats.underlying, + cacheHits: this.cacheStats.hits, cacheMisses: this.cacheStats.misses, + providerHealth: Object.fromEntries(this.providerHealth), errors: this.errors.map((error) => ({ ...error })), + browser: this.browserMetadata, uptimeMs: performance.now() - this.startedAt, + }); + } +} diff --git a/src/services/dialog-service.js b/src/services/dialog-service.js new file mode 100644 index 00000000..8e385775 --- /dev/null +++ b/src/services/dialog-service.js @@ -0,0 +1,12 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class DialogService { + /** @param {any} dialogPort */ + constructor(dialogPort) { this.port = assertPort(dialogPort, "DialogPort", PORT_METHODS.dialog); } + /** @param {Record} options */ + open(options) { return this.port.open(options); } + /** @param {number} id */ + close(id) { return this.port.close(id); } +} diff --git a/src/services/external-url-policy.js b/src/services/external-url-policy.js new file mode 100644 index 00000000..aa3bee11 --- /dev/null +++ b/src/services/external-url-policy.js @@ -0,0 +1,48 @@ +// @ts-check + +import { JhsError } from "../core/jhs-error.js"; + +const PRIVATE_IPV4 = [ + /^10\./, /^127\./, /^169\.254\./, /^192\.168\./, + /^172\.(?:1[6-9]|2\d|3[01])\./, /^0\./, +]; + +/** @param {string} hostname */ +function isPrivateLiteral(hostname) { + const value = hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (value === "localhost" || value.endsWith(".localhost") || value === "::1") return true; + if (value.includes(":")) return value.startsWith("fc") || value.startsWith("fd") || value.startsWith("fe8") || value.startsWith("fe9") || value.startsWith("fea") || value.startsWith("feb"); + return PRIVATE_IPV4.some((pattern) => pattern.test(value)); +} + +export class ExternalUrlPolicy { + /** @param {{localOrigins?: string[]}} [options] */ + constructor(options = {}) { + this.localOrigins = new Set((options.localOrigins ?? []).map((origin) => new URL(origin).origin)); + } + + /** @param {string} origin */ + authorizeLocalOrigin(origin) { this.localOrigins.add(new URL(origin).origin); } + + /** @param {string | URL} input @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ + assertAllowed(input, policy) { + let url; + try { url = input instanceof URL ? input : new URL(input); } + catch (cause) { throw new JhsError("INVALID_URL", "外部地址无效", { source: "ExternalUrlPolicy", cause }); } + if (!new Set(["http:", "https:"]).has(url.protocol)) throw new JhsError("INVALID_URL", "仅允许 HTTP/HTTPS 地址", { source: "ExternalUrlPolicy" }); + if (policy.trustClass === "builtin-public") { + if (url.protocol !== "https:" || !(policy.hosts ?? []).some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`))) { + throw new JhsError("INVALID_URL", "地址不在 Integration manifest 允许范围", { source: "ExternalUrlPolicy" }); + } + } else if (policy.trustClass === "custom-public") { + if (url.protocol !== "https:" || isPrivateLiteral(url.hostname)) throw new JhsError("INVALID_URL", "自定义公开来源必须使用公网 HTTPS", { source: "ExternalUrlPolicy" }); + } else if (policy.trustClass === "user-local") { + const expectedOrigin = policy.expectedOrigin ? new URL(policy.expectedOrigin).origin : url.origin; + if (!this.localOrigins.has(expectedOrigin) || url.origin !== expectedOrigin) throw new JhsError("INVALID_URL", "本地来源未获得精确 origin 授权", { source: "ExternalUrlPolicy" }); + } else throw new JhsError("INVALID_URL", "未知 URL trust class", { source: "ExternalUrlPolicy" }); + return url; + } + + /** @param {string | URL} finalUrl @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ + assertFinalUrl(finalUrl, policy) { return this.assertAllowed(finalUrl, policy); } +} diff --git a/src/services/http-service.js b/src/services/http-service.js new file mode 100644 index 00000000..dcb2b48d --- /dev/null +++ b/src/services/http-service.js @@ -0,0 +1,140 @@ +// @ts-check + +import { JhsError } from "../core/jhs-error.js"; + +/** @param {unknown} value @returns {string} */ +function stableSerialize(value) { + if (value == null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`; + const object = /** @type {Record} */ (value); + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(object[key])}`).join(",")}}`; +} + +/** @param {string} value */ +async function sha256(value) { + const bytes = new TextEncoder().encode(value); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((item) => item.toString(16).padStart(2, "0")).join(""); +} + +/** @param {string | URL} input */ +export function canonicalizeUrl(input) { + const url = new URL(input); + const entries = [...url.searchParams.entries()].sort(([leftKey, leftValue], [rightKey, rightValue]) => leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue)); + url.search = ""; + for (const [key, value] of entries) url.searchParams.append(key, value); + url.hash = ""; + return url.href; +} + +/** @param {Record} options */ +export async function createRequestKey(options) { + const method = String(options.method ?? "GET").toUpperCase(); + const headers = Object.fromEntries(Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)])); + const varyHeaders = [...new Set((options.varyHeaders ?? []).map((/** @type {unknown} */ header) => String(header).toLowerCase()))].sort(); + const varyValues = Object.fromEntries(varyHeaders.map((header) => [header, headers[header] ?? ""])); + const key = { + providerId: String(options.providerId), method, canonicalUrl: canonicalizeUrl(options.url), + bodyHash: await sha256(stableSerialize(options.body ?? null)), responseType: String(options.responseType ?? "text"), + cacheScope: String(options.cacheScope ?? "none"), varyHeadersHash: await sha256(stableSerialize(varyValues)), + sessionScopeId: options.cacheScope === "session" ? String(options.sessionScopeId ?? "") : "", + }; + if (key.cacheScope === "session" && !key.sessionScopeId) throw new TypeError("sessionScopeId is required for session requests"); + return Object.freeze(key); +} + +export class HttpService { + /** @param {{request: (options: any) => Promise}} port @param {import("./external-url-policy.js").ExternalUrlPolicy} urlPolicy @param {{diagnostics?: import("./diagnostics-service.js").DiagnosticsService, cache?: import("./cache-service.js").CacheService}} [options] */ + constructor(port, urlPolicy, options = {}) { + this.port = port; + this.urlPolicy = urlPolicy; + this.diagnostics = options.diagnostics ?? null; + this.cache = options.cache ?? null; + /** @type {Map, controller: AbortController, consumers: number}>} */ + this.inflight = new Map(); + } + + /** @param {Record} options @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ + async request(options, scope) { + const method = String(options.method ?? "GET").toUpperCase(); + const cacheScope = options.cacheScope ?? "none"; + if (method !== "GET" && cacheScope !== "none") throw new TypeError("Mutation requests cannot use generic cache/dedupe"); + const urlPolicy = /** @type {{trustClass: string, hosts?: string[], expectedOrigin?: string}} */ (options.urlPolicy); + const initialUrl = this.urlPolicy.assertAllowed(options.url, urlPolicy); + const requestKey = await createRequestKey({ ...options, method, url: initialUrl.href, cacheScope }); + const serializedKey = stableSerialize(requestKey); + const cachePolicy = { scope: cacheScope, sessionScopeId: options.sessionScopeId }; + if (method === "GET" && cacheScope !== "none" && this.cache) { + const cached = this.cache.get(serializedKey, cachePolicy); + if (cached.hit) return cached.value; + } + if (method !== "GET" || cacheScope === "none") return this.executeUnderlying({ ...options, method, url: initialUrl.href }, urlPolicy); + + let entry = this.inflight.get(serializedKey); + if (!entry) { + const controller = new AbortController(); + entry = { controller, consumers: 0, promise: Promise.resolve() }; + entry.promise = this.executeUnderlying({ ...options, method, url: initialUrl.href, signal: controller.signal }, urlPolicy) + .then((response) => { + this.cache?.set(serializedKey, response, { ...cachePolicy, ttlMs: options.ttlMs ?? 0, negative: options.negative === true }); + return response; + }) + .finally(() => { + this.inflight.delete(serializedKey); + this.updateDiagnostics(); + }); + this.inflight.set(serializedKey, entry); + } + return this.consume(entry, scope); + } + + /** @param {Record} options @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} urlPolicy */ + async executeUnderlying(options, urlPolicy) { + try { + const response = await this.port.request(options); + this.urlPolicy.assertFinalUrl(response.finalUrl || options.url, urlPolicy); + if (response.status >= 400) throw new JhsError(response.status === 404 ? "NOT_FOUND" : "NETWORK_ERROR", `HTTP ${response.status}`, { source: options.providerId, retryable: response.status >= 500 }); + return response; + } catch (error) { + const normalized = JhsError.from(error, options.providerId); + this.diagnostics?.recordError(normalized); + throw normalized; + } + } + + /** @param {{promise: Promise, controller: AbortController, consumers: number}} entry @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ + consume(entry, scope) { + entry.consumers += 1; + let released = false; + const consumer = { release: () => { + if (released) return; + released = true; + entry.consumers -= 1; + if (entry.consumers === 0 && !this.isSettled(entry)) entry.controller.abort(); + this.updateDiagnostics(); + } }; + const removeFromScope = scope?.ownRequestConsumer(consumer); + this.updateDiagnostics(); + let removeAbortListener = () => {}; + const result = scope ? Promise.race([ + entry.promise, + new Promise((_, reject) => { + const onAbort = () => reject(new JhsError("ABORTED", "LifecycleScope 已释放", { source: scope.id })); + scope.signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => scope.signal.removeEventListener("abort", onAbort); + }), + ]) : entry.promise; + return result.finally(() => { + removeAbortListener(); + removeFromScope?.(); + consumer.release(); + }); + } + + /** @param {{promise: Promise}} entry */ + isSettled(entry) { return ![...this.inflight.values()].includes(/** @type {any} */ (entry)); } + updateDiagnostics() { + const consumers = [...this.inflight.values()].reduce((sum, entry) => sum + entry.consumers, 0); + this.diagnostics?.updateRequests(consumers, this.inflight.size); + } +} diff --git a/src/services/magnet-service.js b/src/services/magnet-service.js new file mode 100644 index 00000000..2c2406e0 --- /dev/null +++ b/src/services/magnet-service.js @@ -0,0 +1,18 @@ +// @ts-check + +export class MagnetService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ + constructor(providers) { this.providers = providers; } + /** @param {Record} movieRef @param {Record} [context] */ + async search(movieRef, context = {}) { + const results = []; + for (const provider of await this.providers.getAvailable("magnet", context)) { + try { + const items = await provider.search(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (Array.isArray(items)) results.push(...items); + } catch (error) { this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } + } + return results; + } +} diff --git a/src/services/movie-identity-service.js b/src/services/movie-identity-service.js new file mode 100644 index 00000000..89c7dd5e --- /dev/null +++ b/src/services/movie-identity-service.js @@ -0,0 +1,16 @@ +// @ts-check + +import { normalizeMovieCarNum } from "../core/movie-identity.js"; + +export class MovieIdentityService { + /** @param {unknown} value */ + normalize(value) { return normalizeMovieCarNum(value); } + /** @param {...unknown} candidates */ + firstValid(...candidates) { + for (const candidate of candidates) { + const carNum = this.normalize(candidate); + if (carNum) return carNum; + } + return null; + } +} diff --git a/src/services/navigation-service.js b/src/services/navigation-service.js new file mode 100644 index 00000000..a4e999b5 --- /dev/null +++ b/src/services/navigation-service.js @@ -0,0 +1,14 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class NavigationService { + /** @param {any} navigationPort */ + constructor(navigationPort) { this.port = assertPort(navigationPort, "NavigationPort", PORT_METHODS.navigation); } + /** @param {string | URL} url @param {{newTab?: boolean}} [options] */ + open(url, options) { return this.port.open(String(url), options); } + /** @param {string | URL} url */ + assign(url) { return this.port.assign(String(url)); } + /** @param {string | URL} url */ + replace(url) { return this.port.replace(String(url)); } +} diff --git a/src/services/offline-service.js b/src/services/offline-service.js new file mode 100644 index 00000000..945848d6 --- /dev/null +++ b/src/services/offline-service.js @@ -0,0 +1,22 @@ +// @ts-check + +export class OfflineService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ + constructor(providers) { this.providers = providers; } + /** @param {Record} resource @param {Record} [context] */ + async submit(resource, context = {}) { + const providers = await this.providers.getAvailable("offline", context); + const failures = []; + for (const provider of providers) { + try { + const result = await provider.submit(resource, context); + this.providers.updateHealth(provider.id, { ok: true }); + return result; + } catch (error) { + failures.push(error); + this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + throw new AggregateError(failures, providers.length ? "所有离线 Provider 均失败" : "没有可用的离线 Provider"); + } +} diff --git a/src/services/profile-service.js b/src/services/profile-service.js new file mode 100644 index 00000000..9b3884f6 --- /dev/null +++ b/src/services/profile-service.js @@ -0,0 +1,38 @@ +// @ts-check + +export class ProfileService extends EventTarget { + /** @param {{windowRuntime?: Window, scope: import("../core/lifecycle-scope.js").LifecycleScope}} options */ + constructor(options) { + super(); + this.window = options.windowRuntime ?? window; + this.scope = options.scope; + this.coarseQuery = this.window.matchMedia("(any-pointer: coarse)"); + this.profile = this.calculate(); + this.started = false; + } + + calculate() { + const width = this.window.innerWidth; + const shortSide = Math.min(this.window.innerWidth, this.window.innerHeight); + if (width <= 767 || this.coarseQuery.matches && shortSide <= 480) return "compact"; + if (width >= 1200) return "wide"; + return "regular"; + } + + start() { + if (this.started) return; + this.started = true; + const update = () => { + const next = this.calculate(); + if (next === this.profile) return; + const previous = this.profile; + this.profile = next; + this.dispatchEvent(new CustomEvent("profile.changed", { detail: Object.freeze({ previous, profile: next }) })); + }; + this.scope.listen(this.window, "resize", update, { passive: true }); + this.scope.listen(this.window, "orientationchange", update, { passive: true }); + this.scope.listen(this.coarseQuery, "change", update); + } + + current() { return this.profile; } +} diff --git a/src/services/related-service.js b/src/services/related-service.js new file mode 100644 index 00000000..627cc764 --- /dev/null +++ b/src/services/related-service.js @@ -0,0 +1,14 @@ +// @ts-check + +export class RelatedService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + async list(movieRef, options = {}) { + for (const manifest of this.integrations.list("movie.related")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.listRelated === "function") return adapter.listRelated(movieRef, options); + } + return []; + } +} diff --git a/src/services/review-service.js b/src/services/review-service.js new file mode 100644 index 00000000..d5167986 --- /dev/null +++ b/src/services/review-service.js @@ -0,0 +1,14 @@ +// @ts-check + +export class ReviewService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + async list(movieRef, options = {}) { + for (const manifest of this.integrations.list("movie.reviews")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.listReviews === "function") return adapter.listReviews(movieRef, options); + } + return []; + } +} diff --git a/src/services/screenshot-service.js b/src/services/screenshot-service.js new file mode 100644 index 00000000..82ff33ab --- /dev/null +++ b/src/services/screenshot-service.js @@ -0,0 +1,17 @@ +// @ts-check + +export class ScreenshotService { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ + constructor(providers) { this.providers = providers; } + /** @param {Record} movieRef @param {Record} [context] */ + async resolve(movieRef, context = {}) { + for (const provider of await this.providers.getAvailable("screenshot", context)) { + try { + const result = await provider.resolve(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (result) return result; + } catch (error) { this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } + } + return null; + } +} diff --git a/src/services/settings-service.js b/src/services/settings-service.js new file mode 100644 index 00000000..d613fcc7 --- /dev/null +++ b/src/services/settings-service.js @@ -0,0 +1,31 @@ +// @ts-check + +export class SettingsService extends EventTarget { + /** @param {{get: (key: string) => Promise, set: (key: string, value: unknown) => Promise}} storage @param {{validators?: Record boolean>}} [options] */ + constructor(storage, options = {}) { + super(); + this.storage = storage; + this.validators = options.validators ?? {}; + this.snapshotValue = Object.freeze({}); + } + + /** @param {string} key */ + async load(key = "setting") { + const stored = await this.storage.get(key); + this.snapshotValue = Object.freeze(stored && typeof stored === "object" && !Array.isArray(stored) ? { ...stored } : {}); + return this.snapshotValue; + } + + snapshot() { return this.snapshotValue; } + + /** @param {string} name @param {unknown} value @param {string} [storageKey] */ + async set(name, value, storageKey = "setting") { + const validator = this.validators[name]; + if (validator && !validator(value)) throw new TypeError(`Invalid setting: ${name}`); + const next = Object.freeze({ ...this.snapshotValue, [name]: value }); + await this.storage.set(storageKey, next); + this.snapshotValue = next; + this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name, value, snapshot: next }) })); + return next; + } +} diff --git a/src/services/storage-service.js b/src/services/storage-service.js new file mode 100644 index 00000000..a463971b --- /dev/null +++ b/src/services/storage-service.js @@ -0,0 +1,14 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class StorageService { + /** @param {any} storagePort */ + constructor(storagePort) { this.port = assertPort(storagePort, "StoragePort", PORT_METHODS.storage); } + /** @param {string} key */ + get(key) { return this.port.get(key); } + /** @param {string} key @param {unknown} value */ + set(key, value) { return this.port.set(key, value); } + /** @param {string} key */ + remove(key) { return this.port.remove(key); } +} diff --git a/src/services/style-registry.js b/src/services/style-registry.js new file mode 100644 index 00000000..bb2848da --- /dev/null +++ b/src/services/style-registry.js @@ -0,0 +1,12 @@ +// @ts-check + +import { assertPort, PORT_METHODS } from "../contracts/ports.js"; + +export class StyleRegistry { + /** @param {any} stylePort */ + constructor(stylePort) { this.port = assertPort(stylePort, "StylePort", PORT_METHODS.style); } + /** @param {string} id @param {string} css */ + register(id, css) { return this.port.register(id, css); } + /** @param {string} id */ + remove(id) { return this.port.remove(id); } +} diff --git a/src/ui/detail/hosted-detail-surface.js b/src/ui/detail/hosted-detail-surface.js new file mode 100644 index 00000000..8b72f6af --- /dev/null +++ b/src/ui/detail/hosted-detail-surface.js @@ -0,0 +1,31 @@ +// @ts-check + +export class HostedDetailSurface { + /** @param {{locateDetailRoot: () => Element | null, locateDetailSlots: () => Record}} hostAdapter */ + constructor(hostAdapter) { + this.hostAdapter = hostAdapter; + this.root = null; + /** @type {Readonly>} */ + this.slots = Object.freeze({}); + this.mounted = new Map(); + } + mount() { + this.root = this.hostAdapter.locateDetailRoot(); + this.slots = Object.freeze({ ...this.hostAdapter.locateDetailSlots() }); + return this; + } + /** @param {string} slot @param {Element} element */ + mountPanel(slot, element) { + const target = this.slots[slot]; + if (!target) return false; + target.append(element); + this.mounted.set(slot, element); + return true; + } + dispose() { + for (const element of this.mounted.values()) element.remove(); + this.mounted.clear(); + this.root = null; + this.slots = Object.freeze({}); + } +} diff --git a/src/ui/detail/owned-detail-surface.js b/src/ui/detail/owned-detail-surface.js new file mode 100644 index 00000000..067f1128 --- /dev/null +++ b/src/ui/detail/owned-detail-surface.js @@ -0,0 +1,23 @@ +// @ts-check + +export class OwnedDetailSurface { + /** @param {Document} [documentRuntime] */ + constructor(documentRuntime = document) { this.document = documentRuntime; this.root = null; this.slots = new Map(); } + /** @param {Element} container */ + mount(container) { + const root = this.document.createElement("section"); + root.className = "jhs-ui jhs-owned-detail"; + for (const name of ["summary", "gallery", "resources", "reviews", "related"]) { + const slot = this.document.createElement("section"); + slot.dataset.jhsSection = name; + root.append(slot); + this.slots.set(name, slot); + } + container.replaceChildren(root); + this.root = root; + return this; + } + /** @param {string} name */ + slot(name) { return this.slots.get(name) ?? null; } + dispose() { this.root?.remove(); this.root = null; this.slots.clear(); } +} diff --git a/src/ui/table/create-jhs-table.js b/src/ui/table/create-jhs-table.js new file mode 100644 index 00000000..16bcd6ae --- /dev/null +++ b/src/ui/table/create-jhs-table.js @@ -0,0 +1,11 @@ +// @ts-check + +/** @param {any} TabulatorRuntime @param {string | Element} target @param {Record} options */ +export function createJhsTable(TabulatorRuntime, target, options) { + if (typeof TabulatorRuntime !== "function") throw new TypeError("Tabulator runtime is required"); + const pageSizes = (options.paginationSizeSelector ?? [20, 50, 100, 1000]).filter((/** @type {any} */ value) => Number.isInteger(value) && value > 0); + return new TabulatorRuntime(target, { + layout: "fitColumns", responsiveLayout: "collapse", pagination: true, paginationMode: "local", + paginationSize: pageSizes[0] ?? 20, ...options, paginationSizeSelector: pageSizes, + }); +} diff --git a/tests/123av-adapter.test.js b/tests/123av-adapter.test.js index 6df254fd..f0b4b774 100644 --- a/tests/123av-adapter.test.js +++ b/tests/123av-adapter.test.js @@ -1,8 +1,9 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const readSource = (path) => readFileSync(join(import.meta.dirname, "..", path), "utf8"); +const readSource = (path) => readTestFile(join(import.meta.dirname, "..", path), "utf8"); const fc2Source = readSource("src/plugins/external-search/fc2-by-123av.js"); const otherSiteSource = readSource("src/plugins/external-search/other-site.js"); const httpSource = readSource("src/core/http.js"); diff --git a/tests/activity-retention.test.js b/tests/activity-retention.test.js index 479c1545..ae9ae42f 100644 --- a/tests/activity-retention.test.js +++ b/tests/activity-retention.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadRetention() { - const source = readFileSync(join(repoRoot, "src/core/state-service.js"), "utf8"), end = source.indexOf("class StateService"); + const source = readTestFile(join(repoRoot, "src/core/state-service.js"), "utf8"), end = source.indexOf("class StateService"); const context = vm.createContext({ Date, JSON, Object, Array, Math }); vm.runInContext(`${source.slice(0, end)}; globalThis.prune = pruneActivityLog;`, context); return context.prune; diff --git a/tests/actress-decoration.test.js b/tests/actress-decoration.test.js index a511770b..3bfb8a79 100644 --- a/tests/actress-decoration.test.js +++ b/tests/actress-decoration.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,7 +6,7 @@ import jquery from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it } from "vitest"; -const source = readFileSync(join(import.meta.dirname, "../src/plugins/status/compat-enhancements.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/plugins/status/compat-enhancements.js"), "utf8"); async function render(html, url, favorites = [], blacklist = []) { const dom = new JSDOM(html, { url }), $ = jquery(dom.window); const context = vm.createContext({ window: dom.window, document: dom.window.document, URL, decodeURIComponent, $, BasePlugin: class { getPageInfo() { return {}; } }, storageManager: { getFavoriteActressList: async () => favorites, getBlacklist: async () => blacklist }, isDetailPage: false }); diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js new file mode 100644 index 00000000..c6a45eeb --- /dev/null +++ b/tests/architecture-runtime.test.js @@ -0,0 +1,91 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; +import { CommandRegistry } from "../src/app/command-registry.js"; +import { DependencyContainer } from "../src/app/dependency-container.js"; +import { FeatureRuntime, migrateDisabledPlugins } from "../src/app/feature-runtime.js"; +import { ProviderRegistry } from "../src/app/provider-registry.js"; +import { defineFeature, defineIntegration } from "../src/contracts/manifests.js"; +import { SERVICE } from "../src/contracts/tokens.js"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; +import { BasePlugin, PluginManager } from "../src/core/plugin-manager.js"; +import { DiagnosticsService } from "../src/services/diagnostics-service.js"; + +describe("v6.5 architecture runtime contracts", () => { + it("injects only declared tokens and rejects duplicate or missing dependencies", () => { + const container = new DependencyContainer(); + const movie = { id: "movie" }; + container.register(SERVICE.movie, movie); + expect(container.resolveDeclared([SERVICE.movie])[SERVICE.movie]).toBe(movie); + expect(container.resolveDeclared([SERVICE.movie])[SERVICE.review]).toBeUndefined(); + expect(() => container.register(SERVICE.movie, {})).toThrow(/Duplicate/); + expect(() => container.resolveDeclared([SERVICE.review])).toThrow(/Missing/); + }); + + it("limits transitional legacy plugins to explicitly declared dependencies", () => { + class DependencyPlugin extends BasePlugin { getName() { return "DependencyPlugin"; } } + class ConsumerPlugin extends BasePlugin { + getName() { return "ConsumerPlugin"; } + dependency(name) { return this.getDependency(name); } + } + const manager = new PluginManager(); + manager.setDependencyDeclarations({ ConsumerPlugin: ["DependencyPlugin"] }); + manager.register(DependencyPlugin); + manager.register(ConsumerPlugin); + const consumer = manager.getBean("ConsumerPlugin"); + expect(consumer.dependency("DependencyPlugin")).toBe(manager.getBean("DependencyPlugin")); + expect(() => consumer.dependency("UndeclaredPlugin")).toThrow(/未声明依赖/); + }); + + it("lazily activates a command owner and preserves contribution-level disabling", async () => { + const commands = new CommandRegistry(); + const diagnostics = new DiagnosticsService(); + const runtime = new FeatureRuntime({ + container: new DependencyContainer().register(SERVICE.movie, { open: vi.fn() }), + commands, diagnostics, disabled: ["ReviewPlugin"], site: "javdb", route: "detail", + }); + const activate = vi.fn((deps, api) => ({ + commands: { "detail.open": () => deps[SERVICE.movie].open() }, + enabled: api.enabledContributions, + })); + runtime.register(defineFeature({ + id: "detail", kind: "feature", disableable: true, sites: ["javdb"], routes: ["detail"], + startup: "on-command", requires: [SERVICE.movie], contributes: ["detail.reviews", "detail.related"], + providesCommands: ["detail.open"], activate, + })); + await commands.execute("detail.open"); + expect(activate).toHaveBeenCalledOnce(); + expect(diagnostics.exportSnapshot().activeContributions).toEqual(["detail.related"]); + expect(migrateDisabledPlugins(["ReviewPlugin", "UnknownPlugin"])).toEqual(["detail.reviews", "UnknownPlugin"]); + }); + + it("disposes all scope-owned resources and blocks stale generations", () => { + const target = new EventTarget(); + const listener = vi.fn(); + const observer = { disconnect: vi.fn() }; + const consumer = { release: vi.fn() }; + const scope = new LifecycleScope("test"); + scope.listen(target, "change", listener); + scope.ownObserver(observer); + scope.ownRequestConsumer(consumer); + const generation = scope.nextGeneration(); + expect(scope.canCommit(generation)).toBe(true); + scope.dispose(); + target.dispatchEvent(new Event("change")); + expect(listener).not.toHaveBeenCalled(); + expect(observer.disconnect).toHaveBeenCalledOnce(); + expect(consumer.release).toHaveBeenCalledOnce(); + expect(scope.snapshot()).toMatchObject({ listeners: 0, observers: 0, requestConsumers: 0, disposed: true }); + expect(scope.canCommit(generation)).toBe(false); + }); + + it("validates integration manifests and keeps ProviderRegistry focused", async () => { + expect(() => defineIntegration({ id: "bad", trustClass: "builtin-public", hosts: [], capabilities: [], requires: [], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(); + const registry = new ProviderRegistry(); + registry.register({ id: "slow", capabilities: ["magnet"], priority: 1 }); + registry.register({ id: "fast", capabilities: ["magnet"], priority: 2, isAvailable: async () => true }); + expect((await registry.getAvailable("magnet", {})).map((provider) => provider.id)).toEqual(["fast", "slow"]); + registry.updateHealth("fast", { ok: true }); + expect(registry.getHealth("fast")).toMatchObject({ ok: true }); + }); +}); diff --git a/tests/audit-remediation.test.js b/tests/audit-remediation.test.js index c024fd5b..f65832fd 100644 --- a/tests/audit-remediation.test.js +++ b/tests/audit-remediation.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; function loadClass(file, className, extras = {}) { - const source = readFileSync(join(process.cwd(), file), "utf8"), start = source.indexOf(`class ${className}`); + const source = readTestFile(join(process.cwd(), file), "utf8"), start = source.indexOf(`class ${className}`); const context = vm.createContext({ URL, encodeURIComponent, BasePlugin: class {}, i: (target, key, value) => (target[key] = value), l: false, r: false, o: "https://javdb.com/", ...extras }); vm.runInContext(`${source.slice(start)};globalThis.Exported=${className}`, context); return { Class: context.Exported, context }; @@ -40,7 +41,7 @@ describe("6.2.0 audit remediation", () => { }); it("keeps all JHS UI layout decisions on mobileMode", () => { - const setting = readFileSync(join(process.cwd(), "src/plugins/backup/setting.js"), "utf8"), search = readFileSync(join(process.cwd(), "src/plugins/avatar/search-by-image.js"), "utf8"), mobile = readFileSync(join(process.cwd(), "src/plugins/status/mobile-bottom-bar.js"), "utf8"); + const setting = readTestFile(join(process.cwd(), "src/plugins/backup/setting.js"), "utf8"), search = readTestFile(join(process.cwd(), "src/plugins/avatar/search-by-image.js"), "utf8"), mobile = readTestFile(join(process.cwd(), "src/plugins/status/mobile-bottom-bar.js"), "utf8"); expect(setting).not.toContain("utils.isMobile()"); expect(search).not.toContain("utils.isMobile()"); expect(search).toContain("utils.isMobileMode()"); diff --git a/tests/blacklist-filter-ui.test.js b/tests/blacklist-filter-ui.test.js index 0cfea266..fbc66e1a 100644 --- a/tests/blacklist-filter-ui.test.js +++ b/tests/blacklist-filter-ui.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,7 +6,7 @@ import jqueryFactory from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it, vi } from "vitest"; -const source = readFileSync(join(process.cwd(), "src/plugins/blacklist/blacklist.js"), "utf8"); +const source = readTestFile(join(process.cwd(), "src/plugins/blacklist/blacklist.js"), "utf8"); function createPlugin() { const dom = new JSDOM(``, { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window); @@ -63,8 +64,9 @@ describe("blacklist reset and pagination contracts", () => { expect(reset).toContain('search.val("")'); }); - it("exposes a real all-page selector and localized label", () => { - expect(source).toContain("paginationSizeSelector: [ 20, 50, 100, 1e3, !0 ]"); + it("does not expose Tabulator's blank all-page option", () => { + expect(source).toContain("paginationSizeSelector: [ 20, 50, 100, 1e3 ]"); + expect(source).not.toContain("paginationSizeSelector: [ 20, 50, 100, 1e3, !0 ]"); expect(source).toContain('all: "全部"'); expect(source).not.toContain("99999"); }); diff --git a/tests/blacklist-parser-boundary.test.js b/tests/blacklist-parser-boundary.test.js index de2487de..d82b17dc 100644 --- a/tests/blacklist-parser-boundary.test.js +++ b/tests/blacklist-parser-boundary.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -21,14 +22,14 @@ function loadBlacklist(html, save = vi.fn(async () => {}), pageUrl = "https://ja console, URL, Date, window: dom.window, document: dom.window.document, $, BasePlugin, storageManager: { batchSaveBlacklistCarList: save }, T: "javdb", I: "javbus", d: "filter", r: true, l: false, o: "", _: "yes", gmHttp, clog: { error: vi.fn(), log: vi.fn() }, show: { info: vi.fn(), ok: vi.fn() }, utils: { htmlTo$dom: source => $(new JSDOM(source, { url: pageUrl }).window.document) }, i: (target, key, value) => target[key] = value }); - const source = [ "src/core/feature-helpers.js", "src/parsers/third-party-parsers.js", "src/plugins/blacklist/blacklist.js" ].map(file => readFileSync(join(repoRoot, file), "utf8")).join("\n"); + const source = [ "src/core/feature-helpers.js", "src/integrations/host-list/parser.js", "src/plugins/blacklist/blacklist.js" ].map(file => readTestFile(join(repoRoot, file), "utf8")).join("\n"); vm.runInContext(`${source};globalThis.Plugin=BlacklistPlugin`, context); return { plugin: new context.Plugin, $page: $(dom.window.document), save, gmHttp }; } describe("blacklist parser boundaries", () => { it("uses the full-batch label for initial and refreshed tooltips", () => { - const source = readFileSync(join(repoRoot, "src/plugins/blacklist/blacklist.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/plugins/blacklist/blacklist.js"), "utf8"); expect(source).not.toContain("上次检测时间"); expect(source.match(/上次整批检测/g)).toHaveLength(2); }); diff --git a/tests/browser/fixtures/javbus-detail.html b/tests/browser/fixtures/javbus-detail.html new file mode 100644 index 00000000..0c6bec98 --- /dev/null +++ b/tests/browser/fixtures/javbus-detail.html @@ -0,0 +1,11 @@ + + +ABC-123 Fixture + +
    +

    ABC-123 Browser Fixture

    +

    識別碼: ABC-123

    + +
    + + diff --git a/tests/browser/fixtures/javdb-detail.html b/tests/browser/fixtures/javdb-detail.html new file mode 100644 index 00000000..80e1fae3 --- /dev/null +++ b/tests/browser/fixtures/javdb-detail.html @@ -0,0 +1,11 @@ + + +JavDB fixture + +
    +
    ABC-123
    +
    Fixture magnets
    +
    Fixture reviews
    +
    + + diff --git a/tests/browser/harness/runtime.js b/tests/browser/harness/runtime.js new file mode 100644 index 00000000..0137a653 --- /dev/null +++ b/tests/browser/harness/runtime.js @@ -0,0 +1,93 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const browserRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = resolve(browserRoot, "..", ".."); + +export async function fulfillHostFixtures(context) { + const javdb = await readFile(join(browserRoot, "fixtures", "javdb-detail.html"), "utf8"); + const javbus = await readFile(join(browserRoot, "fixtures", "javbus-detail.html"), "utf8"); + await context.route("**/*", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.isNavigationRequest() && request.frame() === request.frame().page().mainFrame()) { + if (url.hostname === "javdb.com") return route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: javdb }); + if (url.hostname === "www.javbus.com") return route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body: javbus }); + } + return route.abort("blockedbyclient"); + }); +} + +export async function injectUserscriptRuntime(page) { + const startupErrors = []; + page.on("pageerror", (error) => startupErrors.push(error.stack || error.message)); + page.on("console", (message) => { + if (message.type() === "error") startupErrors.push(message.text()); + }); + await page.addScriptTag({ path: join(browserRoot, "node_modules", "jquery", "dist", "jquery.min.js") }); + await page.addScriptTag({ path: join(browserRoot, "node_modules", "localforage", "dist", "localforage.min.js") }); + await page.addScriptTag({ path: join(browserRoot, "node_modules", "tabulator-tables", "dist", "js", "tabulator.min.js") }); + await page.addStyleTag({ path: join(browserRoot, "node_modules", "tabulator-tables", "dist", "css", "tabulator.min.css") }); + await page.evaluate(() => { + window.__jhsBrowserDiagnostics = { requests: [], startedAt: performance.now() }; + window.unsafeWindow = window; + window.GM_getValue = async (_key, fallback) => fallback; + window.GM_setValue = async () => undefined; + window.GM_openInTab = () => ({ close() {} }); + window.GM_xmlhttpRequest = (options) => { + window.__jhsBrowserDiagnostics.requests.push({ method: options.method || "GET", url: String(options.url || "") }); + let aborted = false; + queueMicrotask(() => { + if (!aborted) options.onerror?.({ error: "Browser fixture blocked external request" }); + }); + return { abort() { aborted = true; options.onabort?.(); } }; + }; + window.md5 = (value) => `fixture-${String(value).length}`; + window.Toastify = () => ({ showToast() {} }); + window.Viewer = class { show() {} hide() {} destroy() {} update() {} }; + window.requestIdleCallback ||= (callback) => setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 0); + let layerId = 0; + const mountedLayers = new Map(); + window.layer = { + open(options = {}) { + const id = ++layerId; + const host = document.createElement("div"); + host.className = "layui-layer"; + host.dataset.layerId = String(id); + const content = options.content?.jquery ? options.content[0] : options.content; + if (content instanceof Node) host.append(content); + else if (typeof content === "string") host.textContent = content; + document.body.append(host); + mountedLayers.set(id, { host, options }); + options.success?.(window.jQuery(host), id); + return id; + }, + close(id) { + const mounted = mountedLayers.get(id); + mounted?.options?.end?.(); + mounted?.host?.remove(); + mountedLayers.delete(id); + }, + closeAll() { [...mountedLayers.keys()].forEach((id) => this.close(id)); }, + confirm(_message, options, yes) { const id = this.open(options); yes?.(id); return id; }, + msg() {} + }; + }); + await page.addScriptTag({ path: join(repoRoot, "JHS.user.js") }); + try { + await page.waitForFunction(() => Boolean(window.unsafeWindow?.pluginManager?.getStartupReport), null, { timeout: 15_000 }); + } catch (error) { + throw new Error(`JHS fixture bootstrap failed: ${startupErrors.join("\n") || error.message}`); + } +} + +export async function assertNoHorizontalOverflow(page) { + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth + })); + if (dimensions.scrollWidth > dimensions.clientWidth + 1) { + throw new Error(`Horizontal overflow: ${dimensions.scrollWidth}px > ${dimensions.clientWidth}px`); + } +} diff --git a/tests/browser/package-lock.json b/tests/browser/package-lock.json new file mode 100644 index 00000000..f8ff47aa --- /dev/null +++ b/tests/browser/package-lock.json @@ -0,0 +1,123 @@ +{ + "name": "jhs-browser-smoke", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jhs-browser-smoke", + "devDependencies": { + "@playwright/test": "1.62.1", + "jquery": "3.7.1", + "localforage": "1.10.0", + "tabulator-tables": "6.3.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tabulator-tables": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/tabulator-tables/-/tabulator-tables-6.3.1.tgz", + "integrity": "sha512-qFW7kfadtcaISQIibKAIy0f3eeIXUVi8242Vly1iJfMD79kfEGzfczNuPBN/80hDxHzQJXYbmJ8VipI40hQtfA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/tests/browser/package.json b/tests/browser/package.json new file mode 100644 index 00000000..d227faec --- /dev/null +++ b/tests/browser/package.json @@ -0,0 +1,18 @@ +{ + "name": "jhs-browser-smoke", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "pretest": "node scripts/check-local-browser.mjs", + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.62.1", + "jquery": "3.7.1", + "localforage": "1.10.0", + "tabulator-tables": "6.3.1" + } +} diff --git a/tests/browser/playwright.config.js b/tests/browser/playwright.config.js new file mode 100644 index 00000000..0838e9e3 --- /dev/null +++ b/tests/browser/playwright.config.js @@ -0,0 +1,30 @@ +import { defineConfig } from "@playwright/test"; + +const channel = process.env.CI ? undefined : process.env.JHS_BROWSER_CHANNEL || "msedge"; +const viewports = [ + ["desktop-wide", 1440, 900, false], + ["desktop-regular", 1280, 720, false], + ["tablet", 820, 1180, true], + ["mobile", 390, 844, true], + ["mobile-small", 360, 640, true], + ["mobile-landscape", 844, 390, true] +]; + +export default defineConfig({ + testDir: "./specs", + outputDir: "../../output/playwright/test-results", + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [["line"], ["html", { outputFolder: "../../output/playwright/report", open: "never" }]], + use: { + channel, + headless: true, + serviceWorkers: "block", + trace: "retain-on-failure", + screenshot: "only-on-failure" + }, + projects: viewports.map(([name, width, height, touch]) => ({ + name, + use: { viewport: { width, height }, hasTouch: touch, isMobile: touch } + })) +}); diff --git a/tests/browser/scripts/check-local-browser.mjs b/tests/browser/scripts/check-local-browser.mjs new file mode 100644 index 00000000..3fd5ea2c --- /dev/null +++ b/tests/browser/scripts/check-local-browser.mjs @@ -0,0 +1,23 @@ +import { access } from "node:fs/promises"; +import path from "node:path"; + +if (process.env.CI) process.exit(0); + +const channel = process.env.JHS_BROWSER_CHANNEL || "msedge"; +if (!new Set(["msedge", "chrome"]).has(channel)) { + throw new Error(`Unsupported JHS_BROWSER_CHANNEL=${channel}; use msedge or chrome`); +} + +const roots = [process.env["PROGRAMFILES"], process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean); +const relativePaths = channel === "msedge" + ? ["Microsoft/Edge/Application/msedge.exe"] + : ["Google/Chrome/Application/chrome.exe"]; +const candidates = roots.flatMap((root) => relativePaths.map((relative) => path.join(root, relative))); +let found = null; +for (const candidate of candidates) { + try { await access(candidate); found = candidate; break; } catch { /* try the next installed location */ } +} +if (!found) { + throw new Error(`${channel} is not installed in a standard Windows location. JHS browser tests never download a browser; install it or set JHS_BROWSER_CHANNEL to an installed channel.`); +} +console.log(`Using installed ${channel}: ${found}`); diff --git a/tests/browser/specs/host-routing.spec.js b/tests/browser/specs/host-routing.spec.js new file mode 100644 index 00000000..a71661a4 --- /dev/null +++ b/tests/browser/specs/host-routing.spec.js @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; +import budget from "../../../performance-budget.json" with { type: "json" }; +import { assertNoHorizontalOverflow, fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime.js"; + +for (const [label, url, expectedPlugin] of [ + ["JavDB", "https://javdb.com/v/test-id", "DetailPagePlugin"], + ["JavBus", "https://www.javbus.com/ABC-123", "BusDetailPagePlugin"] +]) { + test(`${label} uses the real host origin with local fixtures`, async ({ context, page }) => { + await fulfillHostFixtures(context); + await page.goto(url, { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect.poll(() => page.evaluate((name) => window.unsafeWindow.pluginManager.getPluginNames().includes(name), expectedPlugin)).toBe(true); + await expect(page.locator("body")).toBeVisible(); + await assertNoHorizontalOverflow(page); + await page.waitForTimeout(250); + const initialRequests = await page.evaluate(() => window.__jhsBrowserDiagnostics.requests.length); + expect(initialRequests, "deterministic fixture startup request budget").toBeLessThanOrEqual(budget.browserFixture.maximumInitialRequests[label]); + }); +} diff --git a/tests/clipboard.test.js b/tests/clipboard.test.js index 2cec13ed..895413dc 100644 --- a/tests/clipboard.test.js +++ b/tests/clipboard.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -8,7 +9,7 @@ function loadClipboard({ writeText, execResult = true } = {}) { const textarea = { value: "", style: {}, setAttribute: vi.fn(), select: vi.fn(), remove }; const document = { body: { appendChild }, activeElement: { focus }, createElement: vi.fn(() => textarea), execCommand: vi.fn(() => execResult) }; const context = vm.createContext({ URL, navigator: { clipboard: writeText ? { writeText } : null }, document, window: { location: new URL("https://javdb.com/"), innerWidth: 1280, innerHeight: 720 }, show: { info, error }, clog: { error: logError }, JHS_Z_INDEX: { layer: 1 }, i: (target, key, value) => (target[key] = value) }); - const source = readFileSync(join(import.meta.dirname, "../src/core/utils.js"), "utf8"); + const source = readTestFile(join(import.meta.dirname, "../src/core/utils.js"), "utf8"); vm.runInContext(`${source};globalThis.TestUtils=Utils`, context); return { utils: new context.TestUtils(), info, error, logError, document, textarea, focus }; } diff --git a/tests/compat-enhancements.test.js b/tests/compat-enhancements.test.js index cccf0392..1c17cffd 100644 --- a/tests/compat-enhancements.test.js +++ b/tests/compat-enhancements.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it } from "vitest"; -const source = readFileSync(join(import.meta.dirname, "../src/plugins/status/compat-enhancements.js"), "utf8"); -const nav = readFileSync(join(import.meta.dirname, "../src/plugins/status/nav-bar.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/plugins/status/compat-enhancements.js"), "utf8"); +const nav = readTestFile(join(import.meta.dirname, "../src/plugins/status/nav-bar.js"), "utf8"); describe("status and media UX contracts", () => { it("injects the single confirmed ad-container rule only on JavDB", async () => { const loadCss = async siteContext => { @@ -24,5 +25,5 @@ describe("status and media UX contracts", () => { it("loads actress state once into sets", () => { expect(source).toContain("new Set((await storageManager.getFavoriteActressList())"); expect(source).toContain("new Set((await storageManager.getBlacklist())"); }); it("links bounded comment images without rebuilding review DOM", () => { expect(source).toContain("createTreeWalker"); expect(source).toContain("SHOW_TEXT"); expect(source).toContain("showImageViewer"); expect(source).not.toContain("node.html("); }); it("intercepts image paste only on the navigation search input", () => { expect(nav).toContain('$("#search-keyword").on("paste"'); expect(nav).toContain('type.indexOf("image")'); }); - it("uses configured 115 concurrency and cache lifetime", () => { const one15 = readFileSync(join(import.meta.dirname, "../src/plugins/one-one-five/plugins.js"), "utf8"); expect(one15).toContain("mapLimit(cards, this.concurrency"); expect(one15).toContain('getSetting("oneOneFiveConcurrency"'); expect(one15).toContain('getSetting("oneOneFiveCacheMinutes"'); expect(one15).toContain('rootMargin: "200px"'); }); + it("uses configured 115 concurrency and cache lifetime", () => { const one15 = readTestFile(join(import.meta.dirname, "../src/plugins/one-one-five/plugins.js"), "utf8"); expect(one15).toContain("mapLimit(cards, this.concurrency"); expect(one15).toContain('getSetting("oneOneFiveConcurrency"'); expect(one15).toContain('getSetting("oneOneFiveCacheMinutes"'); expect(one15).toContain('rootMargin: "200px"'); }); }); diff --git a/tests/detail-car-num.test.js b/tests/detail-car-num.test.js index af112c23..e08bf7a3 100644 --- a/tests/detail-car-num.test.js +++ b/tests/detail-car-num.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import { describe, expect, it, vi } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadCarNumHelpers() { - const source = readFileSync(join(repoRoot, "src/core/constants.js"), "utf8"), start = source.indexOf("function normalizeCarNum"), end = source.indexOf("let M =", start); + const source = readTestFile(join(repoRoot, "src/core/constants.js"), "utf8"), start = source.indexOf("function normalizeCarNum"), end = source.indexOf("let M =", start); const context = vm.createContext({}); vm.runInContext(`${source.slice(start, end)}; globalThis.normalize = normalizeCarNum; globalThis.first = firstValidCarNum; globalThis.assertContract = assertPageInfoContract;`, context); return context; @@ -53,7 +54,7 @@ function getPageInfo({ url, javdb = false, javbus = false, copyCarNum = null, fa assertPageInfoContract: helpers.assertContract, i: (target, key, value) => (target[key] = value) }); - const source = readFileSync(join(repoRoot, "src/core/plugin-manager.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/core/plugin-manager.js"), "utf8"); vm.runInContext(`${source}; globalThis.TestBasePlugin = BasePlugin;`, context); return context.TestBasePlugin.prototype.getPageInfo.call({}); } @@ -69,7 +70,7 @@ function loadUtils(url = "https://javdb.example/search?q=ABF-142") { normalizeCarNum: loadCarNumHelpers().normalize, i: (target, key, value) => (target[key] = value) }); - const source = readFileSync(join(repoRoot, "src/core/utils.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/core/utils.js"), "utf8"); vm.runInContext(`${source}; globalThis.TestUtils = Utils;`, context); return { utils: new context.TestUtils(), layer, openTab }; } @@ -86,7 +87,7 @@ function loadDmmParser() { $: () => ({ attr: vi.fn().mockReturnThis(), css: vi.fn().mockReturnThis(), append: vi.fn().mockReturnThis() }), show: { error: vi.fn() } }); - const source = readFileSync(join(repoRoot, "src/plugins/image-viewer/preview-video.js"), "utf8"), start = source.indexOf("const Z ="), end = source.indexOf("async function fetchDmmPreview", start); + const source = readTestFile(join(repoRoot, "src/plugins/image-viewer/preview-video.js"), "utf8"), start = source.indexOf("const Z ="), end = source.indexOf("async function fetchDmmPreview", start); vm.runInContext(`${source.slice(start, end)}; globalThis.TestDmmParser = DmmPreviewParser;`, context); return { Parser: context.TestDmmParser, warn, error, request }; } @@ -107,9 +108,9 @@ function loadScreenshotPlugin(overrides = {}) { l: false }); context.CACHE_TTL = { screenshot: 6048e5 }; - const parserSource = readFileSync(join(repoRoot, "src/parsers/third-party-parsers.js"), "utf8"); - const registrySource = readFileSync(join(repoRoot, "src/plugins/image-viewer/screenshot-provider-registry.js"), "utf8"); - const source = readFileSync(join(repoRoot, "src/plugins/image-viewer/screenshot.js"), "utf8"); + const parserSource = readTestFile(join(repoRoot, "src/integrations/javstore/parser.js"), "utf8"); + const registrySource = readTestFile(join(repoRoot, "src/plugins/image-viewer/screenshot-provider-registry.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/plugins/image-viewer/screenshot.js"), "utf8"); vm.runInContext(`${parserSource}\n${registrySource}\n${source}; globalThis.TestScreenshotPlugin = ScreenShotPlugin;`, context); return { Plugin: context.TestScreenshotPlugin, warn, debug, error, cachedRequest }; } @@ -282,20 +283,20 @@ describe("source regression contracts", () => { "src/plugins/image-viewer/preview-video.js", "src/plugins/image-viewer/screenshot.js" ]) { - const source = readFileSync(join(repoRoot, file), "utf8"); + const source = readTestFile(join(repoRoot, file), "utf8"); expect(source).toContain("getPageInfo()"); expect(source).not.toContain("getPageInfo()?.carNum"); } }); it("treats opted-in HTTP 404 responses as neutral results before retry accounting", () => { - const source = readFileSync(join(repoRoot, "src/core/http.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/core/http.js"), "utf8"); expect(source).toMatch(/404 === e\.status && requestOptions\.ignoreNotFound[\s\S]{0,80}a\(null\)/); expect(source.indexOf("404 === e.status && requestOptions.ignoreNotFound")).toBeLessThan(source.indexOf("this._isCloudflareChallenge(e.responseText, e.status)")); }); it("uses readable non-shadowing variables for actress profile links", () => { - const source = readFileSync(join(repoRoot, "src/plugins/new-video/new-video.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/plugins/new-video/new-video.js"), "utf8"); expect(source).toContain("const profileUrl = normalizeHttpUrl(`/actors/${encodeURIComponent(starId)}?t=d`, javDbUrl)"); expect(source).toContain("noteText = isPaused"); expect(source).not.toContain("`${c}/actors/${e.starId}?t=d`"); diff --git a/tests/detail-host-boundary.test.js b/tests/detail-host-boundary.test.js index d559c725..0fbbfc52 100644 --- a/tests/detail-host-boundary.test.js +++ b/tests/detail-host-boundary.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { describe, expect, it, vi } from "vitest"; import { JSDOM } from "jsdom"; import jquery from "jquery"; @@ -5,9 +6,9 @@ import vm from "node:vm"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -const workspaceSource = readFileSync(join(process.cwd(), "src/plugins/status/detail-workspace.js"), "utf8"); -const unifiedSource = readFileSync(join(process.cwd(), "src/plugins/offline/unified-offline.js"), "utf8"); -const selectSource = readFileSync(join(process.cwd(), "src/core/ui-primitives.js"), "utf8").slice(readFileSync(join(process.cwd(), "src/core/ui-primitives.js"), "utf8").indexOf("class JhsSelect")); +const workspaceSource = readTestFile(join(process.cwd(), "src/plugins/status/detail-workspace.js"), "utf8"); +const unifiedSource = readTestFile(join(process.cwd(), "src/plugins/offline/unified-offline.js"), "utf8"); +const selectSource = readTestFile(join(process.cwd(), "src/core/ui-primitives.js"), "utf8").slice(readTestFile(join(process.cwd(), "src/core/ui-primitives.js"), "utf8").indexOf("class JhsSelect")); class BasePlugin { getBean() { return null; } diff --git a/tests/detail-panel-lifecycle.test.js b/tests/detail-panel-lifecycle.test.js index eb9e2aac..b8659a5b 100644 --- a/tests/detail-panel-lifecycle.test.js +++ b/tests/detail-panel-lifecycle.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -15,8 +16,8 @@ function loadPanels() { R: reviewFetch, K: relatedFetch, utils: { formatDate: value => value, q: (event, message, callback) => callback() }, show: { error: vi.fn(), ok: vi.fn() }, clog: { error: vi.fn(), warn: vi.fn() }, escapeHtml: String, i: (target, key, value) => (target[key] = value) }); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8")};globalThis.Review=ReviewPlugin`, context); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8")};globalThis.Related=RelatedPlugin`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8")};globalThis.Review=ReviewPlugin`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8")};globalThis.Related=RelatedPlugin`, context); return { $, Review: context.Review, Related: context.Related, reviewFetch, relatedFetch, storageManager: context.storageManager, window: dom.window }; } diff --git a/tests/detail-state-controller.test.js b/tests/detail-state-controller.test.js index a7c8d146..53f2bfac 100644 --- a/tests/detail-state-controller.test.js +++ b/tests/detail-state-controller.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -20,7 +21,7 @@ function loadController() { storageManager: { getCar: async carNum => records.get(carNum) }, stateService: { toggle }, utils: { q: confirm, closePage }, show: { error: vi.fn() }, clog: { error: vi.fn() } }); - const source = readFileSync(join(repoRoot, "src/core/detail-state-controller.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/core/detail-state-controller.js"), "utf8"); vm.runInContext(`${source};globalThis.Controller=DetailStateController`, context); return { controller: new context.Controller(), dom, $, records, toggle, confirm, closePage }; } @@ -60,7 +61,7 @@ describe("Utils.closePage", () => { i: (target, key, value) => (target[key] = value) }); dom.window.layer = context.layer; - vm.runInContext(`${readFileSync(join(repoRoot, "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils`, context); + vm.runInContext(`${readTestFile(join(repoRoot, "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils`, context); await new context.TestUtils().closePage({ root: dom.window.document.querySelector("#detail") }); expect(close).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledWith(7); diff --git a/tests/event-bus-v2.test.js b/tests/event-bus-v2.test.js index b166b3f4..ae6dcded 100644 --- a/tests/event-bus-v2.test.js +++ b/tests/event-bus-v2.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it, vi } from "vitest"; -const source = readFileSync(join(import.meta.dirname, "../src/core/event-bus.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/core/event-bus.js"), "utf8"); class FakeBroadcastChannel { static channels = []; @@ -13,7 +14,7 @@ class FakeBroadcastChannel { } function loadBus() { - const context = vm.createContext({ BroadcastChannel: FakeBroadcastChannel, crypto: { randomUUID: vi.fn().mockReturnValueOnce("tab-a").mockReturnValueOnce("tab-b").mockReturnValue("event-1") }, Date, Math, Map, Set, window: {}, unsafeWindow: {} }), end = source.indexOf("const jhsEventBus"); + const context = vm.createContext({ BroadcastChannel: FakeBroadcastChannel, crypto: { randomUUID: vi.fn().mockReturnValueOnce("tab-a").mockReturnValueOnce("tab-b").mockReturnValue("event-1") }, Date, Math, Map, Set, window: {}, unsafeWindow: {} }), end = source.indexOf("let jhsEventBus"); vm.runInContext(`${source.slice(0, end)}; globalThis.Bus = JhsEventBus;`, context); return context.Bus; } diff --git a/tests/external-rendering.test.js b/tests/external-rendering.test.js index 6661422b..cd23db20 100644 --- a/tests/external-rendering.test.js +++ b/tests/external-rendering.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import jqueryFactory from "jquery"; import { describe, expect, it } from "vitest"; function loadHitShow() { - const dom = new JSDOM('
    '), $ = jqueryFactory(dom.window), source = readFileSync(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); + const dom = new JSDOM('
    '), $ = jqueryFactory(dom.window), source = readTestFile(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); const escapeHtml = value => $("").text(String(value ?? "")).html(), normalizeHttpUrl = value => { try { const url = new URL(value, "https://javdb.com/"); return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch { return null; } }; const context = vm.createContext({ BasePlugin: class {}, i: (target, key, value) => (target[key] = value), $, document: dom.window.document, escapeHtml, normalizeHttpUrl, loading: () => ({ close() {} }), window: dom.window }); vm.runInContext(`${source};globalThis.HitShowPlugin=HitShowPlugin`, context); diff --git a/tests/fc2-owned-workspace.test.js b/tests/fc2-owned-workspace.test.js index d49c39d4..eba5a616 100644 --- a/tests/fc2-owned-workspace.test.js +++ b/tests/fc2-owned-workspace.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,24 +7,24 @@ import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); -const fc2Source = readFileSync(join(repoRoot, "src/plugins/external-search/fc2.js"), "utf8"); -const fc2By123AvSource = readFileSync(join(repoRoot, "src/plugins/external-search/fc2-by-123av.js"), "utf8"); -const screenshotSource = readFileSync(join(repoRoot, "src/plugins/image-viewer/screenshot.js"), "utf8"); -const listPageSource = readFileSync(join(repoRoot, "src/plugins/status/list-page.js"), "utf8"); -const historySource = readFileSync(join(repoRoot, "src/plugins/status/history.js"), "utf8"); -const stateServiceSource = readFileSync(join(repoRoot, "src/core/state-service.js"), "utf8"); -const titleFilterSource = readFileSync(join(repoRoot, "src/plugins/blacklist/filter-title-keyword.js"), "utf8"); -const highlightMagnetSource = readFileSync(join(repoRoot, "src/plugins/status/highlight-magnet.js"), "utf8"); -const primitivesSource = readFileSync(join(repoRoot, "src/core/ui-primitives.js"), "utf8"); -const magnetHubSource = readFileSync(join(repoRoot, "src/plugins/external-search/magnet-hub.js"), "utf8"); -const loggerSource = readFileSync(join(repoRoot, "src/core/logger.js"), "utf8"); -const top250Source = readFileSync(join(repoRoot, "src/plugins/external-search/top250.js"), "utf8"); +const fc2Source = readTestFile(join(repoRoot, "src/plugins/external-search/fc2.js"), "utf8"); +const fc2By123AvSource = readTestFile(join(repoRoot, "src/plugins/external-search/fc2-by-123av.js"), "utf8"); +const screenshotSource = readTestFile(join(repoRoot, "src/plugins/image-viewer/screenshot.js"), "utf8"); +const listPageSource = readTestFile(join(repoRoot, "src/plugins/status/list-page.js"), "utf8"); +const historySource = readTestFile(join(repoRoot, "src/plugins/status/history.js"), "utf8"); +const stateServiceSource = readTestFile(join(repoRoot, "src/core/state-service.js"), "utf8"); +const titleFilterSource = readTestFile(join(repoRoot, "src/plugins/blacklist/filter-title-keyword.js"), "utf8"); +const highlightMagnetSource = readTestFile(join(repoRoot, "src/plugins/status/highlight-magnet.js"), "utf8"); +const primitivesSource = readTestFile(join(repoRoot, "src/core/ui-primitives.js"), "utf8"); +const magnetHubSource = readTestFile(join(repoRoot, "src/plugins/external-search/magnet-hub.js"), "utf8"); +const loggerSource = readTestFile(join(repoRoot, "src/core/logger.js"), "utf8"); +const top250Source = readTestFile(join(repoRoot, "src/plugins/external-search/top250.js"), "utf8"); function loadWorkspace() { const dom = new JSDOM('
    ', { url: "https://javdb.com/users/collection_codes" }), $ = jqueryFactory(dom.window); const context = vm.createContext({ window: dom.window, document: dom.window.document, Node: dom.window.Node, MutationObserver: dom.window.MutationObserver, $, BasePlugin: class {}, r: true, l: false, normalizeCarNum: value => String(value || "").trim().toUpperCase() || null, jhsEventBus: { emit: vi.fn() }, utils: {}, JhsSelect: {} }); - const source = readFileSync(join(repoRoot, "src/plugins/status/detail-workspace.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/plugins/status/detail-workspace.js"), "utf8"); vm.runInContext(`${source};globalThis.createShell=createFc2DetailShell;globalThis.createContext=createFc2DetailContext`, context); return { $, context }; } @@ -40,7 +41,7 @@ function loadResolver(responseFactory) { normalizeCarNum: value => String(value || "").trim().toUpperCase().replace("FC2-PPV-", "FC2-") || null, utils: { formatDate: String }, show: { error: vi.fn() } }); - const source = readFileSync(join(repoRoot, "src/core/javdb-api.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/core/javdb-api.js"), "utf8"); vm.runInContext(`${source};globalThis.resolveId=resolveJavDbMovieId`, context); return { resolveId: context.resolveId, get, cacheCalls }; } @@ -50,7 +51,7 @@ function loadWantApi({ encryptedToken = "encrypted", response = { success: 1 } } storageManager: { deleteCachedRequest }, gmHttp: { gmRequest }, localStorage: { getItem: key => local.get(key) || null, setItem: (key, value) => local.set(key, value), removeItem: key => local.delete(key) }, decryptData: vi.fn(async value => `token:${value}`), md5: String, normalizeCarNum: String, utils: { formatDate: String }, show: { error: vi.fn() } }); - const source = readFileSync(join(repoRoot, "src/core/javdb-api.js"), "utf8"); + const source = readTestFile(join(repoRoot, "src/core/javdb-api.js"), "utf8"); vm.runInContext(`${source};globalThis.markWant=markJavDbWantWatch`, context); return { markWant: context.markWant, gmRequest, deleteCachedRequest }; } diff --git a/tests/feature-helpers.test.js b/tests/feature-helpers.test.js index 4794d963..49519018 100644 --- a/tests/feature-helpers.test.js +++ b/tests/feature-helpers.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,7 +6,7 @@ import { describe, expect, it } from "vitest"; const warnings = [], notices = []; const context = vm.createContext({ URL, window: { location: { href: "https://javdb.com/" } }, encodeURIComponent, normalizeCarNum: value => typeof value === "string" && value.trim() && !["null", "undefined"].includes(value.trim().toLowerCase()) ? value.trim() : null, escapeHtml: value => value, clog: { warn: (...args) => warnings.push(args), debug: () => {} }, show: { error: message => notices.push(message) } }); -const source = readFileSync(join(import.meta.dirname, "../src/core/feature-helpers.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/core/feature-helpers.js"), "utf8"); vm.runInContext(`${source};globalThis.api={mapLimit,parseNumberSetting,parseTaskTimestamp,shouldSkipStopped,selectLatestPublishTime,normalizeDmmCid,normalizeHttpUrl,normalizeBtihHash,resolveHighResCover,parseCarNumberText,buildFallbackCarUrl,linkCommentImageReferences,safePlay}`, context); const api = context.api; diff --git a/tests/fixtures/integrations/av123/valid.html b/tests/fixtures/integrations/av123/valid.html new file mode 100644 index 00000000..347de1e0 --- /dev/null +++ b/tests/fixtures/integrations/av123/valid.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/integrations/dmm/preview.html b/tests/fixtures/integrations/dmm/preview.html new file mode 100644 index 00000000..d549f34d --- /dev/null +++ b/tests/fixtures/integrations/dmm/preview.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/integrations/fc2ppvdb/detail.html b/tests/fixtures/integrations/fc2ppvdb/detail.html new file mode 100644 index 00000000..19bcada1 --- /dev/null +++ b/tests/fixtures/integrations/fc2ppvdb/detail.html @@ -0,0 +1 @@ +

    Fixture FC2 title

    diff --git a/tests/fixtures/integrations/host-list/valid.html b/tests/fixtures/integrations/host-list/valid.html new file mode 100644 index 00000000..44a021e6 --- /dev/null +++ b/tests/fixtures/integrations/host-list/valid.html @@ -0,0 +1 @@ +
    diff --git a/tests/fixtures/integrations/javbus/detail.html b/tests/fixtures/integrations/javbus/detail.html new file mode 100644 index 00000000..8b6b4010 --- /dev/null +++ b/tests/fixtures/integrations/javbus/detail.html @@ -0,0 +1 @@ +

    識別碼: ABC-123

    diff --git a/tests/fixtures/integrations/javdb/valid.html b/tests/fixtures/integrations/javdb/valid.html new file mode 100644 index 00000000..de71bdbe --- /dev/null +++ b/tests/fixtures/integrations/javdb/valid.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/integrations/javstore/valid.html b/tests/fixtures/integrations/javstore/valid.html new file mode 100644 index 00000000..2d937bf0 --- /dev/null +++ b/tests/fixtures/integrations/javstore/valid.html @@ -0,0 +1 @@ +ABC-123 diff --git a/tests/fixtures/integrations/javtrailers/preview.html b/tests/fixtures/integrations/javtrailers/preview.html new file mode 100644 index 00000000..7ecadd7a --- /dev/null +++ b/tests/fixtures/integrations/javtrailers/preview.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/integrations/subtitlecat/results.html b/tests/fixtures/integrations/subtitlecat/results.html new file mode 100644 index 00000000..536afd96 --- /dev/null +++ b/tests/fixtures/integrations/subtitlecat/results.html @@ -0,0 +1 @@ +Chinese diff --git a/tests/helpers/read-test-file.js b/tests/helpers/read-test-file.js new file mode 100644 index 00000000..4df6b450 --- /dev/null +++ b/tests/helpers/read-test-file.js @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +/** + * Reads fixtures unchanged and lowers legacy VM-loaded production modules to script syntax. + * New architecture tests must import modules directly; this adapter exists only for pre-6.5 VM harnesses. + */ +export function readTestFile(file, encoding) { + const source = readFileSync(file, encoding); + const normalized = path.resolve(file).replaceAll("\\", "/"); + if (!normalized.includes("/src/") || !normalized.endsWith(".js") || typeof source !== "string") return source; + return source + .replace(/^\s*import\s+[^;]+;\s*$/gm, "") + .replace(/^export\s+(?=(?:const|let|var|class|function|async\s+function)\b)/gm, "") + // Legacy VM fixtures provide BasePlugin#getBean. Production code uses + // declared dependency injection and never executes this compatibility projection. + .replaceAll("this.getDependency(", "this.getBean("); +} diff --git a/tests/history-multistate.test.js b/tests/history-multistate.test.js index 32795c10..09abfd07 100644 --- a/tests/history-multistate.test.js +++ b/tests/history-multistate.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -13,7 +14,7 @@ function loadHistory() { utils: { getDialogArea: () => [], q: confirm }, show: { error: vi.fn() }, clog: { debug: vi.fn() }, i: (target, key, value) => (target[key] = value), r: true, l: false, d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", m: "屏蔽", v: "收藏", y: "下载", k: "观看" }); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/status/history.js"), "utf8")};globalThis.History=HistoryPlugin`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/status/history.js"), "utf8")};globalThis.History=HistoryPlugin`, context); const plugin = new context.History; plugin.tableObj = { setData: vi.fn() }; return { plugin, $, patch, toggle, layer, confirm }; diff --git a/tests/history-selection.test.js b/tests/history-selection.test.js index 2e0c130f..8d98a1ea 100644 --- a/tests/history-selection.test.js +++ b/tests/history-selection.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -71,7 +72,7 @@ function loadHistory(records = createRecords()) { y: "下载", k: "观看" }); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/status/history.js"), "utf8")};globalThis.History=HistoryPlugin`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/status/history.js"), "utf8")};globalThis.History=HistoryPlugin`, context); const plugin = new context.History, root = $("#history"); const table = { currentData: records.slice(0, 50), diff --git a/tests/hit-show-page.test.js b/tests/hit-show-page.test.js index e3730fcb..dd5d8ec1 100644 --- a/tests/hit-show-page.test.js +++ b/tests/hit-show-page.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it } from "vitest"; -const source = readFileSync(join(process.cwd(), "src/core/site-context.js"), "utf8"); +const source = readTestFile(join(process.cwd(), "src/core/site-context.js"), "utf8"); const context = vm.createContext({ URL }); vm.runInContext(`${source};globalThis.helpers={isHitShowPage,isNormalListPage,isListPage}`, context); diff --git a/tests/hit-show.test.js b/tests/hit-show.test.js index 407a7014..4339d8a4 100644 --- a/tests/hit-show.test.js +++ b/tests/hit-show.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -21,7 +22,7 @@ function loadHitShow({ movies = [], fetchScore = vi.fn(), cache = {}, sortMethod escapeHtml: value => $("").text(String(value ?? "")).html(), normalizeHttpUrl: value => value }); - const source = readFileSync(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); + const source = readTestFile(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); vm.runInContext(`${source};globalThis.HitShowPlugin=HitShowPlugin`, context); return { plugin: new context.HitShowPlugin(), context, dom, $, storage, loadingClose, sortItems, listPage, coverButton }; } diff --git a/tests/http-request-options.test.js b/tests/http-request-options.test.js index 33aca612..7320cf04 100644 --- a/tests/http-request-options.test.js +++ b/tests/http-request-options.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it } from "vitest"; -const httpSource = readFileSync(join(import.meta.dirname, "../src/core/http.js"), "utf8"); +const httpSource = readTestFile(join(import.meta.dirname, "../src/core/http.js"), "utf8"); function loadHttp(onRequest = null) { const context = vm.createContext({ @@ -19,8 +20,8 @@ function loadHttp(onRequest = null) { utils: { retry: async (operation) => operation() }, GM_xmlhttpRequest: (options) => onRequest?.(options) }); - vm.runInContext(httpSource, context); - return context.window.gmHttp; + vm.runInContext(`${httpSource};globalThis.testGmHttp=gmHttp`, context); + return context.testGmHttp; } describe("HTTP Cloudflare detection", () => { @@ -43,7 +44,7 @@ describe("HTTP Cloudflare detection", () => { }); it("does not classify the normal 123AV listing fixture as a challenge", () => { - const html = readFileSync(join(import.meta.dirname, "fixtures/123av-cards.html"), "utf8"); + const html = readTestFile(join(import.meta.dirname, "fixtures/123av-cards.html"), "utf8"); expect(gmHttp._isCloudflareChallenge(html, 200)).toBe(false); }); }); diff --git a/tests/http-service.test.js b/tests/http-service.test.js new file mode 100644 index 00000000..4d751d7b --- /dev/null +++ b/tests/http-service.test.js @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; +import { CacheService } from "../src/services/cache-service.js"; +import { DiagnosticsService } from "../src/services/diagnostics-service.js"; +import { ExternalUrlPolicy } from "../src/services/external-url-policy.js"; +import { canonicalizeUrl, createRequestKey, HttpService } from "../src/services/http-service.js"; +import { SettingsService } from "../src/services/settings-service.js"; + +describe("HTTP, URL and settings contracts", () => { + it("canonicalizes RequestKey without retaining credential values", async () => { + expect(canonicalizeUrl("https://api.example.test/a?z=2&a=1#secret")).toBe("https://api.example.test/a?a=1&z=2"); + const key = await createRequestKey({ providerId: "example", method: "GET", url: "https://api.example.test/a", headers: { Authorization: "Bearer secret" }, varyHeaders: ["Authorization"], cacheScope: "session", sessionScopeId: "account-a" }); + expect(JSON.stringify(key)).not.toContain("Bearer secret"); + expect(key.varyHeadersHash).toHaveLength(64); + }); + + it("deduplicates scoped GET consumers and aborts only after the last consumer", async () => { + let resolveRequest; + const request = vi.fn(() => new Promise((resolve) => { resolveRequest = resolve; })); + const diagnostics = new DiagnosticsService(); + const service = new HttpService({ request }, new ExternalUrlPolicy(), { diagnostics, cache: new CacheService({ diagnostics }) }); + const firstScope = new LifecycleScope("first"); + const secondScope = new LifecycleScope("second"); + const options = { providerId: "example", url: "https://api.example.test/data", urlPolicy: { trustClass: "builtin-public", hosts: ["api.example.test"] }, cacheScope: "public", ttlMs: 1000 }; + const first = service.request(options, firstScope); + const second = service.request(options, secondScope); + await vi.waitFor(() => expect(request).toHaveBeenCalledOnce()); + firstScope.dispose(); + await expect(first).rejects.toMatchObject({ code: "ABORTED" }); + expect(request.mock.calls[0][0].signal.aborted).toBe(false); + resolveRequest({ status: 200, data: { ok: true }, finalUrl: "https://api.example.test/data" }); + await expect(second).resolves.toMatchObject({ data: { ok: true } }); + expect(diagnostics.exportSnapshot()).toMatchObject({ requestConsumers: 0, underlyingRequests: 0 }); + }); + + it("revalidates finalUrl for every trust class", async () => { + const service = new HttpService({ request: async () => ({ status: 200, finalUrl: "https://evil.example/data" }) }, new ExternalUrlPolicy()); + await expect(service.request({ providerId: "example", url: "https://api.example.test/data", urlPolicy: { trustClass: "builtin-public", hosts: ["api.example.test"] }, cacheScope: "none" })).rejects.toMatchObject({ code: "INVALID_URL" }); + }); + + it("permits user-local only for an explicitly authorized exact origin", () => { + const policy = new ExternalUrlPolicy({ localOrigins: ["http://192.168.1.10:5244"] }); + expect(policy.assertAllowed("http://192.168.1.10:5244/dav", { trustClass: "user-local", expectedOrigin: "http://192.168.1.10:5244" }).pathname).toBe("/dav"); + expect(() => policy.assertFinalUrl("http://192.168.1.10:8080/dav", { trustClass: "user-local", expectedOrigin: "http://192.168.1.10:5244" })).toThrow(); + expect(() => policy.assertAllowed("https://127.0.0.1/data", { trustClass: "custom-public" })).toThrow(); + }); + + it("updates a settings snapshot only after persistence succeeds", async () => { + const values = new Map([["setting", { theme: "light" }]]); + const storage = { get: async (key) => values.get(key), set: vi.fn(async (key, value) => values.set(key, value)) }; + const settings = new SettingsService(storage, { validators: { theme: (value) => ["light", "dark"].includes(String(value)) } }); + await settings.load(); + await settings.set("theme", "dark"); + expect(settings.snapshot()).toEqual({ theme: "dark" }); + expect(() => settings.set("theme", "invalid")).rejects.toThrow(/Invalid/); + }); +}); diff --git a/tests/image-hover-preview.test.js b/tests/image-hover-preview.test.js index b0b56d36..9ef12acc 100644 --- a/tests/image-hover-preview.test.js +++ b/tests/image-hover-preview.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -58,13 +59,18 @@ function loadPreviewClass() { set src(value) { this.value = value; } get src() { return this.value; } } - const context = { console, document, Image: FakeImage, JHS_Z_INDEX: { tooltip: 9999999999 }, utils: { isMobileMode: () => false }, setTimeout, clearTimeout }; + class FakeLifecycleScope { + constructor() { this.cleanups = []; } + listen(target, type, listener) { target.addEventListener(type, listener); this.cleanups.push(() => target.removeEventListener(type, listener)); } + dispose() { this.cleanups.splice(0).reverse().forEach((cleanup) => cleanup()); } + } + const context = { console, document, Image: FakeImage, LifecycleScope: FakeLifecycleScope, Date, JHS_Z_INDEX: { tooltip: 9999999999 }, utils: { isMobileMode: () => false }, setTimeout, clearTimeout }; context.window = context; context.innerWidth = 800; context.innerHeight = 600; context.requestAnimationFrame = (callback) => (frames.push(callback), frames.length); context.cancelAnimationFrame = () => {}; - const source = readFileSync(join(process.cwd(), "src/core/logger.js"), "utf8"), start = source.indexOf("window.ImageHoverPreview = class"), end = source.indexOf("}, async function()", start); + const source = readTestFile(join(process.cwd(), "src/core/logger.js"), "utf8"), start = source.indexOf("window.ImageHoverPreview = class"), end = source.indexOf("}, async function()", start); vm.runInContext(`${source.slice(start, end + 1)}; globalThis.TestImageHoverPreview = window.ImageHoverPreview;`, vm.createContext(context)); const flushFrames = () => { while (frames.length) frames.shift()(); }; return { Preview: context.TestImageHoverPreview, document, firstCover, createCover, head, images, flushFrames }; diff --git a/tests/integrations/av123.contract.test.js b/tests/integrations/av123.contract.test.js new file mode 100644 index 00000000..5c18b3ee --- /dev/null +++ b/tests/integrations/av123.contract.test.js @@ -0,0 +1,11 @@ +import jquery from "jquery"; +import { JSDOM } from "jsdom"; +import { expect, it } from "vitest"; +import { parse123AvCards } from "../../src/integrations/av123/parser.js"; + +it("normalizes 123AV cards without returning DOM", () => { + const dom = new JSDOM(''); + const cards = parse123AvCards(jquery(dom.window)(dom.window.document)); + expect(cards[0]).toMatchObject({ carNum: "FC2-123", title: "Title" }); + expect(cards[0]).not.toBeInstanceOf(dom.window.Node); +}); diff --git a/tests/integrations/av123.malformed.test.js b/tests/integrations/av123.malformed.test.js new file mode 100644 index 00000000..d69a9f7b --- /dev/null +++ b/tests/integrations/av123.malformed.test.js @@ -0,0 +1,9 @@ +import jquery from "jquery"; +import { JSDOM } from "jsdom"; +import { expect, it } from "vitest"; +import { parse123AvCards } from "../../src/integrations/av123/parser.js"; + +it("treats malformed 123AV cards as an empty contract", () => { + const dom = new JSDOM('
    '); + expect(parse123AvCards(jquery(dom.window)(dom.window.document))).toEqual([]); +}); diff --git a/tests/integrations/dmm.contract.test.js b/tests/integrations/dmm.contract.test.js new file mode 100644 index 00000000..0a674919 --- /dev/null +++ b/tests/integrations/dmm.contract.test.js @@ -0,0 +1,3 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { parseDmmPreview } from "../../src/integrations/dmm/parser.js"; +it("normalizes a DMM preview", () => { document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/dmm/preview.html"), "utf8"); expect(parseDmmPreview(jquery(document), "https://www.dmm.co.jp/").url).toBe("https://www.dmm.co.jp/preview/sample.mp4"); }); diff --git a/tests/integrations/dmm.malformed.test.js b/tests/integrations/dmm.malformed.test.js new file mode 100644 index 00000000..ddd2c9b0 --- /dev/null +++ b/tests/integrations/dmm.malformed.test.js @@ -0,0 +1,3 @@ +// @vitest-environment jsdom +import jquery from "jquery"; import { expect, it } from "vitest"; import { parseDmmPreview } from "../../src/integrations/dmm/parser.js"; +it("rejects malformed DMM preview HTML", () => { document.body.replaceChildren(); expect(() => parseDmmPreview(jquery(document), "https://www.dmm.co.jp/")).toThrow(/missing/); }); diff --git a/tests/integrations/fc2ppvdb.contract.test.js b/tests/integrations/fc2ppvdb.contract.test.js new file mode 100644 index 00000000..5652ad47 --- /dev/null +++ b/tests/integrations/fc2ppvdb.contract.test.js @@ -0,0 +1,3 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { parseFc2PpvDbDetail } from "../../src/integrations/fc2ppvdb/parser.js"; +it("normalizes an FC2PPVDB detail", () => { document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/fc2ppvdb/detail.html"), "utf8"); expect(parseFc2PpvDbDetail(jquery(document), "https://fc2ppvdb.com/articles/12345")).toMatchObject({ carNum: "FC2-12345", title: "Fixture FC2 title" }); }); diff --git a/tests/integrations/fc2ppvdb.malformed.test.js b/tests/integrations/fc2ppvdb.malformed.test.js new file mode 100644 index 00000000..c39cf551 --- /dev/null +++ b/tests/integrations/fc2ppvdb.malformed.test.js @@ -0,0 +1,3 @@ +// @vitest-environment jsdom +import jquery from "jquery"; import { expect, it } from "vitest"; import { parseFc2PpvDbDetail } from "../../src/integrations/fc2ppvdb/parser.js"; +it("rejects malformed FC2PPVDB HTML", () => { document.body.replaceChildren(); expect(() => parseFc2PpvDbDetail(jquery(document), "https://fc2ppvdb.com/articles/12345")).toThrow(/malformed/); }); diff --git a/tests/integrations/host-list.contract.test.js b/tests/integrations/host-list.contract.test.js new file mode 100644 index 00000000..61289ef3 --- /dev/null +++ b/tests/integrations/host-list.contract.test.js @@ -0,0 +1,9 @@ +import jquery from "jquery"; +import { JSDOM } from "jsdom"; +import { expect, it } from "vitest"; +import { parseDetailPage } from "../../src/integrations/host-list/parser.js"; + +it("normalizes a host list page state", () => { + const dom = new JSDOM('
    '); + expect(parseDetailPage(jquery(dom.window)(dom.window.document), { boxSelector: ".movie-list", requestDomItemSelector: ".item" })).toMatchObject({ state: "valid", isEmpty: false }); +}); diff --git a/tests/integrations/javbus.contract.test.js b/tests/integrations/javbus.contract.test.js new file mode 100644 index 00000000..4f02c9e6 --- /dev/null +++ b/tests/integrations/javbus.contract.test.js @@ -0,0 +1,14 @@ +// @vitest-environment jsdom + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import jquery from "jquery"; +import { describe, expect, it } from "vitest"; +import { parseJavBusMovieRef } from "../../src/integrations/javbus/parser.js"; + +describe("javbus normalized contract", () => { + it("returns a MovieRef from the fixture", () => { + document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/javbus/detail.html"), "utf8"); + expect(parseJavBusMovieRef(jquery(window), "https://www.javbus.com/ABC-123")).toEqual({ carNum: "ABC-123", url: "https://www.javbus.com/ABC-123" }); + }); +}); diff --git a/tests/integrations/javbus.malformed.test.js b/tests/integrations/javbus.malformed.test.js new file mode 100644 index 00000000..e92588ba --- /dev/null +++ b/tests/integrations/javbus.malformed.test.js @@ -0,0 +1,12 @@ +// @vitest-environment jsdom + +import jquery from "jquery"; +import { describe, expect, it } from "vitest"; +import { parseJavBusMovieRef } from "../../src/integrations/javbus/parser.js"; + +describe("javbus malformed response", () => { + it("rejects a response without an identifier", () => { + document.body.replaceChildren(); + expect(() => parseJavBusMovieRef(jquery(window), "https://www.javbus.com/")).toThrow(/identifier/); + }); +}); diff --git a/tests/integrations/javdb.contract.test.js b/tests/integrations/javdb.contract.test.js new file mode 100644 index 00000000..9fb0be26 --- /dev/null +++ b/tests/integrations/javdb.contract.test.js @@ -0,0 +1,9 @@ +import jquery from "jquery"; +import { JSDOM } from "jsdom"; +import { expect, it } from "vitest"; +import { parseJavDbActorList } from "../../src/integrations/javdb/parser.js"; + +it("normalizes JavDB actor contracts", () => { + const dom = new JSDOM(''); + expect(parseJavDbActorList(jquery(dom.window)(dom.window.document), "https://javdb.com")).toMatchObject({ state: "valid", actors: [{ starId: "a1", name: "Actor" }] }); +}); diff --git a/tests/integrations/javdb.malformed.test.js b/tests/integrations/javdb.malformed.test.js new file mode 100644 index 00000000..754e1b36 --- /dev/null +++ b/tests/integrations/javdb.malformed.test.js @@ -0,0 +1,9 @@ +import jquery from "jquery"; +import { JSDOM } from "jsdom"; +import { expect, it } from "vitest"; +import { parseJavDbActorList } from "../../src/integrations/javdb/parser.js"; + +it("classifies malformed JavDB actor pages", () => { + const dom = new JSDOM("
    "); + expect(parseJavDbActorList(jquery(dom.window)(dom.window.document), "https://javdb.com").state).toBe("invalid"); +}); diff --git a/tests/integrations/javstore.contract.test.js b/tests/integrations/javstore.contract.test.js new file mode 100644 index 00000000..ad7bc333 --- /dev/null +++ b/tests/integrations/javstore.contract.test.js @@ -0,0 +1,9 @@ +import jquery from "jquery"; +import { JSDOM } from "jsdom"; +import { expect, it } from "vitest"; +import { parseJavStoreSearch } from "../../src/integrations/javstore/parser.js"; + +it("normalizes JavStore search results", () => { + const dom = new JSDOM('ABC-123'); + expect(parseJavStoreSearch(jquery(dom.window)(dom.window.document), "ABC-123")).toEqual(["https://javstore.net/ABC-123-pn.html"]); +}); diff --git a/tests/integrations/javstore.malformed.test.js b/tests/integrations/javstore.malformed.test.js new file mode 100644 index 00000000..b9b070e6 --- /dev/null +++ b/tests/integrations/javstore.malformed.test.js @@ -0,0 +1,9 @@ +import jquery from "jquery"; +import { JSDOM } from "jsdom"; +import { expect, it } from "vitest"; +import { parseJavStoreSearch } from "../../src/integrations/javstore/parser.js"; + +it("rejects malformed JavStore candidates", () => { + const dom = new JSDOM('ABC-123'); + expect(parseJavStoreSearch(jquery(dom.window)(dom.window.document), "ABC-123")).toEqual([]); +}); diff --git a/tests/integrations/javtrailers.contract.test.js b/tests/integrations/javtrailers.contract.test.js new file mode 100644 index 00000000..a5987594 --- /dev/null +++ b/tests/integrations/javtrailers.contract.test.js @@ -0,0 +1,3 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { parseJavTrailersPreview } from "../../src/integrations/javtrailers/parser.js"; +it("normalizes a JavTrailers preview", () => { document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/javtrailers/preview.html"), "utf8"); expect(parseJavTrailersPreview(jquery(document), "https://javtrailers.com/").url).toBe("https://javtrailers.com/videos/sample.mp4"); }); diff --git a/tests/integrations/subtitlecat.contract.test.js b/tests/integrations/subtitlecat.contract.test.js new file mode 100644 index 00000000..362b41c7 --- /dev/null +++ b/tests/integrations/subtitlecat.contract.test.js @@ -0,0 +1,3 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { parseSubtitleCatResults } from "../../src/integrations/subtitlecat/parser.js"; +it("normalizes SubtitleCat results", () => { document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/subtitlecat/results.html"), "utf8"); expect(parseSubtitleCatResults(jquery(document), "https://subtitlecat.com/")[0]).toEqual({ language: "Chinese", url: "https://subtitlecat.com/subtitles/test-zh" }); }); diff --git a/tests/javdb-status-import.test.js b/tests/javdb-status-import.test.js index 14c17bbf..2e4fb13d 100644 --- a/tests/javdb-status-import.test.js +++ b/tests/javdb-status-import.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -12,7 +13,7 @@ function loadImporter(html = '
    jqueryFactory(dom.window)(new dom.window.DOMParser().parseFromString(value, "text/html")) }, show: { info: vi.fn(), ok: vi.fn(), error: vi.fn() }, clog: { error: vi.fn() }, loading: () => ({ close() {} }), i: (target, key, value) => (target[key] = value), setTimeout, URL }); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/status/want-and-watched-videos.js"), "utf8")};globalThis.Importer=WantAndWatchedVideosPlugin`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/status/want-and-watched-videos.js"), "utf8")};globalThis.Importer=WantAndWatchedVideosPlugin`, context); return { plugin: new context.Importer, patch, get }; } diff --git a/tests/legacy-storage-adapter.test.js b/tests/legacy-storage-adapter.test.js index efaf545e..1660426a 100644 --- a/tests/legacy-storage-adapter.test.js +++ b/tests/legacy-storage-adapter.test.js @@ -1,10 +1,11 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it, vi } from "vitest"; function createAdapter(existing = null) { - const source = readFileSync(join(import.meta.dirname, "../src/core/storage.js"), "utf8"), show = { error: vi.fn() }, patch = vi.fn(async () => ({ changed: [ "ABC-1" ], transactionId: "tx" })), context = vm.createContext({ + const source = readTestFile(join(import.meta.dirname, "../src/core/storage.js"), "utf8"), show = { error: vi.fn() }, patch = vi.fn(async () => ({ changed: [ "ABC-1" ], transactionId: "tx" })), context = vm.createContext({ WeakSet, WeakMap, Map, Set, Promise, Date, JSON, Object, Array, Math, a: message => { throw new TypeError(message); }, i: (target, key, value) => (target[key] = value), diff --git a/tests/list-sorting.test.js b/tests/list-sorting.test.js index c77c8355..6cb76baa 100644 --- a/tests/list-sorting.test.js +++ b/tests/list-sorting.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -13,7 +14,7 @@ function loadPlugin(url, html) { BasePlugin: class { getSelector() { return { boxSelector: ".movie-list", itemSelector: ".movie-list > .item" }; } }, clog: { error: vi.fn() } }); - const source = readFileSync(join(import.meta.dirname, "../src/plugins/status/list-page-button.js"), "utf8"); + const source = readTestFile(join(import.meta.dirname, "../src/plugins/status/list-page-button.js"), "utf8"); vm.runInContext(`${source};globalThis.Plugin=ListPageButtonPlugin`, context); return { $, plugin: new context.Plugin(), localStorage: dom.window.localStorage }; } diff --git a/tests/list-visibility.test.js b/tests/list-visibility.test.js index 60c95c9c..a428b096 100644 --- a/tests/list-visibility.test.js +++ b/tests/list-visibility.test.js @@ -1,10 +1,11 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it } from "vitest"; function loadVisibility() { - const source = readFileSync(join(process.cwd(), "src/plugins/status/list-page.js"), "utf8"), start = source.indexOf("const QUICK_FILTER_LABELS"), end = source.indexOf("class ListPagePlugin", start), context = vm.createContext({ _: "yes", C: "no", hasAnyState: flags => Object.values(flags).some(Boolean) }); + const source = readTestFile(join(process.cwd(), "src/plugins/status/list-page.js"), "utf8"), start = source.indexOf("const QUICK_FILTER_LABELS"), end = source.indexOf("class ListPagePlugin", start), context = vm.createContext({ _: "yes", C: "no", hasAnyState: flags => Object.values(flags).some(Boolean) }); vm.runInContext(`${source.slice(start, end)};globalThis.api={normalizeQuickFilterKey,isHardHidden,matchesQuickFilter,shouldShowItem,PRIMARY_QUICK_FILTERS,SECONDARY_QUICK_FILTERS}`, context); return context.api; } diff --git a/tests/list-workflow.test.js b/tests/list-workflow.test.js index a33efa09..e0208ba0 100644 --- a/tests/list-workflow.test.js +++ b/tests/list-workflow.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -16,7 +17,7 @@ function loadListButtonPlugin() { hasAnyState: flags => [ "favorite", "downloaded", "watched", "blocked" ].some((key => !0 === flags?.[key])), isHardHidden: (flags, reasons = {}) => Boolean(flags.blocked || reasons.keyword || reasons.actorBlacklist || reasons.actressBlacklist) }); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/plugins/status/list-page-button.js"), "utf8")};globalThis.Plugin=ListPageButtonPlugin`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/status/list-page-button.js"), "utf8")};globalThis.Plugin=ListPageButtonPlugin`, context); return { dom, $, plugin: new context.Plugin(), storageManager, show }; } diff --git a/tests/media-parser.test.js b/tests/media-parser.test.js index 14a09c68..c92c5ff9 100644 --- a/tests/media-parser.test.js +++ b/tests/media-parser.test.js @@ -1,8 +1,9 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const cases = JSON.parse(readFileSync(join(import.meta.dirname, "fixtures/media/cases.json"), "utf8")); -const helpers = readFileSync(join(import.meta.dirname, "../src/core/feature-helpers.js"), "utf8"); +const cases = JSON.parse(readTestFile(join(import.meta.dirname, "fixtures/media/cases.json"), "utf8")); +const helpers = readTestFile(join(import.meta.dirname, "../src/core/feature-helpers.js"), "utf8"); describe("media regression fixture matrix", () => { it("contains DMM, VR, FC2, uncensored and Mgstage branches", () => { for (const key of ["dmm", "vr", "fc2", "uncensored", "mgstage"]) expect(cases[key].length).toBeGreaterThan(0); }); it("uses candidate CID and centralized cover resolution", () => { expect(helpers).toContain("function normalizeDmmCid"); expect(helpers).toContain("function resolveHighResCover"); expect(helpers).not.toMatch(/NKNKJDVAJ.*===|MCHT.*===/); }); diff --git a/tests/media-playback.test.js b/tests/media-playback.test.js index 20dd39a7..e7b05c2d 100644 --- a/tests/media-playback.test.js +++ b/tests/media-playback.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const preview = readFileSync(join(import.meta.dirname, "../src/plugins/image-viewer/preview-video.js"), "utf8"); -const bus = readFileSync(join(import.meta.dirname, "../src/plugins/image-viewer/bus-preview-video.js"), "utf8"); +const preview = readTestFile(join(import.meta.dirname, "../src/plugins/image-viewer/preview-video.js"), "utf8"); +const bus = readTestFile(join(import.meta.dirname, "../src/plugins/image-viewer/bus-preview-video.js"), "utf8"); describe("preview playback contracts", () => { it("starts the native JavDB preview before awaiting DMM without taking over its source", () => { @@ -48,7 +49,7 @@ describe("preview playback contracts", () => { } }); it("keeps native play calls inside safePlay only", () => { - const allRuntime = [ preview, bus, readFileSync(join(import.meta.dirname, "../src/plugins/image-viewer/cover-button.js"), "utf8"), readFileSync(join(import.meta.dirname, "../src/plugins/status/list-page.js"), "utf8") ].join("\n"); + const allRuntime = [ preview, bus, readTestFile(join(import.meta.dirname, "../src/plugins/image-viewer/cover-button.js"), "utf8"), readTestFile(join(import.meta.dirname, "../src/plugins/status/list-page.js"), "utf8") ].join("\n"); expect(allRuntime).not.toMatch(/\.play\s*\(/); }); }); diff --git a/tests/migration-v2.test.js b/tests/migration-v2.test.js index ca04d0d1..a44e56b1 100644 --- a/tests/migration-v2.test.js +++ b/tests/migration-v2.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import { describe, expect, it, vi } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadMigration() { - const constants = readFileSync(join(repoRoot, "src/core/constants.js"), "utf8"), start = constants.indexOf("function normalizeCarNum"), end = constants.indexOf("function assertPageInfoContract", start), state = readFileSync(join(repoRoot, "src/core/state-model.js"), "utf8"), migration = readFileSync(join(repoRoot, "src/core/migration.js"), "utf8"), context = vm.createContext({ d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", CURRENT_DATA_VERSION: 2, Date, Object, Array, Map, Set, JSON, utils: { getNowStr: () => "2026-08-22" } }); + const constants = readTestFile(join(repoRoot, "src/core/constants.js"), "utf8"), start = constants.indexOf("function normalizeCarNum"), end = constants.indexOf("function assertPageInfoContract", start), state = readTestFile(join(repoRoot, "src/core/state-model.js"), "utf8"), migration = readTestFile(join(repoRoot, "src/core/migration.js"), "utf8"), context = vm.createContext({ d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", CURRENT_DATA_VERSION: 2, Date, Object, Array, Map, Set, JSON, utils: { getNowStr: () => "2026-08-22" } }); vm.runInContext(`${constants.slice(start, end)}\n${state}\n${migration}; globalThis.api = { run: runDataMigrations, validate: validatePortableData };`, context); return context.api; } diff --git a/tests/mobile-fab.test.js b/tests/mobile-fab.test.js index 07dcde03..0d4e91b1 100644 --- a/tests/mobile-fab.test.js +++ b/tests/mobile-fab.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -20,7 +21,7 @@ function loadMobilePlugin() { r: true, l: false, m: "屏蔽", v: "收藏", y: "下载", k: "观看", normalizeStateFlags: value => value || {}, storageManager: {}, stateService: {}, show: { info: vi.fn() }, utils: {}, clog: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, setTimeout, clearTimeout }); - const source = readFileSync(join(import.meta.dirname, "../src/plugins/status/mobile-bottom-bar.js"), "utf8"); + const source = readTestFile(join(import.meta.dirname, "../src/plugins/status/mobile-bottom-bar.js"), "utf8"); vm.runInContext(`${source};globalThis.TestMobilePlugin=MobileBottomBarPlugin`, context); return { $, listButtons, listPage, plugin: new context.TestMobilePlugin() }; } diff --git a/tests/models-profile.test.js b/tests/models-profile.test.js new file mode 100644 index 00000000..c5b5e6c7 --- /dev/null +++ b/tests/models-profile.test.js @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from "vitest"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; +import { SelectionModel } from "../src/core/selection-model.js"; +import { ProfileService } from "../src/services/profile-service.js"; +import { createJhsTable } from "../src/ui/table/create-jhs-table.js"; + +describe("selection, profile and table primitives", () => { + it("retains all-filtered selection across pages with explicit exclusions", () => { + const model = new SelectionModel((item) => item.id); + const items = Array.from({ length: 120 }, (_, index) => ({ id: String(index) })); + model.selectAllFiltered(); + model.set(items[42], false); + expect(model.values(items)).toHaveLength(119); + expect(model.has(items[42])).toBe(false); + }); + + it("recomputes compact/regular/wide from any-pointer and viewport changes", () => { + const listeners = new Map(); + const media = new EventTarget(); + Object.defineProperty(media, "matches", { value: true, writable: true }); + const windowRuntime = new EventTarget(); + Object.assign(windowRuntime, { innerWidth: 1000, innerHeight: 700, matchMedia: () => media }); + const scope = new LifecycleScope("profile"); + const service = new ProfileService({ windowRuntime, scope }); + service.addEventListener("profile.changed", (event) => listeners.set("last", event.detail)); + service.start(); + expect(service.current()).toBe("regular"); + windowRuntime.innerWidth = 460; + windowRuntime.dispatchEvent(new Event("resize")); + expect(service.current()).toBe("compact"); + expect(listeners.get("last")).toMatchObject({ profile: "compact" }); + scope.dispose(); + expect(scope.snapshot().listeners).toBe(0); + }); + + it("normalizes JhsTable page sizes without Tabulator's blank all option", () => { + const Tabulator = vi.fn(function(target, options) { this.target = target; this.options = options; }); + const table = createJhsTable(Tabulator, "#table", { paginationSizeSelector: [20, 50, true, 1000] }); + expect(table.options.paginationSizeSelector).toEqual([20, 50, 1000]); + }); +}); diff --git a/tests/new-video-inbox.test.js b/tests/new-video-inbox.test.js index 754cd716..ef7f0043 100644 --- a/tests/new-video-inbox.test.js +++ b/tests/new-video-inbox.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import { describe, expect, it } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadAggregator() { - const constants = readFileSync(join(repoRoot, "src/core/constants.js"), "utf8"), start = constants.indexOf("function normalizeCarNum"), end = constants.indexOf("function assertPageInfoContract", start), model = readFileSync(join(repoRoot, "src/core/state-model.js"), "utf8"), source = readFileSync(join(repoRoot, "src/plugins/new-video/new-video.js"), "utf8"), aggregateStart = source.indexOf("function aggregateNewVideoRecords"), aggregateEnd = source.indexOf("class NewVideoPlugin", aggregateStart), context = vm.createContext({ d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", Date, Object, Array, Map, Set, JSON }); + const constants = readTestFile(join(repoRoot, "src/core/constants.js"), "utf8"), start = constants.indexOf("function normalizeCarNum"), end = constants.indexOf("function assertPageInfoContract", start), model = readTestFile(join(repoRoot, "src/core/state-model.js"), "utf8"), source = readTestFile(join(repoRoot, "src/plugins/new-video/new-video.js"), "utf8"), aggregateStart = source.indexOf("function aggregateNewVideoRecords"), aggregateEnd = source.indexOf("class NewVideoPlugin", aggregateStart), context = vm.createContext({ d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", Date, Object, Array, Map, Set, JSON }); vm.runInContext(`${constants.slice(start, end)}\n${model}\n${source.slice(aggregateStart, aggregateEnd)}; globalThis.aggregate = aggregateNewVideoRecords;`, context); return context.aggregate; } diff --git a/tests/new-video-workspace.test.js b/tests/new-video-workspace.test.js index 3fe3ce3a..e90d54fe 100644 --- a/tests/new-video-workspace.test.js +++ b/tests/new-video-workspace.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -48,7 +49,7 @@ function createHarness() { T: "javdb", I: "javbus", D: "filter", A: "uncensored", _: "yes", l: false }); context.globalThis = context; - const source = readFileSync(join(repoRoot, "src/plugins/new-video/new-video.js"), "utf8"), start = source.indexOf("function aggregateNewVideoRecords"); + const source = readTestFile(join(repoRoot, "src/plugins/new-video/new-video.js"), "utf8"), start = source.indexOf("function aggregateNewVideoRecords"); vm.runInContext(`${source.slice(start)};globalThis.TestPlugin=NewVideoPlugin`, context); const plugin = new context.TestPlugin; plugin.nvWorkspaceMounted = true, plugin._viewMode = "list"; diff --git a/tests/offline-provider.test.js b/tests/offline-provider.test.js index 4915c3af..fc9854a2 100644 --- a/tests/offline-provider.test.js +++ b/tests/offline-provider.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,7 +7,7 @@ import { JSDOM } from "jsdom"; import { describe, expect, it, vi } from "vitest"; function loadRegistry() { - const source = readFileSync(join(import.meta.dirname, "../src/plugins/offline/unified-offline.js"), "utf8"), end = source.indexOf("class UnifiedOfflinePlugin"), context = vm.createContext({ Map, Array, Date, TypeError }); + const source = readTestFile(join(import.meta.dirname, "../src/plugins/offline/unified-offline.js"), "utf8"), end = source.indexOf("class UnifiedOfflinePlugin"), context = vm.createContext({ Map, Array, Date, TypeError }); vm.runInContext(`${source.slice(0, end)}; globalThis.Registry = OfflineProviderRegistry;`, context); return context.Registry; } @@ -21,7 +22,7 @@ function loadOfflinePlugin(submit, history = vi.fn(async () => {})) { show: { ok: vi.fn(), error: vi.fn() }, utils: { q: vi.fn() }, storageManager: {}, layer: {}, OneOneFiveClient: class {}, getDetailResourceAdapter: vi.fn(), jhsEventBus: { on: vi.fn() } }); - const source = readFileSync(join(import.meta.dirname, "../src/plugins/offline/unified-offline.js"), "utf8"); + const source = readTestFile(join(import.meta.dirname, "../src/plugins/offline/unified-offline.js"), "utf8"); vm.runInContext(`${source};globalThis.TestOfflinePlugin=UnifiedOfflinePlugin;`, context); const plugin = new context.TestOfflinePlugin(), provider = { id: "115", name: "115", submit }; plugin.registry = { getCandidates: vi.fn(async () => [ { provider, availability: { authState: "ready" } } ]), updateAvailability: vi.fn() }; diff --git a/tests/one-one-five.test.js b/tests/one-one-five.test.js index 032cd375..aa3295a9 100644 --- a/tests/one-one-five.test.js +++ b/tests/one-one-five.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,7 +6,7 @@ import { describe, expect, it, vi } from "vitest"; function load115(http = {}) { const context = vm.createContext({ URLSearchParams, encodeURIComponent, gmHttp: http, normalizeCarNum: value => typeof value === "string" && value.trim() ? value.trim() : null, ProviderError: class extends Error { constructor(provider, code, message, options) { super(message); Object.assign(this, { provider, code, ...options }); } } }); - const source = readFileSync(join(import.meta.dirname, "../src/plugins/one-one-five/client.js"), "utf8"); + const source = readTestFile(join(import.meta.dirname, "../src/plugins/one-one-five/client.js"), "utf8"); vm.runInContext(`${source};globalThis.api={OneOneFiveClient,normalize115Keyword,build115PlayUrl,preview115Rename}`, context); return context.api; } @@ -23,7 +24,7 @@ describe("115 domain", () => { expect(api.preview115Rename("old-4K-U.mkv", "abc-1")).toBe("ABC-1-4K-U.mkv"); }); it("keeps disabled plugins request-free", () => { - const source = readFileSync(join(import.meta.dirname, "../src/plugins/one-one-five/plugins.js"), "utf8"); + const source = readTestFile(join(import.meta.dirname, "../src/plugins/one-one-five/plugins.js"), "utf8"); expect(source).toContain('getSetting("enable115Match", !1)'); expect(source).toContain("return this.setupListMatching()"); expect(source).toContain('jhsEventBus.on("list-items-added"'); diff --git a/tests/parser-fixtures.test.js b/tests/parser-fixtures.test.js index af096001..fe8acbe7 100644 --- a/tests/parser-fixtures.test.js +++ b/tests/parser-fixtures.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,8 +6,8 @@ import jquery from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it } from "vitest"; -const fixture = (name) => readFileSync(join(import.meta.dirname, "fixtures", name), "utf8"); -const source = readFileSync(join(import.meta.dirname, "../src/parsers/third-party-parsers.js"), "utf8"); +const fixture = (name) => readTestFile(join(import.meta.dirname, "fixtures", name), "utf8"); +const source = ["javstore", "javdb", "host-list", "av123"].map((id) => readTestFile(join(import.meta.dirname, `../src/integrations/${id}/parser.js`), "utf8")).join("\n"); function loadParsers(html) { const dom = new JSDOM(html, { url: "https://javdb.com/" }), $ = jquery(dom.window); diff --git a/tests/performance-hotpaths.test.js b/tests/performance-hotpaths.test.js index 0e6f34e9..1144d484 100644 --- a/tests/performance-hotpaths.test.js +++ b/tests/performance-hotpaths.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -24,9 +25,10 @@ function loadTaskLifecycle({ isListPage = false, hidden = false } = {}) { i: (target, key, value) => (target[key] = value) }); const source = [ - readFileSync(join(repoRoot, "src/core/site-context.js"), "utf8"), - readFileSync(join(repoRoot, "src/parsers/third-party-parsers.js"), "utf8"), - readFileSync(join(repoRoot, "src/plugins/new-video/task.js"), "utf8"), + readTestFile(join(repoRoot, "src/core/site-context.js"), "utf8"), + readTestFile(join(repoRoot, "src/integrations/javdb/parser.js"), "utf8"), + readTestFile(join(repoRoot, "src/integrations/host-list/parser.js"), "utf8"), + readTestFile(join(repoRoot, "src/plugins/new-video/task.js"), "utf8"), "globalThis.TestTaskPlugin=TaskPlugin;" ].join("\n"); vm.runInContext(source, context); @@ -49,12 +51,12 @@ function loadListObserver() { normalizeStateFlags: flags => Object.fromEntries([ "favorite", "downloaded", "watched", "blocked" ].map((key => [ key, !0 === flags?.[key] ]))), hasAnyState: flags => [ "favorite", "downloaded", "watched", "blocked" ].some((key => !0 === flags?.[key])) }); - vm.runInContext(`${readFileSync(join(repoRoot, "src/plugins/status/list-page.js"), "utf8")};globalThis.TestListPagePlugin=ListPagePlugin;`, context); + vm.runInContext(`${readTestFile(join(repoRoot, "src/plugins/status/list-page.js"), "utf8")};globalThis.TestListPagePlugin=ListPagePlugin;`, context); return { dom, plugin: new context.TestListPagePlugin(), $, fetch, mapLimit, storageManager, clog: context.clog }; } function initializeAccessibilityDom(html) { - const dom = new JSDOM(html), source = readFileSync(join(repoRoot, "src/core/ui-primitives.js"), "utf8"), start = source.indexOf("function initializeUiAccessibility"), end = source.indexOf("class JhsSelect", start), context = vm.createContext({ + const dom = new JSDOM(html), source = readTestFile(join(repoRoot, "src/core/ui-primitives.js"), "utf8"), start = source.indexOf("function initializeUiAccessibility"), end = source.indexOf("class JhsSelect", start), context = vm.createContext({ document: dom.window.document, Node: dom.window.Node, MutationObserver: dom.window.MutationObserver, queueMicrotask }); vm.runInContext(`${source.slice(start, end)};initializeUiAccessibility();`, context); diff --git a/tests/provider-registries.test.js b/tests/provider-registries.test.js index eb6b32dd..a707e43a 100644 --- a/tests/provider-registries.test.js +++ b/tests/provider-registries.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -5,7 +6,7 @@ import { describe, expect, it, vi } from "vitest"; function load(path, exports, extra = {}) { const context = vm.createContext({ URL, console, clog: { warn() {} }, ...extra }); - vm.runInContext(`${readFileSync(join(import.meta.dirname, "..", path), "utf8")};globalThis.result={${exports.join(",")}}`, context); + vm.runInContext(`${readTestFile(join(import.meta.dirname, "..", path), "utf8")};globalThis.result={${exports.join(",")}}`, context); return context.result; } diff --git a/tests/release-ui-contracts.test.js b/tests/release-ui-contracts.test.js index 9ea75a5f..f088a336 100644 --- a/tests/release-ui-contracts.test.js +++ b/tests/release-ui-contracts.test.js @@ -1,8 +1,9 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const read = file => readFileSync(join(process.cwd(), file), "utf8"); +const read = file => readTestFile(join(process.cwd(), file), "utf8"); describe("v6.4.1 frozen UI contracts", () => { const newVideo = read("src/plugins/new-video/new-video.js"); diff --git a/tests/resource-settings.test.js b/tests/resource-settings.test.js index 302da344..2eb78be6 100644 --- a/tests/resource-settings.test.js +++ b/tests/resource-settings.test.js @@ -1,10 +1,11 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it, vi } from "vitest"; -const registry = readFileSync(join(import.meta.dirname, "../src/plugins/external-search/magnet-source-registry.js"), "utf8"); -const service = readFileSync(join(import.meta.dirname, "../src/plugins/backup/resource-settings.js"), "utf8"); +const registry = readTestFile(join(import.meta.dirname, "../src/plugins/external-search/magnet-source-registry.js"), "utf8"); +const service = readTestFile(join(import.meta.dirname, "../src/plugins/backup/resource-settings.js"), "utf8"); function load(initial = {}) { const values = new Map(Object.entries(initial)), storage = { getSetting: vi.fn(async (key, fallback) => values.has(key) ? values.get(key) : fallback), saveSettingItem: vi.fn(async (key, value) => values.set(key, value)) }; const context = vm.createContext({ URL, storageManager: storage, Date }); @@ -30,7 +31,7 @@ describe("resource settings service", () => { }); describe("resource settings UI contracts", () => { - const template = readFileSync(join(import.meta.dirname, "../src/plugins/backup/setting-templates.js"), "utf8"), setting = readFileSync(join(import.meta.dirname, "../src/plugins/backup/setting.js"), "utf8"); + const template = readTestFile(join(import.meta.dirname, "../src/plugins/backup/setting-templates.js"), "utf8"), setting = readTestFile(join(import.meta.dirname, "../src/plugins/backup/setting.js"), "utf8"); it("keeps JSON advanced and separates cloud and data tools", () => { expect(template).not.toContain("自定义磁力源 JSON"); expect(template).toContain('id="cloud-services-panel"'); expect(template).toContain('id="data-tools-panel"'); expect(template).toContain("高级 · 导入 / 导出配置"); }); it("custom delete requires confirmation and built-ins are not deletable", () => { expect(setting).toContain('utils.q(event, `确认删除来源'); expect(setting).toContain("card(source, false"); }); it("car import requires preview before confirm", () => { expect(template).toContain('id="confirm-car-number-import" class="jhs-btn jhs-btn--primary" disabled'); expect(setting).toContain('if (!this.pendingCarImport) return show.info("请先解析预览")'); }); diff --git a/tests/settings-invalidation.test.js b/tests/settings-invalidation.test.js index 114cb5cd..9e395516 100644 --- a/tests/settings-invalidation.test.js +++ b/tests/settings-invalidation.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const storage = readFileSync(join(process.cwd(), "src/core/storage.js"), "utf8"); -const settingForms = readFileSync(join(process.cwd(), "src/plugins/backup/setting-forms.js"), "utf8"); +const storage = readTestFile(join(process.cwd(), "src/core/storage.js"), "utf8"); +const settingForms = readTestFile(join(process.cwd(), "src/plugins/backup/setting-forms.js"), "utf8"); function methodBody(source, start, end) { return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start))); diff --git a/tests/site-context.test.js b/tests/site-context.test.js index e559cb1b..10b9f6c6 100644 --- a/tests/site-context.test.js +++ b/tests/site-context.test.js @@ -1,9 +1,10 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; import { describe, expect, it } from "vitest"; -const source = readFileSync(join(import.meta.dirname, "../src/core/site-context.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/core/site-context.js"), "utf8"); const context = vm.createContext({ URL, window: { location: new URL("https://javdb.com/") } }); vm.runInContext(`${source}; globalThis.detectSiteForTest = detectSite;`, context); const detectSite = context.detectSiteForTest; diff --git a/tests/startup-performance.test.js b/tests/startup-performance.test.js index 43ff6d95..2cf3598c 100644 --- a/tests/startup-performance.test.js +++ b/tests/startup-performance.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; @@ -20,7 +21,7 @@ function loadPluginClasses() { clog: { error: vi.fn() }, i: (target, key, value) => (target[key] = value) }); - const source = `${readFileSync(join(repoRoot, "src/core/plugin-manager.js"), "utf8")}\nglobalThis.TestPluginManager = PluginManager; globalThis.TestBasePlugin = BasePlugin;`; + const source = `${readTestFile(join(repoRoot, "src/core/plugin-manager.js"), "utf8")}\nglobalThis.TestPluginManager = PluginManager; globalThis.TestBasePlugin = BasePlugin;`; vm.runInContext(source, context); return { PluginManager: context.TestPluginManager, BasePlugin: context.TestBasePlugin, idleCallbacks, insertStyle }; } @@ -34,7 +35,7 @@ function loadStorageManager(forage) { }, i: (target, key, value) => (target[key] = value) }); - const source = `${readFileSync(join(repoRoot, "src/core/storage.js"), "utf8")}\nglobalThis.TestStorageManager = StorageManager;`; + const source = `${readTestFile(join(repoRoot, "src/core/storage.js"), "utf8")}\nglobalThis.TestStorageManager = StorageManager;`; vm.runInContext(source, context); return new context.TestStorageManager(); } @@ -58,15 +59,15 @@ function loadTaskPlugin(gmHttp, overrides = {}) { storageManager: overrides.storageManager || {}, $: () => ({ text: vi.fn() }) }); - const parsers = readFileSync(join(repoRoot, "src/parsers/third-party-parsers.js"), "utf8"); - const source = `${parsers}\n${readFileSync(join(repoRoot, "src/plugins/new-video/task.js"), "utf8")}\nglobalThis.TestTaskPlugin = TaskPlugin;`; + const parsers = ["src/integrations/javdb/parser.js", "src/integrations/host-list/parser.js"].map((file) => readTestFile(join(repoRoot, file), "utf8")).join("\n"); + const source = `${parsers}\n${readTestFile(join(repoRoot, "src/plugins/new-video/task.js"), "utf8")}\nglobalThis.TestTaskPlugin = TaskPlugin;`; vm.runInContext(source, context); return new context.TestTaskPlugin(); } function loadStorageQueue() { const context = vm.createContext({ clog: { error: vi.fn() } }); - const queueSource = readFileSync(join(repoRoot, "src/plugins/external-search/other-site.js"), "utf8").split("class OtherSitePlugin")[0]; + const queueSource = readTestFile(join(repoRoot, "src/plugins/external-search/other-site.js"), "utf8").split("class OtherSitePlugin")[0]; vm.runInContext(`${queueSource}\nglobalThis.TestStorageQueue = StorageQueue;`, context); return { Queue: context.TestStorageQueue, error: context.clog.error }; } @@ -98,8 +99,8 @@ function loadHttpManager(requestHandler) { }; context.window = context; context.unsafeWindow = context; - vm.runInContext(readFileSync(join(repoRoot, "src/core/http.js"), "utf8"), vm.createContext(context)); - return context.gmHttp; + vm.runInContext(`${readTestFile(join(repoRoot, "src/core/http.js"), "utf8")};globalThis.testGmHttp=gmHttp`, vm.createContext(context)); + return context.testGmHttp; } describe("startup scheduling", () => { @@ -190,9 +191,9 @@ describe("startup scheduling", () => { }); it("does not include removed legacy service integrations", () => { - const mainSource = readFileSync(join(repoRoot, "src/main.js"), "utf8"); - const registrySource = readFileSync(join(repoRoot, "src/plugins/registry.js"), "utf8"); - const utilsSource = readFileSync(join(repoRoot, "src/core/utils.js"), "utf8"); + const mainSource = readTestFile(join(repoRoot, "src/main.js"), "utf8"); + const registrySource = readTestFile(join(repoRoot, "src/plugins/registry.js"), "utf8"); + const utilsSource = readTestFile(join(repoRoot, "src/core/utils.js"), "utf8"); expect(mainSource).not.toContain("parallel_GM_xmlhttpRequest.js"); expect(mainSource).not.toContain("@connect 127.0.0.1"); @@ -210,7 +211,7 @@ describe("startup scheduling", () => { "src/plugins/image-viewer/screenshot.js", "src/plugins/status/auto-page.js" ]; - const source = sourceFiles.map((file) => readFileSync(join(repoRoot, file), "utf8")).join("\n"); + const source = sourceFiles.map((file) => readTestFile(join(repoRoot, file), "utf8")).join("\n"); const removedMethods = [ "getUsedDomains", "postForm", "postFileFormData", "downloadFileInChunks", "getActressMap", "getThirdPartyCacheStats", "resetCacheHitStats", diff --git a/tests/state-model.test.js b/tests/state-model.test.js index 17ac3b3d..d567a940 100644 --- a/tests/state-model.test.js +++ b/tests/state-model.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,8 +7,8 @@ import { describe, expect, it } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadStateModel() { - const constants = readFileSync(join(repoRoot, "src/core/constants.js"), "utf8"), normalizeStart = constants.indexOf("function normalizeCarNum"), normalizeEnd = constants.indexOf("function assertPageInfoContract", normalizeStart); - const source = readFileSync(join(repoRoot, "src/core/state-model.js"), "utf8"), context = vm.createContext({ d: "filter", h: "favorite", g: "hasDown", p: "hasWatch" }); + const constants = readTestFile(join(repoRoot, "src/core/constants.js"), "utf8"), normalizeStart = constants.indexOf("function normalizeCarNum"), normalizeEnd = constants.indexOf("function assertPageInfoContract", normalizeStart); + const source = readTestFile(join(repoRoot, "src/core/state-model.js"), "utf8"), context = vm.createContext({ d: "filter", h: "favorite", g: "hasDown", p: "hasWatch" }); vm.runInContext(`${constants.slice(normalizeStart, normalizeEnd)}\n${source}; globalThis.api = { normalizeCarNum, createEmptyStateFlags, stateFlagsFromLegacyStatus, normalizeStateFlags, projectLegacyStatus, syncLegacyStatus, hasAnyState, mergeCanonicalCarRecords };`, context); return context.api; } diff --git a/tests/state-service.test.js b/tests/state-service.test.js index 2d753234..1c117a1f 100644 --- a/tests/state-service.test.js +++ b/tests/state-service.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -6,8 +7,8 @@ import { describe, expect, it, vi } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadStateService() { - const constants = readFileSync(join(repoRoot, "src/core/constants.js"), "utf8"), normalizeStart = constants.indexOf("function normalizeCarNum"), normalizeEnd = constants.indexOf("function assertPageInfoContract", normalizeStart); - const model = readFileSync(join(repoRoot, "src/core/state-model.js"), "utf8"), service = readFileSync(join(repoRoot, "src/core/state-service.js"), "utf8"), serviceEnd = service.indexOf("const stateService"); + const constants = readTestFile(join(repoRoot, "src/core/constants.js"), "utf8"), normalizeStart = constants.indexOf("function normalizeCarNum"), normalizeEnd = constants.indexOf("function assertPageInfoContract", normalizeStart); + const model = readTestFile(join(repoRoot, "src/core/state-model.js"), "utf8"), service = readTestFile(join(repoRoot, "src/core/state-service.js"), "utf8"), serviceEnd = service.indexOf("const stateService"); const context = vm.createContext({ d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", Date, Object, Array, Map, Set, JSON, Math, window: { location: { href: "https://javdb.example/v/1" } }, crypto: { randomUUID: vi.fn().mockImplementation((() => `id-${Math.random()}`)) }, utils: { getNowStr: () => "2026-08-22 12:00:00" } }); vm.runInContext(`${constants.slice(normalizeStart, normalizeEnd)}\n${model}\n${service.slice(0, serviceEnd)}; globalThis.StateServiceClass = StateService;`, context); return context.StateServiceClass; diff --git a/tests/stats-scope.test.js b/tests/stats-scope.test.js index f17d7829..6b945add 100644 --- a/tests/stats-scope.test.js +++ b/tests/stats-scope.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -30,7 +31,7 @@ function loadStatsPlugin() { normalizeStateFlags: flags => ({ blocked: !!flags?.blocked, favorite: !!flags?.favorite, downloaded: !!flags?.downloaded, watched: !!flags?.watched }), hasAnyState: flags => Object.values(flags).some(Boolean), escapeHtml: value => String(value), r: true, l: false }); - const source = readFileSync(join(import.meta.dirname, "../src/plugins/stats/stats.js"), "utf8"); + const source = readTestFile(join(import.meta.dirname, "../src/plugins/stats/stats.js"), "utf8"); vm.runInContext(`${source};globalThis.TestStatsPlugin=StatsPlugin`, context); return { $, layer, listPage, newVideo, plugin: new context.TestStatsPlugin() }; } diff --git a/tests/storage-last-publish.test.js b/tests/storage-last-publish.test.js index 7ed1b9e3..f804ec9f 100644 --- a/tests/storage-last-publish.test.js +++ b/tests/storage-last-publish.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -15,7 +16,7 @@ function createStorage(initial) { normalizeCarNum: value => String(value || "").trim().toUpperCase().replace(/[_\s]+/g, "-"), escapeHtml: value => String(value || ""), d: "filter", CURRENT_DATA_VERSION: 2, PORTABLE_DATA_KEYS: [], hasPortableUserData: async () => false, validatePortableData() {}, runDataMigrations: async () => {}, stateService: {} }); - const source = [ "../src/core/storage-index.js", "../src/core/storage.js" ].map(file => readFileSync(join(import.meta.dirname, file), "utf8")).join("\n"); + const source = [ "../src/core/storage-index.js", "../src/core/storage.js" ].map(file => readTestFile(join(import.meta.dirname, file), "utf8")).join("\n"); vm.runInContext(`${source};globalThis.Storage=StorageManager`, context); return { storage: new context.Storage, data, emit }; } diff --git a/tests/task-scheduler.test.js b/tests/task-scheduler.test.js index 2906f13a..a54cf198 100644 --- a/tests/task-scheduler.test.js +++ b/tests/task-scheduler.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -63,7 +64,7 @@ function createHarness(initialTime = "2026-08-23T13:20:00.789", pageUrl = "https setTimeout, clearTimeout }); context.globalThis = context; - const source = [ "src/core/site-context.js", "src/core/feature-helpers.js", "src/parsers/third-party-parsers.js", "src/plugins/new-video/task.js" ].map(file => readFileSync(join(repoRoot, file), "utf8")).join("\n"); + const source = [ "src/core/site-context.js", "src/core/feature-helpers.js", "src/integrations/javdb/parser.js", "src/integrations/host-list/parser.js", "src/plugins/new-video/task.js" ].map(file => readTestFile(join(repoRoot, file), "utf8")).join("\n"); vm.runInContext(`${source};globalThis.Task=TaskPlugin`, context); const plugin = new context.Task; return { plugin, clock, values, settings, favorites, blacklistItems, storageManager, gmHttp, beans, locks, jhsEventBus, $, htmlToPage: context.utils.htmlTo$dom }; diff --git a/tests/ui-architecture.test.js b/tests/ui-architecture.test.js index 8ccd584e..5c195cde 100644 --- a/tests/ui-architecture.test.js +++ b/tests/ui-architecture.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -18,7 +19,7 @@ describe("dialog preset sizing", () => { }); describe("detail workspace adapters", () => { - const source = readFileSync(join(process.cwd(), "src/plugins/status/detail-workspace.js"), "utf8"); + const source = readTestFile(join(process.cwd(), "src/plugins/status/detail-workspace.js"), "utf8"); it("keeps the protected JavDB controller and resource root as an adapter boundary", () => { expect(source).toContain('[data-controller="magnet-sort"]'); expect(source).toContain('controller.find("#magnets-content")'); @@ -37,20 +38,20 @@ describe("detail workspace adapters", () => { }); describe("list toolbar and UI cleanup contracts", () => { - const commandbar = readFileSync(join(process.cwd(), "src/plugins/status/mobile-bottom-bar.js"), "utf8"); - const hitShow = readFileSync(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); - const translate = readFileSync(join(process.cwd(), "src/plugins/translate/translate.js"), "utf8"); - const settings = readFileSync(join(process.cwd(), "src/plugins/backup/setting-templates.js"), "utf8"); - const settingStyles = readFileSync(join(process.cwd(), "src/plugins/backup/setting-styles.js"), "utf8"); - const pluginPanels = readFileSync(join(process.cwd(), "src/plugins/backup/setting-panels.js"), "utf8"); - const reviews = readFileSync(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8"); - const oneTwoThreeOffline = readFileSync(join(process.cwd(), "src/plugins/one-two-three/offline.js"), "utf8"); - const newVideo = readFileSync(join(process.cwd(), "src/plugins/new-video/new-video.js"), "utf8"); - const related = readFileSync(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8"); - const settingForms = readFileSync(join(process.cwd(), "src/plugins/backup/setting-forms.js"), "utf8"); - const listButtons = readFileSync(join(process.cwd(), "src/plugins/status/list-page-button.js"), "utf8"); - const coverButtons = readFileSync(join(process.cwd(), "src/plugins/image-viewer/cover-button.js"), "utf8"); - const injection = readFileSync(join(process.cwd(), "src/core/css-injection.js"), "utf8"); + const commandbar = readTestFile(join(process.cwd(), "src/plugins/status/mobile-bottom-bar.js"), "utf8"); + const hitShow = readTestFile(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); + const translate = readTestFile(join(process.cwd(), "src/plugins/translate/translate.js"), "utf8"); + const settings = readTestFile(join(process.cwd(), "src/plugins/backup/setting-templates.js"), "utf8"); + const settingStyles = readTestFile(join(process.cwd(), "src/plugins/backup/setting-styles.js"), "utf8"); + const pluginPanels = readTestFile(join(process.cwd(), "src/plugins/backup/setting-panels.js"), "utf8"); + const reviews = readTestFile(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8"); + const oneTwoThreeOffline = readTestFile(join(process.cwd(), "src/plugins/one-two-three/offline.js"), "utf8"); + const newVideo = readTestFile(join(process.cwd(), "src/plugins/new-video/new-video.js"), "utf8"); + const related = readTestFile(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8"); + const settingForms = readTestFile(join(process.cwd(), "src/plugins/backup/setting-forms.js"), "utf8"); + const listButtons = readTestFile(join(process.cwd(), "src/plugins/status/list-page-button.js"), "utf8"); + const coverButtons = readTestFile(join(process.cwd(), "src/plugins/image-viewer/cover-button.js"), "utf8"); + const injection = readTestFile(join(process.cwd(), "src/core/css-injection.js"), "utf8"); it("builds the command bar after plugin initialization and keeps semantic actions separate", () => { expect(commandbar).toContain("async afterPluginsReady()"); @@ -133,7 +134,7 @@ describe("list toolbar and UI cleanup contracts", () => { }); it("shares quick settings across desktop and mobile without a mobile navbar trigger", () => { - const settingPlugin = readFileSync(join(process.cwd(), "src/plugins/backup/setting.js"), "utf8"); + const settingPlugin = readTestFile(join(process.cwd(), "src/plugins/backup/setting.js"), "utf8"); expect(settingPlugin).toContain("if (utils.isMobileMode()) return;"); expect(settingPlugin).toContain("openQuickSetting()"); expect(settingPlugin).toContain("buildQuickSettingHtml()"); diff --git a/tests/utils.test.js b/tests/utils.test.js index 3e2d314c..0d2f145b 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -1,3 +1,4 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import vm from "node:vm"; @@ -16,7 +17,7 @@ function loadUtilsWithObserver() { setTimeout, clearTimeout, setInterval, clearInterval, clog: { error: vi.fn() }, i: (target, key, value) => (target[key] = value) }); - vm.runInContext(`${readFileSync(join(process.cwd(), "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils;`, context); + vm.runInContext(`${readTestFile(join(process.cwd(), "src/core/utils.js"), "utf8")};globalThis.TestUtils=Utils;`, context); return { utils: new context.TestUtils(), getObserver: () => observer }; } diff --git a/tests/webdav-compat.test.js b/tests/webdav-compat.test.js index 15288c1f..7ee2c552 100644 --- a/tests/webdav-compat.test.js +++ b/tests/webdav-compat.test.js @@ -1,8 +1,9 @@ +import { readTestFile } from "./helpers/read-test-file.js"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const source = readFileSync(join(import.meta.dirname, "../src/plugins/backup/webdav-client.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/plugins/backup/webdav-client.js"), "utf8"); describe("WebDAV compatibility contracts", () => { it("accepts existing and parent-conflict MKCOL responses", () => expect(source).toMatch(/405\|409/)); it("falls back from missing displayname to decoded href", () => { diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json new file mode 100644 index 00000000..000484d9 --- /dev/null +++ b/tsconfig.architecture.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true + }, + "include": [ + "src/app/**/*.js", + "src/contracts/**/*.js", + "src/core/jhs-error.js", + "src/core/cache-policy.js", + "src/core/event-bus.js", + "src/core/legacy-plugin-contributions.js", + "src/core/lifecycle-scope.js", + "src/core/movie-identity.js", + "src/core/selection-model.js", + "src/features/**/*.js", + "src/integrations/**/*.js", + "src/platform/**/*.js", + "src/services/**/*.js", + "src/ui/**/*.js", + "src/plugins/dependency-map.js" + ], + "exclude": ["src/app/bootstrap.js"] +} From 89747c72fe4abb5822b1edae05bacb510703aa6d Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:40:16 +0800 Subject: [PATCH 002/130] =?UTF-8?q?Refactor:=20=E6=8E=A8=E8=BF=9B=20v6.5?= =?UTF-8?q?=20=E6=9C=8D=E5=8A=A1=E4=B8=8E=E9=9B=86=E6=88=90=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 建立严格 Contribution 依赖注入、HostAdapter 与 Diagnostics 迁移边界 - 将 JavDB、JavBus、AV123、DMM、FC2、JavStore、Wikipedia、翻译与 WebDAV 请求迁入 Integration/Service - 补充本地 Edge Browser Harness、质量门禁、checkJs 覆盖和兼容回归测试 修改原因: - 降低 Feature 对 GM 请求、第三方协议、存储和其他插件实例的直接耦合 - 保持 6.4.1 版本、数据契约和本地无浏览器下载策略 验证: - npm run check 通过(89 个测试文件,518 项测试) - npm run check:browser 通过(13 passed,5 skipped,使用本机 Edge) - git diff --check、codegraph sync 通过 - 根目录与 dist 产物 SHA256 一致 --- JHS.user.js | 4430 ++++++++++------- scripts/architecture-check.mjs | 4 +- scripts/checkjs-coverage.mjs | 10 +- scripts/integration-quality-check.mjs | 7 + scripts/regression-check.mjs | 30 +- scripts/ui-consistency-audit.mjs | 22 +- src/app/bootstrap.js | 29 +- src/app/create-app-context.js | 19 +- src/app/feature-runtime.js | 21 + src/app/integration-catalog.js | 5 +- src/contracts/manifests.js | 12 + src/contracts/tokens.js | 3 + src/core/credential-crypto.js | 46 + src/core/javdb-api.js | 81 +- src/core/legacy-plugin-contributions.js | 33 + src/core/plugin-manager.js | 19 +- src/core/site-context.js | 15 +- src/core/theme.js | 15 +- src/features/catalog.js | 3 +- src/features/detail/manifest.js | 2 +- src/features/legacy-capabilities.js | 18 + src/features/system/catalog.js | 6 +- src/integrations/av123/manifest.js | 76 +- src/integrations/av123/parser.js | 41 +- src/integrations/dmm/manifest.js | 28 +- src/integrations/dmm/parser.js | 13 +- src/integrations/fc2content/manifest.js | 34 + src/integrations/fc2content/parser.js | 11 + src/integrations/fc2ppvdb/manifest.js | 42 +- src/integrations/fc2ppvdb/parser.js | 26 +- src/integrations/google-translate/manifest.js | 40 + src/integrations/google-translate/parser.js | 9 + src/integrations/javbus/manifest.js | 35 +- src/integrations/javbus/parser.js | 39 +- src/integrations/javdb/manifest.js | 106 +- src/integrations/javdb/signature.js | 15 + src/integrations/javstore/manifest.js | 37 +- src/integrations/javstore/parser.js | 36 +- src/integrations/subtitlecat/manifest.js | 16 +- src/integrations/wikipedia/manifest.js | 29 + src/integrations/wikipedia/parser.js | 29 + src/platform/hosts/javbus-host-adapter.js | 12 +- src/platform/hosts/javdb-host-adapter.js | 12 +- .../userscript/userscript-http-adapter.js | 3 +- src/plugins/avatar/actress-info.js | 116 +- src/plugins/backup/setting-backup.js | 15 +- src/plugins/backup/setting-forms.js | 32 +- src/plugins/backup/setting-panels.js | 17 +- src/plugins/backup/setting-styles.js | 4 +- src/plugins/backup/setting.js | 29 +- src/plugins/backup/webdav-client.js | 83 - src/plugins/blacklist/blacklist.js | 3 +- src/plugins/dependency-map.js | 6 +- src/plugins/external-search/fc2-by-123av.js | 88 +- src/plugins/external-search/fc2.js | 50 +- src/plugins/external-search/hit-show.js | 27 +- src/plugins/external-search/other-site.js | 28 +- src/plugins/external-search/related.js | 33 +- src/plugins/external-search/review.js | 35 +- src/plugins/external-search/top250.js | 2 +- src/plugins/image-viewer/bus-preview-video.js | 63 +- .../screenshot-provider-registry.js | 8 +- src/plugins/image-viewer/screenshot.js | 44 +- src/plugins/registry.js | 121 +- src/plugins/stats/stats.js | 3 + src/plugins/status/detail-page-button.js | 9 +- src/plugins/status/fold-category.js | 7 +- src/plugins/status/history.js | 43 +- src/plugins/status/list-page-button.js | 12 +- src/plugins/status/list-page.js | 41 +- src/plugins/status/mobile-bottom-bar.js | 8 +- src/plugins/translate/translate.js | 30 +- src/services/actress-info-service.js | 32 + src/services/diagnostics-service.js | 34 +- src/services/magnet-service.js | 12 +- src/services/movie-identity-service.js | 84 + src/services/related-service.js | 2 +- src/services/review-service.js | 2 +- src/services/screenshot-service.js | 10 +- src/services/settings-service.js | 15 +- src/services/translation-service.js | 15 + src/services/webdav-service.js | 54 + src/ui/detail/hosted-detail-surface.js | 32 + src/ui/detail/screenshot-panel.js | 56 + src/ui/translation/title-translation.js | 27 + tests/123av-adapter.test.js | 14 +- tests/actress-info-service.test.js | 14 + tests/architecture-runtime.test.js | 48 +- tests/blacklist-filter-ui.test.js | 3 +- tests/browser/harness/runtime.js | 15 +- tests/browser/specs/host-routing.spec.js | 15 +- tests/credential-crypto.test.js | 18 + tests/detail-car-num.test.js | 82 +- tests/detail-panel-lifecycle.test.js | 24 +- tests/fc2-owned-workspace.test.js | 40 +- .../integrations/fc2content/images.html | 1 + .../integrations/fc2ppvdb/detail.html | 2 +- .../google-translate/translation.json | 3 + .../fixtures/integrations/javbus/detail.html | 2 +- .../integrations/wikipedia/actress.html | 8 + tests/history-multistate.test.js | 3 +- tests/history-selection.test.js | 47 +- tests/hit-show.test.js | 21 +- tests/hosted-detail-surface.test.js | 32 + tests/http-service.test.js | 12 + tests/integrations/av123.contract.test.js | 24 +- tests/integrations/av123.malformed.test.js | 12 +- tests/integrations/dmm.contract.test.js | 19 +- tests/integrations/dmm.malformed.test.js | 14 +- .../integrations/fc2content.contract.test.js | 14 + .../integrations/fc2content.malformed.test.js | 11 + tests/integrations/fc2ppvdb.contract.test.js | 28 +- tests/integrations/fc2ppvdb.malformed.test.js | 14 +- .../google-translate.contract.test.js | 17 + .../google-translate.malformed.test.js | 7 + tests/integrations/javbus.contract.test.js | 30 +- tests/integrations/javbus.malformed.test.js | 6 + tests/integrations/javdb.contract.test.js | 36 +- tests/integrations/javdb.malformed.test.js | 10 + tests/integrations/javstore.contract.test.js | 34 +- tests/integrations/javstore.malformed.test.js | 14 +- .../integrations/subtitlecat.contract.test.js | 3 +- tests/integrations/wikipedia.contract.test.js | 21 + .../integrations/wikipedia.malformed.test.js | 7 + tests/list-sorting.test.js | 8 +- tests/magnet-service.test.js | 13 + tests/mobile-fab.test.js | 3 +- tests/movie-identity-service.test.js | 52 + tests/performance-hotpaths.test.js | 18 +- tests/screenshot-panel.test.js | 20 + tests/screenshot-service.test.js | 22 + tests/stats-scope.test.js | 7 +- tests/translation-service.test.js | 10 + tests/ui-architecture.test.js | 16 +- tests/userscript-http-adapter.test.js | 19 + tests/webdav-compat.test.js | 6 +- tests/webdav-service.test.js | 26 + tsconfig.architecture.json | 5 +- 138 files changed, 5146 insertions(+), 2655 deletions(-) create mode 100644 src/core/credential-crypto.js create mode 100644 src/features/legacy-capabilities.js create mode 100644 src/integrations/fc2content/manifest.js create mode 100644 src/integrations/fc2content/parser.js create mode 100644 src/integrations/google-translate/manifest.js create mode 100644 src/integrations/google-translate/parser.js create mode 100644 src/integrations/javdb/signature.js create mode 100644 src/integrations/wikipedia/manifest.js create mode 100644 src/integrations/wikipedia/parser.js delete mode 100644 src/plugins/backup/webdav-client.js create mode 100644 src/services/actress-info-service.js create mode 100644 src/services/translation-service.js create mode 100644 src/services/webdav-service.js create mode 100644 src/ui/detail/screenshot-panel.js create mode 100644 src/ui/translation/title-translation.js create mode 100644 tests/actress-info-service.test.js create mode 100644 tests/credential-crypto.test.js create mode 100644 tests/fixtures/integrations/fc2content/images.html create mode 100644 tests/fixtures/integrations/google-translate/translation.json create mode 100644 tests/fixtures/integrations/wikipedia/actress.html create mode 100644 tests/hosted-detail-surface.test.js create mode 100644 tests/integrations/fc2content.contract.test.js create mode 100644 tests/integrations/fc2content.malformed.test.js create mode 100644 tests/integrations/google-translate.contract.test.js create mode 100644 tests/integrations/google-translate.malformed.test.js create mode 100644 tests/integrations/wikipedia.contract.test.js create mode 100644 tests/integrations/wikipedia.malformed.test.js create mode 100644 tests/magnet-service.test.js create mode 100644 tests/movie-identity-service.test.js create mode 100644 tests/screenshot-panel.test.js create mode 100644 tests/screenshot-service.test.js create mode 100644 tests/translation-service.test.js create mode 100644 tests/userscript-http-adapter.test.js create mode 100644 tests/webdav-service.test.js diff --git a/JHS.user.js b/JHS.user.js index 377d95a9..d285279b 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -426,6 +426,22 @@ // src/core/legacy-plugin-contributions.js var LEGACY_PLUGIN_CONTRIBUTION_MAP = Object.freeze({ + ListPagePlugin: "list.core", + AutoPagePlugin: "list.auto-page", + Fc2Plugin: "detail.fc2-owned", + FoldCategoryPlugin: "list.fold-category", + ListPageButtonPlugin: "list.actions", + HistoryPlugin: "library.history", + SettingPlugin: "settings.core", + NavBarPlugin: "identity.javdb-navigation", + BusNavBarPlugin: "identity.javbus-navigation", + HitShowPlugin: "discovery.hit-show", + TOP250Plugin: "discovery.top250", + SearchByImagePlugin: "identity.image-search", + Fc2By123AvPlugin: "detail.fc2-lookup", + DetailPagePlugin: "detail.native", + BusDetailPagePlugin: "detail.native", + DetailWorkspacePlugin: "detail.workspace", ReviewPlugin: "detail.reviews", RelatedPlugin: "detail.related", ScreenShotPlugin: "detail.screenshot", @@ -436,7 +452,24 @@ DetailPageButtonPlugin: "detail.state-actions", HighlightMagnetPlugin: "detail.native-magnets", OtherSitePlugin: "detail.external-sites", - SubTitleCatPlugin: "detail.subtitle" + SubTitleCatPlugin: "detail.subtitle", + FilterTitleKeywordPlugin: "library.keyword-filter", + ActressInfoPlugin: "identity.actress-info", + TranslatePlugin: "external-bridge.translation", + WantAndWatchedVideosPlugin: "library.state-actions", + BlacklistPlugin: "library.blacklist", + FavoriteActressesPlugin: "library.favorite-actresses", + NewVideoPlugin: "discovery.new-video", + TaskPlugin: "discovery.scheduler", + StatsPlugin: "stats.dashboard", + MobileBottomBarPlugin: "responsive-shell.bottom-bar", + OneOneFiveMatchPlugin: "external-bridge.115-match", + UnifiedOfflinePlugin: "external-bridge.offline", + CompatibilityEnhancementsPlugin: "compatibility.enhancements", + BusImgPlugin: "detail.gallery", + BusPreviewVideoPlugin: "detail.gallery", + OneTwoThreeOfflinePlugin: "external-bridge.123pan", + JavTrailersPlugin: "external-bridge.javtrailers" }); function migrateDisabledPlugins(value) { const input = Array.isArray(value) ? value : []; @@ -466,7 +499,7 @@ // src/core/plugin-manager.js var _PluginManager = class _PluginManager { - constructor() { + constructor(options = {}) { this.plugins = /* @__PURE__ */ new Map(); this._errorLog = []; this._lastTimings = []; @@ -479,12 +512,13 @@ this._idleCompleted = 0; this._disabledPluginsPromise = null; this._dependencyDeclarations = Object.freeze({}); + this.diagnostics = options.diagnostics ?? null; } setDependencyDeclarations(declarations) { if (this.plugins.size) throw new Error("依赖声明必须在插件注册前配置"); this._dependencyDeclarations = declarations || Object.freeze({}); } - register(e2) { + register(e2, runtimeServices = {}) { if ("function" != typeof e2) throw new Error("插件必须是一个类"); const a2 = performance.now(); const t2 = new e2(); @@ -492,8 +526,10 @@ const n2 = t2.getName(); if (this.plugins.has(n2)) throw new Error(`插件"${n2}"已注册`); t2.declaredDependencies = new Set(this._dependencyDeclarations[n2] || []); + t2.runtimeServices = Object.freeze({ ...runtimeServices }); this.plugins.set(n2, t2); this._registrationMs += performance.now() - a2; + this._syncDiagnostics(); } // 仅供 6.6 前的外部 Compatibility Facade 使用。 getBean(e2) { @@ -511,12 +547,14 @@ stack: n2?.stack || "" }); this._errorLog.length > 200 && this._errorLog.shift(); + this.diagnostics?.recordError({ source: "legacy-plugin", plugin: e2, phase: t2, message: n2?.message || String(n2) }); } getErrorLog() { return [...this._errorLog]; } clearErrorLog() { this._errorLog = []; + this.diagnostics?.clearErrors(); } getTimings() { return [...this._lastTimings]; @@ -535,6 +573,9 @@ idleCompleted: this._idleCompleted }; } + _syncDiagnostics() { + this.diagnostics?.setLegacyRuntime(this.getPluginNames(), this.getStartupReport(), this.getTimings()); + } async _getDisabledPlugins() { return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { try { @@ -574,6 +615,7 @@ const o2 = s2.map(((e2) => e2.css)).filter(Boolean); o2.length > 0 && utils.insertStyle(o2); this._cssMs = performance.now() - a2; + this._syncDiagnostics(); } async _runPlugin(e2) { const t2 = performance.now(); @@ -602,6 +644,7 @@ this._idlePending--; this._idleCompleted++; } + this._syncDiagnostics(); } async processPlugins() { const e2 = await this._getDisabledPlugins(), t2 = utils.isMobileMode(), n2 = [], a2 = [], i2 = []; @@ -633,6 +676,7 @@ this._readyMs = performance.now() - this._startedAt; this._idlePending = a2.length; a2.length && this._scheduleIdle((() => this._runIdlePlugins(a2))); + this._syncDiagnostics(); } }; __name(_PluginManager, "PluginManager"); @@ -648,6 +692,7 @@ } i(this, "pluginManager", null); i(this, "declaredDependencies", /* @__PURE__ */ new Set()); + i(this, "runtimeServices", Object.freeze({})); } getName() { throw new Error(`${this.constructor.name} 未显示getName()`); @@ -658,6 +703,10 @@ } return this.pluginManager.resolveDeclaredPlugin(e2); } + getRuntimeService(name) { + if (!Object.prototype.hasOwnProperty.call(this.runtimeServices, name)) throw new Error(`${this.getName()} 未声明运行时依赖 ${name}`); + return this.runtimeServices[name]; + } async initCss() { return ""; } @@ -898,11 +947,15 @@ document.documentElement.setAttribute("data-jhs-theme", resolved); } __name(applyTheme, "applyTheme"); - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (() => { - storageManager.getSetting("themeMode", "light").then(((e2) => { - "auto" === e2 && applyTheme(); - })); - })); + function initializeThemeRuntime(scope) { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + scope.listen(media, "change", () => { + storageManager.getSetting("themeMode", "light").then(((mode) => { + if ("auto" === mode) void applyTheme(); + })); + }); + } + __name(initializeThemeRuntime, "initializeThemeRuntime"); // src/core/utils.js var _Utils = class _Utils { @@ -3548,10 +3601,16 @@ return this.document.querySelector(".masonry"); } locateDetailRoot() { - return this.document.querySelector(".container .row.movie"); + return this.locateNativeMagnets()?.closest(".container") ?? this.document.querySelector(".container .row.movie")?.parentElement ?? null; } locateDetailSlots() { - return Object.freeze({ summary: this.locateDetailRoot(), resources: this.locateNativeMagnets(), reviews: this.document.querySelector("#reviews") }); + const root = this.locateDetailRoot(); + return Object.freeze({ + summary: root?.querySelector('[data-jhs-slot="summary-actions"]') ?? root?.querySelector(".row.movie") ?? null, + resources: this.locateNativeMagnets(), + reviews: root?.querySelector('[data-jhs-slot="reviews"]') ?? this.document.querySelector("#reviews"), + related: root?.querySelector('[data-jhs-slot="related"]') + }); } locateNativeGallery() { return this.document.querySelector("#sample-waterfall, .sample-box"); @@ -3583,10 +3642,16 @@ return this.document.querySelector(".movie-list"); } locateDetailRoot() { - return this.document.querySelector(".movie-panel-info")?.closest(".container") ?? this.document.querySelector("main"); + return this.document.querySelector(".video-detail") ?? this.document.querySelector(".movie-panel-info")?.closest(".container") ?? this.document.querySelector("main"); } locateDetailSlots() { - return Object.freeze({ summary: this.document.querySelector(".movie-panel-info"), resources: this.document.querySelector("#magnets-content"), reviews: this.document.querySelector("#reviews") }); + const root = this.locateDetailRoot(); + return Object.freeze({ + summary: root?.querySelector('[data-jhs-slot="summary-actions"]') ?? this.document.querySelector(".movie-panel-info"), + resources: this.document.querySelector("#magnets-content"), + reviews: root?.querySelector('[data-jhs-slot="reviews"]') ?? this.document.querySelector("#reviews"), + related: root?.querySelector('[data-jhs-slot="related"]') + }); } locateNativeGallery() { return this.document.querySelector(".tile-images, .preview-images"); @@ -3611,35 +3676,47 @@ if (!Array.isArray(value)) throw new TypeError(`${field} must be an array`); } __name(requireArray, "requireArray"); - function defineFeature(manifest) { - requireNonEmptyString(manifest.id, "Feature id"); - if (!FEATURE_KINDS.has(String(manifest.kind))) throw new TypeError("Feature kind must be system or feature"); - if (!STARTUP_MODES.has(String(manifest.startup))) throw new TypeError("Feature startup mode is invalid"); - for (const field of ["sites", "routes", "requires", "contributes", "providesCommands"]) requireArray(manifest[field], field); - if (typeof manifest.activate !== "function") throw new TypeError("Feature activate must be a function"); - if (manifest.kind === "system" && manifest.disableable !== false) throw new TypeError("System features cannot be disableable"); - return Object.freeze({ ...manifest }); + function defineFeature(manifest2) { + requireNonEmptyString(manifest2.id, "Feature id"); + if (!FEATURE_KINDS.has(String(manifest2.kind))) throw new TypeError("Feature kind must be system or feature"); + if (!STARTUP_MODES.has(String(manifest2.startup))) throw new TypeError("Feature startup mode is invalid"); + for (const field of ["sites", "routes", "requires", "contributes", "providesCommands"]) requireArray(manifest2[field], field); + if (typeof manifest2.activate !== "function") throw new TypeError("Feature activate must be a function"); + if (manifest2.kind === "system" && manifest2.disableable !== false) throw new TypeError("System features cannot be disableable"); + return Object.freeze({ ...manifest2 }); } __name(defineFeature, "defineFeature"); - function defineIntegration(manifest) { - requireNonEmptyString(manifest.id, "Integration id"); - if (!TRUST_CLASSES.has(String(manifest.trustClass))) throw new TypeError("Integration trustClass is invalid"); - if (!QUALITY_LEVELS.has(String(manifest.quality))) throw new TypeError("Integration quality is invalid"); - for (const field of ["hosts", "capabilities", "requires"]) requireArray(manifest[field], field); + function defineContribution(manifest2) { + requireNonEmptyString(manifest2.id, "Contribution id"); + requireNonEmptyString(manifest2.featureId, "Contribution featureId"); + requireNonEmptyString(manifest2.legacyPluginId, "Contribution legacyPluginId"); + requireArray(manifest2.sites, "sites"); + requireArray(manifest2.requires, "requires"); + if (typeof manifest2.plugin !== "function") throw new TypeError("Contribution plugin must be a class"); + if (!manifest2.order || typeof manifest2.order !== "object") throw new TypeError("Contribution order must be explicit"); + return Object.freeze({ ...manifest2, sites: Object.freeze([.../** @type {unknown[]} */ + manifest2.sites]), order: Object.freeze({ ...manifest2.order }) }); + } + __name(defineContribution, "defineContribution"); + function defineIntegration(manifest2) { + requireNonEmptyString(manifest2.id, "Integration id"); + if (!TRUST_CLASSES.has(String(manifest2.trustClass))) throw new TypeError("Integration trustClass is invalid"); + if (!QUALITY_LEVELS.has(String(manifest2.quality))) throw new TypeError("Integration quality is invalid"); + for (const field of ["hosts", "capabilities", "requires"]) requireArray(manifest2[field], field); const hosts = ( /** @type {unknown[]} */ - manifest.hosts + manifest2.hosts ); const capabilities = ( /** @type {unknown[]} */ - manifest.capabilities + manifest2.capabilities ); if (hosts.length === 0 || capabilities.length === 0) throw new TypeError("Integration hosts and capabilities cannot be empty"); - if (manifest.cachePolicy === void 0) throw new TypeError("Integration cachePolicy must be explicit"); - if (typeof manifest.createClient !== "function" || typeof manifest.createAdapter !== "function") { + if (manifest2.cachePolicy === void 0) throw new TypeError("Integration cachePolicy must be explicit"); + if (typeof manifest2.createClient !== "function" || typeof manifest2.createAdapter !== "function") { throw new TypeError("Integration client and adapter factories are required"); } - return Object.freeze({ ...manifest }); + return Object.freeze({ ...manifest2 }); } __name(defineIntegration, "defineIntegration"); @@ -3662,13 +3739,16 @@ navigation: createToken("service", "navigation"), http: createToken("service", "http"), storage: createToken("service", "storage"), + webdav: createToken("service", "webdav"), dialog: createToken("service", "dialog"), settings: createToken("service", "settings"), movie: createToken("service", "movie"), + actressInfo: createToken("service", "actress-info"), review: createToken("service", "review"), related: createToken("service", "related"), magnet: createToken("service", "magnet"), screenshot: createToken("service", "screenshot"), + translation: createToken("service", "translation"), offline: createToken("service", "offline"), cache: createToken("service", "cache"), state: createToken("service", "state"), @@ -3693,12 +3773,42 @@ this.root = null; this.slots = Object.freeze({}); this.mounted = /* @__PURE__ */ new Map(); + this.created = /* @__PURE__ */ new Set(); } mount() { this.root = this.hostAdapter.locateDetailRoot(); + if (this.root) this.ensureOwnedSlots(); this.slots = Object.freeze({ ...this.hostAdapter.locateDetailSlots() }); return this; } + ensureOwnedSlots() { + if (!this.root) return; + const documentRuntime = this.root.ownerDocument; + let summary = this.root.querySelector(':scope > [data-jhs-slot="summary-actions"]'); + if (!summary) { + summary = documentRuntime.createElement("div"); + summary.className = "jhs-detail-owned-slot jhs-detail-owned-slot--summary-actions"; + summary.setAttribute("data-jhs-slot", "summary-actions"); + this.root.append(summary); + this.created.add(summary); + } + let group = this.root.querySelector(':scope > [data-jhs-slot-group="post-resource"]'); + if (!group) { + group = documentRuntime.createElement("div"); + group.className = "jhs-detail-post-resource"; + group.setAttribute("data-jhs-slot-group", "post-resource"); + this.root.append(group); + this.created.add(group); + } + for (const name of ["reviews", "related"]) { + if (group.querySelector(`[data-jhs-slot="${name}"]`)) continue; + const slot = documentRuntime.createElement("section"); + slot.className = `jhs-detail-owned-slot jhs-detail-owned-slot--${name}`; + slot.setAttribute("data-jhs-slot", name); + group.append(slot); + this.created.add(slot); + } + } /** @param {string} slot @param {Element} element */ mountPanel(slot, element) { const target = this.slots[slot]; @@ -3709,7 +3819,9 @@ } dispose() { for (const element of this.mounted.values()) element.remove(); + for (const element of this.created) element.remove(); this.mounted.clear(); + this.created.clear(); this.root = null; this.slots = Object.freeze({}); } @@ -3750,7 +3862,7 @@ routes: ["detail"], startup: "eager", requires: [PORT.host, SERVICE.movie, SERVICE.review, SERVICE.related, SERVICE.magnet, SERVICE.screenshot], - contributes: ["detail.state-actions", "detail.gallery", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], + contributes: ["detail.native", "detail.workspace", "detail.fc2-owned", "detail.fc2-lookup", "detail.state-actions", "detail.gallery", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], providesCommands: [], activate: /* @__PURE__ */ __name((deps, runtime) => { const controller = new DetailController({ hostAdapter: deps[PORT.host], scope: runtime.scope, enabledContributions: runtime.enabledContributions }); @@ -3759,6 +3871,28 @@ }, "activate") }); + // src/features/legacy-capabilities.js + var feature = /* @__PURE__ */ __name((id, sites, contributes) => defineFeature({ + id, + kind: "feature", + disableable: true, + sites, + routes: [], + startup: "eager", + requires: [], + contributes, + providesCommands: [], + activate: /* @__PURE__ */ __name(() => ({}), "activate") + }), "feature"); + var legacyCapabilityManifests = Object.freeze([ + feature("list", ["javdb", "javbus"], ["list.core", "list.auto-page", "list.fold-category", "list.actions"]), + feature("library", ["javdb", "javbus"], ["library.history", "library.keyword-filter", "library.state-actions", "library.blacklist", "library.favorite-actresses"]), + feature("discovery", ["javdb", "javbus"], ["discovery.hit-show", "discovery.top250", "discovery.new-video", "discovery.scheduler"]), + feature("external-bridge", ["javdb", "javbus", "123pan", "javtrailers", "subtitlecat"], ["external-bridge.translation", "external-bridge.115-match", "external-bridge.offline", "external-bridge.123pan", "external-bridge.javtrailers", "detail.subtitle"]), + feature("identity", ["javdb", "javbus"], ["identity.javdb-navigation", "identity.javbus-navigation", "identity.image-search", "identity.actress-info"]), + feature("compatibility", ["javdb", "javbus"], ["compatibility.enhancements"]) + ]); + // src/features/system/catalog.js var systemFeatureManifests = Object.freeze([ defineFeature({ @@ -3769,7 +3903,7 @@ routes: [], startup: "on-command", requires: [SERVICE.diagnostics], - contributes: [], + contributes: ["settings.core"], providesCommands: ["settings.open"], activate: /* @__PURE__ */ __name(() => ({ commands: { "settings.open": /* @__PURE__ */ __name(() => document.querySelector("#setting-btn, #mini-setting-btn")?.dispatchEvent(new MouseEvent("click", { bubbles: true })), "settings.open") } }), "activate") }), @@ -3793,7 +3927,7 @@ routes: [], startup: "eager", requires: [SERVICE.profile], - contributes: [], + contributes: ["responsive-shell.bottom-bar"], providesCommands: [], activate: /* @__PURE__ */ __name((deps) => ({ profile: deps[SERVICE.profile].current() }), "activate") }), @@ -3805,14 +3939,14 @@ routes: [], startup: "idle", requires: [SERVICE.diagnostics], - contributes: [], + contributes: ["stats.dashboard"], providesCommands: [], activate: /* @__PURE__ */ __name((deps) => ({ diagnostics: deps[SERVICE.diagnostics] }), "activate") }) ]); // src/features/catalog.js - var featureManifests = Object.freeze([...systemFeatureManifests, manifest_default]); + var featureManifests = Object.freeze([...systemFeatureManifests, manifest_default, ...legacyCapabilityManifests]); // src/plugins/dependency-map.js var LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ @@ -3822,16 +3956,14 @@ CompatibilityEnhancementsPlugin: ["ListPagePlugin"], CoverButtonPlugin: ["ListPagePlugin", "ScreenShotPlugin", "OtherSitePlugin"], DetailPageButtonPlugin: ["DetailWorkspacePlugin", "MagnetHubPlugin", "HighlightMagnetPlugin"], - Fc2By123AvPlugin: ["OtherSitePlugin", "Fc2Plugin", "TranslatePlugin"], + Fc2By123AvPlugin: ["OtherSitePlugin", "Fc2Plugin"], Fc2Plugin: [ "DetailPageButtonPlugin", "MagnetHubPlugin", "FilterTitleKeywordPlugin", "OtherSitePlugin", - "ScreenShotPlugin", "Fc2By123AvPlugin", "TOP250Plugin", - "TranslatePlugin", "HighlightMagnetPlugin", "ReviewPlugin", "RelatedPlugin" @@ -3858,8 +3990,6 @@ NavBarPlugin: ["SearchByImagePlugin"], NewVideoPlugin: ["TaskPlugin", "SettingPlugin", "OtherSitePlugin"], PreviewVideoPlugin: ["DetailPageButtonPlugin"], - RelatedPlugin: ["DetailWorkspacePlugin"], - ReviewPlugin: ["DetailWorkspacePlugin", "RelatedPlugin"], SettingPlugin: [ "CoverButtonPlugin", "TaskPlugin", @@ -3880,105 +4010,80 @@ // src/plugins/avatar/actress-info.js var _ActressInfoPlugin = class _ActressInfoPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "apiUrl", "https://ja.wikipedia.org/wiki/"); - } getName() { return "ActressInfoPlugin"; } async handle() { - "yes" === await storageManager.getSetting("enableLoadActressInfo", "yes") && await this.loadActressInfo(); + if ("yes" === await storageManager.getSetting("enableLoadActressInfo", "yes")) await this.loadActressInfo(); } async loadActressInfo() { await Promise.all([this.handleDetailPage(), this.handleStarPage()]); } async initCss() { - return "\n \n "; + return ``; } async handleDetailPage() { if ($(".actress-info").length > 0) return; - let e2 = $(".female").prev().map(((e3, t3) => $(t3).text().trim())).get(); - if (!e2.length) return; - const t2 = "jhs_actress_info", n2 = localStorage.getItem(t2) ? JSON.parse(localStorage.getItem(t2)) : {}; - let a2 = null, i2 = ""; - for (let o2 = 0; o2 < e2.length; o2++) { - let t3 = e2[o2]; - if (a2 = n2[t3], !a2) try { - a2 = await this.searchInfo(t3), a2 && (n2[t3] = a2); - } catch (s2) { + const names = $(".female").prev().map(((index, item) => $(item).text().trim())).get(); + if (!names.length) return; + const blocks = []; + for (const name of names) { + let info = null; + try { + info = await this.searchInfo(name); + } catch { clog.error("该名称查询失败,尝试其它名称"); } - let r2 = ""; - r2 = a2 ? ` - - ` : ` `, i2 += r2; + const block = $('
    '); + if (info) { + block.append($("").text(`${name}:`)); + const link = $("").attr({ href: info.url, target: "_blank", rel: "noopener noreferrer" }).addClass("jhs-layout-9813a0dd"); + link.append( + $("").addClass("info-tag").text(`${info.birthday} ${info.age}`.trim()), + $("").addClass("info-tag").text(`${info.height} ${info.weight}`.trim()), + $("").addClass("info-tag").text(`${info.threeSizeText} ${info.braSize}`.trim()) + ); + block.append(link); + } else { + const href = this.getRuntimeService("actressInfo").profileUrl(name); + block.append($("").attr({ href, target: "_blank", rel: "noopener noreferrer" }).append($("").text(`${name}:`))); + } + blocks.push(block); } - $('strong:contains("演員")').parent().after(i2), localStorage.setItem(t2, JSON.stringify(n2)); + $('strong:contains("演員")').parent().after(...blocks); } async handleStarPage() { if ($(".actress-info").length > 0) return; - let e2 = [], t2 = $(".actor-section-name"); - t2.length && t2.text().trim().split(",").forEach(((t3) => { - e2.push(t3.trim()); - })); - let n2 = $(".section-meta:not(:contains('影片'))"); - if (n2.length && n2.text().trim().split(",").forEach(((t3) => { - e2.push(t3.trim()); - })), !e2.length) return; - const a2 = "jhs_actress_info", i2 = localStorage.getItem(a2) ? JSON.parse(localStorage.getItem(a2)) : {}; - let s2 = null; - for (let l2 = 0; l2 < e2.length; l2++) { - let t3 = e2[l2]; - if (s2 = i2[t3], s2) break; + const names = [], title = $(".actor-section-name"); + if (title.length) title.text().trim().split(",").forEach(((name) => names.push(name.trim()))); + const meta = $(".section-meta:not(:contains('影片'))"); + if (meta.length) meta.text().trim().split(",").forEach(((name) => names.push(name.trim()))); + if (!names.length) return; + let info = null; + for (const name of names) { try { - s2 = await this.searchInfo(t3); - } catch (r2) { + info = await this.searchInfo(name); + } catch { clog.error("该名称查询失败,尝试其它名称"); } - if (s2) break; + if (info) break; } - s2 && e2.forEach(((e3) => { - i2[e3] = s2; - })); - let o2 = '
    无此相关演员信息
    '; - s2 && (o2 = ` - -
    -
    - 出生日期: ${s2.birthday} - 年龄: ${s2.age} - 身高: ${s2.height} -
    -
    - 体重: ${s2.weight} - 三围: ${s2.threeSizeText} - 罩杯: ${s2.braSize} -
    -
    -
    - `), t2.parent().append(o2), localStorage.setItem(a2, JSON.stringify(i2)); - } - async searchInfo(e2) { - "三上悠亞" === e2 && (e2 = "三上悠亜"); - let t2 = this.apiUrl + e2; - const n2 = await gmHttp.get(t2), a2 = new DOMParser(), i2 = $(a2.parseFromString(n2, "text/html")); - let s2 = i2.find('a[title="誕生日"]').parent().parent().find("td").text().trim(), o2 = i2.find("th:contains('現年齢')").parent().find("td").text().trim() ? parseInt(i2.find("th:contains('現年齢')").parent().find("td").text().trim()) + "岁" : "", r2 = i2.find('tr:has(a[title="身長"]) td').text().trim().split(" ")[0] + "cm", l2 = i2.find('tr:has(a[title="体重"]) td').text().trim().split("/")[1].trim(); - return "― kg" === l2 && (l2 = ""), { - birthday: s2, - age: o2, - height: r2, - weight: l2, - threeSizeText: i2.find('a[title="スリーサイズ"]').closest("tr").find("td").text().replace("cm", "").trim(), - braSize: i2.find('th:contains("ブラサイズ")').next("td").contents().first().text().trim(), - url: t2 - }; + const body = $('
    '); + if (!info) body.text("无此相关演员信息"); + else { + const row1 = $('
    '), row2 = $('
    '); + row1.append($("").addClass("jhs-layout-dd5a75f6").text(`出生日期: ${info.birthday}`), $("").addClass("jhs-layout-d4a09a0d").text(`年龄: ${info.age}`), $("").addClass("jhs-layout-d4a09a0d").text(`身高: ${info.height}`)); + row2.append($("").addClass("jhs-layout-dd5a75f6").text(`体重: ${info.weight}`), $("").addClass("jhs-layout-d4a09a0d").text(`三围: ${info.threeSizeText}`), $("").addClass("jhs-layout-d4a09a0d").text(`罩杯: ${info.braSize}`)); + body.append(row1, row2); + } + const result = info ? $("").addClass("actress-info").attr({ href: info.url, target: "_blank", rel: "noopener noreferrer" }).append(body) : body.addClass("actress-info"); + title.parent().append(result); + } + async searchInfo(name) { + const scope = await this.getRuntimeService("scope")(); + return this.getRuntimeService("actressInfo").lookup(name, { scope }); } }; __name(_ActressInfoPlugin, "ActressInfoPlugin"); @@ -4583,768 +4688,231 @@ __name(_ResourceSettingsService, "ResourceSettingsService"); var ResourceSettingsService = _ResourceSettingsService; - // src/plugins/backup/webdav-client.js - var _WebDavClient = class _WebDavClient { - constructor(e2, t2, n2) { - this.davUrl = e2.endsWith("/") ? e2 : e2 + "/", this.username = t2, this.password = n2, this.folderName = null; + // src/core/credential-crypto.js + var ENCRYPTION_SALT = "x7k9p3"; + var CREDENTIAL_PREFIX = "AES:"; + async function getEncryptionKey() { + const encoder = new TextEncoder(); + const material = await crypto.subtle.importKey("raw", encoder.encode(`${ENCRYPTION_SALT}.jhs.v1`), { name: "PBKDF2" }, false, ["deriveKey"]); + return crypto.subtle.deriveKey({ name: "PBKDF2", salt: encoder.encode("jhs-backup"), iterations: 1e5, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); + } + __name(getEncryptionKey, "getEncryptionKey"); + function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer), chunkSize = 32768; + let value = ""; + for (let index = 0; index < bytes.length; index += chunkSize) value += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + return btoa(value); + } + __name(arrayBufferToBase64, "arrayBufferToBase64"); + function base64ToArrayBuffer(value) { + const decoded = atob(value), bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index++) bytes[index] = decoded.charCodeAt(index); + return bytes; + } + __name(base64ToArrayBuffer, "base64ToArrayBuffer"); + async function encryptData(value) { + const key = await getEncryptionKey(), iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value)); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + return arrayBufferToBase64(combined.buffer); + } + __name(encryptData, "encryptData"); + async function decryptData(value) { + const key = await getEncryptionKey(), combined = base64ToArrayBuffer(value), iv = combined.slice(0, 12), data = combined.slice(12); + const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data); + return new TextDecoder().decode(decrypted); + } + __name(decryptData, "decryptData"); + async function encryptCredential(value) { + return value && !value.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(value) : value; + } + __name(encryptCredential, "encryptCredential"); + async function decryptCredential(value) { + return value && value.startsWith(CREDENTIAL_PREFIX) ? decryptData(value.slice(CREDENTIAL_PREFIX.length)) : value; + } + __name(decryptCredential, "decryptCredential"); + + // src/ui/table/create-jhs-table.js + function createJhsTable(TabulatorRuntime, target, options) { + if (typeof TabulatorRuntime !== "function") throw new TypeError("Tabulator runtime is required"); + const pageSizes = (options.paginationSizeSelector ?? [20, 50, 100, 1e3]).filter((value) => Number.isInteger(value) && value > 0); + return new TabulatorRuntime(target, { + layout: "fitColumns", + responsiveLayout: "collapse", + pagination: true, + paginationMode: "local", + paginationSize: pageSizes[0] ?? 20, + ...options, + paginationSizeSelector: pageSizes + }); + } + __name(createJhsTable, "createJhsTable"); + + // src/plugins/backup/setting-backup.js + async function importSettingData(showDiffPreviewFn) { + try { + const input = document.createElement("input"); + input.type = "file", input.accept = ".json"; + const cleanup = /* @__PURE__ */ __name(() => input.remove(), "cleanup"); + input.onchange = async (e2) => { + const t2 = e2.target.files[0]; + if (!t2) return void cleanup(); + const n2 = new FileReader(); + n2.onload = async (e3) => { + cleanup(); + try { + const t3 = e3.target.result.toString(), n3 = JSON.parse(t3); + if (!n3 || "object" != typeof n3 || Array.isArray(n3)) throw new Error("文件内容不是有效的数据对象"); + const a2 = loading(); + try { + const e4 = await storageManager.exportData(), t4 = await storageManager.diffData(e4, n3); + a2.close(), showDiffPreviewFn(t4, n3, null); + } catch (i2) { + a2.close(), clog.error(i2), show.error("差异分析失败: " + i2.message); + } + } catch (t3) { + clog.error(t3), show.error("导入失败:文件内容不是有效的JSON格式 " + t3.message); + } + }, n2.onerror = () => { + cleanup(), show.error("读取文件时出错"); + }, n2.readAsText(t2); + }, document.body.appendChild(input), input.click(); + setTimeout(cleanup, 3e5); + } catch (e2) { + clog.error(e2), show.error("导入数据时出错: " + e2.message); } - _getAuthHeaders() { - return { - Authorization: `Basic ${btoa(`${this.username}:${this.password}`)}`, - Depth: "1" - }; + } + __name(importSettingData, "importSettingData"); + async function backupDataByWebDav(folderName, webdavService) { + const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; + if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); + const a2 = t2.webDavUsername; + if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); + const i2 = await decryptCredential(t2.webDavPassword); + if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); + let s2 = utils.getNowStr("_", "_") + ".json", o2 = JSON.stringify(await storageManager.exportData()); + o2 = await encryptData(o2); + let r2 = loading(); + try { + const e2 = webdavService.createClient({ url: n2, username: a2, password: i2 }); + await e2.backup(folderName, s2, o2), show.ok("备份完成"); + } catch (l2) { + clog.error(l2), show.error(l2.toString()); + } finally { + r2.close(); } - _sendRequest(e2, t2, n2 = {}, a2) { - return new Promise(((i2, s2) => { - const o2 = this.davUrl + t2, r2 = { - ...this._getAuthHeaders(), - ...n2 - }; - GM_xmlhttpRequest({ - method: e2, - url: o2, - headers: r2, - data: a2, - onload: /* @__PURE__ */ __name((e3) => { - e3.status >= 200 && e3.status < 300 ? i2(e3) : (clog.error(e3), s2(new Error(`请求失败 ${e3.status}: ${e3.statusText}`))); - }, "onload"), - onerror: /* @__PURE__ */ __name((e3) => { - clog.error("请求WebDav发生错误:", e3), s2(new Error("请求WebDav失败, 请检查服务是否启动, 凭证是否正确")); - }, "onerror") - }); - })); + } + __name(backupDataByWebDav, "backupDataByWebDav"); + async function backupListBtnByWebDav(folderName, openFileListDialogFn, webdavService) { + const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; + if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); + const a2 = t2.webDavUsername; + if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); + const i2 = await decryptCredential(t2.webDavPassword); + if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); + let s2 = loading(); + try { + const e2 = webdavService.createClient({ url: n2, username: a2, password: i2 }), t3 = await e2.getBackupList(folderName); + openFileListDialogFn(t3, e2, "WebDav"); + } catch (o2) { + clog.error(o2), show.error(`发生错误: ${o2 ? o2.message : o2}`); + } finally { + s2.close(); } - async _ensureFolder(e2) { - try { - await this._sendRequest("MKCOL", e2); - } catch (t2) { - if (!/请求失败 (405|409):/.test(t2.message)) throw t2; + } + __name(backupListBtnByWebDav, "backupListBtnByWebDav"); + function openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn) { + const formatSize = /* @__PURE__ */ __name((size) => { + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let i2 = 0, s2 = size; + for (; s2 >= 1024 && i2 < units.length - 1; ) s2 /= 1024, i2++; + return `${s2 % 1 == 0 ? s2.toFixed(0) : s2.toFixed(2)} ${units[i2]}`; + }, "formatSize"); + const renderCards = /* @__PURE__ */ __name((files) => { + if (!files || files.length === 0) { + return '
    暂无数据
    '; } - } - async backup(e2, t2, n2) { - await this._ensureFolder(e2); - const a2 = e2 + "/" + t2; - await this._sendRequest("PUT", a2, { - "Content-Type": "text/plain" - }, n2); - } - async getFileList(e2) { - var t2, n2, a2; - const i2 = (await this._sendRequest("PROPFIND", e2, { - "Content-Type": "application/xml" - }, '\n \n \n \n \n \n \n \n \n \n ')).responseText, s2 = new DOMParser().parseFromString(i2, "text/xml").getElementsByTagNameNS("DAV:", "response"), o2 = []; - for (let r2 = 0; r2 < s2.length; r2++) { - if (0 === r2) continue; - let e3 = s2[r2]; - const displayNameNode = e3.getElementsByTagNameNS("DAV:", "displayname")[0]; - const href = e3.getElementsByTagNameNS("DAV:", "href")[0]?.textContent || ""; - const fallbackName = decodeURIComponent(href.replace(/\/$/, "").split("/").pop() || ""); - const i3 = displayNameNode?.textContent || fallbackName, l2 = (null == (t2 = e3.getElementsByTagNameNS("DAV:", "getcontentlength")[0]) ? void 0 : t2.textContent) || "0", c2 = (null == (n2 = e3.getElementsByTagNameNS("DAV:", "creationdate")[0]) ? void 0 : n2.textContent) || (null == (a2 = e3.getElementsByTagNameNS("DAV:", "getlastmodified")[0]) ? void 0 : a2.textContent) || ""; - "0" !== l2 && o2.push({ - fileId: i3, - name: i3, - size: Number(l2), - createTime: c2 + return files.map((file, idx) => ` +
    +
    ${escapeHtml(file.name)}
    +
    ${formatSize(file.size)} · ${utils.getNowStr("-", ":", file.createTime)}
    +
    + + + +
    +
    + `).join(""); + }, "renderCards"); + const containerId = "jhs-backup-card-list"; + layer.open({ + type: 1, + title: n2 + "备份文件", + content: `
    ${renderCards(e2)}
    `, + area: utils.getResponsiveArea(["800px", "70%"]), + anim: -1, + success: /* @__PURE__ */ __name((layerEl) => { + const container = $(layerEl).find(`#${containerId}`); + container.on("click", ".jhs-backup-btn", async (ev) => { + const btn = $(ev.currentTarget); + const action = btn.data("action"); + const idx = btn.data("idx"); + const file = e2[idx]; + if (!file) return; + if (action === "delete") { + layer.confirm(`是否删除 ${file.name} ?`, { + icon: 3, + title: "提示", + btn: ["确定", "取消"] + }, async (confirmIdx) => { + layer.close(confirmIdx); + let load = loading(); + try { + await t2.deleteFile(file.fileId); + e2 = await t2.getBackupList(folderName); + container.html(renderCards(e2)); + layer.alert("删除成功"); + } catch (err) { + clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); + } finally { + load.close(); + } + }); + } else if (action === "download") { + let load = loading(); + try { + const data = await decryptData(await t2.getFileContent(file.fileId)); + utils.download(data, file.name); + } catch (err) { + clog.error(err), show.error("下载失败: " + err); + } finally { + load.close(); + } + } else if (action === "import") { + let load = loading(); + try { + let data = await t2.getFileContent(file.fileId); + data = await decryptData(data); + const parsed = JSON.parse(data); + const currentData = await storageManager.exportData(); + const diff = await storageManager.diffData(currentData, parsed); + load.close(); + showDiffPreviewFn(diff, null, parsed); + } catch (err) { + load.close(), clog.error(err), show.error("预览失败: " + (err ? err.message : err)); + } + } }); - } - return o2.reverse(), o2; - } - async deleteFile(e2) { - let t2 = this.folderName + "/" + encodeURI(e2); - await this._sendRequest("DELETE", t2, { - "Cache-Control": "no-cache" - }); - } - async getBackupList(e2) { - return this.folderName = e2, await this._ensureFolder(e2), this.getFileList(e2); - } - async getFileContent(e2) { - let t2 = this.folderName + "/" + e2; - return (await this._sendRequest("GET", t2, { - Accept: "application/octet-stream" - })).responseText; - } - }; - __name(_WebDavClient, "WebDavClient"); - var WebDavClient = _WebDavClient; - - // src/core/cache-policy.js - var HOUR = 60 * 60 * 1e3; - var DAY = 24 * HOUR; - var CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); - var _ProviderError = class _ProviderError extends Error { - /** @param {string} provider @param {string} code @param {string} message @param {{cause?: unknown, status?: number, url?: string, retryable?: boolean}} [options] */ - constructor(provider, code, message, options = {}) { - super(message, { cause: options.cause }); - this.name = "ProviderError"; - this.provider = provider; - this.code = code; - this.status = options.status || 0; - this.url = options.url || ""; - this.retryable = Boolean(options.retryable); - } - }; - __name(_ProviderError, "ProviderError"); - var ProviderError = _ProviderError; - - // src/plugins/image-viewer/preview-video.js - var Z = /* @__PURE__ */ __name((e2, t2) => { - if (!e2 || 0 === e2.length) return null; - const n2 = new Set(e2); - if (n2.has(t2)) return t2; - const a2 = L.map(((e3) => e3.quality)).reverse(); - for (const i2 of a2) if (n2.has(i2)) return i2; - return e2[0]; - }, "Z"); - var ee = "jhs_dmm_video"; - var _DmmPreviewParser = class _DmmPreviewParser { - constructor(e2) { - this.carNum = e2, this.lastError = null; - } - _checkCache() { - const e2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; - return e2[this.carNum] ? (clog.debug("缓存中存在预览视频信息", e2[this.carNum]), e2[this.carNum]) : null; - } - _updateCache(e2) { - const t2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; - t2[this.carNum] = e2, clog.debug("成功解析出预览视频并已缓存:", e2), localStorage.setItem(ee, JSON.stringify(t2)); - } - async _searchContentIds() { - const e2 = this.carNum, t2 = e2.replace(/-/g, ""), n2 = [{ - keyword: e2.replace("-", "00"), - name: "00-替换关键词" - }, { - keyword: e2, - name: "原始番号关键词" - }, { - keyword: t2, - name: "无连字符关键词" - }], a2 = e2.toLowerCase(); - let hadSuccessfulRequest = false; - for (const o2 of n2) { - const { keyword: e3, name: n3 } = o2, i3 = e3.toLowerCase(); - clog.debug(`--- 尝试使用 ${n3} (${e3}) 进行 API 搜索 ---`); - const r2 = `https://api.dmm.com/affiliate/v3/ItemList?${new URLSearchParams({ - api_id: "UrwskPfkqQ0DuVry2gYL", - affiliate_id: "10278-996", - output: "json", - site: "FANZA", - sort: "match", - keyword: e3 - }).toString()}`; - let l2; - try { - l2 = await gmHttp.get(r2); - hadSuccessfulRequest = true; - } catch (s2) { - this.lastError = new ProviderError("dmm", "HTTP_ERROR", `DMM API 请求失败: ${s2.message || s2}`, { - cause: s2, - url: r2, - status: s2?.status, - retryable: true - }), clog.error(`API 请求失败,跳过 ${n3}:`, this.lastError); - continue; - } - if (!l2 || !l2.result || !l2.result.result_count) { - clog.debug("API 返回无结果,尝试下一个关键词。"); - continue; - } - const c2 = []; - for (const s2 of l2.result.items) { - if (c2.length >= 2) break; - const e4 = s2.content_id || "", o3 = s2.maker_product || ""; - (e4.includes(i3.replace("-", "")) || a2 === o3.toLowerCase() || e4.includes(t2.toLowerCase())) && (c2.push({ - serviceCode: s2.service_code, - floorCode: s2.floor_code, - contentId: e4, - pageUrl: s2.URL - }), clog.debug(`[${n3}] cid|makerProduct 匹配成功:`, e4, o3)); - } - if (c2.length > 0) { - clog.debug(`--- 成功通过 ${n3} 找到 Content IDs ---`); - const t3 = $("#fanzaBtn"); - let a3 = `https://www.dmm.co.jp/search/=/searchstr=${e3}`, i4 = "single"; - c2.length > 1 ? (t3.attr("href", a3), t3.append('多结果'), t3.css("backgroundColor", "var(--jhs-status-down)"), i4 = "multiple") : (a3 = c2[0].pageUrl, t3.attr("href", a3), t3.css("backgroundColor", "var(--jhs-status-down)")); - const s2 = "jhs_other_site_dmm", o3 = localStorage.getItem(s2) ? JSON.parse(localStorage.getItem(s2)) : {}; - return o3[this.carNum] = { - type: i4, - url: a3 - }, localStorage.setItem(s2, JSON.stringify(o3)), c2; - } - clog.debug(`[${n3}] API 返回结果数 ${l2.result.result_count},但无精确匹配的 Content ID。`); - } - hadSuccessfulRequest && (this.lastError = null); - clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"); - const i2 = $("#fanzaBtn"); - return i2.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), i2.attr("title", "未查询到, 点击前往搜索页"), i2.css("backgroundColor", "var(--jhs-status-filter)"), null; - } - async _extractTrailerLinks({ contentId: e2, serviceCode: t2, floorCode: n2 }) { - const a2 = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e2}/mtype=AhRVShI_/service=${t2}/floor=${n2}/mode=/`, i2 = await gmHttp.get(a2, null, { - "accept-language": "ja-JP,ja;q=0.9", - Cookie: "age_check_done=1" - }); - if ("string" != typeof i2) throw clog.error(i2), new ProviderError("dmm", "PARSE_ERROR", "解析播放页内容失败, 非文本内容", { - url: a2 - }); - if (i2.includes("このサービスはお住まいの地域からは")) throw new ProviderError("dmm", "REGION_BLOCKED", "DMM 预览源不可用,请将 DMM 域名分流到日本 IP", { - url: a2 - }); - const s2 = i2.match(/const\s+args\s+=\s+(.*);/); - if (!s2) throw new ProviderError("dmm", "PARSE_ERROR", "未在脚本中找到 const args = ... 变量", { - url: a2 - }); - let o2; - try { - ({ bitrates: o2 } = JSON.parse(s2[1])); - } catch (d2) { - throw new ProviderError("dmm", "PARSE_ERROR", `解析播放器脚本 JSON 失败: ${d2.message}`, { - cause: d2, - url: a2 - }); - } - const r2 = {}, l2 = L.map(((e3) => e3.quality)).join("|"), c2 = new RegExp(`(${l2})\\.mp4$`); - if (!Array.isArray(o2)) throw clog.error("解析画质链接失败: bitrates 字段不是一个数组或不存在"), new ProviderError("dmm", "PARSE_ERROR", "解析画质链接失败: bitrates 字段不是一个数组或不存在", { - url: a2 - }); - clog.debug("原始数据返回:", o2); - for (const h2 of o2) { - const e3 = null == h2 ? void 0 : h2.src; - if (!e3 || "string" != typeof e3 || !e3.endsWith(".mp4")) continue; - const t3 = e3.match(c2); - let n3 = ""; - t3 && t3[1] && (n3 = t3[1]), n3 && !r2[n3] && (r2[n3] = e3); - } - if (0 === Object.keys(r2).length) throw new ProviderError("dmm", "PARSE_ERROR", "未找到匹配要求的预览画质视频", { - url: a2 - }); - return r2; - } - async fetchVideo() { - const carNum = normalizeCarNum(this.carNum); - if (!carNum) return clog.warn("跳过 DMM 解析:番号不可用"), null; - this.carNum = carNum; - const e2 = this._checkCache(); - if (e2) return e2; - let t2; - try { - const e3 = this.carNum.toLowerCase(); - if (e3.startsWith("heyzo") || /^(n\d+|\d+(-\d+)*)$/.test(e3) || /^n\d+$/.test(e3)) return clog.debug("无码番号类型,取消 DMM 解析"), null; - if (this.carNum.includes("VR-")) return clog.debug("VR 类型,取消 DMM 解析"), null; - t2 = await this._searchContentIds(); - } catch (n2) { - this.lastError = n2 instanceof ProviderError ? n2 : new ProviderError("dmm", "PARSE_ERROR", n2.message || String(n2), { - cause: n2 - }), clog.error("DMM API 搜索失败:", this.lastError); - const e3 = $("#fanzaBtn"); - return e3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), e3.attr("title", "未查询到, 点击前往搜索页"), e3.css("backgroundColor", "var(--jhs-status-filter)"), null; - } - if (!t2 || 0 === t2.length) return null; - try { - const e3 = await Promise.any(t2.map(((e4) => this._extractTrailerLinks(e4)))); - return this._updateCache(e3), e3; - } catch (a2) { - const e3 = a2.errors || [a2]; - this.lastError = e3.find(((e4) => "REGION_BLOCKED" === e4?.code)) || e3.find(((e4) => e4 instanceof ProviderError)) || new ProviderError("dmm", "PARSE_ERROR", e3[0]?.message || String(e3[0]), { - cause: e3[0] - }), clog.error(`解析失败: ${this.lastError.message}`, e3); - const t3 = $("#fanzaBtn"); - return t3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), t3.attr("title", "未查询到, 点击前往搜索页"), t3.css("backgroundColor", "var(--jhs-status-filter)"), null; - } - } - }; - __name(_DmmPreviewParser, "DmmPreviewParser"); - var DmmPreviewParser = _DmmPreviewParser; - async function fetchDmmPreview(carNum) { - const parser = new DmmPreviewParser(carNum), sources = await parser.fetchVideo(); - return { - sources, - error: parser.lastError - }; - } - __name(fetchDmmPreview, "fetchDmmPreview"); - var _PreviewVideoPlugin = class _PreviewVideoPlugin extends BasePlugin { - getName() { - return "PreviewVideoPlugin"; - } - async initCss() { - return ".jhs-dmm-preview-player{display:none;width:100%;height:auto}.jhs-dmm-preview-player.is-active{display:block}.jhs-native-preview-hidden{display:none!important}"; - } - async handle() { - if (!isDetailPage) return; - const trigger = $(".preview-video-container"), openVideo = /* @__PURE__ */ __name(() => { - utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => { - this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))); - })); - }, "openVideo"); - trigger.off("click.jhsVideo").on("click.jhsVideo", openVideo); - await storageManager.getSetting("enableLoadPreviewVideo", _) !== _ || o.includes("autoPlay=1") || this.initDmm(); - const url = window.location.href; - (url.includes("gallery-1") || url.includes("gallery-2")) && openVideo(), url.includes("autoPlay=1") && trigger.length > 0 && trigger[0].click(); - } - async initDmm() { - try { - const { sources } = await this.getDmmPreview(); - if (!sources) return; - const $video = $("#preview-video"), video = $video[0]; - if (video) return; - clog.debug("JavDB没有视频播放元素, 开始创建..."); - const cover = $(".column-video-cover img").attr("src"); - $(".preview-images").prepend(` - - 预告片 - - - `), $(".preview-video-container").off("click.jhsVideo").on("click.jhsVideo", (() => { - utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))))); - })); - } catch (error) { - clog.error("预加载 DMM 失败:", error); - } - } - /** 复用单次 DMM 请求,避免预加载和点击处理重复抓取。 */ - getDmmPreview() { - if (this.dmmPreviewPromise) return this.dmmPreviewPromise; - this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum).then(((result) => { - (result.error?.retryable || "HTTP_ERROR" === result.error?.code) && (this.dmmPreviewPromise = null); - return result; - }), ((error) => { - this.dmmPreviewPromise = null; - throw error; - })); - return this.dmmPreviewPromise; - } - /** 创建与 JavDB HLS 生命周期完全隔离的 DMM 播放器。 */ - createDmmPlayer($nativeVideo) { - const $host = $nativeVideo.parent(), existing = $host.find("#jhs-preview-video"); - if (existing.length) return existing; - const $player = $(''); - return $nativeVideo.after($player), $player; - } - /** 销毁 JHS 播放器并把播放权完整交回 JavDB。 */ - async restoreNativePlayer($nativeVideo, nativeVideo, notify = false) { - const $dmmVideo = $nativeVideo.parent().find("#jhs-preview-video"), dmmVideo = $dmmVideo[0]; - dmmVideo && (dmmVideo.pause(), $dmmVideo.removeAttr("src"), dmmVideo.load(), $dmmVideo.remove()); - $nativeVideo.removeClass("jhs-native-preview-hidden"); - return safePlay(nativeVideo, { - context: "JavDB 原生预览回退", - notify - }); - } - async handleVideo() { - const $nativeVideo = $("#preview-video"); - if (!$nativeVideo.length) return; - const $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = localStorage.getItem("jhs_videoMuted"); - void safePlay(nativeVideo, { - context: "JavDB 原生预览", - notify: false - }); - const dmmEnabled = await storageManager.getSetting("enableLoadPreviewVideo", _) !== C, dmmResult = dmmEnabled ? await this.getDmmPreview() : { - sources: null, - error: null - }, { sources, error } = dmmResult, $toolbar = $("
    ").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
    ").addClass("jhs-video-quality-list").attr({ - role: "group", - "aria-label": "视频画质" - }); - $host.find("#video-bottom-toolbar").remove(); - let dmmPlayed = false, $dmmVideo = null, dmmVideo = null; - if (sources) { - const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; - const currentTime = nativeVideo.currentTime; - $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = !muted || "yes" === muted, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { - localStorage.setItem("jhs_videoMuted", dmmVideo.muted ? "yes" : "no"); - })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); - dmmPlayed = await safePlay(dmmVideo, { - context: "JavDB 高画质预览", - notify: false - }); - if (!dmmPlayed && !dmmVideo.muted) dmmVideo.muted = true, dmmPlayed = await safePlay(dmmVideo, { - context: "JavDB 高画质预览静音重试", - notify: false - }); - dmmPlayed ? (nativeVideo.pause(), $nativeVideo.addClass("jhs-native-preview-hidden")) : ($dmmVideo.removeClass("is-active"), await this.restoreNativePlayer($nativeVideo, nativeVideo, true)); - dmmPlayed && L.forEach(((quality) => { - const qualitySource = sources[quality.quality]; - if (!qualitySource) return; - const active = dmmPlayed && selectedQuality === quality.quality; - $qualityList.append($(``)); - })); - } - $toolbar.append($qualityList); - const $actions = $("
    ").addClass("jhs-toolbar"); - $actions.append('', '', ''), $toolbar.append($actions), $host.append($toolbar), sources || await safePlay(nativeVideo, { - context: "JavDB 预览视频", - notify: true, - message: "REGION_BLOCKED" === error?.code ? error.message : "当前视频源无法播放" - }); - $toolbar.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (event) => { - const $button = $(event.currentTarget); - if ($button.hasClass("active")) return; - try { - if (!dmmVideo) return; - const currentTime = dmmVideo.currentTime, previousSource = $dmmVideo.attr("src"); - $dmmVideo.attr("src", $button.data("video-src")), dmmVideo.load(), dmmVideo.currentTime = currentTime; - const played = await safePlay(dmmVideo, { - context: "JavDB 画质切换", - notify: false - }); - if (played) $toolbar.find(".jhs-video-quality-btn").removeClass("active").attr("aria-pressed", "false"), $button.addClass("active").attr("aria-pressed", "true"); - else { - previousSource && ($dmmVideo.attr("src", previousSource), dmmVideo.load(), dmmVideo.currentTime = currentTime); - const restored = previousSource && await safePlay(dmmVideo, { - context: "JavDB 画质切换回退", - notify: false - }); - restored || await this.restoreNativePlayer($nativeVideo, nativeVideo, true); - } - } catch (playbackError) { - clog.error("切换画质失败:", playbackError); - } - })), $("#speed-btn").off("click.jhsVideo").on("click.jhsVideo", (() => { - dmmVideo && (dmmVideo.currentTime += 10); - })), $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", ((event) => (event.preventDefault(), this.getDependency("DetailPageButtonPlugin").filterOne(event)))), $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getDependency("DetailPageButtonPlugin").filterOne(event))), $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getDependency("DetailPageButtonPlugin").favoriteOne(event))); - } - }; - __name(_PreviewVideoPlugin, "PreviewVideoPlugin"); - var PreviewVideoPlugin = _PreviewVideoPlugin; - - // src/plugins/image-viewer/bus-preview-video.js - var ENCRYPTION_SALT = "x7k9p3"; - async function getEncryptionKey() { - const enc = new TextEncoder(); - const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(ENCRYPTION_SALT + ".jhs.v1"), { - name: "PBKDF2" - }, false, ["deriveKey"]); - return crypto.subtle.deriveKey({ - name: "PBKDF2", - salt: enc.encode("jhs-backup"), - iterations: 1e5, - hash: "SHA-256" - }, keyMaterial, { - name: "AES-GCM", - length: 256 - }, false, ["encrypt", "decrypt"]); - } - __name(getEncryptionKey, "getEncryptionKey"); - function arrayBufferToBase64(e2) { - const t2 = new Uint8Array(e2), n2 = 32768; - let a2 = ""; - for (let i2 = 0; i2 < t2.length; i2 += n2) a2 += String.fromCharCode.apply(null, t2.subarray(i2, i2 + n2)); - return btoa(a2); - } - __name(arrayBufferToBase64, "arrayBufferToBase64"); - function base64ToArrayBuffer(e2) { - const t2 = atob(e2), n2 = new Uint8Array(t2.length); - for (let a2 = 0; a2 < t2.length; a2++) n2[a2] = t2.charCodeAt(a2); - return n2; - } - __name(base64ToArrayBuffer, "base64ToArrayBuffer"); - async function encryptData(e2) { - const t2 = await getEncryptionKey(), n2 = crypto.getRandomValues(new Uint8Array(12)), a2 = new TextEncoder(), i2 = await crypto.subtle.encrypt({ - name: "AES-GCM", - iv: n2 - }, t2, a2.encode(e2)), s2 = new Uint8Array(n2.length + i2.byteLength); - return s2.set(n2), s2.set(new Uint8Array(i2), n2.length), arrayBufferToBase64(s2); - } - __name(encryptData, "encryptData"); - async function decryptData(e2) { - const t2 = await getEncryptionKey(), n2 = base64ToArrayBuffer(e2), a2 = n2.slice(0, 12), i2 = n2.slice(12), s2 = await crypto.subtle.decrypt({ - name: "AES-GCM", - iv: a2 - }, t2, i2); - return new TextDecoder().decode(s2); - } - __name(decryptData, "decryptData"); - var CREDENTIAL_PREFIX = "AES:"; - async function encryptCredential(e2) { - return e2 && !e2.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(e2) : e2; - } - __name(encryptCredential, "encryptCredential"); - async function decryptCredential(e2) { - return e2 && e2.startsWith(CREDENTIAL_PREFIX) ? await decryptData(e2.slice(CREDENTIAL_PREFIX.length)) : e2; - } - __name(decryptCredential, "decryptCredential"); - var _BusPreviewVideoPlugin = class _BusPreviewVideoPlugin extends BasePlugin { - getName() { - return "BusPreviewVideoPlugin"; - } - async initCss() { - return "\n .bus-preview-modal { position:fixed; inset:0; z-index:var(--jhs-z-modal); display:flex; align-items:center; justify-content:center; visibility:hidden; opacity:0; background:rgba(0,0,0,.95); transition:opacity var(--jhs-motion-base) var(--jhs-ease); }\n .bus-preview-modal.is-open { visibility:visible; opacity:1; }\n .bus-preview-modal-content { position:relative; display:flex; max-width:95%; max-height:95%; flex-direction:column; align-items:center; gap:var(--jhs-space-3); }\n .video-player-wrapper { position:relative; width:80vw; max-width:100%; max-height:85vh; aspect-ratio:16/9; background:#000; }\n .video-player-wrapper #preview-video { position:absolute; inset:0; }\n "; - } - initModal() { - if (0 === $("#bus-preview-modal").length) { - $("body").append('\n
    \n
    \n
    \n
    \n '); - const e2 = $("#bus-preview-modal"); - e2.on("click", ((e3) => { - "bus-preview-modal" === e3.target.id && this.closeVideoModal(); - })), $(document).on("keydown", ((t2) => { - "Escape" === t2.key && e2.hasClass("is-open") && this.closeVideoModal(); - })); - } - } - closeVideoModal() { - const e2 = $("#preview-video"); - e2.length > 0 && e2[0].pause(), $("#bus-preview-modal").removeClass("is-open"); - } - async handle() { - if (!isDetailPage) return; - this.initModal(); - const e2 = $("#sample-waterfall .sample-box .photo-frame img:first").attr("src"), t2 = $(` - `); - $("#sample-waterfall").prepend(t2); - "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum).catch(((e3) => clog.warn("预加载 DMM 失败", e3))); - let n2 = false, a2 = $(".preview-video-container"); - a2.on("click", (async (e3) => { - if (e3.preventDefault(), e3.stopPropagation(), n2) show.info("正在加载中, 勿重复点击"); - else { - n2 = true; - try { - await this.handleVideo(); - } finally { - n2 = false; - } - } - })), window.location.href.includes("autoPlay=1") && a2.trigger("click"); - } - async handleVideo() { - const e2 = $("#bus-preview-modal"), t2 = e2.find(".bus-preview-modal-content"); - let n2 = $("#preview-video"); - if (n2.length > 0) return e2.addClass("is-open"), void await safePlay(n2[0], { - context: "JavBus 预览视频", - notify: true - }); - let a2 = this.getPageInfo().carNum; - const { sources: i2, error: previewError } = await fetchDmmPreview(a2); - i2 && 0 !== Object.keys(i2).length ? (await this.createVideoPlayerAndControls(i2, t2), n2 = $("#preview-video"), n2.length > 0 ? (e2.addClass("is-open"), await safePlay(n2[0], { - context: "JavBus 预览视频", - notify: true, - message: "REGION_BLOCKED" === previewError?.code ? previewError.message : "当前视频源无法播放" - })) : show.error("视频播放器创建失败。")) : show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未找到可用的视频源。"); - } - async createVideoPlayerAndControls(e2, t2) { - let n2 = await storageManager.getSetting("videoQuality"); - n2 = Z(Object.keys(e2), n2); - let a2 = e2[n2]; - t2.html(` -
    - -
    -
    -
    - `); - const i2 = $("#preview-video"), s2 = i2.find("source"), o2 = t2.find(".jhs-video-quality-list"); - if (!i2.length || !s2.length) return; - const r2 = i2[0], l2 = localStorage.getItem("jhs_videoMuted"); - r2.muted = !l2 || "yes" === l2, i2.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (function() { - localStorage.setItem("jhs_videoMuted", r2.muted ? "yes" : "no"); - })); - let c2 = ""; - L.forEach(((t3) => { - let a3 = e2[t3.quality]; - if (a3) { - const e3 = n2 === t3.quality; - c2 += ` - - `; - } - })), o2.html(c2); - const d2 = o2.find(".jhs-video-quality-btn"); - o2.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (e3) => { - try { - const t3 = $(e3.currentTarget); - if (t3.hasClass("active")) return; - let n3 = t3.attr("data-video-src"); - s2.attr("src", n3); - const a3 = r2.currentTime; - r2.load(), r2.currentTime = a3, await safePlay(r2, { - context: "JavBus 画质切换", - notify: true - }) && (d2.removeClass("active").attr("aria-pressed", "false"), t3.addClass("active").attr("aria-pressed", "true")); - } catch (t3) { - clog.error("切换画质失败:", t3); - } - })); - } - }; - __name(_BusPreviewVideoPlugin, "BusPreviewVideoPlugin"); - var BusPreviewVideoPlugin = _BusPreviewVideoPlugin; - - // src/plugins/backup/setting-backup.js - async function importSettingData(showDiffPreviewFn) { - try { - const input = document.createElement("input"); - input.type = "file", input.accept = ".json"; - const cleanup = /* @__PURE__ */ __name(() => input.remove(), "cleanup"); - input.onchange = async (e2) => { - const t2 = e2.target.files[0]; - if (!t2) return void cleanup(); - const n2 = new FileReader(); - n2.onload = async (e3) => { - cleanup(); - try { - const t3 = e3.target.result.toString(), n3 = JSON.parse(t3); - if (!n3 || "object" != typeof n3 || Array.isArray(n3)) throw new Error("文件内容不是有效的数据对象"); - const a2 = loading(); - try { - const e4 = await storageManager.exportData(), t4 = await storageManager.diffData(e4, n3); - a2.close(), showDiffPreviewFn(t4, n3, null); - } catch (i2) { - a2.close(), clog.error(i2), show.error("差异分析失败: " + i2.message); - } - } catch (t3) { - clog.error(t3), show.error("导入失败:文件内容不是有效的JSON格式 " + t3.message); - } - }, n2.onerror = () => { - cleanup(), show.error("读取文件时出错"); - }, n2.readAsText(t2); - }, document.body.appendChild(input), input.click(); - setTimeout(cleanup, 3e5); - } catch (e2) { - clog.error(e2), show.error("导入数据时出错: " + e2.message); - } - } - __name(importSettingData, "importSettingData"); - async function backupDataByWebDav(folderName) { - const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; - if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); - const a2 = t2.webDavUsername; - if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); - const i2 = await decryptCredential(t2.webDavPassword); - if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); - let s2 = utils.getNowStr("_", "_") + ".json", o2 = JSON.stringify(await storageManager.exportData()); - o2 = await encryptData(o2); - let r2 = loading(); - try { - const e2 = new WebDavClient(n2, a2, i2); - await e2.backup(folderName, s2, o2), show.ok("备份完成"); - } catch (l2) { - clog.error(l2), show.error(l2.toString()); - } finally { - r2.close(); - } - } - __name(backupDataByWebDav, "backupDataByWebDav"); - async function backupListBtnByWebDav(folderName, openFileListDialogFn) { - const t2 = await storageManager.getSetting(), n2 = t2.webDavUrl; - if (!n2) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); - const a2 = t2.webDavUsername; - if (!a2) return void show.error("请填写webDav用户名并保存后, 再试此功能"); - const i2 = await decryptCredential(t2.webDavPassword); - if (!i2) return void show.error("请填写webDav密码并保存后, 再试此功能"); - let s2 = loading(); - try { - const e2 = new WebDavClient(n2, a2, i2), t3 = await e2.getBackupList(folderName); - openFileListDialogFn(t3, e2, "WebDav"); - } catch (o2) { - clog.error(o2), show.error(`发生错误: ${o2 ? o2.message : o2}`); - } finally { - s2.close(); - } - } - __name(backupListBtnByWebDav, "backupListBtnByWebDav"); - function openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn) { - const formatSize = /* @__PURE__ */ __name((size) => { - const units = ["B", "KB", "MB", "GB", "TB", "PB"]; - let i2 = 0, s2 = size; - for (; s2 >= 1024 && i2 < units.length - 1; ) s2 /= 1024, i2++; - return `${s2 % 1 == 0 ? s2.toFixed(0) : s2.toFixed(2)} ${units[i2]}`; - }, "formatSize"); - const renderCards = /* @__PURE__ */ __name((files) => { - if (!files || files.length === 0) { - return '
    暂无数据
    '; - } - return files.map((file, idx) => ` -
    -
    ${escapeHtml(file.name)}
    -
    ${formatSize(file.size)} · ${utils.getNowStr("-", ":", file.createTime)}
    -
    - - - -
    -
    - `).join(""); - }, "renderCards"); - const containerId = "jhs-backup-card-list"; - layer.open({ - type: 1, - title: n2 + "备份文件", - content: `
    ${renderCards(e2)}
    `, - area: utils.getResponsiveArea(["800px", "70%"]), - anim: -1, - success: /* @__PURE__ */ __name((layerEl) => { - const container = $(layerEl).find(`#${containerId}`); - container.on("click", ".jhs-backup-btn", async (ev) => { - const btn = $(ev.currentTarget); - const action = btn.data("action"); - const idx = btn.data("idx"); - const file = e2[idx]; - if (!file) return; - if (action === "delete") { - layer.confirm(`是否删除 ${file.name} ?`, { - icon: 3, - title: "提示", - btn: ["确定", "取消"] - }, async (confirmIdx) => { - layer.close(confirmIdx); - let load = loading(); - try { - await t2.deleteFile(file.fileId); - e2 = await t2.getBackupList(folderName); - container.html(renderCards(e2)); - layer.alert("删除成功"); - } catch (err) { - clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); - } finally { - load.close(); - } - }); - } else if (action === "download") { - let load = loading(); - try { - const data = await decryptData(await t2.getFileContent(file.fileId)); - utils.download(data, file.name); - } catch (err) { - clog.error(err), show.error("下载失败: " + err); - } finally { - load.close(); - } - } else if (action === "import") { - let load = loading(); - try { - let data = await t2.getFileContent(file.fileId); - data = await decryptData(data); - const parsed = JSON.parse(data); - const currentData = await storageManager.exportData(); - const diff = await storageManager.diffData(currentData, parsed); - load.close(); - showDiffPreviewFn(diff, null, parsed); - } catch (err) { - load.close(), clog.error(err), show.error("预览失败: " + (err ? err.message : err)); - } - } - }); - }, "success") - }); - } - __name(openFileListDialogMobile, "openFileListDialogMobile"); - function openFileListDialog(e2, t2, n2, folderName, showDiffPreviewFn) { - if (utils.isMobileMode()) { - openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn); - return; + }, "success") + }); + } + __name(openFileListDialogMobile, "openFileListDialogMobile"); + function openFileListDialog(e2, t2, n2, folderName, showDiffPreviewFn) { + if (utils.isMobileMode()) { + openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn); + return; } layer.open({ type: 1, @@ -5353,7 +4921,8 @@ area: utils.getResponsiveArea(["800px", "70%"]), anim: -1, success: /* @__PURE__ */ __name((a2) => { - const i2 = new Tabulator("#table-container", { + const i2 = createJhsTable(Tabulator, "#table-container", { + pagination: false, layout: "fitColumns", placeholder: "暂无数据", virtualDom: true, @@ -5871,7 +5440,7 @@ `; } __name(buildSettingCss, "buildSettingCss"); - async function applyImageMode() { + async function applyImageMode(busImgPlugin = null) { $("#verticalImgStyle").remove(); if (await storageManager.getSetting("enableVerticalModel", C) === _) { let e2 = "100% 50% !important"; @@ -5916,24 +5485,11 @@ `; $("`; } constructor() { - super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "cache", null), i(this, "translationPending", /* @__PURE__ */ new Map()), i(this, "filterContext", null), i(this, "pendingItems", /* @__PURE__ */ new Set()), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), i(this, "_debouncedTranslateWrite", null), i(this, "itemIndex", /* @__PURE__ */ new Map()), i(this, "recountFrame", null); + super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "filterContext", null), i(this, "pendingItems", /* @__PURE__ */ new Set()), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), i(this, "itemIndex", /* @__PURE__ */ new Map()), i(this, "recountFrame", null); } getName() { return "ListPagePlugin"; @@ -6294,7 +5850,7 @@ const b2 = utils.time("组装数据耗时"), P2 = (null == s2 ? void 0 : s2.tagPosition) || "rightTop"; const O2 = n2.filter(((e3) => e3)); this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); - const R2 = []; + const R = []; for (let n3 = 0; n3 < e2.length; n3++) { n3 > 0 && n3 % 12 == 0 && await this.yieldListFrame(); let t2 = $(e2[n3]); @@ -6327,9 +5883,9 @@ } } } - hardHidden || R2.push(t2); + hardHidden || R.push(t2); } - this.scheduleRecount(), void this.translateListItems(R2).catch(((e3) => clog.error("列表页翻译任务失败", e3))); + this.scheduleRecount(), void this.translateListItems(R).catch(((e3) => clog.error("列表页翻译任务失败", e3))); const D2 = utils.time("处理页面耗时"), A2 = utils.time("累计耗费时间"); clog.log(` @@ -6509,20 +6065,6 @@ })); })); } - getTranslationCache() { - if (this.cache && "object" == typeof this.cache && !Array.isArray(this.cache)) return this.cache; - try { - this.cache = JSON.parse(localStorage.getItem("jhs_translate") || "{}"); - } catch (error) { - clog.warn("列表翻译缓存无法解析,已忽略旧缓存", error), this.cache = {}; - } - return this.cache && "object" == typeof this.cache && !Array.isArray(this.cache) ? this.cache : this.cache = {}; - } - scheduleTranslationWrite() { - this._debouncedTranslateWrite && clearTimeout(this._debouncedTranslateWrite), this._debouncedTranslateWrite = setTimeout((() => { - localStorage.setItem("jhs_translate", JSON.stringify(this.getTranslationCache())); - }), 500); - } applyTranslatedTitle(e2, t2, n2) { const a2 = e2.find(".video-title"); r ? (a2.contents().each((function() { @@ -6532,13 +6074,9 @@ async translate(e2) { let t2, n2, a2 = e2.find(".video-title"); if (r ? (t2 = a2.contents().filter(((e3, t3) => 3 === t3.nodeType && "" !== t3.textContent.trim())).text().trim(), n2 = e2.find(".video-title strong").text().trim()) : (t2 = (e2.find("img").attr("data-title") || "").trim(), n2 = (e2.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t2 || !n2) return; - const cache = this.getTranslationCache(); - if (cache[n2]) return void this.applyTranslatedTitle(e2, cache[n2], n2); - let pending = this.translationPending.get(n2); - if (!pending) { - pending = _e(t2).then(((translated) => (cache[n2] = translated, this.scheduleTranslationWrite(), translated))).finally((() => this.translationPending.delete(n2))), this.translationPending.set(n2, pending); - } - this.applyTranslatedTitle(e2, await pending, n2); + const scope = await this.getRuntimeService("scope")(); + const translated = await this.getRuntimeService("translation").translate(t2, { scope }); + this.applyTranslatedTitle(e2, translated, n2); } async revertTranslation() { $(this.getSelector().itemSelector).toArray().forEach(((e2) => { @@ -6582,12 +6120,12 @@ var ListPagePlugin = _ListPagePlugin; // src/plugins/backup/setting-forms.js - async function loadSettingForm(getBean) { + async function loadSettingForm(dependencies) { let e2 = await storageManager.getSetting(); $("#videoQuality").val(e2.videoQuality), $("#reviewCount").val(e2.reviewCount || 20), $("#tagPosition").val(e2.tagPosition || "rightTop"), $("#defaultQuickFilterTab").val(normalizeQuickFilterKey(e2.defaultQuickFilterTab)), $("#needClosePageBasic").prop("checked", !e2.needClosePage || e2.needClosePage === _), $("#autoRemoveNewVideoMarkAfterBrowse").prop("checked", !!e2.autoRemoveNewVideoMarkAfterBrowse && e2.autoRemoveNewVideoMarkAfterBrowse === _), $("#waitCheckCount").val(e2.waitCheckCount || 5), $("#checkConcurrencyCount").val(parseNumberSetting(e2.checkConcurrencyCount, 2, { min: 2, max: 5 })), $("#checkRequestSleep").val(parseNumberSetting(e2.checkRequestSleep, 100, { min: 0, max: 3e3 })), $("#enableCheckBlacklist").val(e2.enableCheckBlacklist || _), $("#checkBlacklist_intervalTime").val(e2.checkBlacklist_intervalTime || 12), $("#checkBlacklist_ruleTime").val(parseNumberSetting(e2.checkBlacklist_ruleTime, 8760, { min: 0 })), $("#enableCheckFavoriteActress").val(e2.enableCheckFavoriteActress || _), $("#checkFavoriteActress_IntervalTime").val(e2.checkFavoriteActress_IntervalTime || 24), $("#enableCheckNewVideo").val(e2.enableCheckNewVideo || _), $("#checkNewVideo_intervalTime").val(e2.checkNewVideo_intervalTime || 12), $("#checkNewVideo_ruleTime").val(parseNumberSetting(e2.checkNewVideo_ruleTime, 8760, { min: 0 })); const t2 = e2.highlightedTagNumber || 1, n2 = e2.highlightedTagColor || "#ce2222"; $("#highlightedTagNumber").val(e2.highlightedTagNumber || 1), $("#highlightedTagColor").val(e2.highlightedTagColor || "#ce2222"), $("#highlightedTagLabel").css("border", `${t2}px solid ${n2}`), $("#enableClog").val(e2.enableClog || _), $("#clogMsgCount").val(e2.clogMsgCount || 2e3), $("#mobileMode").val(e2.mobileMode || "auto"), $("#themeMode").val(e2.themeMode || "light"), $("#httpTimeout").val(e2.httpTimeout || 5e3), $("#httpRetryCount").val(e2.httpRetryCount || 3), $("#webDavUrl").val(e2.webDavUrl || ""), $("#webDavUsername").val(e2.webDavUsername || ""), $("#webDavPassword").val(await decryptCredential(e2.webDavPassword) || ""), $("#enableTitleSelectFilter").prop("checked", !e2.enableTitleSelectFilter || e2.enableTitleSelectFilter === _), $("#enableFavoriteActresses").prop("checked", !e2.enableFavoriteActresses || e2.enableFavoriteActresses === _), $("#enableSaveActressCarInfo").prop("checked", !!e2.enableSaveActressCarInfo && e2.enableSaveActressCarInfo === _), $("#enableScreenSvg").prop("checked", !e2.enableScreenSvg || e2.enableScreenSvg === _), $("#enableVideoSvg").prop("checked", !e2.enableVideoSvg || e2.enableVideoSvg === _), $("#enableHandleSvg").prop("checked", !e2.enableHandleSvg || e2.enableHandleSvg === _), $("#enableSiteSvg").prop("checked", !e2.enableSiteSvg || e2.enableSiteSvg === _), $("#enableCopySvg").prop("checked", !e2.enableCopySvg || e2.enableCopySvg === _), $("#enableLoadActressInfo").prop("checked", !e2.enableLoadActressInfo || e2.enableLoadActressInfo === _), $("#enableVerticalModel").prop("checked", !!e2.enableVerticalModel && e2.enableVerticalModel === _), $("#containerColumns").val(e2.containerColumns || 5), $("#showContainerColumns").text(e2.containerColumns || 5), $("#containerWidth").val((e2.containerWidth || 100) - 70), $("#showContainerWidth").text((e2.containerWidth || 100) + "%"); - const a2 = getBean("OtherSitePlugin"), i2 = await a2.getMissAvUrl(), s2 = await a2.getjableUrl(), o2 = await a2.getAvgleUrl(), r2 = await a2.getJavTrailersUrl(), l2 = await a2.getAv123Url(), c2 = await a2.getJavDbUrl(), d2 = await a2.getJavBusUrl(), h2 = await a2.getSupJavUrl(); + const a2 = dependencies.otherSite, i2 = await a2.getMissAvUrl(), s2 = await a2.getjableUrl(), o2 = await a2.getAvgleUrl(), r2 = await a2.getJavTrailersUrl(), l2 = await a2.getAv123Url(), c2 = await a2.getJavDbUrl(), d2 = await a2.getJavBusUrl(), h2 = await a2.getSupJavUrl(); $("#missAvUrl").val(i2), $("#jableUrl").val(s2), $("#avgleUrl").val(o2), $("#javTrailersUrl").val(r2), $("#av123Url").val(l2), $("#javDbUrl").val(c2), $("#javBusUrl").val(d2), $("#supJavUrl").val(h2); let g2 = await storageManager.getReviewFilterKeywordList(), p2 = await storageManager.getTitleFilterKeyword(); g2 && g2.forEach(((e3) => { @@ -6599,10 +6137,10 @@ "Enter" === t3.key && addKeyword(t3, e3); })); })); - bindLayoutRangeEvents(); + bindLayoutRangeEvents(dependencies.busImg); } __name(loadSettingForm, "loadSettingForm"); - function bindLayoutRangeEvents() { + function bindLayoutRangeEvents(busImgPlugin) { $("#containerColumns").off(".jhsSetting").on("input.jhsSetting", (() => { const columns = $("#containerColumns").val(); $("#showContainerColumns").text(columns); @@ -6610,7 +6148,7 @@ movieList && (movieList.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); masonry && (masonry.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); })).on("change.jhsSetting", (async (event) => { - await storageManager.saveSettingItem("containerColumns", $(event.currentTarget).val()), await applyImageMode(); + await storageManager.saveSettingItem("containerColumns", $(event.currentTarget).val()), await applyImageMode(busImgPlugin); })); $("#containerWidth").off(".jhsSetting").on("input.jhsSetting", ((event) => { const width = parseInt($(event.target).val()) + 70, widthText = `${width}%`; @@ -6621,7 +6159,7 @@ })).on("change.jhsSetting", ((event) => storageManager.saveSettingItem("containerWidth", parseInt($(event.currentTarget).val()) + 70))); } __name(bindLayoutRangeEvents, "bindLayoutRangeEvents"); - async function initQuickSettingForm(getBean, getSelector, openSettingDialogFn) { + async function initQuickSettingForm(dependencies, getSelector, openSettingDialogFn) { let e2 = await storageManager.getSetting(); $("#needClosePage").prop("checked", !e2.needClosePage || e2.needClosePage === _), $("#autoPage").prop("checked", !e2.autoPage || e2.autoPage === _), $("#translateTitle").prop("checked", !e2.translateTitle || e2.translateTitle === _), $("#enableLoadActressInfo").prop("checked", !e2.enableLoadActressInfo || e2.enableLoadActressInfo === _), $("#enableLoadOtherSite").prop("checked", !e2.enableLoadOtherSite || e2.enableLoadOtherSite === _), $("#needClosePage").on("change", (async (t2) => { await storageManager.saveSettingItem("needClosePage", $("#needClosePage").is(":checked") ? _ : C), await jhsEventBus.emit("filter-rules-changed"); @@ -6630,7 +6168,7 @@ await storageManager.saveSettingItem("autoPage", n2), $("#sort-toggle-btn").prop("disabled", n2 === _).attr("title", n2 === _ ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"); })), $("#translateTitle").on("change", (async (t2) => { const n2 = $("#translateTitle").is(":checked") ? _ : C; - await storageManager.saveSettingItem("translateTitle", n2), n2 === _ ? (await getBean("ListPagePlugin").doFilter(), isDetailPage && await getBean("TranslatePlugin").translate()) : (await getBean("ListPagePlugin").revertTranslation(), $(".translated-title").remove()); + await storageManager.saveSettingItem("translateTitle", n2), n2 === _ ? (await dependencies.listPage.doFilter(), isDetailPage && await dependencies.translate?.translate()) : (await dependencies.listPage.revertTranslation(), $(".translated-title").remove()); })), $("#hoverBigImg").prop("checked", !!e2.hoverBigImg && e2.hoverBigImg === _), $("#hoverBigImg").on("change", (async (t2) => { const n2 = $("#hoverBigImg").is(":checked") ? _ : C; await storageManager.saveSettingItem("hoverBigImg", n2), window.imageHoverPreviewObj && (window.imageHoverPreviewObj.destroy(), window.imageHoverPreviewObj = null), n2 === _ && (window.imageHoverPreviewObj = new ImageHoverPreview({ @@ -6638,25 +6176,25 @@ })); })), $("#enableLoadActressInfo").on("change", (async (t2) => { const n2 = $("#enableLoadActressInfo").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadActressInfo", n2), n2 === _ ? getBean("ActressInfoPlugin").loadActressInfo() : $(".actress-info").remove(); + await storageManager.saveSettingItem("enableLoadActressInfo", n2), n2 === _ ? dependencies.actressInfo?.loadActressInfo() : $(".actress-info").remove(); })), $("#enableLoadOtherSite").on("change", (async (t2) => { const n2 = $("#enableLoadOtherSite").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadOtherSite", n2), n2 === _ ? await getBean("OtherSitePlugin").loadOtherSite() : $("#otherSiteBox").remove(); + await storageManager.saveSettingItem("enableLoadOtherSite", n2), n2 === _ ? await dependencies.otherSite.loadOtherSite() : $("#otherSiteBox").remove(); })), $("#enableLoadScreenShot").prop("checked", !e2.enableLoadScreenShot || e2.enableLoadScreenShot === _), $("#enableLoadScreenShot").on("change", (async (t2) => { const n2 = $("#enableLoadScreenShot").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadScreenShot", n2), n2 === _ ? await getBean("ScreenShotPlugin").loadScreenShot() : $(".screen-container").remove(); + await storageManager.saveSettingItem("enableLoadScreenShot", n2), n2 === _ ? await dependencies.screenshot.loadScreenShot() : $(".screen-container").remove(); })), $("#enableLoadPreviewVideo").prop("checked", !e2.enableLoadPreviewVideo || e2.enableLoadPreviewVideo === _), $("#enableLoadPreviewVideo").on("change", (async (t2) => { const n2 = $("#enableLoadPreviewVideo").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableLoadPreviewVideo", n2); })), $("#enableVerticalModel").prop("checked", !!e2.enableVerticalModel && e2.enableVerticalModel === _), $("#enableVerticalModel").on("change", (async (t2) => { const n2 = $("#enableVerticalModel").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableVerticalModel", n2), applyImageMode(); + await storageManager.saveSettingItem("enableVerticalModel", n2), applyImageMode(dependencies.busImg); })), $("#moreBtn").on("click", (() => { $(".simple-setting, .mini-simple-setting").html("").hide(), openSettingDialogFn("base-panel"); })); } __name(initQuickSettingForm, "initQuickSettingForm"); - async function saveSettingForm(getBean) { + async function saveSettingForm(dependencies) { let e2 = await storageManager.getSetting(); const nextWebDavUrl = String($("#webDavUrl").val() || "").trim(); let nextWebDavOrigin = null; @@ -6686,8 +6224,8 @@ let t3 = $(e3).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); n2.push(t3); })), await storageManager.saveTitleFilterKeyword(n2), show.ok("保存成功"), await jhsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }); - const a2 = getBean("NewVideoPlugin"); - a2 && a2.resetBtnTip(), getBean("BlacklistPlugin").resetBtnTip(), getBean("BlacklistPlugin").reloadTable(); + const a2 = dependencies.newVideo; + a2 && a2.resetBtnTip(), dependencies.blacklist.resetBtnTip(), dependencies.blacklist.reloadTable(); } __name(saveSettingForm, "saveSettingForm"); function addLabelTag(e2, t2) { @@ -7463,7 +7001,8 @@ }; if (0 === e2.length) return void $("#snapshot-list").html('
    暂无快照,点击上方按钮创建
    '); $("#snapshot-list").find(".tabulator").length && $("#snapshot-list").empty(); - const n2 = new Tabulator("#snapshot-list", { + const n2 = createJhsTable(Tabulator, "#snapshot-list", { + pagination: false, layout: "fitColumns", placeholder: "暂无数据", data: e2, @@ -7565,9 +7104,10 @@ }); } __name(showDiffPreview, "showDiffPreview"); - async function renderPluginMgmtPanel(pluginManager) { + async function renderPluginMgmtPanel(diagnostics) { + const diagnosticSnapshot = diagnostics.exportSnapshot(); const disabled = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); - const allNames = pluginManager.getPluginNames(); + const allNames = diagnosticSnapshot.legacyPlugins; const { categories, corePlugins, pluginMeta } = getPluginCategories(); const registeredSet = new Set(allNames); let html = ""; @@ -7606,13 +7146,13 @@ if (!list.includes(disabledId)) list.push(disabledId); } await storageManager.saveSettingItem("disabledPlugins", JSON.stringify(list)); - const all = pluginManager.getPluginNames(); + const all = diagnosticSnapshot.legacyPlugins; $("#pm-total").text(all.length); $("#pm-enabled").text(all.length - list.length); $("#pm-disabled").text(list.length); show.ok(`插件 "${name}" 已${$(e2.target).is(":checked") ? "启用" : "禁用"},刷新后生效`); }); - const startup = pluginManager.getStartupReport?.(), timings = pluginManager.getTimings(); + const startup = diagnosticSnapshot.legacyStartup, timings = diagnosticSnapshot.legacyTimings; const formatMs = /* @__PURE__ */ __name((value) => Number.isFinite(value) ? value.toFixed(1) : "0.0", "formatMs"); let startupHtml = startup ? `
    就绪: ${formatMs(startup.readyMs)} ms注册: ${formatMs(startup.registrationMs)} ms样式: ${formatMs(startup.cssMs)} ms即时插件: ${formatMs(startup.immediateMs)} ms空闲任务: ${startup.idleCompleted}/${startup.idleCompleted + startup.idlePending}

    就绪耗时不包含 @require 下载及浏览器脚本解析时间。

    ` : ""; if (timings.length) { @@ -7628,11 +7168,11 @@ } else { $("#plugin-timing-table").html(startupHtml + '

    暂无数据,刷新页面后自动采集。

    '); } - const errorLog = pluginManager.getErrorLog(); + const errorLog = diagnosticSnapshot.errors.filter((error) => error.source === "legacy-plugin"); if (errorLog.length) { let eHtml = '
    '; for (const err of [...errorLog].reverse()) { - eHtml += ``; + eHtml += ``; } eHtml += "
    时间插件阶段错误信息
    ${escapeHtml(err.time.substring(11, 19))}${escapeHtml(err.plugin)}${escapeHtml(err.phase)}${escapeHtml(err.message)}
    ${escapeHtml(String(err.timestamp || "").substring(11, 19))}${escapeHtml(err.plugin)}${escapeHtml(err.phase)}${escapeHtml(err.message)}
    "; $("#plugin-error-log").html(eHtml); @@ -7675,6 +7215,25 @@ } __name(repairDataHealthWithBackup, "repairDataHealthWithBackup"); + // src/core/cache-policy.js + var HOUR = 60 * 60 * 1e3; + var DAY = 24 * HOUR; + var CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); + var _ProviderError = class _ProviderError extends Error { + /** @param {string} provider @param {string} code @param {string} message @param {{cause?: unknown, status?: number, url?: string, retryable?: boolean}} [options] */ + constructor(provider, code, message, options = {}) { + super(message, { cause: options.cause }); + this.name = "ProviderError"; + this.provider = provider; + this.code = code; + this.status = options.status || 0; + this.url = options.url || ""; + this.retryable = Boolean(options.retryable); + } + }; + __name(_ProviderError, "ProviderError"); + var ProviderError = _ProviderError; + // src/plugins/one-one-five/client.js var _OneOneFiveClient = class _OneOneFiveClient { constructor(http = gmHttp) { @@ -7797,13 +7356,24 @@ getName() { return "SettingPlugin"; } + getFormDependencies() { + return Object.freeze({ + otherSite: this.getDependency("OtherSitePlugin"), + listPage: this.getDependency("ListPagePlugin"), + translate: this.getDependency("TranslatePlugin"), + actressInfo: this.getDependency("ActressInfoPlugin"), + screenshot: this.getDependency("ScreenShotPlugin"), + newVideo: this.getDependency("NewVideoPlugin"), + blacklist: this.getDependency("BlacklistPlugin"), + busImg: this.getDependency("BusImgPlugin") + }); + } async initCss() { const e2 = await storageManager.getSetting(); let t2 = (null == e2 ? void 0 : e2.containerWidth) ?? "100"; utils.isMobileMode() && (t2 = "100"); let n2 = utils.isMobileMode() ? 1 : (null == e2 ? void 0 : e2.containerColumns) ?? 5; - window.getBeanForSetting = this.getDependency.bind(this); - applyImageMode().catch(((e3) => clog.error("[JHS] applyImageMode failed:", e3))); + applyImageMode(this.getDependency("BusImgPlugin")).catch(((e3) => clog.error("[JHS] applyImageMode failed:", e3))); return buildSettingCss(t2, n2, l, r); } async handle() { @@ -7824,7 +7394,7 @@ })), $(".main-nav, .container-fluid").on("mouseenter", ".setting-box", (async () => { $(".simple-setting").html(buildQuickSettingHtml()).show(); try { - await initQuickSettingForm(this.getDependency.bind(this), this.getSelector.bind(this), this.openSettingDialog.bind(this)); + await initQuickSettingForm(this.getFormDependencies(), this.getSelector.bind(this), this.openSettingDialog.bind(this)); } catch (error) { clog.warn("桌面快捷设置初始化失败", error); } @@ -7834,7 +7404,7 @@ })), $(".main-nav, .container-fluid").on("mouseenter", ".mini-setting-box", (async () => { $(".mini-simple-setting").html(buildQuickSettingHtml()).show(); try { - await initQuickSettingForm(this.getDependency.bind(this), this.getSelector.bind(this), this.openSettingDialog.bind(this)); + await initQuickSettingForm(this.getFormDependencies(), this.getSelector.bind(this), this.openSettingDialog.bind(this)); } catch (error) { clog.warn("迷你快捷设置初始化失败", error); } @@ -7868,7 +7438,7 @@ event.shiftKey && document.activeElement === first ? (event.preventDefault(), last.focus()) : !event.shiftKey && document.activeElement === last && (event.preventDefault(), first.focus()); })); try { - await initQuickSettingForm(this.getDependency.bind(this), this.getSelector.bind(this), ((panel) => { + await initQuickSettingForm(this.getFormDependencies(), this.getSelector.bind(this), ((panel) => { closeQuickSetting(false), void this.openSettingDialog(panel).catch(((error) => clog.error("完整设置打开失败", error))); })), sheet.find(".jhs-quick-setting__close").trigger("focus"); } catch (error) { @@ -7885,7 +7455,7 @@ area: utils.getDialogArea("lg"), scrollbar: false, success: /* @__PURE__ */ __name(async (e3, n2) => { - $(e3).find(".layui-layer-content").css("position", "relative"), this.renderTaskStatuses(), injectHealthPanel(), injectPluginMgmtPanel(), injectSnapshotPanel(), injectNetworkPanel(), injectResourceSourcesPanel(), await loadSettingForm(this.getDependency.bind(this)), await this.loadResourceSettings(), JhsSelect.enhance(e3), this.bindClick(), $(".side-menu-item.active").attr("aria-current", "page"), utils.setupEscClose(n2), t2 && t2(); + $(e3).find(".layui-layer-content").css("position", "relative"), this.renderTaskStatuses(), injectHealthPanel(), injectPluginMgmtPanel(), injectSnapshotPanel(), injectNetworkPanel(), injectResourceSourcesPanel(), await loadSettingForm(this.getFormDependencies()), await this.loadResourceSettings(), JhsSelect.enhance(e3), this.bindClick(), $(".side-menu-item.active").attr("aria-current", "page"), utils.setupEscClose(n2), t2 && t2(); this.renderTaskStatuses(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.renderTaskStatuses())); if (utils.isMobileMode()) { this.collapseAdvancedTabs(); @@ -7972,15 +7542,15 @@ }); } bindClick() { - const settingPlugin = this; + const settingPlugin = this, webdav = this.getRuntimeService("webdav"); $(".side-menu-item").on("click", (function() { $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); const e3 = $(this).data("panel"); - $("#" + e3).show(), "cache-panel" === e3 ? ($("#saveBtn").hide(), $("#clean-all").removeClass("jhs-is-hidden")) : ($("#saveBtn").show(), $("#clean-all").addClass("jhs-is-hidden")), "health-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderDataHealthPanel()), "plugin-mgmt-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel(this.pluginManager)), "snapshot-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), "network-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel()); - })), $("#importBtn").on("click", ((e3) => importSettingData(showDiffPreview))), $("#exportBtn").on("click", ((e3) => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async (e3) => this.confirmCarNumbers(e3))), $("#webdavBackupBtn").on("click", ((e3) => backupDataByWebDav(this.folderName))), $("#webdavBackupListBtn").on("click", ((e3) => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, showDiffPreview)))), $("#saveBtn").on("click", (() => saveSettingForm(this.getDependency.bind(this)))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", ((e3) => { + $("#" + e3).show(), "cache-panel" === e3 ? ($("#saveBtn").hide(), $("#clean-all").removeClass("jhs-is-hidden")) : ($("#saveBtn").show(), $("#clean-all").addClass("jhs-is-hidden")), "health-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderDataHealthPanel()), "plugin-mgmt-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel(settingPlugin.getRuntimeService("diagnostics"))), "snapshot-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), "network-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel()); + })), $("#importBtn").on("click", ((e3) => importSettingData(showDiffPreview))), $("#exportBtn").on("click", ((e3) => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async (e3) => this.confirmCarNumbers(e3))), $("#webdavBackupBtn").on("click", ((e3) => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", ((e3) => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, showDiffPreview), webdav))), $("#saveBtn").on("click", (() => saveSettingForm(this.getFormDependencies()))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", ((e3) => { utils.q(e3, "修复前会自动下载备份,是否继续?", (() => repairDataHealthWithBackup())); })), $("#pm-clear-log").on("click", (() => { - this.pluginManager.clearErrorLog(), $("#plugin-error-log").text("无错误记录"), show.ok("错误日志已清空"); + this.getRuntimeService("diagnostics").clearErrors(), $("#plugin-error-log").text("无错误记录"), show.ok("错误日志已清空"); })), $("#createSnapshotBtn").on("click", (async () => { let e3 = loading(); try { @@ -8451,7 +8021,7 @@ this.checkBlacklist_ruleTime = parseNumberSetting(await storageManager.getSetting("checkBlacklist_ruleTime"), 8760, { min: 0 }); const e2 = await this.getTableData(), placeholder = document.createElement("div"); renderStateView(placeholder, { type: "empty", title: "没有符合当前筛选条件的黑名单记录" }); - this.tableObj = new Tabulator("#table-container", { + this.tableObj = createJhsTable(Tabulator, "#table-container", { layout: "fitColumns", placeholder, virtualDom: true, @@ -8706,222 +8276,35 @@ __name(_FilterTitleKeywordPlugin, "FilterTitleKeywordPlugin"); var FilterTitleKeywordPlugin = _FilterTitleKeywordPlugin; - // src/core/javdb-api.js - var U = "https://jdforrepam.com/api"; - var javDbMovieIdRequests = /* @__PURE__ */ new Map(); - function O() { - const e2 = "jhs_review_ts", t2 = "jhs_review_sign", n2 = Math.floor(Date.now() / 1e3); - if (n2 - (localStorage.getItem(e2) || 0) <= 20) return localStorage.getItem(t2); - const a2 = `${n2}.lpw6vgqzsp.${md5(`${n2}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; - return localStorage.setItem(e2, n2), localStorage.setItem(t2, a2), a2; - } - __name(O, "O"); - async function resolveJavDbMovieId(carNum) { - const normalized = normalizeCarNum(carNum); - if (!normalized) return null; - if (javDbMovieIdRequests.has(normalized)) return javDbMovieIdRequests.get(normalized); - const request = storageManager.cachedRequest(`javdb-movie-id:${normalized}`, 7 * 864e5, (async () => { - const response = await gmHttp.get(`${U}/v2/search`, { - q: normalized, - page: 1, - type: "movie", - limit: 20, - movie_type: "all", - from_recent: "false", - movie_filter_by: "all", - movie_sort_by: "relevance" - }, { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - host: "jdforrepam.com", - jdsignature: await O() - }); - if (!Array.isArray(response?.data?.movies)) throw new Error(response?.message || "JavDB 番号解析失败"); - const match = response.data.movies.find(((movie) => normalizeCarNum(movie.number) === normalized)); - return match?.id ? { __jhsCacheTtl: 7 * 864e5, data: { movieId: String(match.id) } } : { __jhsCacheTtl: 6 * 36e5, data: { miss: true } }; - })).then(((value) => value?.miss ? null : value?.movieId || null)); - javDbMovieIdRequests.set(normalized, request); - try { - return await request; - } finally { - javDbMovieIdRequests.get(normalized) === request && javDbMovieIdRequests.delete(normalized); - } - } - __name(resolveJavDbMovieId, "resolveJavDbMovieId"); - async function markJavDbWantWatch(movieId) { - const id = String(movieId || "").trim(), encryptedToken = localStorage.getItem("jhs_appAuthorization"), token = encryptedToken ? await decryptData(encryptedToken) : ""; - if (!token) { - const error = new Error("请先登录 JavDB 账号"); - throw error.code = "LOGIN_REQUIRED", error; - } - if (!id) throw new Error("JavDB 影片 ID 无效"); - const boundary = "----jhs-javdb-want-watch", body = [["status", "want_watch"], ["score", "0"], ["content", ""]].map((([name, value]) => `--${boundary}\r -Content-Disposition: form-data; name="${name}"\r -\r -${value}\r -`)).join("") + `--${boundary}--\r -`; - try { - const response = await gmHttp.gmRequest("POST", `${U}/v1/movies/${encodeURIComponent(id)}/reviews`, body, {}, { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - authorization: `Bearer ${token}`, - jdsignature: await O(), - "content-type": `multipart/form-data; boundary=${boundary}` - }); - if (0 === response?.success) throw response; - await storageManager.deleteCachedRequest(`movie-detail:${id}`); - return response; - } catch (error) { - if (401 === error?.status || "JWTVerificationError" === error?.action || /未登录|登录|unauthorized|jwt/i.test(error?.message || "")) { - localStorage.removeItem("jhs_appAuthorization"); - const loginError = new Error("JavDB 登录已失效,请重新登录"); - throw loginError.code = "LOGIN_REQUIRED", loginError; - } - throw error instanceof Error ? error : new Error(error?.message || "加入 JavDB 想看失败"); - } - } - __name(markJavDbWantWatch, "markJavDbWantWatch"); - var R = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { - let a2 = `${U}/v1/movies/${e2}/reviews`, i2 = { - jdSignature: await O() - }; - return await storageManager.cachedRequest(`reviews:${e2}:${t2}:${n2}`, 864e5, (async () => { - const e3 = await gmHttp.get(a2, { - page: t2, - sort_by: "hotly", - limit: n2 - }, i2); - if (!e3?.data?.reviews) throw new Error(e3?.message || "获取评论失败"); - return e3.data.reviews; - })); - }, "R"); - var V = /* @__PURE__ */ __name(async (e2) => { - let t2 = `${U}/v4/movies/${e2}`, n2 = { - jdSignature: await O() - }; - const a2 = await storageManager.cachedRequest(`movie-detail:${e2}`, 6048e5, (async () => { - const e3 = await gmHttp.get(t2, null, n2); - if (!e3.data) throw new Error(e3.message || "获取视频详情失败"); - return e3; - })); - if (!a2.data) throw show.error("获取视频详情失败: " + a2.message), new Error(a2.message); - const i2 = a2.data.movie, s2 = i2.preview_images, o2 = []; - return s2.forEach(((e3) => { - o2.push(e3.large_url.replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")); - })), { - movieId: i2.id, - actors: i2.actors, - duration: i2.duration, - title: i2.origin_title, - carNum: i2.number, - score: i2.score, - releaseDate: i2.release_date, - watchedCount: i2.watched_count, - imgList: o2 - }; - }, "V"); - var K = /* @__PURE__ */ __name(async (e2, t2 = 1, n2 = 20) => { - let a2 = `${U}/v1/lists/related?movie_id=${e2}&page=${t2}&limit=${n2}`, i2 = { - jdSignature: await O() - }; - const s2 = await storageManager.cachedRequest(`related:${e2}:${t2}:${n2}`, 864e5, (async () => { - const e3 = await gmHttp.get(a2, null, i2); - if (!e3?.data?.lists) throw new Error(e3?.message || "获取相关清单失败"); - return e3; - })), o2 = []; - return s2.data.lists.forEach(((e3) => { - o2.push({ - relatedId: e3.id, - name: e3.name, - movieCount: e3.movies_count, - collectionCount: e3.collections_count, - viewCount: e3.views_count, - createTime: utils.formatDate(e3.created_at) - }); - })), o2; - }, "K"); - var W = /* @__PURE__ */ __name(async (e2 = "daily", t2 = "high_score") => { - let n2 = `${U}/v1/rankings/playback?period=${e2}&filter_by=${t2}`, a2 = { - jdSignature: await O() - }; - return (await gmHttp.get(n2, null, a2)).data.movies; - }, "W"); - var q = /* @__PURE__ */ __name(async (e2 = "all", t2 = "", n2 = 1, a2 = 40) => { - let i2 = `${U}/v1/movies/top?start_rank=1&type=${e2}&type_value=${t2}&ignore_watched=false&page=${n2}&limit=${a2}`; - const l2 = localStorage.getItem("jhs_appAuthorization"), c2 = l2 ? await decryptData(l2) : ""; - let s2 = { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - host: "jdforrepam.com", - authorization: "Bearer " + c2, - jdsignature: await O() - }; - return await gmHttp.get(i2, null, s2); - }, "q"); - - // src/integrations/av123/parser.js - function parse123AvCards($page, baseUrl = "https://123av.com") { - const items = /* @__PURE__ */ new Map(); - const wrap = ( + // src/ui/translation/title-translation.js + async function renderTranslatedTitle(options) { + const jq = ( /** @type {any} */ - globalThis.jQuery ?? /** @type {any} */ - globalThis.$ ?? $page.constructor - ); - $page.find(".card").each(((index, element) => { - const $card = wrap(element); - const $link = $card.find('a.card__link[href*="/cn/v/fc2-ppv-"]').first(); - const href = $link.attr("href"), text = $link.text().trim(); - const numberMatch = text.match(/FC2-PPV-(\d+)/i); - if (!href || !numberMatch) return; - const carNum = `FC2-${numberMatch[1]}`; - const detailUrl = new URL(href, baseUrl); - detailUrl.hash = ""; - items.set(carNum, { - imgSrc: $card.find("img.card__img[src]").first().attr("src") || "", - carNum, - href: detailUrl.href, - title: text.replace(/^FC2-PPV-\d+\s*[—–-]\s*/i, "").trim(), - preview: $card.find(".card__poster[data-preview]").first().attr("data-preview") || null - }); - })); - return [...items.values()]; - } - __name(parse123AvCards, "parse123AvCards"); - function merge123AvCards(cardLists) { - const items = /* @__PURE__ */ new Map(); - cardLists.flat().forEach( - /** @type {Record} */ - ((item) => items.set(item.carNum, item)) + globalThis.$ ); - return [...items.values()]; - } - __name(merge123AvCards, "merge123AvCards"); - function parse123AvSourceMaxPage($page, baseUrl = "https://123av.com") { - const lastHref = $page.find('a[rel="last"]').first().attr("href"); - if (lastHref) { - const page = Number.parseInt(new URL(lastHref, baseUrl).searchParams.get("page") ?? "", 10); - if (Number.isFinite(page)) return page; + const root = options.root ? jq(options.root) : jq(document); + let title = root.find(".origin-title").first(); + if (!title.length) title = root.find(".current-title").first(); + if (!title.length) title = root.find("h3").first(); + if (!title.length) return; + const sourceText = title.text().trim(); + if (!sourceText) throw new TypeError("获取标题失败, 无法进行翻译"); + let translatedNode = title.nextAll(".translated-title").first(); + if (!translatedNode.length) translatedNode = jq('
    ').insertAfter(title); + translatedNode.removeClass("is-error").text("翻译中..."); + try { + const translated = await options.translation.translate(sourceText, { scope: options.scope }); + if (!title[0]?.isConnected) return; + translatedNode.text(translated); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + globalThis.clog?.error("翻译失败:", error); + translatedNode.addClass("is-error").text(`翻译失败: ${message}`); } - const inputMax = Number.parseInt($page.find("input.pager__input[max]").first().attr("max"), 10); - if (Number.isFinite(inputMax)) return inputMax; - const totalMatch = $page.find(".pager__total").first().text().match(/(\d[\d,]*)/); - return totalMatch ? Number.parseInt(totalMatch[1].replaceAll(",", ""), 10) : null; - } - __name(parse123AvSourceMaxPage, "parse123AvSourceMaxPage"); - function parse123AvVideoInfo($page, url) { - const idMatch = new URL(url).pathname.match(/fc2-ppv-(\d+)/i); - const id = idMatch ? idMatch[1] : null; - const rawTitle = $page.find("h1.watch__title").first().text().trim(); - const title = id ? rawTitle.replace(new RegExp(`^FC2-PPV-${id}\\s*[—–-]\\s*`, "i"), "").trim() : rawTitle; - const pageText = $page.find("body").text(); - const publishDate = pageText.match(/发布日期\s*(\d{4}-\d{2}-\d{2})/)?.[1] || ""; - return { id, publishDate, title, moviePoster: null }; } - __name(parse123AvVideoInfo, "parse123AvVideoInfo"); + __name(renderTranslatedTitle, "renderTranslatedTitle"); // src/plugins/external-search/fc2-by-123av.js - var AV123_REQUEST_OPTIONS = Object.freeze({ cookiePartitionTopLevelSite: "https://123av.com" }); var _Fc2By123AvPlugin = class _Fc2By123AvPlugin extends BasePlugin { constructor() { super(...arguments), i(this, "$contentBox", $(".section .container")), i(this, "urlParams", new URLSearchParams(window.location.search)), i(this, "currentPage", this.urlParams.get("page") ? parseInt(this.urlParams.get("page")) : 1), i(this, "maxPage", null), i(this, "keyword", this.urlParams.get("keyword") || null); @@ -8929,11 +8312,9 @@ ${value}\r getName() { return "Fc2By123AvPlugin"; } - async getBaseUrl() { - return await this.getDependency("OtherSitePlugin").getAv123Url(); - } - request123Av(e2, requestOptions = {}) { - return gmHttp.get(e2, {}, {}, false, { ...AV123_REQUEST_OPTIONS, ...requestOptions }); + async resolveMovieId(carNum) { + const scope = await this.getRuntimeService("scope")(); + return (await this.getRuntimeService("movie").resolve({ carNum }, { scope }))?.movieId || null; } async handle() { $("#navbar-menu-hero > div > div:nth-child(1) > div > a:nth-child(4)").after('123Av-Fc2'), $('.tabs li:contains("FC2")').after('
  • 123Av-Fc2
  • '), o.includes("/advanced_search?type=100") && (this.hookPage(), await this.handleQuery()); @@ -8969,20 +8350,14 @@ ${value}\r async handleQuery() { let e2 = loading(); try { - let e3 = [2 * this.currentPage - 1, 2 * this.currentPage]; - this.keyword && (e3 = [1], $(".page-box").hide()); - const t2 = await this.getBaseUrl(); - const requests = e3.map(((sourcePage) => this.request123Av(this.keyword ? `${t2}/cn/search?keyword=${encodeURIComponent(this.keyword)}` : `${t2}/cn/makers/fc2?page=${sourcePage}`))); - const pages = (await Promise.all(requests)).map(((html) => utils.htmlTo$dom(html))); - const i2 = merge123AvCards(pages.map((($page) => parse123AvCards($page, t2)))); - if (!this.keyword && !this.maxPage && pages.length) { - const sourceMaxPage = parse123AvSourceMaxPage(pages[0], t2); - sourceMaxPage && (this.maxPage = Math.ceil(sourceMaxPage / 2), this.renderPagination()); - } + this.keyword && $(".page-box").hide(); + const scope = await this.getRuntimeService("scope")(); + const result = await this.getRuntimeService("movie").catalog("av123", { page: this.currentPage, keyword: this.keyword || "" }, { scope }); + const i2 = result.items; + if (!this.keyword && !this.maxPage && result.maxPage) this.maxPage = result.maxPage, this.renderPagination(); if (0 === i2.length) { clog.log(i2), show.error("无结果"); - const e4 = this.keyword ? `${t2}/cn/search?keyword=${encodeURIComponent(this.keyword)}` : `${t2}/cn/makers/fc2`; - clog.error("获取数据失败!", e4); + clog.error("123AV 获取数据失败"); } let s2 = this.markDataListHtml(i2); $(".movie-list").html(s2), await utils.smoothScrollToTop(); @@ -8997,7 +8372,7 @@ ${value}\r } /** 将 123AV 数据填入 Fc2Plugin 创建的固定工作区。 */ async loadDetail(context, url) { - const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum), movieIdPromise = resolveJavDbMovieId(context.carNum), fc2Plugin = this.getDependency("Fc2Plugin"); + const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum), movieIdPromise = this.resolveMovieId(context.carNum), fc2Plugin = this.getDependency("Fc2Plugin"); void fc2Plugin.configureJavDbWantButton(context, movieIdPromise), void fc2Plugin.mountPanels(context, movieIdPromise), void movieIdPromise.then(((movieId) => context.isAlive() && fc2Plugin.fetchAndRenderNativeMagnets(context, movieId))).catch(((error) => { context.isAlive() && fc2Plugin.setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))), clog.error("123AV 磁力关联失败", error); })); @@ -9010,9 +8385,11 @@ ${value}\r } async loadSummary(context, url) { try { - const info = await this.get123AvVideoInfo(url); + const info = await this.get123AvVideoInfo(context.carNum, url); if (!context.isAlive()) return null; - this.render123AvSummary(context, info), await this.getDependency("TranslatePlugin").translate(context.carNum, false, { root: context.root }); + this.render123AvSummary(context, info); + const scope = await this.getRuntimeService("scope")(); + if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _) await renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope }); return info; } catch (error) { context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); @@ -9021,7 +8398,7 @@ ${value}\r } async retryResolvedMagnets(context) { try { - return await this.getDependency("Fc2Plugin").fetchAndRenderNativeMagnets(context, await resolveJavDbMovieId(context.carNum)); + return await this.getDependency("Fc2Plugin").fetchAndRenderNativeMagnets(context, await this.resolveMovieId(context.carNum)); } catch (error) { context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))); } @@ -9030,42 +8407,18 @@ ${value}\r const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

    '); title.find("strong").text(info.title || "无标题"), body.append(title, $('
    ').append($("").text(`番号:${context.carNum}`), $("").text(`发行:${info.publishDate || "未知"}`)), '
    主演:正在加载演员…
    ', '
    ', this.getDependency("Fc2Plugin").createSourceLinks(context), $('').text(info.publishDate || "")); } - async get123AvVideoInfo(e2) { - const t2 = await this.request123Av(e2); - return parse123AvVideoInfo(utils.htmlTo$dom(t2), e2); + async get123AvVideoInfo(carNum, e2) { + const scope = await this.getRuntimeService("scope")(); + const detail = await this.getRuntimeService("movie").detail({ carNum, url: e2, providerId: "av123" }, { scope }); + return { title: detail?.title || "", publishDate: detail?.releaseDate || "", moviePoster: null }; } async getActressInfo(e2) { - let t2 = `https://fc2ppvdb.com/articles/${e2.replace("FC2-", "")}`; - const n2 = await gmHttp.get(t2), a2 = $(n2), i2 = a2.find("div").filter((function() { - return 0 === $(this).text().trim().indexOf("女優:"); - })); - if (0 === i2.length || i2.length > 1) return { actors: [], seller: null }; - const s2 = $(i2[0]).find("a"); - const actors = []; - if (s2.length > 0) { - s2.each(((t3, n3) => { - const link = $(n3), name = link.text().trim(), url = normalizeHttpUrl(link.attr("href"), "https://fc2ppvdb.com"); - name && actors.push({ name, url }); - })); - } - const r2 = a2.find("div").filter((function() { - return 0 === $(this).text().trim().indexOf("販売者:"); - })); - let seller = null; - if (r2.length > 0) { - const link = $(r2[0]).find("a").first(); - link.length && (seller = { name: link.text().trim(), url: normalizeHttpUrl(link.attr("href"), "https://fc2ppvdb.com") }); - } - return { actors, seller }; + const scope = await this.getRuntimeService("scope")(); + return this.getRuntimeService("movie").people("fc2ppvdb", { carNum: e2 }, { scope }); } async getImgList(e2) { - let t2 = e2.replace("FC2-", ""), n2 = `https://adult.contents.fc2.com/article/${e2.replace("FC2-", "")}/`; - const a2 = await gmHttp.get(n2, null, { - referer: n2 - }); - return $(a2).find(".items_article_SampleImagesArea img").map((function() { - return normalizeHttpUrl($(this).attr("src"), n2); - })).get().filter(Boolean); + const scope = await this.getRuntimeService("scope")(); + return (await this.getRuntimeService("movie").images("fc2content", { carNum: e2 }, { scope })).map(((item) => item.url)); } async reloadImages(context) { try { @@ -9084,7 +8437,7 @@ ${value}\r markDataListHtml(e2) { let t2 = ""; return e2.forEach(((e3) => { - const href = normalizeHttpUrl(e3.href, "https://123av.com"), imageUrl = normalizeHttpUrl(e3.imgSrc, "https://123av.com"); + const href = e3.url, imageUrl = e3.imageUrl; if (!href) return; t2 += `
    @@ -9131,26 +8484,149 @@ ${value}\r if (button.prop("disabled")) return; button.prop("disabled", true).attr("aria-busy", "true"); try { - const record = "function" == typeof config.getRecord ? await config.getRecord() : config.getRecord || { carNum: config.carNum }; - await stateService.toggle(config.carNum, flag, { type: config.activityType, record }), await this.render(config), await utils.closePage({ layerIndex: config.layerIndex, root: config.root }); + const record = "function" == typeof config.getRecord ? await config.getRecord() : config.getRecord || { carNum: config.carNum }; + await stateService.toggle(config.carNum, flag, { type: config.activityType, record }), await this.render(config), await utils.closePage({ layerIndex: config.layerIndex, root: config.root }); + } catch (error) { + clog.error("详情状态更新失败", error), show.error("操作失败"); + } finally { + button[0]?.isConnected && button.prop("disabled", false).removeAttr("aria-busy"); + } + } + async render({ root = document, carNum, selectors = {} }) { + const record = await storageManager.getCar(normalizeCarNum(carNum)), flags = normalizeStateFlags(record?.stateFlags); + for (const [flag, definition] of Object.entries(DETAIL_STATE_BUTTONS)) { + const button = $(root).find(selectors[flag] || definition.selector), active = !!flags[flag]; + button.attr("aria-pressed", String(active)).find("span").first().text(active ? definition.active() : definition.inactive()); + } + return flags; + } + }; + __name(_DetailStateController, "DetailStateController"); + var DetailStateController = _DetailStateController; + var detailStateController = new DetailStateController(); + + // src/core/javdb-api.js + var U = "https://jdforrepam.com/api"; + var signatureSecond = 0; + var signatureValue = ""; + function O() { + const now = Math.floor(Date.now() / 1e3); + if (signatureValue && now - signatureSecond <= 20) return signatureValue; + signatureSecond = now; + signatureValue = `${now}.lpw6vgqzsp.${md5(`${now}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; + return signatureValue; + } + __name(O, "O"); + async function markJavDbWantWatch(movieId) { + const id = String(movieId || "").trim(), encryptedToken = localStorage.getItem("jhs_appAuthorization"), token = encryptedToken ? await decryptData(encryptedToken) : ""; + if (!token) { + const error = new Error("请先登录 JavDB 账号"); + throw error.code = "LOGIN_REQUIRED", error; + } + if (!id) throw new Error("JavDB 影片 ID 无效"); + const boundary = "----jhs-javdb-want-watch", body = [["status", "want_watch"], ["score", "0"], ["content", ""]].map((([name, value]) => `--${boundary}\r +Content-Disposition: form-data; name="${name}"\r +\r +${value}\r +`)).join("") + `--${boundary}--\r +`; + try { + const response = await gmHttp.gmRequest("POST", `${U}/v1/movies/${encodeURIComponent(id)}/reviews`, body, {}, { + "user-agent": "Dart/3.5 (dart:io)", + "accept-language": "zh-TW", + authorization: `Bearer ${token}`, + jdsignature: await O(), + "content-type": `multipart/form-data; boundary=${boundary}` + }); + if (0 === response?.success) throw response; + await storageManager.deleteCachedRequest(`movie-detail:${id}`); + return response; + } catch (error) { + if (401 === error?.status || "JWTVerificationError" === error?.action || /未登录|登录|unauthorized|jwt/i.test(error?.message || "")) { + localStorage.removeItem("jhs_appAuthorization"); + const loginError = new Error("JavDB 登录已失效,请重新登录"); + throw loginError.code = "LOGIN_REQUIRED", loginError; + } + throw error instanceof Error ? error : new Error(error?.message || "加入 JavDB 想看失败"); + } + } + __name(markJavDbWantWatch, "markJavDbWantWatch"); + var q = /* @__PURE__ */ __name(async (e2 = "all", t2 = "", n2 = 1, a2 = 40) => { + let i2 = `${U}/v1/movies/top?start_rank=1&type=${e2}&type_value=${t2}&ignore_watched=false&page=${n2}&limit=${a2}`; + const l2 = localStorage.getItem("jhs_appAuthorization"), c2 = l2 ? await decryptData(l2) : ""; + let s2 = { + "user-agent": "Dart/3.5 (dart:io)", + "accept-language": "zh-TW", + host: "jdforrepam.com", + authorization: "Bearer " + c2, + jdsignature: await O() + }; + return await gmHttp.get(i2, null, s2); + }, "q"); + + // src/ui/detail/screenshot-panel.js + function normalizeImageUrl(value) { + try { + const url = new URL(String(value || "")); + return url.protocol === "https:" ? url.href : null; + } catch { + return null; + } + } + __name(normalizeImageUrl, "normalizeImageUrl"); + function renderImage(host, url, alt) { + const jq = ( + /** @type {any} */ + globalThis.$ + ); + const normalized = normalizeImageUrl(url); + if (!normalized) return null; + const image = jq("").attr({ src: normalized, alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"); + const button = jq('').attr("aria-label", `查看${alt}大图`).append(image); + host.empty().append(button).off("click.jhsScreenshot").on("click.jhsScreenshot", ".jhs-fc2-screenshot-thumbnail", (event) => { + event.preventDefault(); + event.stopPropagation(); + globalThis.showImageViewer(image[0]); + }); + return normalized; + } + __name(renderImage, "renderImage"); + async function renderScreenshotPanel(options) { + const jq = ( + /** @type {any} */ + globalThis.$ + ); + const host = jq(options.target), isActive = options.isActive ?? (() => true); + if (!host.length || options.settings.enableLoadScreenShot === "no") return host.empty(), null; + const load = /* @__PURE__ */ __name(async (resultHost = host) => { + resultHost.empty().append(jq("
    ").addClass("jhs-panel-state").text("正在加载缩略图…")); + try { + const images = await options.screenshot.resolve({ carNum: options.carNum }, { scope: options.scope }); + if (!isActive()) return null; + const image = Array.isArray(images) ? images[0] : images; + if (!image?.url) return resultHost.empty(), null; + return renderImage(resultHost, image.url, "缩略图"); } catch (error) { - clog.error("详情状态更新失败", error), show.error("操作失败"); - } finally { - button[0]?.isConnected && button.prop("disabled", false).removeAttr("aria-busy"); - } - } - async render({ root = document, carNum, selectors = {} }) { - const record = await storageManager.getCar(normalizeCarNum(carNum)), flags = normalizeStateFlags(record?.stateFlags); - for (const [flag, definition] of Object.entries(DETAIL_STATE_BUTTONS)) { - const button = $(root).find(selectors[flag] || definition.selector), active = !!flags[flag]; - button.attr("aria-pressed", String(active)).find("span").first().text(active ? definition.active() : definition.inactive()); + if (!isActive()) return null; + const state = jq("
    ").addClass("jhs-panel-state").text("缩略图加载失败 "); + const retry = jq('').on("click", () => void load(resultHost)); + resultHost.empty().append(state.append(retry)); + globalThis.clog?.error("缩略图加载失败", error); + return null; } - return flags; - } - }; - __name(_DetailStateController, "DetailStateController"); - var DetailStateController = _DetailStateController; - var detailStateController = new DetailStateController(); + }, "load"); + if (options.settings.screenshotMode !== "manual") return load(); + const tabs = jq('
    '); + const result = jq('
    ').text("请选择截图来源"); + const button = jq(''); + button.on("click", async () => { + button.attr("aria-selected", "true"); + await load(result); + }); + host.empty().append(tabs.append(button), result); + return host; + } + __name(renderScreenshotPanel, "renderScreenshotPanel"); // src/plugins/status/detail-workspace.js function getDetailResourceAdapter() { @@ -9378,6 +8854,10 @@ ${value}\r getName() { return "Fc2Plugin"; } + async resolveMovieId(carNum) { + const scope = await this.getRuntimeService("scope")(); + return (await this.getRuntimeService("movie").resolve({ carNum }, { scope }))?.movieId || null; + } async initCss() { return ``; } + async handle() { + if (!window.isDetailPage || !r) return; + const movieId = new URL(window.location.href).pathname.split("/").filter(Boolean).pop(); + if (movieId) await this.showRelated(this.getHostedSlot("related"), movieId); + } + getHostedSlot(name) { + const element = this.getRuntimeService("host").locateDetailSlots()[name]; + return element ? $(element) : $(); + } async showRelated(target, movieId, options = {}) { const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getDependency("DetailWorkspacePlugin")?.getSlot("related"); + const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getHostedSlot("related"); if (!movieId) return void show.error("未传入movieId"); if (!isActive() || !host?.length) return $(); const existing = host.children('[data-jhs-panel="related"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); @@ -10446,15 +9954,17 @@ ${error.stack}` : ""); state.loading = true; const { movieId, panel } = state, container = panel.find(".jhs-related-container"), footer = panel.find(".jhs-related-footer"); container.empty().append($('
    ').text("获取清单中...")), footer.empty(); - let related; + let related, scope; try { - related = await K(movieId, 1, 20); + scope = await this.getRuntimeService("scope")(); + related = await this.getRuntimeService("related").list({ movieId }, { page: 1, limit: 20, scope }); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); clog.error("获取清单失败:", error); state.loading = false; return void this.renderRetry(container, (() => this.fetchAndDisplayRelateds(state))); } - if (!state.isActive?.()) return void (state.loading = false); + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); state.loading = false, state.loaded = true; container.empty(); if (!related.length) return void container.append($('
    ').text("无清单")); @@ -10470,13 +9980,16 @@ ${error.stack}` : ""); footer.empty().append(button, end); button.on("click", (async () => { const nextPage = state.page + 1; + let scope; button.text("加载中...").prop("disabled", true); try { - const related = await K(state.movieId, nextPage, 20); - if (!state.isActive?.()) return; + scope = await this.getRuntimeService("scope")(); + const related = await this.getRuntimeService("related").list({ movieId: state.movieId }, { page: nextPage, limit: 20, scope }); + if (!state.isActive?.() || scope?.signal?.aborted) return; state.page = nextPage; this.displayRelateds(state, related, container), related.length < 20 ? (button.remove(), end.show()) : button.text("加载更多清单").prop("disabled", false); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return; clog.error("加载更多清单失败:", error), button.text("加载失败,请重试").prop("disabled", false); } })); @@ -10484,10 +9997,10 @@ ${error.stack}` : ""); displayRelateds(state, related, container) { related.forEach(((item) => { const row = $(''), title = $("").addClass("jhs-related-title").attr({ - href: `/lists/${encodeURIComponent(item.relatedId)}`, + href: `/lists/${encodeURIComponent(item.id)}`, target: "_blank", rel: "noopener noreferrer" - }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createTime || "未知"}`); + }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createdAt ? utils.formatDate(item.createdAt) : "未知"}`); meta.append( $("").text(`视频:${Number(item.movieCount) || 0}`), $("").text(`收藏:${Number(item.collectionCount) || 0}`), @@ -10536,19 +10049,24 @@ ${error.stack}` : ""); if (!window.isDetailPage) return; if (r) { const movieId = this.parseMovieId(window.location.href); - const workspace = this.getDependency("DetailWorkspacePlugin"); - await Promise.all([this.showReview(movieId, workspace?.getSlot("reviews")), this.getDependency("RelatedPlugin").showRelated(workspace?.getSlot("related"), movieId)]); + await this.showReview(movieId, this.getHostedSlot("reviews")); } if (l) { const carNumber = this.getPageInfo().carNum; if (!carNumber) return void clog.warn("跳过 JavBus 评论解析:番号不可用"); - const movieId = await resolveJavDbMovieId(carNumber); - movieId && await this.showReview(movieId, this.getDependency("DetailWorkspacePlugin")?.getSlot("reviews")); + const scope = await this.getRuntimeService("scope")(); + const movieRef = await this.getRuntimeService("movie").resolve({ carNum: carNumber }, { scope }); + movieRef?.movieId && await this.showReview(movieRef.movieId, this.getHostedSlot("reviews")); } } + getHostedSlot(name) { + const element = this.getRuntimeService("host").locateDetailSlots()[name]; + return element ? $(element) : $(); + } async showReview(movieId, target, options = {}) { const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const enabled = await storageManager.getSetting("enableLoadReview", _), host = target?.length ? target : this.getDependency("DetailWorkspacePlugin")?.getSlot("reviews") || $("#magnets-content"); + const settings = await storageManager.getSetting(); + const enabled = settings.enableLoadReview === _ ? _ : C, host = target?.length ? target : this.getHostedSlot("reviews"); if (!isActive() || !host?.length) return $(); const existing = host.children('[data-jhs-panel="reviews"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); if (existing.length) return existing; @@ -10576,15 +10094,17 @@ ${error.stack}` : ""); const { movieId, panel } = state, container = panel.find(".jhs-review-container"), footer = panel.find(".jhs-review-footer"); container.empty().append($('
    ').text("获取评论中...")), footer.empty(); const pageSize = await storageManager.getSetting("reviewCount", 20); - let reviews; + let reviews, scope; try { - reviews = await R(movieId, 1, pageSize); + scope = await this.getRuntimeService("scope")(); + reviews = await this.getRuntimeService("review").list({ movieId }, { page: 1, limit: pageSize, scope }); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); error.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", error), clog.error("获取评论失败:", error); state.loading = false; return void this.renderRetry(container, "获取评论失败", (() => this.fetchAndDisplayReviews(state))); } - if (!state.isActive?.()) return void (state.loading = false); + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); state.loading = false, state.loaded = true; container.empty(); if (!reviews.length) return void container.append($('
    ').text("无评论")); @@ -10603,13 +10123,16 @@ ${error.stack}` : ""); footer.empty().append(button, end); button.on("click", (async () => { const nextPage = state.page + 1; + let scope; button.text("加载中...").prop("disabled", true); try { - const reviews = await R(state.movieId, nextPage, pageSize); - if (!state.isActive?.()) return; + scope = await this.getRuntimeService("scope")(); + const reviews = await this.getRuntimeService("review").list({ movieId: state.movieId }, { page: nextPage, limit: pageSize, scope }); + if (!state.isActive?.() || scope?.signal?.aborted) return; state.page = nextPage; await this.displayReviews(state, reviews, container, keywords), reviews.length < pageSize ? (button.remove(), end.show()) : button.text("加载更多评论").prop("disabled", false); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return; clog.error("加载更多评论失败:", error), button.text("加载失败,请重试").prop("disabled", false); } })); @@ -10621,13 +10144,13 @@ ${error.stack}` : ""); const content = String(review.content || ""); if (filter?.test(content)) continue; const item = $('
    '), meta = $('
    '), body = $('
    '); - meta.append($("").addClass("jhs-review-author").text(review.username || "匿名用户")); + meta.append($("").addClass("jhs-review-author").text(review.author || "匿名用户")); const stars = $(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); for (let index = 0; index < score; index++) stars.append(''); meta.append( stars, - $("").text(utils.formatDate(review.created_at)), - $("").text(`点赞:${Number(review.likes_count) || 0}`), + $("").text(utils.formatDate(review.createdAt)), + $("").text(`点赞:${Number(review.likes) || 0}`), $("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`) ); await this.appendReviewContent(body, content), item.append(meta, body), container.append(item); @@ -10842,139 +10365,579 @@ ${error.stack}` : ""); }); } }; - __name(_Top250Plugin, "Top250Plugin"); - var Top250Plugin = _Top250Plugin; - - // src/plugins/favorite/favorite-actresses.js - var _FavoriteActressesPlugin = class _FavoriteActressesPlugin extends BasePlugin { + __name(_Top250Plugin, "Top250Plugin"); + var Top250Plugin = _Top250Plugin; + + // src/plugins/favorite/favorite-actresses.js + var _FavoriteActressesPlugin = class _FavoriteActressesPlugin extends BasePlugin { + getName() { + return "FavoriteActressesPlugin"; + } + async handle() { + this.bindEvent(), await this.highlightActress(), this.replaceActressAvatar(); + } + async highlightActress() { + if (!isDetailPage) return; + if (await storageManager.getSetting("enableFavoriteActresses", _) !== _) return; + const e2 = await storageManager.getFavoriteActressList(); + if (!e2 || 0 === e2.length) return; + const t2 = /* @__PURE__ */ new Set(); + e2.forEach(((e3) => { + e3.starId && t2.add(String(e3.starId).trim()); + })), 0 !== t2.size && $(".female").prev().each(((e3, n2) => { + const a2 = $(n2), i2 = a2.attr("href"); + let s2 = null; + if (i2) { + const e4 = (i2.endsWith("/") ? i2.slice(0, -1) : i2).split("/"), t3 = e4[e4.length - 1]; + t3 && (s2 = t3.trim()); + } + let o2 = false; + s2 && (o2 = t2.has(s2)), o2 && (a2.addClass("highlighted"), a2.attr("title", "高亮已收藏演员, 可在设置-基础配置中关闭")); + })); + } + async removeActorFromStorage(e2) { + await storageManager.removeFavoriteActress(e2) && (clog.log("移除演员成功"), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(e2) } }))); + } + bindEvent() { + const e2 = /\/actors\/(\w+)\/(collect|uncollect)/; + $(document).on("confirm:complete", 'a[href*="/actors/"][href*="/uncollect"]', (async (t2) => { + const [n2] = t2.detail; + if (!n2) return; + const a2 = $(t2.currentTarget).attr("href").match(e2), i2 = a2 ? a2[1] : null; + i2 && await this.removeActorFromStorage(i2); + })), $("#button-collect-actor").click((async (t2) => { + const n2 = $("#button-collect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; + let i2 = [], s2 = $(".actor-section-name"); + s2.length && s2.text().trim().split(",").forEach(((e3) => { + i2.push(e3.trim()); + })); + let o2 = $(".section-meta:not(:contains('影片'))"); + if (o2.length && o2.text().trim().split(",").forEach(((e3) => { + i2.push(e3.trim()); + })), !i2.length) return void clog.error("获取演员名称失败"); + const r2 = i2[0]; + if (!a2) return void clog.error("无法获取演员ID进行收藏操作。"); + const l2 = ($(".avatar").first().css("background-image") || "").replace(/^url\(["']?|["']?\)$/g, ""), c2 = { + starId: a2, + name: r2, + allName: i2, + avatar: l2 + }; + 1 === await storageManager.addFavoriteActressList([c2]) ? (clog.log(`收藏演员成功: ${r2} (ID: ${a2})`), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(a2) } }))) : clog.log(`收藏演员失败: ${r2} (ID: ${a2})`); + })), $("#button-uncollect-actor").click((async (t2) => { + const n2 = $("#button-uncollect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; + a2 ? await this.removeActorFromStorage(a2) : clog.error("无法获取演员ID进行取消收藏操作。"); + })); + } + async replaceActressAvatar() { + const e2 = this.getActressId(); + if (!e2) return; + const t2 = (await storageManager.getFavoriteActressList()).find(((t3) => t3.starId === e2)); + if (t2 && t2.avatar) { + const e3 = `url('${t2.avatar}')`; + let n2 = $(".avatar").first(); + if (0 === n2.length) { + const e4 = '
    '; + $(".section-columns").prepend(e4), n2 = $(".avatar").first(); + } + if (0 === n2.length) return; + n2.css("background-image").trim().toLowerCase() !== e3.trim().toLowerCase() && (n2.css("background-image", e3), n2.css("background-size", "cover"), n2.css("background-position", "top center"), n2.css("background-repeat", "no-repeat")); + } + } + }; + __name(_FavoriteActressesPlugin, "FavoriteActressesPlugin"); + var FavoriteActressesPlugin = _FavoriteActressesPlugin; + + // src/plugins/image-viewer/bus-img.js + var _BusImgPlugin = class _BusImgPlugin extends BasePlugin { + getName() { + return "BusImgPlugin"; + } + handle() { + } + async getVisibleImageItems(e2, t2) { + let n2 = []; + const a2 = document.querySelectorAll(e2); + for (const i2 of a2) { + if (!utils.isHidden(i2)) { + const e3 = i2.querySelector(t2); + if (!(e3 instanceof HTMLImageElement)) continue; + e3.style.removeProperty("height"); + let a3 = e3.offsetHeight; + a3 > 0 && n2.push({ + element: i2, + imgElement: e3, + height: a3 + }); + } + } + return n2; + } + async logImageHeightsByRow() { + if (await storageManager.getSetting("enableVerticalModel", C) === _) return; + const e2 = this.getSelector().itemSelector, t2 = await storageManager.getSetting("containerColumns", 5), n2 = await this.getVisibleImageItems(e2, "img"); + if (0 === n2.length) return; + const a2 = []; + for (let i2 = 0; i2 < n2.length; i2++) { + const e3 = Math.floor(i2 / t2); + a2[e3] || (a2[e3] = []), a2[e3].push(n2[i2]); + } + a2.forEach(((e3, t3) => { + const n3 = e3.map(((e4) => e4.height)); + if (n3.length < 2) return; + const a3 = Math.min(...n3), i2 = Math.max(...n3); + let s2 = 0; + i2 - a3 > 50 && (s2 = a3, e3.forEach(((e4) => { + if (e4.height !== s2) { + const t4 = `${s2}px`; + e4.imgElement.style.setProperty("height", t4, "important"); + } + }))); + })); + } + }; + __name(_BusImgPlugin, "BusImgPlugin"); + var BusImgPlugin = _BusImgPlugin; + + // src/plugins/image-viewer/preview-video.js + var Z = /* @__PURE__ */ __name((e2, t2) => { + if (!e2 || 0 === e2.length) return null; + const n2 = new Set(e2); + if (n2.has(t2)) return t2; + const a2 = L.map(((e3) => e3.quality)).reverse(); + for (const i2 of a2) if (n2.has(i2)) return i2; + return e2[0]; + }, "Z"); + var ee = "jhs_dmm_video"; + var _DmmPreviewParser = class _DmmPreviewParser { + constructor(e2) { + this.carNum = e2, this.lastError = null; + } + _checkCache() { + const e2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; + return e2[this.carNum] ? (clog.debug("缓存中存在预览视频信息", e2[this.carNum]), e2[this.carNum]) : null; + } + _updateCache(e2) { + const t2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; + t2[this.carNum] = e2, clog.debug("成功解析出预览视频并已缓存:", e2), localStorage.setItem(ee, JSON.stringify(t2)); + } + async _searchContentIds() { + const e2 = this.carNum, t2 = e2.replace(/-/g, ""), n2 = [{ + keyword: e2.replace("-", "00"), + name: "00-替换关键词" + }, { + keyword: e2, + name: "原始番号关键词" + }, { + keyword: t2, + name: "无连字符关键词" + }], a2 = e2.toLowerCase(); + let hadSuccessfulRequest = false; + for (const o2 of n2) { + const { keyword: e3, name: n3 } = o2, i3 = e3.toLowerCase(); + clog.debug(`--- 尝试使用 ${n3} (${e3}) 进行 API 搜索 ---`); + const r2 = `https://api.dmm.com/affiliate/v3/ItemList?${new URLSearchParams({ + api_id: "UrwskPfkqQ0DuVry2gYL", + affiliate_id: "10278-996", + output: "json", + site: "FANZA", + sort: "match", + keyword: e3 + }).toString()}`; + let l2; + try { + l2 = await gmHttp.get(r2); + hadSuccessfulRequest = true; + } catch (s2) { + this.lastError = new ProviderError("dmm", "HTTP_ERROR", `DMM API 请求失败: ${s2.message || s2}`, { + cause: s2, + url: r2, + status: s2?.status, + retryable: true + }), clog.error(`API 请求失败,跳过 ${n3}:`, this.lastError); + continue; + } + if (!l2 || !l2.result || !l2.result.result_count) { + clog.debug("API 返回无结果,尝试下一个关键词。"); + continue; + } + const c2 = []; + for (const s2 of l2.result.items) { + if (c2.length >= 2) break; + const e4 = s2.content_id || "", o3 = s2.maker_product || ""; + (e4.includes(i3.replace("-", "")) || a2 === o3.toLowerCase() || e4.includes(t2.toLowerCase())) && (c2.push({ + serviceCode: s2.service_code, + floorCode: s2.floor_code, + contentId: e4, + pageUrl: s2.URL + }), clog.debug(`[${n3}] cid|makerProduct 匹配成功:`, e4, o3)); + } + if (c2.length > 0) { + clog.debug(`--- 成功通过 ${n3} 找到 Content IDs ---`); + const t3 = $("#fanzaBtn"); + let a3 = `https://www.dmm.co.jp/search/=/searchstr=${e3}`, i4 = "single"; + c2.length > 1 ? (t3.attr("href", a3), t3.append('多结果'), t3.css("backgroundColor", "var(--jhs-status-down)"), i4 = "multiple") : (a3 = c2[0].pageUrl, t3.attr("href", a3), t3.css("backgroundColor", "var(--jhs-status-down)")); + const s2 = "jhs_other_site_dmm", o3 = localStorage.getItem(s2) ? JSON.parse(localStorage.getItem(s2)) : {}; + return o3[this.carNum] = { + type: i4, + url: a3 + }, localStorage.setItem(s2, JSON.stringify(o3)), c2; + } + clog.debug(`[${n3}] API 返回结果数 ${l2.result.result_count},但无精确匹配的 Content ID。`); + } + hadSuccessfulRequest && (this.lastError = null); + clog.warn("所有关键词尝试均未找到匹配的Content ID, 解析Dmm视频失败"); + const i2 = $("#fanzaBtn"); + return i2.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), i2.attr("title", "未查询到, 点击前往搜索页"), i2.css("backgroundColor", "var(--jhs-status-filter)"), null; + } + async _extractTrailerLinks({ contentId: e2, serviceCode: t2, floorCode: n2 }) { + const a2 = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${e2}/mtype=AhRVShI_/service=${t2}/floor=${n2}/mode=/`, i2 = await gmHttp.get(a2, null, { + "accept-language": "ja-JP,ja;q=0.9", + Cookie: "age_check_done=1" + }); + if ("string" != typeof i2) throw clog.error(i2), new ProviderError("dmm", "PARSE_ERROR", "解析播放页内容失败, 非文本内容", { + url: a2 + }); + if (i2.includes("このサービスはお住まいの地域からは")) throw new ProviderError("dmm", "REGION_BLOCKED", "DMM 预览源不可用,请将 DMM 域名分流到日本 IP", { + url: a2 + }); + const s2 = i2.match(/const\s+args\s+=\s+(.*);/); + if (!s2) throw new ProviderError("dmm", "PARSE_ERROR", "未在脚本中找到 const args = ... 变量", { + url: a2 + }); + let o2; + try { + ({ bitrates: o2 } = JSON.parse(s2[1])); + } catch (d2) { + throw new ProviderError("dmm", "PARSE_ERROR", `解析播放器脚本 JSON 失败: ${d2.message}`, { + cause: d2, + url: a2 + }); + } + const r2 = {}, l2 = L.map(((e3) => e3.quality)).join("|"), c2 = new RegExp(`(${l2})\\.mp4$`); + if (!Array.isArray(o2)) throw clog.error("解析画质链接失败: bitrates 字段不是一个数组或不存在"), new ProviderError("dmm", "PARSE_ERROR", "解析画质链接失败: bitrates 字段不是一个数组或不存在", { + url: a2 + }); + clog.debug("原始数据返回:", o2); + for (const h2 of o2) { + const e3 = null == h2 ? void 0 : h2.src; + if (!e3 || "string" != typeof e3 || !e3.endsWith(".mp4")) continue; + const t3 = e3.match(c2); + let n3 = ""; + t3 && t3[1] && (n3 = t3[1]), n3 && !r2[n3] && (r2[n3] = e3); + } + if (0 === Object.keys(r2).length) throw new ProviderError("dmm", "PARSE_ERROR", "未找到匹配要求的预览画质视频", { + url: a2 + }); + return r2; + } + async fetchVideo() { + const carNum = normalizeCarNum(this.carNum); + if (!carNum) return clog.warn("跳过 DMM 解析:番号不可用"), null; + this.carNum = carNum; + const e2 = this._checkCache(); + if (e2) return e2; + let t2; + try { + const e3 = this.carNum.toLowerCase(); + if (e3.startsWith("heyzo") || /^(n\d+|\d+(-\d+)*)$/.test(e3) || /^n\d+$/.test(e3)) return clog.debug("无码番号类型,取消 DMM 解析"), null; + if (this.carNum.includes("VR-")) return clog.debug("VR 类型,取消 DMM 解析"), null; + t2 = await this._searchContentIds(); + } catch (n2) { + this.lastError = n2 instanceof ProviderError ? n2 : new ProviderError("dmm", "PARSE_ERROR", n2.message || String(n2), { + cause: n2 + }), clog.error("DMM API 搜索失败:", this.lastError); + const e3 = $("#fanzaBtn"); + return e3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), e3.attr("title", "未查询到, 点击前往搜索页"), e3.css("backgroundColor", "var(--jhs-status-filter)"), null; + } + if (!t2 || 0 === t2.length) return null; + try { + const e3 = await Promise.any(t2.map(((e4) => this._extractTrailerLinks(e4)))); + return this._updateCache(e3), e3; + } catch (a2) { + const e3 = a2.errors || [a2]; + this.lastError = e3.find(((e4) => "REGION_BLOCKED" === e4?.code)) || e3.find(((e4) => e4 instanceof ProviderError)) || new ProviderError("dmm", "PARSE_ERROR", e3[0]?.message || String(e3[0]), { + cause: e3[0] + }), clog.error(`解析失败: ${this.lastError.message}`, e3); + const t3 = $("#fanzaBtn"); + return t3.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`), t3.attr("title", "未查询到, 点击前往搜索页"), t3.css("backgroundColor", "var(--jhs-status-filter)"), null; + } + } + }; + __name(_DmmPreviewParser, "DmmPreviewParser"); + var DmmPreviewParser = _DmmPreviewParser; + async function fetchDmmPreview(carNum) { + const parser = new DmmPreviewParser(carNum), sources = await parser.fetchVideo(); + return { + sources, + error: parser.lastError + }; + } + __name(fetchDmmPreview, "fetchDmmPreview"); + var _PreviewVideoPlugin = class _PreviewVideoPlugin extends BasePlugin { getName() { - return "FavoriteActressesPlugin"; + return "PreviewVideoPlugin"; } - async handle() { - this.bindEvent(), await this.highlightActress(), this.replaceActressAvatar(); + async initCss() { + return ".jhs-dmm-preview-player{display:none;width:100%;height:auto}.jhs-dmm-preview-player.is-active{display:block}.jhs-native-preview-hidden{display:none!important}"; } - async highlightActress() { + async handle() { if (!isDetailPage) return; - if (await storageManager.getSetting("enableFavoriteActresses", _) !== _) return; - const e2 = await storageManager.getFavoriteActressList(); - if (!e2 || 0 === e2.length) return; - const t2 = /* @__PURE__ */ new Set(); - e2.forEach(((e3) => { - e3.starId && t2.add(String(e3.starId).trim()); - })), 0 !== t2.size && $(".female").prev().each(((e3, n2) => { - const a2 = $(n2), i2 = a2.attr("href"); - let s2 = null; - if (i2) { - const e4 = (i2.endsWith("/") ? i2.slice(0, -1) : i2).split("/"), t3 = e4[e4.length - 1]; - t3 && (s2 = t3.trim()); - } - let o2 = false; - s2 && (o2 = t2.has(s2)), o2 && (a2.addClass("highlighted"), a2.attr("title", "高亮已收藏演员, 可在设置-基础配置中关闭")); - })); - } - async removeActorFromStorage(e2) { - await storageManager.removeFavoriteActress(e2) && (clog.log("移除演员成功"), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(e2) } }))); + const trigger = $(".preview-video-container"), openVideo = /* @__PURE__ */ __name(() => { + utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => { + this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))); + })); + }, "openVideo"); + trigger.off("click.jhsVideo").on("click.jhsVideo", openVideo); + await storageManager.getSetting("enableLoadPreviewVideo", _) !== _ || o.includes("autoPlay=1") || this.initDmm(); + const url = window.location.href; + (url.includes("gallery-1") || url.includes("gallery-2")) && openVideo(), url.includes("autoPlay=1") && trigger.length > 0 && trigger[0].click(); } - bindEvent() { - const e2 = /\/actors\/(\w+)\/(collect|uncollect)/; - $(document).on("confirm:complete", 'a[href*="/actors/"][href*="/uncollect"]', (async (t2) => { - const [n2] = t2.detail; - if (!n2) return; - const a2 = $(t2.currentTarget).attr("href").match(e2), i2 = a2 ? a2[1] : null; - i2 && await this.removeActorFromStorage(i2); - })), $("#button-collect-actor").click((async (t2) => { - const n2 = $("#button-collect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; - let i2 = [], s2 = $(".actor-section-name"); - s2.length && s2.text().trim().split(",").forEach(((e3) => { - i2.push(e3.trim()); + async initDmm() { + try { + const { sources } = await this.getDmmPreview(); + if (!sources) return; + const $video = $("#preview-video"), video = $video[0]; + if (video) return; + clog.debug("JavDB没有视频播放元素, 开始创建..."); + const cover = $(".column-video-cover img").attr("src"); + $(".preview-images").prepend(` + + 预告片 + + + `), $(".preview-video-container").off("click.jhsVideo").on("click.jhsVideo", (() => { + utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => this.handleVideo().catch(((error) => clog.error("预览视频处理失败", error))))); })); - let o2 = $(".section-meta:not(:contains('影片'))"); - if (o2.length && o2.text().trim().split(",").forEach(((e3) => { - i2.push(e3.trim()); - })), !i2.length) return void clog.error("获取演员名称失败"); - const r2 = i2[0]; - if (!a2) return void clog.error("无法获取演员ID进行收藏操作。"); - const l2 = ($(".avatar").first().css("background-image") || "").replace(/^url\(["']?|["']?\)$/g, ""), c2 = { - starId: a2, - name: r2, - allName: i2, - avatar: l2 - }; - 1 === await storageManager.addFavoriteActressList([c2]) ? (clog.log(`收藏演员成功: ${r2} (ID: ${a2})`), document.dispatchEvent(new CustomEvent("actress-state-changed", { detail: { starId: String(a2) } }))) : clog.log(`收藏演员失败: ${r2} (ID: ${a2})`); - })), $("#button-uncollect-actor").click((async (t2) => { - const n2 = $("#button-uncollect-actor").attr("href").match(e2), a2 = n2 ? n2[1] : null; - a2 ? await this.removeActorFromStorage(a2) : clog.error("无法获取演员ID进行取消收藏操作。"); + } catch (error) { + clog.error("预加载 DMM 失败:", error); + } + } + /** 复用单次 DMM 请求,避免预加载和点击处理重复抓取。 */ + getDmmPreview() { + if (this.dmmPreviewPromise) return this.dmmPreviewPromise; + this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum).then(((result) => { + (result.error?.retryable || "HTTP_ERROR" === result.error?.code) && (this.dmmPreviewPromise = null); + return result; + }), ((error) => { + this.dmmPreviewPromise = null; + throw error; })); + return this.dmmPreviewPromise; } - async replaceActressAvatar() { - const e2 = this.getActressId(); - if (!e2) return; - const t2 = (await storageManager.getFavoriteActressList()).find(((t3) => t3.starId === e2)); - if (t2 && t2.avatar) { - const e3 = `url('${t2.avatar}')`; - let n2 = $(".avatar").first(); - if (0 === n2.length) { - const e4 = '
    '; - $(".section-columns").prepend(e4), n2 = $(".avatar").first(); - } - if (0 === n2.length) return; - n2.css("background-image").trim().toLowerCase() !== e3.trim().toLowerCase() && (n2.css("background-image", e3), n2.css("background-size", "cover"), n2.css("background-position", "top center"), n2.css("background-repeat", "no-repeat")); + /** 创建与 JavDB HLS 生命周期完全隔离的 DMM 播放器。 */ + createDmmPlayer($nativeVideo) { + const $host = $nativeVideo.parent(), existing = $host.find("#jhs-preview-video"); + if (existing.length) return existing; + const $player = $(''); + return $nativeVideo.after($player), $player; + } + /** 销毁 JHS 播放器并把播放权完整交回 JavDB。 */ + async restoreNativePlayer($nativeVideo, nativeVideo, notify = false) { + const $dmmVideo = $nativeVideo.parent().find("#jhs-preview-video"), dmmVideo = $dmmVideo[0]; + dmmVideo && (dmmVideo.pause(), $dmmVideo.removeAttr("src"), dmmVideo.load(), $dmmVideo.remove()); + $nativeVideo.removeClass("jhs-native-preview-hidden"); + return safePlay(nativeVideo, { + context: "JavDB 原生预览回退", + notify + }); + } + async handleVideo() { + const $nativeVideo = $("#preview-video"); + if (!$nativeVideo.length) return; + const $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = localStorage.getItem("jhs_videoMuted"); + void safePlay(nativeVideo, { + context: "JavDB 原生预览", + notify: false + }); + const dmmEnabled = await storageManager.getSetting("enableLoadPreviewVideo", _) !== C, dmmResult = dmmEnabled ? await this.getDmmPreview() : { + sources: null, + error: null + }, { sources, error } = dmmResult, $toolbar = $("
    ").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
    ").addClass("jhs-video-quality-list").attr({ + role: "group", + "aria-label": "视频画质" + }); + $host.find("#video-bottom-toolbar").remove(); + let dmmPlayed = false, $dmmVideo = null, dmmVideo = null; + if (sources) { + const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; + const currentTime = nativeVideo.currentTime; + $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = !muted || "yes" === muted, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { + localStorage.setItem("jhs_videoMuted", dmmVideo.muted ? "yes" : "no"); + })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); + dmmPlayed = await safePlay(dmmVideo, { + context: "JavDB 高画质预览", + notify: false + }); + if (!dmmPlayed && !dmmVideo.muted) dmmVideo.muted = true, dmmPlayed = await safePlay(dmmVideo, { + context: "JavDB 高画质预览静音重试", + notify: false + }); + dmmPlayed ? (nativeVideo.pause(), $nativeVideo.addClass("jhs-native-preview-hidden")) : ($dmmVideo.removeClass("is-active"), await this.restoreNativePlayer($nativeVideo, nativeVideo, true)); + dmmPlayed && L.forEach(((quality) => { + const qualitySource = sources[quality.quality]; + if (!qualitySource) return; + const active = dmmPlayed && selectedQuality === quality.quality; + $qualityList.append($(``)); + })); } + $toolbar.append($qualityList); + const $actions = $("
    ").addClass("jhs-toolbar"); + $actions.append('', '', ''), $toolbar.append($actions), $host.append($toolbar), sources || await safePlay(nativeVideo, { + context: "JavDB 预览视频", + notify: true, + message: "REGION_BLOCKED" === error?.code ? error.message : "当前视频源无法播放" + }); + $toolbar.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (event) => { + const $button = $(event.currentTarget); + if ($button.hasClass("active")) return; + try { + if (!dmmVideo) return; + const currentTime = dmmVideo.currentTime, previousSource = $dmmVideo.attr("src"); + $dmmVideo.attr("src", $button.data("video-src")), dmmVideo.load(), dmmVideo.currentTime = currentTime; + const played = await safePlay(dmmVideo, { + context: "JavDB 画质切换", + notify: false + }); + if (played) $toolbar.find(".jhs-video-quality-btn").removeClass("active").attr("aria-pressed", "false"), $button.addClass("active").attr("aria-pressed", "true"); + else { + previousSource && ($dmmVideo.attr("src", previousSource), dmmVideo.load(), dmmVideo.currentTime = currentTime); + const restored = previousSource && await safePlay(dmmVideo, { + context: "JavDB 画质切换回退", + notify: false + }); + restored || await this.restoreNativePlayer($nativeVideo, nativeVideo, true); + } + } catch (playbackError) { + clog.error("切换画质失败:", playbackError); + } + })), $("#speed-btn").off("click.jhsVideo").on("click.jhsVideo", (() => { + dmmVideo && (dmmVideo.currentTime += 10); + })), $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", ((event) => (event.preventDefault(), this.getDependency("DetailPageButtonPlugin").filterOne(event)))), $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getDependency("DetailPageButtonPlugin").filterOne(event))), $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((event) => this.getDependency("DetailPageButtonPlugin").favoriteOne(event))); } }; - __name(_FavoriteActressesPlugin, "FavoriteActressesPlugin"); - var FavoriteActressesPlugin = _FavoriteActressesPlugin; + __name(_PreviewVideoPlugin, "PreviewVideoPlugin"); + var PreviewVideoPlugin = _PreviewVideoPlugin; - // src/plugins/image-viewer/bus-img.js - var _BusImgPlugin = class _BusImgPlugin extends BasePlugin { + // src/plugins/image-viewer/bus-preview-video.js + var _BusPreviewVideoPlugin = class _BusPreviewVideoPlugin extends BasePlugin { getName() { - return "BusImgPlugin"; + return "BusPreviewVideoPlugin"; } - handle() { + async initCss() { + return "\n .bus-preview-modal { position:fixed; inset:0; z-index:var(--jhs-z-modal); display:flex; align-items:center; justify-content:center; visibility:hidden; opacity:0; background:rgba(0,0,0,.95); transition:opacity var(--jhs-motion-base) var(--jhs-ease); }\n .bus-preview-modal.is-open { visibility:visible; opacity:1; }\n .bus-preview-modal-content { position:relative; display:flex; max-width:95%; max-height:95%; flex-direction:column; align-items:center; gap:var(--jhs-space-3); }\n .video-player-wrapper { position:relative; width:80vw; max-width:100%; max-height:85vh; aspect-ratio:16/9; background:#000; }\n .video-player-wrapper #preview-video { position:absolute; inset:0; }\n "; } - async getVisibleImageItems(e2, t2) { - let n2 = []; - const a2 = document.querySelectorAll(e2); - for (const i2 of a2) { - if (!utils.isHidden(i2)) { - const e3 = i2.querySelector(t2); - if (!(e3 instanceof HTMLImageElement)) continue; - e3.style.removeProperty("height"); - let a3 = e3.offsetHeight; - a3 > 0 && n2.push({ - element: i2, - imgElement: e3, - height: a3 - }); - } + initModal() { + if (0 === $("#bus-preview-modal").length) { + $("body").append('\n
    \n
    \n
    \n
    \n '); + const e2 = $("#bus-preview-modal"); + e2.on("click", ((e3) => { + "bus-preview-modal" === e3.target.id && this.closeVideoModal(); + })), $(document).on("keydown", ((t2) => { + "Escape" === t2.key && e2.hasClass("is-open") && this.closeVideoModal(); + })); } - return n2; } - async logImageHeightsByRow() { - if (await storageManager.getSetting("enableVerticalModel", C) === _) return; - const e2 = this.getSelector().itemSelector, t2 = await storageManager.getSetting("containerColumns", 5), n2 = await this.getVisibleImageItems(e2, "img"); - if (0 === n2.length) return; - const a2 = []; - for (let i2 = 0; i2 < n2.length; i2++) { - const e3 = Math.floor(i2 / t2); - a2[e3] || (a2[e3] = []), a2[e3].push(n2[i2]); - } - a2.forEach(((e3, t3) => { - const n3 = e3.map(((e4) => e4.height)); - if (n3.length < 2) return; - const a3 = Math.min(...n3), i2 = Math.max(...n3); - let s2 = 0; - i2 - a3 > 50 && (s2 = a3, e3.forEach(((e4) => { - if (e4.height !== s2) { - const t4 = `${s2}px`; - e4.imgElement.style.setProperty("height", t4, "important"); + closeVideoModal() { + const e2 = $("#preview-video"); + e2.length > 0 && e2[0].pause(), $("#bus-preview-modal").removeClass("is-open"); + } + async handle() { + if (!isDetailPage) return; + this.initModal(); + const e2 = $("#sample-waterfall .sample-box .photo-frame img:first").attr("src"), t2 = $(` + `); + $("#sample-waterfall").prepend(t2); + "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum).catch(((e3) => clog.warn("预加载 DMM 失败", e3))); + let n2 = false, a2 = $(".preview-video-container"); + a2.on("click", (async (e3) => { + if (e3.preventDefault(), e3.stopPropagation(), n2) show.info("正在加载中, 勿重复点击"); + else { + n2 = true; + try { + await this.handleVideo(); + } finally { + n2 = false; } - }))); + } + })), window.location.href.includes("autoPlay=1") && a2.trigger("click"); + } + async handleVideo() { + const e2 = $("#bus-preview-modal"), t2 = e2.find(".bus-preview-modal-content"); + let n2 = $("#preview-video"); + if (n2.length > 0) return e2.addClass("is-open"), void await safePlay(n2[0], { + context: "JavBus 预览视频", + notify: true + }); + let a2 = this.getPageInfo().carNum; + const { sources: i2, error: previewError } = await fetchDmmPreview(a2); + i2 && 0 !== Object.keys(i2).length ? (await this.createVideoPlayerAndControls(i2, t2), n2 = $("#preview-video"), n2.length > 0 ? (e2.addClass("is-open"), await safePlay(n2[0], { + context: "JavBus 预览视频", + notify: true, + message: "REGION_BLOCKED" === previewError?.code ? previewError.message : "当前视频源无法播放" + })) : show.error("视频播放器创建失败。")) : show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未找到可用的视频源。"); + } + async createVideoPlayerAndControls(e2, t2) { + let n2 = await storageManager.getSetting("videoQuality"); + n2 = Z(Object.keys(e2), n2); + let a2 = e2[n2]; + t2.html(` +
    + +
    +
    +
    + `); + const i2 = $("#preview-video"), s2 = i2.find("source"), o2 = t2.find(".jhs-video-quality-list"); + if (!i2.length || !s2.length) return; + const settings = this.getRuntimeService("settings"), r2 = i2[0], muted = settings.snapshot().videoMuted; + r2.muted = muted == null || muted === true, i2.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { + void settings.set("videoMuted", r2.muted).catch(((error) => clog.error("保存视频静音设置失败", error))); + })); + let c2 = ""; + L.forEach(((t3) => { + let a3 = e2[t3.quality]; + if (a3) { + const e3 = n2 === t3.quality; + c2 += ` + + `; + } + })), o2.html(c2); + const d2 = o2.find(".jhs-video-quality-btn"); + o2.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (e3) => { + try { + const t3 = $(e3.currentTarget); + if (t3.hasClass("active")) return; + let n3 = t3.attr("data-video-src"); + s2.attr("src", n3); + const a3 = r2.currentTime; + r2.load(), r2.currentTime = a3, await safePlay(r2, { + context: "JavBus 画质切换", + notify: true + }) && (d2.removeClass("active").attr("aria-pressed", "false"), t3.addClass("active").attr("aria-pressed", "true")); + } catch (t3) { + clog.error("切换画质失败:", t3); + } })); } }; - __name(_BusImgPlugin, "BusImgPlugin"); - var BusImgPlugin = _BusImgPlugin; + __name(_BusPreviewVideoPlugin, "BusPreviewVideoPlugin"); + var BusPreviewVideoPlugin = _BusPreviewVideoPlugin; // src/plugins/image-viewer/cover-button.js var _CoverButtonPlugin = class _CoverButtonPlugin extends BasePlugin { @@ -11187,6 +11150,14 @@ ${error.stack}` : ""); __name(normalizeMovieCarNum, "normalizeMovieCarNum"); // src/integrations/javstore/parser.js + function resolveDocument(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelectorAll === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelectorAll === "function") return candidate; + throw new TypeError("JavStore document is invalid"); + } + __name(resolveDocument, "resolveDocument"); function normalizeJavStoreAssetUrl(value, baseUrl = "https://javstore.net") { if (!value) return null; try { @@ -11202,22 +11173,31 @@ ${error.stack}` : ""); function parseJavStoreSearch($searchPage, carNum, baseUrl = "https://javstore.net") { const normalizedCarNum = normalizeMovieCarNum(carNum); if (!normalizedCarNum) return []; - const wrap = ( - /** @type {any} */ - globalThis.jQuery ?? /** @type {any} */ - globalThis.$ ?? $searchPage.constructor - ); - return $searchPage.find('a[href$="-pn.html"]').filter(((index, element) => wrap(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()))).map(((index, element) => new URL(wrap(element).attr("href"), baseUrl).href)).get(); + if (typeof $searchPage?.find === "function") { + const wrap = ( + /** @type {any} */ + globalThis.jQuery ?? /** @type {any} */ + globalThis.$ ?? $searchPage.constructor + ); + return $searchPage.find('a[href$="-pn.html"]').filter(((index, element) => wrap(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()))).map(((index, element) => new URL(wrap(element).attr("href"), baseUrl).href)).get(); + } + return [...resolveDocument($searchPage).querySelectorAll('a[href$="-pn.html"]')].filter((element) => element.textContent?.trim().toUpperCase().includes(normalizedCarNum.toUpperCase())).map((element) => new URL(element.getAttribute("href") || "", baseUrl).href); } __name(parseJavStoreSearch, "parseJavStoreSearch"); function parseJavStorePreview($detailPage, detailUrl) { - const wrap = ( - /** @type {any} */ - globalThis.jQuery ?? /** @type {any} */ - globalThis.$ ?? $detailPage.constructor - ); - const isPreviewLink = /* @__PURE__ */ __name((index, element) => "CLICK HERE!" === wrap(element).text().trim(), "isPreviewLink"); - const previewHref = $detailPage.find("a").filter(isPreviewLink).first().attr("href"); + if (typeof $detailPage?.find === "function") { + const wrap = ( + /** @type {any} */ + globalThis.jQuery ?? /** @type {any} */ + globalThis.$ ?? $detailPage.constructor + ); + const link = $detailPage.find("a").filter(((index, element) => "CLICK HERE!" === wrap(element).text().trim())).first(); + const previewHref2 = link.attr("href"); + if (!previewHref2) return null; + const previewUrl2 = normalizeJavStoreAssetUrl(previewHref2, detailUrl); + return previewUrl2 ? previewUrl2.replace(".th", "") : null; + } + const previewHref = [...resolveDocument($detailPage).querySelectorAll("a")].find((element) => element.textContent?.trim() === "CLICK HERE!")?.getAttribute("href"); if (!previewHref) return null; const previewUrl = normalizeJavStoreAssetUrl(previewHref, detailUrl); return previewUrl ? previewUrl.replace(".th", "") : null; @@ -11226,28 +11206,32 @@ ${error.stack}` : ""); // src/plugins/image-viewer/screenshot-provider-registry.js var _ScreenshotProviderRegistry = class _ScreenshotProviderRegistry { + /** @param {Array<{id: string, name: string, enabled?: boolean, priority?: number, getScreenshot: (carNum: string) => Promise}>} [providers] */ constructor(providers = []) { this.providers = /* @__PURE__ */ new Map(); providers.forEach(((provider) => this.register(provider))); } + /** @param {{id: string, name: string, enabled?: boolean, priority?: number, getScreenshot: (carNum: string) => Promise}} provider */ register(provider) { if (!provider?.id || !provider.name || "function" !== typeof provider.getScreenshot) throw new TypeError("Invalid screenshot provider"); this.providers.set(provider.id, { enabled: true, priority: 100, ...provider }); return this; } + /** @param {string} id */ get(id) { return this.providers.get(id) || null; } getEnabledProviders() { return [...this.providers.values()].filter(((provider) => provider.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); } + /** @param {string} carNum */ async first(carNum) { for (const provider of this.getEnabledProviders()) { try { const result = await provider.getScreenshot(carNum); if (result?.url) return result; } catch (error) { - clog.warn(`截图源 ${provider.name} 请求失败`, error); + globalThis.clog?.warn(`截图源 ${provider.name} 请求失败`, error); } } return null; @@ -11264,7 +11248,7 @@ ${error.stack}` : ""); async initializeProviders() { const settings = await new ResourceSettingsService().getScreenshotSettings(), configured = /* @__PURE__ */ __name((id) => settings.providers.find(((provider) => provider.id === id)) || {}, "configured"); this.providerRegistry = new ScreenshotProviderRegistry([ - { id: "javstore", name: "JavStore", priority: 10, getScreenshot: /* @__PURE__ */ __name((carNum) => this.getCachedProviderScreenshot("javstore", carNum, (() => this.getJavStoreScreenShot(carNum))), "getScreenshot") }, + { id: "javstore", name: "JavStore", priority: 10, getScreenshot: /* @__PURE__ */ __name((carNum) => this.getServiceScreenshot(carNum), "getScreenshot") }, { id: "projectjav", name: "ProjectJav", enabled: false, priority: 20, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") }, { id: "18av", name: "18AV", enabled: false, priority: 30, getScreenshot: /* @__PURE__ */ __name(async () => null, "getScreenshot") } ].map(((provider) => ({ ...provider, ...configured(provider.id), enabled: !["projectjav", "18av"].includes(provider.id) && (configured(provider.id).enabled ?? provider.enabled ?? true), getScreenshot: provider.getScreenshot })))); @@ -11318,7 +11302,6 @@ ${error.stack}` : ""); async getScreenshotFromInitializedProviders(e2) { e2 = normalizeCarNum(e2); if (!e2) throw new Error("缩略图番号不可用"); - localStorage.removeItem("jhs_screenShot"); let n2; try { n2 = await this.providerRegistry.first(e2); @@ -11329,39 +11312,11 @@ ${error.stack}` : ""); let url = n2.url, a2 = url.indexOf("https://"); return -1 !== a2 && (url = url.substring(a2)), clog.log(`缩略图获取成功 (${n2.source}):`, url), url; } - async getCachedProviderScreenshot(provider, carNum, loader) { - const value = await storageManager.cachedRequest(`screenshot:${provider}:${carNum}`, CACHE_TTL.screenshot, (async () => { - const loadedUrl = await loader(), url2 = "javstore" === provider ? normalizeJavStoreAssetUrl(loadedUrl) : loadedUrl; - return url2 ? { __jhsCacheTtl: CACHE_TTL.screenshot, data: { url: url2 } } : { __jhsCacheTtl: CACHE_TTL.screenshotNegative, data: { miss: true } }; - })); - if (!value || value.miss) return null; - const cachedUrl = "string" === typeof value ? value : value.url, url = "javstore" === provider ? normalizeJavStoreAssetUrl(cachedUrl) : cachedUrl; - return url ? { url, source: provider, detailUrl: null } : null; - } - async getJavStoreScreenShot(e2) { - const t2 = `https://javstore.net/search?q=${encodeURIComponent(e2)}`; - clog.debug("JavStore 搜索地址:", t2); - let n2 = await gmHttp.get(t2, {}, {}, false, { ignoreNotFound: true }); - if (!n2) return clog.debug("JavStore 搜索页未获取:", t2), null; - const a2 = utils.htmlTo$dom(n2); - const i2 = parseJavStoreSearch(a2, e2); - if (!i2.length) return clog.debug("JavStore, 查询番号无结果:", t2), null; - for (const e3 of i2) { - const t3 = e3; - clog.debug("JavStore 候选详情:", t3); - const n3 = await gmHttp.get(t3, {}, {}, false, { ignoreNotFound: true }); - if (!n3) { - clog.debug("JavStore 详情页未获取:", t3); - continue; - } - const a3 = parseJavStorePreview(utils.htmlTo$dom(n3), t3); - if (!a3) { - clog.debug("JavStore 详情页没有 CLICK HERE!:", t3); - continue; - } - return clog.debug("JavStore 预览图:", a3), a3; - } - return clog.debug("JavStore, 所有候选均无有效预览图:", t2), null; + async getServiceScreenshot(carNum) { + const scope = await this.getRuntimeService("scope")(); + const images = await this.getRuntimeService("screenshot").resolve({ carNum }, { scope }); + const image = Array.isArray(images) ? images[0] : images; + return image?.url ? { url: image.url, source: image.providerId || "javstore", detailUrl: null } : null; } addImg(e2, t2) { const url = normalizeJavStoreAssetUrl(t2); @@ -13223,6 +13178,7 @@ ${error.stack}` : ""); $("#newVideoBtn").after(e2), $("#statsBtn").on("click", (() => this.openDialog())); } async openDialog() { + const diagnostics = this.getRuntimeService("diagnostics").exportSnapshot(); const cars = await storageManager.getCarList(), actresses = await storageManager.getFavoriteActressList(), blacklist = await storageManager.getBlacklist(), activity = await stateService.getActivityLog(), total = cars.length; const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; cars.forEach(((car) => { @@ -13251,7 +13207,9 @@ ${error.stack}` : ""); { label: "未鉴定", value: pending, action: null }, { label: "收藏演员", value: actresses.length, action: null }, { label: "黑名单演员", value: blacklist.length, action: null }, - { label: "新作品待处理", value: newVideos, action: "new-video" } + { label: "新作品待处理", value: newVideos, action: "new-video" }, + { label: "活跃功能", value: diagnostics.activeFeatures.length, action: null }, + { label: "运行错误", value: diagnostics.errors.length, action: null } ]; const statusRows = [["收藏", counts.favorite, "var(--jhs-status-fav)"], ["下载", counts.hasDown, "var(--jhs-status-down)"], ["已看", counts.hasWatch, "var(--jhs-status-watch)"], ["手动屏蔽", counts.manualBlocked, "var(--jhs-status-filter)"], ["未鉴定", pending, "var(--jhs-border-strong)"]]; const row = /* @__PURE__ */ __name((label, value, max, color, href = "") => `
    ${href ? `${escapeHtml(label)}` : `${escapeHtml(label)}`}${value}${max === total && total ? ` (${Math.round(value / total * 100)}%)` : ""}
    `, "row"); @@ -13640,7 +13598,10 @@ ${error.stack}` : ""); $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2.doFilterMagnet(), $("#enable-magnets-filter").on("click", ((e3) => { let t3 = $("#magnets-span"); "关闭磁力过滤" === t3.text() ? (a2.showAll(), t3.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a2.doFilterMagnet(), t3.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); - })), $("#search-subtitle-btn").on("click", ((e3) => utils.openPage(`https://subtitlecat.com/index.php?search=${t2}`, t2, false, e3))), $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(t2))); + })), $("#search-subtitle-btn").on("click", ((e3) => { + const target = this.getRuntimeService("movie").sourceUrls({ carNum: t2 }, ["subtitlecat"])[0]?.url; + if (target) utils.openPage(target, t2, false, e3); + })), $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(t2))); if (!t2) { $("#filterBtn, #favoriteBtn, #hasDownBtn, #hasWatchBtn, #magnetSearchBtn, #xunLeiSubtitleBtn, #search-subtitle-btn").prop("disabled", true).attr("title", "番号不可用"); return void clog.warn("详情操作不可用:番号不可用"); @@ -13680,7 +13641,8 @@ ${error.stack}` : ""); area: utils.getResponsiveArea(["60%", "70%"]), anim: -1, success: /* @__PURE__ */ __name((t4, a2) => { - new Tabulator("#xunlei-table-container", { + createJhsTable(Tabulator, "#xunlei-table-container", { + pagination: false, layout: "fitColumns", placeholder: "暂无数据", virtualDom: true, @@ -13895,9 +13857,10 @@ ${error.stack}` : ""); `); let a2 = $("h2.section-title"); if (a2.length > 0 && (a2.append('\n
    \n \n
    \n '), t2 = $("section > div > div.box")), !t2) return; - let i2 = $("#foldCategoryBtn"), s2 = localStorage.getItem("jhs_foldCategory") === _, [o2, r2] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; + const settings = this.getRuntimeService("settings"); + let i2 = $("#foldCategoryBtn"), s2 = settings.snapshot().foldCategoryCollapsed === true, [o2, r2] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; i2.find("span").text(o2).end().find("i").attr("class", r2), window.location.href.includes("noFold=1") || t2[s2 ? "hide" : "show"](), i2.on("click", (async (e2) => { - e2.preventDefault(), s2 = !s2, localStorage.setItem("jhs_foldCategory", s2 ? _ : C); + e2.preventDefault(), s2 = !s2, await settings.set("foldCategoryCollapsed", s2); const [n3, a3] = s2 ? ["展开", "icon-angle-double-down"] : ["折叠", "icon-angle-double-up"]; i2.find("span").text(n3).end().find("i").attr("class", a3), t2[s2 ? "hide" : "show"](); })); @@ -13992,10 +13955,66 @@ ${error.stack}` : ""); __name(_HighlightMagnetPlugin, "HighlightMagnetPlugin"); var HighlightMagnetPlugin = _HighlightMagnetPlugin; + // src/core/selection-model.js + var _SelectionModel = class _SelectionModel extends EventTarget { + /** @param {(item: any) => string} keyOf */ + constructor(keyOf) { + super(); + this.keyOf = keyOf; + this.mode = "explicit"; + this.selected = /* @__PURE__ */ new Set(); + this.excluded = /* @__PURE__ */ new Set(); + } + /** @param {any} item @param {boolean} selected */ + set(item, selected) { + const key = this.keyOf(item), target = this.mode === "all-filtered" ? this.excluded : this.selected; + this.mode === "all-filtered" ? selected ? target.delete(key) : target.add(key) : selected ? target.add(key) : target.delete(key); + this.emit(); + } + selectAllFiltered() { + this.mode = "all-filtered"; + this.selected.clear(); + this.excluded.clear(); + this.emit(); + } + clear() { + this.mode = "explicit"; + this.selected.clear(); + this.excluded.clear(); + this.emit(); + } + /** @param {any} item */ + has(item) { + const key = this.keyOf(item); + return this.mode === "all-filtered" ? !this.excluded.has(key) : this.selected.has(key); + } + /** @param {any[]} filteredItems */ + values(filteredItems) { + return this.mode === "all-filtered" ? filteredItems.filter((item) => !this.excluded.has(this.keyOf(item))) : filteredItems.filter((item) => this.selected.has(this.keyOf(item))); + } + snapshot() { + return Object.freeze({ mode: this.mode, selected: [...this.selected], excluded: [...this.excluded] }); + } + emit() { + this.dispatchEvent(new CustomEvent("selection.changed", { detail: this.snapshot() })); + } + }; + __name(_SelectionModel, "SelectionModel"); + var SelectionModel = _SelectionModel; + + // src/features/history/history-selection-model.js + var _HistorySelectionModel = class _HistorySelectionModel extends SelectionModel { + constructor() { + super((item) => normalizeMovieCarNum(item?.carNum) ?? String(item?.id ?? "")); + } + }; + __name(_HistorySelectionModel, "HistorySelectionModel"); + var HistorySelectionModel = _HistorySelectionModel; + // src/plugins/status/history.js var _HistoryPlugin = class _HistoryPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectAll", false), i(this, "historyExcludedCarNums", /* @__PURE__ */ new Set()), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); + super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectionModel", new HistorySelectionModel()), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); } getName() { return "HistoryPlugin"; @@ -14135,9 +14154,12 @@ ${error.stack}` : ""); normalizeHistoryCarNum(carNum) { return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); } + isHistoryAllFiltered() { + return "all-filtered" === this.historySelectionModel.mode; + } /** 清空 History 的全选、排除项和当前页选择。 */ resetHistorySelection(deselectRows = true) { - this.historySelectAll = false, this.historyExcludedCarNums.clear(); + this.historySelectionModel.clear(); if (deselectRows && this.tableObj) { this.historySelectionSyncing = true; try { @@ -14151,12 +14173,12 @@ ${error.stack}` : ""); createHistorySelectAllCheckbox() { const checkbox = document.createElement("input"); return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { - checkbox.checked ? (this.historySelectAll = true, this.historyExcludedCarNums.clear(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); + checkbox.checked ? (this.historySelectionModel.selectAllFiltered(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); })), checkbox; } syncHistoryPageSelection() { - if (!this.tableObj || !this.historySelectAll) return void this.updateHistorySelectionUi(); - const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.map(((item) => item.carNum)).filter(((carNum) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(carNum)))); + if (!this.tableObj || !this.isHistoryAllFiltered()) return void this.updateHistorySelectionUi(); + const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.filter(((item) => this.historySelectionModel.has(item))).map(((item) => item.carNum)); this.historySelectionSyncing = true; try { this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); @@ -14166,19 +14188,19 @@ ${error.stack}` : ""); this.updateHistorySelectionUi(); } updateHistoryRowSelection(row, selected) { - if (!this.historySelectAll || this.historySelectionSyncing) return void this.updateHistorySelectionUi(); - const carNum = this.normalizeHistoryCarNum(row?.getData?.().carNum); - carNum && (selected ? this.historyExcludedCarNums.delete(carNum) : this.historyExcludedCarNums.add(carNum)), this.updateHistorySelectionUi(); + if (this.historySelectionSyncing) return void this.updateHistorySelectionUi(); + const item = row?.getData?.(); + item && this.historySelectionModel.set(item, selected), this.updateHistorySelectionUi(); } getHistorySelectionSummary() { - if (this.historySelectAll) { - const excluded = this.historyExcludedCarNums.size, selected = Math.max(0, this.historyFilteredCount - excluded); + if (this.isHistoryAllFiltered()) { + const excluded = this.historySelectionModel.excluded.size, selected = Math.max(0, this.historyFilteredCount - excluded); return { count: selected, text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` }; } - const count = this.tableObj?.getSelectedData?.().length || 0; + const count = this.historySelectionModel.selected.size; return { count, text: `已选择当前页 ${count} 条` @@ -14189,15 +14211,15 @@ ${error.stack}` : ""); const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { - element.checked = this.historySelectAll, element.indeterminate = this.historySelectAll ? this.historyExcludedCarNums.size > 0 : summary.count > 0; + element.checked = this.isHistoryAllFiltered(), element.indeterminate = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size > 0 : summary.count > 0; })); } /** 解析批量操作实际要处理的当前页或完整筛选结果。 */ async getHistoryBatchSelection() { - if (!this.historySelectAll) return this.tableObj?.getSelectedData?.() || []; + if (!this.isHistoryAllFiltered()) return this.historySelectionModel.values(this.tableObj?.getData?.() || []); const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map(((item) => this.normalizeHistoryCarNum(item.carNum)))); - for (const carNum of this.historyExcludedCarNums) available.has(carNum) || this.historyExcludedCarNums.delete(carNum); - return this.historyFilteredCount = rows.length, rows.filter(((item) => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(item.carNum)))); + for (const carNum of this.historySelectionModel.excluded) available.has(carNum) || this.historySelectionModel.excluded.delete(carNum); + return this.historyFilteredCount = rows.length, this.historySelectionModel.values(rows); } bindHistoryActions(root) { root.on("click.jhsHistory", (function(e2) { @@ -14239,7 +14261,7 @@ ${error.stack}` : ""); let n2 = await this.getHistoryBatchSelection(), a2 = "", i2 = ""; t2.hasClass("multiple-history-filterBtn") ? (a2 = "屏蔽", i2 = d) : t2.hasClass("multiple-history-favoriteBtn") ? (a2 = "收藏", i2 = h) : t2.hasClass("multiple-history-hasDownBtn") ? (a2 = "已下载", i2 = g) : t2.hasClass("multiple-history-hasWatchBtn") ? (a2 = "已观看", i2 = p) : t2.hasClass("multiple-history-deleteBtn") && (a2 = "移除", i2 = "delete"); if (!n2.length) return void show.info("请先选择要处理的记录"); - const selectionText = this.historySelectAll ? this.historyExcludedCarNums.size ? `当前筛选结果中已选择 ${n2.length} 条,排除 ${this.historyExcludedCarNums.size} 条` : `当前筛选结果中已选择全部 ${n2.length} 条` : `当前页已选择 ${n2.length} 条`; + const selectionText = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size ? `当前筛选结果中已选择 ${n2.length} 条,排除 ${this.historySelectionModel.excluded.size} 条` : `当前筛选结果中已选择全部 ${n2.length} 条` : `当前页已选择 ${n2.length} 条`; utils.q(e2, `${selectionText},是否标记为${a2}?`, (async () => { let e3 = loading(); try { @@ -14300,7 +14322,7 @@ ${error.stack}` : ""); }; } async loadTableData() { - this.tableObj = new Tabulator(this.historyRoot.find("#table-container").get(0), { + this.tableObj = createJhsTable(Tabulator, this.historyRoot.find("#table-container").get(0), { layout: "fitColumns", placeholder: "暂无数据", virtualDom: true, @@ -14457,7 +14479,7 @@ ${error.stack}` : ""); } } }), this.tableObj.on("dataProcessed", (() => { - this.historySelectAll ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); + this.isHistoryAllFiltered() ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); })), this.tableObj.on("rowSelected", ((row) => { this.updateHistoryRowSelection(row, true); })), this.tableObj.on("rowDeselected", ((row) => { @@ -14588,7 +14610,7 @@ ${error.stack}` : ""); })); const r2 = o.includes("advanced_search"); r2 && (t2 = $("h2.section-title")); - const l2 = localStorage.getItem("jhs_sortMethod"), d2 = "当前排序方式: " + ("rateCount" === l2 ? "评价人数" : "date" === l2 ? "时间" : "默认"); + const l2 = this.getRuntimeService("settings").snapshot().sortMethod || "default", d2 = "当前排序方式: " + ("rateCount" === l2 ? "评价人数" : "date" === l2 ? "时间" : "默认"); t2.append(`
    @@ -14616,7 +14638,7 @@ ${error.stack}` : ""); const e3 = await storageManager.getBlacklist(), a3 = this.getActressPageInfo(); e3.find(((e4) => e4.starId === a3.starId)) && (t2 = "已加入黑名单", n2 = "jhs-btn--muted"); } - const a2 = localStorage.getItem("jhs_sortMethod") || "default"; + const a2 = this.getRuntimeService("settings").snapshot().sortMethod || "default"; $(".masonry").parent().prepend(`
    @@ -14707,9 +14729,9 @@ ${error.stack}` : ""); const open = !menu.hasClass("is-open"); menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); })); - menu.on("click", ".jhs-sort-option", ((event) => { + menu.on("click", ".jhs-sort-option", (async (event) => { const item = $(event.currentTarget), method = item.data("sort-method"); - localStorage.setItem("jhs_sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(true), void this.sortItems().catch(((error) => clog.error("列表排序失败", error))); + await this.getRuntimeService("settings").set("sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(true), await this.sortItems().catch(((error) => clog.error("列表排序失败", error))); })).on("keydown", ".jhs-sort-option", ((event) => { const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), close(true); @@ -14727,7 +14749,7 @@ ${error.stack}` : ""); if (!e2 && (o.includes("handle") || o.includes("advanced_search"))) return; const s2 = await storageManager.getSetting("autoPage"); if (c || s2 === _ && !e2) return; - const t2 = localStorage.getItem("jhs_sortMethod"); + const t2 = this.getRuntimeService("settings").snapshot().sortMethod; if (!t2) return; const i2 = this.getSelector(), d2 = $(i2.boxSelector), h2 = $(i2.itemSelector); h2.each((function(e3) { @@ -15052,7 +15074,7 @@ ${error.stack}` : ""); const item = /* @__PURE__ */ __name((action, label, attributes = "") => ``, "item"), group = /* @__PURE__ */ __name((content) => `
    ${content}
    `, "group"), divider = ''; let items; if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); + const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; } else if (window.isDetailPage) { const statusDefs = [{ action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" }]; @@ -15111,7 +15133,7 @@ ${error.stack}` : ""); menu.addClass("jhs-fab-menu-open").attr("aria-hidden", "false"); backdrop.addClass("jhs-fab-backdrop-visible"); if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default"; + const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default"; const sortLabel = { default: "默认", rateCount: "评价人数", date: "时间" }[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); @@ -15156,10 +15178,10 @@ ${error.stack}` : ""); event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ".jhs-mobile-sort-option", ((event) => { + })).on("click", ".jhs-mobile-sort-option", (async (event) => { event.stopPropagation(); const value = $(event.currentTarget).data("jhs-sort"); - localStorage.setItem("jhs_sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), void this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(true); + await this.getRuntimeService("settings").set("sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), await this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(true); })); menu.on("click", ".jhs-fab-menu-item", (e2) => { const action = $(e2.currentTarget).data("action"); @@ -15371,131 +15393,90 @@ ${error.stack}` : ""); isDetailPage && this.translate(); } async translate(e2, t2 = true, options = {}) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; + if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) !== _) return; l && (t2 = false); - const root = options.root ? $(options.root) : $(document); - let n2 = root.find(".origin-title").first(); - if (n2.length || (n2 = root.find(".current-title").first()), n2.length || (n2 = root.find("h3").first()), !n2.length) return; - const a2 = n2.text().trim(); - if (!a2) return void show.error("获取标题失败, 无法进行翻译"); - let i2 = n2.nextAll(".translated-title").first(); - i2.length || (i2 = $('
    ').insertAfter(n2)), i2.removeClass("is-error").text("翻译中..."); - e2 || (e2 = this.getPageInfo().carNum); - const s2 = "string" == typeof e2 ? e2.trim() : "", o2 = s2 && "undefined" !== s2 ? s2 : a2; - let r2 = {}; - try { - const e3 = localStorage.getItem("jhs_translate"); - e3 && (r2 = JSON.parse(e3) || {}); - } catch (l2) { - clog.warn("翻译缓存无法解析,已忽略旧缓存", l2); - } - if (r2[o2]) return void i2.text(r2[o2]); - try { - const e3 = await _e(a2, "ja", "zh-CN"); - if (!n2[0]?.isConnected) return; - i2.text(e3), r2[o2] = e3, localStorage.setItem("jhs_translate", JSON.stringify(r2)); - } catch (l2) { - clog.error("翻译失败:", l2), i2.addClass("is-error").text(`翻译失败: ${l2.message || String(l2)}`); - } + const scope = await this.getRuntimeService("scope")(); + await renderTranslatedTitle({ root: options.root, carNum: e2, translation: this.getRuntimeService("translation"), scope }); } }; __name(_TranslatePlugin, "TranslatePlugin"); var TranslatePlugin = _TranslatePlugin; // src/plugins/registry.js - var DEFAULT_JAVDB_PLUGINS = [ - ListPagePlugin, - AutoPagePlugin, - Fc2Plugin, - FoldCategoryPlugin, - ListPageButtonPlugin, - HistoryPlugin, - SettingPlugin, - NavBarPlugin, - HitShowPlugin, - Top250Plugin, - SearchByImagePlugin, - CoverButtonPlugin, - Fc2By123AvPlugin, - DetailPagePlugin, - DetailWorkspacePlugin, - ReviewPlugin, - RelatedPlugin, - DetailPageButtonPlugin, - HighlightMagnetPlugin, - PreviewVideoPlugin, - FilterTitleKeywordPlugin, - ActressInfoPlugin, - OtherSitePlugin, - TranslatePlugin, - WantAndWatchedVideosPlugin, - MagnetHubPlugin, - ScreenShotPlugin, - BlacklistPlugin, - FavoriteActressesPlugin, - NewVideoPlugin, - TaskPlugin, - StatsPlugin, - MobileBottomBarPlugin, - OneOneFiveMatchPlugin, - UnifiedOfflinePlugin, - CompatibilityEnhancementsPlugin - ]; - var DEFAULT_JAVBUS_PLUGINS = [ - ListPagePlugin, - ListPageButtonPlugin, - SettingPlugin, - HistoryPlugin, - AutoPagePlugin, - SearchByImagePlugin, - BusNavBarPlugin, - CoverButtonPlugin, - BusImgPlugin, - BusDetailPagePlugin, - DetailWorkspacePlugin, - DetailPageButtonPlugin, - ReviewPlugin, - FilterTitleKeywordPlugin, - HighlightMagnetPlugin, - BusPreviewVideoPlugin, - MagnetHubPlugin, - ScreenShotPlugin, - OtherSitePlugin, - TranslatePlugin, - BlacklistPlugin, - TaskPlugin, - StatsPlugin, - MobileBottomBarPlugin, - OneOneFiveMatchPlugin, - UnifiedOfflinePlugin, - CompatibilityEnhancementsPlugin - ]; - var DEFAULT_SHARED_PLUGIN_RULES = [ - { - shouldRegister: /* @__PURE__ */ __name((context) => context.isJavDB || context.isJavBus || context.is123Pan, "shouldRegister"), - plugins: [OneTwoThreeOfflinePlugin] - }, - { - shouldRegister: /* @__PURE__ */ __name((context) => context.isJavTrailers, "shouldRegister"), - plugins: [JavTrailersPlugin] - }, - { - shouldRegister: /* @__PURE__ */ __name((context) => context.isSubtitleCat, "shouldRegister"), - plugins: [SubTitleCatPlugin] - } - ]; - function registerPluginGroup(pluginManager, plugins) { - plugins.forEach(((pluginClass) => pluginManager.register(pluginClass))); - } - __name(registerPluginGroup, "registerPluginGroup"); - function registerSitePlugins(pluginManager, locationLike = window.location) { + var manifest = /* @__PURE__ */ __name((id, featureId, plugin, sites, order, requires = []) => defineContribution({ id, featureId, legacyPluginId: plugin.name, plugin, sites, order, requires }), "manifest"); + var legacyContributionManifests = Object.freeze([ + manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation]), + manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }), + manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.screenshot]), + manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), + manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), + manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), + manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), + manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), + manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }), + manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }), + manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), + manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), + manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), + manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }), + manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie]), + manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related]), + manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), + manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }), + manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), + manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), + manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), + manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [SERVICE.movie]), + manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), + manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), + manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), + manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }), + manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), + manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), + manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), + manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics]), + manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), + manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }), + manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), + manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), + manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), + manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), + manifest("detail.native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), + manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings]), + manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }), + manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), + manifest("detail.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }) + ]); + function registerSitePlugins(pluginManager, featureRuntime, site) { pluginManager.setDependencyDeclarations(LEGACY_PLUGIN_DEPENDENCY_MAP); - const context = detectSite(locationLike); - DEFAULT_SHARED_PLUGIN_RULES.forEach(((rule) => { - rule.shouldRegister(context) && registerPluginGroup(pluginManager, rule.plugins); - })); - context.isJavDB && registerPluginGroup(pluginManager, DEFAULT_JAVDB_PLUGINS); - context.isJavBus && registerPluginGroup(pluginManager, DEFAULT_JAVBUS_PLUGINS); + legacyContributionManifests.filter((item) => item.sites.includes(site) && featureRuntime.isContributionEnabled(item.featureId, item.id, item.legacyPluginId)).sort((left, right) => Number(left.order[site]) - Number(right.order[site])).forEach((item) => { + const dependencies = featureRuntime.resolveDeclaredDependencies(item.requires); + const runtimeServices = {}; + runtimeServices.scope = () => featureRuntime.getScope(item.featureId); + const runtimeNames = /* @__PURE__ */ new Map([ + [PORT.host, "host"], + [SERVICE.diagnostics, "diagnostics"], + [SERVICE.review, "review"], + [SERVICE.related, "related"], + [SERVICE.movie, "movie"], + [SERVICE.magnet, "magnet"], + [SERVICE.settings, "settings"], + [SERVICE.cache, "cache"], + [SERVICE.actressInfo, "actressInfo"], + [SERVICE.screenshot, "screenshot"], + [SERVICE.translation, "translation"], + [SERVICE.webdav, "webdav"], + [SERVICE.dialog, "dialog"] + ]); + for (const token of item.requires) { + const name = runtimeNames.get(token); + if (!name) throw new Error(`Legacy contribution ${item.id} has no runtime name for ${String(token)}`); + runtimeServices[name] = dependencies[token]; + } + pluginManager.register(item.plugin, runtimeServices); + }); } __name(registerSitePlugins, "registerSitePlugins"); @@ -15622,7 +15603,7 @@ ${error.stack}` : ""); if (typeof requestImplementation !== "function") throw new TypeError("GM_xmlhttpRequest is required"); this.requestImplementation = requestImplementation; } - /** @param {{url: string, method?: string, headers?: Record, body?: unknown, responseType?: string, timeout?: number, signal?: AbortSignal}} options */ + /** @param {{url: string, method?: string, headers?: Record, body?: unknown, responseType?: string, timeout?: number, signal?: AbortSignal, requestOptions?: Record}} options */ request(options) { return new Promise((resolve, reject) => { let settled = false; @@ -15638,6 +15619,7 @@ ${error.stack}` : ""); finish(reject, new JhsError("ABORTED", "网络请求已取消", { source: "UserscriptHttpAdapter" })); }, "onAbort"); handle = this.requestImplementation({ + ...options.requestOptions, method: options.method ?? "GET", url: options.url, headers: options.headers, @@ -15709,7 +15691,51 @@ ${error.stack}` : ""); __name(_CacheService, "CacheService"); var CacheService = _CacheService; + // src/services/actress-info-service.js + var _ActressInfoService = class _ActressInfoService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations @param {import("./cache-service.js").CacheService} cache */ + constructor(integrations, cache) { + this.integrations = integrations; + this.cache = cache; + } + /** @param {string} name */ + profileUrl(name) { + for (const manifest2 of this.integrations.list("person.actress-info")) { + const adapter = this.integrations.getAdapter(manifest2.id); + if (typeof adapter.profileUrl === "function") return adapter.profileUrl(name); + } + return ""; + } + /** @param {string} name @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async lookup(name, options = {}) { + const normalized = String(name).trim() === "三上悠亞" ? "三上悠亜" : String(name).trim(); + if (!normalized) return null; + const key = `actress-info:${normalized}`; + const cached = this.cache.get(key, { scope: "public" }); + if (cached.hit) return cached.value; + for (const manifest2 of this.integrations.list("person.actress-info")) { + const adapter = this.integrations.getAdapter(manifest2.id); + if (typeof adapter.lookup !== "function") continue; + const result = await adapter.lookup(normalized, options); + this.cache.set(key, result, { scope: "public", ttlMs: 6048e5, negative: !result }); + return result; + } + return null; + } + }; + __name(_ActressInfoService, "ActressInfoService"); + var ActressInfoService = _ActressInfoService; + // src/services/diagnostics-service.js + var SENSITIVE_KEY = /^(?:authorization|cookie|set-cookie|password|token|secret|credential)$/i; + function sanitize(value, key = "") { + if (SENSITIVE_KEY.test(key)) return "[redacted]"; + if (Array.isArray(value)) return value.map((item) => sanitize(item)); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, sanitize(child, childKey)])); + if (typeof value !== "string") return value; + return value.replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, "$1[redacted]@"); + } + __name(sanitize, "sanitize"); var _DiagnosticsService = class _DiagnosticsService { constructor() { this.startedAt = performance.now(); @@ -15722,6 +15748,9 @@ ${error.stack}` : ""); this.providerHealth = /* @__PURE__ */ new Map(); this.errors = []; this.browserMetadata = null; + this.legacyPlugins = []; + this.legacyStartup = null; + this.legacyTimings = []; } /** @param {string} id @param {number} durationMs */ recordStartup(id, durationMs) { @@ -15753,28 +15782,42 @@ ${error.stack}` : ""); } /** @param {unknown} error */ recordError(error) { - const value = error && typeof error === "object" && "toJSON" in error && typeof error.toJSON === "function" ? error.toJSON() : { message: error instanceof Error ? error.message : String(error) }; - this.errors.push({ ...value, timestamp: (/* @__PURE__ */ new Date()).toISOString() }); + const value = error && typeof error === "object" && "toJSON" in error && typeof error.toJSON === "function" ? error.toJSON() : error instanceof Error ? { message: error.message } : error && typeof error === "object" ? { ...error } : { message: String(error) }; + this.errors.push({ .../** @type {Record} */ + sanitize(value), timestamp: (/* @__PURE__ */ new Date()).toISOString() }); if (this.errors.length > 100) this.errors.shift(); } /** @param {Record} metadata */ setBrowserMetadata(metadata) { this.browserMetadata = Object.freeze({ ...metadata }); } + /** @param {string[]} names @param {Record} startup @param {Array>} timings */ + setLegacyRuntime(names, startup, timings) { + this.legacyPlugins = [...names]; + this.legacyStartup = Object.freeze({ ...startup }); + this.legacyTimings = timings.map((item) => Object.freeze({ ...item })); + } + clearErrors() { + this.errors = []; + } exportSnapshot() { + const scopes = [...this.scopes.values()]; return Object.freeze({ activeFeatures: [...this.activeFeatures], activeContributions: [...this.activeContributions], startupTimings: Object.fromEntries(this.startupTimings), - activeScopes: [...this.scopes.values()], - globalListeners: 0, - observers: [...this.scopes.values()].reduce((sum, scope) => sum + Number(scope.observers ?? 0), 0), + activeScopes: scopes, + globalListeners: scopes.filter((scope) => scope.id === "app:root").reduce((sum, scope) => sum + Number(scope.listeners ?? 0), 0), + observers: scopes.reduce((sum, scope) => sum + Number(scope.observers ?? 0), 0), requestConsumers: this.requestStats.consumers, underlyingRequests: this.requestStats.underlying, cacheHits: this.cacheStats.hits, cacheMisses: this.cacheStats.misses, providerHealth: Object.fromEntries(this.providerHealth), errors: this.errors.map((error) => ({ ...error })), + legacyPlugins: [...this.legacyPlugins], + legacyStartup: this.legacyStartup, + legacyTimings: this.legacyTimings.map((item) => ({ ...item })), browser: this.browserMetadata, uptimeMs: performance.now() - this.startedAt }); @@ -16023,9 +16066,10 @@ ${error.stack}` : ""); // src/services/magnet-service.js var _MagnetService = class _MagnetService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ - constructor(providers) { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(providers, integrations = null) { this.providers = providers; + this.integrations = integrations; } /** @param {Record} movieRef @param {Record} [context] */ async search(movieRef, context = {}) { @@ -16041,12 +16085,24 @@ ${error.stack}` : ""); } return results; } + /** @param {Record} movieRef @param {Record} [context] */ + async listNative(movieRef, context = {}) { + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : "javdb"; + const manifest2 = (this.integrations?.list("movie.magnets") ?? []).find((item) => item.id === providerId); + if (!manifest2) return []; + const adapter = this.integrations?.getAdapter(manifest2.id); + return typeof adapter?.listMagnets === "function" ? adapter.listMagnets(movieRef, context) : []; + } }; __name(_MagnetService, "MagnetService"); var MagnetService = _MagnetService; // src/services/movie-identity-service.js var _MovieIdentityService = class _MovieIdentityService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(integrations = null) { + this.integrations = integrations; + } /** @param {unknown} value */ normalize(value) { return normalizeMovieCarNum(value); @@ -16059,6 +16115,92 @@ ${error.stack}` : ""); } return null; } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async resolve(movieRef, options = {}) { + const carNum = this.normalize(movieRef.carNum); + if (!carNum) return null; + const candidates = this.integrations?.list("movie.search") ?? []; + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : null; + const ordered = providerId ? candidates.filter((manifest2) => manifest2.id === providerId) : [...candidates].sort((left, right) => Number(right.id === "javdb") - Number(left.id === "javdb")); + for (const manifest2 of ordered) { + const adapter = this.integrations?.getAdapter(manifest2.id); + if (typeof adapter?.resolveMovie !== "function") continue; + const resolved = await adapter.resolveMovie({ ...movieRef, carNum }, options); + if (resolved) return resolved; + } + return Object.freeze({ ...movieRef, carNum }); + } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async detail(movieRef, options = {}) { + const candidates = this.integrations?.list("movie.detail") ?? []; + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : movieRef.movieId ? "javdb" : null; + const ordered = providerId ? candidates.filter((manifest2) => manifest2.id === providerId) : candidates; + for (const manifest2 of ordered) { + const adapter = this.integrations?.getAdapter(manifest2.id); + if (typeof adapter?.getDetail === "function") return adapter.getDetail(movieRef, options); + } + return null; + } + /** @param {{period?: string, filter?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async rankings(options = {}) { + for (const manifest2 of this.integrations?.list("movie.ranking") ?? []) { + const adapter = this.integrations?.getAdapter(manifest2.id); + if (typeof adapter?.listRankings === "function") return adapter.listRankings(options); + } + return []; + } + /** @param {string} providerId @param {Record} query @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async catalog(providerId, query = {}, options = {}) { + const manifest2 = (this.integrations?.list("movie.catalog") ?? []).find((item) => item.id === providerId); + if (!manifest2) throw new TypeError(`Movie catalog provider is unavailable: ${providerId}`); + const adapter = this.integrations?.getAdapter(manifest2.id); + if (typeof adapter?.listCatalog !== "function") throw new TypeError(`Movie catalog operation is unavailable: ${providerId}`); + return adapter.listCatalog(query, options); + } + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async people(providerId, movieRef, options = {}) { + const manifest2 = (this.integrations?.list("movie.credits") ?? []).find((item) => item.id === providerId); + if (!manifest2) return Object.freeze({ actors: Object.freeze([]), seller: null }); + const adapter = this.integrations?.getAdapter(manifest2.id); + return typeof adapter?.getPeople === "function" ? adapter.getPeople(movieRef, options) : Object.freeze({ actors: Object.freeze([]), seller: null }); + } + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async images(providerId, movieRef, options = {}) { + const manifest2 = (this.integrations?.list("movie.images") ?? []).find((item) => item.id === providerId); + if (!manifest2) return []; + const adapter = this.integrations?.getAdapter(manifest2.id); + return typeof adapter?.getImages === "function" ? adapter.getImages(movieRef, options) : []; + } + /** @param {Record} movieRef @param {string[]} providerIds */ + sourceUrls(movieRef, providerIds) { + return Object.freeze(providerIds.map((providerId) => { + try { + const adapter = this.integrations?.getAdapter(providerId); + const url = typeof adapter?.detailUrl === "function" ? adapter.detailUrl(movieRef) : null; + return url ? Object.freeze({ providerId, url }) : null; + } catch { + return null; + } + }).filter(Boolean)); + } + /** @param {string} providerId @param {Record} movieRef */ + searchUrl(providerId, movieRef) { + try { + const adapter = this.integrations?.getAdapter(providerId); + return typeof adapter?.searchUrl === "function" ? adapter.searchUrl(movieRef) : null; + } catch { + return null; + } + } + /** @param {string} providerId @param {string} url */ + matchesProviderUrl(providerId, url) { + try { + const adapter = this.integrations?.getAdapter(providerId); + return typeof adapter?.matchesUrl === "function" && adapter.matchesUrl(url); + } catch { + return false; + } + } }; __name(_MovieIdentityService, "MovieIdentityService"); var MovieIdentityService = _MovieIdentityService; @@ -16134,10 +16276,10 @@ ${error.stack}` : ""); constructor(integrations) { this.integrations = integrations; } - /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ async list(movieRef, options = {}) { - for (const manifest of this.integrations.list("movie.related")) { - const adapter = this.integrations.getAdapter(manifest.id); + for (const manifest2 of this.integrations.list("movie.related")) { + const adapter = this.integrations.getAdapter(manifest2.id); if (typeof adapter.listRelated === "function") return adapter.listRelated(movieRef, options); } return []; @@ -16152,10 +16294,10 @@ ${error.stack}` : ""); constructor(integrations) { this.integrations = integrations; } - /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ async list(movieRef, options = {}) { - for (const manifest of this.integrations.list("movie.reviews")) { - const adapter = this.integrations.getAdapter(manifest.id); + for (const manifest2 of this.integrations.list("movie.reviews")) { + const adapter = this.integrations.getAdapter(manifest2.id); if (typeof adapter.listReviews === "function") return adapter.listReviews(movieRef, options); } return []; @@ -16166,9 +16308,10 @@ ${error.stack}` : ""); // src/services/screenshot-service.js var _ScreenshotService = class _ScreenshotService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ - constructor(providers) { + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(providers, integrations = null) { this.providers = providers; + this.integrations = integrations; } /** @param {Record} movieRef @param {Record} [context] */ async resolve(movieRef, context = {}) { @@ -16181,12 +16324,36 @@ ${error.stack}` : ""); this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } } + for (const manifest2 of this.integrations?.list("movie.images") ?? []) { + const adapter = this.integrations?.getAdapter(manifest2.id); + if (typeof adapter?.getImages !== "function") continue; + const result = await adapter.getImages(movieRef, context); + if (Array.isArray(result) && result.length) return result; + } return null; } }; __name(_ScreenshotService, "ScreenshotService"); var ScreenshotService = _ScreenshotService; + // src/services/translation-service.js + var _TranslationService = class _TranslationService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { + this.integrations = integrations; + } + /** @param {string} text @param {{sourceLanguage?: string, targetLanguage?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async translate(text, options = {}) { + const manifest2 = this.integrations.list("text.translate")[0]; + if (!manifest2) throw new TypeError("Translation provider is unavailable"); + const adapter = this.integrations.getAdapter(manifest2.id); + if (typeof adapter?.translate !== "function") throw new TypeError(`Translation operation is unavailable: ${manifest2.id}`); + return adapter.translate(text, options); + } + }; + __name(_TranslationService, "TranslationService"); + var TranslationService = _TranslationService; + // src/services/settings-service.js var _SettingsService = class _SettingsService extends EventTarget { /** @param {{get: (key: string) => Promise, set: (key: string, value: unknown) => Promise}} storage @param {{validators?: Record boolean>}} [options] */ @@ -16195,6 +16362,7 @@ ${error.stack}` : ""); this.storage = storage; this.validators = options.validators ?? {}; this.snapshotValue = Object.freeze({}); + this.writeChain = Promise.resolve(); } /** @param {string} key */ async load(key = "setting") { @@ -16209,11 +16377,15 @@ ${error.stack}` : ""); async set(name, value, storageKey = "setting") { const validator = this.validators[name]; if (validator && !validator(value)) throw new TypeError(`Invalid setting: ${name}`); - const next = Object.freeze({ ...this.snapshotValue, [name]: value }); - await this.storage.set(storageKey, next); - this.snapshotValue = next; - this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name, value, snapshot: next }) })); - return next; + const operation = this.writeChain.then(async () => { + const next = Object.freeze({ ...this.snapshotValue, [name]: value }); + await this.storage.set(storageKey, next); + this.snapshotValue = next; + this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name, value, snapshot: next }) })); + return next; + }); + this.writeChain = operation.then(() => void 0, () => void 0); + return operation; } }; __name(_SettingsService, "SettingsService"); @@ -16241,6 +16413,88 @@ ${error.stack}` : ""); __name(_StorageService, "StorageService"); var StorageService = _StorageService; + // src/services/webdav-service.js + var _WebDavClient = class _WebDavClient { + /** @param {import("./http-service.js").HttpService} http @param {string} davUrl @param {string} username @param {string} password */ + constructor(http, davUrl, username, password) { + this.http = http; + this.baseUrl = new URL(davUrl.endsWith("/") ? davUrl : `${davUrl}/`); + this.username = username; + this.password = password; + this.folderName = null; + } + authHeaders() { + return { Authorization: `Basic ${btoa(`${this.username}:${this.password}`)}`, Depth: "1" }; + } + /** @param {string} method @param {string} path @param {Record} [headers] @param {unknown} [body] */ + async request(method, path, headers = {}, body) { + const url = new URL(path.replace(/^\/+/, ""), this.baseUrl); + const response = await this.http.request({ + providerId: "webdav", + method, + url: url.href, + headers: { ...this.authHeaders(), ...headers }, + body, + responseType: "text", + cacheScope: "none", + urlPolicy: { trustClass: "user-local", expectedOrigin: this.baseUrl.origin } + }); + return response.data ?? response.responseText ?? ""; + } + /** @param {string} folder */ + async ensureFolder(folder) { + try { + await this.request("MKCOL", folder); + } catch (error) { + if (!(error instanceof Error) || !/HTTP (405|409)/.test(error.message)) throw error; + } + } + /** @param {string} folder @param {string} name @param {string} content */ + async backup(folder, name, content) { + await this.ensureFolder(folder); + await this.request("PUT", `${folder}/${name}`, { "Content-Type": "text/plain" }, content); + } + /** @param {string} folder */ + async getFileList(folder) { + const xml = String(await this.request("PROPFIND", folder, { "Content-Type": "application/xml" }, '')); + const responses = new DOMParser().parseFromString(xml, "text/xml").getElementsByTagNameNS("DAV:", "response"), files = []; + for (let index = 1; index < responses.length; index++) { + const item = responses[index], node = /* @__PURE__ */ __name((name2) => item.getElementsByTagNameNS("DAV:", name2)[0]?.textContent || "", "node"); + const href = node("href"), name = node("displayname") || decodeURIComponent(href.replace(/\/$/, "").split("/").pop() || ""), size = node("getcontentlength"); + if (size !== "0") files.push({ fileId: name, name, size: Number(size), createTime: node("creationdate") || node("getlastmodified") }); + } + return files.reverse(); + } + /** @param {string} name */ + async deleteFile(name) { + await this.request("DELETE", `${this.folderName}/${encodeURI(name)}`, { "Cache-Control": "no-cache" }); + } + /** @param {string} folder */ + async getBackupList(folder) { + this.folderName = folder; + await this.ensureFolder(folder); + return this.getFileList(folder); + } + /** @param {string} name */ + async getFileContent(name) { + return String(await this.request("GET", `${this.folderName}/${name}`, { Accept: "application/octet-stream" })); + } + }; + __name(_WebDavClient, "WebDavClient"); + var WebDavClient = _WebDavClient; + var _WebDavService = class _WebDavService { + /** @param {import("./http-service.js").HttpService} http */ + constructor(http) { + this.http = http; + } + /** @param {{url: string, username: string, password: string}} config */ + createClient(config) { + return new WebDavClient(this.http, config.url, config.username, config.password); + } + }; + __name(_WebDavService, "WebDavService"); + var WebDavService = _WebDavService; + // src/services/style-registry.js var _StyleRegistry = class _StyleRegistry { /** @param {any} stylePort */ @@ -16351,56 +16605,74 @@ ${error.stack}` : ""); this.commands.setActivator((featureId) => this.activate(featureId).then(() => void 0)); } /** @param {Record} manifest */ - register(manifest) { + register(manifest2) { const validated = ( /** @type {Record} */ - defineFeature(manifest) + defineFeature(manifest2) ); if (this.manifests.has(validated.id)) throw new Error(`Duplicate feature: ${validated.id}`); this.manifests.set(validated.id, validated); for (const command of validated.providesCommands) this.commands.registerOwner(command, validated.id); } /** @param {Record} manifest */ - isEligible(manifest) { - if (manifest.kind !== "system" && this.disabled.has(manifest.id)) return false; - if (manifest.sites.length && !manifest.sites.includes(this.site)) return false; - if (manifest.routes.length && !manifest.routes.includes(this.route)) return false; + isEligible(manifest2) { + if (manifest2.kind !== "system" && this.disabled.has(manifest2.id)) return false; + if (manifest2.sites.length && !manifest2.sites.includes(this.site)) return false; + if (manifest2.routes.length && !manifest2.routes.includes(this.route)) return false; return true; } + /** @param {string} featureId @param {string} contributionId @param {string} legacyPluginId */ + isContributionEnabled(featureId, contributionId, legacyPluginId) { + const manifest2 = this.manifests.get(featureId); + if (!manifest2) return false; + if (manifest2.sites.length && !manifest2.sites.includes(this.site)) return false; + if (!manifest2.contributes.includes(contributionId)) return false; + if (manifest2.kind !== "system" && this.disabled.has(manifest2.id)) return false; + if (manifest2.kind === "system") return true; + return !this.disabled.has(contributionId) && !this.disabled.has(legacyPluginId); + } + /** @param {symbol[]} tokens */ + resolveDeclaredDependencies(tokens) { + return this.container.resolveDeclared(tokens); + } + /** @param {string} featureId */ + async getScope(featureId) { + return (await this.activate(featureId)).scope; + } /** @param {string} id */ activate(id) { const existing = this.activations.get(id); if (existing) return existing; - const manifest = this.manifests.get(id); - if (!manifest) return Promise.reject(new Error(`Unknown feature: ${id}`)); - if (!this.isEligible(manifest)) return Promise.reject(new Error(`Feature is disabled or ineligible: ${id}`)); - const activation = this.activateManifest(manifest); + const manifest2 = this.manifests.get(id); + if (!manifest2) return Promise.reject(new Error(`Unknown feature: ${id}`)); + if (!this.isEligible(manifest2)) return Promise.reject(new Error(`Feature is disabled or ineligible: ${id}`)); + const activation = this.activateManifest(manifest2); this.activations.set(id, activation); activation.catch(() => this.activations.delete(id)); return activation; } /** @param {Record} manifest */ - async activateManifest(manifest) { + async activateManifest(manifest2) { const started = performance.now(); - const scope = new LifecycleScope(`feature:${manifest.id}`, { onChange: /* @__PURE__ */ __name((snapshot) => this.diagnostics.updateScope(snapshot), "onChange") }); + const scope = new LifecycleScope(`feature:${manifest2.id}`, { onChange: /* @__PURE__ */ __name((snapshot) => this.diagnostics.updateScope(snapshot), "onChange") }); try { - const dependencies = this.container.resolveDeclared(manifest.requires); - const enabledContributions = Object.freeze(manifest.contributes.filter((id) => !this.disabled.has(id))); - const result = await manifest.activate(dependencies, Object.freeze({ scope, enabledContributions })); - for (const command of manifest.providesCommands) { + const dependencies = this.container.resolveDeclared(manifest2.requires); + const enabledContributions = Object.freeze(manifest2.contributes.filter((id) => !this.disabled.has(id))); + const result = await manifest2.activate(dependencies, Object.freeze({ scope, enabledContributions })); + for (const command of manifest2.providesCommands) { const handler = result?.commands?.[command]; - if (typeof handler !== "function") throw new Error(`Feature ${manifest.id} did not provide command ${command}`); - this.commands.registerHandler(command, handler, manifest.id); + if (typeof handler !== "function") throw new Error(`Feature ${manifest2.id} did not provide command ${command}`); + this.commands.registerHandler(command, handler, manifest2.id); } - this.diagnostics.setFeature(manifest.id, true); + this.diagnostics.setFeature(manifest2.id, true); enabledContributions.forEach((id) => this.diagnostics.setContribution(id, true)); - this.diagnostics.recordStartup(manifest.id, performance.now() - started); - return Object.freeze({ manifest, scope, enabledContributions, dispose: /* @__PURE__ */ __name(() => { + this.diagnostics.recordStartup(manifest2.id, performance.now() - started); + return Object.freeze({ manifest: manifest2, scope, enabledContributions, dispose: /* @__PURE__ */ __name(() => { result?.dispose?.(); scope.dispose(); - this.diagnostics.setFeature(manifest.id, false); + this.diagnostics.setFeature(manifest2.id, false); enabledContributions.forEach((id) => this.diagnostics.setContribution(id, false)); - this.activations.delete(manifest.id); + this.activations.delete(manifest2.id); }, "dispose") }); } catch (error) { scope.dispose(); @@ -16409,12 +16681,12 @@ ${error.stack}` : ""); } } async start() { - for (const manifest of this.manifests.values()) { - if (!this.isEligible(manifest)) continue; - if (manifest.startup === "eager") await this.activate(manifest.id); - else if (manifest.startup === "idle") { + for (const manifest2 of this.manifests.values()) { + if (!this.isEligible(manifest2)) continue; + if (manifest2.startup === "eager") await this.activate(manifest2.id); + else if (manifest2.startup === "idle") { const schedule = globalThis.requestIdleCallback ?? ((callback) => setTimeout(callback, 0)); - schedule(() => void this.activate(manifest.id).catch(() => void 0)); + schedule(() => void this.activate(manifest2.id).catch(() => void 0)); } } } @@ -16482,11 +16754,11 @@ ${error.stack}` : ""); this.adapters = /* @__PURE__ */ new Map(); } /** @param {Record} manifest */ - register(manifest) { + register(manifest2) { try { const validated = ( /** @type {Record} */ - defineIntegration(manifest) + defineIntegration(manifest2) ); if (this.manifests.has(validated.id)) throw new Error(`Duplicate integration: ${validated.id}`); this.manifests.set(validated.id, validated); @@ -16497,18 +16769,18 @@ ${error.stack}` : ""); } /** @param {string} capability */ list(capability) { - return [...this.manifests.values()].filter((manifest) => manifest.capabilities.includes(capability)); + return [...this.manifests.values()].filter((manifest2) => manifest2.capabilities.includes(capability)); } /** @param {string} id */ getAdapter(id) { const cached = this.adapters.get(id); if (cached) return cached; - const manifest = this.manifests.get(id); - if (!manifest) throw new Error(`Unknown integration: ${id}`); + const manifest2 = this.manifests.get(id); + if (!manifest2) throw new Error(`Unknown integration: ${id}`); try { - const dependencies = this.container.resolveDeclared(manifest.requires); - const client = manifest.createClient(dependencies); - const adapter = manifest.createAdapter(client, dependencies); + const dependencies = this.container.resolveDeclared(manifest2.requires); + const client = manifest2.createClient(dependencies); + const adapter = manifest2.createAdapter(client, dependencies); this.adapters.set(id, adapter); return adapter; } catch (error) { @@ -16560,6 +16832,7 @@ ${error.stack}` : ""); const dialog = new DialogService(dialogPort); const styles = new StyleRegistry(stylePort); const http = new HttpService(httpPort, urlPolicy, { diagnostics, cache }); + const webdav = new WebDavService(http); const settings = new SettingsService(storage); const profile = new ProfileService({ scope: rootScope }); profile.start(); @@ -16567,42 +16840,463 @@ ${error.stack}` : ""); const providers = new ProviderRegistry(diagnostics); const settingsRegistry = new SettingsRegistry(); const integrations = new IntegrationRegistry(container, diagnostics); - const movie = new MovieIdentityService(); + const movie = new MovieIdentityService(integrations); + const actressInfo = new ActressInfoService(integrations, cache); const review = new ReviewService(integrations); const related = new RelatedService(integrations); - const magnet = new MagnetService(providers); - const screenshot = new ScreenshotService(providers); + const magnet = new MagnetService(providers, integrations); + const screenshot = new ScreenshotService(providers, integrations); + const translation = new TranslationService(integrations); const offline = new OfflineService(providers); - container.register(PORT.navigation, navigationPort).register(PORT.http, httpPort).register(PORT.storage, storagePort).register(PORT.dialog, dialogPort).register(PORT.style, stylePort).register(SERVICE.diagnostics, diagnostics).register(SERVICE.urlPolicy, urlPolicy).register(SERVICE.navigation, navigation).register(SERVICE.http, http).register(SERVICE.storage, storage).register(SERVICE.dialog, dialog).register(SERVICE.settings, settings).register(SERVICE.cache, cache).register(SERVICE.profile, profile).register(SERVICE.movie, movie).register(SERVICE.review, review).register(SERVICE.related, related).register(SERVICE.magnet, magnet).register(SERVICE.screenshot, screenshot).register(SERVICE.offline, offline).register(REGISTRY.command, commands).register(REGISTRY.provider, providers).register(REGISTRY.integration, integrations).register(REGISTRY.settings, settingsRegistry); + container.register(PORT.navigation, navigationPort).register(PORT.http, httpPort).register(PORT.storage, storagePort).register(PORT.dialog, dialogPort).register(PORT.style, stylePort).register(SERVICE.diagnostics, diagnostics).register(SERVICE.urlPolicy, urlPolicy).register(SERVICE.navigation, navigation).register(SERVICE.http, http).register(SERVICE.storage, storage).register(SERVICE.webdav, webdav).register(SERVICE.dialog, dialog).register(SERVICE.settings, settings).register(SERVICE.cache, cache).register(SERVICE.profile, profile).register(SERVICE.movie, movie).register(SERVICE.actressInfo, actressInfo).register(SERVICE.review, review).register(SERVICE.related, related).register(SERVICE.magnet, magnet).register(SERVICE.screenshot, screenshot).register(SERVICE.offline, offline).register(SERVICE.translation, translation).register(REGISTRY.command, commands).register(REGISTRY.provider, providers).register(REGISTRY.integration, integrations).register(REGISTRY.settings, settingsRegistry); if (runtime.hostAdapter) container.register(PORT.host, runtime.hostAdapter); const features = new FeatureRuntime({ container, commands, diagnostics, disabled: runtime.disabled, site: runtime.site, route: runtime.route }); container.register(REGISTRY.feature, features); - return Object.freeze({ rootScope, container, ports: Object.freeze({ navigationPort, httpPort, storagePort, dialogPort, stylePort }), services: Object.freeze({ diagnostics, urlPolicy, navigation, http, storage, dialog, styles, settings, cache, profile, movie, review, related, magnet, screenshot, offline }), registries: Object.freeze({ commands, providers, integrations, settings: settingsRegistry, features }) }); + return Object.freeze({ rootScope, container, ports: Object.freeze({ navigationPort, httpPort, storagePort, dialogPort, stylePort }), services: Object.freeze({ diagnostics, urlPolicy, navigation, http, storage, webdav, dialog, styles, settings, cache, profile, movie, actressInfo, review, related, magnet, screenshot, translation, offline }), registries: Object.freeze({ commands, providers, integrations, settings: settingsRegistry, features }) }); } __name(createAppContext, "createAppContext"); + // src/contracts/models.js + var ACTRESS_TYPE = Object.freeze({ censored: "censored", uncensored: "uncensored" }); + function defineMovieRef(value) { + if (typeof value.carNum !== "string" || !value.carNum) throw new TypeError("MovieRef.carNum is required"); + return Object.freeze({ ...value }); + } + __name(defineMovieRef, "defineMovieRef"); + function defineMovieDetail(value) { + if (typeof value.carNum !== "string" || !value.carNum) throw new TypeError("MovieDetail.carNum is required"); + return Object.freeze({ ...value }); + } + __name(defineMovieDetail, "defineMovieDetail"); + + // src/integrations/av123/parser.js + function resolveDocument2(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelectorAll === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelectorAll === "function") return candidate; + throw new TypeError("123AV document is invalid"); + } + __name(resolveDocument2, "resolveDocument"); + function parse123AvCards($page, baseUrl = "https://123av.com") { + const items = /* @__PURE__ */ new Map(); + if (typeof $page?.find !== "function") { + for (const card of resolveDocument2($page).querySelectorAll(".card")) { + const link = card.querySelector('a.card__link[href*="/cn/v/fc2-ppv-"]'); + const href = link?.getAttribute("href"), text = link?.textContent?.trim() ?? ""; + const numberMatch = text.match(/FC2-PPV-(\d+)/i); + if (!href || !numberMatch) continue; + const carNum = `FC2-${numberMatch[1]}`, detailUrl = new URL(href, baseUrl); + detailUrl.hash = ""; + items.set(carNum, { + imgSrc: card.querySelector("img.card__img[src]")?.getAttribute("src") || "", + carNum, + href: detailUrl.href, + title: text.replace(/^FC2-PPV-\d+\s*[—–-]\s*/i, "").trim(), + preview: card.querySelector(".card__poster[data-preview]")?.getAttribute("data-preview") || null + }); + } + return [...items.values()]; + } + const wrap = ( + /** @type {any} */ + globalThis.jQuery ?? /** @type {any} */ + globalThis.$ ?? $page.constructor + ); + $page.find(".card").each(((index, element) => { + const $card = wrap(element); + const $link = $card.find('a.card__link[href*="/cn/v/fc2-ppv-"]').first(); + const href = $link.attr("href"), text = $link.text().trim(); + const numberMatch = text.match(/FC2-PPV-(\d+)/i); + if (!href || !numberMatch) return; + const carNum = `FC2-${numberMatch[1]}`; + const detailUrl = new URL(href, baseUrl); + detailUrl.hash = ""; + items.set(carNum, { + imgSrc: $card.find("img.card__img[src]").first().attr("src") || "", + carNum, + href: detailUrl.href, + title: text.replace(/^FC2-PPV-\d+\s*[—–-]\s*/i, "").trim(), + preview: $card.find(".card__poster[data-preview]").first().attr("data-preview") || null + }); + })); + return [...items.values()]; + } + __name(parse123AvCards, "parse123AvCards"); + function merge123AvCards(cardLists) { + const items = /* @__PURE__ */ new Map(); + cardLists.flat().forEach( + /** @type {Record} */ + ((item) => items.set(item.carNum, item)) + ); + return [...items.values()]; + } + __name(merge123AvCards, "merge123AvCards"); + function parse123AvSourceMaxPage($page, baseUrl = "https://123av.com") { + if (typeof $page?.find !== "function") { + const document2 = resolveDocument2($page), lastHref2 = document2.querySelector('a[rel="last"]')?.getAttribute("href"); + if (lastHref2) { + const page = Number.parseInt(new URL(lastHref2, baseUrl).searchParams.get("page") ?? "", 10); + if (Number.isFinite(page)) return page; + } + const inputMax2 = Number.parseInt(document2.querySelector("input.pager__input[max]")?.getAttribute("max") ?? "", 10); + if (Number.isFinite(inputMax2)) return inputMax2; + const totalMatch2 = document2.querySelector(".pager__total")?.textContent?.match(/(\d[\d,]*)/); + return totalMatch2 ? Number.parseInt(totalMatch2[1].replaceAll(",", ""), 10) : null; + } + const lastHref = $page.find('a[rel="last"]').first().attr("href"); + if (lastHref) { + const page = Number.parseInt(new URL(lastHref, baseUrl).searchParams.get("page") ?? "", 10); + if (Number.isFinite(page)) return page; + } + const inputMax = Number.parseInt($page.find("input.pager__input[max]").first().attr("max"), 10); + if (Number.isFinite(inputMax)) return inputMax; + const totalMatch = $page.find(".pager__total").first().text().match(/(\d[\d,]*)/); + return totalMatch ? Number.parseInt(totalMatch[1].replaceAll(",", ""), 10) : null; + } + __name(parse123AvSourceMaxPage, "parse123AvSourceMaxPage"); + function parse123AvVideoInfo($page, url) { + const idMatch = new URL(url).pathname.match(/fc2-ppv-(\d+)/i); + const id = idMatch ? idMatch[1] : null; + const nativeDocument = typeof $page?.find === "function" ? null : resolveDocument2($page); + const rawTitle = nativeDocument ? nativeDocument.querySelector("h1.watch__title")?.textContent?.trim() ?? "" : $page.find("h1.watch__title").first().text().trim(); + const title = id ? rawTitle.replace(new RegExp(`^FC2-PPV-${id}\\s*[—–-]\\s*`, "i"), "").trim() : rawTitle; + const pageText = nativeDocument ? nativeDocument.body?.textContent ?? "" : $page.find("body").text(); + const publishDate = pageText.match(/发布日期\s*(\d{4}-\d{2}-\d{2})/)?.[1] || ""; + return { id, publishDate, title, moviePoster: null }; + } + __name(parse123AvVideoInfo, "parse123AvVideoInfo"); + // src/integrations/av123/manifest.js + var REQUEST_OPTIONS = Object.freeze({ cookiePartition: { topLevelSite: "https://123av.com" } }); + function create123AvAdapter(http) { + const request = /* @__PURE__ */ __name((url, scope) => http.request({ + providerId: "av123", + method: "GET", + url, + responseType: "text", + cacheScope: "public", + ttlMs: 6048e5, + requestOptions: REQUEST_OPTIONS, + urlPolicy: { trustClass: "builtin-public", hosts: ["123av.com"] } + }, scope), "request"); + return Object.freeze({ + contracts: ["MovieRef", "MovieDetail"], + /** @param {{carNum?: unknown}} movieRef */ + searchUrl(movieRef) { + const carNum = normalizeMovieCarNum(movieRef?.carNum); + return carNum ? `https://123av.com/cn/search?keyword=${encodeURIComponent(carNum)}` : null; + }, + /** @param {{carNum: string, url?: string}} movieRef */ + detailUrl(movieRef) { + const id = normalizeMovieCarNum(movieRef.carNum)?.match(/^FC2-(\d+)$/)?.[1]; + return movieRef.url ? new URL(movieRef.url).href : id ? `https://123av.com/cn/v/fc2-ppv-${id}` : null; + }, + /** @param {string} value */ + matchesUrl(value) { + try { + return new URL(value).hostname === "123av.com"; + } catch { + return false; + } + }, + /** @param {{page?: number, keyword?: string}} query @param {{scope?: any}} [options] */ + async listCatalog(query = {}, options = {}) { + const page = Math.max(1, Number(query.page) || 1), keyword = String(query.keyword || "").trim(); + const sourcePages = keyword ? [1] : [page * 2 - 1, page * 2]; + const urls = sourcePages.map((sourcePage) => keyword ? `https://123av.com/cn/search?keyword=${encodeURIComponent(keyword)}` : `https://123av.com/cn/makers/fc2?page=${sourcePage}`); + const responses = await Promise.all(urls.map((url) => request(url, options.scope))); + const lists = responses.map((response, index) => parse123AvCards(response.data, response.finalUrl || urls[index])); + const items = merge123AvCards(lists).map((item) => Object.freeze({ + carNum: item.carNum, + title: item.title, + url: item.href, + imageUrl: item.imgSrc ? new URL(item.imgSrc, "https://123av.com").href : null, + previewUrl: item.preview ? new URL(item.preview, "https://123av.com").href : null, + providerId: "av123" + })); + const sourceMaxPage = keyword || !responses.length ? null : parse123AvSourceMaxPage(responses[0].data, responses[0].finalUrl || urls[0]); + return Object.freeze({ items, maxPage: sourceMaxPage ? Math.ceil(sourceMaxPage / 2) : null }); + }, + /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ + async resolveMovie(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum); + if (!carNum) return null; + const url = this.searchUrl({ carNum }); + if (!url) return null; + const response = await request(url, options.scope); + const match = parse123AvCards(response.data, response.finalUrl || url).find((item) => normalizeMovieCarNum(item.carNum) === carNum); + return match ? defineMovieRef({ carNum, url: match.href, providerId: "av123" }) : null; + }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum), id = carNum?.match(/^FC2-(\d+)$/)?.[1]; + if (!carNum || !movieRef.url && !id) throw new TypeError("123AV movie reference is invalid"); + const url = new URL(movieRef.url || `https://123av.com/cn/v/fc2-ppv-${id}`).href; + const response = await request(url, options.scope), info = parse123AvVideoInfo(response.data, response.finalUrl || url); + if (!info.title) throw new TypeError("123AV detail is malformed"); + return defineMovieDetail({ carNum, title: info.title, releaseDate: info.publishDate || null, url: response.finalUrl || url, providerId: "av123" }); + } + }); + } + __name(create123AvAdapter, "create123AvAdapter"); var manifest_default2 = defineIntegration({ id: "av123", trustClass: "builtin-public", hosts: ["123av.com"], - capabilities: ["movie.search", "movie.detail", "movie.images", "actor.lookup"], - requires: [PORT.http, SERVICE.urlPolicy], - createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "av123" }), "createClient"), - createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieRef", "MovieDetail"] }), "createAdapter"), + capabilities: ["movie.search", "movie.detail", "movie.catalog"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => create123AvAdapter(client.http), "createAdapter"), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail }, + cachePolicy: { "movie.search": "public-7d", "movie.detail": CACHE.externalDetail, "movie.catalog": "public-7d" }, quality: "silver" }); + // src/integrations/dmm/parser.js + function resolveDocument3(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelector === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelector === "function") return candidate; + throw new TypeError("DMM preview document is invalid"); + } + __name(resolveDocument3, "resolveDocument"); + function parseDmmPreview(page, baseUrl) { + const document2 = resolveDocument3(page); + const value = document2.querySelector("[data-video-url]")?.getAttribute("data-video-url") ?? document2.querySelector("video source[src]")?.getAttribute("src"); + if (!value) throw new TypeError("DMM preview URL is missing"); + const url = new URL(value, baseUrl); + if (url.protocol !== "https:") throw new TypeError("DMM preview must use HTTPS"); + return Object.freeze({ url: url.href }); + } + __name(parseDmmPreview, "parseDmmPreview"); + // src/integrations/dmm/manifest.js - var manifest_default3 = defineIntegration({ id: "dmm", trustClass: "builtin-public", hosts: ["dmm.co.jp"], capabilities: ["movie.preview"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "dmm" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MoviePreview"] }), "createAdapter"), createHostAdapter: null, cachePolicy: { "movie.preview": CACHE.externalDetail }, quality: "silver" }); + function createDmmAdapter(http) { + return Object.freeze({ + contracts: ["MoviePreview"], + /** @param {{url: string}} movieRef @param {{scope?: any}} [options] */ + async getPreview(movieRef, options = {}) { + const url = new URL(movieRef.url); + const response = await http.request({ + providerId: "dmm", + method: "GET", + url: url.href, + responseType: "text", + cacheScope: "public", + ttlMs: 6048e5, + urlPolicy: { trustClass: "builtin-public", hosts: ["dmm.co.jp"] } + }, options.scope); + return parseDmmPreview(response.data, response.finalUrl || url.href); + } + }); + } + __name(createDmmAdapter, "createDmmAdapter"); + var manifest_default3 = defineIntegration({ + id: "dmm", + trustClass: "builtin-public", + hosts: ["dmm.co.jp"], + capabilities: ["movie.preview"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createDmmAdapter(client.http), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "movie.preview": CACHE.externalDetail }, + quality: "silver" + }); + + // src/integrations/fc2ppvdb/parser.js + function resolveDocument4(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelector === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelector === "function") return candidate; + throw new TypeError("FC2PPVDB detail document is invalid"); + } + __name(resolveDocument4, "resolveDocument"); + function parseFc2PpvDbDetail(page, url) { + const id = new URL(url).pathname.match(/(?:articles?|videos?)\/(\d+)/i)?.[1]; + const carNum = normalizeMovieCarNum(id ? `FC2-${id}` : ""); + const title = resolveDocument4(page).querySelector("h1")?.textContent?.trim() ?? ""; + if (!carNum || !title) throw new TypeError("FC2PPVDB detail is malformed"); + return defineMovieDetail({ carNum, title, url: new URL(url).href }); + } + __name(parseFc2PpvDbDetail, "parseFc2PpvDbDetail"); + function parseFc2PpvDbPeople(page, url) { + const document2 = resolveDocument4(page), baseUrl = new URL(url); + const rows = [...document2.querySelectorAll("div")]; + const actressRow = rows.find((item) => item.textContent?.trim().startsWith("女優:")); + const sellerRow = rows.find((item) => item.textContent?.trim().startsWith("販売者:")); + const actors = [...actressRow?.querySelectorAll("a[href]") ?? []].map((link) => Object.freeze({ + name: link.textContent?.trim() || "", + url: new URL(link.getAttribute("href") || "", baseUrl).href + })).filter((actor) => actor.name); + const sellerLink = sellerRow?.querySelector("a[href]"); + const seller = sellerLink ? Object.freeze({ name: sellerLink.textContent?.trim() || "", url: new URL(sellerLink.getAttribute("href") || "", baseUrl).href }) : null; + return Object.freeze({ actors: Object.freeze(actors), seller }); + } + __name(parseFc2PpvDbPeople, "parseFc2PpvDbPeople"); // src/integrations/fc2ppvdb/manifest.js - var manifest_default4 = defineIntegration({ id: "fc2ppvdb", trustClass: "builtin-public", hosts: ["fc2ppvdb.com"], capabilities: ["movie.detail", "movie.images"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "fc2ppvdb" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieDetail"] }), "createAdapter"), createHostAdapter: null, cachePolicy: { "movie.detail": CACHE.externalDetail }, quality: "silver" }); + function createFc2PpvDbAdapter(http) { + const resolveUrl = /* @__PURE__ */ __name((movieRef) => { + const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; + if (!id) throw new TypeError("FC2PPVDB movie identifier is invalid"); + return new URL(movieRef.url || `https://fc2ppvdb.com/articles/${id}`); + }, "resolveUrl"); + const request = /* @__PURE__ */ __name((url, scope) => http.request({ + providerId: "fc2ppvdb", + method: "GET", + url: url.href, + responseType: "text", + cacheScope: "public", + ttlMs: 6048e5, + urlPolicy: { trustClass: "builtin-public", hosts: ["fc2ppvdb.com"] } + }, scope), "request"); + return Object.freeze({ + contracts: ["MovieDetail", "MovieCredits"], + /** @param {{carNum: string, url?: string}} movieRef */ + detailUrl(movieRef) { + return resolveUrl(movieRef).href; + }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const url = resolveUrl(movieRef), response = await request(url, options.scope); + return parseFc2PpvDbDetail(response.data, response.finalUrl || url.href); + }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getPeople(movieRef, options = {}) { + const url = resolveUrl(movieRef), response = await request(url, options.scope); + return parseFc2PpvDbPeople(response.data, response.finalUrl || url.href); + } + }); + } + __name(createFc2PpvDbAdapter, "createFc2PpvDbAdapter"); + var manifest_default4 = defineIntegration({ + id: "fc2ppvdb", + trustClass: "builtin-public", + hosts: ["fc2ppvdb.com"], + capabilities: ["movie.detail", "movie.credits"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createFc2PpvDbAdapter(client.http), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.credits": CACHE.externalDetail }, + quality: "silver" + }); - // src/integrations/host-list/manifest.js + // src/integrations/fc2content/parser.js + function parseFc2ContentImages(html, baseUrl) { + const document2 = new DOMParser().parseFromString(html, "text/html"), images = /* @__PURE__ */ new Set(); + for (const image of document2.querySelectorAll(".items_article_SampleImagesArea img[src]")) { + const url = new URL(image.getAttribute("src") || "", baseUrl); + if (url.protocol === "https:") images.add(url.href); + } + return Object.freeze([...images].map((url) => Object.freeze({ url, providerId: "fc2content" }))); + } + __name(parseFc2ContentImages, "parseFc2ContentImages"); + + // src/integrations/fc2content/manifest.js + function createFc2ContentAdapter(http) { + return Object.freeze({ + contracts: ["Screenshot"], + /** @param {{carNum: string}} movieRef */ + detailUrl(movieRef) { + const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; + return id ? `https://adult.contents.fc2.com/article/${id}/` : null; + }, + /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ + async getImages(movieRef, options = {}) { + const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; + if (!id) return []; + const url = `https://adult.contents.fc2.com/article/${id}/`; + const response = await http.request({ + providerId: "fc2content", + method: "GET", + url, + responseType: "text", + cacheScope: "public", + ttlMs: 6048e5, + headers: { referer: url }, + urlPolicy: { trustClass: "builtin-public", hosts: ["adult.contents.fc2.com"] } + }, options.scope); + return parseFc2ContentImages(response.data, response.finalUrl || url); + } + }); + } + __name(createFc2ContentAdapter, "createFc2ContentAdapter"); var manifest_default5 = defineIntegration({ + id: "fc2content", + trustClass: "builtin-public", + hosts: ["adult.contents.fc2.com"], + capabilities: ["movie.images"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createFc2ContentAdapter(client.http), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "movie.images": CACHE.externalDetail }, + quality: "silver" + }); + + // src/integrations/google-translate/parser.js + function parseGoogleTranslation(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new TypeError("Translation response is invalid"); + const translation = ( + /** @type {{translation?: unknown}} */ + payload.translation + ); + if (typeof translation !== "string" || !translation.trim()) throw new TypeError("Translation text is missing"); + return translation; + } + __name(parseGoogleTranslation, "parseGoogleTranslation"); + + // src/integrations/google-translate/manifest.js + var ENDPOINT = "https://translate-pa.googleapis.com/v1/translate"; + function createGoogleTranslateAdapter(http) { + return Object.freeze({ + contracts: ["Translation"], + /** @param {string} text @param {{sourceLanguage?: string, targetLanguage?: string, scope?: any}} [options] */ + async translate(text, options = {}) { + if (!String(text || "").trim()) throw new TypeError("Translation text cannot be empty"); + const url = new URL(ENDPOINT); + url.search = new URLSearchParams({ + "params.client": "gtx", + dataTypes: "TRANSLATION", + key: "AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA", + "query.sourceLanguage": options.sourceLanguage ?? "ja", + "query.targetLanguage": options.targetLanguage ?? "zh-CN", + "query.text": text + }).toString(); + const response = await http.request({ + providerId: "google-translate", + method: "GET", + url: url.href, + responseType: "json", + cacheScope: "public", + ttlMs: 2592e6, + urlPolicy: { trustClass: "builtin-public", hosts: ["translate-pa.googleapis.com"] } + }, options.scope); + return parseGoogleTranslation(response.data); + } + }); + } + __name(createGoogleTranslateAdapter, "createGoogleTranslateAdapter"); + var manifest_default6 = defineIntegration({ + id: "google-translate", + trustClass: "builtin-public", + hosts: ["translate-pa.googleapis.com"], + capabilities: ["text.translate"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createGoogleTranslateAdapter(client.http), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "text.translate": CACHE.externalDetail }, + quality: "silver" + }); + + // src/integrations/host-list/manifest.js + var manifest_default7 = defineIntegration({ id: "host-list", trustClass: "builtin-public", hosts: ["javdb.com", "javbus.com"], @@ -16615,56 +17309,362 @@ ${error.stack}` : ""); quality: "bronze" }); + // src/integrations/javdb/signature.js + var signatureSecond2 = 0; + var signatureValue2 = ""; + function createJavDbSignature() { + const now = Math.floor(Date.now() / 1e3); + if (signatureValue2 && now - signatureSecond2 <= 20) return signatureValue2; + const md5Runtime = ( + /** @type {any} */ + globalThis.md5 + ); + if (typeof md5Runtime !== "function") throw new Error("Missing userscript vendor runtime: md5"); + signatureSecond2 = now; + signatureValue2 = `${now}.lpw6vgqzsp.${md5Runtime(`${now}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; + return signatureValue2; + } + __name(createJavDbSignature, "createJavDbSignature"); + // src/integrations/javdb/manifest.js - var manifest_default6 = defineIntegration({ + var API_ORIGIN = "https://jdforrepam.com"; + function createJavDbAdapter(http, sign = createJavDbSignature) { + const request = /* @__PURE__ */ __name(async (path, query, options = {}, headers = {}) => { + const url = new URL(path, API_ORIGIN); + Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, String(value))); + const response = await http.request({ + providerId: "javdb", + method: "GET", + url: url.href, + responseType: "json", + headers: { jdSignature: sign(), ...headers }, + cacheScope: "public", + ttlMs: options.ttlMs ?? 864e5, + urlPolicy: { trustClass: "builtin-public", hosts: ["jdforrepam.com"] } + }, options.scope); + return response.data; + }, "request"); + return Object.freeze({ + contracts: ["MovieRef", "MovieDetail", "Actor", "Magnet", "Review", "RelatedList"], + /** @param {Record} movieRef @param {{scope?: any}} [options] */ + async resolveMovie(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum); + if (!carNum) return null; + const payload = await request("/api/v2/search", { q: carNum, page: 1, type: "movie", limit: 20, movie_type: "all", from_recent: "false", movie_filter_by: "all", movie_sort_by: "relevance" }, { ...options, ttlMs: 6048e5 }, { "user-agent": "Dart/3.5 (dart:io)", "accept-language": "zh-TW", host: "jdforrepam.com" }); + if (!Array.isArray(payload?.data?.movies)) throw new Error(payload?.message || "JavDB search response is invalid"); + const match = payload.data.movies.find((movie) => normalizeMovieCarNum(movie.number) === carNum); + return match?.id ? Object.freeze({ carNum, movieId: String(match.id), providerId: "javdb" }) : null; + }, + /** @param {Record} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) throw new TypeError("JavDB movie id is required"); + const payload = await request(`/api/v4/movies/${encodeURIComponent(movieId)}`, {}, { ...options, ttlMs: 6048e5 }); + const movie = payload?.data?.movie; + if (!movie?.number) throw new Error(payload?.message || "JavDB detail response is invalid"); + const imageUrls = Array.isArray(movie.preview_images) ? movie.preview_images.map((image) => String(image.large_url || "").replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")).filter(Boolean) : []; + return Object.freeze({ + movieId: String(movie.id || movieId), + carNum: normalizeMovieCarNum(movie.number), + title: String(movie.origin_title || movie.title || ""), + actors: Array.isArray(movie.actors) ? movie.actors.map((actor) => Object.freeze({ id: String(actor.id), name: String(actor.name || ""), gender: Number(actor.gender) })) : [], + duration: Number(movie.duration) || null, + score: Number(movie.score) || 0, + releaseDate: movie.release_date || null, + watchedCount: Number(movie.watched_count) || 0, + imageUrls: Object.freeze(imageUrls), + providerId: "javdb" + }); + }, + /** @param {Record} movieRef @param {{scope?: any}} [options] */ + async listMagnets(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) return []; + const payload = await request(`/api/v1/movies/${encodeURIComponent(movieId)}/magnets`, {}, options); + if (!Array.isArray(payload?.data?.magnets)) throw new Error(payload?.message || "JavDB magnet response is invalid"); + return payload.data.magnets.map((item) => Object.freeze({ + hash: String(item.hash || ""), + title: String(item.name || ""), + hasHdTag: Boolean(item.hd), + hasSubtitleTag: Boolean(item.cnsub), + createdAt: item.created_at || null, + seeders: Number(item.seeders) || 0, + sizeMb: Number(item.size) || 0, + fileCount: Number(item.files_count) || 0, + providerId: "javdb" + })); + }, + /** @param {{period?: string, filter?: string, scope?: any}} [options] */ + async listRankings(options = {}) { + const payload = await request("/api/v1/rankings/playback", { period: options.period || "daily", filter_by: options.filter || "high_score" }, options); + if (!Array.isArray(payload?.data?.movies)) throw new Error(payload?.message || "JavDB ranking response is invalid"); + return payload.data.movies.map((movie) => Object.freeze({ + movieId: String(movie.id), + carNum: normalizeMovieCarNum(movie.number), + title: String(movie.origin_title || ""), + coverUrl: String(movie.cover_url || "").replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com"), + releaseDate: movie.release_date || null, + hasSubtitle: Boolean(movie.has_cnsub), + magnetCount: Number(movie.magnets_count) || 0, + newMagnets: Boolean(movie.new_magnets), + providerId: "javdb" + })); + }, + /** @param {Record} movieRef @param {{scope?: any, page?: number, limit?: number}} [options] */ + async listReviews(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) return []; + const payload = await request(`/api/v1/movies/${encodeURIComponent(movieId)}/reviews`, { page: options.page ?? 1, sort_by: "hotly", limit: options.limit ?? 20 }, options); + if (!Array.isArray(payload?.data?.reviews)) throw new Error(payload?.message || "JavDB review response is invalid"); + return payload.data.reviews.map((review) => Object.freeze({ + author: String(review.username || "匿名用户"), + content: String(review.content || ""), + score: Number(review.score) || 0, + createdAt: review.created_at || null, + likes: Number(review.likes_count) || 0 + })); + }, + /** @param {Record} movieRef @param {{scope?: any, page?: number, limit?: number}} [options] */ + async listRelated(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) return []; + const payload = await request("/api/v1/lists/related", { movie_id: movieId, page: options.page ?? 1, limit: options.limit ?? 20 }, options); + if (!Array.isArray(payload?.data?.lists)) throw new Error(payload?.message || "JavDB related response is invalid"); + return payload.data.lists.map((item) => Object.freeze({ + id: String(item.id), + name: String(item.name || "未命名清单"), + movieCount: Number(item.movies_count) || 0, + collectionCount: Number(item.collections_count) || 0, + viewCount: Number(item.views_count) || 0, + createdAt: item.created_at || null + })); + } + }); + } + __name(createJavDbAdapter, "createJavDbAdapter"); + var manifest_default8 = defineIntegration({ id: "javdb", trustClass: "builtin-public", - hosts: ["javdb.com"], - capabilities: ["movie.search", "movie.detail", "movie.state", "actor.lookup"], - requires: [PORT.http, SERVICE.urlPolicy], - createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javdb" }), "createClient"), - createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieRef", "MovieDetail", "Actor"] }), "createAdapter"), + hosts: ["javdb.com", "jdforrepam.com"], + capabilities: ["movie.search", "movie.detail", "movie.magnets", "movie.ranking", "movie.state", "movie.reviews", "movie.related", "actor.lookup"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createJavDbAdapter(client.http), "createAdapter"), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail }, + cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.magnets": "public-1d", "movie.ranking": "public-1d", "movie.reviews": "public-1d", "movie.related": "public-1d" }, quality: "silver" }); + // src/integrations/javbus/parser.js + function resolveDocument5(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelector === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelector === "function") return candidate; + throw new TypeError("JavBus document is invalid"); + } + __name(resolveDocument5, "resolveDocument"); + function readMovieRef(page, url) { + if (typeof page?.find === "function") { + return normalizeMovieCarNum(page.find("span.header:contains('識別碼:')").parent().text().replace("識別碼:", "")) ?? normalizeMovieCarNum(new URL(url).pathname.split("/").filter(Boolean).pop()); + } + const document2 = resolveDocument5(page); + const label = [...document2.querySelectorAll("span.header")].find((element) => element.textContent?.includes("識別碼")); + return normalizeMovieCarNum(label?.parentElement?.textContent?.replace("識別碼:", "")) ?? normalizeMovieCarNum(new URL(url).pathname.split("/").filter(Boolean).pop()); + } + __name(readMovieRef, "readMovieRef"); + function parseJavBusMovieRef(page, url) { + const carNum = readMovieRef(page, url); + if (!carNum) throw new TypeError("JavBus movie identifier is missing"); + return defineMovieRef({ carNum, url: new URL(url).href }); + } + __name(parseJavBusMovieRef, "parseJavBusMovieRef"); + function parseJavBusMovieDetail(page, url) { + const movieRef = parseJavBusMovieRef(page, url); + const nativeDocument = typeof page?.find === "function" ? null : resolveDocument5(page); + const title = nativeDocument ? nativeDocument.querySelector("h3")?.textContent?.trim() || movieRef.carNum : page.find("h3").first().text().trim() || movieRef.carNum; + const coverValue = nativeDocument ? nativeDocument.querySelector(".bigImage img[src], a.bigImage img[src]")?.getAttribute("src") : page.find(".bigImage img[src], a.bigImage img[src]").first().attr("src"); + return defineMovieDetail({ ...movieRef, title, coverUrl: coverValue ? new URL(coverValue, url).href : null, providerId: "javbus" }); + } + __name(parseJavBusMovieDetail, "parseJavBusMovieDetail"); + // src/integrations/javbus/manifest.js - var manifest_default7 = defineIntegration({ + function createJavBusAdapter(http) { + return Object.freeze({ + contracts: ["MovieDetail", "Screenshot"], + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum); + if (!carNum) throw new TypeError("JavBus movie reference is invalid"); + const url = new URL(movieRef.url || `https://www.javbus.com/${encodeURIComponent(carNum)}`).href; + const response = await http.request({ + providerId: "javbus", + method: "GET", + url, + responseType: "text", + cacheScope: "public", + ttlMs: 6048e5, + urlPolicy: { trustClass: "builtin-public", hosts: ["javbus.com"] } + }, options.scope); + return parseJavBusMovieDetail(response.data, response.finalUrl || url); + }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getImages(movieRef, options = {}) { + const detail = await this.getDetail(movieRef, options); + return detail.coverUrl ? [Object.freeze({ url: detail.coverUrl, providerId: "javbus" })] : []; + } + }); + } + __name(createJavBusAdapter, "createJavBusAdapter"); + var manifest_default9 = defineIntegration({ id: "javbus", trustClass: "builtin-public", hosts: ["javbus.com"], - capabilities: ["movie.search", "movie.detail", "movie.images"], - requires: [PORT.http, SERVICE.urlPolicy], - createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javbus" }), "createClient"), - createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MovieRef", "MovieDetail"] }), "createAdapter"), + capabilities: ["movie.detail", "movie.images"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createJavBusAdapter(client.http), "createAdapter"), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail }, + cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.images": CACHE.externalDetail }, quality: "silver" }); // src/integrations/javstore/manifest.js - var manifest_default8 = defineIntegration({ + function createJavStoreAdapter(http) { + const request = /* @__PURE__ */ __name((url, scope) => http.request({ + providerId: "javstore", + method: "GET", + url, + responseType: "text", + cacheScope: "public", + ttlMs: 6048e5, + urlPolicy: { trustClass: "builtin-public", hosts: ["javstore.net"] } + }, scope), "request"); + return Object.freeze({ + contracts: ["Screenshot"], + /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ + async getImages(movieRef, options = {}) { + const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(movieRef.carNum || "")}`; + const search = await request(searchUrl, options.scope); + const candidates = parseJavStoreSearch(search.data, movieRef.carNum, search.finalUrl || searchUrl); + if (!candidates.length) return []; + for (const candidate of candidates) { + try { + const detail = await request(candidate, options.scope); + const imageUrl = parseJavStorePreview(detail.data, detail.finalUrl || candidate); + if (imageUrl) return [Object.freeze({ url: imageUrl, providerId: "javstore" })]; + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "NOT_FOUND")) throw error; + } + } + return []; + } + }); + } + __name(createJavStoreAdapter, "createJavStoreAdapter"); + var manifest_default10 = defineIntegration({ id: "javstore", trustClass: "builtin-public", hosts: ["javstore.net"], capabilities: ["movie.images"], - requires: [PORT.http, SERVICE.urlPolicy], - createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javstore" }), "createClient"), - createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["Screenshot"] }), "createAdapter"), + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createJavStoreAdapter(client.http), "createAdapter"), createHostAdapter: null, cachePolicy: { "movie.images": CACHE.externalDetail }, quality: "silver" }); // src/integrations/javtrailers/manifest.js - var manifest_default9 = defineIntegration({ id: "javtrailers", trustClass: "builtin-public", hosts: ["javtrailers.com"], capabilities: ["movie.preview"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javtrailers" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MoviePreview"] }), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); + var manifest_default11 = defineIntegration({ id: "javtrailers", trustClass: "builtin-public", hosts: ["javtrailers.com"], capabilities: ["movie.preview"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javtrailers" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MoviePreview"] }), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); // src/integrations/subtitlecat/manifest.js - var manifest_default10 = defineIntegration({ id: "subtitlecat", trustClass: "builtin-public", hosts: ["subtitlecat.com"], capabilities: ["subtitle.search"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "subtitlecat" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["Subtitle"] }), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); + function createSubtitleCatAdapter() { + return Object.freeze({ + contracts: ["Subtitle"], + /** @param {{carNum?: unknown}} movieRef */ + detailUrl(movieRef) { + const carNum = String(movieRef?.carNum || "").trim(); + if (!carNum) return null; + const url = new URL("/index.php", "https://subtitlecat.com"); + url.searchParams.set("search", carNum); + return url.href; + } + }); + } + __name(createSubtitleCatAdapter, "createSubtitleCatAdapter"); + var manifest_default12 = defineIntegration({ id: "subtitlecat", trustClass: "builtin-public", hosts: ["subtitlecat.com"], capabilities: ["subtitle.search"], requires: [], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "subtitlecat" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => createSubtitleCatAdapter(), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); + + // src/integrations/wikipedia/parser.js + function compact(value) { + return value.replace(/\s+/g, " ").trim(); + } + __name(compact, "compact"); + function parseWikipediaActressInfo(html, url) { + const document2 = new DOMParser().parseFromString(html, "text/html"); + const birthday = compact(document2.querySelector('a[title="誕生日"]')?.closest("tr")?.querySelector("td")?.textContent ?? ""); + const ageCell = [...document2.querySelectorAll("th")].find((item) => item.textContent?.includes("現年齢"))?.parentElement?.querySelector("td"); + const heightCell = document2.querySelector('a[title="身長"]')?.closest("tr")?.querySelector("td"); + const weightCell = document2.querySelector('a[title="体重"]')?.closest("tr")?.querySelector("td"); + const sizesCell = document2.querySelector('a[title="スリーサイズ"]')?.closest("tr")?.querySelector("td"); + const braCell = [...document2.querySelectorAll("th")].find((item) => item.textContent?.includes("ブラサイズ"))?.nextElementSibling; + if (!birthday && !heightCell && !sizesCell) throw new TypeError("Wikipedia actress information is missing"); + const age = Number.parseInt(compact(ageCell?.textContent ?? ""), 10); + const height = compact(heightCell?.textContent ?? "").split(" ")[0]; + const weightParts = compact(weightCell?.textContent ?? "").split("/"); + const weight = compact(weightParts.at(-1) ?? "").replace(/^―\s*kg$/, ""); + return Object.freeze({ + birthday, + age: Number.isFinite(age) ? `${age}岁` : "", + height: height && !height.endsWith("cm") ? `${height}cm` : height, + weight, + threeSizeText: compact(sizesCell?.textContent ?? "").replace("cm", "").trim(), + braSize: compact(braCell?.textContent ?? ""), + url: new URL(url).href + }); + } + __name(parseWikipediaActressInfo, "parseWikipediaActressInfo"); + + // src/integrations/wikipedia/manifest.js + function createWikipediaAdapter(http) { + return Object.freeze({ + contracts: ["ActressInfo"], + /** @param {string} name */ + profileUrl(name) { + return new URL(`/wiki/${encodeURIComponent(String(name))}`, "https://ja.wikipedia.org").href; + }, + /** @param {string} name @param {{scope?: any}} [options] */ + async lookup(name, options = {}) { + const url = this.profileUrl(name); + const response = await http.request({ + providerId: "wikipedia", + method: "GET", + url, + responseType: "text", + cacheScope: "public", + ttlMs: 6048e5, + urlPolicy: { trustClass: "builtin-public", hosts: ["ja.wikipedia.org"] } + }, options.scope); + return parseWikipediaActressInfo(response.data, response.finalUrl || url); + } + }); + } + __name(createWikipediaAdapter, "createWikipediaAdapter"); + var manifest_default13 = defineIntegration({ + id: "wikipedia", + trustClass: "builtin-public", + hosts: ["ja.wikipedia.org"], + capabilities: ["person.actress-info"], + requires: [SERVICE.http], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createWikipediaAdapter(client.http), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "person.actress-info": CACHE.externalDetail }, + quality: "silver" + }); // src/app/integration-catalog.js - var integrationManifests = Object.freeze([manifest_default2, manifest_default3, manifest_default4, manifest_default5, manifest_default6, manifest_default7, manifest_default8, manifest_default9, manifest_default10]); + var integrationManifests = Object.freeze([manifest_default2, manifest_default3, manifest_default4, manifest_default5, manifest_default6, manifest_default7, manifest_default8, manifest_default9, manifest_default10, manifest_default11, manifest_default12, manifest_default13]); // src/app/bootstrap.js function patchLayerRuntime(layerRuntime) { @@ -16741,6 +17741,7 @@ ${error.stack}` : ""); importVendorStyles(); const disabled = await migrateDisabledPluginSettings(); const localOriginSettings = await prepareLocalOrigins(); + const siteContext2 = detectSite(window.location); const hostAdapter = r ? new JavDbHostAdapter() : l ? new JavBusHostAdapter() : null; const route = hostAdapter?.detectRoute() ?? "other"; const context = createAppContext({ @@ -16748,18 +17749,37 @@ ${error.stack}` : ""); storageForage: storageManager2.forage, layer: vendors.layer, hostAdapter, - site: r ? "javdb" : l ? "javbus" : "other", + site: siteContext2.site, route, disabled, localOrigins: localOriginSettings.origins }); + const settingsSnapshot = await context.services.settings.load(); + const legacySortMethod = localStorage.getItem("jhs_sortMethod"); + if (settingsSnapshot.sortMethod == null && ["default", "rateCount", "date"].includes(legacySortMethod || "")) { + await context.services.settings.set("sortMethod", legacySortMethod); + } + const legacyFoldCategory = localStorage.getItem("jhs_foldCategory"); + if (settingsSnapshot.foldCategoryCollapsed == null && ["yes", "no"].includes(legacyFoldCategory || "")) { + await context.services.settings.set("foldCategoryCollapsed", legacyFoldCategory === "yes"); + } + const legacyVideoMuted = localStorage.getItem("jhs_videoMuted"); + if (settingsSnapshot.videoMuted == null && ["yes", "no"].includes(legacyVideoMuted || "")) { + await context.services.settings.set("videoMuted", legacyVideoMuted === "yes"); + } const logger = initializeLoggerRuntime(context.rootScope); + initializeThemeRuntime(context.rootScope); + context.services.diagnostics.setBrowserMetadata({ + userAgent: navigator.userAgent, + platform: navigator.platform, + ...globalThis.__jhsBrowserTestMetadata ?? {} + }); Object.assign(globalThis, logger); if (localOriginSettings.notice) globalThis.show.info(localOriginSettings.notice); - const pluginManager = new PluginManager(); - registerSitePlugins(pluginManager); - for (const manifest of integrationManifests) context.registries.integrations.register(manifest); - for (const manifest of featureManifests) context.registries.features.register(manifest); + const pluginManager = new PluginManager({ diagnostics: context.services.diagnostics }); + for (const manifest2 of integrationManifests) context.registries.integrations.register(manifest2); + for (const manifest2 of featureManifests) context.registries.features.register(manifest2); + registerSitePlugins(pluginManager, context.registries.features, siteContext2.site); await context.registries.features.start(); attachCompatibilityFacade({ pluginManager, diff --git a/scripts/architecture-check.mjs b/scripts/architecture-check.mjs index 720759cb..be3b6d43 100644 --- a/scripts/architecture-check.mjs +++ b/scripts/architecture-check.mjs @@ -75,6 +75,7 @@ async function checkImportGraph() { const graph = new Map(); const boundaryErrors = []; const allowedLayers = { + core: new Set(["core"]), features: new Set(["services", "ui", "contracts", "core"]), services: new Set(["contracts", "core"]), integrations: new Set(["contracts", "core"]), @@ -95,8 +96,9 @@ async function checkImportGraph() { const sourceRelative = path.relative(path.join(rootDir, "src"), file).split(path.sep); const targetRelative = path.relative(path.join(rootDir, "src"), dependency).split(path.sep); const sameFeature = sourceLayer === "features" && targetLayer === "features" && sourceRelative[1] === targetRelative[1]; + const sameIntegration = sourceLayer === "integrations" && targetLayer === "integrations" && sourceRelative[1] === targetRelative[1]; const featureCatalog = sourceRelative.join("/") === "features/catalog.js" && targetLayer === "features"; - if (allowedLayers[sourceLayer] && !allowedLayers[sourceLayer].has(targetLayer) && !sameFeature && !featureCatalog) { + if (allowedLayers[sourceLayer] && !allowedLayers[sourceLayer].has(targetLayer) && !sameFeature && !sameIntegration && !featureCatalog) { boundaryErrors.push(`${sourceLayer} -> ${targetLayer}: ${path.relative(rootDir, file)} imports ${path.relative(rootDir, dependency)}`); } } diff --git a/scripts/checkjs-coverage.mjs b/scripts/checkjs-coverage.mjs index 283a7554..3ef98295 100644 --- a/scripts/checkjs-coverage.mjs +++ b/scripts/checkjs-coverage.mjs @@ -1,9 +1,11 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import ts from "typescript"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const srcDir = path.join(rootDir, "src"); +const configPath = path.join(rootDir, "tsconfig.architecture.json"); async function listFiles(directory) { const entries = await readdir(directory, { withFileTypes: true }); @@ -15,14 +17,18 @@ async function listFiles(directory) { } const files = await listFiles(srcDir); +const config = ts.readConfigFile(configPath, ts.sys.readFile); +if (config.error) throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); +const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, rootDir); +const compiledFiles = new Set(parsed.fileNames.map((file) => path.resolve(file))); const checked = []; for (const file of files) { const source = await readFile(file, "utf8"); - if (/^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); + if (compiledFiles.has(path.resolve(file)) && /^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); } const coverage = files.length === 0 ? 100 : (checked.length / files.length) * 100; const required = Number(process.env.JHS_CHECKJS_MIN ?? 50); if (coverage + Number.EPSILON < required) { throw new Error(`checkJs coverage ${coverage.toFixed(1)}% is below ${required}%`); } -console.log(`checkJs coverage: ${checked.length}/${files.length} (${coverage.toFixed(1)}%, required ${required}%)`); +console.log(`checkJs coverage: ${checked.length}/${files.length} (${coverage.toFixed(1)}%, required ${required}%; ${compiledFiles.size} source files compiled)`); diff --git a/scripts/integration-quality-check.mjs b/scripts/integration-quality-check.mjs index 953cc92b..f5af0bbd 100644 --- a/scripts/integration-quality-check.mjs +++ b/scripts/integration-quality-check.mjs @@ -46,6 +46,13 @@ for (const entry of entries) { if (manifest?.quality === "silver") { const malformedTest = path.join(rootDir, "tests", "integrations", `${entry.name}.malformed.test.js`); if (!(await exists(malformedTest))) errors.push(`${entry.name}: Silver requires malformed response test`); + try { + const adapter = manifest.createAdapter(manifest.createClient({}), {}); + const operations = Object.keys(adapter ?? {}).filter((key) => key !== "contracts" && typeof adapter[key] === "function"); + if (operations.length === 0) errors.push(`${entry.name}: Silver requires an executable adapter operation`); + } catch (error) { + errors.push(`${entry.name}: Silver adapter cannot be constructed for quality inspection (${error?.message || error})`); + } } } diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index 95ec8780..ec837714 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -18,13 +18,14 @@ function extractMetadata(source, key) { return source.match(new RegExp(`^// @${key}\\s+(.+)$`, "m"))?.[1]?.trim(); } -function extractRegistryArray(registrySource, name) { - const match = registrySource.match(new RegExp(`const ${name} = \\[([\\s\\S]*?)\\];`)); - assert(match, `Missing registry array: ${name}`); - return match[1] - .split(",") - .map((item) => item.trim()) - .filter(Boolean); +function extractContributionOrder(registrySource, site) { + return registrySource.split(/\r?\n/).flatMap((line) => { + const match = line.match(/manifest\("[^"]+",\s*"[^"]+",\s*(\w+),\s*\[([^\]]+)\],\s*\{([^}]+)\}(?:,\s*\[[^\]]*\])?\)/); + if (!match || !match[2].includes(`"${site}"`)) return []; + const order = match[3].match(new RegExp(`(?:^|,\\s*)\\s*(?:"${site}"|${site})\\s*:\\s*(\\d+)`)); + assert(order, `Missing ${site} order for ${match[1]}`); + return [{ plugin: match[1], order: Number(order[1]) }]; + }).filter((item) => item.order > 0).sort((left, right) => left.order - right.order).map((item) => item.plugin); } function assertIncludes(source, token, label) { @@ -308,8 +309,8 @@ for (const [file, className, pluginName] of expectedPlugins) { assertIncludes(source, `return "${pluginName}"`, file); } -const javdbPlugins = extractRegistryArray(registry, "DEFAULT_JAVDB_PLUGINS"); -const javbusPlugins = extractRegistryArray(registry, "DEFAULT_JAVBUS_PLUGINS"); +const javdbPlugins = extractContributionOrder(registry, "javdb"); +const javbusPlugins = extractContributionOrder(registry, "javbus"); assert( javdbPlugins.join(",") === "ListPagePlugin,AutoPagePlugin,Fc2Plugin,FoldCategoryPlugin,ListPageButtonPlugin,HistoryPlugin,SettingPlugin,NavBarPlugin,HitShowPlugin,Top250Plugin,SearchByImagePlugin,CoverButtonPlugin,Fc2By123AvPlugin,DetailPagePlugin,DetailWorkspacePlugin,ReviewPlugin,RelatedPlugin,DetailPageButtonPlugin,HighlightMagnetPlugin,PreviewVideoPlugin,FilterTitleKeywordPlugin,ActressInfoPlugin,OtherSitePlugin,TranslatePlugin,WantAndWatchedVideosPlugin,MagnetHubPlugin,ScreenShotPlugin,BlacklistPlugin,FavoriteActressesPlugin,NewVideoPlugin,TaskPlugin,StatsPlugin,MobileBottomBarPlugin,OneOneFiveMatchPlugin,UnifiedOfflinePlugin,CompatibilityEnhancementsPlugin", "JavDB plugin registration order changed" @@ -318,10 +319,9 @@ assert( javbusPlugins.join(",") === "ListPagePlugin,ListPageButtonPlugin,SettingPlugin,HistoryPlugin,AutoPagePlugin,SearchByImagePlugin,BusNavBarPlugin,CoverButtonPlugin,BusImgPlugin,BusDetailPagePlugin,DetailWorkspacePlugin,DetailPageButtonPlugin,ReviewPlugin,FilterTitleKeywordPlugin,HighlightMagnetPlugin,BusPreviewVideoPlugin,MagnetHubPlugin,ScreenShotPlugin,OtherSitePlugin,TranslatePlugin,BlacklistPlugin,TaskPlugin,StatsPlugin,MobileBottomBarPlugin,OneOneFiveMatchPlugin,UnifiedOfflinePlugin,CompatibilityEnhancementsPlugin", "JavBus plugin registration order changed" ); -assertIncludes(registry, "context.is123Pan", "shared registry"); -assertIncludes(registry, "plugins: [ OneTwoThreeOfflinePlugin ]", "shared registry"); -assertIncludes(registry, "context.isJavTrailers", "shared registry"); -assertIncludes(registry, "context.isSubtitleCat", "shared registry"); +assertIncludes(registry, 'OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"]', "shared registry"); +assertIncludes(registry, 'JavTrailersPlugin, ["javtrailers"]', "shared registry"); +assertIncludes(registry, 'SubTitleCatPlugin, ["subtitlecat"]', "shared registry"); const siteContext = await read("src/core/site-context.js"); for (const [metadataToken, runtimeToken] of [ ["javdb", "JAVDB_HOST_PATTERN"], @@ -350,7 +350,7 @@ sourceByFile.set("core/migration.js", migration); sourceByFile.set("core/state-service.js", stateService); sourceByFile.set("core/plugin-manager.js", await read("src/core/plugin-manager.js")); sourceByFile.set("core/utils.js", await read("src/core/utils.js")); -sourceByFile.set("backup/webdav-client.js", await read("src/plugins/backup/webdav-client.js")); +sourceByFile.set("services/webdav-service.js", await read("src/services/webdav-service.js")); sourceByFile.set("backup/setting-backup.js", await read("src/plugins/backup/setting-backup.js")); sourceByFile.set("backup/setting-styles.js", await read("src/plugins/backup/setting-styles.js")); sourceByFile.set("backup/setting-templates.js", await read("src/plugins/backup/setting-templates.js")); @@ -369,7 +369,7 @@ const regressionMatrix = [ ["黑名单检测", [["blacklist/blacklist.js", "BlacklistPlugin"], ["blacklist/filter-title-keyword.js", "FilterTitleKeywordPlugin"], ["core/storage.js", "batchSaveBlacklistCarList"]]], ["统计面板", [["stats/stats.js", "StatsPlugin"], ["stats/stats.js", "coverageStart"], ["stats/stats.js", "6.4.0"]]], ["数据导入导出", [["backup/setting-backup.js", "importSettingData"], ["backup/setting-backup.js", "exportSettingData"], ["core/storage.js", "exportData"]]], - ["WebDAV 备份", [["backup/webdav-client.js", "class WebDavClient"], ["backup/setting-backup.js", "backupDataByWebDav"], ["backup/webdav-client.js", "PROPFIND"]]], + ["WebDAV 备份", [["services/webdav-service.js", "class WebDavClient"], ["backup/setting-backup.js", "backupDataByWebDav"], ["services/webdav-service.js", "PROPFIND"]]], ["图片查看器", [["core/logger.js", "showImageViewer"], ["core/logger.js", "new Viewer"], ["image-viewer/screenshot.js", "ScreenShotPlugin"]]], ["第三方请求失败场景", [["core/storage.js", "cachedRequest"], ["core/http.js", "onerror"], ["external-search/other-site.js", "detectOtherSites"]]], ["多标签页同步", [["core/event-bus.js", "eventId"], ["core/event-bus.js", "originId"], ["status/list-page.js", "list-items-added"]]], diff --git a/scripts/ui-consistency-audit.mjs b/scripts/ui-consistency-audit.mjs index ed8c9ba7..571449ce 100644 --- a/scripts/ui-consistency-audit.mjs +++ b/scripts/ui-consistency-audit.mjs @@ -23,7 +23,7 @@ async function listJavaScriptFiles(dir) { return files; } -const [theme, primitives, build, injection, magnet, settings, utils, detail, commandbar, newVideo, manager, hitShow, translate, settingStyles, main, packageSource, logger, reviews, related, settingPanels, settingForms, listButtons, coverButtons, highlightMagnet, task, storageQueue, constants, previewVideo, screenshot, parsers, otherSite, builtSource] = await Promise.all([ +const [theme, primitives, build, injection, magnet, settings, utils, detail, commandbar, newVideo, manager, hitShow, translate, translationUi, settingStyles, main, packageSource, logger, reviews, related, settingPanels, settingForms, listButtons, coverButtons, highlightMagnet, task, storageQueue, constants, previewVideo, screenshot, parsers, javstoreIntegration, otherSite, builtSource] = await Promise.all([ readFile(join(srcRoot, "core", "theme.js"), "utf8"), readFile(join(srcRoot, "core", "ui-primitives.js"), "utf8"), readFile(join(repoRoot, "scripts", "build.mjs"), "utf8"), @@ -37,6 +37,7 @@ const [theme, primitives, build, injection, magnet, settings, utils, detail, com readFile(join(srcRoot, "core", "plugin-manager.js"), "utf8"), readFile(join(srcRoot, "plugins", "external-search", "hit-show.js"), "utf8"), readFile(join(srcRoot, "plugins", "translate", "translate.js"), "utf8"), + readFile(join(srcRoot, "ui", "translation", "title-translation.js"), "utf8"), readFile(join(srcRoot, "plugins", "backup", "setting-styles.js"), "utf8"), readFile(join(srcRoot, "main.js"), "utf8"), readFile(join(repoRoot, "package.json"), "utf8"), @@ -54,6 +55,7 @@ const [theme, primitives, build, injection, magnet, settings, utils, detail, com readFile(join(srcRoot, "plugins", "image-viewer", "preview-video.js"), "utf8"), readFile(join(srcRoot, "plugins", "image-viewer", "screenshot.js"), "utf8"), readFile(join(srcRoot, "integrations", "javstore", "parser.js"), "utf8"), + readFile(join(srcRoot, "integrations", "javstore", "manifest.js"), "utf8"), readFile(join(srcRoot, "plugins", "external-search", "other-site.js"), "utf8"), readFile(join(repoRoot, "JHS.user.js"), "utf8") ]); @@ -132,9 +134,10 @@ forbidMatch(hitShow, /is-active|aria-current/, "hit show period must not retain for (const field of ["data-jhs-rate-count", "data-jhs-publish-time", "data-original-index"]) requireMatch(hitShow, new RegExp(field), `hit show sorting field missing ${field}`); forbidMatch(hitShow, /tool-box|button is-small/, "hit show must use the shared segmented toolbar"); -requireMatch(translate, /const s = "string" == typeof e \? e\.trim\(\) : "", o = s && "undefined" !== s \? s : a/, "translation cache key needs a safe title fallback"); -requireMatch(translate, /nextAll\("\.translated-title"\)/, "translation output must update an existing node"); -forbidMatch(translate, /translated-title[\s\S]{0,300}\.html\(/, "translated external text must not use html()"); +requireMatch(translate, /getRuntimeService\("translation"\)/, "translation feature must use the declared service"); +forbidMatch(translate, /localStorage|fetch\(/, "translation feature must not own network or cache persistence"); +requireMatch(translationUi, /nextAll\("\.translated-title"\)/, "translation output must update an existing node"); +forbidMatch(translationUi, /translated-title[\s\S]{0,400}\.html\(/, "translated external text must not use html()"); forbidMatch(settingStyles, /mini-switch:checked[\s\S]{0,120}status-down/, "ordinary switches must use the accent color"); forbidMatch(settingStyles, /right:\s*-300%/, "quick settings must be anchored to its trigger"); forbidMatch(settingStyles, /\.form-content\s+\*/, "legacy form-content wildcard must not resize nested controls"); @@ -172,7 +175,8 @@ requireMatch(detail, /normalizeHostActions\(root\.find\("\.video-meta-panel"\)\. requireMatch(detail, /jhs-detail-host-action/, "detail workspace host action appearance class is missing"); requireMatch(listButtons, /role="menuitemradio"/, "sort control must use menuitemradio options"); -requireMatch(listButtons, /jhs_sortMethod/, "sort control must retain its storage key"); +requireMatch(listButtons, /getRuntimeService\("settings"\)\.set\("sortMethod"/, "sort control must persist through SettingsService"); +requireMatch(await readFile(join(srcRoot, "app", "bootstrap.js"), "utf8"), /localStorage\.getItem\("jhs_sortMethod"\)[\s\S]*settings\.set\("sortMethod"/, "sort control must migrate its legacy storage key"); for (const key of ["ArrowDown", "ArrowUp", "Home", "End", "Escape"]) requireMatch(listButtons, new RegExp(key), `sort control is missing ${key} keyboard behavior`); forbidMatch(listButtons, /]+sort-toggle-btn/, "native sort select must not return"); @@ -219,17 +223,17 @@ forbidMatch(previewVideo, /nativePreviewSrc|rememberNativeSource|restoreNativeSo forbidMatch(previewVideo, /\$nativeVideo\.attr\("src"|nativeVideo\.load\(\)/, "JHS must not replace or reload the JavDB HLS media source"); requireMatch(screenshot, /async getScreenshot\(e\)\s*\{\s*e = normalizeCarNum\(e\)/, "screenshots must validate carNum first"); requireMatch(screenshot, /无法获取番号,缩略图未加载/, "screenshot invalid-number fallback is missing"); -requireMatch(screenshot, /javstore\.net\/search\?q=\$\{encodeURIComponent\(e\)\}/, "JavStore must use its query search endpoint"); +requireMatch(javstoreIntegration, /javstore\.net\/search\?q=\$\{encodeURIComponent\(movieRef\.carNum \|\| ""\)\}/, "JavStore must use its query search endpoint"); requireMatch(parsers, /a\[href\$=["']-pn\.html["']\][\s\S]{0,240}includes\(normalizedCarNum\.toUpperCase\(\)\)[\s\S]{0,180}\.map\([\s\S]{0,120}\.get\(\)/, "JavStore must preserve all matching -pn.html results in source order"); -requireMatch(screenshot, /for \(const e of i\)[\s\S]*gmHttp\.get\(t/, "JavStore detail URLs must be checked sequentially"); +requireMatch(javstoreIntegration, /for \(const candidate of candidates\)[\s\S]*await request\(candidate/, "JavStore detail URLs must be checked sequentially"); requireMatch(parsers, /normalizeJavStoreAssetUrl\(previewHref, detailUrl\)/, "JavStore preview URLs must be resolved and normalized against the detail page"); requireMatch(parsers, /"javstore\.net" === hostname \|\| hostname\.endsWith\("\.javstore\.net"\)[\s\S]{0,100}url\.protocol = "https:"/, "JavStore HTTP preview URLs must be upgraded selectively"); requireMatch(parsers, /previewUrl\.replace\("\.th", ""\)/, "JavStore preview URLs must retain .th compatibility"); -requireMatch(screenshot, /"javstore" === provider \? normalizeJavStoreAssetUrl\(cachedUrl\) : cachedUrl/, "legacy JavStore screenshot cache reads must normalize asset URLs"); +requireMatch(screenshot, /getRuntimeService\("screenshot"\)\.resolve/, "screenshots must resolve through ScreenshotService"); requireMatch(screenshot, /addImg\(e, t\)[\s\S]{0,100}normalizeJavStoreAssetUrl\(t\)/, "screenshot rendering must normalize JavStore asset URLs at the final boundary"); requireMatch(parsers, /"CLICK HERE!" === wrap\(element\)\.text\(\)\.trim\(\)/, "JavStore detail parsing must retain the CLICK HERE! link contract"); -requireMatch(screenshot, /详情页没有 CLICK HERE![\s\S]{0,80}continue/, "JavStore must continue after a candidate without CLICK HERE!"); +requireMatch(javstoreIntegration, /if \(imageUrl\) return[\s\S]*return \[\]/, "JavStore must continue after a candidate without CLICK HERE!"); forbidMatch(screenshot, /javstore\.net\/search\/|img\[src\*=['"]_s\.jpg/, "legacy JavStore search or detail fallback must not return"); requireMatch(otherSite, /跳过第三方站点解析:番号不可用/, "external sites must fail fast without a car number"); for (const entry of ["checkNewVideo", "checkFavoriteActress", "checkOneNewVideo"]) diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js index b2560a04..5c582e26 100644 --- a/src/app/bootstrap.js +++ b/src/app/bootstrap.js @@ -1,6 +1,7 @@ // @ts-check import { l, r } from "../core/constants.js"; +import { detectSite } from "../core/site-context.js"; import { JhsError } from "../core/jhs-error.js"; import { runDataMigrations } from "../core/migration.js"; import { PluginManager } from "../core/plugin-manager.js"; @@ -9,7 +10,7 @@ import { gmHttp, storageManager, utils } from "../core/http.js"; import { initializeEventBus } from "../core/event-bus.js"; import { migrateDisabledPlugins, parseDisabledPlugins } from "../core/legacy-plugin-contributions.js"; import { initializeLoggerRuntime } from "../core/logger.js"; -import { applyTheme } from "../core/theme.js"; +import { applyTheme, initializeThemeRuntime } from "../core/theme.js"; import { getVendorRuntime } from "../platform/userscript/vendor-runtime.js"; import { JavBusHostAdapter } from "../platform/hosts/javbus-host-adapter.js"; import { JavDbHostAdapter } from "../platform/hosts/javdb-host-adapter.js"; @@ -90,19 +91,39 @@ export async function bootstrapJhs() { importVendorStyles(); const disabled = await migrateDisabledPluginSettings(); const localOriginSettings = await prepareLocalOrigins(); + const siteContext = detectSite(window.location); const hostAdapter = r ? new JavDbHostAdapter() : l ? new JavBusHostAdapter() : null; const route = hostAdapter?.detectRoute() ?? "other"; const context = createAppContext({ gmRequest: globalThis.GM_xmlhttpRequest, storageForage: storageManager.forage, - layer: vendors.layer, hostAdapter, site: r ? "javdb" : l ? "javbus" : "other", route, disabled, localOrigins: localOriginSettings.origins, + layer: vendors.layer, hostAdapter, site: siteContext.site, route, disabled, localOrigins: localOriginSettings.origins, }); + const settingsSnapshot = await context.services.settings.load(); + const legacySortMethod = localStorage.getItem("jhs_sortMethod"); + if (settingsSnapshot.sortMethod == null && ["default", "rateCount", "date"].includes(legacySortMethod || "")) { + await context.services.settings.set("sortMethod", legacySortMethod); + } + const legacyFoldCategory = localStorage.getItem("jhs_foldCategory"); + if (settingsSnapshot.foldCategoryCollapsed == null && ["yes", "no"].includes(legacyFoldCategory || "")) { + await context.services.settings.set("foldCategoryCollapsed", legacyFoldCategory === "yes"); + } + const legacyVideoMuted = localStorage.getItem("jhs_videoMuted"); + if (settingsSnapshot.videoMuted == null && ["yes", "no"].includes(legacyVideoMuted || "")) { + await context.services.settings.set("videoMuted", legacyVideoMuted === "yes"); + } const logger = initializeLoggerRuntime(context.rootScope); + initializeThemeRuntime(context.rootScope); + context.services.diagnostics.setBrowserMetadata({ + userAgent: navigator.userAgent, + platform: navigator.platform, + ...(globalThis.__jhsBrowserTestMetadata ?? {}), + }); Object.assign(globalThis, logger); if (localOriginSettings.notice) globalThis.show.info(localOriginSettings.notice); - const pluginManager = new PluginManager(); - registerSitePlugins(pluginManager); + const pluginManager = new PluginManager({ diagnostics: context.services.diagnostics }); for (const manifest of integrationManifests) context.registries.integrations.register(manifest); for (const manifest of featureManifests) context.registries.features.register(manifest); + registerSitePlugins(pluginManager, context.registries.features, siteContext.site); await context.registries.features.start(); attachCompatibilityFacade({ pluginManager, utils, gmHttp, storageManager, stateService, jhsEventBus, diff --git a/src/app/create-app-context.js b/src/app/create-app-context.js index 98e6b093..766034c5 100644 --- a/src/app/create-app-context.js +++ b/src/app/create-app-context.js @@ -7,6 +7,7 @@ import { IndexedDbStorageAdapter } from "../platform/userscript/indexeddb-storag import { LayerDialogAdapter } from "../platform/userscript/layer-dialog-adapter.js"; import { UserscriptHttpAdapter } from "../platform/userscript/userscript-http-adapter.js"; import { CacheService } from "../services/cache-service.js"; +import { ActressInfoService } from "../services/actress-info-service.js"; import { DiagnosticsService } from "../services/diagnostics-service.js"; import { DialogService } from "../services/dialog-service.js"; import { ExternalUrlPolicy } from "../services/external-url-policy.js"; @@ -19,8 +20,10 @@ import { ProfileService } from "../services/profile-service.js"; import { RelatedService } from "../services/related-service.js"; import { ReviewService } from "../services/review-service.js"; import { ScreenshotService } from "../services/screenshot-service.js"; +import { TranslationService } from "../services/translation-service.js"; import { SettingsService } from "../services/settings-service.js"; import { StorageService } from "../services/storage-service.js"; +import { WebDavService } from "../services/webdav-service.js"; import { StyleRegistry } from "../services/style-registry.js"; import { CommandRegistry } from "./command-registry.js"; import { DependencyContainer } from "./dependency-container.js"; @@ -47,6 +50,7 @@ export function createAppContext(runtime) { const dialog = new DialogService(dialogPort); const styles = new StyleRegistry(stylePort); const http = new HttpService(httpPort, urlPolicy, { diagnostics, cache }); + const webdav = new WebDavService(http); const settings = new SettingsService(storage); const profile = new ProfileService({ scope: rootScope }); profile.start(); @@ -54,25 +58,28 @@ export function createAppContext(runtime) { const providers = new ProviderRegistry(diagnostics); const settingsRegistry = new SettingsRegistry(); const integrations = new IntegrationRegistry(container, diagnostics); - const movie = new MovieIdentityService(); + const movie = new MovieIdentityService(integrations); + const actressInfo = new ActressInfoService(integrations, cache); const review = new ReviewService(integrations); const related = new RelatedService(integrations); - const magnet = new MagnetService(providers); - const screenshot = new ScreenshotService(providers); + const magnet = new MagnetService(providers, integrations); + const screenshot = new ScreenshotService(providers, integrations); + const translation = new TranslationService(integrations); const offline = new OfflineService(providers); container .register(PORT.navigation, navigationPort).register(PORT.http, httpPort).register(PORT.storage, storagePort) .register(PORT.dialog, dialogPort).register(PORT.style, stylePort) .register(SERVICE.diagnostics, diagnostics).register(SERVICE.urlPolicy, urlPolicy) - .register(SERVICE.navigation, navigation).register(SERVICE.http, http).register(SERVICE.storage, storage) + .register(SERVICE.navigation, navigation).register(SERVICE.http, http).register(SERVICE.storage, storage).register(SERVICE.webdav, webdav) .register(SERVICE.dialog, dialog).register(SERVICE.settings, settings).register(SERVICE.cache, cache).register(SERVICE.profile, profile) - .register(SERVICE.movie, movie).register(SERVICE.review, review).register(SERVICE.related, related).register(SERVICE.magnet, magnet) + .register(SERVICE.movie, movie).register(SERVICE.actressInfo, actressInfo).register(SERVICE.review, review).register(SERVICE.related, related).register(SERVICE.magnet, magnet) .register(SERVICE.screenshot, screenshot).register(SERVICE.offline, offline) + .register(SERVICE.translation, translation) .register(REGISTRY.command, commands).register(REGISTRY.provider, providers).register(REGISTRY.integration, integrations).register(REGISTRY.settings, settingsRegistry); if (runtime.hostAdapter) container.register(PORT.host, runtime.hostAdapter); const features = new FeatureRuntime({ container, commands, diagnostics, disabled: runtime.disabled, site: runtime.site, route: runtime.route }); container.register(REGISTRY.feature, features); - return Object.freeze({ rootScope, container, ports: Object.freeze({ navigationPort, httpPort, storagePort, dialogPort, stylePort }), services: Object.freeze({ diagnostics, urlPolicy, navigation, http, storage, dialog, styles, settings, cache, profile, movie, review, related, magnet, screenshot, offline }), registries: Object.freeze({ commands, providers, integrations, settings: settingsRegistry, features }) }); + return Object.freeze({ rootScope, container, ports: Object.freeze({ navigationPort, httpPort, storagePort, dialogPort, stylePort }), services: Object.freeze({ diagnostics, urlPolicy, navigation, http, storage, webdav, dialog, styles, settings, cache, profile, movie, actressInfo, review, related, magnet, screenshot, translation, offline }), registries: Object.freeze({ commands, providers, integrations, settings: settingsRegistry, features }) }); } diff --git a/src/app/feature-runtime.js b/src/app/feature-runtime.js index 7891bc71..ecbacc52 100644 --- a/src/app/feature-runtime.js +++ b/src/app/feature-runtime.js @@ -38,6 +38,27 @@ export class FeatureRuntime { return true; } + /** @param {string} featureId @param {string} contributionId @param {string} legacyPluginId */ + isContributionEnabled(featureId, contributionId, legacyPluginId) { + const manifest = this.manifests.get(featureId); + if (!manifest) return false; + if (manifest.sites.length && !manifest.sites.includes(this.site)) return false; + if (!manifest.contributes.includes(contributionId)) return false; + if (manifest.kind !== "system" && this.disabled.has(manifest.id)) return false; + if (manifest.kind === "system") return true; + return !this.disabled.has(contributionId) && !this.disabled.has(legacyPluginId); + } + + /** @param {symbol[]} tokens */ + resolveDeclaredDependencies(tokens) { + return this.container.resolveDeclared(tokens); + } + + /** @param {string} featureId */ + async getScope(featureId) { + return (await this.activate(featureId)).scope; + } + /** @param {string} id */ activate(id) { const existing = this.activations.get(id); diff --git a/src/app/integration-catalog.js b/src/app/integration-catalog.js index 41dcb506..8175eb43 100644 --- a/src/app/integration-catalog.js +++ b/src/app/integration-catalog.js @@ -3,11 +3,14 @@ import av123 from "../integrations/av123/manifest.js"; import dmm from "../integrations/dmm/manifest.js"; import fc2ppvdb from "../integrations/fc2ppvdb/manifest.js"; +import fc2content from "../integrations/fc2content/manifest.js"; +import googleTranslate from "../integrations/google-translate/manifest.js"; import hostList from "../integrations/host-list/manifest.js"; import javdb from "../integrations/javdb/manifest.js"; import javbus from "../integrations/javbus/manifest.js"; import javstore from "../integrations/javstore/manifest.js"; import javtrailers from "../integrations/javtrailers/manifest.js"; import subtitlecat from "../integrations/subtitlecat/manifest.js"; +import wikipedia from "../integrations/wikipedia/manifest.js"; -export const integrationManifests = Object.freeze([av123, dmm, fc2ppvdb, hostList, javdb, javbus, javstore, javtrailers, subtitlecat]); +export const integrationManifests = Object.freeze([av123, dmm, fc2ppvdb, fc2content, googleTranslate, hostList, javdb, javbus, javstore, javtrailers, subtitlecat, wikipedia]); diff --git a/src/contracts/manifests.js b/src/contracts/manifests.js index 2313de97..18dcc2fb 100644 --- a/src/contracts/manifests.js +++ b/src/contracts/manifests.js @@ -26,6 +26,18 @@ export function defineFeature(manifest) { return Object.freeze({ ...manifest }); } +/** @param {Record} manifest */ +export function defineContribution(manifest) { + requireNonEmptyString(manifest.id, "Contribution id"); + requireNonEmptyString(manifest.featureId, "Contribution featureId"); + requireNonEmptyString(manifest.legacyPluginId, "Contribution legacyPluginId"); + requireArray(manifest.sites, "sites"); + requireArray(manifest.requires, "requires"); + if (typeof manifest.plugin !== "function") throw new TypeError("Contribution plugin must be a class"); + if (!manifest.order || typeof manifest.order !== "object") throw new TypeError("Contribution order must be explicit"); + return Object.freeze({ ...manifest, sites: Object.freeze([...(/** @type {unknown[]} */ (manifest.sites))]), order: Object.freeze({ ...manifest.order }) }); +} + /** @param {Record} manifest */ export function defineIntegration(manifest) { requireNonEmptyString(manifest.id, "Integration id"); diff --git a/src/contracts/tokens.js b/src/contracts/tokens.js index b41dea2f..1cb2bc8a 100644 --- a/src/contracts/tokens.js +++ b/src/contracts/tokens.js @@ -20,13 +20,16 @@ export const SERVICE = Object.freeze({ navigation: createToken("service", "navigation"), http: createToken("service", "http"), storage: createToken("service", "storage"), + webdav: createToken("service", "webdav"), dialog: createToken("service", "dialog"), settings: createToken("service", "settings"), movie: createToken("service", "movie"), + actressInfo: createToken("service", "actress-info"), review: createToken("service", "review"), related: createToken("service", "related"), magnet: createToken("service", "magnet"), screenshot: createToken("service", "screenshot"), + translation: createToken("service", "translation"), offline: createToken("service", "offline"), cache: createToken("service", "cache"), state: createToken("service", "state"), diff --git a/src/core/credential-crypto.js b/src/core/credential-crypto.js new file mode 100644 index 00000000..09769140 --- /dev/null +++ b/src/core/credential-crypto.js @@ -0,0 +1,46 @@ +// @ts-check + +const ENCRYPTION_SALT = "x7k9p3"; +const CREDENTIAL_PREFIX = "AES:"; + +async function getEncryptionKey() { + const encoder = new TextEncoder(); + const material = await crypto.subtle.importKey("raw", encoder.encode(`${ENCRYPTION_SALT}.jhs.v1`), { name: "PBKDF2" }, false, ["deriveKey"]); + return crypto.subtle.deriveKey({ name: "PBKDF2", salt: encoder.encode("jhs-backup"), iterations: 100_000, hash: "SHA-256" }, material, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); +} + +/** @param {ArrayBuffer} buffer */ +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer), chunkSize = 0x8000; + let value = ""; + for (let index = 0; index < bytes.length; index += chunkSize) value += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + return btoa(value); +} + +/** @param {string} value */ +function base64ToArrayBuffer(value) { + const decoded = atob(value), bytes = new Uint8Array(decoded.length); + for (let index = 0; index < decoded.length; index++) bytes[index] = decoded.charCodeAt(index); + return bytes; +} + +/** @param {string} value */ +export async function encryptData(value) { + const key = await getEncryptionKey(), iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(value)); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); combined.set(new Uint8Array(encrypted), iv.length); + return arrayBufferToBase64(combined.buffer); +} + +/** @param {string} value */ +export async function decryptData(value) { + const key = await getEncryptionKey(), combined = base64ToArrayBuffer(value), iv = combined.slice(0, 12), data = combined.slice(12); + const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, data); + return new TextDecoder().decode(decrypted); +} + +/** @param {string} value */ +export async function encryptCredential(value) { return value && !value.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(value) : value; } +/** @param {string} value */ +export async function decryptCredential(value) { return value && value.startsWith(CREDENTIAL_PREFIX) ? decryptData(value.slice(CREDENTIAL_PREFIX.length)) : value; } diff --git a/src/core/javdb-api.js b/src/core/javdb-api.js index 4f704584..76bf2d55 100644 --- a/src/core/javdb-api.js +++ b/src/core/javdb-api.js @@ -1,48 +1,15 @@ -import { normalizeCarNum } from "./constants.js"; -import { decryptData } from "../plugins/image-viewer/bus-preview-video.js"; +import { decryptData } from "./credential-crypto.js"; export const U = "https://jdforrepam.com/api"; -const javDbMovieIdRequests = new Map(); +let signatureSecond = 0, signatureValue = ""; /** 生成 JavDB API 请求签名 (HMAC-时间戳+盐+MD5, 20秒缓存) */ export function O() { - const e = "jhs_review_ts", t = "jhs_review_sign", n = Math.floor(Date.now() / 1e3); - if (n - (localStorage.getItem(e) || 0) <= 20) return localStorage.getItem(t); - const a = `${n}.lpw6vgqzsp.${md5(`${n}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; - return localStorage.setItem(e, n), localStorage.setItem(t, a), a; -} - -/** 按规范化番号精确解析 JavDB movieId,并合并同番号并发请求。 */ -export async function resolveJavDbMovieId(carNum) { - const normalized = normalizeCarNum(carNum); - if (!normalized) return null; - if (javDbMovieIdRequests.has(normalized)) return javDbMovieIdRequests.get(normalized); - const request = storageManager.cachedRequest(`javdb-movie-id:${normalized}`, 7 * 864e5, (async () => { - const response = await gmHttp.get(`${U}/v2/search`, { - q: normalized, - page: 1, - type: "movie", - limit: 20, - movie_type: "all", - from_recent: "false", - movie_filter_by: "all", - movie_sort_by: "relevance" - }, { - "user-agent": "Dart/3.5 (dart:io)", - "accept-language": "zh-TW", - host: "jdforrepam.com", - jdsignature: await O() - }); - if (!Array.isArray(response?.data?.movies)) throw new Error(response?.message || "JavDB 番号解析失败"); - const match = response.data.movies.find((movie => normalizeCarNum(movie.number) === normalized)); - return match?.id ? { __jhsCacheTtl: 7 * 864e5, data: { movieId: String(match.id) } } : { __jhsCacheTtl: 6 * 36e5, data: { miss: !0 } }; - })).then((value => value?.miss ? null : value?.movieId || null)); - javDbMovieIdRequests.set(normalized, request); - try { - return await request; - } finally { - javDbMovieIdRequests.get(normalized) === request && javDbMovieIdRequests.delete(normalized); - } + const now = Math.floor(Date.now() / 1e3); + if (signatureValue && now - signatureSecond <= 20) return signatureValue; + signatureSecond = now; + signatureValue = `${now}.lpw6vgqzsp.${md5(`${now}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; + return signatureValue; } /** 将影片加入当前 JavDB 账号的“想看”,使用与移动端功能相同的登录凭据。 */ @@ -75,20 +42,7 @@ export async function markJavDbWantWatch(movieId) { } } -export const R = async (e, t = 1, n = 20) => { - let a = `${U}/v1/movies/${e}/reviews`, i = { - jdSignature: await O() - }; - return await storageManager.cachedRequest(`reviews:${e}:${t}:${n}`, 864e5, (async () => { - const e = await gmHttp.get(a, { - page: t, - sort_by: "hotly", - limit: n - }, i); - if (!e?.data?.reviews) throw new Error(e?.message || "获取评论失败"); - return e.data.reviews; - })); -}, V = async e => { +export const V = async e => { let t = `${U}/v4/movies/${e}`, n = { jdSignature: await O() }; @@ -112,25 +66,6 @@ export const R = async (e, t = 1, n = 20) => { watchedCount: i.watched_count, imgList: o }; -}, K = async (e, t = 1, n = 20) => { - let a = `${U}/v1/lists/related?movie_id=${e}&page=${t}&limit=${n}`, i = { - jdSignature: await O() - }; - const s = await storageManager.cachedRequest(`related:${e}:${t}:${n}`, 864e5, (async () => { - const e = await gmHttp.get(a, null, i); - if (!e?.data?.lists) throw new Error(e?.message || "获取相关清单失败"); - return e; - })), o = []; - return s.data.lists.forEach((e => { - o.push({ - relatedId: e.id, - name: e.name, - movieCount: e.movies_count, - collectionCount: e.collections_count, - viewCount: e.views_count, - createTime: utils.formatDate(e.created_at) - }); - })), o; }, W = async (e = "daily", t = "high_score") => { let n = `${U}/v1/rankings/playback?period=${e}&filter_by=${t}`, a = { jdSignature: await O() diff --git a/src/core/legacy-plugin-contributions.js b/src/core/legacy-plugin-contributions.js index 958d8099..211555c9 100644 --- a/src/core/legacy-plugin-contributions.js +++ b/src/core/legacy-plugin-contributions.js @@ -1,4 +1,20 @@ export const LEGACY_PLUGIN_CONTRIBUTION_MAP = Object.freeze({ + ListPagePlugin: "list.core", + AutoPagePlugin: "list.auto-page", + Fc2Plugin: "detail.fc2-owned", + FoldCategoryPlugin: "list.fold-category", + ListPageButtonPlugin: "list.actions", + HistoryPlugin: "library.history", + SettingPlugin: "settings.core", + NavBarPlugin: "identity.javdb-navigation", + BusNavBarPlugin: "identity.javbus-navigation", + HitShowPlugin: "discovery.hit-show", + TOP250Plugin: "discovery.top250", + SearchByImagePlugin: "identity.image-search", + Fc2By123AvPlugin: "detail.fc2-lookup", + DetailPagePlugin: "detail.native", + BusDetailPagePlugin: "detail.native", + DetailWorkspacePlugin: "detail.workspace", ReviewPlugin: "detail.reviews", RelatedPlugin: "detail.related", ScreenShotPlugin: "detail.screenshot", @@ -10,6 +26,23 @@ export const LEGACY_PLUGIN_CONTRIBUTION_MAP = Object.freeze({ HighlightMagnetPlugin: "detail.native-magnets", OtherSitePlugin: "detail.external-sites", SubTitleCatPlugin: "detail.subtitle", + FilterTitleKeywordPlugin: "library.keyword-filter", + ActressInfoPlugin: "identity.actress-info", + TranslatePlugin: "external-bridge.translation", + WantAndWatchedVideosPlugin: "library.state-actions", + BlacklistPlugin: "library.blacklist", + FavoriteActressesPlugin: "library.favorite-actresses", + NewVideoPlugin: "discovery.new-video", + TaskPlugin: "discovery.scheduler", + StatsPlugin: "stats.dashboard", + MobileBottomBarPlugin: "responsive-shell.bottom-bar", + OneOneFiveMatchPlugin: "external-bridge.115-match", + UnifiedOfflinePlugin: "external-bridge.offline", + CompatibilityEnhancementsPlugin: "compatibility.enhancements", + BusImgPlugin: "detail.gallery", + BusPreviewVideoPlugin: "detail.gallery", + OneTwoThreeOfflinePlugin: "external-bridge.123pan", + JavTrailersPlugin: "external-bridge.javtrailers", }); /** @param {unknown} value */ diff --git a/src/core/plugin-manager.js b/src/core/plugin-manager.js index 801a4ae2..d8c11e9c 100644 --- a/src/core/plugin-manager.js +++ b/src/core/plugin-manager.js @@ -2,7 +2,7 @@ import { A, B, D, I, P, T, assertPageInfoContract, firstValidCarNum, i, l, norma import { disabledIdForPlugin, parseDisabledPlugins } from "./legacy-plugin-contributions.js"; export class PluginManager { - constructor() { + constructor(options = {}) { this.plugins = new Map; this._errorLog = []; this._lastTimings = []; @@ -15,12 +15,13 @@ export class PluginManager { this._idleCompleted = 0; this._disabledPluginsPromise = null; this._dependencyDeclarations = Object.freeze({}); + this.diagnostics = options.diagnostics ?? null; } setDependencyDeclarations(declarations) { if (this.plugins.size) throw new Error("依赖声明必须在插件注册前配置"); this._dependencyDeclarations = declarations || Object.freeze({}); } - register(e) { + register(e, runtimeServices = {}) { if ("function" != typeof e) throw new Error("插件必须是一个类"); const a = performance.now(); const t = new e; @@ -28,8 +29,10 @@ export class PluginManager { const n = t.getName(); if (this.plugins.has(n)) throw new Error(`插件"${n}"已注册`); t.declaredDependencies = new Set(this._dependencyDeclarations[n] || []); + t.runtimeServices = Object.freeze({ ...runtimeServices }); this.plugins.set(n, t); this._registrationMs += performance.now() - a; + this._syncDiagnostics(); } // 仅供 6.6 前的外部 Compatibility Facade 使用。 getBean(e) { @@ -47,9 +50,10 @@ export class PluginManager { stack: n?.stack || "" }); this._errorLog.length > 200 && this._errorLog.shift(); + this.diagnostics?.recordError({ source: "legacy-plugin", plugin: e, phase: t, message: n?.message || String(n) }); } getErrorLog() { return [...this._errorLog]; } - clearErrorLog() { this._errorLog = []; } + clearErrorLog() { this._errorLog = []; this.diagnostics?.clearErrors(); } getTimings() { return [...this._lastTimings]; } getPluginNames() { return Array.from(this.plugins.keys()); } getStartupReport() { @@ -63,6 +67,7 @@ export class PluginManager { idleCompleted: this._idleCompleted }; } + _syncDiagnostics() { this.diagnostics?.setLegacyRuntime(this.getPluginNames(), this.getStartupReport(), this.getTimings()); } async _getDisabledPlugins() { return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { try { const e = await storageManager.getSetting("disabledPlugins", "[]"); @@ -99,6 +104,7 @@ export class PluginManager { const o = s.map((e => e.css)).filter(Boolean); o.length > 0 && utils.insertStyle(o); this._cssMs = performance.now() - a; + this._syncDiagnostics(); } async _runPlugin(e) { const t = performance.now(); @@ -127,6 +133,7 @@ export class PluginManager { this._idlePending--; this._idleCompleted++; } + this._syncDiagnostics(); } async processPlugins() { const e = await this._getDisabledPlugins(), t = utils.isMobileMode(), n = [], a = [], i = []; @@ -157,6 +164,7 @@ export class PluginManager { this._readyMs = performance.now() - this._startedAt; this._idlePending = a.length; a.length && this._scheduleIdle((() => this._runIdlePlugins(a))); + this._syncDiagnostics(); } } @@ -187,6 +195,7 @@ export class BasePlugin { } i(this, "pluginManager", null); i(this, "declaredDependencies", new Set); + i(this, "runtimeServices", Object.freeze({})); } getName() { throw new Error(`${this.constructor.name} 未显示getName()`); @@ -197,6 +206,10 @@ export class BasePlugin { } return this.pluginManager.resolveDeclaredPlugin(e); } + getRuntimeService(name) { + if (!Object.prototype.hasOwnProperty.call(this.runtimeServices, name)) throw new Error(`${this.getName()} 未声明运行时依赖 ${name}`); + return this.runtimeServices[name]; + } async initCss() { return ""; } diff --git a/src/core/site-context.js b/src/core/site-context.js index b1f053f0..7d712daf 100644 --- a/src/core/site-context.js +++ b/src/core/site-context.js @@ -1,13 +1,16 @@ +// @ts-check + const JAVDB_HOST_PATTERN = /^(?:[a-z0-9-]+\.)?javdb(?:[a-z0-9-]*)\.com$/i; const JAVBUS_HOST_MARKERS = ["javbus", "javsee", "seejav"]; -/** 规范化 location、URL 或字符串输入。 */ +/** @param {Location | URL | string} [locationLike] */ function normalizeLocation(locationLike = window.location) { if (locationLike instanceof URL) return locationLike; if ("string" === typeof locationLike) return new URL(locationLike); return new URL(locationLike.href || `${locationLike.protocol}//${locationLike.hostname}${locationLike.pathname || "/"}${locationLike.search || ""}`); } /** 仅按 hostname 识别脚本运行站点,避免 URL 查询串造成误判。 */ +/** @param {Location | URL | string} [locationLike] */ export function detectSite(locationLike = window.location) { const locationUrl = normalizeLocation(locationLike); const hostname = locationUrl.hostname.toLowerCase().replace(/\.$/, ""); @@ -21,19 +24,21 @@ export function detectSite(locationLike = window.location) { } /** 识别由 JHS 接管渲染的 JavDB 热播榜页面。 */ +/** @param {Location | URL | string} [locationLike] */ export function isHitShowPage(locationLike = window.location) { const locationUrl = normalizeLocation(locationLike); return "/advanced_search" === locationUrl.pathname && "1" === locationUrl.searchParams.get("handlePlayback"); } /** 识别保留站点原生列表生命周期的页面。 */ -function isNormalListPage(locationLike = window.location, hasMovieList = null) { +/** @param {Location | URL | string} [locationLike] @param {boolean | null} [hasMovieList] */ +function isNormalListPage(locationLike = window.location, hasMovieList = false) { const locationUrl = normalizeLocation(locationLike); - const hasList = null === hasMovieList ? "undefined" != typeof $ && $(".movie-list").length > 0 : Boolean(hasMovieList); - return !isHitShowPage(locationUrl) && (hasList || locationUrl.pathname.includes("advanced_search")); + return !isHitShowPage(locationUrl) && (Boolean(hasMovieList) || locationUrl.pathname.includes("advanced_search")); } /** 识别所有具备列表页能力的页面,包括 JHS 热播榜。 */ -export function isListPage(locationLike = window.location, hasMovieList = null) { +/** @param {Location | URL | string} [locationLike] @param {boolean | null} [hasMovieList] */ +export function isListPage(locationLike = window.location, hasMovieList = false) { return isHitShowPage(locationLike) || isNormalListPage(locationLike, hasMovieList); } diff --git a/src/core/theme.js b/src/core/theme.js index 4c3c69da..2ffad771 100644 --- a/src/core/theme.js +++ b/src/core/theme.js @@ -406,9 +406,12 @@ export async function applyTheme() { document.documentElement.setAttribute("data-jhs-theme", resolved); } -/** 跟随系统模式下, 监听系统深浅色切换并实时应用。 */ -window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (() => { - storageManager.getSetting("themeMode", "light").then((e => { - "auto" === e && applyTheme(); - })); -})); +/** @param {import("./lifecycle-scope.js").LifecycleScope} scope */ +export function initializeThemeRuntime(scope) { + const media = window.matchMedia("(prefers-color-scheme: dark)"); + scope.listen(media, "change", () => { + storageManager.getSetting("themeMode", "light").then((mode => { + if ("auto" === mode) void applyTheme(); + })); + }); +} diff --git a/src/features/catalog.js b/src/features/catalog.js index 72a68943..c5165864 100644 --- a/src/features/catalog.js +++ b/src/features/catalog.js @@ -1,6 +1,7 @@ // @ts-check import detail from "./detail/manifest.js"; +import { legacyCapabilityManifests } from "./legacy-capabilities.js"; import { systemFeatureManifests } from "./system/catalog.js"; -export const featureManifests = Object.freeze([...systemFeatureManifests, detail]); +export const featureManifests = Object.freeze([...systemFeatureManifests, detail, ...legacyCapabilityManifests]); diff --git a/src/features/detail/manifest.js b/src/features/detail/manifest.js index 4719518b..a321669c 100644 --- a/src/features/detail/manifest.js +++ b/src/features/detail/manifest.js @@ -7,7 +7,7 @@ import { DetailController } from "./detail-controller.js"; export default defineFeature({ id: "detail", kind: "feature", disableable: true, sites: ["javdb", "javbus"], routes: ["detail"], startup: "eager", requires: [PORT.host, SERVICE.movie, SERVICE.review, SERVICE.related, SERVICE.magnet, SERVICE.screenshot], - contributes: ["detail.state-actions", "detail.gallery", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], + contributes: ["detail.native", "detail.workspace", "detail.fc2-owned", "detail.fc2-lookup", "detail.state-actions", "detail.gallery", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], providesCommands: [], activate: (/** @type {any} */ deps, /** @type {any} */ runtime) => { const controller = new DetailController({ hostAdapter: deps[PORT.host], scope: runtime.scope, enabledContributions: runtime.enabledContributions }); diff --git a/src/features/legacy-capabilities.js b/src/features/legacy-capabilities.js new file mode 100644 index 00000000..aca1f920 --- /dev/null +++ b/src/features/legacy-capabilities.js @@ -0,0 +1,18 @@ +// @ts-check + +import { defineFeature } from "../contracts/manifests.js"; + +/** @param {string} id @param {string[]} sites @param {string[]} contributes */ +const feature = (id, sites, contributes) => defineFeature({ + id, kind: "feature", disableable: true, sites, routes: [], startup: "eager", + requires: [], contributes, providesCommands: [], activate: () => ({}), +}); + +export const legacyCapabilityManifests = Object.freeze([ + feature("list", ["javdb", "javbus"], ["list.core", "list.auto-page", "list.fold-category", "list.actions"]), + feature("library", ["javdb", "javbus"], ["library.history", "library.keyword-filter", "library.state-actions", "library.blacklist", "library.favorite-actresses"]), + feature("discovery", ["javdb", "javbus"], ["discovery.hit-show", "discovery.top250", "discovery.new-video", "discovery.scheduler"]), + feature("external-bridge", ["javdb", "javbus", "123pan", "javtrailers", "subtitlecat"], ["external-bridge.translation", "external-bridge.115-match", "external-bridge.offline", "external-bridge.123pan", "external-bridge.javtrailers", "detail.subtitle"]), + feature("identity", ["javdb", "javbus"], ["identity.javdb-navigation", "identity.javbus-navigation", "identity.image-search", "identity.actress-info"]), + feature("compatibility", ["javdb", "javbus"], ["compatibility.enhancements"]), +]); diff --git a/src/features/system/catalog.js b/src/features/system/catalog.js index 70e821e8..1662f1ac 100644 --- a/src/features/system/catalog.js +++ b/src/features/system/catalog.js @@ -6,7 +6,7 @@ import { SERVICE } from "../../contracts/tokens.js"; export const systemFeatureManifests = Object.freeze([ defineFeature({ id: "settings", kind: "system", disableable: false, sites: [], routes: [], startup: "on-command", - requires: [SERVICE.diagnostics], contributes: [], providesCommands: ["settings.open"], + requires: [SERVICE.diagnostics], contributes: ["settings.core"], providesCommands: ["settings.open"], activate: () => ({ commands: { "settings.open": () => document.querySelector("#setting-btn, #mini-setting-btn")?.dispatchEvent(new MouseEvent("click", { bubbles: true })) } }), }), defineFeature({ @@ -16,11 +16,11 @@ export const systemFeatureManifests = Object.freeze([ }), defineFeature({ id: "responsive-shell", kind: "system", disableable: false, sites: [], routes: [], startup: "eager", - requires: [SERVICE.profile], contributes: [], providesCommands: [], activate: (/** @type {any} */ deps) => ({ profile: deps[SERVICE.profile].current() }), + requires: [SERVICE.profile], contributes: ["responsive-shell.bottom-bar"], providesCommands: [], activate: (/** @type {any} */ deps) => ({ profile: deps[SERVICE.profile].current() }), }), defineFeature({ id: "stats", kind: "system", disableable: false, sites: ["javdb", "javbus"], routes: [], startup: "idle", - requires: [SERVICE.diagnostics], contributes: [], providesCommands: [], + requires: [SERVICE.diagnostics], contributes: ["stats.dashboard"], providesCommands: [], activate: (/** @type {any} */ deps) => ({ diagnostics: deps[SERVICE.diagnostics] }), }), ]); diff --git a/src/integrations/av123/manifest.js b/src/integrations/av123/manifest.js index bbddada7..b2c9d03e 100644 --- a/src/integrations/av123/manifest.js +++ b/src/integrations/av123/manifest.js @@ -1,11 +1,77 @@ // @ts-check import { defineIntegration } from "../../contracts/manifests.js"; -import { CACHE, PORT, SERVICE } from "../../contracts/tokens.js"; +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { defineMovieDetail, defineMovieRef } from "../../contracts/models.js"; +import { normalizeMovieCarNum } from "../../core/movie-identity.js"; +import { merge123AvCards, parse123AvCards, parse123AvSourceMaxPage, parse123AvVideoInfo } from "./parser.js"; + +const REQUEST_OPTIONS = Object.freeze({ cookiePartition: { topLevelSite: "https://123av.com" } }); + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function create123AvAdapter(http) { + /** @param {string} url @param {any} scope */ + const request = (url, scope) => http.request({ + providerId: "av123", method: "GET", url, responseType: "text", cacheScope: "public", ttlMs: 604_800_000, + requestOptions: REQUEST_OPTIONS, urlPolicy: { trustClass: "builtin-public", hosts: ["123av.com"] }, + }, scope); + return Object.freeze({ + contracts: ["MovieRef", "MovieDetail"], + /** @param {{carNum?: unknown}} movieRef */ + searchUrl(movieRef) { + const carNum = normalizeMovieCarNum(movieRef?.carNum); + return carNum ? `https://123av.com/cn/search?keyword=${encodeURIComponent(carNum)}` : null; + }, + /** @param {{carNum: string, url?: string}} movieRef */ + detailUrl(movieRef) { + const id = normalizeMovieCarNum(movieRef.carNum)?.match(/^FC2-(\d+)$/)?.[1]; + return movieRef.url ? new URL(movieRef.url).href : id ? `https://123av.com/cn/v/fc2-ppv-${id}` : null; + }, + /** @param {string} value */ + matchesUrl(value) { try { return new URL(value).hostname === "123av.com"; } catch { return false; } }, + /** @param {{page?: number, keyword?: string}} query @param {{scope?: any}} [options] */ + async listCatalog(query = {}, options = {}) { + const page = Math.max(1, Number(query.page) || 1), keyword = String(query.keyword || "").trim(); + const sourcePages = keyword ? [1] : [page * 2 - 1, page * 2]; + const urls = sourcePages.map((sourcePage) => keyword + ? `https://123av.com/cn/search?keyword=${encodeURIComponent(keyword)}` + : `https://123av.com/cn/makers/fc2?page=${sourcePage}`); + const responses = await Promise.all(urls.map((url) => request(url, options.scope))); + const lists = responses.map((response, index) => parse123AvCards(response.data, response.finalUrl || urls[index])); + const items = merge123AvCards(lists).map((item) => Object.freeze({ + carNum: item.carNum, title: item.title, url: item.href, + imageUrl: item.imgSrc ? new URL(item.imgSrc, "https://123av.com").href : null, + previewUrl: item.preview ? new URL(item.preview, "https://123av.com").href : null, + providerId: "av123", + })); + const sourceMaxPage = keyword || !responses.length ? null : parse123AvSourceMaxPage(responses[0].data, responses[0].finalUrl || urls[0]); + return Object.freeze({ items, maxPage: sourceMaxPage ? Math.ceil(sourceMaxPage / 2) : null }); + }, + /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ + async resolveMovie(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum); + if (!carNum) return null; + const url = this.searchUrl({ carNum }); + if (!url) return null; + const response = await request(url, options.scope); + const match = parse123AvCards(response.data, response.finalUrl || url).find((item) => normalizeMovieCarNum(item.carNum) === carNum); + return match ? defineMovieRef({ carNum, url: match.href, providerId: "av123" }) : null; + }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum), id = carNum?.match(/^FC2-(\d+)$/)?.[1]; + if (!carNum || (!movieRef.url && !id)) throw new TypeError("123AV movie reference is invalid"); + const url = new URL(movieRef.url || `https://123av.com/cn/v/fc2-ppv-${id}`).href; + const response = await request(url, options.scope), info = parse123AvVideoInfo(response.data, response.finalUrl || url); + if (!info.title) throw new TypeError("123AV detail is malformed"); + return defineMovieDetail({ carNum, title: info.title, releaseDate: info.publishDate || null, url: response.finalUrl || url, providerId: "av123" }); + }, + }); +} export default defineIntegration({ id: "av123", trustClass: "builtin-public", hosts: ["123av.com"], - capabilities: ["movie.search", "movie.detail", "movie.images", "actor.lookup"], - requires: [PORT.http, SERVICE.urlPolicy], createClient: () => Object.freeze({ id: "av123" }), - createAdapter: () => Object.freeze({ contracts: ["MovieRef", "MovieDetail"] }), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail }, quality: "silver", + capabilities: ["movie.search", "movie.detail", "movie.catalog"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => create123AvAdapter(client.http), createHostAdapter: null, + cachePolicy: { "movie.search": "public-7d", "movie.detail": CACHE.externalDetail, "movie.catalog": "public-7d" }, quality: "silver", }); diff --git a/src/integrations/av123/parser.js b/src/integrations/av123/parser.js index 0acb3dd7..ca002c12 100644 --- a/src/integrations/av123/parser.js +++ b/src/integrations/av123/parser.js @@ -1,9 +1,34 @@ // @ts-check +/** @param {any} page */ +function resolveDocument(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelectorAll === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelectorAll === "function") return candidate; + throw new TypeError("123AV document is invalid"); +} + /** 解析 123AV 中文列表或搜索页中的作品卡片。 */ /** @param {any} $page @param {string} [baseUrl] */ export function parse123AvCards($page, baseUrl = "https://123av.com") { const items = new Map(); + if (typeof $page?.find !== "function") { + for (const card of resolveDocument($page).querySelectorAll(".card")) { + const link = card.querySelector('a.card__link[href*="/cn/v/fc2-ppv-"]'); + const href = link?.getAttribute("href"), text = link?.textContent?.trim() ?? ""; + const numberMatch = text.match(/FC2-PPV-(\d+)/i); + if (!href || !numberMatch) continue; + const carNum = `FC2-${numberMatch[1]}`, detailUrl = new URL(href, baseUrl); + detailUrl.hash = ""; + items.set(carNum, { + imgSrc: card.querySelector("img.card__img[src]")?.getAttribute("src") || "", carNum, href: detailUrl.href, + title: text.replace(/^FC2-PPV-\d+\s*[—–-]\s*/i, "").trim(), + preview: card.querySelector(".card__poster[data-preview]")?.getAttribute("data-preview") || null, + }); + } + return [...items.values()]; + } const wrap = /** @type {any} */ (globalThis).jQuery ?? /** @type {any} */ (globalThis).$ ?? $page.constructor; $page.find(".card").each(((/** @type {number} */ index, /** @type {Element} */ element) => { const $card = wrap(element); @@ -36,6 +61,17 @@ export function merge123AvCards(cardLists) { /** 从新版 123AV pager 中读取源站最大页数。 */ /** @param {any} $page @param {string} [baseUrl] */ export function parse123AvSourceMaxPage($page, baseUrl = "https://123av.com") { + if (typeof $page?.find !== "function") { + const document = resolveDocument($page), lastHref = document.querySelector('a[rel="last"]')?.getAttribute("href"); + if (lastHref) { + const page = Number.parseInt(new URL(lastHref, baseUrl).searchParams.get("page") ?? "", 10); + if (Number.isFinite(page)) return page; + } + const inputMax = Number.parseInt(document.querySelector("input.pager__input[max]")?.getAttribute("max") ?? "", 10); + if (Number.isFinite(inputMax)) return inputMax; + const totalMatch = document.querySelector(".pager__total")?.textContent?.match(/(\d[\d,]*)/); + return totalMatch ? Number.parseInt(totalMatch[1].replaceAll(",", ""), 10) : null; + } const lastHref = $page.find('a[rel="last"]').first().attr("href"); if (lastHref) { const page = Number.parseInt(new URL(lastHref, baseUrl).searchParams.get("page") ?? "", 10); @@ -52,9 +88,10 @@ export function parse123AvSourceMaxPage($page, baseUrl = "https://123av.com") { export function parse123AvVideoInfo($page, url) { const idMatch = new URL(url).pathname.match(/fc2-ppv-(\d+)/i); const id = idMatch ? idMatch[1] : null; - const rawTitle = $page.find("h1.watch__title").first().text().trim(); + const nativeDocument = typeof $page?.find === "function" ? null : resolveDocument($page); + const rawTitle = nativeDocument ? nativeDocument.querySelector("h1.watch__title")?.textContent?.trim() ?? "" : $page.find("h1.watch__title").first().text().trim(); const title = id ? rawTitle.replace(new RegExp(`^FC2-PPV-${id}\\s*[—–-]\\s*`, "i"), "").trim() : rawTitle; - const pageText = $page.find("body").text(); + const pageText = nativeDocument ? nativeDocument.body?.textContent ?? "" : $page.find("body").text(); const publishDate = pageText.match(/发布日期\s*(\d{4}-\d{2}-\d{2})/)?.[1] || ""; return { id, publishDate, title, moviePoster: null }; } diff --git a/src/integrations/dmm/manifest.js b/src/integrations/dmm/manifest.js index 6db37804..6779550e 100644 --- a/src/integrations/dmm/manifest.js +++ b/src/integrations/dmm/manifest.js @@ -1,4 +1,28 @@ // @ts-check import { defineIntegration } from "../../contracts/manifests.js"; -import { CACHE, PORT, SERVICE } from "../../contracts/tokens.js"; -export default defineIntegration({ id: "dmm", trustClass: "builtin-public", hosts: ["dmm.co.jp"], capabilities: ["movie.preview"], requires: [PORT.http, SERVICE.urlPolicy], createClient: () => Object.freeze({ id: "dmm" }), createAdapter: () => Object.freeze({ contracts: ["MoviePreview"] }), createHostAdapter: null, cachePolicy: { "movie.preview": CACHE.externalDetail }, quality: "silver" }); +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { parseDmmPreview } from "./parser.js"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function createDmmAdapter(http) { + return Object.freeze({ + contracts: ["MoviePreview"], + /** @param {{url: string}} movieRef @param {{scope?: any}} [options] */ + async getPreview(movieRef, options = {}) { + const url = new URL(movieRef.url); + const response = await http.request({ + providerId: "dmm", method: "GET", url: url.href, responseType: "text", + cacheScope: "public", ttlMs: 604_800_000, + urlPolicy: { trustClass: "builtin-public", hosts: ["dmm.co.jp"] }, + }, options.scope); + return parseDmmPreview(response.data, response.finalUrl || url.href); + }, + }); +} + +export default defineIntegration({ + id: "dmm", trustClass: "builtin-public", hosts: ["dmm.co.jp"], capabilities: ["movie.preview"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createDmmAdapter(client.http), createHostAdapter: null, + cachePolicy: { "movie.preview": CACHE.externalDetail }, quality: "silver", +}); diff --git a/src/integrations/dmm/parser.js b/src/integrations/dmm/parser.js index 918f7273..3ee8b714 100644 --- a/src/integrations/dmm/parser.js +++ b/src/integrations/dmm/parser.js @@ -1,8 +1,19 @@ // @ts-check +/** @param {any} page */ +function resolveDocument(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelector === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelector === "function") return candidate; + throw new TypeError("DMM preview document is invalid"); +} + /** @param {any} page @param {string} baseUrl */ export function parseDmmPreview(page, baseUrl) { - const value = page.find("[data-video-url]").first().attr("data-video-url") ?? page.find("video source[src]").first().attr("src"); + const document = resolveDocument(page); + const value = document.querySelector("[data-video-url]")?.getAttribute("data-video-url") + ?? document.querySelector("video source[src]")?.getAttribute("src"); if (!value) throw new TypeError("DMM preview URL is missing"); const url = new URL(value, baseUrl); if (url.protocol !== "https:") throw new TypeError("DMM preview must use HTTPS"); diff --git a/src/integrations/fc2content/manifest.js b/src/integrations/fc2content/manifest.js new file mode 100644 index 00000000..2b568298 --- /dev/null +++ b/src/integrations/fc2content/manifest.js @@ -0,0 +1,34 @@ +// @ts-check +import { defineIntegration } from "../../contracts/manifests.js"; +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { parseFc2ContentImages } from "./parser.js"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function createFc2ContentAdapter(http) { + return Object.freeze({ + contracts: ["Screenshot"], + /** @param {{carNum: string}} movieRef */ + detailUrl(movieRef) { + const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; + return id ? `https://adult.contents.fc2.com/article/${id}/` : null; + }, + /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ + async getImages(movieRef, options = {}) { + const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; + if (!id) return []; + const url = `https://adult.contents.fc2.com/article/${id}/`; + const response = await http.request({ + providerId: "fc2content", method: "GET", url, responseType: "text", cacheScope: "public", ttlMs: 604_800_000, + headers: { referer: url }, urlPolicy: { trustClass: "builtin-public", hosts: ["adult.contents.fc2.com"] }, + }, options.scope); + return parseFc2ContentImages(response.data, response.finalUrl || url); + }, + }); +} + +export default defineIntegration({ + id: "fc2content", trustClass: "builtin-public", hosts: ["adult.contents.fc2.com"], capabilities: ["movie.images"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createFc2ContentAdapter(client.http), createHostAdapter: null, + cachePolicy: { "movie.images": CACHE.externalDetail }, quality: "silver", +}); diff --git a/src/integrations/fc2content/parser.js b/src/integrations/fc2content/parser.js new file mode 100644 index 00000000..55b0c9e4 --- /dev/null +++ b/src/integrations/fc2content/parser.js @@ -0,0 +1,11 @@ +// @ts-check + +/** @param {string} html @param {string} baseUrl */ +export function parseFc2ContentImages(html, baseUrl) { + const document = new DOMParser().parseFromString(html, "text/html"), images = new Set(); + for (const image of document.querySelectorAll(".items_article_SampleImagesArea img[src]")) { + const url = new URL(image.getAttribute("src") || "", baseUrl); + if (url.protocol === "https:") images.add(url.href); + } + return Object.freeze([...images].map((url) => Object.freeze({ url, providerId: "fc2content" }))); +} diff --git a/src/integrations/fc2ppvdb/manifest.js b/src/integrations/fc2ppvdb/manifest.js index e0acc16d..6e406e5a 100644 --- a/src/integrations/fc2ppvdb/manifest.js +++ b/src/integrations/fc2ppvdb/manifest.js @@ -1,4 +1,42 @@ // @ts-check import { defineIntegration } from "../../contracts/manifests.js"; -import { CACHE, PORT, SERVICE } from "../../contracts/tokens.js"; -export default defineIntegration({ id: "fc2ppvdb", trustClass: "builtin-public", hosts: ["fc2ppvdb.com"], capabilities: ["movie.detail", "movie.images"], requires: [PORT.http, SERVICE.urlPolicy], createClient: () => Object.freeze({ id: "fc2ppvdb" }), createAdapter: () => Object.freeze({ contracts: ["MovieDetail"] }), createHostAdapter: null, cachePolicy: { "movie.detail": CACHE.externalDetail }, quality: "silver" }); +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { parseFc2PpvDbDetail, parseFc2PpvDbPeople } from "./parser.js"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function createFc2PpvDbAdapter(http) { + /** @param {{carNum: string, url?: string}} movieRef */ + const resolveUrl = (movieRef) => { + const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; + if (!id) throw new TypeError("FC2PPVDB movie identifier is invalid"); + return new URL(movieRef.url || `https://fc2ppvdb.com/articles/${id}`); + }; + /** @param {URL} url @param {any} scope */ + const request = (url, scope) => http.request({ + providerId: "fc2ppvdb", method: "GET", url: url.href, responseType: "text", + cacheScope: "public", ttlMs: 604_800_000, + urlPolicy: { trustClass: "builtin-public", hosts: ["fc2ppvdb.com"] }, + }, scope); + return Object.freeze({ + contracts: ["MovieDetail", "MovieCredits"], + /** @param {{carNum: string, url?: string}} movieRef */ + detailUrl(movieRef) { return resolveUrl(movieRef).href; }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const url = resolveUrl(movieRef), response = await request(url, options.scope); + return parseFc2PpvDbDetail(response.data, response.finalUrl || url.href); + }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getPeople(movieRef, options = {}) { + const url = resolveUrl(movieRef), response = await request(url, options.scope); + return parseFc2PpvDbPeople(response.data, response.finalUrl || url.href); + }, + }); +} + +export default defineIntegration({ + id: "fc2ppvdb", trustClass: "builtin-public", hosts: ["fc2ppvdb.com"], capabilities: ["movie.detail", "movie.credits"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createFc2PpvDbAdapter(client.http), createHostAdapter: null, + cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.credits": CACHE.externalDetail }, quality: "silver", +}); diff --git a/src/integrations/fc2ppvdb/parser.js b/src/integrations/fc2ppvdb/parser.js index c0dfcd9f..54bb13ba 100644 --- a/src/integrations/fc2ppvdb/parser.js +++ b/src/integrations/fc2ppvdb/parser.js @@ -1,11 +1,35 @@ // @ts-check import { defineMovieDetail } from "../../contracts/models.js"; import { normalizeCarNum } from "../../core/movie-identity.js"; + +/** @param {any} page */ +function resolveDocument(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelector === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelector === "function") return candidate; + throw new TypeError("FC2PPVDB detail document is invalid"); +} + /** @param {any} page @param {string} url */ export function parseFc2PpvDbDetail(page, url) { const id = new URL(url).pathname.match(/(?:articles?|videos?)\/(\d+)/i)?.[1]; const carNum = normalizeCarNum(id ? `FC2-${id}` : ""); - const title = page.find("h1").first().text().trim(); + const title = resolveDocument(page).querySelector("h1")?.textContent?.trim() ?? ""; if (!carNum || !title) throw new TypeError("FC2PPVDB detail is malformed"); return defineMovieDetail({ carNum, title, url: new URL(url).href }); } + +/** @param {any} page @param {string} url */ +export function parseFc2PpvDbPeople(page, url) { + const document = resolveDocument(page), baseUrl = new URL(url); + const rows = [...document.querySelectorAll("div")]; + const actressRow = rows.find((item) => item.textContent?.trim().startsWith("女優:")); + const sellerRow = rows.find((item) => item.textContent?.trim().startsWith("販売者:")); + const actors = [...(actressRow?.querySelectorAll("a[href]") ?? [])].map((link) => Object.freeze({ + name: link.textContent?.trim() || "", url: new URL(link.getAttribute("href") || "", baseUrl).href, + })).filter((actor) => actor.name); + const sellerLink = sellerRow?.querySelector("a[href]"); + const seller = sellerLink ? Object.freeze({ name: sellerLink.textContent?.trim() || "", url: new URL(sellerLink.getAttribute("href") || "", baseUrl).href }) : null; + return Object.freeze({ actors: Object.freeze(actors), seller }); +} diff --git a/src/integrations/google-translate/manifest.js b/src/integrations/google-translate/manifest.js new file mode 100644 index 00000000..02fd76ed --- /dev/null +++ b/src/integrations/google-translate/manifest.js @@ -0,0 +1,40 @@ +// @ts-check +import { defineIntegration } from "../../contracts/manifests.js"; +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { parseGoogleTranslation } from "./parser.js"; + +const ENDPOINT = "https://translate-pa.googleapis.com/v1/translate"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function createGoogleTranslateAdapter(http) { + return Object.freeze({ + contracts: ["Translation"], + /** @param {string} text @param {{sourceLanguage?: string, targetLanguage?: string, scope?: any}} [options] */ + async translate(text, options = {}) { + if (!String(text || "").trim()) throw new TypeError("Translation text cannot be empty"); + const url = new URL(ENDPOINT); + url.search = new URLSearchParams({ + "params.client": "gtx", + dataTypes: "TRANSLATION", + key: "AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA", + "query.sourceLanguage": options.sourceLanguage ?? "ja", + "query.targetLanguage": options.targetLanguage ?? "zh-CN", + "query.text": text, + }).toString(); + const response = await http.request({ + providerId: "google-translate", method: "GET", url: url.href, responseType: "json", + cacheScope: "public", ttlMs: 2_592_000_000, + urlPolicy: { trustClass: "builtin-public", hosts: ["translate-pa.googleapis.com"] }, + }, options.scope); + return parseGoogleTranslation(response.data); + }, + }); +} + +export default defineIntegration({ + id: "google-translate", trustClass: "builtin-public", hosts: ["translate-pa.googleapis.com"], + capabilities: ["text.translate"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createGoogleTranslateAdapter(client.http), createHostAdapter: null, + cachePolicy: { "text.translate": CACHE.externalDetail }, quality: "silver", +}); diff --git a/src/integrations/google-translate/parser.js b/src/integrations/google-translate/parser.js new file mode 100644 index 00000000..f38b81e7 --- /dev/null +++ b/src/integrations/google-translate/parser.js @@ -0,0 +1,9 @@ +// @ts-check + +/** @param {unknown} payload */ +export function parseGoogleTranslation(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new TypeError("Translation response is invalid"); + const translation = /** @type {{translation?: unknown}} */ (payload).translation; + if (typeof translation !== "string" || !translation.trim()) throw new TypeError("Translation text is missing"); + return translation; +} diff --git a/src/integrations/javbus/manifest.js b/src/integrations/javbus/manifest.js index f1a38d2e..33cf3f8b 100644 --- a/src/integrations/javbus/manifest.js +++ b/src/integrations/javbus/manifest.js @@ -1,12 +1,37 @@ // @ts-check import { defineIntegration } from "../../contracts/manifests.js"; -import { CACHE, PORT, SERVICE } from "../../contracts/tokens.js"; +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { normalizeMovieCarNum } from "../../core/movie-identity.js"; +import { parseJavBusMovieDetail } from "./parser.js"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function createJavBusAdapter(http) { + return Object.freeze({ + contracts: ["MovieDetail", "Screenshot"], + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum); + if (!carNum) throw new TypeError("JavBus movie reference is invalid"); + const url = new URL(movieRef.url || `https://www.javbus.com/${encodeURIComponent(carNum)}`).href; + const response = await http.request({ + providerId: "javbus", method: "GET", url, responseType: "text", cacheScope: "public", ttlMs: 604_800_000, + urlPolicy: { trustClass: "builtin-public", hosts: ["javbus.com"] }, + }, options.scope); + return parseJavBusMovieDetail(response.data, response.finalUrl || url); + }, + /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ + async getImages(movieRef, options = {}) { + const detail = await this.getDetail(movieRef, options); + return detail.coverUrl ? [Object.freeze({ url: detail.coverUrl, providerId: "javbus" })] : []; + }, + }); +} export default defineIntegration({ id: "javbus", trustClass: "builtin-public", hosts: ["javbus.com"], - capabilities: ["movie.search", "movie.detail", "movie.images"], - requires: [PORT.http, SERVICE.urlPolicy], createClient: () => Object.freeze({ id: "javbus" }), - createAdapter: () => Object.freeze({ contracts: ["MovieRef", "MovieDetail"] }), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail }, quality: "silver", + capabilities: ["movie.detail", "movie.images"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createJavBusAdapter(client.http), createHostAdapter: null, + cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.images": CACHE.externalDetail }, quality: "silver", }); diff --git a/src/integrations/javbus/parser.js b/src/integrations/javbus/parser.js index b65c71be..1e59f3af 100644 --- a/src/integrations/javbus/parser.js +++ b/src/integrations/javbus/parser.js @@ -1,12 +1,45 @@ // @ts-check -import { defineMovieRef } from "../../contracts/models.js"; +import { defineMovieDetail, defineMovieRef } from "../../contracts/models.js"; import { normalizeCarNum } from "../../core/movie-identity.js"; +/** @param {any} page */ +function resolveDocument(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelector === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelector === "function") return candidate; + throw new TypeError("JavBus document is invalid"); +} + /** @param {any} page @param {string} url */ -export function parseJavBusMovieRef(page, url) { - const carNum = normalizeCarNum(page.find("span.header:contains('識別碼:')").parent().text().replace("識別碼:", "")) +function readMovieRef(page, url) { + if (typeof page?.find === "function") { + return normalizeCarNum(page.find("span.header:contains('識別碼:')").parent().text().replace("識別碼:", "")) + ?? normalizeCarNum(new URL(url).pathname.split("/").filter(Boolean).pop()); + } + const document = resolveDocument(page); + const label = [...document.querySelectorAll("span.header")].find((element) => element.textContent?.includes("識別碼")); + return normalizeCarNum(label?.parentElement?.textContent?.replace("識別碼:", "")) ?? normalizeCarNum(new URL(url).pathname.split("/").filter(Boolean).pop()); +} + +/** @param {any} page @param {string} url */ +export function parseJavBusMovieRef(page, url) { + const carNum = readMovieRef(page, url); if (!carNum) throw new TypeError("JavBus movie identifier is missing"); return defineMovieRef({ carNum, url: new URL(url).href }); } + +/** @param {any} page @param {string} url */ +export function parseJavBusMovieDetail(page, url) { + const movieRef = parseJavBusMovieRef(page, url); + const nativeDocument = typeof page?.find === "function" ? null : resolveDocument(page); + const title = nativeDocument + ? nativeDocument.querySelector("h3")?.textContent?.trim() || movieRef.carNum + : page.find("h3").first().text().trim() || movieRef.carNum; + const coverValue = nativeDocument + ? nativeDocument.querySelector(".bigImage img[src], a.bigImage img[src]")?.getAttribute("src") + : page.find(".bigImage img[src], a.bigImage img[src]").first().attr("src"); + return defineMovieDetail({ ...movieRef, title, coverUrl: coverValue ? new URL(coverValue, url).href : null, providerId: "javbus" }); +} diff --git a/src/integrations/javdb/manifest.js b/src/integrations/javdb/manifest.js index e9d69e2b..9e614337 100644 --- a/src/integrations/javdb/manifest.js +++ b/src/integrations/javdb/manifest.js @@ -1,11 +1,105 @@ // @ts-check + import { defineIntegration } from "../../contracts/manifests.js"; -import { CACHE, PORT, SERVICE } from "../../contracts/tokens.js"; +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { normalizeMovieCarNum } from "../../core/movie-identity.js"; +import { createJavDbSignature } from "./signature.js"; + +const API_ORIGIN = "https://jdforrepam.com"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http @param {() => string} sign */ +export function createJavDbAdapter(http, sign = createJavDbSignature) { + /** @param {string} path @param {Record} query @param {{scope?: any, ttlMs?: number}} options @param {Record} [headers] */ + const request = async (path, query, options = {}, headers = {}) => { + const url = new URL(path, API_ORIGIN); + Object.entries(query).forEach(([key, value]) => url.searchParams.set(key, String(value))); + const response = await http.request({ + providerId: "javdb", method: "GET", url: url.href, responseType: "json", + headers: { jdSignature: sign(), ...headers }, cacheScope: "public", ttlMs: options.ttlMs ?? 86_400_000, + urlPolicy: { trustClass: "builtin-public", hosts: ["jdforrepam.com"] }, + }, options.scope); + return response.data; + }; + return Object.freeze({ + contracts: ["MovieRef", "MovieDetail", "Actor", "Magnet", "Review", "RelatedList"], + /** @param {Record} movieRef @param {{scope?: any}} [options] */ + async resolveMovie(movieRef, options = {}) { + const carNum = normalizeMovieCarNum(movieRef.carNum); + if (!carNum) return null; + const payload = await request("/api/v2/search", { q: carNum, page: 1, type: "movie", limit: 20, movie_type: "all", from_recent: "false", movie_filter_by: "all", movie_sort_by: "relevance" }, { ...options, ttlMs: 604_800_000 }, { "user-agent": "Dart/3.5 (dart:io)", "accept-language": "zh-TW", host: "jdforrepam.com" }); + if (!Array.isArray(payload?.data?.movies)) throw new Error(payload?.message || "JavDB search response is invalid"); + const match = payload.data.movies.find((/** @type {any} */ movie) => normalizeMovieCarNum(movie.number) === carNum); + return match?.id ? Object.freeze({ carNum, movieId: String(match.id), providerId: "javdb" }) : null; + }, + /** @param {Record} movieRef @param {{scope?: any}} [options] */ + async getDetail(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) throw new TypeError("JavDB movie id is required"); + const payload = await request(`/api/v4/movies/${encodeURIComponent(movieId)}`, {}, { ...options, ttlMs: 604_800_000 }); + const movie = payload?.data?.movie; + if (!movie?.number) throw new Error(payload?.message || "JavDB detail response is invalid"); + const imageUrls = Array.isArray(movie.preview_images) ? movie.preview_images.map((/** @type {any} */ image) => String(image.large_url || "").replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com")).filter(Boolean) : []; + return Object.freeze({ + movieId: String(movie.id || movieId), carNum: normalizeMovieCarNum(movie.number), title: String(movie.origin_title || movie.title || ""), + actors: Array.isArray(movie.actors) ? movie.actors.map((/** @type {any} */ actor) => Object.freeze({ id: String(actor.id), name: String(actor.name || ""), gender: Number(actor.gender) })) : [], + duration: Number(movie.duration) || null, score: Number(movie.score) || 0, releaseDate: movie.release_date || null, + watchedCount: Number(movie.watched_count) || 0, imageUrls: Object.freeze(imageUrls), providerId: "javdb", + }); + }, + /** @param {Record} movieRef @param {{scope?: any}} [options] */ + async listMagnets(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) return []; + const payload = await request(`/api/v1/movies/${encodeURIComponent(movieId)}/magnets`, {}, options); + if (!Array.isArray(payload?.data?.magnets)) throw new Error(payload?.message || "JavDB magnet response is invalid"); + return payload.data.magnets.map((/** @type {any} */ item) => Object.freeze({ + hash: String(item.hash || ""), title: String(item.name || ""), hasHdTag: Boolean(item.hd), hasSubtitleTag: Boolean(item.cnsub), + createdAt: item.created_at || null, seeders: Number(item.seeders) || 0, sizeMb: Number(item.size) || 0, + fileCount: Number(item.files_count) || 0, providerId: "javdb", + })); + }, + /** @param {{period?: string, filter?: string, scope?: any}} [options] */ + async listRankings(options = {}) { + const payload = await request("/api/v1/rankings/playback", { period: options.period || "daily", filter_by: options.filter || "high_score" }, options); + if (!Array.isArray(payload?.data?.movies)) throw new Error(payload?.message || "JavDB ranking response is invalid"); + return payload.data.movies.map((/** @type {any} */ movie) => Object.freeze({ + movieId: String(movie.id), carNum: normalizeMovieCarNum(movie.number), title: String(movie.origin_title || ""), + coverUrl: String(movie.cover_url || "").replace(/https:\/\/[^/]+\/rhe951l4q/, "https://c0.jdbstatic.com"), releaseDate: movie.release_date || null, + hasSubtitle: Boolean(movie.has_cnsub), magnetCount: Number(movie.magnets_count) || 0, newMagnets: Boolean(movie.new_magnets), providerId: "javdb", + })); + }, + /** @param {Record} movieRef @param {{scope?: any, page?: number, limit?: number}} [options] */ + async listReviews(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) return []; + const payload = await request(`/api/v1/movies/${encodeURIComponent(movieId)}/reviews`, { page: options.page ?? 1, sort_by: "hotly", limit: options.limit ?? 20 }, options); + if (!Array.isArray(payload?.data?.reviews)) throw new Error(payload?.message || "JavDB review response is invalid"); + return payload.data.reviews.map((/** @type {any} */ review) => Object.freeze({ + author: String(review.username || "匿名用户"), content: String(review.content || ""), + score: Number(review.score) || 0, createdAt: review.created_at || null, + likes: Number(review.likes_count) || 0, + })); + }, + /** @param {Record} movieRef @param {{scope?: any, page?: number, limit?: number}} [options] */ + async listRelated(movieRef, options = {}) { + const movieId = String(movieRef.movieId || "").trim(); + if (!movieId) return []; + const payload = await request("/api/v1/lists/related", { movie_id: movieId, page: options.page ?? 1, limit: options.limit ?? 20 }, options); + if (!Array.isArray(payload?.data?.lists)) throw new Error(payload?.message || "JavDB related response is invalid"); + return payload.data.lists.map((/** @type {any} */ item) => Object.freeze({ + id: String(item.id), name: String(item.name || "未命名清单"), movieCount: Number(item.movies_count) || 0, + collectionCount: Number(item.collections_count) || 0, viewCount: Number(item.views_count) || 0, + createdAt: item.created_at || null, + })); + }, + }); +} export default defineIntegration({ - id: "javdb", trustClass: "builtin-public", hosts: ["javdb.com"], - capabilities: ["movie.search", "movie.detail", "movie.state", "actor.lookup"], - requires: [PORT.http, SERVICE.urlPolicy], createClient: () => Object.freeze({ id: "javdb" }), - createAdapter: () => Object.freeze({ contracts: ["MovieRef", "MovieDetail", "Actor"] }), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail }, quality: "silver", + id: "javdb", trustClass: "builtin-public", hosts: ["javdb.com", "jdforrepam.com"], + capabilities: ["movie.search", "movie.detail", "movie.magnets", "movie.ranking", "movie.state", "movie.reviews", "movie.related", "actor.lookup"], + requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createJavDbAdapter(client.http), createHostAdapter: null, + cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.magnets": "public-1d", "movie.ranking": "public-1d", "movie.reviews": "public-1d", "movie.related": "public-1d" }, quality: "silver", }); diff --git a/src/integrations/javdb/signature.js b/src/integrations/javdb/signature.js new file mode 100644 index 00000000..ae1dba99 --- /dev/null +++ b/src/integrations/javdb/signature.js @@ -0,0 +1,15 @@ +// @ts-check + +let signatureSecond = 0; +let signatureValue = ""; + +/** Generate the JavDB request signature with the userscript vendor MD5 runtime. */ +export function createJavDbSignature() { + const now = Math.floor(Date.now() / 1_000); + if (signatureValue && now - signatureSecond <= 20) return signatureValue; + const md5Runtime = /** @type {any} */ (globalThis).md5; + if (typeof md5Runtime !== "function") throw new Error("Missing userscript vendor runtime: md5"); + signatureSecond = now; + signatureValue = `${now}.lpw6vgqzsp.${md5Runtime(`${now}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; + return signatureValue; +} diff --git a/src/integrations/javstore/manifest.js b/src/integrations/javstore/manifest.js index a1fb9f66..2f047744 100644 --- a/src/integrations/javstore/manifest.js +++ b/src/integrations/javstore/manifest.js @@ -1,10 +1,41 @@ // @ts-check import { defineIntegration } from "../../contracts/manifests.js"; -import { CACHE, PORT, SERVICE } from "../../contracts/tokens.js"; +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { parseJavStorePreview, parseJavStoreSearch } from "./parser.js"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function createJavStoreAdapter(http) { + /** @param {string} url @param {any} scope */ + const request = (url, scope) => http.request({ + providerId: "javstore", method: "GET", url, responseType: "text", cacheScope: "public", ttlMs: 604_800_000, + urlPolicy: { trustClass: "builtin-public", hosts: ["javstore.net"] }, + }, scope); + return Object.freeze({ + contracts: ["Screenshot"], + /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ + async getImages(movieRef, options = {}) { + const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(movieRef.carNum || "")}`; + const search = await request(searchUrl, options.scope); + const candidates = parseJavStoreSearch(search.data, movieRef.carNum, search.finalUrl || searchUrl); + if (!candidates.length) return []; + for (const candidate of candidates) { + try { + const detail = await request(candidate, options.scope); + const imageUrl = parseJavStorePreview(detail.data, detail.finalUrl || candidate); + if (imageUrl) return [Object.freeze({ url: imageUrl, providerId: "javstore" })]; + } catch (error) { + if (!(error && typeof error === "object" && "code" in error && error.code === "NOT_FOUND")) throw error; + } + } + return []; + }, + }); +} export default defineIntegration({ id: "javstore", trustClass: "builtin-public", hosts: ["javstore.net"], - capabilities: ["movie.images"], requires: [PORT.http, SERVICE.urlPolicy], - createClient: () => Object.freeze({ id: "javstore" }), createAdapter: () => Object.freeze({ contracts: ["Screenshot"] }), + capabilities: ["movie.images"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createJavStoreAdapter(client.http), createHostAdapter: null, cachePolicy: { "movie.images": CACHE.externalDetail }, quality: "silver", }); diff --git a/src/integrations/javstore/parser.js b/src/integrations/javstore/parser.js index d6541ff0..49b042fa 100644 --- a/src/integrations/javstore/parser.js +++ b/src/integrations/javstore/parser.js @@ -2,6 +2,15 @@ import { normalizeCarNum } from "../../core/movie-identity.js"; +/** @param {any} page */ +function resolveDocument(page) { + if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); + if (typeof page?.querySelectorAll === "function") return page; + const candidate = page?.[0] ?? page?.get?.(0); + if (typeof candidate?.querySelectorAll === "function") return candidate; + throw new TypeError("JavStore document is invalid"); +} + /** 规范 JavStore 资源地址,并升级其自有域名的 HTTP 链接。 */ /** @param {unknown} value @param {string} [baseUrl] */ export function normalizeJavStoreAssetUrl(value, baseUrl = "https://javstore.net") { @@ -21,18 +30,29 @@ export function normalizeJavStoreAssetUrl(value, baseUrl = "https://javstore.net export function parseJavStoreSearch($searchPage, carNum, baseUrl = "https://javstore.net") { const normalizedCarNum = normalizeCarNum(carNum); if (!normalizedCarNum) return []; - const wrap = /** @type {any} */ (globalThis).jQuery ?? /** @type {any} */ (globalThis).$ ?? $searchPage.constructor; - return $searchPage.find('a[href$="-pn.html"]').filter(((/** @type {number} */ index, /** @type {Element} */ element) => - wrap(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()) - )).map(((/** @type {number} */ index, /** @type {Element} */ element) => new URL(wrap(element).attr("href"), baseUrl).href)).get(); + if (typeof $searchPage?.find === "function") { + const wrap = /** @type {any} */ (globalThis).jQuery ?? /** @type {any} */ (globalThis).$ ?? $searchPage.constructor; + return $searchPage.find('a[href$="-pn.html"]') + .filter(((/** @type {number} */ index, /** @type {Element} */ element) => wrap(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()))) + .map(((/** @type {number} */ index, /** @type {Element} */ element) => new URL(wrap(element).attr("href"), baseUrl).href)).get(); + } + return [...resolveDocument($searchPage).querySelectorAll('a[href$="-pn.html"]')] + .filter((element) => element.textContent?.trim().toUpperCase().includes(normalizedCarNum.toUpperCase())) + .map((element) => new URL(element.getAttribute("href") || "", baseUrl).href); } /** 提取 JavStore 详情页 CLICK HERE! 对应的绝对预览图地址。 */ /** @param {any} $detailPage @param {string} detailUrl */ export function parseJavStorePreview($detailPage, detailUrl) { - const wrap = /** @type {any} */ (globalThis).jQuery ?? /** @type {any} */ (globalThis).$ ?? $detailPage.constructor; - /** @param {number} index @param {Element} element */ - const isPreviewLink = (index, element) => "CLICK HERE!" === wrap(element).text().trim(); - const previewHref = $detailPage.find("a").filter(isPreviewLink).first().attr("href"); + if (typeof $detailPage?.find === "function") { + const wrap = /** @type {any} */ (globalThis).jQuery ?? /** @type {any} */ (globalThis).$ ?? $detailPage.constructor; + const link = $detailPage.find("a").filter(((/** @type {number} */ index, /** @type {Element} */ element) => "CLICK HERE!" === wrap(element).text().trim())).first(); + const previewHref = link.attr("href"); + if (!previewHref) return null; + const previewUrl = normalizeJavStoreAssetUrl(previewHref, detailUrl); + return previewUrl ? previewUrl.replace(".th", "") : null; + } + const previewHref = [...resolveDocument($detailPage).querySelectorAll("a")] + .find((element) => element.textContent?.trim() === "CLICK HERE!")?.getAttribute("href"); if (!previewHref) return null; const previewUrl = normalizeJavStoreAssetUrl(previewHref, detailUrl); return previewUrl ? previewUrl.replace(".th", "") : null; diff --git a/src/integrations/subtitlecat/manifest.js b/src/integrations/subtitlecat/manifest.js index 3f17ec70..50e4b753 100644 --- a/src/integrations/subtitlecat/manifest.js +++ b/src/integrations/subtitlecat/manifest.js @@ -1,4 +1,16 @@ // @ts-check import { defineIntegration } from "../../contracts/manifests.js"; -import { PORT, SERVICE } from "../../contracts/tokens.js"; -export default defineIntegration({ id: "subtitlecat", trustClass: "builtin-public", hosts: ["subtitlecat.com"], capabilities: ["subtitle.search"], requires: [PORT.http, SERVICE.urlPolicy], createClient: () => Object.freeze({ id: "subtitlecat" }), createAdapter: () => Object.freeze({ contracts: ["Subtitle"] }), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); +export function createSubtitleCatAdapter() { + return Object.freeze({ + contracts: ["Subtitle"], + /** @param {{carNum?: unknown}} movieRef */ + detailUrl(movieRef) { + const carNum = String(movieRef?.carNum || "").trim(); + if (!carNum) return null; + const url = new URL("/index.php", "https://subtitlecat.com"); + url.searchParams.set("search", carNum); + return url.href; + }, + }); +} +export default defineIntegration({ id: "subtitlecat", trustClass: "builtin-public", hosts: ["subtitlecat.com"], capabilities: ["subtitle.search"], requires: [], createClient: () => Object.freeze({ id: "subtitlecat" }), createAdapter: () => createSubtitleCatAdapter(), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); diff --git a/src/integrations/wikipedia/manifest.js b/src/integrations/wikipedia/manifest.js new file mode 100644 index 00000000..583d0221 --- /dev/null +++ b/src/integrations/wikipedia/manifest.js @@ -0,0 +1,29 @@ +// @ts-check +import { defineIntegration } from "../../contracts/manifests.js"; +import { CACHE, SERVICE } from "../../contracts/tokens.js"; +import { parseWikipediaActressInfo } from "./parser.js"; + +/** @param {{request: (options: Record, scope?: any) => Promise}} http */ +export function createWikipediaAdapter(http) { + return Object.freeze({ + contracts: ["ActressInfo"], + /** @param {string} name */ + profileUrl(name) { return new URL(`/wiki/${encodeURIComponent(String(name))}`, "https://ja.wikipedia.org").href; }, + /** @param {string} name @param {{scope?: any}} [options] */ + async lookup(name, options = {}) { + const url = this.profileUrl(name); + const response = await http.request({ + providerId: "wikipedia", method: "GET", url, responseType: "text", cacheScope: "public", ttlMs: 604_800_000, + urlPolicy: { trustClass: "builtin-public", hosts: ["ja.wikipedia.org"] }, + }, options.scope); + return parseWikipediaActressInfo(response.data, response.finalUrl || url); + }, + }); +} + +export default defineIntegration({ + id: "wikipedia", trustClass: "builtin-public", hosts: ["ja.wikipedia.org"], capabilities: ["person.actress-info"], requires: [SERVICE.http], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http] }), + createAdapter: (/** @type {any} */ client) => createWikipediaAdapter(client.http), createHostAdapter: null, + cachePolicy: { "person.actress-info": CACHE.externalDetail }, quality: "silver", +}); diff --git a/src/integrations/wikipedia/parser.js b/src/integrations/wikipedia/parser.js new file mode 100644 index 00000000..82d2d977 --- /dev/null +++ b/src/integrations/wikipedia/parser.js @@ -0,0 +1,29 @@ +// @ts-check + +/** @param {string} value */ +function compact(value) { return value.replace(/\s+/g, " ").trim(); } + +/** @param {string} html @param {string} url */ +export function parseWikipediaActressInfo(html, url) { + const document = new DOMParser().parseFromString(html, "text/html"); + const birthday = compact(document.querySelector('a[title="誕生日"]')?.closest("tr")?.querySelector("td")?.textContent ?? ""); + const ageCell = [...document.querySelectorAll("th")].find((item) => item.textContent?.includes("現年齢"))?.parentElement?.querySelector("td"); + const heightCell = document.querySelector('a[title="身長"]')?.closest("tr")?.querySelector("td"); + const weightCell = document.querySelector('a[title="体重"]')?.closest("tr")?.querySelector("td"); + const sizesCell = document.querySelector('a[title="スリーサイズ"]')?.closest("tr")?.querySelector("td"); + const braCell = [...document.querySelectorAll("th")].find((item) => item.textContent?.includes("ブラサイズ"))?.nextElementSibling; + if (!birthday && !heightCell && !sizesCell) throw new TypeError("Wikipedia actress information is missing"); + const age = Number.parseInt(compact(ageCell?.textContent ?? ""), 10); + const height = compact(heightCell?.textContent ?? "").split(" ")[0]; + const weightParts = compact(weightCell?.textContent ?? "").split("/"); + const weight = compact(weightParts.at(-1) ?? "").replace(/^―\s*kg$/, ""); + return Object.freeze({ + birthday, + age: Number.isFinite(age) ? `${age}岁` : "", + height: height && !height.endsWith("cm") ? `${height}cm` : height, + weight, + threeSizeText: compact(sizesCell?.textContent ?? "").replace("cm", "").trim(), + braSize: compact(braCell?.textContent ?? ""), + url: new URL(url).href, + }); +} diff --git a/src/platform/hosts/javbus-host-adapter.js b/src/platform/hosts/javbus-host-adapter.js index 3f8bf304..2e6b5c10 100644 --- a/src/platform/hosts/javbus-host-adapter.js +++ b/src/platform/hosts/javbus-host-adapter.js @@ -9,8 +9,16 @@ export class JavBusHostAdapter { return carNum ? Object.freeze({ carNum, url: this.location.href, site: "javbus" }) : null; } locateListRoot() { return this.document.querySelector(".masonry"); } - locateDetailRoot() { return this.document.querySelector(".container .row.movie"); } - locateDetailSlots() { return Object.freeze({ summary: this.locateDetailRoot(), resources: this.locateNativeMagnets(), reviews: this.document.querySelector("#reviews") }); } + locateDetailRoot() { return this.locateNativeMagnets()?.closest(".container") ?? this.document.querySelector(".container .row.movie")?.parentElement ?? null; } + locateDetailSlots() { + const root = this.locateDetailRoot(); + return Object.freeze({ + summary: root?.querySelector('[data-jhs-slot="summary-actions"]') ?? root?.querySelector(".row.movie") ?? null, + resources: this.locateNativeMagnets(), + reviews: root?.querySelector('[data-jhs-slot="reviews"]') ?? this.document.querySelector("#reviews"), + related: root?.querySelector('[data-jhs-slot="related"]'), + }); + } locateNativeGallery() { return this.document.querySelector("#sample-waterfall, .sample-box"); } locateNativeMagnets() { return this.document.querySelector("#magnet-table"); } } diff --git a/src/platform/hosts/javdb-host-adapter.js b/src/platform/hosts/javdb-host-adapter.js index 4ec0eb94..2f2d9c42 100644 --- a/src/platform/hosts/javdb-host-adapter.js +++ b/src/platform/hosts/javdb-host-adapter.js @@ -13,8 +13,16 @@ export class JavDbHostAdapter { return carNum ? Object.freeze({ carNum, url: this.location.href, site: "javdb" }) : null; } locateListRoot() { return this.document.querySelector(".movie-list"); } - locateDetailRoot() { return this.document.querySelector(".movie-panel-info")?.closest(".container") ?? this.document.querySelector("main"); } - locateDetailSlots() { return Object.freeze({ summary: this.document.querySelector(".movie-panel-info"), resources: this.document.querySelector("#magnets-content"), reviews: this.document.querySelector("#reviews") }); } + locateDetailRoot() { return this.document.querySelector(".video-detail") ?? this.document.querySelector(".movie-panel-info")?.closest(".container") ?? this.document.querySelector("main"); } + locateDetailSlots() { + const root = this.locateDetailRoot(); + return Object.freeze({ + summary: root?.querySelector('[data-jhs-slot="summary-actions"]') ?? this.document.querySelector(".movie-panel-info"), + resources: this.document.querySelector("#magnets-content"), + reviews: root?.querySelector('[data-jhs-slot="reviews"]') ?? this.document.querySelector("#reviews"), + related: root?.querySelector('[data-jhs-slot="related"]'), + }); + } locateNativeGallery() { return this.document.querySelector(".tile-images, .preview-images"); } locateNativeMagnets() { return this.document.querySelector("#magnets-content"); } } diff --git a/src/platform/userscript/userscript-http-adapter.js b/src/platform/userscript/userscript-http-adapter.js index 0e8e3776..31e7f56f 100644 --- a/src/platform/userscript/userscript-http-adapter.js +++ b/src/platform/userscript/userscript-http-adapter.js @@ -9,7 +9,7 @@ export class UserscriptHttpAdapter { this.requestImplementation = requestImplementation; } - /** @param {{url: string, method?: string, headers?: Record, body?: unknown, responseType?: string, timeout?: number, signal?: AbortSignal}} options */ + /** @param {{url: string, method?: string, headers?: Record, body?: unknown, responseType?: string, timeout?: number, signal?: AbortSignal, requestOptions?: Record}} options */ request(options) { return new Promise((resolve, reject) => { let settled = false; @@ -27,6 +27,7 @@ export class UserscriptHttpAdapter { finish(reject, new JhsError("ABORTED", "网络请求已取消", { source: "UserscriptHttpAdapter" })); }; handle = this.requestImplementation({ + ...options.requestOptions, method: options.method ?? "GET", url: options.url, headers: options.headers, data: options.body, responseType: options.responseType, timeout: options.timeout, onload: (/** @type {any} */ response) => finish(resolve, { diff --git a/src/plugins/avatar/actress-info.js b/src/plugins/avatar/actress-info.js index bfa98a49..7032ade8 100644 --- a/src/plugins/avatar/actress-info.js +++ b/src/plugins/avatar/actress-info.js @@ -1,83 +1,65 @@ -import { i } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; export class ActressInfoPlugin extends BasePlugin { - constructor() { - super(...arguments), i(this, "apiUrl", "https://ja.wikipedia.org/wiki/"); - } - getName() { - return "ActressInfoPlugin"; - } - async handle() { - "yes" === await storageManager.getSetting("enableLoadActressInfo", "yes") && await this.loadActressInfo(); - } - async loadActressInfo() { - await Promise.all([ this.handleDetailPage(), this.handleStarPage() ]); - } + getName() { return "ActressInfoPlugin"; } + async handle() { if ("yes" === await storageManager.getSetting("enableLoadActressInfo", "yes")) await this.loadActressInfo(); } + async loadActressInfo() { await Promise.all([this.handleDetailPage(), this.handleStarPage()]); } async initCss() { - return "\n \n "; + return ``; } async handleDetailPage() { if ($(".actress-info").length > 0) return; - let e = $(".female").prev().map(((e, t) => $(t).text().trim())).get(); - if (!e.length) return; - const t = "jhs_actress_info", n = localStorage.getItem(t) ? JSON.parse(localStorage.getItem(t)) : {}; - let a = null, i = ""; - for (let o = 0; o < e.length; o++) { - let t = e[o]; - if (a = n[t], !a) try { - a = await this.searchInfo(t), a && (n[t] = a); - } catch (s) { - clog.error("该名称查询失败,尝试其它名称"); + const names = $(".female").prev().map(((index, item) => $(item).text().trim())).get(); + if (!names.length) return; + const blocks = []; + for (const name of names) { + let info = null; + try { info = await this.searchInfo(name); } catch { clog.error("该名称查询失败,尝试其它名称"); } + const block = $('
    '); + if (info) { + block.append($("").text(`${name}:`)); + const link = $("").attr({ href: info.url, target: "_blank", rel: "noopener noreferrer" }).addClass("jhs-layout-9813a0dd"); + link.append( + $("").addClass("info-tag").text(`${info.birthday} ${info.age}`.trim()), + $("").addClass("info-tag").text(`${info.height} ${info.weight}`.trim()), + $("").addClass("info-tag").text(`${info.threeSizeText} ${info.braSize}`.trim()), + ); + block.append(link); + } else { + const href = this.getRuntimeService("actressInfo").profileUrl(name); + block.append($("").attr({ href, target: "_blank", rel: "noopener noreferrer" }).append($("").text(`${name}:`))); } - let r = ""; - r = a ? `\n \n ` : ` `, - i += r; + blocks.push(block); } - $('strong:contains("演員")').parent().after(i), localStorage.setItem(t, JSON.stringify(n)); + $('strong:contains("演員")').parent().after(...blocks); } async handleStarPage() { if ($(".actress-info").length > 0) return; - let e = [], t = $(".actor-section-name"); - t.length && t.text().trim().split(",").forEach((t => { - e.push(t.trim()); - })); - let n = $(".section-meta:not(:contains('影片'))"); - if (n.length && n.text().trim().split(",").forEach((t => { - e.push(t.trim()); - })), !e.length) return; - const a = "jhs_actress_info", i = localStorage.getItem(a) ? JSON.parse(localStorage.getItem(a)) : {}; - let s = null; - for (let l = 0; l < e.length; l++) { - let t = e[l]; - if (s = i[t], s) break; - try { - s = await this.searchInfo(t); - } catch (r) { - clog.error("该名称查询失败,尝试其它名称"); - } - if (s) break; + const names = [], title = $(".actor-section-name"); + if (title.length) title.text().trim().split(",").forEach((name => names.push(name.trim()))); + const meta = $(".section-meta:not(:contains('影片'))"); + if (meta.length) meta.text().trim().split(",").forEach((name => names.push(name.trim()))); + if (!names.length) return; + let info = null; + for (const name of names) { + try { info = await this.searchInfo(name); } catch { clog.error("该名称查询失败,尝试其它名称"); } + if (info) break; + } + const body = $('
    '); + if (!info) body.text("无此相关演员信息"); + else { + const row1 = $('
    '), row2 = $('
    '); + row1.append($("").addClass("jhs-layout-dd5a75f6").text(`出生日期: ${info.birthday}`), $("").addClass("jhs-layout-d4a09a0d").text(`年龄: ${info.age}`), $("").addClass("jhs-layout-d4a09a0d").text(`身高: ${info.height}`)); + row2.append($("").addClass("jhs-layout-dd5a75f6").text(`体重: ${info.weight}`), $("").addClass("jhs-layout-d4a09a0d").text(`三围: ${info.threeSizeText}`), $("").addClass("jhs-layout-d4a09a0d").text(`罩杯: ${info.braSize}`)); + body.append(row1, row2); } - s && e.forEach((e => { - i[e] = s; - })); - let o = '
    无此相关演员信息
    '; - s && (o = `\n \n
    \n
    \n 出生日期: ${s.birthday}\n 年龄: ${s.age}\n 身高: ${s.height}\n
    \n
    \n 体重: ${s.weight}\n 三围: ${s.threeSizeText}\n 罩杯: ${s.braSize}\n
    \n
    \n
    \n `), - t.parent().append(o), localStorage.setItem(a, JSON.stringify(i)); + const result = info ? $("").addClass("actress-info").attr({ href: info.url, target: "_blank", rel: "noopener noreferrer" }).append(body) : body.addClass("actress-info"); + title.parent().append(result); } - async searchInfo(e) { - "三上悠亞" === e && (e = "三上悠亜"); - let t = this.apiUrl + e; - const n = await gmHttp.get(t), a = new DOMParser, i = $(a.parseFromString(n, "text/html")); - let s = i.find('a[title="誕生日"]').parent().parent().find("td").text().trim(), o = i.find("th:contains('現年齢')").parent().find("td").text().trim() ? parseInt(i.find("th:contains('現年齢')").parent().find("td").text().trim()) + "岁" : "", r = i.find('tr:has(a[title="身長"]) td').text().trim().split(" ")[0] + "cm", l = i.find('tr:has(a[title="体重"]) td').text().trim().split("/")[1].trim(); - return "― kg" === l && (l = ""), { - birthday: s, - age: o, - height: r, - weight: l, - threeSizeText: i.find('a[title="スリーサイズ"]').closest("tr").find("td").text().replace("cm", "").trim(), - braSize: i.find('th:contains("ブラサイズ")').next("td").contents().first().text().trim(), - url: t - }; + async searchInfo(name) { + const scope = await this.getRuntimeService("scope")(); + return this.getRuntimeService("actressInfo").lookup(name, { scope }); } } diff --git a/src/plugins/backup/setting-backup.js b/src/plugins/backup/setting-backup.js index 754480b3..59f3ff6d 100644 --- a/src/plugins/backup/setting-backup.js +++ b/src/plugins/backup/setting-backup.js @@ -1,6 +1,6 @@ import { escapeHtml } from "../../core/constants.js"; -import { WebDavClient } from "./webdav-client.js"; -import { decryptCredential, decryptData, encryptData } from "../image-viewer/bus-preview-video.js"; +import { decryptCredential, decryptData, encryptData } from "../../core/credential-crypto.js"; +import { createJhsTable } from "../../ui/table/create-jhs-table.js"; /** Handle JSON file import via file input, run diff analysis, show preview. */ export async function importSettingData(showDiffPreviewFn) { @@ -38,7 +38,7 @@ export async function importSettingData(showDiffPreviewFn) { } /** Create encrypted backup and upload via WebDAV. */ -export async function backupDataByWebDav(folderName) { +export async function backupDataByWebDav(folderName, webdavService) { const t = await storageManager.getSetting(), n = t.webDavUrl; if (!n) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); const a = t.webDavUsername; @@ -49,7 +49,7 @@ export async function backupDataByWebDav(folderName) { o = await encryptData(o); let r = loading(); try { - const e = new WebDavClient(n, a, i); + const e = webdavService.createClient({ url: n, username: a, password: i }); await e.backup(folderName, s, o), show.ok("备份完成"); } catch (l) { clog.error(l), show.error(l.toString()); @@ -59,7 +59,7 @@ export async function backupDataByWebDav(folderName) { } /** List WebDAV backups and open the file list dialog. */ -export async function backupListBtnByWebDav(folderName, openFileListDialogFn) { +export async function backupListBtnByWebDav(folderName, openFileListDialogFn, webdavService) { const t = await storageManager.getSetting(), n = t.webDavUrl; if (!n) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); const a = t.webDavUsername; @@ -68,7 +68,7 @@ export async function backupListBtnByWebDav(folderName, openFileListDialogFn) { if (!i) return void show.error("请填写webDav密码并保存后, 再试此功能"); let s = loading(); try { - const e = new WebDavClient(n, a, i), t = await e.getBackupList(folderName); + const e = webdavService.createClient({ url: n, username: a, password: i }), t = await e.getBackupList(folderName); openFileListDialogFn(t, e, "WebDav"); } catch (o) { clog.error(o), show.error(`发生错误: ${o ? o.message : o}`); @@ -171,7 +171,8 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn) { area: utils.getResponsiveArea([ "800px", "70%" ]), anim: -1, success: a => { - const i = new Tabulator("#table-container", { + const i = createJhsTable(Tabulator, "#table-container", { + pagination: !1, layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, diff --git a/src/plugins/backup/setting-forms.js b/src/plugins/backup/setting-forms.js index 1bf038b5..deab28af 100644 --- a/src/plugins/backup/setting-forms.js +++ b/src/plugins/backup/setting-forms.js @@ -2,11 +2,11 @@ import { C, _, r } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { parseNumberSetting } from "../../core/feature-helpers.js"; import { applyImageMode } from "./setting-styles.js"; -import { decryptCredential, encryptCredential } from "../image-viewer/bus-preview-video.js"; +import { decryptCredential, encryptCredential } from "../../core/credential-crypto.js"; import { normalizeQuickFilterKey } from "../status/list-page.js"; /** Load all settings from storage into the main settings dialog form fields. */ -export async function loadSettingForm(getBean) { +export async function loadSettingForm(dependencies) { let e = await storageManager.getSetting(); $("#videoQuality").val(e.videoQuality), $("#reviewCount").val(e.reviewCount || 20), $("#tagPosition").val(e.tagPosition || "rightTop"), $("#defaultQuickFilterTab").val(normalizeQuickFilterKey(e.defaultQuickFilterTab)), $("#needClosePageBasic").prop("checked", !e.needClosePage || e.needClosePage === _), $("#autoRemoveNewVideoMarkAfterBrowse").prop("checked", !!e.autoRemoveNewVideoMarkAfterBrowse && e.autoRemoveNewVideoMarkAfterBrowse === _), $("#waitCheckCount").val(e.waitCheckCount || 5), @@ -35,7 +35,7 @@ export async function loadSettingForm(getBean) { $("#enableVerticalModel").prop("checked", !!e.enableVerticalModel && e.enableVerticalModel === _), $("#containerColumns").val(e.containerColumns || 5), $("#showContainerColumns").text(e.containerColumns || 5), $("#containerWidth").val((e.containerWidth || 100) - 70), $("#showContainerWidth").text((e.containerWidth || 100) + "%"); - const a = getBean("OtherSitePlugin"), i = await a.getMissAvUrl(), s = await a.getjableUrl(), o = await a.getAvgleUrl(), r = await a.getJavTrailersUrl(), l = await a.getAv123Url(), c = await a.getJavDbUrl(), d = await a.getJavBusUrl(), h = await a.getSupJavUrl(); + const a = dependencies.otherSite, i = await a.getMissAvUrl(), s = await a.getjableUrl(), o = await a.getAvgleUrl(), r = await a.getJavTrailersUrl(), l = await a.getAv123Url(), c = await a.getJavDbUrl(), d = await a.getJavBusUrl(), h = await a.getSupJavUrl(); $("#missAvUrl").val(i), $("#jableUrl").val(s), $("#avgleUrl").val(o), $("#javTrailersUrl").val(r), $("#av123Url").val(l), $("#javDbUrl").val(c), $("#javBusUrl").val(d), $("#supJavUrl").val(h); let g = await storageManager.getReviewFilterKeywordList(), p = await storageManager.getTitleFilterKeyword(); @@ -48,11 +48,11 @@ export async function loadSettingForm(getBean) { "Enter" === t.key && addKeyword(t, e); })); })); - bindLayoutRangeEvents(); + bindLayoutRangeEvents(dependencies.busImg); } /** Bind the shared layout range controls without accumulating handlers. */ -function bindLayoutRangeEvents() { +function bindLayoutRangeEvents(busImgPlugin) { $("#containerColumns").off(".jhsSetting").on("input.jhsSetting", (() => { const columns = $("#containerColumns").val(); $("#showContainerColumns").text(columns); @@ -60,7 +60,7 @@ function bindLayoutRangeEvents() { movieList && (movieList.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); masonry && (masonry.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); })).on("change.jhsSetting", (async event => { - await storageManager.saveSettingItem("containerColumns", $(event.currentTarget).val()), await applyImageMode(); + await storageManager.saveSettingItem("containerColumns", $(event.currentTarget).val()), await applyImageMode(busImgPlugin); })); $("#containerWidth").off(".jhsSetting").on("input.jhsSetting", (event => { const width = parseInt($(event.target).val()) + 70, widthText = `${width}%`; @@ -72,7 +72,7 @@ function bindLayoutRangeEvents() { } /** Initialize quick settings in either the desktop popover or mobile layer. */ -export async function initQuickSettingForm(getBean, getSelector, openSettingDialogFn) { +export async function initQuickSettingForm(dependencies, getSelector, openSettingDialogFn) { let e = await storageManager.getSetting(); $("#needClosePage").prop("checked", !e.needClosePage || e.needClosePage === _), $("#autoPage").prop("checked", !e.autoPage || e.autoPage === _), $("#translateTitle").prop("checked", !e.translateTitle || e.translateTitle === _), @@ -86,8 +86,8 @@ export async function initQuickSettingForm(getBean, getSelector, openSettingDial await storageManager.saveSettingItem("autoPage", n), $("#sort-toggle-btn").prop("disabled", n === _).attr("title", n === _ ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"); })), $("#translateTitle").on("change", (async t => { const n = $("#translateTitle").is(":checked") ? _ : C; - await storageManager.saveSettingItem("translateTitle", n), n === _ ? (await getBean("ListPagePlugin").doFilter(), - isDetailPage && await getBean("TranslatePlugin").translate()) : (await getBean("ListPagePlugin").revertTranslation(), + await storageManager.saveSettingItem("translateTitle", n), n === _ ? (await dependencies.listPage.doFilter(), + isDetailPage && await dependencies.translate?.translate()) : (await dependencies.listPage.revertTranslation(), $(".translated-title").remove()); })), $("#hoverBigImg").prop("checked", !!e.hoverBigImg && e.hoverBigImg === _), $("#hoverBigImg").on("change", (async t => { @@ -98,14 +98,14 @@ export async function initQuickSettingForm(getBean, getSelector, openSettingDial })); })), $("#enableLoadActressInfo").on("change", (async t => { const n = $("#enableLoadActressInfo").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadActressInfo", n), n === _ ? getBean("ActressInfoPlugin").loadActressInfo() : $(".actress-info").remove(); + await storageManager.saveSettingItem("enableLoadActressInfo", n), n === _ ? dependencies.actressInfo?.loadActressInfo() : $(".actress-info").remove(); })), $("#enableLoadOtherSite").on("change", (async t => { const n = $("#enableLoadOtherSite").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadOtherSite", n), n === _ ? await getBean("OtherSitePlugin").loadOtherSite() : $("#otherSiteBox").remove(); + await storageManager.saveSettingItem("enableLoadOtherSite", n), n === _ ? await dependencies.otherSite.loadOtherSite() : $("#otherSiteBox").remove(); })), $("#enableLoadScreenShot").prop("checked", !e.enableLoadScreenShot || e.enableLoadScreenShot === _), $("#enableLoadScreenShot").on("change", (async t => { const n = $("#enableLoadScreenShot").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableLoadScreenShot", n), n === _ ? await getBean("ScreenShotPlugin").loadScreenShot() : $(".screen-container").remove(); + await storageManager.saveSettingItem("enableLoadScreenShot", n), n === _ ? await dependencies.screenshot.loadScreenShot() : $(".screen-container").remove(); })), $("#enableLoadPreviewVideo").prop("checked", !e.enableLoadPreviewVideo || e.enableLoadPreviewVideo === _), $("#enableLoadPreviewVideo").on("change", (async t => { const n = $("#enableLoadPreviewVideo").is(":checked") ? _ : C; @@ -113,14 +113,14 @@ export async function initQuickSettingForm(getBean, getSelector, openSettingDial })), $("#enableVerticalModel").prop("checked", !!e.enableVerticalModel && e.enableVerticalModel === _), $("#enableVerticalModel").on("change", (async t => { const n = $("#enableVerticalModel").is(":checked") ? _ : C; - await storageManager.saveSettingItem("enableVerticalModel", n), applyImageMode(); + await storageManager.saveSettingItem("enableVerticalModel", n), applyImageMode(dependencies.busImg); })), $("#moreBtn").on("click", (() => { $(".simple-setting, .mini-simple-setting").html("").hide(), openSettingDialogFn("base-panel"); })); } /** Read all form values and save to storage. */ -export async function saveSettingForm(getBean) { +export async function saveSettingForm(dependencies) { let e = await storageManager.getSetting(); const nextWebDavUrl = String($("#webDavUrl").val() || "").trim(); let nextWebDavOrigin = null; @@ -175,8 +175,8 @@ export async function saveSettingForm(getBean) { let t = $(e).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); n.push(t); })), await storageManager.saveTitleFilterKeyword(n), show.ok("保存成功"), await jhsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }); - const a = getBean("NewVideoPlugin"); - a && a.resetBtnTip(), getBean("BlacklistPlugin").resetBtnTip(), getBean("BlacklistPlugin").reloadTable(); + const a = dependencies.newVideo; + a && a.resetBtnTip(), dependencies.blacklist.resetBtnTip(), dependencies.blacklist.reloadTable(); } /** Create a removable keyword label tag in the filter panel. */ diff --git a/src/plugins/backup/setting-panels.js b/src/plugins/backup/setting-panels.js index 73019bb4..77a7a989 100644 --- a/src/plugins/backup/setting-panels.js +++ b/src/plugins/backup/setting-panels.js @@ -1,6 +1,7 @@ import { escapeHtml } from "../../core/constants.js"; import { disabledIdForPlugin, parseDisabledPlugins } from "../../core/legacy-plugin-contributions.js"; import { getPluginCategories } from "./setting-templates.js"; +import { createJhsTable } from "../../ui/table/create-jhs-table.js"; /** Render the network/external requests panel: circuit breaker status, domain stats. */ export async function renderNetworkPanel() { @@ -44,7 +45,8 @@ export async function renderSnapshotPanel() { }; if (0 === e.length) return void $("#snapshot-list").html('
    暂无快照,点击上方按钮创建
    '); $("#snapshot-list").find(".tabulator").length && $("#snapshot-list").empty(); - const n = new Tabulator("#snapshot-list", { + const n = createJhsTable(Tabulator, "#snapshot-list", { + pagination: !1, layout: "fitColumns", placeholder: "暂无数据", data: e, @@ -136,9 +138,10 @@ export function showDiffPreview(e, t, n = null) { } /** Render the plugin management panel: categorized plugin list, timing, errors, cache stats. */ -export async function renderPluginMgmtPanel(pluginManager) { +export async function renderPluginMgmtPanel(diagnostics) { + const diagnosticSnapshot = diagnostics.exportSnapshot(); const disabled = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); - const allNames = pluginManager.getPluginNames(); + const allNames = diagnosticSnapshot.legacyPlugins; const { categories, corePlugins, pluginMeta } = getPluginCategories(); const registeredSet = new Set(allNames); let html = ""; @@ -177,13 +180,13 @@ export async function renderPluginMgmtPanel(pluginManager) { if (!list.includes(disabledId)) list.push(disabledId); } await storageManager.saveSettingItem("disabledPlugins", JSON.stringify(list)); - const all = pluginManager.getPluginNames(); + const all = diagnosticSnapshot.legacyPlugins; $("#pm-total").text(all.length); $("#pm-enabled").text(all.length - list.length); $("#pm-disabled").text(list.length); show.ok(`插件 "${name}" 已${$(e.target).is(":checked") ? "启用" : "禁用"},刷新后生效`); }); - const startup = pluginManager.getStartupReport?.(), timings = pluginManager.getTimings(); + const startup = diagnosticSnapshot.legacyStartup, timings = diagnosticSnapshot.legacyTimings; const formatMs = value => Number.isFinite(value) ? value.toFixed(1) : "0.0"; let startupHtml = startup ? `
    就绪: ${formatMs(startup.readyMs)} ms注册: ${formatMs(startup.registrationMs)} ms样式: ${formatMs(startup.cssMs)} ms即时插件: ${formatMs(startup.immediateMs)} ms空闲任务: ${startup.idleCompleted}/${startup.idleCompleted + startup.idlePending}

    就绪耗时不包含 @require 下载及浏览器脚本解析时间。

    ` : ""; if (timings.length) { @@ -199,11 +202,11 @@ export async function renderPluginMgmtPanel(pluginManager) { } else { $("#plugin-timing-table").html(startupHtml + '

    暂无数据,刷新页面后自动采集。

    '); } - const errorLog = pluginManager.getErrorLog(); + const errorLog = diagnosticSnapshot.errors.filter((error) => error.source === "legacy-plugin"); if (errorLog.length) { let eHtml = ''; for (const err of [...errorLog].reverse()) { - eHtml += ``; + eHtml += ``; } eHtml += '
    时间插件阶段错误信息
    ${escapeHtml(err.time.substring(11, 19))}${escapeHtml(err.plugin)}${escapeHtml(err.phase)}${escapeHtml(err.message)}
    ${escapeHtml(String(err.timestamp || "").substring(11, 19))}${escapeHtml(err.plugin)}${escapeHtml(err.phase)}${escapeHtml(err.message)}
    '; $("#plugin-error-log").html(eHtml); diff --git a/src/plugins/backup/setting-styles.js b/src/plugins/backup/setting-styles.js index bf5dfff0..455697bd 100644 --- a/src/plugins/backup/setting-styles.js +++ b/src/plugins/backup/setting-styles.js @@ -399,7 +399,7 @@ export function buildSettingCss(containerWidth, containerColumns, isJavBus, isJa } /** Toggle between vertical (cover-fit) and normal (contain) image display modes. */ -export async function applyImageMode() { +export async function applyImageMode(busImgPlugin = null) { $("#verticalImgStyle").remove(); if (await storageManager.getSetting("enableVerticalModel", C) === _) { let e = "100% 50% !important"; @@ -444,5 +444,5 @@ export async function applyImageMode() { `; $("`; } + async handle() { + if (!window.isDetailPage || !r) return; + const movieId = new URL(window.location.href).pathname.split("/").filter(Boolean).pop(); + if (movieId) await this.showRelated(this.getHostedSlot("related"), movieId); + } + getHostedSlot(name) { + const element = this.getRuntimeService("host").locateDetailSlots()[name]; + return element ? $(element) : $(); + } async showRelated(target, movieId, options = {}) { const isActive = "function" === typeof options.isActive ? options.isActive : () => !0; - const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getDependency("DetailWorkspacePlugin")?.getSlot("related"); + const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getHostedSlot("related"); if (!movieId) return void show.error("未传入movieId"); if (!isActive() || !host?.length) return $(); const existing = host.children('[data-jhs-panel="related"]').filter(((_, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); @@ -47,15 +55,17 @@ export class RelatedPlugin extends BasePlugin { state.loading = !0; const { movieId, panel } = state, container = panel.find(".jhs-related-container"), footer = panel.find(".jhs-related-footer"); container.empty().append($('
    ').text("获取清单中...")), footer.empty(); - let related; + let related, scope; try { - related = await K(movieId, 1, 20); + scope = await this.getRuntimeService("scope")(); + related = await this.getRuntimeService("related").list({ movieId }, { page: 1, limit: 20, scope }); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); clog.error("获取清单失败:", error); state.loading = !1; return void this.renderRetry(container, (() => this.fetchAndDisplayRelateds(state))); } - if (!state.isActive?.()) return void (state.loading = !1); + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); state.loading = !1, state.loaded = !0; container.empty(); if (!related.length) return void container.append($('
    ').text("无清单")); @@ -71,13 +81,16 @@ export class RelatedPlugin extends BasePlugin { footer.empty().append(button, end); button.on("click", (async () => { const nextPage = state.page + 1; + let scope; button.text("加载中...").prop("disabled", !0); try { - const related = await K(state.movieId, nextPage, 20); - if (!state.isActive?.()) return; + scope = await this.getRuntimeService("scope")(); + const related = await this.getRuntimeService("related").list({ movieId: state.movieId }, { page: nextPage, limit: 20, scope }); + if (!state.isActive?.() || scope?.signal?.aborted) return; state.page = nextPage; this.displayRelateds(state, related, container), related.length < 20 ? (button.remove(), end.show()) : button.text("加载更多清单").prop("disabled", !1); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return; clog.error("加载更多清单失败:", error), button.text("加载失败,请重试").prop("disabled", !1); } })); @@ -85,10 +98,10 @@ export class RelatedPlugin extends BasePlugin { displayRelateds(state, related, container) { related.forEach((item => { const row = $(''), title = $("").addClass("jhs-related-title").attr({ - href: `/lists/${encodeURIComponent(item.relatedId)}`, + href: `/lists/${encodeURIComponent(item.id)}`, target: "_blank", rel: "noopener noreferrer" - }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createTime || "未知"}`); + }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createdAt ? utils.formatDate(item.createdAt) : "未知"}`); meta.append($("").text(`视频:${Number(item.movieCount) || 0}`), $("").text(`收藏:${Number(item.collectionCount) || 0}`), $("").text(`查看:${Number(item.viewCount) || 0}`), time), row.append(heading, meta), container.append(row); })); diff --git a/src/plugins/external-search/review.js b/src/plugins/external-search/review.js index aca92921..8d797b04 100644 --- a/src/plugins/external-search/review.js +++ b/src/plugins/external-search/review.js @@ -1,5 +1,4 @@ import { C, _, escapeHtml, l, r } from "../../core/constants.js"; -import { R, resolveJavDbMovieId } from "../../core/javdb-api.js"; import { BasePlugin } from "../../core/plugin-manager.js"; export class ReviewPlugin extends BasePlugin { @@ -37,19 +36,24 @@ export class ReviewPlugin extends BasePlugin { if (!window.isDetailPage) return; if (r) { const movieId = this.parseMovieId(window.location.href); - const workspace = this.getDependency("DetailWorkspacePlugin"); - await Promise.all([ this.showReview(movieId, workspace?.getSlot("reviews")), this.getDependency("RelatedPlugin").showRelated(workspace?.getSlot("related"), movieId) ]); + await this.showReview(movieId, this.getHostedSlot("reviews")); } if (l) { const carNumber = this.getPageInfo().carNum; if (!carNumber) return void clog.warn("跳过 JavBus 评论解析:番号不可用"); - const movieId = await resolveJavDbMovieId(carNumber); - movieId && await this.showReview(movieId, this.getDependency("DetailWorkspacePlugin")?.getSlot("reviews")); + const scope = await this.getRuntimeService("scope")(); + const movieRef = await this.getRuntimeService("movie").resolve({ carNum: carNumber }, { scope }); + movieRef?.movieId && await this.showReview(movieRef.movieId, this.getHostedSlot("reviews")); } } + getHostedSlot(name) { + const element = this.getRuntimeService("host").locateDetailSlots()[name]; + return element ? $(element) : $(); + } async showReview(movieId, target, options = {}) { const isActive = "function" === typeof options.isActive ? options.isActive : () => !0; - const enabled = await storageManager.getSetting("enableLoadReview", _), host = target?.length ? target : this.getDependency("DetailWorkspacePlugin")?.getSlot("reviews") || $("#magnets-content"); + const settings = await storageManager.getSetting(); + const enabled = settings.enableLoadReview === _ ? _ : C, host = target?.length ? target : this.getHostedSlot("reviews"); if (!isActive() || !host?.length) return $(); const existing = host.children('[data-jhs-panel="reviews"]').filter(((_, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); if (existing.length) return existing; @@ -78,16 +82,18 @@ export class ReviewPlugin extends BasePlugin { const { movieId, panel } = state, container = panel.find(".jhs-review-container"), footer = panel.find(".jhs-review-footer"); container.empty().append($('
    ').text("获取评论中...")), footer.empty(); const pageSize = await storageManager.getSetting("reviewCount", 20); - let reviews; + let reviews, scope; try { - reviews = await R(movieId, 1, pageSize); + scope = await this.getRuntimeService("scope")(); + reviews = await this.getRuntimeService("review").list({ movieId }, { page: 1, limit: pageSize, scope }); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); error.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", error), clog.error("获取评论失败:", error); state.loading = !1; return void this.renderRetry(container, "获取评论失败", (() => this.fetchAndDisplayReviews(state))); } - if (!state.isActive?.()) return void (state.loading = !1); + if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); state.loading = !1, state.loaded = !0; container.empty(); if (!reviews.length) return void container.append($('
    ').text("无评论")); @@ -106,13 +112,16 @@ export class ReviewPlugin extends BasePlugin { footer.empty().append(button, end); button.on("click", (async () => { const nextPage = state.page + 1; + let scope; button.text("加载中...").prop("disabled", !0); try { - const reviews = await R(state.movieId, nextPage, pageSize); - if (!state.isActive?.()) return; + scope = await this.getRuntimeService("scope")(); + const reviews = await this.getRuntimeService("review").list({ movieId: state.movieId }, { page: nextPage, limit: pageSize, scope }); + if (!state.isActive?.() || scope?.signal?.aborted) return; state.page = nextPage; await this.displayReviews(state, reviews, container, keywords), reviews.length < pageSize ? (button.remove(), end.show()) : button.text("加载更多评论").prop("disabled", !1); } catch (error) { + if (!state.isActive?.() || scope?.signal?.aborted) return; clog.error("加载更多评论失败:", error), button.text("加载失败,请重试").prop("disabled", !1); } })); @@ -124,10 +133,10 @@ export class ReviewPlugin extends BasePlugin { const content = String(review.content || ""); if (filter?.test(content)) continue; const item = $('
    '), meta = $('
    '), body = $('
    '); - meta.append($("").addClass("jhs-review-author").text(review.username || "匿名用户")); + meta.append($("").addClass("jhs-review-author").text(review.author || "匿名用户")); const stars = $(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); for (let index = 0; index < score; index++) stars.append(''); - meta.append(stars, $("").text(utils.formatDate(review.created_at)), $("").text(`点赞:${Number(review.likes_count) || 0}`), + meta.append(stars, $("").text(utils.formatDate(review.createdAt)), $("").text(`点赞:${Number(review.likes) || 0}`), $("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`)); await this.appendReviewContent(body, content), item.append(meta, body), container.append(item); } diff --git a/src/plugins/external-search/top250.js b/src/plugins/external-search/top250.js index 3109ccde..6c298ae7 100644 --- a/src/plugins/external-search/top250.js +++ b/src/plugins/external-search/top250.js @@ -1,7 +1,7 @@ import { escapeHtml, i } from "../../core/constants.js"; import { O, U, q } from "../../core/javdb-api.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { encryptData } from "../image-viewer/bus-preview-video.js"; +import { encryptData } from "../../core/credential-crypto.js"; const me = "jhs_appAuthorization"; diff --git a/src/plugins/image-viewer/bus-preview-video.js b/src/plugins/image-viewer/bus-preview-video.js index 217939ee..7674b47c 100644 --- a/src/plugins/image-viewer/bus-preview-video.js +++ b/src/plugins/image-viewer/bus-preview-video.js @@ -3,63 +3,6 @@ import { safePlay } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { Z, fetchDmmPreview } from "./preview-video.js"; -const ENCRYPTION_SALT = "x7k9p3"; - -async function getEncryptionKey() { - const enc = new TextEncoder(); - const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(ENCRYPTION_SALT + ".jhs.v1"), { - name: "PBKDF2" - }, false, [ "deriveKey" ]); - return crypto.subtle.deriveKey({ - name: "PBKDF2", - salt: enc.encode("jhs-backup"), - iterations: 1e5, - hash: "SHA-256" - }, keyMaterial, { - name: "AES-GCM", - length: 256 - }, false, [ "encrypt", "decrypt" ]); -} - -function arrayBufferToBase64(e) { - const t = new Uint8Array(e), n = 0x8000; - let a = ""; - for (let i = 0; i < t.length; i += n) a += String.fromCharCode.apply(null, t.subarray(i, i + n)); - return btoa(a); -} - -function base64ToArrayBuffer(e) { - const t = atob(e), n = new Uint8Array(t.length); - for (let a = 0; a < t.length; a++) n[a] = t.charCodeAt(a); - return n; -} - -export async function encryptData(e) { - const t = await getEncryptionKey(), n = crypto.getRandomValues(new Uint8Array(12)), a = new TextEncoder(), i = await crypto.subtle.encrypt({ - name: "AES-GCM", - iv: n - }, t, a.encode(e)), s = new Uint8Array(n.length + i.byteLength); - return s.set(n), s.set(new Uint8Array(i), n.length), arrayBufferToBase64(s); -} - -export async function decryptData(e) { - const t = await getEncryptionKey(), n = base64ToArrayBuffer(e), a = n.slice(0, 12), i = n.slice(12), s = await crypto.subtle.decrypt({ - name: "AES-GCM", - iv: a - }, t, i); - return new TextDecoder().decode(s); -} - -const CREDENTIAL_PREFIX = "AES:"; - -export async function encryptCredential(e) { - return e && !e.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(e) : e; -} - -export async function decryptCredential(e) { - return e && e.startsWith(CREDENTIAL_PREFIX) ? await decryptData(e.slice(CREDENTIAL_PREFIX.length)) : e; -} - export class BusPreviewVideoPlugin extends BasePlugin { getName() { return "BusPreviewVideoPlugin"; @@ -123,9 +66,9 @@ export class BusPreviewVideoPlugin extends BasePlugin { t.html(`\n
    \n \n
    \n
    \n
    \n `); const i = $("#preview-video"), s = i.find("source"), o = t.find(".jhs-video-quality-list"); if (!i.length || !s.length) return; - const r = i[0], l = localStorage.getItem("jhs_videoMuted"); - r.muted = !l || "yes" === l, i.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (function() { - localStorage.setItem("jhs_videoMuted", r.muted ? "yes" : "no"); + const settings = this.getRuntimeService("settings"), r = i[0], muted = settings.snapshot().videoMuted; + r.muted = muted == null || muted === !0, i.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { + void settings.set("videoMuted", r.muted).catch((error => clog.error("保存视频静音设置失败", error))); })); let c = ""; L.forEach((t => { diff --git a/src/plugins/image-viewer/screenshot-provider-registry.js b/src/plugins/image-viewer/screenshot-provider-registry.js index daced713..4027fee9 100644 --- a/src/plugins/image-viewer/screenshot-provider-registry.js +++ b/src/plugins/image-viewer/screenshot-provider-registry.js @@ -1,15 +1,21 @@ +// @ts-check + export class ScreenshotProviderRegistry { + /** @param {Array<{id: string, name: string, enabled?: boolean, priority?: number, getScreenshot: (carNum: string) => Promise}>} [providers] */ constructor(providers = []) { this.providers = new Map(); providers.forEach((provider => this.register(provider))); } + /** @param {{id: string, name: string, enabled?: boolean, priority?: number, getScreenshot: (carNum: string) => Promise}} provider */ register(provider) { if (!provider?.id || !provider.name || "function" !== typeof provider.getScreenshot) throw new TypeError("Invalid screenshot provider"); this.providers.set(provider.id, { enabled: true, priority: 100, ...provider }); return this; } + /** @param {string} id */ get(id) { return this.providers.get(id) || null; } getEnabledProviders() { return [...this.providers.values()].filter((provider => provider.enabled)).sort(((a, b) => a.priority - b.priority)); } + /** @param {string} carNum */ async first(carNum) { for (const provider of this.getEnabledProviders()) { - try { const result = await provider.getScreenshot(carNum); if (result?.url) return result; } catch (error) { clog.warn(`截图源 ${provider.name} 请求失败`, error); } + try { const result = await provider.getScreenshot(carNum); if (result?.url) return result; } catch (error) { /** @type {any} */ (globalThis).clog?.warn(`截图源 ${provider.name} 请求失败`, error); } } return null; } diff --git a/src/plugins/image-viewer/screenshot.js b/src/plugins/image-viewer/screenshot.js index 55f63118..6a55a4b1 100644 --- a/src/plugins/image-viewer/screenshot.js +++ b/src/plugins/image-viewer/screenshot.js @@ -1,7 +1,6 @@ -import { CACHE_TTL } from "../../core/cache-policy.js"; import { l, normalizeCarNum, r } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { normalizeJavStoreAssetUrl, parseJavStorePreview, parseJavStoreSearch } from "../../integrations/javstore/parser.js"; +import { normalizeJavStoreAssetUrl } from "../../integrations/javstore/parser.js"; import { ResourceSettingsService } from "../backup/resource-settings.js"; import { ScreenshotProviderRegistry } from "./screenshot-provider-registry.js"; @@ -12,7 +11,7 @@ export class ScreenShotPlugin extends BasePlugin { async initializeProviders() { const settings = await new ResourceSettingsService().getScreenshotSettings(), configured = id => settings.providers.find((provider => provider.id === id)) || {}; this.providerRegistry = new ScreenshotProviderRegistry([ - { id: "javstore", name: "JavStore", priority: 10, getScreenshot: carNum => this.getCachedProviderScreenshot("javstore", carNum, (() => this.getJavStoreScreenShot(carNum))) }, + { id: "javstore", name: "JavStore", priority: 10, getScreenshot: carNum => this.getServiceScreenshot(carNum) }, { id: "projectjav", name: "ProjectJav", enabled: !1, priority: 20, getScreenshot: async () => null }, { id: "18av", name: "18AV", enabled: !1, priority: 30, getScreenshot: async () => null } ].map((provider => ({ ...provider, ...configured(provider.id), enabled: !["projectjav", "18av"].includes(provider.id) && (configured(provider.id).enabled ?? provider.enabled ?? true), getScreenshot: provider.getScreenshot })))); @@ -61,7 +60,6 @@ export class ScreenShotPlugin extends BasePlugin { async getScreenshotFromInitializedProviders(e) { e = normalizeCarNum(e); if (!e) throw new Error("缩略图番号不可用"); - localStorage.removeItem("jhs_screenShot"); let n; try { n = await this.providerRegistry.first(e); @@ -72,39 +70,11 @@ export class ScreenShotPlugin extends BasePlugin { let url = n.url, a = url.indexOf("https://"); return -1 !== a && (url = url.substring(a)), clog.log(`缩略图获取成功 (${n.source}):`, url), url; } - async getCachedProviderScreenshot(provider, carNum, loader) { - const value = await storageManager.cachedRequest(`screenshot:${provider}:${carNum}`, CACHE_TTL.screenshot, (async () => { - const loadedUrl = await loader(), url = "javstore" === provider ? normalizeJavStoreAssetUrl(loadedUrl) : loadedUrl; - return url ? { __jhsCacheTtl: CACHE_TTL.screenshot, data: { url } } : { __jhsCacheTtl: CACHE_TTL.screenshotNegative, data: { miss: !0 } }; - })); - if (!value || value.miss) return null; - const cachedUrl = "string" === typeof value ? value : value.url, url = "javstore" === provider ? normalizeJavStoreAssetUrl(cachedUrl) : cachedUrl; - return url ? { url, source: provider, detailUrl: null } : null; - } - async getJavStoreScreenShot(e) { - const t = `https://javstore.net/search?q=${encodeURIComponent(e)}`; - clog.debug("JavStore 搜索地址:", t); - let n = await gmHttp.get(t, {}, {}, !1, {ignoreNotFound: !0}); - if (!n) return clog.debug("JavStore 搜索页未获取:", t), null; - const a = utils.htmlTo$dom(n); - const i = parseJavStoreSearch(a, e); - if (!i.length) return clog.debug("JavStore, 查询番号无结果:", t), null; - for (const e of i) { - const t = e; - clog.debug("JavStore 候选详情:", t); - const n = await gmHttp.get(t, {}, {}, !1, {ignoreNotFound: !0}); - if (!n) { - clog.debug("JavStore 详情页未获取:", t); - continue; - } - const a = parseJavStorePreview(utils.htmlTo$dom(n), t); - if (!a) { - clog.debug("JavStore 详情页没有 CLICK HERE!:", t); - continue; - } - return clog.debug("JavStore 预览图:", a), a; - } - return clog.debug("JavStore, 所有候选均无有效预览图:", t), null; + async getServiceScreenshot(carNum) { + const scope = await this.getRuntimeService("scope")(); + const images = await this.getRuntimeService("screenshot").resolve({ carNum }, { scope }); + const image = Array.isArray(images) ? images[0] : images; + return image?.url ? { url: image.url, source: image.providerId || "javstore", detailUrl: null } : null; } addImg(e, t) { const url = normalizeJavStoreAssetUrl(t); diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 86a42a87..bcf0f1ca 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -1,4 +1,7 @@ -import { detectSite } from "../core/site-context.js"; +// @ts-check + +import { defineContribution } from "../contracts/manifests.js"; +import { PORT, SERVICE } from "../contracts/tokens.js"; import { LEGACY_PLUGIN_DEPENDENCY_MAP } from "./dependency-map.js"; import { ActressInfoPlugin } from "./avatar/actress-info.js"; import { SearchByImagePlugin } from "./avatar/search-by-image.js"; @@ -44,51 +47,79 @@ import { WantAndWatchedVideosPlugin } from "./status/want-and-watched-videos.js" import { SubTitleCatPlugin } from "./subtitle/subtitle-cat.js"; import { TranslatePlugin } from "./translate/translate.js"; -const DEFAULT_JAVDB_PLUGINS = [ - ListPagePlugin, AutoPagePlugin, Fc2Plugin, FoldCategoryPlugin, ListPageButtonPlugin, - HistoryPlugin, SettingPlugin, NavBarPlugin, HitShowPlugin, Top250Plugin, - SearchByImagePlugin, CoverButtonPlugin, Fc2By123AvPlugin, DetailPagePlugin, DetailWorkspacePlugin, ReviewPlugin, - RelatedPlugin, DetailPageButtonPlugin, HighlightMagnetPlugin, PreviewVideoPlugin, FilterTitleKeywordPlugin, - ActressInfoPlugin, OtherSitePlugin, TranslatePlugin, WantAndWatchedVideosPlugin, - MagnetHubPlugin, ScreenShotPlugin, BlacklistPlugin, FavoriteActressesPlugin, NewVideoPlugin, - TaskPlugin, StatsPlugin, MobileBottomBarPlugin - , OneOneFiveMatchPlugin, UnifiedOfflinePlugin, CompatibilityEnhancementsPlugin -]; - -const DEFAULT_JAVBUS_PLUGINS = [ - ListPagePlugin, ListPageButtonPlugin, SettingPlugin, - HistoryPlugin, AutoPagePlugin, SearchByImagePlugin, BusNavBarPlugin, CoverButtonPlugin, - BusImgPlugin, BusDetailPagePlugin, DetailWorkspacePlugin, DetailPageButtonPlugin, ReviewPlugin, - FilterTitleKeywordPlugin, HighlightMagnetPlugin, BusPreviewVideoPlugin, MagnetHubPlugin, ScreenShotPlugin, - OtherSitePlugin, TranslatePlugin, BlacklistPlugin, TaskPlugin, StatsPlugin, MobileBottomBarPlugin - , OneOneFiveMatchPlugin, UnifiedOfflinePlugin, CompatibilityEnhancementsPlugin -]; +const manifest = (id, featureId, plugin, sites, order, requires = []) => defineContribution({ id, featureId, legacyPluginId: plugin.name, plugin, sites, order, requires }); -const DEFAULT_SHARED_PLUGIN_RULES = [ - { - shouldRegister: context => context.isJavDB || context.isJavBus || context.is123Pan, - plugins: [ OneTwoThreeOfflinePlugin ] - }, - { - shouldRegister: context => context.isJavTrailers, - plugins: [ JavTrailersPlugin ] - }, - { - shouldRegister: context => context.isSubtitleCat, - plugins: [ SubTitleCatPlugin ] - } -]; - -function registerPluginGroup(pluginManager, plugins) { - plugins.forEach((pluginClass => pluginManager.register(pluginClass))); -} +export const legacyContributionManifests = Object.freeze([ + manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation]), + manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }), + manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.screenshot]), + manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), + manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), + manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), + manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), + manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), + manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }), + manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }), + manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), + manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), + manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), + manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }), + manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie]), + manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related]), + manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), + manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }), + manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), + manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), + manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), + manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [SERVICE.movie]), + manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), + manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), + manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), + manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }), + manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), + manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), + manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), + manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics]), + manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), + manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }), + manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), + manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), + manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), + manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), + manifest("detail.native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), + manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings]), + manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }), + manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), + manifest("detail.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }), +]); -export function registerSitePlugins(pluginManager, locationLike = window.location) { +/** @param {import("../core/plugin-manager.js").PluginManager} pluginManager @param {import("../app/feature-runtime.js").FeatureRuntime} featureRuntime @param {string} site */ +export function registerSitePlugins(pluginManager, featureRuntime, site) { pluginManager.setDependencyDeclarations(LEGACY_PLUGIN_DEPENDENCY_MAP); - const context = detectSite(locationLike); - DEFAULT_SHARED_PLUGIN_RULES.forEach((rule => { - rule.shouldRegister(context) && registerPluginGroup(pluginManager, rule.plugins); - })); - context.isJavDB && registerPluginGroup(pluginManager, DEFAULT_JAVDB_PLUGINS); - context.isJavBus && registerPluginGroup(pluginManager, DEFAULT_JAVBUS_PLUGINS); + legacyContributionManifests + .filter((item) => item.sites.includes(site) && featureRuntime.isContributionEnabled(item.featureId, item.id, item.legacyPluginId)) + .sort((left, right) => Number(left.order[site]) - Number(right.order[site])) + .forEach((item) => { + const dependencies = featureRuntime.resolveDeclaredDependencies(item.requires); + const runtimeServices = {}; + runtimeServices.scope = () => featureRuntime.getScope(item.featureId); + const runtimeNames = new Map([ + [PORT.host, "host"], [SERVICE.diagnostics, "diagnostics"], [SERVICE.review, "review"], + [SERVICE.related, "related"], [SERVICE.movie, "movie"], [SERVICE.magnet, "magnet"], + [SERVICE.settings, "settings"], [SERVICE.cache, "cache"], + [SERVICE.actressInfo, "actressInfo"], + [SERVICE.screenshot, "screenshot"], + [SERVICE.translation, "translation"], + [SERVICE.webdav, "webdav"], + [SERVICE.dialog, "dialog"], + ]); + for (const token of item.requires) { + const name = runtimeNames.get(token); + if (!name) throw new Error(`Legacy contribution ${item.id} has no runtime name for ${String(token)}`); + runtimeServices[name] = dependencies[token]; + } + pluginManager.register(item.plugin, runtimeServices); + }); } diff --git a/src/plugins/stats/stats.js b/src/plugins/stats/stats.js index 4d6d9140..c630816e 100644 --- a/src/plugins/stats/stats.js +++ b/src/plugins/stats/stats.js @@ -31,6 +31,7 @@ export class StatsPlugin extends BasePlugin { $("#newVideoBtn").after(e), $("#statsBtn").on("click", (() => this.openDialog())); } async openDialog() { + const diagnostics = this.getRuntimeService("diagnostics").exportSnapshot(); const cars = await storageManager.getCarList(), actresses = await storageManager.getFavoriteActressList(), blacklist = await storageManager.getBlacklist(), activity = await stateService.getActivityLog(), total = cars.length; const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; cars.forEach((car => { const flags = normalizeStateFlags(car.stateFlags); flags.blocked && counts.manualBlocked++, flags.favorite && counts.favorite++, flags.downloaded && counts.hasDown++, flags.watched && counts.hasWatch++, hasAnyState(flags) || counts.pending++; })); @@ -54,6 +55,8 @@ export class StatsPlugin extends BasePlugin { { label: "收藏演员", value: actresses.length, action: null }, { label: "黑名单演员", value: blacklist.length, action: null }, { label: "新作品待处理", value: newVideos, action: "new-video" } + , { label: "活跃功能", value: diagnostics.activeFeatures.length, action: null } + , { label: "运行错误", value: diagnostics.errors.length, action: null } ]; const statusRows = [ [ "收藏", counts.favorite, "var(--jhs-status-fav)" ], [ "下载", counts.hasDown, "var(--jhs-status-down)" ], [ "已看", counts.hasWatch, "var(--jhs-status-watch)" ], [ "手动屏蔽", counts.manualBlocked, "var(--jhs-status-filter)" ], [ "未鉴定", pending, "var(--jhs-border-strong)" ] ]; const row = (label, value, max, color, href = "") => `
    ${href ? `${escapeHtml(label)}` : `${escapeHtml(label)}`}${value}${max === total && total ? ` (${Math.round(value / total * 100)}%)` : ""}
    `; diff --git a/src/plugins/status/detail-page-button.js b/src/plugins/status/detail-page-button.js index e824b6b2..0bae8838 100644 --- a/src/plugins/status/detail-page-button.js +++ b/src/plugins/status/detail-page-button.js @@ -2,6 +2,7 @@ import { C, _, k, l, m, normalizeCarNum, r, v, y } from "../../core/constants.js import { detailStateController } from "../../core/detail-state-controller.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { stateService } from "../../core/state-service.js"; +import { createJhsTable } from "../../ui/table/create-jhs-table.js"; export class DetailPageButtonPlugin extends BasePlugin { getName() { @@ -44,7 +45,10 @@ export class DetailPageButtonPlugin extends BasePlugin { let t = $("#magnets-span"); "关闭磁力过滤" === t.text() ? (a.showAll(), t.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a.doFilterMagnet(), t.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); - })), $("#search-subtitle-btn").on("click", (e => utils.openPage(`https://subtitlecat.com/index.php?search=${t}`, t, !1, e))), + })), $("#search-subtitle-btn").on("click", (e => { + const target = this.getRuntimeService("movie").sourceUrls({ carNum: t }, ["subtitlecat"])[0]?.url; + if (target) utils.openPage(target, t, !1, e); + })), $("#xunLeiSubtitleBtn").on("click", (() => this.searchXunLeiSubtitle(t))); if (!t) { $("#filterBtn, #favoriteBtn, #hasDownBtn, #hasWatchBtn, #magnetSearchBtn, #xunLeiSubtitleBtn, #search-subtitle-btn").prop("disabled", !0).attr("title", "番号不可用"); @@ -85,7 +89,8 @@ export class DetailPageButtonPlugin extends BasePlugin { area: utils.getResponsiveArea([ "60%", "70%" ]), anim: -1, success: (t, a) => { - new Tabulator("#xunlei-table-container", { + createJhsTable(Tabulator, "#xunlei-table-container", { + pagination: !1, layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, diff --git a/src/plugins/status/fold-category.js b/src/plugins/status/fold-category.js index 7feffeb7..357cdf96 100644 --- a/src/plugins/status/fold-category.js +++ b/src/plugins/status/fold-category.js @@ -1,4 +1,4 @@ -import { C, _, o } from "../../core/constants.js"; +import { o } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; export class FoldCategoryPlugin extends BasePlugin { @@ -48,10 +48,11 @@ export class FoldCategoryPlugin extends BasePlugin { let a = $("h2.section-title"); if (a.length > 0 && (a.append('\n
    \n \n
    \n '), t = $("section > div > div.box")), !t) return; - let i = $("#foldCategoryBtn"), s = localStorage.getItem("jhs_foldCategory") === _, [o, r] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; + const settings = this.getRuntimeService("settings"); + let i = $("#foldCategoryBtn"), s = settings.snapshot().foldCategoryCollapsed === !0, [o, r] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; i.find("span").text(o).end().find("i").attr("class", r), window.location.href.includes("noFold=1") || t[s ? "hide" : "show"](), i.on("click", (async e => { - e.preventDefault(), s = !s, localStorage.setItem("jhs_foldCategory", s ? _ : C); + e.preventDefault(), s = !s, await settings.set("foldCategoryCollapsed", s); const [n, a] = s ? [ "展开", "icon-angle-double-down" ] : [ "折叠", "icon-angle-double-up" ]; i.find("span").text(n).end().find("i").attr("class", a), t[s ? "hide" : "show"](); })); diff --git a/src/plugins/status/history.js b/src/plugins/status/history.js index 2f1af888..c6a240e3 100644 --- a/src/plugins/status/history.js +++ b/src/plugins/status/history.js @@ -3,11 +3,13 @@ import { BasePlugin } from "../../core/plugin-manager.js"; import { hasAnyState, legacyActionToFlag, normalizeStateFlags } from "../../core/state-model.js"; import { stateService } from "../../core/state-service.js"; import { JhsSelect } from "../../core/ui-primitives.js"; +import { HistorySelectionModel } from "../../features/history/history-selection-model.js"; +import { createJhsTable } from "../../ui/table/create-jhs-table.js"; export class HistoryPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectAll", !1), - i(this, "historyExcludedCarNums", new Set), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), + super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectionModel", new HistorySelectionModel), + i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", !1); } getName() { @@ -132,9 +134,12 @@ export class HistoryPlugin extends BasePlugin { normalizeHistoryCarNum(carNum) { return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); } + isHistoryAllFiltered() { + return "all-filtered" === this.historySelectionModel.mode; + } /** 清空 History 的全选、排除项和当前页选择。 */ resetHistorySelection(deselectRows = !0) { - this.historySelectAll = !1, this.historyExcludedCarNums.clear(); + this.historySelectionModel.clear(); if (deselectRows && this.tableObj) { this.historySelectionSyncing = !0; try { @@ -149,12 +154,12 @@ export class HistoryPlugin extends BasePlugin { const checkbox = document.createElement("input"); return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { - checkbox.checked ? (this.historySelectAll = !0, this.historyExcludedCarNums.clear(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); + checkbox.checked ? (this.historySelectionModel.selectAllFiltered(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); })), checkbox; } syncHistoryPageSelection() { - if (!this.tableObj || !this.historySelectAll) return void this.updateHistorySelectionUi(); - const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.map((item => item.carNum)).filter((carNum => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(carNum)))); + if (!this.tableObj || !this.isHistoryAllFiltered()) return void this.updateHistorySelectionUi(); + const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.filter((item => this.historySelectionModel.has(item))).map((item => item.carNum)); this.historySelectionSyncing = !0; try { this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); @@ -164,19 +169,19 @@ export class HistoryPlugin extends BasePlugin { this.updateHistorySelectionUi(); } updateHistoryRowSelection(row, selected) { - if (!this.historySelectAll || this.historySelectionSyncing) return void this.updateHistorySelectionUi(); - const carNum = this.normalizeHistoryCarNum(row?.getData?.().carNum); - carNum && (selected ? this.historyExcludedCarNums.delete(carNum) : this.historyExcludedCarNums.add(carNum)), this.updateHistorySelectionUi(); + if (this.historySelectionSyncing) return void this.updateHistorySelectionUi(); + const item = row?.getData?.(); + item && this.historySelectionModel.set(item, selected), this.updateHistorySelectionUi(); } getHistorySelectionSummary() { - if (this.historySelectAll) { - const excluded = this.historyExcludedCarNums.size, selected = Math.max(0, this.historyFilteredCount - excluded); + if (this.isHistoryAllFiltered()) { + const excluded = this.historySelectionModel.excluded.size, selected = Math.max(0, this.historyFilteredCount - excluded); return { count: selected, text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` }; } - const count = this.tableObj?.getSelectedData?.().length || 0; + const count = this.historySelectionModel.selected.size; return { count, text: `已选择当前页 ${count} 条` @@ -187,15 +192,15 @@ export class HistoryPlugin extends BasePlugin { const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { - element.checked = this.historySelectAll, element.indeterminate = this.historySelectAll ? this.historyExcludedCarNums.size > 0 : summary.count > 0; + element.checked = this.isHistoryAllFiltered(), element.indeterminate = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size > 0 : summary.count > 0; })); } /** 解析批量操作实际要处理的当前页或完整筛选结果。 */ async getHistoryBatchSelection() { - if (!this.historySelectAll) return this.tableObj?.getSelectedData?.() || []; + if (!this.isHistoryAllFiltered()) return this.historySelectionModel.values(this.tableObj?.getData?.() || []); const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map((item => this.normalizeHistoryCarNum(item.carNum)))); - for (const carNum of this.historyExcludedCarNums) available.has(carNum) || this.historyExcludedCarNums.delete(carNum); - return this.historyFilteredCount = rows.length, rows.filter((item => !this.historyExcludedCarNums.has(this.normalizeHistoryCarNum(item.carNum)))); + for (const carNum of this.historySelectionModel.excluded) available.has(carNum) || this.historySelectionModel.excluded.delete(carNum); + return this.historyFilteredCount = rows.length, this.historySelectionModel.values(rows); } bindHistoryActions(root) { root.on("click.jhsHistory", (function(e) { @@ -237,7 +242,7 @@ export class HistoryPlugin extends BasePlugin { i = h) : t.hasClass("multiple-history-hasDownBtn") ? (a = "已下载", i = g) : t.hasClass("multiple-history-hasWatchBtn") ? (a = "已观看", i = p) : t.hasClass("multiple-history-deleteBtn") && (a = "移除", i = "delete"); if (!n.length) return void show.info("请先选择要处理的记录"); - const selectionText = this.historySelectAll ? this.historyExcludedCarNums.size ? `当前筛选结果中已选择 ${n.length} 条,排除 ${this.historyExcludedCarNums.size} 条` : `当前筛选结果中已选择全部 ${n.length} 条` : `当前页已选择 ${n.length} 条`; + const selectionText = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size ? `当前筛选结果中已选择 ${n.length} 条,排除 ${this.historySelectionModel.excluded.size} 条` : `当前筛选结果中已选择全部 ${n.length} 条` : `当前页已选择 ${n.length} 条`; utils.q(e, `${selectionText},是否标记为${a}?`, (async () => { let e = loading(); try { @@ -302,7 +307,7 @@ export class HistoryPlugin extends BasePlugin { }; } async loadTableData() { - this.tableObj = new Tabulator(this.historyRoot.find("#table-container").get(0), { + this.tableObj = createJhsTable(Tabulator, this.historyRoot.find("#table-container").get(0), { layout: "fitColumns", placeholder: "暂无数据", virtualDom: !0, @@ -459,7 +464,7 @@ export class HistoryPlugin extends BasePlugin { } } }), this.tableObj.on("dataProcessed", (() => { - this.historySelectAll ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); + this.isHistoryAllFiltered() ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); })), this.tableObj.on("rowSelected", (row => { this.updateHistoryRowSelection(row, !0); })), this.tableObj.on("rowDeselected", (row => { diff --git a/src/plugins/status/list-page-button.js b/src/plugins/status/list-page-button.js index d5fa9413..053764a1 100644 --- a/src/plugins/status/list-page-button.js +++ b/src/plugins/status/list-page-button.js @@ -33,7 +33,7 @@ export class ListPageButtonPlugin extends BasePlugin { })); const r = o.includes("advanced_search"); r && (t = $("h2.section-title")); - const l = localStorage.getItem("jhs_sortMethod"), d = "当前排序方式: " + ("rateCount" === l ? "评价人数" : "date" === l ? "时间" : "默认"); + const l = this.getRuntimeService("settings").snapshot().sortMethod || "default", d = "当前排序方式: " + ("rateCount" === l ? "评价人数" : "date" === l ? "时间" : "默认"); t.append(`\n
    \n \n ${e ? `\n \n \n \n \n ` : ""}\n ${o.includes("/tags") ? `\n \n ` : ""}\n
    \n
    \n \n \n ${c ? "" : this.sortMenuHtml(l || "default", d)}\n
    \n `); } if (l) { @@ -43,7 +43,7 @@ export class ListPageButtonPlugin extends BasePlugin { const e = await storageManager.getBlacklist(), a = this.getActressPageInfo(); e.find((e => e.starId === a.starId)) && (t = "已加入黑名单", n = "jhs-btn--muted"); } - const a = localStorage.getItem("jhs_sortMethod") || "default"; + const a = this.getRuntimeService("settings").snapshot().sortMethod || "default"; $(".masonry").parent().prepend(`\n
    \n \n ${e ? ` \n \n \n \n \n ` : ''}\n ${this.sortMenuHtml(a)}\n
    \n `); } $("#waitCheckBtn > span").text("开始鉴定"); @@ -109,10 +109,10 @@ export class ListPageButtonPlugin extends BasePlugin { const open = !menu.hasClass("is-open"); menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); })); - menu.on("click", ".jhs-sort-option", (event => { + menu.on("click", ".jhs-sort-option", (async event => { const item = $(event.currentTarget), method = item.data("sort-method"); - localStorage.setItem("jhs_sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), - $("#jhs-sort-current").text(item.text()), close(!0), void this.sortItems().catch((error => clog.error("列表排序失败", error))); + await this.getRuntimeService("settings").set("sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), + $("#jhs-sort-current").text(item.text()), close(!0), await this.sortItems().catch((error => clog.error("列表排序失败", error))); })).on("keydown", ".jhs-sort-option", (event => { const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), close(!0); @@ -130,7 +130,7 @@ export class ListPageButtonPlugin extends BasePlugin { if (!e && (o.includes("handle") || o.includes("advanced_search"))) return; const s = await storageManager.getSetting("autoPage"); if (c || s === _ && !e) return; - const t = localStorage.getItem("jhs_sortMethod"); + const t = this.getRuntimeService("settings").snapshot().sortMethod; if (!t) return; const i = this.getSelector(), d = $(i.boxSelector), h = $(i.itemSelector); h.each((function(e) { diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index c60cc834..4f21e523 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -6,19 +6,7 @@ import { isHitShowPage } from "../../core/site-context.js"; import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; import { stateService } from "../../core/state-service.js"; -export const _e = async (e, t = "ja", n = "zh-CN") => { - if (!e) throw new Error("翻译文本不能为空"); - const a = "https://translate-pa.googleapis.com/v1/translate?" + new URLSearchParams({ - "params.client": "gtx", - dataTypes: "TRANSLATION", - key: "AIzaSyDLEeFI5OtFBwYBIoK_jj5m32rZK5CkCXA", - "query.sourceLanguage": t, - "query.targetLanguage": n, - "query.text": e - }), i = await fetch(a); - if (!i.ok) throw new Error(`${i.status} ${i.statusText}`); - return (await i.json()).translation; -}, Te = { +const Te = { IS_FILTERED: { text: u, color: "var(--jhs-status-filter)", @@ -131,10 +119,10 @@ export class ListPagePlugin extends BasePlugin { super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), - i(this, "currentPageTotalCount", 0), i(this, "cache", null), i(this, "translationPending", new Map), + i(this, "currentPageTotalCount", 0), i(this, "filterContext", null), i(this, "pendingItems", new Set), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), - i(this, "_debouncedTranslateWrite", null), i(this, "itemIndex", new Map), i(this, "recountFrame", null); + i(this, "itemIndex", new Map), i(this, "recountFrame", null); } getName() { return "ListPagePlugin"; @@ -551,18 +539,6 @@ export class ListPagePlugin extends BasePlugin { })); })); } - getTranslationCache() { - if (this.cache && "object" == typeof this.cache && !Array.isArray(this.cache)) return this.cache; - try { this.cache = JSON.parse(localStorage.getItem("jhs_translate") || "{}"); } catch (error) { - clog.warn("列表翻译缓存无法解析,已忽略旧缓存", error), this.cache = {}; - } - return this.cache && "object" == typeof this.cache && !Array.isArray(this.cache) ? this.cache : this.cache = {}; - } - scheduleTranslationWrite() { - this._debouncedTranslateWrite && clearTimeout(this._debouncedTranslateWrite), this._debouncedTranslateWrite = setTimeout((() => { - localStorage.setItem("jhs_translate", JSON.stringify(this.getTranslationCache())); - }), 500); - } applyTranslatedTitle(e, t, n) { const a = e.find(".video-title"); r ? (a.contents().each((function() { @@ -574,14 +550,9 @@ export class ListPagePlugin extends BasePlugin { if (r ? (t = a.contents().filter(((e, t) => 3 === t.nodeType && "" !== t.textContent.trim())).text().trim(), n = e.find(".video-title strong").text().trim()) : (t = (e.find("img").attr("data-title") || "").trim(), n = (e.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t || !n) return; - const cache = this.getTranslationCache(); - if (cache[n]) return void this.applyTranslatedTitle(e, cache[n], n); - let pending = this.translationPending.get(n); - if (!pending) { - pending = _e(t).then((translated => (cache[n] = translated, this.scheduleTranslationWrite(), translated))).finally((() => this.translationPending.delete(n))), - this.translationPending.set(n, pending); - } - this.applyTranslatedTitle(e, await pending, n); + const scope = await this.getRuntimeService("scope")(); + const translated = await this.getRuntimeService("translation").translate(t, { scope }); + this.applyTranslatedTitle(e, translated, n); } async revertTranslation() { $(this.getSelector().itemSelector).toArray().forEach((e => { diff --git a/src/plugins/status/mobile-bottom-bar.js b/src/plugins/status/mobile-bottom-bar.js index a28f2283..0a53a23b 100644 --- a/src/plugins/status/mobile-bottom-bar.js +++ b/src/plugins/status/mobile-bottom-bar.js @@ -279,7 +279,7 @@ export class MobileBottomBarPlugin extends BasePlugin { const item = (action, label, attributes = "") => ``, group = content => `
    ${content}
    `, divider = ''; let items; if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), + const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; @@ -336,7 +336,7 @@ export class MobileBottomBarPlugin extends BasePlugin { backdrop.addClass("jhs-fab-backdrop-visible"); // 刷新排序标签 if (window.isListPage) { - const sortMethod = localStorage.getItem("jhs_sortMethod") || "default"; + const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default"; const sortLabel = { default: "默认", rateCount: "评价人数", date: "时间" }[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); @@ -385,10 +385,10 @@ export class MobileBottomBarPlugin extends BasePlugin { event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ".jhs-mobile-sort-option", (event => { + })).on("click", ".jhs-mobile-sort-option", (async event => { event.stopPropagation(); const value = $(event.currentTarget).data("jhs-sort"); - localStorage.setItem("jhs_sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), void this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(!0); + await this.getRuntimeService("settings").set("sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), await this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(!0); })); // 菜单项点击 menu.on("click", ".jhs-fab-menu-item", (e) => { diff --git a/src/plugins/translate/translate.js b/src/plugins/translate/translate.js index 29fd6761..89810439 100644 --- a/src/plugins/translate/translate.js +++ b/src/plugins/translate/translate.js @@ -1,6 +1,6 @@ import { _, l } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { _e } from "../status/list-page.js"; +import { renderTranslatedTitle } from "../../ui/translation/title-translation.js"; export class TranslatePlugin extends BasePlugin { getName() { @@ -13,31 +13,9 @@ export class TranslatePlugin extends BasePlugin { isDetailPage && this.translate(); } async translate(e, t = !0, options = {}) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; + if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) !== _) return; l && (t = !1); - const root = options.root ? $(options.root) : $(document); - let n = root.find(".origin-title").first(); - if (n.length || (n = root.find(".current-title").first()), n.length || (n = root.find("h3").first()), !n.length) return; - const a = n.text().trim(); - if (!a) return void show.error("获取标题失败, 无法进行翻译"); - let i = n.nextAll(".translated-title").first(); - i.length || (i = $('
    ').insertAfter(n)), i.removeClass("is-error").text("翻译中..."); - e || (e = this.getPageInfo().carNum); - const s = "string" == typeof e ? e.trim() : "", o = s && "undefined" !== s ? s : a; - let r = {}; - try { - const e = localStorage.getItem("jhs_translate"); - e && (r = JSON.parse(e) || {}); - } catch (l) { - clog.warn("翻译缓存无法解析,已忽略旧缓存", l); - } - if (r[o]) return void i.text(r[o]); - try { - const e = await _e(a, "ja", "zh-CN"); - if (!n[0]?.isConnected) return; - i.text(e), r[o] = e, localStorage.setItem("jhs_translate", JSON.stringify(r)); - } catch (l) { - clog.error("翻译失败:", l), i.addClass("is-error").text(`翻译失败: ${l.message || String(l)}`); - } + const scope = await this.getRuntimeService("scope")(); + await renderTranslatedTitle({ root: options.root, carNum: e, translation: this.getRuntimeService("translation"), scope }); } } diff --git a/src/services/actress-info-service.js b/src/services/actress-info-service.js new file mode 100644 index 00000000..ee36aaed --- /dev/null +++ b/src/services/actress-info-service.js @@ -0,0 +1,32 @@ +// @ts-check + +export class ActressInfoService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations @param {import("./cache-service.js").CacheService} cache */ + constructor(integrations, cache) { this.integrations = integrations; this.cache = cache; } + + /** @param {string} name */ + profileUrl(name) { + for (const manifest of this.integrations.list("person.actress-info")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.profileUrl === "function") return adapter.profileUrl(name); + } + return ""; + } + + /** @param {string} name @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async lookup(name, options = {}) { + const normalized = String(name).trim() === "三上悠亞" ? "三上悠亜" : String(name).trim(); + if (!normalized) return null; + const key = `actress-info:${normalized}`; + const cached = this.cache.get(key, { scope: "public" }); + if (cached.hit) return cached.value; + for (const manifest of this.integrations.list("person.actress-info")) { + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter.lookup !== "function") continue; + const result = await adapter.lookup(normalized, options); + this.cache.set(key, result, { scope: "public", ttlMs: 604_800_000, negative: !result }); + return result; + } + return null; + } +} diff --git a/src/services/diagnostics-service.js b/src/services/diagnostics-service.js index 8ff9b301..9a7d6269 100644 --- a/src/services/diagnostics-service.js +++ b/src/services/diagnostics-service.js @@ -1,5 +1,16 @@ // @ts-check +const SENSITIVE_KEY = /^(?:authorization|cookie|set-cookie|password|token|secret|credential)$/i; + +/** @param {unknown} value @param {string} [key] @returns {unknown} */ +function sanitize(value, key = "") { + if (SENSITIVE_KEY.test(key)) return "[redacted]"; + if (Array.isArray(value)) return value.map((item) => sanitize(item)); + if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, sanitize(child, childKey)])); + if (typeof value !== "string") return value; + return value.replace(/(https?:\/\/)([^\s/@:]+):([^\s/@]+)@/gi, "$1[redacted]@"); +} + export class DiagnosticsService { constructor() { this.startedAt = performance.now(); @@ -13,6 +24,11 @@ export class DiagnosticsService { /** @type {Array>} */ this.errors = []; this.browserMetadata = null; + /** @type {string[]} */ + this.legacyPlugins = []; + this.legacyStartup = null; + /** @type {Array>} */ + this.legacyTimings = []; } /** @param {string} id @param {number} durationMs */ @@ -33,21 +49,31 @@ export class DiagnosticsService { recordError(error) { const value = error && typeof error === "object" && "toJSON" in error && typeof error.toJSON === "function" ? error.toJSON() - : { message: error instanceof Error ? error.message : String(error) }; - this.errors.push({ ...value, timestamp: new Date().toISOString() }); + : error instanceof Error ? { message: error.message } : error && typeof error === "object" ? { ...error } : { message: String(error) }; + this.errors.push({ ...(/** @type {Record} */ (sanitize(value))), timestamp: new Date().toISOString() }); if (this.errors.length > 100) this.errors.shift(); } /** @param {Record} metadata */ setBrowserMetadata(metadata) { this.browserMetadata = Object.freeze({ ...metadata }); } + /** @param {string[]} names @param {Record} startup @param {Array>} timings */ + setLegacyRuntime(names, startup, timings) { + this.legacyPlugins = [...names]; + this.legacyStartup = Object.freeze({ ...startup }); + this.legacyTimings = timings.map((item) => Object.freeze({ ...item })); + } + clearErrors() { this.errors = []; } exportSnapshot() { + const scopes = [...this.scopes.values()]; return Object.freeze({ activeFeatures: [...this.activeFeatures], activeContributions: [...this.activeContributions], - startupTimings: Object.fromEntries(this.startupTimings), activeScopes: [...this.scopes.values()], - globalListeners: 0, observers: [...this.scopes.values()].reduce((sum, scope) => sum + Number(scope.observers ?? 0), 0), + startupTimings: Object.fromEntries(this.startupTimings), activeScopes: scopes, + globalListeners: scopes.filter((scope) => scope.id === "app:root").reduce((sum, scope) => sum + Number(scope.listeners ?? 0), 0), + observers: scopes.reduce((sum, scope) => sum + Number(scope.observers ?? 0), 0), requestConsumers: this.requestStats.consumers, underlyingRequests: this.requestStats.underlying, cacheHits: this.cacheStats.hits, cacheMisses: this.cacheStats.misses, providerHealth: Object.fromEntries(this.providerHealth), errors: this.errors.map((error) => ({ ...error })), + legacyPlugins: [...this.legacyPlugins], legacyStartup: this.legacyStartup, legacyTimings: this.legacyTimings.map((item) => ({ ...item })), browser: this.browserMetadata, uptimeMs: performance.now() - this.startedAt, }); } diff --git a/src/services/magnet-service.js b/src/services/magnet-service.js index 2c2406e0..712fd7ef 100644 --- a/src/services/magnet-service.js +++ b/src/services/magnet-service.js @@ -1,8 +1,8 @@ // @ts-check export class MagnetService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ - constructor(providers) { this.providers = providers; } + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } /** @param {Record} movieRef @param {Record} [context] */ async search(movieRef, context = {}) { const results = []; @@ -15,4 +15,12 @@ export class MagnetService { } return results; } + /** @param {Record} movieRef @param {Record} [context] */ + async listNative(movieRef, context = {}) { + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : "javdb"; + const manifest = (this.integrations?.list("movie.magnets") ?? []).find((item) => item.id === providerId); + if (!manifest) return []; + const adapter = this.integrations?.getAdapter(manifest.id); + return typeof adapter?.listMagnets === "function" ? adapter.listMagnets(movieRef, context) : []; + } } diff --git a/src/services/movie-identity-service.js b/src/services/movie-identity-service.js index 89c7dd5e..8ddd1868 100644 --- a/src/services/movie-identity-service.js +++ b/src/services/movie-identity-service.js @@ -3,6 +3,8 @@ import { normalizeMovieCarNum } from "../core/movie-identity.js"; export class MovieIdentityService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(integrations = null) { this.integrations = integrations; } /** @param {unknown} value */ normalize(value) { return normalizeMovieCarNum(value); } /** @param {...unknown} candidates */ @@ -13,4 +15,86 @@ export class MovieIdentityService { } return null; } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async resolve(movieRef, options = {}) { + const carNum = this.normalize(movieRef.carNum); + if (!carNum) return null; + const candidates = this.integrations?.list("movie.search") ?? []; + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : null; + const ordered = providerId + ? candidates.filter((manifest) => manifest.id === providerId) + : [...candidates].sort((left, right) => Number(right.id === "javdb") - Number(left.id === "javdb")); + for (const manifest of ordered) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.resolveMovie !== "function") continue; + const resolved = await adapter.resolveMovie({ ...movieRef, carNum }, options); + if (resolved) return resolved; + } + return Object.freeze({ ...movieRef, carNum }); + } + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async detail(movieRef, options = {}) { + const candidates = this.integrations?.list("movie.detail") ?? []; + const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : movieRef.movieId ? "javdb" : null; + const ordered = providerId ? candidates.filter((manifest) => manifest.id === providerId) : candidates; + for (const manifest of ordered) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.getDetail === "function") return adapter.getDetail(movieRef, options); + } + return null; + } + /** @param {{period?: string, filter?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async rankings(options = {}) { + for (const manifest of this.integrations?.list("movie.ranking") ?? []) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.listRankings === "function") return adapter.listRankings(options); + } + return []; + } + /** @param {string} providerId @param {Record} query @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async catalog(providerId, query = {}, options = {}) { + const manifest = (this.integrations?.list("movie.catalog") ?? []).find((item) => item.id === providerId); + if (!manifest) throw new TypeError(`Movie catalog provider is unavailable: ${providerId}`); + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.listCatalog !== "function") throw new TypeError(`Movie catalog operation is unavailable: ${providerId}`); + return adapter.listCatalog(query, options); + } + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async people(providerId, movieRef, options = {}) { + const manifest = (this.integrations?.list("movie.credits") ?? []).find((item) => item.id === providerId); + if (!manifest) return Object.freeze({ actors: Object.freeze([]), seller: null }); + const adapter = this.integrations?.getAdapter(manifest.id); + return typeof adapter?.getPeople === "function" ? adapter.getPeople(movieRef, options) : Object.freeze({ actors: Object.freeze([]), seller: null }); + } + /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async images(providerId, movieRef, options = {}) { + const manifest = (this.integrations?.list("movie.images") ?? []).find((item) => item.id === providerId); + if (!manifest) return []; + const adapter = this.integrations?.getAdapter(manifest.id); + return typeof adapter?.getImages === "function" ? adapter.getImages(movieRef, options) : []; + } + /** @param {Record} movieRef @param {string[]} providerIds */ + sourceUrls(movieRef, providerIds) { + return Object.freeze(providerIds.map((providerId) => { + try { + const adapter = this.integrations?.getAdapter(providerId); + const url = typeof adapter?.detailUrl === "function" ? adapter.detailUrl(movieRef) : null; + return url ? Object.freeze({ providerId, url }) : null; + } catch { return null; } + }).filter(Boolean)); + } + /** @param {string} providerId @param {Record} movieRef */ + searchUrl(providerId, movieRef) { + try { + const adapter = this.integrations?.getAdapter(providerId); + return typeof adapter?.searchUrl === "function" ? adapter.searchUrl(movieRef) : null; + } catch { return null; } + } + /** @param {string} providerId @param {string} url */ + matchesProviderUrl(providerId, url) { + try { + const adapter = this.integrations?.getAdapter(providerId); + return typeof adapter?.matchesUrl === "function" && adapter.matchesUrl(url); + } catch { return false; } + } } diff --git a/src/services/related-service.js b/src/services/related-service.js index 627cc764..9fc0d4f8 100644 --- a/src/services/related-service.js +++ b/src/services/related-service.js @@ -3,7 +3,7 @@ export class RelatedService { /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ constructor(integrations) { this.integrations = integrations; } - /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ async list(movieRef, options = {}) { for (const manifest of this.integrations.list("movie.related")) { const adapter = this.integrations.getAdapter(manifest.id); diff --git a/src/services/review-service.js b/src/services/review-service.js index d5167986..b7bf47c7 100644 --- a/src/services/review-service.js +++ b/src/services/review-service.js @@ -3,7 +3,7 @@ export class ReviewService { /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ constructor(integrations) { this.integrations = integrations; } - /** @param {Record} movieRef @param {{signal?: AbortSignal}} [options] */ + /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ async list(movieRef, options = {}) { for (const manifest of this.integrations.list("movie.reviews")) { const adapter = this.integrations.getAdapter(manifest.id); diff --git a/src/services/screenshot-service.js b/src/services/screenshot-service.js index 82ff33ab..6535f253 100644 --- a/src/services/screenshot-service.js +++ b/src/services/screenshot-service.js @@ -1,8 +1,8 @@ // @ts-check export class ScreenshotService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ - constructor(providers) { this.providers = providers; } + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ + constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } /** @param {Record} movieRef @param {Record} [context] */ async resolve(movieRef, context = {}) { for (const provider of await this.providers.getAvailable("screenshot", context)) { @@ -12,6 +12,12 @@ export class ScreenshotService { if (result) return result; } catch (error) { this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } } + for (const manifest of this.integrations?.list("movie.images") ?? []) { + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.getImages !== "function") continue; + const result = await adapter.getImages(movieRef, context); + if (Array.isArray(result) && result.length) return result; + } return null; } } diff --git a/src/services/settings-service.js b/src/services/settings-service.js index d613fcc7..58df79ee 100644 --- a/src/services/settings-service.js +++ b/src/services/settings-service.js @@ -7,6 +7,7 @@ export class SettingsService extends EventTarget { this.storage = storage; this.validators = options.validators ?? {}; this.snapshotValue = Object.freeze({}); + this.writeChain = Promise.resolve(); } /** @param {string} key */ @@ -22,10 +23,14 @@ export class SettingsService extends EventTarget { async set(name, value, storageKey = "setting") { const validator = this.validators[name]; if (validator && !validator(value)) throw new TypeError(`Invalid setting: ${name}`); - const next = Object.freeze({ ...this.snapshotValue, [name]: value }); - await this.storage.set(storageKey, next); - this.snapshotValue = next; - this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name, value, snapshot: next }) })); - return next; + const operation = this.writeChain.then(async () => { + const next = Object.freeze({ ...this.snapshotValue, [name]: value }); + await this.storage.set(storageKey, next); + this.snapshotValue = next; + this.dispatchEvent(new CustomEvent("settings.changed", { detail: Object.freeze({ name, value, snapshot: next }) })); + return next; + }); + this.writeChain = operation.then(() => undefined, () => undefined); + return operation; } } diff --git a/src/services/translation-service.js b/src/services/translation-service.js new file mode 100644 index 00000000..dca368fc --- /dev/null +++ b/src/services/translation-service.js @@ -0,0 +1,15 @@ +// @ts-check + +export class TranslationService { + /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(integrations) { this.integrations = integrations; } + + /** @param {string} text @param {{sourceLanguage?: string, targetLanguage?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ + async translate(text, options = {}) { + const manifest = this.integrations.list("text.translate")[0]; + if (!manifest) throw new TypeError("Translation provider is unavailable"); + const adapter = this.integrations.getAdapter(manifest.id); + if (typeof adapter?.translate !== "function") throw new TypeError(`Translation operation is unavailable: ${manifest.id}`); + return adapter.translate(text, options); + } +} diff --git a/src/services/webdav-service.js b/src/services/webdav-service.js new file mode 100644 index 00000000..39b594d6 --- /dev/null +++ b/src/services/webdav-service.js @@ -0,0 +1,54 @@ +// @ts-check + +export class WebDavClient { + /** @param {import("./http-service.js").HttpService} http @param {string} davUrl @param {string} username @param {string} password */ + constructor(http, davUrl, username, password) { + this.http = http; + this.baseUrl = new URL(davUrl.endsWith("/") ? davUrl : `${davUrl}/`); + this.username = username; + this.password = password; + this.folderName = null; + } + authHeaders() { return { Authorization: `Basic ${btoa(`${this.username}:${this.password}`)}`, Depth: "1" }; } + /** @param {string} method @param {string} path @param {Record} [headers] @param {unknown} [body] */ + async request(method, path, headers = {}, body) { + const url = new URL(path.replace(/^\/+/, ""), this.baseUrl); + const response = await this.http.request({ + providerId: "webdav", method, url: url.href, headers: { ...this.authHeaders(), ...headers }, body, + responseType: "text", cacheScope: "none", + urlPolicy: { trustClass: "user-local", expectedOrigin: this.baseUrl.origin }, + }); + return response.data ?? response.responseText ?? ""; + } + /** @param {string} folder */ + async ensureFolder(folder) { + try { await this.request("MKCOL", folder); } + catch (error) { if (!(error instanceof Error) || !/HTTP (405|409)/.test(error.message)) throw error; } + } + /** @param {string} folder @param {string} name @param {string} content */ + async backup(folder, name, content) { await this.ensureFolder(folder); await this.request("PUT", `${folder}/${name}`, { "Content-Type": "text/plain" }, content); } + /** @param {string} folder */ + async getFileList(folder) { + const xml = String(await this.request("PROPFIND", folder, { "Content-Type": "application/xml" }, '')); + const responses = new DOMParser().parseFromString(xml, "text/xml").getElementsByTagNameNS("DAV:", "response"), files = []; + for (let index = 1; index < responses.length; index++) { + const item = responses[index], node = (/** @type {string} */ name) => item.getElementsByTagNameNS("DAV:", name)[0]?.textContent || ""; + const href = node("href"), name = node("displayname") || decodeURIComponent(href.replace(/\/$/, "").split("/").pop() || ""), size = node("getcontentlength"); + if (size !== "0") files.push({ fileId: name, name, size: Number(size), createTime: node("creationdate") || node("getlastmodified") }); + } + return files.reverse(); + } + /** @param {string} name */ + async deleteFile(name) { await this.request("DELETE", `${this.folderName}/${encodeURI(name)}`, { "Cache-Control": "no-cache" }); } + /** @param {string} folder */ + async getBackupList(folder) { this.folderName = folder; await this.ensureFolder(folder); return this.getFileList(folder); } + /** @param {string} name */ + async getFileContent(name) { return String(await this.request("GET", `${this.folderName}/${name}`, { Accept: "application/octet-stream" })); } +} + +export class WebDavService { + /** @param {import("./http-service.js").HttpService} http */ + constructor(http) { this.http = http; } + /** @param {{url: string, username: string, password: string}} config */ + createClient(config) { return new WebDavClient(this.http, config.url, config.username, config.password); } +} diff --git a/src/ui/detail/hosted-detail-surface.js b/src/ui/detail/hosted-detail-surface.js index 8b72f6af..749cb41f 100644 --- a/src/ui/detail/hosted-detail-surface.js +++ b/src/ui/detail/hosted-detail-surface.js @@ -8,12 +8,42 @@ export class HostedDetailSurface { /** @type {Readonly>} */ this.slots = Object.freeze({}); this.mounted = new Map(); + this.created = new Set(); } mount() { this.root = this.hostAdapter.locateDetailRoot(); + if (this.root) this.ensureOwnedSlots(); this.slots = Object.freeze({ ...this.hostAdapter.locateDetailSlots() }); return this; } + ensureOwnedSlots() { + if (!this.root) return; + const documentRuntime = this.root.ownerDocument; + let summary = this.root.querySelector(':scope > [data-jhs-slot="summary-actions"]'); + if (!summary) { + summary = documentRuntime.createElement("div"); + summary.className = "jhs-detail-owned-slot jhs-detail-owned-slot--summary-actions"; + summary.setAttribute("data-jhs-slot", "summary-actions"); + this.root.append(summary); + this.created.add(summary); + } + let group = this.root.querySelector(':scope > [data-jhs-slot-group="post-resource"]'); + if (!group) { + group = documentRuntime.createElement("div"); + group.className = "jhs-detail-post-resource"; + group.setAttribute("data-jhs-slot-group", "post-resource"); + this.root.append(group); + this.created.add(group); + } + for (const name of ["reviews", "related"]) { + if (group.querySelector(`[data-jhs-slot="${name}"]`)) continue; + const slot = documentRuntime.createElement("section"); + slot.className = `jhs-detail-owned-slot jhs-detail-owned-slot--${name}`; + slot.setAttribute("data-jhs-slot", name); + group.append(slot); + this.created.add(slot); + } + } /** @param {string} slot @param {Element} element */ mountPanel(slot, element) { const target = this.slots[slot]; @@ -24,7 +54,9 @@ export class HostedDetailSurface { } dispose() { for (const element of this.mounted.values()) element.remove(); + for (const element of this.created) element.remove(); this.mounted.clear(); + this.created.clear(); this.root = null; this.slots = Object.freeze({}); } diff --git a/src/ui/detail/screenshot-panel.js b/src/ui/detail/screenshot-panel.js new file mode 100644 index 00000000..e9cac0cc --- /dev/null +++ b/src/ui/detail/screenshot-panel.js @@ -0,0 +1,56 @@ +// @ts-check + +/** @param {unknown} value */ +function normalizeImageUrl(value) { + try { + const url = new URL(String(value || "")); + return url.protocol === "https:" ? url.href : null; + } catch { return null; } +} + +/** @param {any} host @param {string} url @param {string} alt */ +function renderImage(host, url, alt) { + const jq = /** @type {any} */ (globalThis).$; + const normalized = normalizeImageUrl(url); + if (!normalized) return null; + const image = jq("").attr({ src: normalized, alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"); + const button = jq('').attr("aria-label", `查看${alt}大图`).append(image); + host.empty().append(button).off("click.jhsScreenshot").on("click.jhsScreenshot", ".jhs-fc2-screenshot-thumbnail", (/** @type {any} */ event) => { + event.preventDefault(); event.stopPropagation(); + /** @type {any} */ (globalThis).showImageViewer(image[0]); + }); + return normalized; +} + +/** + * @param {{target: any, carNum: string, screenshot: import("../../services/screenshot-service.js").ScreenshotService, settings: Record, scope?: import("../../core/lifecycle-scope.js").LifecycleScope, isActive?: () => boolean}} options + */ +export async function renderScreenshotPanel(options) { + const jq = /** @type {any} */ (globalThis).$; + const host = jq(options.target), isActive = options.isActive ?? (() => true); + if (!host.length || options.settings.enableLoadScreenShot === "no") return host.empty(), null; + const load = async (resultHost = host) => { + resultHost.empty().append(jq("
    ").addClass("jhs-panel-state").text("正在加载缩略图…")); + try { + const images = await options.screenshot.resolve({ carNum: options.carNum }, { scope: options.scope }); + if (!isActive()) return null; + const image = Array.isArray(images) ? images[0] : images; + if (!image?.url) return resultHost.empty(), null; + return renderImage(resultHost, image.url, "缩略图"); + } catch (error) { + if (!isActive()) return null; + const state = jq("
    ").addClass("jhs-panel-state").text("缩略图加载失败 "); + const retry = jq('').on("click", () => void load(resultHost)); + resultHost.empty().append(state.append(retry)); + /** @type {any} */ (globalThis).clog?.error("缩略图加载失败", error); + return null; + } + }; + if (options.settings.screenshotMode !== "manual") return load(); + const tabs = jq('
    '); + const result = jq('
    ').text("请选择截图来源"); + const button = jq(''); + button.on("click", async () => { button.attr("aria-selected", "true"); await load(result); }); + host.empty().append(tabs.append(button), result); + return host; +} diff --git a/src/ui/translation/title-translation.js b/src/ui/translation/title-translation.js new file mode 100644 index 00000000..035ebf13 --- /dev/null +++ b/src/ui/translation/title-translation.js @@ -0,0 +1,27 @@ +// @ts-check + +/** + * @param {{root?: any, carNum?: string, translation: import("../../services/translation-service.js").TranslationService, scope?: import("../../core/lifecycle-scope.js").LifecycleScope}} options + */ +export async function renderTranslatedTitle(options) { + const jq = /** @type {any} */ (globalThis).$; + const root = options.root ? jq(options.root) : jq(document); + let title = root.find(".origin-title").first(); + if (!title.length) title = root.find(".current-title").first(); + if (!title.length) title = root.find("h3").first(); + if (!title.length) return; + const sourceText = title.text().trim(); + if (!sourceText) throw new TypeError("获取标题失败, 无法进行翻译"); + let translatedNode = title.nextAll(".translated-title").first(); + if (!translatedNode.length) translatedNode = jq('
    ').insertAfter(title); + translatedNode.removeClass("is-error").text("翻译中..."); + try { + const translated = await options.translation.translate(sourceText, { scope: options.scope }); + if (!title[0]?.isConnected) return; + translatedNode.text(translated); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + /** @type {any} */ (globalThis).clog?.error("翻译失败:", error); + translatedNode.addClass("is-error").text(`翻译失败: ${message}`); + } +} diff --git a/tests/123av-adapter.test.js b/tests/123av-adapter.test.js index f0b4b774..ecc0bdf2 100644 --- a/tests/123av-adapter.test.js +++ b/tests/123av-adapter.test.js @@ -5,15 +5,17 @@ import { describe, expect, it } from "vitest"; const readSource = (path) => readTestFile(join(import.meta.dirname, "..", path), "utf8"); const fc2Source = readSource("src/plugins/external-search/fc2-by-123av.js"); +const integrationSource = readSource("src/integrations/av123/manifest.js"); const otherSiteSource = readSource("src/plugins/external-search/other-site.js"); const httpSource = readSource("src/core/http.js"); describe("123AV Chinese adapter contract", () => { it("uses the confirmed Chinese list and encoded search routes", () => { - expect(fc2Source).toContain("/cn/makers/fc2?page=${sourcePage}"); - expect(fc2Source).toContain("/cn/search?keyword=${encodeURIComponent(this.keyword)}"); - expect(otherSiteSource).toContain('`${await this.getAv123Url()}/cn`'); - expect(otherSiteSource).toContain("encodeURIComponent(t)"); + expect(integrationSource).toContain("/cn/makers/fc2?page=${sourcePage}"); + expect(integrationSource).toContain("/cn/search?keyword=${encodeURIComponent(keyword)}"); + expect(fc2Source).not.toContain("123av.com"); + expect(otherSiteSource).toContain('providerId: "av123"'); + expect(otherSiteSource).toContain('getRuntimeService("movie").searchUrl'); }); it("removes obsolete 123AV selectors, routes and sorting controls", () => { @@ -30,8 +32,8 @@ describe("123AV Chinese adapter contract", () => { expect(httpSource).toContain("n.requestUrl = t"); expect(httpSource).toContain("n.finalUrl = e.finalUrl"); expect(httpSource).toContain("n.cfDiagnostics ="); - expect(fc2Source).toContain('cookiePartitionTopLevelSite: "https://123av.com"'); - expect(otherSiteSource).toContain('requestOptions: { cookiePartitionTopLevelSite: "https://123av.com" }'); + expect(integrationSource).toContain('cookiePartition: { topLevelSite: "https://123av.com" }'); + expect(otherSiteSource).not.toContain("cookiePartitionTopLevelSite"); }); it("does not classify ordinary 404 markup as a Cloudflare challenge", () => { diff --git a/tests/actress-info-service.test.js b/tests/actress-info-service.test.js new file mode 100644 index 00000000..8bf27966 --- /dev/null +++ b/tests/actress-info-service.test.js @@ -0,0 +1,14 @@ +import { expect, it, vi } from "vitest"; +import { ActressInfoService } from "../src/services/actress-info-service.js"; +import { CacheService } from "../src/services/cache-service.js"; + +it("normalizes aliases and caches actress information", async () => { + const lookup = vi.fn(async name => ({ name })); + const adapter = { lookup, profileUrl: name => `profile:${name}` }; + const integrations = { list: () => [{ id: "wikipedia" }], getAdapter: () => adapter }; + const service = new ActressInfoService(integrations, new CacheService()); + await expect(service.lookup("三上悠亞")).resolves.toEqual({ name: "三上悠亜" }); + await expect(service.lookup("三上悠亞")).resolves.toEqual({ name: "三上悠亜" }); + expect(lookup).toHaveBeenCalledOnce(); + expect(service.profileUrl("A")).toBe("profile:A"); +}); diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js index c6a45eeb..f7a3f540 100644 --- a/tests/architecture-runtime.test.js +++ b/tests/architecture-runtime.test.js @@ -1,12 +1,14 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; +import jquery from "jquery"; import { CommandRegistry } from "../src/app/command-registry.js"; import { DependencyContainer } from "../src/app/dependency-container.js"; import { FeatureRuntime, migrateDisabledPlugins } from "../src/app/feature-runtime.js"; import { ProviderRegistry } from "../src/app/provider-registry.js"; import { defineFeature, defineIntegration } from "../src/contracts/manifests.js"; -import { SERVICE } from "../src/contracts/tokens.js"; +import { PORT, SERVICE } from "../src/contracts/tokens.js"; +import { featureManifests } from "../src/features/catalog.js"; import { LifecycleScope } from "../src/core/lifecycle-scope.js"; import { BasePlugin, PluginManager } from "../src/core/plugin-manager.js"; import { DiagnosticsService } from "../src/services/diagnostics-service.js"; @@ -37,6 +39,50 @@ describe("v6.5 architecture runtime contracts", () => { expect(() => consumer.dependency("UndeclaredPlugin")).toThrow(/未声明依赖/); }); + it("registers legacy contributions from manifests without changing site order", async () => { + expect(featureManifests).toHaveLength(11); + window.matchMedia ??= () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + const $ = jquery; + vi.stubGlobal("$", $); + vi.stubGlobal("jQuery", $); + vi.stubGlobal("localforage", { INDEXEDDB: "indexeddb", createInstance: () => ({}) }); + const legacyRuntime = await import("../src/core/http.js"); + Object.assign(globalThis, legacyRuntime); + const { registerSitePlugins } = await import("../src/plugins/registry.js"); + const createRuntime = (site, disabled = []) => { + const diagnostics = new DiagnosticsService(); + const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.settings, {}).register(SERVICE.cache, {}); + const runtime = new FeatureRuntime({ container, commands: new CommandRegistry(), diagnostics, disabled, site, route: "list" }); + featureManifests.forEach((manifest) => runtime.register(manifest)); + return runtime; + }; + const javdb = new PluginManager(); + registerSitePlugins(javdb, createRuntime("javdb"), "javdb"); + expect(javdb.getPluginNames()).toEqual([ + "OneTwoThreeOfflinePlugin", "ListPagePlugin", "AutoPagePlugin", "Fc2Plugin", "FoldCategoryPlugin", "ListPageButtonPlugin", + "HistoryPlugin", "SettingPlugin", "NavBarPlugin", "HitShowPlugin", "TOP250Plugin", "SearchByImagePlugin", "CoverButtonPlugin", + "Fc2By123AvPlugin", "DetailPagePlugin", "DetailWorkspacePlugin", "ReviewPlugin", "RelatedPlugin", "DetailPageButtonPlugin", + "HighlightMagnetPlugin", "PreviewVideoPlugin", "FilterTitleKeywordPlugin", "ActressInfoPlugin", "OtherSitePlugin", "TranslatePlugin", + "WantAndWatchedVideosPlugin", "MagnetHubPlugin", "ScreenShotPlugin", "BlacklistPlugin", "FavoriteActressesPlugin", "NewVideoPlugin", + "TaskPlugin", "StatsPlugin", "MobileBottomBarPlugin", "OneOneFiveMatchPlugin", "UnifiedOfflinePlugin", "CompatibilityEnhancementsPlugin", + ]); + const javbus = new PluginManager(); + registerSitePlugins(javbus, createRuntime("javbus", ["ReviewPlugin"]), "javbus"); + expect(javbus.getPluginNames()).not.toContain("ReviewPlugin"); + expect(javbus.getPluginNames()).toContain("DetailWorkspacePlugin"); + }, 15_000); + + it("exports measured and redacted diagnostics", () => { + const diagnostics = new DiagnosticsService(); + diagnostics.updateScope({ id: "app:root", listeners: 3, observers: 1 }); + diagnostics.recordError({ message: "failed https://user:pass@example.com/x", authorization: "Bearer secret" }); + const snapshot = diagnostics.exportSnapshot(); + expect(snapshot.globalListeners).toBe(3); + expect(snapshot.observers).toBe(1); + expect(snapshot.errors[0]).toMatchObject({ authorization: "[redacted]" }); + expect(snapshot.errors[0].message).not.toContain("user:pass"); + }); + it("lazily activates a command owner and preserves contribution-level disabling", async () => { const commands = new CommandRegistry(); const diagnostics = new DiagnosticsService(); diff --git a/tests/blacklist-filter-ui.test.js b/tests/blacklist-filter-ui.test.js index fbc66e1a..385f654e 100644 --- a/tests/blacklist-filter-ui.test.js +++ b/tests/blacklist-filter-ui.test.js @@ -19,6 +19,7 @@ function createPlugin() { const JhsSelect = { setValue: vi.fn((target, value) => $(target).val(value)) }; let tabulatorOptions; function Tabulator(selector, options) { tabulatorOptions = options; } + const createJhsTable = (Runtime, selector, options) => new Runtime(selector, options); const context = vm.createContext({ console, Object, Array, Map, Set, Promise, Date, URL, $, BasePlugin, storageManager, JhsSelect, i: (target, key, value) => target[key] = value, @@ -26,7 +27,7 @@ function createPlugin() { B: "actor", P: "actress", D: "censored", A: "uncensored", T: "javdb", I: "javbus", l: false, r: true, window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, normalizeHttpUrl: value => { try { const url = new URL(String(value), dom.window.location.href); return ["http:", "https:"].includes(url.protocol) ? url.href : null; } catch { return null; } }, parseNumberSetting: (value, fallback) => Number.isFinite(Number(value)) ? Number(value) : fallback, - renderStateView: vi.fn(), utils: {}, show: {}, clog: {}, navigator: {}, jhsEventBus: {}, stateService: {}, gmHttp: {}, Tabulator + renderStateView: vi.fn(), utils: {}, show: {}, clog: {}, navigator: {}, jhsEventBus: {}, stateService: {}, gmHttp: {}, Tabulator, createJhsTable }); vm.runInContext(`${source};globalThis.TestPlugin=BlacklistPlugin`, context); const plugin = new context.TestPlugin; diff --git a/tests/browser/harness/runtime.js b/tests/browser/harness/runtime.js index 0137a653..2570b637 100644 --- a/tests/browser/harness/runtime.js +++ b/tests/browser/harness/runtime.js @@ -19,8 +19,9 @@ export async function fulfillHostFixtures(context) { }); } -export async function injectUserscriptRuntime(page) { +export async function injectUserscriptRuntime(page, options = {}) { const startupErrors = []; + const browserVersion = page.context().browser()?.version() || "unknown"; page.on("pageerror", (error) => startupErrors.push(error.stack || error.message)); page.on("console", (message) => { if (message.type() === "error") startupErrors.push(message.text()); @@ -29,7 +30,15 @@ export async function injectUserscriptRuntime(page) { await page.addScriptTag({ path: join(browserRoot, "node_modules", "localforage", "dist", "localforage.min.js") }); await page.addScriptTag({ path: join(browserRoot, "node_modules", "tabulator-tables", "dist", "js", "tabulator.min.js") }); await page.addStyleTag({ path: join(browserRoot, "node_modules", "tabulator-tables", "dist", "css", "tabulator.min.css") }); - await page.evaluate(() => { + await page.evaluate(async (disabledPlugins) => { + const forage = window.localforage.createInstance({ driver: window.localforage.INDEXEDDB, name: "JAV-JHS", version: 1, storeName: "appData" }); + await forage.setItem("setting", { + translateTitle: "no", + ...(disabledPlugins?.length ? { disabledPlugins: JSON.stringify(disabledPlugins) } : {}), + }); + }, options.disabledPlugins || []); + await page.evaluate((version) => { + window.__jhsBrowserTestMetadata = { fixture: true, version }; window.__jhsBrowserDiagnostics = { requests: [], startedAt: performance.now() }; window.unsafeWindow = window; window.GM_getValue = async (_key, fallback) => fallback; @@ -73,7 +82,7 @@ export async function injectUserscriptRuntime(page) { confirm(_message, options, yes) { const id = this.open(options); yes?.(id); return id; }, msg() {} }; - }); + }, browserVersion); await page.addScriptTag({ path: join(repoRoot, "JHS.user.js") }); try { await page.waitForFunction(() => Boolean(window.unsafeWindow?.pluginManager?.getStartupReport), null, { timeout: 15_000 }); diff --git a/tests/browser/specs/host-routing.spec.js b/tests/browser/specs/host-routing.spec.js index a71661a4..20ca857c 100644 --- a/tests/browser/specs/host-routing.spec.js +++ b/tests/browser/specs/host-routing.spec.js @@ -14,7 +14,18 @@ for (const [label, url, expectedPlugin] of [ await expect(page.locator("body")).toBeVisible(); await assertNoHorizontalOverflow(page); await page.waitForTimeout(250); - const initialRequests = await page.evaluate(() => window.__jhsBrowserDiagnostics.requests.length); - expect(initialRequests, "deterministic fixture startup request budget").toBeLessThanOrEqual(budget.browserFixture.maximumInitialRequests[label]); + const initialRequests = await page.evaluate(() => window.__jhsBrowserDiagnostics.requests); + expect(initialRequests.length, `deterministic fixture startup request budget: ${JSON.stringify(initialRequests)}`).toBeLessThanOrEqual(budget.browserFixture.maximumInitialRequests[label]); }); } + +test("legacy disabled plugin migrates to one contribution only", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers storage migration"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/v/test-id", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page, { disabledPlugins: ["ReviewPlugin"] }); + const pluginNames = await page.evaluate(() => window.unsafeWindow.pluginManager.getPluginNames()); + expect(pluginNames).not.toContain("ReviewPlugin"); + expect(pluginNames).toContain("RelatedPlugin"); + expect(pluginNames).toContain("DetailWorkspacePlugin"); +}); diff --git a/tests/credential-crypto.test.js b/tests/credential-crypto.test.js new file mode 100644 index 00000000..58fd8e4f --- /dev/null +++ b/tests/credential-crypto.test.js @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { decryptCredential, decryptData, encryptCredential, encryptData } from "../src/core/credential-crypto.js"; + +describe("credential crypto", () => { + it("round-trips encrypted data without exposing plaintext", async () => { + const encrypted = await encryptData("secret-value"); + expect(encrypted).not.toContain("secret-value"); + await expect(decryptData(encrypted)).resolves.toBe("secret-value"); + }); + + it("marks credentials once and preserves unencrypted compatibility values", async () => { + const encrypted = await encryptCredential("token"); + expect(encrypted).toMatch(/^AES:/); + await expect(encryptCredential(encrypted)).resolves.toBe(encrypted); + await expect(decryptCredential(encrypted)).resolves.toBe("token"); + await expect(decryptCredential("legacy-token")).resolves.toBe("legacy-token"); + }); +}); diff --git a/tests/detail-car-num.test.js b/tests/detail-car-num.test.js index e08bf7a3..935d884b 100644 --- a/tests/detail-car-num.test.js +++ b/tests/detail-car-num.test.js @@ -96,7 +96,7 @@ function loadScreenshotPlugin(overrides = {}) { const warn = vi.fn(), debug = vi.fn(), error = vi.fn(), cachedRequest = vi.fn(), context = vm.createContext({ console, URL, - BasePlugin: class {}, + BasePlugin: class { getRuntimeService(name) { return "scope" === name ? async () => overrides.scope : overrides[name]; } }, normalizeCarNum: loadCarNumHelpers().normalize, clog: { warn, debug, error, log: vi.fn() }, storageManager: { cachedRequest }, @@ -193,77 +193,13 @@ describe("detail car number propagation", () => { expect(cachedRequest).not.toHaveBeenCalled(); }); - it("checks matching JavStore results in source order until CLICK HERE! is found", async () => { - const candidates = [ - { text: "IPZZ-479 first", href: "/first-ipzz479-pn.html" }, - { text: "IPZZ-479 second", href: "/second-ipzz479-pn.html" }, - { text: "another title", href: "/other-pn.html" } - ]; - const searchLinks = { - filter: (predicate) => { - const matches = candidates.filter((element, index) => predicate(index, element)); - return { map: (mapper) => ({ get: () => matches.map((element, index) => mapper(index, element)) }) }; - } - }; - const noPreview = { filter: () => noPreview, first: () => noPreview, attr: () => undefined }; - const previewLink = { filter: (predicate) => (predicate(0, { text: "CLICK HERE!" }), previewLink), first: () => previewLink, - attr: () => "//img.javstore.net/images/2025/02/13/MOSAIC-ARCHIVE-ipzz-479_s.jpg" }; - const searchDom = { find: vi.fn(() => searchLinks) }; - const firstDetail = { find: vi.fn(() => noPreview) }; - const secondDetail = { find: vi.fn(() => previewLink) }; - const gmHttp = { get: vi.fn().mockResolvedValueOnce("search-html").mockResolvedValueOnce("first-html").mockResolvedValueOnce("second-html") }; - const utils = { htmlTo$dom: vi.fn((html) => ({ "search-html": searchDom, "first-html": firstDetail, "second-html": secondDetail })[html]) }; - const { Plugin } = loadScreenshotPlugin({ gmHttp, utils, $: (element) => ({ text: () => element.text, attr: () => element.href }) }); - await expect(new Plugin().getJavStoreScreenShot("IPZZ-479")).resolves.toBe("https://img.javstore.net/images/2025/02/13/MOSAIC-ARCHIVE-ipzz-479_s.jpg"); - expect(gmHttp.get.mock.calls.map(([url]) => url)).toEqual([ - "https://javstore.net/search?q=IPZZ-479", - "https://javstore.net/first-ipzz479-pn.html", - "https://javstore.net/second-ipzz479-pn.html" - ]); - expect(searchDom.find).toHaveBeenCalledWith('a[href$="-pn.html"]'); - expect(firstDetail.find).toHaveBeenCalledWith("a"); - expect(secondDetail.find).toHaveBeenCalledWith("a"); - }); - - it("returns null for a JavStore 404 or empty search without requesting details", async () => { - const gmHttp = { get: vi.fn().mockResolvedValue(null) }; - const { Plugin } = loadScreenshotPlugin({ gmHttp }); - await expect(new Plugin().getJavStoreScreenShot("ABF-142")).resolves.toBeNull(); - expect(gmHttp.get).toHaveBeenCalledTimes(1); - expect(gmHttp.get.mock.calls[0][4]).toEqual({ ignoreNotFound: true }); - }); - - it("returns null when no -pn.html result text matches the car number", async () => { - const links = { filter: (predicate) => (predicate(0, { text: "another title" }), { map: () => ({ get: () => [] }) }) }; - const gmHttp = { get: vi.fn().mockResolvedValue("search-html") }; - const utils = { htmlTo$dom: vi.fn(() => ({ find: vi.fn(() => links) })) }; - const { Plugin, debug } = loadScreenshotPlugin({ gmHttp, utils, $: (element) => ({ text: () => element.text }) }); - await expect(new Plugin().getJavStoreScreenShot("IPZZ-479")).resolves.toBeNull(); - expect(gmHttp.get).toHaveBeenCalledTimes(1); - expect(debug).toHaveBeenCalledWith("JavStore, 查询番号无结果:", "https://javstore.net/search?q=IPZZ-479"); - }); - - it("continues after a candidate 404 and returns the next valid preview", async () => { - const candidateElements = [ { text: "IPZZ-479 first", href: "/missing-pn.html" }, { text: "IPZZ-479 second", href: "/valid-pn.html" } ]; - const searchLinks = { filter: (predicate) => ({ map: (mapper) => ({ get: () => candidateElements.filter((element, index) => predicate(index, element)).map((element, index) => mapper(index, element)) }) }) }; - const preview = { filter: (predicate) => (predicate(0, { text: "CLICK HERE!" }), preview), first: () => preview, attr: () => "/preview.th.jpg" }; - const gmHttp = { get: vi.fn().mockResolvedValueOnce("search-html").mockResolvedValueOnce(null).mockResolvedValueOnce("detail-html") }; - const utils = { htmlTo$dom: vi.fn((html) => "search-html" === html ? { find: () => searchLinks } : { find: () => preview }) }; - const { Plugin } = loadScreenshotPlugin({ gmHttp, utils, $: (element) => ({ text: () => element.text, attr: () => element.href }) }); - await expect(new Plugin().getJavStoreScreenShot("IPZZ-479")).resolves.toBe("https://javstore.net/preview.jpg"); - expect(gmHttp.get).toHaveBeenCalledTimes(3); - }); - - it("upgrades legacy JavStore cache entries and normalizes new cache writes", async () => { - const loaded = loadScreenshotPlugin(); - loaded.cachedRequest.mockResolvedValueOnce({ url: "http://img.javstore.net/legacy.jpg" }); - await expect(new loaded.Plugin().getCachedProviderScreenshot("javstore", "IPZZ-479", vi.fn())).resolves.toEqual({ - url: "https://img.javstore.net/legacy.jpg", source: "javstore", detailUrl: null - }); - loaded.cachedRequest.mockImplementationOnce(async (key, ttl, loader) => (await loader()).data); - await expect(new loaded.Plugin().getCachedProviderScreenshot("javstore", "IPZZ-480", async () => "http://img2.javstore.net/new.jpg")).resolves.toEqual({ - url: "https://img2.javstore.net/new.jpg", source: "javstore", detailUrl: null + it("resolves screenshots through the declared ScreenshotService", async () => { + const resolve = vi.fn(async () => [{ url: "https://img.javstore.net/preview.jpg", providerId: "javstore" }]); + const { Plugin } = loadScreenshotPlugin({ screenshot: { resolve }, scope: { id: "detail" } }); + await expect(new Plugin().getServiceScreenshot("IPZZ-479")).resolves.toEqual({ + url: "https://img.javstore.net/preview.jpg", source: "javstore", detailUrl: null, }); + expect(resolve).toHaveBeenCalledWith({ carNum: "IPZZ-479" }, { scope: { id: "detail" } }); }); it("normalizes a legacy JavStore URL again at the image rendering boundary", () => { @@ -278,7 +214,6 @@ describe("detail car number propagation", () => { describe("source regression contracts", () => { it("keeps detail consumers on the strict getPageInfo object contract", () => { for (const file of [ - "src/plugins/translate/translate.js", "src/plugins/status/detail-page-button.js", "src/plugins/image-viewer/preview-video.js", "src/plugins/image-viewer/screenshot.js" @@ -287,6 +222,9 @@ describe("source regression contracts", () => { expect(source).toContain("getPageInfo()"); expect(source).not.toContain("getPageInfo()?.carNum"); } + const translate = readTestFile(join(repoRoot, "src/plugins/translate/translate.js"), "utf8"); + expect(translate).toContain('getRuntimeService("translation")'); + expect(translate).not.toContain("getPageInfo()?.carNum"); }); it("treats opted-in HTTP 404 responses as neutral results before retry accounting", () => { diff --git a/tests/detail-panel-lifecycle.test.js b/tests/detail-panel-lifecycle.test.js index b8659a5b..514812f2 100644 --- a/tests/detail-panel-lifecycle.test.js +++ b/tests/detail-panel-lifecycle.test.js @@ -6,14 +6,15 @@ import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; -function loadPanels() { +function loadPanels({ settings = { enableLoadReview: "yes", enableLoadRelated: "yes", enableTitleSelectFilter: "yes" } } = {}) { const dom = new JSDOM('
    ', { url: "https://javdb.com/v/a" }), $ = jqueryFactory(dom.window); - const reviewFetch = vi.fn(async movieId => [{ username: movieId, score: 1, created_at: "2026-08-22", likes_count: 0, content: "ok" }]); - const relatedFetch = vi.fn(async movieId => [{ relatedId: movieId, name: movieId, movieCount: 1, collectionCount: 0, viewCount: 0, createTime: "today" }]); + const reviewFetch = vi.fn(async movieRef => [{ author: movieRef.movieId, score: 1, createdAt: "2026-08-22", likes: 0, content: "ok" }]); + const relatedFetch = vi.fn(async movieRef => [{ id: movieRef.movieId, name: movieRef.movieId, movieCount: 1, collectionCount: 0, viewCount: 0, createdAt: "today" }]); + const runtimeServices = { review: { list: reviewFetch }, related: { list: relatedFetch }, scope: async () => null }; const context = vm.createContext({ - document: dom.window.document, window: dom.window, $, BasePlugin: class { getBean() { return null; } }, _: "yes", C: "no", r: false, l: false, - storageManager: { getSetting: vi.fn(async key => "reviewCount" === key ? 20 : "yes"), getReviewFilterKeywordList: vi.fn(async () => []), saveReviewFilterKeyword: vi.fn(), saveSettingItem: vi.fn() }, - R: reviewFetch, K: relatedFetch, utils: { formatDate: value => value, q: (event, message, callback) => callback() }, show: { error: vi.fn(), ok: vi.fn() }, clog: { error: vi.fn(), warn: vi.fn() }, escapeHtml: String, + document: dom.window.document, window: dom.window, $, BasePlugin: class { getBean() { return null; } getRuntimeService(name) { return runtimeServices[name]; } }, _: "yes", C: "no", r: false, l: false, + storageManager: { getSetting: vi.fn(async (key, fallback) => null == key ? settings : "reviewCount" === key ? 20 : settings[key] ?? fallback), getReviewFilterKeywordList: vi.fn(async () => []), saveReviewFilterKeyword: vi.fn(), saveSettingItem: vi.fn() }, + utils: { formatDate: value => value, q: (event, message, callback) => callback() }, show: { error: vi.fn(), ok: vi.fn() }, clog: { error: vi.fn(), warn: vi.fn() }, escapeHtml: String, i: (target, key, value) => (target[key] = value) }); vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8")};globalThis.Review=ReviewPlugin`, context); @@ -22,6 +23,15 @@ function loadPanels() { } describe("detail panel instance lifecycle", () => { + it("does not fetch reviews until expanded when the preference was never saved", async () => { + const { $, Review, reviewFetch } = loadPanels({ settings: {} }), plugin = new Review; + const panel = await plugin.showReview("A", $("#review-a")); + expect(reviewFetch).not.toHaveBeenCalled(); + expect(panel.find(".jhs-review-toggle").attr("aria-expanded")).toBe("false"); + panel.find(".jhs-review-toggle").trigger("click"); + await vi.waitFor((() => expect(reviewFetch).toHaveBeenCalledOnce())); + }); + it("keeps review floor, loading state, and panel ownership per target", async () => { const { $, Review, reviewFetch } = loadPanels(), plugin = new Review; const panelA = await plugin.showReview("A", $("#review-a")), panelB = await plugin.showReview("B", $("#review-b")); @@ -54,7 +64,7 @@ describe("detail panel instance lifecycle", () => { it("adopts owned workspace headers and does not prefetch the second review page", async () => { const { $, Review, reviewFetch } = loadPanels(), plugin = new Review; const section = $('
    ').appendTo("body"); - reviewFetch.mockResolvedValueOnce(Array.from({ length: 20 }, ((_, index) => ({ username: String(index), score: 1, created_at: "2026", likes_count: 0, content: "ok" })))); + reviewFetch.mockResolvedValueOnce(Array.from({ length: 20 }, ((_, index) => ({ author: String(index), score: 1, createdAt: "2026", likes: 0, content: "ok" })))); const panel = await plugin.showReview("owned", section.find("#owned-reviews"), { ownedSection: section, isActive: () => true }); expect(panel.children(".jhs-panel-header")).toHaveLength(0), expect(section.find('> header [data-jhs-section-actions="reviews"] .jhs-review-toggle')).toHaveLength(1); expect(reviewFetch).toHaveBeenCalledTimes(1), expect(panel.find(".jhs-review-load-more")).toHaveLength(1); diff --git a/tests/fc2-owned-workspace.test.js b/tests/fc2-owned-workspace.test.js index eba5a616..eed0be49 100644 --- a/tests/fc2-owned-workspace.test.js +++ b/tests/fc2-owned-workspace.test.js @@ -5,6 +5,10 @@ import vm from "node:vm"; import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; +import { createJavDbAdapter } from "../src/integrations/javdb/manifest.js"; +import { HttpService } from "../src/services/http-service.js"; +import { ExternalUrlPolicy } from "../src/services/external-url-policy.js"; +import { CacheService } from "../src/services/cache-service.js"; const repoRoot = join(import.meta.dirname, ".."); const fc2Source = readTestFile(join(repoRoot, "src/plugins/external-search/fc2.js"), "utf8"); @@ -30,20 +34,13 @@ function loadWorkspace() { } function loadResolver(responseFactory) { - const cacheCalls = [], get = vi.fn(responseFactory), storageManager = { - async cachedRequest(key, ttl, loader) { - const loaded = await loader(); - cacheCalls.push({ key, ttl, loaded }); - return loaded?.data ?? loaded; - } - }, local = new Map(), context = vm.createContext({ - storageManager, gmHttp: { get }, localStorage: { getItem: key => local.get(key) || null, setItem: (key, value) => local.set(key, value) }, md5: String, - normalizeCarNum: value => String(value || "").trim().toUpperCase().replace("FC2-PPV-", "FC2-") || null, - utils: { formatDate: String }, show: { error: vi.fn() } - }); - const source = readTestFile(join(repoRoot, "src/core/javdb-api.js"), "utf8"); - vm.runInContext(`${source};globalThis.resolveId=resolveJavDbMovieId`, context); - return { resolveId: context.resolveId, get, cacheCalls }; + const requests = [], get = vi.fn(responseFactory), port = { request: async options => { + requests.push(options); + return { status: 200, data: await get(options), finalUrl: options.url }; + } }; + const http = new HttpService(port, new ExternalUrlPolicy(), { cache: new CacheService() }); + const adapter = createJavDbAdapter(http, () => "signature"); + return { resolveId: carNum => adapter.resolveMovie({ carNum }).then(value => value?.movieId || null), get, requests }; } function loadWantApi({ encryptedToken = "encrypted", response = { success: 1 } } = {}) { @@ -126,15 +123,15 @@ describe("FC2 owned detail workspace", () => { expect(listPageSource).toContain("{ source: fc2Source }"); expect(historySource).toContain("resolveFc2Source(t)"); expect(stateServiceSource).toContain('"fc2Source"'); - expect(fc2Source).toContain("&source=${encodeURIComponent(source)}"); + expect(fc2Source).toContain('target.searchParams.set("source", source)'); }); it("restores source links, magnet metadata and scoped quality filtering", () => { expect(fc2Source).toContain("FC2PPVDB"); expect(fc2Source).toContain("FC2 市场"); - expect(fc2Source).toContain("item.hd && tags.append"); - expect(fc2Source).toContain("item.cnsub && tags.append"); - expect(fc2Source).toContain("item.created_at"); + expect(fc2Source).toContain("item.hasHdTag && tags.append"); + expect(fc2Source).toContain("item.hasSubtitleTag && tags.append"); + expect(fc2Source).toContain("item.createdAt"); expect(fc2Source).toContain('data-jhs-action="filter-native-magnets"'); expect(highlightMagnetSource).toContain("assessMagnet({"); }); @@ -205,14 +202,15 @@ describe("JavDB exact movie resolver", () => { const first = loaded.resolveId("FC2-123"), second = loaded.resolveId("fc2-123"); release({ data: { movies: [ { id: "wrong", number: "FC2-1234" }, { id: "right", number: "FC2-123" } ] } }); await expect(Promise.all([ first, second ])).resolves.toEqual([ "right", "right" ]), expect(loaded.get).toHaveBeenCalledOnce(); - expect(loaded.cacheCalls[0].loaded.__jhsCacheTtl).toBe(7 * 864e5); + expect(loaded.requests).toHaveLength(1); + expect(loaded.requests[0]).toMatchObject({ providerId: "javdb", cacheScope: "public", ttlMs: 7 * 864e5 }); }); it("uses a short negative cache value but does not convert network errors into misses", async () => { const miss = loadResolver(async () => ({ data: { movies: [ { id: "near", number: "FC2-999" } ] } })); - await expect(miss.resolveId("FC2-123")).resolves.toBeNull(), expect(miss.cacheCalls[0].loaded).toEqual({ __jhsCacheTtl: 6 * 36e5, data: { miss: true } }); + await expect(miss.resolveId("FC2-123")).resolves.toBeNull(), expect(miss.get).toHaveBeenCalledOnce(); const failed = loadResolver(async () => { throw new Error("network"); }); - await expect(failed.resolveId("FC2-123")).rejects.toThrow("network"), expect(failed.cacheCalls).toHaveLength(0); + await expect(failed.resolveId("FC2-123")).rejects.toThrow("network"), expect(failed.get).toHaveBeenCalledOnce(); }); }); diff --git a/tests/fixtures/integrations/fc2content/images.html b/tests/fixtures/integrations/fc2content/images.html new file mode 100644 index 00000000..dbe7992f --- /dev/null +++ b/tests/fixtures/integrations/fc2content/images.html @@ -0,0 +1 @@ +
    diff --git a/tests/fixtures/integrations/fc2ppvdb/detail.html b/tests/fixtures/integrations/fc2ppvdb/detail.html index 19bcada1..b77fc1ff 100644 --- a/tests/fixtures/integrations/fc2ppvdb/detail.html +++ b/tests/fixtures/integrations/fc2ppvdb/detail.html @@ -1 +1 @@ -

    Fixture FC2 title

    +

    Fixture FC2 title

    女優:Actress A
    販売者:Seller S
    diff --git a/tests/fixtures/integrations/google-translate/translation.json b/tests/fixtures/integrations/google-translate/translation.json new file mode 100644 index 00000000..19a99492 --- /dev/null +++ b/tests/fixtures/integrations/google-translate/translation.json @@ -0,0 +1,3 @@ +{ + "translation": "翻译结果" +} diff --git a/tests/fixtures/integrations/javbus/detail.html b/tests/fixtures/integrations/javbus/detail.html index 8b6b4010..21e44ba6 100644 --- a/tests/fixtures/integrations/javbus/detail.html +++ b/tests/fixtures/integrations/javbus/detail.html @@ -1 +1 @@ -

    識別碼: ABC-123

    +

    ABC-123 Fixture title

    識別碼: ABC-123

    diff --git a/tests/fixtures/integrations/wikipedia/actress.html b/tests/fixtures/integrations/wikipedia/actress.html new file mode 100644 index 00000000..ed4c7d94 --- /dev/null +++ b/tests/fixtures/integrations/wikipedia/actress.html @@ -0,0 +1,8 @@ + + + + + + + +
    誕生日1993年8月16日
    現年齢33歳
    身長159 cm
    体重― / 48 kg
    スリーサイズ83 - 59 - 88 cm
    ブラサイズE
    diff --git a/tests/history-multistate.test.js b/tests/history-multistate.test.js index 09abfd07..77e392b2 100644 --- a/tests/history-multistate.test.js +++ b/tests/history-multistate.test.js @@ -5,11 +5,12 @@ import vm from "node:vm"; import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; +import { HistorySelectionModel } from "../src/features/history/history-selection-model.js"; function loadHistory() { const dom = new JSDOM("", { url: "https://javdb.com/users/collection_codes" }), $ = jqueryFactory(dom.window), patch = vi.fn().mockResolvedValue(), toggle = vi.fn().mockResolvedValue(), close = vi.fn(), confirm = vi.fn((event, message, callback) => callback()); const layer = { open: vi.fn(), close }, context = vm.createContext({ - document: dom.window.document, window: dom.window, $, BasePlugin: class {}, Tabulator: class {}, layer, + document: dom.window.document, window: dom.window, $, BasePlugin: class {}, HistorySelectionModel, Tabulator: class {}, layer, normalizeStateFlags: flags => ({ favorite: false, downloaded: false, watched: false, blocked: false, ...flags }), stateService: { patch, toggle }, legacyActionToFlag: action => ({ filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" })[action], utils: { getDialogArea: () => [], q: confirm }, show: { error: vi.fn() }, clog: { debug: vi.fn() }, i: (target, key, value) => (target[key] = value), r: true, l: false, d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", m: "屏蔽", v: "收藏", y: "下载", k: "观看" diff --git a/tests/history-selection.test.js b/tests/history-selection.test.js index 8d98a1ea..568f10d9 100644 --- a/tests/history-selection.test.js +++ b/tests/history-selection.test.js @@ -5,6 +5,7 @@ import vm from "node:vm"; import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; +import { HistorySelectionModel } from "../src/features/history/history-selection-model.js"; function createRecords(count = 120) { return Array.from({ length: count }, ((_, index) => ({ @@ -48,6 +49,7 @@ function loadHistory(records = createRecords()) { window: dom.window, $, BasePlugin: class {}, + HistorySelectionModel, Tabulator: class {}, layer: {}, storageManager: { getCarList }, @@ -106,7 +108,7 @@ describe("History cross-page selection", () => { expect(page.dataList).toHaveLength(50); expect(page.totalCount).toBe(120); - plugin.historySelectAll = true; + plugin.historySelectionModel.selectAllFiltered(); const selected = await plugin.getHistoryBatchSelection(); expect(selected).toHaveLength(120); expect(selected.at(-1).carNum).toBe("ABC-120"); @@ -118,21 +120,21 @@ describe("History cross-page selection", () => { const checkbox = plugin.createHistorySelectAllCheckbox(); checkbox.checked = true; checkbox.dispatchEvent(new checkbox.ownerDocument.defaultView.Event("change")); - expect(plugin.historySelectAll).toBe(true); + expect(plugin.isHistoryAllFiltered()).toBe(true); expect(table.selectedData).toHaveLength(50); checkbox.checked = false; checkbox.dispatchEvent(new checkbox.ownerDocument.defaultView.Event("change")); - expect(plugin.historySelectAll).toBe(false); + expect(plugin.isHistoryAllFiltered()).toBe(false); expect(table.selectedData).toHaveLength(0); }); it("restores visible selection after paging and honors exclusions", async () => { const { plugin, table } = loadHistory(); await plugin.getDataList(1, 50, []); - plugin.historySelectAll = true; - plugin.historyExcludedCarNums.add("ABC-052"); - plugin.historyExcludedCarNums.add("ABC-053"); + plugin.historySelectionModel.selectAllFiltered(); + plugin.historySelectionModel.excluded.add("ABC-052"); + plugin.historySelectionModel.excluded.add("ABC-053"); table.currentData = createRecords().slice(50, 100); plugin.syncHistoryPageSelection(); @@ -149,62 +151,63 @@ describe("History cross-page selection", () => { it("keeps ordinary selection limited to selected rows on the current page", async () => { const { plugin, table } = loadHistory(); table.selectedData = table.currentData.slice(0, 3); + table.selectedData.forEach((item => plugin.historySelectionModel.set(item, true))); expect(await plugin.getHistoryBatchSelection()).toEqual(table.selectedData); }); it("clears selection for filtering but preserves it while sorting", async () => { const { plugin, root, table } = loadHistory(); - plugin.historySelectAll = true; - plugin.historyExcludedCarNums.add("ABC-002"); + plugin.historySelectionModel.selectAllFiltered(); + plugin.historySelectionModel.excluded.add("ABC-002"); await plugin.getDataList(1, 50, [ { field: "carNum", dir: "desc" } ]); - expect(plugin.historySelectAll).toBe(true); - expect(plugin.historyExcludedCarNums.has("ABC-002")).toBe(true); + expect(plugin.isHistoryAllFiltered()).toBe(true); + expect(plugin.historySelectionModel.excluded.has("ABC-002")).toBe(true); root.find("#searchCarNum").val("ABC-1"); await plugin.reloadTable(); - expect(plugin.historySelectAll).toBe(false); - expect(plugin.historyExcludedCarNums.size).toBe(0); + expect(plugin.isHistoryAllFiltered()).toBe(false); + expect(plugin.historySelectionModel.excluded.size).toBe(0); expect(table.setPage).toHaveBeenCalledWith(1); }); it("uses the same full selection for confirmation and batch mutation", async () => { const { plugin, root, patch, confirmation } = loadHistory(); await plugin.getDataList(1, 50, []); - plugin.historySelectAll = true; - plugin.historyExcludedCarNums.add("ABC-002"); - plugin.historyExcludedCarNums.add("ABC-003"); + plugin.historySelectionModel.selectAllFiltered(); + plugin.historySelectionModel.excluded.add("ABC-002"); + plugin.historySelectionModel.excluded.add("ABC-003"); plugin.bindHistoryActions(root); root.find(".multiple-history-favoriteBtn").trigger("click"); await vi.waitFor((() => expect(patch).toHaveBeenCalledOnce())); expect(patch.mock.calls[0][0]).toHaveLength(118); expect(confirmation.mock.calls[0][1]).toContain("已选择 118 条,排除 2 条"); - expect(plugin.historySelectAll).toBe(false); + expect(plugin.isHistoryAllFiltered()).toBe(false); }); it("retains the full selection when a batch mutation fails", async () => { const { plugin, root, patch, show } = loadHistory(); await plugin.getDataList(1, 50, []); - plugin.historySelectAll = true; - plugin.historyExcludedCarNums.add("ABC-002"); + plugin.historySelectionModel.selectAllFiltered(); + plugin.historySelectionModel.excluded.add("ABC-002"); patch.mockRejectedValueOnce(new Error("write failed")); plugin.bindHistoryActions(root); root.find(".multiple-history-favoriteBtn").trigger("click"); await vi.waitFor((() => expect(show.error).toHaveBeenCalledWith("操作失败,请稍后重试"))); - expect(plugin.historySelectAll).toBe(true); - expect(plugin.historyExcludedCarNums.has("ABC-002")).toBe(true); + expect(plugin.isHistoryAllFiltered()).toBe(true); + expect(plugin.historySelectionModel.excluded.has("ABC-002")).toBe(true); }); it("retains selection when a delete resolves without changing records", async () => { const { plugin, root, remove, show } = loadHistory(); await plugin.getDataList(1, 50, []); - plugin.historySelectAll = true; + plugin.historySelectionModel.selectAllFiltered(); plugin.bindHistoryActions(root); root.find(".multiple-history-deleteBtn").trigger("click"); await vi.waitFor((() => expect(show.error).toHaveBeenCalledWith("提供的番号中没有一个存在于列表中。"))); expect(remove.mock.calls[0][0]).toHaveLength(120); - expect(plugin.historySelectAll).toBe(true); + expect(plugin.isHistoryAllFiltered()).toBe(true); }); }); diff --git a/tests/hit-show.test.js b/tests/hit-show.test.js index 4339d8a4..278caa35 100644 --- a/tests/hit-show.test.js +++ b/tests/hit-show.test.js @@ -10,13 +10,20 @@ function loadHitShow({ movies = [], fetchScore = vi.fn(), cache = {}, sortMethod const dom = new JSDOM('

    榜单

    ', { url: "https://javdb.com/advanced_search?handlePlayback=1&period=daily" }); const $ = jqueryFactory(dom.window), storage = new Map([["jhs_score_info", JSON.stringify(cache)], ["jhs_sortMethod", sortMethod]]); $.expr.pseudos.hidden = element => "none" === element.style.display; + const runtimeCache = { ...cache }, cacheService = { + get: vi.fn(() => ({ hit: Object.keys(runtimeCache).length > 0, value: runtimeCache })), + set: vi.fn((key, value) => Object.assign(runtimeCache, value)), + }, settings = { snapshot: () => ({ sortMethod }) }; const loadingClose = vi.fn(), sortItems = vi.fn().mockResolvedValue(), listPage = { replaceHdImg: vi.fn(), doFilter: vi.fn().mockResolvedValue(), applyVisibility: vi.fn(), bindMovieDetailNavigation: vi.fn(), getSelector: () => ({ itemSelector: ".movie-list .item" }) }, coverButton = { addSvgBtn: vi.fn() }; const context = vm.createContext({ - BasePlugin: class { getBean(name) { return { ListPagePlugin: listPage, ListPageButtonPlugin: { sortItems }, CoverButtonPlugin: coverButton }[name]; } }, + BasePlugin: class { + getBean(name) { return { ListPagePlugin: listPage, ListPageButtonPlugin: { sortItems }, CoverButtonPlugin: coverButton }[name]; } + getRuntimeService(name) { return { scope: async () => ({ signal: { aborted: false } }), movie: { rankings: async () => movies, detail: async ({ movieId }) => fetchScore(movieId) }, settings, cache: cacheService }[name]; } + }, i: (target, key, value) => (target[key] = value), $, document: dom.window.document, window: dom.window, - URLSearchParams, isHitShowPage: () => true, W: vi.fn().mockResolvedValue(movies), V: fetchScore, + URLSearchParams, isHitShowPage: () => true, loading: () => ({ close: loadingClose }), clog: { error: vi.fn(), warn: vi.fn() }, localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, escapeHtml: value => $("").text(String(value ?? "")).html(), @@ -24,7 +31,7 @@ function loadHitShow({ movies = [], fetchScore = vi.fn(), cache = {}, sortMethod }); const source = readTestFile(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); vm.runInContext(`${source};globalThis.HitShowPlugin=HitShowPlugin`, context); - return { plugin: new context.HitShowPlugin(), context, dom, $, storage, loadingClose, sortItems, listPage, coverButton }; + return { plugin: new context.HitShowPlugin(), context, dom, $, storage, runtimeCache, loadingClose, sortItems, listPage, coverButton, fetchScore }; } const movie = id => ({ id, number: id.toUpperCase(), release_date: "2026-08-16", origin_title: id, cover_url: "https://example.com/a.jpg", magnets_count: 0 }); @@ -33,11 +40,11 @@ describe("HitShowPlugin lifecycle", () => { it("closes loading after rendering while score requests continue", async () => { let resolveScore; const pendingScore = new Promise(resolve => { resolveScore = resolve; }); - const { plugin, loadingClose, context, dom } = loadHitShow({ movies: [movie("a")], fetchScore: vi.fn(() => pendingScore) }); + const { plugin, loadingClose, dom, fetchScore } = loadHitShow({ movies: [movie("a")], fetchScore: vi.fn(() => pendingScore) }); await plugin.handlePlayback(); expect(dom.window.document.querySelector("#a")).not.toBeNull(); expect(loadingClose).toHaveBeenCalledOnce(); - expect(context.V).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(fetchScore).toHaveBeenCalledOnce()); resolveScore({ score: 4.5, watchedCount: 20 }); await pendingScore; }); @@ -60,14 +67,14 @@ describe("HitShowPlugin lifecycle", () => { it("uses legacy cache and isolates one failed score request", async () => { const fetchScore = vi.fn(id => "bad" === id ? Promise.reject(new Error("500")) : Promise.resolve({ score: 3.5, watchedCount: 7 })); - const movies = [movie("cached"), movie("bad"), movie("good")], { plugin, $, storage } = loadHitShow({ movies, fetchScore, cache: { cached: { html: "4.2分,由88人评价", watchedCount: 88 } } }); + const movies = [movie("cached"), movie("bad"), movie("good")], { plugin, $, runtimeCache } = loadHitShow({ movies, fetchScore, cache: { cached: { html: "4.2分,由88人评价", watchedCount: 88 } } }); $(".container").append(`
    ${plugin.markDataListHtml(movies)}
    `); await expect(plugin.loadScore(movies)).resolves.toBeUndefined(); expect(fetchScore).not.toHaveBeenCalledWith("cached"); expect($("#cached").attr("data-jhs-rate-count")).toBe("88"); expect($("#bad").attr("data-jhs-rate-count")).toBe("0"); expect($("#good").attr("data-jhs-rate-count")).toBe("7"); - expect(JSON.parse(storage.get("jhs_score_info")).good.watchedCount).toBe(7); + expect(runtimeCache.good.watchedCount).toBe(7); }); it("sorts once after scores only for rate-count mode", async () => { diff --git a/tests/hosted-detail-surface.test.js b/tests/hosted-detail-surface.test.js new file mode 100644 index 00000000..4631ce87 --- /dev/null +++ b/tests/hosted-detail-surface.test.js @@ -0,0 +1,32 @@ +import { JSDOM } from "jsdom"; +import { describe, expect, it } from "vitest"; +import { HostedDetailSurface } from "../src/ui/detail/hosted-detail-surface.js"; + +describe("HostedDetailSurface", () => { + it("creates stable owned slots before legacy contributions start and disposes them", () => { + const dom = new JSDOM('
    '); + const root = dom.window.document.querySelector("main"); + const hostAdapter = { + locateDetailRoot: () => root, + locateDetailSlots: () => ({ + resources: root.querySelector("#resources"), + reviews: root.querySelector('[data-jhs-slot="reviews"]'), + related: root.querySelector('[data-jhs-slot="related"]'), + }), + }; + const surface = new HostedDetailSurface(hostAdapter).mount(); + expect(surface.slots.reviews).not.toBeNull(); + expect(surface.slots.related).not.toBeNull(); + surface.dispose(); + expect(root.querySelector("[data-jhs-slot-group]")).toBeNull(); + expect(root.querySelector('[data-jhs-slot="summary-actions"]')).toBeNull(); + }); + + it("does not remove slots owned by an existing host workspace", () => { + const dom = new JSDOM('
    '); + const root = dom.window.document.querySelector("main"), group = root.querySelector("[data-jhs-slot-group]"); + const hostAdapter = { locateDetailRoot: () => root, locateDetailSlots: () => ({ reviews: root.querySelector('[data-jhs-slot="reviews"]'), related: root.querySelector('[data-jhs-slot="related"]') }) }; + new HostedDetailSurface(hostAdapter).mount().dispose(); + expect(root.querySelector("[data-jhs-slot-group]")).toBe(group); + }); +}); diff --git a/tests/http-service.test.js b/tests/http-service.test.js index 4d751d7b..4d95934a 100644 --- a/tests/http-service.test.js +++ b/tests/http-service.test.js @@ -54,4 +54,16 @@ describe("HTTP, URL and settings contracts", () => { expect(settings.snapshot()).toEqual({ theme: "dark" }); expect(() => settings.set("theme", "invalid")).rejects.toThrow(/Invalid/); }); + + it("serializes concurrent settings writes without dropping fields", async () => { + const values = new Map([["setting", { existing: true }]]), storage = { + get: vi.fn(async key => values.get(key)), + set: vi.fn(async (key, value) => { await Promise.resolve(); values.set(key, value); }), + }; + const settings = new SettingsService(storage); + await settings.load(); + await Promise.all([settings.set("sortMethod", "date"), settings.set("theme", "dark")]); + expect(settings.snapshot()).toEqual({ existing: true, sortMethod: "date", theme: "dark" }); + expect(values.get("setting")).toEqual(settings.snapshot()); + }); }); diff --git a/tests/integrations/av123.contract.test.js b/tests/integrations/av123.contract.test.js index 5c18b3ee..2b4d7ce3 100644 --- a/tests/integrations/av123.contract.test.js +++ b/tests/integrations/av123.contract.test.js @@ -1,6 +1,7 @@ import jquery from "jquery"; import { JSDOM } from "jsdom"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; +import { create123AvAdapter } from "../../src/integrations/av123/manifest.js"; import { parse123AvCards } from "../../src/integrations/av123/parser.js"; it("normalizes 123AV cards without returning DOM", () => { @@ -9,3 +10,24 @@ it("normalizes 123AV cards without returning DOM", () => { expect(cards[0]).toMatchObject({ carNum: "FC2-123", title: "Title" }); expect(cards[0]).not.toBeInstanceOf(dom.window.Node); }); + +it("loads search and detail contracts through HttpService", async () => { + const runtime = new JSDOM(); + vi.stubGlobal("DOMParser", runtime.window.DOMParser); + const request = vi.fn(async options => ({ + status: 200, + data: options.url.includes("/search?") || options.url.includes("/makers/") + ? '' + : '

    FC2-PPV-123 — Detail title

    发布日期 2026-01-02

    ', + finalUrl: options.url, + })); + const adapter = create123AvAdapter({ request }); + await expect(adapter.listCatalog({ page: 1 })).resolves.toMatchObject({ + items: [expect.objectContaining({ carNum: "FC2-123", providerId: "av123" })], + }); + const movieRef = await adapter.resolveMovie({ carNum: "FC2-123" }); + expect(movieRef).toMatchObject({ carNum: "FC2-123", providerId: "av123", url: "https://123av.com/cn/v/fc2-ppv-123" }); + await expect(adapter.getDetail(movieRef)).resolves.toMatchObject({ carNum: "FC2-123", title: "Detail title", releaseDate: "2026-01-02" }); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ providerId: "av123", cacheScope: "public", requestOptions: { cookiePartition: { topLevelSite: "https://123av.com" } } }), undefined); + vi.unstubAllGlobals(); +}); diff --git a/tests/integrations/av123.malformed.test.js b/tests/integrations/av123.malformed.test.js index d69a9f7b..3ec355c9 100644 --- a/tests/integrations/av123.malformed.test.js +++ b/tests/integrations/av123.malformed.test.js @@ -1,9 +1,19 @@ import jquery from "jquery"; import { JSDOM } from "jsdom"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; +import { create123AvAdapter } from "../../src/integrations/av123/manifest.js"; import { parse123AvCards } from "../../src/integrations/av123/parser.js"; it("treats malformed 123AV cards as an empty contract", () => { const dom = new JSDOM('
    '); expect(parse123AvCards(jquery(dom.window)(dom.window.document))).toEqual([]); }); + +it("keeps malformed provider responses out of normalized contracts", async () => { + const runtime = new JSDOM(); + vi.stubGlobal("DOMParser", runtime.window.DOMParser); + const adapter = create123AvAdapter({ request: async options => ({ status: 200, data: "", finalUrl: options.url }) }); + await expect(adapter.resolveMovie({ carNum: "FC2-123" })).resolves.toBeNull(); + await expect(adapter.getDetail({ carNum: "FC2-123" })).rejects.toThrow(/malformed/); + vi.unstubAllGlobals(); +}); diff --git a/tests/integrations/dmm.contract.test.js b/tests/integrations/dmm.contract.test.js index 0a674919..f12d3a6b 100644 --- a/tests/integrations/dmm.contract.test.js +++ b/tests/integrations/dmm.contract.test.js @@ -1,3 +1,18 @@ // @vitest-environment jsdom -import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { parseDmmPreview } from "../../src/integrations/dmm/parser.js"; -it("normalizes a DMM preview", () => { document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/dmm/preview.html"), "utf8"); expect(parseDmmPreview(jquery(document), "https://www.dmm.co.jp/").url).toBe("https://www.dmm.co.jp/preview/sample.mp4"); }); +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createDmmAdapter } from "../../src/integrations/dmm/manifest.js"; +import { parseDmmPreview } from "../../src/integrations/dmm/parser.js"; + +const fixture = readFileSync(join(import.meta.dirname, "../fixtures/integrations/dmm/preview.html"), "utf8"); + +it("normalizes a DMM preview", () => { + expect(parseDmmPreview(fixture, "https://www.dmm.co.jp/").url).toBe("https://www.dmm.co.jp/preview/sample.mp4"); +}); + +it("loads previews through the declared HTTP boundary", async () => { + const request = vi.fn(async options => ({ status: 200, data: fixture, finalUrl: options.url })); + await expect(createDmmAdapter({ request }).getPreview({ url: "https://www.dmm.co.jp/player/1" })).resolves.toEqual({ url: "https://www.dmm.co.jp/preview/sample.mp4" }); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ providerId: "dmm", cacheScope: "public", ttlMs: 604_800_000 }), undefined); +}); diff --git a/tests/integrations/dmm.malformed.test.js b/tests/integrations/dmm.malformed.test.js index ddd2c9b0..c864bb07 100644 --- a/tests/integrations/dmm.malformed.test.js +++ b/tests/integrations/dmm.malformed.test.js @@ -1,3 +1,13 @@ // @vitest-environment jsdom -import jquery from "jquery"; import { expect, it } from "vitest"; import { parseDmmPreview } from "../../src/integrations/dmm/parser.js"; -it("rejects malformed DMM preview HTML", () => { document.body.replaceChildren(); expect(() => parseDmmPreview(jquery(document), "https://www.dmm.co.jp/")).toThrow(/missing/); }); +import { expect, it } from "vitest"; +import { createDmmAdapter } from "../../src/integrations/dmm/manifest.js"; +import { parseDmmPreview } from "../../src/integrations/dmm/parser.js"; + +it("rejects malformed DMM preview HTML", () => { + expect(() => parseDmmPreview("", "https://www.dmm.co.jp/")).toThrow(/missing/); +}); + +it("does not silently normalize an unavailable provider", async () => { + const adapter = createDmmAdapter({ request: async () => { throw new Error("provider unavailable"); } }); + await expect(adapter.getPreview({ url: "https://www.dmm.co.jp/player/1" })).rejects.toThrow("provider unavailable"); +}); diff --git a/tests/integrations/fc2content.contract.test.js b/tests/integrations/fc2content.contract.test.js new file mode 100644 index 00000000..67fcb8eb --- /dev/null +++ b/tests/integrations/fc2content.contract.test.js @@ -0,0 +1,14 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createFc2ContentAdapter } from "../../src/integrations/fc2content/manifest.js"; +import { parseFc2ContentImages } from "../../src/integrations/fc2content/parser.js"; + +const fixture = readFileSync(join(import.meta.dirname, "../fixtures/integrations/fc2content/images.html"), "utf8"); +it("normalizes FC2 content sample images", () => expect(parseFc2ContentImages(fixture, "https://adult.contents.fc2.com/article/123/")).toHaveLength(2)); +it("loads FC2 content images through HttpService", async () => { + const request = vi.fn(async options => ({ data: fixture, finalUrl: options.url })); + await expect(createFc2ContentAdapter({ request }).getImages({ carNum: "FC2-123" })).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ providerId: "fc2content" })])); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ providerId: "fc2content", cacheScope: "public" }), undefined); +}); diff --git a/tests/integrations/fc2content.malformed.test.js b/tests/integrations/fc2content.malformed.test.js new file mode 100644 index 00000000..dd08849c --- /dev/null +++ b/tests/integrations/fc2content.malformed.test.js @@ -0,0 +1,11 @@ +// @vitest-environment jsdom +import { expect, it } from "vitest"; +import { createFc2ContentAdapter } from "../../src/integrations/fc2content/manifest.js"; +import { parseFc2ContentImages } from "../../src/integrations/fc2content/parser.js"; + +it("returns an empty normalized contract for missing images", () => expect(parseFc2ContentImages("", "https://adult.contents.fc2.com/article/1/")).toEqual([])); +it("does not request invalid FC2 identifiers", async () => { + let calls = 0; + await expect(createFc2ContentAdapter({ request: async () => (++calls, {}) }).getImages({ carNum: "ABC-123" })).resolves.toEqual([]); + expect(calls).toBe(0); +}); diff --git a/tests/integrations/fc2ppvdb.contract.test.js b/tests/integrations/fc2ppvdb.contract.test.js index 5652ad47..8c3b8fcc 100644 --- a/tests/integrations/fc2ppvdb.contract.test.js +++ b/tests/integrations/fc2ppvdb.contract.test.js @@ -1,3 +1,27 @@ // @vitest-environment jsdom -import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { parseFc2PpvDbDetail } from "../../src/integrations/fc2ppvdb/parser.js"; -it("normalizes an FC2PPVDB detail", () => { document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/fc2ppvdb/detail.html"), "utf8"); expect(parseFc2PpvDbDetail(jquery(document), "https://fc2ppvdb.com/articles/12345")).toMatchObject({ carNum: "FC2-12345", title: "Fixture FC2 title" }); }); +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createFc2PpvDbAdapter } from "../../src/integrations/fc2ppvdb/manifest.js"; +import { parseFc2PpvDbDetail, parseFc2PpvDbPeople } from "../../src/integrations/fc2ppvdb/parser.js"; + +const fixture = readFileSync(join(import.meta.dirname, "../fixtures/integrations/fc2ppvdb/detail.html"), "utf8"); + +it("normalizes an FC2PPVDB detail", () => { + expect(parseFc2PpvDbDetail(fixture, "https://fc2ppvdb.com/articles/12345")).toMatchObject({ carNum: "FC2-12345", title: "Fixture FC2 title" }); +}); + +it("normalizes FC2PPVDB actress and seller links", () => { + expect(parseFc2PpvDbPeople(fixture, "https://fc2ppvdb.com/articles/12345")).toEqual({ + actors: [{ name: "Actress A", url: "https://fc2ppvdb.com/actresses/a" }], + seller: { name: "Seller S", url: "https://fc2ppvdb.com/sellers/s" }, + }); +}); + +it("loads details through the declared HTTP boundary", async () => { + const request = vi.fn(async options => ({ status: 200, data: fixture, finalUrl: options.url })); + const adapter = createFc2PpvDbAdapter({ request }); + await expect(adapter.getDetail({ carNum: "FC2-12345" })).resolves.toMatchObject({ carNum: "FC2-12345" }); + await expect(adapter.getPeople({ carNum: "FC2-12345" })).resolves.toMatchObject({ actors: [{ name: "Actress A" }] }); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ providerId: "fc2ppvdb", cacheScope: "public" }), undefined); +}); diff --git a/tests/integrations/fc2ppvdb.malformed.test.js b/tests/integrations/fc2ppvdb.malformed.test.js index c39cf551..78f4c4b0 100644 --- a/tests/integrations/fc2ppvdb.malformed.test.js +++ b/tests/integrations/fc2ppvdb.malformed.test.js @@ -1,3 +1,13 @@ // @vitest-environment jsdom -import jquery from "jquery"; import { expect, it } from "vitest"; import { parseFc2PpvDbDetail } from "../../src/integrations/fc2ppvdb/parser.js"; -it("rejects malformed FC2PPVDB HTML", () => { document.body.replaceChildren(); expect(() => parseFc2PpvDbDetail(jquery(document), "https://fc2ppvdb.com/articles/12345")).toThrow(/malformed/); }); +import { expect, it } from "vitest"; +import { createFc2PpvDbAdapter } from "../../src/integrations/fc2ppvdb/manifest.js"; +import { parseFc2PpvDbDetail } from "../../src/integrations/fc2ppvdb/parser.js"; + +it("rejects malformed FC2PPVDB HTML", () => { + expect(() => parseFc2PpvDbDetail("", "https://fc2ppvdb.com/articles/12345")).toThrow(/malformed/); +}); + +it("rejects invalid identifiers before issuing a request", async () => { + const adapter = createFc2PpvDbAdapter({ request: async () => { throw new Error("must not request"); } }); + await expect(adapter.getDetail({ carNum: "invalid" })).rejects.toThrow(/identifier/); +}); diff --git a/tests/integrations/google-translate.contract.test.js b/tests/integrations/google-translate.contract.test.js new file mode 100644 index 00000000..3da36d1f --- /dev/null +++ b/tests/integrations/google-translate.contract.test.js @@ -0,0 +1,17 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createGoogleTranslateAdapter } from "../../src/integrations/google-translate/manifest.js"; +import { parseGoogleTranslation } from "../../src/integrations/google-translate/parser.js"; + +const fixture = JSON.parse(readFileSync(join(import.meta.dirname, "../fixtures/integrations/google-translate/translation.json"), "utf8")); + +it("normalizes a translation response", () => { + expect(parseGoogleTranslation(fixture)).toBe("翻译结果"); +}); + +it("loads translations through the declared HTTP boundary", async () => { + const request = vi.fn(async options => ({ data: fixture, finalUrl: options.url })); + await expect(createGoogleTranslateAdapter({ request }).translate("原題")).resolves.toBe("翻译结果"); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ providerId: "google-translate", responseType: "json", cacheScope: "public" }), undefined); +}); diff --git a/tests/integrations/google-translate.malformed.test.js b/tests/integrations/google-translate.malformed.test.js new file mode 100644 index 00000000..4c38662b --- /dev/null +++ b/tests/integrations/google-translate.malformed.test.js @@ -0,0 +1,7 @@ +import { expect, it } from "vitest"; +import { parseGoogleTranslation } from "../../src/integrations/google-translate/parser.js"; + +it("rejects malformed translation responses", () => { + expect(() => parseGoogleTranslation({ translation: "" })).toThrow("text is missing"); + expect(() => parseGoogleTranslation([])).toThrow("response is invalid"); +}); diff --git a/tests/integrations/javbus.contract.test.js b/tests/integrations/javbus.contract.test.js index 4f02c9e6..b809e932 100644 --- a/tests/integrations/javbus.contract.test.js +++ b/tests/integrations/javbus.contract.test.js @@ -3,12 +3,36 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; -import { describe, expect, it } from "vitest"; -import { parseJavBusMovieRef } from "../../src/integrations/javbus/parser.js"; +import { describe, expect, it, vi } from "vitest"; +import { createJavBusAdapter } from "../../src/integrations/javbus/manifest.js"; +import { parseJavBusMovieDetail, parseJavBusMovieRef } from "../../src/integrations/javbus/parser.js"; +import { CacheService } from "../../src/services/cache-service.js"; +import { ExternalUrlPolicy } from "../../src/services/external-url-policy.js"; +import { HttpService } from "../../src/services/http-service.js"; + +const fixture = readFileSync(join(import.meta.dirname, "../fixtures/integrations/javbus/detail.html"), "utf8"); describe("javbus normalized contract", () => { it("returns a MovieRef from the fixture", () => { - document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/javbus/detail.html"), "utf8"); + document.documentElement.innerHTML = fixture; expect(parseJavBusMovieRef(jquery(window), "https://www.javbus.com/ABC-123")).toEqual({ carNum: "ABC-123", url: "https://www.javbus.com/ABC-123" }); }); + + it("normalizes remote detail and cover contracts", () => { + expect(parseJavBusMovieDetail(fixture, "https://www.javbus.com/ABC-123")).toMatchObject({ + carNum: "ABC-123", title: "ABC-123 Fixture title", coverUrl: "https://www.javbus.com/covers/abc-123.jpg", providerId: "javbus", + }); + }); + + it("deduplicates concurrent detail and image consumers", async () => { + const request = vi.fn(async options => ({ status: 200, data: fixture, finalUrl: options.url })); + const http = new HttpService({ request }, new ExternalUrlPolicy(), { cache: new CacheService() }); + const adapter = createJavBusAdapter(http); + const [detail, images] = await Promise.all([ + adapter.getDetail({ carNum: "ABC-123" }), adapter.getImages({ carNum: "ABC-123" }), + ]); + expect(detail.title).toBe("ABC-123 Fixture title"); + expect(images).toEqual([{ url: "https://www.javbus.com/covers/abc-123.jpg", providerId: "javbus" }]); + expect(request).toHaveBeenCalledOnce(); + }); }); diff --git a/tests/integrations/javbus.malformed.test.js b/tests/integrations/javbus.malformed.test.js index e92588ba..c06cd0e4 100644 --- a/tests/integrations/javbus.malformed.test.js +++ b/tests/integrations/javbus.malformed.test.js @@ -2,6 +2,7 @@ import jquery from "jquery"; import { describe, expect, it } from "vitest"; +import { createJavBusAdapter } from "../../src/integrations/javbus/manifest.js"; import { parseJavBusMovieRef } from "../../src/integrations/javbus/parser.js"; describe("javbus malformed response", () => { @@ -9,4 +10,9 @@ describe("javbus malformed response", () => { document.body.replaceChildren(); expect(() => parseJavBusMovieRef(jquery(window), "https://www.javbus.com/")).toThrow(/identifier/); }); + + it("rejects invalid references before requesting a provider", async () => { + const adapter = createJavBusAdapter({ request: async () => { throw new Error("must not request"); } }); + await expect(adapter.getDetail({ carNum: "" })).rejects.toThrow(/reference/); + }); }); diff --git a/tests/integrations/javdb.contract.test.js b/tests/integrations/javdb.contract.test.js index 9fb0be26..0c1ae5ea 100644 --- a/tests/integrations/javdb.contract.test.js +++ b/tests/integrations/javdb.contract.test.js @@ -1,9 +1,43 @@ import jquery from "jquery"; import { JSDOM } from "jsdom"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { parseJavDbActorList } from "../../src/integrations/javdb/parser.js"; +import { createJavDbAdapter } from "../../src/integrations/javdb/manifest.js"; it("normalizes JavDB actor contracts", () => { const dom = new JSDOM(''); expect(parseJavDbActorList(jquery(dom.window)(dom.window.document), "https://javdb.com")).toMatchObject({ state: "valid", actors: [{ starId: "a1", name: "Actor" }] }); }); + +it("normalizes JavDB review and related API contracts through HttpService", async () => { + const request = vi.fn() + .mockResolvedValueOnce({ data: { data: { reviews: [{ username: "A", content: "text", score: 4, created_at: "2026-01-01", likes_count: 2 }] } } }) + .mockResolvedValueOnce({ data: { data: { lists: [{ id: 7, name: "List", movies_count: 3, collections_count: 2, views_count: 9, created_at: "2026-01-02" }] } } }); + const adapter = createJavDbAdapter({ request }, () => "signature"); + await expect(adapter.listReviews({ movieId: "m1" })).resolves.toEqual([{ author: "A", content: "text", score: 4, createdAt: "2026-01-01", likes: 2 }]); + await expect(adapter.listRelated({ movieId: "m1" })).resolves.toEqual([{ id: "7", name: "List", movieCount: 3, collectionCount: 2, viewCount: 9, createdAt: "2026-01-02" }]); + expect(request.mock.calls[0][0]).toMatchObject({ providerId: "javdb", cacheScope: "public", urlPolicy: { trustClass: "builtin-public", hosts: ["jdforrepam.com"] } }); +}); + +it("resolves an exact normalized movie reference through the JavDB search contract", async () => { + const request = vi.fn(async () => ({ data: { data: { movies: [{ id: 9, number: "ABC-123" }, { id: 10, number: "ABC-124" }] } } })); + const adapter = createJavDbAdapter({ request }, () => "signature"); + await expect(adapter.resolveMovie({ carNum: "abc-123" })).resolves.toEqual({ carNum: "ABC-123", movieId: "9", providerId: "javdb" }); +}); + +it("normalizes JavDB detail and magnet contracts", async () => { + const request = vi.fn() + .mockResolvedValueOnce({ data: { data: { movie: { id: 9, number: "ABC-123", origin_title: "Title", actors: [{ id: 2, name: "Actor", gender: 0 }], preview_images: [{ large_url: "https://old/rhe951l4q/a.jpg" }], watched_count: 8 } } } }) + .mockResolvedValueOnce({ data: { data: { magnets: [{ hash: "abc", name: "Magnet", hd: true, cnsub: false, size: 2048, files_count: 2 }] } } }); + const adapter = createJavDbAdapter({ request }, () => "signature"); + await expect(adapter.getDetail({ movieId: "9" })).resolves.toMatchObject({ movieId: "9", carNum: "ABC-123", title: "Title", imageUrls: ["https://c0.jdbstatic.com/a.jpg"] }); + await expect(adapter.listMagnets({ movieId: "9" })).resolves.toEqual([{ hash: "abc", title: "Magnet", hasHdTag: true, hasSubtitleTag: false, createdAt: null, seeders: 0, sizeMb: 2048, fileCount: 2, providerId: "javdb" }]); +}); + +it("normalizes JavDB ranking contracts", async () => { + const request = vi.fn(async () => ({ data: { data: { movies: [{ id: 9, number: "ABC-123", origin_title: "Title", cover_url: "https://old/rhe951l4q/a.jpg", has_cnsub: true, magnets_count: 2 }] } } })); + await expect(createJavDbAdapter({ request }, () => "signature").listRankings({ period: "weekly" })).resolves.toEqual([ + { movieId: "9", carNum: "ABC-123", title: "Title", coverUrl: "https://c0.jdbstatic.com/a.jpg", releaseDate: null, hasSubtitle: true, magnetCount: 2, newMagnets: false, providerId: "javdb" }, + ]); + expect(request.mock.calls[0][0].url).toContain("period=weekly"); +}); diff --git a/tests/integrations/javdb.malformed.test.js b/tests/integrations/javdb.malformed.test.js index 754e1b36..14651382 100644 --- a/tests/integrations/javdb.malformed.test.js +++ b/tests/integrations/javdb.malformed.test.js @@ -2,8 +2,18 @@ import jquery from "jquery"; import { JSDOM } from "jsdom"; import { expect, it } from "vitest"; import { parseJavDbActorList } from "../../src/integrations/javdb/parser.js"; +import { createJavDbAdapter } from "../../src/integrations/javdb/manifest.js"; it("classifies malformed JavDB actor pages", () => { const dom = new JSDOM("
    "); expect(parseJavDbActorList(jquery(dom.window)(dom.window.document), "https://javdb.com").state).toBe("invalid"); }); + +it("rejects malformed JavDB API contracts", async () => { + const adapter = createJavDbAdapter({ request: async () => ({ data: { data: {} } }) }, () => "signature"); + await expect(adapter.listReviews({ movieId: "m1" })).rejects.toThrow(/invalid/); + await expect(adapter.listRelated({ movieId: "m1" })).rejects.toThrow(/invalid/); + await expect(adapter.getDetail({ movieId: "m1" })).rejects.toThrow(/invalid/); + await expect(adapter.listMagnets({ movieId: "m1" })).rejects.toThrow(/invalid/); + await expect(adapter.listRankings()).rejects.toThrow(/invalid/); +}); diff --git a/tests/integrations/javstore.contract.test.js b/tests/integrations/javstore.contract.test.js index ad7bc333..50abbe2d 100644 --- a/tests/integrations/javstore.contract.test.js +++ b/tests/integrations/javstore.contract.test.js @@ -1,9 +1,33 @@ -import jquery from "jquery"; -import { JSDOM } from "jsdom"; -import { expect, it } from "vitest"; +// @vitest-environment jsdom +import { expect, it, vi } from "vitest"; +import { createJavStoreAdapter } from "../../src/integrations/javstore/manifest.js"; import { parseJavStoreSearch } from "../../src/integrations/javstore/parser.js"; it("normalizes JavStore search results", () => { - const dom = new JSDOM('ABC-123'); - expect(parseJavStoreSearch(jquery(dom.window)(dom.window.document), "ABC-123")).toEqual(["https://javstore.net/ABC-123-pn.html"]); + expect(parseJavStoreSearch('ABC-123', "ABC-123")).toEqual(["https://javstore.net/ABC-123-pn.html"]); +}); + +it("loads normalized images through the declared HTTP boundary", async () => { + const request = vi.fn(async options => ({ + status: 200, + data: options.url.includes("/search?") ? 'ABC-123' : 'CLICK HERE!', + finalUrl: options.url, + })); + await expect(createJavStoreAdapter({ request }).getImages({ carNum: "ABC-123" })).resolves.toEqual([ + { url: "https://javstore.net/images/ABC-123.jpg", providerId: "javstore" }, + ]); + expect(request).toHaveBeenCalledTimes(2); +}); + +it("continues through candidates after missing or invalid details", async () => { + const notFound = Object.assign(new Error("missing"), { code: "NOT_FOUND" }); + const request = vi.fn() + .mockResolvedValueOnce({ data: 'ABC-123ABC-123ABC-123', finalUrl: "https://javstore.net/search?q=ABC-123" }) + .mockRejectedValueOnce(notFound) + .mockResolvedValueOnce({ data: "", finalUrl: "https://javstore.net/second-pn.html" }) + .mockResolvedValueOnce({ data: 'CLICK HERE!', finalUrl: "https://javstore.net/third-pn.html" }); + await expect(createJavStoreAdapter({ request }).getImages({ carNum: "ABC-123" })).resolves.toEqual([ + { url: "https://javstore.net/preview.jpg", providerId: "javstore" }, + ]); + expect(request).toHaveBeenCalledTimes(4); }); diff --git a/tests/integrations/javstore.malformed.test.js b/tests/integrations/javstore.malformed.test.js index b9b070e6..622df52f 100644 --- a/tests/integrations/javstore.malformed.test.js +++ b/tests/integrations/javstore.malformed.test.js @@ -1,9 +1,15 @@ -import jquery from "jquery"; -import { JSDOM } from "jsdom"; +// @vitest-environment jsdom import { expect, it } from "vitest"; +import { createJavStoreAdapter } from "../../src/integrations/javstore/manifest.js"; import { parseJavStoreSearch } from "../../src/integrations/javstore/parser.js"; it("rejects malformed JavStore candidates", () => { - const dom = new JSDOM('ABC-123'); - expect(parseJavStoreSearch(jquery(dom.window)(dom.window.document), "ABC-123")).toEqual([]); + expect(parseJavStoreSearch('ABC-123', "ABC-123")).toEqual([]); +}); + +it("returns an empty contract without requesting a detail when no candidate matches", async () => { + let calls = 0; + const adapter = createJavStoreAdapter({ request: async options => ({ status: 200, data: (++calls, ""), finalUrl: options.url }) }); + await expect(adapter.getImages({ carNum: "ABC-123" })).resolves.toEqual([]); + expect(calls).toBe(1); }); diff --git a/tests/integrations/subtitlecat.contract.test.js b/tests/integrations/subtitlecat.contract.test.js index 362b41c7..94c0c8e8 100644 --- a/tests/integrations/subtitlecat.contract.test.js +++ b/tests/integrations/subtitlecat.contract.test.js @@ -1,3 +1,4 @@ // @vitest-environment jsdom -import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { parseSubtitleCatResults } from "../../src/integrations/subtitlecat/parser.js"; +import { readFileSync } from "node:fs"; import { join } from "node:path"; import jquery from "jquery"; import { expect, it } from "vitest"; import { createSubtitleCatAdapter } from "../../src/integrations/subtitlecat/manifest.js"; import { parseSubtitleCatResults } from "../../src/integrations/subtitlecat/parser.js"; it("normalizes SubtitleCat results", () => { document.documentElement.innerHTML = readFileSync(join(import.meta.dirname, "../fixtures/integrations/subtitlecat/results.html"), "utf8"); expect(parseSubtitleCatResults(jquery(document), "https://subtitlecat.com/")[0]).toEqual({ language: "Chinese", url: "https://subtitlecat.com/subtitles/test-zh" }); }); +it("owns the SubtitleCat search URL", () => { expect(createSubtitleCatAdapter().detailUrl({ carNum: "ABC-123" })).toBe("https://subtitlecat.com/index.php?search=ABC-123"); }); diff --git a/tests/integrations/wikipedia.contract.test.js b/tests/integrations/wikipedia.contract.test.js new file mode 100644 index 00000000..ae009f0f --- /dev/null +++ b/tests/integrations/wikipedia.contract.test.js @@ -0,0 +1,21 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { createWikipediaAdapter } from "../../src/integrations/wikipedia/manifest.js"; +import { parseWikipediaActressInfo } from "../../src/integrations/wikipedia/parser.js"; + +const fixture = readFileSync(join(import.meta.dirname, "../fixtures/integrations/wikipedia/actress.html"), "utf8"); + +it("normalizes Wikipedia actress information", () => { + expect(parseWikipediaActressInfo(fixture, "https://ja.wikipedia.org/wiki/test")).toEqual({ + birthday: "1993年8月16日", age: "33岁", height: "159cm", weight: "48 kg", + threeSizeText: "83 - 59 - 88", braSize: "E", url: "https://ja.wikipedia.org/wiki/test", + }); +}); + +it("loads Wikipedia through the declared HTTP boundary", async () => { + const request = vi.fn(async options => ({ data: fixture, finalUrl: options.url })); + await expect(createWikipediaAdapter({ request }).lookup("三上悠亜")).resolves.toEqual(expect.objectContaining({ age: "33岁" })); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ providerId: "wikipedia", cacheScope: "public" }), undefined); +}); diff --git a/tests/integrations/wikipedia.malformed.test.js b/tests/integrations/wikipedia.malformed.test.js new file mode 100644 index 00000000..ee6ff2dc --- /dev/null +++ b/tests/integrations/wikipedia.malformed.test.js @@ -0,0 +1,7 @@ +// @vitest-environment jsdom +import { expect, it } from "vitest"; +import { parseWikipediaActressInfo } from "../../src/integrations/wikipedia/parser.js"; + +it("rejects malformed Wikipedia pages", () => { + expect(() => parseWikipediaActressInfo("missing", "https://ja.wikipedia.org/wiki/test")).toThrow("information is missing"); +}); diff --git a/tests/list-sorting.test.js b/tests/list-sorting.test.js index 6cb76baa..cff0d121 100644 --- a/tests/list-sorting.test.js +++ b/tests/list-sorting.test.js @@ -8,15 +8,17 @@ import { describe, expect, it, vi } from "vitest"; function loadPlugin(url, html) { const dom = new JSDOM(html, { url }), $ = jqueryFactory(dom.window); + let sortMethod = "default"; + const settings = { snapshot: () => ({ sortMethod }), set: vi.fn(async (name, value) => { sortMethod = value; }) }; const context = vm.createContext({ window: dom.window, document: dom.window.document, URLSearchParams, $, o: dom.window.location.href, r: true, l: false, c: false, _: "yes", localStorage: dom.window.localStorage, storageManager: { getSetting: vi.fn(async () => "yes") }, isHitShowPage: () => false, - BasePlugin: class { getSelector() { return { boxSelector: ".movie-list", itemSelector: ".movie-list > .item" }; } }, + BasePlugin: class { getSelector() { return { boxSelector: ".movie-list", itemSelector: ".movie-list > .item" }; } getRuntimeService() { return settings; } }, clog: { error: vi.fn() } }); const source = readTestFile(join(import.meta.dirname, "../src/plugins/status/list-page-button.js"), "utf8"); vm.runInContext(`${source};globalThis.Plugin=ListPageButtonPlugin`, context); - return { $, plugin: new context.Plugin(), localStorage: dom.window.localStorage }; + return { $, plugin: new context.Plugin(), setSortMethod: value => { sortMethod = value; } }; } describe("FC2 list sorting", () => { @@ -27,7 +29,7 @@ describe("FC2 list sorting", () => {
    3.67分, 由5人评价
    3.50分, 由55人评价
    `); - loaded.localStorage.setItem("jhs_sortMethod", "rateCount"); + loaded.setSortMethod("rateCount"); await loaded.plugin.sortItems(); diff --git a/tests/magnet-service.test.js b/tests/magnet-service.test.js new file mode 100644 index 00000000..4512e8c6 --- /dev/null +++ b/tests/magnet-service.test.js @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from "vitest"; +import { MagnetService } from "../src/services/magnet-service.js"; + +describe("MagnetService", () => { + it("loads native magnets only from the selected Integration", async () => { + const listMagnets = vi.fn(async () => [{ hash: "abc", providerId: "javdb" }]); + const integrations = { list: vi.fn(() => [{ id: "javdb" }]), getAdapter: vi.fn(() => ({ listMagnets })) }; + const providers = { getAvailable: vi.fn(async () => []), updateHealth: vi.fn() }; + await expect(new MagnetService(providers, integrations).listNative({ movieId: "9", providerId: "javdb" }, { scope: "scope" })).resolves.toEqual([{ hash: "abc", providerId: "javdb" }]); + expect(listMagnets).toHaveBeenCalledWith({ movieId: "9", providerId: "javdb" }, { scope: "scope" }); + expect(providers.getAvailable).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/mobile-fab.test.js b/tests/mobile-fab.test.js index 0d4e91b1..f91590d7 100644 --- a/tests/mobile-fab.test.js +++ b/tests/mobile-fab.test.js @@ -11,7 +11,8 @@ function loadMobilePlugin() { dom.window.isListPage = true, dom.window.isDetailPage = false; const listPage = { activeQuickFilter: "waitCheck", setQuickFilter: vi.fn(), syncQuickFilterUi: vi.fn() }, listButtons = { openWaitCheck: vi.fn(async () => {}), sortItems: vi.fn(async () => {}) }; const beans = { ListPagePlugin: listPage, ListPageButtonPlugin: listButtons }; - class BasePlugin { getBean(name) { return beans[name]; } } + const settings = { snapshot: () => ({ sortMethod: "default" }), set: vi.fn(async () => {}) }; + class BasePlugin { getBean(name) { return beans[name]; } getRuntimeService() { return settings; } } const labels = { all: "全部", waitCheck: "待鉴定", favorite: "收藏", hasDown: "下载", hasWatch: "已看", blockedItems: "屏蔽项", favoriteUndownloaded: "收藏未下载", favoriteUnwatched: "收藏未观看", downloadedUnwatched: "下载未观看", recent7d: "最近 7 天" }; const context = vm.createContext({ window: dom.window, document: dom.window.document, $, BasePlugin, localStorage: dom.window.localStorage, diff --git a/tests/movie-identity-service.test.js b/tests/movie-identity-service.test.js new file mode 100644 index 00000000..9f6b6f43 --- /dev/null +++ b/tests/movie-identity-service.test.js @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; +import { MovieIdentityService } from "../src/services/movie-identity-service.js"; + +describe("MovieIdentityService", () => { + it("uses JavDB as the default canonical resolver without probing AV123 first", async () => { + const av123 = { resolveMovie: vi.fn(async () => ({ movieId: "av123" })) }; + const javdb = { resolveMovie: vi.fn(async () => ({ movieId: "javdb" })) }; + const integrations = { + list: vi.fn(() => [{ id: "av123" }, { id: "javdb" }]), + getAdapter: vi.fn(id => ({ av123, javdb })[id]), + }; + await expect(new MovieIdentityService(integrations).resolve({ carNum: "ABC-123" })).resolves.toMatchObject({ movieId: "javdb" }); + expect(av123.resolveMovie).not.toHaveBeenCalled(); + }); + + it("honors an explicitly selected Integration", async () => { + const av123 = { resolveMovie: vi.fn(async () => ({ movieId: "av123" })) }; + const integrations = { list: vi.fn(() => [{ id: "av123" }, { id: "javdb" }]), getAdapter: vi.fn(() => av123) }; + await expect(new MovieIdentityService(integrations).resolve({ carNum: "FC2-123", providerId: "av123" })).resolves.toMatchObject({ movieId: "av123" }); + expect(integrations.getAdapter).toHaveBeenCalledWith("av123"); + }); + + it("routes JavDB detail requests by movie id", async () => { + const getDetail = vi.fn(async () => ({ movieId: "9", carNum: "ABC-123" })); + const integrations = { list: vi.fn(() => [{ id: "av123" }, { id: "javdb" }]), getAdapter: vi.fn(() => ({ getDetail })) }; + await expect(new MovieIdentityService(integrations).detail({ movieId: "9" }, { scope: "scope" })).resolves.toMatchObject({ movieId: "9" }); + expect(integrations.getAdapter).toHaveBeenCalledWith("javdb"); + expect(getDetail).toHaveBeenCalledWith({ movieId: "9" }, { scope: "scope" }); + }); + + it("routes ranking queries through the declared capability", async () => { + const listRankings = vi.fn(async () => [{ movieId: "9" }]); + const integrations = { list: vi.fn(() => [{ id: "javdb" }]), getAdapter: vi.fn(() => ({ listRankings })) }; + await expect(new MovieIdentityService(integrations).rankings({ period: "daily" })).resolves.toEqual([{ movieId: "9" }]); + expect(listRankings).toHaveBeenCalledWith({ period: "daily" }); + }); + + it("routes catalog queries only to the selected provider", async () => { + const listCatalog = vi.fn(async () => ({ items: [{ carNum: "FC2-1" }], maxPage: 2 })); + const integrations = { list: vi.fn(() => [{ id: "av123" }]), getAdapter: vi.fn(() => ({ listCatalog })) }; + await expect(new MovieIdentityService(integrations).catalog("av123", { page: 1 }, { scope: "scope" })).resolves.toMatchObject({ maxPage: 2 }); + expect(integrations.list).toHaveBeenCalledWith("movie.catalog"); + expect(listCatalog).toHaveBeenCalledWith({ page: 1 }, { scope: "scope" }); + }); + + it("builds provider-owned source URLs without leaking hosts into features", () => { + const integrations = { getAdapter: vi.fn(id => ({ detailUrl: ({ carNum }) => `${id}:${carNum}` })) }; + expect(new MovieIdentityService(integrations).sourceUrls({ carNum: "FC2-1" }, ["fc2ppvdb", "fc2content"])).toEqual([ + { providerId: "fc2ppvdb", url: "fc2ppvdb:FC2-1" }, { providerId: "fc2content", url: "fc2content:FC2-1" }, + ]); + }); +}); diff --git a/tests/performance-hotpaths.test.js b/tests/performance-hotpaths.test.js index 1144d484..0928caf0 100644 --- a/tests/performance-hotpaths.test.js +++ b/tests/performance-hotpaths.test.js @@ -38,9 +38,10 @@ function loadTaskLifecycle({ isListPage = false, hidden = false } = {}) { function loadListObserver() { const dom = new JSDOM('
    ', { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window); dom.window.isListPage = true; - const fetch = vi.fn(async () => ({ ok: true, json: async () => ({ translation: "译文" }) })), mapLimit = vi.fn(async (items, concurrency, mapper) => Promise.all(items.map(mapper))), storageManager = { getSetting: vi.fn(async () => "yes") }; + const translate = vi.fn(async () => "译文"), mapLimit = vi.fn(async (items, concurrency, mapper) => Promise.all(items.map(mapper))), storageManager = { getSetting: vi.fn(async () => "yes") }; class BasePlugin { getSelector() { return { boxSelector: ".movie-list", itemSelector: ".movie-list .item", coverImgSelector: ".movie-list .item img" }; } + getRuntimeService(name) { return name === "translation" ? { translate } : async () => undefined; } } const context = vm.createContext({ console, window: dom.window, document: dom.window.document, Node: dom.window.Node, MutationObserver: dom.window.MutationObserver, @@ -52,7 +53,7 @@ function loadListObserver() { hasAnyState: flags => [ "favorite", "downloaded", "watched", "blocked" ].some((key => !0 === flags?.[key])) }); vm.runInContext(`${readTestFile(join(repoRoot, "src/plugins/status/list-page.js"), "utf8")};globalThis.TestListPagePlugin=ListPagePlugin;`, context); - return { dom, plugin: new context.TestListPagePlugin(), $, fetch, mapLimit, storageManager, clog: context.clog }; + return { dom, plugin: new context.TestListPagePlugin(), $, translate, mapLimit, storageManager, clog: context.clog }; } function initializeAccessibilityDom(html) { @@ -111,23 +112,16 @@ describe("list mutation hot path", () => { expect(plugin.processAddedItems).toHaveBeenCalledTimes(1); }); - it("bounds translation concurrency and deduplicates an in-flight car number", async () => { - const { dom, plugin, $, fetch, mapLimit } = loadListObserver(), container = dom.window.document.querySelector(".movie-list"); + it("bounds translation concurrency and delegates translation to the service", async () => { + const { dom, plugin, $, translate, mapLimit } = loadListObserver(), container = dom.window.document.querySelector(".movie-list"); container.innerHTML = '
    ABC-123 原題
    ABC-123 原題
    '; const items = $(container).find(".item").toArray().map((item => $(item))); await plugin.translateListItems(items); expect(mapLimit).toHaveBeenCalledWith(items, 3, expect.any(Function)); - expect(fetch).toHaveBeenCalledTimes(1); + expect(translate).toHaveBeenCalledTimes(2); expect($(items[0]).attr("data-jhs-translation-key")).toBe("ABC-123"); }); - it("recovers from a damaged translation cache", () => { - const { dom, plugin, clog } = loadListObserver(); - dom.window.localStorage.setItem("jhs_translate", "{"); - expect(plugin.getTranslationCache()).toEqual({}); - expect(clog.warn).toHaveBeenCalledTimes(1); - }); - it("collects one current-page summary with hard-hidden union and debug reasons", () => { const { plugin, dom } = loadListObserver(), container = dom.window.document.querySelector(".movie-list"); container.innerHTML = ` diff --git a/tests/screenshot-panel.test.js b/tests/screenshot-panel.test.js new file mode 100644 index 00000000..382ae8d4 --- /dev/null +++ b/tests/screenshot-panel.test.js @@ -0,0 +1,20 @@ +// @vitest-environment jsdom +import jquery from "jquery"; +import { beforeEach, expect, it, vi } from "vitest"; +import { renderScreenshotPanel } from "../src/ui/detail/screenshot-panel.js"; + +beforeEach(() => { globalThis.$ = jquery; globalThis.showImageViewer = vi.fn(); globalThis.clog = { error: vi.fn() }; document.body.innerHTML = '
    '; }); + +it("renders a service-owned screenshot without legacy plugin access", async () => { + const resolve = vi.fn(async () => [{ url: "https://img.javstore.net/test.jpg" }]); + await expect(renderScreenshotPanel({ target: jquery("#target"), carNum: "ABC-123", screenshot: { resolve }, settings: {} })).resolves.toBe("https://img.javstore.net/test.jpg"); + expect(document.querySelector("#target img")?.getAttribute("src")).toBe("https://img.javstore.net/test.jpg"); +}); + +it("keeps manual mode request-free until selected", async () => { + const resolve = vi.fn(async () => []); + await renderScreenshotPanel({ target: jquery("#target"), carNum: "ABC-123", screenshot: { resolve }, settings: { screenshotMode: "manual" } }); + expect(resolve).not.toHaveBeenCalled(); + jquery("#target button").trigger("click"); + await vi.waitFor(() => expect(resolve).toHaveBeenCalledOnce()); +}); diff --git a/tests/screenshot-service.test.js b/tests/screenshot-service.test.js new file mode 100644 index 00000000..159c3020 --- /dev/null +++ b/tests/screenshot-service.test.js @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { ScreenshotService } from "../src/services/screenshot-service.js"; + +describe("ScreenshotService", () => { + it("keeps provider priority before integration fallback", async () => { + const provider = { id: "preferred", resolve: vi.fn(async () => ["provider-image"]) }; + const providers = { getAvailable: vi.fn(async () => [provider]), updateHealth: vi.fn() }; + const integrations = { list: vi.fn(() => [{ id: "javstore" }]), getAdapter: vi.fn() }; + await expect(new ScreenshotService(providers, integrations).resolve({ carNum: "ABC-123" })).resolves.toEqual(["provider-image"]); + expect(integrations.getAdapter).not.toHaveBeenCalled(); + }); + + it("uses normalized Integration images when providers have no result", async () => { + const providers = { getAvailable: vi.fn(async () => []), updateHealth: vi.fn() }; + const getImages = vi.fn(async () => [{ url: "https://javstore.net/a.jpg", providerId: "javstore" }]); + const integrations = { list: vi.fn(() => [{ id: "javstore" }]), getAdapter: vi.fn(() => ({ getImages })) }; + await expect(new ScreenshotService(providers, integrations).resolve({ carNum: "ABC-123" }, { scope: "scope" })).resolves.toEqual([ + { url: "https://javstore.net/a.jpg", providerId: "javstore" }, + ]); + expect(getImages).toHaveBeenCalledWith({ carNum: "ABC-123" }, { scope: "scope" }); + }); +}); diff --git a/tests/stats-scope.test.js b/tests/stats-scope.test.js index 6b945add..421a9db8 100644 --- a/tests/stats-scope.test.js +++ b/tests/stats-scope.test.js @@ -11,7 +11,10 @@ function loadStatsPlugin() { const listPage = { getCurrentPageSummary: vi.fn(() => ({ blockedItems: 7 })), setQuickFilter: vi.fn() }; const newVideo = { getPendingNewVideoTotal: vi.fn(async () => 3), openDialog: vi.fn() }; const beans = { ListPagePlugin: listPage, NewVideoPlugin: newVideo, OtherSitePlugin: { getJavDbUrl: vi.fn(async () => "https://javdb.com") } }; - class BasePlugin { getBean(name) { return beans[name]; } } + class BasePlugin { + getBean(name) { return beans[name]; } + getRuntimeService() { return { exportSnapshot: () => ({ activeFeatures: ["list"], errors: [] }) }; } + } const layer = { close: vi.fn(), open: vi.fn(options => { @@ -43,7 +46,7 @@ describe("Stats scope semantics", () => { expect(layer.open.mock.calls[0][0].title).toBe("统计"); const groups = $(".jhs-stats__group"), overview = groups.eq(0), currentPage = groups.eq(1); - expect(overview.find(".jhs-stats__metric")).toHaveLength(9); + expect(overview.find(".jhs-stats__metric")).toHaveLength(11); expect(overview.find("button.jhs-stats__metric")).toHaveLength(1); expect(overview.find("button[data-action='new-video'] span").text()).toBe("新作品待处理"); expect(overview.find("[data-filter]")).toHaveLength(0); diff --git a/tests/translation-service.test.js b/tests/translation-service.test.js new file mode 100644 index 00000000..6299f73f --- /dev/null +++ b/tests/translation-service.test.js @@ -0,0 +1,10 @@ +import { expect, it, vi } from "vitest"; +import { TranslationService } from "../src/services/translation-service.js"; + +it("resolves the declared translation capability", async () => { + const translate = vi.fn(async () => "译文"); + const integrations = { list: vi.fn(() => [{ id: "google-translate" }]), getAdapter: vi.fn(() => ({ translate })) }; + const service = new TranslationService(integrations); + await expect(service.translate("原題")).resolves.toBe("译文"); + expect(translate).toHaveBeenCalledWith("原題", {}); +}); diff --git a/tests/ui-architecture.test.js b/tests/ui-architecture.test.js index 5c195cde..b4652c8d 100644 --- a/tests/ui-architecture.test.js +++ b/tests/ui-architecture.test.js @@ -41,6 +41,7 @@ describe("list toolbar and UI cleanup contracts", () => { const commandbar = readTestFile(join(process.cwd(), "src/plugins/status/mobile-bottom-bar.js"), "utf8"); const hitShow = readTestFile(join(process.cwd(), "src/plugins/external-search/hit-show.js"), "utf8"); const translate = readTestFile(join(process.cwd(), "src/plugins/translate/translate.js"), "utf8"); + const translationUi = readTestFile(join(process.cwd(), "src/ui/translation/title-translation.js"), "utf8"); const settings = readTestFile(join(process.cwd(), "src/plugins/backup/setting-templates.js"), "utf8"); const settingStyles = readTestFile(join(process.cwd(), "src/plugins/backup/setting-styles.js"), "utf8"); const pluginPanels = readTestFile(join(process.cwd(), "src/plugins/backup/setting-panels.js"), "utf8"); @@ -88,9 +89,10 @@ describe("list toolbar and UI cleanup contracts", () => { }); it("uses an idempotent translation node and a safe cache key", () => { - expect(translate).toContain('nextAll(".translated-title").first()'); - expect(translate).toContain('"undefined" !== s'); - expect(translate).toContain('localStorage.setItem("jhs_translate"'); + expect(translationUi).toContain('nextAll(".translated-title").first()'); + expect(translate).toContain('getRuntimeService("translation")'); + expect(translate).not.toContain("localStorage"); + expect(translationUi).not.toMatch(/\.html\(/); expect(translate).not.toMatch(/\.html\(/); }); @@ -156,13 +158,13 @@ describe("list toolbar and UI cleanup contracts", () => { }); it("binds full-settings layout ranges idempotently after loading the form", () => { - expect(settingForms).toContain("bindLayoutRangeEvents();"); + expect(settingForms).toContain("bindLayoutRangeEvents(dependencies.busImg);"); expect(settingForms).toContain('.off(".jhsSetting")'); expect(settingForms).toContain('.on("input.jhsSetting"'); expect(settingForms).toContain('.on("change.jhsSetting"'); expect(settingForms).toContain('saveSettingItem("containerColumns"'); expect(settingForms).toContain('saveSettingItem("containerWidth"'); - expect(settingForms).toContain("await applyImageMode()"); + expect(settingForms).toContain("await applyImageMode(busImgPlugin)"); const rangeBinding = settingForms.slice(settingForms.indexOf("function bindLayoutRangeEvents"), settingForms.indexOf("async function initQuickSettingForm")); const columnsInput = rangeBinding.slice(rangeBinding.indexOf('on("input.jhsSetting"'), rangeBinding.indexOf('on("change.jhsSetting"')); expect(columnsInput).not.toContain("saveSettingItem"); @@ -178,7 +180,7 @@ describe("list toolbar and UI cleanup contracts", () => { expect(reviews).not.toContain("item columns is-desktop"); expect(reviews).not.toContain("jhs-layout-"); expect(related).toContain("jhs-related-item"); - expect(related).toContain("encodeURIComponent(item.relatedId)"); + expect(related).toContain("encodeURIComponent(item.id)"); expect(related).not.toContain("item columns is-desktop"); expect(related).not.toContain("jhs-layout-"); expect(reviews).toMatch(/jhs-review-content[^}]*font-size:16px[^}]*line-height:1\.7/); @@ -203,7 +205,7 @@ describe("list toolbar and UI cleanup contracts", () => { it("uses semantic keyboard popovers and stable sort storage", () => { expect(listButtons).toContain('role="menuitemradio"'); - expect(listButtons).toContain('localStorage.setItem("jhs_sortMethod"'); + expect(listButtons).toContain('getRuntimeService("settings").set("sortMethod"'); for (const key of [ "ArrowDown", "ArrowUp", "Home", "End", "Escape" ]) expect(listButtons).toContain(key); expect(listButtons).not.toMatch(/]+sort-toggle-btn/); expect(coverButtons).toContain("width:152px"); diff --git a/tests/userscript-http-adapter.test.js b/tests/userscript-http-adapter.test.js new file mode 100644 index 00000000..508d8272 --- /dev/null +++ b/tests/userscript-http-adapter.test.js @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { UserscriptHttpAdapter } from "../src/platform/userscript/userscript-http-adapter.js"; + +describe("UserscriptHttpAdapter", () => { + it("passes declared userscript request options without allowing core fields to be overridden", async () => { + const request = vi.fn(options => { + options.onload({ status: 200, responseText: "ok", finalUrl: options.url }); + return { abort: vi.fn() }; + }); + const response = await new UserscriptHttpAdapter(request).request({ + method: "GET", url: "https://123av.com/cn/search", responseType: "text", + requestOptions: { cookiePartition: { topLevelSite: "https://123av.com" }, method: "POST", url: "https://invalid.example" }, + }); + expect(response.data).toBe("ok"); + expect(request).toHaveBeenCalledWith(expect.objectContaining({ + method: "GET", url: "https://123av.com/cn/search", cookiePartition: { topLevelSite: "https://123av.com" }, + })); + }); +}); diff --git a/tests/webdav-compat.test.js b/tests/webdav-compat.test.js index 7ee2c552..847bf1fb 100644 --- a/tests/webdav-compat.test.js +++ b/tests/webdav-compat.test.js @@ -3,12 +3,12 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -const source = readTestFile(join(import.meta.dirname, "../src/plugins/backup/webdav-client.js"), "utf8"); +const source = readTestFile(join(import.meta.dirname, "../src/services/webdav-service.js"), "utf8"); describe("WebDAV compatibility contracts", () => { it("accepts existing and parent-conflict MKCOL responses", () => expect(source).toMatch(/405\|409/)); it("falls back from missing displayname to decoded href", () => { - expect(source).toContain('getElementsByTagNameNS("DAV:", "href")'); + expect(source).toContain('node("href")'); expect(source).toContain("decodeURIComponent"); - expect(source).toContain("displayNameNode?.textContent || fallbackName"); + expect(source).toContain('node("displayname") || decodeURIComponent'); }); }); diff --git a/tests/webdav-service.test.js b/tests/webdav-service.test.js new file mode 100644 index 00000000..3401132d --- /dev/null +++ b/tests/webdav-service.test.js @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +import { expect, it, vi } from "vitest"; +import { WebDavService } from "../src/services/webdav-service.js"; + +it("routes WebDAV requests through HttpService with an exact user-local origin", async () => { + const request = vi.fn(async () => ({ data: "" })); + const client = new WebDavService({ request }).createClient({ url: "http://192.168.1.10:5244/dav", username: "user", password: "pass" }); + await client.backup("JHS", "backup.json", "payload"); + expect(request).toHaveBeenNthCalledWith(1, expect.objectContaining({ + providerId: "webdav", method: "MKCOL", url: "http://192.168.1.10:5244/dav/JHS", + cacheScope: "none", urlPolicy: { trustClass: "user-local", expectedOrigin: "http://192.168.1.10:5244" }, + })); + expect(request).toHaveBeenNthCalledWith(2, expect.objectContaining({ + method: "PUT", body: "payload", headers: expect.objectContaining({ Authorization: `Basic ${btoa("user:pass")}` }), + })); +}); + +it("parses portable WebDAV file metadata without exposing credentials", async () => { + const xml = ` + /dav/JHS/0 + /dav/JHS/a.jsona.json42today + `; + const request = vi.fn(async options => ({ data: options.method === "PROPFIND" ? xml : "" })); + const client = new WebDavService({ request }).createClient({ url: "https://dav.example/base", username: "u", password: "p" }); + await expect(client.getBackupList("JHS")).resolves.toEqual([{ fileId: "a.json", name: "a.json", size: 42, createTime: "today" }]); +}); diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index 000484d9..6b87ae9a 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -15,17 +15,20 @@ "src/contracts/**/*.js", "src/core/jhs-error.js", "src/core/cache-policy.js", + "src/core/credential-crypto.js", "src/core/event-bus.js", "src/core/legacy-plugin-contributions.js", "src/core/lifecycle-scope.js", "src/core/movie-identity.js", "src/core/selection-model.js", + "src/core/site-context.js", "src/features/**/*.js", "src/integrations/**/*.js", "src/platform/**/*.js", "src/services/**/*.js", "src/ui/**/*.js", - "src/plugins/dependency-map.js" + "src/plugins/dependency-map.js", + "src/plugins/image-viewer/screenshot-provider-registry.js" ], "exclude": ["src/app/bootstrap.js"] } From 59b242c9c53c1e443d729b413f7678ce5b2df0ae Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:59:12 +0800 Subject: [PATCH 003/130] =?UTF-8?q?Refactor:=20=E6=94=B6=E6=95=9B=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=9D=A2=E6=9D=BF=E4=B8=8E=20FC2=20=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 抽离 FC2 工作区、评论与相关清单共享 UI,Hosted 与 Owned Detail 复用相同面板 - 将 123AV 数据加载与 FC2 工作区编排解耦,删除 123AV 对 Fc2Plugin 的反向依赖 - 将评论过滤与磁力筛选迁入已声明的 Storage/Settings 依赖 - 构建时剥离仅供 checkJs 使用的 JSDoc,保持源码类型信息并满足 bundle 预算 - 更新架构、UI 与回归门禁及相关测试 修改原因: - 逐步消除 Plugin 间双向依赖,并落实 Hosted/Owned Detail 的共享 Panel 边界 - 避免新架构类型注释进入正式用户脚本造成无行为价值的体积增长 验证: - npm run check 通过,89 个测试文件共 518 项测试通过 - npm run check:browser 通过,本机 Edge 13 项通过、5 项按环境跳过 - git diff --check 与 codegraph sync 通过 - 根目录与 dist/JHS.user.js SHA256 一致 --- JHS.user.js | 933 ++++++++------------ scripts/build.mjs | 11 +- scripts/regression-check.mjs | 4 +- scripts/ui-consistency-audit.mjs | 7 +- src/plugins/dependency-map.js | 3 +- src/plugins/external-search/fc2-by-123av.js | 22 +- src/plugins/external-search/fc2.js | 59 +- src/plugins/external-search/related.js | 81 +- src/plugins/external-search/review.js | 130 +-- src/plugins/registry.js | 7 +- src/ui/detail/fc2-workspace-view.js | 36 + src/ui/detail/related-panel.js | 108 +++ src/ui/detail/review-panel.js | 134 +++ tests/architecture-runtime.test.js | 2 +- tests/detail-panel-lifecycle.test.js | 11 +- tests/fc2-owned-workspace.test.js | 18 +- tests/ui-architecture.test.js | 11 +- 17 files changed, 735 insertions(+), 842 deletions(-) create mode 100644 src/ui/detail/fc2-workspace-view.js create mode 100644 src/ui/detail/related-panel.js create mode 100644 src/ui/detail/review-panel.js diff --git a/JHS.user.js b/JHS.user.js index d285279b..b97afe14 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -256,7 +256,6 @@ "BOOTSTRAP_FAILED" ]); var _JhsError = class _JhsError extends Error { - /** @param {string} code @param {string} message @param {{source?: string, retryable?: boolean, cause?: unknown, details?: Record}} [options] */ constructor(code, message, options = {}) { super(message, { cause: options.cause }); if (!JHS_ERROR_CODES.includes(code)) throw new TypeError(`Unknown JhsError code: ${code}`); @@ -266,7 +265,6 @@ this.retryable = options.retryable ?? false; this.details = options.details ? Object.freeze({ ...options.details }) : null; } - /** @param {unknown} error @param {string} [source] */ static from(error, source = "jhs") { if (error instanceof _JhsError) return error; if (error instanceof DOMException && error.name === "AbortError") { @@ -473,18 +471,12 @@ }); function migrateDisabledPlugins(value) { const input = Array.isArray(value) ? value : []; - const mapping = ( - /** @type {Record} */ - LEGACY_PLUGIN_CONTRIBUTION_MAP - ); + const mapping = LEGACY_PLUGIN_CONTRIBUTION_MAP; return [...new Set(input.filter((id) => typeof id === "string").map((id) => mapping[id] ?? id))]; } __name(migrateDisabledPlugins, "migrateDisabledPlugins"); function disabledIdForPlugin(pluginName) { - return ( - /** @type {Record} */ - LEGACY_PLUGIN_CONTRIBUTION_MAP[pluginName] ?? pluginName - ); + return LEGACY_PLUGIN_CONTRIBUTION_MAP[pluginName] ?? pluginName; } __name(disabledIdForPlugin, "disabledIdForPlugin"); function parseDisabledPlugins(serialized) { @@ -865,32 +857,26 @@ // src/core/event-bus.js var _JhsEventBus = class _JhsEventBus { - /** @param {string} [channelName] */ constructor(channelName = "channel-refresh") { this.originId = globalThis.crypto?.randomUUID?.() || `tab_${Date.now()}_${Math.random().toString(36).slice(2)}`; this.listeners = /* @__PURE__ */ new Map(), this.seen = /* @__PURE__ */ new Set(), this.channel = new BroadcastChannel(channelName); this.channel.addEventListener("message", ((event) => this._receive(event.data))); } - /** @param {string} type @param {(payload: any, event: any) => unknown} handler */ on(type, handler) { const handlers = this.listeners.get(type) || /* @__PURE__ */ new Set(); return handlers.add(handler), this.listeners.set(type, handlers), () => handlers.delete(handler); } - /** @param {any} event */ async _dispatch(event) { for (const handler of [...this.listeners.get(event.type) || []]) await handler(event.payload, event); } - /** @param {string} eventId */ _remember(eventId) { this.seen.add(eventId), this.seen.size > 256 && this.seen.delete(this.seen.values().next().value); } - /** @param {string} type @param {any} [payload] @param {{broadcast?: boolean}} [options] */ async emit(type, payload = {}, options = {}) { const event = { eventId: globalThis.crypto?.randomUUID?.() || `event_${Date.now()}_${Math.random().toString(36).slice(2)}`, originId: this.originId, type, payload, timestamp: Date.now() }; this._remember(event.eventId), await this._dispatch(event), false !== options.broadcast && this.channel.postMessage(event); return event; } - /** @param {any} event */ async _receive(event) { if (!event || event.originId === this.originId || event.eventId && this.seen.has(event.eventId)) return; if (!event.eventId) { @@ -906,10 +892,7 @@ function initializeEventBus() { if (jhsEventBus) return jhsEventBus; jhsEventBus = new JhsEventBus(); - const runtimeWindow = ( - /** @type {any} */ - window - ); + const runtimeWindow = window; runtimeWindow.refresh = () => jhsEventBus?.emit("legacy-refresh"); runtimeWindow.cleanCache_filter_actor_actress_car_list = () => jhsEventBus?.emit("blacklist-rules-changed"); runtimeWindow.clean_cacheSettingObj = () => jhsEventBus?.emit("settings-changed"); @@ -1207,7 +1190,6 @@ const t2 = window.innerWidth; return this.isMobileMode() ? ["100%", "90%"] : t2 >= 1200 ? e2 || this.getDefaultArea() : ["70%", "90%"]; } - /** 按用途返回具有固定上限和安全边距的弹窗尺寸。 */ getDialogArea(e2 = "md") { const t2 = { sm: [480, 640], @@ -1272,7 +1254,6 @@ sleep(e2 = 1e3) { return new Promise(((t2) => setTimeout(t2, e2))); } - /** 创建可取消的尾沿防抖函数。 */ debounce(callback, wait = 200) { let timer = null; const debounced = /* @__PURE__ */ __name(function(...args) { @@ -1425,7 +1406,6 @@ this._cacheGenerations.set(e2, this._getCacheGeneration(e2) + 1); this._pendingReads.delete(e2); } - /** 合并同一存储键的并发读取,并阻止失效前的旧读取回填缓存。 */ async _readCached(e2, t2, n2) { if (null !== this[e2]) return this[e2]; const a2 = this._pendingReads.get(t2); @@ -1935,7 +1915,6 @@ report: await this.inspectDataHealth() }; } - /** 数据迁移: 将旧版扁平键名重命名为新格式 */ async merge_table_name() { let e2 = "filter_actor_actress_info_list", t2 = await this.forage.getItem(e2) || []; t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_key, t2)), await this.forage.removeItem(e2), e2 = "favorite_actresses_info_list", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.favorite_actresses_key, t2)), await this.forage.removeItem(e2), e2 = "car_list_filter_actor_actress", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.blacklist_car_list_key, t2)), await this.forage.removeItem(e2), e2 = "title_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_title_key, t2)), await this.forage.removeItem(e2), e2 = "review_filter_keyword", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.filter_keyword_review_key, t2)), await this.forage.removeItem(e2), e2 = "highlightedTags", t2 = await this.forage.getItem(e2) || [], t2 && t2.length > 0 && (clog.debug("更正", e2), await this._setItemAndInvalidate(this.highlighted_tags_key, t2)), await this.forage.removeItem(e2); @@ -1968,7 +1947,6 @@ } e2.checkFilterTime && (delete e2.checkFilterTime, t2 = true), e2.checkFilterConcurrencyCount && (delete e2.checkFilterConcurrencyCount, t2 = true), e2.checkFilterSleep && (delete e2.checkFilterSleep, t2 = true), t2 && (await this.saveSetting(e2), clog.debug("配置数据已更正")); } - /** 数据迁移: 补全黑名单条目缺失的 role/starId/allName/movieType 字段 */ async merge_blacklist() { const e2 = await this.getBlacklist(); if (!e2 || 0 === e2.length) return; @@ -2582,7 +2560,6 @@ // src/core/lifecycle-scope.js var _LifecycleScope = class _LifecycleScope { - /** @param {string} id @param {{onChange?: (snapshot: ReturnType) => void}} [options] */ constructor(id, options = {}) { if (!id) throw new TypeError("LifecycleScope id is required"); this.id = id; @@ -2598,7 +2575,6 @@ get signal() { return this.controller.signal; } - /** @param {EventTarget} target @param {string} type @param {EventListenerOrEventListenerObject} listener @param {AddEventListenerOptions | boolean} [options] */ listen(target, type, listener, options) { this.assertActive(); target.addEventListener(type, listener, options); @@ -2608,7 +2584,6 @@ this.listenerCount -= 1; }); } - /** @param {{disconnect: () => void}} observer */ ownObserver(observer) { this.assertActive(); this.observerCount += 1; @@ -2617,11 +2592,9 @@ this.observerCount -= 1; }); } - /** @param {number} timerId */ ownTimeout(timerId) { return this.addCleanup(() => clearTimeout(timerId)); } - /** @param {{release: () => void}} consumer */ ownRequestConsumer(consumer) { this.assertActive(); this.requestConsumers.add(consumer); @@ -2631,7 +2604,6 @@ consumer.release(); }); } - /** @param {() => void} cleanup */ addCleanup(cleanup) { this.assertActive(); let active = true; @@ -2651,7 +2623,6 @@ this.generation += 1; return this.generation; } - /** @param {number} generation */ canCommit(generation) { return !this.disposed && generation === this.generation; } @@ -3564,10 +3535,7 @@ __name(initializeLoggerRuntime, "initializeLoggerRuntime"); // src/platform/userscript/vendor-runtime.js - function getVendorRuntime(runtimeWindow = ( - /** @type {any} */ - window - )) { + function getVendorRuntime(runtimeWindow = window) { const values = { $: runtimeWindow.jQuery, Tabulator: runtimeWindow.Tabulator, @@ -3585,7 +3553,6 @@ // src/platform/hosts/javbus-host-adapter.js var _JavBusHostAdapter = class _JavBusHostAdapter { - /** @param {Document} [documentRuntime] @param {Location} [locationRuntime] */ constructor(documentRuntime = document, locationRuntime = window.location) { this.document = documentRuntime; this.location = locationRuntime; @@ -3624,7 +3591,6 @@ // src/platform/hosts/javdb-host-adapter.js var _JavDbHostAdapter = class _JavDbHostAdapter { - /** @param {Document} [documentRuntime] @param {Location} [locationRuntime] */ constructor(documentRuntime = document, locationRuntime = window.location) { this.document = documentRuntime; this.location = locationRuntime; @@ -3694,8 +3660,7 @@ requireArray(manifest2.requires, "requires"); if (typeof manifest2.plugin !== "function") throw new TypeError("Contribution plugin must be a class"); if (!manifest2.order || typeof manifest2.order !== "object") throw new TypeError("Contribution order must be explicit"); - return Object.freeze({ ...manifest2, sites: Object.freeze([.../** @type {unknown[]} */ - manifest2.sites]), order: Object.freeze({ ...manifest2.order }) }); + return Object.freeze({ ...manifest2, sites: Object.freeze([...manifest2.sites]), order: Object.freeze({ ...manifest2.order }) }); } __name(defineContribution, "defineContribution"); function defineIntegration(manifest2) { @@ -3703,14 +3668,8 @@ if (!TRUST_CLASSES.has(String(manifest2.trustClass))) throw new TypeError("Integration trustClass is invalid"); if (!QUALITY_LEVELS.has(String(manifest2.quality))) throw new TypeError("Integration quality is invalid"); for (const field of ["hosts", "capabilities", "requires"]) requireArray(manifest2[field], field); - const hosts = ( - /** @type {unknown[]} */ - manifest2.hosts - ); - const capabilities = ( - /** @type {unknown[]} */ - manifest2.capabilities - ); + const hosts = manifest2.hosts; + const capabilities = manifest2.capabilities; if (hosts.length === 0 || capabilities.length === 0) throw new TypeError("Integration hosts and capabilities cannot be empty"); if (manifest2.cachePolicy === void 0) throw new TypeError("Integration cachePolicy must be explicit"); if (typeof manifest2.createClient !== "function" || typeof manifest2.createAdapter !== "function") { @@ -3767,7 +3726,6 @@ // src/ui/detail/hosted-detail-surface.js var _HostedDetailSurface = class _HostedDetailSurface { - /** @param {{locateDetailRoot: () => Element | null, locateDetailSlots: () => Record}} hostAdapter */ constructor(hostAdapter) { this.hostAdapter = hostAdapter; this.root = null; @@ -3809,7 +3767,6 @@ this.created.add(slot); } } - /** @param {string} slot @param {Element} element */ mountPanel(slot, element) { const target = this.slots[slot]; if (!target) return false; @@ -3831,7 +3788,6 @@ // src/features/detail/detail-controller.js var _DetailController = class _DetailController { - /** @param {{hostAdapter: any, scope: import("../../core/lifecycle-scope.js").LifecycleScope, enabledContributions: readonly string[]}} options */ constructor(options) { this.hostAdapter = options.hostAdapter; this.scope = options.scope; @@ -3956,7 +3912,6 @@ CompatibilityEnhancementsPlugin: ["ListPagePlugin"], CoverButtonPlugin: ["ListPagePlugin", "ScreenShotPlugin", "OtherSitePlugin"], DetailPageButtonPlugin: ["DetailWorkspacePlugin", "MagnetHubPlugin", "HighlightMagnetPlugin"], - Fc2By123AvPlugin: ["OtherSitePlugin", "Fc2Plugin"], Fc2Plugin: [ "DetailPageButtonPlugin", "MagnetHubPlugin", @@ -3964,9 +3919,7 @@ "OtherSitePlugin", "Fc2By123AvPlugin", "TOP250Plugin", - "HighlightMagnetPlugin", - "ReviewPlugin", - "RelatedPlugin" + "HighlightMagnetPlugin" ], HistoryPlugin: ["UnifiedOfflinePlugin", "ListPagePlugin", "Fc2Plugin"], HitShowPlugin: ["ListPageButtonPlugin", "ListPagePlugin", "CoverButtonPlugin"], @@ -4434,7 +4387,6 @@ if (item.prop("disabled")) return; this.source.val(item.attr("data-value")), this.emitChange(), this.close(true); } - /** 派发一次真实原生 change,同时兼容宿主与 jQuery 监听器。 */ emitChange() { this.source[0]?.dispatchEvent(new Event("change", { bubbles: true @@ -5965,7 +5917,6 @@ } })); } - /** 从任意列表卡片进入统一详情导航。 */ async openMovieDetail(item, { event = null, autoplay = false, newTab = false } = {}) { const card = item?.jquery ? item : $(item), { carNum, aHref, fc2Source } = this.findCarNumAndHref(card); if (!carNum || !aHref) return; @@ -5977,7 +5928,6 @@ const destination = new URL(aHref, window.location.origin); autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, true, { event, newTab: shouldOpenTab }), this.$currentImage = null; } - /** 为宿主与合成列表统一绑定左键、修饰键和中键导航。 */ bindMovieDetailNavigation(container) { const root = $(container), selector = ".item img, .item .video-title"; root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, ((event) => { @@ -7220,7 +7170,6 @@ var DAY = 24 * HOUR; var CACHE_TTL = Object.freeze({ magnet: 6 * HOUR, screenshot: 7 * DAY, screenshotNegative: 12 * HOUR, match115: HOUR, externalDetail: DAY }); var _ProviderError = class _ProviderError extends Error { - /** @param {string} provider @param {string} code @param {string} message @param {{cause?: unknown, status?: number, url?: string, retryable?: boolean}} [options] */ constructor(provider, code, message, options = {}) { super(message, { cause: options.cause }); this.name = "ProviderError"; @@ -7413,7 +7362,6 @@ $(".mini-simple-setting").html("").hide(); })); } - /** Open shared quick settings in the mobile bottom sheet. */ async openQuickSetting() { $("#jhs-quick-setting-backdrop, #jhs-quick-setting-sheet").remove(); const previousFocus = document.activeElement; @@ -8278,10 +8226,7 @@ // src/ui/translation/title-translation.js async function renderTranslatedTitle(options) { - const jq = ( - /** @type {any} */ - globalThis.$ - ); + const jq = globalThis.$; const root = options.root ? jq(options.root) : jq(document); let title = root.find(".origin-title").first(); if (!title.length) title = root.find(".current-title").first(); @@ -8304,6 +8249,41 @@ } __name(renderTranslatedTitle, "renderTranslatedTitle"); + // src/ui/detail/fc2-workspace-view.js + function normalizeUrl(value) { + try { + const url = new URL(String(value || "")); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; + } catch { + return null; + } + } + __name(normalizeUrl, "normalizeUrl"); + function renderFc2State(target, message, retry = null) { + const jq = globalThis.$, host = jq(target).empty(), state = jq('
    ').text(message); + if (retry) state.addClass("is-error").append(" ", jq('').on("click", retry)); + host.append(state); + } + __name(renderFc2State, "renderFc2State"); + function renderFc2Gallery(context, images) { + const jq = globalThis.$; + const urls = [...new Set((images || []).map(normalizeUrl).filter(Boolean))]; + const grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); + if (!urls.length) return renderFc2State(grid, "暂无剧照"); + preview.append(jq("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); + urls.forEach((url, index) => grid.append(jq('').attr("aria-label", `查看剧照 ${index + 1}`).append(jq("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" })))); + } + __name(renderFc2Gallery, "renderFc2Gallery"); + function createFc2SourceLinks(context, movieService) { + const jq = globalThis.$; + const links = jq(''); + const providerLinks = movieService.sourceUrls({ carNum: context.carNum }, ["fc2ppvdb", "fc2content"]); + const values = [["123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeUrl(context.url)], ...providerLinks.map((item) => [item.providerId === "fc2ppvdb" ? "FC2PPVDB" : "FC2 市场", item.url])]; + values.forEach(([label, href]) => href && links.append(jq("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label))); + return links; + } + __name(createFc2SourceLinks, "createFc2SourceLinks"); + // src/plugins/external-search/fc2-by-123av.js var _Fc2By123AvPlugin = class _Fc2By123AvPlugin extends BasePlugin { constructor() { @@ -8367,21 +8347,14 @@ e2.close(); } } - async open123AvFc2Dialog(carNum, url) { - return this.getDependency("Fc2Plugin").openFc2Dialog(null, carNum, url, { source: "123av" }); - } - /** 将 123AV 数据填入 Fc2Plugin 创建的固定工作区。 */ async loadDetail(context, url) { - const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum), movieIdPromise = this.resolveMovieId(context.carNum), fc2Plugin = this.getDependency("Fc2Plugin"); - void fc2Plugin.configureJavDbWantButton(context, movieIdPromise), void fc2Plugin.mountPanels(context, movieIdPromise), void movieIdPromise.then(((movieId) => context.isAlive() && fc2Plugin.fetchAndRenderNativeMagnets(context, movieId))).catch(((error) => { - context.isAlive() && fc2Plugin.setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))), clog.error("123AV 磁力关联失败", error); - })); - imagesPromise.then(((images) => context.isAlive() && this.getDependency("Fc2Plugin").renderGallery(context, images))).catch(((error) => context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))))); + const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum); + imagesPromise.then(((images) => context.isAlive() && renderFc2Gallery(context, images))).catch(((error) => context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))))); actressPromise.then((async (data) => { await infoPromise.catch((() => null)); context.isAlive() && this.render123AvActress(context, data); })).catch(((error) => clog.error("FC2 演员信息加载失败", error))); - await Promise.allSettled([infoPromise, imagesPromise, actressPromise, movieIdPromise]); + await Promise.allSettled([infoPromise, imagesPromise, actressPromise]); } async loadSummary(context, url) { try { @@ -8392,20 +8365,13 @@ if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _) await renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope }); return info; } catch (error) { - context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); throw error; } } - async retryResolvedMagnets(context) { - try { - return await this.getDependency("Fc2Plugin").fetchAndRenderNativeMagnets(context, await this.resolveMovieId(context.carNum)); - } catch (error) { - context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))); - } - } render123AvSummary(context, info) { const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

    '); - title.find("strong").text(info.title || "无标题"), body.append(title, $('
    ').append($("").text(`番号:${context.carNum}`), $("").text(`发行:${info.publishDate || "未知"}`)), '
    主演:正在加载演员…
    ', '
    ', this.getDependency("Fc2Plugin").createSourceLinks(context), $('').text(info.publishDate || "")); + title.find("strong").text(info.title || "无标题"), body.append(title, $('
    ').append($("").text(`番号:${context.carNum}`), $("").text(`发行:${info.publishDate || "未知"}`)), '
    主演:正在加载演员…
    ', '
    ', createFc2SourceLinks(context, this.getRuntimeService("movie")), $('').text(info.publishDate || "")); } async get123AvVideoInfo(carNum, e2) { const scope = await this.getRuntimeService("scope")(); @@ -8423,9 +8389,9 @@ async reloadImages(context) { try { const images = await this.getImgList(context.carNum); - context.isAlive() && this.getDependency("Fc2Plugin").renderGallery(context, images); + context.isAlive() && renderFc2Gallery(context, images); } catch (error) { - context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))); + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))); } } render123AvActress(context, data) { @@ -8575,10 +8541,7 @@ ${value}\r } __name(normalizeImageUrl, "normalizeImageUrl"); function renderImage(host, url, alt) { - const jq = ( - /** @type {any} */ - globalThis.$ - ); + const jq = globalThis.$; const normalized = normalizeImageUrl(url); if (!normalized) return null; const image = jq("").attr({ src: normalized, alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"); @@ -8592,10 +8555,7 @@ ${value}\r } __name(renderImage, "renderImage"); async function renderScreenshotPanel(options) { - const jq = ( - /** @type {any} */ - globalThis.$ - ); + const jq = globalThis.$; const host = jq(options.target), isActive = options.isActive ?? (() => true); if (!host.length || options.settings.enableLoadScreenShot === "no") return host.empty(), null; const load = /* @__PURE__ */ __name(async (resultHost = host) => { @@ -8628,6 +8588,272 @@ ${value}\r } __name(renderScreenshotPanel, "renderScreenshotPanel"); + // src/ui/detail/related-panel.js + var _RelatedPanel = class _RelatedPanel { + constructor(dependencies) { + this.related = dependencies.related; + this.settings = dependencies.settings; + this.scope = dependencies.scope; + } + async show(target, movieId, options = {}) { + const jq = globalThis.$, isActive = options.isActive ?? (() => true); + if (!movieId) throw new TypeError("未传入movieId"); + if (!isActive() || !target?.length) return jq(); + const existing = target.children('[data-jhs-panel="related"]').filter((_index, element) => jq(element).attr("data-jhs-movie-id") === String(movieId)).first(); + if (existing.length) return existing; + const panel = jq('').attr("data-jhs-movie-id", String(movieId)); + const header = jq('

    相关清单

    '); + const toggle = jq(''); + const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1, isActive }; + header.append(toggle); + if (options.ownedSection) options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle); + else panel.append(header); + panel.append('', ''); + target.append(panel); + const enabled = (this.settings.snapshot().enableLoadRelated ?? "no") === "yes"; + this.updateToggle(toggle, enabled); + toggle.on("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const expanded = toggle.find(".toggle-text").text() === "展开"; + this.updateToggle(toggle, expanded); + panel.find(".jhs-related-container, .jhs-related-footer").toggle(expanded); + if (expanded && !state.loaded && !state.loading) void this.fetch(state); + void this.settings.set("enableLoadRelated", expanded ? "yes" : "no"); + }); + if (enabled) await this.fetch(state); + else panel.find(".jhs-related-container, .jhs-related-footer").hide(); + return panel; + } + updateToggle(toggle, expanded) { + toggle.attr("aria-expanded", String(expanded)); + toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"); + toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); + } + async fetch(state) { + if (state.loading || !state.isActive()) return; + state.loading = true; + const container = state.panel.find(".jhs-related-container"), footer = state.panel.find(".jhs-related-footer"); + container.empty().append(globalThis.$("
    ").addClass("jhs-panel-state").text("获取清单中...")); + footer.empty(); + let scope; + try { + scope = await this.scope(); + const related = await this.related.list({ movieId: state.movieId }, { page: 1, limit: 20, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.loading = false; + state.loaded = true; + container.empty(); + if (!related.length) return void container.append(globalThis.$("
    ").addClass("jhs-panel-state").text("无清单")); + this.display(state, related, container); + if (related.length === 20) this.bindLoadMore(state, container, footer); + else footer.append(globalThis.$("
    ").addClass("jhs-panel-end").text("已加载全部清单")); + } catch (error) { + state.loading = false; + if (!state.isActive() || scope?.signal?.aborted) return; + globalThis.clog?.error("获取清单失败:", error); + this.renderRetry(container, () => void this.fetch(state)); + } + } + renderRetry(container, retry) { + const jq = globalThis.$; + container.empty().append(jq('
    ').append(document.createTextNode("获取清单失败 "), jq('').on("click", retry))); + } + bindLoadMore(state, container, footer) { + const jq = globalThis.$, button = jq(''), end = jq('').hide(); + footer.empty().append(button, end); + button.on("click", async () => { + const nextPage = state.page + 1; + let scope; + button.text("加载中...").prop("disabled", true); + try { + scope = await this.scope(); + const related = await this.related.list({ movieId: state.movieId }, { page: nextPage, limit: 20, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.page = nextPage; + this.display(state, related, container); + if (related.length < 20) button.remove(), end.show(); + else button.text("加载更多清单").prop("disabled", false); + } catch (error) { + if (!state.isActive() || scope?.signal?.aborted) return; + globalThis.clog?.error("加载更多清单失败:", error); + button.text("加载失败,请重试").prop("disabled", false); + } + }); + } + display(state, related, container) { + const jq = globalThis.$; + related.forEach((item) => { + const title = jq("").addClass("jhs-related-title").attr({ href: `/lists/${encodeURIComponent(item.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(item.name || "未命名清单"); + const heading = jq('').append(jq("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title); + const meta = jq(''); + const formatted = item.createdAt ? globalThis.utils.formatDate(item.createdAt) : "未知"; + meta.append(jq("").text(`视频:${Number(item.movieCount) || 0}`), jq("").text(`收藏:${Number(item.collectionCount) || 0}`), jq("").text(`查看:${Number(item.viewCount) || 0}`), jq('').text(`创建时间:${formatted}`)); + container.append(jq('').append(heading, meta)); + }); + } + }; + __name(_RelatedPanel, "RelatedPanel"); + var RelatedPanel = _RelatedPanel; + + // src/ui/detail/review-panel.js + var FILTER_KEY = "review_filter_keyword"; + var _ReviewPanel = class _ReviewPanel { + constructor(dependencies) { + this.review = dependencies.review; + this.settings = dependencies.settings; + this.storage = dependencies.storage; + this.scope = dependencies.scope; + } + async show(movieId, target, options = {}) { + const jq = globalThis.$, isActive = options.isActive ?? (() => true); + if (!isActive() || !target?.length) return jq(); + const existing = target.children('[data-jhs-panel="reviews"]').filter((_index, element) => jq(element).attr("data-jhs-movie-id") === String(movieId)).first(); + if (existing.length) return existing; + const panel = jq('
    ').attr("data-jhs-movie-id", String(movieId)); + const header = jq('

    评论

    '); + const toggle = jq(''); + const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1, isActive }; + header.append(toggle); + if (options.ownedSection) options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle); + else panel.append(header); + panel.append('
    ', ''); + target.append(panel); + this.bindFilter(panel); + const enabled = (this.settings.snapshot().enableLoadReview ?? "no") === "yes"; + this.updateToggle(toggle, enabled); + toggle.on("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const expanded = toggle.find(".toggle-text").text() === "展开"; + this.updateToggle(toggle, expanded); + panel.find(".jhs-review-container, .jhs-review-footer").toggle(expanded); + if (expanded && !state.loaded && !state.loading) void this.fetch(state); + void this.settings.set("enableLoadReview", expanded ? "yes" : "no"); + }); + if (enabled) await this.fetch(state); + else panel.find(".jhs-review-container, .jhs-review-footer").hide(); + return panel; + } + updateToggle(toggle, expanded) { + toggle.attr("aria-expanded", String(expanded)); + toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"); + toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); + } + async fetch(state) { + if (state.loading || !state.isActive()) return; + const jq = globalThis.$, container = state.panel.find(".jhs-review-container"), footer = state.panel.find(".jhs-review-footer"); + state.loading = true; + container.empty().append(jq('
    ').text("获取评论中...")); + footer.empty(); + const pageSize = Number(this.settings.snapshot().reviewCount) || 20; + let scope; + try { + scope = await this.scope(); + const reviews = await this.review.list({ movieId: state.movieId }, { page: 1, limit: pageSize, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.loading = false; + state.loaded = true; + container.empty(); + if (!reviews.length) return void container.append(jq('
    ').text("无评论")); + const keywords = await this.getKeywords(); + await this.display(state, reviews, container, keywords); + if (reviews.length === pageSize) this.bindLoadMore(state, pageSize, keywords, container, footer); + else footer.append(jq('
    ').text("已加载全部评论")); + } catch (error) { + state.loading = false; + if (!state.isActive() || scope?.signal?.aborted) return; + globalThis.clog?.error("获取评论失败:", error); + this.renderRetry(container, "获取评论失败", () => void this.fetch(state)); + } + } + async getKeywords() { + const value = await this.storage.get(FILTER_KEY); + return Array.isArray(value) ? value.map(String) : []; + } + async saveKeyword(text) { + const values = await this.getKeywords(); + if (!values.includes(text)) await this.storage.set(FILTER_KEY, [...values, text]); + } + renderRetry(container, message, retry) { + const jq = globalThis.$; + container.empty().append(jq('
    ').append(document.createTextNode(`${message} `), jq('').on("click", retry))); + } + bindLoadMore(state, pageSize, keywords, container, footer) { + const jq = globalThis.$, button = jq(''), end = jq('
    已加载全部评论
    ').hide(); + footer.empty().append(button, end); + button.on("click", async () => { + const nextPage = state.page + 1; + let scope; + button.text("加载中...").prop("disabled", true); + try { + scope = await this.scope(); + const reviews = await this.review.list({ movieId: state.movieId }, { page: nextPage, limit: pageSize, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.page = nextPage; + await this.display(state, reviews, container, keywords); + if (reviews.length < pageSize) button.remove(), end.show(); + else button.text("加载更多评论").prop("disabled", false); + } catch (error) { + if (!state.isActive() || scope?.signal?.aborted) return; + globalThis.clog?.error("加载更多评论失败:", error); + button.text("加载失败,请重试").prop("disabled", false); + } + }); + } + async display(state, reviews, container, keywords) { + const jq = globalThis.$; + const filter = keywords.length ? new RegExp(keywords.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")) : null; + for (const review of reviews) { + const content = String(review.content || ""); + if (filter?.test(content)) continue; + const item = jq('
    '), meta = jq('
    '), body = jq('
    '); + meta.append(jq("").addClass("jhs-review-author").text(review.author || "匿名用户")); + const stars = jq(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); + for (let index = 0; index < score; index++) stars.append(''); + const formatted = globalThis.utils.formatDate(review.createdAt); + meta.append(stars, jq("").text(formatted), jq("").text(`点赞:${Number(review.likes) || 0}`), jq("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`)); + this.appendContent(body, content); + item.append(meta, body); + container.append(item); + } + } + appendContent(container, content) { + const pattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; + let cursor = 0, match; + while (match = pattern.exec(content)) { + if (match.index > cursor) container.append(document.createTextNode(content.slice(cursor, match.index))); + this.appendLink(container, match[0]); + cursor = match.index + match[0].length; + } + if (cursor < content.length) container.append(document.createTextNode(content.slice(cursor))); + } + appendLink(container, value) { + const jq = globalThis.$, isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接", isResource = isEd2k || isMagnet; + const wrapper = jq(isResource ? '' : ''), main = jq(''); + const open = isEd2k ? jq('').text(label).on("click", () => globalThis.utils.copyToClipboard(label, value)) : jq("").addClass("jhs-review-link").attr({ href: value, target: "_blank", rel: "noopener noreferrer" }).text(label); + const copy = jq('').on("click", () => globalThis.utils.copyToClipboard(label, value)); + main.append(open, copy); + wrapper.append(main); + if (isResource) wrapper.append(jq('').append(jq('').attr("data-resource", value))); + container.append(wrapper); + } + bindFilter(panel) { + panel.off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", async (event) => { + if ((this.settings.snapshot().enableTitleSelectFilter ?? "yes") !== "yes") return; + const text = String(window.getSelection()?.toString() || ""); + if (!text) return; + event.preventDefault(); + await globalThis.utils.q(event, `是否将 '${text}' 加入评论区关键词?`, async () => { + await this.saveKeyword(text); + globalThis.show.ok("操作成功, 刷新页面后生效"); + }); + }); + } + }; + __name(_ReviewPanel, "ReviewPanel"); + var ReviewPanel = _ReviewPanel; + // src/plugins/status/detail-workspace.js function getDetailResourceAdapter() { if (!window.isDetailPage) return null; @@ -8755,7 +8981,6 @@ ${value}\r group.children('[data-jhs-slot="reviews"]').length || group.append('
    '); group.children('[data-jhs-slot="related"]').length || group.append(''); } - /** 只移动 JHS 自有插槽,将其固定在稳定宿主锚点旁。 */ placeOwnedSlots() { const root = this.hostRoot, resource = getDetailResourceAdapter(); if (!root?.length) return; @@ -8937,7 +9162,6 @@ ${value}\r end: /* @__PURE__ */ __name(() => context?.destroy(), "end") }); } - /** 统一挂载 FC2 与 123AV-FC2 详情。 */ mountFc2Detail(host, options) { const target = $(host), previous = target.data("jhsFc2Context"); previous?.destroy?.(), target.empty(); @@ -8989,20 +9213,26 @@ ${value}\r }))).then(((result) => { if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); })); - "123av" === context.source ? void this.getDependency("Fc2By123AvPlugin").loadDetail(context, context.url) : void this.loadNativeDetail(context); + "123av" === context.source ? void this.load123AvDetail(context) : void this.loadNativeDetail(context); } createResourceGroup(title, role) { return $('

    ').find("h3").text(title).end().find("div").attr("data-jhs-role", role).end(); } - setState(target, message, retry = null) { - const host = $(target).empty(), state = $('
    ').text(message); - retry && state.addClass("is-error").append(" ", $('').on("click", retry)), host.append(state); - } async loadNativeDetail(context) { const movieIdPromise = Promise.resolve(context.movieId); this.configureJavDbWantButton(context, movieIdPromise), await Promise.allSettled([this.fetchAndRenderNativeDetail(context), this.fetchAndRenderNativeMagnets(context), this.mountPanels(context, movieIdPromise)]); } - /** 绑定当前工作区自己的 JavDB“想看”操作。 */ + async load123AvDetail(context) { + const source = this.getDependency("Fc2By123AvPlugin"), movieIdPromise = source.resolveMovieId(context.carNum); + void this.configureJavDbWantButton(context, movieIdPromise), void this.mountPanels(context, movieIdPromise), void movieIdPromise.then(((movieId) => context.isAlive() && this.fetchAndRenderNativeMagnets(context, movieId))).catch(((error) => { + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.load123AvMagnets(context))), clog.error("123AV 磁力关联失败", error); + })); + await source.loadDetail(context, context.url); + } + async load123AvMagnets(context) { + const movieId = await this.getDependency("Fc2By123AvPlugin").resolveMovieId(context.carNum); + return this.fetchAndRenderNativeMagnets(context, movieId); + } async configureJavDbWantButton(context, movieIdPromise) { const button = context.root.find('[data-jhs-action="javdb-want"]'); try { @@ -9039,10 +9269,10 @@ ${value}\r const movie = await this.getRuntimeService("movie").detail({ movieId: context.movieId, carNum: context.carNum, providerId: "javdb" }, { scope }); if (!movie) throw new Error("JavDB 影片详情不存在"); if (!context.isAlive()) return; - this.renderSummary(context, movie), this.renderGallery(context, movie.imageUrls || []); + this.renderSummary(context, movie), renderFc2Gallery(context, movie.imageUrls || []); if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _) await renderTranslatedTitle({ root: context.root, carNum: movie.carNum || context.carNum, translation: this.getRuntimeService("translation"), scope }); } catch (error) { - context.isAlive() && this.setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); } } renderSummary(context, movie) { @@ -9054,29 +9284,18 @@ ${value}\r (movie.actors || []).forEach(((actor) => { actors.append($("").addClass("jhs-fc2-actor").attr({ href: `/actors/${encodeURIComponent(actor.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(actor.name || "未知演员")), 0 === actor.gender && actressNames.push(actor.name); })); - movie.actors?.length || actors.append($("").text("暂无演员信息")), body.append(actors, this.createSourceLinks(context), $('').text(actressNames.join(" ")), $('').text(movie.releaseDate || "")); - } - createSourceLinks(context) { - const links = $(''), providerLinks = this.getRuntimeService("movie").sourceUrls({ carNum: context.carNum }, ["fc2ppvdb", "fc2content"]), values = [["123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeHttpUrl(context.url)], ...providerLinks.map(((item) => [item.providerId === "fc2ppvdb" ? "FC2PPVDB" : "FC2 市场", item.url]))]; - values.forEach((([label, href]) => href && links.append($("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label)))); - return links; - } - renderGallery(context, images) { - const urls = [...new Set((images || []).map(((url) => normalizeHttpUrl(url))).filter(Boolean))], grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); - if (!urls.length) return this.setState(grid, "暂无剧照"); - preview.append($("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); - urls.forEach(((url, index) => grid.append($('').attr("aria-label", `查看剧照 ${index + 1}`).append($("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" }))))); + movie.actors?.length || actors.append($("").text("暂无演员信息")), body.append(actors, createFc2SourceLinks(context, this.getRuntimeService("movie")), $('').text(actressNames.join(" ")), $('').text(movie.releaseDate || "")); } async fetchAndRenderNativeMagnets(context, movieId = context.movieId) { const host = context.root.find('[data-jhs-role="native-magnets"]'); - this.setState(host, "正在加载站内磁力…"); + renderFc2State(host, "正在加载站内磁力…"); try { - if (!movieId) return this.setState(host, "JavDB 暂无对应作品"); + if (!movieId) return renderFc2State(host, "JavDB 暂无对应作品"); const scope = await this.getRuntimeService("scope")(); const magnets = await this.getRuntimeService("magnet").listNative({ movieId, providerId: "javdb" }, { scope }); if (!context.isAlive()) return; host.empty(); - if (!magnets.length) return this.setState(host, "暂无站内磁力"); + if (!magnets.length) return renderFc2State(host, "暂无站内磁力"); const highlighter = this.getDependency("HighlightMagnetPlugin"), assessments = []; magnets.forEach(((item) => { const hash = normalizeBtihHash(item.hash); @@ -9087,7 +9306,7 @@ ${value}\r })); await this.bindNativeMagnetFilter(context, host, assessments.some(((item) => item.highQuality))); } catch (error) { - context.isAlive() && this.setState(host, "站内磁力加载失败", (() => void this.fetchAndRenderNativeMagnets(context, movieId))), clog.error("FC2 磁力加载失败", error); + context.isAlive() && renderFc2State(host, "站内磁力加载失败", (() => void this.fetchAndRenderNativeMagnets(context, movieId))), clog.error("FC2 磁力加载失败", error); } } async bindNativeMagnetFilter(context, host, hasMatch) { @@ -9098,23 +9317,25 @@ ${value}\r enabled && hasMatch && host.find('.jhs-fc2-magnet-item[data-jhs-high-quality="false"]').hide(); button.attr("aria-pressed", String(enabled && hasMatch)).text(hasMatch ? enabled ? "显示全部磁力" : "过滤低质量" : "暂无可过滤项").prop("disabled", !hasMatch); }, "apply"); - actions.append(button), apply(await storageManager.getSetting("enableMagnetsFilter", _) === _), button.on(`click${context.namespace}`, (async () => { + const settings = this.getRuntimeService("settings"); + actions.append(button), apply((settings.snapshot().enableMagnetsFilter ?? _) === _), button.on(`click${context.namespace}`, (async () => { const enabled = "true" !== button.attr("aria-pressed"); - apply(enabled), await storageManager.saveSettingItem("enableMagnetsFilter", enabled ? _ : C); + apply(enabled), await settings.set("enableMagnetsFilter", enabled ? _ : "no"); })); } async mountPanels(context, movieIdPromise) { try { const movieId = await movieIdPromise; if (!context.isAlive()) return; - if (!movieId) return this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"), this.setState(context.getSlot("reviews"), "JavDB 暂无对应作品"), this.setState(context.getSlot("related"), "JavDB 暂无对应作品"); + if (!movieId) return this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"), renderFc2State(context.getSlot("reviews"), "JavDB 暂无对应作品"), renderFc2State(context.getSlot("related"), "JavDB 暂无对应作品"); this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); - await Promise.allSettled([this.getDependency("ReviewPlugin").showReview(movieId, context.getSlot("reviews"), { ownedSection: context.getSection("reviews"), isActive: context.isAlive }), this.getDependency("RelatedPlugin").showRelated(context.getSlot("related"), movieId, { ownedSection: context.getSection("related"), isActive: context.isAlive })]); + const scope = /* @__PURE__ */ __name(() => this.getRuntimeService("scope")(), "scope"), relatedPanel = new RelatedPanel({ related: this.getRuntimeService("related"), settings: this.getRuntimeService("settings"), scope }), reviewPanel = new ReviewPanel({ review: this.getRuntimeService("review"), settings: this.getRuntimeService("settings"), storage: this.getRuntimeService("storage"), scope }); + await Promise.allSettled([reviewPanel.show(movieId, context.getSlot("reviews"), { ownedSection: context.getSection("reviews"), isActive: context.isAlive }), relatedPanel.show(context.getSlot("related"), movieId, { ownedSection: context.getSection("related"), isActive: context.isAlive })]); } catch (error) { if (!context.isAlive()) return; this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); const retry = /* @__PURE__ */ __name(() => void this.mountPanels(context, this.resolveMovieId(context.carNum)), "retry"); - this.setState(context.getSlot("reviews"), "评论关联失败", retry), this.setState(context.getSlot("related"), "相关清单关联失败", retry), clog.error("FC2 JavDB 关联失败", error); + renderFc2State(context.getSlot("reviews"), "评论关联失败", retry), renderFc2State(context.getSlot("related"), "相关清单关联失败", retry), clog.error("FC2 JavDB 关联失败", error); } } clearOwnedPanel(context, name) { @@ -9929,85 +10150,8 @@ ${error.stack}` : ""); return element ? $(element) : $(); } async showRelated(target, movieId, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getHostedSlot("related"); - if (!movieId) return void show.error("未传入movieId"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="related"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('').attr("data-jhs-movie-id", String(movieId)), header = $('

    相关清单

    '), toggle = $(''), state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle) : panel.append(header), panel.append('', ''), host.append(panel), state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-related-container, .jhs-related-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayRelateds(state), storageManager.saveSettingItem("enableLoadRelated", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayRelateds(state) : panel.find(".jhs-related-container, .jhs-related-footer").hide(); - return panel; - } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); - } - async fetchAndDisplayRelateds(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = true; - const { movieId, panel } = state, container = panel.find(".jhs-related-container"), footer = panel.find(".jhs-related-footer"); - container.empty().append($('
    ').text("获取清单中...")), footer.empty(); - let related, scope; - try { - scope = await this.getRuntimeService("scope")(); - related = await this.getRuntimeService("related").list({ movieId }, { page: 1, limit: 20, scope }); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); - clog.error("获取清单失败:", error); - state.loading = false; - return void this.renderRetry(container, (() => this.fetchAndDisplayRelateds(state))); - } - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); - state.loading = false, state.loaded = true; - container.empty(); - if (!related.length) return void container.append($('
    ').text("无清单")); - this.displayRelateds(state, related, container), 20 === related.length ? this.bindLoadMore(state, container, footer) : footer.append($('
    ').text("已加载全部清单")); - } - renderRetry(container, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode("获取清单失败 ")); - state.append($('').on("click", retry)), container.append(state); - } - bindLoadMore(state, container, footer) { - const button = $(''), end = $('').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - let scope; - button.text("加载中...").prop("disabled", true); - try { - scope = await this.getRuntimeService("scope")(); - const related = await this.getRuntimeService("related").list({ movieId: state.movieId }, { page: nextPage, limit: 20, scope }); - if (!state.isActive?.() || scope?.signal?.aborted) return; - state.page = nextPage; - this.displayRelateds(state, related, container), related.length < 20 ? (button.remove(), end.show()) : button.text("加载更多清单").prop("disabled", false); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return; - clog.error("加载更多清单失败:", error), button.text("加载失败,请重试").prop("disabled", false); - } - })); - } - displayRelateds(state, related, container) { - related.forEach(((item) => { - const row = $(''), title = $("").addClass("jhs-related-title").attr({ - href: `/lists/${encodeURIComponent(item.id)}`, - target: "_blank", - rel: "noopener noreferrer" - }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createdAt ? utils.formatDate(item.createdAt) : "未知"}`); - meta.append( - $("").text(`视频:${Number(item.movieCount) || 0}`), - $("").text(`收藏:${Number(item.collectionCount) || 0}`), - $("").text(`查看:${Number(item.viewCount) || 0}`), - time - ), row.append(heading, meta), container.append(row); - })); + const panel = new RelatedPanel({ related: this.getRuntimeService("related"), settings: this.getRuntimeService("settings"), scope: /* @__PURE__ */ __name(() => this.getRuntimeService("scope")(), "scope") }); + return panel.show(target?.length ? target : this.getHostedSlot("related"), movieId, options); } }; __name(_RelatedPlugin, "RelatedPlugin"); @@ -10064,133 +10208,8 @@ ${error.stack}` : ""); return element ? $(element) : $(); } async showReview(movieId, target, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => true; - const settings = await storageManager.getSetting(); - const enabled = settings.enableLoadReview === _ ? _ : C, host = target?.length ? target : this.getHostedSlot("reviews"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="reviews"]').filter(((_2, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('
    ').attr("data-jhs-movie-id", String(movieId)); - const header = $('

    评论

    '); - const toggle = $(''); - const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle) : panel.append(header), panel.append('
    ', ''), host.append(panel), this.bindRightClickFilter(); - state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-review-container, .jhs-review-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayReviews(state), storageManager.saveSettingItem("enableLoadReview", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayReviews(state) : panel.find(".jhs-review-container, .jhs-review-footer").hide(); - return panel; - } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); - } - async fetchAndDisplayReviews(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = true; - const { movieId, panel } = state, container = panel.find(".jhs-review-container"), footer = panel.find(".jhs-review-footer"); - container.empty().append($('
    ').text("获取评论中...")), footer.empty(); - const pageSize = await storageManager.getSetting("reviewCount", 20); - let reviews, scope; - try { - scope = await this.getRuntimeService("scope")(); - reviews = await this.getRuntimeService("review").list({ movieId }, { page: 1, limit: pageSize, scope }); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); - error.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", error), clog.error("获取评论失败:", error); - state.loading = false; - return void this.renderRetry(container, "获取评论失败", (() => this.fetchAndDisplayReviews(state))); - } - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = false); - state.loading = false, state.loaded = true; - container.empty(); - if (!reviews.length) return void container.append($('
    ').text("无评论")); - const keywords = await storageManager.getReviewFilterKeywordList(); - await this.displayReviews(state, reviews, container, keywords); - reviews.length === pageSize ? this.bindLoadMore(state, pageSize, keywords, container, footer) : footer.append($('
    ').text("已加载全部评论")); - } - renderRetry(container, message, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode(`${message} `)); - const button = $('').on("click", retry); - state.append(button), container.append(state); - } - bindLoadMore(state, pageSize, keywords, container, footer) { - const button = $(''), end = $('
    已加载全部评论
    ').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - let scope; - button.text("加载中...").prop("disabled", true); - try { - scope = await this.getRuntimeService("scope")(); - const reviews = await this.getRuntimeService("review").list({ movieId: state.movieId }, { page: nextPage, limit: pageSize, scope }); - if (!state.isActive?.() || scope?.signal?.aborted) return; - state.page = nextPage; - await this.displayReviews(state, reviews, container, keywords), reviews.length < pageSize ? (button.remove(), end.show()) : button.text("加载更多评论").prop("disabled", false); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return; - clog.error("加载更多评论失败:", error), button.text("加载失败,请重试").prop("disabled", false); - } - })); - } - async displayReviews(state, reviews, container, keywords) { - if (!reviews.length) return; - const filter = keywords.length > 0 ? new RegExp(keywords.map(((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("|")) : null; - for (const review of reviews) { - const content = String(review.content || ""); - if (filter?.test(content)) continue; - const item = $('
    '), meta = $('
    '), body = $('
    '); - meta.append($("").addClass("jhs-review-author").text(review.author || "匿名用户")); - const stars = $(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); - for (let index = 0; index < score; index++) stars.append(''); - meta.append( - stars, - $("").text(utils.formatDate(review.createdAt)), - $("").text(`点赞:${Number(review.likes) || 0}`), - $("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`) - ); - await this.appendReviewContent(body, content), item.append(meta, body), container.append(item); - } - } - appendReviewContent(container, content) { - const linkPattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; - let cursor = 0, match; - while (match = linkPattern.exec(content)) { - match.index > cursor && container.append(document.createTextNode(content.slice(cursor, match.index))); - this.appendLinkControls(container, match[0]), cursor = match.index + match[0].length; - } - cursor < content.length && container.append(document.createTextNode(content.slice(cursor))); - } - appendLinkControls(container, value) { - const isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接"; - const isResource = isMagnet || isEd2k, wrapper = $(isResource ? '' : ''); - const main = $(''); - const open = isEd2k ? $('').text(label).on("click", (() => utils.copyToClipboard(label, value))) : $("").addClass("jhs-review-link").attr({ - href: value, - target: "_blank", - rel: "noopener noreferrer" - }).text(label); - const copy = $('').on("click", (() => utils.copyToClipboard(label, value))); - main.append(open, copy), wrapper.append(main); - if (isResource) { - const actions = $(''); - actions.append(``); - wrapper.append(actions); - } - container.append(wrapper); - } - bindRightClickFilter() { - $(document).off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", (async (event) => { - if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; - const text = window.getSelection().toString(); - text && (event.preventDefault(), await utils.q(event, `是否将 '${text}' 加入评论区关键词?`, (async () => { - await storageManager.saveReviewFilterKeyword(text), show.ok("操作成功, 刷新页面后生效"); - }))); - })); + const panel = new ReviewPanel({ review: this.getRuntimeService("review"), settings: this.getRuntimeService("settings"), storage: this.getRuntimeService("storage"), scope: /* @__PURE__ */ __name(() => this.getRuntimeService("scope")(), "scope") }); + return panel.show(movieId, target?.length ? target : this.getHostedSlot("reviews"), options); } }; __name(_ReviewPlugin, "ReviewPlugin"); @@ -10713,7 +10732,6 @@ ${error.stack}` : ""); clog.error("预加载 DMM 失败:", error); } } - /** 复用单次 DMM 请求,避免预加载和点击处理重复抓取。 */ getDmmPreview() { if (this.dmmPreviewPromise) return this.dmmPreviewPromise; this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum).then(((result) => { @@ -10725,14 +10743,12 @@ ${error.stack}` : ""); })); return this.dmmPreviewPromise; } - /** 创建与 JavDB HLS 生命周期完全隔离的 DMM 播放器。 */ createDmmPlayer($nativeVideo) { const $host = $nativeVideo.parent(), existing = $host.find("#jhs-preview-video"); if (existing.length) return existing; const $player = $(''); return $nativeVideo.after($player), $player; } - /** 销毁 JHS 播放器并把播放权完整交回 JavDB。 */ async restoreNativePlayer($nativeVideo, nativeVideo, notify = false) { const $dmmVideo = $nativeVideo.parent().find("#jhs-preview-video"), dmmVideo = $dmmVideo[0]; dmmVideo && (dmmVideo.pause(), $dmmVideo.removeAttr("src"), dmmVideo.load(), $dmmVideo.remove()); @@ -10969,7 +10985,6 @@ ${error.stack}` : ""); async handle() { window.isListPage && (this.addSvgBtn(), await this.bindClick()); } - /** 构建卡片工具和三个卡片内 popover。 */ buildToolBox() { return `
    @@ -11174,11 +11189,7 @@ ${error.stack}` : ""); const normalizedCarNum = normalizeMovieCarNum(carNum); if (!normalizedCarNum) return []; if (typeof $searchPage?.find === "function") { - const wrap = ( - /** @type {any} */ - globalThis.jQuery ?? /** @type {any} */ - globalThis.$ ?? $searchPage.constructor - ); + const wrap = globalThis.jQuery ?? globalThis.$ ?? $searchPage.constructor; return $searchPage.find('a[href$="-pn.html"]').filter(((index, element) => wrap(element).text().trim().toUpperCase().includes(normalizedCarNum.toUpperCase()))).map(((index, element) => new URL(wrap(element).attr("href"), baseUrl).href)).get(); } return [...resolveDocument($searchPage).querySelectorAll('a[href$="-pn.html"]')].filter((element) => element.textContent?.trim().toUpperCase().includes(normalizedCarNum.toUpperCase())).map((element) => new URL(element.getAttribute("href") || "", baseUrl).href); @@ -11186,11 +11197,7 @@ ${error.stack}` : ""); __name(parseJavStoreSearch, "parseJavStoreSearch"); function parseJavStorePreview($detailPage, detailUrl) { if (typeof $detailPage?.find === "function") { - const wrap = ( - /** @type {any} */ - globalThis.jQuery ?? /** @type {any} */ - globalThis.$ ?? $detailPage.constructor - ); + const wrap = globalThis.jQuery ?? globalThis.$ ?? $detailPage.constructor; const link = $detailPage.find("a").filter(((index, element) => "CLICK HERE!" === wrap(element).text().trim())).first(); const previewHref2 = link.attr("href"); if (!previewHref2) return null; @@ -11206,25 +11213,21 @@ ${error.stack}` : ""); // src/plugins/image-viewer/screenshot-provider-registry.js var _ScreenshotProviderRegistry = class _ScreenshotProviderRegistry { - /** @param {Array<{id: string, name: string, enabled?: boolean, priority?: number, getScreenshot: (carNum: string) => Promise}>} [providers] */ constructor(providers = []) { this.providers = /* @__PURE__ */ new Map(); providers.forEach(((provider) => this.register(provider))); } - /** @param {{id: string, name: string, enabled?: boolean, priority?: number, getScreenshot: (carNum: string) => Promise}} provider */ register(provider) { if (!provider?.id || !provider.name || "function" !== typeof provider.getScreenshot) throw new TypeError("Invalid screenshot provider"); this.providers.set(provider.id, { enabled: true, priority: 100, ...provider }); return this; } - /** @param {string} id */ get(id) { return this.providers.get(id) || null; } getEnabledProviders() { return [...this.providers.values()].filter(((provider) => provider.enabled)).sort(((a2, b2) => a2.priority - b2.priority)); } - /** @param {string} carNum */ async first(carNum) { for (const provider of this.getEnabledProviders()) { try { @@ -11342,7 +11345,6 @@ ${error.stack}` : ""); t3.stopPropagation(), t3.preventDefault(), window.open(searchUrl, "_blank"); })); } - /** 将截图状态与结果限制在指定容器内,供自有详情工作区使用。 */ async loadInto(target, carNum, { isActive = /* @__PURE__ */ __name(() => true, "isActive") } = {}) { const host = $(target); if (!host.length || "yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return host.empty(), null; @@ -12274,11 +12276,7 @@ ${error.stack}` : ""); const container = $page.find("#actors").first(); if (!container.length) return { state: "invalid", isEmpty: false, actors: [], nextUrl: null }; const actors = [], boxes = container.find(".actor-box").toArray(); - const wrap = ( - /** @type {any} */ - globalThis.jQuery ?? /** @type {any} */ - globalThis.$ ?? $page.constructor - ); + const wrap = globalThis.jQuery ?? globalThis.$ ?? $page.constructor; const normalizeName = /* @__PURE__ */ __name((name) => name.trim(), "normalizeName"); try { for (const box of boxes) { @@ -12367,7 +12365,6 @@ ${error.stack}` : ""); const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; } - /** 返回当前标签页可证明的任务状态,不写入调度数据。 */ getTaskStatusSnapshot(name) { const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey)); const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); @@ -12523,7 +12520,6 @@ ${error.stack}` : ""); this.configLoadPromise = null; })), this.configLoadPromise; } - /** 确保所有任务入口均已具备配置和站点地址。 */ async ensureReady() { (!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getDependency("OtherSitePlugin").getJavDbUrl()); if (!this.javDbUrl) throw new Error("JavDB 地址未配置"); @@ -13115,7 +13111,6 @@ ${error.stack}` : ""); throw this.isTokenExpiredError(i2) ? "TOKEN_EXPIRED" : i2.message ? "响应解析失败: " + i2.message : String(i2); } } - /** CRC32-IEEE (poly 0xEDB88320) — 与 Go crc32.ChecksumIEEE 一致 */ _crc32(e2) { const t2 = new Array(256); for (let n3 = 0; n3 < 256; n3++) { @@ -13127,7 +13122,6 @@ ${error.stack}` : ""); for (let a2 = 0; a2 < e2.length; a2++) n2 = t2[(n2 ^ e2.charCodeAt(a2)) & 255] ^ n2 >>> 8; return (n2 ^ 4294967295) >>> 0; } - /** 为 123 云盘 API URL 附加签名查询参数(与 Go signPath 算法一致) */ _signUrl(e2) { const t2 = ["a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z"]; const n2 = Math.round(1e7 * Math.random()); @@ -13885,7 +13879,6 @@ ${error.stack}` : ""); doFilterMagnet() { this.handleDb(), this.handleBus(); } - /** 给磁力行注入评分徽章(幂等:已有则跳过) */ injectScoreBadge(el, title) { try { if (el.find(".jhs-magnet-score").length > 0) return; @@ -13905,7 +13898,6 @@ ${error.stack}` : ""); const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); return { resolution, subtitle, recognized: !!resolution || subtitle, highQuality: "4k" === resolution || "2160p" === resolution || subtitle }; } - /** 返回与 DOM 无关的磁力质量结果,供自有详情工作区复用。 */ assessMagnet({ title = "", hasHdTag = false, hasSubtitleTag = false, date = null, seeders = 0 } = {}) { const signals = this.getQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); @@ -13957,7 +13949,6 @@ ${error.stack}` : ""); // src/core/selection-model.js var _SelectionModel = class _SelectionModel extends EventTarget { - /** @param {(item: any) => string} keyOf */ constructor(keyOf) { super(); this.keyOf = keyOf; @@ -13965,7 +13956,6 @@ ${error.stack}` : ""); this.selected = /* @__PURE__ */ new Set(); this.excluded = /* @__PURE__ */ new Set(); } - /** @param {any} item @param {boolean} selected */ set(item, selected) { const key = this.keyOf(item), target = this.mode === "all-filtered" ? this.excluded : this.selected; this.mode === "all-filtered" ? selected ? target.delete(key) : target.add(key) : selected ? target.add(key) : target.delete(key); @@ -13983,12 +13973,10 @@ ${error.stack}` : ""); this.excluded.clear(); this.emit(); } - /** @param {any} item */ has(item) { const key = this.keyOf(item); return this.mode === "all-filtered" ? !this.excluded.has(key) : this.selected.has(key); } - /** @param {any[]} filteredItems */ values(filteredItems) { return this.mode === "all-filtered" ? filteredItems.filter((item) => !this.excluded.has(this.keyOf(item))) : filteredItems.filter((item) => this.selected.has(this.keyOf(item))); } @@ -14157,7 +14145,6 @@ ${error.stack}` : ""); isHistoryAllFiltered() { return "all-filtered" === this.historySelectionModel.mode; } - /** 清空 History 的全选、排除项和当前页选择。 */ resetHistorySelection(deselectRows = true) { this.historySelectionModel.clear(); if (deselectRows && this.tableObj) { @@ -14214,7 +14201,6 @@ ${error.stack}` : ""); element.checked = this.isHistoryAllFiltered(), element.indeterminate = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size > 0 : summary.count > 0; })); } - /** 解析批量操作实际要处理的当前页或完整筛选结果。 */ async getHistoryBatchSelection() { if (!this.isHistoryAllFiltered()) return this.historySelectionModel.values(this.tableObj?.getData?.() || []); const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map(((item) => this.normalizeHistoryCarNum(item.carNum)))); @@ -14282,7 +14268,6 @@ ${error.stack}` : ""); })); })); } - /** 返回应用当前搜索、状态筛选和排序后的完整 History 数据。 */ async getFilteredHistoryData(sorters = this.historySorters) { let a2 = await storageManager.getCarList(); this.allCount = a2.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a2.forEach(((e2) => { @@ -14656,7 +14641,6 @@ ${error.stack}` : ""); const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); } - /** 构建与原排序值兼容的 JHS 菜单。 */ sortMenuHtml(method, title = "选择列表排序方式") { const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, current = labels[method] || labels.default; return `
    `; @@ -14719,7 +14703,6 @@ ${error.stack}` : ""); })); })); } - /** 绑定排序 popover 的选择与键盘交互。 */ bindSortMenu() { const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = /* @__PURE__ */ __name((focus = false) => { menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); @@ -14993,7 +14976,6 @@ ${error.stack}` : ""); async afterPluginsReady() { this.buildCommandBar(); } - /** 将列表页分散的 JHS 控件收敛为单一命令栏。 */ buildCommandBar() { if (!window.isListPage || $("#jhs-page-commandbar").length) return; const commandbar = $(` @@ -15058,7 +15040,6 @@ ${error.stack}` : ""); })); this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); } - /** 获取详情页番号 */ getCarNum() { try { const basePlugin = this.getDependency("DetailPageButtonPlugin"); @@ -15082,7 +15063,6 @@ ${error.stack}` : ""); } else items = group(item("logger", "运行日志") + item("setting", "设置")); return $(``); } - /** 刷新详情页菜单的状态指示 */ async refreshDetailStatus() { try { const carNum = this.getCarNum(); @@ -15407,7 +15387,7 @@ ${error.stack}` : ""); var legacyContributionManifests = Object.freeze([ manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation]), manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }), - manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.screenshot]), + manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }), @@ -15420,8 +15400,8 @@ ${error.stack}` : ""); manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }), - manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie]), - manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related]), + manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), + manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }), manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), @@ -15468,6 +15448,7 @@ ${error.stack}` : ""); [SERVICE.screenshot, "screenshot"], [SERVICE.translation, "translation"], [SERVICE.webdav, "webdav"], + [SERVICE.storage, "storage"], [SERVICE.dialog, "dialog"] ]); for (const token of item.requires) { @@ -15494,9 +15475,7 @@ ${error.stack}` : ""); function assertPort(implementation, name, methods) { if (!implementation || typeof implementation !== "object") throw new TypeError(`${name} implementation is required`); for (const method of methods) { - if (typeof /** @type {Record} */ - /** @type {unknown} */ - implementation[method] !== "function") throw new TypeError(`${name}.${method} is required`); + if (typeof implementation[method] !== "function") throw new TypeError(`${name}.${method} is required`); } return implementation; } @@ -15511,21 +15490,17 @@ ${error.stack}` : ""); // src/platform/browser/browser-navigation-adapter.js var _BrowserNavigationAdapter = class _BrowserNavigationAdapter { - /** @param {{location?: Location, open?: typeof window.open}} [runtime] */ constructor(runtime = {}) { this.location = runtime.location ?? window.location; this.openWindow = runtime.open ?? window.open.bind(window); assertPort(this, "NavigationPort", PORT_METHODS.navigation); } - /** @param {string} url @param {{newTab?: boolean}} [options] */ open(url, options = {}) { return options.newTab ? this.openWindow(url, "_blank", "noopener") : this.assign(url); } - /** @param {string} url */ assign(url) { this.location.assign(url); } - /** @param {string} url */ replace(url) { this.location.replace(url); } @@ -15535,11 +15510,9 @@ ${error.stack}` : ""); // src/platform/browser/browser-style-adapter.js var _BrowserStyleAdapter = class _BrowserStyleAdapter { - /** @param {Document} [documentRuntime] */ constructor(documentRuntime = document) { this.document = documentRuntime; } - /** @param {string} id @param {string} css */ register(id, css) { if (this.document.getElementById(id)) throw new Error(`Duplicate style: ${id}`); const style = this.document.createElement("style"); @@ -15548,7 +15521,6 @@ ${error.stack}` : ""); this.document.head.append(style); return () => style.remove(); } - /** @param {string} id */ remove(id) { this.document.getElementById(id)?.remove(); } @@ -15558,19 +15530,15 @@ ${error.stack}` : ""); // src/platform/userscript/indexeddb-storage-adapter.js var _IndexedDbStorageAdapter = class _IndexedDbStorageAdapter { - /** @param {{getItem: (key: string) => Promise, setItem: (key: string, value: unknown) => Promise, removeItem: (key: string) => Promise}} forage */ constructor(forage) { this.forage = forage; } - /** @param {string} key */ get(key) { return this.forage.getItem(key); } - /** @param {string} key @param {unknown} value */ async set(key, value) { await this.forage.setItem(key, value); } - /** @param {string} key */ async remove(key) { await this.forage.removeItem(key); } @@ -15580,15 +15548,12 @@ ${error.stack}` : ""); // src/platform/userscript/layer-dialog-adapter.js var _LayerDialogAdapter = class _LayerDialogAdapter { - /** @param {{open: (options: Record) => number, close: (id: number) => void}} layerRuntime */ constructor(layerRuntime) { this.layer = layerRuntime; } - /** @param {Record} options */ open(options) { return this.layer.open(options); } - /** @param {number} id */ close(id) { this.layer.close(id); } @@ -15598,12 +15563,10 @@ ${error.stack}` : ""); // src/platform/userscript/userscript-http-adapter.js var _UserscriptHttpAdapter = class _UserscriptHttpAdapter { - /** @param {(options: Record) => {abort?: () => void} | void} requestImplementation */ constructor(requestImplementation) { if (typeof requestImplementation !== "function") throw new TypeError("GM_xmlhttpRequest is required"); this.requestImplementation = requestImplementation; } - /** @param {{url: string, method?: string, headers?: Record, body?: unknown, responseType?: string, timeout?: number, signal?: AbortSignal, requestOptions?: Record}} options */ request(options) { return new Promise((resolve, reject) => { let settled = false; @@ -15647,14 +15610,12 @@ ${error.stack}` : ""); // src/services/cache-service.js var _CacheService = class _CacheService { - /** @param {{namespaceVersion?: number, diagnostics?: import("./diagnostics-service.js").DiagnosticsService}} [options] */ constructor(options = {}) { this.namespaceVersion = options.namespaceVersion ?? 1; this.diagnostics = options.diagnostics ?? null; this.publicCache = /* @__PURE__ */ new Map(); this.sessionCaches = /* @__PURE__ */ new Map(); } - /** @param {string} scope @param {string} sessionScopeId */ storeFor(scope, sessionScopeId = "") { if (scope === "public") return this.publicCache; if (scope === "session") { @@ -15665,7 +15626,6 @@ ${error.stack}` : ""); } return null; } - /** @param {string} key @param {{scope: string, sessionScopeId?: string}} policy */ get(key, policy) { const store = this.storeFor(policy.scope, policy.sessionScopeId); const entry = store?.get(`${this.namespaceVersion}:${key}`); @@ -15677,7 +15637,6 @@ ${error.stack}` : ""); this.diagnostics?.recordCache(true); return { hit: true, value: entry.value, negative: entry.negative }; } - /** @param {string} key @param {unknown} value @param {{scope: string, sessionScopeId?: string, ttlMs: number, negative?: boolean}} policy */ set(key, value, policy) { const store = this.storeFor(policy.scope, policy.sessionScopeId); if (!store) return; @@ -15693,12 +15652,10 @@ ${error.stack}` : ""); // src/services/actress-info-service.js var _ActressInfoService = class _ActressInfoService { - /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations @param {import("./cache-service.js").CacheService} cache */ constructor(integrations, cache) { this.integrations = integrations; this.cache = cache; } - /** @param {string} name */ profileUrl(name) { for (const manifest2 of this.integrations.list("person.actress-info")) { const adapter = this.integrations.getAdapter(manifest2.id); @@ -15706,7 +15663,6 @@ ${error.stack}` : ""); } return ""; } - /** @param {string} name @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async lookup(name, options = {}) { const normalized = String(name).trim() === "三上悠亞" ? "三上悠亜" : String(name).trim(); if (!normalized) return null; @@ -15752,46 +15708,35 @@ ${error.stack}` : ""); this.legacyStartup = null; this.legacyTimings = []; } - /** @param {string} id @param {number} durationMs */ recordStartup(id, durationMs) { this.startupTimings.set(id, durationMs); } - /** @param {string} id @param {boolean} active */ setFeature(id, active) { active ? this.activeFeatures.add(id) : this.activeFeatures.delete(id); } - /** @param {string} id @param {boolean} active */ setContribution(id, active) { active ? this.activeContributions.add(id) : this.activeContributions.delete(id); } - /** @param {{id: string}} snapshot */ updateScope(snapshot) { this.scopes.set(snapshot.id, Object.freeze({ ...snapshot })); } - /** @param {number} consumers @param {number} underlying */ updateRequests(consumers, underlying) { this.requestStats = { consumers, underlying }; } - /** @param {boolean} hit */ recordCache(hit) { hit ? this.cacheStats.hits += 1 : this.cacheStats.misses += 1; } - /** @param {string} id @param {Record} health */ updateProvider(id, health) { this.providerHealth.set(id, Object.freeze({ ...health })); } - /** @param {unknown} error */ recordError(error) { const value = error && typeof error === "object" && "toJSON" in error && typeof error.toJSON === "function" ? error.toJSON() : error instanceof Error ? { message: error.message } : error && typeof error === "object" ? { ...error } : { message: String(error) }; - this.errors.push({ .../** @type {Record} */ - sanitize(value), timestamp: (/* @__PURE__ */ new Date()).toISOString() }); + this.errors.push({ ...sanitize(value), timestamp: (/* @__PURE__ */ new Date()).toISOString() }); if (this.errors.length > 100) this.errors.shift(); } - /** @param {Record} metadata */ setBrowserMetadata(metadata) { this.browserMetadata = Object.freeze({ ...metadata }); } - /** @param {string[]} names @param {Record} startup @param {Array>} timings */ setLegacyRuntime(names, startup, timings) { this.legacyPlugins = [...names]; this.legacyStartup = Object.freeze({ ...startup }); @@ -15828,15 +15773,12 @@ ${error.stack}` : ""); // src/services/dialog-service.js var _DialogService = class _DialogService { - /** @param {any} dialogPort */ constructor(dialogPort) { this.port = assertPort(dialogPort, "DialogPort", PORT_METHODS.dialog); } - /** @param {Record} options */ open(options) { return this.port.open(options); } - /** @param {number} id */ close(id) { return this.port.close(id); } @@ -15861,15 +15803,12 @@ ${error.stack}` : ""); } __name(isPrivateLiteral, "isPrivateLiteral"); var _ExternalUrlPolicy = class _ExternalUrlPolicy { - /** @param {{localOrigins?: string[]}} [options] */ constructor(options = {}) { this.localOrigins = new Set((options.localOrigins ?? []).map((origin) => new URL(origin).origin)); } - /** @param {string} origin */ authorizeLocalOrigin(origin) { this.localOrigins.add(new URL(origin).origin); } - /** @param {string | URL} input @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ assertAllowed(input, policy) { let url; try { @@ -15890,7 +15829,6 @@ ${error.stack}` : ""); } else throw new JhsError("INVALID_URL", "未知 URL trust class", { source: "ExternalUrlPolicy" }); return url; } - /** @param {string | URL} finalUrl @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} policy */ assertFinalUrl(finalUrl, policy) { return this.assertAllowed(finalUrl, policy); } @@ -15902,10 +15840,7 @@ ${error.stack}` : ""); function stableSerialize(value) { if (value == null || typeof value !== "object") return JSON.stringify(value); if (Array.isArray(value)) return `[${value.map(stableSerialize).join(",")}]`; - const object = ( - /** @type {Record} */ - value - ); + const object = value; return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(object[key])}`).join(",")}}`; } __name(stableSerialize, "stableSerialize"); @@ -15944,7 +15879,6 @@ ${error.stack}` : ""); } __name(createRequestKey, "createRequestKey"); var _HttpService = class _HttpService { - /** @param {{request: (options: any) => Promise}} port @param {import("./external-url-policy.js").ExternalUrlPolicy} urlPolicy @param {{diagnostics?: import("./diagnostics-service.js").DiagnosticsService, cache?: import("./cache-service.js").CacheService}} [options] */ constructor(port, urlPolicy, options = {}) { this.port = port; this.urlPolicy = urlPolicy; @@ -15952,15 +15886,11 @@ ${error.stack}` : ""); this.cache = options.cache ?? null; this.inflight = /* @__PURE__ */ new Map(); } - /** @param {Record} options @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ async request(options, scope) { const method = String(options.method ?? "GET").toUpperCase(); const cacheScope = options.cacheScope ?? "none"; if (method !== "GET" && cacheScope !== "none") throw new TypeError("Mutation requests cannot use generic cache/dedupe"); - const urlPolicy = ( - /** @type {{trustClass: string, hosts?: string[], expectedOrigin?: string}} */ - options.urlPolicy - ); + const urlPolicy = options.urlPolicy; const initialUrl = this.urlPolicy.assertAllowed(options.url, urlPolicy); const requestKey = await createRequestKey({ ...options, method, url: initialUrl.href, cacheScope }); const serializedKey = stableSerialize(requestKey); @@ -15985,7 +15915,6 @@ ${error.stack}` : ""); } return this.consume(entry, scope); } - /** @param {Record} options @param {{trustClass: string, hosts?: string[], expectedOrigin?: string}} urlPolicy */ async executeUnderlying(options, urlPolicy) { try { const response = await this.port.request(options); @@ -15998,7 +15927,6 @@ ${error.stack}` : ""); throw normalized; } } - /** @param {{promise: Promise, controller: AbortController, consumers: number}} entry @param {import("../core/lifecycle-scope.js").LifecycleScope} [scope] */ consume(entry, scope) { entry.consumers += 1; let released = false; @@ -16027,12 +15955,8 @@ ${error.stack}` : ""); consumer.release(); }); } - /** @param {{promise: Promise}} entry */ isSettled(entry) { - return ![...this.inflight.values()].includes( - /** @type {any} */ - entry - ); + return ![...this.inflight.values()].includes(entry); } updateDiagnostics() { const consumers = [...this.inflight.values()].reduce((sum, entry) => sum + entry.consumers, 0); @@ -16044,19 +15968,15 @@ ${error.stack}` : ""); // src/services/navigation-service.js var _NavigationService = class _NavigationService { - /** @param {any} navigationPort */ constructor(navigationPort) { this.port = assertPort(navigationPort, "NavigationPort", PORT_METHODS.navigation); } - /** @param {string | URL} url @param {{newTab?: boolean}} [options] */ open(url, options) { return this.port.open(String(url), options); } - /** @param {string | URL} url */ assign(url) { return this.port.assign(String(url)); } - /** @param {string | URL} url */ replace(url) { return this.port.replace(String(url)); } @@ -16066,12 +15986,10 @@ ${error.stack}` : ""); // src/services/magnet-service.js var _MagnetService = class _MagnetService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } - /** @param {Record} movieRef @param {Record} [context] */ async search(movieRef, context = {}) { const results = []; for (const provider of await this.providers.getAvailable("magnet", context)) { @@ -16085,7 +16003,6 @@ ${error.stack}` : ""); } return results; } - /** @param {Record} movieRef @param {Record} [context] */ async listNative(movieRef, context = {}) { const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : "javdb"; const manifest2 = (this.integrations?.list("movie.magnets") ?? []).find((item) => item.id === providerId); @@ -16099,15 +16016,12 @@ ${error.stack}` : ""); // src/services/movie-identity-service.js var _MovieIdentityService = class _MovieIdentityService { - /** @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ constructor(integrations = null) { this.integrations = integrations; } - /** @param {unknown} value */ normalize(value) { return normalizeMovieCarNum(value); } - /** @param {...unknown} candidates */ firstValid(...candidates) { for (const candidate of candidates) { const carNum = this.normalize(candidate); @@ -16115,7 +16029,6 @@ ${error.stack}` : ""); } return null; } - /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async resolve(movieRef, options = {}) { const carNum = this.normalize(movieRef.carNum); if (!carNum) return null; @@ -16130,7 +16043,6 @@ ${error.stack}` : ""); } return Object.freeze({ ...movieRef, carNum }); } - /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async detail(movieRef, options = {}) { const candidates = this.integrations?.list("movie.detail") ?? []; const providerId = typeof movieRef.providerId === "string" ? movieRef.providerId : movieRef.movieId ? "javdb" : null; @@ -16141,7 +16053,6 @@ ${error.stack}` : ""); } return null; } - /** @param {{period?: string, filter?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async rankings(options = {}) { for (const manifest2 of this.integrations?.list("movie.ranking") ?? []) { const adapter = this.integrations?.getAdapter(manifest2.id); @@ -16149,7 +16060,6 @@ ${error.stack}` : ""); } return []; } - /** @param {string} providerId @param {Record} query @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async catalog(providerId, query = {}, options = {}) { const manifest2 = (this.integrations?.list("movie.catalog") ?? []).find((item) => item.id === providerId); if (!manifest2) throw new TypeError(`Movie catalog provider is unavailable: ${providerId}`); @@ -16157,21 +16067,18 @@ ${error.stack}` : ""); if (typeof adapter?.listCatalog !== "function") throw new TypeError(`Movie catalog operation is unavailable: ${providerId}`); return adapter.listCatalog(query, options); } - /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async people(providerId, movieRef, options = {}) { const manifest2 = (this.integrations?.list("movie.credits") ?? []).find((item) => item.id === providerId); if (!manifest2) return Object.freeze({ actors: Object.freeze([]), seller: null }); const adapter = this.integrations?.getAdapter(manifest2.id); return typeof adapter?.getPeople === "function" ? adapter.getPeople(movieRef, options) : Object.freeze({ actors: Object.freeze([]), seller: null }); } - /** @param {string} providerId @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async images(providerId, movieRef, options = {}) { const manifest2 = (this.integrations?.list("movie.images") ?? []).find((item) => item.id === providerId); if (!manifest2) return []; const adapter = this.integrations?.getAdapter(manifest2.id); return typeof adapter?.getImages === "function" ? adapter.getImages(movieRef, options) : []; } - /** @param {Record} movieRef @param {string[]} providerIds */ sourceUrls(movieRef, providerIds) { return Object.freeze(providerIds.map((providerId) => { try { @@ -16183,7 +16090,6 @@ ${error.stack}` : ""); } }).filter(Boolean)); } - /** @param {string} providerId @param {Record} movieRef */ searchUrl(providerId, movieRef) { try { const adapter = this.integrations?.getAdapter(providerId); @@ -16192,7 +16098,6 @@ ${error.stack}` : ""); return null; } } - /** @param {string} providerId @param {string} url */ matchesProviderUrl(providerId, url) { try { const adapter = this.integrations?.getAdapter(providerId); @@ -16207,11 +16112,9 @@ ${error.stack}` : ""); // src/services/offline-service.js var _OfflineService = class _OfflineService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ constructor(providers) { this.providers = providers; } - /** @param {Record} resource @param {Record} [context] */ async submit(resource, context = {}) { const providers = await this.providers.getAvailable("offline", context); const failures = []; @@ -16233,7 +16136,6 @@ ${error.stack}` : ""); // src/services/profile-service.js var _ProfileService = class _ProfileService extends EventTarget { - /** @param {{windowRuntime?: Window, scope: import("../core/lifecycle-scope.js").LifecycleScope}} options */ constructor(options) { super(); this.window = options.windowRuntime ?? window; @@ -16272,11 +16174,9 @@ ${error.stack}` : ""); // src/services/related-service.js var _RelatedService = class _RelatedService { - /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ constructor(integrations) { this.integrations = integrations; } - /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ async list(movieRef, options = {}) { for (const manifest2 of this.integrations.list("movie.related")) { const adapter = this.integrations.getAdapter(manifest2.id); @@ -16290,11 +16190,9 @@ ${error.stack}` : ""); // src/services/review-service.js var _ReviewService = class _ReviewService { - /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ constructor(integrations) { this.integrations = integrations; } - /** @param {Record} movieRef @param {{scope?: import("../core/lifecycle-scope.js").LifecycleScope, page?: number, limit?: number}} [options] */ async list(movieRef, options = {}) { for (const manifest2 of this.integrations.list("movie.reviews")) { const adapter = this.integrations.getAdapter(manifest2.id); @@ -16308,12 +16206,10 @@ ${error.stack}` : ""); // src/services/screenshot-service.js var _ScreenshotService = class _ScreenshotService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } - /** @param {Record} movieRef @param {Record} [context] */ async resolve(movieRef, context = {}) { for (const provider of await this.providers.getAvailable("screenshot", context)) { try { @@ -16338,11 +16234,9 @@ ${error.stack}` : ""); // src/services/translation-service.js var _TranslationService = class _TranslationService { - /** @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ constructor(integrations) { this.integrations = integrations; } - /** @param {string} text @param {{sourceLanguage?: string, targetLanguage?: string, scope?: import("../core/lifecycle-scope.js").LifecycleScope}} [options] */ async translate(text, options = {}) { const manifest2 = this.integrations.list("text.translate")[0]; if (!manifest2) throw new TypeError("Translation provider is unavailable"); @@ -16356,7 +16250,6 @@ ${error.stack}` : ""); // src/services/settings-service.js var _SettingsService = class _SettingsService extends EventTarget { - /** @param {{get: (key: string) => Promise, set: (key: string, value: unknown) => Promise}} storage @param {{validators?: Record boolean>}} [options] */ constructor(storage, options = {}) { super(); this.storage = storage; @@ -16364,7 +16257,6 @@ ${error.stack}` : ""); this.snapshotValue = Object.freeze({}); this.writeChain = Promise.resolve(); } - /** @param {string} key */ async load(key = "setting") { const stored = await this.storage.get(key); this.snapshotValue = Object.freeze(stored && typeof stored === "object" && !Array.isArray(stored) ? { ...stored } : {}); @@ -16373,7 +16265,6 @@ ${error.stack}` : ""); snapshot() { return this.snapshotValue; } - /** @param {string} name @param {unknown} value @param {string} [storageKey] */ async set(name, value, storageKey = "setting") { const validator = this.validators[name]; if (validator && !validator(value)) throw new TypeError(`Invalid setting: ${name}`); @@ -16393,19 +16284,15 @@ ${error.stack}` : ""); // src/services/storage-service.js var _StorageService = class _StorageService { - /** @param {any} storagePort */ constructor(storagePort) { this.port = assertPort(storagePort, "StoragePort", PORT_METHODS.storage); } - /** @param {string} key */ get(key) { return this.port.get(key); } - /** @param {string} key @param {unknown} value */ set(key, value) { return this.port.set(key, value); } - /** @param {string} key */ remove(key) { return this.port.remove(key); } @@ -16415,7 +16302,6 @@ ${error.stack}` : ""); // src/services/webdav-service.js var _WebDavClient = class _WebDavClient { - /** @param {import("./http-service.js").HttpService} http @param {string} davUrl @param {string} username @param {string} password */ constructor(http, davUrl, username, password) { this.http = http; this.baseUrl = new URL(davUrl.endsWith("/") ? davUrl : `${davUrl}/`); @@ -16426,7 +16312,6 @@ ${error.stack}` : ""); authHeaders() { return { Authorization: `Basic ${btoa(`${this.username}:${this.password}`)}`, Depth: "1" }; } - /** @param {string} method @param {string} path @param {Record} [headers] @param {unknown} [body] */ async request(method, path, headers = {}, body) { const url = new URL(path.replace(/^\/+/, ""), this.baseUrl); const response = await this.http.request({ @@ -16441,7 +16326,6 @@ ${error.stack}` : ""); }); return response.data ?? response.responseText ?? ""; } - /** @param {string} folder */ async ensureFolder(folder) { try { await this.request("MKCOL", folder); @@ -16449,12 +16333,10 @@ ${error.stack}` : ""); if (!(error instanceof Error) || !/HTTP (405|409)/.test(error.message)) throw error; } } - /** @param {string} folder @param {string} name @param {string} content */ async backup(folder, name, content) { await this.ensureFolder(folder); await this.request("PUT", `${folder}/${name}`, { "Content-Type": "text/plain" }, content); } - /** @param {string} folder */ async getFileList(folder) { const xml = String(await this.request("PROPFIND", folder, { "Content-Type": "application/xml" }, '')); const responses = new DOMParser().parseFromString(xml, "text/xml").getElementsByTagNameNS("DAV:", "response"), files = []; @@ -16465,17 +16347,14 @@ ${error.stack}` : ""); } return files.reverse(); } - /** @param {string} name */ async deleteFile(name) { await this.request("DELETE", `${this.folderName}/${encodeURI(name)}`, { "Cache-Control": "no-cache" }); } - /** @param {string} folder */ async getBackupList(folder) { this.folderName = folder; await this.ensureFolder(folder); return this.getFileList(folder); } - /** @param {string} name */ async getFileContent(name) { return String(await this.request("GET", `${this.folderName}/${name}`, { Accept: "application/octet-stream" })); } @@ -16483,11 +16362,9 @@ ${error.stack}` : ""); __name(_WebDavClient, "WebDavClient"); var WebDavClient = _WebDavClient; var _WebDavService = class _WebDavService { - /** @param {import("./http-service.js").HttpService} http */ constructor(http) { this.http = http; } - /** @param {{url: string, username: string, password: string}} config */ createClient(config) { return new WebDavClient(this.http, config.url, config.username, config.password); } @@ -16497,15 +16374,12 @@ ${error.stack}` : ""); // src/services/style-registry.js var _StyleRegistry = class _StyleRegistry { - /** @param {any} stylePort */ constructor(stylePort) { this.port = assertPort(stylePort, "StylePort", PORT_METHODS.style); } - /** @param {string} id @param {string} css */ register(id, css) { return this.port.register(id, css); } - /** @param {string} id */ remove(id) { return this.port.remove(id); } @@ -16520,23 +16394,19 @@ ${error.stack}` : ""); this.handlers = /* @__PURE__ */ new Map(); this.activateOwner = null; } - /** @param {(featureId: string) => Promise} activator */ setActivator(activator) { this.activateOwner = activator; } - /** @param {string} command @param {string} featureId */ registerOwner(command, featureId) { const owner = this.owners.get(command); if (owner && owner !== featureId) throw new Error(`Duplicate command owner: ${command}`); this.owners.set(command, featureId); } - /** @param {string} command @param {(...args: any[]) => any} handler @param {string} featureId */ registerHandler(command, handler, featureId) { if (this.owners.get(command) !== featureId) throw new Error(`Feature ${featureId} does not own command ${command}`); if (this.handlers.has(command)) throw new Error(`Duplicate command handler: ${command}`); this.handlers.set(command, handler); } - /** @param {string} command @param {...any} args */ async execute(command, ...args) { const owner = this.owners.get(command); if (!owner) throw new Error(`Unknown command: ${command}`); @@ -16554,12 +16424,10 @@ ${error.stack}` : ""); // src/app/dependency-container.js var _DependencyContainer = class _DependencyContainer { - /** @param {{recordError?: (error: unknown) => void} | null} [diagnostics] */ constructor(diagnostics = null) { this.values = /* @__PURE__ */ new Map(); this.diagnostics = diagnostics; } - /** @param {symbol} token @param {unknown} value */ register(token, value) { if (typeof token !== "symbol") throw new TypeError("Dependency token must be a symbol"); if (this.values.has(token)) { @@ -16570,7 +16438,6 @@ ${error.stack}` : ""); this.values.set(token, value); return this; } - /** @param {readonly symbol[]} requiredTokens */ resolveDeclared(requiredTokens) { const dependencies = /* @__PURE__ */ Object.create(null); for (const token of requiredTokens) { @@ -16592,7 +16459,6 @@ ${error.stack}` : ""); // src/app/feature-runtime.js var _FeatureRuntime = class _FeatureRuntime { - /** @param {{container: import("./dependency-container.js").DependencyContainer, commands: import("./command-registry.js").CommandRegistry, diagnostics: import("../services/diagnostics-service.js").DiagnosticsService, disabled?: string[], site?: string, route?: string}} options */ constructor(options) { this.container = options.container; this.commands = options.commands; @@ -16604,24 +16470,18 @@ ${error.stack}` : ""); this.activations = /* @__PURE__ */ new Map(); this.commands.setActivator((featureId) => this.activate(featureId).then(() => void 0)); } - /** @param {Record} manifest */ register(manifest2) { - const validated = ( - /** @type {Record} */ - defineFeature(manifest2) - ); + const validated = defineFeature(manifest2); if (this.manifests.has(validated.id)) throw new Error(`Duplicate feature: ${validated.id}`); this.manifests.set(validated.id, validated); for (const command of validated.providesCommands) this.commands.registerOwner(command, validated.id); } - /** @param {Record} manifest */ isEligible(manifest2) { if (manifest2.kind !== "system" && this.disabled.has(manifest2.id)) return false; if (manifest2.sites.length && !manifest2.sites.includes(this.site)) return false; if (manifest2.routes.length && !manifest2.routes.includes(this.route)) return false; return true; } - /** @param {string} featureId @param {string} contributionId @param {string} legacyPluginId */ isContributionEnabled(featureId, contributionId, legacyPluginId) { const manifest2 = this.manifests.get(featureId); if (!manifest2) return false; @@ -16631,15 +16491,12 @@ ${error.stack}` : ""); if (manifest2.kind === "system") return true; return !this.disabled.has(contributionId) && !this.disabled.has(legacyPluginId); } - /** @param {symbol[]} tokens */ resolveDeclaredDependencies(tokens) { return this.container.resolveDeclared(tokens); } - /** @param {string} featureId */ async getScope(featureId) { return (await this.activate(featureId)).scope; } - /** @param {string} id */ activate(id) { const existing = this.activations.get(id); if (existing) return existing; @@ -16651,7 +16508,6 @@ ${error.stack}` : ""); activation.catch(() => this.activations.delete(id)); return activation; } - /** @param {Record} manifest */ async activateManifest(manifest2) { const started = performance.now(); const scope = new LifecycleScope(`feature:${manifest2.id}`, { onChange: /* @__PURE__ */ __name((snapshot) => this.diagnostics.updateScope(snapshot), "onChange") }); @@ -16699,13 +16555,11 @@ ${error.stack}` : ""); // src/app/provider-registry.js var _ProviderRegistry = class _ProviderRegistry { - /** @param {import("../services/diagnostics-service.js").DiagnosticsService | null} [diagnostics] */ constructor(diagnostics = null) { this.providers = /* @__PURE__ */ new Map(); this.health = /* @__PURE__ */ new Map(); this.diagnostics = diagnostics; } - /** @param {Record} provider */ register(provider) { if (!provider || typeof provider.id !== "string" || !Array.isArray(provider.capabilities)) { throw new TypeError("Provider requires id and capabilities"); @@ -16714,15 +16568,12 @@ ${error.stack}` : ""); this.providers.set(provider.id, Object.freeze({ priority: 0, enabled: true, ...provider })); return provider; } - /** @param {string} id */ get(id) { return this.providers.get(id) ?? null; } - /** @param {string} capability */ list(capability) { return [...this.providers.values()].filter((provider) => provider.enabled !== false && provider.capabilities.includes(capability)).sort((left, right) => Number(right.priority ?? 0) - Number(left.priority ?? 0)); } - /** @param {string} capability @param {unknown} context */ async getAvailable(capability, context) { const result = []; for (const provider of this.list(capability)) { @@ -16730,13 +16581,11 @@ ${error.stack}` : ""); } return result; } - /** @param {string} id @param {Record} result */ updateHealth(id, result) { if (!this.providers.has(id)) throw new Error(`Unknown provider: ${id}`); this.health.set(id, Object.freeze({ ...result, updatedAt: Date.now() })); this.diagnostics?.updateProvider(id, result); } - /** @param {string} id */ getHealth(id) { return this.health.get(id) ?? null; } @@ -16746,20 +16595,15 @@ ${error.stack}` : ""); // src/app/integration-registry.js var _IntegrationRegistry = class _IntegrationRegistry { - /** @param {import("./dependency-container.js").DependencyContainer} container @param {import("../services/diagnostics-service.js").DiagnosticsService} diagnostics */ constructor(container, diagnostics) { this.container = container; this.diagnostics = diagnostics; this.manifests = /* @__PURE__ */ new Map(); this.adapters = /* @__PURE__ */ new Map(); } - /** @param {Record} manifest */ register(manifest2) { try { - const validated = ( - /** @type {Record} */ - defineIntegration(manifest2) - ); + const validated = defineIntegration(manifest2); if (this.manifests.has(validated.id)) throw new Error(`Duplicate integration: ${validated.id}`); this.manifests.set(validated.id, validated); } catch (error) { @@ -16767,11 +16611,9 @@ ${error.stack}` : ""); throw error; } } - /** @param {string} capability */ list(capability) { return [...this.manifests.values()].filter((manifest2) => manifest2.capabilities.includes(capability)); } - /** @param {string} id */ getAdapter(id) { const cached = this.adapters.get(id); if (cached) return cached; @@ -16797,17 +16639,14 @@ ${error.stack}` : ""); constructor() { this.descriptors = /* @__PURE__ */ new Map(); } - /** @param {{id: string, owner: string, contribution?: string, validate?: (value: unknown) => boolean} & Record} descriptor */ register(descriptor) { if (!descriptor?.id || !descriptor.owner) throw new TypeError("Setting descriptor requires id and owner"); if (this.descriptors.has(descriptor.id)) throw new Error(`Duplicate setting descriptor: ${descriptor.id}`); this.descriptors.set(descriptor.id, Object.freeze({ ...descriptor })); } - /** @param {{disabledContributions?: Set}} [options] */ list(options = {}) { return [...this.descriptors.values()].filter((descriptor) => !descriptor.contribution || !options.disabledContributions?.has(descriptor.contribution)); } - /** @param {string} id */ get(id) { return this.descriptors.get(id) ?? null; } @@ -16898,11 +16737,7 @@ ${error.stack}` : ""); } return [...items.values()]; } - const wrap = ( - /** @type {any} */ - globalThis.jQuery ?? /** @type {any} */ - globalThis.$ ?? $page.constructor - ); + const wrap = globalThis.jQuery ?? globalThis.$ ?? $page.constructor; $page.find(".card").each(((index, element) => { const $card = wrap(element); const $link = $card.find('a.card__link[href*="/cn/v/fc2-ppv-"]').first(); @@ -16925,10 +16760,7 @@ ${error.stack}` : ""); __name(parse123AvCards, "parse123AvCards"); function merge123AvCards(cardLists) { const items = /* @__PURE__ */ new Map(); - cardLists.flat().forEach( - /** @type {Record} */ - ((item) => items.set(item.carNum, item)) - ); + cardLists.flat().forEach(((item) => items.set(item.carNum, item))); return [...items.values()]; } __name(merge123AvCards, "merge123AvCards"); @@ -16982,17 +16814,14 @@ ${error.stack}` : ""); }, scope), "request"); return Object.freeze({ contracts: ["MovieRef", "MovieDetail"], - /** @param {{carNum?: unknown}} movieRef */ searchUrl(movieRef) { const carNum = normalizeMovieCarNum(movieRef?.carNum); return carNum ? `https://123av.com/cn/search?keyword=${encodeURIComponent(carNum)}` : null; }, - /** @param {{carNum: string, url?: string}} movieRef */ detailUrl(movieRef) { const id = normalizeMovieCarNum(movieRef.carNum)?.match(/^FC2-(\d+)$/)?.[1]; return movieRef.url ? new URL(movieRef.url).href : id ? `https://123av.com/cn/v/fc2-ppv-${id}` : null; }, - /** @param {string} value */ matchesUrl(value) { try { return new URL(value).hostname === "123av.com"; @@ -17000,7 +16829,6 @@ ${error.stack}` : ""); return false; } }, - /** @param {{page?: number, keyword?: string}} query @param {{scope?: any}} [options] */ async listCatalog(query = {}, options = {}) { const page = Math.max(1, Number(query.page) || 1), keyword = String(query.keyword || "").trim(); const sourcePages = keyword ? [1] : [page * 2 - 1, page * 2]; @@ -17018,7 +16846,6 @@ ${error.stack}` : ""); const sourceMaxPage = keyword || !responses.length ? null : parse123AvSourceMaxPage(responses[0].data, responses[0].finalUrl || urls[0]); return Object.freeze({ items, maxPage: sourceMaxPage ? Math.ceil(sourceMaxPage / 2) : null }); }, - /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ async resolveMovie(movieRef, options = {}) { const carNum = normalizeMovieCarNum(movieRef.carNum); if (!carNum) return null; @@ -17028,7 +16855,6 @@ ${error.stack}` : ""); const match = parse123AvCards(response.data, response.finalUrl || url).find((item) => normalizeMovieCarNum(item.carNum) === carNum); return match ? defineMovieRef({ carNum, url: match.href, providerId: "av123" }) : null; }, - /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ async getDetail(movieRef, options = {}) { const carNum = normalizeMovieCarNum(movieRef.carNum), id = carNum?.match(/^FC2-(\d+)$/)?.[1]; if (!carNum || !movieRef.url && !id) throw new TypeError("123AV movie reference is invalid"); @@ -17076,7 +16902,6 @@ ${error.stack}` : ""); function createDmmAdapter(http) { return Object.freeze({ contracts: ["MoviePreview"], - /** @param {{url: string}} movieRef @param {{scope?: any}} [options] */ async getPreview(movieRef, options = {}) { const url = new URL(movieRef.url); const response = await http.request({ @@ -17156,16 +16981,13 @@ ${error.stack}` : ""); }, scope), "request"); return Object.freeze({ contracts: ["MovieDetail", "MovieCredits"], - /** @param {{carNum: string, url?: string}} movieRef */ detailUrl(movieRef) { return resolveUrl(movieRef).href; }, - /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ async getDetail(movieRef, options = {}) { const url = resolveUrl(movieRef), response = await request(url, options.scope); return parseFc2PpvDbDetail(response.data, response.finalUrl || url.href); }, - /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ async getPeople(movieRef, options = {}) { const url = resolveUrl(movieRef), response = await request(url, options.scope); return parseFc2PpvDbPeople(response.data, response.finalUrl || url.href); @@ -17201,12 +17023,10 @@ ${error.stack}` : ""); function createFc2ContentAdapter(http) { return Object.freeze({ contracts: ["Screenshot"], - /** @param {{carNum: string}} movieRef */ detailUrl(movieRef) { const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; return id ? `https://adult.contents.fc2.com/article/${id}/` : null; }, - /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ async getImages(movieRef, options = {}) { const id = String(movieRef.carNum || "").match(/^(?:FC2-)?(?:PPV-)?(\d+)$/i)?.[1]; if (!id) return []; @@ -17242,10 +17062,7 @@ ${error.stack}` : ""); // src/integrations/google-translate/parser.js function parseGoogleTranslation(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new TypeError("Translation response is invalid"); - const translation = ( - /** @type {{translation?: unknown}} */ - payload.translation - ); + const translation = payload.translation; if (typeof translation !== "string" || !translation.trim()) throw new TypeError("Translation text is missing"); return translation; } @@ -17256,7 +17073,6 @@ ${error.stack}` : ""); function createGoogleTranslateAdapter(http) { return Object.freeze({ contracts: ["Translation"], - /** @param {string} text @param {{sourceLanguage?: string, targetLanguage?: string, scope?: any}} [options] */ async translate(text, options = {}) { if (!String(text || "").trim()) throw new TypeError("Translation text cannot be empty"); const url = new URL(ENDPOINT); @@ -17315,10 +17131,7 @@ ${error.stack}` : ""); function createJavDbSignature() { const now = Math.floor(Date.now() / 1e3); if (signatureValue2 && now - signatureSecond2 <= 20) return signatureValue2; - const md5Runtime = ( - /** @type {any} */ - globalThis.md5 - ); + const md5Runtime = globalThis.md5; if (typeof md5Runtime !== "function") throw new Error("Missing userscript vendor runtime: md5"); signatureSecond2 = now; signatureValue2 = `${now}.lpw6vgqzsp.${md5Runtime(`${now}71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa`)}`; @@ -17346,7 +17159,6 @@ ${error.stack}` : ""); }, "request"); return Object.freeze({ contracts: ["MovieRef", "MovieDetail", "Actor", "Magnet", "Review", "RelatedList"], - /** @param {Record} movieRef @param {{scope?: any}} [options] */ async resolveMovie(movieRef, options = {}) { const carNum = normalizeMovieCarNum(movieRef.carNum); if (!carNum) return null; @@ -17355,7 +17167,6 @@ ${error.stack}` : ""); const match = payload.data.movies.find((movie) => normalizeMovieCarNum(movie.number) === carNum); return match?.id ? Object.freeze({ carNum, movieId: String(match.id), providerId: "javdb" }) : null; }, - /** @param {Record} movieRef @param {{scope?: any}} [options] */ async getDetail(movieRef, options = {}) { const movieId = String(movieRef.movieId || "").trim(); if (!movieId) throw new TypeError("JavDB movie id is required"); @@ -17376,7 +17187,6 @@ ${error.stack}` : ""); providerId: "javdb" }); }, - /** @param {Record} movieRef @param {{scope?: any}} [options] */ async listMagnets(movieRef, options = {}) { const movieId = String(movieRef.movieId || "").trim(); if (!movieId) return []; @@ -17394,7 +17204,6 @@ ${error.stack}` : ""); providerId: "javdb" })); }, - /** @param {{period?: string, filter?: string, scope?: any}} [options] */ async listRankings(options = {}) { const payload = await request("/api/v1/rankings/playback", { period: options.period || "daily", filter_by: options.filter || "high_score" }, options); if (!Array.isArray(payload?.data?.movies)) throw new Error(payload?.message || "JavDB ranking response is invalid"); @@ -17410,7 +17219,6 @@ ${error.stack}` : ""); providerId: "javdb" })); }, - /** @param {Record} movieRef @param {{scope?: any, page?: number, limit?: number}} [options] */ async listReviews(movieRef, options = {}) { const movieId = String(movieRef.movieId || "").trim(); if (!movieId) return []; @@ -17424,7 +17232,6 @@ ${error.stack}` : ""); likes: Number(review.likes_count) || 0 })); }, - /** @param {Record} movieRef @param {{scope?: any, page?: number, limit?: number}} [options] */ async listRelated(movieRef, options = {}) { const movieId = String(movieRef.movieId || "").trim(); if (!movieId) return []; @@ -17492,7 +17299,6 @@ ${error.stack}` : ""); function createJavBusAdapter(http) { return Object.freeze({ contracts: ["MovieDetail", "Screenshot"], - /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ async getDetail(movieRef, options = {}) { const carNum = normalizeMovieCarNum(movieRef.carNum); if (!carNum) throw new TypeError("JavBus movie reference is invalid"); @@ -17508,7 +17314,6 @@ ${error.stack}` : ""); }, options.scope); return parseJavBusMovieDetail(response.data, response.finalUrl || url); }, - /** @param {{carNum: string, url?: string}} movieRef @param {{scope?: any}} [options] */ async getImages(movieRef, options = {}) { const detail = await this.getDetail(movieRef, options); return detail.coverUrl ? [Object.freeze({ url: detail.coverUrl, providerId: "javbus" })] : []; @@ -17542,7 +17347,6 @@ ${error.stack}` : ""); }, scope), "request"); return Object.freeze({ contracts: ["Screenshot"], - /** @param {{carNum: string}} movieRef @param {{scope?: any}} [options] */ async getImages(movieRef, options = {}) { const searchUrl = `https://javstore.net/search?q=${encodeURIComponent(movieRef.carNum || "")}`; const search = await request(searchUrl, options.scope); @@ -17582,7 +17386,6 @@ ${error.stack}` : ""); function createSubtitleCatAdapter() { return Object.freeze({ contracts: ["Subtitle"], - /** @param {{carNum?: unknown}} movieRef */ detailUrl(movieRef) { const carNum = String(movieRef?.carNum || "").trim(); if (!carNum) return null; @@ -17629,11 +17432,9 @@ ${error.stack}` : ""); function createWikipediaAdapter(http) { return Object.freeze({ contracts: ["ActressInfo"], - /** @param {string} name */ profileUrl(name) { return new URL(`/wiki/${encodeURIComponent(String(name))}`, "https://ja.wikipedia.org").href; }, - /** @param {string} name @param {{scope?: any}} [options] */ async lookup(name, options = {}) { const url = this.profileUrl(name); const response = await http.request({ diff --git a/scripts/build.mjs b/scripts/build.mjs index 1a0468ad..0ddf8e8a 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -39,12 +39,13 @@ export async function buildUserscript({ outputPaths = [distPath, rootPath], tran write: false, logLevel: "silent", plugins: [{ - name: "strip-userscript-metadata", + name: "strip-build-comments", setup(build) { - build.onLoad({ filter: /src[\\/]main\.js$/ }, async (args) => ({ - contents: (await readFile(args.path, "utf8")).replace(/^\/\/ ==UserScript==[\s\S]*?^\/\/ ==\/UserScript==\r?\n?/m, ""), - loader: "js" - })); + build.onLoad({ filter: /src[\\/].*\.js$/ }, async (args) => { + let contents = await readFile(args.path, "utf8"); + if (args.path === srcPath) contents = contents.replace(/^\/\/ ==UserScript==[\s\S]*?^\/\/ ==\/UserScript==\r?\n?/m, ""); + return { contents: contents.replace(/\/\*\*[\s\S]*?\*\//g, ""), loader: "js" }; + }); } }] }); diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index ec837714..29e24622 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -131,6 +131,7 @@ assertIncludes(buildScript, "keepNames: true", "readable bundled build"); assertIncludes(buildScript, "minifySyntax: false", "readable bundled build"); assertIncludes(buildScript, "minifyWhitespace: false", "readable bundled build"); assertIncludes(buildScript, "minifyIdentifiers: false", "readable bundled build"); +assertIncludes(buildScript, 'contents.replace(/\\/\\*\\*[\\s\\S]*?\\*\\//g, "")', "production bundle strips source-only JSDoc"); const stableReleaseChecks = [ ["storage database identity", storage, 'name: "JAV-JHS"'], @@ -187,7 +188,8 @@ for (const [label, source] of [["FC2", fc2], ["FC2/123AV", fc2By123Av]]) { assert(!source.includes("stateService.patch("), `${label} state actions must use toggle semantics`); } assertIncludes(fc2, "detailStateController.bind", "shared FC2 detail state controller"); -assertIncludes(fc2By123Av, 'this.getDependency("Fc2Plugin").openFc2Dialog', "123AV must reuse FC2 state and shell ownership"); +assertIncludes(fc2, '"123av" === context.source ? void this.load123AvDetail(context)', "FC2 controller must own 123AV detail orchestration"); +assert(!fc2By123Av.includes('getDependency("Fc2Plugin")'), "123AV data source must not depend on the FC2 UI plugin"); assert(!uiPrimitives.includes('.trigger("change")'), "JhsSelect must dispatch one native change without jQuery double fire"); for (const [label, source] of [["123", one23Offline], ["115", one115Offline]]) { assert(!source.includes("injectJavDbButtons"), `${label} provider must not inject JavDB UI`); diff --git a/scripts/ui-consistency-audit.mjs b/scripts/ui-consistency-audit.mjs index 571449ce..dd88b207 100644 --- a/scripts/ui-consistency-audit.mjs +++ b/scripts/ui-consistency-audit.mjs @@ -23,7 +23,7 @@ async function listJavaScriptFiles(dir) { return files; } -const [theme, primitives, build, injection, magnet, settings, utils, detail, commandbar, newVideo, manager, hitShow, translate, translationUi, settingStyles, main, packageSource, logger, reviews, related, settingPanels, settingForms, listButtons, coverButtons, highlightMagnet, task, storageQueue, constants, previewVideo, screenshot, parsers, javstoreIntegration, otherSite, builtSource] = await Promise.all([ +const [theme, primitives, build, injection, magnet, settings, utils, detail, commandbar, newVideo, manager, hitShow, translate, translationUi, settingStyles, main, packageSource, logger, reviews, reviewUi, related, settingPanels, settingForms, listButtons, coverButtons, highlightMagnet, task, storageQueue, constants, previewVideo, screenshot, parsers, javstoreIntegration, otherSite, builtSource] = await Promise.all([ readFile(join(srcRoot, "core", "theme.js"), "utf8"), readFile(join(srcRoot, "core", "ui-primitives.js"), "utf8"), readFile(join(repoRoot, "scripts", "build.mjs"), "utf8"), @@ -43,6 +43,7 @@ const [theme, primitives, build, injection, magnet, settings, utils, detail, com readFile(join(repoRoot, "package.json"), "utf8"), readFile(join(srcRoot, "core", "logger.js"), "utf8"), readFile(join(srcRoot, "plugins", "external-search", "review.js"), "utf8"), + readFile(join(srcRoot, "ui", "detail", "review-panel.js"), "utf8"), readFile(join(srcRoot, "plugins", "external-search", "related.js"), "utf8"), readFile(join(srcRoot, "plugins", "backup", "setting-panels.js"), "utf8"), readFile(join(srcRoot, "plugins", "backup", "setting-forms.js"), "utf8"), @@ -165,8 +166,8 @@ forbidMatch(detail, /routeSections|moveToSection|movePanelToSection/, "detail wo for (const [source, label] of [[reviews, "reviews"], [related, "related lists"]]) { forbidMatch(source, /item columns is-desktop|jhs-layout-[a-f0-9]{8}/, `${label} must not reuse host or migration layout classes`); } -requireMatch(reviews, /document\.createTextNode/, "review external content must be rendered as text nodes"); -requireMatch(reviews, /appendLinkControls/, "review links must use compact semantic controls"); +requireMatch(reviewUi, /document\.createTextNode/, "review external content must be rendered as text nodes"); +requireMatch(reviewUi, /appendLink/, "review links must use compact semantic controls"); requireMatch(reviews, /font-size:15px[\s\S]*font-weight:600/, "review author must use 15px semibold text"); requireMatch(reviews, /jhs-review-content[^}]*font-size:16px[^}]*line-height:1\.7/, "review body readability contract is missing"); forbidMatch(reviews, /jhs-review-content[^}]*max-width/, "review body must use the full available width"); diff --git a/src/plugins/dependency-map.js b/src/plugins/dependency-map.js index 73689c41..0e4c62e1 100644 --- a/src/plugins/dependency-map.js +++ b/src/plugins/dependency-map.js @@ -12,11 +12,10 @@ export const LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ CompatibilityEnhancementsPlugin: ["ListPagePlugin"], CoverButtonPlugin: ["ListPagePlugin", "ScreenShotPlugin", "OtherSitePlugin"], DetailPageButtonPlugin: ["DetailWorkspacePlugin", "MagnetHubPlugin", "HighlightMagnetPlugin"], - Fc2By123AvPlugin: ["OtherSitePlugin", "Fc2Plugin"], Fc2Plugin: [ "DetailPageButtonPlugin", "MagnetHubPlugin", "FilterTitleKeywordPlugin", "OtherSitePlugin", "Fc2By123AvPlugin", "TOP250Plugin", - "HighlightMagnetPlugin", "ReviewPlugin", "RelatedPlugin" + "HighlightMagnetPlugin" ], HistoryPlugin: ["UnifiedOfflinePlugin", "ListPagePlugin", "Fc2Plugin"], HitShowPlugin: ["ListPageButtonPlugin", "ListPagePlugin", "CoverButtonPlugin"], diff --git a/src/plugins/external-search/fc2-by-123av.js b/src/plugins/external-search/fc2-by-123av.js index 4cf8c19a..e13746d0 100644 --- a/src/plugins/external-search/fc2-by-123av.js +++ b/src/plugins/external-search/fc2-by-123av.js @@ -1,6 +1,7 @@ import { _, escapeHtml, i, o } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { renderTranslatedTitle } from "../../ui/translation/title-translation.js"; +import { createFc2SourceLinks, renderFc2Gallery, renderFc2State } from "../../ui/detail/fc2-workspace-view.js"; export class Fc2By123AvPlugin extends BasePlugin { constructor() { @@ -79,19 +80,15 @@ export class Fc2By123AvPlugin extends BasePlugin { e.close(); } } - async open123AvFc2Dialog(carNum, url) { return this.getDependency("Fc2Plugin").openFc2Dialog(null, carNum, url, { source: "123av" }); } /** 将 123AV 数据填入 Fc2Plugin 创建的固定工作区。 */ async loadDetail(context, url) { - const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum), movieIdPromise = this.resolveMovieId(context.carNum), fc2Plugin = this.getDependency("Fc2Plugin"); - void fc2Plugin.configureJavDbWantButton(context, movieIdPromise), void fc2Plugin.mountPanels(context, movieIdPromise), void movieIdPromise.then((movieId => context.isAlive() && fc2Plugin.fetchAndRenderNativeMagnets(context, movieId))).catch((error => { - context.isAlive() && fc2Plugin.setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))), clog.error("123AV 磁力关联失败", error); - })); - imagesPromise.then((images => context.isAlive() && this.getDependency("Fc2Plugin").renderGallery(context, images))).catch((error => context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))))); + const infoPromise = this.loadSummary(context, url), imagesPromise = this.getImgList(context.carNum), actressPromise = this.getActressInfo(context.carNum); + imagesPromise.then((images => context.isAlive() && renderFc2Gallery(context, images))).catch((error => context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))))); actressPromise.then((async data => { await infoPromise.catch((() => null)); context.isAlive() && this.render123AvActress(context, data); })).catch((error => clog.error("FC2 演员信息加载失败", error))); - await Promise.allSettled([ infoPromise, imagesPromise, actressPromise, movieIdPromise ]); + await Promise.allSettled([ infoPromise, imagesPromise, actressPromise ]); } async loadSummary(context, url) { try { @@ -102,16 +99,13 @@ export class Fc2By123AvPlugin extends BasePlugin { if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _) await renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope }); return info; } catch (error) { - context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); throw error; } } - async retryResolvedMagnets(context) { - try { return await this.getDependency("Fc2Plugin").fetchAndRenderNativeMagnets(context, await this.resolveMovieId(context.carNum)); } catch (error) { context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.retryResolvedMagnets(context))); } - } render123AvSummary(context, info) { const body = context.root.find('[data-jhs-role="summary-content"]').empty(), title = $('

    '); - title.find("strong").text(info.title || "无标题"), body.append(title, $('
    ').append($("").text(`番号:${context.carNum}`), $("").text(`发行:${info.publishDate || "未知"}`)), '
    主演:正在加载演员…
    ', '
    ', this.getDependency("Fc2Plugin").createSourceLinks(context), $('').text(info.publishDate || "")); + title.find("strong").text(info.title || "无标题"), body.append(title, $('
    ').append($("").text(`番号:${context.carNum}`), $("").text(`发行:${info.publishDate || "未知"}`)), '
    主演:正在加载演员…
    ', '
    ', createFc2SourceLinks(context, this.getRuntimeService("movie")), $('').text(info.publishDate || "")); } async get123AvVideoInfo(carNum, e) { const scope = await this.getRuntimeService("scope")(); @@ -129,9 +123,9 @@ export class Fc2By123AvPlugin extends BasePlugin { async reloadImages(context) { try { const images = await this.getImgList(context.carNum); - context.isAlive() && this.getDependency("Fc2Plugin").renderGallery(context, images); + context.isAlive() && renderFc2Gallery(context, images); } catch (error) { - context.isAlive() && this.getDependency("Fc2Plugin").setState(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))); + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="gallery-grid"]'), "剧照加载失败", (() => void this.reloadImages(context))); } } render123AvActress(context, data) { diff --git a/src/plugins/external-search/fc2.js b/src/plugins/external-search/fc2.js index ba923a32..3cc59cb1 100644 --- a/src/plugins/external-search/fc2.js +++ b/src/plugins/external-search/fc2.js @@ -1,10 +1,13 @@ -import { C, _, k, m, o, v, y } from "../../core/constants.js"; +import { _, k, m, o, v, y } from "../../core/constants.js"; import { detailStateController } from "../../core/detail-state-controller.js"; -import { normalizeBtihHash, normalizeHttpUrl } from "../../core/feature-helpers.js"; +import { normalizeBtihHash } from "../../core/feature-helpers.js"; import { markJavDbWantWatch } from "../../core/javdb-api.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { renderTranslatedTitle } from "../../ui/translation/title-translation.js"; import { renderScreenshotPanel } from "../../ui/detail/screenshot-panel.js"; +import { createFc2SourceLinks, renderFc2Gallery, renderFc2State } from "../../ui/detail/fc2-workspace-view.js"; +import { RelatedPanel } from "../../ui/detail/related-panel.js"; +import { ReviewPanel } from "../../ui/detail/review-panel.js"; import { createFc2DetailContext, createFc2DetailShell } from "../status/detail-workspace.js"; export class Fc2Plugin extends BasePlugin { @@ -127,17 +130,24 @@ export class Fc2Plugin extends BasePlugin { target: screenshot, carNum: context.carNum.replace("FC2-", ""), screenshot: this.getRuntimeService("screenshot"), settings: this.getRuntimeService("settings").snapshot(), scope, isActive: context.isAlive, }))).then((result => { if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); })); - "123av" === context.source ? void this.getDependency("Fc2By123AvPlugin").loadDetail(context, context.url) : void this.loadNativeDetail(context); + "123av" === context.source ? void this.load123AvDetail(context) : void this.loadNativeDetail(context); } createResourceGroup(title, role) { return $('

    ').find("h3").text(title).end().find("div").attr("data-jhs-role", role).end(); } - setState(target, message, retry = null) { - const host = $(target).empty(), state = $('
    ').text(message); - retry && state.addClass("is-error").append(" ", $('').on("click", retry)), host.append(state); - } async loadNativeDetail(context) { const movieIdPromise = Promise.resolve(context.movieId); this.configureJavDbWantButton(context, movieIdPromise), await Promise.allSettled([ this.fetchAndRenderNativeDetail(context), this.fetchAndRenderNativeMagnets(context), this.mountPanels(context, movieIdPromise) ]); } + async load123AvDetail(context) { + const source = this.getDependency("Fc2By123AvPlugin"), movieIdPromise = source.resolveMovieId(context.carNum); + void this.configureJavDbWantButton(context, movieIdPromise), void this.mountPanels(context, movieIdPromise), void movieIdPromise.then((movieId => context.isAlive() && this.fetchAndRenderNativeMagnets(context, movieId))).catch((error => { + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.load123AvMagnets(context))), clog.error("123AV 磁力关联失败", error); + })); + await source.loadDetail(context, context.url); + } + async load123AvMagnets(context) { + const movieId = await this.getDependency("Fc2By123AvPlugin").resolveMovieId(context.carNum); + return this.fetchAndRenderNativeMagnets(context, movieId); + } /** 绑定当前工作区自己的 JavDB“想看”操作。 */ async configureJavDbWantButton(context, movieIdPromise) { const button = context.root.find('[data-jhs-action="javdb-want"]'); @@ -175,10 +185,10 @@ export class Fc2Plugin extends BasePlugin { const movie = await this.getRuntimeService("movie").detail({ movieId: context.movieId, carNum: context.carNum, providerId: "javdb" }, { scope }); if (!movie) throw new Error("JavDB 影片详情不存在"); if (!context.isAlive()) return; - this.renderSummary(context, movie), this.renderGallery(context, movie.imageUrls || []); + this.renderSummary(context, movie), renderFc2Gallery(context, movie.imageUrls || []); if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _) await renderTranslatedTitle({ root: context.root, carNum: movie.carNum || context.carNum, translation: this.getRuntimeService("translation"), scope }); } catch (error) { - context.isAlive() && this.setState(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); + context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); } } renderSummary(context, movie) { @@ -188,29 +198,18 @@ export class Fc2Plugin extends BasePlugin { [ `番号:${movie.carNum || context.carNum}`, `发行:${movie.releaseDate || "未知"}`, `评分:${Number.isFinite(Number(movie.score)) ? movie.score : "无"}`, `时长:${Number.isFinite(Number(movie.duration)) ? movie.duration + " 分钟" : "无"}` ].forEach((value => meta.append($("").text(value)))), body.append(meta); const actors = $('
    主演:
    '), actressNames = []; (movie.actors || []).forEach((actor => { actors.append($("").addClass("jhs-fc2-actor").attr({ href: `/actors/${encodeURIComponent(actor.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(actor.name || "未知演员")), 0 === actor.gender && actressNames.push(actor.name); })); - movie.actors?.length || actors.append($("").text("暂无演员信息")), body.append(actors, this.createSourceLinks(context), $('').text(actressNames.join(" ")), $('').text(movie.releaseDate || "")); - } - createSourceLinks(context) { - const links = $(''), providerLinks = this.getRuntimeService("movie").sourceUrls({ carNum: context.carNum }, ["fc2ppvdb", "fc2content"]), values = [ [ "123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeHttpUrl(context.url) ], ...providerLinks.map((item => [ item.providerId === "fc2ppvdb" ? "FC2PPVDB" : "FC2 市场", item.url ])) ]; - values.forEach((([ label, href ]) => href && links.append($("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label)))); - return links; - } - renderGallery(context, images) { - const urls = [ ...new Set((images || []).map((url => normalizeHttpUrl(url))).filter(Boolean)) ], grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); - if (!urls.length) return this.setState(grid, "暂无剧照"); - preview.append($("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); - urls.forEach(((url, index) => grid.append($("").attr("aria-label", `查看剧照 ${index + 1}`).append($("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" }))))); + movie.actors?.length || actors.append($("").text("暂无演员信息")), body.append(actors, createFc2SourceLinks(context, this.getRuntimeService("movie")), $('').text(actressNames.join(" ")), $('').text(movie.releaseDate || "")); } async fetchAndRenderNativeMagnets(context, movieId = context.movieId) { const host = context.root.find('[data-jhs-role="native-magnets"]'); - this.setState(host, "正在加载站内磁力…"); + renderFc2State(host, "正在加载站内磁力…"); try { - if (!movieId) return this.setState(host, "JavDB 暂无对应作品"); + if (!movieId) return renderFc2State(host, "JavDB 暂无对应作品"); const scope = await this.getRuntimeService("scope")(); const magnets = await this.getRuntimeService("magnet").listNative({ movieId, providerId: "javdb" }, { scope }); if (!context.isAlive()) return; host.empty(); - if (!magnets.length) return this.setState(host, "暂无站内磁力"); + if (!magnets.length) return renderFc2State(host, "暂无站内磁力"); const highlighter = this.getDependency("HighlightMagnetPlugin"), assessments = []; magnets.forEach((item => { const hash = normalizeBtihHash(item.hash); @@ -220,26 +219,28 @@ export class Fc2Plugin extends BasePlugin { info.append($("").attr("href", magnet).text(item.title || magnet), $("
    ").addClass("jhs-fc2-meta").text(`${(Number(item.sizeMb || 0) / 1024).toFixed(2)} GB · ${Number(item.fileCount) || 0} 个文件${item.createdAt ? ` · ${item.createdAt}` : ""}`), tags), actions.append($('').attr("data-clipboard-text", magnet), $('').attr({ "data-resource": magnet, "data-jhs-offline-owner": "fc2" })), host.append(row.append(info, actions)); })); await this.bindNativeMagnetFilter(context, host, assessments.some((item => item.highQuality))); - } catch (error) { context.isAlive() && this.setState(host, "站内磁力加载失败", (() => void this.fetchAndRenderNativeMagnets(context, movieId))), clog.error("FC2 磁力加载失败", error); } + } catch (error) { context.isAlive() && renderFc2State(host, "站内磁力加载失败", (() => void this.fetchAndRenderNativeMagnets(context, movieId))), clog.error("FC2 磁力加载失败", error); } } async bindNativeMagnetFilter(context, host, hasMatch) { const section = context.getSection("resources"), actions = section.find(".jhs-fc2-section__actions"), old = actions.find('[data-jhs-action="filter-native-magnets"]'); old.remove(); const button = $(''), apply = enabled => { host.find(".jhs-fc2-magnet-item").show(); enabled && hasMatch && host.find('.jhs-fc2-magnet-item[data-jhs-high-quality="false"]').hide(); button.attr("aria-pressed", String(enabled && hasMatch)).text(hasMatch ? enabled ? "显示全部磁力" : "过滤低质量" : "暂无可过滤项").prop("disabled", !hasMatch); }; - actions.append(button), apply(await storageManager.getSetting("enableMagnetsFilter", _) === _), button.on(`click${context.namespace}`, (async () => { const enabled = "true" !== button.attr("aria-pressed"); apply(enabled), await storageManager.saveSettingItem("enableMagnetsFilter", enabled ? _ : C); })); + const settings = this.getRuntimeService("settings"); + actions.append(button), apply((settings.snapshot().enableMagnetsFilter ?? _) === _), button.on(`click${context.namespace}`, (async () => { const enabled = "true" !== button.attr("aria-pressed"); apply(enabled), await settings.set("enableMagnetsFilter", enabled ? _ : "no"); })); } async mountPanels(context, movieIdPromise) { try { const movieId = await movieIdPromise; if (!context.isAlive()) return; - if (!movieId) return this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"), this.setState(context.getSlot("reviews"), "JavDB 暂无对应作品"), this.setState(context.getSlot("related"), "JavDB 暂无对应作品"); + if (!movieId) return this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"), renderFc2State(context.getSlot("reviews"), "JavDB 暂无对应作品"), renderFc2State(context.getSlot("related"), "JavDB 暂无对应作品"); this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); - await Promise.allSettled([ this.getDependency("ReviewPlugin").showReview(movieId, context.getSlot("reviews"), { ownedSection: context.getSection("reviews"), isActive: context.isAlive }), this.getDependency("RelatedPlugin").showRelated(context.getSlot("related"), movieId, { ownedSection: context.getSection("related"), isActive: context.isAlive }) ]); + const scope = () => this.getRuntimeService("scope")(), relatedPanel = new RelatedPanel({ related: this.getRuntimeService("related"), settings: this.getRuntimeService("settings"), scope }), reviewPanel = new ReviewPanel({ review: this.getRuntimeService("review"), settings: this.getRuntimeService("settings"), storage: this.getRuntimeService("storage"), scope }); + await Promise.allSettled([ reviewPanel.show(movieId, context.getSlot("reviews"), { ownedSection: context.getSection("reviews"), isActive: context.isAlive }), relatedPanel.show(context.getSlot("related"), movieId, { ownedSection: context.getSection("related"), isActive: context.isAlive }) ]); } catch (error) { if (!context.isAlive()) return; this.clearOwnedPanel(context, "reviews"), this.clearOwnedPanel(context, "related"); const retry = () => void this.mountPanels(context, this.resolveMovieId(context.carNum)); - this.setState(context.getSlot("reviews"), "评论关联失败", retry), this.setState(context.getSlot("related"), "相关清单关联失败", retry), clog.error("FC2 JavDB 关联失败", error); + renderFc2State(context.getSlot("reviews"), "评论关联失败", retry), renderFc2State(context.getSlot("related"), "相关清单关联失败", retry), clog.error("FC2 JavDB 关联失败", error); } } clearOwnedPanel(context, name) { context.getSlot(name).empty(), context.getSection(name).find(".jhs-fc2-section__actions").empty(); } diff --git a/src/plugins/external-search/related.js b/src/plugins/external-search/related.js index e0dd5b0f..a6cbb6f1 100644 --- a/src/plugins/external-search/related.js +++ b/src/plugins/external-search/related.js @@ -1,5 +1,6 @@ -import { C, _, r } from "../../core/constants.js"; +import { r } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; +import { RelatedPanel } from "../../ui/detail/related-panel.js"; export class RelatedPlugin extends BasePlugin { getName() { @@ -29,81 +30,7 @@ export class RelatedPlugin extends BasePlugin { return element ? $(element) : $(); } async showRelated(target, movieId, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => !0; - const enabled = await storageManager.getSetting("enableLoadRelated", C), host = target?.length ? target : this.getHostedSlot("related"); - if (!movieId) return void show.error("未传入movieId"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="related"]').filter(((_, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('').attr("data-jhs-movie-id", String(movieId)), header = $('

    相关清单

    '), toggle = $(''), state = { movieId, panel, floorIndex: 1, loaded: !1, loading: !1, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle) : panel.append(header), panel.append('', ''), host.append(panel), state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", (event => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-related-container, .jhs-related-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayRelateds(state), storageManager.saveSettingItem("enableLoadRelated", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayRelateds(state) : panel.find(".jhs-related-container, .jhs-related-footer").hide(); - return panel; - } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), - toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); - } - async fetchAndDisplayRelateds(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = !0; - const { movieId, panel } = state, container = panel.find(".jhs-related-container"), footer = panel.find(".jhs-related-footer"); - container.empty().append($('
    ').text("获取清单中...")), footer.empty(); - let related, scope; - try { - scope = await this.getRuntimeService("scope")(); - related = await this.getRuntimeService("related").list({ movieId }, { page: 1, limit: 20, scope }); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); - clog.error("获取清单失败:", error); - state.loading = !1; - return void this.renderRetry(container, (() => this.fetchAndDisplayRelateds(state))); - } - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); - state.loading = !1, state.loaded = !0; - container.empty(); - if (!related.length) return void container.append($('
    ').text("无清单")); - this.displayRelateds(state, related, container), 20 === related.length ? this.bindLoadMore(state, container, footer) : footer.append($('
    ').text("已加载全部清单")); - } - renderRetry(container, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode("获取清单失败 ")); - state.append($('').on("click", retry)), container.append(state); - } - bindLoadMore(state, container, footer) { - const button = $(''), end = $('').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - let scope; - button.text("加载中...").prop("disabled", !0); - try { - scope = await this.getRuntimeService("scope")(); - const related = await this.getRuntimeService("related").list({ movieId: state.movieId }, { page: nextPage, limit: 20, scope }); - if (!state.isActive?.() || scope?.signal?.aborted) return; - state.page = nextPage; - this.displayRelateds(state, related, container), related.length < 20 ? (button.remove(), end.show()) : button.text("加载更多清单").prop("disabled", !1); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return; - clog.error("加载更多清单失败:", error), button.text("加载失败,请重试").prop("disabled", !1); - } - })); - } - displayRelateds(state, related, container) { - related.forEach((item => { - const row = $(''), title = $("").addClass("jhs-related-title").attr({ - href: `/lists/${encodeURIComponent(item.id)}`, - target: "_blank", - rel: "noopener noreferrer" - }).text(item.name || "未命名清单"), heading = $('').append($("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title), meta = $(''), time = $('').text(`创建时间:${item.createdAt ? utils.formatDate(item.createdAt) : "未知"}`); - meta.append($("").text(`视频:${Number(item.movieCount) || 0}`), $("").text(`收藏:${Number(item.collectionCount) || 0}`), - $("").text(`查看:${Number(item.viewCount) || 0}`), time), row.append(heading, meta), container.append(row); - })); + const panel = new RelatedPanel({ related: this.getRuntimeService("related"), settings: this.getRuntimeService("settings"), scope: () => this.getRuntimeService("scope")() }); + return panel.show(target?.length ? target : this.getHostedSlot("related"), movieId, options); } } diff --git a/src/plugins/external-search/review.js b/src/plugins/external-search/review.js index 8d797b04..fcd986f5 100644 --- a/src/plugins/external-search/review.js +++ b/src/plugins/external-search/review.js @@ -1,5 +1,6 @@ -import { C, _, escapeHtml, l, r } from "../../core/constants.js"; +import { l, r } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; +import { ReviewPanel } from "../../ui/detail/review-panel.js"; export class ReviewPlugin extends BasePlugin { getName() { @@ -51,130 +52,7 @@ export class ReviewPlugin extends BasePlugin { return element ? $(element) : $(); } async showReview(movieId, target, options = {}) { - const isActive = "function" === typeof options.isActive ? options.isActive : () => !0; - const settings = await storageManager.getSetting(); - const enabled = settings.enableLoadReview === _ ? _ : C, host = target?.length ? target : this.getHostedSlot("reviews"); - if (!isActive() || !host?.length) return $(); - const existing = host.children('[data-jhs-panel="reviews"]').filter(((_, element) => $(element).attr("data-jhs-movie-id") === String(movieId))).first(); - if (existing.length) return existing; - const panel = $('
    ').attr("data-jhs-movie-id", String(movieId)); - const header = $('

    评论

    '); - const toggle = $(''); - const state = { movieId, panel, floorIndex: 1, loaded: !1, loading: !1, page: 1 }; - header.append(toggle), options.ownedSection ? options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle) : panel.append(header), panel.append('
    ', ''), host.append(panel), this.bindRightClickFilter(); - state.isActive = isActive; - this.updateToggle(toggle, enabled === _); - toggle.on("click", (event => { - event.preventDefault(), event.stopPropagation(); - const expanded = "展开" === toggle.find(".toggle-text").text(); - this.updateToggle(toggle, expanded), panel.find(".jhs-review-container, .jhs-review-footer").toggle(expanded), expanded && !state.loaded && !state.loading && void this.fetchAndDisplayReviews(state), storageManager.saveSettingItem("enableLoadReview", expanded ? _ : C); - })); - enabled === _ ? await this.fetchAndDisplayReviews(state) : panel.find(".jhs-review-container, .jhs-review-footer").hide(); - return panel; - } - updateToggle(toggle, expanded) { - toggle.attr("aria-expanded", String(expanded)), toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"), - toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); - } - async fetchAndDisplayReviews(state) { - if (state.loading || !state.isActive?.()) return; - state.loading = !0; - const { movieId, panel } = state, container = panel.find(".jhs-review-container"), footer = panel.find(".jhs-review-footer"); - container.empty().append($('
    ').text("获取评论中...")), footer.empty(); - const pageSize = await storageManager.getSetting("reviewCount", 20); - let reviews, scope; - try { - scope = await this.getRuntimeService("scope")(); - reviews = await this.getRuntimeService("review").list({ movieId }, { page: 1, limit: pageSize, scope }); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); - error.toString().includes("簽名已過期") && show.error("生成签名失败, 请检查系统时间及时区是否正确!"), clog.error("获取评论失败:", error), - clog.error("获取评论失败:", error); - state.loading = !1; - return void this.renderRetry(container, "获取评论失败", (() => this.fetchAndDisplayReviews(state))); - } - if (!state.isActive?.() || scope?.signal?.aborted) return void (state.loading = !1); - state.loading = !1, state.loaded = !0; - container.empty(); - if (!reviews.length) return void container.append($('
    ').text("无评论")); - const keywords = await storageManager.getReviewFilterKeywordList(); - await this.displayReviews(state, reviews, container, keywords); - reviews.length === pageSize ? this.bindLoadMore(state, pageSize, keywords, container, footer) : footer.append($('
    ').text("已加载全部评论")); - } - renderRetry(container, message, retry) { - container.empty(); - const state = $('
    ').append(document.createTextNode(`${message} `)); - const button = $('').on("click", retry); - state.append(button), container.append(state); - } - bindLoadMore(state, pageSize, keywords, container, footer) { - const button = $(''), end = $('
    已加载全部评论
    ').hide(); - footer.empty().append(button, end); - button.on("click", (async () => { - const nextPage = state.page + 1; - let scope; - button.text("加载中...").prop("disabled", !0); - try { - scope = await this.getRuntimeService("scope")(); - const reviews = await this.getRuntimeService("review").list({ movieId: state.movieId }, { page: nextPage, limit: pageSize, scope }); - if (!state.isActive?.() || scope?.signal?.aborted) return; - state.page = nextPage; - await this.displayReviews(state, reviews, container, keywords), reviews.length < pageSize ? (button.remove(), end.show()) : button.text("加载更多评论").prop("disabled", !1); - } catch (error) { - if (!state.isActive?.() || scope?.signal?.aborted) return; - clog.error("加载更多评论失败:", error), button.text("加载失败,请重试").prop("disabled", !1); - } - })); - } - async displayReviews(state, reviews, container, keywords) { - if (!reviews.length) return; - const filter = keywords.length > 0 ? new RegExp(keywords.map((value => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))).join("|")) : null; - for (const review of reviews) { - const content = String(review.content || ""); - if (filter?.test(content)) continue; - const item = $('
    '), meta = $('
    '), body = $('
    '); - meta.append($("").addClass("jhs-review-author").text(review.author || "匿名用户")); - const stars = $(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); - for (let index = 0; index < score; index++) stars.append(''); - meta.append(stars, $("").text(utils.formatDate(review.createdAt)), $("").text(`点赞:${Number(review.likes) || 0}`), - $("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`)); - await this.appendReviewContent(body, content), item.append(meta, body), container.append(item); - } - } - appendReviewContent(container, content) { - const linkPattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; - let cursor = 0, match; - while ((match = linkPattern.exec(content))) { - match.index > cursor && container.append(document.createTextNode(content.slice(cursor, match.index))); - this.appendLinkControls(container, match[0]), cursor = match.index + match[0].length; - } - cursor < content.length && container.append(document.createTextNode(content.slice(cursor))); - } - appendLinkControls(container, value) { - const isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接"; - const isResource = isMagnet || isEd2k, wrapper = $(isResource ? '' : ''); - const main = $(''); - const open = isEd2k ? $('').text(label).on("click", (() => utils.copyToClipboard(label, value))) : $("").addClass("jhs-review-link").attr({ - href: value, - target: "_blank", - rel: "noopener noreferrer" - }).text(label); - const copy = $('').on("click", (() => utils.copyToClipboard(label, value))); - main.append(open, copy), wrapper.append(main); - if (isResource) { - const actions = $(''); - actions.append(``); - wrapper.append(actions); - } - container.append(wrapper); - } - bindRightClickFilter() { - $(document).off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", (async event => { - if (await storageManager.getSetting("enableTitleSelectFilter", _) !== _) return; - const text = window.getSelection().toString(); - text && (event.preventDefault(), await utils.q(event, `是否将 '${text}' 加入评论区关键词?`, (async () => { - await storageManager.saveReviewFilterKeyword(text), show.ok("操作成功, 刷新页面后生效"); - }))); - })); + const panel = new ReviewPanel({ review: this.getRuntimeService("review"), settings: this.getRuntimeService("settings"), storage: this.getRuntimeService("storage"), scope: () => this.getRuntimeService("scope")() }); + return panel.show(movieId, target?.length ? target : this.getHostedSlot("reviews"), options); } } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index bcf0f1ca..33b58964 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -52,7 +52,7 @@ const manifest = (id, featureId, plugin, sites, order, requires = []) => defineC export const legacyContributionManifests = Object.freeze([ manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation]), manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }), - manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.screenshot]), + manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }), @@ -65,8 +65,8 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }), - manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie]), - manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related]), + manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), + manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }), manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), @@ -113,6 +113,7 @@ export function registerSitePlugins(pluginManager, featureRuntime, site) { [SERVICE.screenshot, "screenshot"], [SERVICE.translation, "translation"], [SERVICE.webdav, "webdav"], + [SERVICE.storage, "storage"], [SERVICE.dialog, "dialog"], ]); for (const token of item.requires) { diff --git a/src/ui/detail/fc2-workspace-view.js b/src/ui/detail/fc2-workspace-view.js new file mode 100644 index 00000000..5ca6761b --- /dev/null +++ b/src/ui/detail/fc2-workspace-view.js @@ -0,0 +1,36 @@ +// @ts-check + +/** @param {unknown} value */ +function normalizeUrl(value) { + try { + const url = new URL(String(value || "")); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; + } catch { return null; } +} + +/** @param {any} target @param {string} message @param {null|(() => void)} [retry] */ +export function renderFc2State(target, message, retry = null) { + const jq = /** @type {any} */ (globalThis).$, host = jq(target).empty(), state = jq('
    ').text(message); + if (retry) state.addClass("is-error").append(" ", jq('').on("click", retry)); + host.append(state); +} + +/** @param {any} context @param {unknown[]} images */ +export function renderFc2Gallery(context, images) { + const jq = /** @type {any} */ (globalThis).$; + const urls = [...new Set((images || []).map(normalizeUrl).filter(Boolean))]; + const grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); + if (!urls.length) return renderFc2State(grid, "暂无剧照"); + preview.append(jq("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); + urls.forEach((url, index) => grid.append(jq('').attr("aria-label", `查看剧照 ${index + 1}`).append(jq("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" })))); +} + +/** @param {any} context @param {any} movieService */ +export function createFc2SourceLinks(context, movieService) { + const jq = /** @type {any} */ (globalThis).$; + const links = jq(''); + const providerLinks = movieService.sourceUrls({ carNum: context.carNum }, ["fc2ppvdb", "fc2content"]); + const values = [["123av" === context.source ? "123AV 原页面" : "JavDB 原页面", normalizeUrl(context.url)], ...providerLinks.map((/** @type {any} */ item) => [item.providerId === "fc2ppvdb" ? "FC2PPVDB" : "FC2 市场", item.url])]; + values.forEach(([label, href]) => href && links.append(jq("").addClass("jhs-btn jhs-btn--ghost jhs-btn--sm").attr({ href, target: "_blank", rel: "noopener noreferrer" }).text(label))); + return links; +} diff --git a/src/ui/detail/related-panel.js b/src/ui/detail/related-panel.js new file mode 100644 index 00000000..78067d41 --- /dev/null +++ b/src/ui/detail/related-panel.js @@ -0,0 +1,108 @@ +// @ts-check + +export class RelatedPanel { + /** @param {{related: any, settings: any, scope: () => Promise}} dependencies */ + constructor(dependencies) { this.related = dependencies.related; this.settings = dependencies.settings; this.scope = dependencies.scope; } + + /** @param {any} target @param {string} movieId @param {{ownedSection?: any, isActive?: () => boolean}} [options] */ + async show(target, movieId, options = {}) { + const jq = /** @type {any} */ (globalThis).$, isActive = options.isActive ?? (() => true); + if (!movieId) throw new TypeError("未传入movieId"); + if (!isActive() || !target?.length) return jq(); + const existing = target.children('[data-jhs-panel="related"]').filter((/** @type {number} */ _index, /** @type {Element} */ element) => jq(element).attr("data-jhs-movie-id") === String(movieId)).first(); + if (existing.length) return existing; + const panel = jq('').attr("data-jhs-movie-id", String(movieId)); + const header = jq('

    相关清单

    '); + const toggle = jq(''); + const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1, isActive }; + header.append(toggle); + if (options.ownedSection) options.ownedSection.find('[data-jhs-section-actions="related"]').first().append(toggle); + else panel.append(header); + panel.append('', ''); + target.append(panel); + const enabled = (this.settings.snapshot().enableLoadRelated ?? "no") === "yes"; + this.updateToggle(toggle, enabled); + toggle.on("click", (/** @type {any} */ event) => { + event.preventDefault(); event.stopPropagation(); + const expanded = toggle.find(".toggle-text").text() === "展开"; + this.updateToggle(toggle, expanded); + panel.find(".jhs-related-container, .jhs-related-footer").toggle(expanded); + if (expanded && !state.loaded && !state.loading) void this.fetch(state); + void this.settings.set("enableLoadRelated", expanded ? "yes" : "no"); + }); + if (enabled) await this.fetch(state); else panel.find(".jhs-related-container, .jhs-related-footer").hide(); + return panel; + } + + /** @param {any} toggle @param {boolean} expanded */ + updateToggle(toggle, expanded) { + toggle.attr("aria-expanded", String(expanded)); + toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"); + toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); + } + + /** @param {any} state */ + async fetch(state) { + if (state.loading || !state.isActive()) return; + state.loading = true; + const container = state.panel.find(".jhs-related-container"), footer = state.panel.find(".jhs-related-footer"); + container.empty().append(/** @type {any} */ (globalThis).$("
    ").addClass("jhs-panel-state").text("获取清单中...")); + footer.empty(); + let scope; + try { + scope = await this.scope(); + const related = await this.related.list({ movieId: state.movieId }, { page: 1, limit: 20, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.loading = false; state.loaded = true; container.empty(); + if (!related.length) return void container.append(/** @type {any} */ (globalThis).$("
    ").addClass("jhs-panel-state").text("无清单")); + this.display(state, related, container); + if (related.length === 20) this.bindLoadMore(state, container, footer); + else footer.append(/** @type {any} */ (globalThis).$("
    ").addClass("jhs-panel-end").text("已加载全部清单")); + } catch (error) { + state.loading = false; + if (!state.isActive() || scope?.signal?.aborted) return; + /** @type {any} */ (globalThis).clog?.error("获取清单失败:", error); + this.renderRetry(container, () => void this.fetch(state)); + } + } + + /** @param {any} container @param {() => void} retry */ + renderRetry(container, retry) { + const jq = /** @type {any} */ (globalThis).$; + container.empty().append(jq('
    ').append(document.createTextNode("获取清单失败 "), jq('').on("click", retry))); + } + + /** @param {any} state @param {any} container @param {any} footer */ + bindLoadMore(state, container, footer) { + const jq = /** @type {any} */ (globalThis).$, button = jq(''), end = jq('').hide(); + footer.empty().append(button, end); + button.on("click", async () => { + const nextPage = state.page + 1; let scope; + button.text("加载中...").prop("disabled", true); + try { + scope = await this.scope(); + const related = await this.related.list({ movieId: state.movieId }, { page: nextPage, limit: 20, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.page = nextPage; this.display(state, related, container); + if (related.length < 20) button.remove(), end.show(); else button.text("加载更多清单").prop("disabled", false); + } catch (error) { + if (!state.isActive() || scope?.signal?.aborted) return; + /** @type {any} */ (globalThis).clog?.error("加载更多清单失败:", error); + button.text("加载失败,请重试").prop("disabled", false); + } + }); + } + + /** @param {any} state @param {any[]} related @param {any} container */ + display(state, related, container) { + const jq = /** @type {any} */ (globalThis).$; + related.forEach((item) => { + const title = jq("").addClass("jhs-related-title").attr({ href: `/lists/${encodeURIComponent(item.id)}`, target: "_blank", rel: "noopener noreferrer" }).text(item.name || "未命名清单"); + const heading = jq('').append(jq("").addClass("jhs-related-index").text(`#${state.floorIndex++}`), title); + const meta = jq(''); + const formatted = item.createdAt ? /** @type {any} */ (globalThis).utils.formatDate(item.createdAt) : "未知"; + meta.append(jq("").text(`视频:${Number(item.movieCount) || 0}`), jq("").text(`收藏:${Number(item.collectionCount) || 0}`), jq("").text(`查看:${Number(item.viewCount) || 0}`), jq('').text(`创建时间:${formatted}`)); + container.append(jq('').append(heading, meta)); + }); + } +} diff --git a/src/ui/detail/review-panel.js b/src/ui/detail/review-panel.js new file mode 100644 index 00000000..c5dcf33d --- /dev/null +++ b/src/ui/detail/review-panel.js @@ -0,0 +1,134 @@ +// @ts-check + +const FILTER_KEY = "review_filter_keyword"; + +export class ReviewPanel { + /** @param {{review: any, settings: any, storage: any, scope: () => Promise}} dependencies */ + constructor(dependencies) { this.review = dependencies.review; this.settings = dependencies.settings; this.storage = dependencies.storage; this.scope = dependencies.scope; } + + /** @param {string} movieId @param {any} target @param {{ownedSection?: any, isActive?: () => boolean}} [options] */ + async show(movieId, target, options = {}) { + const jq = /** @type {any} */ (globalThis).$, isActive = options.isActive ?? (() => true); + if (!isActive() || !target?.length) return jq(); + const existing = target.children('[data-jhs-panel="reviews"]').filter((/** @type {number} */ _index, /** @type {Element} */ element) => jq(element).attr("data-jhs-movie-id") === String(movieId)).first(); + if (existing.length) return existing; + const panel = jq('
    ').attr("data-jhs-movie-id", String(movieId)); + const header = jq('

    评论

    '); + const toggle = jq(''); + const state = { movieId, panel, floorIndex: 1, loaded: false, loading: false, page: 1, isActive }; + header.append(toggle); + if (options.ownedSection) options.ownedSection.find('[data-jhs-section-actions="reviews"]').first().append(toggle); else panel.append(header); + panel.append('
    ', ''); + target.append(panel); this.bindFilter(panel); + const enabled = (this.settings.snapshot().enableLoadReview ?? "no") === "yes"; + this.updateToggle(toggle, enabled); + toggle.on("click", (/** @type {any} */ event) => { + event.preventDefault(); event.stopPropagation(); + const expanded = toggle.find(".toggle-text").text() === "展开"; + this.updateToggle(toggle, expanded); panel.find(".jhs-review-container, .jhs-review-footer").toggle(expanded); + if (expanded && !state.loaded && !state.loading) void this.fetch(state); + void this.settings.set("enableLoadReview", expanded ? "yes" : "no"); + }); + if (enabled) await this.fetch(state); else panel.find(".jhs-review-container, .jhs-review-footer").hide(); + return panel; + } + + /** @param {any} toggle @param {boolean} expanded */ + updateToggle(toggle, expanded) { toggle.attr("aria-expanded", String(expanded)); toggle.find(".toggle-text").text(expanded ? "折叠" : "展开"); toggle.find(".toggle-icon").text(expanded ? "▲" : "▼"); } + + /** @param {any} state */ + async fetch(state) { + if (state.loading || !state.isActive()) return; + const jq = /** @type {any} */ (globalThis).$, container = state.panel.find(".jhs-review-container"), footer = state.panel.find(".jhs-review-footer"); + state.loading = true; container.empty().append(jq('
    ').text("获取评论中...")); footer.empty(); + const pageSize = Number(this.settings.snapshot().reviewCount) || 20; let scope; + try { + scope = await this.scope(); + const reviews = await this.review.list({ movieId: state.movieId }, { page: 1, limit: pageSize, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.loading = false; state.loaded = true; container.empty(); + if (!reviews.length) return void container.append(jq('
    ').text("无评论")); + const keywords = await this.getKeywords(); await this.display(state, reviews, container, keywords); + if (reviews.length === pageSize) this.bindLoadMore(state, pageSize, keywords, container, footer); else footer.append(jq('
    ').text("已加载全部评论")); + } catch (error) { + state.loading = false; + if (!state.isActive() || scope?.signal?.aborted) return; + /** @type {any} */ (globalThis).clog?.error("获取评论失败:", error); + this.renderRetry(container, "获取评论失败", () => void this.fetch(state)); + } + } + + async getKeywords() { const value = await this.storage.get(FILTER_KEY); return Array.isArray(value) ? value.map(String) : []; } + /** @param {string} text */ + async saveKeyword(text) { const values = await this.getKeywords(); if (!values.includes(text)) await this.storage.set(FILTER_KEY, [...values, text]); } + + /** @param {any} container @param {string} message @param {() => void} retry */ + renderRetry(container, message, retry) { + const jq = /** @type {any} */ (globalThis).$; + container.empty().append(jq('
    ').append(document.createTextNode(`${message} `), jq('').on("click", retry))); + } + + /** @param {any} state @param {number} pageSize @param {string[]} keywords @param {any} container @param {any} footer */ + bindLoadMore(state, pageSize, keywords, container, footer) { + const jq = /** @type {any} */ (globalThis).$, button = jq(''), end = jq('
    已加载全部评论
    ').hide(); + footer.empty().append(button, end); + button.on("click", async () => { + const nextPage = state.page + 1; let scope; button.text("加载中...").prop("disabled", true); + try { + scope = await this.scope(); + const reviews = await this.review.list({ movieId: state.movieId }, { page: nextPage, limit: pageSize, scope }); + if (!state.isActive() || scope?.signal?.aborted) return; + state.page = nextPage; await this.display(state, reviews, container, keywords); + if (reviews.length < pageSize) button.remove(), end.show(); else button.text("加载更多评论").prop("disabled", false); + } catch (error) { + if (!state.isActive() || scope?.signal?.aborted) return; + /** @type {any} */ (globalThis).clog?.error("加载更多评论失败:", error); button.text("加载失败,请重试").prop("disabled", false); + } + }); + } + + /** @param {any} state @param {any[]} reviews @param {any} container @param {string[]} keywords */ + async display(state, reviews, container, keywords) { + const jq = /** @type {any} */ (globalThis).$; + const filter = keywords.length ? new RegExp(keywords.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")) : null; + for (const review of reviews) { + const content = String(review.content || ""); if (filter?.test(content)) continue; + const item = jq('
    '), meta = jq('
    '), body = jq('
    '); + meta.append(jq("").addClass("jhs-review-author").text(review.author || "匿名用户")); + const stars = jq(''), score = Math.max(0, Math.min(5, Number(review.score) || 0)); + for (let index = 0; index < score; index++) stars.append(''); + const formatted = /** @type {any} */ (globalThis).utils.formatDate(review.createdAt); + meta.append(stars, jq("").text(formatted), jq("").text(`点赞:${Number(review.likes) || 0}`), jq("").addClass("jhs-review-floor").text(`#${state.floorIndex++}楼`)); + this.appendContent(body, content); item.append(meta, body); container.append(item); + } + } + + /** @param {any} container @param {string} content */ + appendContent(container, content) { + const pattern = /ed2k:\/\/\|file\|[^|]+\|\d+\|[a-fA-F0-9]{32}\|\/|magnet:\?[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+|https?:\/\/[^\s"'<>`,;\u4e00-\u9fa5,。?!()【】]+/g; + let cursor = 0, match; + while ((match = pattern.exec(content))) { if (match.index > cursor) container.append(document.createTextNode(content.slice(cursor, match.index))); this.appendLink(container, match[0]); cursor = match.index + match[0].length; } + if (cursor < content.length) container.append(document.createTextNode(content.slice(cursor))); + } + + /** @param {any} container @param {string} value */ + appendLink(container, value) { + const jq = /** @type {any} */ (globalThis).$, isEd2k = value.startsWith("ed2k://"), isMagnet = value.startsWith("magnet:"), label = isEd2k ? "ED2K 链接" : isMagnet ? "Magnet 链接" : "打开链接", isResource = isEd2k || isMagnet; + const wrapper = jq(isResource ? '' : ''), main = jq(''); + const open = isEd2k ? jq('').text(label).on("click", () => /** @type {any} */ (globalThis).utils.copyToClipboard(label, value)) : jq("").addClass("jhs-review-link").attr({ href: value, target: "_blank", rel: "noopener noreferrer" }).text(label); + const copy = jq('').on("click", () => /** @type {any} */ (globalThis).utils.copyToClipboard(label, value)); + main.append(open, copy); wrapper.append(main); + if (isResource) wrapper.append(jq('').append(jq('').attr("data-resource", value))); + container.append(wrapper); + } + + /** @param {any} panel */ + bindFilter(panel) { + panel.off("contextmenu.jhsReviewFilter", ".review-content").on("contextmenu.jhsReviewFilter", ".review-content", async (/** @type {any} */ event) => { + if ((this.settings.snapshot().enableTitleSelectFilter ?? "yes") !== "yes") return; + const text = String(window.getSelection()?.toString() || ""); if (!text) return; + event.preventDefault(); + await /** @type {any} */ (globalThis).utils.q(event, `是否将 '${text}' 加入评论区关键词?`, async () => { await this.saveKeyword(text); /** @type {any} */ (globalThis).show.ok("操作成功, 刷新页面后生效"); }); + }); + } +} diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js index f7a3f540..972b98f0 100644 --- a/tests/architecture-runtime.test.js +++ b/tests/architecture-runtime.test.js @@ -51,7 +51,7 @@ describe("v6.5 architecture runtime contracts", () => { const { registerSitePlugins } = await import("../src/plugins/registry.js"); const createRuntime = (site, disabled = []) => { const diagnostics = new DiagnosticsService(); - const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.settings, {}).register(SERVICE.cache, {}); + const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.settings, {}).register(SERVICE.storage, {}).register(SERVICE.cache, {}); const runtime = new FeatureRuntime({ container, commands: new CommandRegistry(), diagnostics, disabled, site, route: "list" }); featureManifests.forEach((manifest) => runtime.register(manifest)); return runtime; diff --git a/tests/detail-panel-lifecycle.test.js b/tests/detail-panel-lifecycle.test.js index 514812f2..f305f3d5 100644 --- a/tests/detail-panel-lifecycle.test.js +++ b/tests/detail-panel-lifecycle.test.js @@ -10,16 +10,19 @@ function loadPanels({ settings = { enableLoadReview: "yes", enableLoadRelated: " const dom = new JSDOM('
    ', { url: "https://javdb.com/v/a" }), $ = jqueryFactory(dom.window); const reviewFetch = vi.fn(async movieRef => [{ author: movieRef.movieId, score: 1, createdAt: "2026-08-22", likes: 0, content: "ok" }]); const relatedFetch = vi.fn(async movieRef => [{ id: movieRef.movieId, name: movieRef.movieId, movieCount: 1, collectionCount: 0, viewCount: 0, createdAt: "today" }]); - const runtimeServices = { review: { list: reviewFetch }, related: { list: relatedFetch }, scope: async () => null }; + const reviewKeywords = []; + const runtimeServices = { review: { list: reviewFetch }, related: { list: relatedFetch }, settings: { snapshot: () => settings, set: vi.fn(async (name, value) => settings[name] = value) }, storage: { get: vi.fn(async () => reviewKeywords), set: vi.fn(async (_key, value) => { reviewKeywords.splice(0, reviewKeywords.length, ...value); }) }, scope: async () => null }; const context = vm.createContext({ document: dom.window.document, window: dom.window, $, BasePlugin: class { getBean() { return null; } getRuntimeService(name) { return runtimeServices[name]; } }, _: "yes", C: "no", r: false, l: false, storageManager: { getSetting: vi.fn(async (key, fallback) => null == key ? settings : "reviewCount" === key ? 20 : settings[key] ?? fallback), getReviewFilterKeywordList: vi.fn(async () => []), saveReviewFilterKeyword: vi.fn(), saveSettingItem: vi.fn() }, utils: { formatDate: value => value, q: (event, message, callback) => callback() }, show: { error: vi.fn(), ok: vi.fn() }, clog: { error: vi.fn(), warn: vi.fn() }, escapeHtml: String, i: (target, key, value) => (target[key] = value) }); + vm.runInContext(readTestFile(join(process.cwd(), "src/ui/detail/related-panel.js"), "utf8"), context); + vm.runInContext(readTestFile(join(process.cwd(), "src/ui/detail/review-panel.js"), "utf8"), context); vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8")};globalThis.Review=ReviewPlugin`, context); vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8")};globalThis.Related=RelatedPlugin`, context); - return { $, Review: context.Review, Related: context.Related, reviewFetch, relatedFetch, storageManager: context.storageManager, window: dom.window }; + return { $, Review: context.Review, Related: context.Related, reviewFetch, relatedFetch, storage: runtimeServices.storage, window: dom.window }; } describe("detail panel instance lifecycle", () => { @@ -54,11 +57,11 @@ describe("detail panel instance lifecycle", () => { }); it("binds the review context-menu handler exactly once across multiple panels", async () => { - const { $, Review, storageManager, window } = loadPanels(), plugin = new Review; + const { $, Review, storage, window } = loadPanels(), plugin = new Review; await plugin.showReview("A", $("#review-a")), await plugin.showReview("B", $("#review-b")); window.getSelection = () => ({ toString: () => "keyword" }); $("#review-a .review-content").trigger("contextmenu"); - await vi.waitFor((() => expect(storageManager.saveReviewFilterKeyword).toHaveBeenCalledOnce())); + await vi.waitFor((() => expect(storage.set).toHaveBeenCalledOnce())); }); it("adopts owned workspace headers and does not prefetch the second review page", async () => { diff --git a/tests/fc2-owned-workspace.test.js b/tests/fc2-owned-workspace.test.js index eed0be49..50c4b7cc 100644 --- a/tests/fc2-owned-workspace.test.js +++ b/tests/fc2-owned-workspace.test.js @@ -12,6 +12,7 @@ import { CacheService } from "../src/services/cache-service.js"; const repoRoot = join(import.meta.dirname, ".."); const fc2Source = readTestFile(join(repoRoot, "src/plugins/external-search/fc2.js"), "utf8"); +const fc2ViewSource = readTestFile(join(repoRoot, "src/ui/detail/fc2-workspace-view.js"), "utf8"); const fc2By123AvSource = readTestFile(join(repoRoot, "src/plugins/external-search/fc2-by-123av.js"), "utf8"); const screenshotSource = readTestFile(join(repoRoot, "src/plugins/image-viewer/screenshot.js"), "utf8"); const listPageSource = readTestFile(join(repoRoot, "src/plugins/status/list-page.js"), "utf8"); @@ -82,7 +83,7 @@ describe("FC2 owned detail workspace", () => { it("lets sections keep their content height and opens gallery thumbnails in the viewer", () => { expect(fc2Source).toMatch(/\.jhs-fc2-workspace \{[^}]*grid-auto-rows:max-content;[^}]*align-content:start;/); expect(fc2Source).toMatch(/\.jhs-fc2-gallery-grid \{[^}]*minmax\(112px,144px\)/); - expect(fc2Source).toContain('class=\\"jhs-btn jhs-fc2-gallery-item\\"'); + expect(fc2ViewSource).toContain('class="jhs-btn jhs-fc2-gallery-item"'); expect(fc2Source).toContain('showImageViewer(image, "", { galleryRoot: gallery[0] })'); expect(fc2Source).not.toContain('"data-fancybox"'); expect(loggerSource).toContain("initialViewIndex"); @@ -127,8 +128,8 @@ describe("FC2 owned detail workspace", () => { }); it("restores source links, magnet metadata and scoped quality filtering", () => { - expect(fc2Source).toContain("FC2PPVDB"); - expect(fc2Source).toContain("FC2 市场"); + expect(fc2ViewSource).toContain("FC2PPVDB"); + expect(fc2ViewSource).toContain("FC2 市场"); expect(fc2Source).toContain("item.hasHdTag && tags.append"); expect(fc2Source).toContain("item.hasSubtitleTag && tags.append"); expect(fc2Source).toContain("item.createdAt"); @@ -144,9 +145,12 @@ describe("FC2 owned detail workspace", () => { }); it("shares the 123AV movie resolver and keeps summary retry local", () => { - expect(fc2By123AvSource).toContain("fc2Plugin.mountPanels(context, movieIdPromise)"); - expect(fc2By123AvSource).toContain("fc2Plugin.configureJavDbWantButton(context, movieIdPromise)"); - expect(fc2By123AvSource).toContain("movieIdPromise.then"); + expect(fc2Source).toContain("this.mountPanels(context, movieIdPromise)"); + expect(fc2Source).toContain("this.configureJavDbWantButton(context, movieIdPromise)"); + expect(fc2Source).toContain("movieIdPromise.then"); + expect(fc2By123AvSource).not.toContain("mountPanels(context"); + expect(fc2By123AvSource).not.toContain("configureJavDbWantButton(context"); + expect(fc2By123AvSource).not.toContain('getBean("Fc2Plugin")'); expect(fc2By123AvSource).toContain("loadSummary(context, url)"); expect(fc2By123AvSource).not.toContain("() => void this.loadDetail(context, url)"); }); @@ -171,7 +175,7 @@ describe("FC2 owned detail workspace", () => { expect(fc2Source).not.toMatch(/catch\(\(error => [^{\n]*\), clog\.error/); expect(fc2By123AvSource).not.toMatch(/catch\(\(error => [^{\n]*\), clog\.error/); expect(fc2Source).toContain('catch((error => {\n context.isAlive() && sitesGroup.remove()'); - expect(fc2By123AvSource).toContain('catch((error => {\n context.isAlive() && fc2Plugin.setState'); + expect(fc2Source).toContain('catch((error => {\n context.isAlive() && renderFc2State'); }); it("initializes screenshot providers once per owned render and removes empty spacing", () => { diff --git a/tests/ui-architecture.test.js b/tests/ui-architecture.test.js index b4652c8d..f3ef6c2f 100644 --- a/tests/ui-architecture.test.js +++ b/tests/ui-architecture.test.js @@ -46,9 +46,11 @@ describe("list toolbar and UI cleanup contracts", () => { const settingStyles = readTestFile(join(process.cwd(), "src/plugins/backup/setting-styles.js"), "utf8"); const pluginPanels = readTestFile(join(process.cwd(), "src/plugins/backup/setting-panels.js"), "utf8"); const reviews = readTestFile(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8"); + const reviewUi = readTestFile(join(process.cwd(), "src/ui/detail/review-panel.js"), "utf8"); const oneTwoThreeOffline = readTestFile(join(process.cwd(), "src/plugins/one-two-three/offline.js"), "utf8"); const newVideo = readTestFile(join(process.cwd(), "src/plugins/new-video/new-video.js"), "utf8"); const related = readTestFile(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8"); + const relatedUi = readTestFile(join(process.cwd(), "src/ui/detail/related-panel.js"), "utf8"); const settingForms = readTestFile(join(process.cwd(), "src/plugins/backup/setting-forms.js"), "utf8"); const listButtons = readTestFile(join(process.cwd(), "src/plugins/status/list-page-button.js"), "utf8"); const coverButtons = readTestFile(join(process.cwd(), "src/plugins/image-viewer/cover-button.js"), "utf8"); @@ -175,21 +177,22 @@ describe("list toolbar and UI cleanup contracts", () => { it("uses semantic review and related layouts with safe external text", () => { expect(reviews).toContain("jhs-review-item"); - expect(reviews).toContain("document.createTextNode"); - expect(reviews).toContain("appendLinkControls"); + expect(reviewUi).toContain("document.createTextNode"); + expect(reviewUi).toContain("appendLink"); expect(reviews).not.toContain("item columns is-desktop"); expect(reviews).not.toContain("jhs-layout-"); expect(related).toContain("jhs-related-item"); - expect(related).toContain("encodeURIComponent(item.id)"); + expect(relatedUi).toContain("encodeURIComponent(item.id)"); expect(related).not.toContain("item columns is-desktop"); expect(related).not.toContain("jhs-layout-"); expect(reviews).toMatch(/jhs-review-content[^}]*font-size:16px[^}]*line-height:1\.7/); expect(reviews).not.toMatch(/jhs-review-content[^}]*max-width/); expect(related).toContain("jhs-related-heading"); + expect(relatedUi).not.toMatch(/\.html\(/); }); it("keeps 123 as an auth/API provider without legacy submission UI", () => { - expect(reviews).toContain('jhs-review-offline-btn jhs-offline-btn'); + expect(reviewUi).toContain('jhs-review-offline-btn jhs-offline-btn'); expect(oneTwoThreeOffline).toContain("startTokenSync()"); expect(oneTwoThreeOffline).toContain("async resolveMagnet"); expect(oneTwoThreeOffline).toContain("async submitTask"); From 88950e661e92ee71d4745cb617731a7ff0f7c0e1 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:03:44 +0800 Subject: [PATCH 004/130] =?UTF-8?q?Refactor:=20=E7=BB=9F=E4=B8=80=E7=A3=81?= =?UTF-8?q?=E5=8A=9B=E8=B4=A8=E9=87=8F=E4=B8=8E=E7=9B=91=E5=90=AC=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将磁力评分与质量信号抽至 Core,并由 MagnetService 对业务调用提供统一入口 - 移除 FC2 Owned Detail 对 HighlightMagnetPlugin 的评分依赖 - 将磁力高亮与统一离线的全局事件监听登记到 Feature LifecycleScope - 将磁力高亮设置读取迁至声明的 SettingsService 依赖 - 更新相关回归测试与正式构建产物 修改原因: - 纯业务判定不应由 UI Plugin 提供,且全局监听必须可追踪、可释放 验证: - npm run check 通过,89 个测试文件共 518 项测试通过 - npm run check:browser 通过,本机 Edge 13 项通过、5 项按环境跳过 - git diff --check 与 codegraph sync 通过 - 根目录与 dist/JHS.user.js SHA256 一致 --- JHS.user.js | 89 +++++++++++++---------- src/core/magnet-quality.js | 37 ++++++++++ src/plugins/dependency-map.js | 3 +- src/plugins/external-search/fc2.js | 4 +- src/plugins/external-search/magnet-hub.js | 27 +------ src/plugins/offline/unified-offline.js | 4 +- src/plugins/registry.js | 2 +- src/plugins/status/highlight-magnet.js | 19 ++--- src/services/magnet-service.js | 4 + tests/fc2-owned-workspace.test.js | 8 +- 10 files changed, 109 insertions(+), 88 deletions(-) create mode 100644 src/core/magnet-quality.js diff --git a/JHS.user.js b/JHS.user.js index b97afe14..a860e806 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -3918,8 +3918,7 @@ "FilterTitleKeywordPlugin", "OtherSitePlugin", "Fc2By123AvPlugin", - "TOP250Plugin", - "HighlightMagnetPlugin" + "TOP250Plugin" ], HistoryPlugin: ["UnifiedOfflinePlugin", "ListPagePlugin", "Fc2Plugin"], HitShowPlugin: ["ListPageButtonPlugin", "ListPagePlugin", "CoverButtonPlugin"], @@ -9296,11 +9295,11 @@ ${value}\r if (!context.isAlive()) return; host.empty(); if (!magnets.length) return renderFc2State(host, "暂无站内磁力"); - const highlighter = this.getDependency("HighlightMagnetPlugin"), assessments = []; + const magnetService = this.getRuntimeService("magnet"), assessments = []; magnets.forEach(((item) => { const hash = normalizeBtihHash(item.hash); if (!hash) return; - const magnet = `magnet:?xt=urn:btih:${hash}`, assessment = highlighter.assessMagnet({ title: item.title, hasHdTag: item.hasHdTag, hasSubtitleTag: item.hasSubtitleTag, date: item.createdAt, seeders: item.seeders }), row = $('
    ').attr("data-jhs-high-quality", String(assessment.highQuality)), info = $('
    '), actions = $('
    '), tags = $('
    '); + const magnet = `magnet:?xt=urn:btih:${hash}`, assessment = magnetService.assess({ title: item.title, hasHdTag: item.hasHdTag, hasSubtitleTag: item.hasSubtitleTag, date: item.createdAt, seeders: item.seeders }), row = $('
    ').attr("data-jhs-high-quality", String(assessment.highQuality)), info = $('
    '), actions = $('
    '), tags = $('
    '); assessments.push(assessment), tags.append($("").addClass("jhs-badge").attr("title", `磁力质量评分 ${assessment.score.total}`).text(`${assessment.grade} ${assessment.score.total}`)), item.hasHdTag && tags.append('高清'), item.hasSubtitleTag && tags.append('字幕'); info.append($("").attr("href", magnet).text(item.title || magnet), $("
    ").addClass("jhs-fc2-meta").text(`${(Number(item.sizeMb || 0) / 1024).toFixed(2)} GB · ${Number(item.fileCount) || 0} 个文件${item.createdAt ? ` · ${item.createdAt}` : ""}`), tags), actions.append($('').attr("data-clipboard-text", magnet), $('').attr({ "data-resource": magnet, "data-jhs-offline-owner": "fc2" })), host.append(row.append(info, actions)); })); @@ -9570,35 +9569,46 @@ ${error.stack}` : ""); __name(_JavTrailersPlugin, "JavTrailersPlugin"); var JavTrailersPlugin = _JavTrailersPlugin; - // src/plugins/external-search/magnet-hub.js - function calcMagnetScore(e2) { - let t2 = 0; - const n2 = e2.seeders || 0; - const seedersScore = n2 >= 50 ? 35 : n2 >= 10 ? 25 : n2 >= 1 ? 15 : 3; - t2 += seedersScore; - const a2 = (e2.title || "").toLowerCase(), resolution = String(e2.resolution || "").toLowerCase(); - const resolutionScore = /4k|2160p/.test(resolution) || /4k|2160p/.test(a2) ? 25 : /1080p/.test(resolution) || /1080p/.test(a2) ? 20 : /720p/.test(resolution) || /720p/.test(a2) ? 15 : 5; - t2 += resolutionScore; - const subtitleScore = e2.hasSubtitle || /-c\b|-uc\b|chinese|中字|字幕/.test(a2) ? 20 : 0; - t2 += subtitleScore; - const i2 = e2.date ? _daysSince(e2.date) : 999; - const freshnessScore = i2 <= 7 ? 15 : i2 <= 30 ? 12 : i2 <= 90 ? 8 : 3; - t2 += freshnessScore; - const completenessScore = /sample|预告|trailer/.test(a2) ? -15 : 0; - t2 += completenessScore; - return { total: Math.max(0, Math.min(100, t2)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; + // src/core/magnet-quality.js + function calcMagnetScore(magnet) { + let total = 0; + const seeders = Number(magnet.seeders) || 0; + const seedersScore = seeders >= 50 ? 35 : seeders >= 10 ? 25 : seeders >= 1 ? 15 : 3; + total += seedersScore; + const title = String(magnet.title || "").toLowerCase(), resolution = String(magnet.resolution || "").toLowerCase(); + const resolutionScore = /4k|2160p/.test(resolution) || /4k|2160p/.test(title) ? 25 : /1080p/.test(resolution) || /1080p/.test(title) ? 20 : /720p/.test(resolution) || /720p/.test(title) ? 15 : 5; + total += resolutionScore; + const subtitleScore = magnet.hasSubtitle || /-c\b|-uc\b|chinese|中字|字幕/.test(title) ? 20 : 0; + total += subtitleScore; + const days = daysSince(magnet.date), freshnessScore = days <= 7 ? 15 : days <= 30 ? 12 : days <= 90 ? 8 : 3; + total += freshnessScore; + const completenessScore = /sample|预告|trailer/.test(title) ? -15 : 0; + total += completenessScore; + return { total: Math.max(0, Math.min(100, total)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; } __name(calcMagnetScore, "calcMagnetScore"); - function _daysSince(e2) { + function daysSince(value) { try { - const t2 = new Date(e2); - if (isNaN(t2.getTime())) return 999; - return Math.max(0, Math.floor((Date.now() - t2.getTime()) / 864e5)); - } catch (t2) { + const date = new Date(String(value || "")); + return Number.isNaN(date.getTime()) ? 999 : Math.max(0, Math.floor((Date.now() - date.getTime()) / 864e5)); + } catch { return 999; } } - __name(_daysSince, "_daysSince"); + __name(daysSince, "daysSince"); + function getMagnetQualitySignals(title, hasSubtitleTag = false) { + const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); + return { resolution, subtitle, recognized: Boolean(resolution) || subtitle, highQuality: resolution === "4k" || resolution === "2160p" || subtitle }; + } + __name(getMagnetQualitySignals, "getMagnetQualitySignals"); + function assessMagnetQuality({ title = "", hasHdTag = false, hasSubtitleTag = false, date = null, seeders = 0 } = {}) { + const signals = getMagnetQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; + const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); + return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; + } + __name(assessMagnetQuality, "assessMagnetQuality"); + + // src/plugins/external-search/magnet-hub.js var _MagnetHubPlugin = class _MagnetHubPlugin extends BasePlugin { constructor() { super(...arguments), i(this, "sourceRegistry", new MagnetSourceRegistry()), i(this, "searchEngines", []); @@ -12838,7 +12848,9 @@ ${error.stack}` : ""); } async handle() { if (!(r || l)) return; - this.registerProviders(), this.bindSubmit(), window.isDetailPage && (this.injectNativeButtons(), jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); + const scope = await this.getRuntimeService("scope")(); + this.registerProviders(), this.bindSubmit(), scope.addCleanup((() => $(document).off(".jhsUnifiedOffline"))); + if (window.isDetailPage) this.injectNativeButtons(), scope.addCleanup(jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); } registerProviders() { const one23 = this.getDependency("OneTwoThreeOfflinePlugin"); @@ -13866,9 +13878,11 @@ ${error.stack}` : ""); // src/plugins/status/highlight-magnet.js var _HighlightMagnetPlugin = class _HighlightMagnetPlugin extends BasePlugin { async handle() { - window.isDetailPage && jhsEventBus.on("magnet-items-updated", (() => { - void storageManager.getSetting("enableMagnetsFilter", _).then(((enabled) => enabled === _ ? this.doFilterMagnet() : this.showAll())); - })); + if (!window.isDetailPage) return; + const settings = this.getRuntimeService("settings"), scope = await this.getRuntimeService("scope")(); + scope.addCleanup(jhsEventBus.on("magnet-items-updated", (() => { + (settings.snapshot().enableMagnetsFilter ?? _) === _ ? this.doFilterMagnet() : this.showAll(); + }))); } async initCss() { return ``; @@ -13895,13 +13909,7 @@ ${error.stack}` : ""); } } getQualitySignals(title, hasSubtitleTag = false) { - const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); - return { resolution, subtitle, recognized: !!resolution || subtitle, highQuality: "4k" === resolution || "2160p" === resolution || subtitle }; - } - assessMagnet({ title = "", hasHdTag = false, hasSubtitleTag = false, date = null, seeders = 0 } = {}) { - const signals = this.getQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; - const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); - return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; + return getMagnetQualitySignals(title, hasSubtitleTag); } updateFilterHint(hasMatch) { $("#enable-magnets-filter").removeClass("do-hide").attr("data-tip", hasMatch ? "仅显示识别到的高质量或字幕磁力" : "未识别到可过滤项,当前未隐藏磁力"); @@ -15403,7 +15411,7 @@ ${error.stack}` : ""); manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), - manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }), + manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [SERVICE.settings]), manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), @@ -16010,6 +16018,9 @@ ${error.stack}` : ""); const adapter = this.integrations?.getAdapter(manifest2.id); return typeof adapter?.listMagnets === "function" ? adapter.listMagnets(movieRef, context) : []; } + assess(magnet) { + return assessMagnetQuality(magnet); + } }; __name(_MagnetService, "MagnetService"); var MagnetService = _MagnetService; diff --git a/src/core/magnet-quality.js b/src/core/magnet-quality.js new file mode 100644 index 00000000..69b0abba --- /dev/null +++ b/src/core/magnet-quality.js @@ -0,0 +1,37 @@ +// @ts-check + +/** @param {Record} magnet */ +export function calcMagnetScore(magnet) { + let total = 0; + const seeders = Number(magnet.seeders) || 0; + const seedersScore = seeders >= 50 ? 35 : seeders >= 10 ? 25 : seeders >= 1 ? 15 : 3; + total += seedersScore; + const title = String(magnet.title || "").toLowerCase(), resolution = String(magnet.resolution || "").toLowerCase(); + const resolutionScore = /4k|2160p/.test(resolution) || /4k|2160p/.test(title) ? 25 : /1080p/.test(resolution) || /1080p/.test(title) ? 20 : /720p/.test(resolution) || /720p/.test(title) ? 15 : 5; + total += resolutionScore; + const subtitleScore = magnet.hasSubtitle || /-c\b|-uc\b|chinese|中字|字幕/.test(title) ? 20 : 0; + total += subtitleScore; + const days = daysSince(magnet.date), freshnessScore = days <= 7 ? 15 : days <= 30 ? 12 : days <= 90 ? 8 : 3; + total += freshnessScore; + const completenessScore = /sample|预告|trailer/.test(title) ? -15 : 0; + total += completenessScore; + return { total: Math.max(0, Math.min(100, total)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; +} + +/** @param {unknown} value */ +function daysSince(value) { + try { const date = new Date(String(value || "")); return Number.isNaN(date.getTime()) ? 999 : Math.max(0, Math.floor((Date.now() - date.getTime()) / 86_400_000)); } catch { return 999; } +} + +/** @param {unknown} title @param {boolean} [hasSubtitleTag] */ +export function getMagnetQualitySignals(title, hasSubtitleTag = false) { + const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); + return { resolution, subtitle, recognized: Boolean(resolution) || subtitle, highQuality: resolution === "4k" || resolution === "2160p" || subtitle }; +} + +/** @param {{title?: unknown, hasHdTag?: boolean, hasSubtitleTag?: boolean, date?: unknown, seeders?: number}} magnet */ +export function assessMagnetQuality({ title = "", hasHdTag = false, hasSubtitleTag = false, date = null, seeders = 0 } = {}) { + const signals = getMagnetQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; + const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); + return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; +} diff --git a/src/plugins/dependency-map.js b/src/plugins/dependency-map.js index 0e4c62e1..43a1b29c 100644 --- a/src/plugins/dependency-map.js +++ b/src/plugins/dependency-map.js @@ -14,8 +14,7 @@ export const LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ DetailPageButtonPlugin: ["DetailWorkspacePlugin", "MagnetHubPlugin", "HighlightMagnetPlugin"], Fc2Plugin: [ "DetailPageButtonPlugin", "MagnetHubPlugin", "FilterTitleKeywordPlugin", "OtherSitePlugin", - "Fc2By123AvPlugin", "TOP250Plugin", - "HighlightMagnetPlugin" + "Fc2By123AvPlugin", "TOP250Plugin" ], HistoryPlugin: ["UnifiedOfflinePlugin", "ListPagePlugin", "Fc2Plugin"], HitShowPlugin: ["ListPageButtonPlugin", "ListPagePlugin", "CoverButtonPlugin"], diff --git a/src/plugins/external-search/fc2.js b/src/plugins/external-search/fc2.js index 3cc59cb1..92142f85 100644 --- a/src/plugins/external-search/fc2.js +++ b/src/plugins/external-search/fc2.js @@ -210,11 +210,11 @@ export class Fc2Plugin extends BasePlugin { if (!context.isAlive()) return; host.empty(); if (!magnets.length) return renderFc2State(host, "暂无站内磁力"); - const highlighter = this.getDependency("HighlightMagnetPlugin"), assessments = []; + const magnetService = this.getRuntimeService("magnet"), assessments = []; magnets.forEach((item => { const hash = normalizeBtihHash(item.hash); if (!hash) return; - const magnet = `magnet:?xt=urn:btih:${hash}`, assessment = highlighter.assessMagnet({ title: item.title, hasHdTag: item.hasHdTag, hasSubtitleTag: item.hasSubtitleTag, date: item.createdAt, seeders: item.seeders }), row = $('
    ').attr("data-jhs-high-quality", String(assessment.highQuality)), info = $('
    '), actions = $('
    '), tags = $('
    '); + const magnet = `magnet:?xt=urn:btih:${hash}`, assessment = magnetService.assess({ title: item.title, hasHdTag: item.hasHdTag, hasSubtitleTag: item.hasSubtitleTag, date: item.createdAt, seeders: item.seeders }), row = $('
    ').attr("data-jhs-high-quality", String(assessment.highQuality)), info = $('
    '), actions = $('
    '), tags = $('
    '); assessments.push(assessment), tags.append($("").addClass("jhs-badge").attr("title", `磁力质量评分 ${assessment.score.total}`).text(`${assessment.grade} ${assessment.score.total}`)), item.hasHdTag && tags.append('高清'), item.hasSubtitleTag && tags.append('字幕'); info.append($("").attr("href", magnet).text(item.title || magnet), $("
    ").addClass("jhs-fc2-meta").text(`${(Number(item.sizeMb || 0) / 1024).toFixed(2)} GB · ${Number(item.fileCount) || 0} 个文件${item.createdAt ? ` · ${item.createdAt}` : ""}`), tags), actions.append($('').attr("data-clipboard-text", magnet), $('').attr({ "data-resource": magnet, "data-jhs-offline-owner": "fc2" })), host.append(row.append(info, actions)); })); diff --git a/src/plugins/external-search/magnet-hub.js b/src/plugins/external-search/magnet-hub.js index 53c06c34..b388f05a 100644 --- a/src/plugins/external-search/magnet-hub.js +++ b/src/plugins/external-search/magnet-hub.js @@ -1,36 +1,11 @@ import { CACHE_TTL, ProviderError } from "../../core/cache-policy.js"; import { escapeHtml, i, l, r } from "../../core/constants.js"; import { mapLimit } from "../../core/feature-helpers.js"; +import { calcMagnetScore } from "../../core/magnet-quality.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { BUILT_IN_MAGNET_SOURCES, ResourceSettingsService } from "../backup/resource-settings.js"; import { MagnetSourceRegistry, applyMagnetRules, deduplicateMagnetResults, normalizeMagnetResult, parseCustomMagnetResponse, parseNativeMagnets, validateCustomMagnetSource } from "./magnet-source-registry.js"; -/** 磁力评分:基于做种数/分辨率/字幕/新鲜度/完整性,返回 0-100 分 */ -export function calcMagnetScore(e) { - let t = 0; - const n = (e.seeders || 0); - const seedersScore = n >= 50 ? 35 : n >= 10 ? 25 : n >= 1 ? 15 : 3; - t += seedersScore; - const a = (e.title || "").toLowerCase(), resolution = String(e.resolution || "").toLowerCase(); - const resolutionScore = /4k|2160p/.test(resolution) || /4k|2160p/.test(a) ? 25 : /1080p/.test(resolution) || /1080p/.test(a) ? 20 : /720p/.test(resolution) || /720p/.test(a) ? 15 : 5; - t += resolutionScore; - const subtitleScore = e.hasSubtitle || /-c\b|-uc\b|chinese|中字|字幕/.test(a) ? 20 : 0; - t += subtitleScore; - const i = e.date ? _daysSince(e.date) : 999; - const freshnessScore = i <= 7 ? 15 : i <= 30 ? 12 : i <= 90 ? 8 : 3; - t += freshnessScore; - const completenessScore = /sample|预告|trailer/.test(a) ? -15 : 0; - t += completenessScore; - return { total: Math.max(0, Math.min(100, t)), seeders: seedersScore, resolution: resolutionScore, subtitle: subtitleScore, freshness: freshnessScore, completeness: completenessScore }; -} -function _daysSince(e) { - try { - const t = new Date(e); - if (isNaN(t.getTime())) return 999; - return Math.max(0, Math.floor((Date.now() - t.getTime()) / 864e5)); - } catch (t) { return 999; } -} - export class MagnetHubPlugin extends BasePlugin { constructor() { super(...arguments), i(this, "sourceRegistry", new MagnetSourceRegistry()), i(this, "searchEngines", []); diff --git a/src/plugins/offline/unified-offline.js b/src/plugins/offline/unified-offline.js index 0670bc29..b3b33172 100644 --- a/src/plugins/offline/unified-offline.js +++ b/src/plugins/offline/unified-offline.js @@ -36,7 +36,9 @@ export class UnifiedOfflinePlugin extends BasePlugin { async initCss() { return ''; } async handle() { if (!(r || l)) return; - this.registerProviders(), this.bindSubmit(), window.isDetailPage && (this.injectNativeButtons(), jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); + const scope = await this.getRuntimeService("scope")(); + this.registerProviders(), this.bindSubmit(), scope.addCleanup((() => $(document).off(".jhsUnifiedOffline"))); + if (window.isDetailPage) this.injectNativeButtons(), scope.addCleanup(jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); } registerProviders() { const one23 = this.getDependency("OneTwoThreeOfflinePlugin"); diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 33b58964..ab748a9d 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -68,7 +68,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), - manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }), + manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [SERVICE.settings]), manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), diff --git a/src/plugins/status/highlight-magnet.js b/src/plugins/status/highlight-magnet.js index d3a025a1..90d07fed 100644 --- a/src/plugins/status/highlight-magnet.js +++ b/src/plugins/status/highlight-magnet.js @@ -1,13 +1,15 @@ import { _, l, r } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { calcMagnetScore } from "../external-search/magnet-hub.js"; +import { calcMagnetScore, getMagnetQualitySignals } from "../../core/magnet-quality.js"; export class HighlightMagnetPlugin extends BasePlugin { async handle() { - window.isDetailPage && jhsEventBus.on("magnet-items-updated", (() => { - void storageManager.getSetting("enableMagnetsFilter", _).then((enabled => enabled === _ ? this.doFilterMagnet() : this.showAll())); - })); + if (!window.isDetailPage) return; + const settings = this.getRuntimeService("settings"), scope = await this.getRuntimeService("scope")(); + scope.addCleanup(jhsEventBus.on("magnet-items-updated", (() => { + (settings.snapshot().enableMagnetsFilter ?? _) === _ ? this.doFilterMagnet() : this.showAll(); + }))); } async initCss() { return ``; @@ -33,14 +35,7 @@ export class HighlightMagnetPlugin extends BasePlugin { } catch (e) { clog.debug("磁力评分徽章注入失败,已忽略", e); } } getQualitySignals(title, hasSubtitleTag = !1) { - const value = String(title || "").toLowerCase(), resolution = /(?:4k|2160p|1080p|720p)/.exec(value)?.[0] || "", subtitle = hasSubtitleTag || /(?:-c\b|-u(?:c)?\b|chinese|中字|字幕)/.test(value); - return { resolution, subtitle, recognized: !!resolution || subtitle, highQuality: "4k" === resolution || "2160p" === resolution || subtitle }; - } - /** 返回与 DOM 无关的磁力质量结果,供自有详情工作区复用。 */ - assessMagnet({ title = "", hasHdTag = !1, hasSubtitleTag = !1, date = null, seeders = 0 } = {}) { - const signals = this.getQualitySignals(title, hasSubtitleTag), highQuality = hasHdTag || signals.highQuality; - const score = calcMagnetScore({ title, date, seeders, resolution: hasHdTag && !signals.resolution ? "1080p" : signals.resolution, hasSubtitle: signals.subtitle }); - return { ...signals, highQuality, score, grade: score.total >= 70 ? "高" : score.total >= 40 ? "中" : "低" }; + return getMagnetQualitySignals(title, hasSubtitleTag); } updateFilterHint(hasMatch) { $("#enable-magnets-filter").removeClass("do-hide").attr("data-tip", hasMatch ? "仅显示识别到的高质量或字幕磁力" : "未识别到可过滤项,当前未隐藏磁力"); diff --git a/src/services/magnet-service.js b/src/services/magnet-service.js index 712fd7ef..5510fb98 100644 --- a/src/services/magnet-service.js +++ b/src/services/magnet-service.js @@ -1,5 +1,7 @@ // @ts-check +import { assessMagnetQuality } from "../core/magnet-quality.js"; + export class MagnetService { /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } @@ -23,4 +25,6 @@ export class MagnetService { const adapter = this.integrations?.getAdapter(manifest.id); return typeof adapter?.listMagnets === "function" ? adapter.listMagnets(movieRef, context) : []; } + /** @param {Parameters[0]} magnet */ + assess(magnet) { return assessMagnetQuality(magnet); } } diff --git a/tests/fc2-owned-workspace.test.js b/tests/fc2-owned-workspace.test.js index 50c4b7cc..80d06fac 100644 --- a/tests/fc2-owned-workspace.test.js +++ b/tests/fc2-owned-workspace.test.js @@ -9,6 +9,7 @@ import { createJavDbAdapter } from "../src/integrations/javdb/manifest.js"; import { HttpService } from "../src/services/http-service.js"; import { ExternalUrlPolicy } from "../src/services/external-url-policy.js"; import { CacheService } from "../src/services/cache-service.js"; +import { assessMagnetQuality } from "../src/core/magnet-quality.js"; const repoRoot = join(import.meta.dirname, ".."); const fc2Source = readTestFile(join(repoRoot, "src/plugins/external-search/fc2.js"), "utf8"); @@ -21,7 +22,6 @@ const stateServiceSource = readTestFile(join(repoRoot, "src/core/state-service.j const titleFilterSource = readTestFile(join(repoRoot, "src/plugins/blacklist/filter-title-keyword.js"), "utf8"); const highlightMagnetSource = readTestFile(join(repoRoot, "src/plugins/status/highlight-magnet.js"), "utf8"); const primitivesSource = readTestFile(join(repoRoot, "src/core/ui-primitives.js"), "utf8"); -const magnetHubSource = readTestFile(join(repoRoot, "src/plugins/external-search/magnet-hub.js"), "utf8"); const loggerSource = readTestFile(join(repoRoot, "src/core/logger.js"), "utf8"); const top250Source = readTestFile(join(repoRoot, "src/plugins/external-search/top250.js"), "utf8"); @@ -134,13 +134,11 @@ describe("FC2 owned detail workspace", () => { expect(fc2Source).toContain("item.hasSubtitleTag && tags.append"); expect(fc2Source).toContain("item.createdAt"); expect(fc2Source).toContain('data-jhs-action="filter-native-magnets"'); - expect(highlightMagnetSource).toContain("assessMagnet({"); + expect(fc2Source).toContain("magnetService.assess({"); }); it("assesses explicit HD and subtitle tags even when the title has no marker", () => { - const context = vm.createContext({ BasePlugin: class {}, clog: { debug: vi.fn() } }); - vm.runInContext(`${magnetHubSource.slice(0, magnetHubSource.indexOf("class MagnetHubPlugin"))}\n${highlightMagnetSource};globalThis.Highlighter=HighlightMagnetPlugin`, context); - const highlighter = Object.create(context.Highlighter.prototype), assessed = highlighter.assessMagnet({ title: "FC2-123", hasHdTag: true, hasSubtitleTag: true, seeders: 0 }); + const assessed = assessMagnetQuality({ title: "FC2-123", hasHdTag: true, hasSubtitleTag: true, seeders: 0 }); expect(assessed.highQuality).toBe(true), expect(assessed.subtitle).toBe(true), expect(assessed.score.resolution).toBe(20), expect(assessed.score.subtitle).toBe(20); }); From 0eb08ac58f3dff529af8d680a3d0bc5014457410 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:16:19 +0800 Subject: [PATCH 005/130] =?UTF-8?q?Refactor:=20=E5=BB=BA=E7=AB=8B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=AF=BB=E5=8F=96=E4=B8=8E=E7=94=9F=E5=91=BD=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 抽离无 UI 依赖的 ListItemReader,统一读取 JavDB/JavBus 列表卡片 - 移除 Blacklist、Task、UnifiedOffline 对 ListPagePlugin 的解析依赖 - 将 ListPage 的 EventBus、MutationObserver、IntersectionObserver 与延迟任务纳入 LifecycleScope - 让 115 匹配和外部站点通过 HostAdapter 定位列表及详情挂载点 - 新增列表卡片契约测试,并扩大 checkJs 覆盖 修改原因: - 列表数据解析属于共享边界,不应由列表 UI 插件充当 Service Locator - 列表相关全局监听和观察器必须可追踪、可释放 验证: - npm run check 通过,90 个测试文件共 521 项测试通过 - npm run check:browser 通过,本机 Edge 13 项通过、5 项按环境跳过 - 架构债务降至 212,import cycle 为 0 - git diff --check 与 codegraph sync 通过 - 根目录与 dist/JHS.user.js SHA256 一致 --- JHS.user.js | 133 +++++++++++++--------- src/core/list-item-reader.js | 29 +++++ src/plugins/blacklist/blacklist.js | 7 +- src/plugins/dependency-map.js | 6 +- src/plugins/external-search/other-site.js | 2 +- src/plugins/new-video/task.js | 3 +- src/plugins/offline/unified-offline.js | 3 +- src/plugins/one-one-five/plugins.js | 15 ++- src/plugins/registry.js | 4 +- src/plugins/status/list-page.js | 44 +++---- tests/blacklist-parser-boundary.test.js | 6 +- tests/list-item-reader.test.js | 26 +++++ tests/offline-provider.test.js | 2 +- tests/one-one-five.test.js | 5 +- tests/task-scheduler.test.js | 4 +- tsconfig.architecture.json | 2 + 16 files changed, 186 insertions(+), 105 deletions(-) create mode 100644 src/core/list-item-reader.js create mode 100644 tests/list-item-reader.test.js diff --git a/JHS.user.js b/JHS.user.js index a860e806..46ff0b27 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -3907,7 +3907,7 @@ // src/plugins/dependency-map.js var LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ AutoPagePlugin: ["ListPagePlugin"], - BlacklistPlugin: ["TaskPlugin", "SettingPlugin", "ListPagePlugin"], + BlacklistPlugin: ["TaskPlugin", "SettingPlugin"], BusNavBarPlugin: ["SearchByImagePlugin"], CompatibilityEnhancementsPlugin: ["ListPagePlugin"], CoverButtonPlugin: ["ListPagePlugin", "ScreenShotPlugin", "OtherSitePlugin"], @@ -3955,9 +3955,9 @@ "BusImgPlugin" ], StatsPlugin: ["OtherSitePlugin", "NewVideoPlugin", "ListPagePlugin"], - TaskPlugin: ["OtherSitePlugin", "BlacklistPlugin", "ListPagePlugin"], + TaskPlugin: ["OtherSitePlugin", "BlacklistPlugin"], TOP250Plugin: ["HitShowPlugin"], - UnifiedOfflinePlugin: ["OneTwoThreeOfflinePlugin", "ListPagePlugin"] + UnifiedOfflinePlugin: ["OneTwoThreeOfflinePlugin"] }); // src/plugins/avatar/actress-info.js @@ -5440,6 +5440,49 @@ } __name(applyImageMode, "applyImageMode"); + // src/core/movie-identity.js + var SIMPLE_CAR_PREFIXES2 = /* @__PURE__ */ new Set(["ABC", "ABP", "ADN", "ATID", "BF", "CAWD", "DLDSS", "DVAJ", "FSDSS", "HEYZO", "HMN", "IPX", "IPZZ", "JUQ", "JUL", "JUX", "MEYD", "MIAA", "MIDE", "MIDV", "MIMK", "MIRD", "NIMA", "PRED", "RBD", "SDDE", "SONE", "SSIS", "SSNI", "STARS", "URE", "VEC", "WAAA", "WANZ", "XVSR"]); + function normalizeMovieCarNum(value) { + if (typeof value !== "string") return null; + let carNum = value.trim(); + if (!carNum || ["undefined", "null"].includes(carNum.toLowerCase())) return null; + carNum = carNum.normalize("NFKC").replace(/[‐‑‒–—―﹘﹣-]/g, "-").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toUpperCase(); + const match = carNum.match(/^([A-Z]{2,8})(\d{2,7})$/); + return match && SIMPLE_CAR_PREFIXES2.has(match[1]) ? `${match[1]}-${match[2]}` : carNum || null; + } + __name(normalizeMovieCarNum, "normalizeMovieCarNum"); + + // src/core/list-item-reader.js + function readListItem(item) { + const jq = globalThis.$, element = item?.jquery ? item : jq(item); + const anchor = element.find("a"), url = anchor.attr("href"), titleNode = element.find(".video-title"); + let carNum, title, publishTime; + if (titleNode.length) { + const strong = titleNode.find("strong"); + if (strong.length) carNum = strong.text().trim(); + title = anchor.attr("title") ? anchor.attr("title").trim() : carNum ? titleNode.text().replace(carNum, "").trim() : titleNode.text().trim(); + publishTime = element.find(".meta").text().trim(); + } + if (!carNum) { + const image = element.find("img"); + if (image.length) title = image.attr("title")?.trim() || image.attr("data-title")?.trim() || title; + const dates = element.find("date").map((_index, node) => jq(node).text().trim()).get(); + publishTime = dates.find((value) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(value)); + carNum = dates.find((value) => !/^\d{4}-\d{1,2}-\d{1,2}$/.test(value)); + } + const normalized = normalizeMovieCarNum(carNum); + if (!normalized) throw new TypeError("提取番号信息失败"); + return Object.freeze({ + carNum: normalized, + aHref: url, + url, + title, + publishTime, + fc2Source: ["fc2", "123av"].includes(element.attr("data-jhs-fc2-source")) ? element.attr("data-jhs-fc2-source") : "fc2" + }); + } + __name(readListItem, "readListItem"); + // src/plugins/status/list-page.js var Te = { IS_FILTERED: { @@ -5562,18 +5605,24 @@ } async handle() { if (!window.isListPage || isHitShowPage()) return; + const scope = await this.getRuntimeService("scope")(); const refreshAll = /* @__PURE__ */ __name(async () => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); const e2 = this.getDependency("HistoryPlugin"); e2.tableObj && e2.tableObj.setData(); }, "refreshAll"); - jhsEventBus.on("legacy-refresh", refreshAll), jhsEventBus.on("blacklist-rules-changed", refreshAll), jhsEventBus.on("filter-rules-changed", refreshAll), jhsEventBus.on("settings-changed", refreshAll), jhsEventBus.on("car-state-changed", (async (payload) => { + ["legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed"].forEach(((type) => scope.addCleanup(jhsEventBus.on(type, refreshAll)))), scope.addCleanup(jhsEventBus.on("car-state-changed", (async (payload) => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); const items = this.getIndexedItems(payload.carNums || []); items.length && (await this.doFilterItems(items), this.applyVisibility(items)); const history = this.getDependency("HistoryPlugin"); history.tableObj && history.tableObj.setData(); - })), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(); + }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(scope), scope.addCleanup((() => { + this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); + this.hdImageObserver?.disconnect(), this.hdImageObserver = null; + this.recountFrame && (globalThis.cancelAnimationFrame?.(this.recountFrame) ?? clearTimeout(this.recountFrame)), this.recountFrame = null; + $(this.getSelector().boxSelector).off(".jhsMovieDetail"), $("#jhs-quick-filter").off(), this.itemIndex.clear(); + })); } async createQuickFilter() { if ($("#jhs-quick-filter").length) return; @@ -5640,7 +5689,7 @@ clog.debug("触发"), localStorage.setItem(t2, e3.toString()); })); } - checkDom() { + checkDom(scope = null) { if (!window.isListPage || isHitShowPage()) return; const e2 = this.getSelector(), t2 = document.querySelector(e2.boxSelector); if (!t2) return void clog.error("没有找到容器节点!"); @@ -5663,6 +5712,7 @@ childList: true, subtree: false }); + scope?.ownObserver(a2); } async processAddedItems(items) { const selector = this.getSelector(), covers = items.flatMap(((item) => [...item.querySelectorAll(selector.coverImgSelector)])); @@ -5946,30 +5996,12 @@ return t2; } findCarNumAndHref(e2) { - var t2, n2; - let a2, i2, s2, o2 = e2.find("a"), r2 = o2.attr("href"), l2 = e2.find(".video-title"); - if (l2.length > 0) { - let t3 = l2.find("strong"); - t3.length > 0 && (a2 = t3.text().trim()), i2 = o2.attr("title") ? o2.attr("title").trim() : a2 ? l2.text().replace(a2, "").trim() : l2.text().trim(), s2 = e2.find(".meta").text().trim(); - } - if (!a2) { - let o3 = e2.find("img"); - r2 && o3.length > 0 && (i2 = (null == (t2 = o3.attr("title")) ? void 0 : t2.trim()) || (null == (n2 = o3.attr("data-title")) ? void 0 : n2.trim())); - const l3 = /* @__PURE__ */ __name((e3) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e3), "l"); - s2 = e2.find("date").map(((e3, t3) => $(t3).text().trim())).get().find(l3), a2 = e2.find("date").map(((e3, t3) => $(t3).text().trim())).get().find(((e3) => !l3(e3))); - } - if (!a2) { - const e3 = "提取番号信息失败"; - throw show.error(e3), new Error(e3); + try { + return readListItem(e2); + } catch (error) { + show.error("提取番号信息失败"); + throw error; } - return { - carNum: normalizeCarNum(a2), - aHref: r2, - url: r2, - title: i2, - publishTime: s2, - fc2Source: ["fc2", "123av"].includes(e2.attr("data-jhs-fc2-source")) ? e2.attr("data-jhs-fc2-source") : "fc2" - }; } showCarNumBox(e2) { const t2 = $(".movie-list .item").toArray().find(((t3) => $(t3).find(".video-title strong").text() === e2)); @@ -8114,7 +8146,7 @@ let n2, a2; if (t2 ? (l && t2.find(".avatar-box").length > 0 && t2.find(".avatar-box").parent().remove(), n2 = t2.find(this.getSelector().requestDomItemSelector), a2 = t2.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")), a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); for (const s2 of n2) { - const t3 = $(s2), { carNum: n3, url: a3, publishTime: o2 } = this.getDependency("ListPagePlugin").findCarNumAndHref(t3); + const t3 = $(s2), { carNum: n3, url: a3, publishTime: o2 } = readListItem(t3); if (a3 && n3) try { await stateService.patch(n3, { blocked: true }, { type: "actor-page-block", record: { carNum: n3, url: a3, names: e2, publishTime: o2 } }), clog.log("屏蔽演员番号", e2, n3); } catch (i2) { @@ -8133,7 +8165,7 @@ pageDom ? (n2 = pageDom.find(this.getSelector().requestDomItemSelector), a2 = pageDom.find(this.getSelector().nextPageSelector).attr("href")) : (n2 = $(this.getSelector().itemSelector), a2 = $(this.getSelector().nextPageSelector).attr("href")); if (a2 && 0 === n2.length) throw show.error("解析列表失败"), new Error("解析列表失败"); for (const i2 of n2) { - const n3 = $(i2), { carNum: a3, url: o2, publishTime: r2 } = this.getDependency("ListPagePlugin").findCarNumAndHref(n3); + const n3 = $(i2), { carNum: a3, url: o2, publishTime: r2 } = readListItem(n3); if (o2 && a3) try { const flag = legacyActionToFlag(t2); flag && await stateService.patch(a3, { [flag]: true }, { type: "actor-page-batch-state", record: { carNum: a3, url: o2, names: e2, publishTime: r2 } }), clog.log("批量操作", e2, a3, t2); @@ -8172,7 +8204,7 @@ if (pageState.isEmpty && nextPageLink) throw new Error("黑名单作品空页面包含下一页"); const records = [], publishTimes = []; for (const item of pageState.items) { - const element = $(item), { carNum, url, publishTime } = this.getDependency("ListPagePlugin").findCarNumAndHref(element); + const element = $(item), { carNum, url, publishTime } = readListItem(element); publishTime && publishTimes.push(publishTime), url && carNum && records.push({ carNum, url, @@ -9951,7 +9983,7 @@ ${error.stack}` : ""); } async loadOtherSite(e2, t2, n2 = {}) { if ("yes" !== await storageManager.getSetting("enableLoadOtherSite", "yes")) return; - const root = n2.root ? $(n2.root) : $(document), target = n2.target ? $(n2.target) : root.find(".movie-panel-info,.container .info").first(); + const root = n2.root ? $(n2.root) : $(document), target = n2.target ? $(n2.target) : $(this.getRuntimeService("host").locateDetailSlots().summary); if (!target.length || n2.isActive && !n2.isActive()) return; root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); e2 = normalizeCarNum(e2) || this.getPageInfo().carNum; @@ -11162,18 +11194,6 @@ ${error.stack}` : ""); __name(_CoverButtonPlugin, "CoverButtonPlugin"); var CoverButtonPlugin = _CoverButtonPlugin; - // src/core/movie-identity.js - var SIMPLE_CAR_PREFIXES2 = /* @__PURE__ */ new Set(["ABC", "ABP", "ADN", "ATID", "BF", "CAWD", "DLDSS", "DVAJ", "FSDSS", "HEYZO", "HMN", "IPX", "IPZZ", "JUQ", "JUL", "JUX", "MEYD", "MIAA", "MIDE", "MIDV", "MIMK", "MIRD", "NIMA", "PRED", "RBD", "SDDE", "SONE", "SSIS", "SSNI", "STARS", "URE", "VEC", "WAAA", "WANZ", "XVSR"]); - function normalizeMovieCarNum(value) { - if (typeof value !== "string") return null; - let carNum = value.trim(); - if (!carNum || ["undefined", "null"].includes(carNum.toLowerCase())) return null; - carNum = carNum.normalize("NFKC").replace(/[‐‑‒–—―﹘﹣-]/g, "-").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toUpperCase(); - const match = carNum.match(/^([A-Z]{2,8})(\d{2,7})$/); - return match && SIMPLE_CAR_PREFIXES2.has(match[1]) ? `${match[1]}-${match[2]}` : carNum || null; - } - __name(normalizeMovieCarNum, "normalizeMovieCarNum"); - // src/integrations/javstore/parser.js function resolveDocument(page) { if (typeof page === "string") return new DOMParser().parseFromString(page, "text/html"); @@ -12753,7 +12773,7 @@ ${error.stack}` : ""); let c2 = []; const publishTimes = []; for (const m2 of s2) { - const e3 = $(m2), { carNum: s3, url: o3, title: r2, publishTime: l2 } = this.getDependency("ListPagePlugin").findCarNumAndHref(e3); + const e3 = $(m2), { carNum: s3, url: o3, title: r2, publishTime: l2 } = readListItem(e3); l2 && publishTimes.push(l2); if (!s3) continue; a2.find(((e4) => r2.includes(e4) || s3.includes(e4))) || (i2.has(s3) || (() => { @@ -12897,7 +12917,7 @@ ${error.stack}` : ""); getVideoInfo(button) { if (window.isDetailPage) return this.getPageInfo(); const item = button?.closest?.(".item"); - return item?.length ? this.getDependency("ListPagePlugin").findCarNumAndHref(item) : this.getPageInfo(); + return item?.length ? readListItem(item) : this.getPageInfo(); } async submitResource(event, resource, button = $(), context = null, retryOf = null, options = {}) { if (button.hasClass("loading")) return; @@ -12938,10 +12958,17 @@ ${error.stack}` : ""); } async handle() { if (!await storageManager.getSetting("enable115Match", false)) return; - if (!isDetailPage) return this.setupListMatching(); + const hostAdapter = this.getRuntimeService("host"); + if (!isDetailPage) { + const scope = await this.getRuntimeService("scope")(); + await this.setupListMatching(hostAdapter), scope.ownObserver(this.observer), scope.addCleanup((() => { + this.unsubscribeItems?.(), this.unsubscribeItems = null, this.flushTimer && clearTimeout(this.flushTimer), this.flushTimer = null, this.pendingCards.clear(); + })); + return; + } const carNum = this.getPageInfo().carNum, keyword = normalize115Keyword(carNum); if (!keyword) return; - const host = $(".movie-panel-info,.container .info").first(); + const host = $(hostAdapter.locateDetailSlots().summary); host.append('
    115匹配:匹配中
    '); try { const cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60), matches = await storageManager.cachedRequest(`115match:${carNum}`, cacheMinutes * 6e4, (() => new OneOneFiveClient().search(keyword))); @@ -12960,13 +12987,13 @@ ${error.stack}` : ""); clog.error("115 匹配失败", error); } } - async setupListMatching() { + async setupListMatching(hostAdapter) { this.concurrency = Math.max(1, Math.min(10, Number(await storageManager.getSetting("oneOneFiveConcurrency", 4)) || 4)), this.cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60); this.observer = new IntersectionObserver(((entries) => { entries.forEach(((entry) => entry.isIntersecting && (this.observer.unobserve(entry.target), this.pendingCards.add(entry.target)))); this.pendingCards.size && this.scheduleFlush(); }), { rootMargin: "200px" }); - this.registerCards($(".movie-list .item,.masonry .item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", ((payload) => this.registerCards(payload.items || []))); + this.registerCards($(hostAdapter.locateListRoot()).find(".item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", ((payload) => this.registerCards(payload.items || []))); } registerCards(cards) { cards.forEach(((card) => { @@ -15415,7 +15442,7 @@ ${error.stack}` : ""); manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), - manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [SERVICE.movie]), + manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie]), manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), @@ -15426,7 +15453,7 @@ ${error.stack}` : ""); manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), - manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }), + manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host]), manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), diff --git a/src/core/list-item-reader.js b/src/core/list-item-reader.js new file mode 100644 index 00000000..c20f8998 --- /dev/null +++ b/src/core/list-item-reader.js @@ -0,0 +1,29 @@ +// @ts-check + +import { normalizeMovieCarNum } from "./movie-identity.js"; + +/** Read one JavDB/JavBus list card without depending on ListPagePlugin. @param {any} item */ +export function readListItem(item) { + const jq = /** @type {any} */ (globalThis).$, element = item?.jquery ? item : jq(item); + const anchor = element.find("a"), url = anchor.attr("href"), titleNode = element.find(".video-title"); + let carNum, title, publishTime; + if (titleNode.length) { + const strong = titleNode.find("strong"); + if (strong.length) carNum = strong.text().trim(); + title = anchor.attr("title") ? anchor.attr("title").trim() : carNum ? titleNode.text().replace(carNum, "").trim() : titleNode.text().trim(); + publishTime = element.find(".meta").text().trim(); + } + if (!carNum) { + const image = element.find("img"); + if (image.length) title = image.attr("title")?.trim() || image.attr("data-title")?.trim() || title; + const dates = element.find("date").map((/** @type {number} */ _index, /** @type {Element} */ node) => jq(node).text().trim()).get(); + publishTime = dates.find((/** @type {string} */ value) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(value)); + carNum = dates.find((/** @type {string} */ value) => !/^\d{4}-\d{1,2}-\d{1,2}$/.test(value)); + } + const normalized = normalizeMovieCarNum(carNum); + if (!normalized) throw new TypeError("提取番号信息失败"); + return Object.freeze({ + carNum: normalized, aHref: url, url, title, publishTime, + fc2Source: ["fc2", "123av"].includes(element.attr("data-jhs-fc2-source")) ? element.attr("data-jhs-fc2-source") : "fc2", + }); +} diff --git a/src/plugins/blacklist/blacklist.js b/src/plugins/blacklist/blacklist.js index c0dae7a9..dcb83ac1 100644 --- a/src/plugins/blacklist/blacklist.js +++ b/src/plugins/blacklist/blacklist.js @@ -2,6 +2,7 @@ import { A, B, D, I, P, T, d, i, l, o, r } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { normalizeHttpUrl, parseNumberSetting, selectLatestPublishTime, shouldSkipStopped } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; +import { readListItem } from "../../core/list-item-reader.js"; import { legacyActionToFlag } from "../../core/state-model.js"; import { stateService } from "../../core/state-service.js"; import { JhsSelect, renderStateView } from "../../core/ui-primitives.js"; @@ -348,7 +349,7 @@ export class BlacklistPlugin extends BasePlugin { a = $(this.getSelector().nextPageSelector).attr("href")), a && 0 === n.length) throw show.error("解析列表失败"), new Error("解析列表失败"); for (const s of n) { - const t = $(s), {carNum: n, url: a, publishTime: o} = this.getDependency("ListPagePlugin").findCarNumAndHref(t); + const t = $(s), {carNum: n, url: a, publishTime: o} = readListItem(t); if (a && n) try { await stateService.patch(n, { blocked: !0 }, { type: "actor-page-block", record: { carNum: n, url: a, names: e, publishTime: o } }), clog.log("屏蔽演员番号", e, n); } catch (i) { @@ -367,7 +368,7 @@ export class BlacklistPlugin extends BasePlugin { pageDom ? (n = pageDom.find(this.getSelector().requestDomItemSelector), a = pageDom.find(this.getSelector().nextPageSelector).attr("href")) : (n = $(this.getSelector().itemSelector), a = $(this.getSelector().nextPageSelector).attr("href")); if (a && 0 === n.length) throw show.error("解析列表失败"), new Error("解析列表失败"); for (const i of n) { - const n = $(i), {carNum: a, url: o, publishTime: r} = this.getDependency("ListPagePlugin").findCarNumAndHref(n); + const n = $(i), {carNum: a, url: o, publishTime: r} = readListItem(n); if (o && a) try { const flag = legacyActionToFlag(t); flag && await stateService.patch(a, { [flag]: !0 }, { type: "actor-page-batch-state", record: { carNum: a, url: o, names: e, publishTime: r } }), clog.log("批量操作", e, a, t); @@ -403,7 +404,7 @@ export class BlacklistPlugin extends BasePlugin { if (pageState.isEmpty && nextPageLink) throw new Error("黑名单作品空页面包含下一页"); const records = [], publishTimes = []; for (const item of pageState.items) { - const element = $(item), {carNum, url, publishTime} = this.getDependency("ListPagePlugin").findCarNumAndHref(element); + const element = $(item), {carNum, url, publishTime} = readListItem(element); publishTime && publishTimes.push(publishTime), url && carNum && records.push({ carNum, url, diff --git a/src/plugins/dependency-map.js b/src/plugins/dependency-map.js index 43a1b29c..d8fc5844 100644 --- a/src/plugins/dependency-map.js +++ b/src/plugins/dependency-map.js @@ -7,7 +7,7 @@ */ export const LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ AutoPagePlugin: ["ListPagePlugin"], - BlacklistPlugin: ["TaskPlugin", "SettingPlugin", "ListPagePlugin"], + BlacklistPlugin: ["TaskPlugin", "SettingPlugin"], BusNavBarPlugin: ["SearchByImagePlugin"], CompatibilityEnhancementsPlugin: ["ListPagePlugin"], CoverButtonPlugin: ["ListPagePlugin", "ScreenShotPlugin", "OtherSitePlugin"], @@ -35,8 +35,8 @@ export const LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ "ActressInfoPlugin", "ScreenShotPlugin", "NewVideoPlugin", "BlacklistPlugin", "BusImgPlugin" ], StatsPlugin: ["OtherSitePlugin", "NewVideoPlugin", "ListPagePlugin"], - TaskPlugin: ["OtherSitePlugin", "BlacklistPlugin", "ListPagePlugin"], + TaskPlugin: ["OtherSitePlugin", "BlacklistPlugin"], TOP250Plugin: ["HitShowPlugin"], - UnifiedOfflinePlugin: ["OneTwoThreeOfflinePlugin", "ListPagePlugin"] + UnifiedOfflinePlugin: ["OneTwoThreeOfflinePlugin"] }); // @ts-check diff --git a/src/plugins/external-search/other-site.js b/src/plugins/external-search/other-site.js index ccc92df4..41401b2d 100644 --- a/src/plugins/external-search/other-site.js +++ b/src/plugins/external-search/other-site.js @@ -108,7 +108,7 @@ export class OtherSitePlugin extends BasePlugin { } async loadOtherSite(e, t, n = {}) { if ("yes" !== await storageManager.getSetting("enableLoadOtherSite", "yes")) return; - const root = n.root ? $(n.root) : $(document), target = n.target ? $(n.target) : root.find(".movie-panel-info,.container .info").first(); + const root = n.root ? $(n.root) : $(document), target = n.target ? $(n.target) : $(this.getRuntimeService("host").locateDetailSlots().summary); if (!target.length || n.isActive && !n.isActive()) return; root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); e = normalizeCarNum(e) || this.getPageInfo().carNum; diff --git a/src/plugins/new-video/task.js b/src/plugins/new-video/task.js index 39a7f3b1..58fab2f1 100644 --- a/src/plugins/new-video/task.js +++ b/src/plugins/new-video/task.js @@ -1,6 +1,7 @@ import { I, T, _, i, l, normalizeCarNum } from "../../core/constants.js"; import { parseNumberSetting, parseTaskTimestamp, selectLatestPublishTime, shouldSkipStopped } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; +import { readListItem } from "../../core/list-item-reader.js"; import { detectSite } from "../../core/site-context.js"; import { parseJavDbActorList } from "../../integrations/javdb/parser.js"; import { parseDetailPage } from "../../integrations/host-list/parser.js"; @@ -447,7 +448,7 @@ export class TaskPlugin extends BasePlugin { let c = []; const publishTimes = []; for (const m of s) { - const e = $(m), {carNum: s, url: o, title: r, publishTime: l} = this.getDependency("ListPagePlugin").findCarNumAndHref(e); + const e = $(m), {carNum: s, url: o, title: r, publishTime: l} = readListItem(e); l && publishTimes.push(l); if (!s) continue; a.find((e => r.includes(e) || s.includes(e))) || (i.has(s) || (() => { diff --git a/src/plugins/offline/unified-offline.js b/src/plugins/offline/unified-offline.js index b3b33172..69bab602 100644 --- a/src/plugins/offline/unified-offline.js +++ b/src/plugins/offline/unified-offline.js @@ -1,6 +1,7 @@ import { l, r } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { BasePlugin } from "../../core/plugin-manager.js"; +import { readListItem } from "../../core/list-item-reader.js"; import { stateService } from "../../core/state-service.js"; import { OneOneFiveClient } from "../one-one-five/client.js"; import { getDetailResourceAdapter } from "../status/detail-workspace.js"; @@ -79,7 +80,7 @@ export class UnifiedOfflinePlugin extends BasePlugin { getVideoInfo(button) { if (window.isDetailPage) return this.getPageInfo(); const item = button?.closest?.(".item"); - return item?.length ? this.getDependency("ListPagePlugin").findCarNumAndHref(item) : this.getPageInfo(); + return item?.length ? readListItem(item) : this.getPageInfo(); } async submitResource(event, resource, button = $(), context = null, retryOf = null, options = {}) { if (button.hasClass("loading")) return; diff --git a/src/plugins/one-one-five/plugins.js b/src/plugins/one-one-five/plugins.js index 958a01b8..d3251d59 100644 --- a/src/plugins/one-one-five/plugins.js +++ b/src/plugins/one-one-five/plugins.js @@ -9,9 +9,16 @@ export class OneOneFiveMatchPlugin extends BasePlugin { getName() { return "OneOneFiveMatchPlugin"; } async handle() { if (!await storageManager.getSetting("enable115Match", !1)) return; - if (!isDetailPage) return this.setupListMatching(); + const hostAdapter = this.getRuntimeService("host"); + if (!isDetailPage) { + const scope = await this.getRuntimeService("scope")(); + await this.setupListMatching(hostAdapter), scope.ownObserver(this.observer), scope.addCleanup((() => { + this.unsubscribeItems?.(), this.unsubscribeItems = null, this.flushTimer && clearTimeout(this.flushTimer), this.flushTimer = null, this.pendingCards.clear(); + })); + return; + } const carNum = this.getPageInfo().carNum, keyword = normalize115Keyword(carNum); if (!keyword) return; - const host = $(".movie-panel-info,.container .info").first(); host.append('
    115匹配:匹配中
    '); + const host = $(hostAdapter.locateDetailSlots().summary); host.append('
    115匹配:匹配中
    '); try { const cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60), matches = await storageManager.cachedRequest(`115match:${carNum}`, cacheMinutes * 6e4, (() => new OneOneFiveClient().search(keyword))); const box = $(".jhs-115-match").empty().append("115匹配:"); @@ -20,13 +27,13 @@ export class OneOneFiveMatchPlugin extends BasePlugin { box.on("click", ".jhs-115-rename", (event => this.renameWithPreview(event, $(event.currentTarget).data("match"), carNum))); } catch (error) { const box = $(".jhs-115-match").empty().append("115匹配:", document.createTextNode("未登录或请求失败 ")); box.append('去登录', $('').on("click", (() => location.reload()))); clog.error("115 匹配失败", error); } } - async setupListMatching() { + async setupListMatching(hostAdapter) { this.concurrency = Math.max(1, Math.min(10, Number(await storageManager.getSetting("oneOneFiveConcurrency", 4)) || 4)), this.cacheMinutes = Math.max(1, Number(await storageManager.getSetting("oneOneFiveCacheMinutes", 60)) || 60); this.observer = new IntersectionObserver((entries => { entries.forEach((entry => entry.isIntersecting && (this.observer.unobserve(entry.target), this.pendingCards.add(entry.target)))); this.pendingCards.size && this.scheduleFlush(); }), { rootMargin: "200px" }); - this.registerCards($(".movie-list .item,.masonry .item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", (payload => this.registerCards(payload.items || []))); + this.registerCards($(hostAdapter.locateListRoot()).find(".item").get()), this.unsubscribeItems = jhsEventBus.on("list-items-added", (payload => this.registerCards(payload.items || []))); } registerCards(cards) { cards.forEach((card => { "true" !== card.dataset.jhs115Observed && "matched" !== card.dataset.jhs115State && (card.dataset.jhs115Observed = "true", this.observer.observe(card)); })); diff --git a/src/plugins/registry.js b/src/plugins/registry.js index ab748a9d..d8b60da0 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -72,7 +72,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), - manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [SERVICE.movie]), + manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie]), manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), @@ -83,7 +83,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), - manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }), + manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host]), manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index 4f21e523..24ab9115 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -2,6 +2,7 @@ import { B, C, _, b, escapeHtml, i, k, l, normalizeCarNum, o, r, u, y } from ".. import { jhsEventBus } from "../../core/event-bus.js"; import { mapLimit, safePlay } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; +import { readListItem } from "../../core/list-item-reader.js"; import { isHitShowPage } from "../../core/site-context.js"; import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; import { stateService } from "../../core/state-service.js"; @@ -129,23 +130,29 @@ export class ListPagePlugin extends BasePlugin { } async handle() { if (!window.isListPage || isHitShowPage()) return; + const scope = await this.getRuntimeService("scope")(); const refreshAll = async () => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); const e = this.getDependency("HistoryPlugin"); e.tableObj && e.tableObj.setData(); }; - jhsEventBus.on("legacy-refresh", refreshAll), jhsEventBus.on("blacklist-rules-changed", refreshAll), jhsEventBus.on("filter-rules-changed", refreshAll), jhsEventBus.on("settings-changed", refreshAll), - jhsEventBus.on("car-state-changed", (async payload => { + [ "legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed" ].forEach((type => scope.addCleanup(jhsEventBus.on(type, refreshAll)))), + scope.addCleanup(jhsEventBus.on("car-state-changed", (async payload => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); const items = this.getIndexedItems(payload.carNums || []); items.length && (await this.doFilterItems(items), this.applyVisibility(items)); const history = this.getDependency("HistoryPlugin"); history.tableObj && history.tableObj.setData(); - })), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), + }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: !1 }), - this.checkDom(); + this.checkDom(scope), scope.addCleanup((() => { + this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); + this.hdImageObserver?.disconnect(), this.hdImageObserver = null; + this.recountFrame && (globalThis.cancelAnimationFrame?.(this.recountFrame) ?? clearTimeout(this.recountFrame)), this.recountFrame = null; + $(this.getSelector().boxSelector).off(".jhsMovieDetail"), $("#jhs-quick-filter").off(), this.itemIndex.clear(); + })); } async createQuickFilter() { if ($("#jhs-quick-filter").length) return; @@ -215,7 +222,7 @@ export class ListPagePlugin extends BasePlugin { clog.debug("触发"), localStorage.setItem(t, e.toString()); })); } - checkDom() { + checkDom(scope = null) { if (!window.isListPage || isHitShowPage()) return; const e = this.getSelector(), t = document.querySelector(e.boxSelector); if (!t) return void clog.error("没有找到容器节点!"); @@ -239,6 +246,7 @@ export class ListPagePlugin extends BasePlugin { childList: !0, subtree: !1 }); + scope?.ownObserver(a); } async processAddedItems(items) { const selector = this.getSelector(), covers = items.flatMap((item => [ ...item.querySelectorAll(selector.coverImgSelector) ])); @@ -465,31 +473,7 @@ export class ListPagePlugin extends BasePlugin { return t; } findCarNumAndHref(e) { - var t, n; - let a, i, s, o = e.find("a"), r = o.attr("href"), l = e.find(".video-title"); - if (l.length > 0) { - let t = l.find("strong"); - t.length > 0 && (a = t.text().trim()), i = o.attr("title") ? o.attr("title").trim() : a ? l.text().replace(a, "").trim() : l.text().trim(), - s = e.find(".meta").text().trim(); - } - if (!a) { - let o = e.find("img"); - r && o.length > 0 && (i = (null == (t = o.attr("title")) ? void 0 : t.trim()) || (null == (n = o.attr("data-title")) ? void 0 : n.trim())); - const l = e => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e); - s = e.find("date").map(((e, t) => $(t).text().trim())).get().find(l), a = e.find("date").map(((e, t) => $(t).text().trim())).get().find((e => !l(e))); - } - if (!a) { - const e = "提取番号信息失败"; - throw show.error(e), new Error(e); - } - return { - carNum: normalizeCarNum(a), - aHref: r, - url: r, - title: i, - publishTime: s, - fc2Source: [ "fc2", "123av" ].includes(e.attr("data-jhs-fc2-source")) ? e.attr("data-jhs-fc2-source") : "fc2" - }; + try { return readListItem(e); } catch (error) { show.error("提取番号信息失败"); throw error; } } showCarNumBox(e) { const t = $(".movie-list .item").toArray().find((t => $(t).find(".video-title strong").text() === e)); diff --git a/tests/blacklist-parser-boundary.test.js b/tests/blacklist-parser-boundary.test.js index d82b17dc..a13c2d46 100644 --- a/tests/blacklist-parser-boundary.test.js +++ b/tests/blacklist-parser-boundary.test.js @@ -11,16 +11,16 @@ const repoRoot = join(import.meta.dirname, ".."); function loadBlacklist(html, save = vi.fn(async () => {}), pageUrl = "https://javdb.com/actors/a") { const dom = new JSDOM(html, { url: pageUrl }), $ = jqueryFactory(dom.window); const gmHttp = { get: vi.fn(async () => '
    ') }; - const listPage = { findCarNumAndHref: element => ({ carNum: element.attr("data-car"), url: element.attr("data-url"), publishTime: element.attr("data-date") }) }; class BasePlugin { getSelector(site) { return "javbus" === site ? { boxSelector: ".masonry", itemSelector: ".masonry .item", requestDomItemSelector: "#waterfall .item", nextPageSelector: "#next" } : { boxSelector: ".movie-list", itemSelector: ".movie-list .item", requestDomItemSelector: ".movie-list .item", nextPageSelector: ".pagination-next" }; } - getBean(name) { return "ListPagePlugin" === name ? listPage : null; } + getBean() { return null; } } const context = vm.createContext({ console, URL, Date, window: dom.window, document: dom.window.document, $, BasePlugin, storageManager: { batchSaveBlacklistCarList: save }, - T: "javdb", I: "javbus", d: "filter", r: true, l: false, o: "", _: "yes", gmHttp, clog: { error: vi.fn(), log: vi.fn() }, show: { info: vi.fn(), ok: vi.fn() }, utils: { htmlTo$dom: source => $(new JSDOM(source, { url: pageUrl }).window.document) }, i: (target, key, value) => target[key] = value + T: "javdb", I: "javbus", d: "filter", r: true, l: false, o: "", _: "yes", gmHttp, clog: { error: vi.fn(), log: vi.fn() }, show: { info: vi.fn(), ok: vi.fn() }, utils: { htmlTo$dom: source => $(new JSDOM(source, { url: pageUrl }).window.document) }, i: (target, key, value) => target[key] = value, + readListItem: element => ({ carNum: element.attr("data-car"), url: element.attr("data-url"), publishTime: element.attr("data-date") }) }); const source = [ "src/core/feature-helpers.js", "src/integrations/host-list/parser.js", "src/plugins/blacklist/blacklist.js" ].map(file => readTestFile(join(repoRoot, file), "utf8")).join("\n"); vm.runInContext(`${source};globalThis.Plugin=BlacklistPlugin`, context); diff --git a/tests/list-item-reader.test.js b/tests/list-item-reader.test.js new file mode 100644 index 00000000..355bda14 --- /dev/null +++ b/tests/list-item-reader.test.js @@ -0,0 +1,26 @@ +import { JSDOM } from "jsdom"; +import jqueryFactory from "jquery"; +import { afterEach, describe, expect, it } from "vitest"; +import { readListItem } from "../src/core/list-item-reader.js"; + +afterEach(() => { delete globalThis.$; }); + +function card(html) { + const dom = new JSDOM(html), $ = jqueryFactory(dom.window); + globalThis.$ = $; + return $(dom.window.document.body).children().first(); +} + +describe("list item reader", () => { + it("reads JavDB-style cards and preserves explicit FC2 source", () => { + const item = card(''); + expect(readListItem(item)).toMatchObject({ carNum: "FC2-123", url: "/v/a", title: "标题", publishTime: "2026-08-24", fc2Source: "123av" }); + }); + + it("reads JavBus-style date nodes", () => { + const item = card('
    ABC-1232026-8-4
    '); + expect(readListItem(item)).toMatchObject({ carNum: "ABC-123", title: "Bus title", publishTime: "2026-8-4", fc2Source: "fc2" }); + }); + + it("rejects cards without an identity", () => expect(() => readListItem(card("
    "))).toThrow("提取番号信息失败")); +}); diff --git a/tests/offline-provider.test.js b/tests/offline-provider.test.js index fc9854a2..7b77e09d 100644 --- a/tests/offline-provider.test.js +++ b/tests/offline-provider.test.js @@ -20,7 +20,7 @@ function loadOfflinePlugin(submit, history = vi.fn(async () => {})) { r: true, l: false, setTimeout, clearTimeout, stateService: { appendOfflineHistory: history, patch: vi.fn() }, show: { ok: vi.fn(), error: vi.fn() }, utils: { q: vi.fn() }, storageManager: {}, layer: {}, - OneOneFiveClient: class {}, getDetailResourceAdapter: vi.fn(), jhsEventBus: { on: vi.fn() } + OneOneFiveClient: class {}, getDetailResourceAdapter: vi.fn(), jhsEventBus: { on: vi.fn() }, readListItem: vi.fn() }); const source = readTestFile(join(import.meta.dirname, "../src/plugins/offline/unified-offline.js"), "utf8"); vm.runInContext(`${source};globalThis.TestOfflinePlugin=UnifiedOfflinePlugin;`, context); diff --git a/tests/one-one-five.test.js b/tests/one-one-five.test.js index aa3295a9..5019d678 100644 --- a/tests/one-one-five.test.js +++ b/tests/one-one-five.test.js @@ -26,7 +26,10 @@ describe("115 domain", () => { it("keeps disabled plugins request-free", () => { const source = readTestFile(join(import.meta.dirname, "../src/plugins/one-one-five/plugins.js"), "utf8"); expect(source).toContain('getSetting("enable115Match", !1)'); - expect(source).toContain("return this.setupListMatching()"); + expect(source).toContain("await this.setupListMatching(hostAdapter)"); + expect(source).toContain("scope.ownObserver(this.observer)"); + expect(source).toContain("hostAdapter.locateListRoot()"); + expect(source).not.toContain('$(".movie-list .item,.masonry .item")'); expect(source).toContain('jhsEventBus.on("list-items-added"'); expect(source).not.toContain("new MutationObserver"); expect(source).not.toContain("gmHttp"); diff --git a/tests/task-scheduler.test.js b/tests/task-scheduler.test.js index a54cf198..09a181f3 100644 --- a/tests/task-scheduler.test.js +++ b/tests/task-scheduler.test.js @@ -42,8 +42,7 @@ function createHarness(initialTime = "2026-08-23T13:20:00.789", pageUrl = "https const gmHttp = { get: vi.fn() }, beans = { OtherSitePlugin: { getJavDbUrl: vi.fn(async () => "https://javdb.com"), getJavBusUrl: vi.fn(async () => "https://www.javbus.com") }, NewVideoPlugin: { loadData: vi.fn(async () => {}), resetBtnTip: vi.fn(async () => {}) }, - BlacklistPlugin: { resetBtnTip: vi.fn(async () => {}) }, - ListPagePlugin: { findCarNumAndHref: element => ({ carNum: element.attr("data-car"), url: element.attr("data-url"), title: element.attr("data-title") || "", publishTime: element.attr("data-date") || "" }) } + BlacklistPlugin: { resetBtnTip: vi.fn(async () => {}) } }; class BasePlugin { getBean(name) { return beans[name]; } @@ -61,6 +60,7 @@ function createHarness(initialTime = "2026-08-23T13:20:00.789", pageUrl = "https utils: { sleep: vi.fn(async () => {}), getNowStr: (a = "-", b = ":", timestamp = null) => format(null == timestamp ? clock.now : timestamp), getHourDifference: (left, right) => Math.floor(Math.abs(right.getTime() - left.getTime()) / 36e5), genericSort: items => [ ...items ], htmlTo$dom: html => $(new JSDOM(html, { url: "https://javdb.com/" }).window.document) }, clog: { log: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, show: { info: vi.fn(), error: vi.fn() }, i: (target, key, value) => target[key] = value, jhsEventBus, normalizeCarNum: value => String(value || "").toUpperCase(), + readListItem: element => ({ carNum: element.attr("data-car"), url: element.attr("data-url"), title: element.attr("data-title") || "", publishTime: element.attr("data-date") || "" }), setTimeout, clearTimeout }); context.globalThis = context; diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index 6b87ae9a..42e6703c 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -19,6 +19,8 @@ "src/core/event-bus.js", "src/core/legacy-plugin-contributions.js", "src/core/lifecycle-scope.js", + "src/core/list-item-reader.js", + "src/core/magnet-quality.js", "src/core/movie-identity.js", "src/core/selection-model.js", "src/core/site-context.js", From 131bddd7e16bd9e2ec662e4325d528e4d04a3cee Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:22:33 +0800 Subject: [PATCH 006/130] =?UTF-8?q?Refactor:=20=E6=8B=86=E5=88=86=20Histor?= =?UTF-8?q?y=20=E6=95=B0=E6=8D=AE=E8=AE=BF=E9=97=AE=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 新增 HistoryRepository,集中历史列表、活动日志、离线记录与状态变更访问 - History 对话框改用声明注入的 DialogService,并同步回归门禁 - 补充 Repository 契约及现有 History 交互测试注入 修改原因: - 将 History UI 与 StateService、StorageManager 的直接操作隔离,为后续 controller/model 拆分建立稳定边界 - 消除 History 对 Layer 平台实现的直接依赖 验证: - npm run check 通过(91 个测试文件,522 项测试) - npm run check:browser 通过(本机 Edge:13 passed,5 skipped) - codegraph sync、git diff --check 与根/dist 产物哈希一致性检查通过 --- JHS.user.js | 67 ++++++++++++++++------ scripts/regression-check.mjs | 2 +- src/features/history/history-repository.js | 19 ++++++ src/plugins/registry.js | 2 +- src/plugins/status/history.js | 31 +++++----- tests/history-multistate.test.js | 5 +- tests/history-repository.test.js | 22 +++++++ tests/history-selection.test.js | 9 +-- 8 files changed, 119 insertions(+), 38 deletions(-) create mode 100644 src/features/history/history-repository.js create mode 100644 tests/history-repository.test.js diff --git a/JHS.user.js b/JHS.user.js index 46ff0b27..8e74c5c0 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -14034,10 +14034,44 @@ ${error.stack}` : ""); __name(_HistorySelectionModel, "HistorySelectionModel"); var HistorySelectionModel = _HistorySelectionModel; + // src/features/history/history-repository.js + var _HistoryRepository = class _HistoryRepository { + constructor(dependencies) { + this.storage = dependencies.storage; + this.state = dependencies.state; + } + list() { + return this.storage.getCarList(); + } + activity() { + return this.state.getActivityLog(); + } + offline() { + return this.state.getOfflineHistory(); + } + undo(transactionId) { + return this.state.undoTransaction(transactionId); + } + remove(carNums) { + return this.state.remove(carNums); + } + toggle(carNum, flag, options) { + return this.state.toggle(carNum, flag, options); + } + patch(carNums, flags, options) { + return this.state.patch(carNums, flags, options); + } + removeOffline(id) { + return this.state.removeOfflineHistory(id); + } + }; + __name(_HistoryRepository, "HistoryRepository"); + var HistoryRepository = _HistoryRepository; + // src/plugins/status/history.js var _HistoryPlugin = class _HistoryPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectionModel", new HistorySelectionModel()), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); + super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectionModel", new HistorySelectionModel()), i(this, "historyRepository", new HistoryRepository({ storage: storageManager, state: stateService })), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); } getName() { return "HistoryPlugin"; @@ -14104,7 +14138,7 @@ ${error.stack}` : "");
    `; e2 = e2.replace('
    { - const result = await stateService.undoTransaction($(event.currentTarget).data("transaction")); + const result = await this.historyRepository.undo($(event.currentTarget).data("transaction")); show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); })).on("click", ".jhs-copy-offline", (async (event) => { await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); })).on("click", ".jhs-retry-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)); + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find(((entry) => entry.id === id)); item && await this.getDependency("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); })).on("click", ".jhs-open-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find(((entry) => entry.id === id)), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find(((entry) => entry.id === id)), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); url && window.open(url, "_blank", "noopener,noreferrer"); })).on("click", ".jhs-delete-offline", (async (event) => { - await stateService.removeOfflineHistory($(event.currentTarget).data("id")), await this.renderOfflineHistory(); + await this.historyRepository.removeOffline($(event.currentTarget).data("id")), await this.renderOfflineHistory(); })), this.bindHistoryActions(root); }, "success"), end: /* @__PURE__ */ __name(() => { @@ -14156,7 +14190,7 @@ ${error.stack}` : ""); return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); } async renderActivityHistory() { - const log = await stateService.getActivityLog(), host = this.historyRoot.find("#table-container").empty(); + const log = await this.historyRepository.activity(), host = this.historyRoot.find("#table-container").empty(); if (!log.entries.length) return void host.html('
    暂无操作记录
    '); log.entries.slice().reverse().forEach(((entry) => { const reverted = entry.changes.filter(((change) => "reverted" === change.undoState)).length, conflicts = entry.changes.filter(((change) => "conflict" === change.undoState)).length; @@ -14164,7 +14198,7 @@ ${error.stack}` : ""); })); } async renderOfflineHistory() { - const history = await stateService.getOfflineHistory(), host = this.historyRoot.find("#table-container").empty(); + const history = await this.historyRepository.offline(), host = this.historyRoot.find("#table-container").empty(); if (!history.length) return void host.html('
    暂无离线任务
    '); history.slice().reverse().forEach(((item) => { const actions = $('
    ').append($('').attr("data-resource", item.resource), $('').attr("data-id", item.id), $('').attr("data-id", item.id), $('').attr("data-id", item.id)); @@ -14264,7 +14298,7 @@ ${error.stack}` : ""); const t2 = $(e2.currentTarget), n2 = t2.closest(".action-btns"), a2 = n2.attr("data-car-num"), i2 = n2.attr("data-href"), s2 = /* @__PURE__ */ __name(async (actionType) => { try { const flag = legacyActionToFlag(actionType); - await stateService.toggle(a2, flag, { type: "history-state", record: { carNum: a2, url: i2 } }), await this.reloadTable(); + await this.historyRepository.toggle(a2, flag, { type: "history-state", record: { carNum: a2, url: i2 } }), await this.reloadTable(); } catch (s3) { clog.error("历史记录操作失败:", s3), show.error("操作失败"); } @@ -14287,12 +14321,12 @@ ${error.stack}` : ""); let e3 = loading(); try { if ("delete" === i2) { - const e4 = n2.map(((e5) => e5.carNum)), t3 = await stateService.remove(e4); + const e4 = n2.map(((e5) => e5.carNum)), t3 = await this.historyRepository.remove(e4); if (!t3.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); show.ok(`已成功删除 ${t3.changed.length} 个番号`); } else { const flag = legacyActionToFlag(i2); - await stateService.patch(n2.map(((item) => item.carNum)), { [flag]: true }, { type: "history-batch-state", records: n2 }), show.ok("操作成功"); + await this.historyRepository.patch(n2.map(((item) => item.carNum)), { [flag]: true }, { type: "history-batch-state", records: n2 }), show.ok("操作成功"); } this.resetHistorySelection(), await this.reloadTable(false); } catch (t3) { @@ -14304,7 +14338,7 @@ ${error.stack}` : ""); })); } async getFilteredHistoryData(sorters = this.historySorters) { - let a2 = await storageManager.getCarList(); + let a2 = await this.historyRepository.list(); this.allCount = a2.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a2.forEach(((e2) => { const flags = normalizeStateFlags(e2.stateFlags); flags.blocked && this.filterCount++, flags.favorite && this.favoriteCount++, flags.downloaded && this.hasDownCount++, flags.watched && this.hasWatchCount++, hasAnyState(flags) || this.waitCheckCount++; @@ -14512,7 +14546,7 @@ ${error.stack}` : ""); } handleDelete(e2, t2) { utils.q(e2, `是否移除${t2}?`, (async () => { - await stateService.remove(t2), this.getDependency("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); + await this.historyRepository.remove(t2), this.getDependency("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); })); } async handleClickDetail(e2, t2) { @@ -14563,7 +14597,8 @@ ${error.stack}` : "");
    `; - layer.open({ + const dialog = this.getRuntimeService("dialog"); + dialog.open({ type: 1, title: `编辑记录: ${t2}`, area: utils.getDialogArea("sm"), @@ -14590,7 +14625,7 @@ ${error.stack}` : ""); blocked: editRoot.find("#edit-blocked").prop("checked") }; const save = /* @__PURE__ */ __name(async () => { - await stateService.patch(e2.carNum, nextFlags, { type: "history-edit", replaceMetadata: true, record: { ...e2, names: n3, url: i2, remark: s3 } }), this.tableObj.setData(), layer.close(t3); + await this.historyRepository.patch(e2.carNum, nextFlags, { type: "history-edit", replaceMetadata: true, record: { ...e2, names: n3, url: i2, remark: s3 } }), this.tableObj.setData(), dialog.close(t3); }, "save"); if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e2.carNum}?`, (() => void save())), false; await save(); @@ -15425,7 +15460,7 @@ ${error.stack}` : ""); manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), - manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }), + manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog]), manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index 29e24622..5a9f29db 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -249,7 +249,7 @@ assertIncludes(unifiedOffline, "preferredProviderId", "offline retries must pref assert(!statusImport.includes("$.ajax("), "multi-page import must use one awaited promise chain"); assertIncludes(statusImport, "return this.parseMovieList(nextPage, result)", "multi-page import recursion must be awaited by return"); assert(!history.includes('$(".layui-layer-content")'), "history events must be scoped to their own layer"); -assertIncludes(history, "stateService.toggle(a, flag", "single history actions must toggle state"); +assertIncludes(history, "this.historyRepository.toggle(a, flag", "single history actions must toggle state through HistoryRepository"); assert(!review.includes('id="reviews'), "review panels must not expose fixed instance ids"); assert(!related.includes('id="related'), "related panels must not expose fixed instance ids"); assertIncludes(statusImport, 'this.flag = "watched"', "JavDB watched import mapping"); diff --git a/src/features/history/history-repository.js b/src/features/history/history-repository.js new file mode 100644 index 00000000..393e22ca --- /dev/null +++ b/src/features/history/history-repository.js @@ -0,0 +1,19 @@ +// @ts-check + +export class HistoryRepository { + /** @param {{storage: any, state: any}} dependencies */ + constructor(dependencies) { this.storage = dependencies.storage; this.state = dependencies.state; } + list() { return this.storage.getCarList(); } + activity() { return this.state.getActivityLog(); } + offline() { return this.state.getOfflineHistory(); } + /** @param {string} transactionId */ + undo(transactionId) { return this.state.undoTransaction(transactionId); } + /** @param {string | string[]} carNums */ + remove(carNums) { return this.state.remove(carNums); } + /** @param {string} carNum @param {string} flag @param {Record} options */ + toggle(carNum, flag, options) { return this.state.toggle(carNum, flag, options); } + /** @param {string | string[]} carNums @param {Record} flags @param {Record} options */ + patch(carNums, flags, options) { return this.state.patch(carNums, flags, options); } + /** @param {string} id */ + removeOffline(id) { return this.state.removeOfflineHistory(id); } +} diff --git a/src/plugins/registry.js b/src/plugins/registry.js index d8b60da0..b0c45a60 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -55,7 +55,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), - manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }), + manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog]), manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), diff --git a/src/plugins/status/history.js b/src/plugins/status/history.js index c6a240e3..cd2ddf9a 100644 --- a/src/plugins/status/history.js +++ b/src/plugins/status/history.js @@ -4,11 +4,13 @@ import { hasAnyState, legacyActionToFlag, normalizeStateFlags } from "../../core import { stateService } from "../../core/state-service.js"; import { JhsSelect } from "../../core/ui-primitives.js"; import { HistorySelectionModel } from "../../features/history/history-selection-model.js"; +import { HistoryRepository } from "../../features/history/history-repository.js"; import { createJhsTable } from "../../ui/table/create-jhs-table.js"; export class HistoryPlugin extends BasePlugin { constructor() { super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectionModel", new HistorySelectionModel), + i(this, "historyRepository", new HistoryRepository({ storage: storageManager, state: stateService })), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", !1); } @@ -59,7 +61,7 @@ export class HistoryPlugin extends BasePlugin { openHistory() { let e = `\n
    \n
    \n \n \n \n
    \n
    \n \n \n \n \n \n
    \n
    \n
    \n `; e = e.replace('
    { - const result = await stateService.undoTransaction($(event.currentTarget).data("transaction")); + const result = await this.historyRepository.undo($(event.currentTarget).data("transaction")); show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); })).on("click", ".jhs-copy-offline", (async event => { await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); })).on("click", ".jhs-retry-offline", (async event => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)); + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry => entry.id === id)); item && await this.getDependency("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: !0, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); })).on("click", ".jhs-open-offline", (async event => { - const id = $(event.currentTarget).data("id"), item = (await stateService.getOfflineHistory()).find((entry => entry.id === id)), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry => entry.id === id)), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); url && window.open(url, "_blank", "noopener,noreferrer"); })).on("click", ".jhs-delete-offline", (async event => { - await stateService.removeOfflineHistory($(event.currentTarget).data("id")), await this.renderOfflineHistory(); + await this.historyRepository.removeOffline($(event.currentTarget).data("id")), await this.renderOfflineHistory(); })), this.bindHistoryActions(root); }, end: () => { @@ -113,7 +115,7 @@ export class HistoryPlugin extends BasePlugin { return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); } async renderActivityHistory() { - const log = await stateService.getActivityLog(), host = this.historyRoot.find("#table-container").empty(); + const log = await this.historyRepository.activity(), host = this.historyRoot.find("#table-container").empty(); if (!log.entries.length) return void host.html('
    暂无操作记录
    '); log.entries.slice().reverse().forEach((entry => { const reverted = entry.changes.filter((change => "reverted" === change.undoState)).length, conflicts = entry.changes.filter((change => "conflict" === change.undoState)).length; @@ -121,7 +123,7 @@ export class HistoryPlugin extends BasePlugin { })); } async renderOfflineHistory() { - const history = await stateService.getOfflineHistory(), host = this.historyRoot.find("#table-container").empty(); + const history = await this.historyRepository.offline(), host = this.historyRoot.find("#table-container").empty(); if (!history.length) return void host.html('
    暂无离线任务
    '); history.slice().reverse().forEach((item => { const actions = $("
    ").append($("").attr("data-resource", item.resource), $("").attr("data-id", item.id), $("").attr("data-id", item.id), $("").attr("data-id", item.id)); @@ -224,7 +226,7 @@ export class HistoryPlugin extends BasePlugin { const t = $(e.currentTarget), n = t.closest(".action-btns"), a = n.attr("data-car-num"), i = n.attr("data-href"), s = async actionType => { try { const flag = legacyActionToFlag(actionType); - await stateService.toggle(a, flag, { type: "history-state", record: { carNum: a, url: i } }), await this.reloadTable(); + await this.historyRepository.toggle(a, flag, { type: "history-state", record: { carNum: a, url: i } }), await this.reloadTable(); } catch (s) { clog.error("历史记录操作失败:", s), show.error("操作失败"); } }; if (t.hasClass("history-filterBtn")) { @@ -247,12 +249,12 @@ export class HistoryPlugin extends BasePlugin { let e = loading(); try { if ("delete" === i) { - const e = n.map((e => e.carNum)), t = await stateService.remove(e); + const e = n.map((e => e.carNum)), t = await this.historyRepository.remove(e); if (!t.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); show.ok(`已成功删除 ${t.changed.length} 个番号`); } else { const flag = legacyActionToFlag(i); - await stateService.patch(n.map((item => item.carNum)), { [flag]: !0 }, { type: "history-batch-state", records: n }), show.ok("操作成功"); + await this.historyRepository.patch(n.map((item => item.carNum)), { [flag]: !0 }, { type: "history-batch-state", records: n }), show.ok("操作成功"); } this.resetHistorySelection(), await this.reloadTable(!1); } catch (t) { @@ -265,7 +267,7 @@ export class HistoryPlugin extends BasePlugin { } /** 返回应用当前搜索、状态筛选和排序后的完整 History 数据。 */ async getFilteredHistoryData(sorters = this.historySorters) { - let a = await storageManager.getCarList(); + let a = await this.historyRepository.list(); this.allCount = a.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a.forEach((e => { const flags = normalizeStateFlags(e.stateFlags); @@ -477,7 +479,7 @@ export class HistoryPlugin extends BasePlugin { } handleDelete(e, t) { utils.q(e, `是否移除${t}?`, (async () => { - await stateService.remove(t), this.getDependency("ListPagePlugin").showCarNumBox(t), + await this.historyRepository.remove(t), this.getDependency("ListPagePlugin").showCarNumBox(t), await this.reloadTable(); })); } @@ -503,7 +505,8 @@ export class HistoryPlugin extends BasePlugin { const t = e.carNum, n = e.names || "", a = e.url || "", flags = normalizeStateFlags(e.stateFlags), s = e.remark || ""; let editRoot = $(); const c = `\n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    状态:\n \n \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n `; - layer.open({ + const dialog = this.getRuntimeService("dialog"); + dialog.open({ type: 1, title: `编辑记录: ${t}`, area: utils.getDialogArea("sm"), @@ -527,7 +530,7 @@ export class HistoryPlugin extends BasePlugin { favorite: editRoot.find("#edit-favorite").prop("checked"), downloaded: editRoot.find("#edit-downloaded").prop("checked"), watched: editRoot.find("#edit-watched").prop("checked"), blocked: editRoot.find("#edit-blocked").prop("checked") }; const save = async () => { - await stateService.patch(e.carNum, nextFlags, { type: "history-edit", replaceMetadata: !0, record: { ...e, names: n, url: i, remark: s } }), this.tableObj.setData(), layer.close(t); + await this.historyRepository.patch(e.carNum, nextFlags, { type: "history-edit", replaceMetadata: !0, record: { ...e, names: n, url: i, remark: s } }), this.tableObj.setData(), dialog.close(t); }; if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e.carNum}?`, (() => void save())), !1; await save(); diff --git a/tests/history-multistate.test.js b/tests/history-multistate.test.js index 77e392b2..f68e9770 100644 --- a/tests/history-multistate.test.js +++ b/tests/history-multistate.test.js @@ -6,12 +6,13 @@ import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; import { HistorySelectionModel } from "../src/features/history/history-selection-model.js"; +import { HistoryRepository } from "../src/features/history/history-repository.js"; function loadHistory() { const dom = new JSDOM("", { url: "https://javdb.com/users/collection_codes" }), $ = jqueryFactory(dom.window), patch = vi.fn().mockResolvedValue(), toggle = vi.fn().mockResolvedValue(), close = vi.fn(), confirm = vi.fn((event, message, callback) => callback()); const layer = { open: vi.fn(), close }, context = vm.createContext({ - document: dom.window.document, window: dom.window, $, BasePlugin: class {}, HistorySelectionModel, Tabulator: class {}, layer, - normalizeStateFlags: flags => ({ favorite: false, downloaded: false, watched: false, blocked: false, ...flags }), stateService: { patch, toggle }, legacyActionToFlag: action => ({ filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" })[action], + document: dom.window.document, window: dom.window, $, BasePlugin: class { getRuntimeService(name) { return name === "dialog" ? { open: layer.open, close: layer.close } : null; } }, HistorySelectionModel, HistoryRepository, Tabulator: class {}, layer, + normalizeStateFlags: flags => ({ favorite: false, downloaded: false, watched: false, blocked: false, ...flags }), storageManager: {}, stateService: { patch, toggle }, legacyActionToFlag: action => ({ filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" })[action], utils: { getDialogArea: () => [], q: confirm }, show: { error: vi.fn() }, clog: { debug: vi.fn() }, i: (target, key, value) => (target[key] = value), r: true, l: false, d: "filter", h: "favorite", g: "hasDown", p: "hasWatch", m: "屏蔽", v: "收藏", y: "下载", k: "观看" }); diff --git a/tests/history-repository.test.js b/tests/history-repository.test.js new file mode 100644 index 00000000..d953d84c --- /dev/null +++ b/tests/history-repository.test.js @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from "vitest"; +import { HistoryRepository } from "../src/features/history/history-repository.js"; + +describe("history repository", () => { + it("centralizes history reads and mutations without changing state contracts", async () => { + const storage = { getCarList: vi.fn(async () => [{ carNum: "ABC-1" }]) }; + const state = { + getActivityLog: vi.fn(async () => ({ entries: [] })), getOfflineHistory: vi.fn(async () => []), + undoTransaction: vi.fn(async () => ({ reverted: [], conflicts: [] })), remove: vi.fn(async () => ({ changed: ["ABC-1"] })), + toggle: vi.fn(async () => ({})), patch: vi.fn(async () => ({})), removeOfflineHistory: vi.fn(async () => true), + }; + const repository = new HistoryRepository({ storage, state }); + await expect(repository.list()).resolves.toEqual([{ carNum: "ABC-1" }]); + await repository.activity(); await repository.offline(); await repository.undo("tx-1"); await repository.remove("ABC-1"); + await repository.toggle("ABC-1", "favorite", { type: "test" }); + await repository.patch(["ABC-1"], { favorite: true }, { type: "test" }); await repository.removeOffline("offline-1"); + expect(state.undoTransaction).toHaveBeenCalledWith("tx-1"); + expect(state.toggle).toHaveBeenCalledWith("ABC-1", "favorite", { type: "test" }); + expect(state.patch).toHaveBeenCalledWith(["ABC-1"], { favorite: true }, { type: "test" }); + expect(state.removeOfflineHistory).toHaveBeenCalledWith("offline-1"); + }); +}); diff --git a/tests/history-selection.test.js b/tests/history-selection.test.js index 568f10d9..a717db42 100644 --- a/tests/history-selection.test.js +++ b/tests/history-selection.test.js @@ -6,6 +6,7 @@ import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; import { HistorySelectionModel } from "../src/features/history/history-selection-model.js"; +import { HistoryRepository } from "../src/features/history/history-repository.js"; function createRecords(count = 120) { return Array.from({ length: count }, ((_, index) => ({ @@ -39,7 +40,7 @@ function loadHistory(records = createRecords()) { `, { url: "https://javdb.com/users/collection_codes" }); const $ = jqueryFactory(dom.window), getCarList = vi.fn().mockImplementation((async () => records.map((item => ({ ...item }))))), patch = vi.fn().mockResolvedValue({ changed: records.map((item => item.carNum)) }), remove = vi.fn().mockResolvedValue({ changed: [] }), - confirmation = vi.fn(((event, message, callback) => callback())), close = vi.fn(), show = { + confirmation = vi.fn(((event, message, callback) => callback())), close = vi.fn(), layer = { open: vi.fn(), close }, show = { ok: vi.fn(), info: vi.fn(), error: vi.fn() @@ -48,10 +49,10 @@ function loadHistory(records = createRecords()) { document: dom.window.document, window: dom.window, $, - BasePlugin: class {}, - HistorySelectionModel, + BasePlugin: class { getRuntimeService(name) { return name === "dialog" ? { open: layer.open, close: layer.close } : null; } }, + HistorySelectionModel, HistoryRepository, Tabulator: class {}, - layer: {}, + layer, storageManager: { getCarList }, stateService: { patch, remove, toggle: vi.fn() }, normalizeCarNum: value => String(value || "").trim().toUpperCase(), From 8df62cbc4bd5fcb0516a09d56d0aed3e0a5bac7d Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:26:13 +0800 Subject: [PATCH 007/130] =?UTF-8?q?Refactor:=20=E6=94=B6=E6=95=9B=20Stats?= =?UTF-8?q?=20=E6=95=B0=E6=8D=AE=E4=B8=8E=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 新增 StatsRepository,并发读取统计所需的领域快照 - Stats 对话框改用声明注入的 DialogService - 补充 Repository 契约测试并更新 Stats VM 注入 修改原因: - 隔离 Stats UI 对 StorageManager、StateService 和 Layer 实现的直接访问 - 保持现有统计口径和跨功能按钮行为不变 验证: - npm run check 通过(92 个测试文件,523 项测试) - npm run check:browser 通过(本机 Edge:13 passed,5 skipped) - codegraph sync、git diff --check 与根/dist 产物哈希一致性检查通过 --- JHS.user.js | 32 ++++++++++++++++++++++---- src/features/stats/stats-repository.js | 16 +++++++++++++ src/plugins/registry.js | 2 +- src/plugins/stats/stats.js | 12 +++++++--- tests/stats-repository.test.js | 25 ++++++++++++++++++++ tests/stats-scope.test.js | 9 ++++++-- 6 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 src/features/stats/stats-repository.js create mode 100644 tests/stats-repository.test.js diff --git a/JHS.user.js b/JHS.user.js index 8e74c5c0..2c16f6c8 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -13178,8 +13178,31 @@ ${error.stack}` : ""); __name(_OneTwoThreeOfflinePlugin, "OneTwoThreeOfflinePlugin"); var OneTwoThreeOfflinePlugin = _OneTwoThreeOfflinePlugin; + // src/features/stats/stats-repository.js + var _StatsRepository = class _StatsRepository { + constructor(dependencies) { + this.storage = dependencies.storage; + this.state = dependencies.state; + } + async loadLibrarySnapshot() { + const [cars, actresses, blacklist, activity] = await Promise.all([ + this.storage.getCarList(), + this.storage.getFavoriteActressList(), + this.storage.getBlacklist(), + this.state.getActivityLog() + ]); + return Object.freeze({ cars, actresses, blacklist, activity }); + } + }; + __name(_StatsRepository, "StatsRepository"); + var StatsRepository = _StatsRepository; + // src/plugins/stats/stats.js var _StatsPlugin = class _StatsPlugin extends BasePlugin { + constructor() { + super(...arguments); + this.statsRepository = new StatsRepository({ storage: storageManager, state: stateService }); + } getName() { return "StatsPlugin"; } @@ -13212,7 +13235,7 @@ ${error.stack}` : ""); } async openDialog() { const diagnostics = this.getRuntimeService("diagnostics").exportSnapshot(); - const cars = await storageManager.getCarList(), actresses = await storageManager.getFavoriteActressList(), blacklist = await storageManager.getBlacklist(), activity = await stateService.getActivityLog(), total = cars.length; + const { cars, actresses, blacklist, activity } = await this.statsRepository.loadLibrarySnapshot(), total = cars.length; const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; cars.forEach(((car) => { const flags = normalizeStateFlags(car.stateFlags); @@ -13263,13 +13286,14 @@ ${error.stack}` : "");

    活动趋势

    ${coverageNote}

    ${trend7.identified}近 7 天新增鉴定
    ${trend7.downloaded}近 7 天标记下载
    ${trend7.watched}近 7 天标记观看
    ${trend30.identified}近 30 天新增鉴定
    ${trend30.downloaded}近 30 天标记下载
    ${trend30.watched}近 30 天标记观看
    ${topActresses.length ? `

    Top 10 演员

    ${topActresses.map(((item) => row(item.name, item.count, topValue, "var(--jhs-accent)", new URL(item.starId ? `/actors/${encodeURIComponent(item.starId)}` : `/search?q=${encodeURIComponent(item.name)}`, javDbUrl).href))).join("")}
    ` : ""}
    `; - layer.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: false, area: utils.getDialogArea("lg"), anim: -1, success: /* @__PURE__ */ __name((layerElement, layerIndex) => { + const dialog = this.getRuntimeService("dialog"); + dialog.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: false, area: utils.getDialogArea("lg"), anim: -1, success: /* @__PURE__ */ __name((layerElement, layerIndex) => { $(layerElement).find(".jhs-stats__bar").each((function() { $(this).css({ "--jhs-value": `${$(this).data("width")}%`, "--jhs-bar": $(this).data("color") }); })); $(layerElement).find("button.jhs-stats__metric[data-action]").on("click", ((event) => { const metric = $(event.currentTarget), action = metric.data("action"); - layer.close(layerIndex); + dialog.close(layerIndex); if ("new-video" === action) return this.getDependency("NewVideoPlugin").openDialog(); if ("filter" === action) this.getDependency("ListPagePlugin").setQuickFilter(metric.data("filter")); })); @@ -15486,7 +15510,7 @@ ${error.stack}` : ""); manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), - manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics]), + manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host]), manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), diff --git a/src/features/stats/stats-repository.js b/src/features/stats/stats-repository.js new file mode 100644 index 00000000..2c41a440 --- /dev/null +++ b/src/features/stats/stats-repository.js @@ -0,0 +1,16 @@ +// @ts-check + +export class StatsRepository { + /** @param {{storage: any, state: any}} dependencies */ + constructor(dependencies) { this.storage = dependencies.storage; this.state = dependencies.state; } + + async loadLibrarySnapshot() { + const [cars, actresses, blacklist, activity] = await Promise.all([ + this.storage.getCarList(), + this.storage.getFavoriteActressList(), + this.storage.getBlacklist(), + this.state.getActivityLog(), + ]); + return Object.freeze({ cars, actresses, blacklist, activity }); + } +} diff --git a/src/plugins/registry.js b/src/plugins/registry.js index b0c45a60..2076ad74 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -81,7 +81,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), - manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics]), + manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host]), manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), diff --git a/src/plugins/stats/stats.js b/src/plugins/stats/stats.js index c630816e..f8a0c870 100644 --- a/src/plugins/stats/stats.js +++ b/src/plugins/stats/stats.js @@ -2,8 +2,13 @@ import { escapeHtml } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; import { stateService } from "../../core/state-service.js"; +import { StatsRepository } from "../../features/stats/stats-repository.js"; export class StatsPlugin extends BasePlugin { + constructor() { + super(...arguments); + this.statsRepository = new StatsRepository({ storage: storageManager, state: stateService }); + } getName() { return "StatsPlugin"; } async initCss() { return ` @@ -32,7 +37,7 @@ export class StatsPlugin extends BasePlugin { } async openDialog() { const diagnostics = this.getRuntimeService("diagnostics").exportSnapshot(); - const cars = await storageManager.getCarList(), actresses = await storageManager.getFavoriteActressList(), blacklist = await storageManager.getBlacklist(), activity = await stateService.getActivityLog(), total = cars.length; + const { cars, actresses, blacklist, activity } = await this.statsRepository.loadLibrarySnapshot(), total = cars.length; const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; cars.forEach((car => { const flags = normalizeStateFlags(car.stateFlags); flags.blocked && counts.manualBlocked++, flags.favorite && counts.favorite++, flags.downloaded && counts.hasDown++, flags.watched && counts.hasWatch++, hasAnyState(flags) || counts.pending++; })); const actressCounts = new Map; @@ -72,11 +77,12 @@ export class StatsPlugin extends BasePlugin {

    活动趋势

    ${coverageNote}

    ${trend7.identified}近 7 天新增鉴定
    ${trend7.downloaded}近 7 天标记下载
    ${trend7.watched}近 7 天标记观看
    ${trend30.identified}近 30 天新增鉴定
    ${trend30.downloaded}近 30 天标记下载
    ${trend30.watched}近 30 天标记观看
    ${topActresses.length ? `

    Top 10 演员

    ${topActresses.map((item => row(item.name, item.count, topValue, "var(--jhs-accent)", new URL(item.starId ? `/actors/${encodeURIComponent(item.starId)}` : `/search?q=${encodeURIComponent(item.name)}`, javDbUrl).href))).join("")}
    ` : ""}
    `; - layer.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: !1, area: utils.getDialogArea("lg"), anim: -1, success: (layerElement, layerIndex) => { + const dialog = this.getRuntimeService("dialog"); + dialog.open({ type: 1, title: "统计", content: dialogHtml, scrollbar: !1, area: utils.getDialogArea("lg"), anim: -1, success: (layerElement, layerIndex) => { $(layerElement).find(".jhs-stats__bar").each((function() { $(this).css({ "--jhs-value": `${$(this).data("width")}%`, "--jhs-bar": $(this).data("color") }); })); $(layerElement).find("button.jhs-stats__metric[data-action]").on("click", (event => { const metric = $(event.currentTarget), action = metric.data("action"); - layer.close(layerIndex); + dialog.close(layerIndex); if ("new-video" === action) return this.getDependency("NewVideoPlugin").openDialog(); if ("filter" === action) this.getDependency("ListPagePlugin").setQuickFilter(metric.data("filter")); })); diff --git a/tests/stats-repository.test.js b/tests/stats-repository.test.js new file mode 100644 index 00000000..04876f4a --- /dev/null +++ b/tests/stats-repository.test.js @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { StatsRepository } from "../src/features/stats/stats-repository.js"; + +describe("stats repository", () => { + it("loads one consistent library snapshot through the domain storage contracts", async () => { + const storage = { + getCarList: vi.fn(async () => [{ carNum: "ABC-1" }]), + getFavoriteActressList: vi.fn(async () => [{ starId: "actor-1" }]), + getBlacklist: vi.fn(async () => [{ starId: "actor-2" }]), + }; + const state = { getActivityLog: vi.fn(async () => ({ entries: [] })) }; + const repository = new StatsRepository({ storage, state }); + + await expect(repository.loadLibrarySnapshot()).resolves.toEqual({ + cars: [{ carNum: "ABC-1" }], + actresses: [{ starId: "actor-1" }], + blacklist: [{ starId: "actor-2" }], + activity: { entries: [] }, + }); + expect(storage.getCarList).toHaveBeenCalledOnce(); + expect(storage.getFavoriteActressList).toHaveBeenCalledOnce(); + expect(storage.getBlacklist).toHaveBeenCalledOnce(); + expect(state.getActivityLog).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/stats-scope.test.js b/tests/stats-scope.test.js index 421a9db8..20e4de79 100644 --- a/tests/stats-scope.test.js +++ b/tests/stats-scope.test.js @@ -5,6 +5,7 @@ import vm from "node:vm"; import jqueryFactory from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it, vi } from "vitest"; +import { StatsRepository } from "../src/features/stats/stats-repository.js"; function loadStatsPlugin() { const dom = new JSDOM("", { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window); @@ -13,7 +14,11 @@ function loadStatsPlugin() { const beans = { ListPagePlugin: listPage, NewVideoPlugin: newVideo, OtherSitePlugin: { getJavDbUrl: vi.fn(async () => "https://javdb.com") } }; class BasePlugin { getBean(name) { return beans[name]; } - getRuntimeService() { return { exportSnapshot: () => ({ activeFeatures: ["list"], errors: [] }) }; } + getRuntimeService(name) { + if (name === "diagnostics") return { exportSnapshot: () => ({ activeFeatures: ["list"], errors: [] }) }; + if (name === "dialog") return { open: layer.open, close: layer.close }; + return null; + } } const layer = { close: vi.fn(), @@ -24,7 +29,7 @@ function loadStatsPlugin() { }) }; const context = vm.createContext({ - window: dom.window, document: dom.window.document, $, BasePlugin, layer, URL, + window: dom.window, document: dom.window.document, $, BasePlugin, StatsRepository, layer, URL, storageManager: { getCarList: vi.fn(async () => [ { stateFlags: { blocked: true } }, { stateFlags: { favorite: true, downloaded: true, watched: true } }, { stateFlags: {} } ]), getFavoriteActressList: vi.fn(async () => [ {} ]), getBlacklist: vi.fn(async () => [ {}, {} ]) From eaebaec2a27145ba5116b05c2fcea57646f09dd3 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:28:41 +0800 Subject: [PATCH 008/130] =?UTF-8?q?Refactor:=20=E6=94=B6=E6=95=9B=20Librar?= =?UTF-8?q?y=20=E5=AF=B9=E8=AF=9D=E6=A1=86=E5=B9=B3=E5=8F=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - Blacklist 与以图搜图对话框改用声明注入的 DialogService - 为两个 Contribution 补充 SERVICE.dialog 依赖声明 - 保持任务、筛选、上传与对话框交互逻辑不变 修改原因: - 继续消除 Feature 对 Layer 平台实现的直接访问 - 让运行时依赖保持显式并受 manifest 校验 验证: - npm run check 通过(92 个测试文件,523 项测试) - npm run check:browser 通过(本机 Edge:13 passed,5 skipped) - codegraph sync、git diff --check 与根/dist 产物哈希一致性检查通过 --- JHS.user.js | 8 ++++---- src/plugins/avatar/search-by-image.js | 2 +- src/plugins/blacklist/blacklist.js | 2 +- src/plugins/registry.js | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 2c16f6c8..2f3bdcb8 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -4065,7 +4065,7 @@ return "\n \n "; } open(e2) { - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "以图识图", content: '\n
    \n
    \n
    \n

    拖拽图片到此处 或 点击按钮选择图片

    \n

    也可以直接 Ctrl+V 粘贴图片或 图片URL

    \n
    \n \n \n
    \n \n
    \n \n
    \n \n
    \n \n
    \n \n \n
    \n \n
    \n

    请选择识图网站:

    \n
    \n
    \n
    \n \n
    \n ', @@ -7906,7 +7906,7 @@
    `; - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "演员黑名单", content: n2, @@ -15489,7 +15489,7 @@ ${error.stack}` : ""); manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }), - manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }), + manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog]), manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), @@ -15506,7 +15506,7 @@ ${error.stack}` : ""); manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), - manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), diff --git a/src/plugins/avatar/search-by-image.js b/src/plugins/avatar/search-by-image.js index 93f21295..b6ed0a20 100644 --- a/src/plugins/avatar/search-by-image.js +++ b/src/plugins/avatar/search-by-image.js @@ -24,7 +24,7 @@ export class SearchByImagePlugin extends BasePlugin { return "\n \n "; } open(e) { - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "以图识图", content: '\n
    \n
    \n
    \n

    拖拽图片到此处 或 点击按钮选择图片

    \n

    也可以直接 Ctrl+V 粘贴图片或 图片URL

    \n
    \n \n \n
    \n \n
    \n \n
    \n \n
    \n \n
    \n \n \n
    \n \n
    \n

    请选择识图网站:

    \n
    \n
    \n
    \n \n
    \n ', diff --git a/src/plugins/blacklist/blacklist.js b/src/plugins/blacklist/blacklist.js index dcb83ac1..b9d69b96 100644 --- a/src/plugins/blacklist/blacklist.js +++ b/src/plugins/blacklist/blacklist.js @@ -106,7 +106,7 @@ export class BlacklistPlugin extends BasePlugin { async openBlacklistDialog() { const e = this.getDependency("TaskPlugin"), t = await storageManager.getSetting(); let n = `\n
    \n
    \n
    \n \n \n
    \n
    \n \n \n \n \n \n
    \n\n
    \n
    \n
    \n `; - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "演员黑名单", content: n, diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 2076ad74..f1df3ed5 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -60,7 +60,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }), - manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }), + manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog]), manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), @@ -77,7 +77,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), - manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), From 354f918bb968bcf8cc5c23613035ecb75859fa29 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:30:39 +0800 Subject: [PATCH 009/130] =?UTF-8?q?Refactor:=20=E6=B3=A8=E5=85=A5=20Top250?= =?UTF-8?q?=20=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - Top250 登录弹窗改由声明注入的 DialogService 打开和关闭 - 保留既有加密凭证写入行的 architecture fingerprint - 为 discovery.top250 补充 SERVICE.dialog 依赖 修改原因: - 消除 Top250 对 Layer 打开入口的直接依赖 - 避免把既有 localStorage 凭证债务伪装成新位置 验证: - npm run check 通过(92 个测试文件,523 项测试) - codegraph sync、git diff --check 与根/dist 产物哈希一致性检查通过 --- JHS.user.js | 8 +++++--- src/plugins/external-search/top250.js | 4 +++- src/plugins/registry.js | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 2f3bdcb8..06101be6 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -10361,7 +10361,9 @@ ${error.stack}` : ""); }) : window.location.href = s2; } openLoginDialog({ onSuccess = null } = {}) { - layer.open({ + const dialog = this.getRuntimeService("dialog"); + const layer2 = { close: /* @__PURE__ */ __name((index) => dialog.close(index), "close") }; + dialog.open({ type: 1, title: "JavDB", closeBtn: 1, @@ -10413,7 +10415,7 @@ ${error.stack}` : ""); if (1 !== n3) throw clog.error("登录失败", e4), new Error(e4.message); { let n4 = e4.data.token; - await localStorage.setItem(me, await encryptData(n4)), show.ok("登录成功"), layer.close(t2), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; + await localStorage.setItem(me, await encryptData(n4)), show.ok("登录成功"), layer2.close(t2), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; } } })).catch(((e4) => { @@ -15488,7 +15490,7 @@ ${error.stack}` : ""); manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), - manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }), + manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog]), manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), diff --git a/src/plugins/external-search/top250.js b/src/plugins/external-search/top250.js index 6c298ae7..a17898c9 100644 --- a/src/plugins/external-search/top250.js +++ b/src/plugins/external-search/top250.js @@ -106,7 +106,9 @@ export class Top250Plugin extends BasePlugin { }) : window.location.href = s; } openLoginDialog({ onSuccess = null } = {}) { - layer.open({ + const dialog = this.getRuntimeService("dialog"); + const layer = { close: index => dialog.close(index) }; + dialog.open({ type: 1, title: "JavDB", closeBtn: 1, diff --git a/src/plugins/registry.js b/src/plugins/registry.js index f1df3ed5..7c409369 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -59,7 +59,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), - manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }), + manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog]), manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), From 6eb5f87d7a49890c8854873ec62d73e064cbeaa7 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:42:30 +0800 Subject: [PATCH 010/130] =?UTF-8?q?Refactor:=20=E7=BB=9F=E4=B8=80=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E6=93=8D=E4=BD=9C=E5=AF=B9=E8=AF=9D=E6=A1=86=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - UnifiedOffline、115 匹配与详情资源操作改用声明注入的 DialogService - 为三个 Contribution 补充 SERVICE.dialog 依赖和回归测试 - Browser Harness 固定阻断网络时的 retry/circuit 设置,消除请求预算时序波动 修改原因: - 继续消除 Feature 对 Layer 平台实现的直接访问 - 保证 deterministic fixture 统计逻辑启动请求,不受后台重试时点影响 验证: - npm run check 通过(92 个测试文件,525 项测试) - npm run check:browser 通过(本机 Edge:13 passed,5 skipped) - JavBus Browser 用例重复三轮,共 18 项通过 - codegraph sync、git diff --check 与根/dist 产物哈希一致性检查通过 --- JHS.user.js | 21 ++++++++++++--------- src/plugins/offline/unified-offline.js | 5 +++-- src/plugins/one-one-five/plugins.js | 2 +- src/plugins/registry.js | 6 +++--- src/plugins/status/detail-page-button.js | 8 +++++--- tests/browser/harness/runtime.js | 2 ++ tests/detail-car-num.test.js | 6 ++++++ tests/offline-provider.test.js | 23 ++++++++++++++++++++--- tests/one-one-five.test.js | 2 ++ 9 files changed, 54 insertions(+), 21 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 06101be6..1f278413 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -12909,11 +12909,12 @@ ${error.stack}` : ""); const mode = await storageManager.getSetting("offlineProviderMode", "ask"), preferred = candidates.find(((candidate) => candidate.provider.id === mode)); if (preferred) return preferred; return new Promise(((resolve) => { + const dialog = this.getRuntimeService("dialog"); const content = $('

    选择离线服务

    '), toolbar = content.find(".jhs-toolbar"); candidates.forEach(((candidate) => toolbar.append($('').text(`${candidate.provider.name} · ${"ready" === candidate.availability.authState ? "已就绪" : "状态未知"}`).on("click", (() => { - layer.close(index), resolve(candidate); + dialog.close(index), resolve(candidate); }))))); - const index = layer.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: /* @__PURE__ */ __name(() => resolve(null), "cancel") }); + const index = dialog.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: /* @__PURE__ */ __name(() => resolve(null), "cancel") }); })); } getVideoInfo(button) { @@ -13022,7 +13023,7 @@ ${error.stack}` : ""); if (!matches.length) return this.matchCard(element, true); if (1 === matches.length) return window.open(build115PlayUrl(matches[0]), "_blank"); const links = matches.map(((match) => `${escapeHtml(match.name)}`)).join("
    "); - layer.open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea(["560px", "auto"]) }); + this.getRuntimeService("dialog").open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea(["560px", "auto"]) }); })); } catch (error) { element.dataset.jhs115State = "failed", card.find(".jhs-115-list-match").remove(), card.find(".video-title").first().prepend($('').one("click", (() => this.matchCard(element, true)))), clog.warn("115 单卡匹配失败", error); @@ -13642,7 +13643,7 @@ ${error.stack}` : ""); const workspaceSlot = this.getDependency("DetailWorkspacePlugin")?.getSlot("summary-actions"); workspaceSlot?.length ? workspaceSlot.append(n2) : r ? $(".tabs").after(n2) : l && $("#mag-submit-show").before(n2), $("#magnetSearchBtn").on("click", (async () => { let t3 = await this.getDependency("MagnetHubPlugin").createMagnetHub(e2.carNum); - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "磁力搜索 " + e2.carNum, content: '
    ', @@ -13689,10 +13690,11 @@ ${error.stack}` : ""); return detailStateController.requestToggle(this.getStateBinding(), "watched", event); } searchXunLeiSubtitle(e2) { + const dialog = this.getRuntimeService("dialog"); let t2 = loading(); gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e2}`).then(((t3) => { let n2 = t3.data; - n2 && 0 !== n2.length ? layer.open({ + n2 && 0 !== n2.length ? dialog.open({ type: 1, title: "迅雷字幕", content: '\n
    \n
    \n
    \n ', @@ -13779,6 +13781,7 @@ ${error.stack}` : ""); if (!e2) return void clog.error("未提供文件URL"); const n2 = e2.split(".").pop().toLowerCase(); if ("ass" === n2 || "srt" === n2) try { + const dialog = this.getRuntimeService("dialog"); let a2 = await gmHttp.get(e2), i2 = "字幕预览"; "ass" === n2 ? i2 = "ASS字幕预览 - " + t2 : "srt" === n2 && (i2 = "SRT字幕预览 - " + t2); const s2 = a2.split("\n"); @@ -13790,7 +13793,7 @@ ${error.stack}` : ""); `; })); const l2 = o2; - layer.open({ + dialog.open({ type: 1, title: i2, area: utils.getResponsiveArea(["80%", "80%"]), @@ -15498,7 +15501,7 @@ ${error.stack}` : ""); manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }), manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), - manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), + manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [SERVICE.settings]), manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), @@ -15514,8 +15517,8 @@ ${error.stack}` : ""); manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), - manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host]), - manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), + manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host, SERVICE.dialog]), + manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [SERVICE.dialog]), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), diff --git a/src/plugins/offline/unified-offline.js b/src/plugins/offline/unified-offline.js index 69bab602..d8c0e4d2 100644 --- a/src/plugins/offline/unified-offline.js +++ b/src/plugins/offline/unified-offline.js @@ -72,9 +72,10 @@ export class UnifiedOfflinePlugin extends BasePlugin { const mode = await storageManager.getSetting("offlineProviderMode", "ask"), preferred = candidates.find((candidate => candidate.provider.id === mode)); if (preferred) return preferred; return new Promise((resolve => { + const dialog = this.getRuntimeService("dialog"); const content = $('

    选择离线服务

    '), toolbar = content.find(".jhs-toolbar"); - candidates.forEach((candidate => toolbar.append($("").text(`${candidate.provider.name} · ${"ready" === candidate.availability.authState ? "已就绪" : "状态未知"}`).on("click", (() => { layer.close(index), resolve(candidate); }))))); - const index = layer.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: () => resolve(null) }); + candidates.forEach((candidate => toolbar.append($("").text(`${candidate.provider.name} · ${"ready" === candidate.availability.authState ? "已就绪" : "状态未知"}`).on("click", (() => { dialog.close(index), resolve(candidate); }))))); + const index = dialog.open({ type: 1, title: "选择离线服务", content, area: utils.getDialogArea("sm"), cancel: () => resolve(null) }); })); } getVideoInfo(button) { diff --git a/src/plugins/one-one-five/plugins.js b/src/plugins/one-one-five/plugins.js index d3251d59..96f8c7ed 100644 --- a/src/plugins/one-one-five/plugins.js +++ b/src/plugins/one-one-five/plugins.js @@ -54,7 +54,7 @@ export class OneOneFiveMatchPlugin extends BasePlugin { card.find(".jhs-115-list-match").remove(); const badge = $("").text(matches.length ? `匹配${matches.length}个` : "未匹配").data("matches", matches); card.find(".video-title").first().prepend(badge), element.dataset.jhs115State = "matched"; - badge.on("click", (() => { if (!matches.length) return this.matchCard(element, !0); if (1 === matches.length) return window.open(build115PlayUrl(matches[0]), "_blank"); const links = matches.map((match => `${escapeHtml(match.name)}`)).join("
    "); layer.open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea([ "560px", "auto" ]) }); })); + badge.on("click", (() => { if (!matches.length) return this.matchCard(element, !0); if (1 === matches.length) return window.open(build115PlayUrl(matches[0]), "_blank"); const links = matches.map((match => `${escapeHtml(match.name)}`)).join("
    "); this.getRuntimeService("dialog").open({ type: 1, title: `${carNum} 115匹配`, content: `
    ${links}
    `, area: utils.getResponsiveArea([ "560px", "auto" ]) }); })); } catch (error) { element.dataset.jhs115State = "failed", card.find(".jhs-115-list-match").remove(), card.find(".video-title").first().prepend($('').one("click", (() => this.matchCard(element, !0)))), clog.warn("115 单卡匹配失败", error); } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 7c409369..1f4a187f 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -67,7 +67,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }), manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), - manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie]), + manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [SERVICE.settings]), manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), @@ -83,8 +83,8 @@ export const legacyContributionManifests = Object.freeze([ manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), - manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host]), - manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }), + manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host, SERVICE.dialog]), + manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [SERVICE.dialog]), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), diff --git a/src/plugins/status/detail-page-button.js b/src/plugins/status/detail-page-button.js index 0bae8838..3cba4eb3 100644 --- a/src/plugins/status/detail-page-button.js +++ b/src/plugins/status/detail-page-button.js @@ -28,7 +28,7 @@ export class DetailPageButtonPlugin extends BasePlugin { const workspaceSlot = this.getDependency("DetailWorkspacePlugin")?.getSlot("summary-actions"); workspaceSlot?.length ? workspaceSlot.append(n) : r ? $(".tabs").after(n) : l && $("#mag-submit-show").before(n), $("#magnetSearchBtn").on("click", (async () => { let t = await this.getDependency("MagnetHubPlugin").createMagnetHub(e.carNum); - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "磁力搜索 " + e.carNum, content: '
    ', @@ -78,10 +78,11 @@ export class DetailPageButtonPlugin extends BasePlugin { return detailStateController.requestToggle(this.getStateBinding(), "watched", event); } searchXunLeiSubtitle(e) { + const dialog = this.getRuntimeService("dialog"); let t = loading(); gmHttp.get(`https://api-shoulei-ssl.xunlei.com/oracle/subtitle?gcid=&cid=&name=${e}`).then((t => { let n = t.data; - n && 0 !== n.length ? layer.open({ + n && 0 !== n.length ? dialog.open({ type: 1, title: "迅雷字幕", content: '\n
    \n
    \n
    \n ', @@ -168,6 +169,7 @@ export class DetailPageButtonPlugin extends BasePlugin { if (!e) return void clog.error("未提供文件URL"); const n = e.split(".").pop().toLowerCase(); if ("ass" === n || "srt" === n) try { + const dialog = this.getRuntimeService("dialog"); let a = await gmHttp.get(e), i = "字幕预览"; "ass" === n ? i = "ASS字幕预览 - " + t : "srt" === n && (i = "SRT字幕预览 - " + t); const s = a.split("\n"); @@ -178,7 +180,7 @@ export class DetailPageButtonPlugin extends BasePlugin { o += `${n}. ${e}\n`; })); const l = o; - layer.open({ + dialog.open({ type: 1, title: i, area: utils.getResponsiveArea([ "80%", "80%" ]), diff --git a/tests/browser/harness/runtime.js b/tests/browser/harness/runtime.js index 2570b637..b019a7ed 100644 --- a/tests/browser/harness/runtime.js +++ b/tests/browser/harness/runtime.js @@ -34,6 +34,8 @@ export async function injectUserscriptRuntime(page, options = {}) { const forage = window.localforage.createInstance({ driver: window.localforage.INDEXEDDB, name: "JAV-JHS", version: 1, storeName: "appData" }); await forage.setItem("setting", { translateTitle: "no", + httpRetryCount: 1, + circuitBreakerThreshold: 1, ...(disabledPlugins?.length ? { disabledPlugins: JSON.stringify(disabledPlugins) } : {}), }); }, options.disabledPlugins || []); diff --git a/tests/detail-car-num.test.js b/tests/detail-car-num.test.js index 935d884b..dd3abfbc 100644 --- a/tests/detail-car-num.test.js +++ b/tests/detail-car-num.test.js @@ -227,6 +227,12 @@ describe("source regression contracts", () => { expect(translate).not.toContain("getPageInfo()?.carNum"); }); + it("routes detail state-action dialogs through the declared DialogService", () => { + const source = readTestFile(join(repoRoot, "src/plugins/status/detail-page-button.js"), "utf8"); + expect(source).toContain('getRuntimeService("dialog")'); + expect(source).not.toContain("layer.open"); + }); + it("treats opted-in HTTP 404 responses as neutral results before retry accounting", () => { const source = readTestFile(join(repoRoot, "src/core/http.js"), "utf8"); expect(source).toMatch(/404 === e\.status && requestOptions\.ignoreNotFound[\s\S]{0,80}a\(null\)/); diff --git a/tests/offline-provider.test.js b/tests/offline-provider.test.js index 7b77e09d..0f084e6b 100644 --- a/tests/offline-provider.test.js +++ b/tests/offline-provider.test.js @@ -14,19 +14,23 @@ function loadRegistry() { function loadOfflinePlugin(submit, history = vi.fn(async () => {})) { const dom = new JSDOM(''), $ = jqueryFactory(dom.window); - class BasePlugin {} + const layer = { + close: vi.fn(), + open: vi.fn(options => { options.content.appendTo("body"); return 7; }), + }; + class BasePlugin { getRuntimeService(name) { return name === "dialog" ? { open: layer.open, close: layer.close } : null; } } const context = vm.createContext({ window: dom.window, document: dom.window.document, $, BasePlugin, Map, Array, Date, TypeError, r: true, l: false, setTimeout, clearTimeout, stateService: { appendOfflineHistory: history, patch: vi.fn() }, - show: { ok: vi.fn(), error: vi.fn() }, utils: { q: vi.fn() }, storageManager: {}, layer: {}, + show: { ok: vi.fn(), error: vi.fn() }, utils: { q: vi.fn(), getDialogArea: vi.fn(() => []) }, storageManager: { getSetting: vi.fn(async () => "ask") }, layer, OneOneFiveClient: class {}, getDetailResourceAdapter: vi.fn(), jhsEventBus: { on: vi.fn() }, readListItem: vi.fn() }); const source = readTestFile(join(import.meta.dirname, "../src/plugins/offline/unified-offline.js"), "utf8"); vm.runInContext(`${source};globalThis.TestOfflinePlugin=UnifiedOfflinePlugin;`, context); const plugin = new context.TestOfflinePlugin(), provider = { id: "115", name: "115", submit }; plugin.registry = { getCandidates: vi.fn(async () => [ { provider, availability: { authState: "ready" } } ]), updateAvailability: vi.fn() }; - return { $, button: $("button"), context, history, plugin }; + return { $, button: $("button"), context, history, layer, plugin }; } describe("offline provider registry", () => { @@ -62,6 +66,19 @@ describe("offline provider registry", () => { }); describe("unified offline button state", () => { + it("uses the declared dialog service when the user must select a provider", async () => { + const { $, layer, plugin } = loadOfflinePlugin(vi.fn()); + const candidates = [ + { provider: { id: "123", name: "123 云盘" }, availability: { authState: "ready" } }, + { provider: { id: "115", name: "115" }, availability: { authState: "unknown" } }, + ]; + const selected = plugin.chooseCandidate({}, candidates); + await vi.waitFor(() => expect(layer.open).toHaveBeenCalledOnce()); + $(".jhs-toolbar button").eq(1).trigger("click"); + await expect(selected).resolves.toBe(candidates[1]); + expect(layer.close).toHaveBeenCalledWith(7); + }); + it("keeps focusable success feedback busy and then restores the original idle state", async () => { vi.useFakeTimers(); try { diff --git a/tests/one-one-five.test.js b/tests/one-one-five.test.js index 5019d678..935f8eb0 100644 --- a/tests/one-one-five.test.js +++ b/tests/one-one-five.test.js @@ -33,6 +33,8 @@ describe("115 domain", () => { expect(source).toContain('jhsEventBus.on("list-items-added"'); expect(source).not.toContain("new MutationObserver"); expect(source).not.toContain("gmHttp"); + expect(source).toContain('getRuntimeService("dialog").open'); + expect(source).not.toContain("layer.open"); }); it("classifies addOffline errors", () => { const api = load115(); From 3bd840bcaa58e191205182d85f135b95b4ef10d6 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:45:21 +0800 Subject: [PATCH 011/130] =?UTF-8?q?Refactor:=20=E7=BB=9F=E4=B8=80=E6=96=B0?= =?UTF-8?q?=E4=BD=9C=E5=93=81=E5=AF=B9=E8=AF=9D=E6=A1=86=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - NewVideo 工作区、演员编辑、CDN 选择和头像选择统一使用 DialogService - 为 discovery.new-video 声明 SERVICE.dialog 依赖 - 增加源码回归契约,禁止 Layer open/close 回流 修改原因: - 消除 Discovery Feature 对 Layer 平台实现的直接访问 - 保持现有工作区、缓存、演员编辑和任务调度行为不变 验证: - npm run check 通过(92 个测试文件,526 项测试) - npm run check:browser 通过(本机 Edge:13 passed,5 skipped) - codegraph sync、git diff --check 与根/dist 产物哈希一致性检查通过 --- JHS.user.js | 22 ++++++++++++---------- src/plugins/new-video/new-video.js | 20 +++++++++++--------- src/plugins/registry.js | 2 +- tests/new-video-workspace.test.js | 7 +++++++ 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 1f278413..8963485a 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -11726,7 +11726,7 @@ ${error.stack}` : "");
    `; - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: '新作品检测', content: o2, @@ -12106,6 +12106,7 @@ ${error.stack}` : ""); }), void this.hydrateVisibleCovers(s2, generation); } async editActress(e2) { + const dialog = this.getRuntimeService("dialog"); const t2 = String(e2.name || ""), n2 = normalizeHttpUrl(e2.avatar, this.nvJavDbUrl) || "", a2 = String(e2.remark || ""), i2 = Array.isArray(e2.allName) ? e2.allName.join(",") : "", s2 = Array.isArray(e2.newVideoList) ? e2.newVideoList.map(((e3) => "string" == typeof e3 ? e3 : e3.carNum)).join(",") : "", o2 = String(e2.starId || ""), l2 = e2.actressType || "", safe = /* @__PURE__ */ __name((value) => escapeHtml(String(value || "")), "safe"), c2 = `
    @@ -12154,7 +12155,7 @@ ${error.stack}` : "");
    `; - layer.open({ + dialog.open({ type: 1, title: `编辑女优: ${safe(t2)} (${safe(o2)})`, area: utils.getDialogArea("sm"), @@ -12192,7 +12193,7 @@ ${error.stack}` : "");

    切换源会清除本地缓存的数据,并在下次搜索时重新加载。

    `; - layer.open({ + dialog.open({ type: 1, title: "选择 CDN 源", area: utils.getResponsiveArea(["400px", "auto"]), @@ -12210,8 +12211,8 @@ ${error.stack}` : ""); } catch (a4) { clog.error("清除 IndexedDB 缓存失败:", a4); } - show.ok(`CDN 源已切换为: ${tt[n5].name}`), layer.close(e5); - } else layer.close(e5); + show.ok(`CDN 源已切换为: ${tt[n5].name}`), dialog.close(e5); + } else dialog.close(e5); }, "yes") }); })(); @@ -12226,7 +12227,7 @@ ${error.stack}` : ""); await storageManager.updateFavoriteActress(e2); await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-edited" }); show.ok(`女优 ${a3} 信息已更新`); - layer.close(t3); + dialog.close(t3); } catch (err) { show.error("修改失败: " + (err.message || err)); } @@ -12251,6 +12252,7 @@ ${error.stack}` : ""); })); } async searchAvatar() { + const dialog = this.getRuntimeService("dialog"); const e2 = $("#edit-actress-name"), t2 = $("#edit-actress-allname"), n2 = e2.val().trim(), a2 = t2.val().trim().split(/[\uff0c,]/).map(((e3) => e3.trim())).filter(((e3) => e3.length > 0)); if (n2 && a2.unshift(n2), 0 === a2.length) return void show.error("请先填写女优主名称或别名进行搜索。"); const i2 = loading("正在搜索头像..."); @@ -12271,7 +12273,7 @@ ${error.stack}` : ""); candidate.append($('').attr("src", url)), candidate.append($('载入中')), avatarList.append(candidate); })); let l2 = 0; - layer.open({ + dialog.open({ type: 1, title: `选择女优头像 (${s2.length} 张)`, area: utils.getResponsiveArea(["900px", "85%"]), @@ -12286,12 +12288,12 @@ ${error.stack}` : ""); })), image.on("error", (function() { wrapper.remove(), l2++; const e4 = s2.length - l2; - i3.text(`点击图片即可选择(已移除 ${l2} 张错误图片,剩余 ${e4} 张)`), 0 === e4 && (show.error("所有搜索到的头像链接均已失效,无法选择。"), layer.close(t3)); + i3.text(`点击图片即可选择(已移除 ${l2} 张错误图片,剩余 ${e4} 张)`), 0 === e4 && (show.error("所有搜索到的头像链接均已失效,无法选择。"), dialog.close(t3)); })), this.complete && (this.naturalWidth > 0 ? image.trigger("load") : image.trigger("error")); })), candidates.on("click", (function() { const candidate = $(this), url = candidate.attr("data-url"); $("#edit-actress-avatar").val(url), $("#edit-avatar-preview").attr("src", url), candidates.attr("aria-pressed", "false"), candidate.attr("aria-pressed", "true"), setTimeout((() => { - layer.close(t3); + dialog.close(t3); }), 150); })), utils.setupEscClose(t3); }, "success") @@ -15513,7 +15515,7 @@ ${error.stack}` : ""); manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), - manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), + manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog]), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), diff --git a/src/plugins/new-video/new-video.js b/src/plugins/new-video/new-video.js index a3fbb74d..2dc057bf 100644 --- a/src/plugins/new-video/new-video.js +++ b/src/plugins/new-video/new-video.js @@ -310,7 +310,7 @@ export class NewVideoPlugin extends BasePlugin {
    `; - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: '新作品检测', content: o, @@ -684,8 +684,9 @@ export class NewVideoPlugin extends BasePlugin { }), void this.hydrateVisibleCovers(s, generation); } async editActress(e) { + const dialog = this.getRuntimeService("dialog"); const t = String(e.name || ""), n = normalizeHttpUrl(e.avatar, this.nvJavDbUrl) || "", a = String(e.remark || ""), i = Array.isArray(e.allName) ? e.allName.join(",") : "", s = Array.isArray(e.newVideoList) ? e.newVideoList.map((e => "string" == typeof e ? e : e.carNum)).join(",") : "", o = String(e.starId || ""), l = e.actressType || "", safe = value => escapeHtml(String(value || "")), c = `\n
    \n
    \n Avatar Preview\n
    \n \n \n
    \n \n \n
    \n
    \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n \n \n
    \n
    \n `; - layer.open({ + dialog.open({ type: 1, title: `编辑女优: ${safe(t)} (${safe(o)})`, area: utils.getDialogArea("sm"), @@ -712,7 +713,7 @@ export class NewVideoPlugin extends BasePlugin { })), $("#select-cdn-btn").on("click", (async () => { await async function() { const e = at, t = tt.map(((t, n) => `\n \n `)).join(""), n = `\n
    \n

    请选择头像数据源 (当前: ${tt[e].name}):

    \n ${t}\n

    切换源会清除本地缓存的数据,并在下次搜索时重新加载。

    \n
    \n `; - layer.open({ + dialog.open({ type: 1, title: "选择 CDN 源", area: utils.getResponsiveArea([ "400px", "auto" ]), @@ -731,8 +732,8 @@ export class NewVideoPlugin extends BasePlugin { } catch (a) { clog.error("清除 IndexedDB 缓存失败:", a); } - show.ok(`CDN 源已切换为: ${tt[n].name}`), layer.close(e); - } else layer.close(e); + show.ok(`CDN 源已切换为: ${tt[n].name}`), dialog.close(e); + } else dialog.close(e); } }); }(); @@ -748,7 +749,7 @@ export class NewVideoPlugin extends BasePlugin { await storageManager.updateFavoriteActress(e); await jhsEventBus.emit("new-video-changed", { reason: "favorite-actress-edited" }); show.ok(`女优 ${a} 信息已更新`); - layer.close(t); + dialog.close(t); } catch(err) { show.error("修改失败: " + (err.message || err)); } @@ -777,6 +778,7 @@ export class NewVideoPlugin extends BasePlugin { })); } async searchAvatar() { + const dialog = this.getRuntimeService("dialog"); const e = $("#edit-actress-name"), t = $("#edit-actress-allname"), n = e.val().trim(), a = t.val().trim().split(/[\uff0c,]/).map((e => e.trim())).filter((e => e.length > 0)); if (n && a.unshift(n), 0 === a.length) return void show.error("请先填写女优主名称或别名进行搜索。"); const i = loading("正在搜索头像..."); @@ -797,7 +799,7 @@ export class NewVideoPlugin extends BasePlugin { candidate.append($("\"\"").attr("src", url)), candidate.append($('载入中')), avatarList.append(candidate); })); let l = 0; - layer.open({ + dialog.open({ type: 1, title: `选择女优头像 (${s.length} 张)`, area: utils.getResponsiveArea([ "900px", "85%" ]), @@ -813,13 +815,13 @@ export class NewVideoPlugin extends BasePlugin { wrapper.remove(), l++; const e = s.length - l; i.text(`点击图片即可选择(已移除 ${l} 张错误图片,剩余 ${e} 张)`), 0 === e && (show.error("所有搜索到的头像链接均已失效,无法选择。"), - layer.close(t)); + dialog.close(t)); })), this.complete && (this.naturalWidth > 0 ? image.trigger("load") : image.trigger("error")); })), candidates.on("click", (function() { const candidate = $(this), url = candidate.attr("data-url"); $("#edit-actress-avatar").val(url), $("#edit-avatar-preview").attr("src", url), candidates.attr("aria-pressed", "false"), candidate.attr("aria-pressed", "true"), setTimeout((() => { - layer.close(t); + dialog.close(t); }), 150); })), utils.setupEscClose(t); } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 1f4a187f..ed7cbcb9 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -79,7 +79,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), - manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }), + manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog]), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), diff --git a/tests/new-video-workspace.test.js b/tests/new-video-workspace.test.js index e90d54fe..f98a7dce 100644 --- a/tests/new-video-workspace.test.js +++ b/tests/new-video-workspace.test.js @@ -59,6 +59,13 @@ function createHarness() { afterEach(() => vi.useRealTimers()); describe("new video workspace snapshot", () => { + it("routes workspace, editor, CDN and avatar dialogs through DialogService", () => { + const source = readTestFile(join(repoRoot, "src/plugins/new-video/new-video.js"), "utf8"); + expect(source).toContain('getRuntimeService("dialog")'); + expect(source).not.toContain("layer.open"); + expect(source).not.toContain("layer.close"); + }); + it("reads each source once and keeps view-only filtering in memory", async () => { const harness = createHarness(); harness.plugin.renderCurrentView = vi.fn(async () => {}), harness.plugin.renderTaskStatuses = vi.fn(); From 80d7e5da3d93d90f51ba74096aa9f12c68be8023 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:52:05 +0800 Subject: [PATCH 012/130] =?UTF-8?q?Refactor:=20=E7=BB=9F=E4=B8=80=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E5=A4=87=E4=BB=BD=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 扩展 DialogPort 与 DialogService 的 confirm/alert 能力 - 将设置页、差异预览和 WebDAV 备份对话框迁至声明式 DialogService 依赖 - 补充适配器转发测试与浏览器 harness alert stub 修改原因: - 消除设置 Feature 对 layui layer 全局对象的直接依赖 - 保持设置、备份和资源编辑的现有交互行为不变 验证: - npm run check 通过(93 个测试文件,527 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 77 ++++++++++--------- src/contracts/ports.js | 2 +- .../userscript/layer-dialog-adapter.js | 6 +- src/plugins/backup/setting-backup.js | 22 +++--- src/plugins/backup/setting-panels.js | 6 +- src/plugins/backup/setting.js | 52 +++++++++++-- src/plugins/registry.js | 2 +- src/services/dialog-service.js | 4 + tests/browser/harness/runtime.js | 1 + tests/dialog-service.test.js | 19 +++++ 10 files changed, 132 insertions(+), 59 deletions(-) create mode 100644 tests/dialog-service.test.js diff --git a/JHS.user.js b/JHS.user.js index 8963485a..4f6c9d71 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -4774,7 +4774,7 @@ } } __name(backupListBtnByWebDav, "backupListBtnByWebDav"); - function openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn) { + function openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn, dialog) { const formatSize = /* @__PURE__ */ __name((size) => { const units = ["B", "KB", "MB", "GB", "TB", "PB"]; let i2 = 0, s2 = size; @@ -4798,7 +4798,7 @@ `).join(""); }, "renderCards"); const containerId = "jhs-backup-card-list"; - layer.open({ + dialog.open({ type: 1, title: n2 + "备份文件", content: `
    ${renderCards(e2)}
    `, @@ -4813,18 +4813,18 @@ const file = e2[idx]; if (!file) return; if (action === "delete") { - layer.confirm(`是否删除 ${file.name} ?`, { + dialog.confirm(`是否删除 ${file.name} ?`, { icon: 3, title: "提示", btn: ["确定", "取消"] }, async (confirmIdx) => { - layer.close(confirmIdx); + dialog.close(confirmIdx); let load = loading(); try { await t2.deleteFile(file.fileId); e2 = await t2.getBackupList(folderName); container.html(renderCards(e2)); - layer.alert("删除成功"); + dialog.alert("删除成功"); } catch (err) { clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); } finally { @@ -4860,12 +4860,12 @@ }); } __name(openFileListDialogMobile, "openFileListDialogMobile"); - function openFileListDialog(e2, t2, n2, folderName, showDiffPreviewFn) { + function openFileListDialog(e2, t2, n2, folderName, showDiffPreviewFn, dialog) { if (utils.isMobileMode()) { - openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn); + openFileListDialogMobile(e2, t2, n2, folderName, showDiffPreviewFn, dialog); return; } - layer.open({ + dialog.open({ type: 1, title: n2 + "备份文件", content: '\n
    \n
    \n
    \n ', @@ -4920,17 +4920,17 @@ return s2((() => { const a4 = e3.getElement().querySelector(".backup-delete"), s3 = e3.getElement().querySelector(".backup-download"), r2 = e3.getElement().querySelector(".backup-import"); a4 && a4.addEventListener("click", ((e4) => { - layer.confirm(`是否删除 ${o2.name} ?`, { + dialog.confirm(`是否删除 ${o2.name} ?`, { icon: 3, title: "提示", btn: ["确定", "取消"] }, (async (e5) => { - layer.close(e5); + dialog.close(e5); let a5 = loading(); try { await t2.deleteFile(o2.fileId); let e6 = await t2.getBackupList(folderName); - i2.replaceData(e6), layer.alert("删除成功"); + i2.replaceData(e6), dialog.alert("删除成功"); } catch (s4) { clog.error(s4), show.error(`发生错误: ${s4 ? s4.message : s4}`); } finally { @@ -7040,7 +7040,7 @@ }); } __name(renderSnapshotPanel, "renderSnapshotPanel"); - function showDiffPreview(e2, t2, n2 = null) { + function showDiffPreview(e2, t2, n2 = null, dialog) { const a2 = e2.summary, i2 = []; for (const [s3, o2] of Object.entries(e2.stores)) { if ("unchanged" === o2.status) continue; @@ -7064,7 +7064,7 @@ } s2 += '
    导入将覆盖当前数据,建议先创建快照备份
    '; s2 += "
    "; - const r2 = layer.open({ + const r2 = dialog.open({ type: 1, title: "数据差异预览", content: s2, @@ -7072,7 +7072,7 @@ btn: ["确认导入", "取消"], anim: -1, yes: /* @__PURE__ */ __name(async (s3) => { - layer.close(s3); + dialog.close(s3); let o2 = loading(); try { await storageManager.createSnapshot("导入前自动备份", "auto-import"), n2 ? (await storageManager.importData(n2), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)) : t2 && (await storageManager.importData(t2), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)); @@ -7427,7 +7427,7 @@ async openSettingDialog(e2 = "backup-panel", t2) { const a2 = this.getDependency("CoverButtonPlugin"); const s2 = buildSettingDialogHtml(e2, this.cacheItems, a2); - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "设置", content: s2, @@ -7521,12 +7521,12 @@ }); } bindClick() { - const settingPlugin = this, webdav = this.getRuntimeService("webdav"); + const settingPlugin = this, webdav = this.getRuntimeService("webdav"), dialog = this.getRuntimeService("dialog"), previewDiff = /* @__PURE__ */ __name((diff, imported, restored = null) => showDiffPreview(diff, imported, restored, dialog), "previewDiff"); $(".side-menu-item").on("click", (function() { $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); const e3 = $(this).data("panel"); $("#" + e3).show(), "cache-panel" === e3 ? ($("#saveBtn").hide(), $("#clean-all").removeClass("jhs-is-hidden")) : ($("#saveBtn").show(), $("#clean-all").addClass("jhs-is-hidden")), "health-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderDataHealthPanel()), "plugin-mgmt-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel(settingPlugin.getRuntimeService("diagnostics"))), "snapshot-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), "network-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel()); - })), $("#importBtn").on("click", ((e3) => importSettingData(showDiffPreview))), $("#exportBtn").on("click", ((e3) => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async (e3) => this.confirmCarNumbers(e3))), $("#webdavBackupBtn").on("click", ((e3) => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", ((e3) => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, showDiffPreview), webdav))), $("#saveBtn").on("click", (() => saveSettingForm(this.getFormDependencies()))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", ((e3) => { + })), $("#importBtn").on("click", ((e3) => importSettingData(previewDiff))), $("#exportBtn").on("click", ((e3) => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async (e3) => this.confirmCarNumbers(e3))), $("#webdavBackupBtn").on("click", ((e3) => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", ((e3) => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, previewDiff, dialog), webdav))), $("#saveBtn").on("click", (() => saveSettingForm(this.getFormDependencies()))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", ((e3) => { utils.q(e3, "修复前会自动下载备份,是否继续?", (() => repairDataHealthWithBackup())); })), $("#pm-clear-log").on("click", (() => { this.getRuntimeService("diagnostics").clearErrors(), $("#plugin-error-log").text("无错误记录"), show.ok("错误日志已清空"); @@ -7660,7 +7660,7 @@ })); } openSourceDialog(existing = null) { - const fields = ["rowSelector", "titleSelector", "magnetSelector", "sizeSelector", "dateSelector", "seedersSelector", "leechersSelector", "resultsPath", "titlePath", "magnetPath", "hashPath", "sizePath", "datePath", "seedersPath"]; + const dialog = this.getRuntimeService("dialog"), fields = ["rowSelector", "titleSelector", "magnetSelector", "sizeSelector", "dateSelector", "seedersSelector", "leechersSelector", "resultsPath", "titlePath", "magnetPath", "hashPath", "sizePath", "datePath", "seedersPath"]; const content = $(`
    `); const renderFields = /* @__PURE__ */ __name(() => { const type = content.find('[name="parserType"]').val(), names = "torrent-table" === type ? fields.slice(0, 7) : "json" === type ? fields.slice(7) : []; @@ -7671,42 +7671,35 @@ const input = content.find(`[name="${key}"]`); "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); }); - content.on("change", '[name="parserType"]', renderFields); - renderFields(); - content.appendTo("body").hide(); - layer.open({ type: 1, title: existing ? "编辑自定义磁力源" : "添加自定义磁力源", content, area: utils.getDialogArea("md"), btn: ["保存", "取消"], success: /* @__PURE__ */ __name(() => content.show(), "success"), end: /* @__PURE__ */ __name(() => content.remove(), "end"), yes: /* @__PURE__ */ __name(async (index) => { + content.on("change", '[name="parserType"]', renderFields), renderFields(), content.appendTo("body").hide(); + dialog.open({ type: 1, title: existing ? "编辑自定义磁力源" : "添加自定义磁力源", content, area: utils.getDialogArea("md"), btn: ["保存", "取消"], success: /* @__PURE__ */ __name(() => content.show(), "success"), end: /* @__PURE__ */ __name(() => content.remove(), "end"), yes: /* @__PURE__ */ __name(async (index) => { const form = Object.fromEntries(content.find("input,select").map(((i2, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); try { - const source = buildCustomMagnetSource(form, existing); - const target = existing ? this.resourceState.custom.findIndex(((item) => item.id === existing.id)) : -1; + const source = buildCustomMagnetSource(form, existing), target = existing ? this.resourceState.custom.findIndex(((item) => item.id === existing.id)) : -1; target >= 0 ? this.resourceState.custom.splice(target, 1, source) : this.resourceState.custom.push(source); - await this.resourceSettings.saveMagnetSources(this.resourceState.custom); - layer.close(index); - this.renderResourceSettings(); + await this.resourceSettings.saveMagnetSources(this.resourceState.custom), dialog.close(index), this.renderResourceSettings(); } catch (error) { show.error(error.message); } }, "yes") }); } openRuleDialog(kind, existing = null) { - const isTag = "tag" === kind, content = $(`
    ${isTag ? "" : ''}${isTag ? '' : ''}
    `); + const dialog = this.getRuntimeService("dialog"), isTag = "tag" === kind; + const content = $(`
    ${isTag ? "" : ''}${isTag ? '' : ''}
    `); Object.entries(existing || { enabled: true, type: "contains", weight: 0, action: "hide", penalty: -20 }).forEach(([key, value]) => { const input = content.find(`[name="${key}"]`); "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); }); content.appendTo("body").hide(); - layer.open({ type: 1, title: `${existing ? "编辑" : "新建"}${isTag ? "标签" : "过滤"}规则`, content, area: utils.getDialogArea("sm"), btn: ["保存", "取消"], success: /* @__PURE__ */ __name(() => content.show(), "success"), end: /* @__PURE__ */ __name(() => content.remove(), "end"), yes: /* @__PURE__ */ __name(async (index) => { + dialog.open({ type: 1, title: `${existing ? "编辑" : "新建"}${isTag ? "标签" : "过滤"}规则`, content, area: utils.getDialogArea("sm"), btn: ["保存", "取消"], success: /* @__PURE__ */ __name(() => content.show(), "success"), end: /* @__PURE__ */ __name(() => content.remove(), "end"), yes: /* @__PURE__ */ __name(async (index) => { const rule = Object.fromEntries(content.find("input,select").map(((i2, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); - rule.id = existing?.id || `rule-${Date.now()}`; - rule.weight = Number(rule.weight); - rule.penalty = Number(rule.penalty); + rule.id = existing?.id || `rule-${Date.now()}`, rule.weight = Number(rule.weight), rule.penalty = Number(rule.penalty); try { validateRule(rule); const key = isTag ? "tags" : "filters", target = existing ? this.resourceState[key].findIndex(((item) => item.id === existing.id)) : -1; target >= 0 ? this.resourceState[key].splice(target, 1, rule) : this.resourceState[key].push(rule); await (isTag ? this.resourceSettings.saveMagnetTagRules(this.resourceState[key]) : this.resourceSettings.saveMagnetFilterRules(this.resourceState[key])); - layer.close(index); - this.renderRules(kind); + dialog.close(index), this.renderRules(kind); } catch (error) { show.error(error.message); } @@ -15492,7 +15485,7 @@ ${error.stack}` : ""); manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog]), - manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), @@ -15585,7 +15578,7 @@ ${error.stack}` : ""); navigation: ["open", "assign", "replace"], http: ["request"], storage: ["get", "set", "remove"], - dialog: ["open", "close"], + dialog: ["open", "close", "confirm", "alert"], style: ["register", "remove"] }); @@ -15658,6 +15651,12 @@ ${error.stack}` : ""); close(id) { this.layer.close(id); } + confirm(message, options, yes) { + return this.layer.confirm(message, options, yes); + } + alert(message, options) { + return this.layer.alert(message, options); + } }; __name(_LayerDialogAdapter, "LayerDialogAdapter"); var LayerDialogAdapter = _LayerDialogAdapter; @@ -15883,6 +15882,12 @@ ${error.stack}` : ""); close(id) { return this.port.close(id); } + confirm(message, options, yes) { + return this.port.confirm(message, options, yes); + } + alert(message, options) { + return this.port.alert(message, options); + } }; __name(_DialogService, "DialogService"); var DialogService = _DialogService; diff --git a/src/contracts/ports.js b/src/contracts/ports.js index 4f4d6bfa..eb34eb63 100644 --- a/src/contracts/ports.js +++ b/src/contracts/ports.js @@ -13,6 +13,6 @@ export const PORT_METHODS = Object.freeze({ navigation: ["open", "assign", "replace"], http: ["request"], storage: ["get", "set", "remove"], - dialog: ["open", "close"], + dialog: ["open", "close", "confirm", "alert"], style: ["register", "remove"], }); diff --git a/src/platform/userscript/layer-dialog-adapter.js b/src/platform/userscript/layer-dialog-adapter.js index 7501ca4d..c8ec113e 100644 --- a/src/platform/userscript/layer-dialog-adapter.js +++ b/src/platform/userscript/layer-dialog-adapter.js @@ -1,10 +1,14 @@ // @ts-check export class LayerDialogAdapter { - /** @param {{open: (options: Record) => number, close: (id: number) => void}} layerRuntime */ + /** @param {{open: (options: Record) => number, close: (id: number) => void, confirm: (message: string, options: Record, yes: (id: number) => void) => number, alert: (message: string, options?: Record) => number}} layerRuntime */ constructor(layerRuntime) { this.layer = layerRuntime; } /** @param {Record} options */ open(options) { return this.layer.open(options); } /** @param {number} id */ close(id) { this.layer.close(id); } + /** @param {string} message @param {Record} options @param {(id: number) => void} yes */ + confirm(message, options, yes) { return this.layer.confirm(message, options, yes); } + /** @param {string} message @param {Record} [options] */ + alert(message, options) { return this.layer.alert(message, options); } } diff --git a/src/plugins/backup/setting-backup.js b/src/plugins/backup/setting-backup.js index 59f3ff6d..ee992a8f 100644 --- a/src/plugins/backup/setting-backup.js +++ b/src/plugins/backup/setting-backup.js @@ -78,7 +78,7 @@ export async function backupListBtnByWebDav(folderName, openFileListDialogFn, we } /** Mobile-specific backup file list dialog using card-based UI. */ -function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn) { +function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog) { const formatSize = (size) => { const units = ["B", "KB", "MB", "GB", "TB", "PB"]; let i = 0, s = size; @@ -102,7 +102,7 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn) { `).join(""); }; const containerId = "jhs-backup-card-list"; - layer.open({ + dialog.open({ type: 1, title: n + "备份文件", content: `
    ${renderCards(e)}
    `, @@ -117,16 +117,16 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn) { const file = e[idx]; if (!file) return; if (action === "delete") { - layer.confirm(`是否删除 ${file.name} ?`, { + dialog.confirm(`是否删除 ${file.name} ?`, { icon: 3, title: "提示", btn: ["确定", "取消"] }, async (confirmIdx) => { - layer.close(confirmIdx); + dialog.close(confirmIdx); let load = loading(); try { await t.deleteFile(file.fileId); e = await t.getBackupList(folderName); container.html(renderCards(e)); - layer.alert("删除成功"); + dialog.alert("删除成功"); } catch (err) { clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); } finally { load.close(); } @@ -159,12 +159,12 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn) { } /** Desktop backup file list dialog using Tabulator table. */ -export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn) { +export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialog) { if (utils.isMobileMode()) { - openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn); + openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog); return; } - layer.open({ + dialog.open({ type: 1, title: n + "备份文件", content: '\n
    \n
    \n
    \n ', @@ -219,17 +219,17 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn) { return s((() => { const a = e.getElement().querySelector(".backup-delete"), s = e.getElement().querySelector(".backup-download"), r = e.getElement().querySelector(".backup-import"); a && a.addEventListener("click", (e => { - layer.confirm(`是否删除 ${o.name} ?`, { + dialog.confirm(`是否删除 ${o.name} ?`, { icon: 3, title: "提示", btn: [ "确定", "取消" ] }, (async e => { - layer.close(e); + dialog.close(e); let a = loading(); try { await t.deleteFile(o.fileId); let e = await t.getBackupList(folderName); - i.replaceData(e), layer.alert("删除成功"); + i.replaceData(e), dialog.alert("删除成功"); } catch (s) { clog.error(s), show.error(`发生错误: ${s ? s.message : s}`); } finally { diff --git a/src/plugins/backup/setting-panels.js b/src/plugins/backup/setting-panels.js index 77a7a989..51295091 100644 --- a/src/plugins/backup/setting-panels.js +++ b/src/plugins/backup/setting-panels.js @@ -93,7 +93,7 @@ export async function renderSnapshotPanel() { } /** Show a data diff preview dialog before importing data. */ -export function showDiffPreview(e, t, n = null) { +export function showDiffPreview(e, t, n = null, dialog) { const a = e.summary, i = []; for (const [s, o] of Object.entries(e.stores)) { if ("unchanged" === o.status) continue; @@ -117,7 +117,7 @@ export function showDiffPreview(e, t, n = null) { } s += '
    导入将覆盖当前数据,建议先创建快照备份
    '; s += '
    '; - const r = layer.open({ + const r = dialog.open({ type: 1, title: "数据差异预览", content: s, @@ -125,7 +125,7 @@ export function showDiffPreview(e, t, n = null) { btn: ["确认导入", "取消"], anim: -1, yes: async s => { - layer.close(s); + dialog.close(s); let o = loading(); try { await storageManager.createSnapshot("导入前自动备份", "auto-import"), diff --git a/src/plugins/backup/setting.js b/src/plugins/backup/setting.js index c6f9d415..48ffae4e 100644 --- a/src/plugins/backup/setting.js +++ b/src/plugins/backup/setting.js @@ -142,7 +142,7 @@ export class SettingPlugin extends BasePlugin { async openSettingDialog(e = "backup-panel", t) { const a = this.getDependency("CoverButtonPlugin"); const s = buildSettingDialogHtml(e, this.cacheItems, a); - layer.open({ + this.getRuntimeService("dialog").open({ type: 1, title: "设置", content: s, @@ -237,7 +237,7 @@ export class SettingPlugin extends BasePlugin { }); } bindClick() { - const settingPlugin = this, webdav = this.getRuntimeService("webdav"); + const settingPlugin = this, webdav = this.getRuntimeService("webdav"), dialog = this.getRuntimeService("dialog"), previewDiff = (diff, imported, restored = null) => showDiffPreview(diff, imported, restored, dialog); $(".side-menu-item").on("click", (function() { $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); const e = $(this).data("panel"); @@ -246,9 +246,9 @@ export class SettingPlugin extends BasePlugin { "plugin-mgmt-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel(settingPlugin.getRuntimeService("diagnostics"))), "snapshot-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), "network-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel()); - })), $("#importBtn").on("click", (e => importSettingData(showDiffPreview))), $("#exportBtn").on("click", (e => exportSettingData())), + })), $("#importBtn").on("click", (e => importSettingData(previewDiff))), $("#exportBtn").on("click", (e => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async e => this.confirmCarNumbers(e))), - $("#webdavBackupBtn").on("click", (e => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", (e => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, showDiffPreview), webdav))), + $("#webdavBackupBtn").on("click", (e => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", (e => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, previewDiff, dialog), webdav))), $("#saveBtn").on("click", (() => saveSettingForm(this.getFormDependencies()))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", (e => { utils.q(e, "修复前会自动下载备份,是否继续?", (() => repairDataHealthWithBackup())); @@ -309,8 +309,48 @@ export class SettingPlugin extends BasePlugin { $("#builtin-magnet-source-list").empty().append(this.resourceState.builtIn.map((source => card(source, false, "magnet")))); $("#custom-magnet-source-list").empty().append(this.resourceState.custom.length ? this.resourceState.custom.map((source => card(source, true, "magnet"))) : '

    暂无自定义来源

    '); $("#screenshot-source-list").empty().append(this.resourceState.screenshot.providers.map((source => card(source, false, "screenshot")))); this.resourceState.screenshot.providers.forEach(((source, index) => { if (false === source.implemented) $("#screenshot-source-list .jhs-resource-card").eq(index).find(".jhs-source-toggle").prop("disabled", true).end().find("strong").after('未实现').end().find(".jhs-source-test").remove(); })); $(`input[name="screenshotMode"][value="${this.resourceState.screenshot.mode}"]`).prop("checked", true); this.renderRules("tag"); this.renderRules("filter"); } renderRules(kind) { const list = "tag" === kind ? this.resourceState.tags : this.resourceState.filters, host = $("#magnet-" + kind + "-rule-list").empty(); if (!list.length) host.append('

    暂无规则

    '); list.forEach((rule => { const node = $('
    ').append($("").text(rule.name), $("

    ").text(`${"regex" === rule.type ? "正则" : "包含"}:${rule.pattern}${"tag" === kind ? ` · 权重 ${Number(rule.weight) >= 0 ? "+" : ""}${rule.weight || 0}` : ` · ${"hide" === rule.action ? "隐藏" : `降权 ${rule.penalty || -20}`}`}`), '
    '); node.on("click", ".jhs-rule-edit", (() => this.openRuleDialog(kind, rule))).on("click", ".jhs-rule-delete", (event => utils.q(event, `确认删除规则「${rule.name}」?`, (async () => { const key = "tag" === kind ? "tags" : "filters"; this.resourceState[key] = this.resourceState[key].filter((item => item.id !== rule.id)); await ("tag" === kind ? this.resourceSettings.saveMagnetTagRules(this.resourceState[key]) : this.resourceSettings.saveMagnetFilterRules(this.resourceState[key])); this.renderRules(kind); })))); host.append(node); })); } - openSourceDialog(existing = null) { const fields = ["rowSelector","titleSelector","magnetSelector","sizeSelector","dateSelector","seedersSelector","leechersSelector","resultsPath","titlePath","magnetPath","hashPath","sizePath","datePath","seedersPath"]; const content = $(`
    `); const renderFields = () => { const type = content.find('[name="parserType"]').val(), names = "torrent-table" === type ? fields.slice(0, 7) : "json" === type ? fields.slice(7) : []; content.find(".jhs-parser-fields").html(names.map((name => ``)).join("")); names.forEach((name => content.find(`[name="${name}"]`).val(existing?.[name] || ""))); }; Object.entries(existing || { enabled: true, priority: 100, parserType: "magnet-links" }).forEach(([key, value]) => { const input = content.find(`[name="${key}"]`); "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); }); content.on("change", '[name="parserType"]', renderFields); renderFields(); content.appendTo("body").hide(); layer.open({ type: 1, title: existing ? "编辑自定义磁力源" : "添加自定义磁力源", content, area: utils.getDialogArea("md"), btn: ["保存", "取消"], success: () => content.show(), end: () => content.remove(), yes: async index => { const form = Object.fromEntries(content.find("input,select").map(((i, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); try { const source = buildCustomMagnetSource(form, existing); const target = existing ? this.resourceState.custom.findIndex((item => item.id === existing.id)) : -1; target >= 0 ? this.resourceState.custom.splice(target, 1, source) : this.resourceState.custom.push(source); await this.resourceSettings.saveMagnetSources(this.resourceState.custom); layer.close(index); this.renderResourceSettings(); } catch (error) { show.error(error.message); } } }); } - openRuleDialog(kind, existing = null) { const isTag = "tag" === kind, content = $(`
    ${isTag ? "" : ''}${isTag ? '' : ''}
    `); Object.entries(existing || { enabled: true, type: "contains", weight: 0, action: "hide", penalty: -20 }).forEach(([key, value]) => { const input = content.find(`[name="${key}"]`); "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); }); content.appendTo("body").hide(); layer.open({ type: 1, title: `${existing ? "编辑" : "新建"}${isTag ? "标签" : "过滤"}规则`, content, area: utils.getDialogArea("sm"), btn: ["保存", "取消"], success: () => content.show(), end: () => content.remove(), yes: async index => { const rule = Object.fromEntries(content.find("input,select").map(((i, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); rule.id = existing?.id || `rule-${Date.now()}`; rule.weight = Number(rule.weight); rule.penalty = Number(rule.penalty); try { validateRule(rule); const key = isTag ? "tags" : "filters", target = existing ? this.resourceState[key].findIndex((item => item.id === existing.id)) : -1; target >= 0 ? this.resourceState[key].splice(target, 1, rule) : this.resourceState[key].push(rule); await (isTag ? this.resourceSettings.saveMagnetTagRules(this.resourceState[key]) : this.resourceSettings.saveMagnetFilterRules(this.resourceState[key])); layer.close(index); this.renderRules(kind); } catch (error) { show.error(error.message); } } }); } + openSourceDialog(existing = null) { + const dialog = this.getRuntimeService("dialog"), fields = ["rowSelector","titleSelector","magnetSelector","sizeSelector","dateSelector","seedersSelector","leechersSelector","resultsPath","titlePath","magnetPath","hashPath","sizePath","datePath","seedersPath"]; + const content = $(`
    `); + const renderFields = () => { + const type = content.find('[name="parserType"]').val(), names = "torrent-table" === type ? fields.slice(0, 7) : "json" === type ? fields.slice(7) : []; + content.find(".jhs-parser-fields").html(names.map((name => ``)).join("")); + names.forEach((name => content.find(`[name="${name}"]`).val(existing?.[name] || ""))); + }; + Object.entries(existing || { enabled: true, priority: 100, parserType: "magnet-links" }).forEach(([key, value]) => { + const input = content.find(`[name="${key}"]`); + "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); + }); + content.on("change", '[name="parserType"]', renderFields), renderFields(), content.appendTo("body").hide(); + dialog.open({ type: 1, title: existing ? "编辑自定义磁力源" : "添加自定义磁力源", content, area: utils.getDialogArea("md"), btn: ["保存", "取消"], success: () => content.show(), end: () => content.remove(), yes: async index => { + const form = Object.fromEntries(content.find("input,select").map(((i, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); + try { + const source = buildCustomMagnetSource(form, existing), target = existing ? this.resourceState.custom.findIndex((item => item.id === existing.id)) : -1; + target >= 0 ? this.resourceState.custom.splice(target, 1, source) : this.resourceState.custom.push(source); + await this.resourceSettings.saveMagnetSources(this.resourceState.custom), dialog.close(index), this.renderResourceSettings(); + } catch (error) { show.error(error.message); } + } }); + } + openRuleDialog(kind, existing = null) { + const dialog = this.getRuntimeService("dialog"), isTag = "tag" === kind; + const content = $(`
    ${isTag ? "" : ''}${isTag ? '' : ''}
    `); + Object.entries(existing || { enabled: true, type: "contains", weight: 0, action: "hide", penalty: -20 }).forEach(([key, value]) => { + const input = content.find(`[name="${key}"]`); + "checkbox" === input.attr("type") ? input.prop("checked", value) : input.val(value); + }); + content.appendTo("body").hide(); + dialog.open({ type: 1, title: `${existing ? "编辑" : "新建"}${isTag ? "标签" : "过滤"}规则`, content, area: utils.getDialogArea("sm"), btn: ["保存", "取消"], success: () => content.show(), end: () => content.remove(), yes: async index => { + const rule = Object.fromEntries(content.find("input,select").map(((i, element) => [element.name, "checkbox" === element.type ? element.checked : element.value])).get()); + rule.id = existing?.id || `rule-${Date.now()}`, rule.weight = Number(rule.weight), rule.penalty = Number(rule.penalty); + try { + validateRule(rule); + const key = isTag ? "tags" : "filters", target = existing ? this.resourceState[key].findIndex((item => item.id === existing.id)) : -1; + target >= 0 ? this.resourceState[key].splice(target, 1, rule) : this.resourceState[key].push(rule); + await (isTag ? this.resourceSettings.saveMagnetTagRules(this.resourceState[key]) : this.resourceSettings.saveMagnetFilterRules(this.resourceState[key])); + dialog.close(index), this.renderRules(kind); + } catch (error) { show.error(error.message); } + } }); + } async saveCloudSettings() { await this.resourceSettings.saveCloudSettings({ enable123Offline: $("#enable123Offline").is(":checked"), enable115Offline: $("#enable115Offline").is(":checked"), providerMode: $("#offlineProviderMode").val(), enable115Match: $("#enable115Match").is(":checked"), enable115LoginRedirect: $("#enable115LoginRedirect").is(":checked"), concurrency: Number($("#oneOneFiveConcurrency").val()), cacheMinutes: Number($("#oneOneFiveCacheMinutes").val()) }); } async checkOneOneFiveLogin() { const badge = $("#one-one-five-state").text("检测中"); try { badge.text(await new OneOneFiveClient().checkLogin() ? "已登录" : "未登录"); } catch { badge.text("检测失败"); } } async testSource(button, url) { if (!url) return show.info("本站来源无需跨站测试"); const node = $(button).prop("disabled", true), badge = node.siblings(".jhs-source-test-state").length ? node.siblings(".jhs-source-test-state") : $('').insertAfter(node); badge.text("检测中"); try { const response = await gmHttp.get(url); badge.text(response ? "200 · 可解析" : "空响应"); } catch (error) { badge.text(error?._cfBlocked ? "Cloudflare 拦截" : error?.status === 404 ? "404" : error?._circuitBreaker ? "熔断" : "请求失败"); } finally { node.prop("disabled", false).text("测试"); } } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index ed7cbcb9..b64f8612 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -56,7 +56,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog]), - manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav]), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), diff --git a/src/services/dialog-service.js b/src/services/dialog-service.js index 8e385775..a88bc99a 100644 --- a/src/services/dialog-service.js +++ b/src/services/dialog-service.js @@ -9,4 +9,8 @@ export class DialogService { open(options) { return this.port.open(options); } /** @param {number} id */ close(id) { return this.port.close(id); } + /** @param {string} message @param {Record} options @param {(id: number) => void} yes */ + confirm(message, options, yes) { return this.port.confirm(message, options, yes); } + /** @param {string} message @param {Record} [options] */ + alert(message, options) { return this.port.alert(message, options); } } diff --git a/tests/browser/harness/runtime.js b/tests/browser/harness/runtime.js index b019a7ed..2ccd67fa 100644 --- a/tests/browser/harness/runtime.js +++ b/tests/browser/harness/runtime.js @@ -82,6 +82,7 @@ export async function injectUserscriptRuntime(page, options = {}) { }, closeAll() { [...mountedLayers.keys()].forEach((id) => this.close(id)); }, confirm(_message, options, yes) { const id = this.open(options); yes?.(id); return id; }, + alert(message, options = {}) { return this.open({ ...options, content: String(message) }); }, msg() {} }; }, browserVersion); diff --git a/tests/dialog-service.test.js b/tests/dialog-service.test.js new file mode 100644 index 00000000..bd13e805 --- /dev/null +++ b/tests/dialog-service.test.js @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { LayerDialogAdapter } from "../src/platform/userscript/layer-dialog-adapter.js"; +import { DialogService } from "../src/services/dialog-service.js"; + +describe("dialog service", () => { + it("forwards open, close, confirm and alert through the DialogPort", () => { + const layer = { + open: vi.fn(() => 1), close: vi.fn(), confirm: vi.fn(() => 2), alert: vi.fn(() => 3), + }; + const service = new DialogService(new LayerDialogAdapter(layer)), yes = vi.fn(); + expect(service.open({ title: "dialog" })).toBe(1); + service.close(1); + expect(service.confirm("confirm", { icon: 3 }, yes)).toBe(2); + expect(service.alert("done")).toBe(3); + expect(layer.close).toHaveBeenCalledWith(1); + expect(layer.confirm).toHaveBeenCalledWith("confirm", { icon: 3 }, yes); + expect(layer.alert).toHaveBeenCalledWith("done", undefined); + }); +}); From 8cf2395e8763f2e51bb1381013b657ca4442fbb8 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:55:08 +0800 Subject: [PATCH 013/130] =?UTF-8?q?Refactor:=20=E6=94=B6=E6=95=9B=20Top250?= =?UTF-8?q?=20=E5=87=AD=E6=8D=AE=E4=B8=8E=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 在现有凭据加密模块中封装加密令牌的检测、写入与删除 - 将 Top250 登录流程迁至凭据边界和已声明的 DialogService - 保持 jhs_appAuthorization key 与原 AES 密文格式不变 - 补充加密本地凭据的往返与删除测试 修改原因: - 消除 Top250 Feature 对 layer 和 localStorage 的直接依赖 - 避免迁移对话框时导致本地存储债务指纹漂移 验证: - npm run check 通过(93 个测试文件,528 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 19 +++++++++++++++---- src/core/credential-crypto.js | 9 +++++++++ src/plugins/external-search/top250.js | 9 ++++----- tests/credential-crypto.test.js | 22 ++++++++++++++++++++-- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 4f6c9d71..a0fa8e82 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -4684,6 +4684,18 @@ return value && value.startsWith(CREDENTIAL_PREFIX) ? decryptData(value.slice(CREDENTIAL_PREFIX.length)) : value; } __name(decryptCredential, "decryptCredential"); + function hasStoredEncryptedCredential(key) { + return Boolean(localStorage.getItem(key)); + } + __name(hasStoredEncryptedCredential, "hasStoredEncryptedCredential"); + async function storeEncryptedCredential(key, value) { + localStorage.setItem(key, await encryptData(value)); + } + __name(storeEncryptedCredential, "storeEncryptedCredential"); + function removeStoredEncryptedCredential(key) { + localStorage.removeItem(key); + } + __name(removeStoredEncryptedCredential, "removeStoredEncryptedCredential"); // src/ui/table/create-jhs-table.js function createJhsTable(TabulatorRuntime, target, options) { @@ -10319,7 +10331,7 @@ ${error.stack}` : ""); const n3 = t3.filter(((e4) => "1" === this.has_cnsub ? e4.has_cnsub : "0" !== this.has_cnsub || !e4.has_cnsub)), a3 = this.getDependency("HitShowPlugin"); let r3 = a3.markDataListHtml(n3); i2.html(r3), await a3.initializeRenderedList(), await a3.loadScore(n3), o2 = true; - } else clog.error(e3), i2.html(`

    ${escapeHtml(l3)}

    `), show.error(l3), "JWTVerificationError" === c2 && (await localStorage.removeItem(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o2 = true; + } else clog.error(e3), i2.html(`

    ${escapeHtml(l3)}

    `), show.error(l3), "JWTVerificationError" === c2 && (removeStoredEncryptedCredential(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o2 = true; } catch (r2) { l2 < 3 ? (clog.error(`获取Top数据失败 (第 ${l2} 次重试):`, r2), await new Promise(((e3) => setTimeout(e3, 1e3)))) : (clog.error("所有重试尝试均失败,无法获取Top数据。", r2), i2.html("

    无法加载数据,请稍后再试。

    ")); } finally { @@ -10345,7 +10357,7 @@ ${error.stack}` : ""); })); } async checkLogin(e2, t2) { - if (!localStorage.getItem(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); + if (!hasStoredEncryptedCredential(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); let n2 = "all", a2 = "", i2 = t2.get("t") || ""; /^y\d+$/.test(i2) ? (n2 = "year", a2 = i2.substring(1)) : "" !== i2 && (n2 = "video_type", a2 = i2); let s2 = `/advanced_search?handleTop=1&handleType=${n2}&type_value=${a2}`; @@ -10355,7 +10367,6 @@ ${error.stack}` : ""); } openLoginDialog({ onSuccess = null } = {}) { const dialog = this.getRuntimeService("dialog"); - const layer2 = { close: /* @__PURE__ */ __name((index) => dialog.close(index), "close") }; dialog.open({ type: 1, title: "JavDB", @@ -10408,7 +10419,7 @@ ${error.stack}` : ""); if (1 !== n3) throw clog.error("登录失败", e4), new Error(e4.message); { let n4 = e4.data.token; - await localStorage.setItem(me, await encryptData(n4)), show.ok("登录成功"), layer2.close(t2), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; + await storeEncryptedCredential(me, n4), show.ok("登录成功"), dialog.close(t2), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; } } })).catch(((e4) => { diff --git a/src/core/credential-crypto.js b/src/core/credential-crypto.js index 09769140..3de75da7 100644 --- a/src/core/credential-crypto.js +++ b/src/core/credential-crypto.js @@ -44,3 +44,12 @@ export async function decryptData(value) { export async function encryptCredential(value) { return value && !value.startsWith(CREDENTIAL_PREFIX) ? CREDENTIAL_PREFIX + await encryptData(value) : value; } /** @param {string} value */ export async function decryptCredential(value) { return value && value.startsWith(CREDENTIAL_PREFIX) ? decryptData(value.slice(CREDENTIAL_PREFIX.length)) : value; } + +/** @param {string} key */ +export function hasStoredEncryptedCredential(key) { return Boolean(localStorage.getItem(key)); } + +/** @param {string} key @param {string} value */ +export async function storeEncryptedCredential(key, value) { localStorage.setItem(key, await encryptData(value)); } + +/** @param {string} key */ +export function removeStoredEncryptedCredential(key) { localStorage.removeItem(key); } diff --git a/src/plugins/external-search/top250.js b/src/plugins/external-search/top250.js index a17898c9..837beced 100644 --- a/src/plugins/external-search/top250.js +++ b/src/plugins/external-search/top250.js @@ -1,7 +1,7 @@ import { escapeHtml, i } from "../../core/constants.js"; import { O, U, q } from "../../core/javdb-api.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { encryptData } from "../../core/credential-crypto.js"; +import { hasStoredEncryptedCredential, removeStoredEncryptedCredential, storeEncryptedCredential } from "../../core/credential-crypto.js"; const me = "jhs_appAuthorization"; @@ -68,7 +68,7 @@ export class Top250Plugin extends BasePlugin { const n = t.filter((e => "1" === this.has_cnsub ? e.has_cnsub : "0" !== this.has_cnsub || !e.has_cnsub)), a = this.getDependency("HitShowPlugin"); let r = a.markDataListHtml(n); i.html(r), await a.initializeRenderedList(), await a.loadScore(n), o = !0; - } else clog.error(e), i.html(`

    ${escapeHtml(l)}

    `), show.error(l), "JWTVerificationError" === c && (await localStorage.removeItem(me), + } else clog.error(e), i.html(`

    ${escapeHtml(l)}

    `), show.error(l), "JWTVerificationError" === c && (removeStoredEncryptedCredential(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o = !0; } catch (r) { l < 3 ? (clog.error(`获取Top数据失败 (第 ${l} 次重试):`, r), await new Promise((e => setTimeout(e, 1e3)))) : (clog.error("所有重试尝试均失败,无法获取Top数据。", r), @@ -96,7 +96,7 @@ export class Top250Plugin extends BasePlugin { })); } async checkLogin(e, t) { - if (!localStorage.getItem(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); + if (!hasStoredEncryptedCredential(me)) return show.error("该类别依赖移动端接口,请先完成登录"), void this.openLoginDialog(); let n = "all", a = "", i = t.get("t") || ""; /^y\d+$/.test(i) ? (n = "year", a = i.substring(1)) : "" !== i && (n = "video_type", a = i); @@ -107,7 +107,6 @@ export class Top250Plugin extends BasePlugin { } openLoginDialog({ onSuccess = null } = {}) { const dialog = this.getRuntimeService("dialog"); - const layer = { close: index => dialog.close(index) }; dialog.open({ type: 1, title: "JavDB", @@ -134,7 +133,7 @@ export class Top250Plugin extends BasePlugin { if (1 !== n) throw clog.error("登录失败", e), new Error(e.message); { let n = e.data.token; - await localStorage.setItem(me, await encryptData(n)), show.ok("登录成功"), layer.close(t), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; + await storeEncryptedCredential(me, n), show.ok("登录成功"), dialog.close(t), "function" === typeof onSuccess ? await onSuccess() : window.location.href = "/advanced_search?handleTop=1&period=daily"; } } })).catch((e => { diff --git a/tests/credential-crypto.test.js b/tests/credential-crypto.test.js index 58fd8e4f..1a13e6ec 100644 --- a/tests/credential-crypto.test.js +++ b/tests/credential-crypto.test.js @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { decryptCredential, decryptData, encryptCredential, encryptData } from "../src/core/credential-crypto.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { decryptCredential, decryptData, encryptCredential, encryptData, hasStoredEncryptedCredential, removeStoredEncryptedCredential, storeEncryptedCredential } from "../src/core/credential-crypto.js"; + +afterEach(() => vi.unstubAllGlobals()); describe("credential crypto", () => { it("round-trips encrypted data without exposing plaintext", async () => { @@ -15,4 +17,20 @@ describe("credential crypto", () => { await expect(decryptCredential(encrypted)).resolves.toBe("token"); await expect(decryptCredential("legacy-token")).resolves.toBe("legacy-token"); }); + + it("keeps encrypted local credential storage behind the crypto boundary", async () => { + const values = new Map(); + vi.stubGlobal("localStorage", { + getItem: key => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + removeItem: key => values.delete(key), + }); + expect(hasStoredEncryptedCredential("auth")).toBe(false); + await storeEncryptedCredential("auth", "secret-token"); + expect(hasStoredEncryptedCredential("auth")).toBe(true); + expect(values.get("auth")).not.toContain("secret-token"); + await expect(decryptData(values.get("auth"))).resolves.toBe("secret-token"); + removeStoredEncryptedCredential("auth"); + expect(hasStoredEncryptedCredential("auth")).toBe(false); + }); }); From 08c58f1e93b3bd24a540b8af17553cf5e39b420c Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:01:16 +0800 Subject: [PATCH 014/130] =?UTF-8?q?Refactor:=20=E7=BB=9F=E4=B8=80=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=E9=A1=B5=E7=BD=91=E7=BB=9C=E8=AF=B7=E6=B1=82=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 通过现有 HttpService 封装当前宿主页 HTML 请求 - 将自动翻页、状态导入和演员补录迁至声明式 HTTP 依赖 - 请求绑定 Feature LifecycleScope,并锁定当前 HTTPS origin - 强化 ExternalUrlPolicy 对 expectedOrigin 的初始地址与重定向复验 修改原因: - 消除三处 Feature 对 gmHttp 的直接依赖 - 防止宿主页请求被重定向到未声明的同根域子站 验证: - npm run check 通过(94 个测试文件,530 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 32 +++++++++++++++---- src/core/host-page-request.js | 20 ++++++++++++ src/plugins/registry.js | 8 ++--- src/plugins/status/auto-page.js | 4 ++- src/plugins/status/list-page.js | 4 ++- src/plugins/status/want-and-watched-videos.js | 4 ++- src/services/external-url-policy.js | 1 + tests/architecture-runtime.test.js | 2 +- tests/host-page-request.test.js | 23 +++++++++++++ tests/http-service.test.js | 7 ++++ tests/javdb-status-import.test.js | 2 +- 11 files changed, 92 insertions(+), 15 deletions(-) create mode 100644 src/core/host-page-request.js create mode 100644 tests/host-page-request.test.js diff --git a/JHS.user.js b/JHS.user.js index a0fa8e82..0cc1b382 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -5452,6 +5452,21 @@ } __name(applyImageMode, "applyImageMode"); + // src/core/host-page-request.js + async function requestHostPage(http, input, scope) { + const url = new URL(input, window.location.href); + const response = await http.request({ + providerId: "host-page", + method: "GET", + url: url.href, + responseType: "text", + cacheScope: "none", + urlPolicy: { trustClass: "builtin-public", hosts: [window.location.hostname], expectedOrigin: window.location.origin } + }, scope); + return String(response.data ?? response.responseText ?? ""); + } + __name(requestHostPage, "requestHostPage"); + // src/core/movie-identity.js var SIMPLE_CAR_PREFIXES2 = /* @__PURE__ */ new Set(["ABC", "ABP", "ADN", "ATID", "BF", "CAWD", "DLDSS", "DVAJ", "FSDSS", "HEYZO", "HMN", "IPX", "IPZZ", "JUQ", "JUL", "JUX", "MEYD", "MIAA", "MIDE", "MIDV", "MIMK", "MIRD", "NIMA", "PRED", "RBD", "SDDE", "SONE", "SSIS", "SSNI", "STARS", "URE", "VEC", "WAAA", "WANZ", "XVSR"]); function normalizeMovieCarNum(value) { @@ -6002,7 +6017,8 @@ let t2 = null; if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e2); - const n2 = await gmHttp.get(e2), a2 = utils.htmlTo$dom(n2); + const scope = await this.getRuntimeService("scope")(); + const n2 = await requestHostPage(this.getRuntimeService("http"), e2, scope), a2 = utils.htmlTo$dom(n2); r ? t2 = a2.find(".female").prev().map(((e3, t3) => $(t3).text())).get().join(" ") : l && (t2 = a2.find('span[onmouseover*="star_"] a').map(((e3, t3) => $(t3).text())).get().join(" ")), clog.debug("解析到名称:", t2); } return t2; @@ -13368,7 +13384,8 @@ ${error.stack}` : ""); this.isLoading = true, this.setState("waterfall-loading", "加载中..."); const t2 = this.getSelector(); try { - const i2 = await gmHttp.get(this.nextUrl); + const scope = await this.getRuntimeService("scope")(); + const i2 = await requestHostPage(this.getRuntimeService("http"), this.nextUrl, scope); clog.log("请求下一页内容:", this.nextUrl); const s2 = utils.htmlTo$dom(i2); l && s2.find(".avatar-box").length > 0 && s2.find(".avatar-box").parent().remove(); @@ -15440,7 +15457,8 @@ ${error.stack}` : ""); } if (!n2) return result; await utils.sleep(1e3); - const html = await gmHttp.get(new URL(n2, window.location.href).href), nextPage = utils.htmlTo$dom(html); + const scope = await this.getRuntimeService("scope")(); + const html = await requestHostPage(this.getRuntimeService("http"), new URL(n2, window.location.href), scope), nextPage = utils.htmlTo$dom(html); return this.parseMovieList(nextPage, result); } }; @@ -15490,8 +15508,8 @@ ${error.stack}` : ""); // src/plugins/registry.js var manifest = /* @__PURE__ */ __name((id, featureId, plugin, sites, order, requires = []) => defineContribution({ id, featureId, legacyPluginId: plugin.name, plugin, sites, order, requires }), "manifest"); var legacyContributionManifests = Object.freeze([ - manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation]), - manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }), + manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation, SERVICE.http]), + manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }, [SERVICE.http]), manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), @@ -15514,7 +15532,7 @@ ${error.stack}` : ""); manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie]), manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), - manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), + manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http]), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), @@ -15549,6 +15567,7 @@ ${error.stack}` : ""); [SERVICE.magnet, "magnet"], [SERVICE.settings, "settings"], [SERVICE.cache, "cache"], + [SERVICE.http, "http"], [SERVICE.actressInfo, "actressInfo"], [SERVICE.screenshot, "screenshot"], [SERVICE.translation, "translation"], @@ -15934,6 +15953,7 @@ ${error.stack}` : ""); throw new JhsError("INVALID_URL", "外部地址无效", { source: "ExternalUrlPolicy", cause }); } if (!(/* @__PURE__ */ new Set(["http:", "https:"])).has(url.protocol)) throw new JhsError("INVALID_URL", "仅允许 HTTP/HTTPS 地址", { source: "ExternalUrlPolicy" }); + if (policy.expectedOrigin && url.origin !== new URL(policy.expectedOrigin).origin) throw new JhsError("INVALID_URL", "地址离开已声明的精确 origin", { source: "ExternalUrlPolicy" }); if (policy.trustClass === "builtin-public") { if (url.protocol !== "https:" || !(policy.hosts ?? []).some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`))) { throw new JhsError("INVALID_URL", "地址不在 Integration manifest 允许范围", { source: "ExternalUrlPolicy" }); diff --git a/src/core/host-page-request.js b/src/core/host-page-request.js new file mode 100644 index 00000000..61627d4a --- /dev/null +++ b/src/core/host-page-request.js @@ -0,0 +1,20 @@ +// @ts-check + +/** + * Fetches HTML from the current host through HttpService and its URL policy. + * @param {{request: (options: Record, scope?: import("./lifecycle-scope.js").LifecycleScope) => Promise}} http + * @param {string | URL} input + * @param {import("./lifecycle-scope.js").LifecycleScope} [scope] + */ +export async function requestHostPage(http, input, scope) { + const url = new URL(input, window.location.href); + const response = await http.request({ + providerId: "host-page", + method: "GET", + url: url.href, + responseType: "text", + cacheScope: "none", + urlPolicy: { trustClass: "builtin-public", hosts: [window.location.hostname], expectedOrigin: window.location.origin }, + }, scope); + return String(response.data ?? response.responseText ?? ""); +} diff --git a/src/plugins/registry.js b/src/plugins/registry.js index b64f8612..898f6b68 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -50,8 +50,8 @@ import { TranslatePlugin } from "./translate/translate.js"; const manifest = (id, featureId, plugin, sites, order, requires = []) => defineContribution({ id, featureId, legacyPluginId: plugin.name, plugin, sites, order, requires }); export const legacyContributionManifests = Object.freeze([ - manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation]), - manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }), + manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation, SERVICE.http]), + manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }, [SERVICE.http]), manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), @@ -74,7 +74,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie]), manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), - manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }), + manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http]), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), @@ -108,7 +108,7 @@ export function registerSitePlugins(pluginManager, featureRuntime, site) { const runtimeNames = new Map([ [PORT.host, "host"], [SERVICE.diagnostics, "diagnostics"], [SERVICE.review, "review"], [SERVICE.related, "related"], [SERVICE.movie, "movie"], [SERVICE.magnet, "magnet"], - [SERVICE.settings, "settings"], [SERVICE.cache, "cache"], + [SERVICE.settings, "settings"], [SERVICE.cache, "cache"], [SERVICE.http, "http"], [SERVICE.actressInfo, "actressInfo"], [SERVICE.screenshot, "screenshot"], [SERVICE.translation, "translation"], diff --git a/src/plugins/status/auto-page.js b/src/plugins/status/auto-page.js index 83d03f44..83f06fe4 100644 --- a/src/plugins/status/auto-page.js +++ b/src/plugins/status/auto-page.js @@ -1,4 +1,5 @@ import { C, _, i, l, o, r } from "../../core/constants.js"; +import { requestHostPage } from "../../core/host-page-request.js"; import { BasePlugin } from "../../core/plugin-manager.js"; export class AutoPagePlugin extends BasePlugin { @@ -58,7 +59,8 @@ export class AutoPagePlugin extends BasePlugin { this.isLoading = !0, this.setState("waterfall-loading", "加载中..."); const t = this.getSelector(); try { - const i = await gmHttp.get(this.nextUrl); + const scope = await this.getRuntimeService("scope")(); + const i = await requestHostPage(this.getRuntimeService("http"), this.nextUrl, scope); clog.log("请求下一页内容:", this.nextUrl); const s = utils.htmlTo$dom(i); l && s.find(".avatar-box").length > 0 && s.find(".avatar-box").parent().remove(); diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index 24ab9115..d9656ee8 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -1,6 +1,7 @@ import { B, C, _, b, escapeHtml, i, k, l, normalizeCarNum, o, r, u, y } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { mapLimit, safePlay } from "../../core/feature-helpers.js"; +import { requestHostPage } from "../../core/host-page-request.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { readListItem } from "../../core/list-item-reader.js"; import { isHitShowPage } from "../../core/site-context.js"; @@ -466,7 +467,8 @@ export class ListPagePlugin extends BasePlugin { let t = null; if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e); - const n = await gmHttp.get(e), a = utils.htmlTo$dom(n); + const scope = await this.getRuntimeService("scope")(); + const n = await requestHostPage(this.getRuntimeService("http"), e, scope), a = utils.htmlTo$dom(n); r ? t = a.find(".female").prev().map(((e, t) => $(t).text())).get().join(" ") : l && (t = a.find('span[onmouseover*="star_"] a').map(((e, t) => $(t).text())).get().join(" ")), clog.debug("解析到名称:", t); } diff --git a/src/plugins/status/want-and-watched-videos.js b/src/plugins/status/want-and-watched-videos.js index 51de9fff..6c6d026e 100644 --- a/src/plugins/status/want-and-watched-videos.js +++ b/src/plugins/status/want-and-watched-videos.js @@ -1,4 +1,5 @@ import { i } from "../../core/constants.js"; +import { requestHostPage } from "../../core/host-page-request.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { stateService } from "../../core/state-service.js"; @@ -46,7 +47,8 @@ export class WantAndWatchedVideosPlugin extends BasePlugin { } if (!n) return result; await utils.sleep(1e3); - const html = await gmHttp.get(new URL(n, window.location.href).href), nextPage = utils.htmlTo$dom(html); + const scope = await this.getRuntimeService("scope")(); + const html = await requestHostPage(this.getRuntimeService("http"), new URL(n, window.location.href), scope), nextPage = utils.htmlTo$dom(html); return this.parseMovieList(nextPage, result); } } diff --git a/src/services/external-url-policy.js b/src/services/external-url-policy.js index aa3bee11..e6664e4d 100644 --- a/src/services/external-url-policy.js +++ b/src/services/external-url-policy.js @@ -30,6 +30,7 @@ export class ExternalUrlPolicy { try { url = input instanceof URL ? input : new URL(input); } catch (cause) { throw new JhsError("INVALID_URL", "外部地址无效", { source: "ExternalUrlPolicy", cause }); } if (!new Set(["http:", "https:"]).has(url.protocol)) throw new JhsError("INVALID_URL", "仅允许 HTTP/HTTPS 地址", { source: "ExternalUrlPolicy" }); + if (policy.expectedOrigin && url.origin !== new URL(policy.expectedOrigin).origin) throw new JhsError("INVALID_URL", "地址离开已声明的精确 origin", { source: "ExternalUrlPolicy" }); if (policy.trustClass === "builtin-public") { if (url.protocol !== "https:" || !(policy.hosts ?? []).some((host) => url.hostname === host || url.hostname.endsWith(`.${host}`))) { throw new JhsError("INVALID_URL", "地址不在 Integration manifest 允许范围", { source: "ExternalUrlPolicy" }); diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js index 972b98f0..6bc373f9 100644 --- a/tests/architecture-runtime.test.js +++ b/tests/architecture-runtime.test.js @@ -51,7 +51,7 @@ describe("v6.5 architecture runtime contracts", () => { const { registerSitePlugins } = await import("../src/plugins/registry.js"); const createRuntime = (site, disabled = []) => { const diagnostics = new DiagnosticsService(); - const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.settings, {}).register(SERVICE.storage, {}).register(SERVICE.cache, {}); + const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.settings, {}).register(SERVICE.storage, {}).register(SERVICE.cache, {}).register(SERVICE.http, {}); const runtime = new FeatureRuntime({ container, commands: new CommandRegistry(), diagnostics, disabled, site, route: "list" }); featureManifests.forEach((manifest) => runtime.register(manifest)); return runtime; diff --git a/tests/host-page-request.test.js b/tests/host-page-request.test.js new file mode 100644 index 00000000..70e64638 --- /dev/null +++ b/tests/host-page-request.test.js @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { requestHostPage } from "../src/core/host-page-request.js"; + +describe("host page request", () => { + it("uses HttpService with the current HTTPS host and lifecycle scope", async () => { + const previousWindow = globalThis.window; + const scope = {}, request = vi.fn(async () => ({ status: 200, data: "
    ok
    " })); + globalThis.window = { location: new URL("https://javdb.com/list") }; + try { + await expect(requestHostPage({ request }, "/page/2", scope)).resolves.toBe("
    ok
    "); + expect(request).toHaveBeenCalledWith({ + providerId: "host-page", + method: "GET", + url: "https://javdb.com/page/2", + responseType: "text", + cacheScope: "none", + urlPolicy: { trustClass: "builtin-public", hosts: ["javdb.com"], expectedOrigin: "https://javdb.com" }, + }, scope); + } finally { + globalThis.window = previousWindow; + } + }); +}); diff --git a/tests/http-service.test.js b/tests/http-service.test.js index 4d95934a..5d789563 100644 --- a/tests/http-service.test.js +++ b/tests/http-service.test.js @@ -38,6 +38,13 @@ describe("HTTP, URL and settings contracts", () => { await expect(service.request({ providerId: "example", url: "https://api.example.test/data", urlPolicy: { trustClass: "builtin-public", hosts: ["api.example.test"] }, cacheScope: "none" })).rejects.toMatchObject({ code: "INVALID_URL" }); }); + it("can constrain builtin requests and redirects to an exact origin", () => { + const policy = new ExternalUrlPolicy(), contract = { trustClass: "builtin-public", hosts: ["example.test"], expectedOrigin: "https://example.test" }; + expect(policy.assertAllowed("https://example.test/page", contract).pathname).toBe("/page"); + expect(() => policy.assertAllowed("https://api.example.test/page", contract)).toThrow(/精确 origin/); + expect(() => policy.assertFinalUrl("https://api.example.test/page", contract)).toThrow(/精确 origin/); + }); + it("permits user-local only for an explicitly authorized exact origin", () => { const policy = new ExternalUrlPolicy({ localOrigins: ["http://192.168.1.10:5244"] }); expect(policy.assertAllowed("http://192.168.1.10:5244/dav", { trustClass: "user-local", expectedOrigin: "http://192.168.1.10:5244" }).pathname).toBe("/dav"); diff --git a/tests/javdb-status-import.test.js b/tests/javdb-status-import.test.js index 2e4fb13d..f087f5f9 100644 --- a/tests/javdb-status-import.test.js +++ b/tests/javdb-status-import.test.js @@ -11,7 +11,7 @@ function loadImporter(html = '
    get.mockResolvedValueOnce(response))); const context = vm.createContext({ document: dom.window.document, window: dom.window, DOMParser: dom.window.DOMParser, $, - BasePlugin: class { getSelector() { return { itemSelector: ".movie-list .item" }; } }, stateService: { patch }, gmHttp: { get }, utils: { q: vi.fn(), sleep: vi.fn(), htmlTo$dom: value => jqueryFactory(dom.window)(new dom.window.DOMParser().parseFromString(value, "text/html")) }, show: { info: vi.fn(), ok: vi.fn(), error: vi.fn() }, clog: { error: vi.fn() }, loading: () => ({ close() {} }), i: (target, key, value) => (target[key] = value), setTimeout, URL + BasePlugin: class { getSelector() { return { itemSelector: ".movie-list .item" }; } getRuntimeService(name) { return "scope" === name ? () => ({}) : {}; } }, stateService: { patch }, requestHostPage: (_http, url) => get(String(url)), utils: { q: vi.fn(), sleep: vi.fn(), htmlTo$dom: value => jqueryFactory(dom.window)(new dom.window.DOMParser().parseFromString(value, "text/html")) }, show: { info: vi.fn(), ok: vi.fn(), error: vi.fn() }, clog: { error: vi.fn() }, loading: () => ({ close() {} }), i: (target, key, value) => (target[key] = value), setTimeout, URL }); vm.runInContext(`${readTestFile(join(process.cwd(), "src/plugins/status/want-and-watched-videos.js"), "utf8")};globalThis.Importer=WantAndWatchedVideosPlugin`, context); return { plugin: new context.Importer, patch, get }; From 41a6e83b517cd01dcbc08a9205df57a399dd6cf9 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:04:46 +0800 Subject: [PATCH 015/130] =?UTF-8?q?Refactor:=20=E6=89=A9=E5=B1=95=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E5=85=BC=E5=AE=B9=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 在既有 StoragePort 中增加 legacy local key 的显式访问方法 - 保持 IndexedDB 数据与 localStorage key 为独立 namespace - 将以图识图站点选择、磁力来源选择和标签折叠迁至声明式 StorageService - 补充两个存储 namespace 隔离测试 修改原因: - 消除三组 Feature 对 localStorage 的直接依赖 - 保留现有 key、值格式和用户偏好行为,不触发数据迁移 验证: - npm run check 通过(95 个测试文件,531 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 44 ++++++++++++++----- src/app/bootstrap.js | 2 +- src/app/create-app-context.js | 4 +- src/contracts/ports.js | 2 +- .../userscript/indexeddb-storage-adapter.js | 10 ++++- src/plugins/avatar/search-by-image.js | 4 +- src/plugins/external-search/magnet-hub.js | 4 +- src/plugins/registry.js | 6 +-- src/plugins/status/list-page.js | 4 +- src/services/storage-service.js | 6 +++ tests/storage-service.test.js | 27 ++++++++++++ 11 files changed, 86 insertions(+), 27 deletions(-) create mode 100644 tests/storage-service.test.js diff --git a/JHS.user.js b/JHS.user.js index 0cc1b382..b3721d30 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -4106,7 +4106,7 @@ try { const t3 = await this.searchByImage(e3); s2.hide(), d2.show(), h2.empty(); - const n3 = "jhs_selectedSites", a3 = JSON.parse(localStorage.getItem(n3) || "{}"); + const storage = this.getRuntimeService("storage"), n3 = "jhs_selectedSites", a3 = JSON.parse(storage.getLocal(n3) || "{}"); this.siteList.forEach(((e4) => { const n4 = e4.url.replace("{占位符}", encodeURIComponent(t3)), i3 = false !== a3[e4.name]; h2.append(` @@ -4122,7 +4122,7 @@ `); })), h2.on("change", ".site-checkbox", (function() { const e4 = $(this).data("site-name"); - a3[e4] = $(this).is(":checked"), localStorage.setItem(n3, JSON.stringify(a3)); + a3[e4] = $(this).is(":checked"), storage.setLocal(n3, JSON.stringify(a3)); })), h2.show(); } finally { this.isUploading = false; @@ -5710,10 +5710,10 @@ if (!window.location.href.includes("actors")) return; const e2 = $(".tag-expand"); if (0 === e2.length) return; - const t2 = "jhs_tag_expand", n2 = "true" === localStorage.getItem(t2), a2 = $(".actor-tags .content"); + const storage = this.getRuntimeService("storage"), t2 = "jhs_tag_expand", n2 = "true" === storage.getLocal(t2), a2 = $(".actor-tags .content"); n2 && a2.hasClass("collapse") && e2[0].click(), e2.on("click", (function() { const e3 = !$(".actor-tags .content").hasClass("collapse"); - clog.debug("触发"), localStorage.setItem(t2, e3.toString()); + clog.debug("触发"), storage.setLocal(t2, e3.toString()); })); } checkDom(scope = null) { @@ -9746,7 +9746,7 @@ ${error.stack}` : ""); await this.initializeSources(); e2 = e2.replace("FC2-", ""); const root = options.root ? $(options.root) : $(document), engines = [...this.searchEngines]; - const t2 = $('
    '), n2 = $('
    '), a2 = "jhs_magnetHub_selectedEngine", i2 = localStorage.getItem(a2); + const storage = this.getRuntimeService("storage"), t2 = $('
    '), n2 = $('
    '), a2 = "jhs_magnetHub_selectedEngine", i2 = storage.getLocal(a2); const o2 = $('
    '); let currentEngine = engines.find(((engine) => engine.id === i2)) || engines[0] || null; if (!currentEngine) return t2.append($('
    ').text("暂无可用磁力来源,请前往设置启用来源")); @@ -9756,7 +9756,7 @@ ${error.stack}` : ""); const r2 = $('
    '); return t2.append(r2), t2.on("click", ".magnet-tab", ((n3) => { const i3 = $(n3.target).data("engine"); - currentEngine = engines.find(((e3) => e3.id === i3)), t2.find('[data-jhs-role="magnet-target"]').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id), localStorage.setItem(a2, i3), t2.find(".magnet-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }), $(n3.target).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }), this.searchEngine(r2, currentEngine, e2, root); + currentEngine = engines.find(((e3) => e3.id === i3)), t2.find('[data-jhs-role="magnet-target"]').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e2))).toggle("all" !== currentEngine.id), storage.setLocal(a2, i3), t2.find(".magnet-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }), $(n3.target).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }), this.searchEngine(r2, currentEngine, e2, root); })), t2.on("keydown", ".magnet-tab", ((e3) => { if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e3.key)) return; e3.preventDefault(); @@ -15508,7 +15508,7 @@ ${error.stack}` : ""); // src/plugins/registry.js var manifest = /* @__PURE__ */ __name((id, featureId, plugin, sites, order, requires = []) => defineContribution({ id, featureId, legacyPluginId: plugin.name, plugin, sites, order, requires }), "manifest"); var legacyContributionManifests = Object.freeze([ - manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation, SERVICE.http]), + manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation, SERVICE.http, SERVICE.storage]), manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }, [SERVICE.http]), manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), @@ -15518,7 +15518,7 @@ ${error.stack}` : ""); manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), - manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog]), + manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog, SERVICE.storage]), manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), @@ -15533,7 +15533,7 @@ ${error.stack}` : ""); manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie]), manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http]), - manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), + manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }, [SERVICE.storage]), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), @@ -15607,7 +15607,7 @@ ${error.stack}` : ""); var PORT_METHODS = Object.freeze({ navigation: ["open", "assign", "replace"], http: ["request"], - storage: ["get", "set", "remove"], + storage: ["get", "set", "remove", "getLocal", "setLocal", "removeLocal"], dialog: ["open", "close", "confirm", "alert"], style: ["register", "remove"] }); @@ -15654,8 +15654,9 @@ ${error.stack}` : ""); // src/platform/userscript/indexeddb-storage-adapter.js var _IndexedDbStorageAdapter = class _IndexedDbStorageAdapter { - constructor(forage) { + constructor(forage, localStore) { this.forage = forage; + this.localStore = localStore; } get(key) { return this.forage.getItem(key); @@ -15666,6 +15667,15 @@ ${error.stack}` : ""); async remove(key) { await this.forage.removeItem(key); } + getLocal(key) { + return this.localStore.getItem(key); + } + setLocal(key, value) { + this.localStore.setItem(key, value); + } + removeLocal(key) { + this.localStore.removeItem(key); + } }; __name(_IndexedDbStorageAdapter, "IndexedDbStorageAdapter"); var IndexedDbStorageAdapter = _IndexedDbStorageAdapter; @@ -16436,6 +16446,15 @@ ${error.stack}` : ""); remove(key) { return this.port.remove(key); } + getLocal(key) { + return this.port.getLocal(key); + } + setLocal(key, value) { + return this.port.setLocal(key, value); + } + removeLocal(key) { + return this.port.removeLocal(key); + } }; __name(_StorageService, "StorageService"); var StorageService = _StorageService; @@ -16801,7 +16820,7 @@ ${error.stack}` : ""); const container = new DependencyContainer(diagnostics); const navigationPort = new BrowserNavigationAdapter(); const httpPort = new UserscriptHttpAdapter(runtime.gmRequest); - const storagePort = new IndexedDbStorageAdapter(runtime.storageForage); + const storagePort = new IndexedDbStorageAdapter(runtime.storageForage, runtime.localStorage); const dialogPort = new LayerDialogAdapter(runtime.layer); const stylePort = new BrowserStyleAdapter(); const urlPolicy = new ExternalUrlPolicy({ localOrigins: runtime.localOrigins }); @@ -17688,6 +17707,7 @@ ${error.stack}` : ""); const context = createAppContext({ gmRequest: globalThis.GM_xmlhttpRequest, storageForage: storageManager2.forage, + localStorage: globalThis.localStorage, layer: vendors.layer, hostAdapter, site: siteContext2.site, diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js index 5c582e26..b06b0654 100644 --- a/src/app/bootstrap.js +++ b/src/app/bootstrap.js @@ -95,7 +95,7 @@ export async function bootstrapJhs() { const hostAdapter = r ? new JavDbHostAdapter() : l ? new JavBusHostAdapter() : null; const route = hostAdapter?.detectRoute() ?? "other"; const context = createAppContext({ - gmRequest: globalThis.GM_xmlhttpRequest, storageForage: storageManager.forage, + gmRequest: globalThis.GM_xmlhttpRequest, storageForage: storageManager.forage, localStorage: globalThis.localStorage, layer: vendors.layer, hostAdapter, site: siteContext.site, route, disabled, localOrigins: localOriginSettings.origins, }); const settingsSnapshot = await context.services.settings.load(); diff --git a/src/app/create-app-context.js b/src/app/create-app-context.js index 766034c5..33c6577e 100644 --- a/src/app/create-app-context.js +++ b/src/app/create-app-context.js @@ -33,14 +33,14 @@ import { IntegrationRegistry } from "./integration-registry.js"; import { SettingsRegistry } from "./settings-registry.js"; import { LifecycleScope } from "../core/lifecycle-scope.js"; -/** @param {{gmRequest: (options: Record) => any, storageForage: any, layer: any, hostAdapter?: any, disabled?: string[], site?: string, route?: string, localOrigins?: string[]}} runtime */ +/** @param {{gmRequest: (options: Record) => any, storageForage: any, localStorage: Storage, layer: any, hostAdapter?: any, disabled?: string[], site?: string, route?: string, localOrigins?: string[]}} runtime */ export function createAppContext(runtime) { const diagnostics = new DiagnosticsService(); const rootScope = new LifecycleScope("app:root", { onChange: (snapshot) => diagnostics.updateScope(snapshot) }); const container = new DependencyContainer(diagnostics); const navigationPort = new BrowserNavigationAdapter(); const httpPort = new UserscriptHttpAdapter(runtime.gmRequest); - const storagePort = new IndexedDbStorageAdapter(runtime.storageForage); + const storagePort = new IndexedDbStorageAdapter(runtime.storageForage, runtime.localStorage); const dialogPort = new LayerDialogAdapter(runtime.layer); const stylePort = new BrowserStyleAdapter(); const urlPolicy = new ExternalUrlPolicy({ localOrigins: runtime.localOrigins }); diff --git a/src/contracts/ports.js b/src/contracts/ports.js index eb34eb63..9fca71fd 100644 --- a/src/contracts/ports.js +++ b/src/contracts/ports.js @@ -12,7 +12,7 @@ export function assertPort(implementation, name, methods) { export const PORT_METHODS = Object.freeze({ navigation: ["open", "assign", "replace"], http: ["request"], - storage: ["get", "set", "remove"], + storage: ["get", "set", "remove", "getLocal", "setLocal", "removeLocal"], dialog: ["open", "close", "confirm", "alert"], style: ["register", "remove"], }); diff --git a/src/platform/userscript/indexeddb-storage-adapter.js b/src/platform/userscript/indexeddb-storage-adapter.js index 906c9e20..346ed6e8 100644 --- a/src/platform/userscript/indexeddb-storage-adapter.js +++ b/src/platform/userscript/indexeddb-storage-adapter.js @@ -1,12 +1,18 @@ // @ts-check export class IndexedDbStorageAdapter { - /** @param {{getItem: (key: string) => Promise, setItem: (key: string, value: unknown) => Promise, removeItem: (key: string) => Promise}} forage */ - constructor(forage) { this.forage = forage; } + /** @param {{getItem: (key: string) => Promise, setItem: (key: string, value: unknown) => Promise, removeItem: (key: string) => Promise}} forage @param {Storage} localStore */ + constructor(forage, localStore) { this.forage = forage; this.localStore = localStore; } /** @param {string} key */ get(key) { return this.forage.getItem(key); } /** @param {string} key @param {unknown} value */ async set(key, value) { await this.forage.setItem(key, value); } /** @param {string} key */ async remove(key) { await this.forage.removeItem(key); } + /** @param {string} key */ + getLocal(key) { return this.localStore.getItem(key); } + /** @param {string} key @param {string} value */ + setLocal(key, value) { this.localStore.setItem(key, value); } + /** @param {string} key */ + removeLocal(key) { this.localStore.removeItem(key); } } diff --git a/src/plugins/avatar/search-by-image.js b/src/plugins/avatar/search-by-image.js index b6ed0a20..0af375ed 100644 --- a/src/plugins/avatar/search-by-image.js +++ b/src/plugins/avatar/search-by-image.js @@ -67,13 +67,13 @@ export class SearchByImagePlugin extends BasePlugin { try { const t = await this.searchByImage(e); s.hide(), d.show(), h.empty(); - const n = "jhs_selectedSites", a = JSON.parse(localStorage.getItem(n) || "{}"); + const storage = this.getRuntimeService("storage"), n = "jhs_selectedSites", a = JSON.parse(storage.getLocal(n) || "{}"); this.siteList.forEach((e => { const n = e.url.replace("{占位符}", encodeURIComponent(t)), i = !1 !== a[e.name]; h.append(`\n
    \n \n ${e.name}\n ${e.name}\n \n `); })), h.on("change", ".site-checkbox", (function() { const e = $(this).data("site-name"); - a[e] = $(this).is(":checked"), localStorage.setItem(n, JSON.stringify(a)); + a[e] = $(this).is(":checked"), storage.setLocal(n, JSON.stringify(a)); })), h.show(); } finally { this.isUploading = !1; diff --git a/src/plugins/external-search/magnet-hub.js b/src/plugins/external-search/magnet-hub.js index b388f05a..de9b4f56 100644 --- a/src/plugins/external-search/magnet-hub.js +++ b/src/plugins/external-search/magnet-hub.js @@ -62,7 +62,7 @@ export class MagnetHubPlugin extends BasePlugin { await this.initializeSources(); e = e.replace("FC2-", ""); const root = options.root ? $(options.root) : $(document), engines = [ ...this.searchEngines ]; - const t = $('
    '), n = $('
    '), a = "jhs_magnetHub_selectedEngine", i = localStorage.getItem(a); + const storage = this.getRuntimeService("storage"), t = $('
    '), n = $('
    '), a = "jhs_magnetHub_selectedEngine", i = storage.getLocal(a); const o = $('
    '); let currentEngine = engines.find((engine => engine.id === i)) || engines[0] || null; if (!currentEngine) return t.append($('
    ').text("暂无可用磁力来源,请前往设置启用来源")); @@ -75,7 +75,7 @@ export class MagnetHubPlugin extends BasePlugin { return t.append(r), t.on("click", ".magnet-tab", (n => { const i = $(n.target).data("engine"); currentEngine = engines.find((e => e.id === i)), t.find('[data-jhs-role="magnet-target"]').attr("href", currentEngine.targetPage.replace("{keyword}", encodeURIComponent(e))).toggle("all" !== currentEngine.id), - localStorage.setItem(a, i), t.find(".magnet-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }), $(n.target).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }), + storage.setLocal(a, i), t.find(".magnet-tab").removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }), $(n.target).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }), this.searchEngine(r, currentEngine, e, root); })), t.on("keydown", ".magnet-tab", (e => { if (![ "ArrowLeft", "ArrowRight", "Home", "End" ].includes(e.key)) return; diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 898f6b68..415eb288 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -50,7 +50,7 @@ import { TranslatePlugin } from "./translate/translate.js"; const manifest = (id, featureId, plugin, sites, order, requires = []) => defineContribution({ id, featureId, legacyPluginId: plugin.name, plugin, sites, order, requires }); export const legacyContributionManifests = Object.freeze([ - manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation, SERVICE.http]), + manifest("list.core", "list", ListPagePlugin, ["javdb", "javbus"], { javdb: 1, javbus: 1 }, [SERVICE.translation, SERVICE.http, SERVICE.storage]), manifest("list.auto-page", "list", AutoPagePlugin, ["javdb", "javbus"], { javdb: 2, javbus: 5 }, [SERVICE.http]), manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), @@ -60,7 +60,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), - manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog]), + manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog, SERVICE.storage]), manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), @@ -75,7 +75,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie]), manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http]), - manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }), + manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }, [SERVICE.storage]), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index d9656ee8..5d774dab 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -217,10 +217,10 @@ export class ListPagePlugin extends BasePlugin { if (!window.location.href.includes("actors")) return; const e = $(".tag-expand"); if (0 === e.length) return; - const t = "jhs_tag_expand", n = "true" === localStorage.getItem(t), a = $(".actor-tags .content"); + const storage = this.getRuntimeService("storage"), t = "jhs_tag_expand", n = "true" === storage.getLocal(t), a = $(".actor-tags .content"); n && a.hasClass("collapse") && e[0].click(), e.on("click", (function() { const e = !$(".actor-tags .content").hasClass("collapse"); - clog.debug("触发"), localStorage.setItem(t, e.toString()); + clog.debug("触发"), storage.setLocal(t, e.toString()); })); } checkDom(scope = null) { diff --git a/src/services/storage-service.js b/src/services/storage-service.js index a463971b..5eb14a94 100644 --- a/src/services/storage-service.js +++ b/src/services/storage-service.js @@ -11,4 +11,10 @@ export class StorageService { set(key, value) { return this.port.set(key, value); } /** @param {string} key */ remove(key) { return this.port.remove(key); } + /** @param {string} key */ + getLocal(key) { return this.port.getLocal(key); } + /** @param {string} key @param {string} value */ + setLocal(key, value) { return this.port.setLocal(key, value); } + /** @param {string} key */ + removeLocal(key) { return this.port.removeLocal(key); } } diff --git a/tests/storage-service.test.js b/tests/storage-service.test.js new file mode 100644 index 00000000..93b6487d --- /dev/null +++ b/tests/storage-service.test.js @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from "vitest"; +import { IndexedDbStorageAdapter } from "../src/platform/userscript/indexeddb-storage-adapter.js"; +import { StorageService } from "../src/services/storage-service.js"; + +describe("storage service", () => { + it("keeps IndexedDB data and legacy local keys in separate namespaces", async () => { + const indexed = new Map(), local = new Map(); + const forage = { + getItem: vi.fn(async key => indexed.get(key)), + setItem: vi.fn(async (key, value) => indexed.set(key, value)), + removeItem: vi.fn(async key => indexed.delete(key)), + }; + const localStore = { + getItem: vi.fn(key => local.get(key) ?? null), + setItem: vi.fn((key, value) => local.set(key, value)), + removeItem: vi.fn(key => local.delete(key)), + }; + const storage = new StorageService(new IndexedDbStorageAdapter(forage, localStore)); + await storage.set("setting", { theme: "dark" }); + storage.setLocal("jhs_tag_expand", "true"); + await expect(storage.get("setting")).resolves.toEqual({ theme: "dark" }); + expect(storage.getLocal("jhs_tag_expand")).toBe("true"); + expect(indexed.has("jhs_tag_expand")).toBe(false); + storage.removeLocal("jhs_tag_expand"); + expect(storage.getLocal("jhs_tag_expand")).toBeNull(); + }); +}); From 458dc9bca1ed999c1518fa179461dbcf64994ae2 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:08:32 +0800 Subject: [PATCH 016/130] =?UTF-8?q?Refactor:=20=E7=BB=9F=E4=B8=80=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E7=BD=91=E7=BB=9C=E8=AF=8A=E6=96=AD=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 由 DiagnosticsService 代理旧 HTTP runtime 的熔断与域名统计控制 - 将设置页网络面板迁至声明式 DiagnosticsService - 将缓存查看与清理迁至 StorageService 的 local 兼容通道 - 保持缓存 key、清理范围和显示格式不变 修改原因: - 消除 Settings Feature 对 gmHttp 和 localStorage 的直接依赖 - 避免共线债务在单项迁移时改变指纹位置 验证: - npm run check 通过(95 个测试文件,532 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 47 +++++++++++++++++++--------- src/app/bootstrap.js | 2 +- src/app/create-app-context.js | 4 +-- src/plugins/backup/setting-panels.js | 10 +++--- src/plugins/backup/setting.js | 18 +++++------ src/plugins/registry.js | 2 +- src/services/diagnostics-service.js | 14 ++++++++- tests/http-service.test.js | 19 +++++++++++ 8 files changed, 82 insertions(+), 34 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index b3721d30..616c4258 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -6970,8 +6970,8 @@ __name(buildQuickSettingHtml, "buildQuickSettingHtml"); // src/plugins/backup/setting-panels.js - async function renderNetworkPanel() { - const e2 = gmHttp.getCircuitBreakerStatus(), t2 = gmHttp.getDomainStats(), n2 = await storageManager.getSetting("circuitBreakerThreshold", 3), a2 = await storageManager.getSetting("circuitBreakerCooldown", 6e4); + async function renderNetworkPanel(diagnostics) { + const network = diagnostics.getNetworkDiagnostics(), e2 = network.circuitBreakers, t2 = network.domainStats, n2 = await storageManager.getSetting("circuitBreakerThreshold", 3), a2 = await storageManager.getSetting("circuitBreakerCooldown", 6e4); $("#circuitBreakerThreshold").val(n2), $("#circuitBreakerCooldownSec").val(Math.round(a2 / 1e3)); const i2 = Object.entries(e2); if (i2.length) { @@ -6993,11 +6993,11 @@ } else $("#domain-stats-table").html('

    暂无统计数据

    '); $(".reset-breaker").off("click").on("click", ((e3) => { const t3 = $(e3.target).data("domain"); - gmHttp.resetCircuitBreaker(t3), show.ok(`已重置 ${t3} 的熔断状态`), renderNetworkPanel(); + diagnostics.resetCircuitBreaker(t3), show.ok(`已重置 ${t3} 的熔断状态`), renderNetworkPanel(diagnostics); })), $("#resetAllBreakersBtn").off("click").on("click", (() => { - gmHttp.resetAllCircuitBreakers(), show.ok("已重置全部熔断状态"), renderNetworkPanel(); + diagnostics.resetAllCircuitBreakers(), show.ok("已重置全部熔断状态"), renderNetworkPanel(diagnostics); })), $("#clearDomainStatsBtn").off("click").on("click", (() => { - gmHttp.clearDomainStats(), show.ok("已清空域名统计"), renderNetworkPanel(); + diagnostics.clearDomainStats(), show.ok("已清空域名统计"), renderNetworkPanel(diagnostics); })); } __name(renderNetworkPanel, "renderNetworkPanel"); @@ -7549,11 +7549,11 @@ }); } bindClick() { - const settingPlugin = this, webdav = this.getRuntimeService("webdav"), dialog = this.getRuntimeService("dialog"), previewDiff = /* @__PURE__ */ __name((diff, imported, restored = null) => showDiffPreview(diff, imported, restored, dialog), "previewDiff"); + const settingPlugin = this, webdav = this.getRuntimeService("webdav"), dialog = this.getRuntimeService("dialog"), diagnostics = this.getRuntimeService("diagnostics"), storage = this.getRuntimeService("storage"), previewDiff = /* @__PURE__ */ __name((diff, imported, restored = null) => showDiffPreview(diff, imported, restored, dialog), "previewDiff"); $(".side-menu-item").on("click", (function() { $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); const e3 = $(this).data("panel"); - $("#" + e3).show(), "cache-panel" === e3 ? ($("#saveBtn").hide(), $("#clean-all").removeClass("jhs-is-hidden")) : ($("#saveBtn").show(), $("#clean-all").addClass("jhs-is-hidden")), "health-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderDataHealthPanel()), "plugin-mgmt-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel(settingPlugin.getRuntimeService("diagnostics"))), "snapshot-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), "network-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel()); + $("#" + e3).show(), "cache-panel" === e3 ? ($("#saveBtn").hide(), $("#clean-all").removeClass("jhs-is-hidden")) : ($("#saveBtn").show(), $("#clean-all").addClass("jhs-is-hidden")), "health-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderDataHealthPanel()), "plugin-mgmt-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel(settingPlugin.getRuntimeService("diagnostics"))), "snapshot-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), "network-panel" === e3 && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel(diagnostics)); })), $("#importBtn").on("click", ((e3) => importSettingData(previewDiff))), $("#exportBtn").on("click", ((e3) => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async (e3) => this.confirmCarNumbers(e3))), $("#webdavBackupBtn").on("click", ((e3) => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", ((e3) => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, previewDiff, dialog), webdav))), $("#saveBtn").on("click", (() => saveSettingForm(this.getFormDependencies()))), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", ((e3) => { utils.q(e3, "修复前会自动下载备份,是否继续?", (() => repairDataHealthWithBackup())); })), $("#pm-clear-log").on("click", (() => { @@ -7569,16 +7569,16 @@ } })), $(".clean-btn").on("click", (async (e3) => { const t3 = $(e3.currentTarget).data("key"), n3 = this.cacheItems.find(((e4) => e4.key === t3)); - t3 === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : "_circuitBreaker" === t3 ? gmHttp.resetAllCircuitBreakers() : "_domainStats" === t3 ? gmHttp.clearDomainStats() : localStorage.removeItem(t3), show.ok(`${n3.text} 清理成功`), $("#cache-data-display").addClass("jhs-is-hidden"), "jhs_dmm_video" === t3 && localStorage.removeItem("jhs_other_site_dmm"); + t3 === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : "_circuitBreaker" === t3 ? diagnostics.resetAllCircuitBreakers() : "_domainStats" === t3 ? diagnostics.clearDomainStats() : storage.removeLocal(t3), show.ok(`${n3.text} 清理成功`), $("#cache-data-display").addClass("jhs-is-hidden"), "jhs_dmm_video" === t3 && storage.removeLocal("jhs_other_site_dmm"); })), $("#clean-all").on("click", (async () => { - this.cacheItems.forEach(((e3) => localStorage.removeItem(e3.key))), show.ok("全部缓存已清理"), $("#cache-data-display").addClass("jhs-is-hidden"), localStorage.removeItem("jhs_other_site_dmm"), await storageManager.clearThirdPartyCache(); + this.cacheItems.forEach(((e3) => storage.removeLocal(e3.key))), show.ok("全部缓存已清理"), $("#cache-data-display").addClass("jhs-is-hidden"), storage.removeLocal("jhs_other_site_dmm"), await storageManager.clearThirdPartyCache(); })), $(".view-btn").on("click", (async (e3) => { const t3 = $(e3.currentTarget).data("key"); let n3; if (t3 === storageManager.third_party_cache_key) n3 = JSON.stringify(await storageManager.getThirdPartyCache()); - else if ("_circuitBreaker" === t3) n3 = JSON.stringify(gmHttp.getCircuitBreakerStatus()); - else if ("_domainStats" === t3) n3 = JSON.stringify(gmHttp.getDomainStats()); - else n3 = localStorage.getItem(t3); + else if ("_circuitBreaker" === t3) n3 = JSON.stringify(diagnostics.getNetworkDiagnostics().circuitBreakers); + else if ("_domainStats" === t3) n3 = JSON.stringify(diagnostics.getNetworkDiagnostics().domainStats); + else n3 = storage.getLocal(t3); const a3 = $("#cache-data-display"), i2 = a3.find("pre"); if (a3.removeClass("jhs-is-hidden"), n3) try { const e4 = JSON.parse(n3); @@ -15514,7 +15514,7 @@ ${error.stack}` : ""); manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog]), - manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog]), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog, SERVICE.storage]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), @@ -15833,7 +15833,7 @@ ${error.stack}` : ""); } __name(sanitize, "sanitize"); var _DiagnosticsService = class _DiagnosticsService { - constructor() { + constructor(options = {}) { this.startedAt = performance.now(); this.activeFeatures = /* @__PURE__ */ new Set(); this.activeContributions = /* @__PURE__ */ new Set(); @@ -15847,6 +15847,7 @@ ${error.stack}` : ""); this.legacyPlugins = []; this.legacyStartup = null; this.legacyTimings = []; + this.legacyHttp = options.legacyHttp ?? null; } recordStartup(id, durationMs) { this.startupTimings.set(id, durationMs); @@ -15885,6 +15886,21 @@ ${error.stack}` : ""); clearErrors() { this.errors = []; } + getNetworkDiagnostics() { + return Object.freeze({ + circuitBreakers: this.legacyHttp?.getCircuitBreakerStatus?.() ?? {}, + domainStats: this.legacyHttp?.getDomainStats?.() ?? {} + }); + } + resetCircuitBreaker(domain) { + this.legacyHttp?.resetCircuitBreaker?.(domain); + } + resetAllCircuitBreakers() { + this.legacyHttp?.resetAllCircuitBreakers?.(); + } + clearDomainStats() { + this.legacyHttp?.clearDomainStats?.(); + } exportSnapshot() { const scopes = [...this.scopes.values()]; return Object.freeze({ @@ -16815,7 +16831,7 @@ ${error.stack}` : ""); // src/app/create-app-context.js function createAppContext(runtime) { - const diagnostics = new DiagnosticsService(); + const diagnostics = new DiagnosticsService({ legacyHttp: runtime.legacyHttp }); const rootScope = new LifecycleScope("app:root", { onChange: /* @__PURE__ */ __name((snapshot) => diagnostics.updateScope(snapshot), "onChange") }); const container = new DependencyContainer(diagnostics); const navigationPort = new BrowserNavigationAdapter(); @@ -17706,6 +17722,7 @@ ${error.stack}` : ""); const route = hostAdapter?.detectRoute() ?? "other"; const context = createAppContext({ gmRequest: globalThis.GM_xmlhttpRequest, + legacyHttp: gmHttp2, storageForage: storageManager2.forage, localStorage: globalThis.localStorage, layer: vendors.layer, diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js index b06b0654..0286dafd 100644 --- a/src/app/bootstrap.js +++ b/src/app/bootstrap.js @@ -95,7 +95,7 @@ export async function bootstrapJhs() { const hostAdapter = r ? new JavDbHostAdapter() : l ? new JavBusHostAdapter() : null; const route = hostAdapter?.detectRoute() ?? "other"; const context = createAppContext({ - gmRequest: globalThis.GM_xmlhttpRequest, storageForage: storageManager.forage, localStorage: globalThis.localStorage, + gmRequest: globalThis.GM_xmlhttpRequest, legacyHttp: gmHttp, storageForage: storageManager.forage, localStorage: globalThis.localStorage, layer: vendors.layer, hostAdapter, site: siteContext.site, route, disabled, localOrigins: localOriginSettings.origins, }); const settingsSnapshot = await context.services.settings.load(); diff --git a/src/app/create-app-context.js b/src/app/create-app-context.js index 33c6577e..47a251ca 100644 --- a/src/app/create-app-context.js +++ b/src/app/create-app-context.js @@ -33,9 +33,9 @@ import { IntegrationRegistry } from "./integration-registry.js"; import { SettingsRegistry } from "./settings-registry.js"; import { LifecycleScope } from "../core/lifecycle-scope.js"; -/** @param {{gmRequest: (options: Record) => any, storageForage: any, localStorage: Storage, layer: any, hostAdapter?: any, disabled?: string[], site?: string, route?: string, localOrigins?: string[]}} runtime */ +/** @param {{gmRequest: (options: Record) => any, legacyHttp?: any, storageForage: any, localStorage: Storage, layer: any, hostAdapter?: any, disabled?: string[], site?: string, route?: string, localOrigins?: string[]}} runtime */ export function createAppContext(runtime) { - const diagnostics = new DiagnosticsService(); + const diagnostics = new DiagnosticsService({ legacyHttp: runtime.legacyHttp }); const rootScope = new LifecycleScope("app:root", { onChange: (snapshot) => diagnostics.updateScope(snapshot) }); const container = new DependencyContainer(diagnostics); const navigationPort = new BrowserNavigationAdapter(); diff --git a/src/plugins/backup/setting-panels.js b/src/plugins/backup/setting-panels.js index 51295091..6b0a3f08 100644 --- a/src/plugins/backup/setting-panels.js +++ b/src/plugins/backup/setting-panels.js @@ -4,8 +4,8 @@ import { getPluginCategories } from "./setting-templates.js"; import { createJhsTable } from "../../ui/table/create-jhs-table.js"; /** Render the network/external requests panel: circuit breaker status, domain stats. */ -export async function renderNetworkPanel() { - const e = gmHttp.getCircuitBreakerStatus(), t = gmHttp.getDomainStats(), n = await storageManager.getSetting("circuitBreakerThreshold", 3), a = await storageManager.getSetting("circuitBreakerCooldown", 6e4); +export async function renderNetworkPanel(diagnostics) { + const network = diagnostics.getNetworkDiagnostics(), e = network.circuitBreakers, t = network.domainStats, n = await storageManager.getSetting("circuitBreakerThreshold", 3), a = await storageManager.getSetting("circuitBreakerCooldown", 6e4); $("#circuitBreakerThreshold").val(n), $("#circuitBreakerCooldownSec").val(Math.round(a / 1e3)); const i = Object.entries(e); if (i.length) { @@ -27,11 +27,11 @@ export async function renderNetworkPanel() { } else $("#domain-stats-table").html('

    暂无统计数据

    '); $(".reset-breaker").off("click").on("click", (e => { const t = $(e.target).data("domain"); - gmHttp.resetCircuitBreaker(t), show.ok(`已重置 ${t} 的熔断状态`), renderNetworkPanel(); + diagnostics.resetCircuitBreaker(t), show.ok(`已重置 ${t} 的熔断状态`), renderNetworkPanel(diagnostics); })), $("#resetAllBreakersBtn").off("click").on("click", (() => { - gmHttp.resetAllCircuitBreakers(), show.ok("已重置全部熔断状态"), renderNetworkPanel(); + diagnostics.resetAllCircuitBreakers(), show.ok("已重置全部熔断状态"), renderNetworkPanel(diagnostics); })), $("#clearDomainStatsBtn").off("click").on("click", (() => { - gmHttp.clearDomainStats(), show.ok("已清空域名统计"), renderNetworkPanel(); + diagnostics.clearDomainStats(), show.ok("已清空域名统计"), renderNetworkPanel(diagnostics); })); } diff --git a/src/plugins/backup/setting.js b/src/plugins/backup/setting.js index 48ffae4e..9ba3d2dc 100644 --- a/src/plugins/backup/setting.js +++ b/src/plugins/backup/setting.js @@ -237,7 +237,7 @@ export class SettingPlugin extends BasePlugin { }); } bindClick() { - const settingPlugin = this, webdav = this.getRuntimeService("webdav"), dialog = this.getRuntimeService("dialog"), previewDiff = (diff, imported, restored = null) => showDiffPreview(diff, imported, restored, dialog); + const settingPlugin = this, webdav = this.getRuntimeService("webdav"), dialog = this.getRuntimeService("dialog"), diagnostics = this.getRuntimeService("diagnostics"), storage = this.getRuntimeService("storage"), previewDiff = (diff, imported, restored = null) => showDiffPreview(diff, imported, restored, dialog); $(".side-menu-item").on("click", (function() { $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); const e = $(this).data("panel"); @@ -245,7 +245,7 @@ export class SettingPlugin extends BasePlugin { $("#clean-all").addClass("jhs-is-hidden")), "health-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderDataHealthPanel()), "plugin-mgmt-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderPluginMgmtPanel(settingPlugin.getRuntimeService("diagnostics"))), "snapshot-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderSnapshotPanel()), - "network-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel()); + "network-panel" === e && ($("#saveBtn").hide(), $("#clean-all").addClass("jhs-is-hidden"), renderNetworkPanel(diagnostics)); })), $("#importBtn").on("click", (e => importSettingData(previewDiff))), $("#exportBtn").on("click", (e => exportSettingData())), $("#preview-car-number-import").on("click", (() => this.previewCarNumbers())), $("#confirm-car-number-import").on("click", (async e => this.confirmCarNumbers(e))), $("#webdavBackupBtn").on("click", (e => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", (e => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, previewDiff, dialog), webdav))), @@ -263,19 +263,19 @@ export class SettingPlugin extends BasePlugin { } finally { e.close(); } })), $(".clean-btn").on("click", (async e => { const t = $(e.currentTarget).data("key"), n = this.cacheItems.find((e => e.key === t)); - t === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : "_circuitBreaker" === t ? gmHttp.resetAllCircuitBreakers() : "_domainStats" === t ? gmHttp.clearDomainStats() : localStorage.removeItem(t), + t === storageManager.third_party_cache_key ? await storageManager.clearThirdPartyCache() : "_circuitBreaker" === t ? diagnostics.resetAllCircuitBreakers() : "_domainStats" === t ? diagnostics.clearDomainStats() : storage.removeLocal(t), show.ok(`${n.text} 清理成功`), $("#cache-data-display").addClass("jhs-is-hidden"), - "jhs_dmm_video" === t && localStorage.removeItem("jhs_other_site_dmm"); + "jhs_dmm_video" === t && storage.removeLocal("jhs_other_site_dmm"); })), $("#clean-all").on("click", (async () => { - this.cacheItems.forEach((e => localStorage.removeItem(e.key))), show.ok("全部缓存已清理"), - $("#cache-data-display").addClass("jhs-is-hidden"), localStorage.removeItem("jhs_other_site_dmm"), await storageManager.clearThirdPartyCache(); + this.cacheItems.forEach((e => storage.removeLocal(e.key))), show.ok("全部缓存已清理"), + $("#cache-data-display").addClass("jhs-is-hidden"), storage.removeLocal("jhs_other_site_dmm"), await storageManager.clearThirdPartyCache(); })), $(".view-btn").on("click", (async e => { const t = $(e.currentTarget).data("key"); let n; if (t === storageManager.third_party_cache_key) n = JSON.stringify(await storageManager.getThirdPartyCache()); - else if ("_circuitBreaker" === t) n = JSON.stringify(gmHttp.getCircuitBreakerStatus()); - else if ("_domainStats" === t) n = JSON.stringify(gmHttp.getDomainStats()); - else n = localStorage.getItem(t); + else if ("_circuitBreaker" === t) n = JSON.stringify(diagnostics.getNetworkDiagnostics().circuitBreakers); + else if ("_domainStats" === t) n = JSON.stringify(diagnostics.getNetworkDiagnostics().domainStats); + else n = storage.getLocal(t); const a = $("#cache-data-display"), i = a.find("pre"); if (a.removeClass("jhs-is-hidden"), n) try { const e = JSON.parse(n); diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 415eb288..0fd1d119 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -56,7 +56,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog]), - manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog]), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog, SERVICE.storage]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), diff --git a/src/services/diagnostics-service.js b/src/services/diagnostics-service.js index 9a7d6269..eceaf331 100644 --- a/src/services/diagnostics-service.js +++ b/src/services/diagnostics-service.js @@ -12,7 +12,8 @@ function sanitize(value, key = "") { } export class DiagnosticsService { - constructor() { + /** @param {{legacyHttp?: any}} [options] */ + constructor(options = {}) { this.startedAt = performance.now(); this.activeFeatures = new Set(); this.activeContributions = new Set(); @@ -29,6 +30,7 @@ export class DiagnosticsService { this.legacyStartup = null; /** @type {Array>} */ this.legacyTimings = []; + this.legacyHttp = options.legacyHttp ?? null; } /** @param {string} id @param {number} durationMs */ @@ -62,6 +64,16 @@ export class DiagnosticsService { this.legacyTimings = timings.map((item) => Object.freeze({ ...item })); } clearErrors() { this.errors = []; } + getNetworkDiagnostics() { + return Object.freeze({ + circuitBreakers: this.legacyHttp?.getCircuitBreakerStatus?.() ?? {}, + domainStats: this.legacyHttp?.getDomainStats?.() ?? {}, + }); + } + /** @param {string} domain */ + resetCircuitBreaker(domain) { this.legacyHttp?.resetCircuitBreaker?.(domain); } + resetAllCircuitBreakers() { this.legacyHttp?.resetAllCircuitBreakers?.(); } + clearDomainStats() { this.legacyHttp?.clearDomainStats?.(); } exportSnapshot() { const scopes = [...this.scopes.values()]; diff --git a/tests/http-service.test.js b/tests/http-service.test.js index 5d789563..3c0a261e 100644 --- a/tests/http-service.test.js +++ b/tests/http-service.test.js @@ -73,4 +73,23 @@ describe("HTTP, URL and settings contracts", () => { expect(settings.snapshot()).toEqual({ existing: true, sortMethod: "date", theme: "dark" }); expect(values.get("setting")).toEqual(settings.snapshot()); }); + + it("proxies legacy network controls through DiagnosticsService", () => { + const legacyHttp = { + getCircuitBreakerStatus: vi.fn(() => ({ "api.example": { state: "open" } })), + getDomainStats: vi.fn(() => ({ "api.example": { count: 2, errors: 1 } })), + resetCircuitBreaker: vi.fn(), resetAllCircuitBreakers: vi.fn(), clearDomainStats: vi.fn(), + }; + const diagnostics = new DiagnosticsService({ legacyHttp }); + expect(diagnostics.getNetworkDiagnostics()).toEqual({ + circuitBreakers: { "api.example": { state: "open" } }, + domainStats: { "api.example": { count: 2, errors: 1 } }, + }); + diagnostics.resetCircuitBreaker("api.example"); + diagnostics.resetAllCircuitBreakers(); + diagnostics.clearDomainStats(); + expect(legacyHttp.resetCircuitBreaker).toHaveBeenCalledWith("api.example"); + expect(legacyHttp.resetAllCircuitBreakers).toHaveBeenCalledOnce(); + expect(legacyHttp.clearDomainStats).toHaveBeenCalledOnce(); + }); }); From c0cee011e809e65384d92eea92649a25049c3e8c Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:13:13 +0800 Subject: [PATCH 017/130] =?UTF-8?q?Refactor:=20=E7=BB=9F=E4=B8=80=E9=BB=91?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E5=88=86=E9=A1=B5=E8=AF=B7=E6=B1=82=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将黑名单任务时间读取迁至 StorageService local 兼容通道 - 将三个手动分页请求迁至 HttpService 并绑定 Feature LifecycleScope - 通过当前宿主精确 origin 复验初始地址与重定向 - 补充 JavBus 下一页请求与直连清零回归断言 修改原因: - 消除 Blacklist Feature 对 gmHttp 和 localStorage 的直接依赖 - 保持自动调度器跨镜像容错逻辑不受本次迁移影响 验证: - npm run check 通过(95 个测试文件,532 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 14 +++++++------- src/plugins/blacklist/blacklist.js | 13 +++++++------ src/plugins/registry.js | 2 +- tests/blacklist-parser-boundary.test.js | 5 ++++- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 616c4258..9975e46b 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -7891,16 +7891,16 @@ })); } async resetBtnTip() { - const e2 = this.getDependency("TaskPlugin"), t2 = localStorage.getItem(e2.lastCheckBlacklistTimeKey) || "无", n2 = await storageManager.getSetting("checkBlacklist_intervalTime", 12); + const e2 = this.getDependency("TaskPlugin"), t2 = this.getRuntimeService("storage").getLocal(e2.lastCheckBlacklistTimeKey) || "无", n2 = await storageManager.getSetting("checkBlacklist_intervalTime", 12); this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime", 8760), $("#checkBlacklistBtn").attr("data-tip", `上次整批检测: ${t2}; 检测间隔时间: ${n2}小时`); } async openBlacklistDialog() { - const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getSetting(); + const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getSetting(), lastCheck = this.getRuntimeService("storage").getLocal(e2.lastCheckBlacklistTimeKey) || "无"; let n2 = `
    - +
    @@ -8177,7 +8177,7 @@ processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); if (a2) { clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); - const t3 = await gmHttp.get(a2), n3 = new DOMParser(), i2 = $(n3.parseFromString(t3, "text/html")); + const scope = await this.getRuntimeService("scope")(), t3 = await requestHostPage(this.getRuntimeService("http"), a2, scope), n3 = new DOMParser(), i2 = $(n3.parseFromString(t3, "text/html")); await this.filterAllVideo(e2, i2, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); } @@ -8197,7 +8197,7 @@ processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); if (a2) { clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); - const i2 = await gmHttp.get(a2), s2 = new DOMParser(), o2 = $(s2.parseFromString(i2, "text/html")); + const scope = await this.getRuntimeService("scope")(), i2 = await requestHostPage(this.getRuntimeService("http"), a2, scope), s2 = new DOMParser(), o2 = $(s2.parseFromString(i2, "text/html")); await this.batchSaveAllVideos(e2, t2, o2, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); } @@ -8208,7 +8208,7 @@ let n3; this.lastPageLink = a2; clog.log("正在请求下一页内容:", a2); - const i2 = await gmHttp.get(a2); + const scope = await this.getRuntimeService("scope")(), i2 = await requestHostPage(this.getRuntimeService("http"), a2, scope); n3 = utils.htmlTo$dom(i2); await this.filterActorVideo(e2, t2, n3, site, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 新增 ${processed} 个番号`); @@ -15535,7 +15535,7 @@ ${error.stack}` : ""); manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http]), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }, [SERVICE.storage]), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), - manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog, SERVICE.storage, SERVICE.http]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog]), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), diff --git a/src/plugins/blacklist/blacklist.js b/src/plugins/blacklist/blacklist.js index b9d69b96..904e6e47 100644 --- a/src/plugins/blacklist/blacklist.js +++ b/src/plugins/blacklist/blacklist.js @@ -1,6 +1,7 @@ import { A, B, D, I, P, T, d, i, l, o, r } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { normalizeHttpUrl, parseNumberSetting, selectLatestPublishTime, shouldSkipStopped } from "../../core/feature-helpers.js"; +import { requestHostPage } from "../../core/host-page-request.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { readListItem } from "../../core/list-item-reader.js"; import { legacyActionToFlag } from "../../core/state-model.js"; @@ -99,13 +100,13 @@ export class BlacklistPlugin extends BasePlugin { })); } async resetBtnTip() { - const e = this.getDependency("TaskPlugin"), t = localStorage.getItem(e.lastCheckBlacklistTimeKey) || "无", n = await storageManager.getSetting("checkBlacklist_intervalTime", 12); + const e = this.getDependency("TaskPlugin"), t = this.getRuntimeService("storage").getLocal(e.lastCheckBlacklistTimeKey) || "无", n = await storageManager.getSetting("checkBlacklist_intervalTime", 12); this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime", 8760), $("#checkBlacklistBtn").attr("data-tip", `上次整批检测: ${t}; 检测间隔时间: ${n}小时`); } async openBlacklistDialog() { - const e = this.getDependency("TaskPlugin"), t = await storageManager.getSetting(); - let n = `\n
    \n
    \n
    \n \n \n
    \n
    \n \n \n \n \n \n
    \n\n
    \n
    \n
    \n `; + const e = this.getDependency("TaskPlugin"), t = await storageManager.getSetting(), lastCheck = this.getRuntimeService("storage").getLocal(e.lastCheckBlacklistTimeKey) || "无"; + let n = `\n
    \n
    \n
    \n \n \n
    \n
    \n \n \n \n \n \n
    \n\n
    \n
    \n
    \n `; this.getRuntimeService("dialog").open({ type: 1, title: "演员黑名单", @@ -359,7 +360,7 @@ export class BlacklistPlugin extends BasePlugin { processed += n.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); if (a) { clog.log("正在请求下一页内容:", a), await new Promise((e => setTimeout(e, 500))); - const t = await gmHttp.get(a), n = new DOMParser, i = $(n.parseFromString(t, "text/html")); + const scope = await this.getRuntimeService("scope")(), t = await requestHostPage(this.getRuntimeService("http"), a, scope), n = new DOMParser, i = $(n.parseFromString(t, "text/html")); await this.filterAllVideo(e, i, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); } @@ -376,7 +377,7 @@ export class BlacklistPlugin extends BasePlugin { } processed += n.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); if (a) { clog.log("正在请求下一页内容:", a), await new Promise((e => setTimeout(e, 500))); - const i = await gmHttp.get(a), s = new DOMParser, o = $(s.parseFromString(i, "text/html")); + const scope = await this.getRuntimeService("scope")(), i = await requestHostPage(this.getRuntimeService("http"), a, scope), s = new DOMParser, o = $(s.parseFromString(i, "text/html")); await this.batchSaveAllVideos(e, t, o, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); } @@ -387,7 +388,7 @@ export class BlacklistPlugin extends BasePlugin { let n; this.lastPageLink = a; clog.log("正在请求下一页内容:", a); - const i = await gmHttp.get(a); + const scope = await this.getRuntimeService("scope")(), i = await requestHostPage(this.getRuntimeService("http"), a, scope); n = utils.htmlTo$dom(i); await this.filterActorVideo(e, t, n, site, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 新增 ${processed} 个番号`); diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 0fd1d119..025e0cf3 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -77,7 +77,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http]), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }, [SERVICE.storage]), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), - manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog]), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog, SERVICE.storage, SERVICE.http]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog]), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), diff --git a/tests/blacklist-parser-boundary.test.js b/tests/blacklist-parser-boundary.test.js index a13c2d46..0d28dfe6 100644 --- a/tests/blacklist-parser-boundary.test.js +++ b/tests/blacklist-parser-boundary.test.js @@ -16,10 +16,11 @@ function loadBlacklist(html, save = vi.fn(async () => {}), pageUrl = "https://ja ? { boxSelector: ".masonry", itemSelector: ".masonry .item", requestDomItemSelector: "#waterfall .item", nextPageSelector: "#next" } : { boxSelector: ".movie-list", itemSelector: ".movie-list .item", requestDomItemSelector: ".movie-list .item", nextPageSelector: ".pagination-next" }; } getBean() { return null; } + getRuntimeService(name) { return "scope" === name ? () => ({}) : {}; } } const context = vm.createContext({ console, URL, Date, window: dom.window, document: dom.window.document, $, BasePlugin, storageManager: { batchSaveBlacklistCarList: save }, - T: "javdb", I: "javbus", d: "filter", r: true, l: false, o: "", _: "yes", gmHttp, clog: { error: vi.fn(), log: vi.fn() }, show: { info: vi.fn(), ok: vi.fn() }, utils: { htmlTo$dom: source => $(new JSDOM(source, { url: pageUrl }).window.document) }, i: (target, key, value) => target[key] = value, + T: "javdb", I: "javbus", d: "filter", r: true, l: false, o: "", _: "yes", requestHostPage: (_http, url) => gmHttp.get(String(url)), clog: { error: vi.fn(), log: vi.fn() }, show: { info: vi.fn(), ok: vi.fn() }, utils: { htmlTo$dom: source => $(new JSDOM(source, { url: pageUrl }).window.document) }, i: (target, key, value) => target[key] = value, readListItem: element => ({ carNum: element.attr("data-car"), url: element.attr("data-url"), publishTime: element.attr("data-date") }) }); const source = [ "src/core/feature-helpers.js", "src/integrations/host-list/parser.js", "src/plugins/blacklist/blacklist.js" ].map(file => readTestFile(join(repoRoot, file), "utf8")).join("\n"); @@ -32,6 +33,7 @@ describe("blacklist parser boundaries", () => { const source = readTestFile(join(repoRoot, "src/plugins/blacklist/blacklist.js"), "utf8"); expect(source).not.toContain("上次检测时间"); expect(source.match(/上次整批检测/g)).toHaveLength(2); + expect(source).not.toMatch(/\b(?:gmHttp|localStorage)\s*\./); }); it("rejects challenge, missing containers and empty pages with pagination", async () => { @@ -72,6 +74,7 @@ describe("blacklist parser boundaries", () => { .mockResolvedValueOnce({ nextPageLink: "https://www.javbus.com/star/a/2" }) .mockResolvedValueOnce({ nextPageLink: null }); await loaded.plugin.filterActorVideo("A", "a", loaded.$page, "javbus"); + expect(loaded.gmHttp.get).toHaveBeenCalledWith("https://www.javbus.com/star/a/2"); expect(loaded.plugin.parseAndSaveFilterInfo).toHaveBeenNthCalledWith(1, loaded.$page, "A", "a", "javbus"); expect(loaded.plugin.parseAndSaveFilterInfo).toHaveBeenNthCalledWith(2, expect.anything(), "A", "a", "javbus"); }); From 6fcbe8e5718c1bc07bb51b83c4808f897ad7aaec Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:15:14 +0800 Subject: [PATCH 018/130] =?UTF-8?q?Refactor:=20=E6=89=98=E7=AE=A1=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BF=BB=E9=A1=B5=E5=85=A8=E5=B1=80=E7=9B=91=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将 AutoPage 的 window scroll listener 登记到 Feature LifecycleScope - 将一秒启动检查 timer 同步纳入 scope 清理 - 补充 scope dispose 后 listener 计数归零与 removeEventListener 断言 修改原因: - 推进未登记 app-global listener 硬门禁 - 防止 Feature 释放后滚动回调和启动 timer 继续访问旧 DOM 验证: - npm run check 通过(95 个测试文件,533 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 7 ++++--- src/plugins/status/auto-page.js | 7 ++++--- tests/audit-remediation.test.js | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 9975e46b..60d33716 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -13356,6 +13356,7 @@ ${error.stack}` : ""); } async waterfall() { if (await this.shouldDisablePaging()) return; + const scope = await this.getRuntimeService("scope")(); const e2 = this.getSelector(); if (this.container = document.querySelector(e2.boxSelector), !this.container) return void clog.error("没有找到容器节点,停止瀑布流!"); this.loader = document.createElement("div"), this.loader.className = "jhs-scroll", this.container.parentNode.insertBefore(this.loader, this.container.nextSibling), this.pageItems.push({ @@ -13366,16 +13367,16 @@ ${error.stack}` : ""); this.loader.classList.contains("waterfall-error") && void this.loadNextPage().catch(((error) => clog.error("瀑布流重试失败", error))); })), (() => { let t3 = false; - window.addEventListener("scroll", (() => { + scope.listen(window, "scroll", (() => { t3 || (t3 = true, requestAnimationFrame((() => { this.checkLoad(), this.checkScrollPosition(), t3 = false; }))); })); })(); const t2 = document.querySelector(e2.nextPageSelector); - this.nextUrl = null == t2 ? void 0 : t2.href, this.hasMore = !!this.nextUrl, setTimeout((() => { + this.nextUrl = null == t2 ? void 0 : t2.href, this.hasMore = !!this.nextUrl, scope.ownTimeout(setTimeout((() => { this.checkLoad(); - }), 1e3), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); + }), 1e3)), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); } async loadNextPage() { var e2; diff --git a/src/plugins/status/auto-page.js b/src/plugins/status/auto-page.js index 83f06fe4..a04ab1a5 100644 --- a/src/plugins/status/auto-page.js +++ b/src/plugins/status/auto-page.js @@ -29,6 +29,7 @@ export class AutoPagePlugin extends BasePlugin { } async waterfall() { if (await this.shouldDisablePaging()) return; + const scope = await this.getRuntimeService("scope")(); const e = this.getSelector(); if (this.container = document.querySelector(e.boxSelector), !this.container) return void clog.error("没有找到容器节点,停止瀑布流!"); this.loader = document.createElement("div"), this.loader.className = "jhs-scroll", @@ -41,16 +42,16 @@ export class AutoPagePlugin extends BasePlugin { this.loader.classList.contains("waterfall-error") && void this.loadNextPage().catch((error => clog.error("瀑布流重试失败", error))); })), (() => { let t = !1; - window.addEventListener("scroll", (() => { + scope.listen(window, "scroll", (() => { t || (t = !0, requestAnimationFrame((() => { this.checkLoad(), this.checkScrollPosition(), t = !1; }))); })); })(); const t = document.querySelector(e.nextPageSelector); - this.nextUrl = null == t ? void 0 : t.href, this.hasMore = !!this.nextUrl, setTimeout((() => { + this.nextUrl = null == t ? void 0 : t.href, this.hasMore = !!this.nextUrl, scope.ownTimeout(setTimeout((() => { this.checkLoad(); - }), 1e3), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); + }), 1e3)), this.hasMore || this.setState("waterfall-no-more", "已经到底了"); } async loadNextPage() { var e; diff --git a/tests/audit-remediation.test.js b/tests/audit-remediation.test.js index f65832fd..80fcd618 100644 --- a/tests/audit-remediation.test.js +++ b/tests/audit-remediation.test.js @@ -5,6 +5,7 @@ import vm from "node:vm"; import { JSDOM } from "jsdom"; import jqueryFactory from "jquery"; import { describe, expect, it, vi } from "vitest"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; function loadClass(file, className, extras = {}) { const source = readTestFile(join(process.cwd(), file), "utf8"), start = source.indexOf(`class ${className}`); @@ -26,6 +27,26 @@ describe("6.2.0 audit remediation", () => { expect(plugin.loader).toBeUndefined(); }); + it("owns AutoPage global listeners and startup timer in its Feature scope", async () => { + const dom = new JSDOM('
    ', { url: "https://javdb.com/" }); + const add = vi.spyOn(dom.window, "addEventListener"), remove = vi.spyOn(dom.window, "removeEventListener"), scope = new LifecycleScope("feature:list"); + const { Class } = loadClass("src/plugins/status/auto-page.js", "AutoPagePlugin", { + window: dom.window, document: dom.window.document, requestAnimationFrame: callback => callback(), setTimeout: vi.fn(() => 1), + storageManager: { getSetting: vi.fn().mockResolvedValue("yes") }, _: "yes", C: "no", clog: { error: vi.fn() }, + }); + const plugin = new Class(); + plugin.shouldDisablePaging = vi.fn().mockResolvedValue(false); + plugin.getSelector = () => ({ boxSelector: "#list", nextPageSelector: ".next" }); + plugin.getRuntimeService = name => "scope" === name ? () => scope : {}; + plugin.checkLoad = vi.fn(); + await plugin.waterfall(); + expect(scope.snapshot().listeners).toBe(1); + expect(add).toHaveBeenCalledWith("scroll", expect.any(Function), undefined); + scope.dispose(); + expect(scope.snapshot()).toMatchObject({ listeners: 0, disposed: true }); + expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function), undefined); + }); + it("binds OtherSite settings idempotently and recovers malformed storage", () => { const dom = new JSDOM('
    '), $ = jqueryFactory(dom.window), warn = vi.fn(); const storage = new Map([["jhs_enabled_sites", "broken-json"]]); From 47dfe47565943646f3345f6b3726b48111c9fa0c Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:18:40 +0800 Subject: [PATCH 019/130] =?UTF-8?q?Refactor:=20=E6=89=98=E7=AE=A1=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BB=BB=E5=8A=A1=E8=B0=83=E5=BA=A6=E7=94=9F=E5=91=BD?= =?UTF-8?q?=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将全部 jhs_time 调度 key 迁至 StorageService local 兼容通道 - 将 visibilitychange/pagehide listener 登记到 discovery Feature scope - 将 EventBus unsubscribe 与 clearSchedule 纳入 scope cleanup - 补充 dispose 后两个全局 listener 归零和调度状态回归测试 修改原因: - 消除 Task Feature 对 localStorage 和全局监听 API 的直接依赖 - 防止 Feature 释放后后台 timer 或设置回调继续运行 验证: - npm run check 通过(95 个测试文件,533 项测试) - npm run check:browser 通过(13 passed,5 skipped,本机 Edge,无下载) - git diff --check、codegraph sync 与正式产物哈希一致性检查通过 --- JHS.user.js | 38 ++++++++++++++++-------------- src/plugins/new-video/task.js | 37 +++++++++++++++-------------- src/plugins/registry.js | 2 +- tests/performance-hotpaths.test.js | 22 ++++++++++++----- tests/task-scheduler.test.js | 8 ++++++- 5 files changed, 63 insertions(+), 44 deletions(-) diff --git a/JHS.user.js b/JHS.user.js index 60d33716..781e55bb 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -12360,7 +12360,7 @@ ${error.stack}` : ""); // src/plugins/new-video/task.js var _TaskPlugin = class _TaskPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue()), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), i(this, "taskTimer", null), i(this, "taskRunning", false), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "settingsHandler", null), i(this, "taskConfigDirty", false), i(this, "configLoadPromise", null), i(this, "configLoadQueued", false), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", false), i(this, "activeTasks", /* @__PURE__ */ new Set()); + super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue()), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), i(this, "taskTimer", null), i(this, "taskRunning", false), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "lifecycleScope", null), i(this, "settingsHandler", null), i(this, "taskConfigDirty", false), i(this, "configLoadPromise", null), i(this, "configLoadQueued", false), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", false), i(this, "activeTasks", /* @__PURE__ */ new Set()); } getName() { return "TaskPlugin"; @@ -12416,11 +12416,11 @@ ${error.stack}` : ""); return schedules[name]; } getTaskScheduleState(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)), attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)); return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; } getTaskStatusSnapshot(name) { - const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey)); + const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(this.getRuntimeService("storage").getLocal(state.nextKey)); const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); let status = "idle"; if (this.activeTasks.has(name)) status = "running"; @@ -12458,34 +12458,34 @@ ${error.stack}` : ""); } async shouldStartTask(name, force = false) { if (force) return true; - const schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); - const completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); - let next = parseTaskTimestamp(localStorage.getItem(schedule.nextKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); + const completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)), attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)); + let next = parseTaskTimestamp(storage.getLocal(schedule.nextKey)); if (null == next) { const pending = null != attempt && (null == completed || attempt > completed); next = pending ? attempt + 3e5 : null == completed ? 0 : completed + 36e5 * interval; - localStorage.setItem(schedule.nextKey, String(next)); + storage.setLocal(schedule.nextKey, String(next)); } return Date.now() >= next; } beginTaskAttempt(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)); const now = Math.floor(Date.now() / 1e3) * 1e3, attempt = null == completed ? now : Math.max(now, completed + 1e3); - localStorage.setItem(schedule.attemptKey, String(attempt)), localStorage.setItem(schedule.nextKey, String(attempt + 3e5)); + storage.setLocal(schedule.attemptKey, String(attempt)), storage.setLocal(schedule.nextKey, String(attempt + 3e5)); return attempt; } async finalizeTask(name, completed) { - const schedule = this.getTaskSchedule(name); - if (!completed) return localStorage.setItem(schedule.nextKey, String(Date.now() + 3e5)); - const attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); - localStorage.setItem(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), localStorage.setItem(schedule.nextKey, String(completedAt + 36e5 * interval)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name); + if (!completed) return storage.setLocal(schedule.nextKey, String(Date.now() + 3e5)); + const attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); + storage.setLocal(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), storage.setLocal(schedule.nextKey, String(completedAt + 36e5 * interval)); } async recalculateSchedules() { await this.loadConfig(); for (const [name, interval] of [["blacklist", this.taskConfig.checkBlacklist_intervalTime], ["favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime], ["newVideo", this.taskConfig.checkNewVideo_intervalTime]]) { const state = this.getTaskScheduleState(name); if (state.pending) continue; - localStorage.setItem(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); + this.getRuntimeService("storage").setLocal(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); } } async invalidateConfig(recalculate = false) { @@ -12500,18 +12500,20 @@ ${error.stack}` : ""); this.configRefreshPromise = null; })), this.configRefreshPromise; } - handle() { + async handle() { if (!window.isListPage) return; + this.lifecycleScope || (this.lifecycleScope = await this.getRuntimeService("scope")()); this.visibilityHandler || (this.visibilityHandler = () => { document.hidden ? this.clearSchedule() : this.scheduleTask(0); - }, this.pageHideHandler = () => this.clearSchedule(), document.addEventListener("visibilitychange", this.visibilityHandler), window.addEventListener("pagehide", this.pageHideHandler)); + }, this.pageHideHandler = () => this.clearSchedule(), this.lifecycleScope.listen(document, "visibilitychange", this.visibilityHandler), this.lifecycleScope.listen(window, "pagehide", this.pageHideHandler), this.lifecycleScope.addCleanup((() => this.clearSchedule()))); this.settingsHandler || (this.settingsHandler = async () => { try { storageManager._invalidateCache?.(storageManager.setting_key), await this.invalidateConfig(true), this.scheduleTask(0); } catch (error) { clog.error("任务设置刷新失败", error); } - }, globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler)); + }, this.lifecycleScope.addCleanup(globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler) || (() => { + }))); return document.hidden ? void 0 : this.runAndSchedule(); } clearSchedule() { @@ -15539,7 +15541,7 @@ ${error.stack}` : ""); manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog, SERVICE.storage, SERVICE.http]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog]), - manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), + manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }, [SERVICE.storage]), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host, SERVICE.dialog]), diff --git a/src/plugins/new-video/task.js b/src/plugins/new-video/task.js index 58fab2f1..560a2c7d 100644 --- a/src/plugins/new-video/task.js +++ b/src/plugins/new-video/task.js @@ -15,7 +15,7 @@ export class TaskPlugin extends BasePlugin { i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), - i(this, "taskTimer", null), i(this, "taskRunning", !1), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), + i(this, "taskTimer", null), i(this, "taskRunning", !1), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "lifecycleScope", null), i(this, "settingsHandler", null), i(this, "taskConfigDirty", !1), i(this, "configLoadPromise", null), i(this, "configLoadQueued", !1), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", !1), i(this, "activeTasks", new Set); } @@ -73,12 +73,12 @@ export class TaskPlugin extends BasePlugin { return schedules[name]; } getTaskScheduleState(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)), attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)); return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; } /** 返回当前标签页可证明的任务状态,不写入调度数据。 */ getTaskStatusSnapshot(name) { - const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(localStorage.getItem(state.nextKey)); + const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(this.getRuntimeService("storage").getLocal(state.nextKey)); const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); let status = "idle"; if (this.activeTasks.has(name)) status = "running"; @@ -116,34 +116,34 @@ export class TaskPlugin extends BasePlugin { } async shouldStartTask(name, force = !1) { if (force) return !0; - const schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); - const completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)), attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)); - let next = parseTaskTimestamp(localStorage.getItem(schedule.nextKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); + const completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)), attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)); + let next = parseTaskTimestamp(storage.getLocal(schedule.nextKey)); if (null == next) { const pending = null != attempt && (null == completed || attempt > completed); next = pending ? attempt + 3e5 : null == completed ? 0 : completed + 36e5 * interval; - localStorage.setItem(schedule.nextKey, String(next)); + storage.setLocal(schedule.nextKey, String(next)); } return Date.now() >= next; } beginTaskAttempt(name) { - const schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(localStorage.getItem(schedule.completedKey)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)); const now = Math.floor(Date.now() / 1e3) * 1e3, attempt = null == completed ? now : Math.max(now, completed + 1e3); - localStorage.setItem(schedule.attemptKey, String(attempt)), localStorage.setItem(schedule.nextKey, String(attempt + 3e5)); + storage.setLocal(schedule.attemptKey, String(attempt)), storage.setLocal(schedule.nextKey, String(attempt + 3e5)); return attempt; } async finalizeTask(name, completed) { - const schedule = this.getTaskSchedule(name); - if (!completed) return localStorage.setItem(schedule.nextKey, String(Date.now() + 3e5)); - const attempt = parseTaskTimestamp(localStorage.getItem(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); - localStorage.setItem(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), localStorage.setItem(schedule.nextKey, String(completedAt + 36e5 * interval)); + const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name); + if (!completed) return storage.setLocal(schedule.nextKey, String(Date.now() + 3e5)); + const attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)) || 0, completedAt = Math.max(Math.floor(Date.now() / 1e3) * 1e3, attempt), interval = await this.getLatestTaskInterval(name); + storage.setLocal(schedule.completedKey, utils.getNowStr("-", ":", completedAt)), storage.setLocal(schedule.nextKey, String(completedAt + 36e5 * interval)); } async recalculateSchedules() { await this.loadConfig(); for (const [name, interval] of [ [ "blacklist", this.taskConfig.checkBlacklist_intervalTime ], [ "favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime ], [ "newVideo", this.taskConfig.checkNewVideo_intervalTime ] ]) { const state = this.getTaskScheduleState(name); if (state.pending) continue; - localStorage.setItem(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); + this.getRuntimeService("storage").setLocal(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); } } async invalidateConfig(recalculate = !1) { @@ -158,19 +158,20 @@ export class TaskPlugin extends BasePlugin { this.configRefreshPromise = null; })), this.configRefreshPromise; } - handle() { + async handle() { if (!window.isListPage) return; + this.lifecycleScope ||= await this.getRuntimeService("scope")(); this.visibilityHandler || (this.visibilityHandler = () => { document.hidden ? this.clearSchedule() : this.scheduleTask(0); - }, this.pageHideHandler = () => this.clearSchedule(), document.addEventListener("visibilitychange", this.visibilityHandler), - window.addEventListener("pagehide", this.pageHideHandler)); + }, this.pageHideHandler = () => this.clearSchedule(), this.lifecycleScope.listen(document, "visibilitychange", this.visibilityHandler), + this.lifecycleScope.listen(window, "pagehide", this.pageHideHandler), this.lifecycleScope.addCleanup((() => this.clearSchedule()))); this.settingsHandler || (this.settingsHandler = async () => { try { storageManager._invalidateCache?.(storageManager.setting_key), await this.invalidateConfig(!0), this.scheduleTask(0); } catch (error) { clog.error("任务设置刷新失败", error); } - }, globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler)); + }, this.lifecycleScope.addCleanup(globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler) || (() => {}))); return document.hidden ? void 0 : this.runAndSchedule(); } clearSchedule() { diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 025e0cf3..65683c8f 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -80,7 +80,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog, SERVICE.storage, SERVICE.http]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog]), - manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }), + manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }, [SERVICE.storage]), manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host, SERVICE.dialog]), diff --git a/tests/performance-hotpaths.test.js b/tests/performance-hotpaths.test.js index 0928caf0..9541fd9c 100644 --- a/tests/performance-hotpaths.test.js +++ b/tests/performance-hotpaths.test.js @@ -9,16 +9,21 @@ import { describe, expect, it, vi } from "vitest"; const repoRoot = join(import.meta.dirname, ".."); function loadTaskLifecycle({ isListPage = false, hidden = false } = {}) { - const listeners = new Map(), documentListeners = new Map(), setTimeoutSpy = vi.fn(() => 1), clearTimeoutSpy = vi.fn(); + const listeners = new Map(), documentListeners = new Map(), setTimeoutSpy = vi.fn(() => 1), clearTimeoutSpy = vi.fn(), cleanups = []; const location = new URL("https://javdb.com/v/test"), window = { - location, isListPage, addEventListener: (type, listener) => listeners.set(type, listener) + location, isListPage, addEventListener: (type, listener) => listeners.set(type, listener), removeEventListener: type => listeners.delete(type) }, document = { - hidden, addEventListener: (type, listener) => documentListeners.set(type, listener) + hidden, addEventListener: (type, listener) => documentListeners.set(type, listener), removeEventListener: type => documentListeners.delete(type) }, locks = { request: vi.fn(async (key, options, callback) => callback({ key })) }; + const storage = { getLocal: vi.fn(), setLocal: vi.fn() }, scope = { + listen(target, type, listener) { target.addEventListener(type, listener); cleanups.push(() => target.removeEventListener(type, listener)); }, + addCleanup(cleanup) { cleanups.push(cleanup); }, + dispose() { [...cleanups].reverse().forEach(cleanup => cleanup()); } + }; const context = vm.createContext({ console, URL, window, document, navigator: { locks }, setTimeout: setTimeoutSpy, clearTimeout: clearTimeoutSpy, localStorage: { getItem: vi.fn(), setItem: vi.fn() }, $: () => ({ length: 0 }), l: true, _: "yes", - T: "javdb", I: "javbus", D: "censored", A: "uncensored", BasePlugin: class {}, + T: "javdb", I: "javbus", D: "censored", A: "uncensored", BasePlugin: class { getRuntimeService(name) { return "storage" === name ? storage : "scope" === name ? () => scope : null; } }, StorageQueue: class { constructor() { this.queue = Promise.resolve(); } }, storageManager: {}, utils: { sleep: vi.fn(), getNowStr: vi.fn(), getHourDifference: vi.fn() }, clog: { log: vi.fn(), debug: vi.fn(), error: vi.fn(), warn: vi.fn() }, show: { info: vi.fn(), error: vi.fn() }, @@ -32,7 +37,7 @@ function loadTaskLifecycle({ isListPage = false, hidden = false } = {}) { "globalThis.TestTaskPlugin=TaskPlugin;" ].join("\n"); vm.runInContext(source, context); - return { plugin: new context.TestTaskPlugin(), window, document, listeners, documentListeners, locks, setTimeoutSpy }; + return { plugin: new context.TestTaskPlugin(), window, document, listeners, documentListeners, locks, setTimeoutSpy, scope }; } function loadListObserver() { @@ -75,11 +80,16 @@ describe("background task lifecycle", () => { }); it("runs once on a visible list page and schedules the next visible check", async () => { - const { plugin, setTimeoutSpy } = loadTaskLifecycle({ isListPage: true }); + const { plugin, setTimeoutSpy, scope, listeners, documentListeners } = loadTaskLifecycle({ isListPage: true }); plugin.doTask = vi.fn(async () => {}); await plugin.handle(); expect(plugin.doTask).toHaveBeenCalledTimes(1); expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3e5); + expect(listeners.has("pagehide")).toBe(true); + expect(documentListeners.has("visibilitychange")).toBe(true); + scope.dispose(); + expect(listeners.size).toBe(0); + expect(documentListeners.size).toBe(0); }); it("keeps hidden list pages dormant and retains the cross-tab lock", async () => { diff --git a/tests/task-scheduler.test.js b/tests/task-scheduler.test.js index 09a181f3..25c1dda0 100644 --- a/tests/task-scheduler.test.js +++ b/tests/task-scheduler.test.js @@ -5,6 +5,7 @@ import vm from "node:vm"; import jqueryFactory from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it, vi } from "vitest"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; const repoRoot = join(import.meta.dirname, ".."); @@ -28,6 +29,7 @@ function createHarness(initialTime = "2026-08-23T13:20:00.789", pageUrl = "https addFavoriteActressList: vi.fn(async () => {}), updateFavoriteActress: vi.fn(async () => true), updateBlacklistItem: vi.fn(async update => Object.assign(blacklistItems.find(item => item.starId === update.starId), update)), getCarMap: vi.fn(async () => new Map) }; const localStorage = { getItem: vi.fn(key => values.has(key) ? values.get(key) : null), setItem: vi.fn((key, value) => values.set(key, String(value))), removeItem: vi.fn(key => values.delete(key)) }; + const storage = { getLocal: localStorage.getItem, setLocal: localStorage.setItem, removeLocal: localStorage.removeItem }, scope = new LifecycleScope("feature:discovery"); const eventHandlers = new Map, jhsEventBus = { on: vi.fn((type, handler) => { const handlers = eventHandlers.get(type) || []; @@ -46,6 +48,7 @@ function createHarness(initialTime = "2026-08-23T13:20:00.789", pageUrl = "https }; class BasePlugin { getBean(name) { return beans[name]; } + getRuntimeService(name) { return "storage" === name ? storage : "scope" === name ? () => scope : null; } getSelector(site = "javdb") { return site === "javbus" ? { boxSelector: ".masonry", itemSelector: ".masonry .item", requestDomItemSelector: "#waterfall .item", nextPageSelector: "#next" } : { boxSelector: ".movie-list", itemSelector: ".movie-list .item", requestDomItemSelector: ".movie-list .item", nextPageSelector: ".pagination-next" }; } } class StorageQueue { async addTask(task) { return task(); } async waitAllFinished() {} } @@ -67,7 +70,7 @@ function createHarness(initialTime = "2026-08-23T13:20:00.789", pageUrl = "https const source = [ "src/core/site-context.js", "src/core/feature-helpers.js", "src/integrations/javdb/parser.js", "src/integrations/host-list/parser.js", "src/plugins/new-video/task.js" ].map(file => readTestFile(join(repoRoot, file), "utf8")).join("\n"); vm.runInContext(`${source};globalThis.Task=TaskPlugin`, context); const plugin = new context.Task; - return { plugin, clock, values, settings, favorites, blacklistItems, storageManager, gmHttp, beans, locks, jhsEventBus, $, htmlToPage: context.utils.htmlTo$dom }; + return { plugin, clock, values, settings, favorites, blacklistItems, storageManager, gmHttp, beans, locks, jhsEventBus, scope, $, htmlToPage: context.utils.htmlTo$dom }; } describe("task scheduler state machine", () => { @@ -166,6 +169,9 @@ describe("task scheduler state machine", () => { await harness.plugin.handle(); await harness.jhsEventBus.emit("settings-changed", {}); expect(recalculate).toHaveBeenCalledOnce(); + expect(harness.scope.snapshot().listeners).toBe(2); + harness.scope.dispose(); + expect(harness.scope.snapshot()).toMatchObject({ listeners: 0, disposed: true }); }); it("reports only current-tab execution as running and cleans active state on failure", async () => { From 961108fd6d0ba05e16bcbda1d4ee5f919bf227ec Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:26:24 +0800 Subject: [PATCH 020/130] =?UTF-8?q?Refactor:=20=E6=89=98=E7=AE=A1=20123=20?= =?UTF-8?q?=E4=BA=91=E7=9B=98=E6=8E=88=E6=9D=83=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 通过 StorageService 隔离站点 localStorage 与 Userscript GM 值命名空间 - 由 Feature LifecycleScope 托管授权同步监听和轮询定时器 - 保持既有授权键、来源元数据与失效清理语义,并补充回归测试 修改原因: - 消除 123 云盘功能对平台存储接口和全局监听的直接依赖 - 确保 Feature dispose 后不残留 listener 或 polling work 验证: - npm run check 通过(96 个测试文件,535 项测试) - npm run check:browser 通过(本机 Edge,13 passed,5 skipped) - git diff --check、codegraph sync 与正式双产物 SHA256 一致性检查通过 --- JHS.user.js | 50 +++++++++++++------ src/app/bootstrap.js | 3 +- src/app/create-app-context.js | 4 +- src/contracts/ports.js | 2 +- .../userscript/indexeddb-storage-adapter.js | 8 ++- src/plugins/one-two-three/offline.js | 24 +++++---- src/plugins/registry.js | 2 +- src/services/storage-service.js | 4 ++ tests/browser/harness/runtime.js | 4 +- tests/one-two-three-platform.test.js | 49 ++++++++++++++++++ tests/storage-service.test.js | 10 +++- tests/ui-architecture.test.js | 2 +- 12 files changed, 126 insertions(+), 36 deletions(-) create mode 100644 tests/one-two-three-platform.test.js diff --git a/JHS.user.js b/JHS.user.js index 781e55bb..dca46c10 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -13076,23 +13076,28 @@ ${error.stack}` : ""); return "OneTwoThreeOfflinePlugin"; } async handle() { - "yun.123pan.com" === window.location.hostname && this.startTokenSync(); + if ("yun.123pan.com" !== window.location.hostname) return; + this.startTokenSync(await this.getRuntimeService("scope")()); } - startTokenSync() { + startTokenSync(scope) { this.syncTokenOnce(), this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = setInterval((() => this.syncTokenOnce()), this.syncFallbackMs); const e2 = /* @__PURE__ */ __name(() => this.syncTokenOnce(), "e"); - window.addEventListener("storage", e2), window.addEventListener("focus", e2), document.addEventListener("visibilitychange", (() => { + scope.addCleanup((() => { + this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = null; + })); + scope.listen(window, "storage", e2), scope.listen(window, "focus", e2), scope.listen(document, "visibilitychange", (() => { document.hidden || this.syncTokenOnce(); })); } getTokenFrom123Pan() { - let e2 = (localStorage.getItem("authorToken") || "").trim(); + const storage = this.getRuntimeService("storage"); + let e2 = (storage.getLocal("authorToken") || "").trim(); if (e2) return { token: e2, source: "authorToken" }; try { - const t3 = JSON.parse(localStorage.getItem("userInfo") || "{}"); + const t3 = JSON.parse(storage.getLocal("userInfo") || "{}"); if (t3.authorToken || t3.token) return { token: (t3.authorToken || t3.token || "").trim(), source: t3.authorToken ? "userInfo.authorToken" : "userInfo.token" @@ -13116,20 +13121,21 @@ ${error.stack}` : ""); }; } syncTokenOnce() { - const e2 = this.getTokenFrom123Pan(); + const storage = this.getRuntimeService("storage"), e2 = this.getTokenFrom123Pan(); if (!e2.token) return; - const t2 = GM_getValue(this.tokenKey, ""), n2 = GM_getValue(this.tokenMetaKey, null); + const t2 = storage.getValue(this.tokenKey, ""), n2 = storage.getValue(this.tokenMetaKey, null); if (t2 === e2.token && n2 && n2.source === e2.source) return; - GM_setValue(this.tokenKey, e2.token), GM_setValue(this.tokenMetaKey, { + storage.setValue(this.tokenKey, e2.token), storage.setValue(this.tokenMetaKey, { source: e2.source, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }), t2 !== e2.token && show.info(`123 云盘授权已更新:${e2.source}`); } getStoredToken() { - return GM_getValue(this.tokenKey, ""); + return this.getRuntimeService("storage").getValue(this.tokenKey, ""); } clearStoredToken(e2) { - GM_setValue(this.tokenKey, ""), GM_setValue(this.tokenMetaKey, { + const storage = this.getRuntimeService("storage"); + storage.setValue(this.tokenKey, ""), storage.setValue(this.tokenMetaKey, { source: "cleared", reason: e2, updatedAt: (/* @__PURE__ */ new Date()).toISOString() @@ -15551,7 +15557,7 @@ ${error.stack}` : ""); manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), manifest("detail.native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings]), - manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }), + manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }, [SERVICE.storage]), manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), manifest("detail.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }) ]); @@ -15610,7 +15616,7 @@ ${error.stack}` : ""); var PORT_METHODS = Object.freeze({ navigation: ["open", "assign", "replace"], http: ["request"], - storage: ["get", "set", "remove", "getLocal", "setLocal", "removeLocal"], + storage: ["get", "set", "remove", "getLocal", "setLocal", "removeLocal", "getValue", "setValue"], dialog: ["open", "close", "confirm", "alert"], style: ["register", "remove"] }); @@ -15657,9 +15663,11 @@ ${error.stack}` : ""); // src/platform/userscript/indexeddb-storage-adapter.js var _IndexedDbStorageAdapter = class _IndexedDbStorageAdapter { - constructor(forage, localStore) { + constructor(forage, localStore, gmGetValue, gmSetValue) { this.forage = forage; this.localStore = localStore; + this.gmGetValue = gmGetValue; + this.gmSetValue = gmSetValue; } get(key) { return this.forage.getItem(key); @@ -15679,6 +15687,12 @@ ${error.stack}` : ""); removeLocal(key) { this.localStore.removeItem(key); } + getValue(key, fallback) { + return this.gmGetValue(key, fallback); + } + setValue(key, value) { + this.gmSetValue(key, value); + } }; __name(_IndexedDbStorageAdapter, "IndexedDbStorageAdapter"); var IndexedDbStorageAdapter = _IndexedDbStorageAdapter; @@ -16474,6 +16488,12 @@ ${error.stack}` : ""); removeLocal(key) { return this.port.removeLocal(key); } + getValue(key, fallback) { + return this.port.getValue(key, fallback); + } + setValue(key, value) { + return this.port.setValue(key, value); + } }; __name(_StorageService, "StorageService"); var StorageService = _StorageService; @@ -16839,7 +16859,7 @@ ${error.stack}` : ""); const container = new DependencyContainer(diagnostics); const navigationPort = new BrowserNavigationAdapter(); const httpPort = new UserscriptHttpAdapter(runtime.gmRequest); - const storagePort = new IndexedDbStorageAdapter(runtime.storageForage, runtime.localStorage); + const storagePort = new IndexedDbStorageAdapter(runtime.storageForage, runtime.localStorage, runtime.gmGetValue, runtime.gmSetValue); const dialogPort = new LayerDialogAdapter(runtime.layer); const stylePort = new BrowserStyleAdapter(); const urlPolicy = new ExternalUrlPolicy({ localOrigins: runtime.localOrigins }); @@ -17725,6 +17745,8 @@ ${error.stack}` : ""); const route = hostAdapter?.detectRoute() ?? "other"; const context = createAppContext({ gmRequest: globalThis.GM_xmlhttpRequest, + gmGetValue: globalThis.GM_getValue, + gmSetValue: globalThis.GM_setValue, legacyHttp: gmHttp2, storageForage: storageManager2.forage, localStorage: globalThis.localStorage, diff --git a/src/app/bootstrap.js b/src/app/bootstrap.js index 0286dafd..a52fa6eb 100644 --- a/src/app/bootstrap.js +++ b/src/app/bootstrap.js @@ -95,7 +95,8 @@ export async function bootstrapJhs() { const hostAdapter = r ? new JavDbHostAdapter() : l ? new JavBusHostAdapter() : null; const route = hostAdapter?.detectRoute() ?? "other"; const context = createAppContext({ - gmRequest: globalThis.GM_xmlhttpRequest, legacyHttp: gmHttp, storageForage: storageManager.forage, localStorage: globalThis.localStorage, + gmRequest: globalThis.GM_xmlhttpRequest, gmGetValue: globalThis.GM_getValue, gmSetValue: globalThis.GM_setValue, + legacyHttp: gmHttp, storageForage: storageManager.forage, localStorage: globalThis.localStorage, layer: vendors.layer, hostAdapter, site: siteContext.site, route, disabled, localOrigins: localOriginSettings.origins, }); const settingsSnapshot = await context.services.settings.load(); diff --git a/src/app/create-app-context.js b/src/app/create-app-context.js index 47a251ca..540f85f4 100644 --- a/src/app/create-app-context.js +++ b/src/app/create-app-context.js @@ -33,14 +33,14 @@ import { IntegrationRegistry } from "./integration-registry.js"; import { SettingsRegistry } from "./settings-registry.js"; import { LifecycleScope } from "../core/lifecycle-scope.js"; -/** @param {{gmRequest: (options: Record) => any, legacyHttp?: any, storageForage: any, localStorage: Storage, layer: any, hostAdapter?: any, disabled?: string[], site?: string, route?: string, localOrigins?: string[]}} runtime */ +/** @param {{gmRequest: (options: Record) => any, gmGetValue: (key: string, fallback?: unknown) => unknown, gmSetValue: (key: string, value: unknown) => void, legacyHttp?: any, storageForage: any, localStorage: Storage, layer: any, hostAdapter?: any, disabled?: string[], site?: string, route?: string, localOrigins?: string[]}} runtime */ export function createAppContext(runtime) { const diagnostics = new DiagnosticsService({ legacyHttp: runtime.legacyHttp }); const rootScope = new LifecycleScope("app:root", { onChange: (snapshot) => diagnostics.updateScope(snapshot) }); const container = new DependencyContainer(diagnostics); const navigationPort = new BrowserNavigationAdapter(); const httpPort = new UserscriptHttpAdapter(runtime.gmRequest); - const storagePort = new IndexedDbStorageAdapter(runtime.storageForage, runtime.localStorage); + const storagePort = new IndexedDbStorageAdapter(runtime.storageForage, runtime.localStorage, runtime.gmGetValue, runtime.gmSetValue); const dialogPort = new LayerDialogAdapter(runtime.layer); const stylePort = new BrowserStyleAdapter(); const urlPolicy = new ExternalUrlPolicy({ localOrigins: runtime.localOrigins }); diff --git a/src/contracts/ports.js b/src/contracts/ports.js index 9fca71fd..e2034885 100644 --- a/src/contracts/ports.js +++ b/src/contracts/ports.js @@ -12,7 +12,7 @@ export function assertPort(implementation, name, methods) { export const PORT_METHODS = Object.freeze({ navigation: ["open", "assign", "replace"], http: ["request"], - storage: ["get", "set", "remove", "getLocal", "setLocal", "removeLocal"], + storage: ["get", "set", "remove", "getLocal", "setLocal", "removeLocal", "getValue", "setValue"], dialog: ["open", "close", "confirm", "alert"], style: ["register", "remove"], }); diff --git a/src/platform/userscript/indexeddb-storage-adapter.js b/src/platform/userscript/indexeddb-storage-adapter.js index 346ed6e8..478b3a7e 100644 --- a/src/platform/userscript/indexeddb-storage-adapter.js +++ b/src/platform/userscript/indexeddb-storage-adapter.js @@ -1,8 +1,8 @@ // @ts-check export class IndexedDbStorageAdapter { - /** @param {{getItem: (key: string) => Promise, setItem: (key: string, value: unknown) => Promise, removeItem: (key: string) => Promise}} forage @param {Storage} localStore */ - constructor(forage, localStore) { this.forage = forage; this.localStore = localStore; } + /** @param {{getItem: (key: string) => Promise, setItem: (key: string, value: unknown) => Promise, removeItem: (key: string) => Promise}} forage @param {Storage} localStore @param {(key: string, fallback?: unknown) => unknown} gmGetValue @param {(key: string, value: unknown) => void} gmSetValue */ + constructor(forage, localStore, gmGetValue, gmSetValue) { this.forage = forage; this.localStore = localStore; this.gmGetValue = gmGetValue; this.gmSetValue = gmSetValue; } /** @param {string} key */ get(key) { return this.forage.getItem(key); } /** @param {string} key @param {unknown} value */ @@ -15,4 +15,8 @@ export class IndexedDbStorageAdapter { setLocal(key, value) { this.localStore.setItem(key, value); } /** @param {string} key */ removeLocal(key) { this.localStore.removeItem(key); } + /** @param {string} key @param {unknown} [fallback] */ + getValue(key, fallback) { return this.gmGetValue(key, fallback); } + /** @param {string} key @param {unknown} value */ + setValue(key, value) { this.gmSetValue(key, value); } } diff --git a/src/plugins/one-two-three/offline.js b/src/plugins/one-two-three/offline.js index fdd0df7c..eb0e367c 100644 --- a/src/plugins/one-two-three/offline.js +++ b/src/plugins/one-two-three/offline.js @@ -9,23 +9,26 @@ export class OneTwoThreeOfflinePlugin extends BasePlugin { return "OneTwoThreeOfflinePlugin"; } async handle() { - "yun.123pan.com" === window.location.hostname && this.startTokenSync(); + if ("yun.123pan.com" !== window.location.hostname) return; + this.startTokenSync(await this.getRuntimeService("scope")()); } - startTokenSync() { + startTokenSync(scope) { this.syncTokenOnce(), this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = setInterval((() => this.syncTokenOnce()), this.syncFallbackMs); const e = () => this.syncTokenOnce(); - window.addEventListener("storage", e), window.addEventListener("focus", e), document.addEventListener("visibilitychange", (() => { + scope.addCleanup((() => { this.syncTimer && clearInterval(this.syncTimer), this.syncTimer = null; })); + scope.listen(window, "storage", e), scope.listen(window, "focus", e), scope.listen(document, "visibilitychange", (() => { document.hidden || this.syncTokenOnce(); })); } getTokenFrom123Pan() { - let e = (localStorage.getItem("authorToken") || "").trim(); + const storage = this.getRuntimeService("storage"); + let e = (storage.getLocal("authorToken") || "").trim(); if (e) return { token: e, source: "authorToken" }; try { - const t = JSON.parse(localStorage.getItem("userInfo") || "{}"); + const t = JSON.parse(storage.getLocal("userInfo") || "{}"); if (t.authorToken || t.token) return { token: (t.authorToken || t.token || "").trim(), source: t.authorToken ? "userInfo.authorToken" : "userInfo.token" @@ -47,20 +50,21 @@ export class OneTwoThreeOfflinePlugin extends BasePlugin { }; } syncTokenOnce() { - const e = this.getTokenFrom123Pan(); + const storage = this.getRuntimeService("storage"), e = this.getTokenFrom123Pan(); if (!e.token) return; - const t = GM_getValue(this.tokenKey, ""), n = GM_getValue(this.tokenMetaKey, null); + const t = storage.getValue(this.tokenKey, ""), n = storage.getValue(this.tokenMetaKey, null); if (t === e.token && n && n.source === e.source) return; - GM_setValue(this.tokenKey, e.token), GM_setValue(this.tokenMetaKey, { + storage.setValue(this.tokenKey, e.token), storage.setValue(this.tokenMetaKey, { source: e.source, updatedAt: (new Date).toISOString() }), t !== e.token && show.info(`123 云盘授权已更新:${e.source}`); } getStoredToken() { - return GM_getValue(this.tokenKey, ""); + return this.getRuntimeService("storage").getValue(this.tokenKey, ""); } clearStoredToken(e) { - GM_setValue(this.tokenKey, ""), GM_setValue(this.tokenMetaKey, { + const storage = this.getRuntimeService("storage"); + storage.setValue(this.tokenKey, ""), storage.setValue(this.tokenMetaKey, { source: "cleared", reason: e, updatedAt: (new Date).toISOString() diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 65683c8f..4c3344f6 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -90,7 +90,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), manifest("detail.native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings]), - manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }), + manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }, [SERVICE.storage]), manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), manifest("detail.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }), ]); diff --git a/src/services/storage-service.js b/src/services/storage-service.js index 5eb14a94..d6fdcc65 100644 --- a/src/services/storage-service.js +++ b/src/services/storage-service.js @@ -17,4 +17,8 @@ export class StorageService { setLocal(key, value) { return this.port.setLocal(key, value); } /** @param {string} key */ removeLocal(key) { return this.port.removeLocal(key); } + /** @param {string} key @param {unknown} [fallback] */ + getValue(key, fallback) { return this.port.getValue(key, fallback); } + /** @param {string} key @param {unknown} value */ + setValue(key, value) { return this.port.setValue(key, value); } } diff --git a/tests/browser/harness/runtime.js b/tests/browser/harness/runtime.js index 2ccd67fa..5c73483b 100644 --- a/tests/browser/harness/runtime.js +++ b/tests/browser/harness/runtime.js @@ -43,8 +43,8 @@ export async function injectUserscriptRuntime(page, options = {}) { window.__jhsBrowserTestMetadata = { fixture: true, version }; window.__jhsBrowserDiagnostics = { requests: [], startedAt: performance.now() }; window.unsafeWindow = window; - window.GM_getValue = async (_key, fallback) => fallback; - window.GM_setValue = async () => undefined; + window.GM_getValue = (_key, fallback) => fallback; + window.GM_setValue = () => undefined; window.GM_openInTab = () => ({ close() {} }); window.GM_xmlhttpRequest = (options) => { window.__jhsBrowserDiagnostics.requests.push({ method: options.method || "GET", url: String(options.url || "") }); diff --git a/tests/one-two-three-platform.test.js b/tests/one-two-three-platform.test.js new file mode 100644 index 00000000..7cd4d1d9 --- /dev/null +++ b/tests/one-two-three-platform.test.js @@ -0,0 +1,49 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { JSDOM } from "jsdom"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; +import { OneTwoThreeOfflinePlugin } from "../src/plugins/one-two-three/offline.js"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +function createHarness(localEntries = []) { + const dom = new JSDOM("
    ", { url: "https://yun.123pan.com/" }), local = new Map(localEntries), values = new Map(), scope = new LifecycleScope("feature:external-bridge"); + const storage = { + getLocal: vi.fn(key => local.get(key) ?? null), + getValue: vi.fn((key, fallback) => values.has(key) ? values.get(key) : fallback), + setValue: vi.fn((key, value) => values.set(key, value)), + }; + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("show", { info: vi.fn() }); + vi.stubGlobal("clog", { debug: vi.fn() }); + const plugin = new OneTwoThreeOfflinePlugin(); + plugin.runtimeServices = Object.freeze({ storage, scope: () => scope }); + return { plugin, scope, storage, values }; +} + +describe("123Pan platform boundary", () => { + it("preserves token keys while separating site-local discovery from GM storage", () => { + const { plugin, storage, values } = createHarness([["authorToken", "site-token"]]); + plugin.syncTokenOnce(); + expect(values.get("jhs_123pan_author_token")).toBe("site-token"); + expect(values.get("jhs_123pan_author_token_meta")).toMatchObject({ source: "authorToken" }); + expect(storage.setValue).toHaveBeenCalledTimes(2); + expect(plugin.getStoredToken()).toBe("site-token"); + }); + + it("removes all global listeners and polling when the Feature scope is disposed", async () => { + vi.useFakeTimers(); + const { plugin, scope } = createHarness(); + await plugin.handle(); + expect(scope.snapshot().listeners).toBe(3); + expect(plugin.syncTimer).not.toBeNull(); + scope.dispose(); + expect(scope.snapshot()).toMatchObject({ listeners: 0, disposed: true }); + expect(plugin.syncTimer).toBeNull(); + }); +}); diff --git a/tests/storage-service.test.js b/tests/storage-service.test.js index 93b6487d..bb26d6e6 100644 --- a/tests/storage-service.test.js +++ b/tests/storage-service.test.js @@ -4,7 +4,7 @@ import { StorageService } from "../src/services/storage-service.js"; describe("storage service", () => { it("keeps IndexedDB data and legacy local keys in separate namespaces", async () => { - const indexed = new Map(), local = new Map(); + const indexed = new Map(), local = new Map(), values = new Map(); const forage = { getItem: vi.fn(async key => indexed.get(key)), setItem: vi.fn(async (key, value) => indexed.set(key, value)), @@ -15,7 +15,8 @@ describe("storage service", () => { setItem: vi.fn((key, value) => local.set(key, value)), removeItem: vi.fn(key => local.delete(key)), }; - const storage = new StorageService(new IndexedDbStorageAdapter(forage, localStore)); + const getValue = vi.fn((key, fallback) => values.has(key) ? values.get(key) : fallback), setValue = vi.fn((key, value) => values.set(key, value)); + const storage = new StorageService(new IndexedDbStorageAdapter(forage, localStore, getValue, setValue)); await storage.set("setting", { theme: "dark" }); storage.setLocal("jhs_tag_expand", "true"); await expect(storage.get("setting")).resolves.toEqual({ theme: "dark" }); @@ -23,5 +24,10 @@ describe("storage service", () => { expect(indexed.has("jhs_tag_expand")).toBe(false); storage.removeLocal("jhs_tag_expand"); expect(storage.getLocal("jhs_tag_expand")).toBeNull(); + expect(storage.getValue("token", "missing")).toBe("missing"); + storage.setValue("token", "secret"); + expect(storage.getValue("token", "missing")).toBe("secret"); + expect(indexed.has("token")).toBe(false); + expect(local.has("token")).toBe(false); }); }); diff --git a/tests/ui-architecture.test.js b/tests/ui-architecture.test.js index f3ef6c2f..e9fbc7a2 100644 --- a/tests/ui-architecture.test.js +++ b/tests/ui-architecture.test.js @@ -193,7 +193,7 @@ describe("list toolbar and UI cleanup contracts", () => { it("keeps 123 as an auth/API provider without legacy submission UI", () => { expect(reviewUi).toContain('jhs-review-offline-btn jhs-offline-btn'); - expect(oneTwoThreeOffline).toContain("startTokenSync()"); + expect(oneTwoThreeOffline).toContain("startTokenSync(scope)"); expect(oneTwoThreeOffline).toContain("async resolveMagnet"); expect(oneTwoThreeOffline).toContain("async submitTask"); expect(oneTwoThreeOffline).not.toContain("async submitMagnet"); From 8fe8412f8854f69ab216c98e51e0eac142cb3b76 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:30:41 +0800 Subject: [PATCH 021/130] =?UTF-8?q?Refactor:=20=E6=89=98=E7=AE=A1=20JavTra?= =?UTF-8?q?ilers=20=E6=92=AD=E6=94=BE=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将 message、播放器 click 与 timeupdate 监听纳入 Feature LifecycleScope - 让 loopDetector 返回可取消句柄并托管延迟播放器初始化 - 补充 listener、DOM wait 与媒体事件 dispose 回归测试 修改原因: - 避免 JavTrailers Feature 销毁后继续响应全局消息或修改旧播放器 - 使旧 DOM 等待工具能够遵守统一生命周期边界 验证: - npm run check 通过(97 个测试文件,538 项测试) - npm run check:browser 通过(本机 Edge,13 passed,5 skipped) - git diff --check、codegraph sync 与正式双产物 SHA256 一致性检查通过 --- JHS.user.js | 40 +++++++++----- src/core/utils.js | 4 +- src/plugins/external-search/javtrailers.js | 30 +++++++---- tests/javtrailers-lifecycle.test.js | 63 ++++++++++++++++++++++ tests/utils.test.js | 10 ++++ 5 files changed, 121 insertions(+), 26 deletions(-) create mode 100644 tests/javtrailers-lifecycle.test.js diff --git a/JHS.user.js b/JHS.user.js index dca46c10..822d005d 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -1066,8 +1066,11 @@ }, "g"), p2 = /* @__PURE__ */ __name(() => { c2 || (clearTimeout(r2), r2 = setTimeout(g2, Math.max(0, n2))); }, "p"); + const cancel = /* @__PURE__ */ __name(() => { + c2 = true, d2(); + }, "cancel"); this.intervalContainer[s2] = {}; - if (e2()) return void h2(true); + if (e2()) return h2(true), cancel; if ("function" == typeof MutationObserver && document.documentElement) o2 = new MutationObserver(p2), o2.observe(document.documentElement, { childList: true, subtree: true, characterData: true }); else this.intervalContainer[s2].fallback = setInterval(g2, Math.max(100, n2)); l2 = setTimeout((() => { @@ -1080,6 +1083,7 @@ } h2(t3 || i2); }), Math.max(0, a2)); + return cancel; } rightClick(e2, t2, n2) { let a2; @@ -9565,7 +9569,7 @@ ${error.stack}` : ""); constructor() { super(), this.hasBand = false; } - handle() { + async handle() { let e2 = window.location.href; if (!e2.includes("handle=1")) return; if ($("h1:contains('Page not found')").length) { @@ -9578,21 +9582,27 @@ ${error.stack}` : ""); const n2 = e2.split("?")[0].split("search/")[1].toLowerCase(), a2 = t2.find(((e3) => $(e3).find(".vid-title").text().toLowerCase().includes(n2))); if (a2) return void (window.location.href = $(a2).attr("href") + window.location.search); } - this.handlePlayJavTrailers(), $("#videoPlayerContainer").on("click", (() => { - this.handlePlayJavTrailers(); - })), window.addEventListener("message", ((e3) => { - let t3 = document.getElementById("vjs_video_3_html5_api"); - t3 && (t3.currentTime += 5); + const scope = await this.getRuntimeService("scope")(); + this.handlePlayJavTrailers(scope), this.bindPlaybackControls(scope); + } + bindPlaybackControls(scope) { + const container = $("#videoPlayerContainer"), replay = /* @__PURE__ */ __name(() => this.handlePlayJavTrailers(scope), "replay"); + container.off("click.jhsJavTrailers").on("click.jhsJavTrailers", replay), scope.addCleanup((() => container.off("click.jhsJavTrailers", replay))), scope.listen(window, "message", (() => { + let t2 = document.getElementById("vjs_video_3_html5_api"); + t2 && (t2.currentTime += 5); })); } - handlePlayJavTrailers() { - this.hasBand || (utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { - setTimeout((() => { + handlePlayJavTrailers(scope) { + if (this.hasBand || scope.signal.aborted) return; + const playerWait = utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { + if (scope.signal.aborted) return; + scope.ownTimeout(setTimeout((() => { + if (scope.signal.aborted) return; this.hasBand = true; let e2 = document.getElementById("vjs_video_3_html5_api"); clog.debug(e2), safePlay(e2, { context: "JavTrailers 预览" - }), e2.currentTime = 5, e2.addEventListener("timeupdate", (function() { + }), e2.currentTime = 5, scope.listen(e2, "timeupdate", (function() { e2.currentTime >= 14 && e2.currentTime < 16 && (e2.currentTime += 2); })), $("#vjs_video_3_html5_api").css({ position: "fixed", @@ -9605,8 +9615,9 @@ ${error.stack}` : ""); bottom: "20px", zIndex: String(JHS_Z_INDEX.debug) }); - }), 100); - })), utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { + }), 100)); + })), canvasWait = utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { + if (scope.signal.aborted) return; 0 !== $("#vjs_video_3 canvas").length && $("#vjs_video_3 canvas").css({ position: "fixed", width: "100vw", @@ -9616,7 +9627,8 @@ ${error.stack}` : ""); right: "0", zIndex: String(JHS_Z_INDEX.debug - 1) }); - }))); + })); + scope.addCleanup(playerWait), scope.addCleanup(canvasWait); } }; __name(_JavTrailersPlugin, "JavTrailersPlugin"); diff --git a/src/core/utils.js b/src/core/utils.js index 1caa91d4..d136a448 100644 --- a/src/core/utils.js +++ b/src/core/utils.js @@ -130,8 +130,9 @@ export class Utils { }, p = () => { c || (clearTimeout(r), r = setTimeout(g, Math.max(0, n))); }; + const cancel = () => { c = !0, d(); }; this.intervalContainer[s] = {}; - if (e()) return void h(!0); + if (e()) return h(!0), cancel; if ("function" == typeof MutationObserver && document.documentElement) o = new MutationObserver(p), o.observe(document.documentElement, { childList: !0, subtree: !0, characterData: !0 }); else this.intervalContainer[s].fallback = setInterval(g, Math.max(100, n)); l = setTimeout((() => { @@ -140,6 +141,7 @@ export class Utils { try { t = e(); } catch (e) { clog.error("DOM 等待条件执行失败", e); } h(t || i); }), Math.max(0, a)); + return cancel; } rightClick(e, t, n) { let a; diff --git a/src/plugins/external-search/javtrailers.js b/src/plugins/external-search/javtrailers.js index 3ab09cc4..aba84b60 100644 --- a/src/plugins/external-search/javtrailers.js +++ b/src/plugins/external-search/javtrailers.js @@ -9,7 +9,7 @@ export class JavTrailersPlugin extends BasePlugin { constructor() { super(), this.hasBand = !1; } - handle() { + async handle() { let e = window.location.href; if (!e.includes("handle=1")) return; if ($("h1:contains('Page not found')").length) { @@ -22,21 +22,27 @@ export class JavTrailersPlugin extends BasePlugin { const n = e.split("?")[0].split("search/")[1].toLowerCase(), a = t.find((e => $(e).find(".vid-title").text().toLowerCase().includes(n))); if (a) return void (window.location.href = $(a).attr("href") + window.location.search); } - this.handlePlayJavTrailers(), $("#videoPlayerContainer").on("click", (() => { - this.handlePlayJavTrailers(); - })), window.addEventListener("message", (e => { + const scope = await this.getRuntimeService("scope")(); + this.handlePlayJavTrailers(scope), this.bindPlaybackControls(scope); + } + bindPlaybackControls(scope) { + const container = $("#videoPlayerContainer"), replay = () => this.handlePlayJavTrailers(scope); + container.off("click.jhsJavTrailers").on("click.jhsJavTrailers", replay), scope.addCleanup((() => container.off("click.jhsJavTrailers", replay))), scope.listen(window, "message", (() => { let t = document.getElementById("vjs_video_3_html5_api"); t && (t.currentTime += 5); })); } - handlePlayJavTrailers() { - this.hasBand || (utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { - setTimeout((() => { + handlePlayJavTrailers(scope) { + if (this.hasBand || scope.signal.aborted) return; + const playerWait = utils.loopDetector((() => 0 !== $("#vjs_video_3_html5_api").length), (() => { + if (scope.signal.aborted) return; + scope.ownTimeout(setTimeout((() => { + if (scope.signal.aborted) return; this.hasBand = !0; let e = document.getElementById("vjs_video_3_html5_api"); clog.debug(e), safePlay(e, { context: "JavTrailers 预览" - }), e.currentTime = 5, e.addEventListener("timeupdate", (function() { + }), e.currentTime = 5, scope.listen(e, "timeupdate", (function() { e.currentTime >= 14 && e.currentTime < 16 && (e.currentTime += 2); })), $("#vjs_video_3_html5_api").css({ position: "fixed", @@ -49,8 +55,9 @@ export class JavTrailersPlugin extends BasePlugin { bottom: "20px", zIndex: String(JHS_Z_INDEX.debug) }); - }), 100); - })), utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { + }), 100)); + })), canvasWait = utils.loopDetector((() => $("#vjs_video_3 canvas").length > 0), (() => { + if (scope.signal.aborted) return; 0 !== $("#vjs_video_3 canvas").length && $("#vjs_video_3 canvas").css({ position: "fixed", width: "100vw", @@ -60,6 +67,7 @@ export class JavTrailersPlugin extends BasePlugin { right: "0", zIndex: String(JHS_Z_INDEX.debug - 1) }); - }))); + })); + scope.addCleanup(playerWait), scope.addCleanup(canvasWait); } } diff --git a/tests/javtrailers-lifecycle.test.js b/tests/javtrailers-lifecycle.test.js new file mode 100644 index 00000000..cc66e39e --- /dev/null +++ b/tests/javtrailers-lifecycle.test.js @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; +import jquery from "jquery"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; +import { JavTrailersPlugin } from "../src/plugins/external-search/javtrailers.js"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("JavTrailers lifecycle", () => { + it("disposes playback message and jQuery click listeners with the Feature scope", () => { + document.body.innerHTML = '
    '; + vi.stubGlobal("$", jquery); + const plugin = new JavTrailersPlugin(), scope = new LifecycleScope("feature:external-bridge"); + vi.spyOn(plugin, "handlePlayJavTrailers").mockImplementation(() => {}); + plugin.bindPlaybackControls(scope); + expect(scope.snapshot().listeners).toBe(1); + $("#videoPlayerContainer").trigger("click"); + expect(plugin.handlePlayJavTrailers).toHaveBeenCalledTimes(1); + const video = document.getElementById("vjs_video_3_html5_api"); + video.currentTime = 1; + window.dispatchEvent(new MessageEvent("message")); + expect(video.currentTime).toBe(6); + scope.dispose(); + $("#videoPlayerContainer").trigger("click"); + window.dispatchEvent(new MessageEvent("message")); + expect(plugin.handlePlayJavTrailers).toHaveBeenCalledTimes(1); + expect(video.currentTime).toBe(6); + expect(scope.snapshot()).toMatchObject({ listeners: 0, disposed: true }); + }); + + it("cancels DOM waits and the media listener when disposed", async () => { + vi.useFakeTimers(); + document.body.innerHTML = '
    '; + const cancellations = []; + vi.stubGlobal("$", jquery); + vi.stubGlobal("clog", { debug: vi.fn() }); + vi.stubGlobal("utils", { loopDetector: (condition, callback) => { + if (condition()) callback(); + const cancel = vi.fn(); + cancellations.push(cancel); + return cancel; + } }); + const plugin = new JavTrailersPlugin(), scope = new LifecycleScope("feature:external-bridge"), video = document.getElementById("vjs_video_3_html5_api"); + video.play = vi.fn(async () => {}); + plugin.handlePlayJavTrailers(scope); + await vi.advanceTimersByTimeAsync(100); + expect(scope.snapshot().listeners).toBe(1); + video.currentTime = 15; + video.dispatchEvent(new Event("timeupdate")); + expect(video.currentTime).toBe(17); + scope.dispose(); + video.currentTime = 15; + video.dispatchEvent(new Event("timeupdate")); + expect(video.currentTime).toBe(15); + expect(cancellations).toHaveLength(2); + cancellations.forEach(cancel => expect(cancel).toHaveBeenCalledOnce()); + expect(scope.snapshot()).toMatchObject({ listeners: 0, disposed: true }); + }); +}); diff --git a/tests/utils.test.js b/tests/utils.test.js index 0d2f145b..dd3d7a3e 100644 --- a/tests/utils.test.js +++ b/tests/utils.test.js @@ -309,6 +309,16 @@ describe("loopDetector", () => { expect(getObserver().disconnected).toBe(true); expect(Object.keys(utils.intervalContainer)).toHaveLength(0); }); + + it("supports lifecycle cancellation before the condition becomes ready", () => { + vi.useFakeTimers(); + let ready = false; + const callback = vi.fn(), { utils, getObserver } = loadUtilsWithObserver(), cancel = utils.loopDetector(() => ready, callback, 20, 1e4, false); + cancel(), ready = true, getObserver().emit(), vi.advanceTimersByTime(20); + expect(callback).not.toHaveBeenCalled(); + expect(getObserver().disconnected).toBe(true); + expect(Object.keys(utils.intervalContainer)).toHaveLength(0); + }); }); describe("debounce", () => { From 95de22fcae6fd804bbc0971e2bbd6c032b0b5304 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:41:51 +0800 Subject: [PATCH 022/130] =?UTF-8?q?Refactor:=20=E5=BB=BA=E7=AB=8B=20123=20?= =?UTF-8?q?=E4=BA=91=E7=9B=98=20Integration=20=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 新增 pan123 Silver Integration,集中签名、URL trust、解析与提交契约 - 由 OfflineService 桥接 UnifiedOffline,授权 Contribution 仅保留凭证同步职责 - 将 401/403/429 归一为 JhsError 认证与限流错误,并保留 httpTimeout 设置 - 增加脱敏 fixture、契约测试、畸形响应与不可用 Provider 测试 修改原因: - 消除 Feature 对 123 外部 URL、gmHttp、原始响应和签名协议的直接依赖 - 保证认证失败、生命周期取消和 Provider 不可用行为可由 Service 统一处理 验证: - npm run check 通过(99 个测试文件,543 项测试) - npm run check:browser 通过(本机 Edge,13 passed,5 skipped) - Integration quality 13 项通过,架构债务降至 121,ESM 166 模块零循环 - git diff --check、codegraph sync 与正式双产物 SHA256 一致性检查通过 --- JHS.user.js | 209 +++++++++++------- src/app/create-app-context.js | 2 +- src/app/integration-catalog.js | 3 +- src/integrations/pan123/manifest.js | 104 +++++++++ src/plugins/offline/unified-offline.js | 10 +- src/plugins/one-two-three/offline.js | 68 ------ src/plugins/registry.js | 3 +- src/services/http-service.js | 5 +- src/services/offline-service.js | 16 +- tests/architecture-runtime.test.js | 2 +- .../fixtures/integrations/pan123/resolve.json | 14 ++ .../fixtures/integrations/pan123/submit.json | 6 + tests/http-service.test.js | 8 + tests/integrations/pan123.contract.test.js | 35 +++ tests/integrations/pan123.malformed.test.js | 20 ++ tests/ui-architecture.test.js | 10 +- 16 files changed, 351 insertions(+), 164 deletions(-) create mode 100644 src/integrations/pan123/manifest.js create mode 100644 tests/fixtures/integrations/pan123/resolve.json create mode 100644 tests/fixtures/integrations/pan123/submit.json create mode 100644 tests/integrations/pan123.contract.test.js create mode 100644 tests/integrations/pan123.malformed.test.js diff --git a/JHS.user.js b/JHS.user.js index 822d005d..c710348d 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -12907,17 +12907,16 @@ ${error.stack}` : ""); async handle() { if (!(r || l)) return; const scope = await this.getRuntimeService("scope")(); - this.registerProviders(), this.bindSubmit(), scope.addCleanup((() => $(document).off(".jhsUnifiedOffline"))); + this.registerProviders(scope), this.bindSubmit(), scope.addCleanup((() => $(document).off(".jhsUnifiedOffline"))); if (window.isDetailPage) this.injectNativeButtons(), scope.addCleanup(jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); } - registerProviders() { - const one23 = this.getDependency("OneTwoThreeOfflinePlugin"); + registerProviders(scope) { + const one23 = this.getDependency("OneTwoThreeOfflinePlugin"), offline = this.getRuntimeService("offline"); one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: ["magnet"], retryPolicy: { automaticAttempts: 0 }, isEnabled: /* @__PURE__ */ __name(() => storageManager.getSetting("enable123Offline", true), "isEnabled"), getAvailability: /* @__PURE__ */ __name(async () => one23.getStoredToken() ? { available: true, authState: "ready", reason: "授权已同步" } : { available: false, authState: "token-missing", reason: "尚未同步 123 授权" }, "getAvailability"), submit: /* @__PURE__ */ __name(async (resource) => { const token = one23.getStoredToken(); if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); - const resolved = await one23.resolveMagnet(resource, token); - return one23.submitTask(resolved, token); - }, "submit"), openUrl: /* @__PURE__ */ __name(() => "https://yun.123pan.com", "openUrl") }); + return offline.submitWithIntegration("pan123", resource, { token, scope }); + }, "submit"), openUrl: /* @__PURE__ */ __name(() => offline.getIntegrationHomeUrl("pan123"), "openUrl") }); this.registry.register({ id: "115", name: "115", capabilities: ["magnet", "ed2k"], retryPolicy: { automaticAttempts: 0 }, isEnabled: /* @__PURE__ */ __name(() => storageManager.getSetting("enable115Offline", false), "isEnabled"), getAvailability: /* @__PURE__ */ __name(async () => ({ available: true, authState: "unknown", reason: "提交时确认登录状态" }), "getAvailability"), submit: /* @__PURE__ */ __name((resource) => new OneOneFiveClient().addOffline(resource), "submit"), openUrl: /* @__PURE__ */ __name(() => "https://115.com", "openUrl") }); window.offlineProviderRegistry = this.registry; } @@ -12976,7 +12975,7 @@ ${error.stack}` : ""); })); } catch (error) { const code = error?.code || ("TOKEN_EXPIRED" === error ? "TOKEN_EXPIRED" : "SUBMIT_FAILED"); - ["LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING"].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: false, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: error.message || String(error) }); + ["AUTH_REQUIRED", "LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING"].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: false, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: error.message || String(error) }); restoreButton(); submitted || await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: error?.message || String(error), retryOf }), show.error(`${selected.provider.name} 离线失败:${error?.message || error}`); } finally { @@ -13153,72 +13152,6 @@ ${error.stack}` : ""); updatedAt: (/* @__PURE__ */ new Date()).toISOString() }); } - isTokenExpiredError(e2) { - const msg = e2 instanceof Error ? e2.message : "object" == typeof e2 && e2 ? e2.message || "" : String(e2 || ""); - return "TOKEN_EXPIRED" === e2 || "TOKEN_EXPIRED" === msg || msg.toLowerCase().includes("token is expired"); - } - assertApiResult(e2, t2) { - if (0 === e2.code) return; - const n2 = e2.message || e2.msg || t2 || "请求失败"; - throw /token is expired/i.test(n2) ? "TOKEN_EXPIRED" : n2; - } - /* 依赖 gmRequest 在非 2xx 时 reject 对象上附加 status 属性 */ - async resolveMagnet(e2, t2) { - try { - const n2 = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/resolve"), { urls: e2 }, { - Authorization: "Bearer " + t2, - "App-Version": "3", - platform: "web", - Origin: "https://yun.123pan.com", - Referer: "https://yun.123pan.com/" - }); - return this.assertApiResult(n2, "解析失败"), n2.data && n2.data.list && n2.data.list.length > 0 ? n2.data.list[0] : Promise.reject(n2.message || `解析失败 (${n2.code})`); - } catch (a2) { - if (a2 && 401 === a2.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(a2) ? "TOKEN_EXPIRED" : a2.message ? "响应解析失败: " + a2.message : String(a2); - } - } - async submitTask(e2, t2) { - if (!e2.files || 0 === e2.files.length) throw "没有可建立离线的文件"; - const n2 = e2.files.map(((e3) => e3.id)), a2 = e2.files.reduce(((e3, t3) => e3 + (t3.size || 0)), 0); - try { - const i2 = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/submit"), { - resource_list: [{ resource_id: e2.id, select_file_id: n2 }] - }, { - Authorization: "Bearer " + t2, - "App-Version": "3", - platform: "web" - }); - return this.assertApiResult(i2, "提交失败"), { fileCount: n2.length, totalSize: a2 }; - } catch (i2) { - if (i2 && 401 === i2.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(i2) ? "TOKEN_EXPIRED" : i2.message ? "响应解析失败: " + i2.message : String(i2); - } - } - _crc32(e2) { - const t2 = new Array(256); - for (let n3 = 0; n3 < 256; n3++) { - let a2 = n3; - for (let i2 = 0; i2 < 8; i2++) a2 = 1 & a2 ? 3988292384 ^ a2 >>> 1 : a2 >>> 1; - t2[n3] = a2; - } - let n2 = 4294967295; - for (let a2 = 0; a2 < e2.length; a2++) n2 = t2[(n2 ^ e2.charCodeAt(a2)) & 255] ^ n2 >>> 8; - return (n2 ^ 4294967295) >>> 0; - } - _signUrl(e2) { - const t2 = ["a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z"]; - const n2 = Math.round(1e7 * Math.random()); - const a2 = /* @__PURE__ */ new Date(); - const i2 = new Date(a2.getTime() + 6e4 * a2.getTimezoneOffset() + 288e5); - const s2 = `${i2.getFullYear()}${String(i2.getMonth() + 1).padStart(2, "0")}${String(i2.getDate()).padStart(2, "0")}${String(i2.getHours()).padStart(2, "0")}${String(i2.getMinutes()).padStart(2, "0")}`; - let o2 = ""; - for (let r3 = 0; r3 < s2.length; r3++) o2 += t2[parseInt(s2[r3])]; - const r2 = this._crc32(o2), l2 = Math.floor(i2.getTime() / 1e3); - const c2 = `${l2}|${n2}|${new URL(e2).pathname}|web|3|${r2}`; - const u2 = this._crc32(c2), d2 = new URL(e2); - return d2.searchParams.set(String(r2), `${l2}-${n2}-${u2}`), d2.toString(); - } }; __name(_OneTwoThreeOfflinePlugin, "OneTwoThreeOfflinePlugin"); var OneTwoThreeOfflinePlugin = _OneTwoThreeOfflinePlugin; @@ -15563,7 +15496,7 @@ ${error.stack}` : ""); manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host, SERVICE.dialog]), - manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [SERVICE.dialog]), + manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [SERVICE.dialog, SERVICE.offline]), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), @@ -15594,6 +15527,7 @@ ${error.stack}` : ""); [SERVICE.translation, "translation"], [SERVICE.webdav, "webdav"], [SERVICE.storage, "storage"], + [SERVICE.offline, "offline"], [SERVICE.dialog, "dialog"] ]); for (const token of item.requires) { @@ -16111,7 +16045,10 @@ ${error.stack}` : ""); try { const response = await this.port.request(options); this.urlPolicy.assertFinalUrl(response.finalUrl || options.url, urlPolicy); - if (response.status >= 400) throw new JhsError(response.status === 404 ? "NOT_FOUND" : "NETWORK_ERROR", `HTTP ${response.status}`, { source: options.providerId, retryable: response.status >= 500 }); + if (response.status >= 400) { + const code = [401, 403].includes(response.status) ? "AUTH_REQUIRED" : response.status === 404 ? "NOT_FOUND" : response.status === 429 ? "RATE_LIMITED" : "NETWORK_ERROR"; + throw new JhsError(code, `HTTP ${response.status}`, { source: options.providerId, retryable: response.status === 429 || response.status >= 500, details: { status: response.status } }); + } return response; } catch (error) { const normalized = JhsError.from(error, options.providerId); @@ -16307,8 +16244,19 @@ ${error.stack}` : ""); // src/services/offline-service.js var _OfflineService = class _OfflineService { - constructor(providers) { + constructor(providers, integrations) { this.providers = providers; + this.integrations = integrations; + } + submitWithIntegration(integrationId, resource, context = {}) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.submit !== "function") throw new TypeError(`Integration ${integrationId} does not support offline submission`); + return adapter.submit(resource, context); + } + getIntegrationHomeUrl(integrationId) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.homeUrl !== "string") throw new TypeError(`Integration ${integrationId} does not declare a home URL`); + return adapter.homeUrl; } async submit(resource, context = {}) { const providers = await this.providers.getAvailable("offline", context); @@ -16896,7 +16844,7 @@ ${error.stack}` : ""); const magnet = new MagnetService(providers, integrations); const screenshot = new ScreenshotService(providers, integrations); const translation = new TranslationService(integrations); - const offline = new OfflineService(providers); + const offline = new OfflineService(providers, integrations); container.register(PORT.navigation, navigationPort).register(PORT.http, httpPort).register(PORT.storage, storagePort).register(PORT.dialog, dialogPort).register(PORT.style, stylePort).register(SERVICE.diagnostics, diagnostics).register(SERVICE.urlPolicy, urlPolicy).register(SERVICE.navigation, navigation).register(SERVICE.http, http).register(SERVICE.storage, storage).register(SERVICE.webdav, webdav).register(SERVICE.dialog, dialog).register(SERVICE.settings, settings).register(SERVICE.cache, cache).register(SERVICE.profile, profile).register(SERVICE.movie, movie).register(SERVICE.actressInfo, actressInfo).register(SERVICE.review, review).register(SERVICE.related, related).register(SERVICE.magnet, magnet).register(SERVICE.screenshot, screenshot).register(SERVICE.offline, offline).register(SERVICE.translation, translation).register(REGISTRY.command, commands).register(REGISTRY.provider, providers).register(REGISTRY.integration, integrations).register(REGISTRY.settings, settingsRegistry); if (runtime.hostAdapter) container.register(PORT.host, runtime.hostAdapter); const features = new FeatureRuntime({ container, commands, diagnostics, disabled: runtime.disabled, site: runtime.site, route: runtime.route }); @@ -17592,6 +17540,107 @@ ${error.stack}` : ""); // src/integrations/javtrailers/manifest.js var manifest_default11 = defineIntegration({ id: "javtrailers", trustClass: "builtin-public", hosts: ["javtrailers.com"], capabilities: ["movie.preview"], requires: [PORT.http, SERVICE.urlPolicy], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "javtrailers" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => Object.freeze({ contracts: ["MoviePreview"] }), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); + // src/integrations/pan123/manifest.js + var URL_POLICY = Object.freeze({ trustClass: "builtin-public", hosts: ["123pan.com"] }); + function crc32(value) { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index += 1) { + let entry = index; + for (let bit = 0; bit < 8; bit += 1) entry = entry & 1 ? 3988292384 ^ entry >>> 1 : entry >>> 1; + table[index] = entry; + } + let result = 4294967295; + for (let index = 0; index < value.length; index += 1) result = table[(result ^ value.charCodeAt(index)) & 255] ^ result >>> 8; + return (result ^ 4294967295) >>> 0; + } + __name(crc32, "crc32"); + function secureNonce() { + const value = new Uint32Array(1); + crypto.getRandomValues(value); + return value[0] % 1e7; + } + __name(secureNonce, "secureNonce"); + function signPan123Url(input, options = {}) { + const alphabet = ["a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z"]; + const nonce = options.nonce ?? secureNonce(), current = options.now ?? /* @__PURE__ */ new Date(), chinaTime = new Date(current.getTime() + 6e4 * current.getTimezoneOffset() + 288e5); + const dateKey = `${chinaTime.getFullYear()}${String(chinaTime.getMonth() + 1).padStart(2, "0")}${String(chinaTime.getDate()).padStart(2, "0")}${String(chinaTime.getHours()).padStart(2, "0")}${String(chinaTime.getMinutes()).padStart(2, "0")}`; + const encodedDate = [...dateKey].map((digit) => alphabet[Number(digit)]).join(""), pathKey = crc32(encodedDate), timestamp = Math.floor(chinaTime.getTime() / 1e3); + const url = new URL(input), signature = crc32(`${timestamp}|${nonce}|${url.pathname}|web|3|${pathKey}`); + url.searchParams.set(String(pathKey), `${timestamp}-${nonce}-${signature}`); + return url.href; + } + __name(signPan123Url, "signPan123Url"); + function parsePayload(value) { + if (typeof value !== "string") return value; + try { + return JSON.parse(value); + } catch (cause) { + throw new JhsError("PARSE_ERROR", "123 云盘响应不是有效 JSON", { source: "pan123", cause }); + } + } + __name(parsePayload, "parsePayload"); + function assertSuccess(payload, action) { + if (!payload || typeof payload !== "object") throw new JhsError("INVALID_RESPONSE", `123 云盘${action}响应无效`, { source: "pan123" }); + if (payload.code === 0) return payload; + const message = String(payload.message || payload.msg || `${action}失败`); + if (/token is expired|unauthorized|登录|授权/i.test(message)) throw new JhsError("AUTH_REQUIRED", message, { source: "pan123" }); + throw new JhsError("INVALID_RESPONSE", message, { source: "pan123" }); + } + __name(assertSuccess, "assertSuccess"); + function createPan123Adapter(http, runtime = {}) { + const request = /* @__PURE__ */ __name(async (path, body, token, scope) => { + const url = signPan123Url(`https://yun.123pan.com${path}`, { now: runtime.now?.(), nonce: runtime.nonce?.() }); + return http.request({ + providerId: "pan123", + method: "POST", + url, + body: JSON.stringify(body), + responseType: "json", + cacheScope: "none", + timeout: runtime.getTimeout?.() ?? 5e3, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "App-Version": "3", platform: "web", Origin: "https://yun.123pan.com", Referer: "https://yun.123pan.com/" }, + urlPolicy: URL_POLICY + }, scope); + }, "request"); + return Object.freeze({ + contracts: ["OfflineResolvedResource", "OfflineSubmission"], + homeUrl: "https://yun.123pan.com", + async resolve(resource, context = {}) { + const { token, scope } = context; + if (!token) throw new JhsError("AUTH_REQUIRED", "尚未同步 123 授权", { source: "pan123" }); + const response = await request("/b/api/v2/offline_download/task/resolve", { urls: resource }, token, scope), payload = assertSuccess(parsePayload(response.data), "解析"), item = payload.data?.list?.[0]; + if (!item?.id || !Array.isArray(item.files)) throw new JhsError("INVALID_RESPONSE", "123 云盘解析结果缺少资源文件", { source: "pan123" }); + return Object.freeze({ id: item.id, files: Object.freeze(item.files.map((file) => Object.freeze({ id: file.id, size: Number(file.size || 0) }))) }); + }, + async submitResolved(resource, context = {}) { + const { token, scope } = context; + if (!token) throw new JhsError("AUTH_REQUIRED", "尚未同步 123 授权", { source: "pan123" }); + if (!resource?.id || !Array.isArray(resource.files) || resource.files.length === 0) throw new JhsError("INVALID_RESPONSE", "没有可建立离线的文件", { source: "pan123" }); + const fileIds = resource.files.map((file) => file.id), totalSize = resource.files.reduce((sum, file) => sum + Number(file.size || 0), 0); + const response = await request("/b/api/v2/offline_download/task/submit", { resource_list: [{ resource_id: resource.id, select_file_id: fileIds }] }, token, scope); + assertSuccess(parsePayload(response.data), "提交"); + return Object.freeze({ fileCount: fileIds.length, totalSize }); + }, + async submit(resource, context = {}) { + const resolved = await this.resolve(resource, context); + return this.submitResolved(resolved, context); + } + }); + } + __name(createPan123Adapter, "createPan123Adapter"); + var manifest_default12 = defineIntegration({ + id: "pan123", + trustClass: "builtin-public", + hosts: ["123pan.com"], + capabilities: ["offline.resolve", "offline.submit"], + requires: [SERVICE.http, SERVICE.settings], + createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http], settings: dependencies[SERVICE.settings] }), "createClient"), + createAdapter: /* @__PURE__ */ __name((client) => createPan123Adapter(client.http, { getTimeout: /* @__PURE__ */ __name(() => Number(client.settings.snapshot().httpTimeout ?? 5e3), "getTimeout") }), "createAdapter"), + createHostAdapter: null, + cachePolicy: { "offline.resolve": "none", "offline.submit": "none" }, + quality: "silver" + }); + // src/integrations/subtitlecat/manifest.js function createSubtitleCatAdapter() { return Object.freeze({ @@ -17606,7 +17655,7 @@ ${error.stack}` : ""); }); } __name(createSubtitleCatAdapter, "createSubtitleCatAdapter"); - var manifest_default12 = defineIntegration({ id: "subtitlecat", trustClass: "builtin-public", hosts: ["subtitlecat.com"], capabilities: ["subtitle.search"], requires: [], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "subtitlecat" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => createSubtitleCatAdapter(), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); + var manifest_default13 = defineIntegration({ id: "subtitlecat", trustClass: "builtin-public", hosts: ["subtitlecat.com"], capabilities: ["subtitle.search"], requires: [], createClient: /* @__PURE__ */ __name(() => Object.freeze({ id: "subtitlecat" }), "createClient"), createAdapter: /* @__PURE__ */ __name(() => createSubtitleCatAdapter(), "createAdapter"), createHostAdapter: null, cachePolicy: "none", quality: "bronze" }); // src/integrations/wikipedia/parser.js function compact(value) { @@ -17661,7 +17710,7 @@ ${error.stack}` : ""); }); } __name(createWikipediaAdapter, "createWikipediaAdapter"); - var manifest_default13 = defineIntegration({ + var manifest_default14 = defineIntegration({ id: "wikipedia", trustClass: "builtin-public", hosts: ["ja.wikipedia.org"], @@ -17675,7 +17724,7 @@ ${error.stack}` : ""); }); // src/app/integration-catalog.js - var integrationManifests = Object.freeze([manifest_default2, manifest_default3, manifest_default4, manifest_default5, manifest_default6, manifest_default7, manifest_default8, manifest_default9, manifest_default10, manifest_default11, manifest_default12, manifest_default13]); + var integrationManifests = Object.freeze([manifest_default2, manifest_default3, manifest_default4, manifest_default5, manifest_default6, manifest_default7, manifest_default8, manifest_default9, manifest_default10, manifest_default11, manifest_default12, manifest_default13, manifest_default14]); // src/app/bootstrap.js function patchLayerRuntime(layerRuntime) { diff --git a/src/app/create-app-context.js b/src/app/create-app-context.js index 540f85f4..9b0e8a70 100644 --- a/src/app/create-app-context.js +++ b/src/app/create-app-context.js @@ -65,7 +65,7 @@ export function createAppContext(runtime) { const magnet = new MagnetService(providers, integrations); const screenshot = new ScreenshotService(providers, integrations); const translation = new TranslationService(integrations); - const offline = new OfflineService(providers); + const offline = new OfflineService(providers, integrations); container .register(PORT.navigation, navigationPort).register(PORT.http, httpPort).register(PORT.storage, storagePort) diff --git a/src/app/integration-catalog.js b/src/app/integration-catalog.js index 8175eb43..55869028 100644 --- a/src/app/integration-catalog.js +++ b/src/app/integration-catalog.js @@ -10,7 +10,8 @@ import javdb from "../integrations/javdb/manifest.js"; import javbus from "../integrations/javbus/manifest.js"; import javstore from "../integrations/javstore/manifest.js"; import javtrailers from "../integrations/javtrailers/manifest.js"; +import pan123 from "../integrations/pan123/manifest.js"; import subtitlecat from "../integrations/subtitlecat/manifest.js"; import wikipedia from "../integrations/wikipedia/manifest.js"; -export const integrationManifests = Object.freeze([av123, dmm, fc2ppvdb, fc2content, googleTranslate, hostList, javdb, javbus, javstore, javtrailers, subtitlecat, wikipedia]); +export const integrationManifests = Object.freeze([av123, dmm, fc2ppvdb, fc2content, googleTranslate, hostList, javdb, javbus, javstore, javtrailers, pan123, subtitlecat, wikipedia]); diff --git a/src/integrations/pan123/manifest.js b/src/integrations/pan123/manifest.js new file mode 100644 index 00000000..e8c41c85 --- /dev/null +++ b/src/integrations/pan123/manifest.js @@ -0,0 +1,104 @@ +// @ts-check + +import { defineIntegration } from "../../contracts/manifests.js"; +import { SERVICE } from "../../contracts/tokens.js"; +import { JhsError } from "../../core/jhs-error.js"; + +const URL_POLICY = Object.freeze({ trustClass: "builtin-public", hosts: ["123pan.com"] }); + +/** @typedef {{id: string | number, size: number}} Pan123File */ +/** @typedef {{id: string | number, files: readonly Pan123File[]}} Pan123ResolvedResource */ +/** @typedef {{token?: string, scope?: import("../../core/lifecycle-scope.js").LifecycleScope}} Pan123Context */ + +/** @param {string} value */ +export function crc32(value) { + const table = new Uint32Array(256); + for (let index = 0; index < 256; index += 1) { + let entry = index; + for (let bit = 0; bit < 8; bit += 1) entry = entry & 1 ? 0xedb88320 ^ entry >>> 1 : entry >>> 1; + table[index] = entry; + } + let result = 0xffffffff; + for (let index = 0; index < value.length; index += 1) result = table[(result ^ value.charCodeAt(index)) & 0xff] ^ result >>> 8; + return (result ^ 0xffffffff) >>> 0; +} + +function secureNonce() { + const value = new Uint32Array(1); + crypto.getRandomValues(value); + return value[0] % 10_000_000; +} + +/** @param {string} input @param {{now?: Date, nonce?: number}} [options] */ +export function signPan123Url(input, options = {}) { + const alphabet = ["a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z"]; + const nonce = options.nonce ?? secureNonce(), current = options.now ?? new Date(), chinaTime = new Date(current.getTime() + 60_000 * current.getTimezoneOffset() + 28_800_000); + const dateKey = `${chinaTime.getFullYear()}${String(chinaTime.getMonth() + 1).padStart(2, "0")}${String(chinaTime.getDate()).padStart(2, "0")}${String(chinaTime.getHours()).padStart(2, "0")}${String(chinaTime.getMinutes()).padStart(2, "0")}`; + const encodedDate = [...dateKey].map((digit) => alphabet[Number(digit)]).join(""), pathKey = crc32(encodedDate), timestamp = Math.floor(chinaTime.getTime() / 1000); + const url = new URL(input), signature = crc32(`${timestamp}|${nonce}|${url.pathname}|web|3|${pathKey}`); + url.searchParams.set(String(pathKey), `${timestamp}-${nonce}-${signature}`); + return url.href; +} + +/** @param {unknown} value */ +function parsePayload(value) { + if (typeof value !== "string") return value; + try { return JSON.parse(value); } + catch (cause) { throw new JhsError("PARSE_ERROR", "123 云盘响应不是有效 JSON", { source: "pan123", cause }); } +} + +/** @param {any} payload @param {string} action */ +function assertSuccess(payload, action) { + if (!payload || typeof payload !== "object") throw new JhsError("INVALID_RESPONSE", `123 云盘${action}响应无效`, { source: "pan123" }); + if (payload.code === 0) return payload; + const message = String(payload.message || payload.msg || `${action}失败`); + if (/token is expired|unauthorized|登录|授权/i.test(message)) throw new JhsError("AUTH_REQUIRED", message, { source: "pan123" }); + throw new JhsError("INVALID_RESPONSE", message, { source: "pan123" }); +} + +/** @param {{request: (options: Record, scope?: any) => Promise}} http @param {{now?: () => Date, nonce?: () => number, getTimeout?: () => number}} [runtime] */ +export function createPan123Adapter(http, runtime = {}) { + /** @param {string} path @param {unknown} body @param {string} token @param {import("../../core/lifecycle-scope.js").LifecycleScope | undefined} scope */ + const request = async (path, body, token, scope) => { + const url = signPan123Url(`https://yun.123pan.com${path}`, { now: runtime.now?.(), nonce: runtime.nonce?.() }); + return http.request({ + providerId: "pan123", method: "POST", url, body: JSON.stringify(body), responseType: "json", cacheScope: "none", timeout: runtime.getTimeout?.() ?? 5000, + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "App-Version": "3", platform: "web", Origin: "https://yun.123pan.com", Referer: "https://yun.123pan.com/" }, + urlPolicy: URL_POLICY, + }, scope); + }; + return Object.freeze({ + contracts: ["OfflineResolvedResource", "OfflineSubmission"], + homeUrl: "https://yun.123pan.com", + /** @param {string} resource @param {Pan123Context} [context] */ + async resolve(resource, context = {}) { + const { token, scope } = context; + if (!token) throw new JhsError("AUTH_REQUIRED", "尚未同步 123 授权", { source: "pan123" }); + const response = await request("/b/api/v2/offline_download/task/resolve", { urls: resource }, token, scope), payload = assertSuccess(parsePayload(response.data), "解析"), item = payload.data?.list?.[0]; + if (!item?.id || !Array.isArray(item.files)) throw new JhsError("INVALID_RESPONSE", "123 云盘解析结果缺少资源文件", { source: "pan123" }); + return /** @type {Pan123ResolvedResource} */ (Object.freeze({ id: item.id, files: Object.freeze(item.files.map((/** @type {any} */ file) => Object.freeze({ id: file.id, size: Number(file.size || 0) }))) })); + }, + /** @param {Pan123ResolvedResource} resource @param {Pan123Context} [context] */ + async submitResolved(resource, context = {}) { + const { token, scope } = context; + if (!token) throw new JhsError("AUTH_REQUIRED", "尚未同步 123 授权", { source: "pan123" }); + if (!resource?.id || !Array.isArray(resource.files) || resource.files.length === 0) throw new JhsError("INVALID_RESPONSE", "没有可建立离线的文件", { source: "pan123" }); + const fileIds = resource.files.map((file) => file.id), totalSize = resource.files.reduce((sum, file) => sum + Number(file.size || 0), 0); + const response = await request("/b/api/v2/offline_download/task/submit", { resource_list: [{ resource_id: resource.id, select_file_id: fileIds }] }, token, scope); + assertSuccess(parsePayload(response.data), "提交"); + return Object.freeze({ fileCount: fileIds.length, totalSize }); + }, + /** @param {string} resource @param {Pan123Context} [context] */ + async submit(resource, context = {}) { + const resolved = await this.resolve(resource, context); + return this.submitResolved(resolved, context); + }, + }); +} + +export default defineIntegration({ + id: "pan123", trustClass: "builtin-public", hosts: ["123pan.com"], capabilities: ["offline.resolve", "offline.submit"], requires: [SERVICE.http, SERVICE.settings], + createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http], settings: dependencies[SERVICE.settings] }), + createAdapter: (/** @type {any} */ client) => createPan123Adapter(client.http, { getTimeout: () => Number(client.settings.snapshot().httpTimeout ?? 5000) }), createHostAdapter: null, + cachePolicy: { "offline.resolve": "none", "offline.submit": "none" }, quality: "silver", +}); diff --git a/src/plugins/offline/unified-offline.js b/src/plugins/offline/unified-offline.js index d8c0e4d2..9e35f38f 100644 --- a/src/plugins/offline/unified-offline.js +++ b/src/plugins/offline/unified-offline.js @@ -38,12 +38,12 @@ export class UnifiedOfflinePlugin extends BasePlugin { async handle() { if (!(r || l)) return; const scope = await this.getRuntimeService("scope")(); - this.registerProviders(), this.bindSubmit(), scope.addCleanup((() => $(document).off(".jhsUnifiedOffline"))); + this.registerProviders(scope), this.bindSubmit(), scope.addCleanup((() => $(document).off(".jhsUnifiedOffline"))); if (window.isDetailPage) this.injectNativeButtons(), scope.addCleanup(jhsEventBus.on("magnet-items-updated", (() => this.injectNativeButtons()))); } - registerProviders() { - const one23 = this.getDependency("OneTwoThreeOfflinePlugin"); - one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: [ "magnet" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting("enable123Offline", !0), getAvailability: async () => one23.getStoredToken() ? { available: !0, authState: "ready", reason: "授权已同步" } : { available: !1, authState: "token-missing", reason: "尚未同步 123 授权" }, submit: async resource => { const token = one23.getStoredToken(); if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); const resolved = await one23.resolveMagnet(resource, token); return one23.submitTask(resolved, token); }, openUrl: () => "https://yun.123pan.com" }); + registerProviders(scope) { + const one23 = this.getDependency("OneTwoThreeOfflinePlugin"), offline = this.getRuntimeService("offline"); + one23 && this.registry.register({ id: "123", name: "123 云盘", capabilities: [ "magnet" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting("enable123Offline", !0), getAvailability: async () => one23.getStoredToken() ? { available: !0, authState: "ready", reason: "授权已同步" } : { available: !1, authState: "token-missing", reason: "尚未同步 123 授权" }, submit: async resource => { const token = one23.getStoredToken(); if (!token) throw Object.assign(new Error("尚未同步 123 授权"), { code: "TOKEN_MISSING" }); return offline.submitWithIntegration("pan123", resource, { token, scope }); }, openUrl: () => offline.getIntegrationHomeUrl("pan123") }); this.registry.register({ id: "115", name: "115", capabilities: [ "magnet", "ed2k" ], retryPolicy: { automaticAttempts: 0 }, isEnabled: () => storageManager.getSetting("enable115Offline", !1), getAvailability: async () => ({ available: !0, authState: "unknown", reason: "提交时确认登录状态" }), submit: resource => new OneOneFiveClient().addOffline(resource), openUrl: () => "https://115.com" }); window.offlineProviderRegistry = this.registry; } @@ -100,7 +100,7 @@ export class UnifiedOfflinePlugin extends BasePlugin { button.text("已提交"), show.ok(`${selected.provider.name} 离线任务已创建`), utils.q(event, "是否将该作品标记为已下载?", (async () => { info?.carNum && await stateService.patch(info.carNum, { downloaded: !0 }, { type: "offline-mark-downloaded", record: { ...info, names: info.actress || info.names || "" } }); })); } catch (error) { const code = error?.code || ("TOKEN_EXPIRED" === error ? "TOKEN_EXPIRED" : "SUBMIT_FAILED"); - [ "LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING" ].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: !1, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: error.message || String(error) }); + [ "AUTH_REQUIRED", "LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING" ].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: !1, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: error.message || String(error) }); restoreButton(); submitted || await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: error?.message || String(error), retryOf }), show.error(`${selected.provider.name} 离线失败:${error?.message || error}`); } finally { submitted ? setTimeout(restoreButton, this.BUTTON_COOLDOWN_MS) : restoreButton(); } diff --git a/src/plugins/one-two-three/offline.js b/src/plugins/one-two-three/offline.js index eb0e367c..65b7bf53 100644 --- a/src/plugins/one-two-three/offline.js +++ b/src/plugins/one-two-three/offline.js @@ -70,72 +70,4 @@ export class OneTwoThreeOfflinePlugin extends BasePlugin { updatedAt: (new Date).toISOString() }); } - isTokenExpiredError(e) { - const msg = e instanceof Error ? e.message : "object" == typeof e && e ? e.message || "" : String(e || ""); - return "TOKEN_EXPIRED" === e || "TOKEN_EXPIRED" === msg || msg.toLowerCase().includes("token is expired"); - } - assertApiResult(e, t) { - if (0 === e.code) return; - const n = e.message || e.msg || t || "请求失败"; - throw /token is expired/i.test(n) ? "TOKEN_EXPIRED" : n; - } - /* 依赖 gmRequest 在非 2xx 时 reject 对象上附加 status 属性 */ - async resolveMagnet(e, t) { - try { - const n = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/resolve"), { urls: e }, { - Authorization: "Bearer " + t, - "App-Version": "3", - platform: "web", - Origin: "https://yun.123pan.com", - Referer: "https://yun.123pan.com/" - }); - return this.assertApiResult(n, "解析失败"), n.data && n.data.list && n.data.list.length > 0 ? n.data.list[0] : Promise.reject(n.message || `解析失败 (${n.code})`); - } catch (a) { - if (a && 401 === a.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(a) ? "TOKEN_EXPIRED" : a.message ? "响应解析失败: " + a.message : String(a); - } - } - async submitTask(e, t) { - if (!e.files || 0 === e.files.length) throw "没有可建立离线的文件"; - const n = e.files.map((e => e.id)), a = e.files.reduce(((e, t) => e + (t.size || 0)), 0); - try { - const i = await gmHttp.post(this._signUrl("https://yun.123pan.com/b/api/v2/offline_download/task/submit"), { - resource_list: [{ resource_id: e.id, select_file_id: n }] - }, { - Authorization: "Bearer " + t, - "App-Version": "3", - platform: "web" - }); - return this.assertApiResult(i, "提交失败"), { fileCount: n.length, totalSize: a }; - } catch (i) { - if (i && 401 === i.status) throw "TOKEN_EXPIRED"; - throw this.isTokenExpiredError(i) ? "TOKEN_EXPIRED" : i.message ? "响应解析失败: " + i.message : String(i); - } - } - /** CRC32-IEEE (poly 0xEDB88320) — 与 Go crc32.ChecksumIEEE 一致 */ - _crc32(e) { - const t = new Array(256); - for (let n = 0; n < 256; n++) { - let a = n; - for (let i = 0; i < 8; i++) a = 1 & a ? 3988292384 ^ a >>> 1 : a >>> 1; - t[n] = a; - } - let n = 4294967295; - for (let a = 0; a < e.length; a++) n = t[(n ^ e.charCodeAt(a)) & 255] ^ n >>> 8; - return (n ^ 4294967295) >>> 0; - } - /** 为 123 云盘 API URL 附加签名查询参数(与 Go signPath 算法一致) */ - _signUrl(e) { - const t = [ "a", "d", "e", "f", "g", "h", "l", "m", "y", "i", "j", "n", "o", "p", "k", "q", "r", "s", "t", "u", "b", "c", "v", "w", "s", "z" ]; - const n = Math.round(1e7 * Math.random()); - const a = new Date; - const i = new Date(a.getTime() + 6e4 * a.getTimezoneOffset() + 288e5); - const s = `${i.getFullYear()}${String(i.getMonth() + 1).padStart(2, "0")}${String(i.getDate()).padStart(2, "0")}${String(i.getHours()).padStart(2, "0")}${String(i.getMinutes()).padStart(2, "0")}`; - let o = ""; - for (let r = 0; r < s.length; r++) o += t[parseInt(s[r])]; - const r = this._crc32(o), l = Math.floor(i.getTime() / 1e3); - const c = `${l}|${n}|${new URL(e).pathname}|web|3|${r}`; - const u = this._crc32(c), d = new URL(e); - return d.searchParams.set(String(r), `${l}-${n}-${u}`), d.toString(); - } } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 4c3344f6..3d5bbe10 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -84,7 +84,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("stats.dashboard", "stats", StatsPlugin, ["javdb", "javbus"], { javdb: 32, javbus: 23 }, [SERVICE.diagnostics, SERVICE.dialog]), manifest("responsive-shell.bottom-bar", "responsive-shell", MobileBottomBarPlugin, ["javdb", "javbus"], { javdb: 33, javbus: 24 }, [SERVICE.settings]), manifest("external-bridge.115-match", "external-bridge", OneOneFiveMatchPlugin, ["javdb", "javbus"], { javdb: 34, javbus: 25 }, [PORT.host, SERVICE.dialog]), - manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [SERVICE.dialog]), + manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [SERVICE.dialog, SERVICE.offline]), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), @@ -114,6 +114,7 @@ export function registerSitePlugins(pluginManager, featureRuntime, site) { [SERVICE.translation, "translation"], [SERVICE.webdav, "webdav"], [SERVICE.storage, "storage"], + [SERVICE.offline, "offline"], [SERVICE.dialog, "dialog"], ]); for (const token of item.requires) { diff --git a/src/services/http-service.js b/src/services/http-service.js index dcb2b48d..f4777696 100644 --- a/src/services/http-service.js +++ b/src/services/http-service.js @@ -93,7 +93,10 @@ export class HttpService { try { const response = await this.port.request(options); this.urlPolicy.assertFinalUrl(response.finalUrl || options.url, urlPolicy); - if (response.status >= 400) throw new JhsError(response.status === 404 ? "NOT_FOUND" : "NETWORK_ERROR", `HTTP ${response.status}`, { source: options.providerId, retryable: response.status >= 500 }); + if (response.status >= 400) { + const code = [401, 403].includes(response.status) ? "AUTH_REQUIRED" : response.status === 404 ? "NOT_FOUND" : response.status === 429 ? "RATE_LIMITED" : "NETWORK_ERROR"; + throw new JhsError(code, `HTTP ${response.status}`, { source: options.providerId, retryable: response.status === 429 || response.status >= 500, details: { status: response.status } }); + } return response; } catch (error) { const normalized = JhsError.from(error, options.providerId); diff --git a/src/services/offline-service.js b/src/services/offline-service.js index 945848d6..d47f6d36 100644 --- a/src/services/offline-service.js +++ b/src/services/offline-service.js @@ -1,8 +1,20 @@ // @ts-check export class OfflineService { - /** @param {import("../app/provider-registry.js").ProviderRegistry} providers */ - constructor(providers) { this.providers = providers; } + /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry} integrations */ + constructor(providers, integrations) { this.providers = providers; this.integrations = integrations; } + /** @param {string} integrationId @param {unknown} resource @param {Record} [context] */ + submitWithIntegration(integrationId, resource, context = {}) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.submit !== "function") throw new TypeError(`Integration ${integrationId} does not support offline submission`); + return adapter.submit(resource, context); + } + /** @param {string} integrationId */ + getIntegrationHomeUrl(integrationId) { + const adapter = this.integrations.getAdapter(integrationId); + if (typeof adapter.homeUrl !== "string") throw new TypeError(`Integration ${integrationId} does not declare a home URL`); + return adapter.homeUrl; + } /** @param {Record} resource @param {Record} [context] */ async submit(resource, context = {}) { const providers = await this.providers.getAvailable("offline", context); diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js index 6bc373f9..ab184244 100644 --- a/tests/architecture-runtime.test.js +++ b/tests/architecture-runtime.test.js @@ -51,7 +51,7 @@ describe("v6.5 architecture runtime contracts", () => { const { registerSitePlugins } = await import("../src/plugins/registry.js"); const createRuntime = (site, disabled = []) => { const diagnostics = new DiagnosticsService(); - const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.settings, {}).register(SERVICE.storage, {}).register(SERVICE.cache, {}).register(SERVICE.http, {}); + const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.settings, {}).register(SERVICE.storage, {}).register(SERVICE.cache, {}).register(SERVICE.http, {}).register(SERVICE.offline, {}); const runtime = new FeatureRuntime({ container, commands: new CommandRegistry(), diagnostics, disabled, site, route: "list" }); featureManifests.forEach((manifest) => runtime.register(manifest)); return runtime; diff --git a/tests/fixtures/integrations/pan123/resolve.json b/tests/fixtures/integrations/pan123/resolve.json new file mode 100644 index 00000000..577056dd --- /dev/null +++ b/tests/fixtures/integrations/pan123/resolve.json @@ -0,0 +1,14 @@ +{ + "code": 0, + "data": { + "list": [ + { + "id": 42, + "files": [ + { "id": 101, "size": 100 }, + { "id": 102, "size": 200 } + ] + } + ] + } +} diff --git a/tests/fixtures/integrations/pan123/submit.json b/tests/fixtures/integrations/pan123/submit.json new file mode 100644 index 00000000..8c19d04c --- /dev/null +++ b/tests/fixtures/integrations/pan123/submit.json @@ -0,0 +1,6 @@ +{ + "code": 0, + "data": { + "task_id": 9001 + } +} diff --git a/tests/http-service.test.js b/tests/http-service.test.js index 3c0a261e..6e5da989 100644 --- a/tests/http-service.test.js +++ b/tests/http-service.test.js @@ -38,6 +38,14 @@ describe("HTTP, URL and settings contracts", () => { await expect(service.request({ providerId: "example", url: "https://api.example.test/data", urlPolicy: { trustClass: "builtin-public", hosts: ["api.example.test"] }, cacheScope: "none" })).rejects.toMatchObject({ code: "INVALID_URL" }); }); + it("normalizes authentication and rate-limit HTTP statuses", async () => { + const policy = new ExternalUrlPolicy(), options = { providerId: "example", url: "https://api.example.test/data", urlPolicy: { trustClass: "builtin-public", hosts: ["api.example.test"] }, cacheScope: "none" }; + const unauthorized = new HttpService({ request: async () => ({ status: 401, finalUrl: options.url }) }, policy); + await expect(unauthorized.request(options)).rejects.toMatchObject({ code: "AUTH_REQUIRED", retryable: false, details: { status: 401 } }); + const limited = new HttpService({ request: async () => ({ status: 429, finalUrl: options.url }) }, policy); + await expect(limited.request(options)).rejects.toMatchObject({ code: "RATE_LIMITED", retryable: true, details: { status: 429 } }); + }); + it("can constrain builtin requests and redirects to an exact origin", () => { const policy = new ExternalUrlPolicy(), contract = { trustClass: "builtin-public", hosts: ["example.test"], expectedOrigin: "https://example.test" }; expect(policy.assertAllowed("https://example.test/page", contract).pathname).toBe("/page"); diff --git a/tests/integrations/pan123.contract.test.js b/tests/integrations/pan123.contract.test.js new file mode 100644 index 00000000..62b57a3b --- /dev/null +++ b/tests/integrations/pan123.contract.test.js @@ -0,0 +1,35 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { LifecycleScope } from "../../src/core/lifecycle-scope.js"; +import { createPan123Adapter, signPan123Url } from "../../src/integrations/pan123/manifest.js"; +import { OfflineService } from "../../src/services/offline-service.js"; + +const fixture = (name) => JSON.parse(readFileSync(join(import.meta.dirname, `../fixtures/integrations/pan123/${name}.json`), "utf8")); + +describe("pan123 integration contract", () => { + it("signs requests without Math.random and normalizes resolve/submit results", async () => { + const scope = new LifecycleScope("feature:external-bridge"), request = vi.fn(async options => ({ status: 200, data: options.url.includes("/resolve") ? fixture("resolve") : fixture("submit"), finalUrl: options.url })); + const adapter = createPan123Adapter({ request }, { now: () => new Date("2026-08-24T12:34:00.000Z"), nonce: () => 123456 }); + expect(adapter.homeUrl).toBe("https://yun.123pan.com"); + await expect(adapter.submit("magnet:?xt=urn:btih:test", { token: "secret", scope })).resolves.toEqual({ fileCount: 2, totalSize: 300 }); + expect(request).toHaveBeenCalledTimes(2); + for (const [options, receivedScope] of request.mock.calls) { + expect(options).toMatchObject({ providerId: "pan123", method: "POST", responseType: "json", cacheScope: "none", timeout: 5000, urlPolicy: { trustClass: "builtin-public", hosts: ["123pan.com"] } }); + expect(options.url).toMatch(/^https:\/\/yun\.123pan\.com\/b\/api\/v2\/offline_download\/task\/(resolve|submit)\?/); + expect(options.headers.Authorization).toBe("Bearer secret"); + expect(receivedScope).toBe(scope); + } + expect(JSON.parse(request.mock.calls[0][0].body)).toEqual({ urls: "magnet:?xt=urn:btih:test" }); + expect(JSON.parse(request.mock.calls[1][0].body)).toEqual({ resource_list: [{ resource_id: 42, select_file_id: [101, 102] }] }); + expect(signPan123Url("https://yun.123pan.com/path", { now: new Date("2026-08-24T12:34:00.000Z"), nonce: 123456 })).toContain("123456"); + }); + + it("keeps Feature callers behind OfflineService", async () => { + const submit = vi.fn(async () => ({ fileCount: 1, totalSize: 100 })), integrations = { getAdapter: vi.fn(() => ({ submit })) }; + const service = new OfflineService({ getAvailable: vi.fn() }, integrations); + await expect(service.submitWithIntegration("pan123", "magnet:test", { token: "secret" })).resolves.toEqual({ fileCount: 1, totalSize: 100 }); + expect(integrations.getAdapter).toHaveBeenCalledWith("pan123"); + expect(submit).toHaveBeenCalledWith("magnet:test", { token: "secret" }); + }); +}); diff --git a/tests/integrations/pan123.malformed.test.js b/tests/integrations/pan123.malformed.test.js new file mode 100644 index 00000000..20cb9b5b --- /dev/null +++ b/tests/integrations/pan123.malformed.test.js @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createPan123Adapter } from "../../src/integrations/pan123/manifest.js"; + +const runtime = { now: () => new Date("2026-08-24T12:34:00.000Z"), nonce: () => 1 }; + +describe("pan123 malformed responses", () => { + it("rejects invalid JSON and incomplete resolved resources", async () => { + const invalidJson = createPan123Adapter({ request: async () => ({ status: 200, data: "not-json" }) }, runtime); + await expect(invalidJson.resolve("magnet:test", { token: "secret" })).rejects.toMatchObject({ code: "PARSE_ERROR" }); + const missingFiles = createPan123Adapter({ request: async () => ({ status: 200, data: { code: 0, data: { list: [{ id: 1 }] } } }) }, runtime); + await expect(missingFiles.resolve("magnet:test", { token: "secret" })).rejects.toMatchObject({ code: "INVALID_RESPONSE" }); + }); + + it("normalizes expired authorization and unavailable providers", async () => { + const expired = createPan123Adapter({ request: async () => ({ status: 200, data: { code: 401, message: "token is expired" } }) }, runtime); + await expect(expired.resolve("magnet:test", { token: "secret" })).rejects.toMatchObject({ code: "AUTH_REQUIRED" }); + const unavailable = createPan123Adapter({ request: async () => { throw new Error("provider unavailable"); } }, runtime); + await expect(unavailable.resolve("magnet:test", { token: "secret" })).rejects.toThrow("provider unavailable"); + }); +}); diff --git a/tests/ui-architecture.test.js b/tests/ui-architecture.test.js index e9fbc7a2..ed5f825b 100644 --- a/tests/ui-architecture.test.js +++ b/tests/ui-architecture.test.js @@ -48,6 +48,7 @@ describe("list toolbar and UI cleanup contracts", () => { const reviews = readTestFile(join(process.cwd(), "src/plugins/external-search/review.js"), "utf8"); const reviewUi = readTestFile(join(process.cwd(), "src/ui/detail/review-panel.js"), "utf8"); const oneTwoThreeOffline = readTestFile(join(process.cwd(), "src/plugins/one-two-three/offline.js"), "utf8"); + const pan123Integration = readTestFile(join(process.cwd(), "src/integrations/pan123/manifest.js"), "utf8"); const newVideo = readTestFile(join(process.cwd(), "src/plugins/new-video/new-video.js"), "utf8"); const related = readTestFile(join(process.cwd(), "src/plugins/external-search/related.js"), "utf8"); const relatedUi = readTestFile(join(process.cwd(), "src/ui/detail/related-panel.js"), "utf8"); @@ -191,12 +192,13 @@ describe("list toolbar and UI cleanup contracts", () => { expect(relatedUi).not.toMatch(/\.html\(/); }); - it("keeps 123 as an auth/API provider without legacy submission UI", () => { + it("keeps 123 auth sync separate from its Integration API boundary", () => { expect(reviewUi).toContain('jhs-review-offline-btn jhs-offline-btn'); expect(oneTwoThreeOffline).toContain("startTokenSync(scope)"); - expect(oneTwoThreeOffline).toContain("async resolveMagnet"); - expect(oneTwoThreeOffline).toContain("async submitTask"); - expect(oneTwoThreeOffline).not.toContain("async submitMagnet"); + expect(oneTwoThreeOffline).not.toContain("gmHttp"); + expect(oneTwoThreeOffline).not.toContain("offline_download/task"); + expect(pan123Integration).toContain('capabilities: ["offline.resolve", "offline.submit"]'); + expect(pan123Integration).toContain("async submit(resource, context = {})"); expect(oneTwoThreeOffline).not.toContain("markCurrentVideoAsHasDown"); }); From a32e13a9f267d0ef9fdc5648503b766f77fc2cc8 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:48:11 +0800 Subject: [PATCH 023/130] =?UTF-8?q?Refactor:=20=E6=89=98=E7=AE=A1=20DMM=20?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E6=8C=81=E4=B9=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 通过 StorageService 保留 jhs_dmm_video 与 jhs_other_site_dmm 兼容缓存键 - 将 JavDB 预览静音状态统一迁至 SettingsService 布尔 snapshot - 为 JavDB、JavBus 与封面入口声明 Storage/Settings 依赖 - 更新播放审计并补充缓存命名空间回归测试 修改原因: - 消除 DMM 预览链路对 localStorage 的直接访问 - 统一 JavDB/JavBus 播放器静音持久化语义 验证: - npm run check 通过(100 个测试文件,545 项测试) - npm run check:browser 通过(本机 Edge,13 passed,5 skipped) - 架构债务降至 114,ESM 166 模块零循环 - git diff --check、codegraph sync 与正式双产物 SHA256 一致性检查通过 --- JHS.user.js | 38 +++++++++---------- scripts/ui-consistency-audit.mjs | 3 +- src/plugins/image-viewer/bus-preview-video.js | 4 +- src/plugins/image-viewer/cover-button.js | 2 +- src/plugins/image-viewer/preview-video.js | 26 ++++++------- src/plugins/registry.js | 6 +-- tests/dmm-preview-storage.test.js | 19 ++++++++++ tests/media-playback.test.js | 3 +- tests/ui-architecture.test.js | 8 ++++ 9 files changed, 69 insertions(+), 40 deletions(-) create mode 100644 tests/dmm-preview-storage.test.js diff --git a/JHS.user.js b/JHS.user.js index c710348d..391c7c9a 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -10605,16 +10605,16 @@ ${error.stack}` : ""); }, "Z"); var ee = "jhs_dmm_video"; var _DmmPreviewParser = class _DmmPreviewParser { - constructor(e2) { - this.carNum = e2, this.lastError = null; + constructor(e2, storage) { + this.carNum = e2, this.storage = storage, this.lastError = null; } _checkCache() { - const e2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; + const cached = this.storage.getLocal(ee), e2 = cached ? JSON.parse(cached) : {}; return e2[this.carNum] ? (clog.debug("缓存中存在预览视频信息", e2[this.carNum]), e2[this.carNum]) : null; } _updateCache(e2) { - const t2 = localStorage.getItem(ee) ? JSON.parse(localStorage.getItem(ee)) : {}; - t2[this.carNum] = e2, clog.debug("成功解析出预览视频并已缓存:", e2), localStorage.setItem(ee, JSON.stringify(t2)); + const cached = this.storage.getLocal(ee), t2 = cached ? JSON.parse(cached) : {}; + t2[this.carNum] = e2, clog.debug("成功解析出预览视频并已缓存:", e2), this.storage.setLocal(ee, JSON.stringify(t2)); } async _searchContentIds() { const e2 = this.carNum, t2 = e2.replace(/-/g, ""), n2 = [{ @@ -10672,11 +10672,11 @@ ${error.stack}` : ""); const t3 = $("#fanzaBtn"); let a3 = `https://www.dmm.co.jp/search/=/searchstr=${e3}`, i4 = "single"; c2.length > 1 ? (t3.attr("href", a3), t3.append('多结果'), t3.css("backgroundColor", "var(--jhs-status-down)"), i4 = "multiple") : (a3 = c2[0].pageUrl, t3.attr("href", a3), t3.css("backgroundColor", "var(--jhs-status-down)")); - const s2 = "jhs_other_site_dmm", o3 = localStorage.getItem(s2) ? JSON.parse(localStorage.getItem(s2)) : {}; + const s2 = "jhs_other_site_dmm", cached = this.storage.getLocal(s2), o3 = cached ? JSON.parse(cached) : {}; return o3[this.carNum] = { type: i4, url: a3 - }, localStorage.setItem(s2, JSON.stringify(o3)), c2; + }, this.storage.setLocal(s2, JSON.stringify(o3)), c2; } clog.debug(`[${n3}] API 返回结果数 ${l2.result.result_count},但无精确匹配的 Content ID。`); } @@ -10761,8 +10761,8 @@ ${error.stack}` : ""); }; __name(_DmmPreviewParser, "DmmPreviewParser"); var DmmPreviewParser = _DmmPreviewParser; - async function fetchDmmPreview(carNum) { - const parser = new DmmPreviewParser(carNum), sources = await parser.fetchVideo(); + async function fetchDmmPreview(carNum, storage) { + const parser = new DmmPreviewParser(carNum, storage), sources = await parser.fetchVideo(); return { sources, error: parser.lastError @@ -10810,7 +10810,7 @@ ${error.stack}` : ""); } getDmmPreview() { if (this.dmmPreviewPromise) return this.dmmPreviewPromise; - this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum).then(((result) => { + this.dmmPreviewPromise = fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage")).then(((result) => { (result.error?.retryable || "HTTP_ERROR" === result.error?.code) && (this.dmmPreviewPromise = null); return result; }), ((error) => { @@ -10837,7 +10837,7 @@ ${error.stack}` : ""); async handleVideo() { const $nativeVideo = $("#preview-video"); if (!$nativeVideo.length) return; - const $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = localStorage.getItem("jhs_videoMuted"); + const settings = this.getRuntimeService("settings"), $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = settings.snapshot().videoMuted; void safePlay(nativeVideo, { context: "JavDB 原生预览", notify: false @@ -10854,8 +10854,8 @@ ${error.stack}` : ""); if (sources) { const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; const currentTime = nativeVideo.currentTime; - $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = !muted || "yes" === muted, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { - localStorage.setItem("jhs_videoMuted", dmmVideo.muted ? "yes" : "no"); + $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = muted == null || muted === true, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { + void settings.set("videoMuted", dmmVideo.muted).catch(((error2) => clog.error("保存视频静音设置失败", error2))); })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); dmmPlayed = await safePlay(dmmVideo, { context: "JavDB 高画质预览", @@ -10947,7 +10947,7 @@ ${error.stack}` : "");
    `); $("#sample-waterfall").prepend(t2); - "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum).catch(((e3) => clog.warn("预加载 DMM 失败", e3))); + "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage")).catch(((e3) => clog.warn("预加载 DMM 失败", e3))); let n2 = false, a2 = $(".preview-video-container"); a2.on("click", (async (e3) => { if (e3.preventDefault(), e3.stopPropagation(), n2) show.info("正在加载中, 勿重复点击"); @@ -10969,7 +10969,7 @@ ${error.stack}` : ""); notify: true }); let a2 = this.getPageInfo().carNum; - const { sources: i2, error: previewError } = await fetchDmmPreview(a2); + const { sources: i2, error: previewError } = await fetchDmmPreview(a2, this.getRuntimeService("storage")); i2 && 0 !== Object.keys(i2).length ? (await this.createVideoPlayerAndControls(i2, t2), n2 = $("#preview-video"), n2.length > 0 ? (e2.addClass("is-open"), await safePlay(n2[0], { context: "JavBus 预览视频", notify: true, @@ -11208,7 +11208,7 @@ ${error.stack}` : ""); notify: true }), void t2.hide(); t2.addClass("loading"), t2.after('
    '); - const s2 = t2.attr("src"), { sources: o2, error: previewError } = await fetchDmmPreview(n2); + const s2 = t2.attr("src"), { sources: o2, error: previewError } = await fetchDmmPreview(n2, this.getRuntimeService("storage")); if (!o2) return show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未解析到视频"), void this.showImg(e2, t2, n2); let r2 = await storageManager.getSetting("videoQuality"); r2 = Z(Object.keys(o2), r2); @@ -15473,7 +15473,7 @@ ${error.stack}` : ""); manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [SERVICE.dialog]), manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog, SERVICE.storage]), - manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }), + manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }, [SERVICE.storage]), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [SERVICE.movie, SERVICE.translation, SERVICE.settings]), manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }), @@ -15481,7 +15481,7 @@ ${error.stack}` : ""); manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [SERVICE.settings]), - manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }), + manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings]), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie]), @@ -15501,7 +15501,7 @@ ${error.stack}` : ""); manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), manifest("detail.native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), - manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings]), + manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage]), manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }, [SERVICE.storage]), manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), manifest("detail.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }) diff --git a/scripts/ui-consistency-audit.mjs b/scripts/ui-consistency-audit.mjs index dd88b207..904bdfeb 100644 --- a/scripts/ui-consistency-audit.mjs +++ b/scripts/ui-consistency-audit.mjs @@ -218,7 +218,8 @@ requireMatch(previewVideo, /async fetchVideo\(\)\s*\{\s*const carNum = normalize requireMatch(previewVideo, /跳过 DMM 解析:番号不可用/, "DMM invalid-number warning is missing"); requireMatch(previewVideo, /
    `; } + /** @param {JQueryHandle | Element | null} [items] */ async addSvgBtn(items = null) { - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach((element => { + (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((/** @type {Element} */ element) => { const item = $(element); if (item.find(".tool-box").length || l && item.find(".avatar-box").length) return; const host = r ? item.find(".tags").first() : item.find(".photo-info").first(); host.length && host.append(this.buildToolBox()); })), this.enableSvgBtn(items); } + /** @param {JQueryHandle | Element | null} [items] */ async enableSvgBtn(items = null) { const e = await storageManager.getSetting(), {enableScreenSvg: t = _, enableVideoSvg: n = _, enableHandleSvg: a = _, enableSiteSvg: i = _, enableCopySvg: s = _} = e; const scope = items ? $(items) : $(document); @@ -90,21 +97,21 @@ export class CoverButtonPlugin extends BasePlugin { async bindClick() { this.getSelector(); const e = this.getDependency("ListPagePlugin"); - $(document).on("click", ".jhs-card-menu-trigger", (event => { + $(document).on("click", ".jhs-card-menu-trigger", ((/** @type {CardActionEvent} */ event) => { event.preventDefault(), event.stopPropagation(); const trigger = $(event.currentTarget), menu = trigger.siblings(".jhs-card-menu"), open = !menu.hasClass("is-open"); this.closeCardMenus(), menu.toggleClass("is-open", open), trigger.attr("aria-expanded", String(open)), open && menu.children().first().trigger("focus"); - })).on("keydown", ".jhs-card-menu [role='menuitem']", (event => { + })).on("keydown", ".jhs-card-menu [role='menuitem']", ((/** @type {KeyboardEvent} */ event) => { const menu = $(event.currentTarget).closest(".jhs-card-menu"), items = menu.find("[role='menuitem']"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), this.closeCardMenus(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", (event => { + })).on("click", ((/** @type {CardActionEvent} */ event) => { $(event.target).closest(".more-tools-container").length || this.closeCardMenus(); - })), $(document).on("click", ".videoSvg", (t => { - t.preventDefault(), $('.videoSvg[title!="播放视频"]').each(((t, n) => { + })), $(document).on("click", ".videoSvg", ((/** @type {CardActionEvent} */ t) => { + t.preventDefault(), $('.videoSvg[title!="播放视频"]').each(((/** @type {number} */ t, /** @type {HTMLElement} */ n) => { const a = $(n); let i = a.closest(".item"), s = i.find("img"), {carNum: o} = e.findCarNumAndHref(i); this.showImg(a, s, o), a.html(this.videoSvg).attr({ title: "播放视频", "aria-label": "播放视频" }); @@ -117,7 +124,7 @@ export class CoverButtonPlugin extends BasePlugin { if (!i.length) return void show.error("没有找到图片"); void this.showVideo(a, i, t).catch((error => clog.error("卡片预览视频打开失败", error))); } - })), $(document).on("click", ".screenSvg", (async t => { + })), $(document).on("click", ".screenSvg", (async (/** @type {CardActionEvent} */ t) => { t.preventDefault(); let n = loading(); try { @@ -125,14 +132,14 @@ export class CoverButtonPlugin extends BasePlugin { let {carNum: i} = e.findCarNumAndHref(a); i = i.replace("FC2-", ""); const s = await this.getDependency("ScreenShotPlugin").getScreenshot(i); - n.close(), showImageViewer(s); + n.close(), (/** @type {any} */ (globalThis)).showImageViewer(s); } catch (a) { clog.error("图片预览出错:", a), show.error("图片预览出错:" + a); } finally { n.close(); } - })), $(document).on("click", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", (t => { + })), $(document).on("click", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", ((/** @type {CardActionEvent} */ t) => { t.preventDefault(), t.stopPropagation(); try { - const n = $(t.currentTarget), a = n.closest(".item"), {carNum: i, url: s, publishTime: o, fc2Source} = e.findCarNumAndHref(a), r = async t => { + const n = $(t.currentTarget), a = n.closest(".item"), {carNum: i, url: s, publishTime: o, fc2Source} = e.findCarNumAndHref(a), r = async (/** @type {string} */ t) => { try { let n = await e.parseActressName(s); const flag = legacyActionToFlag(t); @@ -144,32 +151,35 @@ export class CoverButtonPlugin extends BasePlugin { } catch (t) { clog.error("按钮点击处理失败:", t); } })); const t = this.getDependency("OtherSitePlugin"), n = await t.getMissAvUrl(), a = await t.getjableUrl(), i = await t.getAvgleUrl(), s = await t.getAv123Url(); - $(this.getSelector().itemSelector).each(((t, o) => { + $(this.getSelector().itemSelector).each(((/** @type {number} */ t, /** @type {HTMLElement} */ o) => { const r = $(o), {carNum: l} = e.findCarNumAndHref(r); r.find(".site-jable").attr({ href: `${a}/search/${l}/`, target: "_blank", rel: "noopener noreferrer" }), r.find(".site-avgle").attr({ href: `${i}/vod/search.html?wd=${l}`, target: "_blank", rel: "noopener noreferrer" }), r.find(".site-miss-av").attr({ href: `${n}/search/${l}`, target: "_blank", rel: "noopener noreferrer" }), r.find(".site-123-av").attr({ href: `${s}/cn/search?keyword=${encodeURIComponent(l)}`, target: "_blank", rel: "noopener noreferrer" }); })); - $(document).on("click", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", (t => { + $(document).on("click", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", ((/** @type {CardActionEvent} */ t) => { try { t.preventDefault(), t.stopPropagation(); const o = $(t.currentTarget), r = o.closest(".item"), {carNum: l} = e.findCarNumAndHref(r); let c = null; - o.hasClass("site-jable") ? c = `${a}/search/${l}/` : o.hasClass("site-avgle") ? c = `${i}/vod/search.html?wd=${l}` : o.hasClass("site-miss-av") ? c = `${n}/search/${l}` : o.hasClass("site-123-av") && (c = `${s}/cn/search?keyword=${encodeURIComponent(l)}`), - t && (t.ctrlKey || t.metaKey) ? GM_openInTab(c, { insert: 0 }) : window.open(c), this.closeCardMenus(); + o.hasClass("site-jable") ? c = `${a}/search/${l}/` : o.hasClass("site-avgle") ? c = `${i}/vod/search.html?wd=${l}` : o.hasClass("site-miss-av") ? c = `${n}/search/${l}` : o.hasClass("site-123-av") && (c = `${s}/cn/search?keyword=${encodeURIComponent(l)}`); + if (!c) return; + t.ctrlKey || t.metaKey ? GM_openInTab(c, { insert: 0 }) : window.open(c), this.closeCardMenus(); } catch (t) { clog.error("站点按钮处理失败:", t); } - })), $(document).on("click", ".titleSvg, .carNumSvg, .downSvg", (t => { + })), $(document).on("click", ".titleSvg, .carNumSvg, .downSvg", ((/** @type {CardActionEvent} */ t) => { t.preventDefault(), t.stopPropagation(); const n = $(t.currentTarget).closest(".item"), {carNum: a, title: i} = e.findCarNumAndHref(n), s = n.find(l ? ".photo-frame img" : ".cover img"); $(t.currentTarget).hasClass("titleSvg") ? utils.copyToClipboard("标题", i) : $(t.currentTarget).hasClass("carNumSvg") ? utils.copyToClipboard("番号", a) : $(t.currentTarget).hasClass("downSvg") && fetch(s.attr("src")).then((e => e.blob())).then((e => utils.download(e, a + " " + i + ".jpg"))), this.closeCardMenus(); })); } + /** @param {JQueryHandle} e @param {JQueryHandle} t @param {string} n */ showImg(e, t, n) { e.html(this.videoSvg).attr({ title: "播放视频", "aria-label": "播放视频" }); let a = $(`#${`${n}_preview_video`}`); a.length > 0 && (a[0].pause(), a.parent().hide()), t.show(), t.removeClass("loading"), t.next(".loading-spinner").remove(); } + /** @param {JQueryHandle} e @param {JQueryHandle} t @param {string} n */ async showVideo(e, t, n) { const a = `${n}_preview_video`; let i = $(`#${a}`); diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index e1832388..00c26f04 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -54,6 +54,7 @@ "src/plugins/external-search/fc2-by-123av.js", "src/plugins/external-search/fc2.js", "src/plugins/external-search/magnet-source-registry.js", + "src/plugins/external-search/magnet-hub.js", "src/plugins/external-search/other-site.js", "src/plugins/external-search/related.js", "src/plugins/external-search/review.js", @@ -61,6 +62,7 @@ "src/plugins/favorite/favorite-actresses.js", "src/plugins/image-viewer/bus-img.js", "src/plugins/image-viewer/bus-preview-video.js", + "src/plugins/image-viewer/cover-button.js", "src/plugins/image-viewer/preview-video.d.ts", "src/plugins/image-viewer/screenshot-provider-registry.js", "src/plugins/image-viewer/screenshot.js", From ff7fe38fbef5acc47fa2d2120cc4c7ededcfd559 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:00:21 +0800 Subject: [PATCH 049/130] =?UTF-8?q?Chore:=20=E7=B1=BB=E5=9E=8B=E5=8C=96?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E4=B8=8E=E5=A4=87=E4=BB=BD=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将 DMM 预览、备份文件操作和设置诊断面板纳入真实 strict checkJs - 删除遮蔽 preview-video.js 的同名声明文件 - 为 JavBus 和列表封面 DMM 请求注入 MovieService 与 LifecycleScope - 将最低类型覆盖门禁从 87% 上调至 89% - 重建并同步正式用户脚本产物 修改原因: - 消除声明文件造成的虚假覆盖并继续推进 6.5 RC 的 95% 目标 - 修复无缓存 DMM 预览无法访问远端 Provider 的依赖缺失 验证: - npm run check 通过,116 个测试文件共 611 项测试通过 - strict checkJs 覆盖率 157/176(89.2%,最低 89%) - Edge Browser Smoke 15 项通过、15 项按配置跳过 - git diff --check 与 codegraph sync 通过 - 根目录与 dist 用户脚本 SHA-256 一致 --- CHANGELOG.md | 2 + JHS.user.js | 71 ++++++++++--------- scripts/checkjs-coverage.mjs | 2 +- src/plugins/backup/setting-backup.js | 68 +++++++++++------- src/plugins/backup/setting-panels.js | 51 +++++++------ src/plugins/image-viewer/bus-preview-video.js | 7 +- src/plugins/image-viewer/cover-button.js | 2 +- src/plugins/image-viewer/preview-video.d.ts | 5 -- src/plugins/image-viewer/preview-video.js | 46 ++++++++---- tests/media-playback.test.js | 10 ++- tsconfig.architecture.json | 4 +- 11 files changed, 165 insertions(+), 103 deletions(-) delete mode 100644 src/plugins/image-viewer/preview-video.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad2c954..bc65bbf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - 截图、宿主详情工作区、兼容增强与统一离线模块进入 strict checkJs;Provider availability、宿主资源边界、Observer 和 jQuery 兼容句柄均补充显式类型,覆盖率门禁同步上调至 84%。 - FC2 详情、外部站点入口和详情操作栏进入 strict checkJs,明确宿主工作区、影片/磁力数据与遗留 Vendor 句柄边界,生产源码类型覆盖率提升至 86.4%。 - 磁力聚合中心与列表封面操作进入 strict checkJs,补齐来源、磁力结果、卡片事件和媒体句柄契约,生产源码类型覆盖率提升至 87.5%,最低覆盖门禁同步上调至 87%。 +- DMM 预览、备份文件操作和设置诊断面板进入真实 strict checkJs,移除遮蔽生产实现的同名声明文件;生产源码类型覆盖率提升至 89.2%,最低覆盖门禁同步上调至 89%。 ### Fixed @@ -24,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - 修复鉴定记录表头全选只处理当前分页的问题;现在会选择当前搜索和筛选条件下的全部记录,支持跨页保留、逐项排除,并让批量提示与实际处理数量一致。 - 修复列表页“全部”仍被旧状态显示设置二次过滤的问题;收藏、已下载和已看作品会正常出现在“全部”中,硬屏蔽内容仍统一收在“屏蔽项”。 - 修复 DMM/FANZA 外部链接检测分支因缓存键变量遮蔽站点配置而触发 TDZ 异常的问题,并增加构建产物回归测试。 +- 修复 JavBus 与列表封面请求 DMM 预览时未注入 MovieService 和 LifecycleScope、导致无缓存请求无法访问远端 Provider 的问题。 ## [6.4.1](../../compare/v6.4.0...v6.4.1) - 2026-08-23 diff --git a/JHS.user.js b/JHS.user.js index e3d95d25..e3ba918c 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -6345,29 +6345,30 @@ __name(createJhsTable, "createJhsTable"); // src/plugins/backup/setting-backup.js + var errorMessage = /* @__PURE__ */ __name((error) => error instanceof Error ? error.message : String(error), "errorMessage"); async function importSettingData(showDiffPreviewFn) { try { const input = document.createElement("input"); input.type = "file", input.accept = ".json"; const cleanup = /* @__PURE__ */ __name(() => input.remove(), "cleanup"); input.onchange = async (e2) => { - const t2 = e2.target.files[0]; + const t2 = e2.currentTarget.files?.[0]; if (!t2) return void cleanup(); const n2 = new FileReader(); n2.onload = async (e3) => { cleanup(); try { - const t3 = e3.target.result.toString(), n3 = JSON.parse(t3); + const t3 = String(e3.currentTarget.result || ""), n3 = JSON.parse(t3); if (!n3 || "object" != typeof n3 || Array.isArray(n3)) throw new Error("文件内容不是有效的数据对象"); const a2 = loading(); try { const e4 = await storageManager.exportData(), t4 = await storageManager.diffData(e4, n3); a2.close(), showDiffPreviewFn(t4, n3, null); } catch (i2) { - a2.close(), clog.error(i2), show.error("差异分析失败: " + i2.message); + a2.close(), clog.error(i2), show.error("差异分析失败: " + errorMessage(i2)); } } catch (t3) { - clog.error(t3), show.error("导入失败:文件内容不是有效的JSON格式 " + t3.message); + clog.error(t3), show.error("导入失败:文件内容不是有效的JSON格式 " + errorMessage(t3)); } }, n2.onerror = () => { cleanup(), show.error("读取文件时出错"); @@ -6375,7 +6376,7 @@ }, document.body.appendChild(input), input.click(); setTimeout(cleanup, 3e5); } catch (e2) { - clog.error(e2), show.error("导入数据时出错: " + e2.message); + clog.error(e2), show.error("导入数据时出错: " + errorMessage(e2)); } } __name(importSettingData, "importSettingData"); @@ -6393,7 +6394,7 @@ const e2 = webdavService.createClient({ url: n2, username: a2, password: i2 }); await e2.backup(folderName, s2, o2), show.ok("备份完成"); } catch (l2) { - clog.error(l2), show.error(l2.toString()); + clog.error(l2), show.error(errorMessage(l2)); } finally { r2.close(); } @@ -6411,7 +6412,7 @@ const e2 = webdavService.createClient({ url: n2, username: a2, password: i2 }), t3 = await e2.getBackupList(folderName); openFileListDialogFn(t3, e2, "WebDav"); } catch (o2) { - clog.error(o2), show.error(`发生错误: ${o2 ? o2.message : o2}`); + clog.error(o2), show.error(`发生错误: ${errorMessage(o2)}`); } finally { s2.close(); } @@ -6469,7 +6470,7 @@ container.html(renderCards(e2)); dialog.alert("删除成功"); } catch (err) { - clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); + clog.error(err), show.error(`发生错误: ${errorMessage(err)}`); } finally { load.close(); } @@ -6495,7 +6496,7 @@ load.close(); showDiffPreviewFn(diff, null, parsed); } catch (err) { - load.close(), clog.error(err), show.error("预览失败: " + (err ? err.message : err)); + load.close(), clog.error(err), show.error("预览失败: " + errorMessage(err)); } } }); @@ -6515,7 +6516,7 @@ area: utils.getResponsiveArea(["800px", "70%"]), anim: -1, success: /* @__PURE__ */ __name((a2) => { - const i2 = createJhsTable(Tabulator, "#table-container", { + const i2 = createJhsTable(globalThis.Tabulator, "#table-container", { pagination: false, layout: "fitColumns", placeholder: "暂无数据", @@ -6575,7 +6576,7 @@ let e6 = await t2.getBackupList(folderName); i2.replaceData(e6), dialog.alert("删除成功"); } catch (s4) { - clog.error(s4), show.error(`发生错误: ${s4 ? s4.message : s4}`); + clog.error(s4), show.error(`发生错误: ${errorMessage(s4)}`); } finally { a5.close(); } @@ -6598,7 +6599,7 @@ const n3 = JSON.parse(e5), i3 = await storageManager.exportData(), s4 = await storageManager.diffData(i3, n3); a5.close(), showDiffPreviewFn(s4, null, n3); } catch (i3) { - a5.close(), clog.error(i3), show.error("预览失败: " + (i3 ? i3.message : i3)); + a5.close(), clog.error(i3), show.error("预览失败: " + errorMessage(i3)); } })); })), '\n \n \n \n '; @@ -6631,7 +6632,7 @@ const e2 = JSON.stringify(await storageManager.exportData()), t2 = `${utils.getNowStr("_", "_")}.json`; utils.download(e2, t2), show.ok("数据导出成功"); } catch (t2) { - clog.error(t2), show.error("导出数据时出错: " + t2.message); + clog.error(t2), show.error("导出数据时出错: " + errorMessage(t2)); } } __name(exportSettingData, "exportSettingData"); @@ -8593,7 +8594,7 @@ if (i2.length) { let t3 = ''; for (const [n3, a3] of i2) { - const i3 = "open" === a3.state ? "熔断" : "half-open" === a3.state ? "半开" : "正常", s3 = "open" === a3.state ? `剩余${Math.ceil((a3.cooldownMs - (Date.now() - a3.openTime)) / 1e3)}秒` : ""; + const i3 = "open" === a3.state ? "熔断" : "half-open" === a3.state ? "半开" : "正常", s3 = "open" === a3.state ? `剩余${Math.ceil((Number(a3.cooldownMs) - (Date.now() - Number(a3.openTime))) / 1e3)}秒` : ""; t3 += ``; } t3 += "
    域名状态失败次数操作
    ${escapeHtml(n3)}${i3} ${s3}${Number(a3.failCount) || 0}
    ", $("#site-health-table").html(t3); @@ -8626,7 +8627,7 @@ }; if (0 === e2.length) return void $("#snapshot-list").html('
    暂无快照,点击上方按钮创建
    '); $("#snapshot-list").find(".tabulator").length && $("#snapshot-list").empty(); - const n2 = createJhsTable(Tabulator, "#snapshot-list", { + const n2 = createJhsTable(globalThis.Tabulator, "#snapshot-list", { pagination: false, layout: "fitColumns", placeholder: "暂无数据", @@ -8651,7 +8652,7 @@ try { await storageManager.restoreSnapshot(i2.id), show.ok("恢复成功, 页面将刷新"), setTimeout(() => location.reload(), 1e3); } catch (t5) { - clog.error(t5), show.error("恢复失败: " + t5.message); + clog.error(t5), show.error("恢复失败: " + (t5 instanceof Error ? t5.message : String(t5))); } finally { e5.close(); } @@ -8663,7 +8664,7 @@ if (!e5) throw new Error("快照不存在"); utils.download(JSON.stringify(e5.data), `snapshot_${escapeHtml(i2.name)}.json`), show.ok("下载成功"); } catch (n3) { - show.error("下载失败: " + n3.message); + show.error("下载失败: " + (n3 instanceof Error ? n3.message : String(n3))); } finally { t5.close(); } @@ -8672,7 +8673,7 @@ try { await storageManager.deleteSnapshot(i2.id), show.ok("已删除"), renderSnapshotPanel(); } catch (t5) { - clog.error(t5), show.error("删除失败: " + t5.message); + clog.error(t5), show.error("删除失败: " + (t5 instanceof Error ? t5.message : String(t5))); } })); })); @@ -8721,7 +8722,7 @@ try { await storageManager.createSnapshot("导入前自动备份", "auto-import"), n2 ? (await storageManager.importData(n2), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)) : t2 && (await storageManager.importData(t2), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)); } catch (r3) { - clog.error(r3), show.error("导入失败: " + r3.message); + clog.error(r3), show.error("导入失败: " + (r3 instanceof Error ? r3.message : String(r3))); } finally { o2.close(); } @@ -8781,7 +8782,7 @@ const formatMs = /* @__PURE__ */ __name((value) => Number.isFinite(value) ? value.toFixed(1) : "0.0", "formatMs"); let startupHtml = startup ? `
    就绪: ${formatMs(startup.readyMs)} ms注册: ${formatMs(startup.registrationMs)} ms样式: ${formatMs(startup.cssMs)} ms即时插件: ${formatMs(startup.immediateMs)} ms空闲任务: ${startup.idleCompleted}/${startup.idleCompleted + startup.idlePending}

    就绪耗时不包含 @require 下载及浏览器脚本解析时间。

    ` : ""; if (timings.length) { - const sorted = [...timings].sort((a2, b2) => b2.elapsed - a2.elapsed); + const sorted = [...timings].sort(((a2, b2) => b2.elapsed - a2.elapsed)); let tHtml = ''; for (const t2 of sorted) { const stateClass = t2.status === "disabled" ? "is-muted" : t2.elapsed > 500 ? "is-slow" : t2.elapsed > 200 ? "is-warning" : ""; @@ -12067,15 +12068,15 @@ ${failure.stack}` : ""); var Z = /* @__PURE__ */ __name((e2, t2) => { if (!e2 || 0 === e2.length) return null; const n2 = new Set(e2); - if (n2.has(t2)) return t2; + if (t2 && n2.has(t2)) return t2; const a2 = L.map(((e3) => e3.quality)).reverse(); for (const i2 of a2) if (n2.has(i2)) return i2; - return e2[0]; + return e2[0] || null; }, "Z"); var ee = "jhs_dmm_video"; var _DmmPreviewParser = class _DmmPreviewParser { constructor(e2, storage, movie, scope) { - this.carNum = e2, this.storage = storage, this.movie = movie, this.scope = scope, this.lastError = null; + this.carNum = e2 || "", this.storage = storage, this.movie = movie, this.scope = scope, this.lastError = null; } _checkCache() { const cached = this.storage.getLocal(ee), e2 = cached ? JSON.parse(cached) : {}; @@ -12114,9 +12115,10 @@ ${failure.stack}` : ""); if (!sources) return null; return this._updateCache(sources), sources; } catch (n2) { - this.lastError = n2 instanceof ProviderError ? n2 : new ProviderError("dmm", n2?.code || "PARSE_ERROR", n2.message || String(n2), { + const error = n2; + this.lastError = n2 instanceof ProviderError ? n2 : new ProviderError("dmm", error?.code || "PARSE_ERROR", error?.message || String(n2), { cause: n2, - retryable: n2?.retryable === true + retryable: error?.retryable === true }), clog.error("DMM API 搜索失败:", this.lastError); const e3 = $("#fanzaBtn"); return e3.attr("href", this.movie.searchUrl("dmm", { carNum: this.carNum })), e3.attr("title", "未查询到, 点击前往搜索页"), e3.css("backgroundColor", "var(--jhs-status-filter)"), null; @@ -12216,12 +12218,14 @@ ${failure.stack}` : ""); "aria-label": "视频画质" }); $host.find("#video-bottom-toolbar").remove(); - let dmmPlayed = false, $dmmVideo = null, dmmVideo = null; + let dmmPlayed = false; + let $dmmVideo = null; + let dmmVideo = null; if (sources) { const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; const currentTime = nativeVideo.currentTime; $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = muted == null || muted === true, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { - void settings.set("videoMuted", dmmVideo.muted).catch(((error2) => clog.error("保存视频静音设置失败", error2))); + void settings.set("videoMuted", dmmVideo.muted).catch((error2) => clog.error("保存视频静音设置失败", error2)); })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); dmmPlayed = await safePlay(dmmVideo, { context: "JavDB 高画质预览", @@ -12313,7 +12317,10 @@ ${failure.stack}` : ""); `); $("#sample-waterfall").prepend(t2); - "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage")).catch(((error) => clog.warn("预加载 DMM 失败", error))); + if ("yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes")) { + const scope = await this.getRuntimeService("scope")(); + void fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope).catch(((error) => clog.warn("预加载 DMM 失败", error))); + } let n2 = false, a2 = $(".preview-video-container"); a2.on("click", (async (e3) => { if (e3.preventDefault(), e3.stopPropagation(), n2) show.info("正在加载中, 勿重复点击"); @@ -12335,7 +12342,7 @@ ${failure.stack}` : ""); notify: true }); let a2 = this.getPageInfo().carNum; - const { sources: i2, error: previewError } = await fetchDmmPreview(a2, this.getRuntimeService("storage")); + const scope = await this.getRuntimeService("scope")(), { sources: i2, error: previewError } = await fetchDmmPreview(a2, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope); i2 && 0 !== Object.keys(i2).length ? (await this.createVideoPlayerAndControls(i2, t2), n2 = $("#preview-video"), n2.length > 0 ? (e2.addClass("is-open"), await safePlay(n2[0], { context: "JavBus 预览视频", notify: true, @@ -12576,7 +12583,7 @@ ${failure.stack}` : ""); notify: true }), void t2.hide(); t2.addClass("loading"), t2.after('
    '); - const s2 = t2.attr("src"), { sources: o2, error: previewError } = await fetchDmmPreview(n2, this.getRuntimeService("storage")); + const s2 = t2.attr("src"), scope = await this.getRuntimeService("scope")(), { sources: o2, error: previewError } = await fetchDmmPreview(n2, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope); if (!o2) return show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未解析到视频"), void this.showImg(e2, t2, n2); let r2 = await storageManager.getSetting("videoQuality"); r2 = Z(Object.keys(o2), r2); @@ -12763,8 +12770,8 @@ ${failure.stack}` : ""); })); } showErrorFallback(e2, t2) { - const errorMessage = t2 instanceof Error ? t2.message : ""; - clog.error("获取缩略图失败:", errorMessage.substring(0, 100)); + const errorMessage2 = t2 instanceof Error ? t2.message : ""; + clog.error("获取缩略图失败:", errorMessage2.substring(0, 100)); const a2 = `jhs-screenshot-message${l ? " jhs-screenshot-message--bus" : ""}`; const carNum = normalizeCarNum(e2); if (!carNum) return void $(".screen-container").empty().append($("
    ").addClass(a2).text("无法获取番号,缩略图未加载")); diff --git a/scripts/checkjs-coverage.mjs b/scripts/checkjs-coverage.mjs index dd1b4cb3..fc695b79 100644 --- a/scripts/checkjs-coverage.mjs +++ b/scripts/checkjs-coverage.mjs @@ -27,7 +27,7 @@ for (const file of files) { if (compiledFiles.has(path.resolve(file)) && /^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); } const coverage = files.length === 0 ? 100 : (checked.length / files.length) * 100; -const required = Number(process.env.JHS_CHECKJS_MIN ?? 87); +const required = Number(process.env.JHS_CHECKJS_MIN ?? 89); if (coverage + Number.EPSILON < required) { throw new Error(`checkJs coverage ${coverage.toFixed(1)}% is below ${required}%`); } diff --git a/src/plugins/backup/setting-backup.js b/src/plugins/backup/setting-backup.js index ee992a8f..265440b9 100644 --- a/src/plugins/backup/setting-backup.js +++ b/src/plugins/backup/setting-backup.js @@ -1,31 +1,41 @@ +// @ts-check + import { escapeHtml } from "../../core/constants.js"; import { decryptCredential, decryptData, encryptData } from "../../core/credential-crypto.js"; import { createJhsTable } from "../../ui/table/create-jhs-table.js"; +/** @typedef {{ name: string, size: number, createTime: string | number, fileId: string }} BackupFile */ +/** @typedef {(diff: any, current: any, imported: any) => void} ShowDiffPreview */ +/** @typedef {any} WebDavHandle */ +/** @typedef {any} DialogHandle */ +/** @param {unknown} error */ +const errorMessage = error => error instanceof Error ? error.message : String(error); + /** Handle JSON file import via file input, run diff analysis, show preview. */ +/** @param {ShowDiffPreview} showDiffPreviewFn */ export async function importSettingData(showDiffPreviewFn) { try { const input = document.createElement("input"); input.type = "file", input.accept = ".json"; const cleanup = () => input.remove(); input.onchange = async e => { - const t = e.target.files[0]; + const t = /** @type {HTMLInputElement} */ (e.currentTarget).files?.[0]; if (!t) return void cleanup(); const n = new FileReader; n.onload = async e => { cleanup(); try { - const t = e.target.result.toString(), n = JSON.parse(t); + const t = String((/** @type {FileReader} */ (e.currentTarget)).result || ""), n = JSON.parse(t); if (!n || "object" != typeof n || Array.isArray(n)) throw new Error("文件内容不是有效的数据对象"); const a = loading(); try { const e = await storageManager.exportData(), t = await storageManager.diffData(e, n); a.close(), showDiffPreviewFn(t, n, null); } catch (i) { - a.close(), clog.error(i), show.error("差异分析失败: " + i.message); + a.close(), clog.error(i), show.error("差异分析失败: " + errorMessage(i)); } } catch (t) { - clog.error(t), show.error("导入失败:文件内容不是有效的JSON格式 " + t.message); + clog.error(t), show.error("导入失败:文件内容不是有效的JSON格式 " + errorMessage(t)); } }, n.onerror = () => { cleanup(), show.error("读取文件时出错"); @@ -33,11 +43,12 @@ export async function importSettingData(showDiffPreviewFn) { }, document.body.appendChild(input), input.click(); setTimeout(cleanup, 3e5); } catch (e) { - clog.error(e), show.error("导入数据时出错: " + e.message); + clog.error(e), show.error("导入数据时出错: " + errorMessage(e)); } } /** Create encrypted backup and upload via WebDAV. */ +/** @param {string} folderName @param {WebDavHandle} webdavService */ export async function backupDataByWebDav(folderName, webdavService) { const t = await storageManager.getSetting(), n = t.webDavUrl; if (!n) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); @@ -52,13 +63,14 @@ export async function backupDataByWebDav(folderName, webdavService) { const e = webdavService.createClient({ url: n, username: a, password: i }); await e.backup(folderName, s, o), show.ok("备份完成"); } catch (l) { - clog.error(l), show.error(l.toString()); + clog.error(l), show.error(errorMessage(l)); } finally { r.close(); } } /** List WebDAV backups and open the file list dialog. */ +/** @param {string} folderName @param {Function} openFileListDialogFn @param {WebDavHandle} webdavService */ export async function backupListBtnByWebDav(folderName, openFileListDialogFn, webdavService) { const t = await storageManager.getSetting(), n = t.webDavUrl; if (!n) return void show.error("请填写webDav服务地址并保存后, 再试此功能"); @@ -71,25 +83,26 @@ export async function backupListBtnByWebDav(folderName, openFileListDialogFn, we const e = webdavService.createClient({ url: n, username: a, password: i }), t = await e.getBackupList(folderName); openFileListDialogFn(t, e, "WebDav"); } catch (o) { - clog.error(o), show.error(`发生错误: ${o ? o.message : o}`); + clog.error(o), show.error(`发生错误: ${errorMessage(o)}`); } finally { s.close(); } } /** Mobile-specific backup file list dialog using card-based UI. */ +/** @param {BackupFile[]} e @param {WebDavHandle} t @param {string} n @param {string} folderName @param {ShowDiffPreview} showDiffPreviewFn @param {DialogHandle} dialog */ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog) { - const formatSize = (size) => { + const formatSize = (/** @type {number} */ size) => { const units = ["B", "KB", "MB", "GB", "TB", "PB"]; let i = 0, s = size; for (; s >= 1024 && i < units.length - 1;) s /= 1024, i++; return `${s % 1 == 0 ? s.toFixed(0) : s.toFixed(2)} ${units[i]}`; }; - const renderCards = (files) => { + const renderCards = (/** @type {BackupFile[]} */ files) => { if (!files || files.length === 0) { return '
    暂无数据
    '; } - return files.map((file, idx) => ` + return files.map((file, /** @type {number} */ idx) => `
    ${escapeHtml(file.name)}
    ${formatSize(file.size)} · ${utils.getNowStr("-", ":", file.createTime)}
    @@ -108,9 +121,9 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog content: `
    ${renderCards(e)}
    `, area: utils.getResponsiveArea(["800px", "70%"]), anim: -1, - success: (layerEl) => { + success: (/** @type {HTMLElement} */ layerEl) => { const container = $(layerEl).find(`#${containerId}`); - container.on("click", ".jhs-backup-btn", async (ev) => { + container.on("click", ".jhs-backup-btn", async (/** @type {MouseEvent} */ ev) => { const btn = $(ev.currentTarget); const action = btn.data("action"); const idx = btn.data("idx"); @@ -119,7 +132,7 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog if (action === "delete") { dialog.confirm(`是否删除 ${file.name} ?`, { icon: 3, title: "提示", btn: ["确定", "取消"] - }, async (confirmIdx) => { + }, async (/** @type {number} */ confirmIdx) => { dialog.close(confirmIdx); let load = loading(); try { @@ -128,7 +141,7 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog container.html(renderCards(e)); dialog.alert("删除成功"); } catch (err) { - clog.error(err), show.error(`发生错误: ${err ? err.message : err}`); + clog.error(err), show.error(`发生错误: ${errorMessage(err)}`); } finally { load.close(); } }); } else if (action === "download") { @@ -150,7 +163,7 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog load.close(); showDiffPreviewFn(diff, null, parsed); } catch (err) { - load.close(), clog.error(err), show.error("预览失败: " + (err ? err.message : err)); + load.close(), clog.error(err), show.error("预览失败: " + errorMessage(err)); } } }); @@ -159,6 +172,7 @@ function openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog } /** Desktop backup file list dialog using Tabulator table. */ +/** @param {BackupFile[]} e @param {WebDavHandle} t @param {string} n @param {string} folderName @param {ShowDiffPreview} showDiffPreviewFn @param {DialogHandle} dialog */ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialog) { if (utils.isMobileMode()) { openFileListDialogMobile(e, t, n, folderName, showDiffPreviewFn, dialog); @@ -170,8 +184,8 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialo content: '\n
    \n
    \n
    \n ', area: utils.getResponsiveArea([ "800px", "70%" ]), anim: -1, - success: a => { - const i = createJhsTable(Tabulator, "#table-container", { + success: (/** @type {HTMLElement} */ a) => { + const i = createJhsTable((/** @type {any} */ (globalThis)).Tabulator, "#table-container", { pagination: !1, layout: "fitColumns", placeholder: "暂无数据", @@ -194,7 +208,7 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialo field: "size", responsive: 1, headerSort: !1, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n) => { const a = [ "B", "KB", "MB", "GB", "TB", "PB" ]; let i = 0, s = e.getData().size; for (;s >= 1024 && i < a.length - 1; ) s /= 1024, i++; @@ -205,7 +219,7 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialo field: "createTime", responsive: 2, headerSort: !1, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n) => { const a = e.getData(); return `${utils.getNowStr("-", ":", a.createTime)}`; } @@ -214,16 +228,16 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialo minWidth: 250, responsive: 0, headerSort: !1, - formatter: (e, a, s) => { + formatter: (/** @type {any} */ e, /** @type {any} */ a, /** @type {(callback: () => void) => void} */ s) => { const o = e.getData(); return s((() => { const a = e.getElement().querySelector(".backup-delete"), s = e.getElement().querySelector(".backup-download"), r = e.getElement().querySelector(".backup-import"); - a && a.addEventListener("click", (e => { + a && a.addEventListener("click", ((/** @type {MouseEvent} */ e) => { dialog.confirm(`是否删除 ${o.name} ?`, { icon: 3, title: "提示", btn: [ "确定", "取消" ] - }, (async e => { + }, (async (/** @type {number} */ e) => { dialog.close(e); let a = loading(); try { @@ -231,12 +245,12 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialo let e = await t.getBackupList(folderName); i.replaceData(e), dialog.alert("删除成功"); } catch (s) { - clog.error(s), show.error(`发生错误: ${s ? s.message : s}`); + clog.error(s), show.error(`发生错误: ${errorMessage(s)}`); } finally { a.close(); } })); - })), s && s.addEventListener("click", (async e => { + })), s && s.addEventListener("click", (async (/** @type {MouseEvent} */ e) => { let a = loading(); try { const e = await decryptData(await t.getFileContent(o.fileId)); @@ -246,7 +260,7 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialo } finally { a.close(); } - })), r && r.addEventListener("click", (async e => { + })), r && r.addEventListener("click", (async (/** @type {MouseEvent} */ e) => { let a = loading(); try { let e = await t.getFileContent(o.fileId); @@ -254,7 +268,7 @@ export function openFileListDialog(e, t, n, folderName, showDiffPreviewFn, dialo const n = JSON.parse(e), i = await storageManager.exportData(), s = await storageManager.diffData(i, n); a.close(), showDiffPreviewFn(s, null, n); } catch (i) { - a.close(), clog.error(i), show.error("预览失败: " + (i ? i.message : i)); + a.close(), clog.error(i), show.error("预览失败: " + errorMessage(i)); } })); })), '\n \n \n \n '; @@ -288,6 +302,6 @@ export async function exportSettingData() { const e = JSON.stringify(await storageManager.exportData()), t = `${utils.getNowStr("_", "_")}.json`; utils.download(e, t), show.ok("数据导出成功"); } catch (t) { - clog.error(t), show.error("导出数据时出错: " + t.message); + clog.error(t), show.error("导出数据时出错: " + errorMessage(t)); } } diff --git a/src/plugins/backup/setting-panels.js b/src/plugins/backup/setting-panels.js index 6b0a3f08..bd1d5db0 100644 --- a/src/plugins/backup/setting-panels.js +++ b/src/plugins/backup/setting-panels.js @@ -1,17 +1,26 @@ +// @ts-check + import { escapeHtml } from "../../core/constants.js"; import { disabledIdForPlugin, parseDisabledPlugins } from "../../core/legacy-plugin-contributions.js"; import { getPluginCategories } from "./setting-templates.js"; import { createJhsTable } from "../../ui/table/create-jhs-table.js"; +/** @typedef {any} DiagnosticsHandle */ +/** @typedef {any} DialogHandle */ +/** @typedef {{ state?: string, cooldownMs?: number, openTime?: number, failCount?: number }} CircuitBreakerRecord */ +/** @typedef {{ count: number, errors: number, lastUsed?: number }} DomainStatsRecord */ +/** @typedef {{ message: string, count: number }} HealthIssue */ + /** Render the network/external requests panel: circuit breaker status, domain stats. */ +/** @param {DiagnosticsHandle} diagnostics */ export async function renderNetworkPanel(diagnostics) { - const network = diagnostics.getNetworkDiagnostics(), e = network.circuitBreakers, t = network.domainStats, n = await storageManager.getSetting("circuitBreakerThreshold", 3), a = await storageManager.getSetting("circuitBreakerCooldown", 6e4); + const network = diagnostics.getNetworkDiagnostics(), e = /** @type {Record} */ (network.circuitBreakers), t = /** @type {Record} */ (network.domainStats), n = await storageManager.getSetting("circuitBreakerThreshold", 3), a = await storageManager.getSetting("circuitBreakerCooldown", 6e4); $("#circuitBreakerThreshold").val(n), $("#circuitBreakerCooldownSec").val(Math.round(a / 1e3)); const i = Object.entries(e); if (i.length) { let t = '
    插件阶段耗时(ms)状态
    '; for (const [n, a] of i) { - const i = "open" === a.state ? "熔断" : "half-open" === a.state ? "半开" : "正常", s = "open" === a.state ? `剩余${Math.ceil((a.cooldownMs - (Date.now() - a.openTime)) / 1e3)}秒` : ""; + const i = "open" === a.state ? "熔断" : "half-open" === a.state ? "半开" : "正常", s = "open" === a.state ? `剩余${Math.ceil((Number(a.cooldownMs) - (Date.now() - Number(a.openTime))) / 1e3)}秒` : ""; t += ``; } t += '
    域名状态失败次数操作
    ${escapeHtml(n)}${i} ${s}${Number(a.failCount) || 0}
    ', $("#site-health-table").html(t); @@ -25,7 +34,7 @@ export async function renderNetworkPanel(diagnostics) { } e += '', e += `

    共 ${s.length} 个域名

    `, $("#domain-stats-table").html(e); } else $("#domain-stats-table").html('

    暂无统计数据

    '); - $(".reset-breaker").off("click").on("click", (e => { + $(".reset-breaker").off("click").on("click", ((/** @type {MouseEvent} */ e) => { const t = $(e.target).data("domain"); diagnostics.resetCircuitBreaker(t), show.ok(`已重置 ${t} 的熔断状态`), renderNetworkPanel(diagnostics); })), $("#resetAllBreakersBtn").off("click").on("click", (() => { @@ -45,7 +54,7 @@ export async function renderSnapshotPanel() { }; if (0 === e.length) return void $("#snapshot-list").html('
    暂无快照,点击上方按钮创建
    '); $("#snapshot-list").find(".tabulator").length && $("#snapshot-list").empty(); - const n = createJhsTable(Tabulator, "#snapshot-list", { + const n = createJhsTable((/** @type {any} */ (globalThis)).Tabulator, "#snapshot-list", { pagination: !1, layout: "fitColumns", placeholder: "暂无数据", @@ -53,35 +62,35 @@ export async function renderSnapshotPanel() { columnDefaults: { headerHozAlign: "center", hozAlign: "center" }, columns: [ { title: "名称", field: "name", width: 200, headerSort: !1 }, - { title: "来源", field: "source", width: 100, headerSort: !1, formatter: e => t[e.getValue()] || e.getValue() }, + { title: "来源", field: "source", width: 100, headerSort: !1, formatter: (/** @type {any} */ e) => t[/** @type {keyof typeof t} */ (e.getValue())] || e.getValue() }, { title: "时间", field: "time", width: 170, headerSort: !1 }, { title: "数据量", field: "itemCount", width: 80, headerSort: !1 }, { - title: "操作", minWidth: 220, headerSort: !1, formatter: (e, t, a) => { + title: "操作", minWidth: 220, headerSort: !1, formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {(callback: () => void) => void} */ a) => { const i = e.getData(); return a((() => { const t = e.getElement().querySelector(".snap-restore"), a = e.getElement().querySelector(".snap-download"), s = e.getElement().querySelector(".snap-delete"); - t && t.addEventListener("click", (async e => { + t && t.addEventListener("click", (async (/** @type {MouseEvent} */ e) => { utils.q(e, `恢复到快照「${escapeHtml(i.name)}」? 当前数据会自动备份。`, (async () => { let e = loading(); try { await storageManager.restoreSnapshot(i.id), show.ok("恢复成功, 页面将刷新"), setTimeout(() => location.reload(), 1e3); } catch (t) { - clog.error(t), show.error("恢复失败: " + t.message); + clog.error(t), show.error("恢复失败: " + (t instanceof Error ? t.message : String(t))); } finally { e.close(); } })); - })), a && a.addEventListener("click", (async e => { + })), a && a.addEventListener("click", (async (/** @type {MouseEvent} */ e) => { let t = loading(); try { const e = await storageManager.getSnapshot(i.id); if (!e) throw new Error("快照不存在"); utils.download(JSON.stringify(e.data), `snapshot_${escapeHtml(i.name)}.json`), show.ok("下载成功"); - } catch (n) { show.error("下载失败: " + n.message); } finally { t.close(); } - })), s && s.addEventListener("click", (async e => { + } catch (n) { show.error("下载失败: " + (n instanceof Error ? n.message : String(n))); } finally { t.close(); } + })), s && s.addEventListener("click", (async (/** @type {MouseEvent} */ e) => { utils.q(e, `删除快照「${escapeHtml(i.name)}」?`, (async () => { try { await storageManager.deleteSnapshot(i.id), show.ok("已删除"), renderSnapshotPanel(); - } catch (t) { clog.error(t), show.error("删除失败: " + t.message); } + } catch (t) { clog.error(t), show.error("删除失败: " + (t instanceof Error ? t.message : String(t))); } })); })); })), ' '; @@ -93,6 +102,7 @@ export async function renderSnapshotPanel() { } /** Show a data diff preview dialog before importing data. */ +/** @param {any} e @param {any} t @param {any} n @param {DialogHandle} dialog */ export function showDiffPreview(e, t, n = null, dialog) { const a = e.summary, i = []; for (const [s, o] of Object.entries(e.stores)) { @@ -110,7 +120,7 @@ export function showDiffPreview(e, t, n = null, dialog) { if (i.length > 0) { s += '
    '; const o = { added: "新增", removed: "缺失", modified: "变更", unchanged: "无变化" }; - for (const r of i) s += ``; + for (const r of i) s += ``; s += '
    数据源状态当前导入新增删除修改
    ${escapeHtml(r.store)}${o[r.status] || r.status}${r.oldCount}${r.newCount}${r.added || "-"}${r.removed || "-"}${r.modified || "-"}
    ${escapeHtml(r.store)}${o[/** @type {keyof typeof o} */ (r.status)] || r.status}${r.oldCount}${r.newCount}${r.added || "-"}${r.removed || "-"}${r.modified || "-"}
    '; } else { s += '
    数据完全一致,无需导入
    '; @@ -124,20 +134,21 @@ export function showDiffPreview(e, t, n = null, dialog) { area: utils.getResponsiveArea(["700px", "auto"]), btn: ["确认导入", "取消"], anim: -1, - yes: async s => { + yes: async (/** @type {number} */ s) => { dialog.close(s); let o = loading(); try { await storageManager.createSnapshot("导入前自动备份", "auto-import"), n ? (await storageManager.importData(n), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)) : t && (await storageManager.importData(t), show.ok("导入成功!"), void setTimeout(() => location.reload(), 1e3)); } catch (r) { - clog.error(r), show.error("导入失败: " + r.message); + clog.error(r), show.error("导入失败: " + (r instanceof Error ? r.message : String(r))); } finally { o.close(); } } }); } /** Render the plugin management panel: categorized plugin list, timing, errors, cache stats. */ +/** @param {DiagnosticsHandle} diagnostics */ export async function renderPluginMgmtPanel(diagnostics) { const diagnosticSnapshot = diagnostics.exportSnapshot(); const disabled = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); @@ -170,7 +181,7 @@ export async function renderPluginMgmtPanel(diagnostics) { $("#pm-total").text(allNames.length); $("#pm-enabled").text(enabledCount); $("#pm-disabled").text(disabled.length); - $(".pm-toggle").off("change").on("change", async (e) => { + $(".pm-toggle").off("change").on("change", async (/** @type {Event} */ e) => { const name = $(e.target).data("plugin"); let list = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); const disabledId = disabledIdForPlugin(name); @@ -187,10 +198,10 @@ export async function renderPluginMgmtPanel(diagnostics) { show.ok(`插件 "${name}" 已${$(e.target).is(":checked") ? "启用" : "禁用"},刷新后生效`); }); const startup = diagnosticSnapshot.legacyStartup, timings = diagnosticSnapshot.legacyTimings; - const formatMs = value => Number.isFinite(value) ? value.toFixed(1) : "0.0"; + const formatMs = (/** @type {number} */ value) => Number.isFinite(value) ? value.toFixed(1) : "0.0"; let startupHtml = startup ? `
    就绪: ${formatMs(startup.readyMs)} ms注册: ${formatMs(startup.registrationMs)} ms样式: ${formatMs(startup.cssMs)} ms即时插件: ${formatMs(startup.immediateMs)} ms空闲任务: ${startup.idleCompleted}/${startup.idleCompleted + startup.idlePending}

    就绪耗时不包含 @require 下载及浏览器脚本解析时间。

    ` : ""; if (timings.length) { - const sorted = [...timings].sort((a, b) => b.elapsed - a.elapsed); + const sorted = [...timings].sort(((/** @type {any} */ a, /** @type {any} */ b) => b.elapsed - a.elapsed)); let tHtml = ''; for (const t of sorted) { const stateClass = t.status === "disabled" ? "is-muted" : t.elapsed > 500 ? "is-slow" : t.elapsed > 200 ? "is-warning" : ""; @@ -202,7 +213,7 @@ export async function renderPluginMgmtPanel(diagnostics) { } else { $("#plugin-timing-table").html(startupHtml + '

    暂无数据,刷新页面后自动采集。

    '); } - const errorLog = diagnosticSnapshot.errors.filter((error) => error.source === "legacy-plugin"); + const errorLog = diagnosticSnapshot.errors.filter((/** @type {any} */ error) => error.source === "legacy-plugin"); if (errorLog.length) { let eHtml = '
    插件阶段耗时(ms)状态
    '; for (const err of [...errorLog].reverse()) { @@ -223,7 +234,7 @@ export async function renderDataHealthPanel() { if (!e.length) return; e.text("体检中..."); try { - const t = await storageManager.inspectDataHealth(), n = t.fixable.reduce(((e, t) => e + t.count), 0), a = t.readonly.reduce(((e, t) => e + t.count), 0), i = t => t.length ? t.map((e => `
  • ${escapeHtml(e.message)}:${e.count}
  • `)).join("") : "
  • 无
  • "; + const t = await storageManager.inspectDataHealth(), n = t.fixable.reduce(((/** @type {number} */ e, /** @type {HealthIssue} */ t) => e + t.count), 0), a = t.readonly.reduce(((/** @type {number} */ e, /** @type {HealthIssue} */ t) => e + t.count), 0), i = (/** @type {HealthIssue[]} */ t) => t.length ? t.map((e => `
  • ${escapeHtml(e.message)}:${e.count}
  • `)).join("") : "
  • 无
  • "; e.html(`
    番号记录:${t.totals.carList}
    diff --git a/src/plugins/image-viewer/bus-preview-video.js b/src/plugins/image-viewer/bus-preview-video.js index c39dc8e4..b3be01ee 100644 --- a/src/plugins/image-viewer/bus-preview-video.js +++ b/src/plugins/image-viewer/bus-preview-video.js @@ -32,7 +32,10 @@ export class BusPreviewVideoPlugin extends BasePlugin { this.initModal(); const e = $("#sample-waterfall .sample-box .photo-frame img:first").attr("src"), t = $(`\n `); $("#sample-waterfall").prepend(t); - "yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes") && fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage")).catch((error => clog.warn("预加载 DMM 失败", error))); + if ("yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes")) { + const scope = await this.getRuntimeService("scope")(); + void fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope).catch((error => clog.warn("预加载 DMM 失败", error))); + } let n = !1, a = $(".preview-video-container"); a.on("click", (async (/** @type {MouseEvent} */ e) => { if (e.preventDefault(), e.stopPropagation(), n) show.info("正在加载中, 勿重复点击"); else { @@ -53,7 +56,7 @@ export class BusPreviewVideoPlugin extends BasePlugin { notify: !0 }); let a = this.getPageInfo().carNum; - const {sources: i, error: previewError} = await fetchDmmPreview(a, this.getRuntimeService("storage")); + const scope = await this.getRuntimeService("scope")(), {sources: i, error: previewError} = await fetchDmmPreview(a, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope); i && 0 !== Object.keys(i).length ? (await this.createVideoPlayerAndControls(i, t), n = $("#preview-video"), n.length > 0 ? (e.addClass("is-open"), await safePlay(n[0], { context: "JavBus 预览视频", diff --git a/src/plugins/image-viewer/cover-button.js b/src/plugins/image-viewer/cover-button.js index 6d5f3142..5d53ecb1 100644 --- a/src/plugins/image-viewer/cover-button.js +++ b/src/plugins/image-viewer/cover-button.js @@ -188,7 +188,7 @@ export class CoverButtonPlugin extends BasePlugin { notify: !0 }), void t.hide(); t.addClass("loading"), t.after('
    '); - const s = t.attr("src"), {sources: o, error: previewError} = await fetchDmmPreview(n, this.getRuntimeService("storage")); + const s = t.attr("src"), scope = await this.getRuntimeService("scope")(), {sources: o, error: previewError} = await fetchDmmPreview(n, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope); if (!o) return show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未解析到视频"), void this.showImg(e, t, n); let r = await storageManager.getSetting("videoQuality"); r = Z(Object.keys(o), r); diff --git a/src/plugins/image-viewer/preview-video.d.ts b/src/plugins/image-viewer/preview-video.d.ts deleted file mode 100644 index 89251abd..00000000 --- a/src/plugins/image-viewer/preview-video.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export function Z(qualities: string[], preferred?: string): string; -export function fetchDmmPreview(carNum: string | null, storage: any, movie?: any, scope?: any): Promise<{ - sources: Record; - error?: { code?: string; message?: string } | null; -}>; diff --git a/src/plugins/image-viewer/preview-video.js b/src/plugins/image-viewer/preview-video.js index ee3a050c..97828058 100644 --- a/src/plugins/image-viewer/preview-video.js +++ b/src/plugins/image-viewer/preview-video.js @@ -1,25 +1,35 @@ +// @ts-check + import { ProviderError } from "../../core/cache-policy.js"; import { C, L, _, normalizeCarNum, o } from "../../core/constants.js"; import { safePlay } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; +/** @typedef {any} JQueryHandle */ +/** @typedef {{ code?: string, message?: string, retryable?: boolean }} PreviewFailure */ +/** @typedef {{ sources: Record | null, error: PreviewFailure | null }} PreviewResult */ + +/** @param {string[]} e @param {string | undefined} t @returns {string | null} */ export const Z = (e, t) => { if (!e || 0 === e.length) return null; const n = new Set(e); - if (n.has(t)) return t; + if (t && n.has(t)) return t; const a = L.map((e => e.quality)).reverse(); for (const i of a) if (n.has(i)) return i; - return e[0]; + return e[0] || null; }, ee = "jhs_dmm_video"; class DmmPreviewParser { + /** @param {string | null} e @param {any} storage @param {any} movie @param {any} scope */ constructor(e, storage, movie, scope) { - this.carNum = e, this.storage = storage, this.movie = movie, this.scope = scope, this.lastError = null; + /** @type {string} */ + this.carNum = e || "", this.storage = storage, this.movie = movie, this.scope = scope, this.lastError = null; } _checkCache() { const cached = this.storage.getLocal(ee), e = cached ? JSON.parse(cached) : {}; return e[this.carNum] ? (clog.debug("缓存中存在预览视频信息", e[this.carNum]), e[this.carNum]) : null; } + /** @param {Record} e */ _updateCache(e) { const cached = this.storage.getLocal(ee), t = cached ? JSON.parse(cached) : {}; t[this.carNum] = e, clog.debug("成功解析出预览视频并已缓存:", e), this.storage.setLocal(ee, JSON.stringify(t)); @@ -53,9 +63,10 @@ class DmmPreviewParser { if (!sources) return null; return this._updateCache(sources), sources; } catch (n) { - this.lastError = n instanceof ProviderError ? n : new ProviderError("dmm", n?.code || "PARSE_ERROR", n.message || String(n), { + const error = /** @type {PreviewFailure} */ (n); + this.lastError = n instanceof ProviderError ? n : new ProviderError("dmm", error?.code || "PARSE_ERROR", error?.message || String(n), { cause: n, - retryable: n?.retryable === true + retryable: error?.retryable === true }), clog.error("DMM API 搜索失败:", this.lastError); const e = $("#fanzaBtn"); return e.attr("href", this.movie.searchUrl("dmm", { carNum: this.carNum })), @@ -65,6 +76,7 @@ class DmmPreviewParser { } /** 获取 DMM 预览源及可供界面判断的失败原因。 */ +/** @param {string | null} carNum @param {any} storage @param {any} movie @param {any} scope @returns {Promise} */ export async function fetchDmmPreview(carNum, storage, movie, scope) { const parser = new DmmPreviewParser(carNum, storage, movie, scope), sources = await parser.fetchVideo(); return { @@ -94,6 +106,7 @@ export class PreviewVideoPlugin extends BasePlugin { const url = window.location.href; (url.includes("gallery-1") || url.includes("gallery-2")) && openVideo(), url.includes("autoPlay=1") && trigger.length > 0 && trigger[0].click(); } + /** @param {any} scope */ async initDmm(scope) { try { const {sources} = await this.getDmmPreview(scope); @@ -110,6 +123,7 @@ export class PreviewVideoPlugin extends BasePlugin { } } /** 复用单次 DMM 请求,避免预加载和点击处理重复抓取。 */ + /** @param {any} [scope] @returns {Promise} */ getDmmPreview(scope = this.lifecycleScope) { if (this.dmmPreviewPromise) return this.dmmPreviewPromise; this.dmmPreviewPromise = Promise.resolve(scope || this.getRuntimeService("scope")()).then((requestScope => fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage"), this.getRuntimeService("movie"), requestScope))).then((result => { @@ -122,6 +136,7 @@ export class PreviewVideoPlugin extends BasePlugin { return this.dmmPreviewPromise; } /** 创建与 JavDB HLS 生命周期完全隔离的 DMM 播放器。 */ + /** @param {JQueryHandle} $nativeVideo @returns {JQueryHandle} */ createDmmPlayer($nativeVideo) { const $host = $nativeVideo.parent(), existing = $host.find("#jhs-preview-video"); if (existing.length) return existing; @@ -129,6 +144,7 @@ export class PreviewVideoPlugin extends BasePlugin { return $nativeVideo.after($player), $player; } /** 销毁 JHS 播放器并把播放权完整交回 JavDB。 */ + /** @param {JQueryHandle} $nativeVideo @param {HTMLVideoElement} nativeVideo @param {boolean} [notify] */ async restoreNativePlayer($nativeVideo, nativeVideo, notify = !1) { const $dmmVideo = $nativeVideo.parent().find("#jhs-preview-video"), dmmVideo = $dmmVideo[0]; dmmVideo && (dmmVideo.pause(), $dmmVideo.removeAttr("src"), dmmVideo.load(), $dmmVideo.remove()); @@ -141,7 +157,7 @@ export class PreviewVideoPlugin extends BasePlugin { async handleVideo() { const $nativeVideo = $("#preview-video"); if (!$nativeVideo.length) return; - const settings = this.getRuntimeService("settings"), $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = $nativeVideo[0], muted = settings.snapshot().videoMuted; + const settings = this.getRuntimeService("settings"), $host = $nativeVideo.parent().css("position", "relative"), nativeVideo = /** @type {HTMLVideoElement} */ ($nativeVideo[0]), muted = settings.snapshot().videoMuted; void safePlay(nativeVideo, { context: "JavDB 原生预览", notify: !1 @@ -154,13 +170,17 @@ export class PreviewVideoPlugin extends BasePlugin { "aria-label": "视频画质" }); $host.find("#video-bottom-toolbar").remove(); - let dmmPlayed = !1, $dmmVideo = null, dmmVideo = null; + let dmmPlayed = !1; + /** @type {JQueryHandle | null} */ + let $dmmVideo = null; + /** @type {any} Legacy jQuery media handle promoted to an HTMLVideoElement at runtime. */ + let dmmVideo = null; if (sources) { - const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = Z(Object.keys(sources), preferredQuality), source = sources[selectedQuality]; + const preferredQuality = await storageManager.getSetting("videoQuality"), selectedQuality = /** @type {string} */ (Z(Object.keys(sources), preferredQuality)), source = /** @type {string} */ (sources[selectedQuality]); const currentTime = nativeVideo.currentTime; $dmmVideo = this.createDmmPlayer($nativeVideo), dmmVideo = $dmmVideo[0], dmmVideo.muted = muted == null || muted === !0, $dmmVideo.off("volumechange.jhsVideo").on("volumechange.jhsVideo", (() => { - void settings.set("videoMuted", dmmVideo.muted).catch((error => clog.error("保存视频静音设置失败", error))); + void settings.set("videoMuted", dmmVideo.muted).catch((/** @type {unknown} */ error) => clog.error("保存视频静音设置失败", error)); })), $dmmVideo.attr("src", source), dmmVideo.load(), dmmVideo.currentTime = currentTime, $dmmVideo.addClass("is-active"); dmmPlayed = await safePlay(dmmVideo, { context: "JavDB 高画质预览", @@ -187,7 +207,7 @@ export class PreviewVideoPlugin extends BasePlugin { notify: !0, message: "REGION_BLOCKED" === error?.code ? error.message : "当前视频源无法播放" }); - $toolbar.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async event => { + $toolbar.off("click.jhsVideo").on("click.jhsVideo", ".jhs-video-quality-btn", (async (/** @type {MouseEvent} */ event) => { const $button = $(event.currentTarget); if ($button.hasClass("active")) return; try { @@ -213,9 +233,9 @@ export class PreviewVideoPlugin extends BasePlugin { })), $("#speed-btn").off("click.jhsVideo").on("click.jhsVideo", (() => { dmmVideo && (dmmVideo.currentTime += 10); })), - $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", (event => (event.preventDefault(), + $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", ((/** @type {MouseEvent} */ event) => (event.preventDefault(), this.getDependency("DetailPageButtonPlugin").filterOne(event)))), - $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", (event => this.getDependency("DetailPageButtonPlugin").filterOne(event))), - $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", (event => this.getDependency("DetailPageButtonPlugin").favoriteOne(event))); + $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((/** @type {MouseEvent} */ event) => this.getDependency("DetailPageButtonPlugin").filterOne(event))), + $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((/** @type {MouseEvent} */ event) => this.getDependency("DetailPageButtonPlugin").favoriteOne(event))); } } diff --git a/tests/media-playback.test.js b/tests/media-playback.test.js index 95ab9282..08249707 100644 --- a/tests/media-playback.test.js +++ b/tests/media-playback.test.js @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; const preview = readTestFile(join(import.meta.dirname, "../src/plugins/image-viewer/preview-video.js"), "utf8"); const bus = readTestFile(join(import.meta.dirname, "../src/plugins/image-viewer/bus-preview-video.js"), "utf8"); +const cover = readTestFile(join(import.meta.dirname, "../src/plugins/image-viewer/cover-button.js"), "utf8"); describe("preview playback contracts", () => { it("starts the native JavDB preview before awaiting DMM without taking over its source", () => { @@ -49,8 +50,15 @@ describe("preview playback contracts", () => { expect(source).toContain("jhs-video-quality-btn"); expect(source).toContain("aria-pressed"); expect(source).not.toContain("video-control-btn"); } }); + it("injects the movie service and lifecycle scope for every remote DMM preview", () => { + for (const source of [bus, cover]) { + expect(source).toContain('getRuntimeService("movie")'); + expect(source).toContain('getRuntimeService("scope")()'); + expect(source).toMatch(/fetchDmmPreview\([^\n]+getRuntimeService\("storage"\)[^\n]+getRuntimeService\("movie"\)[^\n]+scope\)/); + } + }); it("keeps native play calls inside safePlay only", () => { - const allRuntime = [ preview, bus, readTestFile(join(import.meta.dirname, "../src/plugins/image-viewer/cover-button.js"), "utf8"), readTestFile(join(import.meta.dirname, "../src/plugins/status/list-page.js"), "utf8") ].join("\n"); + const allRuntime = [ preview, bus, cover, readTestFile(join(import.meta.dirname, "../src/plugins/status/list-page.js"), "utf8") ].join("\n"); expect(allRuntime).not.toMatch(/\.play\s*\(/); }); }); diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index 00c26f04..f9486772 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -46,6 +46,8 @@ "src/plugins/avatar/actress-info.js", "src/plugins/avatar/search-by-image.js", "src/plugins/backup/resource-settings.js", + "src/plugins/backup/setting-backup.js", + "src/plugins/backup/setting-panels.js", "src/plugins/backup/setting-styles.js", "src/plugins/backup/setting-templates.js", "src/plugins/blacklist/filter-title-keyword.js", @@ -63,7 +65,7 @@ "src/plugins/image-viewer/bus-img.js", "src/plugins/image-viewer/bus-preview-video.js", "src/plugins/image-viewer/cover-button.js", - "src/plugins/image-viewer/preview-video.d.ts", + "src/plugins/image-viewer/preview-video.js", "src/plugins/image-viewer/screenshot-provider-registry.js", "src/plugins/image-viewer/screenshot.js", "src/plugins/external-search/javtrailers.js", From 4a243ee02111eb541eb4526abd8a64b15a4f6e79 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:07:39 +0800 Subject: [PATCH 050/130] =?UTF-8?q?Chore:=20=E7=B1=BB=E5=9E=8B=E5=8C=96?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 移除遮蔽 PluginManager 与 BasePlugin 真实实现的宽泛声明文件 - 为插件注册、启动调度、错误诊断、宿主数据解析与共享图标状态补充严格类型契约 - 显式声明热播插件运行时状态并将 strict checkJs 覆盖率提升至 89.8% - 重新构建并同步根目录 UserScript 产物 修改原因: - 避免声明文件与真实运行时实现漂移,继续推进 RC 前生产源码类型门禁 - 让插件基座的隐式状态和宿主 DOM 边界接受 TypeScript 实际校验 验证: - npm run check 通过(116 个测试文件、611 项测试) - npm run check:browser 通过(本机 Edge,15 passed、15 skipped) - git diff --check 通过 - codegraph sync 通过 - 根目录与 dist UserScript SHA256 一致 --- CHANGELOG.md | 1 + JHS.user.js | 93 ++++++++++------ src/core/plugin-manager.d.ts | 28 ----- src/core/plugin-manager.js | 141 ++++++++++++++++-------- src/plugins/external-search/hit-show.js | 7 +- tsconfig.architecture.json | 2 +- 6 files changed, 167 insertions(+), 105 deletions(-) delete mode 100644 src/core/plugin-manager.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bc65bbf5..5867c160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - FC2 详情、外部站点入口和详情操作栏进入 strict checkJs,明确宿主工作区、影片/磁力数据与遗留 Vendor 句柄边界,生产源码类型覆盖率提升至 86.4%。 - 磁力聚合中心与列表封面操作进入 strict checkJs,补齐来源、磁力结果、卡片事件和媒体句柄契约,生产源码类型覆盖率提升至 87.5%,最低覆盖门禁同步上调至 87%。 - DMM 预览、备份文件操作和设置诊断面板进入真实 strict checkJs,移除遮蔽生产实现的同名声明文件;生产源码类型覆盖率提升至 89.2%,最低覆盖门禁同步上调至 89%。 +- PluginManager 与 BasePlugin 的真实运行时实现进入 strict checkJs,移除宽泛的同名声明文件,并显式约束插件注册、启动时序、错误诊断、宿主信息读取与共享图标状态;生产源码类型覆盖率提升至 89.8%。 ### Fixed diff --git a/JHS.user.js b/JHS.user.js index e3ba918c..b0c1f166 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -2193,15 +2193,16 @@ return this.plugins.get(e2); } _addError(e2, t2, n2) { + const error = n2; this._errorLog.push({ time: (/* @__PURE__ */ new Date()).toISOString(), plugin: e2, phase: t2, - message: n2?.message || String(n2), - stack: n2?.stack || "" + message: error?.message || String(n2), + stack: error?.stack || "" }); this._errorLog.length > 200 && this._errorLog.shift(); - this.diagnostics?.recordError({ source: "legacy-plugin", plugin: e2, phase: t2, message: n2?.message || String(n2) }); + this.diagnostics?.recordError({ source: "legacy-plugin", plugin: e2, phase: t2, message: error?.message || String(n2) }); } getErrorLog() { return [...this._errorLog]; @@ -2278,9 +2279,10 @@ e2.timing.elapsed = performance.now() - t2; e2.timing.status = "ok"; } catch (n2) { + const error = n2; e2.timing.elapsed = performance.now() - t2; e2.timing.status = "error"; - e2.timing.error = n2?.message || String(n2); + e2.timing.error = error?.message || String(n2); clog.error(`插件 ${e2.name} 执行失败`, n2); this._addError(e2.name, "idle" === e2.mode ? "handle-idle" : "handle", n2); } @@ -2301,7 +2303,10 @@ this._syncDiagnostics(); } async processPlugins() { - const e2 = await this._getDisabledPlugins(), t2 = utils.isMobileMode(), n2 = [], a2 = [], i2 = []; + const e2 = await this._getDisabledPlugins(), t2 = utils.isMobileMode(); + const n2 = []; + const a2 = []; + const i2 = []; for (const [s3, o2] of this.plugins) { const r2 = "function" == typeof o2.getStartupMode && "idle" === o2.getStartupMode() ? "idle" : "immediate"; const l2 = { name: s3, elapsed: 0, status: "pending", startupMode: r2 }; @@ -2336,17 +2341,34 @@ __name(_PluginManager, "PluginManager"); var PluginManager = _PluginManager; var _BasePlugin = class _BasePlugin { - constructor() { + constructor(...args) { if (!_BasePlugin._sharedIcons) { + this.settingSvg = ""; + this.editSvg = ""; + this.deleteSvg = ""; + this.checkSvg = ""; + this.actressSvg = ""; + this.newSvg = ""; + this.refreshSvg = ""; + this.blacklistSvg = ""; + this.copySvg = ""; + this.titleSvg = ""; + this.carNumSvg = ""; + this.downSvg = ""; + this.handleSvg = ""; + this.siteSvg = ""; + this.videoSvg = ""; + this.screenSvg = ""; + this.recoveryVideoSvg = ""; i(this, "pluginManager", null), i(this, "settingSvg", ''), i(this, "editSvg", ''), i(this, "deleteSvg", ''), i(this, "checkSvg", ''), i(this, "actressSvg", ''), i(this, "newSvg", ''), i(this, "refreshSvg", ''), i(this, "blacklistSvg", ''), i(this, "copySvg", ''), i(this, "titleSvg", ''), i(this, "carNumSvg", ''), i(this, "downSvg", ''), i(this, "handleSvg", ''), i(this, "siteSvg", ''), i(this, "videoSvg", ''), i(this, "screenSvg", ''), i(this, "recoveryVideoSvg", ''); const e2 = ["settingSvg", "editSvg", "deleteSvg", "checkSvg", "actressSvg", "newSvg", "refreshSvg", "blacklistSvg", "copySvg", "titleSvg", "carNumSvg", "downSvg", "handleSvg", "siteSvg", "videoSvg", "screenSvg", "recoveryVideoSvg"]; _BasePlugin._sharedIcons = Object.fromEntries(e2.map(((e3) => [e3, this[e3]]))); Object.assign(_BasePlugin.prototype, _BasePlugin._sharedIcons); e2.forEach(((e3) => delete this[e3])); } - i(this, "pluginManager", null); - i(this, "declaredDependencies", /* @__PURE__ */ new Set()); - i(this, "runtimeServices", Object.freeze({})); + this.pluginManager = null; + this.declaredDependencies = /* @__PURE__ */ new Set(); + this.runtimeServices = Object.freeze({}); } getName() { throw new Error(`${this.constructor.name} 未显示getName()`); @@ -2361,10 +2383,10 @@ if (!Object.prototype.hasOwnProperty.call(this.runtimeServices, name)) throw new Error(`${this.getName()} 未声明运行时依赖 ${name}`); return this.runtimeServices[name]; } - async initCss() { + initCss() { return ""; } - async handle() { + handle() { } getStartupMode() { return "immediate"; @@ -2378,16 +2400,17 @@ const params = new URL(s2).searchParams, injectedCarNum = normalizeCarNum(params.get("jhsCarNum")); const copyCarNum = normalizeCarNum($('.column-video-info a[data-clipboard-text][title*="番"], .video-detail a[data-clipboard-text][title*="番"]').first().attr("data-clipboard-text")) || normalizeCarNum($('a[title="複製番號"]').attr("data-clipboard-text")); let panelCarNum = null; - $(".column-video-info .panel-block, .video-detail .panel-block").each((function() { - const label = $(this).find("strong, .label").first().text().trim(); + $(".column-video-info .panel-block, .video-detail .panel-block").each((index, element) => { + const panel = $(element); + const label = panel.find("strong, .label").first().text().trim(); if (panelCarNum || !/(?:番号|番號|^ID)\s*[::]?/i.test(label)) return; - panelCarNum = normalizeCarNum($(this).find("[data-clipboard-text]").first().attr("data-clipboard-text")) || normalizeCarNum($(this).find(".value").first().text()); - })); + panelCarNum = normalizeCarNum(panel.find("[data-clipboard-text]").first().attr("data-clipboard-text")) || normalizeCarNum(panel.find(".value").first().text()); + }); const fallbackCarNum = normalizeCarNum($("#video_id, .video-id, .video-title strong").first().text()); carNum = firstValidCarNum(injectedCarNum, copyCarNum, panelCarNum, fallbackCarNum); t2 = s2.split("?")[0].split("#")[0], n2 = $(".female").prev().map(((e2, t3) => $(t3).text())).get().join(" "), a2 = $(".male").prev().map(((e2, t3) => $(t3).text())).get().join(" "), i2 = $('strong:contains("日期:")').parent(".panel-block").find(".value").text().trim(); } - l && (t2 = s2.split("?")[0], carNum = normalizeCarNum(t2.split("/").filter(Boolean).pop().replace(/_\d{4}-\d{2}-\d{2}$/, "")), n2 = $('span[onmouseover*="star_"] a').map(((e2, t3) => $(t3).text())).get().join(" "), a2 = "", i2 = $('span.header:contains("發行日期:")').parent("p").text().trim().replace("發行日期:", "").trim()); + l && (t2 = s2.split("?")[0], carNum = normalizeCarNum((t2.split("/").filter(Boolean).pop() || "").replace(/_\d{4}-\d{2}-\d{2}$/, "")), n2 = $('span[onmouseover*="star_"] a').map(((e2, t3) => $(t3).text())).get().join(" "), a2 = "", i2 = $('span.header:contains("發行日期:")').parent("p").text().trim().replace("發行日期:", "").trim()); return assertPageInfoContract({ carNum, url: t2, @@ -2403,20 +2426,21 @@ getActressPageInfo() { let e2 = window.location.href; if (!e2.includes("/actors/") && !e2.includes("/star/")) throw new Error("接口调用错误, 非演员详情页"); - let t2 = [], n2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - n2.length && n2.text().trim().split(",").forEach(((e3) => { + let t2 = []; + let n2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + n2.length && n2.text().trim().split(",").forEach((e3) => { t2.push(e3.trim()); - })); + }); let a2 = $(".section-meta:not(:contains('影片'))"); - a2.length && a2.text().trim().split(",").forEach(((e3) => { + a2.length && a2.text().trim().split(",").forEach((e3) => { t2.push(e3.trim()); - })); + }); let i2 = $(".section-meta:contains('男優')").length > 0 ? B : P, s2 = D; - t2.some(((e3) => e3.includes("無碼"))) && (s2 = A), e2.includes("uncensored") && (s2 = A); + t2.some((e3) => e3.includes("無碼")) && (s2 = A), e2.includes("uncensored") && (s2 = A); let o2 = null, c2 = null; const d2 = new URL(e2); if (r) { - c2 = d2.pathname.split("/").filter(((e4) => "" !== e4.trim())).pop(); + c2 = d2.pathname.split("/").filter((e4) => "" !== e4.trim()).pop(); const e3 = d2.searchParams; e3.delete("sort_type"), e3.delete("page"), o2 = d2.toString(); } else if (l) { @@ -2451,11 +2475,12 @@ nextPageSelector: "#next" } }; - if (!t2 || !n2[t2]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); - return n2[t2]; + const selectorType = t2; + if (!selectorType || !n2[selectorType]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); + return n2[selectorType]; } parseMovieId(e2) { - return e2.split("/").pop().split(/[?#]/)[0]; + return (e2.split("/").pop() || "").split(/[?#]/)[0]; } getBoxCarInfo(e2) { var t2, n2, a2; @@ -2474,7 +2499,7 @@ const t3 = e2.find("img"); t3.length > 0 && (r2 = (null == (n2 = t3.attr("title")) ? void 0 : n2.trim()) || (null == (a2 = t3.attr("data-title")) ? void 0 : a2.trim())); const i3 = e2.find("date").map(((e3, t4) => $(t4).text().trim())).get(), s3 = /* @__PURE__ */ __name((e3) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e3), "s"); - l2 = i3.find(s3) || null, o2 = i3.find(((e3) => !s3(e3))) || null; + l2 = i3.find(s3) || null, o2 = i3.find((e3) => !s3(e3)) || null; } if (!o2) { const t3 = "提取番号信息失败: carNum 为空"; @@ -2488,15 +2513,17 @@ }; } getBoxCarInfoList(e2 = null) { - if (e2 || (e2 = $(this.getSelector().itemSelector)), 0 === e2.length) return clog.error("获取当前列表页所有item的番号信息失败!"), []; + const items = e2 || $(this.getSelector().itemSelector); + if (0 === items.length) return clog.error("获取当前列表页所有item的番号信息失败!"), []; const t2 = []; - return e2.each(((e3, n2) => { + return items.each(((e3, n2) => { const a2 = $(n2); try { const e4 = this.getBoxCarInfo(a2); t2.push(e4); } catch (i2) { - clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", i2.message, "元素索引:", e3); + const error = i2; + clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", error?.message || String(i2), "元素索引:", e3); } })), t2; } @@ -2515,6 +2542,7 @@ } }; __name(_BasePlugin, "BasePlugin"); + __publicField(_BasePlugin, "_sharedIcons", null); var BasePlugin = _BasePlugin; // src/core/event-bus.js @@ -10947,7 +10975,10 @@ ${value}\r // src/plugins/external-search/hit-show.js var _HitShowPlugin = class _HitShowPlugin extends BasePlugin { constructor() { - super(), i(this, "$contentBox", null), i(this, "$listRoot", null), i(this, "loadGeneration", 0); + super(); + this.$contentBox = null; + this.$listRoot = null; + this.loadGeneration = 0; } getName() { return "HitShowPlugin"; diff --git a/src/core/plugin-manager.d.ts b/src/core/plugin-manager.d.ts deleted file mode 100644 index 576b5370..00000000 --- a/src/core/plugin-manager.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -export class PluginManager { - constructor(options?: { diagnostics?: any }); - register(pluginClass: new () => BasePlugin, runtimeServices?: Record): void; - getBean(name: string): BasePlugin | undefined; - resolveDeclaredPlugin(name: string): BasePlugin | undefined; - getErrorLog(): unknown[]; - clearErrorLog(): void; - getTimings(): unknown[]; - getPluginNames(): string[]; - getStartupReport(): Record; - processCss(): Promise; - processPlugins(): Promise; -} - -export class BasePlugin { - [key: string]: any; - constructor(...args: any[]); - pluginManager: PluginManager; - declaredDependencies: Set; - runtimeServices: Readonly>; - getName(): string; - getDependency(name: string): any; - getRuntimeService(name: string): any; - initCss(): string | undefined | Promise; - handle(): void | Promise; - getStartupMode(): "immediate" | "idle"; - shouldSkipOnMobile(): boolean; -} diff --git a/src/core/plugin-manager.js b/src/core/plugin-manager.js index d8c11e9c..51ee12e0 100644 --- a/src/core/plugin-manager.js +++ b/src/core/plugin-manager.js @@ -1,11 +1,18 @@ +// @ts-check + import { A, B, D, I, P, T, assertPageInfoContract, firstValidCarNum, i, l, normalizeCarNum, o, r } from "./constants.js"; import { disabledIdForPlugin, parseDisabledPlugins } from "./legacy-plugin-contributions.js"; +/** @typedef {{ name: string, elapsed: number, status: string, startupMode: string, error?: string }} PluginTiming */ +/** @typedef {{ time: string, plugin: string, phase: string, message: string, stack: string }} PluginError */ +/** @typedef {{ name: string, plugin: any, mode: string, timing: PluginTiming, startedAt: number }} PluginRun */ + export class PluginManager { + /** @param {{ diagnostics?: any }} [options] */ constructor(options = {}) { - this.plugins = new Map; - this._errorLog = []; - this._lastTimings = []; + /** @type {Map} */ this.plugins = new Map; + /** @type {PluginError[]} */ this._errorLog = []; + /** @type {PluginTiming[]} */ this._lastTimings = []; this._startedAt = performance.now(); this._registrationMs = 0; this._cssMs = 0; @@ -14,13 +21,15 @@ export class PluginManager { this._idlePending = 0; this._idleCompleted = 0; this._disabledPluginsPromise = null; - this._dependencyDeclarations = Object.freeze({}); + /** @type {Readonly>} */ this._dependencyDeclarations = Object.freeze({}); this.diagnostics = options.diagnostics ?? null; } + /** @param {Readonly>} declarations */ setDependencyDeclarations(declarations) { if (this.plugins.size) throw new Error("依赖声明必须在插件注册前配置"); this._dependencyDeclarations = declarations || Object.freeze({}); } + /** @param {new (...args: any[]) => any} e @param {Record} [runtimeServices] */ register(e, runtimeServices = {}) { if ("function" != typeof e) throw new Error("插件必须是一个类"); const a = performance.now(); @@ -35,22 +44,26 @@ export class PluginManager { this._syncDiagnostics(); } // 仅供 6.6 前的外部 Compatibility Facade 使用。 + /** @param {string} e @returns {any} */ getBean(e) { return this.plugins.get(e); } + /** @param {string} e @returns {any} */ resolveDeclaredPlugin(e) { return this.plugins.get(e); } + /** @param {string} e @param {string} t @param {unknown} n */ _addError(e, t, n) { + const error = /** @type {{ message?: string, stack?: string }} */ (n); this._errorLog.push({ time: new Date().toISOString(), plugin: e, phase: t, - message: n?.message || String(n), - stack: n?.stack || "" + message: error?.message || String(n), + stack: error?.stack || "" }); this._errorLog.length > 200 && this._errorLog.shift(); - this.diagnostics?.recordError({ source: "legacy-plugin", plugin: e, phase: t, message: n?.message || String(n) }); + this.diagnostics?.recordError({ source: "legacy-plugin", plugin: e, phase: t, message: error?.message || String(n) }); } getErrorLog() { return [...this._errorLog]; } clearErrorLog() { this._errorLog = []; this.diagnostics?.clearErrors(); } @@ -106,6 +119,7 @@ export class PluginManager { this._cssMs = performance.now() - a; this._syncDiagnostics(); } + /** @param {PluginRun} e */ async _runPlugin(e) { const t = performance.now(); try { @@ -113,19 +127,22 @@ export class PluginManager { e.timing.elapsed = performance.now() - t; e.timing.status = "ok"; } catch (n) { + const error = /** @type {{ message?: string }} */ (n); e.timing.elapsed = performance.now() - t; e.timing.status = "error"; - e.timing.error = n?.message || String(n); + e.timing.error = error?.message || String(n); clog.error(`插件 ${e.name} 执行失败`, n); this._addError(e.name, "idle" === e.mode ? "handle-idle" : "handle", n); } } + /** @param {() => Promise} e */ _scheduleIdle(e) { const t = () => e().catch((e => { clog.error("[JHS] 空闲插件执行失败:", e); })); "function" == typeof requestIdleCallback ? requestIdleCallback(t, { timeout: 1500 }) : setTimeout(t, 100); } + /** @param {PluginRun[]} e */ async _runIdlePlugins(e) { for (const t of e) { t.startedAt = performance.now(); @@ -136,7 +153,10 @@ export class PluginManager { this._syncDiagnostics(); } async processPlugins() { - const e = await this._getDisabledPlugins(), t = utils.isMobileMode(), n = [], a = [], i = []; + const e = await this._getDisabledPlugins(), t = utils.isMobileMode(); + /** @type {PluginRun[]} */ const n = []; + /** @type {PluginRun[]} */ const a = []; + /** @type {PluginTiming[]} */ const i = []; for (const [s, o] of this.plugins) { const r = "function" == typeof o.getStartupMode && "idle" === o.getStartupMode() ? "idle" : "immediate"; const l = { name: s, elapsed: 0, status: "pending", startupMode: r }; @@ -169,8 +189,27 @@ export class PluginManager { } export class BasePlugin { - constructor() { + static _sharedIcons = /** @type {any} */ (null); + /** @param {...any} args */ + constructor(...args) { if (!BasePlugin._sharedIcons) { + /** @type {string} */ this.settingSvg = ""; + /** @type {string} */ this.editSvg = ""; + /** @type {string} */ this.deleteSvg = ""; + /** @type {string} */ this.checkSvg = ""; + /** @type {string} */ this.actressSvg = ""; + /** @type {string} */ this.newSvg = ""; + /** @type {string} */ this.refreshSvg = ""; + /** @type {string} */ this.blacklistSvg = ""; + /** @type {string} */ this.copySvg = ""; + /** @type {string} */ this.titleSvg = ""; + /** @type {string} */ this.carNumSvg = ""; + /** @type {string} */ this.downSvg = ""; + /** @type {string} */ this.handleSvg = ""; + /** @type {string} */ this.siteSvg = ""; + /** @type {string} */ this.videoSvg = ""; + /** @type {string} */ this.screenSvg = ""; + /** @type {string} */ this.recoveryVideoSvg = ""; i(this, "pluginManager", null), i(this, "settingSvg", ''), i(this, "editSvg", ''), i(this, "deleteSvg", ''), @@ -189,54 +228,60 @@ export class BasePlugin { i(this, "screenSvg", ''), i(this, "recoveryVideoSvg", ''); const e = [ "settingSvg", "editSvg", "deleteSvg", "checkSvg", "actressSvg", "newSvg", "refreshSvg", "blacklistSvg", "copySvg", "titleSvg", "carNumSvg", "downSvg", "handleSvg", "siteSvg", "videoSvg", "screenSvg", "recoveryVideoSvg" ]; - BasePlugin._sharedIcons = Object.fromEntries(e.map((e => [ e, this[e] ]))); + BasePlugin._sharedIcons = Object.fromEntries(e.map((e => [ e, (/** @type {any} */ (this))[e] ]))); Object.assign(BasePlugin.prototype, BasePlugin._sharedIcons); - e.forEach((e => delete this[e])); + e.forEach((e => delete (/** @type {any} */ (this))[e])); } - i(this, "pluginManager", null); - i(this, "declaredDependencies", new Set); - i(this, "runtimeServices", Object.freeze({})); + /** @type {any} */ this.pluginManager = null; + /** @type {Set} */ this.declaredDependencies = new Set; + /** @type {Readonly>} */ this.runtimeServices = Object.freeze({}); } getName() { throw new Error(`${this.constructor.name} 未显示getName()`); } + /** @param {string} e @returns {any} */ getDependency(e) { if (!this.declaredDependencies.has(e)) { throw new Error(`${this.getName()} 未声明依赖 ${e}`); } return this.pluginManager.resolveDeclaredPlugin(e); } + /** @param {string} name @returns {any} */ getRuntimeService(name) { if (!Object.prototype.hasOwnProperty.call(this.runtimeServices, name)) throw new Error(`${this.getName()} 未声明运行时依赖 ${name}`); return this.runtimeServices[name]; } - async initCss() { + /** @returns {any} */ + initCss() { return ""; } - async handle() {} + /** @returns {any} */ + handle() {} getStartupMode() { return "immediate"; } shouldSkipOnMobile() { return false; } + /** @returns {any} */ getPageInfo() { let carNum = null, t, n, a, i, s = window.location.href; if (r) { const params = new URL(s).searchParams, injectedCarNum = normalizeCarNum(params.get("jhsCarNum")); const copyCarNum = normalizeCarNum($('.column-video-info a[data-clipboard-text][title*="番"], .video-detail a[data-clipboard-text][title*="番"]').first().attr("data-clipboard-text")) || normalizeCarNum($('a[title="複製番號"]').attr("data-clipboard-text")); - let panelCarNum = null; - $(".column-video-info .panel-block, .video-detail .panel-block").each((function() { - const label = $(this).find("strong, .label").first().text().trim(); + /** @type {string | null} */ let panelCarNum = null; + $(".column-video-info .panel-block, .video-detail .panel-block").each((/** @type {number} */ index, /** @type {HTMLElement} */ element) => { + const panel = $(element); + const label = panel.find("strong, .label").first().text().trim(); if (panelCarNum || !/(?:番号|番號|^ID)\s*[::]?/i.test(label)) return; - panelCarNum = normalizeCarNum($(this).find("[data-clipboard-text]").first().attr("data-clipboard-text")) || - normalizeCarNum($(this).find(".value").first().text()); - })); + panelCarNum = normalizeCarNum(panel.find("[data-clipboard-text]").first().attr("data-clipboard-text")) || + normalizeCarNum(panel.find(".value").first().text()); + }); const fallbackCarNum = normalizeCarNum($("#video_id, .video-id, .video-title strong").first().text()); carNum = firstValidCarNum(injectedCarNum, copyCarNum, panelCarNum, fallbackCarNum); - t = s.split("?")[0].split("#")[0], n = $(".female").prev().map(((e, t) => $(t).text())).get().join(" "), - a = $(".male").prev().map(((e, t) => $(t).text())).get().join(" "), + t = s.split("?")[0].split("#")[0], n = $(".female").prev().map(((/** @type {number} */ e, /** @type {HTMLElement} */ t) => $(t).text())).get().join(" "), + a = $(".male").prev().map(((/** @type {number} */ e, /** @type {HTMLElement} */ t) => $(t).text())).get().join(" "), i = $('strong:contains("日期:")').parent(".panel-block").find(".value").text().trim(); } - l && (t = s.split("?")[0], carNum = normalizeCarNum(t.split("/").filter(Boolean).pop().replace(/_\d{4}-\d{2}-\d{2}$/, "")), - n = $('span[onmouseover*="star_"] a').map(((e, t) => $(t).text())).get().join(" "), + l && (t = s.split("?")[0], carNum = normalizeCarNum((t.split("/").filter(Boolean).pop() || "").replace(/_\d{4}-\d{2}-\d{2}$/, "")), + n = $('span[onmouseover*="star_"] a').map(((/** @type {number} */ e, /** @type {HTMLElement} */ t) => $(t).text())).get().join(" "), a = "", i = $('span.header:contains("發行日期:")').parent("p").text().trim().replace("發行日期:", "").trim()); return assertPageInfoContract({ carNum, @@ -250,23 +295,25 @@ export class BasePlugin { const e = o.match(/\/actors\/([^/?]+)/); return e && e.length > 1 ? e[1] : null; } + /** @returns {any} */ getActressPageInfo() { let e = window.location.href; if (!e.includes("/actors/") && !e.includes("/star/")) throw new Error("接口调用错误, 非演员详情页"); - let t = [], n = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - n.length && n.text().trim().split(",").forEach((e => { + /** @type {string[]} */ let t = []; + let n = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + n.length && n.text().trim().split(",").forEach((/** @type {string} */ e) => { t.push(e.trim()); - })); + }); let a = $(".section-meta:not(:contains('影片'))"); - a.length && a.text().trim().split(",").forEach((e => { + a.length && a.text().trim().split(",").forEach((/** @type {string} */ e) => { t.push(e.trim()); - })); + }); let i = $(".section-meta:contains('男優')").length > 0 ? B : P, s = D; - t.some((e => e.includes("無碼"))) && (s = A), e.includes("uncensored") && (s = A); + t.some((/** @type {string} */ e) => e.includes("無碼")) && (s = A), e.includes("uncensored") && (s = A); let o = null, c = null; const d = new URL(e); if (r) { - c = d.pathname.split("/").filter((e => "" !== e.trim())).pop(); + c = d.pathname.split("/").filter((/** @type {string} */ e) => "" !== e.trim()).pop(); const e = d.searchParams; e.delete("sort_type"), e.delete("page"), o = d.toString(); } else if (l) { @@ -284,6 +331,7 @@ export class BasePlugin { blacklistUrl: o }; } + /** @param {string} [e] @returns {any} */ getSelector(e) { const t = e || (r ? T : l ? I : null), n = { javdb: { @@ -301,12 +349,15 @@ export class BasePlugin { nextPageSelector: "#next" } }; - if (!t || !n[t]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); - return n[t]; + const selectorType = /** @type {keyof typeof n} */ (t); + if (!selectorType || !n[selectorType]) throw new Error("类型错误: 无法确定选择器类型 (JavDb 或 JavBus)"); + return n[selectorType]; } + /** @param {string} e */ parseMovieId(e) { - return e.split("/").pop().split(/[?#]/)[0]; + return (e.split("/").pop() || "").split(/[?#]/)[0]; } + /** @param {any} e @returns {any} */ getBoxCarInfo(e) { var t, n, a; const i = e.find("a"), s = i.attr("href"); @@ -324,8 +375,8 @@ export class BasePlugin { if (!o) { const t = e.find("img"); t.length > 0 && (r = (null == (n = t.attr("title")) ? void 0 : n.trim()) || (null == (a = t.attr("data-title")) ? void 0 : a.trim())); - const i = e.find("date").map(((e, t) => $(t).text().trim())).get(), s = e => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e); - l = i.find(s) || null, o = i.find((e => !s(e))) || null; + const i = e.find("date").map(((/** @type {number} */ e, /** @type {HTMLElement} */ t) => $(t).text().trim())).get(), s = (/** @type {string} */ e) => /^\d{4}-\d{1,2}-\d{1,2}$/.test(e); + l = i.find(s) || null, o = i.find((/** @type {string} */ e) => !s(e)) || null; } if (!o) { const t = "提取番号信息失败: carNum 为空"; @@ -339,20 +390,24 @@ export class BasePlugin { publishTime: l || "" }; } + /** @param {any} [e] @returns {any[]} */ getBoxCarInfoList(e = null) { - if (e || (e = $(this.getSelector().itemSelector)), 0 === e.length) return clog.error("获取当前列表页所有item的番号信息失败!"), + const items = e || $(this.getSelector().itemSelector); + if (0 === items.length) return clog.error("获取当前列表页所有item的番号信息失败!"), []; - const t = []; - return e.each(((e, n) => { + /** @type {any[]} */ const t = []; + return items.each(((/** @type {number} */ e, /** @type {HTMLElement} */ n) => { const a = $(n); try { const e = this.getBoxCarInfo(a); t.push(e); } catch (i) { - clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", i.message, "元素索引:", e); + const error = /** @type {{ message?: string }} */ (i); + clog.error("[getBoxCarInfoList] 提取单个 boxCar 信息失败:", error?.message || String(i), "元素索引:", e); } })), t; } + /** @param {{ carNum?: string }[]} e @param {{ carNum?: string }[]} t */ checkDuplicateCarNumbers(e, t) { if (!e || 0 === e.length || !t || 0 === t.length) return !1; const n = new Set(e.map((e => e.carNum)).filter((e => e))); diff --git a/src/plugins/external-search/hit-show.js b/src/plugins/external-search/hit-show.js index c139e60a..c718d094 100644 --- a/src/plugins/external-search/hit-show.js +++ b/src/plugins/external-search/hit-show.js @@ -1,6 +1,6 @@ // @ts-check -import { escapeHtml, i } from "../../core/constants.js"; +import { escapeHtml } from "../../core/constants.js"; import { normalizeHttpUrl } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { isHitShowPage } from "../../core/site-context.js"; @@ -9,7 +9,10 @@ import { isHitShowPage } from "../../core/site-context.js"; export class HitShowPlugin extends BasePlugin { constructor() { - super(), i(this, "$contentBox", null), i(this, "$listRoot", null), i(this, "loadGeneration", 0); + super(); + /** @type {any} */ this.$contentBox = null; + /** @type {any} */ this.$listRoot = null; + this.loadGeneration = 0; } getName() { return "HitShowPlugin"; diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index f9486772..b0792fb1 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -30,7 +30,7 @@ "src/core/magnet-quality.js", "src/core/migration.js", "src/core/movie-identity.js", - "src/core/plugin-manager.d.ts", + "src/core/plugin-manager.js", "src/core/selection-model.js", "src/core/site-context.js", "src/core/state-model.js", From 072ff92b609fb7b94ebf2dddd642f952cbd8acb8 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:12:19 +0800 Subject: [PATCH 051/130] =?UTF-8?q?Chore:=20=E7=B1=BB=E5=9E=8B=E5=8C=96=20?= =?UTF-8?q?UI=20=E7=BB=84=E4=BB=B6=E5=9F=BA=E5=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 移除遮蔽 UI primitive 与 JhsSelect 真实实现的声明文件 - 为状态视图、可访问性 Observer、原生 Select 适配与交互事件补充严格类型契约 - 将生产源码 strict checkJs 覆盖率提升至 90.3%,最低门禁同步上调至 90% - 重新构建并同步根目录 UserScript 产物 修改原因: - 让共享 UI 组件由生产实现本身接受类型检查,避免声明与运行时行为漂移 - 持续推进 RC 前至少 95% 的生产源码 checkJs 覆盖目标 验证: - npm run check 通过(116 个测试文件、611 项测试) - npm run check:browser 通过(本机 Edge,15 passed、15 skipped) - git diff --check 通过 - codegraph sync 通过 - 根目录与 dist UserScript SHA256 一致 --- CHANGELOG.md | 1 + JHS.user.js | 4 ++- scripts/checkjs-coverage.mjs | 2 +- src/core/ui-primitives.d.ts | 21 --------------- src/core/ui-primitives.js | 50 ++++++++++++++++++++++++------------ tsconfig.architecture.json | 2 +- 6 files changed, 40 insertions(+), 40 deletions(-) delete mode 100644 src/core/ui-primitives.d.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5867c160..e48c73ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - 磁力聚合中心与列表封面操作进入 strict checkJs,补齐来源、磁力结果、卡片事件和媒体句柄契约,生产源码类型覆盖率提升至 87.5%,最低覆盖门禁同步上调至 87%。 - DMM 预览、备份文件操作和设置诊断面板进入真实 strict checkJs,移除遮蔽生产实现的同名声明文件;生产源码类型覆盖率提升至 89.2%,最低覆盖门禁同步上调至 89%。 - PluginManager 与 BasePlugin 的真实运行时实现进入 strict checkJs,移除宽泛的同名声明文件,并显式约束插件注册、启动时序、错误诊断、宿主信息读取与共享图标状态;生产源码类型覆盖率提升至 89.8%。 +- UI primitive 与 JhsSelect 的真实实现进入 strict checkJs,移除同名宽泛声明文件并明确动态容器、原生 Select、可访问性 Observer 和事件边界;生产源码类型覆盖率提升至 90.3%,最低覆盖门禁同步上调至 90%。 ### Fixed diff --git a/JHS.user.js b/JHS.user.js index b0c1f166..fab0b22f 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -1719,7 +1719,9 @@ var _JhsSelect = class _JhsSelect { constructor(select) { this.source = $(select); - if (!this.source.length || _JhsSelect.instances.has(this.source[0])) return _JhsSelect.instances.get(this.source[0]); + if (!this.source.length) return; + const existing = _JhsSelect.instances.get(this.source[0]); + if (existing) return existing; const initiallyHidden = this.source.hasClass("jhs-is-hidden") || "none" === this.source[0].style.display; this.control = $('
    '); this.trigger = $(''); diff --git a/scripts/checkjs-coverage.mjs b/scripts/checkjs-coverage.mjs index fc695b79..8a452870 100644 --- a/scripts/checkjs-coverage.mjs +++ b/scripts/checkjs-coverage.mjs @@ -27,7 +27,7 @@ for (const file of files) { if (compiledFiles.has(path.resolve(file)) && /^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); } const coverage = files.length === 0 ? 100 : (checked.length / files.length) * 100; -const required = Number(process.env.JHS_CHECKJS_MIN ?? 89); +const required = Number(process.env.JHS_CHECKJS_MIN ?? 90); if (coverage + Number.EPSILON < required) { throw new Error(`checkJs coverage ${coverage.toFixed(1)}% is below ${required}%`); } diff --git a/src/core/ui-primitives.d.ts b/src/core/ui-primitives.d.ts deleted file mode 100644 index 7a25cc1c..00000000 --- a/src/core/ui-primitives.d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { LifecycleScope } from "./lifecycle-scope.js"; - -export function buildUiPrimitivesCss(): string; -export function renderStateView(container: any, options?: { - type?: string; - title?: string; - description?: string; - actionLabel?: string; - onAction?: (() => unknown) | null; -}): any; -export function initializeUiAccessibility(lifecycleScope: LifecycleScope): void; - -export class JhsSelect { - [key: string]: any; - constructor(element: Element | any, options?: Record); - static enhance(root?: ParentNode | Element | string): JhsSelect[]; - static refresh(select: HTMLSelectElement | Element | string | any): void; - static setValue(select: HTMLSelectElement | Element | string | any, value: unknown, emit?: boolean): void; - static setVisible(select: HTMLSelectElement | Element | string | any, visible: boolean): void; - destroy(): void; -} diff --git a/src/core/ui-primitives.js b/src/core/ui-primitives.js index c403c0b3..9aafd81c 100644 --- a/src/core/ui-primitives.js +++ b/src/core/ui-primitives.js @@ -1,3 +1,5 @@ +// @ts-check + /** JHS 原生 UI 组件基座:现代桌面工具风格,不依赖宿主站点或第三方框架。 */ export function buildUiPrimitivesCss() { return ` @@ -995,7 +997,11 @@ export function buildUiPrimitivesCss() { `; } -/** 在现有容器内渲染安全的 loading、empty 或 error 状态。 */ +/** + * 在现有容器内渲染安全的 loading、empty 或 error 状态。 + * @param {any} container + * @param {{ type?: string, title?: string, description?: string, actionLabel?: string, onAction?: (() => unknown) | null }} [options] + */ export function renderStateView(container, { type = "empty", title = "", description = "", actionLabel = "", onAction = null } = {}) { const root = container?.jquery ? container : $(container), state = $('
    ').addClass(`jhs-state--${type}`).attr("role", "error" === type ? "alert" : "status"), content = $('
    '); title && content.append($('

    ').text(title)), description && content.append($('

    ').text(description)); @@ -1008,7 +1014,7 @@ export function renderStateView(container, { type = "empty", title = "", descrip export function initializeUiAccessibility(lifecycleScope) { if (!lifecycleScope || "function" != typeof lifecycleScope.observe) throw new TypeError("UI accessibility requires an app LifecycleScope"); const selector = "button.jhs-btn, a.jhs-btn[role='button'], .card-btn, .jhs-icon-btn, [class*='jhs-'] button, [class*='jhs-'] a[role='button']"; - const enhance = (e) => { + const enhance = (/** @type {any} */ e) => { const t = e.nodeType === Node.ELEMENT_NODE && e.matches?.(selector) ? [ e ] : []; const n = e.querySelectorAll ? [ ...e.querySelectorAll(selector) ] : []; [ ...t, ...n ].forEach((e => { @@ -1038,10 +1044,14 @@ export function initializeUiAccessibility(lifecycleScope) { /** 以隐藏原生 select 为值源的统一 JHS 选择器。 */ export class JhsSelect { + /** @type {WeakMap} */ static instances = new WeakMap; + /** @param {any} select */ constructor(select) { this.source = $(select); - if (!this.source.length || JhsSelect.instances.has(this.source[0])) return JhsSelect.instances.get(this.source[0]); + if (!this.source.length) return; + const existing = JhsSelect.instances.get(this.source[0]); + if (existing) return existing; const initiallyHidden = this.source.hasClass("jhs-is-hidden") || "none" === this.source[0].style.display; this.control = $('
    '); this.trigger = $(''); @@ -1050,59 +1060,66 @@ export class JhsSelect { this.source.addClass("jhs-select-source-native").attr({ "aria-hidden": "true", tabindex: "-1" }), initiallyHidden && this.control.addClass("jhs-is-hidden"), JhsSelect.instances.set(this.source[0], this), this.render(), this.bind(), this.refresh(); } + /** @param {any} [root] */ static enhance(root = document) { const scope = $(root), selects = scope.is("select.jhs-select-source") ? scope : scope.find("select.jhs-select-source"); - selects.each(((_, select) => new JhsSelect(select))); + selects.each(((/** @type {number} */ _, /** @type {HTMLSelectElement} */ select) => new JhsSelect(select))); return selects; } + /** @param {any} select */ static get(select) { const element = $(select)[0]; return element ? JhsSelect.instances.get(element) || new JhsSelect(element) : null; } + /** @param {any} select @param {any} value @param {boolean} [emit] */ static setValue(select, value, emit = !1) { const instance = JhsSelect.get(select); if (!instance) return; instance.source.val(value), emit ? instance.emitChange() : instance.refresh(); } + /** @param {any} select */ static refresh(select) { JhsSelect.get(select)?.refresh(); } + /** @param {any} [root] */ static refreshAll(root = document) { - JhsSelect.enhance(root), $(root).find("select.jhs-select-source").each(((_, select) => JhsSelect.refresh(select))); + JhsSelect.enhance(root), $(root).find("select.jhs-select-source").each(((/** @type {number} */ _, /** @type {HTMLSelectElement} */ select) => JhsSelect.refresh(select))); } + /** @param {any} select @param {boolean} visible */ static setVisible(select, visible) { const instance = JhsSelect.get(select); instance?.control.toggleClass("jhs-is-hidden", !visible); } + /** @param {JhsSelect | null} [except] */ static closeAll(except = null) { - $(".jhs-select-control.is-open").each(((_, control) => { + $(".jhs-select-control.is-open").each(((/** @type {number} */ _, /** @type {HTMLElement} */ control) => { const source = $(control).children("select.jhs-select-source")[0], instance = source && JhsSelect.instances.get(source); instance && instance !== except && instance.close(); })); } render() { this.menu.empty(); - const appendOption = (option, target) => { + const appendOption = (/** @type {HTMLOptionElement} */ option, /** @type {any} */ target) => { const button = $(''); button.attr({ "data-value": option.value, "aria-checked": option.selected ? "true" : "false" }).prop("disabled", option.disabled).text(option.text), target.append(button); }; - this.source.children().each(((_, child) => { + this.source.children().each(((/** @type {number} */ _, /** @type {HTMLOptionElement | HTMLOptGroupElement} */ child) => { if ("OPTGROUP" === child.tagName) { const group = $('
    ').attr("aria-label", child.label), label = $('
    ').text(child.label); - group.append(label), $(child).children("option").each(((_, option) => appendOption(option, group))), this.menu.append(group); - } else "OPTION" === child.tagName && appendOption(child, this.menu); + group.append(label), $(child).children("option").each(((/** @type {number} */ _, /** @type {HTMLOptionElement} */ option) => appendOption(option, group))), this.menu.append(group); + } else "OPTION" === child.tagName && appendOption(/** @type {HTMLOptionElement} */ (child), this.menu); })); } bind() { - this.trigger.on("click", (event => { + this.trigger.on("click", ((/** @type {any} */ event) => { event.preventDefault(), event.stopPropagation(), this.source.prop("disabled") || (this.control.hasClass("is-open") ? this.close() : this.open()); - })).on("keydown", (event => { + })).on("keydown", ((/** @type {any} */ event) => { if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; event.preventDefault(), this.open("ArrowUp" === event.key || "End" === event.key ? "last" : "selected"); })); - this.menu.on("click", ".jhs-select-option", (event => { + this.menu.on("click", ".jhs-select-option", ((/** @type {any} */ event) => { event.preventDefault(), this.choose($(event.currentTarget)); - })).on("keydown", ".jhs-select-option", (event => { + })).on("keydown", ".jhs-select-option", ((/** @type {any} */ event) => { const items = this.options(), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), this.close(!0); if ("Tab" === event.key) return void this.close(); @@ -1112,7 +1129,7 @@ export class JhsSelect { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - this.source.on("change.jhsSelect", (() => this.refresh())), $(document).on("click.jhsSelect", (event => { + this.source.on("change.jhsSelect", (() => this.refresh())), $(document).on("click.jhsSelect", ((/** @type {any} */ event) => { $(event.target).closest(this.control).length || this.close(); })); } @@ -1127,6 +1144,7 @@ export class JhsSelect { close(focus = !1) { this.control.removeClass("is-open"), this.menu.removeClass("is-open"), this.trigger.attr("aria-expanded", "false"), focus && this.trigger.trigger("focus"); } + /** @param {any} item */ choose(item) { if (item.prop("disabled")) return; this.source.val(item.attr("data-value")), this.emitChange(), this.close(!0); @@ -1140,6 +1158,6 @@ export class JhsSelect { refresh() { const selected = this.source.find("option:selected").first(), value = this.source.val(); this.trigger.find(".jhs-select-value").text(selected.text()), this.trigger.prop("disabled", this.source.prop("disabled")), - this.menu.find(".jhs-select-option").attr("aria-checked", "false").filter(((_, item) => $(item).attr("data-value") === String(value ?? ""))).attr("aria-checked", "true"); + this.menu.find(".jhs-select-option").attr("aria-checked", "false").filter(((/** @type {number} */ _, /** @type {HTMLElement} */ item) => $(item).attr("data-value") === String(value ?? ""))).attr("aria-checked", "true"); } } diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index b0792fb1..e5af0f17 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -37,7 +37,7 @@ "src/core/state-service.d.ts", "src/core/storage-index.js", "src/core/theme.js", - "src/core/ui-primitives.d.ts", + "src/core/ui-primitives.js", "src/features/**/*.js", "src/integrations/**/*.js", "src/platform/**/*.js", From e996b2f561c38c25721bc099d779c8c5645789c4 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:19:56 +0800 Subject: [PATCH 052/130] =?UTF-8?q?Chore:=20=E5=A2=9E=E5=8A=A0=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E5=8F=91=E5=B8=83=20Smoke=20=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 新增 Tampermonkey 人工 Smoke 记录契约和校验脚本 - 从 6.5.0 起校验实际浏览器、Tampermonkey、六项真实页面结果及 UserScript SHA256 - 将发布 Smoke 校验接入 npm run check 和稳定发布回归审计 - 补充版本阈值、完整记录、缺项和产物不匹配测试 - 将 host-page-request 纳入 strict checkJs,覆盖率提升至 90.9% 修改原因: - 自动 Browser fixture 无法证明真实 UserScript sandbox、GM API、@require 和宿主 CSS - 发布硬门禁需要可审计且与候选产物精确绑定的人工验证证据 验证: - npm run check 通过(117 个测试文件、615 项测试) - npm run check:browser 通过(本机 Edge,15 passed、15 skipped) - git diff --check 通过 - codegraph sync 通过 - 根目录与 dist UserScript SHA256 一致 --- CHANGELOG.md | 1 + .../adr/012-browser-release-gate.md | 4 +- docs/release/manual-smoke.md | 45 +++++++++++ package.json | 3 +- scripts/manual-smoke-check.mjs | 78 +++++++++++++++++++ scripts/regression-check.mjs | 1 + tests/manual-smoke-check.test.js | 36 +++++++++ tsconfig.architecture.json | 1 + 8 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 docs/release/manual-smoke.md create mode 100644 scripts/manual-smoke-check.mjs create mode 100644 tests/manual-smoke-check.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e48c73ff..b514f984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - DMM 预览、备份文件操作和设置诊断面板进入真实 strict checkJs,移除遮蔽生产实现的同名声明文件;生产源码类型覆盖率提升至 89.2%,最低覆盖门禁同步上调至 89%。 - PluginManager 与 BasePlugin 的真实运行时实现进入 strict checkJs,移除宽泛的同名声明文件,并显式约束插件注册、启动时序、错误诊断、宿主信息读取与共享图标状态;生产源码类型覆盖率提升至 89.8%。 - UI primitive 与 JhsSelect 的真实实现进入 strict checkJs,移除同名宽泛声明文件并明确动态容器、原生 Select、可访问性 Observer 和事件边界;生产源码类型覆盖率提升至 90.3%,最低覆盖门禁同步上调至 90%。 +- RC 发布门禁新增 Tampermonkey 人工 Smoke 记录校验;从 6.5.0 起必须记录实际 Edge/Chrome、Tampermonkey、六项真实页面检查和精确 UserScript SHA256,缺失或产物不匹配会阻止发布。 ### Fixed diff --git a/docs/architecture/adr/012-browser-release-gate.md b/docs/architecture/adr/012-browser-release-gate.md index 0467b711..fa6f05fd 100644 --- a/docs/architecture/adr/012-browser-release-gate.md +++ b/docs/architecture/adr/012-browser-release-gate.md @@ -2,4 +2,6 @@ 状态:Accepted -Playwright 位于 Node 22+ 独立子项目,不进入根项目依赖或默认本地 check。本地只驱动已安装的 msedge(可切 chrome),不得自动下载;CI ephemeral runner 只安装 Chromium。Harness 以真实 host URL 配合本地 `route.fulfill`,其他网络全部 abort。自动 smoke 不替代 Tampermonkey 人工发布 Gate。 +Playwright 位于 Node 22+ 独立子项目,不进入根项目依赖或默认本地 check。本地只驱动已安装的 msedge(可切 chrome),不得自动下载;CI ephemeral runner 只安装 Chromium。Harness 以真实 host URL 配合本地 `route.fulfill`,其他网络全部 abort。 + +自动 smoke 不替代 Tampermonkey 人工发布 Gate。从 6.5.0 起,候选产物必须按 [人工 Smoke 记录契约](../../release/manual-smoke.md) 保存实际浏览器、Tampermonkey、真实页面结果和 UserScript SHA256;发布检查会拒绝缺失、失败或产物哈希不匹配的记录。 diff --git a/docs/release/manual-smoke.md b/docs/release/manual-smoke.md new file mode 100644 index 00000000..86875e84 --- /dev/null +++ b/docs/release/manual-smoke.md @@ -0,0 +1,45 @@ +# Tampermonkey 人工 Smoke 记录 + +从 v6.5.0 开始,正式候选产物必须在 Edge 或 Chrome 中通过真实 Tampermonkey Smoke。自动 Playwright fixture 不能代替这项检查。 + +测试完成后创建 `docs/release/manual-smoke-v.json`,记录实际环境和被测试的 `JHS.user.js` SHA256。不得提交占位或推测结果。 + +```json +{ + "version": "6.5.0", + "testedAt": "2026-08-25T12:00:00.000Z", + "tester": "实际测试人", + "browser": { + "channel": "msedge", + "version": "实际浏览器版本" + }, + "userscriptManager": { + "name": "Tampermonkey", + "version": "实际扩展版本" + }, + "artifact": { + "sha256": "JHS.user.js 的小写 SHA256" + }, + "checks": { + "javdb.list": true, + "javdb.detail": true, + "javbus.list": true, + "javbus.detail": true, + "fc2.ownedDetail": true, + "mobile.realViewport": true + }, + "notes": "可选的异常、账号状态或环境说明" +} +``` + +运行: + +```bash +npm run check:release-smoke +``` + +6.4.x 开发基线会明确跳过;版本达到 6.5.0 后,缺少记录、环境字段、任一检查结果或产物哈希不匹配都会失败。需要在版本冻结前预演时,可执行: + +```bash +node scripts/manual-smoke-check.mjs --require --record <记录文件> +``` diff --git a/package.json b/package.json index b37d8d1b..a9deada0 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "check:architecture": "node scripts/architecture-check.mjs", "check:integrations": "node scripts/integration-quality-check.mjs", "check:performance": "node scripts/performance-budget-check.mjs", - "check": "npm run test && npm run build && npm run check:js && npm run check:architecture && npm run check:integrations && npm run check:performance && node scripts/check-sources.mjs && node scripts/release-contract.mjs && node scripts/regression-check.mjs && node scripts/contrast-audit.mjs && node scripts/ui-consistency-audit.mjs && node --check JHS.user.js && node --check dist/JHS.user.js", + "check:release-smoke": "node scripts/manual-smoke-check.mjs", + "check": "npm run test && npm run build && npm run check:js && npm run check:architecture && npm run check:integrations && npm run check:performance && node scripts/check-sources.mjs && node scripts/release-contract.mjs && npm run check:release-smoke && node scripts/regression-check.mjs && node scripts/contrast-audit.mjs && node scripts/ui-consistency-audit.mjs && node --check JHS.user.js && node --check dist/JHS.user.js", "check:browser": "npm --prefix tests/browser test", "check:full": "npm run check && npm run check:browser" }, diff --git a/scripts/manual-smoke-check.mjs b/scripts/manual-smoke-check.mjs new file mode 100644 index 00000000..7a0f1475 --- /dev/null +++ b/scripts/manual-smoke-check.mjs @@ -0,0 +1,78 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +export const REQUIRED_MANUAL_SMOKE_CHECKS = Object.freeze([ + "javdb.list", + "javdb.detail", + "javbus.list", + "javbus.detail", + "fc2.ownedDetail", + "mobile.realViewport", +]); + +function assert(condition, message) { + if (!condition) throw new Error(`Manual smoke gate failed: ${message}`); +} + +export function isVersionAtLeast(version, minimum) { + const parse = (value) => String(value).split(".").map((part) => Number(part)); + const left = parse(version), right = parse(minimum); + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const difference = (left[index] || 0) - (right[index] || 0); + if (difference) return difference > 0; + } + return true; +} + +/** @param {Buffer | string} value */ +export function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +/** + * Validate a human-created Tampermonkey smoke record against the exact built artifact. + * @param {unknown} value + * @param {{ version: string, artifactSha256: string }} expected + */ +export function validateManualSmokeRecord(value, expected) { + const record = value && "object" === typeof value ? /** @type {Record} */ (value) : {}; + assert(record.version === expected.version, `record version must equal ${expected.version}`); + assert(Number.isFinite(Date.parse(record.testedAt)), "testedAt must be an ISO date-time"); + assert("string" === typeof record.tester && record.tester.trim().length > 0, "tester is required"); + assert(["msedge", "chrome"].includes(record.browser?.channel), "browser.channel must be msedge or chrome"); + assert("string" === typeof record.browser?.version && record.browser.version.trim().length > 0, "browser.version is required"); + assert("tampermonkey" === String(record.userscriptManager?.name || "").toLowerCase(), "userscriptManager.name must be Tampermonkey"); + assert("string" === typeof record.userscriptManager?.version && record.userscriptManager.version.trim().length > 0, "userscriptManager.version is required"); + assert(record.artifact?.sha256 === expected.artifactSha256, "artifact.sha256 must match JHS.user.js"); + for (const check of REQUIRED_MANUAL_SMOKE_CHECKS) assert(record.checks?.[check] === true, `checks.${check} must be true`); + return record; +} + +async function main() { + const args = new Set(process.argv.slice(2)); + const packageJson = JSON.parse(await readFile(path.join(rootDir, "package.json"), "utf8")); + const required = args.has("--require") || isVersionAtLeast(packageJson.version, "6.5.0"); + if (!required) { + console.log(`Manual Tampermonkey smoke gate skipped for ${packageJson.version}; required from 6.5.0`); + return; + } + const recordArgIndex = process.argv.indexOf("--record"); + const recordPath = recordArgIndex >= 0 + ? path.resolve(process.argv[recordArgIndex + 1] || "") + : path.join(rootDir, "docs", "release", `manual-smoke-v${packageJson.version}.json`); + const artifact = await readFile(path.join(rootDir, "JHS.user.js")); + const record = JSON.parse(await readFile(recordPath, "utf8")); + validateManualSmokeRecord(record, { version: packageJson.version, artifactSha256: sha256(artifact) }); + console.log(`Manual Tampermonkey smoke gate passed for ${packageJson.version}: ${path.relative(rootDir, recordPath)}`); +} + +if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index 81e6abbc..5b72503f 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -120,6 +120,7 @@ assertIncludes(ciWorkflow, "needs: [node20, check, browser-smoke]", "release che assertIncludes(ciWorkflow, "contents: write", "release write permission"); assertIncludes(ciWorkflow, "--base-ref", "version-change release detection"); assertIncludes(ciWorkflow, "github.ref == 'refs/heads/main'", "release restricted to main pushes"); +assertIncludes(packageJson.scripts.check, "check:release-smoke", "manual Tampermonkey release smoke gate"); assertIncludes(ciWorkflow, "queue: max", "release concurrency queue"); assertIncludes(ciWorkflow, "cancel-in-progress: false", "release concurrency preservation"); assertIncludes(ciWorkflow, "git tag -a", "annotated release tag"); diff --git a/tests/manual-smoke-check.test.js b/tests/manual-smoke-check.test.js new file mode 100644 index 00000000..9d291b66 --- /dev/null +++ b/tests/manual-smoke-check.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { REQUIRED_MANUAL_SMOKE_CHECKS, isVersionAtLeast, validateManualSmokeRecord } from "../scripts/manual-smoke-check.mjs"; + +function createRecord() { + return { + version: "6.5.0", + testedAt: "2026-08-25T12:00:00.000Z", + tester: "release tester", + browser: { channel: "msedge", version: "151.0" }, + userscriptManager: { name: "Tampermonkey", version: "5.4.0" }, + artifact: { sha256: "abc123" }, + checks: Object.fromEntries(REQUIRED_MANUAL_SMOKE_CHECKS.map((check) => [check, true])), + }; +} + +describe("manual Tampermonkey smoke gate", () => { + it("requires the gate from 6.5.0 without treating later 6.4 patches as newer", () => { + expect(isVersionAtLeast("6.4.10", "6.5.0")).toBe(false); + expect(isVersionAtLeast("6.5.0", "6.5.0")).toBe(true); + expect(isVersionAtLeast("7.0.0", "6.5.0")).toBe(true); + }); + + it("accepts a complete record for the exact artifact", () => { + expect(validateManualSmokeRecord(createRecord(), { version: "6.5.0", artifactSha256: "abc123" }).tester).toBe("release tester"); + }); + + it("rejects a record for a different artifact", () => { + expect(() => validateManualSmokeRecord(createRecord(), { version: "6.5.0", artifactSha256: "different" })).toThrow(/artifact\.sha256/); + }); + + it("rejects any omitted real-page smoke item", () => { + const record = createRecord(); + record.checks["javbus.detail"] = false; + expect(() => validateManualSmokeRecord(record, { version: "6.5.0", artifactSha256: "abc123" })).toThrow(/javbus\.detail/); + }); +}); diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index e5af0f17..4fd4a69a 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -23,6 +23,7 @@ "src/core/detail-state-controller.js", "src/core/event-bus.js", "src/core/feature-helpers.js", + "src/core/host-page-request.js", "src/core/javdb-api.js", "src/core/legacy-plugin-contributions.js", "src/core/lifecycle-scope.js", From 6cbba142653b548dedc1f30ed786179f3898c922 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:37:15 +0800 Subject: [PATCH 053/130] =?UTF-8?q?Refactor:=20=E6=94=B6=E6=95=9B=20legacy?= =?UTF-8?q?=20=E8=BF=90=E8=A1=8C=E6=97=B6=E5=AE=9E=E4=BE=8B=E5=88=9B?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将 Utils、StorageManager、GmHttp 与 StateService 的实例创建迁至 Bootstrap Composition Root - 为使用作品状态的 Contribution 显式声明 SERVICE.state,并移除插件对状态单例的直接导入 - 让详情状态控制器按声明依赖延迟创建,并补充禁止 Feature 直连状态单例的架构门禁 - 更新 VM 回归夹具、类型声明、CHANGELOG 与构建产物 修改原因: - 避免模块导入阶段创建运行时 singleton,落实 Bootstrap Purity 和严格 DI - 保持 Compatibility Facade 与 legacy Storage 兼容接口不变 验证: - npm run check 通过(117 个测试文件、615 项测试) - npm run check:browser 通过(本机 Edge,15 通过、15 按配置跳过) - git diff --check 与 codegraph sync 通过 - 根目录和 dist UserScript SHA256 一致 --- CHANGELOG.md | 1 + JHS.user.js | 1713 +++++++++-------- scripts/architecture-check.mjs | 2 + scripts/regression-check.mjs | 3 +- src/app/bootstrap.js | 27 +- src/app/create-app-context.js | 5 +- src/core/detail-state-controller.js | 11 +- src/core/http.js | 17 +- src/core/legacy-runtime.js | 19 + src/core/state-service.d.ts | 3 +- src/core/state-service.js | 8 +- src/plugins/backup/setting.js | 3 +- src/plugins/blacklist/blacklist.js | 5 +- src/plugins/external-search/fc2.js | 12 +- src/plugins/image-viewer/cover-button.js | 3 +- src/plugins/new-video/new-video.js | 13 +- src/plugins/offline/unified-offline.js | 7 +- src/plugins/registry.js | 25 +- src/plugins/stats/stats.js | 7 +- src/plugins/status/compat-enhancements.js | 3 +- src/plugins/status/detail-page-button.js | 21 +- src/plugins/status/history.js | 6 +- src/plugins/status/list-page.js | 5 +- src/plugins/status/want-and-watched-videos.js | 3 +- tests/architecture-runtime.test.js | 5 +- tests/compat-enhancements.test.js | 2 +- tests/detail-state-controller.test.js | 4 +- tests/history-multistate.test.js | 6 +- tests/history-selection.test.js | 4 +- tests/http-request-options.test.js | 6 +- tests/javdb-status-import.test.js | 2 +- tests/new-video-workspace.test.js | 1 + tests/offline-provider.test.js | 4 +- tests/startup-performance.test.js | 6 +- tests/state-service.test.js | 2 +- tests/stats-scope.test.js | 3 +- 36 files changed, 1011 insertions(+), 956 deletions(-) create mode 100644 src/core/legacy-runtime.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b514f984..a34d8c15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ ### Changed +- Utils、Storage、GM HTTP 与 StateService 的 legacy 实例改由 Bootstrap Composition Root 在 Vendor/GM 运行时验证后统一创建;Feature 通过 manifest 显式声明 `SERVICE.state`,不再直接导入模块级状态单例。 - 新增可单测的 Bootstrap Purity 架构门禁,禁止除 Composition Root 外的生产模块在导入阶段启动 DOM、网络、Storage、监听器、Observer 或定时器;原隐藏导航样式改为 Bootstrap 注入时才读取地址。 - strict checkJs 继续覆盖自动翻页、设置样式/模板和 123AV-FC2 入口,并补齐分页空值、DOM 容器与 Integration 返回数据的显式契约。 - 截图、宿主详情工作区、兼容增强与统一离线模块进入 strict checkJs;Provider availability、宿主资源边界、Observer 和 jQuery 兼容句柄均补充显式类型,覆盖率门禁同步上调至 84%。 diff --git a/JHS.user.js b/JHS.user.js index fab0b22f..afe4136a 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -2547,432 +2547,444 @@ __publicField(_BasePlugin, "_sharedIcons", null); var BasePlugin = _BasePlugin; - // src/core/event-bus.js - var _JhsEventBus = class _JhsEventBus { - constructor(channelName = "channel-refresh") { - this.originId = globalThis.crypto?.randomUUID?.() || `tab_${Date.now()}_${Math.random().toString(36).slice(2)}`; - this.listeners = /* @__PURE__ */ new Map(), this.seen = /* @__PURE__ */ new Set(), this.channel = new BroadcastChannel(channelName); - this.channel.addEventListener("message", ((event) => this._receive(event.data))); + // src/core/http.js + var _GmHttp = class _GmHttp { + constructor({ utils: utils2, storageManager: storageManager2 }) { + this.utils = utils2; + this.storageManager = storageManager2; + this._circuitBreakers = /* @__PURE__ */ new Map(); + this._domainStats = /* @__PURE__ */ new Map(); } - on(type, handler) { - const handlers = this.listeners.get(type) || /* @__PURE__ */ new Set(); - return handlers.add(handler), this.listeners.set(type, handlers), () => handlers.delete(handler); + _getDomain(e2) { + try { + return new URL(e2).hostname; + } catch { + return "unknown"; + } } - async _dispatch(event) { - for (const handler of [...this.listeners.get(event.type) || []]) await handler(event.payload, event); + _isCloudflareChallenge(e2, status = 0) { + if ("string" != typeof e2 || !e2) return false; + const text = e2.toLowerCase(); + const hasChallengeTitle = /]*>\s*just a moment(?:\.\.\.)?\s*<\/title>/i.test(e2); + const hasChallengeForm = /id=["']challenge-form["']/i.test(e2); + const hasCfChl = text.includes("cf-chl-") || text.includes("cf_chl_opt"); + const hasChallengePlatform = text.includes("/cdn-cgi/challenge-platform/") || text.includes("challenge-platform"); + const blockedStatus = 403 === status || 429 === status || 503 === status; + return hasChallengeTitle || hasChallengeForm && (hasCfChl || hasChallengePlatform) || blockedStatus && hasCfChl && hasChallengePlatform; } - _remember(eventId) { - this.seen.add(eventId), this.seen.size > 256 && this.seen.delete(this.seen.values().next().value); + _checkCircuitBreaker(e2) { + const t2 = this._circuitBreakers.get(e2); + if (!t2) return null; + if ("open" === t2.state) { + const n2 = Date.now() - t2.openTime; + return n2 < t2.cooldownMs ? { state: "open", remaining: Math.ceil((t2.cooldownMs - n2) / 1e3) } : (t2.state = "half-open", t2.failCount = 0, t2.probing = false, null); + } + if ("half-open" === t2.state && t2.probing) return { state: "half-open", remaining: 0 }; + return null; } - async emit(type, payload = {}, options = {}) { - const event = { eventId: globalThis.crypto?.randomUUID?.() || `event_${Date.now()}_${Math.random().toString(36).slice(2)}`, originId: this.originId, type, payload, timestamp: Date.now() }; - this._remember(event.eventId), await this._dispatch(event), false !== options.broadcast && this.channel.postMessage(event); - return event; + isDomainCircuitBroken(e2) { + return this._checkCircuitBreaker(this._getDomain(e2)); } - async _receive(event) { - if (!event || event.originId === this.originId || event.eventId && this.seen.has(event.eventId)) return; - if (!event.eventId) { - const legacyType = "refresh" === event.type ? "legacy-refresh" : event.type; - return this._dispatch({ ...event, type: legacyType, payload: event.payload || {}, eventId: `legacy_${Date.now()}_${Math.random()}`, originId: "legacy", timestamp: Date.now() }); - } - this._remember(event.eventId), await this._dispatch(event); + /* domainStats.count 统计请求数(含重试),非独立请求数 */ + _recordSuccess(e2) { + let t2 = this._circuitBreakers.get(e2); + t2 && (t2.state = "closed", t2.failCount = 0, t2.probing = false); + let n2 = this._domainStats.get(e2); + n2 || (n2 = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e2, n2)), n2.count++, n2.lastUsed = Date.now(); } - }; - __name(_JhsEventBus, "JhsEventBus"); - var JhsEventBus = _JhsEventBus; - var jhsEventBus; - function initializeEventBus() { - if (jhsEventBus) return jhsEventBus; - jhsEventBus = new JhsEventBus(); - const runtimeWindow = window; - runtimeWindow.refresh = () => jhsEventBus?.emit("legacy-refresh"); - runtimeWindow.cleanCache_filter_actor_actress_car_list = () => jhsEventBus?.emit("blacklist-rules-changed"); - runtimeWindow.clean_cacheSettingObj = () => jhsEventBus?.emit("settings-changed"); - return jhsEventBus; - } - __name(initializeEventBus, "initializeEventBus"); - - // src/core/utils.js - var _Utils = class _Utils { - constructor() { - return i(this, "intervalContainer", {}), i(this, "waitSequence", 0), i(this, "mimeTypes", { - txt: "text/plain", - html: "text/html", - css: "text/css", - csv: "text/csv", - json: "application/json", - xml: "application/xml", - jpg: "image/jpeg", - jpeg: "image/jpeg", - png: "image/png", - gif: "image/gif", - webp: "image/webp", - svg: "image/svg+xml", - pdf: "application/pdf", - doc: "application/msword", - docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - xls: "application/vnd.ms-excel", - xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ppt: "application/vnd.ms-powerpoint", - pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", - zip: "application/zip", - rar: "application/x-rar-compressed", - "7z": "application/x-7z-compressed", - mp3: "audio/mpeg", - wav: "audio/wav", - mp4: "video/mp4", - webm: "video/webm", - ogg: "audio/ogg" - }), i(this, "timers", /* @__PURE__ */ new Map()), i(this, "insertStyle", ((e2) => { - const t2 = (Array.isArray(e2) ? e2 : [e2]).filter(Boolean); - if (0 === t2.length) return; - const n2 = t2.map(((e3) => e3.replace(/^\s*]*>/i, "").replace(/<\/style>?\s*$/i, ""))).filter(Boolean).join("\n"), a2 = document.createElement("style"); - n2 && (a2.textContent = n2, document.head.append(a2)); - })), i(this, "layerIndexStack", []), _Utils.instance || (_Utils.instance = this), _Utils.instance; + _recordFailure(e2) { + let t2 = this._circuitBreakers.get(e2); + t2 || (t2 = { state: "closed", failCount: 0, openTime: 0, cooldownMs: 6e4, threshold: 3 }, this._circuitBreakers.set(e2, t2)), t2.failCount++, ("half-open" === t2.state || t2.failCount >= (t2.threshold || 3)) && (t2.state = "open", t2.openTime = Date.now(), t2.probing = false, clog.warn(`[熔断] ${e2} 连续失败 ${t2.failCount} 次,已熔断 ${t2.cooldownMs / 1e3} 秒`)); + let n2 = this._domainStats.get(e2); + n2 || (n2 = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e2, n2)), n2.count++, n2.errors++, n2.lastUsed = Date.now(); } - importResource(e2) { - let t2; - e2.indexOf("css") >= 0 ? (t2 = document.createElement("link"), t2.setAttribute("rel", "stylesheet"), t2.href = e2) : (t2 = document.createElement("script"), t2.setAttribute("type", "text/javascript"), t2.src = e2), document.documentElement.appendChild(t2); + getCircuitBreakerStatus() { + const e2 = {}; + return this._circuitBreakers.forEach(((t2, n2) => { + e2[n2] = { ...t2 }; + })), e2; } - openPage(e2, t2, n2, a2) { - n2 = n2 ?? true; - const navigation = a2 && (Object.prototype.hasOwnProperty.call(a2, "event") || Object.prototype.hasOwnProperty.call(a2, "newTab")) ? a2 : { event: a2 }, event = navigation.event; - const destination = new URL(e2, window.location.origin), carNum = normalizeCarNum(t2), isMovieDetail = /^\/v\/[^/]+/.test(destination.pathname); - isMovieDetail && carNum && destination.searchParams.set("jhsCarNum", carNum); - if (navigation.newTab || event && (event.ctrlKey || event.metaKey || 1 === event.button)) return void GM_openInTab(destination.href, { - insert: 0 - }); - destination.pathname.includes("/actors/") || destination.pathname.includes("/star/") || destination.searchParams.set("hideNav", "1"); - layer.open({ - type: 2, - title: t2, - content: destination.href, - scrollbar: false, - shadeClose: n2, - area: this.getDialogArea("workspace"), - isOutAnim: false, - anim: -1, - success: /* @__PURE__ */ __name((e3, t3) => { - this.setupEscClose(t3); - }, "success") - }); + resetCircuitBreaker(e2) { + this._circuitBreakers.delete(e2); } - _handleGlobalEscKey(e2) { - if ("Escape" !== e2.key && 27 !== e2.keyCode) return; - if (0 === this.layerIndexStack.length) return; - const t2 = this.layerIndexStack[this.layerIndexStack.length - 1], n2 = $(`#layui-layer${t2}`); - let a2 = false; - if (n2.find(".viewer-container").length > 0) a2 = true; - else { - const e3 = n2.find(`#layui-layer-iframe${t2}`)[0]; - if (e3 && e3.contentDocument) try { - $(e3.contentDocument).find(".viewer-container").length > 0 && (a2 = true); - } catch (i2) { - clog.warn("无法检查跨域 iframe 内的 .viewer-container"); - } - } - a2 || (this.layerIndexStack.pop(), layer.close(t2)); + resetAllCircuitBreakers() { + this._circuitBreakers.clear(); } - setupEscClose(e2) { - var t2; - this._boundHandler || (this._boundHandler = this._handleGlobalEscKey.bind(this), $(document).off("keydown.globalLayerEsc"), $(document).on("keydown.globalLayerEsc", this._boundHandler)), -1 === this.layerIndexStack.indexOf(e2) && this.layerIndexStack.push(e2); - const n2 = $(`#layui-layer-iframe${e2}`), a2 = `keydown.layerEsc${e2}`; - try { - const e3 = null == (t2 = n2[0]) ? void 0 : t2.contentDocument; - if (e3) { - if ("yes" === n2.attr("data-esc-bound")) return; - $(e3).off(a2), $(e3).on(a2, this._boundHandler), n2.attr("data-esc-bound", "yes"); - } - } catch (i2) { - clog.error("iframe监听失败 (跨域或未加载完毕):", i2); - } + getDomainStats() { + const e2 = {}; + return this._domainStats.forEach(((t2, n2) => { + e2[n2] = { ...t2 }; + })), e2; } - async closePage(options = {}) { - if ("yes" !== await storageManager.getSetting("needClosePage", "yes")) return false; - const root = options?.root, parseIndex = /* @__PURE__ */ __name((element) => { - const id = element?.id || "", match = /^layui-layer(\d+)$/.exec(id); - return match ? Number(match[1]) : null; - }, "parseIndex"); - let layerIndex = Number.isInteger(options?.layerIndex) ? options.layerIndex : null; - if (null === layerIndex && root) { - const element = root.jquery ? root[0] : root.nodeType ? root : null, layerElement = element?.matches?.(".layui-layer") ? element : element?.closest?.(".layui-layer"); - layerIndex = parseIndex(layerElement); - } - if (null === layerIndex && window.frameElement) layerIndex = parseIndex(window.frameElement.closest?.(".layui-layer")); - const ownerWindow = window.parent && window.parent !== window ? window.parent : window, ownerLayer = ownerWindow.layer || globalThis.layer; - if (null !== layerIndex && "function" == typeof ownerLayer?.close) return ownerLayer.close(layerIndex), true; - if (window.opener && !window.opener.closed) return window.close(), true; - return false; + clearDomainStats() { + this._domainStats.clear(); } - loopDetector(e2, t2, n2 = 20, a2 = 1e4, i2 = true, scope = null) { - const s2 = ++this.waitSequence; - let o2 = null, r2 = null, l2 = null, c2 = false; - const d2 = /* @__PURE__ */ __name(() => { - o2 && (scope?.releaseObserver ? scope.releaseObserver(o2) : o2.disconnect()), clearTimeout(r2), clearTimeout(l2), clearInterval(this.intervalContainer[s2]?.fallback), delete this.intervalContainer[s2]; - }, "d"), h2 = /* @__PURE__ */ __name((e3) => { - if (c2) return; - c2 = true, d2(), e3 && t2 && t2(); - }, "h"), g2 = /* @__PURE__ */ __name(() => { - if (c2) return; - e2() && h2(true); - }, "g"), p2 = /* @__PURE__ */ __name(() => { - c2 || (clearTimeout(r2), r2 = setTimeout(g2, Math.max(0, n2))); - }, "p"); - const cancel = /* @__PURE__ */ __name(() => { - c2 = true, d2(); - }, "cancel"); - this.intervalContainer[s2] = {}; - if (e2()) return h2(true), cancel; - if (scope?.observe && document.documentElement) o2 = scope.observe(document.documentElement, p2, { childList: true, subtree: true, characterData: true }); - else this.intervalContainer[s2].fallback = setInterval(g2, Math.max(100, n2)); - l2 = setTimeout((() => { - if (c2) return; - let t3 = false; - try { - t3 = e2(); - } catch (e3) { - clog.error("DOM 等待条件执行失败", e3); - } - h2(t3 || i2); - }), Math.max(0, a2)); - return cancel; + async get(e2, t2 = {}, n2 = {}, a2, i2 = {}) { + return this.gmRequest("GET", e2, null, t2, n2, a2, i2); } - rightClick(e2, t2, n2) { - let a2; - "string" == typeof e2 ? a2 = document.querySelector(e2) : e2 instanceof HTMLElement && (a2 = e2), a2 || (clog.warn("rightClick(), 容器无效或未提供,将使用 document.body 进行全局委托。"), a2 = document.body), "string" == typeof t2 && "" !== t2.trim() ? a2.addEventListener("contextmenu", ((e3) => { - const a3 = e3.target.closest(t2); - a3 && n2(e3, a3); - })) : clog.error("rightClick(), 必须提供有效的 targetSelector。"); + post(e2, t2 = {}, n2 = {}) { + n2 = { + "Content-Type": "application/json", + ...n2 + }; + let a2 = JSON.stringify(t2); + return this.gmRequest("POST", e2, a2, null, n2); } - q(e2, t2, n2, a2) { - let o2; - if (this.isMobileMode()) { - o2 = layer.confirm(t2, { - title: "提示", - btn: ["确定", "取消"], - shade: 0, - zIndex: JHS_Z_INDEX.layer - }, (function() { - n2 && n2(), layer.close(o2); - }), (function() { - a2 && a2(); - })); - } else { - let i2, s2; - e2 ? (i2 = e2.clientX - 130, s2 = e2.clientY - 120) : (i2 = window.innerWidth / 2 - 120, s2 = window.innerHeight / 2 - 120); - o2 = layer.confirm(t2, { - offset: [s2, i2], - title: "提示", - btn: ["确定", "取消"], - shade: 0, - zIndex: JHS_Z_INDEX.layer - }, (function() { - n2 && n2(), layer.close(o2); - }), (function() { - a2 && a2(); - })); + async gmRequest(e2, t2, n2 = {}, a2 = {}, i2 = {}, s2 = false, requestOptions = {}) { + if (a2 && Object.keys(a2).length) { + const e3 = new URLSearchParams(a2).toString(); + t2 += (t2.includes("?") ? "&" : "?") + e3; } - } - getNowStr(e2 = "-", t2 = ":", n2 = null) { - let a2; - a2 = n2 ? new Date(n2) : /* @__PURE__ */ new Date(); - const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); - return `${[i2, s2, o2].join(e2)} ${[r2, l2, c2].join(t2)}`; - } - formatDate(e2, t2 = "-", n2 = ":") { - let a2; - if (e2 instanceof Date) a2 = e2; - else { - if ("string" != typeof e2) throw new Error("Invalid date input: must be Date object or date string"); - if (a2 = new Date(e2), isNaN(a2.getTime())) throw new Error("Invalid date string"); + const o2 = this._getDomain(t2), [m2, r2, b2, k2] = await Promise.all([this.storageManager.getSetting("httpTimeout", 5e3), this.storageManager.getSetting("httpRetryCount", 3), this.storageManager.getSetting("circuitBreakerThreshold", 3), this.storageManager.getSetting("circuitBreakerCooldown", 6e4)]); + let u2 = this._circuitBreakers.get(o2); + u2 || (u2 = { state: "closed", failCount: 0, openTime: 0, cooldownMs: k2, threshold: b2, probing: false }, this._circuitBreakers.set(o2, u2)); + const w = this._checkCircuitBreaker(o2); + if (w) { + const e3 = new Error(`站点 ${o2} 已熔断,${w.remaining}秒后重试`); + throw e3._circuitBroken = true, e3; } - const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); - return `${[i2, s2, o2].join(t2)} ${[r2, l2, c2].join(n2)}`; + return n2 || (n2 = void 0), await this.utils.retry(() => { + const c2 = this._checkCircuitBreaker(o2); + if (c2) { + const t3 = new Error(`站点 ${o2} 已熔断,${c2.remaining}秒后重试`); + return t3._circuitBroken = true, Promise.reject(t3); + } + "half-open" === u2.state && (u2.probing = true); + return new Promise(((a3, r3) => { + GM_xmlhttpRequest({ + method: e2, + url: t2, + headers: i2, + timeout: m2, + data: n2, + ...requestOptions.cookiePartitionTopLevelSite ? { + cookiePartition: { topLevelSite: requestOptions.cookiePartitionTopLevelSite } + } : {}, + onload: /* @__PURE__ */ __name((e3) => { + try { + if (404 === e3.status && requestOptions.ignoreNotFound) return void a3(null); + if (this._isCloudflareChallenge(e3.responseText, e3.status)) { + this._recordFailure(o2); + const n3 = new Error(`Cloudflare challenge blocked: ${t2}`); + return n3._cfBlocked = true, n3.status = e3.status, n3.requestUrl = t2, n3.finalUrl = e3.finalUrl, n3.cfDiagnostics = { status: e3.status, requestUrl: t2, finalUrl: e3.finalUrl, contentLength: e3.responseText?.length || 0 }, void r3(n3); + } + if (s2 && e3.finalUrl !== t2 && r3("请求被重定向了,URL是:" + e3.finalUrl), e3.status >= 200 && e3.status < 300) { + this._recordSuccess(o2); + if (e3.responseText) try { + a3(JSON.parse(e3.responseText)); + } catch (n3) { + a3(e3.responseText); + } + else a3(e3.responseText || e3); + } else { + clog.error("请求失败,状态码:", e3.status, t2), this._recordFailure(o2); + if (e3.responseText) { + try { + const t3 = JSON.parse(e3.responseText); + t3.status = e3.status, r3(t3); + } catch { + const t3 = new Error(e3.responseText || `请求发生错误 ${e3.status}`); + t3.status = e3.status, r3(t3); + } + } else { + const t3 = new Error(`请求发生错误 ${e3.status}`); + t3.status = e3.status, r3(t3); + } + } + } catch (n3) { + this._recordFailure(o2), r3(n3); + } + }, "onload"), + onerror: /* @__PURE__ */ __name((e3) => { + clog.error("网络错误:", t2), this._recordFailure(o2), r3(new Error(e3.error || "网络错误")); + }, "onerror"), + ontimeout: /* @__PURE__ */ __name(() => { + this._recordFailure(o2), r3(new Error("请求超时: " + t2)); + }, "ontimeout") + }); + })); + }, r2); } - getHourDifference(e2, t2) { - const n2 = e2.getTime(), a2 = t2.getTime(), i2 = Math.abs(a2 - n2) / 36e5; - return Math.floor(i2); + }; + __name(_GmHttp, "GmHttp"); + var GmHttp = _GmHttp; + + // src/core/state-service.js + var ACTIVITY_SOFT_LIMIT = 1e3; + var ACTIVITY_HARD_LIMIT = 1e4; + var ACTIVITY_RETENTION_MS = 30 * 864e5; + function cloneStateValue(value) { + return null == value ? value : JSON.parse(JSON.stringify(value)); + } + __name(cloneStateValue, "cloneStateValue"); + function stableStateValue(value) { + if (null === value || "object" != typeof value) return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableStateValue).join(",")}]`; + return `{${Object.keys(value).sort().map(((key) => `${JSON.stringify(key)}:${stableStateValue(value[key])}`)).join(",")}}`; + } + __name(stableStateValue, "stableStateValue"); + function getStatePath(value, path) { + return path.split(".").reduce(((current, key) => current?.[key]), value); + } + __name(getStatePath, "getStatePath"); + function setStatePath(value, path, next) { + const keys = path.split("."), last = keys.pop(), target = keys.reduce(((current, key) => current[key] || (current[key] = {})), value); + void 0 === next ? delete target[last] : target[last] = cloneStateValue(next); + } + __name(setStatePath, "setStatePath"); + function captureNewVideoEffect(actresses, decisions, carNum) { + const key = normalizeCarNum(carNum), actressItems = []; + actresses.forEach(((actress, actressIndex) => (actress.newVideoList || []).forEach(((item, itemIndex) => { + normalizeCarNum("string" == typeof item ? item : item.carNum) === key && actressItems.push({ actressIndex, itemIndex, item: cloneStateValue(item) }); + })))); + return { actressItems, decision: cloneStateValue(decisions[key] || null) }; + } + __name(captureNewVideoEffect, "captureNewVideoEffect"); + function canRestoreNewVideoEffect(actresses, decisions, carNum, effect) { + const key = normalizeCarNum(carNum); + if (stableStateValue(decisions[key] || null) !== stableStateValue(null)) return false; + return effect.actressItems.every(((entry) => !(actresses[entry.actressIndex]?.newVideoList || []).some(((item) => normalizeCarNum("string" == typeof item ? item : item.carNum) === key)))); + } + __name(canRestoreNewVideoEffect, "canRestoreNewVideoEffect"); + function restoreNewVideoEffect(actresses, decisions, carNum, effect) { + effect.actressItems.forEach(((entry) => { + const actress = actresses[entry.actressIndex]; + if (!actress) return; + const list = [...actress.newVideoList || []], index = Math.min(entry.itemIndex, list.length); + list.splice(index, 0, cloneStateValue(entry.item)), actress.newVideoList = list; + })); + effect.decision ? decisions[normalizeCarNum(carNum)] = cloneStateValue(effect.decision) : delete decisions[normalizeCarNum(carNum)]; + } + __name(restoreNewVideoEffect, "restoreNewVideoEffect"); + function pruneActivityLog(log, now = Date.now()) { + const result = { entries: Array.isArray(log?.entries) ? log.entries : [], trackingStartedAt: log?.trackingStartedAt || new Date(now).toISOString(), coverageStart: log?.coverageStart || null, truncatedAt: log?.truncatedAt || null }; + const cutoff = now - ACTIVITY_RETENTION_MS, recent = [], older = []; + result.entries.forEach(((entry) => (Date.parse(entry.createdAt) >= cutoff || "pending" === entry.commitState ? recent : older).push(entry))); + older.sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))), recent.sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))); + const olderAllowance = Math.max(0, ACTIVITY_SOFT_LIMIT - recent.length); + result.entries = [...older.slice(-olderAllowance), ...recent].sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))); + if (result.entries.length > ACTIVITY_HARD_LIMIT) { + const pending = result.entries.filter(((entry) => "pending" === entry.commitState)), committed = result.entries.filter(((entry) => "pending" !== entry.commitState)); + const committedAllowance = Math.max(0, ACTIVITY_HARD_LIMIT - pending.length); + result.entries = [...committedAllowance ? committed.slice(-committedAllowance) : [], ...pending].sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))); + result.truncatedAt = new Date(now).toISOString(), result.coverageStart = result.entries[0]?.createdAt || result.truncatedAt; } - download(e2, t2) { - show.info("开始请求下载..."); - const n2 = t2.split(".").pop().toLowerCase(); - let a2, i2 = this.mimeTypes[n2] || "application/octet-stream"; - if (e2 instanceof Blob) a2 = e2; - else if (e2 instanceof ArrayBuffer || ArrayBuffer.isView(e2)) a2 = new Blob([e2], { - type: i2 - }); - else if ("string" == typeof e2 && e2.startsWith("data:")) { - const t3 = atob(e2.split(",")[1]), n3 = new ArrayBuffer(t3.length), s3 = new Uint8Array(n3); - for (let e3 = 0; e3 < t3.length; e3++) s3[e3] = t3.charCodeAt(e3); - a2 = new Blob([s3], { - type: i2 - }); - } else a2 = new Blob([e2], { - type: i2 - }); - const s2 = URL.createObjectURL(a2), o2 = document.createElement("a"); - o2.href = s2, o2.download = t2, document.body.appendChild(o2), o2.click(), setTimeout((() => { - document.body.removeChild(o2), URL.revokeObjectURL(s2); - }), 100); + return result; + } + __name(pruneActivityLog, "pruneActivityLog"); + var _StateService = class _StateService { + constructor(storage, eventBus) { + this.storage = storage, this.eventBus = eventBus, this._queue = Promise.resolve(), this._recovering = false; } - smoothScrollToTop(e2 = 500) { - return new Promise(((t2) => { - const n2 = performance.now(), a2 = window.pageYOffset; - window.requestAnimationFrame(/* @__PURE__ */ __name((function i2(s2) { - const o2 = s2 - n2, r2 = Math.min(o2 / e2, 1), l2 = r2 < 0.5 ? 4 * r2 * r2 * r2 : 1 - Math.pow(-2 * r2 + 2, 3) / 2; - window.scrollTo(0, a2 * (1 - l2)), r2 < 1 ? window.requestAnimationFrame(i2) : t2(); - }), "i")); - })); + _withLock(callback) { + if (globalThis.navigator?.locks?.request) return navigator.locks.request("jhs_state_mutation", callback); + const run = this._queue.then(callback, callback); + return this._queue = run.catch((() => { + })), run; } - isUrl(e2) { - try { - return new URL(e2), true; - } catch (t2) { - return false; - } + async getActivityLog() { + return pruneActivityLog(await this.storage.forage.getItem("activity_log")); } - setHrefParam(e2, t2) { - const n2 = new URL(window.location.href); - n2.searchParams.set(e2, t2), window.history.pushState({}, "", n2.toString()); + async getOfflineHistory() { + return await this.storage.forage.getItem("offline_history") || []; } - getUrlParam(e2, t2) { - const n2 = e2.split("?")[1]; - if (!n2) return null; - const a2 = new RegExp(`(?:^|&)${t2}=([^&]*)`), i2 = n2.match(a2); - let s2 = ""; - return i2 && i2[1] && (s2 = decodeURIComponent(i2[1].replace(/\+/g, " "))), s2 ? "true" === s2 || "false" === s2 ? "true" === s2.toLowerCase() : "string" != typeof s2 || "" === s2.trim() || isNaN(Number(s2)) ? s2 : Number(s2) : s2; + async appendOfflineHistory(record) { + const history = await this.getOfflineHistory(), item = { id: record.id || globalThis.crypto?.randomUUID?.() || `offline_${Date.now()}`, createdAt: record.createdAt || (/* @__PURE__ */ new Date()).toISOString(), ...record, carNum: normalizeCarNum(record.carNum) }; + history.push(item), history.length > 1e3 && history.splice(0, history.length - 1e3), await this.storage.forage.setItem("offline_history", history), await this.eventBus.emit("offline-history-changed", { ids: [item.id] }); + return item; } - getResponsiveArea(e2) { - const t2 = window.innerWidth; - return this.isMobileMode() ? ["100%", "90%"] : t2 >= 1200 ? e2 || this.getDefaultArea() : ["70%", "90%"]; + async removeOfflineHistory(ids) { + const keys = new Set(Array.isArray(ids) ? ids : [ids]), history = await this.getOfflineHistory(), next = history.filter(((item) => !keys.has(item.id))); + if (next.length === history.length) return false; + return await this.storage.forage.setItem("offline_history", next), await this.eventBus.emit("offline-history-changed", { ids: [...keys], removed: true }), true; } - getDialogArea(e2 = "md") { - const t2 = { - sm: [480, 640], - md: [720, 700], - lg: [1040, 760], - xl: [1320, 860], - workspace: [1440, 960] - }, n2 = t2[e2] || t2.md, a2 = window.innerWidth <= 768 ? 16 : "workspace" === e2 ? 32 : 64, i2 = Math.max(320, Math.min(n2[0], window.innerWidth - a2)), s2 = Math.max(320, Math.min(n2[1], window.innerHeight - a2)); - return [`${i2}px`, `${s2}px`]; + async getNewVideoDecisions() { + return await this.storage.forage.getItem("new_video_decisions") || {}; } - getDefaultArea() { - return ["85%", "90%"]; + async _readDomains() { + const [carList, actresses, decisions, activity] = await Promise.all([this.storage.forage.getItem(this.storage.car_list_key), this.storage.forage.getItem(this.storage.favorite_actresses_key), this.storage.forage.getItem("new_video_decisions"), this.storage.forage.getItem("activity_log")]); + return { carList: carList || [], actresses: actresses || [], decisions: decisions || {}, activity: pruneActivityLog(activity) }; } - isMobile() { - const e2 = navigator.userAgent.toLowerCase(); - return ["iphone", "ipod", "ipad", "android", "blackberry", "windows phone", "nokia", "webos", "opera mini", "mobile", "mobi", "tablet"].some(((t2) => e2.includes(t2))); + _removeHandledNewVideos(actresses, decisions, carNums) { + const keys = new Set(carNums.map(normalizeCarNum).filter(Boolean)), nextDecisions = { ...decisions }; + keys.forEach(((key) => delete nextDecisions[key])); + const nextActresses = actresses.map(((actress) => { + if (!Array.isArray(actress.newVideoList)) return actress; + const newVideoList = actress.newVideoList.filter(((item) => !keys.has(normalizeCarNum("string" == typeof item ? item : item.carNum)))); + if (newVideoList.length === actress.newVideoList.length) return actress; + return { ...actress, newVideoList }; + })); + return { actresses: nextActresses, decisions: nextDecisions }; } - isMobileMode() { - const e2 = storageManager.getSettingSync("mobileMode", "auto"); - return "on" === e2 || "off" !== e2 && (this.isMobile() || window.innerWidth < 768); + async _writeActivity(log) { + await this.storage.forage.setItem("activity_log", pruneActivityLog(log)); } - async copyToClipboard(e2, t2) { - const text = String(t2 ?? ""); - let copied = false; + async _commit(domains, next, activity) { + const pendingActivity = cloneStateValue(activity), pendingLog = { ...domains.activity, entries: [...domains.activity.entries, pendingActivity] }; + const journal = { id: activity.id, state: "prepared", createdAt: activity.createdAt, before: cloneStateValue(domains), after: cloneStateValue({ ...next, activity: pendingLog }) }; + await this.storage.forage.setItem("mutation_journal", journal); try { - if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text), copied = true; - else throw new Error("Clipboard API unavailable"); - } catch (clipboardError) { - const textarea = document.createElement("textarea"), activeElement = document.activeElement; - textarea.value = text, textarea.setAttribute("readonly", ""), textarea.style.position = "fixed", textarea.style.opacity = "0", document.body.appendChild(textarea), textarea.select(); - try { - copied = true === document.execCommand("copy"); - if (!copied) throw clipboardError; - } catch (fallbackError) { - clog.error("复制失败:", fallbackError), show.error("复制失败,请手动复制"); - } finally { - textarea.remove(), activeElement?.focus?.(); - } + await this.storage._setItemAndInvalidate(this.storage.car_list_key, next.carList), await this._writeActivity(pendingLog), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, next.actresses), await this.storage.forage.setItem("new_video_decisions", next.decisions); + activity.commitState = "committed", pendingLog.entries = pendingLog.entries.map(((entry) => entry.id === activity.id ? activity : entry)), await this._writeActivity(pendingLog); + await this.storage.forage.removeItem("mutation_journal"), this.storage._invalidateCache(); + } catch (error) { + await this._recoverJournal(journal); + throw error; } - return copied && show.info(`${e2}已复制到剪贴板, ${text}`), copied; } - htmlTo$dom(e2) { - const t2 = new DOMParser(); - return $(t2.parseFromString(e2, "text/html")); + async _recoverJournal(journal) { + const log = await this.getActivityLog(), activity = log.entries.find(((entry) => entry.id === journal.id)); + if ("committed" === activity?.commitState) { + await this.storage._setItemAndInvalidate(this.storage.car_list_key, journal.after.carList), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, journal.after.actresses), await this.storage.forage.setItem("new_video_decisions", journal.after.decisions); + } else { + const current = await this._readDomains(), keys = ["carList", "actresses", "decisions"]; + const conflict = keys.some(((key) => { + const value = stableStateValue(current[key]); + return value !== stableStateValue(journal.before[key]) && value !== stableStateValue(journal.after[key]); + })); + if (conflict) throw new Error("检测到未完成状态事务且数据已发生冲突,请先运行数据健康检查"); + await this.storage._setItemAndInvalidate(this.storage.car_list_key, journal.before.carList), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, journal.before.actresses), await this.storage.forage.setItem("new_video_decisions", journal.before.decisions); + journal.before.activity ? await this._writeActivity(journal.before.activity) : (log.entries = log.entries.filter(((entry) => entry.id !== journal.id)), await this._writeActivity(log)); + } + await this.storage.forage.removeItem("mutation_journal"), this.storage._invalidateCache(); } - isHidden(e2) { - const t2 = e2.jquery ? e2[0] : e2; - return !t2 || (t2.offsetWidth <= 0 && t2.offsetHeight <= 0 || "none" === window.getComputedStyle(t2).display); + async _recoverWithoutLock() { + const journal = await this.storage.forage.getItem("mutation_journal"); + return journal ? (await this._recoverJournal(journal), true) : false; } - time(e2 = "default", t2 = "s", n2 = 2) { - if (this.timers.has(e2)) { - const t3 = this.timers.get(e2), n3 = performance.now() - t3.startTime; - let a2, i2; - return "s" === t3.unit ? (a2 = (n3 / 1e3).toFixed(t3.precision), i2 = "秒") : (a2 = n3.toFixed(t3.precision), i2 = "毫秒"), this.timers.delete(e2), `${e2}: ${a2}${i2}`; - } - this.timers.set(e2, { - startTime: performance.now(), - unit: t2, - precision: n2 + async recoverPendingTransaction() { + return this._withLock((() => this._recoverWithoutLock())); + } + async patch(carNums, patch, options = {}) { + const keys = [...new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean))]; + if (!keys.length) throw new Error("番号为空"); + const invalidFlag = Object.keys(patch).find(((key) => !STATE_FLAG_NAMES.includes(key) || "boolean" != typeof patch[key])); + if (invalidFlag) throw new TypeError(`无效状态字段: ${invalidFlag}`); + return this._withLock((() => this._patchWithoutLock(keys, patch, options))); + } + async _patchWithoutLock(keys, patch, options) { + await this._recoverWithoutLock(); + const domains = await this._readDomains(), map = new Map(domains.carList.map(((record) => [normalizeCarNum(record.carNum), record]))), changes = [], handled = []; + const records = Array.isArray(options.records) ? new Map(options.records.map(((record) => [normalizeCarNum(record.carNum), record]))) : /* @__PURE__ */ new Map(); + keys.forEach(((carNum) => { + const existing = map.get(carNum), metadata = records.get(carNum) || options.record || {}, now = utils.getNowStr(), before = existing ? cloneStateValue(existing) : null; + const record = existing ? { ...existing, stateFlags: normalizeStateFlags(existing.stateFlags) } : { carNum, url: metadata.url || window.location.href, names: metadata.names || "", createDate: now, stateFlags: createEmptyStateFlags() }; + const fields = []; + ["url", "names", "publishTime", "starId", "remark", "fc2Source"].forEach(((field) => { + if ("fc2Source" === field && !["fc2", "123av"].includes(metadata[field])) return; + if (!Object.prototype.hasOwnProperty.call(metadata, field) || null == metadata[field] || !options.replaceMetadata && "" === metadata[field] || record[field] === metadata[field]) return; + record[field] = metadata[field], fields.push(field); + })); + STATE_FLAG_NAMES.forEach(((flag) => Object.prototype.hasOwnProperty.call(patch, flag) && record.stateFlags[flag] !== patch[flag] && (patch[flag] && handled.push(carNum), record.stateFlags[flag] = patch[flag], fields.push(`stateFlags.${flag}`)))); + if (!fields.length && existing) return; + record.updateDate = now, syncLegacyStatus(record), map.set(carNum, record), changes.push({ carNum, operation: existing ? "patch" : "create", fields, before, after: cloneStateValue(record), undoState: "pending" }); + })); + if (!changes.length) return { changed: [], transactionId: null }; + changes.forEach(((change) => handled.includes(change.carNum) && (change.newVideoEffect = captureNewVideoEffect(domains.actresses, domains.decisions, change.carNum)))); + const effects = this._removeHandledNewVideos(domains.actresses, domains.decisions, handled), activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: options.type || "state-patch", commitState: "pending", changes, createdAt: (/* @__PURE__ */ new Date()).toISOString(), undoAttemptedAt: null }; + await this._commit(domains, { carList: [...map.values()], ...effects }, activity), await this.eventBus.emit("car-state-changed", { carNums: changes.map(((change) => change.carNum)), transactionId: activity.id }), handled.length && await this.eventBus.emit("new-video-changed", { carNums: [...new Set(handled)], reason: "state-handled" }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); + return { changed: changes.map(((change) => change.carNum)), transactionId: activity.id }; + } + async toggle(carNum, flag, options = {}) { + if (!STATE_FLAG_NAMES.includes(flag)) throw new TypeError(`无效状态字段: ${flag}`); + const key = normalizeCarNum(carNum); + if (!key) throw new Error("番号为空"); + return this._withLock(async () => { + await this._recoverWithoutLock(); + const record = await this.storage.getCar(key), flags = normalizeStateFlags(record?.stateFlags); + return this._patchWithoutLock([key], { [flag]: !flags[flag] }, options); }); } - sleep(e2 = 1e3) { - return new Promise(((t2) => setTimeout(t2, e2))); + async remove(carNums) { + const keys = new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean)); + return this._withLock(async () => { + await this._recoverWithoutLock(); + const domains = await this._readDomains(), changes = domains.carList.filter(((record) => keys.has(normalizeCarNum(record.carNum)))).map(((record) => ({ carNum: normalizeCarNum(record.carNum), operation: "delete", fields: ["record"], before: cloneStateValue(record), after: null, undoState: "pending" }))); + if (!changes.length) return { changed: [], transactionId: null }; + const activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: "record-delete", commitState: "pending", changes, createdAt: (/* @__PURE__ */ new Date()).toISOString(), undoAttemptedAt: null }; + await this._commit(domains, { carList: domains.carList.filter(((record) => !keys.has(normalizeCarNum(record.carNum)))), actresses: domains.actresses, decisions: domains.decisions }, activity), await this.eventBus.emit("car-records-removed", { carNums: changes.map(((change) => change.carNum)), transactionId: activity.id }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); + return { changed: changes.map(((change) => change.carNum)), transactionId: activity.id }; + }); } - debounce(callback, wait = 200) { - let timer = null; - const debounced = /* @__PURE__ */ __name(function(...args) { - const context = this; - clearTimeout(timer), timer = setTimeout((() => { - timer = null, callback.apply(context, args); - }), Math.max(0, wait)); - }, "debounced"); - return debounced.cancel = () => { - clearTimeout(timer), timer = null; - }, debounced; + async setNewVideoDecision(carNums, action, until = null) { + if (!["ignored", "snoozed", null].includes(action)) throw new TypeError("无效新作决策"); + const keys = [...new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean))]; + return this._withLock(async () => { + await this._recoverWithoutLock(); + const domains = await this._readDomains(), decisions = { ...domains.decisions }, now = (/* @__PURE__ */ new Date()).toISOString(), changes = []; + keys.forEach(((carNum) => { + const before = cloneStateValue(decisions[carNum] || null), after = action ? { action, until: "snoozed" === action ? until : null, createdAt: before?.createdAt || now, updatedAt: now } : null; + stableStateValue(before) === stableStateValue(after) || (after ? decisions[carNum] = after : delete decisions[carNum], changes.push({ carNum, operation: "new-video-decision", fields: ["decision"], before, after, undoState: "pending" })); + })); + if (!changes.length) return { changed: [], transactionId: null }; + const activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: "new-video-decision", commitState: "pending", changes, createdAt: now, undoAttemptedAt: null }; + const changed = changes.map(((change) => change.carNum)); + await this._commit(domains, { carList: domains.carList, actresses: domains.actresses, decisions }, activity), await this.eventBus.emit("new-video-changed", { carNums: changed, reason: action || "decision-restored" }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); + return { changed, transactionId: activity.id }; + }); } - genericSort(e2, t2, n2 = true) { - if (!Array.isArray(e2) || 0 === e2.length) return []; - if (!Array.isArray(t2) || 0 === t2.length) return [...e2]; - const i2 = /* @__PURE__ */ __name((e3) => { - if (e3 instanceof Date) return e3; - if ("string" == typeof e3) { - const t3 = new Date(e3); - if (!isNaN(t3.getTime())) return t3; - } - return e3; - }, "i"); - const getVal = /* @__PURE__ */ __name((e3, t3) => null != t3 ? "function" == typeof t3 ? t3(e3) : e3 && "object" == typeof e3 ? e3[t3] : void 0 : e3, "getVal"); - const nulls = [], nonNulls = []; - for (const item of e2) { - let hasNull = false; - for (const s2 of t2) { - const val = getVal(item, s2.key); - if (null == val || void 0 === val) { - hasNull = true; - break; + async removeFromNewVideoList(carNums, reason = "manual") { + const keys = [...new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean))]; + return this._withLock(async () => { + await this._recoverWithoutLock(); + const domains = await this._readDomains(), changed = keys.filter(((carNum) => { + const effect = captureNewVideoEffect(domains.actresses, domains.decisions, carNum); + return effect.actressItems.length > 0 || !!effect.decision; + })); + if (!changed.length) return { changed: [], transactionId: null }; + const effects = this._removeHandledNewVideos(domains.actresses, domains.decisions, changed), activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: "new-video-remove", commitState: "pending", changes: changed.map(((carNum) => ({ carNum, operation: "new-video-remove", fields: ["newVideoList", "decision"], before: null, after: { removed: true, reason }, newVideoEffect: captureNewVideoEffect(domains.actresses, domains.decisions, carNum), undoState: "pending" }))), createdAt: (/* @__PURE__ */ new Date()).toISOString(), undoAttemptedAt: null }; + await this._commit(domains, { carList: domains.carList, ...effects }, activity), await this.eventBus.emit("new-video-changed", { carNums: changed, reason }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); + return { changed, transactionId: activity.id }; + }); + } + async undoTransaction(transactionId) { + return this._withLock(async () => { + await this._recoverWithoutLock(); + const domains = await this._readDomains(), transaction = domains.activity.entries.find(((entry) => entry.id === transactionId && "committed" === entry.commitState)); + if (!transaction) throw new Error("操作记录不存在或尚未提交"); + const carMap = new Map(domains.carList.map(((record) => [normalizeCarNum(record.carNum), cloneStateValue(record)]))), decisions = { ...domains.decisions }, actresses = cloneStateValue(domains.actresses), reverted = [], conflicts = []; + for (const change of transaction.changes) { + if ("reverted" === change.undoState) continue; + const current = carMap.get(change.carNum); + if (change.newVideoEffect && !canRestoreNewVideoEffect(actresses, decisions, change.carNum, change.newVideoEffect)) { + change.undoState = "conflict", conflicts.push(change.carNum); + continue; } + if ("delete" === change.operation) { + current ? (change.undoState = "conflict", conflicts.push(change.carNum)) : (carMap.set(change.carNum, cloneStateValue(change.before)), change.undoState = "reverted", reverted.push(change.carNum)); + continue; + } + if ("new-video-decision" === change.operation) { + const currentDecision = decisions[change.carNum] || null; + stableStateValue(currentDecision) !== stableStateValue(change.after) ? (change.undoState = "conflict", conflicts.push(change.carNum)) : (change.before ? decisions[change.carNum] = cloneStateValue(change.before) : delete decisions[change.carNum], change.undoState = "reverted", reverted.push(change.carNum)); + continue; + } + if ("new-video-remove" === change.operation) { + restoreNewVideoEffect(actresses, decisions, change.carNum, change.newVideoEffect), change.undoState = "reverted", reverted.push(change.carNum); + continue; + } + if (!["patch", "create"].includes(change.operation) || !current || change.fields.some(((field) => stableStateValue(getStatePath(current, field)) !== stableStateValue(getStatePath(change.after, field))))) { + change.undoState = "conflict", conflicts.push(change.carNum); + continue; + } + if ("create" === change.operation && !change.before) carMap.delete(change.carNum); + else change.fields.forEach(((field) => setStatePath(current, field, getStatePath(change.before, field)))), syncLegacyStatus(current), carMap.set(change.carNum, current); + change.newVideoEffect && restoreNewVideoEffect(actresses, decisions, change.carNum, change.newVideoEffect), change.undoState = "reverted", reverted.push(change.carNum); } - hasNull ? nulls.push(item) : nonNulls.push(item); - } - return nonNulls.sort(((e3, a2) => { - for (const s2 of t2) { - const { key: t3, order: o2 = "asc" } = s2; - let r2 = getVal(e3, t3), l2 = getVal(a2, t3); - const c2 = i2(r2), d2 = i2(l2); - let h2 = c2 instanceof Date && d2 instanceof Date ? c2.getTime() - d2.getTime() : "number" == typeof r2 && "number" == typeof l2 ? r2 - l2 : "string" == typeof r2 && "string" == typeof l2 ? r2.localeCompare(l2) : String(r2).localeCompare(String(l2)); - "desc" === o2 && (h2 *= -1); - if (0 !== h2) return h2; - } - return 0; - })), n2 ? [...nonNulls, ...nulls] : [...nulls, ...nonNulls]; - } - async retry(e2, t2 = 3) { - let n2 = 0; - for (; n2 < t2; ) try { - const t3 = await e2(); - return n2 > 0 && clog.debug(`[重试] 请求成功,共发起 ${n2 + 1} 次。`), t3; - } catch (a2) { - const e3 = a2 instanceof Error ? a2.message : "object" == typeof a2 ? JSON.stringify(a2) : String(a2); - if (a2?._cfBlocked || a2?._circuitBroken || e3.includes("Just a moment") || e3.includes("重定向") || e3.toLowerCase().includes("404 not found")) throw a2; - if (n2++, n2 === t2) throw clog.debug(`[重试] 达到最大重试次数 (${t2}),最终失败:`, a2), a2; - await this.sleep(500 * n2), clog.debug(`[重试] 请求失败,准备第 ${n2 + 1} 次重试, 错误信息: ${e3}`); - } + transaction.undoAttemptedAt = (/* @__PURE__ */ new Date()).toISOString(); + const log = pruneActivityLog(domains.activity), nextCars = [...carMap.values()], journal = { id: `undo_${transactionId}`, state: "prepared", createdAt: transaction.undoAttemptedAt, before: cloneStateValue(domains), after: cloneStateValue({ carList: nextCars, actresses, decisions, activity: log }) }; + await this.storage.forage.setItem("mutation_journal", journal), await this.storage._setItemAndInvalidate(this.storage.car_list_key, nextCars), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, actresses), await this.storage.forage.setItem("new_video_decisions", decisions), await this._writeActivity(log), await this.storage.forage.removeItem("mutation_journal"), this.storage._invalidateCache(); + reverted.length && await this.eventBus.emit("car-state-changed", { carNums: reverted, undoOf: transactionId }), await this.eventBus.emit("activity-log-changed", { transactionId, undo: true }); + return { reverted, conflicts }; + }); } }; - __name(_Utils, "Utils"); - var Utils = _Utils; + __name(_StateService, "StateService"); + var StateService = _StateService; + function attachStateServiceCompatibility(stateService, storageManager2) { + storageManager2.stateService = stateService; + } + __name(attachStateServiceCompatibility, "attachStateServiceCompatibility"); // src/core/storage-index.js function createIndexedMap(items, key) { @@ -3781,443 +3793,443 @@ __name(_StorageManager, "StorageManager"); var StorageManager = _StorageManager; - // src/core/http.js - var utils2 = globalThis.utils ?? new Utils(); - var gmHttp2 = new class { + // src/core/utils.js + var _Utils = class _Utils { constructor() { - this._circuitBreakers = /* @__PURE__ */ new Map(); - this._domainStats = /* @__PURE__ */ new Map(); + return i(this, "intervalContainer", {}), i(this, "waitSequence", 0), i(this, "mimeTypes", { + txt: "text/plain", + html: "text/html", + css: "text/css", + csv: "text/csv", + json: "application/json", + xml: "application/xml", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + webp: "image/webp", + svg: "image/svg+xml", + pdf: "application/pdf", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + zip: "application/zip", + rar: "application/x-rar-compressed", + "7z": "application/x-7z-compressed", + mp3: "audio/mpeg", + wav: "audio/wav", + mp4: "video/mp4", + webm: "video/webm", + ogg: "audio/ogg" + }), i(this, "timers", /* @__PURE__ */ new Map()), i(this, "insertStyle", ((e2) => { + const t2 = (Array.isArray(e2) ? e2 : [e2]).filter(Boolean); + if (0 === t2.length) return; + const n2 = t2.map(((e3) => e3.replace(/^\s*]*>/i, "").replace(/<\/style>?\s*$/i, ""))).filter(Boolean).join("\n"), a2 = document.createElement("style"); + n2 && (a2.textContent = n2, document.head.append(a2)); + })), i(this, "layerIndexStack", []), _Utils.instance || (_Utils.instance = this), _Utils.instance; } - _getDomain(e2) { - try { - return new URL(e2).hostname; - } catch { - return "unknown"; - } + importResource(e2) { + let t2; + e2.indexOf("css") >= 0 ? (t2 = document.createElement("link"), t2.setAttribute("rel", "stylesheet"), t2.href = e2) : (t2 = document.createElement("script"), t2.setAttribute("type", "text/javascript"), t2.src = e2), document.documentElement.appendChild(t2); } - _isCloudflareChallenge(e2, status = 0) { - if ("string" != typeof e2 || !e2) return false; - const text = e2.toLowerCase(); - const hasChallengeTitle = /]*>\s*just a moment(?:\.\.\.)?\s*<\/title>/i.test(e2); - const hasChallengeForm = /id=["']challenge-form["']/i.test(e2); - const hasCfChl = text.includes("cf-chl-") || text.includes("cf_chl_opt"); - const hasChallengePlatform = text.includes("/cdn-cgi/challenge-platform/") || text.includes("challenge-platform"); - const blockedStatus = 403 === status || 429 === status || 503 === status; - return hasChallengeTitle || hasChallengeForm && (hasCfChl || hasChallengePlatform) || blockedStatus && hasCfChl && hasChallengePlatform; + openPage(e2, t2, n2, a2) { + n2 = n2 ?? true; + const navigation = a2 && (Object.prototype.hasOwnProperty.call(a2, "event") || Object.prototype.hasOwnProperty.call(a2, "newTab")) ? a2 : { event: a2 }, event = navigation.event; + const destination = new URL(e2, window.location.origin), carNum = normalizeCarNum(t2), isMovieDetail = /^\/v\/[^/]+/.test(destination.pathname); + isMovieDetail && carNum && destination.searchParams.set("jhsCarNum", carNum); + if (navigation.newTab || event && (event.ctrlKey || event.metaKey || 1 === event.button)) return void GM_openInTab(destination.href, { + insert: 0 + }); + destination.pathname.includes("/actors/") || destination.pathname.includes("/star/") || destination.searchParams.set("hideNav", "1"); + layer.open({ + type: 2, + title: t2, + content: destination.href, + scrollbar: false, + shadeClose: n2, + area: this.getDialogArea("workspace"), + isOutAnim: false, + anim: -1, + success: /* @__PURE__ */ __name((e3, t3) => { + this.setupEscClose(t3); + }, "success") + }); } - _checkCircuitBreaker(e2) { - const t2 = this._circuitBreakers.get(e2); - if (!t2) return null; - if ("open" === t2.state) { - const n2 = Date.now() - t2.openTime; - return n2 < t2.cooldownMs ? { state: "open", remaining: Math.ceil((t2.cooldownMs - n2) / 1e3) } : (t2.state = "half-open", t2.failCount = 0, t2.probing = false, null); + _handleGlobalEscKey(e2) { + if ("Escape" !== e2.key && 27 !== e2.keyCode) return; + if (0 === this.layerIndexStack.length) return; + const t2 = this.layerIndexStack[this.layerIndexStack.length - 1], n2 = $(`#layui-layer${t2}`); + let a2 = false; + if (n2.find(".viewer-container").length > 0) a2 = true; + else { + const e3 = n2.find(`#layui-layer-iframe${t2}`)[0]; + if (e3 && e3.contentDocument) try { + $(e3.contentDocument).find(".viewer-container").length > 0 && (a2 = true); + } catch (i2) { + clog.warn("无法检查跨域 iframe 内的 .viewer-container"); + } } - if ("half-open" === t2.state && t2.probing) return { state: "half-open", remaining: 0 }; - return null; - } - isDomainCircuitBroken(e2) { - return this._checkCircuitBreaker(this._getDomain(e2)); - } - /* domainStats.count 统计请求数(含重试),非独立请求数 */ - _recordSuccess(e2) { - let t2 = this._circuitBreakers.get(e2); - t2 && (t2.state = "closed", t2.failCount = 0, t2.probing = false); - let n2 = this._domainStats.get(e2); - n2 || (n2 = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e2, n2)), n2.count++, n2.lastUsed = Date.now(); - } - _recordFailure(e2) { - let t2 = this._circuitBreakers.get(e2); - t2 || (t2 = { state: "closed", failCount: 0, openTime: 0, cooldownMs: 6e4, threshold: 3 }, this._circuitBreakers.set(e2, t2)), t2.failCount++, ("half-open" === t2.state || t2.failCount >= (t2.threshold || 3)) && (t2.state = "open", t2.openTime = Date.now(), t2.probing = false, clog.warn(`[熔断] ${e2} 连续失败 ${t2.failCount} 次,已熔断 ${t2.cooldownMs / 1e3} 秒`)); - let n2 = this._domainStats.get(e2); - n2 || (n2 = { count: 0, errors: 0, lastUsed: 0 }, this._domainStats.set(e2, n2)), n2.count++, n2.errors++, n2.lastUsed = Date.now(); - } - getCircuitBreakerStatus() { - const e2 = {}; - return this._circuitBreakers.forEach(((t2, n2) => { - e2[n2] = { ...t2 }; - })), e2; - } - resetCircuitBreaker(e2) { - this._circuitBreakers.delete(e2); - } - resetAllCircuitBreakers() { - this._circuitBreakers.clear(); - } - getDomainStats() { - const e2 = {}; - return this._domainStats.forEach(((t2, n2) => { - e2[n2] = { ...t2 }; - })), e2; - } - clearDomainStats() { - this._domainStats.clear(); - } - async get(e2, t2 = {}, n2 = {}, a2, i2 = {}) { - return this.gmRequest("GET", e2, null, t2, n2, a2, i2); - } - post(e2, t2 = {}, n2 = {}) { - n2 = { - "Content-Type": "application/json", - ...n2 - }; - let a2 = JSON.stringify(t2); - return this.gmRequest("POST", e2, a2, null, n2); + a2 || (this.layerIndexStack.pop(), layer.close(t2)); } - async gmRequest(e2, t2, n2 = {}, a2 = {}, i2 = {}, s2 = false, requestOptions = {}) { - if (a2 && Object.keys(a2).length) { - const e3 = new URLSearchParams(a2).toString(); - t2 += (t2.includes("?") ? "&" : "?") + e3; + setupEscClose(e2) { + var t2; + this._boundHandler || (this._boundHandler = this._handleGlobalEscKey.bind(this), $(document).off("keydown.globalLayerEsc"), $(document).on("keydown.globalLayerEsc", this._boundHandler)), -1 === this.layerIndexStack.indexOf(e2) && this.layerIndexStack.push(e2); + const n2 = $(`#layui-layer-iframe${e2}`), a2 = `keydown.layerEsc${e2}`; + try { + const e3 = null == (t2 = n2[0]) ? void 0 : t2.contentDocument; + if (e3) { + if ("yes" === n2.attr("data-esc-bound")) return; + $(e3).off(a2), $(e3).on(a2, this._boundHandler), n2.attr("data-esc-bound", "yes"); + } + } catch (i2) { + clog.error("iframe监听失败 (跨域或未加载完毕):", i2); } - const o2 = this._getDomain(t2), [m2, r2, b2, k2] = await Promise.all([storageManager2.getSetting("httpTimeout", 5e3), storageManager2.getSetting("httpRetryCount", 3), storageManager2.getSetting("circuitBreakerThreshold", 3), storageManager2.getSetting("circuitBreakerCooldown", 6e4)]); - let u2 = this._circuitBreakers.get(o2); - u2 || (u2 = { state: "closed", failCount: 0, openTime: 0, cooldownMs: k2, threshold: b2, probing: false }, this._circuitBreakers.set(o2, u2)); - const w = this._checkCircuitBreaker(o2); - if (w) { - const e3 = new Error(`站点 ${o2} 已熔断,${w.remaining}秒后重试`); - throw e3._circuitBroken = true, e3; + } + async closePage(options = {}) { + if ("yes" !== await storageManager.getSetting("needClosePage", "yes")) return false; + const root = options?.root, parseIndex = /* @__PURE__ */ __name((element) => { + const id = element?.id || "", match = /^layui-layer(\d+)$/.exec(id); + return match ? Number(match[1]) : null; + }, "parseIndex"); + let layerIndex = Number.isInteger(options?.layerIndex) ? options.layerIndex : null; + if (null === layerIndex && root) { + const element = root.jquery ? root[0] : root.nodeType ? root : null, layerElement = element?.matches?.(".layui-layer") ? element : element?.closest?.(".layui-layer"); + layerIndex = parseIndex(layerElement); } - return n2 || (n2 = void 0), await utils2.retry(() => { - const c2 = this._checkCircuitBreaker(o2); - if (c2) { - const t3 = new Error(`站点 ${o2} 已熔断,${c2.remaining}秒后重试`); - return t3._circuitBroken = true, Promise.reject(t3); + if (null === layerIndex && window.frameElement) layerIndex = parseIndex(window.frameElement.closest?.(".layui-layer")); + const ownerWindow = window.parent && window.parent !== window ? window.parent : window, ownerLayer = ownerWindow.layer || globalThis.layer; + if (null !== layerIndex && "function" == typeof ownerLayer?.close) return ownerLayer.close(layerIndex), true; + if (window.opener && !window.opener.closed) return window.close(), true; + return false; + } + loopDetector(e2, t2, n2 = 20, a2 = 1e4, i2 = true, scope = null) { + const s2 = ++this.waitSequence; + let o2 = null, r2 = null, l2 = null, c2 = false; + const d2 = /* @__PURE__ */ __name(() => { + o2 && (scope?.releaseObserver ? scope.releaseObserver(o2) : o2.disconnect()), clearTimeout(r2), clearTimeout(l2), clearInterval(this.intervalContainer[s2]?.fallback), delete this.intervalContainer[s2]; + }, "d"), h2 = /* @__PURE__ */ __name((e3) => { + if (c2) return; + c2 = true, d2(), e3 && t2 && t2(); + }, "h"), g2 = /* @__PURE__ */ __name(() => { + if (c2) return; + e2() && h2(true); + }, "g"), p2 = /* @__PURE__ */ __name(() => { + c2 || (clearTimeout(r2), r2 = setTimeout(g2, Math.max(0, n2))); + }, "p"); + const cancel = /* @__PURE__ */ __name(() => { + c2 = true, d2(); + }, "cancel"); + this.intervalContainer[s2] = {}; + if (e2()) return h2(true), cancel; + if (scope?.observe && document.documentElement) o2 = scope.observe(document.documentElement, p2, { childList: true, subtree: true, characterData: true }); + else this.intervalContainer[s2].fallback = setInterval(g2, Math.max(100, n2)); + l2 = setTimeout((() => { + if (c2) return; + let t3 = false; + try { + t3 = e2(); + } catch (e3) { + clog.error("DOM 等待条件执行失败", e3); } - "half-open" === u2.state && (u2.probing = true); - return new Promise(((a3, r3) => { - GM_xmlhttpRequest({ - method: e2, - url: t2, - headers: i2, - timeout: m2, - data: n2, - ...requestOptions.cookiePartitionTopLevelSite ? { - cookiePartition: { topLevelSite: requestOptions.cookiePartitionTopLevelSite } - } : {}, - onload: /* @__PURE__ */ __name((e3) => { - try { - if (404 === e3.status && requestOptions.ignoreNotFound) return void a3(null); - if (this._isCloudflareChallenge(e3.responseText, e3.status)) { - this._recordFailure(o2); - const n3 = new Error(`Cloudflare challenge blocked: ${t2}`); - return n3._cfBlocked = true, n3.status = e3.status, n3.requestUrl = t2, n3.finalUrl = e3.finalUrl, n3.cfDiagnostics = { status: e3.status, requestUrl: t2, finalUrl: e3.finalUrl, contentLength: e3.responseText?.length || 0 }, void r3(n3); - } - if (s2 && e3.finalUrl !== t2 && r3("请求被重定向了,URL是:" + e3.finalUrl), e3.status >= 200 && e3.status < 300) { - this._recordSuccess(o2); - if (e3.responseText) try { - a3(JSON.parse(e3.responseText)); - } catch (n3) { - a3(e3.responseText); - } - else a3(e3.responseText || e3); - } else { - clog.error("请求失败,状态码:", e3.status, t2), this._recordFailure(o2); - if (e3.responseText) { - try { - const t3 = JSON.parse(e3.responseText); - t3.status = e3.status, r3(t3); - } catch { - const t3 = new Error(e3.responseText || `请求发生错误 ${e3.status}`); - t3.status = e3.status, r3(t3); - } - } else { - const t3 = new Error(`请求发生错误 ${e3.status}`); - t3.status = e3.status, r3(t3); - } - } - } catch (n3) { - this._recordFailure(o2), r3(n3); - } - }, "onload"), - onerror: /* @__PURE__ */ __name((e3) => { - clog.error("网络错误:", t2), this._recordFailure(o2), r3(new Error(e3.error || "网络错误")); - }, "onerror"), - ontimeout: /* @__PURE__ */ __name(() => { - this._recordFailure(o2), r3(new Error("请求超时: " + t2)); - }, "ontimeout") - }); + h2(t3 || i2); + }), Math.max(0, a2)); + return cancel; + } + rightClick(e2, t2, n2) { + let a2; + "string" == typeof e2 ? a2 = document.querySelector(e2) : e2 instanceof HTMLElement && (a2 = e2), a2 || (clog.warn("rightClick(), 容器无效或未提供,将使用 document.body 进行全局委托。"), a2 = document.body), "string" == typeof t2 && "" !== t2.trim() ? a2.addEventListener("contextmenu", ((e3) => { + const a3 = e3.target.closest(t2); + a3 && n2(e3, a3); + })) : clog.error("rightClick(), 必须提供有效的 targetSelector。"); + } + q(e2, t2, n2, a2) { + let o2; + if (this.isMobileMode()) { + o2 = layer.confirm(t2, { + title: "提示", + btn: ["确定", "取消"], + shade: 0, + zIndex: JHS_Z_INDEX.layer + }, (function() { + n2 && n2(), layer.close(o2); + }), (function() { + a2 && a2(); })); - }, r2); + } else { + let i2, s2; + e2 ? (i2 = e2.clientX - 130, s2 = e2.clientY - 120) : (i2 = window.innerWidth / 2 - 120, s2 = window.innerHeight / 2 - 120); + o2 = layer.confirm(t2, { + offset: [s2, i2], + title: "提示", + btn: ["确定", "取消"], + shade: 0, + zIndex: JHS_Z_INDEX.layer + }, (function() { + n2 && n2(), layer.close(o2); + }), (function() { + a2 && a2(); + })); + } } - }(); - var storageManager2 = globalThis.storageManager ?? new StorageManager(); - - // src/core/state-service.js - var ACTIVITY_SOFT_LIMIT = 1e3; - var ACTIVITY_HARD_LIMIT = 1e4; - var ACTIVITY_RETENTION_MS = 30 * 864e5; - function cloneStateValue(value) { - return null == value ? value : JSON.parse(JSON.stringify(value)); - } - __name(cloneStateValue, "cloneStateValue"); - function stableStateValue(value) { - if (null === value || "object" != typeof value) return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(stableStateValue).join(",")}]`; - return `{${Object.keys(value).sort().map(((key) => `${JSON.stringify(key)}:${stableStateValue(value[key])}`)).join(",")}}`; - } - __name(stableStateValue, "stableStateValue"); - function getStatePath(value, path) { - return path.split(".").reduce(((current, key) => current?.[key]), value); - } - __name(getStatePath, "getStatePath"); - function setStatePath(value, path, next) { - const keys = path.split("."), last = keys.pop(), target = keys.reduce(((current, key) => current[key] || (current[key] = {})), value); - void 0 === next ? delete target[last] : target[last] = cloneStateValue(next); - } - __name(setStatePath, "setStatePath"); - function captureNewVideoEffect(actresses, decisions, carNum) { - const key = normalizeCarNum(carNum), actressItems = []; - actresses.forEach(((actress, actressIndex) => (actress.newVideoList || []).forEach(((item, itemIndex) => { - normalizeCarNum("string" == typeof item ? item : item.carNum) === key && actressItems.push({ actressIndex, itemIndex, item: cloneStateValue(item) }); - })))); - return { actressItems, decision: cloneStateValue(decisions[key] || null) }; - } - __name(captureNewVideoEffect, "captureNewVideoEffect"); - function canRestoreNewVideoEffect(actresses, decisions, carNum, effect) { - const key = normalizeCarNum(carNum); - if (stableStateValue(decisions[key] || null) !== stableStateValue(null)) return false; - return effect.actressItems.every(((entry) => !(actresses[entry.actressIndex]?.newVideoList || []).some(((item) => normalizeCarNum("string" == typeof item ? item : item.carNum) === key)))); - } - __name(canRestoreNewVideoEffect, "canRestoreNewVideoEffect"); - function restoreNewVideoEffect(actresses, decisions, carNum, effect) { - effect.actressItems.forEach(((entry) => { - const actress = actresses[entry.actressIndex]; - if (!actress) return; - const list = [...actress.newVideoList || []], index = Math.min(entry.itemIndex, list.length); - list.splice(index, 0, cloneStateValue(entry.item)), actress.newVideoList = list; - })); - effect.decision ? decisions[normalizeCarNum(carNum)] = cloneStateValue(effect.decision) : delete decisions[normalizeCarNum(carNum)]; - } - __name(restoreNewVideoEffect, "restoreNewVideoEffect"); - function pruneActivityLog(log, now = Date.now()) { - const result = { entries: Array.isArray(log?.entries) ? log.entries : [], trackingStartedAt: log?.trackingStartedAt || new Date(now).toISOString(), coverageStart: log?.coverageStart || null, truncatedAt: log?.truncatedAt || null }; - const cutoff = now - ACTIVITY_RETENTION_MS, recent = [], older = []; - result.entries.forEach(((entry) => (Date.parse(entry.createdAt) >= cutoff || "pending" === entry.commitState ? recent : older).push(entry))); - older.sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))), recent.sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))); - const olderAllowance = Math.max(0, ACTIVITY_SOFT_LIMIT - recent.length); - result.entries = [...older.slice(-olderAllowance), ...recent].sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))); - if (result.entries.length > ACTIVITY_HARD_LIMIT) { - const pending = result.entries.filter(((entry) => "pending" === entry.commitState)), committed = result.entries.filter(((entry) => "pending" !== entry.commitState)); - const committedAllowance = Math.max(0, ACTIVITY_HARD_LIMIT - pending.length); - result.entries = [...committedAllowance ? committed.slice(-committedAllowance) : [], ...pending].sort(((left, right) => String(left.createdAt).localeCompare(String(right.createdAt)))); - result.truncatedAt = new Date(now).toISOString(), result.coverageStart = result.entries[0]?.createdAt || result.truncatedAt; + getNowStr(e2 = "-", t2 = ":", n2 = null) { + let a2; + a2 = n2 ? new Date(n2) : /* @__PURE__ */ new Date(); + const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); + return `${[i2, s2, o2].join(e2)} ${[r2, l2, c2].join(t2)}`; } - return result; - } - __name(pruneActivityLog, "pruneActivityLog"); - var _StateService = class _StateService { - constructor(storage, eventBus) { - this.storage = storage, this.eventBus = eventBus, this._queue = Promise.resolve(), this._recovering = false; + formatDate(e2, t2 = "-", n2 = ":") { + let a2; + if (e2 instanceof Date) a2 = e2; + else { + if ("string" != typeof e2) throw new Error("Invalid date input: must be Date object or date string"); + if (a2 = new Date(e2), isNaN(a2.getTime())) throw new Error("Invalid date string"); + } + const i2 = a2.getFullYear(), s2 = String(a2.getMonth() + 1).padStart(2, "0"), o2 = String(a2.getDate()).padStart(2, "0"), r2 = String(a2.getHours()).padStart(2, "0"), l2 = String(a2.getMinutes()).padStart(2, "0"), c2 = String(a2.getSeconds()).padStart(2, "0"); + return `${[i2, s2, o2].join(t2)} ${[r2, l2, c2].join(n2)}`; } - _withLock(callback) { - if (globalThis.navigator?.locks?.request) return navigator.locks.request("jhs_state_mutation", callback); - const run = this._queue.then(callback, callback); - return this._queue = run.catch((() => { - })), run; + getHourDifference(e2, t2) { + const n2 = e2.getTime(), a2 = t2.getTime(), i2 = Math.abs(a2 - n2) / 36e5; + return Math.floor(i2); } - async getActivityLog() { - return pruneActivityLog(await this.storage.forage.getItem("activity_log")); + download(e2, t2) { + show.info("开始请求下载..."); + const n2 = t2.split(".").pop().toLowerCase(); + let a2, i2 = this.mimeTypes[n2] || "application/octet-stream"; + if (e2 instanceof Blob) a2 = e2; + else if (e2 instanceof ArrayBuffer || ArrayBuffer.isView(e2)) a2 = new Blob([e2], { + type: i2 + }); + else if ("string" == typeof e2 && e2.startsWith("data:")) { + const t3 = atob(e2.split(",")[1]), n3 = new ArrayBuffer(t3.length), s3 = new Uint8Array(n3); + for (let e3 = 0; e3 < t3.length; e3++) s3[e3] = t3.charCodeAt(e3); + a2 = new Blob([s3], { + type: i2 + }); + } else a2 = new Blob([e2], { + type: i2 + }); + const s2 = URL.createObjectURL(a2), o2 = document.createElement("a"); + o2.href = s2, o2.download = t2, document.body.appendChild(o2), o2.click(), setTimeout((() => { + document.body.removeChild(o2), URL.revokeObjectURL(s2); + }), 100); } - async getOfflineHistory() { - return await this.storage.forage.getItem("offline_history") || []; + smoothScrollToTop(e2 = 500) { + return new Promise(((t2) => { + const n2 = performance.now(), a2 = window.pageYOffset; + window.requestAnimationFrame(/* @__PURE__ */ __name((function i2(s2) { + const o2 = s2 - n2, r2 = Math.min(o2 / e2, 1), l2 = r2 < 0.5 ? 4 * r2 * r2 * r2 : 1 - Math.pow(-2 * r2 + 2, 3) / 2; + window.scrollTo(0, a2 * (1 - l2)), r2 < 1 ? window.requestAnimationFrame(i2) : t2(); + }), "i")); + })); } - async appendOfflineHistory(record) { - const history = await this.getOfflineHistory(), item = { id: record.id || globalThis.crypto?.randomUUID?.() || `offline_${Date.now()}`, createdAt: record.createdAt || (/* @__PURE__ */ new Date()).toISOString(), ...record, carNum: normalizeCarNum(record.carNum) }; - history.push(item), history.length > 1e3 && history.splice(0, history.length - 1e3), await this.storage.forage.setItem("offline_history", history), await this.eventBus.emit("offline-history-changed", { ids: [item.id] }); - return item; + isUrl(e2) { + try { + return new URL(e2), true; + } catch (t2) { + return false; + } } - async removeOfflineHistory(ids) { - const keys = new Set(Array.isArray(ids) ? ids : [ids]), history = await this.getOfflineHistory(), next = history.filter(((item) => !keys.has(item.id))); - if (next.length === history.length) return false; - return await this.storage.forage.setItem("offline_history", next), await this.eventBus.emit("offline-history-changed", { ids: [...keys], removed: true }), true; + setHrefParam(e2, t2) { + const n2 = new URL(window.location.href); + n2.searchParams.set(e2, t2), window.history.pushState({}, "", n2.toString()); } - async getNewVideoDecisions() { - return await this.storage.forage.getItem("new_video_decisions") || {}; + getUrlParam(e2, t2) { + const n2 = e2.split("?")[1]; + if (!n2) return null; + const a2 = new RegExp(`(?:^|&)${t2}=([^&]*)`), i2 = n2.match(a2); + let s2 = ""; + return i2 && i2[1] && (s2 = decodeURIComponent(i2[1].replace(/\+/g, " "))), s2 ? "true" === s2 || "false" === s2 ? "true" === s2.toLowerCase() : "string" != typeof s2 || "" === s2.trim() || isNaN(Number(s2)) ? s2 : Number(s2) : s2; } - async _readDomains() { - const [carList, actresses, decisions, activity] = await Promise.all([this.storage.forage.getItem(this.storage.car_list_key), this.storage.forage.getItem(this.storage.favorite_actresses_key), this.storage.forage.getItem("new_video_decisions"), this.storage.forage.getItem("activity_log")]); - return { carList: carList || [], actresses: actresses || [], decisions: decisions || {}, activity: pruneActivityLog(activity) }; + getResponsiveArea(e2) { + const t2 = window.innerWidth; + return this.isMobileMode() ? ["100%", "90%"] : t2 >= 1200 ? e2 || this.getDefaultArea() : ["70%", "90%"]; } - _removeHandledNewVideos(actresses, decisions, carNums) { - const keys = new Set(carNums.map(normalizeCarNum).filter(Boolean)), nextDecisions = { ...decisions }; - keys.forEach(((key) => delete nextDecisions[key])); - const nextActresses = actresses.map(((actress) => { - if (!Array.isArray(actress.newVideoList)) return actress; - const newVideoList = actress.newVideoList.filter(((item) => !keys.has(normalizeCarNum("string" == typeof item ? item : item.carNum)))); - if (newVideoList.length === actress.newVideoList.length) return actress; - return { ...actress, newVideoList }; - })); - return { actresses: nextActresses, decisions: nextDecisions }; + getDialogArea(e2 = "md") { + const t2 = { + sm: [480, 640], + md: [720, 700], + lg: [1040, 760], + xl: [1320, 860], + workspace: [1440, 960] + }, n2 = t2[e2] || t2.md, a2 = window.innerWidth <= 768 ? 16 : "workspace" === e2 ? 32 : 64, i2 = Math.max(320, Math.min(n2[0], window.innerWidth - a2)), s2 = Math.max(320, Math.min(n2[1], window.innerHeight - a2)); + return [`${i2}px`, `${s2}px`]; } - async _writeActivity(log) { - await this.storage.forage.setItem("activity_log", pruneActivityLog(log)); + getDefaultArea() { + return ["85%", "90%"]; } - async _commit(domains, next, activity) { - const pendingActivity = cloneStateValue(activity), pendingLog = { ...domains.activity, entries: [...domains.activity.entries, pendingActivity] }; - const journal = { id: activity.id, state: "prepared", createdAt: activity.createdAt, before: cloneStateValue(domains), after: cloneStateValue({ ...next, activity: pendingLog }) }; - await this.storage.forage.setItem("mutation_journal", journal); + isMobile() { + const e2 = navigator.userAgent.toLowerCase(); + return ["iphone", "ipod", "ipad", "android", "blackberry", "windows phone", "nokia", "webos", "opera mini", "mobile", "mobi", "tablet"].some(((t2) => e2.includes(t2))); + } + isMobileMode() { + const e2 = storageManager.getSettingSync("mobileMode", "auto"); + return "on" === e2 || "off" !== e2 && (this.isMobile() || window.innerWidth < 768); + } + async copyToClipboard(e2, t2) { + const text = String(t2 ?? ""); + let copied = false; try { - await this.storage._setItemAndInvalidate(this.storage.car_list_key, next.carList), await this._writeActivity(pendingLog), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, next.actresses), await this.storage.forage.setItem("new_video_decisions", next.decisions); - activity.commitState = "committed", pendingLog.entries = pendingLog.entries.map(((entry) => entry.id === activity.id ? activity : entry)), await this._writeActivity(pendingLog); - await this.storage.forage.removeItem("mutation_journal"), this.storage._invalidateCache(); - } catch (error) { - await this._recoverJournal(journal); - throw error; + if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text), copied = true; + else throw new Error("Clipboard API unavailable"); + } catch (clipboardError) { + const textarea = document.createElement("textarea"), activeElement = document.activeElement; + textarea.value = text, textarea.setAttribute("readonly", ""), textarea.style.position = "fixed", textarea.style.opacity = "0", document.body.appendChild(textarea), textarea.select(); + try { + copied = true === document.execCommand("copy"); + if (!copied) throw clipboardError; + } catch (fallbackError) { + clog.error("复制失败:", fallbackError), show.error("复制失败,请手动复制"); + } finally { + textarea.remove(), activeElement?.focus?.(); + } + } + return copied && show.info(`${e2}已复制到剪贴板, ${text}`), copied; + } + htmlTo$dom(e2) { + const t2 = new DOMParser(); + return $(t2.parseFromString(e2, "text/html")); + } + isHidden(e2) { + const t2 = e2.jquery ? e2[0] : e2; + return !t2 || (t2.offsetWidth <= 0 && t2.offsetHeight <= 0 || "none" === window.getComputedStyle(t2).display); + } + time(e2 = "default", t2 = "s", n2 = 2) { + if (this.timers.has(e2)) { + const t3 = this.timers.get(e2), n3 = performance.now() - t3.startTime; + let a2, i2; + return "s" === t3.unit ? (a2 = (n3 / 1e3).toFixed(t3.precision), i2 = "秒") : (a2 = n3.toFixed(t3.precision), i2 = "毫秒"), this.timers.delete(e2), `${e2}: ${a2}${i2}`; } + this.timers.set(e2, { + startTime: performance.now(), + unit: t2, + precision: n2 + }); } - async _recoverJournal(journal) { - const log = await this.getActivityLog(), activity = log.entries.find(((entry) => entry.id === journal.id)); - if ("committed" === activity?.commitState) { - await this.storage._setItemAndInvalidate(this.storage.car_list_key, journal.after.carList), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, journal.after.actresses), await this.storage.forage.setItem("new_video_decisions", journal.after.decisions); - } else { - const current = await this._readDomains(), keys = ["carList", "actresses", "decisions"]; - const conflict = keys.some(((key) => { - const value = stableStateValue(current[key]); - return value !== stableStateValue(journal.before[key]) && value !== stableStateValue(journal.after[key]); - })); - if (conflict) throw new Error("检测到未完成状态事务且数据已发生冲突,请先运行数据健康检查"); - await this.storage._setItemAndInvalidate(this.storage.car_list_key, journal.before.carList), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, journal.before.actresses), await this.storage.forage.setItem("new_video_decisions", journal.before.decisions); - journal.before.activity ? await this._writeActivity(journal.before.activity) : (log.entries = log.entries.filter(((entry) => entry.id !== journal.id)), await this._writeActivity(log)); - } - await this.storage.forage.removeItem("mutation_journal"), this.storage._invalidateCache(); + sleep(e2 = 1e3) { + return new Promise(((t2) => setTimeout(t2, e2))); } - async _recoverWithoutLock() { - const journal = await this.storage.forage.getItem("mutation_journal"); - return journal ? (await this._recoverJournal(journal), true) : false; + debounce(callback, wait = 200) { + let timer = null; + const debounced = /* @__PURE__ */ __name(function(...args) { + const context = this; + clearTimeout(timer), timer = setTimeout((() => { + timer = null, callback.apply(context, args); + }), Math.max(0, wait)); + }, "debounced"); + return debounced.cancel = () => { + clearTimeout(timer), timer = null; + }, debounced; } - async recoverPendingTransaction() { - return this._withLock((() => this._recoverWithoutLock())); + genericSort(e2, t2, n2 = true) { + if (!Array.isArray(e2) || 0 === e2.length) return []; + if (!Array.isArray(t2) || 0 === t2.length) return [...e2]; + const i2 = /* @__PURE__ */ __name((e3) => { + if (e3 instanceof Date) return e3; + if ("string" == typeof e3) { + const t3 = new Date(e3); + if (!isNaN(t3.getTime())) return t3; + } + return e3; + }, "i"); + const getVal = /* @__PURE__ */ __name((e3, t3) => null != t3 ? "function" == typeof t3 ? t3(e3) : e3 && "object" == typeof e3 ? e3[t3] : void 0 : e3, "getVal"); + const nulls = [], nonNulls = []; + for (const item of e2) { + let hasNull = false; + for (const s2 of t2) { + const val = getVal(item, s2.key); + if (null == val || void 0 === val) { + hasNull = true; + break; + } + } + hasNull ? nulls.push(item) : nonNulls.push(item); + } + return nonNulls.sort(((e3, a2) => { + for (const s2 of t2) { + const { key: t3, order: o2 = "asc" } = s2; + let r2 = getVal(e3, t3), l2 = getVal(a2, t3); + const c2 = i2(r2), d2 = i2(l2); + let h2 = c2 instanceof Date && d2 instanceof Date ? c2.getTime() - d2.getTime() : "number" == typeof r2 && "number" == typeof l2 ? r2 - l2 : "string" == typeof r2 && "string" == typeof l2 ? r2.localeCompare(l2) : String(r2).localeCompare(String(l2)); + "desc" === o2 && (h2 *= -1); + if (0 !== h2) return h2; + } + return 0; + })), n2 ? [...nonNulls, ...nulls] : [...nulls, ...nonNulls]; } - async patch(carNums, patch, options = {}) { - const keys = [...new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean))]; - if (!keys.length) throw new Error("番号为空"); - const invalidFlag = Object.keys(patch).find(((key) => !STATE_FLAG_NAMES.includes(key) || "boolean" != typeof patch[key])); - if (invalidFlag) throw new TypeError(`无效状态字段: ${invalidFlag}`); - return this._withLock((() => this._patchWithoutLock(keys, patch, options))); + async retry(e2, t2 = 3) { + let n2 = 0; + for (; n2 < t2; ) try { + const t3 = await e2(); + return n2 > 0 && clog.debug(`[重试] 请求成功,共发起 ${n2 + 1} 次。`), t3; + } catch (a2) { + const e3 = a2 instanceof Error ? a2.message : "object" == typeof a2 ? JSON.stringify(a2) : String(a2); + if (a2?._cfBlocked || a2?._circuitBroken || e3.includes("Just a moment") || e3.includes("重定向") || e3.toLowerCase().includes("404 not found")) throw a2; + if (n2++, n2 === t2) throw clog.debug(`[重试] 达到最大重试次数 (${t2}),最终失败:`, a2), a2; + await this.sleep(500 * n2), clog.debug(`[重试] 请求失败,准备第 ${n2 + 1} 次重试, 错误信息: ${e3}`); + } } - async _patchWithoutLock(keys, patch, options) { - await this._recoverWithoutLock(); - const domains = await this._readDomains(), map = new Map(domains.carList.map(((record) => [normalizeCarNum(record.carNum), record]))), changes = [], handled = []; - const records = Array.isArray(options.records) ? new Map(options.records.map(((record) => [normalizeCarNum(record.carNum), record]))) : /* @__PURE__ */ new Map(); - keys.forEach(((carNum) => { - const existing = map.get(carNum), metadata = records.get(carNum) || options.record || {}, now = utils.getNowStr(), before = existing ? cloneStateValue(existing) : null; - const record = existing ? { ...existing, stateFlags: normalizeStateFlags(existing.stateFlags) } : { carNum, url: metadata.url || window.location.href, names: metadata.names || "", createDate: now, stateFlags: createEmptyStateFlags() }; - const fields = []; - ["url", "names", "publishTime", "starId", "remark", "fc2Source"].forEach(((field) => { - if ("fc2Source" === field && !["fc2", "123av"].includes(metadata[field])) return; - if (!Object.prototype.hasOwnProperty.call(metadata, field) || null == metadata[field] || !options.replaceMetadata && "" === metadata[field] || record[field] === metadata[field]) return; - record[field] = metadata[field], fields.push(field); - })); - STATE_FLAG_NAMES.forEach(((flag) => Object.prototype.hasOwnProperty.call(patch, flag) && record.stateFlags[flag] !== patch[flag] && (patch[flag] && handled.push(carNum), record.stateFlags[flag] = patch[flag], fields.push(`stateFlags.${flag}`)))); - if (!fields.length && existing) return; - record.updateDate = now, syncLegacyStatus(record), map.set(carNum, record), changes.push({ carNum, operation: existing ? "patch" : "create", fields, before, after: cloneStateValue(record), undoState: "pending" }); - })); - if (!changes.length) return { changed: [], transactionId: null }; - changes.forEach(((change) => handled.includes(change.carNum) && (change.newVideoEffect = captureNewVideoEffect(domains.actresses, domains.decisions, change.carNum)))); - const effects = this._removeHandledNewVideos(domains.actresses, domains.decisions, handled), activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: options.type || "state-patch", commitState: "pending", changes, createdAt: (/* @__PURE__ */ new Date()).toISOString(), undoAttemptedAt: null }; - await this._commit(domains, { carList: [...map.values()], ...effects }, activity), await this.eventBus.emit("car-state-changed", { carNums: changes.map(((change) => change.carNum)), transactionId: activity.id }), handled.length && await this.eventBus.emit("new-video-changed", { carNums: [...new Set(handled)], reason: "state-handled" }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); - return { changed: changes.map(((change) => change.carNum)), transactionId: activity.id }; + }; + __name(_Utils, "Utils"); + var Utils = _Utils; + + // src/core/legacy-runtime.js + function createLegacyRuntime(eventBus) { + const utils2 = globalThis.utils ?? new Utils(); + const storageManager2 = globalThis.storageManager ?? new StorageManager(); + const gmHttp2 = new GmHttp({ utils: utils2, storageManager: storageManager2 }); + const stateService = new StateService(storageManager2, eventBus); + attachStateServiceCompatibility(stateService, storageManager2); + return Object.freeze({ utils: utils2, gmHttp: gmHttp2, storageManager: storageManager2, stateService }); + } + __name(createLegacyRuntime, "createLegacyRuntime"); + + // src/core/event-bus.js + var _JhsEventBus = class _JhsEventBus { + constructor(channelName = "channel-refresh") { + this.originId = globalThis.crypto?.randomUUID?.() || `tab_${Date.now()}_${Math.random().toString(36).slice(2)}`; + this.listeners = /* @__PURE__ */ new Map(), this.seen = /* @__PURE__ */ new Set(), this.channel = new BroadcastChannel(channelName); + this.channel.addEventListener("message", ((event) => this._receive(event.data))); } - async toggle(carNum, flag, options = {}) { - if (!STATE_FLAG_NAMES.includes(flag)) throw new TypeError(`无效状态字段: ${flag}`); - const key = normalizeCarNum(carNum); - if (!key) throw new Error("番号为空"); - return this._withLock(async () => { - await this._recoverWithoutLock(); - const record = await this.storage.getCar(key), flags = normalizeStateFlags(record?.stateFlags); - return this._patchWithoutLock([key], { [flag]: !flags[flag] }, options); - }); + on(type, handler) { + const handlers = this.listeners.get(type) || /* @__PURE__ */ new Set(); + return handlers.add(handler), this.listeners.set(type, handlers), () => handlers.delete(handler); } - async remove(carNums) { - const keys = new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean)); - return this._withLock(async () => { - await this._recoverWithoutLock(); - const domains = await this._readDomains(), changes = domains.carList.filter(((record) => keys.has(normalizeCarNum(record.carNum)))).map(((record) => ({ carNum: normalizeCarNum(record.carNum), operation: "delete", fields: ["record"], before: cloneStateValue(record), after: null, undoState: "pending" }))); - if (!changes.length) return { changed: [], transactionId: null }; - const activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: "record-delete", commitState: "pending", changes, createdAt: (/* @__PURE__ */ new Date()).toISOString(), undoAttemptedAt: null }; - await this._commit(domains, { carList: domains.carList.filter(((record) => !keys.has(normalizeCarNum(record.carNum)))), actresses: domains.actresses, decisions: domains.decisions }, activity), await this.eventBus.emit("car-records-removed", { carNums: changes.map(((change) => change.carNum)), transactionId: activity.id }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); - return { changed: changes.map(((change) => change.carNum)), transactionId: activity.id }; - }); + async _dispatch(event) { + for (const handler of [...this.listeners.get(event.type) || []]) await handler(event.payload, event); } - async setNewVideoDecision(carNums, action, until = null) { - if (!["ignored", "snoozed", null].includes(action)) throw new TypeError("无效新作决策"); - const keys = [...new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean))]; - return this._withLock(async () => { - await this._recoverWithoutLock(); - const domains = await this._readDomains(), decisions = { ...domains.decisions }, now = (/* @__PURE__ */ new Date()).toISOString(), changes = []; - keys.forEach(((carNum) => { - const before = cloneStateValue(decisions[carNum] || null), after = action ? { action, until: "snoozed" === action ? until : null, createdAt: before?.createdAt || now, updatedAt: now } : null; - stableStateValue(before) === stableStateValue(after) || (after ? decisions[carNum] = after : delete decisions[carNum], changes.push({ carNum, operation: "new-video-decision", fields: ["decision"], before, after, undoState: "pending" })); - })); - if (!changes.length) return { changed: [], transactionId: null }; - const activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: "new-video-decision", commitState: "pending", changes, createdAt: now, undoAttemptedAt: null }; - const changed = changes.map(((change) => change.carNum)); - await this._commit(domains, { carList: domains.carList, actresses: domains.actresses, decisions }, activity), await this.eventBus.emit("new-video-changed", { carNums: changed, reason: action || "decision-restored" }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); - return { changed, transactionId: activity.id }; - }); + _remember(eventId) { + this.seen.add(eventId), this.seen.size > 256 && this.seen.delete(this.seen.values().next().value); } - async removeFromNewVideoList(carNums, reason = "manual") { - const keys = [...new Set((Array.isArray(carNums) ? carNums : [carNums]).map(normalizeCarNum).filter(Boolean))]; - return this._withLock(async () => { - await this._recoverWithoutLock(); - const domains = await this._readDomains(), changed = keys.filter(((carNum) => { - const effect = captureNewVideoEffect(domains.actresses, domains.decisions, carNum); - return effect.actressItems.length > 0 || !!effect.decision; - })); - if (!changed.length) return { changed: [], transactionId: null }; - const effects = this._removeHandledNewVideos(domains.actresses, domains.decisions, changed), activity = { id: globalThis.crypto?.randomUUID?.() || `activity_${Date.now()}`, type: "new-video-remove", commitState: "pending", changes: changed.map(((carNum) => ({ carNum, operation: "new-video-remove", fields: ["newVideoList", "decision"], before: null, after: { removed: true, reason }, newVideoEffect: captureNewVideoEffect(domains.actresses, domains.decisions, carNum), undoState: "pending" }))), createdAt: (/* @__PURE__ */ new Date()).toISOString(), undoAttemptedAt: null }; - await this._commit(domains, { carList: domains.carList, ...effects }, activity), await this.eventBus.emit("new-video-changed", { carNums: changed, reason }), await this.eventBus.emit("activity-log-changed", { transactionId: activity.id }); - return { changed, transactionId: activity.id }; - }); + async emit(type, payload = {}, options = {}) { + const event = { eventId: globalThis.crypto?.randomUUID?.() || `event_${Date.now()}_${Math.random().toString(36).slice(2)}`, originId: this.originId, type, payload, timestamp: Date.now() }; + this._remember(event.eventId), await this._dispatch(event), false !== options.broadcast && this.channel.postMessage(event); + return event; } - async undoTransaction(transactionId) { - return this._withLock(async () => { - await this._recoverWithoutLock(); - const domains = await this._readDomains(), transaction = domains.activity.entries.find(((entry) => entry.id === transactionId && "committed" === entry.commitState)); - if (!transaction) throw new Error("操作记录不存在或尚未提交"); - const carMap = new Map(domains.carList.map(((record) => [normalizeCarNum(record.carNum), cloneStateValue(record)]))), decisions = { ...domains.decisions }, actresses = cloneStateValue(domains.actresses), reverted = [], conflicts = []; - for (const change of transaction.changes) { - if ("reverted" === change.undoState) continue; - const current = carMap.get(change.carNum); - if (change.newVideoEffect && !canRestoreNewVideoEffect(actresses, decisions, change.carNum, change.newVideoEffect)) { - change.undoState = "conflict", conflicts.push(change.carNum); - continue; - } - if ("delete" === change.operation) { - current ? (change.undoState = "conflict", conflicts.push(change.carNum)) : (carMap.set(change.carNum, cloneStateValue(change.before)), change.undoState = "reverted", reverted.push(change.carNum)); - continue; - } - if ("new-video-decision" === change.operation) { - const currentDecision = decisions[change.carNum] || null; - stableStateValue(currentDecision) !== stableStateValue(change.after) ? (change.undoState = "conflict", conflicts.push(change.carNum)) : (change.before ? decisions[change.carNum] = cloneStateValue(change.before) : delete decisions[change.carNum], change.undoState = "reverted", reverted.push(change.carNum)); - continue; - } - if ("new-video-remove" === change.operation) { - restoreNewVideoEffect(actresses, decisions, change.carNum, change.newVideoEffect), change.undoState = "reverted", reverted.push(change.carNum); - continue; - } - if (!["patch", "create"].includes(change.operation) || !current || change.fields.some(((field) => stableStateValue(getStatePath(current, field)) !== stableStateValue(getStatePath(change.after, field))))) { - change.undoState = "conflict", conflicts.push(change.carNum); - continue; - } - if ("create" === change.operation && !change.before) carMap.delete(change.carNum); - else change.fields.forEach(((field) => setStatePath(current, field, getStatePath(change.before, field)))), syncLegacyStatus(current), carMap.set(change.carNum, current); - change.newVideoEffect && restoreNewVideoEffect(actresses, decisions, change.carNum, change.newVideoEffect), change.undoState = "reverted", reverted.push(change.carNum); - } - transaction.undoAttemptedAt = (/* @__PURE__ */ new Date()).toISOString(); - const log = pruneActivityLog(domains.activity), nextCars = [...carMap.values()], journal = { id: `undo_${transactionId}`, state: "prepared", createdAt: transaction.undoAttemptedAt, before: cloneStateValue(domains), after: cloneStateValue({ carList: nextCars, actresses, decisions, activity: log }) }; - await this.storage.forage.setItem("mutation_journal", journal), await this.storage._setItemAndInvalidate(this.storage.car_list_key, nextCars), await this.storage._setItemAndInvalidate(this.storage.favorite_actresses_key, actresses), await this.storage.forage.setItem("new_video_decisions", decisions), await this._writeActivity(log), await this.storage.forage.removeItem("mutation_journal"), this.storage._invalidateCache(); - reverted.length && await this.eventBus.emit("car-state-changed", { carNums: reverted, undoOf: transactionId }), await this.eventBus.emit("activity-log-changed", { transactionId, undo: true }); - return { reverted, conflicts }; - }); + async _receive(event) { + if (!event || event.originId === this.originId || event.eventId && this.seen.has(event.eventId)) return; + if (!event.eventId) { + const legacyType = "refresh" === event.type ? "legacy-refresh" : event.type; + return this._dispatch({ ...event, type: legacyType, payload: event.payload || {}, eventId: `legacy_${Date.now()}_${Math.random()}`, originId: "legacy", timestamp: Date.now() }); + } + this._remember(event.eventId), await this._dispatch(event); } }; - __name(_StateService, "StateService"); - var StateService = _StateService; - var stateService = new StateService(storageManager2, jhsEventBus); - function attachStateServiceCompatibility() { - storageManager2.stateService = stateService; + __name(_JhsEventBus, "JhsEventBus"); + var JhsEventBus = _JhsEventBus; + var jhsEventBus; + function initializeEventBus() { + if (jhsEventBus) return jhsEventBus; + jhsEventBus = new JhsEventBus(); + const runtimeWindow = window; + runtimeWindow.refresh = () => jhsEventBus?.emit("legacy-refresh"); + runtimeWindow.cleanCache_filter_actor_actress_car_list = () => jhsEventBus?.emit("blacklist-rules-changed"); + runtimeWindow.clean_cacheSettingObj = () => jhsEventBus?.emit("settings-changed"); + return jhsEventBus; } - __name(attachStateServiceCompatibility, "attachStateServiceCompatibility"); + __name(initializeEventBus, "initializeEventBus"); // src/core/lifecycle-scope.js var _LifecycleScope = class _LifecycleScope { @@ -7471,7 +7483,7 @@ } async getFilterContext() { if (this.filterContext) return this.filterContext; - const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), stateService.getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); + const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), this.getRuntimeService("state").getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); const cutoff = Date.now() - 7 * 864e5; activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState && change.fields?.some(((field) => field.startsWith("stateFlags."))))).forEach(((change) => recentCarNums.add(change.carNum))))); for (const item of blacklistCars) { @@ -7632,7 +7644,7 @@ let s2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"), o2 = ""; s2.length && (o2 = s2.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e3, `是否屏蔽番号 ${n2}?`, (async () => { try { - o2 || (o2 = await this.parseActressName(a2)), await stateService.patch(n2, { blocked: true }, { record: { carNum: n2, url: a2, names: o2, publishTime: i2, fc2Source } }), show.ok("操作成功"); + o2 || (o2 = await this.parseActressName(a2)), await this.getRuntimeService("state").patch(n2, { blocked: true }, { record: { carNum: n2, url: a2, names: o2, publishTime: i2, fc2Source } }), show.ok("操作成功"); } catch (s3) { clog.error("屏蔽操作失败:", s3), show.error("操作失败"); } @@ -9328,7 +9340,7 @@ const carNum = normalizeCarNum(rawCarNum); try { const current = existing.get(carNum); - await stateService.patch(carNum, { [flag]: true }, { type: "manual-car-number-import", record: { carNum, url: current?.url || buildFallbackCarUrl(carNum), names: current?.names || "", publishTime: current?.publishTime || "" } }); + await this.getRuntimeService("state").patch(carNum, { [flag]: true }, { type: "manual-car-number-import", record: { carNum, url: current?.url || buildFallbackCarUrl(carNum), names: current?.names || "", publishTime: current?.publishTime || "" } }); current ? summary.updated++ : summary.added++; } catch (error) { summary.failed++; @@ -9724,7 +9736,7 @@ for (const s2 of n2) { const t3 = $(s2), { carNum: n3, url: a3, publishTime: o2 } = readListItem(t3); if (a3 && n3) try { - await stateService.patch(n3, { blocked: true }, { type: "actor-page-block", record: { carNum: n3, url: a3, names: e2, publishTime: o2 } }), clog.log("屏蔽演员番号", e2, n3); + await this.getRuntimeService("state").patch(n3, { blocked: true }, { type: "actor-page-block", record: { carNum: n3, url: a3, names: e2, publishTime: o2 } }), clog.log("屏蔽演员番号", e2, n3); } catch (i2) { clog.error(`保存失败 [${n3}]:`, i2); } @@ -9744,7 +9756,7 @@ const n3 = $(i2), { carNum: a3, url: o2, publishTime: r2 } = readListItem(n3); if (o2 && a3) try { const flag = legacyActionToFlag(t2); - flag && await stateService.patch(a3, { [flag]: true }, { type: "actor-page-batch-state", record: { carNum: a3, url: o2, names: e2, publishTime: r2 } }), clog.log("批量操作", e2, a3, t2); + flag && await this.getRuntimeService("state").patch(a3, { [flag]: true }, { type: "actor-page-batch-state", record: { carNum: a3, url: o2, names: e2, publishTime: r2 } }), clog.log("批量操作", e2, a3, t2); } catch (s2) { clog.error(`保存失败 [${a3}]:`, s2); } @@ -10049,6 +10061,9 @@ } __name(stateButtonEntries, "stateButtonEntries"); var _DetailStateController = class _DetailStateController { + constructor(stateService) { + this.stateService = stateService; + } bind({ root = document, layerIndex = null, carNum, getRecord = null, activityType = "detail-state", selectors = {} }) { const config = { root, layerIndex, carNum: normalizeCarNum(carNum), getRecord, activityType, selectors }; for (const [flag, definition] of stateButtonEntries()) { @@ -10073,7 +10088,7 @@ button.prop("disabled", true).attr("aria-busy", "true"); try { const record = "function" == typeof config.getRecord ? await config.getRecord() : config.getRecord || { carNum: config.carNum }; - await stateService.toggle(config.carNum, flag, { type: config.activityType, record }), await this.render(config), await utils.closePage({ layerIndex: config.layerIndex, root: config.root }); + await this.stateService.toggle(config.carNum, flag, { type: config.activityType, record }), await this.render(config), await utils.closePage({ layerIndex: config.layerIndex, root: config.root }); } catch (error) { clog.error("详情状态更新失败", error), show.error("操作失败"); } finally { @@ -10091,7 +10106,6 @@ }; __name(_DetailStateController, "DetailStateController"); var DetailStateController = _DetailStateController; - var detailStateController = new DetailStateController(); // src/core/javdb-api.js var U = "https://jdforrepam.com/api"; @@ -10686,9 +10700,16 @@ ${value}\r // src/plugins/external-search/fc2.js var _Fc2Plugin = class _Fc2Plugin extends BasePlugin { + constructor() { + super(...arguments); + this.detailStateController = null; + } getName() { return "Fc2Plugin"; } + getDetailStateController() { + return this.detailStateController || (this.detailStateController = new DetailStateController(this.getRuntimeService("state"))); + } async resolveMovieId(carNum) { const scope = await this.getRuntimeService("scope")(); return (await this.getRuntimeService("movie").resolve({ carNum }, { scope }))?.movieId || null; @@ -10806,7 +10827,7 @@ ${value}\r if (context.isAlive() && !box.children().length) box.append(hub); if (context.isAlive()) hubGroup.removeClass("is-collapsed"), hubButton.attr("aria-expanded", "true").text("收起磁力搜索"), box[0]?.scrollIntoView?.({ block: "nearest" }); })); - detailStateController.bind({ root: context.root, layerIndex: context.layerIndex ?? null, carNum: context.carNum, activityType: "fc2-state", getRecord: /* @__PURE__ */ __name(() => ({ carNum: context.carNum, url: context.url, fc2Source: context.source, names: context.root.find('[data-jhs-role="actress-data"]').text(), publishTime: context.root.find('[data-jhs-role="publish-time"]').text() }), "getRecord") }); + this.getDetailStateController().bind({ root: context.root, layerIndex: context.layerIndex ?? null, carNum: context.carNum, activityType: "fc2-state", getRecord: /* @__PURE__ */ __name(() => ({ carNum: context.carNum, url: context.url, fc2Source: context.source, names: context.root.find('[data-jhs-role="actress-data"]').text(), publishTime: context.root.find('[data-jhs-role="publish-time"]').text() }), "getRecord") }); void this.getDependency("FilterTitleKeywordPlugin").bindDetailRoot(context.root, { layerIndex: context.layerIndex ?? null }); void this.getDependency("OtherSitePlugin").loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: false, isActive: context.isAlive }).then((box) => { if (context.isAlive() && !box) sitesGroup.remove(); @@ -12571,7 +12592,7 @@ ${failure.stack}` : ""); let n4 = await e2.parseActressName(s3); const flag = legacyActionToFlag(t4); if (!flag) throw new Error("不支持的状态操作"); - await stateService.patch(i3, { [flag]: true }, { type: "list-card-state", record: { carNum: i3, url: s3, names: n4, publishTime: o2, fc2Source } }), show.ok("操作成功"); + await this.getRuntimeService("state").patch(i3, { [flag]: true }, { type: "list-card-state", record: { carNum: i3, url: s3, names: n4, publishTime: o2, fc2Source } }), show.ok("操作成功"); } catch (r3) { clog.error("保存操作失败:", r3), show.error("操作失败"); } @@ -13019,7 +13040,7 @@ ${failure.stack}` : ""); } async getPendingNewVideoTotal() { const e2 = await storageManager.getCarMap(), keys = /* @__PURE__ */ new Set(); - this.nvDecisionsCache = await stateService.getNewVideoDecisions(); + this.nvDecisionsCache = await this.getRuntimeService("state").getNewVideoDecisions(); (await storageManager.getFavoriteActressList()).forEach(((actress) => Array.isArray(actress.newVideoList) && actress.newVideoList.forEach(((item) => { const carNum = normalizeCarNum("string" == typeof item ? item : item.carNum); carNum && !e2.has(carNum) && !this.isDecisionHidden(carNum) && keys.add(carNum); @@ -13096,7 +13117,7 @@ ${failure.stack}` : ""); try { const enabled = await storageManager.getSetting("autoRemoveNewVideoMarkAfterBrowse", C); if (enabled !== _) return; - await stateService.removeFromNewVideoList([t2], "browse"); + await this.getRuntimeService("state").removeFromNewVideoList([t2], "browse"); } catch (n2) { clog.error("移除新作品标记失败:", n2); } @@ -13173,7 +13194,7 @@ ${failure.stack}` : ""); const generation = ++this.nvRenderGeneration, container = "list" === this._viewMode ? $("#new-video-list-container") : $("#actress-card-container"); renderStateView(container, { type: "loading", title: "加载中" }); try { - const [actresses, carMap, decisions, javDbUrl, ruleTime] = await Promise.all([storageManager.getFavoriteActressList(), storageManager.getCarMap(), stateService.getNewVideoDecisions(), this.getDependency("OtherSitePlugin").getJavDbUrl(), storageManager.getSetting("checkNewVideo_ruleTime", 8760)]); + const [actresses, carMap, decisions, javDbUrl, ruleTime] = await Promise.all([storageManager.getFavoriteActressList(), storageManager.getCarMap(), this.getRuntimeService("state").getNewVideoDecisions(), this.getDependency("OtherSitePlugin").getJavDbUrl(), storageManager.getSetting("checkNewVideo_ruleTime", 8760)]); if (!this.isWorkspaceMounted() || generation !== this.nvRenderGeneration) return; this.nvActressesCache = actresses, this.nvCarMapCache = carMap, this.nvDecisionsCache = decisions, this.nvJavDbUrl = javDbUrl, this.nvRuleTime = parseNumberSetting(ruleTime, 8760, { min: 0 }); const items = aggregateNewVideoRecords(actresses, carMap, decisions), nextMap = /* @__PURE__ */ new Map(); @@ -13361,10 +13382,10 @@ ${failure.stack}` : ""); this.nvSelected.clear(), $("#new-video-list-container .nv-select").prop("checked", false), this.renderBatchBar(); })).on("click.jhsNvBatch", "#batchMarkFavorite,#batchMarkWatched,#batchMarkDownloaded", ((event) => { const flag = { batchMarkFavorite: "favorite", batchMarkWatched: "watched", batchMarkDownloaded: "downloaded" }[event.currentTarget.id]; - void this.runBatchMutation(((items) => stateService.patch(items.map(((item) => item.carNum)), { [flag]: true }, { type: "new-video-batch-state", records: items.map(((item) => ({ carNum: item.carNum, url: item.url || `/search?q=${encodeURIComponent(item.carNum)}`, names: item.actressName, publishTime: item.publishTime }))) })), "已处理"); - })).on("click.jhsNvBatch", "#batchIgnore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "ignored")), "已忽略"))).on("click.jhsNvBatch", "#batchSnooze", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), "snoozed", new Date(Date.now() + 7 * 864e5).toISOString())), "已暂缓"))).on("click.jhsNvBatch", "#batchRestore", (() => void this.runBatchMutation(((items) => stateService.setNewVideoDecision(items.map(((item) => item.carNum)), null)), "已恢复"))).on("click.jhsNvBatch", "#batchRemoveFromNewVideo", ((event) => { + void this.runBatchMutation(((items) => this.getRuntimeService("state").patch(items.map(((item) => item.carNum)), { [flag]: true }, { type: "new-video-batch-state", records: items.map(((item) => ({ carNum: item.carNum, url: item.url || `/search?q=${encodeURIComponent(item.carNum)}`, names: item.actressName, publishTime: item.publishTime }))) })), "已处理"); + })).on("click.jhsNvBatch", "#batchIgnore", (() => void this.runBatchMutation(((items) => this.getRuntimeService("state").setNewVideoDecision(items.map(((item) => item.carNum)), "ignored")), "已忽略"))).on("click.jhsNvBatch", "#batchSnooze", (() => void this.runBatchMutation(((items) => this.getRuntimeService("state").setNewVideoDecision(items.map(((item) => item.carNum)), "snoozed", new Date(Date.now() + 7 * 864e5).toISOString())), "已暂缓"))).on("click.jhsNvBatch", "#batchRestore", (() => void this.runBatchMutation(((items) => this.getRuntimeService("state").setNewVideoDecision(items.map(((item) => item.carNum)), null)), "已恢复"))).on("click.jhsNvBatch", "#batchRemoveFromNewVideo", ((event) => { const items = this.selectedItems(); - items.length && utils.q(event, `确认将 ${items.length} 个作品从新作列表移除?
    不会删除作品状态记录。`, (() => void this.runBatchMutation(((selected) => stateService.removeFromNewVideoList(selected.map(((item) => item.carNum)), "manual")), "已移除"))); + items.length && utils.q(event, `确认将 ${items.length} 个作品从新作列表移除?
    不会删除作品状态记录。`, (() => void this.runBatchMutation(((selected) => this.getRuntimeService("state").removeFromNewVideoList(selected.map(((item) => item.carNum)), "manual")), "已移除"))); })); } renderBatchBar() { @@ -14281,14 +14302,14 @@ ${failure.stack}` : ""); let submitted = false; try { button.addClass("loading").attr({ "aria-busy": "true", "aria-disabled": "true" }).text("提交中"), await selected.provider.submit(resource, info), this.registry.updateAvailability(selected.provider.id, { available: true, authState: "ready", reason: "最近提交成功" }); - await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "submitted", retryOf }), submitted = true, button.text("已提交"), show.ok(`${selected.provider.name} 离线任务已创建`), utils.q(event, "是否将该作品标记为已下载?", (async () => { - info?.carNum && await stateService.patch(info.carNum, { downloaded: true }, { type: "offline-mark-downloaded", record: { ...info, names: info.actress || info.names || "" } }); + await this.getRuntimeService("state").appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "submitted", retryOf }), submitted = true, button.text("已提交"), show.ok(`${selected.provider.name} 离线任务已创建`), utils.q(event, "是否将该作品标记为已下载?", (async () => { + info?.carNum && await this.getRuntimeService("state").patch(info.carNum, { downloaded: true }, { type: "offline-mark-downloaded", record: { ...info, names: info.actress || info.names || "" } }); })); } catch (error) { const errorRecord = error, code = errorRecord?.code || ("TOKEN_EXPIRED" === error ? "TOKEN_EXPIRED" : "SUBMIT_FAILED"), message = errorRecord?.message || String(error); ["AUTH_REQUIRED", "LOGIN_REQUIRED", "TOKEN_EXPIRED", "TOKEN_MISSING"].includes(code) && this.registry.updateAvailability(selected.provider.id, { available: false, authState: "115" === selected.provider.id ? "login-required" : "token-missing", reason: message }); restoreButton(); - submitted || await stateService.appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: message, retryOf }), show.error(`${selected.provider.name} 离线失败:${message}`); + submitted || await this.getRuntimeService("state").appendOfflineHistory({ providerId: selected.provider.id, providerName: selected.provider.name, resource, resourceType: /^ed2k:/i.test(resource) ? "ed2k" : "magnet", carNum: info?.carNum, status: "failed", errorCode: code, errorMessage: message, retryOf }), show.error(`${selected.provider.name} 离线失败:${message}`); } finally { submitted ? setTimeout(restoreButton, this.BUTTON_COOLDOWN_MS) : restoreButton(); } @@ -14512,7 +14533,10 @@ ${failure.stack}` : ""); var _StatsPlugin = class _StatsPlugin extends BasePlugin { constructor() { super(...arguments); - this.statsRepository = new StatsRepository({ storage: storageManager, state: stateService }); + this.statsRepository = null; + } + getStatsRepository() { + return this.statsRepository || (this.statsRepository = new StatsRepository({ storage: storageManager, state: this.getRuntimeService("state") })); } getName() { return "StatsPlugin"; @@ -14546,7 +14570,7 @@ ${failure.stack}` : ""); } async openDialog() { const diagnostics = this.getRuntimeService("diagnostics").exportSnapshot(); - const { cars, actresses, blacklist, activity } = await this.statsRepository.loadLibrarySnapshot(), total = cars.length; + const { cars, actresses, blacklist, activity } = await this.getStatsRepository().loadLibrarySnapshot(), total = cars.length; const counts = { manualBlocked: 0, favorite: 0, hasDown: 0, hasWatch: 0, pending: 0 }; cars.forEach(((car) => { const flags = normalizeStateFlags(car.stateFlags); @@ -14830,7 +14854,7 @@ ${failure.stack}` : ""); const button = $(''); $(".jhs-detail-btn-row,.movie-info-container,.container .info").first().append(button); button.on("click", ((event) => utils.q(event, `确定移除 ${carNum} 的鉴定记录?`, (async () => { - await stateService.remove(carNum); + await this.getRuntimeService("state").remove(carNum); button.remove(); this.getDependency("ListPagePlugin")?.showCarNumBox?.(carNum); show.ok("鉴定记录已移除"); @@ -14921,6 +14945,10 @@ ${failure.stack}` : ""); constructor() { super(), this.answerCount = 1; this.stateBinding = null; + this.detailStateController = null; + } + getDetailStateController() { + return this.detailStateController || (this.detailStateController = new DetailStateController(this.getRuntimeService("state"))); } async handle() { this.hideVideoControls(), window.isDetailPage && (await this.createMenuBtn(), await this.autoRemoveNewVideoMark()); @@ -14931,7 +14959,7 @@ ${failure.stack}` : ""); if (e2 !== _) return; const t2 = this.getPageInfo(); if (!t2.carNum) return; - await stateService.removeFromNewVideoList([t2.carNum], "browse"); + await this.getRuntimeService("state").removeFromNewVideoList([t2.carNum], "browse"); } catch (e2) { clog.error("自动移除新作品标记失败:", e2); } @@ -14996,10 +15024,10 @@ ${failure.stack}` : ""); $("#filterBtn, #favoriteBtn, #hasDownBtn, #hasWatchBtn, #magnetSearchBtn, #xunLeiSubtitleBtn, #search-subtitle-btn").prop("disabled", true).attr("title", "番号不可用"); return void clog.warn("详情操作不可用:番号不可用"); } - this.stateBinding = detailStateController.bind({ root: document, carNum: t2, activityType: "detail-state", getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord") }); + this.stateBinding = this.getDetailStateController().bind({ root: document, carNum: t2, activityType: "detail-state", getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord") }); } async showStatus(e2) { - return detailStateController.render({ root: document, carNum: e2 }); + return this.getDetailStateController().render({ root: document, carNum: e2 }); } getStateRecord() { const info = this.getPageInfo(); @@ -15011,13 +15039,13 @@ ${failure.stack}` : ""); return this.stateBinding = { root: document, layerIndex: null, carNum: normalizeCarNum(info.carNum), getRecord: /* @__PURE__ */ __name(() => this.getStateRecord(), "getRecord"), activityType: "detail-state", selectors: {} }; } async favoriteOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "favorite", event); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "favorite", event); } async hasDownOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "downloaded", event); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "downloaded", event); } async hasWatchOne(event) { - return detailStateController.requestToggle(this.getStateBinding(), "watched", event); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "watched", event); } async searchXunLeiSubtitle(e2) { const dialog = this.getRuntimeService("dialog"), subtitle = this.getRuntimeService("subtitle"), scope = await this.getRuntimeService("scope")(); @@ -15100,7 +15128,7 @@ ${failure.stack}` : ""); } async filterOne(e2, t2) { e2 && e2.preventDefault(); - return detailStateController.requestToggle(this.getStateBinding(), "blocked", e2); + return this.getDetailStateController().requestToggle(this.getStateBinding(), "blocked", e2); } hideVideoControls() { $(document).on("mouseenter", "#preview-video", ((event) => { @@ -15414,11 +15442,14 @@ ${failure.stack}` : ""); // src/plugins/status/history.js var _HistoryPlugin = class _HistoryPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectionModel", new HistorySelectionModel()), i(this, "historyRepository", new HistoryRepository({ storage: storageManager, state: stateService })), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); + super(...arguments), i(this, "tableObj", null), i(this, "historyRoot", null), i(this, "historySelectionModel", new HistorySelectionModel()), i(this, "_historyRepository", null), i(this, "historySorters", []), i(this, "historyFilteredCount", 0), i(this, "historySelectionSyncing", false); } getName() { return "HistoryPlugin"; } + get historyRepository() { + return this._historyRepository || (this._historyRepository = new HistoryRepository({ storage: storageManager, state: this.getRuntimeService("state") })); + } async initCss() { return ` `; - } - constructor() { - super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "filterContext", null), i(this, "pendingItems", /* @__PURE__ */ new Set()), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), i(this, "itemIndex", /* @__PURE__ */ new Map()), i(this, "recountFrame", null); + + // src/plugins/backup/setting-forms.js + var settingsEventBus = jhsEventBus; + async function loadSettingForm(dependencies) { + let e2 = await storageManager.getSetting(); + $("#videoQuality").val(e2.videoQuality), $("#reviewCount").val(e2.reviewCount || 20), $("#tagPosition").val(e2.tagPosition || "rightTop"), $("#defaultQuickFilterTab").val(normalizeQuickFilterKey(e2.defaultQuickFilterTab)), $("#needClosePageBasic").prop("checked", !e2.needClosePage || e2.needClosePage === _), $("#autoRemoveNewVideoMarkAfterBrowse").prop("checked", !!e2.autoRemoveNewVideoMarkAfterBrowse && e2.autoRemoveNewVideoMarkAfterBrowse === _), $("#waitCheckCount").val(e2.waitCheckCount || 5), $("#checkConcurrencyCount").val(parseNumberSetting(e2.checkConcurrencyCount, 2, { min: 2, max: 5 })), $("#checkRequestSleep").val(parseNumberSetting(e2.checkRequestSleep, 100, { min: 0, max: 3e3 })), $("#enableCheckBlacklist").val(e2.enableCheckBlacklist || _), $("#checkBlacklist_intervalTime").val(e2.checkBlacklist_intervalTime || 12), $("#checkBlacklist_ruleTime").val(parseNumberSetting(e2.checkBlacklist_ruleTime, 8760, { min: 0 })), $("#enableCheckFavoriteActress").val(e2.enableCheckFavoriteActress || _), $("#checkFavoriteActress_IntervalTime").val(e2.checkFavoriteActress_IntervalTime || 24), $("#enableCheckNewVideo").val(e2.enableCheckNewVideo || _), $("#checkNewVideo_intervalTime").val(e2.checkNewVideo_intervalTime || 12), $("#checkNewVideo_ruleTime").val(parseNumberSetting(e2.checkNewVideo_ruleTime, 8760, { min: 0 })); + const t2 = e2.highlightedTagNumber || 1, n2 = e2.highlightedTagColor || "#ce2222"; + $("#highlightedTagNumber").val(e2.highlightedTagNumber || 1), $("#highlightedTagColor").val(e2.highlightedTagColor || "#ce2222"), $("#highlightedTagLabel").css("border", `${t2}px solid ${n2}`), $("#enableClog").val(e2.enableClog || _), $("#clogMsgCount").val(e2.clogMsgCount || 2e3), $("#mobileMode").val(e2.mobileMode || "auto"), $("#themeMode").val(e2.themeMode || "light"), $("#httpTimeout").val(e2.httpTimeout || 5e3), $("#httpRetryCount").val(e2.httpRetryCount || 3), $("#webDavUrl").val(e2.webDavUrl || ""), $("#webDavUsername").val(e2.webDavUsername || ""), $("#webDavPassword").val(await decryptCredential(e2.webDavPassword) || ""), $("#enableTitleSelectFilter").prop("checked", !e2.enableTitleSelectFilter || e2.enableTitleSelectFilter === _), $("#enableFavoriteActresses").prop("checked", !e2.enableFavoriteActresses || e2.enableFavoriteActresses === _), $("#enableSaveActressCarInfo").prop("checked", !!e2.enableSaveActressCarInfo && e2.enableSaveActressCarInfo === _), $("#enableScreenSvg").prop("checked", !e2.enableScreenSvg || e2.enableScreenSvg === _), $("#enableVideoSvg").prop("checked", !e2.enableVideoSvg || e2.enableVideoSvg === _), $("#enableHandleSvg").prop("checked", !e2.enableHandleSvg || e2.enableHandleSvg === _), $("#enableSiteSvg").prop("checked", !e2.enableSiteSvg || e2.enableSiteSvg === _), $("#enableCopySvg").prop("checked", !e2.enableCopySvg || e2.enableCopySvg === _), $("#enableLoadActressInfo").prop("checked", !e2.enableLoadActressInfo || e2.enableLoadActressInfo === _), $("#enableVerticalModel").prop("checked", !!e2.enableVerticalModel && e2.enableVerticalModel === _), $("#containerColumns").val(e2.containerColumns || 5), $("#showContainerColumns").text(e2.containerColumns || 5), $("#containerWidth").val((e2.containerWidth || 100) - 70), $("#showContainerWidth").text((e2.containerWidth || 100) + "%"); + const a2 = dependencies.otherSite, i2 = await a2.getMissAvUrl(), s2 = await a2.getjableUrl(), o2 = await a2.getAvgleUrl(), r2 = await a2.getJavTrailersUrl(), l2 = await a2.getAv123Url(), c2 = await a2.getJavDbUrl(), d2 = await a2.getJavBusUrl(), h2 = await a2.getSupJavUrl(); + $("#missAvUrl").val(i2), $("#jableUrl").val(s2), $("#avgleUrl").val(o2), $("#javTrailersUrl").val(r2), $("#av123Url").val(l2), $("#javDbUrl").val(c2), $("#javBusUrl").val(d2), $("#supJavUrl").val(h2); + let g2 = await storageManager.getReviewFilterKeywordList(), p2 = await storageManager.getTitleFilterKeyword(); + g2 && g2.forEach((e3) => { + addLabelTag("#reviewKeywordContainer", e3); + }), p2 && p2.forEach((e3) => { + addLabelTag("#filterKeywordContainer", e3); + }), ["#reviewKeywordContainer", "#filterKeywordContainer"].forEach((e3) => { + $(`${e3} .add-tag-btn`).on("click", ((t3) => addKeyword(t3, e3))), $(`${e3} .keyword-input`).on("keypress", ((t3) => { + "Enter" === t3.key && addKeyword(t3, e3); + })); + }); + bindLayoutRangeEvents(dependencies.busImg, dependencies.host); + } + __name(loadSettingForm, "loadSettingForm"); + function bindLayoutRangeEvents(busImgPlugin, hostAdapter) { + $("#containerColumns").off(".jhsSetting").on("input.jhsSetting", (() => { + const columns = $("#containerColumns").val(); + $("#showContainerColumns").text(columns); + const listRoot = hostAdapter?.locateListRoot?.(); + listRoot && (listRoot.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); + })).on("change.jhsSetting", (async (event) => { + await storageManager.saveSettingItem("containerColumns", $(event.currentTarget).val()), await applyImageMode(busImgPlugin); + })); + $("#containerWidth").off(".jhsSetting").on("input.jhsSetting", ((event) => { + const width = parseInt($(event.target).val()) + 70, widthText = `${width}%`; + $("#showContainerWidth").text(widthText); + const layoutContainer = hostAdapter?.getListLayoutContainer?.(); + layoutContainer && (layoutContainer.style.minWidth = widthText); + })).on("change.jhsSetting", ((event) => storageManager.saveSettingItem("containerWidth", parseInt($(event.currentTarget).val()) + 70))); + } + __name(bindLayoutRangeEvents, "bindLayoutRangeEvents"); + async function initQuickSettingForm(dependencies, getSelector, openSettingDialogFn) { + let e2 = await storageManager.getSetting(); + $("#needClosePage").prop("checked", !e2.needClosePage || e2.needClosePage === _), $("#autoPage").prop("checked", !e2.autoPage || e2.autoPage === _), $("#translateTitle").prop("checked", !e2.translateTitle || e2.translateTitle === _), $("#enableLoadActressInfo").prop("checked", !e2.enableLoadActressInfo || e2.enableLoadActressInfo === _), $("#enableLoadOtherSite").prop("checked", !e2.enableLoadOtherSite || e2.enableLoadOtherSite === _), $("#needClosePage").on("change", (async (t2) => { + await storageManager.saveSettingItem("needClosePage", $("#needClosePage").is(":checked") ? _ : C), await settingsEventBus.emit("filter-rules-changed"); + })), $("#autoPage").on("change", (async (t2) => { + const n2 = $("#autoPage").is(":checked") ? _ : C; + await storageManager.saveSettingItem("autoPage", n2), $("#sort-toggle-btn").prop("disabled", n2 === _).attr("title", n2 === _ ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"); + })), $("#translateTitle").on("change", (async (t2) => { + const n2 = $("#translateTitle").is(":checked") ? _ : C; + await storageManager.saveSettingItem("translateTitle", n2), n2 === _ ? (await dependencies.listPage.doFilter(), isDetailPage && await dependencies.translate?.translate()) : (await dependencies.listPage.revertTranslation(), $(".translated-title").remove()); + })), $("#hoverBigImg").prop("checked", !!e2.hoverBigImg && e2.hoverBigImg === _), $("#hoverBigImg").on("change", (async (t2) => { + const n2 = $("#hoverBigImg").is(":checked") ? _ : C; + const runtimeWindow = window; + await storageManager.saveSettingItem("hoverBigImg", n2), runtimeWindow.imageHoverPreviewObj && (runtimeWindow.imageHoverPreviewObj.destroy(), runtimeWindow.imageHoverPreviewObj = null), n2 === _ && (runtimeWindow.imageHoverPreviewObj = new ImageHoverPreview({ + selector: getSelector().coverImgSelector + })); + })), $("#enableLoadActressInfo").on("change", (async (t2) => { + const n2 = $("#enableLoadActressInfo").is(":checked") ? _ : C; + await storageManager.saveSettingItem("enableLoadActressInfo", n2), n2 === _ ? dependencies.actressInfo?.loadActressInfo() : $(".actress-info").remove(); + })), $("#enableLoadOtherSite").on("change", (async (t2) => { + const n2 = $("#enableLoadOtherSite").is(":checked") ? _ : C; + await storageManager.saveSettingItem("enableLoadOtherSite", n2), n2 === _ ? await dependencies.otherSite.loadOtherSite() : $("#otherSiteBox").remove(); + })), $("#enableLoadScreenShot").prop("checked", !e2.enableLoadScreenShot || e2.enableLoadScreenShot === _), $("#enableLoadScreenShot").on("change", (async (t2) => { + const n2 = $("#enableLoadScreenShot").is(":checked") ? _ : C; + await storageManager.saveSettingItem("enableLoadScreenShot", n2), n2 === _ ? await dependencies.screenshot.loadScreenShot() : $(".screen-container").remove(); + })), $("#enableLoadPreviewVideo").prop("checked", !e2.enableLoadPreviewVideo || e2.enableLoadPreviewVideo === _), $("#enableLoadPreviewVideo").on("change", (async (t2) => { + const n2 = $("#enableLoadPreviewVideo").is(":checked") ? _ : C; + await storageManager.saveSettingItem("enableLoadPreviewVideo", n2); + })), $("#enableVerticalModel").prop("checked", !!e2.enableVerticalModel && e2.enableVerticalModel === _), $("#enableVerticalModel").on("change", (async (t2) => { + const n2 = $("#enableVerticalModel").is(":checked") ? _ : C; + await storageManager.saveSettingItem("enableVerticalModel", n2), applyImageMode(dependencies.busImg); + })), $("#moreBtn").on("click", (() => { + $(".simple-setting, .mini-simple-setting").html("").hide(), openSettingDialogFn("base-panel"); + })); + } + __name(initQuickSettingForm, "initQuickSettingForm"); + async function saveSettingForm(dependencies) { + let e2 = await storageManager.getSetting(); + const nextWebDavUrl = String($("#webDavUrl").val() || "").trim(); + let nextWebDavOrigin = null; + if (nextWebDavUrl) { + try { + const parsed = new URL(nextWebDavUrl); + if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("protocol"); + nextWebDavOrigin = parsed.origin; + } catch { + return void show.error("WebDAV 地址必须是有效的 HTTP/HTTPS URL"); + } } - getName() { - return "ListPagePlugin"; + const trustedOrigins = new Set(Array.isArray(e2.trustedLocalOrigins) ? e2.trustedLocalOrigins : []); + if (nextWebDavOrigin && !trustedOrigins.has(nextWebDavOrigin)) { + const authorized = await new Promise((resolve) => utils.q(null, `仅授权 WebDAV 精确来源:${nextWebDavOrigin},是否继续?`, () => resolve(true), () => resolve(false))); + if (!authorized) return void show.info("已取消 WebDAV 来源授权"); + trustedOrigins.add(nextWebDavOrigin); } - async handle() { - if (!window.isListPage || isHitShowPage()) return; - const scope = await this.getRuntimeService("scope")(); - const refreshAll = /* @__PURE__ */ __name(async () => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); - const e2 = this.getDependency("HistoryPlugin"); - e2.tableObj && e2.tableObj.setData(); - }, "refreshAll"); - ["legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed"].forEach(((type) => scope.addCleanup(jhsEventBus.on(type, refreshAll)))), scope.addCleanup(jhsEventBus.on("car-state-changed", (async (payload) => { - this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); - const items = this.getIndexedItems(payload.carNums || []); - items.length && (await this.doFilterItems(items), this.applyVisibility(items)); - const history = this.getDependency("HistoryPlugin"); - history.tableObj && history.tableObj.setData(); - }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(scope), scope.addCleanup((() => { - this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); - this.hdImageObserver?.disconnect(), this.hdImageObserver = null; - this.recountFrame && (globalThis.cancelAnimationFrame?.(this.recountFrame) ?? clearTimeout(this.recountFrame)), this.recountFrame = null; - $(this.getSelector().boxSelector).off(".jhsMovieDetail"), $("#jhs-quick-filter").off(), this.itemIndex.clear(); - })); - } - async createQuickFilter() { - if ($("#jhs-quick-filter").length) return; - const e2 = this.getSelector(), primaryHtml = PRIMARY_QUICK_FILTERS.map(((filter) => ``)).join(""), secondaryHtml = SECONDARY_QUICK_FILTERS.map(((filter, index) => `${1 === index ? '' : ""}`)).join(""), t2 = `
    -
    ${primaryHtml}
    -
    - - -
    -
    `; - r ? $(e2.boxSelector).before(t2) : l && $(".masonry").before(t2); - const root = $("#jhs-quick-filter"), toggle = root.find(".jhs-quick-filter__toggle"), menu = root.find(".jhs-quick-filter__menu"), closeMenu = /* @__PURE__ */ __name((restoreFocus = false) => { - menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), restoreFocus && toggle.trigger("focus"); - }, "closeMenu"); - root.on("click", ".jhs-segmented__item", ((event) => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))).on("keydown", ".jhs-segmented__item", ((event) => { - if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const tabs = root.find(".jhs-segmented__item"), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; - tabs.eq(next).trigger("click").trigger("focus"); - })).on("click", ".jhs-filter-option", ((event) => { - this.setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); - })).on("keydown", ".jhs-filter-option", ((event) => { - const items = menu.find(".jhs-filter-option"), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), closeMenu(true); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - toggle.on("click", ((event) => { - event.preventDefault(), event.stopPropagation(); - const open = !menu.hasClass("is-open"); - menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && (menu.find('[aria-checked="true"]').first().length ? menu.find('[aria-checked="true"]').first() : menu.find(".jhs-filter-option").first()).trigger("focus"); - })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", ((event) => { - $(event.target).closest(root).length || closeMenu(); - })); - this.setQuickFilter(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck")); - } - applyVisibility(items = null) { - const e2 = this.activeQuickFilter || "waitCheck", t2 = this.getSelector().itemSelector; - (items ? $(items) : $(t2)).each((function() { - const t3 = $(this), flags = normalizeStateFlags(JSON.parse(t3.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t3.attr("data-jhs-visibility") || "{}"), recent = "yes" === t3.attr("data-jhs-recent"); - shouldShowItem({ filter: e2, flags, visibilityReasons, recent }) ? t3.show() : t3.hide(); - })); - } - setQuickFilter(filter, { syncUi = true } = {}) { - this.activeQuickFilter = normalizeQuickFilterKey(filter), this.applyVisibility(), syncUi && this.syncQuickFilterUi(); - } - syncQuickFilterUi() { - const filter = normalizeQuickFilterKey(this.activeQuickFilter), isPrimary = PRIMARY_QUICK_FILTERS.includes(filter), root = $("#jhs-quick-filter"), tabs = root.find(".jhs-segmented__item"), options = root.find(".jhs-filter-option"); - tabs.removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }); - isPrimary ? tabs.filter(`[data-jhs-filter="${filter}"]`).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }) : tabs.first().attr("tabindex", "0"); - options.attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); - root.find(".jhs-quick-filter__label").text(isPrimary ? "更多筛选" : `筛选:${QUICK_FILTER_LABELS[filter]}`); - $(".jhs-mobile-filter-label").text(`筛选:${QUICK_FILTER_LABELS[filter]}`), $(".jhs-mobile-filter-option").attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); - } - rememberTagExpand() { - if (!window.location.href.includes("actors")) return; - const e2 = $(".tag-expand"); - if (0 === e2.length) return; - const storage = this.getRuntimeService("storage"), t2 = "jhs_tag_expand", n2 = "true" === storage.getLocal(t2), a2 = $(".actor-tags .content"); - n2 && a2.hasClass("collapse") && e2[0].click(), e2.on("click", (function() { - const e3 = !$(".actor-tags .content").hasClass("collapse"); - clog.debug("触发"), storage.setLocal(t2, e3.toString()); - })); - } - checkDom(scope = null) { - if (!window.isListPage || isHitShowPage()) return; - const e2 = this.getSelector(), t2 = document.querySelector(e2.boxSelector); - if (!t2) return void clog.error("没有找到容器节点!"); - if (!scope) return; - scope.observe(t2, ((records) => { - for (const record of records) { - this.removeIndexedItems(record.removedNodes); - for (const node of record.addedNodes) { - if (node.nodeType !== Node.ELEMENT_NODE) continue; - node.matches?.(e2.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), node.querySelectorAll?.(e2.itemSelector).forEach(((item) => { - "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); - })); - } - } - this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { - const items = [...this.pendingItems].filter(((item) => item.isConnected && "true" !== item.dataset.jhsProcessed)); - this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch(((error) => clog.error("列表增量处理失败", error))); - }), 100)); - }), { - childList: true, - subtree: false - }); - } - async processAddedItems(items) { - const selector = this.getSelector(), covers = items.flatMap(((item) => [...item.querySelectorAll(selector.coverImgSelector)])); - this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), items.forEach(((item) => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: false }), this.getDependency("AutoPagePlugin").checkLoad(); - } - rebuildItemIndex() { - this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); - } - indexItems(items) { - items.forEach(((item) => { - try { - const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); - if (!key) return; - const indexed = this.itemIndex.get(key) || /* @__PURE__ */ new Set(); - indexed.add(item), this.itemIndex.set(key, indexed); - } catch (error) { - clog.debug("列表项索引跳过无效卡片", error); - } - })); - } - removeIndexedItems(nodes) { - const removed = /* @__PURE__ */ new Set(); - Array.from(nodes || []).forEach(((node) => { - node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach(((item) => removed.add(item)))); - })); - if (!removed.size) return; - this.itemIndex.forEach(((items, key) => { - items.forEach(((item) => { - removed.has(item) && items.delete(item); - })), items.size || this.itemIndex.delete(key); - })); - } - getIndexedItems(carNums) { - const result = /* @__PURE__ */ new Set(); - carNums.map(normalizeCarNum).forEach(((key) => { - const items = this.itemIndex.get(key); - items?.forEach(((item) => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); - })); - return [...result]; - } - fixBusTitleBox(items = null) { - if (!l) return; - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((e2) => { - var t2; - let n2 = $(e2); - if (n2.find(".avatar-box").length > 0) return; - const a2 = (null == (t2 = n2.find("img").attr("title")) ? void 0 : t2.trim()) || ""; - n2.find(".photo-info span:first").contents().first().wrap(`${a2}`), n2.find("br").remove(); - })); - } - cleanRepeatId() { - if (!l) return; - $("#waterfall_h").removeAttr("id").attr("id", "no-page"); - const e2 = $('[id="waterfall"]'); - 0 !== e2.length && e2.each((function() { - const e3 = $(this); - if (!e3.hasClass("masonry")) { - e3.children().insertAfter(e3), e3.remove(); - } - })); - } - async doFilter() { - return this.doFilterItems(); - } - async doFilterItems(items = null) { - if (!window.isListPage) return; - let e2 = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); - e2.length && (await this.filterMovieList(e2), l && setTimeout((() => { - this.getDependency("BusImgPlugin").logImageHeightsByRow().catch(((e3) => clog.error("JavBus图片高度修正失败", e3))); - }))); - } - async yieldListFrame() { - await new Promise(((e2) => { - window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e2))) : setTimeout(e2); - })); - } - findMatchedTitleKeyword(e2, t2, n2) { - for (const a2 of e2) if (t2.includes(a2) || n2.startsWith(a2)) return a2; - return null; - } - async getFilterContext() { - if (this.filterContext) return this.filterContext; - const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), this.getRuntimeService("state").getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); - const cutoff = Date.now() - 7 * 864e5; - activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState && change.fields?.some(((field) => field.startsWith("stateFlags."))))).forEach(((change) => recentCarNums.add(change.carNum))))); - for (const item of blacklistCars) { - const role = blacklistMap.get(item.starId)?.role; - if (!role) { - clog.error("黑名单数据源丢失演员信息", item); - continue; - } - const target = role === B ? actorCarNumToNameMap : actressCarNumToNameMap, carNum = normalizeCarNum(item.carNum); - target.has(carNum) || target.set(carNum, item.names); - } - return this.filterContext = { titleKeywords, settings, carMap, recentCarNums, actorCarNumToNameMap, actressCarNumToNameMap }; - } - collectCurrentPageSummary() { - const summary = { total: 0, pending: 0, blockedItems: 0, favorite: 0, downloaded: 0, watched: 0, debug: { manualBlocked: 0, keywordBlocked: 0, actorBlocked: 0, actressBlocked: 0 } }; - $(this.getSelector().itemSelector).each(((e2, item) => { - const card = $(item); - if (l && card.find(".avatar-box").length > 0) return; - const flags = normalizeStateFlags(JSON.parse(card.attr("data-jhs-flags") || "{}")), reasons = JSON.parse(card.attr("data-jhs-visibility") || "{}"), hardHidden = isHardHidden(flags, reasons); - summary.total++, flags.favorite && summary.favorite++, flags.downloaded && summary.downloaded++, flags.watched && summary.watched++, hardHidden && summary.blockedItems++, !hasAnyState(flags) && !hardHidden && summary.pending++, flags.blocked && summary.debug.manualBlocked++, reasons.keyword && summary.debug.keywordBlocked++, reasons.actorBlacklist && summary.debug.actorBlocked++, reasons.actressBlacklist && summary.debug.actressBlocked++; - })); - return summary; - } - getCurrentPageSummary() { - return this.collectCurrentPageSummary(); - } - recountStatuses() { - const summary = this.collectCurrentPageSummary(); - this.currentPageFilterCount = summary.debug.manualBlocked, this.currentPageFavoriteCount = summary.favorite, this.currentPageHasDownCount = summary.downloaded, this.currentPageHasWatchCount = summary.watched, this.currentPageKeywordFilterCount = summary.debug.keywordBlocked, this.currentPageActorFilterCount = summary.debug.actorBlocked + summary.debug.actressBlocked, this.currentPageWaitCheckCount = summary.pending, this.currentPageTotalCount = summary.total; - return summary; - } - scheduleRecount() { - if (this.recountFrame) return; - const schedule = window.requestAnimationFrame || ((callback) => setTimeout(callback, 0)); - this.recountFrame = schedule((() => { - this.recountFrame = null, this.recountStatuses(); - })); - } - async translateListItems(e2) { - if (await storageManager.getSetting("translateTitle", _) !== _) return; - await mapLimit(e2, 3, (async (item, index) => { - try { - index > 0 && index % 8 == 0 && await this.yieldListFrame(), await this.translate(item); - } catch (error) { - clog.error("列表标题翻译失败", error); - } - })); - } - async filterMovieList(e2) { - utils.time("累计耗费时间"), utils.time("读取数据耗时"); - const { titleKeywords: n2, settings: s2, carMap: m2, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v2 } = await this.getFilterContext(), o2 = utils.time("读取数据耗时"); - utils.time("组装数据耗时"); - const b2 = utils.time("组装数据耗时"), P2 = (null == s2 ? void 0 : s2.tagPosition) || "rightTop"; - const O2 = n2.filter(((e3) => e3)); - this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); - const R = []; - for (let n3 = 0; n3 < e2.length; n3++) { - n3 > 0 && n3 % 12 == 0 && await this.yieldListFrame(); - let t2 = $(e2[n3]); - if (l && t2.find(".avatar-box").length > 0) continue; - const { carNum: a2, title: i2 } = this.findCarNumAndHref(t2), record = m2.get(a2), flags = normalizeStateFlags(record?.stateFlags), actorFiltered = f.has(a2), actressFiltered = v2.has(a2), keyword = this.findMatchedTitleKeyword(O2, i2, a2), visibilityReasons = { keyword: !!keyword, actorBlacklist: actorFiltered, actressBlacklist: actressFiltered }; - const hardHidden = isHardHidden(flags, visibilityReasons); - t2.attr("data-jhs-flags", JSON.stringify(flags)).attr("data-jhs-visibility", JSON.stringify(visibilityReasons)).attr("data-jhs-recent", recent.has(a2) ? _ : C).attr("data-jhs-tag-position", P2); - const signature = JSON.stringify({ flags, visibilityReasons, P: P2 }); - if (t2.attr("data-jhs-state-signature") !== signature) { - t2.attr("data-jhs-state-signature", signature), t2.find(".jhs-status-tags").remove(); - const badgeDefs = [ - [flags.blocked, Te.IS_FILTERED, "单番号屏蔽"], - [flags.favorite, Te.IS_FAVORITE, ""], - [flags.downloaded, Te.IS_HAS_DOWN, ""], - [flags.watched, Te.IS_HAS_WATCH, ""], - [visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知"], - [visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a2) || ""], - [visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v2.get(a2) || ""] - ].filter(((item) => item[0])); - if (badgeDefs.length) { - const box = $(``); - badgeDefs.forEach((([, definition, tip]) => { - const badge = $(`${escapeHtml(definition.text)}`); - badge.css({ color: definition.on, backgroundColor: definition.color }), box.append(badge); - })); - if (r) t2.find(".tags").append(box); - else if (l) { - const host = t2.find(".item-tag"); - host.length ? host.append(box) : t2.find(".photo-info > span > div").append(box); - } - } - } - hardHidden || R.push(t2); - } - this.scheduleRecount(), void this.translateListItems(R).catch(((e3) => clog.error("列表页翻译任务失败", e3))); - const D2 = utils.time("处理页面耗时"), A2 = utils.time("累计耗费时间"); - clog.log(` -
    时间插件阶段错误信息
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    ${o2}${b2}
    ${D2}${A2}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    待鉴定${this.currentPageWaitCheckCount}
    总数${this.currentPageTotalCount}
    - `); - } - async bindClick() { - let e2 = this.getSelector(); - this.bindMovieDetailNavigation(e2.boxSelector), $(e2.boxSelector).on("click", ".item video", (async (e3) => { - const t2 = e3.currentTarget; - t2.paused ? await safePlay(t2, { - context: "列表视频", - notify: true - }) : t2.pause(), e3.preventDefault(), e3.stopPropagation(); - })), $(e2.boxSelector).on("contextmenu", ".item img, .item video", (async (e3) => { - try { - e3.preventDefault(); - const t2 = $(e3.target).closest(".item"), { carNum: n2, url: a2, publishTime: i2, fc2Source } = this.findCarNumAndHref(t2); - let s2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"), o2 = ""; - s2.length && (o2 = s2.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e3, `是否屏蔽番号 ${n2}?`, (async () => { - try { - o2 || (o2 = await this.parseActressName(a2)), await this.getRuntimeService("state").patch(n2, { blocked: true }, { record: { carNum: n2, url: a2, names: o2, publishTime: i2, fc2Source } }), show.ok("操作成功"); - } catch (s3) { - clog.error("屏蔽操作失败:", s3), show.error("操作失败"); - } - })); - } catch (t2) { - clog.error("右键菜单处理失败:", t2); - } - })); - } - async openMovieDetail(item, { event = null, autoplay = false, newTab = false } = {}) { - const card = item?.jquery ? item : $(item), { carNum, aHref, fc2Source } = this.findCarNumAndHref(card); - if (!carNum || !aHref) return; - const shouldOpenTab = newTab || !!event && (event.ctrlKey || event.metaKey || 1 === event.button); - if (carNum.includes("FC2-")) { - const movieId = this.parseMovieId(aHref); - return shouldOpenTab ? this.getDependency("Fc2Plugin").openFc2Page(movieId, carNum, aHref, { event, newTab: true }, { source: fc2Source }) : this.getDependency("Fc2Plugin").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source }); - } - const destination = new URL(aHref, window.location.origin); - autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, true, { event, newTab: shouldOpenTab }), this.$currentImage = null; - } - bindMovieDetailNavigation(container) { - const root = $(container), selector = ".item img, .item .video-title"; - root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, ((event) => { - if ("auxclick" === event.type && 1 !== event.button || "click" === event.type && event.button && 0 !== event.button) return; - if (event.shiftKey || event.altKey || $(event.target).closest("div.meta-buttons,[class^='jhs-match-']").length) return; - event.preventDefault(), event.stopPropagation(); - void this.openMovieDetail($(event.currentTarget).closest(".item"), { event }).catch(((error) => clog.error("打开影片详情失败", error))); - })); - } - async parseActressName(e2) { - let t2 = null; - if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { - clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e2); - const scope = await this.getRuntimeService("scope")(); - const n2 = await requestHostPage(this.getRuntimeService("http"), e2, scope), a2 = utils.htmlTo$dom(n2); - r ? t2 = a2.find(".female").prev().map(((e3, t3) => $(t3).text())).get().join(" ") : l && (t2 = a2.find('span[onmouseover*="star_"] a').map(((e3, t3) => $(t3).text())).get().join(" ")), clog.debug("解析到名称:", t2); - } - return t2; - } - findCarNumAndHref(e2) { - try { - return readListItem(e2); - } catch (error) { - show.error("提取番号信息失败"); - throw error; - } - } - showCarNumBox(e2) { - const t2 = this.getRuntimeService("host").locateListItems().find(((t3) => $(t3).find(".video-title strong").text() === e2)); - if (t2) { - const n2 = $(t2); - n2.attr("data-hide") === "yes" && (n2.show(), n2.removeAttr("data-hide")); - } - } - _replaceSingleHdImg(e2) { - if ("true" === e2.dataset.hdReplaced) return; - if (r) { - const isJavdbCdn = /jdbstatic\.com|javdb\.com/i.test(e2.src); - if (isJavdbCdn) { - const originalSrc = e2.src; - e2.src = e2.src.replace("thumbs", "covers"); - e2.dataset.hdReplaced = "true"; - e2.title = ""; - e2.onerror = function() { - if (this.src !== originalSrc) { - this.src = originalSrc; - this.onerror = null; - } - }; - } - } else if (l) { - const t2 = /\/(imgs|pics)\/(thumb|thumbs)\//, n2 = /(\.jpg|\.jpeg|\.png)$/i; - t2.test(e2.src) ? (e2.src = e2.src.replace(t2, "/$1/cover/").replace(n2, "_b$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = "") : /ps(\.jpg|\.jpeg|\.png)$/i.test(e2.src) && (e2.src = e2.src.replace(/ps(\.jpg|\.jpeg|\.png)$/i, "pl$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = ""); - } - } - replaceHdImg(e2) { - if (e2 && "string" == typeof e2.jquery && (e2 = e2.toArray()), e2 || (e2 = document.querySelectorAll(this.getSelector().coverImgSelector)), !e2.length) return; - const t2 = Array.from(e2).filter(((e3) => "true" !== e3.dataset.hdReplaced && "true" !== e3.dataset.jhsHdObserved)); - if ("IntersectionObserver" in window && !this.hdImageObserver) this.hdImageObserver = new IntersectionObserver(((entries) => { - entries.forEach(((entry) => { - entry.isIntersecting && (this.hdImageObserver.unobserve(entry.target), delete entry.target.dataset.jhsHdObserved, this._replaceSingleHdImg(entry.target)); - })); - }), { rootMargin: "200px" }); - for (const image of t2) this.hdEagerRemaining > 0 ? (this.hdEagerRemaining--, this._replaceSingleHdImg(image)) : this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", this.hdImageObserver.observe(image)) : this._replaceSingleHdImg(image); - storageManager.getSetting("hoverBigImg", C).then(((e3) => { - e3 === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ - selector: this.getSelector().coverImgSelector - })); - })); - } - applyTranslatedTitle(e2, t2, n2) { - const a2 = e2.find(".video-title"); - r ? (a2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n2) || (this.textContent = " " + t2 + " "); - })), a2.attr("title", t2)) : a2.text(t2), e2.attr("data-jhs-translation-key", n2); - } - async translate(e2) { - let t2, n2, a2 = e2.find(".video-title"); - if (r ? (t2 = a2.contents().filter(((e3, t3) => 3 === t3.nodeType && "" !== t3.textContent.trim())).text().trim(), n2 = e2.find(".video-title strong").text().trim()) : (t2 = (e2.find("img").attr("data-title") || "").trim(), n2 = (e2.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t2 || !n2) return; - const scope = await this.getRuntimeService("scope")(); - const translated = await this.getRuntimeService("translation").translate(t2, { scope }); - this.applyTranslatedTitle(e2, translated, n2); - } - async revertTranslation() { - $(this.getSelector().itemSelector).toArray().forEach(((e2) => { - let t2 = $(e2); - const n2 = t2.find(".box").attr("title") || t2.find(".video-title").attr("title") || t2.find("img").attr("data-title"); - let a2; - r && (a2 = t2.find(".video-title strong").text().trim()); - const i2 = t2.find(".video-title"); - i2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a2) || (this.textContent = " " + n2 + " "); - })), i2.removeAttr("title"); - })); - } - addJumpPageControl() { - const e2 = "gemini-jump-page-control"; - if ($("#" + e2).length > 0) return; - if (0 === $(".pagination-link.is-current").length) return; - const t2 = utils.getUrlParam(o, "page") || 1, n2 = $('', { - id: "jumpPageInput", - placeholder: "页码", - min: "1", - value: t2 + 1 - }), a2 = $('\n
    '), $(".navbar-search").css("margin-left", "0").before('\n \n '), this.handleResize(), $(window).resize((() => { + this.handleResize(); + })), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory()))), l && await this.createBusButton(); + } + async createBusButton() { + const ready = await new Promise((resolve) => { + const startedAt = Date.now(), timer = setInterval((() => { + if ($("#setting-btn").length && $("#top-right-box").length) return clearInterval(timer), resolve(true); + Date.now() - startedAt >= 2500 && (clearInterval(timer), resolve(false)); + }), 25); + }); + if (!ready) return void clog.warn("鉴定记录入口未创建:JavBus 顶部工具区未就绪"); + $("#top-right-box").append(''), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory())); + } + openHistory() { + let e2 = ` +
    +
    + + + +
    +
    + + + + + +
    +
    +
    + `; + e2 = e2.replace('
    { + const root = $(e3); + this.historyRoot = root, root.find("#allSelectBox").prepend(''), this.resetHistorySelection(false), JhsSelect.enhance(root); + await this.loadTableData(), root.on("click.jhsHistory", "#clearSearchbtn", (async (e4) => { + root.find("#searchCarNum").val(""), JhsSelect.setValue(root.find("#dataType"), "all"), await this.reloadTable(), root.find("#allSelectBox").hide(); + })).on("focusout keydown", "#searchCarNum", (async (e4) => { + if ("focusout" === e4.type || "Enter" === e4.key) { + if ("Enter" === e4.key && e4.preventDefault(), "keydown" === e4.type && "Enter" !== e4.key) return; + await this.reloadTable(); + } + })).on("click", ".table-link-param", (async (e4) => { + let t2 = $(e4.currentTarget); + root.find("#searchCarNum").val(t2.text()), await this.reloadTable(); + })).on("change", "#dataType", (async () => { + await this.reloadTable(); + })).on("click", "[data-history-view]", (async (event) => { + const view = $(event.currentTarget).data("history-view"); + root.find("[data-history-view]").removeClass("active"), $(event.currentTarget).addClass("active"), await this.showHistoryView(view); + })).on("click", ".jhs-undo-activity", (async (event) => { + const result = await this.historyRepository.undo($(event.currentTarget).data("transaction")); + show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); + })).on("click", ".jhs-copy-offline", (async (event) => { + await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); + })).on("click", ".jhs-retry-offline", (async (event) => { + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry) => entry.id === id); + item && await this.getDependency("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); + })).on("click", ".jhs-open-offline", (async (event) => { + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry) => entry.id === id), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); + url && window.open(url, "_blank", "noopener,noreferrer"); + })).on("click", ".jhs-delete-offline", (async (event) => { + await this.historyRepository.removeOffline($(event.currentTarget).data("id")), await this.renderOfflineHistory(); + })), this.bindHistoryActions(root); + }, "success"), + end: /* @__PURE__ */ __name(() => { + this.resetHistorySelection(), this.historyRoot?.off(".jhsHistory"), this.historyRoot = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null); + }, "end") + }); + } + async showHistoryView(view) { + const stateView = "state" === view; + this.resetHistorySelection(), this.historyRoot?.find("#filterBox,#allSelectBox").toggle(stateView), this.tableObj?.destroy(), this.tableObj = null; + return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); + } + async renderActivityHistory() { + const log = await this.historyRepository.activity(), host = this.historyRoot.find("#table-container").empty(); + if (!log.entries.length) return void host.html('
    暂无操作记录
    '); + log.entries.slice().reverse().forEach((entry) => { + const reverted = entry.changes.filter((change) => "reverted" === change.undoState).length, conflicts = entry.changes.filter((change) => "conflict" === change.undoState).length; + host.append($('
    ').append($("").text(`${entry.type} · ${entry.changes.length} 项`), $("

    ").text(`${new Date(entry.createdAt).toLocaleString()} · 已撤销 ${reverted} · 冲突 ${conflicts}`), $("

    ").text(entry.changes.map((change) => change.carNum).join("、")), $('').attr("data-transaction", entry.id).prop("disabled", "committed" !== entry.commitState || reverted === entry.changes.length))); + }); + } + async renderOfflineHistory() { + const history = await this.historyRepository.offline(), host = this.historyRoot.find("#table-container").empty(); + if (!history.length) return void host.html('
    暂无离线任务
    '); + history.slice().reverse().forEach((item) => { + const actions = $('
    ').append($('').attr("data-resource", item.resource), $('').attr("data-id", item.id), $('').attr("data-id", item.id), $('').attr("data-id", item.id)); + host.append($('
    ').append($("").text(`${item.providerName || item.providerId} · ${item.status}`), $("

    ").text(`${item.carNum || "未关联番号"} · ${new Date(item.createdAt).toLocaleString()}${item.retryOf ? ` · 重试自 ${item.retryOf}` : ""}`), $("

    ").text(item.errorMessage || item.resource), actions)); + }); + } + async reloadTable(resetSelection = true) { + resetSelection && this.resetHistorySelection(), await this.tableObj?.setPage(1); + } + normalizeHistoryCarNum(carNum) { + return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); + } + isHistoryAllFiltered() { + return "all-filtered" === this.historySelectionModel.mode; + } + resetHistorySelection(deselectRows = true) { + this.historySelectionModel.clear(); + if (deselectRows && this.tableObj) { + this.historySelectionSyncing = true; + try { + this.tableObj.deselectRow(); + } finally { + this.historySelectionSyncing = false; + } + } + this.updateHistorySelectionUi(); + } + createHistorySelectAllCheckbox() { + const checkbox = document.createElement("input"); + return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { + checkbox.checked ? (this.historySelectionModel.selectAllFiltered(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); + })), checkbox; + } + syncHistoryPageSelection() { + if (!this.tableObj || !this.isHistoryAllFiltered()) return void this.updateHistorySelectionUi(); + const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.filter(((item) => this.historySelectionModel.has(item))).map(((item) => item.carNum)); + this.historySelectionSyncing = true; + try { + this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); + } finally { + this.historySelectionSyncing = false; + } + this.updateHistorySelectionUi(); + } + updateHistoryRowSelection(row, selected) { + if (this.historySelectionSyncing) return void this.updateHistorySelectionUi(); + const item = row?.getData?.(); + item && this.historySelectionModel.set(item, selected), this.updateHistorySelectionUi(); + } + getHistorySelectionSummary() { + if (this.isHistoryAllFiltered()) { + const excluded = this.historySelectionModel.excluded.size, selected = Math.max(0, this.historyFilteredCount - excluded); + return { + count: selected, + text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` + }; + } + const count = this.historySelectionModel.selected.size; + return { + count, + text: `已选择当前页 ${count} 条` + }; + } + updateHistorySelectionUi() { + if (!this.historyRoot) return; + const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); + this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); + this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { + element.checked = this.isHistoryAllFiltered(), element.indeterminate = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size > 0 : summary.count > 0; + })); + } + async getHistoryBatchSelection() { + if (!this.isHistoryAllFiltered()) return this.historySelectionModel.values(this.tableObj?.getData?.() || []); + const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map((item) => this.normalizeHistoryCarNum(item.carNum))); + for (const carNum of this.historySelectionModel.excluded) available.has(carNum) || this.historySelectionModel.excluded.delete(carNum); + return this.historyFilteredCount = rows.length, this.historySelectionModel.values(rows); + } + bindHistoryActions(root) { + root.on("click.jhsHistory", (function(e2) { + if (e2.target.closest(".sub-btns-toggle")) { + const button = e2.target.closest(".sub-btns-toggle"), t2 = button.closest(".sub-btns").querySelector(".sub-btns-menu"); + root.find(".sub-btns-menu.show").each(((index, e3) => { + e3 !== t2 && (e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false")); + })), t2.classList.toggle("show"), button.setAttribute("aria-expanded", String(t2.classList.contains("show"))); + } else root.find(".sub-btns-menu.show").each(((index, e3) => { + e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false"); + })); + })), root.on("keydown.jhsHistory", ".sub-btns", ((e2) => { + const menu = $(e2.currentTarget).find(".sub-btns-menu"), items = menu.find('[role="menuitem"]'), current = items.index(document.activeElement); + if ("Escape" === e2.key) return e2.preventDefault(), menu.removeClass("show"), $(e2.currentTarget).find(".sub-btns-toggle").attr("aria-expanded", "false").trigger("focus"); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e2.key) || !menu.hasClass("show")) return; + e2.preventDefault(); + const next = "Home" === e2.key ? 0 : "End" === e2.key ? items.length - 1 : "ArrowDown" === e2.key ? (current + 1 + items.length) % items.length : (current - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); + })), root.on("click.jhsHistory", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", ((e2) => { + e2.preventDefault(), e2.stopPropagation(); + const t2 = $(e2.currentTarget), n2 = t2.closest(".action-btns"), a2 = n2.attr("data-car-num"), i2 = n2.attr("data-href"), s2 = /* @__PURE__ */ __name(async (actionType) => { + try { + const flag = legacyActionToFlag(actionType); + if (!flag) throw new TypeError(`无效历史状态操作: ${actionType}`); + await this.historyRepository.toggle(a2, flag, { type: "history-state", record: { carNum: a2, url: i2 } }), await this.reloadTable(); + } catch (s3) { + clog.error("历史记录操作失败:", s3), show.error("操作失败"); + } + }, "s"); + if (t2.hasClass("history-filterBtn")) { + const record = this.tableObj?.getRow(a2)?.getData(), isBlocked = normalizeStateFlags(record?.stateFlags).blocked; + isBlocked ? void s2(d) : utils.q(e2, `是否屏蔽${a2}?`, (() => s2(d))); + } else t2.hasClass("history-favoriteBtn") ? void s2(h) : t2.hasClass("history-hasDownBtn") ? void s2(g) : t2.hasClass("history-hasWatchBtn") ? void s2(p) : t2.hasClass("history-deleteBtn") ? this.handleDelete(e2, a2) : t2.hasClass("history-detailBtn") && void this.handleClickDetail(e2, { + carNum: a2, + url: i2 + }).catch(((error) => clog.error("历史详情打开失败", error))); + })), root.on("click.jhsHistory", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (async (e2) => { + e2.preventDefault(), e2.stopPropagation(); + const t2 = $(e2.currentTarget); + let n2 = await this.getHistoryBatchSelection(); + let a2 = "", i2 = ""; + t2.hasClass("multiple-history-filterBtn") ? (a2 = "屏蔽", i2 = d) : t2.hasClass("multiple-history-favoriteBtn") ? (a2 = "收藏", i2 = h) : t2.hasClass("multiple-history-hasDownBtn") ? (a2 = "已下载", i2 = g) : t2.hasClass("multiple-history-hasWatchBtn") ? (a2 = "已观看", i2 = p) : t2.hasClass("multiple-history-deleteBtn") && (a2 = "移除", i2 = "delete"); + if (!n2.length) return void show.info("请先选择要处理的记录"); + const selectionText = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size ? `当前筛选结果中已选择 ${n2.length} 条,排除 ${this.historySelectionModel.excluded.size} 条` : `当前筛选结果中已选择全部 ${n2.length} 条` : `当前页已选择 ${n2.length} 条`; + utils.q(e2, `${selectionText},是否标记为${a2}?`, (async () => { + let e3 = loading(); + try { + if ("delete" === i2) { + const e4 = n2.map((e5) => e5.carNum), t3 = await this.historyRepository.remove(e4); + if (!t3.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); + show.ok(`已成功删除 ${t3.changed.length} 个番号`); + } else { + const flag = legacyActionToFlag(i2); + if (!flag) throw new TypeError(`无效历史批量操作: ${i2}`); + await this.historyRepository.patch(n2.map(((item) => item.carNum)), { [flag]: true }, { type: "history-batch-state", records: n2 }), show.ok("操作成功"); + } + this.resetHistorySelection(), await this.reloadTable(false); + } catch (t3) { + clog.error(t3), show.error("操作失败,请稍后重试"); + } finally { + e3.close(); + } + })); + })); + } + async getFilteredHistoryData(sorters = this.historySorters) { + let a2 = await this.historyRepository.list(); + this.allCount = a2.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a2.forEach((e2) => { + const flags = normalizeStateFlags(e2.stateFlags); + flags.blocked && this.filterCount++, flags.favorite && this.favoriteCount++, flags.downloaded && this.hasDownCount++, flags.watched && this.hasWatchCount++, hasAnyState(flags) || this.waitCheckCount++; + }), this.historyRoot.find('#dataType option[value="all"]').text(`所有 (${this.allCount})`), this.historyRoot.find('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), this.historyRoot.find('#dataType option[value="filter"]').text(`${u} (${this.filterCount})`), this.historyRoot.find('#dataType option[value="favorite"]').text(`${b} (${this.favoriteCount})`), this.historyRoot.find('#dataType option[value="hasDown"]').text(`${y} (${this.hasDownCount})`), this.historyRoot.find('#dataType option[value="hasWatch"]').text(`${k} (${this.hasWatchCount})`); + const i2 = String(this.historyRoot.find("#dataType").val() || "all"); + const flagByFilter = { filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" }; + const selectedFlag = flagByFilter[i2]; + let s2 = "all" === i2 ? a2 : "waitCheck" === i2 ? a2.filter(((e2) => !hasAnyState(e2.stateFlags))) : selectedFlag ? a2.filter(((e2) => normalizeStateFlags(e2.stateFlags)[selectedFlag])) : a2; + const o2 = String(this.historyRoot.find("#searchCarNum").val() || "").trim(); + if (o2) { + let e2 = o2.toLowerCase().replace("-c", "").replace("-uc", "").replace("-4k", ""); + s2 = s2.filter((t2) => { + const n2 = t2.carNum.toLowerCase().includes(e2); + const a3 = (t2.names ? t2.names : "").toLowerCase().includes(e2); + return n2 || a3; + }); + } + if (sorters && sorters.length > 0) { + const e2 = sorters[0], t2 = e2.field, a3 = e2.dir; + s2.sort(((e3, n2) => { + const i3 = e3[t2], s3 = n2[t2], o3 = null == i3 || "" === i3, r2 = null == s3 || "" === s3; + return o3 && !r2 ? 1 : !o3 && r2 ? -1 : o3 && r2 ? 0 : i3 < s3 ? "asc" === a3 ? -1 : 1 : i3 > s3 ? "asc" === a3 ? 1 : -1 : 0; + })); + } + return s2; } - async initCss() { - return ` - `; + async getDataList(e2, t2, n2) { + this.historySorters = Array.isArray(n2) ? n2.map((sorter) => ({ + ...sorter + })) : []; + const rows = await this.getFilteredHistoryData(this.historySorters), r2 = rows.length, l2 = Math.ceil(r2 / t2), c2 = (e2 - 1) * t2, m2 = c2 + t2; + return this.historyFilteredCount = r2, { + maxPage: l2, + dataList: rows.slice(c2, m2), + totalCount: r2 + }; } - handleResize() { - $(".navbar-search").is(":hidden") ? ($(".historyBtnBox").show(), $(".miniHistoryBtnBox").hide()) : ($(".historyBtnBox").hide(), $(".miniHistoryBtnBox").show()); + async loadTableData() { + this.tableObj = createJhsTable(globalThis.Tabulator, this.historyRoot.find("#table-container").get(0), { + layout: "fitColumns", + placeholder: "暂无数据", + virtualDom: true, + pagination: true, + paginationMode: "remote", + sortMode: "remote", + ajaxURL: "queryRealm", + dataLoader: false, + ajaxRequestFunc: /* @__PURE__ */ __name(async (e2, t2, n2) => { + const a2 = n2.page, i2 = n2.size, s2 = n2.sort; + return await this.getDataList(a2, i2, s2); + }, "ajaxRequestFunc"), + dataReceiveParams: { + last_page: "maxPage", + last_row: "totalCount", + data: "dataList" + }, + paginationSize: 50, + paginationSizeSelector: [50, 100, 1e3], + paginationCounter: /* @__PURE__ */ __name((e2, t2, n2, a2, i2) => `共 ${a2} 条记录`, "paginationCounter"), + responsiveLayout: "collapse", + responsiveLayoutCollapse: true, + columnDefaults: { + headerHozAlign: "center", + hozAlign: "center" + }, + selectableRowsPersistence: false, + index: "carNum", + columns: [{ + formatter: "rowSelection", + titleFormatter: /* @__PURE__ */ __name(() => this.createHistorySelectAllCheckbox(), "titleFormatter"), + hozAlign: "center", + headerSort: false, + responsive: 0, + width: 40, + cellClick: /* @__PURE__ */ __name((e2, t2) => { + t2.getRow().toggleSelect(); + }, "cellClick") + }, { + title: "番号", + field: "carNum", + width: 120, + sorter: "string", + responsive: 0, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + const a2 = e2.getData().carNum, i2 = a2.indexOf("-"); + if (-1 === i2) return a2; + return `${a2.substring(i2 + 1)}`; + }, "formatter") + }, { + title: "演员", + field: "names", + minWidth: 200, + sorter: "string", + responsive: 5, + headerSort: true, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => (e2.getData().names || "").split(" ").filter((e3) => "" !== e3.trim()).map((e3) => ``).join(" "), "formatter") + }, { + title: "创建时间", + field: "createDate", + width: 170, + sorter: "string", + responsive: 4 + }, { + title: "修改时间", + field: "updateDate", + width: 170, + sorter: "string", + responsive: 4 + }, { + title: "发行时间", + field: "publishTime", + width: 170, + sorter: "string", + responsive: 4 + }, { + title: "来源", + field: "url", + width: 80, + sorter: "string", + responsive: 5, + hozAlign: "left", + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + let a2 = e2.getData().url; + return a2 ? `${a2.includes("javdb") ? "JavDB" : a2.includes("javbus") ? "JavBus" : a2.includes("123av") ? "123AV" : "其他"}` : ""; + }, "formatter") + }, { + title: "状态", + field: "stateFlags", + width: 220, + sorter: false, + responsive: 1, + headerSort: false, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + const flags = normalizeStateFlags(e2.getData().stateFlags); + const badgeItems = [[flags.blocked, "filter", u], [flags.favorite, "fav", b], [flags.downloaded, "down", y], [flags.watched, "watch", k]]; + const badges = badgeItems.filter(((item) => item[0])).map(((item) => `${item[2]}`)); + return badges.join(" ") || '待鉴定'; + }, "formatter") + }, { + title: "备注", + field: "remark", + width: 100, + sorter: "string", + responsive: 6 + }, { + title: "操作", + sorter: "string", + minWidth: 150, + cssClass: "action-cell-dropdown", + responsive: 0, + headerSort: false, + formatter: /* @__PURE__ */ __name((e2, t2, n2) => { + const a2 = e2.getData(); + return n2((() => { + var t3; + null == (t3 = e2.getElement().querySelector(".history-editBtn")) || t3.addEventListener("click", ((e3) => { + this.editRecord(a2); + })); + })), ` +
    + +
    + + +
    +
    `; + }, "formatter") + }], + initialSort: [{ + column: "updateDate", + dir: "desc" + }], + locale: "zh-cn", + langs: { + "zh-cn": { + pagination: { + first: "首页", + first_title: "首页", + last: "尾页", + last_title: "尾页", + prev: "上一页", + prev_title: "上一页", + next: "下一页", + next_title: "下一页", + all: "所有", + page_size: "每页行数" + } + } + } + }), this.tableObj.on("dataProcessed", (() => { + this.isHistoryAllFiltered() ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); + })), this.tableObj.on("rowSelected", ((row) => { + this.updateHistoryRowSelection(row, true); + })), this.tableObj.on("rowDeselected", ((row) => { + this.updateHistoryRowSelection(row, false); + })), this.tableObj.on("rowSelectionChanged", (() => { + this.updateHistorySelectionUi(); + })), this.tableObj.on("rowDblClick", (function(e2, t2) { + t2.toggleSelect(); + })); } - async handle() { - r && ($(".navbar-end").prepend(''), $(".navbar-search").css("margin-left", "0").before('\n \n '), this.handleResize(), $(window).resize((() => { - this.handleResize(); - })), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory()))), l && await this.createBusButton(); + handleDelete(e2, t2) { + utils.q(e2, `是否移除${t2}?`, (async () => { + await this.historyRepository.remove(t2), this.getDependency("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); + })); } - async createBusButton() { - const ready = await new Promise((resolve) => { - const startedAt = Date.now(), timer = setInterval((() => { - if ($("#setting-btn").length && $("#top-right-box").length) return clearInterval(timer), resolve(true); - Date.now() - startedAt >= 2500 && (clearInterval(timer), resolve(false)); - }), 25); - }); - if (!ready) return void clog.warn("鉴定记录入口未创建:JavBus 顶部工具区未就绪"); - $("#top-right-box").append(''), $("#historyBtn,#miniHistoryBtn").on("click", ((e2) => this.openHistory())); + async handleClickDetail(e2, t2) { + if (r) if (t2.carNum.includes("FC2-")) { + const e3 = this.parseMovieId(t2.url); + const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); + plugin.openFc2Dialog(e3, t2.carNum, t2.url, { source }); + } else { + if (!t2.url) return void window.open("/search?q=" + t2.carNum, "_blank"); + utils.openPage(t2.url, t2.carNum, false, e2); + } + if (l) { + let n2 = t2.url; + if (n2.includes("javdb")) if (t2.carNum.includes("FC2-")) { + const e3 = this.parseMovieId(n2); + const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); + await plugin.openFc2Page(e3, t2.carNum, n2, { newTab: true }, { source }); + } else window.open(n2, "_blank"); + else utils.openPage(t2.url, t2.carNum, false, e2); + } } - openHistory() { - let e2 = ` -
    -
    - - - + async editRecord(e2) { + const t2 = e2.carNum, n2 = e2.names || "", a2 = e2.url || "", flags = normalizeStateFlags(e2.stateFlags), s2 = e2.remark || ""; + let editRoot = $(); + const c2 = ` +
    +
    + +
    -
    - - - - - +
    + + +
    +
    状态: + + + + +
    +
    + + +
    +
    + +
    -
    `; - e2 = e2.replace('
    { - const root = $(e3); - this.historyRoot = root, root.find("#allSelectBox").prepend(''), this.resetHistorySelection(false), JhsSelect.enhance(root); - await this.loadTableData(), root.on("click.jhsHistory", "#clearSearchbtn", (async (e4) => { - root.find("#searchCarNum").val(""), JhsSelect.setValue(root.find("#dataType"), "all"), await this.reloadTable(), root.find("#allSelectBox").hide(); - })).on("focusout keydown", "#searchCarNum", (async (e4) => { - if ("focusout" === e4.type || "Enter" === e4.key) { - if ("Enter" === e4.key && e4.preventDefault(), "keydown" === e4.type && "Enter" !== e4.key) return; - await this.reloadTable(); - } - })).on("click", ".table-link-param", (async (e4) => { - let t2 = $(e4.currentTarget); - root.find("#searchCarNum").val(t2.text()), await this.reloadTable(); - })).on("change", "#dataType", (async () => { - await this.reloadTable(); - })).on("click", "[data-history-view]", (async (event) => { - const view = $(event.currentTarget).data("history-view"); - root.find("[data-history-view]").removeClass("active"), $(event.currentTarget).addClass("active"), await this.showHistoryView(view); - })).on("click", ".jhs-undo-activity", (async (event) => { - const result = await this.historyRepository.undo($(event.currentTarget).data("transaction")); - show.info(`撤销完成:${result.reverted.length} 项成功,${result.conflicts.length} 项冲突`), await this.renderActivityHistory(); - })).on("click", ".jhs-copy-offline", (async (event) => { - await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); - })).on("click", ".jhs-retry-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry) => entry.id === id); - item && await this.getDependency("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); - })).on("click", ".jhs-open-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry) => entry.id === id), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); - url && window.open(url, "_blank", "noopener,noreferrer"); - })).on("click", ".jhs-delete-offline", (async (event) => { - await this.historyRepository.removeOffline($(event.currentTarget).data("id")), await this.renderOfflineHistory(); - })), this.bindHistoryActions(root); + title: `编辑记录: ${t2}`, + area: utils.getDialogArea("sm"), + content: c2, + btn: ["保存", "取消"], + success: /* @__PURE__ */ __name((e3, t3) => { + editRoot = $(e3); + const n3 = /* @__PURE__ */ __name((e4) => { + e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); + }, "n"), a3 = editRoot.find("#edit-names"); + a3.on("input", ((event) => n3($(event.currentTarget)))), n3(a3); + const i2 = editRoot.find("#edit-remark"); + i2.on("input", ((event) => n3($(event.currentTarget)))), n3(i2); }, "success"), - end: /* @__PURE__ */ __name(() => { - this.resetHistorySelection(), this.historyRoot?.off(".jhsHistory"), this.historyRoot = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null); - }, "end") - }); - } - async showHistoryView(view) { - const stateView = "state" === view; - this.resetHistorySelection(), this.historyRoot?.find("#filterBox,#allSelectBox").toggle(stateView), this.tableObj?.destroy(), this.tableObj = null; - return stateView ? this.loadTableData() : "activity" === view ? this.renderActivityHistory() : this.renderOfflineHistory(); - } - async renderActivityHistory() { - const log = await this.historyRepository.activity(), host = this.historyRoot.find("#table-container").empty(); - if (!log.entries.length) return void host.html('
    暂无操作记录
    '); - log.entries.slice().reverse().forEach((entry) => { - const reverted = entry.changes.filter((change) => "reverted" === change.undoState).length, conflicts = entry.changes.filter((change) => "conflict" === change.undoState).length; - host.append($('
    ').append($("").text(`${entry.type} · ${entry.changes.length} 项`), $("

    ").text(`${new Date(entry.createdAt).toLocaleString()} · 已撤销 ${reverted} · 冲突 ${conflicts}`), $("

    ").text(entry.changes.map((change) => change.carNum).join("、")), $('').attr("data-transaction", entry.id).prop("disabled", "committed" !== entry.commitState || reverted === entry.changes.length))); - }); - } - async renderOfflineHistory() { - const history = await this.historyRepository.offline(), host = this.historyRoot.find("#table-container").empty(); - if (!history.length) return void host.html('
    暂无离线任务
    '); - history.slice().reverse().forEach((item) => { - const actions = $('
    ').append($('').attr("data-resource", item.resource), $('').attr("data-id", item.id), $('').attr("data-id", item.id), $('').attr("data-id", item.id)); - host.append($('
    ').append($("").text(`${item.providerName || item.providerId} · ${item.status}`), $("

    ").text(`${item.carNum || "未关联番号"} · ${new Date(item.createdAt).toLocaleString()}${item.retryOf ? ` · 重试自 ${item.retryOf}` : ""}`), $("

    ").text(item.errorMessage || item.resource), actions)); + yes: /* @__PURE__ */ __name(async (t3) => { + const n3 = String(editRoot.find("#edit-names").val() || "").trim(), i2 = String(editRoot.find("#edit-url").val() || "").trim(), s3 = String(editRoot.find("#edit-remark").val() || "").trim(), nextFlags = { + favorite: editRoot.find("#edit-favorite").prop("checked"), + downloaded: editRoot.find("#edit-downloaded").prop("checked"), + watched: editRoot.find("#edit-watched").prop("checked"), + blocked: editRoot.find("#edit-blocked").prop("checked") + }; + const save = /* @__PURE__ */ __name(async () => { + await this.historyRepository.patch(e2.carNum, nextFlags, { type: "history-edit", replaceMetadata: true, record: { ...e2, names: n3, url: i2, remark: s3 } }), this.tableObj.setData(), dialog.close(t3); + }, "save"); + if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e2.carNum}?`, (() => void save())), false; + await save(); + }, "yes") }); } - async reloadTable(resetSelection = true) { - resetSelection && this.resetHistorySelection(), await this.tableObj?.setPage(1); - } - normalizeHistoryCarNum(carNum) { - return normalizeCarNum(carNum) || String(carNum || "").trim().toUpperCase(); - } - isHistoryAllFiltered() { - return "all-filtered" === this.historySelectionModel.mode; - } - resetHistorySelection(deselectRows = true) { - this.historySelectionModel.clear(); - if (deselectRows && this.tableObj) { - this.historySelectionSyncing = true; - try { - this.tableObj.deselectRow(); - } finally { - this.historySelectionSyncing = false; - } - } - this.updateHistorySelectionUi(); - } - createHistorySelectAllCheckbox() { - const checkbox = document.createElement("input"); - return checkbox.type = "checkbox", checkbox.className = "jhs-history-select-all", checkbox.setAttribute("aria-label", "选择当前筛选条件下的全部记录"), checkbox.addEventListener("change", (() => { - checkbox.checked ? (this.historySelectionModel.selectAllFiltered(), this.syncHistoryPageSelection()) : this.resetHistorySelection(); - })), checkbox; - } - syncHistoryPageSelection() { - if (!this.tableObj || !this.isHistoryAllFiltered()) return void this.updateHistorySelectionUi(); - const currentRows = this.tableObj.getData?.() || [], selectedIds = currentRows.filter(((item) => this.historySelectionModel.has(item))).map(((item) => item.carNum)); - this.historySelectionSyncing = true; - try { - this.tableObj.deselectRow(), selectedIds.length && this.tableObj.selectRow(selectedIds); - } finally { - this.historySelectionSyncing = false; - } - this.updateHistorySelectionUi(); + }; + __name(_HistoryPlugin, "HistoryPlugin"); + var HistoryPlugin = _HistoryPlugin; + + // src/plugins/status/list-page-button.js + var _ListPageButtonPlugin = class _ListPageButtonPlugin extends BasePlugin { + getName() { + return "ListPageButtonPlugin"; } - updateHistoryRowSelection(row, selected) { - if (this.historySelectionSyncing) return void this.updateHistorySelectionUi(); - const item = row?.getData?.(); - item && this.historySelectionModel.set(item, selected), this.updateHistorySelectionUi(); + async handle() { + if (!window.isListPage) return; + const scope = await this.getRuntimeService("scope")(); + await this.createMenuBtn(scope), this.bindEvent(); + const e2 = await storageManager.getSetting("autoPage"), t2 = this.supportsLiveSorting(); + $("#sort-toggle-btn").prop("disabled", e2 === _ && !t2).attr("title", e2 === _ && !t2 ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"), (e2 !== _ || t2) && await this.sortItems(); } - getHistorySelectionSummary() { - if (this.isHistoryAllFiltered()) { - const excluded = this.historySelectionModel.excluded.size, selected = Math.max(0, this.historyFilteredCount - excluded); - return { - count: selected, - text: excluded ? `已选择当前筛选结果 ${selected} 条,已排除 ${excluded} 条` : `已选择当前筛选结果 ${selected} 条` - }; + async createMenuBtn(scope) { + if (r) { + const e2 = o.includes("/actors/"); + let t2 = $(".main-tabs, .tabs"), n2 = "加入黑名单", a2 = "jhs-btn--filter", s2 = null; + if (e2) { + t2 = $(".toolbar, .section-addition").filter(":last"); + const e3 = await storageManager.getBlacklist(), i2 = this.getActressPageInfo(); + e3.find((e4) => e4.starId === i2.starId) && (n2 = "已加入黑名单", a2 = "jhs-btn--muted"); + } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { + const e3 = $("#addBlacklistBtn"); + e3.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); + const t3 = $("#jhs-check-tag").text().trim(); + if (!t3) return; + const n3 = "no-" + t3, a3 = await storageManager.getBlacklist(); + s2 = a3.find((e4) => e4.starId === n3), s2 && (e3.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); + }), 20, 1e4, true, scope); + const r2 = o.includes("advanced_search"); + r2 && (t2 = $("h2.section-title")); + const l2 = this.getRuntimeService("settings").snapshot().sortMethod || "default", d2 = "当前排序方式: " + ("rateCount" === l2 ? "评价人数" : "date" === l2 ? "时间" : "默认"); + t2.append(` +
    + + ${e2 ? ` + + + + + ` : ""} + ${o.includes("/tags") ? ` + + ` : ""} +
    +
    + + + ${c ? "" : this.sortMenuHtml(l2 || "default", d2)} +
    + `); } - const count = this.historySelectionModel.selected.size; - return { - count, - text: `已选择当前页 ${count} 条` - }; - } - updateHistorySelectionUi() { - if (!this.historyRoot) return; - const summary = this.getHistorySelectionSummary(), batchBox = this.historyRoot.find("#allSelectBox"), filterBox = this.historyRoot.find("#filterBox"); - this.historyRoot.find("#historySelectionSummary").text(summary.text), summary.count > 0 ? (filterBox.hide(), batchBox.show()) : (filterBox.show(), batchBox.hide()); - this.historyRoot.find(".jhs-history-select-all").each(((index, element) => { - element.checked = this.isHistoryAllFiltered(), element.indeterminate = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size > 0 : summary.count > 0; - })); + if (l) { + const e2 = o.includes("/star/"); + let t2 = "加入黑名单", n2 = "jhs-btn--filter"; + if (e2) { + const e3 = await storageManager.getBlacklist(), a3 = this.getActressPageInfo(); + e3.find((e4) => e4.starId === a3.starId) && (t2 = "已加入黑名单", n2 = "jhs-btn--muted"); + } + const a2 = this.getRuntimeService("settings").snapshot().sortMethod || "default"; + $(".masonry").parent().prepend(` +
    + + ${e2 ? ` + + + + + ` : ''} + ${this.sortMenuHtml(a2)} +
    + `); + } + $("#waitCheckBtn > span").text("开始鉴定"); + const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); + newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); } - async getHistoryBatchSelection() { - if (!this.isHistoryAllFiltered()) return this.historySelectionModel.values(this.tableObj?.getData?.() || []); - const rows = await this.getFilteredHistoryData(this.historySorters), available = new Set(rows.map((item) => this.normalizeHistoryCarNum(item.carNum))); - for (const carNum of this.historySelectionModel.excluded) available.has(carNum) || this.historySelectionModel.excluded.delete(carNum); - return this.historyFilteredCount = rows.length, this.historySelectionModel.values(rows); + sortMenuHtml(method, title = "选择列表排序方式") { + const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, key = "string" === typeof method && method in labels ? method : "default", current = labels[key]; + return `
    `; } - bindHistoryActions(root) { - root.on("click.jhsHistory", (function(e2) { - if (e2.target.closest(".sub-btns-toggle")) { - const button = e2.target.closest(".sub-btns-toggle"), t2 = button.closest(".sub-btns").querySelector(".sub-btns-menu"); - root.find(".sub-btns-menu.show").each(((index, e3) => { - e3 !== t2 && (e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false")); - })), t2.classList.toggle("show"), button.setAttribute("aria-expanded", String(t2.classList.contains("show"))); - } else root.find(".sub-btns-menu.show").each(((index, e3) => { - e3.classList.remove("show"), e3.previousElementSibling?.setAttribute("aria-expanded", "false"); + bindEvent() { + $("#waitCheckBtn").on("click", ((e3) => { + void this.openWaitCheck().catch(((error) => clog.error("待鉴定列表打开失败", error))); + })), $("#newVideoBtn").on("click", ((e3) => { + this.getDependency("NewVideoPlugin").openDialog(); + })), $("#blacklistBtn").on("click", ((e3) => { + this.getDependency("BlacklistPlugin").openBlacklistDialog(); + })), this.bindSortMenu(); + const e2 = this.getDependency("BlacklistPlugin"); + $("#addBlacklistBtn").on("click", (async (t2) => { + await e2.addBlacklist(t2); + })), $("#filterAllVideo").on("click", (async (t2) => { + let n2 = { + clientX: t2.clientX, + clientY: t2.clientY + 80 + }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + if (0 === a2.length) return void show.error("获取演员名称失败"); + let i2 = a2.text().trim().split(",")[0]; + utils.q(n2, "一键屏蔽视频列表?", (async () => { + this.loadObj = loading(); + try { + await e2.filterAllVideo(i2); + } catch (t3) { + clog.error(t3); + } finally { + this.loadObj.close(); + } })); - })), root.on("keydown.jhsHistory", ".sub-btns", ((e2) => { - const menu = $(e2.currentTarget).find(".sub-btns-menu"), items = menu.find('[role="menuitem"]'), current = items.index(document.activeElement); - if ("Escape" === e2.key) return e2.preventDefault(), menu.removeClass("show"), $(e2.currentTarget).find(".sub-btns-toggle").attr("aria-expanded", "false").trigger("focus"); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e2.key) || !menu.hasClass("show")) return; - e2.preventDefault(); - const next = "Home" === e2.key ? 0 : "End" === e2.key ? items.length - 1 : "ArrowDown" === e2.key ? (current + 1 + items.length) % items.length : (current - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })), root.on("click.jhsHistory", ".history-deleteBtn, .history-filterBtn, .history-favoriteBtn, .history-hasDownBtn, .history-hasWatchBtn, .history-detailBtn", ((e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.currentTarget), n2 = t2.closest(".action-btns"), a2 = n2.attr("data-car-num"), i2 = n2.attr("data-href"), s2 = /* @__PURE__ */ __name(async (actionType) => { + })), $("#favoriteAllVideo").on("click", (async (t2) => { + let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + if (0 === a2.length) return void show.error("获取演员名称失败"); + let i2 = a2.text().trim().split(",")[0]; + utils.q(n2, "一键收藏所有可见作品?", (async () => { + this.loadObj = loading(); try { - const flag = legacyActionToFlag(actionType); - if (!flag) throw new TypeError(`无效历史状态操作: ${actionType}`); - await this.historyRepository.toggle(a2, flag, { type: "history-state", record: { carNum: a2, url: i2 } }), await this.reloadTable(); - } catch (s3) { - clog.error("历史记录操作失败:", s3), show.error("操作失败"); + await e2.batchSaveAllVideos(i2, h); + } catch (t3) { + clog.error(t3); + } finally { + this.loadObj.close(); } - }, "s"); - if (t2.hasClass("history-filterBtn")) { - const record = this.tableObj?.getRow(a2)?.getData(), isBlocked = normalizeStateFlags(record?.stateFlags).blocked; - isBlocked ? void s2(d) : utils.q(e2, `是否屏蔽${a2}?`, (() => s2(d))); - } else t2.hasClass("history-favoriteBtn") ? void s2(h) : t2.hasClass("history-hasDownBtn") ? void s2(g) : t2.hasClass("history-hasWatchBtn") ? void s2(p) : t2.hasClass("history-deleteBtn") ? this.handleDelete(e2, a2) : t2.hasClass("history-detailBtn") && void this.handleClickDetail(e2, { - carNum: a2, - url: i2 - }).catch(((error) => clog.error("历史详情打开失败", error))); - })), root.on("click.jhsHistory", ".multiple-history-deleteBtn, .multiple-history-filterBtn, .multiple-history-favoriteBtn, .multiple-history-hasDownBtn, .multiple-history-hasWatchBtn", (async (e2) => { - e2.preventDefault(), e2.stopPropagation(); - const t2 = $(e2.currentTarget); - let n2 = await this.getHistoryBatchSelection(); - let a2 = "", i2 = ""; - t2.hasClass("multiple-history-filterBtn") ? (a2 = "屏蔽", i2 = d) : t2.hasClass("multiple-history-favoriteBtn") ? (a2 = "收藏", i2 = h) : t2.hasClass("multiple-history-hasDownBtn") ? (a2 = "已下载", i2 = g) : t2.hasClass("multiple-history-hasWatchBtn") ? (a2 = "已观看", i2 = p) : t2.hasClass("multiple-history-deleteBtn") && (a2 = "移除", i2 = "delete"); - if (!n2.length) return void show.info("请先选择要处理的记录"); - const selectionText = this.isHistoryAllFiltered() ? this.historySelectionModel.excluded.size ? `当前筛选结果中已选择 ${n2.length} 条,排除 ${this.historySelectionModel.excluded.size} 条` : `当前筛选结果中已选择全部 ${n2.length} 条` : `当前页已选择 ${n2.length} 条`; - utils.q(e2, `${selectionText},是否标记为${a2}?`, (async () => { - let e3 = loading(); + })); + })), $("#hasDownAllVideo").on("click", (async (t2) => { + let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + if (0 === a2.length) return void show.error("获取演员名称失败"); + let i2 = a2.text().trim().split(",")[0]; + utils.q(n2, "一键已下载所有可见作品?", (async () => { + this.loadObj = loading(); try { - if ("delete" === i2) { - const e4 = n2.map((e5) => e5.carNum), t3 = await this.historyRepository.remove(e4); - if (!t3.changed.length) return void show.error("提供的番号中没有一个存在于列表中。"); - show.ok(`已成功删除 ${t3.changed.length} 个番号`); - } else { - const flag = legacyActionToFlag(i2); - if (!flag) throw new TypeError(`无效历史批量操作: ${i2}`); - await this.historyRepository.patch(n2.map(((item) => item.carNum)), { [flag]: true }, { type: "history-batch-state", records: n2 }), show.ok("操作成功"); - } - this.resetHistorySelection(), await this.reloadTable(false); + await e2.batchSaveAllVideos(i2, g); } catch (t3) { - clog.error(t3), show.error("操作失败,请稍后重试"); + clog.error(t3); } finally { - e3.close(); + this.loadObj.close(); } })); })); } - async getFilteredHistoryData(sorters = this.historySorters) { - let a2 = await this.historyRepository.list(); - this.allCount = a2.length, this.filterCount = 0, this.favoriteCount = 0, this.hasDownCount = 0, this.hasWatchCount = 0, this.waitCheckCount = 0, a2.forEach((e2) => { - const flags = normalizeStateFlags(e2.stateFlags); - flags.blocked && this.filterCount++, flags.favorite && this.favoriteCount++, flags.downloaded && this.hasDownCount++, flags.watched && this.hasWatchCount++, hasAnyState(flags) || this.waitCheckCount++; - }), this.historyRoot.find('#dataType option[value="all"]').text(`所有 (${this.allCount})`), this.historyRoot.find('#dataType option[value="waitCheck"]').text(`待鉴定 (${this.waitCheckCount})`), this.historyRoot.find('#dataType option[value="filter"]').text(`${u} (${this.filterCount})`), this.historyRoot.find('#dataType option[value="favorite"]').text(`${b} (${this.favoriteCount})`), this.historyRoot.find('#dataType option[value="hasDown"]').text(`${y} (${this.hasDownCount})`), this.historyRoot.find('#dataType option[value="hasWatch"]').text(`${k} (${this.hasWatchCount})`); - const i2 = String(this.historyRoot.find("#dataType").val() || "all"); - const flagByFilter = { filter: "blocked", favorite: "favorite", hasDown: "downloaded", hasWatch: "watched" }; - const selectedFlag = flagByFilter[i2]; - let s2 = "all" === i2 ? a2 : "waitCheck" === i2 ? a2.filter(((e2) => !hasAnyState(e2.stateFlags))) : selectedFlag ? a2.filter(((e2) => normalizeStateFlags(e2.stateFlags)[selectedFlag])) : a2; - const o2 = String(this.historyRoot.find("#searchCarNum").val() || "").trim(); - if (o2) { - let e2 = o2.toLowerCase().replace("-c", "").replace("-uc", "").replace("-4k", ""); - s2 = s2.filter((t2) => { - const n2 = t2.carNum.toLowerCase().includes(e2); - const a3 = (t2.names ? t2.names : "").toLowerCase().includes(e2); - return n2 || a3; - }); - } - if (sorters && sorters.length > 0) { - const e2 = sorters[0], t2 = e2.field, a3 = e2.dir; - s2.sort(((e3, n2) => { - const i3 = e3[t2], s3 = n2[t2], o3 = null == i3 || "" === i3, r2 = null == s3 || "" === s3; - return o3 && !r2 ? 1 : !o3 && r2 ? -1 : o3 && r2 ? 0 : i3 < s3 ? "asc" === a3 ? -1 : 1 : i3 > s3 ? "asc" === a3 ? 1 : -1 : 0; - })); - } - return s2; + bindSortMenu() { + const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = /* @__PURE__ */ __name((focus = false) => { + menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); + }, "close"); + toggle.on("click", ((event) => { + event.preventDefault(), event.stopPropagation(); + const open = !menu.hasClass("is-open"); + menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); + })); + menu.on("click", ".jhs-sort-option", (async (event) => { + const item = $(event.currentTarget), method = item.data("sort-method"); + await this.getRuntimeService("settings").set("sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(true), await this.sortItems().catch(((error) => clog.error("列表排序失败", error))); + })).on("keydown", ".jhs-sort-option", ((event) => { + const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); + if ("Escape" === event.key) return event.preventDefault(), close(true); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); + })); + $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", ((event) => { + $(event.target).closest(control).length || close(); + })); + } + async sortItems() { + const e2 = this.supportsLiveSorting(); + if (!e2 && (o.includes("handle") || o.includes("advanced_search"))) return; + const s2 = await storageManager.getSetting("autoPage"); + if (c || s2 === _ && !e2) return; + const t2 = this.getRuntimeService("settings").snapshot().sortMethod; + if (!t2) return; + const i2 = this.getSelector(), d2 = $(i2.boxSelector), h2 = $(i2.itemSelector); + h2.each(((e3, element) => { + $(element).attr("data-original-index") || $(element).attr("data-original-index", e3); + })); + const items = h2.get().map(((element, index) => { + const card = $(element), originalIndex = Number(card.attr("data-original-index")) || 0; + if ("default" === t2) return { element, key: originalIndex, originalIndex, index }; + if ("rateCount" === t2) { + const explicit = Number(card.attr("data-jhs-rate-count")), match = card.find(".score").text().replaceAll(",", "").match(/(?:由\s*)?(\d+)\s*人(?:评价)?/); + return { element, key: Number.isFinite(explicit) ? explicit : match ? Number(match[1]) : 0, originalIndex, index }; + } + const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter(((index2, element2) => /^\d{4}-\d{1,2}-\d{1,2}$/.test($(element2).text().trim()))).first().text().trim(), timestamp = Date.parse(value); + return { element, key: Number.isFinite(timestamp) ? timestamp : 0, originalIndex, index }; + })); + items.sort(((e3, n2) => "default" === t2 ? e3.key - n2.key : n2.key - e3.key || e3.originalIndex - n2.originalIndex || e3.index - n2.index)); + const sortedElements = items.map((item) => item.element); + "default" === t2 ? $(sortedElements).appendTo(d2) : d2.empty().append(sortedElements); } - async getDataList(e2, t2, n2) { - this.historySorters = Array.isArray(n2) ? n2.map((sorter) => ({ - ...sorter - })) : []; - const rows = await this.getFilteredHistoryData(this.historySorters), r2 = rows.length, l2 = Math.ceil(r2 / t2), c2 = (e2 - 1) * t2, m2 = c2 + t2; - return this.historyFilteredCount = r2, { - maxPage: l2, - dataList: rows.slice(c2, m2), - totalCount: r2 - }; + isHitShowPage() { + return isHitShowPage(window.location); } - async loadTableData() { - this.tableObj = createJhsTable(globalThis.Tabulator, this.historyRoot.find("#table-container").get(0), { - layout: "fitColumns", - placeholder: "暂无数据", - virtualDom: true, - pagination: true, - paginationMode: "remote", - sortMode: "remote", - ajaxURL: "queryRealm", - dataLoader: false, - ajaxRequestFunc: /* @__PURE__ */ __name(async (e2, t2, n2) => { - const a2 = n2.page, i2 = n2.size, s2 = n2.sort; - return await this.getDataList(a2, i2, s2); - }, "ajaxRequestFunc"), - dataReceiveParams: { - last_page: "maxPage", - last_row: "totalCount", - data: "dataList" - }, - paginationSize: 50, - paginationSizeSelector: [50, 100, 1e3], - paginationCounter: /* @__PURE__ */ __name((e2, t2, n2, a2, i2) => `共 ${a2} 条记录`, "paginationCounter"), - responsiveLayout: "collapse", - responsiveLayoutCollapse: true, - columnDefaults: { - headerHozAlign: "center", - hozAlign: "center" - }, - selectableRowsPersistence: false, - index: "carNum", - columns: [{ - formatter: "rowSelection", - titleFormatter: /* @__PURE__ */ __name(() => this.createHistorySelectAllCheckbox(), "titleFormatter"), - hozAlign: "center", - headerSort: false, - responsive: 0, - width: 40, - cellClick: /* @__PURE__ */ __name((e2, t2) => { - t2.getRow().toggleSelect(); - }, "cellClick") - }, { - title: "番号", - field: "carNum", - width: 120, - sorter: "string", - responsive: 0, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const a2 = e2.getData().carNum, i2 = a2.indexOf("-"); - if (-1 === i2) return a2; - return `${a2.substring(i2 + 1)}`; - }, "formatter") - }, { - title: "演员", - field: "names", - minWidth: 200, - sorter: "string", - responsive: 5, - headerSort: true, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => (e2.getData().names || "").split(" ").filter((e3) => "" !== e3.trim()).map((e3) => ``).join(" "), "formatter") - }, { - title: "创建时间", - field: "createDate", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "修改时间", - field: "updateDate", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "发行时间", - field: "publishTime", - width: 170, - sorter: "string", - responsive: 4 - }, { - title: "来源", - field: "url", - width: 80, - sorter: "string", - responsive: 5, - hozAlign: "left", - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - let a2 = e2.getData().url; - return a2 ? `${a2.includes("javdb") ? "JavDB" : a2.includes("javbus") ? "JavBus" : a2.includes("123av") ? "123AV" : "其他"}` : ""; - }, "formatter") - }, { - title: "状态", - field: "stateFlags", - width: 220, - sorter: false, - responsive: 1, - headerSort: false, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const flags = normalizeStateFlags(e2.getData().stateFlags); - const badgeItems = [[flags.blocked, "filter", u], [flags.favorite, "fav", b], [flags.downloaded, "down", y], [flags.watched, "watch", k]]; - const badges = badgeItems.filter(((item) => item[0])).map(((item) => `${item[2]}`)); - return badges.join(" ") || '待鉴定'; - }, "formatter") - }, { - title: "备注", - field: "remark", - width: 100, - sorter: "string", - responsive: 6 - }, { - title: "操作", - sorter: "string", - minWidth: 150, - cssClass: "action-cell-dropdown", - responsive: 0, - headerSort: false, - formatter: /* @__PURE__ */ __name((e2, t2, n2) => { - const a2 = e2.getData(); - return n2((() => { - var t3; - null == (t3 = e2.getElement().querySelector(".history-editBtn")) || t3.addEventListener("click", ((e3) => { - this.editRecord(a2); - })); - })), ` -
    - -
    - - -
    -
    `; - }, "formatter") - }], - initialSort: [{ - column: "updateDate", - dir: "desc" - }], - locale: "zh-cn", - langs: { - "zh-cn": { - pagination: { - first: "首页", - first_title: "首页", - last: "尾页", - last_title: "尾页", - prev: "上一页", - prev_title: "上一页", - next: "下一页", - next_title: "下一页", - all: "所有", - page_size: "每页行数" - } + isFc2ListPage() { + return r && "/advanced_search" === window.location.pathname && "3" === new URLSearchParams(window.location.search).get("type"); + } + supportsLiveSorting() { + return this.isHitShowPage() || this.isFc2ListPage(); + } + async openWaitCheck() { + let e2 = this.getSelector(); + const t2 = await storageManager.getSetting("waitCheckCount", 5); + let a2 = 0; + const listPage = this.getDependency("ListPagePlugin"); + for (const element of $(e2.itemSelector).toArray()) { + if (a2 >= t2) break; + const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); + if (hasAnyState(flags) || isHardHidden(flags, visibilityReasons)) continue; + await listPage.openMovieDetail(item, { autoplay: true, newTab: false }), a2++; + } + 0 === a2 && show.info("没有需鉴定的视频"); + } + }; + __name(_ListPageButtonPlugin, "ListPageButtonPlugin"); + var ListPageButtonPlugin = _ListPageButtonPlugin; + + // src/plugins/status/list-page.js + var Te = { + IS_FILTERED: { + text: u, + color: "var(--jhs-status-filter)", + on: "var(--jhs-status-filter-on)", + reasonType: "单番号屏蔽", + isCounted: true, + countKey: "currentPageFilterCount" + }, + IS_FAVORITE: { + text: b, + color: "var(--jhs-status-fav)", + on: "var(--jhs-status-fav-on)", + reasonType: "", + isCounted: true, + countKey: "currentPageFavoriteCount" + }, + IS_HAS_DOWN: { + text: y, + color: "var(--jhs-status-down)", + on: "var(--jhs-status-down-on)", + reasonType: "", + isCounted: true, + countKey: "currentPageHasDownCount" + }, + IS_HAS_WATCH: { + text: k, + color: "var(--jhs-status-watch)", + on: "var(--jhs-status-watch-on)", + reasonType: "", + isCounted: true, + countKey: "currentPageHasWatchCount" + }, + IS_KEYWORD_FILTER: { + text: "关键词屏蔽", + color: "var(--jhs-status-filter)", + on: "var(--jhs-status-filter-on)", + reasonType: "", + isCounted: true, + countKey: "currentPageKeywordFilterCount" + }, + IS_ACTOR_FILTER: { + text: "男演员屏蔽", + color: "var(--jhs-status-filter)", + on: "var(--jhs-status-filter-on)", + reasonType: "", + isCounted: true, + countKey: "currentPageActorFilterCount" + }, + IS_ACTRESS_FILTER: { + text: "女演员屏蔽", + color: "var(--jhs-status-filter)", + on: "var(--jhs-status-filter-on)", + reasonType: "", + isCounted: true, + countKey: "currentPageActorFilterCount" + }, + IS_WAIT_CHECK: { + text: "", + color: "", + on: "", + reasonType: "", + isCounted: true, + countKey: "currentPageWaitCheckCount" + } + }; + var _ListPagePlugin = class _ListPagePlugin extends BasePlugin { + async initCss() { + return ``; + } + constructor() { + super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "filterContext", null), i(this, "pendingItems", /* @__PURE__ */ new Set()), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), i(this, "itemIndex", /* @__PURE__ */ new Map()), i(this, "recountFrame", null); + } + getName() { + return "ListPagePlugin"; + } + async handle() { + if (!window.isListPage || isHitShowPage()) return; + const scope = await this.getRuntimeService("scope")(); + const refreshAll = /* @__PURE__ */ __name(async () => { + this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); + const e2 = this.getDependency("HistoryPlugin"); + e2.tableObj && e2.tableObj.setData(); + }, "refreshAll"); + ["legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed"].forEach(((type) => scope.addCleanup(jhsEventBus.on(type, refreshAll)))), scope.addCleanup(jhsEventBus.on("car-state-changed", (async (payload) => { + this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); + const items = this.getIndexedItems(payload.carNums || []); + items.length && (await this.doFilterItems(items), this.applyVisibility(items)); + const history = this.getDependency("HistoryPlugin"); + history.tableObj && history.tableObj.setData(); + }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(scope), scope.addCleanup((() => { + this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); + this.hdImageObserver?.disconnect(), this.hdImageObserver = null; + this.recountFrame && (globalThis.cancelAnimationFrame?.(this.recountFrame) ?? clearTimeout(this.recountFrame)), this.recountFrame = null; + $(this.getSelector().boxSelector).off(".jhsMovieDetail"), $("#jhs-quick-filter").off(), this.itemIndex.clear(); + })); + } + async createQuickFilter() { + if ($("#jhs-quick-filter").length) return; + const e2 = this.getSelector(), primaryHtml = PRIMARY_QUICK_FILTERS.map(((filter) => ``)).join(""), secondaryHtml = SECONDARY_QUICK_FILTERS.map(((filter, index) => `${1 === index ? '' : ""}`)).join(""), t2 = `
    +
    ${primaryHtml}
    +
    + + +
    +
    `; + r ? $(e2.boxSelector).before(t2) : l && $(".masonry").before(t2); + const root = $("#jhs-quick-filter"), toggle = root.find(".jhs-quick-filter__toggle"), menu = root.find(".jhs-quick-filter__menu"), closeMenu = /* @__PURE__ */ __name((restoreFocus = false) => { + menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), restoreFocus && toggle.trigger("focus"); + }, "closeMenu"); + root.on("click", ".jhs-segmented__item", ((event) => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))).on("keydown", ".jhs-segmented__item", ((event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const tabs = root.find(".jhs-segmented__item"), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; + tabs.eq(next).trigger("click").trigger("focus"); + })).on("click", ".jhs-filter-option", ((event) => { + this.setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); + })).on("keydown", ".jhs-filter-option", ((event) => { + const items = menu.find(".jhs-filter-option"), index = items.index(event.currentTarget); + if ("Escape" === event.key) return event.preventDefault(), closeMenu(true); + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; + items.eq(next).trigger("focus"); + })); + toggle.on("click", ((event) => { + event.preventDefault(), event.stopPropagation(); + const open = !menu.hasClass("is-open"); + menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && (menu.find('[aria-checked="true"]').first().length ? menu.find('[aria-checked="true"]').first() : menu.find(".jhs-filter-option").first()).trigger("focus"); + })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", ((event) => { + $(event.target).closest(root).length || closeMenu(); + })); + this.setQuickFilter(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck")); + } + applyVisibility(items = null) { + const e2 = this.activeQuickFilter || "waitCheck", t2 = this.getSelector().itemSelector; + (items ? $(items) : $(t2)).each((function() { + const t3 = $(this), flags = normalizeStateFlags(JSON.parse(t3.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t3.attr("data-jhs-visibility") || "{}"), recent = "yes" === t3.attr("data-jhs-recent"); + shouldShowItem({ filter: e2, flags, visibilityReasons, recent }) ? t3.show() : t3.hide(); + })); + } + setQuickFilter(filter, { syncUi = true } = {}) { + this.activeQuickFilter = normalizeQuickFilterKey(filter), this.applyVisibility(), syncUi && this.syncQuickFilterUi(); + } + syncQuickFilterUi() { + const filter = normalizeQuickFilterKey(this.activeQuickFilter), isPrimary = PRIMARY_QUICK_FILTERS.includes(filter), root = $("#jhs-quick-filter"), tabs = root.find(".jhs-segmented__item"), options = root.find(".jhs-filter-option"); + tabs.removeClass("active").attr({ "aria-selected": "false", tabindex: "-1" }); + isPrimary ? tabs.filter(`[data-jhs-filter="${filter}"]`).addClass("active").attr({ "aria-selected": "true", tabindex: "0" }) : tabs.first().attr("tabindex", "0"); + options.attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); + root.find(".jhs-quick-filter__label").text(isPrimary ? "更多筛选" : `筛选:${QUICK_FILTER_LABELS[filter]}`); + $(".jhs-mobile-filter-label").text(`筛选:${QUICK_FILTER_LABELS[filter]}`), $(".jhs-mobile-filter-option").attr("aria-checked", "false").filter(`[data-jhs-filter="${filter}"]`).attr("aria-checked", "true"); + } + rememberTagExpand() { + if (!window.location.href.includes("actors")) return; + const e2 = $(".tag-expand"); + if (0 === e2.length) return; + const storage = this.getRuntimeService("storage"), t2 = "jhs_tag_expand", n2 = "true" === storage.getLocal(t2), a2 = $(".actor-tags .content"); + n2 && a2.hasClass("collapse") && e2[0].click(), e2.on("click", (function() { + const e3 = !$(".actor-tags .content").hasClass("collapse"); + clog.debug("触发"), storage.setLocal(t2, e3.toString()); + })); + } + checkDom(scope = null) { + if (!window.isListPage || isHitShowPage()) return; + const e2 = this.getSelector(), t2 = document.querySelector(e2.boxSelector); + if (!t2) return void clog.error("没有找到容器节点!"); + if (!scope) return; + scope.observe(t2, ((records) => { + for (const record of records) { + this.removeIndexedItems(record.removedNodes); + for (const node of record.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + node.matches?.(e2.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), node.querySelectorAll?.(e2.itemSelector).forEach(((item) => { + "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); + })); } } - }), this.tableObj.on("dataProcessed", (() => { - this.isHistoryAllFiltered() ? this.syncHistoryPageSelection() : this.updateHistorySelectionUi(); - })), this.tableObj.on("rowSelected", ((row) => { - this.updateHistoryRowSelection(row, true); - })), this.tableObj.on("rowDeselected", ((row) => { - this.updateHistoryRowSelection(row, false); - })), this.tableObj.on("rowSelectionChanged", (() => { - this.updateHistorySelectionUi(); - })), this.tableObj.on("rowDblClick", (function(e2, t2) { - t2.toggleSelect(); + this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { + const items = [...this.pendingItems].filter(((item) => item.isConnected && "true" !== item.dataset.jhsProcessed)); + this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch(((error) => clog.error("列表增量处理失败", error))); + }), 100)); + }), { + childList: true, + subtree: false + }); + } + async processAddedItems(items) { + const selector = this.getSelector(), covers = items.flatMap(((item) => [...item.querySelectorAll(selector.coverImgSelector)])); + this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), items.forEach(((item) => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: false }), this.getDependency("AutoPagePlugin").checkLoad(); + } + rebuildItemIndex() { + this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); + } + indexItems(items) { + items.forEach(((item) => { + try { + const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); + if (!key) return; + const indexed = this.itemIndex.get(key) || /* @__PURE__ */ new Set(); + indexed.add(item), this.itemIndex.set(key, indexed); + } catch (error) { + clog.debug("列表项索引跳过无效卡片", error); + } })); } - handleDelete(e2, t2) { - utils.q(e2, `是否移除${t2}?`, (async () => { - await this.historyRepository.remove(t2), this.getDependency("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); + removeIndexedItems(nodes) { + const removed = /* @__PURE__ */ new Set(); + Array.from(nodes || []).forEach(((node) => { + node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach(((item) => removed.add(item)))); + })); + if (!removed.size) return; + this.itemIndex.forEach(((items, key) => { + items.forEach(((item) => { + removed.has(item) && items.delete(item); + })), items.size || this.itemIndex.delete(key); })); } - async handleClickDetail(e2, t2) { - if (r) if (t2.carNum.includes("FC2-")) { - const e3 = this.parseMovieId(t2.url); - const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); - plugin.openFc2Dialog(e3, t2.carNum, t2.url, { source }); - } else { - if (!t2.url) return void window.open("/search?q=" + t2.carNum, "_blank"); - utils.openPage(t2.url, t2.carNum, false, e2); - } - if (l) { - let n2 = t2.url; - if (n2.includes("javdb")) if (t2.carNum.includes("FC2-")) { - const e3 = this.parseMovieId(n2); - const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t2); - await plugin.openFc2Page(e3, t2.carNum, n2, { newTab: true }, { source }); - } else window.open(n2, "_blank"); - else utils.openPage(t2.url, t2.carNum, false, e2); - } + getIndexedItems(carNums) { + const result = /* @__PURE__ */ new Set(); + carNums.map(normalizeCarNum).forEach(((key) => { + const items = this.itemIndex.get(key); + items?.forEach(((item) => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); + })); + return [...result]; } - async editRecord(e2) { - const t2 = e2.carNum, n2 = e2.names || "", a2 = e2.url || "", flags = normalizeStateFlags(e2.stateFlags), s2 = e2.remark || ""; - let editRoot = $(); - const c2 = ` -
    -
    - - -
    -
    - - -
    -
    状态: - - - - -
    -
    - - -
    -
    - - -
    -
    - `; - const dialog = this.getRuntimeService("dialog"); - dialog.open({ - type: 1, - title: `编辑记录: ${t2}`, - area: utils.getDialogArea("sm"), - content: c2, - btn: ["保存", "取消"], - success: /* @__PURE__ */ __name((e3, t3) => { - editRoot = $(e3); - const n3 = /* @__PURE__ */ __name((e4) => { - e4.css("height", "auto"), e4.css("height", e4[0].scrollHeight + 15 + "px"); - }, "n"), a3 = editRoot.find("#edit-names"); - a3.on("input", ((event) => n3($(event.currentTarget)))), n3(a3); - const i2 = editRoot.find("#edit-remark"); - i2.on("input", ((event) => n3($(event.currentTarget)))), n3(i2); - }, "success"), - yes: /* @__PURE__ */ __name(async (t3) => { - const n3 = String(editRoot.find("#edit-names").val() || "").trim(), i2 = String(editRoot.find("#edit-url").val() || "").trim(), s3 = String(editRoot.find("#edit-remark").val() || "").trim(), nextFlags = { - favorite: editRoot.find("#edit-favorite").prop("checked"), - downloaded: editRoot.find("#edit-downloaded").prop("checked"), - watched: editRoot.find("#edit-watched").prop("checked"), - blocked: editRoot.find("#edit-blocked").prop("checked") - }; - const save = /* @__PURE__ */ __name(async () => { - await this.historyRepository.patch(e2.carNum, nextFlags, { type: "history-edit", replaceMetadata: true, record: { ...e2, names: n3, url: i2, remark: s3 } }), this.tableObj.setData(), dialog.close(t3); - }, "save"); - if (!flags.blocked && nextFlags.blocked) return utils.q(null, `是否屏蔽${e2.carNum}?`, (() => void save())), false; - await save(); - }, "yes") - }); + fixBusTitleBox(items = null) { + if (!l) return; + (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((e2) => { + var t2; + let n2 = $(e2); + if (n2.find(".avatar-box").length > 0) return; + const a2 = (null == (t2 = n2.find("img").attr("title")) ? void 0 : t2.trim()) || ""; + n2.find(".photo-info span:first").contents().first().wrap(`${a2}`), n2.find("br").remove(); + })); } - }; - __name(_HistoryPlugin, "HistoryPlugin"); - var HistoryPlugin = _HistoryPlugin; - - // src/plugins/status/list-page-button.js - var _ListPageButtonPlugin = class _ListPageButtonPlugin extends BasePlugin { - getName() { - return "ListPageButtonPlugin"; + cleanRepeatId() { + if (!l) return; + $("#waterfall_h").removeAttr("id").attr("id", "no-page"); + const e2 = $('[id="waterfall"]'); + 0 !== e2.length && e2.each((function() { + const e3 = $(this); + if (!e3.hasClass("masonry")) { + e3.children().insertAfter(e3), e3.remove(); + } + })); } - async handle() { + async doFilter() { + return this.doFilterItems(); + } + async doFilterItems(items = null) { if (!window.isListPage) return; - const scope = await this.getRuntimeService("scope")(); - await this.createMenuBtn(scope), this.bindEvent(); - const e2 = await storageManager.getSetting("autoPage"), t2 = this.supportsLiveSorting(); - $("#sort-toggle-btn").prop("disabled", e2 === _ && !t2).attr("title", e2 === _ && !t2 ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"), (e2 !== _ || t2) && await this.sortItems(); + let e2 = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); + e2.length && (await this.filterMovieList(e2), l && setTimeout((() => { + this.getDependency("BusImgPlugin").logImageHeightsByRow().catch(((e3) => clog.error("JavBus图片高度修正失败", e3))); + }))); } - async createMenuBtn(scope) { - if (r) { - const e2 = o.includes("/actors/"); - let t2 = $(".main-tabs, .tabs"), n2 = "加入黑名单", a2 = "jhs-btn--filter", s2 = null; - if (e2) { - t2 = $(".toolbar, .section-addition").filter(":last"); - const e3 = await storageManager.getBlacklist(), i2 = this.getActressPageInfo(); - e3.find(((e4) => e4.starId === i2.starId)) && (n2 = "已加入黑名单", a2 = "jhs-btn--muted"); - } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { - const e3 = $("#addBlacklistBtn"); - e3.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); - const t3 = $("#jhs-check-tag").text().trim(); - if (!t3) return; - const n3 = "no-" + t3, a3 = await storageManager.getBlacklist(); - s2 = a3.find(((e4) => e4.starId === n3)), s2 && (e3.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); - }), 20, 1e4, true, scope); - const r2 = o.includes("advanced_search"); - r2 && (t2 = $("h2.section-title")); - const l2 = this.getRuntimeService("settings").snapshot().sortMethod || "default", d2 = "当前排序方式: " + ("rateCount" === l2 ? "评价人数" : "date" === l2 ? "时间" : "默认"); - t2.append(` -
    - - ${e2 ? ` - - - - - ` : ""} - ${o.includes("/tags") ? ` - - ` : ""} -
    -
    - - - ${c ? "" : this.sortMenuHtml(l2 || "default", d2)} -
    - `); - } - if (l) { - const e2 = o.includes("/star/"); - let t2 = "加入黑名单", n2 = "jhs-btn--filter"; - if (e2) { - const e3 = await storageManager.getBlacklist(), a3 = this.getActressPageInfo(); - e3.find(((e4) => e4.starId === a3.starId)) && (t2 = "已加入黑名单", n2 = "jhs-btn--muted"); + async yieldListFrame() { + await new Promise(((e2) => { + window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e2))) : setTimeout(e2); + })); + } + findMatchedTitleKeyword(e2, t2, n2) { + for (const a2 of e2) if (t2.includes(a2) || n2.startsWith(a2)) return a2; + return null; + } + async getFilterContext() { + if (this.filterContext) return this.filterContext; + const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), this.getRuntimeService("state").getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); + const cutoff = Date.now() - 7 * 864e5; + activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState && change.fields?.some(((field) => field.startsWith("stateFlags."))))).forEach(((change) => recentCarNums.add(change.carNum))))); + for (const item of blacklistCars) { + const role = blacklistMap.get(item.starId)?.role; + if (!role) { + clog.error("黑名单数据源丢失演员信息", item); + continue; } - const a2 = this.getRuntimeService("settings").snapshot().sortMethod || "default"; - $(".masonry").parent().prepend(` -
    - - ${e2 ? ` - - - - - ` : ''} - ${this.sortMenuHtml(a2)} -
    - `); + const target = role === B ? actorCarNumToNameMap : actressCarNumToNameMap, carNum = normalizeCarNum(item.carNum); + target.has(carNum) || target.set(carNum, item.names); } - $("#waitCheckBtn > span").text("开始鉴定"); - const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); - newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); + return this.filterContext = { titleKeywords, settings, carMap, recentCarNums, actorCarNumToNameMap, actressCarNumToNameMap }; } - sortMenuHtml(method, title = "选择列表排序方式") { - const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, current = labels[method] || labels.default; - return `
    `; + collectCurrentPageSummary() { + const summary = { total: 0, pending: 0, blockedItems: 0, favorite: 0, downloaded: 0, watched: 0, debug: { manualBlocked: 0, keywordBlocked: 0, actorBlocked: 0, actressBlocked: 0 } }; + $(this.getSelector().itemSelector).each(((e2, item) => { + const card = $(item); + if (l && card.find(".avatar-box").length > 0) return; + const flags = normalizeStateFlags(JSON.parse(card.attr("data-jhs-flags") || "{}")), reasons = JSON.parse(card.attr("data-jhs-visibility") || "{}"), hardHidden = isHardHidden(flags, reasons); + summary.total++, flags.favorite && summary.favorite++, flags.downloaded && summary.downloaded++, flags.watched && summary.watched++, hardHidden && summary.blockedItems++, !hasAnyState(flags) && !hardHidden && summary.pending++, flags.blocked && summary.debug.manualBlocked++, reasons.keyword && summary.debug.keywordBlocked++, reasons.actorBlacklist && summary.debug.actorBlocked++, reasons.actressBlacklist && summary.debug.actressBlocked++; + })); + return summary; } - bindEvent() { - $("#waitCheckBtn").on("click", ((e3) => { - void this.openWaitCheck(e3).catch(((error) => clog.error("待鉴定列表打开失败", error))); - })), $("#newVideoBtn").on("click", ((e3) => { - this.getDependency("NewVideoPlugin").openDialog(); - })), $("#blacklistBtn").on("click", ((e3) => { - this.getDependency("BlacklistPlugin").openBlacklistDialog(); - })), this.bindSortMenu(); - const e2 = this.getDependency("BlacklistPlugin"); - $("#addBlacklistBtn").on("click", (async (t2) => { - await e2.addBlacklist(t2); - })), $("#filterAllVideo").on("click", (async (t2) => { - let n2 = { - clientX: t2.clientX, - clientY: t2.clientY + 80 - }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键屏蔽视频列表?", (async () => { - this.loadObj = loading(); - try { - await e2.filterAllVideo(i2); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })), $("#favoriteAllVideo").on("click", (async (t2) => { - let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键收藏所有可见作品?", (async () => { - this.loadObj = loading(); - try { - await e2.batchSaveAllVideos(i2, h); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); - } - })); - })), $("#hasDownAllVideo").on("click", (async (t2) => { - let n2 = { clientX: t2.clientX, clientY: t2.clientY + 80 }, a2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a2.length) return void show.error("获取演员名称失败"); - let i2 = a2.text().trim().split(",")[0]; - utils.q(n2, "一键已下载所有可见作品?", (async () => { - this.loadObj = loading(); - try { - await e2.batchSaveAllVideos(i2, g); - } catch (t3) { - clog.error(t3); - } finally { - this.loadObj.close(); + getCurrentPageSummary() { + return this.collectCurrentPageSummary(); + } + recountStatuses() { + const summary = this.collectCurrentPageSummary(); + this.currentPageFilterCount = summary.debug.manualBlocked, this.currentPageFavoriteCount = summary.favorite, this.currentPageHasDownCount = summary.downloaded, this.currentPageHasWatchCount = summary.watched, this.currentPageKeywordFilterCount = summary.debug.keywordBlocked, this.currentPageActorFilterCount = summary.debug.actorBlocked + summary.debug.actressBlocked, this.currentPageWaitCheckCount = summary.pending, this.currentPageTotalCount = summary.total; + return summary; + } + scheduleRecount() { + if (this.recountFrame) return; + const schedule = window.requestAnimationFrame || ((callback) => setTimeout(callback, 0)); + this.recountFrame = schedule((() => { + this.recountFrame = null, this.recountStatuses(); + })); + } + async translateListItems(e2) { + if (await storageManager.getSetting("translateTitle", _) !== _) return; + await mapLimit(e2, 3, (async (item, index) => { + try { + index > 0 && index % 8 == 0 && await this.yieldListFrame(), await this.translate(item); + } catch (error) { + clog.error("列表标题翻译失败", error); + } + })); + } + async filterMovieList(e2) { + utils.time("累计耗费时间"), utils.time("读取数据耗时"); + const { titleKeywords: n2, settings: s2, carMap: m2, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v2 } = await this.getFilterContext(), o2 = utils.time("读取数据耗时"); + utils.time("组装数据耗时"); + const b2 = utils.time("组装数据耗时"), P2 = (null == s2 ? void 0 : s2.tagPosition) || "rightTop"; + const O2 = n2.filter(((e3) => e3)); + this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); + const R = []; + for (let n3 = 0; n3 < e2.length; n3++) { + n3 > 0 && n3 % 12 == 0 && await this.yieldListFrame(); + let t2 = $(e2[n3]); + if (l && t2.find(".avatar-box").length > 0) continue; + const { carNum: a2, title: i2 } = this.findCarNumAndHref(t2), record = m2.get(a2), flags = normalizeStateFlags(record?.stateFlags), actorFiltered = f.has(a2), actressFiltered = v2.has(a2), keyword = this.findMatchedTitleKeyword(O2, i2, a2), visibilityReasons = { keyword: !!keyword, actorBlacklist: actorFiltered, actressBlacklist: actressFiltered }; + const hardHidden = isHardHidden(flags, visibilityReasons); + t2.attr("data-jhs-flags", JSON.stringify(flags)).attr("data-jhs-visibility", JSON.stringify(visibilityReasons)).attr("data-jhs-recent", recent.has(a2) ? _ : C).attr("data-jhs-tag-position", P2); + const signature = JSON.stringify({ flags, visibilityReasons, P: P2 }); + if (t2.attr("data-jhs-state-signature") !== signature) { + t2.attr("data-jhs-state-signature", signature), t2.find(".jhs-status-tags").remove(); + const badgeDefs = [ + [flags.blocked, Te.IS_FILTERED, "单番号屏蔽"], + [flags.favorite, Te.IS_FAVORITE, ""], + [flags.downloaded, Te.IS_HAS_DOWN, ""], + [flags.watched, Te.IS_HAS_WATCH, ""], + [visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知"], + [visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a2) || ""], + [visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v2.get(a2) || ""] + ].filter(((item) => item[0])); + if (badgeDefs.length) { + const box = $(``); + badgeDefs.forEach((([, definition, tip]) => { + const badge = $(`${escapeHtml(definition.text)}`); + badge.css({ color: definition.on, backgroundColor: definition.color }), box.append(badge); + })); + if (r) t2.find(".tags").append(box); + else if (l) { + const host = t2.find(".item-tag"); + host.length ? host.append(box) : t2.find(".photo-info > span > div").append(box); + } } - })); + } + hardHidden || R.push(t2); + } + this.scheduleRecount(), void this.translateListItems(R).catch(((e3) => clog.error("列表页翻译任务失败", e3))); + const D2 = utils.time("处理页面耗时"), A2 = utils.time("累计耗费时间"); + clog.log(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ${o2}${b2}
    ${D2}${A2}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    待鉴定${this.currentPageWaitCheckCount}
    总数${this.currentPageTotalCount}
    + `); + } + async bindClick() { + let e2 = this.getSelector(); + this.bindMovieDetailNavigation(e2.boxSelector), $(e2.boxSelector).on("click", ".item video", (async (e3) => { + const t2 = e3.currentTarget; + t2.paused ? await safePlay(t2, { + context: "列表视频", + notify: true + }) : t2.pause(), e3.preventDefault(), e3.stopPropagation(); + })), $(e2.boxSelector).on("contextmenu", ".item img, .item video", (async (e3) => { + try { + e3.preventDefault(); + const t2 = $(e3.target).closest(".item"), { carNum: n2, url: a2, publishTime: i2, fc2Source } = this.findCarNumAndHref(t2); + let s2 = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"), o2 = ""; + s2.length && (o2 = s2.text().trim().split(",")[0].replace("(無碼)", "")), utils.q(e3, `是否屏蔽番号 ${n2}?`, (async () => { + try { + o2 || (o2 = await this.parseActressName(a2)), await this.getRuntimeService("state").patch(n2, { blocked: true }, { record: { carNum: n2, url: a2, names: o2, publishTime: i2, fc2Source } }), show.ok("操作成功"); + } catch (s3) { + clog.error("屏蔽操作失败:", s3), show.error("操作失败"); + } + })); + } catch (t2) { + clog.error("右键菜单处理失败:", t2); + } })); } - bindSortMenu() { - const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = /* @__PURE__ */ __name((focus = false) => { - menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); - }, "close"); - toggle.on("click", ((event) => { + async openMovieDetail(item, { event = null, autoplay = false, newTab = false } = {}) { + const card = item?.jquery ? item : $(item), { carNum, aHref, fc2Source } = this.findCarNumAndHref(card); + if (!carNum || !aHref) return; + const shouldOpenTab = newTab || !!event && (event.ctrlKey || event.metaKey || 1 === event.button); + if (carNum.includes("FC2-")) { + const movieId = this.parseMovieId(aHref); + return shouldOpenTab ? this.getDependency("Fc2Plugin").openFc2Page(movieId, carNum, aHref, { event, newTab: true }, { source: fc2Source }) : this.getDependency("Fc2Plugin").openFc2Dialog(movieId, carNum, aHref, { source: fc2Source }); + } + const destination = new URL(aHref, window.location.origin); + autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, true, { event, newTab: shouldOpenTab }), this.$currentImage = null; + } + bindMovieDetailNavigation(container) { + const root = $(container), selector = ".item img, .item .video-title"; + root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, ((event) => { + if ("auxclick" === event.type && 1 !== event.button || "click" === event.type && event.button && 0 !== event.button) return; + if (event.shiftKey || event.altKey || $(event.target).closest("div.meta-buttons,[class^='jhs-match-']").length) return; event.preventDefault(), event.stopPropagation(); - const open = !menu.hasClass("is-open"); - menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); - })); - menu.on("click", ".jhs-sort-option", (async (event) => { - const item = $(event.currentTarget), method = item.data("sort-method"); - await this.getRuntimeService("settings").set("sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(true), await this.sortItems().catch(((error) => clog.error("列表排序失败", error))); - })).on("keydown", ".jhs-sort-option", ((event) => { - const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); - if ("Escape" === event.key) return event.preventDefault(), close(true); - if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; - event.preventDefault(); - const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; - items.eq(next).trigger("focus"); - })); - $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", ((event) => { - $(event.target).closest(control).length || close(); + void this.openMovieDetail($(event.currentTarget).closest(".item"), { event }).catch(((error) => clog.error("打开影片详情失败", error))); })); } - async sortItems() { - const e2 = this.supportsLiveSorting(); - if (!e2 && (o.includes("handle") || o.includes("advanced_search"))) return; - const s2 = await storageManager.getSetting("autoPage"); - if (c || s2 === _ && !e2) return; - const t2 = this.getRuntimeService("settings").snapshot().sortMethod; - if (!t2) return; - const i2 = this.getSelector(), d2 = $(i2.boxSelector), h2 = $(i2.itemSelector); - h2.each((function(e3) { - $(this).attr("data-original-index") || $(this).attr("data-original-index", e3); - })); - const items = h2.get().map(((element, index) => { - const card = $(element), originalIndex = Number(card.attr("data-original-index")) || 0; - if ("default" === t2) return { element, key: originalIndex, originalIndex, index }; - if ("rateCount" === t2) { - const explicit = Number(card.attr("data-jhs-rate-count")), match = card.find(".score").text().replaceAll(",", "").match(/(?:由\s*)?(\d+)\s*人(?:评价)?/); - return { element, key: Number.isFinite(explicit) ? explicit : match ? Number(match[1]) : 0, originalIndex, index }; + async parseActressName(e2) { + let t2 = null; + if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { + clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e2); + const scope = await this.getRuntimeService("scope")(); + const n2 = await requestHostPage(this.getRuntimeService("http"), e2, scope), a2 = utils.htmlTo$dom(n2); + r ? t2 = a2.find(".female").prev().map(((e3, t3) => $(t3).text())).get().join(" ") : l && (t2 = a2.find('span[onmouseover*="star_"] a').map(((e3, t3) => $(t3).text())).get().join(" ")), clog.debug("解析到名称:", t2); + } + return t2; + } + findCarNumAndHref(e2) { + try { + return readListItem(e2); + } catch (error) { + show.error("提取番号信息失败"); + throw error; + } + } + showCarNumBox(e2) { + const t2 = this.getRuntimeService("host").locateListItems().find(((t3) => $(t3).find(".video-title strong").text() === e2)); + if (t2) { + const n2 = $(t2); + n2.attr("data-hide") === "yes" && (n2.show(), n2.removeAttr("data-hide")); + } + } + _replaceSingleHdImg(e2) { + if ("true" === e2.dataset.hdReplaced) return; + if (r) { + const isJavdbCdn = /jdbstatic\.com|javdb\.com/i.test(e2.src); + if (isJavdbCdn) { + const originalSrc = e2.src; + e2.src = e2.src.replace("thumbs", "covers"); + e2.dataset.hdReplaced = "true"; + e2.title = ""; + e2.onerror = function() { + if (this.src !== originalSrc) { + this.src = originalSrc; + this.onerror = null; + } + }; } - const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter((function() { - return /^\d{4}-\d{1,2}-\d{1,2}$/.test($(this).text().trim()); - })).first().text().trim(), timestamp = Date.parse(value); - return { element, key: Number.isFinite(timestamp) ? timestamp : 0, originalIndex, index }; + } else if (l) { + const t2 = /\/(imgs|pics)\/(thumb|thumbs)\//, n2 = /(\.jpg|\.jpeg|\.png)$/i; + t2.test(e2.src) ? (e2.src = e2.src.replace(t2, "/$1/cover/").replace(n2, "_b$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = "") : /ps(\.jpg|\.jpeg|\.png)$/i.test(e2.src) && (e2.src = e2.src.replace(/ps(\.jpg|\.jpeg|\.png)$/i, "pl$1"), e2.dataset.hdReplaced = "true", e2.dataset.title = e2.title, e2.title = ""); + } + } + replaceHdImg(e2) { + if (e2 && "string" == typeof e2.jquery && (e2 = e2.toArray()), e2 || (e2 = document.querySelectorAll(this.getSelector().coverImgSelector)), !e2.length) return; + const t2 = Array.from(e2).filter(((e3) => "true" !== e3.dataset.hdReplaced && "true" !== e3.dataset.jhsHdObserved)); + if ("IntersectionObserver" in window && !this.hdImageObserver) this.hdImageObserver = new IntersectionObserver(((entries) => { + entries.forEach(((entry) => { + entry.isIntersecting && (this.hdImageObserver.unobserve(entry.target), delete entry.target.dataset.jhsHdObserved, this._replaceSingleHdImg(entry.target)); + })); + }), { rootMargin: "200px" }); + for (const image of t2) this.hdEagerRemaining > 0 ? (this.hdEagerRemaining--, this._replaceSingleHdImg(image)) : this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", this.hdImageObserver.observe(image)) : this._replaceSingleHdImg(image); + storageManager.getSetting("hoverBigImg", C).then(((e3) => { + e3 === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ + selector: this.getSelector().coverImgSelector + })); })); - items.sort(((e3, n2) => "default" === t2 ? e3.key - n2.key : n2.key - e3.key || e3.originalIndex - n2.originalIndex || e3.index - n2.index)); - const sortedElements = items.map(((item) => item.element)); - "default" === t2 ? $(sortedElements).appendTo(d2) : d2.empty().append(sortedElements); } - isHitShowPage() { - return isHitShowPage(window.location); + applyTranslatedTitle(e2, t2, n2) { + const a2 = e2.find(".video-title"); + r ? (a2.contents().each((function() { + 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n2) || (this.textContent = " " + t2 + " "); + })), a2.attr("title", t2)) : a2.text(t2), e2.attr("data-jhs-translation-key", n2); } - isFc2ListPage() { - return r && "/advanced_search" === window.location.pathname && "3" === new URLSearchParams(window.location.search).get("type"); + async translate(e2) { + let t2, n2, a2 = e2.find(".video-title"); + if (r ? (t2 = a2.contents().filter(((e3, t3) => 3 === t3.nodeType && "" !== t3.textContent.trim())).text().trim(), n2 = e2.find(".video-title strong").text().trim()) : (t2 = (e2.find("img").attr("data-title") || "").trim(), n2 = (e2.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t2 || !n2) return; + const scope = await this.getRuntimeService("scope")(); + const translated = await this.getRuntimeService("translation").translate(t2, { scope }); + this.applyTranslatedTitle(e2, translated, n2); } - supportsLiveSorting() { - return this.isHitShowPage() || this.isFc2ListPage(); + async revertTranslation() { + $(this.getSelector().itemSelector).toArray().forEach(((e2) => { + let t2 = $(e2); + const n2 = t2.find(".box").attr("title") || t2.find(".video-title").attr("title") || t2.find("img").attr("data-title"); + let a2; + r && (a2 = t2.find(".video-title strong").text().trim()); + const i2 = t2.find(".video-title"); + i2.contents().each((function() { + 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a2) || (this.textContent = " " + n2 + " "); + })), i2.removeAttr("title"); + })); } - async openWaitCheck() { - let e2 = this.getSelector(); - const t2 = await storageManager.getSetting("waitCheckCount", 5); - let a2 = 0; - const listPage = this.getDependency("ListPagePlugin"); - for (const element of $(e2.itemSelector).toArray()) { - if (a2 >= t2) break; - const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); - if (hasAnyState(flags) || isHardHidden(flags, visibilityReasons)) continue; - await listPage.openMovieDetail(item, { autoplay: true, newTab: false }), a2++; - } - 0 === a2 && show.info("没有需鉴定的视频"); + addJumpPageControl() { + const e2 = "gemini-jump-page-control"; + if ($("#" + e2).length > 0) return; + if (0 === $(".pagination-link.is-current").length) return; + const t2 = utils.getUrlParam(o, "page") || 1, n2 = $('', { + id: "jumpPageInput", + placeholder: "页码", + min: "1", + value: t2 + 1 + }), a2 = $('`, "item"), group = /* @__PURE__ */ __name((content) => `
    ${content}
    `, "group"), divider = ''; let items; if (window.isListPage) { - const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); + const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? requestedSortMethod : "default", sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; } else if (window.isDetailPage) { const statusDefs = [{ action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" }]; @@ -16545,21 +16545,16 @@ ${failure.stack}` : ""); const car = await storageManager.getCar(carNum); const menu = $(".jhs-fab-menu"); const colors = { filter: "var(--jhs-status-filter)", fav: "var(--jhs-status-fav)", down: "var(--jhs-status-down)", watch: "var(--jhs-status-watch)" }; - const flags = normalizeStateFlags(car?.stateFlags), activeKeys = new Set([ - flags.blocked && "filter", - flags.favorite && "fav", - flags.downloaded && "down", - flags.watched && "watch" - ].filter(Boolean)); - menu.find(".jhs-fab-status-dot").each(function() { - const key = $(this).data("status-key"); - const item = $(this).closest(".jhs-fab-menu-item"); + const flags = normalizeStateFlags(car?.stateFlags), activeKeys = /* @__PURE__ */ new Set(); + flags.blocked && activeKeys.add("filter"), flags.favorite && activeKeys.add("fav"), flags.downloaded && activeKeys.add("down"), flags.watched && activeKeys.add("watch"); + menu.find(".jhs-fab-status-dot").each(((index, element) => { + const key = String($(element).data("status-key") || ""), item = $(element).closest(".jhs-fab-menu-item"); if (activeKeys.has(key)) { - $(this).css({ background: colors[key] || "var(--jhs-border-strong)" }), item.attr("aria-pressed", "true"); + $(element).css({ background: colors[key] || "var(--jhs-border-strong)" }), item.attr("aria-pressed", "true"); } else { - $(this).css({ background: "var(--jhs-border-strong)" }), item.attr("aria-pressed", "false"); + $(element).css({ background: "var(--jhs-border-strong)" }), item.attr("aria-pressed", "false"); } - }); + })); } catch (e2) { clog.warn("移动端详情状态刷新失败", e2); } @@ -16588,8 +16583,8 @@ ${failure.stack}` : ""); menu.addClass("jhs-fab-menu-open").attr("aria-hidden", "false"); backdrop.addClass("jhs-fab-backdrop-visible"); if (window.isListPage) { - const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default"; - const sortLabel = { default: "默认", rateCount: "评价人数", date: "时间" }[sortMethod]; + const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }; + const sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? requestedSortMethod : "default", sortLabel = sortLabels[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); } @@ -16598,12 +16593,12 @@ ${failure.stack}` : ""); const self = this; const items = menu.find(".jhs-fab-menu-item"); items.first().trigger("focus"); - items.each(function(i2) { - const el = $(this); + items.each(((i2, element) => { + const el = $(element); setTimeout(() => { if (gen === self._fabGeneration) el.addClass("jhs-fab-item-visible"); }, 30 + i2 * 35); - }); + })); } }, "toggleMenu"); fab.on("click", toggleMenu); diff --git a/docs/architecture/ownership.md b/docs/architecture/ownership.md index 0ad08a33..a3693b34 100644 --- a/docs/architecture/ownership.md +++ b/docs/architecture/ownership.md @@ -14,6 +14,7 @@ Ownership 描述的是唯一职责归属,不是人员归属。新增行为必 | Hosted 详情布局 | HostedDetailSurface | | FC2/123AV 自有详情布局 | OwnedDetailSurface | | History 跨页选择 | HistorySelectionModel | +| List 快速筛选键、硬屏蔽判定与匹配规则 | List Filters Feature | | 外部 URL 信任与 redirect 复验 | ExternalUrlPolicy | | 请求合并、缓存与取消 | HttpService / CacheService / LifecycleScope | | 设置 snapshot 与本 runtime 事件 | SettingsService | diff --git a/scripts/checkjs-coverage.mjs b/scripts/checkjs-coverage.mjs index 978adad7..99573b70 100644 --- a/scripts/checkjs-coverage.mjs +++ b/scripts/checkjs-coverage.mjs @@ -27,7 +27,7 @@ for (const file of files) { if (compiledFiles.has(path.resolve(file)) && /^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); } const coverage = files.length === 0 ? 100 : (checked.length / files.length) * 100; -const required = Number(process.env.JHS_CHECKJS_MIN ?? 92); +const required = Number(process.env.JHS_CHECKJS_MIN ?? 93.5); if (coverage + Number.EPSILON < required) { throw new Error(`checkJs coverage ${coverage.toFixed(1)}% is below ${required}%`); } diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index 1978d278..8c9c51bf 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -53,6 +53,7 @@ const javDbHostAdapter = await read("src/platform/hosts/javdb-host-adapter.js"); const unifiedOffline = await read("src/plugins/offline/unified-offline.js"); const hitShow = await read("src/plugins/external-search/hit-show.js"); const listPageButton = await read("src/plugins/status/list-page-button.js"); +const listFiltersSource = await read("src/features/list/list-filters.js"); const fc2 = await read("src/plugins/external-search/fc2.js"); const fc2By123Av = await read("src/plugins/external-search/fc2-by-123av.js"); const uiPrimitives = await read("src/core/ui-primitives.js"); @@ -209,12 +210,12 @@ for (const retiredSetting of [ "showAllItem", "showFavoriteItem", "showHasDownIt assert(!listPageSource.includes(retiredSetting) && !settingFormsSource.includes(retiredSetting) && !settingTemplatesSource.includes(retiredSetting), `retired list visibility setting returned: ${retiredSetting}`); assertIncludes(listPageSource, "getIndexedItems(payload.carNums || [])", "precise list DOM index lookup"); assertIncludes(listPageSource, "scheduleRecount()", "frame-coalesced status recount"); -assertIncludes(listPageSource, "normalizeQuickFilterKey", "quick filter compatibility boundary"); +assertIncludes(listFiltersSource, "normalizeQuickFilterKey", "quick filter compatibility boundary"); assertIncludes(listPageSource, "collectCurrentPageSummary", "single current-page summary collector"); assertIncludes(listPageSource, "hardHidden || R.push(t)", "hard-hidden cards must stay out of the default translation queue"); -assert((listPageSource.match(/"filter"/g) || []).length === 1, "legacy quick-filter key must only appear in normalizeQuickFilterKey"); +assert((listFiltersSource.match(/"filter"/g) || []).length === 1, "legacy quick-filter key must only appear in normalizeQuickFilterKey"); for (const forbidden of [ 'data-jhs-filter="filter"', 'setQuickFilter("filter")', 'filter === "filter"', '"filter" === filter' ]) - assert(!listPageSource.includes(forbidden) && !mobileSource.includes(forbidden) && !statsSource.includes(forbidden), `legacy quick-filter business key returned: ${forbidden}`); + assert(!listFiltersSource.includes(forbidden) && !listPageSource.includes(forbidden) && !mobileSource.includes(forbidden) && !statsSource.includes(forbidden), `legacy quick-filter business key returned: ${forbidden}`); assert(!statsSource.includes("#jhs-quick-filter"), "Stats must use ListPagePlugin.setQuickFilter instead of filter DOM"); for (const filter of [ "all", "favorite", "hasDown", "hasWatch", "blockedItems", "waitCheck" ]) assert(!statsSource.includes(`data-filter="${filter}"`), `full-library Stats metric must not navigate to current-page filter ${filter}`); diff --git a/src/features/list/list-filters.js b/src/features/list/list-filters.js new file mode 100644 index 00000000..f3f82858 --- /dev/null +++ b/src/features/list/list-filters.js @@ -0,0 +1,51 @@ +// @ts-check + +import { hasAnyState } from "../../core/state-model.js"; + +/** @typedef {import("../../core/state-model.js").StateFlags} StateFlags */ +/** @typedef {{ keyword?: boolean, actorBlacklist?: boolean, actressBlacklist?: boolean }} VisibilityReasons */ + +export const QUICK_FILTER_LABELS = Object.freeze({ + all: "全部", waitCheck: "待鉴定", favorite: "收藏", hasDown: "下载", hasWatch: "已看", + blockedItems: "屏蔽项", favoriteUndownloaded: "收藏未下载", favoriteUnwatched: "收藏未观看", + downloadedUnwatched: "下载未观看", recent7d: "最近 7 天" +}); + +/** @typedef {keyof typeof QUICK_FILTER_LABELS} QuickFilterKey */ +/** @type {ReadonlyArray} */ +export const PRIMARY_QUICK_FILTERS = Object.freeze([ "all", "waitCheck", "favorite", "hasDown", "hasWatch" ]); +/** @type {ReadonlyArray} */ +export const SECONDARY_QUICK_FILTERS = Object.freeze([ "blockedItems", "favoriteUndownloaded", "favoriteUnwatched", "downloadedUnwatched", "recent7d" ]); +const VALID_QUICK_FILTERS = new Set([ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ]); + +/** @param {unknown} value @returns {QuickFilterKey} */ +export function normalizeQuickFilterKey(value) { + if ("filter" === value) return "blockedItems"; + return "string" === typeof value && VALID_QUICK_FILTERS.has(/** @type {QuickFilterKey} */ (value)) ? /** @type {QuickFilterKey} */ (value) : "waitCheck"; +} + +/** @param {StateFlags} flags @param {VisibilityReasons} [visibilityReasons] */ +export function isHardHidden(flags, visibilityReasons = {}) { + return Boolean(flags.blocked || visibilityReasons.keyword || visibilityReasons.actorBlacklist || visibilityReasons.actressBlacklist); +} + +/** @param {unknown} filter @param {StateFlags} flags @param {{ visibilityReasons?: VisibilityReasons, recent?: boolean }} [options] */ +export function matchesQuickFilter(filter, flags, { visibilityReasons = {}, recent = !1 } = {}) { + const normalizedFilter = normalizeQuickFilterKey(filter), hardHidden = isHardHidden(flags, visibilityReasons); + if ("blockedItems" === normalizedFilter) return hardHidden; + if (hardHidden) return !1; + if ("all" === normalizedFilter) return !0; + if ("waitCheck" === normalizedFilter) return !hasAnyState(flags); + if ("favorite" === normalizedFilter) return !!flags.favorite; + if ("hasDown" === normalizedFilter) return !!flags.downloaded; + if ("hasWatch" === normalizedFilter) return !!flags.watched; + if ("favoriteUndownloaded" === normalizedFilter) return !!flags.favorite && !flags.downloaded; + if ("favoriteUnwatched" === normalizedFilter) return !!flags.favorite && !flags.watched; + if ("downloadedUnwatched" === normalizedFilter) return !!flags.downloaded && !flags.watched; + return "recent7d" === normalizedFilter && recent; +} + +/** @param {{ filter: unknown, flags: StateFlags, visibilityReasons?: VisibilityReasons, recent?: boolean }} item */ +export function shouldShowItem({ filter, flags, visibilityReasons, recent }) { + return matchesQuickFilter(filter, flags, { visibilityReasons, recent }); +} diff --git a/src/plugins/backup/setting-forms.js b/src/plugins/backup/setting-forms.js index 0ea50c97..6d728356 100644 --- a/src/plugins/backup/setting-forms.js +++ b/src/plugins/backup/setting-forms.js @@ -1,11 +1,17 @@ +// @ts-check + import { C, _, r } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { parseNumberSetting } from "../../core/feature-helpers.js"; import { applyImageMode } from "./setting-styles.js"; import { decryptCredential, encryptCredential } from "../../core/credential-crypto.js"; -import { normalizeQuickFilterKey } from "../status/list-page.js"; +import { normalizeQuickFilterKey } from "../../features/list/list-filters.js"; + +/** @typedef {Record} SettingDependencies */ +const settingsEventBus = /** @type {NonNullable} */ (jhsEventBus); /** Load all settings from storage into the main settings dialog form fields. */ +/** @param {SettingDependencies} dependencies */ export async function loadSettingForm(dependencies) { let e = await storageManager.getSetting(); $("#videoQuality").val(e.videoQuality), $("#reviewCount").val(e.reviewCount || 20), @@ -39,77 +45,80 @@ export async function loadSettingForm(dependencies) { $("#missAvUrl").val(i), $("#jableUrl").val(s), $("#avgleUrl").val(o), $("#javTrailersUrl").val(r), $("#av123Url").val(l), $("#javDbUrl").val(c), $("#javBusUrl").val(d), $("#supJavUrl").val(h); let g = await storageManager.getReviewFilterKeywordList(), p = await storageManager.getTitleFilterKeyword(); - g && g.forEach((e => { + g && g.forEach((/** @type {string} */ e) => { addLabelTag("#reviewKeywordContainer", e); - })), p && p.forEach((e => { + }), p && p.forEach((/** @type {string} */ e) => { addLabelTag("#filterKeywordContainer", e); - })), [ "#reviewKeywordContainer", "#filterKeywordContainer" ].forEach((e => { - $(`${e} .add-tag-btn`).on("click", (t => addKeyword(t, e))), $(`${e} .keyword-input`).on("keypress", (t => { + }), [ "#reviewKeywordContainer", "#filterKeywordContainer" ].forEach((/** @type {string} */ e) => { + $(`${e} .add-tag-btn`).on("click", ((/** @type {any} */ t) => addKeyword(t, e))), $(`${e} .keyword-input`).on("keypress", ((/** @type {any} */ t) => { "Enter" === t.key && addKeyword(t, e); })); - })); + }); bindLayoutRangeEvents(dependencies.busImg, dependencies.host); } /** Bind the shared layout range controls without accumulating handlers. */ +/** @param {any} busImgPlugin @param {any} hostAdapter */ function bindLayoutRangeEvents(busImgPlugin, hostAdapter) { $("#containerColumns").off(".jhsSetting").on("input.jhsSetting", (() => { const columns = $("#containerColumns").val(); $("#showContainerColumns").text(columns); const listRoot = hostAdapter?.locateListRoot?.(); listRoot && (listRoot.style.gridTemplateColumns = `repeat(${columns}, minmax(0, 1fr))`); - })).on("change.jhsSetting", (async event => { + })).on("change.jhsSetting", (async (/** @type {any} */ event) => { await storageManager.saveSettingItem("containerColumns", $(event.currentTarget).val()), await applyImageMode(busImgPlugin); })); - $("#containerWidth").off(".jhsSetting").on("input.jhsSetting", (event => { + $("#containerWidth").off(".jhsSetting").on("input.jhsSetting", ((/** @type {any} */ event) => { const width = parseInt($(event.target).val()) + 70, widthText = `${width}%`; $("#showContainerWidth").text(widthText); const layoutContainer = hostAdapter?.getListLayoutContainer?.(); layoutContainer && (layoutContainer.style.minWidth = widthText); - })).on("change.jhsSetting", (event => storageManager.saveSettingItem("containerWidth", parseInt($(event.currentTarget).val()) + 70))); + })).on("change.jhsSetting", ((/** @type {any} */ event) => storageManager.saveSettingItem("containerWidth", parseInt($(event.currentTarget).val()) + 70))); } /** Initialize quick settings in either the desktop popover or mobile layer. */ +/** @param {SettingDependencies} dependencies @param {() => any} getSelector @param {(panel: string) => void} openSettingDialogFn */ export async function initQuickSettingForm(dependencies, getSelector, openSettingDialogFn) { let e = await storageManager.getSetting(); $("#needClosePage").prop("checked", !e.needClosePage || e.needClosePage === _), $("#autoPage").prop("checked", !e.autoPage || e.autoPage === _), $("#translateTitle").prop("checked", !e.translateTitle || e.translateTitle === _), $("#enableLoadActressInfo").prop("checked", !e.enableLoadActressInfo || e.enableLoadActressInfo === _), $("#enableLoadOtherSite").prop("checked", !e.enableLoadOtherSite || e.enableLoadOtherSite === _), - $("#needClosePage").on("change", (async t => { + $("#needClosePage").on("change", (async (/** @type {any} */ t) => { await storageManager.saveSettingItem("needClosePage", $("#needClosePage").is(":checked") ? _ : C), - await jhsEventBus.emit("filter-rules-changed"); - })), $("#autoPage").on("change", (async t => { + await settingsEventBus.emit("filter-rules-changed"); + })), $("#autoPage").on("change", (async (/** @type {any} */ t) => { const n = $("#autoPage").is(":checked") ? _ : C; await storageManager.saveSettingItem("autoPage", n), $("#sort-toggle-btn").prop("disabled", n === _).attr("title", n === _ ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"); - })), $("#translateTitle").on("change", (async t => { + })), $("#translateTitle").on("change", (async (/** @type {any} */ t) => { const n = $("#translateTitle").is(":checked") ? _ : C; await storageManager.saveSettingItem("translateTitle", n), n === _ ? (await dependencies.listPage.doFilter(), isDetailPage && await dependencies.translate?.translate()) : (await dependencies.listPage.revertTranslation(), $(".translated-title").remove()); })), $("#hoverBigImg").prop("checked", !!e.hoverBigImg && e.hoverBigImg === _), - $("#hoverBigImg").on("change", (async t => { + $("#hoverBigImg").on("change", (async (/** @type {any} */ t) => { const n = $("#hoverBigImg").is(":checked") ? _ : C; - await storageManager.saveSettingItem("hoverBigImg", n), window.imageHoverPreviewObj && (window.imageHoverPreviewObj.destroy(), - window.imageHoverPreviewObj = null), n === _ && (window.imageHoverPreviewObj = new ImageHoverPreview({ + const runtimeWindow = /** @type {any} */ (window); + await storageManager.saveSettingItem("hoverBigImg", n), runtimeWindow.imageHoverPreviewObj && (runtimeWindow.imageHoverPreviewObj.destroy(), + runtimeWindow.imageHoverPreviewObj = null), n === _ && (runtimeWindow.imageHoverPreviewObj = new ImageHoverPreview({ selector: getSelector().coverImgSelector })); - })), $("#enableLoadActressInfo").on("change", (async t => { + })), $("#enableLoadActressInfo").on("change", (async (/** @type {any} */ t) => { const n = $("#enableLoadActressInfo").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableLoadActressInfo", n), n === _ ? dependencies.actressInfo?.loadActressInfo() : $(".actress-info").remove(); - })), $("#enableLoadOtherSite").on("change", (async t => { + })), $("#enableLoadOtherSite").on("change", (async (/** @type {any} */ t) => { const n = $("#enableLoadOtherSite").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableLoadOtherSite", n), n === _ ? await dependencies.otherSite.loadOtherSite() : $("#otherSiteBox").remove(); })), $("#enableLoadScreenShot").prop("checked", !e.enableLoadScreenShot || e.enableLoadScreenShot === _), - $("#enableLoadScreenShot").on("change", (async t => { + $("#enableLoadScreenShot").on("change", (async (/** @type {any} */ t) => { const n = $("#enableLoadScreenShot").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableLoadScreenShot", n), n === _ ? await dependencies.screenshot.loadScreenShot() : $(".screen-container").remove(); })), $("#enableLoadPreviewVideo").prop("checked", !e.enableLoadPreviewVideo || e.enableLoadPreviewVideo === _), - $("#enableLoadPreviewVideo").on("change", (async t => { + $("#enableLoadPreviewVideo").on("change", (async (/** @type {any} */ t) => { const n = $("#enableLoadPreviewVideo").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableLoadPreviewVideo", n); })), $("#enableVerticalModel").prop("checked", !!e.enableVerticalModel && e.enableVerticalModel === _), - $("#enableVerticalModel").on("change", (async t => { + $("#enableVerticalModel").on("change", (async (/** @type {any} */ t) => { const n = $("#enableVerticalModel").is(":checked") ? _ : C; await storageManager.saveSettingItem("enableVerticalModel", n), applyImageMode(dependencies.busImg); })), $("#moreBtn").on("click", (() => { @@ -118,6 +127,7 @@ export async function initQuickSettingForm(dependencies, getSelector, openSettin } /** Read all form values and save to storage. */ +/** @param {SettingDependencies} dependencies */ export async function saveSettingForm(dependencies) { let e = await storageManager.getSetting(); const nextWebDavUrl = String($("#webDavUrl").val() || "").trim(); @@ -163,21 +173,24 @@ export async function saveSettingForm(dependencies) { e.enableLoadActressInfo = $("#enableLoadActressInfo").is(":checked") ? _ : C, e.enableVerticalModel = $("#enableVerticalModel").is(":checked") ? _ : C, e.containerColumns = Number($("#containerColumns").val()) || 5, e.containerWidth = Number($("#containerWidth").val()) + 70 || 100, await storageManager.saveSetting(e); + /** @type {string[]} */ let t = []; - $("#reviewKeywordContainer .keyword-label").toArray().forEach((e => { + $("#reviewKeywordContainer .keyword-label").toArray().forEach((/** @type {Element} */ e) => { let n = $(e).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); t.push(n); - })), await storageManager.saveReviewFilterKeyword(t); + }), await storageManager.saveReviewFilterKeyword(t); + /** @type {string[]} */ let n = []; - $("#filterKeywordContainer .keyword-label").toArray().forEach((e => { + $("#filterKeywordContainer .keyword-label").toArray().forEach((/** @type {Element} */ e) => { let t = $(e).text().replace("×", "").replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " ").trim(); n.push(t); - })), await storageManager.saveTitleFilterKeyword(n), show.ok("保存成功"), await jhsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }); + }), await storageManager.saveTitleFilterKeyword(n), show.ok("保存成功"), await settingsEventBus.emit("filter-rules-changed", { scope: "title-keyword" }); const a = dependencies.newVideo; a && a.resetBtnTip(), dependencies.blacklist.resetBtnTip(), dependencies.blacklist.reloadTable(); } /** Create a removable keyword label tag in the filter panel. */ +/** @param {string} e @param {string} t */ function addLabelTag(e, t) { const n = $(`${e} .tag-box`); let a; @@ -192,7 +205,7 @@ function addLabelTag(e, t) { ×
    `), - a.find(".keyword-remove").click((e => { + a.find(".keyword-remove").click(((/** @type {any} */ e) => { e.stopPropagation(), e.preventDefault(); const t = $(e.currentTarget); const n = t.closest(".keyword-label").attr("data-keyword").split(" ")[0]; @@ -203,6 +216,7 @@ function addLabelTag(e, t) { } /** Add a keyword from the input field to the tag box. */ +/** @param {any} e @param {string} t */ function addKeyword(e, t) { let n = $(`${t} .keyword-input`); const a = n.val().trim(); diff --git a/src/plugins/status/list-page-button.js b/src/plugins/status/list-page-button.js index 2d531000..0c94afc3 100644 --- a/src/plugins/status/list-page-button.js +++ b/src/plugins/status/list-page-button.js @@ -1,8 +1,14 @@ +// @ts-check + import { _, c, g, h, l, o, r } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { isHitShowPage } from "../../core/site-context.js"; import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; -import { isHardHidden } from "./list-page.js"; +import { isHardHidden } from "../../features/list/list-filters.js"; + +/** @typedef {import("../../core/lifecycle-scope.js").LifecycleScope} LifecycleScope */ +/** @typedef {{ starId?: string }} BlacklistRecord */ +/** @typedef {{ element: Element, key: number, originalIndex: number, index: number }} SortItem */ export class ListPageButtonPlugin extends BasePlugin { getName() { @@ -16,6 +22,7 @@ export class ListPageButtonPlugin extends BasePlugin { $("#sort-toggle-btn").prop("disabled", e === _ && !t).attr("title", e === _ && !t ? "瀑布流模式仅支持默认排序" : "选择列表排序方式"), (e !== _ || t) && await this.sortItems(); } + /** @param {LifecycleScope} scope */ async createMenuBtn(scope) { if (r) { const e = o.includes("/actors/"); @@ -23,14 +30,14 @@ export class ListPageButtonPlugin extends BasePlugin { if (e) { t = $(".toolbar, .section-addition").filter(":last"); const e = await storageManager.getBlacklist(), i = this.getActressPageInfo(); - e.find((e => e.starId === i.starId)) && (n = "已加入黑名单", a = "jhs-btn--muted"); + e.find((/** @type {BlacklistRecord} */ e) => e.starId === i.starId) && (n = "已加入黑名单", a = "jhs-btn--muted"); } else o.includes("/tags") && utils.loopDetector((() => $("#jhs-check-tag").text().trim()), (async () => { const e = $("#addBlacklistBtn"); e.attr("data-tip", "将当前分类标签加入到黑名单, 后续有作品更新也会纳入屏蔽中"); const t = $("#jhs-check-tag").text().trim(); if (!t) return; const n = "no-" + t, a = await storageManager.getBlacklist(); - s = a.find((e => e.starId === n)), s && (e.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); + s = a.find((/** @type {BlacklistRecord} */ e) => e.starId === n), s && (e.addClass("jhs-btn--muted").removeClass("jhs-btn--filter"), $("#addBlacklistBtn span").text("已加入黑名单")); }), 20, 1e4, !0, scope); const r = o.includes("advanced_search"); r && (t = $("h2.section-title")); @@ -42,7 +49,7 @@ export class ListPageButtonPlugin extends BasePlugin { let t = "加入黑名单", n = "jhs-btn--filter"; if (e) { const e = await storageManager.getBlacklist(), a = this.getActressPageInfo(); - e.find((e => e.starId === a.starId)) && (t = "已加入黑名单", n = "jhs-btn--muted"); + e.find((/** @type {BlacklistRecord} */ e) => e.starId === a.starId) && (t = "已加入黑名单", n = "jhs-btn--muted"); } const a = this.getRuntimeService("settings").snapshot().sortMethod || "default"; $(".masonry").parent().prepend(`\n
    \n \n ${e ? ` \n \n \n \n \n ` : ''}\n ${this.sortMenuHtml(a)}\n
    \n `); @@ -51,23 +58,23 @@ export class ListPageButtonPlugin extends BasePlugin { const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); newVideoLabel.length && newVideoLabel.empty().append(document.createTextNode("新作品 ("), newVideoCount, document.createTextNode(")")); } - /** 构建与原排序值兼容的 JHS 菜单。 */ + /** @param {unknown} method @param {string} [title] 构建与原排序值兼容的 JHS 菜单。 */ sortMenuHtml(method, title = "选择列表排序方式") { - const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, current = labels[method] || labels.default; + const labels = { default: "默认", rateCount: "评价人数", date: "时间" }, key = "string" === typeof method && method in labels ? /** @type {keyof typeof labels} */ (method) : "default", current = labels[key]; return `
    `; } bindEvent() { - $("#waitCheckBtn").on("click", (e => { - void this.openWaitCheck(e).catch((error => clog.error("待鉴定列表打开失败", error))); - })), $("#newVideoBtn").on("click", (e => { + $("#waitCheckBtn").on("click", ((/** @type {any} */ e) => { + void this.openWaitCheck().catch((error => clog.error("待鉴定列表打开失败", error))); + })), $("#newVideoBtn").on("click", ((/** @type {any} */ e) => { this.getDependency("NewVideoPlugin").openDialog(); - })), $("#blacklistBtn").on("click", (e => { + })), $("#blacklistBtn").on("click", ((/** @type {any} */ e) => { this.getDependency("BlacklistPlugin").openBlacklistDialog(); })), this.bindSortMenu(); const e = this.getDependency("BlacklistPlugin"); - $("#addBlacklistBtn").on("click", (async t => { + $("#addBlacklistBtn").on("click", (async (/** @type {any} */ t) => { await e.addBlacklist(t); - })), $("#filterAllVideo").on("click", (async t => { + })), $("#filterAllVideo").on("click", (async (/** @type {any} */ t) => { let n = { clientX: t.clientX, clientY: t.clientY + 80 @@ -82,7 +89,7 @@ export class ListPageButtonPlugin extends BasePlugin { clog.error(t); } finally { this.loadObj.close(); } })); - })), $("#favoriteAllVideo").on("click", (async t => { + })), $("#favoriteAllVideo").on("click", (async (/** @type {any} */ t) => { let n = {clientX: t.clientX, clientY: t.clientY + 80}, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); let i = a.text().trim().split(",")[0]; @@ -90,7 +97,7 @@ export class ListPageButtonPlugin extends BasePlugin { this.loadObj = loading(); try { await e.batchSaveAllVideos(i, h); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } })); - })), $("#hasDownAllVideo").on("click", (async t => { + })), $("#hasDownAllVideo").on("click", (async (/** @type {any} */ t) => { let n = {clientX: t.clientX, clientY: t.clientY + 80}, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); let i = a.text().trim().split(",")[0]; @@ -105,16 +112,16 @@ export class ListPageButtonPlugin extends BasePlugin { const control = $(".jhs-sort-control"), toggle = control.find("#sort-toggle-btn"), menu = control.find(".jhs-sort-menu"), close = (focus = !1) => { menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), focus && toggle.trigger("focus"); }; - toggle.on("click", (event => { + toggle.on("click", ((/** @type {any} */ event) => { event.preventDefault(), event.stopPropagation(); const open = !menu.hasClass("is-open"); menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.find('[aria-checked="true"]').trigger("focus"); })); - menu.on("click", ".jhs-sort-option", (async event => { + menu.on("click", ".jhs-sort-option", (async (/** @type {any} */ event) => { const item = $(event.currentTarget), method = item.data("sort-method"); await this.getRuntimeService("settings").set("sortMethod", method), menu.find(".jhs-sort-option").attr("aria-checked", "false"), item.attr("aria-checked", "true"), $("#jhs-sort-current").text(item.text()), close(!0), await this.sortItems().catch((error => clog.error("列表排序失败", error))); - })).on("keydown", ".jhs-sort-option", (event => { + })).on("keydown", ".jhs-sort-option", ((/** @type {any} */ event) => { const items = menu.find(".jhs-sort-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), close(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; @@ -122,7 +129,7 @@ export class ListPageButtonPlugin extends BasePlugin { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", (event => { + $(document).off("click.jhsSortMenu").on("click.jhsSortMenu", ((/** @type {any} */ event) => { $(event.target).closest(control).length || close(); })); } @@ -134,21 +141,22 @@ export class ListPageButtonPlugin extends BasePlugin { const t = this.getRuntimeService("settings").snapshot().sortMethod; if (!t) return; const i = this.getSelector(), d = $(i.boxSelector), h = $(i.itemSelector); - h.each((function(e) { - $(this).attr("data-original-index") || $(this).attr("data-original-index", e); + h.each(((/** @type {number} */ e, /** @type {Element} */ element) => { + $(element).attr("data-original-index") || $(element).attr("data-original-index", e); })); - const items = h.get().map(((element, index) => { + /** @type {SortItem[]} */ + const items = h.get().map(((/** @type {Element} */ element, /** @type {number} */ index) => { const card = $(element), originalIndex = Number(card.attr("data-original-index")) || 0; if ("default" === t) return { element, key: originalIndex, originalIndex, index }; if ("rateCount" === t) { const explicit = Number(card.attr("data-jhs-rate-count")), match = card.find(".score").text().replaceAll(",", "").match(/(?:由\s*)?(\d+)\s*人(?:评价)?/); return { element, key: Number.isFinite(explicit) ? explicit : match ? Number(match[1]) : 0, originalIndex, index }; } - const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter((function() { return /^\d{4}-\d{1,2}-\d{1,2}$/.test($(this).text().trim()); })).first().text().trim(), timestamp = Date.parse(value); + const value = card.attr("data-jhs-publish-time") || card.find(".meta").text().trim() || card.find("date").filter(((/** @type {number} */ index, /** @type {Element} */ element) => /^\d{4}-\d{1,2}-\d{1,2}$/.test($(element).text().trim()))).first().text().trim(), timestamp = Date.parse(value); return { element, key: Number.isFinite(timestamp) ? timestamp : 0, originalIndex, index }; })); - items.sort(((e, n) => "default" === t ? e.key - n.key : n.key - e.key || e.originalIndex - n.originalIndex || e.index - n.index)); - const sortedElements = items.map((item => item.element)); + items.sort(((/** @type {SortItem} */ e, /** @type {SortItem} */ n) => "default" === t ? e.key - n.key : n.key - e.key || e.originalIndex - n.originalIndex || e.index - n.index)); + const sortedElements = items.map((/** @type {SortItem} */ item) => item.element); "default" === t ? $(sortedElements).appendTo(d) : d.empty().append(sortedElements); } isHitShowPage() { diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index 955551c2..290254cf 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -6,6 +6,7 @@ import { BasePlugin } from "../../core/plugin-manager.js"; import { readListItem } from "../../core/list-item-reader.js"; import { isHitShowPage } from "../../core/site-context.js"; import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; +import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, isHardHidden, matchesQuickFilter, normalizeQuickFilterKey, shouldShowItem } from "../../features/list/list-filters.js"; const Te = { IS_FILTERED: { @@ -74,44 +75,6 @@ const Te = { } }; -export const QUICK_FILTER_LABELS = Object.freeze({ - all: "全部", waitCheck: "待鉴定", favorite: "收藏", hasDown: "下载", hasWatch: "已看", - blockedItems: "屏蔽项", favoriteUndownloaded: "收藏未下载", favoriteUnwatched: "收藏未观看", - downloadedUnwatched: "下载未观看", recent7d: "最近 7 天" -}), PRIMARY_QUICK_FILTERS = Object.freeze([ "all", "waitCheck", "favorite", "hasDown", "hasWatch" ]), -SECONDARY_QUICK_FILTERS = Object.freeze([ "blockedItems", "favoriteUndownloaded", "favoriteUnwatched", "downloadedUnwatched", "recent7d" ]), -VALID_QUICK_FILTERS = new Set([ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ]); - -/** 将旧筛选键收敛为当前唯一业务键。 */ -export function normalizeQuickFilterKey(value) { - if ("filter" === value) return "blockedItems"; - return VALID_QUICK_FILTERS.has(value) ? value : "waitCheck"; -} - -/** 判断列表卡片是否因状态或规则被硬屏蔽。 */ -export function isHardHidden(flags, visibilityReasons = {}) { - return Boolean(flags.blocked || visibilityReasons.keyword || visibilityReasons.actorBlacklist || visibilityReasons.actressBlacklist); -} - -function matchesQuickFilter(filter, flags, { visibilityReasons = {}, recent = !1 } = {}) { - const normalizedFilter = normalizeQuickFilterKey(filter), hardHidden = isHardHidden(flags, visibilityReasons); - if ("blockedItems" === normalizedFilter) return hardHidden; - if (hardHidden) return !1; - if ("all" === normalizedFilter) return !0; - if ("waitCheck" === normalizedFilter) return !hasAnyState(flags); - if ("favorite" === normalizedFilter) return !!flags.favorite; - if ("hasDown" === normalizedFilter) return !!flags.downloaded; - if ("hasWatch" === normalizedFilter) return !!flags.watched; - if ("favoriteUndownloaded" === normalizedFilter) return !!flags.favorite && !flags.downloaded; - if ("favoriteUnwatched" === normalizedFilter) return !!flags.favorite && !flags.watched; - if ("downloadedUnwatched" === normalizedFilter) return !!flags.downloaded && !flags.watched; - return "recent7d" === normalizedFilter && recent; -} - -function shouldShowItem({ filter, flags, visibilityReasons, recent }) { - return matchesQuickFilter(filter, flags, { visibilityReasons, recent }); -} - export class ListPagePlugin extends BasePlugin { async initCss() { return ``; diff --git a/src/plugins/status/mobile-bottom-bar.js b/src/plugins/status/mobile-bottom-bar.js index 0a53a23b..b98b9973 100644 --- a/src/plugins/status/mobile-bottom-bar.js +++ b/src/plugins/status/mobile-bottom-bar.js @@ -1,7 +1,9 @@ +// @ts-check + import { k, m, r, v, y } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { normalizeStateFlags } from "../../core/state-model.js"; -import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, normalizeQuickFilterKey } from "./list-page.js"; +import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, normalizeQuickFilterKey } from "../../features/list/list-filters.js"; export class MobileBottomBarPlugin extends BasePlugin { constructor() { @@ -240,15 +242,15 @@ export class MobileBottomBarPlugin extends BasePlugin { item.length && item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(batch.find(".jhs-commandbar__menu")); })); batch.find(".jhs-commandbar__menu").children().length && right.append(batch); - $(".jhs-list-btn-row").filter((function() { return !$(this).children().length; })).remove(); - commandbar.find(".jhs-commandbar__more, .jhs-commandbar__batch").each((function() { - const container = $(this), toggle = container.find(".jhs-commandbar__menu-toggle"), menu = container.find(".jhs-commandbar__menu"); - toggle.on("click", (event => { + $(".jhs-list-btn-row").filter(((/** @type {number} */ index, /** @type {Element} */ element) => !$(element).children().length)).remove(); + commandbar.find(".jhs-commandbar__more, .jhs-commandbar__batch").each(((/** @type {number} */ index, /** @type {Element} */ element) => { + const container = $(element), toggle = container.find(".jhs-commandbar__menu-toggle"), menu = container.find(".jhs-commandbar__menu"); + toggle.on("click", ((/** @type {any} */ event) => { event.stopPropagation(); const open = !menu.hasClass("is-open"); commandbar.find(".jhs-commandbar__menu").removeClass("is-open"), commandbar.find(".jhs-commandbar__menu-toggle").attr("aria-expanded", "false"), menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && menu.children().first().trigger("focus"); - })), menu.on("keydown", "[role='menuitem']", (event => { + })), menu.on("keydown", "[role='menuitem']", ((/** @type {any} */ event) => { const items = menu.find("[role='menuitem']"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), menu.removeClass("is-open"), toggle.attr("aria-expanded", "false").trigger("focus"); if ("Tab" === event.key) return menu.removeClass("is-open"), void toggle.attr("aria-expanded", "false"); @@ -260,7 +262,7 @@ export class MobileBottomBarPlugin extends BasePlugin { menu.removeClass("is-open"), toggle.attr("aria-expanded", "false").trigger("focus"); })); })); - $(document).off("click.jhsCommandbar").on("click.jhsCommandbar", (event => { + $(document).off("click.jhsCommandbar").on("click.jhsCommandbar", ((/** @type {any} */ event) => { $(event.target).closest(".jhs-commandbar__more, .jhs-commandbar__batch").length || (commandbar.find(".jhs-commandbar__menu").removeClass("is-open"), commandbar.find(".jhs-commandbar__menu-toggle").attr("aria-expanded", "false")); })); this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); @@ -276,10 +278,10 @@ export class MobileBottomBarPlugin extends BasePlugin { return null; } createMenu() { - const item = (action, label, attributes = "") => ``, group = content => `
    ${content}
    `, divider = ''; + const item = (/** @type {string} */ action, /** @type {string} */ label, /** @type {string} */ attributes = "") => ``, group = (/** @type {string} */ content) => `
    ${content}
    `, divider = ''; let items; if (window.isListPage) { - const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default", sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), + const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? /** @type {keyof typeof sortLabels} */ (requestedSortMethod) : "default", sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; @@ -297,20 +299,19 @@ export class MobileBottomBarPlugin extends BasePlugin { const car = await storageManager.getCar(carNum); const menu = $(".jhs-fab-menu"); const colors = { filter: "var(--jhs-status-filter)", fav: "var(--jhs-status-fav)", down: "var(--jhs-status-down)", watch: "var(--jhs-status-watch)" }; - const flags = normalizeStateFlags(car?.stateFlags), activeKeys = new Set([ - flags.blocked && "filter", flags.favorite && "fav", flags.downloaded && "down", flags.watched && "watch" - ].filter(Boolean)); - menu.find(".jhs-fab-status-dot").each(function () { - const key = $(this).data("status-key"); - const item = $(this).closest(".jhs-fab-menu-item"); + const flags = normalizeStateFlags(car?.stateFlags), activeKeys = new Set(); + flags.blocked && activeKeys.add("filter"), flags.favorite && activeKeys.add("fav"), flags.downloaded && activeKeys.add("down"), flags.watched && activeKeys.add("watch"); + menu.find(".jhs-fab-status-dot").each(((/** @type {number} */ index, /** @type {HTMLElement} */ element) => { + const key = String($(element).data("status-key") || ""), item = $(element).closest(".jhs-fab-menu-item"); if (activeKeys.has(key)) { - $(this).css({ background: colors[key] || "var(--jhs-border-strong)" }), item.attr("aria-pressed", "true"); + $(element).css({ background: colors[/** @type {keyof typeof colors} */ (key)] || "var(--jhs-border-strong)" }), item.attr("aria-pressed", "true"); } else { - $(this).css({ background: "var(--jhs-border-strong)" }), item.attr("aria-pressed", "false"); + $(element).css({ background: "var(--jhs-border-strong)" }), item.attr("aria-pressed", "false"); } - }); + })); } catch (e) { clog.warn("移动端详情状态刷新失败", e); } } + /** @param {any} fab @param {any} backdrop */ bindEvents(fab, backdrop) { const menu = $(".jhs-fab-menu"), filterMenu = menu.find(".jhs-mobile-filter-menu"), sortMenu = menu.find(".jhs-mobile-sort-menu"), filterTrigger = menu.find('[data-action="quickFilter"]'), sortTrigger = menu.find('[data-action="sort"]'), closeFilterMenu = (returnFocus = !1) => { menu.removeClass("jhs-fab-filter-open"), filterTrigger.attr("aria-expanded", "false"), returnFocus && filterTrigger.trigger("focus"); @@ -336,8 +337,8 @@ export class MobileBottomBarPlugin extends BasePlugin { backdrop.addClass("jhs-fab-backdrop-visible"); // 刷新排序标签 if (window.isListPage) { - const sortMethod = this.getRuntimeService("settings").snapshot().sortMethod || "default"; - const sortLabel = { default: "默认", rateCount: "评价人数", date: "时间" }[sortMethod]; + const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }; + const sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? /** @type {keyof typeof sortLabels} */ (requestedSortMethod) : "default", sortLabel = sortLabels[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); } @@ -348,19 +349,19 @@ export class MobileBottomBarPlugin extends BasePlugin { const self = this; const items = menu.find(".jhs-fab-menu-item"); items.first().trigger("focus"); - items.each(function (i) { - const el = $(this); + items.each(((/** @type {number} */ i, /** @type {Element} */ element) => { + const el = $(element); setTimeout(() => { if (gen === self._fabGeneration) el.addClass("jhs-fab-item-visible"); }, 30 + i * 35); - }); + })); } }; // FAB 点击切换 fab.on("click", toggleMenu); // 遮罩点击关闭 backdrop.on("click", (() => closeMenu(!0))); - menu.on("keydown", ".jhs-fab-menu-item", (event => { + menu.on("keydown", ".jhs-fab-menu-item", ((/** @type {any} */ event) => { const items = menu.find(".jhs-fab-menu-item"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; @@ -368,30 +369,30 @@ export class MobileBottomBarPlugin extends BasePlugin { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - filterMenu.on("keydown", ".jhs-mobile-filter-option", (event => { + filterMenu.on("keydown", ".jhs-mobile-filter-option", ((/** @type {any} */ event) => { const items = filterMenu.find(".jhs-mobile-filter-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeFilterMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ".jhs-mobile-filter-option", (event => { + })).on("click", ".jhs-mobile-filter-option", ((/** @type {any} */ event) => { event.stopPropagation(), this.getDependency("ListPagePlugin").setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(!0); })); - sortMenu.on("keydown", ".jhs-mobile-sort-option", (event => { + sortMenu.on("keydown", ".jhs-mobile-sort-option", ((/** @type {any} */ event) => { const items = sortMenu.find(".jhs-mobile-sort-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeSortMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ".jhs-mobile-sort-option", (async event => { + })).on("click", ".jhs-mobile-sort-option", (async (/** @type {any} */ event) => { event.stopPropagation(); const value = $(event.currentTarget).data("jhs-sort"); await this.getRuntimeService("settings").set("sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), await this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(!0); })); // 菜单项点击 - menu.on("click", ".jhs-fab-menu-item", (e) => { + menu.on("click", ".jhs-fab-menu-item", (/** @type {any} */ e) => { const action = $(e.currentTarget).data("action"); if ("quickFilter" === action) { e.stopPropagation(), menu.addClass("jhs-fab-filter-open"), filterTrigger.attr("aria-expanded", "true"); @@ -407,6 +408,7 @@ export class MobileBottomBarPlugin extends BasePlugin { void this.handleAction(action).catch((error => clog.error(`移动端操作 ${action || "unknown"} 失败`, error))); }); } + /** @param {unknown} action */ async handleAction(action) { switch (action) { // 列表页操作 diff --git a/tests/list-visibility.test.js b/tests/list-visibility.test.js index a428b096..ba84f25b 100644 --- a/tests/list-visibility.test.js +++ b/tests/list-visibility.test.js @@ -1,21 +1,10 @@ -import { readTestFile } from "./helpers/read-test-file.js"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import vm from "node:vm"; import { describe, expect, it } from "vitest"; - -function loadVisibility() { - const source = readTestFile(join(process.cwd(), "src/plugins/status/list-page.js"), "utf8"), start = source.indexOf("const QUICK_FILTER_LABELS"), end = source.indexOf("class ListPagePlugin", start), context = vm.createContext({ _: "yes", C: "no", hasAnyState: flags => Object.values(flags).some(Boolean) }); - vm.runInContext(`${source.slice(start, end)};globalThis.api={normalizeQuickFilterKey,isHardHidden,matchesQuickFilter,shouldShowItem,PRIMARY_QUICK_FILTERS,SECONDARY_QUICK_FILTERS}`, context); - return context.api; -} +import * as api from "../src/features/list/list-filters.js"; const flags = (...active) => Object.fromEntries(["favorite", "downloaded", "watched", "blocked"].map(key => [key, active.includes(key)])); const reasons = (...active) => Object.fromEntries(["keyword", "actorBlacklist", "actressBlacklist"].map(key => [key, active.includes(key)])); describe("multi-state list visibility", () => { - const api = loadVisibility(); - it("normalizes legacy and invalid quick-filter keys at the boundary", () => { expect(api.normalizeQuickFilterKey("filter")).toBe("blockedItems"); expect(api.normalizeQuickFilterKey("recent7d")).toBe("recent7d"); diff --git a/tests/media-playback.test.js b/tests/media-playback.test.js index 08249707..1d9c8ac1 100644 --- a/tests/media-playback.test.js +++ b/tests/media-playback.test.js @@ -58,7 +58,7 @@ describe("preview playback contracts", () => { } }); it("keeps native play calls inside safePlay only", () => { - const allRuntime = [ preview, bus, cover, readTestFile(join(import.meta.dirname, "../src/plugins/status/list-page.js"), "utf8") ].join("\n"); + const allRuntime = [ preview, bus, cover, readTestFile(join(import.meta.dirname, "../src/features/list/list-filters.js"), "utf8"), readTestFile(join(import.meta.dirname, "../src/plugins/status/list-page.js"), "utf8") ].join("\n"); expect(allRuntime).not.toMatch(/\.play\s*\(/); }); }); diff --git a/tests/performance-hotpaths.test.js b/tests/performance-hotpaths.test.js index 23cd6e01..65175b7d 100644 --- a/tests/performance-hotpaths.test.js +++ b/tests/performance-hotpaths.test.js @@ -57,7 +57,12 @@ function loadListObserver() { normalizeStateFlags: flags => Object.fromEntries([ "favorite", "downloaded", "watched", "blocked" ].map((key => [ key, !0 === flags?.[key] ]))), hasAnyState: flags => [ "favorite", "downloaded", "watched", "blocked" ].some((key => !0 === flags?.[key])) }); - vm.runInContext(`${readTestFile(join(repoRoot, "src/plugins/status/list-page.js"), "utf8")};globalThis.TestListPagePlugin=ListPagePlugin;`, context); + const source = [ + readTestFile(join(repoRoot, "src/features/list/list-filters.js"), "utf8"), + readTestFile(join(repoRoot, "src/plugins/status/list-page.js"), "utf8"), + "globalThis.TestListPagePlugin=ListPagePlugin;" + ].join("\n"); + vm.runInContext(source, context); return { dom, plugin: new context.TestListPagePlugin(), $, translate, mapLimit, storageManager, clog: context.clog }; } diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index d02937a8..f739ddf2 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -49,6 +49,7 @@ "src/plugins/avatar/search-by-image.js", "src/plugins/backup/resource-settings.js", "src/plugins/backup/setting-backup.js", + "src/plugins/backup/setting-forms.js", "src/plugins/backup/setting-panels.js", "src/plugins/backup/setting-styles.js", "src/plugins/backup/setting-templates.js", @@ -86,6 +87,8 @@ "src/plugins/status/fold-category.js", "src/plugins/status/highlight-magnet.js", "src/plugins/status/history.js", + "src/plugins/status/list-page-button.js", + "src/plugins/status/mobile-bottom-bar.js", "src/plugins/status/nav-bar.js", "src/plugins/status/want-and-watched-videos.js", "src/plugins/subtitle/subtitle-cat.js", From e65a39bdc34c6cb574eac9fe6babfcb72fd61c2a Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:15:45 +0800 Subject: [PATCH 058/130] =?UTF-8?q?Chore:=20=E7=B1=BB=E5=9E=8B=E5=8C=96=20?= =?UTF-8?q?Blacklist=20=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将 Blacklist 真实实现纳入 strict checkJs,明确任务状态订阅、筛选记录、Tabulator 与跨页批量写入边界 - 将隐藏字段初始化改为显式属性,并在使用 EventBus 时验证运行时已完成初始化 - 生产源码类型覆盖率提升至 94.4%,最低门禁上调至 94%,并同步正式 UserScript 产物 修改原因: - 推进 v6.5 Phase 8 的 Blacklist controller/repository 边界 - 防止任务状态、分页解析和批量状态写入在后续拆分中发生隐式类型回归 验证: - Blacklist/Task 定向测试 34 项通过 - npm run check 通过(117 个测试文件、615 项测试) - npm run check:browser 通过(本机 Edge,15 passed、15 skipped,未下载浏览器) - git diff --check 与 codegraph sync 通过 - 根目录和 dist UserScript SHA256 一致 --- CHANGELOG.md | 1 + JHS.user.js | 33 ++++++++----- scripts/checkjs-coverage.mjs | 2 +- src/plugins/blacklist/blacklist.js | 78 ++++++++++++++++++------------ tsconfig.architecture.json | 1 + 5 files changed, 70 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8020094..c564c4b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - StateService 的真实事务实现进入 strict checkJs,删除同名宽泛声明遮蔽,并显式约束状态字段、活动日志、新作决策、journal 恢复与撤销数据边界;生产源码类型覆盖率提升至 91.5%,最低覆盖门禁同步上调至 91.5%。 - History 真实运行时进入 strict checkJs,明确仓储记录、跨页选择、Tabulator 回调与状态编辑边界;生产源码类型覆盖率提升至 92.1%,最低覆盖门禁同步上调至 92%。 - List 快速筛选规则迁入独立 Feature owner,List 按钮、MobileBottomBar 与 Settings 表单不再依赖页面插件实现并进入 strict checkJs;生产源码类型覆盖率提升至 93.8%,最低覆盖门禁同步上调至 93.5%。 +- Blacklist 真实运行时进入 strict checkJs,明确任务状态订阅、筛选记录、Tabulator 回调和跨页批量状态写入边界;生产源码类型覆盖率提升至 94.4%,最低覆盖门禁同步上调至 94%。 - 新增可单测的 Bootstrap Purity 架构门禁,禁止除 Composition Root 外的生产模块在导入阶段启动 DOM、网络、Storage、监听器、Observer 或定时器;原隐藏导航样式改为 Bootstrap 注入时才读取地址。 - strict checkJs 继续覆盖自动翻页、设置样式/模板和 123AV-FC2 入口,并补齐分页空值、DOM 容器与 Integration 返回数据的显式契约。 - 截图、宿主详情工作区、兼容增强与统一离线模块进入 strict checkJs;Provider availability、宿主资源边界、Observer 和 jQuery 兼容句柄均补充显式类型,覆盖率门禁同步上调至 84%。 diff --git a/JHS.user.js b/JHS.user.js index c7a4c2c5..2854aea1 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -8815,9 +8815,16 @@ __name(parseDetailPage, "parseDetailPage"); // src/plugins/blacklist/blacklist.js + function getBlacklistEventBus() { + if (!jhsEventBus) throw new Error("Blacklist EventBus 未初始化"); + return jhsEventBus; + } + __name(getBlacklistEventBus, "getBlacklistEventBus"); var _BlacklistPlugin = class _BlacklistPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "blacklistSearchDebounced", null), i(this, "taskStatusUnsubscribe", null); + super(...arguments); + this.blacklistSearchDebounced = null; + this.taskStatusUnsubscribe = null; } getName() { return "BlacklistPlugin"; @@ -8950,7 +8957,7 @@ const dialog = $(t3).find(".layui-layer-content > div").first().addClass("jhs-blacklist-layout").removeAttr("style"), toolbar = dialog.children("div").first().addClass("jhs-blacklist-toolbar").removeAttr("style"); toolbar.children("div").addClass("jhs-blacklist-toolbar__group").removeAttr("style"), toolbar.find("select,input,a").removeAttr("style"), dialog.find("#table-container").removeAttr("style"); dialog.find("#table-container").before('
    '), JhsSelect.enhance(t3); - this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.renderTaskStatus())); + this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = getBlacklistEventBus().on("task-status-changed", (() => this.renderTaskStatus())); await this.loadTableData(); const content = $(t3).find(".layui-layer-content"), search = content.find("#searchValue"); this.blacklistSearchDebounced = utils.debounce((() => void this.reloadTable()), 200), content.on("click", "#cleanQueryBtn", (async () => { @@ -8980,15 +8987,15 @@ })); }, "success"), end: /* @__PURE__ */ __name(async () => { - this.blacklistSearchDebounced?.cancel?.(), this.blacklistSearchDebounced = null, this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null), await jhsEventBus.emit("blacklist-rules-changed"); + this.blacklistSearchDebounced?.cancel?.(), this.blacklistSearchDebounced = null, this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null), await getBlacklistEventBus().emit("blacklist-rules-changed"); }, "end") }); } renderTaskStatus() { const container = $("#blacklist-task-status"); if (!container.length) return; - const snapshot = this.getDependency("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"); - container.empty().append($('').text(`黑名单:${labels[snapshot.state]}`), $('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); + const snapshot = this.getDependency("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = /* @__PURE__ */ __name((value) => value ? new Date(value).toLocaleString() : "无", "format"), state = snapshot.state; + container.empty().append($('').text(`黑名单:${labels[state] || labels.idle}`), $('').text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); } async reloadTable() { if (!this.tableObj) return; @@ -8998,17 +9005,17 @@ async getTableData() { const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getBlacklist(), n2 = await storageManager.getBlacklistCarList(), a2 = String($("#searchValue").val() || "").trim().toLocaleLowerCase(), i2 = $("#statusType").val(), s2 = $("#dataType"), o2 = s2.val(), r2 = $("#urlType").val(), l2 = t2.length; let c2 = 0, d2 = 0; - const h2 = t2.map(((t3) => { + const h2 = t2.map((t3) => { t3.role === B ? c2++ : t3.role === P && d2++; let n3 = false; return n3 = shouldSkipStopped(t3.lastPublishTime, this.checkBlacklist_ruleTime), { ...t3, isUnCheck: n3 }; - })).filter(((item) => { + }).filter((item) => { const aliases = Array.isArray(item.allName) ? item.allName.join(" ") : item.allName || "", searchable = `${item.name || ""} ${aliases} ${item.starId || ""}`.toLocaleLowerCase(), searchMatch = !a2 || searchable.includes(a2), statusMatch = !i2 || "normal" === i2 && !item.isUnCheck || "stop" === i2 && item.isUnCheck, roleMatch = !o2 || item.role === o2, hasCategory = item.url.includes("t="), urlMatch = !r2 || "hasT" === r2 && hasCategory || "noT" === r2 && !hasCategory; return searchMatch && statusMatch && roleMatch && urlMatch; - })); + }); s2.html(` @@ -9019,21 +9026,21 @@ const e3 = m2.starId; g2.has(e3) || g2.set(e3, []), g2.get(e3).push(m2); } - const p2 = h2.map(((e3) => { + const p2 = h2.map((e3) => { const t3 = e3.starId, n3 = g2.get(t3) || []; return { ...e3, carList: n3, count: n3.length }; - })); + }); return this.currentCarCount = p2.reduce(((e3, t3) => e3 + (t3.count || 0)), 0), p2; } async loadTableData() { this.checkBlacklist_ruleTime = parseNumberSetting(await storageManager.getSetting("checkBlacklist_ruleTime"), 8760, { min: 0 }); const e2 = await this.getTableData(), placeholder = document.createElement("div"); renderStateView(placeholder, { type: "empty", title: "没有符合当前筛选条件的黑名单记录" }); - this.tableObj = createJhsTable(Tabulator, "#table-container", { + this.tableObj = createJhsTable(globalThis.Tabulator, "#table-container", { layout: "fitColumns", placeholder, virtualDom: true, @@ -9188,7 +9195,7 @@ } processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); if (a2) { - clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); + clog.log("正在请求下一页内容:", a2), await new Promise((e3) => setTimeout(e3, 500)); const scope = await this.getRuntimeService("scope")(), t3 = await requestHostPage(this.getRuntimeService("http"), a2, scope), n3 = new DOMParser(), i2 = $(n3.parseFromString(t3, "text/html")); await this.filterAllVideo(e2, i2, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); @@ -9208,7 +9215,7 @@ } processed += n2.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); if (a2) { - clog.log("正在请求下一页内容:", a2), await new Promise(((e3) => setTimeout(e3, 500))); + clog.log("正在请求下一页内容:", a2), await new Promise((e3) => setTimeout(e3, 500)); const scope = await this.getRuntimeService("scope")(), i2 = await requestHostPage(this.getRuntimeService("http"), a2, scope), s2 = new DOMParser(), o2 = $(s2.parseFromString(i2, "text/html")); await this.batchSaveAllVideos(e2, t2, o2, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); diff --git a/scripts/checkjs-coverage.mjs b/scripts/checkjs-coverage.mjs index 99573b70..5618466c 100644 --- a/scripts/checkjs-coverage.mjs +++ b/scripts/checkjs-coverage.mjs @@ -27,7 +27,7 @@ for (const file of files) { if (compiledFiles.has(path.resolve(file)) && /^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); } const coverage = files.length === 0 ? 100 : (checked.length / files.length) * 100; -const required = Number(process.env.JHS_CHECKJS_MIN ?? 93.5); +const required = Number(process.env.JHS_CHECKJS_MIN ?? 94); if (coverage + Number.EPSILON < required) { throw new Error(`checkJs coverage ${coverage.toFixed(1)}% is below ${required}%`); } diff --git a/src/plugins/blacklist/blacklist.js b/src/plugins/blacklist/blacklist.js index 8f546aaf..faea9445 100644 --- a/src/plugins/blacklist/blacklist.js +++ b/src/plugins/blacklist/blacklist.js @@ -1,4 +1,6 @@ -import { A, B, D, I, P, T, d, i, l, o, r } from "../../core/constants.js"; +// @ts-check + +import { A, B, D, I, P, T, d, l, o, r } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { normalizeHttpUrl, parseNumberSetting, selectLatestPublishTime, shouldSkipStopped } from "../../core/feature-helpers.js"; import { requestHostPage } from "../../core/host-page-request.js"; @@ -9,9 +11,18 @@ import { JhsSelect, renderStateView } from "../../core/ui-primitives.js"; import { parseDetailPage } from "../../integrations/host-list/parser.js"; import { createJhsTable } from "../../ui/table/create-jhs-table.js"; +/** @typedef {Record} BlacklistRecord */ +/** @returns {NonNullable} */ +function getBlacklistEventBus() { + if (!jhsEventBus) throw new Error("Blacklist EventBus 未初始化"); + return jhsEventBus; +} + export class BlacklistPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "blacklistSearchDebounced", null), i(this, "taskStatusUnsubscribe", null); + super(...arguments); + /** @type {any} */ this.blacklistSearchDebounced = null; + /** @type {null | (() => void)} */ this.taskStatusUnsubscribe = null; } getName() { return "BlacklistPlugin"; @@ -28,6 +39,7 @@ export class BlacklistPlugin extends BasePlugin { .jhs-blacklist-task-status .jhs-task-status__meta { display:block; margin-top:var(--jhs-space-1); color:var(--jhs-text-muted); font-size:var(--jhs-font-size-xs); } `; } + /** @param {any} e */ async addBlacklist(e) { let t = { clientX: e.clientX, @@ -61,7 +73,7 @@ export class BlacklistPlugin extends BasePlugin { const e = this.getDependency("TaskPlugin"); navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 - }, (async e => { + }, (async (/** @type {any} */ e) => { if (clog.debug("获取锁", e), e) { this.loadObj = loading(); try { @@ -113,11 +125,11 @@ export class BlacklistPlugin extends BasePlugin { scrollbar: !1, area: utils.getDialogArea("xl"), anim: -1, - success: async t => { + success: async (/** @type {any} */ t) => { const dialog = $(t).find(".layui-layer-content > div").first().addClass("jhs-blacklist-layout").removeAttr("style"), toolbar = dialog.children("div").first().addClass("jhs-blacklist-toolbar").removeAttr("style"); toolbar.children("div").addClass("jhs-blacklist-toolbar__group").removeAttr("style"), toolbar.find("select,input,a").removeAttr("style"), dialog.find("#table-container").removeAttr("style"); dialog.find("#table-container").before('
    '), JhsSelect.enhance(t); - this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = jhsEventBus.on("task-status-changed", (() => this.renderTaskStatus())); + this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = getBlacklistEventBus().on("task-status-changed", (() => this.renderTaskStatus())); await this.loadTableData(); const content = $(t).find(".layui-layer-content"), search = content.find("#searchValue"); this.blacklistSearchDebounced = utils.debounce((() => void this.reloadTable()), 200), content.on("click", "#cleanQueryBtn", (async () => { @@ -130,11 +142,11 @@ export class BlacklistPlugin extends BasePlugin { border: "1px solid var(--jhs-status-filter)" }); })); - })).on("click", ".open-url", (e => { + })).on("click", ".open-url", ((/** @type {any} */ e) => { e.preventDefault(); const t = $(e.currentTarget), n = t.attr("data-url"), a = t.attr("data-name"); utils.openPage(n, a, !0, e); - })).on("click", "#checkBlacklistBtn", (event => { + })).on("click", "#checkBlacklistBtn", ((/** @type {any} */ event) => { const button = $(event.currentTarget), label = button.find("span").last(), previous = label.text(); if (button.attr("aria-busy") === "true") return; button.attr("aria-busy", "true").prop("disabled", !0), label.text("检测中…"), navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 }, (async lock => { @@ -147,15 +159,15 @@ export class BlacklistPlugin extends BasePlugin { })); }, end: async () => { - this.blacklistSearchDebounced?.cancel?.(), this.blacklistSearchDebounced = null, this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null), await jhsEventBus.emit("blacklist-rules-changed"); + this.blacklistSearchDebounced?.cancel?.(), this.blacklistSearchDebounced = null, this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null, this.tableObj && (this.tableObj.destroy(), this.tableObj = null), await getBlacklistEventBus().emit("blacklist-rules-changed"); } }); } renderTaskStatus() { const container = $("#blacklist-task-status"); if (!container.length) return; - const snapshot = this.getDependency("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = value => value ? new Date(value).toLocaleString() : "无"; - container.empty().append($("").text(`黑名单:${labels[snapshot.state]}`), $("").text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); + const snapshot = this.getDependency("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = (/** @type {unknown} */ value) => value ? new Date(/** @type {string | number | Date} */ (value)).toLocaleString() : "无", state = /** @type {keyof typeof labels} */ (snapshot.state); + container.empty().append($("").text(`黑名单:${labels[state] || labels.idle}`), $("").text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); } async reloadTable() { if (!this.tableObj) return; @@ -165,7 +177,7 @@ export class BlacklistPlugin extends BasePlugin { async getTableData() { const e = this.getDependency("TaskPlugin"), t = await storageManager.getBlacklist(), n = await storageManager.getBlacklistCarList(), a = String($("#searchValue").val() || "").trim().toLocaleLowerCase(), i = $("#statusType").val(), s = $("#dataType"), o = s.val(), r = $("#urlType").val(), l = t.length; let c = 0, d = 0; - const h = t.map((t => { + const h = t.map((/** @type {BlacklistRecord} */ t) => { t.role === B ? c++ : t.role === P && d++; let n = !1; return n = shouldSkipStopped(t.lastPublishTime, this.checkBlacklist_ruleTime), @@ -173,10 +185,10 @@ export class BlacklistPlugin extends BasePlugin { ...t, isUnCheck: n }; - })).filter((item => { + }).filter((/** @type {BlacklistRecord} */ item) => { const aliases = Array.isArray(item.allName) ? item.allName.join(" ") : item.allName || "", searchable = `${item.name || ""} ${aliases} ${item.starId || ""}`.toLocaleLowerCase(), searchMatch = !a || searchable.includes(a), statusMatch = !i || "normal" === i && !item.isUnCheck || "stop" === i && item.isUnCheck, roleMatch = !o || item.role === o, hasCategory = item.url.includes("t="), urlMatch = !r || "hasT" === r && hasCategory || "noT" === r && !hasCategory; return searchMatch && statusMatch && roleMatch && urlMatch; - })); + }); s.html(`\n \n \n \n `), JhsSelect.setValue(s, o, !1); const g = new Map; @@ -184,21 +196,21 @@ export class BlacklistPlugin extends BasePlugin { const e = m.starId; g.has(e) || g.set(e, []), g.get(e).push(m); } - const p = h.map((e => { + const p = h.map((/** @type {BlacklistRecord} */ e) => { const t = e.starId, n = g.get(t) || []; return { ...e, carList: n, count: n.length }; - })); - return this.currentCarCount = p.reduce(((e, t) => e + (t.count || 0)), 0), p; + }); + return this.currentCarCount = p.reduce(((/** @type {number} */ e, /** @type {BlacklistRecord} */ t) => e + (t.count || 0)), 0), p; } async loadTableData() { this.checkBlacklist_ruleTime = parseNumberSetting(await storageManager.getSetting("checkBlacklist_ruleTime"), 8760, { min: 0 }); const e = await this.getTableData(), placeholder = document.createElement("div"); renderStateView(placeholder, { type: "empty", title: "没有符合当前筛选条件的黑名单记录" }); - this.tableObj = createJhsTable(Tabulator, "#table-container", { + this.tableObj = createJhsTable((/** @type {any} */ (globalThis)).Tabulator, "#table-container", { layout: "fitColumns", placeholder, virtualDom: !0, @@ -207,7 +219,7 @@ export class BlacklistPlugin extends BasePlugin { paginationMode: "local", paginationSize: 20, paginationSizeSelector: [ 20, 50, 100, 1e3 ], - paginationCounter: (e, t, n, a, i) => `演员: ${a}    番号总数: ${this.currentCarCount} `, + paginationCounter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n, /** @type {number} */ a, /** @type {any} */ i) => `演员: ${a}    番号总数: ${this.currentCarCount} `, responsiveLayout: "collapse", responsiveLayoutCollapse: !0, columnDefaults: { @@ -222,7 +234,7 @@ export class BlacklistPlugin extends BasePlugin { minWidth: 100, responsive: 0, headerSort: !1, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n) => { const a = e.getData(), url = normalizeHttpUrl(a.url), link = document.createElement("a"); link.className = "open-url", link.textContent = String(a.name || ""), link.dataset.name = String(a.name || ""); return url ? (link.href = url, link.dataset.url = url, link.target = "_blank", link.rel = "noopener noreferrer") : (link.href = "#", link.setAttribute("aria-disabled", "true")), link; @@ -233,7 +245,7 @@ export class BlacklistPlugin extends BasePlugin { sorter: "string", width: 120, responsive: 5, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n) => { const a = e.getData().role; let i = a; return a === B ? i = "男演员" : a === P && (i = "女演员"), i; @@ -244,7 +256,7 @@ export class BlacklistPlugin extends BasePlugin { sorter: "string", width: 120, responsive: 5, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n) => { const a = e.getData().movieType; let i = a; return a === D ? i = "有码" : a === A && (i = "无码"), i; @@ -256,7 +268,7 @@ export class BlacklistPlugin extends BasePlugin { minWidth: 120, responsive: 4, visible: r, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n) => { let a = e.getData().url.includes("t="); return `${a ? "按所选分类屏蔽" : "未筛选分类"}`; } @@ -284,7 +296,7 @@ export class BlacklistPlugin extends BasePlugin { sorter: "string", width: 120, responsive: 1, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {any} */ n) => { let a = "", i = "继续检测"; return e.getData().isUnCheck && (a = `停更${this.checkBlacklist_ruleTime / 24 / 365}年以上, 下轮任务不再进行检测`, i = "停更跳过"), `${i}`; @@ -296,24 +308,24 @@ export class BlacklistPlugin extends BasePlugin { minWidth: 150, responsive: 0, headerSort: !1, - formatter: (e, t, n) => { + formatter: (/** @type {any} */ e, /** @type {any} */ t, /** @type {(callback: () => void) => void} */ n) => { const a = e.getData(); return n((() => { var t, n; - null == (t = e.getElement().querySelector(".delete-btn")) || t.addEventListener("click", (e => { + null == (t = e.getElement().querySelector(".delete-btn")) || t.addEventListener("click", ((/** @type {Event} */ e) => { const t = a.name, n = a.starId; t ? n ? utils.q(e, `是否移除对 ${t} 的屏蔽?`, (async () => { await storageManager.removeBlacklistCarList(n), await storageManager.deleteBlacklistItem(n), show.info("操作成功"), await this.reloadTable(); })) : show.error("获取starId失败") : show.error("获取名称失败"); - })), null == (n = e.getElement().querySelector(".keyword-btn")) || n.addEventListener("click", (e => { - const t = a.carList.reduce(((e, t) => { + })), null == (n = e.getElement().querySelector(".keyword-btn")) || n.addEventListener("click", ((/** @type {Event} */ e) => { + const t = a.carList.reduce(((/** @type {Record} */ e, /** @type {BlacklistRecord} */ t) => { const n = t.carNum.split("-")[0] + "-"; return e[n] = (e[n] || 0) + 1, e; - }), {}), n = Object.entries(t).map((([e, t]) => ({ + }), /** @type {Record} */ ({})), n = Object.entries(t).map((([e, t]) => ({ prefix: e, count: t - }))).sort(((e, t) => t.count - e.count)); + }))).sort(((/** @type {{ count: number }} */ e, /** @type {{ count: number }} */ t) => t.count - e.count)); clog.debug(n); })); })), ''; @@ -342,6 +354,7 @@ export class BlacklistPlugin extends BasePlugin { } }); } + /** @param {string} e @param {any} [t] @param {number} [page] @param {number} [processed] */ async filterAllVideo(e, t, page = 1, processed = 0) { let n, a; if (t ? (l && t.find(".avatar-box").length > 0 && t.find(".avatar-box").parent().remove(), @@ -358,11 +371,12 @@ export class BlacklistPlugin extends BasePlugin { } processed += n.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); if (a) { - clog.log("正在请求下一页内容:", a), await new Promise((e => setTimeout(e, 500))); + clog.log("正在请求下一页内容:", a), await new Promise((/** @type {(value: void) => void} */ e) => setTimeout(e, 500)); const scope = await this.getRuntimeService("scope")(), t = await requestHostPage(this.getRuntimeService("http"), a, scope), n = new DOMParser, i = $(n.parseFromString(t, "text/html")); await this.filterAllVideo(e, i, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); } + /** @param {string} e @param {string} t @param {any} [pageDom] @param {number} [page] @param {number} [processed] */ async batchSaveAllVideos(e, t, pageDom = null, page = 1, processed = 0) { let n, a; pageDom ? (n = pageDom.find(this.getSelector().requestDomItemSelector), a = pageDom.find(this.getSelector().nextPageSelector).attr("href")) : (n = $(this.getSelector().itemSelector), a = $(this.getSelector().nextPageSelector).attr("href")); @@ -375,11 +389,12 @@ export class BlacklistPlugin extends BasePlugin { } catch (s) { clog.error(`保存失败 [${a}]:`, s); } } processed += n.length, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已扫描 ${processed} 个番号`); - if (a) { clog.log("正在请求下一页内容:", a), await new Promise((e => setTimeout(e, 500))); + if (a) { clog.log("正在请求下一页内容:", a), await new Promise((/** @type {(value: void) => void} */ e) => setTimeout(e, 500)); const scope = await this.getRuntimeService("scope")(), i = await requestHostPage(this.getRuntimeService("http"), a, scope), s = new DOMParser, o = $(s.parseFromString(i, "text/html")); await this.batchSaveAllVideos(e, t, o, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 共扫描 ${processed} 个番号`); } + /** @param {string} e @param {string} t @param {any} n @param {string} [site] @param {number} [page] @param {number} [processed] */ async filterActorVideo(e, t, n, site = l ? I : T, page = 1, processed = 0) { let {nextPageLink: a, recordCount} = await this.parseAndSaveFilterInfo(n, e, t, site); processed += recordCount, $("#checkBlacklistMsg").text(`正在处理第 ${page} 页 · 已屏蔽 ${processed} 个番号`); @@ -392,6 +407,7 @@ export class BlacklistPlugin extends BasePlugin { await this.filterActorVideo(e, t, n, site, page + 1, processed); } else $("#checkBlacklistMsg").text(`处理完成 · ${page} 页 · 新增 ${processed} 个番号`); } + /** @param {any} e @param {string} t @param {string} n @param {string} site */ async parseAndSaveFilterInfo(e, t, n, site) { if (![ T, I ].includes(site)) throw new Error(`未知黑名单来源站点: ${site}`); const page = e || $(document), selector = this.getSelector(site); diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index f739ddf2..8eaa99a4 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -54,6 +54,7 @@ "src/plugins/backup/setting-styles.js", "src/plugins/backup/setting-templates.js", "src/plugins/blacklist/filter-title-keyword.js", + "src/plugins/blacklist/blacklist.js", "src/plugins/dependency-map.js", "src/plugins/external-search/hit-show.js", "src/plugins/external-search/fc2-by-123av.js", From 9c9ebfa4081cb0568ae949d408a69bf8851fe6dd Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:26:05 +0800 Subject: [PATCH 059/130] =?UTF-8?q?Chore:=20=E8=BE=BE=E6=88=90=20RC=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=A6=86=E7=9B=96=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 将 TaskPlugin 真实实现纳入 strict checkJs,明确调度状态、任务结果、配置刷新、网络错误和分页同步边界 - 将 ListPagePlugin 真实实现纳入 strict checkJs,明确增量 DOM 索引、筛选视图、媒体导航、高清图片和翻译边界 - 保留未知任务名的显式失败语义,并将 RC 最低类型覆盖门禁提升至 95% - 生产源码类型覆盖率达到 95.5%,同步正式 UserScript 产物 修改原因: - 完成 Plan v1.1 对 RC checkJs 至少 95% 的硬要求 - 推进 Phase 8 的 List controller/model 边界与后台调度契约 验证: - List/Task/媒体热路径定向测试 62 项通过 - npm run check 通过(117 个测试文件、615 项测试) - npm run check:browser 通过(本机 Edge,15 passed、15 skipped,未下载浏览器) - git diff --check 与 codegraph sync 通过 - 根目录和 dist UserScript SHA256 一致 --- CHANGELOG.md | 1 + JHS.user.js | 186 ++++++++++++++++++++----------- scripts/checkjs-coverage.mjs | 2 +- src/plugins/new-video/task.js | 127 +++++++++++++++------ src/plugins/status/list-page.js | 192 ++++++++++++++++++++------------ tsconfig.architecture.json | 2 + 6 files changed, 339 insertions(+), 171 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c564c4b6..b8e25013 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - History 真实运行时进入 strict checkJs,明确仓储记录、跨页选择、Tabulator 回调与状态编辑边界;生产源码类型覆盖率提升至 92.1%,最低覆盖门禁同步上调至 92%。 - List 快速筛选规则迁入独立 Feature owner,List 按钮、MobileBottomBar 与 Settings 表单不再依赖页面插件实现并进入 strict checkJs;生产源码类型覆盖率提升至 93.8%,最低覆盖门禁同步上调至 93.5%。 - Blacklist 真实运行时进入 strict checkJs,明确任务状态订阅、筛选记录、Tabulator 回调和跨页批量状态写入边界;生产源码类型覆盖率提升至 94.4%,最低覆盖门禁同步上调至 94%。 +- TaskPlugin 与 ListPagePlugin 真实运行时进入 strict checkJs,明确调度状态/结果、配置刷新、网络分页、列表增量索引、筛选视图和媒体导航边界;生产源码类型覆盖率提升至 95.5%,RC 最低覆盖门禁上调至 95%。 - 新增可单测的 Bootstrap Purity 架构门禁,禁止除 Composition Root 外的生产模块在导入阶段启动 DOM、网络、Storage、监听器、Observer 或定时器;原隐藏导航样式改为 Bootstrap 注入时才读取地址。 - strict checkJs 继续覆盖自动翻页、设置样式/模板和 123AV-FC2 入口,并补齐分页空值、DOM 容器与 Integration 返回数据的显式契约。 - 截图、宿主详情工作区、兼容增强与统一离线模块进入 strict checkJs;Provider availability、宿主资源边界、Observer 和 jQuery 兼容句柄均补充显式类型,覆盖率门禁同步上调至 84%。 diff --git a/JHS.user.js b/JHS.user.js index 2854aea1..ee6e2711 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -13111,7 +13111,31 @@ ${failure.stack}` : ""); // src/plugins/new-video/task.js var _TaskPlugin = class _TaskPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue()), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), i(this, "taskTimer", null), i(this, "taskRunning", false), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "lifecycleScope", null), i(this, "settingsHandler", null), i(this, "taskConfigDirty", false), i(this, "configLoadPromise", null), i(this, "configLoadQueued", false), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", false), i(this, "activeTasks", /* @__PURE__ */ new Set()); + super(...arguments); + this.singleTaskKey = "checkNewActressActorFilterCar"; + this.taskConfig = null; + this.storageQueue = new StorageQueue(); + this.lastCheckFavoriteActressTimeKey = "jhs_time_checkFavoriteActress"; + this.lastCheckBlacklistTimeKey = "jhs_time_checkBlacklist"; + this.lastCheckNewVideoTimeKey = "jhs_time_checkNewVideo"; + this.lastCheckFavoriteActressAttemptKey = "jhs_time_checkFavoriteActress_attempt"; + this.lastCheckFavoriteActressNextKey = "jhs_time_checkFavoriteActress_next"; + this.lastCheckBlacklistAttemptKey = "jhs_time_checkBlacklist_attempt"; + this.lastCheckBlacklistNextKey = "jhs_time_checkBlacklist_next"; + this.lastCheckNewVideoAttemptKey = "jhs_time_checkNewVideo_attempt"; + this.lastCheckNewVideoNextKey = "jhs_time_checkNewVideo_next"; + this.taskTimer = null; + this.taskRunning = false; + this.visibilityHandler = null; + this.pageHideHandler = null; + this.lifecycleScope = null; + this.settingsHandler = null; + this.taskConfigDirty = false; + this.configLoadPromise = null; + this.configLoadQueued = false; + this.configRefreshPromise = null; + this.configRefreshQueued = false; + this.activeTasks = /* @__PURE__ */ new Set(); } getName() { return "TaskPlugin"; @@ -13135,7 +13159,7 @@ ${failure.stack}` : ""); o3 > 0 && (clog.debug(`剩余任务数: ${o3}`), await utils.sleep(n2)); } })); - const l2 = await Promise.allSettled(r2), c2 = l2.find(((e3) => "rejected" === e3.status)); + const l2 = await Promise.allSettled(r2), c2 = l2.find((e3) => "rejected" === e3.status); if (c2) throw c2.reason; } isNetworkBlocked(e2) { @@ -13161,8 +13185,9 @@ ${failure.stack}` : ""); if (typeof response.data !== "string") throw new TypeError("宿主页面响应不是 HTML 文本"); return response.data; } catch (error) { - error._taskNetwork = true; - throw error; + const taskError = error; + taskError._taskNetwork = true; + throw taskError; } } createTaskResult(extra = {}) { @@ -13178,7 +13203,7 @@ ${failure.stack}` : ""); } isUnnecessaryCheck(e2, t2) { if (!t2) throw new Error("未传入checkIntervalTime"); - t2 = parseInt(t2); + t2 = parseInt(String(t2)); return utils.getHourDifference(new Date(e2), /* @__PURE__ */ new Date()) < t2; } getTaskSchedule(name) { @@ -13461,13 +13486,15 @@ ${failure.stack}` : ""); this.beginTaskAttempt("favoriteActress"), result.attempted = true; return this.withActiveTask("favoriteActress", async () => { try { - const actors = [], sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); + const actors = []; + const sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); actors.length > 0 && await storageManager.addFavoriteActressList(actors), result.success = actors.length, result.actorCount = actors.length, result.pages = sync.pages; await this.finalizeTask("favoriteActress", true), result.completed = true, clog.log("所有演员信息已收集, 总计数量:", actors.length), $("#checkNewVideoMsg").text(`完整同步完成:演员 ${actors.length},页面 ${sync.pages}`); actors.length > 0 && await this.emitNewVideoChanged("favorite-actress-sync"); return result; } catch (error) { - result.completed = false, error?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(error), await this.finalizeTask("favoriteActress", false), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", error); + const taskError = error; + result.completed = false, taskError?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(taskError), await this.finalizeTask("favoriteActress", false), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", taskError); if (result.fatal) throw error; return result; } @@ -13475,7 +13502,8 @@ ${failure.stack}` : ""); } async scrapeActorInfo(startUrl, target = []) { const expected = new URL("/users/collection_actors", this.javDbUrl), visitedUrls = /* @__PURE__ */ new Set(); - let currentUrl = new URL(startUrl, this.javDbUrl), pages = 0; + let currentUrl = new URL(startUrl, this.javDbUrl); + let pages = 0; for (; currentUrl; ) { try { if (currentUrl.origin !== expected.origin || currentUrl.pathname !== expected.pathname) throw new Error(`收藏演员分页地址越界: ${currentUrl.href}`); @@ -13488,9 +13516,10 @@ ${failure.stack}` : ""); target.push(...parsed.actors), currentUrl = parsed.nextUrl ? new URL(parsed.nextUrl, currentUrl.href) : null; } catch (error) { const requestCodes = ["NETWORK_ERROR", "TIMEOUT", "AUTH_REQUIRED", "RATE_LIMITED", "CF_BLOCKED", "CIRCUIT_OPEN", "ABORTED"]; - requestCodes.includes(error?.code) && (error._taskNetwork = true); - error._taskNetwork || Object.prototype.hasOwnProperty.call(error, "_taskParse") || (error._taskParse = true); - throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, error), error; + const taskError = error; + requestCodes.includes(taskError?.code) && (taskError._taskNetwork = true); + taskError._taskNetwork || Object.prototype.hasOwnProperty.call(taskError, "_taskParse") || (taskError._taskParse = true); + throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, taskError), taskError; } } return { actors: target, pages }; @@ -13518,7 +13547,7 @@ ${failure.stack}` : ""); } clog.log(`检测最新作品, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); if (eligible.length > 0) { - const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map(((item) => item.carNum))); + const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map((item) => item.carNum)); try { await this.limitConcurrency(eligible, concurrency, sleep, (async (actress) => { const { name, starId } = actress, url = `${this.javDbUrl}/actors/${starId}?t=d`; @@ -13577,7 +13606,7 @@ ${failure.stack}` : ""); const e3 = $(m2), { carNum: s3, url: o3, title: r2, publishTime: l2 } = readListItem(e3); l2 && publishTimes.push(l2); if (!s3) continue; - a2.find(((e4) => r2.includes(e4) || s3.includes(e4))) || (i2.has(s3) || (() => { + a2.find((e4) => r2.includes(e4) || s3.includes(e4)) || (i2.has(s3) || (() => { let coverUrl = e3.find("img").attr("src") || ""; if (coverUrl && !coverUrl.startsWith("http")) { coverUrl = coverUrl.startsWith("/") ? this.javDbUrl + coverUrl : this.javDbUrl + "/" + coverUrl; @@ -13599,7 +13628,7 @@ ${failure.stack}` : ""); c2.push({ carNum: s3, coverUrl, title: r2 || "", publishTime: l2 || "", score, voteCount, url }); })()); } - const d2 = selectLatestPublishTime(publishTimes), h2 = await storageManager.getCarMap(), p2 = c2.filter(((e3) => !h2.has(e3.carNum))); + const d2 = selectLatestPublishTime(publishTimes), h2 = await storageManager.getCarMap(), p2 = c2.filter((e3) => !h2.has(e3.carNum)); p2.length > 0 && clog.log(`检测出新作品, ${n2}, 共${p2.length}部`), await storageManager.updateFavoriteActress({ starId: t2, lastCheckTime: utils.getNowStr(), @@ -13610,10 +13639,10 @@ ${failure.stack}` : ""); } async parseActorMovies(items, starId, name, titleKeywords, blacklistSet) { if (!items.length) return await storageManager.updateFavoriteActress({ starId, lastCheckTime: utils.getNowStr(), newVideoList: [] }), 0; - const publishTimes = items.map(((item) => item.publishTime)).filter(Boolean), candidates = items.filter(((item) => { + const publishTimes = items.map((item) => item.publishTime).filter(Boolean), candidates = items.filter((item) => { if (!item.carNum || blacklistSet.has(item.carNum)) return false; - return !titleKeywords.some(((keyword) => item.title?.includes(keyword) || item.carNum.includes(keyword))); - })).map(((item) => ({ + return !titleKeywords.some((keyword) => item.title?.includes(keyword) || item.carNum.includes(keyword)); + }).map((item) => ({ carNum: item.carNum, coverUrl: item.coverUrl || "", title: item.title || "", @@ -13621,15 +13650,15 @@ ${failure.stack}` : ""); score: Number(item.score) || 0, voteCount: Number(item.voteCount) || 0, url: item.url || "" - }))); - const latestPublishTime = selectLatestPublishTime(publishTimes), carMap = await storageManager.getCarMap(), fresh = candidates.filter(((item) => !carMap.has(item.carNum))); + })); + const latestPublishTime = selectLatestPublishTime(publishTimes), carMap = await storageManager.getCarMap(), fresh = candidates.filter((item) => !carMap.has(item.carNum)); fresh.length > 0 && clog.log(`检测出新作品, ${name}, 共${fresh.length}部`); await storageManager.updateFavoriteActress({ starId, lastCheckTime: utils.getNowStr(), newVideoList: fresh, lastPublishTime: latestPublishTime }); return fresh.length; } async checkOneNewVideo(e2) { await this.ensureReady(); - const t2 = await storageManager.getTitleFilterKeyword(), n2 = await storageManager.getBlacklistCarList(), a2 = new Set(n2.map(((e3) => e3.carNum))), { lastCheckTime: i2, name: s2, starId: o2 } = e2; + const t2 = await storageManager.getTitleFilterKeyword(), n2 = await storageManager.getBlacklistCarList(), a2 = new Set(n2.map((e3) => e3.carNum)), { lastCheckTime: i2, name: s2, starId: o2 } = e2; let r2 = `${this.javDbUrl}/actors/${o2}?t=d`; const l2 = $("#checkNewVideoMsg"); try { @@ -15691,6 +15720,11 @@ ${failure.stack}` : ""); var ListPageButtonPlugin = _ListPageButtonPlugin; // src/plugins/status/list-page.js + function getListEventBus() { + if (!jhsEventBus) throw new Error("List EventBus 未初始化"); + return jhsEventBus; + } + __name(getListEventBus, "getListEventBus"); var Te = { IS_FILTERED: { text: u, @@ -15762,7 +15796,24 @@ ${failure.stack}` : ""); return ``; } constructor() { - super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), i(this, "currentPageTotalCount", 0), i(this, "filterContext", null), i(this, "pendingItems", /* @__PURE__ */ new Set()), i(this, "processTimer", null), i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), i(this, "itemIndex", /* @__PURE__ */ new Map()), i(this, "recountFrame", null); + super(...arguments); + this.currentPageFilterCount = 0; + this.currentPageFavoriteCount = 0; + this.currentPageHasDownCount = 0; + this.currentPageHasWatchCount = 0; + this.currentPageKeywordFilterCount = 0; + this.currentPageActorFilterCount = 0; + this.currentPageWaitCheckCount = 0; + this.currentPageTotalCount = 0; + this.filterContext = null; + this.pendingItems = /* @__PURE__ */ new Set(); + this.processTimer = null; + this.hdImageObserver = null; + this.hdEagerRemaining = 12; + this.writeQueue = Promise.resolve(); + this.itemIndex = /* @__PURE__ */ new Map(); + this.recountFrame = null; + this.$currentImage = null; } getName() { return "ListPagePlugin"; @@ -15775,13 +15826,13 @@ ${failure.stack}` : ""); const e2 = this.getDependency("HistoryPlugin"); e2.tableObj && e2.tableObj.setData(); }, "refreshAll"); - ["legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed"].forEach(((type) => scope.addCleanup(jhsEventBus.on(type, refreshAll)))), scope.addCleanup(jhsEventBus.on("car-state-changed", (async (payload) => { + ["legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed"].forEach(((type) => scope.addCleanup(getListEventBus().on(type, refreshAll)))), scope.addCleanup(getListEventBus().on("car-state-changed", (async (payload) => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); const items = this.getIndexedItems(payload.carNums || []); items.length && (await this.doFilterItems(items), this.applyVisibility(items)); const history = this.getDependency("HistoryPlugin"); history.tableObj && history.tableObj.setData(); - }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(scope), scope.addCleanup((() => { + }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await getListEventBus().emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(scope), scope.addCleanup((() => { this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); this.hdImageObserver?.disconnect(), this.hdImageObserver = null; this.recountFrame && (globalThis.cancelAnimationFrame?.(this.recountFrame) ?? clearTimeout(this.recountFrame)), this.recountFrame = null; @@ -15827,8 +15878,8 @@ ${failure.stack}` : ""); } applyVisibility(items = null) { const e2 = this.activeQuickFilter || "waitCheck", t2 = this.getSelector().itemSelector; - (items ? $(items) : $(t2)).each((function() { - const t3 = $(this), flags = normalizeStateFlags(JSON.parse(t3.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t3.attr("data-jhs-visibility") || "{}"), recent = "yes" === t3.attr("data-jhs-recent"); + (items ? $(items) : $(t2)).each(((index, element) => { + const t3 = $(element), flags = normalizeStateFlags(JSON.parse(t3.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t3.attr("data-jhs-visibility") || "{}"), recent = "yes" === t3.attr("data-jhs-recent"); shouldShowItem({ filter: e2, flags, visibilityReasons, recent }) ? t3.show() : t3.hide(); })); } @@ -15863,13 +15914,14 @@ ${failure.stack}` : ""); this.removeIndexedItems(record.removedNodes); for (const node of record.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; - node.matches?.(e2.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), node.querySelectorAll?.(e2.itemSelector).forEach(((item) => { + const element = node; + element.matches?.(e2.itemSelector) && "true" !== element.dataset.jhsProcessed && this.pendingItems.add(element), element.querySelectorAll?.(e2.itemSelector).forEach((item) => { "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); - })); + }); } } this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { - const items = [...this.pendingItems].filter(((item) => item.isConnected && "true" !== item.dataset.jhsProcessed)); + const items = [...this.pendingItems].filter((item) => item.isConnected && "true" !== item.dataset.jhsProcessed); this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch(((error) => clog.error("列表增量处理失败", error))); }), 100)); }), { @@ -15878,14 +15930,14 @@ ${failure.stack}` : ""); }); } async processAddedItems(items) { - const selector = this.getSelector(), covers = items.flatMap(((item) => [...item.querySelectorAll(selector.coverImgSelector)])); - this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), items.forEach(((item) => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: false }), this.getDependency("AutoPagePlugin").checkLoad(); + const selector = this.getSelector(), covers = items.flatMap((item) => [...item.querySelectorAll(selector.coverImgSelector)]); + this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), items.forEach((item) => item.dataset.jhsProcessed = "true"), this.indexItems(items), await getListEventBus().emit("list-items-added", { items }, { broadcast: false }), this.getDependency("AutoPagePlugin").checkLoad(); } rebuildItemIndex() { this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); } indexItems(items) { - items.forEach(((item) => { + items.forEach((item) => { try { const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); if (!key) return; @@ -15894,44 +15946,46 @@ ${failure.stack}` : ""); } catch (error) { clog.debug("列表项索引跳过无效卡片", error); } - })); + }); } removeIndexedItems(nodes) { const removed = /* @__PURE__ */ new Set(); - Array.from(nodes || []).forEach(((node) => { - node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach(((item) => removed.add(item)))); - })); + Array.from(nodes || []).forEach((node) => { + const element = node; + node.nodeType === Node.ELEMENT_NODE && (removed.add(element), element.querySelectorAll?.(this.getSelector().itemSelector).forEach((item) => removed.add(item))); + }); if (!removed.size) return; this.itemIndex.forEach(((items, key) => { - items.forEach(((item) => { + items.forEach((item) => { removed.has(item) && items.delete(item); - })), items.size || this.itemIndex.delete(key); + }), items.size || this.itemIndex.delete(key); })); } getIndexedItems(carNums) { const result = /* @__PURE__ */ new Set(); - carNums.map(normalizeCarNum).forEach(((key) => { + carNums.map(normalizeCarNum).forEach((key) => { + if (!key) return; const items = this.itemIndex.get(key); - items?.forEach(((item) => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); - })); + items?.forEach((item) => item.isConnected ? result.add(item) : items.delete(item)), items && !items.size && this.itemIndex.delete(key); + }); return [...result]; } fixBusTitleBox(items = null) { if (!l) return; - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((e2) => { + (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach((e2) => { var t2; let n2 = $(e2); if (n2.find(".avatar-box").length > 0) return; const a2 = (null == (t2 = n2.find("img").attr("title")) ? void 0 : t2.trim()) || ""; n2.find(".photo-info span:first").contents().first().wrap(`${a2}`), n2.find("br").remove(); - })); + }); } cleanRepeatId() { if (!l) return; $("#waterfall_h").removeAttr("id").attr("id", "no-page"); const e2 = $('[id="waterfall"]'); - 0 !== e2.length && e2.each((function() { - const e3 = $(this); + 0 !== e2.length && e2.each(((index, element) => { + const e3 = $(element); if (!e3.hasClass("masonry")) { e3.children().insertAfter(e3), e3.remove(); } @@ -15944,13 +15998,13 @@ ${failure.stack}` : ""); if (!window.isListPage) return; let e2 = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); e2.length && (await this.filterMovieList(e2), l && setTimeout((() => { - this.getDependency("BusImgPlugin").logImageHeightsByRow().catch(((e3) => clog.error("JavBus图片高度修正失败", e3))); + this.getDependency("BusImgPlugin").logImageHeightsByRow().catch((e3) => clog.error("JavBus图片高度修正失败", e3)); }))); } async yieldListFrame() { - await new Promise(((e2) => { + await new Promise((e2) => { window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e2))) : setTimeout(e2); - })); + }); } findMatchedTitleKeyword(e2, t2, n2) { for (const a2 of e2) if (t2.includes(a2) || n2.startsWith(a2)) return a2; @@ -15960,7 +16014,9 @@ ${failure.stack}` : ""); if (this.filterContext) return this.filterContext; const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), this.getRuntimeService("state").getActivityLog()]), actorCarNumToNameMap = /* @__PURE__ */ new Map(), actressCarNumToNameMap = /* @__PURE__ */ new Map(), recentCarNums = /* @__PURE__ */ new Set(); const cutoff = Date.now() - 7 * 864e5; - activity.entries.filter(((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach(((entry) => entry.changes.filter(((change) => "reverted" !== change.undoState && change.fields?.some(((field) => field.startsWith("stateFlags."))))).forEach(((change) => recentCarNums.add(change.carNum))))); + activity.entries.filter((entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff).forEach((entry) => { + entry.changes.filter((change) => "reverted" !== change.undoState && change.fields?.some((field) => field.startsWith("stateFlags."))).forEach((change) => recentCarNums.add(change.carNum)); + }); for (const item of blacklistCars) { const role = blacklistMap.get(item.starId)?.role; if (!role) { @@ -16012,7 +16068,7 @@ ${failure.stack}` : ""); const { titleKeywords: n2, settings: s2, carMap: m2, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v2 } = await this.getFilterContext(), o2 = utils.time("读取数据耗时"); utils.time("组装数据耗时"); const b2 = utils.time("组装数据耗时"), P2 = (null == s2 ? void 0 : s2.tagPosition) || "rightTop"; - const O2 = n2.filter(((e3) => e3)); + const O2 = n2.filter((e3) => e3); this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); const R = []; for (let n3 = 0; n3 < e2.length; n3++) { @@ -16033,7 +16089,7 @@ ${failure.stack}` : ""); [visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知"], [visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a2) || ""], [visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v2.get(a2) || ""] - ].filter(((item) => item[0])); + ].filter((item) => item[0]); if (badgeDefs.length) { const box = $(``); badgeDefs.forEach((([, definition, tip]) => { @@ -16049,7 +16105,7 @@ ${failure.stack}` : ""); } hardHidden || R.push(t2); } - this.scheduleRecount(), void this.translateListItems(R).catch(((e3) => clog.error("列表页翻译任务失败", e3))); + this.scheduleRecount(), void this.translateListItems(R).catch((e3) => clog.error("列表页翻译任务失败", e3)); const D2 = utils.time("处理页面耗时"), A2 = utils.time("累计耗费时间"); clog.log(` @@ -16168,7 +16224,7 @@ ${failure.stack}` : ""); } } showCarNumBox(e2) { - const t2 = this.getRuntimeService("host").locateListItems().find(((t3) => $(t3).find(".video-title strong").text() === e2)); + const t2 = this.getRuntimeService("host").locateListItems().find((t3) => $(t3).find(".video-title strong").text() === e2); if (t2) { const n2 = $(t2); n2.attr("data-hide") === "yes" && (n2.show(), n2.removeAttr("data-hide")); @@ -16197,43 +16253,45 @@ ${failure.stack}` : ""); } replaceHdImg(e2) { if (e2 && "string" == typeof e2.jquery && (e2 = e2.toArray()), e2 || (e2 = document.querySelectorAll(this.getSelector().coverImgSelector)), !e2.length) return; - const t2 = Array.from(e2).filter(((e3) => "true" !== e3.dataset.hdReplaced && "true" !== e3.dataset.jhsHdObserved)); + const t2 = Array.from(e2).filter((e3) => "true" !== e3.dataset.hdReplaced && "true" !== e3.dataset.jhsHdObserved); if ("IntersectionObserver" in window && !this.hdImageObserver) this.hdImageObserver = new IntersectionObserver(((entries) => { entries.forEach(((entry) => { - entry.isIntersecting && (this.hdImageObserver.unobserve(entry.target), delete entry.target.dataset.jhsHdObserved, this._replaceSingleHdImg(entry.target)); + const image = entry.target; + entry.isIntersecting && (this.hdImageObserver?.unobserve(image), delete image.dataset.jhsHdObserved, this._replaceSingleHdImg(image)); })); }), { rootMargin: "200px" }); for (const image of t2) this.hdEagerRemaining > 0 ? (this.hdEagerRemaining--, this._replaceSingleHdImg(image)) : this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", this.hdImageObserver.observe(image)) : this._replaceSingleHdImg(image); - storageManager.getSetting("hoverBigImg", C).then(((e3) => { - e3 === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ + storageManager.getSetting("hoverBigImg", C).then((e3) => { + const runtimeWindow = window; + e3 === _ && (runtimeWindow.imageHoverPreviewObj ? runtimeWindow.imageHoverPreviewObj.bindEvents() : runtimeWindow.imageHoverPreviewObj = new ImageHoverPreview({ selector: this.getSelector().coverImgSelector })); - })); + }); } applyTranslatedTitle(e2, t2, n2) { const a2 = e2.find(".video-title"); - r ? (a2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n2) || (this.textContent = " " + t2 + " "); + r ? (a2.contents().each(((index, node) => { + 3 !== node.nodeType || "" === (node.textContent || "").trim() || (node.textContent || "").includes(n2) || (node.textContent = " " + t2 + " "); })), a2.attr("title", t2)) : a2.text(t2), e2.attr("data-jhs-translation-key", n2); } async translate(e2) { let t2, n2, a2 = e2.find(".video-title"); - if (r ? (t2 = a2.contents().filter(((e3, t3) => 3 === t3.nodeType && "" !== t3.textContent.trim())).text().trim(), n2 = e2.find(".video-title strong").text().trim()) : (t2 = (e2.find("img").attr("data-title") || "").trim(), n2 = (e2.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t2 || !n2) return; + if (r ? (t2 = a2.contents().filter(((e3, t3) => 3 === t3.nodeType && "" !== (t3.textContent || "").trim())).text().trim(), n2 = e2.find(".video-title strong").text().trim()) : (t2 = (e2.find("img").attr("data-title") || "").trim(), n2 = (e2.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t2 || !n2) return; const scope = await this.getRuntimeService("scope")(); const translated = await this.getRuntimeService("translation").translate(t2, { scope }); this.applyTranslatedTitle(e2, translated, n2); } async revertTranslation() { - $(this.getSelector().itemSelector).toArray().forEach(((e2) => { + $(this.getSelector().itemSelector).toArray().forEach((e2) => { let t2 = $(e2); const n2 = t2.find(".box").attr("title") || t2.find(".video-title").attr("title") || t2.find("img").attr("data-title"); let a2; r && (a2 = t2.find(".video-title strong").text().trim()); const i2 = t2.find(".video-title"); - i2.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a2) || (this.textContent = " " + n2 + " "); + i2.contents().each(((index, node) => { + 3 !== node.nodeType || "" === (node.textContent || "").trim() || (node.textContent || "").includes(a2 || "") || (node.textContent = " " + n2 + " "); })), i2.removeAttr("title"); - })); + }); } addJumpPageControl() { const e2 = "gemini-jump-page-control"; diff --git a/scripts/checkjs-coverage.mjs b/scripts/checkjs-coverage.mjs index 5618466c..30cf5a20 100644 --- a/scripts/checkjs-coverage.mjs +++ b/scripts/checkjs-coverage.mjs @@ -27,7 +27,7 @@ for (const file of files) { if (compiledFiles.has(path.resolve(file)) && /^\s*\/\/\s*@ts-check\b/m.test(source)) checked.push(file); } const coverage = files.length === 0 ? 100 : (checked.length / files.length) * 100; -const required = Number(process.env.JHS_CHECKJS_MIN ?? 94); +const required = Number(process.env.JHS_CHECKJS_MIN ?? 95); if (coverage + Number.EPSILON < required) { throw new Error(`checkJs coverage ${coverage.toFixed(1)}% is below ${required}%`); } diff --git a/src/plugins/new-video/task.js b/src/plugins/new-video/task.js index 267a8975..9f664206 100644 --- a/src/plugins/new-video/task.js +++ b/src/plugins/new-video/task.js @@ -1,4 +1,6 @@ -import { I, T, _, i, l, normalizeCarNum } from "../../core/constants.js"; +// @ts-check + +import { I, T, _, l, normalizeCarNum } from "../../core/constants.js"; import { parseNumberSetting, parseTaskTimestamp, selectLatestPublishTime, shouldSkipStopped } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { readListItem } from "../../core/list-item-reader.js"; @@ -6,17 +8,37 @@ import { detectSite } from "../../core/site-context.js"; import { parseDetailPage } from "../../integrations/host-list/parser.js"; import { StorageQueue } from "../external-search/other-site.js"; +/** @typedef {"blacklist" | "favoriteActress" | "newVideo"} TaskName */ +/** @typedef {Record} TaskRecord */ +/** @typedef {{ attempted: boolean, completed: boolean, fatal: boolean, success: number, networkFailed: number, parseFailed: number, aborted: number, skippedInterval: number, skippedStopped: number, [key: string]: any }} TaskResult */ + export class TaskPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "singleTaskKey", "checkNewActressActorFilterCar"), - i(this, "taskConfig", null), i(this, "storageQueue", new StorageQueue), i(this, "lastCheckFavoriteActressTimeKey", "jhs_time_checkFavoriteActress"), - i(this, "lastCheckBlacklistTimeKey", "jhs_time_checkBlacklist"), i(this, "lastCheckNewVideoTimeKey", "jhs_time_checkNewVideo"), - i(this, "lastCheckFavoriteActressAttemptKey", "jhs_time_checkFavoriteActress_attempt"), i(this, "lastCheckFavoriteActressNextKey", "jhs_time_checkFavoriteActress_next"), - i(this, "lastCheckBlacklistAttemptKey", "jhs_time_checkBlacklist_attempt"), i(this, "lastCheckBlacklistNextKey", "jhs_time_checkBlacklist_next"), - i(this, "lastCheckNewVideoAttemptKey", "jhs_time_checkNewVideo_attempt"), i(this, "lastCheckNewVideoNextKey", "jhs_time_checkNewVideo_next"), - i(this, "taskTimer", null), i(this, "taskRunning", !1), i(this, "visibilityHandler", null), i(this, "pageHideHandler", null), i(this, "lifecycleScope", null), - i(this, "settingsHandler", null), i(this, "taskConfigDirty", !1), i(this, "configLoadPromise", null), i(this, "configLoadQueued", !1), i(this, "configRefreshPromise", null), i(this, "configRefreshQueued", !1), - i(this, "activeTasks", new Set); + super(...arguments); + this.singleTaskKey = "checkNewActressActorFilterCar"; + /** @type {any} */ this.taskConfig = null; + this.storageQueue = new StorageQueue(); + this.lastCheckFavoriteActressTimeKey = "jhs_time_checkFavoriteActress"; + this.lastCheckBlacklistTimeKey = "jhs_time_checkBlacklist"; + this.lastCheckNewVideoTimeKey = "jhs_time_checkNewVideo"; + this.lastCheckFavoriteActressAttemptKey = "jhs_time_checkFavoriteActress_attempt"; + this.lastCheckFavoriteActressNextKey = "jhs_time_checkFavoriteActress_next"; + this.lastCheckBlacklistAttemptKey = "jhs_time_checkBlacklist_attempt"; + this.lastCheckBlacklistNextKey = "jhs_time_checkBlacklist_next"; + this.lastCheckNewVideoAttemptKey = "jhs_time_checkNewVideo_attempt"; + this.lastCheckNewVideoNextKey = "jhs_time_checkNewVideo_next"; + /** @type {ReturnType | null} */ this.taskTimer = null; + this.taskRunning = !1; + /** @type {null | (() => void)} */ this.visibilityHandler = null; + /** @type {null | (() => void)} */ this.pageHideHandler = null; + /** @type {any} */ this.lifecycleScope = null; + /** @type {null | (() => Promise)} */ this.settingsHandler = null; + this.taskConfigDirty = !1; + /** @type {Promise | null} */ this.configLoadPromise = null; + this.configLoadQueued = !1; + /** @type {Promise | null} */ this.configRefreshPromise = null; + this.configRefreshQueued = !1; + /** @type {Set} */ this.activeTasks = new Set(); } getName() { return "TaskPlugin"; @@ -24,6 +46,7 @@ export class TaskPlugin extends BasePlugin { getStartupMode() { return "idle"; } + /** @param {any[]} e @param {number} t @param {number} n @param {(item: any) => Promise} a */ async limitConcurrency(e, t, n, a) { let i = 0, s = !1; const o = Math.max(1, Math.min(t, e.length)), r = Array.from({ length: o }, (async () => { @@ -40,13 +63,15 @@ export class TaskPlugin extends BasePlugin { o > 0 && (clog.debug(`剩余任务数: ${o}`), await utils.sleep(n)); } })); - const l = await Promise.allSettled(r), c = l.find((e => "rejected" === e.status)); + const l = await Promise.allSettled(r), c = l.find((/** @type {PromiseSettledResult} */ e) => "rejected" === e.status); if (c) throw c.reason; } + /** @param {any} e */ isNetworkBlocked(e) { return [ "CF_BLOCKED", "CIRCUIT_OPEN", "ABORTED" ].includes(e?.code) || !0 === e?._cfBlocked || !0 === e?._circuitBroken; } /** 通过统一 HttpService 抓取当前配置宿主的 HTML。 */ + /** @param {string} url @param {string} site */ async requestHostPage(url, site) { const target = new URL(url), builtinHost = site === T ? "javdb.com" : site === I ? "javbus.com" : null; const isBuiltin = builtinHost && (target.hostname === builtinHost || target.hostname.endsWith(`.${builtinHost}`)); @@ -62,11 +87,13 @@ export class TaskPlugin extends BasePlugin { }, await this.getRuntimeService("scope")()); if (typeof response.data !== "string") throw new TypeError("宿主页面响应不是 HTML 文本"); return response.data; - } catch (error) { error._taskNetwork = true; throw error; } + } catch (error) { const taskError = /** @type {any} */ (error); taskError._taskNetwork = true; throw taskError; } } + /** @param {Record} [extra] @returns {TaskResult} */ createTaskResult(extra = {}) { return { attempted: !1, completed: !1, fatal: !1, success: 0, networkFailed: 0, parseFailed: 0, aborted: 0, skippedInterval: 0, skippedStopped: 0, ...extra }; } + /** @param {string} name @param {() => Promise} runner */ async runBackgroundTask(name, runner) { try { return await runner(); @@ -75,12 +102,15 @@ export class TaskPlugin extends BasePlugin { return clog.error(`${name}执行失败,继续后续后台任务`, error), this.createTaskResult({ attempted: !0, error }); } } + /** @param {string | number | Date} e @param {string | number} t */ isUnnecessaryCheck(e, t) { if (!t) throw new Error("未传入checkIntervalTime"); - t = parseInt(t); + t = parseInt(String(t)); return utils.getHourDifference(new Date(e), new Date) < t; } + /** @param {TaskName} name */ getTaskSchedule(name) { + /** @type {Record} */ const schedules = { blacklist: { completedKey: this.lastCheckBlacklistTimeKey, attemptKey: this.lastCheckBlacklistAttemptKey, nextKey: this.lastCheckBlacklistNextKey, intervalSetting: "checkBlacklist_intervalTime", defaultInterval: 12 }, favoriteActress: { completedKey: this.lastCheckFavoriteActressTimeKey, attemptKey: this.lastCheckFavoriteActressAttemptKey, nextKey: this.lastCheckFavoriteActressNextKey, intervalSetting: "checkFavoriteActress_IntervalTime", defaultInterval: 24 }, @@ -89,11 +119,13 @@ export class TaskPlugin extends BasePlugin { if (!schedules[name]) throw new Error(`未知任务调度: ${name}`); return schedules[name]; } + /** @param {TaskName} name */ getTaskScheduleState(name) { const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)), attempt = parseTaskTimestamp(storage.getLocal(schedule.attemptKey)); return { ...schedule, completed, attempt, pending: null != attempt && (null == completed || attempt > completed) }; } /** 返回当前标签页可证明的任务状态,不写入调度数据。 */ + /** @param {TaskName} name */ getTaskStatusSnapshot(name) { const state = this.getTaskScheduleState(name), storedNext = parseTaskTimestamp(this.getRuntimeService("storage").getLocal(state.nextKey)); const nextAt = null == storedNext ? state.pending && null != state.attempt ? state.attempt + 3e5 : 0 : storedNext, now = Date.now(); @@ -103,20 +135,23 @@ export class TaskPlugin extends BasePlugin { else if (null == storedNext || now >= nextAt) status = "due"; return { name, state: status, completedAt: state.completed, attemptAt: state.attempt, nextAt, isPending: state.pending, pendingUntil: state.pending ? nextAt : null }; } + /** @param {string} name @param {string} phase */ async emitTaskStatus(name, phase) { try { - await globalThis.jhsEventBus?.emit?.("task-status-changed", { taskName: name, phase }, { broadcast: !1 }); + await (/** @type {any} */ (globalThis)).jhsEventBus?.emit?.("task-status-changed", { taskName: name, phase }, { broadcast: !1 }); } catch (error) { clog.error(`任务状态通知失败: ${name}/${phase}`, error); } } + /** @param {string} reason @param {unknown[]} [carNums] */ async emitNewVideoChanged(reason, carNums = []) { try { - await globalThis.jhsEventBus?.emit?.("new-video-changed", { reason, carNums: carNums.map(normalizeCarNum).filter(Boolean) }); + await (/** @type {any} */ (globalThis)).jhsEventBus?.emit?.("new-video-changed", { reason, carNums: carNums.map(normalizeCarNum).filter(Boolean) }); } catch (error) { clog.error("新作品状态通知失败", error); } } + /** @param {TaskName} name @param {() => Promise} runner */ async withActiveTask(name, runner) { this.activeTasks.add(name); try { @@ -126,11 +161,13 @@ export class TaskPlugin extends BasePlugin { this.activeTasks.delete(name), await this.emitTaskStatus(name, "finished"); } } + /** @param {TaskName} name */ async getLatestTaskInterval(name) { const schedule = this.getTaskSchedule(name); storageManager._invalidateCache?.(storageManager.setting_key); return parseNumberSetting(await storageManager.getSetting(schedule.intervalSetting, schedule.defaultInterval), schedule.defaultInterval, { min: Number.EPSILON }); } + /** @param {TaskName} name @param {boolean} [force] */ async shouldStartTask(name, force = !1) { if (force) return !0; const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), interval = await this.getLatestTaskInterval(name); @@ -143,12 +180,14 @@ export class TaskPlugin extends BasePlugin { } return Date.now() >= next; } + /** @param {TaskName} name */ beginTaskAttempt(name) { const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name), completed = parseTaskTimestamp(storage.getLocal(schedule.completedKey)); const now = Math.floor(Date.now() / 1e3) * 1e3, attempt = null == completed ? now : Math.max(now, completed + 1e3); storage.setLocal(schedule.attemptKey, String(attempt)), storage.setLocal(schedule.nextKey, String(attempt + 3e5)); return attempt; } + /** @param {TaskName} name @param {boolean} completed */ async finalizeTask(name, completed) { const storage = this.getRuntimeService("storage"), schedule = this.getTaskSchedule(name); if (!completed) return storage.setLocal(schedule.nextKey, String(Date.now() + 3e5)); @@ -157,7 +196,7 @@ export class TaskPlugin extends BasePlugin { } async recalculateSchedules() { await this.loadConfig(); - for (const [name, interval] of [ [ "blacklist", this.taskConfig.checkBlacklist_intervalTime ], [ "favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime ], [ "newVideo", this.taskConfig.checkNewVideo_intervalTime ] ]) { + for (const [name, interval] of /** @type {Array<[TaskName, number]>} */ ([ [ "blacklist", this.taskConfig.checkBlacklist_intervalTime ], [ "favoriteActress", this.taskConfig.checkFavoriteActress_IntervalTime ], [ "newVideo", this.taskConfig.checkNewVideo_intervalTime ] ])) { const state = this.getTaskScheduleState(name); if (state.pending) continue; this.getRuntimeService("storage").setLocal(state.nextKey, String(null == state.completed ? Date.now() : state.completed + 36e5 * interval)); @@ -188,12 +227,13 @@ export class TaskPlugin extends BasePlugin { } catch (error) { clog.error("任务设置刷新失败", error); } - }, this.lifecycleScope.addCleanup(globalThis.jhsEventBus?.on?.("settings-changed", this.settingsHandler) || (() => {}))); + }, this.lifecycleScope.addCleanup((/** @type {any} */ (globalThis)).jhsEventBus?.on?.("settings-changed", this.settingsHandler) || (() => {}))); return document.hidden ? void 0 : this.runAndSchedule(); } clearSchedule() { this.taskTimer && (clearTimeout(this.taskTimer), this.taskTimer = null); } + /** @param {number} [e] */ scheduleTask(e = 3e5) { if (!window.isListPage || document.hidden) return void this.clearSchedule(); this.clearSchedule(), this.taskTimer = setTimeout((() => { @@ -259,6 +299,7 @@ export class TaskPlugin extends BasePlugin { (!this.taskConfig || this.taskConfigDirty) && await this.loadConfig(), this.javDbUrl || (this.javDbUrl = await this.getDependency("OtherSitePlugin").getJavDbUrl()); if (!this.javDbUrl) throw new Error("JavDB 地址未配置"); } + /** @param {string} url */ async resolveBlacklistSite(url) { try { const target = new URL(url), otherSite = this.getDependency("OtherSitePlugin"), [ javDbUrl, javBusUrl ] = await Promise.all([ otherSite?.getJavDbUrl?.(), otherSite?.getJavBusUrl?.() ]); @@ -284,7 +325,7 @@ export class TaskPlugin extends BasePlugin { let blockedError = null, finalized = !1; try { let items = await storageManager.getBlacklist(); - items = items.sort(((left, right) => left.createTime < right.createTime ? 1 : left.createTime > right.createTime ? -1 : 0)); + items = items.sort(((/** @type {TaskRecord} */ left, /** @type {TaskRecord} */ right) => left.createTime < right.createTime ? 1 : left.createTime > right.createTime ? -1 : 0)); const concurrency = this.taskConfig.checkConcurrencyCount, sleep = this.taskConfig.checkRequestSleep, interval = this.taskConfig.checkBlacklist_intervalTime, rule = this.taskConfig.checkBlacklist_ruleTime; const eligible = [], currentHostname = new URL(window.location.href).hostname; for (const item of items) { @@ -309,7 +350,7 @@ export class TaskPlugin extends BasePlugin { clog.log(`检测屏蔽黑名单, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); const blacklistPlugin = this.getDependency("BlacklistPlugin"); try { - await this.limitConcurrency(eligible, concurrency, sleep, (async entry => { + await this.limitConcurrency(eligible, concurrency, sleep, (async (/** @type {TaskRecord} */ entry) => { const {item, site} = entry; const {starId, name, url} = item; let responseText; @@ -346,6 +387,7 @@ export class TaskPlugin extends BasePlugin { } }); } + /** @param {TaskResult} result @param {boolean} completed */ renderBlacklistResult(result, completed) { const retry = completed ? "" : ",5 分钟后补偿未完成项", message = `黑名单整批检测:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval},异站跳过 ${result.skippedHost}${result.aborted ? `,未执行 ${result.aborted}` : ""}${retry}`; $("#checkBlacklistMsg").text(message), clog.log(message); @@ -357,36 +399,43 @@ export class TaskPlugin extends BasePlugin { this.beginTaskAttempt("favoriteActress"), result.attempted = !0; return this.withActiveTask("favoriteActress", async () => { try { - const actors = [], sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); + /** @type {TaskRecord[]} */ + const actors = []; + const sync = await this.scrapeActorInfo(`${this.javDbUrl}/users/collection_actors`, actors); actors.length > 0 && await storageManager.addFavoriteActressList(actors), result.success = actors.length, result.actorCount = actors.length, result.pages = sync.pages; await this.finalizeTask("favoriteActress", !0), result.completed = !0, clog.log("所有演员信息已收集, 总计数量:", actors.length), $("#checkNewVideoMsg").text(`完整同步完成:演员 ${actors.length},页面 ${sync.pages}`); actors.length > 0 && await this.emitNewVideoChanged("favorite-actress-sync"); return result; } catch (error) { - result.completed = !1, error?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(error), await this.finalizeTask("favoriteActress", !1), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", error); + const taskError = /** @type {any} */ (error); + result.completed = !1, taskError?._taskNetwork ? result.networkFailed++ : result.parseFailed++, result.fatal = this.isNetworkBlocked(taskError), await this.finalizeTask("favoriteActress", !1), $("#checkNewVideoMsg").text("演员同步失败,5 分钟后重试整轮"), clog.error("同步收藏演员失败", taskError); if (result.fatal) throw error; return result; } }); } + /** @param {string} startUrl @param {TaskRecord[]} [target] */ async scrapeActorInfo(startUrl, target = []) { const expected = new URL("/users/collection_actors", this.javDbUrl), visitedUrls = new Set; - let currentUrl = new URL(startUrl, this.javDbUrl), pages = 0; + /** @type {URL | null} */ + let currentUrl = new URL(startUrl, this.javDbUrl); + let pages = 0; for (;currentUrl; ) { try { if (currentUrl.origin !== expected.origin || currentUrl.pathname !== expected.pathname) throw new Error(`收藏演员分页地址越界: ${currentUrl.href}`); if (visitedUrls.has(currentUrl.href)) throw new Error(`收藏演员分页循环: ${currentUrl.href}`); if (pages >= 200) throw new Error("收藏演员分页超过 200 页"); visitedUrls.add(currentUrl.href), pages++, clog.log(`正在抓取页面: ${currentUrl.href}`), $("#checkNewVideoMsg").text(`正在解析已收藏的演员: ${currentUrl.href}`); - const parsed = await this.getRuntimeService("actressInfo").collection("javdb", { baseUrl: this.javDbUrl, pageUrl: currentUrl.href }, { scope: await this.getRuntimeService("scope")() }); + const parsed = /** @type {any} */ (await this.getRuntimeService("actressInfo").collection("javdb", { baseUrl: this.javDbUrl, pageUrl: currentUrl.href }, { scope: await this.getRuntimeService("scope")() })); if ("valid" !== parsed.state) throw Object.assign(new Error(`收藏演员页面无效: ${parsed.state}`), { _taskParse: !0 }); if (parsed.isEmpty && parsed.nextUrl) throw Object.assign(new Error("收藏演员空页面包含下一页"), { _taskParse: !0 }); target.push(...parsed.actors), currentUrl = parsed.nextUrl ? new URL(parsed.nextUrl, currentUrl.href) : null; } catch (error) { const requestCodes = [ "NETWORK_ERROR", "TIMEOUT", "AUTH_REQUIRED", "RATE_LIMITED", "CF_BLOCKED", "CIRCUIT_OPEN", "ABORTED" ]; - requestCodes.includes(error?.code) && (error._taskNetwork = !0); - error._taskNetwork || Object.prototype.hasOwnProperty.call(error, "_taskParse") || (error._taskParse = !0); - throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, error), error; + const taskError = /** @type {any} */ (error); + requestCodes.includes(taskError?.code) && (taskError._taskNetwork = !0); + taskError._taskNetwork || Object.prototype.hasOwnProperty.call(taskError, "_taskParse") || (taskError._taskParse = !0); + throw clog.error(`抓取 ${currentUrl?.href || startUrl} 时发生错误,停止本轮同步:`, taskError), taskError; } } return { actors: target, pages }; @@ -400,7 +449,7 @@ export class TaskPlugin extends BasePlugin { let blockedError = null, finalized = !1; try { const actresses = await storageManager.getFavoriteActressList(), sorted = utils.genericSort(actresses, [ { - key: actress => actress.newVideoList?.length ?? 0, + key: (/** @type {TaskRecord} */ actress) => actress.newVideoList?.length ?? 0, order: "desc" }, { key: "lastPublishTime", @@ -414,9 +463,9 @@ export class TaskPlugin extends BasePlugin { } clog.log(`检测最新作品, 总任务数: ${eligible.length}, 并发限制:${concurrency}, 请求间隔时间:${sleep}ms`); if (eligible.length > 0) { - const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map((item => item.carNum))); + const titleKeywords = await storageManager.getTitleFilterKeyword(), blacklistCars = await storageManager.getBlacklistCarList(), blacklistSet = new Set(blacklistCars.map((/** @type {TaskRecord} */ item) => item.carNum)); try { - await this.limitConcurrency(eligible, concurrency, sleep, (async actress => { + await this.limitConcurrency(eligible, concurrency, sleep, (async (/** @type {TaskRecord} */ actress) => { const {name, starId} = actress, url = `${this.javDbUrl}/actors/${starId}?t=d`; try { clog.log("正在检测最新作品, 演员:", name, url), $("#checkNewVideoMsg").text(`正在检测最新作品, 演员: ${name}`); @@ -448,10 +497,12 @@ export class TaskPlugin extends BasePlugin { } }); } + /** @param {TaskResult} result @param {string} [prefix] */ renderCheckResult(result, prefix = "检测结束") { const message = `${prefix}:成功 ${result.success},解析/存储失败 ${result.parseFailed},网络失败 ${result.networkFailed},停更跳过 ${result.skippedStopped},间隔跳过 ${result.skippedInterval}${result.aborted ? `,未执行 ${result.aborted}` : ""}`; $("#checkNewVideoMsg").text(message), clog.log(message); } + /** @param {any} e @param {string} site @param {string} t @param {string} n @param {string[]} a @param {Set} i */ async parsePage(e, site, t, n, a, i) { const selector = this.getSelector(site); site === I && e.find(".avatar-box").length > 0 && e.find(".avatar-box").parent().remove(); @@ -473,7 +524,7 @@ export class TaskPlugin extends BasePlugin { const e = $(m), {carNum: s, url: o, title: r, publishTime: l} = readListItem(e); l && publishTimes.push(l); if (!s) continue; - a.find((e => r.includes(e) || s.includes(e))) || (i.has(s) || (() => { + a.find((/** @type {string} */ e) => r.includes(e) || s.includes(e)) || (i.has(s) || (() => { let coverUrl = e.find("img").attr("src") || ""; if (coverUrl && !coverUrl.startsWith("http")) { coverUrl = coverUrl.startsWith("/") ? this.javDbUrl + coverUrl : this.javDbUrl + "/" + coverUrl; @@ -495,7 +546,7 @@ export class TaskPlugin extends BasePlugin { c.push({ carNum: s, coverUrl: coverUrl, title: r || "", publishTime: l || "", score: score, voteCount: voteCount, url: url }); })()); } - const d = selectLatestPublishTime(publishTimes), h = await storageManager.getCarMap(), p = c.filter((e => !h.has(e.carNum))); + const d = selectLatestPublishTime(publishTimes), h = await storageManager.getCarMap(), p = c.filter((/** @type {TaskRecord} */ e) => !h.has(e.carNum)); p.length > 0 && clog.log(`检测出新作品, ${n}, 共${p.length}部`), await storageManager.updateFavoriteActress({ starId: t, @@ -505,23 +556,25 @@ export class TaskPlugin extends BasePlugin { }); return p.length; } + /** @param {TaskRecord[]} items @param {string} starId @param {string} name @param {string[]} titleKeywords @param {Set} blacklistSet */ async parseActorMovies(items, starId, name, titleKeywords, blacklistSet) { if (!items.length) return await storageManager.updateFavoriteActress({ starId, lastCheckTime: utils.getNowStr(), newVideoList: [] }), 0; - const publishTimes = items.map((item => item.publishTime)).filter(Boolean), candidates = items.filter((item => { + const publishTimes = items.map((/** @type {TaskRecord} */ item) => item.publishTime).filter(Boolean), candidates = items.filter((/** @type {TaskRecord} */ item) => { if (!item.carNum || blacklistSet.has(item.carNum)) return false; - return !titleKeywords.some((keyword => item.title?.includes(keyword) || item.carNum.includes(keyword))); - })).map((item => ({ + return !titleKeywords.some((/** @type {string} */ keyword) => item.title?.includes(keyword) || item.carNum.includes(keyword)); + }).map((/** @type {TaskRecord} */ item) => ({ carNum: item.carNum, coverUrl: item.coverUrl || "", title: item.title || "", publishTime: item.publishTime || "", score: Number(item.score) || 0, voteCount: Number(item.voteCount) || 0, url: item.url || "", - }))); - const latestPublishTime = selectLatestPublishTime(publishTimes), carMap = await storageManager.getCarMap(), fresh = candidates.filter((item => !carMap.has(item.carNum))); + })); + const latestPublishTime = selectLatestPublishTime(publishTimes), carMap = await storageManager.getCarMap(), fresh = candidates.filter((/** @type {TaskRecord} */ item) => !carMap.has(item.carNum)); fresh.length > 0 && clog.log(`检测出新作品, ${name}, 共${fresh.length}部`); await storageManager.updateFavoriteActress({ starId, lastCheckTime: utils.getNowStr(), newVideoList: fresh, lastPublishTime: latestPublishTime }); return fresh.length; } + /** @param {TaskRecord} e */ async checkOneNewVideo(e) { await this.ensureReady(); - const t = await storageManager.getTitleFilterKeyword(), n = await storageManager.getBlacklistCarList(), a = new Set(n.map((e => e.carNum))), {lastCheckTime: i, name: s, starId: o} = e; + const t = await storageManager.getTitleFilterKeyword(), n = await storageManager.getBlacklistCarList(), a = new Set(n.map((/** @type {TaskRecord} */ e) => e.carNum)), {lastCheckTime: i, name: s, starId: o} = e; let r = `${this.javDbUrl}/actors/${o}?t=d`; const l = $("#checkNewVideoMsg"); try { diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index 290254cf..f09258b1 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -1,4 +1,6 @@ -import { B, C, _, b, escapeHtml, i, k, l, normalizeCarNum, o, r, u, y } from "../../core/constants.js"; +// @ts-check + +import { B, C, _, b, escapeHtml, k, l, normalizeCarNum, o, r, u, y } from "../../core/constants.js"; import { jhsEventBus } from "../../core/event-bus.js"; import { mapLimit, safePlay } from "../../core/feature-helpers.js"; import { requestHostPage } from "../../core/host-page-request.js"; @@ -8,6 +10,14 @@ import { isHitShowPage } from "../../core/site-context.js"; import { hasAnyState, normalizeStateFlags } from "../../core/state-model.js"; import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, isHardHidden, matchesQuickFilter, normalizeQuickFilterKey, shouldShowItem } from "../../features/list/list-filters.js"; +/** @typedef {Record} ListRecord */ +/** @typedef {any} JQueryHandle */ +/** @returns {NonNullable} */ +function getListEventBus() { + if (!jhsEventBus) throw new Error("List EventBus 未初始化"); + return jhsEventBus; +} + const Te = { IS_FILTERED: { text: u, @@ -80,13 +90,24 @@ export class ListPagePlugin extends BasePlugin { return ``; } constructor() { - super(...arguments), i(this, "currentPageFilterCount", 0), i(this, "currentPageFavoriteCount", 0), - i(this, "currentPageHasDownCount", 0), i(this, "currentPageHasWatchCount", 0), i(this, "currentPageKeywordFilterCount", 0), - i(this, "currentPageActorFilterCount", 0), i(this, "currentPageWaitCheckCount", 0), - i(this, "currentPageTotalCount", 0), - i(this, "filterContext", null), i(this, "pendingItems", new Set), i(this, "processTimer", null), - i(this, "hdImageObserver", null), i(this, "hdEagerRemaining", 12), i(this, "writeQueue", Promise.resolve()), - i(this, "itemIndex", new Map), i(this, "recountFrame", null); + super(...arguments); + this.currentPageFilterCount = 0; + this.currentPageFavoriteCount = 0; + this.currentPageHasDownCount = 0; + this.currentPageHasWatchCount = 0; + this.currentPageKeywordFilterCount = 0; + this.currentPageActorFilterCount = 0; + this.currentPageWaitCheckCount = 0; + this.currentPageTotalCount = 0; + /** @type {any} */ this.filterContext = null; + /** @type {Set} */ this.pendingItems = new Set(); + /** @type {ReturnType | null} */ this.processTimer = null; + /** @type {IntersectionObserver | null} */ this.hdImageObserver = null; + this.hdEagerRemaining = 12; + this.writeQueue = Promise.resolve(); + /** @type {Map>} */ this.itemIndex = new Map(); + /** @type {number | null} */ this.recountFrame = null; + /** @type {any} */ this.$currentImage = null; } getName() { return "ListPagePlugin"; @@ -99,8 +120,8 @@ export class ListPagePlugin extends BasePlugin { const e = this.getDependency("HistoryPlugin"); e.tableObj && e.tableObj.setData(); }; - [ "legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed" ].forEach((type => scope.addCleanup(jhsEventBus.on(type, refreshAll)))), - scope.addCleanup(jhsEventBus.on("car-state-changed", (async payload => { + [ "legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed" ].forEach((type => scope.addCleanup(getListEventBus().on(type, refreshAll)))), + scope.addCleanup(getListEventBus().on("car-state-changed", (async payload => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); const items = this.getIndexedItems(payload.carNums || []); items.length && (await this.doFilterItems(items), this.applyVisibility(items)); @@ -109,7 +130,7 @@ export class ListPagePlugin extends BasePlugin { }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), - $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await jhsEventBus.emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: !1 }), + $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await getListEventBus().emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: !1 }), this.checkDom(scope), scope.addCleanup((() => { this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); this.hdImageObserver?.disconnect(), this.hdImageObserver = null; @@ -132,15 +153,15 @@ export class ListPagePlugin extends BasePlugin { const root = $("#jhs-quick-filter"), toggle = root.find(".jhs-quick-filter__toggle"), menu = root.find(".jhs-quick-filter__menu"), closeMenu = (restoreFocus = !1) => { menu.removeClass("is-open"), toggle.attr("aria-expanded", "false"), restoreFocus && toggle.trigger("focus"); }; - root.on("click", ".jhs-segmented__item", (event => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))) - .on("keydown", ".jhs-segmented__item", (event => { + root.on("click", ".jhs-segmented__item", ((/** @type {any} */ event) => this.setQuickFilter($(event.currentTarget).data("jhs-filter")))) + .on("keydown", ".jhs-segmented__item", ((/** @type {any} */ event) => { if (![ "ArrowLeft", "ArrowRight", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const tabs = root.find(".jhs-segmented__item"), index = tabs.index(event.currentTarget), next = "Home" === event.key ? 0 : "End" === event.key ? tabs.length - 1 : "ArrowRight" === event.key ? (index + 1) % tabs.length : (index - 1 + tabs.length) % tabs.length; tabs.eq(next).trigger("click").trigger("focus"); - })).on("click", ".jhs-filter-option", (event => { + })).on("click", ".jhs-filter-option", ((/** @type {any} */ event) => { this.setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(!0); - })).on("keydown", ".jhs-filter-option", (event => { + })).on("keydown", ".jhs-filter-option", ((/** @type {any} */ event) => { const items = menu.find(".jhs-filter-option"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), closeMenu(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; @@ -148,22 +169,24 @@ export class ListPagePlugin extends BasePlugin { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - toggle.on("click", (event => { + toggle.on("click", ((/** @type {any} */ event) => { event.preventDefault(), event.stopPropagation(); const open = !menu.hasClass("is-open"); menu.toggleClass("is-open", open), toggle.attr("aria-expanded", String(open)), open && (menu.find('[aria-checked="true"]').first().length ? menu.find('[aria-checked="true"]').first() : menu.find(".jhs-filter-option").first()).trigger("focus"); - })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", (event => { + })), $(document).off("click.jhsQuickFilter").on("click.jhsQuickFilter", ((/** @type {any} */ event) => { $(event.target).closest(root).length || closeMenu(); })); this.setQuickFilter(await storageManager.getSetting("defaultQuickFilterTab", "waitCheck")); } + /** @param {Element[] | null} [items] */ applyVisibility(items = null) { const e = this.activeQuickFilter || "waitCheck", t = this.getSelector().itemSelector; - (items ? $(items) : $(t)).each((function() { - const t = $(this), flags = normalizeStateFlags(JSON.parse(t.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t.attr("data-jhs-visibility") || "{}"), recent = "yes" === t.attr("data-jhs-recent"); + (items ? $(items) : $(t)).each(((/** @type {number} */ index, /** @type {Element} */ element) => { + const t = $(element), flags = normalizeStateFlags(JSON.parse(t.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(t.attr("data-jhs-visibility") || "{}"), recent = "yes" === t.attr("data-jhs-recent"); shouldShowItem({ filter: e, flags, visibilityReasons, recent }) ? t.show() : t.hide(); })); } + /** @param {unknown} filter @param {{ syncUi?: boolean }} [options] */ setQuickFilter(filter, { syncUi = !0 } = {}) { this.activeQuickFilter = normalizeQuickFilterKey(filter), this.applyVisibility(), syncUi && this.syncQuickFilterUi(); } @@ -185,24 +208,26 @@ export class ListPagePlugin extends BasePlugin { clog.debug("触发"), storage.setLocal(t, e.toString()); })); } + /** @param {any} [scope] */ checkDom(scope = null) { if (!window.isListPage || isHitShowPage()) return; const e = this.getSelector(), t = document.querySelector(e.boxSelector); if (!t) return void clog.error("没有找到容器节点!"); if (!scope) return; - scope.observe(t, (records => { + scope.observe(t, ((/** @type {MutationRecord[]} */ records) => { for (const record of records) { this.removeIndexedItems(record.removedNodes); for (const node of record.addedNodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; - node.matches?.(e.itemSelector) && "true" !== node.dataset.jhsProcessed && this.pendingItems.add(node), - node.querySelectorAll?.(e.itemSelector).forEach((item => { - "true" !== item.dataset.jhsProcessed && this.pendingItems.add(item); - })); + const element = /** @type {Element} */ (node); + element.matches?.(e.itemSelector) && "true" !== /** @type {HTMLElement} */ (element).dataset.jhsProcessed && this.pendingItems.add(element), + element.querySelectorAll?.(e.itemSelector).forEach((/** @type {Element} */ item) => { + "true" !== /** @type {HTMLElement} */ (item).dataset.jhsProcessed && this.pendingItems.add(item); + }); } } this.pendingItems.size && (this.processTimer && clearTimeout(this.processTimer), this.processTimer = setTimeout((() => { - const items = [ ...this.pendingItems ].filter((item => item.isConnected && "true" !== item.dataset.jhsProcessed)); + const items = [ ...this.pendingItems ].filter((/** @type {Element} */ item) => item.isConnected && "true" !== /** @type {HTMLElement} */ (item).dataset.jhsProcessed); this.pendingItems.clear(), this.processTimer = null, items.length && void this.processAddedItems(items).catch((error => clog.error("列表增量处理失败", error))); }), 100)); }), { @@ -210,17 +235,19 @@ export class ListPagePlugin extends BasePlugin { subtree: !1 }); } + /** @param {Element[]} items */ async processAddedItems(items) { - const selector = this.getSelector(), covers = items.flatMap((item => [ ...item.querySelectorAll(selector.coverImgSelector) ])); + const selector = this.getSelector(), covers = items.flatMap((/** @type {Element} */ item) => [ ...item.querySelectorAll(selector.coverImgSelector) ]); this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), - items.forEach((item => item.dataset.jhsProcessed = "true")), this.indexItems(items), await jhsEventBus.emit("list-items-added", { items }, { broadcast: !1 }), this.getDependency("AutoPagePlugin").checkLoad(); + items.forEach((/** @type {Element} */ item) => /** @type {HTMLElement} */ (item).dataset.jhsProcessed = "true"), this.indexItems(items), await getListEventBus().emit("list-items-added", { items }, { broadcast: !1 }), this.getDependency("AutoPagePlugin").checkLoad(); } rebuildItemIndex() { this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); } + /** @param {Element[]} items */ indexItems(items) { - items.forEach((item => { + items.forEach((/** @type {Element} */ item) => { try { const key = normalizeCarNum(this.findCarNumAndHref($(item)).carNum); if (!key) return; @@ -229,43 +256,48 @@ export class ListPagePlugin extends BasePlugin { } catch (error) { clog.debug("列表项索引跳过无效卡片", error); } - })); + }); } + /** @param {NodeList | Node[]} nodes */ removeIndexedItems(nodes) { const removed = new Set; - Array.from(nodes || []).forEach((node => { - node.nodeType === Node.ELEMENT_NODE && (removed.add(node), node.querySelectorAll?.(this.getSelector().itemSelector).forEach((item => removed.add(item)))); - })); + Array.from(nodes || []).forEach((/** @type {Node} */ node) => { + const element = /** @type {Element} */ (node); + node.nodeType === Node.ELEMENT_NODE && (removed.add(element), element.querySelectorAll?.(this.getSelector().itemSelector).forEach((/** @type {Element} */ item) => removed.add(item))); + }); if (!removed.size) return; - this.itemIndex.forEach(((items, key) => { - items.forEach((item => { removed.has(item) && items.delete(item); })), items.size || this.itemIndex.delete(key); + this.itemIndex.forEach(((/** @type {Set} */ items, /** @type {string} */ key) => { + items.forEach((/** @type {Element} */ item) => { removed.has(item) && items.delete(item); }), items.size || this.itemIndex.delete(key); })); } + /** @param {unknown[]} carNums */ getIndexedItems(carNums) { const result = new Set; - carNums.map(normalizeCarNum).forEach((key => { + carNums.map(normalizeCarNum).forEach((/** @type {string | null} */ key) => { + if (!key) return; const items = this.itemIndex.get(key); - items?.forEach((item => item.isConnected ? result.add(item) : items.delete(item))), items && !items.size && this.itemIndex.delete(key); - })); + items?.forEach((/** @type {Element} */ item) => item.isConnected ? result.add(item) : items.delete(item)), items && !items.size && this.itemIndex.delete(key); + }); return [ ...result ]; } + /** @param {Element[] | null} [items] */ fixBusTitleBox(items = null) { if (!l) return; - (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach((e => { + (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach((/** @type {Element} */ e) => { var t; let n = $(e); if (n.find(".avatar-box").length > 0) return; const a = (null == (t = n.find("img").attr("title")) ? void 0 : t.trim()) || ""; n.find(".photo-info span:first").contents().first().wrap(`${a}`), n.find("br").remove(); - })); + }); } cleanRepeatId() { if (!l) return; $("#waterfall_h").removeAttr("id").attr("id", "no-page"); const e = $('[id="waterfall"]'); - 0 !== e.length && e.each((function() { - const e = $(this); + 0 !== e.length && e.each(((/** @type {number} */ index, /** @type {Element} */ element) => { + const e = $(element); if (!e.hasClass("masonry")) { e.children().insertAfter(e), e.remove(); } @@ -274,18 +306,20 @@ export class ListPagePlugin extends BasePlugin { async doFilter() { return this.doFilterItems(); } + /** @param {Element[] | null} [items] */ async doFilterItems(items = null) { if (!window.isListPage) return; let e = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); e.length && (await this.filterMovieList(e), l && setTimeout((() => { - this.getDependency("BusImgPlugin").logImageHeightsByRow().catch((e => clog.error("JavBus图片高度修正失败", e))); + this.getDependency("BusImgPlugin").logImageHeightsByRow().catch((/** @type {unknown} */ e) => clog.error("JavBus图片高度修正失败", e)); }))); } async yieldListFrame() { - await new Promise((e => { + await new Promise((/** @type {(value: void) => void} */ e) => { window.requestAnimationFrame ? window.requestAnimationFrame((() => setTimeout(e))) : setTimeout(e); - })); + }); } + /** @param {string[]} e @param {string} t @param {string} n */ findMatchedTitleKeyword(e, t, n) { for (const a of e) if (t.includes(a) || n.startsWith(a)) return a; return null; @@ -294,7 +328,13 @@ export class ListPagePlugin extends BasePlugin { if (this.filterContext) return this.filterContext; const [titleKeywords, blacklistMap, blacklistCars, settings, carMap, activity] = await Promise.all([ storageManager.getTitleFilterKeyword(), storageManager.getBlacklistMap(), storageManager.getBlacklistCarList(), storageManager.getSetting(), storageManager.getCarMap(), this.getRuntimeService("state").getActivityLog() ]), actorCarNumToNameMap = new Map, actressCarNumToNameMap = new Map, recentCarNums = new Set; const cutoff = Date.now() - 7 * 864e5; - activity.entries.filter((entry => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff)).forEach((entry => entry.changes.filter((change => "reverted" !== change.undoState && change.fields?.some((field => field.startsWith("stateFlags."))))).forEach((change => recentCarNums.add(change.carNum))))); + activity.entries + .filter((/** @type {ListRecord} */ entry) => "committed" === entry.commitState && Date.parse(entry.createdAt) >= cutoff) + .forEach((/** @type {ListRecord} */ entry) => { + entry.changes + .filter((/** @type {ListRecord} */ change) => "reverted" !== change.undoState && change.fields?.some((/** @type {string} */ field) => field.startsWith("stateFlags."))) + .forEach((/** @type {ListRecord} */ change) => recentCarNums.add(change.carNum)); + }); for (const item of blacklistCars) { const role = blacklistMap.get(item.starId)?.role; if (!role) { @@ -308,7 +348,7 @@ export class ListPagePlugin extends BasePlugin { } collectCurrentPageSummary() { const summary = { total: 0, pending: 0, blockedItems: 0, favorite: 0, downloaded: 0, watched: 0, debug: { manualBlocked: 0, keywordBlocked: 0, actorBlocked: 0, actressBlocked: 0 } }; - $(this.getSelector().itemSelector).each(((e, item) => { + $(this.getSelector().itemSelector).each(((/** @type {number} */ e, /** @type {Element} */ item) => { const card = $(item); if (l && card.find(".avatar-box").length > 0) return; const flags = normalizeStateFlags(JSON.parse(card.attr("data-jhs-flags") || "{}")), reasons = JSON.parse(card.attr("data-jhs-visibility") || "{}"), hardHidden = isHardHidden(flags, reasons); @@ -330,23 +370,25 @@ export class ListPagePlugin extends BasePlugin { } scheduleRecount() { if (this.recountFrame) return; - const schedule = window.requestAnimationFrame || (callback => setTimeout(callback, 0)); + const schedule = window.requestAnimationFrame || ((/** @type {FrameRequestCallback} */ callback) => setTimeout(callback, 0)); this.recountFrame = schedule((() => { this.recountFrame = null, this.recountStatuses(); })); } + /** @param {JQueryHandle[]} e */ async translateListItems(e) { if (await storageManager.getSetting("translateTitle", _) !== _) return; await mapLimit(e, 3, (async (item, index) => { try { index > 0 && index % 8 == 0 && await this.yieldListFrame(), await this.translate(item); } catch (error) { clog.error("列表标题翻译失败", error); } })); } + /** @param {Element[]} e */ async filterMovieList(e) { utils.time("累计耗费时间"), utils.time("读取数据耗时"); const {titleKeywords: n, settings: s, carMap: m, recentCarNums: recent, actorCarNumToNameMap: f, actressCarNumToNameMap: v} = await this.getFilterContext(), o = utils.time("读取数据耗时"); utils.time("组装数据耗时"); const b = utils.time("组装数据耗时"), P = (null == s ? void 0 : s.tagPosition) || "rightTop"; - const O = n.filter((e => e)); + const O = n.filter((/** @type {string} */ e) => e); this.currentPageFilterCount = 0, this.currentPageFavoriteCount = 0, this.currentPageHasDownCount = 0, this.currentPageHasWatchCount = 0, this.currentPageKeywordFilterCount = 0, this.currentPageActorFilterCount = 0, this.currentPageWaitCheckCount = 0, this.currentPageTotalCount = 0, utils.time("处理页面耗时"); @@ -364,7 +406,7 @@ export class ListPagePlugin extends BasePlugin { const badgeDefs = [ [ flags.blocked, Te.IS_FILTERED, "单番号屏蔽" ], [ flags.favorite, Te.IS_FAVORITE, "" ], [ flags.downloaded, Te.IS_HAS_DOWN, "" ], [ flags.watched, Te.IS_HAS_WATCH, "" ], [ visibilityReasons.keyword, Te.IS_KEYWORD_FILTER, keyword || "未知" ], [ visibilityReasons.actorBlacklist, Te.IS_ACTOR_FILTER, f.get(a) || "" ], [ visibilityReasons.actressBlacklist, Te.IS_ACTRESS_FILTER, v.get(a) || "" ] - ].filter((item => item[0])); + ].filter((/** @type {any[]} */ item) => item[0]); if (badgeDefs.length) { const box = $(``); badgeDefs.forEach((([, definition, tip]) => { @@ -376,20 +418,20 @@ export class ListPagePlugin extends BasePlugin { } hardHidden || R.push(t); } - this.scheduleRecount(), void this.translateListItems(R).catch((e => clog.error("列表页翻译任务失败", e))); + this.scheduleRecount(), void this.translateListItems(R).catch((/** @type {unknown} */ e) => clog.error("列表页翻译任务失败", e)); const D = utils.time("处理页面耗时"), A = utils.time("累计耗费时间"); clog.log(`\n
    \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
    ${o}${b}
    ${D}${A}
    项目数量项目数量
    屏蔽单番号${this.currentPageFilterCount}收藏${this.currentPageFavoriteCount}
    屏蔽演员${this.currentPageActorFilterCount}已下载${this.currentPageHasDownCount}
    屏蔽关键词${this.currentPageKeywordFilterCount}已观看${this.currentPageHasWatchCount}
    待鉴定${this.currentPageWaitCheckCount}
    总数${this.currentPageTotalCount}
    \n `); } async bindClick() { let e = this.getSelector(); - this.bindMovieDetailNavigation(e.boxSelector), $(e.boxSelector).on("click", ".item video", (async e => { + this.bindMovieDetailNavigation(e.boxSelector), $(e.boxSelector).on("click", ".item video", (async (/** @type {any} */ e) => { const t = e.currentTarget; t.paused ? await safePlay(t, { context: "列表视频", notify: !0 }) : t.pause(), e.preventDefault(), e.stopPropagation(); - })), $(e.boxSelector).on("contextmenu", ".item img, .item video", (async e => { + })), $(e.boxSelector).on("contextmenu", ".item img, .item video", (async (/** @type {any} */ e) => { try { e.preventDefault(); const t = $(e.target).closest(".item"), {carNum: n, url: a, publishTime: i, fc2Source} = this.findCarNumAndHref(t); @@ -403,6 +445,7 @@ export class ListPagePlugin extends BasePlugin { })); } /** 从任意列表卡片进入统一详情导航。 */ + /** @param {any} item @param {{ event?: MouseEvent | null, autoplay?: boolean, newTab?: boolean }} [options] */ async openMovieDetail(item, { event = null, autoplay = !1, newTab = !1 } = {}) { const card = item?.jquery ? item : $(item), {carNum, aHref, fc2Source} = this.findCarNumAndHref(card); if (!carNum || !aHref) return; @@ -415,36 +458,41 @@ export class ListPagePlugin extends BasePlugin { autoplay && destination.searchParams.set("autoPlay", "1"), utils.openPage(destination.href, carNum, !0, { event, newTab: shouldOpenTab }), this.$currentImage = null; } /** 为宿主与合成列表统一绑定左键、修饰键和中键导航。 */ + /** @param {any} container */ bindMovieDetailNavigation(container) { const root = $(container), selector = ".item img, .item .video-title"; - root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, (event => { + root.off("click.jhsMovieDetail auxclick.jhsMovieDetail", selector).on("click.jhsMovieDetail auxclick.jhsMovieDetail", selector, ((/** @type {any} */ event) => { if ("auxclick" === event.type && 1 !== event.button || "click" === event.type && event.button && 0 !== event.button) return; if (event.shiftKey || event.altKey || $(event.target).closest("div.meta-buttons,[class^='jhs-match-']").length) return; event.preventDefault(), event.stopPropagation(); void this.openMovieDetail($(event.currentTarget).closest(".item"), { event }).catch((error => clog.error("打开影片详情失败", error))); })); } + /** @param {string} e */ async parseActressName(e) { let t = null; if (await storageManager.getSetting("enableSaveActressCarInfo", C) === _) { clog.debug("鉴定补录演员信息-已启用, 开始解析详情页"), clog.debug("开始解析演员详情页", e); const scope = await this.getRuntimeService("scope")(); const n = await requestHostPage(this.getRuntimeService("http"), e, scope), a = utils.htmlTo$dom(n); - r ? t = a.find(".female").prev().map(((e, t) => $(t).text())).get().join(" ") : l && (t = a.find('span[onmouseover*="star_"] a').map(((e, t) => $(t).text())).get().join(" ")), + r ? t = a.find(".female").prev().map(((/** @type {number} */ e, /** @type {Element} */ t) => $(t).text())).get().join(" ") : l && (t = a.find('span[onmouseover*="star_"] a').map(((/** @type {number} */ e, /** @type {Element} */ t) => $(t).text())).get().join(" ")), clog.debug("解析到名称:", t); } return t; } + /** @param {JQueryHandle} e */ findCarNumAndHref(e) { try { return readListItem(e); } catch (error) { show.error("提取番号信息失败"); throw error; } } + /** @param {string} e */ showCarNumBox(e) { - const t = this.getRuntimeService("host").locateListItems().find((t => $(t).find(".video-title strong").text() === e)); + const t = this.getRuntimeService("host").locateListItems().find((/** @type {Element} */ t) => $(t).find(".video-title strong").text() === e); if (t) { const n = $(t); n.attr("data-hide") === "yes" && (n.show(), n.removeAttr("data-hide")); } } + /** @param {HTMLImageElement} e */ _replaceSingleHdImg(e) { if ("true" === e.dataset.hdReplaced) return; if (r) { @@ -468,33 +516,38 @@ export class ListPagePlugin extends BasePlugin { e.dataset.hdReplaced = "true", e.dataset.title = e.title, e.title = ""); } } + /** @param {any} [e] */ replaceHdImg(e) { if (e && "string" == typeof e.jquery && (e = e.toArray()), e || (e = document.querySelectorAll(this.getSelector().coverImgSelector)), !e.length) return; - const t = Array.from(e).filter((e => "true" !== e.dataset.hdReplaced && "true" !== e.dataset.jhsHdObserved)); + const t = Array.from(/** @type {Iterable} */ (e)).filter((/** @type {HTMLImageElement} */ e) => "true" !== e.dataset.hdReplaced && "true" !== e.dataset.jhsHdObserved); if ("IntersectionObserver" in window && !this.hdImageObserver) this.hdImageObserver = new IntersectionObserver((entries => { entries.forEach((entry => { - entry.isIntersecting && (this.hdImageObserver.unobserve(entry.target), delete entry.target.dataset.jhsHdObserved, - this._replaceSingleHdImg(entry.target)); + const image = /** @type {HTMLImageElement} */ (entry.target); + entry.isIntersecting && (this.hdImageObserver?.unobserve(image), delete image.dataset.jhsHdObserved, + this._replaceSingleHdImg(image)); })); }), { rootMargin: "200px" }); for (const image of t) this.hdEagerRemaining > 0 ? (this.hdEagerRemaining--, this._replaceSingleHdImg(image)) : this.hdImageObserver ? (image.dataset.jhsHdObserved = "true", this.hdImageObserver.observe(image)) : this._replaceSingleHdImg(image); - storageManager.getSetting("hoverBigImg", C).then((e => { - e === _ && (window.imageHoverPreviewObj ? window.imageHoverPreviewObj.bindEvents() : window.imageHoverPreviewObj = new ImageHoverPreview({ + storageManager.getSetting("hoverBigImg", C).then((/** @type {unknown} */ e) => { + const runtimeWindow = /** @type {any} */ (window); + e === _ && (runtimeWindow.imageHoverPreviewObj ? runtimeWindow.imageHoverPreviewObj.bindEvents() : runtimeWindow.imageHoverPreviewObj = new ImageHoverPreview({ selector: this.getSelector().coverImgSelector })); - })); + }); } + /** @param {JQueryHandle} e @param {string} t @param {string} n */ applyTranslatedTitle(e, t, n) { const a = e.find(".video-title"); - r ? (a.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(n) || (this.textContent = " " + t + " "); + r ? (a.contents().each(((/** @type {number} */ index, /** @type {Node} */ node) => { + 3 !== node.nodeType || "" === (node.textContent || "").trim() || (node.textContent || "").includes(n) || (node.textContent = " " + t + " "); })), a.attr("title", t)) : a.text(t), e.attr("data-jhs-translation-key", n); } + /** @param {JQueryHandle} e */ async translate(e) { let t, n, a = e.find(".video-title"); - if (r ? (t = a.contents().filter(((e, t) => 3 === t.nodeType && "" !== t.textContent.trim())).text().trim(), + if (r ? (t = a.contents().filter(((/** @type {number} */ e, /** @type {Node} */ t) => 3 === t.nodeType && "" !== (t.textContent || "").trim())).text().trim(), n = e.find(".video-title strong").text().trim()) : (t = (e.find("img").attr("data-title") || "").trim(), n = (e.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t || !n) return; const scope = await this.getRuntimeService("scope")(); @@ -502,16 +555,17 @@ export class ListPagePlugin extends BasePlugin { this.applyTranslatedTitle(e, translated, n); } async revertTranslation() { - $(this.getSelector().itemSelector).toArray().forEach((e => { + $(this.getSelector().itemSelector).toArray().forEach((/** @type {Element} */ e) => { let t = $(e); const n = t.find(".box").attr("title") || t.find(".video-title").attr("title") || t.find("img").attr("data-title"); + /** @type {string | undefined} */ let a; r && (a = t.find(".video-title strong").text().trim()); const i = t.find(".video-title"); - i.contents().each((function() { - 3 !== this.nodeType || "" === this.textContent.trim() || this.textContent.includes(a) || (this.textContent = " " + n + " "); + i.contents().each(((/** @type {number} */ index, /** @type {Node} */ node) => { + 3 !== node.nodeType || "" === (node.textContent || "").trim() || (node.textContent || "").includes(a || "") || (node.textContent = " " + n + " "); })), i.removeAttr("title"); - })); + }); } addJumpPageControl() { const e = "gemini-jump-page-control"; @@ -534,7 +588,7 @@ export class ListPagePlugin extends BasePlugin { const t = new URL(window.location.href); t.searchParams.set("page", e.toString()), window.location.href = t.toString(); }; - a.on("click", s), n.on("keypress", (function(e) { + a.on("click", s), n.on("keypress", (function(/** @type {any} */ e) { 13 === e.which && (s(), e.preventDefault()); })); } diff --git a/tsconfig.architecture.json b/tsconfig.architecture.json index 8eaa99a4..c207247c 100644 --- a/tsconfig.architecture.json +++ b/tsconfig.architecture.json @@ -72,6 +72,7 @@ "src/plugins/image-viewer/preview-video.js", "src/plugins/image-viewer/screenshot-provider-registry.js", "src/plugins/image-viewer/screenshot.js", + "src/plugins/new-video/task.js", "src/plugins/external-search/javtrailers.js", "src/plugins/one-one-five/client.js", "src/plugins/one-one-five/plugins.js", @@ -89,6 +90,7 @@ "src/plugins/status/highlight-magnet.js", "src/plugins/status/history.js", "src/plugins/status/list-page-button.js", + "src/plugins/status/list-page.js", "src/plugins/status/mobile-bottom-bar.js", "src/plugins/status/nav-bar.js", "src/plugins/status/want-and-watched-videos.js", From a846152feab54533cec905d72c4d0272921c93f8 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:35:02 +0800 Subject: [PATCH 060/130] =?UTF-8?q?Fix:=20=E4=BF=9D=E6=8C=81=20Contributio?= =?UTF-8?q?n=20=E7=8B=AC=E7=AB=8B=E7=A6=81=E7=94=A8=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 拆分复用同一 ID 的详情 Contribution,并兼容开发期共享 ID - 由 system Feature manifest 下发不可禁用属性,移除 PluginManager 名称保护名单 - Diagnostics 与设置页使用运行时插件描述符,并补充重复 ID 和迁移回归测试 修改原因: - 旧插件必须保持独立启停粒度,system 能力也不应依赖硬编码插件名 验证: - npm run check 通过(117 个文件、615 项测试) - npm run check:browser 通过(本机 Edge,15 passed、15 expected skips) - git diff --check 与 codegraph sync 通过 - 根目录与 dist 用户脚本 SHA256 一致 --- CHANGELOG.md | 2 + JHS.user.js | 76 +++++++++++++++++-------- src/app/feature-runtime.js | 7 +++ src/core/legacy-plugin-contributions.js | 23 +++++--- src/core/plugin-manager.js | 10 ++-- src/features/detail/manifest.js | 2 +- src/plugins/backup/setting-panels.js | 5 +- src/plugins/backup/setting-templates.js | 4 +- src/plugins/registry.js | 25 +++++--- src/services/diagnostics-service.js | 11 ++-- tests/architecture-runtime.test.js | 32 ++++++++++- 11 files changed, 142 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8e25013..de8624ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - List 快速筛选规则迁入独立 Feature owner,List 按钮、MobileBottomBar 与 Settings 表单不再依赖页面插件实现并进入 strict checkJs;生产源码类型覆盖率提升至 93.8%,最低覆盖门禁同步上调至 93.5%。 - Blacklist 真实运行时进入 strict checkJs,明确任务状态订阅、筛选记录、Tabulator 回调和跨页批量状态写入边界;生产源码类型覆盖率提升至 94.4%,最低覆盖门禁同步上调至 94%。 - TaskPlugin 与 ListPagePlugin 真实运行时进入 strict checkJs,明确调度状态/结果、配置刷新、网络分页、列表增量索引、筛选视图和媒体导航边界;生产源码类型覆盖率提升至 95.5%,RC 最低覆盖门禁上调至 95%。 +- Detail 的 legacy Contribution 改为一插件一 ID;封面状态按钮、详情状态按钮、JavDB 预览以及 JavBus 原生详情/图片/预览现在保持各自独立的旧版禁用语义。 +- Transitional PluginManager 的不可禁用属性改由 system Feature manifest 下发,删除 Settings、Stats 与移动工具栏的名称硬编码保护名单。 - 新增可单测的 Bootstrap Purity 架构门禁,禁止除 Composition Root 外的生产模块在导入阶段启动 DOM、网络、Storage、监听器、Observer 或定时器;原隐藏导航样式改为 Bootstrap 注入时才读取地址。 - strict checkJs 继续覆盖自动翻页、设置样式/模板和 123AV-FC2 入口,并补齐分页空值、DOM 容器与 Integration 返回数据的显式契约。 - 截图、宿主详情工作区、兼容增强与统一离线模块进入 strict checkJs;Provider availability、宿主资源边界、Observer 和 jQuery 兼容句柄均补充显式类型,覆盖率门禁同步上调至 84%。 diff --git a/JHS.user.js b/JHS.user.js index ee6e2711..178bba69 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -2101,17 +2101,17 @@ TOP250Plugin: "discovery.top250", SearchByImagePlugin: "identity.image-search", Fc2By123AvPlugin: "detail.fc2-lookup", - DetailPagePlugin: "detail.native", - BusDetailPagePlugin: "detail.native", + DetailPagePlugin: "detail.javdb-native", + BusDetailPagePlugin: "detail.javbus-native", DetailWorkspacePlugin: "detail.workspace", ReviewPlugin: "detail.reviews", RelatedPlugin: "detail.related", ScreenShotPlugin: "detail.screenshot", ScreenshotPlugin: "detail.screenshot", MagnetHubPlugin: "detail.external-magnets", - PreviewVideoPlugin: "detail.gallery", - CoverButtonPlugin: "detail.state-actions", - DetailPageButtonPlugin: "detail.state-actions", + PreviewVideoPlugin: "detail.javdb-preview", + CoverButtonPlugin: "detail.cover-state-actions", + DetailPageButtonPlugin: "detail.page-state-actions", HighlightMagnetPlugin: "detail.native-magnets", OtherSitePlugin: "detail.external-sites", SubTitleCatPlugin: "detail.subtitle", @@ -2128,15 +2128,21 @@ OneOneFiveMatchPlugin: "external-bridge.115-match", UnifiedOfflinePlugin: "external-bridge.offline", CompatibilityEnhancementsPlugin: "compatibility.enhancements", - BusImgPlugin: "detail.gallery", - BusPreviewVideoPlugin: "detail.gallery", + BusImgPlugin: "detail.javbus-images", + BusPreviewVideoPlugin: "detail.javbus-preview", OneTwoThreeOfflinePlugin: "external-bridge.123pan", JavTrailersPlugin: "external-bridge.javtrailers" }); + var LEGACY_SHARED_CONTRIBUTION_MAP = Object.freeze({ + "detail.native": ["detail.javdb-native", "detail.javbus-native"], + "detail.state-actions": ["detail.cover-state-actions", "detail.page-state-actions"], + "detail.gallery": ["detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview"] + }); function migrateDisabledPlugins(value) { const input = Array.isArray(value) ? value : []; const mapping = LEGACY_PLUGIN_CONTRIBUTION_MAP; - return [...new Set(input.filter((id) => typeof id === "string").map((id) => mapping[id] ?? id))]; + const shared = LEGACY_SHARED_CONTRIBUTION_MAP; + return [...new Set(input.filter((id) => typeof id === "string").flatMap((id) => shared[id] ?? [mapping[id] ?? id]))]; } __name(migrateDisabledPlugins, "migrateDisabledPlugins"); function disabledIdForPlugin(pluginName) { @@ -2174,7 +2180,7 @@ if (this.plugins.size) throw new Error("依赖声明必须在插件注册前配置"); this._dependencyDeclarations = declarations || Object.freeze({}); } - register(e2, runtimeServices = {}) { + register(e2, runtimeServices = {}, options = {}) { if ("function" != typeof e2) throw new Error("插件必须是一个类"); const a2 = performance.now(); const t2 = new e2(); @@ -2183,6 +2189,7 @@ if (this.plugins.has(n2)) throw new Error(`插件"${n2}"已注册`); t2.declaredDependencies = new Set(this._dependencyDeclarations[n2] || []); t2.runtimeServices = Object.freeze({ ...runtimeServices }); + t2.disableable = options.disableable ?? true; this.plugins.set(n2, t2); this._registrationMs += performance.now() - a2; this._syncDiagnostics(); @@ -2219,6 +2226,9 @@ getPluginNames() { return Array.from(this.plugins.keys()); } + getPluginDescriptors() { + return [...this.plugins].map(([name, plugin]) => Object.freeze({ name, disableable: plugin.disableable !== false })); + } getStartupReport() { return { registeredPlugins: this.plugins.size, @@ -2231,14 +2241,14 @@ }; } _syncDiagnostics() { - this.diagnostics?.setLegacyRuntime(this.getPluginNames(), this.getStartupReport(), this.getTimings()); + this.diagnostics?.setLegacyRuntime(this.getPluginDescriptors(), this.getStartupReport(), this.getTimings()); } async _getDisabledPlugins() { return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { try { const e2 = await storageManager.getSetting("disabledPlugins", "[]"); const configured = new Set(parseDisabledPlugins(e2)); - return new Set(this.getPluginNames().filter(((name) => !["SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin"].includes(name) && configured.has(disabledIdForPlugin(name))))); + return new Set([...this.plugins].filter((([name, plugin]) => plugin.disableable !== false && configured.has(disabledIdForPlugin(name)))).map((([name]) => name))); } catch (e2) { return /* @__PURE__ */ new Set(); } @@ -5663,7 +5673,7 @@ routes: ["detail"], startup: "eager", requires: [PORT.host, SERVICE.movie, SERVICE.review, SERVICE.related, SERVICE.magnet, SERVICE.screenshot], - contributes: ["detail.native", "detail.workspace", "detail.fc2-owned", "detail.fc2-lookup", "detail.state-actions", "detail.gallery", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], + contributes: ["detail.javdb-native", "detail.javbus-native", "detail.workspace", "detail.fc2-owned", "detail.fc2-lookup", "detail.cover-state-actions", "detail.page-state-actions", "detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], providesCommands: [], activate: /* @__PURE__ */ __name((deps, runtime) => { const controller = new DetailController({ hostAdapter: deps[PORT.host], scope: runtime.scope, enabledContributions: runtime.enabledContributions }); @@ -7378,7 +7388,6 @@ data: group("data", "数据"), network: group("network", "网络") }, - corePlugins: ["SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin"], pluginMeta }; } @@ -8175,7 +8184,8 @@ const diagnosticSnapshot = diagnostics.exportSnapshot(); const disabled = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); const allNames = diagnosticSnapshot.legacyPlugins; - const { categories, corePlugins, pluginMeta } = getPluginCategories(); + const { categories, pluginMeta } = getPluginCategories(); + const pluginDescriptors = new Map((diagnosticSnapshot.legacyPluginDescriptors || []).map((item) => [item.name, item])); const registeredSet = new Set(allNames); let html = ""; for (const [catKey, cat] of Object.entries(categories)) { @@ -8184,7 +8194,7 @@ html += '
    '; html += `

    ${escapeHtml(cat.label)}

    `; for (const pName of visiblePlugins) { - const isCore = corePlugins.includes(pName); + const isCore = pluginDescriptors.get(pName)?.disableable === false; const isDisabled = disabled.includes(disabledIdForPlugin(pName)); const productName = pluginMeta[pName]?.[0] || pName; html += '
    '; @@ -16936,15 +16946,15 @@ ${failure.stack}` : ""); manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [PORT.host, SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [PORT.host, SERVICE.dialog, SERVICE.account]), manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog, SERVICE.storage, SERVICE.imageSearch]), - manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }, [SERVICE.storage, SERVICE.state]), + manifest("detail.cover-state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }, [SERVICE.storage, SERVICE.state]), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [PORT.host, SERVICE.movie, SERVICE.translation, SERVICE.settings]), - manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), + manifest("detail.javdb-native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }, [PORT.host]), manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), - manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state]), + manifest("detail.page-state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [PORT.host, SERVICE.settings]), - manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie]), + manifest("detail.javdb-preview", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie]), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie, SERVICE.storage]), @@ -16962,13 +16972,21 @@ ${failure.stack}` : ""); manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [PORT.host, SERVICE.dialog, SERVICE.offline, SERVICE.state]), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }, [SERVICE.state]), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), - manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), - manifest("detail.native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), - manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage]), + manifest("detail.javbus-images", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), + manifest("detail.javbus-native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), + manifest("detail.javbus-preview", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage]), manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }, [SERVICE.storage]), manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), manifest("detail.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }) ]); + var contributionIds = /* @__PURE__ */ new Set(); + var legacyPluginIds = /* @__PURE__ */ new Set(); + for (const contribution of legacyContributionManifests) { + if (contributionIds.has(contribution.id)) throw new Error(`Duplicate contribution id: ${contribution.id}`); + if (legacyPluginIds.has(contribution.legacyPluginId)) throw new Error(`Duplicate legacy plugin contribution: ${contribution.legacyPluginId}`); + contributionIds.add(contribution.id); + legacyPluginIds.add(contribution.legacyPluginId); + } function registerSitePlugins(pluginManager, featureRuntime, site) { pluginManager.setDependencyDeclarations(LEGACY_PLUGIN_DEPENDENCY_MAP); legacyContributionManifests.filter((item) => item.sites.includes(site) && featureRuntime.isContributionEnabled(item.featureId, item.id, item.legacyPluginId)).sort((left, right) => Number(left.order[site]) - Number(right.order[site])).forEach((item) => { @@ -17002,7 +17020,7 @@ ${failure.stack}` : ""); if (!name) throw new Error(`Legacy contribution ${item.id} has no runtime name for ${String(token)}`); runtimeServices[name] = dependencies[token]; } - pluginManager.register(item.plugin, runtimeServices); + pluginManager.register(item.plugin, runtimeServices, { disableable: featureRuntime.isFeatureDisableable(item.featureId) }); }); } __name(registerSitePlugins, "registerSitePlugins"); @@ -17312,6 +17330,7 @@ ${failure.stack}` : ""); this.errors = []; this.browserMetadata = null; this.legacyPlugins = []; + this.legacyPluginDescriptors = []; this.legacyStartup = null; this.legacyTimings = []; this.legacyHttp = options.legacyHttp ?? null; @@ -17346,8 +17365,9 @@ ${failure.stack}` : ""); setBrowserMetadata(metadata) { this.browserMetadata = Object.freeze({ ...metadata }); } - setLegacyRuntime(names, startup, timings) { - this.legacyPlugins = [...names]; + setLegacyRuntime(descriptors, startup, timings) { + this.legacyPluginDescriptors = descriptors.map((item) => Object.freeze({ ...item })); + this.legacyPlugins = descriptors.map((item) => item.name); this.legacyStartup = Object.freeze({ ...startup }); this.legacyTimings = timings.map((item) => Object.freeze({ ...item })); } @@ -17389,6 +17409,7 @@ ${failure.stack}` : ""); providerHealth: Object.fromEntries(this.providerHealth), errors: this.errors.map((error) => ({ ...error })), legacyPlugins: [...this.legacyPlugins], + legacyPluginDescriptors: this.legacyPluginDescriptors.map((item) => ({ ...item })), legacyStartup: this.legacyStartup, legacyTimings: this.legacyTimings.map((item) => ({ ...item })), browser: this.browserMetadata, @@ -18389,6 +18410,11 @@ ${failure.stack}` : ""); if (manifest2.kind === "system") return true; return !this.disabled.has(contributionId) && !this.disabled.has(legacyPluginId); } + isFeatureDisableable(featureId) { + const manifest2 = this.manifests.get(featureId); + if (!manifest2) throw new Error(`Unknown feature: ${featureId}`); + return manifest2.kind !== "system" && manifest2.disableable !== false; + } resolveDeclaredDependencies(tokens) { return this.container.resolveDeclared(tokens); } diff --git a/src/app/feature-runtime.js b/src/app/feature-runtime.js index ecbacc52..01f3263b 100644 --- a/src/app/feature-runtime.js +++ b/src/app/feature-runtime.js @@ -49,6 +49,13 @@ export class FeatureRuntime { return !this.disabled.has(contributionId) && !this.disabled.has(legacyPluginId); } + /** @param {string} featureId */ + isFeatureDisableable(featureId) { + const manifest = this.manifests.get(featureId); + if (!manifest) throw new Error(`Unknown feature: ${featureId}`); + return manifest.kind !== "system" && manifest.disableable !== false; + } + /** @param {symbol[]} tokens */ resolveDeclaredDependencies(tokens) { return this.container.resolveDeclared(tokens); diff --git a/src/core/legacy-plugin-contributions.js b/src/core/legacy-plugin-contributions.js index 08097b0f..d6f934fe 100644 --- a/src/core/legacy-plugin-contributions.js +++ b/src/core/legacy-plugin-contributions.js @@ -14,17 +14,17 @@ export const LEGACY_PLUGIN_CONTRIBUTION_MAP = Object.freeze({ TOP250Plugin: "discovery.top250", SearchByImagePlugin: "identity.image-search", Fc2By123AvPlugin: "detail.fc2-lookup", - DetailPagePlugin: "detail.native", - BusDetailPagePlugin: "detail.native", + DetailPagePlugin: "detail.javdb-native", + BusDetailPagePlugin: "detail.javbus-native", DetailWorkspacePlugin: "detail.workspace", ReviewPlugin: "detail.reviews", RelatedPlugin: "detail.related", ScreenShotPlugin: "detail.screenshot", ScreenshotPlugin: "detail.screenshot", MagnetHubPlugin: "detail.external-magnets", - PreviewVideoPlugin: "detail.gallery", - CoverButtonPlugin: "detail.state-actions", - DetailPageButtonPlugin: "detail.state-actions", + PreviewVideoPlugin: "detail.javdb-preview", + CoverButtonPlugin: "detail.cover-state-actions", + DetailPageButtonPlugin: "detail.page-state-actions", HighlightMagnetPlugin: "detail.native-magnets", OtherSitePlugin: "detail.external-sites", SubTitleCatPlugin: "detail.subtitle", @@ -41,17 +41,24 @@ export const LEGACY_PLUGIN_CONTRIBUTION_MAP = Object.freeze({ OneOneFiveMatchPlugin: "external-bridge.115-match", UnifiedOfflinePlugin: "external-bridge.offline", CompatibilityEnhancementsPlugin: "compatibility.enhancements", - BusImgPlugin: "detail.gallery", - BusPreviewVideoPlugin: "detail.gallery", + BusImgPlugin: "detail.javbus-images", + BusPreviewVideoPlugin: "detail.javbus-preview", OneTwoThreeOfflinePlugin: "external-bridge.123pan", JavTrailersPlugin: "external-bridge.javtrailers", }); +const LEGACY_SHARED_CONTRIBUTION_MAP = Object.freeze({ + "detail.native": ["detail.javdb-native", "detail.javbus-native"], + "detail.state-actions": ["detail.cover-state-actions", "detail.page-state-actions"], + "detail.gallery": ["detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview"], +}); + /** @param {unknown} value */ export function migrateDisabledPlugins(value) { const input = Array.isArray(value) ? value : []; const mapping = /** @type {Record} */ (LEGACY_PLUGIN_CONTRIBUTION_MAP); - return [...new Set(input.filter((id) => typeof id === "string").map((id) => mapping[id] ?? id))]; + const shared = /** @type {Record} */ (LEGACY_SHARED_CONTRIBUTION_MAP); + return [...new Set(input.filter((id) => typeof id === "string").flatMap((id) => shared[id] ?? [mapping[id] ?? id]))]; } /** @param {string} pluginName */ diff --git a/src/core/plugin-manager.js b/src/core/plugin-manager.js index 51ee12e0..860c9d3d 100644 --- a/src/core/plugin-manager.js +++ b/src/core/plugin-manager.js @@ -29,8 +29,8 @@ export class PluginManager { if (this.plugins.size) throw new Error("依赖声明必须在插件注册前配置"); this._dependencyDeclarations = declarations || Object.freeze({}); } - /** @param {new (...args: any[]) => any} e @param {Record} [runtimeServices] */ - register(e, runtimeServices = {}) { + /** @param {new (...args: any[]) => any} e @param {Record} [runtimeServices] @param {{disableable?: boolean}} [options] */ + register(e, runtimeServices = {}, options = {}) { if ("function" != typeof e) throw new Error("插件必须是一个类"); const a = performance.now(); const t = new e; @@ -39,6 +39,7 @@ export class PluginManager { if (this.plugins.has(n)) throw new Error(`插件"${n}"已注册`); t.declaredDependencies = new Set(this._dependencyDeclarations[n] || []); t.runtimeServices = Object.freeze({ ...runtimeServices }); + t.disableable = options.disableable ?? true; this.plugins.set(n, t); this._registrationMs += performance.now() - a; this._syncDiagnostics(); @@ -69,6 +70,7 @@ export class PluginManager { clearErrorLog() { this._errorLog = []; this.diagnostics?.clearErrors(); } getTimings() { return [...this._lastTimings]; } getPluginNames() { return Array.from(this.plugins.keys()); } + getPluginDescriptors() { return [...this.plugins].map(([name, plugin]) => Object.freeze({ name, disableable: plugin.disableable !== false })); } getStartupReport() { return { registeredPlugins: this.plugins.size, @@ -80,12 +82,12 @@ export class PluginManager { idleCompleted: this._idleCompleted }; } - _syncDiagnostics() { this.diagnostics?.setLegacyRuntime(this.getPluginNames(), this.getStartupReport(), this.getTimings()); } + _syncDiagnostics() { this.diagnostics?.setLegacyRuntime(this.getPluginDescriptors(), this.getStartupReport(), this.getTimings()); } async _getDisabledPlugins() { return this._disabledPluginsPromise || (this._disabledPluginsPromise = (async () => { try { const e = await storageManager.getSetting("disabledPlugins", "[]"); const configured = new Set(parseDisabledPlugins(e)); - return new Set(this.getPluginNames().filter((name => ![ "SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin" ].includes(name) && configured.has(disabledIdForPlugin(name))))); + return new Set([...this.plugins].filter((([name, plugin]) => plugin.disableable !== false && configured.has(disabledIdForPlugin(name)))).map((([name]) => name))); } catch (e) { return new Set; } })()); } async processCss() { diff --git a/src/features/detail/manifest.js b/src/features/detail/manifest.js index a321669c..97247f46 100644 --- a/src/features/detail/manifest.js +++ b/src/features/detail/manifest.js @@ -7,7 +7,7 @@ import { DetailController } from "./detail-controller.js"; export default defineFeature({ id: "detail", kind: "feature", disableable: true, sites: ["javdb", "javbus"], routes: ["detail"], startup: "eager", requires: [PORT.host, SERVICE.movie, SERVICE.review, SERVICE.related, SERVICE.magnet, SERVICE.screenshot], - contributes: ["detail.native", "detail.workspace", "detail.fc2-owned", "detail.fc2-lookup", "detail.state-actions", "detail.gallery", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], + contributes: ["detail.javdb-native", "detail.javbus-native", "detail.workspace", "detail.fc2-owned", "detail.fc2-lookup", "detail.cover-state-actions", "detail.page-state-actions", "detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.subtitle", "detail.external-sites"], providesCommands: [], activate: (/** @type {any} */ deps, /** @type {any} */ runtime) => { const controller = new DetailController({ hostAdapter: deps[PORT.host], scope: runtime.scope, enabledContributions: runtime.enabledContributions }); diff --git a/src/plugins/backup/setting-panels.js b/src/plugins/backup/setting-panels.js index bd1d5db0..91ba561a 100644 --- a/src/plugins/backup/setting-panels.js +++ b/src/plugins/backup/setting-panels.js @@ -153,7 +153,8 @@ export async function renderPluginMgmtPanel(diagnostics) { const diagnosticSnapshot = diagnostics.exportSnapshot(); const disabled = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); const allNames = diagnosticSnapshot.legacyPlugins; - const { categories, corePlugins, pluginMeta } = getPluginCategories(); + const { categories, pluginMeta } = getPluginCategories(); + const pluginDescriptors = new Map((diagnosticSnapshot.legacyPluginDescriptors || []).map((/** @type {{name: string, disableable: boolean}} */ item) => [item.name, item])); const registeredSet = new Set(allNames); let html = ""; for (const [catKey, cat] of Object.entries(categories)) { @@ -162,7 +163,7 @@ export async function renderPluginMgmtPanel(diagnostics) { html += '
    '; html += `

    ${escapeHtml(cat.label)}

    `; for (const pName of visiblePlugins) { - const isCore = corePlugins.includes(pName); + const isCore = pluginDescriptors.get(pName)?.disableable === false; const isDisabled = disabled.includes(disabledIdForPlugin(pName)); const productName = pluginMeta[pName]?.[0] || pName; html += '
    '; diff --git a/src/plugins/backup/setting-templates.js b/src/plugins/backup/setting-templates.js index cda6ce61..b8dc32bb 100644 --- a/src/plugins/backup/setting-templates.js +++ b/src/plugins/backup/setting-templates.js @@ -4,7 +4,7 @@ import { L, r } from "../../core/constants.js"; /** * Build the plugin categories configuration shared between inject and render. - * Returns { categories, corePlugins }. + * Returns the presentation categories; disableability comes from Feature manifests through Diagnostics. */ export function getPluginCategories() { /** @type {Record} */ @@ -22,7 +22,7 @@ export function getPluginCategories() { core:group("core", "基础核心"), list:group("list", "列表页"), detail:group("detail", "详情页"), media:group("media", "媒体"), data:group("data", "数据"), network:group("network", "网络") }, - corePlugins: ["SettingPlugin","StatsPlugin","MobileBottomBarPlugin"], pluginMeta + pluginMeta }; } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 48c54086..95f2b0c1 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -61,15 +61,15 @@ export const legacyContributionManifests = Object.freeze([ manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [PORT.host, SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [PORT.host, SERVICE.dialog, SERVICE.account]), manifest("identity.image-search", "identity", SearchByImagePlugin, ["javdb", "javbus"], { javdb: 11, javbus: 6 }, [SERVICE.dialog, SERVICE.storage, SERVICE.imageSearch]), - manifest("detail.state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }, [SERVICE.storage, SERVICE.state]), + manifest("detail.cover-state-actions", "detail", CoverButtonPlugin, ["javdb", "javbus"], { javdb: 12, javbus: 8 }, [SERVICE.storage, SERVICE.state]), manifest("detail.fc2-lookup", "detail", Fc2By123AvPlugin, ["javdb"], { javdb: 13 }, [PORT.host, SERVICE.movie, SERVICE.translation, SERVICE.settings]), - manifest("detail.native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), + manifest("detail.javdb-native", "detail", DetailPagePlugin, ["javdb"], { javdb: 14 }), manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }, [PORT.host]), manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), - manifest("detail.state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state]), + manifest("detail.page-state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [PORT.host, SERVICE.settings]), - manifest("detail.gallery", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie]), + manifest("detail.javdb-preview", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie]), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), manifest("identity.actress-info", "identity", ActressInfoPlugin, ["javdb"], { javdb: 22 }, [SERVICE.actressInfo]), manifest("detail.external-sites", "detail", OtherSitePlugin, ["javdb", "javbus"], { javdb: 23, javbus: 19 }, [PORT.host, SERVICE.movie, SERVICE.storage]), @@ -87,14 +87,23 @@ export const legacyContributionManifests = Object.freeze([ manifest("external-bridge.offline", "external-bridge", UnifiedOfflinePlugin, ["javdb", "javbus"], { javdb: 35, javbus: 26 }, [PORT.host, SERVICE.dialog, SERVICE.offline, SERVICE.state]), manifest("compatibility.enhancements", "compatibility", CompatibilityEnhancementsPlugin, ["javdb", "javbus"], { javdb: 36, javbus: 27 }, [SERVICE.state]), manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), - manifest("detail.gallery", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), - manifest("detail.native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), - manifest("detail.gallery", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage]), + manifest("detail.javbus-images", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), + manifest("detail.javbus-native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), + manifest("detail.javbus-preview", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage]), manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }, [SERVICE.storage]), manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), manifest("detail.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }), ]); +const contributionIds = new Set(); +const legacyPluginIds = new Set(); +for (const contribution of legacyContributionManifests) { + if (contributionIds.has(contribution.id)) throw new Error(`Duplicate contribution id: ${contribution.id}`); + if (legacyPluginIds.has(contribution.legacyPluginId)) throw new Error(`Duplicate legacy plugin contribution: ${contribution.legacyPluginId}`); + contributionIds.add(contribution.id); + legacyPluginIds.add(contribution.legacyPluginId); +} + /** @param {import("../core/plugin-manager.js").PluginManager} pluginManager @param {import("../app/feature-runtime.js").FeatureRuntime} featureRuntime @param {string} site */ export function registerSitePlugins(pluginManager, featureRuntime, site) { pluginManager.setDependencyDeclarations(LEGACY_PLUGIN_DEPENDENCY_MAP); @@ -126,6 +135,6 @@ export function registerSitePlugins(pluginManager, featureRuntime, site) { if (!name) throw new Error(`Legacy contribution ${item.id} has no runtime name for ${String(token)}`); runtimeServices[name] = dependencies[token]; } - pluginManager.register(item.plugin, runtimeServices); + pluginManager.register(item.plugin, runtimeServices, { disableable: featureRuntime.isFeatureDisableable(item.featureId) }); }); } diff --git a/src/services/diagnostics-service.js b/src/services/diagnostics-service.js index 115b58d1..e8ff754d 100644 --- a/src/services/diagnostics-service.js +++ b/src/services/diagnostics-service.js @@ -27,6 +27,8 @@ export class DiagnosticsService { this.browserMetadata = null; /** @type {string[]} */ this.legacyPlugins = []; + /** @type {Array<{name: string, disableable: boolean}>} */ + this.legacyPluginDescriptors = []; this.legacyStartup = null; /** @type {Array>} */ this.legacyTimings = []; @@ -58,9 +60,10 @@ export class DiagnosticsService { } /** @param {Record} metadata */ setBrowserMetadata(metadata) { this.browserMetadata = Object.freeze({ ...metadata }); } - /** @param {string[]} names @param {Record} startup @param {Array>} timings */ - setLegacyRuntime(names, startup, timings) { - this.legacyPlugins = [...names]; + /** @param {Array<{name: string, disableable: boolean}>} descriptors @param {Record} startup @param {Array>} timings */ + setLegacyRuntime(descriptors, startup, timings) { + this.legacyPluginDescriptors = descriptors.map((item) => Object.freeze({ ...item })); + this.legacyPlugins = descriptors.map((item) => item.name); this.legacyStartup = Object.freeze({ ...startup }); this.legacyTimings = timings.map((item) => Object.freeze({ ...item })); } @@ -89,7 +92,7 @@ export class DiagnosticsService { requestConsumers: this.requestStats.consumers, underlyingRequests: this.requestStats.underlying, cacheHits: this.cacheStats.hits, cacheMisses: this.cacheStats.misses, providerHealth: Object.fromEntries(this.providerHealth), errors: this.errors.map((error) => ({ ...error })), - legacyPlugins: [...this.legacyPlugins], legacyStartup: this.legacyStartup, legacyTimings: this.legacyTimings.map((item) => ({ ...item })), + legacyPlugins: [...this.legacyPlugins], legacyPluginDescriptors: this.legacyPluginDescriptors.map((item) => ({ ...item })), legacyStartup: this.legacyStartup, legacyTimings: this.legacyTimings.map((item) => ({ ...item })), browser: this.browserMetadata, uptimeMs: performance.now() - this.startedAt, }); } diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js index b1556a3a..add1060b 100644 --- a/tests/architecture-runtime.test.js +++ b/tests/architecture-runtime.test.js @@ -47,7 +47,9 @@ describe("v6.5 architecture runtime contracts", () => { vi.stubGlobal("jQuery", $); vi.stubGlobal("localforage", { INDEXEDDB: "indexeddb", createInstance: () => ({}) }); Object.assign(globalThis, { utils: {}, storageManager: {} }); - const { registerSitePlugins } = await import("../src/plugins/registry.js"); + const { legacyContributionManifests, registerSitePlugins } = await import("../src/plugins/registry.js"); + expect(new Set(legacyContributionManifests.map((item) => item.id)).size).toBe(legacyContributionManifests.length); + expect(new Set(legacyContributionManifests.map((item) => item.legacyPluginId)).size).toBe(legacyContributionManifests.length); const createRuntime = (site, disabled = []) => { const diagnostics = new DiagnosticsService(); const container = new DependencyContainer().register(PORT.host, { locateDetailSlots: () => ({}) }).register(SERVICE.diagnostics, diagnostics).register(SERVICE.dialog, {}).register(SERVICE.webdav, {}).register(SERVICE.review, {}).register(SERVICE.related, {}).register(SERVICE.movie, {}).register(SERVICE.actressInfo, {}).register(SERVICE.imageSearch, {}).register(SERVICE.magnet, {}).register(SERVICE.screenshot, {}).register(SERVICE.translation, {}).register(SERVICE.subtitle, {}).register(SERVICE.account, {}).register(SERVICE.settings, {}).register(SERVICE.storage, {}).register(SERVICE.cache, {}).register(SERVICE.http, {}).register(SERVICE.offline, {}).register(SERVICE.state, {}); @@ -69,6 +71,27 @@ describe("v6.5 architecture runtime contracts", () => { registerSitePlugins(javbus, createRuntime("javbus", ["ReviewPlugin"]), "javbus"); expect(javbus.getPluginNames()).not.toContain("ReviewPlugin"); expect(javbus.getPluginNames()).toContain("DetailWorkspacePlugin"); + + const javdbWithCoverDisabled = new PluginManager(); + registerSitePlugins(javdbWithCoverDisabled, createRuntime("javdb", ["CoverButtonPlugin"]), "javdb"); + expect(javdbWithCoverDisabled.getPluginNames()).not.toContain("CoverButtonPlugin"); + expect(javdbWithCoverDisabled.getPluginNames()).toContain("DetailPageButtonPlugin"); + + const javbusWithImagesDisabled = new PluginManager(); + registerSitePlugins(javbusWithImagesDisabled, createRuntime("javbus", ["BusImgPlugin"]), "javbus"); + expect(javbusWithImagesDisabled.getPluginNames()).not.toContain("BusImgPlugin"); + expect(javbusWithImagesDisabled.getPluginNames()).toContain("BusPreviewVideoPlugin"); + + const legacyDiagnostics = new DiagnosticsService(); + const javdbWithStaleSystemDisables = new PluginManager({ diagnostics: legacyDiagnostics }); + registerSitePlugins(javdbWithStaleSystemDisables, createRuntime("javdb", ["settings.core", "stats.dashboard", "responsive-shell.bottom-bar"]), "javdb"); + expect(javdbWithStaleSystemDisables.getPluginNames()).toEqual(expect.arrayContaining(["SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin"])); + expect(javdbWithStaleSystemDisables.getPluginDescriptors().filter((plugin) => plugin.disableable === false).map((plugin) => plugin.name)).toEqual([ + "SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin", + ]); + expect(legacyDiagnostics.exportSnapshot().legacyPluginDescriptors.filter((plugin) => plugin.disableable === false).map((plugin) => plugin.name)).toEqual([ + "SettingPlugin", "StatsPlugin", "MobileBottomBarPlugin", + ]); }, 15_000); it("exports measured and redacted diagnostics", () => { @@ -102,6 +125,13 @@ describe("v6.5 architecture runtime contracts", () => { expect(activate).toHaveBeenCalledOnce(); expect(diagnostics.exportSnapshot().activeContributions).toEqual(["detail.related"]); expect(migrateDisabledPlugins(["ReviewPlugin", "UnknownPlugin"])).toEqual(["detail.reviews", "UnknownPlugin"]); + expect(migrateDisabledPlugins(["CoverButtonPlugin", "DetailPageButtonPlugin", "BusImgPlugin", "BusPreviewVideoPlugin"])).toEqual([ + "detail.cover-state-actions", "detail.page-state-actions", "detail.javbus-images", "detail.javbus-preview", + ]); + expect(migrateDisabledPlugins(["detail.native", "detail.state-actions", "detail.gallery"])).toEqual([ + "detail.javdb-native", "detail.javbus-native", "detail.cover-state-actions", "detail.page-state-actions", + "detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview", + ]); }); it("disposes all scope-owned resources and blocks stale generations", () => { From beee67a9ced2f028968a9d6f4171d69fb4114929 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:39:30 +0800 Subject: [PATCH 061/130] =?UTF-8?q?Chore:=20=E5=BC=BA=E5=8C=96=20Manifest?= =?UTF-8?q?=20=E6=9C=BA=E5=99=A8=E9=AA=8C=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - Feature、Contribution 与 Integration schema 拒绝空值、重复 token 和非法 host - Integration cache policy 必须精确覆盖 capability,Adapter 必须声明 normalized contract - JavDB 补齐显式 no-cache 策略并统一重复依赖错误 修改原因: - 将 Plan v1.1 的 Manifest 和 Integration Quality 要求转为可执行硬门禁 验证: - npm run check 通过(117 个文件、615 项测试) - npm run check:browser 通过(本机 Edge,15 passed、15 expected skips) - git diff --check 与 codegraph sync 通过 - 根目录与 dist 用户脚本 SHA256 一致 --- CHANGELOG.md | 1 + JHS.user.js | 47 ++++++++++++++++++++++++--- scripts/integration-quality-check.mjs | 13 ++++++++ src/app/dependency-container.js | 7 ++++ src/contracts/manifests.js | 40 ++++++++++++++++++++--- src/integrations/javdb/manifest.js | 2 +- tests/architecture-runtime.test.js | 4 +++ 7 files changed, 104 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de8624ac..f7e5602e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - TaskPlugin 与 ListPagePlugin 真实运行时进入 strict checkJs,明确调度状态/结果、配置刷新、网络分页、列表增量索引、筛选视图和媒体导航边界;生产源码类型覆盖率提升至 95.5%,RC 最低覆盖门禁上调至 95%。 - Detail 的 legacy Contribution 改为一插件一 ID;封面状态按钮、详情状态按钮、JavDB 预览以及 JavBus 原生详情/图片/预览现在保持各自独立的旧版禁用语义。 - Transitional PluginManager 的不可禁用属性改由 system Feature manifest 下发,删除 Settings、Stats 与移动工具栏的名称硬编码保护名单。 +- Feature/Contribution/Integration manifest schema 现在拒绝空值、重复 ID/token 和非法 host;Integration cache policy 必须精确覆盖全部 capability,Adapter 必须声明 normalized contract,JavDB 补齐四项显式 no-cache 声明。 - 新增可单测的 Bootstrap Purity 架构门禁,禁止除 Composition Root 外的生产模块在导入阶段启动 DOM、网络、Storage、监听器、Observer 或定时器;原隐藏导航样式改为 Bootstrap 注入时才读取地址。 - strict checkJs 继续覆盖自动翻页、设置样式/模板和 123AV-FC2 入口,并补齐分页空值、DOM 容器与 Integration 返回数据的显式契约。 - 截图、宿主详情工作区、兼容增强与统一离线模块进入 strict checkJs;Provider availability、宿主资源边界、Observer 和 jQuery 兼容句柄均补充显式类型,覆盖率门禁同步上调至 84%。 diff --git a/JHS.user.js b/JHS.user.js index 178bba69..f91572d2 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -5492,12 +5492,28 @@ if (!Array.isArray(value)) throw new TypeError(`${field} must be an array`); } __name(requireArray, "requireArray"); + function requireUniqueStrings(value, field) { + requireArray(value, field); + const items = value; + for (const item of items) requireNonEmptyString(item, `${field} item`); + if (new Set(items).size !== items.length) throw new TypeError(`${field} cannot contain duplicates`); + } + __name(requireUniqueStrings, "requireUniqueStrings"); + function requireUniqueTokens(value, field) { + requireArray(value, field); + const items = value; + if (items.some((item) => typeof item !== "symbol")) throw new TypeError(`${field} must contain dependency tokens`); + if (new Set(items).size !== items.length) throw new TypeError(`${field} cannot contain duplicate tokens`); + } + __name(requireUniqueTokens, "requireUniqueTokens"); function defineFeature(manifest2) { requireNonEmptyString(manifest2.id, "Feature id"); if (!FEATURE_KINDS.has(String(manifest2.kind))) throw new TypeError("Feature kind must be system or feature"); if (!STARTUP_MODES.has(String(manifest2.startup))) throw new TypeError("Feature startup mode is invalid"); - for (const field of ["sites", "routes", "requires", "contributes", "providesCommands"]) requireArray(manifest2[field], field); + for (const field of ["sites", "routes", "contributes", "providesCommands"]) requireUniqueStrings(manifest2[field], field); + requireUniqueTokens(manifest2.requires, "requires"); if (typeof manifest2.activate !== "function") throw new TypeError("Feature activate must be a function"); + if (typeof manifest2.disableable !== "boolean") throw new TypeError("Feature disableable must be explicit"); if (manifest2.kind === "system" && manifest2.disableable !== false) throw new TypeError("System features cannot be disableable"); return Object.freeze({ ...manifest2 }); } @@ -5506,8 +5522,8 @@ requireNonEmptyString(manifest2.id, "Contribution id"); requireNonEmptyString(manifest2.featureId, "Contribution featureId"); requireNonEmptyString(manifest2.legacyPluginId, "Contribution legacyPluginId"); - requireArray(manifest2.sites, "sites"); - requireArray(manifest2.requires, "requires"); + requireUniqueStrings(manifest2.sites, "sites"); + requireUniqueTokens(manifest2.requires, "requires"); if (typeof manifest2.plugin !== "function") throw new TypeError("Contribution plugin must be a class"); if (!manifest2.order || typeof manifest2.order !== "object") throw new TypeError("Contribution order must be explicit"); return Object.freeze({ ...manifest2, sites: Object.freeze([...manifest2.sites]), order: Object.freeze({ ...manifest2.order }) }); @@ -5517,11 +5533,25 @@ requireNonEmptyString(manifest2.id, "Integration id"); if (!TRUST_CLASSES.has(String(manifest2.trustClass))) throw new TypeError("Integration trustClass is invalid"); if (!QUALITY_LEVELS.has(String(manifest2.quality))) throw new TypeError("Integration quality is invalid"); - for (const field of ["hosts", "capabilities", "requires"]) requireArray(manifest2[field], field); + requireUniqueStrings(manifest2.hosts, "hosts"); + requireUniqueStrings(manifest2.capabilities, "capabilities"); + requireUniqueTokens(manifest2.requires, "requires"); const hosts = manifest2.hosts; const capabilities = manifest2.capabilities; if (hosts.length === 0 || capabilities.length === 0) throw new TypeError("Integration hosts and capabilities cannot be empty"); + for (const host of hosts) { + const value = String(host); + if (value !== value.toLowerCase() || new URL(`https://${value}`).hostname !== value) throw new TypeError(`Integration host is invalid: ${value}`); + } if (manifest2.cachePolicy === void 0) throw new TypeError("Integration cachePolicy must be explicit"); + if (manifest2.cachePolicy !== "none") { + if (!manifest2.cachePolicy || typeof manifest2.cachePolicy !== "object" || Array.isArray(manifest2.cachePolicy)) throw new TypeError("Integration cachePolicy must be none or a capability map"); + const policy = manifest2.cachePolicy; + const missing = capabilities.filter((capability) => !Object.hasOwn(policy, String(capability))); + const extra = Object.keys(policy).filter((capability) => !capabilities.includes(capability)); + if (missing.length || extra.length) throw new TypeError(`Integration cachePolicy mismatch (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`); + for (const [capability, value] of Object.entries(policy)) requireNonEmptyString(value, `cachePolicy.${capability}`); + } if (typeof manifest2.createClient !== "function" || typeof manifest2.createAdapter !== "function") { throw new TypeError("Integration client and adapter factories are required"); } @@ -18359,7 +18389,14 @@ ${failure.stack}` : ""); } resolveDeclared(requiredTokens) { const dependencies = /* @__PURE__ */ Object.create(null); + const seen = /* @__PURE__ */ new Set(); for (const token of requiredTokens) { + if (seen.has(token)) { + const error = new JhsError("DUPLICATE_TOKEN", `Duplicate declared dependency: ${String(token)}`, { source: "DependencyContainer" }); + this.diagnostics?.recordError?.(error); + throw error; + } + seen.add(token); if (!this.values.has(token)) { const error = new JhsError("MISSING_DEPENDENCY", `Missing declared dependency: ${String(token)}`, { source: "DependencyContainer" }); this.diagnostics?.recordError?.(error); @@ -19568,7 +19605,7 @@ ${failure.stack}` : ""); createClient: /* @__PURE__ */ __name((dependencies) => Object.freeze({ http: dependencies[SERVICE.http], hostAdapter: dependencies[PORT.javdbHost] }), "createClient"), createAdapter: /* @__PURE__ */ __name((client) => createJavDbAdapter(client.http, createJavDbSignature, client.hostAdapter), "createAdapter"), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.magnets": "public-1d", "movie.ranking": "public-1d", "movie.reviews": "public-1d", "movie.related": "public-1d", "actor.movies": "public-5m", "actor.collection": "none", "actor.uncollect": "none", "account.login": "none" }, + cachePolicy: { "movie.search": "none", "movie.detail": CACHE.externalDetail, "movie.magnets": "public-1d", "movie.ranking": "public-1d", "movie.state": "none", "movie.reviews": "public-1d", "movie.related": "public-1d", "actor.lookup": "none", "actor.movies": "public-5m", "actor.collection": "none", "actor.uncollect": "none", "actor.avatar-placeholder": "none", "account.login": "none" }, quality: "silver" }); diff --git a/scripts/integration-quality-check.mjs b/scripts/integration-quality-check.mjs index f5af0bbd..e814b28c 100644 --- a/scripts/integration-quality-check.mjs +++ b/scripts/integration-quality-check.mjs @@ -39,6 +39,19 @@ for (const entry of entries) { if (!allowedQualities.has(manifest?.quality)) errors.push(`${entry.name}: invalid quality`); if (!Array.isArray(manifest?.capabilities) || manifest.capabilities.length === 0) errors.push(`${entry.name}: no capabilities`); if (manifest?.cachePolicy === undefined) errors.push(`${entry.name}: cachePolicy must be explicit, including none`); + if (manifest?.cachePolicy && manifest.cachePolicy !== "none" && typeof manifest.cachePolicy === "object") { + const missing = manifest.capabilities.filter((capability) => !Object.hasOwn(manifest.cachePolicy, capability)); + const extra = Object.keys(manifest.cachePolicy).filter((capability) => !manifest.capabilities.includes(capability)); + if (missing.length || extra.length) errors.push(`${entry.name}: cachePolicy must explicitly cover exactly its capabilities`); + } + try { + const adapter = manifest.createAdapter(manifest.createClient({}), {}); + if (!Array.isArray(adapter?.contracts) || adapter.contracts.length === 0 || adapter.contracts.some((contract) => typeof contract !== "string" || !contract.trim())) { + errors.push(`${entry.name}: adapter must declare at least one normalized contract`); + } + } catch (error) { + errors.push(`${entry.name}: adapter cannot be constructed for contract inspection (${error?.message || error})`); + } const parserTest = path.join(rootDir, "tests", "integrations", `${entry.name}.contract.test.js`); const fixture = path.join(rootDir, "tests", "fixtures", "integrations", entry.name); if (!(await exists(parserTest))) errors.push(`${entry.name}: missing contract test`); diff --git a/src/app/dependency-container.js b/src/app/dependency-container.js index 6724eef8..142f5c45 100644 --- a/src/app/dependency-container.js +++ b/src/app/dependency-container.js @@ -25,7 +25,14 @@ export class DependencyContainer { /** @param {readonly symbol[]} requiredTokens */ resolveDeclared(requiredTokens) { const dependencies = Object.create(null); + const seen = new Set(); for (const token of requiredTokens) { + if (seen.has(token)) { + const error = new JhsError("DUPLICATE_TOKEN", `Duplicate declared dependency: ${String(token)}`, { source: "DependencyContainer" }); + this.diagnostics?.recordError?.(error); + throw error; + } + seen.add(token); if (!this.values.has(token)) { const error = new JhsError("MISSING_DEPENDENCY", `Missing declared dependency: ${String(token)}`, { source: "DependencyContainer" }); this.diagnostics?.recordError?.(error); diff --git a/src/contracts/manifests.js b/src/contracts/manifests.js index 18dcc2fb..20cd4875 100644 --- a/src/contracts/manifests.js +++ b/src/contracts/manifests.js @@ -15,13 +15,31 @@ function requireArray(value, field) { if (!Array.isArray(value)) throw new TypeError(`${field} must be an array`); } +/** @param {unknown} value @param {string} field */ +function requireUniqueStrings(value, field) { + requireArray(value, field); + const items = /** @type {unknown[]} */ (value); + for (const item of items) requireNonEmptyString(item, `${field} item`); + if (new Set(items).size !== items.length) throw new TypeError(`${field} cannot contain duplicates`); +} + +/** @param {unknown} value @param {string} field */ +function requireUniqueTokens(value, field) { + requireArray(value, field); + const items = /** @type {unknown[]} */ (value); + if (items.some((item) => typeof item !== "symbol")) throw new TypeError(`${field} must contain dependency tokens`); + if (new Set(items).size !== items.length) throw new TypeError(`${field} cannot contain duplicate tokens`); +} + /** @param {Record} manifest */ export function defineFeature(manifest) { requireNonEmptyString(manifest.id, "Feature id"); if (!FEATURE_KINDS.has(String(manifest.kind))) throw new TypeError("Feature kind must be system or feature"); if (!STARTUP_MODES.has(String(manifest.startup))) throw new TypeError("Feature startup mode is invalid"); - for (const field of ["sites", "routes", "requires", "contributes", "providesCommands"]) requireArray(manifest[field], field); + for (const field of ["sites", "routes", "contributes", "providesCommands"]) requireUniqueStrings(manifest[field], field); + requireUniqueTokens(manifest.requires, "requires"); if (typeof manifest.activate !== "function") throw new TypeError("Feature activate must be a function"); + if (typeof manifest.disableable !== "boolean") throw new TypeError("Feature disableable must be explicit"); if (manifest.kind === "system" && manifest.disableable !== false) throw new TypeError("System features cannot be disableable"); return Object.freeze({ ...manifest }); } @@ -31,8 +49,8 @@ export function defineContribution(manifest) { requireNonEmptyString(manifest.id, "Contribution id"); requireNonEmptyString(manifest.featureId, "Contribution featureId"); requireNonEmptyString(manifest.legacyPluginId, "Contribution legacyPluginId"); - requireArray(manifest.sites, "sites"); - requireArray(manifest.requires, "requires"); + requireUniqueStrings(manifest.sites, "sites"); + requireUniqueTokens(manifest.requires, "requires"); if (typeof manifest.plugin !== "function") throw new TypeError("Contribution plugin must be a class"); if (!manifest.order || typeof manifest.order !== "object") throw new TypeError("Contribution order must be explicit"); return Object.freeze({ ...manifest, sites: Object.freeze([...(/** @type {unknown[]} */ (manifest.sites))]), order: Object.freeze({ ...manifest.order }) }); @@ -43,11 +61,25 @@ export function defineIntegration(manifest) { requireNonEmptyString(manifest.id, "Integration id"); if (!TRUST_CLASSES.has(String(manifest.trustClass))) throw new TypeError("Integration trustClass is invalid"); if (!QUALITY_LEVELS.has(String(manifest.quality))) throw new TypeError("Integration quality is invalid"); - for (const field of ["hosts", "capabilities", "requires"]) requireArray(manifest[field], field); + requireUniqueStrings(manifest.hosts, "hosts"); + requireUniqueStrings(manifest.capabilities, "capabilities"); + requireUniqueTokens(manifest.requires, "requires"); const hosts = /** @type {unknown[]} */ (manifest.hosts); const capabilities = /** @type {unknown[]} */ (manifest.capabilities); if (hosts.length === 0 || capabilities.length === 0) throw new TypeError("Integration hosts and capabilities cannot be empty"); + for (const host of hosts) { + const value = String(host); + if (value !== value.toLowerCase() || new URL(`https://${value}`).hostname !== value) throw new TypeError(`Integration host is invalid: ${value}`); + } if (manifest.cachePolicy === undefined) throw new TypeError("Integration cachePolicy must be explicit"); + if (manifest.cachePolicy !== "none") { + if (!manifest.cachePolicy || typeof manifest.cachePolicy !== "object" || Array.isArray(manifest.cachePolicy)) throw new TypeError("Integration cachePolicy must be none or a capability map"); + const policy = /** @type {Record} */ (manifest.cachePolicy); + const missing = capabilities.filter((capability) => !Object.hasOwn(policy, String(capability))); + const extra = Object.keys(policy).filter((capability) => !capabilities.includes(capability)); + if (missing.length || extra.length) throw new TypeError(`Integration cachePolicy mismatch (missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"})`); + for (const [capability, value] of Object.entries(policy)) requireNonEmptyString(value, `cachePolicy.${capability}`); + } if (typeof manifest.createClient !== "function" || typeof manifest.createAdapter !== "function") { throw new TypeError("Integration client and adapter factories are required"); } diff --git a/src/integrations/javdb/manifest.js b/src/integrations/javdb/manifest.js index f1d46da1..941a31e5 100644 --- a/src/integrations/javdb/manifest.js +++ b/src/integrations/javdb/manifest.js @@ -154,5 +154,5 @@ export default defineIntegration({ requires: [SERVICE.http, PORT.javdbHost], createClient: (/** @type {any} */ dependencies) => Object.freeze({ http: dependencies[SERVICE.http], hostAdapter: dependencies[PORT.javdbHost] }), createAdapter: (/** @type {any} */ client) => createJavDbAdapter(client.http, createJavDbSignature, client.hostAdapter), createHostAdapter: null, - cachePolicy: { "movie.detail": CACHE.externalDetail, "movie.magnets": "public-1d", "movie.ranking": "public-1d", "movie.reviews": "public-1d", "movie.related": "public-1d", "actor.movies": "public-5m", "actor.collection": "none", "actor.uncollect": "none", "account.login": "none" }, quality: "silver", + cachePolicy: { "movie.search": "none", "movie.detail": CACHE.externalDetail, "movie.magnets": "public-1d", "movie.ranking": "public-1d", "movie.state": "none", "movie.reviews": "public-1d", "movie.related": "public-1d", "actor.lookup": "none", "actor.movies": "public-5m", "actor.collection": "none", "actor.uncollect": "none", "actor.avatar-placeholder": "none", "account.login": "none" }, quality: "silver", }); diff --git a/tests/architecture-runtime.test.js b/tests/architecture-runtime.test.js index add1060b..eab3ed20 100644 --- a/tests/architecture-runtime.test.js +++ b/tests/architecture-runtime.test.js @@ -22,6 +22,7 @@ describe("v6.5 architecture runtime contracts", () => { expect(container.resolveDeclared([SERVICE.movie])[SERVICE.review]).toBeUndefined(); expect(() => container.register(SERVICE.movie, {})).toThrow(/Duplicate/); expect(() => container.resolveDeclared([SERVICE.review])).toThrow(/Missing/); + expect(() => container.resolveDeclared([SERVICE.movie, SERVICE.movie])).toThrow(/Duplicate declared dependency/); }); it("limits transitional legacy plugins to explicitly declared dependencies", () => { @@ -156,6 +157,9 @@ describe("v6.5 architecture runtime contracts", () => { it("validates integration manifests and keeps ProviderRegistry focused", async () => { expect(() => defineIntegration({ id: "bad", trustClass: "builtin-public", hosts: [], capabilities: [], requires: [], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(); + expect(() => defineIntegration({ id: "bad-host", trustClass: "builtin-public", hosts: ["HTTPS://EXAMPLE.COM"], capabilities: ["movie.detail"], requires: [], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(/host is invalid/); + expect(() => defineIntegration({ id: "bad-cache", trustClass: "builtin-public", hosts: ["example.com"], capabilities: ["movie.detail", "movie.images"], requires: [], cachePolicy: { "movie.detail": "none" }, quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(/cachePolicy mismatch/); + expect(() => defineIntegration({ id: "bad-deps", trustClass: "builtin-public", hosts: ["example.com"], capabilities: ["movie.detail"], requires: [SERVICE.http, SERVICE.http], cachePolicy: "none", quality: "bronze", createClient() {}, createAdapter() {} })).toThrow(/duplicate tokens/); const registry = new ProviderRegistry(); registry.register({ id: "slow", capabilities: ["magnet"], priority: 1 }); registry.register({ id: "fast", capabilities: ["magnet"], priority: 2, isAvailable: async () => true }); From dd956baadf201c4628f278d57303ae03b674b565 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:47:06 +0800 Subject: [PATCH 062/130] =?UTF-8?q?Test:=20=E6=89=A9=E5=B1=95=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E7=AB=99=E7=82=B9=20Browser=20Smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改动内容: - 增加 JavDB/JavBus 脱敏列表 fixture 与真实 origin 路由 - 覆盖 List HostAdapter、移动 FAB、横屏 compact profile 和无横向溢出 - MobileBottomBar 改用声明注入的 ProfileService,并修复 JavBus masonry box sizing 修改原因: - 原 Browser Harness 仅覆盖详情路由,未验证列表和移动 Profile 的真实布局行为 验证: - npm run check 通过(117 个文件、615 项测试) - npm run check:browser 通过(本机 Edge,27 passed、15 expected skips) - git diff --check 与 codegraph sync 通过 - 根目录与 dist 用户脚本 SHA256 一致 --- CHANGELOG.md | 3 +++ JHS.user.js | 7 ++++--- src/core/css-injection.js | 2 +- src/plugins/registry.js | 3 ++- src/plugins/status/mobile-bottom-bar.js | 2 +- tests/architecture-runtime.test.js | 2 +- tests/browser/fixtures/javbus-detail.html | 2 +- tests/browser/fixtures/javbus-list.html | 19 +++++++++++++++++++ tests/browser/fixtures/javdb-detail.html | 2 +- tests/browser/fixtures/javdb-list.html | 23 +++++++++++++++++++++++ tests/browser/harness/runtime.js | 6 ++++-- tests/browser/specs/host-routing.spec.js | 19 +++++++++++++++++++ 12 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 tests/browser/fixtures/javbus-list.html create mode 100644 tests/browser/fixtures/javdb-list.html diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e5602e..627eba12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - TaskPlugin 与 ListPagePlugin 真实运行时进入 strict checkJs,明确调度状态/结果、配置刷新、网络分页、列表增量索引、筛选视图和媒体导航边界;生产源码类型覆盖率提升至 95.5%,RC 最低覆盖门禁上调至 95%。 - Detail 的 legacy Contribution 改为一插件一 ID;封面状态按钮、详情状态按钮、JavDB 预览以及 JavBus 原生详情/图片/预览现在保持各自独立的旧版禁用语义。 - Transitional PluginManager 的不可禁用属性改由 system Feature manifest 下发,删除 Settings、Stats 与移动工具栏的名称硬编码保护名单。 +- MobileBottomBar Contribution 显式依赖 ProfileService,并以 compact profile 取代 legacy User-Agent/宽度判断,触屏横屏设备可按 any-pointer 与短边规则正确激活。 - Feature/Contribution/Integration manifest schema 现在拒绝空值、重复 ID/token 和非法 host;Integration cache policy 必须精确覆盖全部 capability,Adapter 必须声明 normalized contract,JavDB 补齐四项显式 no-cache 声明。 +- Browser Harness 增加 JavDB/JavBus 脱敏列表 fixture,以真实站点 origin 覆盖 List route、HostAdapter、列表运行时、移动工具入口与横向溢出。 - 新增可单测的 Bootstrap Purity 架构门禁,禁止除 Composition Root 外的生产模块在导入阶段启动 DOM、网络、Storage、监听器、Observer 或定时器;原隐藏导航样式改为 Bootstrap 注入时才读取地址。 - strict checkJs 继续覆盖自动翻页、设置样式/模板和 123AV-FC2 入口,并补齐分页空值、DOM 容器与 Integration 返回数据的显式契约。 - 截图、宿主详情工作区、兼容增强与统一离线模块进入 strict checkJs;Provider availability、宿主资源边界、Observer 和 jQuery 兼容句柄均补充显式类型,覆盖率门禁同步上调至 84%。 @@ -31,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ ### Fixed +- 修复 JavBus masonry 在宿主未提供全局 border-box 时因 `width:100%` 叠加水平 padding 产生横向溢出的问题。 - 修复 ESM 单入口迁移后核心主题、宿主布局与 UI primitive 样式未进入启动链的问题;样式模块现在保持导入无副作用,并由 Bootstrap 显式一次性注入。 - 重新整理 FC2 与 123AV-FC2 详情页:影片信息、剧照、资源、评论和相关清单现在各有固定位置,关闭页面后旧请求不会再改动界面;同时补回来源链接、高清/字幕/日期标记与磁力筛选,123AV 详情也能加载对应的 JavDB 磁力。 - 修复 FC2 详情来源识别、局部重试、标题右键关闭和移动端资源显示问题;更多磁力来源现在可以收起并复用已加载结果,空截图或空外部资源也不会留下多余区域。 diff --git a/JHS.user.js b/JHS.user.js index f91572d2..eb2c7980 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -1829,7 +1829,7 @@ `; } async handle() { - window.isListPage && (this.addSvgBtn(), await this.bindClick()); + if (!window.isListPage) return; + const scope = await this.getRuntimeService("scope")(); + this.addSvgBtn(); + await this.bindClick(scope); } buildToolBox() { return ` @@ -12182,23 +12212,27 @@ ${failure.stack}` : ""); const openMenus = $(".jhs-card-menu.is-open"), triggers = openMenus.siblings(".jhs-card-menu-trigger"); openMenus.removeClass("is-open"), triggers.attr("aria-expanded", "false"), focus && triggers.first().trigger("focus"); } - async bindClick() { + async bindClick(scope) { this.getSelector(); - const e2 = this.getDependency("ListPagePlugin"); - $(document).on("click", ".jhs-card-menu-trigger", ((event) => { + const e2 = this.getOptionalDependency("ListPagePlugin"); + if (!e2) return; + const documentRoot = $(document); + documentRoot.off(".jhsCoverButton"); + scope.addCleanup((() => documentRoot.off(".jhsCoverButton"))); + documentRoot.on("click.jhsCoverButton", ".jhs-card-menu-trigger", ((event) => { event.preventDefault(), event.stopPropagation(); const trigger = $(event.currentTarget), menu = trigger.siblings(".jhs-card-menu"), open = !menu.hasClass("is-open"); this.closeCardMenus(), menu.toggleClass("is-open", open), trigger.attr("aria-expanded", String(open)), open && menu.children().first().trigger("focus"); - })).on("keydown", ".jhs-card-menu [role='menuitem']", ((event) => { + })).on("keydown.jhsCoverButton", ".jhs-card-menu [role='menuitem']", ((event) => { const menu = $(event.currentTarget).closest(".jhs-card-menu"), items = menu.find("[role='menuitem']"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), this.closeCardMenus(true); if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ((event) => { + })).on("click.jhsCoverButton", ((event) => { $(event.target).closest(".more-tools-container").length || this.closeCardMenus(); - })), $(document).on("click", ".videoSvg", ((t2) => { + })), documentRoot.on("click.jhsCoverButton", ".videoSvg", ((t2) => { t2.preventDefault(), $('.videoSvg[title!="播放视频"]').each(((t3, n4) => { const a4 = $(n4); let i3 = a4.closest(".item"), s3 = i3.find("img"), { carNum: o2 } = e2.findCarNumAndHref(i3); @@ -12212,7 +12246,7 @@ ${failure.stack}` : ""); if (!i3.length) return void show.error("没有找到图片"); void this.showVideo(a3, i3, t3).catch(((error) => clog.error("卡片预览视频打开失败", error))); } - })), $(document).on("click", ".screenSvg", (async (t2) => { + })), documentRoot.on("click.jhsCoverButton", ".screenSvg", (async (t2) => { t2.preventDefault(); let n3 = loading(); try { @@ -12228,7 +12262,7 @@ ${failure.stack}` : ""); } finally { n3.close(); } - })), $(document).on("click", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", ((t2) => { + })), documentRoot.on("click.jhsCoverButton", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", ((t2) => { t2.preventDefault(), t2.stopPropagation(); try { const n3 = $(t2.currentTarget), a3 = n3.closest(".item"), { carNum: i3, url: s3, publishTime: o2, fc2Source } = e2.findCarNumAndHref(a3), r2 = /* @__PURE__ */ __name(async (t3) => { @@ -12251,7 +12285,7 @@ ${failure.stack}` : ""); const r2 = $(o2), { carNum: l2 } = e2.findCarNumAndHref(r2); r2.find(".site-jable").attr({ href: `${a2}/search/${l2}/`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-avgle").attr({ href: `${i2}/vod/search.html?wd=${l2}`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-miss-av").attr({ href: `${n2}/search/${l2}`, target: "_blank", rel: "noopener noreferrer" }), r2.find(".site-123-av").attr({ href: `${s2}/cn/search?keyword=${encodeURIComponent(l2)}`, target: "_blank", rel: "noopener noreferrer" }); })); - $(document).on("click", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", ((t2) => { + documentRoot.on("click.jhsCoverButton", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", ((t2) => { try { t2.preventDefault(), t2.stopPropagation(); const o2 = $(t2.currentTarget), r2 = o2.closest(".item"), { carNum: l2 } = e2.findCarNumAndHref(r2); @@ -12262,7 +12296,7 @@ ${failure.stack}` : ""); } catch (t3) { clog.error("站点按钮处理失败:", t3); } - })), $(document).on("click", ".titleSvg, .carNumSvg, .downSvg", ((t2) => { + })), documentRoot.on("click.jhsCoverButton", ".titleSvg, .carNumSvg, .downSvg", ((t2) => { t2.preventDefault(), t2.stopPropagation(); const n3 = $(t2.currentTarget).closest(".item"), { carNum: a3, title: i3 } = e2.findCarNumAndHref(n3), s3 = n3.find(l ? ".photo-frame img" : ".cover img"); $(t2.currentTarget).hasClass("titleSvg") ? utils.copyToClipboard("标题", i3) : $(t2.currentTarget).hasClass("carNumSvg") ? utils.copyToClipboard("番号", a3) : $(t2.currentTarget).hasClass("downSvg") && fetch(s3.attr("src")).then(((e3) => e3.blob())).then(((e3) => utils.download(e3, a3 + " " + i3 + ".jpg"))), this.closeCardMenus(); @@ -12696,13 +12730,13 @@ ${failure.stack}` : ""); $("#newVideoCount").text(`${e2}`); } async resetBtnTip() { - const storage = this.getRuntimeService("storage"), e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = storage.getLocal(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = storage.getLocal(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; + const storage = this.getRuntimeService("storage"), e2 = this.getOptionalDependency("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = e2 ? storage.getLocal(e2.lastCheckFavoriteActressTimeKey) || "无" : "任务已禁用", a2 = t2.checkFavoriteActress_IntervalTime, i2 = e2 ? storage.getLocal(e2.lastCheckNewVideoTimeKey) || "无" : "任务已禁用", s2 = t2.checkNewVideo_intervalTime; $("#checkFavoriteActress").attr("data-tip", `上次完整同步: ${n2}; 检测间隔时间: ${a2}小时`), $("#checkNewVideo").attr("data-tip", `上次整批检测: ${i2}; 检测间隔时间: ${s2}小时`); } async openDialog() { const storage = this.getRuntimeService("storage"); this.cleanupNewVideoWorkspace(), this._viewMode = "list" === storage.getLocal("jhs_newVideoViewMode") ? "list" : "actress", this.currentPage = 1, this.nvCurrentPage = 1, this.nvSelected = /* @__PURE__ */ new Set(), this.nvCoverCache = /* @__PURE__ */ new Map(), this.nvActorCoverRequests = /* @__PURE__ */ new Map(), this.nvRenderGeneration++; - const e2 = this.getDependency("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = storage.getLocal(e2.lastCheckFavoriteActressTimeKey) || "无", a2 = t2.checkFavoriteActress_IntervalTime, i2 = storage.getLocal(e2.lastCheckNewVideoTimeKey) || "无", s2 = t2.checkNewVideo_intervalTime; + const e2 = this.getOptionalDependency("TaskPlugin"), t2 = await storageManager.getSetting(), n2 = e2 ? storage.getLocal(e2.lastCheckFavoriteActressTimeKey) || "无" : "任务已禁用", a2 = t2.checkFavoriteActress_IntervalTime, i2 = e2 ? storage.getLocal(e2.lastCheckNewVideoTimeKey) || "无" : "任务已禁用", s2 = t2.checkNewVideo_intervalTime; let o2 = `
    `; - const workspaceSlot = this.getDependency("DetailWorkspacePlugin")?.getSlot("summary-actions"); + const workspaceSlot = this.getOptionalDependency("DetailWorkspacePlugin")?.getSlot?.("summary-actions"); workspaceSlot?.length ? workspaceSlot.append(n2) : r ? $(".tabs").after(n2) : l && $("#mag-submit-show").before(n2), $("#magnetSearchBtn").on("click", (async () => { - let t3 = await this.getDependency("MagnetHubPlugin").createMagnetHub(e2.carNum); + const magnetHub = this.getOptionalDependency("MagnetHubPlugin"); + if (!magnetHub) return void show.info("磁力搜索功能已禁用"); + let t3 = await magnetHub.createMagnetHub(e2.carNum); this.getRuntimeService("dialog").open({ type: 1, title: "磁力搜索 " + e2.carNum, @@ -14690,9 +14732,10 @@ ${failure.stack}` : ""); }, "success") }); })); - const a2 = this.getDependency("HighlightMagnetPlugin"), i2 = await storageManager.getSetting("enableMagnetsFilter", _); - $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2.doFilterMagnet(), $("#enable-magnets-filter").on("click", ((e3) => { + const a2 = this.getOptionalDependency("HighlightMagnetPlugin"), i2 = await storageManager.getSetting("enableMagnetsFilter", _); + a2 || $("#enable-magnets-filter").remove(), $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2?.doFilterMagnet?.(), $("#enable-magnets-filter").on("click", ((e3) => { let t3 = $("#magnets-span"); + if (!a2) return; "关闭磁力过滤" === t3.text() ? (a2.showAll(), t3.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a2.doFilterMagnet(), t3.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); })), $("#search-subtitle-btn").on("click", ((e3) => { const target = this.getRuntimeService("movie").sourceUrls({ carNum: t2 }, ["subtitlecat"])[0]?.url; @@ -14808,10 +14851,12 @@ ${failure.stack}` : ""); e2 && e2.preventDefault(); return this.getDetailStateController().requestToggle(this.getStateBinding(), "blocked", e2); } - hideVideoControls() { - $(document).on("mouseenter", "#preview-video", ((event) => { + hideVideoControls(scope) { + const documentRoot = $(document); + documentRoot.off("mouseenter.jhsDetailVideo").on("mouseenter.jhsDetailVideo", "#preview-video", ((event) => { $(event.currentTarget).prop("controls", true); })); + scope.addCleanup((() => documentRoot.off("mouseenter.jhsDetailVideo"))); } async previewSubtitle(subtitle, t2) { if (!subtitle?.url) return void clog.error("未提供文件URL"); @@ -15138,6 +15183,18 @@ ${failure.stack}` : ""); getName() { return "HistoryPlugin"; } + getSourceLabel(value) { + if (!value) return ""; + const movie = this.getRuntimeService("movie"), settings = this.getRuntimeService("settings").snapshot(); + if (movie.matchesProviderUrl("av123", value)) return "123AV"; + try { + const origin = new URL(value).origin; + if (origin === movie.externalSiteOrigin("javDbBtn", settings)) return "JavDB"; + if (origin === movie.externalSiteOrigin("javBusBtn", settings)) return "JavBus"; + } catch { + } + return value.includes("javdb") ? "JavDB" : value.includes("javbus") ? "JavBus" : value.includes("123av") ? "123AV" : "其他"; + } get historyRepository() { return this._historyRepository || (this._historyRepository = new HistoryRepository({ storage: storageManager, state: this.getRuntimeService("state") })); } @@ -15236,9 +15293,10 @@ ${failure.stack}` : ""); await utils.copyToClipboard("离线资源", $(event.currentTarget).data("resource")); })).on("click", ".jhs-retry-offline", (async (event) => { const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry) => entry.id === id); - item && await this.getDependency("UnifiedOfflinePlugin").submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); + const offline = this.getOptionalDependency("UnifiedOfflinePlugin"); + item && offline && await offline.submitResource(event, item.resource, $(event.currentTarget), { carNum: item.carNum }, item.id, { forceAvailabilityRefresh: true, preferredProviderId: item.providerId }), await this.renderOfflineHistory(); })).on("click", ".jhs-open-offline", (async (event) => { - const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry) => entry.id === id), provider = this.getDependency("UnifiedOfflinePlugin").registry.providers.get(item?.providerId), url = provider?.openUrl?.(); + const id = $(event.currentTarget).data("id"), item = (await this.historyRepository.offline()).find((entry) => entry.id === id), provider = this.getOptionalDependency("UnifiedOfflinePlugin")?.registry?.providers?.get(item?.providerId), url = provider?.openUrl?.(); url && window.open(url, "_blank", "noopener,noreferrer"); })).on("click", ".jhs-delete-offline", (async (event) => { await this.historyRepository.removeOffline($(event.currentTarget).data("id")), await this.renderOfflineHistory(); @@ -15530,7 +15588,7 @@ ${failure.stack}` : ""); hozAlign: "left", formatter: /* @__PURE__ */ __name((e2, t2, n2) => { let a2 = e2.getData().url; - return a2 ? `${a2.includes("javdb") ? "JavDB" : a2.includes("javbus") ? "JavBus" : a2.includes("123av") ? "123AV" : "其他"}` : ""; + return a2 ? `${this.getSourceLabel(a2)}` : ""; }, "formatter") }, { title: "状态", @@ -15617,12 +15675,14 @@ ${failure.stack}` : ""); } handleDelete(e2, t2) { utils.q(e2, `是否移除${t2}?`, (async () => { - await this.historyRepository.remove(t2), this.getDependency("ListPagePlugin").showCarNumBox(t2), await this.reloadTable(); + await this.historyRepository.remove(t2), this.getOptionalDependency("ListPagePlugin")?.showCarNumBox?.(t2), await this.reloadTable(); })); } async handleClickDetail(e2, t2) { if (t2.carNum.includes("FC2-")) { - const plugin = this.getDependency("Fc2Plugin"), source = await plugin.resolveFc2Source(t2), movieId = await plugin.resolveMovieIdForRecord(t2.carNum, t2.url); + const plugin = this.getOptionalDependency("Fc2Plugin"); + if (!plugin) return t2.url ? void utils.openPage(t2.url, t2.carNum, false, e2) : void show.info("FC2 详情功能已禁用"); + const source = await plugin.resolveFc2Source(t2), movieId = await plugin.resolveMovieIdForRecord(t2.carNum, t2.url); if (r) plugin.openFc2Dialog(movieId, t2.carNum, t2.url, { source }); else if (l) await plugin.openFc2Page(movieId, t2.carNum, t2.url, { newTab: true }, { source }); return; @@ -15784,13 +15844,14 @@ ${failure.stack}` : ""); $("#waitCheckBtn").on("click", ((e3) => { void this.openWaitCheck().catch(((error) => clog.error("待鉴定列表打开失败", error))); })), $("#newVideoBtn").on("click", ((e3) => { - this.getDependency("NewVideoPlugin").openDialog(); + this.getOptionalDependency("NewVideoPlugin")?.openDialog?.(); })), $("#blacklistBtn").on("click", ((e3) => { - this.getDependency("BlacklistPlugin").openBlacklistDialog(); + this.getOptionalDependency("BlacklistPlugin")?.openBlacklistDialog?.(); })), this.bindSortMenu(); - const e2 = this.getDependency("BlacklistPlugin"); + const e2 = this.getOptionalDependency("BlacklistPlugin"); + e2 || $("#blacklistBtn,#addBlacklistBtn,#filterAllVideo,#favoriteAllVideo,#hasDownAllVideo").prop("disabled", true).attr("title", "黑名单功能已禁用"); $("#addBlacklistBtn").on("click", (async (t2) => { - await e2.addBlacklist(t2); + await e2?.addBlacklist?.(t2); })), $("#filterAllVideo").on("click", (async (t2) => { let n2 = { clientX: t2.clientX, @@ -15801,7 +15862,7 @@ ${failure.stack}` : ""); utils.q(n2, "一键屏蔽视频列表?", (async () => { this.loadObj = loading(); try { - await e2.filterAllVideo(i2); + await e2?.filterAllVideo?.(i2); } catch (t3) { clog.error(t3); } finally { @@ -15815,7 +15876,7 @@ ${failure.stack}` : ""); utils.q(n2, "一键收藏所有可见作品?", (async () => { this.loadObj = loading(); try { - await e2.batchSaveAllVideos(i2, h); + await e2?.batchSaveAllVideos?.(i2, h); } catch (t3) { clog.error(t3); } finally { @@ -15829,7 +15890,7 @@ ${failure.stack}` : ""); utils.q(n2, "一键已下载所有可见作品?", (async () => { this.loadObj = loading(); try { - await e2.batchSaveAllVideos(i2, g); + await e2?.batchSaveAllVideos?.(i2, g); } catch (t3) { clog.error(t3); } finally { @@ -15900,7 +15961,8 @@ ${failure.stack}` : ""); let e2 = this.getSelector(); const t2 = await storageManager.getSetting("waitCheckCount", 5); let a2 = 0; - const listPage = this.getDependency("ListPagePlugin"); + const listPage = this.getOptionalDependency("ListPagePlugin"); + if (!listPage) return void show.info("列表功能已禁用"); for (const element of $(e2.itemSelector).toArray()) { if (a2 >= t2) break; const item = $(element), flags = normalizeStateFlags(JSON.parse(item.attr("data-jhs-flags") || "{}")), visibilityReasons = JSON.parse(item.attr("data-jhs-visibility") || "{}"); @@ -16017,15 +16079,15 @@ ${failure.stack}` : ""); const scope = await this.getRuntimeService("scope")(); const refreshAll = /* @__PURE__ */ __name(async () => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key), await this.doFilter(), this.applyVisibility(); - const e2 = this.getDependency("HistoryPlugin"); - e2.tableObj && e2.tableObj.setData(); + const e2 = this.getOptionalDependency("HistoryPlugin"); + e2?.tableObj && e2.tableObj.setData(); }, "refreshAll"); ["legacy-refresh", "blacklist-rules-changed", "filter-rules-changed", "settings-changed"].forEach(((type) => scope.addCleanup(getListEventBus().on(type, refreshAll)))), scope.addCleanup(getListEventBus().on("car-state-changed", (async (payload) => { this.filterContext = null, storageManager._invalidateCache(storageManager.car_list_key); const items = this.getIndexedItems(payload.carNums || []); items.length && (await this.doFilterItems(items), this.applyVisibility(items)); - const history = this.getDependency("HistoryPlugin"); - history.tableObj && history.tableObj.setData(); + const history = this.getOptionalDependency("HistoryPlugin"); + history?.tableObj && history.tableObj.setData(); }))), this.cleanRepeatId(), this.replaceHdImg(), this.addJumpPageControl(), this.fixBusTitleBox(), await this.doFilter(), await this.createQuickFilter(), this.applyVisibility(), await this.bindClick(), this.rememberTagExpand(), $(this.getSelector().itemSelector).attr("data-jhs-processed", "true"), this.rebuildItemIndex(), await getListEventBus().emit("list-items-added", { items: $(this.getSelector().itemSelector).toArray() }, { broadcast: false }), this.checkDom(scope), scope.addCleanup((() => { this.processTimer && clearTimeout(this.processTimer), this.processTimer = null, this.pendingItems.clear(); this.hdImageObserver?.disconnect(), this.hdImageObserver = null; @@ -16125,7 +16187,7 @@ ${failure.stack}` : ""); } async processAddedItems(items) { const selector = this.getSelector(), covers = items.flatMap((item) => [...item.querySelectorAll(selector.coverImgSelector)]); - this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getDependency("ListPageButtonPlugin").sortItems(), await this.getDependency("CoverButtonPlugin").addSvgBtn(items), items.forEach((item) => item.dataset.jhsProcessed = "true"), this.indexItems(items), await getListEventBus().emit("list-items-added", { items }, { broadcast: false }), this.getDependency("AutoPagePlugin").checkLoad(); + this.replaceHdImg(covers), this.addJumpPageControl(), this.fixBusTitleBox(items), await this.doFilterItems(items), this.applyVisibility(items), await this.getOptionalDependency("ListPageButtonPlugin")?.sortItems?.(), await this.getOptionalDependency("CoverButtonPlugin")?.addSvgBtn?.(items), items.forEach((item) => item.dataset.jhsProcessed = "true"), this.indexItems(items), await getListEventBus().emit("list-items-added", { items }, { broadcast: false }), this.getOptionalDependency("AutoPagePlugin")?.checkLoad?.(); } rebuildItemIndex() { this.itemIndex.clear(), this.indexItems($(this.getSelector().itemSelector).toArray()); @@ -16192,7 +16254,7 @@ ${failure.stack}` : ""); if (!window.isListPage) return; let e2 = items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray(); e2.length && (await this.filterMovieList(e2), l && setTimeout((() => { - this.getDependency("BusImgPlugin").logImageHeightsByRow().catch((e3) => clog.error("JavBus图片高度修正失败", e3)); + this.getOptionalDependency("BusImgPlugin")?.logImageHeightsByRow?.().catch((e3) => clog.error("JavBus图片高度修正失败", e3)); }))); } async yieldListFrame() { @@ -16384,7 +16446,9 @@ ${failure.stack}` : ""); if (!carNum || !aHref) return; const shouldOpenTab = newTab || !!event && (event.ctrlKey || event.metaKey || 1 === event.button); if (carNum.includes("FC2-")) { - const plugin = this.getDependency("Fc2Plugin"), movieId = await plugin.resolveMovieIdForRecord(carNum, aHref), source = fc2Source || await plugin.resolveFc2Source({ url: aHref }); + const plugin = this.getOptionalDependency("Fc2Plugin"); + if (!plugin) return utils.openPage(aHref, carNum, true, { event, newTab: shouldOpenTab }); + const movieId = await plugin.resolveMovieIdForRecord(carNum, aHref), source = fc2Source || await plugin.resolveFc2Source({ url: aHref }); return shouldOpenTab ? plugin.openFc2Page(movieId, carNum, aHref, { event, newTab: true }, { source }) : plugin.openFc2Dialog(movieId, carNum, aHref, { source }); } const destination = new URL(aHref, window.location.origin); @@ -16401,7 +16465,8 @@ ${failure.stack}` : ""); })); } protectFc2Navigation(root) { - const plugin = this.getDependency("Fc2Plugin"); + const plugin = this.getOptionalDependency("Fc2Plugin"); + if (!plugin) return; root.find(".item").each(((_index, element) => { const item = $(element); if (item.attr("data-jhs-fc2-protected") === "true") return; @@ -16799,7 +16864,7 @@ ${failure.stack}` : ""); $(document).off("click.jhsCommandbar").on("click.jhsCommandbar", ((event) => { $(event.target).closest(".jhs-commandbar__more, .jhs-commandbar__batch").length || (commandbar.find(".jhs-commandbar__menu").removeClass("is-open"), commandbar.find(".jhs-commandbar__menu-toggle").attr("aria-expanded", "false")); })); - this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); + this.getOptionalDependency("ListPagePlugin")?.syncQuickFilterUi?.(); } getCarNum() { try { @@ -16813,7 +16878,7 @@ ${failure.stack}` : ""); const item = /* @__PURE__ */ __name((action, label, attributes = "") => ``, "item"), group = /* @__PURE__ */ __name((content) => `
    ${content}
    `, "group"), divider = ''; let items; if (window.isListPage) { - const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? requestedSortMethod : "default", sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); + const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? requestedSortMethod : "default", sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getOptionalDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; } else if (window.isDetailPage) { const statusDefs = [{ action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" }]; @@ -16869,7 +16934,7 @@ ${failure.stack}` : ""); const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }; const sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? requestedSortMethod : "default", sortLabel = sortLabels[sortMethod]; menu.find('[data-action="sort"]').text(`排序: ${sortLabel}`); - this.getDependency("ListPagePlugin")?.syncQuickFilterUi(); + this.getOptionalDependency("ListPagePlugin")?.syncQuickFilterUi?.(); } if (window.isDetailPage) void this.refreshDetailStatus().catch(((error) => clog.warn("移动端详情状态刷新失败", error))); const gen = ++this._fabGeneration; @@ -16902,7 +16967,7 @@ ${failure.stack}` : ""); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })).on("click", ".jhs-mobile-filter-option", ((event) => { - event.stopPropagation(), this.getDependency("ListPagePlugin").setQuickFilter($(event.currentTarget).data("jhs-filter")), closeMenu(true); + event.stopPropagation(), this.getOptionalDependency("ListPagePlugin")?.setQuickFilter?.($(event.currentTarget).data("jhs-filter")), closeMenu(true); })); sortMenu.on("keydown", ".jhs-mobile-sort-option", ((event) => { const items = sortMenu.find(".jhs-mobile-sort-option"), index = items.index(event.currentTarget); @@ -16914,7 +16979,7 @@ ${failure.stack}` : ""); })).on("click", ".jhs-mobile-sort-option", (async (event) => { event.stopPropagation(); const value = $(event.currentTarget).data("jhs-sort"); - await this.getRuntimeService("settings").set("sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), await this.getDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(true); + await this.getRuntimeService("settings").set("sortMethod", value), sortMenu.find(".jhs-mobile-sort-option").attr("aria-checked", "false"), $(event.currentTarget).attr("aria-checked", "true"), await this.getOptionalDependency("ListPageButtonPlugin")?.sortItems?.(), closeMenu(true); })); menu.on("click", ".jhs-fab-menu-item", (e2) => { const action = $(e2.currentTarget).data("action"); @@ -16936,13 +17001,13 @@ ${failure.stack}` : ""); switch (action) { // 列表页操作 case "check": - await this.getDependency("ListPageButtonPlugin")?.openWaitCheck?.(); + await this.getOptionalDependency("ListPageButtonPlugin")?.openWaitCheck?.(); break; case "newVideo": - this.getDependency("NewVideoPlugin")?.openDialog(); + this.getOptionalDependency("NewVideoPlugin")?.openDialog?.(); break; case "blacklist": - this.getDependency("BlacklistPlugin")?.openBlacklistDialog(); + this.getOptionalDependency("BlacklistPlugin")?.openBlacklistDialog?.(); break; case "sort": break; @@ -16970,7 +17035,7 @@ ${failure.stack}` : ""); break; // 通用 case "setting": - await this.getDependency("SettingPlugin")?.openQuickSetting(); + await this.getOptionalDependency("SettingPlugin")?.openQuickSetting(); break; case "logger": clog.openDialog?.(); @@ -17011,7 +17076,8 @@ ${failure.stack}` : ""); for (let n2 = 0; n2 < t2.length; n2++) if (-1 !== t2[n2].type.indexOf("image")) { const e3 = t2[n2].getAsFile(); $("#search-keyword").blur(); - const a2 = this.getDependency("SearchByImagePlugin"); + const a2 = this.getOptionalDependency("SearchByImagePlugin"); + if (!a2) return void show.info("以图识图功能已禁用"); return void a2.open((() => { a2.handleImageFile(e3), a2.resetSearchUI(); })); @@ -17024,7 +17090,7 @@ ${failure.stack}` : ""); let t2 = $("#search-keyword").val(), n2 = $("#search-type").val(); "" !== t2 && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t2 + "&f=" + n2 : window.open("/search?q=" + t2 + "&f=" + n2)); })), $("#search-img-btn").on("click", (() => { - this.getDependency("SearchByImagePlugin").open(); + this.getOptionalDependency("SearchByImagePlugin")?.open?.(); })); } hookOldSearch() { @@ -17032,7 +17098,7 @@ ${failure.stack}` : ""); if (!e2) return; const t2 = e2.cloneNode(true); e2.parentNode?.replaceChild(t2, e2), $("#button-search-image").attr("data-tooltip", "以图识图"), $(".search-image").on("click", ((e3) => { - this.getDependency("SearchByImagePlugin").open(); + this.getOptionalDependency("SearchByImagePlugin")?.open?.(); })); } margeNav() { @@ -17156,7 +17222,7 @@ ${failure.stack}` : ""); manifest("detail.fc2-owned", "detail", Fc2Plugin, ["javdb"], { javdb: 3 }, [SERVICE.movie, SERVICE.magnet, SERVICE.dialog, SERVICE.translation, SERVICE.settings, SERVICE.storage, SERVICE.screenshot, SERVICE.review, SERVICE.related, SERVICE.state]), manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 4 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), - manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog, SERVICE.state]), + manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog, SERVICE.movie, SERVICE.settings, SERVICE.state]), manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [PORT.host, SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog, SERVICE.storage, SERVICE.settings, SERVICE.http, SERVICE.offline, SERVICE.magnet, SERVICE.movie, SERVICE.state]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }, [SERVICE.movie]), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [PORT.host, SERVICE.movie, SERVICE.settings, SERVICE.cache]), @@ -17190,7 +17256,7 @@ ${failure.stack}` : ""); manifest("identity.javbus-navigation", "identity", BusNavBarPlugin, ["javbus"], { javbus: 7 }), manifest("detail.javbus-images", "detail", BusImgPlugin, ["javbus"], { javbus: 9 }), manifest("detail.javbus-native", "detail", BusDetailPagePlugin, ["javbus"], { javbus: 10 }), - manifest("detail.javbus-preview", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage]), + manifest("detail.javbus-preview", "detail", BusPreviewVideoPlugin, ["javbus"], { javbus: 16 }, [SERVICE.settings, SERVICE.storage, SERVICE.movie]), manifest("external-bridge.123pan", "external-bridge", OneTwoThreeOfflinePlugin, ["javdb", "javbus", "123pan"], { javdb: 0, javbus: 0, "123pan": 1 }, [SERVICE.storage]), manifest("external-bridge.javtrailers", "external-bridge", JavTrailersPlugin, ["javtrailers"], { javtrailers: 1 }), manifest("external-bridge.subtitle", "external-bridge", SubTitleCatPlugin, ["subtitlecat"], { subtitlecat: 1 }) @@ -19233,6 +19299,7 @@ ${failure.stack}` : ""); if (candidates.length) break; } catch (error) { lastError = error; + break; } } if (!candidates.length) { diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index 8c9c51bf..ced77234 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -224,7 +224,7 @@ assertIncludes(statsSource, 'action: "filter", filter: "blockedItems"', "Stats c assertIncludes(statsSource, 'title: "统计"', "Stats dialog title"); assert(!mobileSource.includes("activeQuickFilter ="), "mobile filter actions must use ListPagePlugin.setQuickFilter"); assert(!mobileSource.includes('$("#waitCheckBtn").click()'), "mobile identification must call ListPageButtonPlugin.openWaitCheck directly"); -assertIncludes(mobileSource, 'await this.getDependency("ListPageButtonPlugin")?.openWaitCheck?.()', "mobile identification API"); +assertIncludes(mobileSource, 'await this.getOptionalDependency("ListPageButtonPlugin")?.openWaitCheck?.()', "mobile identification API"); assert(!/\.jhs-commandbar__filters\s*\{[^}]*overflow-x\s*:\s*auto/.test(mobileSource), "command-bar filters must not clip popovers with horizontal overflow"); assert(!/@media \(max-width:\s*1023px\)[\s\S]*?\.jhs-page-commandbar\s*\{[^}]*overflow-x\s*:\s*auto/.test(mobileSource), "tablet command bar must wrap instead of scrolling horizontally"); assert(/@media \(max-width:\s*1023px\)[\s\S]*?\.jhs-page-commandbar\s*\{[^}]*flex-wrap\s*:\s*wrap[^}]*overflow\s*:\s*visible/.test(mobileSource), "tablet command bar must wrap with visible overflow"); diff --git a/src/integrations/dmm/manifest.js b/src/integrations/dmm/manifest.js index a5ae2b1b..0b02f3d2 100644 --- a/src/integrations/dmm/manifest.js +++ b/src/integrations/dmm/manifest.js @@ -38,7 +38,13 @@ export function createDmmAdapter(http) { candidates = parseDmmItemCandidates(response.data, carNum, keyword); hadSuccessfulRequest = true; if (candidates.length) break; - } catch (error) { lastError = error; } + } catch (error) { + // HttpService already owns transport retries. Trying every + // keyword after the provider itself failed only multiplies + // identical startup traffic. + lastError = error; + break; + } } if (!candidates.length) { if (!hadSuccessfulRequest && lastError) throw lastError; diff --git a/src/plugins/backup/setting-panels.js b/src/plugins/backup/setting-panels.js index 6157c0f2..18ce873c 100644 --- a/src/plugins/backup/setting-panels.js +++ b/src/plugins/backup/setting-panels.js @@ -153,6 +153,10 @@ export async function renderPluginMgmtPanel(diagnostics, /** @type {any} */ sett const diagnosticSnapshot = diagnostics.exportSnapshot(); const disabled = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); const allNames = diagnosticSnapshot.legacyPlugins; + const effectiveDisabledNames = (/** @type {string[]} */ values) => { + const disabledSet = new Set(values); + return allNames.filter(((/** @type {string} */ name) => disabledSet.has(name) || disabledSet.has(disabledIdForPlugin(name)))); + }; const { categories, pluginMeta } = getPluginCategories(); const pluginDescriptors = new Map((diagnosticSnapshot.legacyPluginDescriptors || []).map((/** @type {{name: string, disableable: boolean}} */ item) => [item.name, item])); const registeredSet = new Set(allNames); @@ -178,10 +182,10 @@ export async function renderPluginMgmtPanel(diagnostics, /** @type {any} */ sett html += `
    `; } $("#plugin-mgmt-list").html(html); - const enabledCount = allNames.length - disabled.length; + const effectiveDisabled = effectiveDisabledNames(disabled), enabledCount = allNames.length - effectiveDisabled.length; $("#pm-total").text(allNames.length); $("#pm-enabled").text(enabledCount); - $("#pm-disabled").text(disabled.length); + $("#pm-disabled").text(effectiveDisabled.length); $(".pm-toggle").off("change").on("change", async (/** @type {Event} */ e) => { const name = $(e.target).data("plugin"); let list = parseDisabledPlugins(await storageManager.getSetting("disabledPlugins", "[]")); @@ -193,10 +197,10 @@ export async function renderPluginMgmtPanel(diagnostics, /** @type {any} */ sett } if (!settings) throw new Error("SettingsService is unavailable"); await settings.set("disabledPlugins", JSON.stringify(list)); - const all = diagnosticSnapshot.legacyPlugins; + const all = diagnosticSnapshot.legacyPlugins, currentDisabled = effectiveDisabledNames(list); $("#pm-total").text(all.length); - $("#pm-enabled").text(all.length - list.length); - $("#pm-disabled").text(list.length); + $("#pm-enabled").text(all.length - currentDisabled.length); + $("#pm-disabled").text(currentDisabled.length); show.ok(`插件 "${name}" 已${$(e.target).is(":checked") ? "启用" : "禁用"},刷新后生效`); }); const startup = diagnosticSnapshot.legacyStartup, timings = diagnosticSnapshot.legacyTimings; diff --git a/src/plugins/backup/setting-templates.js b/src/plugins/backup/setting-templates.js index 64242072..1f5e7367 100644 --- a/src/plugins/backup/setting-templates.js +++ b/src/plugins/backup/setting-templates.js @@ -563,6 +563,7 @@ export function buildSettingDialogHtml(activePanel, cacheItems) {
    diff --git a/src/plugins/backup/setting.js b/src/plugins/backup/setting.js index 13b90456..5faed39b 100644 --- a/src/plugins/backup/setting.js +++ b/src/plugins/backup/setting.js @@ -167,14 +167,10 @@ export class SettingPlugin extends BasePlugin { if (utils.isMobileMode()) { this.collapseAdvancedTabs(); } - const sections = await Promise.allSettled([ loadSettingForm(this.getFormDependencies()), this.loadResourceSettings() ]); - JhsSelect.enhance(e); - sections.forEach(((result, index) => { - if (result.status !== "rejected") return; - const source = index === 0 ? "settings-form" : "resource-settings"; - clog.error(`${source} 加载失败`, result.reason), this.getRuntimeService("diagnostics").recordError({ source, message: result.reason?.message || String(result.reason) }); - })); - sections.some((result => result.status === "rejected")) && show.error("部分设置内容加载失败,基础操作仍可使用"); + const sections = await Promise.allSettled([ this.hydrateSettingForm(e), this.loadResourceSettings() ]), resourceResult = sections[1]; + if (resourceResult.status === "rejected") { + clog.error("resource-settings 加载失败", resourceResult.reason), this.getRuntimeService("diagnostics").recordError({ source: "resource-settings", message: resourceResult.reason?.message || String(resourceResult.reason) }), show.error("资源设置加载失败,基本设置仍可保存"); + } }, end: () => { this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = null; @@ -182,6 +178,22 @@ export class SettingPlugin extends BasePlugin { } }); } + /** @param {JQueryHandle | HTMLElement} layerRoot */ + async hydrateSettingForm(layerRoot) { + const button = $(layerRoot).find("#saveBtn"), status = $(layerRoot).find("#settings-hydration-status"); + button.attr("data-jhs-settings-ready", "false").prop("disabled", !0).attr("title", "正在加载设置…"), status.empty().text("正在加载设置…"); + try { + await loadSettingForm(this.getFormDependencies()); + if (!button[0]?.isConnected) return !1; + JhsSelect.enhance(layerRoot), button.attr("data-jhs-settings-ready", "true").prop("disabled", !1).removeAttr("title"), status.empty().text("设置已加载"); + return !0; + } catch (error) { + clog.error("settings-form 加载失败", error), this.getRuntimeService("diagnostics").recordError({ source: "settings-form", message: error?.message || String(error) }); + const retry = $('').on("click", (() => void this.hydrateSettingForm(layerRoot))); + status.empty().append(document.createTextNode("表单加载失败,已禁止保存。"), retry), show.error("设置表单加载失败,保存已禁用"); + return !1; + } + } renderTaskStatuses() { const container = $("#setting-task-status-list"); if (!container.length) return; @@ -258,7 +270,7 @@ export class SettingPlugin extends BasePlugin { } bindClick() { const settingPlugin = this, webdav = this.getRuntimeService("webdav"), dialog = this.getRuntimeService("dialog"), diagnostics = this.getRuntimeService("diagnostics"), storage = this.getRuntimeService("storage"), previewDiff = (diff, imported, restored = null) => showDiffPreview(diff, imported, restored, dialog); - $("#saveBtn").attr("data-jhs-settings-ready", "true"); + $("#saveBtn").attr("data-jhs-settings-ready", "false").prop("disabled", !0).attr("title", "正在加载设置…"); $(".side-menu-item").on("click", (function() { $(".side-menu-item").removeClass("active").attr("aria-current", "false"), $(this).addClass("active").attr("aria-current", "page"), $(".content-panel").hide(); const e = $(this).data("panel"); @@ -272,11 +284,11 @@ export class SettingPlugin extends BasePlugin { $("#webdavBackupBtn").on("click", (e => backupDataByWebDav(this.folderName, webdav))), $("#webdavBackupListBtn").on("click", (e => backupListBtnByWebDav(this.folderName, (files, client, label) => openFileListDialog(files, client, label, this.folderName, previewDiff, dialog), webdav))), $("#saveBtn").on("click", (async event => { const button = $(event.currentTarget); - if (button.data("jhsBusy")) return; + if (button.data("jhsBusy") || "true" !== button.attr("data-jhs-settings-ready")) return; button.data("jhsBusy", !0).prop("disabled", !0).attr("aria-busy", "true"); try { await saveSettingForm(this.getFormDependencies()); } catch (error) { clog.error("设置保存失败", error), show.error("设置保存失败"); } - finally { button.removeData("jhsBusy").prop("disabled", !1).removeAttr("aria-busy"); } + finally { button.removeData("jhsBusy").prop("disabled", "true" !== button.attr("data-jhs-settings-ready")).removeAttr("aria-busy"); } })), $("#runHealthCheckBtn").on("click", (() => renderDataHealthPanel())), $("#repairHealthBtn").on("click", (e => { utils.q(e, "修复前会自动下载备份,是否继续?", (() => repairDataHealthWithBackup())); diff --git a/src/plugins/blacklist/blacklist.js b/src/plugins/blacklist/blacklist.js index faea9445..ee8069ad 100644 --- a/src/plugins/blacklist/blacklist.js +++ b/src/plugins/blacklist/blacklist.js @@ -70,7 +70,8 @@ export class BlacklistPlugin extends BasePlugin { } } utils.q(t, i, (async () => { - const e = this.getDependency("TaskPlugin"); + const e = this.getOptionalDependency("TaskPlugin"); + if (!e) return void show.error("后台任务功能已禁用,无法执行黑名单抓取"); navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 }, (async (/** @type {any} */ e) => { @@ -111,12 +112,12 @@ export class BlacklistPlugin extends BasePlugin { })); } async resetBtnTip() { - const e = this.getDependency("TaskPlugin"), t = this.getRuntimeService("storage").getLocal(e.lastCheckBlacklistTimeKey) || "无", n = await storageManager.getSetting("checkBlacklist_intervalTime", 12); + const e = this.getOptionalDependency("TaskPlugin"), t = e ? this.getRuntimeService("storage").getLocal(e.lastCheckBlacklistTimeKey) || "无" : "任务已禁用", n = await storageManager.getSetting("checkBlacklist_intervalTime", 12); this.checkBlacklist_ruleTime = await storageManager.getSetting("checkBlacklist_ruleTime", 8760), $("#checkBlacklistBtn").attr("data-tip", `上次整批检测: ${t}; 检测间隔时间: ${n}小时`); } async openBlacklistDialog() { - const e = this.getDependency("TaskPlugin"), t = await storageManager.getSetting(), lastCheck = this.getRuntimeService("storage").getLocal(e.lastCheckBlacklistTimeKey) || "无"; + const e = this.getOptionalDependency("TaskPlugin"), t = await storageManager.getSetting(), lastCheck = e ? this.getRuntimeService("storage").getLocal(e.lastCheckBlacklistTimeKey) || "无" : "任务已禁用"; let n = `\n
    \n
    \n
    \n \n \n
    \n
    \n \n \n \n \n \n
    \n\n
    \n
    \n
    \n `; this.getRuntimeService("dialog").open({ type: 1, @@ -129,6 +130,7 @@ export class BlacklistPlugin extends BasePlugin { const dialog = $(t).find(".layui-layer-content > div").first().addClass("jhs-blacklist-layout").removeAttr("style"), toolbar = dialog.children("div").first().addClass("jhs-blacklist-toolbar").removeAttr("style"); toolbar.children("div").addClass("jhs-blacklist-toolbar__group").removeAttr("style"), toolbar.find("select,input,a").removeAttr("style"), dialog.find("#table-container").removeAttr("style"); dialog.find("#table-container").before('
    '), JhsSelect.enhance(t); + e || dialog.find("#checkBlacklistBtn").prop("disabled", !0).attr("title", "后台任务功能已禁用"); this.renderTaskStatus(), this.taskStatusUnsubscribe?.(), this.taskStatusUnsubscribe = getBlacklistEventBus().on("task-status-changed", (() => this.renderTaskStatus())); await this.loadTableData(); const content = $(t).find(".layui-layer-content"), search = content.find("#searchValue"); @@ -137,7 +139,7 @@ export class BlacklistPlugin extends BasePlugin { })).on("input", "#searchValue", this.blacklistSearchDebounced).on("change", "#dataType,#statusType,#urlType", (async () => { await this.reloadTable(); })).on("click", "#toSetting", (() => { - this.getDependency("SettingPlugin").openSettingDialog("task-panel", (() => { + this.getOptionalDependency("SettingPlugin")?.openSettingDialog?.("task-panel", (() => { $("#setting-blacklist").css({ border: "1px solid var(--jhs-status-filter)" }); @@ -148,7 +150,7 @@ export class BlacklistPlugin extends BasePlugin { utils.openPage(n, a, !0, e); })).on("click", "#checkBlacklistBtn", ((/** @type {any} */ event) => { const button = $(event.currentTarget), label = button.find("span").last(), previous = label.text(); - if (button.attr("aria-busy") === "true") return; + if (!e || button.attr("aria-busy") === "true") return; button.attr("aria-busy", "true").prop("disabled", !0), label.text("检测中…"), navigator.locks.request(e.singleTaskKey, { ifAvailable: !0 }, (async lock => { lock ? await e.checkBlacklist(!0) : show.error("后台任务正在运行,请稍后再试"); })).catch((error => { @@ -166,7 +168,9 @@ export class BlacklistPlugin extends BasePlugin { renderTaskStatus() { const container = $("#blacklist-task-status"); if (!container.length) return; - const snapshot = this.getDependency("TaskPlugin").getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = (/** @type {unknown} */ value) => value ? new Date(/** @type {string | number | Date} */ (value)).toLocaleString() : "无", state = /** @type {keyof typeof labels} */ (snapshot.state); + const task = this.getOptionalDependency("TaskPlugin"); + if (!task) return void container.empty().text("后台任务功能已禁用"); + const snapshot = task.getTaskStatusSnapshot("blacklist"), labels = { idle: "正常", running: "运行中", pending: "等待下一次任务检查", due: "待运行" }, format = (/** @type {unknown} */ value) => value ? new Date(/** @type {string | number | Date} */ (value)).toLocaleString() : "无", state = /** @type {keyof typeof labels} */ (snapshot.state); container.empty().append($("").text(`黑名单:${labels[state] || labels.idle}`), $("").text(`上次完成 ${format(snapshot.completedAt)};下次检查 ${snapshot.nextAt ? format(snapshot.nextAt) : "立即"}`)); } async reloadTable() { @@ -175,7 +179,7 @@ export class BlacklistPlugin extends BasePlugin { this.tableObj.setData(e); } async getTableData() { - const e = this.getDependency("TaskPlugin"), t = await storageManager.getBlacklist(), n = await storageManager.getBlacklistCarList(), a = String($("#searchValue").val() || "").trim().toLocaleLowerCase(), i = $("#statusType").val(), s = $("#dataType"), o = s.val(), r = $("#urlType").val(), l = t.length; + const t = await storageManager.getBlacklist(), n = await storageManager.getBlacklistCarList(), a = String($("#searchValue").val() || "").trim().toLocaleLowerCase(), i = $("#statusType").val(), s = $("#dataType"), o = s.val(), r = $("#urlType").val(), l = t.length; let c = 0, d = 0; const h = t.map((/** @type {BlacklistRecord} */ t) => { t.role === B ? c++ : t.role === P && d++; diff --git a/src/plugins/dependency-map.js b/src/plugins/dependency-map.js index c68b9802..c10d6669 100644 --- a/src/plugins/dependency-map.js +++ b/src/plugins/dependency-map.js @@ -1,11 +1,14 @@ // @ts-check /** - * Transitional dependency declarations for legacy plugins. + * Transitional optional-availability declarations for legacy plugins. * * New Features use manifest tokens. Legacy plugins receive only the plugin * instances declared here so that internal code cannot use PluginManager as - * an unrestricted service locator during the 6.5 migration. + * an unrestricted service locator during the 6.5 migration. Every production + * call site uses getOptionalDependency(); these edges do not define startup + * ordering and may therefore be cyclic. The hard legacy dependency graph is + * intentionally empty and enforced by architecture-runtime.test.js. */ export const LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ AutoPagePlugin: ["ListPagePlugin"], diff --git a/src/plugins/external-search/hit-show.js b/src/plugins/external-search/hit-show.js index c718d094..a8e860bd 100644 --- a/src/plugins/external-search/hit-show.js +++ b/src/plugins/external-search/hit-show.js @@ -44,10 +44,10 @@ export class HitShowPlugin extends BasePlugin { if (generation !== this.loadGeneration) return; this.$listRoot.html(this.markDataListHtml(movies)); await this.initializeRenderedList(); - await this.getDependency("ListPageButtonPlugin").sortItems(); + await this.getOptionalDependency("ListPageButtonPlugin")?.sortItems?.(); loadingObj.close(), loadingClosed = !0; void this.loadScore(movies, generation).then((async () => { - if (generation === this.loadGeneration && "rateCount" === this.getRuntimeService("settings").snapshot().sortMethod) await this.getDependency("ListPageButtonPlugin").sortItems(); + if (generation === this.loadGeneration && "rateCount" === this.getRuntimeService("settings").snapshot().sortMethod) await this.getOptionalDependency("ListPageButtonPlugin")?.sortItems?.(); })).catch((error => clog.error("热播评分补全失败", error))); } catch (error) { clog.error("所有重试尝试均失败,无法获取数据。", error); @@ -67,9 +67,9 @@ export class HitShowPlugin extends BasePlugin { throw lastError; } async initializeRenderedList() { - const listPage = this.getDependency("ListPagePlugin"); - listPage.replaceHdImg(), await listPage.doFilter(), listPage.applyVisibility(), listPage.bindMovieDetailNavigation(listPage.getSelector().boxSelector); - this.getDependency("CoverButtonPlugin").addSvgBtn(); + const listPage = this.getOptionalDependency("ListPagePlugin"); + listPage && (listPage.replaceHdImg(), await listPage.doFilter(), listPage.applyVisibility(), listPage.bindMovieDetailNavigation(listPage.getSelector().boxSelector)); + await this.getOptionalDependency("CoverButtonPlugin")?.addSvgBtn?.(); } toolBar(/** @type {string | null} */ e) { $("#jhs-hitshow-period").remove(); diff --git a/src/plugins/external-search/top250.js b/src/plugins/external-search/top250.js index 38360b56..3bbef769 100644 --- a/src/plugins/external-search/top250.js +++ b/src/plugins/external-search/top250.js @@ -72,7 +72,8 @@ export class Top250Plugin extends BasePlugin { let t = e.data.movies; if (0 === t.length) return show.error("无数据"), void s.close(); this.movies = t; - const n = t.filter(((/** @type {TopMovie} */ e) => "1" === this.has_cnsub ? e.has_cnsub : "0" !== this.has_cnsub || !e.has_cnsub)), a = this.getDependency("HitShowPlugin"); + const n = t.filter(((/** @type {TopMovie} */ e) => "1" === this.has_cnsub ? e.has_cnsub : "0" !== this.has_cnsub || !e.has_cnsub)), a = this.getOptionalDependency("HitShowPlugin"); + if (!a) return void show.info("热播列表功能已禁用"); let r = a.markDataListHtml(n); i.html(r), await a.initializeRenderedList(), await a.loadScore(n), o = !0; } else clog.error(e), i.html(`

    ${escapeHtml(l)}

    `), show.error(l), "JWTVerificationError" === c && (removeStoredEncryptedCredential(me), @@ -98,7 +99,8 @@ export class Top250Plugin extends BasePlugin { const link = $(element), url = new URL(link.attr("href"), window.location.origin); url.searchParams.set("has_cnsub", value), link.attr("href", url.toString()); })); - const movies = this.movies.filter(((/** @type {TopMovie} */ movie) => "1" === this.has_cnsub ? movie.has_cnsub : "0" !== this.has_cnsub || !movie.has_cnsub)), hitShow = this.getDependency("HitShowPlugin"); + const movies = this.movies.filter(((/** @type {TopMovie} */ movie) => "1" === this.has_cnsub ? movie.has_cnsub : "0" !== this.has_cnsub || !movie.has_cnsub)), hitShow = this.getOptionalDependency("HitShowPlugin"); + if (!hitShow) return void show.info("热播列表功能已禁用"); this.$listRoot.html(hitShow.markDataListHtml(movies)), await hitShow.initializeRenderedList(), void hitShow.loadScore(movies).catch(((/** @type {unknown} */ error) => clog.error("Top250 评分加载失败", error))); })); } diff --git a/src/plugins/image-viewer/bus-preview-video.js b/src/plugins/image-viewer/bus-preview-video.js index b3be01ee..36a78f28 100644 --- a/src/plugins/image-viewer/bus-preview-video.js +++ b/src/plugins/image-viewer/bus-preview-video.js @@ -12,14 +12,20 @@ export class BusPreviewVideoPlugin extends BasePlugin { async initCss() { return "\n .bus-preview-modal { position:fixed; inset:0; z-index:var(--jhs-z-modal); display:flex; align-items:center; justify-content:center; visibility:hidden; opacity:0; background:rgba(0,0,0,.95); transition:opacity var(--jhs-motion-base) var(--jhs-ease); }\n .bus-preview-modal.is-open { visibility:visible; opacity:1; }\n .bus-preview-modal-content { position:relative; display:flex; max-width:95%; max-height:95%; flex-direction:column; align-items:center; gap:var(--jhs-space-3); }\n .video-player-wrapper { position:relative; width:80vw; max-width:100%; max-height:85vh; aspect-ratio:16/9; background:#000; }\n .video-player-wrapper #preview-video { position:absolute; inset:0; }\n "; } - initModal() { + /** @param {import("../../core/lifecycle-scope.js").LifecycleScope} scope */ + initModal(scope) { if (0 === $("#bus-preview-modal").length) { $("body").append('\n
    \n
    \n
    \n
    \n '); const e = $("#bus-preview-modal"); e.on("click", ((/** @type {MouseEvent} */ e) => { e.target instanceof Element && "bus-preview-modal" === e.target.id && this.closeVideoModal(); - })), $(document).on("keydown", ((/** @type {KeyboardEvent} */ t) => { - "Escape" === t.key && e.hasClass("is-open") && this.closeVideoModal(); + })); + scope.listen(document, "keydown", ((/** @type {Event} */ event) => { + event instanceof KeyboardEvent && "Escape" === event.key && e.hasClass("is-open") && this.closeVideoModal(); + })); + scope.addCleanup((() => { + e.off(); + e.remove(); })); } } @@ -29,11 +35,11 @@ export class BusPreviewVideoPlugin extends BasePlugin { } async handle() { if (!isDetailPage) return; - this.initModal(); + const scope = await this.getRuntimeService("scope")(); + this.initModal(scope); const e = $("#sample-waterfall .sample-box .photo-frame img:first").attr("src"), t = $(`\n `); $("#sample-waterfall").prepend(t); if ("yes" === await storageManager.getSetting("enableLoadPreviewVideo", "yes")) { - const scope = await this.getRuntimeService("scope")(); void fetchDmmPreview(this.getPageInfo().carNum, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope).catch((error => clog.warn("预加载 DMM 失败", error))); } let n = !1, a = $(".preview-video-container"); diff --git a/src/plugins/image-viewer/cover-button.js b/src/plugins/image-viewer/cover-button.js index fc818182..380db80d 100644 --- a/src/plugins/image-viewer/cover-button.js +++ b/src/plugins/image-viewer/cover-button.js @@ -36,7 +36,10 @@ export class CoverButtonPlugin extends BasePlugin { `; } async handle() { - window.isListPage && (this.addSvgBtn(), await this.bindClick()); + if (!window.isListPage) return; + const scope = await this.getRuntimeService("scope")(); + this.addSvgBtn(); + await this.bindClick(scope); } /** 构建卡片工具和三个卡片内 popover。 */ buildToolBox() { @@ -93,23 +96,28 @@ export class CoverButtonPlugin extends BasePlugin { const openMenus = $(".jhs-card-menu.is-open"), triggers = openMenus.siblings(".jhs-card-menu-trigger"); openMenus.removeClass("is-open"), triggers.attr("aria-expanded", "false"), focus && triggers.first().trigger("focus"); } - async bindClick() { + /** @param {import("../../core/lifecycle-scope.js").LifecycleScope} scope */ + async bindClick(scope) { this.getSelector(); - const e = this.getDependency("ListPagePlugin"); - $(document).on("click", ".jhs-card-menu-trigger", ((/** @type {CardActionEvent} */ event) => { + const e = this.getOptionalDependency("ListPagePlugin"); + if (!e) return; + const documentRoot = $(document); + documentRoot.off(".jhsCoverButton"); + scope.addCleanup((() => documentRoot.off(".jhsCoverButton"))); + documentRoot.on("click.jhsCoverButton", ".jhs-card-menu-trigger", ((/** @type {CardActionEvent} */ event) => { event.preventDefault(), event.stopPropagation(); const trigger = $(event.currentTarget), menu = trigger.siblings(".jhs-card-menu"), open = !menu.hasClass("is-open"); this.closeCardMenus(), menu.toggleClass("is-open", open), trigger.attr("aria-expanded", String(open)), open && menu.children().first().trigger("focus"); - })).on("keydown", ".jhs-card-menu [role='menuitem']", ((/** @type {KeyboardEvent} */ event) => { + })).on("keydown.jhsCoverButton", ".jhs-card-menu [role='menuitem']", ((/** @type {KeyboardEvent} */ event) => { const menu = $(event.currentTarget).closest(".jhs-card-menu"), items = menu.find("[role='menuitem']"), index = items.index(event.currentTarget); if ("Escape" === event.key) return event.preventDefault(), this.closeCardMenus(!0); if (![ "ArrowDown", "ArrowUp", "Home", "End" ].includes(event.key)) return; event.preventDefault(); const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); - })).on("click", ((/** @type {CardActionEvent} */ event) => { + })).on("click.jhsCoverButton", ((/** @type {CardActionEvent} */ event) => { $(event.target).closest(".more-tools-container").length || this.closeCardMenus(); - })), $(document).on("click", ".videoSvg", ((/** @type {CardActionEvent} */ t) => { + })), documentRoot.on("click.jhsCoverButton", ".videoSvg", ((/** @type {CardActionEvent} */ t) => { t.preventDefault(), $('.videoSvg[title!="播放视频"]').each(((/** @type {number} */ t, /** @type {HTMLElement} */ n) => { const a = $(n); let i = a.closest(".item"), s = i.find("img"), {carNum: o} = e.findCarNumAndHref(i); @@ -123,7 +131,7 @@ export class CoverButtonPlugin extends BasePlugin { if (!i.length) return void show.error("没有找到图片"); void this.showVideo(a, i, t).catch((error => clog.error("卡片预览视频打开失败", error))); } - })), $(document).on("click", ".screenSvg", (async (/** @type {CardActionEvent} */ t) => { + })), documentRoot.on("click.jhsCoverButton", ".screenSvg", (async (/** @type {CardActionEvent} */ t) => { t.preventDefault(); let n = loading(); try { @@ -137,7 +145,7 @@ export class CoverButtonPlugin extends BasePlugin { } catch (a) { clog.error("图片预览出错:", a), show.error("图片预览出错:" + a); } finally { n.close(); } - })), $(document).on("click", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", ((/** @type {CardActionEvent} */ t) => { + })), documentRoot.on("click.jhsCoverButton", ".filterBtn, .favoriteBtn, .hasDownBtn, .hasWatchBtn", ((/** @type {CardActionEvent} */ t) => { t.preventDefault(), t.stopPropagation(); try { const n = $(t.currentTarget), a = n.closest(".item"), {carNum: i, url: s, publishTime: o, fc2Source} = e.findCarNumAndHref(a), r = async (/** @type {string} */ t) => { @@ -159,7 +167,7 @@ export class CoverButtonPlugin extends BasePlugin { r.find(".site-miss-av").attr({ href: `${n}/search/${l}`, target: "_blank", rel: "noopener noreferrer" }), r.find(".site-123-av").attr({ href: `${s}/cn/search?keyword=${encodeURIComponent(l)}`, target: "_blank", rel: "noopener noreferrer" }); })); - $(document).on("click", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", ((/** @type {CardActionEvent} */ t) => { + documentRoot.on("click.jhsCoverButton", ".site-jable, .site-avgle, .site-miss-av, .site-123-av", ((/** @type {CardActionEvent} */ t) => { try { t.preventDefault(), t.stopPropagation(); const o = $(t.currentTarget), r = o.closest(".item"), {carNum: l} = e.findCarNumAndHref(r); @@ -168,7 +176,7 @@ export class CoverButtonPlugin extends BasePlugin { if (!c) return; t.ctrlKey || t.metaKey ? GM_openInTab(c, { insert: 0 }) : window.open(c), this.closeCardMenus(); } catch (t) { clog.error("站点按钮处理失败:", t); } - })), $(document).on("click", ".titleSvg, .carNumSvg, .downSvg", ((/** @type {CardActionEvent} */ t) => { + })), documentRoot.on("click.jhsCoverButton", ".titleSvg, .carNumSvg, .downSvg", ((/** @type {CardActionEvent} */ t) => { t.preventDefault(), t.stopPropagation(); const n = $(t.currentTarget).closest(".item"), {carNum: a, title: i} = e.findCarNumAndHref(n), s = n.find(l ? ".photo-frame img" : ".cover img"); $(t.currentTarget).hasClass("titleSvg") ? utils.copyToClipboard("标题", i) : $(t.currentTarget).hasClass("carNumSvg") ? utils.copyToClipboard("番号", a) : $(t.currentTarget).hasClass("downSvg") && fetch(s.attr("src")).then((e => e.blob())).then((e => utils.download(e, a + " " + i + ".jpg"))), this.closeCardMenus(); diff --git a/src/plugins/image-viewer/preview-video.js b/src/plugins/image-viewer/preview-video.js index 97828058..a2ffb604 100644 --- a/src/plugins/image-viewer/preview-video.js +++ b/src/plugins/image-viewer/preview-video.js @@ -234,8 +234,8 @@ export class PreviewVideoPlugin extends BasePlugin { dmmVideo && (dmmVideo.currentTime += 10); })), $toolbar.off("contextmenu.jhsVideo").on("contextmenu.jhsVideo", "#speed-btn", ((/** @type {MouseEvent} */ event) => (event.preventDefault(), - this.getDependency("DetailPageButtonPlugin").filterOne(event)))), - $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((/** @type {MouseEvent} */ event) => this.getDependency("DetailPageButtonPlugin").filterOne(event))), - $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((/** @type {MouseEvent} */ event) => this.getDependency("DetailPageButtonPlugin").favoriteOne(event))); + this.getOptionalDependency("DetailPageButtonPlugin")?.filterOne?.(event)))), + $("#video-filterBtn").off("click.jhsVideo").on("click.jhsVideo", ((/** @type {MouseEvent} */ event) => this.getOptionalDependency("DetailPageButtonPlugin")?.filterOne?.(event))), + $("#video-favoriteBtn").off("click.jhsVideo").on("click.jhsVideo", ((/** @type {MouseEvent} */ event) => this.getOptionalDependency("DetailPageButtonPlugin")?.favoriteOne?.(event))); } } diff --git a/src/plugins/new-video/new-video.js b/src/plugins/new-video/new-video.js index 111608c3..9f43a134 100644 --- a/src/plugins/new-video/new-video.js +++ b/src/plugins/new-video/new-video.js @@ -167,13 +167,13 @@ export class NewVideoPlugin extends BasePlugin { $("#newVideoCount").text(`${e}`); } async resetBtnTip() { - const storage = this.getRuntimeService("storage"), e = this.getDependency("TaskPlugin"), t = await storageManager.getSetting(), n = storage.getLocal(e.lastCheckFavoriteActressTimeKey) || "无", a = t.checkFavoriteActress_IntervalTime, i = storage.getLocal(e.lastCheckNewVideoTimeKey) || "无", s = t.checkNewVideo_intervalTime; + const storage = this.getRuntimeService("storage"), e = this.getOptionalDependency("TaskPlugin"), t = await storageManager.getSetting(), n = e ? storage.getLocal(e.lastCheckFavoriteActressTimeKey) || "无" : "任务已禁用", a = t.checkFavoriteActress_IntervalTime, i = e ? storage.getLocal(e.lastCheckNewVideoTimeKey) || "无" : "任务已禁用", s = t.checkNewVideo_intervalTime; $("#checkFavoriteActress").attr("data-tip", `上次完整同步: ${n}; 检测间隔时间: ${a}小时`), $("#checkNewVideo").attr("data-tip", `上次整批检测: ${i}; 检测间隔时间: ${s}小时`); } async openDialog() { const storage = this.getRuntimeService("storage"); this.cleanupNewVideoWorkspace(), this._viewMode = "list" === storage.getLocal("jhs_newVideoViewMode") ? "list" : "actress", this.currentPage = 1, this.nvCurrentPage = 1, this.nvSelected = new Set, this.nvCoverCache = new Map, this.nvActorCoverRequests = new Map, this.nvRenderGeneration++; - const e = this.getDependency("TaskPlugin"), t = await storageManager.getSetting(), n = storage.getLocal(e.lastCheckFavoriteActressTimeKey) || "无", a = t.checkFavoriteActress_IntervalTime, i = storage.getLocal(e.lastCheckNewVideoTimeKey) || "无", s = t.checkNewVideo_intervalTime; + const e = this.getOptionalDependency("TaskPlugin"), t = await storageManager.getSetting(), n = e ? storage.getLocal(e.lastCheckFavoriteActressTimeKey) || "无" : "任务已禁用", a = t.checkFavoriteActress_IntervalTime, i = e ? storage.getLocal(e.lastCheckNewVideoTimeKey) || "无" : "任务已禁用", s = t.checkNewVideo_intervalTime; let o = `
    -

    统一离线服务

    115 状态:未知

    服务不可用时会在提交前显示原因。
    +

    统一离线服务

    115 状态:未知

    服务不可用时会在提交前显示原因。

    番号列表导入

    支持换行、空格、逗号分隔番号。必须先解析预览,再确认导入。

    `); const sidebar = $(".jhs-mobile-sidebar,.setting-sidebar").first(); sidebar.append(''); @@ -8555,6 +8562,7 @@ this.collapseAdvancedTabs(); } const sections = await Promise.allSettled([this.hydrateSettingForm(e3), this.loadResourceSettings()]), resourceResult = sections[1]; + JhsSelect.refreshAll(e3); if (resourceResult.status === "rejected") { clog.error("resource-settings 加载失败", resourceResult.reason), this.getRuntimeService("diagnostics").recordError({ source: "resource-settings", message: resourceResult.reason?.message || String(resourceResult.reason) }), show.error("资源设置加载失败,基本设置仍可保存"); } @@ -8571,7 +8579,7 @@ try { await loadSettingForm(this.getFormDependencies()); if (!button[0]?.isConnected) return false; - JhsSelect.enhance(layerRoot), button.attr("data-jhs-settings-ready", "true").prop("disabled", false).removeAttr("title"), status.empty().text("设置已加载"); + JhsSelect.refreshAll(layerRoot), button.attr("data-jhs-settings-ready", "true").prop("disabled", false).removeAttr("title"), status.empty().text("设置已加载"); return true; } catch (error) { clog.error("settings-form 加载失败", error), this.getRuntimeService("diagnostics").recordError({ source: "settings-form", message: error?.message || String(error) }); @@ -8747,6 +8755,7 @@ await this.resourceSettings.importConfig($("#advanced-resource-json").val()); show.ok("资源配置导入成功"); await this.loadResourceSettings(); + JhsSelect.refreshAll(); } catch (error) { show.error(error.message); } @@ -9498,9 +9507,15 @@ const jq = globalThis.$; const urls = [...new Set((images || []).map(normalizeUrl).filter(Boolean))]; const grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); + context.galleryUrls = new Set(urls); if (!urls.length) return renderFc2State(grid, "暂无剧照"); preview.append(jq("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); urls.forEach((url, index) => grid.append(jq('').attr("aria-label", `查看剧照 ${index + 1}`).append(jq("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" })))); + const screenshot = context.root.find('[data-jhs-role="screenshot"]'); + screenshot.find("img").each((_2, img) => { + const src = img.getAttribute("src"); + if (src && context.galleryUrls.has(src)) screenshot.empty(); + }); } __name(renderFc2Gallery, "renderFc2Gallery"); function createFc2SourceLinks(context, movieService) { @@ -9520,8 +9535,9 @@ this.$contentBox = null; this.$listRoot = null; this.urlParams = new URLSearchParams(window.location.search); - this.currentPage = parseInt(this.urlParams.get("page") || "1", 10); + this.currentPage = Math.max(1, parseInt(this.urlParams.get("page") || "1", 10) || 1); this.maxPage = null; + this._queryGeneration = 0; this.keyword = this.urlParams.get("keyword") || null; } getName() { @@ -9541,9 +9557,9 @@ let e2 = $("h2.section-title"); e2.contents().first().replaceWith("123Av"), e2.css("marginBottom", "0"), e2.append('\n
    \n \n \n \n
    \n '), $("#search-123av-keyword").val(this.keyword), $("#search-123av-btn").on("click", (async () => { let e3 = String($("#search-123av-keyword").val() || "").trim(); - e3 && (this.keyword = e3, utils.setHrefParam("keyword", e3), await this.handleQuery()); + e3 && (this.keyword = e3, utils.setHrefParam("keyword", e3), this.currentPage = 1, this.maxPage = null, utils.setHrefParam("page", 1), await this.handleQuery()); })), $("#clear-123av-btn").on("click", (async () => { - $("#search-123av-keyword").val(""), this.keyword = "", utils.setHrefParam("keyword", ""), $(".page-box").show(), await this.handleQuery(); + $("#search-123av-keyword").val(""), this.keyword = "", utils.setHrefParam("keyword", ""), this.currentPage = 1, this.maxPage = null, utils.setHrefParam("page", 1), $(".page-box").show(), await this.handleQuery(); })), $(".empty-message").remove(), $("#foldCategoryBtn").remove(), this.$contentBox.children(".box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append(this.$listRoot), this.$contentBox.append('
    '); utils.setHrefParam("page", this.currentPage); $(".page-box").append('\n \n '), $(document).on("click", ".pagination-link", ((e3) => { @@ -9568,12 +9584,15 @@ } async handleQuery() { let e2 = loading(); + const generation = ++this._queryGeneration; try { $(".page-box").show(); const scope = await this.getRuntimeService("scope")(); + if (generation !== this._queryGeneration) return; const result = await this.getRuntimeService("movie").catalog("av123", { page: this.currentPage, keyword: this.keyword || "" }, { scope }); + if (generation !== this._queryGeneration) return; const i2 = result.items; - if (result.maxPage) this.maxPage = result.maxPage, this.renderPagination(); + this.maxPage = Math.max(1, Number(result.maxPage) || 1), this.currentPage = Math.min(Math.max(1, this.currentPage), this.maxPage), this.renderPagination(); if (0 === i2.length) { clog.log(i2), show.error("无结果"); clog.error("123AV 获取数据失败"); @@ -9829,15 +9848,16 @@ ${value}\r __name(renderImage, "renderImage"); async function renderScreenshotPanel(options) { const jq = globalThis.$; - const host = jq(options.target), isActive = options.isActive ?? (() => true); + const host = jq(options.target), isActive = options.isActive ?? (() => true), providerId = options.providerId ?? "javstore"; if (!host.length || options.settings.enableLoadScreenShot === "no") return host.empty(), null; const load = /* @__PURE__ */ __name(async (resultHost = host) => { resultHost.empty().append(jq("
    ").addClass("jhs-panel-state").text("正在加载缩略图…")); try { - const images = await options.screenshot.resolve({ carNum: options.carNum }, { scope: options.scope }); + const images = await options.screenshot.resolve({ carNum: options.carNum }, { providerId, scope: options.scope }); if (!isActive()) return null; const image = Array.isArray(images) ? images[0] : images; if (!image?.url) return resultHost.empty(), null; + if (options.isDuplicate?.(image.url)) return resultHost.empty(), null; return renderImage(resultHost, image.url, "缩略图"); } catch (error) { if (!isActive()) return null; @@ -10491,7 +10511,9 @@ ${value}\r screenshot: this.getRuntimeService("screenshot"), settings: this.getRuntimeService("settings").snapshot(), scope, - isActive: context.isAlive + isActive: context.isAlive, + providerId: "javstore", + isDuplicate: /* @__PURE__ */ __name((url) => Boolean(context.galleryUrls?.has(url)), "isDuplicate") })).then((result) => { if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); }).catch(((error) => { @@ -11537,11 +11559,13 @@ ${failure.stack}` : ""); } async handleTop() { if (!window.location.href.includes("handleTop=1")) return; + const hitShow = this.getOptionalDependency("HitShowPlugin"); + if (!hitShow) return void show.info("热播列表功能已禁用"); const e2 = new URLSearchParams(window.location.search); let t2 = e2.get("handleType") || "all", n2 = e2.get("type_value") || ""; this.has_cnsub = e2.get("has_cnsub") || ""; let a2 = Number(e2.get("page")) || 1; - this.toolBar(t2, n2, a2), this.hookPage(); + this.hookPage(), this.toolBar(t2, n2, a2); let i2 = this.$listRoot; i2.html(""); let s2 = loading(); @@ -11553,10 +11577,8 @@ ${failure.stack}` : ""); let t3 = e3.data.movies; if (0 === t3.length) return show.error("无数据"), void s2.close(); this.movies = t3; - const n3 = t3.filter(((e4) => "1" === this.has_cnsub ? e4.has_cnsub : "0" !== this.has_cnsub || !e4.has_cnsub)), a3 = this.getOptionalDependency("HitShowPlugin"); - if (!a3) return void show.info("热播列表功能已禁用"); - let r3 = a3.markDataListHtml(n3); - i2.html(r3), await a3.initializeRenderedList(), await a3.loadScore(n3), o2 = true; + const n3 = t3.filter(((e4) => "1" === this.has_cnsub ? e4.has_cnsub : "0" !== this.has_cnsub || !e4.has_cnsub)), r3 = hitShow.markDataListHtml(n3); + i2.html(r3), await hitShow.initializeRenderedList(), await hitShow.loadScore(n3), o2 = true; } else clog.error(e3), i2.html(`

    ${escapeHtml(l3)}

    `), show.error(l3), "JWTVerificationError" === c2 && (removeStoredEncryptedCredential(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o2 = true; } catch (r2) { l2 < 3 ? (clog.error(`获取Top数据失败 (第 ${l2} 次重试):`, r2), await new Promise(((e3) => setTimeout(e3, 1e3)))) : (clog.error("所有重试尝试均失败,无法获取Top数据。", r2), i2.html("

    无法加载数据,请稍后再试。

    ")); @@ -12194,6 +12216,7 @@ ${failure.stack}` : "");
    `; } async addSvgBtn(items = null) { + if (!this.getOptionalDependency("ListPagePlugin")) return; (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((element) => { const item = $(element); if (item.find(".tool-box").length || l && item.find(".avatar-box").length) return; @@ -12487,7 +12510,7 @@ ${failure.stack}` : ""); } async getServiceScreenshot(carNum) { const scope = await this.getRuntimeService("scope")(); - const images = await this.getRuntimeService("screenshot").resolve({ carNum }, { scope }); + const images = await this.getRuntimeService("screenshot").resolve({ carNum }, { providerId: "javstore", scope }); const image = Array.isArray(images) ? images[0] : images; return image?.url ? { url: image.url, source: image.providerId || "javstore", detailUrl: null } : null; } @@ -14732,11 +14755,11 @@ ${failure.stack}` : ""); }, "success") }); })); - const a2 = this.getOptionalDependency("HighlightMagnetPlugin"), i2 = await storageManager.getSetting("enableMagnetsFilter", _); - a2 || $("#enable-magnets-filter").remove(), $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2?.doFilterMagnet?.(), $("#enable-magnets-filter").on("click", ((e3) => { + const a2 = this.getOptionalDependency("HighlightMagnetPlugin"), settings = this.getRuntimeService("settings"), i2 = settings.snapshot().enableMagnetsFilter ?? _; + a2 || $("#enable-magnets-filter").remove(), $("#magnets-span").text(i2 === _ ? "关闭磁力过滤" : "开启磁力过滤"), i2 === _ && a2?.doFilterMagnet?.(), $("#enable-magnets-filter").on("click", (async (e3) => { let t3 = $("#magnets-span"); if (!a2) return; - "关闭磁力过滤" === t3.text() ? (a2.showAll(), t3.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a2.doFilterMagnet(), t3.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); + "关闭磁力过滤" === t3.text() ? (a2.showAll(), t3.text("开启磁力过滤"), await settings.set("enableMagnetsFilter", C)) : (a2.doFilterMagnet(), t3.text("关闭磁力过滤"), await settings.set("enableMagnetsFilter", _)); })), $("#search-subtitle-btn").on("click", ((e3) => { const target = this.getRuntimeService("movie").sourceUrls({ carNum: t2 }, ["subtitlecat"])[0]?.url; if (target) utils.openPage(target, t2, false, e3); @@ -16876,14 +16899,15 @@ ${failure.stack}` : ""); } createMenu() { const item = /* @__PURE__ */ __name((action, label, attributes = "") => ``, "item"), group = /* @__PURE__ */ __name((content) => `
    ${content}
    `, "group"), divider = ''; + const hasListPageButton = !!this.getOptionalDependency("ListPageButtonPlugin"), hasListPage = !!this.getOptionalDependency("ListPagePlugin"), hasNewVideo = !!this.getOptionalDependency("NewVideoPlugin"), hasBlacklist = !!this.getOptionalDependency("BlacklistPlugin"), hasSetting = !!this.getOptionalDependency("SettingPlugin"), hasDetailPageButton = !!this.getOptionalDependency("DetailPageButtonPlugin"), hasHighlightMagnet = !!this.getOptionalDependency("HighlightMagnetPlugin"), hasMagnetHub = !!this.getOptionalDependency("MagnetHubPlugin"); let items; if (window.isListPage) { const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? requestedSortMethod : "default", sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getOptionalDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); - items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; + items = group((hasListPageButton ? item("check", "开始鉴定") : "") + (hasNewVideo ? item("newVideo", "新作品") : "") + (hasBlacklist ? item("blacklist", "黑名单") : "") + (hasListPageButton ? item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') : "") + (hasListPage ? item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"') : "")) + divider + group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")) + (hasListPage ? `` : "") + (hasListPageButton ? `` : ""); } else if (window.isDetailPage) { const statusDefs = [{ action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" }]; - items = group(statusDefs.map(((definition) => item(definition.action, `${definition.icon}`, `aria-label="${definition.label}" aria-pressed="false" data-label="${definition.label}"`))).join("")) + divider + group(item("magnetFilter", "磁力过滤") + item("magnet", "磁力搜索") + item("subtitle", "字幕")) + divider + group(item("logger", "运行日志") + item("setting", "设置")); - } else items = group(item("logger", "运行日志") + item("setting", "设置")); + items = group(hasDetailPageButton ? statusDefs.map(((definition) => item(definition.action, `${definition.icon}`, `aria-label="${definition.label}" aria-pressed="false" data-label="${definition.label}"`))).join("") : "") + divider + group((hasHighlightMagnet ? item("magnetFilter", "磁力过滤") : "") + (hasMagnetHub ? item("magnet", "磁力搜索") : "") + (hasDetailPageButton ? item("subtitle", "字幕") : "")) + divider + group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")); + } else items = group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")); return $(``); } async refreshDetailStatus() { @@ -17234,7 +17258,7 @@ ${failure.stack}` : ""); manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }, [PORT.host]), manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), - manifest("detail.page-state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state]), + manifest("detail.page-state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state, SERVICE.settings]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [PORT.host, SERVICE.settings]), manifest("detail.javdb-preview", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie]), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), @@ -18365,29 +18389,45 @@ ${failure.stack}` : ""); var ReviewService = _ReviewService; // src/services/screenshot-service.js + var DEFAULT_SCREENSHOT_PROVIDER = "javstore"; var _ScreenshotService = class _ScreenshotService { constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } async resolve(movieRef, context = {}) { - for (const provider of await this.providers.getAvailable("screenshot", context)) { - try { - const result = await provider.resolve(movieRef, context); - this.providers.updateHealth(provider.id, { ok: true }); + if (context.providerId) { + const provider = this.providers.get?.(context.providerId); + if (provider && provider.enabled !== false && provider.capabilities?.includes("screenshot")) { + const result = await this.resolveFromProvider(provider, movieRef, context); if (result) return result; - } catch (error) { - this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } + return this.resolveIntegration(context.providerId, movieRef, context); } - for (const manifest2 of this.integrations?.list("movie.images") ?? []) { - const adapter = this.integrations?.getAdapter(manifest2.id); - if (typeof adapter?.getImages !== "function") continue; - const result = await adapter.getImages(movieRef, context); - if (Array.isArray(result) && result.length) return result; + for (const provider of await this.providers.getAvailable("screenshot", context)) { + const result = await this.resolveFromProvider(provider, movieRef, context); + if (result) return result; + } + return this.resolveIntegration(DEFAULT_SCREENSHOT_PROVIDER, movieRef, context); + } + async resolveFromProvider(provider, movieRef, context) { + try { + const result = await provider.resolve(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (result) return result; + } catch (error) { + this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } return null; } + async resolveIntegration(providerId, movieRef, context) { + const manifest2 = this.integrations?.list("movie.images")?.find((item) => item.id === providerId); + if (!manifest2) return null; + const adapter = this.integrations?.getAdapter(manifest2.id); + if (typeof adapter?.getImages !== "function") return null; + const result = await adapter.getImages(movieRef, context); + return Array.isArray(result) && result.length ? result : null; + } getSearchUrl(movieRef) { for (const manifest2 of this.integrations?.list("movie.images") ?? []) { const adapter = this.integrations?.getAdapter(manifest2.id); diff --git a/src/core/ui-primitives.js b/src/core/ui-primitives.js index 9aafd81c..032eb6ce 100644 --- a/src/core/ui-primitives.js +++ b/src/core/ui-primitives.js @@ -1046,6 +1046,14 @@ export function initializeUiAccessibility(lifecycleScope) { export class JhsSelect { /** @type {WeakMap} */ static instances = new WeakMap; + /** @type {boolean} */ + static _globalClickBound = !1; + static _ensureGlobalClickHandler() { + if (JhsSelect._globalClickBound) return; + JhsSelect._globalClickBound = !0, $(document).on("click.jhsSelect", ((/** @type {any} */ event) => { + $(event.target).closest(".jhs-select-control").length || JhsSelect.closeAll(); + })); + } /** @param {any} select */ constructor(select) { this.source = $(select); @@ -1129,9 +1137,7 @@ export class JhsSelect { const next = "Home" === event.key ? 0 : "End" === event.key ? items.length - 1 : "ArrowDown" === event.key ? (index + 1) % items.length : (index - 1 + items.length) % items.length; items.eq(next).trigger("focus"); })); - this.source.on("change.jhsSelect", (() => this.refresh())), $(document).on("click.jhsSelect", ((/** @type {any} */ event) => { - $(event.target).closest(this.control).length || this.close(); - })); + this.source.on("change.jhsSelect", (() => this.refresh())), JhsSelect._ensureGlobalClickHandler(); } options() { return this.menu.find(".jhs-select-option:not(:disabled)"); diff --git a/src/plugins/backup/setting-templates.js b/src/plugins/backup/setting-templates.js index 1f5e7367..7df16e56 100644 --- a/src/plugins/backup/setting-templates.js +++ b/src/plugins/backup/setting-templates.js @@ -650,7 +650,7 @@ export function injectResourceSourcesPanel() {

    截图来源

    自动选择会按优先级依次尝试可用来源。

    高级 · 导入 / 导出配置

    高级功能:错误修改可能导致自定义来源不可用,保存前会校验配置。

    -

    统一离线服务

    115 状态:未知

    服务不可用时会在提交前显示原因。
    +

    统一离线服务

    115 状态:未知

    服务不可用时会在提交前显示原因。

    番号列表导入

    支持换行、空格、逗号分隔番号。必须先解析预览,再确认导入。

    `); const sidebar = $(".jhs-mobile-sidebar,.setting-sidebar").first(); sidebar.append(''); diff --git a/src/plugins/backup/setting.js b/src/plugins/backup/setting.js index 5faed39b..c178a44f 100644 --- a/src/plugins/backup/setting.js +++ b/src/plugins/backup/setting.js @@ -168,6 +168,7 @@ export class SettingPlugin extends BasePlugin { this.collapseAdvancedTabs(); } const sections = await Promise.allSettled([ this.hydrateSettingForm(e), this.loadResourceSettings() ]), resourceResult = sections[1]; + JhsSelect.refreshAll(e); if (resourceResult.status === "rejected") { clog.error("resource-settings 加载失败", resourceResult.reason), this.getRuntimeService("diagnostics").recordError({ source: "resource-settings", message: resourceResult.reason?.message || String(resourceResult.reason) }), show.error("资源设置加载失败,基本设置仍可保存"); } @@ -185,7 +186,7 @@ export class SettingPlugin extends BasePlugin { try { await loadSettingForm(this.getFormDependencies()); if (!button[0]?.isConnected) return !1; - JhsSelect.enhance(layerRoot), button.attr("data-jhs-settings-ready", "true").prop("disabled", !1).removeAttr("title"), status.empty().text("设置已加载"); + JhsSelect.refreshAll(layerRoot), button.attr("data-jhs-settings-ready", "true").prop("disabled", !1).removeAttr("title"), status.empty().text("设置已加载"); return !0; } catch (error) { clog.error("settings-form 加载失败", error), this.getRuntimeService("diagnostics").recordError({ source: "settings-form", message: error?.message || String(error) }); @@ -341,7 +342,7 @@ export class SettingPlugin extends BasePlugin { this.renderResourceSettings(); $("#enable123Offline").prop("checked", cloud.enable123Offline); $("#enable115Offline").prop("checked", cloud.enable115Offline); $("#offlineProviderMode").val(cloud.providerMode); $("#enable115Match").prop("checked", cloud.enable115Match); $("#enable115LoginRedirect").prop("checked", cloud.enable115LoginRedirect); $("#oneOneFiveConcurrency").val(cloud.concurrency); $("#oneOneFiveCacheMinutes").val(cloud.cacheMinutes); $("#cloud-services-panel").off("change.jhsResource", "input, select").on("change.jhsResource", "input, select", (() => void this.saveCloudSettings())); $("#resource-sources-panel").off("change.jhsResource", 'input[name="screenshotMode"]').on("change.jhsResource", 'input[name="screenshotMode"]', (event => { this.resourceState.screenshot.mode = event.currentTarget.value; this.resourceSettings.saveScreenshotSettings(this.resourceState.screenshot); })); $("#add-custom-magnet-source").off("click.jhsResource").on("click.jhsResource", (() => this.openSourceDialog())); $("#add-magnet-tag-rule").off("click.jhsResource").on("click.jhsResource", (() => this.openRuleDialog("tag"))); $("#add-magnet-filter-rule").off("click.jhsResource").on("click.jhsResource", (() => this.openRuleDialog("filter"))); - $("#export-resource-config").off("click.jhsResource").on("click.jhsResource", (async () => $("#advanced-resource-json").val(JSON.stringify(await this.resourceSettings.exportConfig(), null, 2)).prop("readonly", true))); $("#edit-resource-config").off("click.jhsResource").on("click.jhsResource", (() => $("#advanced-resource-json").prop("readonly", false).trigger("focus"))); $("#import-resource-config").off("click.jhsResource").on("click.jhsResource", (async () => { try { await this.resourceSettings.importConfig($("#advanced-resource-json").val()); show.ok("资源配置导入成功"); await this.loadResourceSettings(); } catch (error) { show.error(error.message); } })); + $("#export-resource-config").off("click.jhsResource").on("click.jhsResource", (async () => $("#advanced-resource-json").val(JSON.stringify(await this.resourceSettings.exportConfig(), null, 2)).prop("readonly", true))); $("#edit-resource-config").off("click.jhsResource").on("click.jhsResource", (() => $("#advanced-resource-json").prop("readonly", false).trigger("focus"))); $("#import-resource-config").off("click.jhsResource").on("click.jhsResource", (async () => { try { await this.resourceSettings.importConfig($("#advanced-resource-json").val()); show.ok("资源配置导入成功"); await this.loadResourceSettings(); JhsSelect.refreshAll(); } catch (error) { show.error(error.message); } })); $("#car-number-import,#car-number-import-status").off("input.jhsResource change.jhsResource").on("input.jhsResource change.jhsResource", (() => { this.pendingCarImport = null; $("#confirm-car-number-import").prop("disabled", true).text("确认导入"); })); $("#check-one-one-five-login").off("click.jhsResource").on("click.jhsResource", (() => this.checkOneOneFiveLogin())); } diff --git a/src/plugins/dependency-map.js b/src/plugins/dependency-map.js index c10d6669..4ea58393 100644 --- a/src/plugins/dependency-map.js +++ b/src/plugins/dependency-map.js @@ -30,7 +30,7 @@ export const LEGACY_PLUGIN_DEPENDENCY_MAP = Object.freeze({ ], MobileBottomBarPlugin: [ "ListPagePlugin", "DetailPageButtonPlugin", "ListPageButtonPlugin", - "NewVideoPlugin", "BlacklistPlugin", "SettingPlugin" + "NewVideoPlugin", "BlacklistPlugin", "SettingPlugin", "HighlightMagnetPlugin", "MagnetHubPlugin" ], NavBarPlugin: ["SearchByImagePlugin"], NewVideoPlugin: ["TaskPlugin", "SettingPlugin"], diff --git a/src/plugins/external-search/fc2-by-123av.js b/src/plugins/external-search/fc2-by-123av.js index 39625772..7e9b317d 100644 --- a/src/plugins/external-search/fc2-by-123av.js +++ b/src/plugins/external-search/fc2-by-123av.js @@ -18,8 +18,9 @@ export class Fc2By123AvPlugin extends BasePlugin { /** @type {JQueryHandle | null} */ this.$contentBox = null; /** @type {JQueryHandle | null} */ this.$listRoot = null; this.urlParams = new URLSearchParams(window.location.search); - this.currentPage = parseInt(this.urlParams.get("page") || "1", 10); + this.currentPage = Math.max(1, parseInt(this.urlParams.get("page") || "1", 10) || 1); /** @type {number | null} */ this.maxPage = null; + /** @type {number} */ this._queryGeneration = 0; /** @type {string | null} */ this.keyword = this.urlParams.get("keyword") || null; } getName() { @@ -43,10 +44,10 @@ export class Fc2By123AvPlugin extends BasePlugin { e.contents().first().replaceWith("123Av"), e.css("marginBottom", "0"), e.append('\n
    \n \n \n \n
    \n '), $("#search-123av-keyword").val(this.keyword), $("#search-123av-btn").on("click", (async () => { let e = String($("#search-123av-keyword").val() || "").trim(); - e && (this.keyword = e, utils.setHrefParam("keyword", e), await this.handleQuery()); + e && (this.keyword = e, utils.setHrefParam("keyword", e), this.currentPage = 1, this.maxPage = null, utils.setHrefParam("page", 1), await this.handleQuery()); })), $("#clear-123av-btn").on("click", (async () => { $("#search-123av-keyword").val(""), this.keyword = "", utils.setHrefParam("keyword", ""), - $(".page-box").show(), await this.handleQuery(); + this.currentPage = 1, this.maxPage = null, utils.setHrefParam("page", 1), $(".page-box").show(), await this.handleQuery(); })), $(".empty-message").remove(), $("#foldCategoryBtn").remove(), this.$contentBox.children(".box").remove(), $("#sort-toggle-btn").remove(), this.$contentBox.append(this.$listRoot), @@ -80,12 +81,15 @@ export class Fc2By123AvPlugin extends BasePlugin { } async handleQuery() { let e = loading(); + const generation = ++this._queryGeneration; try { $(".page-box").show(); const scope = await this.getRuntimeService("scope")(); + if (generation !== this._queryGeneration) return; const result = await this.getRuntimeService("movie").catalog("av123", { page: this.currentPage, keyword: this.keyword || "" }, { scope }); + if (generation !== this._queryGeneration) return; const i = result.items; - if (result.maxPage) this.maxPage = result.maxPage, this.renderPagination(); + this.maxPage = Math.max(1, Number(result.maxPage) || 1), this.currentPage = Math.min(Math.max(1, this.currentPage), this.maxPage), this.renderPagination(); if (0 === i.length) { clog.log(i), show.error("无结果"); clog.error("123AV 获取数据失败"); diff --git a/src/plugins/external-search/fc2.js b/src/plugins/external-search/fc2.js index b2225c4b..a550ae46 100644 --- a/src/plugins/external-search/fc2.js +++ b/src/plugins/external-search/fc2.js @@ -27,6 +27,7 @@ import { createFc2DetailContext, createFc2DetailShell } from "../status/detail-w * @property {(name: string) => JQueryHandle} getSlot * @property {(name: string) => JQueryHandle} getSection * @property {() => void} destroy + * @property {Set | undefined} galleryUrls */ /** @typedef {{ id: string, name: string, gender?: number }} MovieActor */ /** @typedef {{ title?: string, carNum?: string, releaseDate?: string, score?: number | string, duration?: number | string, actors?: MovieActor[], imageUrls?: string[] }} Fc2Movie */ @@ -184,6 +185,7 @@ export class Fc2Plugin extends BasePlugin { void Promise.resolve().then((() => this.getRuntimeService("scope")())).then((/** @type {any} */ scope) => renderScreenshotPanel({ target: screenshot, carNum: context.carNum.replace("FC2-", ""), screenshot: this.getRuntimeService("screenshot"), settings: this.getRuntimeService("settings").snapshot(), scope, isActive: context.isAlive, + providerId: "javstore", isDuplicate: url => Boolean(context.galleryUrls?.has(url)), })).then((/** @type {unknown} */ result) => { if (context.isAlive() && !result && !screenshot.children().length) screenshot.remove(); }).catch((error => { context.isAlive() && screenshot.remove(), clog.error("FC2 剧照初始化失败", error); })); diff --git a/src/plugins/external-search/top250.js b/src/plugins/external-search/top250.js index 3bbef769..a30d2754 100644 --- a/src/plugins/external-search/top250.js +++ b/src/plugins/external-search/top250.js @@ -56,11 +56,13 @@ export class Top250Plugin extends BasePlugin { } async handleTop() { if (!window.location.href.includes("handleTop=1")) return; + const hitShow = this.getOptionalDependency("HitShowPlugin"); + if (!hitShow) return void show.info("热播列表功能已禁用"); const e = new URLSearchParams(window.location.search); let t = e.get("handleType") || "all", n = e.get("type_value") || ""; this.has_cnsub = e.get("has_cnsub") || ""; let a = Number(e.get("page")) || 1; - this.toolBar(t, n, a), this.hookPage(); + this.hookPage(), this.toolBar(t, n, a); let i = this.$listRoot; i.html(""); let s = loading(); @@ -72,10 +74,8 @@ export class Top250Plugin extends BasePlugin { let t = e.data.movies; if (0 === t.length) return show.error("无数据"), void s.close(); this.movies = t; - const n = t.filter(((/** @type {TopMovie} */ e) => "1" === this.has_cnsub ? e.has_cnsub : "0" !== this.has_cnsub || !e.has_cnsub)), a = this.getOptionalDependency("HitShowPlugin"); - if (!a) return void show.info("热播列表功能已禁用"); - let r = a.markDataListHtml(n); - i.html(r), await a.initializeRenderedList(), await a.loadScore(n), o = !0; + const n = t.filter(((/** @type {TopMovie} */ e) => "1" === this.has_cnsub ? e.has_cnsub : "0" !== this.has_cnsub || !e.has_cnsub)), r = hitShow.markDataListHtml(n); + i.html(r), await hitShow.initializeRenderedList(), await hitShow.loadScore(n), o = !0; } else clog.error(e), i.html(`

    ${escapeHtml(l)}

    `), show.error(l), "JWTVerificationError" === c && (removeStoredEncryptedCredential(me), await this.checkLogin(null, new URLSearchParams(window.location.search))), o = !0; } catch (r) { diff --git a/src/plugins/image-viewer/cover-button.js b/src/plugins/image-viewer/cover-button.js index 380db80d..1e56c225 100644 --- a/src/plugins/image-viewer/cover-button.js +++ b/src/plugins/image-viewer/cover-button.js @@ -77,6 +77,7 @@ export class CoverButtonPlugin extends BasePlugin { } /** @param {JQueryHandle | Element | null} [items] */ async addSvgBtn(items = null) { + if (!this.getOptionalDependency("ListPagePlugin")) return; (items ? $(items).toArray() : $(this.getSelector().itemSelector).toArray()).forEach(((/** @type {Element} */ element) => { const item = $(element); if (item.find(".tool-box").length || l && item.find(".avatar-box").length) return; diff --git a/src/plugins/image-viewer/screenshot.js b/src/plugins/image-viewer/screenshot.js index 730c49ae..f987fa8e 100644 --- a/src/plugins/image-viewer/screenshot.js +++ b/src/plugins/image-viewer/screenshot.js @@ -80,7 +80,7 @@ export class ScreenShotPlugin extends BasePlugin { /** @param {string} carNum */ async getServiceScreenshot(carNum) { const scope = await this.getRuntimeService("scope")(); - const images = await this.getRuntimeService("screenshot").resolve({ carNum }, { scope }); + const images = await this.getRuntimeService("screenshot").resolve({ carNum }, { providerId: "javstore", scope }); const image = Array.isArray(images) ? images[0] : images; return image?.url ? { url: image.url, source: image.providerId || "javstore", detailUrl: null } : null; } diff --git a/src/plugins/registry.js b/src/plugins/registry.js index ff60dad1..8c5d872e 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -69,7 +69,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("detail.workspace", "detail", DetailWorkspacePlugin, ["javdb", "javbus"], { javdb: 15, javbus: 11 }, [PORT.host]), manifest("detail.reviews", "detail", ReviewPlugin, ["javdb", "javbus"], { javdb: 16, javbus: 13 }, [PORT.host, SERVICE.review, SERVICE.movie, SERVICE.settings, SERVICE.storage]), manifest("detail.related", "detail", RelatedPlugin, ["javdb"], { javdb: 17 }, [PORT.host, SERVICE.related, SERVICE.settings]), - manifest("detail.page-state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state]), + manifest("detail.page-state-actions", "detail", DetailPageButtonPlugin, ["javdb", "javbus"], { javdb: 18, javbus: 12 }, [SERVICE.movie, SERVICE.dialog, SERVICE.subtitle, SERVICE.state, SERVICE.settings]), manifest("detail.native-magnets", "detail", HighlightMagnetPlugin, ["javdb", "javbus"], { javdb: 19, javbus: 15 }, [PORT.host, SERVICE.settings]), manifest("detail.javdb-preview", "detail", PreviewVideoPlugin, ["javdb"], { javdb: 20 }, [SERVICE.storage, SERVICE.settings, SERVICE.movie]), manifest("library.keyword-filter", "library", FilterTitleKeywordPlugin, ["javdb", "javbus"], { javdb: 21, javbus: 14 }), diff --git a/src/plugins/status/detail-page-button.js b/src/plugins/status/detail-page-button.js index fd10a812..5e4c3c7f 100644 --- a/src/plugins/status/detail-page-button.js +++ b/src/plugins/status/detail-page-button.js @@ -51,13 +51,13 @@ export class DetailPageButtonPlugin extends BasePlugin { } }); })); - const a = this.getOptionalDependency("HighlightMagnetPlugin"), i = await storageManager.getSetting("enableMagnetsFilter", _); + const a = this.getOptionalDependency("HighlightMagnetPlugin"), settings = this.getRuntimeService("settings"), i = settings.snapshot().enableMagnetsFilter ?? _; a || $("#enable-magnets-filter").remove(), $("#magnets-span").text(i === _ ? "关闭磁力过滤" : "开启磁力过滤"), i === _ && a?.doFilterMagnet?.(), - $("#enable-magnets-filter").on("click", ((/** @type {ActionEvent} */ e) => { + $("#enable-magnets-filter").on("click", (async (/** @type {ActionEvent} */ e) => { let t = $("#magnets-span"); if (!a) return; - "关闭磁力过滤" === t.text() ? (a.showAll(), t.text("开启磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", C)) : (a.doFilterMagnet(), - t.text("关闭磁力过滤"), storageManager.saveSettingItem("enableMagnetsFilter", _)); + "关闭磁力过滤" === t.text() ? (a.showAll(), t.text("开启磁力过滤"), await settings.set("enableMagnetsFilter", C)) : (a.doFilterMagnet(), + t.text("关闭磁力过滤"), await settings.set("enableMagnetsFilter", _)); })), $("#search-subtitle-btn").on("click", ((/** @type {ActionEvent} */ e) => { const target = this.getRuntimeService("movie").sourceUrls({ carNum: t }, ["subtitlecat"])[0]?.url; if (target) utils.openPage(target, t, !1, e); diff --git a/src/plugins/status/mobile-bottom-bar.js b/src/plugins/status/mobile-bottom-bar.js index c2b5e2a0..52b8fcb5 100644 --- a/src/plugins/status/mobile-bottom-bar.js +++ b/src/plugins/status/mobile-bottom-bar.js @@ -286,16 +286,17 @@ export class MobileBottomBarPlugin extends BasePlugin { } createMenu() { const item = (/** @type {string} */ action, /** @type {string} */ label, /** @type {string} */ attributes = "") => ``, group = (/** @type {string} */ content) => `
    ${content}
    `, divider = ''; + const hasListPageButton = !!this.getOptionalDependency("ListPageButtonPlugin"), hasListPage = !!this.getOptionalDependency("ListPagePlugin"), hasNewVideo = !!this.getOptionalDependency("NewVideoPlugin"), hasBlacklist = !!this.getOptionalDependency("BlacklistPlugin"), hasSetting = !!this.getOptionalDependency("SettingPlugin"), hasDetailPageButton = !!this.getOptionalDependency("DetailPageButtonPlugin"), hasHighlightMagnet = !!this.getOptionalDependency("HighlightMagnetPlugin"), hasMagnetHub = !!this.getOptionalDependency("MagnetHubPlugin"); let items; if (window.isListPage) { const requestedSortMethod = this.getRuntimeService("settings").snapshot().sortMethod, sortLabels = { default: "默认", rateCount: "评价人数", date: "时间" }, sortMethod = "string" === typeof requestedSortMethod && requestedSortMethod in sortLabels ? /** @type {keyof typeof sortLabels} */ (requestedSortMethod) : "default", sortLabel = sortLabels[sortMethod], activeFilter = normalizeQuickFilterKey(this.getOptionalDependency("ListPagePlugin")?.activeQuickFilter), filterOptions = [ ...PRIMARY_QUICK_FILTERS, ...SECONDARY_QUICK_FILTERS ].map(((filter, index) => `${index === PRIMARY_QUICK_FILTERS.length ? '' : ""}`)).join(""), sortOptions = Object.entries(sortLabels).map((([value, label]) => ``)).join(""); - items = group(item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') + item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"')) + divider + group(item("logger", "运行日志") + item("setting", "设置")) + ``; + items = group((hasListPageButton ? item("check", "开始鉴定") : "") + (hasNewVideo ? item("newVideo", "新作品") : "") + (hasBlacklist ? item("blacklist", "黑名单") : "") + (hasListPageButton ? item("sort", `排序: ${sortLabel}`, 'aria-haspopup="menu" aria-expanded="false"') : "") + (hasListPage ? item("quickFilter", `筛选:${QUICK_FILTER_LABELS[activeFilter]}`, 'aria-haspopup="menu" aria-expanded="false"') : "")) + divider + group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")) + (hasListPage ? `` : "") + (hasListPageButton ? `` : ""); } else if (window.isDetailPage) { const statusDefs = [ { action: "filter", icon: m, label: "屏蔽", key: "filter" }, { action: "fav", icon: v, label: "收藏", key: "fav" }, { action: "down", icon: y, label: "已下载", key: "down" }, { action: "watch", icon: k, label: "已观看", key: "watch" } ]; - items = group(statusDefs.map((definition => item(definition.action, `${definition.icon}`, `aria-label="${definition.label}" aria-pressed="false" data-label="${definition.label}"`))).join("")) + divider + group(item("magnetFilter", "磁力过滤") + item("magnet", "磁力搜索") + item("subtitle", "字幕")) + divider + group(item("logger", "运行日志") + item("setting", "设置")); - } else items = group(item("logger", "运行日志") + item("setting", "设置")); + items = group(hasDetailPageButton ? statusDefs.map((definition => item(definition.action, `${definition.icon}`, `aria-label="${definition.label}" aria-pressed="false" data-label="${definition.label}"`))).join("") : "") + divider + group((hasHighlightMagnet ? item("magnetFilter", "磁力过滤") : "") + (hasMagnetHub ? item("magnet", "磁力搜索") : "") + (hasDetailPageButton ? item("subtitle", "字幕") : "")) + divider + group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")); + } else items = group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : "")); return $(``); } /** 刷新详情页菜单的状态指示 */ diff --git a/src/services/screenshot-service.js b/src/services/screenshot-service.js index 3e2da7dd..72819e81 100644 --- a/src/services/screenshot-service.js +++ b/src/services/screenshot-service.js @@ -1,25 +1,51 @@ // @ts-check +/** 未显式指定 provider 时,长缩略图只允许使用的默认来源。 */ +const DEFAULT_SCREENSHOT_PROVIDER = "javstore"; + export class ScreenshotService { /** @param {import("../app/provider-registry.js").ProviderRegistry} providers @param {import("../app/integration-registry.js").IntegrationRegistry | null} [integrations] */ constructor(providers, integrations = null) { this.providers = providers; this.integrations = integrations; } - /** @param {Record} movieRef @param {Record} [context] */ + /** + * 解析影片长缩略图。 + * 指定 providerId 时只使用该来源,无结果返回 null,不 fallback 到其他 movie.images; + * 未指定时先按 Provider 优先级,再回退到默认长缩略图来源(当前仅 javstore)。 + * @param {Record} movieRef + * @param {{ providerId?: string, scope?: unknown }} [context] + */ async resolve(movieRef, context = {}) { - for (const provider of await this.providers.getAvailable("screenshot", context)) { - try { - const result = await provider.resolve(movieRef, context); - this.providers.updateHealth(provider.id, { ok: true }); + if (context.providerId) { + const provider = this.providers.get?.(context.providerId); + if (provider && provider.enabled !== false && provider.capabilities?.includes("screenshot")) { + const result = await this.resolveFromProvider(provider, movieRef, context); if (result) return result; - } catch (error) { this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } + } + return this.resolveIntegration(context.providerId, movieRef, context); } - for (const manifest of this.integrations?.list("movie.images") ?? []) { - const adapter = this.integrations?.getAdapter(manifest.id); - if (typeof adapter?.getImages !== "function") continue; - const result = await adapter.getImages(movieRef, context); - if (Array.isArray(result) && result.length) return result; + for (const provider of await this.providers.getAvailable("screenshot", context)) { + const result = await this.resolveFromProvider(provider, movieRef, context); + if (result) return result; } + return this.resolveIntegration(DEFAULT_SCREENSHOT_PROVIDER, movieRef, context); + } + /** @param {Record} provider @param {Record} movieRef @param {Record} context */ + async resolveFromProvider(provider, movieRef, context) { + try { + const result = await provider.resolve(movieRef, context); + this.providers.updateHealth(provider.id, { ok: true }); + if (result) return result; + } catch (error) { this.providers.updateHealth(provider.id, { ok: false, error: error instanceof Error ? error.message : String(error) }); } return null; } + /** @param {string} providerId @param {Record} movieRef @param {Record} context */ + async resolveIntegration(providerId, movieRef, context) { + const manifest = this.integrations?.list("movie.images")?.find((item) => item.id === providerId); + if (!manifest) return null; + const adapter = this.integrations?.getAdapter(manifest.id); + if (typeof adapter?.getImages !== "function") return null; + const result = await adapter.getImages(movieRef, context); + return Array.isArray(result) && result.length ? result : null; + } /** @param {Record} movieRef */ getSearchUrl(movieRef) { for (const manifest of this.integrations?.list("movie.images") ?? []) { diff --git a/src/ui/detail/fc2-workspace-view.js b/src/ui/detail/fc2-workspace-view.js index 5ca6761b..ae4f89ee 100644 --- a/src/ui/detail/fc2-workspace-view.js +++ b/src/ui/detail/fc2-workspace-view.js @@ -20,9 +20,16 @@ export function renderFc2Gallery(context, images) { const jq = /** @type {any} */ (globalThis).$; const urls = [...new Set((images || []).map(normalizeUrl).filter(Boolean))]; const grid = context.root.find('[data-jhs-role="gallery-grid"]').empty(), preview = context.root.find('[data-jhs-role="main-preview"]').empty(); + context.galleryUrls = new Set(urls); if (!urls.length) return renderFc2State(grid, "暂无剧照"); preview.append(jq("").attr({ src: urls[0], alt: `${context.carNum} 预览`, loading: "eager" })); urls.forEach((url, index) => grid.append(jq('').attr("aria-label", `查看剧照 ${index + 1}`).append(jq("").addClass("jhs-fc2-gallery__image").attr({ src: url, alt: `剧照 ${index + 1}`, loading: "lazy" })))); + // 去重保险:若额外长缩略图复用了 Gallery 图片,隐藏 screenshot 区域。 + const screenshot = context.root.find('[data-jhs-role="screenshot"]'); + screenshot.find("img").each((/** @type {number} */ _, /** @type {HTMLImageElement} */ img) => { + const src = img.getAttribute("src"); + if (src && context.galleryUrls.has(src)) screenshot.empty(); + }); } /** @param {any} context @param {any} movieService */ diff --git a/src/ui/detail/screenshot-panel.js b/src/ui/detail/screenshot-panel.js index e9cac0cc..da631ef1 100644 --- a/src/ui/detail/screenshot-panel.js +++ b/src/ui/detail/screenshot-panel.js @@ -23,19 +23,20 @@ function renderImage(host, url, alt) { } /** - * @param {{target: any, carNum: string, screenshot: import("../../services/screenshot-service.js").ScreenshotService, settings: Record, scope?: import("../../core/lifecycle-scope.js").LifecycleScope, isActive?: () => boolean}} options + * @param {{target: any, carNum: string, screenshot: import("../../services/screenshot-service.js").ScreenshotService, settings: Record, scope?: import("../../core/lifecycle-scope.js").LifecycleScope, isActive?: () => boolean, providerId?: string, isDuplicate?: (url: string) => boolean}} options */ export async function renderScreenshotPanel(options) { const jq = /** @type {any} */ (globalThis).$; - const host = jq(options.target), isActive = options.isActive ?? (() => true); + const host = jq(options.target), isActive = options.isActive ?? (() => true), providerId = options.providerId ?? "javstore"; if (!host.length || options.settings.enableLoadScreenShot === "no") return host.empty(), null; const load = async (resultHost = host) => { resultHost.empty().append(jq("
    ").addClass("jhs-panel-state").text("正在加载缩略图…")); try { - const images = await options.screenshot.resolve({ carNum: options.carNum }, { scope: options.scope }); + const images = await options.screenshot.resolve({ carNum: options.carNum }, { providerId, scope: options.scope }); if (!isActive()) return null; const image = Array.isArray(images) ? images[0] : images; if (!image?.url) return resultHost.empty(), null; + if (options.isDuplicate?.(image.url)) return resultHost.empty(), null; return renderImage(resultHost, image.url, "缩略图"); } catch (error) { if (!isActive()) return null; diff --git a/tests/detail-car-num.test.js b/tests/detail-car-num.test.js index 67549d03..9906ebda 100644 --- a/tests/detail-car-num.test.js +++ b/tests/detail-car-num.test.js @@ -199,7 +199,7 @@ describe("detail car number propagation", () => { await expect(new Plugin().getServiceScreenshot("IPZZ-479")).resolves.toEqual({ url: "https://img.javstore.net/preview.jpg", source: "javstore", detailUrl: null, }); - expect(resolve).toHaveBeenCalledWith({ carNum: "IPZZ-479" }, { scope: { id: "detail" } }); + expect(resolve).toHaveBeenCalledWith({ carNum: "IPZZ-479" }, { providerId: "javstore", scope: { id: "detail" } }); }); it("normalizes a legacy JavStore URL again at the image rendering boundary", () => { diff --git a/tests/fc2-by-123av.test.js b/tests/fc2-by-123av.test.js new file mode 100644 index 00000000..4dc87bdd --- /dev/null +++ b/tests/fc2-by-123av.test.js @@ -0,0 +1,94 @@ +import { readTestFile } from "./helpers/read-test-file.js"; +import { join } from "node:path"; +import vm from "node:vm"; +import { JSDOM } from "jsdom"; +import jqueryFactory from "jquery"; +import { describe, expect, it, vi } from "vitest"; + +function loadFc2123Av({ search = "", page = 1 } = {}) { + const dom = new JSDOM('

    123Av

    ', { url: `https://javdb.com/advanced_search?type=100&keyword=${encodeURIComponent(search)}&page=${page}` }); + const $ = jqueryFactory(dom.window), catalog = vi.fn(async () => ({ items: [], maxPage: 1 })), loadingClose = vi.fn(), setHrefParam = vi.fn(); + const host = { + locateListRoot: () => dom.window.document.querySelector(".container"), + getListContainer: () => dom.window.document.querySelector(".container"), + createOwnedListRoot(classes = []) { const root = dom.window.document.createElement("div"); root.classList.add("movie-list", ...classes); return root; }, + }; + const context = vm.createContext({ + BasePlugin: class { + getBean() { return undefined; } + getRuntimeService(name) { return { host, scope: async () => ({}), movie: { catalog }, settings: { snapshot: () => ({ translateTitle: "_" }) }, translation: {} }[name]; } + }, + _: "_", o: "", escapeHtml: value => $("").text(String(value ?? "")).html(), + renderTranslatedTitle: async () => {}, createFc2SourceLinks: () => "", renderFc2Gallery: () => {}, renderFc2State: () => {}, + $, document: dom.window.document, window: dom.window, URLSearchParams, + loading: () => ({ close: loadingClose }), show: { info: vi.fn(), error: vi.fn(), ok: vi.fn() }, + clog: { error: vi.fn(), log: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + utils: { setHrefParam, smoothScrollToTop: vi.fn(async () => {}) }, + }); + const source = readTestFile(join(process.cwd(), "src/plugins/external-search/fc2-by-123av.js"), "utf8"); + vm.runInContext(`${source};globalThis.Fc2By123AvPlugin=Fc2By123AvPlugin`, context); + return { plugin: new context.Fc2By123AvPlugin(), $, catalog, setHrefParam, loadingClose, show: context.show, dom }; +} + +describe("Fc2By123AvPlugin page-state handling", () => { + it("resets page and maxPage when searching with a new keyword", async () => { + const { plugin, dom, catalog, setHrefParam } = loadFc2123Av({ page: 8 }); + plugin.hookPage(); + dom.window.document.querySelector("#search-123av-keyword").value = "fc2-123"; + dom.window.document.querySelector("#search-123av-btn").click(); + expect(plugin.currentPage).toBe(1); + expect(plugin.maxPage).toBe(null); + expect(setHrefParam).toHaveBeenCalledWith("page", 1); + expect(setHrefParam).toHaveBeenCalledWith("keyword", "fc2-123"); + await vi.waitFor(() => expect(catalog).toHaveBeenCalledWith("av123", { page: 1, keyword: "fc2-123" }, expect.anything())); + expect(plugin.currentPage).toBe(1); + expect(plugin.maxPage).toBe(1); + }); + + it("clears keyword, page and maxPage when resetting the search", async () => { + const { plugin, dom, catalog, setHrefParam } = loadFc2123Av({ search: "old", page: 3 }); + plugin.hookPage(); + dom.window.document.querySelector("#search-123av-keyword").value = "old"; + plugin.currentPage = 4, plugin.maxPage = 20; + dom.window.document.querySelector("#clear-123av-btn").click(); + expect(plugin.keyword).toBe(""); + expect(plugin.currentPage).toBe(1); + expect(plugin.maxPage).toBe(null); + expect(setHrefParam).toHaveBeenCalledWith("keyword", ""); + await vi.waitFor(() => expect(catalog).toHaveBeenCalledWith("av123", { page: 1, keyword: "" }, expect.anything())); + }); + + it("normalizes maxPage and clamps currentPage into bounds", async () => { + const { plugin, catalog } = loadFc2123Av({ page: 99 }); + catalog.mockResolvedValue({ items: [], maxPage: 5 }); + await plugin.handleQuery(); + expect(plugin.maxPage).toBe(5); + expect(plugin.currentPage).toBe(5); + }); + + it("treats a missing maxPage as 1", async () => { + const { plugin, catalog } = loadFc2123Av({ page: 3 }); + catalog.mockResolvedValue({ items: [], maxPage: null }); + await plugin.handleQuery(); + expect(plugin.maxPage).toBe(1); + expect(plugin.currentPage).toBe(1); + }); + + it("drops stale results from an earlier overlapping request", async () => { + const { plugin, catalog } = loadFc2123Av(); + plugin.hookPage(); + let resolveFirst, resolveSecond; + const first = new Promise(resolve => { resolveFirst = resolve; }), second = new Promise(resolve => { resolveSecond = resolve; }); + catalog.mockImplementationOnce(() => first).mockImplementationOnce(() => second); + const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + const p1 = plugin.handleQuery(); + await flush(); + const p2 = plugin.handleQuery(); + await flush(); + resolveFirst({ items: [{ url: "/m/OLD", title: "old" }], maxPage: 10 }); + resolveSecond({ items: [{ url: "/m/NEW", title: "new" }], maxPage: 10 }); + await Promise.all([p1, p2]); + expect(plugin.$listRoot.html()).toContain("NEW"); + expect(plugin.$listRoot.html()).not.toContain("OLD"); + }); +}); diff --git a/tests/fc2-gallery-dedup.test.js b/tests/fc2-gallery-dedup.test.js new file mode 100644 index 00000000..90898de7 --- /dev/null +++ b/tests/fc2-gallery-dedup.test.js @@ -0,0 +1,48 @@ +// @vitest-environment jsdom +import jquery from "jquery"; +import { beforeEach, describe, expect, it } from "vitest"; +import { renderFc2Gallery } from "../src/ui/detail/fc2-workspace-view.js"; + +beforeEach(() => { + globalThis.$ = jquery; + document.body.innerHTML = ""; +}); + +function makeContext() { + const root = jquery('
    ').append( + '
    ' + ).appendTo(document.body); + return { root, carNum: "FC2-123", galleryUrls: undefined }; +} + +describe("FC2 gallery dedup insurance", () => { + it("records gallery urls on the context", () => { + const context = makeContext(); + renderFc2Gallery(context, [ "https://fc2content.net/a/1.jpg", "https://fc2content.net/a/2.jpg" ]); + expect([ ...context.galleryUrls ]).toEqual([ "https://fc2content.net/a/1.jpg", "https://fc2content.net/a/2.jpg" ]); + expect(context.root.find('[data-jhs-role="main-preview"] img').attr("src")).toBe("https://fc2content.net/a/1.jpg"); + }); + + it("records an empty gallery so a later duplicate check cannot match", () => { + const context = makeContext(); + renderFc2Gallery(context, []); + expect(context.galleryUrls.size).toBe(0); + expect(context.root.find('[data-jhs-role="gallery-grid"]').text()).toBe("暂无剧照"); + }); + + it("hides an extra screenshot whose url duplicates a gallery image", () => { + const context = makeContext(); + const shot = context.root.find('[data-jhs-role="screenshot"]'); + shot.append(jquery('')); + renderFc2Gallery(context, [ "https://fc2content.net/a/1.jpg", "https://fc2content.net/a/2.jpg" ]); + expect(shot.children().length).toBe(0); + }); + + it("keeps an extra screenshot that is not in the gallery", () => { + const context = makeContext(); + const shot = context.root.find('[data-jhs-role="screenshot"]'); + shot.append(jquery('')); + renderFc2Gallery(context, [ "https://fc2content.net/a/1.jpg" ]); + expect(shot.children().length).toBe(1); + }); +}); diff --git a/tests/fc2-owned-workspace.test.js b/tests/fc2-owned-workspace.test.js index 938ffd27..8780e82e 100644 --- a/tests/fc2-owned-workspace.test.js +++ b/tests/fc2-owned-workspace.test.js @@ -128,6 +128,14 @@ describe("FC2 owned detail workspace", () => { expect(fc2Source).toContain('target.searchParams.set("source", source)'); }); + it("keeps the native FC2 entry free of a hard list.core dependency so disabling ListPagePlugin stays safe", () => { + expect(fc2Source).not.toContain('getBean("ListPagePlugin")'); + expect(fc2Source).toContain('o.includes("collection_codes?movieId")'); + expect(fc2Source).toContain("openFc2Dialog("); + expect(listPageSource).toContain("protectFc2Navigation(root)"); + expect(listPageSource).toContain('this.getBean("Fc2Plugin")'); + }); + it("restores source links, magnet metadata and scoped quality filtering", () => { expect(fc2ViewSource).toContain("FC2PPVDB"); expect(fc2ViewSource).toContain("FC2 市场"); diff --git a/tests/jhs-select-global-handler.test.js b/tests/jhs-select-global-handler.test.js new file mode 100644 index 00000000..8127e0b2 --- /dev/null +++ b/tests/jhs-select-global-handler.test.js @@ -0,0 +1,51 @@ +import { readTestFile } from "./helpers/read-test-file.js"; +import { join } from "node:path"; +import vm from "node:vm"; +import { JSDOM } from "jsdom"; +import jqueryFactory from "jquery"; +import { describe, expect, it, vi } from "vitest"; + +const uiSource = readTestFile(join(process.cwd(), "src/core/ui-primitives.js"), "utf8"); +const selectSource = uiSource.slice(uiSource.indexOf("class JhsSelect")); + +function loadJhsSelect(html) { + const dom = new JSDOM(html, { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window); + const context = vm.createContext({ + window: dom.window, document: dom.window.document, Node: dom.window.Node, Event: dom.window.Event, $, + setTimeout, clearTimeout, queueMicrotask, clog: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() } + }); + vm.runInContext(`${selectSource};globalThis.JhsSelect=JhsSelect`, context); + return { dom, $, context, document: dom.window.document }; +} + +const fixture = ''; + +describe("JhsSelect global document click handler", () => { + it("registers a single namespaced document handler no matter how many selects are bound", () => { + const { $, context, document } = loadJhsSelect(fixture); + context.JhsSelect.enhance(document); + context.JhsSelect.enhance(document); + const clickEvents = $._data(document, "events")?.click || []; + expect(clickEvents.filter(handler => handler.namespace === "jhsSelect")).toHaveLength(1); + }); + + it("closes an open select when clicking outside the control and keeps it open when clicking inside", () => { + const { $, context, document } = loadJhsSelect(fixture); + const instance = context.JhsSelect.get(document.querySelector("#a")); + expect(instance).not.toBeNull(); + instance.open(); + expect($(".jhs-select-control.is-open")).toHaveLength(1); + $("body").trigger("click"); + expect($(".jhs-select-control.is-open")).toHaveLength(0); + instance.open(); + $(".jhs-select-control").trigger("click"); + expect($(".jhs-select-control.is-open")).toHaveLength(1); + }); + + it("closes the previously open select when a second one opens", () => { + const { $, context, document } = loadJhsSelect(fixture); + const a = context.JhsSelect.get(document.querySelector("#a")), b = context.JhsSelect.get(document.querySelector("#b")); + a.open(), b.open(); + expect($(".jhs-select-control.is-open")).toHaveLength(1); + }); +}); diff --git a/tests/mobile-fab.test.js b/tests/mobile-fab.test.js index 639d5fe8..16a6d2c3 100644 --- a/tests/mobile-fab.test.js +++ b/tests/mobile-fab.test.js @@ -6,11 +6,11 @@ import jqueryFactory from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it, vi } from "vitest"; -function loadMobilePlugin() { +function loadMobilePlugin({ beans: beanOverrides = null, isListPage = true, isDetailPage = false } = {}) { const dom = new JSDOM("", { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window); - dom.window.isListPage = true, dom.window.isDetailPage = false; + dom.window.isListPage = isListPage, dom.window.isDetailPage = isDetailPage; const listPage = { activeQuickFilter: "waitCheck", setQuickFilter: vi.fn(), syncQuickFilterUi: vi.fn() }, listButtons = { openWaitCheck: vi.fn(async () => {}), sortItems: vi.fn(async () => {}) }; - const beans = { ListPagePlugin: listPage, ListPageButtonPlugin: listButtons }; + const beans = beanOverrides ?? { ListPagePlugin: listPage, ListPageButtonPlugin: listButtons, NewVideoPlugin: {}, BlacklistPlugin: {}, SettingPlugin: {}, DetailPageButtonPlugin: {}, HighlightMagnetPlugin: {}, MagnetHubPlugin: {} }; const settings = { snapshot: () => ({ sortMethod: "default" }), set: vi.fn(async () => {}) }, host = { readMovieRef: vi.fn(() => ({ carNum: "ABC-123" })) }; class BasePlugin { getBean(name) { return beans[name]; } getRuntimeService(name) { return "host" === name ? host : settings; } } const labels = { all: "全部", waitCheck: "待鉴定", favorite: "收藏", hasDown: "下载", hasWatch: "已看", blockedItems: "屏蔽项", favoriteUndownloaded: "收藏未下载", favoriteUnwatched: "收藏未观看", downloadedUnwatched: "下载未观看", recent7d: "最近 7 天" }; @@ -37,6 +37,14 @@ describe("mobile list FAB", () => { expect(menu.find('[data-action="quickFilter"]')).toHaveLength(1); }); + it("renders only the logger item when every capability plugin is disabled", () => { + const { plugin } = loadMobilePlugin({ beans: {} }), menu = plugin.createMenu(); + expect(menu.find(".jhs-fab-menu-item")).toHaveLength(1); + expect(menu.find('[data-action="logger"]')).toHaveLength(1); + expect(menu.find('[data-action="setting"]')).toHaveLength(0); + expect(menu.find(".jhs-mobile-filter-menu, .jhs-mobile-sort-menu")).toHaveLength(0); + }); + it("calls the identification API directly instead of simulating a desktop click", async () => { const { listButtons, plugin } = loadMobilePlugin(); await plugin.handleAction("check"); @@ -49,3 +57,37 @@ describe("mobile list FAB", () => { expect(host.readMovieRef).toHaveBeenCalledOnce(); }); }); + +describe("mobile detail FAB", () => { + it("projects the four status actions, magnet filter/search and subtitle when their plugins are present", () => { + const { plugin } = loadMobilePlugin({ isListPage: false, isDetailPage: true }), menu = plugin.createMenu(); + expect(menu.find(".jhs-fab-menu-item")).toHaveLength(9); + expect(menu.find('[data-action="filter"], [data-action="fav"], [data-action="down"], [data-action="watch"]')).toHaveLength(4); + expect(menu.find('[data-action="magnetFilter"]')).toHaveLength(1); + expect(menu.find('[data-action="magnet"]')).toHaveLength(1); + expect(menu.find('[data-action="subtitle"]')).toHaveLength(1); + expect(menu.find('[data-action="logger"], [data-action="setting"]')).toHaveLength(2); + }); + + it("renders only the logger item on a detail page when every capability plugin is disabled", () => { + const { plugin } = loadMobilePlugin({ beans: {}, isListPage: false, isDetailPage: true }), menu = plugin.createMenu(); + expect(menu.find(".jhs-fab-menu-item")).toHaveLength(1); + expect(menu.find('[data-action="logger"]')).toHaveLength(1); + expect(menu.find('[data-action="setting"], [data-action="filter"], [data-action="magnetFilter"], [data-action="magnet"]')).toHaveLength(0); + }); + + it("drops the magnet filter item when HighlightMagnetPlugin is missing but keeps magnet search", () => { + const { plugin } = loadMobilePlugin({ beans: { DetailPageButtonPlugin: {}, MagnetHubPlugin: {} }, isListPage: false, isDetailPage: true }), menu = plugin.createMenu(); + expect(menu.find('[data-action="magnetFilter"]')).toHaveLength(0); + expect(menu.find('[data-action="magnet"]')).toHaveLength(1); + expect(menu.find('[data-action="subtitle"]')).toHaveLength(1); + expect(menu.find('[data-action="filter"]')).toHaveLength(1); + }); + + it("drops status dots, magnet search and subtitle when DetailPageButtonPlugin and MagnetHubPlugin are missing", () => { + const { plugin } = loadMobilePlugin({ beans: { HighlightMagnetPlugin: {} }, isListPage: false, isDetailPage: true }), menu = plugin.createMenu(); + expect(menu.find('[data-action="filter"], [data-action="subtitle"], [data-action="magnet"]')).toHaveLength(0); + expect(menu.find('[data-action="magnetFilter"]')).toHaveLength(1); + expect(menu.find('[data-action="logger"]')).toHaveLength(1); + }); +}); diff --git a/tests/screenshot-panel.test.js b/tests/screenshot-panel.test.js index 382ae8d4..869dac52 100644 --- a/tests/screenshot-panel.test.js +++ b/tests/screenshot-panel.test.js @@ -18,3 +18,16 @@ it("keeps manual mode request-free until selected", async () => { jquery("#target button").trigger("click"); await vi.waitFor(() => expect(resolve).toHaveBeenCalledOnce()); }); + +it("resolves the long-thumbnail only through javstore", async () => { + const resolve = vi.fn(async () => [ { url: "https://img.javstore.net/test.jpg", providerId: "javstore" } ]); + await renderScreenshotPanel({ target: jquery("#target"), carNum: "ABC-123", screenshot: { resolve }, settings: {} }); + expect(resolve).toHaveBeenCalledWith({ carNum: "ABC-123" }, { providerId: "javstore", scope: undefined }); +}); + +it("skips a screenshot that duplicates a gallery image", async () => { + const resolve = vi.fn(async () => [ { url: "https://fc2content.net/gallery/1.jpg", providerId: "fc2content" } ]); + const isDuplicate = vi.fn((url) => url === "https://fc2content.net/gallery/1.jpg"); + await expect(renderScreenshotPanel({ target: jquery("#target"), carNum: "FC2-123", screenshot: { resolve }, settings: {}, isDuplicate })).resolves.toBeNull(); + expect(jquery("#target").children().length).toBe(0); +}); diff --git a/tests/screenshot-service.test.js b/tests/screenshot-service.test.js index 2face00a..685e2833 100644 --- a/tests/screenshot-service.test.js +++ b/tests/screenshot-service.test.js @@ -28,3 +28,57 @@ describe("ScreenshotService", () => { expect(getSearchUrl).toHaveBeenCalledWith({ carNum: "ABC-123" }); }); }); + +describe("ScreenshotService explicit provider resolution", () => { + const threeIntegrations = () => ({ list: vi.fn(() => [ { id: "javstore" }, { id: "javbus" }, { id: "fc2content" } ]), getAdapter: vi.fn() }); + + it("resolves an explicit javstore hit and does not consult javbus or fc2content", async () => { + const integrations = threeIntegrations(); + const getImages = vi.fn(async () => [ { url: "https://javstore.net/long.jpg", providerId: "javstore" } ]); + integrations.getAdapter.mockReturnValue({ getImages }); + const providers = { getAvailable: vi.fn(async () => []), get: vi.fn(() => null), updateHealth: vi.fn() }; + await expect(new ScreenshotService(providers, integrations).resolve({ carNum: "ABC-123" }, { providerId: "javstore" })).resolves.toEqual([ + { url: "https://javstore.net/long.jpg", providerId: "javstore" }, + ]); + expect(integrations.getAdapter).toHaveBeenCalledTimes(1); + expect(integrations.getAdapter).toHaveBeenCalledWith("javstore"); + expect(getImages).toHaveBeenCalledWith({ carNum: "ABC-123" }, { providerId: "javstore" }); + }); + + it("returns null on an explicit javstore miss without falling back to javbus or fc2content", async () => { + const integrations = threeIntegrations(); + const getImages = vi.fn(async () => []); + integrations.getAdapter.mockReturnValue({ getImages }); + const providers = { getAvailable: vi.fn(async () => []), get: vi.fn(() => null), updateHealth: vi.fn() }; + await expect(new ScreenshotService(providers, integrations).resolve({ carNum: "ABC-123" }, { providerId: "javstore" })).resolves.toBeNull(); + expect(integrations.getAdapter).toHaveBeenCalledTimes(1); + expect(integrations.getAdapter).toHaveBeenCalledWith("javstore"); + }); + + it("returns null for an unknown explicit providerId without calling any adapter", async () => { + const integrations = threeIntegrations(); + const providers = { getAvailable: vi.fn(async () => []), get: vi.fn(() => null), updateHealth: vi.fn() }; + await expect(new ScreenshotService(providers, integrations).resolve({ carNum: "ABC-123" }, { providerId: "missing" })).resolves.toBeNull(); + expect(integrations.getAdapter).not.toHaveBeenCalled(); + }); + + it("defaults to javstore only and never lets javbus or fc2content enter the long-thumbnail path", async () => { + const integrations = threeIntegrations(); + const getImages = vi.fn(async () => [ { url: "https://javstore.net/long.jpg", providerId: "javstore" } ]); + integrations.getAdapter.mockReturnValue({ getImages }); + const providers = { getAvailable: vi.fn(async () => []), updateHealth: vi.fn() }; + await expect(new ScreenshotService(providers, integrations).resolve({ carNum: "ABC-123" })).resolves.toEqual([ + { url: "https://javstore.net/long.jpg", providerId: "javstore" }, + ]); + expect(integrations.getAdapter).toHaveBeenCalledTimes(1); + expect(integrations.getAdapter).toHaveBeenCalledWith("javstore"); + }); + + it("honors an explicit runtime screenshot provider before the integration", async () => { + const provider = { id: "javstore", capabilities: [ "screenshot" ], resolve: vi.fn(async () => [ "provider-long" ]) }; + const providers = { get: vi.fn(() => provider), updateHealth: vi.fn() }; + const integrations = threeIntegrations(); + await expect(new ScreenshotService(providers, integrations).resolve({ carNum: "ABC-123" }, { providerId: "javstore" })).resolves.toEqual([ "provider-long" ]); + expect(integrations.getAdapter).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/top250-loading.test.js b/tests/top250-loading.test.js new file mode 100644 index 00000000..701233ea --- /dev/null +++ b/tests/top250-loading.test.js @@ -0,0 +1,54 @@ +import { readTestFile } from "./helpers/read-test-file.js"; +import { join } from "node:path"; +import vm from "node:vm"; +import { JSDOM } from "jsdom"; +import jqueryFactory from "jquery"; +import { describe, expect, it, vi } from "vitest"; + +function loadTop250({ hitShow = null, movies = [] } = {}) { + const dom = new JSDOM('

    Top250

    ', { url: "https://javdb.com/advanced_search?handleTop=1&handleType=all&type_value=" }); + const $ = jqueryFactory(dom.window), q = vi.fn(), loading = vi.fn(() => ({ close: loadingClose })), loadingClose = vi.fn(); + const host = { + locateListRoot: () => dom.window.document.querySelector(".container"), + getListContainer: () => dom.window.document.querySelector(".container"), + createOwnedListRoot(classes = []) { const root = dom.window.document.createElement("div"); root.classList.add("movie-list", ...classes); return root; }, + }; + const hitShowMock = hitShow ? { markDataListHtml: vi.fn(() => ""), initializeRenderedList: vi.fn(async () => {}), loadScore: vi.fn(async () => {}) } : null; + const context = vm.createContext({ + BasePlugin: class { + getBean(name) { return "HitShowPlugin" === name ? hitShowMock : undefined; } + getRuntimeService(name) { return { host, scope: async () => ({ signal: { aborted: false } }), movie: {}, settings: { snapshot: () => ({}) } }[name]; } + }, + i: (target, key, value) => (target[key] = value), $, document: dom.window.document, window: dom.window, + URLSearchParams, q, loading, + show: { info: vi.fn(), error: vi.fn(), ok: vi.fn() }, clog: { error: vi.fn(), log: vi.fn(), warn: vi.fn() }, + escapeHtml: value => $("").text(String(value ?? "")).html(), + hasStoredEncryptedCredential: () => false, removeStoredEncryptedCredential: () => {}, storeEncryptedCredential: async () => {}, + }); + const source = readTestFile(join(process.cwd(), "src/plugins/external-search/top250.js"), "utf8"); + vm.runInContext(`${source};globalThis.Top250Plugin=Top250Plugin`, context); + return { plugin: new context.Top250Plugin(), $, q, loading, loadingClose, show: context.show, hitShowMock, dom }; +} + +describe("Top250Plugin handleTop loading lifecycle", () => { + it("short-circuits without creating a loading overlay when HitShowPlugin is disabled", async () => { + const { plugin, q, loading, show } = loadTop250({ hitShow: null }); + await expect(plugin.handleTop()).resolves.toBeUndefined(); + expect(loading).not.toHaveBeenCalled(); + expect(show.info).toHaveBeenCalledWith("热播列表功能已禁用"); + expect(q).not.toHaveBeenCalled(); + }); + + it("creates and closes exactly one loading overlay for a successful ranking render", async () => { + const hitShow = { markDataListHtml: vi.fn(() => ""), initializeRenderedList: vi.fn(async () => {}), loadScore: vi.fn(async () => {}) }; + const { plugin, q, loading, loadingClose, hitShowMock, dom } = loadTop250({ hitShow, movies: [{ number: "ABC-123", has_cnsub: "0" }] }); + q.mockResolvedValue({ success: 1, data: { movies: [{ number: "ABC-123", has_cnsub: "0" }] } }); + await expect(plugin.handleTop()).resolves.toBeUndefined(); + expect(loading).toHaveBeenCalledOnce(); + expect(loadingClose).toHaveBeenCalledOnce(); + expect(q).toHaveBeenCalledOnce(); + expect(hitShowMock.markDataListHtml).toHaveBeenCalledOnce(); + expect(hitShowMock.loadScore).toHaveBeenCalledOnce(); + expect(dom.window.document.querySelector(".movie-list")).not.toBeNull(); + }); +}); diff --git a/tests/ui-architecture.test.js b/tests/ui-architecture.test.js index ca8bc8ed..dc03798d 100644 --- a/tests/ui-architecture.test.js +++ b/tests/ui-architecture.test.js @@ -78,9 +78,10 @@ describe("list toolbar and UI cleanup contracts", () => { expect(commandbar).toContain('quickFilter.detach()'); expect(commandbar).toContain("jhs-mobile-filter-menu"); expect(commandbar).toContain('item("quickFilter"'); - expect(commandbar).toContain('item("check", "开始鉴定") + item("newVideo", "新作品") + item("blacklist", "黑名单") + item("sort"'); - expect(commandbar).toContain('+ item("quickFilter"'); - expect(commandbar).toContain('+ divider + group(item("logger", "运行日志") + item("setting", "设置"))'); + expect(commandbar).toContain('hasListPageButton ? item("check", "开始鉴定") : "") + (hasNewVideo ? item("newVideo", "新作品") : "") + (hasBlacklist ? item("blacklist", "黑名单") : "") + (hasListPageButton ? item("sort"'); + expect(commandbar).toContain(': "") + (hasListPage ? item("quickFilter"'); + expect(commandbar).toContain('+ divider + group(item("logger", "运行日志") + (hasSetting ? item("setting", "设置") : ""))'); + expect(commandbar).toContain('const hasListPageButton = !!this.getBean("ListPageButtonPlugin")'); expect(commandbar).toContain('await this.getBean("ListPageButtonPlugin")?.openWaitCheck?.()'); expect(commandbar).not.toContain('$("#waitCheckBtn").click()'); expect(commandbar).not.toMatch(/\.jhs-commandbar__filters\s*\{[^}]*overflow-x\s*:\s*auto/); From b1bb3879d0f9fd26b5d8404ca9300a3ef6e3c40a Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:36:33 +0800 Subject: [PATCH 072/130] =?UTF-8?q?Fix:=20JHS=206.5.0=20=E6=94=B6=E5=8F=A3?= =?UTF-8?q?=E6=88=AA=E5=9B=BE/FC2/History/ActressInfo=20=E4=B8=8E=20UI=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- JHS.user.js | 325 +++++++++++--------- docs/release/manual-smoke-v6.5.0.json | 25 ++ scripts/regression-check.mjs | 3 +- src/app/feature-runtime.js | 2 +- src/features/detail/manifest.js | 5 +- src/integrations/javdb/manifest.js | 6 +- src/integrations/wikipedia/manifest.js | 7 +- src/platform/hosts/javdb-host-adapter.js | 1 + src/plugins/avatar/actress-info.js | 12 +- src/plugins/backup/setting-backup.js | 19 +- src/plugins/backup/setting-panels.js | 3 +- src/plugins/blacklist/blacklist.js | 19 +- src/plugins/dependency-map.js | 1 + src/plugins/external-search/fc2-by-123av.js | 4 +- src/plugins/external-search/fc2.js | 21 +- src/plugins/image-viewer/screenshot.js | 15 +- src/plugins/registry.js | 4 +- src/plugins/status/detail-page-button.js | 19 +- src/plugins/status/fc2-navigation.js | 82 +++++ src/plugins/status/history.js | 27 +- src/plugins/status/list-page.js | 26 +- src/services/screenshot-service.js | 11 +- src/ui/detail/fc2-workspace-view.js | 7 +- src/ui/detail/screenshot-panel.js | 37 ++- src/ui/table/create-jhs-table.js | 13 + tests/actress-info-service.test.js | 14 + tests/architecture-runtime.test.js | 2 +- tests/blacklist-filter-ui.test.js | 3 +- tests/fc2-gallery-dedup.test.js | 11 +- tests/fc2-owned-workspace.test.js | 11 +- tests/http-service.test.js | 2 +- tests/integrations/javdb.contract.test.js | 9 +- tests/screenshot-panel.test.js | 7 + tests/screenshot-service.test.js | 20 ++ 34 files changed, 481 insertions(+), 292 deletions(-) create mode 100644 docs/release/manual-smoke-v6.5.0.json create mode 100644 src/plugins/status/fc2-navigation.js diff --git a/JHS.user.js b/JHS.user.js index 5a03e32f..fecccfcd 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -5407,6 +5407,7 @@ } detectRoute() { if (this.location.pathname.startsWith("/v/") || this.location.pathname.startsWith("/movies/")) return "detail"; + if (this.location.pathname === "/users/collection_codes") return "owned-detail"; return this.locateListRoot() ? "list" : "other"; } readMovieRef() { @@ -5746,12 +5747,14 @@ kind: "feature", disableable: true, sites: ["javdb", "javbus"], - routes: ["detail"], + routes: ["detail", "owned-detail"], startup: "eager", requires: [PORT.host, SERVICE.movie, SERVICE.review, SERVICE.related, SERVICE.magnet, SERVICE.screenshot], - contributes: ["detail.javdb-native", "detail.javbus-native", "detail.workspace", "detail.fc2-owned", "detail.fc2-lookup", "detail.cover-state-actions", "detail.page-state-actions", "detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.external-sites"], + contributes: ["detail.javdb-native", "detail.javbus-native", "detail.workspace", "detail.fc2-owned", "detail.fc2-navigation", "detail.fc2-lookup", "detail.cover-state-actions", "detail.page-state-actions", "detail.javdb-preview", "detail.javbus-images", "detail.javbus-preview", "detail.reviews", "detail.related", "detail.native-magnets", "detail.external-magnets", "detail.screenshot", "detail.external-sites"], providesCommands: [], activate: /* @__PURE__ */ __name((deps, runtime) => { + if (runtime.route === "owned-detail") return { dispose: /* @__PURE__ */ __name(() => { + }, "dispose") }; const controller = new DetailController({ hostAdapter: deps[PORT.host], scope: runtime.scope, enabledContributions: runtime.enabledContributions }); controller.start(); return { dispose: /* @__PURE__ */ __name(() => controller.dispose(), "dispose") }; @@ -5860,6 +5863,7 @@ CompatibilityEnhancementsPlugin: ["ListPagePlugin"], CoverButtonPlugin: ["ListPagePlugin", "ScreenShotPlugin"], DetailPageButtonPlugin: ["DetailWorkspacePlugin", "MagnetHubPlugin", "HighlightMagnetPlugin"], + Fc2NavigationPlugin: ["Fc2Plugin"], Fc2Plugin: [ "DetailPageButtonPlugin", "MagnetHubPlugin", @@ -5917,10 +5921,10 @@ return "ActressInfoPlugin"; } async handle() { - if ("yes" === await storageManager.getSetting("enableLoadActressInfo", "yes")) await this.loadActressInfo(); - } - async loadActressInfo() { - await Promise.all([this.handleDetailPage(), this.handleStarPage()]); + if ("yes" !== await storageManager.getSetting("enableLoadActressInfo", "yes")) return; + const path = window.location.pathname; + if (path.startsWith("/v/") || path.startsWith("/movies/")) await this.handleDetailPage(); + else if (path.startsWith("/actors/")) await this.handleStarPage(); } async initCss() { return ``; } async handle() { + if (!isDetailPage) return; + const settings = this.getRuntimeService("settings"), scope = await this.getRuntimeService("scope")(); + const onSettingsChanged = /* @__PURE__ */ __name((event) => { + const names = event.detail?.names; + if (!names?.includes("enableLoadScreenShot")) return; + if (settings.snapshot().enableLoadScreenShot === "no") this.unmountHosted(); + else void this.loadScreenShot().catch((error) => clog.error("长缩略图重新加载失败", error)); + }, "onSettingsChanged"); + settings.addEventListener("settings.changed", onSettingsChanged); + scope.addCleanup((() => settings.removeEventListener("settings.changed", onSettingsChanged))); await this.loadScreenShot(); } + unmountHosted() { + $(".screen-container, .jhs-screenshot-providers").remove(); + } async loadScreenShot() { if (!isDetailPage) return; - if ("yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return; - let e2 = this.getPageInfo().carNum; + const service = this.getScreenshotService(); + if (!service.isEnabled(this.getSettingsSnapshot())) return; + const carNum = this.getPageInfo().carNum; r && $(".preview-images .tile-item").first().before('
    正在加载缩略图
    '), l && $("#sample-waterfall .sample-box:first").after('
    正在加载缩略图
    '); - const mode = await this.initializeProviders(); - if ("manual" === mode) return $(".screen-container").text("请选择截图来源"), void this.renderProviderTabs(e2); try { - const t2 = await this.getScreenshotFromInitializedProviders(e2); - t2 ? (this.addImg("缩略图", t2), clog.log("加载缩略图:", t2)) : this.showErrorFallback(e2, null); - } catch (t2) { - this.showErrorFallback(e2, t2); + const url = await this.getScreenshot(carNum); + url ? (this.addImg("缩略图", url), clog.log("加载缩略图:", url)) : this.showErrorFallback(carNum, null); + } catch (error) { + this.showErrorFallback(carNum, error); } } - renderProviderTabs(carNum) { - const tabs = $('
    '); - this.providerRegistry.getEnabledProviders().forEach(((provider) => tabs.append($('').attr("data-provider", provider.id).text(provider.name)))); - if (!tabs.children().length) return void $(".screen-container").text("没有可用截图来源"); - $(".screen-container").before(tabs); - tabs.on("click", "button:not(:disabled)", (async (event) => { - const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); - $(".screen-container").text(`${provider.name} 加载中…`); - try { - const result = await provider.getScreenshot(carNum); - result?.url ? this.addImg(`${provider.name} 缩略图`, result.url) : $(".screen-container").text(`${provider.name} 无结果`); - } catch (error) { - $(".screen-container").text(`${provider.name} 请求失败`); - clog.error("截图源请求失败", error); - } - })); - } async getScreenshot(e2) { e2 = normalizeCarNum(e2); if (!e2) throw clog.warn("跳过缩略图解析:番号不可用"), new Error("缩略图番号不可用"); - await this.initializeProviders(); - return this.getScreenshotFromInitializedProviders(e2); - } - async getScreenshotFromInitializedProviders(e2) { - const carNum = normalizeCarNum(e2); - if (!carNum) throw new Error("缩略图番号不可用"); - let n2; - try { - n2 = await this.providerRegistry.first(carNum); - } catch (i2) { - throw clog.error("获取缩略图资源失败:", n2, i2), i2; - } - if (!n2) return null; - return clog.log(`缩略图获取成功 (${n2.source}):`, n2.url), n2.url; - } - async getServiceScreenshot(carNum) { + const service = this.getScreenshotService(), settings = this.getSettingsSnapshot(); + if (!service.isEnabled(settings)) return clog.warn("长缩略图功能已关闭,跳过请求"), null; const scope = await this.getRuntimeService("scope")(); - const images = await this.getRuntimeService("screenshot").resolve({ carNum }, { providerId: "javstore", scope }); + const images = await service.resolve({ carNum: e2 }, { scope, settings }); const image = Array.isArray(images) ? images[0] : images; - return image?.url ? { url: image.url, source: image.providerId || "javstore", detailUrl: null } : null; + return image?.url || null; } addImg(e2, t2) { const url = normalizeJavStoreAssetUrl(t2); @@ -12582,7 +12584,7 @@ ${failure.stack}` : ""); const a2 = `jhs-screenshot-message${l ? " jhs-screenshot-message--bus" : ""}`; const carNum = normalizeCarNum(e2); if (!carNum) return void $(".screen-container").empty().append($("
    ").addClass(a2).text("无法获取番号,缩略图未加载")); - const searchUrl = this.getRuntimeService("screenshot").getSearchUrl({ carNum }), container = $(".screen-container").empty(); + const searchUrl = this.getScreenshotService().getSearchUrl({ carNum }), container = $(".screen-container").empty(); const message = $("
    ").addClass(a2).text(t2 instanceof Error ? "获取缩略图失败" : "暂无缩略图结果"), retry = $('点击重试'); container.append(message, $("
    "), retry); searchUrl && container.append(document.createTextNode(" 或 "), $('前往确认').attr("href", searchUrl)); @@ -12600,40 +12602,16 @@ ${failure.stack}` : ""); } async loadInto(target, carNum, { isActive = /* @__PURE__ */ __name(() => true, "isActive") } = {}) { const host = $(target); - if (!host.length || "yes" !== await storageManager.getSetting("enableLoadScreenShot", "yes")) return host.empty(), null; - const mode = await this.initializeProviders(), renderMessage = /* @__PURE__ */ __name((message) => isActive() && host.empty().append($("
    ").addClass("jhs-panel-state").text(message)), "renderMessage"); - if ("manual" === mode) { - host.empty(); - const tabs = $('
    '), result = $('
    ').text("请选择截图来源"); - const enabled = this.providerRegistry.getEnabledProviders(); - enabled.forEach(((provider) => tabs.append($('').attr("data-provider", provider.id).text(provider.name)))); - if (!enabled.length) return host.append($('
    ').text("没有可用截图来源")); - host.append(tabs, result), tabs.on("click", "button:not(:disabled)", (async (event) => { - const provider = this.providerRegistry.get($(event.currentTarget).data("provider")); - result.text(`${provider.name} 加载中…`); - try { - const loaded = await provider.getScreenshot(normalizeCarNum(carNum)); - if (!isActive()) return; - loaded?.url ? this.renderInto(result, loaded.url, `${provider.name} 缩略图`) : result.text(`${provider.name} 无结果`); - } catch (error) { - isActive() && result.text(`${provider.name} 请求失败`), clog.error("截图源请求失败", error); - } - })); - return host; - } - renderMessage("正在加载缩略图…"); - try { - const url = await this.getScreenshotFromInitializedProviders(carNum); - if (!isActive()) return null; - if (!url) return host.empty(), null; - return this.renderInto(host, url, "缩略图"), url; - } catch (error) { - if (!isActive()) return null; - host.empty(); - const state = $('
    ').text("缩略图加载失败 "), retry = $(''); - retry.on("click", (() => void this.loadInto(host, carNum, { isActive }))), host.append(state.append(retry)); - return clog.error("缩略图加载失败", error), null; - } + if (!host.length || !this.getScreenshotService().isEnabled(this.getSettingsSnapshot())) return host.empty(), null; + const scope = await this.getRuntimeService("scope")(); + return renderScreenshotPanel({ + target: host, + carNum, + screenshot: this.getScreenshotService(), + settings: this.getSettingsSnapshot(), + scope, + isActive + }); } renderInto(target, url, alt) { const host = $(target), image = $("").attr({ src: normalizeJavStoreAssetUrl(url), alt, loading: "lazy" }).addClass("jhs-fc2-gallery__image"), button = $('').attr("aria-label", `查看${alt}大图`).append(image); @@ -17427,7 +17405,7 @@ ${failure.stack}` : ""); manifest("external-bridge.translation", "external-bridge", TranslatePlugin, ["javdb", "javbus"], { javdb: 24, javbus: 20 }, [SERVICE.translation, SERVICE.settings]), manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http, SERVICE.state]), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }, [SERVICE.storage, SERVICE.http, SERVICE.magnet]), - manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot]), + manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot, SERVICE.settings]), manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog, SERVICE.storage, SERVICE.http, SERVICE.state]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog, SERVICE.storage, SERVICE.actressInfo, SERVICE.movie, SERVICE.state]), @@ -18557,7 +18535,30 @@ ${failure.stack}` : ""); this.providers = providers; this.integrations = integrations; } + isEnabled(settings) { + return settings?.enableLoadScreenShot !== "no"; + } + parseScreenshotProviders(value) { + if (Array.isArray(value)) return value; + if (typeof value === "string") { + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; + } + getScreenshotSettings(settings) { + return { mode: settings?.screenshotMode === "manual" ? "manual" : "auto", providers: this.parseScreenshotProviders(settings?.screenshotProviders) }; + } + getEnabledProviders(settings) { + const configured = this.parseScreenshotProviders(settings?.screenshotProviders); + return BUILT_IN_SCREENSHOT_SOURCES.map((source) => ({ ...source, ...configured.find((item) => item?.id === source.id) || {} })).filter((provider) => provider.enabled !== false).sort((left, right) => Number(left.priority ?? 100) - Number(right.priority ?? 100)); + } async resolve(movieRef, context = {}) { + if (context.settings && !this.isEnabled(context.settings)) return null; if (context.providerId) { const provider = this.providers.get?.(context.providerId); if (provider) { @@ -18584,6 +18585,7 @@ ${failure.stack}` : ""); } async resolveIntegration(providerId, movieRef, context) { if (!SCREENSHOT_PROVIDER_IDS.has(providerId)) return null; + if (context.settings && !this.isEnabled(context.settings)) return null; const manifest2 = this.integrations?.list("movie.images")?.find((item) => item.id === providerId); if (!manifest2) return null; const adapter = this.integrations?.getAdapter(manifest2.id); diff --git a/scripts/architecture-check.mjs b/scripts/architecture-check.mjs index 087bc8f7..3861230d 100644 --- a/scripts/architecture-check.mjs +++ b/scripts/architecture-check.mjs @@ -104,8 +104,9 @@ async function checkImportGraph() { const targetRelative = path.relative(path.join(rootDir, "src"), dependency).split(path.sep); const sameFeature = sourceLayer === "features" && targetLayer === "features" && sourceRelative[1] === targetRelative[1]; const sameIntegration = sourceLayer === "integrations" && targetLayer === "integrations" && sourceRelative[1] === targetRelative[1]; + const sameSharedLayer = sourceLayer === targetLayer && [ "services", "ui" ].includes(sourceLayer); const featureCatalog = sourceRelative.join("/") === "features/catalog.js" && targetLayer === "features"; - if (allowedLayers[sourceLayer] && !allowedLayers[sourceLayer].has(targetLayer) && !sameFeature && !sameIntegration && !featureCatalog) { + if (allowedLayers[sourceLayer] && !allowedLayers[sourceLayer].has(targetLayer) && !sameFeature && !sameIntegration && !sameSharedLayer && !featureCatalog) { boundaryErrors.push(`${sourceLayer} -> ${targetLayer}: ${path.relative(rootDir, file)} imports ${path.relative(rootDir, dependency)}`); } } diff --git a/scripts/regression-check.mjs b/scripts/regression-check.mjs index c33f20be..c5f8eadd 100644 --- a/scripts/regression-check.mjs +++ b/scripts/regression-check.mjs @@ -121,7 +121,7 @@ assertIncludes(ciWorkflow, "needs: [node20, check, browser-smoke]", "release che assertIncludes(ciWorkflow, "contents: write", "release write permission"); assertIncludes(ciWorkflow, "--base-ref", "version-change release detection"); assertIncludes(ciWorkflow, "github.ref == 'refs/heads/main'", "release restricted to main pushes"); -assertIncludes(packageJson.scripts.check, "check:release-smoke", "manual Tampermonkey release smoke gate"); +assertIncludes(packageJson.scripts["check:release"] ?? "", "check:release-smoke", "manual Tampermonkey release smoke gate"); assertIncludes(ciWorkflow, "queue: max", "release concurrency queue"); assertIncludes(ciWorkflow, "cancel-in-progress: false", "release concurrency preservation"); assertIncludes(ciWorkflow, "git tag -a", "annotated release tag"); diff --git a/scripts/ui-consistency-audit.mjs b/scripts/ui-consistency-audit.mjs index f6084922..7f926112 100644 --- a/scripts/ui-consistency-audit.mjs +++ b/scripts/ui-consistency-audit.mjs @@ -23,7 +23,7 @@ async function listJavaScriptFiles(dir) { return files; } -const [theme, primitives, build, injection, magnet, settings, utils, detail, commandbar, newVideo, manager, hitShow, translate, translationUi, settingStyles, main, packageSource, logger, reviews, reviewUi, related, settingPanels, settingForms, listButtons, coverButtons, highlightMagnet, task, storageQueue, constants, previewVideo, screenshot, parsers, javstoreIntegration, otherSite, javDbHostAdapter, settingRenderer, builtSource] = await Promise.all([ +const [theme, primitives, build, injection, magnet, settings, utils, detail, commandbar, newVideo, manager, hitShow, translate, translationUi, settingStyles, main, packageSource, logger, reviews, reviewUi, related, settingPanels, settingForms, listButtons, coverButtons, highlightMagnet, task, storageQueue, constants, previewVideo, previewService, screenshot, parsers, javstoreIntegration, otherSite, javDbHostAdapter, settingRenderer, builtSource] = await Promise.all([ readFile(join(srcRoot, "core", "theme.js"), "utf8"), readFile(join(srcRoot, "core", "ui-primitives.js"), "utf8"), readFile(join(repoRoot, "scripts", "build.mjs"), "utf8"), @@ -54,6 +54,7 @@ const [theme, primitives, build, injection, magnet, settings, utils, detail, com readFile(join(srcRoot, "plugins", "external-search", "other-site.js"), "utf8"), readFile(join(srcRoot, "core", "constants.js"), "utf8"), readFile(join(srcRoot, "plugins", "image-viewer", "preview-video.js"), "utf8"), + readFile(join(srcRoot, "services", "preview-service.js"), "utf8"), readFile(join(srcRoot, "plugins", "image-viewer", "screenshot.js"), "utf8"), readFile(join(srcRoot, "integrations", "javstore", "parser.js"), "utf8"), readFile(join(srcRoot, "integrations", "javstore", "manifest.js"), "utf8"), @@ -223,8 +224,8 @@ requireMatch(constants, /function assertPageInfoContract[\s\S]*expected object/, requireMatch(manager, /return assertPageInfoContract\(\{\s*carNum,\s*url: t,\s*actress: n,\s*actors: a,\s*publishTime: i\s*\}\)/, "getPageInfo must return its complete public object contract"); requireMatch(utils, /new URL\(e, window\.location\.origin\)[\s\S]*searchParams\.set\("jhsCarNum", carNum\)/, "detail URLs must carry the known car number"); -requireMatch(previewVideo, /async fetchVideo\(\)\s*\{\s*const carNum = normalizeCarNum\(this\.carNum\)/, "DMM must validate carNum before cache and parsing"); -requireMatch(previewVideo, /跳过 DMM 解析:番号不可用/, "DMM invalid-number warning is missing"); +requireMatch(previewService, /async fetchVideo\(\)\s*\{\s*const carNum = normalizeCarNum\(this\.carNum\)/, "DMM must validate carNum before cache and parsing"); +requireMatch(previewService, /跳过 DMM 解析:番号不可用/, "DMM invalid-number warning is missing"); requireMatch(previewVideo, /
    \n
    \n '), + const hasSearchByImage = !!this.getOptionalDependency("SearchByImagePlugin"); + $("#navbar-menu-hero").after('\n \n '), $("#search-keyword").on("paste", ((/** @type {any} */ e) => { const t = e.originalEvent?.clipboardData?.items || []; for (let n = 0; n < t.length; n++) if (-1 !== t[n].type.indexOf("image")) { @@ -47,16 +48,17 @@ export class NavBarPlugin extends BasePlugin { })), $("#search-btn").on("click", ((/** @type {MouseEvent} */ e) => { let t = $("#search-keyword").val(), n = $("#search-type").val(); "" !== t && (window.location.href.includes("/search") ? window.location.href = "/search?q=" + t + "&f=" + n : window.open("/search?q=" + t + "&f=" + n)); - })), $("#search-img-btn").on("click", (() => { + })), hasSearchByImage && $("#search-img-btn").on("click", (() => { this.getOptionalDependency("SearchByImagePlugin")?.open?.(); })); } hookOldSearch() { const e = document.querySelector(".search-image"); if (!e) return; + const hasSearchByImage = !!this.getOptionalDependency("SearchByImagePlugin"); const t = e.cloneNode(!0); e.parentNode?.replaceChild(t, e), $("#button-search-image").attr("data-tooltip", "以图识图"), - $(".search-image").on("click", ((/** @type {MouseEvent} */ e) => { + hasSearchByImage && $(".search-image").on("click", ((/** @type {MouseEvent} */ e) => { this.getOptionalDependency("SearchByImagePlugin")?.open?.(); })); } diff --git a/src/plugins/translate/translate.js b/src/plugins/translate/translate.js index 283860b5..673bd59b 100644 --- a/src/plugins/translate/translate.js +++ b/src/plugins/translate/translate.js @@ -11,8 +11,43 @@ export class TranslatePlugin extends BasePlugin { async initCss() { return "\n "; } - handle() { - isDetailPage && this.translate(); + /** 标题翻译 live 生命周期:listener 只注册一次,OFF→revert,ON→re-apply。 */ + async handle() { + const settings = this.getRuntimeService("settings"), scope = await this.getRuntimeService("scope")(); + if (!this._settingsListenerBound) { + this._settingsListenerBound = true; + const onSettingsChanged = (/** @type {any} */ event) => { + const names = /** @type {string[] | undefined} */ (event.detail?.names); + if (!names?.includes("translateTitle")) return; + this.reconfigure(); + }; + settings.addEventListener("settings.changed", onSettingsChanged); + scope.addCleanup((() => { + settings.removeEventListener("settings.changed", onSettingsChanged); + this._settingsListenerBound = false; + })); + } + this.reconfigure(); + } + reconfigure() { + const enabled = (this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _; + if (enabled) void this.applyTranslation(); + else this.revertTranslation(); + } + /** ON:详情页插入/刷新翻译标题;列表页翻译当前所有卡片。 */ + async applyTranslation() { + if (isDetailPage) return this.translate(); + if (!window.isListPage) return; + const listPage = this.getOptionalDependency("ListPagePlugin"); + if (!listPage?.translateListItems || typeof listPage.getSelector !== "function") return; + const items = $(listPage.getSelector().itemSelector).toArray(); + await listPage.translateListItems(items); + } + /** OFF:详情页移除 JHS 翻译节点;列表页回退为原始标题。 */ + revertTranslation() { + $(".translated-title").remove(); + const listPage = this.getOptionalDependency("ListPagePlugin"); + listPage?.revertTranslation?.(); } /** @param {string | null} [e] @param {boolean} [t] @param {{root?: ParentNode}} [options] */ async translate(e, t = !0, options = {}) { diff --git a/src/services/screenshot-service.js b/src/services/screenshot-service.js index 278ad8e7..e1458274 100644 --- a/src/services/screenshot-service.js +++ b/src/services/screenshot-service.js @@ -44,13 +44,19 @@ export class ScreenshotService { * 解析影片长缩略图。 * 指定 providerId 时只使用该来源,无结果返回 null,不 fallback 到其他 movie.images; * 未指定时先按 Provider 优先级,再回退到默认长缩略图来源(当前仅 javstore)。 + * 传入 settings 时所有来源(ProviderRegistry / Integration fallback / manual provider) + * 都必须位于 getEnabledProviders(settings) 白名单内;全部禁用时直接返回 null。 * @param {Record} movieRef * @param {{ providerId?: string, scope?: unknown, settings?: Record }} [context] */ async resolve(movieRef, context = {}) { // 总开关门禁:任何调用路径在设置关闭时都不得发请求。 if (context.settings && !this.isEnabled(context.settings)) return null; + const enabledProviders = context.settings ? this.getEnabledProviders(context.settings) : null; + const enabledIds = enabledProviders ? new Set(enabledProviders.map((provider) => provider.id)) : null; + if (enabledProviders && !enabledProviders.length) return null; if (context.providerId) { + if (enabledIds && !enabledIds.has(context.providerId)) return null; const provider = this.providers.get?.(context.providerId); if (provider) { if (provider.enabled === false || !provider.capabilities?.includes("screenshot")) return null; @@ -59,9 +65,11 @@ export class ScreenshotService { return this.resolveIntegration(context.providerId, movieRef, context); } for (const provider of await this.providers.getAvailable("screenshot", context)) { + if (enabledIds && !enabledIds.has(provider.id)) continue; const result = await this.resolveFromProvider(provider, movieRef, context); if (result) return result; } + if (enabledIds && !enabledIds.has(DEFAULT_SCREENSHOT_PROVIDER)) return null; return this.resolveIntegration(DEFAULT_SCREENSHOT_PROVIDER, movieRef, context); } /** @param {Record} provider @param {Record} movieRef @param {Record} context */ @@ -84,9 +92,11 @@ export class ScreenshotService { const result = await adapter.getImages(movieRef, context); return Array.isArray(result) && result.length ? result : null; } - /** @param {Record} movieRef */ - getSearchUrl(movieRef) { + /** @param {Record} movieRef @param {Record | null} [settings] */ + getSearchUrl(movieRef, settings = null) { if (!SCREENSHOT_PROVIDER_IDS.has(DEFAULT_SCREENSHOT_PROVIDER)) return null; + if (settings && !this.isEnabled(settings)) return null; + if (settings && !this.getEnabledProviders(settings).some((provider) => provider.id === DEFAULT_SCREENSHOT_PROVIDER)) return null; for (const manifest of this.integrations?.list("movie.images") ?? []) { if (manifest.id !== DEFAULT_SCREENSHOT_PROVIDER) continue; const adapter = this.integrations?.getAdapter(manifest.id); diff --git a/src/ui/translation/title-translation.js b/src/ui/translation/title-translation.js index 035ebf13..13b86dbc 100644 --- a/src/ui/translation/title-translation.js +++ b/src/ui/translation/title-translation.js @@ -17,7 +17,7 @@ export async function renderTranslatedTitle(options) { translatedNode.removeClass("is-error").text("翻译中..."); try { const translated = await options.translation.translate(sourceText, { scope: options.scope }); - if (!title[0]?.isConnected) return; + if (!title[0]?.isConnected || !translatedNode[0]?.isConnected) return; translatedNode.text(translated); } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/tests/audit-remediation.test.js b/tests/audit-remediation.test.js index 9421befd..33511c2a 100644 --- a/tests/audit-remediation.test.js +++ b/tests/audit-remediation.test.js @@ -27,24 +27,25 @@ describe("6.2.0 audit remediation", () => { expect(plugin.loader).toBeUndefined(); }); - it("owns AutoPage global listeners and startup timer in its Feature scope", async () => { + it("owns AutoPage global listeners and startup timer in its live scope", async () => { const dom = new JSDOM('
    ', { url: "https://javdb.com/" }); - const add = vi.spyOn(dom.window, "addEventListener"), remove = vi.spyOn(dom.window, "removeEventListener"), scope = new LifecycleScope("feature:list"); + const add = vi.spyOn(dom.window, "addEventListener"), remove = vi.spyOn(dom.window, "removeEventListener"); const { Class } = loadClass("src/plugins/status/auto-page.js", "AutoPagePlugin", { window: dom.window, document: dom.window.document, requestAnimationFrame: callback => callback(), setTimeout: vi.fn(() => 1), storageManager: { getSetting: vi.fn().mockResolvedValue("yes") }, _: "yes", C: "no", clog: { error: vi.fn() }, + LifecycleScope, }); const plugin = new Class(); plugin.shouldDisablePaging = vi.fn().mockResolvedValue(false); plugin.getSelector = () => ({ boxSelector: "#list", nextPageSelector: ".next" }); - plugin.getRuntimeService = name => "scope" === name ? () => scope : {}; + plugin.getRuntimeService = name => "settings" === name ? { snapshot: () => ({ autoPage: "yes" }) } : "scope" === name ? async () => ({ addCleanup: () => {} }) : {}; plugin.checkLoad = vi.fn(); - plugin.started = true; // 6.5: waterfall 由 start()/reconfigure() 生命周期驱动 - await plugin.waterfall(); - expect(scope.snapshot().listeners).toBe(1); + await plugin.start(); + const live = plugin.liveScope; + expect(live.snapshot().listeners).toBe(1); expect(add).toHaveBeenCalledWith("scroll", expect.any(Function), undefined); - scope.dispose(); - expect(scope.snapshot()).toMatchObject({ listeners: 0, disposed: true }); + plugin.stop(); + expect(live.snapshot()).toMatchObject({ listeners: 0, disposed: true }); expect(remove).toHaveBeenCalledWith("scroll", expect.any(Function), undefined); }); diff --git a/tests/browser/screenshots/baseline/README.md b/tests/browser/screenshots/baseline/README.md index 1bf6abdd..718c5ac4 100644 --- a/tests/browser/screenshots/baseline/README.md +++ b/tests/browser/screenshots/baseline/README.md @@ -1,8 +1,11 @@ # Visual regression baselines -生成/更新基线(需本地或 CI Chromium): +视觉回归是真实 PNG diff:toHaveScreenshot() 会与提交的基线逐像素比较 +(maxDiffPixelRatio: 0.02),不再只是“能截出一张图”。 + +生成/更新基线(需本地或 CI Chromium/Edge): cd tests/browser JHS_VISUAL_REGRESSION=1 npx playwright test --update-snapshots -普通 CI 默认跳过视觉回归(opt-in),避免无基线时误报。 +普通 CI 默认跳过视觉回归(opt-in),避免无基线时误报;基线生成后作为普通文件提交。 diff --git a/tests/browser/specs/setting-effects.spec.js b/tests/browser/specs/setting-effects.spec.js index e37dd091..1df5e5c4 100644 --- a/tests/browser/specs/setting-effects.spec.js +++ b/tests/browser/specs/setting-effects.spec.js @@ -30,7 +30,7 @@ test("preview master switch OFF removes card preview buttons", async ({ context, await injectUserscriptRuntime(page); await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enablePreviewVideo", "no")); await page.waitForTimeout(300); - await expect(page.locator(".videoSvg")).toHaveCount(0); + await expect(page.locator(".videoSvg")).toBeHidden(); // 实现为 toggle(false) 隐藏,语义:列表工具按钮隐藏而非 unmount }); test("all quick filter is the true full set including blocked items", async ({ context, page }, testInfo) => { @@ -52,3 +52,30 @@ test("all quick filter is the true full set including blocked items", async ({ c await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("ListPagePlugin").setQuickFilter("blockedItems")); await expect.poll(() => page.evaluate(() => getComputedStyle(document.querySelector(".movie-list .item")).display)).not.toBe("none"); }); + + +test("mobileMode live toggles the mobile FAB on a desktop viewport", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers mobileMode live layout"); + await fulfillHostFixtures(context); + await page.goto("https://javdb.com/", { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect(page.locator("#jhs-fab")).toHaveCount(0); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("mobileMode", "on")); + await expect(page.locator("#jhs-fab")).toBeVisible(); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("mobileMode", "off")); + await expect(page.locator("#jhs-fab")).toHaveCount(0); +}); + +test("FC2 detail screenshot slot follows the master switch live", async ({ context, page }, testInfo) => { + test.skip(testInfo.project.name !== "desktop-wide", "one deterministic project covers FC2 screenshot lifecycle"); + await fulfillHostFixtures(context); + const url = "https://javdb.com/users/collection_codes?movieId=fixture-id&carNum=FC2-123&url=https%3A%2F%2Ffc2ppvdb.com%2Farticles%2F123&source=fc2"; + await page.goto(url, { waitUntil: "domcontentloaded" }); + await injectUserscriptRuntime(page); + await expect(page.locator(".jhs-fc2-workspace")).toBeVisible(); + await expect(page.locator('[data-jhs-role="screenshot"]')).toHaveCount(1); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enableLoadScreenShot", "no")); + await expect(page.locator('[data-jhs-role="screenshot"]')).toBeEmpty(); + await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").getRuntimeService("settings").set("enableLoadScreenShot", "yes")); + await expect(page.locator('[data-jhs-role="screenshot"]')).not.toBeEmpty(); +}); diff --git a/tests/browser/specs/visual-regression.spec.js b/tests/browser/specs/visual-regression.spec.js index 219d3546..923c9b35 100644 --- a/tests/browser/specs/visual-regression.spec.js +++ b/tests/browser/specs/visual-regression.spec.js @@ -5,6 +5,7 @@ import { fulfillHostFixtures, injectUserscriptRuntime } from "../harness/runtime * 视觉回归(第一阶段核心页面)。 * 首次运行生成基线:JHS_VISUAL_REGRESSION=1 npx playwright test --update-snapshots * 普通 CI 默认跳过(由 JHS_VISUAL_REGRESSION=1 显式开启)。 + * 断言为真实 PNG diff(toHaveScreenshot),不再只验证“能截出一张图”。 */ const ENABLED = process.env.JHS_VISUAL_REGRESSION === "1"; @@ -30,8 +31,7 @@ for (const [label, url, selector] of pages) { }, theme); await page.waitForTimeout(300); await expect(page.locator(selector).first()).toBeVisible(); - const shot = await page.screenshot({ fullPage: false }); - expect(shot.length).toBeGreaterThan(1000); + await expect(page).toHaveScreenshot(`${label.replace(/\s+/g, "-")}-${theme}.png`, { fullPage: false, maxDiffPixelRatio: 0.02 }); }); } } @@ -43,5 +43,5 @@ test("Settings dialog visual baseline", async ({ context, page }, testInfo) => { await injectUserscriptRuntime(page); await page.evaluate(() => window.unsafeWindow.pluginManager.getBean("SettingPlugin").openSettingDialog()); await expect(page.locator(".layui-layer #saveBtn")).toHaveAttribute("data-jhs-settings-ready", "true"); - await expect(page.screenshot()).resolves.toBeInstanceOf(Buffer); + await expect(page).toHaveScreenshot("settings-dialog.png", { fullPage: false, maxDiffPixelRatio: 0.02 }); }); diff --git a/tests/fc2-owned-workspace.test.js b/tests/fc2-owned-workspace.test.js index 463c1fe3..d0348e35 100644 --- a/tests/fc2-owned-workspace.test.js +++ b/tests/fc2-owned-workspace.test.js @@ -191,7 +191,7 @@ describe("FC2 owned detail workspace", () => { it("initializes screenshot through the single ScreenshotService-owned view and removes empty spacing", () => { expect(screenshotSource).toContain("renderScreenshotPanel"); expect(screenshotSource).toContain('service.isEnabled(this.getSettingsSnapshot())'); - expect(fc2Source).toContain('screenshotService.isEnabled(this.getRuntimeService("settings").snapshot())'); + expect(fc2Source).toContain('screenshotService.isEnabled(settings.snapshot())'); expect(fc2Source).toContain(".jhs-fc2-screenshot:empty"); expect(fc2Source).toContain("if (context.isAlive() && !box) sitesGroup.remove()"); }); diff --git a/tests/feature-lifecycle.test.js b/tests/feature-lifecycle.test.js index 778e610d..863cae3b 100644 --- a/tests/feature-lifecycle.test.js +++ b/tests/feature-lifecycle.test.js @@ -4,6 +4,7 @@ import { readTestFile } from "./helpers/read-test-file.js"; import { join } from "node:path"; import vm from "node:vm"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { LifecycleScope } from "../src/core/lifecycle-scope.js"; const repoRoot = join(import.meta.dirname, ".."); const $ = jquery; @@ -54,6 +55,7 @@ describe("Live feature lifecycle (mount/unmount/reconfigure)", () => { const { Plugin, settings } = loadPlugin("src/plugins/status/auto-page.js", { className: "AutoPagePlugin", settingsSnapshot: { autoPage: "no" }, + globals: { LifecycleScope }, }); const plugin = new Plugin(); plugin.getRuntimeService = (name) => name === "settings" ? settings : { scope: async () => ({ addCleanup: () => {}, listen: () => {}, ownTimeout: () => {}, disposed: false }), http: {} }[name]; @@ -126,4 +128,55 @@ describe("Live feature lifecycle (mount/unmount/reconfigure)", () => { expect(unmounted).toHaveBeenCalledTimes(3); expect(mounted).toHaveBeenCalledTimes(4); // 初始 handle 一次 + 3 次重新挂载 }); + + it("AutoPage: stop 释放 liveScope,请求中切 OFF 不再 append", async () => { + let resolveRequest; + const pending = new Promise((resolve) => { resolveRequest = resolve; }); + const { Plugin, settings } = loadPlugin("src/plugins/status/auto-page.js", { + className: "AutoPagePlugin", + settingsSnapshot: { autoPage: "yes" }, + globals: { LifecycleScope, requestHostPage: () => pending }, + }); + const plugin = new Plugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "http" ? {} : async () => ({ addCleanup: () => {}, listen: () => {}, ownTimeout: () => {}, disposed: false }); + plugin.getSelector = () => ({ boxSelector: ".movie-list", requestDomItemSelector: ".movie-list .item", coverImgSelector: ".cover img", nextPageSelector: ".pagination-next" }); + plugin.shouldDisablePaging = async () => false; + plugin.getBoxCarInfoList = () => []; + plugin.getOptionalDependency = () => ({ replaceHdImg: () => {} }); + await plugin.reconfigure(); + expect(plugin.started).toBe(true); + const firstScope = plugin.liveScope; + expect(firstScope).not.toBeNull(); + plugin.nextUrl = "/page/2"; + const loadPromise = plugin.loadNextPage(); + plugin.stop(); + expect(plugin.started).toBe(false); + expect(plugin.liveScope).toBeNull(); + expect(firstScope.disposed).toBe(true); + resolveRequest('
    '); + await loadPromise; + expect(plugin.pageItems).toEqual([]); + expect($(".movie-list").children().length).toBe(0); + }); + + it("ActressInfo: 查询中切 OFF 后异步返回不再 append", async () => { + let resolveInfo; + const pending = new Promise((resolve) => { resolveInfo = resolve; }); + const { Plugin, settings } = loadPlugin("src/plugins/avatar/actress-info.js", { + className: "ActressInfoPlugin", + settingsSnapshot: { enableLoadActressInfo: "yes" }, + }); + win.history.replaceState({}, "", "/v/test-id"); + const plugin = new Plugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "actressInfo" ? { lookup: () => pending, profileUrl: () => "" } : name === "scope" ? async () => ({}) : null; + $("body").append('
    女優A
    演員
    '); + const mountPromise = plugin.mount(); + await Promise.resolve(); + plugin.unmount(); + expect($(".actress-info").length).toBe(0); + resolveInfo({ url: "https://example.test", birthday: "1990-01-01", age: "30", height: "160", weight: "45", threeSizeText: "B", braSize: "B70" }); + await mountPromise; + expect($(".actress-info").length).toBe(0); + expect($(".female").next(".actress-info").length).toBe(0); + }); }); diff --git a/tests/live-lifecycle.test.js b/tests/live-lifecycle.test.js new file mode 100644 index 00000000..4647bdef --- /dev/null +++ b/tests/live-lifecycle.test.js @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +import jquery from "jquery"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CoverButtonPlugin } from "../src/plugins/image-viewer/cover-button.js"; +import { PreviewVideoPlugin } from "../src/plugins/image-viewer/preview-video.js"; +import { BusPreviewVideoPlugin } from "../src/plugins/image-viewer/bus-preview-video.js"; +import { TranslatePlugin } from "../src/plugins/translate/translate.js"; +import { HighlightMagnetPlugin } from "../src/plugins/status/highlight-magnet.js"; + +const $ = jquery; +const win = /** @type {any} */ (globalThis.window); +const doc = win.document; + +/** Minimal SettingsService double that records listeners and emits settings.changed on set(). */ +function makeSettings(initial) { + const listeners = []; + let snapshot = { ...initial }; + const emit = (names) => { + for (const item of listeners) item.handler({ detail: { names } }); + }; + return { + snapshot: () => snapshot, + set: async (key, value) => { snapshot = { ...snapshot, [key]: value }; emit([key]); return snapshot; }, + addEventListener: (name, handler) => listeners.push({ name, handler }), + removeEventListener: vi.fn(), + listeners, + }; +} + +beforeEach(() => { + doc.body.innerHTML = ""; + vi.stubGlobal("$", $); + vi.stubGlobal("clog", { log: vi.fn(), warn: vi.fn(), debug: vi.fn(), error: vi.fn() }); + vi.stubGlobal("show", { error: vi.fn(), info: vi.fn(), ok: vi.fn() }); + vi.stubGlobal("utils", { loopDetector: vi.fn(), htmlTo$dom: vi.fn((html) => $(new win.DOMParser().parseFromString(html, "text/html"))) }); + win.isDetailPage = false; + win.isListPage = false; +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("PreviewVideoPlugin live lifecycle", () => { + it("registers exactly one settings listener even when the master switch starts OFF", async () => { + win.isDetailPage = true; + const settings = makeSettings({ enablePreviewVideo: "no", enableLoadPreviewVideo: "yes" }); + const cleanups = []; + const scope = { addCleanup: (fn) => cleanups.push(fn) }; + const plugin = new PreviewVideoPlugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "scope" ? async () => scope : name === "storage" ? {} : name === "movie" ? {} : null; + plugin.getPageInfo = () => ({ carNum: "ABC-123" }); + const unmountSpy = vi.spyOn(plugin, "unmountPreview").mockImplementation(() => { plugin._previewMounted = false; }); + const mountSpy = vi.spyOn(plugin, "mountPreview").mockImplementation(() => { plugin._previewMounted = true; }); + + await plugin.handle(); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + expect(unmountSpy).toHaveBeenCalledTimes(1); + + for (let i = 0; i < 3; i++) { + await settings.set("enablePreviewVideo", "yes"); + await settings.set("enablePreviewVideo", "no"); + } + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + expect(unmountSpy.mock.calls.length).toBe(4); // 初始 1 + 3 次 OFF + expect(mountSpy).toHaveBeenCalledTimes(3); + }); + + it("DMM sub-switch OFF destroys the JHS player and restores the native preview", () => { + win.isDetailPage = true; + $("body").append('
    '); + const native = /** @type {any} */ (doc.getElementById("preview-video")); + const dmm = /** @type {any} */ (doc.getElementById("jhs-preview-video")); + native.classList.add("jhs-native-preview-hidden"); + Object.defineProperty(dmm, "pause", { value: vi.fn() }); + Object.defineProperty(dmm, "load", { value: vi.fn() }); + const settings = makeSettings({ enablePreviewVideo: "yes", enableLoadPreviewVideo: "no" }); + const cleanups = []; + const scope = { addCleanup: (fn) => cleanups.push(fn) }; + const plugin = new PreviewVideoPlugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "scope" ? async () => scope : name === "storage" ? {} : name === "movie" ? {} : null; + plugin.lifecycleScope = scope; + plugin.reconfigure(); + expect($("#jhs-preview-video").length).toBe(0); + expect($("#video-bottom-toolbar").length).toBe(0); + expect($("#preview-video").hasClass("jhs-native-preview-hidden")).toBe(false); + }); +}); + +describe("BusPreviewVideoPlugin live lifecycle", () => { + it("registers one listener and dispatches mount/unmount on toggles", async () => { + win.isDetailPage = true; + const settings = makeSettings({ enablePreviewVideo: "no", enableLoadPreviewVideo: "yes" }); + const cleanups = []; + const scope = { addCleanup: (fn) => cleanups.push(fn) }; + const plugin = new BusPreviewVideoPlugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "scope" ? async () => scope : name === "storage" ? {} : name === "movie" ? {} : null; + plugin.getPageInfo = () => ({ carNum: "ABC-123" }); + const unmountSpy = vi.spyOn(plugin, "unmountPreview").mockImplementation(() => { plugin._busPreviewMounted = false; }); + const mountSpy = vi.spyOn(plugin, "mountPreview").mockImplementation(() => { if (plugin._busPreviewMounted) return; plugin._busPreviewMounted = true; }); + + await plugin.handle(); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + expect(unmountSpy).toHaveBeenCalledTimes(1); + + await settings.set("enablePreviewVideo", "yes"); + await settings.set("enableLoadPreviewVideo", "no"); + await settings.set("enablePreviewVideo", "no"); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + expect(mountSpy).toHaveBeenCalled(); // 幂等挂载(DMM 子开关切换会再次进入 reconfigure,但不会重复挂载) + expect(unmountSpy.mock.calls.length).toBe(2); + }); +}); + +describe("CoverButtonPlugin listener accumulation", () => { + it("keeps exactly one settings listener across repeated handle() calls", async () => { + win.isListPage = true; + const settings = makeSettings({ enablePreviewVideo: "yes" }); + const cleanups = []; + const scope = { addCleanup: (fn) => cleanups.push(fn) }; + const plugin = new CoverButtonPlugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "scope" ? async () => scope : {}; + plugin.getOptionalDependency = () => null; + plugin.getSelector = () => ({ itemSelector: ".item", boxSelector: ".movie-list" }); + + await plugin.handle(); + await plugin.handle(); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + }); +}); + +describe("TranslatePlugin live lifecycle", () => { + it("reverts on OFF and re-applies on ON with a single listener", async () => { + win.isDetailPage = true; + $("body").append('

    ABC-123 タイトル

    '); + const settings = makeSettings({ translateTitle: "yes" }); + const cleanups = []; + const scope = { addCleanup: (fn) => cleanups.push(fn) }; + const plugin = new TranslatePlugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "scope" ? async () => scope : name === "translation" ? { translate: async () => "译名" } : null; + plugin.getOptionalDependency = () => null; + + await plugin.handle(); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + await vi.waitFor(() => expect($(".translated-title").length).toBe(1)); + expect($(".translated-title").text()).toBe("译名"); + + await settings.set("translateTitle", "no"); + expect($(".translated-title").length).toBe(0); + + await settings.set("translateTitle", "yes"); + await vi.waitFor(() => expect($(".translated-title").length).toBe(1)); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + }); +}); + +describe("HighlightMagnetPlugin live lifecycle", () => { + it("reconfigures on settings.changed and syncs the local button", async () => { + win.isDetailPage = true; + const settings = makeSettings({ enableMagnetsFilter: "yes" }); + const cleanups = []; + const scope = { addCleanup: (fn) => cleanups.push(fn) }; + const plugin = new HighlightMagnetPlugin(); + plugin.getRuntimeService = (name) => name === "settings" ? settings : name === "scope" ? async () => scope : name === "host" ? { getDetailResourceBoundary: () => null } : null; + const filterSpy = vi.spyOn(plugin, "doFilterMagnet").mockImplementation(() => undefined); + const showAllSpy = vi.spyOn(plugin, "showAll").mockImplementation(() => undefined); + + await plugin.handle(); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + expect(filterSpy).toHaveBeenCalledTimes(1); + + await settings.set("enableMagnetsFilter", "no"); + expect(showAllSpy).toHaveBeenCalledTimes(1); + await settings.set("enableMagnetsFilter", "yes"); + expect(filterSpy.mock.calls.length).toBe(2); + expect(settings.listeners.filter((item) => item.name === "settings.changed")).toHaveLength(1); + }); +}); diff --git a/tests/screenshot-service.test.js b/tests/screenshot-service.test.js index c47cda78..ecf08646 100644 --- a/tests/screenshot-service.test.js +++ b/tests/screenshot-service.test.js @@ -102,3 +102,52 @@ describe("ScreenshotService explicit provider resolution", () => { expect(integrations.getAdapter).not.toHaveBeenCalled(); }); }); + +describe("ScreenshotService provider whitelist", () => { + it("returns null immediately when all providers are disabled", async () => { + const getImages = vi.fn(async () => [{ url: "https://javstore.net/a.jpg" }]); + const integrations = { list: vi.fn(() => [{ id: "javstore" }]), getAdapter: vi.fn(() => ({ getImages })) }; + const providers = { getAvailable: vi.fn(async () => []), get: vi.fn(() => null), updateHealth: vi.fn() }; + const service = new ScreenshotService(providers, integrations); + const settings = { enableLoadScreenShot: "yes", screenshotProviders: JSON.stringify([{ id: "javstore", enabled: false }]) }; + await expect(service.resolve({ carNum: "ABC-123" }, { settings })).resolves.toBeNull(); + expect(integrations.getAdapter).not.toHaveBeenCalled(); + expect(providers.getAvailable).not.toHaveBeenCalled(); + }); + + it("does not call a disabled javstore through the default fallback", async () => { + const getImages = vi.fn(async () => [{ url: "https://javstore.net/a.jpg" }]); + const integrations = { list: vi.fn(() => [{ id: "javstore" }]), getAdapter: vi.fn(() => ({ getImages })) }; + const providers = { getAvailable: vi.fn(async () => []), get: vi.fn(() => null), updateHealth: vi.fn() }; + const service = new ScreenshotService(providers, integrations); + const settings = { enableLoadScreenShot: "yes", screenshotProviders: JSON.stringify([{ id: "javstore", enabled: false }]) }; + await expect(service.resolve({ carNum: "ABC-123" }, { settings })).resolves.toBeNull(); + expect(integrations.getAdapter).not.toHaveBeenCalled(); + }); + + it("rejects an explicit providerId that is not in the whitelist", async () => { + const provider = { id: "projectjav", capabilities: ["screenshot"], resolve: vi.fn(async () => ["x"]) }; + const providers = { get: vi.fn(() => provider), getAvailable: vi.fn(async () => []), updateHealth: vi.fn() }; + const integrations = { list: vi.fn(() => []), getAdapter: vi.fn() }; + const service = new ScreenshotService(providers, integrations); + const settings = { enableLoadScreenShot: "yes", screenshotProviders: JSON.stringify([{ id: "javstore", enabled: true }]) }; + await expect(service.resolve({ carNum: "ABC-123" }, { providerId: "projectjav", settings })).resolves.toBeNull(); + expect(provider.resolve).not.toHaveBeenCalled(); + }); + + it("keeps the whitelisted javstore working in default and explicit modes", async () => { + const getImages = vi.fn(async () => [{ url: "https://javstore.net/long.jpg", providerId: "javstore" }]); + const integrations = { list: vi.fn(() => [{ id: "javstore" }]), getAdapter: vi.fn(() => ({ getImages })) }; + const providers = { getAvailable: vi.fn(async () => []), get: vi.fn(() => null), updateHealth: vi.fn() }; + const service = new ScreenshotService(providers, integrations); + const settings = { enableLoadScreenShot: "yes", screenshotProviders: JSON.stringify([{ id: "javstore", enabled: true, priority: 5 }]) }; + await expect(service.resolve({ carNum: "ABC-123" }, { settings })).resolves.toEqual([{ url: "https://javstore.net/long.jpg", providerId: "javstore" }]); + await expect(service.resolve({ carNum: "ABC-123" }, { providerId: "javstore", settings })).resolves.toEqual([{ url: "https://javstore.net/long.jpg", providerId: "javstore" }]); + }); + + it("getSearchUrl returns null when javstore is disabled", () => { + const integrations = { list: vi.fn(() => [{ id: "javstore" }]), getAdapter: vi.fn(() => ({ getSearchUrl: () => "https://javstore.net/search?q=ABC-123" })) }; + const service = new ScreenshotService({ getAvailable: vi.fn(), updateHealth: vi.fn() }, integrations); + expect(service.getSearchUrl({ carNum: "ABC-123" }, { screenshotProviders: JSON.stringify([{ id: "javstore", enabled: false }]) })).toBeNull(); + }); +}); diff --git a/tests/stats-metrics.test.js b/tests/stats-metrics.test.js new file mode 100644 index 00000000..78442f7a --- /dev/null +++ b/tests/stats-metrics.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { computeLibraryStats } from "../src/features/stats/stats-repository.js"; + +describe("computeLibraryStats frozen semantics", () => { + it("separates raw and effective numerators for blocked records", () => { + const stats = computeLibraryStats([ + { carNum: "A", stateFlags: { blocked: true, favorite: true, downloaded: true } }, + { carNum: "B", stateFlags: { favorite: true, downloaded: true, watched: true } }, + { carNum: "C", stateFlags: {} }, + ]); + expect(stats.total).toBe(3); + expect(stats.blocked).toBe(1); + expect(stats.unblocked).toBe(2); + expect(stats.favoriteRaw).toBe(2); + expect(stats.favoriteEffective).toBe(1); + expect(stats.downloadedRaw).toBe(2); + expect(stats.downloadedEffective).toBe(1); + expect(stats.watchedRaw).toBe(1); + expect(stats.watchedEffective).toBe(1); + expect(stats.pending).toBe(1); + }); + + it("pending excludes any-state records including blocked-only", () => { + const stats = computeLibraryStats([ + { carNum: "A", stateFlags: { blocked: true } }, + { carNum: "B", stateFlags: { watched: true } }, + { carNum: "C", stateFlags: {} }, + ]); + expect(stats.unblocked).toBe(2); + expect(stats.pending).toBe(1); + expect(stats.blocked).toBe(1); + }); + + it("handles empty and flagless input", () => { + expect(computeLibraryStats([])).toEqual({ + total: 0, blocked: 0, unblocked: 0, + favoriteRaw: 0, favoriteEffective: 0, + downloadedRaw: 0, downloadedEffective: 0, + watchedRaw: 0, watchedEffective: 0, + pending: 0, + }); + expect(computeLibraryStats([{ carNum: "A" }, { carNum: "B" }]).pending).toBe(2); + }); +}); diff --git a/tests/stats-scope.test.js b/tests/stats-scope.test.js index 8fce26ee..ae74d7fb 100644 --- a/tests/stats-scope.test.js +++ b/tests/stats-scope.test.js @@ -5,7 +5,7 @@ import vm from "node:vm"; import jqueryFactory from "jquery"; import { JSDOM } from "jsdom"; import { describe, expect, it, vi } from "vitest"; -import { StatsRepository } from "../src/features/stats/stats-repository.js"; +import { StatsRepository, computeLibraryStats } from "../src/features/stats/stats-repository.js"; function loadStatsPlugin() { const dom = new JSDOM("", { url: "https://javdb.com/" }), $ = jqueryFactory(dom.window); @@ -33,7 +33,7 @@ function loadStatsPlugin() { }) }; const context = vm.createContext({ - window: dom.window, document: dom.window.document, $, BasePlugin, StatsRepository, layer, URL, + window: dom.window, document: dom.window.document, $, BasePlugin, StatsRepository, computeLibraryStats, layer, URL, storageManager: { getCarList: vi.fn(async () => [ { stateFlags: { blocked: true } }, { stateFlags: { favorite: true, downloaded: true, watched: true } }, { stateFlags: {} } ]), getFavoriteActressList: vi.fn(async () => [ {} ]), getBlacklist: vi.fn(async () => [ {}, {} ]), getSetting: vi.fn(async () => ({})) From cfaed2cc62d3e149ed8bb2994c70c949a6743a77 Mon Sep 17 00:00:00 2001 From: Teamper <88786089+Teamper@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:02:30 +0800 Subject: [PATCH 082/130] =?UTF-8?q?RC=20=E6=94=B6=E5=8F=A3=EF=BC=9A?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E9=A6=96=E9=A1=B5=E6=89=AB=E6=8F=8F=20/=20?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E5=9B=9E=E6=B5=81=20gate=20/=20mobileMode=20?= =?UTF-8?q?Surface=20/=20=E8=A7=86=E8=A7=89=E5=9B=9E=E5=BD=92=E9=97=A8?= =?UTF-8?q?=E7=A6=81\n\n-=20=E6=89=B9=E9=87=8F=E6=89=AB=E6=8F=8F=E5=A7=8B?= =?UTF-8?q?=E7=BB=88=E4=BB=8E=E5=BD=93=E5=89=8D=E6=90=9C=E7=B4=A2=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E7=AC=AC=E4=B8=80=E9=A1=B5=E5=BC=80=E5=A7=8B=EF=BC=88?= =?UTF-8?q?JavDB=20=E5=88=A0=20page=20=E5=8F=82=E6=95=B0=EF=BC=8CJavBus=20?= =?UTF-8?q?=E5=89=A5=20/page/N=20=E4=B8=8E=E5=88=97=E8=A1=A8=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E9=A1=B5=E7=A0=81=EF=BC=89\n-=20Preview/=E7=BF=BB?= =?UTF-8?q?=E8=AF=91/OtherSite=20=E5=85=A8=E9=83=A8=20async=20=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E5=8A=A0=20generation=20+=20settings=20gate=EF=BC=8CO?= =?UTF-8?q?FF=20=E5=90=8E=E4=B8=8D=E5=86=8D=E5=9B=9E=E6=B5=81\n-=20mobileM?= =?UTF-8?q?ode=20=E7=BB=9F=E4=B8=80=E6=8E=A7=E5=88=B6=20FAB=20/=20?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=20CommandBar=20/=20=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=85=A5=E5=8F=A3=E4=B8=89=E5=A5=97=20Surfac?= =?UTF-8?q?e\n-=20=E6=89=B9=E9=87=8F=E4=BA=A4=E4=BA=92=EF=BC=9A=E5=8E=BB?= =?UTF-8?q?=E5=8F=8C=E7=A1=AE=E8=AE=A4=E3=80=81=E5=86=99=E5=85=A5=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E4=B8=8D=E5=8F=AF=E5=8F=96=E6=B6=88=E3=80=81=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=94=B9=E4=B8=BA=E2=80=9C=E6=89=B9=E9=87=8F=E2=80=A6?= =?UTF-8?q?=E2=80=9D=E8=AF=AD=E4=B9=89\n-=20=E6=97=A7=20.search-image=20?= =?UTF-8?q?=E8=AF=86=E5=9B=BE=E5=85=A5=E5=8F=A3=E5=9C=A8=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=20OFF=20=E6=97=B6=E4=B8=8D=E5=86=8D=E8=A7=A6=E7=A2=B0=E5=AE=BF?= =?UTF-8?q?=E4=B8=BB=20DOM\n-=20=E7=9C=9F=E6=AD=A3=E5=90=AF=E7=94=A8=20PNG?= =?UTF-8?q?=20Visual=20Regression=20Release=20Gate=EF=BC=88check:visual=20?= =?UTF-8?q?+=20CI=20+=2022=20=E5=BC=A0=20baseline=EF=BC=89\n-=20AutoPage?= =?UTF-8?q?=20=E6=A0=B9=20scope=20dispose=20=E5=81=9C=E6=AD=A2=E7=80=91?= =?UTF-8?q?=E5=B8=83=E6=B5=81=E5=B9=B6=E7=BB=9F=E4=B8=80=20waterfallPromis?= =?UTF-8?q?e\n-=20=E4=BF=AE=E5=A4=8D=E4=B8=A4=E4=B8=AA=E5=B7=B2=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E7=9A=84=E6=B5=8F=E8=A7=88=E5=99=A8=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=96=AD=E8=A8=80=EF=BC=88settings.patch=20spy=E3=80=81FC2=20?= =?UTF-8?q?=E6=88=AA=E5=9B=BE=E6=A7=BD=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 9 + JHS.user.js | 282 ++++++++++++++---- package.json | 3 +- src/features/list/batch-scanner.js | 14 +- src/features/list/batch-scope.js | 63 ++++ src/features/system/catalog.js | 2 +- src/plugins/backup/setting.js | 39 ++- src/plugins/blacklist/blacklist.js | 4 + src/plugins/external-search/fc2.js | 13 +- src/plugins/external-search/other-site.js | 19 +- src/plugins/image-viewer/bus-preview-video.js | 8 + src/plugins/image-viewer/cover-button.js | 7 + src/plugins/image-viewer/preview-video.js | 16 +- src/plugins/registry.js | 4 +- src/plugins/status/auto-page.js | 4 +- src/plugins/status/list-page-button.js | 25 +- src/plugins/status/list-page.js | 15 + src/plugins/status/mobile-bottom-bar.js | 58 +++- src/plugins/status/nav-bar.js | 3 +- src/plugins/translate/translate.js | 10 +- src/ui/translation/title-translation.js | 3 +- tests/batch-contract.test.js | 12 +- tests/batch-scanner.test.js | 43 +++ tests/batch-scope.test.js | 51 ++++ tests/browser/fixtures/javbus-detail.html | 2 +- tests/browser/fixtures/javdb-detail.html | 1 + tests/browser/fixtures/javdb-list.html | 1 + tests/browser/package.json | 3 +- tests/browser/playwright.config.js | 2 + .../baseline/FC2-Detail-dark-desktop-wide.png | Bin 0 -> 56477 bytes .../baseline/FC2-Detail-dark-mobile.png | Bin 0 -> 35652 bytes .../FC2-Detail-light-desktop-wide.png | Bin 0 -> 56175 bytes .../baseline/FC2-Detail-light-mobile.png | Bin 0 -> 34908 bytes .../JavBus-Detail-dark-desktop-wide.png | Bin 0 -> 39885 bytes .../baseline/JavBus-Detail-dark-mobile.png | Bin 0 -> 39585 bytes .../JavBus-Detail-light-desktop-wide.png | Bin 0 -> 40113 bytes .../baseline/JavBus-Detail-light-mobile.png | Bin 0 -> 39321 bytes .../JavBus-List-dark-desktop-wide.png | Bin 0 -> 24599 bytes .../baseline/JavBus-List-dark-mobile.png | Bin 0 -> 23498 bytes .../JavBus-List-light-desktop-wide.png | Bin 0 -> 24145 bytes .../baseline/JavBus-List-light-mobile.png | Bin 0 -> 22168 bytes .../JavDB-Detail-dark-desktop-wide.png | Bin 0 -> 45613 bytes .../baseline/JavDB-Detail-dark-mobile.png | Bin 0 -> 45196 bytes .../JavDB-Detail-light-desktop-wide.png | Bin 0 -> 47697 bytes .../baseline/JavDB-Detail-light-mobile.png | Bin 0 -> 46839 bytes .../baseline/JavDB-List-dark-desktop-wide.png | Bin 0 -> 30095 bytes .../baseline/JavDB-List-dark-mobile.png | Bin 0 -> 27703 bytes .../JavDB-List-light-desktop-wide.png | Bin 0 -> 29737 bytes .../baseline/JavDB-List-light-mobile.png | Bin 0 -> 26566 bytes tests/browser/screenshots/baseline/README.md | 18 +- .../baseline/settings-dialog-desktop-wide.png | Bin 0 -> 47045 bytes .../baseline/settings-dialog-mobile.png | Bin 0 -> 34865 bytes tests/browser/scripts/visual-gate.mjs | 11 + tests/browser/specs/host-routing.spec.js | 5 +- tests/browser/specs/setting-effects.spec.js | 8 +- tests/browser/specs/visual-regression.spec.js | 8 +- tests/live-lifecycle.test.js | 38 +++ tests/performance-hotpaths.test.js | 6 +- tests/rc-fixes.test.js | 58 ++++ tests/ui-architecture.test.js | 7 +- 61 files changed, 755 insertions(+), 122 deletions(-) create mode 100644 src/features/list/batch-scope.js create mode 100644 tests/batch-scope.test.js create mode 100644 tests/browser/screenshots/baseline/FC2-Detail-dark-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/FC2-Detail-dark-mobile.png create mode 100644 tests/browser/screenshots/baseline/FC2-Detail-light-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/FC2-Detail-light-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavBus-Detail-dark-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavBus-Detail-light-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavBus-List-dark-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavBus-List-dark-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavBus-List-light-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavBus-List-light-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavDB-Detail-dark-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavDB-Detail-dark-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavDB-Detail-light-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavDB-Detail-light-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavDB-List-dark-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavDB-List-dark-mobile.png create mode 100644 tests/browser/screenshots/baseline/JavDB-List-light-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/JavDB-List-light-mobile.png create mode 100644 tests/browser/screenshots/baseline/settings-dialog-desktop-wide.png create mode 100644 tests/browser/screenshots/baseline/settings-dialog-mobile.png create mode 100644 tests/browser/scripts/visual-gate.mjs create mode 100644 tests/rc-fixes.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 202e6e00..d9168ad2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,8 @@ jobs: run: npx playwright install chromium --with-deps - name: Run deterministic browser smoke run: npm run check:browser + - name: Run visual regression gate + run: npm run check:visual - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f622f1..48528492 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/lang/zh-CN/ - 修复列表页“全部”仍被旧状态显示设置二次过滤的问题;收藏、已下载和已看作品会正常出现在“全部”中,硬屏蔽内容仍统一收在“屏蔽项”。 - 修复 DMM/FANZA 外部链接检测分支因缓存键变量遮蔽站点配置而触发 TDZ 异常的问题,并增加构建产物回归测试。 - 修复 JavBus 与列表封面请求 DMM 预览时未注入 MovieService 和 LifecycleScope、导致无缓存请求无法访问远端 Provider 的问题。 +- 修复批量操作从第 2/3 页启动时漏掉前面页面的问题;批量扫描现在总是先解析当前搜索条件的第一页(JavDB 删除 `page` 参数,JavBus 剥离 `/page/N` 与 `/star|genre|maker|actress|series|tag//N`),当前页即第一页时复用 DOM,随后从第一页扫描到最后一页。 +- 修复 Preview 关闭后异步回流:DMM 请求、卡片预览与 JavBus 预览在所有 await 边界后重新校验总开关/DMM 子开关与 generation,OFF 后在途请求不再重建播放器、工具栏或预览入口;JavDB 人工创建的预告片入口单独标记 `data-jhs-dmm-trigger`,DMM OFF 只移除该入口、不动宿主原生预览。 +- 修复列表与 FC2 标题翻译在 OFF 后旧请求返回仍写回译文的问题;列表翻译新增 translationGeneration 作废机制,标题渲染统一在写 DOM 前检查 isActive。 +- 修复外部站点面板在 OFF 后异步重新挂载的问题;OtherSite 新增 mountGeneration,并在 getSiteConfigs/mapLimit 等异步边界后重新校验挂载代次与 `enableLoadOtherSite` 设置,FC2 挂载同时合并 workspace 存活检查。 +- mobileMode 现在统一控制桌面命令栏、桌面设置入口与移动 FAB 三套 Surface:compact 只保留 FAB,regular/wide 只保留桌面工具栏与设置入口;桌面命令栏卸载时把收拢的控件放回宿主原位,再次开启可完整重建。 +- 批量交互收口:一键屏蔽只保留业务函数内的单次确认;批量写入阶段禁用取消按钮并提示“正在写入,无法取消”;批量按钮文案统一为“批量屏蔽 / 批量收藏 / 批量标记已下载”。 +- 修复旧版 `.search-image` 识图入口在 SearchByImagePlugin 禁用时仍被 clone/replace、导致宿主原生按钮失效的问题;插件 OFF 时完全不碰宿主 DOM。 +- 真正启用 PNG Visual Regression Release Gate:新增 `check:visual`(内部显式设置 `JHS_VISUAL_REGRESSION=1`),`check:release` 与 CI 均执行;提交 JavDB/JavBus/FC2/Settings 桌面与移动两组真实 baseline PNG,snapshot 路径不含平台后缀。 +- AutoPage 在 Feature 根 scope dispose 时补 `stop()` 清理,首次启动与重复启动统一走同一 waterfallPromise 管理。 ## [6.4.1](../../compare/v6.4.0...v6.4.1) - 2026-08-23 diff --git a/JHS.user.js b/JHS.user.js index 0ff5565f..e90b7c86 100644 --- a/JHS.user.js +++ b/JHS.user.js @@ -5851,7 +5851,7 @@ sites: [], routes: [], startup: "on-command", - requires: [SERVICE.diagnostics], + requires: [SERVICE.diagnostics, SERVICE.profile], contributes: ["settings.core"], providesCommands: ["settings.open"], activate: /* @__PURE__ */ __name(() => ({ commands: { "settings.open": /* @__PURE__ */ __name(() => openSettingsUi(), "settings.open") } }), "activate") @@ -8484,7 +8484,7 @@ // src/plugins/backup/setting.js var _SettingPlugin = class _SettingPlugin extends BasePlugin { constructor() { - super(...arguments), i(this, "folderName", "JHS-数据备份"), i(this, "resourceSettings", new ResourceSettingsService()), i(this, "pendingCarImport", null), i(this, "taskStatusUnsubscribe", null), i(this, "cacheItems", [{ + super(...arguments), i(this, "folderName", "JHS-数据备份"), i(this, "resourceSettings", new ResourceSettingsService()), i(this, "pendingCarImport", null), i(this, "taskStatusUnsubscribe", null), i(this, "_desktopSettingNavMounted", false), i(this, "_settingScope", null), i(this, "_settingNavResizeCleanup", null), i(this, "cacheItems", [{ key: "jhs_dmm_video", text: "预览视频缓存", title: "预览视频缓存" @@ -8557,6 +8557,7 @@ }); await storageManager.getSetting("enableClog", _) === _ && clog.show(); const scope = await this.getRuntimeService("scope")(); + this._settingScope = scope; const liveSettings = this.getRuntimeService("settings"); const onSettingsChanged = /* @__PURE__ */ __name((event) => { const names = event.detail?.names || []; @@ -8582,18 +8583,31 @@ if (!target) return; event.preventDefault(), $(".simple-setting, .mini-simple-setting").html("").hide(), clog.lowZIndex(), void openSettings().catch((() => void 0)); })); - if (utils.isMobileMode()) return; + scope.addCleanup((() => this.unmountDesktopSettingNav())); + this.syncDesktopSettingNav(this.getRuntimeService("profile").current() === "compact"); + } + syncDesktopSettingNav(compact2) { + if (compact2) this.unmountDesktopSettingNav(); + else this.mountDesktopSettingNav(); + } + mountDesktopSettingNav() { + if (this._desktopSettingNavMounted) return; + const scope = this._settingScope; + if (!scope) return; + this._desktopSettingNavMounted = true; if (r) { let e2 = /* @__PURE__ */ __name(function() { $(".navbar-search").is(":hidden") ? ($(".mini-setting-box").hide(), $(".setting-box").show()) : ($(".mini-setting-box").show(), $(".setting-box").hide()); }, "e"); $("#navbar-menu-user .navbar-end").prepend(''), utils.loopDetector((() => $("#miniHistoryBtn").length > 0), (() => { $(".miniHistoryBtnBox").before('\n \n '), e2(); - }), 20, 1e4, true, scope), scope.listen(window, "resize", e2); + }), 20, 1e4, true, scope); + this._settingNavResizeCleanup?.(); + this._settingNavResizeCleanup = scope.listen(window, "resize", e2); } l && (isDetailPage ? $("h3").before('\n
    \n
    \n
    \n \n
    \n
    \n
    \n
    \n ') : window.isListPage && utils.loopDetector((() => $("#waitCheckBtn").length), (() => { $("#waitCheckBtn").parent().append('\n
    \n
    \n \n
    \n
    \n
    \n '); - }), 1, 1e4, false, scope)), $(".main-nav, .container-fluid").on("mouseenter", ".setting-box", (async () => { + }), 1, 1e4, false, scope)), $(".main-nav, .container-fluid").off("mouseenter.jhsSettingQuick mouseleave.jhsSettingQuick").on("mouseenter.jhsSettingQuick", ".setting-box", (async () => { $(".simple-setting").html(buildQuickSettingHtml(this.getRuntimeService("settingsRegistry"))).show(); try { await initQuickSettingForm(this.getFormDependencies(), this.getSelector.bind(this), this.openSettingDialog.bind(this)); @@ -8601,9 +8615,9 @@ clog.warn("桌面快捷设置初始化失败", error); } clog.lowZIndex(); - })).on("mouseleave", ".setting-box", (() => { + })).on("mouseleave.jhsSettingQuick", ".setting-box", (() => { $(".simple-setting").html("").hide(); - })), $(".main-nav, .container-fluid").on("mouseenter", ".mini-setting-box", (async () => { + })).on("mouseenter.jhsSettingQuick", ".mini-setting-box", (async () => { $(".mini-simple-setting").html(buildQuickSettingHtml(this.getRuntimeService("settingsRegistry"))).show(); try { await initQuickSettingForm(this.getFormDependencies(), this.getSelector.bind(this), this.openSettingDialog.bind(this)); @@ -8611,10 +8625,18 @@ clog.warn("迷你快捷设置初始化失败", error); } clog.lowZIndex(); - })).on("mouseleave", ".mini-setting-box", (() => { + })).on("mouseleave.jhsSettingQuick", ".mini-setting-box", (() => { $(".mini-simple-setting").html("").hide(); })); } + unmountDesktopSettingNav() { + this._desktopSettingNavMounted = false; + this._settingNavResizeCleanup?.(); + this._settingNavResizeCleanup = null; + $(".jhs-setting-nav-item, .jhs-mini-setting-box, .jhs-setting-anchor, .jhs-setting-detail-anchor").remove(); + $(".simple-setting, .mini-simple-setting").html("").hide(); + $(".main-nav, .container-fluid").off("mouseenter.jhsSettingQuick mouseleave.jhsSettingQuick"); + } async openQuickSetting() { $("#jhs-quick-setting-backdrop, #jhs-quick-setting-sheet").remove(); const previousFocus = document.activeElement; @@ -9125,6 +9147,47 @@ } __name(evaluateListItem, "evaluateListItem"); + // src/features/list/batch-scope.js + var JAVBUS_LIST_PREFIXES = /* @__PURE__ */ new Set(["star", "genre", "maker", "actress", "series", "tag"]); + function stripTrailingSlashes(value) { + return value.replace(/\/+$/, ""); + } + __name(stripTrailingSlashes, "stripTrailingSlashes"); + function resolveFirstPageUrl(currentUrl, site) { + try { + const url = new URL(currentUrl); + if (site === "javdb") { + url.searchParams.delete("page"); + return url.href; + } + if (site === "javbus") { + const segments = url.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1]; + const secondLast = segments[segments.length - 2]; + if (segments.length >= 2 && /^\d+$/.test(last)) { + const removable = secondLast === "page" || segments.length >= 3 && JAVBUS_LIST_PREFIXES.has(segments[segments.length - 3]) && !/^\d+$/.test(secondLast); + if (removable) { + const remaining = secondLast === "page" ? segments.slice(0, -2) : segments.slice(0, -1); + url.pathname = remaining.length ? "/" + remaining.join("/") : "/"; + } + } + return url.href; + } + } catch { + } + return currentUrl; + } + __name(resolveFirstPageUrl, "resolveFirstPageUrl"); + function isSamePageUrl(left, right) { + try { + const a2 = new URL(left), b2 = new URL(right); + return a2.origin === b2.origin && stripTrailingSlashes(a2.pathname) === stripTrailingSlashes(b2.pathname) && a2.search === b2.search; + } catch { + return left === right; + } + } + __name(isSamePageUrl, "isSamePageUrl"); + // src/features/list/batch-scanner.js async function scanAllPages({ startDom, @@ -9137,11 +9200,20 @@ onProgress = /* @__PURE__ */ __name(() => { }, "onProgress"), pageDelayMs = 500, - maxPages = 200 + maxPages = 200, + currentUrl = null, + firstPageUrl = null }) { const records = []; const seen = /* @__PURE__ */ new Set(); let dom = startDom, page = 1, scanned = 0; + if (firstPageUrl && currentUrl && !isSamePageUrl(firstPageUrl, currentUrl)) { + if (isCancelled()) return records; + const html = await fetchHtml(firstPageUrl); + if (isCancelled()) return records; + const parsed = new DOMParser().parseFromString(html, "text/html"); + dom = $(parsed); + } while (dom && dom.length) { if (isCancelled()) break; onProgress({ page, scanned, matched: records.length }); @@ -9560,8 +9632,11 @@ this.batchToken = batchToken; const isCancelled = /* @__PURE__ */ __name(() => this.batchToken !== batchToken || Boolean(scope?.disposed), "isCancelled"); const statusHost = /* @__PURE__ */ __name(() => (this.blacklistRoot || $()).find("#checkBlacklistMsg"), "statusHost"); + const site = this.getRuntimeService("host")?.site ?? (r ? "javdb" : l ? "javbus" : null); const records = await scanAllPages({ startDom: root ? $(root) : $(document), + currentUrl: root ? null : window.location.href, + firstPageUrl: root ? null : resolveFirstPageUrl(window.location.href, site), itemSelector: this.getSelector().requestDomItemSelector, nextPageSelector: this.getSelector().nextPageSelector, fetchHtml: /* @__PURE__ */ __name(async (url) => requestHostPage(this.getRuntimeService("http"), url, scope), "fetchHtml"), @@ -9678,6 +9753,7 @@ translatedNode.removeClass("is-error").text("翻译中..."); try { const translated = await options.translation.translate(sourceText, { scope: options.scope }); + if (options.isActive && !options.isActive()) return; if (!title[0]?.isConnected || !translatedNode[0]?.isConnected) return; translatedNode.text(translated); } catch (error) { @@ -10426,6 +10502,7 @@ ${value}\r constructor() { super(...arguments); this.detailStateController = null; + this.translationGeneration = 0; } getName() { return "Fc2Plugin"; @@ -10576,7 +10653,11 @@ ${value}\r settings.snapshot().enableLoadScreenShot === "no" ? this.unmountFc2Screenshot(context) : void this.loadFc2Screenshot(context); } if (names.includes("translateTitle")) { - (settings.snapshot().translateTitle ?? _) === _ ? void this.applyFc2Translation(context) : this.revertFc2Translation(context); + if ((settings.snapshot().translateTitle ?? _) === _) void this.applyFc2Translation(context); + else { + this.translationGeneration++; + this.revertFc2Translation(context); + } } if (names.includes("enableMagnetsFilter")) { context.magnetFilterApply?.((settings.snapshot().enableMagnetsFilter ?? _) === _); @@ -10612,15 +10693,17 @@ ${value}\r context.root.find('[data-jhs-role="screenshot"]').empty(); } applyFc2Translation(context) { - void Promise.resolve().then((() => this.getRuntimeService("scope")())).then((scope) => renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope })).catch(((error) => clog.error("FC2 标题翻译失败", error))); + const generation = this.translationGeneration, settings = this.getRuntimeService("settings"); + void Promise.resolve().then((() => this.getRuntimeService("scope")())).then((scope) => renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope, isActive: /* @__PURE__ */ __name(() => context.isAlive() && (settings.snapshot().translateTitle ?? _) === _ && generation === this.translationGeneration, "isActive") })).catch(((error) => clog.error("FC2 标题翻译失败", error))); } revertFc2Translation(context) { context.root.find(".translated-title").remove(); } mountFc2OtherSites(context, sitesGroup, otherSite) { if (!otherSite) return void sitesGroup.remove(); + const settings = this.getRuntimeService("settings"); sitesGroup.length && sitesGroup.show(); - void Promise.resolve().then((() => otherSite.loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: false, isActive: context.isAlive }))).then((box) => { + void Promise.resolve().then((() => otherSite.loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: false, isActive: /* @__PURE__ */ __name(() => context.isAlive() && settings.snapshot().enableLoadOtherSite !== "no", "isActive") }))).then((box) => { if (context.isAlive() && !box) sitesGroup.remove(); }).catch((error) => { context.isAlive() && sitesGroup.remove(), clog.error("FC2 外部站点加载失败", error); @@ -11292,6 +11375,7 @@ ${failure.stack}` : ""); var _OtherSitePlugin = class _OtherSitePlugin extends BasePlugin { constructor() { super(...arguments); + this.mountGeneration = 0; this.siteConfigs = [ { id: "javTrailersBtn" }, { id: "123AvBtn", providerId: "av123" }, @@ -11348,15 +11432,21 @@ ${failure.stack}` : ""); await this.loadOtherSite(null, null, { autoDetect: false }); } unmount() { + this.mountGeneration++; $("[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); } async loadOtherSite(e2, t2, n2 = {}) { - if (this.getRuntimeService("settings").snapshot().enableLoadOtherSite === "no") return; + const settingsService = this.getRuntimeService("settings"); + if (settingsService.snapshot().enableLoadOtherSite === "no") return; + const generation = ++this.mountGeneration; + const isActive = /* @__PURE__ */ __name(() => generation === this.mountGeneration && ("function" === typeof n2.isActive ? n2.isActive() : true) && settingsService.snapshot().enableLoadOtherSite !== "no", "isActive"); const root = n2.root ? $(n2.root) : $(document), target = n2.target ? $(n2.target) : $(this.getRuntimeService("host").locateDetailSlots().summary); - if (!target.length || n2.isActive && !n2.isActive()) return; + if (!target.length || !isActive()) return; root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); e2 = normalizeCarNum(e2) || this.getPageInfo().carNum; - const enabled = this.getEnabledSites(), configs = (await this.getSiteConfigs()).map(((config) => ({ ...config, sourceCarNum: t2 }))), view = { root, target, configs, carNum: e2, isActive: "function" === typeof n2.isActive ? n2.isActive : () => true }; + const enabled = this.getEnabledSites(), configs = (await this.getSiteConfigs()).map(((config) => ({ ...config, sourceCarNum: t2 }))); + if (!isActive()) return; + const view = { root, target, configs, carNum: e2, isActive }; const box = $('
    '), list = box.find(".jhs-site-list"), settings = $('
    '); configs.forEach(((config) => { if (config.condition && false === config.condition(config.sourceCarNum)) return; @@ -11371,7 +11461,9 @@ ${failure.stack}` : ""); utils.openPage(destination, e2, false, event); })), await mapLimit(configs, 4, (async (config) => { config.condition && false === config.condition(config.sourceCarNum) || await this.prepareSiteLink(e2, config, view); - })), this.renderSettingsArea(view), this.setupEventListeners(view), box.find('[data-jhs-role="detect-sites"]').off("click").on("click", ((event) => { + })); + if (!isActive()) return; + this.renderSettingsArea(view), this.setupEventListeners(view), box.find('[data-jhs-role="detect-sites"]').off("click").on("click", ((event) => { event.preventDefault(), this.detectOtherSites(e2, view); })), n2.autoDetect && await this.detectOtherSites(e2, view); return box; @@ -12000,6 +12092,10 @@ ${failure.stack}` : ""); // src/plugins/image-viewer/bus-preview-video.js var _BusPreviewVideoPlugin = class _BusPreviewVideoPlugin extends BasePlugin { + constructor() { + super(...arguments); + this._busPreviewGeneration = 0; + } getName() { return "BusPreviewVideoPlugin"; } @@ -12052,6 +12148,7 @@ ${failure.stack}` : ""); this.reconfigure(); } reconfigure() { + this._busPreviewGeneration++; const settings = this.getRuntimeService("settings").snapshot(); if (!isPreviewEnabled(settings)) return void this.unmountPreview(); this.mountPreview(); @@ -12089,14 +12186,17 @@ ${failure.stack}` : ""); })), window.location.href.includes("autoPlay=1") && a2.trigger("click"); } async handleVideo() { + const generation = this._busPreviewGeneration; const e2 = $("#bus-preview-modal"), t2 = e2.find(".bus-preview-modal-content"); let n2 = $("#preview-video"); + if (generation !== this._busPreviewGeneration || !isPreviewEnabled(this.getRuntimeService("settings").snapshot())) return; if (n2.length > 0) return e2.addClass("is-open"), void await safePlay(n2[0], { context: "JavBus 预览视频", notify: true }); let a2 = this.getPageInfo().carNum; const scope = await this.getRuntimeService("scope")(), { sources: i2, error: previewError } = await fetchDmmPreviewIfEnabled(a2, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope, this.getRuntimeService("settings").snapshot()); + if (generation !== this._busPreviewGeneration || !isPreviewEnabled(this.getRuntimeService("settings").snapshot())) return; i2 && 0 !== Object.keys(i2).length ? (await this.createVideoPlayerAndControls(i2, t2), n2 = $("#preview-video"), n2.length > 0 ? (e2.addClass("is-open"), await safePlay(n2[0], { context: "JavBus 预览视频", notify: true, @@ -12160,6 +12260,10 @@ ${failure.stack}` : ""); // src/plugins/image-viewer/cover-button.js var _CoverButtonPlugin = class _CoverButtonPlugin extends BasePlugin { + constructor() { + super(...arguments); + this.previewGeneration = 0; + } getName() { return "CoverButtonPlugin"; } @@ -12192,6 +12296,7 @@ ${failure.stack}` : ""); const onSettingsChanged = /* @__PURE__ */ __name((event) => { const names = event.detail?.names || []; if (names.includes("enablePreviewVideo")) { + this.previewGeneration++; if (isPreviewEnabled(settingsService.snapshot())) void this.addSvgBtn().catch(((error) => clog.error("卡片预览重新挂载失败", error))); else { $('[id$="_preview_video"]').each((_2, element) => { @@ -12364,6 +12469,7 @@ ${failure.stack}` : ""); async showVideo(e2, t2, n2) { const settings = this.getRuntimeService("settings").snapshot(); if (!isPreviewEnabled(settings)) return show.error("预览视频已关闭"); + const generation = this.previewGeneration; const a2 = `${n2}_preview_video`; let i2 = $(`#${a2}`); if (i2.length > 0) return i2.parent().show(), await safePlay(i2[0], { @@ -12372,6 +12478,7 @@ ${failure.stack}` : ""); }), void t2.hide(); t2.addClass("loading"), t2.after('
    '); const s2 = t2.attr("src"), scope = await this.getRuntimeService("scope")(), { sources: o2, error: previewError } = await fetchDmmPreviewIfEnabled(n2, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope, settings); + if (generation !== this.previewGeneration || !isPreviewEnabled(this.getRuntimeService("settings").snapshot())) return void this.showImg(e2, t2, n2); if (!o2) return show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未解析到视频"), void this.showImg(e2, t2, n2); let r2 = this.getRuntimeService("settings").snapshot().videoQuality; r2 = Z(Object.keys(o2), r2); @@ -12393,6 +12500,10 @@ ${failure.stack}` : ""); // src/plugins/image-viewer/preview-video.js var _PreviewVideoPlugin = class _PreviewVideoPlugin extends BasePlugin { + constructor() { + super(...arguments); + this.previewGeneration = 0; + } getName() { return "PreviewVideoPlugin"; } @@ -12419,6 +12530,7 @@ ${failure.stack}` : ""); this.reconfigure(); } reconfigure() { + this.previewGeneration++; const settings = this.getRuntimeService("settings").snapshot(); if (!isPreviewEnabled(settings)) return void this.unmountPreview(); this.mountPreview(); @@ -12440,6 +12552,7 @@ ${failure.stack}` : ""); (url.includes("gallery-1") || url.includes("gallery-2")) && openVideo(), url.includes("autoPlay=1") && trigger.length > 0 && trigger[0].click(); } unmountDmmPlayer() { + $("[data-jhs-dmm-trigger]").remove(); const $dmm = $("#jhs-preview-video"), dmm = $dmm[0]; dmm && (dmm.pause(), $dmm.removeAttr("src"), dmm.load(), $dmm.remove()); $("#video-bottom-toolbar").remove(); @@ -12452,6 +12565,7 @@ ${failure.stack}` : ""); $(".preview-video-container").addClass("jhs-native-preview-hidden"); } unmountJhsPreview() { + $("[data-jhs-dmm-trigger]").remove(); const $dmm = $("#jhs-preview-video"), dmm = $dmm[0]; dmm && (dmm.pause(), $dmm.removeAttr("src"), dmm.load(), $dmm.remove()); $("#video-bottom-toolbar").remove(); @@ -12459,15 +12573,17 @@ ${failure.stack}` : ""); $(".preview-video-container").addClass("jhs-native-preview-hidden"); } async initDmm(scope) { + const generation = this.previewGeneration, settings = this.getRuntimeService("settings"); try { const { sources } = await this.getDmmPreview(scope); + if (generation !== this.previewGeneration || scope?.disposed || !isPreviewEnabled(settings.snapshot()) || !isDmmEnabled(settings.snapshot())) return; if (!sources) return; const $video = $("#preview-video"), video = $video[0]; if (video) return; clog.debug("JavDB没有视频播放元素, 开始创建..."); const cover = $(".column-video-cover img").attr("src"); $(".preview-images").prepend(` - + 预告片 @@ -12512,10 +12628,13 @@ ${failure.stack}` : ""); context: "JavDB 原生预览", notify: false }); + const generation = this.previewGeneration; const dmmEnabled = isDmmEnabled(this.getRuntimeService("settings").snapshot()), dmmResult = dmmEnabled ? await this.getDmmPreview() : { sources: null, error: null - }, { sources, error } = dmmResult, $toolbar = $("
    ").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
    ").addClass("jhs-video-quality-list").attr({ + }; + if (generation !== this.previewGeneration || !isPreviewEnabled(settings.snapshot()) || !isDmmEnabled(settings.snapshot())) return; + const { sources, error } = dmmResult, $toolbar = $("
    ").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
    ").addClass("jhs-video-quality-list").attr({ role: "group", "aria-label": "视频画质" }); @@ -14640,6 +14759,7 @@ ${failure.stack}` : ""); }, "onSettingsChanged"); settings.addEventListener("settings.changed", onSettingsChanged); scope.addCleanup((() => settings.removeEventListener("settings.changed", onSettingsChanged))); + scope.addCleanup((() => this.stop())); this.reconfigure(); } reconfigure() { @@ -14655,7 +14775,10 @@ ${failure.stack}` : ""); this.liveScope?.dispose(); this.liveScope = new LifecycleScope("autopage:live"); this.generation++; - return this.waterfall(); + this.waterfallPromise = this.waterfall().finally((() => { + this.waterfallPromise = null; + })); + return this.waterfallPromise; } stop() { this.started = false; @@ -16364,9 +16487,9 @@ ${failure.stack}` : ""); ${e2 ? ` ${hasBlacklist ? `` : ""} -${hasBlacklist ? `` : ""} -${hasListPage ? `` : ""} -${hasListPage ? `` : ""} +${hasBlacklist ? `` : ""} +${hasListPage ? `` : ""} +${hasListPage ? `` : ""} ` : ""} ${o.includes("/tags") ? ` ${hasBlacklist ? `` : ""} @@ -16392,10 +16515,10 @@ ${hasListPage ? ` ${e2 && hasBlacklist ? ` - + ` : ""}${e2 && hasListPage ? ` - - + + ` : ""}${!e2 && hasBlacklist ? `` : ""} ${this.sortMenuHtml(a2)}
    @@ -16421,22 +16544,17 @@ ${hasListPage ? `
    '); ["#waitCheckBtn", "#newVideoBtn", "#historyBtn"].forEach(((selector) => { const item = $(selector).first(); - item.length && item.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo(primary); + item.length && (remember(item[0]), item.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo(primary)); })); primary.children().length && left.append(primary); const more = $('
    '); ["#statsBtn", "#blacklistBtn"].forEach(((selector) => { const item = $(selector).first(); - item.length && item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(more.find(".jhs-commandbar__menu")); + item.length && (remember(item[0]), item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(more.find(".jhs-commandbar__menu"))); })); more.find(".jhs-commandbar__menu").children().length && left.append(more); const quickFilter = $("#jhs-quick-filter").first(); - quickFilter.length && left.append($('
    ').append(quickFilter.detach())); + quickFilter.length && (remember(quickFilter[0]), left.append($('
    ').append(quickFilter.detach()))); const contextItem = $("#addBlacklistBtn").first(); - contextItem.length && contextItem.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo($('
    ').appendTo(right)); + contextItem.length && (remember(contextItem[0]), contextItem.attr("class", "jhs-btn jhs-btn--secondary").removeAttr("role tabindex").detach().appendTo($('
    ').appendTo(right))); const sort = $(".jhs-sort-control").first(); if (sort.length) { const view = $(''); - sort.detach().appendTo(view), right.append(view); + remember(sort[0]), sort.detach().appendTo(view), right.append(view); } const batch = $('
    '); ["#filterAllVideo", "#favoriteAllVideo", "#hasDownAllVideo"].forEach(((selector) => { const item = $(selector).first(); - item.length && item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(batch.find(".jhs-commandbar__menu")); + item.length && (remember(item[0]), item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(batch.find(".jhs-commandbar__menu"))); })); batch.find(".jhs-commandbar__menu").children().length && right.append(batch); $(".jhs-list-btn-row").filter(((index, element) => !$(element).children().length)).remove(); @@ -17715,9 +17882,10 @@ ${hasListPage ? `\n
    \n
    \n '), e(); - }), 20, 1e4, !0, scope), scope.listen(window, "resize", e); + }), 20, 1e4, !0, scope); + this._settingNavResizeCleanup?.(); + this._settingNavResizeCleanup = scope.listen(window, "resize", e); } l && (isDetailPage ? $("h3").before('\n
    \n
    \n
    \n \n
    \n
    \n
    \n
    \n ') : window.isListPage && utils.loopDetector((() => $("#waitCheckBtn").length), (() => { $("#waitCheckBtn").parent().append('\n
    \n
    \n \n
    \n
    \n
    \n '); }), 1, 1e4, !1, scope)), - $(".main-nav, .container-fluid").on("mouseenter", ".setting-box", (async () => { + $(".main-nav, .container-fluid").off("mouseenter.jhsSettingQuick mouseleave.jhsSettingQuick").on("mouseenter.jhsSettingQuick", ".setting-box", (async () => { $(".simple-setting").html(buildQuickSettingHtml(this.getRuntimeService("settingsRegistry"))).show(); try { await initQuickSettingForm(this.getFormDependencies(), this.getSelector.bind(this), this.openSettingDialog.bind(this)); } catch (error) { clog.warn("桌面快捷设置初始化失败", error); } clog.lowZIndex(); - })).on("mouseleave", ".setting-box", (() => { + })).on("mouseleave.jhsSettingQuick", ".setting-box", (() => { $(".simple-setting").html("").hide(); - })), $(".main-nav, .container-fluid").on("mouseenter", ".mini-setting-box", (async () => { + })).on("mouseenter.jhsSettingQuick", ".mini-setting-box", (async () => { $(".mini-simple-setting").html(buildQuickSettingHtml(this.getRuntimeService("settingsRegistry"))).show(); try { await initQuickSettingForm(this.getFormDependencies(), this.getSelector.bind(this), this.openSettingDialog.bind(this)); } catch (error) { clog.warn("迷你快捷设置初始化失败", error); } clog.lowZIndex(); - })).on("mouseleave", ".mini-setting-box", (() => { + })).on("mouseleave.jhsSettingQuick", ".mini-setting-box", (() => { $(".mini-simple-setting").html("").hide(); })); } + unmountDesktopSettingNav() { + this._desktopSettingNavMounted = false; + this._settingNavResizeCleanup?.(); + this._settingNavResizeCleanup = null; + $(".jhs-setting-nav-item, .jhs-mini-setting-box, .jhs-setting-anchor, .jhs-setting-detail-anchor").remove(); + $(".simple-setting, .mini-simple-setting").html("").hide(); + $(".main-nav, .container-fluid").off("mouseenter.jhsSettingQuick mouseleave.jhsSettingQuick"); + } /** Open shared quick settings in the mobile bottom sheet. */ async openQuickSetting() { $("#jhs-quick-setting-backdrop, #jhs-quick-setting-sheet").remove(); diff --git a/src/plugins/blacklist/blacklist.js b/src/plugins/blacklist/blacklist.js index 7ce45fb8..6c75608e 100644 --- a/src/plugins/blacklist/blacklist.js +++ b/src/plugins/blacklist/blacklist.js @@ -9,6 +9,7 @@ import { readListItem } from "../../core/list-item-reader.js"; import { legacyActionToFlag } from "../../core/state-model.js"; import { QUICK_FILTER_LABELS, normalizeQuickFilterKey } from "../../features/list/list-filters.js"; import { createListEvaluationContext, evaluateListItem } from "../../features/list/list-evaluator.js"; +import { resolveFirstPageUrl } from "../../features/list/batch-scope.js"; import { scanAllPages } from "../../features/list/batch-scanner.js"; import { JhsSelect, renderStateView } from "../../core/ui-primitives.js"; import { parseDetailPage } from "../../integrations/host-list/parser.js"; @@ -366,8 +367,11 @@ export class BlacklistPlugin extends BasePlugin { const batchToken = Symbol("batch"); this.batchToken = batchToken; const isCancelled = () => this.batchToken !== batchToken || Boolean(scope?.disposed); const statusHost = () => (this.blacklistRoot || $()).find("#checkBlacklistMsg"); + const site = this.getRuntimeService("host")?.site ?? (r ? "javdb" : l ? "javbus" : null); const records = await scanAllPages({ startDom: root ? $(root) : $(document), + currentUrl: root ? null : window.location.href, + firstPageUrl: root ? null : resolveFirstPageUrl(window.location.href, site), itemSelector: this.getSelector().requestDomItemSelector, nextPageSelector: this.getSelector().nextPageSelector, fetchHtml: async (/** @type {string} */ url) => requestHostPage(this.getRuntimeService("http"), url, scope), diff --git a/src/plugins/external-search/fc2.js b/src/plugins/external-search/fc2.js index 954ed5a7..3c08f0b0 100644 --- a/src/plugins/external-search/fc2.js +++ b/src/plugins/external-search/fc2.js @@ -48,6 +48,7 @@ export class Fc2Plugin extends BasePlugin { super(...arguments); /** @type {DetailStateController | null} */ this.detailStateController = null; + /** @type {number} */ this.translationGeneration = 0; } getName() { return "Fc2Plugin"; } getDetailStateController() { @@ -194,7 +195,11 @@ export class Fc2Plugin extends BasePlugin { settings.snapshot().enableLoadScreenShot === "no" ? this.unmountFc2Screenshot(context) : void this.loadFc2Screenshot(context); } if (names.includes("translateTitle")) { - (settings.snapshot().translateTitle ?? _) === _ ? void this.applyFc2Translation(context) : this.revertFc2Translation(context); + if ((settings.snapshot().translateTitle ?? _) === _) void this.applyFc2Translation(context); + else { + this.translationGeneration++; + this.revertFc2Translation(context); + } } if (names.includes("enableMagnetsFilter")) { context.magnetFilterApply?.((settings.snapshot().enableMagnetsFilter ?? _) === _); @@ -233,7 +238,8 @@ export class Fc2Plugin extends BasePlugin { /** ON:按当前 DOM 标题重新翻译(原生/123AV 共用 .current-title)。 */ /** @param {Fc2DetailContext} context */ applyFc2Translation(context) { - void Promise.resolve().then((() => this.getRuntimeService("scope")())).then((/** @type {any} */ scope) => renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope })).catch((error => clog.error("FC2 标题翻译失败", error))); + const generation = this.translationGeneration, settings = this.getRuntimeService("settings"); + void Promise.resolve().then((() => this.getRuntimeService("scope")())).then((/** @type {any} */ scope) => renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope, isActive: () => context.isAlive() && (settings.snapshot().translateTitle ?? _) === _ && generation === this.translationGeneration })).catch((error => clog.error("FC2 标题翻译失败", error))); } /** OFF:移除 FC2 已渲染的翻译节点。 */ /** @param {Fc2DetailContext} context */ @@ -244,8 +250,9 @@ export class Fc2Plugin extends BasePlugin { /** @param {Fc2DetailContext} context @param {any} sitesGroup @param {any} [otherSite] */ mountFc2OtherSites(context, sitesGroup, otherSite) { if (!otherSite) return void sitesGroup.remove(); + const settings = this.getRuntimeService("settings"); sitesGroup.length && sitesGroup.show(); - void Promise.resolve().then((() => otherSite.loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: !1, isActive: context.isAlive }))).then((/** @type {JQueryHandle | null} */ box) => { if (context.isAlive() && !box) sitesGroup.remove(); }).catch((/** @type {unknown} */ error) => { + void Promise.resolve().then((() => otherSite.loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: !1, isActive: () => context.isAlive() && settings.snapshot().enableLoadOtherSite !== "no" }))).then((/** @type {JQueryHandle | null} */ box) => { if (context.isAlive() && !box) sitesGroup.remove(); }).catch((/** @type {unknown} */ error) => { context.isAlive() && sitesGroup.remove(), clog.error("FC2 外部站点加载失败", error); }); } diff --git a/src/plugins/external-search/other-site.js b/src/plugins/external-search/other-site.js index 5008a739..42934d01 100644 --- a/src/plugins/external-search/other-site.js +++ b/src/plugins/external-search/other-site.js @@ -13,6 +13,7 @@ import { BasePlugin } from "../../core/plugin-manager.js"; export class OtherSitePlugin extends BasePlugin { constructor() { super(...arguments); + /** @type {number} */ this.mountGeneration = 0; /** @type {SiteConfig[]} */ this.siteConfigs = [ { id: "javTrailersBtn" }, { id: "123AvBtn", providerId: "av123" }, { id: "jableBtn" }, { id: "avgleBtn" }, { id: "missAvBtn" }, { id: "supJavBtn" }, @@ -64,18 +65,24 @@ export class OtherSitePlugin extends BasePlugin { if (this.getRuntimeService("settings").snapshot().enableLoadOtherSite === "no") return; await this.loadOtherSite(null, null, { autoDetect: !1 }); } - /** OFF:删除 JHS 自有面板(宿主原生 UI 不做永久销毁)。 */ + /** OFF:删除 JHS 自有面板(宿主原生 UI 不做永久销毁),并使在途挂载作废。 */ unmount() { + this.mountGeneration++; $("[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); } /** @param {string | null} e @param {string | null} t @param {OtherSiteLoadOptions} [n] */ async loadOtherSite(e, t, n = {}) { - if (this.getRuntimeService("settings").snapshot().enableLoadOtherSite === "no") return; + const settingsService = this.getRuntimeService("settings"); + if (settingsService.snapshot().enableLoadOtherSite === "no") return; + const generation = ++this.mountGeneration; + const isActive = () => generation === this.mountGeneration && ("function" === typeof n.isActive ? n.isActive() : !0) && settingsService.snapshot().enableLoadOtherSite !== "no"; const root = n.root ? $(n.root) : $(document), target = n.target ? $(n.target) : $(this.getRuntimeService("host").locateDetailSlots().summary); - if (!target.length || n.isActive && !n.isActive()) return; + if (!target.length || !isActive()) return; root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); e = normalizeCarNum(e) || this.getPageInfo().carNum; - const enabled = this.getEnabledSites(), configs = (await this.getSiteConfigs()).map((config => ({ ...config, sourceCarNum: t }))), view = /** @type {OtherSiteView} */ ({ root, target, configs, carNum: e, isActive: "function" === typeof n.isActive ? n.isActive : () => !0 }); + const enabled = this.getEnabledSites(), configs = (await this.getSiteConfigs()).map((config => ({ ...config, sourceCarNum: t }))); + if (!isActive()) return; + const view = /** @type {OtherSiteView} */ ({ root, target, configs, carNum: e, isActive }); const box = $('
    '), list = box.find(".jhs-site-list"), settings = $('
    '); configs.forEach((config => { if (config.condition && !1 === config.condition(config.sourceCarNum)) return; @@ -90,7 +97,9 @@ export class OtherSitePlugin extends BasePlugin { utils.openPage(destination, e, !1, event); })), await mapLimit(configs, 4, (async (config) => { config.condition && !1 === config.condition(config.sourceCarNum) || await this.prepareSiteLink(e, config, view); - })), this.renderSettingsArea(view), this.setupEventListeners(view), box.find('[data-jhs-role="detect-sites"]').off("click").on("click", ((/** @type {JQuerySiteEvent} */ event) => { + })); + if (!isActive()) return; + this.renderSettingsArea(view), this.setupEventListeners(view), box.find('[data-jhs-role="detect-sites"]').off("click").on("click", ((/** @type {JQuerySiteEvent} */ event) => { event.preventDefault(), this.detectOtherSites(e, view); })), n.autoDetect && await this.detectOtherSites(e, view); return box; diff --git a/src/plugins/image-viewer/bus-preview-video.js b/src/plugins/image-viewer/bus-preview-video.js index 0f70db87..b59f40bc 100644 --- a/src/plugins/image-viewer/bus-preview-video.js +++ b/src/plugins/image-viewer/bus-preview-video.js @@ -6,6 +6,10 @@ import { BasePlugin } from "../../core/plugin-manager.js"; import { Z, fetchDmmPreview, fetchDmmPreviewIfEnabled, isDmmEnabled, isPreviewEnabled } from "../../services/preview-service.js"; export class BusPreviewVideoPlugin extends BasePlugin { + constructor() { + super(...arguments); + /** @type {number} */ this._busPreviewGeneration = 0; + } getName() { return "BusPreviewVideoPlugin"; } @@ -61,6 +65,7 @@ export class BusPreviewVideoPlugin extends BasePlugin { } /** 统一 reconfigure:总开关 OFF→卸载;DMM 子开关 OFF→停止当前 JHS 播放。 */ reconfigure() { + this._busPreviewGeneration++; const settings = this.getRuntimeService("settings").snapshot(); if (!isPreviewEnabled(settings)) return void this.unmountPreview(); this.mountPreview(); @@ -98,14 +103,17 @@ export class BusPreviewVideoPlugin extends BasePlugin { })), window.location.href.includes("autoPlay=1") && a.trigger("click"); } async handleVideo() { + const generation = this._busPreviewGeneration; const e = $("#bus-preview-modal"), t = e.find(".bus-preview-modal-content"); let n = $("#preview-video"); + if (generation !== this._busPreviewGeneration || !isPreviewEnabled(this.getRuntimeService("settings").snapshot())) return; if (n.length > 0) return e.addClass("is-open"), void await safePlay(n[0], { context: "JavBus 预览视频", notify: !0 }); let a = this.getPageInfo().carNum; const scope = await this.getRuntimeService("scope")(), {sources: i, error: previewError} = await fetchDmmPreviewIfEnabled(a, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope, this.getRuntimeService("settings").snapshot()); + if (generation !== this._busPreviewGeneration || !isPreviewEnabled(this.getRuntimeService("settings").snapshot())) return; i && 0 !== Object.keys(i).length ? (await this.createVideoPlayerAndControls(i, t), n = $("#preview-video"), n.length > 0 ? (e.addClass("is-open"), await safePlay(n[0], { context: "JavBus 预览视频", diff --git a/src/plugins/image-viewer/cover-button.js b/src/plugins/image-viewer/cover-button.js index aff4296c..eae99d79 100644 --- a/src/plugins/image-viewer/cover-button.js +++ b/src/plugins/image-viewer/cover-button.js @@ -10,6 +10,10 @@ import { Z, fetchDmmPreviewIfEnabled, isPreviewEnabled } from "../../services/pr /** @typedef {MouseEvent & { ctrlKey?: boolean, metaKey?: boolean }} CardActionEvent */ export class CoverButtonPlugin extends BasePlugin { + constructor() { + super(...arguments); + /** @type {number} */ this.previewGeneration = 0; + } getName() { return "CoverButtonPlugin"; } @@ -42,6 +46,7 @@ export class CoverButtonPlugin extends BasePlugin { const onSettingsChanged = (/** @type {any} */ event) => { const names = /** @type {string[] | undefined} */ (event.detail?.names) || []; if (names.includes("enablePreviewVideo")) { + this.previewGeneration++; if (isPreviewEnabled(settingsService.snapshot())) void this.addSvgBtn().catch((error => clog.error("卡片预览重新挂载失败", error))); else { $('[id$="_preview_video"]').each((/** @type {number} */ _, /** @type {HTMLVideoElement} */ element) => { @@ -217,6 +222,7 @@ export class CoverButtonPlugin extends BasePlugin { async showVideo(e, t, n) { const settings = this.getRuntimeService("settings").snapshot(); if (!isPreviewEnabled(settings)) return show.error("预览视频已关闭"); + const generation = this.previewGeneration; const a = `${n}_preview_video`; let i = $(`#${a}`); if (i.length > 0) return i.parent().show(), await safePlay(i[0], { @@ -225,6 +231,7 @@ export class CoverButtonPlugin extends BasePlugin { }), void t.hide(); t.addClass("loading"), t.after('
    '); const s = t.attr("src"), scope = await this.getRuntimeService("scope")(), {sources: o, error: previewError} = await fetchDmmPreviewIfEnabled(n, this.getRuntimeService("storage"), this.getRuntimeService("movie"), scope, settings); + if (generation !== this.previewGeneration || !isPreviewEnabled(this.getRuntimeService("settings").snapshot())) return void this.showImg(e, t, n); if (!o) return show.error("REGION_BLOCKED" === previewError?.code ? previewError.message : "未解析到视频"), void this.showImg(e, t, n); let r = this.getRuntimeService("settings").snapshot().videoQuality; r = Z(Object.keys(o), r); diff --git a/src/plugins/image-viewer/preview-video.js b/src/plugins/image-viewer/preview-video.js index 775921f4..a83c214e 100644 --- a/src/plugins/image-viewer/preview-video.js +++ b/src/plugins/image-viewer/preview-video.js @@ -9,6 +9,10 @@ import { Z, fetchDmmPreview, isDmmEnabled, isPreviewEnabled } from "../../servic /** @typedef {{ code?: string, message?: string, retryable?: boolean }} PreviewFailure */ export class PreviewVideoPlugin extends BasePlugin { + constructor() { + super(...arguments); + /** @type {number} */ this.previewGeneration = 0; + } getName() { return "PreviewVideoPlugin"; } @@ -36,6 +40,7 @@ export class PreviewVideoPlugin extends BasePlugin { } /** 统一 reconfigure:总开关与 DMM 子开关都从这里走,禁止递归 handle()。 */ reconfigure() { + this.previewGeneration++; const settings = this.getRuntimeService("settings").snapshot(); if (!isPreviewEnabled(settings)) return void this.unmountPreview(); this.mountPreview(); @@ -59,6 +64,7 @@ export class PreviewVideoPlugin extends BasePlugin { } /** 只销毁 JHS DMM 播放器并把控制权交回宿主原生预览(总开关仍开启时保留入口)。 */ unmountDmmPlayer() { + $('[data-jhs-dmm-trigger]').remove(); const $dmm = $("#jhs-preview-video"), dmm = $dmm[0]; dmm && (dmm.pause(), $dmm.removeAttr("src"), dmm.load(), $dmm.remove()); $("#video-bottom-toolbar").remove(); @@ -73,6 +79,7 @@ export class PreviewVideoPlugin extends BasePlugin { } /** 卸载 JHS 播放器并把控制权交回宿主(宿主原生 UI 只做可逆隐藏,不销毁)。 */ unmountJhsPreview() { + $('[data-jhs-dmm-trigger]').remove(); const $dmm = $("#jhs-preview-video"), dmm = $dmm[0]; dmm && (dmm.pause(), $dmm.removeAttr("src"), dmm.load(), $dmm.remove()); $("#video-bottom-toolbar").remove(); @@ -81,14 +88,16 @@ export class PreviewVideoPlugin extends BasePlugin { } /** @param {any} scope */ async initDmm(scope) { + const generation = this.previewGeneration, settings = this.getRuntimeService("settings"); try { const {sources} = await this.getDmmPreview(scope); + if (generation !== this.previewGeneration || scope?.disposed || !isPreviewEnabled(settings.snapshot()) || !isDmmEnabled(settings.snapshot())) return; if (!sources) return; const $video = $("#preview-video"), video = $video[0]; if (video) return; clog.debug("JavDB没有视频播放元素, 开始创建..."); const cover = $(".column-video-cover img").attr("src"); - $(".preview-images").prepend(`\n \n 预告片\n \n \n `), $(".preview-video-container").off("click.jhsVideo").on("click.jhsVideo", (() => { + $(".preview-images").prepend(`\n \n 预告片\n \n \n `), $(".preview-video-container").off("click.jhsVideo").on("click.jhsVideo", (() => { utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (() => this.handleVideo().catch((error => clog.error("预览视频处理失败", error)))), 20, 1e4, !0, scope); })); } catch (error) { @@ -135,10 +144,13 @@ export class PreviewVideoPlugin extends BasePlugin { context: "JavDB 原生预览", notify: !1 }); + const generation = this.previewGeneration; const dmmEnabled = isDmmEnabled(this.getRuntimeService("settings").snapshot()), dmmResult = dmmEnabled ? await this.getDmmPreview() : { sources: null, error: null - }, {sources, error} = dmmResult, $toolbar = $("
    ").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
    ").addClass("jhs-video-quality-list").attr({ + }; + if (generation !== this.previewGeneration || !isPreviewEnabled(settings.snapshot()) || !isDmmEnabled(settings.snapshot())) return; + const {sources, error} = dmmResult, $toolbar = $("
    ").attr("id", "video-bottom-toolbar").addClass("jhs-video-toolbar"), $qualityList = $("
    ").addClass("jhs-video-quality-list").attr({ role: "group", "aria-label": "视频画质" }); diff --git a/src/plugins/registry.js b/src/plugins/registry.js index 726bd5ff..246157ef 100644 --- a/src/plugins/registry.js +++ b/src/plugins/registry.js @@ -60,7 +60,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("list.fold-category", "list", FoldCategoryPlugin, ["javdb"], { javdb: 5 }, [SERVICE.settings]), manifest("list.actions", "list", ListPageButtonPlugin, ["javdb", "javbus"], { javdb: 5, javbus: 2 }, [SERVICE.settings]), manifest("library.history", "library", HistoryPlugin, ["javdb", "javbus"], { javdb: 6, javbus: 4 }, [SERVICE.dialog, SERVICE.movie, SERVICE.settings, SERVICE.state]), - manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [PORT.host, SERVICE.diagnostics, SERVICE.webdav, SERVICE.dialog, SERVICE.storage, SERVICE.settings, SERVICE.http, SERVICE.offline, SERVICE.magnet, SERVICE.movie, SERVICE.state, REGISTRY.settings]), + manifest("settings.core", "settings", SettingPlugin, ["javdb", "javbus"], { javdb: 7, javbus: 3 }, [PORT.host, SERVICE.diagnostics, SERVICE.profile, SERVICE.webdav, SERVICE.dialog, SERVICE.storage, SERVICE.settings, SERVICE.http, SERVICE.offline, SERVICE.magnet, SERVICE.movie, SERVICE.state, REGISTRY.settings]), manifest("identity.javdb-navigation", "identity", NavBarPlugin, ["javdb"], { javdb: 8 }, [SERVICE.movie]), manifest("discovery.hit-show", "discovery", HitShowPlugin, ["javdb"], { javdb: 9 }, [PORT.host, SERVICE.movie, SERVICE.settings, SERVICE.cache]), manifest("discovery.top250", "discovery", Top250Plugin, ["javdb"], { javdb: 10 }, [PORT.host, SERVICE.dialog, SERVICE.account]), @@ -81,7 +81,7 @@ export const legacyContributionManifests = Object.freeze([ manifest("library.state-actions", "library", WantAndWatchedVideosPlugin, ["javdb"], { javdb: 25 }, [SERVICE.http, SERVICE.state]), manifest("detail.external-magnets", "detail", MagnetHubPlugin, ["javdb", "javbus"], { javdb: 26, javbus: 17 }, [SERVICE.storage, SERVICE.http, SERVICE.magnet]), manifest("detail.screenshot", "detail", ScreenShotPlugin, ["javdb", "javbus"], { javdb: 27, javbus: 18 }, [SERVICE.screenshot, SERVICE.settings]), - manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [SERVICE.dialog, SERVICE.storage, SERVICE.http, SERVICE.state]), + manifest("library.blacklist", "library", BlacklistPlugin, ["javdb", "javbus"], { javdb: 28, javbus: 21 }, [PORT.host, SERVICE.dialog, SERVICE.storage, SERVICE.http, SERVICE.state]), manifest("library.favorite-actresses", "library", FavoriteActressesPlugin, ["javdb"], { javdb: 29 }), manifest("discovery.new-video", "discovery", NewVideoPlugin, ["javdb"], { javdb: 30 }, [SERVICE.dialog, SERVICE.storage, SERVICE.actressInfo, SERVICE.movie, SERVICE.state]), manifest("discovery.scheduler", "discovery", TaskPlugin, ["javdb", "javbus"], { javdb: 31, javbus: 22 }, [SERVICE.storage, SERVICE.http, SERVICE.actressInfo, SERVICE.movie]), diff --git a/src/plugins/status/auto-page.js b/src/plugins/status/auto-page.js index d508f0ad..d0fcd2d7 100644 --- a/src/plugins/status/auto-page.js +++ b/src/plugins/status/auto-page.js @@ -39,6 +39,7 @@ export class AutoPagePlugin extends BasePlugin { }; settings.addEventListener("settings.changed", onSettingsChanged); scope.addCleanup((() => settings.removeEventListener("settings.changed", onSettingsChanged))); + scope.addCleanup((() => this.stop())); this.reconfigure(); } /** 总开关 live 生命周期:OFF→stop,ON→start(不刷新页面)。 */ @@ -53,7 +54,8 @@ export class AutoPagePlugin extends BasePlugin { this.liveScope?.dispose(); this.liveScope = new LifecycleScope("autopage:live"); this.generation++; - return this.waterfall(); + this.waterfallPromise = this.waterfall().finally((() => { this.waterfallPromise = null; })); + return this.waterfallPromise; } /** 真正 stop:释放 live scope(scroll listener/定时器/请求全部随之 dispose),并使在途请求作废。 */ stop() { diff --git a/src/plugins/status/list-page-button.js b/src/plugins/status/list-page-button.js index 4cc298b8..e9298a7d 100644 --- a/src/plugins/status/list-page-button.js +++ b/src/plugins/status/list-page-button.js @@ -44,7 +44,7 @@ export class ListPageButtonPlugin extends BasePlugin { const r = o.includes("advanced_search"); r && (t = $("h2.section-title")); const l = this.getRuntimeService("settings").snapshot().sortMethod || "default", d = "当前排序方式: " + ("rateCount" === l ? "评价人数" : "date" === l ? "时间" : "默认"); - t.append(`\n
    \n \n ${e ? `\n${hasBlacklist ? `` : ""}\n${hasBlacklist ? `` : ""}\n${hasListPage ? `` : ""}\n${hasListPage ? `` : ""}\n ` : ""}\n ${o.includes("/tags") ? `\n ${hasBlacklist ? `` : ""}\n ` : ""}\n
    \n
    \n ${hasNewVideo ? `` : ""}\n ${hasBlacklist ? `` : ""}\n ${c ? "" : this.sortMenuHtml(l || "default", d)}\n
    \n `); + t.append(`\n
    \n \n ${e ? `\n${hasBlacklist ? `` : ""}\n${hasBlacklist ? `` : ""}\n${hasListPage ? `` : ""}\n${hasListPage ? `` : ""}\n ` : ""}\n ${o.includes("/tags") ? `\n ${hasBlacklist ? `` : ""}\n ` : ""}\n
    \n
    \n ${hasNewVideo ? `` : ""}\n ${hasBlacklist ? `` : ""}\n ${c ? "" : this.sortMenuHtml(l || "default", d)}\n
    \n `); } if (l) { const e = o.includes("/star/"); @@ -54,7 +54,7 @@ export class ListPageButtonPlugin extends BasePlugin { e.find((/** @type {BlacklistRecord} */ e) => e.starId === a.starId) && (t = "已加入黑名单", n = "jhs-btn--muted"); } const a = this.getRuntimeService("settings").snapshot().sortMethod || "default"; - $(".masonry").parent().prepend(`\n
    \n \n ${e && hasBlacklist ? ` \n \n \n ` : ""}${e && hasListPage ? ` \n \n \n ` : ""}${!e && hasBlacklist ? `` : ""}\n ${this.sortMenuHtml(a)}\n
    \n `); + $(".masonry").parent().prepend(`\n
    \n \n ${e && hasBlacklist ? ` \n \n \n ` : ""}${e && hasListPage ? ` \n \n \n ` : ""}${!e && hasBlacklist ? `` : ""}\n ${this.sortMenuHtml(a)}\n
    \n `); } $("#waitCheckBtn > span").text("开始鉴定"); const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); @@ -78,20 +78,15 @@ export class ListPageButtonPlugin extends BasePlugin { $("#addBlacklistBtn").on("click", (async (/** @type {any} */ t) => { await blacklist?.addBlacklist?.(t); })), $("#filterAllVideo").on("click", (async (/** @type {any} */ t) => { - let n = { - clientX: t.clientX, - clientY: t.clientY + 80 - }, a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); + const a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); - let i = a.text().trim().split(",")[0]; - utils.q(n, "一键屏蔽视频列表?", (async () => { - this.loadObj = loading(); - try { - await blacklist?.filterAllVideo?.(i); - } catch (t) { - clog.error(t); - } finally { this.loadObj.close(); } - })); + const i = a.text().trim().split(",")[0]; + this.loadObj = loading(); + try { + await blacklist?.filterAllVideo?.(i); + } catch (t) { + clog.error(t); + } finally { this.loadObj.close(); } })), $("#favoriteAllVideo").on("click", (async (/** @type {any} */ t) => { let a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); if (0 === a.length) return void show.error("获取演员名称失败"); diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index 931796ca..da1a89b3 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -10,6 +10,7 @@ import { isHitShowPage } from "../../core/site-context.js"; import { hasAnyState, legacyActionToFlag, normalizeStateFlags } from "../../core/state-model.js"; import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, isHardHidden, normalizeQuickFilterKey, shouldShowItem } from "../../features/list/list-filters.js"; import { createListEvaluationContext, evaluateListItem, findMatchedTitleKeyword } from "../../features/list/list-evaluator.js"; +import { resolveFirstPageUrl } from "../../features/list/batch-scope.js"; import { scanAllPages } from "../../features/list/batch-scanner.js"; /** @typedef {Record} ListRecord */ @@ -113,6 +114,7 @@ export class ListPagePlugin extends BasePlugin { /** @type {Map>} */ this.itemIndex = new Map(); /** @type {number | null} */ this.recountFrame = null; /** @type {any} */ this.$currentImage = null; + /** @type {number} */ this.translationGeneration = 0; } getName() { return "ListPagePlugin"; @@ -465,8 +467,11 @@ export class ListPagePlugin extends BasePlugin { clog.debug(`批量扫描第 ${page} 页 · 已扫描 ${scanned} · 匹配 ${matched}`); }; try { + const site = this.getRuntimeService("host")?.site ?? (r ? "javdb" : l ? "javbus" : null); const records = await scanAllPages({ startDom: root ? $(root) : $(document), + currentUrl: root ? null : window.location.href, + firstPageUrl: root ? null : resolveFirstPageUrl(window.location.href, site), itemSelector: this.getSelector().requestDomItemSelector, nextPageSelector: this.getSelector().nextPageSelector, fetchHtml: async (/** @type {string} */ url) => requestHostPage(this.getRuntimeService("http"), url, scope), @@ -476,8 +481,12 @@ export class ListPagePlugin extends BasePlugin { onProgress, }); if (isCancelled()) return { cancelled: true }; + // 写入阶段不可取消:禁用按钮并给出明确文案,避免出现半批数据状态。 + progressElement?.find("#jhs-batch-cancel").prop("disabled", true).attr("title", "正在写入,无法取消"); + setProgress("正在写入,无法取消…"); let updated = 0; for (let index = 0; index < records.length; index += 75) { + if (isCancelled()) break; const chunk = records.slice(index, index + 75); await this.getRuntimeService("state").patch(chunk.map((item) => item.carNum), { [stateFlag]: !0 }, { type: "actor-page-batch-state", @@ -633,14 +642,20 @@ export class ListPagePlugin extends BasePlugin { 3 !== node.nodeType || "" === (node.textContent || "").trim() || (node.textContent || "").includes(n) || (node.textContent = " " + t + " "); })), a.attr("title", t)) : a.text(t), e.attr("data-jhs-translation-key", n); } + /** 使在途列表翻译作废(Translate OFF 时调用)。 */ + invalidateTranslations() { + this.translationGeneration++; + } /** @param {JQueryHandle} e */ async translate(e) { let t, n, a = e.find(".video-title"); if (r ? (t = a.contents().filter(((/** @type {number} */ e, /** @type {Node} */ t) => 3 === t.nodeType && "" !== (t.textContent || "").trim())).text().trim(), n = e.find(".video-title strong").text().trim()) : (t = (e.find("img").attr("data-title") || "").trim(), n = (e.find("a").attr("href") || "").split("/").filter(Boolean).pop()?.trim()), !t || !n) return; + const generation = this.translationGeneration, settings = this.getRuntimeService("settings"); const scope = await this.getRuntimeService("scope")(); const translated = await this.getRuntimeService("translation").translate(t, { scope }); + if (generation !== this.translationGeneration || settings?.snapshot?.().translateTitle !== "yes") return; this.applyTranslatedTitle(e, translated, n); } async revertTranslation() { diff --git a/src/plugins/status/mobile-bottom-bar.js b/src/plugins/status/mobile-bottom-bar.js index 52b8fcb5..048406cc 100644 --- a/src/plugins/status/mobile-bottom-bar.js +++ b/src/plugins/status/mobile-bottom-bar.js @@ -9,6 +9,8 @@ export class MobileBottomBarPlugin extends BasePlugin { constructor() { super(...arguments); this._fabGeneration = 0; + /** @type {Array<{ element: Element, parent: Node | null, next: Node | null }>} */ + this._commandBarSources = []; } getName() { return "MobileBottomBarPlugin"; @@ -191,9 +193,8 @@ export class MobileBottomBarPlugin extends BasePlugin { } async handle() { const profile = this.getRuntimeService("profile"), scope = await this.getRuntimeService("scope")(); - const sync = () => profile.current() === "compact" ? this.mountBottomBar() : this.unmountBottomBar(); - scope.listen(profile, "profile.changed", sync); - sync(); + scope.listen(profile, "profile.changed", () => this.syncSurfaces()); + this.syncSurfaces(); } mountBottomBar() { if ($("#jhs-fab").length) return; @@ -211,11 +212,50 @@ export class MobileBottomBarPlugin extends BasePlugin { $("#jhs-fab, #jhs-fab-menu, .jhs-fab-backdrop").remove(); } async afterPluginsReady() { + this.syncSurfaces(); + } + /** 统一桌面/移动两套 UI Surface:compact 只保留 FAB,regular/wide 只保留桌面工具栏与设置入口。 */ + syncSurfaces() { + const compact = this.getRuntimeService("profile").current() === "compact"; + const setting = this.getOptionalDependency("SettingPlugin"); + if (compact) { + this.unmountDesktopCommandBar(); + setting?.unmountDesktopSettingNav?.(); + this.mountBottomBar(); + } else { + this.unmountBottomBar(); + this.mountDesktopCommandBar(); + setting?.mountDesktopSettingNav?.(); + } + } + mountDesktopCommandBar() { + if (!window.isListPage || $("#jhs-page-commandbar").length) return; this.buildCommandBar(); } + /** 卸载桌面命令栏并把被收拢的控件放回宿主原位(原容器已移除时回退到列表容器前)。 */ + unmountDesktopCommandBar() { + $("#jhs-page-commandbar").remove(); + for (const source of this._commandBarSources || []) { + const element = source.element; + if (!element?.isConnected) continue; + if (source.parent?.isConnected) { + if (source.next?.isConnected) source.parent.insertBefore(element, source.next); + else source.parent.appendChild(element); + } else { + const host = $(this.getSelector().boxSelector).first(); + host.length ? host.before(element) : document.body.appendChild(element); + } + } + this._commandBarSources = []; + $(document).off("click.jhsCommandbar"); + } /** 将列表页分散的 JHS 控件收敛为单一命令栏。 */ buildCommandBar() { if (!window.isListPage || $("#jhs-page-commandbar").length) return; + this._commandBarSources = []; + const remember = (/** @type {Element} */ element) => { + this._commandBarSources.push({ element, parent: element.parentNode, next: element.nextSibling }); + }; const commandbar = $(` '), utils.loopDetector((() => $("#miniHistoryBtn").length > 0), (() => { + if (generation !== this._desktopNavGeneration || !this._desktopSettingNavMounted) return; $(".miniHistoryBtnBox").before('\n \n '), e(); }), 20, 1e4, !0, scope); @@ -135,6 +138,7 @@ i(this, "_desktopSettingNavMounted", !1), i(this, "_settingScope", null), i(this this._settingNavResizeCleanup = scope.listen(window, "resize", e); } l && (isDetailPage ? $("h3").before('\n
    \n
    \n
    \n \n
    \n
    \n
    \n
    \n ') : window.isListPage && utils.loopDetector((() => $("#waitCheckBtn").length), (() => { + if (generation !== this._desktopNavGeneration || !this._desktopSettingNavMounted) return; $("#waitCheckBtn").parent().append('\n
    \n
    \n \n
    \n
    \n
    \n '); }), 1, 1e4, !1, scope)), $(".main-nav, .container-fluid").off("mouseenter.jhsSettingQuick mouseleave.jhsSettingQuick").on("mouseenter.jhsSettingQuick", ".setting-box", (async () => { @@ -152,6 +156,7 @@ i(this, "_desktopSettingNavMounted", !1), i(this, "_settingScope", null), i(this })); } unmountDesktopSettingNav() { + this._desktopNavGeneration++; this._desktopSettingNavMounted = false; this._settingNavResizeCleanup?.(); this._settingNavResizeCleanup = null; diff --git a/src/plugins/blacklist/blacklist.js b/src/plugins/blacklist/blacklist.js index 6c75608e..3298aa55 100644 --- a/src/plugins/blacklist/blacklist.js +++ b/src/plugins/blacklist/blacklist.js @@ -9,8 +9,8 @@ import { readListItem } from "../../core/list-item-reader.js"; import { legacyActionToFlag } from "../../core/state-model.js"; import { QUICK_FILTER_LABELS, normalizeQuickFilterKey } from "../../features/list/list-filters.js"; import { createListEvaluationContext, evaluateListItem } from "../../features/list/list-evaluator.js"; -import { resolveFirstPageUrl } from "../../features/list/batch-scope.js"; import { scanAllPages } from "../../features/list/batch-scanner.js"; +import { endBatchRun, isActiveBatchRun, tryBeginBatchRun } from "../../features/list/batch-coordinator.js"; import { JhsSelect, renderStateView } from "../../core/ui-primitives.js"; import { parseDetailPage } from "../../integrations/host-list/parser.js"; import { createJhsTable } from "../../ui/table/create-jhs-table.js"; @@ -362,39 +362,49 @@ export class BlacklistPlugin extends BasePlugin { const proceed = await new Promise((resolve) => utils.q(null, confirmText, () => resolve(true), () => resolve(false))); if (!proceed) return { cancelled: true }; } + // Single Flight:与列表批量收藏/已下载共用同一协调器,避免互相打断。 + const run = tryBeginBatchRun(); + if (!run) { + show.error("已有批量任务正在执行"); + return { cancelled: true, busy: true }; + } + $("#favoriteAllVideo, #hasDownAllVideo, #filterAllVideo").attr("aria-disabled", "true").addClass("jhs-batch-busy"); const scope = await this.getRuntimeService("scope")(), listPage = this.getOptionalDependency("ListPagePlugin"); const context = "function" === typeof listPage?.createEvaluationContext ? await listPage.createEvaluationContext() : createListEvaluationContext({}); - const batchToken = Symbol("batch"); this.batchToken = batchToken; - const isCancelled = () => this.batchToken !== batchToken || Boolean(scope?.disposed); + const isCancelled = () => !isActiveBatchRun(run) || Boolean(scope?.disposed); const statusHost = () => (this.blacklistRoot || $()).find("#checkBlacklistMsg"); - const site = this.getRuntimeService("host")?.site ?? (r ? "javdb" : l ? "javbus" : null); - const records = await scanAllPages({ - startDom: root ? $(root) : $(document), - currentUrl: root ? null : window.location.href, - firstPageUrl: root ? null : resolveFirstPageUrl(window.location.href, site), - itemSelector: this.getSelector().requestDomItemSelector, - nextPageSelector: this.getSelector().nextPageSelector, - fetchHtml: async (/** @type {string} */ url) => requestHostPage(this.getRuntimeService("http"), url, scope), - parseItem: (/** @type {any} */ item) => readListItem(item), - evaluate: (/** @type {any} */ item) => evaluateListItem({ carNum: item.carNum, title: item.title || "" }, context, { filter: normalized }), - isCancelled, - onProgress: (/** @type {{ page: number, scanned: number, matched: number }} */ {page, scanned, matched}) => { - const host = statusHost(); host.length && host.text(`正在扫描第 ${page} 页 · 已扫描 ${scanned} · 匹配 ${matched} 个番号`); - }, - }); - if (isCancelled()) return { cancelled: true }; - let updated = 0; - for (let index = 0; index < records.length; index += 75) { - const chunk = records.slice(index, index + 75); - await this.getRuntimeService("state").patch(chunk.map((item) => item.carNum), { blocked: !0 }, { - type: "actor-page-block", - records: chunk.map((item) => ({ carNum: item.carNum, url: item.url || "", names: actressName, publishTime: item.publishTime || "" })), + try { + const records = await scanAllPages({ + startDom: root ? $(root) : $(document), + currentUrl: root ? null : window.location.href, + firstPageUrl: root ? null : (this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href) ?? window.location.href), + itemSelector: this.getSelector().requestDomItemSelector, + nextPageSelector: this.getSelector().nextPageSelector, + fetchHtml: async (/** @type {string} */ url) => requestHostPage(this.getRuntimeService("http"), url, scope), + parseItem: (/** @type {any} */ item) => readListItem(item), + evaluate: (/** @type {any} */ item) => evaluateListItem({ carNum: item.carNum, title: item.title || "" }, context, { filter: normalized }), + isCancelled, + onProgress: (/** @type {{ page: number, scanned: number, matched: number }} */ {page, scanned, matched}) => { + const host = statusHost(); host.length && host.text(`正在扫描第 ${page} 页 · 已扫描 ${scanned} · 匹配 ${matched} 个番号`); + }, }); - updated += chunk.length; - clog.log("一键屏蔽进度", `已屏蔽 ${updated}/${records.length} 个番号`); + if (isCancelled()) return { cancelled: true }; + let updated = 0; + for (let index = 0; index < records.length; index += 75) { + const chunk = records.slice(index, index + 75); + await this.getRuntimeService("state").patch(chunk.map((item) => item.carNum), { blocked: !0 }, { + type: "actor-page-block", + records: chunk.map((item) => ({ carNum: item.carNum, url: item.url || "", names: actressName, publishTime: item.publishTime || "", fc2Source: item.fc2Source })), + }); + updated += chunk.length; + clog.log("一键屏蔽进度", `已屏蔽 ${updated}/${records.length} 个番号`); + } + const host = statusHost(); host.length && host.text(`处理完成 · 共屏蔽 ${updated} 个番号`); + return { matched: records.length, updated }; + } finally { + if (isActiveBatchRun(run)) endBatchRun(run); + $("#favoriteAllVideo, #hasDownAllVideo, #filterAllVideo").removeAttr("aria-disabled").removeClass("jhs-batch-busy"); } - const host = statusHost(); host.length && host.text(`处理完成 · 共屏蔽 ${updated} 个番号`); - return { matched: records.length, updated }; } /** @param {string} e @param {string} t @param {any} n @param {string} [site] @param {number} [page] @param {number} [processed] */ async filterActorVideo(e, t, n, site = l ? I : T, page = 1, processed = 0) { diff --git a/src/plugins/external-search/fc2-by-123av.js b/src/plugins/external-search/fc2-by-123av.js index 9ca650b5..46760572 100644 --- a/src/plugins/external-search/fc2-by-123av.js +++ b/src/plugins/external-search/fc2-by-123av.js @@ -2,7 +2,6 @@ import { _, escapeHtml, o } from "../../core/constants.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { renderTranslatedTitle } from "../../ui/translation/title-translation.js"; import { createFc2SourceLinks, renderFc2Gallery, renderFc2State } from "../../ui/detail/fc2-workspace-view.js"; /** @typedef {any} JQueryHandle Legacy jQuery runtime handle. */ @@ -119,8 +118,6 @@ export class Fc2By123AvPlugin extends BasePlugin { const info = await this.get123AvVideoInfo(context.carNum, url); if (!context.isAlive()) return null; this.render123AvSummary(context, info); - const scope = await this.getRuntimeService("scope")(); - if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _) await renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope }); return info; } catch (error) { context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.loadSummary(context, url))), clog.error("123AV 详情加载失败", error); diff --git a/src/plugins/external-search/fc2.js b/src/plugins/external-search/fc2.js index 3c08f0b0..eceaf486 100644 --- a/src/plugins/external-search/fc2.js +++ b/src/plugins/external-search/fc2.js @@ -30,6 +30,7 @@ import { createFc2DetailContext, createFc2DetailShell } from "../../ui/detail/fc * @property {(observer: { disconnect?: () => void }) => unknown} addObserver * @property {((enabled: boolean) => void) | undefined} [magnetFilterApply] * @property {Set | undefined} galleryUrls + * @property {boolean | undefined} translationInFlight */ /** @typedef {{ id: string, name: string, gender?: number }} MovieActor */ /** @typedef {{ title?: string, originalTitle?: string, coverUrl?: string | null, carNum?: string, releaseDate?: string, score?: number | string, duration?: number | string, actors?: MovieActor[], imageUrls?: string[] }} Fc2Movie */ @@ -223,11 +224,13 @@ export class Fc2Plugin extends BasePlugin { isActive: () => context.isAlive() && settings.snapshot().enableLoadScreenShot !== "no", isDuplicate: url => Boolean(context.galleryUrls?.has(url)), })).then((/** @type {unknown} */ result) => { + // 稳定插槽:绝不 remove;渲染函数已写入 empty/error 状态,无需再清空。 if (!context.isAlive()) return; if (settings.snapshot().enableLoadScreenShot === "no") return void screenshot.empty(); - if (!result && !screenshot.children().length) screenshot.remove(); + if (!result) return; }).catch((error) => { - context.isAlive() && settings.snapshot().enableLoadScreenShot !== "no" && screenshot.remove(), clog.error("FC2 剧照初始化失败", error); + context.isAlive() && screenshot.empty(); + clog.error("FC2 剧照初始化失败", error); }); } /** OFF:清空 FC2 截图槽(保留节点以便再次开启)。 */ @@ -235,25 +238,36 @@ export class Fc2Plugin extends BasePlugin { unmountFc2Screenshot(context) { context.root.find('[data-jhs-role="screenshot"]').empty(); } - /** ON:按当前 DOM 标题重新翻译(原生/123AV 共用 .current-title)。 */ + /** ON:按当前 DOM 标题重新翻译(原生/123AV 共用 .current-title);所有翻译入口唯一实现。 */ /** @param {Fc2DetailContext} context */ applyFc2Translation(context) { + if (context.translationInFlight) return; + context.translationInFlight = true; const generation = this.translationGeneration, settings = this.getRuntimeService("settings"); - void Promise.resolve().then((() => this.getRuntimeService("scope")())).then((/** @type {any} */ scope) => renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope, isActive: () => context.isAlive() && (settings.snapshot().translateTitle ?? _) === _ && generation === this.translationGeneration })).catch((error => clog.error("FC2 标题翻译失败", error))); + Promise.resolve().then((() => this.getRuntimeService("scope")())).then((/** @type {any} */ scope) => renderTranslatedTitle({ root: context.root, carNum: context.carNum, translation: this.getRuntimeService("translation"), scope, isActive: () => context.isAlive() && (settings.snapshot().translateTitle ?? _) === _ && generation === this.translationGeneration })).catch((error => clog.error("FC2 标题翻译失败", error))).finally((() => { + context.translationInFlight = false; + })); } /** OFF:移除 FC2 已渲染的翻译节点。 */ /** @param {Fc2DetailContext} context */ revertFc2Translation(context) { context.root.find(".translated-title").remove(); } - /** ON:挂载外部站点面板(方法内部按设置门禁);插件缺失时移除分组。 */ + /** ON:挂载外部站点面板(方法内部按设置门禁);只有整个 capability 缺失时才允许移除分组。 */ /** @param {Fc2DetailContext} context @param {any} sitesGroup @param {any} [otherSite] */ mountFc2OtherSites(context, sitesGroup, otherSite) { if (!otherSite) return void sitesGroup.remove(); const settings = this.getRuntimeService("settings"); sitesGroup.length && sitesGroup.show(); - void Promise.resolve().then((() => otherSite.loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: !1, isActive: () => context.isAlive() && settings.snapshot().enableLoadOtherSite !== "no" }))).then((/** @type {JQueryHandle | null} */ box) => { if (context.isAlive() && !box) sitesGroup.remove(); }).catch((/** @type {unknown} */ error) => { - context.isAlive() && sitesGroup.remove(), clog.error("FC2 外部站点加载失败", error); + void Promise.resolve().then((() => otherSite.loadOtherSite(context.carNum.replace("FC2-", ""), context.carNum, { root: context.root, target: sitesGroup.find('[data-jhs-role="other-sites"]'), autoDetect: !1, isActive: () => context.isAlive() && settings.snapshot().enableLoadOtherSite !== "no" }))).then((/** @type {JQueryHandle | null} */ box) => { + // 稳定插槽:OFF/无结果只隐藏分组,绝不 remove;OFF→ON 仍能重新挂载。 + if (!context.isAlive()) return; + box ? sitesGroup.show() : sitesGroup.hide(); + }).catch((/** @type {unknown} */ error) => { + if (!context.isAlive()) return; + sitesGroup.show(); + renderFc2State(context.root.find('[data-jhs-role="other-sites"]'), "外部站点加载失败"); + clog.error("FC2 外部站点加载失败", error); }); } /** OFF:删除 FC2 内外部站点面板并隐藏分组。 */ @@ -268,6 +282,8 @@ export class Fc2Plugin extends BasePlugin { async loadNativeDetail(context) { const movieIdPromise = Promise.resolve(context.movieId); this.configureJavDbWantButton(context, movieIdPromise), await Promise.allSettled([ this.fetchAndRenderNativeDetail(context), this.fetchAndRenderNativeMagnets(context), this.mountPanels(context, movieIdPromise) ]); + // 初始详情翻译走统一入口(generation/isActive/单飞都在 applyFc2Translation 内)。 + if (context.isAlive()) void this.applyFc2Translation(context); } /** @param {Fc2DetailContext} context */ async load123AvDetail(context) { @@ -280,6 +296,8 @@ export class Fc2Plugin extends BasePlugin { context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="native-magnets"]'), "站内磁力关联失败", (() => void this.load123AvMagnets(context))), clog.error("123AV 磁力关联失败", error); })); await source.loadDetail(context, context.url); + // 初始 123AV 摘要翻译走统一入口。 + if (context.isAlive()) void this.applyFc2Translation(context); } /** @param {Fc2DetailContext} context */ async load123AvMagnets(context) { @@ -335,7 +353,6 @@ export class Fc2Plugin extends BasePlugin { if (!movie) throw new Error("JavDB 影片详情不存在"); if (!context.isAlive()) return; this.renderSummary(context, movie), renderFc2Gallery(context, movie.imageUrls || [], movie.coverUrl || null); - if ((this.getRuntimeService("settings").snapshot().translateTitle ?? _) === _) await renderTranslatedTitle({ root: context.root, carNum: movie.carNum || context.carNum, translation: this.getRuntimeService("translation"), scope }); } catch (error) { context.isAlive() && renderFc2State(context.root.find('[data-jhs-role="summary-content"]'), "影片信息加载失败", (() => void this.fetchAndRenderNativeDetail(context))), clog.error("FC2 详情加载失败", error); } diff --git a/src/plugins/external-search/other-site.js b/src/plugins/external-search/other-site.js index 42934d01..a51fab12 100644 --- a/src/plugins/external-search/other-site.js +++ b/src/plugins/external-search/other-site.js @@ -14,6 +14,7 @@ export class OtherSitePlugin extends BasePlugin { constructor() { super(...arguments); /** @type {number} */ this.mountGeneration = 0; + /** @type {WeakMap} */ this._mountGenerations = new WeakMap(); /** @type {SiteConfig[]} */ this.siteConfigs = [ { id: "javTrailersBtn" }, { id: "123AvBtn", providerId: "av123" }, { id: "jableBtn" }, { id: "avgleBtn" }, { id: "missAvBtn" }, { id: "supJavBtn" }, @@ -68,14 +69,18 @@ export class OtherSitePlugin extends BasePlugin { /** OFF:删除 JHS 自有面板(宿主原生 UI 不做永久销毁),并使在途挂载作废。 */ unmount() { this.mountGeneration++; + this._mountGenerations.set(document, (this._mountGenerations.get(document) || 0) + 1); $("[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); } /** @param {string | null} e @param {string | null} t @param {OtherSiteLoadOptions} [n] */ async loadOtherSite(e, t, n = {}) { const settingsService = this.getRuntimeService("settings"); if (settingsService.snapshot().enableLoadOtherSite === "no") return; - const generation = ++this.mountGeneration; - const isActive = () => generation === this.mountGeneration && ("function" === typeof n.isActive ? n.isActive() : !0) && settingsService.snapshot().enableLoadOtherSite !== "no"; + // 每个 FC2 context(root)持有自己的 generation,互不失效;无 root 的详情页沿用全局 generation。 + const rootElement = n.root?.[0] || null; + const generation = rootElement ? (this._mountGenerations.get(rootElement) || 0) + 1 : ++this.mountGeneration; + rootElement && this._mountGenerations.set(rootElement, generation); + const isActive = () => (rootElement ? generation === this._mountGenerations.get(rootElement) : generation === this.mountGeneration) && ("function" === typeof n.isActive ? n.isActive() : !0) && settingsService.snapshot().enableLoadOtherSite !== "no"; const root = n.root ? $(n.root) : $(document), target = n.target ? $(n.target) : $(this.getRuntimeService("host").locateDetailSlots().summary); if (!target.length || !isActive()) return; root.find("#otherSiteBox,#settingsArea,[data-jhs-other-site-box],[data-jhs-other-site-settings]").remove(); diff --git a/src/plugins/image-viewer/bus-preview-video.js b/src/plugins/image-viewer/bus-preview-video.js index b59f40bc..15bd2f03 100644 --- a/src/plugins/image-viewer/bus-preview-video.js +++ b/src/plugins/image-viewer/bus-preview-video.js @@ -3,7 +3,7 @@ import { L } from "../../core/constants.js"; import { safePlay } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { Z, fetchDmmPreview, fetchDmmPreviewIfEnabled, isDmmEnabled, isPreviewEnabled } from "../../services/preview-service.js"; +import { Z, canUseDmmPreview, fetchDmmPreview, fetchDmmPreviewIfEnabled, isDmmEnabled, isPreviewEnabled } from "../../services/preview-service.js"; export class BusPreviewVideoPlugin extends BasePlugin { constructor() { @@ -63,13 +63,12 @@ export class BusPreviewVideoPlugin extends BasePlugin { } this.reconfigure(); } - /** 统一 reconfigure:总开关 OFF→卸载;DMM 子开关 OFF→停止当前 JHS 播放。 */ + /** 统一 reconfigure:JavBus 无宿主原生预览,整个 JHS 入口只有 DMM 能力 → 必须 Preview+DMM 都 ON。 */ reconfigure() { this._busPreviewGeneration++; const settings = this.getRuntimeService("settings").snapshot(); - if (!isPreviewEnabled(settings)) return void this.unmountPreview(); + if (!canUseDmmPreview(settings)) return void this.unmountPreview(); this.mountPreview(); - if (!isDmmEnabled(settings)) this.closeVideoModal(); } /** 幂等挂载:modal、入口按钮、DMM 预载。 */ mountPreview() { diff --git a/src/plugins/image-viewer/cover-button.js b/src/plugins/image-viewer/cover-button.js index eae99d79..8f5b6fe7 100644 --- a/src/plugins/image-viewer/cover-button.js +++ b/src/plugins/image-viewer/cover-button.js @@ -4,7 +4,7 @@ import { _, d, g, h, k, l, m, p, r, v, y } from "../../core/constants.js"; import { safePlay } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; import { legacyActionToFlag } from "../../core/state-model.js"; -import { Z, fetchDmmPreviewIfEnabled, isPreviewEnabled } from "../../services/preview-service.js"; +import { Z, canUseCardPreview, fetchDmmPreviewIfEnabled, isPreviewEnabled } from "../../services/preview-service.js"; /** @typedef {any} JQueryHandle */ /** @typedef {MouseEvent & { ctrlKey?: boolean, metaKey?: boolean }} CardActionEvent */ @@ -45,9 +45,9 @@ export class CoverButtonPlugin extends BasePlugin { const settingsService = this.getRuntimeService("settings"); const onSettingsChanged = (/** @type {any} */ event) => { const names = /** @type {string[] | undefined} */ (event.detail?.names) || []; - if (names.includes("enablePreviewVideo")) { + if (names.some((name) => name === "enablePreviewVideo" || name === "enableLoadPreviewVideo")) { this.previewGeneration++; - if (isPreviewEnabled(settingsService.snapshot())) void this.addSvgBtn().catch((error => clog.error("卡片预览重新挂载失败", error))); + if (canUseCardPreview(settingsService.snapshot())) void this.addSvgBtn().catch((error => clog.error("卡片预览重新挂载失败", error))); else { $('[id$="_preview_video"]').each((/** @type {number} */ _, /** @type {HTMLVideoElement} */ element) => { element.pause?.(), $(element).parent().remove(); @@ -118,7 +118,8 @@ export class CoverButtonPlugin extends BasePlugin { async enableSvgBtn(items = null) { const e = this.getRuntimeService("settings").snapshot(), {enableLoadScreenShot: t = _, enableVideoSvg: n = _, enablePreviewVideo: q = _, enableHandleSvg: a = _, enableSiteSvg: i = _, enableCopySvg: s = _} = e; const scope = items ? $(items) : $(document); - [ { selector: ".screenSvg", enabled: t === _ && Boolean(this.getOptionalDependency("ScreenShotPlugin")) ? _ : "no" }, { selector: ".videoSvg", enabled: n === _ && q === _ ? _ : "no" }, { selector: ".handleSvg", enabled: a }, { selector: ".siteSvg", enabled: i }, { selector: ".copySvg", enabled: s } ].forEach((({selector: e, enabled: t}) => { + // videoSvg 是 DMM-only 入口:Preview 总开关与 DMM 子开关都必须 ON,否则不显示(不留死按钮)。 + [ { selector: ".screenSvg", enabled: t === _ && Boolean(this.getOptionalDependency("ScreenShotPlugin")) ? _ : "no" }, { selector: ".videoSvg", enabled: n === _ && canUseCardPreview(e) ? _ : "no" }, { selector: ".handleSvg", enabled: a }, { selector: ".siteSvg", enabled: i }, { selector: ".copySvg", enabled: s } ].forEach((({selector: e, enabled: t}) => { scope.find(e).toggle(t === _); })); } @@ -221,7 +222,7 @@ export class CoverButtonPlugin extends BasePlugin { /** @param {JQueryHandle} e @param {JQueryHandle} t @param {string} n */ async showVideo(e, t, n) { const settings = this.getRuntimeService("settings").snapshot(); - if (!isPreviewEnabled(settings)) return show.error("预览视频已关闭"); + if (!canUseCardPreview(settings)) return show.error("预览视频已关闭"); const generation = this.previewGeneration; const a = `${n}_preview_video`; let i = $(`#${a}`); diff --git a/src/plugins/image-viewer/preview-video.js b/src/plugins/image-viewer/preview-video.js index a83c214e..7b145ecc 100644 --- a/src/plugins/image-viewer/preview-video.js +++ b/src/plugins/image-viewer/preview-video.js @@ -3,7 +3,7 @@ import { L, o } from "../../core/constants.js"; import { safePlay } from "../../core/feature-helpers.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { Z, fetchDmmPreview, isDmmEnabled, isPreviewEnabled } from "../../services/preview-service.js"; +import { Z, canUseDmmPreview, canUsePreview, fetchDmmPreview, isDmmEnabled, isPreviewEnabled } from "../../services/preview-service.js"; /** @typedef {any} JQueryHandle */ /** @typedef {{ code?: string, message?: string, retryable?: boolean }} PreviewFailure */ @@ -42,9 +42,9 @@ export class PreviewVideoPlugin extends BasePlugin { reconfigure() { this.previewGeneration++; const settings = this.getRuntimeService("settings").snapshot(); - if (!isPreviewEnabled(settings)) return void this.unmountPreview(); + if (!canUsePreview(settings)) return void this.unmountPreview(); this.mountPreview(); - if (isDmmEnabled(settings)) void this.initDmm(this.lifecycleScope).catch((error => clog.error("预加载 DMM 失败", error))); + if (canUseDmmPreview(settings)) void this.initDmm(this.lifecycleScope).catch((error => clog.error("预加载 DMM 失败", error))); else this.unmountDmmPlayer(); } /** 幂等挂载:入口可见、click 绑定;gallery/autoPlay 只处理一次。 */ diff --git a/src/plugins/status/list-page-button.js b/src/plugins/status/list-page-button.js index e9298a7d..341fb4f7 100644 --- a/src/plugins/status/list-page-button.js +++ b/src/plugins/status/list-page-button.js @@ -44,7 +44,7 @@ export class ListPageButtonPlugin extends BasePlugin { const r = o.includes("advanced_search"); r && (t = $("h2.section-title")); const l = this.getRuntimeService("settings").snapshot().sortMethod || "default", d = "当前排序方式: " + ("rateCount" === l ? "评价人数" : "date" === l ? "时间" : "默认"); - t.append(`\n
    \n \n ${e ? `\n${hasBlacklist ? `` : ""}\n${hasBlacklist ? `` : ""}\n${hasListPage ? `` : ""}\n${hasListPage ? `` : ""}\n ` : ""}\n ${o.includes("/tags") ? `\n ${hasBlacklist ? `` : ""}\n ` : ""}\n
    \n
    \n ${hasNewVideo ? `` : ""}\n ${hasBlacklist ? `` : ""}\n ${c ? "" : this.sortMenuHtml(l || "default", d)}\n
    \n `); + t.append(`\n
    \n \n ${e && hasBlacklist ? `\n\n\n` : ""}\n ${hasListPage ? `\n\n\n` : ""}\n ${o.includes("/tags") && hasBlacklist ? `\n\n` : ""}\n
    \n
    \n ${hasNewVideo ? `` : ""}\n ${hasBlacklist ? `` : ""}\n ${c ? "" : this.sortMenuHtml(l || "default", d)}\n
    \n `); } if (l) { const e = o.includes("/star/"); @@ -54,7 +54,7 @@ export class ListPageButtonPlugin extends BasePlugin { e.find((/** @type {BlacklistRecord} */ e) => e.starId === a.starId) && (t = "已加入黑名单", n = "jhs-btn--muted"); } const a = this.getRuntimeService("settings").snapshot().sortMethod || "default"; - $(".masonry").parent().prepend(`\n
    \n \n ${e && hasBlacklist ? ` \n \n \n ` : ""}${e && hasListPage ? ` \n \n \n ` : ""}${!e && hasBlacklist ? `` : ""}\n ${this.sortMenuHtml(a)}\n
    \n `); + $(".masonry").parent().prepend(`\n
    \n \n ${e && hasBlacklist ? ` \n \n \n ` : ""}${hasListPage ? ` \n \n \n ` : ""}${!e && hasBlacklist ? `` : ""}\n ${this.sortMenuHtml(a)}\n
    \n `); } $("#waitCheckBtn > span").text("开始鉴定"); const newVideoCount = $("#newVideoCount").detach(), newVideoLabel = $("#newVideoBtn > span"); @@ -88,17 +88,13 @@ export class ListPageButtonPlugin extends BasePlugin { clog.error(t); } finally { this.loadObj.close(); } })), $("#favoriteAllVideo").on("click", (async (/** @type {any} */ t) => { - let a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a.length) return void show.error("获取演员名称失败"); - let i = a.text().trim().split(",")[0]; + const scope = this.buildBatchScope(); this.loadObj = loading(); - try { await listPage?.batchSaveAllVideos?.(i, h); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } + try { await listPage?.batchSaveAllVideos?.(scope, h); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } })), $("#hasDownAllVideo").on("click", (async (/** @type {any} */ t) => { - let a = r ? $(".actor-section-name") : $(".avatar-box .photo-info .pb10"); - if (0 === a.length) return void show.error("获取演员名称失败"); - let i = a.text().trim().split(",")[0]; + const scope = this.buildBatchScope(); this.loadObj = loading(); - try { await listPage?.batchSaveAllVideos?.(i, g); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } + try { await listPage?.batchSaveAllVideos?.(scope, g); } catch (t) { clog.error(t); } finally { this.loadObj.close(); } })); } /** 绑定排序 popover 的选择与键盘交互。 */ @@ -162,6 +158,13 @@ export class ListPageButtonPlugin extends BasePlugin { supportsLiveSorting() { return this.isHitShowPage() || this.isFc2ListPage(); } + /** 构造批量任务范围:actor 页携带演员名,搜索/列表页不要求演员名且不写入搜索关键词。 */ + buildBatchScope() { + const isActorPage = r ? o.includes("/actors/") : o.includes("/star/"); + if (!isActorPage) return { kind: "search", displayName: "当前搜索条件", recordName: "" }; + const info = this.getActressPageInfo(); + return { kind: "actor", displayName: info?.name || "", recordName: info?.name || "" }; + } async openWaitCheck() { let e = this.getSelector(); const t = await storageManager.getSetting("waitCheckCount", 5); @@ -176,4 +179,4 @@ export class ListPageButtonPlugin extends BasePlugin { } 0 === a && show.info("没有需鉴定的视频"); } -} +} \ No newline at end of file diff --git a/src/plugins/status/list-page.js b/src/plugins/status/list-page.js index da1a89b3..0a3fdc27 100644 --- a/src/plugins/status/list-page.js +++ b/src/plugins/status/list-page.js @@ -5,13 +5,13 @@ import { jhsEventBus } from "../../core/event-bus.js"; import { mapLimit, safePlay } from "../../core/feature-helpers.js"; import { requestHostPage } from "../../core/host-page-request.js"; import { BasePlugin } from "../../core/plugin-manager.js"; -import { readListItem } from "../../core/list-item-reader.js"; +import { readCardNames, readListItem } from "../../core/list-item-reader.js"; import { isHitShowPage } from "../../core/site-context.js"; import { hasAnyState, legacyActionToFlag, normalizeStateFlags } from "../../core/state-model.js"; import { PRIMARY_QUICK_FILTERS, QUICK_FILTER_LABELS, SECONDARY_QUICK_FILTERS, isHardHidden, normalizeQuickFilterKey, shouldShowItem } from "../../features/list/list-filters.js"; import { createListEvaluationContext, evaluateListItem, findMatchedTitleKeyword } from "../../features/list/list-evaluator.js"; -import { resolveFirstPageUrl } from "../../features/list/batch-scope.js"; import { scanAllPages } from "../../features/list/batch-scanner.js"; +import { endBatchRun, isActiveBatchRun, tryBeginBatchRun } from "../../features/list/batch-coordinator.js"; /** @typedef {Record} ListRecord */ /** @typedef {any} JQueryHandle */ @@ -93,7 +93,7 @@ const Te = { export class ListPagePlugin extends BasePlugin { async initCss() { - return ``; + return ``; } constructor() { super(...arguments); @@ -441,12 +441,14 @@ export class ListPagePlugin extends BasePlugin { /** * Batch-mark every item matching the current quick filter across all pages. * 语义:全部 = 包含 hard-hidden 的真全集;其他筛选 = 全部分页中符合该筛选的项。 - * @param {string} actressName @param {string} flag @param {{ filter?: unknown, confirm?: boolean, root?: any }} [options] + * @param {{ kind?: "actor" | "search", displayName?: string, recordName?: string }} scope + * @param {string} flag @param {{ filter?: unknown, confirm?: boolean, root?: any }} [options] */ - async batchSaveAllVideos(actressName, flag, { filter = this.activeQuickFilter || "waitCheck", confirm = true, root = null } = {}) { + async batchSaveAllVideos(scope, flag, { filter = this.activeQuickFilter || "waitCheck", confirm = true, root = null } = {}) { const stateFlag = legacyActionToFlag(flag); if (!stateFlag) throw new TypeError(`不支持的状态操作: ${flag}`); const normalized = normalizeQuickFilterKey(filter), filterLabel = QUICK_FILTER_LABELS[normalized]; + const actorScope = scope?.kind === "actor", recordName = actorScope ? String(scope.recordName || "") : ""; const confirmText = "all" === normalized ? "将处理当前搜索全部分页的所有作品,包括屏蔽项。" : `将处理当前搜索全部分页中符合「${filterLabel}」筛选的作品。`; @@ -454,10 +456,16 @@ export class ListPagePlugin extends BasePlugin { const proceed = await new Promise((resolve) => utils.q(null, confirmText, () => resolve(true), () => resolve(false))); if (!proceed) return { cancelled: true }; } - const scope = await this.getRuntimeService("scope")(), context = await this.createEvaluationContext(), batchToken = Symbol("batch"); - this.batchToken = batchToken; - const isCancelled = () => this.batchToken !== batchToken || Boolean(scope?.disposed); - const progressElement = this.showBatchProgress(); + // Single Flight:已有批量任务时不启动第二个任务,避免互相打断/污染进度。 + const run = tryBeginBatchRun(); + if (!run) { + show.error("已有批量任务正在执行"); + return { cancelled: true, busy: true }; + } + const runtimeScope = await this.getRuntimeService("scope")(), context = await this.createEvaluationContext(); + const isCancelled = () => !isActiveBatchRun(run) || Boolean(runtimeScope?.disposed); + const progressElement = this.showBatchProgress(run); + this.setBatchButtonsDisabled(true); const setProgress = (/** @type {string} */ text) => { const label = progressElement?.find(".jhs-batch-progress__label"); label && label.length ? label.text(text) : clog.debug(text); @@ -467,20 +475,25 @@ export class ListPagePlugin extends BasePlugin { clog.debug(`批量扫描第 ${page} 页 · 已扫描 ${scanned} · 匹配 ${matched}`); }; try { - const site = this.getRuntimeService("host")?.site ?? (r ? "javdb" : l ? "javbus" : null); const records = await scanAllPages({ startDom: root ? $(root) : $(document), currentUrl: root ? null : window.location.href, - firstPageUrl: root ? null : resolveFirstPageUrl(window.location.href, site), + firstPageUrl: root ? null : (this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href) ?? window.location.href), itemSelector: this.getSelector().requestDomItemSelector, nextPageSelector: this.getSelector().nextPageSelector, - fetchHtml: async (/** @type {string} */ url) => requestHostPage(this.getRuntimeService("http"), url, scope), - parseItem: (/** @type {any} */ item) => readListItem(item), + fetchHtml: async (/** @type {string} */ url) => requestHostPage(this.getRuntimeService("http"), url, runtimeScope), + parseItem: (/** @type {any} */ item) => { + const parsed = readListItem(item); + return actorScope ? parsed : { ...parsed, names: readCardNames(item) }; + }, evaluate: (/** @type {any} */ item) => evaluateListItem({ carNum: item.carNum, title: item.title || "" }, context, { filter: normalized }), isCancelled, onProgress, }); - if (isCancelled()) return { cancelled: true }; + if (isCancelled()) { + progressElement?.remove(); + return { cancelled: true }; + } // 写入阶段不可取消:禁用按钮并给出明确文案,避免出现半批数据状态。 progressElement?.find("#jhs-batch-cancel").prop("disabled", true).attr("title", "正在写入,无法取消"); setProgress("正在写入,无法取消…"); @@ -490,7 +503,7 @@ export class ListPagePlugin extends BasePlugin { const chunk = records.slice(index, index + 75); await this.getRuntimeService("state").patch(chunk.map((item) => item.carNum), { [stateFlag]: !0 }, { type: "actor-page-batch-state", - records: chunk.map((item) => ({ carNum: item.carNum, url: item.url || "", names: actressName, publishTime: item.publishTime || "" })), + records: chunk.map((item) => ({ carNum: item.carNum, url: item.url || "", names: item.names ?? recordName, publishTime: item.publishTime || "", fc2Source: item.fc2Source })), }); updated += chunk.length; setProgress(`已更新 ${updated}/${records.length} 项`); @@ -505,17 +518,24 @@ export class ListPagePlugin extends BasePlugin { setTimeout(() => progressElement?.remove(), 2500); throw error; } finally { - this.batchToken = null; + if (isActiveBatchRun(run)) endBatchRun(run); + this.setBatchButtonsDisabled(false); } } - /** 批量进度浮层(带取消按钮);返回可更新的状态元素。 */ - showBatchProgress() { + /** 批量任务期间禁用/恢复所有批量入口(视觉禁用但保持可点击,点击时由 handler 提示并拒绝启动)。 */ + /** @param {boolean} disabled */ + setBatchButtonsDisabled(disabled) { + $("#favoriteAllVideo, #hasDownAllVideo, #filterAllVideo").attr("aria-disabled", String(disabled)).toggleClass("jhs-batch-busy", disabled); + } + /** 批量进度浮层(带取消按钮);返回可更新的状态元素。每次绑定当前 run 的取消 handler。 */ + /** @param {any} run */ + showBatchProgress(run) { let element = $("#jhs-batch-progress"); if (!element.length) { element = $('
    ').appendTo("body"); element.append(''); - element.find("#jhs-batch-cancel").on("click", () => { this.batchToken = null; }); } + element.find("#jhs-batch-cancel").off("click").on("click", () => endBatchRun(run)); element.find(".jhs-batch-progress__label").remove().end().prepend($('').text("正在扫描…")); return element; } diff --git a/src/plugins/status/mobile-bottom-bar.js b/src/plugins/status/mobile-bottom-bar.js index 048406cc..d82ab659 100644 --- a/src/plugins/status/mobile-bottom-bar.js +++ b/src/plugins/status/mobile-bottom-bar.js @@ -234,12 +234,14 @@ export class MobileBottomBarPlugin extends BasePlugin { } /** 卸载桌面命令栏并把被收拢的控件放回宿主原位(原容器已移除时回退到列表容器前)。 */ unmountDesktopCommandBar() { - $("#jhs-page-commandbar").remove(); - for (const source of this._commandBarSources || []) { + // 必须先 restore 再 remove:按钮此刻仍在 commandbar 内,先删外壳会让 isConnected=false 导致 restore 被跳过。 + // 逆序恢复保证兄弟按钮先回到原位,前面的按钮仍能按记录的 nextSibling 精确插回。 + const sources = [ ...(this._commandBarSources || []) ].reverse(); + for (const source of sources) { const element = source.element; if (!element?.isConnected) continue; if (source.parent?.isConnected) { - if (source.next?.isConnected) source.parent.insertBefore(element, source.next); + if (source.next?.isConnected && source.parent.contains(source.next)) source.parent.insertBefore(element, source.next); else source.parent.appendChild(element); } else { const host = $(this.getSelector().boxSelector).first(); @@ -247,6 +249,7 @@ export class MobileBottomBarPlugin extends BasePlugin { } } this._commandBarSources = []; + $("#jhs-page-commandbar").remove(); $(document).off("click.jhsCommandbar"); } /** 将列表页分散的 JHS 控件收敛为单一命令栏。 */ @@ -292,7 +295,7 @@ export class MobileBottomBarPlugin extends BasePlugin { item.length && (remember(item[0]), item.attr({ class: "jhs-btn jhs-btn--ghost", role: "menuitem", tabindex: "-1" }).detach().appendTo(batch.find(".jhs-commandbar__menu"))); })); batch.find(".jhs-commandbar__menu").children().length && right.append(batch); - $(".jhs-list-btn-row").filter(((/** @type {number} */ index, /** @type {Element} */ element) => !$(element).children().length)).remove(); + // 保留原始 .jhs-list-btn-row 行,unmount 时按钮需要恢复到这些锚点。 commandbar.find(".jhs-commandbar__more, .jhs-commandbar__batch").each(((/** @type {number} */ index, /** @type {Element} */ element) => { const container = $(element), toggle = container.find(".jhs-commandbar__menu-toggle"), menu = container.find(".jhs-commandbar__menu"); toggle.on("click", ((/** @type {any} */ event) => { diff --git a/src/services/preview-service.js b/src/services/preview-service.js index a0278eee..bdf08654 100644 --- a/src/services/preview-service.js +++ b/src/services/preview-service.js @@ -30,6 +30,26 @@ export function isDmmEnabled(settings) { return settings?.enableLoadPreviewVideo !== "no"; } +/** 预览总开关:任一预览入口可见的前提。 @param {Record} settings */ +export function canUsePreview(settings) { + return isPreviewEnabled(settings); +} + +/** 宿主原生预览:总开关 ON 即可(不依赖 DMM)。 @param {Record} settings */ +export function canUseNativePreview(settings) { + return isPreviewEnabled(settings); +} + +/** DMM 高画质能力:总开关与 DMM 子开关都开。 @param {Record} settings */ +export function canUseDmmPreview(settings) { + return isPreviewEnabled(settings) && isDmmEnabled(settings); +} + +/** 列表卡片 Preview 按钮:当前实现只有 DMM source,因此等价于 canUseDmmPreview。 @param {Record} settings */ +export function canUseCardPreview(settings) { + return canUseDmmPreview(settings); +} + class DmmPreviewParser { /** @param {string | null} e @param {any} storage @param {any} movie @param {any} scope */ constructor(e, storage, movie, scope) { diff --git a/src/ui/translation/title-translation.js b/src/ui/translation/title-translation.js index aeeec2a0..1203b4d4 100644 --- a/src/ui/translation/title-translation.js +++ b/src/ui/translation/title-translation.js @@ -21,6 +21,8 @@ export async function renderTranslatedTitle(options) { if (!title[0]?.isConnected || !translatedNode[0]?.isConnected) return; translatedNode.text(translated); } catch (error) { + if (options.isActive && !options.isActive()) return; + if (!title[0]?.isConnected || !translatedNode[0]?.isConnected) return; const message = error instanceof Error ? error.message : String(error); /** @type {any} */ (globalThis).clog?.error("翻译失败:", error); translatedNode.addClass("is-error").text(`翻译失败: ${message}`); diff --git a/tests/batch-contract.test.js b/tests/batch-contract.test.js index b85da521..ce4e91af 100644 --- a/tests/batch-contract.test.js +++ b/tests/batch-contract.test.js @@ -12,8 +12,8 @@ describe("batch action contract (筛选后批量语义)", () => { expect(listPage).toContain("scanAllPages({"); expect(listPage).toContain("startDom: root ? $(root) : $(document)"); expect(listPage).toContain("currentUrl: root ? null : window.location.href"); - expect(listPage).toContain("firstPageUrl: root ? null : resolveFirstPageUrl(window.location.href, site)"); - expect(blacklist).toContain("firstPageUrl: root ? null : resolveFirstPageUrl(window.location.href, site)"); + expect(listPage).toContain('firstPageUrl: root ? null : (this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href) ?? window.location.href)'); + expect(blacklist).toContain('firstPageUrl: root ? null : (this.getRuntimeService("host")?.resolveFirstPageUrl?.(window.location.href) ?? window.location.href)'); expect(listPage).toContain("itemSelector: this.getSelector().requestDomItemSelector"); expect(listPage).toContain('evaluateListItem({ carNum: item.carNum, title: item.title || "" }, context, { filter: normalized })'); }); @@ -30,17 +30,32 @@ describe("batch action contract (筛选后批量语义)", () => { expect(listPage).not.toMatch(/for \(const element of items\)[\s\S]{0,200}state\.patch\(carNum/); }); - it("keeps the button entry points on the same batch API with per-filter confirmation", () => { - expect(listButtons).toContain("batchSaveAllVideos?.(i, h)"); - expect(listButtons).toContain("batchSaveAllVideos?.(i, g)"); + it("keeps the button entry points on the scope-based batch API with per-filter confirmation", () => { + expect(listButtons).toContain("batchSaveAllVideos?.(scope, h)"); + expect(listButtons).toContain("batchSaveAllVideos?.(scope, g)"); + expect(listButtons).toContain("buildBatchScope()"); + expect(listButtons).toContain('{ kind: "search", displayName: "当前搜索条件", recordName: "" }'); expect(listButtons).not.toContain("一键收藏所有可见作品?"); expect(listButtons).toContain("批量屏蔽"); expect(listButtons).toContain("批量收藏"); expect(listButtons).toContain("批量标记已下载"); expect(listButtons).not.toContain('utils.q(n, "一键屏蔽视频列表?"'); + expect(listPage).toContain("async batchSaveAllVideos(scope, flag"); expect(listPage).toContain('正在写入,无法取消'); expect(listPage).toContain('#jhs-batch-cancel").prop("disabled", true'); expect(scanner).toContain("matchesCurrentFilter === true"); expect(scanner).toContain("isSamePageUrl(firstPageUrl, currentUrl)"); }); + + it("runs every batch action through the shared Single Flight coordinator", () => { + expect(listPage).toContain("tryBeginBatchRun()"); + expect(listPage).toContain("isActiveBatchRun(run)"); + expect(listPage).toContain("endBatchRun(run)"); + expect(listPage).toContain("已有批量任务正在执行"); + expect(listPage).toContain("setBatchButtonsDisabled(true)"); + expect(listPage).toContain("setBatchButtonsDisabled(false)"); + expect(blacklist).toContain("tryBeginBatchRun()"); + expect(blacklist).toContain("已有批量任务正在执行"); + expect(blacklist).toContain("if (isActiveBatchRun(run)) endBatchRun(run)"); + }); }); diff --git a/tests/batch-coordinator.test.js b/tests/batch-coordinator.test.js new file mode 100644 index 00000000..bf08cfa1 --- /dev/null +++ b/tests/batch-coordinator.test.js @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { endBatchRun, isActiveBatchRun, isBatchRunActive, tryBeginBatchRun } from "../src/features/list/batch-coordinator.js"; + +describe("batch Single Flight coordinator", () => { + it("accepts exactly one run at a time", () => { + const first = tryBeginBatchRun(); + expect(first).not.toBeNull(); + expect(isBatchRunActive()).toBe(true); + expect(tryBeginBatchRun()).toBeNull(); + expect(tryBeginBatchRun()).toBeNull(); + endBatchRun(first); + expect(isBatchRunActive()).toBe(false); + }); + + it("allows a new run after the previous one ends", () => { + const first = tryBeginBatchRun(); + endBatchRun(first); + const second = tryBeginBatchRun(); + expect(second).not.toBeNull(); + expect(isActiveBatchRun(second)).toBe(true); + endBatchRun(second); + }); + + it("a stale run can never clear a newer run", () => { + const first = tryBeginBatchRun(); + endBatchRun(first); + const second = tryBeginBatchRun(); + // 旧任务的 finally 再次结束自己的 run:不得影响新 run。 + endBatchRun(first); + expect(isActiveBatchRun(second)).toBe(true); + expect(isBatchRunActive()).toBe(true); + endBatchRun(second); + expect(isBatchRunActive()).toBe(false); + }); +}); diff --git a/tests/batch-scope.test.js b/tests/batch-scope.test.js index 34c64cac..02408aac 100644 --- a/tests/batch-scope.test.js +++ b/tests/batch-scope.test.js @@ -1,40 +1,69 @@ import { describe, expect, it } from "vitest"; -import { isSamePageUrl, resolveFirstPageUrl } from "../src/features/list/batch-scope.js"; +import { isSamePageUrl } from "../src/features/list/batch-scope.js"; +import { JavBusHostAdapter } from "../src/platform/hosts/javbus-host-adapter.js"; +import { JavDbHostAdapter } from "../src/platform/hosts/javdb-host-adapter.js"; -describe("resolveFirstPageUrl", () => { - it("drops the page query param on JavDB and keeps other search conditions", () => { - expect(resolveFirstPageUrl("https://javdb.com/actors/abc?page=3&v=1", "javdb")) +// resolveFirstPageUrl 不触碰 DOM;node 环境显式传 null,避免默认参数引用 document/window。 +const javdb = new JavDbHostAdapter(/** @type {any} */ (null), /** @type {any} */ (null)); +const javbus = new JavBusHostAdapter(/** @type {any} */ (null), /** @type {any} */ (null)); + +describe("resolveFirstPageUrl (JavDbHostAdapter)", () => { + it("drops the page query param and keeps other search conditions", () => { + expect(javdb.resolveFirstPageUrl("https://javdb.com/actors/abc?page=3&v=1")) .toBe("https://javdb.com/actors/abc?v=1"); - expect(resolveFirstPageUrl("https://javdb.com/actors/abc?q=test&page=2", "javdb")) + expect(javdb.resolveFirstPageUrl("https://javdb.com/actors/abc?q=test&page=2")) .toBe("https://javdb.com/actors/abc?q=test"); - expect(resolveFirstPageUrl("https://javdb.com/actors/abc?page=1", "javdb")) + expect(javdb.resolveFirstPageUrl("https://javdb.com/actors/abc?page=1")) .toBe("https://javdb.com/actors/abc"); + expect(javdb.resolveFirstPageUrl("https://javdb.com/search?q=abc&page=5&f=all")) + .toBe("https://javdb.com/search?q=abc&f=all"); }); +}); - it("strips JavBus /page/N", () => { - expect(resolveFirstPageUrl("https://www.javbus.com/page/3", "javbus")) +describe("resolveFirstPageUrl (JavBusHostAdapter)", () => { + it("strips /page/N", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/page/3")) .toBe("https://www.javbus.com/"); - expect(resolveFirstPageUrl("https://www.javbus.com/page/3?x=1", "javbus")) + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/page/3?x=1")) .toBe("https://www.javbus.com/?x=1"); }); - it("strips JavBus /star//N and other paged list prefixes", () => { - expect(resolveFirstPageUrl("https://www.javbus.com/star/abc/3", "javbus")) + it("strips /star//N and other paged list prefixes", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/star/abc/3")) .toBe("https://www.javbus.com/star/abc"); - expect(resolveFirstPageUrl("https://www.javbus.com/genre/xyz/2", "javbus")) + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/genre/xyz/2")) .toBe("https://www.javbus.com/genre/xyz"); - expect(resolveFirstPageUrl("https://www.javbus.com/maker/m/4", "javbus")) + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/maker/m/4")) .toBe("https://www.javbus.com/maker/m"); }); + it("strips search/director/studio/label paged routes", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/search/ABC/3")) + .toBe("https://www.javbus.com/search/ABC"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/director/x/3")) + .toBe("https://www.javbus.com/director/x"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/studio/x/3")) + .toBe("https://www.javbus.com/studio/x"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/label/x/3")) + .toBe("https://www.javbus.com/label/x"); + }); + + it("handles site/language prefixed paths such as /en/...", () => { + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/en/search/ABC/3")) + .toBe("https://www.javbus.com/en/search/ABC"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/en/page/3")) + .toBe("https://www.javbus.com/en"); + }); + it("leaves unpaged and unknown URLs untouched", () => { - expect(resolveFirstPageUrl("https://www.javbus.com/star/abc", "javbus")) + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/star/abc")) .toBe("https://www.javbus.com/star/abc"); - expect(resolveFirstPageUrl("https://www.javbus.com/abc-123", "javbus")) + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/abc-123")) .toBe("https://www.javbus.com/abc-123"); - expect(resolveFirstPageUrl("https://example.com/x?page=2", "unknown")) - .toBe("https://example.com/x?page=2"); - expect(resolveFirstPageUrl("not-a-url", "javdb")).toBe("not-a-url"); + expect(javbus.resolveFirstPageUrl("https://www.javbus.com/search/ABC")) + .toBe("https://www.javbus.com/search/ABC"); + expect(javbus.resolveFirstPageUrl("not-a-url")) + .toBe("not-a-url"); }); }); diff --git a/tests/browser/fixtures/javbus-detail.html b/tests/browser/fixtures/javbus-detail.html index 30158b1c..5aa6f4ec 100644 --- a/tests/browser/fixtures/javbus-detail.html +++ b/tests/browser/fixtures/javbus-detail.html @@ -6,6 +6,7 @@

    ABC-123 Browser Fixture

    識別碼: ABC-123

    + - + \ No newline at end of file diff --git a/tests/browser/fixtures/javdb-list.html b/tests/browser/fixtures/javdb-list.html index e4807cee..48eb3c0e 100644 --- a/tests/browser/fixtures/javdb-list.html +++ b/tests/browser/fixtures/javdb-list.html @@ -15,10 +15,12 @@
    2026-08-25
    4.2100人評價
    +
    + - + \ No newline at end of file diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide.png b/tests/browser/screenshots/baseline/JavBus-Detail-dark-desktop-wide.png index 251f8e3c77d93428ed90c708abe3f6ccda3f0426..9a360e360d32de79cafe2105e2d6875ff013785c 100644 GIT binary patch literal 43295 zcmdSARa~206E9j?D3n5x;x5I#xH}Xm?%qOimlOyRUMNtCwP=e&&|<~iTU>${4NlNN z2m}jk+V}hRIT!n4pL28e$<_RxJXvejf6bct&#Xzbj+QDuE*0*B2M_SoUMcB4cz~7r z;K8F0I1lfi{N}@mc<_Mmftr%Mfq&+~^3x!av%EX>{Ne#i>*nKsFd5epDOhxfm7Y&y z^2ih9@jy81a2LKC=AxgT<7Dcem{gC`BTHBHmCJNU-IU$1D%MK!eknbbf5CwH;e*D* zM9*P>XyCVYX~EVfFXR+-c)x9)hMY_u9332tc-ZrG!f$x5-te>E>v8`n`DXO5pd`zwU*5qbXm(hT$JzruIbE{wmDK|<)Gzd{Ke2KHY;NBaNcfwcMl z>d}JNxQH&6?Yy)bG){?nCTZ!u=3V_&Q!V;YrN-TgB5IsaSr?|h_}&X(ubroo{$E5| zLP6zR{?3DwS1nYX(4AA@qklp@n;q-T+Pib7cA3Gpw>zC7Nq*O2dBd)YzW^)J5$e0y z{_9Wv!r@uij@Yxly_n=OgwiB#JnDpkzE!CV^Rac7+)O?T+=!p7I+N%fAnI)yXt1=P zg>y{D{0m=Y8^LTaqMzJa-237)1WQOnDEY9e={u+g!WjO6x515kjxrF}5FGQzo*r1d zb#Ij1Ij1)U>2vt9psxrnP*(&uAt3Ulh8V84;>Y3gq3HtueYL9Nq@jQ2g|C3j;OQQ9 zS?l)w#B0)|x!(WovS&naH6Ww+EqTk=zP>)b2KPJfMPp0PMc&HeY+N1jqG+u92}8MD z@BUHGnd7=2rOKcKE(c5CZM@g1R664OzoyYey6xNCvRYn-U=7R(k zx#Vf}Fp3-jr-i1Uss3HMHQRAv-cdJc!W(NT!^N!it*Pk*JIl2tB}PpoHtr@(-cIgk zHM>5uxXs$U8A&EJjz@|4;K}Ax(&uc_?KV~_TZrrJHp+$L*@R#3HZ`@&LIMFN%{hxB z>6xOodg*7G32Jux&;esyTxqO-O}ZqUBIAps+ttsn2g%B(Y2~t1N#bu2$t1R?;pe{- z8=nrDlb!iD@ZN3Z?@6Zd4RKpubm1)ztxk`F`DWj}&Pkjgennxsq1^pd1aNfoQ$5g!@GYNdt8;KO zi6pn*-ZaNaZ}};Cd1_IozrEGQdBYcF}I&#^)JSN z&)?_xzA-PPN~fo!5m~pbuv2YGy*WWAkg(pKj}{9qa61ddr(a(!Kyti)^2;Fm>$c#U z`nT517S1-V3$amf?IiW5A~8A-%!$6v4OZ?yif4wyIm(an8-Uwu=o# zC2cKNwd7EWRLF4t&&a_E3c733^u}pWSM8u!%7!n*yD#i!Pq0ztb(_!pcX2fy zQQ9pRa=v7dXqI2C@#?KIlJzF9&*3#SHRkT7f;;WEOUXVBYSof2T8fd}{~nCohc<|1 zzRIsD_9U3vgS9@;S6>AMhs_#*6Vxov)XI6QjyJcY&epb@u@c5CC|g%kuqfSH-F}V* z7CWw#nj6ALvu}|iQJ$p~8y;VYa}t@vZPH}Vj?4G@(?*B0Qqfi?l03AIKa;&sg}-vd zy(5PBTcN@xTHQ7Q6x#dj>aokm`wDx^#*#o z`XoHFy=gM_@rekt8-Ds-8}QrHz@4k~z@USO9F%ecJ#}9!b&DO(^u0q*u@zDZeV^rA!+1Vyj=b7%h|AQ~KfVZ#nH|=M$ncKE#pl&)*!$;O*#Tt|o5Qb| zVk>4*t|H%EzMXQSA|6n{`}Ars4`<-U(X}-9M<)a0CQT=$9RS^C6d!3h7HS6?7gFNl8xi;4*3F z6+*|1TD2htg?~lZozxohW~~PVZllLj>K2$v5m~05BC#e#6+!iu-!8FR_9iOdveyeOPe=})5Z_iJ3HuFMK{Kteu62UFL z#)ei673C#A>Qr9#71=oya{4CThagb2RQY*<4BQ@x<_l?i7j0?EAQQZ>r0SD0 zkl!0^X}(}%B6t+yY-A6Jv)s+m+a1V~bW%_Mtb4JZNLo1KtxlYiX?qisjSJ;g9mNi%o&f?Dg$(r^woK4;dhf9wu3KsTSN?vlV9?h}|+Wzpmx{Y7xL9{_@z|3TdT8zC` zrX&}#L^ULp!GNsXyC#h^nQi)b zLUu}h*Ajp95m2YnP-oE4=OkxJ3rrP_1lVZM2v=IWr)&Oxr#;X#Z=`Rf=aeC}vzBQx zK1)t#>Xvai9b1QaS*rG&j8~hNud4FoLl{nf1Inm^&(B2veQd|o)E za9D5IAumhoL8$>KA-?-MUW1o@sV2w3*f zq7j#wty1YM;|pz2$ku@nxEQ#fUQBP^VX7un{z&lU`XNiku;iR}gp*Ax#=j)FsYim^ z)8c&=zL^sb7#|VDM!@u$pq) zGqU@3yR|l@qewW5N&g0XS*T(rX zbSM`mOTZ5XOuvJL{rBP#Ts#sD<5l!6@OTbc9H>D9553XD82uUL~XGS^Z!g+ z@Lv9nu>UvK`hQGr^Zz{CVz|do?(lzRa=Ta$mgV?Y-;3=_VZ?P)*b079O8Wybkm9?% zoF5_HMV6l2W$!-VGW;)dNAlOL!nDF<(oWy$`y9?X#(fmo6)L8~yP%@V)B-I!IM%7? zdWb}`h8y|xeQ#S~E#5>vR)}%J1GW#oFpF(@#w5@je|X}b#XHC>tHyR7|B_OufmXhhG%bCd@j3sHZ9pE6F2KDY~Et~EejBe0d|6s5N)l zo(T1r&MID^!t<9(?BA&U&D|bbToakwzgzCp@QENy-s9u2OLX-fV8`ss^k%u&(Lr>F2>Pm?_ULW!4sLua>VrM)N?h(8V8Qb3-s`mQ^8ezw2aKAu91NpP3)!h`xd3Y}j-pufM)a}V~zvfG`IVl`Ir0R3wfaoCNotTsyKO++j|j0oJ7|@s>4L? zk;E5=9tWi0mKi<6TjI55VBewb_CXqb-tT?f&S9V^vKNsKOfVjlcsNuT6i28|mOh0a zK2PlCr1fgE44oa?H+}Rwg(_)=%p)a-cSfXXeD77>M8-(9M5i`bFw$ik0ld+SLp;4k zC(1e|KEkb7v9JAzQ6aG8q&)3(vqndWVeLP>5csxY%yF74!)|&vOcS8a{5e#X0;sN0 zMUyo_Z2L?^IACc-PR`|HydD#2fcBv#U9l+Pib=)Fn>c=z4^$zTZAVG?>I}>~0_|?< zN6c_z%W!kk#+BcalO>xx#Vt3mpTA!fzPYi|woTf6CK?5C)MORtaV5;i_=SnKYJO9A z%uks|n!&`JZeD~LoPDgzzrX}^3X0Pj+<5A&>{EW2sc9@^*1qyiYF{pMA=@*7)UE5S zM@?YL)@Z}WSK^NnPq_n|UI}{-koanhE;3=JQypjyors+8vJ8FG*JNKIW@fTca2PKg zb(u%yjvq?wen#ofaSfHDfCs!)qL7*(oo7`qqV&C^w3*_@Xx{GGO$z)bxZj*mjX0pH z9}ylt(!V^D7&d%Z+c^N zh-qf^$^k;HJu|mul5c0%J*5E~r^0>V>*ru?$o#=i1+pjPmi-*Z|L}cUrX6PT%x=#= z%E92WQkG?%)RO>eYRkAh0oBd8r)=WhjwHNYp_z0ML|@Z z7@rPRI!4zWy0}&8frv;u3s@4yKT7pgY*zJd-AiwCK;oZhBu;9x`Bt^1Knt5vh+cEs zlr(EXwh}j&aj$~MbRHB5d?W&P(-U^o=5FepGg;Vsa}0b*?BA7e@=y~^R=(kls_h;v zDgI-tyy9k_zT_C4k_N#ulz0dLD9P$`RWo*wjK`1mqo>{xb35;W3Kcv;`2r+jm|M;v zKFf8N*&0gm*y;Oi={`bwpQkVy7wO!w^98>mvp%jp#7KA{E|q82TG<>tw0joNezg5S z87vC(4TH{azK=>vak*yAVhA|Mmg9$cc>Di+RjUS0s>Mia3$=EVwMjmGYCQ}o?hYlv zF~f`_4)|~cp{_Ht3-9%c5@DLOnZlB$D^(-r&ila5uF4j#QyrNO7>MfIKM_)s&sm&$ zH?7*R7rgPbKIwBpt}a_KA+-Ji0IW`%C=+rz3CL)B9HWh!QZj4vCBcnhvIro#oYz1_ zW&2?;AAd{2TgeNfiqJDKxi}_2^8_{m47irYuiT#Qw|V(-Av7p#a4nMN&|j%=2Ja7O z783Wq=>wGfC?}r?|K5yrD|3edd!gfp_?h!rj4+RqM$x(!MIq4yB*&?+vFFxM<@w|m zwKrm2Zx0d1YVgeiEvMK%HLo|AT6WcN?8$g|+M3nBRN?M&_NBo`O4&h5+Qg}A-yIPN zCuMfaTPLbd&#jez_Ta{<_?=Mt<{>U=F5%~!ZX8QUS#nGt!`)iiRHB^R^hNMLo(eY8 zEn!Y0#m!IXM87~(oO zUyGzD>eLNzJ1`0MWL-Pyb_W;eH);4vXDc$E{bdm^rc(gN8JyeDfmdLKsOL zVBvWf;&qS1X@CQ|dJKe+iyqz2T4iZ5+&8|3aP=-H5ARkak-hC{a*Migs^^>MNFnhA z$&5xu$kHjxZ~GvkEXE#xfv7SF4Bx1BZX+37sT25IbKkoO$2Z?mM%XegbM8ZoFwF0F z1`UtjRj(gb7;Rj^-`EeO#87P{#PL@+6#bA})iGuT-6QIp+kXmMG)V6a*V$9-RaMsm z_ijAFl-5d9tP;Y+!T&{sH}`7XkP#nfIhLeZQ55J#X;WJphS50XMsRin$#_lVAS4ah zW3~k#JBQg)$njBd9Kj~_iag8qYGMzWUa_cJ3$UsIMuRs zr+yLL%#s#n|EN+n5lJP4N$TyHmn5pG#+_`%iNOMf?8e?l^@=|DdOA)yHjURbT8agu?`E^yNr-SV=kFA!-j{!d7~$;9*prdQ74_Z}H*08J zggW8ppRAutI@dq)&dW+q{slV6p{MG>|Fi#TWEiS`tEd3!!?>(f)Ue_0djv+D6-l7! zO&+B{aswTPuEXcrhg%9kke!oj<-=cek<2ha>z*Rm+7YksRZK@#5ep)cGSDe3@nuT% z%eIL60NllAP5`o)iG4$R;wM)(D{?>Di!;@U9u{YiVutgaE;$R`yC5U3J*-C2>4&}4 zIGS4dDWb-^P5DBGj+H(8_&mx56P(L>vH6S4!p&`oFH4FWCrU+40C^yk#~+;JTMq$<$J|qr$Ug>TNdN$l#w^m zz4nY^B;PUqqv%tpyJY*&G-F=3a{bi+PPJxh!keyre0H#W=H7|t5swtqB@?6@rv+Uq z>4vA&K5f?{CBTm&3iG78x4FQ!X#eJ1QTyIgAPt;~>RFk?aDcOW1VGp46wzG6?v-LiiS;uFf12T05~O085#d zN>=aCE1bKGpvdqa3((%g+N5BhI2J|S=`cMKr-L}2N!a1b+dZ6+FcwpSq>nz1gU;Oy zshdBk7(e`;QcI8}qwpV40%infHu zr4wZZV)*a#0?1NQ)y%m^r_u)A#uyFChk2iHiuPhewVfjD)%8k-s%C`wsPUR;G|v+I z97%Xsvu1bPR(x3$@BKl(?gnCj6x3K|g%dYQ^m&S@O4}i}fy%ds<@vFwxFR@Y508jU zH{Nd3@)>*J_k_eL?85Gdpsq(ls|@agl+#W^X5f;ky}Hreg#HZ(7G~9E^203LubZL0 zC376u(lGBPA&iN4kjGoat!S!R@u31{_@6`MsYc{kV$}tIYFwOR7QFvNylz3q=TRYc z%pyIB_DsQey0Wqk>m0wbPy$)0u<6Jb4CC+uB`j|-;_RaWTknq5AFa|A``z9E38BeI z3L+v~BSg$SrwACC+4Rmb)4OMZiMHP$gcKpx`bkx#qp6gxMe%*hQa)s)cH;n}7)MAQ z6r~8RRr*?}l9m~H(VkZT-nzvpJ26lwnAknb`$thxKi9E$>TnO=EUus4oh^Stn7pa& znpcf7tY_nWpq58$-#eooD)Eh7JK^btlyp!AR#4(%K?1}&{}TwZfAV3+RhS}09Q256 zVx9q8P`+_$L*T2XSzLF?Tx&{6AD%3+SSyF*Q;G`XZ%m>v4_tx})f(`Ou(`pb`Xf>P?V{yJf(ywb$$X;GR55)y??9@p zAy0pPyoy=&Pa@*qVL`u4LO#hmRd}xC2?+xg4GQ{p5kk!wfv5ovxeunU(x^|Fgp_At z#Q_SYEc1jt;7_seN=duj|IP5-k#GL^uM!&+NV?)914t-cZqxHFEKLb>ult zmNoVUk`ecKs!&}tdF0oT>k&Q)(H>9!RE!9?QOikf9ngel^TEo6UuE&bY4bD^ogUe=jPV3WUgCcKm_p zKGQY+2{8OC{jaVu>&ZR&`TuiE8Fy~4+iZ{|`S;}q7QViJL4NSy|Kyv^|IO^bB`SA( z!`<`prr1LR|5*Zw&MmSfvtF~9e(!(^-BHAvaDUSNE*pkGK5Tgur&W9+Db3qH<_uwP ztTz+??fL4;F-c^V;Nm94FG&*K;5UHPoVdbsurSktFOcQ$3>I{M2&Rh1M}Dke1^#^Mp1t15a{&pX_&O2-V^_2HtH9keR2^PGj|`3 zVwo`ZBf7@^qx+z@8#~5u{)s;wxub6IFeG7a=iJ{?PtK(V*|29mONco=E5YWymW zQutQ#Y*a|P!Ci9Z`a{tlIFExmLJ6gIm@z;)w$oN0`Y7c)p&QRPg#%b}paqi5SrBSX zBVDlWLtN{m;l8FAJtp!jJGVu`QMQm2oib`Yzg>^f|lGHD}={iJA3qJ;fM_3Q% zsSo^`+w>TIzxPOfCMm2LQo*i51-MW6JT=aqX~vGSJ8ymXrSFjzhEOESVukq$tKpyL zvQD}dsr{{P`3C$6bQ8)-=bs#d6QKL&YKhj=NUVC+IFV@sc?cDg8*t5hc4n*To^m=a z*4g7+Q0*PyuXP^AJGV~8zTn(z^OA$WPlp0DKA1XsUrL4OLPgdfdjM8jf6~XgcQpVIz42MDk>jr{_`5oKTgwjs!ot9DJ$)*UV73BoX|tZx@* zRt?+(Gc1PgZrEI;5qCn5_2E|eS~@K?V$=s?MqEUaz=+&&Y6wh8ai5H4;eBh#z@q*X z16JI&N!i*+6EnqUDGd2|N+o&k=H(Sne=m0`c`Y)bp*zLqk5kVf6tV5G6{&N3vE;wQ zmB2HN&PRC@y8wx9avV&>s(d^<@9p(Gp1-#4>veDI|IEJmwD0B&*X`^#+#mreH|+K2 zk=xoT=k%K{zW8kh=*GDU+Obk*SUthI2|-0uH6pxKd}6D^$!HY1=#RO+C^0Z#^SaHE zB+sos-Q)58!9Z~qW{JpI)?lf#kV_2GDxRwnM!sanXTJ%_5oM=PLMtEg5#tRclWdGo z^zEcjWvM%S#pzr9tB~-0B{?L_swa$hfuC^_I-|yH> z>OVT=;@)ES+AM*7+2o9YX@+NsZl1w`pA09hI4fE0%V|B2E z5Zg9~lEa1U#*$fc7*sx1^iam#Q#^@IRYc1RZOw4mN&5!a?!QTnCG9<>hcv{f>o_}K z|4;#whVd3Ud^|y3kap)Kg%b-iGN(&nq?LQsluxZh{ql~%4hP3c8pIk{(bv> zY2^(+>Ka5%HFe0;< z9ct|Cc@_3ppHDv{q8`|;cI)ucto>-=@!%SQ+U-i?OV!`)<7Ijnf5gDhtR(`>F!%h* zEusnGYu1|nph7-FrGTN@wJ~J|tlxV|jOYKzn=Vh66ZOv<2LJef*n5P`vUI1P%5-+l zWO;uknp@<~wEFfE7CAmKO+iIoN1J!cAs@%l^UmE#U=7to-R^L%=Y(DWW*Hr0p}X(v z4See>$xtosOPD(K%CAxap;t!5R)y%@%Qa#m~irG3K6f_kMmQT*K zpTO|Qz-tC+gq?kJF9M|VtT=amm)(aVW0e)iVA;$bw@O9-q1UUH0eyVkFpNQJ$k55h z`#Ny-%dij+@bcl4Jtp8cGe6lUANsJxh3QoJSJV6y7VYW(0&YoipYRsoW$2p2i5_Bf z&Juop;pd2hUpOCTZd>~a7-!Lsx)L(e5F^8hH1l*!CS>fH{HD(YJHrlt+|3q%Hq$)@ zVuepo_)4O~jH)o%WiDq9)!_*%?LYM~6q-PvGU;LF%0v?F^3%ZOy!VuAty`~p51(Yx zh4*s7Mk66jC1&9HcPB$c@l$Y89`z$av##PeSm8dvUJ&nc=4MFc@y%`SOF&Hj^J&NW zAsTKBYg_Q}?i|I|%;U{DT;bPW=qH_|c#Yc&1imRHS;!$2-PMSFMQXDd|6bPQ?lZ5# zQtwk9%6A6)r_-VVv}2)txrSF!7g!+vX&t6j5ASQ?$F&qKq{%)HYy#0#B)`m}uQvS0~I#jBdkF)~ylV)jLQfeb}=eEjEVfnyl`Gn0WE@|rz z5Hn}$Hi--t^>;RZ>1EfJ%F}pvd1h>KR+pcpw+vUu$($iobIcS>6)H)*6hg1g65j|p z*8h`h4KZViy{};&dQLY#LL^F@Oi#0@FGi`@?U}?`35krWZ zB}eOxdRG%*R|9P49`o_KJhPbxI^x7d35yZHkoXKkB4{l zL4v2`kAoeiOcMIhOT(ax(+oZ@5?-=P_9Z`*qL2NUAWZVk%Ci%(E(kLHrQ77V3HF?m z7Nk#?wubJ4A;Mnnz8uh_wuSe(?uX=t7$LGD4hRp=xEpf|uGh_ZcLVae zu)&Vu3Dq>^dnT3)LZ5Bi?cC8w`0I525)l$Ut9Hm%>)1{|6>J7vKb2;awnCJ~3>M67;_drP)dx#7-xRlM1>Fj(t}*Gy zE12bERIafjmB@j}iIfSdVo8&`1kh)93#RMygD)|Sf*K*mC$egyykDw@QNzC$zp=>P z`19=cvD6d?HCnV~nIbk4W_7~bm4=4j5C7sSA=^=zo4!2#zA3l7W1W+$;y2M8+^y}G zb@M%6KP#qjQ1z{->e~AKyt{+6r1)8;U%`-33&@1(Jz32IN@}RzB8Ipg@=`YY4|m?S zY}hq~7+w_{mSI`E^gcP_mDFx8*(~GXTJhKIVqqqS-(w?TW#50!#!!tmEof~$umDkj&Yl(?5sEW?2J^yb2 z`i^1Urh?6N^mS|1Q_i2w6Dff0HqM1$g)$<~U06L;^Jht`B1e?k{UD?gxNMSEkzj}= zU>gR$k1{&#ry{&ve#QZ-a{W&aco*4Li5IQCN;a@MjCSnY0Q1&f+4}9S+NGfy07h#)yeDg31Rew&^A_ zWlP$iZH=S0h@h1g(T>(rGDN`s%Ei&M%&N~^Vz_BmTixFB5&s#se_#3`Z#DSrD4tyG zeFxU@vKI-k1`G&-l+s~D?Foz1+<)7Wo*qyBL-J!FUvpADfR=C7vF2&hPiN=xDc{<` zZr!F$XBWfy&{Q9Aj3hi@Yc2h(&~HVai*g3>a+MwV&b`Vybm%{g3J?BQUC^=jJ#KP4 zFySX?U-T%|;1QoJmFJ%n45ol1<~gyOl+V0@JQR9{i@8S&u%pWZRl#mmD&D}Pg{7N8 zmX|q0+9FRPk!4dJwKM3AGKA$y=%!bya7&47km%zLe8FqbcmQH~?r>Hu++m62=H^#; zL}1uxf*IbU$dC5neaTdQ{r)~@ksh;fxJ$X(&lxjE!YM)Y460 zDJoF!ez;*5MTNB(H+OSzYn4(3zb3~h@LGmkedfG+VmLf+lFFT`i zWZO#QLO7gvVVf!~Sk=nIXMacR`|KF{4frfFDy;xTK5k}v*kRbY;2E?;;Bc2;W$JB2 za*7zkqz*4 zjD<-i-pyb0sLFr*aYk?B5?CpLx^Y6w7Ou$#d+NmczolN6Zrk6Q&pCuk2F*@VM`d^V zOJCn2yN6w}oSqil2=&_&jxCkC?Vr!6;JqH#Y=Vr!Ud+u&t^hAh7ML3?WJ4u*wAS8r zDckd(XJ-clx8$|=qp>xflGD6=O#|3)Ye>BYvfZ`#Axf!bn<`NRn|F&S*~|ILLz(-y znM>=balqf*9N_a~Ty8%!64)+JPRqBz?cR9m8!;lpe;yD0`Kx}3#$RKd&)un6;Ki-O zo$BS?3I26z=g~Wny3?17?p9(ir8}}zwTbHJqM+z}6k2m9DS`z)l>4A^M*#G$=P9ip91%n`h*HT3C~_K zk^Pa(%J}2K>(X9(R^e|atJYZ~Zp+!f2d_W3Mvb9^`=5&Pf|@yRX6U8edwM(LO-0%0N`BWug^|3&jd-*LEpghFmy1Rq3+LprK?ke52@s#e5t*y0Mu>O+YPz2T z=*O=A;uh5MbWxv8%fFgPB~KDTCs|kc<9ma+*YTpCkscw5kBJ?%6C_+YpR`86S5XlB44lj2kxd& zdoEswzjf;Xj(4?OGkX(Fw(wNG{q)YE_R1k?VZ(mWyF-FA#jCv@)HiHAsw`^2O;$o$ zm83BC%+btKzs8$oZ<0z0!Pw&?=(Sh38ro7M17W!%u6MB&<#Duf9cS^~X1$3z1iAX- zDSo7dI?EO;ygy7Ep4PSuZUc6g!0>Bz%qt$5T*}@u8WL*~9t;|C&F7`C=y*+XfoO8dOTlhY0%_aH|;lpPZ#G0xd{_plD zp1Z*hraY!==TQ2$h#9m~FE%Lg4$^;n)8bMo(Y&8fvD)cD{ne|D)qfBk53GkSE-oNdxPE}%9qM?Qz$$M`9oZjqaiYni4qbMP?+KxlhyklDZPQdyuy&a`))w@h9Cv^N3w1YW|5E5Z<;Oj=u%!)s$>h^^o?md#i z2h@$)?X=h`mg!&WsK3-yn_pPQw&V&aHsHR(d#U+~>^*30W!>muO*Mk=5?l%8J(s=7 z<;gEOx|?T>rJ)sY3hmn`U5`|i`s_~O^mH~OR+g%2xn=@wSZwb^aJ29n`AhiATtcIk zcT@UfimN&y>S!1^fzVeC%zTu9C|_hUbgSh{NXSgFYv1@_8&hMind4uvZk`*lMxZ9+c5qZ;#aFA}~rA_0df!4gA`*PnM_am*NHDED57@&~svL6qc9jx!@ zAh-DTrC+*@0BCjTiBiO*#tzO}<7Woo%#0G8c~y@5O8wD&c?2sb$nepa09viUB+q}Ou5UB% z5Uu@NCR6VX%bQC>?iTV1sy?BWt&rl4$0KPU)hML<8Y(>Dx02XJDALfs=amRub*F#l zBST!7=9F}W-!Z!P`#`fr*zBUoB5-&>*Q+1pE(S#S7l{fsKoC1z20gO@_j2Xh%D^Y2<7L`4 z<8qAC=-o+WV58tjY2?_MYiPN^Rsok3(#pi)92I?il_O0NV4H=mHwwmxKk$|E(PQ2N*!H1X1ct5$u{Ev5 zwW=CQ$Bg-MhxsY(N8{>^PcS_MBM^^)? zRkG;QgFbrYWs2M3{eZ)WNB~>J(ddqZy`OS_pAsw>`x#j~RfnT1w5EX*>8QE8kS^_A z;gNi2eJacDJVonxN*_{piR4l2(YE^fxn*bL4mIC9^d;DDG+%nINL_DcfgqKhI-fkK zC;qVP)Ayw?w~x8+=qpdBhc>aK!s-|8X~Mdt5Tbq~1LCdY@jm19E5QN!r}*%-U=Qhr zdExbiwQ%L@Y{kIjYhSNY-THkIZrNGCg{O^{&?4iQor@yd_bwJ@&W`o@ZWmRjblC+l z)J){yabx37k0>@>Ed-N76?D((s);cvM4DTRWX!pHO}wUo9RzY@6mWiJG~ExUsVt|S zH6EQY3;r3Zrr|;D^B#q}`+H&DMMMO8JmY?Ku(h*)l|Y?NaVTE1p#vK$`H4|Exg$FO z4>a20F75D6u*^x9F}U3HHX@@-oQf+037baaz1aC8{odz$ru#+wR@HUysiD_erAhtu z?M>NPVb4rw&}}Ls57#`hJP78p6j|~`wsR|fus7ri$seG>dE2&q7uYCs62EA#`3#%T zoAvA*0mv?+Z{8?g@4Wlbq@Y$jTNP;2?moZ=LY(d|93-KBcPHF6RXdg~vweNewj_4g zo#!pKCEbSFsP}FmN~!1fn54*%_oSO&t1DFDGH>$Q>4zWv?&vSfv^SkYZyNV*yswnq zUcf;xS7%4c(^z; zEJ_0aMug5022agA%74bCDU3H8()bg_DanLPNQf5{yGDI#U!frk%C3>XK8uR!e$(_m z=ym2QdhZvaQC$celH{!#)SGkXSz}gri@e2r8*X_FM1^>7La%%4?JRRLh~2eQZqaia z*qQQkYUH~rI~3UG&F(?d6N>!{m^3?fgx3TK3)Yqnvdh$5CXE5m>!_brz1a3pCOY(H zSA#Ud)(qVh(zrylvGl|5G^G94s&w;|T8mdhiNDVgg{b_M2vihsP8MYAaJ+xt3CuvXOlZm|~L&hNUGJfocLwkh{jQFxxh- z&wcwYT0gaDe0gNq@1TMxj=2F01=Hr2bgGL2Vs+5rnpoeamF3+8#1p+NmrHeHgyn>2 zw&MkwO=hA%bKnS&Hdr4DKm+#Qp%5{Ha=!AH6-VyHPiDs+DzEXRMC_%^7^>bhISwo>B zD>QoP_k!QgpE-BOJEn#nBhv1}l#Rz@?R?`qjFBWneTn^Z=}Jpl$ib-sl@yNLMs;Y;||xD{xtmA<`Gm6&N=r6~J* zBh2)Gywnb{@a>X%w{+Z8)N4NL zX0j~0m*%H36VhBY$)%J)?X&pR(*vw;4*SlzWCen+uo?Xz5+ z$C07v=AHcFs*!E-KAu{bG)*ts=p5Rie2#HMkM-#*JeNEuNN6eDd~t@V#42vQUWn5| z#dwIwz}>mjBv8p7Q{$qTc9yXh6t5dC(*PN5xTr6(WbVsy)+z8Y{vYF-LwA6a(tM`` zdryxnR6*?+hcR+fvcBP;d{dl-jccuO!Kltp7Wh`(`;F%LtXeN!L#o$*glbKpI-DRp zia`~T@kCX7Jl1l%s~km)eg!NmzBU{C*Nt$uTf0)~-*@MCQ=%62S|hC@y3I^9B|N2P z{#fH`RqhwL2aEvvRQ@5yz&bVC{VJdT)&eMr2=H(kuDUDKq7!BH(mjM|e9>nvQ<>b0 zv@%&b@bBIq%6^WvKh{FG$m$-MoVVyt?|ZW%TVxjQlsnpwHz^fAJx(xAa`&a|=t|Yp zH17drAW;M_&k_)BH$SV7Xre=l#uRZX_9f)iu{3w1IArlrgSFW%*4^XrIPJRCYjO6H8%Ph zXg$JzdF2u`Ey#@7ePnvgw`2WX+He_U!8zCg4{bl{iY&ghRqUaa&4ft@z5Xa+^`qHy z;6dJT!7$Gfp^R2g>cp=!7IQeEvaf; zA98on-RnWCd)nBn+#lgS4Dnn2Dv6C7pVYIQ$4aU$yS}kFYdnwTW8n<{Dj2E254%cG z>*N`p{QmGh=Dy#!mK-yy>UHwFz&AEcc5VnVd3t`R`;fyn_Uw0mmB`%ADX5-FmPzCU z894F#Zfr3`y!$ukRa52u(m4pqBW+Jz%kH7#C}`2IWj{xC9eFFFnlbuV`%s{>(d`i- z>8|kl9z5Z*_M4Mg$$SN@^+x)V-!(J5N=j$+Gyr?cWx^ET4nEkt@ynM3^V_<&i>75~ zGc$`Bz7vmn`Uk#>?Y2xe8fmP3VTx*b@VUb^%3wytA^U`QXkX>4Ez^x*Jz`7$ zpU_8)_oo`B&`U_M0q>AG;$(4%zwQQj$vTFxBW981NX3kh+I9gq&&e6YoUvo(giv9( z`=;U?S1?lt$DQloAjZ=wxSyx$?oI(UdB0jeqrA|QE7;nXaKFbJf!JEfVux$MZ}K!j z;WxuQffhJo=k$E;FyVvEJioH*Tf{STmw|2FFI&Ht8b7h3UmZ$Hos|bl;))gQq5rvH z46Y&nX?1$Jl5h*G8Dn`B_{?KKbbH&>Mbv=q_>)3C`lnh>(24WN@k;7H6C9=aKH8&$ zDj0{+RPX2{Vda6^esVajm&Q|-n^wIeJttMc{lR(kKwwh>MQcEzJypvE=Z|pO=W3&vr1aps`mGF6kgLl zZ3$lGhU6@;jAtTGDBJCKv+l;#cS@rT@K`Msl2ttYwee-B^6=`2T)nZg-0wPLGAD7S z&e|0@M%d1^v6bRzJ!k`eco~<`9!s=w8O??WFD&i+=;SbS`ds5~=XPm1(F};|M3)t- zS6xp?>o&8B8=e48UylZwbg=UMR>9YHTktD5f{+Im+|JF%S3uk0U#C2~z5=7XHfrfF zk8ak=jhJzo;%TeS_H499nHVoE5?`(dG+DJfpgV>n^4#~&>;DI7?-kZm*DY-8V?jkg zK{|qn^d=yLj)H)6=^X?Fr1z50d_+XL5_*gD-g`+=`TeduVwhqtQ!)x57*>3mq|0*GvT5@n3^>aGl!k0=Lc7Q)u4)S#Aypvq3?^>TEo~cnS4)%&VCD7jfjoj^G5e=i(Qc z(4TfPjUspdpj;Be_!e)Ai#=pWI*oZVWWp1{j5KkfU;l>RJTdQk=p^>fH+E?WukTsn z6-aa(-tHNn&FA@GiwZBLyGvtbBiJor!SE7tVY^+KvtkWbrRzHE#+S0T>~^9Aadpgk zdo+#3FK0BmW$y3V)4wDE?Rf~{q4B9{P{&KpXfrKr3TW76s`dz-zEfK_H z`mu(pl+)9LkYLKa<@~NF6{+eA3AGW8&vc|NI^QtFUA8$-yxi%1d_SMwn!Xi_gj|>Y zxmX6!1{m$WWMWi?k-K-p{k)1sDY1K~M&;7iV;9zjtOLZ=Y&y2%^2}DWh;y zTc*67%g7fd&#Ois1>!eR{QbZz_t|K9gP!;6j2$C)Ym#Do)#mt%V6ZbsiUT zO5c`!M-#qIc2-q@A6{K|2Qg;Hsp^>NarH8548_Q&9#XIt>$mW6$oH-C^LxDv_s0FR zM6}|!W>pAbI9pt1yBHG5W)0y?Ow%^Blenmf${Jk7px(bY0+l~&_>9FfP(37D_)9g6K09!sVDAs_npd$~PuhDDQp z<>}gLsRc#^6T2%b;sKi#@mC0ZF|XN5t|Q>+n4{G$Ljf~}$@TuJcPsl(85pT3LgSvE zG?h5;cK*t|*KPu>GoB$`_*8Z2m6o*WT@xMhmXy$r;|z4OWb^mpB_E*nt)DGqQNaA=)+pr`{ly*H4e{>z;6zoCXHK* zULKTnhMopimm)PXSb1MPMK;HHsdUQ4j5&-mt#5>43nnG|mLC0;y5%S1GOVoFXYHW;okU#laCNT&Y8lT$c=cGT*>2Na7DWp0q*xl zt+iO(v;MUTz4`|%u}~(`qGacj=8Ghc>-gadt%X}#3>&NI8k@4dbb0RH7#9jZ87W(h z0xPbI&o1Z-sNH?j>dN5Ob|9pm=?B8TnMuCHqlkMwIKk$``sYM$c!>o}!IijjP+9c; zal=|i_lFssOPlJT>Y1m=iOZ^P*&xl#s!s^Qls1Uo2TS$p^VY4Lw z-BiBc3~iqS9Jeib%SCZedSHj$=UUFzD(mi|*1DO=w_*@;lnUa*Q7>ks@rn(j0{eD) z)vRE_LxD0;$HK6%Bx1Lb&mYz>%*Gaq@91x_u;Jp`uMRL+XWC}9#I z`{NYROqb~vBN}P7I+EeMCT*m9c8q9i3Yc|Uv21AE`o=yc;rtF{fy@ohUV6a4sTa)} z>X+yF38#(w;T7(RWLbvw+iN^c%*hxB6dA6VH;}Ld3%@GQso0CC31idXAWHY(^ZB3c z?$~SbA*Qo$B@>;jV1%)>+(3y*^GO0Exe1g}trmZ`r-dQkxj5>4&V4bx^CH&Dw~BBO zaID~Wz!zpa=GQd+wP!JWDLQ`Xj*sod>!}=Q5LJKt$evp&_}7N5;_UeJ8q}>)-9#9I zzuVb9wn3qMx2hTw>Ln{7R~#TbA@PErcP+a+NU1jBjR7@Ts*;uFI6%shQfkn-kv&o2 zOyZ0Msml@e^+GFd-{6!&x4P%Mod?&;SD;27S;v+*Xg-K}{{oQxUWiCWwZn#$wfg`e zjO{)I!}(1jEzUE?-(K`9RLe{}qILYV*fymU)EV;z@bdcN&aJOHO`Yc{&=qGdt(Cr1 zv(OK1hs^XL*Xb7G`}6>Nn>vv&oXo){a^c$_#>7X}LOWreWpY3CR4``kuO-3AK!Zv!c z5(f`i6F)3D4+-6P@kKM;@_8hZ*RM1oW`&Hhod*b~&8`}PO-REgV;qf}<=KoNF zLr3mG-=o|o51{TV1-LxiZljkw+#ed)U->V`30G}NmDW^&`(T#zI?gnb$auB%=yCQ3 zIlNBfSdAmM|w)1kbH1NidH5}b+hj$j!fu6Z|8bXHFXl! zJT1m3Xvi2w(afir-R4b;H1$=ZOfU?g5+Vm^x4rb!?WzTxel_Q^bQG}`v(-barOvU2 zhFv3@9*Qu(*qgr@RILW=%=##zeDC;HkV5FW$wq3sl4Z)+IrW@rhu?*CB!f?UmGo|{ zB)zAX6Alv}OP6JRNWgeBDa%W4_nB+WgyH8>D6V&{8;|n5V~i@vfg{|c5< zM*7ZBU9ec3w$3?@?Fa2*49Fxyz92^5GceM~wn%n=jbiS@#K%vYg3Q+i&DP>$7Xt1zLfB-b-Wv!R z$~1=bQ9YfhL1icVWn0=MJZ@vCib^d1shA1+^{1fy1Y{S+UUTsF^>{*qdLEXT!AVGVUn_5T@sgBLS65n^;@(?v>G7c@KU< z&+YCy9Z;!r-%V5PCmW8zKh6(7pUIHFb=whI#|v?PXj1ifi0Ur73cwi_0$NRQSBI4e zfXZgwN~piKd?)=H6*i~^bM5VHGa3Q?#l>y~^G4aI&du~Erq|VJGRaNV-cvc>?6?BkQ%U?%^_iz1r$!#;@(YHWs0B(?0pkY!ha`=5z zOzuV57yI3Cg$MJ$L2j4VX*oUvSiWYrf zU*`jV?Wa9DCm3WpFQHfSI97iv7bd_8tqWFV4T|D|JkK!hjaX7nu|7x+vhkLD+Axf8 z@%Z5E-NqlV_+tW&Dq17x!lE1qCO>yY(o6bKdoH_SiQ*%SM_Y8@Tt_3hV69w~mAp;#};%;;YYd@f7t@Nv0E zrb8mveeUi*@ne5k%H-A#eGCx;3&0)FJh0AHr?@?e=?n^DKC`NZF0Sw+3Xs->t(@P6 zfEPtPN8V>q3SgqJv_kIGp47Cl2CdLk&0*SH9sImzxV~0aQb5MGKuhvkS^rwQ+H+pU zJN9To>S^b)b^A0yYaA% zzBLg?+1{+QnK}H{+T^XAT=bjAZl%D5Qb*#e$AYDumusld3GdUfT&Xn$+i#=SA53^7^XEF)U=ii1U1I%53Gyh*Yz{fRhv3bijB|Kbh8(F^5A4 zScoXI(RdZF24N?584?kMd?AY)+IKB9?`F>)_=V~F6g2#qL+&iM zhoMe`4)&K!O{~yg;e7YoT3UJzD8@u16@)x@3oB*hy_h`In8kHHbAFkHcm>`PdQ*Z3 zzGMrlEO&q8Z;W&AS;?$K}b-f}6|dD!Xf zT4vJ7z6`muv!wvS6Z+-mmZ_h*P2^zH=0Y}4?a1Q&{Z*KV|K_s^N|oVfgx%Ghg*S5c z>j)wG?msVxesCiXVn@)`TS=E@cYkWntkIxJ!U*|w?Oe=#&}cf+!yS42G)M6K&6;&w z^~~N-b0G63QVm4db@AnfeKNU-^6laaHggylKISBj5zlDCGEZ|vB-jAvYRB#`2W7p}hU0r%|8<<#WOS#g%9c`4-r0kI<7KqwKx%S$0Q zl$k={4BX6o9gYZCuc^N->Es={tQTn;@^&(AtyZrlP6XypN_TSyS|W55J_WitJ`3)W zJpabrpVoD2>iXO|hYy7I7=pPllT_c@BsK->4RSr3g|T`z~{{w>Q_Y<6S>?YMP5awFiFpvDW- zsD>Vj@dd!~a^Q%B@7t~)>~zR$C~=uDBe^LOWWN6wBG8fxd`GEEDld$)K1jZN?!GM8 zY)QZ0{rr+2V8lXiTVGv&Ke7}5cZLsXc;B>hXml(ZeI(W5cCbdsyg1T$n&IBq+nY|) zw}K}?s2bhOh|8yuE2Ja`(^`oM22vI2u%e;4+K{M_8JRSD?mY@bnIOt&{*Ha@%tSam zeS0^k&?;yt`?}|Q%F<)6IU;E2yi*>-lnhj=_lN1$1QA>6PgZ2O+JG$cr+&I8PkEQ2s{1ltiWUJ3{%@x5llc=}vV->*tFEy% zJ!w9|Nk71$=9VZ1-FuNWSAUea<-~m_Tjxp=2AO`sB*K;!yBA88?53BZifJrx829B| z4Z`SqT8v>=Z}I)R>38&2+*zazcFh{-+iZ*oSc>? zoZY{AjRpLx|1*+P9l|1L@YH<|?aAqiuo5hZ+aHS?`~K=a1$`_0dr^XT;QE8n*UKls z#=n)8iuBKc9eE(Kw8P(d5Xytq2_gR{WBukqu0(+C`u9)uS!VA*;q&Rm(@&+g6BsAk zjWZ)c8gVX6cg%_G>N^}BM}tEiu~8gCmesds*zU|-E2;Ltl*oKh4yx+7)aa*URvcGW z(=5ds`3Y4CbfjlfZ8mKaTEJVB_5;LU`MKxNHv!?FtBPX^m{$@o!R)@kVqZv-6bPRNQgjqp4>5t4Zxb=G@}9+}ZiG;qndxd;fAh6oE$e-P`>P z9Qa-3fAcKyk(=7w!tGxal{b=O;^SyNqq3WS1ogiqkqTqx1GOU1I~KM(f}0HSU4_My zirdS(>tjKmV?m*>th`mafe+I2JsC+%NX3Wu@>eS`88b{jPX9cy8fx^L(;uM-lPFZ( z+%%pAl4CQTWIVC*CVf*<`P6L-TRy>$V{t;q9b`O0b?wdmsDDU5#u737Ie*;8=}U-I z%l?1x7T!4vj#X+I4|eMy@tgge0SHB|)s}~(EeA_U43BnU#7OHzZ#L!zXRq?TmY%zi z>K|n1TRaxt;kX<}Z@PHJfWpVG!Y_a6EBr$_lNq>^4Z1~mFU(%wiw-ZVlxx!*R@a zEST!IN14U^UHgaMs$L`}bai&}Joa=FxKyA*fb<&aL@yolF$fz5CU6}3}wObPb9 zYOwJ&Xnj?s#mLb7nj{wZZ$^mNDYYIXO}g<%oDK6S5azCj+T5xqas#X+XL5=^0VVHJ$lZLv35 zz89~U;QL0E&5}h>vh7x-k&MmIlTNsCo16K|cPYVM1BF-hI1~}$2od+KBKyIzL;SdY zL_&{0?zodUvFB_N*4mJ(i^rUR+0HoMy01-}}E&ITdW=F;NRB)`kpyj(s=H)rS|0X^BsVSja z2ljdMd%h>{fU1oz`p$^_uq4!e}w|q$KON+;QuO!ehbT-RU>VMh(sOAyl+B%0EQ@K{|6mpl=FR$ z{_$o|;rh??&u-QG^x9QDp_hM_$S|o-CEw2u=w2FuNZ=rAR^ls3ESW+^ip2U7OGH(a z&&JTz@lG({>{#F!eqpt0dsD#2laS;28{TRd4JQ1SWW;1CSy zMiceFO$&DaKXnYGsvnR4?{b8jZl)te1wA+OCsju}h*r9^%uSkueF__vP%jQ>t27M}Zw zhjzku&hP$n^QN2AfB6$;_VV-Wh&!FEgY6*}+>rUvAqfjg>1=sX>Fu!V1AtsE3usj+ z@_hcOg0OngttXV6o_G9kUBp?LdpC9;uF&YnvcD!+h1i|@3|m)SZU4X*9%FEWm1Al` zbgiD7&%NiF{gaL-oL}uBnL`nd|6^iUDRnhZR{B5G3u8C|!&D`z<*gdl_Kh9oLEq0^ zxwor+xm#Z%-N|m=$bL&g8Y^&@5V#x>4T$8s<7;N86oUO%$@pA{LHgr+vY3#XW3U43 za_DDwb)Y-g}FzRyW4qF4@u9nWV?Ap|?|9$+ZK+F8QK*2paj=ZF92lM~8 z%wZ|$%&^ck^DFBwvJ|BRxik4eLf3G06Xp|KY6sp|{DzE&MSbs(VHtYZF403$D#pyKlNN!Ou!H@gI zY5ZT-%g0`KwG;SDmgqzKNf)H$8L9X(2zSADrk42Gqjx8H2=nw4YJxz<7h+XCe#_T_ za!6|SXehUz6$I=rPhj;xO}GOqWTJG!DbIc8Ws{ntN72Jz;`?>p=yYLKfK{I3D5 z@%6LuP2WaL$<)?9I~eb0L5Wscsb&Ek=B6qLS-1xTCHN29t*8Yv`TimceVg^~)W-km z_$*14R7tP@Pm*|i>$}JxWBT5ph;4b$Rb!hUuk|7*-Rj%7yAD20Y7XrYbw_HclFqqL^ zW@MW9;RtwKTEJsA|I|_I9W2(-aqd~C>T!+?YOOS#(6+LaH@d_ohbWX{>)B*1mv9xj z#&sPsyrHOI3{Ys?taWl4h4|XyM^BAmn9g$6QCBE-cC$k&p$U`z2rT8ZFd13jjm)35 zsW-ygt)SBx*UqB|-l=#ypS59qINy5Oh$?a{j8Hl_O4ai)^F`QGI29o?86eOhYHw%M z(TG3WsZUG89A2#_q;65jVjk1rwgj<(K7ZpYa|l z{=@Qp$c}f{&-vXk$R(f2pyjzIH!Pe+^1j5p)6BWX4=p(Li5)kk3c!dz#gpkJQY-O6 zw(Ch2E^79n*G*=*=8rcbw?68m$+~rw1eSVE$R%vC8AhwHq{*Gb$Q(9{=5OZ~opsZ~ zp+7pyjS{Qp3UzW6Jp8ruEXs2BC%+iN1uXTC?^VCMc51X8yfpcv-*yNyMI~X<2EX*q zZ*~lVRqxjpy3D4^fTmdnNp;;fo%Gj-Sq@HP5=3Ph6j>yJgZ$)mu3w;+gE;z4v2OKM4?Y#t(Q^6|y^~V4PlE2^>@%+ixI%-;l0=}l10rc$Wgn6o6~RGPuKa%Fqo5-_Iasw~ps7p>Fqf6KaUERu$)r~QP$+~_L zB4#*=?P|1_zrH9=XkM;Ml?_2=HCmon@?Q)OWP)_YLh`BE!d$VA{#)PxZ^hwoL&b?8 ze00 zcDtQLIAna*@!W+OWGPRisC|l&;Hm1if?o|>5*z>}7*ZGedvY8;n6`P?V6rvXw|N-b z)3{|UyLRucM@%fV(%9D;y4Es>5e_<1U~;}a+6osb^>n~k@Ljz)W0fnixVfj0|#~Uuqh6KY)Z5hP&5fW${Y?ea(V2HMO zGix*L%MgDy?sm974MhOuw59#5LF-p>WT5fWv-@)ES(2T<@R=Ov#aGiZ;kI0w>AcMv@yS7YNebNZ zgtp81FL|-5^xM70PXvbPi^j%qM^kxzgOh%ojfwf2y^)T|3ZBH<<=q(u*~EF0!GPM` zs>RzXQ9Q&}+f3O;YZx(vRVl(-)!L7c+#)%_OIB@M$WRBn=t~U6#2tsXWTKjSLMQ`G=qyMe1hXAB1l2(4zCNjEr5aO7R$53{_SJUkG5(P*lSE@diN zqnwko=zi)YA?D1}c^RqP&LZgF&MC#Pk~vW+0scX=VjQQ`GL}rV9cznLcsECT^30{?6=WgqRFc&NL@?t zV?S(vW{fq&2)7&}7Ao!6w`{C_h}KD6FRU-t#_gS%!jmX*M{VmA{5zUywVRcB>lDLb zqWeXAo#w3$N+lFK{T>2sV$0j&4$G+7LE&2bSiM=z5PcOY6yz6(?G%Ri)T7(^mgfUU zQAOIJf%bp;MqOA(4`p6@tPLML=w8AeRvZpb#}Y|P5!+gA997m|c$jc!M}W}vB=p2)brqvsmjmN|5PfIn-g z&f?j4GGfhEZsecjAWp#fpyuY9G)bHE;OPfBqu_+XV0*O1j#8Sq+m(-tV!!YCdTDpX zj@itpPbv}m4tV^nIdwVPi%~|r_r*?MG15M|L%kf3@to!=)WZxlP5k7sM#}k1QXg>{ zVs^7?VXXJo-(!*pNkhL!Qy14ma4!sMb^LV zhD;ez?nvlqX!*8qjtC*ZBNCBwkb=lg7jZw1PEd+G4*=$#HjXY?%i^G}PbIJc9#6hT z=dD&27e~re5Bf<+ZJ#F5J~6ys>m=o5gmsN(D?SV53-tHi?eKu56Wb4m^`ry18AD|> zHJkQEKu3=cYy(*wssq)`2q=S7Ba!K@%uf$6ZuQD4%an2z7sd+q2! zovQoUmM$q{BOS322KvAD>@!K5)w}}93a4_0g~|8D=oG{`dv!0*>@*Ojjh`;^QHFgx zV?ifJJ3HT`%O!{AgtI$8BvS|XZ_A!z#L30%;8wW|;1n4FqY{MK52DQ{Xx=m6m?o&e z(nBB(W0ekVTdf_m44B7R~+ek&(8V+7fGPw-3sOcAN+H*3-H6>Ma z4AS_}xCs{WaG%f=Yvzbj4~A45p{;op11xViXbQRED^gB?<3oW(HYnySk~Z2_;zM54>yO(Ucx@d1013Vd=Z zXYwbb!H53XfSo-uXP0Dm26VFX)_!KO-gR6do~w~-`(qDzqkY7k_Od(X85YAdTjsr- zGhakR^jq0YHpISh-pi?5x134cqF%VPay00SShmV``ObmT!0jk0_ECt4_nPy|_7xm1 zoT7#$H{M36L7=;XPK+WmnGfU_d|$~s&UP&TESer@oULYw9Y- zh8QsqQMip$S#F7-i^Ju>QWcrkSO421Am40P?;zKVA9NZVqOj9DK4eUH>n0Nc?x&;d zUHU>2;BYNn?ZkD_NO5^YnZ-xCN+&7r?4wU=Ho8u6c%W~%MP#zGdh&jzpcIFx6rmyt z@S^{Lcp=0xhrc)A;dHhaJp0z72QOpT>2gGUUE;w2!H7p{s!8%Zfa@~mhT2Kos@U&oUL#sN!3C*9?pihy6aTYeIaZxkvkv zVnQ1?XzAZy&YrcFCkx_TS57enygN&BPF=I4px0PPO?)PKMzJfYkSGx95~||M^W1gt zSB#PHK+PbVuTCZQ%;2SE!@m1n+J8O|#|rV0p6-hsML6x15_^5Z`9h z9&Rl-c~`L0r94Ru5=~70YG>|Orx#H&BrGHY|HPPF4JXW?-(wzlq2lRT)O+PZmr+Kf zMEoKUp@CCPx7Y z7CXHBCfH5%Flh9J==O+n@Yl-vh#*Q^TNR@^-T0Vj^S;IJt-!S*PW3sLpuK}rHtWZH zuU*vrRTA;$-sVm~Ez{A>6JGDGT38#%*v6?bSVWLtimP*xi;d-vzExnWvv9~m$Lsi* z$$(>;BLCA#Qw8~h;Cp~Ty0QnM3L4kvEX+)vFF57Idijj)b)wWVlQL~9cmspuJ7OhW zXVE*TL6%epLd9;~!)4p=lJ!y1h$jnUbvwaqrsV1|(fmc)8Z0T)!YMM7q?Ni*bFV^x zJb=^auj^=-j6!FVC=h-5!o~1UTb?@a#@T}1nKj6U!uM!s)a1~$H z9C_L5&p(~yDkvN$Sf2nN8c>Vjy>XRzA9a?)1@ai(pU(Rk;Fc?q`(goAdO`g_9UH`hS9g>cT8Nc=EI+c5%HG|p@RQ@ z-Yi)z45n7MRf%Vj(+mwZ#gk)?s?=US9BmH+Wz;(8mvTtR_hqn$*)44a5BwD55fob& zJ2sW9290gF@Xfw<+1z@bbeqRxIghv zDVBMm_9y~N<*LWkgYNuh;2w3ar>scU<7js1Uu`g*xD*q=(f7@S#t@lG&N3wQzQknU`ZK2Q=Ux+*Ohi` zK)&ao{q7XTrx=>~976ztGE%xQX?~v0%|++{d*6$qflduq(^KOxiB5aN+TumntSjF= zqNjIKr7+)UW{$*0MA&w6XbWg7x*1@d!$zm&A7tR0X{}Ash`j2r6Sv#%ruxVrSLlM^ zcQTC%PA5Xe6;)K+jTYr%a(sDanAt_drVzq3bpzB^ehIBKt8zZXU`l=Z{b#wGbnxC967aHC5;5LpbK>(gYPt` zXp3$f(IzK*`!yCBA>bx&TxCR0S`ovA2M_q`ia(A3>p$$z;sHh#?^W2|w>k9=H_nW1 z&8U`Z@X48yG5fiwzBL{|L=}6`q=B5kLO$rBEwRck5w8bB%#z zqP0bKawg$&i48vA;6D?cjBWpV`oq~J$;TY_!{X@CWYUl`E=IlS%*h8etPzu7-1nBc z0=?jMe5%!=X~BKRW$DvMXn_x0b`Ey0ap$_lWryT0VDXtImEh#A(nUC^&A#{P97acZ zy|Jd7?K!L%|3HOSedD0wU5cmV6M9@Eobwbn)}g$`NDk@jblA$XhtLXJdj_nvEME>$ zFNI7knx|SA_4b;_Nth$-gtdkE{R$Y4IL)`lbtole?I9JZb?7>J0noIFhrQGFB4%ie z=Z$LPkDNrVq-vnOYy>Hv@<*d|qbh*OYq{^(eeC{&NfXf;i(LlgIg^2RgO* zMa#n3bYUS#&T~WK0k(;YA!iZ0MFQiOiHs*~=E({~n6l~~$?Td+5=7|e)@ZfhW+gDL zS8YDF#UHqP3*V2N{N!rwP>W+0He9ai`Z67l(Pc`e4 z*2t>yT(H~NaB14E4l3lq=8#f2t??9o^M8}7LbuNow^dlthCkH@Q)sQ6V*Z-Ah@txi zX}?9)rxx{$bgYwo-2=?jcV1An4F= zY00!H8}rkGboweY5>vB&CLXHA(3Sys=k9Z={)s6+&FSPu4K@PNqB9X1kRTl?_C*5+ z(pYmZ_0JslwO99N>n7OFYfGD*oHAF)PB3})0ki|SSD`voq%|$I;d?_PKbb{CH+jdU9r%Eme(wcInUQLYz{pvM40<7Xh&fZE*+ZO60fW6j_$Ic zTg_@dH9>uyt+X#LD@qfJd&uik{JfRoavubdaWaAlyD>3b+-mla&CZ`}3V#0D9isbP zt)xMu#~NmoDPKM+7kNFC&fQcm+|{aZJl3KuYzC%IepyvibRn93`>6egFu9BRF-5MF zu5%KMk8?dKLhR1oPs7AVnUVtXx+8<{Vn+kM$`cC~(Rp}p-lp(A1;k=vGtqH&X?C+z zM;-vsr)-ktROXz~OGKv`Tp`4iyTIiAh4wGhG-hRuy|=-=o2BrvM{n)rGZGiGM!cMA zmG7`h1=5LazLi$-H8}R}Nyro}l=|m=-|E1R=EXKALh0x33<=;iexylIa??&v&_Ww| zmAPnTsb>V1Qs)sA5UM`;e!{p>m*_mKz8KuN$Um%M-bhX3HrRA|D&c(Vh6VM1rZz|g zs%X&KRe8iEL*Y^1EPB-(@<7qCNUnx!=VLLWTp?U27=JX>Q)*h=_Z1w*<58aWGchaM zP3zU7MvS`5(rpXazPu2>Rmo7;Rjo4Q{z9i5M!9%f;O(QreA-->dXZW^;ECf*W$2r% zM%(iDuu$yOfpMiV6DxNSMGSqj<+oC02|9wl$IBPiVgg*!O)!{05>Xv{RI4Yx?OkA(Lm>ixS=xEG9Cx}Z=WV}>tIJ=N`VNuo4ImY(4(z{_o zUQDM9iN|$Bb@y$l6Ta@}b?MmyDtOVo=KV#5%I3`m*~RTWzYZU4kkeVEgdeP$&3JlsmRd@z2BqUKYX#lK;Yznyi+ zrEBiWPqa@tJrs;P9JEdnjalJLDe0ehel(CIYK3oHHx!rJu9jrevP{W$mSLNt4 zOXX47ul%8~O&^r8u;02y$x6si=@TzUEoK0xQq_2>o8Dx?Uz%P|_CBs+?!hf{OH*L0 z1G?q2Fgccng9D*!#QP!|J?En96l~U*#t->h*r?|otm1B)@g7$83m8TsBQ51+ngYSU zd`%xqf9l=hfrokdk!p3kQe6vDMrS_f4Q@6tt?><=aaIKaE5+@$hqk7`W^(=dZNa)e zAYQ}NnR^>ogSp?{AHQE5>ftKxC7;12=YDOx>IM#-)eimY8n(_^Df%~6gq`0bn&b-~ z_Hecf?r=QKSRc$sugh`jK;LIv1Zw9L`;P7>x=Vz~-SA~2^_mFt$&Rb2XGE8YJtuNb zRl`cvcj{Sz#Qc0)F`pzv>uBelr}oO6wED##w>a-zRMLA!8PqK$tL)!r)ZY=x$V$vk zA0Ip53xCX|la^L{9g)}AJ2mO;4GHD|P8L~;H@!6H<)c{sy<|;XcK7_7}q8P3f zYGbCSVx+FA>-Fizmx_@SsW0zIQE%^-3+n;pU7}uq<~6rP8mZ%`>C;tfE?PKg!Rzi{ zXdOJ*f}GV-7KQ*_L5P;kF7X2dfBN5deVaQ7hNjZDuY0QCnzt4pv#m5I&0bbx##HJT1e`wDzW%0nRNZs zmr85(fARv<(rN0b188$jL%dA6IRD zXQ%_+X@qIS*|?0hoVF_K_fD>hs=At$P+J|9lna$#Z1xswh8(t%%4nMPr5pe2mQJ3c z05K#{+(T>H_BNqWB6i!$t?0v9Ervx7&iQKF2*?UN9;XlAQ(ydxI!; zB#}Ziaqz9BHiI9;?6zWkICyb|zBD|c{j!3Qs-J(Vhc8?#wKCMpZ?9Cei}L6}NFm?echz`za*1TA z7k0sYEh9#15eyJ&e(r-Ih~9b8mQJ#%n1gVvR%_VF##a1=Ev=5pl&=>IGOF{H0xyu`irgKBvrb)Awn$n9V>$#%qWul%Qob&xfrf-jVuIXGhB$>cQ==I->Ss_fMfCT(rgLOonuedNgpsX}H#)}{5F_Ob9`?WD3JN9%$_{vVv2_FXk6K;Xud ztsE^@Sil;|NrtK8+CfNh3I`bMI@&3!949%^h}!1qdviNRl!^8gjEu7cfr~YtrrMSX znbg+REK2in160)jhxS;w;B}3=X9+ zy8UbX7@5viWcln&5Ap~2xse-aqqcZ%(D-TL{j&WepO-b8Aw75o&8MJhgCNnWP zGxhOCNh7(X*Z37BLNzhJq2@)y)WzhwrxK5SYRKvZ z!hEmYpogAoT6Mg)wDORYcyyoxt-SlDrF7)ZH|;Yo_qw8LbJ^o!B}yv_I@;FfiLIS--?z1v-HjqnS)=uh{8h_GF`=?(dJ<*BAbRX|W zB&>*e5OmjMHnKc)jD;At1nX@PJbTeldm*2-o&@s_bg66Y<{oalqVkZ5cVtUJ&bE(m zJR0GYNhfD1X16ED>nP_c9E!d!=0oR%c&WIayc&^l1rx-==BCuv5N8?IZInj z&A}Xbz+COwn*F3ss%&yo3-@9+*?vXOLozzQW|k{NYiEicV^f}OX$ zoi5PaQ|ieI$JW;$jTWXbg5~M8u7hvci4&&qCw}X}u;mE0<4<5C+~8z#VbsEg&!(j2 z9<>qIxrVNpo{@*3StqD>C{KM*+I2f-7?os%civ`h*T(#!2>H`n4GFVuNw(+qts!N! zkSo&DX7y%d0XB|#hj`p)>MDOd^XKx~jhL52R)?fYTT}8t%7> zIXviHDvLOaazgucuWKcrT^*nF98ahhE2%5M_NKUshsB4Y>(s>?L|S_a78CK!ZiS9d zf&Ep{Osak-ZCyP*U#NM#_6s)mlM@90S9{kP)nwMKQAC`fDP8KPLm8z?5flQ5B_ds# zlmrGuIs~LgKx76+5EzIe2!cQYq7aH;Adpa`NN=G?6$C{H)o?*~+CDB^CM2}?`m&7_V)2jCTZ_&Yq}?`-!evKtvg@6$?t z)|qyCFJaoUi?`+<4sF`X{x|`$s;H1=Pl?aLhBOgme`RmpwDh(sRr679Ex7Mi*d8KW zym3k2_~u3&BQV?QC(ji|9F4M$c*Bm=8~t`4e8@|)mD-sqc|aj=?+aFfNU*4%JzYN< z5Hb^SiIQq+U61kXcALLj-_=g-PS48VSHh{a&MAh?7iF2*Cw?i-5?QBJ)=ASdZEC`Y zSq%-hrP)|s@+fFp67w8u%VehaCpK-{@46Z^Zc2CRE~jF&6=jeQ;y~Dd(=HYG`c0$k zs&|#4Lj!KZ4mE=Mpq;+9`x957!whBS=JaI0iU+m4cMSFKMkeq z{z?YCasIeB(LCXcywX2NEPyg5UWY2zc- zGiJwa%uq^uNg<~hTj42&0?`b11#wU!R{AwKYJZVLkq`$gL9c?6x4Xx_%Lr@pt5(ea zmokL!Tau*=&>j=XN!H?mkeS)p(y=`ctVy93KL4MvS)m(+JG&B5f7zAq$h7V`zvg2$ zS+8wdKuGa;_#Lb9{zfB@e|(6-lwVuK^6rMb6Z;sFAFw#57*c;- za%$r}bfA0{~aq~goRJ!h36eZ`Ikdu@lvSowb@SYP}3zX;YBLoXp1(FNE1lRyoMA*I)+SsA!4 z@~B0g>ReL>ZJE7ruZ&Of#U!b+<}vSLSYaDtcy&7SwRSBF%WDSPlJR|b@@=v7YFDU7 z198N?vQba3!M4(_q|r%DII%op7qBr--`hyON`{?xfjfL;WdH}WFUF-$MbL@FNE)nN zcd_e(Wkga$C&-$fON?$q!@^fC^q+O(@0pV{VWgo4J+>4K-?(K-ytG3_`9;=#;9%)9 zV0a3Z+)kwqX-rSmxh@~D3K*N%JC zfagYkqTcABV1|`|PcT{HSoFy0GJ=Qju)hq_{%58?nbObvFF}@nKS2!GCl@M;7F<6L zHxw^Q;AJXf@tS*MzcEfo%g@x)sMoWj!#jM{qW5}HkU6TBTFu7H6Mme*MAS$ps1Ph* z2PH8n$nfRsMXyY>rSEN|&=6JF@Z7?8RAglPKB4~&vw)fI@g(0y@llI2&qOXfknu2< zDJja?9>``y!7?f;;Lhe7{V;~vuF4jLRLj*V0cLjIKKTxAMYFbkU9>h5s(?gb`e3zX z)sv0k==DRHdLh?1xXD^e8PDgoOS_xOsjoL)p;I{O$mA)upO$xxx5NaV0CfZ4ua;yn z3mil9F~E_lsd*xiRqs5Ci#7_0?8z9S2)tlW<)txj3Eir-s?%S4}o4~eYkTVi6P#sT^ zaZ_7)Pw&A(AS)TF%~7CE8pLJXUG0o#RWM{v7L=pL*$BTNg!<43aZ4!Z!o<{`odR7% zu(P)WGM$ZE?*n3IU(Nxn)=bfpU(ny$Yc3Da`>&JxZ3k?)qNQ!QI`$vJv&XHN%r9uR zhI>Ji-Ge1}g}gKxKmMU9zOM7p9hX)v^>VZ$Fd>AQIf}?bmnw zXh98?4F3$8*zCIw%aXjAp;M9+DE=&TJ9fRtt{{=~bpttih)2D9$CB1O_B%bI|1`M> zKP6iS%tY;Ei57J`oGxFWFP&%W#v#(UM9*9I_Nx+^4ApN%K$hrZAkPQ?P`{&09!wUO zqrix7si;U2Mb-$F90Vxu5`D2`8(~Rr_KJ;^@u}~6v-kXMB)V(71>P5cUq#MPPE5M| zWL?i6{=+%5x>2$n2F$IsX8gk9r2oq8?$L38l8?|!*X)c}VR@uZYv8Xc{GfdxHV4Ty zMJw_tuehg7cuUM@J6IKnE)TFwk-}QbzcQM6+w#X8#Z7TN((cS>v7#$>@)35W_GNlq zE3&$s%J_5pOR~rH-{}+~Op`7QR`06A0tQO}yD7w6XQDZ3w3q+q$P!E<&aJ@HxYg-y z!7tu2mzV@pZzZg1*Lt5zDT3dW-do60Ys*n^n%SxeWCN4nLKy;qlT8+xnANFkUPagrbLQ!8j z#T1eJk$sY@F~6`jsdAM*>&w?J<+?=s?GrcbvcXjI`nHHrMGA8oTQOV^Oy3xH;1EAz z^{>^3rXD;wdEVz`)*Uf{iyB(r978^0weXMHuf>&0jD8p*td|2+BG(nlB8dY?0P zxR4N1yD?>nH%vfuxjGM+6K7p@2Jaa@fKGbqUwCTIeFM9Ci^()?8TUA!9YPycOrt!* z;l;=pQmWs8^QRVJKYf>YUfwplct=$VpJM#i{^YDdiOpn?4+vl_JB_5{HUDjQXf7Y)}V*d7kc4q%FR7t`UqYVk};29Lj#m* zz@8aZ&G&gHO0>dn5b}%~^MNtlAvl_DiF;Iu`PjIsIxqp#0#vfTdwOCkCCc=9oy-~_yTwc0lplC9roYh%SE!Vsl7IEmMNgn;QA&v8*@~E9=BCr9(Dx+2_B~ZG zvS}GRx!HFcbobp(?ub}|P_-ZZv&sP6z<8*7$@U0I68#%fV~2T>w6f@AbGq?`;4HU(@8$G29n{xDS@e%;LU2) z-|{65I>B33;dXa9Q56TtVAw5_fy+EqXAq)S?uKArF1i0?Y_KZ@vjO9QPprZA3p*oB zim633V;jJ*#XAPn2V0`P{t%O@b%P)zZ(VQ)wEn7B!RA2dUYhaXBa+#m0?fgSdV&o$ zlk1u$s}hqSt1+q4y`23ZY!&Ro{@T{4sC7g~W!h^KK$0{@O$?AEVUv3JXlfj){ zJRS-3&K*gVX3i)wVpjc|&(rQ>aUO90Mz!4aDgkU^ncdNKECQ%I$ z-wEbbm7CbSN(8NF9iBc}SS*eBI8}s?q0zg}_{uBMan?X1&+JHPv$`&S+C)Poy$Tap zYr@P)3Jo+j$7HC!R>I7kL6U$G> zLd+~CoM_15V7k{HB_Ala={X69CO(wl1>WjkU|KmNf-tj5EbrRnzjs5A{P#N~9JPo! zNPk7-^Y3s*JR1I^;s0+uXc)?|{68V}JDO`uJ3LZ0lfCp`yLA2kF~|mb@6_b4qhW$h z!aA@nqZKt+s+zTyLGj(^3?|dX0(YD93a9S)Bji?_5^;%UW@n=OEhbL=2&$I<(e4h% zSYqDon?iSgHyCrry@DHDENO1eFj^@6cCL*(`bl79>P+mivu53+gsnBdY(MTlWSMUp zVaE@zmPa2)Iyka}e~Y;Q7>UhcVL9bsaO0Zg(F$<1@Et9DM+@KIVB!0A$Y}o9>p@UZ z5a~(9FZ(~eAuCx{%Sa;S7+{oI7$IgNpjPA0nX* zS#EA_W{@5ZWw>~QKAb*m0o~71Bi6&k-+yBOVVF!O5vBoDJfd-XEkbpW=Ek5lEc5aV z2RT+l2(v9Vz2HaWu8H~{lV=LkA-y!6{i0-3V`F0z6K>Tu`bi1q(W=a9I97CPYs;+% zvsXoojB+OCQYUCn8;7qPtiaxXP>P~;^3?D9pg2|A+&gA$4x<-5F1a-3E3cr?-P0oh zI2h@T+K@v{?Kcb$C+2GDY<^V;iCD>1(%#&ADikNgRVpeYz`@jk7w7d)${JnW-I&l# z+^6J6TlqW@ZV-k2C7|PeBNZDoo*Sbv6`IdvkuPJrrBo;{FYizdnGsFaq2#jbXjHeZ zVd);5pd%{1jH`D8hW(_8#l#=un1;=>$J=Z9_H&Rcfbe z2au|n8GUL|OYP=F=1*$$iUIsP9;CzMxr48f0MVHS8oCm#hf)CbH4C zi-whH=ZgeWr9QAwm0w;-BB}ePqON?)+3em=lf_}l(&%7+^pFQmUuu{|cPK&+z^gOuheq{-Ru_Cp+l0GdwRdY_GS>4g4Ez>YO!j}T!|Iv_WZ3~d*BgCNq>IorWUJW`XLfz(=dcrUR3UdeMd&NpCWfx^2e&KUMUOO zq94OiF+1nimT!^k1e=iHe}yA^Zt+Q5f*@;QI!junyKIhJi%la&naGdlUkhjFWANW5 z%gL25)z9Acl!=kn>-$w4$kNipa%|$4y3}~eDpKl}U-32v9GVvPq}UCXuz=_VTYd?9 zap?}fx9c?7$19`MohjGtql{FUAk-lzYmK!FC)NxR_u?!KwIDo5gKM?*kTFnb1qaR~ zlG5dRu#qYmG+L8j~YEj5@zXydzaLC6fA@eIYdX+^qreA-##q}rTr)I zo}8nJ>g|Dg!t~t*fD_Yi54`whW$qxM626o9A|zNZ zg+~yd{@|t4&DuHI&k=BW{BV+z=(_do5ED!WB$ejak1&=m1ISO`C$WuE8Rg8v0g28K zlkfWVi?53!=4)#Sc+`wYF!q9^Gj+?!K_g_;z@kaVG-rM?SZUyqx~iPv)-`_?1F7>} zh=N>o|3@Co$QQ_mmw99{j!;00)ZOq-n-c{13w*XKZJtQDad2DP(*-jyIh$Rel=nSL zh}gTLyeI{WxV+%mIbnI2#p4IGZK%c=VVi);sqk))3KpMCp2kWOL0P+LcsgcVvFZ1p zq?4ZCiaZ6<0CZb>7L2m6k{1rE1q9n*v)}p%$&aeP-_IPHGBvZixZnk>%azvT*U*p} zJ>4}Lkh@5`nOFtRi1e1N9Z4^feC z5P@>yAt93W_H~A!aL^oy4G!d-H;G<120DjO_{SNmQk+C)9r@ZUA<9jm^pU&*Fh@F= z;uilIbXt%Nps5)kR9dQx;AG{^nu1jdMGdifP6WY@1A)51n>CZ;GIbm0D4n{eYwWtNeuO!ozh>-FB#o%kQPDc>}Bxnb^eyrH$ksXY9Klhn<3%}*7U3o`8`_^LBKxDH&3^y z2IJ8>Uo|5D6i8`$3XG2pRMqlU@~;?Zhz=^#73eFN#I;dDGb=<0FuoQUr9?1O^1tfJZ6ue#N%$e5JISuqE-s7u zcRxG<6h;RUC=8PT+E`@%?8Dm%%9G#8%4r(X%?LcfSIBsDX=F51d(SwKgBVYkOI|H6XJxjG1Bn zq1|Dk2!x~zY-oQ;@0FweC(2X^p2Y=9MB`*0#nVO^w84mK1}7AJ=i0Qo?Lu0Ncy!8f z1|Xh35s=TbNA+zobANm_ewtK?9i|v4!x#O_Nfy`}W{;rlY5M61O6cg`(5sR( zGoytEm|*!%t}Gr8gYh~Wa>svrwKVl~J6r<_D){3Pi z?Lz-;=$*eLIw!;)D_s2pvoCVSvPNOMU#-#WN(1xGKS~?2PyijfoVj2x!F!qVY0(tK z2nH>MuMvZ;62bA6c4ry2S(&H?80U1Z10=dGb z{RkZ$SGTiQbxg3{ukZ{SLQI(#vJm}n)#$fch@j%1a6L)GFq>Hg0V2GzwFH6bY({zC zvD}FHX=9n7yXI!o0*6=^8!p+8N0@x^X=QC%ny(ME0QqqNQuJ9fE+3vhNv>ey9Df+C z-6joRN?$)1$=U%sz#KbX+c_c#b^OE2g?P6ojHm18l)C?!WmZ$(uI`Y~u6-Ita0bKs zL*RH-9utavwD=JX^1kT_Kj%n}fB9atbiG$#m!C%0u*u~jI=pQo3hG?e(dJuVHLrRe z=iIqpe!hL&mFw4)r{+4hFh!T1gB_-jg`YERF%2|=9eE5eAkL}ruXHlZ_ zp|3E#k;csZ%ur>E*$f!)liT~(0g8rsyilD9s6wMnqI6fu4qeqtNeKvAm_D{7D`<#( zQg%g6N%@OKS^?4*a!dy&c5K6URKuKRj^gMiaB8rLl)no3>F$Uyug~z+jiWpSnI|^q z4Li8Zv%>qgLnRGW2jrc^^HYH>vbQ6C%~?q_|BiXt3Xd&O+z^#g85-PWDtF{Q@neD2 z5Dd`NvmL)P+?j)4P`#K~6T2XDahJl;7xtOEql-#&yaq`IL>bVIdtALvUZAx_u`+7M z@`^-UZd6nLt?d}1jlzp&29-^;gzmJ-(ITAK?DwwmeBZvZ-HVy>4Nse`rpeUHI5*=z z7Z-<0yaG{bo#P1sz?{!$z&}%pe>X2oPO|sX8z7!DEL44Gg`2wLB+LDrI3lLxx@Ge# z=(9~sUUzUMDe`GmH*J|;syqZu7C8?t9*(vp{+uk0a%pqyo&a>(sB2!GNZtAO5sk#q zE_-GFR43=PHD1_^{*hOAsJHCz%V(nNU;v*c}(5lruT4Hj2&i*8;*C=K*O4Hx6j>xabY zL6?m0Ii=mAb|{t4@A~u#ESyV6yyj!!a0Xb9EF?lgZssTrlpe3|=kVc2-_AV6n%b02 zv6cG4s}C7$ye-RCspQMbUKY5v0*WyXvslK|y>x|QDXRA9vDGX4PJ4MEuoOg0X?|bb z!38LiW^m={CQ< zxSS?=@jZTL`8BP=TcV^z4+e~K$M`Xj?&rnZ`iv~6rTi`Y+L6CgcZpIGvA9%K`qai# zh1xD5YigP+X`;H5bmDncl{L(@8sLptpNSE5ZFJ(NPr&wH(e!*gzsCu27eFf< z4ZBt)28V zVS;(7FwXZ*1Fp(RFK=ejq@{utssn%v6aa*0Ti4e#wZ@Q~y$P#u$uvI)w4Wne2>xWt z20?mFd`Zy^DP~?qt&K>}Kk6Z|;5NTM8um08R&AVZBWv-2cZ)U$W@&5^icW6>Natn` z+47k>I*`rL6XJ%-IxQkP7rZt#vh)X6&@8FF3uSNBPkI-;EXwvQLKU79gf+vW(jA&g z<0QvJ0HiUSRWv|{XSqQ}JLRSHB4aWgZ6wI@bygI^0q~qBb_WCu;&4oOO?g4rU;Od< z!iJ#`90C)38)K75*X9)eF9Zyb!gei~x-0p}_hy`>I8CKMSugYb1OeNN-D2OeEBVCN z=7`Js`WLqmgz_%Dvf;&K2CQ$JC?h{v<&B8HXm#nxUuHpGf{ z79IQStngsO0^x>hug4A@5t~w$_iw=pso-y4OPJx+gQGTwU()EUr%b_#2`x3dqnwXJ zgGligjx6Z4OjP|kS9GPGY{krEt&mdfNbb%7kg5Q`n$Z5p`8eZGt+oknyW_t>AXqF{ z#XkHmoOqJm)L8fr#((nU|1BWyY1n$$L)1OTPfz zZ`GIlgjlnD*JOq!dtf=vSI5_qX8#cf&tIXPKewurRwMTZEHLXU1)F}*S2F0&*JH@4 zkfyMc!(h1x)KrRLwX4EP{5qhyaYX8qQ55Rk0_Hi2lA8WH$&}%sqbPMktm7>?pSa~o>NJadMvY0ZBL+$K{l9?24_lqM;PMFO7 z-R@0m2WO>p-Qf8n8XHq6wN_D8y{Onk7&QP;PS;0<`H^=Pt*Dp3+;TGO`$ygv(k&v) z{e-7J_Rq~Frq0>`>Zvf_piXnjaWfe=Qd|^$@czOWt7}`wAmFUja~AZ2MOZ(n$%* zWO+Y%PeIEc)BirtsKX12FcJ;_8QdAi4i%*Ddp-UPx5c3h zzqD+L$j+Ua5bt})TBTr}6eOM2;MI8gs{(Wa9#SpHm3qii?KDAIIxE>2?O`(s8g0z5 zXQDx$V(FVIly9Wzi5zT>eIywdSo&a)3rzG78fo0`XuqPbFvH12+FwemDha+IGK1#B z_xU1uvt}F#wWP5<{dowco|M>3CZ&gcwIcxY4w+%0r~Vu}(nWJi(uDj2W1f8FCV3jZ zrf=zY`oyp09c>if&leJ96dGuXF+)Vt&#{sn8s5n$tw%}|B~}v>D%Ji9ki?q#E3#>S zE94Fl{E3u>2)*n*U#IL|`}Ec_iC!P~HYGulP^b`}F_|fnH-Jkc=GBQGnLSe6L@o_- z6&LCs8|XB65aH1at^ZZ+)iNp?-#}A3z(C{lcKU&#j+Fdw3~6QZVZ+*np+!yn~bcB(ibivG%TVO-luCHa8tSa)+R8oqcF3Qd9=e|il zqEs0MhZ-2=MA2S3O$J46Q+k{9ACS$AwGiAr(OK>2tHtipwN%HO*p-4zN)>(3&A-u^ zz3`ZAIn}V$%Z8X~+gia2m?)ksZT{CPQPW6|A+RzVUzPGV@$b6SJ5+nS+t6CbOV3jPLWa*}6=v^9MMS#gO-9&CcrC(7WO8Sf0 z7h}I5DpUGw2&h7!{_JVcpru;`+f&*3BgYEdk44;8ela7=S+-sOIgkI*dHdti(lOD* zl4i+0yUtN-{Vz@4vJNt8(o^8Qm`RZS&`?C4sHWG!_q(gV_zjzj%O!gjbEx(V$-%tt zW~0)!qoW;dXs~#|J+3UI>5)oGRuW|>4ej+rC}yI+iIwW^`bnmCKa=n5Uom4fdqgnR z<16LP7QF+cB~X9<(A=M}u$NaNMJW-U_{9i9; z>pUWtkcDm6UPCQ5^H=BhUF&uT5Fw%}A31iIzzQ@H?zZEksQGw7H&Kfj;DDVBARN?C zqKCB=EFb>_EC8HNb^TyG*%)Z{wi+F`5I!PD zsykZHZGfyunSA}bCKyj#MChX_L0b%znYtPDK~KTn}2#6!PmNHsq&KN=Vum%wIQ#=roC&k$n1G(4oV_z-PX zM^<_kCarJC+$OEaRQi$SmPLs4+kbKL*rJdL^4QPL0Tcu#1yx+DS9Uz^Zv3TTBl*SZ z{B&KPI;nYuwzdmtDB|1~C5xo#3uot=_bCD@-w6-C~&Zo*x-lY6CMtk zyP&#uGD<)hzD^ldQrp>afmO6639;8FZJ;t;XlPrksMA?m2K@pV$y8=!2Bd<9+4%hC znQ@=VT((jMj1OZMmn{!-4~@XiuYk#3NvV~7q4z7t>eUU%>0Kt6D~>cvuKj-sMcf0; zftMd1)@{U_`q{q__fH?fk!B0F9xnns;GYH^JTpTh$lU!T4OmQpjvWLVGZAljy}HM# zm|>DMAe&#|O+B4ikKB7Wuc98f>MD!Epqe0wzHL#?+`%19iw(iahxQauF0x>E1c|a5 z5_~%Z>ua`mQSx_i8zv~>zVHUhLF5?v8Tv8WioVlE%DdiKv+Br3K^J#(*lYMk^!}?A zM2`$sr)5n9m#3k_uqk0&s7-(2zA0}I`I!n!*ipsoXP%R-(Xr)MyC zTIR2>&FEyUVkiU<=wgGpoZ>qzNh5|U0jMC)gG$yFBYoYKw?B{8H_W-ii>&d+uGcz@ zQn?*t#=Xe-f{=3^9c^x2H*w2b9v9GOx9p}J=;6=rm`mF?SM2WKjl9N9nkdD{(F9vU^Kk4uVVS@8I%Y}KB1 zC46nKQ%L((b6UNb@xC9Ii0z$gUt-%aqK0JDY9;V!=G_?|b`z6NgzWQ=T6ws`&&#m(_B zNBwXu?6+n)aoG(PYjT4dT`8I$iD5sa=lY$3`5sk{ttSceKQrd1h8k}5O{k{^rHF-R5W}W8zd&Ur=qPRW&*XIAU1eaSe&NW>}9v>WjAvH;(G_tkp)L~j&IYI%l1D%kMcPZxqXwP zVQ|9kw-3%Oxv^gxIQXuq@E$Jqu;nD8TIb(qGLK!7qSG4muuuMYUJvnF`0pHW<Egd^4pld2;{eBGd5LCa$62R2*|#9q-2g7W3i7B z+J~^2vlovMfWhDAsdKRF6YLb@qra%=MYcI~W=2GH_43tKuB)uQonLO})DFNoN}F)SU+RCjfH{hiU8{n}ko-L} zO%oH%xte{1yrV{^aSvROS4ry4;y z?@pQtws=igTS1Xr5=_sCLk!eNulhkK!ygt;m~~qo`Kx|yqe4Z#^G#}H^=V#6R2F);AuTpxTBcrh|cm(`ZGny6|b>V3{Ca(0+#GfH6|d30#4 zjH`QqOR4_7@u|6v#4r-+pC2WtX;7vw>Z#C`9b~bN&>sj)l_v7Kmdi1yEF<2Z@qKEY z@a@Ma%B{@scOn*t#qRZU%7R@fhX2cXC|!S9C&*yP`At7OiZQ6D;{ua_SDBZu?us$ zuEkI*yHeCiT^whENLD;PU!7~e)(BnA7cptAgPxbSrACs#k=LPJuIcaBmQM!oO_o2N z{~a?H-E+YHVDH(=g_`<()*f`#CTStm`g5kT9G@=?g5P^fBD(xG{%%b*oZUp0*o^|2$G#SLomZnfUOMfpu6#)|Wf zbnkAHyg*S^Ui)_y?+HE)uqG$=l8^gxG~mHTcl+bBBtk`e*_hZ)sK|MqqYc=5^%5eg zlI^0qh-q;+(&Ki4x<#FsY*9PJVesQA+D&df7JA=oXPMVE zFJiZMDvr%oRdC0*(K^C?fXq{CEIgvxDJ>Zv?ziqdJf2?|RDSFXwD_hB*FtUHeRJ&} z6$_csMyvOx&^3O;9oXR4CaVfyZyqE&(o72~;C%*iTV=LUJx}dLV2Xxq zVprP2vNj-T@GNc_w}Tmnir_%=v@rGP1$umPvi60OUr(2DmG;tGfuLZE11WXyq6gKx zW~4-_6oRskSxklWULCnU2CJ>hFdJ>^$((!G_-k(hpR4Lf3W2tyiybeFZcnDx9sN+} z{9RNzuLap%>iRnay#CD=+vw~Ja+Y{@hppRoeb;ulGgRg4y&PoJq7vkL&47A1Z#rCQ zyW1;MX*#(isak|zGTkgaWO`lWQNn^sfqH%OQu&^Ueh|-kX=qxB`R9=EQgUN&lv9@B zyTw-bwe4B`s9Xc}LZqz1-CErgBkpdQU4Ek?>UuY;JoKa^A2GEF-Kd4(5!aKlMn`n=b0GY zyOeOF)gkwmn8l_na4&|H%dH$IAF7Rg;a{%sP9DsZPtbF#={z?J(sRoF>$dZaa^ZRW zAneL~dCe+_zp=*ESn)toir{Z{G-8_3#MCKwI|`ShlYnFDEYWcjsBtsr8Oz#glC*4j zYcUO<)o7cBVD(5I=Wox=4P<#kqT{(tY1!=*>eOQ*SdtLk_=m^t`1UV6faBeMa~gm` znIrbt=|Qt^v;Y{Jno>YjBz}F{r2HjG;L@+BYLI{zWt-0XjsHCRKEJ+m`!{}B=)EFE zC4YXs!6&~i!L18EyB51HGqPk7L(bCOqvJHyy}YGyBExyeyhIC2B_fkeI(92qdR(Gl zVsuwB^Y8Cw+Qas2Ph7{*=sScDFg~RXw|VYc?TIa=z=E<$zZOxq_*J3vmfHmhEmPe%=R8Z+%QmGQ#AuM68Ik8vXsS2a4TMRe*CXoa zRf+JN1c`*-F>~qZ`jj?(Yj;XteD{Lvk`h)k1Z!sEq6u`4QmZ@Lc`%CFBVcKu#Bg*& zPaZ?=z9zv6@;(|dk}N@e>}gn^ia)<|6Oeh>EEBi^dklWsq84LYO4V>43q7Bel61vA z%mWKNyIawRT1uA~Qczx7PTb`|LZLcwd8yL542e00zI z{A2w0p+P>7Rg*xUSSFJ8n>+2jv3JOW6U=rX`NOH5(d`(=FgYlt{bDzLWXg{ZHCH3A z*|$Okw((1GJ{$`Q((i`Fr?sw*2JU3;vmZPp!G0XNr{3G-n{PG8ryaqYhTr8T3!iq^ z=zjKcl%dw_UWK!9i~^OZ3~lb#f1OAKzn892P)^4bmgTDwe*2l<>a)pYn(4^53n!pK z$L{z;fGC%M&t_qb?)~*rjH8&NT~BciIbIW^l1g+c+6o`J@#jYI44vb%yrS`N5;hEe z=Slq+%r~R=_J;?jeLFsRbi7D7W(dAhRfN3fN1Y>$u+0ar&xpmXrwk5}H43nXk531N zEaTm9DDx4-eu^zP%HDH%zr~a(eK(j^bexvZB%#q6wq6|a_Bjkdt(Z%cWYwPxbtrN~ z^;Z{V9vMzA!Ry5A$KWJX@8N;ir#i8tCg_w@N4)UsF(>1ao}c{9A#o`yX6y zwmja?vc!3}aOVF~4!2$XRJW=dcRjLU-9JJ(1<~6-w=bBnZrQk`7D`#hjh>D0g0IUt zb^s*Jx8W31mz<>DyFYGIJ$!F3C$V`IjkCzX8N&jTk(OAn%$Rc_8of7Iw&VZOSxzJ`#irf zsHPX#pte<#dkj$(I9=KI-875;LhSGr7D**bEH65f!Fl5QuJtOg-$fb%R2X3Egc>fG z7z&rIl0fO+MQDdi=QZo$hTW;cO_Pjxi#<)^!%fJE=9GWS<>B?-P9b&P`}4$ zC?d!g2)!;sxfZ&RPLA{1#aPBph<3HhE7@?bEJ=kyQM2Q(FUB5ZWKRV&cB&Ud&3=}~ zcM+6woR!j3uH*`seeQM+e^nt^>)&KZn)m^!un6z|-H~2;8NAILtUW``T2&5YaR|5H ziyf*2-aS0Te`S%fyHX3;r%Wa#;kF6&uHLtoU&haQ&tM#Q#I&F1>BtUjJMrCFJp~6L z!%4Ha&rgRb(Xod+V8sZsI{uG{)BSaFQTRh_iXi(;LrwKJ_YFObR)#YQH|hB0uj}Xr z193H0-oZf{eb=VfiU?YN-Q*lGwv)MJ7)r{ZWW^NmapZx+F0f7Fi){8A7;M`nPgb^g z<5GSykz@HN@U`8oojq0|eb$#_fh3J>51bTsh1mAhX+vW6+)r^Y{%QVwk32p4UX!;f%TW8swfZo7> zDWS{{XXYjN5lo1%0ChX06POd_om8}redNogP$xfUyzz%0c0Kv-+jH%F2xcX<^-JBr zgB3mUiSWhw1LL)t6^XnrCdcN!H*8NlyBO%9zXvP;4+;-jr7iF zI~gYl($n?TqvAqN1~qkR)bVYSnO)fqo6wzW&UVeIBCmc)AfmLLDqXz-xo(#9uZ%B_ zxGGaTFRbSTt}ioq|&(T)U<%Th>J&b)%F-p9@|_hVzwI@ z^N^M;c-qLSEgWI)f`bf8!#z1n8rya^dy4zM$D(6VgKd17`t_!ZpQx++&P}`QP(@s? z?_<)vBZm+S*fRUjXf^$Uu?;SWwcHmERn9U83-Phu^fmupn{bxWdH+y-(6ItmLG9xI z{k$R^iz;6ySy`~jQUm-C7hu?=DmU7)8QwvWwMN0YcKh^oXlGo1RBTJ@(CYz`KXT)e zdRsf_Zm83t%FW;v6F2n@U8je@l6p~SG`!I|G2lLUzt~IfJ=AP!HXgoU-296@BjH1t z>HW-9IOFfVhg1_{#H4G#i&)V4Wa5M;gLpiPz=i5b`jLZP$K|IlZgp{ISb>5cq)U;f zUge@NfX4S&Q~;vi233o>t2svbxzQ03)kA{%5Gz9wLA%q1j~Y47fYr(D5b<|D9=DDye;J;z{v zd{kyQaZW?@a+h2y(Qt{kDp-dnvvHjEHK4o#4;n<9RcI#*`#jfk}BL%x4^%LiD zVb9xy=^Q_l8tu<3dM-1mbv1b)>QCoWkZ7ypRqymSpDR3y4xhij)A0|S#&bFQVul+p z`?qree~Ds5OMZG7T^ zb!gw^j8bdcry6r7Q0bb8hM<+%{4Hrns?>6#+Ej^lRQiVvWvXk?VgiS=Nqdvkp1U3M{|zCr10jJn<>ilUt1=LJuM;o!&%Sp>NaRaN{+F%Pjq=3 zp4zgO2VDmQE%cpey7tag`3*}?(QC_9;ySYFLg#5UBYLg-;pw(_*A~*RltbLio#NRA zEFdDrGu-Lk9knNNs3V@zW-m(qr5(5Z5w7)ja5u*hnt_O4r}YiI5t#7IP>6i@tXB|8 z(DIZmGxKxEo{E;~04(9vYq7mzCUVo?)J%P7Mo9&Eh>5ksj_+>E;4gyk-qP8G`|JV_%I30 z(P*i2UMo>nwgrt?XTD<&aFMn>$FK3>7<^0?qr~ysHZoTyc=N;f*XZ*o-xN=Vw*qY9 zRcFzWJyRNmZK+vtw~Y8aHqcA=l-9TOF;NX*nBi@ANl@h*K9|#km|plaGUYd@&6cFF z=smGew5Xq^Cnfj!ZjF|9O*7>BmyTSc>7T~66S}bB?#lXf^7dQvp>A;a>#$Aa`O+#3 z*WLs<$To8A^R4J~U$X3No)A#USF!5N&>JJk1(xaCg93xkolHJFHXg%}4$Q3ajXu4G^$nVi!+kBdjUN6+JS?8SdDnbhTQGHjrkuXdb6?@oB{sDxD^1d3CP zTy8p>iYK3G3{`ZZIv~wV;gN7)5^xP|DRm!qbrHYs8xUPKFt@Y07|50HF|UIp7oL9l zZsK%A6KNIB^g^3v{OHfySqYEy&6axxL6_@R!fwqEO-Do|$%a zw2z`TPlKRTQ#pR)K&Oi!t&+>sNX1{LZBY2TXoCc^HpfhhYI>#?5e{Os?|(8DIVS6> zA!f5mE!C*uFt(o0nqdOP^cZ&y2L17g6zvhLrNk$$#buTA%QS-WRP=q=YsF@w?x0t6 znM6EGrD4OOl|il{89~Yp#iANY$ErbRmdkssNUSXsz+*o26gSu8NXN0gzmiQHKzrBW zUUh8f^yl?(hr?$dwTQ8pCZAw|c2pjext;Poeq+(*`@kRdXWzj>D+^k5^h)y$UOl{3 zMjD0c&+(jK_S8HyZAgq~x3RiQ65R=#8ENa7VgBMsyP&CO%XdUU)sDy(58EjY)Qu`c zwHbbrU+Sr+DTcw-&oghU+PYEJt855wwf<`+juI`^*M@8^H;0ABv->%pM(3q6lXE(qMzT-hvCaj zpJMpPbrsAh!5RW?jyzk6?W3Xfv0Ype{GYEE(C?m!OJ^x5{rG65u_22x8D=V+08q%z zbu2DXkMkbZwgd^XGTe%%6RtxGH^z;KZ8Y}3{_r#hgx>WOM(KgI&Vp!K_?{3 z+#bFUUpJ_J+s+aHLFz{}dJR)=2h}6@bI|R=`l+nv@wiU9-QHC!KwR=T`BB}w6IML9 zz^CC2u3Lva1_>uqt-=23&iT%!ha`7%RAob|Co^_yada+&Tm>0JmC;AQy?fB1*Z}fR zbhy2j&&+)are=7BQDA3IQrgwj(l)=OXHn7M9=jJI%xl zzWqUi6k>YX7lI$yZmJ199h8sHe&5=*9sPw0X|C&^1SeG|kf@awLSh`2pS*h~bA~tY zbr_vzuto#1R`#J;CL4!**q`ObvGK!RzCk4Ux-VCbJ-Arj0}D?3 z`{FrB_3CdrCte0I@i6l4%m8?Pez}evS079CJw3;-yA zAu)Tc=+A=|DVjM3AVFTG>MKT!G+l%on8pa&ruo@f3sb8`teN4VB{YvA$UZ3{?>gyIhBD0}lF5>%quz|4I>#7_ZN(zzk%jOyImbk3H zlo+X{%kRF~*$OF@ai`j3o_?0==^ju9t2~J{rbpHi`Qm^f=*u5cb7~m)ino+8IX=W@U3sgjJ~GKwV*1D#IZ=nBH*eNRYz`W4oh>IuGxo-!0`ISP z$~_GC`FukI*&DqEV9IQHP?@djz>$@PK=3LV74=flHJ=j)@#oh^q?7G{*TU|mij}MS{rBPwtD3BrMB#dM);HbpQJ%IuOo|3@hLyaXozL4z6hcx}@(Og5{S2k4Cb~f< z9yww=qN@{Ss&k*Y)Zv#c#7^ziX6wPhyUB!cL9&>V8swKc)RjO4&H8e^|J_p>+jYtf z*`dSV$`hAQlLDNh?m}+f&r%%*a^3MhTzL?`rp|L&_8AGd2^JTrOE9-=`}0Niw~Z{w ze~?}8fbM0C@Yswa4qmjqQ!51Q_!ss(^0qoLKg{TGHu`-a@`;TQ@d;mZfG-|lz*|Fv zuf?m4+|ul{B$D%6RB4{kS=PQiU+cv}%P@Wg%1Hh6*#1xUwht;pp< zBWwRspw6)BIDPrq=1$W|4b*M0VT6#&iA`E-m}KIs!$RBWIuTPlGVj#Ul< zQhoHgJAVp@mvi>=6Efm_9N-7mGJIrZ-Zi&xoG=WM z8%Nhm`CjblIVli%+Xz}5ZlHdXf^0jhL+e6Lzzr&6ayd02#P!JFEZJ;o>-56@9-%hb zGNnF-cI--sqWD#F&^1&O-R09#%J4=QZ%&nuw)U}8z*VPMNaZg+M%cbG=JP*YB2d&) z51ZC4W9h78`9fW}WDR1W`qIA3a`&dCzN_uzS5iPap3=PAX6fY}1-X#mKt7`1Wlnl* zZXmYNH+#I%@h{(nnb}wU(y|9DYq?Ke!De8q#z2$szq9Go0Y-`!23Nr&t`Dz}ckj-G zncK%ZT)`~-;B-6coM?(&YCr7w(D(PnXB^QvcLDaTLf(3cD8dR&!3In-l5G7rhf84} zND-RsRAcpR4nUGz%(Y5*-v6w$xlE0--4;vp{_TFOSVY@L9u4(%Z5b~LrjJCT&QjVM zS%LYqu`T2%$c!uSn@Hor8O(~t$NkSp@*l%?OrH8II>!i+pom|U^lEUjPZ7o5S+9f% zF+J7qu$)-!94Ktagr&&lceBKYKRe1bPQKA895m|^o(C8hZARzO>r|AQ90Ir@TNiiZ z`Yo(^*|fS;QKAtwd49;Ejmto&f9w|MaZ8VVIO!D?WfvZ!V7sCqrq<-TKiYpC%~%5A z&DRw%;F(X#Hchltk8xq3pPpfneAs#?>0ia}=K-|;@~bIY;S^%)7AT+v)4E@$BY$sr zwYTSt`rkiI?d`sQefcV~BWa5Q2Qa&dNH3#k&WW+3t89#7fSH6MT z`>h={P^Uo@u6-_f@`7A#2BBU!;DClw8$+hG;FFa+QHRardfFs<i*NkPP^Dc4g9)k5-E& zQ8wo5Kwlr{qZY-EOG?o~44CRq&zGg4S|Xepc2^#Dwq&kHXJx^Pr5*E%HNCM1?o(en7}i+qqt6nRHB_i73V)UxQ)OwBmXkif0;fUsR9sJYA3ymL#?T5 z^eXSCRoeU_8BE)54+E>G6Jqg(z0gmiFN4~2Cw@&aRSI}X;m%HbcJ}}B$J^d1l*_FWA8Tb&}Gd5>}Ny%TvkFNJyw6coW z*_EDQdqrsJHXewo>s}5Rjvk*F!{@!Cv@12dT4C5PBU{#()Q*oKgZS*PU?BlR*=j#A z=r#lLs0ds~hnAW;{5hU#QOl|LjDklqlEU7JX>Q00u~RZl&xCD+3otI&o1i2z++?Sq zD}#y452t3MLj^sC2&>(QqlJ!_{VT7iHRoLORE(ycqye5<6IRJ0t$of`e!Ql%cpcjF z<=ZOf{x!w^-rTYYB%4g={A*9Pf6cyFRcbQY#c+4nTnE>RX!8c?_x$6()PDVi{Z>2p zw6Rc{0nU~OA>Ps4`!5@xz{aa_pWS2YTFU~Zr{2vns&q`|QsA(oY7q~NNh1_TA9)UO z`DQXUUPgrE6q6#}O}^n@ov=HZU37|zeBccmz6>*Xj)Y8brQ^kDrF@?X`?3D655+sf zdL2w2pZQMNO`ht6wxo75wH~-zLKyCkic7r9vtu!kRZu8({gOjQG+X6v@^RU)Nzu9Q z;ClF}c%;?;Z~@rNA=Gx2*f*YzC>tLrzvE<~=dNV+ECNtLmjmef;d${|EW7#uX}vPD zCxJ=A(b#{rdiNblXD!BS`#Jb-So(-#`Quxs%s`GX)8AhqYeO*knid^7Qqi~dE=RXj zGXzs%O?W;C5}d@@%DeUW#dpHR+I8O_Wa{Ia3rtn+JYN_TkKX{AF1wQa7Gb#5Y%Apr z^cXr-NMy+biRS}iVq5ez)Wqp?^S(2jGM!k-(RPovCFynOq@*ml@L$GD?}FEN36K|G z?k<6zeg*JZ89$jBbU;AI)`e$7Bqf|zx4OP^wqRsGif2bfK!kx~&e3}l8E=Ok)66+k zspT|#nv#)nPE%Wm7NnBzx)K);j}l`=AXlI{sw#G~{tuN&v z7l>hq@1js%M+;lvaqn5}qOohCDpgvmo&8p(vI&;$#D1p4dhaz(SElI262bR<&r0Mu zDyzj}nkn4Ca32t6FCLTKz){eOHGj@kUR105<{G-7gJTI4V%(V9RHeZLe)+675Lj117MyGF3b9+;Z zrrSP}SUfhha*g=wzWYemR+*N;~T=P zw=HPfp9{Oa!sz)w?7e4HQ{T5PY)3^!1u4=j6zNFss0c_$dQCt|=p8~4A}XMuAiaZh z=~4p(LhrqX8ft*h0s#^T1a9-g`gY_rv>+`^yK$NZ5Prwb$Nr&AH}!p7~@- zD^S}}K(H}-85Luv8C`P;QURG-gu~#*t-={P!IjYNjN64?4w6FeM(#fMw&}zw!Y4WG z^(#6n!@+eBUZ-sbR3Z?`m%9q=m+N9qb)GcYsciw5G79M zBP$I(7-w|E6vUo0%6ZlQcJeDui33kjV8nK=XQ>r^$^*K!S8K>lnUs0O+n@fzT@1PF zx5!!%aE~x!JR`~*frBc&fxXK-yAiAKEtyzzIvfTpo~s6DoiTFesd-MNmbl8oE%VA< zTMPIAb1`wE{-?DVCnxwuuGLRjCfB2&RMKL#gaJh>t9G49LdWOLn?B~GckA@mmMuxC zN+nCCI4R476{2GCvEQ00q2ilZhV`amRiIfszUx-q`Cfl`$0FtZQ$<&tL1LmLy~{0V;L zRs-5kZk7An@d97fWyMLB$J|OAanj9!a>R3!fZcShEnNgFn;$*idt0tV)m6DXkA?fE z=#xN#*^5xMM$3hl`4&%BzR_H+OL%{oS~>XF6}zf_C4I4|Pd&!4&nl3+ey*?9_o4Nh zV8!ryc2#(nr(fLfJ?EXq8_z8i?Z-`hTnf7VSUkokCmJDQK!GoTYhB0U)q7dRbiC&0 zxxuWMlV0M--|#b~$2hpX(_Q9MUW8{!m3 zg1fu(38lKdk(4s^-TQgYeWN9=);C%fifm3kJSJSRU`O-FJQQpn>(AY?ErWh58*?lG zmg_abp-=12it{s-n=;)h( zQe!xEVm@C3ZOA>oT^Upzr`U0eQZO}87$c<{T+x*rFJgfm zq$OeH3b8vWl?;c`(rM1fX$=u!oIH+|8OiXv>1I&K(|&!YUs9Pe`1?a1M3D(To~Bw) zq$A=S9kiUXLriWcFjK49jluvEu+7@(CH_^v6kPh?GR5F=DGNJzbRo37IxL-&!*$YA zgW_IHYwsG3llmKFS?3RRBTptIw6wShsRAtGj?*aFWCzS7uq{%?UzL#Lxv9^XCiDb7 zRIXbJuR-t_-f28J6P)Q#ts9Dx;snfsZ4^B$K_}VdrElI|@%PS0R)uR9sOU3j4$=vt zFMUEjtxnZ8#@aqD9X@QV{WxzB0o}XUhnfo=r--3Wu zd-yR?OKsh-dD*biSM&I}`ovlJ*!glg{h1~@GD=`a%6Vl$tk7ZS7?&g_v)yN^O3yyM zu^HD;?>txi`?n9CzBvP7`h>l`i?_KEl*jM#0lx`haC5Hq_TsF@u)aA=JNGGBvgbhO zLNpj376B{7-|*Cd>t_mj3ZmFd0(Qnb&wR<1Q7~9Fu1@i*-=Sz}QTW+|LhxI<-`l$Z zox9Houc`w8>Ar`K9Qhx?t(&)Je;yck`uV5p81ZK<5QRwZc|um1_RV)!!3)KWS7-7Hzr)a(Zn4q&C(1_isYDU3QuE*bgW1LWbLa6Fry zPVcx@I!-QYT-U=Bt~_(zY2{39%QrpBOY5_(A)v*AuhungG&z&ET@2@9N>19|BWNj{ z?$X3tlZkTsm{oXvHDvHq^8IK74zJ%CK5A|>)~!SjE#}*n5s#T}2c|mvAH}c#xO>A# zE-J#YBvUsd0&R(&sb2g&r8ek zzF`qU4h}ddFqVvhI+q+}JGvp-_B(|9J()|xLVa!e_2nf^X{3eBEg{!4G_1NcQbl}T zLrS(t!939>2fTaG%YL}o|C%}R$vt0J`k9=kiZ?-bhSNw(YuxAMZLKW6A)|}#1{M_z zEoX$_r^8LJM%dhyJP&W5%wWkYS{ZQFkY}FL=)E!aBmf?lQ6 z`p=0{>Cof6=it0>Thz;vKv%nM^>irgVVI2)?c#5yLxzK%%hx{Jhz|IQ@LGx|;1W+n<73Yxiy;Rv~j8oXs}shh@+C zkvjS`Xea%S_T->Cmj)Xq!8V4m``;~`6a`#suOVNoyJ$@lo2<@ zuX2ib#PzbL*H}2G58~Ql!ogWOmxQYx>xZyk4uR);k>i@5-iK!;N63(DQM%suCjQm$ z)%7#~&>weYk0$-Rm|CIup~vmdy+0SmXK7rc7_YOqKYeX^w+4?H2glJqok{g=WPy!x z3TxzV3)uJA=y%@LY+QSz21M@7h!^`U#>7bxO4b@81etAV)H%oXsip0umdGn1)ATlz zlRIC3Zp>(HZi<0UJf0FXo1nQ&hZqUpt9<(ku*(ZTm#*Za*O8yZrL z#MhCK-<=#|n%lT_)SJgHm7ZnrpEK+~$;j>$kln$#O?O2GK<4`^IwI@Rv>FL@>-wn= zNgIfpae<0=3j8rtboo+~$7&Q?XN(M}KtPl&sCFdERrEoOUX{Aay#WQSr4Qc#qHoJ6l)CQ{UMMTA|8@Y;NETZ4#F`!^*$@cy8hz29{ zhazYggpHAOdJIZU=2@Wn&z7Q=i~e3fz;98=j4!1@HImA!WbaRBCf0}l{s)`jenU*X zc9~k>VyCUk?5hEWc7Pb_mQmMVDF4pSZ!5GH^CowfA7Fn|k%$hWQYbM|kU8IhK3lWU zH$I8wKU9ap(ndfGt0WqA;^}Dd;bjKiS1znmIMoxq6qdx~1r#IB7S%)#t$QS`2Op{^#%f!r zPIMVNB}^rAI4AofYzW8c53?#d2H=aJhD0p8=wSCl(t}_X7-aM#MmRmV6=t#AH+HgY z*RuP$e)yJS05vi*+~~pDxVcN z<&Y-gZv94AMbzQT>#it`JH9K|!O|Zp#m+MFRYMyZ@xryDnQyU(>X(|~Y~#ozIz+?)o~JQFO# zk5b^FA;~aXle*OwU>{Te z;t%k!2o+j!Zuhp|eaxpbx0gFjA04FXeI+gspeWzYWnWxxd_6xd^dm1xn(n2$ZFC1TgYz1xJhl#_P5*I_ z-dMNhFmsxgit6krdv;fOL2?X3e*RhX?u8+hxDe(5gAaQRMPzA6>cG57nig4zfvldBYOOf5FaX)Nqg{y#OK#Mj1(T_F}Qa}7i z)ZOM2E}64AcRtKKOz{O!$amICl53DIMZ~S`HxpYJHAaP4<4wP@+3Tg3B8?jA-8S7D zK9|`)p}}wGQxW2W33XUgKd~*CN3}n7c-WqoLJRbd7wuxP@t=VGE&<~V3*J81ZusT1_wGw4vTB?CdAY<`^`UXO00q}p{0I6yR zV*OCyM-TMgoe=s4_zvKZ@E+7%d^S++w(jFs6;Hqito_DZ3>xh=JTt#1#sb$Sp6zb_ z>jr54R(!bd-)pH3@VmXU-eDC-58wu~*?L;OMy1LLj?S#cYj>`$s&{R<1rD^m1~Jcf zi3{b8PJ8*{*JnJ>B`#cGC$sp&JWnxP$)2qx$EcXP4;E`Ud_i*bi{q;02j?E~rJjee zvhqOHRq8FzETC*9@12y&*oECS?R|gh#K${`Kg-qto?=aVqGm9_YQ=ji2Xqv00nvOH zaefQ*`GK9plk8viJd7_<+X`ga(P{N@& zc)axQqvqKj_BE-x0#1pZTEPahvtv*$7(wYC71!TLMBl$Wi|V)rZD08jtOY;ZNujr8b z?mGckQrX7#!9G6^c`Jn9WTN}|=3u{Sv4I4BqMd#^or+zZ*w4(|3y$N3dk#pVKvToR z=}=Q9quupU3QK)HQ}4n1PscvG6r^N6eK}B%M$xQU`^bpp>OoecNAr*}PiO2NuxX_J zE;^{l1CFSEO~Vh0uO9+zUXMU-z{-S5Y_8 z3VNE#oHKM+D#*mG@-huZ-9RR$k92Atw8x4<7QcOqv*ko4LpCWlrP6pqXD#^mHR2Op3$=cJ|&Tfh*dyY-x>#>hfl2(E?1GcQ6L}H9x4j) znsMjhP=E33neRqF&lI#jd5iZw;r4a}Y(FRy1O&uC--IVBUAnMv@O0q?f=XWUJl_pp zMi|pM+&E1J;8q7$rJy-r_UCCy1)PgTHQUARTL)y+NPnwfO~Te7##g)am&7Pvvd_#G zUG0=DZ?maxwHG=%qR})h2D~|&oV#k7TUQjg5sED(2O45=b*oK^OcKj`@zkng>ULc5 z1J66G%EGC@y1E~d8M34`Jguto21CjhWy#qC-M35!c@k4mhCcI-=Y~40w8*cFH+&_7 z@{sQzbw2dJ`>TYTkL#pvH#JAz*zVhb+S9!7)E|k$BHgy0IpL`Z+6~pJiQY3?U)?+n zgHnX|o5?saKD&-YMcsTOsO4FIyXdSa^pWJKq3#h@0;-AXu03RnPf3yyDeTamt8Ux+ zI>p<(k#yX&ELhbUSdPCb8DAoJ!X6<#OdwB_1e=%O(l3*cF?Vw+cN2w`D%RAgmBV_2 z4sQ6M)obe{79RQ?e#;+kk45De&l0%xK!nM!K3@al*jPHLd`4DkBUGLIsh;`y5Aek~ zh+md17M9oGGhpE}`T;?lM)BZjY|ElF8*u%3{is(rA-UAt-bIfH zmS@rt^6B{`TxlNj#m=dxZilS7AH#xP-e_5rSj;MYit#Uzw%xFPr*S+(?&NR0&Xt3$ zpg``|6e$_{_)O=I1xB?j-IUMgMxzpQv<|06-DT_v#$Go%+!ot*$s|N53yeXOC`sC z{#9lh1pYz}A19>R;0*=puh(xpEhX(`8HUsS64FEHQ|ZPR3nhN8kbg}px?Z<`t^Zrq z9jEIIv%cQP!IvTT;r**zem9wkJ{dzG&?;y#*aJ<-h;!e{y)0us1z(_t=`M0hh)IOm zurp)@u!PqkSESgt@^8kUv(})#JB2O3j`%Dx&dN-q4;!g)XIlkn^uZRs`|SYA7bRsm zeNO}*Q}i@!N7|UNU#DD?iiC$-DKQ;-g~D(0CYC%qkl8@K^hA&UM~(6r^l2w zA}d2w7`ZHp(xKrEs1@>@sHYd;if=GsnT-*wuB)F;Or`y0@YbNfa?pRb`{P(H%6gDQ zm#51aM0ySS4Tso9uSrwi=D(Dq;*`4I=~i3i7v^R3BU|yIynMYr3^U?0xHf}1phe2zN}7Ha*C zn`!SCm-Et%4acz+JK_e)azcNLa}7|BvM234vyn+;NaCJ7D`gvI2w1DwVRN1rHVfmz z6U0RynDC28cW=mOR_7dTucet9O1my@MxfY*@r*wx_nEwvB$qjiCGaKeX$M`2qB+2* z)Z&;%{Ood}U8c&(CiW73B&bw}W>$hr@z@M`x!2@sYP_LA$vR_jGAyTPtLBv(!3sJj zA}K2>sRsM3TfXs~wX}$v<{);w!vtFkP9OqVuN6^o{-6v#F?bpy8(lL8y^{eB}-AFn(8}~vdIntS+m^ODZoa>I?E8TpknFhl_L5y z!|G>}x+T1Z@b$g5PzmUlIBoa5d9TrL-4>%(8dmvUdIW4JPhQTk&pYe0pL-QaXVvXF zQv>N%0%7Ab=XuH9Rfk)>ifAj1v&)TX$?>dyJrhNQhe3j%H7z! z_@V-;>BC3QsNJk*J+%Yl4s&jf$L6&60s_2z61gyJ+hX0_eJ-|d7lLC|;_K_}G!W@m z&08+3q@^alJ!~dIHv@mVx17eOr;ZbL$4-=jJ70d565H>g$qicH9R2<>dHKd{iI02LujnBYNG`qg4<;17P zZrs$-ci;dJwgh_%1R7ff?sfLX@%j8jJFB#~^tR&_`?PHKxlA?YGWfpc@iC{s&Yg4M z7r;{m@ytL#ei!;`gcOvY&#zt~ubgRIY0FEjj$y97(f#;o;i<_cp z-ql5&cy$Izcia{I_Rt&^{TPG1)!tV>3+fA+EEB5#oePjqqBeyUUR#~~Sg z|8lHx&#d@VVq$*afrtIi@_LgW>|MjpIoR^ZQO>2$3irB#dM8$A7mQANMzCejey$&3 zai#g#>uACWgF%J2lDRI4RJ8rW!~zjdMQ8*hL+j9*3=3 zZz7my?dR2dYV9Z5bimzF2*lP~Dv*^=4!alGiejdp+!&Hlq zI?6iHe}0|p!k-#WpfFn+=96#vCm*t;+ly-|!{~seoK?B2E2}F5DeN(EyavVw9*-aj z3QmAHce#9fhXAJnuWoBp&yEIFv0+7;w~4cWNnJsG?y%$byF@NnqOQsnYTkgA9v!gl zVlo#>#d>xAH@{MT+P?K#d$6E$lIU8`w-@r=Jyp6BV;*@6;%eWc`1%?S5T4Y+L@%%e9;z8}WNvv%KpDo#tBU2-irgy~nEJyLQzJ|O{&Ut+hmyuv99z~x3s zZ*L@0$gtHo`!7|Dx@aZxzjw<6Ern>HWfs;)711vy9K&c<^6Vc(jwmmFG!%@QHae+1 zz5_LlP8Fya6AP3ZJK@C9JJ6jZwmDI-wA86G z&E{#Xo-CX9sMkohF8BAVH~nv7E=RU~h0gi!udsmS_d4aB^nLBLMuXN$WXVy?RIv{% zEm5jW$p#0BQL*vKkFP&y56a2QFL#~ORLKLCFhB8PbYThKUJBb3gZ_S#Bv$dF*SB{i zr6`Us@}(uAlq2)F%XLRt=i^ZiaG83MPGMYOk#bM3={%w z{TZQ*N`fN#29gt>d%d%Mr_vM|BW?JMi$j)QM6z%J9#T-aBf;}sl7JP=AU(^so2 zdDDc_IU(n~r8X#gRpx2ki}8zALv6j3=U9R}hILMQKUa~-$k=Hcm&#~yb0;Y>gP8lR zf3Ukqht1Od<#Z3$j@ zb<4h=YGwqqR9uz3qyB>f_RE2xx`Xxgxpi}h zcwyo52|w)T(R>P$+bVhQ`!Wc3RoK}pSnJr+$}>O#MHUpRBp#10G$9O(4m!E`^z&}o%I|XY?aZeH-OdkoC2y%s&j2r_H%tXRg}57Y)Hke^B74{-jf{ncI`Lop3HYO zy4~HK!p>s7yIb_r;1a^mrImQOO?7odEcEntR*FOmE0u%178@+blhQVa9|Ym0cP;m0 zI|_K?rqn`o#fAN*UlV5!W91_nwRvf7p!w!fWFAW%ZjY#kd8&7X%Vea|Q`1nFIzC)G zt^A^*x^TuIFMJEA_0n5V6vxvMS>{AFC8NBAUiE7P4xC$eA_8A(vJ36v&4X zV@2P>Jalx+tBdOEYNYs|gWd(G+wBz;P)_o(55CBEui^%5&Bd$sgCax^)uYHzvV;+f zXi9DEs>_+R5?T$bP$;j7ZmI;*H|>c@ zNx8KK63z?A%1OAGns(38Ra$jEBtlDfy3eE-t4Z9;a;GVgNRS}y@JYo%`fgJ<_BtZk zrX`qL?i`C{ZIEUzanRyTFmA4tyY}%@O~P0`u--H=pf!pH)2*(myzw5WOngD{Y&?fv zk6Kng6Y8L>YWJ`#C%3?|KXfUIM^_CWx4v4T33rn}zHK-qu3e!GeWsmtRX5udLaiPl zq__tA%|2C!{*fUBzH4*d&I-CIT?p9MJdk_7{WYH1fuQNJHIxz4*kI;WnVcab(#cg( z5hW0vk`_g>6q8SqvKGvb4%C4GpB@IOgR3;9bkjZ{rJ7lx3}%6)>2MHo2BkC{p{wWQ zM4+cvm(y;96IHuDmAj233xKJAuX;NJ^Z~DNtVVT!2>q4^?)r?N~Fk+ zBT_VTn#H{)S`@9VUZkJ;gB3dZn{qTXi&jdjqinG_TZBsHf5YGB_q7_DY@w;K~q`%qhaI9UoKZUU_zJk~14= zWqO<&K0Eu_mMZ<6T~<`T@Vo6jG#Qr$&@*eAS;^hwjnEzMR#T1XaT*e#oFmhM)*~e! zCHHm?mth0`YNF0y>@u@e=-y#%&0;viBY%9Sw+*c0;-%U)d*UZ-!3_DNCE35DsyYrT z#MM-J7ug1Ac*Y{{zdSLp&G3!l8um1j5%4D@x>&E8GBWD1EtKF4+*kP$jmj*W>CqKy z8r;RmC9gRz{Om#INwVS%-I%!QP_-G?KClyU0?J%hrMdfTU{+j1`Z<@%$+(H3Ue;?B z<$PXW-;5uXM`s_C*&r_Gp`3`%V>qBt=-P`|nJ;l#+Sra{dbas={U1bY63#ML*)MM2veX&kF zw_mA8OMjv+hUaW~dgFsIDO)xxm9bTEhdKGhVPFAnTOO7DO(*O2(`y%qWTZL&9qQU5 zyS|6F^|ok3(sMSyj??3-hJgiDz0{>esy&U4xVBaM$~5efZjPuGkpGyFIBhkv5zD1= z2UpItz2%i?I$9FFuENXpp71?$b+Nm$m(%LIlD4EqzHaDFdeswYXTs^RJDar{%x43; zB&yMwDkK_E<)nPpSPikBndpB7WDReyUCgYdjIA0RfUInL7C)`nKzLR`3iV<+`J_d; zq}lldJssbjt=L4YjiqpryCvnRB>hMiw6Q{^Q6l-~k#^v@!>`@j>Br?|G*x+RH$?l> z-L33?I-rs@9_Z)v*VyZMh1^usvMW$Q{$$C?W827!@@+3URMp+n+3bH{LZ-(H7?G%+ zK|h=>hA-_0T9i7Te>-XF*kSX+p|e)h3x=P3XK@@&ZK00XQr%keIF_9A>K9c!3+Vol z4ux3jZznDHjh-2dZ6r&NyT)K!<_7@B$^+kEObOBHk+OcP<(YXZBHW4RpF{;n3B7Z; zp}4ppXpNfb*AA}Y_mL%eYSxbKw)n!qU2eLlq2)iMdy=on1CchM>@kzu;^YH z(1cZfxI8ke_>LvW$ZoB)Yn38MMfSx3MG&rkQjafobRUx>E7)mdibYnG#@wWIN}ka= z*MV0E+=%59)kqj!Oq5PPuvS=u5^hHcvJWN{jjzn1?0c(jI*xXBC1#7>-LKIaJ(PgM zI{b}#ep1L%Vl^EFD~ba@m=yz#+frC#Ya`%7V61_;RSvuC{-tfvM~b`<3m>J*;F9)M z(f;K4x27ESmA@47ckJwA)^FP$P-_~MYZ}#LR_5~Dz{Zq%pPng$#^c<4T3Ui4fOC(Z z6r3(hms`*ZRV&nVTiD9^s@%^JrwlyRPar4vsK6xCzuOMd>CLz6(DXXK+75;m|Uid3*! zv6E!Un!v#ymL2vGkk^)PYmQ7_7CvDrcmU^(IQ6M|bg&Ujrq=IU#TCS~{hG6I&bHd? z`S0Ij{>^O;+gNm9i!KnKk@^DKyA|qn!)knbqn@h*YfXJUhXAw5%aup5cB&?LPhF0f z`=Rr;zS_ivOnq}agD><_geon{8FeL6ZfJujOaFLP_ zRW2H+ub6n}O(G#Hs{8%RZ-VkM;lG-{0|rTwUHEtK)y;6i#l^lpb+G3{rvhEtJ?*ldohoPQ^tkUOyysn(!*=Ow-QgEs zI#Z&QkhUpcVj*bfyva; zHH3N*;mBM0LtFzXFeVsJLqTPB-}c_?LaUEr*=CEgmul;4vIQ1!Lb`g47P(&MhTeH@ zj*HU;^@SDX2fU1hp&HAIFTMT2nj*>AC>}Y6+{^IYHN{ta+qb-ku`}Z|@koHuOv)I^ zz-xu`Io&&+gXg`};9P`^u7;)@6jh;erKA0PVa~C^evP!Uuag%nm{NHhti#qkqJK4P zT*|V1%2U0~{ZZvPb2CI{9+up5fc`Yfz*$?Ki>&4s=HNa0Gbh zy8FHH|7@zSyVp~&^|35JzkI~;9c$j|++ou%nL)}z2h<_Iav2l1|(v*N3b+$1K z686LvyC;`E=~7A*kEX1kta^gj+#@i;3opCb*+p@=PG8vmQHyBv zMANqgp=p4cHsABDZp!33Xk9OoYs0QCDfGogU!r@`j+TDeYAuLdV|2XxXXYv9yuOr3 zsUG8XmXJ&*%~8pgV7fLR=aTiVpD$cY_qLH zPxfB0>R`;(D#&))rqiwP5_pXr=e)O%)dcaFE*dmg%u6)0%@D}?NxZ0OYJ~3LscCvZ z)RZdAtgpJLSUIYJD$oZE!0q*%ko^>@&v;DE0O*6n;@TJeU~M+G@w@ z{CPlgpTqWtkxvz?uhlhPxb0{1hzS@=cH8=l-M(6zZy%;_HNX2NpnnMljKdg;vAd4T zMa0z!l*VtqYimX7|A;J5+ycg@3}jPX7y{+K zVR_Qj#aq8s(5id#Il3>=kFRp&D}=xH&jwcOkC!K4%og}1`VKrV}wjUnI@K9IP7N2Weg zmuGClafSF`>cL|{4RD}X=YS!0q~9Fyh)osU7HmU?jFQO{WIKD&<8$xwyy=j?;}+WlNT;*RW@NVZIt=?|IUl!Xi0j+@yLnU~HIO2%!PBR$ z92O>IuX3lyr!fHa^<#Kz;kt#3lxZq{Xz9egFmBfKyl$&A0HU!`09)hM03MzAjf63$ z>?^L-=TWQbAYtdYPQNmUhTo!n(0-RY#fo^LGNP#11GS>o_vOx49Ud0>kidLiF2ce# z^c-$V8}}Tyacm@Hh;?=CguWd%fYsk?7+-aL8f0tOOG}~^}@U@opNE7mU2Nq5@op%tTl~* zia?bRF}-(4=d+{N+U#)~!o4?d2P|I$t&U9z?z69e(84|C1riwFrI~|aoJZ%k=ErOr^|m-m(9{SkSGD(w5cEkl zd+q0fC1KQ#xZ#wNE?>hbZhWeuqI@(}UKoUw(JSaxRzbw@n%46Y&*u5Meo(}4`z@pN zZ>q!`ALu ztk@Z*i)M24H>lij+zxcNQ9r&3HiY)ywF?c9e2Ry@g4M=J-y<$Fi^k{njO$L*)Yp4; zOCw-hTvb+7G9LaJc?!T$@DAL(R%=;Y5d538zNFyQsl3GHe}KEcXlB(r5nt-NbEaD= zrcG8m>sKIku6;y*@q$b4Ydb(ADjQ z=~q`W>{TLDb@#?ny3w&CRAd~&ew-0L-P%=a4$g*GE`a#|i-K9{TDXoU(lsUTiMY3 zPuCo0>&NFb`F=)>4A5bd$uNUG48wC*)Y@SgjWo{F3l)V7z7-8}19Yc{9a>P>%XP5W zmLETh^z!2QJ~NeiAs|5fV0r%0ov)! zAwPb=xHR2IZ7|xlo$Gp}nsN1sKOjR^`OU+lUPt1W+`T1I$ z^QlbwP{b`d35S6}2;md@s zmre@-cy0U~JET`>&GRX%Z1?b zmK-_BrtH)heqhf7_BWXa*2izex%Mtzfr39)H=v1TjiQcl%PO|p3I3VN zGw>uhf3)oRa*}OBVj(llz<)slNtWHda^Zl}oXE^O^voj|G2gq&jW$kZC6kXyRbvdBO z7^gT1I5fBHdD2OOlA4_T7PI*wZZYHvcCtCIRy_8XC?tIYm3|)(a zay?}xjyWP$ACA}5@z%S(Y~X3#Uu=HUTQ1j|)jRcTkYNhm6U0?{-SYBbyrcpHjnS7tstp{ z)uwLskRaF>Xn*7B9j9B23@p6f$h|fBcc%)d0ps`Wp2IX7AH(uQ&k81m{3D5jgT2d} zF=*MFPR@wt4ha82p&@+fBxhH9d+H2dhh=PERBKK;=*zwlviNWT4OseRTY!~2I>7)_oDtK#Nb zh8Lf4iSML{eHDH&Bz6z_nOcfDvAj#hK8D&M(cOLAn`excPd)L8!qdoQidS#-nZwgj z1`U%E&m`pLLXho+Y6v)Wg-2Y#BQ<6|6JgfhHu=8eQr%u#eQUj?Jm0&=k*;wGu0zDq zQgVGBceu#hh|qF1N6*IhNJ&N8h(2xV_7bNw#ewf&QYmC={m<^5$#+U{vW>&<@gW24 z+6QJ%6Wq3Ax@pHV67@<1?h`Dc$oFwyRHaWI_Z>$mTDsVKQ%Cg>pH!>ijRc?NDjyWb zUfQhWu)Hs|S$%ueUB66Rd%UqSe@1|x)6N4F_!65bAN6}o(#rSe6GZho^{`i_ zO1WV_be7|`U7BqDUaO{jy_H@`&hhvy4pF&Z&+Z^Rg`ElB99gxIUu=iGqlE`%A3mGB zW_WGt3EAe8&Rbx+&_RBCTlpY(TUYh?;-^pC`rierioUaoA{@5GT8}v5;@D_&UuN}* zKvsUrJ~?V_FKn&eThh(y>*qrmDNMD@R0q`#-ulzQ1B%K8G5UmSrdADB?|1b#Rl$AH z3aKvj8DB*yLY=#5K7VQCy)Tx1W@9iHF|BrA^w|>P-6soVJGHYNK1Ioob{>^C@bEU`sCcyjpqGI<~{+EB)_WfH|Fr_WPNQDqenx>xIBy zKpI*{ib@D7s;vS z4xutiGE;jlds@!}o%=StO8Y9__g?YmFlL;pkCfZ^XgI3WP{AsJSV-@l7h;4|2eOiM zm|2swZb_T(k>@TqhV;kWD~))IJ#E#3c?z~il)q1|{Htm2vPu%)JwJWW^YLoIc3c95 z^IJ>`Qv%e$2>v3J?Y{4RNL}>>IcHt-{Sb<3nPm#+A~Ci@8;hQVcXS8uf3;W}^0z-p zN%6ex%zbBu&6sA6L(w?t^&P#+k_ZlqdeRKH-s%`xcuijJHpJ#IWbuV_o#9m9a%VEJ zx3%S&t+K)z8&h@e-s|V-msu9MKcBx$%W zBo#L3ZZ+aPxs1?UZ5NZ{p&1h-kGQy(A-_+Wf9^d7q(I-KY0;db8$|#CATx{9yXp3R z+dkr1ld@I@4c#jM7K3z1)9mP+h2s*BN-6|CTECar6meGNO5lOZ_qL{Utq$olWF3Zi>E|de@03RqB6k;R0<0$9?k@d0HLs>h}B>%6zX45Y1r`Fw_y55u1{fmp-cfYcDcyCdZp7C!*&@5-+b)P5N1x1_B!;hLj zeO39>T9cVg^TlPcUwyE4h)ax^RlU^@o`c2x9&-C9(dk|RVj4@<&;A_e5|?$A?AueM zke%jA73h+)sI=n;d6M1#zZkv$^Fsa^<9}@JA6xs!*8U&l zsejzjKU3{L(a4{P;h%_Y;mGew;(qcd2e;IV0%lhFdpMZRt zTu)Q`2XYfFMJ&}EE0T+OM&3C(@_0v1Xmi{QqDJRSS26i=;I!^eYZ2lB+3~N)nS5hz za%TPnP{}{>{qbV$H!-F;gU@U6n$kB@fxws>Wa-Wvpm;Q0rMy_h+Y z^zIk-zm~Xn<&UR+l=e3!Y@-w-efi*h=}#1QS7U+n_rlVRKkKg-Qh#-Z|2gTOf%xZ* z_{TKNF8OJw4wBWKUePh7%GJo(Xn# zUZ(i#eQ9G47ZqT00E7D7JjfX=zzjzZJ!JQgGl2`Gh;I^Jld&|dKmgAea&CQ>Ea=g>b}M&L~-Z7m|or7 zyGPMvpj8^`tAFAjGlTK-h4n*U47|dCI^^9xz-RC4s5>83vgmPpB37&P7wMP_Pj?rS zaBlYCEJKG8WvJiQR0w^4e(9WteVG!BJ{pztH&Na6oAkDoO^AgC_A&ZY9-g&>*MQ6p zZEx@Y9ou`q9(G=k*v_#Sl+sC!kU4zu{O;c;JE`P8DD73{`9HSQir?mu09dK)9sW(^huJQ%OxlaIOu6^k&FYs5&eSi- zwkX#5mHPcG_iLW@Bd;6Rc(kuut*_Vcv9wDjYqWDJozSRc(0VU{MM*|s+2}kj2e}nS z>4}g3j>^+igKFBv?!Bwj-V%K&RdWgt{J;6-MQ z%)zcIC^r_Oh)R;EBp6wFVtlWRtIra4#)PHfB@8-9&Qri>H#H+1gJqy#Gbe`*(W|Uj zBL;<3IiGmp!4%aPfb1$WV-WA~9-NFMuoXQBgDX`~Ei>v!QsyB%6=;9Hve`eH>7@n1$K}t*yu5}^pv*j@Au%-#&A&odyu8@*Z5)O0iL^b zRoc0lYb;rs3VUrezPoQBTaEahvJiAaJvX*#5=GkoJ;-d7gjD8oi|^QWt4;uCnj?@F z2E_{pvrFH1pE!QV;tb2qAr!DcEKX7eI%_d;m1gIB3yM)2Moy3xhnTQA^Q=;bXK_W% zjx<5Wx@5o^Xm?d6O`=pzAsIdycil`H3L;2evWoN71M?rSxGO7p!j6>M5x8?(rMtpf zOHoARR8^3I;w)n8YA_%-jNMT^!Qz`GpTWpoRwWI*_uSk$bfjESqnm50=i1!)1#;U- zpQA}(%_6^u0>hC}Fi|=?O}`j&I<~bmr=g)JOFNPVQmt|d%N!YHYLQTo99GJnV}qy) z*g1{+(|4dllP0v&mRoncE<8W7x+SP0sS2R!N%7L_M&IsTo!ff{VLv`W(e<&b*tK&>x-2Ficy#j+Moi&5`NzH?*5@36}*d@rYVM~R~NwxmHTIoWS z;3lfd0xBj*2_6he8QmKx{KJV=KxCi*V#8@t6lC@(FTH9hQ!`bL5!>h(;-CsmF933P2%VfABhQrfE*2AD-qeE zenC#d$K~;zL>L`N1%Ak=Rf&HT;inF9dwX+Y(AKAxFZ-F|z7a#5PF>F(_6-Xe2w><% zNnV8GWaMv$LiS*ncp5>ULM^#QVpcu3(B1J4my^}n8TOJt-Q)nVen~3!}SGrk6C2aZ-B{7t#g=q6UC&s1>iy{cTE5JW|>lM;bUjcVT-oH5=5HqdrDIQjeb*R;cTYoOi64ni{L@lb&p zUeZyY@=r*?>-S_0j)d~jHcsM=U*`h}Ve2NG%bz^Dd0C$8yra6UUC@ib5DG`1rtB&X zot2m)lk}{VGXVHQ1q%N+yJ{R5yRRp#XL}E8FBSpYw*9taA5Qa-zlvu;v@N}81 zjb!1JgN4i>ATGmyKlUyv2o=_X;CS8}Dm-@0K%cs4>an5S>txFLg)4(qyLO~-b>s}G zAqhVUd>Z;n{NtkuI}s~>RF;OG0IqysJmxTniUTeO#E~=D>G%p5am!tm zm=sP}g8+MBR*<>Fdp*{0iJ3j*h5jneSfnovgxY)VO?-g~nOB(oii1*^B3@#63YTXq+2t_ebb$4%1)$@(?G!Cl_U1WdADxait zzc$4|Z7Ws?zQv75?2ZW@P&euIb7cm|x#}ZF$d%jrSFD<|wfrES@9bX9E^lnM+$#-a zvX}P2X>D+;h(pB(H^y%W#az-dCL9{kNSN!HZyefl0DKJ{9pxoUrRm+=8a%oUf@E*G zf=UItbE<-_G`G4lyI)-lGyxyxCy6Agm9$H%0YbZ`^L%)j(})!ydV2ff#!WhlyR_QN z2r=A7zR){dAw%1q(n({F0?dey9t!+W3|U8+rdR{bQh@+1S-J!OE1tcD)#nUL8G4&0 z{LV`!g_cPp_WF^aRl$M4B7Zwc5PL-a7sdA`0wRP4)EI4z{S`>X;*xHgS56&XEaaV0 z+Xi$b-{(qgFFz^VQXrkoiD7|T<>IBgT1`o|yn|&4Ia4$$Hn<$T4ok-C7(*T9J8gG# zIe6+Pfqk=RD<6Ww_@Q-JkA6u(HkO}t|7LsuG8E`Ile(cH^>Yzs%A(6ODe6Q@r;Ac= zW!jhF>n8S&EtR=pSX_p{+jU9ADK(Yu2PVc^v2U7xY|9>yW>_H&XU@|vCFRTh>5ey9 z$3?r+Hb&rnH@_;+>+6E^Q+|ah=A$fhi*WwIlE2!i@pQ$QplLa!rdjIL0Y@_GYYT<4}>v->sNqEaC$7j>;Wgo&}#_xodQ zjYz4xXdg;PXCs5+jJ>{J7D~$vZJW?q*qYvd_!yOa(vfwB-c0G-QW`Zni!9&ZcLm!r zSXnXOEGcR(@-3Ir+a7XQ;h}Qpq~*Pn^|(vwdQZ=0ML>zc8XSb%5-TiF20A_$*c!@^ z^FB6FZZjK_`~#xuB6UYBz(}r3UYlRMA^z20F8}04c2tL1t;72s?53QocVrFdDV6xG zv~j~T@XLVJ2*`VUv)ebn>n_6`_WpK^PfwF%$w?C*_0Iit7lAD>lY!aS{7w&R+q9f3 zGh9V_;Io_br)F$lGQ!LX;X+gPoKiw3%oKD@4(ag@n?1dvygbbd-t4g(VlhVx{0OG# z!w0!B7!;=8GK9x%*D@^F7^2;?$<+1y;fa@gKEx^Si!eI|a7>cpcFha&Zag11j1)Ws zR*_l0f--*6dNM#+Z|*k)S?@8G1)Yr9@HpkQEUHt0q|?J|92=YZ&699)%cIoG=hLNU zNSMZUo!Qsr786c*{~r0~z^#*6Y~9`P+=M31X*Y}O zsGJS+$w&xtX8Sf!fT`gOnzA(IJOQdE8@;yuD}h98UEg@>J;y=&;w5t=u-<{fZHB zO6F0pzzP<_U^%&l=6a%2%HV||%`Qhal$=sQ*1MZVN{1&`#3 z!$$n~0=^_=<+;}Y$P?B$z++%-1|kJbP3yC%LwQ;1bzBm(qm6nTwkzM2qr&vg%{Gi9 z-fRiXhiY7YN5Zk+K}qKQ$K05MmI`9VuYgfb*>JM8k^gl2wcF<3wh9K)WMrn@89yPP z=#UH1FWdz_)k*K#T#hxoT+~TL0Fzv1wC_&sYl+_7j!x^VXLKApN!Hjzh10)hrs=JP z>=NCRVP;NBUH}+1_gW8|QNnznKp6=h=+-pw0M(MjO8(>{+gaE0@r}E%iueFPr7)r$ zqhsuI4BGU?kM5BvSMsMwMThyDpHWe?8n*@Fjf@S~xlyGQCz~NbW{Y1X99GnU34gl( zwo8RZfu_P8z0#r~jV+0K2v!k5f!bp#m6j}D2f0dYaYO)7VI*oI{a>OOFA)v*PVsj%{UD z;6G`86gW6n%s){G|JMq^|F*nDAp&EJnbzv?SX`>Ul|}U&izIhV(_>s*+HyZr`Gy9U zY$Z!q1L5F)p_^6`^qRms$>aafPzgq+ObEwA9K#Q}-e zqDmeh+!j|Dvu?AlZMo<((CiPFhHM%V@Rmm>P1_&)Rpk&p6UK$dP3;4W#jvU z^?v8sI9QwvB@E^7CgvU5{}=9^dvNpm}paO(wF=9~23gQ%>OYt1zCl*8ih5_tZ%kF)1ru7>k8jR9( zH}5vyb~Wr;rs|v#8#tDug-@B;dB-GFcvRTKQjy^)gEI<~=sz#BG5TU<@u(`~oSpGq zVtSt!{M8+{ER<EXc4Dnqm-ygM z0c19xVFDK=#4^$mJtx2=1|ZBNPju2Su=GzMU*^0~LR!l3jI-!whCHD>R-%GSeejN?aOQ@1Y}(NmD=UcUvdtijiwUm=;+H za)I8dahc}ToDD8E6917SAqs)5WyTp17_V}3w>A@fS5l)4HG%QD#KlvFD@;o?5vNB- zYcBcyJ0U=7{+$e!vpjri`KL@(pyq^uojMf+)gZ?XK?E`{ss!8>{^_#hw5Y_?)%&%2 z)C&&o4#oE<0$IW`oNA&}p`~hYWa7fHVkt(vcxs@jkmjQpOsi>b$ON4OCH9+>NHO;O zM>bIa1K>S9h&P+be%P6hrwwD@}rD$al;o zn-_zuJo)p-M|+C{rNj7X;~JPGfQmzo#f9BXKFeq5oh;Y{Cp9h~16$(0H>`9nG$;fo z3OO=gqRe4+bZl;3-)6I~fFilYGSml-6dK#BfR3D1hd)PONT)c#du%Hf)H_Q|4=LWJ z#9;&Cz}fvX>Wx7|r#!|;(4*IcV5lbc?tZP64*rP1h0ooiv}xnjGq2$&ykwU67$a`^ zr)(4lnWa)ot9?^J$>c{8mYw|27#kFo0`?$IPk&L~qHnXd%ReutQJ*^)A!=F;wH$@D ztl4rEX;D%ON>C=GDV3OV3~Spl*&{c&Tzq~Hrhxli5v#Fs=C!ta$!AY&uAPXGnua_+ z86q|mNZc;AL)l0=Z$6os^^Qp1Q9a6RndrpOsarcASv?r>FUj|cI;Y0JLB#cFKLhnj zn=>^pxgZ-;cx?dL`zULg$fnal%VtJi@Gt-Tk(O8S+?rflIp<0s0ZbP7RYDShX zvGXr?J?x0g-%jVDHf}5R_N~rKPHH?0-UtL`KetNiV1pq8Ix-TC*~2}xlc#f{a#;D{ z4&u(6`5oy!^{Y4HQ&axN%&pH<*pq<*o3?Y_iK}3+sM$N6BFA+S1ODhB2ZA8!w{^mw zDkGzQ@%cx0?svU9%;ciYzElCny;d!nBIIo=hrT+hutdj(n+*AuTDj~53)>*hkw^+o(JIIP!blDJsnkV2wqZuwyM2B27v^jll+Zi7 z;0W?7Zylcf5lg$B&Kr9gc9g8E6DMSW8&h8BX=r4PhzL- zhWG`R#C{!4Oz9jA)ppW6Wvna}N1zS_-ztOld1JH0 z@-Ft#kd!5<{7@Yn{9M+%+vCI(M)kHlnqs9lmo4vAAk!w*X|@_)gsOX>RNq(q*uK0b z>y(O>2P2xa?n27k`_A}j_QFTT%iG30+ zvM5z5V}5>sCC`zqDsFe(NXw!r+ZJD$Xl@FRyd-gV#rqbYUzuqs5`XqTgV?pwXs8X5 z`f!W#*>fst;NS$r{S9L+Ve=`bU#jt9fv63SI)`W^#YuzOvIs$k6ecU#KrvgtyyQGH z>stwKjIjk%w!~_GnKt#13WIazUWLqFYuw=(27eNyVa*jhN_O5(0tF)Ca*QT{q)K8! zpu-D(TDLU^_fl~<779r>+|_##8L)4i7-eE;?F?ia#_>vClx<9suZJJ03Qu5d%N1Ke z8$ptV%RLgj*=EPD8rG7O1r_3#7~WZaGLvr{4a@nXx1?8ti>vkr2m3@N?#*8&x3rzn zV8ASfj5*W(>8mC%fZ;EYzqe63(!b$YGpX-2?*9!X|KF0>|3OcHlAm2g+1PlFz;J); zXE~2geR1x)z(WEYd@j$0%0}aE^QZGT@K#^$p{C=-%t^(ITfL~JMy`#et?0%>NJwZ8 z=3F0U#N#yi37AX}=N%0Dtq`SP{lc~ElgZ`R>{i+h`}*rkc<&}sDwCIQFDm3W-yMEt zjMsAx`QOJ$i%J0B$2C+%rnH8u1SByv8N^_q7BD$m==mbxssBC_6_IbbvZp82z=zqt zCpA&W2QtA#8H*LEKB>#`zg>~|wX8`-7T>-l&o{cT$?ia{$Wq06f9TA;u` zDv-r^>+QMy+q6dX2c{yO)0eyNE9L?-DRCl%?*5f-yf66Hj{j^m7|@}y&*#Z#>i2Xq z5S)vQEY_hi)qZmnNS!MSn{Bz>iVKRnl$g7%1T^Crw37S1fZ%^%SN5ZG11TqZUH8V` zlf%>wv=N)Nymq`05vh%U2SdZy`zFUwQy!h>cV(s`D4)PIdDww08Wr_b$zf|7R zQkpSaf_%TVSXc#-{o~2V&Su{NVnlGCg#{uXt{_lb5_T=5mObU$PN|_jr8ES|P{Q+{ z-#|*d*S%NpJ6j@_6#TRE`i1VHK{)%lp5&w%{^|OUw5a)tz|}|WFFu~Z#nqOl>iA(Z ztJIqh#H`1H9=QvlS~$IE;g5Da$~~IIK$NN~a8J5JJ~m3ON{EC|NkG6H>~s2I6!S#N z42t+v?!RSRlpEjK6gSPoFzXZ{a^qD$`&(n5*QOvcIgY(dY+XOlmzu#1==7laH2F~x zpVz@*H(LKvg_;;yAlG$SB=K^`5}|0>FFIi7tA&-9XO>LtT_Gh~FSlY7doW>6pvCekbVCAnI#`?f-{NpEu;d1=s3GJ%pDlkiaS9TFr`v|o0D zG!QyA`_xMOp95ZtL=W4f9&$C$4<;|~@m!XhorZG!h3*P}3iA(lpJPwlbvNnseoy}o4XmLNXRGZr7g|?D{w|-`j?D?|| zs`Gcph`;9bw>q0dR?pQSjyj>HXNk%KG+7CjIX!NolsODuiW9^(BqJU%lLyZNvgKcMnhr)_^brT?76bqBTH@6{6wDb zB@w^E32uD^B>{n8iL?xszxUnTm>AJPNvywW;FP}MiDu$kJw zvWZE&{cTs_VrnF~yq0)o6@NJUdtw5Z=v9o%*OaYnYmzg}h%xc9%Rg~&_4(FKkOMXu+y(OQR>c{HV)7*Syf5?DG}fkx3B=m?JSHvvGnoEfIeKLi7e|d9H--) z7+5o!d(~U^qz~rgG+0u;JG_0Wld!`-U(m@+W+erSp3Ve+iSfoQU}9i=>bDXk7CLHT zdnSt37|0vzOh5ipb-qEHdPCqy!Vs|cuVWpPJ&PMmi!NHXBh4Akt7#%M?^)HD-hC`xUGuV>6TVEYCY4DX5Gnv~_wmlAp#hUPG&mUNa&)=1;_z;-rqjQe*}jWoW-C6| z#MJ$l62x}9>?bn~fvk3z`Y+7*0qn0#<2 zssCNn;T<)-02O>7%iM{~NfCzBuc%HL{G{&*c;f-|Xs+e%nim6z#`=+yjQ?vm>9YwU zynfkgvVb!W39~+iM)fbm==}8Cbww3CczsEl)K}xRzZ+X4Ee;#!*d}{S0`hYFzmAh65V}GM`&k+dOZM zs#S}y_HECpC2dDXfXKh8sVxMqJkQf>kX`GyC#i3JD+%9GqPSzj2^p;|wm!mWe_}i=6zQ+Yf7K}qgM2Fao{>Hhm?hpN# z#E{PxyU*XG;-uy)_Is)-+E=u_phqiBQ#1>x+(00Q??8NTrx>pJ8b7PmNQ~|KiP@!l z(TGuVzpd={_SeZ}j61?Q1uFa~0^z5biy}=GQz-|jkcO@nht`{O_XX7K(ASI0-$ew1 zHx|hZcyHGdA6z6GmHIkA4QO^o$I$KrZ#G20e=WJTAb$ zP~%qY>*pVJAiqm}nr&qQdV2m4O$Vty-^-Y~|3u5h14Z}0G2i16nLvOi@9n^Ti*7&o z=2&Uq(9?d)8%pB##|fP8`@KOT#u_TWyLmt6sge`GHRyXm{)k^rcfv%$*}vzjsN>H5 z#oJN)1yp_1iZxdEfbchfJ#ymrEBy4vhX#vd$2PLEky!gC$pA-O<9Bc5cudB;v72q< z0MhKz*!OOZWdBdZB$WSv*qqjOVts}!C;2uR6xv!2*Nm&zivB-H|M0oC1L2x~=z)8# z2^ggF`AoWiF zUox}=2O<_;swu0uWXrs@TzW0f#GW(bcYkZ$M8k01Nq6G7n4807{)MO-Sut?ZPX!{| zi(`(oogl1Q=eADSvf_ZG96Z@Ti*?dpmdp>aVZZJt1V$1w%U1HqWhFM5_}i5hN8+gh z?>9Cp5pVy%z{B7Jq^QVJX#3y#gug>9rpPWx_y!(7~C}FpMGm_A-Pkv(nx4yEW*N>aHSQu_4TFkX>kv)6+lSbIGFOXek&Io1?pajCr=3&eN1 zzbmnGhb^##PuUC^O$irA^smEAsqT#ysf%%L#HZ^;`47F4T?~HU;5uTP>f(S%*_qu* zMECry0pMeZB~>Vf6`$W&>BRJm>s>i+k*HbT9tNo;bZ~SgQw!-gP|Tt?4x@bdIe!jc z#&I?@${f)*VRKzhc`ZavN})aynB!EqMtl<<>g9onTP|LR3KdzLvs7^&O#IjK;Db(x zTkR`%=mh;_cwcmn$KibI1#iE0LLl@a;xt|Ng&x{i3=~-x!I;Xgi2dZWaTK`v;2q_I zRi2%h#5begKAry>R(f7sm3vi!poyO`0y&qhy;f2-L&^k(v zm-;%9AWk3lGWVKAd_>FF_%HNfqB3w=l0_Q&c;7m;B~g}LlXA*JTxseF-+XcTE>C<+ zpT6G?jB)RPi_1}+W-1Y2Dx_u-QlI~w?qB`#Qvi5JXC;kQ*MmIa#SArMc{j2&`$SqL zo(h$BRHLW}N8c@>^>;p5j>x5YlU8rF#M-L;pWI)t5J3#O5_sUgbV=LUV#d&LiVea( z3?V?6yY|GoW6pBM2D1mei1FaaO1W($Sq_Y&QN4dQenR}Xo7z?QAVqu+LP6Ex3d&= zp_QIxicrVqBwtv4O#5#fGn=Ax*5|9|Guwht4%Y>5Y*hX~;_f5(KW#^C3 zib^j6>9m&d%b~FHtG{>vPE!Ril8h;$`+cz^pIzla;wiC#DmJdgfFi-xZPC7lffJ#Us% zXhU;kgzg5%9K}bvwjUwr>&<-gj+pwm|K?x+C4U2%jyMh$d~wvDa>I_x#(wc+dOX?f z3nEEolMtoWlcMe`l)1Iz)VxU*Jh2{38CE(5{58lnAB!+GDO`GjQBJN1PZhNMTjx1& z+F69vIp(hNwOkovwl{!s%-rRoBV#V>mPOp1X`>(lv{&c61_MUiIHB@9^2 zQqa?Rlh2Y0GGV}k!(pP-_J;X<&$qv~2!1`x5rt{#A*vqA3#4xo_pZm{Jfmm`KhAr}G3qFi@`Qcv`T0XaJYkP02#B0*V zpuaE+btY`-xNQJr!M*21RmDq(KCk&aWKusnuV?jKhe?DCV!nUA8q7>|RInU5q3W!t zwLsPgqKtaZYKXvTrCSNpSU&xeU#~teghiR|HNH| zPd|gd5(FhP(P4obR9YB$g|O>IQ~%@hJ8G*FKTIWH$k)__;qBXT@6UHUx1-+i!Q~Ul zrtJ5AHRPgSKfles@{98IeEU`8*DzCnbpe;V-9fWb6C`xWw*&oi z5o?x@UGPVF{63t(FW);R+l}7@U?WZ@BoK@!vN8vjPuxTLqSu?BiQnAYf&|>%_j}vJ z)Hqf>2K??i`0NiABcP9|{x>h0q$AWvt(ffwXZ!4L{DxdH@;;7fwf6oSA|j0z{ovN! z;5VPP`=g_#sTzP8ddxMlUVG)lnQ_oo7)I>t7Wr-rsUVqt`)}O-wtH($39o}Dmjd}p zfW5S|c2u=IrBFP67*?f<8A^#7}3gO3KJC1_*)o_EHE zT+AG)A9TC?a3B8{*`D^hssQjUjvgz62CIcZ>_U;v^uCrsI8a zcl-pePw^iDmHwq7S@Gw5GBpM89EJMTXY@6Sd@a|oT3E|>r)XN!jwwr-FEI(P0CfF& z9RBiFei-eHpSG=j^a6f0D=0cEJQzr8^u3xcW{j809vkAt#Mc*8UJUFBDw}!(O=Snv)m_P|p&Wxh*u_KCno6!mr&Sz4QjTceCKmuFT!2Rq_wy zw6`_D?H%=?DF+3Gk(=AG8sRVX7Gf=Je)w!u=-vM~27fTYywV*0 zm;8Ca57kR#i#P;hjuXgXaNd>OQ%oM+GOQ;iecsce=JLq4EL6kMBV*B%*mgPW)J|5N z0x=bBd|cL3mud&_YsnZusRG4qQD2^Ra#GmGICX^Cr-qUVbtRRi#)Sy^{)uiG z3VnnAYU|SQ&@tRIsKX=WBNW2`Iz$pwAMj!RZcd1MO& zrQK@bU>c2+&GkNQHd_EZqS+`W>61ueV8Az7+(@DXgNMFKJ6Xs@63 z?D^y=K+k)dd)6Yo+(OP}X4n#|gU3blmT;UG3v#>MEhM4-H~Y`JO}F7xy8Mt0;I-c8 z;}DgU3rT!k6V~JZm_1rrEKN6g9!i9V=G2p4}esdIdW_V4Jaj{%E1oVKn1+LOxWZdooKsCBAR!qmV z={~fi5n18+Qx<<7`c*mlCD#i*D@QWCmh4YD^wKMq+{uj%rIT5kqa*;E>z3gu$v`qd zC??*4-xu+Ad@T%vZJ|Z8a9?RokI<2%{_`(PpzizuHRF|(;S2H}URI|+4XT*l8>?QE ztbT5$mJijm)c>3no?CjpCj}R5&v?9YWC;u^slpDMmM+=*N_DYjthyRw$z1 z+n(OI7^}YcdW=XRVVqaQ{~PpRim9tDGjJJ{Lh`4JkoNNzRq?zmiia6n59E;@q)0&A zP4?GM5*u$bw8zEAF8+Sz*>{i@9qtBYPixZ!z;b7l`=!}oVYzKI#M{y7j&9r(i7^%3-e2oc z_Svw_#OKkj?UR_DpbENj0-6i`<348pCx4^0VussN+VSn5W!2GyBwp~Eb1a?#*mrjs z$NjB8CC3_X7o{dW7~6(g!*dQigf8wE2%YQOtoKmOkFYS<(4}6S{_az;yliOlMn#~I z-DXXi8*k&~tBku0Qx)efW%vy!Gzl3_8ah83{b(`~RD*4JSdLjiH|{cpq$AP-@pR=C z(DQ1cb5t3?O;b6A?yT;CPzkHYQ}3f zfIa;NYx_swV1z2(H+2KnU|IVdQKk179+SZ;na;)#Bp&HfN(;uqTd08 z1l(!)rOK`XgKCa^jH%ClF45QIxkUUB_T=>XV;4WFSmKp`!FSaQ11(*K;2qj%S?d6P zpuew^^oshljjnuq{m<8zDoBKCKH>SyZpB%qcw|C}=7(uiy-nQ}2YhFZ7ZaYl)~d$e zm@5e5j?j{N1H+dUq$ZfyUOUg9KRuycrT(7l5T4q?{0~;jV)s@m{GuRYJ|?^U&-47Y zpw~YFDxz^M0aK@;7mqnRt}lzv-V9k3RTNzNPcIK#)Z1vGLn+6)UJdKc>Af*ULB=Im7u^h)uO?=L&Qlp()72(@2yXsBTEFYE4(*>Y5X!n83)iwA?9Dlah-uJr(gdF#?R^dG2hR zN;En+IVn_X-RWkpP~gTP7?SR(?ww_pR|Bd@ zWBa^^6=^b*<>)zG#iar_2zb=odn)BY>RHZd&rq24U)y0Ca3tOqJ7Abs{#uS1bWXbqZ0qA7 zxFqX~Z*Yi|qQC3Zs+DmaxmoMw?xJjAbpJ^lgmMCRHAC8(>5IU*ip!dkjQ2sJal_Us z=3>B0qkq)=d-03Flh;TSNjkEeyl^qOUCp^WAF<89MP?pqDOH8L8a8_ciG9>0*+S_h z51qA-k>$?$KtFar(7f*oBk2QXihKlsjlgyuDnQlSIMAm`G(8J`iAFHUxwtGG&!#$4_2q?6s#+bo(1iP!h6KE2T|s~ zG1v}?eVdF(d=(-*Z+WB*Rt%>T_P%KOX6!K*c_o9CX}@0VoI=LO+guGu zsB%M`&Gwh2Xj4x9YC9|}jQn}5wCHtYuXKC^H~wbQC#S>dTiL_zEP`;B?OIrV(z^y0 zz)Mh7^G)^z=FKTa`)#1>tlQH9DORMe-!&KgPwxuUJ#3edVzY?lQ}xkLbk=p$%2ZxI zE<(caoPdwdwDRc$SudKOk^vbQ8r%&_X{Ak!D)6c84{<414n_>%rv<{qk_`SzGjHRo zpxYtpgy%cTOj`DB2KH!C8a*eM(WwQeYD54eEb+PEsg$Q^QOh=UpTGlF``JuFTG!@{46D0h0CDkCpWuy(qe+HS(J=!d+oOHRDtK{^U@BhwNPt zlg9l}%~hw~W#{xrO8b0sz}HAfz0ex@M`}WmECG$1f zWGO>f$)?%Cd5S7+#TLUTSwvji(nsxzEt<}K$T$XIP8=vtfEHSJIqc>#b1GJ6(G6yMkCN##+{2`A}nG*R*ynzPdID2r; zJ5&Yre_m3R*S*nFghi_7!d`WXPa>{EgA~I`NGg|VS;=NFq)lWih8ls)NRq<`A8O_f?JboS$G7G6(-@s_?T(S;8=wd%QRv1oB}ey*Yr(bv_( zFkW1~Hge3;``VsZMSzrJI&!fq0aFA4>bjvv399BIS+)>kOofWJLJn_|ro-n0MY3-L zJ%6Oz{*Ar4vE)>Xjd;}A*(?F&JbovUJ?oC$alUKyFd-gOLh{Tsd`3pz`%xsRxE!&B zM5MgDkXNCi{v9TE-~Ak}iE6h;m-Q80b>~~$05UX9*_*F#mlQzt1El-w)0lWvBg z*dH+aw!gmEXL}bphlY*{SXn%JwGVok#=X1vE91;*xV6D5FZ$laEaWlC`xbQBx8&uUvItrwrON%bWg;vAEnjYg}M zh*)?eZxRJ+|GcX81Z0{nlMNSmLvI5XV_)yT2?SR-*k+3PF&1fEn3*mG3#j)+FrYw4#bLIRq4KePL9- zl=AqH`!}k3e+K-~9G$p?D`{ITDd!I#gdinNp~OVJ`fPz;Q}IFn_fDp|;f}s|$>cZO zZ(tc-&$);D2yI%HVs-Yn;scDge@jgpGyBURj7kD;?`U9NhEE~3)K;>exe0i2cK(l| z0{T*$ozs16UzU)U)Nny8r~hpfArEU`pw$5r<%7qphxc&oB67R@3|lK(+}EO@N6>%s zjJ$M*v`XpYn{YU`r$9m3%VeR=H3E55e`n+{_QZjQpF8;wiIz+;(fc;o{MIhcLDtko z)@dQObWD0{CUXJ*eK=*pfPd@9{f9RL!ZK&zL5YK>na@gRPKBUrf(WdA6%U$gsb8w^ zqw}?Rbjd9K%LN?A)26h2|4t#;WUGGktFyD866HJ#%r^0RM#55s!^1gR3$1v4@W*il zCaX?HJTkM)4_sm2nP*I-shbVmDagaCl`~q_5I=!IK|3jm_BsgO|9DP!GUD61Z(Zr{ zzgKS^*TOz;#AeO^-IG1#vu#aYZym)V&{kxXa_U>vF7n)|ti^OZKPOP)tXQ43OeQE9 z9`cb!0^#8H$&{!O{*T@}ARXr47YZXminM059?{I)I7H@_7L9xili8#Kv=gu90PVNs z;B<5Mn(xckJ!)S=AYCrk>FsK~8#c7r;y_Usn%-zqUq3^tu<{B1m4&%e)uo91APG%> zh9>K8u&kBz{Cqk)wb%97qy5!(41B};0}k5|gCFY;;O78-_>Fz*=_Jr zqD3{%V?n)|0p+&)0|Wn|y66{BTantYqo673n{QVmF@gZ*P{dwd)$L{T*#{YNVnY;7c-)I3{OLl7zK z_x$4~-O5TVqHwy-O*z5ITFv8XCKtE7y*gJ7q(IIoH!574HMah=a$W*=S>$)6D0_M; zHLLD-`Jg-;ZBhN@*MBUm`-@}j#K891Bwy^(CTG{{b5IhG< zsGJFL3N8G~Kj`?JE!(k{9tHLE$KrC#hfn-jdRsLr5L~V^0XeUZloxF=w}t3-0aB){ zS&LQwzJg~K3T#w1lZ03|R}bW1C@p+;ca6C?Gk{Osg8^g%1++|_?0krU(CO@io|*rL zvbPRvE8Nz7E6`E_3KS?#ai_RLTilBm2^4pCFKuym2<{pv8Z;?Tpb#8Fin|4O3*=_4 zwb!|OpS#a<_xXS3OXfG{^JV8)DT zX9FVFJwejDnGx@c!tvdEGaa_epHAb~w^(m}JU$R&x8y4w#uT;dK6^{?rcd3ZBC2?o zPl_v7xDWkqo98d^RNk!;8FOIm)iY38>8`T5qzEXi_Pq_iq)3-k@m$@0YCmW=o&MH) z0WzP)`bP4~=>>Y}PmIvvU5%a(vlxRO;*|ua^4ecBMg@d8kvWJLzr#4Ouj@=`N}CPP_X&(hO9) zgWMhNorSesf>dzYCl*xQ03K1uT&+B7H)3OXx+HRW`vEs+$p9O}~ z{qXK2c45h0yy6_9^Tz%M-S}wnO4V$^n>Ho&Mg}IHHb}1dE_tOLnM_4t_$pBv1CtS5 zJk|YQ<{rbo1`4EukAsHA+L~6~o*O`Xr^iO@Z@=*zN;M-q2(O z>PM%`#bi33VP6cT#1Blr=non>8KyA!zSz$aCcFF1c4vDXL<4*i{w5_+G13j;&rK<1 zEB{ztvYAIfBa%ZvCq0JXilHlw6mmSYGCQu73lL*^0;loYIUQB6)HFd)Ru>#Z{)O>D zVX)Ruu!-?WXOp9?6{!F9;?j!U`S?5<5ID$dHDPX_W&dN?IAWU8+K6x6Gajk~A|g^I6}1`&)?F9+^>|vHL=L%yofG zaWKNwo%v;SeIR&mf8HHrLM|N81@lHP1vddHYUFCgY%fM$HIMviay4s&=adVu&^Kxz zlz8etqpxW17L{BC$|M0gmdmgs*Vp$9YcmT~Y_2?90ViYjy$>xj1fQp-TrN{3#zZLx zzCpx|IzTRes&~B$IH8J{0B@gfbv~*4<5D1JP1o^chEqU=V$zaaN$KVKukpl#k5!sn zI?HCxaBdNS@g0q~8sj~MpKd<~YT%Jeilm8q71#wIY}FB{FEe#4I~Qz3^2s43g>{^g zaC0P8#25sVzD%UeZa2 zwb8h=RO|VqXsA=?{-~iI5Ieojs^300M@c;jc3S@=k`C~!2vSjzC&8VMIEY~o4LI9# z2{4ef2g(?`EZqDMNU=!Q%lvee@aGT9l71pnT*vK-?tmGq$j#2v^Povfq@6?Y#}dt! zK31t)(AKK?%A0 zZ3EgDKUn-ob?*t+ARO-_Oq$NNrnsxy$XWISOL_SRtpl1L?9gq~tZ8`RZ#aVFpkJRO zT4k^7!j`@$B7=49AeZBEmbzIvGaMqmA_OR!`O>KMc!PKLj@8wBi3W6=kG#tAj}Ly8 z$Zu}cch^%m>-_bFa^Ur%J`qD2QLA&cXbG&UG6!DUxt)%$Oj9uzT-jli%5R@k#W~=` znUoP$7)bhZMVho)m4>tzj$7Axd~@AR=pIv15XWsVFMl&aNS5k)8NI^*eXSDf(7Q;( zHHAd(@Gr&xa)Cx=TfBTiA6}R*QV~7>jX3(U%(dqzc(?NY?k0qd2$UmmH_rUD3E7*! zlks+3r^%YnMAT@Ma7CI<+%m0vT!Tlc5PtS1Zms7VEnw~QNY1s>CHB1>|Kso_A@hlx zCOzf)LHi;Xs&`7Uc6g~mE+~MWOQa$--#6jg(VShr>shn)tIv@A<=>;bi|fjYHh=!2 zKL>*(C=RQy03=V&9R`Ee4i1Il)c02xcH=0@e@2d4qgX+nr+l0UCOPdK;SPz(FTEiG zGK}`dr@W44pK&%C+W+d4Bq|kJK2K`7wH6m!U-zbYO)XW%J=+OosI}{wo%+4(E<3)m zf_lQ(Lw{5~HND_*MnT~r;H7N>SxY>j8vc$W-I;?cs@mLfl@&j_&9(f512tszt7}0{ zB%=5T(m#vKbj!I{b+F&o*djz zA6J4UtawWaW{BpPWK=j;+Ll+i1EjxZ&EX4xjp5YoztGI!df#tbgPn5?`g(89=c6Bd zo|nl1OirX)38iF91QCSB$ET%|2J0P=m%da7N@{zZe6rfqVTX25=Iy*z^rPu31s+aY zBE*3}!EUV&UO5zRjNsCoq58ab-7TR+a-*Vv+;gjgM0aaUQJ4^a-S>tWeW7i-s~iJ@ ztmAoM$5Tb@bSI0qq&hxKxJ|fEo-{cXgL49c{D|XsBz3Q9Or6`K<1L0u2gr zu-cTo<&qT9u>SthtNHm;qY?!5`!%)sVjea}Gas$R89vhe^?X#}RXySflj>_K!YlKBbQ^xZ2H4|(JC5x+)WG!4L<%O7?jG>d3`>jf; z<+D!dbi0=TM$q!d&qA~dtWSE`*Dx< zA0yB4Z*<{hYIy1N(iRL80P*XcDl5)P&J)h>+YP$GC0UeCBD!&N&3#rcHN6>@D?=tb-Qy1$=)B&g+`TuNq` zGc{S1*Y!(EGdwhucmFELBKBSr*W~r{%!Hf0tjR-_*rb&(3pp2k(Tjg;0ddSt%NwjH zSw-Zw7!5^*&T>Xobu9VquX%;T$3PXzz@?YIj*FRY=70Y(A7rLZcPX=PL|(>IP{+Mw zhQ(8YDg_i*trl{54Eo?kAkfywQWgE9As5FM*>rCyXJRJRQ^+4iycHQ>4n_nFA+(CJ__n zxmYjAu54>uc}uG1U=oOvw|Z>b;Dx?FXLgb0)^x1Cy1XUM9{A+m^`*DzCpmnK;JesG z(*sQ6f;fGjIPjL0hXxb<_2c6?-X0X~h?^gBxysD_1f_%LeJlrNB3MXsboGAnKN#AB+Z ze;)s4cpjDFzuwp%utcAg&~{@|0kY!unyRmr15lW5XD~}0L-NILyeUZTYBq@0cuOU+ zcyOS5`e=kZE`KG_bms;%}X!a2O9j3c?z7q3$>b&0Yx*Zq<78?Jms-BTK-itc3+ z2?VCB&`)mlr57KiuOnZy8K@IIDact2(6GEE2Dbq_I66i54~iF$s2(MmRmJVq@YB-q zdUpSQ>u4OK7RG>yrGnaZ$4`t1eCP7E<}bFI%M^O?lyd^y7*HjL@-*(m$hcrFATVG zDbcUC4W#LayX4xO;l{9e<=V3+n_juserwa@x(6qjI=2eEbB01*Cy1x?4+L zhIVY`C|6jZ0`0uoEy{cHx69N!W;^`7a|rmpQ6te@wE6rt|Lcce%olPtMY=oW5Y`!&G6Wv8k~?kG4C^6R`u0@6Z6B&3IS@>q{e{cFm8 zW+VhXzm3C_CH#lmi;WQW|3Ge|YIB9a?e4a2X{b1_biD7s&6vsN3$&_`m&NA1I}lRm z)K{29T#7Ap+Adr)osXr1Z-+VjUG`1buFoNRO}j#ZQ@i_KapwBrk83C{7%}d3QFJ)G z{6=?6=<9$!iAk}mKI3Mm+gZPD*xX%MdMDsFdu<2d`i8gE)fJ61)X?)-+}PEtor>$+ ziO87KXJc8%f}cN!MN}121j@6Imee~m1P3}woNep}nZp(dy6ASQ0+-NhAa8DfaQypl z0Rh3ZG(EhSNNPb3mxOV@yS9D%-2!gsOFSegmwD9R3w{!=)wCe|IRBO|xiH6yvshpEoD+!^6m9yCw;oaT5nOM2t>d;nmDGICO<| z@1n?!Wi7xNZ%0$|Mc{v@v2CEkt~g$t^ko zOqn^|vQdh?Z8+h+tUJ_vw%yWlt>8A2%`n)4i;E-QguC5tJ|ZEt7UGCTiWJR(dCh6s zjAJzP3=C|OX(L6|in_x;=~z;IcM#2~lfI3ubGV&o2e;Eq5|qnlzPpZ#WCe#;(39`( zzKEwBygmN%OT+ejJ(RmMqjZ&K&frO_t+fsG8NWY#DjKvyr!xvbM>c zDL_rUVX^Z`Tq7w?*Iq}hi-Mk+TI=N~3JF@vE)(!Uuk5yT_$hR*Zu4Hl z4mho{W$!gpJL*MDo0WF})M?#`^gX5d+z}K^Ol<(Xo=AGSIUH=UVa!jH{_FDIb8*ia zygG0YXgd3YtV%GbEiZ4mV7}A2CPVU%KE17%E3+B`Qi#w3OBwHqp3DolkL+tb zUQ$bG&2B;-DDvqBYV8aBtZ_L-5_59^n%&Nqj1BG#;;1X+N|_`^`o`8O{QSi`u9}v>DDY;@MnJDB+0yj=Sj_ZLkf$>q zdSWqFy{!BW%*w?t(P3KVk4BlnPQ=*)wq(L5Dll=Fx88`NMX3u&0qnR#t2=8TcB1=W{N5lPy`r zaU&Nu^JRu{YQp`Q`{|l(poUuF!;019HF%8%Q?ER2;CQ^{%`aq)5e&C&x?xE z^Jk3~O%U44TX8DM-cge}Va`86GF4i7)&9O}gaF{Js#nO{w}HKty#t&TCBTlWjnK7E zmg9BwL&e10!|Ky-qTSq|b1Cza3oNMKZvIHYoYp830JHhb%DkCTxlGKk)saDTOkK?g zfQdX5!DBoSDynCzFwmdy`x7t=kl&!VI+^796~Og}wRxpJkavXHXiO!eTBiW{Eql<3 zO>%*l@PLm&LS`Z4a-2Z%!$o++vF&Wzw{J?1qjEdG!0Qzy3yLWP?9YKEwyvK2^$l)H zscT!cMv0Q;{(|CMG}kYmQSb^Z|7{d~>*yDi#ySY=>sOPR*dtahv)A~l>Ln`{Jltk! zTlsC+9iUD#9_CK6PCeta-7aqW<3Y7md?(l_+(S@yc0$stI_@%%s_xtA5ronw&->i!Ky1R4w8ZRc z?!U=8M&YXxlP^4sUQc;Wl5JbjS1c}PDG8jHe}5yF*=(_an(_gKIn#Yq3V3erA=_FK zmU67f>Pq0LsAyopGQ#?Nak_jI=46{``9^U*25xW7Xx}tWaP_$;H6%J)X_zHo^L^!v z^Iq0|W{QC#{Uw(@%ZOLg<28{%7@JW>3a@*QkZnn;f3x!f1X}Gc7&q_*=Y?~sQmm)Y zo5w9KPzPB#{>u9x^_s2x3DU#4ES>a}Bw)(@#FH@nD6MC>3Tn~m`7qgD(Ll=}jq|-0 zAER*?M-`WNX#=CJJfm$&(t5E?F

    =dTzBfWR1?SY=XGiyz7-i@H^yrO|wF$nzOX4 zQpbx4gwuQFs-|alkm?S*t@tWn_zYdjf|k;1b*#s(wwbEl_s)lTQzQ*;sMX`PD zjjEB>DYo{>@HdTcIfzw8zQ!({+)B~N=fD|jYdjw#k95pgu=nxoCtl&YCM~gwO^yp=x;A; z@jz{_RP2Dr36#`wWQ5*JI1M04UD8V=t#|!fa|5iA&v}JNELIy0+Be{hJNRXX1536Z zjD!;-ODA`z&2h{T@WjG$=j7BgnsBwBIW~y__x(-Jb?<`S7jYY#789x+iFHyfd9~*V zu+-I;E3QX|<0A-34jTwQLlO6G7#-pC=WBPg#wDg%R2v&&Si~E&2o%c8QqkL3AyNsN zP4ZPdID6OJG=M~UIgP04XF(ixg(O{D9J}Pk*VV46xa}RHN-y%Fieg-;BSg|5?{+3v6H?lf z9_rp+^xB{0a*08xtRZ^#{m>7i%=$$@`qPNCuxTj0&1ORmpXWDJp%%M zK&pq}f7X%t$>scPp-fx?A3_~cuV=7Pi{&xOt?Xoo^(4CbMJ9yvzqNqN)J;-dE|my% zRh`we(!zPVz{_*JK)6pTVPrphN=yI#^L}a}3aCxFBieC)55MvDDFOXzfA30u<@An0 zS_I9AQO$2v+vMaa0u}xHc=*DcP#O2Fn#|bXHcw**jr}wQHz|PgT6FABOY2RI1##tk zlhrweECF9qnvXG0bKSu9L&co|f)am|*?MSwp?pADSnepNn=#z3ZzKHNrxS7@c%qMWpaU z5`UvXf{#rM5Gs9~yY?4{!)&UC8I2CGaNBFyRF;qCLq-ijQ0K{HzlkW-+ESiSU(qZk zGWDs_T0yT-u0h@*DEgfE9#K%|%01AnuSfu7Fj`5lIWV~T$X{c1z&Xhk=qYRPqbjIS z#qfy3Nf8~makWoQ<2^z}+VBpsRP?7e5nh{cTbC*-KnHXC)O4TlR)Y{U zRm)M!&Q%u3`u84u?*F_p;mM&NUvi3ECn%PjuV(4>Yl_ASKE8?mH8a47VOGN+mXGWU zP?UO>guj~H^Wc$w04FAp-zx6IQ^`%P@r3KrB~9>d4rRuoht6!? z;cubh{Akj+T89?f1GMHa?$h;Xc;Bc|C<(+jYR(ve+)u?lIx2?1GP^ zQ0-D;k(RoedTjaprD`H#6!*%R*Zf$>-&L01EWqz{3|!(%Ercu)7$P6+(4Gq?-qrot<}JaZr5kkBN2$7V z(Vw^IW?yss`oyarx5XaZ@hT5}vU)DHs+1+VF#{GHj%Hst{yPB{wY0>>(Bf`0cgixP zSO3M<@TR?_rEKbxt9_!enXAg(9`_0U794E@ZCt6Vjrh1Xp}USPO%^kRsvjzN=%_9) ziUtcGeE?M-L@^kU<;7Js4OSNJ?NjiUzr(YNfYt#wSnHK4^=m+binbp?UiX%Sn0R z%*g90^s!I$ue<+l3JAj&hJg)UjipvcZEK?FULWiyemY#qD+Hft#{NalKI5<5MA~d0 z>W#hHr%fVR-Kdm!q&P0U))-<;MJW_f%rf=#?4OE(nBs)=6a0@toL?AJ6{w{NC00h} zzsV%5I}-I{`+2TjExnS-iztRPQ{P}V7AEP6`ivprpn@N!8B1!=LpL}!R*gxq)$+=B zeXI|S*LF+#|Dgy_2h51Y`D~=>%?5?g@;`p^I#=A%#!hD0Pp1F({dr01vt#MUv19Vn zj@5z(spuQ2{hIK#PD=?H{Jr+M&V@k8+9Nr_v{zDCZ?_^>`E;YnLgJpqZ=<)$NJeu` z!<(5cb>i2|eiery4VVDE65`%oWvAmd??!*yiDE z@NZ3o4@RAhqa^X*!OGVv@WcQAQ7#JMkX`2{1N8O1y!?wGcn);zmMb*{R_r?a_t{#| z=~Pnandf4BiFYm8)Rx}JFo$Fd&wnHJ^o014)_qpdeElJzU%|%h1?| zRB>1T^ZQ~sC0oe;tHg5?Q`*-zaWWXfGu{)1hsSF`W5icJTAPi>CE23*klm@}E$x1; zl&;BC0mS;f%HHTLwD8ehx>wcm(z?gjB}YW-mwMI~o5{;LYL;ywFu@>7TX0^I6hCY1Zx?9_C9z+`e&3 zw)p;En72hdpjXW72rzWI4*@C5vl2XLWIE^eKgHi)@(B*uT9A&N{s&@jyIi!r#GTJj z{p_c~s_5Pitj0h-{ssh*dFvD}gAW96{8~XB3aaQ8Nven-cc?^ATr2lr=UbC4#;Ug= zSQwADHr&2QIs6PUt6$l#6jhSEnA3L;~s)m|xqBP7oF zKkYJ*^Uz_nYl=2gL4dInK@le2QV4cZ*6db>O&>x79FaIUu;H|%K0E3RK?}K@_h?XK4pf^1TQ4+i=JBsx0!YcBO=vv5|St#CpXqpW*Lr? zlbc*noC{%j2L>V|0+%Q)$SU-U_NiA5`)up6Gdro|(b0{O#Ke$Mv+%XS zjO+WGL41JLt>5n8&ZlsqZy2H|E*FuMmXp8ZxiZuqc##Vmqw9Pqr2>TB-{HPUN<}4a zH}=9No;1y}Z+{?THv#5V^N?(8zBWp;;kN_oGI0m_>i9hZ!C(X20&D{0C0OwlQ>Bdd zp8&RMUakVU99-|luk?dH2qf8PT`?yWN%drj`!{8_oY#Z$~igj_Z?agfWUn|zLBKTu??3Dsa`v}F`rsCxw1-FM)C zEvELwr)Q^)B`H?^Xk;+7{ZK(&8znMYqdJ3Wnyl(gA8lC`%SmwqyVeOp9zK;4rpi|xMW z*13b3ysQEucw{HfqX*AQZf3k0msMs>0Dx>QZ3#Yrn22!>=HhUg`qYi9g9-qn!2!i3 z!Nk`lL4zIWCnUpb?TwmL`V7_Mx&eIZ8=Q1e+hSlgBoo151Z= zb2mm|_tYuSG6%PpRe*pWuupErKHGt6-S1UFeG>w&+OBjm-3ACuZ8%kAaWZC-b&e)5 z+3ScMnsTKhV_rgo-&`#eYwLyQZJ!BdzxUYfepm#9vGFf=UxYbdIGcbyL`?|G*=N(W zY?B$d(JZ&oob1-Ev+Sv}DXy+9!}<9wZEYOTsjqZ$B`2xB+Um3E0v^TWQPLDd=EaQW zScb|jH1+R*AYiWw-lrY*@`f+qc!q!`>U7%-QGmdGyiAeKlK}i4g;lHS#d^m>u7H=y zR}ZvTNr2tvqFFLxY>GuO%4$(^H-#PbPRa}xtw^!oF3&t=3}N41y!XWxosSR}F?}zC zYGEL*VW4ftc;28JBSNz-V8`?*N@<-o zF=CO7;bRr0CVk4pSe{VxRm$5do}1fMQaY&oSpfZ8bSUzuYuwn66~pSS_OL;Z^Nsl& zLnv$Sz~S?tA}*;ssTd48D=CqGMQ8`PUNRpUEO##r7N#k0CHMM^gPDfQ zEQjomETSR;im~d!*di@s#Z>9Jz*iy48jhIvd9CNUm%WIgmCNV-5qG)UxaXS-C$!l7 zOQA{;`HrX%dJWm-ReX@5{&{z2jaM%K!tr zBrL=yU1*0Z!nC&jv1E)ZQ4E`;fPgGJPs%3APJ3-%H%Z{H+`o3UQoHNbabfN)?Xh>~ z`xi?jF8VG!e57Csxp-i1+*(r`OIdjhNN6;-x@-6Wwl9LM{9q|s+b+3@AVA4vn%*UP z!NHJl_a~(&n(02g?c0B0O43Qk8@svuncDk9P26BC)-{0tX9)q1M{32DcZLYib>b2t zWli_@nr`L#z%%50Ku0YJ1yk94Y8Zvd#iowe(!%dyuW^bJj{7YEj!%Y!R8qHaAp_17 zW9Rb8blo{#{%5Dge)JH7%#^)J8D>R;o4&O5N1aUAwV40M>HzcZj*Zb_MB&Vf$~T_; z)a|@dPig#BWQdo(7Us0BhaMAv9la|5tp%{!23VGf_{Eg8VyB*Ns*FU)g_*a?F3g0{ zt#ZuQ#p0k(U+(;wdt;t&`JrzWMRzxJHr4JjE_~asYfbGrfg%ph>@mUms;6`=l;yp? z)+w{$rhX9o36y`iH+U+#1T0Zb9PLwoq9UGS>2HC zdEM_ZxE>ReY5R;#dVC~!k4SwTreKN9d0U|2{nERAC6^fTG^~Q_52xos9;C8`BFnip zdC|H+(5d&Sy#B@X<%vt;vvgc~C9v}iqz!K9fRR{OBpvHXbT%Q7&DcF;+qX;I&PECF zpNLZhv!2I74jb_jr5yIB_F|_BjI0QD z4R2fl3)C}2y)Y*`H{=v29ZQJYq&W!fI~pDK4Imc1z23NPQ+k$l&h0!xio73B$3jto zpLx$~83q!Z>--Y|QO(2YPS-#Ub@Z;h{bVY376}Jh#hd+Gah6`3)lD17UTfzYZgjde z66;m=2NIh3jx2~D#n&3?s$@Qrp*)GW+ORa_*{3SxW7?#zU&m(~XBs9N`zEqEDZr!` z<9Wn;=CnzExovl733GIC5#-nB7T1-=Se&!BE*Ph^|F44{rj5j{Rb@)=P>6{n`grx} zf56y(3QrgLWW*whZ?_lB6{4ug%W%(-%Pk)mWDEXtcnVAq4`aC5W_*ODxG3mql&{Ae zhsOPIwxEDqV`siv{hmd7^n&qa56bH%FujT^AHuB3cXZUn@r`yipy|KF)U1)EJL%}t z>+O*5pJ<4vh}x~r2WfA!{fB>_b^a3qBB}q5VAhlGI2mLQF;wO z6mcIMWOqC-5{x5B4zb~~s@xacq_WC3 zkunS1$l&{%bgAKkG0Ajsyjn8HL*sw20cBoqo*2bRIV@7NzV;`_?Zq^ed^}a{SdNfk zb-8y}VB7bmI*jw4zu!RjE@$%P!M~U#6N3tJQ|zPr557`AuUUNZKS`b6)Ik4p>ilDE z2kp+1mofEwG^vdVw+`dc2Wo*|jZmmQIUTjz?k!+db zH*zs6?e;_D2X=HH$*Tv!|COQMZ`s;8aB$XAz9Euu*j}bBI-gHn>cYm#SN#V-J){2@ zK)oHqe)9YGeFvJI?U#h9f7c*BUPiAQ3O|u_`DfJ3l#YIrEEuE^$Qn^(0Cu&h*tbIVCJ5 zM}l-4e&Q}VuN~u8zx)E{J?%sK0f*-IXahvo!ZnlouiIZh&a~X1I@u#^EZSEseiZ+W zt~z-dK722A)`BTb3lw1D7IEj~8Gy8GFq%kgsmjWo8-p z^{I(o!BflXMU-3AeNV|~%hMJX`Qe}&uExWj>Q&Lc;OCQi)!D|K=KB|q#37=V8=FnZ zRZD>D)f!%LHY%yZa}fLLDT2d~b(eSY24KJBFHm8#k4f=XlA$~%_IxW`YrdLQo%Ue*n0d{swRm?vHCLAo zv|Y*gnCvf80sTCru81(_gA?&dYiQ%T^9=lvxCJKO(|0Ff8dW6!c~TM|#V|W{-EbIX z<6Ad%g&W5(+5*?-=0I~eor@`tP4`wsTRR0v6x}rSQoMWtu2z?S;8CC!QT#0VFeAgw zKSQ*z4gxv)Q_w+!|KyEEX8lc%5Ul2G5VTQKbwii=Bz|aPpY9#A+zp&!BQbPDUn<+`%q-wcDVdC2&+O9aUjt` zB84E`8#)F7#|Z}9H%H^_gGzI<3wobYc8bus?~v*Wb3%&bNI)rMJqvfBMY7YT!#abraAn(@uh`L9yUX5?U? zO%bf#xwR;A(EqROcw7c;uw)AqD8}5kLtOFu2^*z9{QRxKz^`6Xys^`kv9MM{{4_sANy z7!-}16_sELO7DI3hZ7mbi-$Zo3ypPJgup}F6vBOloz5UAF)?^o|JteV+of@kVEAU6<^6R8?$DF`Q-Sx|982aSkOTVz-m!%&Be385|H8gkPTd&3M_0-uC0*evot! z1xu1gYo4PXV@Vk!ZJ35_Q+a(O8<+WhO-$1P;=H6J<~g)jCCIPmM8opX@#-fzb`iBu zuyMUoUVBiFy$Fk^qmtb>|HQ~>99liW&)Dq}X7kE_}@9_yaIz0;SPdPpP#*$c_R zPN>Z(A9u>|-!@w>k6oeBnX_TaQ6lNCHpbC?_y=>sZ9+`^USQWW`YKe3*vG>%z)c+O z)?Gf4!wqfg#9{nxZxAIQEE44*N2y4_+9;DO9^4~$zB&IZS(9PZB%2_-(k&{%0Hhro zjUhw~g};nrV2B(m1^IY}2F={u^%ZX%LIg};Q1g{Ou+ib~r=OLmO>VVp8_9vJo=ceu z6~muTJdB+5^}EhOVOpN{&rdwNoJcL%hljZ){Hf9#txnDc?51*RtT!k;ecLa_Dr|QY zQLS(7CzWpiH{U+${ls9T(XmIft|FgY&Pe%Ela()|-nXc{k2}B%q6>DPj5E)j6XNl- z{OG{?xbrtAd-TuDt>+j1X>6{Qsx{{JTTDNc=1DO_&ctV+3Hjw&nD5Z0j`N$+T7P@R z<30gr;&sUJS@(R*YfFuai{9&y(q8SgZz@TN(;T1MJ&kQyF)*0jq50SZ@7-(54fe9K zQKQZg8>6O%FNbOdb8VviZz@x52d)C3yA&JB>?B+YBiDwIx|&8jMtgo_1P-wlz&0ii z9Pr^A6Sa`e_(3y+Tsl~Y4_xB$3GmaN-ihGSp!rOIHCao;yDc|$bo(N>v2LhbRkfSq zeMt_)VR)i=T0l^`E6rMu+A5~+o91vOh+?b!T&G1h9LaWGnS!mX<&wywR#Z8E{x?a8 zHt{_Ild%*HIy;&=Tk^+e74hW#)zZl1>lekI$mR}q8fBa0@)eL!gz|2fKJ#h+o;D(Qnt-7fiBQbl7!NiJ5A z*`FH?u|c7n8|7upYwBHf&CnQq%tYJ#5$-X>PAr>hK%=fIg{P%LyUWEM*5!w}`Ltql zjBzTRVlpO^H@E{*QdCr$>cl*Cuo+((aVl9-QcQpS3y&vqWa(~tyax&_PC8ut$f%bE%zbgUnXK0fdOVGc3rRx}eUThMP7Pahd+>4h1-GI#Wy<5$6Gp|5%Kv&$benpF&Zf2)Y zEzovnN-)LJ(WhBkAKvEO6Q;_6ZY8VUNw5lZC$w9^ zHl}bV4=1*qmkeJD2|fYO56cn4-l6XidRLfnH6ZrC4d(t8OqP+;p7oZMu35h=ng)NO zKi@x44S23rynlVLOL2Yjy5`IJj;p(n>RNaB_?z;Yd^PH@-4&?JEDEqs zZ;>K_)=|3lO>Ez5$ZfTl?9Uhe$_GiXqk-Ey!M^MzE^C7z8j=`)r;#ed$xkv^$>3v< zgH7F&M`NILCGu!KZ*uDr4p%92j~RQKk;)#Cq|QYBCdLv`=OrF*_E62NG#(|LPARe7 z*!j-ebSFQFkZG0%lOP{%yd1<8UhQkWV6u^KDZTc3qsj@}?t%`kR2m_JT7rSLn`!5_ zRpVdJ1P0a?4*O*5W+|R@HW!XK*IN&)SizY^rX)bg`m8dA5}kQ zCDPPdb$MXjS=YdOf2>NQrWUc>U=U77sF^!=vXk90FVWt75JfRC{%I zRPolPW^Z4W9CkL~K>YbmINLnT0Xjf7@K0P1_%SVVP}m^$!U-ga?@hDHpYB zq~99D^j0b_(U;=2FZ^uuK%kPcyf_75ZXcLZqY?+Vi2E+6KQ2HbxkdLQ<#g+}7@|CerO>_Uj?IB!56=XhV{mq6& z{{4ga^%#74bgBG*G$e&)5n^l@$j$2B)ULd*9kk>Xe}o+P#!dSm1Z(~<+~#Q_ zztJ0n&OdmX^hmk)``VMl*P;K5T5La_yh&fc#!2dMTy1$@t44_#-Mu}N_&VU> zLmY3A2r*p+}FFLDGp>4<0N${-5#jD~x~Y!~cuH zQvdCx310LAGoQMt#s8!kg_+=D3fo)r6D^voCE@&Eez?x^uT|@1e9r=Q7stY&P4pO> z3Gyzw0RuBb@G#FsaLRew7|L_%L4`f-^O0QWa0egsu#d#p))_ji-5tO1&_(*2Oj1L^ zgmL6=tujJWP1nyE!p9W}PwcnnF0t-bem&_QQ5~AQ9)b8Nh;nc7?F#ZR;(i*%UY7Il zI&Fr}eA$P|7fsGCJG_7JmEtu^5@o-M!^F_0okQGtGKH*ZSWar(U{2UW)7ae;sy~CB zf$E(e)vQZ_NBT3KTDwo@&95))Esx@n2v(V9d3pphhSR(CgWk;`_V0@i%( zdTwrF0qAAv@7jocd{DQ!Wtaz?rS(*&OdmNJKJA~5K6)*Kds%JHb(_|boz55Q>Kc~Q zKjRqBuLLL>Uh2~aU3_<@C}TQtR?&AltrVS!w+7NEx~l(_bcKkZ4G8))l5Bx+ls+e# zF!m{hfn?#2g==l|j9vQDW}uZJ-^?$FaI@?0=l*MC7ni^%K{dPu3%Fl*iC8eR}jTl?hAA0l58+>JnxRBT0c-ticx5EpM0HMkSiRn@$<}6X)IIYqXB=2NUg@9Q;JK zkZxr`Ra30)mG7l43jpewRtx3OD+8QvYg_-#@_0DMvJaqi99tAFOz4CoN zh@L*%|KQ(4zy5%M+$CNd-SQcU*I$8E$uEAw>E98OOTH-%N#=|!S`7N()v0{4DltT# zio@;v29Fo9?W#WD&9x=Z3V4byO1@e2jvd^vpQ%01mqtm!oJ2_)uUb%gGyLvB zK0qjdoV1K1U4pPrLIBn=tOPlsBMx&)kWn7WQ)(gV@ympX$CXNcjCah4b@#g zx{s(c&ysqyGJW^3ydcE685);~^}whst6cvjZD|~_z_jq1m7-IP3~ifkXS)N`Xbp9u z@x9DaUNBR{=sWrB{;%XGL3U05PkYxD)zr4GqeKEKA^|A^(n2q~MS2W~l!U5)2vQW4 zB9PF;NE3)y00k02lt6+=Q#T+WJ?u!6L`6^#r57VXIzdV#kh8My9pk>7hjSn9824en zCNpdO|M&N~R^}JqOgGdkzP6KE_W^qE#0TA%Q(2`mS?}jd(00Cj7? z(tpta21SQ+)B2rF62+^cme8Kk&a)dZV`|)|6NZ$q4rkAiR2DitW5FOkatFA4?&!?f ztG@LuG;fkdTnsVzudJ9A%bw0eMNix6=yR!|&n*HYUG)ttjHGQE2=8~5rs!S{%+oc^ zoO^966lUOA;${5ECOJq)CMw%EFMIq#y2rA#59DP(LAOHR9 z5H#^d%A#Pz^wR5=mOh_-L4ubxO}Eh|XtgS%SDRMb4eOVUzuBemKR9#`T^%w^M~Yhd zB+EWN40`ikNcLFvyk=#n&#G_w^0`B2M`8z?VtPMHXpc{=j~)wWs|-4atxLp*{N8rf zA9d6XDN64!Lc|WiyVu+|#~G~;)sG%u@S2mns6UjJcJm}jp@@IH!f%Zzlj9+2jl9ZfiCb-ZdsbF+!3{a3hbsm4!OGkGKZYnd=TMP3|lH4&9pxg##dk+ zsOvstr$~m#Cd0T{t*7B3zoNdJq}1R9fvb|bOkHu{3`c0>t1Jdxg&XcnluCl!u+caL z+tr)-TH0z1#n1(_4`s!+bvEB;tbSD`4(%^-P}kvoe+3+PC{Xtd4?; z>7k+d`?`yuCqnpo`M?@7d-lp2+-nzs5o5tNa&}tc^+qF9_z%<8xjlGrLhl}RnMS!7 z>-2a@Hf8q6IM;c|PG;#C>(;y!j`PkQ(P!~hPy+)JxIm0zT$~NFPFIbCr?+r_FSF(G za#;STou;6xq*B@;v?7(ig!pF%Ae6jkB8x^1W#pei!1?C7*Y~ZhTcwV+8LyaXrY>KE z%StH|oH!RbM_sHsEHf0Gs)#0WltyQM?wY!pYns+a5NJOFWpT=-k-0qW{P(?GGT^M| zS?sQiQOpMXEz0HAo$}sc>2leV-`af(6}FKaVbv_v2LiZ*Ti0sL?_mo0A%J2B_w*={ z_vT{x8|50ft}}yeslhGmx9KYacV-Me^-tW&QYFA!8Tn^+9@@`XIRl0ODTM#_x+kml zfO3WQ>_y_%54eZ_V%{@ph%?LVxPG1@Wiy8uixZ5T-%>*WPwfuor7`g)3CnzG-$&Ae zh~Jc$jqmv(v%!b61KQ+`k0)KXhN1|IE_$md05U`_A!%;(^f0L(50>%mL#a8;3=Am0XZ&mY-qUfykLl&h5n9wBn(wtSv; z6N_;X(Q(!rO~#`IS>S|(O))$tW+0YDE2n?hX?{>%Uw{$SIZaJ1HD`!TdtGMfXtIBG zM2$(qNj#xbdxn@`<86-9Ot03p=&|_$qA@c z@N*I))7n*^uWU-^M4`uAEMHsAq=Pc> z>t}2B0wcywXMhkg3+22F-l?rK7R63g%OA$A1W!C!scf!wj|-9j>R&ynrhK^Hc>AxT zQ(i$P(X9#@73y&5n|`sgm@z#=0KGaa)e9gbN$MQDK#W5(=VbLth)v{RW}9wARqNtK zoAAw1vEorwYcT;NaK}-EKIpn(ikO#rq**qb1V1i4D;=ma0rBDEr)T@jOpCcb-DUH! zd24*KiAW&EgpI^;KNg&KFjp5$@c1A#!9p%wVqq+ez2;6}ttz?2HPl%_P)}CYu1g=u#85Fa#P!y3MIRYgRtA^McqL z`2!!X|KAY9qMV*vP2jJJ#jwz7 zYx2o24438oD2*bklMOaAqu8Q$7DoQuwmR@_Hsx|@%fsezeecGJTW2mtJe{2vRTn_y&0SYbfw+^Lz&S_(Q{o})txaWv<-k^}W(^;!|3TVZBV-+<^fFR&alz95c2$n%gq z$0q`{kvSe4SJv;^pq};lMbznEczQVSBl#rhw%mSO(%}Kqfq1Zu#_6xZ5&`zY>sbx=~)8igG|bFmuaG ztPCl!@Y=Gi%Wu=o+*uXw9tQoov|~?&PBrb59bHoKhXM)7M=-q0QkkyMy85Ari@VP7 zZ+NOL2s*X8BLo~tcM4Zpf=2JOp&@|PL+98sO{7xNv2fEz&;xruMNxw_XE}idDOZb% z`gGe#PplJjK0#bYKnKNpRGbkihFWlTz(9OwzfHk>5Fwhw^!4xZW;91;)s>nHsnZ{Cy&0`9YN5=gg;w z=Sf2^|KfSB{LUenp2>$I;8ttG2V@Eus|Ql9{w@V5@2K(gfXm>>-#*xbpU<}-b%`k) zQM+cgTU6)jZJ+`f^ zyQ1AJ6G$-=1N+8O82L6ZRMQT$iVzKxyj}G0<|CwpJI5YICsol zh$NI!80$oD&Ju%X#{xruydlC8Ms|WC=G7%QW;P=2Ga7qGaU~dN)e1oa&bJ5)FLf$# za_Umu2S}nl?TlYxI$N+T5=^qiUI!S)d8cTVNz*f;k{Fac@u;5Ud#Z#XPno4VYh>Us z5sXvN@y; zYExu2A4uS$p;eM{%bST?l|7aEO+r>VFWo&J>h6tHV1lkkfku(_%N>PSjY%rH4vj71 zRpdA2s^O#i-!Ky$JB5U?+%@JChu3AN=#ocG6zvN?HhLr&-03S&s3=n#IH%9*R&vx} zU)h?Q8SDG6>859l3$3q6w|C^>T6*80!ouh(P6Z$3!*UBP^n9|u zWSe|#qfxUM<8=#OuCp}%zf&R!M_@k3HWO4IbH;y+t`~{xDtKD9cokc3mn#}IopmZT z+WjR-ROP%8a%wc;ha76mr{B+B2xxnLN$#MMJohM%1SiRTKP_t-$&Fl1*XGtO-^w!@ zx%F{cK6gGmQ>|a|Koz#YI@RpPlV>95`^*JJ?F~KAr=ev2NOCYMWNe+PM)&vOi!%lQ z$9?ZBWE@bzn$-6MkRXcG;l~Yw3pYMva)urruoflVr>oZ(Deo_Ihxv3uITF--bN$ag z#Ui)mXcm8ZUKJN5C7(gW#iDoCmN5H1r-;e$OgQOvCS2~#qVz=JBWXTx)DB??v zXjhowgJnKWPI#z;>fGY1AU(mht&%3FWA#nkTh(k1J0~3uSk?yGY6$}U5<|O=pRpCH z=yqX+1haG$SBhchj~P)mv0vDB9q~>(yBOQ@$hd`QVkR#L)y}@}0pBDQ4vdD9CK1=B z1VtR#G*Q%MV*I`QvbF3pBt?R10OeVi>Rn3nxBGP!->hUSsvG|{1>%c=qekOCn=<8W zN<@b&*ia;tf3nLEI}*Qg&@sC^(|X|%0q}lV6e8QO$82d`Da@l@@#oF#ZXqDJTYp~C zc=|}K)%`nrjxORTx#xLpt-B5Dd2`g)365M$dhBXc5>Gw^*(E&Zdh81%3C?APti|62F3 z)7n%E`SmULEHjH;w`5S)we=|AJ8)PTM}_JE_~&$ruen#HH{O-Nm153PYnK`HlS zS$dCuLHPH;9{;l2qd3{oi#x{ZXJxV@m&qE zs~KbTFH2KLff}Bv3leLXQVC3TOhoqop0)3Z`DD)&0rE(J^|Gl0-=Q1| zIod!vikZVb(c0!3ks4R;O~K5T70wBq>)@< zFUK3WUeu&5R-`F}EZ2H~qP4kdKat2te-Il;%>}}ZwPAfC3tZe@;7N+~|M1T-xO^ZG lmy1$Ha6ljsKW~!Y1HB>?+p+Wa#{r*!P?ioB73P<4{{ka`&}9Gs literal 39585 zcmc$`Wl$VZxUNeG!QGtzgS*=xK?b+M9fG@Cun^n^cMHKixa;5qcW2N5!Gi=n?0wGu zb?fY^bL+~#mQ~f?kJa7JdcL|@V+c!-d7)Iu4chcBrK4yWojhvviY)~mkD@12RO=Q z7cw<+I*Qj?+CxSyu5y2gw*WBcOddLNrMQfZ)wDOo$_&t|Ml?wd)0a$Y8Hfmy_!vQF%g@Aywg|e6(idEC z3yLuOycV>uobAn&mMcPMa@~n+Xy`m48l&h8a~~=H)D@hVl0z?S@-bM4t6NDe76kok z#h$*!31=8gNsB@RJCl^6Nlxa+gOC>3fbZ7?<@HHR+&t&W{SC5LSb^xy3zG4u!=Zz} z+LLAaM?9E^G=zm*s^p$ZLvow=$qZqjZPKwmZ?+WVXiSZjT*4wXdCUGCLElOlXZ#Yk z<*?Gh%pyz;Mmh`Y(rGhCfhOc>m=SH@ki%-HPp zJLY-Oj$F8eee>9HDqo2fe{ZN7M;1)K4+gQ*8J+Goy64RFuj)o@cyiFDhBMiCtCCo^ zbuSE2OH!6-Qcs#%Id&$S?m_!)xO-#hZA;8=jKZ_$*DdgkF_<*aH4+UED_y$$3tk4% zW)`hnH$ZY)ZhU=YE-mXfrt4d^0;nx4{hF|puBHW6C6F47w5|W4_ek zCMhWo|NTsD-GOti)b{>}Q>5@|ESO9$vNV7erEUEv2m8*T9T+~m0dmh?$QA3?yyt6f$;QZqT1aAy)H^zs z5XNj!?=c)r!b9-vl>Iq|Jr{9RI{avdF4t$KiWtKOn5>+*T={h<+lcbbh1_N5M1*pB z9JL!*;@9t1t9;N}T#a@JRjus#ma97bs^VJPnd1}2ETbXmeLFhG(khFe-L3IIDK7AG z@BVTBUKu9d%S!mPYBV8<9CPZS8(Tsjf?ajC6#uwwRoJ#NAoYoVokM`>gdZ+mfNavUk1s90!?!h$ z%iDpf1_E$Gy1UM*vQjAM|EGXVq1?%KiD_6NxXuNzATf5^gjoP2S=uO7VI;y7pv5j% zJTM6@9S&tS^hA=q3em93<@BstU6MH!Y^a*4tPOmMM?(pT>*ouz=JHKDu~9^!{Mj)k z=O&u_t)A3{>9|o(w^I7^ncT-E28^f_W+B_=cBFg8Or^Lr5}lZ?&DxVj&)a7?+}eOA z=Bm>g3RWMJ5dBX)D%~44(5YWA2@5#_^dDF>eOs(~#$?o%E^M@Y`5U-ofg(HAGpbt{ z@q|ro#!K?dN_3tC+V`}byHJet&nF93!2;exd?A#jjSbCScZW@(+QyrO^qP2Vi<1#} zxNVY@^bCHCm=yjUefCeN;tEXHJk4wcF8@kAw$8Toh51#YxD81_Xi_@bg&lL&0&9O- z-;b(|jG;ON#>R|=J}mx=aapp1=(#tvaD*OhIHAU@a8k(y5tS(7r_PC6P(p8q z&+qAB6c^HFl67+}+%PaR#ykh~rUxZHVzf!2oyc$MqL8&JC26!nEPQCK;LzqhIqz8* zLpfw3Az_*V<&1Pqk~c&^yP$#IK?Yrgde2VR(5D`_N!zH;Qg}#Q1YRAnyd)GG(JBDT>{y$1EG1Bp1l$$+#mbF0%7n z7j<@spwP{j5`Ijo+K(hzYP$|M%@{{A+nxAzOnSAwKCJW*fQBYZ?B(0w+YIW|BAFl$ z`1cXcEZnPr`@1I&?=jtau&scuq0~{RQt`CM2CIwrXcUrXms7p>SuM3B#dLq|0E`3? z<%-x48^$Ixf^Qb_RpVF~9rc{+nArGuxpH}hsA0{>pi#Q)JxVF({Z4cdB=INvV1die z&&0}1dX<~0v0eockx;E~p)gnA59b!1BzxhMo$ZMd2$y?KZhy3w%PC>Bwl5~fZ`RuO z1B@9xE>sL=rD*Xy$;v=v>Lw#|d&ztquGV-=vMYG$B-Rzk-I>=PzQP4&lHDjuq^(6e zei7kEC*wqmLx)ok#O1WK!HZHeu0_}k7o|KQk#LEUX+7KCt{JAT_M{7YMD>!a&^|8e z3d|M?`(&dytio<6u#MXPyG`R8QecjiJDQ?gG4G}xz3YusT&_t>!O{2Zx)1?kw*Ev; z%!q395j{a{db#t^kMeDrI~9xMCH#0Cg>oX+XEq36m{=0BvwkCsLQak7T`MZyP;4lD zza>~UGTCBSEhWU>zQq+GN*v2&SELiOBLUg`SL6KW;Q+0Ui67n36}j#rI8R?v zZlmLv+-Iw2Vs$^bRlEc<5C9*j*-~hKG1)}vr7v%Rnu&{bk9_k!aCV%63yrqdbVp>+^88Pdt#Xq>W*>i z?qD!{g)7Y{qT$*gX9?>pT>0!B6bxzb-29~GhJcv*RSGHH&(WjhQvC^MqfT_Un~ccj zkX?m6lXd5cXx$KrB#f!aNWj6+aKxhig@oec1>%pCozsoVWKe=nXZbkz21wR<9WfI= zx-ZIJExsb^BqQ$2t0p91ZGO{4EWZ6yBJ-1m|V-QliUOb#9`u#TxN?hI~^3GA4ri)2PrAowtgKdk@g=cB*+x?IM^bQ*vU2y<|12Lo7UC+)VTCaOm$o(W1x~ahHFSQQ6z8 zKAj&29KGfdUfqgn4#^v3`$P73ke%D*Mv2LvKbHIcCrA`#RivdHaR zDLGWljI9Vpycb7PW+DK8IL!-5J`^;2$)T7skxXheTi}lVGg0~8lo6R&-fQuP^WmSG zaYUZaDX4MZ%$dsd`&+W!CTtyuZ1Zdnea33nH@pDe$w(z)qfh9`^D^h2oq;;>??m{K z$4`Epl|k34kR;E_3IP73G&mAbWc<=9IkSm{MmTAFbO(WVB^NL$6`r;|&^NcNSE>O! zxAn=cUKnwcbn|S>h4OHTFolDe=td^99S!|D-iNWI;G4DRWyb;K=TJW=3!d_mS##;- z;L<_dkm=w$Kg0r(5@j)y{WiYFP>|U-T*v0h7A1WYqy{5{*q4M*j>q^}$!WZ#IYS3J z^y@OnSkK09>ARG5rQ1Gd)E`>+iprxM7LkngU20Fxbd^S+35FBev6rju5wA@kHq*9| z=TuKv$i_NkWF1}FUJS4yE{@7U0frsb<9O5DGNJg16dd7+GlX65(v4I;$ZY?xA&vvC zR3?C{s|nXLyg-tl=-+MAxeP1+2EiaHC)}GQavjRQA9b?~*9y89JUjY+D_}*@7?P-T zP(t-Nj;hnGX;Ap#L+t^ZpE%Kv_;7H3?$Wcyub47G`}a`A*XpKuKI4?9@pNuDxI3P{ z>ySZ!r3PbWhwY+pD}9Pc!o-b6!76oGiWhM0Zny~qc~1-HPD1RB4Em39DP0|6T6_7=c#Izs@lr=h+&I z45kyx^+Gy35_&%0a~E_$r&gdJbp530Qi`G@;ntHG$}?ievw{Lt8;~=^+H=j6Rg@xM zq6SDVVRBew%?Y`lW5qMVR&d3? zIQ#r$BqdhBee}uL1W-hdWTYSpw_#y|$jX7&9Y1BOLXuhqXV^UTohupd) z>z8t;Vd)Q%L!t|~%5hJUV95zKm=8M|f8%Mf+H>xYJV0)|tP>(-~(2@E(gTv8tS4E$j!}dw%Scnm+oQ z9FIA`B1Isa!y(vm(NCJ?elf0W75GD@{!Tw5J6(L1uX%`m0XJCj# zx`Pe&6L#vi3iBqB-$R9&=1-oE_3eHMDKg4tSHUZF;Vf3mpH?Wf_aCkFlji2eFk2w& zf^0Rrv(a~Gf_CaCQ14CvDR}Yx>bO(uMt;8m_zQgdh7q(Yu02YVJ{0uqeMVzmX@`3$ z^HcQad%k8PipyrI*x+!0V8={94|9I5lb}dO!Z@)GWs`0$cPb6*{X5wjuA6SEK6uk@ zG+&>!RAf-dDC?gGH*h1=p-(3K=lGaiy(_qFx(+uyEvKl_T$6+W_pBLK4M|eaqW-l- z4ZutiJ|!e9w|Pn*AoJ=VId5?r%&crMZ&sX#DWvMW+Yuv=N|`lMAO;S_-FasEu$(8% zQq0v=x$Kp4rVY4RO)~YFPt|-VF5>31b{gJL5~;<9lnwC_zEJ(>8mmzw+)9 z2D)vLGzrezeB;wc_xfhW2tuZ2us6RHY|$L;ZLxs+T|$%?S_w#1qZ7K+jT*J$6Ojk) zY`biH_QrxkGbwKp1~8YHj}Uq*M&>~}`+{c|KilB3E4Y+@vzC`97yRqO2IzxIH7d~x z2pAYP^v%#Cn6?P*aIr%?74~MGMCMTpE4f0o1SLjuwfZTc5Q@8g6`?j(B^7UvN}E^~`aW7PghU7a3WA|b%zz8B1LQ90 z!W!SH5FV!JtT3S#($VZfk+MS2mtM*ASw@dU82M+Ep5wXdK z|8HGALNwrh1sAT@6Z^OK`gX+JTl30aX-LGm>lLRe ztoz$iKQxAjJ!rd|vkCPb7Yd<#Dub%nZ<)LpFIa0ot z`AAoO)Etcn@q973@)P!eh>1T=r<(AS`$}!vGtQ#qYo9&!Ye)+{AWlYt99~=g^=`%b zC*5C`_0A5l`;Yg$`3FJo?F&p`2LZ3iQ#jahU;WlEByi!Ms}6o;ZsX;Y3C2l?S5}Bc ze6>M{Ey}83MtIPRk3%;Y=Yxx5WqwCHYQal0J*EHmVH=M)mj3OehC3Skk9V@p@BiCw z4!0n!JT+E!6$aT^pewFzw01Ti?BW!Qx0pg^^eKP#>oi0wR=#9?I8%+Qo(r$ILQORg zF1#vXG97@3+QzR%{Ry{cbVs7I?Z;q0>YSRKB!oD%-6!;UZML4OI#N4U*MFDs*6=8T zs5%E~#@#~R(#s@PgUSW;{!4X;U=vT^ z1K;^WYjGHD#1`)3Io|1DP_4+!f%HgV`TFW-5Zpa6V{7W24Yo> zb(ml#f=L*1;(YoZELz?CQa{mqQ7mKk*A`1p42h>n{eGyB{}|)?1SG&;vFebzZdBpP zd!7R2`b6dQ9wH0pA@G$3Wl7#~i&7LisM^l6et$ZH=aYk+2y>sKFLERDPRT3ZPFz`c z>&yFc=rgPrmD@Bv2ST4C<;cQMq_y=cl+NSN!5vC+a3lax)*l_qH{vy!<40C@q@-I* zm=_*wP>bZ7Tw9`WOr2(YtDHNZw0CqBXqFT`>6Y{S7sl~wxtrLqPriq9VJ{(r8<@OS zP4JMm<^fHju_z|0R&d<2A>~`-V*_<+P6AOn` z0%kqaYSP@1@_=qALrFEb!7+s>Eg#L0^aUq!d0Jvqw^c*)u}Dv{0Or`#@iWSO-mY0~ z@iIQ=7WR7prt6*06_Y(i-{3B+d(oYFiq!Bng!ryimTnSrp$i#q^Kq&W?x06#_uWiJ zSmAu^qjuI15tyNYAC8gaTGj1ufI0$ znqEBNGg;O8iFmJBU{Q!BVnsDocj1WCQE54YxPJemvkcrlpBhb6(dAjt$>1v!@QF6s zI5}W0@=ND#6`k99?4h#+72(;ICI0P+Jx%B*7s3@1TqI)ef)&zoce<3%%Z@XrO2iwp zx;pPD!n?Pnb8aq%iS8(9t;B?^#yW9w;Ef)JTu`u%nB+*TD@CjiBbY)9c`o2g(Tz#| zV$50g?}EzrMy}im(CptKa7xg~)2NSKkQs36i{vER@bQ9~8*QO<3aPQwr-igMPD#Q_ z2hE58^lv=(ukc@rRETH9w0w`j9>;6~J9eWNH~$D36KjZJ$EBmCtQ3CctvMyiaasq8 zp3<&SmSYq?J=w7Bo~qW{g7C1ZPB#CPjuxu}UklMo*V8F-O1at5HRMxH6z`h-L-(H@ z7zo^I6@V|HDWLj{4+4W8>)1jttV9YT=Qgfu!ljQhCQ}{GKePV;C|ABOpbl-`O5L<^ zyYDFrfZXCrdz^w*4_Qh~>5Eug=BK&b&aTehMbwSWe%o8+Ukvz78>I5;YP@`9SRa;b zCnQ2j-*ef`C4hRjY@e;Wz*)bO%`a$9itJ!2jaxlU&omu2cD|Iu3yACSRsQjJ_IApUstLeFLnxLRF^h=xEA+E}f<7H?> zRaI9k-UCU2^}k$zM1Nm0^i1#gfuzw4=Z=-H=UHDdH&bm`4WCVYZbW~u&`fUg(ubR$ zB)!(lx7GiE3NJi8=W$qoUzebF_-iC!^!G^XOyfTjwj=}kji-)KmwEr46GF1XJG3u3 zFcSvV?4cstYGrZay~1I;L+ka5R9EYh1FzOKaV?Pwf!5B@xpOm+iLA5)XusiH$wD8$VBZEdTNI zNVJ+0Y5<-=7zrkb6}J^dduxNS<#893>tJQo?UGzp)RTqH?#|?5?!av%)+L)p*e56b z)SqfZliP|GYdwMN0UbkfvH+m4?RumO6Rm4p$G6I_iU4LW8ajLXji#IaJ~t#`FE0^+ zh)`_?!o8i)tr1WG2A?V7T#c=}0PAZaov7G!u3E#d8G7zxCvdQz-3-BX} z&hH#5gB~#7xe|Ybu5}jo--Ixwv+|#3Pc+}s8Vk5|?)xaV)b{!wCY~_E>%=+N=hpt*G2lT-{50^vfs5v}Snd=g7!L%z^<{5dFo1%G z{o7iU|0)!~MM1$Y_lY(}p;u0URGjiZ$EvozYfcM9`cGi6J?pvy^FAGGizl(*zjnKr zUDy(Cibj+mE@z4(5r!Fd+ZeOM|G?o)+QG$56MYece?BPCsss%UBjsEDElaGVd}ez) zG&&rZjx7NHZU*_DLg1HEi@0D=;C>Vy^53R`%iWg!{X_uC*g)HzxX07HEy9ET!vIQt zR|Cl#g?VAaZJKUA-S2JzpMFZi52OAiS)%XqPk7UGgp0XY zy~Rw&>mv<34PKSP6(bAV#`RI@!vCU40u=&3x_;iQncbnmH==wTHz_G8F4$xd^4dw} z=&1l|82L7rG*wdsZ_b5A&_N(n0>UqO9WNN^a>$XYa&N&GxFy8DAO6qq7I!v^141tH zbd*k|om&kIXs&On{_r0cupf?7As3*x?OXR_P|f-FrS{Kn7>D?lUO>s!ugqvKT{Im4 ztD%UM((La#dG~Yu4B;b%Mfl&&qW%Q~Rt?=(2d|yaFDIBtPZpkA3333emxGzqcu{4I zf;9RG28X^0LIbQ2fv(QfuXk#@u6@Dt@t^3@n#tFKh%m2+(;+r4RpONzndbbtHiSq)HlF;;eTz3#|F^gg+n;5<1Rp86 zedbogH>UOV6ANbh4~BgnE?o;zZLq!@eyf(iX;W1^5bJG@v<{!{mG2_|ZKyb=R53A7 zHx7SeS}`;R9Kyt}iSxUQ@HLy6d8BQmZ?s$~WDxZqs0%0@{VE|oCm`@4zt}%DyW@db8}&a%l$iNnch60Yr`{f zT7}BnyQa{_NKdazBT>xi47^ZmZk5#WcVqE^9+}=G1;7QFNB9zQ0Xe?DRRp3^f#__b zAY^Q}$J#Xfp#FQ6D1gx25v9Ua^;m9IpKEz)?cTp*DSt9}sa#^(sMx61)6szp32QuV zf#bmDi0|-6u_XUcM(tQMRn7l2*RS%TeDX^Ju`5?nzyb zjt-xZ*c3@k(z2JweN!PpBj8UOS4(N3d60Mr!*@{JFA};2ipmwE2)7cJd03fD{8xdR z)BtC{m>%Y+j0A!-veWKAf-jD1ip6K&6Jg&!b>h`#?aGuWNYjvC?`mD|2veqd_}yz9 z8Oxj&UA(>(PU_eFJhfKEchSeW7gfJIvZ>&2GY%4abd%0N}gysYn6N3kWr$UwgyU?Tf!ZgF;>*p_Ge zuY?{`SrdcG%C>eF`ywU{J>I-zw98)W^EYcCSwWWhP&~sd);j*}SDA0IX<39jRzVHr z>sv42Vl|qHR@y&Sscf%k<0$rb*AkiRpWfbJR0to*{H8+dx&@GY*W6BveZyhivBW~I ze>h%Qc1o-Fkrdl*L#(|DTi4EGeo}M}sK%mJRy>@M;ZfCe`5qw;mPHoTY5I*8EB+j2 z`*>AiIHBAv;HMwka{f3ZXzrn#MBb#mT2U>jETe(@Axx0cHTRsuoh7J@U{EEc7=3uD zs12>S&B)4n>(2N(?QLFX(2nZgTxOadwrk6$N3=ZeWMk(2dof9;=TMx?D$C3f>D&;%Vc8M(Q$}|E26DQ~_Z}a9%nbNx_IKe8Pv%A{Ayuk0 z3r%yPRLtd9)ze}lAGUhriFy=_J4P^PZ%WC*^w7Akh6K%Y^mhRI$~D!5$YA|dk%)?T$8rUYLx-(UPbMK+pDHZOW*e(ezr?_G%N zI*NFJY)GG0BfYllndXj4$o>ix6M9&Vn!2tAu+1FQ+pxOBIuscLEIuy`j$OM%pBR7F z`gMh*HI{Un-hIfoOCW$HTg`?!VKa#CJMQ;5b0uqd6rYnmsm~%?SwAeIGD7#4Zu~&i zF`39NrFzIargic~pg>L&$QvRJ*BU93^h8lQjE+V{2wlD)MnsL;3C}oc!&!AvP~b6x z;U1^W54LP)>){P=_gFPZad92o8jvfW*u!i_&)@|jKMN*v=J3)oRGXc37?pHdZ2tX_ z@1E}_%`57j^j~?;Nwj54J?8glYbn2e(`S#Cf1gqjgd=&9 za+>)kK@o$hRK}`n?G8k!`=NfI->r*3%onk@1}&6)o^-=DeqGme+4&o33<@y^wu6}V zD3U#D_HqQXsN6UlMBmJJXX-#HvG~7Y=u_qHIaMESd-tggGjuR7hj-A2U6C# z>0(kVbMshq1+@8OVzOf?mlpjtR?_HZ-J!#0xG$_!?p^ed@6O%w8X>uI8Mr*|U_)BT(IvUw$y z`xlbkul=yHZzZ1R%-U+BLzs!ne2=9fEV%xGj4fY?lw#C*%|BAWo;4z?-|8ToI^;BD zs!h>2o;NoPLWaz^t*S_`Ocq?jBZ*Tw8}9w@bG6a^=d2ae%Z6Y5EiO>sI{~-nLb+4j zu*OkcXRB}w_r@G=3Kgc(1zu;H9;f&61QG0rZQc)i#2#IPAPw4i>GW)$!$INh#i^ya zNEowqB@RYwVJ9YKxs(L?05ENjH(wxQDni(E={)@&x@Mr#7IXn!+{>&E3%O24&@q@= zT8t2^zQ)ZlMKGig3?uvBrI-Px0GZ$6)YDU694siR!e0It`1*rU-?Z8=NTr>cAUF{5 zjfS!1mo(zzax&2E3%33zGPfU|nattGN%W|X5Zq06aNP5HPj$q9YnsmAa~s^aK9`l? zg$IHA%eaF4_r$S(3$RcH2#Yy+T-fgH3F=kwscA} zN6cVlb@7|Txov;bE{Fj_k$XQLWFqLhgFo+QDT{UfvIA|{;x1xgn{hK`INp+!lIOz|-eVj2* zKGbnaRU)A&d6iF@uZZRO8;W?#b8ux1~NpFrg9^mv19ta3D5o60<7B zYmlnATTRKE9F|wgx=8nY&3;4L9*dsy`{JNnhkDnGsM&N2{KQ=0r?g8b&Tsp^6+Zf2W`zavB{|W&%`n~puV8* z_v@vhyT;8Z;W?82q=2SQrVxHYaY4aD(Hnz*bP=Vs_4#23O>Q4%Pynu7TwGSX8uC1G zw183XJfnZ>6kJ7H#zGOglbZPtg88J={XSO zBN|3l{o}mNq~pqKbqa-^aWqy$^N#L8EY#QEb1(aj8!D3SIN4=3K4g-IKIOjo++KQ? zHg&<8{mM(Oq+*BIh{>sP8O9ts`Kk3P5swUhS@_NWMI-z_x({;Z?YD%YCxg6MZ zj?a3YNOWdp{o=mS=eA_q)*9{}RvUP8%VXm3Z>=1}KP{P|+Vi^bTJu^y6xrKLG|RO9 zAa9?0+N$n4OffcUjS&$mNvM=fzo5I^ne0EDo!Vkjn&H$#t8eoET3G`+X!CS7OxOzo{va)`WCsVAiO-Mv(>B#$auW1K}x+ zN*QKdWVKmKap}2Ma_&2&)1O+X#RBqXeHN%~ap>YK>tPsa%9${D4$zW4;VxJGHKnY5 z?FS*G=rS89TAa;C|GQjH>+w5!nf>5A;M(~Rqeg&f%jY8KOpI|IX!SrPuxaXZv+I59 zRT-_)rKdTpqP}1k81GsYcmGmD# zV}U=6M-uUn(9ef#soLe+Ut^lnF_G&C$YgEN{VkI;VmCqec-xL2c9IiEb_ zA<<%}Yc(}_M8dn_aP75o;w{m{-eWLC(S)C4!D6H(bS5f#(@I}Ze=|i@7i|lB6_N%2 ztlsZJ8;JeTzV9lizzQX@(NVhKF)$RZ<|v{Jkidz8-*s^An$@0WHqJj?t0I}@r)cS?(caE7xy&k|F2WBY`a5pL_}VOmD#!Oi+fj{1tGq5_!_ifB-0 z>90Yp*%%bQsi3QWb>B_)znh1_q9~qWsDMK{PrSaW`@? zctp}f@g%pzmWmA{W6vd0Y@r~DDY~sdF_2mIuv%Y(XayRBb1+?t9jNF%^a8?)?Q+G%=LZ_R8q*63KiF$@yaQ?&QAFO&QUd0aAgmrMvjFY|`7FfS*-~ zBqHYrCQ8)VQ)>(6`-H$(Nd&}_lSNm1`yDLLKxdCur6Fo-dkVj@`tk4a9apBG_pmmY zPQV+jL-zU{uy}b`c5!34$~?A0o7>=XSzXcdv`dnRJs$(}`2huKYT*@emRGtkEGCWi z27+fT(j7&b7wLySIn=OTpx_OV$Da$e;G!Mf6Y1aZOe_C!+Qv~uAYf}%qVc%NqXQm5 zr<7@SVPN(p=ZzXEmq+(wR==GPVEqw^b7Y3qc96qqTmuVBnb%#RO-Fo1DfNM7u7Z*W zFD92t6X>s_$g;@r&8@lisZzlD?hX82h{Bhbm05oLLm(LpGNS#!$kV^?M8I}Qr9+Sy zx31SL#|WDwZFyS|4!>9$hjp1`M{z}SMcP?wOew8N%LEMqg=QcOpqFJ@S zh?BIw$Iyl#KB)1nm-^FH{Ec2C!OUPnf5KqN#Vju)F{^VH3B8Lbx^<}%qVY2YN? zjNw!+(h{P*kdd1GcUtFHDTf6zh6*v^)!0T`qRI5M?0sHE1}uBv7vsDf!gJC7IPY04 zk_3Ks@X87>XQQ85LB8GErnOhUZBdG04Xu%%TQ&#ptM_xdh+lLz{2Urs2 zHt_y?4`HYvP5fC};Ojh-7H_cDgrXN8+pkKH=6eD)|I=R9xAhb>?`Nl_xnsJ9vTrG@Eq-^#O0%`EhnYUtpgDQDuJR|;nW2;Tc z996W*FWJ#wnd)&x*WqCr@jU`QLiG+C8!tw^?dUts97SimET+#>4<_@)TpHaEJEJ^v zr+&Hph6BgXxLCjWE)7e$+<$U8-ZZX0%a5@}xg@10KX_Un z=*$~Rwld3qZyxaC5ta^}zx(3wN1DyVUAO1er81Rgp47Y{&~NE9Q|Vu&pI4`PRvW$d zCi*G1IMAWDT`bY#DC4rYWRf|Grr|Jjqi8Vwk8C$T{(OfyypFtA&2AaBBpzGWdAgav zuwbR)uRr%SQuAK2r*wI7u2{Ar%GSz3;-5BiSq>p2TcY7Km&R=*NxKZEn};uG@yUk+ zIMsB`3aS=rp`h?qMHR@Mk?wFJO|q>Z7Y&4Rv%1Mefqj0n!0l&a1CBgs}&A&+4)j6&)M5$}?F^Jmx48R2ce(FX@bJk%msAgbSu)jw;j#nmzl*NHa z$jz-irF*YC_xu-{{GRf0&y(2G0r6w}ziN(vaWC=i$+LSH*=_;k%do9o9Sev@umPl$ zBCAgh-IcTGHJ7i*gbJ6Yt6!fb$hxg}Q*oT5K}9Bcnfom~Teo(7Lj6Lc)$DBV0xk4e z0&L1}%fl)FZH1Se8ye1o5S>lnx?aF$uwbcG_{hp5 zi#d$ebK_%UPRqRU)zxpauC(*!sF*oNQV+6t72UGIY>b^%Yh`!O2>Y%s>RHSA zIczVwbWyHSbs3_0^>~+!-|DSBY*G&v%8U&^L{<5b$wO=zbBZL*aDhpu!W$BD@Q>yTvO4twA&c~8{__&iAjd}eXjioOgy2^ zr5U*88RpW*IX{u;6RKs`n#r*-u}ih)5zJC@BbAfsrXc~!_j&-PxguKZ^qy+ADaoz; z${emwiJS$GS1pu|Y)+|@k)IamZgHaemq6j;e;Al^@jx0;%m%Rd^$l+>)(BjBE|dt? z)R6ASaPNF&KMRoEc%ABIUtv(3EGBY`R&HNVMb9(wYihn2w4_d}!-QRUzAUqu-##3h z#osTrd&WZ6VZ5O0OQn}&k#=icTZOiuCN9$jqYrduwIY9#f2Rv6rvT^tQHKUvH)pvz zGrF63@GT_2)Ah$tQRdTHIi6N{%F0cJ(dLpU>b&f2EQo2kMTCeCeRUz!QEFyeb1-0}5zC5B@zx8R zqf_BX*3|w!rf?W5{UTT1G}BA8sDVXSaduyPfC(UnK99AKQ+}?c{Oc<%6F1gwLMCLX z2~WG9Dp1BSIK)DBwfJlym&a?ay}m5CR&e7tZg3LB%qr$$%;PvwWip}GftbgXaVTE* zUoK$Y#bdagd5L5*lZ>*v&vTn^E#vNavI~(}=yvC^!wVlKvR|h$wDFuB%DAf5eK!#A z`#653y7kgurCGRw%Xh2O(<7A1vQhs)Tr*v^cXX+*Q1dq9g(o6)?g%LyZE^_6yu!9D z-!0AD2!A2NSi-KZo|MSVa*0D|5?*B-4HZP8q^tS6;>ichQs@^e#vH48!i z)5bT)ch~kcF(m_C)*wr50hw6+m;qg8Jy}SSCV2kb zyl=C%N3R{oL;^~YX1EMb@e6u3WzqRX}U#rfE-t~B$RKK;D zH%$C}nk#nRTFkf2ga|d&KV42Uxa3jHabKU$WXh9EzHjcHLe3#Z!gDt5^HvR)mX$yx}i?bZ3HB*M-BX?gzkJ;AIbl;C>|R@pEF=!Z>H-JSEwqi z4>q>=k}xX;IA!VhTYE)MhLk@VAP>S>VS+kmVjr*c+*0)Xtxf((h=^i#T8i0Y3Cww% z=rQH97|l!z%I!ojkj!tmnhY4u4y2pCy=7FJQJc0+4T_cG4y8C0cc;ZAxNC6@ z?%Lu`afjkgaF^o6ouI*80t5)o$1^j}yw7}VX5RJw%8%@|@9eDPzV~sS*L7~=e`s=V zUuG54x;Fzzl`nrhqy0{`5f?e(U(s+rslJcz!MZ=Q8QIg|+&J<;Lygrm~Gj-9?mnHJF8v!lPqhVh2fbptN%rucd zZe(2*mumBM-RoDhFZxdzqXx9d+Hz zI|B>O5XT^AlW2PDi9BRy_u4{|#3C91_bgPf!y&mM{v6eW&Ux{am49=j@rC;XkmdO- zYON#HoSx-zJ8S+X)P5|&WJLJpbTCFLHTH305PIC;k_EWhJ1S!_oPnI3MVgF&{q@VT z&d=)TDG?p4CS#e_o>Zrj_a>~#TaMgYbY>l@_D^!I$?a^7c4`tN0U1251GA?y@aB?Y z!^b`g<)NEU|EuL1lq+c_mAKQPGE{{*YbnK})dwJYaG@c7CWsy!S{tO-+ah+qxRB|H z{LPq1_W3B%KT+z*s~2dc{LAZcZ>g!0o%;yFJG^j0?4q)&CE@&pvDWCK$3wEProDdS zqY<0=Fc?jUtS_vIZf5`N(uZ?jzSehe#w96q9w*>W>S`d&Wv53nH=dnm8OY3Mz2Y%n z9eP{S52K49^6YJwvJ1nhEz(WuI z5Ywg)Di|i_7e+U<<*dO@2x+)sA&w^t8S$DLLQRF5ZUo#=n-g zFCSXYNdcwua8Y~>Zo+mww5N&Hzj{1{4@Ubsxe&a5+)kH_cj@b>d*VfHI9_Zs0p;Fj z#qoUca=Io@I-~=+w|kieRc)zGjm01d==ki8WQnvr|M;3AM2YK8r{yfBUj9?;aN8)c z=KNCGl+R?B&)eCv*D9vNvach4dIwd~Pfc-kBd`Z#f_jadFONN?DEu847LS>Yl!83yC?+-YL#| zHt_CY-)VgI*a(TC`mT%7@vtc3XU%D8Vj<|Hdy3XzXFbePwAn&xPgf#TNXotS6lDrt zSwii)%ek7PI~pJK4~>||&*~9674m*+N0JzCIXahWts*c{J&-Dt2;O69{1ullVD{*C z2rwxuX}Mm82MfyjZdUd4ipJSQk;_;;F!=GGPqeG%4-4z_6JhU{TGsZ^9hJ90R)gEzPY57Au9DCxo z=Yu+JD$LFWRB2Ka^fYy{ZJ+n<{Z1V1l>(;Fp9k;7W-$8GK z#8KCbU1q}S*7uK*BCI$b4XP#saxH^3PY7cx~6oDiGE z#VpLsV8HlgF08cIR06PLp%qr(=PX2oXHT}3_v?(X?fE<8eor^CrU}$?q9B6XTxbog zVOSfCJ$752H!!RI9LyZy2qbW9IXX>u+)#s*Tg<`EYztEc;|D&kkS_an4{SUxqTzCx z^q|dvpI7;wPRiobKlth`D{}(cug=$EVz7jL3H3A6H_`BQk1c<6Vcs|IXzMx$5|$df zR*m77N{;C}Gd^EDi6O-t3p!BwZ(bUL(o$DU^h@Qn>W!`e${z2nz{x`BQZYp4QFDmU zNabdoA>o?m>aW>8y;)tv)b32-dj_Wux0F)af}Gq?467}*-IC-b9yoWRVRb*TBOr*b zQu3ru`a*^<*49DaS~yhWgm2c^lAGRcxRXK{ss;$;K7kx$y%HbF=QP?E^qV75K|3~R z_t2jKMA?gg>}}64Ge&nji>;N#cPSGR2*t*f!4|Ac+2maKxIBfevOUHd2 zpM(+$NMWvKQ91{AY8E)kTe$p1Ut^?i7mT)ON;?MPrLR2E^&vX0zco@wi3b*zCkO=Z zw<^9F^PiTYM9`9Wbb>AV$2H? z9axruv52P!Im~pchFZGnRXoi$*3<^l5a_6*yFseV$Q(1(`(qtREE{@%O8>nLyU>*b zjP~}uAA9NSM(4ZBel@#Q+Z@Fg&u(RL0~upfo*Niz+iP&)w&{IaHb8zn(9hP=Hn6Jw;<)=1%aGYBG3g9#C+6aaK#VQzYP$K zC0{nPH4{AmCDZP3hI==+dk<9Q67q=!1JYe4$k7_gW4R1j34VRkq9%dzvnMohuIsi| zQ6;nx`V*8am~XsiS0Nj^++-Tsj1pUI4NX*`bfIi8@xKa9{2Nn2HD`j8NY9|+nYS}~ zPXB!BYvAd_JaoX#*8f zc4K31nm5zcHP(b>z|KETTZP>sZQG7DI@hzMPAw}z_+nuM1_aYjc`!dvAi{tLSs!-} z&sn`&klP>0f84Jr=OpNrJmthtb1L$`NMUr=KDGO(=sif+J}%G;Cj4dVbwR@p_wK?c zf_K3Z1ZNEHeonHS63H~Ib>8U%6I)1P+h%GKXoKsK09EQ+RMvcFsu1EmR};G%iR+!V z{pMz?A^EYI!#qK&3j$5Ik`RDgOrE+U2m4&{^i434!#Lq%Hz3iikhO@Q;?4NmK-wgMn#VQuA}kE88DNL5*djS3De`! zJ!8;hkd%+O5>|<^5%#`wz{bZX^z58sx4HxEmf8q2IQeQ6i>E2Dwhs9!K<3G%c)Fk)LVih zGH?Wb#7Z6%CtG|jmMnqKC+F0R5|kI^Pd1}f8uG!#_nas0~w^5}{QZ;}7ww4!= zaghu89luUEUYZbE4{!Ca_@@?d8s>;RxXipg2QD!3$`F1j27#j4@*NF3?4SJIYClms}K7tBeA4H z@Zc$D-{iL<)|*gs#RvT+-9mkP`H`nUi=i1ZPnI!fB=@J;r(QhZZjjo->U?aK;__0N z@XaAMcrt{Wk$Ku(bVi_nc;UbtQnfqRs1Cad#0nES<$?)dQ%K6LMLJYLFmTgQ=RYqzV0rO*oze zGl3#H9^;UACdeOKS#!6(=X@PedYX!3*jX@qruh()aIE;s|A+U9NKF1yFBgw~`=8I1 z=Hhx2t4<@gA&44m#GxqHWaUntHc5lY^;r-T#vk>-|6QU;B&#;eA>b`=!5~LgK@sLatRw?JP2yB5 zf9@@K6{Gdqmxf)>E9r$8Q8lUzUr}1Q6ab# zwlV6y#AMCm{L6~tsQBK--xB58K#@3qjEMZFs9`U(FRirKUVbu&{d{{WU3U;e?0fYe zo9D*FzcRU%j}&z4U^}4~!hXE`BSCO*Dt3ci+XR)?LJb_it$3s;+>`F{XxnNtNg3x% zXKq-uZGZC<((CPWt{^X{_ZS%-r>RlXt3%LS<#&QXN+E%TfS^arYKQW_7NGolPUW|| zV65`FyMblsv)YC8SPRTN689B3O3N1tVLR)k=vZdm9@dU~%;B#y?PArKAs1d<>0mPP ziBMU;mg4nbe4fU}{S&wBy_eLC7~*TO&v8nefOxfL4S0%(P?~_ZMi>T({FuO7Vk0^b zoYC*3J8nVCx`%JY+O3WB!_|kkO=x!HE3%Se_fxeAI5LXavoH|i zhuU4zfN0i4@O@IpHQBq!0+wqZ*rAQRzM%hypAbuXoAQz}GQQ7lD+^O!X^2<;d8Lnr z-gK|sRve^wysue~m}fsZNaRWNJyyd})32YG=fny;L%&m^?Oc&PG=$QvlQlKD`&iZy zU^c4=+IU+Gj*G%ODLFyN=x+bMVvMIHF&x(T52V7ztly~gTduFK$zOhBF3{LexoF8Z zM8^R7@Ssq<%M(L-qyoyfMs)`#KPlg8|;J7Tk zQq}!Lps8Ls2U1yiEY;r1f%qwaPA8H`cM2SumGk(+{_?qe&vq3~oZ=tr680ExkGu@<)f53?Hx?}U}t~I5V0nNFzos?>HggC|{ z@mCoV@^@jRdaU+Kcb7P74-fHNw>?N#CUQ z1;Nz5wf4q_t;2!O$>BM56qG^lxjC_^ZKYq%=;5N>U~TAceP0P zW<~vjC2^fawN(DFfmqpeLiafJ4(I;B42Cx5U(D3-?$BL}LfX=W*5RHk5D=oi6or*y z>+2ls1?3=S!6_t{}2bj>hh$>?s3!(y%BkFTeP z+9jzozO?jVsIT`Upj^(d-N~x8>`oLpA&qR=ky5t_o>ZIxCm4}Ee|=>Bc=d?y^f`W$ zauVJ)&%|V!krDh}R=2|p7C@lijsiD66Q}1pV3Rg%ucI!|Y?ul=Wk)noBWVdAyuPWGY0VqWrAqi8J|Ejw$7s|sgZ|cPwFlE z8c9_AdInUHB_d%~QCh-50F9B++~8?>Z@u8e#cB0Ts=hCZAq2&1s^?KALmtswF*H`1pm^QS8Cl~KJ?je zB1RnL+cjAZjH2r^ZX2S}6@hX%&9QgX;2vg~=Q&%-VFMK&+M zt&wgtjvFJ(8ii7QeI;qK&#|i1<`#vUw@}DUkK}`+4t9+RP{Ud>uk1I(*B`hMQViZv zCrZ0n$vB6}Et(5{RCV}WUwgacYTDzv+9XuS?{H7V7%AsqGX70v?OrV$mg8lF40(&% zXe&b3{+fp0;zYOEya&EmhH9|VTs*yh*FH8BCfe95VU~n@SKAtl9A4+N31f%_!~Hrg z(>7e-z^`J#cb#0x?TtQZNAY%dOF-hiO=H%8ucYrZ`n?(byLNbG{qHKlczqV8w1*KH zLKvpT)2DxQ%X2MZk)UmGiR^4RXq(2IMgm&aju(toNiu(eCyPhsabaL9dl$ynBOx|4 zkfeBAX2hQDca-$*e02}sa^^3{Uh?tOnzFQiQYeW?=bP7WB?4w$K^`Fq0;8*yFaaWJ zZ;buA(?9tH2T6R*Ck{iZc=e=cgQyXVtDXh2nOBqXo(HAj%|Be%h7gh68f0nZaXH+3 zPg}CnN@rCDNM-J7A3lS97SjXJCuoK1%#{bG8wll;SARxN2yf|b!!Z)Ne^0r|6gKWM zdT6q;)scA{nJH9M8t+Ada9Z1RY+)NGEP|@>sp;8C2??;{1h^Rt_EDg8-sK;kX)%BUEXh=(DN~7>ttmlwpbc?b)V_$pin%MsqDx2o{Z1Hfld|VcGZ-=O*8OjhuGs9Wqya+;vv`20c} zAe?JI7+l!XTZv|EKFfybr^3Bn^t0+`d31r|^NuN<%M3c5B<-wJC}Y<1Lg?!g$D^J{ zU6*EJThlEL6%&@Vsi={J!QLG}jtV%9x_; z*l3zb*hh$eR3g)T#q1tMGsewB8Jd{(xnsxCY!`SxaN5j&N`yXLfg~qyMzX$Vh) zT%r0cKnRZ*jW8p9-g3vev!*b%a&LjfhEsJ_b9F7Des(}5Jw)GFdQ5&SEvLNfBe!Ix z!L1q66jv_jA^j7f`Z1LxTNKAp^pzQ4#}F~IRWn0H-^!?viHpm3uBgi_d1K?*Lxcm( zb!H!5lUPP0*<+75=-Cy;QHol(&V6})b`a0Zlu18JBRL1Iz1aRP3yNgY59x{UM3zwz z8_M3CblzALDU8{qbzGJ_| zQ_;-PTq!KYd>2LY(kurEQTB;${Ss!f=>N2ae?#8-&7+xLsM}SB6nkwdBYG;dF4$Fa zj`V;~rV6okF6nrseWN3}A_Xg0oZ;BkMZ-`-Ew77`mWGm6Li52FmQWD`$o;Pz^ z-lWbywE+JaROIO-*i7AStB#Ce?N&Oe?)o+`WmvfT`CDpZN=E$ojRi|5PS;&cOKB-# zNkOwpP9oU`$Al73^wY;Pse~d3e_PGz>qFHZyZaMGMI(KrS7_~&XI@O~=G%&E_bk?T z`JVj;Iy?akX1B>r$ux5(es{U965zx9{PLf55La!RHE(*JJmY(@Sl~g%#?&W}kU02o zA84*N-xJK2MyMLZ-1Ctyo=DHc13s|%j?aqEgJ84>R1&z=^w_=i@hE%PoH25Q0m!Bu zX}J39{t`c1%IAD4-Q!I@;%2v~j0nY>ga-wd;hApzqFvs(^8*cTa-Aok<)ee=3|eRYtSj2X+3 zv3uyHvE_d_?%)-rOztmivF%Ld#}__a=SFh@ePbTP{@rc&p1ViM0NQQnUBZFvGMIX! zjN{BS@k?kyiIanyLU^(xA6rtHGa9jtV&T1*X9tr12?Yv)jOq-n@^*tT2mJdtm07^d z^y^gt;BYh*-wmbJf6f>7+HWx0Y*E*IRo~E?>?b1@A;-mfr%yc9BSb(v-TnWJ`@5Iz zJ^oBeMTkwR?{yrM+kyVmAc7PV4z|cvkR>6u49J~qcRWX)cl_FGXj!BeOddAmh-7gCO`0}qQ`kh;HzX}5k$H!U_% z_rb&MjZAre03z>0yuXMV*gFh*tMoRK>WlA1!HshM2U2?H1yT>*)dQEehx_P`&5o9m z!_tMVM#|4uV3B8QKj|a$UtIQ0lBxdSOeM0U>=0rKzaQM+no@iwlysn)*V#}^Oj%tA z68K#ssR*gCQ{!&tqOrsuBnX`ErH5)Mv3@X_=>TZxHOUG6Xd9H<4gu{pAbk~`dxP*1 zgV&4re>Kbh{S1qsOMUPUgDjk(VTg`9&zPU zvYq0MV&LIuViO6m+w+WZ<|VP5jFYCq0VGi2R?R)`C@SubXX9?ZL2}#@&Cqj1{ZT&E z>%Ic{Kk~o>q;~lv@Jh-4q4li17c^yQDWc;R|M_4jLB-2wcHNG|GJ(PUC1B^l`|00xf!`GLUg9el z-d(@nWG1*+a(}3oIm1Kn{rpc!AZ$OS2gH`$&{1b%@!NY1lV~s~gupUkoeAMi{Esxh z%S~7}uM1^qN5|t}uhi?+`>hR?{HY%ysq4TC)q{z^PC!5t!anvZjMpXnx?^=;h{`-- zUQ!KcCOgzEtj|hliHrl9h7QW={&OJZkZmK{jw=0zKA6V&k18mgDfz1>tGGk=^4m48 z{bec-)E~nC*X*f(Q3Ar);$046?HO;pz^#1e4oelel{paPR&D=P)%GsSBYUh3bQlA|Kje=&=(J0F^c)A323KW|mTKaUMme+>;2)y!|%Zb*_d z5bG$C8yk{#&{ruQ9}Zh=E8q3!&Z85uqbn|n07D$^5K5IXfxjT3sxO{ z+3}~7z>by&1W9uVq!KRP_wVasYXAsqI4Aq$AKIDT-NWrXt>+@4r}QiS)n_B5N2g=@ zH+#U_wGE7Geck6)R{hY{`MI(i?GZyCSZ@F{Xri_9DCMP=k+td-@-c!u<(lZrq{ z=Yf^=X5den)9GFn>douLnj;&y*IMH#k4HzjieDYTy*^^)OpK zTw~E|-t0gjju_IZWd-b|wK16VYk=DsoQZ@-DlzgDK|4lKXO9Lo8KaTi^GUVeM7u zWM`xWF`&Xw&jekf0CdM`sh8TS#=mlnS+Z-1WK^At2Fn*(H-)IZr~sKs^l#N%?kaG& z*b}$$^)v1cvXxDnbWfvE)sorI`7o*sekBdh;_gC)lm^Acs~fZNRMLOdebU#_)HYEX zv;cYeNhDV-zpdyl(#UU(?`T71bgcZYaGC2PAefG1*i{yWl_gVB;K1$q7!vIi+XjGqC9E4=P`8thuY54%h_CGGqjoH@gh~ zHze3d!m_BZmy(r{nuXbw@v>7_r}S0!b9`)|Uo^p8#TNO7*9;y&0m%(>Yo;7C@hTm9 zY4gtpM!+?m{6-uTJ1<_qu>aq{uq}JG=Jp;x#h*p*<3K9Ji$WJ48~bNOl|{y{4ctu7ViA@%8sW z1D1Bt2HCOS{BWS_t)j?HPQUGDJm4QdF(NaIJ3CA1(4h}ZGBv+iwXn+>Z;sHa2ZlqKAKBc4E@A%#{?HurLoyX4G{{?;Ds?!vN z4aX?DH@gk46VcP^N0a{^*J`)!&m=IClu26*XPDK~_=E!pU#u%K9qZ9G1Uf!#wA$2P z9aOGPoT}J-aJl=?SoFOYCLnj&Lwh{{hd-Dkbj76BFT094M^D;%plR7-b2q)WN>TKp z5kBHM;_hrK|Nh*aH(~G>PnbauTW^B9;|*wfpkv;G&NP zsF5~*-gtrcjl48IC{XE1CUM;hrYlP|4^swCP2PS3-Vt?7k!swCL@|6rH;X6G15#zR zUz#=Pj@RQ{KUDu(d$x5xNLKI4th-svqDI7t>-^3}_Su*b_U!C<8ZgL>r?%{{W_BK+ z2J{&qp@{p%PcPv9)ovVzB0=c|)8c>0c%v}dHs{pR;lq`zBT!>_@@=+Zkv>hjoxt}# zk`#@dmNU@3G0**5AEO0I=c#s=tTypP_9{b66PIwdRTbIF$k>mP+MhQ~3VL>npwr-) zLMH8ohnP_hlk=DqFmOTJt>vyg251Z#J@OlAvG?{Pz|QJ8E|u`8Rx-SVM6$Gh;Z#P- zl)3G9^|*BC&eN7r(bax}pyVXvvbWa1gQ+@TtEVK*x2mgvHMOSCzX@fw74r=yOlrG3&?~BKE*LQXV^2-tx<3>P^!n6U zRZ-BhURH5N933$-+HKFPySN)lzN~yeq?O*_b_8>$GTkXSrK7L7S zb|x}QekPSdM$M$nOD)xGUh;RXyiCUU|?^%PZ}L}&DP7xnN6W! ze_Y8}+4*3+vduVoEWP%-nX&$PmA*tP^^5@dkO7FB!UNUcgRP|`K6PEmD z{L85Vag!T$>c_Vg1%9Ib`y6amF#PeM(d-!Sj0P@OuafT}Xki6k#SH&J)9-rc6Hmw2 zbonOzp)Y!JR)&B?^cI)@$aNs=D2IzE6}q%70CJI8)mF8z8?O&a{QWhc^1;$`T!F|?v$X#d>UdRxjr&_xio8b%jcn%jQ! z`Ry%58$C-p0N~zpjT4rIR)urOV_Loyqwt^OD zC=u;5`eLF`A+LWc{`!X@`1?K{Yw**ypw~m2)FS~nTTNRdezE$0($AdfkXyx zmI%3sRfE>#dVuaaJXY9aBEVnjcg{;~_kYDF_rRHh_p?*PlV@`Ig!Gh2hanP=V8ds@ z=PJHjL2Cz|aok23BD;i}myG3Ao%QV7UsB-Ln8N}gTg~96lbeIHxH_)Aa+xaCe`*1v zc#K3YXW6S$=Z-T)1wxIsGWLwH|3f&KfwhOP5YUtyF1i1ww2Xg=Wk5b?d(tSsif&qa zF5TWsciuzV$&G&mP^3`z4FUjd=Tl~KsRr2>W}=)=bM2xC?f)%5y}yhX|D0=?PF3nq zdCKDN#qNGx@sXTYiRd}P@Dsx7g%bUm0o&HD*AoO^Fa3A6xG{BSl^UDqD z+fToK#JTLBVFqG;F72vsYI^p#k^b%NFQ{kDNi->n_werZN9q5xPeq^C|HVEP>$m)b z`t{>ct2Uvwql4FL7zq1WF9E%?p{Zd} zxHdTv^gf&2=!I1#z3TM;@L7H>wj9EBW%)7izyQOmp1{@sa3c<}30A()vOqcL3!g#k z<{ohAj-YKPx7a<)1QT22ISt78`9f3p(%7-?BPL;NM>T)v*+Jn^M0%T`{X-_YZgvEV z-*va^8mjwxg-kT`J)McKwze5ZWDb0OE+c!W#MA1wwZpY4UtImXrQDz$-}mNzu#p7r zP9=bybF~n%gW0AbR9|C?Kypweo0*hR#`2d1*Wk;Otu!F~z*cWwOI5>3!2?}>^P&>U z1V9nQ_w81Ve4cJ_eYMX4=8tbR+O!bj6j5i8Sgf0NOY((&cr6)W+ZZOGVLv`kT!3jk z(brh3-*$O^KEO{83F0Rs&q^m_X_(7RO|1B0T2WqeEBp>}sgkcA6=7CbLkQd3&9i

    _sR{TiRVAn1tTwFTDqL6Xm@*&6_S@wk zlFa)v&x--@Z^jBcz1)0PQsjMh@x5tHH!%a5EdRGApjG|1=+nsAeJW?+~6I9 zGkv&rs|u7Rqkc}Grp>Qwk+z$lj=9K~J#np9r{3Q50Dp=sJE~~0+)BS1z2oAGtz_VP za>0E1BzpHofO{8gw%&JeQC2`lu@~OJ*VCW6!o{-PYB%b+`3}tF^;Y!xAW-_h@wF$O zUF2N`YWKjVt!3GxJ1kUv_RLY*@%fC)PkpLwsi6jXUEk+8$}h_eR&*!I@?KiY;xV2t z)K(3fDghDFIZ4>6+WW=DKd`MdT6O%t-`nJxK96v@c zQ);ltfcNZ|bnxmdcY6BL^>rgB<1cZ13k{OW>eQh_EGDWiJ9ZVjDDTeF$ZSt5zr|fM zMW5YPqKuA**UoL{sQdfD)siR;=c_qLm{FY$ zgS#kOKVSP!=M_&=qEUOpP$Pdaq~Nmal9GbB=V#}3>~+-Vr;3vXwgs=nKirvb*}QYj zGW&G8*8{Hk+SRfoo$d!4U36!1FeD?x^&MP!hZTQkv}PcZPEIs zbs@?uavg3eLIysV9lqnOCtVb%T0~)nk z$~&Rn4C&GGPgQRYQ0I^uPb%cK8`)FnhZ!q4@;o*?txb_W+Z-R63K3YMy(5B6dbHAOCKgmY`Hnn zkGy*4@7K5qW@#&iISrQWL7RQXlx{{rIS|x9ncs#l1OgaqUffI8eXz%E7ISM?OL_V< z{@Qi7N20lPl2}PxN>TQEdAV6Why9H21;i#tLa_7u5Nv|1BoWcaygz7VaF=%7s5mk6 zvX6+HiR<+ouG*nCc;$2Lysa0X(Mer~oYHRXUe1_XlKJ5ZuFUDP@Z<59K)`TyX({sb z?rIgvpq75VR2e~IQN@S7U%5riZ)xMp)v5_?V#w)QSFo||>KDu4owW^0P%f8X)Ci=U zzu&+rEJQh#^b5^J{1m=Z!~Mv%V;e-i3g5;~AJW4vb%Q0C?=81>RT^q)^N#m81_p|4 zHn&t&vDCihaM4rJCY92C;fI{7L~~J_am^{w{Pc5fh{LIxacz?*)j-Nj4_D~PpE@0x zYn!heVJt5dlhGz@C3+~b*s|C$7w5sp&#K_e)-!PX?$??K(9I^YwBt0+I4X;@N#Ald z@ZeacQO|Fkh_G28STUTz?`)3Nzq!f72j|a~Ki6C>7GJbM?^6awbvgy}tG-JS!aT+= zf^W@0i3CTCm$v3UKJsQ!L|pj9#wvAKl*W1L9)J6VlBuF}$w6$b0+tn>c?H$`nc(+m z2?YKirla&HUMYJba(^!nC=Zk{dh_)E@!`PYEG z4^Re&!P66R^_Q-?e=OOQoq%(RnOIWS`A@t-QtC_9--5XDJ5_o8y)8-A;(FIgnP^=epa+)Kr~t#1K+Vhpdi|uf|zdwed;A zu-=g=V&T%|H2!#15n8HR-0wlo-$!I~6!f@m>pXN#TDYiau~YOyW-nH2P1#M~O`l~_ zO%b;-WND7C=JY^dApXz|J6x%`_TdsB3XKz7iHh!1emK|BC}l`mg5aa{_z5Q!$WksJ$k(0MUxJ`!fW?gcHUfOR^_tvaOHIcpe)AdkY z)rzqHiAG9G>I_kHYi%tS^pu-9NZTOuo*4q7P|xE6QC^tmjLy~;ap zGe#eD2d-t*8TonT0lmNa&Og+@*Io2pjy!7DUDU_%V%H#wBYGA0_HK1f@}ksAYqhZ`;YkV4*g8Z;^2qs?u7fM094Wl0mXVm%!(P@6PkR+nyGUw}PmP7-7a?1$ z#NqgD@`kW(FzZsEIO_nvPg9@Uk);2LLjPjM0Q^_k=3d3Bll@|{W+R@d zVB7jFFwIsRO||i{;birLp3=sv!myV%Z@VA-Z$-OWm!vnfu5+H@@1DT00$b+Oo$V6jjedm6Srhy(oMkg!d z^I|{CX{E_?0%iX z8jv$qNw@U45o(zIEgxGYh%9|ACWBv?YfHmw%n{DxfUxfj-b1CXsNAhXFGqRwLB@lk z@sAY~9iHtf|Jbcc$5p-EXvH^VEKw!gH1y+ke_FXre3ypV(;c2v`9Q^GQgLJ3>u)y> z^~C?x6+=9|<)>XEFcL+rOP1ZWb8%S($)3={!cA*2b!;p%-b)r5jFzwXDRJJ+qpGQ= zWu=v!NTdv(pU2oMVpfoi*2LwCv9ruE>+KTPJ)HAwTx5@($E*pM`F!B9|6ye&>%HZNT zm?0Y+HNZ?hhBnS=0?tu3GFF-1BJ}M)Cll@u2CYgGcx8^RQJya(8!Q+NmTYcOqH%HE z;1CNCA(R80XD}IJ^4d z7k+(VzMpz~pjE`dT7zYzqx*8F3TRR6V11+*VE|V)BYkJ>S|j)ToU5=mz{1u1^4QxV zI`4F{`V_$;W8(JV7qd-;0Ma1w&!Z0t8;Ji?jWIHT3`%h3r}uB~LjNH}ko<=f@kaX$ z|Gns^S8H&SO)2G3E}^-BU*D!i7lLj?;V}vX5>uQ+e*^xFB>&y&EhvfAA)5RzQUsD4 z)jR*62yd~@_JPS}_Gzx}z9*h<@4lLj{tdjpkcq$ahJLbqSZDe8L-Zv)8?9IHzL?fT zD(2g7P=8FAZqxO_r;H;}`vkmr$Pd3`Eh$5uH>QxF+GkJ%;@pydY60D}#Qy@q zu|kIa_bJalr5z3Z|FAY)c}d=1VnH5*LcJa5et!UWo*f@9@-+qD{P{V3qxtaq)qc@S z5jSs)x9@UH&IfWnUFUJzn$MGR9_}~$H_cY6(`34h1x>4IucmG^VXt4E;r&PB`2R!U zujoswx%j{64L_y--_E3zK9hFB>9WfdYEhT0|CoLa5E!dVSSEWMjzK{9?f+s`+qAfj z$yN#e^z%)*X!DJb7>eJ28!j0VP)Ei#k!`gEjtJqkL_(-Sz}*(M>B!fi(XN+#5)Bb( zDRJ*Qr2qA6>#ycYoCXTI>*NOny>LrxVqOo&hbKLLVs`nPg<)ZS!imgfc&np%?WB-T$4dB~VYY=1XB~jjCS1YJ|2wuWbPpdlYX{Us zYntkI$~mTB*FQlWX_wKIUCG>3nYN0@WJ6(ENr;gOpL2hb%}RQBXD&c%ycNs+daC{r`Bcd zjf)FcO(&%N@O_J8f=g{km;%lDb$CD>eF^*d0JaOZ)o{^aX}jGoLnk=PqNz z*5(&oN`z@xS>Vf9KOcDfhLJi_WB3X4N*4nlDEr9PB}Iu`3?N0lT-wzc}wN>N341 zE=1X`{>a|SMw_fDTt^Se(|vusvwfJ(e1(^k`B1V_Op1UYgo+~*zdtlO>ZL*HOe0@p z-pX&M&wz{GVJ-xl6Ff@K+IH3$xue`p2bm^tVR5FVXNpRXP$yWhb8uDBA1TLLmxV{) zt1;5H$#OG3^E11}r}+vb5qf-x+~3ydZ3i@h@RVVB$T^`{bv&gIeJa~C7`xJysjefZ zYP%{MVKMkKLo1}ZB(=PWed?wx${%Nogx$AsoCS9ziporXc7pK zvm%S*AH9NWJxte ztfv>W~yQR*b<@!WcU`+lX;2H20A zK--&nV@>Cb-ddtW_sW5~iZ?zeZZ!$Yzt>T&W705cVC8AY-C|Xw zkp;h@q1CM{fmruT#&oUf!x5;q)DoW(EYnta(e02r#5UdDvw-;&}g5a5svpY>W z%U=lV4#@Z3xdRPPOuB~as@6=)8}k@Lk$%K0yRvCQ4*7e&7w@!y1l7^_V9GRYNrty2 z)KEsZy`v%t*q5B9VD9Dbk%u!M5Pp2rQ>NMw0@!>JsGPmKiegRvl8;xJGG@1x7?|mifm3h+Ckk49L);LF8zBi%iost zJYzrh%)R`u@7%a)?V@fvn?E5#o3cDzU-@?G|7-6`!`a%pe~hJunu?lBqDl>&P@y%8 zn5#2NRj*b{gcxHUT1pKG)z;9OYPz_sRw_{;DA8LnwI~ro2wFmv7>fVN{eOO+=Y7B5 zujg5N?X!M+t>0SD*?X_kIIrb5Nci=naM#}9p~3|Z>$8M;nmsWl{eI!`{d$7Mk3;-! zmuR8D5A)IkjcyHO{`vJYDyg;d8SOfkqKD7U@iSPffLaAW> z;~6$F;CP$(zgquA;9ms(Mc{u%AQ=R*0QMz?k?;hSz7XJiUhAY(Bq*6Ra$g$ge+m*- zD(-MzxyNml$u8YKTa;3hdJz-{E9CgM+C!B+vcQr@|AvN{bEqK+AuOV5i}?l4QxnAM&aa=0SNGT1VMi;foda$VOyzM2C+QD3ES_mTGc}a+&^EpX3|2pb13+D5YF|Wz5pfXg*yjPkEyK$Ig~CI=?eus$lbF zuEnNqQD%X<&$|_weW5(8p!lU{_S18Y%82EhwphYp=VeIq_N9ajd64R1a3(&aC+c>0 z01mA)1Fpt*6n-5W`?EOU=Z$kG|By}IpGsmSiQ59#z+#}NdCkZ`&crS&#*5#~)_PUg zd~8}nWZf_BVeb}}zp=UPxU5~Kk~LbDENMR>I13#~syoAhS4^#ff*gq_bweK-`?+l? zQ;=WgT1N3mz4FYm>33%qI&lGu()Jqfn58J5Y~Hx}q#~PT5J@JyD9kh90YkKK=7)&r z(6IMV$B>J?kL+v)_;Ja2l~kF#`9~*63K-M@$pMf^8_&KJ8E+EQpRw)wT_A`B11`

    yisD^K^Qgx#nE%){0TR_L_R$TeAO=}9kc_Jl#ja2N{x zeo&+u46-b9OQjQhYu%KZ-sd;PUJ25cMt!+EJ1>)pdY161SxcRHqiU?kka9b*i{oA1 z;$@$|`LA?CGyU8~NT-{VEry>JSmQEFOGrnmv0=yR#^xmsHtp~eow?)Yd5@ScKDD_b zf7Lx$ktNXXgeTs(2BMI{r77s=CWQpC8fCyL%Qm(tn0YflESH`N%9v@n(Ek2L|9S%9 z@xD=7UWuJ%2sChf0nvRNW(+u}icZWUY!uqxP$1(Qi!s)>9#VIBsxz|uGU zGu2Bqb&BhnnNbabKr|y)p;>g>a{g=^jLS5+)RcBYP*hbg?g!<_q>U;;F>lBZDSOTZ z(Gb46Vvd&?8C|z=Er^dD8IT7YrDSr9fJE(bNi=5<%kj7Jr^yU3`^PFPfG$~y0JJED z^R&G%#MJPrd}DFD(1OX{?CuTo2tx%;!MK$0tF|pO;|05L{SkaaS7GP{U!p$&H4$B0 zJ{7gJP}(91)6g`!#k~mWb(}N=@Cbm0N7MNMt_+zG{{8uTH!TJ8B?fYP42`Qdcz>4f z`D8_XxU2&PMovr6)6+B`+mtY_!)P)R zJ(R%qYaD&M^52Y(r&&66jr`st`KNHq-NXCii)xDWf;^vQi$X@jh!5<3MH@IfygAjg zaNUbSDs8QQBR~HE2*jeL>#EtPvBs0y{%1$;4auPT1znl$>AH7Ir=6Aiou!%OR_Ap) z(B8x0||K3J?N1Bsbd~^8~V+se6=` zngM%Gu{RKWbf6Twk}%a7tXqw~7p?VLiR06)A(B1K!CbXPQGqh|BpZC(5;G1HFM-O%ssH3ijcVsQ3wY=o{KF6p5cIKZ#wiE5HQX(` zJ3Xs$lJ7@jB{edz`0oRNQ@E@shOUaJv#m4sKf%6SEMaEwfABj+dMHC~2&a;|nRV@^ z4GA#r$!($YkLiVQDXXSE401pkELZB|AD*ot61bxn=SA-K;WVF`$5;Z*Y^XBV;yU%ZGpDg|XcX;-x zXT8X3@hO~I4JI+1L9{H6N~x5(M^dWwL1c9Lr-s=XqOijg?Ah*=9~-XlQ$C8v5Upuu zgDBi3Rvjjx;-UeEEgW$YRc^US2I%j6o>~2L!`Dx(hi_Hpo4c31lh%!crUJR95`Hkc zkwcNbRHIWZ0G$;H0OX1}8sODS&f@d12=YsZ>5!>Jois@DqyFQ4*#(MU3fcv{+~ZmN#%$ zybjBSBJ|bHJVb=;ug^YrI?-S4{bW4@CfRS`E41<&CFk93=l3G`t2K448w+o-0c~n$ z3mAF=wNWxUxHJD9Lz)d_tRAZt z6V=uD@qW2;5vR04u1dj+Y9?mjMP97v9QFv-k@RrY*X^gDiCh3gY93B5m=hR!1#ze| zf-RcF&C1(O+@j+u_!SL6{K7P_d7Zi7A^EA#;jUd*sj<|k+n>WuM9Ct)eW#2l_z*m9 zsgkrYdT0UkJD5)%^n0LzcEOpGW$kD6!AttDCbaXT9^s-7!525Yc}$=jDj4*)BrDo4 zBbNiP5jtY$D^4OC#+GuZIt}uQ8KK6KSl57H`1OuJSdVSlrf!VklLtBNJu1?kS&{__ zdUi01{d~aA=I_0ADplG?>bN1NwjdFlgAlFDl9MI9l03W-uV^sR$#}I?MudX(Cik8l zG`s>E%>3)eDWQR#GWdV@>Zo7o+-DwsL*Z4-wcjE=sRSWK5FI9o(4 zQdmuL$-0?O1H@@x2nQJGQS@{TMGauAxXlndNLcw`Z5o}dMybRiWFu@OSm)9~e1^|j zq0gFcYrv2wzTLBjIN+tM9ARuIT@5T0%onC9u zh!^9EQAnNNHNz;nZY%KvU0PBD@+&IvNY9n!Hbgm>9yeZ zWy7iOJ`VN}>L20vrm<{qcC|Yv_=mX5B^LiyXI@%dMc|y*6z$BJ z4&kldV)zMQN*>s&Ai=Gg{4B~}+3fb+E4E2Q1USA&9!2FHQ*+`-99{_>&_X*#taUj5{ cIaMDIh~1rdLhJ}+26zIpwQ{tqGxtjPKQh9xB>(^b diff --git a/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide.png b/tests/browser/screenshots/baseline/JavBus-Detail-light-desktop-wide.png index 47e4cb4b5c13155c087d135bd56a2c228105addd..17add1e8912aa886f34ae9cecedce0e112d265d2 100644 GIT binary patch literal 43695 zcmdSAXH?Ve6D?{-L_m=yRYXMTJ#-YL_bw0;5s=<{S7{=F@y&vv9-zHhfv*tH5nP<=5lMpo(S;7ZY4{qGJK`1ZxPUFUnyBRlb z+zz;Z>-wDu&YOWZZalgn|L(1pd+OE*imJH1hN9zs4)Nn|OZ>HPKr{e~k?HJ2ZcdH~aSm{~DhcAKU*mZnXb@ zeNf4rKaON{n%i1%k0Pn5si>(^l2Z)YE{Dq?fv~vTZj^$RnX!=(gXeI?jNy!)=Bp@a z#ZV15g+E_gH{}?Yr{FXkvb0{Is>PaFc;KjJ0Q~$t<$+kYE;WG3{O7A`mLcQ#?H#nE za;DlpaTsI-Ha;JdCnWOUZX1B2zcwRf_RUWx?##;g$IKc)D6^w{aLh znVKNH;E$uf2DY*?`-dgXx2&aAKh^>{^Qj%<%JDd+X2#%ox4n*qPwrOD|M}o&h@O;G zHq0dw(UBV$XX;;GZuZZIZaw%|sL0B^RR#v>7LZ^U?4AZLH z`EwN~w=Lk)jK`;6#qGAk5@LWlxmVnhKCc8=PE72ly;%KeY{i@~6rrC=e(!ygpB3)Y z^l3E?!Y(%maSZf5u&-FxfHGl#TJr+G9*qcwe+hA;7b zkSDNmrgWRP0p0xOsH10QupJYHk1eYQ*~3Vek@k}U>O(5T&)PKnsD4RsbTz+&>KHqP zJlPfriGWHr`8w;rZqqQAOqZ*C*H4d61^`~Yiw=|I^<84#{Mxg`XlCY|IPyN_G4G|r zVPDpyW^^wADI*R;9+kz^j)#p287v_`S$s7MOCW+Ar2-^cq_UBM81wL?!Vs&NVv&Bs zqv4p8dV2ABXL{uj@c>p(VaRFy~$EDTOwZ-=8wX)!Kfk@qcu08&PjCA+uNGtHWmU78@42yYfO-pJaAkHH?u=Sn#uaX$<(M zjgh`#nHBM~1O}-m>f3B3g2|~9vb;?6uzd8;A2i;EWAwX8r=xTUYpzTK3U{L{q-y&L|!hkHN??AZ#EAFe?4-cu%*i}$u&@TDe6!q!MPzC$x z{s8+ZKD+bpY)dxea$>Fy=!m5BnfK$iHn)zf|LHO}mk4+USxC~X8xCJcT zOgn|9S8aVH)UL)`$6+7j&!Qi~dS2;<4|;}^+@-gD^(Z>?MH7(UXC&R_rW?Y-zwrg5 z%Wk^i>(_y+@r)cxx*gEuogWRZ`&06pb8>oiy8sXG55La7{m%DUz9Ge9=}-Q$myUi* zz0RnkXKIun#%*BVqUjySIj$txYCuVw@+XVvP34U+=fKw>vmM4@w zNb9GQ#k9IEUYuy(P-`Qb?**z=2J$=6g+R8)B0?}Gjj>@_KlP{3Wo@IN7qULT3vf>K zACD>Jbs0QVQ^W;qZ0eSu03=FS7%UUI?G_Z9BNo>(>BQVR=bk@^pm-Ya&D%5n>Yc{c zc`UMnboDcXb#+W+O(jyatWmVA_gU#0QP7f?D?MP22Rt!`2qi!iHJI5>b04B3}>B!wf+alW)-o~@TNLiKwHFR^-&M$dpo zcygnON+KQM8FHn9>++t zPo`iTYe{UISTlqe&K&yl56$+P`O!yG@~pSF44K(gR1Ig5iINFvvHC1p zu9mGsWCTmRMYb2oGEoq?g-c6w1yK+CMg>ViBVB?<38zSv+wmqZb6``$S{RB`lueDX zX+v?7JkpIXeOkcIQ>%Bj*U|qC z6S=nKZma>v9X;jj3n#K@F#o2{Z)uCgBj_-U6gvw3*t$kbb{dewDyXNWZKwr`hQw7f z8?x};?{eqiy}0vjGft=U10BQb?$4^1C_MT$g~Cd6>=0!?^rjVJbb4`?XbU3prmaWTf zwy5rHzog$s1D)hYC*ng^BrpfX@tl6HU5f!`Gqa# zgTyBbKWbb-z&wB4QGw8i)C}zW$!mirF&dT9XUPE`MSZ-WS?|?qPN#-exp9J*aHpjA zg)b~lddcc)5Fd+Ohl@r9*J7D^YC;l70Db#I3Z{Ax8^wtf$FrQ$8S97`-D{lCqAn-h za~E+?E=yN%ZCdoWx)~a1-uw^L{|o{98N@-uVZcdpvClMS6R3h^Y0Lix zj*|a7CJf#N7V;v2@Df;Md3Cjho}Rw`Y?ITlvT|QXP+mt~o?;N3;Wpkha1Hk~;lIbl z{=Z%LE?3U)8E2Q57uI2}#>TJ1!Ug7SSBT&e-Yna9V!y_#Y5ajC|8;sWe)xaEi1B|s z^!!1>x&Q4zbVQ_`Wc4ZH#B*z3_k>*jZ+AMUxXRV6v_R*v_|8(5FD#UvgiD^i`Tc*|)fwkSkI1@rS;NgN)anJR*54WaC*|wzM6u zk@+yG_>;_bX?;6m@_fMP8CR!lUT~zsq=SZtg?^%J4HC>UQO9>u$#cv|uoxduchvIy z&%`j8|H%I6rMA#UXBiSx%&Qr>DJex`cX7uDvmZOtsTnO{u$O?o#p7I@e~xI6#jnuyjtAbEOC>B=<~=#| zryaY7>J_iW<%wv+m``8$3IXOV4(m0BN^{j6WDT+(jDP;g#pz}As_P7E9z{t;Ghe#B z+;_Y(uy8l>UoJrVyq`iDl&GW$Umz8*TPV2^-Z%a$%NGX-L~vbF*0M?JzINHm^bu6epq>DC5y)<0P(C6C@247RGFV~?3*_3=`8 z`}?Z{)sVh7~Ep%nvM`7e*AQLoAV<_(bUED3YwQ3mL&>OPfTtKx`EL^6GlY>VdK{Der#A0yXJ z0&5DFd!UVXK#|Izn0!{8`AoBPjFM(Y@2mk*UriV6eH4}k_4h;*Xz27tP|loxjae;`6Ma9* zAu&inGhOe#nuwI`eAlabrr~Cj5q1!3?$KPI4)V-UaI-GMK0mOI`PjJi!e>x0;t)`F zFxKR-I=X>AsFi4Zp$sxFe6bPt=&(q-M0^xZP06DpU8SOj843eI|RnZ_vcZQOk}P@_wxG7Ib6-P%^&4mtzAE-vV<;u-re zQ$2dlwC!^LHaA%eKg_iKJd zG2tk&j|(&sao)v_14urhuTkB^FLRTF_UuzdB|NLNTsvM}$p@i(+iEe~^k%7rWG%CH zwe@KaF(HbKYfx35ninK_{mPRm6dZa!8Ipg(_)ARY*O0-wYMyCQQJMHnhIEd|fN*|T zJaS}SK;8guz>Z>);b{jk~a5y zEPuJYnG&&w3MlcX%M5&SHICn;EPT?|axT+_;7DMNbA|bkFuD*X`xd0PX{DX^Thy3^ z$HxN{Rk!YaE44}zG(X_D?8SnM>++L`I<8r+O=x-9Q0183l$tBI0xo%=7 ze=jOAA(bsss=fjkkBE?sk?KOnNqu73%1Yp_E%EJD2Dpj2Ou1 zXJVKjClnDk2QWfa;P86b9{`zm9MP@We}8fH%-rr&17lzoKI?f|>Ul;$J>InJq$x1Z zYizi9@B_&v%`(x*K6A8#E++t#LX4Az)*nNSkl1C*!oA9zk_4 zdA_B&$?{J+R;e$vx8(Tv5^*p*72~Pwm+^tqQZg4l5|W zaMN^%x~{gKa~?VZJw2QhsoQDY{c88@#n@ThVJFz?brvgMLLBfiUGxeaXibYn>6d7& znZ%(3QDd8rbq@_SQ!~qYUzd<2t2f`wUiry{8*~A!H|Vmz}_IeQU*gm5GV-sSTY(&U2fQ zDaD#k38p@^&V2>T$B#?LKWP+K6 zTeoiw2;8Z!;_#LKnZ0k>H~Zdo)nt-Tu}sGW=%Wl64yG>6`nZPnNg38fL>!-z$~yJB z0j2WqBBuMW@1WI)E5~SO7r{6*wgpXXw&kR|{inq2n})t-0|qJRdj&_jj00tPLq)_q zb_8uv6--b)S&O{Fp)Yk-K^wL%3AbafUTEk!ZVf~88c8Bz;_F9jAB{weMl3y#Z|~oK zk|*%k%hhxfeu%PM`xzKIM`Hyq>=Dx+-54$&(+{tM+l}(9=pZ{6tzx*LVQQr(s)HIOtLAR#N8L@-77f#+ygknU3wc_J@pGmCFM;He1Oy ziO~Tv#r(mKi)3fz#XiCZl@LuwD~)cm>5D+~J%TeyBlbm}pYG zOUkCRLUblfNNSTwZuGi&-A&$HlJ3Hn?|e9JHoCGckCjv*1Tq4A$4hrSKf0Z!*vXkf zItRdUMwabd*gzMD5n=HmHRr7W74%ONUArp8umZ)dngKFgt7E8VZp52WV$*zRtT=r+ zr>5i8iSJw6)xoz*Pd?aEYPj(5Zsw>I0B=vz8p_=D{Oy8^?vL6sc~4(| z6j{<@J}BSeI-)-Xr*wfMX{A_tE-MN|9D$(Te2l)0H_90w+~s>o2@A`j?x)9G24M-L98pb`lO8U((p}U z8*qyCj`qUwbjfFA^*zh+@<{jZTiBwlDk&LFjQeEIm(P(2F~6a`N@C{vik<4>He%vM zJUR{VSB6gnE^_q-&Wj>*VW77HhN-#rBrE%tq8fwtR?JXnle; z&=DZpRvUHoZusTlBTCQ!`+GrX30l!3u15o46LAE#Kp$i~@EI2Bf;NY%XkYu@bAxuw z*riq?+h^1hT|-i5@Z~v-7WaZvVj|s&?}pp&g+4fxzvKdI*@k);G?=Ko``i#4S7D=D z?K%s0GjIOeCFvOF)kF)=%=ODRf;K1cX)TY58o$sLRROcL?71qjOb{!wUh1-}=bD&4 z5KqnaM^>t)Kq~<>1v|=+B?N_Sg{;swibM37Y-#z|8>hMKq!keN=ofp<6xVz{QE@%D zHIvM#Oa)ivQs>zw;5lhp-ZdxYSbyUD}Lu|ghDgXc03x2@Y(1q7^>+4*w%jk z?$Pd_z)sAY%asO8P!o}5i|k_qvE(yI7pAP{gVIX(!A}8eD0e1H3P1YbHY|&AQay+$ z`x2@WJ)?exZc#E`V~MovYEKPHdvj;r|FY?>*Ftm;z&d!8At zbR`k3NqN(`S+ij$)|#DZn0L$ZT2Wf-?5v0Tc#&z;((T}>8XZO2&LHS?mD%_dLxeJK0jIgnML(bT3bnR z_u!~wR4jy>^Ie#W6}!xMxlvDbV-wG4-S)=KP!hMcUaBdb#8S{w{y?bwG_BXbFrkp6 zFx(?XNZrxHw%KGPPOoYyk3G~mhFLC<-d0ZB2(7v#X0rq#uLcc_jb3y}o1#s#kHLE< zi{BQYtc3^o;M97rzUlIl886*%)g$=wCiPH1^E2zWVWOr~8~3h5`Pr*4D>qmAPgHN* zc=eS1e=lf{>-}o}^&jDzTNt5K68O)T)b9VI$=>*XA%4HqB?|bL3;28L{~r|fh5!z$ zHPrHx^J2H$1OJpI&F?Qp`m&b!6`vd)xnaM1c#&v2I{)QJKBo^TTyt1(^KPWI0yV-G z9<1M3j5K!+7OF^kamQF9;J11w4dIW4c zTv47kFE#8_cXB)-)~?Z+;F=Cw>WP-x%p-P7K`iaA=5HAa>15N<#k;SkM3Y?J8zfhmb*msOv{RHj$B7rPCho^kWo`MS)WYK9KX4x2N6Xb7FPj?0 zDuM9h6gh27u!aC<`eE(3@xs2x_NVA>A{&T=$5#xQWD9sMsn7~{Jw~s@EnyRcWMF=$7O9G!9h4cW5*9Nqi*~DG|{hxcg z+Ni$1Z!n1ZwiF*WCLPEe2qJnn3J`s+tsVQ6wuMG(5t84d@|v`i=sE6!~`)>Ed%7?;N!t1ClmHU_okF7d+du1bBoKKz4I2X{suUpxJMCxZGh z%Uz+6jgcxF>E+!*_O2v1KBgKL!B`c*pNAS8d2D}lZ@4@>N<9S1`0-4MhZGf#L_PtPt4@alkwQOj)X?-VBb((m*f_YAgAFaP`{7j&%j4N0@$D7@O^P^L ztFL2|#b#wu@z#FKawE1L)v|rFFQgl5Hn;HsfyCD&sz>yA%lxybIu{=W9qD(7FA}yn zI>p)teydaPt(=)oSd4DNQ?$33sk+F8`!|tn7rXIksqOXc)mJ>-MJOt$x!wKGS}af< z_P^;6qq`|qG3jQ&Ku_`Kb=Ut9+AIHnMJIxvKVvX~zHev}!+zw}Q2fQev0j67%h1kpJWC|Lg5fGd*u1NxT%x>> z;E#jzzR%~^1G~k+l^-|vk((_=4PbrpLUqM^4GZrytiSrYZo#+(k39X=5N`QjzBUT7 z=c@ftv-#(Q%=(Ip`xsqk^X>*4oA;J3n|GNW^%(_l0N7<6{hUe~^x1Do$;zh)pJ;F+ z^Vl(g7tvOg2<6BWB+B|-Q4#Vf{}&E7e%l`kJQ4#VhZY#x1|bUU_kx96Rad)+9>KLAlNM6GB<&EAQ=zLk2LDV`Bew{(9N!C)v|NwOXdN%tz>d4+sqF2K-@kSPOpOt9Fn?25I z*LYV5srNhS+go$Pjte2*MAv7k6dT^gpR-&cFbluLA@j!UVPR*Vp(lmd&}%uEr)q$S zEppvjjTm}*#uXXR7s%w<1&NaRgPb#hvqKd)Gt;UB#ZKkW^?wEf$kSaP-LpP7MAq$< z3l=X`S&;#=HPutw@smsz&Pw3YlBr!$o#H)t63aLRa~bJg-`5kl#>Rv&r?!K@$Fwi0 z4xYrbDfISZu^&_%Ts0P(Q>8)_mxd3DOLJcYReXIS4LEV+)L@JCoYQj?wASjaxwz)vC8hJmBAjG3JeT113v;eshR8aNT7 z^CUIM&nt4)ZzYe(X!|RfyxViITZ7bhEf=2r=>ivr=QDqj^Eo||oTedKE1rrXuK01# zcOKM%uxKOUY^Q7&)fC5`0;^9iNI%jR3=aeBX2Qu*3lsY!C0hmUjnbTKUPhxWMPDSj&g)FrC1 zI`w3<{Wa^yQ`>LF&Z$S=N}nMLfU!@vlp;3|qe&`ZZ}*`TuO8p??=RSQQk&Uf%0=}a zSIU$DW}MSf6Vyu%Qr5N-t=LHVjf|y}n=Mw(DKEEjF+_RfG|~D?1?6rJDaXXA23PV- z(c7((Ga}1(QR+rFRpbjTgjQ68yWGM%QfXPUT&u+M4rr(kK4BFr(`jeoLFShfTXOhH zv$Xj0oH`|08kD$M8gr_^s8Y5kRuAGnQB(xm86$oAX} z)?t~fJR5@rtp`x{t$OU6a`ayd^-R5P{i^Rt8?+m&PrWKdm4-g=3?>&$paE`evWe$b zl-&_&Y`n?IUL~_yVs5gVqS_YRWwz9fFC7|RTfI^Q@!*_r;hT&(N3mJuBhPy|pU361 zC0}NmVJ~_*qW%LG0oSeWf&S^Q$=z%H=^X8zw9A}=8=~+|Vn{cj0Q3#Cb6Hiq6xL%4 zb3;Ap4=W(Q zjV`rAIIxlxXhW6s+%Zqps*3w~o6p}qwdzcnc$n}{Bz_HiCjWP1w)Xp<&EOk1{_ohP z{Xdqvub=4u>7uY1(_ji8U)AUsj8EVJrYu)_p84m`M>;y>2M{RqXEnK7{{&kpp8%k` ziccW)(-(-O3J7}MO z8Fqk*m7~*YQ^VHQ7M72^seH41$>Wja2W!7j{aFzGBl0b3i5frGrXqlV?UWn#{ZL(W zy{!M#&*b{LF3fsY!ftHz^>Z(fE@N^fiesWG|A7R3s zopj%#FY!gqlKT??&v?jB@b*Q~*^D8WUm(SAOJ8a2?%tE%c=P0|IK7j~_u{Pf-Ak>z z>l@aAh_BGWgv%HMda-y@YwMEF!~6mQWGNoo;PRQfNvBy&kF;iVq{Ro^TN3)6X{ z+)~Q*M)_d{Y!`a2OSPGWx#iWFMde8YEr!U^kJjd;HR${uv!P?Nx8m$`?khmW5wb4` z1)_WURAukq7qPR&dS-?AWaGg{E?M?NmkczUVyKeYl(y>&hAy4SR~KV&oVpQ}s_C$b zsy*7BiN3X$dF5m~5fKIpsd1(f)sl1j1J*uACSlD()K#P7p?NRAcx9FkY1PlDnW5vK z+jIWD%SvJAw#p|k#$U6iSId0Of^f|4^iEE@>s1XZou>b|eM@tnh5SNhVD$2m<+wO2 z2iMgE9FVE+;TQ+_{=}W(F7k3jc=LU=Q)ByeYA=?b&ooNx;!G2q%|2C2ayrRg$BE`< z@Bu&HAn!^#51F0u()Jp+0D))Tw5H>}fV1|mRR@6O-JPd(?kkB)Cx?k0I$XmS^K{Xn zx}^~h?tYwG?}Ar?$Ec~tce>#kbL|N?e3Mx7C)otaeuH?rOy$GwE-O*+lMv9PwZlTOyY(bJEuE3ULVvNzn(ol6&H=8s;T zlREBrS@*+EVxW9vZSuh>sX-=Z@n#27cA(SrV_f9tCGmC3Z%=$zclUw6MoUOTJ zhdzj#xYi5|wXW;5iUXkLup<4%Ys=Y#A`qz6tW&#o?Urt6{W0hLKYkrJWX@e=)P=(N zr{=}iWxln!)~@NpXNjx>y~?CBqgbvJi5&B7|91QIZGyiJPtl6cFP-&DHnxe{hMWIc zrutuzADxuf9n@LX|7y5yQPD-lNMw3)EDEf4jX3B6TY0BlU@%O6cv?hzX@#eGfa|vc zi8Q}l9X{|G+!K(+xa;RSR zP)NQUU8_$&gQB~B&#Ah<@i`v)-rfB`Dy9JE&@3OjDj+}8TW^{??7_WCKZ6kHi03`#uWL$cxjXX;9x~2 zGmaEDd{%>CJRnW~u0eg1=n+OYD@cD+crDR6cqJt8mKPG@)p9n} zQFW>^$0wW-cY@Jv@x6F689is_Yx>jocaJeT@urkan0MC8%@v6>A+H{FO&j|6=P84Q zI_<0Z-^BCgxaJ&KpBv_e`LPYuPAsVPcXyZt_&31R>LT44oz1s(C#Me3;nl=gmSL+l zjOHeh4pKm1EWNl}XwK+K0Z^u`|EOTnZ@J-X-s4%KteodfD#vZ_AQ_qQw6oPalIZ3U zOy2Mg;Q3kMDVP$~MQ7Kmq+pPqt6LWy;WM-y9P3xF`<~MipSr2*iz2f=9SRf4PU~hI z_i3tArI#&hQ^f_~OQ~d7UrANQ7qq5=9V8(jbC3Gs|3V z9i4GvDzx*3+pb3$8a-2C=UcgyV&yh_@w@7AfdAZ%u9VEw1LsT*x2aX|C9Eh$7Vpw> zJImDAy1i}-0Ghiv?Wyr%=sUCUGZgAbtT*+PG~Fy;0pl2V%39FnU==QDs*dv~vXuhf zd)ZYdy11#BZAX9BP9m&0I^{4;RM_OCcaGz_uJ06bjz?MREuPzT8X}ettB^` zn`VS(wZ-gv50Z?PPOS)#*g19)s@tx;DP$iQ2aOA_m$H03Hy2XPz^Ufn@u_+w2D-Y0 zcX>8Y>NR7J%J51Ecu0&(K&jrob4YsIqB;F|uP~}CoVO-$$~Py(dtvTlFwq!DWiB}h zfSG(8c=ztz1X(tk!KMqvT+S=^Sy`|Sp`Sx%RR~>;5E_1DGu@vSyv2;$4+P%<;x?>$ z6>u{X&Liw&w;v9CDFHzF^KIl?w3#D(=D3 zoazb{>NGVM zbVC;}dTWw3V1nz7d#tRr^9nD6+$FFbZ8tG^6%s5K&_jTB{AjmCct>&%f3-gZ-{Erk zXoH^A%Z7gJcIkksv!hk`VZh2G3Os<<*iQ;(A|anTc74l1`ffp&?v;fJ-RV+W`u$~s znDlzjsVjWHL!BS3-+X9W?i&PCNX^iZ1ez&JqwId)j~svQ{SqlB;sI8#rMfTSo7bN1UTnfm&1yZz~7`}y>r zMAxy$CFY<7huqI3n)F=SoO^5o%p~FY-q`}UUL0n`bXT_?bWl#hzjozx)ivGO@FZU7 z;o~!AKAp?689dpPfXFptnO}sZeo5>H<-6gb7 zK3@!2&>fLzjl@7cfk)3GJUT{pP-gATJ@SktW6dMg9!MREPbF6w!<#E>xG}}-rytG^ zZ(_2S&al%P+Pe^Ewfz+zUMq^oAMK0N%K1R*BH!QgjmcgGc(nuWqV6T$a%^KX-94-| zBbJX9G&X6#-fsoKwiERNRC$~ChLSEPPk@Oaqcs8T3)JC?gdXj zTli})^R~e#4i(MY_oyGJZpTRB8Eh}#05D5!FwcQrv6pq1ZA=`!x%kC9>pGTyNR(z( zYSZhrX1v|;l&(yf&EN=;Hbv)dz7Yy4va3Xzj7jV-@;XFTiO$>#pQ>O*?pn*5e9l zEN<&B>lu09jbhGEJJr}+hp-M#rees!E{2op9931}`wEo4>dZYLD)IbfW8wTOMxSBB ziZ!s?V%^_~kz*~vH)X_BZK4rK9iF>Fp+J3cjZ=gIqa=9Z*3e;Dbw|LuLcN{hsTpKcQaTq^B1fCOra1Eh)yuAUdfZpKBT%9NN_dAZh2a_78OR;^K^fko^jg zqui^04eBEh4dYU}xnM22*uTa*hrdt&)TQtmxBpfaF~Ab}ls=4P&9|xgYCj=Jg{qhX zKK(5JP)h5Tn|i~}@28rYTlHV->#OA>h>X31#y450sGBAcYi?#MujjC(GrZTgpm#Ud z_TC7iA9vL3Bf|>UJ$HNep}>PoV^He+bg6|+Ytl0PDH-T8s@mx)fg%mJf;QF$Bk8(I zU5L`LtCbPk21^PObROHs15I_F<_^pLGqR;(@$kz+t&yf80{`GL!jna(;V-c4%9PGJ z^ud=%8BCSk@SK`=)zRVMc=cDWA7y(#9rqv2kwbc8?p?2zquE{V;sMzEN9r$2dSLm5 zc;ayHAVqCsLyxx#N+9K5#+Brrz@7^wm@?;ZINh=W@@lS69O)T!y;YXys;)H~pCMZO zxhpS%PGf)WBN2o+QPk;b?6(wL>qp{izo~G1zx6MhsurUO;36mY`J3U|QVU&S$52i$ zAv4)P%usR*Eq~z6+jME8Rdy%+oC8(h@4a)8ef7SoD>Rs{-bcj6nFHctBU~A8mWF@8 zBz!grp*J|lcFtDo_wL`PGT&a`4^^(Dhr(lF?q)Iz$16B<(9~)5_Oh8DVs^YS!A)1) z;+}}!Q`NCi0@mP+5D=6(e7n=VN?_BTYD2&!r}|{daV*v^#4GDkcJQQKy2*S0d?s%f z=la1Rv-N!LOu|}tXYU(0SIi!zVYBEg+JtNv19y`@tfKW@)-HS5q{8smXs%&4e#a-z4^E!DuswE1cV z!=0rJKgpcrGxY&#=6^cTU&*zBn@j^KH|jH4#HD}&GZl+QzHDpv-K+A!tUu0 zL<%I;HkQV~(Bi12u*Vh#JkV6i1MxRRMQq2@~PwI=)y7Uy%vWg z!W*p16g5syZ7tmw#ng<27wmUNV#{#At6zwwjWhD5fy>PLMX!cnNX_LBw#&)c3;cFk zhWTG&M%RBbTqYpikWd^Q3v=;Wu`q~J7p}-YvKpJWP*-^peEg)yH^Irppvl}D&;LDiDcg9TRoVU4O3}u22v(ff0RoxovCC(uH&Ni?cropAa(+SeS~$op!?W zKdC}iUeHcnw0ngczr3%w78RotMnCL1%4UeYax2X3$MJaVaKXGCKk;qIoKm`6L|^S6 zQ|6lq-=Q(IEa~pY?Udt362lq=lBxZgiM&!X!LtP_u+ehK{U_C_G1CX`0v2D|c+Od?*0Y)Yv z_|{8_4^~&A`W}K-E7a%bhjnC3sKfji)ie$PdVIwMm6U3~{bu)Lxm~LiMB~fT=(y0i zN6>Y8u9X@@i9ov+9@atsTd-gPb|~IZ3N+2vJ~ebS%{}x5xso?-F$m$DzLX00uLRd( z!ULRl-hhna;#{U`zRdhCc%Ap)yz+4Ax!)@a;>6CSb-{bVSHb=Up|GeJI+wx$l6a|4 zN(Pk2tq`*{u}1*j7DTxUIIUdJkpBEf^*C|I(17hX*Kf7nMIGjRu~5wATzDv*+XY%K z40zjlKLj_v5ce8yd<7_=Jyn%2VL|i3GbpaR&!DvBX%nt=D>hTUG;vaFo>W-lnlvLh zCn%Iv2#A0>gzBKm8qYB zO}d6OhZnt44UTdVaFi4t!2pt9`LZvJ!cc@L++k&URf67Qb(~g&!W>47FmNmQF@>73 zY^rjl68hb^a3DTXc%?%qk=j^CY&6ohAAqs}0t>voC!C9ZIorytEIy$YR%1_AAmE@Z z(^(F8{HXyrSa|FoOjB1dXbDyKyLy7b1{#@rPmlQ0dNs(K=6}RtCv(zhlV4i|J@lP` z7haOaZLH7dxe`_1_MYAfXgtGf*w}!%96Ns7U0x*L@!Hv{88cH&NF$pu+62gWgYEm* z1xEWW7IGhtmHBG9UVJWl>$mWxzs~4Muy6<})s9s)E_L_Os{Z61Y4N&6d=aV1$ig*k z-$;1F{>K?&gGMp_wqtycvJqS@d+t${g?4fohfx!Qt?gSzYb%w@Gwp1F-(KsXoM*nc zIc7Uwf@~1(vMx_8A=9?ntpB9$t&_dg9hzF^^GU+WSSJh$m6uG=(IaEvP**+g+&UQ? zpum@M@0oT;^Xz5t7++d()Gp}C%XaB!x$E=O>2Zp-6#_C`oRzSmfM6Tzf&|S*@qsF@ z0Q$;PE0O3KOHco39hG0bnhmc>?+4Zqp(jHo1!LNyJxx(@2Cko$VOP$R=4pYs9Wp40 zXRW8?GJ^Plvw@2`(ys|;j8G~lhwpt=pSGO{ip~c2UcS7*=;k+Jwxn#f2g2j%FV{|* zs!vRW&fCi(ldrTr+;KfOSdGLc^it1HPeYk669!&fi>(q`K7L ziLg}KgY_?wydQ_5J?%e!XQGPn-Vy2=BqkA3maCN-2_WOvm^w*EAD;R;)b0L==-ksO z`ofVzEf!ShKa8zrcv4DABbesh_^Ea`cBSzIFu*bfQMnRkEcTIJ@a~UL44Nc{(Kagb z=M!*HKac=#A7gGGg2H}$9|-xf7EMgEckl=TDb|$87+~k1zW7nR^k%o<(V@KckQ(;5 zW0@X(i_XiaE)sL+Ft_acApxPKL0sI`uUUQq)g+?n+h6^t%^>^doq8QnLn7&Zqq(h; z8=;|1%KdE0r3HHQ=AO;GNd}*=_Oq|LDQx?qtOORC2YMk}JRTXa^=-Bp8KUZ4yVFg- z(DUOu&EgY1aqhMX=YnhW=HEn8%4nUU2qV4|F6g&@v_gMyFb+$%tfoCm4lRagk1@a2 zTiqwU5aU}Y%!u;Og|FUg#i(kc+trvQYR;3LTwptnzM_sxMab%BOKw*Q8xVBy&HT(~ zUYIXE;y1Hf8(>-WTby``;T&`^!JxDM!r6O9HPx+uyDA${X%8YIT||s@=^aD_q)YE0 zA_NGb_ZAzV(gXygMq22d&>>0-y@$|3uL-?}KzOs?|K8_4W1MrooRM!CW3J4MWUaZ@ zJ@4Oj;fUVcjuFHrCeD}%Yi+%2Nlah2UWNgjg3Uydj-V)Ajd-4c@Mv1^zEqYqJxvK^ z$2P4;FiM?3s|{$zr}}IW`LKzi1(h}%;h^+ey!O`PJ$G!tC-@&DS6n*z8>`GS6a~c! zSE1WbUE>s!t2gK-UTDa|EbQ?0MTw<0SCn&u4pUP0oKsk4zr>+NSXr5u9bX7;fN9p(N7%*G4>3P|36yQN>`gwlzLmJ#*H+dG_W{1G&9hkTt)Yl%-a$ zN8=1z=*0c|#5K8sg;-{J-^p=9ZlOzVe)s;5)tpTnJ>9<=-AU6uxi@U9fC4%u1H)<^viD{*O{c$IzK$QY`7O^CO$pjU94ha)+>Q!v=XEM< z^7`ucYfJ9zvWOL3kB=Q!!gQX~8GH12j53l}V(^&6(Bt9Ag? z)E%Ur8``dd^+aD5n$oaH1pT<2PSIvE zkVzCqY7E7`1WRql+?f$>3x0bdzEh`EV0vw4;8it>Q5)nj+U>!ML9SK@bPU~@Jfwpa z3i&P$#&s--fRoYd0NgZ?qn6Q+j=0yQXVOB5+mnCVA~WCjh)rEd+Dz<4#`)sJwN>He z)7*x4mB?3g-D+ENYvavvnQhM4lY8FBzp6a~<`cwx-~FC#_-GP2z(8Y=TUm{=BIwtS z1s(+kw!(yuf(U&y?eKD?-+sL+J8Te}snWs$fy5&WaavC5<@+4Y z(xwN-jVdEwy=NtN@Fkl&EI9ABOG%^=GHfxW=2pQeQ__M%*S36UlZwuiSx`B4dxUa* z=&-W>K?GI{{Vq1Gnkru^_WQe{grkOT3h!{MwRO37q_&HABQI=)c>!M}H7(1^Z$5%Z z>#k!$proRT&eda6Z6=>YT?a(VS|=(@TnA3` z5)85rW6FN(iy{~lpY;;TX20fp{r%oFfMEZoqqJZ*kkZsNcBtj2qAmdG(iKeO8%Ej0 z$fRj%2_^e!^BYZGt%{0BTD4}`t#s)zj1N)7rL!)dR{HHHX6s$VoQL6|UWmPSRncB9 zZ-dBN+hEEK)3gsyhr1~KRM_TLg$Ib-E7zb^IGp8Oa9P)nHNmX$sXP{Jm3&a%!kaSZ zIT|}>|9)<$TA-fOy-B|2dFXXc^KEI56bV*gDOObkrR~G|FyEC0(=dW~5( z?$G&BCGyL$wN3nJpKme)zMH*@Q^|g~uWCp4y~Iyo$ORNCMawxd?O&GUy?F!)C<(hJ z1QU-o>#yr(`j*ZNIqMlN_H;u-r`J6z{sL)34&p~Qlx%25rkvR#W*#0LUR#;$?dQ?u zD$PZ7ka8Q$JHfHyKQ|z?*mMfI6I3oIKjAkrG!oid%(97n>zp-}hqf*@q+`R5gsGrX zo`exSn~G-JfAkb^b5~@^rxdS!9=`RFBQLvnxd-O1=`M}3BC1Tra+YJfz4yTjPC3dc zP_{&KJ_;xC6uGSApF;&%KlSM8JU#scmCS!7`L|i!1GdrA4&7W1F+t@Yc`n=@R1sF3 zC5l$T*C_d@-~B$5CX^7&T5S};W%~V*E0Y|G*;G5Klf9G4w`M;4Fn6E*DNWrP_`v|A z9;dF-kNff@lH0@uUe@tODze{JXzmg>85v2SkUPHkENPf~oc+?sR;Y~mAMtj(!#9>O zmFaxZ3lRP}4r3Xb(12p6K77MD0VmNErZShoIr$wszENRvB!IF2!5dFO8=_ua0u}MF z7=x5Vhpb!b$#>;(*6e^v#r?;3~NN(H!duB=!`7q znVh6k67!6~RY~wBp=F=`0&W6#b<8178w*eKr3J4C4V7sQ8_&jpkrxetbair}%dM-$ zTnrRSE1;Fr4;{B#w=4?ym^R*|Ok&H|dg5UpbvaMnaPdWcXSe-%=f@wV }ZFFZH)T3zzbCe5b)G1<85SmJd#%hzdZ5I~WHcu*>kw?3LHgpuBcgktr zt{8Hu(g*JsW98Svf~*G=oWv~vmYz)=rd4p;PD4_3sI!q%Nx{tJtkAuo!~vk-EjY+O z>-mJ~7)-iro{fgbc>UoTvX)Oq{S@B7!wp8Oif6nH%AE8w+kCdoS~35~aT@NF;$4+b z=nkoin}mr%E@fqaAy&l$pRGM5A>Mn*47Mz<+YZJhScG0jbW`oMLY1A5}=3sLM*2MtF|C18=mBM6d0oOk)SgqVe`I+ayPv zFmd;wP7W;$v6p%5cnTWbp=cP*1YOmaKiXL7vR%z!e)tckYx9XaGrNtpg7juKjC)2S{#{i2=*=3VQHCAh(=9XB)lJ?;zxco zsqgs;p6dub>6PJ4A28L|w`DEpm^fA9_uII+mCZ$AhkIwgvngu7F!rrkQAg-BQ^x(R zPGd~)-rhaq;5N~XjL5`BkC=!f0__BCpwH@i+!&akJDrpPS=NWLk*$^dw-(S_=fD{{ zD+H=UQRw(2=>>6~@3y0Y;Q5&*JS&`tS_Vhjt*eYH-K;3`=O)!^{0nPq*E`k_3LxVDIZS|7pu=>~=Yw=*;oR zt7~%Z8HHgJiB6M;$0;C2%Q84CR#u|3qiE$-4DRz1x+0tOWMzHWnvErRr2|4TF&9)S z=Bm0YivY}yCRLv%m@{AuV^XZ>zv+W2O7jaew9mXe$)BXH6sre|oY167FCND)XQV}p zhj(2|D;?VT8=5?}V4wuyxF)Y&<-!viF^0|LRYvBiinJG`(Ek@*XF;(|L8YGy9sIuM=+a4mfWo|c15oyT5&_`EkB zd(|}!Lte^9j?eG8JvZ5f`1eDi0EuBr`A4`cCk}`R)`fh~Z@ZtWEf8fQD6~yDi@6Sz zk#+{}dn6TJRR1xjl@0+YDm5J99+w~$w(`9Iq+QnQ4 z{bnjwk0&hcaRIB)kWkLD&*cU$$N1wIm3xfoAr+r7T$)CM)nr+?F z!r4;8#V2Lt#g-?6$-9B8v+Y|kOp#DcSA{#SJ4`J5Gq1JuO)j>!>Xg|ARFrydBE!d; zp6Z)kq2t;l+LrfcDYXF|bouYE?K|@T*zaf_M~K5()g>cDDb&*-*7*%tqY$wJ&u8B_ z>Qp!+V+=g&Y#`?@4m%r)&)}kKFrQWA+#)T7F`CCiA3Dsu9RmkW%9H%KyW0?(cFpc| zh>UX;g7V|Y=>5Pkw2uNULs>^+nUoc$>#WHYWg3MnuYPKArs?poX^L%f*2CvsUp0TC z7aVV{8sP{{@!8z$#WdiXiI*x{;@#IfCAnHw2yNGn6SV$x+7KR{g(#hK0qYQ`O23D3 ze}V>GhyRsX?<~V)IV!ySWH^#LR7Rs9UOuFkR?rmK47ag00V_=m=M#UH75S>n*05mp zGD{eC$>v-MYiM&t`O!1@8%p0oh_7$i=**A?h&NS9SS?!^6&1O|8M0yv%^)$ohEF0s zbV&M4z9*;)7tV3kGU+UAFha3GQ?y5ao>6c`$X;W+zUgHM&mPvh$N+zy6#;Hpsc#Eh ziQ`_rDeU;9IxKJg#+uJH1FPG5G)f1g*&rquY@dBcc4X`E;8SH4rfHa+DuiREt=<&5 z-&27v8Q!|A)zxdN7o3lAKR8I`ECtU+6h3KftB$=XJpzN?*nPT^G?zmm;<~@>4+vpV z8W@*1*|Z)1tca1`R<;v4)wvGcQVzC=q?M4o3Yf(H&Au}7tDCns<~#C6%m)um=&!XW zRUF|Of1T(#mSsK^ckUV}B&{jdc^vjF7NuF!No>vh-hfnrwTA zFRjdlr59!DpD95@h)_??mIHUl^uBd#_I{MDJYw$bL;=cC_CxA--;mmU2>b#wj3{Sp zh|k~p(n$2uLB*)In10ve&?uwFepG?SpeSi~wNgrNeF6#wI(_{-)910<0~;_Xt1H1oJki@<uBK!1JT)&Edk-B2VE*ZEoCPs%ZxSpJg z_Oy;H>)Y`OsO5*D{f<<(%rl=_XoE*#r_;u={FqU5GJn|3{y3Bomfq74R#@B;M>J0u(BaR?+0x0!~eTn!=flVE-ikS09r{Xdnc zP>fpzCkrW7PE1nbP{E>$?7x0_&68~8q`lbPi%JtGwOC8~C0XtH1udZrS)h1<7NccC zwUTVWb}QM21$WZq1GW;P&$++4(Cc>69=Ov0l-6Cy$+lxxYGCbSz8=-!gp@!`ymwOj?vjYx(k|cU z?CFp<6CskqpwaHqxVDP}N>H;MOMgw^0@AftHm_tlCE-M08B;030pt3DXnlk^6m$)D z`P$K;k~Wl8zTMqZw8`)E_hWOhq^*D6YigSD?-fz=7gy7daW^ZpSp|^H_2)K5=8*&5 z5Nu?^Ejy`6WWZ_^zdBJ4VyfS1*1@ET-e%)Gs>X{GqHGnfyUIA~QV$XGO_+*27{QwQ^){^oCOXhvjGqPKWn2)Wq>|?gS-10fbSOv6T(a+=R)bMRPVRt$6DjsY$p) z;^#&voA-Xdz_>=p><6S{cX+f2+~;;682KTSYW&DRjKNTh^Ou76Xv^d@W%Snsy;o9M zT8iH9k#sTdKA7fdVLs*(YWS$tO1QwB0{nxkf0%N2XnMCedpTP=$YF0lgB$CHd4V!~ z_|2^hn*yTbY3{0z2K8AMqg1aoXhy0{Ye>d=-YhXm7|GCPA9 zTjXn_DwyEwk=JUiRwuHanqrr5+n)4Xc-4nJ_d)x+U;2S3tZ7$lJ1zjls;54MY7@kj zmE768IGw;q=&9)$MZQmTw=~gw542jYnam5O_3GQR6JxFYW(i#9jO6!lvTp*)I=wV zyr6ztpk(VjdgjQ%HuLBrzXI1oW{hfESphc6^l~OppgOqp!WKh!-}HuCpySyL5brLQ`C|HSFachf1}=E=$wK3l`);SEd%>`M~*Eul3dve>6`WX7l{P zq~gtT?pHenfvt+zcJ|-%pPGAp?o4s1fJtlP`48(dOyx)Yis-cU@oC-3VYf<6o@