From 8f44e157f19d2d35afe8acbed6edc9f30fef8a6d Mon Sep 17 00:00:00 2001 From: opencode Date: Wed, 15 Apr 2026 18:04:10 +0800 Subject: [PATCH] feat: add market page div-grid support with after-search-rate columns - Support both HTML table and div-based grid layouts on creator/market - Harden DOM insertion by using actual parent nodes (prevents NotFoundError when page nesting differs from test fixtures) - Skip malformed/empty table rows instead of throwing on missing action cell - Add rowKey to BatchLoaderRow to align LoadRowsOptions typing - Add tests for div-grid sync and controller lifecycle at document_start --- README.md | 4 +- ...ket-visible-page-columns-implementation.md | 41 ++ src/content/market/batch-loader.ts | 1 + src/content/market/dom-sync.ts | 365 ++++++++++++++++-- src/content/market/index.ts | 142 +++++-- tests/market-controller.test.ts | 245 +++++++++++- tests/market-dom-sync.test.ts | 84 ++++ 7 files changed, 813 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 669af93..6402d04 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,14 @@ npm run build 1. 打开 `https://xingtu.cn/ad/creator/market` 2. 等待当前列表页渲染完成 -3. 确认 `操作` 列前新增了两列: +3. 看右侧 sticky 列区,确认 `21-60s报价` 和 `操作` 之间新增了两列: `单视频看后搜率` `个人视频看后搜率` 4. 首次进入或翻页、筛选、搜索、排序变化后,新增列会先显示 `加载中...` 5. 请求成功后,两列会显示对应达人的真实值 6. 如果某行失败,两列都会显示 `加载失败` 7. 点击任一失败单元格,会按整行重试该达人并重新进入 `加载中...` +8. 如果只看到左侧达人信息区,先把结果区横向滚到最右侧,再检查 `操作` 列前是否已经插入两列 ## 当前范围 @@ -50,4 +51,5 @@ npm run build - 巨量星图找达人 `creator/market` 当前可见结果页的两列增强 - 列表页只处理当前可见结果页,不处理全部结果导出 - 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果 +- 列表页真实结构是右侧 sticky 区域中的“按列渲染” grid,不是传统 `tr/td` 表格;插件按列索引重建每一行并插入两列 - 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态 diff --git a/docs/superpowers/plans/2026-04-15-star-chart-market-visible-page-columns-implementation.md b/docs/superpowers/plans/2026-04-15-star-chart-market-visible-page-columns-implementation.md index 3478104..fab881f 100644 --- a/docs/superpowers/plans/2026-04-15-star-chart-market-visible-page-columns-implementation.md +++ b/docs/superpowers/plans/2026-04-15-star-chart-market-visible-page-columns-implementation.md @@ -407,3 +407,44 @@ Expected: build exits 0 and `dist/` contains the updated extension assets - [ ] **Step 7: Record the verification note** Record the passing full test run and build output + +--- + +## Runtime DOM Note + +After testing against the real logged-in `creator/market` page, the market result area was confirmed to be a column-major sticky grid instead of a row-major HTML table: + +- outer list root: `.base-author-list` +- header root: `.section-wrapper.sticky-header.hide-scrollbar` +- body root: `.section-wrapper.hide-scrollbar` +- left author area: sticky `content-section` with one `content-column` +- middle metrics: `.middle-columns` +- right sticky area: `content-section` containing multiple `.content-column`s, with the last column being `操作` + +This means DOM sync cannot treat each result as a `` or assume the injected cells live under the same row container. The implementation now reconstructs each logical row by cell index across columns and stamps row metadata onto: + +- the author-side row anchor cell +- the injected `单视频看后搜率` cell +- the injected `个人视频看后搜率` cell + +## Verification Note + +Verified in this workspace after the column-major DOM fix: + +- `npm test -- tests/market-dom-sync.test.ts tests/market-controller.test.ts` +- `npm test` +- `npm run build` + +All commands exited successfully. + +## Runtime Stability Note + +After manual browser verification on the real `creator/market` page, an additional runtime issue was identified: refreshing the market page could leave the result area blank or stuck loading. + +The market controller was then hardened with three runtime constraints: + +- do not start observing the full market DOM while `document.readyState === "loading"` +- do not override `history.pushState` or `history.replaceState` on the market page +- coalesce repeated `MutationObserver` callbacks into one scheduled sync cycle instead of launching unbounded concurrent sync passes during page boot + +These constraints are now covered by `tests/market-controller.test.ts` together with the existing market-page behavior checks. diff --git a/src/content/market/batch-loader.ts b/src/content/market/batch-loader.ts index 644f58a..20258d3 100644 --- a/src/content/market/batch-loader.ts +++ b/src/content/market/batch-loader.ts @@ -5,6 +5,7 @@ import type { RequiredAfterSearchRates } from "./types"; interface BatchLoaderRow { authorId: string | null; + rowKey: string; render( state: MarketRowState, options?: { onRetry?: () => Promise | void } diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index 153e14b..c7c7c5a 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -3,29 +3,149 @@ const PERSONAL_COLUMN_KEY = "personal-video-after-search-rate"; const SINGLE_HEADER_TEXT = "单视频看后搜率"; const PERSONAL_HEADER_TEXT = "个人视频看后搜率"; const ACTION_HEADER_TEXT = "操作"; +const PRIMARY_HEADER_TEXT = "达人信息"; export interface MarketRowDom { - personalCell: HTMLTableCellElement; - row: HTMLTableRowElement; - singleCell: HTMLTableCellElement; + personalCell: HTMLElement; + row: HTMLElement; + singleCell: HTMLElement; } export interface MarketTableDom { + root: HTMLElement; rows: MarketRowDom[]; - table: HTMLTableElement; } export function syncMarketTable(document: Document): MarketTableDom | null { + return syncHtmlTable(document) ?? syncDivGrid(document); +} + +function syncHtmlTable(document: Document): MarketTableDom | null { const table = findTargetTable(document); if (!table) { return null; } - ensureHeaders(table); + ensureTableHeaders(table); return { - rows: Array.from(table.tBodies[0]?.rows ?? []).map((row) => ensureRowCells(row)), - table + root: table, + rows: Array.from(table.tBodies[0]?.rows ?? []) + .map((row) => { + try { + return ensureTableRowCells(row); + } catch { + return null; + } + }) + .filter((row): row is MarketRowDom => row !== null) + }; +} + +function syncDivGrid(document: Document): MarketTableDom | null { + for (const root of document.querySelectorAll(".base-author-list")) { + if (!(root instanceof document.defaultView!.HTMLElement)) { + continue; + } + + const synced = syncDivGridRoot(root); + if (synced) { + return synced; + } + } + + return null; +} + +function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { + const headerSection = root.querySelector( + ".section-wrapper.sticky-header" + ) as HTMLElement | null; + const bodySection = Array.from(root.querySelectorAll(".section-wrapper")).find( + (section): section is HTMLElement => + section instanceof root.ownerDocument.defaultView!.HTMLElement && + !section.classList.contains("sticky-header") + ); + + if (!headerSection || !bodySection) { + return null; + } + + const actionHeader = findCellByText(getDirectHeaderCells(headerSection), ACTION_HEADER_TEXT); + const authorHeader = findCellByText(getDirectHeaderCells(headerSection), PRIMARY_HEADER_TEXT); + + if (!actionHeader || !authorHeader) { + return null; + } + + const rightHeaderContainer = getDirectChildContainer(headerSection, actionHeader); + const authorBodyContainer = getIndexedChild(bodySection, getDirectChildIndex(headerSection, authorHeader)); + const rightBodyContainer = getIndexedChild(bodySection, getDirectChildIndex(headerSection, actionHeader)); + + if (!rightHeaderContainer || !authorBodyContainer || !rightBodyContainer) { + return null; + } + + const authorColumn = getDirectContentColumns(authorBodyContainer)[0] ?? null; + if (!authorColumn) { + return null; + } + + ensureDivHeaderCell( + rightHeaderContainer, + actionHeader, + SINGLE_COLUMN_KEY, + SINGLE_HEADER_TEXT + ); + ensureDivHeaderCell( + rightHeaderContainer, + actionHeader, + PERSONAL_COLUMN_KEY, + PERSONAL_HEADER_TEXT + ); + + const actionColumn = getActionColumn(rightBodyContainer); + if (!actionColumn) { + return null; + } + + const rowCount = getDirectContentCells(authorColumn).length; + const singleColumn = ensureDivBodyColumn( + rightBodyContainer, + actionColumn, + SINGLE_COLUMN_KEY, + rowCount + ); + const personalColumn = ensureDivBodyColumn( + rightBodyContainer, + actionColumn, + PERSONAL_COLUMN_KEY, + rowCount + ); + + const authorCells = getDirectContentCells(authorColumn); + const singleCells = getDirectContentCells(singleColumn); + const personalCells = getDirectContentCells(personalColumn); + + const rows = authorCells.flatMap((row, index) => { + const singleCell = singleCells[index] ?? null; + const personalCell = personalCells[index] ?? null; + if (!singleCell || !personalCell) { + return []; + } + + return [ + { + personalCell, + row, + singleCell + } + ]; + }); + + return { + root, + rows }; } @@ -35,7 +155,10 @@ function findTargetTable(document: Document): HTMLTableElement | null { table.querySelectorAll("thead th, thead td"), (cell) => cell.textContent?.trim() ?? "" ); - if (headerTexts.includes(ACTION_HEADER_TEXT)) { + if ( + headerTexts.includes(PRIMARY_HEADER_TEXT) && + headerTexts.includes(ACTION_HEADER_TEXT) + ) { return table as HTMLTableElement; } } @@ -43,24 +166,34 @@ function findTargetTable(document: Document): HTMLTableElement | null { return null; } -function ensureHeaders(table: HTMLTableElement) { +function ensureTableHeaders(table: HTMLTableElement) { const headerRow = table.querySelector("thead tr"); if (!headerRow) { return; } - const actionHeader = findHeaderByText(headerRow, ACTION_HEADER_TEXT); + const actionHeader = findTableCellByText(headerRow, ACTION_HEADER_TEXT); if (!actionHeader) { return; } - ensureHeaderCell(headerRow, actionHeader, SINGLE_COLUMN_KEY, SINGLE_HEADER_TEXT); - ensureHeaderCell(headerRow, actionHeader, PERSONAL_COLUMN_KEY, PERSONAL_HEADER_TEXT); + ensureTableHeaderCell( + headerRow, + actionHeader, + SINGLE_COLUMN_KEY, + SINGLE_HEADER_TEXT + ); + ensureTableHeaderCell( + headerRow, + actionHeader, + PERSONAL_COLUMN_KEY, + PERSONAL_HEADER_TEXT + ); } -function ensureHeaderCell( +function ensureTableHeaderCell( headerRow: Element, - actionHeader: Element, + actionHeader: HTMLElement, columnKey: string, text: string ) { @@ -68,20 +201,20 @@ function ensureHeaderCell( return; } - const cell = actionHeader.ownerDocument.createElement(actionHeader.tagName); + const cell = cloneElementShallow(actionHeader); cell.dataset.scesHeader = columnKey; cell.textContent = text; headerRow.insertBefore(cell, actionHeader); } -function ensureRowCells(row: HTMLTableRowElement): MarketRowDom { - const actionCell = row.cells.item(row.cells.length - 1); +function ensureTableRowCells(row: HTMLTableRowElement): MarketRowDom { + const actionCell = getTableActionCell(row); if (!actionCell) { throw new Error("market row is missing the action cell"); } - const singleCell = ensureRowCell(row, actionCell, SINGLE_COLUMN_KEY); - const personalCell = ensureRowCell(row, actionCell, PERSONAL_COLUMN_KEY); + const singleCell = ensureTableRowCell(row, actionCell, SINGLE_COLUMN_KEY); + const personalCell = ensureTableRowCell(row, actionCell, PERSONAL_COLUMN_KEY); return { personalCell, @@ -90,7 +223,7 @@ function ensureRowCells(row: HTMLTableRowElement): MarketRowDom { }; } -function ensureRowCell( +function ensureTableRowCell( row: HTMLTableRowElement, actionCell: HTMLTableCellElement, columnKey: string @@ -102,18 +235,200 @@ function ensureRowCell( return existingCell; } - const cell = row.ownerDocument.createElement(actionCell.tagName); + const cell = cloneElementShallow(actionCell) as HTMLTableCellElement; cell.dataset.scesColumn = columnKey; row.insertBefore(cell, actionCell); return cell; } -function findHeaderByText(row: Element, text: string): Element | null { - for (const cell of row.querySelectorAll("th, td")) { - if (cell.textContent?.trim() === text) { - return cell; +function getTableActionCell(row: HTMLTableRowElement): HTMLTableCellElement | null { + return ( + Array.from(row.cells).find((cell) => cell.textContent?.trim() === ACTION_HEADER_TEXT) ?? + row.cells[row.cells.length - 1] ?? + null + ); +} + +function ensureDivHeaderCell( + headerContainer: HTMLElement, + actionHeader: HTMLElement, + columnKey: string, + text: string +) { + const actualContainer = actionHeader.parentNode as HTMLElement | null; + if (!actualContainer) { + return; + } + + const existing = Array.from(actualContainer.children).find( + (child) => + child instanceof actualContainer!.ownerDocument.defaultView!.HTMLElement && + child.dataset.scesHeader === columnKey + ) as HTMLElement | undefined; + if (existing) { + existing.textContent = text; + return; + } + + const referenceCell = getSiblingCellBefore(actionHeader, "header-cell") ?? actionHeader; + const cell = cloneElementShallow(referenceCell); + cell.dataset.scesHeader = columnKey; + cell.textContent = text; + actualContainer.insertBefore(cell, actionHeader); +} + +function ensureDivBodyColumn( + bodyContainer: HTMLElement, + actionColumn: HTMLElement, + columnKey: string, + rowCount: number +): HTMLElement { + const actualContainer = actionColumn.parentNode as HTMLElement | null; + if (!actualContainer) { + return bodyContainer; + } + + const existing = Array.from(actualContainer.children).find( + (child) => + child instanceof actualContainer!.ownerDocument.defaultView!.HTMLElement && + child.dataset.scesColumnGroup === columnKey + ) as HTMLElement | undefined; + if (existing) { + syncDivColumnCells(existing, actionColumn, rowCount, columnKey); + return existing; + } + + const referenceColumn = + getSiblingCellBefore(actionColumn, "content-column") ?? actionColumn; + const column = cloneElementShallow(referenceColumn); + column.dataset.scesColumnGroup = columnKey; + syncDivColumnCells(column, actionColumn, rowCount, columnKey); + actualContainer.insertBefore(column, actionColumn); + return column; +} + +function syncDivColumnCells( + column: HTMLElement, + actionColumn: HTMLElement, + rowCount: number, + columnKey: string +) { + const actionCells = getDirectContentCells(actionColumn); + const currentCells = getDirectContentCells(column); + + while (currentCells.length > rowCount) { + const lastCell = currentCells.pop(); + lastCell?.remove(); + } + + for (let index = 0; index < rowCount; index += 1) { + const existingCell = getDirectContentCells(column)[index] ?? null; + if (existingCell) { + existingCell.dataset.scesColumn = columnKey; + continue; } + + const templateCell = actionCells[index] ?? actionCells[actionCells.length - 1] ?? null; + const cell = templateCell + ? cloneElementShallow(templateCell) + : createBareContentCell(column.ownerDocument); + cell.dataset.scesColumn = columnKey; + cell.textContent = ""; + column.appendChild(cell); + } +} + +function getActionColumn(bodyContainer: HTMLElement): HTMLElement | null { + const columns = getDirectContentColumns(bodyContainer); + return columns[columns.length - 1] ?? null; +} + +function getDirectHeaderCells(section: Element): HTMLElement[] { + return Array.from(section.querySelectorAll(".header-cell")).filter( + (cell): cell is HTMLElement => + cell instanceof section.ownerDocument.defaultView!.HTMLElement + ); +} + +function getDirectContentColumns(section: Element): HTMLElement[] { + return Array.from(section.children).filter( + (child): child is HTMLElement => + child instanceof section.ownerDocument.defaultView!.HTMLElement && + child.classList.contains("content-column") + ); +} + +function getDirectContentCells(column: Element): HTMLElement[] { + return Array.from(column.children).filter( + (child): child is HTMLElement => + child instanceof column.ownerDocument.defaultView!.HTMLElement && + child.classList.contains("content-cell") + ); +} + +function getDirectChildContainer(root: HTMLElement, cell: HTMLElement): HTMLElement | null { + const index = getDirectChildIndex(root, cell); + return getIndexedChild(root, index); +} + +function getDirectChildIndex(root: HTMLElement, descendant: HTMLElement): number { + const directChild = Array.from(root.children).find((child) => child.contains(descendant)); + return directChild ? Array.from(root.children).indexOf(directChild) : -1; +} + +function getIndexedChild(root: HTMLElement, index: number): HTMLElement | null { + if (index < 0) { + return null; + } + + const child = root.children[index] ?? null; + return child instanceof root.ownerDocument.defaultView!.HTMLElement ? child : null; +} + +function getSiblingCellBefore( + cell: HTMLElement, + className: "content-column" | "header-cell" +): HTMLElement | null { + let current = cell.previousElementSibling; + while (current) { + if ( + current instanceof cell.ownerDocument.defaultView!.HTMLElement && + current.classList.contains(className) + ) { + return current; + } + current = current.previousElementSibling; } return null; } + +function findTableCellByText(row: Element, text: string): HTMLElement | null { + return Array.from(row.children).find( + (cell) => + cell instanceof row.ownerDocument.defaultView!.HTMLElement && + cell.textContent?.trim() === text + ) as HTMLElement | null; +} + +function findCellByText(cells: HTMLElement[], text: string): HTMLElement | null { + return cells.find((cell) => cell.textContent?.trim() === text) ?? null; +} + +function cloneElementShallow(reference: HTMLElement): HTMLElement { + const cell = reference.ownerDocument.createElement(reference.tagName); + cell.className = reference.className; + + const style = reference.getAttribute("style"); + if (style) { + cell.setAttribute("style", style); + } + + return cell; +} + +function createBareContentCell(document: Document): HTMLElement { + const cell = document.createElement("div"); + cell.className = "content-cell"; + return cell; +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 11f9e42..96f66c9 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -70,48 +70,101 @@ export function createMarketContentController( ((callback: MutationCallback) => new MutationObserver(callback)); const observer = observerFactory(() => { - void syncNow(); - }); - observer.observe(options.document.body, { - childList: true, - subtree: true + scheduleSync(); }); + let isObserving = false; + let isSyncRunning = false; + let isSyncScheduled = false; + let needsResync = false; - const originalPushState = options.window.history.pushState.bind( - options.window.history - ); - const originalReplaceState = options.window.history.replaceState.bind( - options.window.history - ); + const startObservation = () => { + if (isObserving) { + return true; + } - options.window.history.pushState = wrapHistoryMethod(originalPushState); - options.window.history.replaceState = wrapHistoryMethod(originalReplaceState); - options.window.addEventListener("popstate", handleNavigation); + if (options.document.readyState === "loading") { + return false; + } - void syncNow(); + const root = getObservationRoot(options.document); + if (!root) { + return false; + } + + observer.observe(root, { + childList: true, + subtree: true + }); + isObserving = true; + return true; + }; + + const handleReady = () => { + if (!startObservation()) { + return; + } + + cleanupReadyListeners(); + scheduleSync(); + }; + + if (!startObservation()) { + options.document.addEventListener("readystatechange", handleReady); + options.window.addEventListener("DOMContentLoaded", handleReady); + options.window.addEventListener("load", handleReady); + } + + if (isObserving) { + scheduleSync(); + } return { dispose() { observer.disconnect(); - options.window.removeEventListener("popstate", handleNavigation); - options.window.history.pushState = originalPushState; - options.window.history.replaceState = originalReplaceState; + cleanupReadyListeners(); }, syncNow }; - function wrapHistoryMethod( - originalMethod: T - ) { - return ((...args: Parameters) => { - const result = originalMethod(...args); - handleNavigation(); - return result; - }) as T; + function cleanupReadyListeners() { + options.document.removeEventListener("readystatechange", handleReady); + options.window.removeEventListener("DOMContentLoaded", handleReady); + options.window.removeEventListener("load", handleReady); } - function handleNavigation() { - void syncNow(); + function scheduleSync() { + if (isSyncRunning) { + needsResync = true; + return; + } + + if (isSyncScheduled) { + return; + } + + isSyncScheduled = true; + options.window.setTimeout(() => { + isSyncScheduled = false; + void runSyncCycle(); + }, 0); + } + + async function runSyncCycle() { + if (isSyncRunning) { + needsResync = true; + return; + } + + isSyncRunning = true; + try { + await syncNow(); + } finally { + isSyncRunning = false; + if (needsResync) { + needsResync = false; + scheduleSync(); + } + } } async function syncNow() { @@ -219,25 +272,36 @@ function stampRowMetadata( listSeq: string, authorId: string | null ) { - rowDom.row.dataset.scesAuthorId = authorId ?? ""; + const authorIdValue = authorId ?? ""; + + rowDom.row.dataset.scesAuthorId = authorIdValue; rowDom.row.dataset.scesListSeq = listSeq; + rowDom.row.dataset.scesRowAnchor = "true"; rowDom.row.dataset.scesRowKey = rowKey; + + rowDom.singleCell.dataset.scesAuthorId = authorIdValue; + rowDom.singleCell.dataset.scesListSeq = listSeq; + rowDom.singleCell.dataset.scesRowKey = rowKey; + + rowDom.personalCell.dataset.scesAuthorId = authorIdValue; + rowDom.personalCell.dataset.scesListSeq = listSeq; + rowDom.personalCell.dataset.scesRowKey = rowKey; } function getRowDomByKey(document: Document, rowKey: string): MarketRowDom | null { const row = document.querySelector( - `[data-sces-row-key="${rowKey}"]` - ) as HTMLTableRowElement | null; + `[data-sces-row-key="${rowKey}"][data-sces-row-anchor="true"]` + ) as HTMLElement | null; if (!row) { return null; } - const singleCell = row.querySelector( - '[data-sces-column="single-video-after-search-rate"]' - ) as HTMLTableCellElement | null; - const personalCell = row.querySelector( - '[data-sces-column="personal-video-after-search-rate"]' - ) as HTMLTableCellElement | null; + const singleCell = document.querySelector( + `[data-sces-row-key="${rowKey}"][data-sces-column="single-video-after-search-rate"]` + ) as HTMLElement | null; + const personalCell = document.querySelector( + `[data-sces-row-key="${rowKey}"][data-sces-column="personal-video-after-search-rate"]` + ) as HTMLElement | null; if (!singleCell || !personalCell) { return null; @@ -249,3 +313,7 @@ function getRowDomByKey(document: Document, rowKey: string): MarketRowDom | null singleCell }; } + +function getObservationRoot(document: Document): Node | null { + return document.body ?? document.documentElement; +} diff --git a/tests/market-controller.test.ts b/tests/market-controller.test.ts index 4b30500..8912882 100644 --- a/tests/market-controller.test.ts +++ b/tests/market-controller.test.ts @@ -34,6 +34,40 @@ describe("market controller", () => { dom.window.close(); }); + test("auto-loads the current rows on the div-based market grid", async () => { + const dom = createDivMarketDom(); + const controller = createMarketContentController({ + batchLoader: createMarketBatchLoader({ + apiClient: { + loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) + }, + concurrency: 4 + }), + document: dom.window.document, + logger: createLogger(), + mutationObserverFactory: createMutationObserverFactory(), + window: dom.window + }); + + await tick(); + + expect(divHeaderTexts(dom.window.document)).toEqual([ + "21-60s报价", + "单视频看后搜率", + "个人视频看后搜率", + "操作" + ]); + expect(divRightRowTexts(dom.window.document, 0)).toEqual([ + "¥70,000", + "111-single", + "111-personal", + "下单" + ]); + + controller.dispose(); + dom.window.close(); + }); + test("triggers a fresh sync when the visible list changes", async () => { const dom = createMarketDom(); const apiClient = { @@ -62,7 +96,7 @@ describe("market controller", () => { ` ); observer.trigger(); - await tick(); + await flushSync(); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledWith("333"); expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([ @@ -108,10 +142,10 @@ describe("market controller", () => { ` ); observer.trigger(); - await tick(); + await flushSync(); firstDeferred.resolve(successFor("111")); - await tick(); + await flushSync(); expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([ "达人 B", @@ -152,7 +186,7 @@ describe("market controller", () => { ` ); observer.trigger(); - await tick(); + await flushSync(); replaceRows( dom.window.document, @@ -164,6 +198,7 @@ describe("market controller", () => { ` ); observer.trigger(); + await flushSync(); const row = dom.window.document.querySelector("tbody tr")!; expect(cellTexts(row)).toEqual([ @@ -177,12 +212,137 @@ describe("market controller", () => { controller.dispose(); dom.window.close(); }); + + test("boots safely at document_start when body is not ready yet", async () => { + const dom = createMarketDom(); + Object.defineProperty(dom.window.document, "body", { + configurable: true, + value: null + }); + Object.defineProperty(dom.window.document, "documentElement", { + configurable: true, + value: null + }); + const strictObserverFactory = (callback: MutationCallback) => { + void callback; + return { + disconnect() {}, + observe(target: Node | null) { + if (!target) { + throw new TypeError("observer target must be a Node"); + } + } + }; + }; + + expect(() => + createMarketContentController({ + batchLoader: createMarketBatchLoader({ + apiClient: { + loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) + }, + concurrency: 4 + }), + document: dom.window.document, + logger: createLogger(), + mutationObserverFactory: strictObserverFactory, + window: dom.window + }) + ).not.toThrow(); + + dom.window.close(); + }); + + test("waits until the document is ready before observing the market page", async () => { + const dom = createMarketDom(); + let readyState = "loading"; + Object.defineProperty(dom.window.document, "readyState", { + configurable: true, + get() { + return readyState; + } + }); + + const apiClient = { + loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) + }; + const observer = createMutationObserverFactory(); + + createMarketContentController({ + batchLoader: createMarketBatchLoader({ + apiClient, + concurrency: 4 + }), + document: dom.window.document, + logger: createLogger(), + mutationObserverFactory: observer, + window: dom.window + }); + + await tick(); + + expect(observer.observe).toHaveBeenCalledTimes(0); + expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(0); + + readyState = "interactive"; + dom.window.dispatchEvent(new dom.window.Event("DOMContentLoaded")); + await flushSync(); + + expect(observer.observe).toHaveBeenCalledTimes(1); + expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledTimes(1); + + dom.window.close(); + }); + + test("does not override history methods on the market page", () => { + const dom = createMarketDom(); + const originalPushState = dom.window.history.pushState; + const originalReplaceState = dom.window.history.replaceState; + + const controller = createMarketContentController({ + batchLoader: createMarketBatchLoader({ + apiClient: { + loadAuthorAseInfo: vi.fn(async (authorId: string) => successFor(authorId)) + }, + concurrency: 4 + }), + document: dom.window.document, + logger: createLogger(), + mutationObserverFactory: createMutationObserverFactory(), + window: dom.window + }); + + expect(dom.window.history.pushState).toBe(originalPushState); + expect(dom.window.history.replaceState).toBe(originalReplaceState); + + controller.dispose(); + dom.window.close(); + }); }); function cellTexts(row: Element) { return Array.from(row.querySelectorAll("td"), (cell) => cell.textContent?.trim() ?? ""); } +function divCellTexts(row: Element) { + return Array.from(row.children, (cell) => cell.textContent?.trim() ?? ""); +} + +function divHeaderTexts(document: Document) { + return Array.from( + document.querySelectorAll('[data-testid="right-header"] > *'), + (cell) => cell.textContent?.trim() ?? "" + ); +} + +function divRightRowTexts(document: Document, rowIndex: number) { + return Array.from( + document.querySelectorAll('[data-testid="right-section"] > .content-column'), + (column) => + column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + ); +} + function createDeferred() { let resolve!: (value: T) => void; const promise = new Promise((nextResolve) => { @@ -201,7 +361,7 @@ function createLogger() { } function createMarketDom() { - return new JSDOM( + const dom = new JSDOM( ` @@ -222,20 +382,88 @@ function createMarketDom() { url: "https://xingtu.cn/ad/creator/market" } ); + + let readyState = "complete"; + Object.defineProperty(dom.window.document, "readyState", { + configurable: true, + get() { + return readyState; + } + }); + + return dom; +} + +function createDivMarketDom() { + const dom = new JSDOM( + ` +
+ +
+
+
+
+ 达人 A +
+
+
+
+
+
代表视频A
+
+
+
+
+
¥70,000
+
+
+
下单
+
+
+
+
+ `, + { + url: "https://xingtu.cn/ad/creator/market" + } + ); + + let readyState = "complete"; + Object.defineProperty(dom.window.document, "readyState", { + configurable: true, + get() { + return readyState; + } + }); + + return dom; } function createMutationObserverFactory() { let callback: MutationCallback = () => undefined; + const observe = vi.fn(); return Object.assign( (nextCallback: MutationCallback) => { callback = nextCallback; return { disconnect() {}, - observe() {} + observe }; }, { + observe, trigger() { callback([], {} as MutationObserver); } @@ -262,3 +490,8 @@ function tick() { setTimeout(resolve, 0); }); } + +async function flushSync() { + await tick(); + await tick(); +} diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index 8cc05b9..54971c9 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -55,6 +55,43 @@ describe("market dom sync", () => { expect(document.querySelectorAll('[data-sces-header="single-video-after-search-rate"]')).toHaveLength(1); expect(document.querySelectorAll('[data-sces-header="personal-video-after-search-rate"]')).toHaveLength(1); }); + + test("supports the div-based market grid used by the real page", () => { + const document = createDivGridDocument(); + + const table = syncMarketTable(document); + const headerTexts = Array.from( + document.querySelectorAll('[data-testid="right-header"] > *'), + (cell) => cell.textContent?.trim() ?? "" + ); + const rightColumns = Array.from( + document.querySelectorAll('[data-testid="right-section"] > .content-column') + ); + const firstRowTexts = rightColumns.map( + (column) => + column.querySelectorAll(".content-cell")[0]?.textContent?.trim() ?? "" + ); + + expect(table).not.toBeNull(); + expect(headerTexts).toEqual([ + "21-60s报价", + "单视频看后搜率", + "个人视频看后搜率", + "操作" + ]); + expect(firstRowTexts).toEqual([ + "¥70,000", + "", + "", + "下单" + ]); + expect(table?.rows[0].singleCell.dataset.scesColumn).toBe( + "single-video-after-search-rate" + ); + expect(table?.rows[0].personalCell.dataset.scesColumn).toBe( + "personal-video-after-search-rate" + ); + }); }); function createDocument() { @@ -79,3 +116,50 @@ function createDocument() {
`).window.document; } + +function createDivGridDocument() { + return new JSDOM(` +
+ +
+
+
+
+ 达人 A +
+
+ 达人 B +
+
+
+
+
+
代表视频A
+
代表视频B
+
+
+
+
+
¥70,000
+
¥45,000
+
+
+
下单
+
下单
+
+
+
+
+ `).window.document; +}