feat: add market page after-search-rate columns

This commit is contained in:
wangshaoqing 2026-04-15 15:32:57 +08:00
parent 714745bb36
commit bf6295a4d0
25 changed files with 2107 additions and 192 deletions

View File

@ -1,6 +1,9 @@
# Star Chart Search Enhancer
一个最小化的 Chrome MV3 实验插件,用来在巨量星图的达人详情页拦截页面自己的网络响应,并尝试提取两个“看后搜率”指标。
一个最小化的 Chrome MV3 实验插件,用来增强巨量星图:
- 达人详情页:保留原有的详情页控制台实验链路
- 找达人列表页:在 `creator/market` 当前可见结果页中插入两列看后搜率
## 开发命令
@ -20,14 +23,31 @@ npm run build
## 手工验证
### 详情页控制台实验
1. 打开巨量星图的达人详情页
2. 刷新页面一次,确保内容脚本和页面 hook 都能尽早注入
3. 打开该页面的 DevTools Console
4. 观察是否出现带有 `[star-chart-search-enhancer]` 前缀的日志
5. 找到 `result` 日志,核对其中两个看后搜率是否与达人详情页右侧展示一致
### 找达人列表页列增强
1. 打开 `https://xingtu.cn/ad/creator/market`
2. 等待当前列表页渲染完成
3. 确认 `操作` 列前新增了两列:
`单视频看后搜率`
`个人视频看后搜率`
4. 首次进入或翻页、筛选、搜索、排序变化后,新增列会先显示 `加载中...`
5. 请求成功后,两列会显示对应达人的真实值
6. 如果某行失败,两列都会显示 `加载失败`
7. 点击任一失败单元格,会按整行重试该达人并重新进入 `加载中...`
## 当前范围
- 只支持巨量星图达人详情页
- 只输出到控制台,不改页面 UI
- 成功时输出结构化结果,超时也会输出一个明确失败结果
- 阶段 1 同时支持:
- 巨量星图达人详情页控制台实验
- 巨量星图找达人 `creator/market` 当前可见结果页的两列增强
- 列表页只处理当前可见结果页,不处理全部结果导出
- 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果
- 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态

181
src/content/detail/index.ts Normal file
View File

@ -0,0 +1,181 @@
import { createRouteState } from "../route-state";
import {
isCandidateAnalysisMessage,
isCandidateRequestMessage,
isHookReadyMessage,
isAfterSearchRateResultMessage
} from "../../shared/message-types";
import type { AfterSearchRateResult } from "../../shared/result-types";
const LOG_PREFIX = "[star-chart-search-enhancer]";
const PAGE_HOOK_SCRIPT_ID = "star-chart-search-enhancer-page-hook";
interface ChromeRuntimeLike {
getURL(path: string): string;
}
interface LoggerLike {
debug(...args: unknown[]): void;
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
}
export interface DetailContentControllerOptions {
chromeRuntime: ChromeRuntimeLike;
document: Document;
logger: LoggerLike;
window: Window;
}
export function createDetailContentController(
options: DetailContentControllerOptions
) {
const routeState = createRouteState(options.window.location.href);
let currentSnapshot = routeState.getSnapshot();
const loggedResults = new Map<string, { fingerprint: string; success: boolean }>();
const originalPushState = options.window.history.pushState.bind(
options.window.history
);
const originalReplaceState = options.window.history.replaceState.bind(
options.window.history
);
const onMessage = (event: MessageEvent) => {
if (event.source !== options.window) {
return;
}
if (isHookReadyMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "hook-ready", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-hook-ready",
event.data.payload.routeKey
);
}
return;
}
if (isCandidateRequestMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-request", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-request",
event.data.payload.routeKey
);
}
return;
}
if (isCandidateAnalysisMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-analysis", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-analysis",
event.data.payload.routeKey
);
}
return;
}
if (!isAfterSearchRateResultMessage(event.data)) {
return;
}
const payload = event.data.payload;
if (!isSameRouteIdentity(payload.routeKey, currentSnapshot.routeKey)) {
options.logger.debug(LOG_PREFIX, "stale-result", payload.routeKey);
return;
}
logFinalResult(payload);
};
options.window.addEventListener("message", onMessage);
options.window.history.pushState = wrapHistoryMethod(originalPushState);
options.window.history.replaceState = wrapHistoryMethod(originalReplaceState);
options.window.addEventListener("popstate", handleNavigation);
injectPageHook(options.document, options.chromeRuntime);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
return {
dispose() {
options.window.removeEventListener("message", onMessage);
options.window.removeEventListener("popstate", handleNavigation);
options.window.history.pushState = originalPushState;
options.window.history.replaceState = originalReplaceState;
},
getSnapshot() {
return currentSnapshot;
}
};
function wrapHistoryMethod<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
const result = originalMethod(...args);
handleNavigation();
return result;
}) as T;
}
function handleNavigation() {
currentSnapshot = routeState.advance(options.window.location.href);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
}
function logFinalResult(payload: AfterSearchRateResult) {
const fingerprint = JSON.stringify({
matchedRequestUrl: payload.matchedRequestUrl ?? null,
rates: payload.rates ?? null,
reason: payload.reason ?? null,
routeKey: payload.routeKey,
stage: payload.stage,
success: payload.success
});
const previousResult = loggedResults.get(payload.routeKey);
if (previousResult?.fingerprint === fingerprint) {
return;
}
if (previousResult?.success && !payload.success) {
return;
}
loggedResults.set(payload.routeKey, {
fingerprint,
success: payload.success
});
options.logger.info(LOG_PREFIX, "result", payload);
}
}
function isSameRouteIdentity(leftRouteKey: string, rightRouteKey: string): boolean {
return stripNavigationSeq(leftRouteKey) === stripNavigationSeq(rightRouteKey);
}
function stripNavigationSeq(routeKey: string): string {
return routeKey.replace(/::\d+$/, "");
}
function injectPageHook(document: Document, chromeRuntime: ChromeRuntimeLike) {
if (document.getElementById(PAGE_HOOK_SCRIPT_ID)) {
return;
}
const script = document.createElement("script");
script.id = PAGE_HOOK_SCRIPT_ID;
script.src = chromeRuntime.getURL("page/hook.global.js");
script.async = false;
(document.head ?? document.documentElement).appendChild(script);
}

View File

