feat: wire market plugin controls

This commit is contained in:
admin123 2026-04-20 20:17:48 +08:00
parent f6b2bdc862
commit 9b3c6756bb
4 changed files with 608 additions and 1 deletions

View File

@ -1 +1,34 @@
export {}; import {
createMarketController,
type CreateMarketControllerOptions
} from "./market/index";
interface BootContentScriptOptions {
createMarketController?: (
options: CreateMarketControllerOptions
) => { ready: Promise<void> };
document?: Document;
window?: Window;
}
export async function bootContentScript(
options: BootContentScriptOptions = {}
): Promise<{ ready: Promise<void> } | null> {
const currentWindow = options.window ?? window;
const currentDocument = options.document ?? document;
const controllerFactory =
options.createMarketController ?? createMarketController;
if (!isMarketPage(currentWindow.location.href)) {
return null;
}
return controllerFactory({
document: currentDocument,
window: currentWindow
});
}
function isMarketPage(url: string): boolean {
return url.startsWith("https://xingtu.cn/ad/creator/market");
}

176
src/content/market/index.ts Normal file
View File

@ -0,0 +1,176 @@
import { buildMarketCsv } from "./csv-exporter";
import {
applyRowOrder,
applyRowVisibility,
renderMarketRowState,
syncMarketTable
} from "./dom-sync";
import { applyFilterAndSort } from "./filter-sort-controller";
import { createFullScanController } from "./full-scan-controller";
import { createMarketApiClient } from "./api-client";
import { ensurePluginToolbar } from "./plugin-toolbar";
import { createMarketResultStore } from "./result-store";
import type {
MarketApiResult,
MarketFilterState,
MarketRecord,
MarketRowSnapshot,
MarketSortState
} from "./types";
interface FullScanControllerLike {
ensureScanForExport(): Promise<void>;
ensureScanForFilter(): Promise<void>;
ensureScanForSort(): Promise<void>;
}
export interface CreateMarketControllerOptions {
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
fullScanController?: FullScanControllerLike;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
onCsvReady?: (csv: string) => void;
resultStore?: ReturnType<typeof createMarketResultStore>;
window: Window;
}
export function createMarketController(options: CreateMarketControllerOptions) {
const table = syncMarketTable(options.document);
const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics =
options.loadAuthorMetrics ?? createMarketApiClient().loadAuthorAseInfo;
const buildCsv = options.buildCsv ?? buildMarketCsv;
let activeFilters: MarketFilterState = {};
let activeSort: MarketSortState | undefined;
const fullScanController =
options.fullScanController ??
createFullScanController({
goToNextPage: async () => false,
hasNextPage: () => false,
loadAuthorMetrics,
readCurrentPageRows: () =>
table ? table.rows.map((rowDom) => readRowSnapshot(rowDom.row)) : [],
resultStore
});
const toolbar = ensurePluginToolbar(options.document, {
onApplyFilter: async () => {
activeFilters = {
personalVideoAfterSearchRateMin: parseNumberValue(
toolbar.personalFilterInput.value
),
singleVideoAfterSearchRateMin: parseNumberValue(
toolbar.singleFilterInput.value
)
};
await fullScanController.ensureScanForFilter();
applyCurrentView();
},
onApplySort: async () => {
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
await fullScanController.ensureScanForSort();
applyCurrentView();
},
onExport: async () => {
await fullScanController.ensureScanForExport();
const records = getVisibleOrderedRecords();
options.onCsvReady?.(buildCsv(records));
}
});
const ready = hydrateCurrentPage().then(() => {
applyCurrentView();
});
return {
ready
};
async function hydrateCurrentPage(): Promise<void> {
if (!table) {
return;
}
for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom.row);
resultStore.upsertMarketRow(rowSnapshot);
resultStore.setAuthorLoading(rowSnapshot.authorId);
renderMarketRowState(rowDom, {
...rowSnapshot,
status: "loading"
});
const metricsResult = await loadAuthorMetrics(rowSnapshot.authorId);
if (metricsResult.success) {
resultStore.setAuthorSuccess(rowSnapshot.authorId, metricsResult.rates);
renderMarketRowState(rowDom, {
...rowSnapshot,
status: "success",
rates: metricsResult.rates
});
continue;
}
resultStore.setAuthorFailed(rowSnapshot.authorId, metricsResult.reason);
renderMarketRowState(rowDom, {
...rowSnapshot,
failureReason: metricsResult.reason,
status: "failed"
});
}
}
function applyCurrentView(): void {
if (!table) {
return;
}
const records = getVisibleOrderedRecords();
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, records.map((record) => record.authorId));
}
function getVisibleOrderedRecords(): MarketRecord[] {
return applyFilterAndSort(resultStore.listRecords(), {
filters: activeFilters,
sort: activeSort
});
}
}
function readRowSnapshot(row: HTMLElement): MarketRowSnapshot {
return {
authorId: row.dataset.authorId ?? "",
authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"",
price21To60s:
row
.querySelector('[data-market-field="price21To60s"]')
?.textContent?.trim() ?? ""
};
}
function parseNumberValue(value: string): number | undefined {
if (!value) {
return undefined;
}
const parsedValue = Number(value);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
}
function readSortState(
fieldSelect: HTMLSelectElement,
directionSelect: HTMLSelectElement
): MarketSortState | undefined {
if (!fieldSelect.value) {
return undefined;
}
return {
direction: directionSelect.value === "asc" ? "asc" : "desc",
field: fieldSelect.value as MarketSortState["field"]
};
}

