Allow partial market rates in CSV export

This commit is contained in:
admin123 2026-04-22 19:22:10 +08:00
parent c7ae2fbfcb
commit 2f77199920
5 changed files with 138 additions and 16 deletions

View File

@ -109,7 +109,7 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
data.personal_avg_search_after_view_rate data.personal_avg_search_after_view_rate
); );
if (!singleVideoAfterSearchRate || !personalVideoAfterSearchRate) { if (!singleVideoAfterSearchRate && !personalVideoAfterSearchRate) {
return { return {
success: false, success: false,
reason: "missing-rate" reason: "missing-rate"
@ -119,8 +119,8 @@ export function mapAuthorAseInfoResponse(payload: unknown): MarketApiResult {
return { return {
success: true, success: true,
rates: { rates: {
singleVideoAfterSearchRate, ...(singleVideoAfterSearchRate ? { singleVideoAfterSearchRate } : {}),
personalVideoAfterSearchRate ...(personalVideoAfterSearchRate ? { personalVideoAfterSearchRate } : {})
} }
}; };
} }

View File

@ -43,9 +43,40 @@ const RATE_COLUMNS: CsvColumn[] = [
} }
]; ];
const BACKEND_METRIC_COLUMNS: CsvColumn[] = [
{
header: "看后搜率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchRate ?? ""
},
{
header: "看后搜数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.afterViewSearchCount ?? ""
},
{
header: "新增A3数",
readValue: (record: MarketRecord) =>
record.backendMetrics?.a3IncreaseCount ?? ""
},
{
header: "新增A3率",
readValue: (record: MarketRecord) =>
record.backendMetrics?.newA3Rate ?? ""
},
{
header: "CPA3",
readValue: (record: MarketRecord) => record.backendMetrics?.cpa3 ?? ""
},
{
header: "cp_search",
readValue: (record: MarketRecord) => record.backendMetrics?.cpSearch ?? ""
}
];
export function buildMarketCsv(records: MarketRecord[]): string { export function buildMarketCsv(records: MarketRecord[]): string {
const baseColumns = buildBaseColumns(records); const baseColumns = buildBaseColumns(records);
const csvColumns = [...baseColumns, ...RATE_COLUMNS]; const csvColumns = [...baseColumns, ...RATE_COLUMNS, ...BACKEND_METRIC_COLUMNS];
const headerLine = csvColumns.map((column) => column.header).join(","); const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) => const rowLines = records.map((record) =>
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",") csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
@ -57,10 +88,11 @@ export function buildMarketCsv(records: MarketRecord[]): string {
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] { function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
const orderedHeaders: string[] = []; const orderedHeaders: string[] = [];
const seenHeaders = new Set<string>(); const seenHeaders = new Set<string>();
const excludedHeaders = new Set(["代表视频"]);
records.forEach((record) => { records.forEach((record) => {
Object.keys(record.exportFields ?? {}).forEach((header) => { Object.keys(record.exportFields ?? {}).forEach((header) => {
if (seenHeaders.has(header)) { if (seenHeaders.has(header) || excludedHeaders.has(header)) {
return; return;
} }

View File

@ -60,7 +60,7 @@ export type MarketApiFailureReason =
export type MarketApiSuccessResult = { export type MarketApiSuccessResult = {
success: true; success: true;
rates: Required<AfterSearchRates>; rates: AfterSearchRates;
}; };
export type MarketApiFailureResult = { export type MarketApiFailureResult = {

View File

@ -15,7 +15,13 @@ describe("csv-exporter", () => {
"地区", "地区",
"21-60s报价", "21-60s报价",
"单视频看后搜率", "单视频看后搜率",
"个人视频看后搜率" "个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",") ].join(",")
); );
}); });
@ -27,10 +33,19 @@ describe("csv-exporter", () => {
authorName: "Alice", authorName: "Alice",
exportFields: { exportFields: {
: "Alice", : "Alice",
: "示例视频",
: "100w", : "100w",
"21-60s报价": "¥450,000" "21-60s报价": "¥450,000"
}, },
status: "success", status: "success",
backendMetrics: {
a3IncreaseCount: "78,366.22",
afterViewSearchCount: "9,689.96",
afterViewSearchRate: "0.36%",
cpSearch: "14.46",
cpa3: "1.79",
newA3Rate: "3.44%"
},
rates: { rates: {
singleVideoAfterSearchRate: "0.5%-1%", singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "1% - 3%" personalVideoAfterSearchRate: "1% - 3%"
@ -40,11 +55,56 @@ describe("csv-exporter", () => {
const [headerLine, rowLine] = csv.split("\n"); const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe( expect(headerLine).toBe(
["达人信息", "粉丝数", "21-60s报价", "单视频看后搜率", "个人视频看后搜率"].join( [
"," "达人信息",
) "粉丝数",
"21-60s报价",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",")
); );
expect(rowLine).toBe('Alice,100w,"¥450,000",0.5% - 1%,1% - 3%'); expect(rowLine).toBe(
'Alice,100w,"¥450,000",0.5% - 1%,1% - 3%,0.36%,"9,689.96","78,366.22",3.44%,1.79,14.46'
);
});
test("omits the representative video column from exported page fields", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
exportFields: {
: "Alice",
: "示例视频",
: "100w"
},
status: "success"
} satisfies MarketRecord
]);
const [headerLine, rowLine] = csv.split("\n");
expect(headerLine).toBe(
[
"达人信息",
"粉丝数",
"单视频看后搜率",
"个人视频看后搜率",
"看后搜率",
"看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
].join(",")
);
expect(rowLine).toBe("Alice,100w,,,,,,,,");
}); });
test("escapes commas and quotes", () => { test("escapes commas and quotes", () => {
@ -77,7 +137,7 @@ describe("csv-exporter", () => {
]); ]);
const [, rowLine] = csv.split("\n"); const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,,"); expect(rowLine).toBe("123,Alice,,,,,,,,,,");
}); });
test("uses normalized display values in export rows", () => { test("uses normalized display values in export rows", () => {
@ -97,4 +157,21 @@ describe("csv-exporter", () => {
expect(rowLine).toContain("0.5% - 1%"); expect(rowLine).toContain("0.5% - 1%");
expect(rowLine).toContain("0.02% - 0.1%"); expect(rowLine).toContain("0.02% - 0.1%");
}); });
test("emits empty backend metric cells when backend metrics are absent", () => {
const csv = buildMarketCsv([
{
authorId: "123",
authorName: "Alice",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
} satisfies MarketRecord
]);
const [, rowLine] = csv.split("\n");
expect(rowLine).toBe("123,Alice,,,0.5% - 1%,0.02% - 0.1%,,,,,,");
});
}); });

View File

@ -33,12 +33,10 @@ describe("market-api-client", () => {
}); });
}); });
test("returns a missing-rate failure when the payload omits a required field", () => { test("returns a missing-rate failure when the payload omits both rate fields", () => {
expect( expect(
mapAuthorAseInfoResponse({ mapAuthorAseInfoResponse({
data: { data: {}
avg_search_after_view_rate: "<0.02%"
}
}) })
).toMatchObject({ ).toMatchObject({
success: false, success: false,
@ -46,6 +44,21 @@ describe("market-api-client", () => {
}); });
}); });
test("maps a partially populated payload into partial rates", () => {
expect(
mapAuthorAseInfoResponse({
data: {
personal_avg_search_after_view_rate: "0.02 - 0.1%"
}
})
).toMatchObject({
success: true,
rates: {
personalVideoAfterSearchRate: "0.02% - 0.1%"
}
});
});
test("returns a request-failed result for non-ok responses", async () => { test("returns a request-failed result for non-ok responses", async () => {
const client = createMarketApiClient({ const client = createMarketApiClient({
fetchImpl: async () => ({ fetchImpl: async () => ({