@ -1,15 +1,17 @@
import { createRouteState } from "./route-state";
import { getStarIdFromUrl } from "../shared/get-star-id";
import { RESULT_MESSAGE_TYPE } from "../shared/message-types";
import {
isCandidateAnalysisMessage,
isCandidateRequestMessage,
isHookReadyMessage,
isAfterSearchRateResultMessage,
RESULT_MESSAGE_TYPE
} from "../shared/message-types";
import type { AfterSearchRateResult } from "../shared/result-types";
createDetailContentController,
type DetailContentControllerOptions
} from "./detail/index";
import {
createMarketContentController,
type MarketContentControllerOptions
} from "./market/index";
const LOG_PREFIX = "[star-chart-search-enhancer]";
const PAGE_HOOK_SCRIPT_ID = "star-chart-search-enhancer-page-hook";
interface ControllerLike {
dispose(): void;
}
interface ChromeRuntimeLike {
getURL(path: string): string;
@ -21,162 +23,45 @@ interface LoggerLike {
warn(...args: unknown[]): void;
}
interface ContentControllerOptions {
chromeRuntime: ChromeRuntimeLike;
document: Document;
logger: LoggerLike;
window: Window;
interface ContentControllerOptions extends DetailContentControllerOptions {
detailControllerFactory?: (
options: DetailContentControllerOptions
) => ControllerLike;
marketControllerFactory?: (
options: MarketContentControllerOptions
) => ControllerLike;
}
export function createContentController(options: ContentControllerOptions) {
const routeState = createRouteState(options.window.location.href);
let currentSnapshot = routeState.getSnapshot();
const loggedResults = new Map<string, { fingerprint: string; success: boolean }>();
const originalPushState = options.window.history.pushState.bind(
options.window.history
);
const originalReplaceState = options.window.history.replaceState.bind(
options.window.history
);
const onMessage = (event: MessageEvent) => {
if (event.source !== options.window) {
return;
export function createContentController(
options: ContentControllerOptions
): ControllerLike {
if (isCreatorDetailUrl(options.window.location.href)) {
const detailControllerFactory =
options.detailControllerFactory ?? createDetailContentController;
return detailControllerFactory(options);
}
if (isHookReadyMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "hook-ready", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-hook-ready",
event.data.payload.routeKey
);
}
return;
if (isCreatorMarketUrl(options.window.location.href)) {
const marketControllerFactory =
options.marketControllerFactory ?? createMarketContentController;
return marketControllerFactory(options);
}
if (isCandidateRequestMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-request", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-request",
event.data.payload.routeKey
);
}
return;
}
return createNoopController();
}
if (isCandidateAnalysisMessage(event.data)) {
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
options.logger.info(LOG_PREFIX, "candidate-analysis", event.data.payload);
} else {
options.logger.debug(
LOG_PREFIX,
"stale-candidate-analysis",
event.data.payload.routeKey
);
}
return;
}
if (!isAfterSearchRateResultMessage(event.data)) {
return;
}
const payload = event.data.payload;
if (!isSameRouteIdentity(payload.routeKey, currentSnapshot.routeKey)) {
options.logger.debug(LOG_PREFIX, "stale-result", payload.routeKey);
return;
}
logFinalResult(payload);
};
options.window.addEventListener("message", onMessage);
options.window.history.pushState = wrapHistoryMethod(originalPushState);
options.window.history.replaceState = wrapHistoryMethod(originalReplaceState);
options.window.addEventListener("popstate", handleNavigation);
injectPageHook(options.document, options.chromeRuntime);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
function isCreatorDetailUrl(url: string): boolean {
return getStarIdFromUrl(url) !== null;
}
function createNoopController(): ControllerLike {
return {
dispose() {
options.window.removeEventListener("message", onMessage);
options.window.removeEventListener("popstate", handleNavigation);
options.window.history.pushState = originalPushState;
options.window.history.replaceState = originalReplaceState;
},
getSnapshot() {
return currentSnapshot;
}
dispose() {}
};
function wrapHistoryMethod<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
const result = originalMethod(...args);
handleNavigation();
return result;
}) as T;
}
function handleNavigation() {
currentSnapshot = routeState.advance(options.window.location.href);
options.logger.info(LOG_PREFIX, "route", currentSnapshot);
}
function logFinalResult(payload: AfterSearchRateResult) {
const fingerprint = JSON.stringify({
matchedRequestUrl: payload.matchedRequestUrl ?? null,
rates: payload.rates ?? null,
reason: payload.reason ?? null,
routeKey: payload.routeKey,
stage: payload.stage,
success: payload.success
});
const previousResult = loggedResults.get(payload.routeKey);
if (previousResult?.fingerprint === fingerprint) {
return;
}
if (previousResult?.success && !payload.success) {
return;
}
loggedResults.set(payload.routeKey, {
fingerprint,
success: payload.success
});
options.logger.info(LOG_PREFIX, "result", payload);
}
}
function isSameRouteIdentity(leftRouteKey: string, rightRouteKey: string): boolean {
return stripNavigationSeq(leftRouteKey) === stripNavigationSeq(rightRouteKey);
}
function stripNavigationSeq(routeKey: string): string {
return routeKey.replace(/::\d+$/, "");
}
function injectPageHook(document: Document, chromeRuntime: ChromeRuntimeLike) {
if (document.getElementById(PAGE_HOOK_SCRIPT_ID)) {
return;
}
const script = document.createElement("script");
script.id = PAGE_HOOK_SCRIPT_ID;
script.src = chromeRuntime.getURL("page/hook.global.js");
script.async = false;
(document.head ?? document.documentElement).appendChild(script);
function isCreatorMarketUrl(url: string): boolean {
return new URL(url).pathname === "/ad/creator/market";
}
function bootstrapContentScript() {
@ -192,7 +77,7 @@ function bootstrapContentScript() {
const marker = "__starChartSearchEnhancerContentController";
const scopedWindow = window as Window & {
[marker]?: ReturnType<typeof createContentController>;
[marker]?: ControllerLike;
};
if (scopedWindow[marker]) {

View File

@ -0,0 +1,154 @@
import { normalizeRateValue } from "../../shared/normalize-rate-value";
import type { AfterSearchRates } from "../../shared/result-types";
type MarketApiFailureReason = "bad-response" | "request-failed" | "timeout";
type MarketApiSuccessResult = {
success: true;
rates: Required<AfterSearchRates>;
};
type MarketApiFailureResult = {
reason: MarketApiFailureReason;
success: false;
};
export type MarketApiResult = MarketApiSuccessResult | MarketApiFailureResult;
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface MarketApiClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
}
export function createMarketApiClient(options: MarketApiClientOptions = {}) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadAuthorAseInfo(authorId: string): Promise<MarketApiResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(
buildAuthorAseInfoUrl(authorId, baseUrl),
{
credentials: "include",
method: "GET",
signal: controller.signal
}
);
if (!response.ok) {
return {
reason: "request-failed",
success: false
};
}
return mapAuthorAseInfoResponse(await response.json());
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return {
reason: "timeout",
success: false
};
}
return {
reason: "request-failed",
success: false
};
} finally {
clearTimeout(timeoutId);
}
}
};
}
export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string {
const url = new URL("/gw/api/aggregator/get_author_ase_info", baseUrl);
url.searchParams.set("author_id", authorId);
url.searchParams.set("range", "30");
return url.toString();
}
export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
const data = getPayloadData(payload);
if (!data) {
return {
reason: "bad-response",
success: false
};
}
const singleVideoAfterSearchRate = readNormalizedRate(
data.avg_search_after_view_rate
);
const personalVideoAfterSearchRate = readNormalizedRate(
data.personal_avg_search_after_view_rate
);
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) {
return {
reason: "bad-response",
success: false
};
}
return {
rates: {
personalVideoAfterSearchRate,
singleVideoAfterSearchRate
},
success: true
};
}
function getPayloadData(payload: unknown): Record<string, unknown> | null {
if (!isRecord(payload)) {
return null;
}
if (isRecord(payload.data)) {
return payload.data;
}
return payload;
}
function readNormalizedRate(value: unknown): string | null {
return typeof value === "string" ? normalizeRateValue(value) : null;
}
function resolveBaseUrl(): string {
if (typeof location !== "undefined" && location.origin) {
return location.origin;
}
return "https://xingtu.cn";
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@ -0,0 +1,205 @@
import type { MarketApiResult } from "./api-client";
import { createMarketCacheStore } from "./cache-store";
import type { RowErrorReason, MarketRowState } from "./row-state";
import type { RequiredAfterSearchRates } from "./types";
interface BatchLoaderRow {
authorId: string | null;
render(
state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void }
): void;
}
interface LoadRowsOptions {
listSeq: number;
rows: BatchLoaderRow[];
shouldRenderResult?: (params: {
authorId: string;
listSeq: number;
row: BatchLoaderRow;
}) => boolean;
}
interface MarketBatchLoaderOptions {
apiClient: {
loadAuthorAseInfo(authorId: string): Promise<MarketApiResult>;
};
cacheStore?: ReturnType<typeof createMarketCacheStore>;
concurrency?: number;
}
export function createMarketBatchLoader(options: MarketBatchLoaderOptions) {
const cacheStore = options.cacheStore ?? createMarketCacheStore();
const concurrency = Math.max(options.concurrency ?? 4, 1);
return {
async loadRows(loadOptions: LoadRowsOptions) {
const groupedRows = new Map<string, BatchLoaderRow[]>();
for (const row of loadOptions.rows) {
if (!row.authorId) {
row.render({
authorId: null,
listSeq: loadOptions.listSeq,
reason: "missing-author-id",
retryable: false,
state: "error"
});
continue;
}
const cached = cacheStore.getSuccess(row.authorId);
if (cached) {
row.render({
authorId: row.authorId,
listSeq: loadOptions.listSeq,
personalVideoAfterSearchRate: cached.rates.personalVideoAfterSearchRate,
singleVideoAfterSearchRate: cached.rates.singleVideoAfterSearchRate,
source: "cache",
state: "success"
});
continue;
}
row.render({
authorId: row.authorId,
listSeq: loadOptions.listSeq,
state: "loading"
});
const rowsForAuthor = groupedRows.get(row.authorId) ?? [];
rowsForAuthor.push(row);
groupedRows.set(row.authorId, rowsForAuthor);
}
const tasks = Array.from(groupedRows.entries(), ([authorId, rows]) => {
return () => loadAuthorRows(authorId, rows, loadOptions);
});
await runWithConcurrency(tasks, concurrency);
}
};
async function loadAuthorRows(
authorId: string,
rows: BatchLoaderRow[],
loadOptions: LoadRowsOptions
) {
const result = await requestAuthor(authorId);
for (const row of rows) {
if (
loadOptions.shouldRenderResult &&
!loadOptions.shouldRenderResult({
authorId,
listSeq: loadOptions.listSeq,
row
})
) {
continue;
}
renderAuthorResult(row, authorId, loadOptions.listSeq, result, false);
}
}
async function retryRow(row: BatchLoaderRow, listSeq: number) {
if (!row.authorId) {
return;
}
row.render({
authorId: row.authorId,
listSeq,
state: "loading"
});
const result = await requestAuthor(row.authorId);
renderAuthorResult(row, row.authorId, listSeq, result, false);
}
async function requestAuthor(authorId: string): Promise<MarketApiResult> {
const cached = cacheStore.getSuccess(authorId);
if (cached) {
return {
rates: cached.rates,
success: true
};
}
const inflight = cacheStore.getInflight<MarketApiResult>(authorId);
if (inflight) {
return inflight;
}
const requestPromise = options.apiClient
.loadAuthorAseInfo(authorId)
.then((result) => {
if (result.success) {
cacheStore.setSuccess(authorId, result.rates);
}
return result;
})
.finally(() => {
cacheStore.clearInflight(authorId);
});
cacheStore.setInflight(authorId, requestPromise);
return requestPromise;
}
function renderAuthorResult(
row: BatchLoaderRow,
authorId: string,
listSeq: number,
result: MarketApiResult,
fromCache: boolean
) {
if (result.success) {
const rates = result.rates as RequiredAfterSearchRates;
row.render({
authorId,
listSeq,
personalVideoAfterSearchRate: rates.personalVideoAfterSearchRate,
singleVideoAfterSearchRate: rates.singleVideoAfterSearchRate,
source: fromCache ? "cache" : "network",
state: "success"
});
return;
}
row.render(
{
authorId,
listSeq,
reason: result.reason as RowErrorReason,
retryable: true,
state: "error"
},
{
onRetry: () => retryRow(row, listSeq)
}
);
}
}
async function runWithConcurrency(
tasks: Array<() => Promise<void>>,
concurrency: number
) {
let nextTaskIndex = 0;
const workers = Array.from(
{ length: Math.min(concurrency, tasks.length) },
async () => {
while (nextTaskIndex < tasks.length) {
const task = tasks[nextTaskIndex];
nextTaskIndex += 1;
await task();
}
}
);
await Promise.all(workers);
}

