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
+57
View File
@@ -107,6 +107,63 @@ describe("full-scan-controller", () => {
status: "success"
});
});
test("uses current page market-list rates and still loads missing metrics", async () => {
const store = createMarketResultStore();
const loadAuthorMetrics = vi.fn(async (authorId: string) => ({
success: true as const,
rates:
authorId === "a"
? {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
}));
const controller = createFullScanController({
goToNextPage: async () => false,
hasNextPage: () => false,
loadAuthorMetrics,
readCurrentPageRows: vi.fn(() => [
{
authorId: "a",
authorName: "Alpha",
hasDirectRatesSource: true,
rates: {
singleVideoAfterSearchRate: "0.02%"
}
},
{
authorId: "b",
authorName: "Beta",
hasDirectRatesSource: true
}
]),
resultStore: store
});
await controller.ensureScanForFilter();
expect(loadAuthorMetrics).toHaveBeenCalledTimes(2);
expect(store.getRecord("a")).toMatchObject({
status: "success",
rates: {
singleVideoAfterSearchRate: "0.02%",
personalVideoAfterSearchRate: "0.03% - 0.2%"
}
});
expect(store.getRecord("b")).toMatchObject({
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.01% - 0.1%"
}
});
});
});
function createHarness() {
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, test } from "vitest";
import manifest from "../src/manifest.json";
describe("manifest", () => {
test("injects the content script on the www Xingtu market page", () => {
expect(manifest.content_scripts?.[0]?.matches).toEqual(
expect.arrayContaining([
expect.stringMatching(/^https:\/\/(\*\.|www\.)?xingtu\.cn\/ad\/creator\/market\*$/)
])
);
});
});
+77
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import {
buildAuthorAseInfoUrl,
buildAuthorCommerceSeedBaseInfoUrl,
createMarketApiClient,
mapAuthorAseInfoResponse
} from "../src/content/market/api-client";
@@ -59,6 +60,74 @@ describe("market-api-client", () => {
});
});
test("loads rates from the commerce seed endpoint first", async () => {
const requestedUrls: string[] = [];
const client = createMarketApiClient({
fetchImpl: async (input) => {
requestedUrls.push(input);
return {
ok: true,
json: async () => ({
avg_search_after_view_rate: "0.1% - 0.25%",
personal_avg_search_after_view_rate: "0.02% - 0.1%"
})
};
}
});
await expect(client.loadAuthorAseInfo("7363217488772857856")).resolves.toMatchObject(
{
success: true,
rates: {
singleVideoAfterSearchRate: "0.1% - 0.25%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
}
);
expect(requestedUrls).toEqual([
"https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90"
]);
});
test("falls back to the ASE endpoint when the commerce seed endpoint fails", async () => {
const requestedUrls: string[] = [];
const client = createMarketApiClient({
fetchImpl: async (input) => {
requestedUrls.push(input);
if (input.includes("get_author_commerce_seed_base_info")) {
return {
ok: false,
json: async () => ({})
};
}
return {
ok: true,
json: async () => ({
data: {
avg_search_after_view_rate: "<0.02%",
personal_avg_search_after_view_rate: "0.02 - 0.1%"
}
})
};
}
});
await expect(client.loadAuthorAseInfo("7363217488772857856")).resolves.toMatchObject(
{
success: true,
rates: {
singleVideoAfterSearchRate: "<0.02%",
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
}
);
expect(requestedUrls).toEqual([
"https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90",
"https://xingtu.cn/gw/api/aggregator/get_author_ase_info?author_id=7363217488772857856&range=30"
]);
});
test("returns a timeout result when the request aborts", async () => {
const client = createMarketApiClient({
fetchImpl: async () => {
@@ -72,4 +141,12 @@ describe("market-api-client", () => {
reason: "timeout"
});
});
test("builds the author commerce seed info url with author id and range", () => {
expect(
buildAuthorCommerceSeedBaseInfoUrl("7363217488772857856", "https://xingtu.cn")
).toBe(
"https://xingtu.cn/gw/api/aggregator/get_author_commerce_seed_base_info?o_author_id=7363217488772857856&range=90"
);
});
});
+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));
}
+239
View File
@@ -110,4 +110,243 @@ describe("market-dom-sync", () => {
).map((row) => row.getAttribute("data-author-id"))
).toEqual(["b", "a"]);
});
test("supports the real div-grid market layout and keeps rows aligned", () => {
document.body.innerHTML = buildRealMarketGridFixture();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(readRightHeaderTexts()).toEqual([
"21-60s报价",
"单视频看后搜率",
"个人视频看后搜率",
"操作"
]);
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
renderMarketRowState(table.rows[0], {
authorId: "111",
authorName: "达人 A",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
});
expect(readRightRowTexts(0)).toEqual([
"¥450,000",
"0.5% - 1%",
"0.02% - 0.1%",
"下单"
]);
applyRowVisibility(table, new Set(["222"]));
expect(readAuthorRowHiddenStates()).toEqual([true, false]);
expect(readRightActionHiddenStates()).toEqual([true, false]);
applyRowVisibility(table, new Set(["111", "222"]));
applyRowOrder(table, ["222", "111"]);
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "下单"]);
});
test("falls back to the market vue state when the DOM has no author id", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
attachMarketVueState([
{
attribute_datas: {
nickname: "达人 A"
},
star_id: "111"
},
{
attribute_datas: {
nickname: "达人 B"
},
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
});
test("falls back to serialized market rows when vue state is unavailable", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
document.documentElement.setAttribute(
"data-sces-market-rows",
JSON.stringify([
{
authorId: "111",
authorName: "达人 A",
singleVideoAfterSearchRate: "0.02%"
},
{
authorId: "222",
authorName: "达人 B"
}
])
);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
expect(table.rows[0].rates).toEqual({
singleVideoAfterSearchRate: "0.02%"
});
});
});
function buildRealMarketGridFixture() {
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;">
<div class="content-cell" data-testid="author-cell-111" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111">达人 A</a>
</div>
<div class="content-cell" data-testid="author-cell-222" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222">达人 B</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">代表视频A</div>
<div class="content-cell" style="height: 120px;">代表视频B</div>
</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;">
<div class="content-cell" style="height: 120px;">¥450,000</div>
<div class="content-cell" style="height: 120px;">¥20,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" data-testid="action-cell-111" style="height: 120px;">下单</div>
<div class="content-cell" data-testid="action-cell-222" style="height: 120px;">下单</div>
</div>
</div>
</div>
</div>
`;
}
function buildRealMarketGridFixtureWithoutAuthorIds() {
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 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;">
<div class="content-cell" data-testid="author-cell-1" style="height: 120px;">
<span class="author-nickname">达人 A</span>
</div>
<div class="content-cell" data-testid="author-cell-2" style="height: 120px;">
<span class="author-nickname">达人 B</span>
</div>
</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;">
<div class="content-cell" style="height: 120px;">¥450,000</div>
<div class="content-cell" style="height: 120px;">¥20,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" data-testid="action-cell-1" style="height: 120px;">下单</div>
<div class="content-cell" data-testid="action-cell-2" style="height: 120px;">下单</div>
</div>
</div>
</div>
</div>
`;
}
function attachMarketVueState(
marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }>
) {
const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) {
throw new Error("Expected market root");
}
Object.defineProperty(marketRoot, "__vue__", {
configurable: true,
value: {
_setupState: {
__$temp_1: {
marketList
}
}
}
});
}
function readRightHeaderTexts() {
return Array.from(
document.querySelectorAll('[data-testid="right-header"] > *'),
(cell) => cell.textContent?.trim() ?? ""
);
}
function readRightRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
);
}
function readAuthorNames() {
return Array.from(
document.querySelectorAll('[data-testid="author-section"] .content-cell a'),
(link) => link.textContent?.trim() ?? ""
);
}
function readAuthorRowHiddenStates() {
return Array.from(
document.querySelectorAll('[data-testid^="author-cell-"]'),
(cell) => (cell as HTMLElement).hidden
);
}
function readRightActionHiddenStates() {
return Array.from(
document.querySelectorAll('[data-testid^="action-cell-"]'),
(cell) => (cell as HTMLElement).hidden
);
}
+7
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import {
compareRateValues,
normalizeFractionRateDisplay,
normalizeRateDisplay,
parseRateLowerBound
} from "../src/shared/rate-normalizer";
@@ -26,4 +27,10 @@ describe("rate-normalizer", () => {
test("orders missing values after real values", () => {
expect(compareRateValues(null, "0.02% - 0.1%")).toBeGreaterThan(0);
});
test("normalizes fraction rate values into percentage display", () => {
expect(normalizeFractionRateDisplay("0.0002")).toBe("0.02%");
expect(normalizeFractionRateDisplay("0.0044")).toBe("0.44%");
expect(normalizeFractionRateDisplay("0")).toBe("0%");
});
});