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
+54
View File
@@ -0,0 +1,54 @@
import LogtoClient from "@logto/chrome-extension";
import { readAuthConfig } from "../../shared/auth-config";
import type { AuthClientLike } from "./types";
export function createLogtoAuthClient(): AuthClientLike {
const config = readAuthConfig();
const client = new LogtoClient({
appId: config.appId,
endpoint: config.logtoEndpoint,
resources: [config.apiResource],
scopes: config.scopes
});
return {
getAccessToken(resource?: string) {
return client.getAccessToken(resource);
},
getIdTokenClaims() {
return client.getIdTokenClaims();
},
isAuthenticated() {
return client.isAuthenticated();
},
signIn() {
return client.signIn(readChromeIdentity().getRedirectURL("/callback"));
},
signOut() {
return client.signOut(readChromeIdentity().getRedirectURL());
}
};
}
function readChromeIdentity(): {
getRedirectURL: (path?: string) => string;
} {
const identity = (
globalThis as typeof globalThis & {
chrome?: {
identity?: {
getRedirectURL?: (path?: string) => string;
};
};
}
).chrome?.identity;
if (typeof identity?.getRedirectURL !== "function") {
throw new Error("chrome.identity.getRedirectURL is unavailable");
}
return {
getRedirectURL: identity.getRedirectURL.bind(identity)
};
}
+39
View File
@@ -0,0 +1,39 @@
import { readAuthConfig, type AuthConfig } from "../../shared/auth-config";
import { createLoggedInAuthState, createLoggedOutAuthState } from "./state";
import type { AuthClientLike } from "./types";
export interface AuthController {
getAccessToken(): Promise<string>;
getAuthState(): Promise<ReturnType<typeof createLoggedOutAuthState>>;
signIn(): Promise<void>;
signOut(): Promise<void>;
}
export function createAuthController(options: {
authClient: AuthClientLike;
config?: AuthConfig;
}): AuthController {
const config = options.config ?? readAuthConfig();
return {
async getAccessToken() {
return options.authClient.getAccessToken(config.apiResource);
},
async getAuthState() {
const isAuthenticated = await options.authClient.isAuthenticated();
if (!isAuthenticated) {
return createLoggedOutAuthState(config);
}
const claims = await options.authClient.getIdTokenClaims();
return createLoggedInAuthState(claims, config);
},
async signIn() {
await options.authClient.signIn();
},
async signOut() {
await options.authClient.signOut();
}
};
}
+38
View File
@@ -0,0 +1,38 @@
import type { AuthConfig } from "../../shared/auth-config";
import type { AuthStateValue } from "../../shared/auth-messages";
export function createLoggedOutAuthState(
config?: Pick<AuthConfig, "apiResource">
): AuthStateValue {
return {
isAuthenticated: false,
resource: config?.apiResource ?? null
};
}
export function createLoggedInAuthState(
claims: Record<string, unknown> | null | undefined,
config?: Pick<AuthConfig, "apiResource" | "scopes">
): AuthStateValue {
return {
accessTokenExpiresAt: null,
isAuthenticated: true,
resource: config?.apiResource ?? null,
scopes: config?.scopes ?? [],
tokenAvailable: true,
userInfo: {
email: readStringClaim(claims, "email"),
name: readStringClaim(claims, "name"),
sub: readStringClaim(claims, "sub"),
username: readStringClaim(claims, "username")
}
};
}
function readStringClaim(
claims: Record<string, unknown> | null | undefined,
key: string
): string | undefined {
const value = claims?.[key];
return typeof value === "string" ? value : undefined;
}
+7
View File
@@ -0,0 +1,7 @@
export interface AuthClientLike {
getAccessToken(resource?: string): Promise<string>;
getIdTokenClaims(): Promise<Record<string, unknown> | null>;
isAuthenticated(): Promise<boolean>;
signIn(): Promise<void>;
signOut(): Promise<void>;
}
+166 -11
View File
@@ -1,3 +1,14 @@
import { createAuthController, type AuthController } from "./auth/controller";
import { createLogtoAuthClient } from "./auth/client";
import {
isAuthRequestMessage,
type AuthResponseMessage
} from "../shared/auth-messages";
import { createBatchSubmitClient } from "../shared/batch-submit-client";
import { createBackendMetricsClient } from "../shared/backend-metrics-client";
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "../shared/backend-metrics-config";
import { isBackendMetricsSearchRequestMessage } from "../shared/backend-metrics-messages";
interface ChromeDownloadsLike {
download(
options: {
@@ -32,33 +43,168 @@ type DownloadMarketCsvMessage = {
type: "download-market-csv";
};
type BatchSubmitMessage = {
payload: unknown;
type: "batch:submit";
};
export function registerBackgroundMessageHandler(
chromeLike: ChromeLike = (
globalThis as typeof globalThis & {
chrome?: ChromeLike;
}
).chrome ?? {}
chromeLike: ChromeLike = readChromeLike(),
dependencies: {
authController?: AuthController;
searchBackendMetrics?: (starIds: string[]) => Promise<unknown>;
submitBatch?: (payload: unknown) => Promise<unknown>;
} = {}
): void {
let authController = dependencies.authController;
let searchBackendMetrics = dependencies.searchBackendMetrics;
let submitBatch = dependencies.submitBatch;
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
if (!isDownloadMarketCsvMessage(message)) {
if (isDownloadMarketCsvMessage(message)) {
void triggerCsvDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true });
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false
});
});
return true;
}
if (isBatchSubmitMessage(message)) {
authController ??= createAuthController({
authClient: createLogtoAuthClient()
});
submitBatch ??= createBatchSubmitClient({
baseUrl: "http://127.0.0.1:4319",
getAccessToken: () => authController!.getAccessToken(),
sendMessage: () =>
Promise.reject(new Error("background batch submit does not use sendMessage"))
}).submitBatch;
void submitBatch(message.payload)
.then((value) => {
sendResponse({
ok: true,
type: "batch:ack",
value
});
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false,
type: "batch:error"
});
});
return true;
}
if (isBackendMetricsSearchRequestMessage(message)) {
authController ??= createAuthController({
authClient: createLogtoAuthClient()
});
searchBackendMetrics ??= createBackendMetricsClient({
baseUrl: DEFAULT_BACKEND_METRICS_BASE_URL,
getAccessToken: () => authController!.getAccessToken()
}).searchByStarIds;
void searchBackendMetrics(message.value.starIds)
.then((rows) => {
sendResponse({
ok: true,
type: "backend-metrics:result",
value: {
rows
}
});
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false,
type: "backend-metrics:error"
});
});
return true;
}
if (!isAuthRequestMessage(message)) {
return;
}
void triggerCsvDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true });
authController ??= createAuthController({
authClient: createLogtoAuthClient()
});
void handleAuthMessage(authController, message)
.then((response) => {
sendResponse(response);
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false
});
ok: false,
type: "auth:error"
} satisfies AuthResponseMessage);
});
return true;
});
}
async function handleAuthMessage(
authController: AuthController,
message: Parameters<typeof isAuthRequestMessage>[0] & { type: string }
): Promise<AuthResponseMessage> {
if (message.type === "auth:get-state") {
return {
ok: true,
type: "auth:state",
value: await authController.getAuthState()
};
}
if (message.type === "auth:get-access-token") {
return {
ok: true,
type: "auth:token",
value: {
accessToken: await authController.getAccessToken()
}
};
}
if (message.type === "auth:sign-in") {
await authController.signIn();
return {
ok: true,
type: "auth:ack"
};
}
await authController.signOut();
return {
ok: true,
type: "auth:ack"
};
}
function readChromeLike(): ChromeLike {
return (
globalThis as typeof globalThis & {
chrome?: ChromeLike;
}
).chrome ?? {};
}
async function triggerCsvDownload(
chromeLike: ChromeLike,
message: DownloadMarketCsvMessage
@@ -92,4 +238,13 @@ function isDownloadMarketCsvMessage(
);
}
function isBatchSubmitMessage(message: unknown): message is BatchSubmitMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<BatchSubmitMessage>;
return candidate.type === "batch:submit" && "payload" in candidate;
}
registerBackgroundMessageHandler();
+43
View File
@@ -2,6 +2,11 @@ import {
createMarketController,
type CreateMarketControllerOptions
} from "./market/index";
import { renderMarketAuthGate } from "./market/auth-gate";
import {
isAuthResponseMessage,
type AuthStateValue
} from "../shared/auth-messages";
interface ChromeRuntimeLike {
getURL?: (path: string) => string;
@@ -16,6 +21,7 @@ interface BootContentScriptOptions {
options: CreateMarketControllerOptions
) => { dispose?: () => void; ready: Promise<void> };
document?: Document;
sendAuthMessage?: (message: unknown) => Promise<unknown>;
window?: Window;
}
@@ -26,11 +32,21 @@ export async function bootContentScript(
const currentDocument = options.document ?? document;
const controllerFactory =
options.createMarketController ?? createMarketController;
const sendAuthMessage =
options.sendAuthMessage ?? createRuntimeMessageSender();
if (!isMarketPage(currentWindow.location.href)) {
return null;
}
const authState = await readAuthState(sendAuthMessage);
if (!authState?.isAuthenticated) {
renderMarketAuthGate(currentDocument, currentWindow);
return {
ready: Promise.resolve()
};
}
installMarketPageBridge(currentDocument);
return controllerFactory({
@@ -46,6 +62,17 @@ export async function bootContentScript(
});
}
async function readAuthState(
sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue | null> {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
return null;
}
return response.value;
}
function isMarketPage(url: string): boolean {
const parsedUrl = new URL(url);
const isXingtuHost =
@@ -101,6 +128,22 @@ function requestCsvDownload(csv: string): boolean {
return true;
}
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
return async (message: unknown) => {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (typeof runtime?.sendMessage !== "function") {
return null;
}
return runtime.sendMessage(message);
};
}
function downloadCsv(document: Document, window: Window, csv: string): void {
const blob = new Blob(["\uFEFF", csv], {
type: "text/csv;charset=utf-8"
+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;
}
+10 -1
View File
@@ -3,7 +3,16 @@
"name": "Star Chart Search Enhancer",
"version": "0.2.0421.2",
"description": "Bootstraps the Xingtu creator market content script.",
"permissions": ["downloads"],
"permissions": ["downloads", "identity", "storage"],
"host_permissions": [
"http://*/*",
"https://login-api.intelligrow.cn/*",
"http://127.0.0.1:4319/*",
"https://*/*"
],
"action": {
"default_popup": "popup/index.html"
},
"background": {
"service_worker": "background/index.js"
},
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Chart Search Enhancer</title>
</head>
<body>
<main id="app"></main>
<script src="./index.js"></script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
export interface AuthConfig {
apiResource: string;
appId: string;
enableDevAuthPanel: boolean;
logtoEndpoint: string;
scopes: string[];
}
const defaultAuthConfig: AuthConfig = {
apiResource: "https://talent-search.intelligrow.cn",
appId: "i4jkllbvih0554r4n0fd3",
enableDevAuthPanel: true,
logtoEndpoint: "https://login-api.intelligrow.cn",
scopes: ["openid", "profile", "offline_access", "talent-search:read"]
};
export function readAuthConfig(
overrides: Partial<AuthConfig> = {}
): AuthConfig {
const nextConfig = {
...defaultAuthConfig,
...overrides
};
if (!nextConfig.logtoEndpoint.trim()) {
throw new Error("auth config logtoEndpoint is required");
}
if (!nextConfig.appId.trim()) {
throw new Error("auth config appId is required");
}
if (!nextConfig.apiResource.trim()) {
throw new Error("auth config apiResource is required");
}
return nextConfig;
}
+84
View File
@@ -0,0 +1,84 @@
export type AuthRequestMessage =
| { type: "auth:get-state" }
| { type: "auth:sign-in" }
| { type: "auth:sign-out" }
| { type: "auth:get-access-token" };
export interface AuthStateValue {
accessTokenExpiresAt?: number | null;
isAuthenticated: boolean;
lastError?: string | null;
resource?: string | null;
scopes?: string[];
tokenAvailable?: boolean;
userInfo?: {
email?: string;
name?: string;
sub?: string;
username?: string;
} | null;
}
export type AuthResponseMessage =
| { ok: true; type: "auth:state"; value: AuthStateValue }
| { ok: true; type: "auth:token"; value: { accessToken: string } }
| { ok: true; type: "auth:ack" }
| { ok: false; type: "auth:error"; error: string };
const authRequestTypes = new Set<AuthRequestMessage["type"]>([
"auth:get-state",
"auth:sign-in",
"auth:sign-out",
"auth:get-access-token"
]);
export function isAuthRequestMessage(
value: unknown
): value is AuthRequestMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<AuthRequestMessage>;
return typeof candidate.type === "string" && authRequestTypes.has(candidate.type);
}
export function isAuthResponseMessage(
value: unknown
): value is AuthResponseMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<AuthResponseMessage>;
if (candidate.ok === false) {
return candidate.type === "auth:error" && typeof candidate.error === "string";
}
if (candidate.ok !== true || typeof candidate.type !== "string") {
return false;
}
if (candidate.type === "auth:ack") {
return true;
}
if (candidate.type === "auth:token") {
return Boolean(
candidate.value &&
typeof candidate.value === "object" &&
typeof (candidate.value as { accessToken?: unknown }).accessToken === "string"
);
}
if (candidate.type === "auth:state") {
return Boolean(
candidate.value &&
typeof candidate.value === "object" &&
typeof (candidate.value as { isAuthenticated?: unknown }).isAuthenticated ===
"boolean"
);
}
return false;
}
+132
View File
@@ -0,0 +1,132 @@
import { DEFAULT_BACKEND_METRICS_BASE_URL } from "./backend-metrics-config";
export interface BackendMetricsRow {
a3IncreaseCount: string;
afterViewSearchCount: string;
afterViewSearchRate: string;
cpSearch: string;
cpa3: string;
newA3Rate: string;
starId: string;
}
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface BackendMetricsClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
getAccessToken: () => Promise<string>;
}
export function createBackendMetricsClient(options: BackendMetricsClientOptions) {
const baseUrl = options.baseUrl ?? DEFAULT_BACKEND_METRICS_BASE_URL;
const fetchImpl = options.fetchImpl ?? defaultFetch;
return {
async searchByStarIds(starIds: string[]): Promise<BackendMetricsRow[]> {
const response = await fetchImpl(buildBackendMetricsSearchUrl(baseUrl), {
body: JSON.stringify(buildBackendMetricsSearchRequestBody(starIds)),
headers: {
Authorization: `Bearer ${await options.getAccessToken()}`,
"Content-Type": "application/json"
},
method: "POST"
});
if (!response.ok) {
throw new Error("backend metrics request failed");
}
return mapBackendMetricsSearchResponse(await response.json());
}
};
}
export function buildBackendMetricsSearchUrl(baseUrl: string): string {
return new URL("/api/v1/history/talents/search", baseUrl).toString();
}
export function buildBackendMetricsSearchRequestBody(starIds: string[]) {
return {
page: 1,
size: Math.max(20, starIds.length),
type: "star_id",
values: starIds
};
}
export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetricsRow[] {
const rows = readResponseRows(payload);
if (!rows) {
throw new Error("backend metrics response is invalid");
}
return rows.flatMap((row) => {
if (!isRecord(row) || typeof row.star_id !== "string") {
return [];
}
return [
{
a3IncreaseCount: formatDecimalValue(row.avg_a3_increase_cnt),
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
cpSearch: formatDecimalValue(row.cp_search),
cpa3: formatDecimalValue(row.cpa3),
newA3Rate: formatRateValue(row.avg_new_a3_rate),
starId: row.star_id
}
];
});
}
function readResponseRows(payload: unknown): unknown[] | null {
if (!isRecord(payload) || payload.success !== true) {
return null;
}
const topLevelData = isRecord(payload.data) ? payload.data : null;
return Array.isArray(topLevelData?.data) ? topLevelData.data : null;
}
function formatRateValue(value: unknown): string {
const number = typeof value === "number" ? value : Number(value);
if (Number.isFinite(number)) {
const percentage = number * 100;
const formatted = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: percentage % 1 === 0 ? 0 : 2
}).format(percentage);
return `${formatted}%`;
}
return "";
}
function formatDecimalValue(value: unknown): string {
const number = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(number)) {
return "";
}
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: 2
}).format(number);
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+1
View File
@@ -0,0 +1 @@
export const DEFAULT_BACKEND_METRICS_BASE_URL = "http://192.168.31.29:8083";
+67
View File
@@ -0,0 +1,67 @@
import type { BackendMetricsRow } from "./backend-metrics-client";
export type BackendMetricsSearchRequestMessage = {
type: "backend-metrics:search";
value: {
starIds: string[];
};
};
export type BackendMetricsResponseMessage =
| {
ok: true;
type: "backend-metrics:result";
value: {
rows: BackendMetricsRow[];
};
}
| {
error: string;
ok: false;
type: "backend-metrics:error";
};
export function isBackendMetricsSearchRequestMessage(
value: unknown
): value is BackendMetricsSearchRequestMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<BackendMetricsSearchRequestMessage>;
return (
candidate.type === "backend-metrics:search" &&
Boolean(
candidate.value &&
typeof candidate.value === "object" &&
Array.isArray((candidate.value as { starIds?: unknown }).starIds) &&
(candidate.value as { starIds: unknown[] }).starIds.every(
(starId) => typeof starId === "string"
)
)
);
}
export function isBackendMetricsResponseMessage(
value: unknown
): value is BackendMetricsResponseMessage {
if (!value || typeof value !== "object") {
return false;
}
const candidate = value as Partial<BackendMetricsResponseMessage>;
if (candidate.ok === false) {
return (
candidate.type === "backend-metrics:error" &&
typeof candidate.error === "string"
);
}
return Boolean(
candidate.ok === true &&
candidate.type === "backend-metrics:result" &&
candidate.value &&
typeof candidate.value === "object" &&
Array.isArray((candidate.value as { rows?: unknown }).rows)
);
}
+5 -1
View File
@@ -12,18 +12,22 @@ type FetchLike = (
init?: RequestInit
) => Promise<FetchResponseLike>;
type GetAccessTokenLike = () => Promise<string>;
type SendMessageLike = (message: unknown) => Promise<unknown>;
export function createBatchSubmitClient(options: {
baseUrl: string;
fetchImpl?: FetchLike;
getAccessToken?: GetAccessTokenLike;
sendMessage: SendMessageLike;
}) {
const fetchImpl = options.fetchImpl ?? fetch;
const getAccessToken =
options.getAccessToken ?? (() => readAccessToken(options.sendMessage));
return {
async submitBatch(payload: BatchPayload) {
const token = await readAccessToken(options.sendMessage);
const token = await getAccessToken();
const response = await fetchImpl(
new URL("/api/mock/batches", options.baseUrl).toString(),
{