View File

@ -0,0 +1,32 @@
import type { RequiredAfterSearchRates } from "./types";
interface SuccessCacheEntry {
rates: RequiredAfterSearchRates;
updatedAt: number;
}
export function createMarketCacheStore() {
const successCache = new Map<string, SuccessCacheEntry>();
const inflightRequests = new Map<string, Promise<unknown>>();
return {
clearInflight(authorId: string) {
inflightRequests.delete(authorId);
},
getInflight<T>(authorId: string) {
return inflightRequests.get(authorId) as Promise<T> | undefined;
},
getSuccess(authorId: string) {
return successCache.get(authorId);
},
setInflight<T>(authorId: string, promise: Promise<T>) {
inflightRequests.set(authorId, promise);
},
setSuccess(authorId: string, rates: RequiredAfterSearchRates) {
successCache.set(authorId, {
rates,
updatedAt: Date.now()
});
}
};
}

View File

@ -0,0 +1,119 @@
const SINGLE_COLUMN_KEY = "single-video-after-search-rate";
const PERSONAL_COLUMN_KEY = "personal-video-after-search-rate";
const SINGLE_HEADER_TEXT = "单视频看后搜率";
const PERSONAL_HEADER_TEXT = "个人视频看后搜率";
const ACTION_HEADER_TEXT = "操作";
export interface MarketRowDom {
personalCell: HTMLTableCellElement;
row: HTMLTableRowElement;
singleCell: HTMLTableCellElement;
}
export interface MarketTableDom {
rows: MarketRowDom[];
table: HTMLTableElement;
}
export function syncMarketTable(document: Document): MarketTableDom | null {
const table = findTargetTable(document);
if (!table) {
return null;
}
ensureHeaders(table);
return {
rows: Array.from(table.tBodies[0]?.rows ?? []).map((row) => ensureRowCells(row)),
table
};
}
function findTargetTable(document: Document): HTMLTableElement | null {
for (const table of document.querySelectorAll("table")) {
const headerTexts = Array.from(
table.querySelectorAll("thead th, thead td"),
(cell) => cell.textContent?.trim() ?? ""
);
if (headerTexts.includes(ACTION_HEADER_TEXT)) {
return table as HTMLTableElement;
}
}
return null;
}
function ensureHeaders(table: HTMLTableElement) {
const headerRow = table.querySelector("thead tr");
if (!headerRow) {
return;
}
const actionHeader = findHeaderByText(headerRow, ACTION_HEADER_TEXT);
if (!actionHeader) {
return;
}
ensureHeaderCell(headerRow, actionHeader, SINGLE_COLUMN_KEY, SINGLE_HEADER_TEXT);
ensureHeaderCell(headerRow, actionHeader, PERSONAL_COLUMN_KEY, PERSONAL_HEADER_TEXT);
}
function ensureHeaderCell(
headerRow: Element,
actionHeader: Element,
columnKey: string,
text: string
) {
if (headerRow.querySelector(`[data-sces-header="${columnKey}"]`)) {
return;
}
const cell = actionHeader.ownerDocument.createElement(actionHeader.tagName);
cell.dataset.scesHeader = columnKey;
cell.textContent = text;
headerRow.insertBefore(cell, actionHeader);
}
function ensureRowCells(row: HTMLTableRowElement): MarketRowDom {
const actionCell = row.cells.item(row.cells.length - 1);
if (!actionCell) {
throw new Error("market row is missing the action cell");
}
const singleCell = ensureRowCell(row, actionCell, SINGLE_COLUMN_KEY);
const personalCell = ensureRowCell(row, actionCell, PERSONAL_COLUMN_KEY);
return {
personalCell,
row,
singleCell
};
}
function ensureRowCell(
row: HTMLTableRowElement,
actionCell: HTMLTableCellElement,
columnKey: string
) {
const existingCell = row.querySelector(
`[data-sces-column="${columnKey}"]`
) as HTMLTableCellElement | null;
if (existingCell) {
return existingCell;
}
const cell = row.ownerDocument.createElement(actionCell.tagName);
cell.dataset.scesColumn = columnKey;
row.insertBefore(cell, actionCell);
return cell;
}
function findHeaderByText(row: Element, text: string): Element | null {
for (const cell of row.querySelectorAll("th, td")) {
if (cell.textContent?.trim() === text) {
return cell;
}
}
return null;
}

