218 lines
5.9 KiB
TypeScript
218 lines
5.9 KiB
TypeScript
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(
|
|
readAverageA3IncreaseCount(row)
|
|
),
|
|
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
|
|
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
|
|
cpSearch: formatDecimalValue(row.cp_search),
|
|
cpa3: formatDecimalValue(readCpa3Value(row)),
|
|
newA3Rate: formatRateValue(row.avg_new_a3_rate),
|
|
starId: row.star_id
|
|
}
|
|
];
|
|
});
|
|
}
|
|
|
|
function readAverageA3IncreaseCount(row: Record<string, unknown>): number | null {
|
|
const directAverage = readFiniteNumber(row.avg_a3_increase_cnt);
|
|
if (directAverage !== null) {
|
|
return directAverage;
|
|
}
|
|
|
|
const totalNewA3 = readTotalNewA3Value(row);
|
|
const videoCount =
|
|
readFiniteNumber(row.video_count) ?? readNestedVideoCount(row.videos);
|
|
if (totalNewA3 === null || videoCount === null || videoCount <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return totalNewA3 / videoCount;
|
|
}
|
|
|
|
function readCpa3Value(row: Record<string, unknown>): number | null {
|
|
const directCpa3 = readFiniteNumber(row.cpa3);
|
|
if (directCpa3 !== null) {
|
|
return directCpa3;
|
|
}
|
|
|
|
const totalCost = readFiniteNumber(row.total_estimated_video_cost);
|
|
const totalNewA3 = readTotalNewA3Value(row);
|
|
if (totalCost === null || totalNewA3 === null || totalNewA3 <= 0) {
|
|
return null;
|
|
}
|
|
|
|
return totalCost / totalNewA3;
|
|
}
|
|
|
|
function readTotalNewA3Value(row: Record<string, unknown>): number | null {
|
|
const derivedFromTotals = deriveTotalNewA3FromTotals(row);
|
|
if (derivedFromTotals !== null) {
|
|
return derivedFromTotals;
|
|
}
|
|
|
|
return deriveTotalNewA3FromVideos(row.videos);
|
|
}
|
|
|
|
function deriveTotalNewA3FromTotals(row: Record<string, unknown>): number | null {
|
|
const totalPlayCount = readFiniteNumber(row.total_play_cnt);
|
|
const averageNewA3Rate = readFiniteNumber(row.avg_new_a3_rate);
|
|
if (totalPlayCount === null || averageNewA3Rate === null) {
|
|
return null;
|
|
}
|
|
|
|
return totalPlayCount * averageNewA3Rate;
|
|
}
|
|
|
|
function deriveTotalNewA3FromVideos(value: unknown): number | null {
|
|
if (!Array.isArray(value)) {
|
|
return null;
|
|
}
|
|
|
|
let total = 0;
|
|
let hasFiniteValue = false;
|
|
value.forEach((video) => {
|
|
if (!isRecord(video)) {
|
|
return;
|
|
}
|
|
|
|
const newA3 = readFiniteNumber(video.new_a3);
|
|
if (newA3 === null) {
|
|
return;
|
|
}
|
|
|
|
hasFiniteValue = true;
|
|
total += newA3;
|
|
});
|
|
|
|
return hasFiniteValue ? total : null;
|
|
}
|
|
|
|
function readNestedVideoCount(value: unknown): number | null {
|
|
return Array.isArray(value) ? value.length : null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function readFiniteNumber(value: unknown): number | null {
|
|
const number = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|