View File

@ -0,0 +1,137 @@
export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
onExport(): Promise<void> | void;
}
export interface PluginToolbarDom {
exportButton: HTMLButtonElement;
filterApplyButton: HTMLButtonElement;
personalFilterInput: HTMLInputElement;
root: HTMLElement;
singleFilterInput: HTMLInputElement;
sortApplyButton: HTMLButtonElement;
sortDirectionSelect: HTMLSelectElement;
sortFieldSelect: HTMLSelectElement;
}
export function ensurePluginToolbar(
document: Document,
handlers: PluginToolbarHandlers
): PluginToolbarDom {
const existingRoot = document.querySelector(
"[data-plugin-toolbar='root']"
) as HTMLElement | null;
if (existingRoot) {
return readToolbarDom(existingRoot);
}
const root = document.createElement("section");
root.dataset.pluginToolbar = "root";
const singleFilterInput = document.createElement("input");
singleFilterInput.type = "number";
singleFilterInput.step = "0.01";
singleFilterInput.dataset.pluginFilterSingle = "input";
const personalFilterInput = document.createElement("input");
personalFilterInput.type = "number";
personalFilterInput.step = "0.01";
personalFilterInput.dataset.pluginFilterPersonal = "input";
const filterApplyButton = document.createElement("button");
filterApplyButton.type = "button";
filterApplyButton.dataset.pluginFilterApply = "button";
filterApplyButton.textContent = "应用筛选";
const sortFieldSelect = document.createElement("select");
sortFieldSelect.dataset.pluginSortField = "select";
appendOption(sortFieldSelect, "", "不排序");
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
const sortDirectionSelect = document.createElement("select");
sortDirectionSelect.dataset.pluginSortDirection = "select";
appendOption(sortDirectionSelect, "desc", "降序");
appendOption(sortDirectionSelect, "asc", "升序");
const sortApplyButton = document.createElement("button");
sortApplyButton.type = "button";
sortApplyButton.dataset.pluginSortApply = "button";
sortApplyButton.textContent = "应用排序";
const exportButton = document.createElement("button");
exportButton.type = "button";
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
root.append(
singleFilterInput,
personalFilterInput,
filterApplyButton,
sortFieldSelect,
sortDirectionSelect,
sortApplyButton,
exportButton
);
document.body.prepend(root);
filterApplyButton.addEventListener("click", () => {
void handlers.onApplyFilter();
});
sortApplyButton.addEventListener("click", () => {
void handlers.onApplySort();
});
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
return {
exportButton,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
};
}
function appendOption(
select: HTMLSelectElement,
value: string,
label: string
): void {
const option = select.ownerDocument.createElement("option");
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
return {
exportButton: root.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement,
filterApplyButton: root.querySelector(
'[data-plugin-filter-apply="button"]'
) as HTMLButtonElement,
personalFilterInput: root.querySelector(
'[data-plugin-filter-personal="input"]'
) as HTMLInputElement,
root,
singleFilterInput: root.querySelector(
'[data-plugin-filter-single="input"]'
) as HTMLInputElement,
sortApplyButton: root.querySelector(
'[data-plugin-sort-apply="button"]'
) as HTMLButtonElement,
sortDirectionSelect: root.querySelector(
'[data-plugin-sort-direction="select"]'
) as HTMLSelectElement,
sortFieldSelect: root.querySelector(
'[data-plugin-sort-field="select"]'
) as HTMLSelectElement
};
}

View File

@ -0,0 +1,261 @@
// @vitest-environment jsdom
// @vitest-environment-options {"url":"https://xingtu.cn/"}
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createMarketResultStore } from "../src/content/market/result-store";
describe("market-content-entry", () => {
beforeEach(() => {
document.body.innerHTML = "";
window.history.replaceState({}, "", "/");
});
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);
});
test("hydrates current page rows on start", async () => {
document.body.innerHTML = buildMarketFixture();
const { createMarketController } = await import("../src/content/market/index");
const controller = 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("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 = 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 = 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 = 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");
});
});
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 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")
);
}
async function flush() {
await Promise.resolve();
await Promise.resolve();
}