View File

@ -0,0 +1,98 @@
import { getStarIdFromUrl } from "../../shared/get-star-id";
type ExtractAuthorIdSuccessResult = {
authorId: string;
source: "attribute" | "detail-link";
success: true;
};
type ExtractAuthorIdFailureResult = {
authorId: null;
reason: "missing-author-id";
success: false;
};
export type ExtractAuthorIdResult =
| ExtractAuthorIdSuccessResult
| ExtractAuthorIdFailureResult;
const ATTRIBUTE_NAMES = [
"data-author-id",
"data-authorid",
"data-author_id",
"author_id"
] as const;
export function extractAuthorIdFromRow(row: Element): ExtractAuthorIdResult {
const detailLinkAuthorId = extractAuthorIdFromDetailLink(row);
if (detailLinkAuthorId) {
return {
authorId: detailLinkAuthorId,
source: "detail-link",
success: true
};
}
const attributeAuthorId = extractAuthorIdFromAttributes(row);
if (attributeAuthorId) {
return {
authorId: attributeAuthorId,
source: "attribute",
success: true
};
}
return {
authorId: null,
reason: "missing-author-id",
success: false
};
}
function extractAuthorIdFromDetailLink(row: Element): string | null {
for (const link of row.querySelectorAll("a[href]")) {
const href = link.getAttribute("href");
if (!href) {
continue;
}
const authorId = getStarIdFromUrl(toAbsoluteUrl(href));
if (authorId) {
return authorId;
}
}
return null;
}
function extractAuthorIdFromAttributes(row: Element): string | null {
for (const attributeName of ATTRIBUTE_NAMES) {
const directMatch = readAttributeValue(row, attributeName);
if (directMatch) {
return directMatch;
}
const descendant = row.querySelector(`[${attributeName}]`);
const descendantMatch = descendant
? readAttributeValue(descendant, attributeName)
: null;
if (descendantMatch) {
return descendantMatch;
}
}
return null;
}
function readAttributeValue(element: Element, attributeName: string): string | null {
const value = element.getAttribute(attributeName)?.trim() ?? "";
return /^\d+$/.test(value) ? value : null;
}
function toAbsoluteUrl(href: string): string {
try {
return new URL(href, "https://xingtu.cn").toString();
} catch {
return href;
}
}

251
src/content/market/index.ts Normal file
View File

