diff --git a/pugongying/xhs-pgy-export.user.js b/pugongying/xhs-pgy-export.user.js
index f308d29..07b33e2 100644
--- a/pugongying/xhs-pgy-export.user.js
+++ b/pugongying/xhs-pgy-export.user.js
@@ -1,12 +1,14 @@
// ==UserScript==
// @name 小红书蒲公英达人信息导出
// @namespace https://pgy.xiaohongshu.com/
-// @version 0.1.1
-// @description 输入达人主页链接或达人 ID,勾选字段后导出 Excel
+// @version 0.1.2
+// @author wangxuesheng
+// @description 输入达人主页链接或达人 ID,勾选字段后导出 xlsx 或飞书电子表格
// @match https://pgy.xiaohongshu.com/*
// @grant GM_xmlhttpRequest
// @connect api.internal.intelligrow.cn
// @connect xhslink.com
+// @connect open.feishu.cn
// @require https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js
// ==/UserScript==
@@ -19,15 +21,27 @@
function gmFetch(url, options) {
return new Promise((resolve, reject) => {
const headers = options && options.headers ? options.headers : {};
- GM_xmlhttpRequest({
+ const request =
+ typeof GM_xmlhttpRequest === "function"
+ ? GM_xmlhttpRequest
+ : root.GM && typeof root.GM.xmlHttpRequest === "function"
+ ? root.GM.xmlHttpRequest.bind(root.GM)
+ : null;
+ if (!request) {
+ reject(new Error("当前脚本管理器不支持 GM_xmlhttpRequest,无法跨域请求。"));
+ return;
+ }
+ request({
method: (options && options.method) || "GET",
url,
headers,
+ data: options && options.body,
onload(res) {
resolve({
ok: res.status >= 200 && res.status < 300,
status: res.status,
- json: () => Promise.resolve(JSON.parse(res.responseText)),
+ json: () =>
+ Promise.resolve(res.responseText ? JSON.parse(res.responseText) : {}),
});
},
onerror(err) {
@@ -40,6 +54,9 @@
const API_BASE =
"https://pgy.xiaohongshu.com/api/solar/cooperator/user/blogger/";
const PROXY_API_BASE = "https://api.internal.intelligrow.cn";
+ const FEISHU_OPEN_API_BASE = "https://open.feishu.cn/open-apis";
+ const FEISHU_APP_ID_STORAGE_KEY = "xhs-pgy-export:feishu-app-id";
+ const FEISHU_APP_SECRET_STORAGE_KEY = "xhs-pgy-export:feishu-app-secret";
const SUPPLEMENTAL_ENDPOINTS = [
{
namespace: "fansProfile",
@@ -135,6 +152,13 @@
const STORAGE_INPUT_KEY = "xhs-pgy-export:last-input";
const SCRIPT_FLAG = "__xhsPgyExportMounted__";
+ function hasGmRequest() {
+ return (
+ typeof GM_xmlhttpRequest === "function" ||
+ Boolean(root.GM && typeof root.GM.xmlHttpRequest === "function")
+ );
+ }
+
function isPlainObject(value) {
return Object.prototype.toString.call(value) === "[object Object]";
}
@@ -410,6 +434,51 @@
});
}
+ function normalizeCellValue(value) {
+ if (value === null || value === undefined) {
+ return "";
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return value;
+ }
+ if (typeof value === "bigint") {
+ return String(value);
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ return String(value);
+ }
+
+ function buildFeishuSheetValues(records, selectedFields) {
+ const fields = Array.isArray(selectedFields) ? selectedFields : [];
+ const values = [fields.map((field) => getFieldLabel(field))];
+ const list = Array.isArray(records) ? records : [];
+ for (const record of list) {
+ const flattened = record && record.flattened ? record.flattened : {};
+ values.push(fields.map((field) => normalizeCellValue(flattened[field])));
+ }
+ return values;
+ }
+
+ function columnIndexToName(index) {
+ let value = Math.max(1, Number(index) || 1);
+ let name = "";
+ while (value > 0) {
+ const remainder = (value - 1) % 26;
+ name = String.fromCharCode(65 + remainder) + name;
+ value = Math.floor((value - 1) / 26);
+ }
+ return name;
+ }
+
+ function buildFeishuRange(sheetId, rowCount, columnCount) {
+ const safeSheetId = normalizeScalar(sheetId) || "0";
+ const safeRowCount = Math.max(1, Number(rowCount) || 1);
+ const safeColumnCount = Math.max(1, Number(columnCount) || 1);
+ return `${safeSheetId}!A1:${columnIndexToName(safeColumnCount)}${safeRowCount}`;
+ }
+
function formatTimestamp(date) {
const safeDate = date instanceof Date ? date : new Date();
const parts = [
@@ -467,7 +536,7 @@
const extra =
typeof config.extraHeaders === "function" ? config.extraHeaders() : {};
const hasExtra = Object.keys(extra).length > 0;
- const fetcher = hasExtra && typeof GM_xmlhttpRequest === "function" ? gmFetch : fetchImpl;
+ const fetcher = hasExtra && hasGmRequest() ? gmFetch : fetchImpl;
const response = await fetcher(config.buildUrl(userId), {
method: "GET",
credentials: "include",
@@ -515,6 +584,207 @@
return mergedPayload;
}
+ async function parseJsonResponse(response, actionName) {
+ if (!response || !response.ok) {
+ const status = response ? response.status : "unknown";
+ throw new Error(`${actionName}失败,状态码:${status}`);
+ }
+
+ const json = await response.json();
+ if (Number(json && json.code) !== 0) {
+ throw new Error(`${actionName}失败:${(json && (json.msg || json.message)) || "未知错误"}`);
+ }
+ return json;
+ }
+
+ async function feishuApiRequest(path, options) {
+ const settings = options || {};
+ const fetchImpl =
+ settings.fetchImpl ||
+ (hasGmRequest() ? gmFetch : null) ||
+ (typeof root.fetch === "function" ? root.fetch.bind(root) : null);
+ if (typeof fetchImpl !== "function") {
+ throw new Error("当前环境不支持 fetch,无法请求飞书接口。");
+ }
+
+ const headers = {
+ "Content-Type": "application/json; charset=utf-8",
+ ...(settings.headers || {}),
+ };
+ const response = await fetchImpl(`${FEISHU_OPEN_API_BASE}${path}`, {
+ method: settings.method || "GET",
+ headers,
+ body: settings.body === undefined ? undefined : JSON.stringify(settings.body),
+ });
+ return parseJsonResponse(response, settings.actionName || "请求飞书接口");
+ }
+
+ async function getFeishuTenantAccessToken(options) {
+ const settings = options || {};
+ const appId = settings.appId;
+ const appSecret = settings.appSecret;
+ if (!appId || !appSecret) {
+ throw new Error("缺少飞书应用 app_id 或 app_secret。");
+ }
+
+ const json = await feishuApiRequest("/auth/v3/tenant_access_token/internal", {
+ method: "POST",
+ body: {
+ app_id: appId,
+ app_secret: appSecret,
+ },
+ fetchImpl: settings.fetchImpl,
+ actionName: "获取飞书应用访问凭证",
+ });
+
+ if (!json.tenant_access_token) {
+ throw new Error("获取飞书应用访问凭证失败:响应中缺少 tenant_access_token。");
+ }
+ return json.tenant_access_token;
+ }
+
+ async function createFeishuSpreadsheet(options) {
+ const settings = options || {};
+ const token = settings.tenantAccessToken;
+ if (!token) {
+ throw new Error("缺少飞书 tenant_access_token,无法创建电子表格。");
+ }
+
+ const json = await feishuApiRequest("/sheets/v3/spreadsheets", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: {
+ title: settings.title || `蒲公英达人导出-${formatTimestamp(new Date())}`,
+ },
+ fetchImpl: settings.fetchImpl,
+ actionName: "创建飞书电子表格",
+ });
+
+ const spreadsheet = json?.data?.spreadsheet || json?.data || {};
+ const spreadsheetToken =
+ spreadsheet.spreadsheet_token || spreadsheet.token || json?.data?.spreadsheet_token;
+ if (!spreadsheetToken) {
+ throw new Error("创建飞书电子表格失败:响应中缺少 spreadsheet_token。");
+ }
+
+ return {
+ spreadsheetToken,
+ url: spreadsheet.url || json?.data?.url || "",
+ };
+ }
+
+ async function getFeishuFirstSheetId(options) {
+ const settings = options || {};
+ const token = settings.tenantAccessToken;
+ const spreadsheetToken = settings.spreadsheetToken;
+ if (!token || !spreadsheetToken) {
+ throw new Error("缺少飞书表格访问参数,无法获取工作表信息。");
+ }
+
+ const json = await feishuApiRequest(
+ `/sheets/v2/spreadsheets/${encodeURIComponent(spreadsheetToken)}/metainfo`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ fetchImpl: settings.fetchImpl,
+ actionName: "获取飞书工作表信息",
+ },
+ );
+
+ const sheets = json?.data?.sheets || [];
+ const firstSheet = sheets[0] || {};
+ const sheetId = firstSheet.sheetId || firstSheet.sheet_id || firstSheet.id;
+ if (!sheetId) {
+ throw new Error("获取飞书工作表信息失败:响应中缺少 sheetId。");
+ }
+ return sheetId;
+ }
+
+ async function writeFeishuSheetValues(options) {
+ const settings = options || {};
+ const token = settings.tenantAccessToken;
+ const spreadsheetToken = settings.spreadsheetToken;
+ const sheetId = settings.sheetId;
+ const values = Array.isArray(settings.values) ? settings.values : [];
+ if (!token || !spreadsheetToken || !sheetId) {
+ throw new Error("缺少飞书表格写入参数。");
+ }
+ if (!values.length) {
+ throw new Error("没有可写入飞书电子表格的数据。");
+ }
+
+ const range = buildFeishuRange(sheetId, values.length, values[0]?.length || 1);
+ await feishuApiRequest(
+ `/sheets/v2/spreadsheets/${encodeURIComponent(spreadsheetToken)}/values_batch_update`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: {
+ valueRanges: [
+ {
+ range,
+ values,
+ },
+ ],
+ },
+ fetchImpl: settings.fetchImpl,
+ actionName: "写入飞书电子表格",
+ },
+ );
+
+ return { range };
+ }
+
+ async function exportRecordsToFeishuSpreadsheet(options) {
+ const settings = options || {};
+ const records = Array.isArray(settings.records) ? settings.records : [];
+ const fields = Array.isArray(settings.fields) ? settings.fields : [];
+ if (!records.length) {
+ throw new Error("没有可导出的达人数据,请先读取数据。");
+ }
+ if (!fields.length) {
+ throw new Error("请至少勾选一个导出字段。");
+ }
+
+ const fetchImpl = settings.fetchImpl;
+ const tenantAccessToken = await getFeishuTenantAccessToken({
+ appId: settings.appId,
+ appSecret: settings.appSecret,
+ fetchImpl,
+ });
+ const spreadsheet = await createFeishuSpreadsheet({
+ tenantAccessToken,
+ title: settings.title,
+ fetchImpl,
+ });
+ const sheetId = await getFeishuFirstSheetId({
+ tenantAccessToken,
+ spreadsheetToken: spreadsheet.spreadsheetToken,
+ fetchImpl,
+ });
+ const values = buildFeishuSheetValues(records, fields);
+ const writeResult = await writeFeishuSheetValues({
+ tenantAccessToken,
+ spreadsheetToken: spreadsheet.spreadsheetToken,
+ sheetId,
+ values,
+ fetchImpl,
+ });
+
+ return {
+ ...spreadsheet,
+ sheetId,
+ rowCount: records.length,
+ range: writeResult.range,
+ };
+ }
+
async function mapWithConcurrency(items, limit, mapper, onDone) {
const list = Array.isArray(items) ? items : [];
if (!list.length) {
@@ -678,6 +948,37 @@
};
},
+ async exportFeishuSpreadsheet(selectedFields, onProgress) {
+ if (!cachedRecords.length) {
+ throw new Error("请先读取字段并确认达人数据。");
+ }
+
+ const fields =
+ Array.isArray(selectedFields) && selectedFields.length
+ ? selectedFields
+ : cachedFields.map((field) => field.path);
+
+ const report = (percentage, message) => {
+ if (typeof onProgress !== "function") {
+ return;
+ }
+ onProgress(Math.max(0, Math.min(100, percentage)), message || "");
+ };
+
+ report(0, "正在获取飞书应用访问凭证...");
+ const credentials = resolveFeishuCredentials(settings);
+ const result = await exportRecordsToFeishuSpreadsheet({
+ appId: credentials.appId,
+ appSecret: credentials.appSecret,
+ title: `蒲公英达人导出-${formatTimestamp(now())}`,
+ records: cachedRecords,
+ fields,
+ fetchImpl: hasGmRequest() ? undefined : fetchImpl,
+ });
+ report(100, "已写入飞书电子表格");
+ return result;
+ },
+
getState() {
return {
records: cachedRecords.slice(),
@@ -716,6 +1017,14 @@
}
}
+ function resolveFeishuCredentials(settings) {
+ const options = settings || {};
+ return {
+ appId: options.feishuAppId || loadLocal(FEISHU_APP_ID_STORAGE_KEY, ""),
+ appSecret: options.feishuAppSecret || loadLocal(FEISHU_APP_SECRET_STORAGE_KEY, ""),
+ };
+ }
+
const XLSX_CDN_URLS = [
"https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js",
@@ -897,6 +1206,13 @@
box-shadow: 0 16px 34px rgba(187, 61, 14, 0.28);
}
+ .xhs-export-fab.local {
+ right: 144px;
+ color: #5e412f;
+ background: rgba(110, 67, 41, 0.08);
+ box-shadow: none;
+ }
+
.xhs-export-fab:disabled {
opacity: 0.55;
cursor: not-allowed;
@@ -1110,7 +1426,17 @@
font-size: 12px;
line-height: 1.45;
color: #7c5b48;
- word-break: break-all;
+ word-break: break-word;
+ }
+
+ .xhs-export-modal-link {
+ color: #c8581c;
+ font-weight: 800;
+ text-decoration: none;
+ }
+
+ .xhs-export-modal-link:hover {
+ text-decoration: underline;
}
.xhs-export-modal-actions {
@@ -1263,7 +1589,8 @@
-
+
+
`;
const modalBackdrop = doc.createElement("div");
@@ -1279,7 +1606,7 @@
- 下载已完成
+ 导出已完成
@@ -1295,7 +1622,8 @@
toggle,
panel,
input: panel.querySelector(".xhs-export-input"),
- exportButton: panel.querySelector('[data-action="export"]'),
+ localExportButton: panel.querySelector('[data-action="export-local"]'),
+ feishuExportButton: panel.querySelector('[data-action="export-feishu"]'),
status: panel.querySelector(".xhs-export-status"),
progress: panel.querySelector(".xhs-export-progress"),
progressText: panel.querySelector(".xhs-export-progress-text"),
@@ -1474,24 +1802,67 @@
refs.modalBackdrop.setAttribute("aria-hidden", "true");
}
- function openModal(refs, subtitle, autoCloseMs) {
+ function openModal(refs, subtitle) {
if (!refs || !refs.modalBackdrop) {
return;
}
- if (typeof subtitle === "string" && refs.modalSubtitle) {
- refs.modalSubtitle.textContent = subtitle;
+ if (refs.modalSubtitle) {
+ refs.modalSubtitle.replaceChildren();
+ if (subtitle && typeof subtitle === "object" && typeof subtitle.nodeType === "number") {
+ refs.modalSubtitle.appendChild(subtitle);
+ } else if (typeof subtitle === "string") {
+ refs.modalSubtitle.textContent = subtitle;
+ }
}
refs.modalBackdrop.classList.add("is-open");
refs.modalBackdrop.setAttribute("aria-hidden", "false");
if (refs.modalTimer) {
clearTimeout(refs.modalTimer);
+ refs.modalTimer = null;
}
- const delay =
- typeof autoCloseMs === "number" && Number.isFinite(autoCloseMs) && autoCloseMs > 0
- ? autoCloseMs
- : 2500;
- refs.modalTimer = setTimeout(() => closeModal(refs), delay);
+ }
+
+ function buildFeishuSuccessModalContent(result) {
+ const fragment = root.document.createDocumentFragment();
+ const rowCount = result && result.rowCount;
+ fragment.appendChild(root.document.createTextNode(`已导出 ${rowCount} 条数据:`));
+
+ const url = result && result.url;
+ if (url) {
+ const link = root.document.createElement("a");
+ link.className = "xhs-export-modal-link";
+ link.href = url;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ link.textContent = "飞书表格链接";
+ fragment.appendChild(link);
+ return fragment;
+ }
+
+ fragment.appendChild(
+ root.document.createTextNode(`表格 token:${(result && result.spreadsheetToken) || ""}`),
+ );
+ return fragment;
+ }
+
+ function setExportButtonsDisabled(refs, disabled) {
+ if (refs.localExportButton) {
+ refs.localExportButton.disabled = Boolean(disabled);
+ }
+ if (refs.feishuExportButton) {
+ refs.feishuExportButton.disabled = Boolean(disabled);
+ }
+ }
+
+ async function prepareExportData(controller, refs, checkedFields, progressEnd) {
+ const rawInput = refs.input.value;
+ saveLocal(STORAGE_INPUT_KEY, rawInput);
+ await controller.preview(rawInput, (current, total) => {
+ const pct = total ? Math.floor((current / total) * progressEnd) : 0;
+ setProgress(refs, pct, `正在读取达人数据 ${current}/${total || 0}`, false);
+ });
+ return checkedFields;
}
function bindUi(controller, refs) {
@@ -1508,39 +1879,24 @@
closeModal(refs);
refs.modalCloseButton.addEventListener("click", () => closeModal(refs));
- refs.modalBackdrop.addEventListener("click", (event) => {
- if (event.target === refs.modalBackdrop) {
- closeModal(refs);
- }
- });
- root.document.addEventListener("keydown", (event) => {
- if (event.key === "Escape") {
- closeModal(refs);
- }
- });
refs.toggle.addEventListener("click", () => {
refs.panel.classList.toggle("is-open");
});
- refs.exportButton.addEventListener("click", async () => {
+ refs.localExportButton.addEventListener("click", async () => {
try {
const checkedFields = getCheckedFields(refs.fields);
if (!checkedFields.length) {
throw new Error("请至少勾选一个导出字段。");
}
- refs.exportButton.disabled = true;
+ setExportButtonsDisabled(refs, true);
hideProgress(refs);
setProgress(refs, 0, "准备导出...", false);
setStatus(refs.status, "正在读取达人数据,请稍候...", false);
- const rawInput = refs.input.value;
- saveLocal(STORAGE_INPUT_KEY, rawInput);
- await controller.preview(rawInput, (current, total) => {
- const pct = total ? Math.floor((current / total) * 45) : 0;
- setProgress(refs, pct, `正在读取达人数据 ${current}/${total || 0}`, false);
- });
+ await prepareExportData(controller, refs, checkedFields, 45);
setStatus(refs.status, "正在生成导出文件...", false);
const result = await controller.exportSheetAsync(
checkedFields,
@@ -1554,7 +1910,7 @@
);
downloadFile(result.filename, result.content);
setProgress(refs, 100, "已触发下载", false);
- openModal(refs, `文件:${result.filename}`, 2500);
+ openModal(refs, `文件:${result.filename}`);
setStatus(
refs.status,
`已导出 ${result.rowCount ?? (result.rows ? result.rows.length : 0)} 条达人数据,文件名:${result.filename}`,
@@ -1564,7 +1920,48 @@
setProgress(refs, 100, "导出失败", true);
setStatus(refs.status, error.message || "导出失败。", true);
} finally {
- refs.exportButton.disabled = false;
+ setExportButtonsDisabled(refs, false);
+ }
+ });
+
+ refs.feishuExportButton.addEventListener("click", async () => {
+ try {
+ const checkedFields = getCheckedFields(refs.fields);
+ if (!checkedFields.length) {
+ throw new Error("请至少勾选一个导出字段。");
+ }
+
+ setExportButtonsDisabled(refs, true);
+ hideProgress(refs);
+ setProgress(refs, 0, "准备导出到飞书...", false);
+ setStatus(refs.status, "正在读取达人数据,请稍候...", false);
+
+ await prepareExportData(controller, refs, checkedFields, 40);
+ setStatus(refs.status, "正在创建飞书电子表格并写入数据...", false);
+ const result = await controller.exportFeishuSpreadsheet(
+ checkedFields,
+ (percentage, message) =>
+ setProgress(
+ refs,
+ 40 + Math.floor((percentage * 60) / 100),
+ message || "正在写入飞书电子表格...",
+ false,
+ ),
+ );
+ setProgress(refs, 100, "已写入飞书电子表格", false);
+ openModal(refs, buildFeishuSuccessModalContent(result));
+ setStatus(
+ refs.status,
+ result.url
+ ? `已导出 ${result.rowCount} 条达人数据到飞书:${result.url}`
+ : `已导出 ${result.rowCount} 条达人数据到飞书,表格 token:${result.spreadsheetToken}`,
+ false,
+ );
+ } catch (error) {
+ setProgress(refs, 100, "导出飞书失败", true);
+ setStatus(refs.status, error.message || "导出飞书失败。", true);
+ } finally {
+ setExportButtonsDisabled(refs, false);
}
});
}
@@ -1599,13 +1996,19 @@
return {
API_BASE,
buildExportRows,
+ buildFeishuRange,
+ buildFeishuSheetValues,
buildFieldOptions,
- buildSpreadsheetXml,
createExportController,
+ createFeishuSpreadsheet,
extractBloggerId,
+ exportRecordsToFeishuSpreadsheet,
fetchMergedBloggerRecord,
flattenRecord,
getFieldLabel,
+ getFeishuFirstSheetId,
+ getFeishuTenantAccessToken,
parseCreatorInputs,
+ writeFeishuSheetValues,
};
});
diff --git a/pugongying/xhs-pgy-export.user.test.js b/pugongying/xhs-pgy-export.user.test.js
index 26bcb00..b4523ee 100644
--- a/pugongying/xhs-pgy-export.user.test.js
+++ b/pugongying/xhs-pgy-export.user.test.js
@@ -1,10 +1,8 @@
const test = require("node:test");
const assert = require("node:assert/strict");
-// The userscript exports some helpers in a shorthand object; in Node that means any
-// undeclared identifiers referenced there must exist on `global`.
-global.buildSpreadsheetXml = () => "";
global.document = { cookie: "x=y" };
+global.GM_xmlhttpRequest = undefined;
const api = require("./xhs-pgy-export.user.js");
@@ -38,3 +36,268 @@ test("data_summary request includes business=1", async () => {
assert.equal(parsed.searchParams.get("business"), "1");
assert.equal(parsed.searchParams.get("userId"), "u-123");
});
+
+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("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 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;
+ }
+});
diff --git a/yuntu/yuntuReportFilling/server/src/report-service.js b/yuntu/yuntuReportFilling/server/src/report-service.js
index 169b88e..149e200 100644
--- a/yuntu/yuntuReportFilling/server/src/report-service.js
+++ b/yuntu/yuntuReportFilling/server/src/report-service.js
@@ -44,14 +44,24 @@ function extractReportId(reportInfo) {
);
}
+function extractReportInfo(input) {
+ if (!input.payload) {
+ return input;
+ }
+
+ return ensureObject(input.payload, 'payload');
+}
+
function validateAndNormalizeReportInput(input) {
- const reportInfo = ensureObject(input, 'body');
- const reportId = extractReportId(reportInfo);
+ const body = ensureObject(input, 'body');
+ const reportId = extractReportId(body);
if (!reportId) {
throw new ValidationError('report_id is required in response info');
}
+ const reportInfo = extractReportInfo(body);
+
return {
reportId,
reportInfo,
@@ -68,6 +78,7 @@ function toDatabaseRecord(normalizedReport) {
module.exports = {
ValidationError,
extractReportId,
+ extractReportInfo,
validateAndNormalizeReportInput,
toDatabaseRecord,
};
diff --git a/yuntu/yuntuReportFilling/server/test/report-service.test.js b/yuntu/yuntuReportFilling/server/test/report-service.test.js
index 1971204..020d7f5 100644
--- a/yuntu/yuntuReportFilling/server/test/report-service.test.js
+++ b/yuntu/yuntuReportFilling/server/test/report-service.test.js
@@ -18,6 +18,31 @@ function createResponseInfo() {
};
}
+function createPersistRequest() {
+ return {
+ reportId: 'report-001',
+ sourceType: 'MANUAL_CAPTURE',
+ sourceReportId: null,
+ aadvid: '1648829117232140',
+ payload: {
+ name: '测试报告',
+ startTime: '2025-03-01',
+ endTime: '2026-02-28',
+ categories: [],
+ analysisDims: ['MARKETOVERVIEW'],
+ },
+ };
+}
+
+test('validateAndNormalizeReportInput accepts persist request with payload', () => {
+ const input = createPersistRequest();
+
+ const result = validateAndNormalizeReportInput(input);
+
+ assert.equal(result.reportId, 'report-001');
+ assert.deepEqual(result.reportInfo, input.payload);
+});
+
test('validateAndNormalizeReportInput accepts response info with data.reportId', () => {
const input = createResponseInfo();
@@ -74,3 +99,20 @@ test('toDatabaseRecord maps response info into segmented_market_reports columns'
},
});
});
+
+test('toDatabaseRecord maps persist request payload into segmented_market_reports report_info', () => {
+ const normalized = validateAndNormalizeReportInput(createPersistRequest());
+
+ const record = toDatabaseRecord(normalized);
+
+ assert.deepEqual(record, {
+ report_id: 'report-001',
+ report_info: {
+ name: '测试报告',
+ startTime: '2025-03-01',
+ endTime: '2026-02-28',
+ categories: [],
+ analysisDims: ['MARKETOVERVIEW'],
+ },
+ });
+});
diff --git a/yuntu/yuntuReportFilling/server/test/server.test.js b/yuntu/yuntuReportFilling/server/test/server.test.js
index 3850ac7..f99b821 100644
--- a/yuntu/yuntuReportFilling/server/test/server.test.js
+++ b/yuntu/yuntuReportFilling/server/test/server.test.js
@@ -14,6 +14,22 @@ function createResponseInfo() {
};
}
+function createPersistRequest() {
+ return {
+ reportId: 'report-001',
+ sourceType: 'MANUAL_CAPTURE',
+ sourceReportId: null,
+ aadvid: '1648829117232140',
+ payload: {
+ name: '测试报告',
+ startTime: '2025-03-01',
+ endTime: '2026-02-28',
+ categories: [],
+ analysisDims: ['MARKETOVERVIEW'],
+ },
+ };
+}
+
async function withServer(repository, callback) {
const app = createApp({
repository,
@@ -131,3 +147,43 @@ test('POST /api/reports persists raw response info and returns created response'
},
);
});
+
+test('POST /api/reports persists persist request payload as report_info', async () => {
+ let savedRecord = null;
+
+ await withServer(
+ {
+ async save(record) {
+ savedRecord = record;
+ return {
+ reportId: record.report_id,
+ created: true,
+ };
+ },
+ },
+ async (baseUrl) => {
+ const persistRequest = createPersistRequest();
+ const response = await fetch(`${baseUrl}/api/reports`, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ },
+ body: JSON.stringify(persistRequest),
+ });
+ const body = await response.json();
+
+ assert.equal(response.status, 201);
+ assert.deepEqual(body, {
+ success: true,
+ data: {
+ reportId: 'report-001',
+ created: true,
+ },
+ });
+ assert.deepEqual(savedRecord, {
+ report_id: 'report-001',
+ report_info: persistRequest.payload,
+ });
+ },
+ );
+});
diff --git a/yuntu/yuntuReportFilling/yuntuReportFilling.test.js b/yuntu/yuntuReportFilling/yuntuReportFilling.test.js
index f7263fd..cd78991 100644
--- a/yuntu/yuntuReportFilling/yuntuReportFilling.test.js
+++ b/yuntu/yuntuReportFilling/yuntuReportFilling.test.js
@@ -199,19 +199,53 @@ test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appe
assert.notEqual(copied.nested, original.nested);
});
-test("buildPersistRequest keeps the response info unchanged for backend parsing", () => {
- const responseInfo = {
- code: 0,
- message: "success",
- data: {
- reportId: "report-001",
- status: "SUCCESS",
- },
+test("buildPersistRequest sends create payload and metadata to the backend", () => {
+ const payload = {
+ name: "测试报告",
+ startTime: "2025-03-01",
+ endTime: "2026-02-28",
+ categories: [],
+ analysisDims: ["MARKETOVERVIEW"],
};
- const request = api.buildPersistRequest(responseInfo);
+ const request = api.buildPersistRequest({
+ payload,
+ aadvid: "1648829117232140",
+ reportId: "report-001",
+ sourceType: "MANUAL_CAPTURE",
+ sourceReportId: null,
+ });
- assert.deepEqual(request, responseInfo);
- assert.notEqual(request, responseInfo);
- assert.notEqual(request.data, responseInfo.data);
+ assert.deepEqual(request, {
+ reportId: "report-001",
+ sourceType: "MANUAL_CAPTURE",
+ sourceReportId: null,
+ aadvid: "1648829117232140",
+ payload: {
+ name: "测试报告",
+ startTime: "2025-03-01",
+ endTime: "2026-02-28",
+ categories: [],
+ analysisDims: ["MARKETOVERVIEW"],
+ },
+ });
+ assert.notEqual(request.payload, payload);
+});
+
+test("buildPersistRequest records auto-copy source report id", () => {
+ const request = api.buildPersistRequest({
+ payload: {
+ name: "同比报告",
+ startTime: "2024-03-01",
+ endTime: "2025-02-28",
+ },
+ aadvid: "1648829117232140",
+ reportId: "report-002",
+ sourceType: "AUTO_COPY",
+ sourceReportId: "report-001",
+ });
+
+ assert.equal(request.reportId, "report-002");
+ assert.equal(request.sourceType, "AUTO_COPY");
+ assert.equal(request.sourceReportId, "report-001");
});
diff --git a/yuntu/yuntuReportFilling/yuntuReportFilling.user.js b/yuntu/yuntuReportFilling/yuntuReportFilling.user.js
index 74d9e43..2a9674e 100644
--- a/yuntu/yuntuReportFilling/yuntuReportFilling.user.js
+++ b/yuntu/yuntuReportFilling/yuntuReportFilling.user.js
@@ -3,7 +3,7 @@
// @namespace https://yuntu.oceanengine.com/
// @version 0.1.0
// @description 记录最近一次成功创建的云图报告,并一键复制同比报告
-// @author wangxi
+// @author wangxuesheng
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList*
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketcreation*
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/*
@@ -162,8 +162,20 @@
return cloned;
}
- function buildPersistRequest(responseJson) {
- return deepCloneJson(responseJson);
+ function buildPersistRequest({
+ payload,
+ aadvid,
+ reportId,
+ sourceType,
+ sourceReportId,
+ }) {
+ return {
+ reportId: String(reportId),
+ sourceType,
+ sourceReportId: sourceReportId ? String(sourceReportId) : null,
+ aadvid: String(aadvid),
+ payload: deepCloneJson(payload),
+ };
}
function parseJson(text) {
@@ -320,7 +332,7 @@
let lastError = null;
for (const base of bases) {
- try {
+ try {
if (typeof GM_xmlhttpRequest !== "function") {
const response = await fetch(`${base}/api/reports`, {
method: "POST",
@@ -424,6 +436,7 @@
try {
const nextPayload = buildAutoCopyPayload(payload);
const requestUrl = getCreateRequestUrl(meta);
+ const sourceReportId = meta.reportId || null;
const { responseJson } = await createReportThroughPage(requestUrl, nextPayload);
const reportId = extractReportId(responseJson);
@@ -440,7 +453,15 @@
};
writeStoredData(nextPayload, nextMeta);
- await persistToLocalServer(buildPersistRequest(responseJson));
+ await persistToLocalServer(
+ buildPersistRequest({
+ payload: nextPayload,
+ aadvid: nextMeta.aadvid,
+ reportId,
+ sourceType: "AUTO_COPY",
+ sourceReportId,
+ }),
+ );
showToast(`同比报告创建成功:${reportId}`, "success");
} catch (error) {
@@ -486,7 +507,15 @@
writeStoredData(payload, meta);
updateButtonState();
- await persistToLocalServer(buildPersistRequest(responseJson));
+ await persistToLocalServer(
+ buildPersistRequest({
+ payload,
+ aadvid,
+ reportId,
+ sourceType: "MANUAL_CAPTURE",
+ sourceReportId: null,
+ }),
+ );
showToast(`已记录报告配置:${reportId}`, "success");
} catch (error) {