199 lines
4.9 KiB
TypeScript
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;
|
|
}
|