scriptCat/pugongying/test/userscript.test.js

530 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 &amp; B &lt;达人&gt;/);
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"]),
/请先读取字段并确认达人数据/,
);
});