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(
+ `
+
+ `,
+ {
+ 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(`
+
+ `).window.document;
+}