// ==UserScript== // @name 小红书蒲公英达人信息导出 // @namespace https://pgy.xiaohongshu.com/ // @version 0.1.3 // @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 extractIdFromHtml(html) { const text = normalizeScalar(html); if (!text) { return ""; } const userIdPatterns = [ /"user"\s*:\s*\{[^{}]*"id"\s*:\s*"([0-9a-f]{24})"/i, /"userId"\s*:\s*"([0-9a-f]{24})"/i, /"realUserId"\s*:\s*"([0-9a-f]{24})"/i, ]; for (const pattern of userIdPatterns) { const match = text.match(pattern); if (match) { return match[1]; } } return ""; } function resolveShortUrl(url) { return new Promise((resolve) => { if (typeof GM_xmlhttpRequest !== "function") { resolve({ url, html: "" }); return; } GM_xmlhttpRequest({ method: "GET", url, onload(res) { const html = res.responseText || ""; if (res.finalUrl && res.finalUrl !== url) { resolve({ url: res.finalUrl, html }); return; } const match = html && html.match(/href="([^"]+)"/); if (match) { resolve({ url: match[1].replace(/&/g, "&"), html }); } else { resolve({ url, html }); } }, onerror() { resolve({ url, html: "" }); }, }); }); } 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 resolved = await resolveShortUrl(raw); const realUrl = typeof resolved === "string" ? resolved : resolved.url; try { const resolvedId = extractIdFromUrl(new URL(realUrl)); if (resolvedId) { return resolvedId; } } catch (error) { return extractIdFromHtml(resolved.html); } return extractIdFromHtml(resolved.html); } 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 }; } function appendQueryParam(url, key, value) { const safeValue = normalizeScalar(value); if (!safeValue) { return url; } const separator = String(url).includes("?") ? "&" : "?"; return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(safeValue)}`; } 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 cookie = extra["X-Cookie"] || extra["x-cookie"]; const url = appendQueryParam(config.buildUrl(userId), "cookie", cookie); const response = await fetcher(url, { 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, }; } function buildFeishuBitableRecords(records, selectedFields) { const fields = Array.isArray(selectedFields) ? selectedFields : []; const labels = fields.map((field) => getFieldLabel(field)); const list = Array.isArray(records) ? records : []; return list.map((record) => { const flattened = record && record.flattened ? record.flattened : {}; const row = {}; fields.forEach((field, index) => { row[labels[index]] = normalizeScalar(flattened[field]); }); return { fields: row }; }); } async function createFeishuBitableApp(options) { const settings = options || {}; const token = settings.tenantAccessToken; if (!token) { throw new Error("缺少飞书 tenant_access_token,无法创建多维表格。"); } const json = await feishuApiRequest("/bitable/v1/apps", { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: { name: settings.title || `蒲公英达人导出-${formatTimestamp(new Date())}`, }, fetchImpl: settings.fetchImpl, actionName: "创建飞书多维表格", }); const app = json?.data?.app || json?.data || {}; const appToken = app.app_token || app.appToken || json?.data?.app_token; const tableId = app.default_table_id || app.defaultTableId || json?.data?.default_table_id; if (!appToken) { throw new Error("创建飞书多维表格失败:响应中缺少 app_token。"); } if (!tableId) { throw new Error("创建飞书多维表格失败:响应中缺少 default_table_id。"); } return { appToken, tableId, url: app.url || json?.data?.url || "", }; } async function createFeishuBitableTextFields(options) { const settings = options || {}; const token = settings.tenantAccessToken; const appToken = settings.appToken; const tableId = settings.tableId; const fields = Array.isArray(settings.fields) ? settings.fields : []; if (!token || !appToken || !tableId) { throw new Error("缺少飞书多维表格字段创建参数。"); } const createdFields = []; for (const field of fields) { const label = getFieldLabel(field); const json = await feishuApiRequest( `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( tableId, )}/fields`, { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: { field_name: label, type: 1, }, fetchImpl: settings.fetchImpl, actionName: `创建飞书多维表格字段 ${label}`, }, ); createdFields.push(json?.data?.field || json?.data || {}); } return createdFields; } async function updateFeishuBitableTextField(options) { const settings = options || {}; const token = settings.tenantAccessToken; const appToken = settings.appToken; const tableId = settings.tableId; const fieldId = settings.fieldId; const fieldName = settings.fieldName; if (!token || !appToken || !tableId || !fieldId || !fieldName) { throw new Error("缺少飞书多维表格字段更新参数。"); } const json = await feishuApiRequest( `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( tableId, )}/fields/${encodeURIComponent(fieldId)}`, { method: "PUT", headers: { Authorization: `Bearer ${token}`, }, body: { field_name: fieldName, type: 1, }, fetchImpl: settings.fetchImpl, actionName: `更新飞书多维表格字段 ${fieldName}`, }, ); return json?.data?.field || json?.data || {}; } async function listFeishuBitableFields(options) { const settings = options || {}; const token = settings.tenantAccessToken; const appToken = settings.appToken; const tableId = settings.tableId; if (!token || !appToken || !tableId) { throw new Error("缺少飞书多维表格字段查询参数。"); } const json = await feishuApiRequest( `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( tableId, )}/fields`, { method: "GET", headers: { Authorization: `Bearer ${token}`, }, fetchImpl: settings.fetchImpl, actionName: "获取飞书多维表格字段", }, ); return json?.data?.items || json?.data?.fields || []; } function pickReusableFeishuBitablePrimaryField(fields) { const list = Array.isArray(fields) ? fields : []; return ( list.find((field) => field.is_primary || field.isPrimary) || list.find((field) => field.field_id || field.fieldId || field.id) || null ); } async function prepareFeishuBitableFields(options) { const settings = options || {}; const fields = Array.isArray(settings.fields) ? settings.fields : []; if (!fields.length) { return []; } const existingFields = await listFeishuBitableFields(settings); const reusableField = pickReusableFeishuBitablePrimaryField(existingFields); const createdFields = []; let remainingFields = fields.slice(); if (reusableField) { const fieldId = reusableField.field_id || reusableField.fieldId || reusableField.id; createdFields.push( await updateFeishuBitableTextField({ ...settings, fieldId, fieldName: getFieldLabel(fields[0]), }), ); remainingFields = fields.slice(1); } createdFields.push( ...(await createFeishuBitableTextFields({ ...settings, fields: remainingFields, })), ); return createdFields; } async function deleteFeishuBitableField(options) { const settings = options || {}; const token = settings.tenantAccessToken; const appToken = settings.appToken; const tableId = settings.tableId; const fieldId = settings.fieldId; if (!token || !appToken || !tableId || !fieldId) { throw new Error("缺少飞书多维表格字段删除参数。"); } await feishuApiRequest( `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( tableId, )}/fields/${encodeURIComponent(fieldId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}`, }, fetchImpl: settings.fetchImpl, actionName: "删除飞书多维表格默认字段", }, ); } async function deleteFeishuBitableExtraFields(options) { const settings = options || {}; const selectedLabels = new Set( (Array.isArray(settings.fields) ? settings.fields : []).map((field) => getFieldLabel(field), ), ); const fields = await listFeishuBitableFields(settings); const removableFields = fields.filter((field) => { const fieldName = field.field_name || field.fieldName || field.name; const fieldId = field.field_id || field.fieldId || field.id; return fieldId && fieldName && !selectedLabels.has(fieldName); }); for (const field of removableFields) { await deleteFeishuBitableField({ ...settings, fieldId: field.field_id || field.fieldId || field.id, }); } return { deletedCount: removableFields.length }; } async function listFeishuBitableRecords(options) { const settings = options || {}; const token = settings.tenantAccessToken; const appToken = settings.appToken; const tableId = settings.tableId; if (!token || !appToken || !tableId) { throw new Error("缺少飞书多维表格记录查询参数。"); } const records = []; let pageToken = ""; do { const suffix = pageToken ? `?page_token=${encodeURIComponent(pageToken)}` : ""; const json = await feishuApiRequest( `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( tableId, )}/records${suffix}`, { method: "GET", headers: { Authorization: `Bearer ${token}`, }, fetchImpl: settings.fetchImpl, actionName: "获取飞书多维表格默认记录", }, ); const data = json?.data || {}; records.push(...(data.items || data.records || [])); pageToken = data.has_more ? data.page_token || "" : ""; } while (pageToken); return records; } async function deleteFeishuBitableRecords(options) { const settings = options || {}; const token = settings.tenantAccessToken; const appToken = settings.appToken; const tableId = settings.tableId; const recordIds = Array.isArray(settings.recordIds) ? settings.recordIds : []; if (!token || !appToken || !tableId) { throw new Error("缺少飞书多维表格记录删除参数。"); } if (!recordIds.length) { return { deletedCount: 0 }; } const batchSize = 500; let deletedCount = 0; for (let index = 0; index < recordIds.length; index += batchSize) { const batch = recordIds.slice(index, index + batchSize); await feishuApiRequest( `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( tableId, )}/records/batch_delete`, { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: { records: batch, }, fetchImpl: settings.fetchImpl, actionName: "删除飞书多维表格默认记录", }, ); deletedCount += batch.length; } return { deletedCount }; } async function deleteFeishuBitableDefaultRecords(options) { const records = await listFeishuBitableRecords(options); const recordIds = records .map((record) => record.record_id || record.recordId || record.id) .filter(Boolean); return deleteFeishuBitableRecords({ ...options, recordIds, }); } async function writeFeishuBitableRecords(options) { const settings = options || {}; const token = settings.tenantAccessToken; const appToken = settings.appToken; const tableId = settings.tableId; const records = Array.isArray(settings.records) ? settings.records : []; if (!token || !appToken || !tableId) { throw new Error("缺少飞书多维表格写入参数。"); } if (!records.length) { throw new Error("没有可写入飞书多维表格的数据。"); } const batchSize = 500; let writtenCount = 0; for (let index = 0; index < records.length; index += batchSize) { const batch = records.slice(index, index + batchSize); await feishuApiRequest( `/bitable/v1/apps/${encodeURIComponent(appToken)}/tables/${encodeURIComponent( tableId, )}/records/batch_create`, { method: "POST", headers: { Authorization: `Bearer ${token}`, }, body: { records: batch, }, fetchImpl: settings.fetchImpl, actionName: "写入飞书多维表格", }, ); writtenCount += batch.length; } return { writtenCount }; } async function exportRecordsToFeishuBitable(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 bitable = await createFeishuBitableApp({ tenantAccessToken, title: settings.title, fetchImpl, }); await prepareFeishuBitableFields({ tenantAccessToken, appToken: bitable.appToken, tableId: bitable.tableId, fields, fetchImpl, }); await deleteFeishuBitableExtraFields({ tenantAccessToken, appToken: bitable.appToken, tableId: bitable.tableId, fields, fetchImpl, }); await deleteFeishuBitableDefaultRecords({ tenantAccessToken, appToken: bitable.appToken, tableId: bitable.tableId, fetchImpl, }); const bitableRecords = buildFeishuBitableRecords(records, fields); const writeResult = await writeFeishuBitableRecords({ tenantAccessToken, appToken: bitable.appToken, tableId: bitable.tableId, records: bitableRecords, fetchImpl, }); return { ...bitable, rowCount: records.length, writtenCount: writeResult.writtenCount, }; } 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 { getFeishuCredentials() { return resolveFeishuCredentials(settings); }, saveFeishuCredentials(credentials) { const appId = String((credentials && credentials.appId) || "").trim(); const appSecret = String((credentials && credentials.appSecret) || "").trim(); if (!appId || !appSecret) { throw new Error("请填写飞书应用 app_id 和 app_secret。"); } saveLocal(FEISHU_APP_ID_STORAGE_KEY, appId); saveLocal(FEISHU_APP_SECRET_STORAGE_KEY, appSecret); return { appId, appSecret, }; }, 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; }, async exportFeishuBitable(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 exportRecordsToFeishuBitable({ 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } 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-config { display: grid; gap: 8px; } .xhs-export-config-panel { display: none; gap: 10px; padding: 12px; border-radius: 14px; background: rgba(255, 255, 255, 0.72); border: 1px solid rgba(123, 83, 52, 0.12); } .xhs-export-config.is-open .xhs-export-config-panel { display: grid; } .xhs-export-config-grid { display: grid; grid-template-columns: 1fr; gap: 10px; } .xhs-export-config-field { display: grid; gap: 6px; } .xhs-export-config-label { font-size: 12px; font-weight: 800; color: #5e412f; } .xhs-export-config-input { 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-radio-group { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .xhs-export-radio { position: relative; min-width: 0; } .xhs-export-radio-input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; } .xhs-export-radio-label { display: flex; align-items: center; justify-content: center; min-height: 34px; padding: 8px 10px; border: 1px solid rgba(141, 88, 51, 0.18); border-radius: 12px; background: rgba(255, 255, 255, 0.76); color: #5e412f; font-size: 12px; font-weight: 800; text-align: center; } .xhs-export-radio-input:checked + .xhs-export-radio-label { border-color: transparent; color: #fff8ef; background: linear-gradient(135deg, #ef6a00, #d72638); } .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 { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 42px; margin-top: 12px; border-radius: 999px; color: #fff8ef; background: linear-gradient(135deg, #ef6a00, #d72638); box-shadow: 0 12px 26px rgba(187, 61, 14, 0.24); font-weight: 800; text-decoration: none; } .xhs-export-modal-link:hover { filter: brightness(1.03); } .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 = `