@ -0,0 +1,251 @@
import { createMarketApiClient } from "./api-client";
import { createMarketBatchLoader } from "./batch-loader";
import { syncMarketTable, type MarketRowDom } from "./dom-sync";
import { extractAuthorIdFromRow } from "./id-extractor";
import { createListSignature } from "./list-signature";
import { renderMarketRowState } from "./row-render";
import type { MarketRowState } from "./row-state";
interface LoggerLike {
debug(...args: unknown[]): void;
info(...args: unknown[]): void;
warn(...args: unknown[]): void;
}
interface MutationObserverLike {
disconnect(): void;
observe(target: Node, options?: MutationObserverInit): void;
}
interface BatchLoaderLike {
loadRows(options: {
listSeq: number;
rows: Array<{
authorId: string | null;
render(
state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void }
): void;
rowKey: string;
}>;
shouldRenderResult?: (params: {
authorId: string;
listSeq: number;
row: {
authorId: string | null;
rowKey: string;
render(
state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void }
): void;
};
}) => boolean;
}): Promise<void>;
}
export interface MarketContentControllerOptions {
batchLoader?: BatchLoaderLike;
document: Document;
logger: LoggerLike;
mutationObserverFactory?: (
callback: MutationCallback
) => MutationObserverLike;
window: Window;
}
export function createMarketContentController(
options: MarketContentControllerOptions
) {
const batchLoader =
options.batchLoader ??
createMarketBatchLoader({
apiClient: createMarketApiClient(),
concurrency: 4
});
let listSeq = 0;
let currentSignature: string | null = null;
const observerFactory =
options.mutationObserverFactory ??
((callback: MutationCallback) => new MutationObserver(callback));
const observer = observerFactory(() => {
void syncNow();
});
observer.observe(options.document.body, {
childList: true,
subtree: true
});
const originalPushState = options.window.history.pushState.bind(
options.window.history
);
const originalReplaceState = options.window.history.replaceState.bind(
options.window.history
);
options.window.history.pushState = wrapHistoryMethod(originalPushState);
options.window.history.replaceState = wrapHistoryMethod(originalReplaceState);
options.window.addEventListener("popstate", handleNavigation);
void syncNow();
return {
dispose() {
observer.disconnect();
options.window.removeEventListener("popstate", handleNavigation);
options.window.history.pushState = originalPushState;
options.window.history.replaceState = originalReplaceState;
},
syncNow
};
function wrapHistoryMethod<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
const result = originalMethod(...args);
handleNavigation();
return result;
}) as T;
}
function handleNavigation() {
void syncNow();
}
async function syncNow() {
const table = syncMarketTable(options.document);
if (!table) {
return;
}
const extractedRows = table.rows.map((rowDom, index) => {
const authorIdResult = extractAuthorIdFromRow(rowDom.row);
return {
authorId: authorIdResult.success ? authorIdResult.authorId : null,
rowDom,
rowKey: `market-row-${listSeq + 1}-${index}`
};
});
const signature = createListSignature({
authorIds: extractedRows.map(
(row, index) => row.authorId ?? `missing-${index}`
),
url: options.window.location.href
});
if (signature === currentSignature) {
return;
}
currentSignature = signature;
listSeq += 1;
const rows = extractedRows.map((row, index) => {
const nextRowKey = `market-row-${listSeq}-${index}`;
stampRowMetadata(row.rowDom, nextRowKey, String(listSeq), row.authorId);
return {
authorId: row.authorId,
render: (
state: MarketRowState,
renderOptions?: { onRetry?: () => Promise<void> | void }
) => {
renderRowByKey(nextRowKey, state, renderOptions);
},
rowKey: nextRowKey
};
});
await batchLoader.loadRows({
listSeq,
rows,
shouldRenderResult: ({ authorId, listSeq: resultListSeq, row }) =>
isFreshRowTarget(row.rowKey, authorId, resultListSeq)
});
}
function renderRowByKey(
rowKey: string,
state: MarketRowState,
renderOptions?: { onRetry?: () => Promise<void> | void }
) {
const rowDom = getRowDomByKey(options.document, rowKey);
if (!rowDom) {
return;
}
if (!isFreshRowTarget(rowKey, state.authorId, state.listSeq)) {
options.logger.debug(
"[star-chart-search-enhancer]",
"market-stale-result-dropped",
{
authorId: state.authorId,
listSeq: state.listSeq,
rowKey
}
);
return;
}
renderMarketRowState(rowDom, state, renderOptions);
}
function isFreshRowTarget(
rowKey: string,
authorId: string | null,
nextListSeq: number
) {
if (nextListSeq !== listSeq) {
return false;
}
const rowDom = getRowDomByKey(options.document, rowKey);
if (!rowDom) {
return false;
}
return (
rowDom.row.dataset.scesListSeq === String(nextListSeq) &&
(rowDom.row.dataset.scesAuthorId ?? "") === (authorId ?? "")
);
}
}
function stampRowMetadata(
rowDom: MarketRowDom,
rowKey: string,
listSeq: string,
authorId: string | null
) {
rowDom.row.dataset.scesAuthorId = authorId ?? "";
rowDom.row.dataset.scesListSeq = listSeq;
rowDom.row.dataset.scesRowKey = rowKey;
}
function getRowDomByKey(document: Document, rowKey: string): MarketRowDom | null {
const row = document.querySelector(
`[data-sces-row-key="${rowKey}"]`
) as HTMLTableRowElement | null;
if (!row) {
return null;
}
const singleCell = row.querySelector(
'[data-sces-column="single-video-after-search-rate"]'
) as HTMLTableCellElement | null;
const personalCell = row.querySelector(
'[data-sces-column="personal-video-after-search-rate"]'
) as HTMLTableCellElement | null;
if (!singleCell || !personalCell) {
return null;
}
return {
personalCell,
row,
singleCell
};
}

View File

@ -0,0 +1,9 @@
interface ListSignatureOptions {
authorIds: string[];
url: string;
}
export function createListSignature(options: ListSignatureOptions): string {
const parsedUrl = new URL(options.url);
return `${parsedUrl.pathname}${parsedUrl.search}::${options.authorIds.join(",")}`;
}

View File

@ -0,0 +1,52 @@
import type { MarketRowDom } from "./dom-sync";
import type { MarketRowState } from "./row-state";
interface RenderMarketRowStateOptions {
onRetry?: () => void;
}
export function renderMarketRowState(
rowDom: MarketRowDom,
state: MarketRowState,
options: RenderMarketRowStateOptions = {}
) {
applySharedMetadata(rowDom, state);
if (state.state === "loading") {
setCellState(rowDom, "加载中...");
return;
}
if (state.state === "success") {
setRetryHandler(rowDom, undefined);
rowDom.singleCell.textContent = state.singleVideoAfterSearchRate;
rowDom.personalCell.textContent = state.personalVideoAfterSearchRate;
return;
}
setCellState(rowDom, "加载失败");
setRetryHandler(rowDom, state.retryable ? options.onRetry : undefined);
}
function applySharedMetadata(rowDom: MarketRowDom, state: MarketRowState) {
const authorId = state.authorId ?? "";
const listSeq = String(state.listSeq);
rowDom.row.dataset.scesAuthorId = authorId;
rowDom.row.dataset.scesListSeq = listSeq;
rowDom.singleCell.dataset.scesAuthorId = authorId;
rowDom.singleCell.dataset.scesListSeq = listSeq;
rowDom.personalCell.dataset.scesAuthorId = authorId;
rowDom.personalCell.dataset.scesListSeq = listSeq;
}
function setCellState(rowDom: MarketRowDom, text: string) {
setRetryHandler(rowDom, undefined);
rowDom.singleCell.textContent = text;
rowDom.personalCell.textContent = text;
}
function setRetryHandler(rowDom: MarketRowDom, onRetry?: () => void) {
rowDom.singleCell.onclick = onRetry ?? null;
rowDom.personalCell.onclick = onRetry ?? null;
}

View File

@ -0,0 +1,27 @@
export type RowErrorReason =
| "bad-response"
| "missing-author-id"
| "request-failed"
| "timeout";
export type MarketRowState =
| {
authorId: string;
listSeq: number;
state: "loading";
}
| {
authorId: string;
listSeq: number;
personalVideoAfterSearchRate: string;
singleVideoAfterSearchRate: string;
source: "cache" | "network";
state: "success";
}
| {
authorId: string | null;
listSeq: number;
reason: RowErrorReason;
retryable: boolean;
state: "error";
};

View File

@ -0,0 +1,3 @@
import type { AfterSearchRates } from "../../shared/result-types";
export type RequiredAfterSearchRates = Required<AfterSearchRates>;

View File

