chore: initial commit

This commit is contained in:
wangshaoqing 2026-04-15 15:17:42 +08:00
commit 714745bb36
30 changed files with 6826 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.DS_Store
.vscode/
*.log

33
README.md Normal file
View File

@ -0,0 +1,33 @@
# Star Chart Search Enhancer
一个最小化的 Chrome MV3 实验插件,用来在巨量星图的达人详情页拦截页面自己的网络响应,并尝试提取两个“看后搜率”指标。
## 开发命令
```bash
npm install
npm test
npm run build
```
## 加载插件
1. 先执行 `npm run build`
2. 打开 `chrome://extensions`
3. 打开“开发者模式”
4. 选择“加载已解压的扩展程序”
5. 选择本项目的 `dist/` 目录
## 手工验证
1. 打开巨量星图的达人详情页
2. 刷新页面一次,确保内容脚本和页面 hook 都能尽早注入
3. 打开该页面的 DevTools Console
4. 观察是否出现带有 `[star-chart-search-enhancer]` 前缀的日志
5. 找到 `result` 日志,核对其中两个看后搜率是否与达人详情页右侧展示一致
## 当前范围
- 只支持巨量星图达人详情页
- 只输出到控制台,不改页面 UI
- 成功时输出结构化结果,超时也会输出一个明确失败结果

View File

@ -0,0 +1,278 @@
# 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

View File

@ -0,0 +1,409 @@
# 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

View File

@ -0,0 +1,568 @@
# 星图达人详情页看后搜率插件实验设计
## 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` 或无关候选响应,下一步应优先验证“数据是否根本不走网络响应体”这一前提,而不是继续扩大提取规则范围。

View File

@ -0,0 +1,623 @@
# 星图找达人列表页看后搜率列增强设计(阶段 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 更新
- 需要显式处理列表变化后的陈旧结果抑制
### 方案 2DOM 增强 + 页面上下文请求桥
仍然做 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 就具备较高可行性。

2564
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "star-chart-search-enhancer",
"private": true,
"type": "module",
"scripts": {
"build": "node scripts/build.mjs",
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"jsdom": "^26.1.0",
"tsup": "^8.5.0",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
}
}

33
scripts/build.mjs Normal file
View File

@ -0,0 +1,33 @@
import { copyFile, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { build } from "tsup";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, "..");
const distDir = path.join(projectRoot, "dist");
await rm(distDir, { force: true, recursive: true });
await build({
clean: false,
dts: false,
entry: {
"content/index": path.join(projectRoot, "src/content/index.ts"),
"page/hook": path.join(projectRoot, "src/page/hook.ts")
},
format: ["iife"],
minify: false,
outDir: distDir,
platform: "browser",
silent: true,
splitting: false,
target: "es2022",
treeshake: false
});
await mkdir(distDir, { recursive: true });
await copyFile(
path.join(projectRoot, "src/manifest.json"),
path.join(distDir, "manifest.json")
);

212
src/content/index.ts Normal file
View File

@ -0,0 +1,212 @@
import { createRouteState } from "./route-state";
import {
isCandidateAnalysisMessage,
isCandidateRequestMessage,
isHookReadyMessage,
isAfterSearchRateResultMessage,
RESULT_MESSAGE_TYPE
} 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;
}
interface ContentControllerOptions {
chromeRuntime: ChromeRuntimeLike;
document: Document;
logger: LoggerLike;
window: Window;
}
export function createContentController(options: ContentControllerOptions) {
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);
}
function bootstrapContentScript() {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (!runtime || typeof window === "undefined" || typeof document === "undefined") {
return;
}
const marker = "__starChartSearchEnhancerContentController";
const scopedWindow = window as Window & {
[marker]?: ReturnType<typeof createContentController>;
};
if (scopedWindow[marker]) {
return;
}
scopedWindow[marker] = createContentController({
chromeRuntime: runtime,
document,
logger: console,
window
});
}
bootstrapContentScript();
export { RESULT_MESSAGE_TYPE };

View File

@ -0,0 +1,40 @@
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
};
}

19
src/manifest.json Normal file
View File

@ -0,0 +1,19 @@
{
"manifest_version": 3,
"name": "Star Chart Search Enhancer",
"version": "0.0.1",
"description": "Experimentally capture after-search rates on Xingtu creator detail pages.",
"content_scripts": [
{
"matches": ["https://*.xingtu.cn/ad/creator/author-homepage/*"],
"js": ["content/index.global.js"],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": ["page/hook.global.js"],
"matches": ["https://*.xingtu.cn/*"]
}
]
}

459
src/page/hook.ts Normal file
View File

@ -0,0 +1,459 @@
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();

View File

@ -0,0 +1,31 @@
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("[");
}

View File

@ -0,0 +1,299 @@
import { normalizeRateLabel } from "./normalize-rate-label";
import type {
AfterSearchRateField,
AfterSearchRates,
ExtractAfterSearchRatesResult
} from "./result-types";
type JsonRecord = Record<string, unknown>;
const RATE_RANGE_PATTERN = /\d+(?:\.\d+)?%\s*-\s*\d+(?:\.\d+)?%/;
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 isRateValue(value: string): boolean {
return normalizeRateValue(value) !== null;
}
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}%`;
}
if (RATE_RANGE_PATTERN.test(trimmedValue)) {
return trimmedValue.replace(/\s*-\s*/, "% - ").replace(/%%/g, "%");
}
return null;
}
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);
});
}

View File

@ -0,0 +1,7 @@
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
src/shared/message-types.ts Normal file
View File

@ -0,0 +1,139 @@
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;
}

View File

@ -0,0 +1,28 @@
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;
}

View File

@ -0,0 +1,39 @@
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
src/shared/route-key.ts Normal file
View File

@ -0,0 +1,8 @@
export function createRouteKey(input: {
navigationSeq: number;
pageStarId: string | null;
pathname: string;
}): string {
const routeStarId = input.pageStarId ?? "unknown";
return `${routeStarId}::${input.pathname}::${input.navigationSeq}`;
}

View File

@ -0,0 +1,50 @@
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 page", 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/*"]
})
])
);
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);
});
});

View File

@ -0,0 +1,371 @@
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");
}

View File

@ -0,0 +1,62 @@
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();
});
});
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()
};
}

View File

@ -0,0 +1,120 @@
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
});
});
});

17
tests/get-star-id.test.ts Normal file
View File

@ -0,0 +1,17 @@
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();
});
});

324
tests/page-hook.test.ts Normal file
View File

@ -0,0 +1,324 @@
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
};
}

21
tests/readme.test.ts Normal file
View File

@ -0,0 +1,21 @@
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, and manual 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("达人详情页");
});
});

27
tests/route-key.test.ts Normal file
View File

@ -0,0 +1,27 @@
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");
});
});

16
tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"lib": ["DOM", "ES2022"],
"types": ["vitest/globals"]
},
"include": ["src", "tests", "vitest.config.ts", "scripts/build.mjs"]
}

8
vitest.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
include: ["tests/**/*.test.ts"]
}
});