feat: add logto auth and backend metrics integration

This commit is contained in:
2026-04-22 15:52:12 +08:00
parent b1bb28f5aa
commit c7ae2fbfcb
38 changed files with 4886 additions and 34 deletions
+29
View File
@@ -0,0 +1,29 @@
export function renderMarketAuthGate(
document: Document,
currentWindow: Window
): HTMLElement {
const existingGate = document.querySelector(
'[data-market-auth-gate="root"]'
) as HTMLElement | null;
if (existingGate) {
return existingGate;
}
const root = document.createElement("section");
root.dataset.marketAuthGate = "root";
root.innerHTML = `
<strong>请先登录插件</strong>
<p>打开扩展弹窗完成登录后刷新本页</p>
<button type="button" data-market-auth-help="button">去登录</button>
`;
root
.querySelector('[data-market-auth-help="button"]')
?.addEventListener("click", () => {
currentWindow.alert("请点击浏览器工具栏中的扩展图标完成登录");
});
document.body.prepend(root);
return root;
}
+125 -4
View File
@@ -2,14 +2,17 @@ import {
normalizeFractionRateDisplay,
normalizeRateDisplay
} from "../../shared/rate-normalizer";
import type { AfterSearchRates } from "./types";
import type { AfterSearchRates, BackendMetrics } from "./types";
import type { MarketRecord } from "./types";
const BACKEND_COLUMN_KEY = "backendMetrics";
const SINGLE_COLUMN_KEY = "singleVideoAfterSearchRate";
const PERSONAL_COLUMN_KEY = "personalVideoAfterSearchRate";
const ACTION_HEADER_TEXT = "操作";
const AUTHOR_HEADER_TEXT = "达人信息";
const BACKEND_HEADER_TEXT = "秒探指标";
const UNAVAILABLE_RATE_TEXT = "暂无来源";
const UNAVAILABLE_BACKEND_METRICS_TEXT = "暂无数据";
const SERIALIZED_MARKET_ROWS_ATTRIBUTE = "data-sces-market-rows";
type RowOrderTarget = {
@@ -20,6 +23,7 @@ type RowOrderTarget = {
export interface MarketRowDom {
authorId: string;
authorName: string;
backendMetricsCell: HTMLElement;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
personalCell: HTMLElement;
@@ -105,6 +109,8 @@ export function renderMarketRowState(
rowDom: MarketRowDom,
record: MarketRecord
): void {
renderBackendMetricsCell(rowDom.backendMetricsCell, record);
if (record.status === "success" && record.rates) {
rowDom.singleCell.textContent = readRateCellText(
record.rates.singleVideoAfterSearchRate
@@ -171,6 +177,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
ensureSyntheticHeaderCell(header, BACKEND_COLUMN_KEY, BACKEND_HEADER_TEXT);
const headerLabelsByField = readSyntheticHeaderLabels(header);
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
@@ -178,12 +185,14 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
const row = rowElement as HTMLElement;
const singleCell = ensureSyntheticRowCell(row, SINGLE_COLUMN_KEY);
const personalCell = ensureSyntheticRowCell(row, PERSONAL_COLUMN_KEY);
const backendMetricsCell = ensureSyntheticRowCell(row, BACKEND_COLUMN_KEY);
return {
authorId: row.dataset.authorId ?? "",
authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"",
backendMetricsCell,
exportFields: readSyntheticExportFields(row, headerLabelsByField),
hasDirectRatesSource: false,
orderTargets: [
@@ -274,6 +283,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
const rowCount = getDirectContentCells(authorColumn).length;
ensureDivHeaderCell(actionHeader, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureDivHeaderCell(actionHeader, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
ensureDivHeaderCell(actionHeader, BACKEND_COLUMN_KEY, BACKEND_HEADER_TEXT);
const singleColumn = ensureDivBodyColumn(
rightSection,
@@ -287,6 +297,14 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
PERSONAL_COLUMN_KEY,
rowCount
);
const backendMetricsColumn = ensureDivBodyColumn(
rightSection,
actionColumn,
BACKEND_COLUMN_KEY,
rowCount
);
syncContainerWidth(actionHeader.parentElement);
syncContainerWidth(rightSection);
const allBodyColumns = Array.from(bodySection.children).flatMap((section) =>
section instanceof root.ownerDocument.defaultView!.HTMLElement
@@ -301,6 +319,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
const authorCells = getDirectContentCells(authorColumn);
const singleCells = getDirectContentCells(singleColumn);
const personalCells = getDirectContentCells(personalColumn);
const backendMetricsCells = getDirectContentCells(backendMetricsColumn);
const priceColumn = findPreviousColumn(actionColumn);
const priceCells = priceColumn ? getDirectContentCells(priceColumn) : [];
const vueMarketRows = readVueMarketRows(root);
@@ -309,7 +328,8 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
const rows = authorCells.flatMap((authorCell, index) => {
const singleCell = singleCells[index] ?? null;
const personalCell = personalCells[index] ?? null;
if (!singleCell || !personalCell) {
const backendMetricsCell = backendMetricsCells[index] ?? null;
if (!singleCell || !personalCell || !backendMetricsCell) {
return [];
}
@@ -333,6 +353,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
{
authorId,
authorName,
backendMetricsCell,
exportFields: readExportFieldsForDivGridRow(allHeaderCells, rowCells),
hasDirectRatesSource:
vueMarketRow?.hasDirectRatesSource ??
@@ -395,7 +416,7 @@ function ensureSyntheticRowCell(row: HTMLElement, field: string): HTMLElement {
return existingCell;
}
const nextCell = row.ownerDocument.createElement("span");
const nextCell = row.ownerDocument.createElement(field === BACKEND_COLUMN_KEY ? "div" : "span");
nextCell.dataset.marketRowCell = field;
row.appendChild(nextCell);
return nextCell;
@@ -423,6 +444,7 @@ function ensureDivHeaderCell(
const nextCell = cloneElementShallow(referenceCell);
nextCell.dataset.marketHeaderCell = field;
nextCell.textContent = label;
applyColumnWidth(nextCell, field);
container.insertBefore(nextCell, actionHeader);
return nextCell;
}
@@ -449,6 +471,7 @@ function ensureDivBodyColumn(
const referenceColumn = findPreviousColumn(actionColumn) ?? actionColumn;
const nextColumn = cloneElementShallow(referenceColumn);
nextColumn.dataset.marketColumnGroup = field;
applyColumnWidth(nextColumn, field);
syncDivColumnCells(nextColumn, actionColumn, field, rowCount);
container.insertBefore(nextColumn, actionColumn);
return nextColumn;
@@ -478,6 +501,7 @@ function syncDivColumnCells(
? cloneElementShallow(templateCell)
: createBareContentCell(column.ownerDocument);
nextCell.dataset.marketRowCell = field;
applyColumnWidth(nextCell, field);
nextCell.textContent = "";
column.appendChild(nextCell);
}
@@ -809,8 +833,9 @@ function normalizeExportCellText(value: string | null | undefined): string {
function shouldExportColumn(label: string): boolean {
return Boolean(
label &&
label &&
label !== ACTION_HEADER_TEXT &&
label !== BACKEND_HEADER_TEXT &&
label !== "单视频看后搜率" &&
label !== "个人视频看后搜率"
);
@@ -819,3 +844,99 @@ function shouldExportColumn(label: string): boolean {
function readRateCellText(value: string | undefined): string {
return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT;
}
function applyColumnWidth(element: HTMLElement, field: string): void {
if (field !== BACKEND_COLUMN_KEY) {
return;
}
element.style.minWidth = "240px";
element.style.width = "240px";
}
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 renderBackendMetricsCell(
cell: HTMLElement,
record: MarketRecord
): void {
if (
record.backendMetricsStatus === "loading" ||
(record.status === "loading" && !record.backendMetricsStatus)
) {
cell.textContent = "加载中...";
return;
}
if (record.backendMetricsStatus === "failed") {
cell.textContent = "加载失败";
return;
}
if (record.backendMetricsStatus === "missing") {
cell.textContent = UNAVAILABLE_BACKEND_METRICS_TEXT;
return;
}
if (record.backendMetricsStatus !== "success" || !record.backendMetrics) {
cell.textContent = "";
return;
}
cell.replaceChildren(createBackendMetricsPanel(cell.ownerDocument, record.backendMetrics));
}
function createBackendMetricsPanel(
document: Document,
backendMetrics: BackendMetrics
): HTMLElement {
const panel = document.createElement("div");
panel.dataset.marketBackendMetrics = "panel";
[
["看后搜率", backendMetrics.afterViewSearchRate],
["看后搜数", backendMetrics.afterViewSearchCount],
["新增A3数", backendMetrics.a3IncreaseCount],
["新增A3率", backendMetrics.newA3Rate],
["CPA3", backendMetrics.cpa3],
["cp_search", backendMetrics.cpSearch]
].forEach(([label, value]) => {
const item = document.createElement("div");
item.textContent = `${label}${value ?? ""}`;
panel.appendChild(item);
});
return panel;
}
+169 -5
View File
@@ -21,8 +21,9 @@ import {
isAuthResponseMessage,
type AuthStateValue
} from "../../shared/auth-messages";
import { createBatchSubmitClient } from "../../shared/batch-submit-client";
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
import type {
BackendMetrics,
MarketApiResult,
MarketFilterState,
MarketExportTarget,
@@ -41,6 +42,9 @@ export interface CreateMarketControllerOptions {
document: Document;
getAuthState?: () => Promise<AuthStateValue>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
searchBackendMetrics?: (starIds: string[]) => Promise<
Array<BackendMetrics & { starId: string }>
>;
mutationObserverFactory?: (
callback: MutationCallback
) => MutationObserverLike;
@@ -57,6 +61,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const resultStore = options.resultStore ?? createMarketResultStore();
const loadAuthorMetrics =
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
const searchBackendMetrics =
options.searchBackendMetrics ??
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
const buildCsv = options.buildCsv ?? buildMarketCsv;
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
const mutationObserverFactory =
@@ -67,10 +74,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
(() => options.window.prompt("请输入批次名称"));
const submitBatch =
options.submitBatch ??
createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
sendMessage: sendRuntimeMessage
}).submitBatch;
((payload: BatchPayload) =>
readBatchSubmitAck(sendRuntimeMessage, payload));
const exportRangeController = createExportRangeController({
document: options.document,
onProgress: ({ currentPage, totalPages }) => {
@@ -197,12 +202,21 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return;
}
const pageRows: Array<{
rowDom: MarketRowDom;
rowSnapshot: MarketRowSnapshot;
}> = [];
for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
continue;
}
pageRows.push({
rowDom,
rowSnapshot
});
resultStore.upsertMarketRow(rowSnapshot);
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
if (existingRecord?.status === "success" && existingRecord.rates) {
@@ -265,6 +279,86 @@ export function createMarketController(options: CreateMarketControllerOptions) {
status: "failed"
});
}
await hydrateBackendMetricsForPage(pageRows);
}
async function hydrateBackendMetricsForPage(
pageRows: Array<{
rowDom: MarketRowDom;
rowSnapshot: MarketRowSnapshot;
}>
): Promise<void> {
if (!searchBackendMetrics || pageRows.length === 0) {
return;
}
const pendingBackendRows = pageRows.filter(({ rowDom, rowSnapshot }) => {
const record = resultStore.getRecord(rowSnapshot.authorId);
if (!record) {
return false;
}
if (
record.backendMetricsStatus === "success" ||
record.backendMetricsStatus === "missing" ||
record.backendMetricsStatus === "failed" ||
record.backendMetricsStatus === "loading"
) {
renderMarketRowState(rowDom, record);
return false;
}
resultStore.setBackendMetricsLoading(rowSnapshot.authorId);
renderMarketRowState(rowDom, {
...record,
...rowSnapshot,
backendMetricsStatus: "loading"
});
return true;
});
if (pendingBackendRows.length === 0) {
return;
}
try {
const rows = await searchBackendMetrics(
pendingBackendRows.map(({ rowSnapshot }) => rowSnapshot.authorId)
);
const rowMap = new Map(rows.map((row) => [row.starId, row]));
pendingBackendRows.forEach(({ rowSnapshot }) => {
const backendMetrics = rowMap.get(rowSnapshot.authorId);
if (backendMetrics) {
resultStore.setBackendMetricsSuccess(rowSnapshot.authorId, backendMetrics);
} else {
resultStore.setBackendMetricsMissing(rowSnapshot.authorId);
}
const record = resultStore.getRecord(rowSnapshot.authorId);
if (!record) {
return;
}
const pageRow = pendingBackendRows.find(
(candidate) => candidate.rowSnapshot.authorId === rowSnapshot.authorId
);
if (pageRow) {
renderMarketRowState(pageRow.rowDom, record);
}
});
} catch {
pendingBackendRows.forEach(({ rowDom, rowSnapshot }) => {
resultStore.setBackendMetricsFailed(rowSnapshot.authorId);
const record = resultStore.getRecord(rowSnapshot.authorId);
if (!record) {
return;
}
renderMarketRowState(rowDom, record);
});
}
}
function applyCurrentView(): void {
@@ -360,6 +454,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
...existingRecord,
...rowSnapshot,
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
backendMetrics: mergeFieldMap(
existingRecord?.backendMetrics,
rowSnapshot.backendMetrics
),
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
exportFields: mergeFieldMap(
existingRecord?.exportFields,
rowSnapshot.exportFields
@@ -644,6 +743,57 @@ async function readAuthState(
return response.value;
}
async function readBatchSubmitAck(
sendMessage: (message: unknown) => Promise<unknown>,
payload: BatchPayload
): Promise<unknown> {
const response = await sendMessage({
payload,
type: "batch:submit"
});
if (
response &&
typeof response === "object" &&
(response as { ok?: unknown }).ok === true
) {
return (response as { value?: unknown }).value;
}
if (
response &&
typeof response === "object" &&
(response as { ok?: unknown }).ok === false &&
typeof (response as { error?: unknown }).error === "string"
) {
throw new Error((response as { error: string }).error);
}
throw new Error("批次提交失败,请稍后重试");
}
async function readBackendMetrics(
sendMessage: (message: unknown) => Promise<unknown>,
starIds: string[]
): Promise<Array<BackendMetrics & { starId: string }>> {
const response = await sendMessage({
type: "backend-metrics:search",
value: {
starIds
}
});
if (
isBackendMetricsResponseMessage(response) &&
response.ok &&
response.type === "backend-metrics:result"
) {
return response.value.rows;
}
throw new Error("后端指标加载失败");
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
@@ -658,3 +808,17 @@ function mergeStringValue(
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function hasRuntimeMessageSender(): boolean {
return Boolean(
(
globalThis as typeof globalThis & {
chrome?: {
runtime?: {
sendMessage?: unknown;
};
};
}
).chrome?.runtime?.sendMessage
);
}
+32 -5
View File
@@ -1,4 +1,5 @@
import type {
BackendMetrics,
MarketApiFailureReason,
MarketRecord,
MarketRowSnapshot
@@ -25,6 +26,26 @@ export function createMarketResultStore() {
existingRecord.status = "loading";
delete existingRecord.failureReason;
},
setBackendMetricsFailed(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "failed";
},
setBackendMetricsLoading(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "loading";
},
setBackendMetricsMissing(authorId: string) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "missing";
},
setBackendMetricsSuccess(authorId: string, backendMetrics: BackendMetrics) {
const existingRecord = ensureRecord(authorId);
existingRecord.backendMetricsStatus = "success";
existingRecord.backendMetrics = {
...existingRecord.backendMetrics,
...backendMetrics
};
},
setAuthorSuccess(authorId: string, rates: AfterSearchRates) {
const existingRecord = ensureRecord(authorId);
existingRecord.status = "success";
@@ -49,19 +70,24 @@ export function createMarketResultStore() {
row.price21To60s
);
existingRecord.exportFields = mergeFieldMap(
existingRecord.exportFields,
existingRecord.exportFields,
row.exportFields
);
existingRecord.backendMetrics = mergeFieldMap(
existingRecord.backendMetrics,
row.backendMetrics
);
existingRecord.hasDirectRatesSource =
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
return existingRecord;
}
const nextRecord: MarketRecord = {
...row,
status: "idle"
};
const nextRecord: MarketRecord = {
...row,
backendMetricsStatus: "idle",
status: "idle"
};
records.set(row.authorId, nextRecord);
return nextRecord;
}
@@ -76,6 +102,7 @@ export function createMarketResultStore() {
const nextRecord: MarketRecord = {
authorId,
authorName: authorId,
backendMetricsStatus: "idle",
status: "idle"
};
records.set(authorId, nextRecord);
+11
View File
@@ -3,11 +3,21 @@ export interface AfterSearchRates {
singleVideoAfterSearchRate?: string;
}
export interface BackendMetrics {
a3IncreaseCount?: string;
afterViewSearchCount?: string;
afterViewSearchRate?: string;
cpSearch?: string;
cpa3?: string;
newA3Rate?: string;
}
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
export interface MarketRowSnapshot {
authorId: string;
authorName: string;
backendMetrics?: BackendMetrics;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
location?: string;
@@ -16,6 +26,7 @@ export interface MarketRowSnapshot {
}
export interface MarketRecord extends MarketRowSnapshot {
backendMetricsStatus?: MarketRecordStatus;
status: MarketRecordStatus;
failureReason?: MarketApiFailureReason;
}