feat: update export automation scripts

This commit is contained in:
wxs 2026-05-08 16:00:24 +08:00
parent 6dcdbc1983
commit 07ff72eafa
7 changed files with 899 additions and 61 deletions

View File

@ -1,12 +1,14 @@
// ==UserScript== // ==UserScript==
// @name 小红书蒲公英达人信息导出 // @name 小红书蒲公英达人信息导出
// @namespace https://pgy.xiaohongshu.com/ // @namespace https://pgy.xiaohongshu.com/
// @version 0.1.1 // @version 0.1.2
// @description 输入达人主页链接或达人 ID勾选字段后导出 Excel // @author wangxuesheng
// @description 输入达人主页链接或达人 ID勾选字段后导出 xlsx 或飞书电子表格
// @match https://pgy.xiaohongshu.com/* // @match https://pgy.xiaohongshu.com/*
// @grant GM_xmlhttpRequest // @grant GM_xmlhttpRequest
// @connect api.internal.intelligrow.cn // @connect api.internal.intelligrow.cn
// @connect xhslink.com // @connect xhslink.com
// @connect open.feishu.cn
// @require https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js // @require https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js
// ==/UserScript== // ==/UserScript==
@ -19,15 +21,27 @@
function gmFetch(url, options) { function gmFetch(url, options) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const headers = options && options.headers ? options.headers : {}; 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", method: (options && options.method) || "GET",
url, url,
headers, headers,
data: options && options.body,
onload(res) { onload(res) {
resolve({ resolve({
ok: res.status >= 200 && res.status < 300, ok: res.status >= 200 && res.status < 300,
status: res.status, status: res.status,
json: () => Promise.resolve(JSON.parse(res.responseText)), json: () =>
Promise.resolve(res.responseText ? JSON.parse(res.responseText) : {}),
}); });
}, },
onerror(err) { onerror(err) {
@ -40,6 +54,9 @@
const API_BASE = const API_BASE =
"https://pgy.xiaohongshu.com/api/solar/cooperator/user/blogger/"; "https://pgy.xiaohongshu.com/api/solar/cooperator/user/blogger/";
const PROXY_API_BASE = "https://api.internal.intelligrow.cn"; 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 = [ const SUPPLEMENTAL_ENDPOINTS = [
{ {
namespace: "fansProfile", namespace: "fansProfile",
@ -135,6 +152,13 @@
const STORAGE_INPUT_KEY = "xhs-pgy-export:last-input"; const STORAGE_INPUT_KEY = "xhs-pgy-export:last-input";
const SCRIPT_FLAG = "__xhsPgyExportMounted__"; const SCRIPT_FLAG = "__xhsPgyExportMounted__";
function hasGmRequest() {
return (
typeof GM_xmlhttpRequest === "function" ||
Boolean(root.GM && typeof root.GM.xmlHttpRequest === "function")
);
}
function isPlainObject(value) { function isPlainObject(value) {
return Object.prototype.toString.call(value) === "[object Object]"; 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) { function formatTimestamp(date) {
const safeDate = date instanceof Date ? date : new Date(); const safeDate = date instanceof Date ? date : new Date();
const parts = [ const parts = [
@ -467,7 +536,7 @@
const extra = const extra =
typeof config.extraHeaders === "function" ? config.extraHeaders() : {}; typeof config.extraHeaders === "function" ? config.extraHeaders() : {};
const hasExtra = Object.keys(extra).length > 0; 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), { const response = await fetcher(config.buildUrl(userId), {
method: "GET", method: "GET",
credentials: "include", credentials: "include",
@ -515,6 +584,207 @@
return mergedPayload; 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) { async function mapWithConcurrency(items, limit, mapper, onDone) {
const list = Array.isArray(items) ? items : []; const list = Array.isArray(items) ? items : [];
if (!list.length) { 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() { getState() {
return { return {
records: cachedRecords.slice(), 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 = [ const XLSX_CDN_URLS = [
"https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js", "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", "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); 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 { .xhs-export-fab:disabled {
opacity: 0.55; opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
@ -1110,7 +1426,17 @@
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
color: #7c5b48; 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 { .xhs-export-modal-actions {
@ -1263,7 +1589,8 @@
</div> </div>
<div class="xhs-export-field-select"></div> <div class="xhs-export-field-select"></div>
</div> </div>
<button class="xhs-export-fab" data-action="export">导出表格</button> <button class="xhs-export-fab local" data-action="export-local">下载 xlsx</button>
<button class="xhs-export-fab" data-action="export-feishu">导出飞书</button>
`; `;
const modalBackdrop = doc.createElement("div"); const modalBackdrop = doc.createElement("div");
@ -1279,7 +1606,7 @@
<path d="M62 105 L92 132 L146 78" fill="none" stroke="#9adf86" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" /> <path d="M62 105 L92 132 L146 78" fill="none" stroke="#9adf86" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<h3 class="xhs-export-modal-title">下载已完成</h3> <h3 class="xhs-export-modal-title">导出已完成</h3>
<p class="xhs-export-modal-subtitle"></p> <p class="xhs-export-modal-subtitle"></p>
<div class="xhs-export-modal-actions"> <div class="xhs-export-modal-actions">
<button class="xhs-export-modal-btn" type="button">知道了</button> <button class="xhs-export-modal-btn" type="button">知道了</button>
@ -1295,7 +1622,8 @@
toggle, toggle,
panel, panel,
input: panel.querySelector(".xhs-export-input"), 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"), status: panel.querySelector(".xhs-export-status"),
progress: panel.querySelector(".xhs-export-progress"), progress: panel.querySelector(".xhs-export-progress"),
progressText: panel.querySelector(".xhs-export-progress-text"), progressText: panel.querySelector(".xhs-export-progress-text"),
@ -1474,24 +1802,67 @@
refs.modalBackdrop.setAttribute("aria-hidden", "true"); refs.modalBackdrop.setAttribute("aria-hidden", "true");
} }
function openModal(refs, subtitle, autoCloseMs) { function openModal(refs, subtitle) {
if (!refs || !refs.modalBackdrop) { if (!refs || !refs.modalBackdrop) {
return; return;
} }
if (typeof subtitle === "string" && refs.modalSubtitle) { 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.modalSubtitle.textContent = subtitle;
} }
}
refs.modalBackdrop.classList.add("is-open"); refs.modalBackdrop.classList.add("is-open");
refs.modalBackdrop.setAttribute("aria-hidden", "false"); refs.modalBackdrop.setAttribute("aria-hidden", "false");
if (refs.modalTimer) { if (refs.modalTimer) {
clearTimeout(refs.modalTimer); clearTimeout(refs.modalTimer);
refs.modalTimer = null;
} }
const delay = }
typeof autoCloseMs === "number" && Number.isFinite(autoCloseMs) && autoCloseMs > 0
? autoCloseMs function buildFeishuSuccessModalContent(result) {
: 2500; const fragment = root.document.createDocumentFragment();
refs.modalTimer = setTimeout(() => closeModal(refs), delay); 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) { function bindUi(controller, refs) {
@ -1508,39 +1879,24 @@
closeModal(refs); closeModal(refs);
refs.modalCloseButton.addEventListener("click", () => 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.toggle.addEventListener("click", () => {
refs.panel.classList.toggle("is-open"); refs.panel.classList.toggle("is-open");
}); });
refs.exportButton.addEventListener("click", async () => { refs.localExportButton.addEventListener("click", async () => {
try { try {
const checkedFields = getCheckedFields(refs.fields); const checkedFields = getCheckedFields(refs.fields);
if (!checkedFields.length) { if (!checkedFields.length) {
throw new Error("请至少勾选一个导出字段。"); throw new Error("请至少勾选一个导出字段。");
} }
refs.exportButton.disabled = true; setExportButtonsDisabled(refs, true);
hideProgress(refs); hideProgress(refs);
setProgress(refs, 0, "准备导出...", false); setProgress(refs, 0, "准备导出...", false);
setStatus(refs.status, "正在读取达人数据,请稍候...", false); setStatus(refs.status, "正在读取达人数据,请稍候...", false);
const rawInput = refs.input.value; await prepareExportData(controller, refs, checkedFields, 45);
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);
});
setStatus(refs.status, "正在生成导出文件...", false); setStatus(refs.status, "正在生成导出文件...", false);
const result = await controller.exportSheetAsync( const result = await controller.exportSheetAsync(
checkedFields, checkedFields,
@ -1554,7 +1910,7 @@
); );
downloadFile(result.filename, result.content); downloadFile(result.filename, result.content);
setProgress(refs, 100, "已触发下载", false); setProgress(refs, 100, "已触发下载", false);
openModal(refs, `文件:${result.filename}`, 2500); openModal(refs, `文件:${result.filename}`);
setStatus( setStatus(
refs.status, refs.status,
`已导出 ${result.rowCount ?? (result.rows ? result.rows.length : 0)} 条达人数据,文件名:${result.filename}`, `已导出 ${result.rowCount ?? (result.rows ? result.rows.length : 0)} 条达人数据,文件名:${result.filename}`,
@ -1564,7 +1920,48 @@
setProgress(refs, 100, "导出失败", true); setProgress(refs, 100, "导出失败", true);
setStatus(refs.status, error.message || "导出失败。", true); setStatus(refs.status, error.message || "导出失败。", true);
} finally { } 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 { return {
API_BASE, API_BASE,
buildExportRows, buildExportRows,
buildFeishuRange,
buildFeishuSheetValues,
buildFieldOptions, buildFieldOptions,
buildSpreadsheetXml,
createExportController, createExportController,
createFeishuSpreadsheet,
extractBloggerId, extractBloggerId,
exportRecordsToFeishuSpreadsheet,
fetchMergedBloggerRecord, fetchMergedBloggerRecord,
flattenRecord, flattenRecord,
getFieldLabel, getFieldLabel,
getFeishuFirstSheetId,
getFeishuTenantAccessToken,
parseCreatorInputs, parseCreatorInputs,
writeFeishuSheetValues,
}; };
}); });

View File

@ -1,10 +1,8 @@
const test = require("node:test"); const test = require("node:test");
const assert = require("node:assert/strict"); 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.document = { cookie: "x=y" };
global.GM_xmlhttpRequest = undefined;
const api = require("./xhs-pgy-export.user.js"); 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("business"), "1");
assert.equal(parsed.searchParams.get("userId"), "u-123"); 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;
}
});

View File

@ -44,14 +44,24 @@ function extractReportId(reportInfo) {
); );
} }
function extractReportInfo(input) {
if (!input.payload) {
return input;
}
return ensureObject(input.payload, 'payload');
}
function validateAndNormalizeReportInput(input) { function validateAndNormalizeReportInput(input) {
const reportInfo = ensureObject(input, 'body'); const body = ensureObject(input, 'body');
const reportId = extractReportId(reportInfo); const reportId = extractReportId(body);
if (!reportId) { if (!reportId) {
throw new ValidationError('report_id is required in response info'); throw new ValidationError('report_id is required in response info');
} }
const reportInfo = extractReportInfo(body);
return { return {
reportId, reportId,
reportInfo, reportInfo,
@ -68,6 +78,7 @@ function toDatabaseRecord(normalizedReport) {
module.exports = { module.exports = {
ValidationError, ValidationError,
extractReportId, extractReportId,
extractReportInfo,
validateAndNormalizeReportInput, validateAndNormalizeReportInput,
toDatabaseRecord, toDatabaseRecord,
}; };

View File

@ -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', () => { test('validateAndNormalizeReportInput accepts response info with data.reportId', () => {
const input = createResponseInfo(); 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'],
},
});
});

View File

@ -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) { async function withServer(repository, callback) {
const app = createApp({ const app = createApp({
repository, 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,
});
},
);
});

View File

@ -199,19 +199,53 @@ test("buildAutoCopyPayload deep clones payload, shifts top-level dates, and appe
assert.notEqual(copied.nested, original.nested); assert.notEqual(copied.nested, original.nested);
}); });
test("buildPersistRequest keeps the response info unchanged for backend parsing", () => { test("buildPersistRequest sends create payload and metadata to the backend", () => {
const responseInfo = { const payload = {
code: 0, name: "测试报告",
message: "success", startTime: "2025-03-01",
data: { endTime: "2026-02-28",
reportId: "report-001", categories: [],
status: "SUCCESS", analysisDims: ["MARKETOVERVIEW"],
},
}; };
const request = api.buildPersistRequest(responseInfo); const request = api.buildPersistRequest({
payload,
assert.deepEqual(request, responseInfo); aadvid: "1648829117232140",
assert.notEqual(request, responseInfo); reportId: "report-001",
assert.notEqual(request.data, responseInfo.data); sourceType: "MANUAL_CAPTURE",
sourceReportId: null,
});
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");
}); });

View File

@ -3,7 +3,7 @@
// @namespace https://yuntu.oceanengine.com/ // @namespace https://yuntu.oceanengine.com/
// @version 0.1.0 // @version 0.1.0
// @description 记录最近一次成功创建的云图报告,并一键复制同比报告 // @description 记录最近一次成功创建的云图报告,并一键复制同比报告
// @author wangxi // @author wangxuesheng
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketList* // @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/segmentedMarketcreation*
// @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/* // @match https://yuntu.oceanengine.com/yuntu_brand/ecom/product/segmentedMarketDetail/*
@ -162,8 +162,20 @@
return cloned; return cloned;
} }
function buildPersistRequest(responseJson) { function buildPersistRequest({
return deepCloneJson(responseJson); payload,
aadvid,
reportId,
sourceType,
sourceReportId,
}) {
return {
reportId: String(reportId),
sourceType,
sourceReportId: sourceReportId ? String(sourceReportId) : null,
aadvid: String(aadvid),
payload: deepCloneJson(payload),
};
} }
function parseJson(text) { function parseJson(text) {
@ -424,6 +436,7 @@
try { try {
const nextPayload = buildAutoCopyPayload(payload); const nextPayload = buildAutoCopyPayload(payload);
const requestUrl = getCreateRequestUrl(meta); const requestUrl = getCreateRequestUrl(meta);
const sourceReportId = meta.reportId || null;
const { responseJson } = await createReportThroughPage(requestUrl, nextPayload); const { responseJson } = await createReportThroughPage(requestUrl, nextPayload);
const reportId = extractReportId(responseJson); const reportId = extractReportId(responseJson);
@ -440,7 +453,15 @@
}; };
writeStoredData(nextPayload, nextMeta); writeStoredData(nextPayload, nextMeta);
await persistToLocalServer(buildPersistRequest(responseJson)); await persistToLocalServer(
buildPersistRequest({
payload: nextPayload,
aadvid: nextMeta.aadvid,
reportId,
sourceType: "AUTO_COPY",
sourceReportId,
}),
);
showToast(`同比报告创建成功:${reportId}`, "success"); showToast(`同比报告创建成功:${reportId}`, "success");
} catch (error) { } catch (error) {
@ -486,7 +507,15 @@
writeStoredData(payload, meta); writeStoredData(payload, meta);
updateButtonState(); updateButtonState();
await persistToLocalServer(buildPersistRequest(responseJson)); await persistToLocalServer(
buildPersistRequest({
payload,
aadvid,
reportId,
sourceType: "MANUAL_CAPTURE",
sourceReportId: null,
}),
);
showToast(`已记录报告配置:${reportId}`, "success"); showToast(`已记录报告配置:${reportId}`, "success");
} catch (error) { } catch (error) {