feat: stabilize Xingtu market data sync
This commit is contained in:
+69
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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/*"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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+$/, "");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user