feat: 完善视频任务与 KOC 资源库
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
|
||||
57
tests/partner-batch-upload.test.mjs
Normal file
57
tests/partner-batch-upload.test.mjs
Normal 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("SOURCE",1)</f><v>=DISPIMG("SOURCE",1)</v></c>
|
||||
<c r="F2" t="str"><f>_xlfn.DISPIMG("PUBLISH",1)</f><v>=DISPIMG("PUBLISH",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);
|
||||
});
|
||||
327
tests/partner-batch-workbook.test.mjs
Normal file
327
tests/partner-batch-workbook.test.mjs
Normal 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("SOURCE",1)</f><v>=DISPIMG("SOURCE",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("PUBLISH",1)</f><v>=DISPIMG("PUBLISH",1)</v></c>
|
||||
<c r="G2" t="str"><f>_xlfn.DISPIMG("CREATOR",1)</f><v>=DISPIMG("CREATOR",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);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
98
tests/workbook-image.test.mjs
Normal file
98
tests/workbook-image.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user