const test = require("node:test"); const assert = require("node:assert/strict"); const { buildExportRows, buildCsvContent, buildFieldOptions, buildSpreadsheetXml, createExportController, flattenRecord, getFieldLabel, parseCreatorInputs, } = require("../src/xhs-pgy-export-core.js"); test("parseCreatorInputs supports ids, homepage links, dedupe and ignores junk", () => { const inputs = ` 5776652682ec3912d6f508d5 https://www.xiaohongshu.com/user/profile/5776652682ec3912d6f508d5 https://pgy.xiaohongshu.com/api/solar/cooperator/user/blogger/5f1234567890abcdef123456 not-a-valid-id `; assert.deepEqual(parseCreatorInputs(inputs), [ "5776652682ec3912d6f508d5", "5f1234567890abcdef123456", ]); }); test("flattenRecord expands nested objects and normalizes arrays", () => { const flattened = flattenRecord({ id: "abc", profile: { nickname: "达人A", tags: ["美妆", "护肤"], }, prices: [ { title: "图文", amount: 600 }, { title: "视频", amount: 1200 }, ], empty: null, }); assert.equal(flattened.id, "abc"); assert.equal(flattened["profile.nickname"], "达人A"); assert.equal(flattened["profile.tags"], "美妆 | 护肤"); assert.match(flattened.prices, /图文/); assert.equal(flattened.empty, ""); }); test("buildFieldOptions and buildExportRows merge fields across records", () => { const records = [ { raw: { id: "1", name: "达人A", metrics: { fans: 1000 } }, flattened: flattenRecord({ id: "1", name: "达人A", metrics: { fans: 1000 } }), }, { raw: { id: "2", name: "达人B", contact: { wechat: "abc123" } }, flattened: flattenRecord({ id: "2", name: "达人B", contact: { wechat: "abc123" } }), }, ]; const fields = buildFieldOptions(records); assert.deepEqual( fields.map((field) => field.path), ["id", "metrics.fans", "name"], ); assert.deepEqual( fields.map((field) => field.label), ["ID", "粉丝数", "达人昵称"], ); assert.deepEqual( fields.map((field) => Object.keys(field).sort()), [ ["label", "path"], ["label", "path"], ["label", "path"], ], ); const rows = buildExportRows(records, ["id", "name", "contact.wechat"]); assert.deepEqual(rows, [ { id: "1", name: "达人A", "contact.wechat": "" }, { id: "2", name: "达人B", "contact.wechat": "abc123" }, ]); }); test("getFieldLabel maps known creator fields and falls back for unknown fields", () => { assert.equal(getFieldLabel("userId"), "达人ID"); assert.equal(getFieldLabel("name"), "达人昵称"); assert.equal(getFieldLabel("fansCount"), "粉丝数"); assert.equal(getFieldLabel("contentTags"), "内容标签"); assert.equal(getFieldLabel("clothingIndustryPrice.picturePrice"), "服饰行业图文报价"); assert.equal(getFieldLabel("dataSummary"), "数据概览"); assert.equal(getFieldLabel("dataSummary.fans30GrowthRate"), "近30天涨粉率"); assert.equal(getFieldLabel("dataSummary.mAccumImpCompare"), "曝光中位数超越率"); assert.equal(getFieldLabel("dataSummary.noteType"), "笔记内容类型"); assert.equal(getFieldLabel("dataSummary.activeDayInLast7"), "近7天活跃天数"); assert.equal(getFieldLabel("dataSummary.responseRate"), "响应率"); assert.equal(getFieldLabel("fansSummary.fansNum"), "粉丝总数"); assert.equal(getFieldLabel("fansSummary.fansIncreaseNum"), "涨粉数"); assert.equal(getFieldLabel("fansSummary.fansGrowthRate"), "粉丝增长率"); assert.equal(getFieldLabel("fansSummary.fansGrowthBeyondRate"), "粉丝增长超越率"); assert.equal(getFieldLabel("fansSummary.activeFansL28"), "近28天活跃粉丝数"); assert.equal(getFieldLabel("fansSummary.activeFansRate"), "活跃粉丝占比"); assert.equal(getFieldLabel("fansSummary.activeFansBeyondRate"), "活跃粉丝超越率"); assert.equal(getFieldLabel("fansSummary.engageFansRate"), "互动粉丝占比"); assert.equal(getFieldLabel("fansSummary.engageFansL30"), "近30天互动粉丝数"); assert.equal(getFieldLabel("fansSummary.engageFansBeyondRate"), "互动粉丝超越率"); assert.equal(getFieldLabel("fansSummary.readFansIn30"), "近30天阅读粉丝数"); assert.equal(getFieldLabel("fansSummary.readFansRate"), "阅读粉丝占比"); assert.equal(getFieldLabel("fansSummary.readFansBeyondRate"), "阅读粉丝超越率"); assert.equal(getFieldLabel("fansSummary.payFansUserRate30d"), "近30天支付粉丝占比"); assert.equal(getFieldLabel("fansSummary.payFansUserNum30d"), "近30天支付粉丝数"); assert.equal(getFieldLabel("fansSummary.cityDistribution"), "粉丝概览 - cityDistribution"); assert.equal(getFieldLabel("fansProfile.ages"), "粉丝年龄分布"); assert.equal(getFieldLabel("fansProfile.gender.male"), "粉丝男性占比"); assert.equal(getFieldLabel("fansProfile.gender.female"), "粉丝女性占比"); assert.equal(getFieldLabel("fansProfile.interests"), "粉丝兴趣分布"); assert.equal(getFieldLabel("fansProfile.provinces"), "粉丝省份分布"); assert.equal(getFieldLabel("fansProfile.cities"), "粉丝城市分布"); assert.equal(getFieldLabel("fansProfile.devices"), "粉丝设备分布"); assert.equal(getFieldLabel("fansProfile.dateKey"), "画像日期"); assert.equal(getFieldLabel("metrics.customScore"), "metrics.customScore"); }); test("buildSpreadsheetXml escapes xml-sensitive characters", () => { const xml = buildSpreadsheetXml({ columns: ["id", "name"], rows: [{ id: "1", name: "A & B <达人>" }], sheetName: "达人数据", }); assert.match(xml, /A & B <达人>/); assert.match(xml, //); }); test("buildCsvContent adds BOM and escapes commas, quotes and newlines", () => { const csv = buildCsvContent({ columns: ["id", "name"], headers: ["达人ID", "达人昵称"], rows: [{ id: "1", name: 'A,"B"\n达人' }], }); assert.equal(csv.charCodeAt(0), 0xfeff); assert.match(csv, /达人ID,达人昵称/); assert.match(csv, /1,"A,""B""\n达人"/); }); test("createExportController previews and exports creator data", async () => { const seenRequests = []; const controller = createExportController({ fetchImpl: async (url, options) => { seenRequests.push({ url, options }); if (!url.includes("/api/solar/cooperator/user/blogger/")) { return { ok: false, status: 404, json: async () => ({}), }; } const id = url.split("/").pop(); return { ok: true, json: async () => ({ success: true, data: { id, name: `达人-${id.slice(-4)}`, metrics: { fans: Number(id.slice(-2)), }, }, }), }; }, now: () => new Date("2026-03-12T08:09:10Z"), }); const preview = await controller.preview(` 5776652682ec3912d6f508d5 5f1234567890abcdef123456 `); assert.equal(seenRequests.length, 8); assert.equal(preview.records.length, 2); assert.deepEqual(preview.selectedFields, ["id", "metrics.fans", "name"]); assert.equal(preview.fields.find((field) => field.path === "name").label, "达人昵称"); assert.deepEqual( preview.fields.map((field) => Object.keys(field).sort()), preview.fields.map(() => ["label", "path"]), ); const exported = controller.exportSheet(["id", "name", "metrics.fans"]); assert.equal(exported.filename, "xhs-bloggers-20260312-160910.xlsx"); assert.equal(exported.rows.length, 2); assert.deepEqual(exported.headers, ["ID", "达人昵称", "粉丝数"]); assert.ok(Buffer.isBuffer(exported.content)); assert.equal(exported.content[0], 0x50); // P assert.equal(exported.content[1], 0x4b); // K }); test("createExportController merges supplemental endpoint payloads into namespaced fields", async () => { const seenUrls = []; const controller = createExportController({ fetchImpl: async (url) => { seenUrls.push(url); if (url.includes("/api/solar/cooperator/user/blogger/")) { return { ok: true, json: async () => ({ data: { id: "61f27a60000000001000cb5f", userId: "61f27a60000000001000cb5f", name: "测试达人", }, }), }; } if (url.includes("/api/pgy/kol/data/data_summary")) { return { ok: true, json: async () => ({ data: { avgRead: 1200, avgInteract: 98, }, }), }; } if (url.includes("/api/solar/kol/data_v3/fans_summary")) { return { ok: true, json: async () => ({ data: { cityDistribution: ["上海", "杭州"], maleRate: 0.22, }, }), }; } if (url.includes("/api/solar/kol/data/61f27a60000000001000cb5f/fans_profile")) { return { ok: true, json: async () => ({ data: { age18_24: 0.31, age25_34: 0.44, }, }), }; } throw new Error(`unexpected url: ${url}`); }, }); const preview = await controller.preview("61f27a60000000001000cb5f"); assert.equal(seenUrls.length, 4); assert.ok( seenUrls.some((url) => url.includes("/api/pgy/kol/data/data_summary?userId=61f27a60000000001000cb5f&business=1"), ), ); assert.ok( seenUrls.some((url) => url.includes("/api/solar/kol/data_v3/fans_summary?userId=61f27a60000000001000cb5f"), ), ); assert.ok( seenUrls.some((url) => url.includes("/api/solar/kol/data/61f27a60000000001000cb5f/fans_profile"), ), ); assert.equal(preview.records[0].raw.dataSummary.avgRead, 1200); assert.equal(preview.records[0].raw.fansSummary.maleRate, 0.22); assert.equal(preview.records[0].raw.fansProfile.age18_24, 0.31); assert.equal(preview.records[0].flattened["dataSummary.avgRead"], "1200"); assert.equal(preview.records[0].flattened["fansSummary.cityDistribution"], "上海 | 杭州"); assert.equal(preview.fields.find((field) => field.path === "dataSummary.avgRead").label, "平均阅读量"); assert.equal( preview.fields.find((field) => field.path === "fansProfile.age18_24"), undefined, ); }); test("createExportController applies mapped Chinese headers for provided supplemental sample fields", async () => { const controller = createExportController({ fetchImpl: async (url) => { if (url.includes("/api/solar/cooperator/user/blogger/")) { return { ok: true, json: async () => ({ data: { id: "61f27a60000000001000cb5f", userId: "61f27a60000000001000cb5f", name: "李欢喜", }, }), }; } if (url.includes("/api/pgy/kol/data/data_summary")) { return { ok: true, json: async () => ({ data: { fans30GrowthRate: "4.7", activeDayInLast7: 7, noteType: [{ contentTag: "母婴", percent: "100.0" }], responseRate: "95.6", }, }), }; } if (url.includes("/api/solar/kol/data_v3/fans_summary")) { return { ok: true, json: async () => ({ data: {}, }), }; } if (url.includes("/fans_profile")) { return { ok: true, json: async () => ({ data: { ages: [{ group: "25-34", percent: 0.67 }], gender: { male: 0.12, female: 0.88 }, interests: [{ name: "母婴", percent: 0.17 }], provinces: [{ name: "广东", percent: 0.14 }], cities: [{ name: "广州", percent: 0.03 }], devices: [{ name: "apple inc.", desc: "苹果", percent: 0.28 }], dateKey: "2026-03-11", }, }), }; } throw new Error(`unexpected url: ${url}`); }, }); await controller.preview("61f27a60000000001000cb5f"); const exported = controller.exportSheet([ "name", "dataSummary.fans30GrowthRate", "dataSummary.activeDayInLast7", "dataSummary.noteType", "dataSummary.responseRate", "fansProfile.ages", "fansProfile.gender.male", "fansProfile.gender.female", "fansProfile.interests", "fansProfile.provinces", "fansProfile.cities", "fansProfile.devices", "fansProfile.dateKey", ]); assert.deepEqual(exported.headers, [ "达人昵称", "近30天涨粉率", "近7天活跃天数", "笔记内容类型", "响应率", "粉丝年龄分布", "粉丝男性占比", "粉丝女性占比", "粉丝兴趣分布", "粉丝省份分布", "粉丝城市分布", "粉丝设备分布", "画像日期", ]); }); test("createExportController applies mapped Chinese headers for provided fansSummary fields", async () => { const controller = createExportController({ fetchImpl: async (url) => { if (url.includes("/api/solar/cooperator/user/blogger/")) { return { ok: true, json: async () => ({ data: { id: "61f27a60000000001000cb5f", userId: "61f27a60000000001000cb5f", name: "李欢喜", }, }), }; } if (url.includes("/api/pgy/kol/data/data_summary")) { return { ok: true, json: async () => ({ data: {} }) }; } if (url.includes("/api/solar/kol/data_v3/fans_summary")) { return { ok: true, json: async () => ({ data: { fansNum: 11824, fansIncreaseNum: 534, fansGrowthRate: "4.7", fansGrowthBeyondRate: "90.3", activeFansL28: 4329, activeFansRate: "36.6", activeFansBeyondRate: "31.4", engageFansRate: "8.0", engageFansL30: 946, engageFansBeyondRate: "97.6", readFansIn30: 1343, readFansRate: "11.4", readFansBeyondRate: "89.0", payFansUserRate30d: "2.7", payFansUserNum30d: 320, }, }), }; } if (url.includes("/fans_profile")) { return { ok: true, json: async () => ({ data: {} }) }; } throw new Error(`unexpected url: ${url}`); }, }); await controller.preview("61f27a60000000001000cb5f"); const exported = controller.exportSheet([ "fansSummary.fansNum", "fansSummary.fansIncreaseNum", "fansSummary.fansGrowthRate", "fansSummary.fansGrowthBeyondRate", "fansSummary.activeFansL28", "fansSummary.activeFansRate", "fansSummary.activeFansBeyondRate", "fansSummary.engageFansRate", "fansSummary.engageFansL30", "fansSummary.engageFansBeyondRate", "fansSummary.readFansIn30", "fansSummary.readFansRate", "fansSummary.readFansBeyondRate", "fansSummary.payFansUserRate30d", "fansSummary.payFansUserNum30d", ]); assert.deepEqual(exported.headers, [ "粉丝总数", "涨粉数", "粉丝增长率", "粉丝增长超越率", "近28天活跃粉丝数", "活跃粉丝占比", "活跃粉丝超越率", "互动粉丝占比", "近30天互动粉丝数", "互动粉丝超越率", "近30天阅读粉丝数", "阅读粉丝占比", "阅读粉丝超越率", "近30天支付粉丝占比", "近30天支付粉丝数", ]); }); test("createExportController tolerates supplemental endpoint failures", async () => { const controller = createExportController({ fetchImpl: async (url) => { if (url.includes("/api/solar/cooperator/user/blogger/")) { return { ok: true, json: async () => ({ data: { id: "61f27a60000000001000cb5f", userId: "61f27a60000000001000cb5f", name: "测试达人", }, }), }; } return { ok: false, status: 500, json: async () => ({}), }; }, }); const preview = await controller.preview("61f27a60000000001000cb5f"); assert.equal(preview.records[0].raw.name, "测试达人"); assert.equal(preview.records[0].raw.dataSummary, undefined); assert.equal(preview.records[0].flattened.name, "测试达人"); }); test("createExportController rejects empty input and request failures", async () => { const controller = createExportController({ fetchImpl: async () => ({ ok: false, status: 403, json: async () => ({}), }), }); await assert.rejects( controller.preview(""), /请输入至少一个有效的达人主页链接或达人 ID/, ); await assert.rejects( controller.preview("5776652682ec3912d6f508d5"), /请求达人 5776652682ec3912d6f508d5 失败,状态码:403/, ); assert.throws( () => controller.exportSheet(["id"]), /请先读取字段并确认达人数据/, ); });