feat: 完善视频任务与 KOC 资源库

This commit is contained in:
巫凤萍
2026-08-15 03:53:09 +08:00
parent ad3dbdcc86
commit f37d05dd88
66 changed files with 6633 additions and 558 deletions

View File

@@ -120,6 +120,55 @@ test("resolves a wiki sheet and imports title, body, tags, and all images", asyn
assert.equal(valuesCall.url.searchParams.get("ranges"), "sheet-one!A1:J20");
});
test("imports video attachments from a Feishu video task sheet", async () => {
clearFeishuAccessTokenCacheForTests();
const { fetchImpl } = fakeFeishu();
const videoFetch = async (input, init = {}) => {
const url = new URL(String(input));
if (url.pathname.endsWith("/values_batch_get")) {
return apiResponse({
valueRanges: [
{
values: [
["标题", "内容(标题+正文+tag", "视频"],
[
"一条测试视频",
"一条测试视频\n视频正文 #测试",
{
type: "attachment",
fileToken: "video-token-one",
text: "demo.mp4",
mimeType: "video/mp4",
size: 1024,
},
],
],
},
],
});
}
return fetchImpl(input, init);
};
const source = await readFeishuSource(
"https://tenant.feishu.cn/wiki/wiki-test",
bindings,
videoFetch,
);
assert.equal(source.rows.length, 1);
assert.equal(source.rows[0].title, "一条测试视频");
assert.equal(source.rows[0].body, "一条测试视频\n视频正文 #测试");
assert.deepEqual(source.rows[0].videos, [
{
index: 1,
fileToken: "video-token-one",
name: "demo.mp4",
mimeType: "video/mp4",
size: 1024,
},
]);
});
test("requires an exact sheet link when a workbook has multiple visible sheets", async () => {
clearFeishuAccessTokenCacheForTests();
const { fetchImpl } = fakeFeishu({

View File

@@ -1,7 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
collectMetricsFromMcp,
collectXhsMetricsFromMcp,
resolveAccountProfileFromMcp,
resolveCollectionMcpConfig,
resolveXhsPublicAccountDetails,
resolveXhsPublicAccountId,
@@ -40,10 +42,17 @@ function toolEnvelope(payload, isError = false) {
};
}
function createFakeMcp(toolResults) {
function createFakeMcp(toolResults, redirects = {}) {
let toolIndex = 0;
const calls = [];
const fetchImpl = async (url, init) => {
if (!init?.body) {
const requestUrl = String(url);
calls.push({ url: requestUrl, body: null, headers: new Headers(init?.headers) });
const location = redirects[requestUrl];
if (!location) throw new Error(`Unexpected public request: ${requestUrl}`);
return new Response("", { status: 302, headers: { location } });
}
const body = JSON.parse(init.body);
calls.push({ url: String(url), body, headers: new Headers(init.headers) });
if (body.method === "initialize") {
@@ -103,6 +112,7 @@ test("collects likes, comments and favorites from the verified MCP shape", async
likes: 483,
comments: 41,
collects: 519,
shares: 0,
});
assert.equal(calls.length, 3);
assert.equal(calls[2].body.params.name, "fetch_content_detail");
@@ -147,7 +157,7 @@ test("collects through a stateless MCP server without a session header", async (
fetchImpl,
);
assert.deepEqual(result, { likes: 12, comments: 3, collects: 6 });
assert.deepEqual(result, { likes: 12, comments: 3, collects: 6, shares: 0 });
assert.deepEqual(
calls.map((call) => call.body.method),
["initialize", "tools/call"],
@@ -217,19 +227,24 @@ test("resolves the real XHS account profile from a submitted note link", async (
redId: "94329495984",
ipLocation: "重庆",
followers: 734,
gender: "",
bio: "",
recentNoteTitles: [],
providerTags: [],
});
assert.equal(calls.length, 4);
const mcpCalls = calls.filter((call) => call.body?.method);
assert.equal(mcpCalls.length, 4);
assert.equal(
calls[2].body.params.name,
"collect_xhs_wen_note_detail",
mcpCalls[2].body.params.name,
"fetch_content_detail",
);
assert.equal(
calls[2].body.params.arguments.request.note_id,
"6a671108000000000f004bef",
mcpCalls[2].body.params.arguments.request.link,
"https://www.xiaohongshu.com/discovery/item/6a671108000000000f004bef",
);
assert.equal(calls[3].body.params.name, "parse_xhs_user_summary");
assert.equal(mcpCalls[3].body.params.name, "parse_xhs_user_summary");
assert.equal(
calls[3].body.params.arguments.request.url,
mcpCalls[3].body.params.arguments.request.url,
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
);
assert.equal(
@@ -253,7 +268,17 @@ test("resolves followers directly from the supported XHS user summary tool", asy
ipLocation: "福建",
nickname: "555 五",
userId: "1020668113",
gender: "女",
desc: "分享城市周末与美食",
tags: ["本地生活"],
},
notes: [
{
note_id: "note-1",
title: "长沙湘菜探店",
url: "https://www.xiaohongshu.com/explore/note-1",
},
],
},
},
}),
@@ -273,6 +298,10 @@ test("resolves followers directly from the supported XHS user summary tool", asy
followers: 6,
redId: "1020668113",
ipLocation: "福建",
gender: "女",
bio: "分享城市周末与美食",
recentNoteTitles: ["长沙湘菜探店"],
providerTags: ["本地生活"],
});
assert.equal(calls[2].body.params.name, "parse_xhs_user_summary");
});
@@ -338,8 +367,8 @@ test("resolves an xhslink short URL before requesting the author profile", async
"https://www.xiaohongshu.com/user/profile/6905cbca0000000037009f49",
);
assert.equal(
fakeMcp.calls[2].body.params.arguments.request.note_id,
"6a572da40000000021018bd2",
fakeMcp.calls[2].body.params.arguments.request.link,
"http://xhslink.cn/o/AJFyP5dnj7O",
);
});
@@ -400,10 +429,14 @@ test("uses the public note author when MCP profile lookup fails", async () => {
redId: "1020668113",
ipLocation: "待识别",
followers: 6,
gender: "",
bio: "",
recentNoteTitles: [],
providerTags: [],
});
assert.equal(
fakeMcp.calls[2].body.params.name,
"collect_xhs_wen_note_detail",
"fetch_content_detail",
);
});
@@ -431,27 +464,16 @@ test("reads the user-visible Xiaohongshu number from a public profile", async ()
});
});
test("falls back to parse_xhs_note when the primary tool fails", async () => {
test("treats empty interaction counters from the current detail tool as zero", async () => {
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope(
{
response: {
code: 400,
success: false,
msg: "获取内容详情失败",
data: null,
},
},
true,
),
toolEnvelope({
response: {
code: 200,
success: true,
data: {
likes: "1.2万",
comments: 32,
collects: "2,345",
likes: "2",
comments: "",
collects: "",
},
},
}),
@@ -466,11 +488,42 @@ test("falls back to parse_xhs_note when the primary tool fails", async () => {
);
assert.deepEqual(result, {
likes: 12_000,
comments: 32,
collects: 2_345,
likes: 2,
comments: 0,
collects: 0,
shares: 0,
});
assert.equal(calls[3].body.params.name, "parse_xhs_note");
assert.equal(calls.length, 3);
assert.equal(calls[2].body.params.name, "fetch_content_detail");
});
test("surfaces current detail tool failures without calling removed tools", async () => {
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope(
{
response: {
code: 400,
success: false,
msg: "获取内容详情失败",
data: null,
},
},
true,
),
]);
await assert.rejects(
collectXhsMetricsFromMcp(
"https://www.xiaohongshu.com/explore/test",
{
endpoint: "https://collector.example/mcp?key=test-key",
},
fetchImpl,
),
/获取内容详情失败/,
);
assert.equal(calls.length, 3);
assert.equal(calls[2].body.params.name, "fetch_content_detail");
});
test("requires the MCP key without sending a network request", async () => {
@@ -551,6 +604,237 @@ test("rebuilds the MCP session after a gateway session miss", async () => {
fetchImpl,
);
assert.deepEqual(result, { likes: 8, comments: 2, collects: 5 });
assert.deepEqual(result, { likes: 8, comments: 2, collects: 5, shares: 0 });
assert.equal(initializeCount, 2);
});
test("collects Douyin likes, favorites, shares and comments", async () => {
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope({
response: {
code: 200,
success: true,
data: {
digg_count: "120",
collect_count: "30",
share_count: "8",
comment_count: "12",
},
},
}),
]);
const result = await collectMetricsFromMcp(
"https://www.douyin.com/video/7520000000000000000",
"抖音",
{ endpoint: "https://collector.example/mcp", key: "test-key" },
fetchImpl,
);
assert.deepEqual(result, {
likes: 120,
collects: 30,
shares: 8,
comments: 12,
});
assert.equal(calls[2].body.params.name, "fetch_content_detail");
assert.equal(calls[2].body.params.arguments.request.plant, "dy");
});
test("maps the current Douyin MCP metric field names", async () => {
const { fetchImpl } = createFakeMcp([
toolEnvelope({
response: {
code: 200,
success: true,
data: {
liked_count: "5682",
collected_count: "565",
share_count: "6878",
comment_count: "332",
},
},
}),
]);
const result = await collectMetricsFromMcp(
"https://v.douyin.com/5O5VpgomO2U/",
"抖音",
{ endpoint: "https://collector.example/mcp", key: "test-key" },
fetchImpl,
);
assert.deepEqual(result, {
likes: 5682,
collects: 565,
shares: 6878,
comments: 332,
});
});
test("resolves a Douyin account from a submitted work link", async () => {
const secUid = "MS4wLjABAAAA-test-profile-123456";
const { calls, fetchImpl } = createFakeMcp([
toolEnvelope({
response: {
code: 200,
success: true,
data: {
author: {
nickname: "抖音作者",
sec_uid: secUid,
unique_id: "douyin-123",
},
},
},
}),
toolEnvelope({
response: {
code: 200,
success: true,
data: {
user: {
nickname: "抖音作者",
unique_id: "douyin-123",
sec_uid: secUid,
follower_count: "1.5万",
ip_location: "上海",
},
},
},
}),
]);
const profile = await resolveAccountProfileFromMcp(
"https://www.douyin.com/video/7520000000000000000",
"抖音",
"回填昵称",
{ endpoint: "https://collector.example/mcp", key: "test-key" },
fetchImpl,
);
assert.deepEqual(profile, {
platformUid: secUid,
nickname: "抖音作者",
profileUrl: `https://www.douyin.com/user/${secUid}`,
redId: "douyin-123",
ipLocation: "上海",
followers: 15_000,
gender: "",
bio: "",
recentNoteTitles: [],
providerTags: [],
});
assert.equal(calls[2].body.params.arguments.request.plant, "dy");
assert.equal(calls[3].body.params.name, "parse_dy_user_summary");
});
test("does not treat a Douyin short-link device id as the author sec_uid", async () => {
const shortLink = "https://v.douyin.com/5O5VpgomO2U/";
const { calls, fetchImpl } = createFakeMcp(
[
toolEnvelope({
response: {
code: 200,
success: true,
data: {
nickname: "搞怪噜噜😜",
user_id: "4065311277723529",
},
},
}),
],
{
[shortLink]: "https://www.iesdouyin.com/share/video/7671270545631842038/?did=MS4wLjABAAAA-device-token&with_sec_did=1",
},
);
const profile = await resolveAccountProfileFromMcp(
shortLink,
"抖音",
"回填昵称",
{ endpoint: "https://collector.example/mcp", key: "test-key" },
fetchImpl,
);
assert.deepEqual(profile, {
platformUid: "4065311277723529",
nickname: "搞怪噜噜😜",
profileUrl: "",
redId: "",
ipLocation: "待识别",
followers: null,
gender: "",
bio: "",
recentNoteTitles: [],
providerTags: [],
});
assert.equal(calls.find((call) => call.body === null)?.url, shortLink);
assert.equal(
calls.find((call) => call.body?.params?.name === "parse_dy_user_summary"),
undefined,
);
});
test("resolves a Douyin profile only when the public redirect exposes sec_uid", async () => {
const shortLink = "https://v.douyin.com/author-sec-uid/";
const secUid = "MS4wLjABAAAAOqL4Jdu8htr7EWCDAyIr5z_7uvCAxhj-GOzCWg5zn8bDiKOp3WPw7lWkTyHvZpMY";
const { calls, fetchImpl } = createFakeMcp(
[
toolEnvelope({
response: {
code: 200,
success: true,
data: {
nickname: "抖音作者",
user_id: "4065311277723529",
},
},
}),
toolEnvelope({
response: {
code: 200,
success: true,
data: {
user: {
nickname: "抖音作者",
unique_id: "douyin-987",
sec_uid: secUid,
follower_count: "2.3万",
ip_location: "广东",
},
},
},
}),
],
{
[shortLink]: `https://www.iesdouyin.com/share/video/7671270545631842038/?sec_uid=${secUid}`,
},
);
const profile = await resolveAccountProfileFromMcp(
shortLink,
"抖音",
"回填昵称",
{ endpoint: "https://collector.example/mcp", key: "test-key" },
fetchImpl,
);
assert.deepEqual(profile, {
platformUid: secUid,
nickname: "抖音作者",
profileUrl: `https://www.douyin.com/user/${secUid}`,
redId: "douyin-987",
ipLocation: "广东",
followers: 23_000,
gender: "",
bio: "",
recentNoteTitles: [],
providerTags: [],
});
assert.equal(
calls.find((call) => call.body?.params?.name === "parse_dy_user_summary")
?.body.params.arguments.request.url,
`https://www.douyin.com/user/${secUid}`,
);
});

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import { strToU8, unzipSync, zipSync } from "fflate";
import { compactPartnerBatchWorkbookForUpload } from "../koc-portal/app/batch-workbook-upload.ts";
const imageBytes = (marker, size) => {
const bytes = new Uint8Array(size);
bytes.set([0x89, 0x50, 0x4e, 0x47, marker]);
for (let index = 5; index < bytes.length; index += 1) bytes[index] = marker;
return bytes;
};
test("slims oversized WPS workbooks without removing backfill screenshots", () => {
const worksheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="inlineStr"><is><t>序号(不能改)</t></is></c>
<c r="D1" t="inlineStr"><is><t>图片1</t></is></c>
<c r="F1" t="inlineStr"><is><t>笔记截图</t></is></c>
<c r="G1" t="inlineStr"><is><t>数据分析截图(单篇笔记数据分析截图)</t></is></c>
</row>
<row r="2">
<c r="D2" t="str"><f>_xlfn.DISPIMG(&quot;SOURCE&quot;,1)</f><v>=DISPIMG(&quot;SOURCE&quot;,1)</v></c>
<c r="F2" t="str"><f>_xlfn.DISPIMG(&quot;PUBLISH&quot;,1)</f><v>=DISPIMG(&quot;PUBLISH&quot;,1)</v></c>
</row>
</sheetData>
</worksheet>`;
const cellImages = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<etc:cellImages xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="1" name="SOURCE"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId1"/></xdr:blipFill></xdr:pic></etc:cellImage>
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="2" name="PUBLISH"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId2"/></xdr:blipFill></xdr:pic></etc:cellImage>
</etc:cellImages>`;
const relationships = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/source.png"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/publish.png"/>
</Relationships>`;
const workbook = zipSync(
{
"xl/worksheets/sheet1.xml": strToU8(worksheet),
"xl/cellimages.xml": strToU8(cellImages),
"xl/_rels/cellimages.xml.rels": strToU8(relationships),
"xl/media/source.png": [imageBytes(1, 2_000_000), { level: 0 }],
"xl/media/publish.png": [imageBytes(2, 2_000), { level: 0 }],
},
{ level: 0 },
);
const compacted = compactPartnerBatchWorkbookForUpload(workbook);
const entries = unzipSync(compacted.bytes);
assert.equal(entries["xl/media/source.png"], undefined);
assert.deepEqual(entries["xl/media/publish.png"], imageBytes(2, 2_000));
assert.equal(compacted.removedMediaCount, 1);
assert.equal(compacted.preservedScreenshotCount, 1);
assert.ok(compacted.bytes.byteLength < workbook.byteLength / 10);
});

View File

@@ -0,0 +1,327 @@
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 {
PARTNER_BATCH_HEADERS,
buildPartnerBatchWorkbookColumns,
parsePartnerBatchWorkbook,
resolvePartnerWorkbookOrigin,
} from "../lib/partner-batch-workbook.ts";
import { hasMp4FileSignature } from "../lib/video-file.ts";
const png = Uint8Array.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
]);
test("round-trips hidden assignment IDs and embedded backfill screenshots", () => {
const workbook = buildRecoveryWorkbook({
sheetName: "批量回填",
headers: [...PARTNER_BATCH_HEADERS],
columnWidths: [10, 20, 40, 30, 40, 24, 28, 20, 20, 20],
hiddenColumns: [7, 8, 9],
rows: [
{
cells: [
1,
"测试笔记",
"正文 #话题",
"",
"https://www.xiaohongshu.com/explore/1234567890abcdef",
"",
"",
"distribution-1",
"",
"",
],
images: [
{
column: 3,
image: { bytes: png, contentType: "image/png", description: "原图" },
},
{
column: 5,
image: { bytes: png, contentType: "image/png", description: "笔记截图" },
},
{
column: 6,
image: { bytes: png, contentType: "image/png", description: "数据截图" },
},
],
},
],
});
const rows = parsePartnerBatchWorkbook(workbook);
assert.equal(rows.length, 1);
assert.equal(rows[0].distributionId, "distribution-1");
assert.equal(rows[0].title, "测试笔记");
assert.match(rows[0].publishUrl, /xiaohongshu\.com/);
assert.equal(rows[0].publishScreenshot?.contentType, "image/png");
assert.equal(rows[0].creatorScreenshot?.contentType, "image/png");
const entries = unzipSync(workbook);
const worksheet = strFromU8(entries["xl/worksheets/sheet1.xml"]);
assert.match(worksheet, /序号(不能改)/);
assert.doesNotMatch(worksheet, /张原图(见图)|已回填(见图)|请插入笔记截图/);
assert.match(worksheet, /<drawing r:id="rId1"\/>/);
assert.doesNotMatch(worksheet, /#VALUE!/);
const drawing = strFromU8(entries["xl/drawings/drawing1.xml"]);
assert.equal((drawing.match(/<xdr:twoCellAnchor editAs="twoCell">/g) ?? []).length, 3);
assert.match(drawing, /<xdr:col>3<\/xdr:col>/);
assert.match(drawing, /<xdr:col>5<\/xdr:col>/);
assert.match(drawing, /<xdr:col>6<\/xdr:col>/);
assert.match(worksheet, /min="8" max="8"[^>]*hidden="1"/);
});
test("accepts the legacy sequence header for previously exported workbooks", () => {
const legacyHeaders = [...PARTNER_BATCH_HEADERS];
legacyHeaders[0] = "序号";
const workbook = buildRecoveryWorkbook({
sheetName: "批量回填",
headers: legacyHeaders,
columnWidths: [10, 20, 40, 30, 40, 24, 28, 20, 20, 20],
hiddenColumns: [7, 8, 9],
rows: [
{
cells: [
1,
"旧模板笔记",
"正文",
"",
"",
"",
"",
"distribution-legacy",
"",
"",
],
images: [],
},
],
});
const rows = parsePartnerBatchWorkbook(workbook);
assert.equal(rows[0].sequence, "1");
assert.equal(rows[0].distributionId, "distribution-legacy");
});
test("imports dynamic source image columns without confusing screenshot columns", () => {
const headers = [
"序号(不能改)",
"标题",
"笔记内容(正文+话题)",
"图片1",
"图片2",
"发布链接",
"笔记截图",
"数据分析截图(单篇笔记数据分析截图)",
"_系统笔记ID",
"_原笔记截图",
"_原数据分析截图",
];
const workbook = buildRecoveryWorkbook({
sheetName: "批量回填",
headers,
columnWidths: headers.map(() => 20),
hiddenColumns: [8, 9, 10],
rows: [
{
cells: [
1,
"多图笔记",
"正文",
"",
"",
"https://www.xiaohongshu.com/explore/dynamic",
"",
"",
"distribution-dynamic",
"",
"",
],
images: [
{
column: 3,
image: { bytes: png, contentType: "image/png", description: "原图1" },
},
{
column: 4,
image: { bytes: png, contentType: "image/png", description: "原图2" },
},
{
column: 6,
image: { bytes: png, contentType: "image/png", description: "笔记截图" },
},
{
column: 7,
image: { bytes: png, contentType: "image/png", description: "数据截图" },
},
],
},
],
});
const rows = parsePartnerBatchWorkbook(workbook);
assert.equal(rows[0].distributionId, "distribution-dynamic");
assert.equal(rows[0].publishScreenshot?.contentType, "image/png");
assert.equal(rows[0].creatorScreenshot?.contentType, "image/png");
});
test("imports screenshots saved by WPS as DISPIMG cell images", () => {
const sourceImage = Uint8Array.from([...png, 1]);
const publishScreenshot = Uint8Array.from([...png, 2]);
const creatorScreenshot = Uint8Array.from([...png, 3]);
const worksheet = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">
<c r="A1" t="inlineStr"><is><t>序号(不能改)</t></is></c>
<c r="B1" t="inlineStr"><is><t>标题</t></is></c>
<c r="C1" t="inlineStr"><is><t>笔记内容(正文+话题)</t></is></c>
<c r="D1" t="inlineStr"><is><t>图片1</t></is></c>
<c r="E1" t="inlineStr"><is><t>发布链接</t></is></c>
<c r="F1" t="inlineStr"><is><t>笔记截图</t></is></c>
<c r="G1" t="inlineStr"><is><t>数据分析截图(单篇笔记数据分析截图)</t></is></c>
<c r="H1" t="inlineStr"><is><t>_系统笔记ID</t></is></c>
<c r="I1" t="inlineStr"><is><t>_原笔记截图</t></is></c>
<c r="J1" t="inlineStr"><is><t>_原数据分析截图</t></is></c>
</row>
<row r="2">
<c r="A2"><v>1</v></c>
<c r="B2" t="inlineStr"><is><t>WPS 笔记</t></is></c>
<c r="D2" t="str"><f>_xlfn.DISPIMG(&quot;SOURCE&quot;,1)</f><v>=DISPIMG(&quot;SOURCE&quot;,1)</v></c>
<c r="E2" t="inlineStr"><is><t>https://www.xiaohongshu.com/explore/wps</t></is></c>
<c r="F2" t="str"><f>_xlfn.DISPIMG(&quot;PUBLISH&quot;,1)</f><v>=DISPIMG(&quot;PUBLISH&quot;,1)</v></c>
<c r="G2" t="str"><f>_xlfn.DISPIMG(&quot;CREATOR&quot;,1)</f><v>=DISPIMG(&quot;CREATOR&quot;,1)</v></c>
<c r="H2" t="inlineStr"><is><t>distribution-wps</t></is></c>
</row>
</sheetData>
</worksheet>`;
const cellImages = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<etc:cellImages xmlns:etc="http://www.wps.cn/officeDocument/2017/etCustomData" xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="1" name="SOURCE"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId1"/></xdr:blipFill></xdr:pic></etc:cellImage>
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="2" name="PUBLISH"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId2"/></xdr:blipFill></xdr:pic></etc:cellImage>
<etc:cellImage><xdr:pic><xdr:nvPicPr><xdr:cNvPr id="3" name="CREATOR"/></xdr:nvPicPr><xdr:blipFill><a:blip r:embed="rId3"/></xdr:blipFill></xdr:pic></etc:cellImage>
</etc:cellImages>`;
const relationships = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/source.png"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/publish.png"/>
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/creator.png"/>
</Relationships>`;
const workbook = zipSync({
"xl/worksheets/sheet1.xml": strToU8(worksheet),
"xl/cellimages.xml": strToU8(cellImages),
"xl/_rels/cellimages.xml.rels": strToU8(relationships),
"xl/media/source.png": sourceImage,
"xl/media/publish.png": publishScreenshot,
"xl/media/creator.png": creatorScreenshot,
});
const rows = parsePartnerBatchWorkbook(workbook);
assert.equal(rows.length, 1);
assert.equal(rows[0].distributionId, "distribution-wps");
assert.deepEqual(rows[0].publishScreenshot?.bytes, publishScreenshot);
assert.deepEqual(rows[0].creatorScreenshot?.bytes, creatorScreenshot);
});
test("builds video-task workbooks with video columns and no source image columns", () => {
const columns = buildPartnerBatchWorkbookColumns({
contentFormat: "video",
maxSourceImages: 3,
maxSourceVideos: 2,
});
assert.deepEqual(columns.headers.slice(0, 8), [
"序号(不能改)",
"标题",
"笔记内容(正文+话题)",
"视频1",
"视频2",
"发布链接",
"笔记截图",
"数据分析截图(单篇笔记数据分析截图)",
]);
assert.equal(columns.headers.some((header) => /^图片\d+$/.test(header)), false);
const workbook = buildRecoveryWorkbook({
sheetName: "视频批量回填",
headers: columns.headers,
columnWidths: columns.columnWidths,
hiddenColumns: [
columns.systemColumn,
columns.systemColumn + 1,
columns.systemColumn + 2,
],
rows: [
{
cells: [
1,
"视频笔记",
"视频正文 #测试",
"下载视频1",
"下载视频2",
"",
"",
"",
"distribution-video",
"",
"",
],
images: [],
hyperlinks: [
{
column: columns.sourceVideoStartColumn,
url: "https://koc.example.com/api/partner-image?kind=video&download=1",
},
],
},
],
});
const entries = unzipSync(workbook);
const worksheet = strFromU8(entries["xl/worksheets/sheet1.xml"]);
const relationships = strFromU8(
entries["xl/worksheets/_rels/sheet1.xml.rels"],
);
assert.match(worksheet, /视频1/);
assert.doesNotMatch(worksheet, /图片1/);
assert.match(relationships, /https:\/\/koc\.example\.com\/api\/partner-image/);
assert.match(relationships, /download=1/);
});
test("uses the configured public origin before proxy or container addresses", () => {
const request = new Request("http://app:3000/api/partner-batch-workbook", {
headers: {
host: "app:3000",
"x-forwarded-host": "internal-proxy:8080",
"x-forwarded-proto": "https",
},
});
assert.equal(
resolvePartnerWorkbookOrigin(request, "https://koc.example.com/koc/"),
"https://koc.example.com",
);
});
test("preserves a forwarded non-standard port when no origin is configured", () => {
const request = new Request("http://app:3000/api/partner-batch-workbook", {
headers: {
host: "app:3000",
"x-forwarded-host": "localhost:8080",
"x-forwarded-proto": "http",
},
});
assert.equal(resolvePartnerWorkbookOrigin(request), "http://localhost:8080");
});
test("recognizes MP4 bytes instead of trusting a response content type", () => {
const mp4Header = Uint8Array.from([
0x00, 0x00, 0x00, 0x18,
0x66, 0x74, 0x79, 0x70,
0x69, 0x73, 0x6f, 0x6d,
0x00, 0x00, 0x02, 0x00,
0x69, 0x73, 0x6f, 0x6d,
0x6d, 0x70, 0x34, 0x32,
]);
assert.equal(hasMp4FileSignature(mp4Header), true);
assert.equal(hasMp4FileSignature(new TextEncoder().encode("not a video")), false);
});

