Fix silent market CSV export

This commit is contained in:
2026-04-29 11:33:00 +08:00
parent fe60253cd3
commit 07d1dffe78
12 changed files with 2255 additions and 171 deletions
+1
View File
@@ -9,6 +9,7 @@ describe("manifest", () => {
expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/)
])
);
expect(manifest.content_scripts?.[0]?.run_at).toBe("document_start");
});
test("declares the downloads and auth permissions plus background worker", () => {
+296
View File
@@ -11,6 +11,7 @@ describe("market-content-entry", () => {
beforeEach(() => {
document.body.innerHTML = "";
document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
document.documentElement.removeAttribute("data-test-page-index");
window.history.replaceState({}, "", "/");
});
@@ -33,7 +34,13 @@ describe("market-content-entry", () => {
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
delete (
globalThis as typeof globalThis & {
fetch?: unknown;
}
).fetch;
document.documentElement.removeAttribute("data-sces-market-rows");
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
document.documentElement.removeAttribute("data-test-page-index");
vi.resetModules();
@@ -95,6 +102,40 @@ describe("market-content-entry", () => {
).not.toBeNull();
});
test("installs the market bridge before auth state resolves", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
let resolveAuthState: ((value: unknown) => void) | null = null;
window.history.replaceState({}, "", "/ad/creator/market");
const { bootContentScript } = await import("../src/content/index");
const bootPromise = bootContentScript({
createMarketController,
sendAuthMessage: vi.fn(
() =>
new Promise((resolve) => {
resolveAuthState = resolve;
})
)
});
expect(
document.documentElement.querySelector('[data-sces-market-bridge="script"]')
).not.toBeNull();
expect(createMarketController).not.toHaveBeenCalled();
resolveAuthState?.({
ok: true,
type: "auth:state",
value: { isAuthenticated: true }
});
await bootPromise;
expect(createMarketController).toHaveBeenCalledTimes(1);
});
test("boots the market controller on the www Xingtu market URL", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
@@ -657,6 +698,104 @@ describe("market-content-entry", () => {
]);
});
test("keeps all plugin columns in loading state until backend metrics are ready", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
const backendDeferred = createDeferred<
Array<{
afterViewSearchRate: string;
starId: string;
}>
>();
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async (authorId) => ({
success: true,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
searchBackendMetrics: () => backendDeferred.promise,
window
}));
await flushWithTimers();
expect(readDivPluginRowTexts(0)).toEqual([
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中..."
]);
expect(readDivPluginRowTexts(1)).toEqual([
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中...",
"加载中..."
]);
backendDeferred.resolve([
{
afterViewSearchRate: "0.36%",
starId: "111"
},
{
afterViewSearchRate: "1.4%",
starId: "222"
}
]);
await controller.ready;
expect(readDivPluginRowTexts(0)).toEqual([
"0.02%",
"0.03% - 0.2%",
"0.36%",
"",
"",
"",
"",
""
]);
expect(readDivPluginRowTexts(1)).toEqual([
"0.5% - 1%",
"0.01% - 0.1%",
"1.4%",
"",
"",
"",
"",
""
]);
});
test("hydrates real rows from serialized market rows when vue state is unavailable", async () => {
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
{
@@ -1050,6 +1189,125 @@ describe("market-content-entry", () => {
15000
);
test(
"default export replays captured market requests silently without paging the visible table",
async () => {
const pages = [
[{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }],
[{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }],
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }],
[{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }],
[{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }]
];
document.body.innerHTML = buildRealMarketFixture(pages[0]);
const pagination = installAsyncPaginationHarness(pages);
const buildCsv = vi.fn(() => "csv-output");
const onCsvReady = vi.fn();
const fetchMock = vi.fn(async (_input: string, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
const pageIndex = Math.max((body.page ?? 1) - 1, 0);
return {
json: async () => ({
data: {
marketList: buildMarketListResponseRows(pages[pageIndex] ?? [])
}
}),
ok: true
};
});
(
globalThis as typeof globalThis & {
fetch?: typeof fetchMock;
}
).fetch = fetchMock;
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page: 1
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady,
window
}));
await controller.ready;
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 100);
expect(pagination.getClicks()).toBe(0);
expect(fetchMock).toHaveBeenCalledTimes(5);
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
"111",
"222",
"333",
"444",
"555"
]);
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
},
15000
);
test(
"default export falls back to visible pagination when no captured market request is available",
async () => {
const pages = [
[{ authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" }],
[{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }],
[{ authorId: "333", authorName: "达人 C", price21To60s: "¥33,000" }],
[{ authorId: "444", authorName: "达人 D", price21To60s: "¥44,000" }],
[{ authorId: "555", authorName: "达人 E", price21To60s: "¥55,000" }]
];
document.body.innerHTML = buildRealMarketFixture(pages[0]);
const pagination = installAsyncPaginationHarness(pages);
const buildCsv = vi.fn(() => "csv-output");
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
buildCsv,
document,
loadAuthorMetrics: async () => ({
success: false,
reason: "request-failed"
}),
onCsvReady: vi.fn(),
window
}));
await controller.ready;
click('[data-plugin-export="button"]');
await waitForMockCall(buildCsv, 120, 100);
expect(pagination.getClicks()).toBe(4);
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
"111",
"222",
"333",
"444",
"555"
]);
},
15000
);
test(
"default export waits for the next page rows instead of only the pager state",
async () => {
@@ -2663,6 +2921,29 @@ function attachMarketListState(
});
}
function buildMarketListResponseRows(
rows: Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
): Array<Record<string, unknown>> {
return rows.map((row) => ({
attribute_datas: {
items: JSON.stringify([
{
title: `代表视频${row.authorName}`
}
]),
nick_name: row.authorName,
nickname: row.authorName,
price_20_60: Number(row.price21To60s.replace(/[^\d]/g, ""))
},
nick_name: row.authorName,
star_id: row.authorId
}));
}
function installPaginationHarness(
pages: Array<
Array<{
@@ -3638,3 +3919,18 @@ async function waitForMockCall(
await flushWithTimers();
}
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return {
promise,
reject,
resolve
};
}
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url":"https://www.xingtu.cn/ad/creator/market"}
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
describe("market-page-bridge", () => {
beforeEach(() => {
document.body.innerHTML = '<div class="base-author-list"></div>';
document.documentElement.removeAttribute("data-sces-market-request-snapshot");
vi.spyOn(window, "setInterval").mockReturnValue(0 as unknown as number);
});
afterEach(() => {
delete (
window as Window & {
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
vi.restoreAllMocks();
vi.resetModules();
});
test("ignores non-search market list responses when capturing the export snapshot", async () => {
const originalFetch = vi.fn(async () => ({
clone() {
return this;
},
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "推荐达人"
},
star_id: "recommend-1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
})
}));
(
window as Window & {
fetch: typeof fetch;
}
).fetch = originalFetch as unknown as typeof fetch;
await import("../src/content/market/page-bridge");
await window.fetch("/gw/api/gauthor/demander_get_recommend_author_lists_v2", {
headers: {
Accept: "application/json, text/plain, */*"
},
method: "GET"
});
await Promise.resolve();
await Promise.resolve();
expect(
document.documentElement.getAttribute("data-sces-market-request-snapshot")
).toBeNull();
});
test("captures the search_for_author_square request for silent export", async () => {
const originalFetch = vi.fn(async () => ({
clone() {
return this;
},
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "搜索达人"
},
star_id: "search-1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
})
}));
(
window as Window & {
fetch: typeof fetch;
}
).fetch = originalFetch as unknown as typeof fetch;
await import("../src/content/market/page-bridge");
await window.fetch("/gw/api/gsearch/search_for_author_square", {
body: JSON.stringify({
page_param: {
page: "1"
}
}),
headers: {
"Content-Type": "application/json"
},
method: "POST"
});
await Promise.resolve();
await Promise.resolve();
expect(
JSON.parse(
document.documentElement.getAttribute(
"data-sces-market-request-snapshot"
) ?? "null"
)
).toEqual(
expect.objectContaining({
body: JSON.stringify({
page_param: {
page: "1"
}
}),
method: "POST",
url: "/gw/api/gsearch/search_for_author_square"
})
);
});
});
+263
View File
@@ -0,0 +1,263 @@
// @vitest-environment jsdom
import { describe, expect, test } from "vitest";
import { createSilentExportController } from "../src/content/market/silent-export-controller";
describe("silent-export-controller", () => {
test("replays exports from the live market page state when the request snapshot attribute is missing", async () => {
document.body.innerHTML = '<div class="base-author-list"></div>';
const marketRoot = document.querySelector(".base-author-list") as HTMLElement & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
};
marketRoot.__vue__ = {
_setupState: {
__$temp_1: {
reqParams: {
scene_param: {
platform_source: 1
},
page_param: {
limit: "20",
page: "2"
},
search_param: {
seach_type: 3
}
}
}
}
};
const requestedPages: number[] = [];
const requestedHeaders: Array<HeadersInit | undefined> = [];
const requestedUrls: string[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number | string };
};
const pageNo = Number(body.page_param?.page ?? 0);
requestedPages.push(pageNo);
requestedHeaders.push(init?.headers);
requestedUrls.push(url);
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: `达人${pageNo}`,
price_20_60: pageNo * 1000
},
star_id: String(pageNo)
}
],
pagination: {
limit: 20,
page: pageNo,
total_count: 100
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([2, 3]);
expect(
requestedUrls.every((url) =>
url.endsWith("/gw/api/gsearch/search_for_author_square")
)
).toBe(true);
expect(requestedHeaders).toEqual([
{
Accept: "application/json, text/plain, */*",
"Agw-Js-Conv": "str",
"Content-Type": "application/json",
"x-login-source": "1"
},
{
Accept: "application/json, text/plain, */*",
"Agw-Js-Conv": "str",
"Content-Type": "application/json",
"x-login-source": "1"
}
]);
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("replays paged exports when the page number is nested inside the request body", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: 2
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number };
};
const pageNo = body.page_param?.page ?? 0;
requestedPages.push(pageNo);
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: `达人${pageNo}`,
price_20_60: pageNo * 1000
},
star_id: String(pageNo)
}
],
pagination: {
limit: 20,
page: pageNo,
total_count: 100
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([2, 3]);
expect(records?.map((record) => record.authorId)).toEqual(["2", "3"]);
});
test("starts from page 1 when the captured request omits an explicit page number", async () => {
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
filters: {
keyword: "test"
}
}),
method: "POST",
url: "https://xingtu.cn/api/mock-market-search"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as { page?: number };
const page = body.page ?? 0;
requestedPages.push(page);
return {
json: async () => ({
data: {
marketList: [
{
attribute_datas: {
nickname: `达人${page}`,
price_20_60: page * 1000
},
star_id: String(page)
}
]
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 2
});
expect(requestedPages).toEqual([1, 2]);
expect(records?.map((record) => record.authorId)).toEqual(["1", "2"]);
});
test("accepts snapshot headers that contain non-string values from the live XHR capture", async () => {
document.body.innerHTML = "";
document.documentElement.setAttribute(
"data-sces-market-request-snapshot",
JSON.stringify({
body: JSON.stringify({
page_param: {
page: "1",
limit: "20"
}
}),
headers: {
Accept: "application/json, text/plain, */*",
"Content-Type": "application/json",
"x-login-source": 1,
"Agw-Js-Conv": "str"
},
method: "POST",
url: "/gw/api/gsearch/search_for_author_square"
})
);
const requestedPages: number[] = [];
const controller = createSilentExportController({
document,
fetchImpl: async (_url, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as {
page_param?: { page?: number | string };
};
requestedPages.push(Number(body.page_param?.page ?? 0));
return {
json: async () => ({
authors: [
{
attribute_datas: {
nickname: "达人1"
},
star_id: "1"
}
],
pagination: {
limit: 20,
page: 1,
total_count: 20
}
}),
ok: true
};
}
});
const records = await controller.exportRecords({
mode: "count",
pageCount: 1
});
expect(requestedPages).toEqual([1]);
expect(records?.map((record) => record.authorId)).toEqual(["1"]);
});
});