517 lines
13 KiB
TypeScript
517 lines
13 KiB
TypeScript
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")) ??
|
|
"",
|
|
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<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;
|
|
}
|