import { escapeCsvCell } from "../../shared/csv"; import { buildMarketCsvColumns, listBackendMetricCsvHeaders, listRateCsvHeaders, 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; }; export interface AudienceProfileCsvOptions { selectedHeaders?: string[]; } export type AudienceProfileCsvFieldGroup = { headers: string[]; label: 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_VIDEO_SECTION_LABEL = "内容数据"; const BUSINESS_ESTIMATE_SECTION_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[], options: AudienceProfileCsvOptions = {} ): string { const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); const csvColumns = filterAudienceProfileCsvColumns([ ...marketColumns.map(toMarketColumn), ...buildBusinessAbilityColumns(), ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) ], options.selectedHeaders); 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"); } export function listAudienceProfileCsvHeaders( rows: AudienceProfileExportRow[] = [] ): string[] { const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); return [ ...marketColumns.map((column) => column.header), ...buildBusinessAbilityColumns().map((column) => column.header), ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)).map( (column) => column.header ) ]; } export function listAudienceProfileSelectableFieldGroups(): AudienceProfileCsvFieldGroup[] { return [ { headers: listRateCsvHeaders(), label: "看后搜率" }, { headers: listBackendMetricCsvHeaders(), label: "秒思api数据" }, { headers: buildBusinessVideoColumns().map((column) => column.header), label: "内容数据" }, { headers: buildBusinessEstimateColumns().map((column) => column.header), label: "效果预估" }, ...PROFILE_LAYOUTS.map((layout) => ({ headers: buildProfileColumns(layout).map((column) => column.header), label: layout.label })) ]; } function filterAudienceProfileCsvColumns( columns: AudienceProfileCsvColumn[], selectedHeaders: string[] | undefined ): AudienceProfileCsvColumn[] { if (!selectedHeaders) { return columns; } const selectableHeaderSet = new Set(listAudienceProfileSelectableHeaders()); const selectedHeaderSet = new Set(selectedHeaders); return columns.filter( (column) => !selectableHeaderSet.has(column.header) || selectedHeaderSet.has(column.header) ); } function listAudienceProfileSelectableHeaders(): string[] { return listAudienceProfileSelectableFieldGroups().flatMap( (group) => group.headers ); } function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] { return [...buildBusinessVideoColumns(), ...buildBusinessEstimateColumns()]; } function buildBusinessVideoColumns(): AudienceProfileCsvColumn[] { return [ ...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) => BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({ header: `${BUSINESS_VIDEO_SECTION_LABEL}-${videoLayout.label}-${metricLayout.label}`, readValue: (row: AudienceProfileExportRow) => readBusinessVideoValue(row, videoLayout.key, metricLayout.key) })) ) ]; } function buildBusinessEstimateColumns(): AudienceProfileCsvColumn[] { return [ ...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) => BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({ header: `${BUSINESS_ESTIMATE_SECTION_LABEL}-${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] ?? [] : []; }