Compare commits

..

8 Commits

28 changed files with 4604 additions and 600 deletions

View File

@ -0,0 +1,79 @@
# Market Backend Metrics CSV 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:** Extend CSV export so it appends the six backend metrics already stored on each market record.
**Architecture:** Keep the existing export flow intact and modify only the CSV column definition layer. Reuse `MarketRecord.backendMetrics` as the sole source for the six new CSV columns so the exported data matches what the plugin already loaded into memory.
**Tech Stack:** TypeScript, existing market CSV exporter, Vitest
---
## File Map
- Modify: `src/content/market/csv-exporter.ts`
- Append six backend metrics columns after existing CSV columns.
- Modify: `tests/csv-exporter.test.ts`
- Add failing tests for backend metric headers and values.
### Task 1: Backend Metrics CSV Columns
**Files:**
- Modify: `tests/csv-exporter.test.ts`
- Modify: `src/content/market/csv-exporter.ts`
- [ ] **Step 1: Write the failing CSV exporter tests**
Add tests for:
- the six backend metric headers appended after current columns
- backend metric values exported from `record.backendMetrics`
- blank cells when `backendMetrics` is absent
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test -- tests/csv-exporter.test.ts`
Expected: FAIL because the exporter does not include backend metric columns yet.
- [ ] **Step 3: Write the minimal exporter change**
Append these six columns:
- `看后搜率`
- `看后搜数`
- `新增A3数`
- `新增A3率`
- `CPA3`
- `cp_search`
Each column reads from:
- `record.backendMetrics?.afterViewSearchRate`
- `record.backendMetrics?.afterViewSearchCount`
- `record.backendMetrics?.a3IncreaseCount`
- `record.backendMetrics?.newA3Rate`
- `record.backendMetrics?.cpa3`
- `record.backendMetrics?.cpSearch`
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test -- tests/csv-exporter.test.ts`
Expected: PASS
- [ ] **Step 5: Run full verification**
Run:
```bash
npm test
npm run build
```
Expected:
- full test suite passes
- build succeeds
- [ ] **Step 6: Commit**
```bash
git add src/content/market/csv-exporter.ts tests/csv-exporter.test.ts docs/superpowers/specs/2026-04-22-market-backend-metrics-csv-design.md docs/superpowers/plans/2026-04-22-market-backend-metrics-csv.md
git commit -m "feat: export backend metrics in csv"
```

View File

@ -0,0 +1,61 @@
# Market Scrollable Plugin Columns Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move plugin-generated market columns out of the right sticky area and into the horizontally scrollable middle area.
**Architecture:** Keep Xingtu's native left and right sticky sections intact. Add a dedicated non-sticky plugin section for the injected columns and continue rendering row state through the existing `MarketRowDom` abstraction.
**Tech Stack:** TypeScript, Vitest, jsdom
---
### Task 1: Lock The Intended Layout In Tests
**Files:**
- Modify: `tests/market-dom-sync.test.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write failing tests for the layout boundary**
Add assertions that:
- the right sticky header/body widths remain native after plugin columns are added
- plugin columns live in a dedicated non-sticky section instead of the right sticky section
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
- [ ] **Step 3: Implement the minimal layout changes**
Update the div-grid sync path so plugin columns are inserted into a separate scrollable section.
- [ ] **Step 4: Re-run the same tests**
Run: `npm test -- tests/market-dom-sync.test.ts tests/market-content-entry.test.ts`
- [ ] **Step 5: Commit**
```bash
git add tests/market-dom-sync.test.ts tests/market-content-entry.test.ts src/content/market/dom-sync.ts
git commit -m "feat: move market plugin columns into scrollable section"
```
### Task 2: Verify No Export Regression
**Files:**
- Verify only: `src/content/market/index.ts`
- Verify only: `src/content/market/export-range-controller.ts`
- Verify only: `tests/full-scan-controller.test.ts`
- [ ] **Step 1: Run export-related regression tests**
Run: `npm test -- tests/full-scan-controller.test.ts`
- [ ] **Step 2: Run build**
Run: `npm run build`
- [ ] **Step 3: Report any unrelated failures separately**
Do not broaden the scope unless a new failure is caused by the layout change.

View File

@ -0,0 +1,161 @@
# Market Native Action Bar 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:** Move the plugin export actions into Xingtu's native market action row and remove the old top toolbar and unused filter/sort controls.
**Architecture:** Keep the existing export and batch-submit business logic, but replace the toolbar mount point and DOM shape. The new toolbar becomes a small inline action bar inserted into the native button row beside `自定义指标` and `导出`, while the controller only depends on export-range and action buttons.
**Tech Stack:** TypeScript, Chrome MV3 content script, jsdom/Vitest
---
## File Map
- Modify: `src/content/market/plugin-toolbar.ts`
- Replace the top toolbar with an inline native-style action bar.
- Modify: `src/content/market/index.ts`
- Remove filter/sort toolbar dependencies.
- Modify: `tests/market-content-entry.test.ts`
- Update toolbar assertions to the new placement and reduced control set.
### Task 1: Lock The New Toolbar Shape In Tests
**Files:**
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Write the failing toolbar placement tests**
Add tests that assert:
- the plugin toolbar is inserted next to the native `自定义指标` / `导出` action row
- the toolbar no longer renders filter/sort controls
- the toolbar still renders export range, custom page input, export button, batch button, and status text
- [ ] **Step 2: Run the focused test to verify it fails**
Run: `npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row"`
Expected: FAIL because the toolbar still prepends to `document.body` and still contains filter/sort controls.
- [ ] **Step 3: Add a failing busy-state regression test**
Assert that during export:
- export button is disabled
- batch button is disabled
- export range select is disabled
- custom page input is disabled when visible
- [ ] **Step 4: Run the focused busy-state test**
Run: `npx vitest run tests/market-content-entry.test.ts -t "exporting all pages disables the native action bar controls during the task"`
Expected: FAIL only if the new toolbar structure breaks existing busy-state selectors.
### Task 2: Implement The Native Action Bar
**Files:**
- Modify: `src/content/market/plugin-toolbar.ts`
- [ ] **Step 1: Replace the toolbar mount strategy**
Implement a helper that finds the native action row containing `自定义指标` and `导出`, then inserts the plugin root into that row.
- [ ] **Step 2: Replace the toolbar DOM structure**
Create only:
- export range select
- custom pages input
- export button
- batch submit button
- export status text
Remove creation and lookup of:
- filter inputs
- filter button
- sort field select
- sort direction select
- sort button
- [ ] **Step 3: Apply native-style button and inline layout styling**
Use lightweight inline styles / class reuse so the plugin controls visually align with the native button row.
- [ ] **Step 4: Keep custom-range visibility logic working**
Preserve:
- `current`
- `first-5`
- `first-10`
- `all`
- `custom`
When `custom` is selected, show the input; otherwise hide it.
- [ ] **Step 5: Run the focused tests to verify green**
Run:
```bash
npx vitest run tests/market-content-entry.test.ts -t "renders the plugin action bar inside the native market action row|exporting all pages disables the native action bar controls during the task"
```
Expected: PASS
### Task 3: Remove Toolbar Filter/Sort Dependencies
**Files:**
- Modify: `src/content/market/index.ts`
- Modify: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Remove toolbar filter/sort read paths**
Delete the toolbar handlers and state reads that depend on removed controls.
- [ ] **Step 2: Keep export and batch submission behavior intact**
Ensure:
- export still reads the selected range
- batch submit still reads the selected range
- status text still updates during progress and completion
- [ ] **Step 3: Update affected tests**
Adjust tests that previously asserted filter/sort button disabled state so they assert only the remaining controls.
- [ ] **Step 4: Run focused regression tests**
Run:
```bash
npx vitest run tests/market-content-entry.test.ts -t "custom export range blocks invalid page counts|prompts for a batch name before submitting the current range|exporting all pages disables the native action bar controls during the task"
```
Expected: PASS
### Task 4: Final Verification
**Files:**
- Verify only: `src/content/market/plugin-toolbar.ts`
- Verify only: `src/content/market/index.ts`
- Verify only: `tests/market-content-entry.test.ts`
- [ ] **Step 1: Run the targeted market content tests**
Run:
```bash
npx vitest run tests/market-content-entry.test.ts
```
- [ ] **Step 2: Run build**
Run:
```bash
npm run build
```
- [ ] **Step 3: Commit**
```bash
git add src/content/market/plugin-toolbar.ts src/content/market/index.ts tests/market-content-entry.test.ts docs/superpowers/specs/2026-04-23-market-native-action-bar-design.md docs/superpowers/plans/2026-04-23-market-native-action-bar.md
git commit -m "feat: move market actions into native action bar"
```

View File

@ -0,0 +1,63 @@
# Market Backend Metrics CSV Design
## Goal
Extend the existing CSV export so it includes the six backend metrics already shown in the plugin UI.
## Confirmed Decisions
- Reuse the current export flow.
- Do not add a separate backend request for CSV export.
- Read backend metrics directly from the in-memory `MarketRecord`.
- Append the six backend metrics columns after the existing CSV columns.
- Keep the existing CSV columns and ordering unchanged.
- Use these exact CSV headers:
- `看后搜率`
- `看后搜数`
- `新增A3数`
- `新增A3率`
- `CPA3`
- `cp_search`
- If a record has no backend metrics, export empty strings for these six columns.
## Architecture
- `src/content/market/csv-exporter.ts` remains the single place that defines CSV column layout.
- The exporter will keep current base columns and Xingtu rate columns, then append six backend metrics columns.
- No UI changes.
- No batch submission changes.
- No popup or config changes.
## Data Source
Each exported row will read from:
- existing fields:
- `authorId`
- `authorName`
- `location`
- `price21To60s`
- `rates.singleVideoAfterSearchRate`
- `rates.personalVideoAfterSearchRate`
- new backend metrics fields:
- `backendMetrics.afterViewSearchRate`
- `backendMetrics.afterViewSearchCount`
- `backendMetrics.a3IncreaseCount`
- `backendMetrics.newA3Rate`
- `backendMetrics.cpa3`
- `backendMetrics.cpSearch`
## Failure Handling
- Missing backend metrics: export blank cells
- Existing rate formatting behavior remains unchanged
- Backend loading state does not alter CSV structure; it only affects whether the cells contain values or blanks
## Testing
Add tests for:
- backend metric headers appended to CSV
- backend metric values exported correctly
- empty backend metric cells when metrics are absent
- no regression in current base/rate export behavior

