feat: stabilize Xingtu market data sync
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user