530 lines
17 KiB
JavaScript
530 lines
17 KiB
JavaScript
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, /<Worksheet ss:Name="达人数据">/);
|
||
});
|
||
|
||
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"]),
|
||
/请先读取字段并确认达人数据/,
|
||
);
|
||
});
|