star-chart-search-enhancer/src/content/market/audience-profile-csv.ts

226 lines
6.3 KiB
TypeScript

import { escapeCsvCell } from "../../shared/csv";
import { buildMarketCsvColumns, type CsvColumn } from "./csv-exporter";
import type {
AudienceProfileDistributionItem,
AudienceProfileExportRow,
AudienceProfileKind,
AudienceProfileResult,
BusinessAbilityDurationKind,
BusinessAbilityEstimateMetrics,
BusinessAbilityVideoKind,
BusinessAbilityVideoMetrics
} from "./audience-profile-types";
type AudienceProfileCsvColumn = {
header: string;
readValue: (row: AudienceProfileExportRow) => string;
};
const PROFILE_LAYOUTS: Array<{
includeGender: boolean;
kind: AudienceProfileKind;
label: string;
}> = [
{ includeGender: true, kind: "audience", label: "观众画像" },
{ includeGender: true, kind: "fans", label: "粉丝画像" },
{ includeGender: false, kind: "longtimeFans", label: "铁粉画像" }
];
const GENDER_LABELS = ["男性", "女性"];
const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"];
const CITY_TIER_LABELS = [
"一线城市",
"新一线城市",
"二线城市",
"三线城市",
"四线城市",
"五线城市"
];
const CROWD_LABELS = [
"精致妈妈",
"都市银发",
"新锐白领",
"资深中产",
"都市蓝领",
"Z世代",
"小镇中老年",
"小镇青年"
];
const BUSINESS_VIDEO_LAYOUTS: Array<{
key: BusinessAbilityVideoKind;
label: string;
}> = [
{ key: "personalVideo", label: "个人视频" },
{ key: "xingtuVideo", label: "星图视频" }
];
const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{
key: keyof BusinessAbilityVideoMetrics;
label: string;
}> = [
{ key: "medianPlay", label: "播放量中位数" },
{ key: "finishRate", label: "完播率" },
{ key: "interactionRate", label: "互动率" },
{ key: "publishedItems", label: "发布作品" },
{ key: "averageDuration", label: "平均时长" },
{ key: "averageLike", label: "平均点赞" },
{ key: "averageComment", label: "平均评论" },
{ key: "averageShare", label: "平均转发" }
];
const BUSINESS_ESTIMATE_LAYOUTS: Array<{
key: BusinessAbilityDurationKind;
label: string;
}> = [
{ key: "oneToTwenty", label: "1-20s视频" },
{ key: "twentyToSixty", label: "20-60s视频" },
{ key: "overSixty", label: "60s以上视频" }
];
const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{
key: keyof BusinessAbilityEstimateMetrics;
label: string;
}> = [
{ key: "expectedCpm", label: "预期CPM" },
{ key: "expectedCpe", label: "预期CPE" },
{ key: "expectedPlay", label: "预期播放量" },
{ key: "hotRate", label: "爆文率" }
];
export function buildAudienceProfileCsv(
rows: AudienceProfileExportRow[]
): string {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
const csvColumns = [
...marketColumns.map(toMarketColumn),
...buildBusinessAbilityColumns(),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
];
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = rows.map((row) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(row))).join(",")
);
return [headerLine, ...rowLines].join("\n");
}
function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] {
return [
...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) =>
BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({
header: `商业能力-${videoLayout.label}-${metricLayout.label}`,
readValue: (row: AudienceProfileExportRow) =>
readBusinessVideoValue(row, videoLayout.key, metricLayout.key)
}))
),
...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) =>
BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({
header: `商业能力-${durationLayout.label}-${metricLayout.label}`,
readValue: (row: AudienceProfileExportRow) =>
readBusinessEstimateValue(row, durationLayout.key, metricLayout.key)
}))
)
];
}
function readBusinessVideoValue(
row: AudienceProfileExportRow,
videoKey: BusinessAbilityVideoKind,
metricKey: keyof BusinessAbilityVideoMetrics
): string {
const businessAbility = row.businessAbility;
if (!businessAbility || businessAbility.status !== "success") {
return "";
}
return businessAbility.videos[videoKey]?.[metricKey] ?? "";
}
function readBusinessEstimateValue(
row: AudienceProfileExportRow,
durationKey: BusinessAbilityDurationKind,
metricKey: keyof BusinessAbilityEstimateMetrics
): string {
const businessAbility = row.businessAbility;
if (!businessAbility || businessAbility.status !== "success") {
return "";
}
return businessAbility.estimates[durationKey]?.[metricKey] ?? "";
}
function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn {
return {
header: column.header,
readValue: (row) => column.readValue(row.record)
};
}
function buildProfileColumns(layout: {
includeGender: boolean;
kind: AudienceProfileKind;
label: string;
}): AudienceProfileCsvColumn[] {
const columns: AudienceProfileCsvColumn[] = [];
if (layout.includeGender) {
columns.push(
...buildFixedDistributionColumns(
layout.label,
layout.kind,
"gender",
GENDER_LABELS
)
);
}
columns.push(
...buildFixedDistributionColumns(layout.label, layout.kind, "age", AGE_LABELS),
...buildFixedDistributionColumns(
layout.label,
layout.kind,
"cityTier",
CITY_TIER_LABELS
),
...buildFixedDistributionColumns(layout.label, layout.kind, "crowd", CROWD_LABELS)
);
return columns;
}
function buildFixedDistributionColumns(
prefix: string,
kind: AudienceProfileKind,
key: "age" | "cityTier" | "crowd" | "gender",
labels: string[]
): AudienceProfileCsvColumn[] {
return labels.map((label) => ({
header: `${prefix}-${label}占比`,
readValue: (row) => readDistributionValue(row.profiles[kind], key, label)
}));
}
function readDistributionValue(
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender",
label: string
): string {
if (profile.status !== "success") {
return "";
}
return (
readProfileDistributionItems(profile, key).find(
(candidate) => candidate.label === label
)?.value ?? "0%"
);
}
function readProfileDistributionItems(
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender"
): AudienceProfileDistributionItem[] {
return profile.status === "success" ? profile[key] ?? [] : [];
}