226 lines
6.3 KiB
TypeScript
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] ?? [] : [];
|
|
}
|