View File

@ -0,0 +1,35 @@
# Market Scrollable Plugin Columns Design
**Goal:** Keep the plugin-generated columns visible without letting them cover Xingtu's native middle columns.
**Problem:** The current implementation injects plugin columns into the right sticky section. That expands the sticky width and causes native middle columns such as `预期播放量` and `互动率` to be visually covered.
**Approved Direction:** Keep plugin columns always visible, but move them out of the right sticky area. The plugin columns should live in the horizontally scrollable middle area so they scroll together with the native table columns.
## Design
### Layout
- Preserve the native left sticky author section.
- Preserve the native right sticky section for Xingtu's own right-side columns, especially `21-60s报价` and `操作`.
- Insert plugin columns as a separate non-sticky section immediately before the right sticky section.
- Let the plugin section participate in the same horizontal flow as the middle columns so users reach it through horizontal scrolling.
### DOM Sync Responsibilities
- `syncDivGridRoot()` remains responsible for locating the author section, middle columns, and right sticky section.
- Plugin header cells and plugin body columns should no longer be inserted into `actionHeader.parentElement` / `actionColumn.parentElement`.
- A dedicated plugin section should be created or reused under the header row and body row.
- Row alignment logic should still read plugin cells row-by-row for rendering, visibility, and ordering.
### Behavior
- Export, filtering, sorting, and row hydration should keep working unchanged.
- Only column placement changes.
- Existing synthetic table mode is unaffected.
### Testing
- Add regression coverage proving the right sticky section width stays at the native width.
- Add regression coverage proving plugin columns render in a separate non-sticky section.
- Keep existing export and hydration behavior green.

View File

@ -0,0 +1,94 @@
# Market Native Action Bar Design
## Goal
把插件当前位于页面顶部的工具栏移除,并将真正保留的市场页动作迁移到星图原生操作区中,与 `自定义指标` 和原生 `导出` 处于同一行。
## Confirmed Decisions
- 删除页面顶部整块插件工具栏。
- 只保留这几个插件能力:
- 导出范围选择
- 自定义页数输入
- `导出 CSV`
- `提交批次`
- 状态文案
- 排序和筛选控件从插件工具栏中删除。
- 新的插件操作区放在 `自定义指标` 和原生 `导出` 左侧。
- 主按钮风格尽量复用星图当前页的原生 `xt-button` / `el-button` 样式。
- 不新增弹窗式复杂交互;导出范围继续直接显示在操作区内。
## Layout
### Placement
- 在市场页标题区按钮行内查找 `自定义指标` 和原生 `导出` 所在的横向容器。
- 在该容器中插入一个插件 action bar。
- 插件 action bar 放在这两个原生按钮的左边。
### Visual Structure
插件 action bar 包含:
- `导出范围` 下拉框
- `自定义页数` 输入框,仅在 `自定义` 范围下显示
- `导出 CSV` 按钮
- `提交批次` 按钮
- 状态文本
视觉目标:
- 两个主按钮与原生按钮高度、圆角、边框、字体风格一致
- 范围选择器和页数输入框比按钮略窄,但整体高度对齐
- 状态文案弱化显示,不抢占主要视觉注意力
## Behavior
- `导出 CSV``提交批次` 的业务逻辑保持不变。
- 导出范围逻辑保持不变:
- 当前页
- 前 5 页
- 前 10 页
- 全部
- 自定义
- 选择 `自定义` 时显示页数输入框,否则隐藏。
- 正在导出或提交时:
- `导出 CSV` 按钮禁用
- `提交批次` 按钮禁用
- 范围下拉禁用
- 自定义页数输入禁用
- 顶部原工具栏不再出现。
## DOM Strategy
- `ensurePluginToolbar()` 不再固定 prepend 到 `document.body`
- 它改为:
- 先查找市场页原生操作区
- 在原生按钮组内创建或复用插件 action bar
- 若页面局部重渲染导致节点丢失,内容脚本在后续同步中重新确保其存在
- 工具栏 DOM 结构只保留本次需要的字段,删除筛选和排序输入控件。
## Controller Impact
- `src/content/market/index.ts` 不再从工具栏读取筛选和排序输入值。
- 现有点击表头排序的能力本轮不主动扩展,但不以顶部工具栏为依赖。
- 忙碌状态、状态文本、导出范围读取逻辑继续保留。
## Testing
需要增加或更新测试,覆盖:
- 顶部旧工具栏不再挂载到 `document.body` 顶部
- 新 action bar 挂载到原生按钮组,并位于 `自定义指标` / `导出` 左侧
- 工具栏只保留导出范围、自定义页数、导出、提交、状态文案
- 自定义范围显示/隐藏页数输入框
- 导出和提交忙碌状态仍能正确禁用相关控件
- 自定义范围校验和批次提交路径不回归
## Out of Scope
- 不新增新的筛选 UI
- 不新增新的排序 UI
- 不重做表头排序交互
- 不改动 CSV 内容结构
- 不改动批次 payload 结构

View File

@ -6,6 +6,7 @@ import {
} from "../shared/auth-messages";
import { createBatchSubmitClient } from "../shared/batch-submit-client";
import { createBackendMetricsClient } from "../shared/backend-metrics-client";
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "../shared/batch-submit-config";
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
@ -81,7 +82,7 @@ export function registerBackgroundMessageHandler(
authClient: createLogtoAuthClient()
});
submitBatch ??= createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
baseUrl: DEFAULT_BATCH_SUBMIT_BASE_URL,
getAccessToken: () => authController!.getAccessToken(),
sendMessage: () =>
Promise.reject(new Error("background batch submit does not use sendMessage"))

View File

@ -109,7 +109,7 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
data.personal_avg_search_after_view_rate
);
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
if (!singleVideoAfterSearchRate && !personalVideoAfterSearchRate) {
return {
success: false,
reason: "missing-rate"
@ -119,8 +119,8 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
return {
success: true,
rates: {
singleVideoAfterSearchRate,
personalVideoAfterSearchRate
...(singleVideoAfterSearchRate ? { singleVideoAfterSearchRate } : {}),
...(personalVideoAfterSearchRate ? { personalVideoAfterSearchRate } : {})
}
};
}

View File

@ -40,7 +40,7 @@ export function createBatchPayload(options: {
authorId: record.authorId,
authorName: record.authorName
})),
batchId: `${batchName}-${options.createdAt}`,
batchId: `${logtoUserId}-${options.createdAt}`,
batchName,
createdAt: options.createdAt,
creatorName:

View File

