feat: update export automation scripts
This commit is contained in:
parent
6dcdbc1983
commit
07ff72eafa
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user