@ -5,7 +5,10 @@
"description": "Experimentally capture after-search rates on Xingtu creator detail pages.",
"content_scripts": [
{
"matches": ["https://*.xingtu.cn/ad/creator/author-homepage/*"],
"matches": [
"https://*.xingtu.cn/ad/creator/author-homepage/*",
"https://*.xingtu.cn/ad/creator/market*"
],
"js": ["content/index.global.js"],
"run_at": "document_start"
}

View File

@ -1,4 +1,5 @@
import { normalizeRateLabel } from "./normalize-rate-label";
import { normalizeRateValue } from "./normalize-rate-value";
import type {
AfterSearchRateField,
AfterSearchRates,
@ -7,7 +8,6 @@ import type {
type JsonRecord = Record<string, unknown>;
const RATE_RANGE_PATTERN = /\d+(?:\.\d+)?%\s*-\s*\d+(?:\.\d+)?%/;
const SINGLE_TEXT_PATTERN =
/(?:单条视频看后搜率|单视频看后搜率)\s*([0-9.]+%\s*-\s*[0-9.]+%)/;
const PERSONAL_TEXT_PATTERN = /个人视频看后搜率\s*([0-9.]+%\s*-\s*[0-9.]+%)/;
@ -230,32 +230,6 @@ function parseRatesFromText(text: string): AfterSearchRates {
return rates;
}
function isRateValue(value: string): boolean {
return normalizeRateValue(value) !== null;
}
function normalizeRateValue(value: string): string | null {
const trimmedValue = value.trim();
if (/^<\s*\d+(?:\.\d+)?%$/.test(trimmedValue)) {
return trimmedValue.replace(/\s+/g, "");
}
const normalizedRange = trimmedValue.match(
/^(\d+(?:\.\d+)?)%?\s*-\s*(\d+(?:\.\d+)?)%$/
);
if (normalizedRange) {
const [, start, end] = normalizedRange;
return `${start}% - ${end}%`;
}
if (RATE_RANGE_PATTERN.test(trimmedValue)) {
return trimmedValue.replace(/\s*-\s*/, "% - ").replace(/%%/g, "%");
}
return null;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View File

@ -0,0 +1,25 @@
export function normalizeRateValue(value: string): string | null {
const trimmedValue = value.trim();
if (/^<\s*\d+(?:\.\d+)?%$/.test(trimmedValue)) {
return trimmedValue.replace(/\s+/g, "");
}
const normalizedRange = trimmedValue.match(
/^(\d+(?:\.\d+)?)%?\s*-\s*(\d+(?:\.\d+)?)%$/
);
if (normalizedRange) {
const [, start, end] = normalizedRange;
return `${start}% - ${end}%`;
}
const alreadyNormalizedRange = trimmedValue.match(
/^(\d+(?:\.\d+)?)%\s*-\s*(\d+(?:\.\d+)?)%$/
);
if (alreadyNormalizedRange) {
const [, start, end] = alreadyNormalizedRange;
return `${start}% - ${end}%`;
}
return null;
}

View File

@ -17,7 +17,7 @@ describe("build layout", () => {
rmSync(distDir, { force: true, recursive: true });
});
test("manifest exists and targets the creator detail page", async () => {
test("manifest exists and targets the creator detail and market pages", async () => {
const raw = await readFile(manifestPath, "utf8");
const manifest = JSON.parse(raw);
@ -25,7 +25,10 @@ describe("build layout", () => {
expect(manifest.content_scripts).toEqual(
expect.arrayContaining([
expect.objectContaining({
matches: ["https://*.xingtu.cn/ad/creator/author-homepage/*"]
matches: [
"https://*.xingtu.cn/ad/creator/author-homepage/*",
"https://*.xingtu.cn/ad/creator/market*"
]
})
])
);

View File

@ -46,6 +46,26 @@ describe("content entry", () => {
expect(detailControllerFactory).not.toHaveBeenCalled();
expect(() => controller.dispose()).not.toThrow();
});
test("bootstraps the market controller on market urls", () => {
const dom = createDom("https://xingtu.cn/ad/creator/market");
const detailControllerFactory = vi.fn(() => ({ dispose: vi.fn() }));
const marketController = { dispose: vi.fn() };
const marketControllerFactory = vi.fn(() => marketController);
const controller = createContentController({
chromeRuntime: { getURL: (value: string) => `chrome-extension://test/${value}` },
detailControllerFactory,
document: dom.window.document,
logger: createLogger(),
marketControllerFactory,
window: dom.window
});
expect(detailControllerFactory).not.toHaveBeenCalled();
expect(marketControllerFactory).toHaveBeenCalledTimes(1);
expect(controller).toBe(marketController);
});
});
function createDom(url: string) {

View File

@ -0,0 +1,124 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createMarketApiClient,
mapAuthorAseInfoResponse
} from "../src/content/market/api-client";
import { normalizeRateValue } from "../src/shared/normalize-rate-value";
describe("market api client", () => {
afterEach(() => {
vi.useRealTimers();
});
test("normalizes known rate shapes", () => {
expect(normalizeRateValue("<0.02%")).toBe("<0.02%");
expect(normalizeRateValue("0.02 - 0.1%")).toBe("0.02% - 0.1%");
});
test("maps the known author ase info response fields", () => {
expect(
mapAuthorAseInfoResponse({
data: {
avg_search_after_view_rate: "<0.02%",
personal_avg_search_after_view_rate: "0.02 - 0.1%"
}
})
).toEqual({
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true
});
});
test("returns bad-response when either target field is missing", () => {
expect(
mapAuthorAseInfoResponse({
data: {
avg_search_after_view_rate: "<0.02%"
}
})
).toEqual({
reason: "bad-response",
success: false
});
});
test("issues fetch with credentials include and a timeout signal", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({
data: {
avg_search_after_view_rate: "<0.02%",
personal_avg_search_after_view_rate: "0.02 - 0.1%"
}
}),
ok: true
}));
const client = createMarketApiClient({
baseUrl: "https://xingtu.cn",
fetchImpl,
timeoutMs: 8000
});
const result = await client.loadAuthorAseInfo("6629661559960371207");
expect(fetchImpl).toHaveBeenCalledWith(
"https://xingtu.cn/gw/api/aggregator/get_author_ase_info?author_id=6629661559960371207&range=30",
expect.objectContaining({
credentials: "include",
method: "GET",
signal: expect.any(AbortSignal)
})
);
expect(result).toMatchObject({
success: true
});
});
test("returns timeout when the request is aborted by the timeout budget", async () => {
vi.useFakeTimers();
const fetchImpl = vi.fn(
(_input: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
});
})
);
const client = createMarketApiClient({
baseUrl: "https://xingtu.cn",
fetchImpl,
timeoutMs: 25
});
const resultPromise = client.loadAuthorAseInfo("6629661559960371207");
await vi.advanceTimersByTimeAsync(25);
await expect(resultPromise).resolves.toEqual({
reason: "timeout",
success: false
});
});
test("returns request-failed for non-ok responses", async () => {
const fetchImpl = vi.fn(async () => ({
json: async () => ({}),
ok: false
}));
const client = createMarketApiClient({
baseUrl: "https://xingtu.cn",
fetchImpl,
timeoutMs: 8000
});
await expect(
client.loadAuthorAseInfo("6629661559960371207")
).resolves.toEqual({
reason: "request-failed",
success: false
});
});
});

View File

@ -0,0 +1,235 @@
import { describe, expect, test, vi } from "vitest";
import { createMarketBatchLoader } from "../src/content/market/batch-loader";
describe("market batch loader", () => {
test("puts current-page rows into loading before the request resolves", async () => {
const deferred = createDeferred();
const row = createRowRecorder("111");
const loader = createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(() => deferred.promise)
},
concurrency: 4
});
const loadPromise = loader.loadRows({
listSeq: 1,
rows: [row.row]
});
expect(row.states[0]).toMatchObject({
authorId: "111",
listSeq: 1,
state: "loading"
});
deferred.resolve({
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true
});
await loadPromise;
});
test("reuses the success cache for repeated authorIds", async () => {
const apiClient = {
loadAuthorAseInfo: vi.fn(async () => ({
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true as const
}))
};
const firstRow = createRowRecorder("111");
const secondRow = createRowRecorder("111");
const loader = createMarketBatchLoader({
apiClient,
concurrency: 4
});
await loader.loadRows({
listSeq: 1,
rows: [firstRow.row]
});
await loader.loadRows({
listSeq: 2,
rows: [secondRow.row]
});
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1);
expect(secondRow.states.at(-1)).toMatchObject({
source: "cache",
state: "success"
});
});
test("deduplicates in-flight requests for the same authorId", async () => {
const deferred = createDeferred();
const firstRow = createRowRecorder("111");
const secondRow = createRowRecorder("111");
const apiClient = {
loadAuthorAseInfo: vi.fn(() => deferred.promise)
};
const loader = createMarketBatchLoader({
apiClient,
concurrency: 4
});
const loadPromise = loader.loadRows({
listSeq: 1,
rows: [firstRow.row, secondRow.row]
});
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1);
deferred.resolve({
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true
});
await loadPromise;
});
test("honors the concurrency cap", async () => {
const deferreds = new Map([
["111", createDeferred()],
["222", createDeferred()],
["333", createDeferred()]
]);
const started: string[] = [];
const loader = createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn((authorId: string) => {
started.push(authorId);
return deferreds.get(authorId)!.promise;
})
},
concurrency: 2
});
const loadPromise = loader.loadRows({
listSeq: 1,
rows: [createRowRecorder("111").row, createRowRecorder("222").row, createRowRecorder("333").row]
});
expect(started).toEqual(["111", "222"]);
deferreds.get("111")!.resolve(successResult());
await tick();
expect(started).toEqual(["111", "222", "333"]);
deferreds.get("222")!.resolve(successResult());
deferreds.get("333")!.resolve(successResult());
await loadPromise;
});
test("renders failed rows as error states", async () => {
const row = createRowRecorder("111");
const loader = createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(async () => ({
reason: "request-failed",
success: false as const
}))
},
concurrency: 4
});
await loader.loadRows({
listSeq: 1,
rows: [row.row]
});
expect(row.states.at(-1)).toMatchObject({
reason: "request-failed",
retryable: true,
state: "error"
});
});
test("retries the whole row when the provided retry handler is invoked", async () => {
const row = createRowRecorder("111");
const apiClient = {
loadAuthorAseInfo: vi
.fn()
.mockResolvedValueOnce({
reason: "request-failed",
success: false as const
})
.mockResolvedValueOnce(successResult())
};
const loader = createMarketBatchLoader({
apiClient,
concurrency: 4
});
await loader.loadRows({
listSeq: 1,
rows: [row.row]
});
expect(row.retry).toBeTypeOf("function");
await row.retry?.();
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(2);
expect(row.states.at(-2)).toMatchObject({
state: "loading"
});
expect(row.states.at(-1)).toMatchObject({
state: "success"
});
});
});
function createRowRecorder(authorId: string | null) {
const states: unknown[] = [];
let retry: (() => Promise<void> | void) | undefined;
return {
row: {
authorId,
render(
state: unknown,
options?: { onRetry?: () => Promise<void> | void }
) {
states.push(state);
retry = options?.onRetry;
}
},
get retry() {
return retry;
},
states
};
}
function createDeferred<T = unknown>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function successResult() {
return {
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%"
},
success: true as const
};
}
function tick() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}

