From 07ff72eafa4ddc6d228f67c2e676119e01a24528 Mon Sep 17 00:00:00 2001 From: wxs Date: Fri, 8 May 2026 16:00:24 +0800 Subject: [PATCH] feat: update export automation scripts --- pugongying/xhs-pgy-export.user.js | 479 ++++++++++++++++-- pugongying/xhs-pgy-export.user.test.js | 269 +++++++++- .../server/src/report-service.js | 15 +- .../server/test/report-service.test.js | 42 ++ .../server/test/server.test.js | 56 ++ .../yuntuReportFilling.test.js | 58 ++- .../yuntuReportFilling.user.js | 41 +- 7 files changed, 899 insertions(+), 61 deletions(-) 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) {