Fix silent market CSV export
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
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<string, unknown>
|
||||
): 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")) ??
|
||||
"",
|
||||
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<string, unknown> | 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<string, unknown>): 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<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function readMarketAttributeDatas(
|
||||
record: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
return isRecord(record.attribute_datas) ? record.attribute_datas : {};
|
||||
}
|
||||
|
||||
function readMarketFieldValue(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>,
|
||||
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<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): Record<string, string> | undefined {
|
||||
const exportFields: Record<string, string> = {};
|
||||
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<string, string>,
|
||||
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<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): 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<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): 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<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): 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<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
return readString(readMarketFieldValue(record, attributeDatas, "city")) ?? undefined;
|
||||
}
|
||||
|
||||
function readMarketPrice21To60s(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): string | undefined {
|
||||
return formatCurrencyValue(
|
||||
readNumericValue(readMarketFieldValue(record, attributeDatas, "price_20_60"))
|
||||
);
|
||||
}
|
||||
|
||||
function readMarketRepresentativeVideo(
|
||||
record: Record<string, unknown>,
|
||||
attributeDatas: Record<string, unknown>
|
||||
): 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<string, unknown> | 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<string, unknown>,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user