358 lines
8.8 KiB
TypeScript
358 lines
8.8 KiB
TypeScript
import type {
|
|
SpreadInfoConfig,
|
|
SpreadInfoMetrics,
|
|
SpreadMetricFilterRule
|
|
} from "./types";
|
|
|
|
interface FetchResponseLike {
|
|
json(): Promise<unknown>;
|
|
ok: boolean;
|
|
}
|
|
|
|
type FetchLike = (
|
|
input: string,
|
|
init?: RequestInit
|
|
) => Promise<FetchResponseLike>;
|
|
|
|
interface SpreadInfoClientOptions {
|
|
baseUrl?: string;
|
|
configs?: SpreadInfoConfig[];
|
|
fetchImpl?: FetchLike;
|
|
timeoutMs?: number;
|
|
}
|
|
|
|
interface SpreadInfoMetricDefinition {
|
|
key: keyof MappedSpreadInfoResponse;
|
|
label: string;
|
|
}
|
|
|
|
export interface MappedSpreadInfoResponse {
|
|
averageCommentCount?: string;
|
|
averageDuration?: string;
|
|
averageLikeCount?: string;
|
|
averageShareCount?: string;
|
|
finishRate?: string;
|
|
interactionRate?: string;
|
|
playMedian?: string;
|
|
}
|
|
|
|
const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [
|
|
{
|
|
key: "finishRate",
|
|
label: "完播率"
|
|
},
|
|
{
|
|
key: "playMedian",
|
|
label: "播放量中位数"
|
|
},
|
|
{
|
|
key: "interactionRate",
|
|
label: "互动率"
|
|
},
|
|
{
|
|
key: "averageDuration",
|
|
label: "作品平均时长"
|
|
},
|
|
{
|
|
key: "averageCommentCount",
|
|
label: "作品平均评论数"
|
|
},
|
|
{
|
|
key: "averageLikeCount",
|
|
label: "作品平均点赞数"
|
|
},
|
|
{
|
|
key: "averageShareCount",
|
|
label: "作品平均转发数"
|
|
}
|
|
];
|
|
|
|
export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [
|
|
{
|
|
flowType: 0,
|
|
onlyAssign: false,
|
|
range: 2,
|
|
type: 1
|
|
},
|
|
{
|
|
flowType: 0,
|
|
onlyAssign: false,
|
|
range: 3,
|
|
type: 1
|
|
},
|
|
...buildXingtuVideoConfigs()
|
|
];
|
|
|
|
export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) {
|
|
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
|
const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS;
|
|
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
|
const timeoutMs = options.timeoutMs ?? 8000;
|
|
|
|
return {
|
|
async loadAuthorSpreadMetrics(authorId: string): Promise<SpreadInfoMetrics> {
|
|
const metrics: SpreadInfoMetrics = {};
|
|
|
|
for (const config of configs) {
|
|
const mappedResponse = await loadSpreadInfoFromUrl(
|
|
buildSpreadInfoUrl(authorId, config, baseUrl)
|
|
);
|
|
Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach(
|
|
([header, value]) => {
|
|
metrics[header] = value;
|
|
}
|
|
);
|
|
}
|
|
|
|
return metrics;
|
|
},
|
|
async loadAuthorSpreadMetricSnapshot(
|
|
authorId: string,
|
|
config: SpreadInfoConfig
|
|
): Promise<MappedSpreadInfoResponse> {
|
|
return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl));
|
|
}
|
|
};
|
|
|
|
async function loadSpreadInfoFromUrl(
|
|
url: string
|
|
): Promise<MappedSpreadInfoResponse> {
|
|
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 {};
|
|
}
|
|
|
|
return mapSpreadInfoResponse(await response.json());
|
|
} catch {
|
|
return {};
|
|
} finally {
|
|
clearTimeout(timeoutId);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function buildSpreadInfoUrl(
|
|
authorId: string,
|
|
config: SpreadInfoConfig,
|
|
baseUrl: string
|
|
): string {
|
|
const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl);
|
|
url.searchParams.set("o_author_id", authorId);
|
|
url.searchParams.set("platform_source", "1");
|
|
url.searchParams.set("platform_channel", "1");
|
|
url.searchParams.set("type", String(config.type));
|
|
url.searchParams.set("flow_type", String(config.flowType));
|
|
url.searchParams.set("only_assign", String(config.onlyAssign));
|
|
url.searchParams.set("range", String(config.range));
|
|
return url.toString();
|
|
}
|
|
|
|
export function buildSpreadInfoColumns(
|
|
configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS
|
|
): string[] {
|
|
return configs.flatMap((config) =>
|
|
SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric))
|
|
);
|
|
}
|
|
|
|
export function buildSpreadInfoMetricMap(
|
|
config: SpreadInfoConfig,
|
|
metrics: MappedSpreadInfoResponse
|
|
): SpreadInfoMetrics {
|
|
const values: SpreadInfoMetrics = {};
|
|
|
|
SPREAD_INFO_METRICS.forEach((metric) => {
|
|
const value = metrics[metric.key];
|
|
if (hasTextValue(value)) {
|
|
values[buildSpreadInfoColumnHeader(config, metric)] = value;
|
|
}
|
|
});
|
|
|
|
return values;
|
|
}
|
|
|
|
export function mapSpreadInfoResponse(
|
|
payload: unknown
|
|
): MappedSpreadInfoResponse {
|
|
const data = getPayloadData(payload);
|
|
if (!data) {
|
|
return {};
|
|
}
|
|
|
|
return {
|
|
averageCommentCount: readStringLike(data.comment_avg),
|
|
averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)),
|
|
averageLikeCount: readStringLike(data.like_avg),
|
|
averageShareCount: readStringLike(data.share_avg),
|
|
finishRate: formatBasisPointPercent(
|
|
readNumberLike(readNestedValue(data.play_over_rate, "value"))
|
|
),
|
|
interactionRate: formatBasisPointPercent(
|
|
readNumberLike(readNestedValue(data.interact_rate, "value"))
|
|
),
|
|
playMedian:
|
|
readStringLike(data.play_mid) ??
|
|
readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value"))
|
|
};
|
|
}
|
|
|
|
export function normalizeSpreadInfoConfig(
|
|
config: SpreadInfoConfig
|
|
): SpreadInfoConfig {
|
|
return config.type === 1
|
|
? { ...config, flowType: 0, onlyAssign: false }
|
|
: { ...config };
|
|
}
|
|
|
|
export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string {
|
|
const normalized = normalizeSpreadInfoConfig(config);
|
|
return [
|
|
normalized.type,
|
|
normalized.onlyAssign ? 1 : 0,
|
|
normalized.flowType,
|
|
normalized.range
|
|
].join(":");
|
|
}
|
|
|
|
export function matchesSpreadMetricRule(
|
|
metrics: MappedSpreadInfoResponse,
|
|
rule: SpreadMetricFilterRule
|
|
): boolean {
|
|
const numericValue = readDisplayNumber(metrics[rule.metric]);
|
|
return numericValue !== null && numericValue >= rule.threshold;
|
|
}
|
|
|
|
function buildSpreadInfoColumnHeader(
|
|
config: SpreadInfoConfig,
|
|
metric: SpreadInfoMetricDefinition
|
|
): string {
|
|
return ["内容数据", ...buildConfigPrefixParts(config), metric.label].join("-");
|
|
}
|
|
|
|
function buildConfigPrefixParts(config: SpreadInfoConfig): string[] {
|
|
const typeLabel = config.type === 1 ? "个人视频" : "星图视频";
|
|
const rangeLabel = config.range === 2 ? "近30天" : "近90天";
|
|
|
|
if (config.type === 1) {
|
|
return [typeLabel, rangeLabel];
|
|
}
|
|
|
|
return [
|
|
config.onlyAssign ? "只看指派" : "不限指派",
|
|
config.flowType === 1 ? "排除营销流量" : "不排除营销流量",
|
|
typeLabel,
|
|
rangeLabel
|
|
];
|
|
}
|
|
|
|
function buildXingtuVideoConfigs(): SpreadInfoConfig[] {
|
|
const configs: SpreadInfoConfig[] = [];
|
|
[false, true].forEach((onlyAssign) => {
|
|
([0, 1] as const).forEach((flowType) => {
|
|
([2, 3] as const).forEach((range) => {
|
|
configs.push({
|
|
flowType,
|
|
onlyAssign,
|
|
range,
|
|
type: 2
|
|
});
|
|
});
|
|
});
|
|
});
|
|
return configs;
|
|
}
|
|
|
|
function getPayloadData(payload: unknown): Record<string, unknown> | null {
|
|
if (!isRecord(payload)) {
|
|
return null;
|
|
}
|
|
|
|
return isRecord(payload.data) ? payload.data : payload;
|
|
}
|
|
|
|
function readNestedValue(value: unknown, key: string): unknown {
|
|
return isRecord(value) ? value[key] : undefined;
|
|
}
|
|
|
|
function readStringLike(value: unknown): string | undefined {
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
return String(value);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function readNumberLike(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "string" && value.trim().length > 0) {
|
|
const parsedValue = Number(value);
|
|
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function readDisplayNumber(value: string | undefined): number | null {
|
|
if (!hasTextValue(value)) {
|
|
return null;
|
|
}
|
|
|
|
const parsedValue = Number(value.replace(/[% ,]/g, ""));
|
|
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
}
|
|
|
|
function formatBasisPointPercent(value: number | null): string | undefined {
|
|
if (value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
return `${formatDecimal(value / 100)}%`;
|
|
}
|
|
|
|
function formatMillisecondsAsSeconds(value: number | null): string | undefined {
|
|
if (value === null) {
|
|
return undefined;
|
|
}
|
|
|
|
return formatDecimal(value / 100);
|
|
}
|
|
|
|
function formatDecimal(value: number): string {
|
|
return value.toFixed(2).replace(/\.?0+$/, "");
|
|
}
|
|
|
|
function resolveBaseUrl(): string {
|
|
if (typeof location !== "undefined" && location.origin) {
|
|
return location.origin;
|
|
}
|
|
|
|
return "https://www.xingtu.cn";
|
|
}
|
|
|
|
async function defaultFetch(input: string, init?: RequestInit) {
|
|
return fetch(input, init);
|
|
}
|
|
|
|
function hasTextValue(value: string | undefined): value is string {
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|