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
This commit is contained in:
opencode 2026-04-15 18:04:10 +08:00
parent bf6295a4d0
commit 8f44e157f1
7 changed files with 813 additions and 69 deletions

View File

@ -35,13 +35,14 @@ npm run build
1. 打开 `https://xingtu.cn/ad/creator/market` 1. 打开 `https://xingtu.cn/ad/creator/market`
2. 等待当前列表页渲染完成 2. 等待当前列表页渲染完成
3. 确认 `操作` 列前新增了两列: 3. 看右侧 sticky 列区,确认 `21-60s报价``操作` 之间新增了两列:
`单视频看后搜率` `单视频看后搜率`
`个人视频看后搜率` `个人视频看后搜率`
4. 首次进入或翻页、筛选、搜索、排序变化后,新增列会先显示 `加载中...` 4. 首次进入或翻页、筛选、搜索、排序变化后,新增列会先显示 `加载中...`
5. 请求成功后,两列会显示对应达人的真实值 5. 请求成功后,两列会显示对应达人的真实值
6. 如果某行失败,两列都会显示 `加载失败` 6. 如果某行失败,两列都会显示 `加载失败`
7. 点击任一失败单元格,会按整行重试该达人并重新进入 `加载中...` 7. 点击任一失败单元格,会按整行重试该达人并重新进入 `加载中...`
8. 如果只看到左侧达人信息区,先把结果区横向滚到最右侧,再检查 `操作` 列前是否已经插入两列
## 当前范围 ## 当前范围
@ -50,4 +51,5 @@ npm run build
- 巨量星图找达人 `creator/market` 当前可见结果页的两列增强 - 巨量星图找达人 `creator/market` 当前可见结果页的两列增强
- 列表页只处理当前可见结果页,不处理全部结果导出 - 列表页只处理当前可见结果页,不处理全部结果导出
- 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果 - 列表页使用内存缓存,同一标签页会话内会复用已成功加载的达人结果
- 列表页真实结构是右侧 sticky 区域中的“按列渲染” grid不是传统 `tr/td` 表格;插件按列索引重建每一行并插入两列
- 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态 - 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态

View File

@ -407,3 +407,44 @@ Expected: build exits 0 and `dist/` contains the updated extension assets
- [ ] **Step 7: Record the verification note** - [ ] **Step 7: Record the verification note**
Record the passing full test run and build output 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 `<tr>` 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.

View File

