From bf6295a4d06d92262b0d1f2579d17e174450b7dc Mon Sep 17 00:00:00 2001 From: wangshaoqing Date: Wed, 15 Apr 2026 15:32:57 +0800 Subject: [PATCH] feat: add market page after-search-rate columns --- README.md | 28 ++- src/content/detail/index.ts | 181 ++++++++++++++++ src/content/index.ts | 199 ++++------------- src/content/market/api-client.ts | 154 +++++++++++++ src/content/market/batch-loader.ts | 205 ++++++++++++++++++ src/content/market/cache-store.ts | 32 +++ src/content/market/dom-sync.ts | 119 ++++++++++ src/content/market/id-extractor.ts | 98 +++++++++ src/content/market/index.ts | 251 +++++++++++++++++++++ src/content/market/list-signature.ts | 9 + src/content/market/row-render.ts | 52 +++++ src/content/market/row-state.ts | 27 +++ src/content/market/types.ts | 3 + src/manifest.json | 5 +- src/shared/extract-after-search-rates.ts | 28 +-- src/shared/normalize-rate-value.ts | 25 +++ tests/build-layout.test.ts | 7 +- tests/content-entry.test.ts | 20 ++ tests/market-api-client.test.ts | 124 +++++++++++ tests/market-batch-loader.test.ts | 235 ++++++++++++++++++++ tests/market-controller.test.ts | 264 +++++++++++++++++++++++ tests/market-dom-sync.test.ts | 81 +++++++ tests/market-id-extractor.test.ts | 57 +++++ tests/market-row-render.test.ts | 86 ++++++++ tests/readme.test.ts | 9 +- 25 files changed, 2107 insertions(+), 192 deletions(-) create mode 100644 src/content/detail/index.ts create mode 100644 src/content/market/api-client.ts create mode 100644 src/content/market/batch-loader.ts create mode 100644 src/content/market/cache-store.ts create mode 100644 src/content/market/dom-sync.ts create mode 100644 src/content/market/id-extractor.ts create mode 100644 src/content/market/index.ts create mode 100644 src/content/market/list-signature.ts create mode 100644 src/content/market/row-render.ts create mode 100644 src/content/market/row-state.ts create mode 100644 src/content/market/types.ts create mode 100644 src/shared/normalize-rate-value.ts create mode 100644 tests/market-api-client.test.ts create mode 100644 tests/market-batch-loader.test.ts create mode 100644 tests/market-controller.test.ts create mode 100644 tests/market-dom-sync.test.ts create mode 100644 tests/market-id-extractor.test.ts create mode 100644 tests/market-row-render.test.ts diff --git a/README.md b/README.md index 473202f..669af93 100644 --- a/README.md +++ b/README.md @@ -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` 当前可见结果页的两列增强 +- 列表页只处理当前可见结果页,不处理全部结果导出 +- 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果 +- 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态 diff --git a/src/content/detail/index.ts b/src/content/detail/index.ts new file mode 100644 index 0000000..de3ba7e --- /dev/null +++ b/src/content/detail/index.ts @@ -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(); + + 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( + originalMethod: T + ) { + return ((...args: Parameters) => { + 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); +} diff --git a/src/content/index.ts b/src/content/index.ts index 964779c..e5a3d4e 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -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(); +export function createContentController( + options: ContentControllerOptions +): ControllerLike { + if (isCreatorDetailUrl(options.window.location.href)) { + const detailControllerFactory = + options.detailControllerFactory ?? createDetailContentController; + return detailControllerFactory(options); + } - const originalPushState = options.window.history.pushState.bind( - options.window.history - ); - const originalReplaceState = options.window.history.replaceState.bind( - options.window.history - ); + if (isCreatorMarketUrl(options.window.location.href)) { + const marketControllerFactory = + options.marketControllerFactory ?? createMarketContentController; + return marketControllerFactory(options); + } - const onMessage = (event: MessageEvent) => { - if (event.source !== options.window) { - return; - } + return createNoopController(); +} - 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); +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( - originalMethod: T - ) { - return ((...args: Parameters) => { - 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; + [marker]?: ControllerLike; }; if (scopedWindow[marker]) { diff --git a/src/content/market/api-client.ts b/src/content/market/api-client.ts new file mode 100644 index 0000000..bd558e8 --- /dev/null +++ b/src/content/market/api-client.ts @@ -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; +}; + +type MarketApiFailureResult = { + reason: MarketApiFailureReason; + success: false; +}; + +export type MarketApiResult = MarketApiSuccessResult | MarketApiFailureResult; + +interface FetchResponseLike { + json(): Promise; + ok: boolean; +} + +type FetchLike = ( + input: string, + init?: RequestInit +) => Promise; + +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 { + 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 | 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 { + return typeof value === "object" && value !== null; +} diff --git a/src/content/market/batch-loader.ts b/src/content/market/batch-loader.ts new file mode 100644 index 0000000..644f58a --- /dev/null +++ b/src/content/market/batch-loader.ts @@ -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; +} + +interface LoadRowsOptions { + listSeq: number; + rows: BatchLoaderRow[]; + shouldRenderResult?: (params: { + authorId: string; + listSeq: number; + row: BatchLoaderRow; + }) => boolean; +} + +interface MarketBatchLoaderOptions { + apiClient: { + loadAuthorAseInfo(authorId: string): Promise; + }; + cacheStore?: ReturnType; + 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(); + + 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 { + const cached = cacheStore.getSuccess(authorId); + if (cached) { + return { + rates: cached.rates, + success: true + }; + } + + const inflight = cacheStore.getInflight(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>, + 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); +} diff --git a/src/content/market/cache-store.ts b/src/content/market/cache-store.ts new file mode 100644 index 0000000..f862450 --- /dev/null +++ b/src/content/market/cache-store.ts @@ -0,0 +1,32 @@ +import type { RequiredAfterSearchRates } from "./types"; + +interface SuccessCacheEntry { + rates: RequiredAfterSearchRates; + updatedAt: number; +} + +export function createMarketCacheStore() { + const successCache = new Map(); + const inflightRequests = new Map>(); + + return { + clearInflight(authorId: string) { + inflightRequests.delete(authorId); + }, + getInflight(authorId: string) { + return inflightRequests.get(authorId) as Promise | undefined; + }, + getSuccess(authorId: string) { + return successCache.get(authorId); + }, + setInflight(authorId: string, promise: Promise) { + inflightRequests.set(authorId, promise); + }, + setSuccess(authorId: string, rates: RequiredAfterSearchRates) { + successCache.set(authorId, { + rates, + updatedAt: Date.now() + }); + } + }; +} diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts new file mode 100644 index 0000000..153e14b --- /dev/null +++ b/src/content/market/dom-sync.ts @@ -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; +} diff --git a/src/content/market/id-extractor.ts b/src/content/market/id-extractor.ts new file mode 100644 index 0000000..c3f46d6 --- /dev/null +++ b/src/content/market/id-extractor.ts @@ -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; + } +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts new file mode 100644 index 0000000..11f9e42 --- /dev/null +++ b/src/content/market/index.ts @@ -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; + rowKey: string; + }>; + shouldRenderResult?: (params: { + authorId: string; + listSeq: number; + row: { + authorId: string | null; + rowKey: string; + render( + state: MarketRowState, + options?: { onRetry?: () => Promise | void } + ): void; + }; + }) => boolean; + }): Promise; +} + +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( + originalMethod: T + ) { + return ((...args: Parameters) => { + 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 } + ) => { + 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 } + ) { + 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 + }; +} diff --git a/src/content/market/list-signature.ts b/src/content/market/list-signature.ts new file mode 100644 index 0000000..6da4c75 --- /dev/null +++ b/src/content/market/list-signature.ts @@ -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(",")}`; +} diff --git a/src/content/market/row-render.ts b/src/content/market/row-render.ts new file mode 100644 index 0000000..4982463 --- /dev/null +++ b/src/content/market/row-render.ts @@ -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; +} diff --git a/src/content/market/row-state.ts b/src/content/market/row-state.ts new file mode 100644 index 0000000..4f668e5 --- /dev/null +++ b/src/content/market/row-state.ts @@ -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"; + }; diff --git a/src/content/market/types.ts b/src/content/market/types.ts new file mode 100644 index 0000000..deb3afe --- /dev/null +++ b/src/content/market/types.ts @@ -0,0 +1,3 @@ +import type { AfterSearchRates } from "../../shared/result-types"; + +export type RequiredAfterSearchRates = Required; diff --git a/src/manifest.json b/src/manifest.json index edb975b..37dc542 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -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" } diff --git a/src/shared/extract-after-search-rates.ts b/src/shared/extract-after-search-rates.ts index f5b973f..cffd0c4 100644 --- a/src/shared/extract-after-search-rates.ts +++ b/src/shared/extract-after-search-rates.ts @@ -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; -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); } diff --git a/src/shared/normalize-rate-value.ts b/src/shared/normalize-rate-value.ts new file mode 100644 index 0000000..d96a1d1 --- /dev/null +++ b/src/shared/normalize-rate-value.ts @@ -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; +} diff --git a/tests/build-layout.test.ts b/tests/build-layout.test.ts index cc00605..2bf266a 100644 --- a/tests/build-layout.test.ts +++ b/tests/build-layout.test.ts @@ -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*" + ] }) ]) ); diff --git a/tests/content-entry.test.ts b/tests/content-entry.test.ts index 137f36e..0e88e1d 100644 --- a/tests/content-entry.test.ts +++ b/tests/content-entry.test.ts @@ -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) { diff --git a/tests/market-api-client.test.ts b/tests/market-api-client.test.ts new file mode 100644 index 0000000..717d451 --- /dev/null +++ b/tests/market-api-client.test.ts @@ -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 + }); + }); +}); diff --git a/tests/market-batch-loader.test.ts b/tests/market-batch-loader.test.ts new file mode 100644 index 0000000..88fa21b --- /dev/null +++ b/tests/market-batch-loader.test.ts @@ -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) | undefined; + + return { + row: { + authorId, + render( + state: unknown, + options?: { onRetry?: () => Promise | void } + ) { + states.push(state); + retry = options?.onRetry; + } + }, + get retry() { + return retry; + }, + states + }; +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((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); + }); +} diff --git a/tests/market-controller.test.ts b/tests/market-controller.test.ts new file mode 100644 index 0000000..4b30500 --- /dev/null +++ b/tests/market-controller.test.ts @@ -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, + ` + + 达人 C + 查看 + + ` + ); + 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>(); + 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, + ` + + 达人 B + 查看 + + ` + ); + 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, + ` + + 达人 B + 查看 + + ` + ); + observer.trigger(); + await tick(); + + replaceRows( + dom.window.document, + ` + + 达人 A + 查看 + + ` + ); + 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() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + + return { promise, resolve }; +} + +function createLogger() { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn() + }; +} + +function createMarketDom() { + return new JSDOM( + ` + + + + + + + + + + + + + +
达人信息操作
达人 A查看
+ `, + { + 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); + }); +} diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts new file mode 100644 index 0000000..8cc05b9 --- /dev/null +++ b/tests/market-dom-sync.test.ts @@ -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(` + + + + + + + + + + + + + + + + + +
达人信息操作
达人 A查看
达人 B查看
+ `).window.document; +} diff --git a/tests/market-id-extractor.test.ts b/tests/market-id-extractor.test.ts new file mode 100644 index 0000000..814fe9f --- /dev/null +++ b/tests/market-id-extractor.test.ts @@ -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( + '达人详情' + ); + + expect(extractAuthorIdFromRow(row)).toEqual({ + authorId: "6629661559960371207", + source: "detail-link", + success: true + }); + }); + + test("extracts authorId from fallback row attributes", () => { + const row = createRow( + '' + ); + + 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('没有达人 id'); + + 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( + `
${innerHtml}
` + ); + return dom.window.document.querySelector("tr") as HTMLTableRowElement; +} diff --git a/tests/market-row-render.test.ts b/tests/market-row-render.test.ts new file mode 100644 index 0000000..2bcf788 --- /dev/null +++ b/tests/market-row-render.test.ts @@ -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(` + + + + + + + + + + + + + +
达人信息操作
达人 A查看
+ `).window.document; + + return syncMarketTable(document)!.rows; +} diff --git a/tests/readme.test.ts b/tests/readme.test.ts index c6e3673..e770fd5 100644 --- a/tests/readme.test.ts +++ b/tests/readme.test.ts @@ -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("详情页控制台实验"); }); });