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:
parent
bf6295a4d0
commit
8f44e157f1
@ -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` 表格;插件按列索引重建每一行并插入两列
|
||||
- 成功时输出结构化结果或渲染真实值,失败时会给出明确失败状态
|
||||
|
||||
@ -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 `<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.
|
||||
|
||||
@ -5,6 +5,7 @@ import type { RequiredAfterSearchRates } from "./types";
|
||||
|
||||
interface BatchLoaderRow {
|
||||
authorId: string | null;
|
||||
rowKey: string;
|
||||
render(
|
||||
state: MarketRowState,
|
||||
options?: { onRetry?: () => Promise<void> | void }
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<T extends History["pushState"] | History["replaceState"]>(
|
||||
originalMethod: T
|
||||
) {
|
||||
return ((...args: Parameters<T>) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((nextResolve) => {
|
||||
@ -201,7 +361,7 @@ function createLogger() {
|
||||
}
|
||||
|
||||
function createMarketDom() {
|
||||
return new JSDOM(
|
||||
const dom = new JSDOM(
|
||||
`
|
||||
<table>
|
||||
<thead>
|
||||
@ -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(
|
||||
`
|
||||
<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() {
|
||||
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();
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
</table>
|
||||
`).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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user