蒲公英达人导出

输入达人主页链接或达人 ID,选择需要的 Excel 表头后直接导出。每行一个达人链接或达人 ID。

飞书导出格式
`; 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 = `

导出已完成

`; doc.body.appendChild(toggle); doc.body.appendChild(panel); doc.body.appendChild(modalBackdrop); return { toggle, panel, input: panel.querySelector(".xhs-export-input"), feishuConfig: panel.querySelector(".xhs-export-config"), feishuConfigTrigger: panel.querySelector('[data-action="toggle-feishu-config"]'), feishuAppIdInput: panel.querySelector('[data-config="feishu-app-id"]'), feishuAppSecretInput: panel.querySelector('[data-config="feishu-app-secret"]'), feishuTypeInputs: panel.querySelectorAll('input[name="xhs-export-feishu-type"]'), 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 = `
读取成功后,这里会列出可选表头与字段映射。
`; return; } const wrapper = root.document.createElement("div"); wrapper.className = "xhs-export-select"; wrapper.innerHTML = `
`; 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 = `
${escapeXml(labelText)}
`; 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 loadFeishuConfigForm(controller, refs) { if (!controller || !refs) { return; } const credentials = controller.getFeishuCredentials(); if (refs.feishuAppIdInput) { refs.feishuAppIdInput.value = credentials.appId || ""; } if (refs.feishuAppSecretInput) { refs.feishuAppSecretInput.value = credentials.appSecret || ""; } } function saveFeishuConfigForm(controller, refs) { return controller.saveFeishuCredentials({ appId: refs.feishuAppIdInput ? refs.feishuAppIdInput.value : "", appSecret: refs.feishuAppSecretInput ? refs.feishuAppSecretInput.value : "", }); } function getSelectedFeishuExportType(refs) { const selected = refs && refs.feishuTypeInputs ? Array.from(refs.feishuTypeInputs).find((input) => input.checked) : null; return selected && selected.value === "bitable" ? "bitable" : "spreadsheet"; } function getFeishuExportTypeLabel(type) { return type === "bitable" ? "飞书多维表格" : "飞书电子表格"; } 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, type) { const fragment = root.document.createDocumentFragment(); const label = getFeishuExportTypeLabel(type); const rowCount = result && result.rowCount; fragment.appendChild(root.document.createTextNode(`已导出 ${rowCount} 条数据:`)); const url = result && result.url; if (url) { fragment.appendChild(root.document.createElement("br")); const link = root.document.createElement("a"); link.className = "xhs-export-modal-link"; link.href = url; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = `打开${label}`; fragment.appendChild(link); return fragment; } const token = type === "bitable" ? (result && result.appToken) || "" : (result && result.spreadsheetToken) || ""; fragment.appendChild( root.document.createTextNode(`表格 token:${token}`), ); 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 : ""; loadFeishuConfigForm(controller, refs); 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"); }); if (refs.feishuConfigTrigger && refs.feishuConfig) { refs.feishuConfigTrigger.addEventListener("click", () => { const open = !refs.feishuConfig.classList.contains("is-open"); refs.feishuConfig.classList.toggle("is-open", open); refs.feishuConfigTrigger.setAttribute("aria-expanded", open ? "true" : "false"); }); } 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("请至少勾选一个导出字段。"); } const exportType = getSelectedFeishuExportType(refs); const exportLabel = getFeishuExportTypeLabel(exportType); setExportButtonsDisabled(refs, true); hideProgress(refs); setProgress(refs, 0, "准备导出到飞书...", false); saveFeishuConfigForm(controller, refs); setStatus(refs.status, "正在读取达人数据,请稍候...", false); await prepareExportData(controller, refs, checkedFields, 40); setStatus(refs.status, `正在创建${exportLabel}并写入数据...`, false); const exportMethod = exportType === "bitable" ? controller.exportFeishuBitable : controller.exportFeishuSpreadsheet; const result = await exportMethod.call( controller, checkedFields, (percentage, message) => setProgress( refs, 40 + Math.floor((percentage * 60) / 100), message || `正在写入${exportLabel}...`, false, ), ); setProgress(refs, 100, `已写入${exportLabel}`, false); openModal(refs, buildFeishuSuccessModalContent(result, exportType)); setStatus( refs.status, result.url ? `已导出 ${result.rowCount} 条达人数据到${exportLabel}:${result.url}` : `已导出 ${result.rowCount} 条达人数据到${exportLabel},表格 token:${ exportType === "bitable" ? result.appToken : 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, buildFeishuBitableRecords, buildFeishuRange, buildFeishuSheetValues, buildFieldOptions, createFeishuBitableApp, createFeishuBitableTextFields, createExportController, createFeishuSpreadsheet, deleteFeishuBitableDefaultRecords, deleteFeishuBitableExtraFields, deleteFeishuBitableField, deleteFeishuBitableRecords, extractBloggerId, exportRecordsToFeishuBitable, exportRecordsToFeishuSpreadsheet, fetchMergedBloggerRecord, flattenRecord, getFieldLabel, getFeishuFirstSheetId, getFeishuTenantAccessToken, listFeishuBitableFields, listFeishuBitableRecords, parseCreatorInputs, prepareFeishuBitableFields, updateFeishuBitableTextField, writeFeishuBitableRecords, writeFeishuSheetValues, }; });