diff --git a/pugongying/xhs-pgy-export.user.js b/pugongying/xhs-pgy-export.user.js index 4245f51..3cc0cd2 100644 --- a/pugongying/xhs-pgy-export.user.js +++ b/pugongying/xhs-pgy-export.user.js @@ -1,9 +1,9 @@ // ==UserScript== // @name 小红书蒲公英达人信息导出 // @namespace https://pgy.xiaohongshu.com/ -// @version 0.1.2 +// @version 0.1.3 // @author wangxuesheng -// @description 输入达人主页链接或达人 ID,勾选字段后导出 xlsx 或飞书电子表格 +// @description 输入达人主页链接或达人 ID,勾选字段后导出 xlsx、飞书电子表格或飞书多维表格 // @match https://pgy.xiaohongshu.com/* // @grant GM_xmlhttpRequest // @connect api.internal.intelligrow.cn @@ -260,29 +260,51 @@ 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); + resolve({ url, html: "" }); return; } GM_xmlhttpRequest({ method: "GET", url, onload(res) { + const html = res.responseText || ""; if (res.finalUrl && res.finalUrl !== url) { - resolve(res.finalUrl); + resolve({ url: res.finalUrl, html }); return; } - const match = res.responseText && res.responseText.match(/href="([^"]+)"/); + const match = html && html.match(/href="([^"]+)"/); if (match) { - resolve(match[1].replace(/&/g, "&")); + resolve({ url: match[1].replace(/&/g, "&"), html }); } else { - resolve(url); + resolve({ url, html }); } }, onerror() { - resolve(url); + resolve({ url, html: "" }); }, }); }); @@ -341,12 +363,17 @@ } if (SHORT_LINK_HOSTS.some((h) => parsedUrl.hostname.endsWith(h))) { - const realUrl = await resolveShortUrl(raw); + const resolved = await resolveShortUrl(raw); + const realUrl = typeof resolved === "string" ? resolved : resolved.url; try { - return extractIdFromUrl(new URL(realUrl)); + const resolvedId = extractIdFromUrl(new URL(realUrl)); + if (resolvedId) { + return resolvedId; + } } catch (error) { - return ""; + return extractIdFromHtml(resolved.html); } + return extractIdFromHtml(resolved.html); } return ""; @@ -506,6 +533,15 @@ 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,无法请求达人数据。"); @@ -537,7 +573,9 @@ 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), { + 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: { @@ -785,6 +823,176 @@ }; } + 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 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 createFeishuBitableTextFields({ + tenantAccessToken, + appToken: bitable.appToken, + tableId: bitable.tableId, + fields, + 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) { @@ -997,6 +1205,37 @@ 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(), @@ -1226,6 +1465,46 @@ 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; @@ -1652,6 +1931,19 @@