feat: refine audience profile csv columns
This commit is contained in:
parent
66bc49d498
commit
26ae3bb4b6
@ -1,6 +1,7 @@
|
|||||||
import type { MarketRecord } from "./types";
|
import type { MarketRecord } from "./types";
|
||||||
import type {
|
import type {
|
||||||
AudienceProfileDistributionItem,
|
AudienceProfileDistributionItem,
|
||||||
|
AudienceProfileKind,
|
||||||
AudienceProfileResult,
|
AudienceProfileResult,
|
||||||
AudienceProfileSuccess
|
AudienceProfileSuccess
|
||||||
} from "./audience-profile-types";
|
} from "./audience-profile-types";
|
||||||
@ -18,7 +19,6 @@ type FetchLike = (
|
|||||||
interface AudienceProfileClientOptions {
|
interface AudienceProfileClientOptions {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
fetchImpl?: FetchLike;
|
fetchImpl?: FetchLike;
|
||||||
linkType?: number;
|
|
||||||
timeoutMs?: 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 AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
||||||
const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"];
|
const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"];
|
||||||
|
|
||||||
|
export const AUDIENCE_PROFILE_LINK_TYPES: Record<AudienceProfileKind, number> = {
|
||||||
|
audience: 3,
|
||||||
|
fans: 1,
|
||||||
|
longtimeFans: 4
|
||||||
|
};
|
||||||
|
|
||||||
export function createAudienceProfileClient(
|
export function createAudienceProfileClient(
|
||||||
options: AudienceProfileClientOptions = {}
|
options: AudienceProfileClientOptions = {}
|
||||||
) {
|
) {
|
||||||
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
const baseUrl = options.baseUrl ?? resolveBaseUrl();
|
||||||
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
const fetchImpl = options.fetchImpl ?? defaultFetch;
|
||||||
const timeoutMs = options.timeoutMs ?? 8000;
|
const timeoutMs = options.timeoutMs ?? 8000;
|
||||||
const linkType = options.linkType ?? 1;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async loadAudienceProfile(record: MarketRecord): Promise<AudienceProfileResult> {
|
async loadAudienceProfile(
|
||||||
|
record: MarketRecord,
|
||||||
|
linkType: number
|
||||||
|
): Promise<AudienceProfileResult> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
@ -98,7 +106,7 @@ export function createAudienceProfileClient(
|
|||||||
export function buildAudienceProfileUrl(
|
export function buildAudienceProfileUrl(
|
||||||
authorId: string,
|
authorId: string,
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
linkType = 1
|
linkType = 3
|
||||||
): string {
|
): string {
|
||||||
const url = new URL("/gw/api/data_sp/author_audience_distribution", baseUrl);
|
const url = new URL("/gw/api/data_sp/author_audience_distribution", baseUrl);
|
||||||
url.searchParams.set("o_author_id", authorId);
|
url.searchParams.set("o_author_id", authorId);
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
import { escapeCsvCell } from "../../shared/csv";
|
import { escapeCsvCell } from "../../shared/csv";
|
||||||
import {
|
import { buildMarketCsvColumns, type CsvColumn } from "./csv-exporter";
|
||||||
buildMarketCsvColumns,
|
|
||||||
type CsvColumn
|
|
||||||
} from "./csv-exporter";
|
|
||||||
import type {
|
import type {
|
||||||
AudienceProfileDistributionItem,
|
AudienceProfileDistributionItem,
|
||||||
AudienceProfileExportRow
|
AudienceProfileExportRow,
|
||||||
|
AudienceProfileKind,
|
||||||
|
AudienceProfileResult
|
||||||
} from "./audience-profile-types";
|
} from "./audience-profile-types";
|
||||||
|
|
||||||
type AudienceProfileCsvColumn = {
|
type AudienceProfileCsvColumn = {
|
||||||
@ -13,44 +12,18 @@ type AudienceProfileCsvColumn = {
|
|||||||
readValue: (row: AudienceProfileExportRow) => 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 GENDER_LABELS = ["男性", "女性"];
|
||||||
const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"];
|
||||||
const PROVINCE_LABELS = [
|
|
||||||
"北京",
|
|
||||||
"天津",
|
|
||||||
"河北",
|
|
||||||
"山西",
|
|
||||||
"内蒙古",
|
|
||||||
"辽宁",
|
|
||||||
"吉林",
|
|
||||||
"黑龙江",
|
|
||||||
"上海",
|
|
||||||
"江苏",
|
|
||||||
"浙江",
|
|
||||||
"安徽",
|
|
||||||
"福建",
|
|
||||||
"江西",
|
|
||||||
"山东",
|
|
||||||
"河南",
|
|
||||||
"湖北",
|
|
||||||
"湖南",
|
|
||||||
"广东",
|
|
||||||
"广西",
|
|
||||||
"海南",
|
|
||||||
"重庆",
|
|
||||||
"四川",
|
|
||||||
"贵州",
|
|
||||||
"云南",
|
|
||||||
"西藏",
|
|
||||||
"陕西",
|
|
||||||
"甘肃",
|
|
||||||
"青海",
|
|
||||||
"宁夏",
|
|
||||||
"新疆",
|
|
||||||
"香港",
|
|
||||||
"澳门",
|
|
||||||
"台湾"
|
|
||||||
];
|
|
||||||
const CITY_TIER_LABELS = [
|
const CITY_TIER_LABELS = [
|
||||||
"一线城市",
|
"一线城市",
|
||||||
"新一线城市",
|
"新一线城市",
|
||||||
@ -75,8 +48,8 @@ export function buildAudienceProfileCsv(
|
|||||||
): string {
|
): string {
|
||||||
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record));
|
||||||
const csvColumns = [
|
const csvColumns = [
|
||||||
...marketColumns.map(toAudienceProfileColumn),
|
...marketColumns.map(toMarketColumn),
|
||||||
...buildAudienceProfileColumns()
|
...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout))
|
||||||
];
|
];
|
||||||
const headerLine = csvColumns.map((column) => column.header).join(",");
|
const headerLine = csvColumns.map((column) => column.header).join(",");
|
||||||
const rowLines = rows.map((row) =>
|
const rowLines = rows.map((row) =>
|
||||||
@ -86,95 +59,76 @@ export function buildAudienceProfileCsv(
|
|||||||
return [headerLine, ...rowLines].join("\n");
|
return [headerLine, ...rowLines].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAudienceProfileColumn(
|
function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn {
|
||||||
column: CsvColumn
|
|
||||||
): AudienceProfileCsvColumn {
|
|
||||||
return {
|
return {
|
||||||
header: column.header,
|
header: column.header,
|
||||||
readValue: (row) => column.readValue(row.record)
|
readValue: (row) => column.readValue(row.record)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAudienceProfileColumns(): AudienceProfileCsvColumn[] {
|
function buildProfileColumns(layout: {
|
||||||
return [
|
includeGender: boolean;
|
||||||
{
|
kind: AudienceProfileKind;
|
||||||
header: "画像抓取状态",
|
label: string;
|
||||||
readValue: (row) => (row.profile.status === "success" ? "成功" : "失败")
|
}): AudienceProfileCsvColumn[] {
|
||||||
},
|
|
||||||
{
|
|
||||||
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[] {
|
|
||||||
const columns: AudienceProfileCsvColumn[] = [];
|
const columns: AudienceProfileCsvColumn[] = [];
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
|
if (layout.includeGender) {
|
||||||
columns.push(
|
columns.push(
|
||||||
{
|
...buildFixedDistributionColumns(
|
||||||
header: `${prefix}TOP${index + 1}名称`,
|
layout.label,
|
||||||
readValue: (row) => readDistributionItem(row, key, index)?.label ?? ""
|
layout.kind,
|
||||||
},
|
"gender",
|
||||||
{
|
GENDER_LABELS
|
||||||
header: `${prefix}TOP${index + 1}占比`,
|
)
|
||||||
readValue: (row) => readDistributionItem(row, key, index)?.value ?? ""
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
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(
|
function readDistributionValue(
|
||||||
row: AudienceProfileExportRow,
|
profile: AudienceProfileResult,
|
||||||
key: "age" | "cityTier" | "crowd" | "gender" | "province",
|
key: "age" | "cityTier" | "crowd" | "gender",
|
||||||
label: string
|
label: string
|
||||||
): 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(
|
function readProfileDistributionItems(
|
||||||
row: AudienceProfileExportRow,
|
profile: AudienceProfileResult,
|
||||||
key: "cityTop" | "interest",
|
key: "age" | "cityTier" | "crowd" | "gender"
|
||||||
index: number
|
|
||||||
): AudienceProfileDistributionItem | undefined {
|
|
||||||
return readDistributionItems(row, key)[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDistributionItems(
|
|
||||||
row: AudienceProfileExportRow,
|
|
||||||
key:
|
|
||||||
| "age"
|
|
||||||
| "cityTier"
|
|
||||||
| "cityTop"
|
|
||||||
| "crowd"
|
|
||||||
| "gender"
|
|
||||||
| "interest"
|
|
||||||
| "province"
|
|
||||||
): AudienceProfileDistributionItem[] {
|
): AudienceProfileDistributionItem[] {
|
||||||
return row.profile.status === "success" ? row.profile[key] ?? [] : [];
|
return profile.status === "success" ? profile[key] ?? [] : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import type { MarketRecord } from "./types";
|
import type { MarketRecord } from "./types";
|
||||||
|
|
||||||
|
export type AudienceProfileKind =
|
||||||
|
| "audience"
|
||||||
|
| "fans"
|
||||||
|
| "longtimeFans";
|
||||||
|
|
||||||
export interface AudienceProfileDistributionItem {
|
export interface AudienceProfileDistributionItem {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@ -21,11 +26,17 @@ export interface AudienceProfileFailure {
|
|||||||
status: "failed";
|
status: "failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AudienceProfileSet {
|
||||||
|
audience: AudienceProfileResult;
|
||||||
|
fans: AudienceProfileResult;
|
||||||
|
longtimeFans: AudienceProfileResult;
|
||||||
|
}
|
||||||
|
|
||||||
export type AudienceProfileResult =
|
export type AudienceProfileResult =
|
||||||
| AudienceProfileSuccess
|
| AudienceProfileSuccess
|
||||||
| AudienceProfileFailure;
|
| AudienceProfileFailure;
|
||||||
|
|
||||||
export interface AudienceProfileExportRow {
|
export interface AudienceProfileExportRow {
|
||||||
profile: AudienceProfileResult;
|
profiles: AudienceProfileSet;
|
||||||
record: MarketRecord;
|
record: MarketRecord;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { buildMarketCsv } from "./csv-exporter";
|
import { buildMarketCsv } from "./csv-exporter";
|
||||||
import { buildAudienceProfileCsv } from "./audience-profile-csv";
|
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 { promptForBatchName } from "./batch-name-dialog";
|
||||||
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||||
import {
|
import {
|
||||||
@ -31,6 +34,7 @@ import {
|
|||||||
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
|
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
|
||||||
import type {
|
import type {
|
||||||
AudienceProfileExportRow,
|
AudienceProfileExportRow,
|
||||||
|
AudienceProfileKind,
|
||||||
AudienceProfileResult
|
AudienceProfileResult
|
||||||
} from "./audience-profile-types";
|
} from "./audience-profile-types";
|
||||||
import type {
|
import type {
|
||||||
@ -52,7 +56,10 @@ export interface CreateMarketControllerOptions {
|
|||||||
buildCsv?: (records: MarketRecord[]) => string;
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
document: Document;
|
document: Document;
|
||||||
getAuthState?: () => Promise<AuthStateValue>;
|
getAuthState?: () => Promise<AuthStateValue>;
|
||||||
loadAudienceProfile?: (record: MarketRecord) => Promise<AudienceProfileResult>;
|
loadAudienceProfile?: (
|
||||||
|
record: MarketRecord,
|
||||||
|
linkType: number
|
||||||
|
) => Promise<AudienceProfileResult>;
|
||||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||||
searchBackendMetrics?: (starIds: string[]) => Promise<
|
searchBackendMetrics?: (starIds: string[]) => Promise<
|
||||||
Array<BackendMetrics & { starId: string }>
|
Array<BackendMetrics & { starId: string }>
|
||||||
@ -92,6 +99,17 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
options.submitBatch ??
|
options.submitBatch ??
|
||||||
((payload: BatchPayload) =>
|
((payload: BatchPayload) =>
|
||||||
readBatchSubmitAck(sendRuntimeMessage, payload));
|
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 activeProgressLabel = "导出中";
|
||||||
let shouldShowDetailedProgress = true;
|
let shouldShowDetailedProgress = true;
|
||||||
const exportRangeController = createExportRangeController({
|
const exportRangeController = createExportRangeController({
|
||||||
@ -208,14 +226,18 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
toolbar,
|
toolbar,
|
||||||
`画像导出中 ${index + 1}/${selectedRecords.length}...`
|
`画像导出中 ${index + 1}/${selectedRecords.length}...`
|
||||||
);
|
);
|
||||||
const profile = await loadAudienceProfile(record);
|
const profiles = await loadAudienceProfileSet(record);
|
||||||
rows.push({
|
rows.push({
|
||||||
profile,
|
profiles,
|
||||||
record
|
record
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.every((row) => row.profile.status === "failed")) {
|
if (
|
||||||
|
rows.every((row) =>
|
||||||
|
Object.values(row.profiles).every((profile) => profile.status === "failed")
|
||||||
|
)
|
||||||
|
) {
|
||||||
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
|
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -637,6 +659,26 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
return records.filter((record) => selectedAuthorIds.has(record.authorId));
|
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> {
|
async function prepareCurrentPageForExport(): Promise<void> {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
await harvestCurrentPageForExport();
|
await harvestCurrentPageForExport();
|
||||||
|
|||||||
@ -21,10 +21,10 @@ describe("audience-profile-client", () => {
|
|||||||
authorId: "7294473194298146854",
|
authorId: "7294473194298146854",
|
||||||
authorName: "奇奇de海洋",
|
authorName: "奇奇de海洋",
|
||||||
status: "success"
|
status: "success"
|
||||||
});
|
}, 3);
|
||||||
|
|
||||||
expect(fetchImpl).toHaveBeenCalledWith(
|
expect(fetchImpl).toHaveBeenCalledWith(
|
||||||
"https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=1",
|
"https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=3",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
method: "GET"
|
method: "GET"
|
||||||
|
|||||||
@ -4,22 +4,37 @@ import { buildAudienceProfileCsv } from "../src/content/market/audience-profile-
|
|||||||
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
|
import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types";
|
||||||
|
|
||||||
describe("audience-profile-csv", () => {
|
describe("audience-profile-csv", () => {
|
||||||
test("appends structured audience profile columns after the market export columns", () => {
|
test("exports only requested profile distribution columns", () => {
|
||||||
const csv = buildAudienceProfileCsv([
|
const csv = buildAudienceProfileCsv([
|
||||||
{
|
{
|
||||||
profile: {
|
profiles: {
|
||||||
|
audience: {
|
||||||
age: [{ label: "31-40", value: "50%" }],
|
age: [{ label: "31-40", value: "50%" }],
|
||||||
cityTier: [{ label: "一线城市", value: "100%" }],
|
cityTier: [{ label: "一线城市", value: "100%" }],
|
||||||
cityTop: [{ label: "广州", value: "30.4%" }],
|
|
||||||
crowd: [{ label: "都市蓝领", value: "100%" }],
|
crowd: [{ label: "都市蓝领", value: "100%" }],
|
||||||
gender: [
|
gender: [
|
||||||
{ label: "男性", value: "71.7%" },
|
{ label: "男性", value: "71.7%" },
|
||||||
{ label: "女性", value: "28.3%" }
|
{ label: "女性", value: "28.3%" }
|
||||||
],
|
],
|
||||||
interest: [{ label: "随拍", value: "100%" }],
|
|
||||||
province: [{ label: "广东", value: "60%" }],
|
|
||||||
status: "success"
|
status: "success"
|
||||||
},
|
},
|
||||||
|
fans: {
|
||||||
|
age: [{ label: "31-40", value: "40%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "80%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "60%" }],
|
||||||
|
gender: [
|
||||||
|
{ label: "男性", value: "60%" },
|
||||||
|
{ label: "女性", value: "40%" }
|
||||||
|
],
|
||||||
|
status: "success"
|
||||||
|
},
|
||||||
|
longtimeFans: {
|
||||||
|
age: [{ label: "31-40", value: "30%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "70%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "50%" }],
|
||||||
|
status: "success"
|
||||||
|
}
|
||||||
|
},
|
||||||
record: {
|
record: {
|
||||||
authorId: "123",
|
authorId: "123",
|
||||||
authorName: "达人 A",
|
authorName: "达人 A",
|
||||||
@ -29,44 +44,55 @@ describe("audience-profile-csv", () => {
|
|||||||
},
|
},
|
||||||
status: "success"
|
status: "success"
|
||||||
}
|
}
|
||||||
}
|
} satisfies AudienceProfileExportRow
|
||||||
] satisfies AudienceProfileExportRow[]);
|
]);
|
||||||
|
|
||||||
const [headerLine, rowLine] = csv.split("\n");
|
const [headerLine, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
expect(headerLine).toContain("达人信息,连接用户数");
|
expect(headerLine).toContain("达人信息,连接用户数");
|
||||||
expect(headerLine).toContain("画像抓取状态");
|
expect(headerLine).not.toContain("抓取状态");
|
||||||
expect(headerLine).toContain("连接用户-男性占比");
|
expect(headerLine).not.toContain("失败原因");
|
||||||
expect(headerLine).toContain("连接用户-31-40占比");
|
expect(headerLine).toContain("观众画像-男性占比");
|
||||||
expect(headerLine).toContain("省份-广东占比");
|
expect(headerLine).toContain("粉丝画像-女性占比");
|
||||||
expect(headerLine).toContain("地域TOP1名称,地域TOP1占比");
|
expect(headerLine).not.toContain("铁粉画像-男性占比");
|
||||||
expect(headerLine).toContain("城市等级-一线城市占比");
|
expect(headerLine).toContain("观众画像-31-40占比");
|
||||||
expect(headerLine).toContain("兴趣TOP1名称,兴趣TOP1占比");
|
expect(headerLine).toContain("粉丝画像-一线城市占比");
|
||||||
expect(headerLine).toContain("八大人群-都市蓝领占比");
|
expect(headerLine).toContain("铁粉画像-都市蓝领占比");
|
||||||
expect(rowLine).toContain("成功");
|
expect(headerLine).not.toContain("省份");
|
||||||
|
expect(headerLine).not.toContain("地域TOP");
|
||||||
|
expect(headerLine).not.toContain("兴趣TOP");
|
||||||
expect(rowLine).toContain("71.7%");
|
expect(rowLine).toContain("71.7%");
|
||||||
expect(rowLine).toContain("广州,30.4%");
|
expect(rowLine).toContain("60%");
|
||||||
expect(rowLine).toContain("随拍,100%");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps failed profile rows and marks their failure reason", () => {
|
test("leaves distribution cells empty when profile loading fails", () => {
|
||||||
const csv = buildAudienceProfileCsv([
|
const csv = buildAudienceProfileCsv([
|
||||||
{
|
{
|
||||||
profile: {
|
profiles: {
|
||||||
|
audience: {
|
||||||
failureReason: "request-failed",
|
failureReason: "request-failed",
|
||||||
status: "failed"
|
status: "failed"
|
||||||
},
|
},
|
||||||
|
fans: {
|
||||||
|
failureReason: "timeout",
|
||||||
|
status: "failed"
|
||||||
|
},
|
||||||
|
longtimeFans: {
|
||||||
|
status: "failed"
|
||||||
|
}
|
||||||
|
},
|
||||||
record: {
|
record: {
|
||||||
authorId: "123",
|
authorId: "123",
|
||||||
authorName: "达人 A",
|
authorName: "达人 A",
|
||||||
status: "success"
|
status: "success"
|
||||||
}
|
}
|
||||||
}
|
} satisfies AudienceProfileExportRow
|
||||||
] satisfies AudienceProfileExportRow[]);
|
]);
|
||||||
|
|
||||||
const [, rowLine] = csv.split("\n");
|
const [, rowLine] = csv.split("\n");
|
||||||
|
|
||||||
expect(rowLine).toContain("失败");
|
expect(rowLine).not.toContain("失败");
|
||||||
expect(rowLine).toContain("request-failed");
|
expect(rowLine).not.toContain("request-failed");
|
||||||
|
expect(rowLine).not.toContain("timeout");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1624,10 +1624,24 @@ describe("market-content-entry", () => {
|
|||||||
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
{ authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" }
|
||||||
]);
|
]);
|
||||||
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
const buildAudienceProfileCsv = vi.fn(() => "profile-csv");
|
||||||
const loadAudienceProfile = vi.fn(async () => ({
|
const loadAudienceProfile = vi.fn(async (_record, linkType: number) => {
|
||||||
|
if (linkType === 4) {
|
||||||
|
return {
|
||||||
|
age: [{ label: "31-40", value: "30%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "50%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "70%" }],
|
||||||
|
status: "success" as const
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
age: [{ label: "31-40", value: "60%" }],
|
||||||
|
crowd: [{ label: "都市蓝领", value: "80%" }],
|
||||||
|
cityTier: [{ label: "一线城市", value: "90%" }],
|
||||||
gender: [{ label: "男性", value: "60%" }],
|
gender: [{ label: "男性", value: "60%" }],
|
||||||
status: "success" as const
|
status: "success" as const
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
const onCsvReady = vi.fn();
|
const onCsvReady = vi.fn();
|
||||||
|
|
||||||
const { createMarketController } = await import("../src/content/market/index");
|
const { createMarketController } = await import("../src/content/market/index");
|
||||||
@ -1651,15 +1665,18 @@ describe("market-content-entry", () => {
|
|||||||
click('[data-plugin-export-audience-profile="button"]');
|
click('[data-plugin-export-audience-profile="button"]');
|
||||||
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
await waitForMockCall(buildAudienceProfileCsv, 40, 50);
|
||||||
|
|
||||||
expect(loadAudienceProfile).toHaveBeenCalledTimes(1);
|
expect(loadAudienceProfile).toHaveBeenCalledTimes(3);
|
||||||
expect(loadAudienceProfile).toHaveBeenCalledWith(
|
expect(loadAudienceProfile.mock.calls.map(([, linkType]) => linkType)).toEqual([
|
||||||
expect.objectContaining({ authorId: "222" })
|
3,
|
||||||
);
|
1,
|
||||||
|
4
|
||||||
|
]);
|
||||||
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([
|
expect(buildAudienceProfileCsv).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
profile: {
|
profiles: {
|
||||||
gender: [{ label: "男性", value: "60%" }],
|
audience: expect.objectContaining({ status: "success" }),
|
||||||
status: "success"
|
fans: expect.objectContaining({ status: "success" }),
|
||||||
|
longtimeFans: expect.objectContaining({ status: "success" })
|
||||||
},
|
},
|
||||||
record: expect.objectContaining({ authorId: "222" })
|
record: expect.objectContaining({ authorId: "222" })
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user