908 lines
27 KiB
TypeScript
908 lines
27 KiB
TypeScript
// @vitest-environment jsdom
|
|
// @vitest-environment-options {"url":"https://xingtu.cn/"}
|
|
|
|
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()
|
|
}));
|
|
|
|
window.history.replaceState({}, "", "/ad/creator/market");
|
|
|
|
const { bootContentScript } = await import("../src/content/index");
|
|
await bootContentScript({
|
|
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);
|
|
});
|
|
|
|
test("hydrates current page rows on start", async () => {
|
|
document.body.innerHTML = buildMarketFixture();
|
|
|
|
const { createMarketController } = await import("../src/content/market/index");
|
|
const controller = trackController(createMarketController({
|
|
document,
|
|
loadAuthorMetrics: async (authorId) => ({
|
|
success: true,
|
|
rates:
|
|
authorId === "a"
|
|
? {
|
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
|
}
|
|
: {
|
|
singleVideoAfterSearchRate: "0.5%-1%",
|
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
|
}
|
|
}),
|
|
window
|
|
}));
|
|
|
|
await controller.ready;
|
|
|
|
expect(
|
|
document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]')
|
|
?.textContent
|
|
).toBe("0.02% - 0.1%");
|
|
expect(
|
|
document.querySelector('[data-market-row-cell="personalVideoAfterSearchRate"]')
|
|
?.textContent
|
|
).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();
|
|
const ensureScanForFilter = vi.fn(async () => {
|
|
resultStore.setAuthorSuccess("a", {
|
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
|
});
|
|
resultStore.setAuthorSuccess("b", {
|
|
singleVideoAfterSearchRate: "0.5%-1%",
|
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
|
});
|
|
});
|
|
|
|
const { createMarketController } = await import("../src/content/market/index");
|
|
const controller = trackController(createMarketController({
|
|
document,
|
|
fullScanController: {
|
|
ensureScanForExport: vi.fn(async () => {}),
|
|
ensureScanForFilter,
|
|
ensureScanForSort: vi.fn(async () => {})
|
|
},
|
|
loadAuthorMetrics: async () => ({
|
|
success: false,
|
|
reason: "request-failed"
|
|
}),
|
|
resultStore,
|
|
window
|
|
}));
|
|
|
|
await controller.ready;
|
|
setInputValue('[data-plugin-filter-single="input"]', "0.1");
|
|
click('[data-plugin-filter-apply="button"]');
|
|
await flush();
|
|
|
|
expect(ensureScanForFilter).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
document.querySelector('[data-market-row="a"]')?.hasAttribute("hidden")
|
|
).toBe(true);
|
|
expect(
|
|
document.querySelector('[data-market-row="b"]')?.hasAttribute("hidden")
|
|
).toBe(false);
|
|
});
|
|
|
|
test("applying plugin sorting triggers full scan and reorders rows", async () => {
|
|
document.body.innerHTML = buildMarketFixture();
|
|
const resultStore = createMarketResultStore();
|
|
const ensureScanForSort = vi.fn(async () => {
|
|
resultStore.setAuthorSuccess("a", {
|
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
|
});
|
|
resultStore.setAuthorSuccess("b", {
|
|
singleVideoAfterSearchRate: "0.5%-1%",
|
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
|
});
|
|
});
|
|
|
|
const { createMarketController } = await import("../src/content/market/index");
|
|
const controller = trackController(createMarketController({
|
|
document,
|
|
fullScanController: {
|
|
ensureScanForExport: vi.fn(async () => {}),
|
|
ensureScanForFilter: vi.fn(async () => {}),
|
|
ensureScanForSort
|
|
},
|
|
loadAuthorMetrics: async () => ({
|
|
success: false,
|
|
reason: "request-failed"
|
|
}),
|
|
resultStore,
|
|
window
|
|
}));
|
|
|
|
await controller.ready;
|
|
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
|
|
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
|
|
click('[data-plugin-sort-apply="button"]');
|
|
await flush();
|
|
|
|
expect(ensureScanForSort).toHaveBeenCalledTimes(1);
|
|
expect(readRowOrder()).toEqual(["b", "a"]);
|
|
});
|
|
|
|
test("export triggers full scan and hands ordered visible records to the csv exporter", async () => {
|
|
document.body.innerHTML = buildMarketFixture();
|
|
const resultStore = createMarketResultStore();
|
|
const buildCsv = vi.fn(() => "csv-output");
|
|
const onCsvReady = vi.fn();
|
|
const ensureScanForExport = vi.fn(async () => {
|
|
resultStore.setAuthorSuccess("a", {
|
|
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
|
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
|
});
|
|
resultStore.setAuthorSuccess("b", {
|
|
singleVideoAfterSearchRate: "0.5%-1%",
|
|
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
|
});
|
|
});
|
|
|
|
const { createMarketController } = await import("../src/content/market/index");
|
|
const controller = trackController(createMarketController({
|
|
buildCsv,
|
|
document,
|
|
fullScanController: {
|
|
ensureScanForExport,
|
|
ensureScanForFilter: vi.fn(async () => {}),
|
|
ensureScanForSort: vi.fn(async () => {})
|
|
},
|
|
loadAuthorMetrics: async () => ({
|
|
success: false,
|
|
reason: "request-failed"
|
|
}),
|
|
onCsvReady,
|
|
resultStore,
|
|
window
|
|
}));
|
|
|
|
await controller.ready;
|
|
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
|
|
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
|
|
click('[data-plugin-sort-apply="button"]');
|
|
await flush();
|
|
click('[data-plugin-export="button"]');
|
|
await flush();
|
|
|
|
expect(ensureScanForExport).toHaveBeenCalledTimes(1);
|
|
expect(buildCsv).toHaveBeenCalledWith(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ authorId: "a" }),
|
|
expect.objectContaining({ authorId: "b" })
|
|
])
|
|
);
|
|
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
|
"b",
|
|
"a"
|
|
]);
|
|
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() {
|
|
return `
|
|
<div data-market-table>
|
|
<div data-market-header>
|
|
<div data-market-header-cell="authorName">达人</div>
|
|
<div data-market-header-cell="price21To60s">21-60s报价</div>
|
|
</div>
|
|
<div data-market-body>
|
|
<div data-market-row="a" data-author-id="a">
|
|
<span data-market-field="authorName">Alpha</span>
|
|
<span data-market-field="price21To60s">450000</span>
|
|
</div>
|
|
<div data-market-row="b" data-author-id="b">
|
|
<span data-market-field="authorName">Beta</span>
|
|
<span data-market-field="price21To60s">70000</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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) {
|
|
throw new Error(`Missing element: ${selector}`);
|
|
}
|
|
|
|
element.click();
|
|
}
|
|
|
|
function setInputValue(selector: string, value: string) {
|
|
const element = document.querySelector(selector) as HTMLInputElement | null;
|
|
if (!element) {
|
|
throw new Error(`Missing input: ${selector}`);
|
|
}
|
|
|
|
element.value = value;
|
|
}
|
|
|
|
function setSelectValue(selector: string, value: string) {
|
|
const element = document.querySelector(selector) as HTMLSelectElement | null;
|
|
if (!element) {
|
|
throw new Error(`Missing select: ${selector}`);
|
|
}
|
|
|
|
element.value = value;
|
|
}
|
|
|
|
function readRowOrder() {
|
|
return Array.from(document.querySelectorAll("[data-market-row]")).map(
|
|
(row) => row.getAttribute("data-author-id")
|
|
);
|
|
}
|
|
|
|
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));
|
|
}
|