feat: stabilize Xingtu market data sync

This commit is contained in:
2026-04-21 14:16:29 +08:00
parent 85e835a96a
commit 55324a5bb7
19 changed files with 2150 additions and 1366 deletions
+655 -9
View File
@@ -1,16 +1,68 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url":"https://xingtu.cn/"}
import { beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store";
const disposers: Array<() => void> = [];
describe("market-content-entry", () => {
beforeEach(() => {
document.body.innerHTML = "";
document.documentElement.removeAttribute("data-sces-market-rows");
window.history.replaceState({}, "", "/");
});
afterEach(() => {
vi.doUnmock("../src/content/market/index");
delete (
globalThis as typeof globalThis & {
chrome?: unknown;
}
).chrome;
delete (
window as Window & {
__starChartSearchEnhancerContentController?: unknown;
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__starChartSearchEnhancerContentController;
delete (
window as Window & {
__SCES_MARKET_PAGE_BRIDGE_INSTALLED__?: boolean;
}
).__SCES_MARKET_PAGE_BRIDGE_INSTALLED__;
document.documentElement.removeAttribute("data-sces-market-rows");
vi.resetModules();
while (disposers.length > 0) {
disposers.pop()?.();
}
});
test("auto boots on import when chrome runtime is available", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
window.history.replaceState({}, "", "/ad/creator/market");
(
globalThis as typeof globalThis & {
chrome?: { runtime?: object };
}
).chrome = {
runtime: {}
};
vi.doMock("../src/content/market/index", () => ({
createMarketController
}));
await import("../src/content/index");
expect(createMarketController).toHaveBeenCalledTimes(1);
});
test("boots the market controller on the Xingtu market URL", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
@@ -23,6 +75,28 @@ describe("market-content-entry", () => {
createMarketController
});
expect(createMarketController).toHaveBeenCalledTimes(1);
expect(
document.documentElement.querySelector('[data-sces-market-bridge="script"]')
).not.toBeNull();
});
test("boots the market controller on the www Xingtu market URL", async () => {
const createMarketController = vi.fn(() => ({
ready: Promise.resolve()
}));
const { bootContentScript } = await import("../src/content/index");
await bootContentScript({
createMarketController,
document,
window: {
location: {
href: "https://www.xingtu.cn/ad/creator/market"
}
} as Window
});
expect(createMarketController).toHaveBeenCalledTimes(1);
});
@@ -30,7 +104,7 @@ describe("market-content-entry", () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = createMarketController({
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async (authorId) => ({
success: true,
@@ -46,7 +120,7 @@ describe("market-content-entry", () => {
}
}),
window
});
}));
await controller.ready;
@@ -60,6 +134,182 @@ describe("market-content-entry", () => {
).toBe("0.03% - 0.2%");
});
test("hydrates the real div-grid market rows on start", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics: async (authorId) => ({
success: true,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}),
window
}));
await controller.ready;
expect(readDivRightRowTexts(0)).toEqual([
"¥450,000",
"0.02% - 0.1%",
"0.03% - 0.2%",
"下单"
]);
expect(readDivRightRowTexts(1)).toEqual([
"¥20,000",
"0.5% - 1%",
"0.01% - 0.1%",
"下单"
]);
});
test("uses the market list single-rate directly and still loads the missing personal rate", async () => {
document.body.innerHTML = buildRealMarketFixture([
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
attachMarketListState([
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.0002",
nickname: "达人 A"
},
star_id: "111"
},
{
attribute_datas: {
nickname: "达人 B"
},
star_id: "222"
}
]);
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics,
window
}));
await controller.ready;
expect(loadAuthorMetrics).toHaveBeenCalledTimes(2);
expect(readDivRightRowTexts(0)).toEqual([
"¥450,000",
"0.02%",
"0.03% - 0.2%",
"下单"
]);
expect(readDivRightRowTexts(1)).toEqual([
"¥20,000",
"0.5% - 1%",
"0.01% - 0.1%",
"下单"
]);
});
test("hydrates real rows from serialized market rows when vue state is unavailable", async () => {
document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([
{
authorName: "达人 A",
price21To60s: "¥450,000"
},
{
authorName: "达人 B",
price21To60s: "¥20,000"
}
]);
document.documentElement.setAttribute(
"data-sces-market-rows",
JSON.stringify([
{
authorId: "111",
authorName: "达人 A",
singleVideoAfterSearchRate: "0.02%"
},
{
authorId: "222",
authorName: "达人 B"
}
])
);
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%"
}
}),
window
}));
await controller.ready;
expect(readDivRightRowTexts(0)).toEqual([
"¥450,000",
"0.02%",
"0.03% - 0.2%",
"下单"
]);
expect(readDivRightRowTexts(1)).toEqual([
"¥20,000",
"0.5% - 1%",
"0.01% - 0.1%",
"下单"
]);
});
test("applying plugin filters triggers full scan and hides non-matching rows", async () => {
document.body.innerHTML = buildMarketFixture();
const resultStore = createMarketResultStore();
@@ -75,7 +325,7 @@ describe("market-content-entry", () => {
});
const { createMarketController } = await import("../src/content/market/index");
const controller = createMarketController({
const controller = trackController(createMarketController({
document,
fullScanController: {
ensureScanForExport: vi.fn(async () => {}),
@@ -88,7 +338,7 @@ describe("market-content-entry", () => {
}),
resultStore,
window
});
}));
await controller.ready;
setInputValue('[data-plugin-filter-single="input"]', "0.1");
@@ -119,7 +369,7 @@ describe("market-content-entry", () => {
});
const { createMarketController } = await import("../src/content/market/index");
const controller = createMarketController({
const controller = trackController(createMarketController({
document,
fullScanController: {
ensureScanForExport: vi.fn(async () => {}),
@@ -132,7 +382,7 @@ describe("market-content-entry", () => {
}),
resultStore,
window
});
}));
await controller.ready;
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
@@ -161,7 +411,7 @@ describe("market-content-entry", () => {
});
const { createMarketController } = await import("../src/content/market/index");
const controller = createMarketController({
const controller = trackController(createMarketController({
buildCsv,
document,
fullScanController: {
@@ -176,7 +426,7 @@ describe("market-content-entry", () => {
onCsvReady,
resultStore,
window
});
}));
await controller.ready;
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
@@ -199,6 +449,105 @@ describe("market-content-entry", () => {
]);
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
});
test("rehydrates rows when the market list DOM changes", async () => {
document.body.innerHTML = buildMarketFixture();
const observer = createMutationObserverFactory();
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "a"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.8%-1%",
personalVideoAfterSearchRate: "0.05% - 0.2%"
}
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics,
mutationObserverFactory: observer.factory,
window
}));
await controller.ready;
document.querySelector("[data-market-body]")!.innerHTML = `
<div data-market-row="c" data-author-id="c">
<span data-market-field="authorName">Gamma</span>
<span data-market-field="price21To60s">88000</span>
</div>
`;
observer.trigger();
await flushWithTimers();
await flushWithTimers();
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual(
expect.arrayContaining(["a", "c"])
);
expect(readRowOrder()).toEqual(["c"]);
expect(
document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]')
?.textContent
).toBe("0.8% - 1%");
});
test("default full scan walks the real market pagination when applying a filter", async () => {
const pages = [
[
{
authorId: "111",
authorName: "达人 A",
price21To60s: "¥450,000"
}
],
[
{
authorId: "222",
authorName: "达人 B",
price21To60s: "¥20,000"
}
]
];
document.body.innerHTML = buildRealMarketFixture(pages[0]);
const pagination = installPaginationHarness(pages);
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "111"
? {
singleVideoAfterSearchRate: "0.02% - 0.1%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}));
const { createMarketController } = await import("../src/content/market/index");
const controller = trackController(createMarketController({
document,
loadAuthorMetrics,
window
}));
await controller.ready;
setInputValue('[data-plugin-filter-single="input"]', "0.1");
click('[data-plugin-filter-apply="button"]');
await flush();
await flush();
expect(pagination.getClicks()).toBe(1);
expect(loadAuthorMetrics.mock.calls.map(([authorId]) => authorId)).toEqual(
expect.arrayContaining(["111", "222"])
);
});
});
function buildMarketFixture() {
@@ -222,6 +571,281 @@ function buildMarketFixture() {
`;
}
function buildRealMarketFixture(
rows: Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
) {
return `
<div class="base-author-list" data-testid="market-root">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;">代表视频</div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div data-testid="author-section" class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
${rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("")}
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">代表视频${row.authorName}</div>
`
)
.join("")}
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("")}
</div>
<div class="content-column" style="min-width: 200px;">
${rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("")}
</div>
</div>
</div>
</div>
<button data-testid="next-page" type="button">下一页</button>
`;
}
function buildRealMarketFixtureWithoutAuthorIds(
rows: Array<{
authorName: string;
price21To60s: string;
}>
) {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;">达人信息</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;">代表视频</div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;">操作</div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">
<div class="author-info-column">
<span class="author-nickname">${row.authorName}</span>
</div>
</div>
`
)
.join("")}
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
${rows
.map(
(_, index) => `
<div class="content-cell" style="height: 120px;">代表视频${index + 1}</div>
`
)
.join("")}
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
${rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("")}
</div>
<div class="content-column" style="min-width: 200px;">
${rows
.map(
() => `
<div class="content-cell" style="height: 120px;">下单</div>
`
)
.join("")}
</div>
</div>
</div>
</div>
`;
}
function attachMarketListState(
marketList: Array<{
attribute_datas?: {
avg_search_after_view_rate_30d?: string;
nickname?: string;
};
star_id?: string;
}>
) {
const marketRoot = document.querySelector('[data-testid="market-root"]');
if (!(marketRoot instanceof HTMLElement)) {
throw new Error("Missing market root");
}
Object.defineProperty(marketRoot, "__vue__", {
configurable: true,
value: {
_setupState: {
__$temp_1: {
marketList
}
}
}
});
}
function installPaginationHarness(
pages: Array<
Array<{
authorId: string;
authorName: string;
price21To60s: string;
}>
>
) {
let pageIndex = 0;
let clicks = 0;
const nextButton = document.querySelector(
'[data-testid="next-page"]'
) as HTMLButtonElement | null;
if (!nextButton) {
throw new Error("Missing next page button");
}
const renderPage = () => {
const authorColumn = document.querySelector(
'[data-testid="author-section"] .content-column'
) as HTMLElement | null;
const middleColumn = document.querySelector(
'.middle-columns .content-column'
) as HTMLElement | null;
const rightColumns = document.querySelectorAll(
'[data-testid="right-section"] > .content-column'
);
if (!authorColumn || !middleColumn || rightColumns.length < 2) {
throw new Error("Missing market columns for pagination harness");
}
const rows = pages[pageIndex];
authorColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="author-cell-${row.authorId}" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/${row.authorId}">${row.authorName}</a>
</div>
`
)
.join("");
middleColumn.innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">代表视频${row.authorName}</div>
`
)
.join("");
(rightColumns[0] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" style="height: 120px;">${row.price21To60s}</div>
`
)
.join("");
(rightColumns[1] as HTMLElement).innerHTML = rows
.map(
(row) => `
<div class="content-cell" data-testid="action-cell-${row.authorId}" data-author-id="${row.authorId}" style="height: 120px;">下单</div>
`
)
.join("");
nextButton.disabled = pageIndex >= pages.length - 1;
nextButton.setAttribute("aria-disabled", nextButton.disabled ? "true" : "false");
};
nextButton.addEventListener("click", () => {
if (pageIndex >= pages.length - 1) {
return;
}
clicks += 1;
pageIndex += 1;
renderPage();
});
renderPage();
return {
getClicks() {
return clicks;
}
};
}
function createMutationObserverFactory() {
let callback: MutationCallback = () => undefined;
return {
factory(nextCallback: MutationCallback) {
callback = nextCallback;
return {
disconnect() {},
observe() {}
};
},
trigger() {
callback([], {} as MutationObserver);
}
};
}
function click(selector: string) {
const element = document.querySelector(selector) as HTMLButtonElement | null;
if (!element) {
@@ -255,7 +879,29 @@ function readRowOrder() {
);
}
function readDivRightRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
);
}
function trackController<T extends { dispose?: () => void }>(controller: T): T {
if (controller.dispose) {
disposers.push(() => controller.dispose?.());
}
return controller;
}
async function flush() {
await Promise.resolve();
await Promise.resolve();
}
async function flushWithTimers() {
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}