release: 0.2.0421.2

This commit is contained in:
admin123 2026-04-21 17:10:06 +08:00
parent 55324a5bb7
commit 9054b362b3
19 changed files with 2832 additions and 178 deletions

View File

@ -0,0 +1,217 @@
# Market Export Range Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add configurable CSV export ranges for the Xingtu market plugin, defaulting to the first 5 pages and supporting current page, preset ranges, all pages, and custom page counts.
**Architecture:** Keep filter and sort scoped to the current page, and introduce a dedicated batch-export path that only runs when exporting. Extend the toolbar with export-range controls, extract page-navigation/signature helpers, and add a focused controller that iterates pages, harvests lazy-loaded DOM fields, merges records by `authorId`, and returns a single record set for CSV generation.
**Tech Stack:** TypeScript, Vitest, jsdom, Chrome MV3 content script
---
### Task 1: Extend Toolbar Controls For Export Range
**Files:**
- Modify: `src/content/market/plugin-toolbar.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- the toolbar defaults to `前5页`
- selecting `自定义` shows and uses a page-count input
- invalid custom values prevent export
- export mode disables toolbar controls during a running task
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
Expected: FAIL because the toolbar has no range selector or custom input behavior.
- [ ] **Step 3: Write the minimal implementation**
Update `plugin-toolbar.ts` to:
- add an export-range `<select>`
- add a custom page-count `<input>`
- add status text support
- expose helpers for reading range state and toggling disabled/running UI
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts -t "export range"`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: add export range toolbar controls"
```
### Task 2: Add Batch Export Coverage
**Files:**
- Modify: `tests/market-content-entry.test.ts`
- Modify: `tests/market-dom-sync.test.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- `当前页` exports only the current page
- `前5页` iterates through pages until it reaches page 5 or runs out of pages
- `全部` stops when the next-page control is no longer usable
- repeated authors across pages are merged by `authorId`
- empty fields from later pages do not erase existing populated fields
- page export waits for page signature change before harvesting the next page
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL on the new batch-export expectations.
- [ ] **Step 3: Write the minimal implementation scaffolding**
Only introduce the minimum new helpers/types needed to make the tests compile:
- export range type(s)
- page signature helpers
- harness utilities in tests
- [ ] **Step 4: Run tests to verify the failures are now behavioral**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because batch export logic is still missing, not because types/selectors are missing.
- [ ] **Step 5: Commit**
```bash
git add tests/market-content-entry.test.ts tests/market-dom-sync.test.ts src/content/market/types.ts
git commit -m "test: cover multi-page export behavior"
```
### Task 3: Implement Batch Export Controller
**Files:**
- Create: `src/content/market/export-range-controller.ts`
- Modify: `src/content/market/types.ts`
- Modify: `src/content/market/dom-sync.ts`
- Modify: `src/content/market/result-store.ts`
- Test: `tests/market-content-entry.test.ts`
- Test: `tests/market-dom-sync.test.ts`
- [ ] **Step 1: Write the failing tests**
Add focused tests for:
- reading a page signature from current DOM
- detecting/using the next-page control
- merging rows by `authorId` with non-empty-field preference
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts`
Expected: FAIL because these helpers/controller do not exist yet.
- [ ] **Step 3: Write the minimal implementation**
Implement `export-range-controller.ts` to:
- normalize export targets (`current`, `count`, `all`)
- harvest current page rows with the existing lazy-load scroll path
- read page signatures
- click/advance pagination and wait for signature change
- merge pages by `authorId` without overwriting non-empty fields
- return a final `MarketRecord[]`
Keep `dom-sync.ts` focused on DOM reads:
- page signature
- next-page element lookup
- next-page enabled check
- [ ] **Step 4: Run tests to verify they pass**
Run:
- `npm test -- tests/market-dom-sync.test.ts`
- `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/export-range-controller.ts src/content/market/dom-sync.ts src/content/market/result-store.ts src/content/market/types.ts tests/market-content-entry.test.ts tests/market-dom-sync.test.ts
git commit -m "feat: add multi-page export controller"
```
### Task 4: Wire Controller Into Market Entry
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `src/content/market/plugin-toolbar.ts`
- Test: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing tests**
Add tests that assert:
- `导出CSV` uses the selected range
- progress text updates while exporting
- controls are re-enabled on success and failure
- invalid custom input blocks export without calling CSV builder
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: FAIL because `market/index.ts` still always exports current-page records.
- [ ] **Step 3: Write the minimal implementation**
Update `market/index.ts` to:
- parse toolbar export range
- keep current-page export for `当前页`
- delegate batch modes to `export-range-controller.ts`
- update toolbar running/error/progress state
- preserve existing current-page lazy-load harvest path
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test -- tests/market-content-entry.test.ts`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add src/content/market/index.ts src/content/market/plugin-toolbar.ts tests/market-content-entry.test.ts
git commit -m "feat: wire export range selection into market export"
```
### Task 5: Verify End-To-End
**Files:**
- Modify: `docs/superpowers/plans/2026-04-21-market-export-range.md`
- [ ] **Step 1: Run targeted regression tests**
Run:
- `npm test -- tests/market-content-entry.test.ts`
- `npm test -- tests/market-dom-sync.test.ts`
Expected: PASS
- [ ] **Step 2: Run the full suite**
Run: `npm test`
Expected: PASS with all test files green.
- [ ] **Step 3: Run the build**
Run: `npm run build`
Expected: PASS and emit updated `dist/` bundles.
- [ ] **Step 4: Mark the plan status**
Update this documents checkboxes to reflect completed steps.
- [ ] **Step 5: Commit**
```bash
git add docs/superpowers/plans/2026-04-21-market-export-range.md
git commit -m "docs: record export range implementation progress"
```

View File

@ -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` 页,支持 `全部``自定义`。批量模式通过真实翻页逐页采集当前筛选结果,不把多页扫描重新引入到筛选和排序链路中。