@ -5,6 +5,7 @@ import type { RequiredAfterSearchRates } from "./types";
interface BatchLoaderRow { interface BatchLoaderRow {
authorId: string | null; authorId: string | null;
rowKey: string;
render( render(
state: MarketRowState, state: MarketRowState,
options?: { onRetry?: () => Promise<void> | void } options?: { onRetry?: () => Promise<void> | void }

View File

@ -3,29 +3,149 @@ const PERSONAL_COLUMN_KEY = "personal-video-after-search-rate";
const SINGLE_HEADER_TEXT = "单视频看后搜率"; const SINGLE_HEADER_TEXT = "单视频看后搜率";
const PERSONAL_HEADER_TEXT = "个人视频看后搜率"; const PERSONAL_HEADER_TEXT = "个人视频看后搜率";
const ACTION_HEADER_TEXT = "操作"; const ACTION_HEADER_TEXT = "操作";
const PRIMARY_HEADER_TEXT = "达人信息";
export interface MarketRowDom { export interface MarketRowDom {
personalCell: HTMLTableCellElement; personalCell: HTMLElement;
row: HTMLTableRowElement; row: HTMLElement;
singleCell: HTMLTableCellElement; singleCell: HTMLElement;
} }
export interface MarketTableDom { export interface MarketTableDom {
root: HTMLElement;
rows: MarketRowDom[]; rows: MarketRowDom[];
table: HTMLTableElement;
} }
export function syncMarketTable(document: Document): MarketTableDom | null { export function syncMarketTable(document: Document): MarketTableDom | null {
return syncHtmlTable(document) ?? syncDivGrid(document);
}
function syncHtmlTable(document: Document): MarketTableDom | null {
const table = findTargetTable(document); const table = findTargetTable(document);
if (!table) { if (!table) {
return null; return null;
} }
ensureHeaders(table); ensureTableHeaders(table);
return { return {
rows: Array.from(table.tBodies[0]?.rows ?? []).map((row) => ensureRowCells(row)), root: table,
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"), table.querySelectorAll("thead th, thead td"),
(cell) => cell.textContent?.trim() ?? "" (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; return table as HTMLTableElement;
} }
} }
@ -43,24 +166,34 @@ function findTargetTable(document: Document): HTMLTableElement | null {
return null; return null;
} }
function ensureHeaders(table: HTMLTableElement) { function ensureTableHeaders(table: HTMLTableElement) {
const headerRow = table.querySelector("thead tr"); const headerRow = table.querySelector("thead tr");
if (!headerRow) { if (!headerRow) {
return; return;
} }
const actionHeader = findHeaderByText(headerRow, ACTION_HEADER_TEXT); const actionHeader = findTableCellByText(headerRow, ACTION_HEADER_TEXT);
if (!actionHeader) { if (!actionHeader) {
return; return;
} }
ensureHeaderCell(headerRow, actionHeader, SINGLE_COLUMN_KEY, SINGLE_HEADER_TEXT); ensureTableHeaderCell(
ensureHeaderCell(headerRow, actionHeader, PERSONAL_COLUMN_KEY, PERSONAL_HEADER_TEXT); headerRow,
actionHeader,
SINGLE_COLUMN_KEY,
SINGLE_HEADER_TEXT
);
ensureTableHeaderCell(
headerRow,
actionHeader,
PERSONAL_COLUMN_KEY,
PERSONAL_HEADER_TEXT
);
} }
function ensureHeaderCell( function ensureTableHeaderCell(
headerRow: Element, headerRow: Element,
actionHeader: Element, actionHeader: HTMLElement,
columnKey: string, columnKey: string,
text: string text: string
) { ) {
@ -68,20 +201,20 @@ function ensureHeaderCell(
return; return;
} }
const cell = actionHeader.ownerDocument.createElement(actionHeader.tagName); const cell = cloneElementShallow(actionHeader);
cell.dataset.scesHeader = columnKey; cell.dataset.scesHeader = columnKey;
cell.textContent = text; cell.textContent = text;
headerRow.insertBefore(cell, actionHeader); headerRow.insertBefore(cell, actionHeader);
} }
function ensureRowCells(row: HTMLTableRowElement): MarketRowDom { function ensureTableRowCells(row: HTMLTableRowElement): MarketRowDom {
const actionCell = row.cells.item(row.cells.length - 1); const actionCell = getTableActionCell(row);
if (!actionCell) { if (!actionCell) {
throw new Error("market row is missing the action cell"); throw new Error("market row is missing the action cell");
} }
const singleCell = ensureRowCell(row, actionCell, SINGLE_COLUMN_KEY); const singleCell = ensureTableRowCell(row, actionCell, SINGLE_COLUMN_KEY);
const personalCell = ensureRowCell(row, actionCell, PERSONAL_COLUMN_KEY); const personalCell = ensureTableRowCell(row, actionCell, PERSONAL_COLUMN_KEY);
return { return {
personalCell, personalCell,
@ -90,7 +223,7 @@ function ensureRowCells(row: HTMLTableRowElement): MarketRowDom {
}; };
} }
function ensureRowCell( function ensureTableRowCell(
row: HTMLTableRowElement, row: HTMLTableRowElement,
actionCell: HTMLTableCellElement, actionCell: HTMLTableCellElement,
columnKey: string columnKey: string
@ -102,18 +235,200 @@ function ensureRowCell(
return existingCell; return existingCell;
} }
const cell = row.ownerDocument.createElement(actionCell.tagName); const cell = cloneElementShallow(actionCell) as HTMLTableCellElement;
cell.dataset.scesColumn = columnKey; cell.dataset.scesColumn = columnKey;
row.insertBefore(cell, actionCell); row.insertBefore(cell, actionCell);
return cell; return cell;
} }
function findHeaderByText(row: Element, text: string): Element | null { function getTableActionCell(row: HTMLTableRowElement): HTMLTableCellElement | null {
for (const cell of row.querySelectorAll("th, td")) { return (
if (cell.textContent?.trim() === text) { Array.from(row.cells).find((cell) => cell.textContent?.trim() === ACTION_HEADER_TEXT) ??
return cell; 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; 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;
}

View File

@ -70,48 +70,101 @@ export function createMarketContentController(
((callback: MutationCallback) => new MutationObserver(callback)); ((callback: MutationCallback) => new MutationObserver(callback));
const observer = observerFactory(() => { const observer = observerFactory(() => {
void syncNow(); scheduleSync();
}); });
observer.observe(options.document.body, { let isObserving = false;
let isSyncRunning = false;
let isSyncScheduled = false;
let needsResync = false;
const startObservation = () => {
if (isObserving) {
return true;
}
if (options.document.readyState === "loading") {
return false;
}
const root = getObservationRoot(options.document);
if (!root) {
return false;
}
observer.observe(root, {
childList: true, childList: true,
subtree: true subtree: true
}); });
isObserving = true;
return true;
};
const originalPushState = options.window.history.pushState.bind( const handleReady = () => {
options.window.history if (!startObservation()) {
); return;
const originalReplaceState = options.window.history.replaceState.bind( }
options.window.history
);
options.window.history.pushState = wrapHistoryMethod(originalPushState); cleanupReadyListeners();
options.window.history.replaceState = wrapHistoryMethod(originalReplaceState); scheduleSync();
options.window.addEventListener("popstate", handleNavigation); };
void syncNow(); if (!startObservation()) {
options.document.addEventListener("readystatechange", handleReady);
options.window.addEventListener("DOMContentLoaded", handleReady);
options.window.addEventListener("load", handleReady);
}
if (isObserving) {
scheduleSync();
}
return { return {
dispose() { dispose() {
observer.disconnect(); observer.disconnect();
options.window.removeEventListener("popstate", handleNavigation); cleanupReadyListeners();
options.window.history.pushState = originalPushState;
options.window.history.replaceState = originalReplaceState;
}, },
syncNow syncNow
}; };
function wrapHistoryMethod<T extends History["pushState"] | History["replaceState"]>( function cleanupReadyListeners() {
originalMethod: T options.document.removeEventListener("readystatechange", handleReady);
) { options.window.removeEventListener("DOMContentLoaded", handleReady);
return ((...args: Parameters<T>) => { options.window.removeEventListener("load", handleReady);
const result = originalMethod(...args);
handleNavigation();
return result;
}) as T;
} }
function handleNavigation() { function scheduleSync() {
void syncNow(); 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() { async function syncNow() {
@ -219,25 +272,36 @@ function stampRowMetadata(
listSeq: string, listSeq: string,
authorId: string | null authorId: string | null
) { ) {
rowDom.row.dataset.scesAuthorId = authorId ?? ""; const authorIdValue = authorId ?? "";
rowDom.row.dataset.scesAuthorId = authorIdValue;
rowDom.row.dataset.scesListSeq = listSeq; rowDom.row.dataset.scesListSeq = listSeq;
rowDom.row.dataset.scesRowAnchor = "true";
rowDom.row.dataset.scesRowKey = rowKey; 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 { function getRowDomByKey(document: Document, rowKey: string): MarketRowDom | null {
const row = document.querySelector( const row = document.querySelector(
`[data-sces-row-key="${rowKey}"]` `[data-sces-row-key="${rowKey}"][data-sces-row-anchor="true"]`
) as HTMLTableRowElement | null; ) as HTMLElement | null;
if (!row) { if (!row) {
return null; return null;
} }
const singleCell = row.querySelector( const singleCell = document.querySelector(
'[data-sces-column="single-video-after-search-rate"]' `[data-sces-row-key="${rowKey}"][data-sces-column="single-video-after-search-rate"]`
) as HTMLTableCellElement | null; ) as HTMLElement | null;
const personalCell = row.querySelector( const personalCell = document.querySelector(
'[data-sces-column="personal-video-after-search-rate"]' `[data-sces-row-key="${rowKey}"][data-sces-column="personal-video-after-search-rate"]`
) as HTMLTableCellElement | null; ) as HTMLElement | null;
if (!singleCell || !personalCell) { if (!singleCell || !personalCell) {
return null; return null;
@ -249,3 +313,7 @@ function getRowDomByKey(document: Document, rowKey: string): MarketRowDom | null
singleCell singleCell
}; };
} }
function getObservationRoot(document: Document): Node | null {
return document.body ?? document.documentElement;
}

View File

@ -34,6 +34,40 @@ describe("market controller", () => {
dom.window.close(); 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 () => { test("triggers a fresh sync when the visible list changes", async () => {
const dom = createMarketDom(); const dom = createMarketDom();
const apiClient = { const apiClient = {
@ -62,7 +96,7 @@ describe("market controller", () => {
` `
); );
observer.trigger(); observer.trigger();
await tick(); await flushSync();
expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledWith("333"); expect(apiClient.loadAuthorAseInfo).toHaveBeenCalledWith("333");
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([ expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
@ -108,10 +142,10 @@ describe("market controller", () => {
` `
); );
observer.trigger(); observer.trigger();
await tick(); await flushSync();
firstDeferred.resolve(successFor("111")); firstDeferred.resolve(successFor("111"));
await tick(); await flushSync();
expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([ expect(cellTexts(dom.window.document.querySelector("tbody tr")!)).toEqual([
"达人 B", "达人 B",
@ -152,7 +186,7 @@ describe("market controller", () => {
` `
); );
observer.trigger(); observer.trigger();
await tick(); await flushSync();
replaceRows( replaceRows(
dom.window.document, dom.window.document,
@ -164,6 +198,7 @@ describe("market controller", () => {
` `
); );
observer.trigger(); observer.trigger();
await flushSync();
const row = dom.window.document.querySelector("tbody tr")!; const row = dom.window.document.querySelector("tbody tr")!;
expect(cellTexts(row)).toEqual([ expect(cellTexts(row)).toEqual([
@ -177,12 +212,137 @@ describe("market controller", () => {
controller.dispose(); controller.dispose();
dom.window.close(); 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) { function cellTexts(row: Element) {
return Array.from(row.querySelectorAll("td"), (cell) => cell.textContent?.trim() ?? ""); 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<T>() { function createDeferred<T>() {
let resolve!: (value: T) => void; let resolve!: (value: T) => void;
const promise = new Promise<T>((nextResolve) => { const promise = new Promise<T>((nextResolve) => {
@ -201,7 +361,7 @@ function createLogger() {
} }
function createMarketDom() { function createMarketDom() {
return new JSDOM( const dom = new JSDOM(
` `
<table> <table>
<thead> <thead>
@ -222,20 +382,88 @@ function createMarketDom() {
url: "https://xingtu.cn/ad/creator/market" 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(
`
<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" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" data-testid="author-row-a" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
</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;">
<div class="content-cell" style="height: 120px;">¥70,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`,
{
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() { function createMutationObserverFactory() {
let callback: MutationCallback = () => undefined; let callback: MutationCallback = () => undefined;
const observe = vi.fn();
return Object.assign( return Object.assign(
(nextCallback: MutationCallback) => { (nextCallback: MutationCallback) => {
callback = nextCallback; callback = nextCallback;
return { return {
disconnect() {}, disconnect() {},
observe() {} observe
}; };
}, },
{ {
observe,
trigger() { trigger() {
callback([], {} as MutationObserver); callback([], {} as MutationObserver);
} }
@ -262,3 +490,8 @@ function tick() {
setTimeout(resolve, 0); setTimeout(resolve, 0);
}); });
} }
async function flushSync() {
await tick();
await tick();
}

View File

@ -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="single-video-after-search-rate"]')).toHaveLength(1);
expect(document.querySelectorAll('[data-sces-header="personal-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() { function createDocument() {
@ -79,3 +116,50 @@ function createDocument() {
</table> </table>
`).window.document; `).window.document;
} }
function createDivGridDocument() {
return new JSDOM(`
<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" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
<div class="content-cell" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222"> B</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
<div class="content-cell" style="height: 120px;">B</div>
</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;">
<div class="content-cell" style="height: 120px;">¥70,000</div>
<div class="content-cell" style="height: 120px;">¥45,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;"></div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`).window.document;
}