@ -43,9 +43,40 @@ const RATE_COLUMNS: CsvColumn[] = [
}
];
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
{
header: "看后搜率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchRate ?? ""
},
{
header: "看后搜数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchCount ?? ""
},
{
header: "新增A3数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.a3IncreaseCount ?? ""
},
{
header: "新增A3率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.newA3Rate ?? ""
},
{
header: "CPA3",
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
},
{
header: "cp_search",
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
}
];
export function buildMarketCsv(records: MarketRecord[]): string {
const baseColumns = buildBaseColumns(records);
const csvColumns = [...baseColumns, ...RATE_COLUMNS];
const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
@ -57,10 +88,11 @@ export function buildMarketCsv(records: MarketRecord[]): string {
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
const orderedHeaders: string[] = [];
const seenHeaders = new Set<string>();
const excludedHeaders = new Set(["代表视频"]);
records.forEach((record) => {
Object.keys(record.exportFields ?? {}).forEach((header) => {
if (seenHeaders.has(header)) {
if (seenHeaders.has(header) || excludedHeaders.has(header)) {
return;
}

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ interface ExportRangeControllerOptions {
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
prepareCurrentPageForExport(): Promise<void>;
readCurrentPageRecords(): MarketRecord[];
readCurrentPageRowCount(): number;
window: Window;
}
@ -26,13 +27,10 @@ export function createExportRangeController(options: ExportRangeControllerOption
currentPage,
totalPages: target.mode === "count" ? target.pageCount : undefined
});
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
if (!currentPageReady) {
const currentPageRecords = await preparePageRecords(expectedMinimumRowCount);
if (!currentPageRecords) {
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));
@ -63,6 +61,33 @@ export function createExportRangeController(options: ExportRangeControllerOption
}
};
async function preparePageRecords(
expectedMinimumRowCount: number | undefined
): Promise<MarketRecord[] | null> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const currentPageReady = await waitForCurrentPageReady();
if (!currentPageReady) {
return null;
}
await options.prepareCurrentPageForExport();
const currentPageRecords = options.readCurrentPageRecords();
if (
currentPageRecords.length > 0 &&
(
typeof expectedMinimumRowCount !== "number" ||
expectedMinimumRowCount <= 0 ||
isCurrentPageTerminal() ||
currentPageRecords.length >= expectedMinimumRowCount
)
) {
return currentPageRecords;
}
}
return null;
}
async function waitForPageChange(previousSignature: string): Promise<boolean> {
const previousPageState = parsePageSignature(previousSignature);
@ -82,9 +107,7 @@ export function createExportRangeController(options: ExportRangeControllerOption
return false;
}
async function waitForCurrentPageReady(
expectedMinimumRowCount: number | undefined
): Promise<boolean> {
async function waitForCurrentPageReady(): Promise<boolean> {
let stableAttemptCount = 0;
let lastReadyFingerprint = "";
@ -101,17 +124,6 @@ export function createExportRangeController(options: ExportRangeControllerOption
continue;
}
if (
typeof expectedMinimumRowCount === "number" &&
expectedMinimumRowCount > 0 &&
!pageState.isTerminalPage &&
pageState.rowCount < expectedMinimumRowCount
) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
const readyFingerprint = [
pageState.pageToken,
pageState.authorIds,
@ -146,9 +158,13 @@ export function createExportRangeController(options: ExportRangeControllerOption
authorIds: pageSignature.authorIds,
isTerminalPage: isPageControlDisabled(nextPageControl),
pageToken: pageSignature.pageToken,
rowCount: options.readCurrentPageRecords().length
rowCount: options.readCurrentPageRowCount()
};
}
function isCurrentPageTerminal(): boolean {
return isPageControlDisabled(findNextPageControl(options.document));
}
}
function parsePageSignature(signature: string): {

View File

@ -3,6 +3,9 @@ import {
parseRateLowerBound
} from "../../shared/rate-normalizer";
import type {
AfterSearchRates,
BackendMetrics,
MarketSortField,
MarketFilterState,
MarketRecord,
MarketSortState
@ -67,13 +70,26 @@ function compareRecords(
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const leftValue = leftRecord.rates?.[sort.field];
const rightValue = rightRecord.rates?.[sort.field];
if (isRateSortField(sort.field)) {
return compareRateSortRecords(leftRecord, rightRecord, sort);
}
return compareBackendMetricRecords(leftRecord, rightRecord, sort);
}
function compareRateSortRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const field = sort.field as keyof Required<AfterSearchRates>;
const leftValue = leftRecord.rates?.[field];
const rightValue = rightRecord.rates?.[field];
const leftLowerBound = parseRateLowerBound(leftValue ?? null);
const rightLowerBound = parseRateLowerBound(rightValue ?? null);
if (leftLowerBound == null && rightLowerBound == null) {
return 0;
return compareRecordIdentity(leftRecord, rightRecord);
}
if (leftLowerBound == null) {
@ -91,5 +107,72 @@ function compareRecords(
}
const tieBreak = compareRateValues(leftValue, rightValue);
if (tieBreak !== 0) {
return sort.direction === "asc" ? tieBreak : -tieBreak;
}
return compareRecordIdentity(leftRecord, rightRecord);
}
function compareBackendMetricRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const field = sort.field as keyof Required<BackendMetrics>;
const leftValue = parseBackendMetricValue(leftRecord.backendMetrics?.[field]);
const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]);
if (leftValue == null && rightValue == null) {
return compareRecordIdentity(leftRecord, rightRecord);
}
if (leftValue == null) {
return 1;
}
if (rightValue == null) {
return -1;
}
if (leftValue !== rightValue) {
return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
}
return compareRecordIdentity(leftRecord, rightRecord);
}
function parseBackendMetricValue(value: string | null | undefined): number | null {
if (!value) {
return null;
}
const normalizedValue = value.replace(/,/g, "").replace(/%/g, "").trim();
if (!normalizedValue) {
return null;
}
const numericValue = Number(normalizedValue);
return Number.isFinite(numericValue) ? numericValue : null;
}
function isRateSortField(
field: MarketSortField
): field is keyof Required<AfterSearchRates> {
return (
field === "singleVideoAfterSearchRate" ||
field === "personalVideoAfterSearchRate"
);
}
function compareRecordIdentity(
leftRecord: MarketRecord,
rightRecord: MarketRecord
): number {
const authorIdCompare = leftRecord.authorId.localeCompare(rightRecord.authorId);
if (authorIdCompare !== 0) {
return authorIdCompare;
}
return leftRecord.authorName.localeCompare(rightRecord.authorName);
}

View File

@ -3,14 +3,16 @@ import { createBatchPayload, type BatchPayload } from "./batch-payload";
import {
applyRowOrder,
applyRowVisibility,
readMarketPageSignature,
renderMarketRowState,
syncPluginSortHeaders,
syncMarketTable,
type MarketRowDom
} from "./dom-sync";
import { applyFilterAndSort } from "./filter-sort-controller";
import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar } from "./plugin-toolbar";
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
import {
readToolbarExportTarget,
setToolbarBusyState,
@ -25,7 +27,6 @@ import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-me
import type {
BackendMetrics,
MarketApiResult,
MarketFilterState,
MarketExportTarget,
MarketRecord,
MarketRowSnapshot,
@ -88,40 +89,41 @@ export function createMarketController(options: CreateMarketControllerOptions) {
},
prepareCurrentPageForExport: prepareCurrentPageForExport,
readCurrentPageRecords: () => getVisibleOrderedRecords(),
readCurrentPageRowCount: () => countCurrentPageRows(options.document),
window: options.window
});
let activeFilters: MarketFilterState = {};
let activeSort: MarketSortState | undefined;
let isDisposed = false;
let isSyncRunning = false;
let isSyncScheduled = false;
let lastKnownPageSignature = "";
let needsResync = false;
let scheduledSyncTimeoutId: number | null = null;
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
const observer = mutationObserverFactory(() => {
if (isDisposed) {
return;
}
let nextPageSignature = lastKnownPageSignature;
try {
nextPageSignature = readMarketPageSignature(options.document);
} catch {
return;
}
const toolbarNeedsRemount =
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
if (nextPageSignature === lastKnownPageSignature && !toolbarNeedsRemount) {
return;
}
scheduleSync();
});
const observationRoot = options.document.body ?? options.document.documentElement;
if (observationRoot) {
observer.observe(observationRoot, {
childList: true,
subtree: true
});
}
startObserving();
const toolbar = ensurePluginToolbar(options.document, {
onApplyFilter: async () => {
activeFilters = {
personalVideoAfterSearchRateMin: parseNumberValue(
toolbar.personalFilterInput.value
),
singleVideoAfterSearchRateMin: parseNumberValue(
toolbar.singleFilterInput.value
)
};
applyCurrentView();
},
onApplySort: async () => {
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
applyCurrentView();
},
const toolbarHandlers = {
onExport: async () => {
const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
@ -185,13 +187,19 @@ export function createMarketController(options: CreateMarketControllerOptions) {
setToolbarBusyState(toolbar, false);
}
}
});
};
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
const ready = runSyncCycle();
return {
dispose() {
isDisposed = true;
observer.disconnect();
if (scheduledSyncTimeoutId !== null) {
options.window.clearTimeout(scheduledSyncTimeoutId);
scheduledSyncTimeoutId = null;
}
},
ready
};
@ -209,7 +217,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
continue;
}
@ -362,21 +370,34 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
function applyCurrentView(): void {
runWithoutMutationSync(() => {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
const table = syncMarketTable(options.document);
if (!table) {
return;
}
syncPluginSortHeaders(options.document, {
activeSort,
onToggleSort: toggleSortFromHeader
});
const records = getVisibleOrderedRecords(table);
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, records.map((record) => record.authorId));
lastKnownPageSignature = readMarketPageSignature(options.document);
});
}
function toggleSortFromHeader(field: MarketSortState["field"]): void {
activeSort = getNextSortState(activeSort, field);
applyCurrentView();
}
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
const currentPageRecords = readCurrentPageRecords(table);
return applyFilterAndSort(currentPageRecords, {
filters: activeFilters,
sort: activeSort
});
}
@ -397,6 +418,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();
await runSyncCycle();
}
async function harvestCurrentPageForExport(): Promise<void> {
@ -445,29 +467,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
return null;
}
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
const authorName =
mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "";
const location = mergeStringValue(existingRecord?.location, rowSnapshot.location);
const price21To60s = mergeStringValue(
existingRecord?.price21To60s,
rowSnapshot.price21To60s
);
return {
...existingRecord,
...rowSnapshot,
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
authorName,
backendMetrics: mergeFieldMap(
existingRecord?.backendMetrics,
rowSnapshot.backendMetrics
),
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
exportFields: mergeFieldMap(
existingRecord?.exportFields,
rowSnapshot.exportFields
),
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
price21To60s: mergeStringValue(
existingRecord?.price21To60s,
rowSnapshot.price21To60s
exportFields: withExportFieldFallbacks(
mergeFieldMap(existingRecord?.exportFields, rowSnapshot.exportFields),
{
authorName,
location,
price21To60s
}
),
location,
price21To60s,
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
status: existingRecord?.status ?? "idle"
} satisfies MarketRecord;
@ -488,27 +518,42 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return null;
}
const seenElements = new Set<HTMLElement>();
const candidateScores = new Map<HTMLElement, { depth: number; scrollRange: number }>();
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;
let depth = 0;
while (currentElement) {
if (
!seenElements.has(currentElement) &&
isScrollableContainer(currentElement)
) {
return currentElement;
if (isScrollableContainer(currentElement)) {
const scrollRange = currentElement.scrollHeight - currentElement.clientHeight;
const existingScore = candidateScores.get(currentElement);
if (!existingScore || depth < existingScore.depth) {
candidateScores.set(currentElement, {
depth,
scrollRange
});
}
}
seenElements.add(currentElement);
depth += 1;
currentElement = currentElement.parentElement;
}
}
return null;
const rankedCandidates = Array.from(candidateScores.entries()).sort((left, right) => {
const [, leftScore] = left;
const [, rightScore] = right;
if (rightScore.scrollRange !== leftScore.scrollRange) {
return rightScore.scrollRange - leftScore.scrollRange;
}
return leftScore.depth - rightScore.depth;
});
return rankedCandidates[0]?.[0] ?? null;
}
function isScrollableContainer(element: HTMLElement): boolean {
@ -529,8 +574,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
let previousFingerprint = "";
let stablePassCount = 0;
let fingerprintStableSince = 0;
for (let attempt = 0; attempt < 9; attempt += 1) {
for (let attempt = 0; attempt < 16; attempt += 1) {
await waitForDomSettled();
if (attempt > 0) {
await new Promise<void>((resolve) => {
@ -555,21 +601,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} else {
previousFingerprint = hydrationSnapshot.fingerprint;
stablePassCount = 1;
fingerprintStableSince = options.window.Date.now();
}
if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) {
const stableForMs = options.window.Date.now() - fingerprintStableSince;
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount === 0 &&
stablePassCount >= 2
) {
return;
}
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount > 0 &&
stablePassCount >= 2 &&
stableForMs >= 500
) {
return;
}
}
}
function readVisibleRowHydrationSnapshot(): {
blankExportFieldCount: number;
fingerprint: string;
missingDefaultFieldCount: number;
} {
const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) {
return {
blankExportFieldCount: 0,
fingerprint: "",
missingDefaultFieldCount: 0
};
@ -580,6 +643,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0
).length;
const blankExportFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value !== "string" || value.trim().length === 0
).length;
const hasAuthorField = hasTextValue(rowSnapshot.exportFields?.["达人信息"]);
const hasRepresentativeVideo = hasTextValue(
rowSnapshot.exportFields?.["代表视频"]
);
@ -587,11 +654,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
hasTextValue(rowSnapshot.price21To60s) ||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
const missingDefaultFieldCount =
Number(!hasRepresentativeVideo) + Number(!hasPriceField);
Number(!hasAuthorField) +
Number(!hasRepresentativeVideo) +
Number(!hasPriceField);
return [
rowSnapshot.authorId,
populatedFieldCount,
`blank:${blankExportFieldCount}`,
hasAuthorField ? "author" : "no-author",
hasRepresentativeVideo ? "video" : "no-video",
hasPriceField ? "price" : "no-price",
`missing:${missingDefaultFieldCount}`
@ -599,6 +670,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
});
return {
blankExportFieldCount: parts.reduce((count, part) => {
const match = part.match(/:blank:(\d+):/);
return count + Number(match?.[1] ?? 0);
}, 0),
fingerprint: parts.join("|"),
missingDefaultFieldCount: parts.reduce((count, part) => {
const match = part.match(/missing:(\d+)$/);
@ -608,6 +683,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
function scheduleSync(): void {
if (isDisposed) {
return;
}
if (isSyncRunning) {
needsResync = true;
return;
@ -618,13 +697,45 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
isSyncScheduled = true;
options.window.setTimeout(() => {
scheduledSyncTimeoutId = options.window.setTimeout(() => {
scheduledSyncTimeoutId = null;
isSyncScheduled = false;
if (isDisposed) {
return;
}
void runSyncCycle();
}, 0);
}
function runWithoutMutationSync(callback: () => void): void {
if (isDisposed) {
return;
}
observer.disconnect();
try {
callback();
} finally {
startObserving();
}
}
function startObserving(): void {
if (isDisposed || !observationRoot) {
return;
}
observer.observe(observationRoot, {
childList: true,
subtree: true
});
}
async function runSyncCycle(): Promise<void> {
if (isDisposed) {
return;
}
if (isSyncRunning) {
needsResync = true;
return;
@ -632,10 +743,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
isSyncRunning = true;
try {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
await hydrateCurrentPage();
applyCurrentView();
lastKnownPageSignature = readMarketPageSignature(options.document);
} finally {
isSyncRunning = false;
if (isDisposed) {
return;
}
if (needsResync) {
needsResync = false;
scheduleSync();
@ -658,7 +774,19 @@ function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
return table.rows
.map((rowDom) => readRowSnapshot(rowDom))
.filter((row): row is MarketRowSnapshot => Boolean(row.authorId));
.filter(
(row): row is MarketRowSnapshot =>
Boolean(row.authorId) && hasTextValue(row.authorName)
);
}
function countCurrentPageRows(document: Document): number {
const table = syncMarketTable(document);
if (!table) {
return 0;
}
return table.rows.filter((rowDom) => Boolean(rowDom.authorId)).length;
}
function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
@ -667,32 +795,31 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
authorName: rowDom.authorName,
exportFields: rowDom.exportFields,
hasDirectRatesSource: rowDom.hasDirectRatesSource,
location: rowDom.location,
price21To60s: rowDom.price21To60s,
rates: rowDom.rates
};
}
function parseNumberValue(value: string): number | undefined {
if (!value) {
return undefined;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
}
function readSortState(
fieldSelect: HTMLSelectElement,
directionSelect: HTMLSelectElement
function getNextSortState(
currentSort: MarketSortState | undefined,
field: MarketSortState["field"]
): MarketSortState | undefined {
if (!fieldSelect.value) {
return undefined;
if (!currentSort || currentSort.field !== field) {
return {
direction: "desc",
field
};
}
if (currentSort.direction === "desc") {
return {
direction: directionSelect.value === "asc" ? "asc" : "desc",
field: fieldSelect.value as MarketSortState["field"]
direction: "asc",
field
};
}
return undefined;
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
@ -805,6 +932,46 @@ function mergeStringValue(
return current;
}
function withExportFieldFallbacks(
exportFields: Record<string, string | undefined> | undefined,
fallbackValues: {
authorName: string;
location: string | undefined;
price21To60s: string | undefined;
}
): Record<string, string | undefined> | undefined {
if (!exportFields) {
return undefined;
}
const nextExportFields = {
...exportFields
};
if (
"达人信息" in nextExportFields &&
!hasTextValue(nextExportFields["达人信息"]) &&
hasTextValue(fallbackValues.authorName)
) {
nextExportFields["达人信息"] = fallbackValues.authorName;
}
if (
"地区" in nextExportFields &&
!hasTextValue(nextExportFields["地区"]) &&
hasTextValue(fallbackValues.location)
) {
nextExportFields["地区"] = fallbackValues.location;
}
if (
"21-60s报价" in nextExportFields &&
!hasTextValue(nextExportFields["21-60s报价"]) &&
hasTextValue(fallbackValues.price21To60s)
) {
nextExportFields["21-60s报价"] = fallbackValues.price21To60s;
}
return nextExportFields;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}

View File

@ -1,8 +1,9 @@
import type { MarketExportScope, MarketExportTarget } from "./types";
import type {
MarketExportScope,
MarketExportTarget
} from "./types";
export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void;
onSubmitBatch(): Promise<void> | void;
}
@ -13,13 +14,15 @@ export interface PluginToolbarDom {
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement;
filterApplyButton: HTMLButtonElement;
personalFilterInput: HTMLInputElement;
root: HTMLElement;
singleFilterInput: HTMLInputElement;
sortApplyButton: HTMLButtonElement;
sortDirectionSelect: HTMLSelectElement;
sortFieldSelect: HTMLSelectElement;
}
export function isPluginToolbarMounted(
root: HTMLElement,
document: Document
): boolean {
const actionRow = findNativeActionRow(document);
return Boolean(actionRow && root.parentElement === actionRow && !root.hidden);
}
export function ensurePluginToolbar(
@ -30,52 +33,13 @@ export function ensurePluginToolbar(
"[data-plugin-toolbar='root']"
) as HTMLElement | null;
if (existingRoot) {
ensureToolbarMounted(existingRoot, document);
return readToolbarDom(existingRoot);
}
const root = document.createElement("section");
root.dataset.pluginToolbar = "root";
const singleFilterInput = document.createElement("input");
singleFilterInput.type = "number";
singleFilterInput.step = "0.01";
singleFilterInput.dataset.pluginFilterSingle = "input";
const personalFilterInput = document.createElement("input");
personalFilterInput.type = "number";
personalFilterInput.step = "0.01";
personalFilterInput.dataset.pluginFilterPersonal = "input";
const filterApplyButton = document.createElement("button");
filterApplyButton.type = "button";
filterApplyButton.dataset.pluginFilterApply = "button";
filterApplyButton.textContent = "应用筛选";
const sortFieldSelect = document.createElement("select");
sortFieldSelect.dataset.pluginSortField = "select";
appendOption(sortFieldSelect, "", "不排序");
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
const sortDirectionSelect = document.createElement("select");
sortDirectionSelect.dataset.pluginSortDirection = "select";
appendOption(sortDirectionSelect, "desc", "降序");
appendOption(sortDirectionSelect, "asc", "升序");
const sortApplyButton = document.createElement("button");
sortApplyButton.type = "button";
sortApplyButton.dataset.pluginSortApply = "button";
sortApplyButton.textContent = "应用排序";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
applyToolbarRootStyles(root);
const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select";
@ -91,32 +55,40 @@ export function ensurePluginToolbar(
exportCustomPagesInput.min = "1";
exportCustomPagesInput.step = "1";
exportCustomPagesInput.hidden = true;
exportCustomPagesInput.placeholder = "页数";
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
const batchSubmitButton = document.createElement("button");
batchSubmitButton.type = "button";
batchSubmitButton.dataset.pluginBatchSubmit = "button";
batchSubmitButton.textContent = "提交批次";
const exportStatusText = document.createElement("span");
exportStatusText.dataset.pluginExportStatus = "text";
applyStatusStyles(exportStatusText);
root.append(
singleFilterInput,
personalFilterInput,
filterApplyButton,
sortFieldSelect,
sortDirectionSelect,
sortApplyButton,
exportRangeSelect,
exportCustomPagesInput,
exportButton,
batchSubmitButton
batchSubmitButton,
exportStatusText
);
root.append(exportStatusText);
document.body.prepend(root);
filterApplyButton.addEventListener("click", () => {
void handlers.onApplyFilter();
});
sortApplyButton.addEventListener("click", () => {
void handlers.onApplySort();
document.body.appendChild(root);
applyNativeControlStyles(document, {
batchSubmitButton,
exportButton,
exportCustomPagesInput,
exportRangeSelect
});
ensureToolbarMounted(root, document);
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
@ -130,13 +102,7 @@ export function ensurePluginToolbar(
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
root
});
});
@ -146,13 +112,7 @@ export function ensurePluginToolbar(
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
root
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
@ -187,25 +147,7 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement,
filterApplyButton: root.querySelector(
'[data-plugin-filter-apply="button"]'
) as HTMLButtonElement,
personalFilterInput: root.querySelector(
'[data-plugin-filter-personal="input"]'
) as HTMLInputElement,
root,
singleFilterInput: root.querySelector(
'[data-plugin-filter-single="input"]'
) as HTMLInputElement,
sortApplyButton: root.querySelector(
'[data-plugin-sort-apply="button"]'
) as HTMLButtonElement,
sortDirectionSelect: root.querySelector(
'[data-plugin-sort-direction="select"]'
) as HTMLSelectElement,
sortFieldSelect: root.querySelector(
'[data-plugin-sort-field="select"]'
) as HTMLSelectElement
root
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom;
@ -273,12 +215,6 @@ export function setToolbarBusyState(
[
toolbar.batchSubmitButton,
toolbar.exportButton,
toolbar.filterApplyButton,
toolbar.sortApplyButton,
toolbar.singleFilterInput,
toolbar.personalFilterInput,
toolbar.sortFieldSelect,
toolbar.sortDirectionSelect,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput
].forEach((element) => {
@ -297,3 +233,279 @@ function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden =
toolbar.exportRangeSelect.value !== "custom";
}
function ensureToolbarMounted(root: HTMLElement, document: Document): void {
const actionRow = findNativeActionRow(document);
if (!actionRow) {
root.hidden = true;
return;
}
const customizeButton = findNativeActionButton(actionRow, "自定义指标");
const insertionAnchor = customizeButton
? findDirectChildAnchor(actionRow, customizeButton)
: null;
if (insertionAnchor) {
actionRow.insertBefore(root, insertionAnchor);
} else if (root.parentElement !== actionRow) {
actionRow.prepend(root);
}
root.hidden = false;
}
function findNativeActionRow(document: Document): HTMLElement | null {
const customizeButton = findNativeActionButton(document, "自定义指标");
const exportButton = findNativeActionButton(document, "导出");
const header = findHeaderContainer(customizeButton, exportButton);
const sharedActionRow =
customizeButton && exportButton
? findSmallestSharedActionRow(customizeButton, exportButton, header)
: null;
if (sharedActionRow) {
return sharedActionRow;
}
const scope = header ?? document;
const candidates = Array.from(
scope.querySelectorAll(".xt-space.xt-space--medium, .search-content--header")
).filter((element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
const rankedCandidates = candidates
.filter((candidate) =>
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
)
.sort((left, right) => {
const depthDelta = getDepthWithinAncestor(right, header) - getDepthWithinAncestor(left, header);
if (depthDelta !== 0) {
return depthDelta;
}
return normalizeText(left.textContent).length - normalizeText(right.textContent).length;
});
return rankedCandidates[0] ?? null;
}
function findHeaderContainer(
customizeButton: HTMLElement | null,
exportButton: HTMLElement | null
): HTMLElement | null {
return (
(customizeButton?.closest(".search-content--header") as HTMLElement | null) ??
(exportButton?.closest(".search-content--header") as HTMLElement | null)
);
}
function findSmallestSharedActionRow(
customizeButton: HTMLElement,
exportButton: HTMLElement,
boundary: HTMLElement | null
): HTMLElement | null {
const exportAncestors = new Set(collectAncestorChain(exportButton, boundary));
for (const candidate of collectAncestorChain(customizeButton, boundary)) {
if (
exportAncestors.has(candidate) &&
isNativeActionRowCandidate(candidate, customizeButton, exportButton)
) {
return candidate;
}
}
return null;
}
function collectAncestorChain(
element: HTMLElement,
boundary: HTMLElement | null
): HTMLElement[] {
const ancestors: HTMLElement[] = [];
let current: HTMLElement | null = element.parentElement;
while (current) {
ancestors.push(current);
if (current === boundary) {
break;
}
current = current.parentElement;
}
return ancestors;
}
function isNativeActionRowCandidate(
candidate: HTMLElement,
customizeButton: HTMLElement | null,
exportButton: HTMLElement | null
): boolean {
if (customizeButton && !candidate.contains(customizeButton)) {
return false;
}
if (exportButton && !candidate.contains(exportButton)) {
return false;
}
const directChildLabels = Array.from(candidate.children)
.flatMap((child) => {
const buttons: Element[] = [];
if (child instanceof candidate.ownerDocument.defaultView!.HTMLButtonElement) {
buttons.push(child);
}
buttons.push(...Array.from(child.querySelectorAll("button")));
return buttons;
})
.map((button) => normalizeText(button.textContent));
return (
directChildLabels.includes("导出") &&
(directChildLabels.includes("自定义指标") || Boolean(customizeButton))
);
}
function getDepthWithinAncestor(
element: HTMLElement,
boundary: HTMLElement | null
): number {
let depth = 0;
let current: HTMLElement | null = element.parentElement;
while (current && current !== boundary) {
depth += 1;
current = current.parentElement;
}
return depth;
}
function findNativeActionButton(
root: ParentNode,
text: string
): HTMLElement | null {
const document = root instanceof Document ? root : root.ownerDocument;
if (!document) {
return null;
}
const candidates = Array.from(root.querySelectorAll("button")).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
return (
candidates.find((element) => normalizeText(element.textContent) === text) ?? null
);
}
function applyToolbarRootStyles(root: HTMLElement): void {
root.style.display = "inline-flex";
root.style.alignItems = "center";
root.style.columnGap = "8px";
root.style.flexWrap = "wrap";
}
function applyNativeControlStyles(
document: Document,
controls: {
batchSubmitButton: HTMLButtonElement;
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
}
): void {
const primaryButton =
findButtonContainingText(document, "发布任务") ??
findButtonContainingText(document, "+发布任务");
const nativeButton =
primaryButton ??
findNativeActionButton(document, "自定义指标") ??
findNativeActionButton(document, "导出");
if (nativeButton) {
controls.exportButton.className = nativeButton.className;
controls.batchSubmitButton.className = nativeButton.className;
}
[controls.exportButton, controls.batchSubmitButton].forEach((button) => {
applyPrimaryButtonStyles(button, Boolean(primaryButton));
button.style.whiteSpace = "nowrap";
});
[controls.exportRangeSelect, controls.exportCustomPagesInput].forEach((element) => {
element.style.height = "32px";
element.style.border = "1px solid #d0d7de";
element.style.borderRadius = "6px";
element.style.padding = "0 10px";
element.style.background = "#fff";
element.style.color = "#1f2329";
element.style.boxSizing = "border-box";
});
controls.exportRangeSelect.style.minWidth = "104px";
controls.exportCustomPagesInput.style.width = "72px";
}
function applyPrimaryButtonStyles(
button: HTMLButtonElement,
isUsingNativePrimaryButtonClass: boolean
): void {
if (!isUsingNativePrimaryButtonClass) {
button.style.backgroundColor = "#fe346e";
button.style.border = "1px solid #fe346e";
button.style.borderRadius = "8px";
button.style.color = "#ffffff";
button.style.height = "32px";
button.style.padding = "0 15px";
} else {
button.style.color = "#ffffff";
}
button.style.boxSizing = "border-box";
}
function applyStatusStyles(statusText: HTMLElement): void {
statusText.style.color = "#64748b";
statusText.style.fontSize = "12px";
statusText.style.lineHeight = "20px";
statusText.style.marginLeft = "4px";
statusText.style.whiteSpace = "nowrap";
}
function normalizeText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function findButtonContainingText(
root: ParentNode,
text: string
): HTMLElement | null {
const document = root instanceof Document ? root : root.ownerDocument;
if (!document) {
return null;
}
const candidates = Array.from(root.querySelectorAll("button")).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
return candidates.find((element) => normalizeText(element.textContent).includes(text)) ?? null;
}
function findDirectChildAnchor(
ancestor: HTMLElement,
descendant: HTMLElement
): HTMLElement | null {
let current: HTMLElement | null = descendant;
let previous: HTMLElement | null = null;
while (current && current !== ancestor) {
previous = current;
current = current.parentElement;
}
return current === ancestor ? previous : null;
}

View File

@ -12,6 +12,10 @@ export interface BackendMetrics {
newA3Rate?: string;
}
export type MarketSortField =
| keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>;
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
export interface MarketRowSnapshot {
@ -49,7 +53,7 @@ export type MarketExportTarget =
export interface MarketSortState {
direction: "asc" | "desc";
field: keyof Required<AfterSearchRates>;
field: MarketSortField;
}
export type MarketApiFailureReason =
@ -60,7 +64,7 @@ export type MarketApiFailureReason =
export type MarketApiSuccessResult = {
success: true;
rates: Required<AfterSearchRates>;
rates: AfterSearchRates;
};
export type MarketApiFailureResult = {

View File

@ -76,11 +76,13 @@ export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetric
return [
{
a3IncreaseCount: formatDecimalValue(row.avg_a3_increase_cnt),
a3IncreaseCount: formatDecimalValue(
readAverageA3IncreaseCount(row)
),
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
cpSearch: formatDecimalValue(row.cp_search),
cpa3: formatDecimalValue(row.cpa3),
cpa3: formatDecimalValue(readCpa3Value(row)),
newA3Rate: formatRateValue(row.avg_new_a3_rate),
starId: row.star_id
}
@ -88,6 +90,84 @@ export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetric
});
}
function readAverageA3IncreaseCount(row: Record<string, unknown>): number | null {
const directAverage = readFiniteNumber(row.avg_a3_increase_cnt);
if (directAverage !== null) {
return directAverage;
}
const totalNewA3 = readTotalNewA3Value(row);
const videoCount =
readFiniteNumber(row.video_count) ?? readNestedVideoCount(row.videos);
if (totalNewA3 === null || videoCount === null || videoCount <= 0) {
return null;
}
return totalNewA3 / videoCount;
}
function readCpa3Value(row: Record<string, unknown>): number | null {
const directCpa3 = readFiniteNumber(row.cpa3);
if (directCpa3 !== null) {
return directCpa3;
}
const totalCost = readFiniteNumber(row.total_estimated_video_cost);
const totalNewA3 = readTotalNewA3Value(row);
if (totalCost === null || totalNewA3 === null || totalNewA3 <= 0) {
return null;
}
return totalCost / totalNewA3;
}
function readTotalNewA3Value(row: Record<string, unknown>): number | null {
const derivedFromTotals = deriveTotalNewA3FromTotals(row);
if (derivedFromTotals !== null) {
return derivedFromTotals;
}
return deriveTotalNewA3FromVideos(row.videos);
}
function deriveTotalNewA3FromTotals(row: Record<string, unknown>): number | null {
const totalPlayCount = readFiniteNumber(row.total_play_cnt);
const averageNewA3Rate = readFiniteNumber(row.avg_new_a3_rate);
if (totalPlayCount === null || averageNewA3Rate === null) {
return null;
}
return totalPlayCount * averageNewA3Rate;
}
function deriveTotalNewA3FromVideos(value: unknown): number | null {
if (!Array.isArray(value)) {
return null;
}
let total = 0;
let hasFiniteValue = false;
value.forEach((video) => {
if (!isRecord(video)) {
return;
}
const newA3 = readFiniteNumber(video.new_a3);
if (newA3 === null) {
return;
}
hasFiniteValue = true;
total += newA3;
});
return hasFiniteValue ? total : null;
}
function readNestedVideoCount(value: unknown): number | null {
return Array.isArray(value) ? value.length : null;
}
function readResponseRows(payload: unknown): unknown[] | null {
if (!isRecord(payload) || payload.success !== true) {
return null;
@ -130,3 +210,8 @@ async function defaultFetch(input: string, init?: RequestInit) {
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readFiniteNumber(value: unknown): number | null {
const number = typeof value === "number" ? value : Number(value);
return Number.isFinite(number) ? number : null;
}

View File

@ -1 +1,2 @@
export const DEFAULT_BACKEND_METRICS_BASE_URL = "http://192.168.31.29:8083";
export const DEFAULT_BACKEND_METRICS_BASE_URL =
"https://talent-search.intelligrow.cn";

View File

@ -1,5 +1,6 @@
import type { BatchPayload } from "../content/market/batch-payload";
import { isAuthResponseMessage } from "./auth-messages";
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "./batch-submit-config";
interface FetchResponseLike {
json(): Promise<unknown>;
@ -16,11 +17,12 @@ type GetAccessTokenLike = () => Promise<string>;
type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createBatchSubmitClient(options: {
baseUrl: string;
baseUrl?: string;
fetchImpl?: FetchLike;
getAccessToken?: GetAccessTokenLike;
sendMessage: SendMessageLike;
}) {
const baseUrl = options.baseUrl ?? DEFAULT_BATCH_SUBMIT_BASE_URL;
const fetchImpl = options.fetchImpl ?? fetch;
const getAccessToken =
options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
@ -29,7 +31,7 @@ export function createBatchSubmitClient(options: {
async submitBatch(payload: BatchPayload) {
const token = await getAccessToken();
const response = await fetchImpl(
new URL("/api/mock/batches", options.baseUrl).toString(),
buildBatchSubmitUrl(baseUrl),
{
body: JSON.stringify(payload),
headers: {
@ -48,11 +50,15 @@ export function createBatchSubmitClient(options: {
throw new Error(`batch submit failed: ${response.status}`);
}
return response.json();
return readBatchSubmitResponse(await response.json());
}
};
}
export function buildBatchSubmitUrl(baseUrl: string): string {
return new URL("/api/v1/batch-status/batches", baseUrl).toString();
}
async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
const response = await sendMessage({ type: "auth:get-access-token" });
@ -67,3 +73,23 @@ async function readAccessToken(sendMessage: SendMessageLike): Promise<string> {
return response.value.accessToken;
}
function readBatchSubmitResponse(payload: unknown): unknown {
if (!isRecord(payload)) {
throw new Error("batch submit response is invalid");
}
if (payload.success !== true) {
const message =
typeof payload.msg === "string" && payload.msg.trim()
? payload.msg
: "batch submit failed";
throw new Error(message);
}
return "data" in payload ? payload.data : payload;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@ -0,0 +1 @@
export const DEFAULT_BATCH_SUBMIT_BASE_URL = "http://192.168.31.29:8083";

View File

@ -10,12 +10,14 @@ import {
describe("backend-metrics-client", () => {
test("exports the default backend metrics base url", () => {
expect(DEFAULT_BACKEND_METRICS_BASE_URL).toBe("http://192.168.31.29:8083");
expect(DEFAULT_BACKEND_METRICS_BASE_URL).toBe(
"https://talent-search.intelligrow.cn"
);
});
test("builds the backend search url", () => {
expect(buildBackendMetricsSearchUrl("http://192.168.31.29:8083")).toBe(
"http://192.168.31.29:8083/api/v1/history/talents/search"
expect(buildBackendMetricsSearchUrl("https://talent-search.intelligrow.cn")).toBe(
"https://talent-search.intelligrow.cn/api/v1/history/talents/search"
);
});
@ -61,6 +63,40 @@ describe("backend-metrics-client", () => {
]);
});
test("derives A3 count and CPA3 from the live aggregate response shape", () => {
expect(
mapBackendMetricsSearchResponse({
data: {
data: [
{
avg_after_view_search_cnt: 25982,
avg_after_view_search_rate: 0.0010872130261527625,
avg_new_a3_rate: 0.11075860229946684,
cp_search: 21.168501270110077,
cpe: 0.630604497471276,
cpm: 23.014670324994974,
star_id: "7021245050621263906",
total_estimated_video_cost: 1100000,
total_play_cnt: 47795601,
video_count: 2
}
]
},
success: true
})
).toEqual([
{
a3IncreaseCount: "2,646,886.98",
afterViewSearchCount: "25,982.00",
afterViewSearchRate: "0.11%",
cpSearch: "21.17",
cpa3: "0.21",
newA3Rate: "11.08%",
starId: "7021245050621263906"
}
]);
});
test("posts star ids with bearer auth when searching backend metrics", async () => {
const fetchImpl = async (_input: string, init?: RequestInit) => ({
json: async () => ({
@ -71,7 +107,7 @@ describe("backend-metrics-client", () => {
}),
ok: true,
status: 200,
url: "http://192.168.31.29:8083/api/v1/history/talents/search"
url: "https://talent-search.intelligrow.cn/api/v1/history/talents/search"
});
const fetchSpy = vi.fn(fetchImpl);
const client = createBackendMetricsClient({
@ -82,7 +118,7 @@ describe("backend-metrics-client", () => {
await client.searchByStarIds(["111", "222"]);
expect(fetchSpy).toHaveBeenCalledWith(
"http://192.168.31.29:8083/api/v1/history/talents/search",
"https://talent-search.intelligrow.cn/api/v1/history/talents/search",
expect.objectContaining({
body: JSON.stringify({
page: 1,

View File

@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
import { createBatchPayload } from "../src/content/market/batch-payload";
describe("batch-payload", () => {
test("builds a batch id from the batch name and timestamp", () => {
test("builds a batch id from the user id and timestamp", () => {
const payload = createBatchPayload({
authState: {
isAuthenticated: true,
@ -26,7 +26,7 @@ describe("batch-payload", () => {
{ authorId: "111", authorName: "达人A" },
{ authorId: "222", authorName: "达人B" }
],
batchId: "618达人筛选第一批-2026-04-22T12:30:00.000Z",
batchId: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z",
batchName: "618达人筛选第一批",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",

View File

@ -1,8 +1,13 @@
import { describe, expect, test, vi } from "vitest";
import { DEFAULT_BATCH_SUBMIT_BASE_URL } from "../src/shared/batch-submit-config";
import { createBatchSubmitClient } from "../src/shared/batch-submit-client";
describe("batch-submit-client", () => {
test("exports the default batch submit base url", () => {
expect(DEFAULT_BATCH_SUBMIT_BASE_URL).toBe("http://192.168.31.29:8083");
});
test("posts the batch payload with a Bearer token", async () => {
const sendMessage = vi.fn(async () => ({
ok: true,
@ -12,7 +17,15 @@ describe("batch-submit-client", () => {
const fetchImpl = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ acceptedCount: 2, ok: true })
json: async () => ({
data: {
batch_id: "p7pdhhtde8kj-2026-04-22T12:30:00.000Z",
status: true,
talent_count: 1
},
msg: "",
success: true
})
}));
const client = createBatchSubmitClient({
@ -32,7 +45,7 @@ describe("batch-submit-client", () => {
});
expect(fetchImpl).toHaveBeenCalledWith(
"http://127.0.0.1:4319/api/mock/batches",
"http://127.0.0.1:4319/api/v1/batch-status/batches",
expect.objectContaining({
body: JSON.stringify({
authors: [{ authorId: "111", authorName: "达人A" }],
@ -52,6 +65,38 @@ describe("batch-submit-client", () => {
);
});
test("throws when the batch submit api returns success false", async () => {
const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
fetchImpl: vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
data: null,
msg: "duplicate batch id",
success: false
})
})),
sendMessage: vi.fn(async () => ({
ok: true,
type: "auth:token",
value: { accessToken: "abc123" }
}))
});
await expect(
client.submitBatch({
authors: [],
batchId: "批次A-2026-04-22T12:30:00.000Z",
batchName: "批次A",
createdAt: "2026-04-22T12:30:00.000Z",
creatorName: "王少卿",
logtoUserId: "p7pdhhtde8kj",
resource: "https://talent-search.intelligrow.cn"
})
).rejects.toThrow(/duplicate batch id/i);
});
test("throws on unauthorized responses", async () => {
const client = createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",

View File

@ -15,7 +15,13 @@ describe("csv-exporter", () => {
"地区",
"21-60s报价",
"单视频看后搜率",
"个人视频看后搜率"
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",")
);
});
@ -27,10 +33,19 @@ describe("csv-exporter", () => {
authorName: "Alice",
exportFields: {
: "Alice",
: "示例视频",
: "100w",
"21-60s报价": "¥450,000"
},
status: "success",
backendMetrics: {
a3IncreaseCount: "78,366.22",
afterViewSearchCount: "9,689.96",
afterViewSearchRate: "0.36%",
cpSearch: "14.46",
cpa3: "1.79",
newA3Rate: "3.44%"
},
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "1% - 3%"
@ -40,11 +55,56 @@ describe("csv-exporter", () => {
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join(
","
)
[
"达人信息",
"粉丝数",
"21-60s报价",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",")
);
expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%');
expect(rowLine).toBe(
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
);
});
test("omits the representative video column from exported page fields", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
exportFields: {
: "Alice",
: "示例视频",
: "100w"
},
status: "success"
} satisfies MarketRecord
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
[
"达人信息",
"粉丝数",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",")
);
expect(rowLine).toBe("Alice,100w,,,,,,,,");
});
test("escapes commas and quotes", () => {
@ -77,7 +137,7 @@ describe("csv-exporter", () => {
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,,");
expect(rowLine).toBe("123,Alice,,,,,,,,,,");
});
test("uses normalized display values in export rows", () => {
@ -97,4 +157,21 @@ describe("csv-exporter", () => {
expect(rowLine).toContain("0.5% - 1%");
expect(rowLine).toContain("0.02% - 0.1%");
});
test("emits empty backend metric cells when backend metrics are absent", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
} satisfies MarketRecord
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
});
});

View File

@ -93,4 +93,95 @@ describe("filter-sort-controller", () => {
expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]);
});
test("sorts by backend metric descending and keeps empty values at the end", () => {
const result = applyFilterAndSort(
[
{
...baseRecords[0],
backendMetrics: {
afterViewSearchRate: "0.36%"
}
},
{
...baseRecords[1],
backendMetrics: {
afterViewSearchRate: "1.4%"
}
},
baseRecords[2]
],
{
sort: {
direction: "desc",
field: "afterViewSearchRate"
}
}
);
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]);
});
test("keeps equal rate buckets in a deterministic order across repeated sorts", () => {
const records: MarketRecord[] = [
{
authorId: "b",
authorName: "Beta",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5% - 1%"
}
},
{
authorId: "a",
authorName: "Alpha",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5% - 1%"
}
},
{
authorId: "d",
authorName: "Delta",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.25% - 0.5%"
}
},
{
authorId: "c",
authorName: "Gamma",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.25% - 0.5%"
}
}
];
const firstResult = applyFilterAndSort(records, {
sort: {
direction: "desc",
field: "singleVideoAfterSearchRate"
}
});
const secondResult = applyFilterAndSort([...records].reverse(), {
sort: {
direction: "desc",
field: "singleVideoAfterSearchRate"
}
});
expect(firstResult.map((record) => record.authorId)).toEqual([
"a",
"b",
"c",
"d"
]);
expect(secondResult.map((record) => record.authorId)).toEqual([
"a",
"b",
"c",
"d"
]);
});
});

View File

@ -33,12 +33,10 @@ describe("market-api-client", () => {
});
});
test("returns a missing-rate failure when the payload omits a required field", () => {
test("returns a missing-rate failure when the payload omits both rate fields", () => {
expect(
mapAuthorAseInfoResponse({
data: {
avg_search_after_view_rate: "<0.02%"
}
data: {}
})
).toMatchObject({
success: false,
@ -46,6 +44,21 @@ describe("market-api-client", () => {
});
});
test("maps a partially populated payload into partial rates", () => {
expect(
mapAuthorAseInfoResponse({
data: {
personal_avg_search_after_view_rate: "0.02 - 0.1%"
}
})
).toMatchObject({
success: true,
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
});
});
test("returns a request-failed result for non-ok responses", async () => {
const client = createMarketApiClient({
fetchImpl: async () => ({

File diff suppressed because it is too large Load Diff

View File

@ -48,9 +48,22 @@ describe("market-dom-sync", () => {
)
).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="backendMetrics"]')
document.querySelector('[data-market-header-cell="afterViewSearchRate"]')
).not.toBeNull();
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(6);
expect(
document.querySelector('[data-market-header-cell="afterViewSearchCount"]')
).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="a3IncreaseCount"]')
).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="newA3Rate"]')
).not.toBeNull();
expect(document.querySelector('[data-market-header-cell="cpa3"]')).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="cpSearch"]')
).not.toBeNull();
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(16);
});
test("renders loading, success, missing, and failed states", () => {
@ -87,12 +100,15 @@ describe("market-dom-sync", () => {
});
expect(alphaRow.singleCell.textContent).toBe("加载中...");
expect(alphaRow.backendMetricsCell.textContent).toBe("加载中...");
expect(alphaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载中...");
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
expect(betaRow.backendMetricsCell.textContent).toContain("看后搜率");
expect(betaRow.backendMetricsCell.textContent).toContain("0.36%");
expect(betaRow.backendMetricsCell.textContent).toContain("CPA3");
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("0.36%");
expect(betaRow.backendMetricsCells.afterViewSearchCount.textContent).toBe("9,689.96");
expect(betaRow.backendMetricsCells.a3IncreaseCount.textContent).toBe("78,366.22");
expect(betaRow.backendMetricsCells.newA3Rate.textContent).toBe("3.44%");
expect(betaRow.backendMetricsCells.cpa3.textContent).toBe("1.79");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("14.46");
renderMarketRowState(betaRow, {
authorId: "b",
@ -104,7 +120,8 @@ describe("market-dom-sync", () => {
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
});
expect(betaRow.backendMetricsCell.textContent).toBe("暂无数据");
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("暂无数据");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("暂无数据");
renderMarketRowState(betaRow, {
authorId: "b",
@ -114,7 +131,8 @@ describe("market-dom-sync", () => {
});
expect(betaRow.singleCell.textContent).toBe("加载失败");
expect(betaRow.personalCell.textContent).toBe("加载失败");
expect(betaRow.backendMetricsCell.textContent).toBe("加载失败");
expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载失败");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("加载失败");
});
test("hides rows outside the visible author ids", () => {
@ -152,27 +170,55 @@ describe("market-dom-sync", () => {
throw new Error("Expected market table");
}
expect(readRightHeaderTexts()).toEqual([
"21-60s报价",
expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]);
expect(readPluginHeaderTexts()).toEqual([
"单视频看后搜率",
"个人视频看后搜率",
"秒探指标",
"操作"
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
]);
expect(
document
.querySelector(".section-wrapper.sticky-header")
?.classList.contains("hide-scrollbar")
).toBe(false);
expect(
document.querySelector(".section-wrapper:not(.sticky-header)")?.classList.contains(
"hide-scrollbar"
)
).toBe(false);
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
expect(
(
document.querySelector('[data-testid="plugin-header"]') as HTMLElement
).style.position
).not.toBe("sticky");
const pluginHeaderCells = Array.from(
document.querySelectorAll('[data-testid="plugin-header"] > .header-cell')
) as HTMLElement[];
expect(pluginHeaderCells[0]?.style.width).toBe("160px");
expect(pluginHeaderCells[1]?.style.width).toBe("160px");
expect(pluginHeaderCells[0]?.style.whiteSpace).toBe("nowrap");
expect(pluginHeaderCells[1]?.style.whiteSpace).toBe("nowrap");
expect(
Number.parseFloat(
(
document.querySelector('[data-testid="right-header"]') as HTMLElement
).style.width
)
).toBeGreaterThan(350);
).toBe(350);
expect(
Number.parseFloat(
(
document.querySelector('[data-testid="right-section"]') as HTMLElement
).style.width
)
).toBeGreaterThan(350);
).toBe(350);
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
renderMarketRowState(table.rows[0], {
@ -194,13 +240,21 @@ describe("market-dom-sync", () => {
}
});
expect(readRightRowTexts(0)).toEqual([
"¥450,000",
expect(readRightRowTexts(0)).toEqual(["¥450,000", "下单"]);
expect(readPluginRowTexts(0)).toEqual([
"0.5% - 1%",
"0.02% - 0.1%",
"看后搜率0.36%看后搜数9,689.96新增A3数78,366.22新增A3率3.44%CPA31.79cp_search14.46",
"下单"
"0.36%",
"9,689.96",
"78,366.22",
"3.44%",
"1.79",
"14.46"
]);
expect(table.rows[0].singleCell.style.width).toBe("160px");
expect(table.rows[0].personalCell.style.width).toBe("160px");
expect(table.rows[0].singleCell.style.whiteSpace).toBe("nowrap");
expect(table.rows[0].personalCell.style.whiteSpace).toBe("nowrap");
applyRowVisibility(table, new Set(["222"]));
@ -211,7 +265,8 @@ describe("market-dom-sync", () => {
applyRowOrder(table, ["222", "111"]);
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "", "下单"]);
expect(readRightRowTexts(0)).toEqual(["¥20,000", "下单"]);
expect(readPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]);
expect(table.rows[0].exportFields).toMatchObject({
"21-60s报价": "¥450,000",
"代表视频": "代表视频A",
@ -219,6 +274,94 @@ describe("market-dom-sync", () => {
});
});
test("keeps a single scroll hint across repeated syncs", () => {
document.body.innerHTML = buildRealMarketGridFixture();
expect(syncMarketTable(document)).not.toBeNull();
expect(syncMarketTable(document)).not.toBeNull();
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
});
test("uses native-like alignment styles for plugin cells", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
const pluginHeaderCell = document.querySelector(
'[data-testid="plugin-header"] [data-market-header-cell="singleVideoAfterSearchRate"]'
) as HTMLElement | null;
const pluginBodyCell = table.rows[0].singleCell;
expect(pluginHeaderCell?.style.display).toBe("flex");
expect(pluginHeaderCell?.style.alignItems).toBe("center");
expect(pluginBodyCell.style.display).toBe("flex");
expect(pluginBodyCell.style.alignItems).toBe("center");
expect(pluginBodyCell.style.paddingTop).toBe("12px");
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
});
test("keeps native-like alignment styles after repeated syncs", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
expect(syncMarketTable(document)).not.toBeNull();
const secondTable = syncMarketTable(document);
if (!secondTable) {
throw new Error("Expected market table");
}
const pluginBodyCell = secondTable.rows[0].singleCell;
expect(pluginBodyCell.style.display).toBe("flex");
expect(pluginBodyCell.style.alignItems).toBe("center");
expect(pluginBodyCell.style.paddingTop).toBe("12px");
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
expect(pluginBodyCell.hasAttribute("data-v-cell-scope")).toBe(true);
});
test("keeps export field alignment when a row is missing the price cell", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithMissingPriceCell();
const initialTable = syncMarketTable(document);
if (!initialTable) {
throw new Error("Expected market table");
}
renderMarketRowState(initialTable.rows[1], {
authorId: "222",
authorName: "达人 B",
backendMetrics: {
a3IncreaseCount: "78,366.22",
afterViewSearchCount: "9,689.96",
afterViewSearchRate: "0.36%",
cpSearch: "14.46",
cpa3: "1.79",
newA3Rate: "3.44%"
},
backendMetricsStatus: "success",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
});
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table after rerender");
}
expect(table.rows[1].exportFields).toMatchObject({
"21-60s报价": "",
"代表视频": "代表视频B",
"达人信息": "达人 B"
});
expect(table.rows[1].exportFields?.["21-60s报价"]).not.toContain("看后搜率");
});
test("falls back to the market vue state when the DOM has no author id", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
attachMarketVueState([
@ -244,6 +387,146 @@ describe("market-dom-sync", () => {
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
});
test("fills blank export cells from the market vue state", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
attachMarketVueState([
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.003",
burst_text_rate: "1",
city: "温州",
content_theme_labels_180d: ["有趣剧情创作", "亲情剧集", "情感短剧"],
follower: "4550556",
gender: "2",
interact_rate_within_30d: "0.0572",
link_link_cnt_by_industry: "27029613",
nickname: "达人 A",
play_over_rate_within_30d: "0.263",
price_20_60: "155000",
prospective_20_60_cpm: "21.2362",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "7298854",
star_id: "111"
},
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.003",
burst_text_rate: "0",
city: "杭州",
content_theme_labels_180d: ["搞笑剧情", "大学宿舍趣事", "校园生活"],
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].authorId).toBe("222");
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields).toMatchObject({
"21-60s报价": "¥38,000",
: "7.2%",
: "搞笑剧情 大学宿舍趣事 1+",
: "35%",
: "-",
: "90.1w",
: "达人 B 女 杭州",
: "剧情搞笑",
: "2,077.3w",
CPM: "182.5",
: "20.8w"
});
expect(table.rows[1].rates).toEqual({
singleVideoAfterSearchRate: "0.3%"
});
});
test("finds market rows in nested vue children", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
attachNestedMarketVueState([
{
attribute_datas: {
city: "杭州",
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields).toMatchObject({
: "90.1w",
: "20.8w",
: "7.2%"
});
});
test("prefers vue fallback when the price cell is polluted", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithPollutedSecondPrice();
attachMarketVueState([
{
attribute_datas: {
city: "杭州",
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields?.["21-60s报价"]).toBe("¥38,000");
});
test("falls back to serialized market rows when vue state is unavailable", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
document.documentElement.setAttribute(
@ -270,6 +553,7 @@ describe("market-dom-sync", () => {
expect(table.rows[0].rates).toEqual({
singleVideoAfterSearchRate: "0.02%"
});
expect(readMarketPageSignature(document)).toContain("::111|222");
});
test("finds the real next-page button in Xingtu pagination", () => {
@ -298,6 +582,16 @@ describe("market-dom-sync", () => {
expect(isPageControlDisabled(nextControl)).toBe(false);
expect(readMarketPageSignature(document)).toContain("1::111|222");
});
test("reads market page signature without mutating the page", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const signature = readMarketPageSignature(document);
expect(signature).toContain("::111|222");
expect(document.querySelector('[data-testid="plugin-header"]')).toBeNull();
expect(document.querySelector('[data-testid="plugin-section"]')).toBeNull();
});
});
function buildRealMarketGridFixture() {
@ -385,8 +679,167 @@ function buildRealMarketGridFixtureWithoutAuthorIds() {
`;
}
function buildRealMarketGridFixtureWithMissingPriceCell() {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;"></div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;"></div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;"></div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" data-testid="author-cell-111" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
<div class="content-cell" data-testid="author-cell-222" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222"> B</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
<div class="content-cell" style="height: 120px;">B</div>
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥450,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" data-testid="action-cell-111" style="height: 120px;"></div>
<div class="content-cell" data-testid="action-cell-222" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`;
}
function buildRealMarketGridFixtureWithScopedAttributes() {
return buildRealMarketGridFixture()
.replace(
'<div class="header-cell" style="min-width: 190px;">代表视频</div>',
'<div data-v-header-scope class="header-cell" style="min-width: 190px;">代表视频</div>'
)
.replace(
'<div class="content-cell" style="height: 120px;">代表视频A</div>',
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频A</div>'
)
.replace(
'<div class="content-cell" style="height: 120px;">代表视频B</div>',
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频B</div>'
);
}
function buildRichMarketGridFixtureWithBlankSecondRow() {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;"></div>
</div>
<div class="middle-columns" style="width: 1210px; display: flex;">
<div class="header-cell" style="min-width: 190px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 180px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;">CPM</div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;"></div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
<div class="middle-columns" style="width: 1210px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;"></div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 180px;">
<div class="content-cell" style="height: 120px;"> 1+</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">2,703w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">455.1w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">21.2</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">729.9w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">5.7%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">26.3%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">100%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
<div class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥155,000</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;"></div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`;
}
function buildRichMarketGridFixtureWithPollutedSecondPrice() {
return buildRichMarketGridFixtureWithBlankSecondRow().replace(
'<div class="content-cell" style="height: 120px;"></div>\n </div>\n <div class="content-column" style="min-width: 200px;">',
'<div class="content-cell" style="height: 120px;">看后搜率0.39%看后搜数2,248.33新增A3数0.00新增A3率0%CPA30.00cp_search20.01</div>\n </div>\n <div class="content-column" style="min-width: 200px;">'
);
}
function attachMarketVueState(
marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }>
marketList: Array<Record<string, unknown>>
) {
const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) {
@ -405,6 +858,40 @@ function attachMarketVueState(
});
}
function attachNestedMarketVueState(marketList: Array<Record<string, unknown>>) {
const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) {
throw new Error("Expected market root");
}
Object.defineProperty(marketRoot, "__vue__", {
configurable: true,
value: {
$children: [
{
$children: [
{
_setupState: {},
$children: [
{
_setupState: {
__$temp_1: {
marketList
}
},
$children: []
}
]
}
],
_setupState: {}
}
],
_setupState: {}
}
});
}
function readRightHeaderTexts() {
return Array.from(
document.querySelectorAll('[data-testid="right-header"] > *'),
@ -412,18 +899,39 @@ function readRightHeaderTexts() {
);
}
function readPluginHeaderTexts() {
return Array.from(
document.querySelectorAll('[data-testid="plugin-header"] > *'),
(cell) => cell.textContent?.trim() ?? ""
);
}
function readRightRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
);
}
function readPluginRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
(column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
);
}
function readScrollHintText() {
return (
document.querySelector('[data-testid="market-scroll-hint"]')?.textContent?.trim() ?? ""
);
}
function readAuthorNames() {
return Array.from(
document.querySelectorAll('[data-testid="author-section"] .content-cell a'),
(link) => link.textContent?.trim() ?? ""
const authorColumn = document.querySelector(
'[data-testid="author-section"] .content-column'
);
return readVisualCells(authorColumn).map(
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
);
}
@ -440,3 +948,22 @@ function readRightActionHiddenStates() {
(cell) => (cell as HTMLElement).hidden
);
}
function readVisualCells(root: Element | null): HTMLElement[] {
if (!root) {
return [];
}
return Array.from(root.querySelectorAll(":scope > .content-cell"))
.filter((cell): cell is HTMLElement => cell instanceof HTMLElement)
.sort((left, right) => {
const leftOrder = Number(left.style.order || "0");
const rightOrder = Number(right.style.order || "0");
if (leftOrder !== rightOrder) {
return leftOrder - rightOrder;
}
const cells = Array.from(root.querySelectorAll(":scope > .content-cell"));
return cells.indexOf(left) - cells.indexOf(right);
});
}