16 Commits
68 changed files with 7360 additions and 6911 deletions
+3 -4
View File
@@ -1,5 +1,4 @@
node_modules/ .worktrees/
.old-reference/
dist/ dist/
.DS_Store node_modules/
.vscode/
*.log
+21 -40
View File
@@ -1,11 +1,8 @@
# Star Chart Search Enhancer # Star Chart Search Enhancer
一个最小化的 Chrome MV3 实验插件,用来增强巨量星图: Chrome MV3 extension for the Xingtu creator market page.
- 达人详情页:保留原有的详情页控制台实验链路 ## Development
- 找达人列表页:在 `creator/market` 当前可见结果页中插入两列看后搜率
## 开发命令
```bash ```bash
npm install npm install
@@ -13,43 +10,27 @@ npm test
npm run build npm run build
``` ```
## 加载插件 ## Load The Extension
1. 先执行 `npm run build` 1. Run `npm run build`
2. 打开 `chrome://extensions` 2. Open `chrome://extensions`
3. 打开“开发者模式” 3. Enable developer mode
4. 选择“加载已解压的扩展程序” 4. Choose `Load unpacked`
5. 选择本项目的 `dist/` 目录 5. Select the `dist/` directory
## 手工验证 ## Current Scope
### 详情页控制台实验 - Adds two after-search-rate columns to the Xingtu market list
- Hydrates the current page immediately
- Provides plugin-owned filter, sort, and CSV export controls
- Triggers full-scan flow only when filter, sort, or export is used
1. 打开巨量星图的达人详情页 ## Manual Verification
2. 刷新页面一次,确保内容脚本和页面 hook 都能尽早注入
3. 打开该页面的 DevTools Console
4. 观察是否出现带有 `[star-chart-search-enhancer]` 前缀的日志
5. 找到 `result` 日志,核对其中两个看后搜率是否与达人详情页右侧展示一致
### 找达人列表页列增强 1. Load the unpacked extension from `dist/`
2. Open `https://xingtu.cn/ad/creator/market`
1. 打开 `https://xingtu.cn/ad/creator/market` 3. Confirm the two new columns appear
2. 等待当前列表页渲染完成 4. Confirm current-page rows move through loading and then render values or failure states
3. 看右侧 sticky 列区,确认 `21-60s报价``操作` 之间新增了两列: 5. Apply a threshold filter and confirm the list hides unmatched rows
`单视频看后搜率` 6. Apply a sort and confirm row order changes
`个人视频看后搜率` 7. Export CSV and confirm the file includes plugin status and after-search-rate fields
4. 首次进入或翻页、筛选、搜索、排序变化后,新增列会先显示 `加载中...`
5. 请求成功后,两列会显示对应达人的真实值
6. 如果某行失败,两列都会显示 `加载失败`
7. 点击任一失败单元格,会按整行重试该达人并重新进入 `加载中...`
8. 如果只看到左侧达人信息区,先把结果区横向滚到最右侧,再检查 `操作` 列前是否已经插入两列
## 当前范围
- 阶段 1 同时支持:
- 巨量星图达人详情页控制台实验
- 巨量星图找达人 `creator/market` 当前可见结果页的两列增强
- 列表页只处理当前可见结果页,不处理全部结果导出
- 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果
- 列表页真实结构是右侧 sticky 区域中的“按列渲染” grid,不是传统 `tr/td` 表格;插件按列索引重建每一行并插入两列
- 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态
@@ -1,278 +0,0 @@
# Star Chart After Search Rate Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a minimal Chrome MV3 extension that captures the two after-search-rate metrics on Xingtu creator detail pages and logs one structured final result per route.
**Architecture:** The extension uses an isolated content script to manage route state, inject a page-context hook, receive `postMessage` results, and print deduplicated structured output. Shared pure utilities handle star ID parsing, route key generation, label normalization, result shaping, and JSON extraction so the network hook stays thin and testable.
**Tech Stack:** TypeScript, Vitest, tsup, Chrome Extension Manifest V3
---
## Implementation Notes
- Current workspace is **not** a git repository, so worktree and commit steps are replaced with explicit verification notes.
- Keep scope to the detail page experiment only.
- Follow TDD strictly: test first, verify red, implement minimal code, verify green.
## Planned File Map
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `vitest.config.ts`
- Create: `scripts/build.mjs`
- Create: `src/manifest.json`
- Create: `src/content/index.ts`
- Create: `src/content/route-state.ts`
- Create: `src/page/hook.ts`
- Create: `src/page/network-interceptor.ts`
- Create: `src/shared/extract-after-search-rates.ts`
- Create: `src/shared/get-star-id.ts`
- Create: `src/shared/message-types.ts`
- Create: `src/shared/normalize-rate-label.ts`
- Create: `src/shared/result-types.ts`
- Create: `src/shared/route-key.ts`
- Create: `tests/extract-after-search-rates.test.ts`
- Create: `tests/get-star-id.test.ts`
- Create: `tests/route-key.test.ts`
- Create: `tests/content-bridge.test.ts`
- Create: `tests/page-hook.test.ts`
- Create: `README.md`
### Task 1: Bootstrap the Tooling and Build Layout
**Files:**
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `vitest.config.ts`
- Create: `scripts/build.mjs`
- Create: `src/manifest.json`
- [ ] **Step 1: Write the failing build-shape test**
Create `tests/build-layout.test.ts` that asserts:
- `src/manifest.json` exists and includes one content script match for `https://*.xingtu.cn/ad/creator/author-homepage/*`
- build script copies manifest and emits `dist/manifest.json`
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/build-layout.test.ts`
Expected: FAIL because project files and scripts do not exist yet
- [ ] **Step 3: Create the minimal tooling files**
Implement:
- `package.json` with `build`, `test`, and `test:run`
- `tsconfig.json`
- `vitest.config.ts`
- `scripts/build.mjs`
- `src/manifest.json`
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/build-layout.test.ts`
Expected: PASS
- [ ] **Step 5: Verify build output**
Run: `npm run build`
Expected: `dist/manifest.json` exists and TypeScript entrypoints build without errors
- [ ] **Step 6: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 2: Implement and Test Shared Extraction Utilities
**Files:**
- Create: `src/shared/extract-after-search-rates.ts`
- Create: `src/shared/get-star-id.ts`
- Create: `src/shared/normalize-rate-label.ts`
- Create: `src/shared/result-types.ts`
- Create: `src/shared/route-key.ts`
- Create: `tests/extract-after-search-rates.test.ts`
- Create: `tests/get-star-id.test.ts`
- Create: `tests/route-key.test.ts`
- [ ] **Step 1: Write the failing utility tests**
Add tests covering:
- parsing page star ID from matching and non-matching URLs
- route key creation with incrementing navigation sequence
- exact-key extraction
- label/value extraction with synonymous labels
- text fallback extraction in a bounded subtree
- partial match stays `success: false`
- unrelated payload returns `matched: false`
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/get-star-id.test.ts tests/route-key.test.ts tests/extract-after-search-rates.test.ts`
Expected: FAIL because utility modules do not exist yet
- [ ] **Step 3: Implement the minimal utilities**
Implement:
- URL parsing for `pageStarId`
- `routeKey` helper
- label normalization helper
- extraction function with `exact-key`, `label-value`, `text-fallback`, and `none`
- shared types for results
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/get-star-id.test.ts tests/route-key.test.ts tests/extract-after-search-rates.test.ts`
Expected: PASS
- [ ] **Step 5: Refactor only if needed**
Keep utility files focused and remove duplication without changing behavior
- [ ] **Step 6: Re-run the utility tests**
Run: `npm test -- tests/get-star-id.test.ts tests/route-key.test.ts tests/extract-after-search-rates.test.ts`
Expected: PASS
- [ ] **Step 7: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 3: Implement and Test the Content Bridge
**Files:**
- Create: `src/content/index.ts`
- Create: `src/content/route-state.ts`
- Create: `src/shared/message-types.ts`
- Modify: `src/shared/result-types.ts`
- Create: `tests/content-bridge.test.ts`
- [ ] **Step 1: Write the failing content bridge tests**
Add tests covering:
- route state initialization from the current URL
- navigation sequence increment on route changes
- stale route messages are ignored
- duplicate final results are not logged twice
- a later success result can replace an earlier failure result
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/content-bridge.test.ts`
Expected: FAIL because content bridge files do not exist yet
- [ ] **Step 3: Implement the minimal content bridge**
Implement:
- route state tracking
- page-hook injection via `<script src=chrome.runtime.getURL(... )>`
- `window.postMessage` listener with source/type guards
- final structured logging and dedupe policy
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/content-bridge.test.ts`
Expected: PASS
- [ ] **Step 5: Verify route-state behavior stays green**
Run: `npm test -- tests/content-bridge.test.ts tests/route-key.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 4: Implement and Test the Page Hook and Network Interceptor
**Files:**
- Create: `src/page/hook.ts`
- Create: `src/page/network-interceptor.ts`
- Modify: `src/shared/extract-after-search-rates.ts`
- Modify: `src/shared/message-types.ts`
- Create: `tests/page-hook.test.ts`
- [ ] **Step 1: Write the failing page-hook tests**
Add tests covering:
- fetch wrapping reads only `response.clone()`
- original fetch result still resolves unchanged
- hook failures do not block the request
- matching payload posts a structured result message
- timeout produces one failure terminal result
- duplicate patching is prevented by a guard
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/page-hook.test.ts`
Expected: FAIL because page hook files do not exist yet
- [ ] **Step 3: Implement the minimal page hook**
Implement:
- one-time patch guard
- fetch wrapper
- lightweight XHR wrapper
- candidate filtering
- timeout handling
- result posting to the content bridge
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/page-hook.test.ts`
Expected: PASS
- [ ] **Step 5: Run the focused suite**
Run: `npm test -- tests/extract-after-search-rates.test.ts tests/content-bridge.test.ts tests/page-hook.test.ts`
Expected: PASS
- [ ] **Step 6: Commit**
Skip: repository is not initialized as git; record the verification output instead
### Task 5: Finalize Build and Manual Verification Instructions
**Files:**
- Modify: `README.md`
- Modify: `scripts/build.mjs`
- Modify: `src/manifest.json`
- [ ] **Step 1: Write the failing documentation assertion**
Extend `tests/build-layout.test.ts` or add `tests/readme.test.ts` to assert the README documents:
- how to install dependencies
- how to run tests
- how to build
- how to load the unpacked extension
- how to verify the console result on a Xingtu detail page
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/build-layout.test.ts`
Expected: FAIL because README does not include the required instructions yet
- [ ] **Step 3: Implement the minimal documentation and polish build output**
Add:
- concise README setup and verification instructions
- any build copy adjustments needed for Chrome to load `dist/`
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/build-layout.test.ts`
Expected: PASS
- [ ] **Step 5: Run the full verification suite**
Run: `npm test`
Expected: all tests PASS
- [ ] **Step 6: Run a fresh production build**
Run: `npm run build`
Expected: build exits 0 and `dist/` contains manifest plus extension scripts
- [ ] **Step 7: Commit**
Skip: repository is not initialized as git; record the verification output instead
@@ -1,450 +0,0 @@
# Star Chart Market Visible Page Columns Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add two after-search-rate columns to the Xingtu creator market list page, auto-load them for the current visible result page, and support cache-backed retryable row rendering without regressing the existing detail-page experiment.
**Architecture:** Keep the existing detail-page hook flow intact, split the content entry into route-aware controllers, and implement the market-page feature entirely inside `src/content/market/`. Market-page data loading uses a dedicated same-origin API client plus a list-sequence-aware controller so DOM sync, cache reuse, retry, and stale-result suppression stay deterministic and testable.
**Tech Stack:** TypeScript, Vitest, jsdom, tsup, Chrome Extension Manifest V3
---
## Implementation Notes
- Current workspace already contains working detail-page code, build scripts, and tests. This plan assumes incremental change, not greenfield scaffolding.
- Current workspace is still not a git repository, so each “commit” step is replaced with a verification note.
- This plan only covers stage 1: current visible result page enhancement.
- Follow TDD strictly: failing test first, then minimal code.
- Do not route the market-page feature through `src/page/hook.ts` unless runtime evidence proves content-script requests are blocked.
- Market async flows must not cache raw `HTMLElement` references across request boundaries. Cache/request layers may store only row metadata such as `authorId`, `listSeq`, and list signature.
- The market API client should expose deterministic error reasons: `timeout` for `AbortError`, `request-failed` for network/non-2xx/parse failures, and `bad-response` for structurally valid responses missing either target field.
- Every async writeback must re-check current DOM markers (`data-sces-author-id`, `data-sces-list-seq`) before rendering, instead of trusting a stale row reference captured earlier.
## Planned File Map
- Modify: `src/manifest.json`
- Add market-page match coverage while preserving detail-page coverage.
- Modify: `src/content/index.ts`
- Reduce to route bootstrap and controller selection.
- Create: `src/content/detail/index.ts`
- Hold the existing detail-page controller logic currently living in `src/content/index.ts`.
- Create: `src/content/market/index.ts`
- Orchestrate market-page DOM sync, list sessions, loading, cache reuse, and retry.
- Create: `src/content/market/api-client.ts`
- Build request URLs, issue same-origin fetches with timeout, and map the known API response into normalized rates.
- Create: `src/content/market/batch-loader.ts`
- Coordinate concurrency-limited loading, stale-result suppression, and row-level retry.
- Create: `src/content/market/cache-store.ts`
- Store success cache and in-flight request dedupe entries by `authorId`.
- Create: `src/content/market/dom-sync.ts`
- Find the target table, insert headers/cells, and bind row metadata markers.
- Create: `src/content/market/id-extractor.ts`
- Extract `authorId` from row links and fallback attributes.
- Create: `src/content/market/list-signature.ts`
- Produce deterministic list/session signatures from current rows and URL state.
- Create: `src/content/market/row-render.ts`
- Render loading, success, and error states into the two injected cells.
- Create: `src/content/market/row-state.ts`
- Define row-status types and transition helpers.
- Create: `src/shared/normalize-rate-value.ts`
- Normalize known rate shapes such as `<0.02%` and `0.02 - 0.1%`.
- Modify: `src/shared/extract-after-search-rates.ts`
- Reuse the extracted shared normalizer so detail-page behavior stays consistent.
- Modify: `tests/build-layout.test.ts`
- Extend manifest/build assertions for the market route.
- Modify: `tests/content-bridge.test.ts`
- Point detail-page tests at the extracted detail controller module.
- Create: `tests/content-entry.test.ts`
- Cover route bootstrap behavior for detail, market, and unsupported URLs.
- Create: `tests/market-api-client.test.ts`
- Cover request URL generation, timeout handling, and response mapping.
- Create: `tests/market-id-extractor.test.ts`
- Cover author-id extraction from row fixtures.
- Create: `tests/market-dom-sync.test.ts`
- Cover header/cell insertion and duplicate protection.
- Create: `tests/market-row-render.test.ts`
- Cover row rendering transitions and retry affordance.
- Create: `tests/market-batch-loader.test.ts`
- Cover cache reuse, in-flight dedupe, concurrency, and retry behavior.
- Create: `tests/market-controller.test.ts`
- Cover list changes, stale-result suppression, and cache-backed rehydration.
- Modify: `tests/readme.test.ts`
- Require README coverage for the market-page feature.
- Modify: `README.md`
- Document market-page support and manual verification.
### Task 1: Split the Content Entry Into a Route Bootstrap and a Detail Controller
**Files:**
- Modify: `src/content/index.ts`
- Create: `src/content/detail/index.ts`
- Modify: `tests/content-bridge.test.ts`
- Create: `tests/content-entry.test.ts`
- [ ] **Step 1: Write the failing route-bootstrap tests**
Add tests asserting:
- detail URLs bootstrap the detail controller
- non-detail, non-market URLs no-op cleanly
- the detail controller still injects the page hook exactly once
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/content-entry.test.ts tests/content-bridge.test.ts`
Expected: FAIL because route bootstrap and extracted detail controller do not exist yet
- [ ] **Step 3: Implement the minimal route bootstrap split**
Implement:
- `createDetailContentController` in `src/content/detail/index.ts`
- route matching in `src/content/index.ts`
- no-op behavior on unsupported URLs
- updated imports in the existing detail tests
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/content-entry.test.ts tests/content-bridge.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record which tests passed and that detail-page behavior remained intact
### Task 2: Extend Manifest Coverage to the Market Page
**Files:**
- Modify: `src/manifest.json`
- Modify: `tests/build-layout.test.ts`
- [ ] **Step 1: Write the failing manifest assertions**
Extend the build-layout test to assert:
- the content script matches both detail and market URLs
- the emitted JS asset list is unchanged
- `web_accessible_resources` for the detail hook still stay present
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/build-layout.test.ts`
Expected: FAIL because the manifest only targets the detail page
- [ ] **Step 3: Implement the minimal manifest update**
Implement:
- add `https://*.xingtu.cn/ad/creator/market*` to `content_scripts.matches`
- keep existing detail-page matches and page-hook asset coverage
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/build-layout.test.ts`
Expected: PASS
- [ ] **Step 5: Verify the build still works**
Run: `npm run build`
Expected: PASS and `dist/manifest.json` includes the market match
- [ ] **Step 6: Record the verification note**
Record the passing build-layout test and build output
### Task 3: Add the Shared Rate Normalizer and the Market API Client
**Files:**
- Create: `src/shared/normalize-rate-value.ts`
- Modify: `src/shared/extract-after-search-rates.ts`
- Create: `src/content/market/api-client.ts`
- Create: `tests/market-api-client.test.ts`
- Modify: `tests/extract-after-search-rates.test.ts`
- [ ] **Step 1: Write the failing API-client and normalization tests**
Cover:
- normalizing `<0.02%` without changing it
- normalizing `0.02 - 0.1%` into `0.02% - 0.1%`
- mapping the known API fields into the two display fields
- returning `bad-response` when either field is missing
- returning `timeout` for aborted requests and `request-failed` for non-OK/network failures
- issuing fetch with `credentials: "include"` and a timeout signal
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/market-api-client.test.ts tests/extract-after-search-rates.test.ts`
Expected: FAIL because the shared normalizer and market API client do not exist yet
- [ ] **Step 3: Implement the minimal shared normalizer and API client**
Implement:
- `normalizeRateValue`
- market request URL builder for `get_author_ase_info`
- response mapper for `avg_search_after_view_rate` and `personal_avg_search_after_view_rate`
- explicit error classification for timeout vs request-failed vs bad-response
- reuse the shared normalizer from the detail extractor
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/market-api-client.test.ts tests/extract-after-search-rates.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record that both the new market mapper tests and the existing detail extractor tests passed
### Task 4: Add Author-ID Extraction and List-Signature Utilities
**Files:**
- Create: `src/content/market/id-extractor.ts`
- Create: `src/content/market/list-signature.ts`
- Create: `tests/market-id-extractor.test.ts`
- [ ] **Step 1: Write the failing extractor tests**
Cover:
- extracting `authorId` from a row detail link
- extracting from fallback row attributes when available
- returning an explicit missing-id result when no stable source exists
- producing a deterministic list signature from author IDs plus relevant URL state
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/market-id-extractor.test.ts`
Expected: FAIL because the market row extraction utilities do not exist yet
- [ ] **Step 3: Implement the minimal extractor utilities**
Implement:
- detail-link parsing via `getStarIdFromUrl`
- fallback attribute parsing
- explicit `missing-author-id` results
- deterministic list-signature generation
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/market-id-extractor.test.ts tests/get-star-id.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record the passing extractor and shared ID tests
### Task 5: Insert the Two Columns and Render Row States
**Files:**
- Create: `src/content/market/dom-sync.ts`
- Create: `src/content/market/row-render.ts`
- Create: `src/content/market/row-state.ts`
- Create: `tests/market-dom-sync.test.ts`
- Create: `tests/market-row-render.test.ts`
- [ ] **Step 1: Write the failing DOM and rendering tests**
Cover:
- inserting two headers before the `操作` column
- inserting two cells before the action cell for each row
- tagging injected cells with stable `data-*` markers
- avoiding duplicate insertion on repeated sync
- rendering `加载中...`, success values, and `加载失败`
- exposing retryable error rows without creating per-cell state divergence
- avoiding any design that requires batch/cache layers to hold row elements after the sync pass ends
- [ ] **Step 2: Run the tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-row-render.test.ts`
Expected: FAIL because the market DOM modules do not exist yet
- [ ] **Step 3: Implement the minimal DOM sync and row rendering**
Implement:
- table/header lookup
- injected header/cell creation
- row-state types
- row rendering with shared row-level status
- ephemeral DOM references only inside the sync/render step
- [ ] **Step 4: Run the tests to verify they pass**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-row-render.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record the passing DOM and row-render test output
### Task 6: Add Cache, In-Flight Dedupe, Concurrency Control, and Row Retry
**Files:**
- Create: `src/content/market/batch-loader.ts`
- Create: `src/content/market/cache-store.ts`
- Create: `tests/market-batch-loader.test.ts`
- Modify: `src/content/market/api-client.ts`
- Modify: `src/content/market/row-state.ts`
- [ ] **Step 1: Write the failing batch-loader tests**
Cover:
- current page rows auto-enter loading state
- same `authorId` reuses success cache
- same `authorId` in-flight requests are deduplicated
- concurrency cap is honored
- failed rows transition to `加载失败`
- clicking one failed cell retries the whole row
- loader outputs can be dropped safely when the consumer reports a newer `listSeq`
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/market-batch-loader.test.ts`
Expected: FAIL because cache/dedupe/loader behavior does not exist yet
- [ ] **Step 3: Implement the minimal cache and loader pipeline**
Implement:
- in-memory cache entries keyed by `authorId`
- in-flight request reuse
- concurrency-limited scheduling
- row retry behavior
- timeout and request-failed transitions
- result delivery based on row metadata and callbacks, not long-lived DOM node ownership
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/market-batch-loader.test.ts tests/market-api-client.test.ts`
Expected: PASS
- [ ] **Step 5: Record the verification note**
Record the passing loader and API-client tests
### Task 7: Build the Market Controller and Activate It on the Market Route
**Files:**
- Create: `src/content/market/index.ts`
- Modify: `src/content/index.ts`
- Create: `tests/market-controller.test.ts`
- Modify: `tests/content-entry.test.ts`
- [ ] **Step 1: Write the failing controller tests**
Cover:
- initial market page auto-load for all current rows
- pagination/filter/search/sort DOM changes trigger a fresh sync
- stale async results do not overwrite a newer list
- cached rows rehydrate immediately when they reappear
- the content entry now selects the market controller on market URLs
- writeback only succeeds when fresh DOM markers still match the returning `authorId` and `listSeq`
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/market-controller.test.ts tests/content-entry.test.ts`
Expected: FAIL because the market controller and route activation do not exist yet
- [ ] **Step 3: Implement the minimal market controller**
Implement:
- table observation
- `listSeq` or equivalent sync-session tracking
- fresh sync on list changes
- stale result suppression before DOM writeback
- re-scan current DOM on each sync instead of holding old row node references
- market route activation in `src/content/index.ts`
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/market-controller.test.ts tests/content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Run the focused market suite**
Run: `npm test -- tests/market-api-client.test.ts tests/market-id-extractor.test.ts tests/market-dom-sync.test.ts tests/market-row-render.test.ts tests/market-batch-loader.test.ts tests/market-controller.test.ts`
Expected: PASS
- [ ] **Step 6: Record the verification note**
Record that the market suite and route-entry tests passed
### Task 8: Update README and Run Final Verification
**Files:**
- Modify: `README.md`
- Modify: `tests/readme.test.ts`
- [ ] **Step 1: Write the failing README expectation**
Extend README tests to assert documentation now includes:
- market page enhancement scope
- the two inserted column names
- loading/failure/retry behavior
- how to manually verify on `creator/market`
- the fact that detail-page behavior still exists
- [ ] **Step 2: Run the test to verify it fails**
Run: `npm test -- tests/readme.test.ts`
Expected: FAIL because README does not document market-page behavior yet
- [ ] **Step 3: Update README minimally**
Add:
- market page support notes
- manual verification steps for both detail and market pages
- current stage-1 limitations
- [ ] **Step 4: Run the test to verify it passes**
Run: `npm test -- tests/readme.test.ts`
Expected: PASS
- [ ] **Step 5: Run the full verification suite**
Run: `npm test`
Expected: all tests PASS
- [ ] **Step 6: Run a fresh production build**
Run: `npm run build`
Expected: build exits 0 and `dist/` contains the updated extension assets
- [ ] **Step 7: Record the verification note**
Record the passing full test run and build output
---
## Runtime DOM Note
After testing against the real logged-in `creator/market` page, the market result area was confirmed to be a column-major sticky grid instead of a row-major HTML table:
- outer list root: `.base-author-list`
- header root: `.section-wrapper.sticky-header.hide-scrollbar`
- body root: `.section-wrapper.hide-scrollbar`
- left author area: sticky `content-section` with one `content-column`
- middle metrics: `.middle-columns`
- right sticky area: `content-section` containing multiple `.content-column`s, with the last column being `操作`
This means DOM sync cannot treat each result as a `<tr>` or assume the injected cells live under the same row container. The implementation now reconstructs each logical row by cell index across columns and stamps row metadata onto:
- the author-side row anchor cell
- the injected `单视频看后搜率` cell
- the injected `个人视频看后搜率` cell
## Verification Note
Verified in this workspace after the column-major DOM fix:
- `npm test -- tests/market-dom-sync.test.ts tests/market-controller.test.ts`
- `npm test`
- `npm run build`
All commands exited successfully.
## Runtime Stability Note
After manual browser verification on the real `creator/market` page, an additional runtime issue was identified: refreshing the market page could leave the result area blank or stuck loading.
The market controller was then hardened with three runtime constraints:
- do not start observing the full market DOM while `document.readyState === "loading"`
- do not override `history.pushState` or `history.replaceState` on the market page
- coalesce repeated `MutationObserver` callbacks into one scheduled sync cycle instead of launching unbounded concurrent sync passes during page boot
These constraints are now covered by `tests/market-controller.test.ts` together with the existing market-page behavior checks.
@@ -0,0 +1,217 @@
# Market Export Range Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add configurable CSV export ranges for the Xingtu market plugin, defaulting to the first 5 pages and supporting current page, preset ranges, all pages, and custom page counts.
**Architecture:** Keep filter and sort scoped to the current page, and introduce a dedicated batch-export path that only runs when exporting. Extend the toolbar with export-range controls, extract page-navigation/signature helpers, and add a focused controller that iterates pages, harvests lazy-loaded DOM fields, merges records by `authorId`, and returns a single record set for CSV generation.
**Tech Stack:** TypeScript, Vitest, jsdom, Chrome MV3 content script
---
### Task 1: Extend Toolbar Controls For Export Range
**Files:**
- Modify: `src/content/market/plugin-toolbar.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- the toolbar defaults to `前5页`
- selecting `自定义` shows and uses a page-count input
- invalid custom values prevent export
- export mode disables toolbar controls during a running task
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
Expected: FAIL because the toolbar has no range selector or custom input behavior.
- [ ] **Step 3: Write the minimal implementation**
Update `plugin-toolbar.ts` to:
- add an export-range `<select>`
- add a custom page-count `<input>`
- add status text support
- expose helpers for reading range state and toggling disabled/running UI
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: add export range toolbar controls"
```
### Task 2: Add Batch Export Coverage
**Files:**
- Modify: `tests/market-content-entry.test.ts`
- Modify: `tests/market-dom-sync.test.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- `当前页` exports only the current page
- `前5页` iterates through pages until it reaches page 5 or runs out of pages
- `全部` stops when the next-page control is no longer usable
- repeated authors across pages are merged by `authorId`
- empty fields from later pages do not erase existing populated fields
- page export waits for page signature change before harvesting the next page
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL on the new batch-export expectations.
- [ ] **Step 3: Write the minimal implementation scaffolding**
Only introduce the minimum new helpers/types needed to make the tests compile:
- export range type(s)
- page signature helpers
- harness utilities in tests
- [ ] **Step 4: Run tests to verify the failures are now behavioral**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because batch export logic is still missing, not because types/selectors are missing.
- [ ] **Step 5: Commit**
```bash
git add tests/market-content-entry.test.ts tests/market-dom-sync.test.ts src/content/market/types.ts
git commit -m "test: cover multi-page export behavior"
```
### Task 3: Implement Batch Export Controller
**Files:**
- Create: `src/content/market/export-range-controller.ts`
- Modify: `src/content/market/types.ts`
- Modify: `src/content/market/dom-sync.ts`
- Modify: `src/content/market/result-store.ts`
- Test: `tests/market-content-entry.test.ts`
- Test: `tests/market-dom-sync.test.ts`
- [ ] **Step 1: Write the failing tests**
Add focused tests for:
- reading a page signature from current DOM
- detecting/using the next-page control
- merging rows by `authorId` with non-empty-field preference
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts`
Expected: FAIL because these helpers/controller do not exist yet.
- [ ] **Step 3: Write the minimal implementation**
Implement `export-range-controller.ts` to:
- normalize export targets (`current`, `count`, `all`)
- harvest current page rows with the existing lazy-load scroll path
- read page signatures
- click/advance pagination and wait for signature change
- merge pages by `authorId` without overwriting non-empty fields
- return a final `MarketRecord[]`
Keep `dom-sync.ts` focused on DOM reads:
- page signature
- next-page element lookup
- next-page enabled check
- [ ] **Step 4: Run tests to verify they pass**
Run:
- `npm test -- tests/market-dom-sync.test.ts`
- `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/export-range-controller.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts tests/market-content-entry.test.ts tests/market-dom-sync.test.ts
git commit -m "feat: add multi-page export controller"
```
### Task 4: Wire Controller Into Market Entry
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `src/content/market/plugin-toolbar.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- `导出CSV` uses the selected range
- progress text updates while exporting
- controls are re-enabled on success and failure
- invalid custom input blocks export without calling CSV builder
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because `market/index.ts` still always exports current-page records.
- [ ] **Step 3: Write the minimal implementation**
Update `market/index.ts` to:
- parse toolbar export range
- keep current-page export for `当前页`
- delegate batch modes to `export-range-controller.ts`
- update toolbar running/error/progress state
- preserve existing current-page lazy-load harvest path
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/index.ts src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: wire export range selection into market export"
```
### Task 5: Verify End-To-End
**Files:**
- Modify: `docs/superpowers/plans/2026-04-21-market-export-range.md`
- [ ] **Step 1: Run targeted regression tests**
Run:
- `npm test -- tests/market-content-entry.test.ts`
- `npm test -- tests/market-dom-sync.test.ts`
Expected: PASS
- [ ] **Step 2: Run the full suite**
Run: `npm test`
Expected: PASS with all test files green.
- [ ] **Step 3: Run the build**
Run: `npm run build`
Expected: PASS and emit updated `dist/` bundles.
- [ ] **Step 4: Mark the plan status**
Update this documents checkboxes to reflect completed steps.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/plans/2026-04-21-market-export-range.md
git commit -m "docs: record export range implementation progress"
```
@@ -1,568 +0,0 @@
# 星图达人详情页看后搜率插件实验设计
## 1. 背景
目标站点为巨量星图达人详情页。当前已经人工确认:
- 达人详情页右侧可以看到两个“看后搜率”指标
- 页面 URL 中可观察到稳定的星图达人 ID
- `search_session_id` 在短时间实验中看起来可能不变,但不应作为稳定依赖
本次工作不是直接做完整产品,而是做一个最小可验证实验,确认浏览器插件能否自动获取这两个指标。
## 2. 目标
本实验的目标只有一个:
- 在巨量星图达人详情页内,由 Chrome 插件自动拿到两个看后搜率,并以结构化结果输出到控制台或插件侧日志中
成功标准:
- 进入达人详情页后,插件无需人工复制数据
- 插件能自动拿到两个目标值
- 输出结果中包含达人标识、页面 URL、命中的请求 URL、两个看后搜率值
- 当两项值都提取成功时,明确标记成功
- 插件注入与拦截不能影响页面原有请求、渲染与交互
- 同一次详情页进入只输出一个最终结果;如果先失败后成功,可以升级为成功结果
## 3. 非目标
本实验明确不做以下内容:
- 不在找达人列表页批量抓取多个达人数据
- 不在列表页直接渲染看后搜率列
- 不复现页面接口签名或自行构造后台请求
- 不依赖 `search_session_id`
- 不做插件 UI 美化
- 不做导出、缓存、排序、批处理
- 不接入后端服务或数据库
## 4. 关键判断
### 4.1 达人标识
实验阶段将 URL 中的星图达人 ID 作为当前页面达人标识候选值。插件应优先从详情页路径中提取该 ID,并在日志输出中保留。
### 4.2 会话参数
`search_session_id` 不参与任何业务判断。它更像搜索会话或埋点参数,可能随着入口、刷新、筛选条件或路由变化而变化,不应作为稳定主键。
### 4.3 数据来源策略
本实验接受复用页面自己发出的请求来拿数据,因此优先方案为:
- 在页面上下文中拦截 `fetch` / `XMLHttpRequest`
- 解析页面真实收到的接口响应
- 从响应中提取两个看后搜率
这比插件自行复刻请求更适合当前最小实验,因为它不需要单独处理 CORS、Cookie、鉴权、签名和接口重放。
### 4.4 结果解释边界
如果插件在观察窗口内始终没有拿到完整结果,这只能说明“当前网络拦截路径尚未被证实可行”,不能直接推断为插件实现失败。
也就是说:
- 成功命中时,可以证明“网络拦截方案可行”
- 持续超时或只拿到无关响应时,只能证明“本轮实验未证实该路径可行”
- 若连续多次超时,应转向检查 DOM、内联 bootstrap 数据或更早期的数据注入方式,而不是继续盲目扩展字段匹配规则
## 5. 方案概览
插件采用 Chrome Manifest V3,先只支持达人详情页。
核心由两层脚本组成:
- `content script`
- 负责在详情页尽早注入页面脚本
- 负责接收页面脚本传回的数据
- 负责统一打印结构化结果
- `page hook script`
- 运行在页面上下文
- 包装 `window.fetch``XMLHttpRequest`
- 拦截和分析候选响应
- 一旦提取到两个看后搜率,通过 `window.postMessage` 发回内容脚本
首版默认不引入以下能力,避免实验面过大:
- 不使用 `background service worker`
- 不申请 `storage``tabs``scripting` 等额外权限,除非实现阶段确认必需
- 不做 popup、options page 或独立调试面板
## 6. 页面范围与前提
### 6.1 页面匹配范围
首版只匹配达人详情页,例如:
- `https://*.xingtu.cn/ad/creator/author-homepage/*`
Manifest 约束建议同时写清:
- `content_scripts.matches` 只覆盖上述详情页
- `run_at` 使用 `document_start`
- 注入到页面上下文的脚本通过 `chrome.runtime.getURL(...)` + `<script>` 标签加载
- 被注入的页面脚本必须放入 `web_accessible_resources`
如果实现阶段发现页面 CSP 会拦截上述脚本标签注入,则需要切换为单一路径兜底,而不是同时堆叠多套方案:
- 优先评估 `content_scripts.world = "MAIN"` 是否足够
- 若仍不足,再评估引入最小 `background` + `chrome.scripting` 路径
- 首版不应在尚未证实必要性时提前引入两套注入机制
### 6.2 前提假设
- 目标值来源于页面请求返回的数据,而不是纯 DOM 计算结果
- 页面在进入详情页时会触发至少一个包含目标数据的 JSON 请求
- 为提高命中率,实验允许用户在页面打开后刷新一次
## 7. 架构与数据流
整体数据流如下:
1. 用户进入达人详情页
2. `content script``document_start` 注入 `page hook script`
3. `page hook script` 拦截后续 `fetch` / `XHR`
4. 过滤候选响应并尝试解析 JSON
5. 从响应中提取两个看后搜率
6. 通过 `window.postMessage` 将结果发回 `content script`
7. `content script` 记录结构化日志,作为实验成功依据
建议统一输出如下结构:
```js
{
success: true,
stage: "captured",
routeKey: "6629661559960371207::/ad/creator/author-homepage/6629661559960371207::1",
pageStarId: "6629661559960371207",
responseStarId: "6629661559960371207",
pageUrl: "https://xingtu.cn/ad/creator/author-homepage/...",
matchedRequestUrl: "https://...",
requestMethod: "GET",
status: 200,
extractorLevel: "label-value",
rates: {
singleVideoAfterSearchRate: "0.5% - 1%",
personalVideoAfterSearchRate: "0.5% - 1%"
},
rawPathHints: [
"data.xxx.card_list[2].metrics[0]",
"data.xxx.card_list[2].metrics[1]"
],
source: "network",
capturedAt: 1776210000000
}
```
当无法完整提取时,`success` 必须为 `false`,并附带错误阶段信息。无论成功还是失败,最终结果结构都应稳定包含:
- `success`
- `stage`
- `routeKey`
- `pageStarId`
- `pageUrl`
- `capturedAt`
- `reason``rates`
## 8. 网络拦截策略
### 8.1 拦截范围
页面脚本同时拦截:
- `window.fetch`
- `XMLHttpRequest.prototype.open`
- `XMLHttpRequest.prototype.send`
原因:
- 站点可能混用 `fetch``XHR`
- 实验阶段不应预设调用方式
### 8.2 拦截实现约束
无论采用什么包装方式,都必须满足以下不破坏页面的约束:
- 包装 `fetch` / `XHR` 时不得改变原始返回值、异常行为或 `this` 绑定
- 读取 `fetch` 响应时只能消费 `response.clone()`,绝不能读取原始 body
- `XHR` 只在请求完成后读取 `response` / `responseText`,不得篡改业务侧回调链
-`blob``arraybuffer``document` 等非文本响应直接跳过
- 所有 hook 逻辑必须 `fail-open`:自身异常只能吞掉并记录 `debug` 日志,不能阻断页面请求
- 页面脚本要有一次性 patch guard,避免重复注入导致多层包装
### 8.3 候选响应筛选
不是所有响应都值得解析。为减少噪声,先做轻量筛选:
- 响应 `content-type` 包含 `json`
- 请求 URL 包含达人详情、商业能力、种草价值、author-homepage、creator 等相关片段时优先
- 非 JSON 响应直接跳过
- 若响应头缺失 `content-type`,但 URL 命中高相关片段且响应文本形态像 JSON,可做一次受控解析尝试
- 为控制成本,可给响应文本设置大小上限;超限时只记录摘要,不进入深度扫描
注意:
- URL 过滤只是性能优化,不是唯一命中依据
- 真正成功与否仍由数据层提取结果决定
## 9. 数据提取策略
提取逻辑必须从拦截层中拆出,做成独立纯函数,以便单元测试。
推荐接口:
```ts
extractAfterSearchRates(payload: unknown): {
matched: boolean
success: boolean
extractorLevel: "exact-key" | "label-value" | "text-fallback" | "none"
rates?: {
singleVideoAfterSearchRate?: string
personalVideoAfterSearchRate?: string
}
rawPathHints: string[]
matchedLabels?: string[]
candidateStarId?: string
reason?: string
}
```
### 9.1 一级:精确字段匹配
优先尝试命中明显字段名,例如包含:
- `after_search_rate`
- `search_rate`
- `lookback_search`
- `kanhousou`
如果未来真实响应中存在明确 key,这一层应最稳定。
但要避免把泛化字段误判为目标值:
- 不要因为单独出现 `search_rate` 就直接判定命中
- 至少要求同层或近邻上下文同时出现 `after_search``看后搜` 等语义信号
- 若只能命中一个疑似字段,应降级为 `matched: true``success: false`
### 9.2 二级:半结构化 label/value 匹配
若响应是卡片化数据,可能不存在固定 key,而是类似:
- 某个数组项里包含 label 与 value
- label 为“单视频看后搜率”“单条视频看后搜率”“个人视频看后搜率”或近似文案
此时应按 label 识别两项值,并记录命中的对象路径。
实现上建议先做 label 标准化,再走小型同义词表:
- 去掉空格、全半角差异和无意义标点
- 将近义 label 归一到固定内部字段名
- 只在同一局部对象或同一卡片内成对提取,避免跨模块拼接出伪成功
### 9.3 三级:文本兜底匹配
若响应中没有明显结构,但存在文本片段,可用正则从附近文本中提取类似:
- `0.5% - 1%`
- `0.5%-1%`
文本兜底只用于实验,不应作为长期主路径。
为降低误判,文本兜底应限制在“已经被判定为相关卡片或相关子树”的局部文本中,不建议对整份响应做无上下文全文扫描。
### 9.4 成功判定
只有当以下条件同时满足时才记为成功:
- 找到单视频或单条视频看后搜率
- 找到个人视频看后搜率
任意一项缺失,都视为失败或半命中,不得报成功。
## 10. 达人 ID 提取规则
达人 ID 提取优先级:
1. 从当前详情页 URL 路径中提取 `pageStarId`
2. 如果响应体中也出现达人 ID,则作为 `responseStarId` 附加记录
3. 若两者不一致,不得用响应值覆盖页面值,而是保留双方并标记 `idMismatch: true`
4. `routeKey` 统一基于页面路由生成,不基于响应体里的达人 ID 反推
## 11. 消息桥设计
页面上下文与内容脚本之间使用 `window.postMessage` 通信。
消息建议形如:
```js
{
source: "star-chart-search-enhancer",
type: "AFTER_SEARCH_RATE_RESULT",
payload: {
success: true,
stage: "captured",
routeKey: "...",
pageStarId: "...",
matchedRequestUrl: "...",
extractorLevel: "label-value",
rates: {
singleVideoAfterSearchRate: "...",
personalVideoAfterSearchRate: "..."
},
rawPathHints: []
}
}
```
内容脚本需要做来源过滤:
- 只接受 `window === event.source` 的消息
- 只接受固定 `source``type`
- 校验 `payload` 的必要字段和字段类型
- 丢弃 `routeKey` 不是当前页面路由快照的陈旧消息
## 12. 日志与调试策略
实验阶段先不做复杂 UI,统一走控制台日志。
建议分三级输出:
- `info`
- 插件已注入
- 当前页面匹配详情页
- 当前达人 ID
- 当前 `routeKey`
- `debug`
- 命中候选请求 URL
- JSON 解析是否成功
- 提取逻辑命中了哪一级规则
- `result`
- 最终结构化结果对象
所有日志建议统一加前缀,例如 `[star-chart-search-enhancer]`
日志只打印结构化结果、阶段信息、候选请求摘要和路径提示,不打印整份原始响应体。原因:
- 原始响应通常体积较大,会降低调试可读性
- 站点响应可能包含与本实验无关的业务字段,不应在控制台无约束暴露
- 对提取逻辑来说,`matchedRequestUrl``extractorLevel``rawPathHints`、候选 label 摘要通常已经足够排查
如确需保留真实样本做后续测试,应该采用脱敏后的 fixture 文件,而不是直接把原始 payload 打到控制台。
如果同页面多次命中:
- 同一 `routeKey` 默认只打印一个最终 `result`
- 若先出现失败终态、后出现完整成功结果,可以用成功结果覆盖前一次终态
- 若命中的是相同结果指纹,则不重复打印,避免控制台刷屏
## 13. SPA 与重复进入处理
巨量星图页面可能存在单页路由切换,因此需要兼顾以下情况:
- `history.pushState`
- `history.replaceState`
- `popstate`
建议为每次详情页进入生成一个新的 `routeKey`,例如:
```text
${pageStarId ?? "unknown"}::${location.pathname}::${navigationSeq}
```
在检测到路由切换到新的达人详情页时,插件应:
- 递增 `navigationSeq`
- 重置当前页面的命中缓存
- 更新当前达人 ID
- 广播新的 `routeKey` 给注入层或在注入层同步读取
- 等待后续请求再次命中
这样可以避免前一路由的慢响应在新页面落地后被错误归到当前达人。
## 14. 失败兜底策略
### 14.1 注入太晚
如果内容脚本注入时页面关键请求已经发完,则本轮可能拿不到数据。实验接受手工刷新一次页面作为前提条件。
### 14.2 拿到响应但不是可解析 JSON
记录:
- 请求 URL
- 请求方法
- 状态码
但不记为成功。
### 14.3 命中候选响应但字段未识别
输出:
- 命中的请求 URL
- 失败原因
- 可疑路径提示或 label 值摘要
便于后续迭代提取规则。
### 14.4 观察窗口超时
若在单次详情页进入或路由切换后的观察窗口内始终没有拿到完整成功结果,应输出一个明确的失败终态,避免用户无法区分“还在等待”与“本轮失败”。
建议:
- 观察窗口默认设为 8 到 10 秒
- 超时结果使用 `success: false``stage: "timeout"`
- 结果中附带 `candidateRequestCount``lastCandidateRequestUrl``pageStarId``routeKey`
### 14.5 多次命中或重复消息
同一页面多次命中时:
- 如果前一次不完整、后一次完整,保留后一次
- 如果都是完整结果,默认保留最新一次
- 对完全相同的 `routeKey + matchedRequestUrl + normalizedRates` 结果做去重
## 15. TDD 与测试策略
本实验虽然是浏览器插件,但提取逻辑必须先做测试驱动,避免每次改规则都依赖人工打开目标站点。
### 15.1 待测模块拆分
建议拆为三类模块:
- `extractors`
- 只负责从 JSON 中提取两个看后搜率
- 纯函数,可完整单测
- `page-hook`
- 负责拦截网络请求、调用提取器、发消息
- 可做轻量行为测试
- `content-bridge`
- 负责注入、接收消息、打印结果
- 可做消息桥测试
### 15.2 单元测试优先级
首批测试用例至少覆盖:
- 标准固定 key 命中
- label/value 结构命中
- 文本兜底命中
- 仅命中一个值时不能算成功
- 完全无关响应不应误判
- 百分比范围字符串存在空格或无空格时仍可识别
- 路由切换后的陈旧消息会被丢弃
- `fetch` 包装通过 `clone()` 读取,不消费原始响应
- hook 内部抛错时请求仍然正常返回
- 超时路径会输出明确失败结果
一旦首次命中真实页面响应,应立刻补充一组匿名化 fixture 测试:
- 从真实响应中删去无关字段与敏感标识
- 固化为最小可复现样本
- 用该 fixture 保护当前提取规则,避免后续重构把已验证路径改坏
### 15.3 集成验证
最小人工验证步骤:
1. 加载未打包的 Chrome 插件
2. 打开巨量星图达人详情页
3. 刷新页面一次
4. 打开 DevTools Console
5. 检查是否出现结构化结果对象
6. 将结果中的两个值与页面展示值逐项比对
### 15.4 成功证据
只有同时满足以下条件,才可宣布实验通过:
- 控制台出现结构化结果
- 两个值都存在
- 两个值与页面右侧显示一致
## 16. 建议的最小代码结构
建议以最小清晰结构开始,不为未来需求过度设计:
```text
src/
manifest.json
content/
index.ts
route-state.ts
page/
hook.ts
network-interceptor.ts
shared/
extract-after-search-rates.ts
normalize-rate-label.ts
get-star-id.ts
route-key.ts
message-types.ts
result-types.ts
tests/
extract-after-search-rates.test.ts
content-bridge.test.ts
page-hook.test.ts
route-state.test.ts
```
如果当前项目尚未有脚手架,可在实现阶段再补构建工具,不在本设计阶段提前锁死。
## 17. 实现边界
首版只交付以下能力:
- 一个可加载到 Chrome 的最小 MV3 扩展
- 在达人详情页自动注入 hook
- 自动抓取并输出两个看后搜率
- 覆盖关键提取逻辑的测试
- 明确的超时失败结果与去重策略
首版不交付:
- 列表页注入指标
- 批量抓取多个达人
- 导出和缓存
- 插件弹窗管理界面
- 后台服务
- 后台常驻 worker 与云端同步
## 18. 后续扩展方向
当详情页实验成功后,下一阶段可以考虑:
- 在找达人列表页按行补充这两个指标
- 从详情页拦截结果建立字段映射,逐步定位稳定接口
- 将成功命中的接口 URL 与字段路径固化,减少全量扫描成本
- 增加插件调试面板,展示最近一次命中的原始来源与提取路径
## 19. 风险与注意事项
- 目标站点前端实现可能变化,尤其是 label 文案和响应结构
- 若站点在页面初始化前就发出关键请求,注入时机不够早会影响命中率
- 若接口数据经过额外加密、压缩或二次映射,网络拦截不一定能直接得到最终值
- 若页面使用 Service Worker、流式响应或非常规封装,可能需要补充拦截策略
- 若两个看后搜率实际来自 SSR、内联脚本或前端二次计算,网络拦截实验会出现稳定超时,此时应尽快切换实验路径
## 20. 当前结论
在当前约束下,最合理的最小实验路径是:
- 不依赖 `search_session_id`
- 仅支持达人详情页
- 通过页面上下文拦截 `fetch/XHR`
- 将数据提取逻辑做成纯函数并用测试保护
-`routeKey`、超时终态和去重规则保证日志可判读
- 成功后先用控制台结构化日志作为验收依据
只要详情页的两个看后搜率确实来自页面可见的 JSON 响应,这个实验具备较高可行性。
如果连续多次只得到 `timeout` 或无关候选响应,下一步应优先验证“数据是否根本不走网络响应体”这一前提,而不是继续扩大提取规则范围。
@@ -1,623 +0,0 @@
# 星图找达人列表页看后搜率列增强设计(阶段 1)
## 1. 背景
前置实验已经验证:
- Chrome MV3 扩展可以在巨量星图详情页稳定运行
- 当前仓库已经存在详情页 `content/page/shared` 基础结构、构建脚本和测试
- 插件已经能够识别真实接口响应,并确认可用指标接口为:
- `/gw/api/aggregator/get_author_ase_info?author_id=<id>&range=30`
- 已确认字段映射:
- `avg_search_after_view_rate` -> `单视频看后搜率`
- `personal_avg_search_after_view_rate` -> `个人视频看后搜率`
用户当前的新目标不是继续在详情页控制台验证,而是进入找达人列表页,在表格中直接新增两列,把这两个值自动补齐并显示出来。
本阶段是“现有插件能力的列表页扩展”,不是重新从空仓库设计一套插件。
## 2. 阶段拆分
本需求拆为两个阶段:
### 阶段 1
只处理当前列表页当前结果页:
- 列表页自动新增两列
- 页面进入后自动为当前页所有达人加载这两个值
- 支持筛选、翻页、搜索、排序变化后的自动重跑
- 支持同达人内存缓存
- 支持失败后按整行重试
### 阶段 2
在阶段 1 稳定后再做:
- 按当前筛选条件拉取全部达人
- 插件自己的导出按钮与导出逻辑
- 导出结果中带上这两个新增字段
本设计文档只覆盖阶段 1,但会为阶段 2 预留状态结构与数据命名。
## 3. 目标
阶段 1 的目标是:
- 在找达人列表页自动插入两列:
- `单视频看后搜率`
- `个人视频看后搜率`
- 进入页面后自动批量加载当前页所有达人这两个值
- 在列表变化后自动重新补齐当前页
- 成功时展示真实值
- 失败时展示 `加载失败`
- 点击任一失败单元格,按整行重试
- 对同达人做内存缓存,避免重复请求
- 不回归现有详情页能力与已有测试
## 4. 非目标
阶段 1 明确不做:
- 不处理全部结果导出
- 不接管页面原生导出按钮
- 不实现插件自己的导出按钮
- 不支持按这两列排序
- 不持久化缓存到 `storage`
- 不做批量抓取全部分页结果
- 不修改页面原始列表接口响应
- 不把列表页逻辑继续堆进现有详情页 controller 内
- 不复用详情页“通用响应扫描提取器”作为列表页主路径
- 不接入后端服务
## 5. 用户已确认的产品约束
- 两列形式:插入成两列
- 列位置:放在最右侧 `操作` 列前
- 首次状态:显示 `加载中...`
- 失败状态:显示 `加载失败`
- 失败重试:点击任一失败单元格,按整行重试
- 加载方式:页面进入后自动批量加载当前页所有达人
- 缓存方式:内存缓存
- 表头文案:使用完整名称
- 排序能力:不支持
- 页面变化后行为:翻页、切筛选、搜索、排序变化后都自动重新补齐当前页
- 阶段拆分:接受先做当前页列表增强,再做全部导出
- 现有详情页实验链路继续保留,不因列表页阶段 1 被破坏
## 6. 方案对比
### 方案 1DOM 增强 + content script 主动请求已验证接口
在列表页中识别表头和数据行,插入两列,然后由列表页 content script 主动请求已验证的指标接口补齐值。
优点:
- 最贴合当前仓库现状,不必引入新的页面注入资产
- 不需要复用详情页的网络 hook 扫描逻辑
- 风险集中在 DOM 识别、请求调度和状态回写上,边界清楚
- 后续导出阶段可复用同一批量请求与字段规范化链路
缺点:
- 需要稳定提取每行达人 `author_id`
- 需要自己维护并发、缓存、失败重试和 DOM 更新
- 需要显式处理列表变化后的陈旧结果抑制
### 方案 2:DOM 增强 + 页面上下文请求桥
仍然做 DOM 增强,但实际请求不在 content script 中发,而是通过页面上下文桥接后由页面环境发起。
优点:
- 如果列表页接口对隔离环境请求有限制,这条路径更稳
缺点:
- 会引入新的页面资产、消息桥和构建改动
- 阶段 1 复杂度明显上升
- 当前没有证据表明必须这样做
### 方案 3:劫持列表接口并补字段后再交还页面
拦截找达人列表接口响应,修改返回对象,尝试让页面自己渲染新列数据。
优点:
- 理论上更接近“原生数据”
缺点:
- 侵入性高
- 列表接口未必包含或容易关联这两个字段
- 需要同时控制页面渲染结构和数据结构,不适合阶段 1
### 推荐结论
采用 `方案 1DOM 增强 + content script 主动请求已验证接口`
同时明确一条边界:
- 阶段 1 不做“双通道请求”
- 如果后续实测证明 content script 的同源请求在列表页不可行,应停下重新修订设计,而不是临时在实现中偷偷加第二套请求桥
## 7. 页面范围
阶段 1 只在找达人列表页启用,例如:
- `https://*.xingtu.cn/ad/creator/market*`
详情页逻辑保留,作为已验证链路和回归基线,但不作为阶段 1 的交付重点。
## 8. 数据源与请求策略
### 8.1 已确认接口
阶段 1 直接请求:
```text
/gw/api/aggregator/get_author_ase_info?author_id=<id>&range=30
```
### 8.2 请求执行上下文
阶段 1 的请求由列表页 content script 发起,不复用详情页的页面 hook。
请求约束建议写死:
- 使用 `fetch`
- `method: "GET"`
- `credentials: "include"`
-`AbortController` 控制单请求超时
- 不额外伪造签名,不改写页面 Cookie,不依赖 `search_session_id`
- 建议默认超时预算固定为 `8000ms`
这意味着列表页实现与详情页“拦截页面自身请求”的实验链路是两条不同路径,阶段 1 不需要把它们硬绑在一起。
### 8.3 响应映射策略
列表页阶段 1 不应再走 `extractAfterSearchRates(payload)` 这种“泛化扫描整个响应”的路径,而应使用一个针对已知接口的专用映射函数,例如:
```ts
mapAuthorAseInfoResponse(payload: unknown): {
success: boolean
rates?: {
singleVideoAfterSearchRate: string
personalVideoAfterSearchRate: string
}
reason?: "bad-response" | "missing-field"
}
```
原因:
- 这个接口的字段已经明确,没必要再走启发式扫描
- 继续复用详情页提取器会把阶段 1 绑定到不必要的共享行为上
- 专用 mapper 更容易测、更不容易误判,也更适合阶段 2 复用
失败分类也应固定下来:
- `AbortError` 或主动超时中止,归类为 `timeout`
- 网络异常、非 2xx、JSON 解析失败,归类为 `request-failed`
- 成功拿到响应但缺字段或结构不符,归类为 `bad-response`
### 8.4 字段与格式规范化
当前已确认的真实值格式包括:
- `<0.02%`
- `0.02 - 0.1%`
展示前统一做轻量规范化:
- `<0.02%` 保留
- `0.02 - 0.1%` 规范为 `0.02% - 0.1%`
若任一目标字段缺失,都视为本次响应不可用,不报成功。
## 9. 与当前仓库的适配约束
当前仓库已经有一套详情页入口、结果类型和测试基线。阶段 1 应按“并列模块”接入,而不是直接把市场页逻辑塞进现有详情页实现里。
建议约束:
- `src/content/index.ts` 只负责做路由分发与公共 bootstrap
- 现有详情页 controller 迁移为独立模块,避免被市场页逻辑污染
- 市场页能力在 `src/content/market/` 下实现
- 详情页现有 `src/page/*` 注入链路阶段 1 不改或尽量少改
这样可以把列表页新增复杂度限制在 content 层,不去动已经稳定的详情页 page hook。
## 10. 列表页架构与数据流
整体数据流如下:
1. 用户进入找达人列表页
2. 内容脚本入口识别当前为 `market` 路由,启动 market controller
3. market controller 识别主表、表头与当前行集合
4.`操作` 列前插入两列表头
5. 为当前同步周期生成新的 `listSeq`
6. 对每一行提取达人 `author_id`
7. 先渲染行初始状态:
-`author_id` 的行为 `加载中...`
- 无法提取 `author_id` 的行为 `加载失败`
8. 调度器按并发限制批量请求 `get_author_ase_info`
9. 成功后把对应行更新为真实值
10. 失败后将该行两列更新为 `加载失败`
11. 点击失败单元格时,以该行 `author_id` 为单位重试
12. 翻页、筛选、搜索、排序变化后,生成新的 `listSeq` 并重新同步当前页
## 11. DOM 识别与插入策略
### 11.1 表格识别
优先以“表头文本语义 + 表格结构”识别主列表,而不是依赖脆弱 class 名:
- 找到包含 `达人信息``操作` 等列标题的主表头
- 找到其对应的数据行容器
- 允许表头和数据区不是同一 DOM 层级
### 11.2 表头插入
插入规则固定为:
- 找到标题为 `操作` 的列
- 在其前插入:
- `单视频看后搜率`
- `个人视频看后搜率`
`操作` 列暂时识别失败:
- 不盲目插列
- 记录 debug 日志
- 等待 DOM 下一次稳定后再尝试
### 11.3 行单元格插入
对每一行:
- 找到对应的 `操作` 单元格
- 在它前面插入两个插件单元格
- 插件单元格带稳定 `data-*` 标记
- 插件单元格额外记录:
- `data-sces-author-id`
- `data-sces-list-seq`
- `data-sces-column`
### 11.4 DOM 复用边界
实现时不要把 `HTMLElement` 长久缓存到请求层或缓存层。
原因:
- 列表页可能替换整块 DOM
- 页面也可能复用行节点或重排节点
- 长持有旧节点会导致结果回写错位
正确方式是:
- 每次同步重新扫描当前可见行
- 只在渲染阶段临时持有当前节点引用
- 请求层、缓存层、批量调度层只保存 `authorId``listSeq``signature` 等轻量标识
- 异步结果回写前先校验 `listSeq``authorId` 绑定仍然匹配
## 12. 达人 ID 提取策略
阶段 1 的关键依赖是从每一行稳定拿到达人 `author_id`
优先级如下:
1. 从行内详情页链接提取
若头像、昵称、封面、跳转按钮链接指向达人详情页,则直接从 URL 中提取达人 ID。
2. 从行内 `data-*`、埋点属性、按钮参数中提取
若页面在行内直接存了作者 ID,优先复用。
3. 若当前行无法提取 ID
不猜测,不发请求。该行两列显示 `加载失败`,并记录 debug 原因 `missing-author-id`
阶段 1 不依赖:
- `search_session_id`
- 当前页排序位置
- 当前页行号
## 13. 列表身份与陈旧结果抑制
阶段 1 需要明确区分“当前这次列表同步”和“前一次列表同步”。
建议引入:
```ts
type ListSession = {
listSeq: number
signature: string
}
```
其中:
- `listSeq` 每次检测到列表数据源变化时递增
- `signature` 由当前页 `authorId` 列表和必要的 URL 查询参数组成
所有异步回写都必须满足:
- 结果对应的 `listSeq` 仍等于当前 controller 的 `listSeq`
- 行节点上的 `data-sces-list-seq` 与结果中的 `listSeq` 一致
- 行节点上的 `data-sces-author-id` 与结果中的 `authorId` 一致
否则直接丢弃,不允许写回旧列表结果。
实现上还应补一条:
- 真正写回前重新从当前 DOM 查询目标行和目标单元格,而不是信任旧闭包里的节点引用
## 14. 状态模型
每一行使用统一状态,驱动两列一起更新:
```ts
type RowStatus =
| {
state: "loading"
authorId: string
listSeq: number
}
| {
state: "success"
authorId: string
listSeq: number
source: "cache" | "network"
singleVideoAfterSearchRate: string
personalVideoAfterSearchRate: string
}
| {
state: "error"
authorId: string | null
listSeq: number
retryable: boolean
reason: "request-failed" | "timeout" | "missing-author-id" | "bad-response"
}
```
两个单元格共用一份行状态,而不是各自独立状态。
## 15. 缓存与请求去重
阶段 1 使用内存缓存:
```ts
Map<authorId, CacheEntry>
```
缓存项建议包含:
- `status`
- `rates`
- `updatedAt`
- `inflightPromise`
行为规则:
- 同一达人在同一标签页会话内再次出现时,优先复用成功缓存
- 若该达人正在请求中,不重复发请求,复用同一 `inflightPromise`
- `missing-author-id` 不进入 `authorId` 缓存
- 瞬时错误不作为长期成功缓存保存;用户点击重试时必须允许重新发请求
阶段 1 不做持久缓存。页面刷新后缓存丢失是接受的。
## 16. 批量加载、并发与重试策略
因为页面进入后要自动为当前页所有达人批量加载,所以必须限制并发,避免过载或拖慢页面。
建议:
- 当前页自动批量加载
- 并发上限设置为 `4`
- 剩余任务排队
- 单请求有独立超时
调度规则建议写清:
- 当前页有 `authorId` 的行先全部进入 `加载中...`
- 然后逐批更新
- 列表变化后,未开始的旧任务直接丢弃
- 已发出的旧任务即使返回,也必须经过 `listSeq` 校验后才能回写
- 批量调度器只负责返回 `authorId + listSeq + result`,不直接持有或操作 DOM
失败重试规则:
- 点击任一失败单元格,只重试该行对应的 `authorId`
- 两列一起切回 `加载中...`
- 若该达人当前已有 `inflightPromise`,则直接复用,不重复起请求
## 17. 列表变化监听
阶段 1 需要自动响应以下变化:
- 翻页
- 切换筛选
- 重新搜索
- 切换排序
推荐统一抽象成“列表数据源变化”,而不是分别写四套逻辑。
实现上建议组合使用:
- `MutationObserver` 观察表格区域变化
- 当前行 `authorId` 列表签名
- 当前 URL / 查询参数变化
当以下任一变化发生时,触发一次新同步:
- 当前页行集合改变
- 列表主容器被替换
- 搜索参数或分页参数改变
## 18. 渲染规则
### 18.1 初始态
当前页新行出现后:
-`authorId` 的行两列显示 `加载中...`
-`authorId` 的行两列显示 `加载失败`
### 18.2 成功态
成功后分别显示:
- `单视频看后搜率`
- `个人视频看后搜率`
### 18.3 失败态
失败后两个单元格都显示:
- `加载失败`
### 18.4 失败重试
点击任一失败单元格时:
- 对应整行重试
- 两列一起切回 `加载中...`
- 再次根据结果统一更新
## 19. 错误处理与日志
阶段 1 的失败场景至少包括:
- 当前行缺少 `author_id`
- 请求超时
- 请求失败
- 响应结构异常
- 返回值只拿到一项
处理原则:
- 不阻断其他行
- 单行失败不影响整页
- 失败要有明确展示
- 失败原因要能在日志中区分
日志建议保留统一前缀,例如:
- `[star-chart-search-enhancer] market-sync-start`
- `[star-chart-search-enhancer] market-row-error`
- `[star-chart-search-enhancer] market-stale-result-dropped`
## 20. TDD 策略
阶段 1 必须继续用 TDD 推进,尤其是列表页路由分发、DOM 增强和状态同步逻辑。
### 20.1 纯函数测试
新增或扩展:
- 列表页详情链接中的达人 ID 提取
- 指标接口响应到展示字段的专用 mapper
- 值格式规范化
- 列表签名生成
- 行状态机变换
### 20.2 DOM 测试
新增基于最小 DOM fixture 的测试:
-`操作` 列前插入两列表头
- 在每一行的 `操作` 单元格前插入两列
- 已插入时不重复插入
- 行状态在 `loading -> success -> error -> retry` 间正确切换
### 20.3 调度测试
新增测试覆盖:
- 当前页自动批量加载
- 同达人请求去重
- 并发上限控制
- 缓存复用
- 列表变化后旧结果不回写新列表
### 20.4 路由与构建回归测试
必须保留并扩展现有测试:
- Manifest 现在同时覆盖详情页和 `creator/market`
- 详情页原有 content/page 行为不回归
- 新的路由入口能在 market 页面启动正确 controller
## 21. 建议的代码结构调整
为了实现阶段 1,建议在现有项目基础上调整为以下职责边界:
```text
src/
content/
index.ts
detail/
index.ts
market/
index.ts
api-client.ts
batch-loader.ts
cache-store.ts
dom-sync.ts
id-extractor.ts
list-signature.ts
row-render.ts
row-state.ts
shared/
get-star-id.ts
normalize-rate-value.ts
result-types.ts
```
说明:
- 详情页 controller 应从当前 `content/index.ts` 中拆出去,避免市场页逻辑污染原实现
- 列表页阶段 1 不建议修改 `src/page/hook.ts`
- 列表页应使用专用 `api-client + mapper`,而不是借道详情页提取器
## 22. 交付边界
阶段 1 完成时,应当满足:
- 找达人列表页能自动新增两列
- 进入页面后当前页所有达人自动开始加载
- 成功时显示真实值
- 失败时显示 `加载失败`
- 点击失败单元格可按整行重试
- 翻页、搜索、筛选、排序变化后自动重跑
- 同达人内存缓存生效
- 自动化测试覆盖关键行为
- 详情页现有能力与测试继续通过
阶段 1 完成时,仍然不要求:
- 导出全部结果
- 接管原生导出按钮
- 排序这两列
- 跨刷新缓存
## 23. 风险
- 列表页 DOM 结构可能比详情页更容易变化
- 行内达人 ID 不一定总能稳定拿到
- 自动批量请求过多时可能受限流影响
- 页面自身虚拟滚动或复用行 DOM 时,可能影响状态回写
- 若 content script 的同源请求在实际页面环境受限,需要回到设计层重新决定是否引入页面请求桥
## 24. 当前结论
阶段 1 的正确方向已经明确:
- 不继续做详情页控制台实验
- 在现有仓库上增量添加 market page controller
-`操作` 列前插入两列
- 用 content script 主动请求已验证接口
- 用专用 mapper 处理已知字段,不复用详情页泛化提取器
-`listSeq`、内存缓存、整行状态和失败重试形成闭环
只要列表页能够稳定拿到每行达人 `author_id`,且 content script 对该接口的同源请求可用,阶段 1 就具备较高可行性。
@@ -0,0 +1,257 @@
# 星图市场导出范围设计
## 背景
当前插件的 `导出CSV` 只导出当前页数据,并在星图默认列后追加两列:
- `单视频看后搜率`
- `个人视频看后搜率`
现阶段用户希望把导出范围扩展为可配置页数,而不是固定当前页。同时需要保留当前默认列导出逻辑,避免重新引入之前“全量扫描导致页面闪动、卡顿、排序异常”的问题。
## 目标
- 支持导出范围配置,默认导出前 `5` 页。
- 支持以下范围选项:
- `当前页`
- `前5页`
- `前10页`
- `全部`
- `自定义`
- 保留当前 CSV 规则:
- 先导出星图页面默认列
- 末尾追加两列插件字段
- 批量导出时真实翻页采集当前筛选结果页,不改变当前页面筛选条件来源。
- 保持当前“筛选/排序只作用当前页”的行为,不重新绑定多页扫描逻辑。
## 非目标
- 不改为直接调用星图列表接口拉多页数据。
- 不让插件筛选或排序自动跨页生效。
- 不处理“导出后自动回到最初分页位置”的复杂恢复,首版只要求滚动位置恢复到单页采集前。
## 交互设计
在插件工具栏中保留一个 `导出CSV` 按钮,同时新增导出范围控件。
### 范围控件
- 默认值:`前5页`
- 可选项:
- `当前页`
- `前5页`
- `前10页`
- `全部`
- `自定义`
- 选择 `自定义` 时,展示页数输入框
- 仅接受正整数
- 无效值时禁止导出并提示错误
### 任务状态
- 导出开始后:
- `导出CSV` 按钮禁用
- 筛选按钮、排序按钮、范围控件、自定义输入框一并禁用
- 按钮文案根据任务模式更新:
- 当前页:`导出中...`
- 前 N 页:`导出中 3/5 页...`
- 全部:`导出中 第3页...`
- 全部导出时额外显示轻提示:
- `全部导出可能耗时较长,页面会自动翻页。`
### 失败反馈
- 任一页采集失败时直接终止
- 通过按钮附近状态文本或 `alert` 提示:
- `第 N 页导出失败,请稍后重试`
- 失败时不下载残缺 CSV
## 执行流程
### 当前页导出
当前页模式沿用现有逻辑:
1. 等待当前页表格可读取
2. 自动滚动当前页一次,补齐懒加载字段
3. 读取当前页星图默认列
4. 合并插件两列
5. 生成 CSV 并下载
### 多页导出
多页模式仅在导出任务中启用,不影响平时筛选和排序。
1. 读取导出范围配置
2. 初始化批量任务状态与结果聚合器
3. 对当前页执行:
- 等待表格稳定
- 自动滚动当前页以采全默认列
- 读取当前页记录
-`authorId` 合并入结果集
4. 判断是否达到目标页数
5. 若未完成,则点击下一页
6. 等待“页切换完成”信号
7. 重复步骤 3 到 6
8. 汇总所有记录,生成单个 CSV 下载
### 全部导出停止条件
`全部` 模式从当前页开始向后翻页,直到任一条件成立:
- 下一页按钮不可点击
- 当前页签名不再变化
- 页面无法继续翻页并达到超时阈值
## 页切换判定
批量导出不能只依赖固定 `setTimeout`。翻页完成需要同时满足至少一个强信号:
- 当前页作者 ID 集合发生变化
- 当前页首行作者 ID 变化
- 当前页活动分页按钮变化
- 下一页按钮状态变化
为避免误判,翻页流程需要:
1. 记录翻页前页签名
2. 点击下一页
3. 轮询直到签名变化或超时
4. 签名变化后再执行一次当前页表格稳定等待
## 当前页采集策略
当前页导出已知存在懒加载问题:页面初加载时后半段行的默认列为空,滚动后才补齐。
因此无论当前页导出还是多页导出,每一页都必须执行:
1. 初次读取当前页快照并写入 store
2. 查找当前市场列表滚动容器
3. 逐步向下滚动直到该页底部
4. 每次滚动后等待 DOM 稳定,再采集一轮
5. 恢复滚动位置并再采集一轮
这一步只针对“当前已打开的页”,不跨页扫描。
## 数据合并规则
### 去重键
- 使用 `authorId` 去重
### 字段合并
- 新页记录有值时,补充到已有记录
- 新页记录为空时,不覆盖已有非空值
- 插件两列沿用当前已有的合并规则
### CSV 表头
- 先保留星图默认列顺序
- 再追加:
- `单视频看后搜率`
- `个人视频看后搜率`
## 错误处理
### 单页失败
以下情况视为单页失败:
- 找不到表格根节点
- 找不到下一页按钮但目标尚未完成
- 翻页后页签名未变化且超时
- 当前页采集过程中出现异常
处理方式:
- 立即终止任务
- 恢复工具栏控件状态
- 显示页级错误信息
- 不触发下载
### 用户干预
首版不额外提供取消按钮,但应防御以下情况:
- 用户手动切换筛选
- 用户手动翻页
- 用户再次点击导出
处理方式:
- 导出期间禁用插件控件
- 对关键节点重新校验表格和页签名
- 如环境已被破坏,终止任务并报错
## 实现建议
建议按以下边界组织实现:
### `plugin-toolbar`
- 新增导出范围选择器
- 新增自定义页数输入框
- 新增导出过程中的状态展示
### `market/index`
- 保留当前页导出逻辑作为单页模式
- 新增批量导出入口
- 管理任务状态、按钮禁用、进度回写
### 新建批量导出控制器
建议新增类似 `export-range-controller.ts` 的模块,职责包括:
- 读取当前页记录
- 控制翻页
- 等待页切换
- 聚合多页数据
- 统一返回最终 `MarketRecord[]`
这样可以避免把 `market/index.ts` 继续堆大。
### `dom-sync` 或新辅助模块
- 提供读取当前页签名
- 提供定位下一页按钮
- 提供判断下一页是否可点击
## 测试策略
### 单元测试
- 默认导出范围为前 5 页
- 选择 `当前页` 时只导出一页
- 选择 `前10页` 时最多导出 10 页
- `自定义` 输入非法值时不启动导出
- `全部` 模式在没有下一页时停止
- 多页合并时按 `authorId` 去重
- 空字段不会覆盖已有非空字段
### 集成测试
- 批量导出时会依次点击下一页
- 翻页后等待新页签名再采集
- 每一页在导出前都会执行滚动采集
- 中途某页失败时不生成 CSV
- 导出期间工具栏控件被禁用
## 风险
- 星图分页 DOM 结构可能不稳定,下一页按钮定位需要做降级兼容。
- 全部导出在高页数场景下耗时会较长。
- 页面中途刷新或筛选重算可能打断批量任务。
## 推荐实施顺序
1. 工具栏新增范围选择与自定义输入
2. 抽出批量导出控制器
3. 实现前 N 页导出
4. 在前 N 页能力稳定后开放 `全部`
5. 补齐全部相关失败与超时测试
## 决策结论
采用“一个导出按钮 + 范围选择器”的方案,默认导出前 `5` 页,支持 `全部``自定义`。批量模式通过真实翻页逐页采集当前筛选结果,不把多页扫描重新引入到筛选和排序链路中。
+1162 -574
View File
File diff suppressed because it is too large Load Diff
+10 -8
View File
@@ -1,16 +1,18 @@
{ {
"name": "star-chart-search-enhancer", "name": "market-plugin-impl",
"version": "0.2.0421.2",
"description": "Bootstrap for the Xingtu market Chrome MV3 extension.",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"build": "node scripts/build.mjs", "build": "node scripts/build.mjs",
"test": "vitest run", "test": "vitest run --passWithNoTests",
"test:watch": "vitest" "test:watch": "vitest --passWithNoTests"
}, },
"license": "UNLICENSED",
"devDependencies": { "devDependencies": {
"jsdom": "^26.1.0", "jsdom": "^29.0.2",
"tsup": "^8.5.0", "tsup": "^8.5.1",
"typescript": "^5.8.3", "typescript": "^6.0.3",
"vitest": "^3.1.3" "vitest": "^4.1.4"
} }
} }
+37 -14
View File
@@ -1,33 +1,56 @@
import { copyFile, mkdir, rm } from "node:fs/promises"; import { cp, mkdir, rm } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { build } from "tsup"; import { build } from "tsup";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, ".."); const projectRoot = path.resolve(__dirname, "..");
const distDir = path.join(projectRoot, "dist"); const distDir = path.join(projectRoot, "dist");
await rm(distDir, { force: true, recursive: true }); await rm(distDir, { recursive: true, force: true });
await mkdir(path.join(distDir, "content"), { recursive: true });
await mkdir(path.join(distDir, "background"), { recursive: true });
await build({ await build({
clean: false,
dts: false,
entry: { entry: {
"content/index": path.join(projectRoot, "src/content/index.ts"), index: path.join(projectRoot, "src/content/index.ts"),
"page/hook": path.join(projectRoot, "src/page/hook.ts") "market-page-bridge": path.join(
projectRoot,
"src/content/market/page-bridge.ts"
)
}, },
format: ["iife"], format: ["iife"],
minify: false,
outDir: distDir,
platform: "browser", platform: "browser",
silent: true, target: "chrome114",
outDir: path.join(distDir, "content"),
clean: false,
splitting: false, splitting: false,
target: "es2022", sourcemap: false,
treeshake: false minify: false,
outExtension() {
return { js: ".js" };
}
}); });
await mkdir(distDir, { recursive: true }); await build({
await copyFile( entry: {
index: path.join(projectRoot, "src/background/index.ts")
},
format: ["iife"],
platform: "browser",
target: "chrome114",
outDir: path.join(distDir, "background"),
clean: false,
splitting: false,
sourcemap: false,
minify: false,
outExtension() {
return { js: ".js" };
}
});
await cp(
path.join(projectRoot, "src/manifest.json"), path.join(projectRoot, "src/manifest.json"),
path.join(distDir, "manifest.json") path.join(distDir, "manifest.json")
); );
+95
View File
@@ -0,0 +1,95 @@
interface ChromeDownloadsLike {
download(
options: {
filename: string;
saveAs?: boolean;
url: string;
},
callback?: () => void
): Promise<unknown> | void;
}
interface ChromeRuntimeLike {
onMessage?: {
addListener(
listener: (
message: unknown,
sender: unknown,
sendResponse: (response: unknown) => void
) => boolean | void
): void;
};
}
interface ChromeLike {
downloads?: ChromeDownloadsLike;
runtime?: ChromeRuntimeLike;
}
type DownloadMarketCsvMessage = {
csv: string;
filename: string;
type: "download-market-csv";
};
export function registerBackgroundMessageHandler(
chromeLike: ChromeLike = (
globalThis as typeof globalThis & {
chrome?: ChromeLike;
}
).chrome ?? {}
): void {
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
if (!isDownloadMarketCsvMessage(message)) {
return;
}
void triggerCsvDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true });
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false
});
});
return true;
});
}
async function triggerCsvDownload(
chromeLike: ChromeLike,
message: DownloadMarketCsvMessage
): Promise<void> {
if (!chromeLike.downloads?.download) {
throw new Error("chrome.downloads.download is unavailable");
}
const csvUrl = `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${message.csv}`)}`;
await Promise.resolve(
chromeLike.downloads.download({
filename: message.filename,
saveAs: false,
url: csvUrl
})
);
}
function isDownloadMarketCsvMessage(
message: unknown
): message is DownloadMarketCsvMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<DownloadMarketCsvMessage>;
return (
candidate.type === "download-market-csv" &&
typeof candidate.csv === "string" &&
typeof candidate.filename === "string"
);
}
registerBackgroundMessageHandler();
-181
View File
@@ -1,181 +0,0 @@
import { createRouteState } from "../route-state";
import {
isCandidateAnalysisMessage,
isCandidateRequestMessage,
isHookReadyMessage,
isAfterSearchRateResultMessage
} from "../../shared/message-types";
import type { AfterSearchRateResult } from "../../shared/result-types";
const LOG_PREFIX = "[star-chart-search-enhancer]";
const PAGE_HOOK_SCRIPT_ID = "star-chart-search-enhancer-page-hook";
interface ChromeRuntimeLike {
getURL(path: string): string;
}
interface LoggerLike {
debug(...args: unknown[]): void;
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
}
export interface DetailContentControllerOptions {
chromeRuntime: ChromeRuntimeLike;
document: Document;
logger: LoggerLike;
window: Window;
}
export function createDetailContentController(
options: DetailContentControllerOptions
) {
const routeState = createRouteState(options.window.location.href);
let currentSnapshot = routeState.getSnapshot();
const loggedResults = new Map<string, { fingerprint: string; success: boolean }>();
const originalPushState = options.window.history.pushState.bind(
options.window.history
);
const originalReplaceState = options.window.history.replaceState.bind(
options.window.history
);
const onMessage = (event: MessageEvent) => {
if (event.source !== options.window) {
return;
}
if (isHookReadyMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "hook-ready", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-hook-ready",
event.data.payload.routeKey
);
}
return;
}
if (isCandidateRequestMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-request", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-request",
event.data.payload.routeKey
);
}
return;
}
if (isCandidateAnalysisMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-analysis", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-analysis",
event.data.payload.routeKey
);
}
return;
}
if (!isAfterSearchRateResultMessage(event.data)) {
return;
}
const payload = event.data.payload;
if (!isSameRouteIdentity(payload.routeKey, currentSnapshot.routeKey)) {
options.logger.debug(LOG_PREFIX, "stale-result", payload.routeKey);
return;
}
logFinalResult(payload);
};
options.window.addEventListener("message", onMessage);
options.window.history.pushState = wrapHistoryMethod(originalPushState);
options.window.history.replaceState = wrapHistoryMethod(originalReplaceState);
options.window.addEventListener("popstate", handleNavigation);
injectPageHook(options.document, options.chromeRuntime);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
return {
dispose() {
options.window.removeEventListener("message", onMessage);
options.window.removeEventListener("popstate", handleNavigation);
options.window.history.pushState = originalPushState;
options.window.history.replaceState = originalReplaceState;
},
getSnapshot() {
return currentSnapshot;
}
};
function wrapHistoryMethod<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
const result = originalMethod(...args);
handleNavigation();
return result;
}) as T;
}
function handleNavigation() {
currentSnapshot = routeState.advance(options.window.location.href);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
}
function logFinalResult(payload: AfterSearchRateResult) {
const fingerprint = JSON.stringify({
matchedRequestUrl: payload.matchedRequestUrl ?? null,
rates: payload.rates ?? null,
reason: payload.reason ?? null,
routeKey: payload.routeKey,
stage: payload.stage,
success: payload.success
});
const previousResult = loggedResults.get(payload.routeKey);
if (previousResult?.fingerprint === fingerprint) {
return;
}
if (previousResult?.success && !payload.success) {
return;
}
loggedResults.set(payload.routeKey, {
fingerprint,
success: payload.success
});
options.logger.info(LOG_PREFIX, "result", payload);
}
}
function isSameRouteIdentity(leftRouteKey: string, rightRouteKey: string): boolean {
return stripNavigationSeq(leftRouteKey) === stripNavigationSeq(rightRouteKey);
}
function stripNavigationSeq(routeKey: string): string {
return routeKey.replace(/::\d+$/, "");
}
function injectPageHook(document: Document, chromeRuntime: ChromeRuntimeLike) {
if (document.getElementById(PAGE_HOOK_SCRIPT_ID)) {
return;
}
const script = document.createElement("script");
script.id = PAGE_HOOK_SCRIPT_ID;
script.src = chromeRuntime.getURL("page/hook.global.js");
script.async = false;
(document.head ?? document.documentElement).appendChild(script);
}
+106 -55
View File
@@ -1,67 +1,57 @@
import { getStarIdFromUrl } from "../shared/get-star-id";
import { RESULT_MESSAGE_TYPE } from "../shared/message-types";
import { import {
createDetailContentController, createMarketController,
type DetailContentControllerOptions type CreateMarketControllerOptions
} from "./detail/index";
import {
createMarketContentController,
type MarketContentControllerOptions
} from "./market/index"; } from "./market/index";
interface ControllerLike {
dispose(): void;
}
interface ChromeRuntimeLike { interface ChromeRuntimeLike {
getURL(path: string): string; getURL?: (path: string) => string;
id?: string;
sendMessage?: (message: unknown) => void | Promise<unknown>;
} }
interface LoggerLike { const DOWNLOAD_MARKET_CSV_MESSAGE = "download-market-csv";
debug(...args: unknown[]): void;
info(...args: unknown[]): void; interface BootContentScriptOptions {
warn(...args: unknown[]): void; createMarketController?: (
options: CreateMarketControllerOptions
) => { dispose?: () => void; ready: Promise<void> };
document?: Document;
window?: Window;
} }
interface ContentControllerOptions extends DetailContentControllerOptions { export async function bootContentScript(
detailControllerFactory?: ( options: BootContentScriptOptions = {}
options: DetailContentControllerOptions ): Promise<{ ready: Promise<void> } | null> {
) => ControllerLike; const currentWindow = options.window ?? window;
marketControllerFactory?: ( const currentDocument = options.document ?? document;
options: MarketContentControllerOptions const controllerFactory =
) => ControllerLike; options.createMarketController ?? createMarketController;
}
export function createContentController( if (!isMarketPage(currentWindow.location.href)) {
options: ContentControllerOptions return null;
): ControllerLike {
if (isCreatorDetailUrl(options.window.location.href)) {
const detailControllerFactory =
options.detailControllerFactory ?? createDetailContentController;
return detailControllerFactory(options);
} }
if (isCreatorMarketUrl(options.window.location.href)) { installMarketPageBridge(currentDocument);
const marketControllerFactory =
options.marketControllerFactory ?? createMarketContentController; return controllerFactory({
return marketControllerFactory(options); document: currentDocument,
onCsvReady: (csv: string) => {
if (requestCsvDownload(csv)) {
return;
} }
return createNoopController(); downloadCsv(currentDocument, currentWindow, csv);
},
window: currentWindow
});
} }
function isCreatorDetailUrl(url: string): boolean { function isMarketPage(url: string): boolean {
return getStarIdFromUrl(url) !== null; const parsedUrl = new URL(url);
} const isXingtuHost =
parsedUrl.hostname === "xingtu.cn" || parsedUrl.hostname.endsWith(".xingtu.cn");
function createNoopController(): ControllerLike { return isXingtuHost && parsedUrl.pathname.startsWith("/ad/creator/market");
return {
dispose() {}
};
}
function isCreatorMarketUrl(url: string): boolean {
return new URL(url).pathname === "/ad/creator/market";
} }
function bootstrapContentScript() { function bootstrapContentScript() {
@@ -77,21 +67,82 @@ function bootstrapContentScript() {
const marker = "__starChartSearchEnhancerContentController"; const marker = "__starChartSearchEnhancerContentController";
const scopedWindow = window as Window & { const scopedWindow = window as Window & {
[marker]?: ControllerLike; [marker]?: boolean | { dispose?: () => void; ready: Promise<void> } | null;
}; };
if (scopedWindow[marker]) { if (scopedWindow[marker]) {
return; return;
} }
scopedWindow[marker] = createContentController({ scopedWindow[marker] = true;
chromeRuntime: runtime, void bootContentScript().then((controller) => {
document, scopedWindow[marker] = controller;
logger: console,
window
}); });
} }
bootstrapContentScript(); bootstrapContentScript();
export { RESULT_MESSAGE_TYPE }; function requestCsvDownload(csv: string): boolean {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (!runtime?.id || typeof runtime.sendMessage !== "function") {
return false;
}
runtime.sendMessage({
csv,
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
type: DOWNLOAD_MARKET_CSV_MESSAGE
});
return true;
}
function downloadCsv(document: Document, window: Window, csv: string): void {
const blob = new Blob(["\uFEFF", csv], {
type: "text/csv;charset=utf-8"
});
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(objectUrl);
}
function formatTimestampForFilename(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function installMarketPageBridge(document: Document) {
if (
document.documentElement.querySelector(
'[data-sces-market-bridge="script"]'
)
) {
return;
}
const script = document.createElement("script");
script.dataset.scesMarketBridge = "script";
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
const bridgeUrl = runtime?.getURL?.("content/market-page-bridge.js");
if (bridgeUrl) {
script.src = bridgeUrl;
} else {
script.textContent = "";
}
(document.head ?? document.documentElement).appendChild(script);
}
+46 -42
View File
@@ -1,23 +1,10 @@
import { normalizeRateValue } from "../../shared/normalize-rate-value"; import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import type { AfterSearchRates } from "../../shared/result-types"; import type { MarketApiResult } from "./types";
type MarketApiFailureReason = "bad-response" | "request-failed" | "timeout";
type MarketApiSuccessResult = {
success: true;
rates: Required<AfterSearchRates>;
};
type MarketApiFailureResult = {
reason: MarketApiFailureReason;
success: false;
};
export type MarketApiResult = MarketApiSuccessResult | MarketApiFailureResult;
interface FetchResponseLike { interface FetchResponseLike {
json(): Promise<unknown>; json(): Promise<unknown>;
ok: boolean; ok: boolean;
status?: number;
} }
type FetchLike = ( type FetchLike = (
@@ -38,23 +25,32 @@ export function createMarketApiClient(options: MarketApiClientOptions = {}) {
return { return {
async loadAuthorAseInfo(authorId: string): Promise<MarketApiResult> { async loadAuthorAseInfo(authorId: string): Promise<MarketApiResult> {
const primaryResult = await loadAuthorMetricsFromUrl(
buildAuthorCommerceSeedBaseInfoUrl(authorId, baseUrl)
);
if (primaryResult.success || primaryResult.reason === "timeout") {
return primaryResult;
}
return loadAuthorMetricsFromUrl(buildAuthorAseInfoUrl(authorId, baseUrl));
}
};
async function loadAuthorMetricsFromUrl(url: string): Promise<MarketApiResult> {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetchImpl( const response = await fetchImpl(url, {
buildAuthorAseInfoUrl(authorId, baseUrl),
{
credentials: "include", credentials: "include",
method: "GET", method: "GET",
signal: controller.signal signal: controller.signal
} });
);
if (!response.ok) { if (!response.ok) {
return { return {
reason: "request-failed", success: false,
success: false reason: "request-failed"
}; };
} }
@@ -62,20 +58,19 @@ export function createMarketApiClient(options: MarketApiClientOptions = {}) {
} catch (error) { } catch (error) {
if (isAbortError(error) || controller.signal.aborted) { if (isAbortError(error) || controller.signal.aborted) {
return { return {
reason: "timeout", success: false,
success: false reason: "timeout"
}; };
} }
return { return {
reason: "request-failed", success: false,
success: false reason: "request-failed"
}; };
} finally { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
} }
};
} }
export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string { export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string {
@@ -85,12 +80,25 @@ export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string
return url.toString(); return url.toString();
} }
export function buildAuthorCommerceSeedBaseInfoUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL(
"/gw/api/aggregator/get_author_commerce_seed_base_info",
baseUrl
);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("range", "90");
return url.toString();
}
export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult { export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
const data = getPayloadData(payload); const data = getPayloadData(payload);
if (!data) { if (!data) {
return { return {
reason: "bad-response", success: false,
success: false reason: "bad-response"
}; };
} }
@@ -103,17 +111,17 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) { if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
return { return {
reason: "bad-response", success: false,
success: false reason: "missing-rate"
}; };
} }
return { return {
success: true,
rates: { rates: {
personalVideoAfterSearchRate, singleVideoAfterSearchRate,
singleVideoAfterSearchRate personalVideoAfterSearchRate
}, }
success: true
}; };
} }
@@ -122,15 +130,11 @@ function getPayloadData(payload: unknown): Record<string, unknown> | null {
return null; return null;
} }
if (isRecord(payload.data)) { return isRecord(payload.data) ? payload.data : payload;
return payload.data;
}
return payload;
} }
function readNormalizedRate(value: unknown): string | null { function readNormalizedRate(value: unknown): string | null {
return typeof value === "string" ? normalizeRateValue(value) : null; return typeof value === "string" ? normalizeRateDisplay(value) : null;
} }
function resolveBaseUrl(): string { function resolveBaseUrl(): string {
-206
View File
@@ -1,206 +0,0 @@
import type { MarketApiResult } from "./api-client";
import { createMarketCacheStore } from "./cache-store";
import type { RowErrorReason, MarketRowState } from "./row-state";
import type { RequiredAfterSearchRates } from "./types";
interface BatchLoaderRow {
authorId: string | null;
rowKey: string;
render(
state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void }
): void;
}
interface LoadRowsOptions {
listSeq: number;
rows: BatchLoaderRow[];
shouldRenderResult?: (params: {
authorId: string;
listSeq: number;
row: BatchLoaderRow;
}) => boolean;
}
interface MarketBatchLoaderOptions {
apiClient: {
loadAuthorAseInfo(authorId: string): Promise<MarketApiResult>;
};
cacheStore?: ReturnType<typeof createMarketCacheStore>;
concurrency?: number;
}
export function createMarketBatchLoader(options: MarketBatchLoaderOptions) {
const cacheStore = options.cacheStore ?? createMarketCacheStore();
const concurrency = Math.max(options.concurrency ?? 4, 1);
return {
async loadRows(loadOptions: LoadRowsOptions) {
const groupedRows = new Map<string, BatchLoaderRow[]>();
for (const row of loadOptions.rows) {
if (!row.authorId) {
row.render({
authorId: null,
listSeq: loadOptions.listSeq,
reason: "missing-author-id",
retryable: false,
state: "error"
});
continue;
}
const cached = cacheStore.getSuccess(row.authorId);
if (cached) {
row.render({
authorId: row.authorId,
listSeq: loadOptions.listSeq,
personalVideoAfterSearchRate: cached.rates.personalVideoAfterSearchRate,
singleVideoAfterSearchRate: cached.rates.singleVideoAfterSearchRate,
source: "cache",
state: "success"
});
continue;
}
row.render({
authorId: row.authorId,
listSeq: loadOptions.listSeq,
state: "loading"
});
const rowsForAuthor = groupedRows.get(row.authorId) ?? [];
rowsForAuthor.push(row);
groupedRows.set(row.authorId, rowsForAuthor);
}
const tasks = Array.from(groupedRows.entries(), ([authorId, rows]) => {
return () => loadAuthorRows(authorId, rows, loadOptions);
});
await runWithConcurrency(tasks, concurrency);
}
};
async function loadAuthorRows(
authorId: string,
rows: BatchLoaderRow[],
loadOptions: LoadRowsOptions
) {
const result = await requestAuthor(authorId);
for (const row of rows) {
if (
loadOptions.shouldRenderResult &&
!loadOptions.shouldRenderResult({
authorId,
listSeq: loadOptions.listSeq,
row
})
) {
continue;
}
renderAuthorResult(row, authorId, loadOptions.listSeq, result, false);
}
}
async function retryRow(row: BatchLoaderRow, listSeq: number) {
if (!row.authorId) {
return;
}
row.render({
authorId: row.authorId,
listSeq,
state: "loading"
});
const result = await requestAuthor(row.authorId);
renderAuthorResult(row, row.authorId, listSeq, result, false);
}
async function requestAuthor(authorId: string): Promise<MarketApiResult> {
const cached = cacheStore.getSuccess(authorId);
if (cached) {
return {
rates: cached.rates,
success: true
};
}
const inflight = cacheStore.getInflight<MarketApiResult>(authorId);
if (inflight) {
return inflight;
}
const requestPromise = options.apiClient
.loadAuthorAseInfo(authorId)
.then((result) => {
if (result.success) {
cacheStore.setSuccess(authorId, result.rates);
}
return result;
})
.finally(() => {
cacheStore.clearInflight(authorId);
});
cacheStore.setInflight(authorId, requestPromise);
return requestPromise;
}
function renderAuthorResult(
row: BatchLoaderRow,
authorId: string,
listSeq: number,
result: MarketApiResult,
fromCache: boolean
) {
if (result.success) {
const rates = result.rates as RequiredAfterSearchRates;
row.render({
authorId,
listSeq,
personalVideoAfterSearchRate: rates.personalVideoAfterSearchRate,
singleVideoAfterSearchRate: rates.singleVideoAfterSearchRate,
source: fromCache ? "cache" : "network",
state: "success"
});
return;
}
row.render(
{
authorId,
listSeq,
reason: result.reason as RowErrorReason,
retryable: true,
state: "error"
},
{
onRetry: () => retryRow(row, listSeq)
}
);
}
}
async function runWithConcurrency(
tasks: Array<() => Promise<void>>,
concurrency: number
) {
let nextTaskIndex = 0;
const workers = Array.from(
{ length: Math.min(concurrency, tasks.length) },
async () => {
while (nextTaskIndex < tasks.length) {
const task = tasks[nextTaskIndex];
nextTaskIndex += 1;
await task();
}
}
);
await Promise.all(workers);
}
-32
View File
@@ -1,32 +0,0 @@
import type { RequiredAfterSearchRates } from "./types";
interface SuccessCacheEntry {
rates: RequiredAfterSearchRates;
updatedAt: number;
}
export function createMarketCacheStore() {
const successCache = new Map<string, SuccessCacheEntry>();
const inflightRequests = new Map<string, Promise<unknown>>();
return {
clearInflight(authorId: string) {
inflightRequests.delete(authorId);
},
getInflight<T>(authorId: string) {
return inflightRequests.get(authorId) as Promise<T> | undefined;
},
getSuccess(authorId: string) {
return successCache.get(authorId);
},
setInflight<T>(authorId: string, promise: Promise<T>) {
inflightRequests.set(authorId, promise);
},
setSuccess(authorId: string, rates: RequiredAfterSearchRates) {
successCache.set(authorId, {
rates,
updatedAt: Date.now()
});
}
};
}
+80
View File
@@ -0,0 +1,80 @@
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import { escapeCsvCell } from "../../shared/csv";
import type { MarketRecord } from "./types";
type CsvColumn = {
header: string;
readValue: (record: MarketRecord) => string;
};
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
{
header: "达人ID",
readValue: (record: MarketRecord) => record.authorId
},
{
header: "达人名称",
readValue: (record: MarketRecord) => record.authorName
},
{
header: "地区",
readValue: (record: MarketRecord) => record.location ?? ""
},
{
header: "21-60s报价",
readValue: (record: MarketRecord) => record.price21To60s ?? ""
}
];
const RATE_COLUMNS: CsvColumn[] = [
{
header: "单视频看后搜率",
readValue: (record: MarketRecord) =>
record.rates?.singleVideoAfterSearchRate
? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate)
: ""
},
{
header: "个人视频看后搜率",
readValue: (record: MarketRecord) =>
record.rates?.personalVideoAfterSearchRate
? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate)
: ""
}
];
export function buildMarketCsv(records: MarketRecord[]): string {
const baseColumns = buildBaseColumns(records);
const csvColumns = [...baseColumns, ...RATE_COLUMNS];
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
);
return [headerLine, ...rowLines].join("\n");
}
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
const orderedHeaders: string[] = [];
const seenHeaders = new Set<string>();
records.forEach((record) => {
Object.keys(record.exportFields ?? {}).forEach((header) => {
if (seenHeaders.has(header)) {
return;
}
seenHeaders.add(header);
orderedHeaders.push(header);
});
});
if (orderedHeaders.length === 0) {
return FALLBACK_BASE_COLUMNS;
}
return orderedHeaders.map((header) => ({
header,
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
}));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
import {
findNextPageControl,
isPageControlDisabled,
readMarketPageSignature
} from "./dom-sync";
import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types";
interface ExportRangeControllerOptions {
document: Document;
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
prepareCurrentPageForExport(): Promise<void>;
readCurrentPageRecords(): MarketRecord[];
window: Window;
}
export function createExportRangeController(options: ExportRangeControllerOptions) {
return {
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
const mergedRecords = new Map<string, MarketRecord>();
let currentPage = 0;
let expectedMinimumRowCount: number | undefined;
while (true) {
currentPage += 1;
options.onProgress?.({
currentPage,
totalPages: target.mode === "count" ? target.pageCount : undefined
});
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
if (!currentPageReady) {
throw new Error(`${currentPage} 页加载超时,请稍后重试`);
}
await options.prepareCurrentPageForExport();
const currentPageRecords = options.readCurrentPageRecords();
currentPageRecords.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
});
expectedMinimumRowCount = Math.max(
expectedMinimumRowCount ?? 0,
currentPageRecords.length
);
if (target.mode === "count" && currentPage >= target.pageCount) {
break;
}
const previousSignature = readMarketPageSignature(options.document);
const nextPageControl = findNextPageControl(options.document);
if (!nextPageControl || isPageControlDisabled(nextPageControl)) {
break;
}
nextPageControl.click();
const pageChanged = await waitForPageChange(previousSignature);
if (!pageChanged) {
throw new Error(`${currentPage + 1} 页导出失败,请稍后重试`);
}
}
return Array.from(mergedRecords.values());
}
};
async function waitForPageChange(previousSignature: string): Promise<boolean> {
const previousPageState = parsePageSignature(previousSignature);
for (let attempt = 0; attempt < 60; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 50);
});
await Promise.resolve();
const nextSignature = readMarketPageSignature(options.document);
const nextPageState = parsePageSignature(nextSignature);
if (hasLoadedNextPage(previousPageState, nextPageState)) {
return true;
}
}
return false;
}
async function waitForCurrentPageReady(
expectedMinimumRowCount: number | undefined
): Promise<boolean> {
let stableAttemptCount = 0;
let lastReadyFingerprint = "";
for (let attempt = 0; attempt < 80; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 150);
});
await Promise.resolve();
const pageState = readCurrentPageState();
if (!pageState.authorIds || pageState.rowCount <= 0) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
if (
typeof expectedMinimumRowCount === "number" &&
expectedMinimumRowCount > 0 &&
!pageState.isTerminalPage &&
pageState.rowCount < expectedMinimumRowCount
) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
const readyFingerprint = [
pageState.pageToken,
pageState.authorIds,
String(pageState.rowCount),
pageState.isTerminalPage ? "terminal" : "paged"
].join("::");
if (readyFingerprint === lastReadyFingerprint) {
stableAttemptCount += 1;
} else {
lastReadyFingerprint = readyFingerprint;
stableAttemptCount = 1;
}
if (stableAttemptCount >= 6) {
return true;
}
}
return false;
}
function readCurrentPageState(): {
authorIds: string;
isTerminalPage: boolean;
pageToken: string;
rowCount: number;
} {
const pageSignature = parsePageSignature(readMarketPageSignature(options.document));
const nextPageControl = findNextPageControl(options.document);
return {
authorIds: pageSignature.authorIds,
isTerminalPage: isPageControlDisabled(nextPageControl),
pageToken: pageSignature.pageToken,
rowCount: options.readCurrentPageRecords().length
};
}
}
function parsePageSignature(signature: string): {
authorIds: string;
pageToken: string;
} {
const separatorIndex = signature.indexOf("::");
if (separatorIndex < 0) {
return {
authorIds: "",
pageToken: signature.trim()
};
}
return {
authorIds: signature.slice(separatorIndex + 2).trim(),
pageToken: signature.slice(0, separatorIndex).trim()
};
}
function hasLoadedNextPage(
previousPageState: {
authorIds: string;
pageToken: string;
},
nextPageState: {
authorIds: string;
pageToken: string;
}
): boolean {
if (!nextPageState.authorIds) {
return false;
}
if (nextPageState.pageToken || previousPageState.pageToken) {
return nextPageState.pageToken !== previousPageState.pageToken;
}
return nextPageState.authorIds !== previousPageState.authorIds;
}
function mergeMarketRecord(
existingRecord: MarketRecord | undefined,
incomingRecord: MarketRecord
): MarketRecord {
if (!existingRecord) {
return {
...incomingRecord,
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
rates: mergeFieldMap(undefined, incomingRecord.rates)
};
}
return {
...existingRecord,
...incomingRecord,
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
exportFields: mergeFieldMap(
existingRecord.exportFields,
incomingRecord.exportFields
),
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
hasDirectRatesSource:
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
location: mergeStringValue(existingRecord.location, incomingRecord.location),
price21To60s: mergeStringValue(
existingRecord.price21To60s,
incomingRecord.price21To60s
),
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
status: mergeStatus(existingRecord.status, incomingRecord.status)
};
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStatus(
current: MarketRecordStatus,
incoming: MarketRecordStatus
): MarketRecordStatus {
const priority: Record<MarketRecordStatus, number> = {
failed: 1,
idle: 0,
loading: 2,
missing: -1,
success: 3
};
return priority[incoming] >= priority[current] ? incoming : current;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (hasTextValue(incoming) || !hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
@@ -0,0 +1,95 @@
import {
compareRateValues,
parseRateLowerBound
} from "../../shared/rate-normalizer";
import type {
MarketFilterState,
MarketRecord,
MarketSortState
} from "./types";
interface ApplyFilterAndSortOptions {
filters?: MarketFilterState;
sort?: MarketSortState;
}
export function applyFilterAndSort(
records: MarketRecord[],
options: ApplyFilterAndSortOptions = {}
): MarketRecord[] {
const filteredRecords = records.filter((record) =>
matchesFilters(record, options.filters)
);
if (!options.sort) {
return filteredRecords;
}
return [...filteredRecords].sort((leftRecord, rightRecord) =>
compareRecords(leftRecord, rightRecord, options.sort as MarketSortState)
);
}
function matchesFilters(
record: MarketRecord,
filters: MarketFilterState | undefined
): boolean {
if (!filters) {
return true;
}
return (
meetsThreshold(
record.rates?.singleVideoAfterSearchRate,
filters.singleVideoAfterSearchRateMin
) &&
meetsThreshold(
record.rates?.personalVideoAfterSearchRate,
filters.personalVideoAfterSearchRateMin
)
);
}
function meetsThreshold(
rateValue: string | undefined,
minValue: number | undefined
): boolean {
if (minValue == null) {
return true;
}
const lowerBound = parseRateLowerBound(rateValue ?? null);
return lowerBound != null && lowerBound >= minValue;
}
function compareRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const leftValue = leftRecord.rates?.[sort.field];
const rightValue = rightRecord.rates?.[sort.field];
const leftLowerBound = parseRateLowerBound(leftValue ?? null);
const rightLowerBound = parseRateLowerBound(rightValue ?? null);
if (leftLowerBound == null && rightLowerBound == null) {
return 0;
}
if (leftLowerBound == null) {
return 1;
}
if (rightLowerBound == null) {
return -1;
}
if (leftLowerBound !== rightLowerBound) {
return sort.direction === "asc"
? leftLowerBound - rightLowerBound
: rightLowerBound - leftLowerBound;
}
const tieBreak = compareRateValues(leftValue, rightValue);
return sort.direction === "asc" ? tieBreak : -tieBreak;
}
@@ -0,0 +1,94 @@
import type {
MarketApiFailureReason,
MarketApiResult,
MarketRowSnapshot
} from "./types";
import type { AfterSearchRates } from "./types";
interface ResultStoreLike {
setAuthorFailed(authorId: string, reason: MarketApiFailureReason): void;
setAuthorLoading(authorId: string): void;
setAuthorSuccess(authorId: string, rates: AfterSearchRates): void;
upsertMarketRow(row: MarketRowSnapshot): void;
}
interface FullScanControllerOptions {
goToNextPage(): Promise<boolean>;
hasNextPage(): boolean;
loadAuthorMetrics(authorId: string): Promise<MarketApiResult>;
readCurrentPageRows(): MarketRowSnapshot[];
resultStore: ResultStoreLike;
}
export function createFullScanController(options: FullScanControllerOptions) {
let completedScan = false;
let scanPromise: Promise<void> | null = null;
return {
ensureScanForExport() {
return ensureScan();
},
ensureScanForFilter() {
return ensureScan();
},
ensureScanForSort() {
return ensureScan();
}
};
function ensureScan(): Promise<void> {
if (completedScan) {
return Promise.resolve();
}
if (scanPromise) {
return scanPromise;
}
scanPromise = runScan().finally(() => {
scanPromise = null;
});
return scanPromise;
}
async function runScan(): Promise<void> {
do {
await scanCurrentPage();
if (!options.hasNextPage()) {
completedScan = true;
return;
}
} while (await options.goToNextPage());
completedScan = true;
}
async function scanCurrentPage(): Promise<void> {
const rows = options.readCurrentPageRows();
for (const row of rows) {
options.resultStore.upsertMarketRow(row);
if (row.hasDirectRatesSource) {
const directRates = row.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
options.resultStore.setAuthorSuccess(row.authorId, directRates);
if (hasAllRates) {
continue;
}
}
options.resultStore.setAuthorLoading(row.authorId);
const metricsResult = await options.loadAuthorMetrics(row.authorId);
if (metricsResult.success) {
options.resultStore.setAuthorSuccess(row.authorId, metricsResult.rates);
continue;
}
options.resultStore.setAuthorFailed(row.authorId, metricsResult.reason);
}
}
}
-98
View File
@@ -1,98 +0,0 @@
import { getStarIdFromUrl } from "../../shared/get-star-id";
type ExtractAuthorIdSuccessResult = {
authorId: string;
source: "attribute" | "detail-link";
success: true;
};
type ExtractAuthorIdFailureResult = {
authorId: null;
reason: "missing-author-id";
success: false;
};
export type ExtractAuthorIdResult =
| ExtractAuthorIdSuccessResult
| ExtractAuthorIdFailureResult;
const ATTRIBUTE_NAMES = [
"data-author-id",
"data-authorid",
"data-author_id",
"author_id"
] as const;
export function extractAuthorIdFromRow(row: Element): ExtractAuthorIdResult {
const detailLinkAuthorId = extractAuthorIdFromDetailLink(row);
if (detailLinkAuthorId) {
return {
authorId: detailLinkAuthorId,
source: "detail-link",
success: true
};
}
const attributeAuthorId = extractAuthorIdFromAttributes(row);
if (attributeAuthorId) {
return {
authorId: attributeAuthorId,
source: "attribute",
success: true
};
}
return {
authorId: null,
reason: "missing-author-id",
success: false
};
}
function extractAuthorIdFromDetailLink(row: Element): string | null {
for (const link of row.querySelectorAll("a[href]")) {
const href = link.getAttribute("href");
if (!href) {
continue;
}
const authorId = getStarIdFromUrl(toAbsoluteUrl(href));
if (authorId) {
return authorId;
}
}
return null;
}
function extractAuthorIdFromAttributes(row: Element): string | null {
for (const attributeName of ATTRIBUTE_NAMES) {
const directMatch = readAttributeValue(row, attributeName);
if (directMatch) {
return directMatch;
}
const descendant = row.querySelector(`[${attributeName}]`);
const descendantMatch = descendant
? readAttributeValue(descendant, attributeName)
: null;
if (descendantMatch) {
return descendantMatch;
}
}
return null;
}
function readAttributeValue(element: Element, attributeName: string): string | null {
const value = element.getAttribute(attributeName)?.trim() ?? "";
return /^\d+$/.test(value) ? value : null;
}
function toAbsoluteUrl(href: string): string {
try {
return new URL(href, "https://xingtu.cn").toString();
} catch {
return href;
}
}
+464 -239
View File
@@ -1,138 +1,424 @@
import { buildMarketCsv } from "./csv-exporter";
import {
applyRowOrder,
applyRowVisibility,
renderMarketRowState,
syncMarketTable,
type MarketRowDom
} from "./dom-sync";
import { applyFilterAndSort } from "./filter-sort-controller";
import { createMarketApiClient } from "./api-client"; import { createMarketApiClient } from "./api-client";
import { createMarketBatchLoader } from "./batch-loader"; import { createExportRangeController } from "./export-range-controller";
import { syncMarketTable, type MarketRowDom } from "./dom-sync"; import { ensurePluginToolbar } from "./plugin-toolbar";
import { extractAuthorIdFromRow } from "./id-extractor"; import {
import { createListSignature } from "./list-signature"; readToolbarExportTarget,
import { renderMarketRowState } from "./row-render"; setToolbarBusyState,
import type { MarketRowState } from "./row-state"; setToolbarExportStatus
} from "./plugin-toolbar";
interface LoggerLike { import { createMarketResultStore } from "./result-store";
debug(...args: unknown[]): void; import type {
info(...args: unknown[]): void; MarketApiResult,
warn(...args: unknown[]): void; MarketFilterState,
} MarketExportTarget,
MarketRecord,
MarketRowSnapshot,
MarketSortState
} from "./types";
interface MutationObserverLike { interface MutationObserverLike {
disconnect(): void; disconnect(): void;
observe(target: Node, options?: MutationObserverInit): void; observe(target: Node, options?: MutationObserverInit): void;
} }
interface BatchLoaderLike { export interface CreateMarketControllerOptions {
loadRows(options: { buildCsv?: (records: MarketRecord[]) => string;
listSeq: number;
rows: Array<{
authorId: string | null;
render(
state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void }
): void;
rowKey: string;
}>;
shouldRenderResult?: (params: {
authorId: string;
listSeq: number;
row: {
authorId: string | null;
rowKey: string;
render(
state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void }
): void;
};
}) => boolean;
}): Promise<void>;
}
export interface MarketContentControllerOptions {
batchLoader?: BatchLoaderLike;
document: Document; document: Document;
logger: LoggerLike; loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
mutationObserverFactory?: ( mutationObserverFactory?: (
callback: MutationCallback callback: MutationCallback
) => MutationObserverLike; ) => MutationObserverLike;
onCsvReady?: (csv: string) => void;
resultStore?: ReturnType<typeof createMarketResultStore>;
window: Window; window: Window;
} }
export function createMarketContentController( export function createMarketController(options: CreateMarketControllerOptions) {
options: MarketContentControllerOptions const marketApiClient = createMarketApiClient();
) { const resultStore = options.resultStore ?? createMarketResultStore();
const batchLoader = const loadAuthorMetrics =
options.batchLoader ?? options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
createMarketBatchLoader({ const buildCsv = options.buildCsv ?? buildMarketCsv;
apiClient: createMarketApiClient(), const mutationObserverFactory =
concurrency: 4
});
let listSeq = 0;
let currentSignature: string | null = null;
const observerFactory =
options.mutationObserverFactory ?? options.mutationObserverFactory ??
((callback: MutationCallback) => new MutationObserver(callback)); ((callback: MutationCallback) => new MutationObserver(callback));
const exportRangeController = createExportRangeController({
const observer = observerFactory(() => { document: options.document,
scheduleSync(); onProgress: ({ currentPage, totalPages }) => {
setToolbarExportStatus(
toolbar,
totalPages
? `导出中 ${currentPage}/${totalPages} 页...`
: `导出中 第${currentPage}页...`
);
},
prepareCurrentPageForExport: prepareCurrentPageForExport,
readCurrentPageRecords: () => getVisibleOrderedRecords(),
window: options.window
}); });
let isObserving = false; let activeFilters: MarketFilterState = {};
let activeSort: MarketSortState | undefined;
let isSyncRunning = false; let isSyncRunning = false;
let isSyncScheduled = false; let isSyncScheduled = false;
let needsResync = false; let needsResync = false;
const observer = mutationObserverFactory(() => {
const startObservation = () => { scheduleSync();
if (isObserving) { });
return true; const observationRoot = options.document.body ?? options.document.documentElement;
} if (observationRoot) {
observer.observe(observationRoot, {
if (options.document.readyState === "loading") {
return false;
}
const root = getObservationRoot(options.document);
if (!root) {
return false;
}
observer.observe(root, {
childList: true, childList: true,
subtree: true subtree: true
}); });
isObserving = true; }
return true;
};
const handleReady = () => { const toolbar = ensurePluginToolbar(options.document, {
if (!startObservation()) { onApplyFilter: async () => {
activeFilters = {
personalVideoAfterSearchRateMin: parseNumberValue(
toolbar.personalFilterInput.value
),
singleVideoAfterSearchRateMin: parseNumberValue(
toolbar.singleFilterInput.value
)
};
applyCurrentView();
},
onApplySort: async () => {
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
applyCurrentView();
},
onExport: async () => {
const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return; return;
} }
cleanupReadyListeners(); setToolbarBusyState(toolbar, true);
scheduleSync(); try {
}; const records = await exportRecords(exportTarget.target);
options.onCsvReady?.(buildCsv(records));
if (!startObservation()) { setToolbarExportStatus(toolbar, "");
options.document.addEventListener("readystatechange", handleReady); } catch (error) {
options.window.addEventListener("DOMContentLoaded", handleReady); setToolbarExportStatus(
options.window.addEventListener("load", handleReady); toolbar,
error instanceof Error ? error.message : "导出失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
} }
if (isObserving) {
scheduleSync();
} }
});
const ready = runSyncCycle();
return { return {
dispose() { dispose() {
observer.disconnect(); observer.disconnect();
cleanupReadyListeners();
}, },
syncNow ready
}; };
function cleanupReadyListeners() { async function hydrateCurrentPage(): Promise<void> {
options.document.removeEventListener("readystatechange", handleReady); const table = syncMarketTable(options.document);
options.window.removeEventListener("DOMContentLoaded", handleReady); if (!table) {
options.window.removeEventListener("load", handleReady); return;
} }
function scheduleSync() { for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
continue;
}
resultStore.upsertMarketRow(rowSnapshot);
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
if (existingRecord?.status === "success" && existingRecord.rates) {
renderMarketRowState(rowDom, existingRecord);
continue;
}
if (existingRecord?.status === "failed") {
renderMarketRowState(rowDom, existingRecord);
continue;
}
if (existingRecord?.status === "loading") {
renderMarketRowState(rowDom, {
...rowSnapshot,
status: "loading"
});
continue;
}
if (rowSnapshot.hasDirectRatesSource) {
const directRates = rowSnapshot.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
resultStore.setAuthorSuccess(rowSnapshot.authorId, directRates);
renderMarketRowState(rowDom, {
...rowSnapshot,
rates: directRates,
status: "success"
});
if (hasAllRates) {
continue;
}
}
resultStore.setAuthorLoading(rowSnapshot.authorId);
renderMarketRowState(rowDom, {
...rowSnapshot,
rates: resultStore.getRecord(rowSnapshot.authorId)?.rates,
status: "loading"
});
const metricsResult = await loadAuthorMetrics(rowSnapshot.authorId);
if (metricsResult.success) {
resultStore.setAuthorSuccess(rowSnapshot.authorId, metricsResult.rates);
renderMarketRowState(rowDom, {
...rowSnapshot,
status: "success",
rates: metricsResult.rates
});
continue;
}
resultStore.setAuthorFailed(rowSnapshot.authorId, metricsResult.reason);
renderMarketRowState(rowDom, {
...rowSnapshot,
failureReason: metricsResult.reason,
status: "failed"
});
}
}
function applyCurrentView(): void {
const table = syncMarketTable(options.document);
if (!table) {
return;
}
const records = getVisibleOrderedRecords(table);
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, records.map((record) => record.authorId));
}
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
const currentPageRecords = readCurrentPageRecords(table);
return applyFilterAndSort(currentPageRecords, {
filters: activeFilters,
sort: activeSort
});
}
async function exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
if (target.mode === "count" && target.pageCount <= 1) {
setToolbarExportStatus(toolbar, "导出中...");
await prepareCurrentPageForExport();
return getVisibleOrderedRecords();
}
return exportRangeController.exportRecords(target);
}
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();
}
async function harvestCurrentPageForExport(): Promise<void> {
await collectCurrentPageSnapshotsUntilSettled();
const table = syncMarketTable(options.document);
const scrollContainer = findCurrentPageScrollContainer(table);
if (!scrollContainer) {
return;
}
const originalScrollTop = scrollContainer.scrollTop;
const maxScrollTop = Math.max(
0,
scrollContainer.scrollHeight - scrollContainer.clientHeight
);
if (maxScrollTop <= 0) {
return;
}
const step = Math.max(scrollContainer.clientHeight, 240);
for (
let nextScrollTop = Math.min(originalScrollTop + step, maxScrollTop);
nextScrollTop > originalScrollTop && nextScrollTop <= maxScrollTop;
nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop)
) {
setScrollTop(scrollContainer, nextScrollTop);
await collectCurrentPageSnapshotsUntilSettled();
if (nextScrollTop === maxScrollTop) {
break;
}
}
if (scrollContainer.scrollTop !== originalScrollTop) {
setScrollTop(scrollContainer, originalScrollTop);
await collectCurrentPageSnapshotsUntilSettled();
}
}
function readCurrentPageRecords(table: ReturnType<typeof syncMarketTable>): MarketRecord[] {
if (!table) {
return [];
}
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
return null;
}
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
return {
...existingRecord,
...rowSnapshot,
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
exportFields: mergeFieldMap(
existingRecord?.exportFields,
rowSnapshot.exportFields
),
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
price21To60s: mergeStringValue(
existingRecord?.price21To60s,
rowSnapshot.price21To60s
),
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
status: existingRecord?.status ?? "idle"
} satisfies MarketRecord;
})
.filter((record): record is MarketRecord => record !== null);
}
function collectCurrentPageSnapshots(): void {
readCurrentPageRows(options.document).forEach((rowSnapshot) => {
resultStore.upsertMarketRow(rowSnapshot);
});
}
function findCurrentPageScrollContainer(
table: ReturnType<typeof syncMarketTable>
): HTMLElement | null {
if (!table) {
return null;
}
const seenElements = new Set<HTMLElement>();
const candidateRoots = table.rows
.map((rowDom) => rowDom.row)
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
for (const rootElement of candidateRoots) {
let currentElement = rootElement.parentElement;
while (currentElement) {
if (
!seenElements.has(currentElement) &&
isScrollableContainer(currentElement)
) {
return currentElement;
}
seenElements.add(currentElement);
currentElement = currentElement.parentElement;
}
}
return null;
}
function isScrollableContainer(element: HTMLElement): boolean {
const computedStyle = options.window.getComputedStyle(element);
return (
/auto|scroll|overlay/.test(computedStyle.overflowY) &&
element.scrollHeight > element.clientHeight
);
}
async function waitForDomSettled(): Promise<void> {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 0);
});
await Promise.resolve();
}
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
let previousFingerprint = "";
let stablePassCount = 0;
for (let attempt = 0; attempt < 12; attempt += 1) {
await waitForDomSettled();
if (attempt > 0) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 100);
});
await Promise.resolve();
}
collectCurrentPageSnapshots();
const nextFingerprint = readVisibleRowHydrationFingerprint();
if (!nextFingerprint) {
stablePassCount = 0;
previousFingerprint = "";
continue;
}
if (nextFingerprint === previousFingerprint) {
stablePassCount += 1;
} else {
previousFingerprint = nextFingerprint;
stablePassCount = 1;
}
if (stablePassCount >= 3) {
return;
}
}
}
function readVisibleRowHydrationFingerprint(): string {
const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) {
return "";
}
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0
).length;
return [
rowSnapshot.authorId,
populatedFieldCount,
rowSnapshot.price21To60s?.trim() ? "price" : "no-price"
].join(":");
})
.join("|");
}
function scheduleSync(): void {
if (isSyncRunning) { if (isSyncRunning) {
needsResync = true; needsResync = true;
return; return;
@@ -149,7 +435,7 @@ export function createMarketContentController(
}, 0); }, 0);
} }
async function runSyncCycle() { async function runSyncCycle(): Promise<void> {
if (isSyncRunning) { if (isSyncRunning) {
needsResync = true; needsResync = true;
return; return;
@@ -157,7 +443,8 @@ export function createMarketContentController(
isSyncRunning = true; isSyncRunning = true;
try { try {
await syncNow(); await hydrateCurrentPage();
applyCurrentView();
} finally { } finally {
isSyncRunning = false; isSyncRunning = false;
if (needsResync) { if (needsResync) {
@@ -167,153 +454,91 @@ export function createMarketContentController(
} }
} }
async function syncNow() { }
const table = syncMarketTable(options.document);
function setScrollTop(element: HTMLElement, top: number): void {
element.scrollTop = top;
element.dispatchEvent(new Event("scroll"));
}
function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
const table = syncMarketTable(document);
if (!table) { if (!table) {
return; return [];
} }
const extractedRows = table.rows.map((rowDom, index) => { return table.rows
const authorIdResult = extractAuthorIdFromRow(rowDom.row); .map((rowDom) => readRowSnapshot(rowDom))
return { .filter((row): row is MarketRowSnapshot => Boolean(row.authorId));
authorId: authorIdResult.success ? authorIdResult.authorId : null,
rowDom,
rowKey: `market-row-${listSeq + 1}-${index}`
};
});
const signature = createListSignature({
authorIds: extractedRows.map(
(row, index) => row.authorId ?? `missing-${index}`
),
url: options.window.location.href
});
if (signature === currentSignature) {
return;
}
currentSignature = signature;
listSeq += 1;
const rows = extractedRows.map((row, index) => {
const nextRowKey = `market-row-${listSeq}-${index}`;
stampRowMetadata(row.rowDom, nextRowKey, String(listSeq), row.authorId);
return {
authorId: row.authorId,
render: (
state: MarketRowState,
renderOptions?: { onRetry?: () => Promise<void> | void }
) => {
renderRowByKey(nextRowKey, state, renderOptions);
},
rowKey: nextRowKey
};
});
await batchLoader.loadRows({
listSeq,
rows,
shouldRenderResult: ({ authorId, listSeq: resultListSeq, row }) =>
isFreshRowTarget(row.rowKey, authorId, resultListSeq)
});
}
function renderRowByKey(
rowKey: string,
state: MarketRowState,
renderOptions?: { onRetry?: () => Promise<void> | void }
) {
const rowDom = getRowDomByKey(options.document, rowKey);
if (!rowDom) {
return;
}
if (!isFreshRowTarget(rowKey, state.authorId, state.listSeq)) {
options.logger.debug(
"[star-chart-search-enhancer]",
"market-stale-result-dropped",
{
authorId: state.authorId,
listSeq: state.listSeq,
rowKey
}
);
return;
}
renderMarketRowState(rowDom, state, renderOptions);
}
function isFreshRowTarget(
rowKey: string,
authorId: string | null,
nextListSeq: number
) {
if (nextListSeq !== listSeq) {
return false;
}
const rowDom = getRowDomByKey(options.document, rowKey);
if (!rowDom) {
return false;
}
return (
rowDom.row.dataset.scesListSeq === String(nextListSeq) &&
(rowDom.row.dataset.scesAuthorId ?? "") === (authorId ?? "")
);
}
} }
function stampRowMetadata( function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
rowDom: MarketRowDom,
rowKey: string,
listSeq: string,
authorId: string | null
) {
const authorIdValue = authorId ?? "";
rowDom.row.dataset.scesAuthorId = authorIdValue;
rowDom.row.dataset.scesListSeq = listSeq;
rowDom.row.dataset.scesRowAnchor = "true";
rowDom.row.dataset.scesRowKey = rowKey;
rowDom.singleCell.dataset.scesAuthorId = authorIdValue;
rowDom.singleCell.dataset.scesListSeq = listSeq;
rowDom.singleCell.dataset.scesRowKey = rowKey;
rowDom.personalCell.dataset.scesAuthorId = authorIdValue;
rowDom.personalCell.dataset.scesListSeq = listSeq;
rowDom.personalCell.dataset.scesRowKey = rowKey;
}
function getRowDomByKey(document: Document, rowKey: string): MarketRowDom | null {
const row = document.querySelector(
`[data-sces-row-key="${rowKey}"][data-sces-row-anchor="true"]`
) as HTMLElement | null;
if (!row) {
return null;
}
const singleCell = document.querySelector(
`[data-sces-row-key="${rowKey}"][data-sces-column="single-video-after-search-rate"]`
) as HTMLElement | null;
const personalCell = document.querySelector(
`[data-sces-row-key="${rowKey}"][data-sces-column="personal-video-after-search-rate"]`
) as HTMLElement | null;
if (!singleCell || !personalCell) {
return null;
}
return { return {
personalCell, authorId: rowDom.authorId,
row, authorName: rowDom.authorName,
singleCell exportFields: rowDom.exportFields,
hasDirectRatesSource: rowDom.hasDirectRatesSource,
price21To60s: rowDom.price21To60s,
rates: rowDom.rates
}; };
} }
function getObservationRoot(document: Document): Node | null { function parseNumberValue(value: string): number | undefined {
return document.body ?? document.documentElement; if (!value) {
return undefined;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
}
function readSortState(
fieldSelect: HTMLSelectElement,
directionSelect: HTMLSelectElement
): MarketSortState | undefined {
if (!fieldSelect.value) {
return undefined;
}
return {
direction: directionSelect.value === "asc" ? "asc" : "desc",
field: fieldSelect.value as MarketSortState["field"]
};
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (hasTextValue(incoming) || !hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
} }
-9
View File
@@ -1,9 +0,0 @@
interface ListSignatureOptions {
authorIds: string[];
url: string;
}
export function createListSignature(options: ListSignatureOptions): string {
const parsedUrl = new URL(options.url);
return `${parsedUrl.pathname}${parsedUrl.search}::${options.authorIds.join(",")}`;
}
+130
View File
@@ -0,0 +1,130 @@
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
type MarketRow = {
attribute_datas?: Record<string, unknown>;
nick_name?: string;
star_id?: string;
};
declare global {
interface Window {
[BRIDGE_MARKER]?: boolean;
}
}
installMarketPageBridge();
function installMarketPageBridge() {
if (window[BRIDGE_MARKER]) {
syncSerializedMarketRows();
return;
}
window[BRIDGE_MARKER] = true;
syncSerializedMarketRows();
const observer = new MutationObserver(() => {
syncSerializedMarketRows();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
window.setInterval(() => {
syncSerializedMarketRows();
}, 1000);
}
function syncSerializedMarketRows() {
const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
if (
document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !==
nextSerializedRows
) {
document.documentElement.setAttribute(
SERIALIZED_MARKET_ROWS_ATTRIBUTE,
nextSerializedRows
);
}
}
function readSerializedMarketRows() {
const marketList = readMarketList();
return marketList
.map((row) => {
const attributeDatas = isRecord(row.attribute_datas) ? row.attribute_datas : {};
const singleVideoAfterSearchRate = readNormalizedFractionRate(
attributeDatas.avg_search_after_view_rate_30d
);
return {
authorId:
readString(row.star_id) ?? readString(attributeDatas.id) ?? "",
authorName:
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
singleVideoAfterSearchRate
};
})
.filter((row) => Boolean(row.authorId || row.authorName));
}
function readMarketList(): MarketRow[] {
const marketRoot = document.querySelector(".base-author-list") as
| (HTMLElement & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
})
| null;
const setupState = marketRoot?.__vue__?._setupState;
if (!setupState) {
return [];
}
for (const value of Object.values(setupState)) {
const candidate = unwrapVueRef(value);
if (Array.isArray(candidate) && looksLikeMarketList(candidate)) {
return candidate as MarketRow[];
}
if (!isRecord(candidate) || !Array.isArray(candidate.marketList)) {
continue;
}
if (looksLikeMarketList(candidate.marketList)) {
return candidate.marketList as MarketRow[];
}
}
return [];
}
function looksLikeMarketList(value: unknown[]): boolean {
const firstRow = value[0];
return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
}
function unwrapVueRef(value: unknown): unknown {
if (isRecord(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readNormalizedFractionRate(value: unknown): string | undefined {
return typeof value === "string"
? normalizeFractionRateDisplay(value) ?? undefined
: undefined;
}
+282
View File
@@ -0,0 +1,282 @@
import type { MarketExportScope, MarketExportTarget } from "./types";
export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void;
}
export interface PluginToolbarDom {
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement;
filterApplyButton: HTMLButtonElement;
personalFilterInput: HTMLInputElement;
root: HTMLElement;
singleFilterInput: HTMLInputElement;
sortApplyButton: HTMLButtonElement;
sortDirectionSelect: HTMLSelectElement;
sortFieldSelect: HTMLSelectElement;
}
export function ensurePluginToolbar(
document: Document,
handlers: PluginToolbarHandlers
): PluginToolbarDom {
const existingRoot = document.querySelector(
"[data-plugin-toolbar='root']"
) as HTMLElement | null;
if (existingRoot) {
return readToolbarDom(existingRoot);
}
const root = document.createElement("section");
root.dataset.pluginToolbar = "root";
const singleFilterInput = document.createElement("input");
singleFilterInput.type = "number";
singleFilterInput.step = "0.01";
singleFilterInput.dataset.pluginFilterSingle = "input";
const personalFilterInput = document.createElement("input");
personalFilterInput.type = "number";
personalFilterInput.step = "0.01";
personalFilterInput.dataset.pluginFilterPersonal = "input";
const filterApplyButton = document.createElement("button");
filterApplyButton.type = "button";
filterApplyButton.dataset.pluginFilterApply = "button";
filterApplyButton.textContent = "应用筛选";
const sortFieldSelect = document.createElement("select");
sortFieldSelect.dataset.pluginSortField = "select";
appendOption(sortFieldSelect, "", "不排序");
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
const sortDirectionSelect = document.createElement("select");
sortDirectionSelect.dataset.pluginSortDirection = "select";
appendOption(sortDirectionSelect, "desc", "降序");
appendOption(sortDirectionSelect, "asc", "升序");
const sortApplyButton = document.createElement("button");
sortApplyButton.type = "button";
sortApplyButton.dataset.pluginSortApply = "button";
sortApplyButton.textContent = "应用排序";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select";
appendOption(exportRangeSelect, "current", "当前页");
appendOption(exportRangeSelect, "first-5", "前5页");
appendOption(exportRangeSelect, "first-10", "前10页");
appendOption(exportRangeSelect, "all", "全部");
appendOption(exportRangeSelect, "custom", "自定义");
exportRangeSelect.value = "first-5";
const exportCustomPagesInput = document.createElement("input");
exportCustomPagesInput.type = "number";
exportCustomPagesInput.min = "1";
exportCustomPagesInput.step = "1";
exportCustomPagesInput.hidden = true;
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
const exportStatusText = document.createElement("span");
exportStatusText.dataset.pluginExportStatus = "text";
root.append(
singleFilterInput,
personalFilterInput,
filterApplyButton,
sortFieldSelect,
sortDirectionSelect,
sortApplyButton,
exportRangeSelect,
exportCustomPagesInput,
exportButton
);
root.append(exportStatusText);
document.body.prepend(root);
filterApplyButton.addEventListener("click", () => {
void handlers.onApplyFilter();
});
sortApplyButton.addEventListener("click", () => {
void handlers.onApplySort();
});
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
});
});
const toolbarDom = {
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom;
}
function appendOption(
select: HTMLSelectElement,
value: string,
label: string
): void {
const option = select.ownerDocument.createElement("option");
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
const toolbarDom = {
exportButton: root.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement,
exportCustomPagesInput: root.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement,
exportRangeSelect: root.querySelector(
'[data-plugin-export-range="select"]'
) as HTMLSelectElement,
exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement,
filterApplyButton: root.querySelector(
'[data-plugin-filter-apply="button"]'
) as HTMLButtonElement,
personalFilterInput: root.querySelector(
'[data-plugin-filter-personal="input"]'
) as HTMLInputElement,
root,
singleFilterInput: root.querySelector(
'[data-plugin-filter-single="input"]'
) as HTMLInputElement,
sortApplyButton: root.querySelector(
'[data-plugin-sort-apply="button"]'
) as HTMLButtonElement,
sortDirectionSelect: root.querySelector(
'[data-plugin-sort-direction="select"]'
) as HTMLSelectElement,
sortFieldSelect: root.querySelector(
'[data-plugin-sort-field="select"]'
) as HTMLSelectElement
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom;
}
export function readToolbarExportTarget(
toolbar: PluginToolbarDom
): { error?: string; target?: MarketExportTarget } {
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
if (scope === "all") {
return {
target: {
mode: "all"
}
};
}
if (scope === "current") {
return {
target: {
mode: "count",
pageCount: 1
}
};
}
if (scope === "first-5") {
return {
target: {
mode: "count",
pageCount: 5
}
};
}
if (scope === "first-10") {
return {
target: {
mode: "count",
pageCount: 10
}
};
}
const pageCount = Number(toolbar.exportCustomPagesInput.value);
if (!Number.isInteger(pageCount) || pageCount < 1) {
return {
error: "请输入有效页数"
};
}
return {
target: {
mode: "count",
pageCount
}
};
}
export function setToolbarBusyState(
toolbar: PluginToolbarDom,
isBusy: boolean
): void {
[
toolbar.exportButton,
toolbar.filterApplyButton,
toolbar.sortApplyButton,
toolbar.singleFilterInput,
toolbar.personalFilterInput,
toolbar.sortFieldSelect,
toolbar.sortDirectionSelect,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput
].forEach((element) => {
element.disabled = isBusy;
});
}
export function setToolbarExportStatus(
toolbar: PluginToolbarDom,
text: string
): void {
toolbar.exportStatusText.textContent = text;
}
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden =
toolbar.exportRangeSelect.value !== "custom";
}
+121
View File
@@ -0,0 +1,121 @@
import type {
MarketApiFailureReason,
MarketRecord,
MarketRowSnapshot
} from "./types";
import type { AfterSearchRates } from "./types";
export function createMarketResultStore() {
const records = new Map<string, MarketRecord>();
return {
getRecord(authorId: string) {
return records.get(authorId) ?? null;
},
listRecords() {
return Array.from(records.values());
},
setAuthorFailed(authorId: string, reason: MarketApiFailureReason) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "failed";
existingRecord.failureReason = reason;
},
setAuthorLoading(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "loading";
delete existingRecord.failureReason;
},
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "success";
existingRecord.rates = {
...existingRecord.rates,
...rates
};
delete existingRecord.failureReason;
},
upsertMarketRow(row: MarketRowSnapshot) {
const existingRecord = records.get(row.authorId);
if (existingRecord) {
existingRecord.authorName =
mergeStringValue(existingRecord.authorName, row.authorName) ??
existingRecord.authorName;
existingRecord.location = mergeStringValue(
existingRecord.location,
row.location
);
existingRecord.price21To60s = mergeStringValue(
existingRecord.price21To60s,
row.price21To60s
);
existingRecord.exportFields = mergeFieldMap(
existingRecord.exportFields,
row.exportFields
);
existingRecord.hasDirectRatesSource =
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
return existingRecord;
}
const nextRecord: MarketRecord = {
...row,
status: "idle"
};
records.set(row.authorId, nextRecord);
return nextRecord;
}
};
function ensureRecord(authorId: string): MarketRecord {
const existingRecord = records.get(authorId);
if (existingRecord) {
return existingRecord;
}
const nextRecord: MarketRecord = {
authorId,
authorName: authorId,
status: "idle"
};
records.set(authorId, nextRecord);
return nextRecord;
}
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (!hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (!hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
-52
View File
@@ -1,52 +0,0 @@
import type { MarketRowDom } from "./dom-sync";
import type { MarketRowState } from "./row-state";
interface RenderMarketRowStateOptions {
onRetry?: () => void;
}
export function renderMarketRowState(
rowDom: MarketRowDom,
state: MarketRowState,
options: RenderMarketRowStateOptions = {}
) {
applySharedMetadata(rowDom, state);
if (state.state === "loading") {
setCellState(rowDom, "加载中...");
return;
}
if (state.state === "success") {
setRetryHandler(rowDom, undefined);
rowDom.singleCell.textContent = state.singleVideoAfterSearchRate;
rowDom.personalCell.textContent = state.personalVideoAfterSearchRate;
return;
}
setCellState(rowDom, "加载失败");
setRetryHandler(rowDom, state.retryable ? options.onRetry : undefined);
}
function applySharedMetadata(rowDom: MarketRowDom, state: MarketRowState) {
const authorId = state.authorId ?? "";
const listSeq = String(state.listSeq);
rowDom.row.dataset.scesAuthorId = authorId;
rowDom.row.dataset.scesListSeq = listSeq;
rowDom.singleCell.dataset.scesAuthorId = authorId;
rowDom.singleCell.dataset.scesListSeq = listSeq;
rowDom.personalCell.dataset.scesAuthorId = authorId;
rowDom.personalCell.dataset.scesListSeq = listSeq;
}
function setCellState(rowDom: MarketRowDom, text: string) {
setRetryHandler(rowDom, undefined);
rowDom.singleCell.textContent = text;
rowDom.personalCell.textContent = text;
}
function setRetryHandler(rowDom: MarketRowDom, onRetry?: () => void) {
rowDom.singleCell.onclick = onRetry ?? null;
rowDom.personalCell.onclick = onRetry ?? null;
}
-27
View File
@@ -1,27 +0,0 @@
export type RowErrorReason =
| "bad-response"
| "missing-author-id"
| "request-failed"
| "timeout";
export type MarketRowState =
| {
authorId: string;
listSeq: number;
state: "loading";
}
| {
authorId: string;
listSeq: number;
personalVideoAfterSearchRate: string;
singleVideoAfterSearchRate: string;
source: "cache" | "network";
state: "success";
}
| {
authorId: string | null;
listSeq: number;
reason: RowErrorReason;
retryable: boolean;
state: "error";
};
+59 -2
View File
@@ -1,3 +1,60 @@
import type { AfterSearchRates } from "../../shared/result-types"; export interface AfterSearchRates {
personalVideoAfterSearchRate?: string;
singleVideoAfterSearchRate?: string;
}
export type RequiredAfterSearchRates = Required<AfterSearchRates>; export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
export interface MarketRowSnapshot {
authorId: string;
authorName: string;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
location?: string;
price21To60s?: string;
rates?: AfterSearchRates;
}
export interface MarketRecord extends MarketRowSnapshot {
status: MarketRecordStatus;
failureReason?: MarketApiFailureReason;
}
export interface MarketFilterState {
personalVideoAfterSearchRateMin?: number;
singleVideoAfterSearchRateMin?: number;
}
export type MarketExportScope = "current" | "first-5" | "first-10" | "all" | "custom";
export type MarketExportTarget =
| {
mode: "all";
}
| {
mode: "count";
pageCount: number;
};
export interface MarketSortState {
direction: "asc" | "desc";
field: keyof Required<AfterSearchRates>;
}
export type MarketApiFailureReason =
| "bad-response"
| "missing-rate"
| "request-failed"
| "timeout";
export type MarketApiSuccessResult = {
success: true;
rates: Required<AfterSearchRates>;
};
export type MarketApiFailureResult = {
success: false;
reason: MarketApiFailureReason;
};
export type MarketApiResult = MarketApiSuccessResult | MarketApiFailureResult;
-40
View File
@@ -1,40 +0,0 @@
import { getStarIdFromUrl } from "../shared/get-star-id";
import { createRouteKey } from "../shared/route-key";
export interface RouteSnapshot {
navigationSeq: number;
pageStarId: string | null;
pathname: string;
routeKey: string;
url: string;
}
export function createRouteState(initialUrl: string) {
let snapshot = createSnapshot(initialUrl, 1);
return {
advance(nextUrl: string): RouteSnapshot {
snapshot = createSnapshot(nextUrl, snapshot.navigationSeq + 1);
return snapshot;
},
getSnapshot(): RouteSnapshot {
return snapshot;
}
};
}
function createSnapshot(url: string, navigationSeq: number): RouteSnapshot {
const parsedUrl = new URL(url);
const pageStarId = getStarIdFromUrl(parsedUrl.href);
return {
navigationSeq,
pageStarId,
pathname: parsedUrl.pathname,
routeKey: createRouteKey({
navigationSeq,
pageStarId,
pathname: parsedUrl.pathname
}),
url: parsedUrl.href
};
}
+14 -7
View File
@@ -1,22 +1,29 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "Star Chart Search Enhancer", "name": "Star Chart Search Enhancer",
"version": "0.0.1", "version": "0.2.0421.2",
"description": "Experimentally capture after-search rates on Xingtu creator detail pages.", "description": "Bootstraps the Xingtu creator market content script.",
"permissions": ["downloads"],
"background": {
"service_worker": "background/index.js"
},
"content_scripts": [ "content_scripts": [
{ {
"matches": [ "matches": [
"https://*.xingtu.cn/ad/creator/author-homepage/*", "https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*" "https://*.xingtu.cn/ad/creator/market*"
], ],
"js": ["content/index.global.js"], "js": ["content/index.js"],
"run_at": "document_start" "run_at": "document_idle"
} }
], ],
"web_accessible_resources": [ "web_accessible_resources": [
{ {
"resources": ["page/hook.global.js"], "resources": ["content/market-page-bridge.js"],
"matches": ["https://*.xingtu.cn/*"] "matches": [
"https://xingtu.cn/*",
"https://*.xingtu.cn/*"
]
} }
] ]
} }
-459
View File
@@ -1,459 +0,0 @@
import { getStarIdFromUrl } from "../shared/get-star-id";
import {
CANDIDATE_ANALYSIS_MESSAGE_TYPE,
CANDIDATE_REQUEST_MESSAGE_TYPE,
EXTENSION_MESSAGE_SOURCE,
HOOK_READY_MESSAGE_TYPE,
RESULT_MESSAGE_TYPE
} from "../shared/message-types";
import { createRouteKey } from "../shared/route-key";
import type {
AfterSearchRateResult,
ExtractAfterSearchRatesResult
} from "../shared/result-types";
import { extractAfterSearchRates as defaultExtractAfterSearchRates } from "../shared/extract-after-search-rates";
import {
looksLikeCandidateRequest,
shouldInspectResponse
} from "./network-interceptor";
const PAGE_HOOK_MARKER = "__starChartSearchEnhancerPageHookInstalled";
const XHR_REQUEST_URL = "__starChartSearchEnhancerRequestUrl";
const XHR_REQUEST_METHOD = "__starChartSearchEnhancerRequestMethod";
interface HookWindow extends Window {
[PAGE_HOOK_MARKER]?: boolean;
[key: string]: unknown;
}
interface InstallPageHookOptions {
extractAfterSearchRates?: (
payload: unknown
) => ExtractAfterSearchRatesResult;
now?: () => number;
postMessage?: (message: unknown, targetOrigin: string) => void;
timeoutMs?: number;
window?: HookWindow;
}
interface RouteContext {
navigationSeq: number;
pageStarId: string | null;
routeKey: string;
}
export function installPageHook(options: InstallPageHookOptions = {}) {
const hookWindow =
options.window ??
((globalThis as typeof globalThis & { window?: HookWindow }).window as
| HookWindow
| undefined);
if (!hookWindow) {
return { alreadyInstalled: false };
}
if (hookWindow[PAGE_HOOK_MARKER]) {
return { alreadyInstalled: true };
}
hookWindow[PAGE_HOOK_MARKER] = true;
const extractAfterSearchRates =
options.extractAfterSearchRates ?? defaultExtractAfterSearchRates;
const now = options.now ?? Date.now;
const postMessage = options.postMessage ?? hookWindow.postMessage.bind(hookWindow);
const timeoutMs = options.timeoutMs ?? 10_000;
let routeContext = createRouteContext(hookWindow.location.href, 1);
let candidateRequestCount = 0;
let lastCandidateRequestUrl: string | undefined;
let timeoutHandle = hookWindow.setTimeout(emitTimeoutResult, timeoutMs);
const originalFetch = hookWindow.fetch.bind(hookWindow);
const originalPushState = hookWindow.history.pushState.bind(hookWindow.history);
const originalReplaceState = hookWindow.history.replaceState.bind(
hookWindow.history
);
const originalXhrOpen = hookWindow.XMLHttpRequest.prototype.open;
const originalXhrSend = hookWindow.XMLHttpRequest.prototype.send;
postMessage(
{
payload: {
pageStarId: routeContext.pageStarId,
routeKey: routeContext.routeKey
},
source: EXTENSION_MESSAGE_SOURCE,
type: HOOK_READY_MESSAGE_TYPE
},
"*"
);
hookWindow.fetch = (async (...args: Parameters<typeof fetch>) => {
const response = await originalFetch(...args);
const requestUrl = resolveRequestUrl(args[0], hookWindow.location.href);
const requestMethod = resolveRequestMethod(args[0], args[1]);
void inspectFetchResponse({
extractAfterSearchRates,
now,
postMessage,
requestMethod,
requestUrl,
response,
routeContext,
window: hookWindow
});
return response;
}) as typeof fetch;
hookWindow.history.pushState = wrapHistoryMethod(originalPushState);
hookWindow.history.replaceState = wrapHistoryMethod(originalReplaceState);
hookWindow.addEventListener("popstate", handleNavigation);
hookWindow.XMLHttpRequest.prototype.open = function open(
this: XMLHttpRequest & Record<string, unknown>,
...args: Parameters<XMLHttpRequest["open"]>
) {
this[XHR_REQUEST_METHOD] = args[0];
this[XHR_REQUEST_URL] = resolveAbsoluteUrl(String(args[1]), hookWindow.location.href);
return originalXhrOpen.apply(this, args);
};
hookWindow.XMLHttpRequest.prototype.send = function send(
this: XMLHttpRequest & Record<string, unknown>,
...args: Parameters<XMLHttpRequest["send"]>
) {
this.addEventListener("loadend", () => {
const requestUrl = typeof this[XHR_REQUEST_URL] === "string" ? this[XHR_REQUEST_URL] : "";
if (!requestUrl || !looksLikeCandidateRequest(requestUrl)) {
return;
}
const responseText =
typeof this.responseText === "string" ? this.responseText : "";
if (!responseText) {
return;
}
if (
!shouldInspectResponse({
contentType: this.getResponseHeader?.("content-type") ?? null,
text: responseText,
url: requestUrl
})
) {
return;
}
void inspectPayloadText({
extractAfterSearchRates,
now,
postMessage,
requestMethod:
typeof this[XHR_REQUEST_METHOD] === "string" ? this[XHR_REQUEST_METHOD] : "GET",
requestUrl,
status: this.status,
text: responseText
});
});
return originalXhrSend.apply(this, args);
};
return { alreadyInstalled: false };
function wrapHistoryMethod<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
const result = originalMethod(...args);
handleNavigation();
return result;
}) as T;
}
function handleNavigation() {
routeContext = createRouteContext(
hookWindow.location.href,
routeContext.navigationSeq + 1
);
candidateRequestCount = 0;
lastCandidateRequestUrl = undefined;
hookWindow.clearTimeout(timeoutHandle);
timeoutHandle = hookWindow.setTimeout(emitTimeoutResult, timeoutMs);
}
function emitTimeoutResult() {
const payload: AfterSearchRateResult = {
capturedAt: now(),
pageStarId: routeContext.pageStarId,
pageUrl: hookWindow.location.href,
rawPathHints: [],
reason: "Timed out waiting for after-search-rate capture",
routeKey: routeContext.routeKey,
stage: "timeout",
success: false,
matchedRequestUrl: lastCandidateRequestUrl
};
postMessageResult(postMessage, payload);
}
async function inspectFetchResponse(input: {
extractAfterSearchRates: (
payload: unknown
) => ExtractAfterSearchRatesResult;
now: () => number;
postMessage: (message: unknown, targetOrigin: string) => void;
requestMethod: string;
requestUrl: string;
response: Response;
routeContext: RouteContext;
window: HookWindow;
}) {
try {
const clone = input.response.clone();
const text = await clone.text();
if (
!shouldInspectResponse({
contentType: clone.headers.get("content-type"),
text,
url: input.requestUrl
})
) {
return;
}
await inspectPayloadText({
extractAfterSearchRates: input.extractAfterSearchRates,
now: input.now,
postMessage: input.postMessage,
requestMethod: input.requestMethod,
requestUrl: input.requestUrl,
status: input.response.status,
text
});
} catch {
return;
}
}
async function inspectPayloadText(input: {
extractAfterSearchRates: (
payload: unknown
) => ExtractAfterSearchRatesResult;
now: () => number;
postMessage: (message: unknown, targetOrigin: string) => void;
requestMethod: string;
requestUrl: string;
status: number;
text: string;
}) {
try {
const parsedPayload = JSON.parse(input.text);
const extractionResult = input.extractAfterSearchRates(parsedPayload);
if (!extractionResult.matched && !looksLikeCandidateRequest(input.requestUrl)) {
return;
}
candidateRequestCount += 1;
lastCandidateRequestUrl = input.requestUrl;
postMessage(
{
payload: {
extractorLevel: extractionResult.extractorLevel,
matched: extractionResult.matched,
reason: extractionResult.reason,
requestUrl: input.requestUrl,
routeKey: routeContext.routeKey,
signalEntries: collectSignalEntries(parsedPayload),
success: extractionResult.success,
topLevelKeys: getTopLevelKeys(parsedPayload)
},
source: EXTENSION_MESSAGE_SOURCE,
type: CANDIDATE_ANALYSIS_MESSAGE_TYPE
},
"*"
);
postMessage(
{
payload: {
requestMethod: input.requestMethod,
requestUrl: input.requestUrl,
routeKey: routeContext.routeKey,
status: input.status
},
source: EXTENSION_MESSAGE_SOURCE,
type: CANDIDATE_REQUEST_MESSAGE_TYPE
},
"*"
);
if (!extractionResult.success) {
return;
}
hookWindow.clearTimeout(timeoutHandle);
postMessageResult(input.postMessage, {
capturedAt: input.now(),
pageStarId: routeContext.pageStarId,
pageUrl: hookWindow.location.href,
rawPathHints: extractionResult.rawPathHints,
routeKey: routeContext.routeKey,
stage: "captured",
success: true,
matchedRequestUrl: input.requestUrl,
rates: extractionResult.rates
});
} catch {
return;
}
}
}
function createRouteContext(url: string, navigationSeq: number): RouteContext {
const parsedUrl = new URL(url);
const pageStarId = getStarIdFromUrl(parsedUrl.href);
return {
navigationSeq,
pageStarId,
routeKey: createRouteKey({
navigationSeq,
pageStarId,
pathname: parsedUrl.pathname
})
};
}
function postMessageResult(
postMessage: (message: unknown, targetOrigin: string) => void,
payload: AfterSearchRateResult
) {
postMessage(
{
payload,
source: EXTENSION_MESSAGE_SOURCE,
type: RESULT_MESSAGE_TYPE
},
"*"
);
}
function resolveRequestMethod(
input: RequestInfo | URL,
init?: RequestInit
): string {
if (input instanceof Request) {
return input.method;
}
return init?.method ?? "GET";
}
function resolveRequestUrl(input: RequestInfo | URL, baseUrl: string): string {
if (input instanceof Request) {
return input.url;
}
if (input instanceof URL) {
return input.href;
}
return resolveAbsoluteUrl(String(input), baseUrl);
}
function resolveAbsoluteUrl(url: string, baseUrl: string): string {
return new URL(url, baseUrl).href;
}
function getTopLevelKeys(payload: unknown): string[] {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
return [];
}
return Object.keys(payload).slice(0, 8);
}
function collectSignalEntries(payload: unknown): string[] {
const entries: string[] = [];
const signalPattern = /(search|view|rate|ase|seed|看后搜)/i;
visitPayload(payload, "$", (value, path, key) => {
if (!key || !signalPattern.test(key) || entries.length >= 12) {
return;
}
const summary = summarizeValue(value);
entries.push(`${path}=${summary}`);
});
return entries;
}
function summarizeValue(value: unknown): string {
if (typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
return `[array:${value.length}]`;
}
if (value && typeof value === "object") {
return `[object:${Object.keys(value).slice(0, 5).join(",")}]`;
}
return String(value);
}
function visitPayload(
value: unknown,
path: string,
visitor: (value: unknown, path: string, key?: string) => void,
key?: string
): void {
visitor(value, path, key);
if (Array.isArray(value)) {
value.forEach((entry, index) => {
visitPayload(entry, `${path}[${index}]`, visitor);
});
return;
}
if (!value || typeof value !== "object") {
return;
}
Object.entries(value).forEach(([entryKey, entryValue]) => {
visitPayload(entryValue, `${path}.${entryKey}`, visitor, entryKey);
});
}
function bootstrapPageHook() {
const hookWindow = (
globalThis as typeof globalThis & { window?: HookWindow }
).window;
if (!hookWindow) {
return;
}
if (!hookWindow.location.hostname.endsWith("xingtu.cn")) {
return;
}
installPageHook({ window: hookWindow });
}
bootstrapPageHook();
-31
View File
@@ -1,31 +0,0 @@
const CANDIDATE_URL_KEYWORDS = [
"author-homepage",
"creator",
"seed",
"search",
"value",
"metric"
];
export function looksLikeCandidateRequest(url: string): boolean {
const normalizedUrl = url.toLowerCase();
return CANDIDATE_URL_KEYWORDS.some((keyword) => normalizedUrl.includes(keyword));
}
export function shouldInspectResponse(input: {
contentType: string | null;
text: string;
url: string;
}): boolean {
const normalizedContentType = input.contentType?.toLowerCase() ?? "";
if (normalizedContentType.includes("json")) {
return true;
}
if (!looksLikeCandidateRequest(input.url)) {
return false;
}
const trimmedText = input.text.trim();
return trimmedText.startsWith("{") || trimmedText.startsWith("[");
}
+7
View File
@@ -0,0 +1,7 @@
export function escapeCsvCell(value: string): string {
if (/[",\n]/.test(value)) {
return `"${value.replace(/"/g, "\"\"")}"`;
}
return value;
}
-273
View File
@@ -1,273 +0,0 @@
import { normalizeRateLabel } from "./normalize-rate-label";
import { normalizeRateValue } from "./normalize-rate-value";
import type {
AfterSearchRateField,
AfterSearchRates,
ExtractAfterSearchRatesResult
} from "./result-types";
type JsonRecord = Record<string, unknown>;
const SINGLE_TEXT_PATTERN =
/(?:单条视频看后搜率|单视频看后搜率)\s*([0-9.]+%\s*-\s*[0-9.]+%)/;
const PERSONAL_TEXT_PATTERN = /个人视频看后搜率\s*([0-9.]+%\s*-\s*[0-9.]+%)/;
const SINGLE_KEY_SIGNALS = [
"avgsearchafterviewrate",
"singlevideoaftersearchrate",
"singlevideoaftersearch",
"单条视频看后搜率",
"单视频看后搜率"
];
const PERSONAL_KEY_SIGNALS = [
"personalavgsearchafterviewrate",
"personalvideoaftersearchrate",
"personalvideoaftersearch",
"个人视频看后搜率"
];
export function extractAfterSearchRates(
payload: unknown
): ExtractAfterSearchRatesResult {
const exactKeyResult = scanForExactKeyMatches(payload);
if (exactKeyResult.matched) {
return finalizeResult("exact-key", exactKeyResult.rates, exactKeyResult.paths);
}
const labelValueResult = scanForLabelValueMatches(payload);
if (labelValueResult.matched) {
return finalizeResult(
"label-value",
labelValueResult.rates,
labelValueResult.paths,
labelValueResult.labels
);
}
const textFallbackResult = scanForTextFallback(payload);
if (textFallbackResult.matched) {
return finalizeResult(
"text-fallback",
textFallbackResult.rates,
textFallbackResult.paths
);
}
return {
extractorLevel: "none",
matched: false,
rawPathHints: [],
reason: "No after-search-rate signals found",
success: false
};
}
function scanForExactKeyMatches(payload: unknown): {
matched: boolean;
paths: string[];
rates: AfterSearchRates;
} {
const rates: AfterSearchRates = {};
const paths: string[] = [];
visit(payload, "$", (value, path, key) => {
if (typeof value !== "string" || !key) {
return;
}
const keyField = resolveFieldFromKey(key);
const normalizedValue = normalizeRateValue(value);
if (!keyField || !normalizedValue || rates[keyField]) {
return;
}
rates[keyField] = normalizedValue;
paths.push(path);
});
return {
matched: Object.keys(rates).length > 0,
paths,
rates
};
}
function scanForLabelValueMatches(payload: unknown): {
labels: string[];
matched: boolean;
paths: string[];
rates: AfterSearchRates;
} {
const rates: AfterSearchRates = {};
const paths: string[] = [];
const labels: string[] = [];
visit(payload, "$", (value, path) => {
if (!isRecord(value)) {
return;
}
const labelEntry = findStringField(value, ["label", "name", "title"]);
const valueEntry = findStringField(value, ["value", "text", "data"]);
const normalizedValue = valueEntry
? normalizeRateValue(valueEntry.value)
: null;
if (!labelEntry || !valueEntry || !normalizedValue) {
return;
}
const normalizedLabel = normalizeRateLabel(labelEntry.value);
if (!normalizedLabel || rates[normalizedLabel]) {
return;
}
rates[normalizedLabel] = normalizedValue;
labels.push(labelEntry.value);
paths.push(`${path}.${labelEntry.key}`);
});
return {
labels,
matched: Object.keys(rates).length > 0,
paths,
rates
};
}
function scanForTextFallback(payload: unknown): {
matched: boolean;
paths: string[];
rates: AfterSearchRates;
} {
const matches: Array<{ path: string; rates: AfterSearchRates }> = [];
visit(payload, "$", (value, path) => {
if (!isRecord(value)) {
return;
}
const localText = Object.values(value)
.filter((entry): entry is string => typeof entry === "string")
.join(" ");
if (!localText.includes("看后搜率")) {
return;
}
const rates = parseRatesFromText(localText);
if (Object.keys(rates).length === 0) {
return;
}
matches.push({ path, rates });
});
if (matches.length === 0) {
return { matched: false, paths: [], rates: {} };
}
const bestMatch = matches.find(
(match) =>
Boolean(match.rates.singleVideoAfterSearchRate) &&
Boolean(match.rates.personalVideoAfterSearchRate)
) ?? matches[0];
return {
matched: true,
paths: [bestMatch.path],
rates: bestMatch.rates
};
}
function finalizeResult(
extractorLevel: ExtractAfterSearchRatesResult["extractorLevel"],
rates: AfterSearchRates,
rawPathHints: string[],
matchedLabels?: string[]
): ExtractAfterSearchRatesResult {
const success =
Boolean(rates.singleVideoAfterSearchRate) &&
Boolean(rates.personalVideoAfterSearchRate);
return {
extractorLevel,
matched: Object.keys(rates).length > 0,
matchedLabels,
rawPathHints,
rates: Object.keys(rates).length > 0 ? rates : undefined,
reason: success ? undefined : "Only one after-search-rate value was found",
success
};
}
function resolveFieldFromKey(key: string): AfterSearchRateField | null {
const normalizedKey = key.replace(/[^a-z0-9\u4e00-\u9fa5]/gi, "").toLowerCase();
if (PERSONAL_KEY_SIGNALS.some((signal) => normalizedKey.includes(signal))) {
return "personalVideoAfterSearchRate";
}
if (SINGLE_KEY_SIGNALS.some((signal) => normalizedKey.includes(signal))) {
return "singleVideoAfterSearchRate";
}
return null;
}
function parseRatesFromText(text: string): AfterSearchRates {
const rates: AfterSearchRates = {};
const singleMatch = text.match(SINGLE_TEXT_PATTERN);
const personalMatch = text.match(PERSONAL_TEXT_PATTERN);
if (singleMatch?.[1]) {
rates.singleVideoAfterSearchRate = singleMatch[1];
}
if (personalMatch?.[1]) {
rates.personalVideoAfterSearchRate = personalMatch[1];
}
return rates;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function findStringField(
record: JsonRecord,
candidates: string[]
): { key: string; value: string } | null {
for (const key of candidates) {
const candidate = record[key];
if (typeof candidate === "string") {
return { key, value: candidate };
}
}
return null;
}
function visit(
value: unknown,
path: string,
visitor: (value: unknown, path: string, key?: string) => void,
key?: string
): void {
visitor(value, path, key);
if (Array.isArray(value)) {
value.forEach((entry, index) => {
visit(entry, `${path}[${index}]`, visitor);
});
return;
}
if (!isRecord(value)) {
return;
}
Object.entries(value).forEach(([entryKey, entryValue]) => {
visit(entryValue, `${path}.${entryKey}`, visitor, entryKey);
});
}
-7
View File
@@ -1,7 +0,0 @@
const STAR_ID_PATTERN =
/\/ad\/creator\/author-homepage(?:\/[^/?#]+)?\/(?<starId>\d+)(?:[/?#]|$)/;
export function getStarIdFromUrl(url: string): string | null {
const match = url.match(STAR_ID_PATTERN);
return match?.groups?.starId ?? null;
}
-139
View File
@@ -1,139 +0,0 @@
import type { AfterSearchRateResult } from "./result-types";
export const EXTENSION_MESSAGE_SOURCE = "star-chart-search-enhancer";
export const CANDIDATE_ANALYSIS_MESSAGE_TYPE =
"AFTER_SEARCH_RATE_CANDIDATE_ANALYSIS";
export const CANDIDATE_REQUEST_MESSAGE_TYPE =
"AFTER_SEARCH_RATE_CANDIDATE_REQUEST";
export const HOOK_READY_MESSAGE_TYPE = "AFTER_SEARCH_RATE_HOOK_READY";
export const RESULT_MESSAGE_TYPE = "AFTER_SEARCH_RATE_RESULT";
export interface CandidateAnalysisMessage {
payload: {
extractorLevel: string;
matched: boolean;
reason?: string;
requestUrl: string;
routeKey: string;
signalEntries: string[];
success: boolean;
topLevelKeys: string[];
};
source: typeof EXTENSION_MESSAGE_SOURCE;
type: typeof CANDIDATE_ANALYSIS_MESSAGE_TYPE;
}
export interface CandidateRequestMessage {
payload: {
requestMethod: string;
requestUrl: string;
routeKey: string;
status: number;
};
source: typeof EXTENSION_MESSAGE_SOURCE;
type: typeof CANDIDATE_REQUEST_MESSAGE_TYPE;
}
export interface HookReadyMessage {
payload: {
pageStarId: string | null;
routeKey: string;
};
source: typeof EXTENSION_MESSAGE_SOURCE;
type: typeof HOOK_READY_MESSAGE_TYPE;
}
export interface AfterSearchRateResultMessage {
payload: AfterSearchRateResult;
source: typeof EXTENSION_MESSAGE_SOURCE;
type: typeof RESULT_MESSAGE_TYPE;
}
export function isCandidateAnalysisMessage(
value: unknown
): value is CandidateAnalysisMessage {
if (!isRecord(value) || value.source !== EXTENSION_MESSAGE_SOURCE) {
return false;
}
if (value.type !== CANDIDATE_ANALYSIS_MESSAGE_TYPE || !isRecord(value.payload)) {
return false;
}
return (
typeof value.payload.extractorLevel === "string" &&
typeof value.payload.matched === "boolean" &&
typeof value.payload.requestUrl === "string" &&
typeof value.payload.routeKey === "string" &&
Array.isArray(value.payload.signalEntries) &&
typeof value.payload.success === "boolean" &&
Array.isArray(value.payload.topLevelKeys)
);
}
export function isCandidateRequestMessage(
value: unknown
): value is CandidateRequestMessage {
if (!isRecord(value) || value.source !== EXTENSION_MESSAGE_SOURCE) {
return false;
}
if (value.type !== CANDIDATE_REQUEST_MESSAGE_TYPE || !isRecord(value.payload)) {
return false;
}
return (
typeof value.payload.requestMethod === "string" &&
typeof value.payload.requestUrl === "string" &&
typeof value.payload.routeKey === "string" &&
typeof value.payload.status === "number"
);
}
export function isHookReadyMessage(value: unknown): value is HookReadyMessage {
if (!isRecord(value) || value.source !== EXTENSION_MESSAGE_SOURCE) {
return false;
}
if (value.type !== HOOK_READY_MESSAGE_TYPE || !isRecord(value.payload)) {
return false;
}
return (
typeof value.payload.routeKey === "string" &&
(typeof value.payload.pageStarId === "string" || value.payload.pageStarId === null)
);
}
export function isAfterSearchRateResultMessage(
value: unknown
): value is AfterSearchRateResultMessage {
if (!isRecord(value)) {
return false;
}
return (
value.source === EXTENSION_MESSAGE_SOURCE &&
value.type === RESULT_MESSAGE_TYPE &&
isAfterSearchRateResult(value.payload)
);
}
function isAfterSearchRateResult(value: unknown): value is AfterSearchRateResult {
if (!isRecord(value)) {
return false;
}
return (
typeof value.capturedAt === "number" &&
typeof value.pageUrl === "string" &&
Array.isArray(value.rawPathHints) &&
typeof value.routeKey === "string" &&
typeof value.stage === "string" &&
typeof value.success === "boolean"
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
-28
View File
@@ -1,28 +0,0 @@
import type { AfterSearchRateField } from "./result-types";
function normalizeText(value: string): string {
return value
.trim()
.replace(/\s+/g, "")
.replace(/[:,。.()()\-]/g, "")
.toLowerCase();
}
export function normalizeRateLabel(
label: string
): AfterSearchRateField | null {
const normalized = normalizeText(label);
if (normalized.includes("个人视频看后搜率")) {
return "personalVideoAfterSearchRate";
}
if (
normalized.includes("单条视频看后搜率") ||
normalized.includes("单视频看后搜率")
) {
return "singleVideoAfterSearchRate";
}
return null;
}
-25
View File
@@ -1,25 +0,0 @@
export function normalizeRateValue(value: string): string | null {
const trimmedValue = value.trim();
if (/^<\s*\d+(?:\.\d+)?%$/.test(trimmedValue)) {
return trimmedValue.replace(/\s+/g, "");
}
const normalizedRange = trimmedValue.match(
/^(\d+(?:\.\d+)?)%?\s*-\s*(\d+(?:\.\d+)?)%$/
);
if (normalizedRange) {
const [, start, end] = normalizedRange;
return `${start}% - ${end}%`;
}
const alreadyNormalizedRange = trimmedValue.match(
/^(\d+(?:\.\d+)?)%\s*-\s*(\d+(?:\.\d+)?)%$/
);
if (alreadyNormalizedRange) {
const [, start, end] = alreadyNormalizedRange;
return `${start}% - ${end}%`;
}
return null;
}
+102
View File
@@ -0,0 +1,102 @@
type ComparableRate = {
isLessThan: boolean;
numeric: number;
};
export function normalizeRateDisplay(value: string): string {
const trimmedValue = value.trim();
const rangeMatch = trimmedValue.match(
/^([0-9]+(?:\.[0-9]+)?)\s*%?\s*-\s*([0-9]+(?:\.[0-9]+)?)\s*%$/
);
if (rangeMatch) {
const [, lowerBound, upperBound] = rangeMatch;
return `${lowerBound}% - ${upperBound}%`;
}
return trimmedValue.replace(/\s+/g, "");
}
export function normalizeFractionRateDisplay(value: string): string | null {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return null;
}
const percentageValue = numericValue * 100;
return `${trimTrailingZeros(percentageValue.toFixed(6))}%`;
}
export function parseRateLowerBound(value: string | null | undefined): number | null {
const comparableRate = toComparableRate(value);
return comparableRate?.numeric ?? null;
}
export function compareRateValues(
leftValue: string | null | undefined,
rightValue: string | null | undefined
): number {
const leftComparable = toComparableRate(leftValue);
const rightComparable = toComparableRate(rightValue);
if (!leftComparable && !rightComparable) {
return 0;
}
if (!leftComparable) {
return 1;
}
if (!rightComparable) {
return -1;
}
if (leftComparable.numeric !== rightComparable.numeric) {
return leftComparable.numeric - rightComparable.numeric;
}
if (leftComparable.isLessThan === rightComparable.isLessThan) {
return 0;
}
return leftComparable.isLessThan ? -1 : 1;
}
function toComparableRate(value: string | null | undefined): ComparableRate | null {
if (!value) {
return null;
}
const normalizedValue = normalizeRateDisplay(value);
const lessThanMatch = normalizedValue.match(/^<\s*([0-9]+(?:\.[0-9]+)?)%$/);
if (lessThanMatch) {
return {
isLessThan: true,
numeric: Number(lessThanMatch[1])
};
}
const rangeMatch = normalizedValue.match(
/^([0-9]+(?:\.[0-9]+)?)%\s*-\s*([0-9]+(?:\.[0-9]+)?)%$/
);
if (rangeMatch) {
return {
isLessThan: false,
numeric: Number(rangeMatch[1])
};
}
const exactMatch = normalizedValue.match(/^([0-9]+(?:\.[0-9]+)?)%$/);
if (exactMatch) {
return {
isLessThan: false,
numeric: Number(exactMatch[1])
};
}
return null;
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.?0+$/, "");
}
-39
View File
@@ -1,39 +0,0 @@
export type AfterSearchRateField =
| "singleVideoAfterSearchRate"
| "personalVideoAfterSearchRate";
export type ExtractorLevel =
| "exact-key"
| "label-value"
| "text-fallback"
| "none";
export interface AfterSearchRates {
personalVideoAfterSearchRate?: string;
singleVideoAfterSearchRate?: string;
}
export interface ExtractAfterSearchRatesResult {
extractorLevel: ExtractorLevel;
matched: boolean;
matchedLabels?: string[];
rawPathHints: string[];
rates?: AfterSearchRates;
reason?: string;
success: boolean;
}
export type CaptureStage = "captured" | "timeout";
export interface AfterSearchRateResult {
capturedAt: number;
pageStarId: string | null;
pageUrl: string;
rawPathHints: string[];
reason?: string;
routeKey: string;
stage: CaptureStage;
success: boolean;
matchedRequestUrl?: string;
rates?: AfterSearchRates;
}
-8
View File
@@ -1,8 +0,0 @@
export function createRouteKey(input: {
navigationSeq: number;
pageStarId: string | null;
pathname: string;
}): string {
const routeStarId = input.pageStarId ?? "unknown";
return `${routeStarId}::${input.pathname}::${input.navigationSeq}`;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, test, vi } from "vitest";
import { registerBackgroundMessageHandler } from "../src/background/index";
describe("background-index", () => {
test("downloads csv files when the content script sends a download message", async () => {
const listeners: Array<
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
> = [];
const download = vi.fn(async () => undefined);
const sendResponse = vi.fn();
registerBackgroundMessageHandler({
downloads: {
download
},
runtime: {
onMessage: {
addListener(listener) {
listeners.push(listener);
}
}
}
});
expect(listeners).toHaveLength(1);
const result = listeners[0](
{
csv: "列1,列2\n值1,值2",
filename: "market.csv",
type: "download-market-csv"
},
{},
sendResponse
);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(download).toHaveBeenCalledTimes(1);
expect(download).toHaveBeenCalledWith(
expect.objectContaining({
filename: "market.csv",
saveAs: false,
url: expect.stringContaining("data:text/csv;charset=utf-8,")
})
);
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
});
});
-53
View File
@@ -1,53 +0,0 @@
import { existsSync, rmSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { beforeEach, describe, expect, test } from "vitest";
const execFileAsync = promisify(execFile);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const distDir = path.join(projectRoot, "dist");
const manifestPath = path.join(projectRoot, "src", "manifest.json");
describe("build layout", () => {
beforeEach(() => {
rmSync(distDir, { force: true, recursive: true });
});
test("manifest exists and targets the creator detail and market pages", async () => {
const raw = await readFile(manifestPath, "utf8");
const manifest = JSON.parse(raw);
expect(manifest.manifest_version).toBe(3);
expect(manifest.content_scripts).toEqual(
expect.arrayContaining([
expect.objectContaining({
matches: [
"https://*.xingtu.cn/ad/creator/author-homepage/*",
"https://*.xingtu.cn/ad/creator/market*"
]
})
])
);
expect(manifest.content_scripts[0].js).toEqual(["content/index.global.js"]);
expect(manifest.web_accessible_resources[0].resources).toEqual([
"page/hook.global.js"
]);
expect(manifest.web_accessible_resources[0].matches).toEqual([
"https://*.xingtu.cn/*"
]);
});
test("build emits dist/manifest.json", async () => {
await execFileAsync("node", ["scripts/build.mjs"], { cwd: projectRoot });
const output = path.join(distDir, "manifest.json");
expect(existsSync(output)).toBe(true);
const fileInfo = await stat(output);
expect(fileInfo.isFile()).toBe(true);
});
});
-371
View File
@@ -1,371 +0,0 @@
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createDetailContentController } from "../src/content/detail/index";
import { createRouteState } from "../src/content/route-state";
import {
CANDIDATE_ANALYSIS_MESSAGE_TYPE,
CANDIDATE_REQUEST_MESSAGE_TYPE,
EXTENSION_MESSAGE_SOURCE,
HOOK_READY_MESSAGE_TYPE,
RESULT_MESSAGE_TYPE
} from "../src/shared/message-types";
import type { AfterSearchRateResult } from "../src/shared/result-types";
describe("content bridge", () => {
let dom: JSDOM;
beforeEach(() => {
dom = new JSDOM("", {
url: "https://xingtu.cn/ad/creator/author-homepage/douyin-video/6629661559960371207"
});
});
afterEach(() => {
dom.window.close();
});
test("initializes route state from the current url", () => {
const state = createRouteState(dom.window.location.href);
expect(state.getSnapshot()).toMatchObject({
navigationSeq: 1,
pageStarId: "6629661559960371207",
pathname: "/ad/creator/author-homepage/douyin-video/6629661559960371207",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1"
});
});
test("increments the navigation sequence when the route changes", () => {
const state = createRouteState(dom.window.location.href);
const nextSnapshot = state.advance(
"https://xingtu.cn/ad/creator/author-homepage/douyin-video/7777777777777777777"
);
expect(nextSnapshot).toMatchObject({
navigationSeq: 2,
pageStarId: "7777777777777777777",
routeKey:
"7777777777777777777::/ad/creator/author-homepage/douyin-video/7777777777777777777::2"
});
});
test("ignores stale route messages", () => {
const logger = createLogger();
const controller = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
const stalePayload: AfterSearchRateResult = {
capturedAt: Date.now(),
pageStarId: "old-id",
pageUrl: dom.window.location.href,
rawPathHints: [],
routeKey: "old-id::/old-path::1",
stage: "captured",
success: true,
rates: {
personalVideoAfterSearchRate: "0.5% - 1%",
singleVideoAfterSearchRate: "1% - 2%"
}
};
dom.window.dispatchEvent(
new dom.window.MessageEvent("message", {
data: {
payload: stalePayload,
source: EXTENSION_MESSAGE_SOURCE,
type: RESULT_MESSAGE_TYPE
},
source: dom.window
})
);
expect(resultLogs(logger)).toHaveLength(0);
controller.dispose();
});
test("injects the built page-hook asset path", () => {
const logger = createLogger();
const firstController = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
const secondController = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
const injectedScripts = dom.window.document.querySelectorAll(
"#star-chart-search-enhancer-page-hook"
);
expect(injectedScripts).toHaveLength(1);
expect((injectedScripts[0] as HTMLScriptElement).src).toBe(
"chrome-extension://test/page/hook.global.js"
);
firstController.dispose();
secondController.dispose();
});
test("logs a hook-ready diagnostic when the page hook announces itself", () => {
const logger = createLogger();
const controller = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
dom.window.dispatchEvent(
new dom.window.MessageEvent("message", {
data: {
payload: {
pageStarId: "6629661559960371207",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1"
},
source: EXTENSION_MESSAGE_SOURCE,
type: HOOK_READY_MESSAGE_TYPE
},
source: dom.window
})
);
expect(logger.info).toHaveBeenCalledWith(
"[star-chart-search-enhancer]",
"hook-ready",
expect.objectContaining({
pageStarId: "6629661559960371207"
})
);
controller.dispose();
});
test("logs candidate-request diagnostics for the current route", () => {
const logger = createLogger();
const controller = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
dom.window.dispatchEvent(
new dom.window.MessageEvent("message", {
data: {
payload: {
requestMethod: "GET",
requestUrl: "https://api.xingtu.cn/creator/value",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1",
status: 200
},
source: EXTENSION_MESSAGE_SOURCE,
type: CANDIDATE_REQUEST_MESSAGE_TYPE
},
source: dom.window
})
);
expect(logger.info).toHaveBeenCalledWith(
"[star-chart-search-enhancer]",
"candidate-request",
expect.objectContaining({
requestUrl: "https://api.xingtu.cn/creator/value"
})
);
controller.dispose();
});
test("logs candidate-analysis diagnostics for the current route", () => {
const logger = createLogger();
const controller = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
dom.window.dispatchEvent(
new dom.window.MessageEvent("message", {
data: {
payload: {
extractorLevel: "none",
matched: false,
reason: "No after-search-rate signals found",
requestUrl: "https://api.xingtu.cn/creator/value",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::2",
signalEntries: [
"$.data.avg_search_after_view_rate=0.0015",
"$.data.avg_search_after_view_rate_rank_percent=0.9"
],
success: false,
topLevelKeys: ["data", "status_code"]
},
source: EXTENSION_MESSAGE_SOURCE,
type: CANDIDATE_ANALYSIS_MESSAGE_TYPE
},
source: dom.window
})
);
expect(logger.info).toHaveBeenCalledWith(
"[star-chart-search-enhancer]",
"candidate-analysis",
expect.objectContaining({
requestUrl: "https://api.xingtu.cn/creator/value",
signalEntries: [
"$.data.avg_search_after_view_rate=0.0015",
"$.data.avg_search_after_view_rate_rank_percent=0.9"
],
topLevelKeys: ["data", "status_code"]
})
);
controller.dispose();
});
test("accepts result messages when only the navigation sequence differs", () => {
const logger = createLogger();
const controller = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
dom.window.dispatchEvent(
new dom.window.MessageEvent("message", {
data: {
payload: {
capturedAt: Date.now(),
pageStarId: "6629661559960371207",
pageUrl: dom.window.location.href,
rawPathHints: [],
reason: "Timed out waiting for after-search-rate capture",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::3",
stage: "timeout",
success: false
},
source: EXTENSION_MESSAGE_SOURCE,
type: RESULT_MESSAGE_TYPE
},
source: dom.window
})
);
expect(logger.info).toHaveBeenCalledWith(
"[star-chart-search-enhancer]",
"result",
expect.objectContaining({
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::3",
stage: "timeout"
})
);
controller.dispose();
});
test("does not log duplicate final results twice", () => {
const logger = createLogger();
const controller = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
const payload = currentRoutePayload(dom.window.location.href);
dom.window.dispatchEvent(messageEvent(dom.window, payload));
dom.window.dispatchEvent(messageEvent(dom.window, payload));
expect(resultLogs(logger)).toHaveLength(1);
controller.dispose();
});
test("allows a later success result to replace an earlier failure", () => {
const logger = createLogger();
const controller = createDetailContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
document: dom.window.document,
logger,
window: dom.window
});
const timeoutPayload: AfterSearchRateResult = {
capturedAt: Date.now(),
pageStarId: "6629661559960371207",
pageUrl: dom.window.location.href,
rawPathHints: [],
reason: "Timed out waiting for relevant responses",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1",
stage: "timeout",
success: false
};
const successPayload = currentRoutePayload(dom.window.location.href);
dom.window.dispatchEvent(messageEvent(dom.window, timeoutPayload));
dom.window.dispatchEvent(messageEvent(dom.window, successPayload));
const logs = resultLogs(logger);
expect(logs).toHaveLength(2);
expect(logs.at(-1)?.[2]).toMatchObject({
stage: "captured",
success: true
});
controller.dispose();
});
});
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn()
};
}
function currentRoutePayload(pageUrl: string): AfterSearchRateResult {
return {
capturedAt: Date.now(),
pageStarId: "6629661559960371207",
pageUrl,
rawPathHints: ["$.cards[0].metrics[0]"],
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1",
stage: "captured",
success: true,
rates: {
personalVideoAfterSearchRate: "0.5% - 1%",
singleVideoAfterSearchRate: "1% - 2%"
}
};
}
function messageEvent(window: Window, payload: AfterSearchRateResult): MessageEvent {
return new window.MessageEvent("message", {
data: {
payload,
source: EXTENSION_MESSAGE_SOURCE,
type: RESULT_MESSAGE_TYPE
},
source: window
});
}
function resultLogs(logger: ReturnType<typeof createLogger>) {
return logger.info.mock.calls.filter((call) => call[1] === "result");
}
-82
View File
@@ -1,82 +0,0 @@
import { JSDOM } from "jsdom";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createContentController } from "../src/content/index";
const doms: JSDOM[] = [];
describe("content entry", () => {
afterEach(() => {
while (doms.length > 0) {
doms.pop()?.window.close();
}
});
test("bootstraps the detail controller on creator detail urls", () => {
const dom = createDom(
"https://xingtu.cn/ad/creator/author-homepage/douyin-video/6629661559960371207"
);
const detailController = { dispose: vi.fn() };
const detailControllerFactory = vi.fn(() => detailController);
const controller = createContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
detailControllerFactory,
document: dom.window.document,
logger: createLogger(),
window: dom.window
});
expect(detailControllerFactory).toHaveBeenCalledTimes(1);
expect(controller).toBe(detailController);
});
test("returns a no-op controller on unsupported urls", () => {
const dom = createDom("https://xingtu.cn/");
const detailControllerFactory = vi.fn(() => ({ dispose: vi.fn() }));
const controller = createContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
detailControllerFactory,
document: dom.window.document,
logger: createLogger(),
window: dom.window
});
expect(detailControllerFactory).not.toHaveBeenCalled();
expect(() => controller.dispose()).not.toThrow();
});
test("bootstraps the market controller on market urls", () => {
const dom = createDom("https://xingtu.cn/ad/creator/market");
const detailControllerFactory = vi.fn(() => ({ dispose: vi.fn() }));
const marketController = { dispose: vi.fn() };
const marketControllerFactory = vi.fn(() => marketController);
const controller = createContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
detailControllerFactory,
document: dom.window.document,
logger: createLogger(),
marketControllerFactory,
window: dom.window
});
expect(detailControllerFactory).not.toHaveBeenCalled();
expect(marketControllerFactory).toHaveBeenCalledTimes(1);
expect(controller).toBe(marketController);
});
});
function createDom(url: string) {
const dom = new JSDOM("", { url });
return doms.push(dom), dom;
}
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn()
};
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, test } from "vitest";
import { buildMarketCsv } from "../src/content/market/csv-exporter";
import type { MarketRecord } from "../src/content/market/types";
describe("csv-exporter", () => {
test("uses fallback header order when no page export fields are available", () => {
const csv = buildMarketCsv([]);
const [headerLine] = csv.split("\n");
expect(headerLine).toBe(
[
"达人ID",
"达人名称",
"地区",
"21-60s报价",
"单视频看后搜率",
"个人视频看后搜率"
].join(",")
);
});
test("uses page export field order and appends the two plugin columns", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
exportFields: {
: "Alice",
: "100w",
"21-60s报价": "¥450,000"
},
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "1% - 3%"
}
} satisfies MarketRecord
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join(
","
)
);
expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%');
});
test("escapes commas and quotes", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice, \"A\"",
location: "Hangzhou",
price21To60s: "450000",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "1% - 3%"
}
}
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toContain("\"Alice, \"\"A\"\"\"");
});
test("emits empty rate fields plus failed status for failed rows", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
status: "failed",
failureReason: "request-failed"
}
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,,");
});
test("uses normalized display values in export rows", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
} satisfies MarketRecord
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toContain("0.5% - 1%");
expect(rowLine).toContain("0.02% - 0.1%");
});
});
-120
View File
@@ -1,120 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractAfterSearchRates } from "../src/shared/extract-after-search-rates";
describe("extractAfterSearchRates", () => {
test("extracts rates from explicit keys", () => {
const result = extractAfterSearchRates({
metrics: {
personal_video_after_search_rate: "0.5% - 1%",
single_video_after_search_rate: "1% - 3%"
}
});
expect(result).toMatchObject({
extractorLevel: "exact-key",
matched: true,
success: true,
rates: {
personalVideoAfterSearchRate: "0.5% - 1%",
singleVideoAfterSearchRate: "1% - 3%"
}
});
expect(result.rawPathHints).not.toHaveLength(0);
});
test("extracts rates from the commerce seed base info response keys", () => {
const result = extractAfterSearchRates({
avg_a3_incr_cnt: "20,000 - 100,000",
avg_search_after_view_rate: "<0.02%",
avg_search_after_view_rate_rank_percent: "0.25",
personal_avg_search_after_view_rate: "0.02 - 0.1%",
personal_avg_search_after_view_rate_rank_percent: "0.9"
});
expect(result).toMatchObject({
extractorLevel: "exact-key",
matched: true,
success: true,
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
}
});
});
test("extracts rates from normalized label and value pairs", () => {
const result = extractAfterSearchRates({
cards: [
{
metrics: [
{
label: "单条视频看后搜率",
value: "0.5%-1%"
},
{
label: "个人视频看后搜率",
value: "1% - 3%"
}
]
}
]
});
expect(result).toMatchObject({
extractorLevel: "label-value",
matched: true,
success: true,
rates: {
personalVideoAfterSearchRate: "1% - 3%",
singleVideoAfterSearchRate: "0.5% - 1%"
}
});
});
test("falls back to bounded text extraction when labels are only present in local text", () => {
const result = extractAfterSearchRates({
cardTitle: "种草价值",
summary:
"单视频看后搜率 0.5% - 1% ,个人视频看后搜率 1% - 2%,该达人近30天表现稳定"
});
expect(result).toMatchObject({
extractorLevel: "text-fallback",
matched: true,
success: true,
rates: {
personalVideoAfterSearchRate: "1% - 2%",
singleVideoAfterSearchRate: "0.5% - 1%"
}
});
});
test("keeps partial matches as non-success", () => {
const result = extractAfterSearchRates({
metrics: [
{
label: "个人视频看后搜率",
value: "1% - 2%"
}
]
});
expect(result).toMatchObject({
extractorLevel: "label-value",
matched: true,
success: false,
rates: {
personalVideoAfterSearchRate: "1% - 2%"
}
});
});
test("returns an unmatched result for unrelated payloads", () => {
expect(extractAfterSearchRates({ foo: "bar" })).toMatchObject({
extractorLevel: "none",
matched: false,
success: false
});
});
});
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, test } from "vitest";
import { applyFilterAndSort } from "../src/content/market/filter-sort-controller";
import type { MarketRecord } from "../src/content/market/types";
const baseRecords: MarketRecord[] = [
{
authorId: "a",
authorName: "Alpha",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
},
{
authorId: "b",
authorName: "Beta",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5% - 1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
},
{
authorId: "c",
authorName: "Gamma",
status: "failed",
failureReason: "request-failed"
}
];
describe("filter-sort-controller", () => {
test("passes only when the lower bound meets the single-rate threshold", () => {
const result = applyFilterAndSort(baseRecords, {
filters: {
singleVideoAfterSearchRateMin: 0.1
}
});
expect(result.map((record) => record.authorId)).toEqual(["b"]);
});
test("passes only when the lower bound meets the personal-rate threshold", () => {
const result = applyFilterAndSort(baseRecords, {
filters: {
personalVideoAfterSearchRateMin: 0.02
}
});
expect(result.map((record) => record.authorId)).toEqual(["a"]);
});
test("sorts by single-rate descending using the lower bound", () => {
const result = applyFilterAndSort(baseRecords, {
sort: {
direction: "desc",
field: "singleVideoAfterSearchRate"
}
});
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]);
});
test("sorts by personal-rate ascending using the lower bound", () => {
const result = applyFilterAndSort(baseRecords, {
sort: {
direction: "asc",
field: "personalVideoAfterSearchRate"
}
});
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]);
});
test("keeps failed and missing rows at the end", () => {
const result = applyFilterAndSort(
[
...baseRecords,
{
authorId: "d",
authorName: "Delta",
status: "missing"
}
],
{
sort: {
direction: "desc",
field: "singleVideoAfterSearchRate"
}
}
);
expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]);
});
});
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, test, vi } from "vitest";
import { createFullScanController } from "../src/content/market/full-scan-controller";
import { createMarketResultStore } from "../src/content/market/result-store";
describe("full-scan-controller", () => {
test("does not start a scan during initial construction", () => {
const readCurrentPageRows = vi.fn(() => []);
createFullScanController({
goToNextPage: async () => false,
hasNextPage: () => false,
loadAuthorMetrics: async () => ({
success: false as const,
reason: "request-failed" as const
}),
readCurrentPageRows,
resultStore: createMarketResultStore()
});
expect(readCurrentPageRows).not.toHaveBeenCalled();
});
test("starts a full scan for filter actions", async () => {
const harness = createHarness();
await harness.controller.ensureScanForFilter();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
expect(harness.loadAuthorMetrics).toHaveBeenCalledTimes(2);
});
test("starts a full scan for sort actions", async () => {
const harness = createHarness();
await harness.controller.ensureScanForSort();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
});
test("starts a full scan for export actions", async () => {
const harness = createHarness();
await harness.controller.ensureScanForExport();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
});
test("does not restart a completed scan unnecessarily", async () => {
const harness = createHarness();
await harness.controller.ensureScanForFilter();
await harness.controller.ensureScanForSort();
expect(harness.readCurrentPageRows).toHaveBeenCalledTimes(2);
expect(harness.loadAuthorMetrics).toHaveBeenCalledTimes(2);
});
test("records failed author fetches without aborting the whole scan", async () => {
const store = createMarketResultStore();
let pageIndex = 0;
const pages = [
[
{
authorId: "a",
authorName: "Alpha"
}
],
[
{
authorId: "b",
authorName: "Beta"
}
]
];
const controller = createFullScanController({
goToNextPage: async () => {
pageIndex += 1;
return true;
},
hasNextPage: () => pageIndex < pages.length - 1,
loadAuthorMetrics: async (authorId) =>
authorId === "a"
? {
success: false as const,
reason: "request-failed" as const
}
: {
success: true as const,
rates: {
singleVideoAfterSearchRate: "0.5% - 1%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
},
readCurrentPageRows: vi.fn(() => pages[pageIndex]),
resultStore: store
});
await controller.ensureScanForExport();
expect(store.getRecord("a")).toMatchObject({
status: "failed",
failureReason: "request-failed"
});
expect(store.getRecord("b")).toMatchObject({
status: "success"
});
});
test("uses current page market-list rates and still loads missing metrics", async () => {
const store = createMarketResultStore();
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "a"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}));
const controller = createFullScanController({
goToNextPage: async () => false,
hasNextPage: () => false,
loadAuthorMetrics,
readCurrentPageRows: vi.fn(() => [
{
authorId: "a",
authorName: "Alpha",
hasDirectRatesSource: true,
rates: {
singleVideoAfterSearchRate: "0.02%"
}
},
{
authorId: "b",
authorName: "Beta",
hasDirectRatesSource: true
}
]),
resultStore: store
});
await controller.ensureScanForFilter();
expect(loadAuthorMetrics).toHaveBeenCalledTimes(2);
expect(store.getRecord("a")).toMatchObject({
status: "success",
rates: {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
});
expect(store.getRecord("b")).toMatchObject({
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
});
});
});
function createHarness() {
const store = createMarketResultStore();
let pageIndex = 0;
const pages = [
[
{
authorId: "a",
authorName: "Alpha"
}
],
[
{
authorId: "b",
authorName: "Beta"
}
]
];
const readCurrentPageRows = vi.fn(() => pages[pageIndex]);
const loadAuthorMetrics = vi.fn(async () => ({
success: true as const,
rates: {
singleVideoAfterSearchRate: "0.5% - 1%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
}));
return {
controller: createFullScanController({
goToNextPage: async () => {
pageIndex += 1;
return true;
},
hasNextPage: () => pageIndex < pages.length - 1,
loadAuthorMetrics,
readCurrentPageRows,
resultStore: store
}),
loadAuthorMetrics,
readCurrentPageRows,
store
};
}
-17
View File
@@ -1,17 +0,0 @@
import { describe, expect, test } from "vitest";
import { getStarIdFromUrl } from "../src/shared/get-star-id";
describe("getStarIdFromUrl", () => {
test("returns the star id from a creator detail page url", () => {
expect(
getStarIdFromUrl(
"https://xingtu.cn/ad/creator/author-homepage/douyin-video/6629661559960371207?foo=bar"
)
).toBe("6629661559960371207");
});
test("returns null for non-matching urls", () => {
expect(getStarIdFromUrl("https://xingtu.cn/ad/creator/market")).toBeNull();
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, test } from "vitest";
import manifest from "../src/manifest.json";
describe("manifest", () => {
test("injects the content script on the www Xingtu market page", () => {
expect(manifest.content_scripts?.[0]?.matches).toEqual(
expect.arrayContaining([
expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/)
])
);
});
test("declares the downloads permission and background worker for csv export", () => {
expect(manifest.permissions).toEqual(
expect.arrayContaining(["downloads"])
);
expect(manifest.background?.service_worker).toBe("background/index.js");
});
});
+111 -83
View File
@@ -1,22 +1,22 @@
import { afterEach, describe, expect, test, vi } from "vitest"; import { describe, expect, test } from "vitest";
import { import {
buildAuthorAseInfoUrl,
buildAuthorCommerceSeedBaseInfoUrl,
createMarketApiClient, createMarketApiClient,
mapAuthorAseInfoResponse mapAuthorAseInfoResponse
} from "../src/content/market/api-client"; } from "../src/content/market/api-client";
import { normalizeRateValue } from "../src/shared/normalize-rate-value";
describe("market api client", () => { describe("market-api-client", () => {
afterEach(() => { test("builds the author ase info url with author id and range", () => {
vi.useRealTimers(); expect(
buildAuthorAseInfoUrl("123", "https://xingtu.cn")
).toBe(
"https://xingtu.cn/gw/api/aggregator/get_author_ase_info?author_id=123&range=30"
);
}); });
test("normalizes known rate shapes", () => { test("maps a valid ASE payload into normalized rates", () => {
expect(normalizeRateValue("<0.02%")).toBe("<0.02%");
expect(normalizeRateValue("0.02 - 0.1%")).toBe("0.02% - 0.1%");
});
test("maps the known author ase info response fields", () => {
expect( expect(
mapAuthorAseInfoResponse({ mapAuthorAseInfoResponse({
data: { data: {
@@ -24,101 +24,129 @@ describe("market api client", () => {
personal_avg_search_after_view_rate: "0.02 - 0.1%" personal_avg_search_after_view_rate: "0.02 - 0.1%"
} }
}) })
).toEqual({ ).toMatchObject({
success: true,
rates: { rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%", singleVideoAfterSearchRate: "<0.02%",
singleVideoAfterSearchRate: "<0.02%" personalVideoAfterSearchRate: "0.02% - 0.1%"
}, }
success: true
}); });
}); });
test("returns bad-response when either target field is missing", () => { test("returns a missing-rate failure when the payload omits a required field", () => {
expect( expect(
mapAuthorAseInfoResponse({ mapAuthorAseInfoResponse({
data: { data: {
avg_search_after_view_rate: "<0.02%" avg_search_after_view_rate: "<0.02%"
} }
}) })
).toEqual({ ).toMatchObject({
reason: "bad-response", success: false,
success: false reason: "missing-rate"
}); });
}); });
test("issues fetch with credentials include and a timeout signal", async () => { test("returns a request-failed result for non-ok responses", async () => {
const fetchImpl = vi.fn(async () => ({ const client = createMarketApiClient({
fetchImpl: async () => ({
ok: false,
json: async () => ({})
})
});
await expect(client.loadAuthorAseInfo("123")).resolves.toMatchObject({
success: false,
reason: "request-failed"
});
});
test("loads rates from the commerce seed endpoint first", async () => {
const requestedUrls: string[] = [];
const client = createMarketApiClient({
fetchImpl: async (input) => {
requestedUrls.push(input);
return {
ok: true,
json: async () => ({
avg_search_after_view_rate: "0.1% - 0.25%",
personal_avg_search_after_view_rate: "0.02% - 0.1%"
})
};
}
});
await expect(client.loadAuthorAseInfo("7363217488772857856")).resolves.toMatchObject(
{
success: true,
rates: {
singleVideoAfterSearchRate: "0.1% - 0.25%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
}
);
expect(requestedUrls).toEqual([
"https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90"
]);
});
test("falls back to the ASE endpoint when the commerce seed endpoint fails", async () => {
const requestedUrls: string[] = [];
const client = createMarketApiClient({
fetchImpl: async (input) => {
requestedUrls.push(input);
if (input.includes("get_author_commerce_seed_base_info")) {
return {
ok: false,
json: async () => ({})
};
}
return {
ok: true,
json: async () => ({ json: async () => ({
data: { data: {
avg_search_after_view_rate: "<0.02%", avg_search_after_view_rate: "<0.02%",
personal_avg_search_after_view_rate: "0.02 - 0.1%" personal_avg_search_after_view_rate: "0.02 - 0.1%"
} }
}),
ok: true
}));
const client = createMarketApiClient({
baseUrl: "https://xingtu.cn",
fetchImpl,
timeoutMs: 8000
});
const result = await client.loadAuthorAseInfo("6629661559960371207");
expect(fetchImpl).toHaveBeenCalledWith(
"https://xingtu.cn/gw/api/aggregator/get_author_ase_info?author_id=6629661559960371207&range=30",
expect.objectContaining({
credentials: "include",
method: "GET",
signal: expect.any(AbortSignal)
}) })
};
}
});
await expect(client.loadAuthorAseInfo("7363217488772857856")).resolves.toMatchObject(
{
success: true,
rates: {
singleVideoAfterSearchRate: "<0.02%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
}
); );
expect(result).toMatchObject({ expect(requestedUrls).toEqual([
success: true "https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90",
"https://xingtu.cn/gw/api/aggregator/get_author_ase_info?author_id=7363217488772857856&range=30"
]);
});
test("returns a timeout result when the request aborts", async () => {
const client = createMarketApiClient({
fetchImpl: async () => {
throw new DOMException("Timed out", "AbortError");
},
timeoutMs: 1
});
await expect(client.loadAuthorAseInfo("123")).resolves.toMatchObject({
success: false,
reason: "timeout"
}); });
}); });
test("returns timeout when the request is aborted by the timeout budget", async () => { test("builds the author commerce seed info url with author id and range", () => {
vi.useFakeTimers(); expect(
buildAuthorCommerceSeedBaseInfoUrl("7363217488772857856", "https://xingtu.cn")
const fetchImpl = vi.fn( ).toBe(
(_input: string, init?: RequestInit) => "https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90"
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
});
})
); );
const client = createMarketApiClient({
baseUrl: "https://xingtu.cn",
fetchImpl,
timeoutMs: 25
});
const resultPromise = client.loadAuthorAseInfo("6629661559960371207");
await vi.advanceTimersByTimeAsync(25);
await expect(resultPromise).resolves.toEqual({
reason: "timeout",
success: false
});
});
test("returns request-failed for non-ok responses", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({}),
ok: false
}));
const client = createMarketApiClient({
baseUrl: "https://xingtu.cn",
fetchImpl,
timeoutMs: 8000
});
await expect(
client.loadAuthorAseInfo("6629661559960371207")
).resolves.toEqual({
reason: "request-failed",
success: false
});
}); });
}); });
-235
View File
@@ -1,235 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { createMarketBatchLoader } from "../src/content/market/batch-loader";
describe("market batch loader", () => {
test("puts current-page rows into loading before the request resolves", async () => {
const deferred = createDeferred();
const row = createRowRecorder("111");
const loader = createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(() => deferred.promise)
},
concurrency: 4
});
const loadPromise = loader.loadRows({
listSeq: 1,
rows: [row.row]
});
expect(row.states[0]).toMatchObject({
authorId: "111",
listSeq: 1,
state: "loading"
});
deferred.resolve({
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true
});
await loadPromise;
});
test("reuses the success cache for repeated authorIds", async () => {
const apiClient = {
loadAuthorAseInfo: vi.fn(async () => ({
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true as const
}))
};
const firstRow = createRowRecorder("111");
const secondRow = createRowRecorder("111");
const loader = createMarketBatchLoader({
apiClient,
concurrency: 4
});
await loader.loadRows({
listSeq: 1,
rows: [firstRow.row]
});
await loader.loadRows({
listSeq: 2,
rows: [secondRow.row]
});
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1);
expect(secondRow.states.at(-1)).toMatchObject({
source: "cache",
state: "success"
});
});
test("deduplicates in-flight requests for the same authorId", async () => {
const deferred = createDeferred();
const firstRow = createRowRecorder("111");
const secondRow = createRowRecorder("111");
const apiClient = {
loadAuthorAseInfo: vi.fn(() => deferred.promise)
};
const loader = createMarketBatchLoader({
apiClient,
concurrency: 4
});
const loadPromise = loader.loadRows({
listSeq: 1,
rows: [firstRow.row, secondRow.row]
});
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1);
deferred.resolve({
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true
});
await loadPromise;
});
test("honors the concurrency cap", async () => {
const deferreds = new Map([
["111", createDeferred()],
["222", createDeferred()],
["333", createDeferred()]
]);
const started: string[] = [];
const loader = createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn((authorId: string) => {
started.push(authorId);
return deferreds.get(authorId)!.promise;
})
},
concurrency: 2
});
const loadPromise = loader.loadRows({
listSeq: 1,
rows: [createRowRecorder("111").row, createRowRecorder("222").row, createRowRecorder("333").row]
});
expect(started).toEqual(["111", "222"]);
deferreds.get("111")!.resolve(successResult());
await tick();
expect(started).toEqual(["111", "222", "333"]);
deferreds.get("222")!.resolve(successResult());
deferreds.get("333")!.resolve(successResult());
await loadPromise;
});
test("renders failed rows as error states", async () => {
const row = createRowRecorder("111");
const loader = createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(async () => ({
reason: "request-failed",
success: false as const
}))
},
concurrency: 4
});
await loader.loadRows({
listSeq: 1,
rows: [row.row]
});
expect(row.states.at(-1)).toMatchObject({
reason: "request-failed",
retryable: true,
state: "error"
});
});
test("retries the whole row when the provided retry handler is invoked", async () => {
const row = createRowRecorder("111");
const apiClient = {
loadAuthorAseInfo: vi
.fn()
.mockResolvedValueOnce({
reason: "request-failed",
success: false as const
})
.mockResolvedValueOnce(successResult())
};
const loader = createMarketBatchLoader({
apiClient,
concurrency: 4
});
await loader.loadRows({
listSeq: 1,
rows: [row.row]
});
expect(row.retry).toBeTypeOf("function");
await row.retry?.();
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(2);
expect(row.states.at(-2)).toMatchObject({
state: "loading"
});
expect(row.states.at(-1)).toMatchObject({
state: "success"
});
});
});
function createRowRecorder(authorId: string | null) {
const states: unknown[] = [];
let retry: (() => Promise<void> | void) | undefined;
return {
row: {
authorId,
render(
state: unknown,
options?: { onRetry?: () => Promise<void> | void }
) {
states.push(state);
retry = options?.onRetry;
}
},
get retry() {
return retry;
},
states
};
}
function createDeferred<T = unknown>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function successResult() {
return {
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true as const
};
}
function tick() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
File diff suppressed because it is too large Load Diff
-497
View File
@@ -1,497 +0,0 @@
import { JSDOM } from "jsdom";
import { describe, expect, test, vi } from "vitest";
import { createMarketBatchLoader } from "../src/content/market/batch-loader";
import { createMarketContentController } from "../src/content/market/index";
describe("market controller", () => {
test("auto-loads the current market rows on startup", async () => {
const dom = createMarketDom();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
},
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: createMutationObserverFactory(),
window: dom.window
});
await tick();
const firstRow = dom.window.document.querySelector("tbody tr")!;
expect(cellTexts(firstRow)).toEqual([
"达人 A",
"111-single",
"111-personal",
"查看"
]);
controller.dispose();
dom.window.close();
});
test("auto-loads the current rows on the div-based market grid", async () => {
const dom = createDivMarketDom();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
},
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: createMutationObserverFactory(),
window: dom.window
});
await tick();
expect(divHeaderTexts(dom.window.document)).toEqual([
"21-60s报价",
"单视频看后搜率",
"个人视频看后搜率",
"操作"
]);
expect(divRightRowTexts(dom.window.document, 0)).toEqual([
"¥70,000",
"111-single",
"111-personal",
"下单"
]);
controller.dispose();
dom.window.close();
});
test("triggers a fresh sync when the visible list changes", async () => {
const dom = createMarketDom();
const apiClient = {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
};
const observer = createMutationObserverFactory();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient,
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: observer,
window: dom.window
});
await tick();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/333">达人 C</a></td>
<td>查看</td>
</tr>
`
);
observer.trigger();
await flushSync();
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledWith("333");
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
"达人 C",
"333-single",
"333-personal",
"查看"
]);
controller.dispose();
dom.window.close();
});
test("drops stale async results after a newer list replaces the old one", async () => {
const dom = createMarketDom();
const firstDeferred = createDeferred<ReturnType<typeof successFor>>();
const apiClient = {
loadAuthorAseInfo: vi
.fn()
.mockImplementationOnce(() => firstDeferred.promise)
.mockImplementationOnce(async () => successFor("222"))
};
const observer = createMutationObserverFactory();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient,
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: observer,
window: dom.window
});
await tick();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a></td>
<td>查看</td>
</tr>
`
);
observer.trigger();
await flushSync();
firstDeferred.resolve(successFor("111"));
await flushSync();
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
"达人 B",
"222-single",
"222-personal",
"查看"
]);
controller.dispose();
dom.window.close();
});
test("rehydrates cached rows immediately when they reappear", async () => {
const dom = createMarketDom();
const apiClient = {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
};
const observer = createMutationObserverFactory();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient,
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: observer,
window: dom.window
});
await tick();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a></td>
<td>查看</td>
</tr>
`
);
observer.trigger();
await flushSync();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a></td>
<td>查看</td>
</tr>
`
);
observer.trigger();
await flushSync();
const row = dom.window.document.querySelector("tbody tr")!;
expect(cellTexts(row)).toEqual([
"达人 A",
"111-single",
"111-personal",
"查看"
]);
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(2);
controller.dispose();
dom.window.close();
});
test("boots safely at document_start when body is not ready yet", async () => {
const dom = createMarketDom();
Object.defineProperty(dom.window.document, "body", {
configurable: true,
value: null
});
Object.defineProperty(dom.window.document, "documentElement", {
configurable: true,
value: null
});
const strictObserverFactory = (callback: MutationCallback) => {
void callback;
return {
disconnect() {},
observe(target: Node | null) {
if (!target) {
throw new TypeError("observer target must be a Node");
}
}
};
};
expect(() =>
createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
},
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: strictObserverFactory,
window: dom.window
})
).not.toThrow();
dom.window.close();
});
test("waits until the document is ready before observing the market page", async () => {
const dom = createMarketDom();
let readyState = "loading";
Object.defineProperty(dom.window.document, "readyState", {
configurable: true,
get() {
return readyState;
}
});
const apiClient = {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
};
const observer = createMutationObserverFactory();
createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient,
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: observer,
window: dom.window
});
await tick();
expect(observer.observe).toHaveBeenCalledTimes(0);
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(0);
readyState = "interactive";
dom.window.dispatchEvent(new dom.window.Event("DOMContentLoaded"));
await flushSync();
expect(observer.observe).toHaveBeenCalledTimes(1);
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1);
dom.window.close();
});
test("does not override history methods on the market page", () => {
const dom = createMarketDom();
const originalPushState = dom.window.history.pushState;
const originalReplaceState = dom.window.history.replaceState;
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
},
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: createMutationObserverFactory(),
window: dom.window
});
expect(dom.window.history.pushState).toBe(originalPushState);
expect(dom.window.history.replaceState).toBe(originalReplaceState);
controller.dispose();
dom.window.close();
});
});
function cellTexts(row: Element) {
return Array.from(row.querySelectorAll("td"), (cell) => cell.textContent?.trim() ?? "");
}
function divCellTexts(row: Element) {
return Array.from(row.children, (cell) => cell.textContent?.trim() ?? "");
}
function divHeaderTexts(document: Document) {
return Array.from(
document.querySelectorAll('[data-testid="right-header"] > *'),
(cell) => cell.textContent?.trim() ?? ""
);
}
function divRightRowTexts(document: Document, rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
);
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn()
};
}
function createMarketDom() {
const dom = new JSDOM(
`
<table>
<thead>
<tr>
<th>达人信息</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a></td>
<td>查看</td>
</tr>
</tbody>
</table>
`,
{
url: "https://xingtu.cn/ad/creator/market"
}
);
let readyState = "complete";
Object.defineProperty(dom.window.document, "readyState", {
configurable: true,
get() {
return readyState;
}
});
return dom;
}
function createDivMarketDom() {
const dom = new JSDOM(
`
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;">代表视频</div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" data-testid="author-row-a" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">代表视频A</div>
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥70,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;">下单</div>
</div>
</div>
</div>
</div>
`,
{
url: "https://xingtu.cn/ad/creator/market"
}
);
let readyState = "complete";
Object.defineProperty(dom.window.document, "readyState", {
configurable: true,
get() {
return readyState;
}
});
return dom;
}
function createMutationObserverFactory() {
let callback: MutationCallback = () => undefined;
const observe = vi.fn();
return Object.assign(
(nextCallback: MutationCallback) => {
callback = nextCallback;
return {
disconnect() {},
observe
};
},
{
observe,
trigger() {
callback([], {} as MutationObserver);
}
}
);
}
function replaceRows(document: Document, rowsHtml: string) {
document.querySelector("tbody")!.innerHTML = rowsHtml;
}
function successFor(authorId: string) {
return {
rates: {
personalVideoAfterSearchRate: `${authorId}-personal`,
singleVideoAfterSearchRate: `${authorId}-single`
},
success: true as const
};
}
function tick() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}
async function flushSync() {
await tick();
await tick();
}
+322 -100
View File
@@ -1,124 +1,252 @@
import { JSDOM } from "jsdom"; // @vitest-environment jsdom
import { describe, expect, test } from "vitest";
import { syncMarketTable } from "../src/content/market/dom-sync"; import { beforeEach, describe, expect, test } from "vitest";
describe("market dom sync", () => { import {
test("inserts two headers before the 操作 column", () => { applyRowOrder,
const document = createDocument(); applyRowVisibility,
findNextPageControl,
isPageControlDisabled,
readMarketPageSignature,
renderMarketRowState,
syncMarketTable
} from "../src/content/market/dom-sync";
import type { MarketRecord } from "../src/content/market/types";
const table = syncMarketTable(document); describe("market-dom-sync", () => {
const headers = Array.from( beforeEach(() => {
document.querySelectorAll("thead th"), document.body.innerHTML = `
(cell) => cell.textContent?.trim() ?? "" <div data-market-table>
); <div data-market-header>
<div data-market-header-cell="authorName">达人</div>
expect(table).not.toBeNull(); <div data-market-header-cell="price21To60s">21-60s报价</div>
expect(headers).toEqual([ </div>
"达人信息", <div data-market-body>
"单视频看后搜率", <div data-market-row data-author-id="a">
"个人视频看后搜率", <span data-market-field="authorName">Alpha</span>
"操作" <span data-market-field="price21To60s">450000</span>
]); </div>
<div data-market-row data-author-id="b">
<span data-market-field="authorName">Beta</span>
<span data-market-field="price21To60s">70000</span>
</div>
</div>
</div>
`;
}); });
test("inserts two cells before the action cell for each row and tags them", () => { test("injects the two header cells and per-row cells", () => {
const document = createDocument();
const table = syncMarketTable(document); const table = syncMarketTable(document);
expect(table?.rows).toHaveLength(2); expect(table).not.toBeNull();
expect( expect(
table?.rows.map((row) => document.querySelector('[data-market-header-cell="singleVideoAfterSearchRate"]')
Array.from(row.row.cells, (cell) => cell.textContent?.trim() ?? "") ).not.toBeNull();
expect(
document.querySelector(
'[data-market-header-cell="personalVideoAfterSearchRate"]'
) )
).toEqual([ ).not.toBeNull();
["达人 A", "", "", "查看"], expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(4);
["达人 B", "", "", "查看"]
]);
expect(table?.rows[0].singleCell.dataset.scesColumn).toBe(
"single-video-after-search-rate"
);
expect(table?.rows[0].personalCell.dataset.scesColumn).toBe(
"personal-video-after-search-rate"
);
}); });
test("does not duplicate injected columns when synced twice", () => { test("renders loading, success, and failed states", () => {
const document = createDocument(); const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
syncMarketTable(document); const alphaRow = table.rows[0];
syncMarketTable(document); const betaRow = table.rows[1];
expect(document.querySelectorAll('[data-sces-column="single-video-after-search-rate"]')).toHaveLength(2); renderMarketRowState(alphaRow, {
expect(document.querySelectorAll('[data-sces-column="personal-video-after-search-rate"]')).toHaveLength(2); authorId: "a",
expect(document.querySelectorAll('[data-sces-header="single-video-after-search-rate"]')).toHaveLength(1); authorName: "Alpha",
expect(document.querySelectorAll('[data-sces-header="personal-video-after-search-rate"]')).toHaveLength(1); status: "loading"
});
renderMarketRowState(betaRow, {
authorId: "b",
authorName: "Beta",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
}); });
test("supports the div-based market grid used by the real page", () => { expect(alphaRow.singleCell.textContent).toBe("加载中...");
const document = createDivGridDocument(); expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
renderMarketRowState(betaRow, {
authorId: "b",
authorName: "Beta",
status: "failed"
});
expect(betaRow.singleCell.textContent).toBe("加载失败");
expect(betaRow.personalCell.textContent).toBe("加载失败");
});
test("hides rows outside the visible author ids", () => {
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
applyRowVisibility(table, new Set(["b"]));
expect(table.rows[0].row.hidden).toBe(true);
expect(table.rows[1].row.hidden).toBe(false);
});
test("reorders rows based on ordered author ids", () => {
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
applyRowOrder(table, ["b", "a"]);
expect(
Array.from(
document.querySelectorAll("[data-market-row]")
).map((row) => row.getAttribute("data-author-id"))
).toEqual(["b", "a"]);
});
test("supports the real div-grid market layout and keeps rows aligned", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const table = syncMarketTable(document); const table = syncMarketTable(document);
const headerTexts = Array.from( if (!table) {
document.querySelectorAll('[data-testid="right-header"] > *'), throw new Error("Expected market table");
(cell) => cell.textContent?.trim() ?? "" }
);
const rightColumns = Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column')
);
const firstRowTexts = rightColumns.map(
(column) =>
column.querySelectorAll(".content-cell")[0]?.textContent?.trim() ?? ""
);
expect(table).not.toBeNull(); expect(readRightHeaderTexts()).toEqual([
expect(headerTexts).toEqual([
"21-60s报价", "21-60s报价",
"单视频看后搜率", "单视频看后搜率",
"个人视频看后搜率", "个人视频看后搜率",
"操作" "操作"
]); ]);
expect(firstRowTexts).toEqual([ expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
"¥70,000",
"", renderMarketRowState(table.rows[0], {
"", authorId: "111",
authorName: "达人 A",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
});
expect(readRightRowTexts(0)).toEqual([
"¥450,000",
"0.5% - 1%",
"0.02% - 0.1%",
"下单" "下单"
]); ]);
expect(table?.rows[0].singleCell.dataset.scesColumn).toBe(
"single-video-after-search-rate" applyRowVisibility(table, new Set(["222"]));
);
expect(table?.rows[0].personalCell.dataset.scesColumn).toBe( expect(readAuthorRowHiddenStates()).toEqual([true, false]);
"personal-video-after-search-rate" expect(readRightActionHiddenStates()).toEqual([true, false]);
applyRowVisibility(table, new Set(["111", "222"]));
applyRowOrder(table, ["222", "111"]);
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]);
expect(table.rows[0].exportFields).toMatchObject({
"21-60s报价": "¥450,000",
"代表视频": "代表视频A",
"达人信息": "达人 A"
});
});
test("falls back to the market vue state when the DOM has no author id", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
attachMarketVueState([
{
attribute_datas: {
nickname: "达人 A"
},
star_id: "111"
},
{
attribute_datas: {
nickname: "达人 B"
},
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
});
test("falls back to serialized market rows when vue state is unavailable", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
document.documentElement.setAttribute(
"data-sces-market-rows",
JSON.stringify([
{
authorId: "111",
authorName: "达人 A",
singleVideoAfterSearchRate: "0.02%"
},
{
authorId: "222",
authorName: "达人 B"
}
])
); );
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
expect(table.rows[0].rates).toEqual({
singleVideoAfterSearchRate: "0.02%"
});
});
test("finds the real next-page button in Xingtu pagination", () => {
document.body.innerHTML = `
${buildRealMarketGridFixture()}
<div class="pagination xt-space xt-space--medium">
<div class="el-pagination is-background xt-pagination xt-pagination--normal">
<button type="button" disabled="disabled" class="btn-prev">
<i class="el-icon el-icon-arrow-left"></i>
</button>
<ul class="el-pager">
<li class="number active">1</li>
<li class="number">2</li>
</ul>
<button type="button" class="btn-next">
<i class="el-icon el-icon-arrow-right"></i>
</button>
</div>
</div>
`;
const nextControl = findNextPageControl(document);
expect(nextControl).not.toBeNull();
expect(nextControl?.className).toContain("btn-next");
expect(isPageControlDisabled(nextControl)).toBe(false);
expect(readMarketPageSignature(document)).toContain("1::111|222");
}); });
}); });
function createDocument() { function buildRealMarketGridFixture() {
return new JSDOM(` return `
<table>
<thead>
<tr>
<th>达人信息</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>达人 A</td>
<td>查看</td>
</tr>
<tr>
<td>达人 B</td>
<td>查看</td>
</tr>
</tbody>
</table>
`).window.document;
}
function createDivGridDocument() {
return new JSDOM(`
<div class="base-author-list"> <div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar"> <div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;"> <div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
@@ -133,12 +261,12 @@ function createDivGridDocument() {
</div> </div>
</div> </div>
<div class="section-wrapper hide-scrollbar"> <div class="section-wrapper hide-scrollbar">
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;"> <div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;"> <div class="content-column" style="min-width: 310px;">
<div class="content-cell" style="height: 120px;"> <div class="content-cell" data-testid="author-cell-111" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a> <a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
</div> </div>
<div class="content-cell" style="height: 120px;"> <div class="content-cell" data-testid="author-cell-222" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a> <a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a>
</div> </div>
</div> </div>
@@ -151,15 +279,109 @@ function createDivGridDocument() {
</div> </div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;"> <div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;"> <div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥70,000</div> <div class="content-cell" style="height: 120px;">¥450,000</div>
<div class="content-cell" style="height: 120px;">¥45,000</div> <div class="content-cell" style="height: 120px;">¥20,000</div>
</div> </div>
<div class="content-column" style="min-width: 200px;"> <div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;">下单</div> <div class="content-cell" data-testid="action-cell-111" style="height: 120px;">下单</div>
<div class="content-cell" style="height: 120px;">下单</div> <div class="content-cell" data-testid="action-cell-222" style="height: 120px;">下单</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`).window.document; `;
}
function buildRealMarketGridFixtureWithoutAuthorIds() {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" data-testid="author-cell-1" style="height: 120px;">
<span class="author-nickname">达人 A</span>
</div>
<div class="content-cell" data-testid="author-cell-2" style="height: 120px;">
<span class="author-nickname">达人 B</span>
</div>
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥450,000</div>
<div class="content-cell" style="height: 120px;">¥20,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" data-testid="action-cell-1" style="height: 120px;">下单</div>
<div class="content-cell" data-testid="action-cell-2" style="height: 120px;">下单</div>
</div>
</div>
</div>
</div>
`;
}
function attachMarketVueState(
marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }>
) {
const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) {
throw new Error("Expected market root");
}
Object.defineProperty(marketRoot, "__vue__", {
configurable: true,
value: {
_setupState: {
__$temp_1: {
marketList
}
}
}
});
}
function readRightHeaderTexts() {
return Array.from(
document.querySelectorAll('[data-testid="right-header"] > *'),
(cell) => cell.textContent?.trim() ?? ""
);
}
function readRightRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
);
}
function readAuthorNames() {
return Array.from(
document.querySelectorAll('[data-testid="author-section"] .content-cell a'),
(link) => link.textContent?.trim() ?? ""
);
}
function readAuthorRowHiddenStates() {
return Array.from(
document.querySelectorAll('[data-testid^="author-cell-"]'),
(cell) => (cell as HTMLElement).hidden
);
}
function readRightActionHiddenStates() {
return Array.from(
document.querySelectorAll('[data-testid^="action-cell-"]'),
(cell) => (cell as HTMLElement).hidden
);
} }
-57
View File
@@ -1,57 +0,0 @@
import { JSDOM } from "jsdom";
import { describe, expect, test } from "vitest";
import { extractAuthorIdFromRow } from "../src/content/market/id-extractor";
import { createListSignature } from "../src/content/market/list-signature";
describe("market id extractor", () => {
test("extracts authorId from a detail link inside the row", () => {
const row = createRow(
'<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/6629661559960371207">达人详情</a>'
);
expect(extractAuthorIdFromRow(row)).toEqual({
authorId: "6629661559960371207",
source: "detail-link",
success: true
});
});
test("extracts authorId from fallback row attributes", () => {
const row = createRow(
'<button data-author-id="7312345678901234567">查看</button>'
);
expect(extractAuthorIdFromRow(row)).toEqual({
authorId: "7312345678901234567",
source: "attribute",
success: true
});
});
test("returns an explicit missing-author-id result when no stable source exists", () => {
const row = createRow('<span>没有达人 id</span>');
expect(extractAuthorIdFromRow(row)).toEqual({
authorId: null,
reason: "missing-author-id",
success: false
});
});
test("builds a deterministic list signature from authorIds and url state", () => {
expect(
createListSignature({
authorIds: ["111", "222", "333"],
url: "https://xingtu.cn/ad/creator/market?page=2&keyword=test"
})
).toBe("/ad/creator/market?page=2&keyword=test::111,222,333");
});
});
function createRow(innerHtml: string) {
const dom = new JSDOM(
`<table><tbody><tr><td>${innerHtml}</td></tr></tbody></table>`
);
return dom.window.document.querySelector("tr") as HTMLTableRowElement;
}
-86
View File
@@ -1,86 +0,0 @@
import { JSDOM } from "jsdom";
import { describe, expect, test, vi } from "vitest";
import { syncMarketTable } from "../src/content/market/dom-sync";
import { renderMarketRowState } from "../src/content/market/row-render";
describe("market row render", () => {
test("renders the loading state for both injected cells", () => {
const rows = getRows();
renderMarketRowState(rows[0], {
authorId: "111",
listSeq: 1,
state: "loading"
});
expect(rows[0].singleCell.textContent).toBe("加载中...");
expect(rows[0].personalCell.textContent).toBe("加载中...");
expect(rows[0].singleCell.dataset.scesAuthorId).toBe("111");
expect(rows[0].singleCell.dataset.scesListSeq).toBe("1");
});
test("renders success values for both injected cells", () => {
const rows = getRows();
renderMarketRowState(rows[0], {
authorId: "111",
listSeq: 1,
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%",
source: "network",
state: "success"
});
expect(rows[0].singleCell.textContent).toBe("<0.02%");
expect(rows[0].personalCell.textContent).toBe("0.02% - 0.1%");
});
test("renders a retryable error state without per-cell divergence", () => {
const onRetry = vi.fn();
const rows = getRows();
renderMarketRowState(
rows[0],
{
authorId: "111",
listSeq: 2,
reason: "request-failed",
retryable: true,
state: "error"
},
{ onRetry }
);
rows[0].personalCell.dispatchEvent(
new rows[0].row.ownerDocument.defaultView!.MouseEvent("click", {
bubbles: true
})
);
expect(rows[0].singleCell.textContent).toBe("加载失败");
expect(rows[0].personalCell.textContent).toBe("加载失败");
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
function getRows() {
const document = new JSDOM(`
<table>
<thead>
<tr>
<th>达人信息</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr>
<td>达人 A</td>
<td>查看</td>
</tr>
</tbody>
</table>
`).window.document;
return syncMarketTable(document)!.rows;
}
-324
View File
@@ -1,324 +0,0 @@
import { JSDOM } from "jsdom";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { installPageHook } from "../src/page/hook";
import {
CANDIDATE_ANALYSIS_MESSAGE_TYPE,
CANDIDATE_REQUEST_MESSAGE_TYPE,
EXTENSION_MESSAGE_SOURCE,
HOOK_READY_MESSAGE_TYPE,
RESULT_MESSAGE_TYPE
} from "../src/shared/message-types";
import type { ExtractAfterSearchRatesResult } from "../src/shared/result-types";
describe("page hook", () => {
let dom: JSDOM;
beforeEach(() => {
vi.useFakeTimers();
dom = new JSDOM("", {
url: "https://xingtu.cn/ad/creator/author-homepage/douyin-video/6629661559960371207"
});
});
afterEach(() => {
dom.window.close();
vi.useRealTimers();
});
test("reads fetch bodies through response.clone() and leaves the original response intact", async () => {
const payload = JSON.stringify({
metrics: {
personal_video_after_search_rate: "0.5% - 1%",
single_video_after_search_rate: "1% - 2%"
}
});
const response = new Response(payload, {
headers: { "content-type": "application/json" },
status: 200
});
const cloneSpy = vi.spyOn(response, "clone");
const originalFetch = vi.fn().mockResolvedValue(response);
const postMessage = vi.fn();
installPageHook({
extractAfterSearchRates: () => successExtraction(),
postMessage,
timeoutMs: 5_000,
window: createHookWindow(dom.window, originalFetch)
});
const returnedResponse = await dom.window.fetch("https://api.xingtu.cn/creator/metrics");
await vi.runAllTimersAsync();
expect(cloneSpy).toHaveBeenCalledTimes(1);
expect(await returnedResponse.text()).toBe(payload);
});
test("keeps requests alive if extraction throws", async () => {
const response = new Response(JSON.stringify({ ok: true }), {
headers: { "content-type": "application/json" },
status: 200
});
const originalFetch = vi.fn().mockResolvedValue(response);
installPageHook({
extractAfterSearchRates: () => {
throw new Error("boom");
},
postMessage: vi.fn(),
timeoutMs: 5_000,
window: createHookWindow(dom.window, originalFetch)
});
const returnedResponse = await dom.window.fetch("https://api.xingtu.cn/creator/metrics");
expect(returnedResponse.status).toBe(200);
expect(await returnedResponse.json()).toEqual({ ok: true });
});
test("posts a structured success result when extraction succeeds", async () => {
const response = new Response(JSON.stringify({ any: "payload" }), {
headers: { "content-type": "application/json" },
status: 200
});
const postMessage = vi.fn();
installPageHook({
extractAfterSearchRates: () => successExtraction(),
postMessage,
timeoutMs: 5_000,
window: createHookWindow(dom.window, vi.fn().mockResolvedValue(response))
});
await dom.window.fetch("https://api.xingtu.cn/creator/metrics");
await vi.runAllTimersAsync();
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
matchedRequestUrl: "https://api.xingtu.cn/creator/metrics",
pageStarId: "6629661559960371207",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1",
stage: "captured",
success: true
}),
source: EXTENSION_MESSAGE_SOURCE,
type: RESULT_MESSAGE_TYPE
}),
"*"
);
});
test("posts one timeout result if no successful capture appears", async () => {
const postMessage = vi.fn();
installPageHook({
extractAfterSearchRates: () => unmatchedExtraction(),
postMessage,
timeoutMs: 100,
window: createHookWindow(dom.window, vi.fn())
});
await vi.advanceTimersByTimeAsync(100);
expect(postMessage).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
payload: expect.objectContaining({
reason: "Timed out waiting for after-search-rate capture",
stage: "timeout",
success: false
}),
source: EXTENSION_MESSAGE_SOURCE,
type: RESULT_MESSAGE_TYPE
}),
"*"
);
});
test("announces hook-ready immediately after installation", () => {
const postMessage = vi.fn();
installPageHook({
extractAfterSearchRates: () => unmatchedExtraction(),
postMessage,
timeoutMs: 5_000,
window: createHookWindow(dom.window, vi.fn())
});
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
pageStarId: "6629661559960371207",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1"
}),
source: EXTENSION_MESSAGE_SOURCE,
type: HOOK_READY_MESSAGE_TYPE
}),
"*"
);
});
test("emits a candidate-request diagnostic before extraction", async () => {
const response = new Response(JSON.stringify({ any: "payload" }), {
headers: { "content-type": "application/json" },
status: 200
});
const postMessage = vi.fn();
installPageHook({
extractAfterSearchRates: () => unmatchedExtraction(),
postMessage,
timeoutMs: 5_000,
window: createHookWindow(dom.window, vi.fn().mockResolvedValue(response))
});
await dom.window.fetch("https://api.xingtu.cn/creator/value");
await vi.runAllTimersAsync();
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
requestMethod: "GET",
requestUrl: "https://api.xingtu.cn/creator/value",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1",
status: 200
}),
source: EXTENSION_MESSAGE_SOURCE,
type: CANDIDATE_REQUEST_MESSAGE_TYPE
}),
"*"
);
});
test("emits a candidate-analysis diagnostic after evaluating a candidate response", async () => {
const response = new Response(
JSON.stringify({
data: {
avg_search_after_view_rate: 0.0015,
avg_search_after_view_rate_rank_percent: 0.9
},
status_code: 0
}),
{
headers: { "content-type": "application/json" },
status: 200
}
);
const postMessage = vi.fn();
installPageHook({
extractAfterSearchRates: () => unmatchedExtraction(),
postMessage,
timeoutMs: 5_000,
window: createHookWindow(dom.window, vi.fn().mockResolvedValue(response))
});
await dom.window.fetch("https://api.xingtu.cn/creator/value");
await vi.runAllTimersAsync();
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
extractorLevel: "none",
matched: false,
requestUrl: "https://api.xingtu.cn/creator/value",
routeKey:
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::1",
signalEntries: [
"$.data.avg_search_after_view_rate=0.0015",
"$.data.avg_search_after_view_rate_rank_percent=0.9"
],
success: false,
topLevelKeys: ["data", "status_code"]
}),
source: EXTENSION_MESSAGE_SOURCE,
type: CANDIDATE_ANALYSIS_MESSAGE_TYPE
}),
"*"
);
});
test("prevents duplicate patching", async () => {
const response = new Response(JSON.stringify({ any: "payload" }), {
headers: { "content-type": "application/json" },
status: 200
});
const fetchSpy = vi.fn().mockResolvedValue(response);
const postMessage = vi.fn();
const hookWindow = createHookWindow(dom.window, fetchSpy);
const firstInstall = installPageHook({
extractAfterSearchRates: () => successExtraction(),
postMessage,
timeoutMs: 5_000,
window: hookWindow
});
const secondInstall = installPageHook({
extractAfterSearchRates: () => successExtraction(),
postMessage,
timeoutMs: 5_000,
window: hookWindow
});
await dom.window.fetch("https://api.xingtu.cn/creator/metrics");
await vi.runAllTimersAsync();
expect(firstInstall.alreadyInstalled).toBe(false);
expect(secondInstall.alreadyInstalled).toBe(true);
expect(postMessage).toHaveBeenCalledTimes(4);
});
});
function createHookWindow(window: Window, fetchImpl: typeof fetch) {
Object.defineProperty(window, "fetch", {
configurable: true,
value: fetchImpl,
writable: true
});
class FakeXMLHttpRequest {
addEventListener() {}
open() {}
send() {}
}
Object.defineProperty(window, "XMLHttpRequest", {
configurable: true,
value: FakeXMLHttpRequest,
writable: true
});
return window as Window &
typeof globalThis & {
XMLHttpRequest: typeof FakeXMLHttpRequest;
fetch: typeof fetch;
};
}
function successExtraction(): ExtractAfterSearchRatesResult {
return {
extractorLevel: "label-value",
matched: true,
rawPathHints: ["$.metrics[0]"],
rates: {
personalVideoAfterSearchRate: "0.5% - 1%",
singleVideoAfterSearchRate: "1% - 2%"
},
success: true
};
}
function unmatchedExtraction(): ExtractAfterSearchRatesResult {
return {
extractorLevel: "none",
matched: false,
rawPathHints: [],
reason: "No signals found",
success: false
};
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, test } from "vitest";
import {
compareRateValues,
normalizeFractionRateDisplay,
normalizeRateDisplay,
parseRateLowerBound
} from "../src/shared/rate-normalizer";
describe("rate-normalizer", () => {
test("normalizes compact ranges", () => {
expect(normalizeRateDisplay("0.5%-1%")).toBe("0.5% - 1%");
});
test("normalizes ranges with missing percent on the lower bound", () => {
expect(normalizeRateDisplay("0.02 - 0.1%")).toBe("0.02% - 0.1%");
});
test("parses the lower bound of a range", () => {
expect(parseRateLowerBound("0.02% - 0.1%")).toBe(0.02);
});
test("treats less-than values as smaller than the boundary range", () => {
expect(compareRateValues("<0.02%", "0.02% - 0.1%")).toBeLessThan(0);
});
test("orders missing values after real values", () => {
expect(compareRateValues(null, "0.02% - 0.1%")).toBeGreaterThan(0);
});
test("normalizes fraction rate values into percentage display", () => {
expect(normalizeFractionRateDisplay("0.0002")).toBe("0.02%");
expect(normalizeFractionRateDisplay("0.0044")).toBe("0.44%");
expect(normalizeFractionRateDisplay("0")).toBe("0%");
});
});
-28
View File
@@ -1,28 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const readmePath = path.resolve(__dirname, "..", "README.md");
describe("README", () => {
test("documents setup, build, detail verification, and market-page verification", async () => {
const readme = await readFile(readmePath, "utf8");
expect(readme).toContain("npm install");
expect(readme).toContain("npm test");
expect(readme).toContain("npm run build");
expect(readme).toContain("chrome://extensions");
expect(readme).toContain("dist/");
expect(readme).toContain("DevTools Console");
expect(readme).toContain("达人详情页");
expect(readme).toContain("creator/market");
expect(readme).toContain("单视频看后搜率");
expect(readme).toContain("个人视频看后搜率");
expect(readme).toContain("加载中...");
expect(readme).toContain("加载失败");
expect(readme).toContain("点击任一失败单元格");
expect(readme).toContain("详情页控制台实验");
});
});
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, test } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store";
describe("result-store", () => {
test("creates loading records from current-page rows", () => {
const store = createMarketResultStore();
store.upsertMarketRow({
authorId: "123",
authorName: "Alice",
price21To60s: "450000"
});
store.setAuthorLoading("123");
expect(store.getRecord("123")).toMatchObject({
authorId: "123",
authorName: "Alice",
price21To60s: "450000",
status: "loading"
});
});
test("updates one author to success", () => {
const store = createMarketResultStore();
store.upsertMarketRow({
authorId: "123",
authorName: "Alice"
});
store.setAuthorSuccess("123", {
singleVideoAfterSearchRate: "<0.02%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
});
expect(store.getRecord("123")).toMatchObject({
status: "success",
rates: {
singleVideoAfterSearchRate: "<0.02%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
});
});
test("preserves failed authors instead of dropping them", () => {
const store = createMarketResultStore();
store.upsertMarketRow({
authorId: "123",
authorName: "Alice"
});
store.setAuthorFailed("123", "request-failed");
expect(store.listRecords()).toHaveLength(1);
expect(store.getRecord("123")).toMatchObject({
status: "failed",
failureReason: "request-failed"
});
});
test("dedupes the same author across repeated page writes", () => {
const store = createMarketResultStore();
store.upsertMarketRow({
authorId: "123",
authorName: "Alice",
price21To60s: "450000"
});
store.upsertMarketRow({
authorId: "123",
authorName: "Alice v2",
price21To60s: "470000"
});
expect(store.listRecords()).toHaveLength(1);
});
test("keeps the original major fields stable after repeated writes", () => {
const store = createMarketResultStore();
store.upsertMarketRow({
authorId: "123",
authorName: "Alice",
location: "Hangzhou",
price21To60s: "450000"
});
store.upsertMarketRow({
authorId: "123",
authorName: "Alice v2",
location: "Shanghai",
price21To60s: "470000"
});
expect(store.getRecord("123")).toMatchObject({
authorId: "123",
authorName: "Alice",
location: "Hangzhou",
price21To60s: "450000"
});
});
});
-27
View File
@@ -1,27 +0,0 @@
import { describe, expect, test } from "vitest";
import { createRouteKey } from "../src/shared/route-key";
describe("createRouteKey", () => {
test("uses the page star id when it exists", () => {
expect(
createRouteKey({
navigationSeq: 2,
pageStarId: "6629661559960371207",
pathname: "/ad/creator/author-homepage/douyin-video/6629661559960371207"
})
).toBe(
"6629661559960371207::/ad/creator/author-homepage/douyin-video/6629661559960371207::2"
);
});
test("falls back to unknown when the star id is absent", () => {
expect(
createRouteKey({
navigationSeq: 1,
pageStarId: null,
pathname: "/ad/creator/author-homepage/douyin-video/unknown"
})
).toBe("unknown::/ad/creator/author-homepage/douyin-video/unknown::1");
});
});
+6 -6
View File
@@ -1,16 +1,16 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022", "target": "ES2022",
"lib": ["DOM", "ES2022"],
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"strict": true, "strict": true,
"esModuleInterop": true, "noEmit": true,
"forceConsistentCasingInFileNames": true, "isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true, "esModuleInterop": true,
"lib": ["DOM", "ES2022"], "skipLibCheck": true,
"types": ["vitest/globals"] "types": ["vitest/globals"]
}, },
"include": ["src", "tests", "vitest.config.ts", "scripts/build.mjs"] "include": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
} }
+2 -1
View File
@@ -2,7 +2,8 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "jsdom", globals: true,
passWithNoTests: true,
include: ["tests/**/*.test.ts"] include: ["tests/**/*.test.ts"]
} }
}); });