diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index 3cceac0..fb24cf7 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -2,8 +2,13 @@ import { normalizeFractionRateDisplay, normalizeRateDisplay } from "../../shared/rate-normalizer"; -import type { AfterSearchRates, BackendMetrics } from "./types"; -import type { MarketRecord } from "./types"; +import type { + AfterSearchRates, + BackendMetrics, + MarketRecord, + MarketSortField, + MarketSortState +} from "./types"; const BACKEND_COLUMN_KEY = "backendMetrics"; const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate"; @@ -11,21 +16,71 @@ const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate"; const ACTION_HEADER_TEXT = "操作"; const AUTHOR_HEADER_TEXT = "达人信息"; const BACKEND_HEADER_TEXT = "秒探指标"; +const MARKET_SCROLL_HINT_TEXT = "横向滚动可查看看后搜率、秒探指标"; +const MARKET_SCROLLBAR_STYLE_ID = "sces-market-scrollbar-style"; const UNAVAILABLE_RATE_TEXT = "暂无来源"; const UNAVAILABLE_BACKEND_METRICS_TEXT = "暂无数据"; const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows"; +const SORTABLE_RATE_FIELDS = [SINGLE_COLUMN_KEY, PERSONAL_COLUMN_KEY] as const; + +const BACKEND_METRIC_COLUMNS = [ + { + field: "afterViewSearchRate", + label: "看后搜率" + }, + { + field: "afterViewSearchCount", + label: "看后搜数" + }, + { + field: "a3IncreaseCount", + label: "新增A3数" + }, + { + field: "newA3Rate", + label: "新增A3率" + }, + { + field: "cpa3", + label: "CPA3" + }, + { + field: "cpSearch", + label: "cp_search" + } +] as const satisfies Array<{ + field: keyof BackendMetrics; + label: string; +}>; + +type BackendMetricField = (typeof BACKEND_METRIC_COLUMNS)[number]["field"]; +const SORTABLE_MARKET_FIELDS = [ + ...SORTABLE_RATE_FIELDS, + ...BACKEND_METRIC_COLUMNS.map((column) => column.field) +] as const satisfies readonly MarketSortField[]; type RowOrderTarget = { container: HTMLElement; node: HTMLElement; }; +type MarketDataRow = { + authorId: string; + authorName: string; + exportFields?: Record; + hasDirectRatesSource: boolean; + location?: string; + price21To60s?: string; + rates?: AfterSearchRates; +}; + export interface MarketRowDom { authorId: string; authorName: string; - backendMetricsCell: HTMLElement; + backendMetricsCells: Record; exportFields?: Record; hasDirectRatesSource?: boolean; + location?: string; personalCell: HTMLElement; price21To60s?: string; rates?: AfterSearchRates; @@ -109,7 +164,7 @@ export function renderMarketRowState( rowDom: MarketRowDom, record: MarketRecord ): void { - renderBackendMetricsCell(rowDom.backendMetricsCell, record); + renderBackendMetricsCells(rowDom.backendMetricsCells, record); if (record.status === "success" && record.rates) { rowDom.singleCell.textContent = readRateCellText( @@ -167,6 +222,30 @@ export function applyRowOrder( }); } +export function syncPluginSortHeaders( + root: ParentNode, + options: { + activeSort?: MarketSortState; + onToggleSort: (field: MarketSortField) => void; + } +): void { + SORTABLE_MARKET_FIELDS.forEach((field) => { + const cell = root.querySelector( + `[data-market-header-cell="${field}"]` + ) as HTMLElement | null; + if (!cell) { + return; + } + + syncSortableHeaderCell(cell, { + direction: + options.activeSort?.field === field ? options.activeSort.direction : "none", + field, + onToggleSort: options.onToggleSort + }); + }); +} + function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { const header = root.querySelector("[data-market-header]") as HTMLElement | null; const body = root.querySelector("[data-market-body]") as HTMLElement | null; @@ -177,7 +256,9 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率"); ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率"); - ensureSyntheticHeaderCell(header, BACKEND_COLUMN_KEY, BACKEND_HEADER_TEXT); + BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => { + ensureSyntheticHeaderCell(header, field, label); + }); const headerLabelsByField = readSyntheticHeaderLabels(header); const rows = Array.from(body.querySelectorAll("[data-market-row]")).map( @@ -185,16 +266,20 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { const row = rowElement as HTMLElement; const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY); const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY); - const backendMetricsCell = ensureSyntheticRowCell(row, BACKEND_COLUMN_KEY); + const backendMetricsCells = Object.fromEntries( + BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)]) + ) as Record; return { authorId: row.dataset.authorId ?? "", authorName: row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ?? "", - backendMetricsCell, + backendMetricsCells, exportFields: readSyntheticExportFields(row, headerLabelsByField), hasDirectRatesSource: false, + location: + row.querySelector('[data-market-field="location"]')?.textContent?.trim() ?? "", orderTargets: [ { container: body, @@ -260,6 +345,13 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { return null; } + const rightHeaderSection = actionHeader.parentElement; + if (!(rightHeaderSection instanceof root.ownerDocument.defaultView!.HTMLElement)) { + return null; + } + const middleHeaderSection = + findPreviousNativeSection(rightHeaderSection) ?? rightHeaderSection; + const authorSection = getIndexedChild( bodySection, getDirectChildIndex(headerSection, authorHeader) @@ -272,6 +364,16 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { if (!authorSection || !rightSection) { return null; } + const middleBodySection = findPreviousNativeSection(rightSection) ?? rightSection; + + const pluginHeaderSection = ensurePluginSection(headerSection, rightHeaderSection, { + testId: "plugin-header", + type: "header" + }); + const pluginBodySection = ensurePluginSection(bodySection, rightSection, { + testId: "plugin-section", + type: "body" + }); const authorColumn = getDirectContentColumns(authorSection)[0] ?? null; const actionColumn = getActionColumn(rightSection); @@ -281,30 +383,57 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { } const rowCount = getDirectContentCells(authorColumn).length; - ensureDivHeaderCell(actionHeader, SINGLE_COLUMN_KEY, "单视频看后搜率"); - ensureDivHeaderCell(actionHeader, PERSONAL_COLUMN_KEY, "个人视频看后搜率"); - ensureDivHeaderCell(actionHeader, BACKEND_COLUMN_KEY, BACKEND_HEADER_TEXT); - + const headerTemplateCell = + getDirectHeaderCells(middleHeaderSection).at(-1) ?? + findPreviousHeaderCell(actionHeader) ?? + actionHeader; + const bodyTemplateColumn = + getDirectContentColumns(middleBodySection).at(-1) ?? + findPreviousColumn(actionColumn) ?? + actionColumn; + ensureDivHeaderCell( + pluginHeaderSection, + headerTemplateCell, + SINGLE_COLUMN_KEY, + "单视频看后搜率" + ); + ensureDivHeaderCell( + pluginHeaderSection, + headerTemplateCell, + PERSONAL_COLUMN_KEY, + "个人视频看后搜率" + ); const singleColumn = ensureDivBodyColumn( - rightSection, - actionColumn, + pluginBodySection, + bodyTemplateColumn, SINGLE_COLUMN_KEY, rowCount ); const personalColumn = ensureDivBodyColumn( - rightSection, - actionColumn, + pluginBodySection, + bodyTemplateColumn, PERSONAL_COLUMN_KEY, rowCount ); - const backendMetricsColumn = ensureDivBodyColumn( - rightSection, - actionColumn, - BACKEND_COLUMN_KEY, - rowCount - ); - syncContainerWidth(actionHeader.parentElement); - syncContainerWidth(rightSection); + const backendMetricColumns = Object.fromEntries( + BACKEND_METRIC_COLUMNS.map(({ field, label }) => { + ensureDivHeaderCell(pluginHeaderSection, headerTemplateCell, field, label); + return [ + field, + ensureDivBodyColumn( + pluginBodySection, + bodyTemplateColumn, + field, + rowCount + ) + ]; + }) + ) as Record; + syncContainerWidth(pluginHeaderSection); + syncContainerWidth(pluginBodySection); + ensureVisibleHorizontalScroll(headerSection); + ensureVisibleHorizontalScroll(bodySection); + ensureScrollHint(root, headerSection); const allBodyColumns = Array.from(bodySection.children).flatMap((section) => section instanceof root.ownerDocument.defaultView!.HTMLElement @@ -319,7 +448,12 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { const authorCells = getDirectContentCells(authorColumn); const singleCells = getDirectContentCells(singleColumn); const personalCells = getDirectContentCells(personalColumn); - const backendMetricsCells = getDirectContentCells(backendMetricsColumn); + const backendMetricCellsByField = Object.fromEntries( + BACKEND_METRIC_COLUMNS.map(({ field }) => [ + field, + getDirectContentCells(backendMetricColumns[field]) + ]) + ) as Record; const priceColumn = findPreviousColumn(actionColumn); const priceCells = priceColumn ? getDirectContentCells(priceColumn) : []; const vueMarketRows = readVueMarketRows(root); @@ -328,37 +462,55 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { const rows = authorCells.flatMap((authorCell, index) => { const singleCell = singleCells[index] ?? null; const personalCell = personalCells[index] ?? null; - const backendMetricsCell = backendMetricsCells[index] ?? null; - if (!singleCell || !personalCell || !backendMetricsCell) { + const backendMetricsCells = Object.fromEntries( + BACKEND_METRIC_COLUMNS.map(({ field }) => [ + field, + backendMetricCellsByField[field][index] ?? null + ]) + ) as Record; + if ( + !singleCell || + !personalCell || + Object.values(backendMetricsCells).some((cell) => cell === null) + ) { return []; } - const rowCells = allBodyColumns - .map((column) => getDirectContentCells(column)[index] ?? null) - .filter((cell): cell is HTMLElement => cell !== null); + const alignedRowCells = allBodyColumns.map( + (column) => getDirectContentCells(column)[index] ?? null + ); + const rowCells = alignedRowCells.filter( + (cell): cell is HTMLElement => cell !== null + ); const vueMarketRow = vueMarketRows[index] ?? null; const serializedMarketRow = serializedMarketRows[index] ?? null; + const fallbackMarketRow = mergeMarketDataRows(serializedMarketRow, vueMarketRow); + const exportFields = mergeExportFieldMaps( + readExportFieldsForDivGridRow(allHeaderCells, alignedRowCells), + fallbackMarketRow?.exportFields + ); const authorId = extractAuthorId(authorCell) || - vueMarketRow?.authorId || - serializedMarketRow?.authorId || + fallbackMarketRow?.authorId || ""; const authorName = extractAuthorName(authorCell) || - vueMarketRow?.authorName || - serializedMarketRow?.authorName || + fallbackMarketRow?.authorName || ""; + const price21To60s = mergeNonEmptyString( + priceCells[index]?.textContent?.trim() ?? "", + fallbackMarketRow?.price21To60s + ); return [ { authorId, authorName, - backendMetricsCell, - exportFields: readExportFieldsForDivGridRow(allHeaderCells, rowCells), + backendMetricsCells: backendMetricsCells as Record, + exportFields, hasDirectRatesSource: - vueMarketRow?.hasDirectRatesSource ?? - serializedMarketRow?.hasDirectRatesSource ?? - false, + fallbackMarketRow?.hasDirectRatesSource ?? false, + location: fallbackMarketRow?.location, orderTargets: rowCells .map((cell) => { const container = cell.parentElement; @@ -373,8 +525,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { }) .filter((target): target is RowOrderTarget => target !== null), personalCell, - price21To60s: priceCells[index]?.textContent?.trim() ?? "", - rates: vueMarketRow?.rates ?? serializedMarketRow?.rates, + price21To60s, + rates: fallbackMarketRow?.rates, row: authorCell, singleCell, visibilityTargets: rowCells @@ -397,6 +549,7 @@ function ensureSyntheticHeaderCell( ) as HTMLElement | null; if (existingCell) { + existingCell.textContent = label; return existingCell; } @@ -423,63 +576,54 @@ function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement { } function ensureDivHeaderCell( - actionHeader: HTMLElement, + container: HTMLElement, + templateCell: HTMLElement, field: string, label: string ): HTMLElement { - const container = actionHeader.parentElement; - if (!container) { - return actionHeader; - } - const existingCell = container.querySelector( `[data-market-header-cell="${field}"]` ) as HTMLElement | null; if (existingCell) { existingCell.textContent = label; + applyPluginHeaderCellStyles(existingCell); return existingCell; } - const referenceCell = findPreviousHeaderCell(actionHeader) ?? actionHeader; - const nextCell = cloneElementShallow(referenceCell); + const nextCell = cloneElementShallow(templateCell); nextCell.dataset.marketHeaderCell = field; nextCell.textContent = label; applyColumnWidth(nextCell, field); - container.insertBefore(nextCell, actionHeader); + applyPluginHeaderCellStyles(nextCell); + container.appendChild(nextCell); return nextCell; } function ensureDivBodyColumn( - bodySection: HTMLElement, - actionColumn: HTMLElement, + container: HTMLElement, + templateColumn: HTMLElement, field: string, rowCount: number ): HTMLElement { - const container = actionColumn.parentElement; - if (!container) { - return bodySection; - } - const existingColumn = container.querySelector( `[data-market-column-group="${field}"]` ) as HTMLElement | null; if (existingColumn) { - syncDivColumnCells(existingColumn, actionColumn, field, rowCount); + syncDivColumnCells(existingColumn, templateColumn, field, rowCount); return existingColumn; } - const referenceColumn = findPreviousColumn(actionColumn) ?? actionColumn; - const nextColumn = cloneElementShallow(referenceColumn); + const nextColumn = cloneElementShallow(templateColumn); nextColumn.dataset.marketColumnGroup = field; applyColumnWidth(nextColumn, field); - syncDivColumnCells(nextColumn, actionColumn, field, rowCount); - container.insertBefore(nextColumn, actionColumn); + syncDivColumnCells(nextColumn, templateColumn, field, rowCount); + container.appendChild(nextColumn); return nextColumn; } function syncDivColumnCells( column: HTMLElement, - actionColumn: HTMLElement, + templateColumn: HTMLElement, field: string, rowCount: number ): void { @@ -488,25 +632,46 @@ function syncDivColumnCells( currentCells.pop()?.remove(); } - const actionCells = getDirectContentCells(actionColumn); + const templateCells = getDirectContentCells(templateColumn); for (let index = 0; index < rowCount; index += 1) { const existingCell = getDirectContentCells(column)[index] ?? null; if (existingCell) { existingCell.dataset.marketRowCell = field; + applyPluginContentCellStyles(existingCell); continue; } - const templateCell = actionCells[index] ?? actionCells[actionCells.length - 1] ?? null; + const templateCell = + templateCells[index] ?? templateCells[templateCells.length - 1] ?? null; const nextCell = templateCell ? cloneElementShallow(templateCell) : createBareContentCell(column.ownerDocument); nextCell.dataset.marketRowCell = field; applyColumnWidth(nextCell, field); + applyPluginContentCellStyles(nextCell); nextCell.textContent = ""; column.appendChild(nextCell); } } +function applyPluginHeaderCellStyles(cell: HTMLElement): void { + cell.style.display = "flex"; + cell.style.alignItems = "center"; + cell.style.justifyContent = "normal"; + cell.style.cursor = "pointer"; + cell.style.whiteSpace = "nowrap"; +} + +function applyPluginContentCellStyles(cell: HTMLElement): void { + cell.style.display = "flex"; + cell.style.alignItems = "center"; + cell.style.justifyContent = "normal"; + cell.style.paddingTop = "12px"; + cell.style.paddingBottom = "12px"; + cell.style.boxSizing = "border-box"; + cell.style.whiteSpace = "nowrap"; +} + function getOwnerDocument(root: ParentNode): Document | null { if ("ownerDocument" in root && root.ownerDocument) { return root.ownerDocument; @@ -557,7 +722,7 @@ function readSyntheticExportFields( function readExportFieldsForDivGridRow( headerCells: HTMLElement[], - rowCells: HTMLElement[] + rowCells: Array ): Record { const exportFields: Record = {}; @@ -567,7 +732,7 @@ function readExportFieldsForDivGridRow( return; } - exportFields[headerLabel] = normalizeExportCellText(cell.textContent); + exportFields[headerLabel] = normalizeExportCellText(cell?.textContent); }); return exportFields; @@ -603,6 +768,128 @@ function findPreviousColumn(column: HTMLElement): HTMLElement | null { return null; } +function ensurePluginSection( + rootSection: HTMLElement, + referenceSection: HTMLElement, + options: { + testId: string; + type: "header" | "body"; + } +): HTMLElement { + const existingSection = rootSection.querySelector( + `[data-market-plugin-section="${options.type}"]` + ) as HTMLElement | null; + if (existingSection) { + existingSection.dataset.testid = options.testId; + existingSection.setAttribute("data-testid", options.testId); + return existingSection; + } + + const templateSection = + findPreviousSection(referenceSection) ?? referenceSection; + const nextSection = cloneElementShallow(templateSection); + nextSection.dataset.marketPluginSection = options.type; + nextSection.dataset.testid = options.testId; + nextSection.setAttribute("data-testid", options.testId); + resetStickySectionStyles(nextSection); + rootSection.insertBefore(nextSection, referenceSection); + return nextSection; +} + +function ensureVisibleHorizontalScroll(section: HTMLElement): void { + ensureVisibleScrollbarStyles(section.ownerDocument); + section.classList.remove("hide-scrollbar"); + section.dataset.marketScrollbar = "visible"; + section.style.overflowX = "auto"; + section.style.scrollbarWidth = "thin"; + section.style.scrollbarColor = "rgba(148, 163, 184, 0.95) rgba(226, 232, 240, 0.9)"; +} + +function ensureVisibleScrollbarStyles(document: Document): void { + if (document.getElementById(MARKET_SCROLLBAR_STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = MARKET_SCROLLBAR_STYLE_ID; + style.textContent = ` + [data-market-scrollbar="visible"]::-webkit-scrollbar { + display: block !important; + height: 10px !important; + } + + [data-market-scrollbar="visible"]::-webkit-scrollbar-track { + background: rgba(226, 232, 240, 0.9) !important; + border-radius: 999px; + } + + [data-market-scrollbar="visible"]::-webkit-scrollbar-thumb { + background: rgba(148, 163, 184, 0.95) !important; + border: 2px solid rgba(226, 232, 240, 0.9); + border-radius: 999px; + } + `; + document.head.appendChild(style); +} + +function ensureScrollHint(root: HTMLElement, headerSection: HTMLElement): void { + const existingHint = root.querySelector( + '[data-testid="market-scroll-hint"]' + ) as HTMLElement | null; + if (existingHint) { + existingHint.textContent = MARKET_SCROLL_HINT_TEXT; + return; + } + + const hint = root.ownerDocument.createElement("div"); + hint.dataset.testid = "market-scroll-hint"; + hint.setAttribute("data-testid", "market-scroll-hint"); + hint.textContent = MARKET_SCROLL_HINT_TEXT; + hint.style.color = "#64748b"; + hint.style.display = "flex"; + hint.style.fontSize = "12px"; + hint.style.justifyContent = "flex-end"; + hint.style.lineHeight = "18px"; + hint.style.padding = "0 12px 8px"; + root.insertBefore(hint, headerSection); +} + +function findPreviousSection(section: HTMLElement): HTMLElement | null { + let current = section.previousElementSibling; + while (current) { + if (current instanceof section.ownerDocument.defaultView!.HTMLElement) { + return current; + } + current = current.previousElementSibling; + } + + return null; +} + +function findPreviousNativeSection(section: HTMLElement): HTMLElement | null { + let current = section.previousElementSibling; + while (current) { + if ( + current instanceof section.ownerDocument.defaultView!.HTMLElement && + !current.hasAttribute("data-market-plugin-section") + ) { + return current; + } + current = current.previousElementSibling; + } + + return null; +} + +function resetStickySectionStyles(section: HTMLElement): void { + section.style.position = ""; + section.style.left = ""; + section.style.right = ""; + section.style.zIndex = ""; + section.style.width = ""; + section.style.minWidth = ""; +} + function getActionColumn(bodySection: HTMLElement): HTMLElement | null { const columns = getDirectContentColumns(bodySection); return columns[columns.length - 1] ?? null; @@ -651,12 +938,9 @@ function findCellByText(cells: HTMLElement[], text: string): HTMLElement | null function cloneElementShallow(reference: HTMLElement): HTMLElement { const clone = reference.ownerDocument.createElement(reference.tagName); - clone.className = reference.className; - - const style = reference.getAttribute("style"); - if (style) { - clone.setAttribute("style", style); - } + Array.from(reference.attributes).forEach((attribute) => { + clone.setAttribute(attribute.name, attribute.value); + }); return clone; } @@ -701,12 +985,7 @@ function extractAuthorIdFromHref(href: string): string | null { function readVueMarketRows( marketRoot: HTMLElement -): Array<{ - authorId: string; - authorName: string; - hasDirectRatesSource: boolean; - rates?: AfterSearchRates; -}> { +): MarketDataRow[] { const vueRoot = ( marketRoot as HTMLElement & { __vue__?: { @@ -734,23 +1013,24 @@ function readVueMarketRows( return marketList.map((row) => { const record = isRecord(row) ? row : {}; - const attributeDatas = isRecord(record.attribute_datas) - ? record.attribute_datas - : {}; + const attributeDatas = readMarketAttributeDatas(record); const singleVideoAfterSearchRate = normalizeMarketListRate( - attributeDatas.avg_search_after_view_rate_30d + readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d") ); return { authorId: - readString(record.star_id) ?? - readString(attributeDatas.id) ?? + readString(readMarketFieldValue(record, attributeDatas, "star_id")) ?? + readString(readMarketFieldValue(record, attributeDatas, "id")) ?? "", authorName: - readString(attributeDatas.nickname) ?? - readString(record.nick_name) ?? + readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? + readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? "", + exportFields: buildMarketExportFieldFallbacks(record, attributeDatas), hasDirectRatesSource: true, + location: readMarketLocation(record, attributeDatas), + price21To60s: readMarketPrice21To60s(record, attributeDatas), rates: singleVideoAfterSearchRate ? { singleVideoAfterSearchRate @@ -765,12 +1045,7 @@ function readVueMarketRows( function readSerializedMarketRows( document: Document -): Array<{ - authorId: string; - authorName: string; - hasDirectRatesSource: boolean; - rates?: AfterSearchRates; -}> { +): MarketDataRow[] { const serializedRows = document.documentElement.getAttribute( SERIALIZED_MARKET_ROWS_ATTRIBUTE ); @@ -793,7 +1068,10 @@ function readSerializedMarketRows( return { authorId: readString(record.authorId) ?? "", authorName: readString(record.authorName) ?? "", + exportFields: readSerializedExportFields(record), hasDirectRatesSource: Boolean(singleVideoAfterSearchRate), + location: readString(record.location) ?? undefined, + price21To60s: readString(record.price21To60s) ?? undefined, rates: singleVideoAfterSearchRate ? { singleVideoAfterSearchRate @@ -819,11 +1097,29 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function readMarketAttributeDatas( + record: Record +): Record { + return isRecord(record.attribute_datas) ? record.attribute_datas : {}; +} + +function readMarketFieldValue( + record: Record, + attributeDatas: Record, + field: string +): unknown { + return record[field] ?? attributeDatas[field]; +} + function readString(value: unknown): string | null { return typeof value === "string" ? value : null; } function normalizeMarketListRate(value: unknown): string | null { + if (typeof value === "number") { + return normalizeFractionRateDisplay(String(value)); + } + return typeof value === "string" ? normalizeFractionRateDisplay(value) : null; } @@ -832,26 +1128,188 @@ function normalizeExportCellText(value: string | null | undefined): string { } function shouldExportColumn(label: string): boolean { + const excludedBackendLabels = new Set(BACKEND_METRIC_COLUMNS.map((column) => column.label)); return Boolean( label && label !== ACTION_HEADER_TEXT && label !== BACKEND_HEADER_TEXT && + !excludedBackendLabels.has(label) && label !== "单视频看后搜率" && label !== "个人视频看后搜率" ); } +function syncSortableHeaderCell( + cell: HTMLElement, + options: { + direction: MarketSortState["direction"] | "none"; + field: MarketSortField; + onToggleSort: (field: MarketSortField) => void; + } +): void { + const label = readSortableHeaderLabel(cell); + const sorterRoot = ensureHeaderSorterRoot(cell); + const text = ensureHeaderSorterText(sorterRoot); + const icon = ensureHeaderSorterIcon(sorterRoot); + const upTriangle = ensureHeaderTriangle(icon, "up"); + const downTriangle = ensureHeaderTriangle(icon, "down"); + + text.textContent = label; + cell.dataset.marketSortField = options.field; + cell.dataset.marketSortDirection = options.direction; + cell.setAttribute("role", "button"); + cell.tabIndex = 0; + cell.onclick = () => { + options.onToggleSort(options.field); + }; + cell.onkeydown = (event) => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + event.preventDefault(); + options.onToggleSort(options.field); + }; + + syncTriangleStyles(upTriangle, { + active: options.direction === "asc", + direction: "up" + }); + syncTriangleStyles(downTriangle, { + active: options.direction === "desc", + direction: "down" + }); +} + +function readSortableHeaderLabel(cell: HTMLElement): string { + return ( + cell.dataset.marketHeaderLabel ?? + normalizeExportCellText(cell.textContent) ?? + "" + ); +} + +function ensureHeaderSorterRoot(cell: HTMLElement): HTMLElement { + const existingRoot = cell.querySelector( + '[data-market-sorter="root"]' + ) as HTMLElement | null; + if (existingRoot) { + return existingRoot; + } + + cell.dataset.marketHeaderLabel = normalizeExportCellText(cell.textContent); + cell.replaceChildren(); + + const root = cell.ownerDocument.createElement("span"); + root.dataset.marketSorter = "root"; + root.style.alignItems = "center"; + root.style.display = "inline-flex"; + root.style.gap = "4px"; + root.style.maxWidth = "100%"; + cell.appendChild(root); + return root; +} + +function ensureHeaderSorterText(sorterRoot: HTMLElement): HTMLElement { + const existingText = sorterRoot.querySelector( + '[data-market-sorter="text"]' + ) as HTMLElement | null; + if (existingText) { + return existingText; + } + + const text = sorterRoot.ownerDocument.createElement("span"); + text.dataset.marketSorter = "text"; + text.style.display = "inline-block"; + text.style.lineHeight = "20px"; + text.style.whiteSpace = "nowrap"; + sorterRoot.appendChild(text); + return text; +} + +function ensureHeaderSorterIcon(sorterRoot: HTMLElement): HTMLElement { + const existingIcon = sorterRoot.querySelector( + '[data-market-sorter="icon"]' + ) as HTMLElement | null; + if (existingIcon) { + return existingIcon; + } + + const icon = sorterRoot.ownerDocument.createElement("span"); + icon.dataset.marketSorter = "icon"; + icon.style.display = "inline-flex"; + icon.style.flexDirection = "column"; + icon.style.gap = "2px"; + icon.style.justifyContent = "center"; + icon.style.minWidth = "8px"; + sorterRoot.appendChild(icon); + return icon; +} + +function ensureHeaderTriangle( + iconRoot: HTMLElement, + direction: "up" | "down" +): HTMLElement { + const existingTriangle = iconRoot.querySelector( + `[data-market-sorter-triangle="${direction}"]` + ) as HTMLElement | null; + if (existingTriangle) { + return existingTriangle; + } + + const triangle = iconRoot.ownerDocument.createElement("span"); + triangle.dataset.marketSorterTriangle = direction; + triangle.style.display = "block"; + triangle.style.height = "0"; + triangle.style.width = "0"; + triangle.style.borderLeft = "4px solid transparent"; + triangle.style.borderRight = "4px solid transparent"; + iconRoot.appendChild(triangle); + return triangle; +} + +function syncTriangleStyles( + triangle: HTMLElement, + options: { + active: boolean; + direction: "up" | "down"; + } +): void { + const activeColor = "#1f2329"; + const inactiveColor = "#c9cdd4"; + + if (options.direction === "up") { + triangle.style.borderBottom = `5px solid ${ + options.active ? activeColor : inactiveColor + }`; + triangle.style.borderTop = "0 solid transparent"; + } else { + triangle.style.borderTop = `5px solid ${ + options.active ? activeColor : inactiveColor + }`; + triangle.style.borderBottom = "0 solid transparent"; + } +} + function readRateCellText(value: string | undefined): string { return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT; } function applyColumnWidth(element: HTMLElement, field: string): void { - if (field !== BACKEND_COLUMN_KEY) { - return; + if (field === BACKEND_COLUMN_KEY) { + element.style.minWidth = "240px"; + element.style.width = "240px"; } - element.style.minWidth = "240px"; - element.style.width = "240px"; + if (field === SINGLE_COLUMN_KEY || field === PERSONAL_COLUMN_KEY) { + element.style.minWidth = "160px"; + element.style.width = "160px"; + } + + if (BACKEND_METRIC_COLUMNS.some((column) => column.field === field)) { + element.style.minWidth = "120px"; + element.style.width = "120px"; + } } function syncContainerWidth(container: Element | null): void { @@ -888,55 +1346,420 @@ function readElementWidth(element: HTMLElement): number { return 0; } -function renderBackendMetricsCell( - cell: HTMLElement, +function mergeMarketDataRows( + baseRow: MarketDataRow | null, + preferredRow: MarketDataRow | null +): MarketDataRow | null { + if (!baseRow && !preferredRow) { + return null; + } + + if (!baseRow) { + return preferredRow; + } + + if (!preferredRow) { + return baseRow; + } + + return { + authorId: preferredRow.authorId || baseRow.authorId, + authorName: preferredRow.authorName || baseRow.authorName, + exportFields: mergeExportFieldMaps(baseRow.exportFields, preferredRow.exportFields), + hasDirectRatesSource: + preferredRow.hasDirectRatesSource || baseRow.hasDirectRatesSource, + location: mergeNonEmptyString(baseRow.location, preferredRow.location), + price21To60s: mergeNonEmptyString( + baseRow.price21To60s, + preferredRow.price21To60s + ), + rates: mergeRates(baseRow.rates, preferredRow.rates) + }; +} + +function mergeExportFieldMaps( + current: Record | undefined, + fallback: Record | undefined +): Record | undefined { + if (!current && !fallback) { + return undefined; + } + + const nextFields: Record = { + ...(current ?? {}) + }; + Object.entries(fallback ?? {}).forEach(([key, value]) => { + if (!hasTextValue(nextFields[key]) && hasTextValue(value)) { + nextFields[key] = value; + } + }); + return nextFields; +} + +function mergeRates( + current: AfterSearchRates | undefined, + fallback: AfterSearchRates | undefined +): AfterSearchRates | undefined { + if (!current && !fallback) { + return undefined; + } + + return { + singleVideoAfterSearchRate: + current?.singleVideoAfterSearchRate ?? fallback?.singleVideoAfterSearchRate, + personalVideoAfterSearchRate: + current?.personalVideoAfterSearchRate ?? + fallback?.personalVideoAfterSearchRate + }; +} + +function mergeNonEmptyString( + current: string | undefined, + fallback: string | undefined +): string | undefined { + return hasTextValue(current) ? current : fallback; +} + +function buildMarketExportFieldFallbacks( + record: Record, + attributeDatas: Record +): Record | undefined { + const exportFields: Record = {}; + const authorInfo = buildMarketAuthorInfo(record, attributeDatas); + const authorType = buildMarketAuthorType(record, attributeDatas); + const contentTheme = buildMarketContentTheme(record, attributeDatas); + const connectedUsers = formatWanValue( + readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry")) + ); + const followerCount = formatWanValue( + readNumericValue(readMarketFieldValue(record, attributeDatas, "follower")) + ); + const expectedCpm = formatDecimalDisplay( + readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm")) + ); + const expectedPlayCount = formatWanValue( + readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num")) + ); + const interactionRate = formatFractionPercent( + readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d")) + ); + const finishRate = formatFractionPercent( + readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d")) + ); + const burstRate = readBurstRateDisplay( + readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate")) + ); + const price21To60s = readMarketPrice21To60s(record, attributeDatas); + const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas); + + assignExportField(exportFields, "达人信息", authorInfo); + assignExportField(exportFields, "代表视频", representativeVideo); + assignExportField(exportFields, "达人类型", authorType); + assignExportField(exportFields, "内容主题", contentTheme); + assignExportField(exportFields, "连接用户数", connectedUsers); + assignExportField(exportFields, "粉丝数", followerCount); + assignExportField(exportFields, "预期CPM", expectedCpm); + assignExportField(exportFields, "预期播放量", expectedPlayCount); + assignExportField(exportFields, "互动率", interactionRate); + assignExportField(exportFields, "完播率", finishRate); + assignExportField(exportFields, "爆文率", burstRate); + assignExportField(exportFields, "21-60s报价", price21To60s); + + return Object.keys(exportFields).length > 0 ? exportFields : undefined; +} + +function assignExportField( + exportFields: Record, + key: string, + value: string | undefined +): void { + if (hasTextValue(value)) { + exportFields[key] = value; + } +} + +function buildMarketAuthorInfo( + record: Record, + attributeDatas: Record +): string | undefined { + const nickname = + readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? + readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? + ""; + const parts = [ + nickname, + readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")), + readString(readMarketFieldValue(record, attributeDatas, "city")) ?? "" + ].filter((value) => Boolean(value)); + + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function buildMarketAuthorType( + record: Record, + attributeDatas: Record +): string | undefined { + const tagsRelation = readRecordLike( + readMarketFieldValue(record, attributeDatas, "tags_relation") + ); + if (tagsRelation) { + const primaryTag = Object.keys(tagsRelation)[0]; + if (hasTextValue(primaryTag)) { + return primaryTag; + } + } + + return undefined; +} + +function buildMarketContentTheme( + record: Record, + attributeDatas: Record +): string | undefined { + const themes = readStringArray( + readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d") + ); + if (themes.length === 0) { + return undefined; + } + + if (themes.length <= 2) { + return themes.join(" "); + } + + return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`; +} + +function readMarketLocation( + record: Record, + attributeDatas: Record +): string | undefined { + return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? undefined; +} + +function readMarketPrice21To60s( + record: Record, + attributeDatas: Record +): string | undefined { + return formatCurrencyValue( + readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60")) + ); +} + +function readMarketRepresentativeVideo( + record: Record, + attributeDatas: Record +): string | undefined { + const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items")); + for (const item of items) { + if (!isRecord(item)) { + continue; + } + + const title = readString(item.title); + if (hasTextValue(title)) { + return normalizeExportCellText(title); + } + } + + return undefined; +} + +function readMarketGenderLabel(value: unknown): string | undefined { + const rawValue = typeof value === "number" ? String(value) : readString(value); + if (rawValue === "1") { + return "男"; + } + + if (rawValue === "2") { + return "女"; + } + + return undefined; +} + +function readBurstRateDisplay(value: number | null): string | undefined { + if (value === null) { + return undefined; + } + + if (value <= 0) { + return "-"; + } + + return formatFractionPercent(value); +} + +function formatCurrencyValue(value: number | null): string | undefined { + if (value === null) { + return undefined; + } + + return `¥${value.toLocaleString("en-US", { + maximumFractionDigits: 0 + })}`; +} + +function formatWanValue(value: number | null): string | undefined { + if (value === null) { + return undefined; + } + + return `${formatDecimalWithGrouping(value / 10000)}w`; +} + +function formatFractionPercent(value: number | null): string | undefined { + if (value === null) { + return undefined; + } + + return `${formatDecimalDisplay(value * 100)}%`; +} + +function formatDecimalDisplay(value: number | null): string | undefined { + if (value === null) { + return undefined; + } + + return value.toLocaleString("en-US", { + maximumFractionDigits: 1, + minimumFractionDigits: 0, + useGrouping: false + }); +} + +function formatDecimalWithGrouping(value: number): string { + return value.toLocaleString("en-US", { + maximumFractionDigits: 1, + minimumFractionDigits: 0 + }); +} + +function readNumericValue(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const trimmedValue = value.trim(); + if (!trimmedValue) { + return null; + } + + const parsedValue = Number(trimmedValue); + return Number.isFinite(parsedValue) ? parsedValue : null; + } + + return null; +} + +function readStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value.filter((item): item is string => typeof item === "string"); + } + + if (typeof value === "string") { + try { + const parsedValue = JSON.parse(value); + return Array.isArray(parsedValue) + ? parsedValue.filter((item): item is string => typeof item === "string") + : []; + } catch { + return []; + } + } + + return []; +} + +function readArrayLike(value: unknown): unknown[] { + if (Array.isArray(value)) { + return value; + } + + if (typeof value === "string") { + try { + const parsedValue = JSON.parse(value); + return Array.isArray(parsedValue) ? parsedValue : []; + } catch { + return []; + } + } + + return []; +} + +function readRecordLike(value: unknown): Record | null { + if (isRecord(value)) { + return value; + } + + if (typeof value === "string") { + try { + const parsedValue = JSON.parse(value); + return isRecord(parsedValue) ? parsedValue : null; + } catch { + return null; + } + } + + return null; +} + +function readSerializedExportFields( + record: Record +): Record | undefined { + if (!isRecord(record.exportFields)) { + return undefined; + } + + const entries = Object.entries(record.exportFields).flatMap(([key, value]) => + typeof value === "string" ? [[key, value]] : [] + ); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function hasTextValue(value: string | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function renderBackendMetricsCells( + cells: Record, record: MarketRecord ): void { if ( record.backendMetricsStatus === "loading" || (record.status === "loading" && !record.backendMetricsStatus) ) { - cell.textContent = "加载中..."; + fillBackendMetricCells(cells, "加载中..."); return; } if (record.backendMetricsStatus === "failed") { - cell.textContent = "加载失败"; + fillBackendMetricCells(cells, "加载失败"); return; } if (record.backendMetricsStatus === "missing") { - cell.textContent = UNAVAILABLE_BACKEND_METRICS_TEXT; + fillBackendMetricCells(cells, UNAVAILABLE_BACKEND_METRICS_TEXT); return; } if (record.backendMetricsStatus !== "success" || !record.backendMetrics) { - cell.textContent = ""; + fillBackendMetricCells(cells, ""); return; } - cell.replaceChildren(createBackendMetricsPanel(cell.ownerDocument, record.backendMetrics)); -} - -function createBackendMetricsPanel( - document: Document, - backendMetrics: BackendMetrics -): HTMLElement { - const panel = document.createElement("div"); - panel.dataset.marketBackendMetrics = "panel"; - - [ - ["看后搜率", backendMetrics.afterViewSearchRate], - ["看后搜数", backendMetrics.afterViewSearchCount], - ["新增A3数", backendMetrics.a3IncreaseCount], - ["新增A3率", backendMetrics.newA3Rate], - ["CPA3", backendMetrics.cpa3], - ["cp_search", backendMetrics.cpSearch] - ].forEach(([label, value]) => { - const item = document.createElement("div"); - item.textContent = `${label}${value ?? ""}`; - panel.appendChild(item); + BACKEND_METRIC_COLUMNS.forEach(({ field }) => { + cells[field].textContent = record.backendMetrics?.[field] ?? ""; + }); +} + +function fillBackendMetricCells( + cells: Record, + value: string +): void { + BACKEND_METRIC_COLUMNS.forEach(({ field }) => { + cells[field].textContent = value; }); - - return panel; } diff --git a/src/content/market/filter-sort-controller.ts b/src/content/market/filter-sort-controller.ts index 3399350..5bc615d 100644 --- a/src/content/market/filter-sort-controller.ts +++ b/src/content/market/filter-sort-controller.ts @@ -3,6 +3,9 @@ import { parseRateLowerBound } from "../../shared/rate-normalizer"; import type { + AfterSearchRates, + BackendMetrics, + MarketSortField, MarketFilterState, MarketRecord, MarketSortState @@ -67,8 +70,21 @@ function compareRecords( rightRecord: MarketRecord, sort: MarketSortState ): number { - const leftValue = leftRecord.rates?.[sort.field]; - const rightValue = rightRecord.rates?.[sort.field]; + if (isRateSortField(sort.field)) { + return compareRateSortRecords(leftRecord, rightRecord, sort); + } + + return compareBackendMetricRecords(leftRecord, rightRecord, sort); +} + +function compareRateSortRecords( + leftRecord: MarketRecord, + rightRecord: MarketRecord, + sort: MarketSortState +): number { + const field = sort.field as keyof Required; + const leftValue = leftRecord.rates?.[field]; + const rightValue = rightRecord.rates?.[field]; const leftLowerBound = parseRateLowerBound(leftValue ?? null); const rightLowerBound = parseRateLowerBound(rightValue ?? null); @@ -93,3 +109,50 @@ function compareRecords( const tieBreak = compareRateValues(leftValue, rightValue); return sort.direction === "asc" ? tieBreak : -tieBreak; } + +function compareBackendMetricRecords( + leftRecord: MarketRecord, + rightRecord: MarketRecord, + sort: MarketSortState +): number { + const field = sort.field as keyof Required; + const leftValue = parseBackendMetricValue(leftRecord.backendMetrics?.[field]); + const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]); + + if (leftValue == null && rightValue == null) { + return 0; + } + + if (leftValue == null) { + return 1; + } + + if (rightValue == null) { + return -1; + } + + return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue; +} + +function parseBackendMetricValue(value: string | null | undefined): number | null { + if (!value) { + return null; + } + + const normalizedValue = value.replace(/,/g, "").replace(/%/g, "").trim(); + if (!normalizedValue) { + return null; + } + + const numericValue = Number(normalizedValue); + return Number.isFinite(numericValue) ? numericValue : null; +} + +function isRateSortField( + field: MarketSortField +): field is keyof Required { + return ( + field === "singleVideoAfterSearchRate" || + field === "personalVideoAfterSearchRate" + ); +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts index e621a5c..d025aa5 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -4,6 +4,7 @@ import { applyRowOrder, applyRowVisibility, renderMarketRowState, + syncPluginSortHeaders, syncMarketTable, type MarketRowDom } from "./dom-sync"; @@ -14,7 +15,8 @@ import { ensurePluginToolbar } from "./plugin-toolbar"; import { readToolbarExportTarget, setToolbarBusyState, - setToolbarExportStatus + setToolbarExportStatus, + setToolbarSortState } from "./plugin-toolbar"; import { createMarketResultStore } from "./result-store"; import { @@ -88,6 +90,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { }, prepareCurrentPageForExport: prepareCurrentPageForExport, readCurrentPageRecords: () => getVisibleOrderedRecords(), + readCurrentPageRowCount: () => countCurrentPageRows(options.document), window: options.window }); let activeFilters: MarketFilterState = {}; @@ -99,12 +102,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { scheduleSync(); }); const observationRoot = options.document.body ?? options.document.documentElement; - if (observationRoot) { - observer.observe(observationRoot, { - childList: true, - subtree: true - }); - } + startObserving(); const toolbar = ensurePluginToolbar(options.document, { onApplyFilter: async () => { @@ -209,7 +207,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { for (const rowDom of table.rows) { const rowSnapshot = readRowSnapshot(rowDom); - if (!rowSnapshot.authorId) { + if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) { continue; } @@ -362,14 +360,27 @@ export function createMarketController(options: CreateMarketControllerOptions) { } function applyCurrentView(): void { - const table = syncMarketTable(options.document); - if (!table) { - return; - } + runWithoutMutationSync(() => { + const table = syncMarketTable(options.document); + if (!table) { + return; + } - const records = getVisibleOrderedRecords(table); - applyRowVisibility(table, new Set(records.map((record) => record.authorId))); - applyRowOrder(table, records.map((record) => record.authorId)); + syncPluginSortHeaders(options.document, { + activeSort, + onToggleSort: toggleSortFromHeader + }); + + const records = getVisibleOrderedRecords(table); + applyRowVisibility(table, new Set(records.map((record) => record.authorId))); + applyRowOrder(table, records.map((record) => record.authorId)); + }); + } + + function toggleSortFromHeader(field: MarketSortState["field"]): void { + activeSort = getNextSortState(activeSort, field); + setToolbarSortState(toolbar, activeSort); + applyCurrentView(); } function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] { @@ -397,6 +408,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { async function prepareCurrentPageForExport(): Promise { await runSyncCycle(); await harvestCurrentPageForExport(); + await runSyncCycle(); } async function harvestCurrentPageForExport(): Promise { @@ -445,29 +457,37 @@ export function createMarketController(options: CreateMarketControllerOptions) { return table.rows .map((rowDom) => { const rowSnapshot = readRowSnapshot(rowDom); - if (!rowSnapshot.authorId) { + if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) { return null; } const existingRecord = resultStore.getRecord(rowSnapshot.authorId); + const authorName = + mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? ""; + const location = mergeStringValue(existingRecord?.location, rowSnapshot.location); + const price21To60s = mergeStringValue( + existingRecord?.price21To60s, + rowSnapshot.price21To60s + ); return { ...existingRecord, ...rowSnapshot, - authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "", + authorName, backendMetrics: mergeFieldMap( existingRecord?.backendMetrics, rowSnapshot.backendMetrics ), backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle", - exportFields: mergeFieldMap( - existingRecord?.exportFields, - rowSnapshot.exportFields - ), - location: mergeStringValue(existingRecord?.location, rowSnapshot.location), - price21To60s: mergeStringValue( - existingRecord?.price21To60s, - rowSnapshot.price21To60s + exportFields: withExportFieldFallbacks( + mergeFieldMap(existingRecord?.exportFields, rowSnapshot.exportFields), + { + authorName, + location, + price21To60s + } ), + location, + price21To60s, rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates), status: existingRecord?.status ?? "idle" } satisfies MarketRecord; @@ -488,27 +508,42 @@ export function createMarketController(options: CreateMarketControllerOptions) { return null; } - const seenElements = new Set(); + const candidateScores = new Map(); const candidateRoots = table.rows .map((rowDom) => rowDom.row) .filter((row): row is HTMLElement => row instanceof options.window.HTMLElement); for (const rootElement of candidateRoots) { let currentElement = rootElement.parentElement; + let depth = 0; while (currentElement) { - if ( - !seenElements.has(currentElement) && - isScrollableContainer(currentElement) - ) { - return currentElement; + if (isScrollableContainer(currentElement)) { + const scrollRange = currentElement.scrollHeight - currentElement.clientHeight; + const existingScore = candidateScores.get(currentElement); + if (!existingScore || depth < existingScore.depth) { + candidateScores.set(currentElement, { + depth, + scrollRange + }); + } } - seenElements.add(currentElement); + depth += 1; currentElement = currentElement.parentElement; } } - return null; + const rankedCandidates = Array.from(candidateScores.entries()).sort((left, right) => { + const [, leftScore] = left; + const [, rightScore] = right; + if (rightScore.scrollRange !== leftScore.scrollRange) { + return rightScore.scrollRange - leftScore.scrollRange; + } + + return leftScore.depth - rightScore.depth; + }); + + return rankedCandidates[0]?.[0] ?? null; } function isScrollableContainer(element: HTMLElement): boolean { @@ -529,8 +564,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { async function collectCurrentPageSnapshotsUntilSettled(): Promise { let previousFingerprint = ""; let stablePassCount = 0; + let fingerprintStableSince = 0; - for (let attempt = 0; attempt < 9; attempt += 1) { + for (let attempt = 0; attempt < 16; attempt += 1) { await waitForDomSettled(); if (attempt > 0) { await new Promise((resolve) => { @@ -555,21 +591,38 @@ export function createMarketController(options: CreateMarketControllerOptions) { } else { previousFingerprint = hydrationSnapshot.fingerprint; stablePassCount = 1; + fingerprintStableSince = options.window.Date.now(); } - if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) { + const stableForMs = options.window.Date.now() - fingerprintStableSince; + if ( + hydrationSnapshot.missingDefaultFieldCount === 0 && + hydrationSnapshot.blankExportFieldCount === 0 && + stablePassCount >= 2 + ) { + return; + } + + if ( + hydrationSnapshot.missingDefaultFieldCount === 0 && + hydrationSnapshot.blankExportFieldCount > 0 && + stablePassCount >= 2 && + stableForMs >= 500 + ) { return; } } } function readVisibleRowHydrationSnapshot(): { + blankExportFieldCount: number; fingerprint: string; missingDefaultFieldCount: number; } { const table = syncMarketTable(options.document); if (!table || table.rows.length === 0) { return { + blankExportFieldCount: 0, fingerprint: "", missingDefaultFieldCount: 0 }; @@ -580,6 +633,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter( (value) => typeof value === "string" && value.trim().length > 0 ).length; + const blankExportFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter( + (value) => typeof value !== "string" || value.trim().length === 0 + ).length; + const hasAuthorField = hasTextValue(rowSnapshot.exportFields?.["达人信息"]); const hasRepresentativeVideo = hasTextValue( rowSnapshot.exportFields?.["代表视频"] ); @@ -587,11 +644,15 @@ export function createMarketController(options: CreateMarketControllerOptions) { hasTextValue(rowSnapshot.price21To60s) || hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]); const missingDefaultFieldCount = - Number(!hasRepresentativeVideo) + Number(!hasPriceField); + Number(!hasAuthorField) + + Number(!hasRepresentativeVideo) + + Number(!hasPriceField); return [ rowSnapshot.authorId, populatedFieldCount, + `blank:${blankExportFieldCount}`, + hasAuthorField ? "author" : "no-author", hasRepresentativeVideo ? "video" : "no-video", hasPriceField ? "price" : "no-price", `missing:${missingDefaultFieldCount}` @@ -599,6 +660,10 @@ export function createMarketController(options: CreateMarketControllerOptions) { }); return { + blankExportFieldCount: parts.reduce((count, part) => { + const match = part.match(/:blank:(\d+):/); + return count + Number(match?.[1] ?? 0); + }, 0), fingerprint: parts.join("|"), missingDefaultFieldCount: parts.reduce((count, part) => { const match = part.match(/missing:(\d+)$/); @@ -624,6 +689,26 @@ export function createMarketController(options: CreateMarketControllerOptions) { }, 0); } + function runWithoutMutationSync(callback: () => void): void { + observer.disconnect(); + try { + callback(); + } finally { + startObserving(); + } + } + + function startObserving(): void { + if (!observationRoot) { + return; + } + + observer.observe(observationRoot, { + childList: true, + subtree: true + }); + } + async function runSyncCycle(): Promise { if (isSyncRunning) { needsResync = true; @@ -658,7 +743,19 @@ function readCurrentPageRows(document: Document): MarketRowSnapshot[] { return table.rows .map((rowDom) => readRowSnapshot(rowDom)) - .filter((row): row is MarketRowSnapshot => Boolean(row.authorId)); + .filter( + (row): row is MarketRowSnapshot => + Boolean(row.authorId) && hasTextValue(row.authorName) + ); +} + +function countCurrentPageRows(document: Document): number { + const table = syncMarketTable(document); + if (!table) { + return 0; + } + + return table.rows.filter((rowDom) => Boolean(rowDom.authorId)).length; } function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { @@ -667,6 +764,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { authorName: rowDom.authorName, exportFields: rowDom.exportFields, hasDirectRatesSource: rowDom.hasDirectRatesSource, + location: rowDom.location, price21To60s: rowDom.price21To60s, rates: rowDom.rates }; @@ -695,6 +793,27 @@ function readSortState( }; } +function getNextSortState( + currentSort: MarketSortState | undefined, + field: MarketSortState["field"] +): MarketSortState | undefined { + if (!currentSort || currentSort.field !== field) { + return { + direction: "desc", + field + }; + } + + if (currentSort.direction === "desc") { + return { + direction: "asc", + field + }; + } + + return undefined; +} + function mergeFieldMap>( current: T | undefined, incoming: T | undefined @@ -805,6 +924,46 @@ function mergeStringValue( return current; } +function withExportFieldFallbacks( + exportFields: Record | undefined, + fallbackValues: { + authorName: string; + location: string | undefined; + price21To60s: string | undefined; + } +): Record | undefined { + if (!exportFields) { + return undefined; + } + + const nextExportFields = { + ...exportFields + }; + if ( + "达人信息" in nextExportFields && + !hasTextValue(nextExportFields["达人信息"]) && + hasTextValue(fallbackValues.authorName) + ) { + nextExportFields["达人信息"] = fallbackValues.authorName; + } + if ( + "地区" in nextExportFields && + !hasTextValue(nextExportFields["地区"]) && + hasTextValue(fallbackValues.location) + ) { + nextExportFields["地区"] = fallbackValues.location; + } + if ( + "21-60s报价" in nextExportFields && + !hasTextValue(nextExportFields["21-60s报价"]) && + hasTextValue(fallbackValues.price21To60s) + ) { + nextExportFields["21-60s报价"] = fallbackValues.price21To60s; + } + + return nextExportFields; +} + function hasTextValue(value: string | undefined): boolean { return typeof value === "string" && value.trim().length > 0; } diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 44a1243..f5eab9d 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -1,4 +1,46 @@ -import type { MarketExportScope, MarketExportTarget } from "./types"; +import type { + MarketExportScope, + MarketExportTarget, + MarketSortState +} from "./types"; + +const SORT_FIELD_OPTIONS = [ + { + label: "单视频看后搜率", + value: "singleVideoAfterSearchRate" + }, + { + label: "个人视频看后搜率", + value: "personalVideoAfterSearchRate" + }, + { + label: "看后搜率", + value: "afterViewSearchRate" + }, + { + label: "看后搜数", + value: "afterViewSearchCount" + }, + { + label: "新增A3数", + value: "a3IncreaseCount" + }, + { + label: "新增A3率", + value: "newA3Rate" + }, + { + label: "CPA3", + value: "cpa3" + }, + { + label: "cp_search", + value: "cpSearch" + } +] as const satisfies Array<{ + label: string; + value: NonNullable; +}>; export interface PluginToolbarHandlers { onApplyFilter(): Promise | void; @@ -54,8 +96,9 @@ export function ensurePluginToolbar( const sortFieldSelect = document.createElement("select"); sortFieldSelect.dataset.pluginSortField = "select"; appendOption(sortFieldSelect, "", "不排序"); - appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率"); - appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率"); + SORT_FIELD_OPTIONS.forEach(({ label, value }) => { + appendOption(sortFieldSelect, value, label); + }); const sortDirectionSelect = document.createElement("select"); sortDirectionSelect.dataset.pluginSortDirection = "select"; @@ -293,6 +336,14 @@ export function setToolbarExportStatus( toolbar.exportStatusText.textContent = text; } +export function setToolbarSortState( + toolbar: PluginToolbarDom, + sort: MarketSortState | undefined +): void { + toolbar.sortFieldSelect.value = sort?.field ?? ""; + toolbar.sortDirectionSelect.value = sort?.direction ?? "desc"; +} + function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void { toolbar.exportCustomPagesInput.hidden = toolbar.exportRangeSelect.value !== "custom"; diff --git a/src/content/market/types.ts b/src/content/market/types.ts index 4f18fa4..f496f37 100644 --- a/src/content/market/types.ts +++ b/src/content/market/types.ts @@ -12,6 +12,10 @@ export interface BackendMetrics { newA3Rate?: string; } +export type MarketSortField = + | keyof Required + | keyof Required; + export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing"; export interface MarketRowSnapshot { @@ -49,7 +53,7 @@ export type MarketExportTarget = export interface MarketSortState { direction: "asc" | "desc"; - field: keyof Required; + field: MarketSortField; } export type MarketApiFailureReason = diff --git a/tests/filter-sort-controller.test.ts b/tests/filter-sort-controller.test.ts index 3880b69..40db0fc 100644 --- a/tests/filter-sort-controller.test.ts +++ b/tests/filter-sort-controller.test.ts @@ -93,4 +93,32 @@ describe("filter-sort-controller", () => { expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]); }); + + test("sorts by backend metric descending and keeps empty values at the end", () => { + const result = applyFilterAndSort( + [ + { + ...baseRecords[0], + backendMetrics: { + afterViewSearchRate: "0.36%" + } + }, + { + ...baseRecords[1], + backendMetrics: { + afterViewSearchRate: "1.4%" + } + }, + baseRecords[2] + ], + { + sort: { + direction: "desc", + field: "afterViewSearchRate" + } + } + ); + + expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]); + }); }); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 5b1ef29..2514558 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -273,13 +273,13 @@ describe("market-content-entry", () => { expect(searchBackendMetrics).toHaveBeenCalledTimes(1); expect(searchBackendMetrics).toHaveBeenCalledWith(["a", "b"]); expect( - document.querySelector('[data-market-row-cell="backendMetrics"]')?.textContent - ).toContain("看后搜率"); + document.querySelector('[data-market-row-cell="afterViewSearchRate"]')?.textContent + ).toBe("0.36%"); expect( - document.querySelector('[data-market-row-cell="backendMetrics"]')?.textContent - ).toContain("0.36%"); + document.querySelector('[data-market-row-cell="cpSearch"]')?.textContent + ).toBe("14.46"); expect( - document.querySelectorAll('[data-market-row-cell="backendMetrics"]')[1]?.textContent + document.querySelectorAll('[data-market-row-cell="afterViewSearchRate"]')[1]?.textContent ).toBe("暂无数据"); }); @@ -340,19 +340,27 @@ describe("market-content-entry", () => { await controller.ready; - expect(readDivRightRowTexts(0)).toEqual([ - "¥450,000", + expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]); + expect(readDivPluginRowTexts(0)).toEqual([ "0.02% - 0.1%", "0.03% - 0.2%", "", - "下单" + "", + "", + "", + "", + "" ]); - expect(readDivRightRowTexts(1)).toEqual([ - "¥20,000", + expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]); + expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "", - "下单" + "", + "", + "", + "", + "" ]); }); @@ -408,19 +416,27 @@ describe("market-content-entry", () => { await controller.ready; expect(loadAuthorMetrics).toHaveBeenCalledTimes(2); - expect(readDivRightRowTexts(0)).toEqual([ - "¥450,000", + expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]); + expect(readDivPluginRowTexts(0)).toEqual([ "0.02%", "0.03% - 0.2%", "", - "下单" + "", + "", + "", + "", + "" ]); - expect(readDivRightRowTexts(1)).toEqual([ - "¥20,000", + expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]); + expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "", - "下单" + "", + "", + "", + "", + "" ]); }); @@ -471,19 +487,27 @@ describe("market-content-entry", () => { await controller.ready; - expect(readDivRightRowTexts(0)).toEqual([ - "¥450,000", + expect(readDivRightRowTexts(0)).toEqual(["¥450,000", "下单"]); + expect(readDivPluginRowTexts(0)).toEqual([ "0.02%", "0.03% - 0.2%", "", - "下单" + "", + "", + "", + "", + "" ]); - expect(readDivRightRowTexts(1)).toEqual([ - "¥20,000", + expect(readDivRightRowTexts(1)).toEqual(["¥20,000", "下单"]); + expect(readDivPluginRowTexts(1)).toEqual([ "0.5% - 1%", "0.01% - 0.1%", "", - "下单" + "", + "", + "", + "", + "" ]); }); @@ -555,6 +579,117 @@ describe("market-content-entry", () => { expect(readRowOrder()).toEqual(["b", "a"]); }); + test("clicking plugin sort headers cycles sort state and syncs the toolbar", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + } + ]); + const resultStore = createMarketResultStore(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + resultStore, + window + })); + + await controller.ready; + resultStore.setAuthorSuccess("111", { + singleVideoAfterSearchRate: "0.02% - 0.1%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + }); + resultStore.setAuthorSuccess("222", { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + }); + + click('[data-market-sort-field="singleVideoAfterSearchRate"]'); + await flush(); + + expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); + expectSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate"); + expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); + expect( + document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') + ?.getAttribute("data-market-sort-direction") + ).toBe("desc"); + + click('[data-market-sort-field="singleVideoAfterSearchRate"]'); + await flush(); + + expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); + expectSelectValue('[data-plugin-sort-direction="select"]', "asc"); + expect( + document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') + ?.getAttribute("data-market-sort-direction") + ).toBe("asc"); + + click('[data-market-sort-field="singleVideoAfterSearchRate"]'); + await flush(); + + expect(readDivAuthorOrder()).toEqual(["达人 A", "达人 B"]); + expectSelectValue('[data-plugin-sort-field="select"]', ""); + expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); + expect( + document.querySelector('[data-market-sort-field="singleVideoAfterSearchRate"]') + ?.getAttribute("data-market-sort-direction") + ).toBe("none"); + }); + + test("clicking backend metric headers sorts by metric values on the current page", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + } + ]); + const resultStore = createMarketResultStore(); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + resultStore, + window + })); + + await controller.ready; + resultStore.setBackendMetricsSuccess("111", { + afterViewSearchRate: "0.36%" + }); + resultStore.setBackendMetricsSuccess("222", { + afterViewSearchRate: "1.4%" + }); + + click('[data-market-sort-field="afterViewSearchRate"]'); + await flush(); + + expect(readDivAuthorOrder()).toEqual(["达人 B", "达人 A"]); + expectSelectValue('[data-plugin-sort-field="select"]', "afterViewSearchRate"); + expectSelectValue('[data-plugin-sort-direction="select"]', "desc"); + }); + test("toolbar defaults export range to the first 5 pages and reveals custom input on demand", async () => { document.body.innerHTML = buildMarketFixture(); @@ -1172,6 +1307,247 @@ describe("market-content-entry", () => { expect(onCsvReady).toHaveBeenCalledWith("csv-output"); }); + test("export harvests lazy current-page fields from the effective scroll container", async () => { + const rows = [ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + }, + { + authorId: "333", + authorName: "达人 C", + price21To60s: "¥30,000" + }, + { + authorId: "444", + authorName: "达人 D", + price21To60s: "¥40,000" + } + ]; + + document.body.innerHTML = ` +
+
+ ${buildRealMarketFixture(rows)} +
+
+ `; + const outerScrollContainer = document.querySelector( + '[data-testid="market-outer-scroll-shell"]' + ) as HTMLElement; + const innerScrollContainer = document.querySelector( + '[data-testid="market-inner-scroll-shell"]' + ) as HTMLElement; + installLazyFieldHydrationHarness({ + hiddenRowIndexes: [2, 3], + scrollContainer: outerScrollContainer + }); + + let innerScrollTop = 0; + Object.defineProperty(innerScrollContainer, "clientHeight", { + configurable: true, + value: 120 + }); + Object.defineProperty(innerScrollContainer, "scrollHeight", { + configurable: true, + value: 240 + }); + Object.defineProperty(innerScrollContainer, "scrollTop", { + configurable: true, + get() { + return innerScrollTop; + }, + set(value: number) { + innerScrollTop = value; + } + }); + + const resultStore = createMarketResultStore(); + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + resultStore, + window + })); + + await controller.ready; + rows.forEach((row, index) => { + resultStore.setAuthorSuccess(row.authorId, { + personalVideoAfterSearchRate: `0.0${index + 1}%`, + singleVideoAfterSearchRate: `0.1${index + 1}%` + }); + }); + + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 50); + + expect(buildCsv.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "333", + exportFields: expect.objectContaining({ + "21-60s报价": "¥30,000", + 代表视频: "代表视频达人 C" + }) + }), + expect.objectContaining({ + authorId: "444", + exportFields: expect.objectContaining({ + "21-60s报价": "¥40,000", + 代表视频: "代表视频达人 D" + }) + }) + ]) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }); + + test("export reloads backend metrics for rows discovered during scroll harvest", async () => { + const rows = [ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥20,000" + }, + { + authorId: "333", + authorName: "达人 C", + price21To60s: "¥30,000" + }, + { + authorId: "444", + authorName: "达人 D", + price21To60s: "¥40,000" + } + ]; + + document.body.innerHTML = ` +
+ ${buildRealMarketFixture(rows)} +
+ `; + installLazyFieldHydrationHarness({ + hiddenRowIndexes: [2, 3], + hideAuthorIdentity: true, + scrollContainer: document.querySelector( + '[data-testid="market-scroll-shell"]' + ) as HTMLElement + }); + + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + const searchBackendMetrics = vi.fn(async (starIds: string[]) => + starIds.map((starId) => ({ + a3IncreaseCount: `${starId}-a3`, + afterViewSearchCount: `${starId}-count`, + afterViewSearchRate: `${starId}-rate`, + cpSearch: `${starId}-cp-search`, + cpa3: `${starId}-cpa3`, + newA3Rate: `${starId}-new-a3`, + starId + })) + ); + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + searchBackendMetrics, + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 50); + + expect(buildCsv.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "333", + backendMetrics: expect.objectContaining({ + afterViewSearchRate: "333-rate" + }) + }), + expect.objectContaining({ + authorId: "444", + backendMetrics: expect.objectContaining({ + afterViewSearchRate: "444-rate" + }) + }) + ]) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }); + + test("export excludes rows whose author name is still empty", async () => { + document.body.innerHTML = buildMarketFixture(); + const blankRow = document.querySelector('[data-market-row=\"b\"]'); + if (!(blankRow instanceof HTMLElement)) { + throw new Error("Missing blank-row fixture"); + } + const authorNameCell = blankRow.querySelector('[data-market-field=\"authorName\"]'); + if (!(authorNameCell instanceof HTMLElement)) { + throw new Error("Missing author name cell"); + } + authorNameCell.textContent = ""; + + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 50); + + expect(buildCsv.mock.calls[0][0]).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "b" + }) + ]) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }); + test( "export waits for delayed lazy field hydration before reading current-page rows", async () => { @@ -1264,6 +1640,225 @@ describe("market-content-entry", () => { 15000 ); + test( + "export waits for delayed rich field hydration before reading current-page rows", + async () => { + const rows = [ + { + authorId: "111", + authorName: "达人 A", + price21To60s: "¥11,000" + }, + { + authorId: "222", + authorName: "达人 B", + price21To60s: "¥22,000" + }, + { + authorId: "333", + authorName: "达人 C", + price21To60s: "¥33,000" + }, + { + authorId: "444", + authorName: "达人 D", + price21To60s: "¥44,000" + } + ]; + + document.body.innerHTML = ` +
+ ${buildRichExportMarketFixture(rows)} +
+ `; + installRichLazyFieldHydrationHarness({ + hiddenRowIndexes: [2, 3], + hydrateDelayMs: 350, + scrollContainer: document.querySelector( + '[data-testid="market-scroll-shell"]' + ) as HTMLElement + }); + + const buildCsv = vi.fn(() => "csv-output"); + const onCsvReady = vi.fn(); + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "current"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 120, 50); + + expect(buildCsv).toHaveBeenCalledTimes(1); + expect(buildCsv.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "333", + exportFields: expect.objectContaining({ + "21-60s报价": "¥33,000", + 互动率: "7.3%", + 代表视频: "代表视频达人 C", + 内容主题: "内容主题达人 C", + 完播率: "26.3%", + 爆文率: "10%", + 粉丝数: "33.3w", + 达人信息: "达人 C", + 达人类型: "剧情", + 连接用户数: "300w", + 预期CPM: "23.3", + 预期播放量: "63.3w" + }) + }), + expect.objectContaining({ + authorId: "444", + exportFields: expect.objectContaining({ + "21-60s报价": "¥44,000", + 互动率: "7.4%", + 代表视频: "代表视频达人 D", + 内容主题: "内容主题达人 D", + 完播率: "26.4%", + 爆文率: "11%", + 粉丝数: "44.4w", + 达人信息: "达人 D", + 达人类型: "测评", + 连接用户数: "400w", + 预期CPM: "24.4", + 预期播放量: "64.4w" + }) + }) + ]) + ); + expect(onCsvReady).toHaveBeenCalledWith("csv-output"); + }, + 15000 + ); + + test( + "default export harvests lazy fields and backend metrics across the first 5 pages", + async () => { + const pages = [ + [ + { authorId: "111", authorName: "达人 A1", price21To60s: "¥11,000" }, + { authorId: "112", authorName: "达人 A2", price21To60s: "¥12,000" } + ], + [ + { authorId: "221", authorName: "达人 B1", price21To60s: "¥21,000" }, + { authorId: "222", authorName: "达人 B2", price21To60s: "¥22,000" } + ], + [ + { authorId: "331", authorName: "达人 C1", price21To60s: "¥31,000" }, + { authorId: "332", authorName: "达人 C2", price21To60s: "¥32,000" } + ], + [ + { authorId: "441", authorName: "达人 D1", price21To60s: "¥41,000" }, + { authorId: "442", authorName: "达人 D2", price21To60s: "¥42,000" } + ], + [ + { authorId: "551", authorName: "达人 E1", price21To60s: "¥51,000" }, + { authorId: "552", authorName: "达人 E2", price21To60s: "¥52,000" } + ] + ]; + + document.body.innerHTML = ` +
+ ${buildRealMarketFixture(pages[0])} +
+ `; + const pagination = installAsyncPaginationHarness(pages); + installPagedLazyFieldHydrationHarness({ + hiddenRowIndexes: [1], + hideAuthorIdentity: true, + scrollContainer: document.querySelector( + '[data-testid="market-scroll-shell"]' + ) as HTMLElement + }); + + const buildCsv = vi.fn(() => "csv-output"); + const searchBackendMetrics = vi.fn(async (starIds: string[]) => + starIds.map((starId) => ({ + a3IncreaseCount: `${starId}-a3`, + afterViewSearchCount: `${starId}-count`, + afterViewSearchRate: `${starId}-rate`, + cpSearch: `${starId}-cp-search`, + cpa3: `${starId}-cpa3`, + newA3Rate: `${starId}-new-a3`, + starId + })) + ); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + buildCsv, + document, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady: vi.fn(), + searchBackendMetrics, + window + })); + + await controller.ready; + setSelectValue('[data-plugin-export-range="select"]', "first-5"); + dispatchChange('[data-plugin-export-range="select"]'); + click('[data-plugin-export="button"]'); + await waitForMockCall(buildCsv, 160, 100); + + expect(pagination.getClicks()).toBe(4); + expect(buildCsv).toHaveBeenCalledTimes(1); + expect( + buildCsv.mock.calls[0][0].map((record) => record.authorId).sort() + ).toEqual([ + "111", + "112", + "221", + "222", + "331", + "332", + "441", + "442", + "551", + "552" + ]); + expect(buildCsv.mock.calls[0][0]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + authorId: "112", + backendMetrics: expect.objectContaining({ + afterViewSearchRate: "112-rate" + }), + exportFields: expect.objectContaining({ + "21-60s报价": "¥12,000", + "达人信息": "达人 A2" + }) + }), + expect.objectContaining({ + authorId: "552", + backendMetrics: expect.objectContaining({ + afterViewSearchRate: "552-rate" + }), + exportFields: expect.objectContaining({ + "21-60s报价": "¥52,000", + "达人信息": "达人 E2" + }) + }) + ]) + ); + }, + 20000 + ); + test("rehydrates rows when the market list DOM changes", async () => { document.body.innerHTML = buildMarketFixture(); const observer = createMutationObserverFactory(); @@ -1457,6 +2052,145 @@ function buildRealMarketFixture( `; } +function buildRichExportMarketFixture( + rows: Array<{ + authorId: string; + authorName: string; + price21To60s: string; + }> +) { + const middleColumns = [ + { + header: "代表视频", + readValue: (row: { authorName: string }) => `代表视频${row.authorName}` + }, + { + header: "达人类型", + readValue: (row: { authorId: string }) => + row.authorId === "444" ? "测评" : "剧情" + }, + { + header: "内容主题", + readValue: (row: { authorName: string }) => `内容主题${row.authorName}` + }, + { + header: "连接用户数", + readValue: (row: { authorId: string }) => `${row.authorId[0]}00w` + }, + { + header: "粉丝数", + readValue: (row: { authorId: string }) => `${row.authorId[0]}${row.authorId[0]}.${row.authorId[0]}w` + }, + { + header: "预期CPM", + readValue: (row: { authorId: string }) => `2${row.authorId[0]}.${row.authorId[0]}` + }, + { + header: "预期播放量", + readValue: (row: { authorId: string }) => `6${row.authorId[0]}.${row.authorId[0]}w` + }, + { + header: "互动率", + readValue: (row: { authorId: string }) => `7.${row.authorId[0]}%` + }, + { + header: "完播率", + readValue: (row: { authorId: string }) => `26.${row.authorId[0]}%` + }, + { + header: "爆文率", + readValue: (row: { authorId: string }) => `${Number(row.authorId[0]) + 7}%` + } + ]; + const middleWidth = middleColumns.reduce((width, column) => { + if (column.header === "内容主题") { + return width + 180; + } + + if (column.header === "代表视频") { + return width + 190; + } + + return width + 120; + }, 0); + + return ` +
+ +
+
+
+ ${rows + .map( + (row) => ` + + ` + ) + .join("")} +
+
+
+ ${middleColumns + .map((column) => { + const width = column.header === "内容主题" ? 180 : column.header === "代表视频" ? 190 : 120; + return ` +
+ ${rows + .map( + (row) => ` +
${column.readValue(row)}
+ ` + ) + .join("")} +
+ `; + }) + .join("")} +
+
+
+ ${rows + .map( + (row) => ` +
${row.price21To60s}
+ ` + ) + .join("")} +
+
+ ${rows + .map( + (row) => ` +
下单
+ ` + ) + .join("")} +
+
+
+
+ + `; +} + function buildRealMarketFixtureWithoutAuthorIds( rows: Array<{ authorName: string; @@ -1994,34 +2728,58 @@ function installProgressivePaginationHarness( } function installLazyFieldHydrationHarness(options: { + hideAuthorIdentity?: boolean; hiddenRowIndexes: number[]; + hideAuthorCells?: boolean; hydrateDelayMs?: number; scrollContainer: HTMLElement; }) { - const { hiddenRowIndexes, hydrateDelayMs = 0, scrollContainer } = options; + const { + hideAuthorIdentity = false, + hiddenRowIndexes, + hideAuthorCells = false, + hydrateDelayMs = 0, + scrollContainer + } = options; const rightColumns = document.querySelectorAll( '[data-testid="right-section"] > .content-column' ); + const authorCells = Array.from( + document.querySelectorAll('[data-testid="author-section"] .content-column .content-cell') + ) as HTMLElement[]; const middleCells = Array.from( document.querySelectorAll(".middle-columns .content-column .content-cell") ) as HTMLElement[]; const priceCells = Array.from(rightColumns[0]?.querySelectorAll(".content-cell") ?? []) as HTMLElement[]; const hiddenCells = hiddenRowIndexes.flatMap((rowIndex) => { + const authorCell = hideAuthorCells ? authorCells[rowIndex] ?? null : null; const middleCell = middleCells[rowIndex] ?? null; const priceCell = priceCells[rowIndex] ?? null; - return [middleCell, priceCell] + return [authorCell, middleCell, priceCell] .filter((cell): cell is HTMLElement => cell !== null) .map((cell) => ({ cell, text: cell.textContent ?? "" })); }); + const hiddenAuthorIdentityCells = hideAuthorIdentity + ? hiddenRowIndexes + .map((rowIndex) => authorCells[rowIndex] ?? null) + .filter((cell): cell is HTMLElement => cell !== null) + .map((cell) => ({ + cell, + html: cell.innerHTML + })) + : []; hiddenCells.forEach(({ cell }) => { cell.textContent = ""; }); + hiddenAuthorIdentityCells.forEach(({ cell }) => { + cell.innerHTML = ""; + }); let hydrated = false; let scrollTopValue = 0; @@ -2046,6 +2804,9 @@ function installLazyFieldHydrationHarness(options: { hydrated = true; window.setTimeout(() => { + hiddenAuthorIdentityCells.forEach(({ cell, html }) => { + cell.innerHTML = html; + }); hiddenCells.forEach(({ cell, text }) => { cell.textContent = text; }); @@ -2054,6 +2815,235 @@ function installLazyFieldHydrationHarness(options: { }); } +function installRichLazyFieldHydrationHarness(options: { + hiddenRowIndexes: number[]; + hydrateDelayMs?: number; + scrollContainer: HTMLElement; +}) { + const { hiddenRowIndexes, hydrateDelayMs = 0, scrollContainer } = options; + const middleColumns = Array.from( + document.querySelectorAll(".middle-columns .content-column") + ) as HTMLElement[]; + const delayedColumns = middleColumns.slice(1); + const hiddenCells = hiddenRowIndexes.flatMap((rowIndex) => { + const rowCells = delayedColumns.map( + (column) => + (Array.from(column.querySelectorAll(".content-cell"))[rowIndex] as HTMLElement | undefined) ?? + null + ); + + return rowCells + .filter((cell): cell is HTMLElement => cell !== null) + .map((cell) => ({ + cell, + html: cell.innerHTML + })); + }); + + hiddenCells.forEach(({ cell }) => { + cell.innerHTML = ""; + }); + + let hydrated = false; + let scrollTopValue = 0; + Object.defineProperty(scrollContainer, "clientHeight", { + configurable: true, + value: 120 + }); + Object.defineProperty(scrollContainer, "scrollHeight", { + configurable: true, + value: 480 + }); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + get() { + return scrollTopValue; + }, + set(value: number) { + scrollTopValue = value; + if (hydrated || value <= 0) { + return; + } + + hydrated = true; + window.setTimeout(() => { + hiddenCells.forEach(({ cell, html }) => { + cell.innerHTML = html; + }); + }, hydrateDelayMs); + } + }); +} + +function installPagedLazyFieldHydrationHarness(options: { + hideAuthorIdentity?: boolean; + hiddenRowIndexes: number[]; + hideAuthorCells?: boolean; + hydrateDelayMs?: number; + scrollContainer: HTMLElement; +}) { + const { + hideAuthorIdentity = false, + hiddenRowIndexes, + hideAuthorCells = false, + hydrateDelayMs = 0, + scrollContainer + } = options; + const observers: MutationObserver[] = []; + let currentPageToken = ""; + let hiddenPageToken = ""; + let hydratedPageToken = ""; + let hiddenTextCells: Array<{ cell: HTMLElement; text: string }> = []; + let hiddenAuthorIdentityCells: Array<{ cell: HTMLElement; html: string }> = []; + let scrollTopValue = 0; + + const readPageToken = () => + document.documentElement.getAttribute("data-test-page-index") ?? "1"; + + const clearPageState = () => { + hiddenTextCells = []; + hiddenAuthorIdentityCells = []; + }; + + const hideCurrentPage = () => { + const pageToken = readPageToken(); + if (pageToken === hiddenPageToken) { + return; + } + + const rightColumns = document.querySelectorAll( + '[data-testid="right-section"] > .content-column' + ); + const authorCells = Array.from( + document.querySelectorAll( + '[data-testid="author-section"] .content-column .content-cell' + ) + ) as HTMLElement[]; + const middleCells = Array.from( + document.querySelectorAll(".middle-columns .content-column .content-cell") + ) as HTMLElement[]; + const priceCells = Array.from( + rightColumns[0]?.querySelectorAll(".content-cell") ?? [] + ) as HTMLElement[]; + + clearPageState(); + hiddenTextCells = hiddenRowIndexes.flatMap((rowIndex) => { + const authorCell = hideAuthorCells ? authorCells[rowIndex] ?? null : null; + const middleCell = middleCells[rowIndex] ?? null; + const priceCell = priceCells[rowIndex] ?? null; + + return [authorCell, middleCell, priceCell] + .filter((cell): cell is HTMLElement => cell !== null) + .map((cell) => ({ + cell, + text: cell.textContent ?? "" + })); + }); + hiddenAuthorIdentityCells = hideAuthorIdentity + ? hiddenRowIndexes + .map((rowIndex) => authorCells[rowIndex] ?? null) + .filter((cell): cell is HTMLElement => cell !== null) + .map((cell) => ({ + cell, + html: cell.innerHTML + })) + : []; + + hiddenTextCells.forEach(({ cell }) => { + cell.textContent = ""; + }); + hiddenAuthorIdentityCells.forEach(({ cell }) => { + cell.innerHTML = ""; + }); + + currentPageToken = pageToken; + hiddenPageToken = pageToken; + hydratedPageToken = ""; + }; + + const hydrateCurrentPage = () => { + const pageToken = readPageToken(); + if (pageToken !== hiddenPageToken || pageToken === hydratedPageToken) { + return; + } + + hydratedPageToken = pageToken; + window.setTimeout(() => { + hiddenAuthorIdentityCells.forEach(({ cell, html }) => { + cell.innerHTML = html; + }); + hiddenTextCells.forEach(({ cell, text }) => { + cell.textContent = text; + }); + }, hydrateDelayMs); + }; + + const hideObserver = new MutationObserver(() => { + const pageToken = readPageToken(); + if (pageToken !== currentPageToken) { + currentPageToken = pageToken; + hiddenPageToken = ""; + hydratedPageToken = ""; + } + + window.setTimeout(() => { + hideCurrentPage(); + }, 0); + }); + hideObserver.observe(document.body, { + childList: true, + subtree: true + }); + observers.push(hideObserver); + + const pageObserver = new MutationObserver(() => { + const pageToken = readPageToken(); + if (pageToken === currentPageToken) { + return; + } + + currentPageToken = pageToken; + hiddenPageToken = ""; + hydratedPageToken = ""; + window.setTimeout(() => { + hideCurrentPage(); + }, 0); + }); + pageObserver.observe(document.documentElement, { + attributeFilter: ["data-test-page-index"], + attributes: true + }); + observers.push(pageObserver); + + Object.defineProperty(scrollContainer, "clientHeight", { + configurable: true, + value: 120 + }); + Object.defineProperty(scrollContainer, "scrollHeight", { + configurable: true, + value: 480 + }); + Object.defineProperty(scrollContainer, "scrollTop", { + configurable: true, + get() { + return scrollTopValue; + }, + set(value: number) { + scrollTopValue = value; + if (value <= 0) { + return; + } + + hydrateCurrentPage(); + } + }); + + hideCurrentPage(); + disposers.push(() => { + observers.forEach((observer) => observer.disconnect()); + }); +} + function createMutationObserverFactory() { let callback: MutationCallback = () => undefined; @@ -2098,6 +3088,15 @@ function setSelectValue(selector: string, value: string) { element.value = value; } +function expectSelectValue(selector: string, expected: string) { + const element = document.querySelector(selector) as HTMLSelectElement | null; + if (!element) { + throw new Error(`Missing select: ${selector}`); + } + + expect(element.value).toBe(expected); +} + function dispatchChange(selector: string) { const element = document.querySelector(selector) as HTMLElement | null; if (!element) { @@ -2113,6 +3112,13 @@ function readRowOrder() { ); } +function readDivAuthorOrder() { + return Array.from( + document.querySelectorAll('[data-testid^="author-cell-"] a'), + (link) => link.textContent?.trim() ?? "" + ); +} + function readDivRightRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), @@ -2121,6 +3127,14 @@ function readDivRightRowTexts(rowIndex: number) { ); } +function readDivPluginRowTexts(rowIndex: number) { + return Array.from( + document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), + (column) => + column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + ); +} + function trackController void }>(controller: T): T { if (controller.dispose) { disposers.push(() => controller.dispose?.()); diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index 2e32379..594f894 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -48,9 +48,22 @@ describe("market-dom-sync", () => { ) ).not.toBeNull(); expect( - document.querySelector('[data-market-header-cell="backendMetrics"]') + document.querySelector('[data-market-header-cell="afterViewSearchRate"]') ).not.toBeNull(); - expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(6); + expect( + document.querySelector('[data-market-header-cell="afterViewSearchCount"]') + ).not.toBeNull(); + expect( + document.querySelector('[data-market-header-cell="a3IncreaseCount"]') + ).not.toBeNull(); + expect( + document.querySelector('[data-market-header-cell="newA3Rate"]') + ).not.toBeNull(); + expect(document.querySelector('[data-market-header-cell="cpa3"]')).not.toBeNull(); + expect( + document.querySelector('[data-market-header-cell="cpSearch"]') + ).not.toBeNull(); + expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(16); }); test("renders loading, success, missing, and failed states", () => { @@ -87,12 +100,15 @@ describe("market-dom-sync", () => { }); expect(alphaRow.singleCell.textContent).toBe("加载中..."); - expect(alphaRow.backendMetricsCell.textContent).toBe("加载中..."); + expect(alphaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载中..."); expect(betaRow.singleCell.textContent).toBe("0.5% - 1%"); expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%"); - expect(betaRow.backendMetricsCell.textContent).toContain("看后搜率"); - expect(betaRow.backendMetricsCell.textContent).toContain("0.36%"); - expect(betaRow.backendMetricsCell.textContent).toContain("CPA3"); + expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("0.36%"); + expect(betaRow.backendMetricsCells.afterViewSearchCount.textContent).toBe("9,689.96"); + expect(betaRow.backendMetricsCells.a3IncreaseCount.textContent).toBe("78,366.22"); + expect(betaRow.backendMetricsCells.newA3Rate.textContent).toBe("3.44%"); + expect(betaRow.backendMetricsCells.cpa3.textContent).toBe("1.79"); + expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("14.46"); renderMarketRowState(betaRow, { authorId: "b", @@ -104,7 +120,8 @@ describe("market-dom-sync", () => { personalVideoAfterSearchRate: "0.02 - 0.1%" } }); - expect(betaRow.backendMetricsCell.textContent).toBe("暂无数据"); + expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("暂无数据"); + expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("暂无数据"); renderMarketRowState(betaRow, { authorId: "b", @@ -114,7 +131,8 @@ describe("market-dom-sync", () => { }); expect(betaRow.singleCell.textContent).toBe("加载失败"); expect(betaRow.personalCell.textContent).toBe("加载失败"); - expect(betaRow.backendMetricsCell.textContent).toBe("加载失败"); + expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载失败"); + expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("加载失败"); }); test("hides rows outside the visible author ids", () => { @@ -152,27 +170,55 @@ describe("market-dom-sync", () => { throw new Error("Expected market table"); } - expect(readRightHeaderTexts()).toEqual([ - "21-60s报价", + expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]); + expect(readPluginHeaderTexts()).toEqual([ "单视频看后搜率", "个人视频看后搜率", - "秒探指标", - "操作" + "看后搜率", + "看后搜数", + "新增A3数", + "新增A3率", + "CPA3", + "cp_search" ]); + expect( + document + .querySelector(".section-wrapper.sticky-header") + ?.classList.contains("hide-scrollbar") + ).toBe(false); + expect( + document.querySelector(".section-wrapper:not(.sticky-header)")?.classList.contains( + "hide-scrollbar" + ) + ).toBe(false); + expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标"); + expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1); + expect( + ( + document.querySelector('[data-testid="plugin-header"]') as HTMLElement + ).style.position + ).not.toBe("sticky"); + const pluginHeaderCells = Array.from( + document.querySelectorAll('[data-testid="plugin-header"] > .header-cell') + ) as HTMLElement[]; + expect(pluginHeaderCells[0]?.style.width).toBe("160px"); + expect(pluginHeaderCells[1]?.style.width).toBe("160px"); + expect(pluginHeaderCells[0]?.style.whiteSpace).toBe("nowrap"); + expect(pluginHeaderCells[1]?.style.whiteSpace).toBe("nowrap"); expect( Number.parseFloat( ( document.querySelector('[data-testid="right-header"]') as HTMLElement ).style.width ) - ).toBeGreaterThan(350); + ).toBe(350); expect( Number.parseFloat( ( document.querySelector('[data-testid="right-section"]') as HTMLElement ).style.width ) - ).toBeGreaterThan(350); + ).toBe(350); expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); renderMarketRowState(table.rows[0], { @@ -194,13 +240,21 @@ describe("market-dom-sync", () => { } }); - expect(readRightRowTexts(0)).toEqual([ - "¥450,000", + expect(readRightRowTexts(0)).toEqual(["¥450,000", "下单"]); + expect(readPluginRowTexts(0)).toEqual([ "0.5% - 1%", "0.02% - 0.1%", - "看后搜率0.36%看后搜数9,689.96新增A3数78,366.22新增A3率3.44%CPA31.79cp_search14.46", - "下单" + "0.36%", + "9,689.96", + "78,366.22", + "3.44%", + "1.79", + "14.46" ]); + expect(table.rows[0].singleCell.style.width).toBe("160px"); + expect(table.rows[0].personalCell.style.width).toBe("160px"); + expect(table.rows[0].singleCell.style.whiteSpace).toBe("nowrap"); + expect(table.rows[0].personalCell.style.whiteSpace).toBe("nowrap"); applyRowVisibility(table, new Set(["222"])); @@ -211,7 +265,8 @@ describe("market-dom-sync", () => { applyRowOrder(table, ["222", "111"]); expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]); - expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "", "下单"]); + expect(readRightRowTexts(0)).toEqual(["¥20,000", "下单"]); + expect(readPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]); expect(table.rows[0].exportFields).toMatchObject({ "21-60s报价": "¥450,000", "代表视频": "代表视频A", @@ -219,6 +274,94 @@ describe("market-dom-sync", () => { }); }); + test("keeps a single scroll hint across repeated syncs", () => { + document.body.innerHTML = buildRealMarketGridFixture(); + + expect(syncMarketTable(document)).not.toBeNull(); + expect(syncMarketTable(document)).not.toBeNull(); + + expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1); + expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标"); + }); + + test("uses native-like alignment styles for plugin cells", () => { + document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes(); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table"); + } + + const pluginHeaderCell = document.querySelector( + '[data-testid="plugin-header"] [data-market-header-cell="singleVideoAfterSearchRate"]' + ) as HTMLElement | null; + const pluginBodyCell = table.rows[0].singleCell; + + expect(pluginHeaderCell?.style.display).toBe("flex"); + expect(pluginHeaderCell?.style.alignItems).toBe("center"); + expect(pluginBodyCell.style.display).toBe("flex"); + expect(pluginBodyCell.style.alignItems).toBe("center"); + expect(pluginBodyCell.style.paddingTop).toBe("12px"); + expect(pluginBodyCell.style.paddingBottom).toBe("12px"); + }); + + test("keeps native-like alignment styles after repeated syncs", () => { + document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes(); + + expect(syncMarketTable(document)).not.toBeNull(); + const secondTable = syncMarketTable(document); + if (!secondTable) { + throw new Error("Expected market table"); + } + + const pluginBodyCell = secondTable.rows[0].singleCell; + expect(pluginBodyCell.style.display).toBe("flex"); + expect(pluginBodyCell.style.alignItems).toBe("center"); + expect(pluginBodyCell.style.paddingTop).toBe("12px"); + expect(pluginBodyCell.style.paddingBottom).toBe("12px"); + expect(pluginBodyCell.hasAttribute("data-v-cell-scope")).toBe(true); + }); + + test("keeps export field alignment when a row is missing the price cell", () => { + document.body.innerHTML = buildRealMarketGridFixtureWithMissingPriceCell(); + + const initialTable = syncMarketTable(document); + if (!initialTable) { + throw new Error("Expected market table"); + } + + renderMarketRowState(initialTable.rows[1], { + authorId: "222", + authorName: "达人 B", + backendMetrics: { + a3IncreaseCount: "78,366.22", + afterViewSearchCount: "9,689.96", + afterViewSearchRate: "0.36%", + cpSearch: "14.46", + cpa3: "1.79", + newA3Rate: "3.44%" + }, + backendMetricsStatus: "success", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.02 - 0.1%" + } + }); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table after rerender"); + } + + expect(table.rows[1].exportFields).toMatchObject({ + "21-60s报价": "", + "代表视频": "代表视频B", + "达人信息": "达人 B" + }); + expect(table.rows[1].exportFields?.["21-60s报价"]).not.toContain("看后搜率"); + }); + test("falls back to the market vue state when the DOM has no author id", () => { document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); attachMarketVueState([ @@ -244,6 +387,146 @@ describe("market-dom-sync", () => { expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); }); + test("fills blank export cells from the market vue state", () => { + document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow(); + attachMarketVueState([ + { + attribute_datas: { + avg_search_after_view_rate_30d: "0.003", + burst_text_rate: "1", + city: "温州", + content_theme_labels_180d: ["有趣剧情创作", "亲情剧集", "情感短剧"], + follower: "4550556", + gender: "2", + interact_rate_within_30d: "0.0572", + link_link_cnt_by_industry: "27029613", + nickname: "达人 A", + play_over_rate_within_30d: "0.263", + price_20_60: "155000", + prospective_20_60_cpm: "21.2362", + tags_relation: { + 剧情搞笑: ["剧情"] + } + }, + expected_play_num: "7298854", + star_id: "111" + }, + { + attribute_datas: { + avg_search_after_view_rate_30d: "0.003", + burst_text_rate: "0", + city: "杭州", + content_theme_labels_180d: ["搞笑剧情", "大学宿舍趣事", "校园生活"], + follower: "901234", + gender: "2", + interact_rate_within_30d: "0.072", + link_link_cnt_by_industry: "20773000", + nickname: "达人 B", + play_over_rate_within_30d: "0.35", + price_20_60: "38000", + prospective_20_60_cpm: "182.5", + tags_relation: { + 剧情搞笑: ["剧情"] + } + }, + expected_play_num: "208000", + star_id: "222" + } + ]); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table"); + } + + expect(table.rows[1].authorId).toBe("222"); + expect(table.rows[1].price21To60s).toBe("¥38,000"); + expect(table.rows[1].exportFields).toMatchObject({ + "21-60s报价": "¥38,000", + 互动率: "7.2%", + 内容主题: "搞笑剧情 大学宿舍趣事 1+", + 完播率: "35%", + 爆文率: "-", + 粉丝数: "90.1w", + 达人信息: "达人 B 女 杭州", + 达人类型: "剧情搞笑", + 连接用户数: "2,077.3w", + 预期CPM: "182.5", + 预期播放量: "20.8w" + }); + expect(table.rows[1].rates).toEqual({ + singleVideoAfterSearchRate: "0.3%" + }); + }); + + test("finds market rows in nested vue children", () => { + document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow(); + attachNestedMarketVueState([ + { + attribute_datas: { + city: "杭州", + follower: "901234", + gender: "2", + interact_rate_within_30d: "0.072", + link_link_cnt_by_industry: "20773000", + nickname: "达人 B", + play_over_rate_within_30d: "0.35", + price_20_60: "38000", + prospective_20_60_cpm: "182.5", + tags_relation: { + 剧情搞笑: ["剧情"] + } + }, + expected_play_num: "208000", + star_id: "222" + } + ]); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table"); + } + + expect(table.rows[1].price21To60s).toBe("¥38,000"); + expect(table.rows[1].exportFields).toMatchObject({ + 粉丝数: "90.1w", + 预期播放量: "20.8w", + 互动率: "7.2%" + }); + }); + + test("prefers vue fallback when the price cell is polluted", () => { + document.body.innerHTML = buildRichMarketGridFixtureWithPollutedSecondPrice(); + attachMarketVueState([ + { + attribute_datas: { + city: "杭州", + follower: "901234", + gender: "2", + interact_rate_within_30d: "0.072", + link_link_cnt_by_industry: "20773000", + nickname: "达人 B", + play_over_rate_within_30d: "0.35", + price_20_60: "38000", + prospective_20_60_cpm: "182.5", + tags_relation: { + 剧情搞笑: ["剧情"] + } + }, + expected_play_num: "208000", + star_id: "222" + } + ]); + + const table = syncMarketTable(document); + if (!table) { + throw new Error("Expected market table"); + } + + expect(table.rows[1].price21To60s).toBe("¥38,000"); + expect(table.rows[1].exportFields?.["21-60s报价"]).toBe("¥38,000"); + }); + test("falls back to serialized market rows when vue state is unavailable", () => { document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); document.documentElement.setAttribute( @@ -385,8 +668,167 @@ function buildRealMarketGridFixtureWithoutAuthorIds() { `; } +function buildRealMarketGridFixtureWithMissingPriceCell() { + return ` +
+ +
+
+
+
+ 达人 A +
+
+ 达人 B +
+
+
+
+
+
代表视频A
+
代表视频B
+
+
+
+
+
¥450,000
+
+
+
下单
+
下单
+
+
+
+
+ `; +} + +function buildRealMarketGridFixtureWithScopedAttributes() { + return buildRealMarketGridFixture() + .replace( + '
代表视频
', + '
代表视频
' + ) + .replace( + '
代表视频A
', + '
代表视频A
' + ) + .replace( + '
代表视频B
', + '
代表视频B
' + ); +} + +function buildRichMarketGridFixtureWithBlankSecondRow() { + return ` +
+ +
+
+
+
+ 达人 A +
+
+
+
+
+
+
代表视频A
+
+
+
+
剧情搞笑
+
+
+
+
有趣剧情创作 亲情剧集 1+
+
+
+
+
2,703w
+
+
+
+
455.1w
+
+
+
+
21.2
+
+
+
+
729.9w
+
+
+
+
5.7%
+
+
+
+
26.3%
+
+
+
+
100%
+
+
+
+
+
+
¥155,000
+
+
+
+
下单
+
下单
+
+
+
+
+ `; +} + +function buildRichMarketGridFixtureWithPollutedSecondPrice() { + return buildRichMarketGridFixtureWithBlankSecondRow().replace( + '
\n \n
', + '
看后搜率0.39%看后搜数2,248.33新增A3数0.00新增A3率0%CPA30.00cp_search20.01
\n
\n
' + ); +} + function attachMarketVueState( - marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }> + marketList: Array> ) { const marketRoot = document.querySelector(".base-author-list"); if (!(marketRoot instanceof HTMLElement)) { @@ -405,6 +847,40 @@ function attachMarketVueState( }); } +function attachNestedMarketVueState(marketList: Array>) { + const marketRoot = document.querySelector(".base-author-list"); + if (!(marketRoot instanceof HTMLElement)) { + throw new Error("Expected market root"); + } + + Object.defineProperty(marketRoot, "__vue__", { + configurable: true, + value: { + $children: [ + { + $children: [ + { + _setupState: {}, + $children: [ + { + _setupState: { + __$temp_1: { + marketList + } + }, + $children: [] + } + ] + } + ], + _setupState: {} + } + ], + _setupState: {} + } + }); +} + function readRightHeaderTexts() { return Array.from( document.querySelectorAll('[data-testid="right-header"] > *'), @@ -412,6 +888,13 @@ function readRightHeaderTexts() { ); } +function readPluginHeaderTexts() { + return Array.from( + document.querySelectorAll('[data-testid="plugin-header"] > *'), + (cell) => cell.textContent?.trim() ?? "" + ); +} + function readRightRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), @@ -420,6 +903,20 @@ function readRightRowTexts(rowIndex: number) { ); } +function readPluginRowTexts(rowIndex: number) { + return Array.from( + document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), + (column) => + column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + ); +} + +function readScrollHintText() { + return ( + document.querySelector('[data-testid="market-scroll-hint"]')?.textContent?.trim() ?? "" + ); +} + function readAuthorNames() { return Array.from( document.querySelectorAll('[data-testid="author-section"] .content-cell a'),