View File

@ -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": {

View File

@ -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")

95
src/background/index.ts Normal file
View File

@ -0,0 +1,95 @@
interface ChromeDownloadsLike {
download(
options: {
filename: string;
saveAs?: boolean;
url: string;
},
callback?: () => void
): Promise<unknown> | void;
}
interface ChromeRuntimeLike {
onMessage?: {
addListener(
listener: (
message: unknown,
sender: unknown,
sendResponse: (response: unknown) => void
) => boolean | void
): void;
};
}
interface ChromeLike {
downloads?: ChromeDownloadsLike;
runtime?: ChromeRuntimeLike;
}
type DownloadMarketCsvMessage = {
csv: string;
filename: string;
type: "download-market-csv";
};
export function registerBackgroundMessageHandler(
chromeLike: ChromeLike = (
globalThis as typeof globalThis & {
chrome?: ChromeLike;
}
).chrome ?? {}
): void {
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
if (!isDownloadMarketCsvMessage(message)) {
return;
}
void triggerCsvDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true });
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false
});
});
return true;
});
}
async function triggerCsvDownload(
chromeLike: ChromeLike,
message: DownloadMarketCsvMessage
): Promise<void> {
if (!chromeLike.downloads?.download) {
throw new Error("chrome.downloads.download is unavailable");
}
const csvUrl = `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${message.csv}`)}`;
await Promise.resolve(
chromeLike.downloads.download({
filename: message.filename,
saveAs: false,
url: csvUrl
})
);
}
function isDownloadMarketCsvMessage(
message: unknown
): message is DownloadMarketCsvMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<DownloadMarketCsvMessage>;
return (
candidate.type === "download-market-csv" &&
typeof candidate.csv === "string" &&
typeof candidate.filename === "string"
);
}
registerBackgroundMessageHandler();

View File

@ -6,8 +6,11 @@ import {
interface ChromeRuntimeLike {
getURL?: (path: string) => string;
id?: string;
sendMessage?: (message: unknown) => void | Promise<unknown>;
}
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(

View File

@ -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<string>();
records.forEach((record) => {
Object.keys(record.exportFields ?? {}).forEach((header) => {
if (seenHeaders.has(header)) {
return;
}
seenHeaders.add(header);
orderedHeaders.push(header);
});
});
if (orderedHeaders.length === 0) {
return FALLBACK_BASE_COLUMNS;
}
return orderedHeaders.map((header) => ({
header,
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
}));
}

View File

@ -20,6 +20,7 @@ type RowOrderTarget = {
export interface MarketRowDom {
authorId: string;
authorName: string;
exportFields?: Record<string, string>;
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<string, string> {
return Array.from(header.querySelectorAll("[data-market-header-cell]")).reduce<
Record<string, string>
>((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<string, string>
): Record<string, string> {
const exportFields: Record<string, string> = {};
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<string, string> {
const exportFields: Record<string, string> = {};
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;
}

View File

@ -0,0 +1,276 @@
import {
findNextPageControl,
isPageControlDisabled,
readMarketPageSignature
} from "./dom-sync";
import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types";
interface ExportRangeControllerOptions {
document: Document;
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
prepareCurrentPageForExport(): Promise<void>;
readCurrentPageRecords(): MarketRecord[];
window: Window;
}
export function createExportRangeController(options: ExportRangeControllerOptions) {
return {
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
const mergedRecords = new Map<string, MarketRecord>();
let currentPage = 0;
let expectedMinimumRowCount: number | undefined;
while (true) {
currentPage += 1;
options.onProgress?.({
currentPage,
totalPages: target.mode === "count" ? target.pageCount : undefined
});
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
if (!currentPageReady) {
throw new Error(`${currentPage} 页加载超时,请稍后重试`);
}
await options.prepareCurrentPageForExport();
const currentPageRecords = options.readCurrentPageRecords();
currentPageRecords.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
});
expectedMinimumRowCount = Math.max(
expectedMinimumRowCount ?? 0,
currentPageRecords.length
);
if (target.mode === "count" && currentPage >= target.pageCount) {
break;
}
const previousSignature = readMarketPageSignature(options.document);
const nextPageControl = findNextPageControl(options.document);
if (!nextPageControl || isPageControlDisabled(nextPageControl)) {
break;
}
nextPageControl.click();
const pageChanged = await waitForPageChange(previousSignature);
if (!pageChanged) {
throw new Error(`${currentPage + 1} 页导出失败,请稍后重试`);
}
}
return Array.from(mergedRecords.values());
}
};
async function waitForPageChange(previousSignature: string): Promise<boolean> {
const previousPageState = parsePageSignature(previousSignature);
for (let attempt = 0; attempt < 60; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 50);
});
await Promise.resolve();
const nextSignature = readMarketPageSignature(options.document);
const nextPageState = parsePageSignature(nextSignature);
if (hasLoadedNextPage(previousPageState, nextPageState)) {
return true;
}
}
return false;
}
async function waitForCurrentPageReady(
expectedMinimumRowCount: number | undefined
): Promise<boolean> {
let stableAttemptCount = 0;
let lastReadyFingerprint = "";
for (let attempt = 0; attempt < 80; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 150);
});
await Promise.resolve();
const pageState = readCurrentPageState();
if (!pageState.authorIds || pageState.rowCount <= 0) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
if (
typeof expectedMinimumRowCount === "number" &&
expectedMinimumRowCount > 0 &&
!pageState.isTerminalPage &&
pageState.rowCount < expectedMinimumRowCount
) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
const readyFingerprint = [
pageState.pageToken,
pageState.authorIds,
String(pageState.rowCount),
pageState.isTerminalPage ? "terminal" : "paged"
].join("::");
if (readyFingerprint === lastReadyFingerprint) {
stableAttemptCount += 1;
} else {
lastReadyFingerprint = readyFingerprint;
stableAttemptCount = 1;
}
if (stableAttemptCount >= 6) {
return true;
}
}
return false;
}
function readCurrentPageState(): {
authorIds: string;
isTerminalPage: boolean;
pageToken: string;
rowCount: number;
} {
const pageSignature = parsePageSignature(readMarketPageSignature(options.document));
const nextPageControl = findNextPageControl(options.document);
return {
authorIds: pageSignature.authorIds,
isTerminalPage: isPageControlDisabled(nextPageControl),
pageToken: pageSignature.pageToken,
rowCount: options.readCurrentPageRecords().length
};
}
}
function parsePageSignature(signature: string): {
authorIds: string;
pageToken: string;
} {
const separatorIndex = signature.indexOf("::");
if (separatorIndex < 0) {
return {
authorIds: "",
pageToken: signature.trim()
};
}
return {
authorIds: signature.slice(separatorIndex + 2).trim(),
pageToken: signature.slice(0, separatorIndex).trim()
};
}
function hasLoadedNextPage(
previousPageState: {
authorIds: string;
pageToken: string;
},
nextPageState: {
authorIds: string;
pageToken: string;
}
): boolean {
if (!nextPageState.authorIds) {
return false;
}
if (nextPageState.pageToken || previousPageState.pageToken) {
return nextPageState.pageToken !== previousPageState.pageToken;
}
return nextPageState.authorIds !== previousPageState.authorIds;
}
function mergeMarketRecord(
existingRecord: MarketRecord | undefined,
incomingRecord: MarketRecord
): MarketRecord {
if (!existingRecord) {
return {
...incomingRecord,
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
rates: mergeFieldMap(undefined, incomingRecord.rates)
};
}
return {
...existingRecord,
...incomingRecord,
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
exportFields: mergeFieldMap(
existingRecord.exportFields,
incomingRecord.exportFields
),
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
hasDirectRatesSource:
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
location: mergeStringValue(existingRecord.location, incomingRecord.location),
price21To60s: mergeStringValue(
existingRecord.price21To60s,
incomingRecord.price21To60s
),
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
status: mergeStatus(existingRecord.status, incomingRecord.status)
};
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStatus(
current: MarketRecordStatus,
incoming: MarketRecordStatus
): MarketRecordStatus {
const priority: Record<MarketRecordStatus, number> = {
failed: 1,
idle: 0,
loading: 2,
missing: -1,
success: 3
};
return priority[incoming] >= priority[current] ? incoming : current;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (hasTextValue(incoming) || !hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}

View File

@ -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<void>;
ensureScanForFilter(): Promise<void>;
ensureScanForSort(): Promise<void>;
}
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<MarketApiResult>;
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();
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<MarketRecord[]> {
if (target.mode === "count" && target.pageCount <= 1) {
setToolbarExportStatus(toolbar, "导出中...");
await prepareCurrentPageForExport();
return getVisibleOrderedRecords();
}
return exportRangeController.exportRecords(target);
}
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();
}
async function harvestCurrentPageForExport(): Promise<void> {
await collectCurrentPageSnapshotsUntilSettled();
const table = syncMarketTable(options.document);
const scrollContainer = findCurrentPageScrollContainer(table);
if (!scrollContainer) {
return;
}
const originalScrollTop = scrollContainer.scrollTop;
const maxScrollTop = Math.max(
0,
scrollContainer.scrollHeight - scrollContainer.clientHeight
);
if (maxScrollTop <= 0) {
return;
}
const step = Math.max(scrollContainer.clientHeight, 240);
for (
let nextScrollTop = Math.min(originalScrollTop + step, maxScrollTop);
nextScrollTop > originalScrollTop && nextScrollTop <= maxScrollTop;
nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop)
) {
setScrollTop(scrollContainer, nextScrollTop);
await collectCurrentPageSnapshotsUntilSettled();
if (nextScrollTop === maxScrollTop) {
break;
}
}
if (scrollContainer.scrollTop !== originalScrollTop) {
setScrollTop(scrollContainer, originalScrollTop);
await collectCurrentPageSnapshotsUntilSettled();
}
}
function readCurrentPageRecords(table: ReturnType<typeof syncMarketTable>): MarketRecord[] {
if (!table) {
return [];
}
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
return null;
}
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
return {
...existingRecord,
...rowSnapshot,
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
exportFields: mergeFieldMap(
existingRecord?.exportFields,
rowSnapshot.exportFields
),
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
price21To60s: mergeStringValue(
existingRecord?.price21To60s,
rowSnapshot.price21To60s
),
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
status: existingRecord?.status ?? "idle"
} satisfies MarketRecord;
})
.filter((record): record is MarketRecord => record !== null);
}
function collectCurrentPageSnapshots(): void {
readCurrentPageRows(options.document).forEach((rowSnapshot) => {
resultStore.upsertMarketRow(rowSnapshot);
});
}
function findCurrentPageScrollContainer(
table: ReturnType<typeof syncMarketTable>
): HTMLElement | null {
if (!table) {
return null;
}
const seenElements = new Set<HTMLElement>();
const candidateRoots = table.rows
.map((rowDom) => rowDom.row)
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
for (const rootElement of candidateRoots) {
let currentElement = rootElement.parentElement;
while (currentElement) {
if (
!seenElements.has(currentElement) &&
isScrollableContainer(currentElement)
) {
return currentElement;
}
seenElements.add(currentElement);
currentElement = currentElement.parentElement;
}
}
return null;
}
function isScrollableContainer(element: HTMLElement): boolean {
const computedStyle = options.window.getComputedStyle(element);
return (
/auto|scroll|overlay/.test(computedStyle.overflowY) &&
element.scrollHeight > element.clientHeight
);
}
async function waitForDomSettled(): Promise<void> {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 0);
});
await Promise.resolve();
}
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
let previousFingerprint = "";
let stablePassCount = 0;
for (let attempt = 0; attempt < 12; attempt += 1) {
await waitForDomSettled();
if (attempt > 0) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 100);
});
await Promise.resolve();
}
collectCurrentPageSnapshots();
const nextFingerprint = readVisibleRowHydrationFingerprint();
if (!nextFingerprint) {
stablePassCount = 0;
previousFingerprint = "";
continue;
}
if (nextFingerprint === previousFingerprint) {
stablePassCount += 1;
} else {
previousFingerprint = nextFingerprint;
stablePassCount = 1;
}
if (stablePassCount >= 3) {
return;
}
}
}
function readVisibleRowHydrationFingerprint(): string {
const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) {
return "";
}
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0
).length;
return [
rowSnapshot.authorId,
populatedFieldCount,
rowSnapshot.price21To60s?.trim() ? "price" : "no-price"
].join(":");
})
.join("|");
}
function scheduleSync(): void {
if (isSyncRunning) {
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));
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
async function goToNextMarketPage(
document: Document,
window: Window
): Promise<boolean> {
const nextButton = findNextPageButton(document);
if (!nextButton || isDisabled(nextButton)) {
return false;
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
const previousSignature = getCurrentPageSignature(document);
nextButton.click();
return waitForPageSignatureChange(document, window, previousSignature);
}
function findNextPageButton(document: Document): HTMLElement | null {
const selectorMatch = document.querySelector(
'[data-testid="next-page"], .ant-pagination-next, .aux-pagination-next, .auxo-pagination-next, [aria-label="next page"]'
);
if (selectorMatch instanceof document.defaultView!.HTMLElement) {
return selectorMatch;
}
return Array.from(document.querySelectorAll("button, a, div, span")).find(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement &&
element.textContent?.trim() === "下一页"
) ?? null;
}
function isDisabled(element: HTMLElement): boolean {
return (
"disabled" in element &&
Boolean((element as HTMLButtonElement).disabled) ||
element.getAttribute("aria-disabled") === "true" ||
/disabled|is-disabled/.test(element.className)
);
}
function getCurrentPageSignature(document: Document): string {
return readCurrentPageRows(document)
.map((row) => row.authorId)
.join("|");
}
function waitForPageSignatureChange(
document: Document,
window: Window,
previousSignature: string
): Promise<boolean> {
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();
});
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;
}

View File

@ -1,3 +1,5 @@
import type { MarketExportScope, MarketExportTarget } from "./types";
export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | 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,12 @@ export function ensurePluginToolbar(
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
return {
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
@ -95,7 +125,25 @@ export function ensurePluginToolbar(
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
};
});
});
const toolbarDom = {
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
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";
}

View File

@ -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<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (!hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (!hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}

View File

@ -8,6 +8,7 @@ export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "mi
export interface MarketRowSnapshot {
authorId: string;
authorName: string;
exportFields?: Record<string, string>;
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<AfterSearchRates>;

View File

@ -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": [

View File

@ -0,0 +1,50 @@
import { describe, expect, test, vi } from "vitest";
import { registerBackgroundMessageHandler } from "../src/background/index";
describe("background-index", () => {
test("downloads csv files when the content script sends a download message", async () => {
const listeners: Array<
(message: unknown, sender: unknown, sendResponse: (response: unknown) => void) => boolean | void
> = [];
const download = vi.fn(async () => undefined);
const sendResponse = vi.fn();
registerBackgroundMessageHandler({
downloads: {
download
},
runtime: {
onMessage: {
addListener(listener) {
listeners.push(listener);
}
}
}
});
expect(listeners).toHaveLength(1);
const result = listeners[0](
{
csv: "列1,列2\n值1,值2",
filename: "market.csv",
type: "download-market-csv"
},
{},
sendResponse
);
expect(result).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(download).toHaveBeenCalledTimes(1);
expect(download).toHaveBeenCalledWith(
expect.objectContaining({
filename: "market.csv",
saveAs: false,
url: expect.stringContaining("data:text/csv;charset=utf-8,")
})
);
expect(sendResponse).toHaveBeenCalledWith({ ok: true });
});
});

View File

@ -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", () => {

View File

@ -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");
});
});

File diff suppressed because it is too large Load Diff

View File

@ -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()}
<div class="pagination xt-space xt-space--medium">
<div class="el-pagination is-background xt-pagination xt-pagination--normal">
<button type="button" disabled="disabled" class="btn-prev">
<i class="el-icon el-icon-arrow-left"></i>
</button>
<ul class="el-pager">
<li class="number active">1</li>
<li class="number">2</li>
</ul>
<button type="button" class="btn-next">
<i class="el-icon el-icon-arrow-right"></i>
</button>
</div>
</div>
`;
const nextControl = findNextPageControl(document);
expect(nextControl).not.toBeNull();
expect(nextControl?.className).toContain("btn-next");
expect(isPageControlDisabled(nextControl)).toBe(false);
expect(readMarketPageSignature(document)).toContain("1::111|222");
});
});
function buildRealMarketGridFixture() {