2097 lines
60 KiB
TypeScript

import {
normalizeFractionRateDisplay,
normalizeRateDisplay
} from "../../shared/rate-normalizer";
import type {
AfterSearchRates,
BackendMetrics,
MarketRecord,
MarketSortField,
MarketSortState
} from "./types";
const BACKEND_COLUMN_KEY = "backendMetrics";
const SELECTION_COLUMN_KEY = "selection";
const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate";
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;
mode: "css" | "dom";
node: HTMLElement;
};
type MarketDataRow = {
authorId: string;
authorName: string;
exportFields?: Record<string, string>;
hasDirectRatesSource: boolean;
location?: string;
price21To60s?: string;
rates?: AfterSearchRates;
};
export interface MarketRowDom {
authorId: string;
authorName: string;
backendMetricsCells: Record<BackendMetricField, HTMLElement>;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
location?: string;
personalCell: HTMLElement;
price21To60s?: string;
rates?: AfterSearchRates;
row: HTMLElement;
selectionCheckbox: HTMLInputElement;
singleCell: HTMLElement;
visibilityTargets: HTMLElement[];
orderTargets: RowOrderTarget[];
}
export interface MarketTableDom {
headerSelectionCheckbox: HTMLInputElement | null;
rows: MarketRowDom[];
}
export function syncMarketTable(root: ParentNode): MarketTableDom | null {
return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root);
}
export function readMarketPageSignature(root: ParentNode): string {
const document = getOwnerDocument(root);
const explicitPageIndex =
document?.documentElement.getAttribute("data-test-page-index") ?? "";
const activePageIndex =
document
?.querySelector(".el-pagination .number.active, .xt-pagination .number.active")
?.textContent?.trim() ?? "";
const authorIds = readRawAuthorIds(root).join("|");
return `${explicitPageIndex || activePageIndex}::${authorIds}`;
}
export function findNextPageControl(root: ParentNode): HTMLElement | null {
const document = getOwnerDocument(root);
if (!document) {
return null;
}
const explicitControl = document.querySelector('[data-testid="next-page"]');
if (explicitControl instanceof document.defaultView!.HTMLElement) {
return explicitControl;
}
const paginationNextControl = document.querySelector(
".el-pagination .btn-next, .xt-pagination .btn-next"
);
if (paginationNextControl instanceof document.defaultView!.HTMLElement) {
return paginationNextControl;
}
const candidates = Array.from(
document.querySelectorAll("button, a, [role='button']")
).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
return (
candidates.find((element) =>
/下一页|next/i.test(normalizeExportCellText(element.textContent))
) ?? null
);
}
export function isPageControlDisabled(control: HTMLElement | null): boolean {
if (!control) {
return true;
}
if (control instanceof HTMLButtonElement) {
return control.disabled;
}
return control.getAttribute("aria-disabled") === "true";
}
export function renderMarketRowState(
rowDom: MarketRowDom,
record: MarketRecord
): void {
renderBackendMetricsCells(rowDom.backendMetricsCells, record);
if (record.status === "success" && record.rates) {
rowDom.singleCell.textContent = readRateCellText(
record.rates.singleVideoAfterSearchRate
);
rowDom.personalCell.textContent = readRateCellText(
record.rates.personalVideoAfterSearchRate
);
return;
}
if (record.status === "loading") {
rowDom.singleCell.textContent = "加载中...";
rowDom.personalCell.textContent = "加载中...";
return;
}
if (record.status === "failed") {
rowDom.singleCell.textContent = "加载失败";
rowDom.personalCell.textContent = "加载失败";
return;
}
rowDom.singleCell.textContent = "";
rowDom.personalCell.textContent = "";
}
export function applyRowVisibility(
table: MarketTableDom,
visibleAuthorIds: Set<string>
): void {
table.rows.forEach((rowDom) => {
const isVisible = visibleAuthorIds.has(rowDom.authorId);
rowDom.visibilityTargets.forEach((target) => {
target.hidden = !isVisible;
});
});
}
export function applyRowOrder(
table: MarketTableDom,
orderedAuthorIds: string[]
): void {
const rowById = new Map(table.rows.map((rowDom) => [rowDom.authorId, rowDom]));
const orderByAuthorId = new Map(
orderedAuthorIds.map((authorId, index) => [authorId, index])
);
orderedAuthorIds.forEach((authorId) => {
const rowDom = rowById.get(authorId);
if (!rowDom) {
return;
}
rowDom.orderTargets.forEach(({ container, mode, node }) => {
const visualOrder = orderByAuthorId.get(authorId) ?? orderedAuthorIds.length;
if (mode === "css") {
container.dataset.marketOrderMode = "css";
container.style.display = "flex";
container.style.flexDirection = "column";
node.style.order = String(visualOrder);
return;
}
container.dataset.marketOrderMode = "dom";
container.appendChild(node);
});
});
}
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
});
});
}
export function syncMarketSelectionState(
table: MarketTableDom,
selectedAuthorIds: Set<string>
): void {
table.rows.forEach((rowDom) => {
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
rowDom.selectionCheckbox.checked = selectedAuthorIds.has(rowDom.authorId);
});
if (!table.headerSelectionCheckbox) {
return;
}
const visibleRows = table.rows.filter((rowDom) =>
rowDom.visibilityTargets.some((target) => !target.hidden)
);
const scopedRows = visibleRows.length > 0 ? visibleRows : table.rows;
const selectedCount = scopedRows.filter((rowDom) =>
selectedAuthorIds.has(rowDom.authorId)
).length;
table.headerSelectionCheckbox.indeterminate =
selectedCount > 0 && selectedCount < scopedRows.length;
table.headerSelectionCheckbox.checked =
scopedRows.length > 0 && selectedCount === scopedRows.length;
table.headerSelectionCheckbox.disabled = scopedRows.length === 0;
}
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;
if (!header || !body) {
return null;
}
const selectionHeader = ensureSyntheticHeaderCell(header, SELECTION_COLUMN_KEY, "");
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeader);
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
BACKEND_METRIC_COLUMNS.forEach(({ field, label }) => {
ensureSyntheticHeaderCell(header, field, label);
});
const headerLabelsByField = readSyntheticHeaderLabels(header);
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
(rowElement) => {
const row = rowElement as HTMLElement;
const selectionCell = ensureSyntheticRowCell(row, SELECTION_COLUMN_KEY);
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY);
const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY);
const backendMetricsCells = Object.fromEntries(
BACKEND_METRIC_COLUMNS.map(({ field }) => [field, ensureSyntheticRowCell(row, field)])
) as Record<BackendMetricField, HTMLElement>;
const authorId = row.dataset.authorId ?? "";
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
return {
authorId,
authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"",
backendMetricsCells,
exportFields: readSyntheticExportFields(row, headerLabelsByField),
hasDirectRatesSource: false,
location:
row.querySelector('[data-market-field="location"]')?.textContent?.trim() ?? "",
orderTargets: [
{
container: body,
mode: "dom",
node: row
}
],
personalCell,
price21To60s:
row
.querySelector('[data-market-field="price21To60s"]')
?.textContent?.trim() ?? "",
rates: undefined,
row,
selectionCheckbox,
singleCell,
visibilityTargets: [row]
} satisfies MarketRowDom;
}
);
return {
headerSelectionCheckbox,
rows
};
}
function syncDivGridMarketTable(root: ParentNode): MarketTableDom | null {
const document = getOwnerDocument(root);
if (!document) {
return null;
}
for (const marketRoot of document.querySelectorAll(".base-author-list")) {
if (!(marketRoot instanceof document.defaultView!.HTMLElement)) {
continue;
}
const syncedTable = syncDivGridRoot(marketRoot);
if (syncedTable) {
return syncedTable;
}
}
return null;
}
function readRawAuthorIds(root: ParentNode): string[] {
const document = getOwnerDocument(root);
const syntheticAuthorIds = readSyntheticAuthorIds(root);
if (syntheticAuthorIds && syntheticAuthorIds.length > 0) {
return syntheticAuthorIds;
}
const divGridAuthorIds = readDivGridAuthorIds(root);
if (divGridAuthorIds && divGridAuthorIds.length > 0) {
return divGridAuthorIds;
}
if (!document) {
return [];
}
return readSerializedMarketRows(document)
.map((row) => row.authorId)
.filter((authorId) => Boolean(authorId));
}
function readSyntheticAuthorIds(root: ParentNode): string[] | null {
const body = root.querySelector("[data-market-body]") as HTMLElement | null;
if (!body) {
return null;
}
return Array.from(body.querySelectorAll("[data-market-row]"))
.map((row) =>
row instanceof HTMLElement ? row.dataset.authorId ?? "" : ""
)
.filter((authorId) => Boolean(authorId));
}
function readDivGridAuthorIds(root: ParentNode): string[] | null {
const document = getOwnerDocument(root);
if (!document) {
return null;
}
const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof document.defaultView!.HTMLElement)) {
return null;
}
const bodySection = Array.from(marketRoot.querySelectorAll(".section-wrapper")).find(
(section): section is HTMLElement =>
section instanceof document.defaultView!.HTMLElement &&
!section.classList.contains("sticky-header")
);
const authorSection = bodySection
? Array.from(bodySection.children).find(
(child): child is HTMLElement =>
child instanceof document.defaultView!.HTMLElement &&
child.querySelector(".content-column .content-cell")
) ?? null
: null;
const authorColumn = authorSection
? getNativeAuthorColumn(authorSection)
: null;
if (!authorColumn) {
return null;
}
return getDirectContentCells(authorColumn)
.map((cell) => extractAuthorId(cell))
.filter((authorId) => Boolean(authorId));
}
function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
const headerSection = root.querySelector(
".section-wrapper.sticky-header"
) as HTMLElement | null;
const bodySection = Array.from(root.querySelectorAll(".section-wrapper")).find(
(section): section is HTMLElement =>
section instanceof root.ownerDocument.defaultView!.HTMLElement &&
!section.classList.contains("sticky-header")
);
if (!headerSection || !bodySection) {
return null;
}
const authorHeader = findCellByText(getDirectHeaderCells(headerSection), AUTHOR_HEADER_TEXT);
const actionHeader = findCellByText(getDirectHeaderCells(headerSection), ACTION_HEADER_TEXT);
if (!authorHeader || !actionHeader) {
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)
);
const authorHeaderSection = getIndexedChild(
headerSection,
getDirectChildIndex(headerSection, authorHeader)
);
const rightSection = getIndexedChild(
bodySection,
getDirectChildIndex(headerSection, actionHeader)
);
if (!authorSection || !authorHeaderSection || !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 = getNativeAuthorColumn(authorSection);
const actionColumn = getActionColumn(rightSection);
if (!authorColumn || !actionColumn) {
return null;
}
const rowCount = getDirectContentCells(authorColumn).length;
const selectionHeaderCell = ensureDivHeaderCell(
authorHeaderSection,
authorHeader,
SELECTION_COLUMN_KEY,
""
);
const headerSelectionCheckbox = ensureSelectionHeaderControl(selectionHeaderCell);
const selectionColumn = ensureDivBodyColumn(
authorSection,
authorColumn,
SELECTION_COLUMN_KEY,
rowCount
);
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(
pluginBodySection,
bodyTemplateColumn,
SINGLE_COLUMN_KEY,
rowCount
);
const personalColumn = ensureDivBodyColumn(
pluginBodySection,
bodyTemplateColumn,
PERSONAL_COLUMN_KEY,
rowCount
);
const backendMetricColumns = Object.fromEntries(
BACKEND_METRIC_COLUMNS.map(({ field, label }) => {
ensureDivHeaderCell(pluginHeaderSection, headerTemplateCell, field, label);
return [
field,
ensureDivBodyColumn(
pluginBodySection,
bodyTemplateColumn,
field,
rowCount
)
];
})
) as Record<BackendMetricField, HTMLElement>;
syncContainerWidth(pluginHeaderSection);
syncContainerWidth(pluginBodySection);
syncContainerWidth(authorHeaderSection);
syncContainerWidth(authorSection);
ensureVisibleHorizontalScroll(headerSection);
ensureVisibleHorizontalScroll(bodySection);
ensureScrollHint(root, headerSection);
const allBodyColumns = Array.from(bodySection.children).flatMap((section) =>
section instanceof root.ownerDocument.defaultView!.HTMLElement
? getDirectContentColumns(section)
: []
);
const allHeaderCells = Array.from(headerSection.children).flatMap((section) =>
section instanceof root.ownerDocument.defaultView!.HTMLElement
? getDirectHeaderCells(section)
: []
);
const authorCells = getDirectContentCells(authorColumn);
const selectionCells = getDirectContentCells(selectionColumn);
const singleCells = getDirectContentCells(singleColumn);
const personalCells = getDirectContentCells(personalColumn);
const backendMetricCellsByField = Object.fromEntries(
BACKEND_METRIC_COLUMNS.map(({ field }) => [
field,
getDirectContentCells(backendMetricColumns[field])
])
) as Record<BackendMetricField, HTMLElement[]>;
const priceColumn = findPreviousColumn(actionColumn);
const priceCells = priceColumn ? getDirectContentCells(priceColumn) : [];
const remainingVueMarketRows = [...readVueMarketRows(root)];
const remainingSerializedMarketRows = [...readSerializedMarketRows(root.ownerDocument)];
const rows = authorCells.flatMap((authorCell, index) => {
const selectionCell = selectionCells[index] ?? null;
const singleCell = singleCells[index] ?? null;
const personalCell = personalCells[index] ?? null;
const backendMetricsCells = Object.fromEntries(
BACKEND_METRIC_COLUMNS.map(({ field }) => [
field,
backendMetricCellsByField[field][index] ?? null
])
) as Record<BackendMetricField, HTMLElement | null>;
if (
!selectionCell ||
!singleCell ||
!personalCell ||
Object.values(backendMetricsCells).some((cell) => cell === null)
) {
return [];
}
const selectionCheckbox = ensureSelectionRowControl(selectionCell);
const alignedRowCells = allBodyColumns.map(
(column) => getDirectContentCells(column)[index] ?? null
);
const rowCells = alignedRowCells.filter(
(cell): cell is HTMLElement => cell !== null
);
const directAuthorId = extractAuthorId(authorCell) || "";
const directAuthorName = extractAuthorName(authorCell) || "";
const vueMarketRow = takeMatchedMarketDataRow(
remainingVueMarketRows,
directAuthorId,
directAuthorName
);
const serializedMarketRow = takeMatchedMarketDataRow(
remainingSerializedMarketRows,
directAuthorId,
directAuthorName
);
const fallbackMarketRow = mergeMarketDataRows(serializedMarketRow, vueMarketRow);
const exportFields = mergeExportFieldMaps(
readExportFieldsForDivGridRow(allHeaderCells, alignedRowCells),
fallbackMarketRow?.exportFields
);
const authorId = directAuthorId || fallbackMarketRow?.authorId || "";
const authorName = directAuthorName || fallbackMarketRow?.authorName || "";
const price21To60s = mergeNonEmptyString(
readDivGridPriceDisplay(priceCells[index]?.textContent),
fallbackMarketRow?.price21To60s
);
selectionCheckbox.dataset.marketSelectionAuthorId = authorId;
return [
{
authorId,
authorName,
backendMetricsCells: backendMetricsCells as Record<BackendMetricField, HTMLElement>,
exportFields,
hasDirectRatesSource:
fallbackMarketRow?.hasDirectRatesSource ?? false,
location: fallbackMarketRow?.location,
orderTargets: rowCells
.map((cell) => {
const container = cell.parentElement;
if (!(container instanceof root.ownerDocument.defaultView!.HTMLElement)) {
return null;
}
return {
container,
mode: "css",
node: cell
};
})
.filter((target): target is RowOrderTarget => target !== null),
personalCell,
price21To60s,
rates: fallbackMarketRow?.rates,
row: authorCell,
selectionCheckbox,
singleCell,
visibilityTargets: rowCells
} satisfies MarketRowDom
];
});
return {
headerSelectionCheckbox,
rows
};
}
function ensureSyntheticHeaderCell(
header: HTMLElement,
field: string,
label: string
): HTMLElement {
const existingCell = header.querySelector(
`[data-market-header-cell="${field}"]`
) as HTMLElement | null;
if (existingCell) {
existingCell.textContent = label;
return existingCell;
}
const nextCell = header.ownerDocument.createElement("div");
nextCell.dataset.marketHeaderCell = field;
nextCell.textContent = label;
if (field === SELECTION_COLUMN_KEY) {
header.insertBefore(nextCell, header.firstChild);
} else {
header.appendChild(nextCell);
}
return nextCell;
}
function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement {
const existingCell = row.querySelector(
`[data-market-row-cell="${field}"]`
) as HTMLElement | null;
if (existingCell) {
return existingCell;
}
const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span");
nextCell.dataset.marketRowCell = field;
if (field === SELECTION_COLUMN_KEY) {
row.insertBefore(nextCell, row.firstChild);
} else {
row.appendChild(nextCell);
}
return nextCell;
}
function ensureDivHeaderCell(
container: HTMLElement,
templateCell: HTMLElement,
field: string,
label: string
): HTMLElement {
const existingCell = container.querySelector(
`[data-market-header-cell="${field}"]`
) as HTMLElement | null;
if (existingCell) {
existingCell.textContent = label;
applyPluginHeaderCellStyles(existingCell);
return existingCell;
}
const nextCell = cloneElementShallow(templateCell);
nextCell.dataset.marketHeaderCell = field;
nextCell.textContent = label;
applyColumnWidth(nextCell, field);
applyPluginHeaderCellStyles(nextCell);
if (field === SELECTION_COLUMN_KEY) {
container.insertBefore(nextCell, templateCell);
} else {
container.appendChild(nextCell);
}
return nextCell;
}
function ensureDivBodyColumn(
container: HTMLElement,
templateColumn: HTMLElement,
field: string,
rowCount: number
): HTMLElement {
const existingColumn = container.querySelector(
`[data-market-column-group="${field}"]`
) as HTMLElement | null;
if (existingColumn) {
syncDivColumnCells(existingColumn, templateColumn, field, rowCount);
return existingColumn;
}
const nextColumn = cloneElementShallow(templateColumn);
nextColumn.dataset.marketColumnGroup = field;
applyColumnWidth(nextColumn, field);
syncDivColumnCells(nextColumn, templateColumn, field, rowCount);
if (field === SELECTION_COLUMN_KEY) {
container.insertBefore(nextColumn, templateColumn);
} else {
container.appendChild(nextColumn);
}
return nextColumn;
}
function syncDivColumnCells(
column: HTMLElement,
templateColumn: HTMLElement,
field: string,
rowCount: number
): void {
const currentCells = getDirectContentCells(column);
while (currentCells.length > rowCount) {
currentCells.pop()?.remove();
}
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 =
templateCells[index] ?? templateCells[templateCells.length - 1] ?? null;
const nextCell =
field === SELECTION_COLUMN_KEY
? templateCell
? createSelectionContentCell(templateCell)
: createBareContentCell(column.ownerDocument)
: 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 ensureSelectionHeaderControl(cell: HTMLElement): HTMLInputElement {
cell.textContent = "";
cell.style.gap = "6px";
cell.style.justifyContent = "center";
const checkbox = ensureSelectionCheckbox(cell, "header");
const label = cell.querySelector(
'[data-market-selection-label="header"]'
) as HTMLElement | null;
if (label) {
label.textContent = "全选";
return checkbox;
}
const nextLabel = cell.ownerDocument.createElement("span");
nextLabel.dataset.marketSelectionLabel = "header";
nextLabel.textContent = "全选";
nextLabel.style.fontSize = "12px";
cell.appendChild(nextLabel);
return checkbox;
}
function ensureSelectionRowControl(cell: HTMLElement): HTMLInputElement {
cell.textContent = "";
cell.style.justifyContent = "center";
return ensureSelectionCheckbox(cell, "row");
}
function ensureSelectionCheckbox(
container: HTMLElement,
kind: "header" | "row"
): HTMLInputElement {
const existingCheckbox = container.querySelector(
`[data-market-selection-checkbox="${kind}"]`
) as HTMLInputElement | null;
if (existingCheckbox) {
existingCheckbox.type = "checkbox";
return existingCheckbox;
}
const checkbox = container.ownerDocument.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.marketSelectionCheckbox = kind;
checkbox.style.cursor = "pointer";
container.appendChild(checkbox);
return checkbox;
}
function getOwnerDocument(root: ParentNode): Document | null {
if ("ownerDocument" in root && root.ownerDocument) {
return root.ownerDocument;
}
return "nodeType" in root && root.nodeType === 9 ? (root as Document) : null;
}
function readSyntheticHeaderLabels(header: HTMLElement): Record<string, string> {
return Array.from(header.querySelectorAll("[data-market-header-cell]")).reduce<
Record<string, string>
>((labels, cell) => {
if (!(cell instanceof header.ownerDocument.defaultView!.HTMLElement)) {
return labels;
}
const field = cell.dataset.marketHeaderCell;
if (!field) {
return labels;
}
labels[field] = normalizeExportCellText(cell.textContent);
return labels;
}, {});
}
function readSyntheticExportFields(
row: HTMLElement,
headerLabelsByField: Record<string, string>
): Record<string, string> {
const exportFields: Record<string, string> = {};
for (const cell of row.querySelectorAll("[data-market-field]")) {
if (!(cell instanceof row.ownerDocument.defaultView!.HTMLElement)) {
continue;
}
const field = cell.dataset.marketField;
const headerLabel = field ? headerLabelsByField[field] : "";
if (!shouldExportColumn(headerLabel)) {
continue;
}
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
}
return exportFields;
}
function readExportFieldsForDivGridRow(
headerCells: HTMLElement[],
rowCells: Array<HTMLElement | null>
): Record<string, string> {
const exportFields: Record<string, string> = {};
rowCells.forEach((cell, index) => {
const headerLabel = normalizeExportCellText(headerCells[index]?.textContent);
if (!shouldExportColumn(headerLabel)) {
return;
}
exportFields[headerLabel] =
headerLabel === "21-60s报价"
? readDivGridPriceDisplay(cell?.textContent) ?? ""
: normalizeExportCellText(cell?.textContent);
});
return exportFields;
}
function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null {
let current = cell.previousElementSibling;
while (current) {
if (
current instanceof cell.ownerDocument.defaultView!.HTMLElement &&
current.classList.contains("header-cell")
) {
return current;
}
current = current.previousElementSibling;
}
return null;
}
function findPreviousColumn(column: HTMLElement): HTMLElement | null {
let current = column.previousElementSibling;
while (current) {
if (
current instanceof column.ownerDocument.defaultView!.HTMLElement &&
current.classList.contains("content-column")
) {
return current;
}
current = current.previousElementSibling;
}
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;
}
function getNativeAuthorColumn(authorSection: HTMLElement): HTMLElement | null {
return (
getDirectContentColumns(authorSection).find(
(column) =>
!column.dataset.marketColumnGroup &&
getDirectContentCells(column).some(
(cell) =>
cell.querySelector("a") ||
cell.querySelector(".author-nickname") ||
Boolean(cell.dataset.authorId)
)
) ?? null
);
}
function getDirectHeaderCells(section: Element): HTMLElement[] {
return Array.from(section.querySelectorAll(".header-cell")).filter(
(cell): cell is HTMLElement =>
cell instanceof section.ownerDocument.defaultView!.HTMLElement
);
}
function getDirectContentColumns(section: Element): HTMLElement[] {
return Array.from(section.children).filter(
(child): child is HTMLElement =>
child instanceof section.ownerDocument.defaultView!.HTMLElement &&
child.classList.contains("content-column")
);
}
function getDirectContentCells(column: Element): HTMLElement[] {
return Array.from(column.children).filter(
(child): child is HTMLElement =>
child instanceof column.ownerDocument.defaultView!.HTMLElement &&
child.classList.contains("content-cell")
);
}
function getDirectChildIndex(root: HTMLElement, descendant: HTMLElement): number {
const directChild = Array.from(root.children).find((child) => child.contains(descendant));
return directChild ? Array.from(root.children).indexOf(directChild) : -1;
}
function getIndexedChild(root: HTMLElement, index: number): HTMLElement | null {
if (index < 0) {
return null;
}
const child = root.children[index] ?? null;
return child instanceof root.ownerDocument.defaultView!.HTMLElement ? child : null;
}
function findCellByText(cells: HTMLElement[], text: string): HTMLElement | null {
return cells.find((cell) => cell.textContent?.trim() === text) ?? null;
}
function cloneElementShallow(reference: HTMLElement): HTMLElement {
const clone = reference.ownerDocument.createElement(reference.tagName);
Array.from(reference.attributes).forEach((attribute) => {
clone.setAttribute(attribute.name, attribute.value);
});
return clone;
}
function createBareContentCell(document: Document): HTMLElement {
const cell = document.createElement("div");
cell.className = "content-cell";
return cell;
}
function createSelectionContentCell(templateCell: HTMLElement): HTMLElement {
const cell = cloneElementShallow(templateCell);
cell.removeAttribute("data-testid");
cell.removeAttribute("data-author-id");
return cell;
}
function extractAuthorId(authorCell: HTMLElement): string {
const explicitAuthorId = authorCell.dataset.authorId;
if (explicitAuthorId) {
return explicitAuthorId;
}
const linkedAuthorId = Array.from(authorCell.querySelectorAll("a"))
.map((link) => extractAuthorIdFromHref((link as HTMLAnchorElement).href))
.find((value): value is string => Boolean(value));
if (linkedAuthorId) {
return linkedAuthorId;
}
const fallbackAuthorId = authorCell
.querySelector("[data-author-id]")
?.getAttribute("data-author-id");
return fallbackAuthorId ?? "";
}
function extractAuthorName(authorCell: HTMLElement): string {
return (
authorCell.querySelector(".author-nickname")?.textContent?.trim() ??
authorCell.textContent?.trim() ??
""
);
}
function extractAuthorIdFromHref(href: string): string | null {
const match = href.match(/\/author-homepage\/[^/]+\/(\d+)/);
return match?.[1] ?? null;
}
function readVueMarketRows(
marketRoot: HTMLElement
): MarketDataRow[] {
const vueRoot = (
marketRoot as HTMLElement & {
__vue__?: {
$children?: unknown[];
_setupState?: Record<string, unknown>;
};
}
).__vue__;
const setupStates = collectVueSetupStates(vueRoot);
for (const setupState of setupStates) {
for (const value of Object.values(setupState)) {
const candidate = unwrapVueRef(value);
if (!candidate || typeof candidate !== "object") {
continue;
}
const marketList = unwrapVueRef(
(candidate as Record<string, unknown>).marketList
);
if (!Array.isArray(marketList)) {
continue;
}
return marketList.map((row) => {
const record = isRecord(row) ? row : {};
const attributeDatas = readMarketAttributeDatas(record);
const singleVideoAfterSearchRate = normalizeMarketListRate(
readMarketFieldValue(record, attributeDatas, "avg_search_after_view_rate_30d")
);
return {
authorId:
readString(readMarketFieldValue(record, attributeDatas, "star_id")) ??
readString(readMarketFieldValue(record, attributeDatas, "id")) ??
"",
authorName:
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
}
: undefined
};
});
}
}
return [];
}
function collectVueSetupStates(
vueRoot:
| {
$children?: unknown[];
_setupState?: Record<string, unknown>;
}
| undefined
): Array<Record<string, unknown>> {
if (!vueRoot) {
return [];
}
const queue: unknown[] = [vueRoot];
const setupStates: Array<Record<string, unknown>> = [];
while (queue.length > 0) {
const current = queue.shift();
if (!isRecord(current)) {
continue;
}
if (isRecord(current._setupState)) {
setupStates.push(current._setupState);
}
const children = Array.isArray(current.$children) ? current.$children : [];
queue.push(...children);
}
return setupStates;
}
function readSerializedMarketRows(
document: Document
): MarketDataRow[] {
const serializedRows = document.documentElement.getAttribute(
SERIALIZED_MARKET_ROWS_ATTRIBUTE
);
if (!serializedRows) {
return [];
}
try {
const parsedRows = JSON.parse(serializedRows);
if (!Array.isArray(parsedRows)) {
return [];
}
return parsedRows
.map((row) => {
const record = isRecord(row) ? row : {};
const singleVideoAfterSearchRate = readString(
record.singleVideoAfterSearchRate
);
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
}
: undefined
};
})
.filter((row) => Boolean(row.authorId || row.authorName));
} catch {
return [];
}
}
function unwrapVueRef(value: unknown): unknown {
if (isRecord(value) && "value" in value) {
return value.value;
}
return value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readMarketAttributeDatas(
record: Record<string, unknown>
): Record<string, unknown> {
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
}
function readMarketFieldValue(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>,
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;
}
function normalizeExportCellText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function readDivGridPriceDisplay(value: string | null | undefined): string | undefined {
const normalizedValue = normalizeExportCellText(value);
if (!normalizedValue) {
return undefined;
}
const match = normalizedValue.match(/^¥?\s*([\d,]+(?:\.\d+)?)$/);
if (!match) {
return undefined;
}
const numericValue = Number(match[1].replace(/,/g, ""));
if (!Number.isFinite(numericValue)) {
return undefined;
}
return formatCurrencyValue(numericValue);
}
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 === SELECTION_COLUMN_KEY) {
element.style.minWidth = "56px";
element.style.width = "56px";
}
if (field === BACKEND_COLUMN_KEY) {
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 {
if (!(container instanceof HTMLElement)) {
return;
}
const directChildren = Array.from(container.children).filter(
(child): child is HTMLElement => child instanceof HTMLElement
);
const totalWidth = directChildren.reduce((sum, child) => {
return sum + readElementWidth(child);
}, 0);
if (totalWidth <= 0) {
return;
}
container.style.width = `${totalWidth}px`;
container.style.minWidth = `${totalWidth}px`;
}
function readElementWidth(element: HTMLElement): number {
const styleWidth = Number.parseFloat(element.style.width || "");
if (Number.isFinite(styleWidth) && styleWidth > 0) {
return styleWidth;
}
const minWidth = Number.parseFloat(element.style.minWidth || "");
if (Number.isFinite(minWidth) && minWidth > 0) {
return minWidth;
}
return 0;
}
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 takeMatchedMarketDataRow(
remainingRows: MarketDataRow[],
authorId: string,
authorName: string
): MarketDataRow | null {
if (remainingRows.length === 0) {
return null;
}
const matchedIndex = remainingRows.findIndex((row) => {
if (authorId && row.authorId === authorId) {
return true;
}
if (authorName && row.authorName === authorName) {
return true;
}
return false;
});
if (matchedIndex >= 0) {
return remainingRows.splice(matchedIndex, 1)[0] ?? null;
}
if (!authorId && !authorName) {
return remainingRows.shift() ?? null;
}
return null;
}
function mergeExportFieldMaps(
current: Record<string, string> | undefined,
fallback: Record<string, string> | undefined
): Record<string, string> | undefined {
if (!current && !fallback) {
return undefined;
}
const nextFields: Record<string, string> = {
...(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<string, unknown>,
attributeDatas: Record<string, unknown>
): Record<string, string> | undefined {
const exportFields: Record<string, string> = {};
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<string, string>,
key: string,
value: string | undefined
): void {
if (hasTextValue(value)) {
exportFields[key] = value;
}
}
function buildMarketAuthorInfo(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): 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<string, unknown>,
attributeDatas: Record<string, unknown>
): 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<string, unknown>,
attributeDatas: Record<string, unknown>
): 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<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? undefined;
}
function readMarketPrice21To60s(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): string | undefined {
return formatCurrencyValue(
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
);
}
function readMarketRepresentativeVideo(
record: Record<string, unknown>,
attributeDatas: Record<string, unknown>
): 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<string, unknown> | 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<string, unknown>
): Record<string, string> | 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<BackendMetricField, HTMLElement>,
record: MarketRecord
): void {
if (
record.backendMetricsStatus === "loading" ||
(record.status === "loading" && !record.backendMetricsStatus)
) {
fillBackendMetricCells(cells, "加载中...");
return;
}
if (record.backendMetricsStatus === "failed") {
fillBackendMetricCells(cells, "加载失败");
return;
}
if (record.backendMetricsStatus === "missing") {
fillBackendMetricCells(cells, UNAVAILABLE_BACKEND_METRICS_TEXT);
return;
}
if (record.backendMetricsStatus !== "success" || !record.backendMetrics) {
fillBackendMetricCells(cells, "");
return;
}
BACKEND_METRIC_COLUMNS.forEach(({ field }) => {
cells[field].textContent = record.backendMetrics?.[field] ?? "";
});
}
function fillBackendMetricCells(
cells: Record<BackendMetricField, HTMLElement>,
value: string
): void {
BACKEND_METRIC_COLUMNS.forEach(({ field }) => {
cells[field].textContent = value;
});
}