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
|
# Star Chart Search Enhancer
|
||||||
|
|
||||||
一个最小化的 Chrome MV3 实验插件,用来在巨量星图的达人详情页拦截页面自己的网络响应,并尝试提取两个“看后搜率”指标。
|
一个最小化的 Chrome MV3 实验插件,用来增强巨量星图:
|
||||||
|
|
||||||
|
- 达人详情页:保留原有的详情页控制台实验链路
|
||||||
|
- 找达人列表页:在 `creator/market` 当前可见结果页中插入两列看后搜率
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
@ -20,14 +23,31 @@ npm run build
|
|||||||
|
|
||||||
## 手工验证
|
## 手工验证
|
||||||
|
|
||||||
|
### 详情页控制台实验
|
||||||
|
|
||||||
1. 打开巨量星图的达人详情页
|
1. 打开巨量星图的达人详情页
|
||||||
2. 刷新页面一次,确保内容脚本和页面 hook 都能尽早注入
|
2. 刷新页面一次,确保内容脚本和页面 hook 都能尽早注入
|
||||||
3. 打开该页面的 DevTools Console
|
3. 打开该页面的 DevTools Console
|
||||||
4. 观察是否出现带有 `[star-chart-search-enhancer]` 前缀的日志
|
4. 观察是否出现带有 `[star-chart-search-enhancer]` 前缀的日志
|
||||||
5. 找到 `result` 日志,核对其中两个看后搜率是否与达人详情页右侧展示一致
|
5. 找到 `result` 日志,核对其中两个看后搜率是否与达人详情页右侧展示一致
|
||||||
|
|
||||||
|
### 找达人列表页列增强
|
||||||
|
|
||||||
|
1. 打开 `https://xingtu.cn/ad/creator/market`
|
||||||
|
2. 等待当前列表页渲染完成
|
||||||
|
3. 确认 `操作` 列前新增了两列:
|
||||||
|
`单视频看后搜率`
|
||||||
|
`个人视频看后搜率`
|
||||||
|
4. 首次进入或翻页、筛选、搜索、排序变化后,新增列会先显示 `加载中...`
|
||||||
|
5. 请求成功后,两列会显示对应达人的真实值
|
||||||
|
6. 如果某行失败,两列都会显示 `加载失败`
|
||||||
|
7. 点击任一失败单元格,会按整行重试该达人并重新进入 `加载中...`
|
||||||
|
|
||||||
## 当前范围
|
## 当前范围
|
||||||
|
|
||||||
- 只支持巨量星图达人详情页
|
- 阶段 1 同时支持:
|
||||||
- 只输出到控制台,不改页面 UI
|
- 巨量星图达人详情页控制台实验
|
||||||
- 成功时输出结构化结果,超时也会输出一个明确失败结果
|
- 巨量星图找达人 `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 {
|
import {
|
||||||
isCandidateAnalysisMessage,
|
createDetailContentController,
|
||||||
isCandidateRequestMessage,
|
type DetailContentControllerOptions
|
||||||
isHookReadyMessage,
|
} from "./detail/index";
|
||||||
isAfterSearchRateResultMessage,
|
import {
|
||||||
RESULT_MESSAGE_TYPE
|
createMarketContentController,
|
||||||
} from "../shared/message-types";
|
type MarketContentControllerOptions
|
||||||
import type { AfterSearchRateResult } from "../shared/result-types";
|
} from "./market/index";
|
||||||
|
|
||||||
const LOG_PREFIX = "[star-chart-search-enhancer]";
|
interface ControllerLike {
|
||||||
const PAGE_HOOK_SCRIPT_ID = "star-chart-search-enhancer-page-hook";
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChromeRuntimeLike {
|
interface ChromeRuntimeLike {
|
||||||
getURL(path: string): string;
|
getURL(path: string): string;
|
||||||
@ -21,162 +23,45 @@ interface LoggerLike {
|
|||||||
warn(...args: unknown[]): void;
|
warn(...args: unknown[]): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ContentControllerOptions {
|
interface ContentControllerOptions extends DetailContentControllerOptions {
|
||||||
chromeRuntime: ChromeRuntimeLike;
|
detailControllerFactory?: (
|
||||||
document: Document;
|
options: DetailContentControllerOptions
|
||||||
logger: LoggerLike;
|
) => ControllerLike;
|
||||||
window: Window;
|
marketControllerFactory?: (
|
||||||
|
options: MarketContentControllerOptions
|
||||||
|
) => ControllerLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createContentController(options: ContentControllerOptions) {
|
export function createContentController(
|
||||||
const routeState = createRouteState(options.window.location.href);
|
options: ContentControllerOptions
|
||||||
let currentSnapshot = routeState.getSnapshot();
|
): ControllerLike {
|
||||||
const loggedResults = new Map<string, { fingerprint: string; success: boolean }>();
|
if (isCreatorDetailUrl(options.window.location.href)) {
|
||||||
|
const detailControllerFactory =
|
||||||
const originalPushState = options.window.history.pushState.bind(
|
options.detailControllerFactory ?? createDetailContentController;
|
||||||
options.window.history
|
return detailControllerFactory(options);
|
||||||
);
|
|
||||||
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 (isCreatorMarketUrl(options.window.location.href)) {
|
||||||
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
|
const marketControllerFactory =
|
||||||
options.logger.info(LOG_PREFIX, "hook-ready", event.data.payload);
|
options.marketControllerFactory ?? createMarketContentController;
|
||||||
} else {
|
return marketControllerFactory(options);
|
||||||
options.logger.debug(
|
|
||||||
LOG_PREFIX,
|
|
||||||
"stale-hook-ready",
|
|
||||||
event.data.payload.routeKey
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCandidateRequestMessage(event.data)) {
|
return createNoopController();
|
||||||
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)) {
|
function isCreatorDetailUrl(url: string): boolean {
|
||||||
if (isSameRouteIdentity(event.data.payload.routeKey, currentSnapshot.routeKey)) {
|
return getStarIdFromUrl(url) !== null;
|
||||||
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 createNoopController(): ControllerLike {
|
||||||
return {
|
return {
|
||||||
dispose() {
|
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 {
|
function isCreatorMarketUrl(url: string): boolean {
|
||||||
return stripNavigationSeq(leftRouteKey) === stripNavigationSeq(rightRouteKey);
|
return new URL(url).pathname === "/ad/creator/market";
|
||||||
}
|
|
||||||
|
|
||||||
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 bootstrapContentScript() {
|
function bootstrapContentScript() {
|
||||||
@ -192,7 +77,7 @@ function bootstrapContentScript() {
|
|||||||
|
|
||||||
const marker = "__starChartSearchEnhancerContentController";
|
const marker = "__starChartSearchEnhancerContentController";
|
||||||
const scopedWindow = window as Window & {
|
const scopedWindow = window as Window & {
|
||||||
[marker]?: ReturnType<typeof createContentController>;
|
[marker]?: ControllerLike;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (scopedWindow[marker]) {
|
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.",
|
"description": "Experimentally capture after-search rates on Xingtu creator detail pages.",
|
||||||
"content_scripts": [
|
"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"],
|
"js": ["content/index.global.js"],
|
||||||
"run_at": "document_start"
|
"run_at": "document_start"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { normalizeRateLabel } from "./normalize-rate-label";
|
import { normalizeRateLabel } from "./normalize-rate-label";
|
||||||
|
import { normalizeRateValue } from "./normalize-rate-value";
|
||||||
import type {
|
import type {
|
||||||
AfterSearchRateField,
|
AfterSearchRateField,
|
||||||
AfterSearchRates,
|
AfterSearchRates,
|
||||||
@ -7,7 +8,6 @@ import type {
|
|||||||
|
|
||||||
type JsonRecord = Record<string, unknown>;
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
const RATE_RANGE_PATTERN = /\d+(?:\.\d+)?%\s*-\s*\d+(?:\.\d+)?%/;
|
|
||||||
const SINGLE_TEXT_PATTERN =
|
const SINGLE_TEXT_PATTERN =
|
||||||
/(?:单条视频看后搜率|单视频看后搜率)\s*([0-9.]+%\s*-\s*[0-9.]+%)/;
|
/(?:单条视频看后搜率|单视频看后搜率)\s*([0-9.]+%\s*-\s*[0-9.]+%)/;
|
||||||
const PERSONAL_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;
|
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 {
|
function isRecord(value: unknown): value is JsonRecord {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
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 });
|
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 raw = await readFile(manifestPath, "utf8");
|
||||||
const manifest = JSON.parse(raw);
|
const manifest = JSON.parse(raw);
|
||||||
|
|
||||||
@ -25,7 +25,10 @@ describe("build layout", () => {
|
|||||||
expect(manifest.content_scripts).toEqual(
|
expect(manifest.content_scripts).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
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(detailControllerFactory).not.toHaveBeenCalled();
|
||||||
expect(() => controller.dispose()).not.toThrow();
|
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) {
|
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");
|
const readmePath = path.resolve(__dirname, "..", "README.md");
|
||||||
|
|
||||||
describe("README", () => {
|
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");
|
const readme = await readFile(readmePath, "utf8");
|
||||||
|
|
||||||
expect(readme).toContain("npm install");
|
expect(readme).toContain("npm install");
|
||||||
@ -17,5 +17,12 @@ describe("README", () => {
|
|||||||
expect(readme).toContain("dist/");
|
expect(readme).toContain("dist/");
|
||||||
expect(readme).toContain("DevTools Console");
|
expect(readme).toContain("DevTools Console");
|
||||||
expect(readme).toContain("达人详情页");
|
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