import { normalizeFractionRateDisplay } from "../../shared/rate-normalizer"; import type { MarketRowSnapshot } from "./types"; export interface ParsedMarketListResponse { currentPage?: number; pageSize?: number; records: MarketRowSnapshot[]; totalCount?: number; totalPages?: number; } const PAGE_NUMBER_KEYS = [ "currentPage", "page", "pageNo", "pageNum", "page_no", "page_num" ] as const; const PAGE_SIZE_KEYS = [ "limit", "pageSize", "page_size", "size" ] as const; const TOTAL_COUNT_KEYS = [ "total", "totalCount", "total_count" ] as const; const TOTAL_PAGE_KEYS = [ "pageCount", "page_count", "totalPage", "totalPages", "total_page", "total_pages" ] as const; export function mapMarketListRow( row: Record ): MarketRowSnapshot { const attributeDatas = readMarketAttributeDatas(row); const singleVideoAfterSearchRate = normalizeMarketListRate( readMarketFieldValue(row, attributeDatas, "avg_search_after_view_rate_30d") ); return { authorId: readString(readMarketFieldValue(row, attributeDatas, "star_id")) ?? readString(readMarketFieldValue(row, attributeDatas, "id")) ?? "", authorName: readString(readMarketFieldValue(row, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(row, attributeDatas, "nick_name")) ?? "", coreUserId: readString(readMarketFieldValue(row, attributeDatas, "core_user_id")) ?? undefined, exportFields: buildMarketExportFieldFallbacks(row, attributeDatas), hasDirectRatesSource: true, location: readMarketLocation(row, attributeDatas), price21To60s: readMarketPrice21To60s(row, attributeDatas), rates: singleVideoAfterSearchRate ? { singleVideoAfterSearchRate } : undefined }; } export function parseMarketListResponse( payload: unknown ): ParsedMarketListResponse | null { const container = findMarketListContainer(payload); if (!container) { return null; } const marketList = readMarketListArray(container); if (!marketList) { return null; } return { currentPage: readKnownNumberDeep(container, PAGE_NUMBER_KEYS) ?? undefined, pageSize: readKnownNumberDeep(container, PAGE_SIZE_KEYS) ?? undefined, records: marketList .map((row) => (isRecord(row) ? mapMarketListRow(row) : null)) .filter( (row): row is MarketRowSnapshot => row !== null && Boolean(row.authorId || row.authorName) ), totalCount: readKnownNumberDeep(container, TOTAL_COUNT_KEYS) ?? undefined, totalPages: readKnownNumberDeep(container, TOTAL_PAGE_KEYS) ?? undefined }; } export function readKnownPaginationNumber( value: unknown, kind: "page" | "pageSize" ): number | null { if (!isRecord(value)) { return null; } return readKnownNumberDeep(value, kind === "page" ? PAGE_NUMBER_KEYS : PAGE_SIZE_KEYS); } function findMarketListContainer(value: unknown): Record | null { const queue: unknown[] = [value]; while (queue.length > 0) { const current = queue.shift(); if (!isRecord(current)) { continue; } if (readMarketListArray(current)) { return current; } Object.values(current).forEach((entry) => { queue.push(unwrapVueRef(entry)); }); } return null; } function readMarketListArray(record: Record): unknown[] | null { const marketList = unwrapVueRef(record.marketList); if (Array.isArray(marketList)) { return marketList; } const authors = unwrapVueRef(record.authors); if (Array.isArray(authors)) { return authors; } return null; } function unwrapVueRef(value: unknown): unknown { if (isRecord(value) && "value" in value) { return value.value; } return value; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function readMarketAttributeDatas( record: Record ): Record { return isRecord(record.attribute_datas) ? record.attribute_datas : {}; } function readMarketFieldValue( record: Record, attributeDatas: Record, field: string ): unknown { return record[field] ?? attributeDatas[field]; } function readString(value: unknown): string | null { return typeof value === "string" ? value : null; } function normalizeMarketListRate(value: unknown): string | null { if (typeof value === "number") { return normalizeFractionRateDisplay(String(value)); } return typeof value === "string" ? normalizeFractionRateDisplay(value) : null; } function normalizeExportCellText(value: string | null | undefined): string { return value?.replace(/\s+/g, " ").trim() ?? ""; } function buildMarketExportFieldFallbacks( record: Record, attributeDatas: Record ): Record | undefined { const exportFields: Record = {}; const authorInfo = buildMarketAuthorInfo(record, attributeDatas); const authorType = buildMarketAuthorType(record, attributeDatas); const contentTheme = buildMarketContentTheme(record, attributeDatas); const connectedUsers = formatWanValue( readNumericValue(readMarketFieldValue(record, attributeDatas, "link_link_cnt_by_industry")) ); const followerCount = formatWanValue( readNumericValue(readMarketFieldValue(record, attributeDatas, "follower")) ); const expectedCpm = formatDecimalDisplay( readNumericValue(readMarketFieldValue(record, attributeDatas, "prospective_20_60_cpm")) ); const expectedPlayCount = formatWanValue( readNumericValue(readMarketFieldValue(record, attributeDatas, "expected_play_num")) ); const interactionRate = formatFractionPercent( readNumericValue(readMarketFieldValue(record, attributeDatas, "interact_rate_within_30d")) ); const finishRate = formatFractionPercent( readNumericValue(readMarketFieldValue(record, attributeDatas, "play_over_rate_within_30d")) ); const burstRate = readBurstRateDisplay( readNumericValue(readMarketFieldValue(record, attributeDatas, "burst_text_rate")) ); const price21To60s = readMarketPrice21To60s(record, attributeDatas); const representativeVideo = readMarketRepresentativeVideo(record, attributeDatas); assignExportField(exportFields, "达人信息", authorInfo); assignExportField(exportFields, "代表视频", representativeVideo); assignExportField(exportFields, "达人类型", authorType); assignExportField(exportFields, "内容主题", contentTheme); assignExportField(exportFields, "连接用户数", connectedUsers); assignExportField(exportFields, "粉丝数", followerCount); assignExportField(exportFields, "预期CPM", expectedCpm); assignExportField(exportFields, "预期播放量", expectedPlayCount); assignExportField(exportFields, "互动率", interactionRate); assignExportField(exportFields, "完播率", finishRate); assignExportField(exportFields, "爆文率", burstRate); assignExportField(exportFields, "21-60s报价", price21To60s); return Object.keys(exportFields).length > 0 ? exportFields : undefined; } function assignExportField( exportFields: Record, key: string, value: string | undefined ): void { if (hasTextValue(value)) { exportFields[key] = value; } } function hasTextValue(value: string | undefined | null): boolean { return Boolean(value && value.trim().length > 0); } function buildMarketAuthorInfo( record: Record, attributeDatas: Record ): string | undefined { const nickname = readString(readMarketFieldValue(record, attributeDatas, "nickname")) ?? readString(readMarketFieldValue(record, attributeDatas, "nick_name")) ?? ""; const parts = [ nickname, readMarketGenderLabel(readMarketFieldValue(record, attributeDatas, "gender")), readString(readMarketFieldValue(record, attributeDatas, "city")) ?? "" ].filter((value) => Boolean(value)); return parts.length > 0 ? parts.join(" ") : undefined; } function buildMarketAuthorType( record: Record, attributeDatas: Record ): string | undefined { const tagsRelation = readRecordLike( readMarketFieldValue(record, attributeDatas, "tags_relation") ); if (tagsRelation) { const primaryTag = Object.keys(tagsRelation)[0]; if (hasTextValue(primaryTag)) { return primaryTag; } } return undefined; } function buildMarketContentTheme( record: Record, attributeDatas: Record ): string | undefined { const themes = readStringArray( readMarketFieldValue(record, attributeDatas, "content_theme_labels_180d") ); if (themes.length === 0) { return undefined; } if (themes.length <= 2) { return themes.join(" "); } return `${themes.slice(0, 2).join(" ")} ${themes.length - 2}+`; } function readMarketLocation( record: Record, attributeDatas: Record ): string | undefined { return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? undefined; } function readMarketPrice21To60s( record: Record, attributeDatas: Record ): string | undefined { return formatCurrencyValue( readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60")) ); } function readMarketRepresentativeVideo( record: Record, attributeDatas: Record ): string | undefined { const items = readArrayLike(readMarketFieldValue(record, attributeDatas, "items")); for (const item of items) { if (!isRecord(item)) { continue; } const title = readString(item.title); if (hasTextValue(title)) { return normalizeExportCellText(title); } } return undefined; } function readMarketGenderLabel(value: unknown): string | undefined { const rawValue = typeof value === "number" ? String(value) : readString(value); if (rawValue === "1") { return "男"; } if (rawValue === "2") { return "女"; } return undefined; } function readBurstRateDisplay(value: number | null): string | undefined { if (value === null) { return undefined; } if (value <= 0) { return "-"; } return formatFractionPercent(value); } function formatCurrencyValue(value: number | null): string | undefined { if (value === null) { return undefined; } return `¥${value.toLocaleString("en-US", { maximumFractionDigits: 0 })}`; } function formatWanValue(value: number | null): string | undefined { if (value === null) { return undefined; } return `${formatDecimalWithGrouping(value / 10000)}w`; } function formatFractionPercent(value: number | null): string | undefined { if (value === null) { return undefined; } return `${formatDecimalDisplay(value * 100)}%`; } function formatDecimalDisplay(value: number | null): string | undefined { if (value === null) { return undefined; } return value.toLocaleString("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 0, useGrouping: false }); } function formatDecimalWithGrouping(value: number): string { return value.toLocaleString("en-US", { maximumFractionDigits: 1, minimumFractionDigits: 0 }); } function readNumericValue(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string") { const trimmedValue = value.trim(); if (!trimmedValue) { return null; } const parsedValue = Number(trimmedValue); return Number.isFinite(parsedValue) ? parsedValue : null; } return null; } function readStringArray(value: unknown): string[] { if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === "string"); } if (typeof value === "string") { try { const parsedValue = JSON.parse(value); return Array.isArray(parsedValue) ? parsedValue.filter((item): item is string => typeof item === "string") : []; } catch { return []; } } return []; } function readArrayLike(value: unknown): unknown[] { if (Array.isArray(value)) { return value; } if (typeof value === "string") { try { const parsedValue = JSON.parse(value); return Array.isArray(parsedValue) ? parsedValue : []; } catch { return []; } } return []; } function readRecordLike(value: unknown): Record | null { if (isRecord(value)) { return value; } if (typeof value === "string") { try { const parsedValue = JSON.parse(value); return isRecord(parsedValue) ? parsedValue : null; } catch { return null; } } return null; } function readKnownNumber( record: Record, keys: readonly string[] ): number | undefined { for (const key of keys) { const value = readNumericValue(record[key]); if (value !== null) { return value; } } return undefined; } function readKnownNumberDeep( value: unknown, keys: readonly string[] ): number | null { if (!isRecord(value)) { return null; } const directValue = readKnownNumber(value, keys); if (typeof directValue === "number") { return directValue; } for (const nestedValue of Object.values(value)) { const candidate = readKnownNumberDeep(unwrapVueRef(nestedValue), keys); if (typeof candidate === "number") { return candidate; } } return null; }