View File

@@ -16,7 +16,7 @@ test("creates an xlsx archive whose pictures are embedded in worksheet cells", (
columnWidths: [8, 24, 24, 24],
rows: [
{
cells: [1, "测试笔记", "见图", "见图"],
cells: [1, "测试笔记", "", ""],
images: [
{
column: 2,
@@ -43,12 +43,24 @@ test("creates an xlsx archive whose pictures are embedded in worksheet cells", (
],
});
const archive = unzipSync(workbook);
const worksheet = strFromU8(archive["xl/worksheets/sheet1.xml"]);
assert.ok(archive["xl/media/image1.png"]);
assert.ok(archive["xl/media/image2.png"]);
assert.match(strFromU8(archive["xl/worksheets/sheet1.xml"]), /<drawing r:id="rId1"\/>/);
assert.match(strFromU8(archive["xl/drawings/drawing1.xml"]), /oneCellAnchor/);
assert.match(strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]), /image2\.png/);
assert.match(worksheet, /<drawing r:id="rId1"\/>/);
assert.doesNotMatch(worksheet, /#VALUE!/);
const drawing = strFromU8(archive["xl/drawings/drawing1.xml"]);
assert.equal((drawing.match(/<xdr:twoCellAnchor editAs="twoCell">/g) ?? []).length, 2);
assert.match(drawing, /<xdr:col>2<\/xdr:col>/);
assert.match(drawing, /<xdr:col>3<\/xdr:col>/);
assert.match(
strFromU8(archive["xl/drawings/_rels/drawing1.xml.rels"]),
/image2\.png/,
);
assert.doesNotMatch(
worksheet,
/见图/,
);
});
test("creates clickable external hyperlinks for resource exports", () => {

View File

@@ -23,6 +23,30 @@ test("builds the KOC LOOP product shell", async () => {
await access(new URL("../.next/static", import.meta.url));
});
test("keeps distribution filters as always-visible fuzzy search fields", async () => {
const [adminApp, globalCss] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
]);
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*flex-direction:\s*row/s);
assert.match(globalCss, /\.distribution-task-search\s*\{[^}]*margin:\s*0/s);
assert.match(globalCss, /\.distribution-task-search\s*>\s*span\s*\{[^}]*flex:\s*0\s+0\s+18px/s);
assert.match(globalCss, /\.distribution-task-filter-combobox\.brand\s*\{[^}]*flex-basis:\s*176px/s);
assert.match(globalCss, /\.distribution-task-filter-menu\s*\{[^}]*position:\s*absolute[^}]*z-index:\s*60/s);
assert.match(globalCss, /\.distribution-task-filter-input\s*\{[^}]*display:\s*flex[^}]*margin:\s*0/s);
assert.match(adminApp, /aria-expanded=\{openTaskFilter === filter\.key\}/);
assert.match(adminApp, /role="combobox"/);
assert.match(adminApp, /role="listbox"/);
assert.match(adminApp, /placeholder=\{`搜索\$\{filter\.label\}`\}/);
assert.match(adminApp, /setTaskFilterValue\(filter\.key, event\.target\.value\)/);
assert.doesNotMatch(adminApp, /distribution-task-filter-trigger/);
assert.doesNotMatch(adminApp, /distribution-task-filter-menu-search/);
assert.doesNotMatch(adminApp, /taskFilterQuery/);
assert.doesNotMatch(adminApp, /distribution-task-option-panel/);
assert.doesNotMatch(adminApp, /<select value=\{contentTypeFilter\}/);
});
test("stacks user management and securely removes departed accounts", async () => {
const [usersPage, usersRoute, globalCss] = await Promise.all([
readFile(new URL("../app/users-page.tsx", import.meta.url), "utf8"),
@@ -137,8 +161,8 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(partnerRoute, /claimantIdentifier\.canonical/);
assert.match(partnerRoute, /legacyPartnerId/);
assert.match(partnerRoute, /微信号或手机号/);
assert.match(partnerRoute, /extractXhsPublishUrl/);
assert.match(partnerRoute, /小红书长链或短链/);
assert.match(partnerRoute, /extractPublishUrl/);
assert.match(partnerRoute, /task\.platform/);
assert.match(partnerRoute, /请填写发布链接/);
assert.doesNotMatch(partnerRoute, /请填写发布账号昵称和发布链接/);
assert.match(partnerRoute, /没有找到领取记录/);
@@ -147,11 +171,16 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(partnerRoute, /enrichDistributionAccount/);
assert.match(partnerRoute, /runInBackground\(enrichment/);
assert.match(accountEnrichment, /profile_url = excluded\.profile_url/);
assert.match(accountEnrichment, /resolveXhsAccountProfileFromMcp/);
assert.match(accountEnrichment, /resolveXhsProfileDetailsFromMcp/);
assert.match(accountEnrichment, /resolveAccountProfileFromMcp/);
assert.match(accountEnrichment, /resolveProfileDetailsFromMcp/);
assert.match(accountEnrichment, /isVerifiedXhsProfileUrl/);
assert.match(accountEnrichment, /endsWith\("\.xiaohongshu\.com"\)/);
assert.match(accountEnrichment, /DELETE FROM accounts/);
assert.match(accountEnrichment, /WHERE platform = \? AND platform_uid = \?/);
assert.match(accountEnrichment, /existingAccount\?\.id \|\| canonicalAccountId/);
assert.match(accountEnrichment, /cl\.claimant_name AS claimant_contact/);
assert.match(accountEnrichment, /current_contact = CASE/);
assert.match(accountEnrichment, /!row\.resolved_account_id/);
assert.match(accountEnrichment, /a\.followers/);
assert.match(accountEnrichment, /followers = CASE/);
assert.match(accountEnrichment, /Number\(row\.followers \?\? 0\) === 0/);
@@ -171,6 +200,9 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(imageRoute, /creator-center\//);
assert.match(imageRoute, /imageKind === "publish"/);
assert.match(imageRoute, /imageKind === "creator"/);
assert.match(imageRoute, /Content-Type", "video\/mp4"/);
assert.match(imageRoute, /video-\$\{imageIndex\}\.mp4/);
assert.match(imageRoute, /downloadRequested \? "attachment" : "inline"/);
assert.match(imageRoute, /cl\.claim_token/);
assert.match(imageUploadRoute, /isAdminRequest/);
assert.match(cors, /KOC_PORTAL_URL/);
@@ -178,6 +210,17 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(cors, /X-KOC-Upload-Kind/);
assert.match(cors, /Access-Control-Allow-Origin/);
assert.match(adminApp, /hasCreatorMetrics/);
assert.match(adminApp, /function PlatformBadge/);
assert.match(adminApp, /function resourceProfileLink/);
assert.match(adminApp, /搜索主页/);
assert.match(adminApp, /latest_publish_url/);
assert.match(adminApp, /通过作品查看主页/);
assert.match(adminApp, /platform-logo/);
assert.match(adminApp, /按任务名称模糊搜索/);
assert.match(adminApp, /全部品牌\/项目/);
assert.match(adminApp, /全部内容类型/);
assert.match(adminApp, /全部平台/);
assert.match(adminApp, /task-scope-subline/);
assert.match(adminApp, /待KOC填写数据/);
assert.match(adminApp, /AdminImageLightbox/);
assert.match(adminApp, /CreatorScreenshotPreview/);
@@ -195,6 +238,43 @@ test("issues external task links and supports one-to-one note submissions", asyn
assert.match(migration, /claim_token/);
});
test("exports and imports claim-bound Excel backfill workbooks", async () => {
const [route, parser, workbook, imageNormalizer, nginx] = await Promise.all([
readFile(new URL("../app/api/partner-batch-workbook/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/partner-batch-workbook.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/recovery-workbook.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/workbook-image.ts", import.meta.url), "utf8"),
readFile(new URL("../deploy/nginx/koc-loop.conf", import.meta.url), "utf8"),
]);
assert.match(route, /批量回填/);
assert.match(route, /publish-evidence/);
assert.match(route, /creator-center/);
assert.match(route, /extractPublishUrl/);
assert.match(route, /findAccess/);
assert.match(route, /isDifferentFromStoredImage/);
assert.match(route, /rowIndex \+ 1/);
assert.match(route, /buildPartnerBatchWorkbookColumns/);
assert.match(route, /resolvePartnerWorkbookOrigin/);
assert.match(route, /download: "1"/);
assert.match(route, /columns\.sourceImageStartColumn \+ index/);
assert.match(parser, /_系统笔记ID/);
assert.match(parser, /笔记截图/);
assert.match(parser, /数据分析截图(单篇笔记数据分析截图)/);
assert.match(parser, /parseImages/);
assert.match(workbook, /hiddenColumns/);
assert.match(workbook, /offsetX/);
assert.doesNotMatch(workbook, /value \|\| "见图"/);
assert.match(imageNormalizer, /\.rotate\(\)/);
assert.match(imageNormalizer, /\.png\(/);
assert.match(nginx, /client_max_body_size 85m/);
assert.equal((nginx.match(/proxy_set_header Host \$http_host;/g) ?? []).length, 2);
assert.equal(
(nginx.match(/proxy_set_header X-Forwarded-Host \$http_host;/g) ?? []).length,
2,
);
});
test("supports task collection schedules and latest public metrics", async () => {
const [
adminApp,
@@ -206,6 +286,7 @@ test("supports task collection schedules and latest public metrics", async () =>
migration,
accountMigration,
compose,
mcpClient,
] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/api/action/route.ts", import.meta.url), "utf8"),
@@ -216,6 +297,7 @@ test("supports task collection schedules and latest public metrics", async () =>
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
readFile(new URL("../mysql/0001_init.sql", import.meta.url), "utf8"),
readFile(new URL("../docker-compose.self-hosted.yml", import.meta.url), "utf8"),
readFile(new URL("../lib/mcp-collection-client.ts", import.meta.url), "utf8"),
]);
for (const label of [
@@ -238,12 +320,19 @@ test("supports task collection schedules and latest public metrics", async () =>
assert.match(adminApp, /sortWithNullsLast/);
assert.match(adminApp, /内容 \/ 发布账号/);
assert.match(adminApp, /recovery-title-link/);
assert.match(adminApp, /打开小红书笔记/);
assert.match(adminApp, /打开\$\{selectedTask\.platform\}作品/);
assert.match(adminApp, /target="_blank"/);
assert.match(adminApp, /noopener noreferrer/);
assert.match(adminApp, /const noteUrl = xhsPublishUrl\(item\.publish_url\)/);
assert.match(adminApp, /const noteUrl = publicPublishUrl\(item\.publish_url\)/);
assert.match(adminApp, /noteUrl \? \(/);
assert.match(adminApp, /updateDistributionPublishUrl/);
assert.match(adminApp, /填写链接/);
assert.match(adminApp, /更新链接/);
assert.match(adminApp, /hostname === "xhslink\.cn"/);
assert.match(actionRoute, /update_distribution_publish_url/);
assert.match(actionRoute, /extractPublishUrl/);
assert.match(actionRoute, /DELETE FROM collection_runs WHERE distribution_id = \?/);
assert.match(actionRoute, /enrichDistributionAccount/);
assert.match(actionRoute, /save_collection_schedule/);
assert.match(actionRoute, /collect_now/);
assert.match(actionRoute, /backfill_account_profiles/);
@@ -253,7 +342,11 @@ test("supports task collection schedules and latest public metrics", async () =>
assert.match(actionRoute, /createCollectionRunTasks/);
assert.match(bootstrapRoute, /runDueScheduledCollections/);
assert.match(collectionService, /INSERT OR IGNORE INTO collection_runs/);
assert.match(collectionService, /collectXhsMetricsFromMcp/);
assert.match(
collectionService,
/run\.status === "success" && source !== "manual"/,
);
assert.match(collectionService, /collectMetricsFromMcp/);
assert.doesNotMatch(collectionService, /hashText/);
assert.match(collectionService, /runScheduledCollections/);
assert.match(collectionService, /runDueScheduledCollections/);
@@ -272,6 +365,10 @@ test("supports task collection schedules and latest public metrics", async () =>
assert.match(migration, /collection_runs_distribution_date_idx/);
assert.match(accountMigration, /public_account_id/);
assert.match(compose, /ENABLE_SCHEDULER/);
assert.match(mcpClient, /"fetch_content_detail"/);
assert.match(mcpClient, /"parse_xhs_user_summary"/);
assert.doesNotMatch(mcpClient, /"parse_xhs_note"/);
assert.doesNotMatch(mcpClient, /"collect_xhs_wen_note_detail"/);
});
test("supports fixed screenshot collection tasks without publish metrics", async () => {
@@ -314,7 +411,7 @@ test("exports complete task recovery data to Excel with embedded images", async
assert.match(adminApp, /导出全部数据/);
assert.match(adminApp, /\/api\/recovery-export/);
assert.match(adminApp, /图片和截图已嵌入表格/);
assert.match(exportRoute, /小红书昵称/);
assert.match(exportRoute, /`\$\{task\.platform\}昵称`/);
assert.match(exportRoute, /曝光量-实际第7天/);
assert.match(exportRoute, /阅读量-实际第7天/);
assert.match(exportRoute, /publish_screenshot_key/);
@@ -323,8 +420,9 @@ test("exports complete task recovery data to Excel with embedded images", async
assert.match(exportRoute, /isAdminRequest/);
assert.match(exportRoute, /consumeMcpExportToken/);
assert.match(workbook, /xl\/drawings\/drawing1\.xml/);
assert.match(workbook, /twoCellAnchor editAs="twoCell"/);
assert.match(workbook, /xl\/media\/image/);
assert.match(workbook, /oneCellAnchor/);
assert.match(workbook, /relationships\/image/);
});
test("provides simple username-password login and three server-enforced roles", async () => {
@@ -393,6 +491,7 @@ test("filters and exports the current KOC resource result set", async () => {
]);
assert.match(adminApp, /搜索账号名称 \/ 账号ID/);
assert.match(adminApp, /当前联系人/);
assert.match(adminApp, /搜索IP地区/);
assert.match(adminApp, /搜索合作来源/);
assert.match(adminApp, /ipLocation\.includes\(ipKeyword\)/);
@@ -401,6 +500,7 @@ test("filters and exports the current KOC resource result set", async () => {
assert.match(adminApp, /filteredAccounts\.map\(\(item\) => item\.account\.id\)/);
assert.match(exportRoute, /小红书号\/抖音号/);
assert.match(exportRoute, /历史合作来源/);
assert.match(exportRoute, /当前联系人/);
assert.match(exportRoute, /合作社资源 · 不可直联/);
assert.match(exportRoute, /isManagerRequest/);
assert.match(exportRoute, /consumeMcpExportToken/);
@@ -408,21 +508,60 @@ test("filters and exports the current KOC resource result set", async () => {
});
test("imports existing KOC resources through a validated spreadsheet preview", async () => {
const [adminApp, importRoute, resourceParser, accountMigration] = await Promise.all([
const [adminApp, globalCss, importRoute, resourceParser, accountMigration, profileMigration, contactMigration] = await Promise.all([
readFile(new URL("../app/admin-app.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
readFile(new URL("../app/api/resources-import/route.ts", import.meta.url), "utf8"),
readFile(new URL("../lib/resource-import.ts", import.meta.url), "utf8"),
readFile(new URL("../mysql/0002_resource_import.sql", import.meta.url), "utf8"),
readFile(new URL("../mysql/0006_account_profile_tags.sql", import.meta.url), "utf8"),
readFile(new URL("../mysql/0007_account_current_contact.sql", import.meta.url), "utf8"),
]);
assert.match(adminApp, /下载导入模板/);
assert.match(adminApp, /校验并预览/);
assert.match(adminApp, /确认导入/);
assert.match(importRoute, /isManagerRequest/);
assert.match(importRoute, /mode !== "commit"/);
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 100/);
assert.match(resourceParser, /当前自动解析仅支持小红书账号主页/);
assert.match(importRoute, /resolveXhsProfileDetailsFromMcp/);
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_ROWS = 10_000/);
assert.match(resourceParser, /RESOURCE_IMPORT_MAX_BYTES = 20 \* 1024 \* 1024/);
assert.match(adminApp, /单次最多 10,000 个账号,文件不超过 20MB/);
assert.match(importRoute, /RESOURCE_IMPORT_DB_BATCH_SIZE = 100/);
assert.match(importRoute, /bulk resource profile enrichment/);
assert.match(adminApp, /异常数据将自动跳过,不会导入/);
assert.match(
adminApp,
/summary\.create \+ importPreview\.summary\.update === 0 \|\| importWorking/,
);
assert.doesNotMatch(
adminApp,
/disabled=\{importPreview\.summary\.error > 0 \|\| importWorking\}/,
);
assert.match(importRoute, /const importableRows = analyzed\.filter/);
assert.match(importRoute, /跳过 \$\{summary\.error\} 条异常数据/);
assert.match(importRoute, /previewAnalyzedRows\(analyzed\)/);
assert.match(resourceParser, /当前自动解析仅支持小红书或抖音账号主页/);
assert.match(importRoute, /resolveProfileDetailsFromMcp/);
assert.match(accountMigration, /cooperation_source/);
assert.match(profileMigration, /ADD COLUMN gender/);
assert.match(profileMigration, /ADD COLUMN bio/);
assert.match(profileMigration, /ADD COLUMN tags/);
assert.match(contactMigration, /ADD COLUMN current_contact/);
assert.match(adminApp, /gender-icon male/);
assert.match(adminApp, /gender-icon female/);
assert.match(adminApp, /resource-profile-avatar/);
assert.match(adminApp, /resource-account-number/);
assert.match(adminApp, /resource-platform-line/);
assert.match(adminApp, /resource-latest/);
assert.match(adminApp, /合作来源/);
assert.match(adminApp, /待打标/);
assert.doesNotMatch(adminApp, /className="verified-dot"/);
assert.match(globalCss, /\.resource-tags\s*\{[^}]*margin-bottom:\s*auto/s);
assert.match(globalCss, /\.resource-card-foot\s*\{[^}]*margin-top:\s*10px/s);
assert.match(adminApp, /resource-tags/);
assert.match(adminApp, /最多 5 个/);
assert.match(resourceParser, /gender: \["性别"\]/);
assert.match(resourceParser, /bio: \["简介"/);
assert.match(resourceParser, /tags: \["标签"/);
});
test("supports anonymous partner delegation without creating a second data flow", async () => {

View File

@@ -1,7 +1,9 @@
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,
@@ -10,6 +12,72 @@ import {
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 = [
"账号主页,合作来源",
@@ -26,6 +94,9 @@ test("parses CSV resources and normalizes public profile data", () => {
ipLocation: "",
followers: 0,
followersResolved: false,
gender: "",
bio: "",
tags: [],
cooperationSource: "林林KOC社群",
errors: [],
});
@@ -34,8 +105,8 @@ test("parses CSV resources and normalizes public profile data", () => {
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+,历史资源",
"账号链接,账号昵称,账号ID,IP属地,粉丝数,性别,简介,标签,合作来源",
"https://www.xiaohongshu.com/user/profile/abc123,番茄不炒蛋,4171542126,江西,10+,女,分享江西本地生活,本地生活、美食探店,历史资源",
].join("\n");
const [row] = parseResourceImportFile(
"resources.csv",
@@ -46,9 +117,36 @@ test("uses optional account fields directly and only requires the profile URL",
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,
@@ -86,11 +184,49 @@ test("parses the first matching worksheet from an XLSX workbook", () => {
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,社群";
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.match(rows[1].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", () => {
@@ -105,6 +241,18 @@ test("rejects invalid optional follower values without requiring other optional
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"),

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import sharp from "sharp";
import { normalizeWorkbookImage } from "../lib/workbook-image.ts";
test("bakes EXIF orientation into exported workbook image pixels", async () => {
const source = await sharp({
create: {
width: 8,
height: 4,
channels: 3,
background: "#e95420",
},
})
.jpeg()
.withMetadata({ orientation: 6 })
.toBuffer();
const normalized = await normalizeWorkbookImage({
bytes: new Uint8Array(source),
contentType: "image/jpeg",
width: 8,
height: 4,
description: "手机照片",
});
const metadata = await sharp(normalized.bytes).metadata();
assert.equal(normalized.contentType, "image/png");
assert.equal(normalized.width, 4);
assert.equal(normalized.height, 8);
assert.equal(metadata.width, 4);
assert.equal(metadata.height, 8);
assert.equal(metadata.orientation, undefined);
});
test("keeps unsupported image bytes unchanged", async () => {
const bytes = Uint8Array.from([1, 2, 3]);
const normalized = await normalizeWorkbookImage({
bytes,
contentType: "application/octet-stream",
description: "未知文件",
});
assert.equal(normalized.bytes, bytes);
assert.equal(normalized.contentType, "application/octet-stream");
});
test("normalizes recognizable images even when storage metadata has no image MIME type", async () => {
const source = await sharp({
create: {
width: 8,
height: 4,
channels: 3,
background: "#22c55e",
},
})
.jpeg()
.withMetadata({ orientation: 6 })
.toBuffer();
const normalized = await normalizeWorkbookImage({
bytes: new Uint8Array(source),
contentType: "application/octet-stream",
description: "方向元数据缺失测试",
});
assert.equal(normalized.contentType, "image/png");
assert.equal(normalized.width, 4);
assert.equal(normalized.height, 8);
});
test("can downsize full-resolution source images for compact workbook exports", async () => {
const source = await sharp({
create: {
width: 4000,
height: 3000,
channels: 3,
background: "#d4a72c",
},
})
.png({ compressionLevel: 0 })
.toBuffer();
const normalized = await normalizeWorkbookImage(
{
bytes: new Uint8Array(source),
contentType: "image/png",
width: 4000,
height: 3000,
description: "批量回填原图",
},
{ maxDimension: 1600, outputFormat: "jpeg", jpegQuality: 82 },
);
const metadata = await sharp(normalized.bytes).metadata();
assert.equal(normalized.contentType, "image/jpeg");
assert.equal(normalized.width, 1600);
assert.equal(normalized.height, 1200);
assert.equal(metadata.format, "jpeg");
assert.ok(normalized.bytes.byteLength < source.byteLength / 20);
});