feat: add market page after-search-rate columns
This commit is contained in:
parent
714745bb36
commit
bf6295a4d0
28
README.md
28
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` 当前可见结果页的两列增强
|
||||
- 列表页只处理当前可见结果页,不处理全部结果导出
|
||||
- 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果
|
||||
- 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态
|
||||
|
||||
181
src/content/detail/index.ts
Normal file
181
src/content/detail/index.ts
Normal 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);
|
||||
}
|
||||
@ -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 }>();
|
||||
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<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]) {
|
||||
|
||||
154
src/content/market/api-client.ts
Normal file
154
src/content/market/api-client.ts
Normal 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;
|
||||
}
|
||||
205
src/content/market/batch-loader.ts
Normal file
205
src/content/market/batch-loader.ts
Normal 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);
|
||||
}
|
||||
32
src/content/market/cache-store.ts
Normal file
32
src/content/market/cache-store.ts
Normal 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()
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
119
src/content/market/dom-sync.ts
Normal file
119
src/content/market/dom-sync.ts
Normal 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;
|
||||
}
|
||||
98
src/content/market/id-extractor.ts
Normal file
98
src/content/market/id-extractor.ts
Normal 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
251
src/content/market/index.ts
Normal 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
|
||||
};
|
||||
}
|
||||
9
src/content/market/list-signature.ts
Normal file
9
src/content/market/list-signature.ts
Normal 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(",")}`;
|
||||
}
|
||||
52
src/content/market/row-render.ts
Normal file
52
src/content/market/row-render.ts
Normal 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;
|
||||
}
|
||||
27
src/content/market/row-state.ts
Normal file
27
src/content/market/row-state.ts
Normal 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";
|
||||
};
|
||||
3
src/content/market/types.ts
Normal file
3
src/content/market/types.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import type { AfterSearchRates } from "../../shared/result-types";
|
||||
|
||||
export type RequiredAfterSearchRates = Required<AfterSearchRates>;
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
25
src/shared/normalize-rate-value.ts
Normal file
25
src/shared/normalize-rate-value.ts
Normal 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;
|
||||
}
|
||||
@ -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*"
|
||||
]
|
||||
})
|
||||
])
|
||||
);
|
||||
|
||||
@ -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) {
|
||||
|
||||
124
tests/market-api-client.test.ts
Normal file
124
tests/market-api-client.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
235
tests/market-batch-loader.test.ts
Normal file
235
tests/market-batch-loader.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
264
tests/market-controller.test.ts
Normal file
264
tests/market-controller.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
81
tests/market-dom-sync.test.ts
Normal file
81
tests/market-dom-sync.test.ts
Normal 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;
|
||||
}
|
||||
57
tests/market-id-extractor.test.ts
Normal file
57
tests/market-id-extractor.test.ts
Normal 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;
|
||||
}
|
||||
86
tests/market-row-render.test.ts
Normal file
86
tests/market-row-render.test.ts
Normal 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;
|
||||
}
|
||||
@ -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("详情页控制台实验");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user