304 lines
7.4 KiB
TypeScript
304 lines
7.4 KiB
TypeScript
import type { MarketRecord } from "./types";
|
|
import type {
|
|
AudienceProfileDistributionItem,
|
|
AudienceProfileKind,
|
|
AudienceProfileResult,
|
|
AudienceProfileSuccess
|
|
} from "./audience-profile-types";
|
|
|
|
interface FetchResponseLike {
|
|
json(): Promise<unknown>;
|
|
ok: boolean;
|
|
}
|
|
|
|
type FetchLike = (
|
|
input: string,
|
|
init?: RequestInit
|
|
) => Promise<FetchResponseLike>;
|
|
|
|
export type AudienceProfileRequestTarget =
|
|
| {
|
|
linkType: number;
|
|
source: "audienceDistribution";
|
|
}
|
|
| {
|
|
authorType: number;
|
|
source: "fansDistribution";
|
|
};
|
|
|
|
interface AudienceProfileClientOptions {
|
|
baseUrl?: string;
|
|
fetchImpl?: FetchLike;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
type DistributionSection =
|
|
| "age"
|
|
| "cityTier"
|
|
| "cityTop"
|
|
| "crowd"
|
|
| "gender"
|
|
| "interest"
|
|
| "province";
|
|
|
|
const SECTION_BY_DISPLAY: Array<[RegExp, DistributionSection]> = [
|
|
[/性别/, "gender"],
|
|
[/年龄/, "age"],
|
|
[/省份|全国省份/, "province"],
|
|
[/城市分布|地域/, "cityTop"],
|
|
[/城市等级/, "cityTier"],
|
|
[/兴趣/, "interest"],
|
|
[/八大人群/, "crowd"]
|
|
];
|
|
|
|
const GENDER_LABELS: Record<string, string> = {
|
|
female: "女性",
|
|
male: "男性"
|
|
};
|
|
|
|
const AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
|
const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"];
|
|
|
|
export const AUDIENCE_PROFILE_TARGETS: Record<
|
|
AudienceProfileKind,
|
|
AudienceProfileRequestTarget
|
|
> = {
|
|
audience: { linkType: 5, source: "audienceDistribution" },
|
|
fans: { authorType: 1, source: "fansDistribution" },
|
|
longtimeFans: { authorType: 5, source: "fansDistribution" }
|
|
};
|
|
|
|
export function createAudienceProfileClient(
|
|
options: AudienceProfileClientOptions = {}
|
|
) {
|
|
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
|
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
|
const timeoutMs = options.timeoutMs ?? 8000;
|
|
|
|
return {
|
|
async loadAudienceProfile(
|
|
record: MarketRecord,
|
|
target: AudienceProfileRequestTarget
|
|
): Promise<AudienceProfileResult> {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
|
|
try {
|
|
const response = await fetchImpl(
|
|
buildAudienceProfileUrl(record.authorId, baseUrl, target),
|
|
{
|
|
credentials: "include",
|
|
method: "GET",
|
|
signal: controller.signal
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
return {
|
|
failureReason: "request-failed",
|
|
status: "failed"
|
|
};
|
|
}
|
|
|
|
return mapAudienceProfileResponse(await response.json());
|
|
} catch (error) {
|
|
return {
|
|
failureReason:
|
|
error instanceof Error && error.name === "AbortError"
|
|
? "timeout"
|
|
: "request-failed",
|
|
status: "failed"
|
|
};
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export function buildAudienceProfileUrl(
|
|
authorId: string,
|
|
baseUrl: string,
|
|
target: AudienceProfileRequestTarget
|
|
): string {
|
|
const url = new URL(
|
|
target.source === "audienceDistribution"
|
|
? "/gw/api/data_sp/author_audience_distribution"
|
|
: "/gw/api/data_sp/get_author_fans_distribution",
|
|
baseUrl
|
|
);
|
|
url.searchParams.set("o_author_id", authorId);
|
|
url.searchParams.set("platform_source", "1");
|
|
if (target.source === "audienceDistribution") {
|
|
url.searchParams.set("platform_channel", "1");
|
|
url.searchParams.set("link_type", String(target.linkType));
|
|
} else {
|
|
url.searchParams.set("author_type", String(target.authorType));
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
export function mapAudienceProfileResponse(
|
|
payload: unknown
|
|
): AudienceProfileResult {
|
|
if (!isRecord(payload) || !Array.isArray(payload.distributions)) {
|
|
return {
|
|
failureReason: "bad-response",
|
|
status: "failed"
|
|
};
|
|
}
|
|
|
|
const profile: AudienceProfileSuccess = {
|
|
status: "success"
|
|
};
|
|
|
|
payload.distributions.forEach((section) => {
|
|
if (!isRecord(section)) {
|
|
return;
|
|
}
|
|
|
|
const display = readString(section.type_display);
|
|
const sectionName = resolveSection(display);
|
|
if (!sectionName || !Array.isArray(section.distribution_list)) {
|
|
return;
|
|
}
|
|
|
|
profile[sectionName] = normalizeDistributionItems(
|
|
section.distribution_list,
|
|
sectionName
|
|
);
|
|
});
|
|
|
|
if (Object.keys(profile).length === 1) {
|
|
return {
|
|
failureReason: "missing-profile",
|
|
status: "failed"
|
|
};
|
|
}
|
|
|
|
return profile;
|
|
}
|
|
|
|
function normalizeDistributionItems(
|
|
rawItems: unknown[],
|
|
sectionName: DistributionSection
|
|
): AudienceProfileDistributionItem[] {
|
|
const parsedItems = rawItems
|
|
.map((item) => {
|
|
if (!isRecord(item)) {
|
|
return null;
|
|
}
|
|
|
|
const key = readString(item.distribution_key);
|
|
const value = readNumber(item.distribution_value);
|
|
if (!key || value === null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
label: normalizeLabel(key, sectionName),
|
|
rawLabel: key,
|
|
value
|
|
};
|
|
})
|
|
.filter((item): item is { label: string; rawLabel: string; value: number } =>
|
|
Boolean(item)
|
|
);
|
|
|
|
const total = parsedItems.reduce((sum, item) => sum + item.value, 0);
|
|
if (total <= 0) {
|
|
return [];
|
|
}
|
|
|
|
return parsedItems
|
|
.sort((left, right) => compareDistributionItems(left, right, sectionName))
|
|
.map((item) => ({
|
|
label: item.label,
|
|
value: formatPercent(item.value / total)
|
|
}));
|
|
}
|
|
|
|
function compareDistributionItems(
|
|
left: { rawLabel: string; value: number },
|
|
right: { rawLabel: string; value: number },
|
|
sectionName: DistributionSection
|
|
): number {
|
|
if (sectionName === "age") {
|
|
return orderIndex(AGE_ORDER, left.rawLabel) - orderIndex(AGE_ORDER, right.rawLabel);
|
|
}
|
|
|
|
if (sectionName === "cityTier") {
|
|
return (
|
|
orderIndex(CITY_TIER_ORDER, left.rawLabel) -
|
|
orderIndex(CITY_TIER_ORDER, right.rawLabel)
|
|
);
|
|
}
|
|
|
|
return right.value - left.value;
|
|
}
|
|
|
|
function orderIndex(order: string[], value: string): number {
|
|
const index = order.indexOf(value);
|
|
return index === -1 ? order.length : index;
|
|
}
|
|
|
|
function normalizeLabel(label: string, sectionName: DistributionSection): string {
|
|
if (sectionName === "gender") {
|
|
return GENDER_LABELS[label] ?? label;
|
|
}
|
|
|
|
if (sectionName === "cityTier" && !label.endsWith("城市")) {
|
|
return `${label}城市`;
|
|
}
|
|
|
|
return label;
|
|
}
|
|
|
|
function resolveSection(display: string | null): DistributionSection | null {
|
|
if (!display) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
SECTION_BY_DISPLAY.find(([pattern]) => pattern.test(display))?.[1] ?? null
|
|
);
|
|
}
|
|
|
|
function formatPercent(value: number): string {
|
|
const percent = Math.round(value * 1000) / 10;
|
|
return `${Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(1)}%`;
|
|
}
|
|
|
|
function readString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
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 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;
|
|
}
|