feat: add logto auth and backend metrics integration
This commit is contained in:
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const DEFAULT_BACKEND_METRICS_BASE_URL = "http://192.168.31.29:8083";
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user