star-chart-search-enhancer/src/content/market/business-ability-client.ts

199 lines
4.9 KiB
TypeScript

import type { MarketRecord } from "./types";
import type {
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityResult,
BusinessAbilitySuccess
} from "./audience-profile-types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface BusinessAbilityClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
timeoutMs?: number;
}
export function createBusinessAbilityClient(
options: BusinessAbilityClientOptions = {}
) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
return {
async loadBusinessAbility(record: MarketRecord): Promise<BusinessAbilityResult> {
const estimates = await loadJson(
buildBusinessAbilityEstimateUrl(record.authorId, baseUrl)
);
if (!estimates.ok) {
return {
failureReason: estimates.failureReason,
status: "failed"
};
}
return {
estimates: mapBusinessAbilityEstimateResponse(estimates.payload),
status: "success"
} satisfies BusinessAbilitySuccess;
}
};
async function loadJson(url: string): Promise<
| { ok: true; payload: unknown }
| { failureReason: string; ok: false }
> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetchImpl(url, {
credentials: "include",
method: "GET",
signal: controller.signal
});
if (!response.ok) {
return { failureReason: "request-failed", ok: false };
}
return { ok: true, payload: await response.json() };
} catch (error) {
return {
failureReason:
error instanceof Error && error.name === "AbortError"
? "timeout"
: "request-failed",
ok: false
};
} finally {
clearTimeout(timeoutId);
}
}
}
export function buildBusinessAbilityEstimateUrl(
authorId: string,
baseUrl: string
): string {
const url = new URL(
"/gw/api/aggregator/get_author_commerce_spread_info",
baseUrl
);
url.searchParams.set("o_author_id", authorId);
return url.toString();
}
export function mapBusinessAbilityEstimateResponse(
payload: unknown
): Partial<Record<BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics>> {
const data = getPayloadData(payload);
const expectedPlay = formatWan(readNumber(data?.vv));
const hotRate = formatDecimalRate(readNumber(data?.platform_hot_rate));
return {
oneToTwenty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_1_20), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_1_20), 1),
expectedPlay,
hotRate
},
overSixty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_60), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_60), 1),
expectedPlay,
hotRate
},
twentyToSixty: {
expectedCpe: formatDecimal(readNumber(data?.cpe_20_60), 1),
expectedCpm: formatFixedDecimal(readNumber(data?.cpm_20_60), 1),
expectedPlay,
hotRate
}
};
}
function formatDecimalRate(value: number | null): string {
if (value === null) {
return "缺失";
}
return `${formatDecimal(value * 100, 0)}%`;
}
function formatWan(value: number | null): string {
if (value === null) {
return "";
}
if (Math.abs(value) >= 10000) {
return `${formatDecimal(value / 10000, 1)}w`;
}
return formatDecimal(value, 0);
}
function formatDecimal(value: number | null, digits: number): string {
if (value === null || !Number.isFinite(value)) {
return "";
}
const fixed = value.toFixed(digits);
return fixed.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1");
}
function formatFixedDecimal(value: number | null, digits: number): string {
if (value === null || !Number.isFinite(value)) {
return "";
}
return value.toFixed(digits);
}
function readNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && value.trim()) {
const numericValue = Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
return null;
}
function getPayloadData(payload: unknown): Record<string, unknown> | null {
if (!isRecord(payload)) {
return null;
}
return isRecord(payload.data) ? payload.data : payload;
}
function resolveBaseUrl(): string {
if (typeof location !== "undefined" && location.origin) {
return location.origin;
}
return "https://xingtu.cn";
}
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;
}