From 9b3c6756bb06db5f5cf1e7c413a187d37d518541 Mon Sep 17 00:00:00 2001 From: admin123 Date: Mon, 20 Apr 2026 20:17:48 +0800 Subject: [PATCH] feat: wire market plugin controls --- src/content/index.ts | 35 +++- src/content/market/index.ts | 176 ++++++++++++++++++ src/content/market/plugin-toolbar.ts | 137 ++++++++++++++ tests/market-content-entry.test.ts | 261 +++++++++++++++++++++++++++ 4 files changed, 608 insertions(+), 1 deletion(-) create mode 100644 src/content/market/index.ts create mode 100644 src/content/market/plugin-toolbar.ts create mode 100644 tests/market-content-entry.test.ts diff --git a/src/content/index.ts b/src/content/index.ts index cb0ff5c..213585c 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1 +1,34 @@ -export {}; +import { + createMarketController, + type CreateMarketControllerOptions +} from "./market/index"; + +interface BootContentScriptOptions { + createMarketController?: ( + options: CreateMarketControllerOptions + ) => { ready: Promise }; + document?: Document; + window?: Window; +} + +export async function bootContentScript( + options: BootContentScriptOptions = {} +): Promise<{ ready: Promise } | 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"); +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts new file mode 100644 index 0000000..d2b692a --- /dev/null +++ b/src/content/market/index.ts @@ -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; + ensureScanForFilter(): Promise; + ensureScanForSort(): Promise; +} + +export interface CreateMarketControllerOptions { + buildCsv?: (records: MarketRecord[]) => string; + document: Document; + fullScanController?: FullScanControllerLike; + loadAuthorMetrics?: (authorId: string) => Promise; + onCsvReady?: (csv: string) => void; + resultStore?: ReturnType; + 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 { + 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"] + }; +} diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts new file mode 100644 index 0000000..b1e2446 --- /dev/null +++ b/src/content/market/plugin-toolbar.ts @@ -0,0 +1,137 @@ +export interface PluginToolbarHandlers { + onApplyFilter(): Promise | void; + onApplySort(): Promise | void; + onExport(): Promise | 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 + }; +} diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts new file mode 100644 index 0000000..9d3b394 --- /dev/null +++ b/tests/market-content-entry.test.ts @@ -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 ` +
+
+
达人
+
21-60s报价
+
+
+
+ Alpha + 450000 +
+
+ Beta + 70000 +
+
+
+ `; +} + +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(); +}