scriptCat/pugongying/xhs-pgy-export.user.js

2015 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name 小红书蒲公英达人信息导出
// @namespace https://pgy.xiaohongshu.com/
// @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==
(function bootstrap(root, factory) {
const api = factory(root);
if (typeof module === "object" && module.exports) {
module.exports = api;
}
})(typeof globalThis !== "undefined" ? globalThis : this, function factory(root) {
function gmFetch(url, options) {
return new Promise((resolve, reject) => {
const headers = options && options.headers ? options.headers : {};
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(res.responseText ? JSON.parse(res.responseText) : {}),
});
},
onerror(err) {
reject(new Error("GM_xmlhttpRequest 网络错误: " + (err.statusText || url)));
},
});
});
}
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",
buildUrl: (userId) =>
`https://pgy.xiaohongshu.com/api/solar/kol/data/${encodeURIComponent(
userId,
)}/fans_profile`,
},
{
namespace: "dataSummary",
buildUrl: (userId) =>
`${PROXY_API_BASE}/v1/pugongying/data_summary?userId=${encodeURIComponent(userId)}&business=1`,
extraHeaders: () => ({
"X-Cookie": root.document.cookie,
"Authorization": "Bearer PsjpalaBZF2EVIyU5M7V9KHzUstOIN82LyMn9nqLekExyxIBnjjURlMKMDBSZwrG",
}),
},
{
namespace: "fansSummary",
buildUrl: (userId) =>
`${PROXY_API_BASE}/v1/pugongying/fans_summary?userId=${encodeURIComponent(userId)}`,
extraHeaders: () => ({
"X-Cookie": root.document.cookie,
"Authorization": "Bearer PsjpalaBZF2EVIyU5M7V9KHzUstOIN82LyMn9nqLekExyxIBnjjURlMKMDBSZwrG",
}),
},
];
const NAMESPACE_LABEL_MAP = {
fansProfile: "粉丝画像",
dataSummary: "数据概览",
fansSummary: "粉丝概览",
};
const FIELD_LABEL_MAP = {
id: "ID",
dataSummary: "数据概览",
"dataSummary.mCpuvNum": "外溢进店中位数",
"dataSummary.estimatePictureCpm": "预估CPM图文",
"dataSummary.estimateVideoCpm": "预估CPM视频",
"dataSummary.picReadCost": "预估阅读单价(图文)",
"dataSummary.videoReadCost": "预估阅读单价(视频)",
"dataSummary.fans30GrowthRate": "粉丝量变化幅度(%)",
"dataSummary.mAccumImpNum": "曝光中位数",
"dataSummary.mEngagementNum": "互动中位数",
"dataSummary.readMedian": "阅读中位数",
fansSummary: "粉丝概览",
"fansSummary.activeFansRate": "活跃粉丝占比(%",
"fansSummary.readFansRate": "阅读粉丝占比(%",
"fansSummary.engageFansRate": "互动粉丝占比(%",
fansProfile: "粉丝画像",
"fansProfile.ages.<18": "18岁以下粉丝占比",
"fansProfile.ages.18-24": "18-24岁粉丝占比",
"fansProfile.ages.25-34": "25-34岁粉丝占比",
"fansProfile.ages.35-44": "35-44岁粉丝占比",
"fansProfile.ages.>44": "44岁以上粉丝占比",
"fansProfile.gender.male": "粉丝男性占比",
"fansProfile.gender.female": "粉丝女性占比",
userId: "达人ID",
name: "达人昵称",
redId: "小红书号",
location: "地区",
travelAreaList: "可接受的出行地",
personalTags: "人设标签",
fansCount: "粉丝数",
likeCollectCountInfo: "获赞与收藏",
businessNoteCount: "商业笔记数",
picturePrice: "图文报价",
videoPrice: "视频报价",
lowerPrice: "最低报价",
userType: "用户类型",
tradeType: "合作行业",
};
const SELECTABLE_FIELD_PATHS = Object.keys(FIELD_LABEL_MAP).filter(
(path) => !(path in NAMESPACE_LABEL_MAP),
);
const FIELD_GROUPS = [
{
label: "达人信息",
fields: SELECTABLE_FIELD_PATHS.filter(
(p) => !p.startsWith("fansProfile.") && !p.startsWith("dataSummary.") && !p.startsWith("fansSummary."),
),
},
{
label: "数据概览",
fields: SELECTABLE_FIELD_PATHS.filter((p) => p.startsWith("dataSummary.")),
},
{
label: "粉丝画像",
fields: SELECTABLE_FIELD_PATHS.filter(
(p) => p.startsWith("fansProfile.") || p.startsWith("fansSummary."),
),
},
];
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]";
}
function normalizeScalar(value) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value.trim();
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
return String(value);
}
function summarizeArray(list) {
if (!Array.isArray(list) || list.length === 0) {
return "";
}
const allScalar = list.every(
(item) =>
item === null ||
item === undefined ||
["string", "number", "boolean", "bigint"].includes(typeof item),
);
if (allScalar) {
return list.map(normalizeScalar).filter(Boolean).join(" | ");
}
return list
.map((item) => {
if (isPlainObject(item) || Array.isArray(item)) {
try {
return JSON.stringify(item);
} catch (error) {
return String(item);
}
}
return normalizeScalar(item);
})
.filter(Boolean)
.join(" | ");
}
function flattenRecord(record, prefix, target) {
const baseTarget = target || {};
const currentPrefix = prefix || "";
if (!isPlainObject(record)) {
if (currentPrefix) {
baseTarget[currentPrefix] = normalizeScalar(record);
}
return baseTarget;
}
const keys = Object.keys(record);
if (keys.length === 0 && currentPrefix) {
baseTarget[currentPrefix] = "";
return baseTarget;
}
for (const key of keys) {
const nextPath = currentPrefix ? `${currentPrefix}.${key}` : key;
const value = record[key];
if (Array.isArray(value)) {
if (
value.length &&
value.every(
(item) =>
isPlainObject(item) && "group" in item && "percent" in item,
)
) {
for (const item of value) {
baseTarget[`${nextPath}.${item.group}`] = item.percent;
}
} else {
baseTarget[nextPath] = summarizeArray(value);
}
continue;
}
if (isPlainObject(value)) {
flattenRecord(value, nextPath, baseTarget);
continue;
}
baseTarget[nextPath] = normalizeScalar(value);
}
return baseTarget;
}
function resolveShortUrl(url) {
return new Promise((resolve) => {
if (typeof GM_xmlhttpRequest !== "function") {
resolve(url);
return;
}
GM_xmlhttpRequest({
method: "GET",
url,
onload(res) {
if (res.finalUrl && res.finalUrl !== url) {
resolve(res.finalUrl);
return;
}
const match = res.responseText && res.responseText.match(/href="([^"]+)"/);
if (match) {
resolve(match[1].replace(/&amp;/g, "&"));
} else {
resolve(url);
}
},
onerror() {
resolve(url);
},
});
});
}
const SHORT_LINK_HOSTS = ["xhslink.com"];
function extractIdFromUrl(parsedUrl) {
const queryCandidates = ["id", "user_id", "userId", "bloggerId", "creatorId"];
for (const key of queryCandidates) {
const queryValue = parsedUrl.searchParams.get(key);
if (queryValue && /^[0-9a-f]{24}$/i.test(queryValue)) {
return queryValue;
}
}
const segments = parsedUrl.pathname
.split("/")
.map((segment) => segment.trim())
.filter(Boolean)
.reverse();
for (const segment of segments) {
if (/^[0-9a-f]{24}$/i.test(segment)) {
return segment;
}
}
return "";
}
async function extractBloggerId(value) {
const raw = normalizeScalar(value);
if (!raw) {
return "";
}
if (/^[0-9a-f]{24}$/i.test(raw)) {
return raw;
}
if (!/^https?:\/\//i.test(raw)) {
return "";
}
let parsedUrl;
try {
parsedUrl = new URL(raw);
} catch (error) {
return "";
}
const directId = extractIdFromUrl(parsedUrl);
if (directId) {
return directId;
}
if (SHORT_LINK_HOSTS.some((h) => parsedUrl.hostname.endsWith(h))) {
const realUrl = await resolveShortUrl(raw);
try {
return extractIdFromUrl(new URL(realUrl));
} catch (error) {
return "";
}
}
return "";
}
async function parseCreatorInputs(rawInput) {
const values = normalizeScalar(rawInput)
.split(/[\n,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
const ids = [];
const seen = new Set();
const resolved = await Promise.all(values.map((v) => extractBloggerId(v)));
for (const id of resolved) {
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
ids.push(id);
}
return ids;
}
function buildFieldOptions(records) {
const fieldMap = new Map();
for (const record of records) {
const flattened = record.flattened || {};
for (const path of Object.keys(flattened)) {
if (!FIELD_LABEL_MAP[path]) {
continue;
}
if (!fieldMap.has(path)) {
fieldMap.set(path, {
path,
label: getFieldLabel(path),
});
}
}
}
return Array.from(fieldMap.values()).sort((left, right) =>
left.path.localeCompare(right.path, "zh-CN"),
);
}
function buildSelectableFieldOptions() {
return SELECTABLE_FIELD_PATHS.map((path) => ({
path,
label: getFieldLabel(path),
}));
}
function getFieldLabel(path) {
if (FIELD_LABEL_MAP[path]) {
return FIELD_LABEL_MAP[path];
}
for (const [namespace, namespaceLabel] of Object.entries(NAMESPACE_LABEL_MAP)) {
if (path === namespace) {
return namespaceLabel;
}
if (path.startsWith(`${namespace}.`)) {
return `${namespaceLabel} - ${path.slice(namespace.length + 1)}`;
}
}
return path;
}
function pickDefaultFields(fieldOptions) {
return fieldOptions.slice(0, 12).map((field) => field.path);
}
function buildExportRows(records, selectedFields) {
return records.map((record) => {
const row = {};
for (const field of selectedFields) {
row[field] = record.flattened[field] || "";
}
return row;
});
}
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 = [
safeDate.getFullYear(),
String(safeDate.getMonth() + 1).padStart(2, "0"),
String(safeDate.getDate()).padStart(2, "0"),
"-",
String(safeDate.getHours()).padStart(2, "0"),
String(safeDate.getMinutes()).padStart(2, "0"),
String(safeDate.getSeconds()).padStart(2, "0"),
];
return parts.join("");
}
function unwrapResponsePayload(json) {
if (isPlainObject(json?.data)) {
return json.data;
}
if (isPlainObject(json?.result)) {
return json.result;
}
if (isPlainObject(json)) {
return json;
}
return { value: json };
}
async function fetchBloggerRecord(id, fetchImpl) {
if (typeof fetchImpl !== "function") {
throw new Error("当前环境不支持 fetch无法请求达人数据。");
}
const response = await fetchImpl(`${API_BASE}${encodeURIComponent(id)}`, {
method: "GET",
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
},
});
if (!response || !response.ok) {
const status = response ? response.status : "unknown";
throw new Error(`请求达人 ${id} 失败,状态码:${status}`);
}
const json = await response.json();
const payload = unwrapResponsePayload(json);
if (!Object.prototype.hasOwnProperty.call(payload, "id")) {
payload.id = id;
}
return payload;
}
async function fetchSupplementalPayload(userId, fetchImpl, config) {
const extra =
typeof config.extraHeaders === "function" ? config.extraHeaders() : {};
const hasExtra = Object.keys(extra).length > 0;
const fetcher = hasExtra && hasGmRequest() ? gmFetch : fetchImpl;
const response = await fetcher(config.buildUrl(userId), {
method: "GET",
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
...extra,
},
});
if (!response || !response.ok) {
const status = response ? response.status : "unknown";
throw new Error(
`请求补充数据 ${config.namespace} 失败userId=${userId},状态码:${status}`,
);
}
const json = await response.json();
return unwrapResponsePayload(json);
}
async function fetchMergedBloggerRecord(id, fetchImpl) {
const primaryPayload = await fetchBloggerRecord(id, fetchImpl);
const userId = primaryPayload.userId || primaryPayload.id || id;
const settledPayloads = await Promise.allSettled(
SUPPLEMENTAL_ENDPOINTS.map((config) =>
fetchSupplementalPayload(userId, fetchImpl, config).then((payload) => ({
namespace: config.namespace,
payload,
})),
),
);
const mergedPayload = {
...primaryPayload,
};
for (const result of settledPayloads) {
if (result.status !== "fulfilled") {
continue;
}
mergedPayload[result.value.namespace] = result.value.payload;
}
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) {
return [];
}
const size = Math.max(1, Number(limit) || 1);
const workerCount = Math.min(size, list.length);
const results = new Array(list.length);
let nextIndex = 0;
let doneCount = 0;
const worker = async () => {
while (true) {
const index = nextIndex;
nextIndex += 1;
if (index >= list.length) {
return;
}
results[index] = await mapper(list[index], index);
doneCount += 1;
if (typeof onDone === "function") {
onDone(doneCount, list.length);
}
}
};
const workers = Array.from({ length: workerCount }, () => worker());
await Promise.all(workers);
return results;
}
function createExportController(options) {
const settings = options || {};
const now = settings.now || (() => new Date());
const fetchImpl =
settings.fetchImpl || (typeof root.fetch === "function" ? root.fetch.bind(root) : null);
const concurrency = Math.max(1, Number(settings.concurrency) || 4);
let cachedRecords = [];
let cachedFields = [];
return {
async preview(rawInput, onProgress) {
const ids = await parseCreatorInputs(rawInput);
if (!ids.length) {
throw new Error("请输入至少一个有效的达人主页链接或达人 ID。");
}
const report = (current, total) => {
if (typeof onProgress === "function") {
onProgress(current, total);
}
};
report(0, ids.length);
const records = await mapWithConcurrency(
ids,
concurrency,
async (id) => {
const raw = await fetchMergedBloggerRecord(id, fetchImpl);
return {
id,
raw,
flattened: flattenRecord(raw),
};
},
(done, total) => report(done, total),
);
cachedRecords = records;
cachedFields = buildFieldOptions(records);
return {
ids,
records,
fields: cachedFields,
selectedFields: pickDefaultFields(cachedFields),
};
},
exportSheet(selectedFields) {
if (!cachedRecords.length) {
throw new Error("请先读取字段并确认达人数据。");
}
const fields =
Array.isArray(selectedFields) && selectedFields.length
? selectedFields
: cachedFields.map((field) => field.path);
const headers = fields.map((field) => getFieldLabel(field));
if (!root.XLSX) {
throw new Error("未加载 SheetJS无法导出 xlsx。");
}
const aoa = [headers.slice()];
for (const record of cachedRecords) {
aoa.push(fields.map((field) => record.flattened[field] || ""));
}
const ws = root.XLSX.utils.aoa_to_sheet(aoa);
const wb = root.XLSX.utils.book_new();
root.XLSX.utils.book_append_sheet(wb, ws, "达人数据");
const content = root.XLSX.write(wb, { bookType: "xlsx", type: "array" });
return {
filename: `xhs-bloggers-${formatTimestamp(now())}.xlsx`,
columns: fields,
headers,
content,
};
},
async exportSheetAsync(selectedFields, onProgress) {
if (!cachedRecords.length) {
throw new Error("请先读取字段并确认达人数据。");
}
const fields =
Array.isArray(selectedFields) && selectedFields.length
? selectedFields
: cachedFields.map((field) => field.path);
const headers = fields.map((field) => getFieldLabel(field));
const total = cachedRecords.length;
const report = (percentage, message) => {
if (typeof onProgress !== "function") {
return;
}
onProgress(Math.max(0, Math.min(100, percentage)), message || "");
};
report(0, "正在生成 Excel(.xlsx)...");
const aoa = [headers.slice()];
const yieldEvery = 50;
for (let index = 0; index < total; index += 1) {
const record = cachedRecords[index];
aoa.push(fields.map((field) => record.flattened[field] || ""));
const isLast = index === total - 1;
if (isLast || (index + 1) % yieldEvery === 0) {
const pct = total ? Math.floor(((index + 1) / total) * 100) : 100;
report(pct, `正在生成 ${index + 1}/${total}`);
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
report(100, "正在打包 xlsx...");
const XLSX = await ensureXlsx();
const ws = XLSX.utils.aoa_to_sheet(aoa);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "达人数据");
const content = XLSX.write(wb, { bookType: "xlsx", type: "array" });
return {
filename: `xhs-bloggers-${formatTimestamp(now())}.xlsx`,
columns: fields,
headers,
content,
rowCount: total,
};
},
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(),
fields: cachedFields.slice(),
};
},
};
}
function escapeXml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function saveLocal(key, value) {
try {
root.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
return;
}
}
function loadLocal(key, fallbackValue) {
try {
const raw = root.localStorage.getItem(key);
if (!raw) {
return fallbackValue;
}
return JSON.parse(raw);
} catch (error) {
return fallbackValue;
}
}
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",
"https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js",
];
const loadedScripts = new Map();
function loadScript(url) {
if (loadedScripts.has(url)) {
return loadedScripts.get(url);
}
const promise = new Promise((resolve, reject) => {
const script = root.document.createElement("script");
script.src = url;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载脚本失败:${url}`));
root.document.head.appendChild(script);
});
loadedScripts.set(url, promise);
return promise;
}
async function ensureXlsx() {
if (root.XLSX && root.XLSX.utils && typeof root.XLSX.write === "function") {
return root.XLSX;
}
for (const url of XLSX_CDN_URLS) {
try {
await loadScript(url);
if (root.XLSX && root.XLSX.utils && typeof root.XLSX.write === "function") {
return root.XLSX;
}
} catch (error) {
// try next url
}
}
throw new Error("加载 SheetJS 失败,可能被网络或页面 CSP 限制。");
}
function downloadFile(filename, content) {
const blob = new Blob([content], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const link = root.document.createElement("a");
const blobUrl = root.URL.createObjectURL(blob);
link.href = blobUrl;
link.download = filename;
root.document.body.appendChild(link);
link.click();
link.remove();
root.URL.revokeObjectURL(blobUrl);
}
function injectStyles(doc) {
if (doc.getElementById("xhs-pgy-export-style")) {
return;
}
const style = doc.createElement("style");
style.id = "xhs-pgy-export-style";
style.textContent = `
.xhs-export-toggle {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99999;
border: 0;
border-radius: 999px;
padding: 12px 18px;
font-size: 14px;
font-weight: 700;
color: #fff8eb;
background: linear-gradient(135deg, #f45d01, #d72638);
box-shadow: 0 12px 28px rgba(187, 61, 14, 0.28);
cursor: pointer;
}
.xhs-export-panel {
position: fixed;
right: 24px;
bottom: 84px;
z-index: 99999;
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 120px);
overflow: hidden;
display: none;
flex-direction: column;
border-radius: 20px;
background:
radial-gradient(circle at top right, rgba(255, 229, 205, 0.95), rgba(255, 245, 236, 0.98) 46%),
linear-gradient(160deg, rgba(255, 250, 246, 0.98), rgba(255, 238, 225, 0.98));
color: #31241d;
box-shadow: 0 24px 60px rgba(76, 34, 15, 0.22);
border: 1px solid rgba(190, 110, 61, 0.18);
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
}
.xhs-export-panel.is-open {
display: flex;
}
.xhs-export-header {
padding: 18px 18px 10px;
}
.xhs-export-title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.xhs-export-subtitle {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: #7c5b48;
}
.xhs-export-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 18px 92px;
overflow: auto;
}
.xhs-export-input {
min-height: 104px;
resize: vertical;
border: 1px solid rgba(141, 88, 51, 0.2);
border-radius: 14px;
padding: 12px 14px;
font-size: 13px;
line-height: 1.6;
background: rgba(255, 255, 255, 0.75);
color: #2e211a;
}
.xhs-export-actions,
.xhs-export-mini-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.xhs-export-actions {
align-items: center;
}
.xhs-export-btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.xhs-export-btn.primary {
background: linear-gradient(135deg, #ef6a00, #d72638);
color: #fff8ef;
}
.xhs-export-fab {
position: absolute;
right: 18px;
bottom: 18px;
z-index: 2;
border: 0;
border-radius: 999px;
padding: 14px 18px;
font-size: 14px;
font-weight: 900;
letter-spacing: 0.3px;
cursor: pointer;
color: #fff8ef;
background: linear-gradient(135deg, #ef6a00, #d72638);
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;
box-shadow: none;
}
.xhs-export-btn.secondary {
background: rgba(110, 67, 41, 0.08);
color: #5e412f;
}
.xhs-export-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.xhs-export-status {
min-height: 20px;
font-size: 12px;
color: #6b4b39;
}
.xhs-export-status.is-error {
color: #bb2528;
}
.xhs-export-progress {
display: grid;
gap: 6px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(123, 83, 52, 0.12);
}
.xhs-export-progress.is-hidden {
display: none;
}
.xhs-export-progress.is-error .xhs-export-progress-meta {
color: #bb2528;
}
.xhs-export-progress-meta {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: #6b4b39;
}
.xhs-export-progress-track {
height: 10px;
border-radius: 999px;
background: rgba(110, 67, 41, 0.12);
overflow: hidden;
}
.xhs-export-progress-bar {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, #ef6a00, #d72638);
transition: width 120ms linear;
}
.xhs-export-progress.is-error .xhs-export-progress-bar {
background: linear-gradient(90deg, #bb2528, #8a0f14);
}
.xhs-export-field-select {
display: grid;
gap: 8px;
}
.xhs-export-select {
display: grid;
gap: 8px;
}
.xhs-export-select-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid rgba(141, 88, 51, 0.2);
border-radius: 14px;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.75);
color: #2e211a;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.xhs-export-select-trigger::after {
content: "";
width: 10px;
height: 10px;
border-right: 2px solid rgba(110, 67, 41, 0.5);
border-bottom: 2px solid rgba(110, 67, 41, 0.5);
transform: rotate(45deg);
transition: transform 120ms ease;
flex: 0 0 auto;
}
.xhs-export-select.is-open .xhs-export-select-trigger::after {
transform: rotate(-135deg);
}
.xhs-export-select-panel {
display: none;
gap: 10px;
padding: 10px 12px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(123, 83, 52, 0.12);
}
.xhs-export-select.is-open .xhs-export-select-panel {
display: grid;
}
.xhs-export-select-search {
border: 1px solid rgba(141, 88, 51, 0.2);
border-radius: 12px;
padding: 9px 10px;
font-size: 12px;
background: rgba(255, 255, 255, 0.85);
color: #2e211a;
}
.xhs-export-select-list {
display: grid;
gap: 8px;
max-height: 280px;
overflow: auto;
padding: 2px;
}
.xhs-export-group-header {
font-size: 14px;
font-weight: 800;
color: #7a6152;
padding: 8px 4px 4px;
border-bottom: 1px solid rgba(141, 88, 51, 0.15);
}
.xhs-export-select-item {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(123, 83, 52, 0.12);
}
.xhs-export-select-item[hidden] {
display: none;
}
.xhs-export-select-item-row {
display: flex;
align-items: center;
gap: 8px;
}
.xhs-export-modal-backdrop {
position: fixed;
inset: 0;
z-index: 100000;
display: none;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(20, 12, 8, 0.32);
backdrop-filter: blur(2px);
}
.xhs-export-modal-backdrop.is-open {
display: flex;
}
.xhs-export-modal {
width: min(320px, calc(100vw - 36px));
border-radius: 22px;
padding: 22px 18px 18px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 30px 80px rgba(40, 18, 8, 0.25);
border: 1px solid rgba(190, 110, 61, 0.18);
text-align: center;
color: #31241d;
}
.xhs-export-modal-icon {
width: 150px;
height: 150px;
margin: 2px auto 10px;
}
.xhs-export-modal-title {
margin: 0;
font-size: 16px;
font-weight: 800;
letter-spacing: 0.3px;
}
.xhs-export-modal-subtitle {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: #7c5b48;
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 {
display: flex;
justify-content: center;
margin-top: 14px;
}
.xhs-export-modal-btn {
border: 0;
border-radius: 999px;
padding: 10px 16px;
font-size: 13px;
font-weight: 800;
cursor: pointer;
background: rgba(110, 67, 41, 0.08);
color: #5e412f;
}
.xhs-export-fields {
display: grid;
gap: 8px;
max-height: 320px;
overflow: auto;
padding: 2px;
}
.xhs-export-field {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(123, 83, 52, 0.12);
}
.xhs-export-field-row {
display: flex;
align-items: center;
gap: 8px;
}
.xhs-export-checkbox {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex: 0 0 auto;
}
.xhs-export-checkbox-input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.xhs-export-checkbox-box {
width: 18px;
height: 18px;
border-radius: 6px;
border: 2px solid rgba(110, 67, 41, 0.28);
background: rgba(255, 255, 255, 0.82);
display: grid;
place-items: center;
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
}
.xhs-export-checkbox-box::after {
content: "";
width: 9px;
height: 5px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
transform: rotate(-45deg);
opacity: 0;
transition: opacity 120ms ease;
}
.xhs-export-checkbox-input:checked + .xhs-export-checkbox-box {
border-color: transparent;
background: linear-gradient(135deg, #8edb7d, #4bbf73);
transform: translateY(-0.5px);
}
.xhs-export-checkbox-input:checked + .xhs-export-checkbox-box::after {
opacity: 1;
}
.xhs-export-field:focus-within {
outline: 2px solid rgba(110, 67, 41, 0.18);
outline-offset: 2px;
}
.xhs-export-field-name {
font-size: 13px;
font-weight: 700;
color: #34251d;
word-break: break-all;
}
.xhs-export-field-sample {
font-size: 11px;
color: #7a6152;
word-break: break-all;
}
@media (max-width: 768px) {
.xhs-export-toggle {
right: 16px;
bottom: 16px;
}
.xhs-export-panel {
right: 16px;
bottom: 72px;
width: calc(100vw - 20px);
}
}
`;
doc.head.appendChild(style);
}
function createPanel(doc) {
const toggle = doc.createElement("button");
toggle.className = "xhs-export-toggle";
toggle.textContent = "达人导出";
const panel = doc.createElement("section");
panel.className = "xhs-export-panel";
panel.innerHTML = `
<div class="xhs-export-header">
<h2 class="xhs-export-title">蒲公英达人导出</h2>
<p class="xhs-export-subtitle">输入达人主页链接或达人 ID选择需要的 Excel 表头后直接导出。每行一个达人链接或达人 ID。</p>
</div>
<div class="xhs-export-body">
<textarea class="xhs-export-input" placeholder="每行一个达人主页链接或达人 ID支持批量。"></textarea>
<div class="xhs-export-status"></div>
<div class="xhs-export-progress is-hidden">
<div class="xhs-export-progress-meta">
<span class="xhs-export-progress-text">准备就绪</span>
<span class="xhs-export-progress-pct">0%</span>
</div>
<div class="xhs-export-progress-track">
<div class="xhs-export-progress-bar"></div>
</div>
</div>
<div class="xhs-export-field-select"></div>
</div>
<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");
modalBackdrop.className = "xhs-export-modal-backdrop";
modalBackdrop.setAttribute("role", "dialog");
modalBackdrop.setAttribute("aria-modal", "true");
modalBackdrop.setAttribute("aria-hidden", "true");
modalBackdrop.innerHTML = `
<div class="xhs-export-modal">
<div class="xhs-export-modal-icon" aria-hidden="true">
<svg viewBox="0 0 200 200" width="150" height="150">
<circle cx="100" cy="100" r="78" fill="none" stroke="#9adf86" stroke-width="10" />
<path d="M62 105 L92 132 L146 78" fill="none" stroke="#9adf86" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<h3 class="xhs-export-modal-title">导出已完成</h3>
<p class="xhs-export-modal-subtitle"></p>
<div class="xhs-export-modal-actions">
<button class="xhs-export-modal-btn" type="button">知道了</button>
</div>
</div>
`;
doc.body.appendChild(toggle);
doc.body.appendChild(panel);
doc.body.appendChild(modalBackdrop);
return {
toggle,
panel,
input: panel.querySelector(".xhs-export-input"),
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"),
progressPct: panel.querySelector(".xhs-export-progress-pct"),
progressBar: panel.querySelector(".xhs-export-progress-bar"),
modalBackdrop,
modalSubtitle: modalBackdrop.querySelector(".xhs-export-modal-subtitle"),
modalCloseButton: modalBackdrop.querySelector(".xhs-export-modal-btn"),
fields: panel.querySelector(".xhs-export-field-select"),
};
}
function updateFieldSelectSummary(container) {
if (!container) {
return;
}
const trigger = container.querySelector(".xhs-export-select-trigger");
if (!trigger) {
return;
}
const checkedCount = container.querySelectorAll('input[type="checkbox"]:checked').length;
const totalCount = container.querySelectorAll('input[type="checkbox"]').length;
trigger.textContent = `可选字段(已选 ${checkedCount}/${totalCount}个字段)`;
}
function renderFields(container, fieldOptions, selectedFields) {
const selected = new Set(selectedFields);
container.innerHTML = "";
if (!fieldOptions.length) {
container.innerHTML = `<div class="xhs-export-field"><div class="xhs-export-field-sample">读取成功后,这里会列出可选表头与字段映射。</div></div>`;
return;
}
const wrapper = root.document.createElement("div");
wrapper.className = "xhs-export-select";
wrapper.innerHTML = `
<button class="xhs-export-select-trigger" type="button" aria-expanded="false"></button>
<div class="xhs-export-select-panel">
<input class="xhs-export-select-search" type="search" placeholder="搜索字段(支持表头/路径)">
<div class="xhs-export-select-list"></div>
</div>
`;
container.appendChild(wrapper);
const trigger = wrapper.querySelector(".xhs-export-select-trigger");
const panel = wrapper.querySelector(".xhs-export-select-panel");
const search = wrapper.querySelector(".xhs-export-select-search");
const list = wrapper.querySelector(".xhs-export-select-list");
const fieldByPath = new Map(fieldOptions.map((f) => [f.path, f]));
for (const group of FIELD_GROUPS) {
const groupFields = group.fields.filter((p) => fieldByPath.has(p));
if (!groupFields.length) continue;
const header = root.document.createElement("div");
header.className = "xhs-export-group-header";
header.textContent = group.label;
header.dataset.group = group.label;
list.appendChild(header);
for (const path of groupFields) {
const field = fieldByPath.get(path);
const labelText = field.label || field.path;
const item = root.document.createElement("label");
item.className = "xhs-export-select-item";
item.dataset.path = field.path;
item.dataset.label = labelText;
item.dataset.group = group.label;
item.innerHTML = `
<div class="xhs-export-select-item-row">
<span class="xhs-export-checkbox">
<input class="xhs-export-checkbox-input" type="checkbox" value="${escapeXml(
field.path,
)}" ${selected.has(field.path) ? "checked" : ""}>
<span class="xhs-export-checkbox-box" aria-hidden="true"></span>
</span>
<span class="xhs-export-field-name">${escapeXml(labelText)}</span>
</div>
`;
list.appendChild(item);
}
}
const setOpenState = (open) => {
wrapper.classList.toggle("is-open", Boolean(open));
if (trigger) {
trigger.setAttribute("aria-expanded", open ? "true" : "false");
}
if (open && search) {
search.focus();
}
};
if (trigger) {
trigger.addEventListener("click", () => {
setOpenState(!wrapper.classList.contains("is-open"));
});
}
if (search) {
search.addEventListener("input", () => {
const q = String(search.value || "").trim().toLowerCase();
for (const item of list.querySelectorAll(".xhs-export-select-item")) {
const label = String(item.dataset.label || "").toLowerCase();
const path = String(item.dataset.path || "").toLowerCase();
item.hidden = q ? !(label.includes(q) || path.includes(q)) : false;
}
for (const header of list.querySelectorAll(".xhs-export-group-header")) {
const group = header.dataset.group;
const hasVisible = list.querySelector(
`.xhs-export-select-item[data-group="${group}"]:not([hidden])`,
);
header.hidden = !hasVisible;
}
});
}
wrapper.addEventListener("change", () => updateFieldSelectSummary(container));
updateFieldSelectSummary(container);
}
function getCheckedFields(container) {
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map((checkbox) => checkbox.value)
.filter(Boolean);
}
function setStatus(node, message, isError) {
node.textContent = message;
node.classList.toggle("is-error", Boolean(isError));
}
function hideProgress(refs) {
if (!refs || !refs.progress) {
return;
}
refs.progress.classList.add("is-hidden");
refs.progress.classList.remove("is-error");
if (refs.progressBar) {
refs.progressBar.style.width = "0%";
}
if (refs.progressPct) {
refs.progressPct.textContent = "0%";
}
if (refs.progressText) {
refs.progressText.textContent = "准备就绪";
}
}
function setProgress(refs, percentage, message, isError) {
if (!refs || !refs.progress) {
return;
}
const pct = Math.max(0, Math.min(100, Number(percentage) || 0));
refs.progress.classList.remove("is-hidden");
refs.progress.classList.toggle("is-error", Boolean(isError));
if (refs.progressBar) {
refs.progressBar.style.width = `${pct}%`;
}
if (refs.progressPct) {
refs.progressPct.textContent = `${Math.round(pct)}%`;
}
if (refs.progressText && typeof message === "string" && message) {
refs.progressText.textContent = message;
}
}
function closeModal(refs) {
if (!refs || !refs.modalBackdrop) {
return;
}
if (refs.modalTimer) {
clearTimeout(refs.modalTimer);
refs.modalTimer = null;
}
refs.modalBackdrop.classList.remove("is-open");
refs.modalBackdrop.setAttribute("aria-hidden", "true");
}
function openModal(refs, subtitle) {
if (!refs || !refs.modalBackdrop) {
return;
}
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;
}
}
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) {
const persistedInput = loadLocal(STORAGE_INPUT_KEY, "");
const staticFields = buildSelectableFieldOptions();
const defaultSelectedFields = SELECTABLE_FIELD_PATHS.slice();
refs.input.value = typeof persistedInput === "string" ? persistedInput : "";
renderFields(
refs.fields,
staticFields,
defaultSelectedFields.length ? defaultSelectedFields : SELECTABLE_FIELD_PATHS.slice(),
);
closeModal(refs);
refs.modalCloseButton.addEventListener("click", () => closeModal(refs));
refs.toggle.addEventListener("click", () => {
refs.panel.classList.toggle("is-open");
});
refs.localExportButton.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, 45);
setStatus(refs.status, "正在生成导出文件...", false);
const result = await controller.exportSheetAsync(
checkedFields,
(percentage, message) =>
setProgress(
refs,
45 + Math.floor((percentage * 55) / 100),
message || "正在生成导出文件...",
false,
),
);
downloadFile(result.filename, result.content);
setProgress(refs, 100, "已触发下载", false);
openModal(refs, `文件:${result.filename}`);
setStatus(
refs.status,
`已导出 ${result.rowCount ?? (result.rows ? result.rows.length : 0)} 条达人数据,文件名:${result.filename}`,
false,
);
} catch (error) {
setProgress(refs, 100, "导出失败", true);
setStatus(refs.status, error.message || "导出失败。", true);
} finally {
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);
}
});
}
function mountUserscript() {
if (!root.document || root[SCRIPT_FLAG]) {
return;
}
root[SCRIPT_FLAG] = true;
injectStyles(root.document);
const refs = createPanel(root.document);
const controller = createExportController();
bindUi(controller, refs);
}
if (
root &&
root.document &&
root.location &&
/pgy\.xiaohongshu\.com$/i.test(root.location.hostname)
) {
if (root.document.readyState === "loading") {
root.document.addEventListener("DOMContentLoaded", mountUserscript, {
once: true,
});
} else {
mountUserscript();
}
}
return {
API_BASE,
buildExportRows,
buildFeishuRange,
buildFeishuSheetValues,
buildFieldOptions,
createExportController,
createFeishuSpreadsheet,
extractBloggerId,
exportRecordsToFeishuSpreadsheet,
fetchMergedBloggerRecord,
flattenRecord,
getFieldLabel,
getFeishuFirstSheetId,
getFeishuTenantAccessToken,
parseCreatorInputs,
writeFeishuSheetValues,
};
});