View File

@ -0,0 +1,264 @@
import { JSDOM } from "jsdom";
import { describe, expect, test, vi } from "vitest";
import { createMarketBatchLoader } from "../src/content/market/batch-loader";
import { createMarketContentController } from "../src/content/market/index";
describe("market controller", () => {
test("auto-loads the current market rows on startup", async () => {
const dom = createMarketDom();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient: {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
},
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: createMutationObserverFactory(),
window: dom.window
});
await tick();
const firstRow = dom.window.document.querySelector("tbody tr")!;
expect(cellTexts(firstRow)).toEqual([
"达人 A",
"111-single",
"111-personal",
"查看"
]);
controller.dispose();
dom.window.close();
});
test("triggers a fresh sync when the visible list changes", async () => {
const dom = createMarketDom();
const apiClient = {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
};
const observer = createMutationObserverFactory();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient,
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: observer,
window: dom.window
});
await tick();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/333"> C</a></td>
<td></td>
</tr>
`
);
observer.trigger();
await tick();
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledWith("333");
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
"达人 C",
"333-single",
"333-personal",
"查看"
]);
controller.dispose();
dom.window.close();
});
test("drops stale async results after a newer list replaces the old one", async () => {
const dom = createMarketDom();
const firstDeferred = createDeferred<ReturnType<typeof successFor>>();
const apiClient = {
loadAuthorAseInfo: vi
.fn()
.mockImplementationOnce(() => firstDeferred.promise)
.mockImplementationOnce(async () => successFor("222"))
};
const observer = createMutationObserverFactory();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient,
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: observer,
window: dom.window
});
await tick();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222"> B</a></td>
<td></td>
</tr>
`
);
observer.trigger();
await tick();
firstDeferred.resolve(successFor("111"));
await tick();
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
"达人 B",
"222-single",
"222-personal",
"查看"
]);
controller.dispose();
dom.window.close();
});
test("rehydrates cached rows immediately when they reappear", async () => {
const dom = createMarketDom();
const apiClient = {
loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId))
};
const observer = createMutationObserverFactory();
const controller = createMarketContentController({
batchLoader: createMarketBatchLoader({
apiClient,
concurrency: 4
}),
document: dom.window.document,
logger: createLogger(),
mutationObserverFactory: observer,
window: dom.window
});
await tick();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222"> B</a></td>
<td></td>
</tr>
`
);
observer.trigger();
await tick();
replaceRows(
dom.window.document,
`
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a></td>
<td></td>
</tr>
`
);
observer.trigger();
const row = dom.window.document.querySelector("tbody tr")!;
expect(cellTexts(row)).toEqual([
"达人 A",
"111-single",
"111-personal",
"查看"
]);
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(2);
controller.dispose();
dom.window.close();
});
});
function cellTexts(row: Element) {
return Array.from(row.querySelectorAll("td"), (cell) => cell.textContent?.trim() ?? "");
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve;
});
return { promise, resolve };
}
function createLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn()
};
}
function createMarketDom() {
return new JSDOM(
`
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a></td>
<td></td>
</tr>
</tbody>
</table>
`,
{
url: "https://xingtu.cn/ad/creator/market"
}
);
}
function createMutationObserverFactory() {
let callback: MutationCallback = () => undefined;
return Object.assign(
(nextCallback: MutationCallback) => {
callback = nextCallback;
return {
disconnect() {},
observe() {}
};
},
{
trigger() {
callback([], {} as MutationObserver);
}
}
);
}
function replaceRows(document: Document, rowsHtml: string) {
document.querySelector("tbody")!.innerHTML = rowsHtml;
}
function successFor(authorId: string) {
return {
rates: {
personalVideoAfterSearchRate: `${authorId}-personal`,
singleVideoAfterSearchRate: `${authorId}-single`
},
success: true as const
};
}
function tick() {
return new Promise((resolve) => {
setTimeout(resolve, 0);
});
}

View File

@ -0,0 +1,81 @@
import { JSDOM } from "jsdom";
import { describe, expect, test } from "vitest";
import { syncMarketTable } from "../src/content/market/dom-sync";
describe("market dom sync", () => {
test("inserts two headers before the 操作 column", () => {
const document = createDocument();
const table = syncMarketTable(document);
const headers = Array.from(
document.querySelectorAll("thead th"),
(cell) => cell.textContent?.trim() ?? ""
);
expect(table).not.toBeNull();
expect(headers).toEqual([
"达人信息",
"单视频看后搜率",
"个人视频看后搜率",
"操作"
]);
});
test("inserts two cells before the action cell for each row and tags them", () => {
const document = createDocument();
const table = syncMarketTable(document);
expect(table?.rows).toHaveLength(2);
expect(
table?.rows.map((row) =>
Array.from(row.row.cells, (cell) => cell.textContent?.trim() ?? "")
)
).toEqual([
["达人 A", "", "", "查看"],
["达人 B", "", "", "查看"]
]);
expect(table?.rows[0].singleCell.dataset.scesColumn).toBe(
"single-video-after-search-rate"
);
expect(table?.rows[0].personalCell.dataset.scesColumn).toBe(
"personal-video-after-search-rate"
);
});
test("does not duplicate injected columns when synced twice", () => {
const document = createDocument();
syncMarketTable(document);
syncMarketTable(document);
expect(document.querySelectorAll('[data-sces-column="single-video-after-search-rate"]')).toHaveLength(2);
expect(document.querySelectorAll('[data-sces-column="personal-video-after-search-rate"]')).toHaveLength(2);
expect(document.querySelectorAll('[data-sces-header="single-video-after-search-rate"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-sces-header="personal-video-after-search-rate"]')).toHaveLength(1);
});
});
function createDocument() {
return new JSDOM(`
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td> A</td>
<td></td>
</tr>
<tr>
<td> B</td>
<td></td>
</tr>
</tbody>
</table>
`).window.document;
}

View File

@ -0,0 +1,57 @@
import { JSDOM } from "jsdom";
import { describe, expect, test } from "vitest";
import { extractAuthorIdFromRow } from "../src/content/market/id-extractor";
import { createListSignature } from "../src/content/market/list-signature";
describe("market id extractor", () => {
test("extracts authorId from a detail link inside the row", () => {
const row = createRow(
'<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/6629661559960371207">达人详情</a>'
);
expect(extractAuthorIdFromRow(row)).toEqual({
authorId: "6629661559960371207",
source: "detail-link",
success: true
});
});
test("extracts authorId from fallback row attributes", () => {
const row = createRow(
'<button data-author-id="7312345678901234567">查看</button>'
);
expect(extractAuthorIdFromRow(row)).toEqual({
authorId: "7312345678901234567",
source: "attribute",
success: true
});
});
test("returns an explicit missing-author-id result when no stable source exists", () => {
const row = createRow('<span>没有达人 id</span>');
expect(extractAuthorIdFromRow(row)).toEqual({
authorId: null,
reason: "missing-author-id",
success: false
});
});
test("builds a deterministic list signature from authorIds and url state", () => {
expect(
createListSignature({
authorIds: ["111", "222", "333"],
url: "https://xingtu.cn/ad/creator/market?page=2&keyword=test"
})
).toBe("/ad/creator/market?page=2&keyword=test::111,222,333");
});
});
function createRow(innerHtml: string) {
const dom = new JSDOM(
`<table><tbody><tr><td>${innerHtml}</td></tr></tbody></table>`
);
return dom.window.document.querySelector("tr") as HTMLTableRowElement;
}

View File

@ -0,0 +1,86 @@
import { JSDOM } from "jsdom";
import { describe, expect, test, vi } from "vitest";
import { syncMarketTable } from "../src/content/market/dom-sync";
import { renderMarketRowState } from "../src/content/market/row-render";
describe("market row render", () => {
test("renders the loading state for both injected cells", () => {
const rows = getRows();
renderMarketRowState(rows[0], {
authorId: "111",
listSeq: 1,
state: "loading"
});
expect(rows[0].singleCell.textContent).toBe("加载中...");
expect(rows[0].personalCell.textContent).toBe("加载中...");
expect(rows[0].singleCell.dataset.scesAuthorId).toBe("111");
expect(rows[0].singleCell.dataset.scesListSeq).toBe("1");
});
test("renders success values for both injected cells", () => {
const rows = getRows();
renderMarketRowState(rows[0], {
authorId: "111",
listSeq: 1,
personalVideoAfterSearchRate: "0.02% - 0.1%",
singleVideoAfterSearchRate: "<0.02%",
source: "network",
state: "success"
});
expect(rows[0].singleCell.textContent).toBe("<0.02%");
expect(rows[0].personalCell.textContent).toBe("0.02% - 0.1%");
});
test("renders a retryable error state without per-cell divergence", () => {
const onRetry = vi.fn();
const rows = getRows();
renderMarketRowState(
rows[0],
{
authorId: "111",
listSeq: 2,
reason: "request-failed",
retryable: true,
state: "error"
},
{ onRetry }
);
rows[0].personalCell.dispatchEvent(
new rows[0].row.ownerDocument.defaultView!.MouseEvent("click", {
bubbles: true
})
);
expect(rows[0].singleCell.textContent).toBe("加载失败");
expect(rows[0].personalCell.textContent).toBe("加载失败");
expect(onRetry).toHaveBeenCalledTimes(1);
});
});
function getRows() {
const document = new JSDOM(`
<table>
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td> A</td>
<td></td>
</tr>
</tbody>
</table>
`).window.document;
return syncMarketTable(document)!.rows;
}

View File

@ -7,7 +7,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const readmePath = path.resolve(__dirname, "..", "README.md");
describe("README", () => {
test("documents setup, build, and manual verification", async () => {
test("documents setup, build, detail verification, and market-page verification", async () => {
const readme = await readFile(readmePath, "utf8");
expect(readme).toContain("npm install");
@ -17,5 +17,12 @@ describe("README", () => {
expect(readme).toContain("dist/");
expect(readme).toContain("DevTools Console");
expect(readme).toContain("达人详情页");
expect(readme).toContain("creator/market");
expect(readme).toContain("单视频看后搜率");
expect(readme).toContain("个人视频看后搜率");
expect(readme).toContain("加载中...");
expect(readme).toContain("加载失败");
expect(readme).toContain("点击任一失败单元格");
expect(readme).toContain("详情页控制台实验");
});
});