Files
koc-loop/tests/resource-import.test.mjs
2026-08-15 03:53:09 +08:00

266 lines
8.6 KiB
JavaScript
Raw Permalink 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.
import assert from "node:assert/strict";
import test from "node:test";
import { strFromU8, strToU8, unzipSync, zipSync } from "fflate";
import { buildRecoveryWorkbook } from "../lib/recovery-workbook.ts";
import {
RESOURCE_IMPORT_MAX_ROWS,
mergeCooperationSources,
normalizeProfileUrl,
parseResourceFollowers,
parseResourceImportFile,
resourceImportMissingFields,
resourcePlatformUid,
} from "../lib/resource-import.ts";
test("accepts several thousand accounts in one import file", () => {
const csv = [
"账号主页,账号昵称,账号ID,IP属地,粉丝数,合作来源",
...Array.from({ length: 3_000 }, (_, index) => {
const id = String(index + 1).padStart(6, "0");
return `https://www.xiaohongshu.com/user/profile/bulk${id},账号${id},${id},上海,100,批量资源`;
}),
].join("\n");
const rows = parseResourceImportFile(
"bulk-resources.csv",
new TextEncoder().encode(csv),
);
assert.equal(RESOURCE_IMPORT_MAX_ROWS, 10_000);
assert.equal(rows.length, 3_000);
assert.equal(rows[0].rowNumber, 2);
assert.equal(rows.at(-1)?.rowNumber, 3_001);
assert.equal(rows.every((row) => row.errors.length === 0), true);
});
test("parses several thousand accounts from an XLSX workbook", () => {
const workbook = buildRecoveryWorkbook({
sheetName: "KOC资源导入",
headers: ["账号主页", "账号昵称", "账号ID", "IP属地", "粉丝数", "合作来源"],
columnWidths: [48, 24, 20, 16, 14, 24],
rows: Array.from({ length: 3_000 }, (_, index) => {
const id = String(index + 1).padStart(6, "0");
return {
cells: [
`https://www.xiaohongshu.com/user/profile/xlsx${id}`,
`账号${id}`,
id,
"北京",
200,
"Excel批量资源",
],
images: [],
};
}),
});
const rows = parseResourceImportFile("bulk-resources.xlsx", workbook);
assert.equal(rows.length, 3_000);
assert.equal(rows[0].profileUrl.endsWith("xlsx000001"), true);
assert.equal(rows.at(-1)?.profileUrl.endsWith("xlsx003000"), true);
});
test("keeps a bounded 10,000-row safety limit", () => {
const csv = [
"账号主页",
...Array.from(
{ length: RESOURCE_IMPORT_MAX_ROWS + 1 },
(_, index) => `https://www.douyin.com/user/bulk-account-${index + 1}`,
),
].join("\n");
assert.throws(
() =>
parseResourceImportFile(
"too-many-resources.csv",
new TextEncoder().encode(csv),
),
/单次最多导入 10000 个账号/,
);
});
test("parses CSV resources and normalizes public profile data", () => {
const csv = [
"账号主页,合作来源",
'"主页https://www.xiaohongshu.com/user/profile/abc123?xsec_token=secret",林林KOC社群',
].join("\n");
const rows = parseResourceImportFile("resources.csv", new TextEncoder().encode(csv));
assert.equal(rows.length, 1);
assert.deepEqual(rows[0], {
rowNumber: 2,
platform: "小红书",
nickname: "",
publicAccountId: "",
profileUrl: "https://www.xiaohongshu.com/user/profile/abc123",
ipLocation: "",
followers: 0,
followersResolved: false,
gender: "",
bio: "",
tags: [],
cooperationSource: "林林KOC社群",
errors: [],
});
assert.equal(resourcePlatformUid(rows[0]), "abc123");
});
test("uses optional account fields directly and only requires the profile URL", () => {
const csv = [
"账号链接,账号昵称,账号ID,IP属地,粉丝数,性别,简介,标签,合作来源",
"https://www.xiaohongshu.com/user/profile/abc123,番茄不炒蛋,4171542126,江西,10+,女,分享江西本地生活,本地生活、美食探店,历史资源",
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
new TextEncoder().encode(csv),
);
assert.equal(row.nickname, "番茄不炒蛋");
assert.equal(row.publicAccountId, "4171542126");
assert.equal(row.ipLocation, "江西");
assert.equal(row.followers, 10);
assert.equal(row.followersResolved, true);
assert.equal(row.gender, "女");
assert.equal(row.bio, "分享江西本地生活");
assert.deepEqual(row.tags, ["本地生活", "美食探店"]);
assert.deepEqual(resourceImportMissingFields(row), []);
});
test("validates optional gender", () => {
const csv = [
"账号链接,性别,标签",
"https://www.xiaohongshu.com/user/profile/abc123,其他,美食探店",
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
new TextEncoder().encode(csv),
);
assert.match(row.errors.join(""), /性别格式不正确/);
});
test("rejects more than five tags in one optional tag cell", () => {
const csv = [
"账号链接,标签",
'https://www.xiaohongshu.com/user/profile/abc123,"美食探店,旅游出行,数码产品,本地生活,婚嫁备婚,美妆护肤"',
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
new TextEncoder().encode(csv),
);
assert.match(row.errors.join(""), /最多填写 5 个标签/);
});
test("normalizes common follower formats and identifies missing enrichment fields", () => {
assert.deepEqual(parseResourceFollowers("1.3万"), {
value: 13_000,
resolved: true,
valid: true,
});
assert.deepEqual(parseResourceFollowers("10+"), {
value: 10,
resolved: true,
valid: true,
});
assert.deepEqual(parseResourceFollowers(""), {
value: 0,
resolved: false,
valid: true,
});
});
test("parses the first matching worksheet from an XLSX workbook", () => {
const workbook = buildRecoveryWorkbook({
sheetName: "KOC资源导入",
headers: ["账号主页", "合作来源"],
columnWidths: [48, 24],
rows: [
{
cells: ["https://www.xiaohongshu.com/user/profile/abc123", "存量资源包"],
images: [],
},
],
});
const rows = parseResourceImportFile("resources.xlsx", workbook);
assert.equal(rows[0].platform, "小红书");
assert.equal(rows[0].cooperationSource, "存量资源包");
assert.equal(rows[0].errors.length, 0);
assert.equal(rows[0].followersResolved, false);
});
test("keeps values in their columns after a self-closing blank XLSX cell", () => {
const workbook = buildRecoveryWorkbook({
sheetName: "KOC资源导入",
headers: ["账号主页", "账号昵称", "账号ID", "IP属地", "粉丝数", "合作来源"],
columnWidths: [48, 24, 20, 16, 14, 24],
rows: [
{
cells: [
"https://www.xiaohongshu.com/user/profile/blank-ip-cell",
"空白IP账号",
"123456789",
"",
10,
"",
],
images: [],
},
],
});
const entries = unzipSync(workbook);
const sheetPath = "xl/worksheets/sheet1.xml";
const sheetXml = strFromU8(entries[sheetPath]);
entries[sheetPath] = strToU8(
sheetXml.replace('<c r="E2"', '<c r="D2"/><c r="E2"'),
);
const [row] = parseResourceImportFile(
"self-closing-blank.xlsx",
zipSync(entries),
);
assert.equal(row.ipLocation, "");
assert.equal(row.followers, 10);
assert.equal(row.followersResolved, true);
assert.deepEqual(row.errors, []);
});
test("reports invalid required fields without hiding valid rows", () => {
const csv = "账号主页,合作来源\n,历史资源\nhttps://www.douyin.com/user/demo,社群\nhttps://example.com/user/demo,其他";
const rows = parseResourceImportFile("resources.csv", new TextEncoder().encode(csv));
assert.match(rows[0].errors.join(""), /账号主页不能为空/);
assert.equal(rows[1].platform, "抖音");
assert.equal(rows[1].errors.length, 0);
assert.match(rows[2].errors.join(""), /仅支持小红书或抖音账号主页/);
});
test("rejects invalid optional follower values without requiring other optional fields", () => {
const csv = [
"账号链接,粉丝数",
"https://www.xiaohongshu.com/user/profile/abc123,很多",
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
new TextEncoder().encode(csv),
);
assert.match(row.errors.join(""), /粉丝数格式不正确/);
});
test("rejects a numeric value entered as an IP location", () => {
const csv = [
"账号链接,IP属地,粉丝数",
"https://www.xiaohongshu.com/user/profile/abc123,10,100",
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
new TextEncoder().encode(csv),
);
assert.match(row.errors.join(""), /IP属地格式不正确/);
});
test("normalizes profile URLs and merges cooperation sources", () => {
assert.equal(
normalizeProfileUrl("https://www.xiaohongshu.com/user/profile/abc/?foo=1#top"),
"https://www.xiaohongshu.com/user/profile/abc",
);
assert.equal(
mergeCooperationSources("林林社群、木子", "木子;历史表格"),
"林林社群、木子、历史表格",
);
});