From 55324a5bb7378a7cd2049df0e8df14f327a7d405 Mon Sep 17 00:00:00 2001 From: admin123 Date: Tue, 21 Apr 2026 14:16:29 +0800 Subject: [PATCH] feat: stabilize Xingtu market data sync --- ...t-after-search-rate-implementation-plan.md | 800 ------------------ ...rt-market-after-search-rate-plugin-spec.md | 464 ---------- scripts/build.mjs | 6 +- src/content/index.ts | 71 +- src/content/market/api-client.ts | 78 +- src/content/market/dom-sync.ts | 618 +++++++++++++- src/content/market/full-scan-controller.ts | 17 +- src/content/market/index.ts | 229 ++++- src/content/market/page-bridge.ts | 130 +++ src/content/market/result-store.ts | 15 +- src/content/market/types.ts | 3 +- src/manifest.json | 14 +- src/shared/rate-normalizer.ts | 14 + tests/full-scan-controller.test.ts | 57 ++ tests/manifest.test.ts | 13 + tests/market-api-client.test.ts | 77 ++ tests/market-content-entry.test.ts | 664 ++++++++++++++- tests/market-dom-sync.test.ts | 239 ++++++ tests/rate-normalizer.test.ts | 7 + 19 files changed, 2150 insertions(+), 1366 deletions(-) delete mode 100644 externaldocs/2026-04-20-star-chart-market-after-search-rate-implementation-plan.md delete mode 100644 externaldocs/2026-04-20-star-chart-market-after-search-rate-plugin-spec.md create mode 100644 src/content/market/page-bridge.ts create mode 100644 tests/manifest.test.ts diff --git a/externaldocs/2026-04-20-star-chart-market-after-search-rate-implementation-plan.md b/externaldocs/2026-04-20-star-chart-market-after-search-rate-implementation-plan.md deleted file mode 100644 index a89b1da..0000000 --- a/externaldocs/2026-04-20-star-chart-market-after-search-rate-implementation-plan.md +++ /dev/null @@ -1,800 +0,0 @@ -# Star Chart Market 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 new Chrome MV3 extension that adds two after-search-rate columns to the Xingtu creator market page, then supports plugin-owned filtering, sorting, full-scan export, and CSV download. - -**Architecture:** Start from a minimal MV3 content-script extension and keep the implementation split into small modules: value normalization, API mapping, result storage, DOM sync, full-scan orchestration, filter/sort control, and CSV export. Follow TDD for every production behavior module; only project bootstrap and config files may be created directly as setup. - -**Tech Stack:** TypeScript, Chrome Manifest V3, Vitest, jsdom, tsup, Node.js - ---- - -## File Structure - -Planned initial structure: - -```text -package.json -package-lock.json -tsconfig.json -vitest.config.ts -scripts/ - build.mjs -src/ - manifest.json - content/ - index.ts - market/ - index.ts - types.ts - dom-sync.ts - api-client.ts - result-store.ts - filter-sort-controller.ts - full-scan-controller.ts - csv-exporter.ts - plugin-toolbar.ts - shared/ - rate-normalizer.ts - csv.ts -tests/ - rate-normalizer.test.ts - market-api-client.test.ts - result-store.test.ts - filter-sort-controller.test.ts - market-dom-sync.test.ts - full-scan-controller.test.ts - csv-exporter.test.ts - market-content-entry.test.ts -``` - -Responsibilities: - -- `src/shared/rate-normalizer.ts`: parse and normalize display values and comparable lower-bound values -- `src/content/market/api-client.ts`: fetch and map `/gw/api/aggregator/get_author_ase_info` -- `src/content/market/result-store.ts`: hold per-author status and merged market records -- `src/content/market/filter-sort-controller.ts`: apply threshold filters and lower-bound sorting -- `src/content/market/dom-sync.ts`: locate market rows, inject two columns, and update row visibility/order -- `src/content/market/full-scan-controller.ts`: paginate through filtered result pages and hydrate the result store -- `src/content/market/csv-exporter.ts`: generate CSV rows and blob/download metadata -- `src/content/market/plugin-toolbar.ts`: render the plugin controls for threshold filter, sorting, and export -- `src/content/market/index.ts`: compose all market modules for the live page -- `src/content/index.ts`: route-match and boot the market controller - -## Task 1: Initialize the Empty Project - -**Files:** -- Create: `package.json` -- Create: `tsconfig.json` -- Create: `vitest.config.ts` -- Create: `scripts/build.mjs` -- Create: `src/manifest.json` -- Create: `src/content/index.ts` - -- [ ] **Step 1: Initialize the repository and npm package** - -Run: - -```bash -git init -npm init -y -``` - -Expected: a new `.git/` directory and baseline `package.json`. - -- [ ] **Step 2: Add build and test dependencies** - -Run: - -```bash -npm install -D typescript vitest jsdom tsup -``` - -Expected: `package-lock.json` created and dev dependencies added. - -- [ ] **Step 3: Write the minimal config and manifest files** - -Create direct setup files: - -- `package.json` scripts: `build`, `test`, `test:watch` -- `tsconfig.json` -- `vitest.config.ts` -- `scripts/build.mjs` -- `src/manifest.json` -- `src/content/index.ts` - -The initial manifest should only match the Xingtu market page and load one content entry bundle. - -- [ ] **Step 4: Run the test command to verify the toolchain is wired** - -Run: - -```bash -npm test -``` - -Expected: Vitest runs with zero or pending test files, without config errors. - -- [ ] **Step 5: Run the build command to verify the extension bundle layout** - -Run: - -```bash -npm run build -``` - -Expected: build succeeds and writes a minimal `dist/` layout containing the manifest and content bundle. - -- [ ] **Step 6: Commit the bootstrap** - -Run: - -```bash -git add package.json package-lock.json tsconfig.json vitest.config.ts scripts/build.mjs src/manifest.json src/content/index.ts -git commit -m "chore: bootstrap mv3 extension project" -``` - -## Task 2: Implement Rate Normalization - -**Files:** -- Create: `src/shared/rate-normalizer.ts` -- Test: `tests/rate-normalizer.test.ts` - -- [ ] **Step 1: Write the failing tests for normalization and comparable lower bounds** - -Add tests covering: - -```ts -import { describe, expect, test } from "vitest"; -import { - compareRateValues, - normalizeRateDisplay, - parseRateLowerBound -} from "../src/shared/rate-normalizer"; - -describe("rate-normalizer", () => { - test("normalizes compact ranges", () => { - expect(normalizeRateDisplay("0.5%-1%")).toBe("0.5% - 1%"); - }); - - test("parses the lower bound of a range", () => { - expect(parseRateLowerBound("0.02% - 0.1%")).toBe(0.02); - }); - - test("treats less-than values as smaller than the boundary range", () => { - expect(compareRateValues("<0.02%", "0.02% - 0.1%")).toBeLessThan(0); - }); -}); -``` - -- [ ] **Step 2: Run the rate normalizer test file and verify RED** - -Run: - -```bash -npm test -- tests/rate-normalizer.test.ts -``` - -Expected: FAIL because the module does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement only: - -- display normalization for compact range strings -- lower-bound parsing -- comparison helper that orders invalid or missing values last - -- [ ] **Step 4: Run the test file and verify GREEN** - -Run: - -```bash -npm test -- tests/rate-normalizer.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Refactor for one parsing pipeline** - -Keep parsing logic in one shared path so filtering, sorting, and export do not interpret values differently. - -- [ ] **Step 6: Re-run the test file after refactor** - -Run: - -```bash -npm test -- tests/rate-normalizer.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/shared/rate-normalizer.ts tests/rate-normalizer.test.ts -git commit -m "feat: add rate normalization helpers" -``` - -## Task 3: Implement the ASE API Client - -**Files:** -- Create: `src/content/market/api-client.ts` -- Create: `src/content/market/types.ts` -- Test: `tests/market-api-client.test.ts` - -- [ ] **Step 1: Write the failing tests for response mapping and failure states** - -Cover: - -- successful mapping from `avg_search_after_view_rate` -- successful mapping from `personal_avg_search_after_view_rate` -- `missing` or `failed` behavior when fields are absent -- timeout or non-OK response failure mapping - -Example test seed: - -```ts -test("maps a valid ASE payload into normalized rates", async () => { - const client = createMarketApiClient({ - fetchImpl: async () => ({ - ok: true, - json: async () => ({ - data: { - avg_search_after_view_rate: "<0.02%", - personal_avg_search_after_view_rate: "0.02 - 0.1%" - } - }) - }) - }); - - await expect(client.loadAuthorAseInfo("123")).resolves.toMatchObject({ - success: true, - rates: { - singleVideoAfterSearchRate: "<0.02%", - personalVideoAfterSearchRate: "0.02% - 0.1%" - } - }); -}); -``` - -- [ ] **Step 2: Run the API client test file and verify RED** - -Run: - -```bash -npm test -- tests/market-api-client.test.ts -``` - -Expected: FAIL because the client module does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement: - -- request builder for `/gw/api/aggregator/get_author_ase_info` -- response mapping -- timeout handling -- normalized success result and stable failure reasons - -- [ ] **Step 4: Run the API client tests and verify GREEN** - -Run: - -```bash -npm test -- tests/market-api-client.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Refactor shared types** - -Move reusable market result types into `src/content/market/types.ts`. - -- [ ] **Step 6: Re-run the API client tests** - -Run: - -```bash -npm test -- tests/market-api-client.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/content/market/api-client.ts src/content/market/types.ts tests/market-api-client.test.ts -git commit -m "feat: add ase api client" -``` - -## Task 4: Implement the Result Store - -**Files:** -- Create: `src/content/market/result-store.ts` -- Modify: `src/content/market/types.ts` -- Test: `tests/result-store.test.ts` - -- [ ] **Step 1: Write the failing tests for merged record lifecycle** - -Cover: - -- create loading records from current-page rows -- update one author to success -- preserve failed authors instead of dropping them -- dedupe the same author across pages -- keep stable major fields after repeated writes - -- [ ] **Step 2: Run the result store test file and verify RED** - -Run: - -```bash -npm test -- tests/result-store.test.ts -``` - -Expected: FAIL because the store module does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement: - -- `upsertMarketRow` -- `setAuthorLoading` -- `setAuthorSuccess` -- `setAuthorFailed` -- `listRecords` -- `getRecord` - -- [ ] **Step 4: Run the result store tests and verify GREEN** - -Run: - -```bash -npm test -- tests/result-store.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Refactor state transitions into one reducer-style path** - -Keep state transitions explicit so later full-scan orchestration does not spread status logic across files. - -- [ ] **Step 6: Re-run the result store tests** - -Run: - -```bash -npm test -- tests/result-store.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/content/market/result-store.ts src/content/market/types.ts tests/result-store.test.ts -git commit -m "feat: add market result store" -``` - -## Task 5: Implement Filter and Sort Control - -**Files:** -- Create: `src/content/market/filter-sort-controller.ts` -- Modify: `src/content/market/types.ts` -- Test: `tests/filter-sort-controller.test.ts` - -- [ ] **Step 1: Write the failing tests for threshold filtering and lower-bound sorting** - -Cover: - -- pass only when lower bound is greater than or equal to the threshold -- sort single-rate descending -- sort personal-rate ascending -- keep failed or missing rows at the end - -- [ ] **Step 2: Run the filter/sort tests and verify RED** - -Run: - -```bash -npm test -- tests/filter-sort-controller.test.ts -``` - -Expected: FAIL because the controller module does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement: - -- filter state type -- sort state type -- record predicate -- comparator -- application helper returning ordered visible record IDs - -- [ ] **Step 4: Run the filter/sort tests and verify GREEN** - -Run: - -```bash -npm test -- tests/filter-sort-controller.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Refactor duplicated comparison branches** - -Keep one comparison path per metric and one final fallback ordering rule. - -- [ ] **Step 6: Re-run the filter/sort tests** - -Run: - -```bash -npm test -- tests/filter-sort-controller.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/content/market/filter-sort-controller.ts src/content/market/types.ts tests/filter-sort-controller.test.ts -git commit -m "feat: add filter and sort controller" -``` - -## Task 6: Implement CSV Export - -**Files:** -- Create: `src/shared/csv.ts` -- Create: `src/content/market/csv-exporter.ts` -- Test: `tests/csv-exporter.test.ts` - -- [ ] **Step 1: Write the failing tests for CSV serialization** - -Cover: - -- header order -- escaped commas and quotes -- failed rows emitting empty rate fields plus status -- export rows using normalized display values - -- [ ] **Step 2: Run the CSV exporter tests and verify RED** - -Run: - -```bash -npm test -- tests/csv-exporter.test.ts -``` - -Expected: FAIL because the exporter module does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement: - -- CSV escaping helper -- column definition list -- row-to-CSV conversion -- blob-ready text generation function - -- [ ] **Step 4: Run the CSV exporter tests and verify GREEN** - -Run: - -```bash -npm test -- tests/csv-exporter.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Refactor field mapping into one declarative schema** - -Keep export field order and labels in one place. - -- [ ] **Step 6: Re-run the CSV exporter tests** - -Run: - -```bash -npm test -- tests/csv-exporter.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/shared/csv.ts src/content/market/csv-exporter.ts tests/csv-exporter.test.ts -git commit -m "feat: add csv exporter" -``` - -## Task 7: Implement Current-Page DOM Sync - -**Files:** -- Create: `src/content/market/dom-sync.ts` -- Test: `tests/market-dom-sync.test.ts` - -- [ ] **Step 1: Write the failing DOM tests for current-page enhancement** - -Use jsdom fixtures that mimic the Xingtu market grid and cover: - -- injecting the two header cells -- injecting one pair of per-row cells -- rendering loading, success, and failed states -- hiding filtered rows -- reordering rows based on an ordered ID list - -- [ ] **Step 2: Run the DOM sync tests and verify RED** - -Run: - -```bash -npm test -- tests/market-dom-sync.test.ts -``` - -Expected: FAIL because the module does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement: - -- page structure discovery -- row extraction with major field snapshots -- inserted-cell rendering -- row hide/show -- row order application - -- [ ] **Step 4: Run the DOM sync tests and verify GREEN** - -Run: - -```bash -npm test -- tests/market-dom-sync.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Refactor DOM selectors into one locator object** - -This keeps future layout changes isolated. - -- [ ] **Step 6: Re-run the DOM sync tests** - -Run: - -```bash -npm test -- tests/market-dom-sync.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/content/market/dom-sync.ts tests/market-dom-sync.test.ts -git commit -m "feat: add market dom sync" -``` - -## Task 8: Implement Full-Scan Pagination Control - -**Files:** -- Create: `src/content/market/full-scan-controller.ts` -- Modify: `src/content/market/types.ts` -- Test: `tests/full-scan-controller.test.ts` - -- [ ] **Step 1: Write the failing tests for on-demand full scans** - -Cover: - -- initial page load does not start full scan -- filter action starts full scan -- sort action starts full scan -- export action starts full scan -- repeated actions do not restart a completed scan unnecessarily -- failed author fetches are recorded but do not abort the whole scan - -- [ ] **Step 2: Run the full-scan tests and verify RED** - -Run: - -```bash -npm test -- tests/full-scan-controller.test.ts -``` - -Expected: FAIL because the controller module does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement the controller around injected dependencies: - -- current-page row reader -- paginator -- author metrics loader -- result store writer - -The controller should expose one explicit method per trigger source, such as: - -- `ensureScanForFilter()` -- `ensureScanForSort()` -- `ensureScanForExport()` - -- [ ] **Step 4: Run the full-scan tests and verify GREEN** - -Run: - -```bash -npm test -- tests/full-scan-controller.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Refactor to a single idempotent scan path** - -All trigger entry points should delegate to the same internal scan routine. - -- [ ] **Step 6: Re-run the full-scan tests** - -Run: - -```bash -npm test -- tests/full-scan-controller.test.ts -``` - -Expected: PASS. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/content/market/full-scan-controller.ts src/content/market/types.ts tests/full-scan-controller.test.ts -git commit -m "feat: add full scan controller" -``` - -## Task 9: Implement the Plugin Toolbar and Market Composition - -**Files:** -- Create: `src/content/market/plugin-toolbar.ts` -- Create: `src/content/market/index.ts` -- Modify: `src/content/index.ts` -- Test: `tests/market-content-entry.test.ts` - -- [ ] **Step 1: Write the failing integration tests for the page entry flow** - -Cover: - -- market controller boots on the Xingtu market URL -- current page rows are hydrated on start -- applying plugin filters triggers full scan and hides non-matching rows -- applying plugin sorting triggers full scan and reorders rows -- export triggers full scan and hands the ordered visible records to the CSV exporter - -- [ ] **Step 2: Run the market entry tests and verify RED** - -Run: - -```bash -npm test -- tests/market-content-entry.test.ts -``` - -Expected: FAIL because the market controller composition does not exist yet. - -- [ ] **Step 3: Write the minimal implementation** - -Implement: - -- toolbar controls for threshold inputs, sort selector, and export button -- event wiring from toolbar to full scan, filter/sort controller, and exporter -- market page bootstrap in `src/content/index.ts` - -- [ ] **Step 4: Run the market entry tests and verify GREEN** - -Run: - -```bash -npm test -- tests/market-content-entry.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Run the whole test suite** - -Run: - -```bash -npm test -``` - -Expected: all test files PASS. - -- [ ] **Step 6: Run the production build** - -Run: - -```bash -npm run build -``` - -Expected: extension build succeeds with all planned bundles and manifest output. - -- [ ] **Step 7: Commit** - -Run: - -```bash -git add src/content/index.ts src/content/market/index.ts src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts -git commit -m "feat: wire market plugin controls" -``` - -## Task 10: Manual Verification and Documentation - -**Files:** -- Modify: `externaldocs/2026-04-20-star-chart-market-after-search-rate-plugin-spec.md` (only if actual implementation forces scope adjustments) -- Create: `README.md` - -- [ ] **Step 1: Write a minimal README** - -Document: - -- install -- test -- build -- load unpacked extension -- manual verification checklist - -- [ ] **Step 2: Run manual verification on the live page** - -Verify: - -- current page gets two new columns -- loading, success, failed states render correctly -- filter triggers scan and hides unmatched rows -- sort triggers scan and reorders rows -- export produces a CSV with plugin status fields - -- [ ] **Step 3: Update the spec if implementation realities changed any promised behavior** - -Only adjust documented scope if the live page proves a requirement impossible or unstable. - -- [ ] **Step 4: Run the full test suite again** - -Run: - -```bash -npm test -npm run build -``` - -Expected: both commands succeed. - -- [ ] **Step 5: Commit** - -Run: - -```bash -git add README.md externaldocs/2026-04-20-star-chart-market-after-search-rate-plugin-spec.md -git commit -m "docs: add usage and verification notes" -``` - -## Notes for Execution - -- Do not write production behavior before the corresponding failing test exists. -- Keep DOM selectors isolated; the Xingtu page is likely to shift. -- Reuse the old project's verified API field names, but do not copy large modules blindly; re-derive them under tests. -- For full-scan logic, dependency-inject pagination and row reading so the orchestration stays testable. -- If a task reveals a missing boundary in the spec, pause and update the spec before continuing. diff --git a/externaldocs/2026-04-20-star-chart-market-after-search-rate-plugin-spec.md b/externaldocs/2026-04-20-star-chart-market-after-search-rate-plugin-spec.md deleted file mode 100644 index 8cecc2b..0000000 --- a/externaldocs/2026-04-20-star-chart-market-after-search-rate-plugin-spec.md +++ /dev/null @@ -1,464 +0,0 @@ -# 星图达人选取页看后搜率增强插件需求与 TDD 规格 - -## 1. 文档目的 - -本文档用于定义一个新的浏览器扩展项目规格。项目目标是在巨量星图达人选取页展示达人详情页中的两个看后搜率指标,并由插件自身提供筛选、排序、导出能力。 - -本文档是需求基线和 TDD 基线,不是实现记录。 - -## 2. 背景 - -当前巨量星图达人详情页已经可以看到两个关键指标: - -- 单视频看后搜率 -- 个人视频看后搜率 - -业务目标是把这两个指标前置到达人选取页,减少逐个点进详情页判断的成本。 - -已知旧项目已经验证过两类能力: - -- 可以从详情页侧拿到这两个指标 -- 可以通过接口 `/gw/api/aggregator/get_author_ase_info` 按达人维度获取这两个指标,并在列表页按行展示 - -因此,新项目不再把重点放在“能不能拿到数据”,而是放在“如何把这两个指标稳定地集成到列表页工作流中”。 - -## 3. 已确认决策 - -以下内容已在需求澄清中确认,后续实现默认以此为准: - -- 插件不依赖星图原生的自定义指标、排序、导出能力 -- 展示、筛选、排序、导出全部由插件自己提供 -- 筛选、排序结果直接作用在当前星图列表 DOM 上,不做独立侧边面板 -- 导出范围为“当前筛选条件下,插件实际抓取到的所有页结果” -- 导出通过插件自动逐页翻页抓取实现,不走批量接口全量拉取 -- 导出字段为“列表已有主要字段 + 插件新增字段” -- 筛选方式为阈值筛选 -- 阈值比较按区间下限判断 -- 排序按区间下限排序 -- 进入列表页后默认只补当前页数据 -- 只有在用户触发插件筛选、排序或导出时,插件才开始逐页抓取全量结果 -- 部分达人抓取失败时,保留该达人,字段标记为空值或失败状态,不中断全量任务 -- 默认导出格式为 `CSV` - -## 4. 产品目标 - -插件第一版需要实现以下目标: - -1. 在巨量星图达人选取页中新增两列看后搜率 -2. 进入页面后,优先补全当前页可见达人数据 -3. 当用户触发插件筛选、排序或导出时,插件自动逐页抓取当前筛选条件下的全部页结果 -4. 基于插件聚合结果,对当前页面列表执行过滤和重排 -5. 将聚合后的结果导出为 `CSV` - -## 5. 非目标 - -第一版明确不做以下内容: - -- 不接入星图原生筛选控件 -- 不接入星图原生排序控件 -- 不接入星图原生导出能力 -- 不做独立后台服务或数据库 -- 不做云端同步 -- 不做跨浏览器兼容优化 -- 不做插件弹窗里的复杂报表视图 -- 不做“进入列表页立即全量抓取” -- 不承诺导出站点原列表中的全部字段,只保证“主要字段 + 新增字段” - -## 6. 页面范围 - -第一版页面范围只覆盖巨量星图达人选取页: - -- `https://xingtu.cn/ad/creator/market` -- 或同类实际列表路由 - -插件只对该列表场景生效,不在达人详情页增加新的交互能力。详情页仅作为数据字段来源的业务参考,不作为第一版主视图。 - -## 7. 用户故事 - -### 7.1 浏览当前页 - -作为投放或选达人用户,我打开达人选取页后,希望直接在列表里看到两个看后搜率,而不是逐条点进详情页查看。 - -### 7.2 基于看后搜率筛人 - -作为用户,我希望设置两个阈值,例如: - -- 单视频看后搜率 `>= 0.1%` -- 个人视频看后搜率 `>= 0.05%` - -插件按我的阈值过滤达人行。 - -### 7.3 基于看后搜率排序 - -作为用户,我希望按照某一个看后搜率从高到低或从低到高重排行列表。 - -### 7.4 导出当前筛选条件下的结果 - -作为用户,我希望导出当前筛选条件下的全部页达人数据,而不是只导出当前可见页。 - -## 8. 功能需求 - -### 8.1 列表页列增强 - -插件需要在达人选取页中增加两列: - -- `单视频看后搜率` -- `个人视频看后搜率` - -列增强要求: - -- 默认进入页面时为当前页每个达人触发数据获取 -- 数据加载中时显示明确的中间态 -- 获取成功时显示格式化后的值 -- 获取失败时显示失败态,且失败态与空值态可区分 -- 当前页发生翻页、筛选、搜索、排序等导致列表变化时,插件需要重新识别当前页达人并补列 - -### 8.2 插件筛选 - -插件需要提供两个阈值输入: - -- `单视频看后搜率 >= X` -- `个人视频看后搜率 >= Y` - -行为要求: - -- 当用户点击应用筛选时,如果全量结果尚未抓齐,插件先启动全量翻页抓取 -- 全量抓取完成后,再基于聚合结果计算筛选结果 -- 页面中不符合条件的达人行从当前插件视角中隐藏 -- 抓取失败的达人默认不通过阈值筛选,但仍在内部结果集中保留失败状态 - -### 8.3 插件排序 - -插件需要支持按以下字段排序: - -- 单视频看后搜率 -- 个人视频看后搜率 - -每个字段至少支持: - -- 升序 -- 降序 - -行为要求: - -- 当用户触发排序时,如果全量结果尚未抓齐,插件先启动全量翻页抓取 -- 全量抓取完成后,插件基于聚合结果对当前列表进行重排 -- 排序结果应与筛选后的可见结果保持一致 -- 失败或空值默认排在末尾 - -### 8.4 插件导出 - -插件需要提供一个 `CSV` 导出入口。 - -导出要求: - -- 导出前如未完成全量抓取,插件先完成当前筛选条件下的逐页抓取 -- 导出结果包含当前筛选条件下的全部页结果,而非仅当前页 -- 导出中保留失败达人,但对应看后搜率字段为空,另增加状态字段标记成功或失败 - -导出字段建议至少包含: - -- 达人 ID -- 达人名称 -- 地区 -- 达人类型 -- 内容主题 -- 粉丝数 -- 预期 CPM -- 预期播放量 -- 互动率 -- 完播率 -- 21-60s 报价 -- 单视频看后搜率 -- 个人视频看后搜率 -- 插件数据状态 - -说明: - -- “列表已有主要字段”以页面当时稳定可读取到的列为准 -- 如果某些页面字段无法稳定解析,允许在实现阶段缩减导出字段,但必须在交付前更新本文档或补充实现说明 - -### 8.5 全量翻页抓取 - -插件在以下场景触发全量翻页抓取: - -- 用户应用插件筛选 -- 用户应用插件排序 -- 用户点击导出 - -全量抓取行为要求: - -- 从当前筛选条件下的第一页开始逐页遍历 -- 对每页达人提取基础列表字段 -- 对每个达人拉取两个看后搜率 -- 聚合到插件内部结果集 -- 自动处理分页推进 -- 自动处理重复达人去重 -- 记录当前进度和失败数量 -- 可在全部完成后恢复用户页面到一个明确状态 - -第一版不要求: - -- 暂停/继续 -- 后台静默抓取 -- 抓取任务跨页面持久化恢复 - -## 9. 数值规则 - -两个看后搜率原始值可能出现如下形式: - -- `0.02% - 0.1%` -- `0.5%-1%` -- `<0.02%` - -第一版统一转换为可比较的“下限值”。 - -规则如下: - -- `0.02% - 0.1%` 的比较值为 `0.02` -- `0.5%-1%` 的比较值为 `0.5` -- `<0.02%` 的比较值视为小于 `0.02% - 0.1%` - -用于筛选时: - -- 只有当下限值 `>=` 用户阈值时,才算通过 - -用于排序时: - -- 按下限值进行排序 -- 失败值、空值排在末尾 - -该规则必须在展示逻辑、筛选逻辑、排序逻辑、导出逻辑中保持一致,不能出现各模块自行解释字符串的情况。 - -## 10. 结果状态定义 - -每个达人在插件结果集中至少需要有以下状态之一: - -- `success` -- `loading` -- `failed` -- `missing` - -建议语义: - -- `success`:已成功拿到两个看后搜率 -- `loading`:当前页初次补列或全量抓取过程中尚未返回 -- `failed`:接口请求失败、超时或返回异常 -- `missing`:接口返回成功,但缺少完整字段 - -页面展示、筛选、排序、导出都需要基于统一状态模型,而不是分别发明一套状态。 - -## 11. 推荐架构 - -第一版采用“最小增量路线”。 - -### 11.1 模块划分 - -建议至少拆分为以下模块: - -- `market-dom-sync` - - 负责识别列表结构、插入两列、读取当前页主要字段、重排或隐藏达人行 - -- `ase-api-client` - - 负责按达人 ID 拉取 `/gw/api/aggregator/get_author_ase_info` - - 只处理网络请求和响应映射 - -- `rate-normalizer` - - 负责把展示值转成统一字符串和比较值 - -- `result-store` - - 负责维护插件内部达人结果集 - - 负责去重、状态更新、失败保留 - -- `full-scan-controller` - - 负责在用户触发筛选、排序、导出时驱动翻页抓取 - - 负责进度、终止条件和错误统计 - -- `filter-sort-controller` - - 负责阈值筛选和排序计算 - -- `csv-exporter` - - 负责导出字段映射和 `CSV` 内容生成 - -### 11.2 数据流 - -默认进入列表页时: - -1. 内容脚本识别当前页达人行 -2. 为当前页达人补列 -3. 调用达人 ASE 接口拉取两项指标 -4. 写入结果集并更新行展示 - -用户触发筛选、排序或导出时: - -1. 检查全量结果是否已覆盖当前筛选条件下的全部页 -2. 如果未覆盖,则启动全量翻页抓取 -3. 每抓到一页就写入结果集 -4. 全量抓取结束后执行筛选或排序 -5. 如果是导出,则根据聚合结果生成 `CSV` - -## 12. 验收标准 - -### 12.1 展示验收 - -- 打开达人选取页后,当前页每个达人都能看到两个新增列位 -- 加载中、成功、失败三种状态可区分 -- 翻页或列表条件变化后,新页仍能自动补列 - -### 12.2 筛选验收 - -- 输入阈值后,插件会在必要时自动抓取全部页结果 -- 抓取完成后,页面只显示满足阈值条件的达人 -- 使用下限规则判断区间值 - -### 12.3 排序验收 - -- 选择看后搜率排序后,插件会在必要时自动抓取全部页结果 -- 抓取完成后,页面达人顺序按下限规则正确变化 -- 失败值、空值排在末尾 - -### 12.4 导出验收 - -- 点击导出后,插件会在必要时自动抓取全部页结果 -- 导出文件格式为 `CSV` -- 导出结果包含插件新增字段 -- 部分失败达人不会导致导出中断 - -## 13. 风险与约束 - -- 星图列表页不是传统 `table`,而是按列或网格拼装,DOM 重排需要谨慎处理 -- 自动翻页抓取可能受到页面节流、懒加载、虚拟列表、分页组件异步行为影响 -- “列表已有主要字段”的读取稳定性依赖页面真实结构 -- 如果站点接口字段变化,达人 ASE 数据映射可能失效 -- 全量抓取耗时与当前筛选条件下结果页数线性相关 - -## 14. TDD 策略 - -本项目必须采用 TDD。优先从纯逻辑模块开始,最后才进入 DOM 与流程控制层。 - -### 14.1 测试分层 - -第一层:纯函数测试 - -- 看后搜率字符串标准化 -- 区间下限解析 -- `<0.02%` 之类特殊值解析 -- 阈值比较 -- 排序比较器 -- 接口响应映射 -- 导出字段序列化 - -第二层:状态与结果集测试 - -- 达人结果创建 -- 同一达人重复写入去重 -- 成功覆盖 loading -- 失败状态保留 -- 缺字段映射为 `missing` - -第三层:流程测试 - -- 仅进入列表页时,只抓当前页 -- 触发筛选时才启动全量翻页抓取 -- 触发排序时才启动全量翻页抓取 -- 触发导出时才启动全量翻页抓取 -- 抓取过程中翻页推进正确 -- 部分页失败不会导致整体任务中止 - -第四层:DOM 集成测试 - -- 正确插入两列 -- 成功态渲染正确 -- 失败态渲染正确 -- 筛选后隐藏不符合条件的行 -- 排序后行顺序变化正确 - -### 14.2 推荐先写的测试 - -建议严格按下面顺序推进: - -1. `rate-normalizer` 测试 -2. `ase-api-client` 响应映射测试 -3. `filter-sort-controller` 测试 -4. `result-store` 测试 -5. `csv-exporter` 测试 -6. `full-scan-controller` 测试 -7. `market-dom-sync` 测试 - -### 14.3 首批必备测试用例 - -至少覆盖以下用例: - -- `0.5%-1%` 被标准化为 `0.5% - 1%` -- `0.02% - 0.1%` 的下限被解析为 `0.02` -- `<0.02%` 排在 `0.02% - 0.1%` 之前 -- 区间下限低于阈值时不通过筛选 -- 区间下限高于或等于阈值时通过筛选 -- 成功值排序高于失败值和空值 -- 接口成功但字段缺失时映射为 `missing` -- 同一达人跨页重复出现时只保留一条聚合记录 -- 仅查看当前页时不会自动翻完整分页 -- 点击导出时会启动全量翻页抓取 -- 部分达人请求失败时,导出仍然成功 -- `CSV` 中失败达人状态字段正确输出 - -### 14.4 人工验证最小清单 - -除了自动化测试,还需要保留最小人工验证: - -1. 加载插件 -2. 打开达人选取页 -3. 确认当前页新增两列 -4. 确认筛选触发全量抓取 -5. 确认排序结果与下限规则一致 -6. 确认导出得到 `CSV` -7. 抽样比对若干达人导出的两个看后搜率与详情页是否一致 - -## 15. 建议的初始目录结构 - -```text -src/ - manifest.json - content/ - index.ts - market/ - index.ts - dom-sync.ts - api-client.ts - full-scan-controller.ts - filter-sort-controller.ts - result-store.ts - csv-exporter.ts - shared/ - rate-normalizer.ts - result-types.ts - csv-types.ts -tests/ - rate-normalizer.test.ts - market-api-client.test.ts - filter-sort-controller.test.ts - result-store.test.ts - full-scan-controller.test.ts - market-dom-sync.test.ts - csv-exporter.test.ts -``` - -## 16. 版本边界 - -第一版交付完成的定义是: - -- 当前页列增强可用 -- 插件筛选可用 -- 插件排序可用 -- 插件全量翻页抓取可用 -- `CSV` 导出可用 -- 关键逻辑有自动化测试保护 - -如果后续要扩展第二版,优先方向应为: - -- 抓取进度 UI -- 全量任务取消能力 -- 更稳定的字段导出映射 -- 本地缓存与重复抓取优化 -- 更完整的插件操作栏交互 - diff --git a/scripts/build.mjs b/scripts/build.mjs index 55d8f3d..bd7a286 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -13,7 +13,11 @@ await mkdir(path.join(distDir, "content"), { recursive: true }); await build({ entry: { - index: path.join(projectRoot, "src/content/index.ts") + index: path.join(projectRoot, "src/content/index.ts"), + "market-page-bridge": path.join( + projectRoot, + "src/content/market/page-bridge.ts" + ) }, format: ["iife"], platform: "browser", diff --git a/src/content/index.ts b/src/content/index.ts index 213585c..c84cb5b 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -3,10 +3,15 @@ import { type CreateMarketControllerOptions } from "./market/index"; +interface ChromeRuntimeLike { + getURL?: (path: string) => string; + id?: string; +} + interface BootContentScriptOptions { createMarketController?: ( options: CreateMarketControllerOptions - ) => { ready: Promise }; + ) => { dispose?: () => void; ready: Promise }; document?: Document; window?: Window; } @@ -23,6 +28,8 @@ export async function bootContentScript( return null; } + installMarketPageBridge(currentDocument); + return controllerFactory({ document: currentDocument, window: currentWindow @@ -30,5 +37,65 @@ export async function bootContentScript( } function isMarketPage(url: string): boolean { - return url.startsWith("https://xingtu.cn/ad/creator/market"); + const parsedUrl = new URL(url); + const isXingtuHost = + parsedUrl.hostname === "xingtu.cn" || parsedUrl.hostname.endsWith(".xingtu.cn"); + + return isXingtuHost && parsedUrl.pathname.startsWith("/ad/creator/market"); +} + +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]?: boolean | { dispose?: () => void; ready: Promise } | null; + }; + + if (scopedWindow[marker]) { + return; + } + + scopedWindow[marker] = true; + void bootContentScript().then((controller) => { + scopedWindow[marker] = controller; + }); +} + +bootstrapContentScript(); + +function installMarketPageBridge(document: Document) { + if ( + document.documentElement.querySelector( + '[data-sces-market-bridge="script"]' + ) + ) { + return; + } + + const script = document.createElement("script"); + script.dataset.scesMarketBridge = "script"; + + const runtime = ( + globalThis as typeof globalThis & { + chrome?: { runtime?: ChromeRuntimeLike }; + } + ).chrome?.runtime; + const bridgeUrl = runtime?.getURL?.("content/market-page-bridge.js"); + + if (bridgeUrl) { + script.src = bridgeUrl; + } else { + script.textContent = ""; + } + + (document.head ?? document.documentElement).appendChild(script); } diff --git a/src/content/market/api-client.ts b/src/content/market/api-client.ts index f070e76..92cfa5a 100644 --- a/src/content/market/api-client.ts +++ b/src/content/market/api-client.ts @@ -4,6 +4,7 @@ import type { MarketApiResult } from "./types"; interface FetchResponseLike { json(): Promise; ok: boolean; + status?: number; } type FetchLike = ( @@ -24,44 +25,52 @@ export function createMarketApiClient(options: MarketApiClientOptions = {}) { return { async loadAuthorAseInfo(authorId: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const primaryResult = await loadAuthorMetricsFromUrl( + buildAuthorCommerceSeedBaseInfoUrl(authorId, baseUrl) + ); + if (primaryResult.success || primaryResult.reason === "timeout") { + return primaryResult; + } - try { - const response = await fetchImpl( - buildAuthorAseInfoUrl(authorId, baseUrl), - { - credentials: "include", - method: "GET", - signal: controller.signal - } - ); + return loadAuthorMetricsFromUrl(buildAuthorAseInfoUrl(authorId, baseUrl)); + } + }; - if (!response.ok) { - return { - success: false, - reason: "request-failed" - }; - } + async function loadAuthorMetricsFromUrl(url: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - return mapAuthorAseInfoResponse(await response.json()); - } catch (error) { - if (isAbortError(error) || controller.signal.aborted) { - return { - success: false, - reason: "timeout" - }; - } + try { + const response = await fetchImpl(url, { + credentials: "include", + method: "GET", + signal: controller.signal + }); + if (!response.ok) { return { success: false, reason: "request-failed" }; - } finally { - clearTimeout(timeoutId); } + + return mapAuthorAseInfoResponse(await response.json()); + } catch (error) { + if (isAbortError(error) || controller.signal.aborted) { + return { + success: false, + reason: "timeout" + }; + } + + return { + success: false, + reason: "request-failed" + }; + } finally { + clearTimeout(timeoutId); } - }; + } } export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string { @@ -71,6 +80,19 @@ export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string return url.toString(); } +export function buildAuthorCommerceSeedBaseInfoUrl( + authorId: string, + baseUrl: string +): string { + const url = new URL( + "/gw/api/aggregator/get_author_commerce_seed_base_info", + baseUrl + ); + url.searchParams.set("o_author_id", authorId); + url.searchParams.set("range", "90"); + return url.toString(); +} + export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult { const data = getPayloadData(payload); if (!data) { diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index 6285a4c..8035dbf 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -1,45 +1,41 @@ -import { normalizeRateDisplay } from "../../shared/rate-normalizer"; +import { + normalizeFractionRateDisplay, + normalizeRateDisplay +} from "../../shared/rate-normalizer"; +import type { AfterSearchRates } from "./types"; import type { MarketRecord } from "./types"; +const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate"; +const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate"; +const ACTION_HEADER_TEXT = "操作"; +const AUTHOR_HEADER_TEXT = "达人信息"; +const UNAVAILABLE_RATE_TEXT = "暂无来源"; +const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows"; + +type RowOrderTarget = { + container: HTMLElement; + node: HTMLElement; +}; + export interface MarketRowDom { authorId: string; + authorName: string; + hasDirectRatesSource?: boolean; personalCell: HTMLElement; + price21To60s?: string; + rates?: AfterSearchRates; row: HTMLElement; singleCell: HTMLElement; + visibilityTargets: HTMLElement[]; + orderTargets: RowOrderTarget[]; } export interface MarketTableDom { - body: HTMLElement; rows: MarketRowDom[]; } export function syncMarketTable(root: ParentNode): MarketTableDom | null { - const header = root.querySelector("[data-market-header]") as HTMLElement | null; - const body = root.querySelector("[data-market-body]") as HTMLElement | null; - - if (!header || !body) { - return null; - } - - ensureHeaderCell(header, "singleVideoAfterSearchRate", "单视频看后搜率"); - ensureHeaderCell(header, "personalVideoAfterSearchRate", "个人视频看后搜率"); - - const rows = Array.from( - body.querySelectorAll("[data-market-row]") - ).map((rowElement) => { - const row = rowElement as HTMLElement; - return { - authorId: row.dataset.authorId ?? "", - personalCell: ensureRowCell(row, "personalVideoAfterSearchRate"), - row, - singleCell: ensureRowCell(row, "singleVideoAfterSearchRate") - }; - }); - - return { - body, - rows - }; + return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root); } export function renderMarketRowState( @@ -47,10 +43,10 @@ export function renderMarketRowState( record: MarketRecord ): void { if (record.status === "success" && record.rates) { - rowDom.singleCell.textContent = normalizeRateDisplay( + rowDom.singleCell.textContent = readRateCellText( record.rates.singleVideoAfterSearchRate ); - rowDom.personalCell.textContent = normalizeRateDisplay( + rowDom.personalCell.textContent = readRateCellText( record.rates.personalVideoAfterSearchRate ); return; @@ -77,7 +73,10 @@ export function applyRowVisibility( visibleAuthorIds: Set ): void { table.rows.forEach((rowDom) => { - rowDom.row.hidden = !visibleAuthorIds.has(rowDom.authorId); + const isVisible = visibleAuthorIds.has(rowDom.authorId); + rowDom.visibilityTargets.forEach((target) => { + target.hidden = !isVisible; + }); }); } @@ -89,13 +88,214 @@ export function applyRowOrder( orderedAuthorIds.forEach((authorId) => { const rowDom = rowById.get(authorId); - if (rowDom) { - table.body.appendChild(rowDom.row); + if (!rowDom) { + return; } + + rowDom.orderTargets.forEach(({ container, node }) => { + container.appendChild(node); + }); }); } -function ensureHeaderCell( +function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { + const header = root.querySelector("[data-market-header]") as HTMLElement | null; + const body = root.querySelector("[data-market-body]") as HTMLElement | null; + + if (!header || !body) { + return null; + } + + ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率"); + ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率"); + + const rows = Array.from(body.querySelectorAll("[data-market-row]")).map( + (rowElement) => { + const row = rowElement as HTMLElement; + const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY); + const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY); + + return { + authorId: row.dataset.authorId ?? "", + authorName: + row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ?? + "", + hasDirectRatesSource: false, + orderTargets: [ + { + container: body, + node: row + } + ], + personalCell, + price21To60s: + row + .querySelector('[data-market-field="price21To60s"]') + ?.textContent?.trim() ?? "", + rates: undefined, + row, + singleCell, + visibilityTargets: [row] + } satisfies MarketRowDom; + } + ); + + return { + rows + }; +} + +function syncDivGridMarketTable(root: ParentNode): MarketTableDom | null { + const document = getOwnerDocument(root); + if (!document) { + return null; + } + + for (const marketRoot of document.querySelectorAll(".base-author-list")) { + if (!(marketRoot instanceof document.defaultView!.HTMLElement)) { + continue; + } + + const syncedTable = syncDivGridRoot(marketRoot); + if (syncedTable) { + return syncedTable; + } + } + + return null; +} + +function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { + const headerSection = root.querySelector( + ".section-wrapper.sticky-header" + ) as HTMLElement | null; + const bodySection = Array.from(root.querySelectorAll(".section-wrapper")).find( + (section): section is HTMLElement => + section instanceof root.ownerDocument.defaultView!.HTMLElement && + !section.classList.contains("sticky-header") + ); + + if (!headerSection || !bodySection) { + return null; + } + + const authorHeader = findCellByText(getDirectHeaderCells(headerSection), AUTHOR_HEADER_TEXT); + const actionHeader = findCellByText(getDirectHeaderCells(headerSection), ACTION_HEADER_TEXT); + + if (!authorHeader || !actionHeader) { + return null; + } + + const authorSection = getIndexedChild( + bodySection, + getDirectChildIndex(headerSection, authorHeader) + ); + const rightSection = getIndexedChild( + bodySection, + getDirectChildIndex(headerSection, actionHeader) + ); + + if (!authorSection || !rightSection) { + return null; + } + + const authorColumn = getDirectContentColumns(authorSection)[0] ?? null; + const actionColumn = getActionColumn(rightSection); + + if (!authorColumn || !actionColumn) { + return null; + } + + const rowCount = getDirectContentCells(authorColumn).length; + ensureDivHeaderCell(actionHeader, SINGLE_COLUMN_KEY, "单视频看后搜率"); + ensureDivHeaderCell(actionHeader, PERSONAL_COLUMN_KEY, "个人视频看后搜率"); + + const singleColumn = ensureDivBodyColumn( + rightSection, + actionColumn, + SINGLE_COLUMN_KEY, + rowCount + ); + const personalColumn = ensureDivBodyColumn( + rightSection, + actionColumn, + PERSONAL_COLUMN_KEY, + rowCount + ); + + const allBodyColumns = Array.from(bodySection.children).flatMap((section) => + section instanceof root.ownerDocument.defaultView!.HTMLElement + ? getDirectContentColumns(section) + : [] + ); + const authorCells = getDirectContentCells(authorColumn); + const singleCells = getDirectContentCells(singleColumn); + const personalCells = getDirectContentCells(personalColumn); + const priceColumn = findPreviousColumn(actionColumn); + const priceCells = priceColumn ? getDirectContentCells(priceColumn) : []; + const vueMarketRows = readVueMarketRows(root); + const serializedMarketRows = readSerializedMarketRows(root.ownerDocument); + + const rows = authorCells.flatMap((authorCell, index) => { + const singleCell = singleCells[index] ?? null; + const personalCell = personalCells[index] ?? null; + if (!singleCell || !personalCell) { + return []; + } + + const rowCells = allBodyColumns + .map((column) => getDirectContentCells(column)[index] ?? null) + .filter((cell): cell is HTMLElement => cell !== null); + const vueMarketRow = vueMarketRows[index] ?? null; + const serializedMarketRow = serializedMarketRows[index] ?? null; + const authorId = + extractAuthorId(authorCell) || + vueMarketRow?.authorId || + serializedMarketRow?.authorId || + ""; + const authorName = + extractAuthorName(authorCell) || + vueMarketRow?.authorName || + serializedMarketRow?.authorName || + ""; + + return [ + { + authorId, + authorName, + hasDirectRatesSource: + vueMarketRow?.hasDirectRatesSource ?? + serializedMarketRow?.hasDirectRatesSource ?? + false, + orderTargets: rowCells + .map((cell) => { + const container = cell.parentElement; + if (!(container instanceof root.ownerDocument.defaultView!.HTMLElement)) { + return null; + } + + return { + container, + node: cell + }; + }) + .filter((target): target is RowOrderTarget => target !== null), + personalCell, + price21To60s: priceCells[index]?.textContent?.trim() ?? "", + rates: vueMarketRow?.rates ?? serializedMarketRow?.rates, + row: authorCell, + singleCell, + visibilityTargets: rowCells + } satisfies MarketRowDom + ]; + }); + + return { + rows + }; +} + +function ensureSyntheticHeaderCell( header: HTMLElement, field: string, label: string @@ -115,7 +315,7 @@ function ensureHeaderCell( return nextCell; } -function ensureRowCell(row: HTMLElement, field: string): HTMLElement { +function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement { const existingCell = row.querySelector( `[data-market-row-cell="${field}"]` ) as HTMLElement | null; @@ -129,3 +329,351 @@ function ensureRowCell(row: HTMLElement, field: string): HTMLElement { row.appendChild(nextCell); return nextCell; } + +function ensureDivHeaderCell( + actionHeader: HTMLElement, + field: string, + label: string +): HTMLElement { + const container = actionHeader.parentElement; + if (!container) { + return actionHeader; + } + + const existingCell = container.querySelector( + `[data-market-header-cell="${field}"]` + ) as HTMLElement | null; + if (existingCell) { + existingCell.textContent = label; + return existingCell; + } + + const referenceCell = findPreviousHeaderCell(actionHeader) ?? actionHeader; + const nextCell = cloneElementShallow(referenceCell); + nextCell.dataset.marketHeaderCell = field; + nextCell.textContent = label; + container.insertBefore(nextCell, actionHeader); + return nextCell; +} + +function ensureDivBodyColumn( + bodySection: HTMLElement, + actionColumn: HTMLElement, + field: string, + rowCount: number +): HTMLElement { + const container = actionColumn.parentElement; + if (!container) { + return bodySection; + } + + const existingColumn = container.querySelector( + `[data-market-column-group="${field}"]` + ) as HTMLElement | null; + if (existingColumn) { + syncDivColumnCells(existingColumn, actionColumn, field, rowCount); + return existingColumn; + } + + const referenceColumn = findPreviousColumn(actionColumn) ?? actionColumn; + const nextColumn = cloneElementShallow(referenceColumn); + nextColumn.dataset.marketColumnGroup = field; + syncDivColumnCells(nextColumn, actionColumn, field, rowCount); + container.insertBefore(nextColumn, actionColumn); + return nextColumn; +} + +function syncDivColumnCells( + column: HTMLElement, + actionColumn: HTMLElement, + field: string, + rowCount: number +): void { + const currentCells = getDirectContentCells(column); + while (currentCells.length > rowCount) { + currentCells.pop()?.remove(); + } + + const actionCells = getDirectContentCells(actionColumn); + for (let index = 0; index < rowCount; index += 1) { + const existingCell = getDirectContentCells(column)[index] ?? null; + if (existingCell) { + existingCell.dataset.marketRowCell = field; + continue; + } + + const templateCell = actionCells[index] ?? actionCells[actionCells.length - 1] ?? null; + const nextCell = templateCell + ? cloneElementShallow(templateCell) + : createBareContentCell(column.ownerDocument); + nextCell.dataset.marketRowCell = field; + nextCell.textContent = ""; + column.appendChild(nextCell); + } +} + +function getOwnerDocument(root: ParentNode): Document | null { + if ("ownerDocument" in root && root.ownerDocument) { + return root.ownerDocument; + } + + return root instanceof Document ? root : null; +} + +function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null { + let current = cell.previousElementSibling; + while (current) { + if ( + current instanceof cell.ownerDocument.defaultView!.HTMLElement && + current.classList.contains("header-cell") + ) { + return current; + } + current = current.previousElementSibling; + } + + return null; +} + +function findPreviousColumn(column: HTMLElement): HTMLElement | null { + let current = column.previousElementSibling; + while (current) { + if ( + current instanceof column.ownerDocument.defaultView!.HTMLElement && + current.classList.contains("content-column") + ) { + return current; + } + current = current.previousElementSibling; + } + + return null; +} + +function getActionColumn(bodySection: HTMLElement): HTMLElement | null { + const columns = getDirectContentColumns(bodySection); + return columns[columns.length - 1] ?? null; +} + +function getDirectHeaderCells(section: Element): HTMLElement[] { + return Array.from(section.querySelectorAll(".header-cell")).filter( + (cell): cell is HTMLElement => + cell instanceof section.ownerDocument.defaultView!.HTMLElement + ); +} + +function getDirectContentColumns(section: Element): HTMLElement[] { + return Array.from(section.children).filter( + (child): child is HTMLElement => + child instanceof section.ownerDocument.defaultView!.HTMLElement && + child.classList.contains("content-column") + ); +} + +function getDirectContentCells(column: Element): HTMLElement[] { + return Array.from(column.children).filter( + (child): child is HTMLElement => + child instanceof column.ownerDocument.defaultView!.HTMLElement && + child.classList.contains("content-cell") + ); +} + +function getDirectChildIndex(root: HTMLElement, descendant: HTMLElement): number { + const directChild = Array.from(root.children).find((child) => child.contains(descendant)); + return directChild ? Array.from(root.children).indexOf(directChild) : -1; +} + +function getIndexedChild(root: HTMLElement, index: number): HTMLElement | null { + if (index < 0) { + return null; + } + + const child = root.children[index] ?? null; + return child instanceof root.ownerDocument.defaultView!.HTMLElement ? child : null; +} + +function findCellByText(cells: HTMLElement[], text: string): HTMLElement | null { + return cells.find((cell) => cell.textContent?.trim() === text) ?? null; +} + +function cloneElementShallow(reference: HTMLElement): HTMLElement { + const clone = reference.ownerDocument.createElement(reference.tagName); + clone.className = reference.className; + + const style = reference.getAttribute("style"); + if (style) { + clone.setAttribute("style", style); + } + + return clone; +} + +function createBareContentCell(document: Document): HTMLElement { + const cell = document.createElement("div"); + cell.className = "content-cell"; + return cell; +} + +function extractAuthorId(authorCell: HTMLElement): string { + const explicitAuthorId = authorCell.dataset.authorId; + if (explicitAuthorId) { + return explicitAuthorId; + } + + const linkedAuthorId = Array.from(authorCell.querySelectorAll("a")) + .map((link) => extractAuthorIdFromHref((link as HTMLAnchorElement).href)) + .find((value): value is string => Boolean(value)); + if (linkedAuthorId) { + return linkedAuthorId; + } + + const fallbackAuthorId = authorCell + .querySelector("[data-author-id]") + ?.getAttribute("data-author-id"); + return fallbackAuthorId ?? ""; +} + +function extractAuthorName(authorCell: HTMLElement): string { + return ( + authorCell.querySelector(".author-nickname")?.textContent?.trim() ?? + authorCell.textContent?.trim() ?? + "" + ); +} + +function extractAuthorIdFromHref(href: string): string | null { + const match = href.match(/\/author-homepage\/[^/]+\/(\d+)/); + return match?.[1] ?? null; +} + +function readVueMarketRows( + marketRoot: HTMLElement +): Array<{ + authorId: string; + authorName: string; + hasDirectRatesSource: boolean; + rates?: AfterSearchRates; +}> { + const vueRoot = ( + marketRoot as HTMLElement & { + __vue__?: { + _setupState?: Record; + }; + } + ).__vue__; + const setupState = vueRoot?._setupState; + if (!setupState) { + return []; + } + + for (const value of Object.values(setupState)) { + const candidate = unwrapVueRef(value); + if (!candidate || typeof candidate !== "object") { + continue; + } + + const marketList = unwrapVueRef( + (candidate as Record).marketList + ); + if (!Array.isArray(marketList)) { + continue; + } + + return marketList.map((row) => { + const record = isRecord(row) ? row : {}; + const attributeDatas = isRecord(record.attribute_datas) + ? record.attribute_datas + : {}; + const singleVideoAfterSearchRate = normalizeMarketListRate( + attributeDatas.avg_search_after_view_rate_30d + ); + + return { + authorId: + readString(record.star_id) ?? + readString(attributeDatas.id) ?? + "", + authorName: + readString(attributeDatas.nickname) ?? + readString(record.nick_name) ?? + "", + hasDirectRatesSource: true, + rates: singleVideoAfterSearchRate + ? { + singleVideoAfterSearchRate + } + : undefined + }; + }); + } + + return []; +} + +function readSerializedMarketRows( + document: Document +): Array<{ + authorId: string; + authorName: string; + hasDirectRatesSource: boolean; + rates?: AfterSearchRates; +}> { + const serializedRows = document.documentElement.getAttribute( + SERIALIZED_MARKET_ROWS_ATTRIBUTE + ); + if (!serializedRows) { + return []; + } + + try { + const parsedRows = JSON.parse(serializedRows); + if (!Array.isArray(parsedRows)) { + return []; + } + + return parsedRows + .map((row) => { + const record = isRecord(row) ? row : {}; + const singleVideoAfterSearchRate = readString( + record.singleVideoAfterSearchRate + ); + return { + authorId: readString(record.authorId) ?? "", + authorName: readString(record.authorName) ?? "", + hasDirectRatesSource: Boolean(singleVideoAfterSearchRate), + rates: singleVideoAfterSearchRate + ? { + singleVideoAfterSearchRate + } + : undefined + }; + }) + .filter((row) => Boolean(row.authorId || row.authorName)); + } catch { + return []; + } +} + +function unwrapVueRef(value: unknown): unknown { + if (isRecord(value) && "value" in value) { + return value.value; + } + + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function normalizeMarketListRate(value: unknown): string | null { + return typeof value === "string" ? normalizeFractionRateDisplay(value) : null; +} + +function readRateCellText(value: string | undefined): string { + return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT; +} diff --git a/src/content/market/full-scan-controller.ts b/src/content/market/full-scan-controller.ts index dc3eaef..635b88a 100644 --- a/src/content/market/full-scan-controller.ts +++ b/src/content/market/full-scan-controller.ts @@ -8,10 +8,7 @@ import type { AfterSearchRates } from "./types"; interface ResultStoreLike { setAuthorFailed(authorId: string, reason: MarketApiFailureReason): void; setAuthorLoading(authorId: string): void; - setAuthorSuccess( - authorId: string, - rates: Required - ): void; + setAuthorSuccess(authorId: string, rates: AfterSearchRates): void; upsertMarketRow(row: MarketRowSnapshot): void; } @@ -71,6 +68,18 @@ export function createFullScanController(options: FullScanControllerOptions) { for (const row of rows) { options.resultStore.upsertMarketRow(row); + if (row.hasDirectRatesSource) { + const directRates = row.rates ?? {}; + const hasAllRates = + Boolean(directRates.singleVideoAfterSearchRate) && + Boolean(directRates.personalVideoAfterSearchRate); + + options.resultStore.setAuthorSuccess(row.authorId, directRates); + if (hasAllRates) { + continue; + } + } + options.resultStore.setAuthorLoading(row.authorId); const metricsResult = await options.loadAuthorMetrics(row.authorId); diff --git a/src/content/market/index.ts b/src/content/market/index.ts index d2b692a..2f8aa1f 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -3,7 +3,8 @@ import { applyRowOrder, applyRowVisibility, renderMarketRowState, - syncMarketTable + syncMarketTable, + type MarketRowDom } from "./dom-sync"; import { applyFilterAndSort } from "./filter-sort-controller"; import { createFullScanController } from "./full-scan-controller"; @@ -24,35 +25,59 @@ interface FullScanControllerLike { ensureScanForSort(): Promise; } +interface MutationObserverLike { + disconnect(): void; + observe(target: Node, options?: MutationObserverInit): void; +} + export interface CreateMarketControllerOptions { buildCsv?: (records: MarketRecord[]) => string; document: Document; fullScanController?: FullScanControllerLike; loadAuthorMetrics?: (authorId: string) => Promise; + mutationObserverFactory?: ( + callback: MutationCallback + ) => MutationObserverLike; onCsvReady?: (csv: string) => void; resultStore?: ReturnType; window: Window; } export function createMarketController(options: CreateMarketControllerOptions) { - const table = syncMarketTable(options.document); + const marketApiClient = createMarketApiClient(); const resultStore = options.resultStore ?? createMarketResultStore(); const loadAuthorMetrics = - options.loadAuthorMetrics ?? createMarketApiClient().loadAuthorAseInfo; + options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo; const buildCsv = options.buildCsv ?? buildMarketCsv; + const mutationObserverFactory = + options.mutationObserverFactory ?? + ((callback: MutationCallback) => new MutationObserver(callback)); let activeFilters: MarketFilterState = {}; let activeSort: MarketSortState | undefined; + let isSyncRunning = false; + let isSyncScheduled = false; + let needsResync = false; const fullScanController = options.fullScanController ?? createFullScanController({ - goToNextPage: async () => false, - hasNextPage: () => false, + goToNextPage: () => goToNextMarketPage(options.document, options.window), + hasNextPage: () => hasNextMarketPage(options.document), loadAuthorMetrics, readCurrentPageRows: () => - table ? table.rows.map((rowDom) => readRowSnapshot(rowDom.row)) : [], + readCurrentPageRows(options.document), resultStore }); + const observer = mutationObserverFactory(() => { + scheduleSync(); + }); + const observationRoot = options.document.body ?? options.document.documentElement; + if (observationRoot) { + observer.observe(observationRoot, { + childList: true, + subtree: true + }); + } const toolbar = ensurePluginToolbar(options.document, { onApplyFilter: async () => { @@ -79,25 +104,68 @@ export function createMarketController(options: CreateMarketControllerOptions) { } }); - const ready = hydrateCurrentPage().then(() => { - applyCurrentView(); - }); + const ready = runSyncCycle(); return { + dispose() { + observer.disconnect(); + }, ready }; async function hydrateCurrentPage(): Promise { + const table = syncMarketTable(options.document); if (!table) { return; } for (const rowDom of table.rows) { - const rowSnapshot = readRowSnapshot(rowDom.row); + const rowSnapshot = readRowSnapshot(rowDom); + if (!rowSnapshot.authorId) { + continue; + } + resultStore.upsertMarketRow(rowSnapshot); + const existingRecord = resultStore.getRecord(rowSnapshot.authorId); + if (existingRecord?.status === "success" && existingRecord.rates) { + renderMarketRowState(rowDom, existingRecord); + continue; + } + + if (existingRecord?.status === "failed") { + renderMarketRowState(rowDom, existingRecord); + continue; + } + + if (existingRecord?.status === "loading") { + renderMarketRowState(rowDom, { + ...rowSnapshot, + status: "loading" + }); + continue; + } + + if (rowSnapshot.hasDirectRatesSource) { + const directRates = rowSnapshot.rates ?? {}; + const hasAllRates = + Boolean(directRates.singleVideoAfterSearchRate) && + Boolean(directRates.personalVideoAfterSearchRate); + + resultStore.setAuthorSuccess(rowSnapshot.authorId, directRates); + renderMarketRowState(rowDom, { + ...rowSnapshot, + rates: directRates, + status: "success" + }); + if (hasAllRates) { + continue; + } + } + resultStore.setAuthorLoading(rowSnapshot.authorId); renderMarketRowState(rowDom, { ...rowSnapshot, + rates: resultStore.getRecord(rowSnapshot.authorId)?.rates, status: "loading" }); @@ -122,6 +190,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { } function applyCurrentView(): void { + const table = syncMarketTable(options.document); if (!table) { return; } @@ -137,18 +206,63 @@ export function createMarketController(options: CreateMarketControllerOptions) { sort: activeSort }); } + + function scheduleSync(): void { + if (isSyncRunning) { + needsResync = true; + return; + } + + if (isSyncScheduled) { + return; + } + + isSyncScheduled = true; + options.window.setTimeout(() => { + isSyncScheduled = false; + void runSyncCycle(); + }, 0); + } + + async function runSyncCycle(): Promise { + if (isSyncRunning) { + needsResync = true; + return; + } + + isSyncRunning = true; + try { + await hydrateCurrentPage(); + applyCurrentView(); + } finally { + isSyncRunning = false; + if (needsResync) { + needsResync = false; + scheduleSync(); + } + } + } + } -function readRowSnapshot(row: HTMLElement): MarketRowSnapshot { +function readCurrentPageRows(document: Document): MarketRowSnapshot[] { + const table = syncMarketTable(document); + if (!table) { + return []; + } + + return table.rows + .map((rowDom) => readRowSnapshot(rowDom)) + .filter((row): row is MarketRowSnapshot => Boolean(row.authorId)); +} + +function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { return { - authorId: row.dataset.authorId ?? "", - authorName: - row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ?? - "", - price21To60s: - row - .querySelector('[data-market-field="price21To60s"]') - ?.textContent?.trim() ?? "" + authorId: rowDom.authorId, + authorName: rowDom.authorName, + hasDirectRatesSource: rowDom.hasDirectRatesSource, + price21To60s: rowDom.price21To60s, + rates: rowDom.rates }; } @@ -174,3 +288,80 @@ function readSortState( field: fieldSelect.value as MarketSortState["field"] }; } + +function hasNextMarketPage(document: Document): boolean { + const nextButton = findNextPageButton(document); + return Boolean(nextButton && !isDisabled(nextButton)); +} + +async function goToNextMarketPage( + document: Document, + window: Window +): Promise { + const nextButton = findNextPageButton(document); + if (!nextButton || isDisabled(nextButton)) { + return false; + } + + const previousSignature = getCurrentPageSignature(document); + nextButton.click(); + + return waitForPageSignatureChange(document, window, previousSignature); +} + +function findNextPageButton(document: Document): HTMLElement | null { + const selectorMatch = document.querySelector( + '[data-testid="next-page"], .ant-pagination-next, .aux-pagination-next, .auxo-pagination-next, [aria-label="next page"]' + ); + if (selectorMatch instanceof document.defaultView!.HTMLElement) { + return selectorMatch; + } + + return Array.from(document.querySelectorAll("button, a, div, span")).find( + (element): element is HTMLElement => + element instanceof document.defaultView!.HTMLElement && + element.textContent?.trim() === "下一页" + ) ?? null; +} + +function isDisabled(element: HTMLElement): boolean { + return ( + "disabled" in element && + Boolean((element as HTMLButtonElement).disabled) || + element.getAttribute("aria-disabled") === "true" || + /disabled|is-disabled/.test(element.className) + ); +} + +function getCurrentPageSignature(document: Document): string { + return readCurrentPageRows(document) + .map((row) => row.authorId) + .join("|"); +} + +function waitForPageSignatureChange( + document: Document, + window: Window, + previousSignature: string +): Promise { + return new Promise((resolve) => { + const startedAt = Date.now(); + + const check = () => { + const currentSignature = getCurrentPageSignature(document); + if (currentSignature && currentSignature !== previousSignature) { + resolve(true); + return; + } + + if (Date.now() - startedAt >= 5000) { + resolve(false); + return; + } + + window.setTimeout(check, 50); + }; + + check(); + }); +} diff --git a/src/content/market/page-bridge.ts b/src/content/market/page-bridge.ts new file mode 100644 index 0000000..b4801cd --- /dev/null +++ b/src/content/market/page-bridge.ts @@ -0,0 +1,130 @@ +import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer"; + +const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__"; +const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows"; + +type MarketRow = { + attribute_datas?: Record; + nick_name?: string; + star_id?: string; +}; + +declare global { + interface Window { + [BRIDGE_MARKER]?: boolean; + } +} + +installMarketPageBridge(); + +function installMarketPageBridge() { + if (window[BRIDGE_MARKER]) { + syncSerializedMarketRows(); + return; + } + + window[BRIDGE_MARKER] = true; + syncSerializedMarketRows(); + + const observer = new MutationObserver(() => { + syncSerializedMarketRows(); + }); + observer.observe(document.documentElement, { + childList: true, + subtree: true + }); + + window.setInterval(() => { + syncSerializedMarketRows(); + }, 1000); +} + +function syncSerializedMarketRows() { + const nextSerializedRows = JSON.stringify(readSerializedMarketRows()); + if ( + document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !== + nextSerializedRows + ) { + document.documentElement.setAttribute( + SERIALIZED_MARKET_ROWS_ATTRIBUTE, + nextSerializedRows + ); + } +} + +function readSerializedMarketRows() { + const marketList = readMarketList(); + return marketList + .map((row) => { + const attributeDatas = isRecord(row.attribute_datas) ? row.attribute_datas : {}; + const singleVideoAfterSearchRate = readNormalizedFractionRate( + attributeDatas.avg_search_after_view_rate_30d + ); + return { + authorId: + readString(row.star_id) ?? readString(attributeDatas.id) ?? "", + authorName: + readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "", + singleVideoAfterSearchRate + }; + }) + .filter((row) => Boolean(row.authorId || row.authorName)); +} + +function readMarketList(): MarketRow[] { + const marketRoot = document.querySelector(".base-author-list") as + | (HTMLElement & { + __vue__?: { + _setupState?: Record; + }; + }) + | null; + const setupState = marketRoot?.__vue__?._setupState; + if (!setupState) { + return []; + } + + for (const value of Object.values(setupState)) { + const candidate = unwrapVueRef(value); + if (Array.isArray(candidate) && looksLikeMarketList(candidate)) { + return candidate as MarketRow[]; + } + + if (!isRecord(candidate) || !Array.isArray(candidate.marketList)) { + continue; + } + + if (looksLikeMarketList(candidate.marketList)) { + return candidate.marketList as MarketRow[]; + } + } + + return []; +} + +function looksLikeMarketList(value: unknown[]): boolean { + const firstRow = value[0]; + return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow); +} + +function unwrapVueRef(value: unknown): unknown { + if (isRecord(value) && "value" in value) { + return value.value; + } + + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function readNormalizedFractionRate(value: unknown): string | undefined { + return typeof value === "string" + ? normalizeFractionRateDisplay(value) ?? undefined + : undefined; +} diff --git a/src/content/market/result-store.ts b/src/content/market/result-store.ts index 6151265..5cb09a4 100644 --- a/src/content/market/result-store.ts +++ b/src/content/market/result-store.ts @@ -25,15 +25,26 @@ export function createMarketResultStore() { existingRecord.status = "loading"; delete existingRecord.failureReason; }, - setAuthorSuccess(authorId: string, rates: Required) { + setAuthorSuccess(authorId: string, rates: AfterSearchRates) { const existingRecord = ensureRecord(authorId); existingRecord.status = "success"; - existingRecord.rates = rates; + existingRecord.rates = { + ...existingRecord.rates, + ...rates + }; delete existingRecord.failureReason; }, upsertMarketRow(row: MarketRowSnapshot) { const existingRecord = records.get(row.authorId); if (existingRecord) { + existingRecord.hasDirectRatesSource = + existingRecord.hasDirectRatesSource || row.hasDirectRatesSource; + if (row.rates) { + existingRecord.rates = { + ...existingRecord.rates, + ...row.rates + }; + } return existingRecord; } diff --git a/src/content/market/types.ts b/src/content/market/types.ts index ed6a533..75224bc 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -8,14 +8,15 @@ export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "mi export interface MarketRowSnapshot { authorId: string; authorName: string; + hasDirectRatesSource?: boolean; location?: string; price21To60s?: string; + rates?: AfterSearchRates; } export interface MarketRecord extends MarketRowSnapshot { status: MarketRecordStatus; failureReason?: MarketApiFailureReason; - rates?: Required; } export interface MarketFilterState { diff --git a/src/manifest.json b/src/manifest.json index ec4751f..95afef3 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -5,9 +5,21 @@ "description": "Bootstraps the Xingtu creator market content script.", "content_scripts": [ { - "matches": ["https://xingtu.cn/ad/creator/market*"], + "matches": [ + "https://xingtu.cn/ad/creator/market*", + "https://*.xingtu.cn/ad/creator/market*" + ], "js": ["content/index.js"], "run_at": "document_idle" } + ], + "web_accessible_resources": [ + { + "resources": ["content/market-page-bridge.js"], + "matches": [ + "https://xingtu.cn/*", + "https://*.xingtu.cn/*" + ] + } ] } diff --git a/src/shared/rate-normalizer.ts b/src/shared/rate-normalizer.ts index 47d7826..9dd9639 100644 --- a/src/shared/rate-normalizer.ts +++ b/src/shared/rate-normalizer.ts @@ -17,6 +17,16 @@ export function normalizeRateDisplay(value: string): string { return trimmedValue.replace(/\s+/g, ""); } +export function normalizeFractionRateDisplay(value: string): string | null { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return null; + } + + const percentageValue = numericValue * 100; + return `${trimTrailingZeros(percentageValue.toFixed(6))}%`; +} + export function parseRateLowerBound(value: string | null | undefined): number | null { const comparableRate = toComparableRate(value); return comparableRate?.numeric ?? null; @@ -86,3 +96,7 @@ function toComparableRate(value: string | null | undefined): ComparableRate | nu return null; } + +function trimTrailingZeros(value: string): string { + return value.replace(/\.?0+$/, ""); +} diff --git a/tests/full-scan-controller.test.ts b/tests/full-scan-controller.test.ts index 3ab9d3b..29df8eb 100644 --- a/tests/full-scan-controller.test.ts +++ b/tests/full-scan-controller.test.ts @@ -107,6 +107,63 @@ describe("full-scan-controller", () => { status: "success" }); }); + + test("uses current page market-list rates and still loads missing metrics", async () => { + const store = createMarketResultStore(); + const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ + success: true as const, + rates: + authorId === "a" + ? { + singleVideoAfterSearchRate: "0.02%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + : { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + })); + + const controller = createFullScanController({ + goToNextPage: async () => false, + hasNextPage: () => false, + loadAuthorMetrics, + readCurrentPageRows: vi.fn(() => [ + { + authorId: "a", + authorName: "Alpha", + hasDirectRatesSource: true, + rates: { + singleVideoAfterSearchRate: "0.02%" + } + }, + { + authorId: "b", + authorName: "Beta", + hasDirectRatesSource: true + } + ]), + resultStore: store + }); + + await controller.ensureScanForFilter(); + + expect(loadAuthorMetrics).toHaveBeenCalledTimes(2); + expect(store.getRecord("a")).toMatchObject({ + status: "success", + rates: { + singleVideoAfterSearchRate: "0.02%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + }); + expect(store.getRecord("b")).toMatchObject({ + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + }); + }); }); function createHarness() { diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts new file mode 100644 index 0000000..7b5ba25 --- /dev/null +++ b/tests/manifest.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "vitest"; + +import manifest from "../src/manifest.json"; + +describe("manifest", () => { + test("injects the content script on the www Xingtu market page", () => { + expect(manifest.content_scripts?.[0]?.matches).toEqual( + expect.arrayContaining([ + expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/) + ]) + ); + }); +}); diff --git a/tests/market-api-client.test.ts b/tests/market-api-client.test.ts index cfb0f7e..e28f29d 100644 --- a/tests/market-api-client.test.ts +++ b/tests/market-api-client.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { buildAuthorAseInfoUrl, + buildAuthorCommerceSeedBaseInfoUrl, createMarketApiClient, mapAuthorAseInfoResponse } from "../src/content/market/api-client"; @@ -59,6 +60,74 @@ describe("market-api-client", () => { }); }); + test("loads rates from the commerce seed endpoint first", async () => { + const requestedUrls: string[] = []; + const client = createMarketApiClient({ + fetchImpl: async (input) => { + requestedUrls.push(input); + return { + ok: true, + json: async () => ({ + avg_search_after_view_rate: "0.1% - 0.25%", + personal_avg_search_after_view_rate: "0.02% - 0.1%" + }) + }; + } + }); + + await expect(client.loadAuthorAseInfo("7363217488772857856")).resolves.toMatchObject( + { + success: true, + rates: { + singleVideoAfterSearchRate: "0.1% - 0.25%", + personalVideoAfterSearchRate: "0.02% - 0.1%" + } + } + ); + expect(requestedUrls).toEqual([ + "https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90" + ]); + }); + + test("falls back to the ASE endpoint when the commerce seed endpoint fails", async () => { + const requestedUrls: string[] = []; + const client = createMarketApiClient({ + fetchImpl: async (input) => { + requestedUrls.push(input); + if (input.includes("get_author_commerce_seed_base_info")) { + return { + ok: false, + json: async () => ({}) + }; + } + + return { + ok: true, + json: async () => ({ + data: { + avg_search_after_view_rate: "<0.02%", + personal_avg_search_after_view_rate: "0.02 - 0.1%" + } + }) + }; + } + }); + + await expect(client.loadAuthorAseInfo("7363217488772857856")).resolves.toMatchObject( + { + success: true, + rates: { + singleVideoAfterSearchRate: "<0.02%", + personalVideoAfterSearchRate: "0.02% - 0.1%" + } + } + ); + expect(requestedUrls).toEqual([ + "https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90", + "https://xingtu.cn/gw/api/aggregator/get_author_ase_info?author_id=7363217488772857856&range=30" + ]); + }); + test("returns a timeout result when the request aborts", async () => { const client = createMarketApiClient({ fetchImpl: async () => { @@ -72,4 +141,12 @@ describe("market-api-client", () => { reason: "timeout" }); }); + + test("builds the author commerce seed info url with author id and range", () => { + expect( + buildAuthorCommerceSeedBaseInfoUrl("7363217488772857856", "https://xingtu.cn") + ).toBe( + "https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90" + ); + }); }); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 9d3b394..5b53f62 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -1,16 +1,68 @@ // @vitest-environment jsdom // @vitest-environment-options {"url":"https://xingtu.cn/"} -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createMarketResultStore } from "../src/content/market/result-store"; +const disposers: Array<() => void> = []; + describe("market-content-entry", () => { beforeEach(() => { document.body.innerHTML = ""; + document.documentElement.removeAttribute("data-sces-market-rows"); window.history.replaceState({}, "", "/"); }); + afterEach(() => { + vi.doUnmock("../src/content/market/index"); + delete ( + globalThis as typeof globalThis & { + chrome?: unknown; + } + ).chrome; + delete ( + window as Window & { + __starChartSearchEnhancerContentController?: unknown; + __SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean; + } + ).__starChartSearchEnhancerContentController; + delete ( + window as Window & { + __SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean; + } + ).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__; + document.documentElement.removeAttribute("data-sces-market-rows"); + vi.resetModules(); + + while (disposers.length > 0) { + disposers.pop()?.(); + } + }); + + test("auto boots on import when chrome runtime is available", async () => { + const createMarketController = vi.fn(() => ({ + ready: Promise.resolve() + })); + + window.history.replaceState({}, "", "/ad/creator/market"); + ( + globalThis as typeof globalThis & { + chrome?: { runtime?: object }; + } + ).chrome = { + runtime: {} + }; + + vi.doMock("../src/content/market/index", () => ({ + createMarketController + })); + + await import("../src/content/index"); + + expect(createMarketController).toHaveBeenCalledTimes(1); + }); + test("boots the market controller on the Xingtu market URL", async () => { const createMarketController = vi.fn(() => ({ ready: Promise.resolve() @@ -23,6 +75,28 @@ describe("market-content-entry", () => { createMarketController }); + expect(createMarketController).toHaveBeenCalledTimes(1); + expect( + document.documentElement.querySelector('[data-sces-market-bridge="script"]') + ).not.toBeNull(); + }); + + test("boots the market controller on the www Xingtu market URL", async () => { + const createMarketController = vi.fn(() => ({ + ready: Promise.resolve() + })); + + const { bootContentScript } = await import("../src/content/index"); + await bootContentScript({ + createMarketController, + document, + window: { + location: { + href: "https://www.xingtu.cn/ad/creator/market" + } + } as Window + }); + expect(createMarketController).toHaveBeenCalledTimes(1); }); @@ -30,7 +104,7 @@ describe("market-content-entry", () => { document.body.innerHTML = buildMarketFixture(); const { createMarketController } = await import("../src/content/market/index"); - const controller = createMarketController({ + const controller = trackController(createMarketController({ document, loadAuthorMetrics: async (authorId) => ({ success: true, @@ -46,7 +120,7 @@ describe("market-content-entry", () => { } }), window - }); + })); await controller.ready; @@ -60,6 +134,182 @@ describe("market-content-entry", () => { ).toBe("0.03% - 0.2%"); }); + test("hydrates the real div-grid market rows on start", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + } + ]); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async (authorId) => ({ + success: true, + rates: + authorId === "111" + ? { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + : { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + }), + window + })); + + await controller.ready; + + expect(readDivRightRowTexts(0)).toEqual([ + "¥450,000", + "0.02% - 0.1%", + "0.03% - 0.2%", + "下单" + ]); + expect(readDivRightRowTexts(1)).toEqual([ + "¥20,000", + "0.5% - 1%", + "0.01% - 0.1%", + "下单" + ]); + }); + + test("uses the market list single-rate directly and still loads the missing personal rate", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + } + ]); + attachMarketListState([ + { + attribute_datas: { + avg_search_after_view_rate_30d: "0.0002", + nickname: "达人 A" + }, + star_id: "111" + }, + { + attribute_datas: { + nickname: "达人 B" + }, + star_id: "222" + } + ]); + const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ + success: true as const, + rates: + authorId === "111" + ? { + singleVideoAfterSearchRate: "0.02%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + : { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + })); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics, + window + })); + + await controller.ready; + + expect(loadAuthorMetrics).toHaveBeenCalledTimes(2); + expect(readDivRightRowTexts(0)).toEqual([ + "¥450,000", + "0.02%", + "0.03% - 0.2%", + "下单" + ]); + expect(readDivRightRowTexts(1)).toEqual([ + "¥20,000", + "0.5% - 1%", + "0.01% - 0.1%", + "下单" + ]); + }); + + test("hydrates real rows from serialized market rows when vue state is unavailable", async () => { + document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ + { + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorName: "达人 B", + price21To60s: "¥20,000" + } + ]); + document.documentElement.setAttribute( + "data-sces-market-rows", + JSON.stringify([ + { + authorId: "111", + authorName: "达人 A", + singleVideoAfterSearchRate: "0.02%" + }, + { + authorId: "222", + authorName: "达人 B" + } + ]) + ); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async (authorId) => ({ + success: true, + rates: + authorId === "111" + ? { + singleVideoAfterSearchRate: "0.02%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + : { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + }), + window + })); + + await controller.ready; + + expect(readDivRightRowTexts(0)).toEqual([ + "¥450,000", + "0.02%", + "0.03% - 0.2%", + "下单" + ]); + expect(readDivRightRowTexts(1)).toEqual([ + "¥20,000", + "0.5% - 1%", + "0.01% - 0.1%", + "下单" + ]); + }); + test("applying plugin filters triggers full scan and hides non-matching rows", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); @@ -75,7 +325,7 @@ describe("market-content-entry", () => { }); const { createMarketController } = await import("../src/content/market/index"); - const controller = createMarketController({ + const controller = trackController(createMarketController({ document, fullScanController: { ensureScanForExport: vi.fn(async () => {}), @@ -88,7 +338,7 @@ describe("market-content-entry", () => { }), resultStore, window - }); + })); await controller.ready; setInputValue('[data-plugin-filter-single="input"]', "0.1"); @@ -119,7 +369,7 @@ describe("market-content-entry", () => { }); const { createMarketController } = await import("../src/content/market/index"); - const controller = createMarketController({ + const controller = trackController(createMarketController({ document, fullScanController: { ensureScanForExport: vi.fn(async () => {}), @@ -132,7 +382,7 @@ describe("market-content-entry", () => { }), resultStore, window - }); + })); await controller.ready; setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); @@ -161,7 +411,7 @@ describe("market-content-entry", () => { }); const { createMarketController } = await import("../src/content/market/index"); - const controller = createMarketController({ + const controller = trackController(createMarketController({ buildCsv, document, fullScanController: { @@ -176,7 +426,7 @@ describe("market-content-entry", () => { onCsvReady, resultStore, window - }); + })); await controller.ready; setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); @@ -199,6 +449,105 @@ describe("market-content-entry", () => { ]); expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }); + + test("rehydrates rows when the market list DOM changes", async () => { + document.body.innerHTML = buildMarketFixture(); + const observer = createMutationObserverFactory(); + const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ + success: true as const, + rates: + authorId === "a" + ? { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + : { + singleVideoAfterSearchRate: "0.8%-1%", + personalVideoAfterSearchRate: "0.05% - 0.2%" + } + })); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics, + mutationObserverFactory: observer.factory, + window + })); + + await controller.ready; + document.querySelector("[data-market-body]")!.innerHTML = ` +
+ Gamma + 88000 +
+ `; + observer.trigger(); + await flushWithTimers(); + await flushWithTimers(); + + expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual( + expect.arrayContaining(["a", "c"]) + ); + expect(readRowOrder()).toEqual(["c"]); + expect( + document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]') + ?.textContent + ).toBe("0.8% - 1%"); + }); + + test("default full scan walks the real market pagination when applying a filter", async () => { + const pages = [ + [ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + } + ], + [ + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + } + ] + ]; + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const pagination = installPaginationHarness(pages); + const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ + success: true as const, + rates: + authorId === "111" + ? { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + : { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + })); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics, + window + })); + + await controller.ready; + setInputValue('[data-plugin-filter-single="input"]', "0.1"); + click('[data-plugin-filter-apply="button"]'); + await flush(); + await flush(); + + expect(pagination.getClicks()).toBe(1); + expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual( + expect.arrayContaining(["111", "222"]) + ); + }); }); function buildMarketFixture() { @@ -222,6 +571,281 @@ function buildMarketFixture() { `; } +function buildRealMarketFixture( + rows: Array<{ + authorId: string; + authorName: string; + price21To60s: string; + }> +) { + return ` +
+ +
+
+
+ ${rows + .map( + (row) => ` + + ` + ) + .join("")} +
+
+
+
+ ${rows + .map( + (row) => ` +
代表视频${row.authorName}
+ ` + ) + .join("")} +
+
+
+
+ ${rows + .map( + (row) => ` +
${row.price21To60s}
+ ` + ) + .join("")} +
+
+ ${rows + .map( + (row) => ` +
下单
+ ` + ) + .join("")} +
+
+
+
+ + `; +} + +function buildRealMarketFixtureWithoutAuthorIds( + rows: Array<{ + authorName: string; + price21To60s: string; + }> +) { + return ` +
+ +
+
+
+ ${rows + .map( + (row) => ` +
+
+ ${row.authorName} +
+
+ ` + ) + .join("")} +
+
+
+
+ ${rows + .map( + (_, index) => ` +
代表视频${index + 1}
+ ` + ) + .join("")} +
+
+
+
+ ${rows + .map( + (row) => ` +
${row.price21To60s}
+ ` + ) + .join("")} +
+
+ ${rows + .map( + () => ` +
下单
+ ` + ) + .join("")} +
+
+
+
+ `; +} + +function attachMarketListState( + marketList: Array<{ + attribute_datas?: { + avg_search_after_view_rate_30d?: string; + nickname?: string; + }; + star_id?: string; + }> +) { + const marketRoot = document.querySelector('[data-testid="market-root"]'); + if (!(marketRoot instanceof HTMLElement)) { + throw new Error("Missing market root"); + } + + Object.defineProperty(marketRoot, "__vue__", { + configurable: true, + value: { + _setupState: { + __$temp_1: { + marketList + } + } + } + }); +} + +function installPaginationHarness( + pages: Array< + Array<{ + authorId: string; + authorName: string; + price21To60s: string; + }> + > +) { + let pageIndex = 0; + let clicks = 0; + const nextButton = document.querySelector( + '[data-testid="next-page"]' + ) as HTMLButtonElement | null; + if (!nextButton) { + throw new Error("Missing next page button"); + } + + const renderPage = () => { + const authorColumn = document.querySelector( + '[data-testid="author-section"] .content-column' + ) as HTMLElement | null; + const middleColumn = document.querySelector( + '.middle-columns .content-column' + ) as HTMLElement | null; + const rightColumns = document.querySelectorAll( + '[data-testid="right-section"] > .content-column' + ); + + if (!authorColumn || !middleColumn || rightColumns.length < 2) { + throw new Error("Missing market columns for pagination harness"); + } + + const rows = pages[pageIndex]; + authorColumn.innerHTML = rows + .map( + (row) => ` + + ` + ) + .join(""); + middleColumn.innerHTML = rows + .map( + (row) => ` +
代表视频${row.authorName}
+ ` + ) + .join(""); + (rightColumns[0] as HTMLElement).innerHTML = rows + .map( + (row) => ` +
${row.price21To60s}
+ ` + ) + .join(""); + (rightColumns[1] as HTMLElement).innerHTML = rows + .map( + (row) => ` +
下单
+ ` + ) + .join(""); + + nextButton.disabled = pageIndex >= pages.length - 1; + nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false"); + }; + + nextButton.addEventListener("click", () => { + if (pageIndex >= pages.length - 1) { + return; + } + + clicks += 1; + pageIndex += 1; + renderPage(); + }); + + renderPage(); + + return { + getClicks() { + return clicks; + } + }; +} + +function createMutationObserverFactory() { + let callback: MutationCallback = () => undefined; + + return { + factory(nextCallback: MutationCallback) { + callback = nextCallback; + return { + disconnect() {}, + observe() {} + }; + }, + trigger() { + callback([], {} as MutationObserver); + } + }; +} + function click(selector: string) { const element = document.querySelector(selector) as HTMLButtonElement | null; if (!element) { @@ -255,7 +879,29 @@ function readRowOrder() { ); } +function readDivRightRowTexts(rowIndex: number) { + return Array.from( + document.querySelectorAll('[data-testid="right-section"] > .content-column'), + (column) => + column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + ); +} + +function trackController void }>(controller: T): T { + if (controller.dispose) { + disposers.push(() => controller.dispose?.()); + } + + return controller; +} + async function flush() { await Promise.resolve(); await Promise.resolve(); } + +async function flushWithTimers() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index 907de5a..b4c00f3 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -110,4 +110,243 @@ describe("market-dom-sync", () => { ).map((row) => row.getAttribute("data-author-id")) ).toEqual(["b", "a"]); }); + + test("supports the real div-grid market layout and keeps rows aligned", () => { + document.body.innerHTML = buildRealMarketGridFixture(); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table"); + } + + expect(readRightHeaderTexts()).toEqual([ + "21-60s报价", + "单视频看后搜率", + "个人视频看后搜率", + "操作" + ]); + expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); + + renderMarketRowState(table.rows[0], { + authorId: "111", + authorName: "达人 A", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.02 - 0.1%" + } + }); + + expect(readRightRowTexts(0)).toEqual([ + "¥450,000", + "0.5% - 1%", + "0.02% - 0.1%", + "下单" + ]); + + applyRowVisibility(table, new Set(["222"])); + + expect(readAuthorRowHiddenStates()).toEqual([true, false]); + expect(readRightActionHiddenStates()).toEqual([true, false]); + + applyRowVisibility(table, new Set(["111", "222"])); + applyRowOrder(table, ["222", "111"]); + + expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]); + expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]); + }); + + test("falls back to the market vue state when the DOM has no author id", () => { + document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); + attachMarketVueState([ + { + attribute_datas: { + nickname: "达人 A" + }, + star_id: "111" + }, + { + attribute_datas: { + nickname: "达人 B" + }, + star_id: "222" + } + ]); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table"); + } + + expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); + }); + + test("falls back to serialized market rows when vue state is unavailable", () => { + document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); + document.documentElement.setAttribute( + "data-sces-market-rows", + JSON.stringify([ + { + authorId: "111", + authorName: "达人 A", + singleVideoAfterSearchRate: "0.02%" + }, + { + authorId: "222", + authorName: "达人 B" + } + ]) + ); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table"); + } + + expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); + expect(table.rows[0].rates).toEqual({ + singleVideoAfterSearchRate: "0.02%" + }); + }); }); + +function buildRealMarketGridFixture() { + return ` +
+ +
+
+
+
+ 达人 A +
+
+ 达人 B +
+
+
+
+
+
代表视频A
+
代表视频B
+
+
+
+
+
¥450,000
+
¥20,000
+
+
+
下单
+
下单
+
+
+
+
+ `; +} + +function buildRealMarketGridFixtureWithoutAuthorIds() { + return ` +
+ +
+
+
+
+ 达人 A +
+
+ 达人 B +
+
+
+
+
+
¥450,000
+
¥20,000
+
+
+
下单
+
下单
+
+
+
+
+ `; +} + +function attachMarketVueState( + marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }> +) { + const marketRoot = document.querySelector(".base-author-list"); + if (!(marketRoot instanceof HTMLElement)) { + throw new Error("Expected market root"); + } + + Object.defineProperty(marketRoot, "__vue__", { + configurable: true, + value: { + _setupState: { + __$temp_1: { + marketList + } + } + } + }); +} + +function readRightHeaderTexts() { + return Array.from( + document.querySelectorAll('[data-testid="right-header"] > *'), + (cell) => cell.textContent?.trim() ?? "" + ); +} + +function readRightRowTexts(rowIndex: number) { + return Array.from( + document.querySelectorAll('[data-testid="right-section"] > .content-column'), + (column) => + column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + ); +} + +function readAuthorNames() { + return Array.from( + document.querySelectorAll('[data-testid="author-section"] .content-cell a'), + (link) => link.textContent?.trim() ?? "" + ); +} + +function readAuthorRowHiddenStates() { + return Array.from( + document.querySelectorAll('[data-testid^="author-cell-"]'), + (cell) => (cell as HTMLElement).hidden + ); +} + +function readRightActionHiddenStates() { + return Array.from( + document.querySelectorAll('[data-testid^="action-cell-"]'), + (cell) => (cell as HTMLElement).hidden + ); +} diff --git a/tests/rate-normalizer.test.ts b/tests/rate-normalizer.test.ts index 138158e..1007eda 100644 --- a/tests/rate-normalizer.test.ts +++ b/tests/rate-normalizer.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { compareRateValues, + normalizeFractionRateDisplay, normalizeRateDisplay, parseRateLowerBound } from "../src/shared/rate-normalizer"; @@ -26,4 +27,10 @@ describe("rate-normalizer", () => { test("orders missing values after real values", () => { expect(compareRateValues(null, "0.02% - 0.1%")).toBeGreaterThan(0); }); + + test("normalizes fraction rate values into percentage display", () => { + expect(normalizeFractionRateDisplay("0.0002")).toBe("0.02%"); + expect(normalizeFractionRateDisplay("0.0044")).toBe("0.44%"); + expect(normalizeFractionRateDisplay("0")).toBe("0%"); + }); });