feat: stabilize Xingtu market data sync

This commit is contained in:
2026-04-21 14:16:29 +08:00
parent 85e835a96a
commit 55324a5bb7
19 changed files with 2150 additions and 1366 deletions
+69 -2
View File
@@ -3,10 +3,15 @@ import {
type CreateMarketControllerOptions
} from "./market/index";
interface ChromeRuntimeLike {
getURL?: (path: string) => string;
id?: string;
}
interface BootContentScriptOptions {
createMarketController?: (
options: CreateMarketControllerOptions
) => { ready: Promise<void> };
) => { dispose?: () => void; ready: Promise<void> };
document?: Document;
window?: Window;
}
@@ -23,6 +28,8 @@ export async function bootContentScript(
return null;
}
installMarketPageBridge(currentDocument);
return controllerFactory({
document: currentDocument,
window: currentWindow
@@ -30,5 +37,65 @@ export async function bootContentScript(
}
function isMarketPage(url: string): boolean {
return url.startsWith("https://xingtu.cn/ad/creator/market");
const parsedUrl = new URL(url);
const isXingtuHost =
parsedUrl.hostname === "xingtu.cn" || parsedUrl.hostname.endsWith(".xingtu.cn");
return isXingtuHost && parsedUrl.pathname.startsWith("/ad/creator/market");
}
function bootstrapContentScript() {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (!runtime || typeof window === "undefined" || typeof document === "undefined") {
return;
}
const marker = "__starChartSearchEnhancerContentController";
const scopedWindow = window as Window & {
[marker]?: boolean | { dispose?: () => void; ready: Promise<void> } | null;
};
if (scopedWindow[marker]) {
return;
}
scopedWindow[marker] = true;
void bootContentScript().then((controller) => {
scopedWindow[marker] = controller;
});
}
bootstrapContentScript();
function installMarketPageBridge(document: Document) {
if (
document.documentElement.querySelector(
'[data-sces-market-bridge="script"]'
)
) {
return;
}
const script = document.createElement("script");
script.dataset.scesMarketBridge = "script";
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
const bridgeUrl = runtime?.getURL?.("content/market-page-bridge.js");
if (bridgeUrl) {
script.src = bridgeUrl;
} else {
script.textContent = "";
}
(document.head ?? document.documentElement).appendChild(script);
}
+50 -28
View File
@@ -4,6 +4,7 @@ import type { MarketApiResult } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
status?: number;
}
type FetchLike = (
@@ -24,44 +25,52 @@ export function createMarketApiClient(options: MarketApiClientOptions = {}) {
return {
async loadAuthorAseInfo(authorId: string): Promise<MarketApiResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const primaryResult = await loadAuthorMetricsFromUrl(
buildAuthorCommerceSeedBaseInfoUrl(authorId, baseUrl)
);
if (primaryResult.success || primaryResult.reason === "timeout") {
return primaryResult;
}
try {
const response = await fetchImpl(
buildAuthorAseInfoUrl(authorId, baseUrl),
{
credentials: "include",
method: "GET",
signal: controller.signal
}
);
return loadAuthorMetricsFromUrl(buildAuthorAseInfoUrl(authorId, baseUrl));
}
};
if (!response.ok) {
return {
success: false,
reason: "request-failed"
};
}
async function loadAuthorMetricsFromUrl(url: string): Promise<MarketApiResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return mapAuthorAseInfoResponse(await response.json());
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return {
success: false,
reason: "timeout"
};
}
try {
const response = await fetchImpl(url, {
credentials: "include",
method: "GET",
signal: controller.signal
});
if (!response.ok) {
return {
success: false,
reason: "request-failed"
};
} finally {
clearTimeout(timeoutId);
}
return mapAuthorAseInfoResponse(await response.json());
} catch (error) {
if (isAbortError(error) || controller.signal.aborted) {
return {
success: false,
reason: "timeout"
};
}
return {
success: false,
reason: "request-failed"
};
} finally {
clearTimeout(timeoutId);
}
};
}
}
export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string {
@@ -71,6 +80,19 @@ export function buildAuthorAseInfoUrl(authorId: string, baseUrl: string): string
return url.toString();
}
export function buildAuthorCommerceSeedBaseInfoUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL(
"/gw/api/aggregator/get_author_commerce_seed_base_info",
baseUrl
);
url.searchParams.set("o_author_id", authorId);
url.searchParams.set("range", "90");
return url.toString();
}
export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
const data = getPayloadData(payload);
if (!data) {
+583 -35
View File
@@ -1,45 +1,41 @@
import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import {
normalizeFractionRateDisplay,
normalizeRateDisplay
} from "../../shared/rate-normalizer";
import type { AfterSearchRates } from "./types";
import type { MarketRecord } from "./types";
const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate";
const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate";
const ACTION_HEADER_TEXT = "操作";
const AUTHOR_HEADER_TEXT = "达人信息";
const UNAVAILABLE_RATE_TEXT = "暂无来源";
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
type RowOrderTarget = {
container: HTMLElement;
node: HTMLElement;
};
export interface MarketRowDom {
authorId: string;
authorName: string;
hasDirectRatesSource?: boolean;
personalCell: HTMLElement;
price21To60s?: string;
rates?: AfterSearchRates;
row: HTMLElement;
singleCell: HTMLElement;
visibilityTargets: HTMLElement[];
orderTargets: RowOrderTarget[];
}
export interface MarketTableDom {
body: HTMLElement;
rows: MarketRowDom[];
}
export function syncMarketTable(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;
}
ensureHeaderCell(header, "singleVideoAfterSearchRate", "单视频看后搜率");
ensureHeaderCell(header, "personalVideoAfterSearchRate", "个人视频看后搜率");
const rows = Array.from(
body.querySelectorAll("[data-market-row]")
).map((rowElement) => {
const row = rowElement as HTMLElement;
return {
authorId: row.dataset.authorId ?? "",
personalCell: ensureRowCell(row, "personalVideoAfterSearchRate"),
row,
singleCell: ensureRowCell(row, "singleVideoAfterSearchRate")
};
});
return {
body,
rows
};
return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root);
}
export function renderMarketRowState(
@@ -47,10 +43,10 @@ export function renderMarketRowState(
record: MarketRecord
): void {
if (record.status === "success" && record.rates) {
rowDom.singleCell.textContent = normalizeRateDisplay(
rowDom.singleCell.textContent = readRateCellText(
record.rates.singleVideoAfterSearchRate
);
rowDom.personalCell.textContent = normalizeRateDisplay(
rowDom.personalCell.textContent = readRateCellText(
record.rates.personalVideoAfterSearchRate
);
return;
@@ -77,7 +73,10 @@ export function applyRowVisibility(
visibleAuthorIds: Set<string>
): void {
table.rows.forEach((rowDom) => {
rowDom.row.hidden = !visibleAuthorIds.has(rowDom.authorId);
const isVisible = visibleAuthorIds.has(rowDom.authorId);
rowDom.visibilityTargets.forEach((target) => {
target.hidden = !isVisible;
});
});
}
@@ -89,13 +88,214 @@ export function applyRowOrder(
orderedAuthorIds.forEach((authorId) => {
const rowDom = rowById.get(authorId);
if (rowDom) {
table.body.appendChild(rowDom.row);
if (!rowDom) {
return;
}
rowDom.orderTargets.forEach(({ container, node }) => {
container.appendChild(node);
});
});
}
function ensureHeaderCell(
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;
}
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
(rowElement) => {
const row = rowElement as HTMLElement;
const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY);
const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY);
return {
authorId: row.dataset.authorId ?? "",
authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"",
hasDirectRatesSource: false,
orderTargets: [
{
container: body,
node: row
}
],
personalCell,
price21To60s:
row
.querySelector('[data-market-field="price21To60s"]')
?.textContent?.trim() ?? "",
rates: undefined,
row,
singleCell,
visibilityTargets: [row]
} satisfies MarketRowDom;
}
);
return {
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 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 authorSection = getIndexedChild(
bodySection,
getDirectChildIndex(headerSection, authorHeader)
);
const rightSection = getIndexedChild(
bodySection,
getDirectChildIndex(headerSection, actionHeader)
);
if (!authorSection || !rightSection) {
return null;
}
const authorColumn = getDirectContentColumns(authorSection)[0] ?? null;
const actionColumn = getActionColumn(rightSection);
if (!authorColumn || !actionColumn) {
return null;
}
const rowCount = getDirectContentCells(authorColumn).length;
ensureDivHeaderCell(actionHeader, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureDivHeaderCell(actionHeader, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
const singleColumn = ensureDivBodyColumn(
rightSection,
actionColumn,
SINGLE_COLUMN_KEY,
rowCount
);
const personalColumn = ensureDivBodyColumn(
rightSection,
actionColumn,
PERSONAL_COLUMN_KEY,
rowCount
);
const allBodyColumns = Array.from(bodySection.children).flatMap((section) =>
section instanceof root.ownerDocument.defaultView!.HTMLElement
? getDirectContentColumns(section)
: []
);
const authorCells = getDirectContentCells(authorColumn);
const singleCells = getDirectContentCells(singleColumn);
const personalCells = getDirectContentCells(personalColumn);
const priceColumn = findPreviousColumn(actionColumn);
const priceCells = priceColumn ? getDirectContentCells(priceColumn) : [];
const vueMarketRows = readVueMarketRows(root);
const serializedMarketRows = readSerializedMarketRows(root.ownerDocument);
const rows = authorCells.flatMap((authorCell, index) => {
const singleCell = singleCells[index] ?? null;
const personalCell = personalCells[index] ?? null;
if (!singleCell || !personalCell) {
return [];
}
const rowCells = allBodyColumns
.map((column) => getDirectContentCells(column)[index] ?? null)
.filter((cell): cell is HTMLElement => cell !== null);
const vueMarketRow = vueMarketRows[index] ?? null;
const serializedMarketRow = serializedMarketRows[index] ?? null;
const authorId =
extractAuthorId(authorCell) ||
vueMarketRow?.authorId ||
serializedMarketRow?.authorId ||
"";
const authorName =
extractAuthorName(authorCell) ||
vueMarketRow?.authorName ||
serializedMarketRow?.authorName ||
"";
return [
{
authorId,
authorName,
hasDirectRatesSource:
vueMarketRow?.hasDirectRatesSource ??
serializedMarketRow?.hasDirectRatesSource ??
false,
orderTargets: rowCells
.map((cell) => {
const container = cell.parentElement;
if (!(container instanceof root.ownerDocument.defaultView!.HTMLElement)) {
return null;
}
return {
container,
node: cell
};
})
.filter((target): target is RowOrderTarget => target !== null),
personalCell,
price21To60s: priceCells[index]?.textContent?.trim() ?? "",
rates: vueMarketRow?.rates ?? serializedMarketRow?.rates,
row: authorCell,
singleCell,
visibilityTargets: rowCells
} satisfies MarketRowDom
];
});
return {
rows
};
}
function ensureSyntheticHeaderCell(
header: HTMLElement,
field: string,
label: string
@@ -115,7 +315,7 @@ function ensureHeaderCell(
return nextCell;
}
function ensureRowCell(row: HTMLElement, field: string): HTMLElement {
function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement {
const existingCell = row.querySelector(
`[data-market-row-cell="${field}"]`
) as HTMLElement | null;
@@ -129,3 +329,351 @@ function ensureRowCell(row: HTMLElement, field: string): HTMLElement {
row.appendChild(nextCell);
return nextCell;
}
function ensureDivHeaderCell(
actionHeader: 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;
return existingCell;
}
const referenceCell = findPreviousHeaderCell(actionHeader) ?? actionHeader;
const nextCell = cloneElementShallow(referenceCell);
nextCell.dataset.marketHeaderCell = field;
nextCell.textContent = label;
container.insertBefore(nextCell, actionHeader);
return nextCell;
}
function ensureDivBodyColumn(
bodySection: HTMLElement,
actionColumn: 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);
return existingColumn;
}
const referenceColumn = findPreviousColumn(actionColumn) ?? actionColumn;
const nextColumn = cloneElementShallow(referenceColumn);
nextColumn.dataset.marketColumnGroup = field;
syncDivColumnCells(nextColumn, actionColumn, field, rowCount);
container.insertBefore(nextColumn, actionColumn);
return nextColumn;
}
function syncDivColumnCells(
column: HTMLElement,
actionColumn: HTMLElement,
field: string,
rowCount: number
): void {
const currentCells = getDirectContentCells(column);
while (currentCells.length > rowCount) {
currentCells.pop()?.remove();
}
const actionCells = getDirectContentCells(actionColumn);
for (let index = 0; index < rowCount; index += 1) {
const existingCell = getDirectContentCells(column)[index] ?? null;
if (existingCell) {
existingCell.dataset.marketRowCell = field;
continue;
}
const templateCell = actionCells[index] ?? actionCells[actionCells.length - 1] ?? null;
const nextCell = templateCell
? cloneElementShallow(templateCell)
: createBareContentCell(column.ownerDocument);
nextCell.dataset.marketRowCell = field;
nextCell.textContent = "";
column.appendChild(nextCell);
}
}
function getOwnerDocument(root: ParentNode): Document | null {
if ("ownerDocument" in root && root.ownerDocument) {
return root.ownerDocument;
}
return root instanceof Document ? root : null;
}
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 getActionColumn(bodySection: HTMLElement): HTMLElement | null {
const columns = getDirectContentColumns(bodySection);
return columns[columns.length - 1] ?? null;
}
function getDirectHeaderCells(section: Element): HTMLElement[] {
return Array.from(section.querySelectorAll(".header-cell")).filter(
(cell): cell is HTMLElement =>
cell instanceof section.ownerDocument.defaultView!.HTMLElement
);
}
function getDirectContentColumns(section: Element): HTMLElement[] {
return Array.from(section.children).filter(
(child): child is HTMLElement =>
child instanceof section.ownerDocument.defaultView!.HTMLElement &&
child.classList.contains("content-column")
);
}
function getDirectContentCells(column: Element): HTMLElement[] {
return Array.from(column.children).filter(
(child): child is HTMLElement =>
child instanceof column.ownerDocument.defaultView!.HTMLElement &&
child.classList.contains("content-cell")
);
}
function 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);
clone.className = reference.className;
const style = reference.getAttribute("style");
if (style) {
clone.setAttribute("style", style);
}
return clone;
}
function createBareContentCell(document: Document): HTMLElement {
const cell = document.createElement("div");
cell.className = "content-cell";
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
): Array<{
authorId: string;
authorName: string;
hasDirectRatesSource: boolean;
rates?: AfterSearchRates;
}> {
const vueRoot = (
marketRoot as HTMLElement & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
}
).__vue__;
const setupState = vueRoot?._setupState;
if (!setupState) {
return [];
}
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 = isRecord(record.attribute_datas)
? record.attribute_datas
: {};
const singleVideoAfterSearchRate = normalizeMarketListRate(
attributeDatas.avg_search_after_view_rate_30d
);
return {
authorId:
readString(record.star_id) ??
readString(attributeDatas.id) ??
"",
authorName:
readString(attributeDatas.nickname) ??
readString(record.nick_name) ??
"",
hasDirectRatesSource: true,
rates: singleVideoAfterSearchRate
? {
singleVideoAfterSearchRate
}
: undefined
};
});
}
return [];
}
function readSerializedMarketRows(
document: Document
): Array<{
authorId: string;
authorName: string;
hasDirectRatesSource: boolean;
rates?: AfterSearchRates;
}> {
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) ?? "",
hasDirectRatesSource: Boolean(singleVideoAfterSearchRate),
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 readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function normalizeMarketListRate(value: unknown): string | null {
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
}
function readRateCellText(value: string | undefined): string {
return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT;
}
+13 -4
View File
@@ -8,10 +8,7 @@ import type { AfterSearchRates } from "./types";
interface ResultStoreLike {
setAuthorFailed(authorId: string, reason: MarketApiFailureReason): void;
setAuthorLoading(authorId: string): void;
setAuthorSuccess(
authorId: string,
rates: Required<AfterSearchRates>
): void;
setAuthorSuccess(authorId: string, rates: AfterSearchRates): void;
upsertMarketRow(row: MarketRowSnapshot): void;
}
@@ -71,6 +68,18 @@ export function createFullScanController(options: FullScanControllerOptions) {
for (const row of rows) {
options.resultStore.upsertMarketRow(row);
if (row.hasDirectRatesSource) {
const directRates = row.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
options.resultStore.setAuthorSuccess(row.authorId, directRates);
if (hasAllRates) {
continue;
}
}
options.resultStore.setAuthorLoading(row.authorId);
const metricsResult = await options.loadAuthorMetrics(row.authorId);
+210 -19
View File
@@ -3,7 +3,8 @@ import {
applyRowOrder,
applyRowVisibility,
renderMarketRowState,
syncMarketTable
syncMarketTable,
type MarketRowDom
} from "./dom-sync";
import { applyFilterAndSort } from "./filter-sort-controller";
import { createFullScanController } from "./full-scan-controller";
@@ -24,35 +25,59 @@ interface FullScanControllerLike {
ensureScanForSort(): Promise<void>;
}
interface MutationObserverLike {
disconnect(): void;
observe(target: Node, options?: MutationObserverInit): void;
}
export interface CreateMarketControllerOptions {
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
fullScanController?: FullScanControllerLike;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
mutationObserverFactory?: (
callback: MutationCallback
) => MutationObserverLike;
onCsvReady?: (csv: string) => void;
resultStore?: ReturnType<typeof createMarketResultStore>;
window: Window;
}
export function createMarketController(options: CreateMarketControllerOptions) {
const table = syncMarketTable(options.document);
const marketApiClient = createMarketApiClient();
const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics =
options.loadAuthorMetrics ?? createMarketApiClient().loadAuthorAseInfo;
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
const buildCsv = options.buildCsv ?? buildMarketCsv;
const mutationObserverFactory =
options.mutationObserverFactory ??
((callback: MutationCallback) => new MutationObserver(callback));
let activeFilters: MarketFilterState = {};
let activeSort: MarketSortState | undefined;
let isSyncRunning = false;
let isSyncScheduled = false;
let needsResync = false;
const fullScanController =
options.fullScanController ??
createFullScanController({
goToNextPage: async () => false,
hasNextPage: () => false,
goToNextPage: () => goToNextMarketPage(options.document, options.window),
hasNextPage: () => hasNextMarketPage(options.document),
loadAuthorMetrics,
readCurrentPageRows: () =>
table ? table.rows.map((rowDom) => readRowSnapshot(rowDom.row)) : [],
readCurrentPageRows(options.document),
resultStore
});
const observer = mutationObserverFactory(() => {
scheduleSync();
});
const observationRoot = options.document.body ?? options.document.documentElement;
if (observationRoot) {
observer.observe(observationRoot, {
childList: true,
subtree: true
});
}
const toolbar = ensurePluginToolbar(options.document, {
onApplyFilter: async () => {
@@ -79,25 +104,68 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
});
const ready = hydrateCurrentPage().then(() => {
applyCurrentView();
});
const ready = runSyncCycle();
return {
dispose() {
observer.disconnect();
},
ready
};
async function hydrateCurrentPage(): Promise<void> {
const table = syncMarketTable(options.document);
if (!table) {
return;
}
for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom.row);
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
continue;
}
resultStore.upsertMarketRow(rowSnapshot);
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
if (existingRecord?.status === "success" && existingRecord.rates) {
renderMarketRowState(rowDom, existingRecord);
continue;
}
if (existingRecord?.status === "failed") {
renderMarketRowState(rowDom, existingRecord);
continue;
}
if (existingRecord?.status === "loading") {
renderMarketRowState(rowDom, {
...rowSnapshot,
status: "loading"
});
continue;
}
if (rowSnapshot.hasDirectRatesSource) {
const directRates = rowSnapshot.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
resultStore.setAuthorSuccess(rowSnapshot.authorId, directRates);
renderMarketRowState(rowDom, {
...rowSnapshot,
rates: directRates,
status: "success"
});
if (hasAllRates) {
continue;
}
}
resultStore.setAuthorLoading(rowSnapshot.authorId);
renderMarketRowState(rowDom, {
...rowSnapshot,
rates: resultStore.getRecord(rowSnapshot.authorId)?.rates,
status: "loading"
});
@@ -122,6 +190,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
function applyCurrentView(): void {
const table = syncMarketTable(options.document);
if (!table) {
return;
}
@@ -137,18 +206,63 @@ export function createMarketController(options: CreateMarketControllerOptions) {
sort: activeSort
});
}
function scheduleSync(): void {
if (isSyncRunning) {
needsResync = true;
return;
}
if (isSyncScheduled) {
return;
}
isSyncScheduled = true;
options.window.setTimeout(() => {
isSyncScheduled = false;
void runSyncCycle();
}, 0);
}
async function runSyncCycle(): Promise<void> {
if (isSyncRunning) {
needsResync = true;
return;
}
isSyncRunning = true;
try {
await hydrateCurrentPage();
applyCurrentView();
} finally {
isSyncRunning = false;
if (needsResync) {
needsResync = false;
scheduleSync();
}
}
}
}
function readRowSnapshot(row: HTMLElement): MarketRowSnapshot {
function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
const table = syncMarketTable(document);
if (!table) {
return [];
}
return table.rows
.map((rowDom) => readRowSnapshot(rowDom))
.filter((row): row is MarketRowSnapshot => Boolean(row.authorId));
}
function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
return {
authorId: row.dataset.authorId ?? "",
authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"",
price21To60s:
row
.querySelector('[data-market-field="price21To60s"]')
?.textContent?.trim() ?? ""
authorId: rowDom.authorId,
authorName: rowDom.authorName,
hasDirectRatesSource: rowDom.hasDirectRatesSource,
price21To60s: rowDom.price21To60s,
rates: rowDom.rates
};
}
@@ -174,3 +288,80 @@ function readSortState(
field: fieldSelect.value as MarketSortState["field"]
};
}
function hasNextMarketPage(document: Document): boolean {
const nextButton = findNextPageButton(document);
return Boolean(nextButton && !isDisabled(nextButton));
}
async function goToNextMarketPage(
document: Document,
window: Window
): Promise<boolean> {
const nextButton = findNextPageButton(document);
if (!nextButton || isDisabled(nextButton)) {
return false;
}
const previousSignature = getCurrentPageSignature(document);
nextButton.click();
return waitForPageSignatureChange(document, window, previousSignature);
}
function findNextPageButton(document: Document): HTMLElement | null {
const selectorMatch = document.querySelector(
'[data-testid="next-page"], .ant-pagination-next, .aux-pagination-next, .auxo-pagination-next, [aria-label="next page"]'
);
if (selectorMatch instanceof document.defaultView!.HTMLElement) {
return selectorMatch;
}
return Array.from(document.querySelectorAll("button, a, div, span")).find(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement &&
element.textContent?.trim() === "下一页"
) ?? null;
}
function isDisabled(element: HTMLElement): boolean {
return (
"disabled" in element &&
Boolean((element as HTMLButtonElement).disabled) ||
element.getAttribute("aria-disabled") === "true" ||
/disabled|is-disabled/.test(element.className)
);
}
function getCurrentPageSignature(document: Document): string {
return readCurrentPageRows(document)
.map((row) => row.authorId)
.join("|");
}
function waitForPageSignatureChange(
document: Document,
window: Window,
previousSignature: string
): Promise<boolean> {
return new Promise((resolve) => {
const startedAt = Date.now();
const check = () => {
const currentSignature = getCurrentPageSignature(document);
if (currentSignature && currentSignature !== previousSignature) {
resolve(true);
return;
}
if (Date.now() - startedAt >= 5000) {
resolve(false);
return;
}
window.setTimeout(check, 50);
};
check();
});
}
+130
View File
@@ -0,0 +1,130 @@
import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer";
const BRIDGE_MARKER = "__SCES_MARKET_PAGE_BRIDGE_INSTALLED__";
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
type MarketRow = {
attribute_datas?: Record<string, unknown>;
nick_name?: string;
star_id?: string;
};
declare global {
interface Window {
[BRIDGE_MARKER]?: boolean;
}
}
installMarketPageBridge();
function installMarketPageBridge() {
if (window[BRIDGE_MARKER]) {
syncSerializedMarketRows();
return;
}
window[BRIDGE_MARKER] = true;
syncSerializedMarketRows();
const observer = new MutationObserver(() => {
syncSerializedMarketRows();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
window.setInterval(() => {
syncSerializedMarketRows();
}, 1000);
}
function syncSerializedMarketRows() {
const nextSerializedRows = JSON.stringify(readSerializedMarketRows());
if (
document.documentElement.getAttribute(SERIALIZED_MARKET_ROWS_ATTRIBUTE) !==
nextSerializedRows
) {
document.documentElement.setAttribute(
SERIALIZED_MARKET_ROWS_ATTRIBUTE,
nextSerializedRows
);
}
}
function readSerializedMarketRows() {
const marketList = readMarketList();
return marketList
.map((row) => {
const attributeDatas = isRecord(row.attribute_datas) ? row.attribute_datas : {};
const singleVideoAfterSearchRate = readNormalizedFractionRate(
attributeDatas.avg_search_after_view_rate_30d
);
return {
authorId:
readString(row.star_id) ?? readString(attributeDatas.id) ?? "",
authorName:
readString(attributeDatas.nickname) ?? readString(row.nick_name) ?? "",
singleVideoAfterSearchRate
};
})
.filter((row) => Boolean(row.authorId || row.authorName));
}
function readMarketList(): MarketRow[] {
const marketRoot = document.querySelector(".base-author-list") as
| (HTMLElement & {
__vue__?: {
_setupState?: Record<string, unknown>;
};
})
| null;
const setupState = marketRoot?.__vue__?._setupState;
if (!setupState) {
return [];
}
for (const value of Object.values(setupState)) {
const candidate = unwrapVueRef(value);
if (Array.isArray(candidate) && looksLikeMarketList(candidate)) {
return candidate as MarketRow[];
}
if (!isRecord(candidate) || !Array.isArray(candidate.marketList)) {
continue;
}
if (looksLikeMarketList(candidate.marketList)) {
return candidate.marketList as MarketRow[];
}
}
return [];
}
function looksLikeMarketList(value: unknown[]): boolean {
const firstRow = value[0];
return isRecord(firstRow) && ("star_id" in firstRow || "attribute_datas" in firstRow);
}
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 readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readNormalizedFractionRate(value: unknown): string | undefined {
return typeof value === "string"
? normalizeFractionRateDisplay(value) ?? undefined
: undefined;
}
+13 -2
View File
@@ -25,15 +25,26 @@ export function createMarketResultStore() {
existingRecord.status = "loading";
delete existingRecord.failureReason;
},
setAuthorSuccess(authorId: string, rates: Required<AfterSearchRates>) {
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "success";
existingRecord.rates = rates;
existingRecord.rates = {
...existingRecord.rates,
...rates
};
delete existingRecord.failureReason;
},
upsertMarketRow(row: MarketRowSnapshot) {
const existingRecord = records.get(row.authorId);
if (existingRecord) {
existingRecord.hasDirectRatesSource =
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
if (row.rates) {
existingRecord.rates = {
...existingRecord.rates,
...row.rates
};
}
return existingRecord;
}
+2 -1
View File
@@ -8,14 +8,15 @@ export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "mi
export interface MarketRowSnapshot {
authorId: string;
authorName: string;
hasDirectRatesSource?: boolean;
location?: string;
price21To60s?: string;
rates?: AfterSearchRates;
}
export interface MarketRecord extends MarketRowSnapshot {
status: MarketRecordStatus;
failureReason?: MarketApiFailureReason;
rates?: Required<AfterSearchRates>;
}
export interface MarketFilterState {
+13 -1
View File
@@ -5,9 +5,21 @@
"description": "Bootstraps the Xingtu creator market content script.",
"content_scripts": [
{
"matches": ["https://xingtu.cn/ad/creator/market*"],
"matches": [
"https://xingtu.cn/ad/creator/market*",
"https://*.xingtu.cn/ad/creator/market*"
],
"js": ["content/index.js"],
"run_at": "document_idle"
}
],
"web_accessible_resources": [
{
"resources": ["content/market-page-bridge.js"],
"matches": [
"https://xingtu.cn/*",
"https://*.xingtu.cn/*"
]
}
]
}
+14
View File
@@ -17,6 +17,16 @@ export function normalizeRateDisplay(value: string): string {
return trimmedValue.replace(/\s+/g, "");
}
export function normalizeFractionRateDisplay(value: string): string | null {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
return null;
}
const percentageValue = numericValue * 100;
return `${trimTrailingZeros(percentageValue.toFixed(6))}%`;
}
export function parseRateLowerBound(value: string | null | undefined): number | null {
const comparableRate = toComparableRate(value);
return comparableRate?.numeric ?? null;
@@ -86,3 +96,7 @@ function toComparableRate(value: string | null | undefined): ComparableRate | nu
return null;
}
function trimTrailingZeros(value: string): string {
return value.replace(/\.?0+$/, "");
}