diff --git a/docs/superpowers/plans/2026-04-21-market-export-range.md b/docs/superpowers/plans/2026-04-21-market-export-range.md new file mode 100644 index 0000000..d1d6645 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-market-export-range.md @@ -0,0 +1,217 @@ +# Market Export Range Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add configurable CSV export ranges for the Xingtu market plugin, defaulting to the first 5 pages and supporting current page, preset ranges, all pages, and custom page counts. + +**Architecture:** Keep filter and sort scoped to the current page, and introduce a dedicated batch-export path that only runs when exporting. Extend the toolbar with export-range controls, extract page-navigation/signature helpers, and add a focused controller that iterates pages, harvests lazy-loaded DOM fields, merges records by `authorId`, and returns a single record set for CSV generation. + +**Tech Stack:** TypeScript, Vitest, jsdom, Chrome MV3 content script + +--- + +### Task 1: Extend Toolbar Controls For Export Range + +**Files:** +- Modify: `src/content/market/plugin-toolbar.ts` +- Test: `tests/market-content-entry.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add tests that assert: +- the toolbar defaults to `前5页` +- selecting `自定义` shows and uses a page-count input +- invalid custom values prevent export +- export mode disables toolbar controls during a running task + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/market-content-entry.test.ts -t "export range"` +Expected: FAIL because the toolbar has no range selector or custom input behavior. + +- [ ] **Step 3: Write the minimal implementation** + +Update `plugin-toolbar.ts` to: +- add an export-range `` +- add status text support +- expose helpers for reading range state and toggling disabled/running UI + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- tests/market-content-entry.test.ts -t "export range"` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts +git commit -m "feat: add export range toolbar controls" +``` + +### Task 2: Add Batch Export Coverage + +**Files:** +- Modify: `tests/market-content-entry.test.ts` +- Modify: `tests/market-dom-sync.test.ts` +- Test: `tests/market-content-entry.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add tests that assert: +- `当前页` exports only the current page +- `前5页` iterates through pages until it reaches page 5 or runs out of pages +- `全部` stops when the next-page control is no longer usable +- repeated authors across pages are merged by `authorId` +- empty fields from later pages do not erase existing populated fields +- page export waits for page signature change before harvesting the next page + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/market-content-entry.test.ts` +Expected: FAIL on the new batch-export expectations. + +- [ ] **Step 3: Write the minimal implementation scaffolding** + +Only introduce the minimum new helpers/types needed to make the tests compile: +- export range type(s) +- page signature helpers +- harness utilities in tests + +- [ ] **Step 4: Run tests to verify the failures are now behavioral** + +Run: `npm test -- tests/market-content-entry.test.ts` +Expected: FAIL because batch export logic is still missing, not because types/selectors are missing. + +- [ ] **Step 5: Commit** + +```bash +git add tests/market-content-entry.test.ts tests/market-dom-sync.test.ts src/content/market/types.ts +git commit -m "test: cover multi-page export behavior" +``` + +### Task 3: Implement Batch Export Controller + +**Files:** +- Create: `src/content/market/export-range-controller.ts` +- Modify: `src/content/market/types.ts` +- Modify: `src/content/market/dom-sync.ts` +- Modify: `src/content/market/result-store.ts` +- Test: `tests/market-content-entry.test.ts` +- Test: `tests/market-dom-sync.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add focused tests for: +- reading a page signature from current DOM +- detecting/using the next-page control +- merging rows by `authorId` with non-empty-field preference + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/market-dom-sync.test.ts` +Expected: FAIL because these helpers/controller do not exist yet. + +- [ ] **Step 3: Write the minimal implementation** + +Implement `export-range-controller.ts` to: +- normalize export targets (`current`, `count`, `all`) +- harvest current page rows with the existing lazy-load scroll path +- read page signatures +- click/advance pagination and wait for signature change +- merge pages by `authorId` without overwriting non-empty fields +- return a final `MarketRecord[]` + +Keep `dom-sync.ts` focused on DOM reads: +- page signature +- next-page element lookup +- next-page enabled check + +- [ ] **Step 4: Run tests to verify they pass** + +Run: +- `npm test -- tests/market-dom-sync.test.ts` +- `npm test -- tests/market-content-entry.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/market/export-range-controller.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts tests/market-content-entry.test.ts tests/market-dom-sync.test.ts +git commit -m "feat: add multi-page export controller" +``` + +### Task 4: Wire Controller Into Market Entry + +**Files:** +- Modify: `src/content/market/index.ts` +- Modify: `src/content/market/plugin-toolbar.ts` +- Test: `tests/market-content-entry.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Add tests that assert: +- `导出CSV` uses the selected range +- progress text updates while exporting +- controls are re-enabled on success and failure +- invalid custom input blocks export without calling CSV builder + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm test -- tests/market-content-entry.test.ts` +Expected: FAIL because `market/index.ts` still always exports current-page records. + +- [ ] **Step 3: Write the minimal implementation** + +Update `market/index.ts` to: +- parse toolbar export range +- keep current-page export for `当前页` +- delegate batch modes to `export-range-controller.ts` +- update toolbar running/error/progress state +- preserve existing current-page lazy-load harvest path + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm test -- tests/market-content-entry.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/content/market/index.ts src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts +git commit -m "feat: wire export range selection into market export" +``` + +### Task 5: Verify End-To-End + +**Files:** +- Modify: `docs/superpowers/plans/2026-04-21-market-export-range.md` + +- [ ] **Step 1: Run targeted regression tests** + +Run: +- `npm test -- tests/market-content-entry.test.ts` +- `npm test -- tests/market-dom-sync.test.ts` + +Expected: PASS + +- [ ] **Step 2: Run the full suite** + +Run: `npm test` +Expected: PASS with all test files green. + +- [ ] **Step 3: Run the build** + +Run: `npm run build` +Expected: PASS and emit updated `dist/` bundles. + +- [ ] **Step 4: Mark the plan status** + +Update this document’s checkboxes to reflect completed steps. + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/plans/2026-04-21-market-export-range.md +git commit -m "docs: record export range implementation progress" +``` diff --git a/docs/superpowers/specs/2026-04-21-market-export-range-design.md b/docs/superpowers/specs/2026-04-21-market-export-range-design.md new file mode 100644 index 0000000..481df78 --- /dev/null +++ b/docs/superpowers/specs/2026-04-21-market-export-range-design.md @@ -0,0 +1,257 @@ +# 星图市场导出范围设计 + +## 背景 + +当前插件的 `导出CSV` 只导出当前页数据,并在星图默认列后追加两列: + +- `单视频看后搜率` +- `个人视频看后搜率` + +现阶段用户希望把导出范围扩展为可配置页数,而不是固定当前页。同时需要保留当前默认列导出逻辑,避免重新引入之前“全量扫描导致页面闪动、卡顿、排序异常”的问题。 + +## 目标 + +- 支持导出范围配置,默认导出前 `5` 页。 +- 支持以下范围选项: + - `当前页` + - `前5页` + - `前10页` + - `全部` + - `自定义` +- 保留当前 CSV 规则: + - 先导出星图页面默认列 + - 末尾追加两列插件字段 +- 批量导出时真实翻页采集当前筛选结果页,不改变当前页面筛选条件来源。 +- 保持当前“筛选/排序只作用当前页”的行为,不重新绑定多页扫描逻辑。 + +## 非目标 + +- 不改为直接调用星图列表接口拉多页数据。 +- 不让插件筛选或排序自动跨页生效。 +- 不处理“导出后自动回到最初分页位置”的复杂恢复,首版只要求滚动位置恢复到单页采集前。 + +## 交互设计 + +在插件工具栏中保留一个 `导出CSV` 按钮,同时新增导出范围控件。 + +### 范围控件 + +- 默认值:`前5页` +- 可选项: + - `当前页` + - `前5页` + - `前10页` + - `全部` + - `自定义` +- 选择 `自定义` 时,展示页数输入框 + - 仅接受正整数 + - 无效值时禁止导出并提示错误 + +### 任务状态 + +- 导出开始后: + - `导出CSV` 按钮禁用 + - 筛选按钮、排序按钮、范围控件、自定义输入框一并禁用 +- 按钮文案根据任务模式更新: + - 当前页:`导出中...` + - 前 N 页:`导出中 3/5 页...` + - 全部:`导出中 第3页...` +- 全部导出时额外显示轻提示: + - `全部导出可能耗时较长,页面会自动翻页。` + +### 失败反馈 + +- 任一页采集失败时直接终止 +- 通过按钮附近状态文本或 `alert` 提示: + - `第 N 页导出失败,请稍后重试` +- 失败时不下载残缺 CSV + +## 执行流程 + +### 当前页导出 + +当前页模式沿用现有逻辑: + +1. 等待当前页表格可读取 +2. 自动滚动当前页一次,补齐懒加载字段 +3. 读取当前页星图默认列 +4. 合并插件两列 +5. 生成 CSV 并下载 + +### 多页导出 + +多页模式仅在导出任务中启用,不影响平时筛选和排序。 + +1. 读取导出范围配置 +2. 初始化批量任务状态与结果聚合器 +3. 对当前页执行: + - 等待表格稳定 + - 自动滚动当前页以采全默认列 + - 读取当前页记录 + - 以 `authorId` 合并入结果集 +4. 判断是否达到目标页数 +5. 若未完成,则点击下一页 +6. 等待“页切换完成”信号 +7. 重复步骤 3 到 6 +8. 汇总所有记录,生成单个 CSV 下载 + +### 全部导出停止条件 + +`全部` 模式从当前页开始向后翻页,直到任一条件成立: + +- 下一页按钮不可点击 +- 当前页签名不再变化 +- 页面无法继续翻页并达到超时阈值 + +## 页切换判定 + +批量导出不能只依赖固定 `setTimeout`。翻页完成需要同时满足至少一个强信号: + +- 当前页作者 ID 集合发生变化 +- 当前页首行作者 ID 变化 +- 当前页活动分页按钮变化 +- 下一页按钮状态变化 + +为避免误判,翻页流程需要: + +1. 记录翻页前页签名 +2. 点击下一页 +3. 轮询直到签名变化或超时 +4. 签名变化后再执行一次当前页表格稳定等待 + +## 当前页采集策略 + +当前页导出已知存在懒加载问题:页面初加载时后半段行的默认列为空,滚动后才补齐。 + +因此无论当前页导出还是多页导出,每一页都必须执行: + +1. 初次读取当前页快照并写入 store +2. 查找当前市场列表滚动容器 +3. 逐步向下滚动直到该页底部 +4. 每次滚动后等待 DOM 稳定,再采集一轮 +5. 恢复滚动位置并再采集一轮 + +这一步只针对“当前已打开的页”,不跨页扫描。 + +## 数据合并规则 + +### 去重键 + +- 使用 `authorId` 去重 + +### 字段合并 + +- 新页记录有值时,补充到已有记录 +- 新页记录为空时,不覆盖已有非空值 +- 插件两列沿用当前已有的合并规则 + +### CSV 表头 + +- 先保留星图默认列顺序 +- 再追加: + - `单视频看后搜率` + - `个人视频看后搜率` + +## 错误处理 + +### 单页失败 + +以下情况视为单页失败: + +- 找不到表格根节点 +- 找不到下一页按钮但目标尚未完成 +- 翻页后页签名未变化且超时 +- 当前页采集过程中出现异常 + +处理方式: + +- 立即终止任务 +- 恢复工具栏控件状态 +- 显示页级错误信息 +- 不触发下载 + +### 用户干预 + +首版不额外提供取消按钮,但应防御以下情况: + +- 用户手动切换筛选 +- 用户手动翻页 +- 用户再次点击导出 + +处理方式: + +- 导出期间禁用插件控件 +- 对关键节点重新校验表格和页签名 +- 如环境已被破坏,终止任务并报错 + +## 实现建议 + +建议按以下边界组织实现: + +### `plugin-toolbar` + +- 新增导出范围选择器 +- 新增自定义页数输入框 +- 新增导出过程中的状态展示 + +### `market/index` + +- 保留当前页导出逻辑作为单页模式 +- 新增批量导出入口 +- 管理任务状态、按钮禁用、进度回写 + +### 新建批量导出控制器 + +建议新增类似 `export-range-controller.ts` 的模块,职责包括: + +- 读取当前页记录 +- 控制翻页 +- 等待页切换 +- 聚合多页数据 +- 统一返回最终 `MarketRecord[]` + +这样可以避免把 `market/index.ts` 继续堆大。 + +### `dom-sync` 或新辅助模块 + +- 提供读取当前页签名 +- 提供定位下一页按钮 +- 提供判断下一页是否可点击 + +## 测试策略 + +### 单元测试 + +- 默认导出范围为前 5 页 +- 选择 `当前页` 时只导出一页 +- 选择 `前10页` 时最多导出 10 页 +- `自定义` 输入非法值时不启动导出 +- `全部` 模式在没有下一页时停止 +- 多页合并时按 `authorId` 去重 +- 空字段不会覆盖已有非空字段 + +### 集成测试 + +- 批量导出时会依次点击下一页 +- 翻页后等待新页签名再采集 +- 每一页在导出前都会执行滚动采集 +- 中途某页失败时不生成 CSV +- 导出期间工具栏控件被禁用 + +## 风险 + +- 星图分页 DOM 结构可能不稳定,下一页按钮定位需要做降级兼容。 +- 全部导出在高页数场景下耗时会较长。 +- 页面中途刷新或筛选重算可能打断批量任务。 + +## 推荐实施顺序 + +1. 工具栏新增范围选择与自定义输入 +2. 抽出批量导出控制器 +3. 实现前 N 页导出 +4. 在前 N 页能力稳定后开放 `全部` +5. 补齐全部相关失败与超时测试 + +## 决策结论 + +采用“一个导出按钮 + 范围选择器”的方案,默认导出前 `5` 页,支持 `全部` 和 `自定义`。批量模式通过真实翻页逐页采集当前筛选结果,不把多页扫描重新引入到筛选和排序链路中。 diff --git a/package.json b/package.json index e504118..2f607dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "market-plugin-impl", - "version": "0.0.0", + "version": "0.2.0421.2", "description": "Bootstrap for the Xingtu market Chrome MV3 extension.", "private": true, "scripts": { diff --git a/scripts/build.mjs b/scripts/build.mjs index bd7a286..52abd9a 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -10,6 +10,7 @@ const distDir = path.join(projectRoot, "dist"); await rm(distDir, { recursive: true, force: true }); await mkdir(path.join(distDir, "content"), { recursive: true }); +await mkdir(path.join(distDir, "background"), { recursive: true }); await build({ entry: { @@ -32,6 +33,23 @@ await build({ } }); +await build({ + entry: { + index: path.join(projectRoot, "src/background/index.ts") + }, + format: ["iife"], + platform: "browser", + target: "chrome114", + outDir: path.join(distDir, "background"), + clean: false, + splitting: false, + sourcemap: false, + minify: false, + outExtension() { + return { js: ".js" }; + } +}); + await cp( path.join(projectRoot, "src/manifest.json"), path.join(distDir, "manifest.json") diff --git a/src/background/index.ts b/src/background/index.ts new file mode 100644 index 0000000..c487728 --- /dev/null +++ b/src/background/index.ts @@ -0,0 +1,95 @@ +interface ChromeDownloadsLike { + download( + options: { + filename: string; + saveAs?: boolean; + url: string; + }, + callback?: () => void + ): Promise | void; +} + +interface ChromeRuntimeLike { + onMessage?: { + addListener( + listener: ( + message: unknown, + sender: unknown, + sendResponse: (response: unknown) => void + ) => boolean | void + ): void; + }; +} + +interface ChromeLike { + downloads?: ChromeDownloadsLike; + runtime?: ChromeRuntimeLike; +} + +type DownloadMarketCsvMessage = { + csv: string; + filename: string; + type: "download-market-csv"; +}; + +export function registerBackgroundMessageHandler( + chromeLike: ChromeLike = ( + globalThis as typeof globalThis & { + chrome?: ChromeLike; + } + ).chrome ?? {} +): void { + chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => { + if (!isDownloadMarketCsvMessage(message)) { + return; + } + + void triggerCsvDownload(chromeLike, message) + .then(() => { + sendResponse({ ok: true }); + }) + .catch((error) => { + sendResponse({ + error: error instanceof Error ? error.message : String(error), + ok: false + }); + }); + + return true; + }); +} + +async function triggerCsvDownload( + chromeLike: ChromeLike, + message: DownloadMarketCsvMessage +): Promise { + if (!chromeLike.downloads?.download) { + throw new Error("chrome.downloads.download is unavailable"); + } + + const csvUrl = `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${message.csv}`)}`; + await Promise.resolve( + chromeLike.downloads.download({ + filename: message.filename, + saveAs: false, + url: csvUrl + }) + ); +} + +function isDownloadMarketCsvMessage( + message: unknown +): message is DownloadMarketCsvMessage { + if (!message || typeof message !== "object") { + return false; + } + + const candidate = message as Partial; + return ( + candidate.type === "download-market-csv" && + typeof candidate.csv === "string" && + typeof candidate.filename === "string" + ); +} + +registerBackgroundMessageHandler(); diff --git a/src/content/index.ts b/src/content/index.ts index c84cb5b..07e2c6e 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -6,8 +6,11 @@ import { interface ChromeRuntimeLike { getURL?: (path: string) => string; id?: string; + sendMessage?: (message: unknown) => void | Promise; } +const DOWNLOAD_MARKET_CSV_MESSAGE = "download-market-csv"; + interface BootContentScriptOptions { createMarketController?: ( options: CreateMarketControllerOptions @@ -32,6 +35,13 @@ export async function bootContentScript( return controllerFactory({ document: currentDocument, + onCsvReady: (csv: string) => { + if (requestCsvDownload(csv)) { + return; + } + + downloadCsv(currentDocument, currentWindow, csv); + }, window: currentWindow }); } @@ -72,6 +82,43 @@ function bootstrapContentScript() { bootstrapContentScript(); +function requestCsvDownload(csv: string): boolean { + const runtime = ( + globalThis as typeof globalThis & { + chrome?: { runtime?: ChromeRuntimeLike }; + } + ).chrome?.runtime; + + if (!runtime?.id || typeof runtime.sendMessage !== "function") { + return false; + } + + runtime.sendMessage({ + csv, + filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`, + type: DOWNLOAD_MARKET_CSV_MESSAGE + }); + return true; +} + +function downloadCsv(document: Document, window: Window, csv: string): void { + const blob = new Blob(["\uFEFF", csv], { + type: "text/csv;charset=utf-8" + }); + const objectUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`; + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(objectUrl); +} + +function formatTimestampForFilename(): string { + return new Date().toISOString().replace(/[:.]/g, "-"); +} + function installMarketPageBridge(document: Document) { if ( document.documentElement.querySelector( diff --git a/src/content/market/csv-exporter.ts b/src/content/market/csv-exporter.ts index 8c10678..7251e8d 100644 --- a/src/content/market/csv-exporter.ts +++ b/src/content/market/csv-exporter.ts @@ -2,7 +2,12 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer"; import { escapeCsvCell } from "../../shared/csv"; import type { MarketRecord } from "./types"; -const CSV_COLUMNS = [ +type CsvColumn = { + header: string; + readValue: (record: MarketRecord) => string; +}; + +const FALLBACK_BASE_COLUMNS: CsvColumn[] = [ { header: "达人ID", readValue: (record: MarketRecord) => record.authorId @@ -18,7 +23,10 @@ const CSV_COLUMNS = [ { header: "21-60s报价", readValue: (record: MarketRecord) => record.price21To60s ?? "" - }, + } +]; + +const RATE_COLUMNS: CsvColumn[] = [ { header: "单视频看后搜率", readValue: (record: MarketRecord) => @@ -32,18 +40,41 @@ const CSV_COLUMNS = [ record.rates?.personalVideoAfterSearchRate ? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate) : "" - }, - { - header: "插件数据状态", - readValue: (record: MarketRecord) => record.status } -] as const; +]; export function buildMarketCsv(records: MarketRecord[]): string { - const headerLine = CSV_COLUMNS.map((column) => column.header).join(","); + const baseColumns = buildBaseColumns(records); + const csvColumns = [...baseColumns, ...RATE_COLUMNS]; + const headerLine = csvColumns.map((column) => column.header).join(","); const rowLines = records.map((record) => - CSV_COLUMNS.map((column) => escapeCsvCell(column.readValue(record))).join(",") + csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",") ); return [headerLine, ...rowLines].join("\n"); } + +function buildBaseColumns(records: MarketRecord[]): CsvColumn[] { + const orderedHeaders: string[] = []; + const seenHeaders = new Set(); + + records.forEach((record) => { + Object.keys(record.exportFields ?? {}).forEach((header) => { + if (seenHeaders.has(header)) { + return; + } + + seenHeaders.add(header); + orderedHeaders.push(header); + }); + }); + + if (orderedHeaders.length === 0) { + return FALLBACK_BASE_COLUMNS; + } + + return orderedHeaders.map((header) => ({ + header, + readValue: (record: MarketRecord) => record.exportFields?.[header] ?? "" + })); +} diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index 8035dbf..bec0ab3 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -20,6 +20,7 @@ type RowOrderTarget = { export interface MarketRowDom { authorId: string; authorName: string; + exportFields?: Record; hasDirectRatesSource?: boolean; personalCell: HTMLElement; price21To60s?: string; @@ -38,6 +39,68 @@ export function syncMarketTable(root: ParentNode): MarketTableDom | null { return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root); } +export function readMarketPageSignature(root: ParentNode): string { + const document = getOwnerDocument(root); + const explicitPageIndex = + document?.documentElement.getAttribute("data-test-page-index") ?? ""; + const activePageIndex = + document + ?.querySelector(".el-pagination .number.active, .xt-pagination .number.active") + ?.textContent?.trim() ?? ""; + const table = syncMarketTable(root); + const authorIds = + table?.rows + .map((row) => row.authorId) + .filter((authorId) => Boolean(authorId)) + .join("|") ?? ""; + + return `${explicitPageIndex || activePageIndex}::${authorIds}`; +} + +export function findNextPageControl(root: ParentNode): HTMLElement | null { + const document = getOwnerDocument(root); + if (!document) { + return null; + } + + const explicitControl = document.querySelector('[data-testid="next-page"]'); + if (explicitControl instanceof document.defaultView!.HTMLElement) { + return explicitControl; + } + + const paginationNextControl = document.querySelector( + ".el-pagination .btn-next, .xt-pagination .btn-next" + ); + if (paginationNextControl instanceof document.defaultView!.HTMLElement) { + return paginationNextControl; + } + + const candidates = Array.from( + document.querySelectorAll("button, a, [role='button']") + ).filter( + (element): element is HTMLElement => + element instanceof document.defaultView!.HTMLElement + ); + + return ( + candidates.find((element) => + /下一页|next/i.test(normalizeExportCellText(element.textContent)) + ) ?? null + ); +} + +export function isPageControlDisabled(control: HTMLElement | null): boolean { + if (!control) { + return true; + } + + if (control instanceof HTMLButtonElement) { + return control.disabled; + } + + return control.getAttribute("aria-disabled") === "true"; +} + export function renderMarketRowState( rowDom: MarketRowDom, record: MarketRecord @@ -109,6 +172,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率"); ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率"); + const headerLabelsByField = readSyntheticHeaderLabels(header); const rows = Array.from(body.querySelectorAll("[data-market-row]")).map( (rowElement) => { const row = rowElement as HTMLElement; @@ -120,6 +184,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { authorName: row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ?? "", + exportFields: readSyntheticExportFields(row, headerLabelsByField), hasDirectRatesSource: false, orderTargets: [ { @@ -228,6 +293,11 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { ? getDirectContentColumns(section) : [] ); + const allHeaderCells = Array.from(headerSection.children).flatMap((section) => + section instanceof root.ownerDocument.defaultView!.HTMLElement + ? getDirectHeaderCells(section) + : [] + ); const authorCells = getDirectContentCells(authorColumn); const singleCells = getDirectContentCells(singleColumn); const personalCells = getDirectContentCells(personalColumn); @@ -263,6 +333,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { { authorId, authorName, + exportFields: readExportFieldsForDivGridRow(allHeaderCells, rowCells), hasDirectRatesSource: vueMarketRow?.hasDirectRatesSource ?? serializedMarketRow?.hasDirectRatesSource ?? @@ -420,6 +491,64 @@ function getOwnerDocument(root: ParentNode): Document | null { return root instanceof Document ? root : null; } +function readSyntheticHeaderLabels(header: HTMLElement): Record { + return Array.from(header.querySelectorAll("[data-market-header-cell]")).reduce< + Record + >((labels, cell) => { + if (!(cell instanceof header.ownerDocument.defaultView!.HTMLElement)) { + return labels; + } + + const field = cell.dataset.marketHeaderCell; + if (!field) { + return labels; + } + + labels[field] = normalizeExportCellText(cell.textContent); + return labels; + }, {}); +} + +function readSyntheticExportFields( + row: HTMLElement, + headerLabelsByField: Record +): Record { + const exportFields: Record = {}; + for (const cell of row.querySelectorAll("[data-market-field]")) { + if (!(cell instanceof row.ownerDocument.defaultView!.HTMLElement)) { + continue; + } + + const field = cell.dataset.marketField; + const headerLabel = field ? headerLabelsByField[field] : ""; + if (!shouldExportColumn(headerLabel)) { + continue; + } + + exportFields[headerLabel] = normalizeExportCellText(cell.textContent); + } + + return exportFields; +} + +function readExportFieldsForDivGridRow( + headerCells: HTMLElement[], + rowCells: HTMLElement[] +): Record { + const exportFields: Record = {}; + + rowCells.forEach((cell, index) => { + const headerLabel = normalizeExportCellText(headerCells[index]?.textContent); + if (!shouldExportColumn(headerLabel)) { + return; + } + + exportFields[headerLabel] = normalizeExportCellText(cell.textContent); + }); + + return exportFields; +} + function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null { let current = cell.previousElementSibling; while (current) { @@ -674,6 +803,19 @@ function normalizeMarketListRate(value: unknown): string | null { return typeof value === "string" ? normalizeFractionRateDisplay(value) : null; } +function normalizeExportCellText(value: string | null | undefined): string { + return value?.replace(/\s+/g, " ").trim() ?? ""; +} + +function shouldExportColumn(label: string): boolean { + return Boolean( + label && + label !== ACTION_HEADER_TEXT && + label !== "单视频看后搜率" && + label !== "个人视频看后搜率" + ); +} + function readRateCellText(value: string | undefined): string { return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT; } diff --git a/src/content/market/export-range-controller.ts b/src/content/market/export-range-controller.ts new file mode 100644 index 0000000..7b0b3b2 --- /dev/null +++ b/src/content/market/export-range-controller.ts @@ -0,0 +1,276 @@ +import { + findNextPageControl, + isPageControlDisabled, + readMarketPageSignature +} from "./dom-sync"; +import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types"; + +interface ExportRangeControllerOptions { + document: Document; + onProgress?: (state: { currentPage: number; totalPages?: number }) => void; + prepareCurrentPageForExport(): Promise; + readCurrentPageRecords(): MarketRecord[]; + window: Window; +} + +export function createExportRangeController(options: ExportRangeControllerOptions) { + return { + async exportRecords(target: MarketExportTarget): Promise { + const mergedRecords = new Map(); + let currentPage = 0; + let expectedMinimumRowCount: number | undefined; + + while (true) { + currentPage += 1; + options.onProgress?.({ + currentPage, + totalPages: target.mode === "count" ? target.pageCount : undefined + }); + const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount); + if (!currentPageReady) { + throw new Error(`第 ${currentPage} 页加载超时,请稍后重试`); + } + + await options.prepareCurrentPageForExport(); + const currentPageRecords = options.readCurrentPageRecords(); + currentPageRecords.forEach((record) => { + const existingRecord = mergedRecords.get(record.authorId); + mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record)); + }); + expectedMinimumRowCount = Math.max( + expectedMinimumRowCount ?? 0, + currentPageRecords.length + ); + + if (target.mode === "count" && currentPage >= target.pageCount) { + break; + } + + const previousSignature = readMarketPageSignature(options.document); + const nextPageControl = findNextPageControl(options.document); + if (!nextPageControl || isPageControlDisabled(nextPageControl)) { + break; + } + + nextPageControl.click(); + const pageChanged = await waitForPageChange(previousSignature); + if (!pageChanged) { + throw new Error(`第 ${currentPage + 1} 页导出失败,请稍后重试`); + } + } + + return Array.from(mergedRecords.values()); + } + }; + + async function waitForPageChange(previousSignature: string): Promise { + const previousPageState = parsePageSignature(previousSignature); + + for (let attempt = 0; attempt < 60; attempt += 1) { + await new Promise((resolve) => { + options.window.setTimeout(resolve, 50); + }); + await Promise.resolve(); + + const nextSignature = readMarketPageSignature(options.document); + const nextPageState = parsePageSignature(nextSignature); + if (hasLoadedNextPage(previousPageState, nextPageState)) { + return true; + } + } + + return false; + } + + async function waitForCurrentPageReady( + expectedMinimumRowCount: number | undefined + ): Promise { + let stableAttemptCount = 0; + let lastReadyFingerprint = ""; + + for (let attempt = 0; attempt < 80; attempt += 1) { + await new Promise((resolve) => { + options.window.setTimeout(resolve, 150); + }); + await Promise.resolve(); + + const pageState = readCurrentPageState(); + if (!pageState.authorIds || pageState.rowCount <= 0) { + stableAttemptCount = 0; + lastReadyFingerprint = ""; + continue; + } + + if ( + typeof expectedMinimumRowCount === "number" && + expectedMinimumRowCount > 0 && + !pageState.isTerminalPage && + pageState.rowCount < expectedMinimumRowCount + ) { + stableAttemptCount = 0; + lastReadyFingerprint = ""; + continue; + } + + const readyFingerprint = [ + pageState.pageToken, + pageState.authorIds, + String(pageState.rowCount), + pageState.isTerminalPage ? "terminal" : "paged" + ].join("::"); + if (readyFingerprint === lastReadyFingerprint) { + stableAttemptCount += 1; + } else { + lastReadyFingerprint = readyFingerprint; + stableAttemptCount = 1; + } + + if (stableAttemptCount >= 6) { + return true; + } + } + + return false; + } + + function readCurrentPageState(): { + authorIds: string; + isTerminalPage: boolean; + pageToken: string; + rowCount: number; + } { + const pageSignature = parsePageSignature(readMarketPageSignature(options.document)); + const nextPageControl = findNextPageControl(options.document); + + return { + authorIds: pageSignature.authorIds, + isTerminalPage: isPageControlDisabled(nextPageControl), + pageToken: pageSignature.pageToken, + rowCount: options.readCurrentPageRecords().length + }; + } +} + +function parsePageSignature(signature: string): { + authorIds: string; + pageToken: string; +} { + const separatorIndex = signature.indexOf("::"); + if (separatorIndex < 0) { + return { + authorIds: "", + pageToken: signature.trim() + }; + } + + return { + authorIds: signature.slice(separatorIndex + 2).trim(), + pageToken: signature.slice(0, separatorIndex).trim() + }; +} + +function hasLoadedNextPage( + previousPageState: { + authorIds: string; + pageToken: string; + }, + nextPageState: { + authorIds: string; + pageToken: string; + } +): boolean { + if (!nextPageState.authorIds) { + return false; + } + + if (nextPageState.pageToken || previousPageState.pageToken) { + return nextPageState.pageToken !== previousPageState.pageToken; + } + + return nextPageState.authorIds !== previousPageState.authorIds; +} + +function mergeMarketRecord( + existingRecord: MarketRecord | undefined, + incomingRecord: MarketRecord +): MarketRecord { + if (!existingRecord) { + return { + ...incomingRecord, + exportFields: mergeFieldMap(undefined, incomingRecord.exportFields), + rates: mergeFieldMap(undefined, incomingRecord.rates) + }; + } + + return { + ...existingRecord, + ...incomingRecord, + authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "", + exportFields: mergeFieldMap( + existingRecord.exportFields, + incomingRecord.exportFields + ), + failureReason: incomingRecord.failureReason ?? existingRecord.failureReason, + hasDirectRatesSource: + existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource, + location: mergeStringValue(existingRecord.location, incomingRecord.location), + price21To60s: mergeStringValue( + existingRecord.price21To60s, + incomingRecord.price21To60s + ), + rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates), + status: mergeStatus(existingRecord.status, incomingRecord.status) + }; +} + +function mergeFieldMap>( + current: T | undefined, + incoming: T | undefined +): T | undefined { + if (!current && !incoming) { + return undefined; + } + + const merged = { + ...(current ?? {}) + } as Record; + + Object.entries(incoming ?? {}).forEach(([key, value]) => { + const currentValue = merged[key]; + if (hasTextValue(value) || !hasTextValue(currentValue)) { + merged[key] = value; + } + }); + + return merged as T; +} + +function mergeStatus( + current: MarketRecordStatus, + incoming: MarketRecordStatus +): MarketRecordStatus { + const priority: Record = { + failed: 1, + idle: 0, + loading: 2, + missing: -1, + success: 3 + }; + + return priority[incoming] >= priority[current] ? incoming : current; +} + +function mergeStringValue( + current: string | undefined, + incoming: string | undefined +): string | undefined { + if (hasTextValue(incoming) || !hasTextValue(current)) { + return incoming ?? current; + } + + return current; +} + +function hasTextValue(value: string | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 2f8aa1f..ab789a6 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -7,24 +7,24 @@ import { type MarketRowDom } from "./dom-sync"; import { applyFilterAndSort } from "./filter-sort-controller"; -import { createFullScanController } from "./full-scan-controller"; import { createMarketApiClient } from "./api-client"; +import { createExportRangeController } from "./export-range-controller"; import { ensurePluginToolbar } from "./plugin-toolbar"; +import { + readToolbarExportTarget, + setToolbarBusyState, + setToolbarExportStatus +} from "./plugin-toolbar"; import { createMarketResultStore } from "./result-store"; import type { MarketApiResult, MarketFilterState, + MarketExportTarget, MarketRecord, MarketRowSnapshot, MarketSortState } from "./types"; -interface FullScanControllerLike { - ensureScanForExport(): Promise; - ensureScanForFilter(): Promise; - ensureScanForSort(): Promise; -} - interface MutationObserverLike { disconnect(): void; observe(target: Node, options?: MutationObserverInit): void; @@ -33,7 +33,6 @@ interface MutationObserverLike { export interface CreateMarketControllerOptions { buildCsv?: (records: MarketRecord[]) => string; document: Document; - fullScanController?: FullScanControllerLike; loadAuthorMetrics?: (authorId: string) => Promise; mutationObserverFactory?: ( callback: MutationCallback @@ -52,22 +51,25 @@ export function createMarketController(options: CreateMarketControllerOptions) { const mutationObserverFactory = options.mutationObserverFactory ?? ((callback: MutationCallback) => new MutationObserver(callback)); + const exportRangeController = createExportRangeController({ + document: options.document, + onProgress: ({ currentPage, totalPages }) => { + setToolbarExportStatus( + toolbar, + totalPages + ? `导出中 ${currentPage}/${totalPages} 页...` + : `导出中 第${currentPage}页...` + ); + }, + prepareCurrentPageForExport: prepareCurrentPageForExport, + readCurrentPageRecords: () => getVisibleOrderedRecords(), + window: options.window + }); let activeFilters: MarketFilterState = {}; let activeSort: MarketSortState | undefined; let isSyncRunning = false; let isSyncScheduled = false; let needsResync = false; - - const fullScanController = - options.fullScanController ?? - createFullScanController({ - goToNextPage: () => goToNextMarketPage(options.document, options.window), - hasNextPage: () => hasNextMarketPage(options.document), - loadAuthorMetrics, - readCurrentPageRows: () => - readCurrentPageRows(options.document), - resultStore - }); const observer = mutationObserverFactory(() => { scheduleSync(); }); @@ -89,18 +91,32 @@ export function createMarketController(options: CreateMarketControllerOptions) { toolbar.singleFilterInput.value ) }; - await fullScanController.ensureScanForFilter(); applyCurrentView(); }, onApplySort: async () => { activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect); - await fullScanController.ensureScanForSort(); applyCurrentView(); }, onExport: async () => { - await fullScanController.ensureScanForExport(); - const records = getVisibleOrderedRecords(); - options.onCsvReady?.(buildCsv(records)); + const exportTarget = readToolbarExportTarget(toolbar); + if (!exportTarget.target) { + setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); + return; + } + + setToolbarBusyState(toolbar, true); + try { + const records = await exportRecords(exportTarget.target); + options.onCsvReady?.(buildCsv(records)); + setToolbarExportStatus(toolbar, ""); + } catch (error) { + setToolbarExportStatus( + toolbar, + error instanceof Error ? error.message : "导出失败,请稍后重试" + ); + } finally { + setToolbarBusyState(toolbar, false); + } } }); @@ -195,18 +211,213 @@ export function createMarketController(options: CreateMarketControllerOptions) { return; } - const records = getVisibleOrderedRecords(); + const records = getVisibleOrderedRecords(table); applyRowVisibility(table, new Set(records.map((record) => record.authorId))); applyRowOrder(table, records.map((record) => record.authorId)); } - function getVisibleOrderedRecords(): MarketRecord[] { - return applyFilterAndSort(resultStore.listRecords(), { + function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] { + const currentPageRecords = readCurrentPageRecords(table); + + return applyFilterAndSort(currentPageRecords, { filters: activeFilters, sort: activeSort }); } + async function exportRecords(target: MarketExportTarget): Promise { + if (target.mode === "count" && target.pageCount <= 1) { + setToolbarExportStatus(toolbar, "导出中..."); + await prepareCurrentPageForExport(); + return getVisibleOrderedRecords(); + } + + return exportRangeController.exportRecords(target); + } + + async function prepareCurrentPageForExport(): Promise { + await runSyncCycle(); + await harvestCurrentPageForExport(); + } + + async function harvestCurrentPageForExport(): Promise { + await collectCurrentPageSnapshotsUntilSettled(); + + const table = syncMarketTable(options.document); + const scrollContainer = findCurrentPageScrollContainer(table); + if (!scrollContainer) { + return; + } + + const originalScrollTop = scrollContainer.scrollTop; + const maxScrollTop = Math.max( + 0, + scrollContainer.scrollHeight - scrollContainer.clientHeight + ); + if (maxScrollTop <= 0) { + return; + } + + const step = Math.max(scrollContainer.clientHeight, 240); + for ( + let nextScrollTop = Math.min(originalScrollTop + step, maxScrollTop); + nextScrollTop > originalScrollTop && nextScrollTop <= maxScrollTop; + nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop) + ) { + setScrollTop(scrollContainer, nextScrollTop); + await collectCurrentPageSnapshotsUntilSettled(); + + if (nextScrollTop === maxScrollTop) { + break; + } + } + + if (scrollContainer.scrollTop !== originalScrollTop) { + setScrollTop(scrollContainer, originalScrollTop); + await collectCurrentPageSnapshotsUntilSettled(); + } + } + + function readCurrentPageRecords(table: ReturnType): MarketRecord[] { + if (!table) { + return []; + } + + return table.rows + .map((rowDom) => { + const rowSnapshot = readRowSnapshot(rowDom); + if (!rowSnapshot.authorId) { + return null; + } + + const existingRecord = resultStore.getRecord(rowSnapshot.authorId); + return { + ...existingRecord, + ...rowSnapshot, + authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "", + exportFields: mergeFieldMap( + existingRecord?.exportFields, + rowSnapshot.exportFields + ), + location: mergeStringValue(existingRecord?.location, rowSnapshot.location), + price21To60s: mergeStringValue( + existingRecord?.price21To60s, + rowSnapshot.price21To60s + ), + rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates), + status: existingRecord?.status ?? "idle" + } satisfies MarketRecord; + }) + .filter((record): record is MarketRecord => record !== null); + } + + function collectCurrentPageSnapshots(): void { + readCurrentPageRows(options.document).forEach((rowSnapshot) => { + resultStore.upsertMarketRow(rowSnapshot); + }); + } + + function findCurrentPageScrollContainer( + table: ReturnType + ): HTMLElement | null { + if (!table) { + return null; + } + + const seenElements = new Set(); + const candidateRoots = table.rows + .map((rowDom) => rowDom.row) + .filter((row): row is HTMLElement => row instanceof options.window.HTMLElement); + + for (const rootElement of candidateRoots) { + let currentElement = rootElement.parentElement; + while (currentElement) { + if ( + !seenElements.has(currentElement) && + isScrollableContainer(currentElement) + ) { + return currentElement; + } + + seenElements.add(currentElement); + currentElement = currentElement.parentElement; + } + } + + return null; + } + + function isScrollableContainer(element: HTMLElement): boolean { + const computedStyle = options.window.getComputedStyle(element); + return ( + /auto|scroll|overlay/.test(computedStyle.overflowY) && + element.scrollHeight > element.clientHeight + ); + } + + async function waitForDomSettled(): Promise { + await new Promise((resolve) => { + options.window.setTimeout(resolve, 0); + }); + await Promise.resolve(); + } + + async function collectCurrentPageSnapshotsUntilSettled(): Promise { + let previousFingerprint = ""; + let stablePassCount = 0; + + for (let attempt = 0; attempt < 12; attempt += 1) { + await waitForDomSettled(); + if (attempt > 0) { + await new Promise((resolve) => { + options.window.setTimeout(resolve, 100); + }); + await Promise.resolve(); + } + + collectCurrentPageSnapshots(); + const nextFingerprint = readVisibleRowHydrationFingerprint(); + if (!nextFingerprint) { + stablePassCount = 0; + previousFingerprint = ""; + continue; + } + + if (nextFingerprint === previousFingerprint) { + stablePassCount += 1; + } else { + previousFingerprint = nextFingerprint; + stablePassCount = 1; + } + + if (stablePassCount >= 3) { + return; + } + } + } + + function readVisibleRowHydrationFingerprint(): string { + const table = syncMarketTable(options.document); + if (!table || table.rows.length === 0) { + return ""; + } + + return table.rows + .map((rowDom) => { + const rowSnapshot = readRowSnapshot(rowDom); + const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter( + (value) => typeof value === "string" && value.trim().length > 0 + ).length; + + return [ + rowSnapshot.authorId, + populatedFieldCount, + rowSnapshot.price21To60s?.trim() ? "price" : "no-price" + ].join(":"); + }) + .join("|"); + } + function scheduleSync(): void { if (isSyncRunning) { needsResync = true; @@ -245,6 +456,11 @@ export function createMarketController(options: CreateMarketControllerOptions) { } +function setScrollTop(element: HTMLElement, top: number): void { + element.scrollTop = top; + element.dispatchEvent(new Event("scroll")); +} + function readCurrentPageRows(document: Document): MarketRowSnapshot[] { const table = syncMarketTable(document); if (!table) { @@ -260,6 +476,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { return { authorId: rowDom.authorId, authorName: rowDom.authorName, + exportFields: rowDom.exportFields, hasDirectRatesSource: rowDom.hasDirectRatesSource, price21To60s: rowDom.price21To60s, rates: rowDom.rates @@ -289,79 +506,39 @@ function readSortState( }; } -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; +function mergeFieldMap>( + current: T | undefined, + incoming: T | undefined +): T | undefined { + if (!current && !incoming) { + return undefined; } - const previousSignature = getCurrentPageSignature(document); - nextButton.click(); + const merged = { + ...(current ?? {}) + } as Record; - 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(); + Object.entries(incoming ?? {}).forEach(([key, value]) => { + const currentValue = merged[key]; + if (hasTextValue(value) || !hasTextValue(currentValue)) { + merged[key] = value; + } }); + + return merged as T; +} + +function mergeStringValue( + current: string | undefined, + incoming: string | undefined +): string | undefined { + if (hasTextValue(incoming) || !hasTextValue(current)) { + return incoming ?? current; + } + + return current; +} + +function hasTextValue(value: string | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; } diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index b1e2446..ba504b3 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -1,3 +1,5 @@ +import type { MarketExportScope, MarketExportTarget } from "./types"; + export interface PluginToolbarHandlers { onApplyFilter(): Promise | void; onApplySort(): Promise | void; @@ -6,6 +8,9 @@ export interface PluginToolbarHandlers { export interface PluginToolbarDom { exportButton: HTMLButtonElement; + exportCustomPagesInput: HTMLInputElement; + exportRangeSelect: HTMLSelectElement; + exportStatusText: HTMLElement; filterApplyButton: HTMLButtonElement; personalFilterInput: HTMLInputElement; root: HTMLElement; @@ -65,6 +70,25 @@ export function ensurePluginToolbar( exportButton.dataset.pluginExport = "button"; exportButton.textContent = "导出CSV"; + const exportRangeSelect = document.createElement("select"); + exportRangeSelect.dataset.pluginExportRange = "select"; + appendOption(exportRangeSelect, "current", "当前页"); + appendOption(exportRangeSelect, "first-5", "前5页"); + appendOption(exportRangeSelect, "first-10", "前10页"); + appendOption(exportRangeSelect, "all", "全部"); + appendOption(exportRangeSelect, "custom", "自定义"); + exportRangeSelect.value = "first-5"; + + const exportCustomPagesInput = document.createElement("input"); + exportCustomPagesInput.type = "number"; + exportCustomPagesInput.min = "1"; + exportCustomPagesInput.step = "1"; + exportCustomPagesInput.hidden = true; + exportCustomPagesInput.dataset.pluginExportCustomPages = "input"; + + const exportStatusText = document.createElement("span"); + exportStatusText.dataset.pluginExportStatus = "text"; + root.append( singleFilterInput, personalFilterInput, @@ -72,8 +96,11 @@ export function ensurePluginToolbar( sortFieldSelect, sortDirectionSelect, sortApplyButton, + exportRangeSelect, + exportCustomPagesInput, exportButton ); + root.append(exportStatusText); document.body.prepend(root); filterApplyButton.addEventListener("click", () => { @@ -85,9 +112,27 @@ export function ensurePluginToolbar( exportButton.addEventListener("click", () => { void handlers.onExport(); }); + exportRangeSelect.addEventListener("change", () => { + syncCustomPagesInputVisibility({ + exportButton, + exportCustomPagesInput, + exportRangeSelect, + exportStatusText, + filterApplyButton, + personalFilterInput, + root, + singleFilterInput, + sortApplyButton, + sortDirectionSelect, + sortFieldSelect + }); + }); - return { + const toolbarDom = { exportButton, + exportCustomPagesInput, + exportRangeSelect, + exportStatusText, filterApplyButton, personalFilterInput, root, @@ -95,7 +140,10 @@ export function ensurePluginToolbar( sortApplyButton, sortDirectionSelect, sortFieldSelect - }; + } satisfies PluginToolbarDom; + syncCustomPagesInputVisibility(toolbarDom); + + return toolbarDom; } function appendOption( @@ -110,10 +158,19 @@ function appendOption( } function readToolbarDom(root: HTMLElement): PluginToolbarDom { - return { + const toolbarDom = { exportButton: root.querySelector( '[data-plugin-export="button"]' ) as HTMLButtonElement, + exportCustomPagesInput: root.querySelector( + '[data-plugin-export-custom-pages="input"]' + ) as HTMLInputElement, + exportRangeSelect: root.querySelector( + '[data-plugin-export-range="select"]' + ) as HTMLSelectElement, + exportStatusText: root.querySelector( + '[data-plugin-export-status="text"]' + ) as HTMLElement, filterApplyButton: root.querySelector( '[data-plugin-filter-apply="button"]' ) as HTMLButtonElement, @@ -133,5 +190,93 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom { sortFieldSelect: root.querySelector( '[data-plugin-sort-field="select"]' ) as HTMLSelectElement + } satisfies PluginToolbarDom; + syncCustomPagesInputVisibility(toolbarDom); + return toolbarDom; +} + +export function readToolbarExportTarget( + toolbar: PluginToolbarDom +): { error?: string; target?: MarketExportTarget } { + const scope = toolbar.exportRangeSelect.value as MarketExportScope; + + if (scope === "all") { + return { + target: { + mode: "all" + } + }; + } + + if (scope === "current") { + return { + target: { + mode: "count", + pageCount: 1 + } + }; + } + + if (scope === "first-5") { + return { + target: { + mode: "count", + pageCount: 5 + } + }; + } + + if (scope === "first-10") { + return { + target: { + mode: "count", + pageCount: 10 + } + }; + } + + const pageCount = Number(toolbar.exportCustomPagesInput.value); + if (!Number.isInteger(pageCount) || pageCount < 1) { + return { + error: "请输入有效页数" + }; + } + + return { + target: { + mode: "count", + pageCount + } }; } + +export function setToolbarBusyState( + toolbar: PluginToolbarDom, + isBusy: boolean +): void { + [ + toolbar.exportButton, + toolbar.filterApplyButton, + toolbar.sortApplyButton, + toolbar.singleFilterInput, + toolbar.personalFilterInput, + toolbar.sortFieldSelect, + toolbar.sortDirectionSelect, + toolbar.exportRangeSelect, + toolbar.exportCustomPagesInput + ].forEach((element) => { + element.disabled = isBusy; + }); +} + +export function setToolbarExportStatus( + toolbar: PluginToolbarDom, + text: string +): void { + toolbar.exportStatusText.textContent = text; +} + +function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void { + toolbar.exportCustomPagesInput.hidden = + toolbar.exportRangeSelect.value !== "custom"; +} diff --git a/src/content/market/result-store.ts b/src/content/market/result-store.ts index 5cb09a4..52a41a0 100644 --- a/src/content/market/result-store.ts +++ b/src/content/market/result-store.ts @@ -37,14 +37,24 @@ export function createMarketResultStore() { upsertMarketRow(row: MarketRowSnapshot) { const existingRecord = records.get(row.authorId); if (existingRecord) { + existingRecord.authorName = + mergeStringValue(existingRecord.authorName, row.authorName) ?? + existingRecord.authorName; + existingRecord.location = mergeStringValue( + existingRecord.location, + row.location + ); + existingRecord.price21To60s = mergeStringValue( + existingRecord.price21To60s, + row.price21To60s + ); + existingRecord.exportFields = mergeFieldMap( + existingRecord.exportFields, + row.exportFields + ); existingRecord.hasDirectRatesSource = existingRecord.hasDirectRatesSource || row.hasDirectRatesSource; - if (row.rates) { - existingRecord.rates = { - ...existingRecord.rates, - ...row.rates - }; - } + existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates); return existingRecord; } @@ -72,3 +82,40 @@ export function createMarketResultStore() { return nextRecord; } } + +function mergeFieldMap>( + current: T | undefined, + incoming: T | undefined +): T | undefined { + if (!current && !incoming) { + return undefined; + } + + const merged = { + ...(current ?? {}) + } as Record; + + Object.entries(incoming ?? {}).forEach(([key, value]) => { + const currentValue = merged[key]; + if (!hasTextValue(currentValue)) { + merged[key] = value; + } + }); + + return merged as T; +} + +function mergeStringValue( + current: string | undefined, + incoming: string | undefined +): string | undefined { + if (!hasTextValue(current)) { + return incoming ?? current; + } + + return current; +} + +function hasTextValue(value: string | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/src/content/market/types.ts b/src/content/market/types.ts index 75224bc..ce89371 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -8,6 +8,7 @@ export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "mi export interface MarketRowSnapshot { authorId: string; authorName: string; + exportFields?: Record; hasDirectRatesSource?: boolean; location?: string; price21To60s?: string; @@ -24,6 +25,17 @@ export interface MarketFilterState { singleVideoAfterSearchRateMin?: number; } +export type MarketExportScope = "current" | "first-5" | "first-10" | "all" | "custom"; + +export type MarketExportTarget = + | { + mode: "all"; + } + | { + mode: "count"; + pageCount: number; + }; + export interface MarketSortState { direction: "asc" | "desc"; field: keyof Required; diff --git a/src/manifest.json b/src/manifest.json index 95afef3..c53f2be 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,8 +1,12 @@ { "manifest_version": 3, "name": "Star Chart Search Enhancer", - "version": "0.0.0", + "version": "0.2.0421.2", "description": "Bootstraps the Xingtu creator market content script.", + "permissions": ["downloads"], + "background": { + "service_worker": "background/index.js" + }, "content_scripts": [ { "matches": [ diff --git a/tests/background-index.test.ts b/tests/background-index.test.ts new file mode 100644 index 0000000..d184eb6 --- /dev/null +++ b/tests/background-index.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test, vi } from "vitest"; + +import { registerBackgroundMessageHandler } from "../src/background/index"; + +describe("background-index", () => { + test("downloads csv files when the content script sends a download message", async () => { + const listeners: Array< + (message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void + > = []; + const download = vi.fn(async () => undefined); + const sendResponse = vi.fn(); + + registerBackgroundMessageHandler({ + downloads: { + download + }, + runtime: { + onMessage: { + addListener(listener) { + listeners.push(listener); + } + } + } + }); + + expect(listeners).toHaveLength(1); + const result = listeners[0]( + { + csv: "列1,列2\n值1,值2", + filename: "market.csv", + type: "download-market-csv" + }, + {}, + sendResponse + ); + + expect(result).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(download).toHaveBeenCalledTimes(1); + expect(download).toHaveBeenCalledWith( + expect.objectContaining({ + filename: "market.csv", + saveAs: false, + url: expect.stringContaining("data:text/csv;charset=utf-8,") + }) + ); + expect(sendResponse).toHaveBeenCalledWith({ ok: true }); + }); +}); diff --git a/tests/csv-exporter.test.ts b/tests/csv-exporter.test.ts index 088dfc1..1dd9991 100644 --- a/tests/csv-exporter.test.ts +++ b/tests/csv-exporter.test.ts @@ -4,7 +4,7 @@ import { buildMarketCsv } from "../src/content/market/csv-exporter"; import type { MarketRecord } from "../src/content/market/types"; describe("csv-exporter", () => { - test("uses the expected header order", () => { + test("uses fallback header order when no page export fields are available", () => { const csv = buildMarketCsv([]); const [headerLine] = csv.split("\n"); @@ -15,12 +15,38 @@ describe("csv-exporter", () => { "地区", "21-60s报价", "单视频看后搜率", - "个人视频看后搜率", - "插件数据状态" + "个人视频看后搜率" ].join(",") ); }); + test("uses page export field order and appends the two plugin columns", () => { + const csv = buildMarketCsv([ + { + authorId: "123", + authorName: "Alice", + exportFields: { + 达人信息: "Alice", + 粉丝数: "100w", + "21-60s报价": "¥450,000" + }, + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "1% - 3%" + } + } satisfies MarketRecord + ]); + + const [headerLine, rowLine] = csv.split("\n"); + expect(headerLine).toBe( + ["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join( + "," + ) + ); + expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%'); + }); + test("escapes commas and quotes", () => { const csv = buildMarketCsv([ { @@ -51,7 +77,7 @@ describe("csv-exporter", () => { ]); const [, rowLine] = csv.split("\n"); - expect(rowLine).toBe("123,Alice,,,,,failed"); + expect(rowLine).toBe("123,Alice,,,,"); }); test("uses normalized display values in export rows", () => { diff --git a/tests/manifest.test.ts b/tests/manifest.test.ts index 7b5ba25..c49fa35 100644 --- a/tests/manifest.test.ts +++ b/tests/manifest.test.ts @@ -10,4 +10,11 @@ describe("manifest", () => { ]) ); }); + + test("declares the downloads permission and background worker for csv export", () => { + expect(manifest.permissions).toEqual( + expect.arrayContaining(["downloads"]) + ); + expect(manifest.background?.service_worker).toBe("background/index.js"); + }); }); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 5b53f62..bc57328 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -100,6 +100,85 @@ describe("market-content-entry", () => { expect(createMarketController).toHaveBeenCalledTimes(1); }); + test("booted export callback downloads the generated csv file", async () => { + const createMarketController = vi.fn(() => ({ + ready: Promise.resolve() + })); + const createObjectURL = vi.fn(() => "blob:test-url"); + const revokeObjectURL = vi.fn(); + let clickedDownload: { download: string; href: string } | null = null; + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(function (this: HTMLAnchorElement) { + clickedDownload = { + download: this.download, + href: this.href + }; + }); + + window.history.replaceState({}, "", "/ad/creator/market"); + Object.defineProperty(window.URL, "createObjectURL", { + configurable: true, + value: createObjectURL + }); + Object.defineProperty(window.URL, "revokeObjectURL", { + configurable: true, + value: revokeObjectURL + }); + + const { bootContentScript } = await import("../src/content/index"); + await bootContentScript({ + createMarketController + }); + + const controllerOptions = createMarketController.mock.calls[0]?.[0]; + expect(controllerOptions?.onCsvReady).toEqual(expect.any(Function)); + + controllerOptions.onCsvReady("列1,列2\n值1,值2"); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(clickedDownload).not.toBeNull(); + expect(clickedDownload?.href).toBe("blob:test-url"); + expect(clickedDownload?.download).toMatch(/\.csv$/); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:test-url"); + }); + + test("booted export callback sends the csv to extension runtime when available", async () => { + const createMarketController = vi.fn(() => ({ + ready: Promise.resolve() + })); + const sendMessage = vi.fn(); + + window.history.replaceState({}, "", "/ad/creator/market"); + ( + globalThis as typeof globalThis & { + chrome?: { runtime?: { id?: string; sendMessage?: (message: unknown) => void } }; + } + ).chrome = { + runtime: { + id: "test-extension", + sendMessage + } + }; + + const { bootContentScript } = await import("../src/content/index"); + await bootContentScript({ + createMarketController + }); + + const controllerOptions = createMarketController.mock.calls[0]?.[0]; + controllerOptions.onCsvReady("列1,列2\n值1,值2"); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + csv: "列1,列2\n值1,值2", + type: "download-market-csv" + }) + ); + }); + test("hydrates current page rows on start", async () => { document.body.innerHTML = buildMarketFixture(); @@ -310,28 +389,13 @@ describe("market-content-entry", () => { ]); }); - test("applying plugin filters triggers full scan and hides non-matching rows", async () => { + test("applying plugin filters hides non-matching current-page rows without a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); - const ensureScanForFilter = vi.fn(async () => { - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - resultStore.setAuthorSuccess("b", { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - }); - }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, - fullScanController: { - ensureScanForExport: vi.fn(async () => {}), - ensureScanForFilter, - ensureScanForSort: vi.fn(async () => {}) - }, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" @@ -341,11 +405,18 @@ describe("market-content-entry", () => { })); await controller.ready; + resultStore.setAuthorSuccess("a", { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + }); + resultStore.setAuthorSuccess("b", { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + }); setInputValue('[data-plugin-filter-single="input"]', "0.1"); click('[data-plugin-filter-apply="button"]'); await flush(); - expect(ensureScanForFilter).toHaveBeenCalledTimes(1); expect( document.querySelector('[data-market-row="a"]')?.hasAttribute("hidden") ).toBe(true); @@ -354,28 +425,13 @@ describe("market-content-entry", () => { ).toBe(false); }); - test("applying plugin sorting triggers full scan and reorders rows", async () => { + test("applying plugin sorting reorders the current page without triggering a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); - const ensureScanForSort = vi.fn(async () => { - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - resultStore.setAuthorSuccess("b", { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - }); - }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ document, - fullScanController: { - ensureScanForExport: vi.fn(async () => {}), - ensureScanForFilter: vi.fn(async () => {}), - ensureScanForSort - }, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" @@ -385,40 +441,63 @@ describe("market-content-entry", () => { })); await controller.ready; + resultStore.setAuthorSuccess("a", { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + }); + resultStore.setAuthorSuccess("b", { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + }); setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); setSelectValue('[data-plugin-sort-direction="select"]', "desc"); click('[data-plugin-sort-apply="button"]'); await flush(); - expect(ensureScanForSort).toHaveBeenCalledTimes(1); expect(readRowOrder()).toEqual(["b", "a"]); }); - test("export triggers full scan and hands ordered visible records to the csv exporter", async () => { + test("toolbar defaults export range to the first 5 pages and reveals custom input on demand", async () => { + document.body.innerHTML = buildMarketFixture(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + window + })); + + await controller.ready; + + const exportRangeSelect = document.querySelector( + '[data-plugin-export-range="select"]' + ) as HTMLSelectElement | null; + const customPagesInput = document.querySelector( + '[data-plugin-export-custom-pages="input"]' + ) as HTMLInputElement | null; + + expect(exportRangeSelect?.value).toBe("first-5"); + expect(customPagesInput?.hidden).toBe(true); + + setSelectValue('[data-plugin-export-range="select"]', "custom"); + dispatchChange('[data-plugin-export-range="select"]'); + + expect(customPagesInput?.hidden).toBe(false); + }); + + test("export uses the current page ordering without triggering a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); const buildCsv = vi.fn(() => "csv-output"); const onCsvReady = vi.fn(); - const ensureScanForExport = vi.fn(async () => { - resultStore.setAuthorSuccess("a", { - singleVideoAfterSearchRate: "0.02% - 0.1%", - personalVideoAfterSearchRate: "0.03% - 0.2%" - }); - resultStore.setAuthorSuccess("b", { - singleVideoAfterSearchRate: "0.5%-1%", - personalVideoAfterSearchRate: "0.01% - 0.1%" - }); - }); const { createMarketController } = await import("../src/content/market/index"); const controller = trackController(createMarketController({ buildCsv, document, - fullScanController: { - ensureScanForExport, - ensureScanForFilter: vi.fn(async () => {}), - ensureScanForSort: vi.fn(async () => {}) - }, loadAuthorMetrics: async () => ({ success: false, reason: "request-failed" @@ -429,14 +508,21 @@ describe("market-content-entry", () => { })); await controller.ready; + resultStore.setAuthorSuccess("a", { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + }); + resultStore.setAuthorSuccess("b", { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + }); setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); setSelectValue('[data-plugin-sort-direction="select"]', "desc"); click('[data-plugin-sort-apply="button"]'); await flush(); click('[data-plugin-export="button"]'); - await flush(); + await waitForMockCall(buildCsv, 40, 50); - expect(ensureScanForExport).toHaveBeenCalledTimes(1); expect(buildCsv).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ authorId: "a" }), @@ -450,6 +536,530 @@ describe("market-content-entry", () => { expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }); + test( + "default export captures the first 5 pages and keeps non-empty fields when merging duplicates", + async () => { + const pages = [ + [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], + [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], + [{ authorId: "222", authorName: "达人 B", price21To60s: "" }], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], + [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], + [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] + ]; + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const pagination = installAsyncPaginationHarness(pages); + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + window + })); + + await controller.ready; + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 100); + + expect(pagination.getClicks()).toBe(4); + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ + "111", + "222", + "333", + "444" + ]); + expect(buildCsv.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "222", + exportFields: expect.objectContaining({ + "21-60s报价": "¥22,000" + }) + }) + ]) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }, + 15000 + ); + + test( + "default export waits for the next page rows instead of only the pager state", + async () => { + const pages = [ + [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], + [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }], + [{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }], + [{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }] + ]; + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const pagination = installLaggyPaginationHarness(pages, { + renderDelayMs: 250 + }); + const buildCsv = vi.fn(() => "csv-output"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); + + await controller.ready; + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 100); + + expect(pagination.getClicks()).toBe(4); + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ + "111", + "222", + "333", + "444", + "555" + ]); + }, + 15000 + ); + + test( + "export waits for a slow page to finish rendering all rows before continuing", + async () => { + const pages = [ + [ + { authorId: "111", authorName: "达人 A1", price21To60s: "¥11,000" }, + { authorId: "112", authorName: "达人 A2", price21To60s: "¥12,000" }, + { authorId: "113", authorName: "达人 A3", price21To60s: "¥13,000" } + ], + [ + { authorId: "221", authorName: "达人 B1", price21To60s: "¥21,000" }, + { authorId: "222", authorName: "达人 B2", price21To60s: "¥22,000" }, + { authorId: "223", authorName: "达人 B3", price21To60s: "¥23,000" } + ], + [ + { authorId: "331", authorName: "达人 C1", price21To60s: "¥31,000" }, + { authorId: "332", authorName: "达人 C2", price21To60s: "¥32,000" }, + { authorId: "333", authorName: "达人 C3", price21To60s: "¥33,000" } + ] + ]; + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const pagination = installProgressivePaginationHarness(pages, { + firstRenderCount: 1, + firstRenderDelayMs: 100, + fullRenderDelayMs: 450 + }); + const buildCsv = vi.fn(() => "csv-output"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "all"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 100); + + expect(pagination.getClicks()).toBe(2); + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ + "111", + "112", + "113", + "221", + "222", + "223", + "331", + "332", + "333" + ]); + }, + 15000 + ); + + test("exporting all pages disables the toolbar during the task and stops at the final page", async () => { + const pages = [ + [{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }], + [{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }], + [{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }] + ]; + + document.body.innerHTML = buildRealMarketFixture(pages[0]); + const pagination = installAsyncPaginationHarness(pages); + const buildCsv = vi.fn(() => "csv-output"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "all"); + dispatchChange('[data-plugin-export-range="select"]'); + + click('[data-plugin-export="button"]'); + + expectButtonDisabled('[data-plugin-export="button"]', true); + expectButtonDisabled('[data-plugin-filter-apply="button"]', true); + expectButtonDisabled('[data-plugin-sort-apply="button"]', true); + expectSelectDisabled('[data-plugin-export-range="select"]', true); + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toContain("导出中"); + + await waitForMockCall(buildCsv, 80, 100); + + expect(pagination.getClicks()).toBe(2); + expectButtonDisabled('[data-plugin-export="button"]', false); + expectSelectDisabled('[data-plugin-export-range="select"]', false); + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([ + "111", + "222", + "333" + ]); + }); + + test("custom export range blocks invalid page counts", async () => { + document.body.innerHTML = buildMarketFixture(); + const buildCsv = vi.fn(() => "csv-output"); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "custom"); + dispatchChange('[data-plugin-export-range="select"]'); + setInputValue('[data-plugin-export-custom-pages="input"]', "0"); + + click('[data-plugin-export="button"]'); + await flush(); + + expect(buildCsv).not.toHaveBeenCalled(); + expect( + document.querySelector('[data-plugin-export-status="text"]')?.textContent + ).toContain("有效页数"); + }); + + test("export only includes records that are present on the current page", async () => { + document.body.innerHTML = buildMarketFixture(); + const resultStore = createMarketResultStore(); + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + resultStore, + window + })); + + await controller.ready; + resultStore.setAuthorSuccess("a", { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + }); + resultStore.setAuthorSuccess("b", { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + }); + resultStore.upsertMarketRow({ + authorId: "c", + authorName: "Gamma" + }); + resultStore.setAuthorSuccess("c", { + singleVideoAfterSearchRate: "9% - 10%", + personalVideoAfterSearchRate: "8% - 9%" + }); + + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 40, 50); + + expect(buildCsv).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ authorId: "a" }), + expect.objectContaining({ authorId: "b" }) + ]) + ); + expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).not.toContain( + "c" + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }); + + test("export prefers fresh current-page fields over stale store export fields", async () => { + document.body.innerHTML = buildMarketFixture(); + const resultStore = createMarketResultStore(); + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + resultStore, + window + })); + + await controller.ready; + resultStore.upsertMarketRow({ + authorId: "a", + authorName: "Old Alpha", + exportFields: { + 达人: "Old Alpha" + } + }); + resultStore.setAuthorSuccess("a", { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + }); + + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 40, 50); + + expect(buildCsv.mock.calls[0][0][0]).toEqual( + expect.objectContaining({ + authorId: "a", + authorName: "Alpha", + exportFields: { + "21-60s报价": "450000", + 达人: "Alpha" + } + }) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }); + + test("export harvests lazy current-page fields before building csv", async () => { + const rows = [ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + }, + { + authorId: "333", + authorName: "达人 C", + price21To60s: "¥30,000" + }, + { + authorId: "444", + authorName: "达人 D", + price21To60s: "¥40,000" + } + ]; + + document.body.innerHTML = ` +
+ ${buildRealMarketFixture(rows)} +
+ `; + installLazyFieldHydrationHarness({ + hiddenRowIndexes: [2, 3], + scrollContainer: document.querySelector( + '[data-testid="market-scroll-shell"]' + ) as HTMLElement + }); + + const resultStore = createMarketResultStore(); + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + resultStore, + window + })); + + await controller.ready; + rows.forEach((row, index) => { + resultStore.setAuthorSuccess(row.authorId, { + personalVideoAfterSearchRate: `0.0${index + 1}%`, + singleVideoAfterSearchRate: `0.1${index + 1}%` + }); + }); + + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 50); + + expect(buildCsv.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "333", + exportFields: expect.objectContaining({ + "21-60s报价": "¥30,000", + 代表视频: "代表视频达人 C" + }) + }), + expect.objectContaining({ + authorId: "444", + exportFields: expect.objectContaining({ + "21-60s报价": "¥40,000", + 代表视频: "代表视频达人 D" + }) + }) + ]) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }); + + test( + "export waits for delayed lazy field hydration before reading current-page rows", + async () => { + const rows = [ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + }, + { + authorId: "333", + authorName: "达人 C", + price21To60s: "¥30,000" + }, + { + authorId: "444", + authorName: "达人 D", + price21To60s: "¥40,000" + } + ]; + + document.body.innerHTML = ` +
+ ${buildRealMarketFixture(rows)} +
+ `; + installLazyFieldHydrationHarness({ + hiddenRowIndexes: [2, 3], + hydrateDelayMs: 350, + scrollContainer: document.querySelector( + '[data-testid="market-scroll-shell"]' + ) as HTMLElement + }); + + const resultStore = createMarketResultStore(); + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + resultStore, + window + })); + + await controller.ready; + rows.forEach((row, index) => { + resultStore.setAuthorSuccess(row.authorId, { + personalVideoAfterSearchRate: `0.0${index + 1}%`, + singleVideoAfterSearchRate: `0.1${index + 1}%` + }); + }); + + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 50); + + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(buildCsv.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "333", + exportFields: expect.objectContaining({ + "21-60s报价": "¥30,000", + 代表视频: "代表视频达人 C" + }) + }), + expect.objectContaining({ + authorId: "444", + exportFields: expect.objectContaining({ + "21-60s报价": "¥40,000", + 代表视频: "代表视频达人 D" + }) + }) + ]) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }, + 15000 + ); + test("rehydrates rows when the market list DOM changes", async () => { document.body.innerHTML = buildMarketFixture(); const observer = createMutationObserverFactory(); @@ -496,7 +1106,7 @@ describe("market-content-entry", () => { ).toBe("0.8% - 1%"); }); - test("default full scan walks the real market pagination when applying a filter", async () => { + test("applying a filter on the real market view stays on the current page", async () => { const pages = [ [ { @@ -543,9 +1153,9 @@ describe("market-content-entry", () => { await flush(); await flush(); - expect(pagination.getClicks()).toBe(1); - expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual( - expect.arrayContaining(["111", "222"]) + expect(pagination.getClicks()).toBe(0); + expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).not.toContain( + "222" ); }); }); @@ -829,6 +1439,417 @@ function installPaginationHarness( }; } +function installAsyncPaginationHarness( + pages: Array< + Array<{ + authorId: string; + authorName: string; + price21To60s: string; + }> + > +) { + let pageIndex = 0; + let clicks = 0; + let activeRenderToken = 0; + const nextButton = document.querySelector( + '[data-testid="next-page"]' + ) as HTMLButtonElement | null; + if (!nextButton) { + throw new Error("Missing next page button"); + } + + const updatePaginationState = () => { + document.documentElement.setAttribute("data-test-page-index", String(pageIndex + 1)); + nextButton.disabled = pageIndex >= pages.length - 1; + nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false"); + }; + + 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(""); + updatePaginationState(); + }; + + nextButton.addEventListener("click", () => { + if (pageIndex >= pages.length - 1) { + return; + } + + clicks += 1; + pageIndex += 1; + const renderToken = ++activeRenderToken; + window.setTimeout(() => { + if (renderToken !== activeRenderToken) { + return; + } + + renderPage(); + }, 0); + }); + + renderPage(); + + return { + getClicks() { + return clicks; + } + }; +} + +function installLaggyPaginationHarness( + pages: Array< + Array<{ + authorId: string; + authorName: string; + price21To60s: string; + }> + >, + options: { + renderDelayMs: number; + } +) { + let pageIndex = 0; + let clicks = 0; + let activeRenderToken = 0; + const nextButton = document.querySelector( + '[data-testid="next-page"]' + ) as HTMLButtonElement | null; + if (!nextButton) { + throw new Error("Missing next page button"); + } + + const updatePaginationState = (visiblePageIndex: number) => { + document.documentElement.setAttribute( + "data-test-page-index", + String(visiblePageIndex + 1) + ); + nextButton.disabled = visiblePageIndex >= pages.length - 1; + nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false"); + }; + + 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(""); + updatePaginationState(pageIndex); + }; + + nextButton.addEventListener("click", () => { + if (pageIndex >= pages.length - 1) { + return; + } + + clicks += 1; + pageIndex += 1; + updatePaginationState(pageIndex); + + 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' + ); + authorColumn!.innerHTML = ""; + middleColumn!.innerHTML = ""; + rightColumns.forEach((column) => { + (column as HTMLElement).innerHTML = ""; + }); + + const renderToken = ++activeRenderToken; + window.setTimeout(() => { + if (renderToken !== activeRenderToken) { + return; + } + + renderPage(); + }, options.renderDelayMs); + }); + + renderPage(); + + return { + getClicks() { + return clicks; + } + }; +} + +function installProgressivePaginationHarness( + pages: Array< + Array<{ + authorId: string; + authorName: string; + price21To60s: string; + }> + >, + options: { + firstRenderCount: number; + firstRenderDelayMs: number; + fullRenderDelayMs: number; + } +) { + let pageIndex = 0; + let clicks = 0; + let activeRenderToken = 0; + const nextButton = document.querySelector( + '[data-testid="next-page"]' + ) as HTMLButtonElement | null; + if (!nextButton) { + throw new Error("Missing next page button"); + } + + const updatePaginationState = (visiblePageIndex: number) => { + document.documentElement.setAttribute( + "data-test-page-index", + String(visiblePageIndex + 1) + ); + nextButton.disabled = visiblePageIndex >= pages.length - 1; + nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false"); + }; + + const renderRows = ( + rows: Array<{ + authorId: string; + authorName: string; + price21To60s: string; + }> + ) => { + 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"); + } + + 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(""); + }; + + const renderFullPage = () => { + renderRows(pages[pageIndex]); + updatePaginationState(pageIndex); + }; + + nextButton.addEventListener("click", () => { + if (pageIndex >= pages.length - 1) { + return; + } + + clicks += 1; + pageIndex += 1; + updatePaginationState(pageIndex); + renderRows([]); + + const renderToken = ++activeRenderToken; + window.setTimeout(() => { + if (renderToken !== activeRenderToken) { + return; + } + + renderRows(pages[pageIndex].slice(0, options.firstRenderCount)); + }, options.firstRenderDelayMs); + window.setTimeout(() => { + if (renderToken !== activeRenderToken) { + return; + } + + renderFullPage(); + }, options.fullRenderDelayMs); + }); + + renderFullPage(); + + return { + getClicks() { + return clicks; + } + }; +} + +function installLazyFieldHydrationHarness(options: { + hiddenRowIndexes: number[]; + hydrateDelayMs?: number; + scrollContainer: HTMLElement; +}) { + const { hiddenRowIndexes, hydrateDelayMs = 0, scrollContainer } = options; + const rightColumns = document.querySelectorAll( + '[data-testid="right-section"] > .content-column' + ); + const middleCells = Array.from( + document.querySelectorAll(".middle-columns .content-column .content-cell") + ) as HTMLElement[]; + const priceCells = Array.from(rightColumns[0]?.querySelectorAll(".content-cell") ?? []) as + HTMLElement[]; + const hiddenCells = hiddenRowIndexes.flatMap((rowIndex) => { + const middleCell = middleCells[rowIndex] ?? null; + const priceCell = priceCells[rowIndex] ?? null; + + return [middleCell, priceCell] + .filter((cell): cell is HTMLElement => cell !== null) + .map((cell) => ({ + cell, + text: cell.textContent ?? "" + })); + }); + + hiddenCells.forEach(({ cell }) => { + cell.textContent = ""; + }); + + let hydrated = false; + let scrollTopValue = 0; + Object.defineProperty(scrollContainer, "clientHeight", { + configurable: true, + value: 120 + }); + Object.defineProperty(scrollContainer, "scrollHeight", { + configurable: true, + value: 480 + }); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + get() { + return scrollTopValue; + }, + set(value: number) { + scrollTopValue = value; + if (hydrated || value <= 0) { + return; + } + + hydrated = true; + window.setTimeout(() => { + hiddenCells.forEach(({ cell, text }) => { + cell.textContent = text; + }); + }, hydrateDelayMs); + } + }); +} + function createMutationObserverFactory() { let callback: MutationCallback = () => undefined; @@ -873,6 +1894,15 @@ function setSelectValue(selector: string, value: string) { element.value = value; } +function dispatchChange(selector: string) { + const element = document.querySelector(selector) as HTMLElement | null; + if (!element) { + throw new Error(`Missing element: ${selector}`); + } + + element.dispatchEvent(new Event("change")); +} + function readRowOrder() { return Array.from(document.querySelectorAll("[data-market-row]")).map( (row) => row.getAttribute("data-author-id") @@ -895,6 +1925,24 @@ function trackController void }>(controller: T): T { return controller; } +function expectButtonDisabled(selector: string, expected: boolean) { + const element = document.querySelector(selector) as HTMLButtonElement | null; + if (!element) { + throw new Error(`Missing button: ${selector}`); + } + + expect(element.disabled).toBe(expected); +} + +function expectSelectDisabled(selector: string, expected: boolean) { + const element = document.querySelector(selector) as HTMLSelectElement | null; + if (!element) { + throw new Error(`Missing select: ${selector}`); + } + + expect(element.disabled).toBe(expected); +} + async function flush() { await Promise.resolve(); await Promise.resolve(); @@ -905,3 +1953,23 @@ async function flushWithTimers() { await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); } + +async function waitForMockCall( + mockFn: { mock: { calls: unknown[][] } }, + maxAttempts = 10, + pollDelayMs = 0 +) { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (mockFn.mock.calls.length > 0) { + return; + } + + if (pollDelayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, pollDelayMs)); + await Promise.resolve(); + continue; + } + + await flushWithTimers(); + } +} diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index b4c00f3..ed31e89 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -5,6 +5,9 @@ import { beforeEach, describe, expect, test } from "vitest"; import { applyRowOrder, applyRowVisibility, + findNextPageControl, + isPageControlDisabled, + readMarketPageSignature, renderMarketRowState, syncMarketTable } from "../src/content/market/dom-sync"; @@ -154,6 +157,11 @@ describe("market-dom-sync", () => { expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]); expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]); + expect(table.rows[0].exportFields).toMatchObject({ + "21-60s报价": "¥450,000", + "代表视频": "代表视频A", + "达人信息": "达人 A" + }); }); test("falls back to the market vue state when the DOM has no author id", () => { @@ -208,6 +216,33 @@ describe("market-dom-sync", () => { singleVideoAfterSearchRate: "0.02%" }); }); + + test("finds the real next-page button in Xingtu pagination", () => { + document.body.innerHTML = ` + ${buildRealMarketGridFixture()} + + `; + + const nextControl = findNextPageControl(document); + + expect(nextControl).not.toBeNull(); + expect(nextControl?.className).toContain("btn-next"); + expect(isPageControlDisabled(nextControl)).toBe(false); + expect(readMarketPageSignature(document)).toContain("1::111|222"); + }); }); function buildRealMarketGridFixture() {