633 lines
18 KiB
JavaScript
633 lines
18 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
|
|
global.document = { cookie: "x=y" };
|
|
global.GM_xmlhttpRequest = undefined;
|
|
|
|
const api = require("./xhs-pgy-export.user.js");
|
|
|
|
function okJson(payload) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
async json() {
|
|
return payload;
|
|
},
|
|
};
|
|
}
|
|
|
|
test("data_summary request includes business=1", async () => {
|
|
const urls = [];
|
|
|
|
async function fetchImpl(url) {
|
|
urls.push(url);
|
|
if (String(url).startsWith(api.API_BASE)) {
|
|
return okJson({ data: { userId: "u-123" } });
|
|
}
|
|
return okJson({ data: {} });
|
|
}
|
|
|
|
await api.fetchMergedBloggerRecord("any-id", fetchImpl);
|
|
|
|
const target = urls.find((u) => String(u).includes("/v1/pugongying/data_summary"));
|
|
assert.ok(target, "expected a call to /v1/pugongying/data_summary");
|
|
|
|
const parsed = new URL(target);
|
|
assert.equal(parsed.searchParams.get("business"), "1");
|
|
assert.equal(parsed.searchParams.get("userId"), "u-123");
|
|
});
|
|
|
|
test("supplemental proxy requests include cookie query parameter", async () => {
|
|
const urls = [];
|
|
|
|
async function fetchImpl(url) {
|
|
urls.push(String(url));
|
|
if (String(url).startsWith(api.API_BASE)) {
|
|
return okJson({ data: { userId: "u-123" } });
|
|
}
|
|
return okJson({ data: {} });
|
|
}
|
|
|
|
await api.fetchMergedBloggerRecord("any-id", fetchImpl);
|
|
|
|
const proxyUrls = urls.filter((url) => url.includes("/v1/pugongying/"));
|
|
assert.equal(proxyUrls.length, 2);
|
|
for (const url of proxyUrls) {
|
|
const parsed = new URL(url);
|
|
assert.equal(parsed.searchParams.get("cookie"), "x=y");
|
|
}
|
|
});
|
|
|
|
test("extractBloggerId resolves xhs short link from SSR profile HTML", async () => {
|
|
const previousGm = global.GM_xmlhttpRequest;
|
|
global.GM_xmlhttpRequest = (options) => {
|
|
assert.equal(options.url, "https://xhslink.com/m/example");
|
|
options.onload({
|
|
status: 200,
|
|
finalUrl:
|
|
"https://www.xiaohongshu.com/user/profile/2022919XN?xsec_source=app_share",
|
|
responseText:
|
|
'<script>window.__INITIAL_STATE__={"profile":{"noteData":[{"user":{"id":"60379f3c000000000101e53f","nickname":"Alice"}}]}}</script>',
|
|
});
|
|
};
|
|
|
|
try {
|
|
await assert.doesNotReject(async () => {
|
|
assert.equal(
|
|
await api.extractBloggerId("https://xhslink.com/m/example"),
|
|
"60379f3c000000000101e53f",
|
|
);
|
|
});
|
|
} finally {
|
|
global.GM_xmlhttpRequest = previousGm;
|
|
}
|
|
});
|
|
|
|
test("buildFeishuSheetValues uses labels as header row", () => {
|
|
const records = [
|
|
{
|
|
flattened: {
|
|
userId: "u-1",
|
|
name: "达人 A",
|
|
fansCount: 123,
|
|
},
|
|
},
|
|
];
|
|
|
|
const values = api.buildFeishuSheetValues(records, ["userId", "name", "fansCount"]);
|
|
|
|
assert.deepEqual(values, [
|
|
["达人ID", "达人昵称", "粉丝数"],
|
|
["u-1", "达人 A", 123],
|
|
]);
|
|
});
|
|
|
|
test("buildFeishuRange expands columns past Z", () => {
|
|
assert.equal(api.buildFeishuRange("abc123", 1, 1), "abc123!A1:A1");
|
|
assert.equal(api.buildFeishuRange("abc123", 2, 28), "abc123!A1:AB2");
|
|
});
|
|
|
|
test("exportRecordsToFeishuSpreadsheet creates spreadsheet then writes values", async () => {
|
|
const calls = [];
|
|
|
|
async function fetchImpl(url, options) {
|
|
const body = options && options.body ? JSON.parse(options.body) : null;
|
|
calls.push({
|
|
url: String(url),
|
|
method: options && options.method,
|
|
headers: options && options.headers,
|
|
body,
|
|
});
|
|
|
|
if (String(url).includes("/auth/v3/tenant_access_token/internal")) {
|
|
return okJson({ code: 0, tenant_access_token: "tenant-token" });
|
|
}
|
|
if (String(url).includes("/sheets/v3/spreadsheets")) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
spreadsheet: {
|
|
spreadsheet_token: "spreadsheet-token",
|
|
url: "https://feishu.example/spreadsheet-token",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/metainfo")) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
sheets: [
|
|
{
|
|
sheetId: "sheet-a",
|
|
title: "Sheet1",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/values_batch_update")) {
|
|
return okJson({ code: 0, data: {} });
|
|
}
|
|
|
|
throw new Error(`unexpected url: ${url}`);
|
|
}
|
|
|
|
const result = await api.exportRecordsToFeishuSpreadsheet({
|
|
appId: "cli_xxx",
|
|
appSecret: "secret",
|
|
title: "测试导出",
|
|
records: [
|
|
{
|
|
flattened: {
|
|
userId: "u-1",
|
|
name: "达人 A",
|
|
},
|
|
},
|
|
],
|
|
fields: ["userId", "name"],
|
|
fetchImpl,
|
|
});
|
|
|
|
assert.equal(result.spreadsheetToken, "spreadsheet-token");
|
|
assert.equal(result.url, "https://feishu.example/spreadsheet-token");
|
|
assert.equal(result.sheetId, "sheet-a");
|
|
assert.equal(calls.length, 4);
|
|
assert.deepEqual(calls[0].body, {
|
|
app_id: "cli_xxx",
|
|
app_secret: "secret",
|
|
});
|
|
assert.equal(calls[1].body.title, "测试导出");
|
|
assert.equal(calls[2].method, "GET");
|
|
assert.equal(calls[3].headers.Authorization, "Bearer tenant-token");
|
|
assert.deepEqual(calls[3].body, {
|
|
valueRanges: [
|
|
{
|
|
range: "sheet-a!A1:B2",
|
|
values: [
|
|
["达人ID", "达人昵称"],
|
|
["u-1", "达人 A"],
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("exportRecordsToFeishuBitable creates app fields and records", async () => {
|
|
const calls = [];
|
|
let fieldsGetCount = 0;
|
|
|
|
async function fetchImpl(url, options) {
|
|
const body = options && options.body ? JSON.parse(options.body) : null;
|
|
calls.push({
|
|
url: String(url),
|
|
method: options && options.method,
|
|
headers: options && options.headers,
|
|
body,
|
|
});
|
|
|
|
if (String(url).includes("/auth/v3/tenant_access_token/internal")) {
|
|
return okJson({ code: 0, tenant_access_token: "tenant-token" });
|
|
}
|
|
if (String(url).endsWith("/bitable/v1/apps")) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
app: {
|
|
app_token: "base-token",
|
|
default_table_id: "tbl-default",
|
|
url: "https://feishu.example/base-token",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (String(url).endsWith("/fields") && options.method === "POST") {
|
|
return okJson({ code: 0, data: { field: { field_id: "fld-created" } } });
|
|
}
|
|
if (String(url).includes("/fields/") && options.method === "PUT") {
|
|
return okJson({ code: 0, data: { field: { field_id: "fld-default-text" } } });
|
|
}
|
|
if (String(url).endsWith("/fields") && options.method === "GET") {
|
|
fieldsGetCount += 1;
|
|
if (fieldsGetCount === 1) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{ field_id: "fld-default-text", field_name: "文本", is_primary: true },
|
|
{ field_id: "fld-default-select", field_name: "单选" },
|
|
{ field_id: "fld-default-date", field_name: "日期" },
|
|
{ field_id: "fld-default-attachment", field_name: "附件" },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
items: [
|
|
{ field_id: "fld-default-text", field_name: "达人ID", is_primary: true },
|
|
{ field_id: "fld-default-select", field_name: "单选" },
|
|
{ field_id: "fld-default-date", field_name: "日期" },
|
|
{ field_id: "fld-default-attachment", field_name: "附件" },
|
|
{ field_id: "fld-name", field_name: "达人昵称" },
|
|
],
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/fields/")) {
|
|
return okJson({ code: 0, data: {} });
|
|
}
|
|
if (String(url).endsWith("/records") && options.method === "GET") {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
items: Array.from({ length: 10 }, (_, index) => ({
|
|
record_id: `rec-empty-${index + 1}`,
|
|
fields: {},
|
|
})),
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/records/batch_delete")) {
|
|
return okJson({ code: 0, data: {} });
|
|
}
|
|
if (String(url).includes("/records/batch_create")) {
|
|
return okJson({ code: 0, data: { records: [{ record_id: "rec-1" }] } });
|
|
}
|
|
|
|
throw new Error(`unexpected url: ${url}`);
|
|
}
|
|
|
|
const result = await api.exportRecordsToFeishuBitable({
|
|
appId: "cli_xxx",
|
|
appSecret: "secret",
|
|
title: "测试多维表格",
|
|
records: [
|
|
{
|
|
flattened: {
|
|
userId: "u-1",
|
|
name: "达人 A",
|
|
},
|
|
},
|
|
],
|
|
fields: ["userId", "name"],
|
|
fetchImpl,
|
|
});
|
|
|
|
assert.equal(result.appToken, "base-token");
|
|
assert.equal(result.tableId, "tbl-default");
|
|
assert.equal(result.url, "https://feishu.example/base-token");
|
|
assert.equal(result.rowCount, 1);
|
|
assert.equal(calls.length, 12);
|
|
assert.deepEqual(calls[0].body, {
|
|
app_id: "cli_xxx",
|
|
app_secret: "secret",
|
|
});
|
|
assert.deepEqual(calls[1].body, {
|
|
name: "测试多维表格",
|
|
});
|
|
assert.equal(calls[2].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true);
|
|
assert.equal(calls[2].method, "GET");
|
|
assert.equal(calls[3].url.endsWith("/fields/fld-default-text"), true);
|
|
assert.equal(calls[3].method, "PUT");
|
|
assert.deepEqual(calls[3].body, {
|
|
field_name: "达人ID",
|
|
type: 1,
|
|
});
|
|
assert.equal(calls[4].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true);
|
|
assert.equal(calls[4].method, "POST");
|
|
assert.deepEqual(calls[4].body, {
|
|
field_name: "达人昵称",
|
|
type: 1,
|
|
});
|
|
assert.equal(calls[5].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/fields"), true);
|
|
assert.equal(calls[5].method, "GET");
|
|
assert.equal(calls[6].url.endsWith("/fields/fld-default-select"), true);
|
|
assert.equal(calls[6].method, "DELETE");
|
|
assert.equal(calls[7].url.endsWith("/fields/fld-default-date"), true);
|
|
assert.equal(calls[7].method, "DELETE");
|
|
assert.equal(calls[8].url.endsWith("/fields/fld-default-attachment"), true);
|
|
assert.equal(calls[8].method, "DELETE");
|
|
assert.equal(calls[9].url.endsWith("/bitable/v1/apps/base-token/tables/tbl-default/records"), true);
|
|
assert.equal(calls[9].method, "GET");
|
|
assert.equal(
|
|
calls[10].url.endsWith(
|
|
"/bitable/v1/apps/base-token/tables/tbl-default/records/batch_delete",
|
|
),
|
|
true,
|
|
);
|
|
assert.deepEqual(calls[10].body, {
|
|
records: [
|
|
"rec-empty-1",
|
|
"rec-empty-2",
|
|
"rec-empty-3",
|
|
"rec-empty-4",
|
|
"rec-empty-5",
|
|
"rec-empty-6",
|
|
"rec-empty-7",
|
|
"rec-empty-8",
|
|
"rec-empty-9",
|
|
"rec-empty-10",
|
|
],
|
|
});
|
|
assert.equal(
|
|
calls[11].url.endsWith(
|
|
"/bitable/v1/apps/base-token/tables/tbl-default/records/batch_create",
|
|
),
|
|
true,
|
|
);
|
|
assert.deepEqual(calls[11].body, {
|
|
records: [
|
|
{
|
|
fields: {
|
|
"达人ID": "u-1",
|
|
"达人昵称": "达人 A",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("writeFeishuBitableRecords writes records in 500-row batches", async () => {
|
|
const batches = [];
|
|
|
|
async function fetchImpl(url, options) {
|
|
assert.equal(
|
|
String(url).endsWith("/bitable/v1/apps/base-token/tables/tbl-default/records/batch_create"),
|
|
true,
|
|
);
|
|
batches.push(JSON.parse(options.body).records.length);
|
|
return okJson({ code: 0, data: {} });
|
|
}
|
|
|
|
const records = Array.from({ length: 501 }, (_, index) => ({
|
|
fields: {
|
|
"达人ID": `u-${index + 1}`,
|
|
},
|
|
}));
|
|
|
|
const result = await api.writeFeishuBitableRecords({
|
|
tenantAccessToken: "tenant-token",
|
|
appToken: "base-token",
|
|
tableId: "tbl-default",
|
|
records,
|
|
fetchImpl,
|
|
});
|
|
|
|
assert.deepEqual(batches, [500, 1]);
|
|
assert.equal(result.writtenCount, 501);
|
|
});
|
|
|
|
test("controller reads Feishu credentials from localStorage instead of bundled secrets", async () => {
|
|
const previousLocalStorage = global.localStorage;
|
|
const previousGm = global.GM_xmlhttpRequest;
|
|
const sentAuthBodies = [];
|
|
|
|
global.GM_xmlhttpRequest = undefined;
|
|
global.localStorage = {
|
|
getItem(key) {
|
|
if (key === "xhs-pgy-export:feishu-app-id") {
|
|
return JSON.stringify("cli_from_storage");
|
|
}
|
|
if (key === "xhs-pgy-export:feishu-app-secret") {
|
|
return JSON.stringify("secret_from_storage");
|
|
}
|
|
return null;
|
|
},
|
|
setItem() {},
|
|
};
|
|
|
|
async function fetchImpl(url, options) {
|
|
const body = options && options.body ? JSON.parse(options.body) : null;
|
|
if (String(url).startsWith(api.API_BASE)) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
id: "60379f3c000000000101e53f",
|
|
userId: "60379f3c000000000101e53f",
|
|
name: "达人 A",
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/auth/v3/tenant_access_token/internal")) {
|
|
sentAuthBodies.push(body);
|
|
return okJson({ code: 0, tenant_access_token: "tenant-token" });
|
|
}
|
|
if (String(url).includes("/sheets/v3/spreadsheets")) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
spreadsheet: {
|
|
spreadsheet_token: "spreadsheet-token",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/metainfo")) {
|
|
return okJson({ code: 0, data: { sheets: [{ sheetId: "sheet-a" }] } });
|
|
}
|
|
if (String(url).includes("/values_batch_update")) {
|
|
return okJson({ code: 0, data: {} });
|
|
}
|
|
throw new Error(`unexpected url: ${url}`);
|
|
}
|
|
|
|
try {
|
|
const controller = api.createExportController({ fetchImpl });
|
|
await controller.preview("60379f3c000000000101e53f");
|
|
await controller.exportFeishuSpreadsheet(["userId", "name"]);
|
|
|
|
assert.deepEqual(sentAuthBodies, [
|
|
{
|
|
app_id: "cli_from_storage",
|
|
app_secret: "secret_from_storage",
|
|
},
|
|
]);
|
|
} finally {
|
|
global.localStorage = previousLocalStorage;
|
|
global.GM_xmlhttpRequest = previousGm;
|
|
}
|
|
});
|
|
|
|
test("controller saves Feishu credentials for later exports", async () => {
|
|
const previousLocalStorage = global.localStorage;
|
|
const previousGm = global.GM_xmlhttpRequest;
|
|
const storage = new Map();
|
|
const sentAuthBodies = [];
|
|
|
|
global.GM_xmlhttpRequest = undefined;
|
|
global.localStorage = {
|
|
getItem(key) {
|
|
return storage.has(key) ? storage.get(key) : null;
|
|
},
|
|
setItem(key, value) {
|
|
storage.set(key, value);
|
|
},
|
|
};
|
|
|
|
async function fetchImpl(url, options) {
|
|
const body = options && options.body ? JSON.parse(options.body) : null;
|
|
if (String(url).startsWith(api.API_BASE)) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
id: "60379f3c000000000101e53f",
|
|
userId: "60379f3c000000000101e53f",
|
|
name: "达人 A",
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/auth/v3/tenant_access_token/internal")) {
|
|
sentAuthBodies.push(body);
|
|
return okJson({ code: 0, tenant_access_token: "tenant-token" });
|
|
}
|
|
if (String(url).includes("/sheets/v3/spreadsheets")) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
spreadsheet: {
|
|
spreadsheet_token: "spreadsheet-token",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (String(url).includes("/metainfo")) {
|
|
return okJson({ code: 0, data: { sheets: [{ sheetId: "sheet-a" }] } });
|
|
}
|
|
if (String(url).includes("/values_batch_update")) {
|
|
return okJson({ code: 0, data: {} });
|
|
}
|
|
throw new Error(`unexpected url: ${url}`);
|
|
}
|
|
|
|
try {
|
|
const controller = api.createExportController({ fetchImpl });
|
|
controller.saveFeishuCredentials({
|
|
appId: " cli_saved ",
|
|
appSecret: " secret_saved ",
|
|
});
|
|
await controller.preview("60379f3c000000000101e53f");
|
|
await controller.exportFeishuSpreadsheet(["userId", "name"]);
|
|
|
|
assert.equal(storage.get("xhs-pgy-export:feishu-app-id"), JSON.stringify("cli_saved"));
|
|
assert.equal(
|
|
storage.get("xhs-pgy-export:feishu-app-secret"),
|
|
JSON.stringify("secret_saved"),
|
|
);
|
|
assert.deepEqual(sentAuthBodies, [
|
|
{
|
|
app_id: "cli_saved",
|
|
app_secret: "secret_saved",
|
|
},
|
|
]);
|
|
} finally {
|
|
global.localStorage = previousLocalStorage;
|
|
global.GM_xmlhttpRequest = previousGm;
|
|
}
|
|
});
|
|
|
|
test("controller uses GM_xmlhttpRequest for Feishu even when page fetch is available", async () => {
|
|
const gmUrls = [];
|
|
const pageFetchUrls = [];
|
|
const previousGm = global.GM_xmlhttpRequest;
|
|
const previousLocalStorage = global.localStorage;
|
|
|
|
global.localStorage = {
|
|
getItem(key) {
|
|
if (key === "xhs-pgy-export:feishu-app-id") {
|
|
return JSON.stringify("cli_from_storage");
|
|
}
|
|
if (key === "xhs-pgy-export:feishu-app-secret") {
|
|
return JSON.stringify("secret_from_storage");
|
|
}
|
|
return null;
|
|
},
|
|
setItem() {},
|
|
};
|
|
|
|
global.GM_xmlhttpRequest = (options) => {
|
|
gmUrls.push(options.url);
|
|
let payload;
|
|
if (String(options.url).includes("/auth/v3/tenant_access_token/internal")) {
|
|
payload = { code: 0, tenant_access_token: "tenant-token" };
|
|
} else if (String(options.url).includes("/sheets/v3/spreadsheets")) {
|
|
payload = {
|
|
code: 0,
|
|
data: {
|
|
spreadsheet: {
|
|
spreadsheet_token: "spreadsheet-token",
|
|
url: "https://feishu.example/spreadsheet-token",
|
|
},
|
|
},
|
|
};
|
|
} else if (String(options.url).includes("/metainfo")) {
|
|
payload = {
|
|
code: 0,
|
|
data: { sheets: [{ sheetId: "sheet-a" }] },
|
|
};
|
|
} else if (String(options.url).includes("/values_batch_update")) {
|
|
payload = { code: 0, data: {} };
|
|
} else {
|
|
throw new Error(`unexpected GM url: ${options.url}`);
|
|
}
|
|
|
|
options.onload({
|
|
status: 200,
|
|
responseText: JSON.stringify(payload),
|
|
});
|
|
};
|
|
|
|
try {
|
|
async function pageFetch(url) {
|
|
pageFetchUrls.push(String(url));
|
|
if (String(url).startsWith(api.API_BASE)) {
|
|
return okJson({
|
|
code: 0,
|
|
data: {
|
|
id: "60379f3c000000000101e53f",
|
|
userId: "60379f3c000000000101e53f",
|
|
name: "达人 A",
|
|
},
|
|
});
|
|
}
|
|
return okJson({ code: 0, data: {} });
|
|
}
|
|
|
|
const controller = api.createExportController({ fetchImpl: pageFetch });
|
|
await controller.preview("60379f3c000000000101e53f");
|
|
const result = await controller.exportFeishuSpreadsheet(["userId", "name"]);
|
|
|
|
assert.equal(result.spreadsheetToken, "spreadsheet-token");
|
|
assert.equal(gmUrls.filter((url) => String(url).includes("open.feishu.cn")).length, 4);
|
|
assert.equal(
|
|
pageFetchUrls.some((url) => url.includes("open.feishu.cn")),
|
|
false,
|
|
"Feishu requests should not use page fetch because browsers block cross-origin OpenAPI calls",
|
|
);
|
|
} finally {
|
|
global.GM_xmlhttpRequest = previousGm;
|
|
global.localStorage = previousLocalStorage;
|
|
}
|
|
});
|