feat: refine audience profile csv columns

This commit is contained in:
2026-05-18 17:25:58 +08:00
parent 66bc49d498
commit 26ae3bb4b6
7 changed files with 229 additions and 171 deletions
+12 -4
View File
@@ -1,6 +1,7 @@
import type { MarketRecord } from "./types";
import type {
AudienceProfileDistributionItem,
AudienceProfileKind,
AudienceProfileResult,
AudienceProfileSuccess
} from "./audience-profile-types";
@@ -18,7 +19,6 @@ type FetchLike = (
interface AudienceProfileClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
linkType?: number;
timeoutMs?: number;
}
@@ -49,16 +49,24 @@ const GENDER_LABELS: Record<string, string> = {
const AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"];
const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"];
export const AUDIENCE_PROFILE_LINK_TYPES: Record<AudienceProfileKind, number> = {
audience: 3,
fans: 1,
longtimeFans: 4
};
export function createAudienceProfileClient(
options: AudienceProfileClientOptions = {}
) {
const baseUrl = options.baseUrl ?? resolveBaseUrl();
const fetchImpl = options.fetchImpl ?? defaultFetch;
const timeoutMs = options.timeoutMs ?? 8000;
const linkType = options.linkType ?? 1;
return {
async loadAudienceProfile(record: MarketRecord): Promise<AudienceProfileResult> {
async loadAudienceProfile(
record: MarketRecord,
linkType: number
): Promise<AudienceProfileResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -98,7 +106,7 @@ export function createAudienceProfileClient(
export function buildAudienceProfileUrl(
authorId: string,
baseUrl: string,
linkType = 1
linkType = 3
): string {
const url = new URL("/gw/api/data_sp/author_audience_distribution", baseUrl);
url.searchParams.set("o_author_id", authorId);
+68 -114
View File
@@ -1,11 +1,10 @@
import { escapeCsvCell } from "../../shared/csv";
import {
buildMarketCsvColumns,
type CsvColumn
} from "./csv-exporter";
import { buildMarketCsvColumns, type CsvColumn } from "./csv-exporter";
import type {
AudienceProfileDistributionItem,
AudienceProfileExportRow
AudienceProfileExportRow,
AudienceProfileKind,
AudienceProfileResult
} from "./audience-profile-types";
type AudienceProfileCsvColumn = {
@@ -13,44 +12,18 @@ type AudienceProfileCsvColumn = {
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 PROVINCE_LABELS = [
"北京",
"天津",
"河北",
"山西",
"内蒙古",
"辽宁",
"吉林",
"黑龙江",
"上海",
"江苏",
"浙江",
"安徽",
"福建",
"江西",
"山东",
"河南",
"湖北",
"湖南",
"广东",
"广西",
"海南",
"重庆",
"四川",
"贵州",
"云南",
"西藏",
"陕西",
"甘肃",
"青海",
"宁夏",
"新疆",
"香港",
"澳门",
"台湾"
];
const CITY_TIER_LABELS = [
"一线城市",
"新一线城市",
@@ -75,8 +48,8 @@ export function buildAudienceProfileCsv(
): string {
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
const csvColumns = [
...marketColumns.map(toAudienceProfileColumn),
...buildAudienceProfileColumns()
...marketColumns.map(toMarketColumn),
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
];
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = rows.map((row) =>
@@ -86,95 +59,76 @@ export function buildAudienceProfileCsv(
return [headerLine, ...rowLines].join("\n");
}
function toAudienceProfileColumn(
column: CsvColumn
): AudienceProfileCsvColumn {
function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn {
return {
header: column.header,
readValue: (row) => column.readValue(row.record)
};
}
function buildAudienceProfileColumns(): AudienceProfileCsvColumn[] {
return [
{
header: "画像抓取状态",
readValue: (row) => (row.profile.status === "success" ? "成功" : "失败")
},
{
header: "画像失败原因",
readValue: (row) =>
row.profile.status === "failed" ? row.profile.failureReason ?? "" : ""
},
...buildFixedDistributionColumns("连接用户", "gender", GENDER_LABELS),
...buildFixedDistributionColumns("连接用户", "age", AGE_LABELS),
...buildFixedDistributionColumns("省份", "province", PROVINCE_LABELS),
...buildRankedDistributionColumns("地域", "cityTop", 10),
...buildFixedDistributionColumns("城市等级", "cityTier", CITY_TIER_LABELS),
...buildRankedDistributionColumns("兴趣", "interest", 10),
...buildFixedDistributionColumns("八大人群", "crowd", CROWD_LABELS)
];
}
function buildFixedDistributionColumns(
prefix: string,
key: "age" | "cityTier" | "crowd" | "gender" | "province",
labels: string[]
): AudienceProfileCsvColumn[] {
return labels.map((label) => ({
header: `${prefix}-${label}占比`,
readValue: (row) => readDistributionValue(row, key, label)
}));
}
function buildRankedDistributionColumns(
prefix: string,
key: "cityTop" | "interest",
count: number
): AudienceProfileCsvColumn[] {
function buildProfileColumns(layout: {
includeGender: boolean;
kind: AudienceProfileKind;
label: string;
}): AudienceProfileCsvColumn[] {
const columns: AudienceProfileCsvColumn[] = [];
for (let index = 0; index < count; index += 1) {
if (layout.includeGender) {
columns.push(
{
header: `${prefix}TOP${index + 1}名称`,
readValue: (row) => readDistributionItem(row, key, index)?.label ?? ""
},
{
header: `${prefix}TOP${index + 1}占比`,
readValue: (row) => readDistributionItem(row, key, index)?.value ?? ""
}
...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(
row: AudienceProfileExportRow,
key: "age" | "cityTier" | "crowd" | "gender" | "province",
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender",
label: string
): string {
return readDistributionItems(row, key).find((item) => item.label === label)?.value ?? "";
if (profile.status !== "success") {
return "";
}
return (
readProfileDistributionItems(profile, key).find(
(candidate) => candidate.label === label
)?.value ?? ""
);
}
function readDistributionItem(
row: AudienceProfileExportRow,
key: "cityTop" | "interest",
index: number
): AudienceProfileDistributionItem | undefined {
return readDistributionItems(row, key)[index];
}
function readDistributionItems(
row: AudienceProfileExportRow,
key:
| "age"
| "cityTier"
| "cityTop"
| "crowd"
| "gender"
| "interest"
| "province"
function readProfileDistributionItems(
profile: AudienceProfileResult,
key: "age" | "cityTier" | "crowd" | "gender"
): AudienceProfileDistributionItem[] {
return row.profile.status === "success" ? row.profile[key] ?? [] : [];
return profile.status === "success" ? profile[key] ?? [] : [];
}
+12 -1
View File
@@ -1,5 +1,10 @@
import type { MarketRecord } from "./types";
export type AudienceProfileKind =
| "audience"
| "fans"
| "longtimeFans";
export interface AudienceProfileDistributionItem {
label: string;
value: string;
@@ -21,11 +26,17 @@ export interface AudienceProfileFailure {
status: "failed";
}
export interface AudienceProfileSet {
audience: AudienceProfileResult;
fans: AudienceProfileResult;
longtimeFans: AudienceProfileResult;
}
export type AudienceProfileResult =
| AudienceProfileSuccess
| AudienceProfileFailure;
export interface AudienceProfileExportRow {
profile: AudienceProfileResult;
profiles: AudienceProfileSet;
record: MarketRecord;
}
+47 -5
View File
@@ -1,6 +1,9 @@
import { buildMarketCsv } from "./csv-exporter";
import { buildAudienceProfileCsv } from "./audience-profile-csv";
import { createAudienceProfileClient } from "./audience-profile-client";
import {
AUDIENCE_PROFILE_LINK_TYPES,
createAudienceProfileClient
} from "./audience-profile-client";
import { promptForBatchName } from "./batch-name-dialog";
import { createBatchPayload, type BatchPayload } from "./batch-payload";
import {
@@ -31,6 +34,7 @@ import {
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
import type {
AudienceProfileExportRow,
AudienceProfileKind,
AudienceProfileResult
} from "./audience-profile-types";
import type {
@@ -52,7 +56,10 @@ export interface CreateMarketControllerOptions {
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
getAuthState?: () => Promise<AuthStateValue>;
loadAudienceProfile?: (record: MarketRecord) => Promise<AudienceProfileResult>;
loadAudienceProfile?: (
record: MarketRecord,
linkType: number
) => Promise<AudienceProfileResult>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
searchBackendMetrics?: (starIds: string[]) => Promise<
Array<BackendMetrics & { starId: string }>
@@ -92,6 +99,17 @@ export function createMarketController(options: CreateMarketControllerOptions) {
options.submitBatch ??
((payload: BatchPayload) =>
readBatchSubmitAck(sendRuntimeMessage, payload));
const audienceProfileTargets: Array<{
kind: AudienceProfileKind;
linkType: number;
}> = [
{ kind: "audience", linkType: AUDIENCE_PROFILE_LINK_TYPES.audience },
{ kind: "fans", linkType: AUDIENCE_PROFILE_LINK_TYPES.fans },
{
kind: "longtimeFans",
linkType: AUDIENCE_PROFILE_LINK_TYPES.longtimeFans
}
];
let activeProgressLabel = "导出中";
let shouldShowDetailedProgress = true;
const exportRangeController = createExportRangeController({
@@ -208,14 +226,18 @@ export function createMarketController(options: CreateMarketControllerOptions) {
toolbar,
`画像导出中 ${index + 1}/${selectedRecords.length}...`
);
const profile = await loadAudienceProfile(record);
const profiles = await loadAudienceProfileSet(record);
rows.push({
profile,
profiles,
record
});
}
if (rows.every((row) => row.profile.status === "failed")) {
if (
rows.every((row) =>
Object.values(row.profiles).every((profile) => profile.status === "failed")
)
) {
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
return;
}
@@ -637,6 +659,26 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return records.filter((record) => selectedAuthorIds.has(record.authorId));
}
async function loadAudienceProfileSet(
record: MarketRecord
): Promise<AudienceProfileExportRow["profiles"]> {
const profiles = {} as AudienceProfileExportRow["profiles"];
for (const { kind, linkType } of audienceProfileTargets) {
try {
profiles[kind] = await loadAudienceProfile(record, linkType);
} catch (error) {
profiles[kind] = {
failureReason:
error instanceof Error ? error.message : "request-failed",
status: "failed"
};
}
}
return profiles;
}
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();