diff --git a/src/content/market/csv-exporter.ts b/src/content/market/csv-exporter.ts new file mode 100644 index 0000000..8c10678 --- /dev/null +++ b/src/content/market/csv-exporter.ts @@ -0,0 +1,49 @@ +import { normalizeRateDisplay } from "../../shared/rate-normalizer"; +import { escapeCsvCell } from "../../shared/csv"; +import type { MarketRecord } from "./types"; + +const CSV_COLUMNS = [ + { + header: "达人ID", + readValue: (record: MarketRecord) => record.authorId + }, + { + header: "达人名称", + readValue: (record: MarketRecord) => record.authorName + }, + { + header: "地区", + readValue: (record: MarketRecord) => record.location ?? "" + }, + { + header: "21-60s报价", + readValue: (record: MarketRecord) => record.price21To60s ?? "" + }, + { + header: "单视频看后搜率", + readValue: (record: MarketRecord) => + record.rates?.singleVideoAfterSearchRate + ? normalizeRateDisplay(record.rates.singleVideoAfterSearchRate) + : "" + }, + { + header: "个人视频看后搜率", + readValue: (record: MarketRecord) => + record.rates?.personalVideoAfterSearchRate + ? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate) + : "" + }, + { + header: "插件数据状态", + readValue: (record: MarketRecord) => record.status + } +] as const; + +export function buildMarketCsv(records: MarketRecord[]): string { + const headerLine = CSV_COLUMNS.map((column) => column.header).join(","); + const rowLines = records.map((record) => + CSV_COLUMNS.map((column) => escapeCsvCell(column.readValue(record))).join(",") + ); + + return [headerLine, ...rowLines].join("\n"); +} diff --git a/src/shared/csv.ts b/src/shared/csv.ts new file mode 100644 index 0000000..1984716 --- /dev/null +++ b/src/shared/csv.ts @@ -0,0 +1,7 @@ +export function escapeCsvCell(value: string): string { + if (/[",\n]/.test(value)) { + return `"${value.replace(/"/g, "\"\"")}"`; + } + + return value; +} diff --git a/tests/csv-exporter.test.ts b/tests/csv-exporter.test.ts new file mode 100644 index 0000000..088dfc1 --- /dev/null +++ b/tests/csv-exporter.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; + +import { buildMarketCsv } from "../src/content/market/csv-exporter"; +import type { MarketRecord } from "../src/content/market/types"; + +describe("csv-exporter", () => { + test("uses the expected header order", () => { + const csv = buildMarketCsv([]); + const [headerLine] = csv.split("\n"); + + expect(headerLine).toBe( + [ + "达人ID", + "达人名称", + "地区", + "21-60s报价", + "单视频看后搜率", + "个人视频看后搜率", + "插件数据状态" + ].join(",") + ); + }); + + test("escapes commas and quotes", () => { + const csv = buildMarketCsv([ + { + authorId: "123", + authorName: "Alice, \"A\"", + location: "Hangzhou", + price21To60s: "450000", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "1% - 3%" + } + } + ]); + + const [, rowLine] = csv.split("\n"); + expect(rowLine).toContain("\"Alice, \"\"A\"\"\""); + }); + + test("emits empty rate fields plus failed status for failed rows", () => { + const csv = buildMarketCsv([ + { + authorId: "123", + authorName: "Alice", + status: "failed", + failureReason: "request-failed" + } + ]); + + const [, rowLine] = csv.split("\n"); + expect(rowLine).toBe("123,Alice,,,,,failed"); + }); + + test("uses normalized display values in export rows", () => { + 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).toContain("0.5% - 1%"); + expect(rowLine).toContain("0.02% - 0.1%"); + }); +});