diff --git a/pugongying/package.json b/pugongying/package.json new file mode 100644 index 0000000..d8ba289 --- /dev/null +++ b/pugongying/package.json @@ -0,0 +1,9 @@ +{ + "name": "browser-script", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "node --test", + "test:coverage": "node --test --experimental-test-coverage" + } +} diff --git a/pugongying/src/xhs-pgy-export-core.js b/pugongying/src/xhs-pgy-export-core.js new file mode 100644 index 0000000..90caf50 --- /dev/null +++ b/pugongying/src/xhs-pgy-export-core.js @@ -0,0 +1,564 @@ +const API_BASE = + "https://pgy.xiaohongshu.com/api/solar/cooperator/user/blogger/"; +const SUPPLEMENTAL_ENDPOINTS = [ + { + namespace: "dataSummary", + buildUrl: (userId) => + `https://pgy.xiaohongshu.com/api/pgy/kol/data/data_summary?userId=${encodeURIComponent( + userId, + )}&business=1`, + }, + { + namespace: "fansSummary", + buildUrl: (userId) => + `https://pgy.xiaohongshu.com/api/solar/kol/data_v3/fans_summary?userId=${encodeURIComponent( + userId, + )}`, + }, + { + namespace: "fansProfile", + buildUrl: (userId) => + `https://pgy.xiaohongshu.com/api/solar/kol/data/${encodeURIComponent( + userId, + )}/fans_profile`, + }, +]; +const NAMESPACE_LABEL_MAP = { + dataSummary: "数据概览", + fansSummary: "粉丝概览", + fansProfile: "粉丝画像", +}; + +const FIELD_LABEL_MAP = { + id: "ID", + "metrics.fans": "粉丝数", + dataSummary: "数据概览", + fansSummary: "粉丝概览", + fansProfile: "粉丝画像", + "dataSummary.fans30GrowthRate": "近30天涨粉率", + "dataSummary.mAccumImpCompare": "曝光中位数超越率", + "dataSummary.noteType": "笔记内容类型", + "dataSummary.activeDayInLast7": "近7天活跃天数", + "dataSummary.responseRate": "响应率", + "dataSummary.avgRead": "平均阅读量", + "fansProfile.ages": "粉丝年龄分布", + "fansProfile.gender.male": "粉丝男性占比", + "fansProfile.gender.female": "粉丝女性占比", + "fansProfile.interests": "粉丝兴趣分布", + "fansProfile.provinces": "粉丝省份分布", + "fansProfile.cities": "粉丝城市分布", + "fansProfile.devices": "粉丝设备分布", + "fansProfile.dateKey": "画像日期", + "fansSummary.fansNum": "粉丝总数", + "fansSummary.fansIncreaseNum": "涨粉数", + "fansSummary.fansGrowthRate": "粉丝增长率", + "fansSummary.fansGrowthBeyondRate": "粉丝增长超越率", + "fansSummary.activeFansL28": "近28天活跃粉丝数", + "fansSummary.activeFansRate": "活跃粉丝占比", + "fansSummary.activeFansBeyondRate": "活跃粉丝超越率", + "fansSummary.engageFansRate": "互动粉丝占比", + "fansSummary.engageFansL30": "近30天互动粉丝数", + "fansSummary.engageFansBeyondRate": "互动粉丝超越率", + "fansSummary.readFansIn30": "近30天阅读粉丝数", + "fansSummary.readFansRate": "阅读粉丝占比", + "fansSummary.readFansBeyondRate": "阅读粉丝超越率", + "fansSummary.payFansUserRate30d": "近30天支付粉丝占比", + "fansSummary.payFansUserNum30d": "近30天支付粉丝数", + userId: "达人ID", + fansCount: "粉丝数", + name: "达人昵称", + redId: "小红书号", + location: "地区", + travelAreaList: "常驻地区", + personalTags: "人设标签", + contentTags: "内容标签", + likeCollectCountInfo: "获赞与收藏", + businessNoteCount: "商业笔记数", + totalNoteCount: "总笔记数", + picturePrice: "图文报价", + videoPrice: "视频报价", + lowerPrice: "最低报价", + userType: "用户类型", + tradeType: "合作行业", + clickMidNum: "阅读中位数", + accumCoopImpMedinNum30d: "近30天合作曝光中位数", + mEngagementNum: "互动中位数", + "clothingIndustryPrice.picturePrice": "服饰行业图文报价", +}; + +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)) { + baseTarget[nextPath] = summarizeArray(value); + continue; + } + + if (isPlainObject(value)) { + flattenRecord(value, nextPath, baseTarget); + continue; + } + + baseTarget[nextPath] = normalizeScalar(value); + } + + return baseTarget; +} + +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 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 ""; +} + +function parseCreatorInputs(rawInput) { + const values = normalizeScalar(rawInput) + .split(/[\n,,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); + + const ids = []; + const seen = new Set(); + + for (const value of values) { + const id = extractBloggerId(value); + 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 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 FIELD_LABEL_MAP[path] || 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 escapeXml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function sanitizeSheetName(value) { + const name = normalizeScalar(value) || "Sheet1"; + return name.replace(/[\\/?*:[\]]/g, "_").slice(0, 31) || "Sheet1"; +} + +function buildSpreadsheetXml(config) { + const sheetName = sanitizeSheetName(config.sheetName || "达人数据"); + const columns = Array.isArray(config.columns) ? config.columns : []; + const headers = + Array.isArray(config.headers) && config.headers.length === columns.length + ? config.headers + : columns; + const rows = Array.isArray(config.rows) ? config.rows : []; + const headerCells = columns + .map( + (column, index) => + `${escapeXml(headers[index] ?? column)}`, + ) + .join(""); + + const dataRows = rows + .map((row) => { + const cells = columns + .map((column) => { + const value = row[column] === undefined ? "" : row[column]; + return `${escapeXml(value)}`; + }) + .join(""); + return `${cells}`; + }) + .join(""); + + return ` + + + + + ${headerCells} + ${dataRows} +
+
+
`; +} + +function escapeCsvValue(value) { + const text = normalizeScalar(value); + if (/["\n,\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +function buildCsvContent(config) { + const columns = Array.isArray(config.columns) ? config.columns : []; + const headers = + Array.isArray(config.headers) && config.headers.length === columns.length + ? config.headers + : columns; + const rows = Array.isArray(config.rows) ? config.rows : []; + const headerLine = headers.map(escapeCsvValue).join(","); + const bodyLines = rows.map((row) => + columns + .map((column) => escapeCsvValue(row[column] === undefined ? "" : row[column])) + .join(","), + ); + + return `\uFEFF${[headerLine, ...bodyLines].join("\r\n")}`; +} + +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 response = await fetchImpl(config.buildUrl(userId), { + method: "GET", + credentials: "include", + headers: { + accept: "application/json, text/plain, */*", + }, + }); + + 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; +} + +function createExportController(options) { + const settings = options || {}; + const now = settings.now || (() => new Date()); + const fetchImpl = settings.fetchImpl; + let cachedRecords = []; + let cachedFields = []; + + return { + async preview(rawInput) { + const ids = parseCreatorInputs(rawInput); + if (!ids.length) { + throw new Error("请输入至少一个有效的达人主页链接或达人 ID。"); + } + + const records = []; + for (const id of ids) { + const raw = await fetchMergedBloggerRecord(id, fetchImpl); + records.push({ + id, + raw, + flattened: flattenRecord(raw), + }); + } + + 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 rows = buildExportRows(cachedRecords, fields); + const headers = fields.map((field) => getFieldLabel(field)); + const content = buildSpreadsheetXml({ + columns: fields, + headers, + rows, + sheetName: "达人数据", + }); + + return { + filename: `xhs-bloggers-${formatTimestamp(now())}.xls`, + columns: fields, + headers, + rows, + content, + }; + }, + + getState() { + return { + records: cachedRecords.slice(), + fields: cachedFields.slice(), + }; + }, + }; +} + +module.exports = { + API_BASE, + SUPPLEMENTAL_ENDPOINTS, + buildExportRows, + buildCsvContent, + buildFieldOptions, + buildSpreadsheetXml, + createExportController, + extractBloggerId, + fetchMergedBloggerRecord, + flattenRecord, + getFieldLabel, + parseCreatorInputs, +}; diff --git a/pugongying/test/userscript.test.js b/pugongying/test/userscript.test.js new file mode 100644 index 0000000..fbf673d --- /dev/null +++ b/pugongying/test/userscript.test.js @@ -0,0 +1,531 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + buildExportRows, + buildCsvContent, + buildFieldOptions, + buildSpreadsheetXml, + createExportController, + flattenRecord, + getFieldLabel, + parseCreatorInputs, +} = require("../src/xhs-pgy-export-core.js"); + +test("parseCreatorInputs supports ids, homepage links, dedupe and ignores junk", () => { + const inputs = ` + 5776652682ec3912d6f508d5 + https://www.xiaohongshu.com/user/profile/5776652682ec3912d6f508d5 + https://pgy.xiaohongshu.com/api/solar/cooperator/user/blogger/5f1234567890abcdef123456 + not-a-valid-id + `; + + assert.deepEqual(parseCreatorInputs(inputs), [ + "5776652682ec3912d6f508d5", + "5f1234567890abcdef123456", + ]); +}); + +test("flattenRecord expands nested objects and normalizes arrays", () => { + const flattened = flattenRecord({ + id: "abc", + profile: { + nickname: "达人A", + tags: ["美妆", "护肤"], + }, + prices: [ + { title: "图文", amount: 600 }, + { title: "视频", amount: 1200 }, + ], + empty: null, + }); + + assert.equal(flattened.id, "abc"); + assert.equal(flattened["profile.nickname"], "达人A"); + assert.equal(flattened["profile.tags"], "美妆 | 护肤"); + assert.match(flattened.prices, /图文/); + assert.equal(flattened.empty, ""); +}); + +test("buildFieldOptions and buildExportRows merge fields across records", () => { + const records = [ + { + raw: { id: "1", name: "达人A", metrics: { fans: 1000 } }, + flattened: flattenRecord({ id: "1", name: "达人A", metrics: { fans: 1000 } }), + }, + { + raw: { id: "2", name: "达人B", contact: { wechat: "abc123" } }, + flattened: flattenRecord({ id: "2", name: "达人B", contact: { wechat: "abc123" } }), + }, + ]; + + const fields = buildFieldOptions(records); + assert.deepEqual( + fields.map((field) => field.path), + ["id", "metrics.fans", "name"], + ); + assert.deepEqual( + fields.map((field) => field.label), + ["ID", "粉丝数", "达人昵称"], + ); + assert.deepEqual( + fields.map((field) => Object.keys(field).sort()), + [ + ["label", "path"], + ["label", "path"], + ["label", "path"], + ], + ); + + const rows = buildExportRows(records, ["id", "name", "contact.wechat"]); + assert.deepEqual(rows, [ + { id: "1", name: "达人A", "contact.wechat": "" }, + { id: "2", name: "达人B", "contact.wechat": "abc123" }, + ]); +}); + +test("getFieldLabel maps known creator fields and falls back for unknown fields", () => { + assert.equal(getFieldLabel("userId"), "达人ID"); + assert.equal(getFieldLabel("name"), "达人昵称"); + assert.equal(getFieldLabel("fansCount"), "粉丝数"); + assert.equal(getFieldLabel("contentTags"), "内容标签"); + assert.equal(getFieldLabel("clothingIndustryPrice.picturePrice"), "服饰行业图文报价"); + assert.equal(getFieldLabel("dataSummary"), "数据概览"); + assert.equal(getFieldLabel("dataSummary.fans30GrowthRate"), "近30天涨粉率"); + assert.equal(getFieldLabel("dataSummary.mAccumImpCompare"), "曝光中位数超越率"); + assert.equal(getFieldLabel("dataSummary.noteType"), "笔记内容类型"); + assert.equal(getFieldLabel("dataSummary.activeDayInLast7"), "近7天活跃天数"); + assert.equal(getFieldLabel("dataSummary.responseRate"), "响应率"); + assert.equal(getFieldLabel("fansSummary.fansNum"), "粉丝总数"); + assert.equal(getFieldLabel("fansSummary.fansIncreaseNum"), "涨粉数"); + assert.equal(getFieldLabel("fansSummary.fansGrowthRate"), "粉丝增长率"); + assert.equal(getFieldLabel("fansSummary.fansGrowthBeyondRate"), "粉丝增长超越率"); + assert.equal(getFieldLabel("fansSummary.activeFansL28"), "近28天活跃粉丝数"); + assert.equal(getFieldLabel("fansSummary.activeFansRate"), "活跃粉丝占比"); + assert.equal(getFieldLabel("fansSummary.activeFansBeyondRate"), "活跃粉丝超越率"); + assert.equal(getFieldLabel("fansSummary.engageFansRate"), "互动粉丝占比"); + assert.equal(getFieldLabel("fansSummary.engageFansL30"), "近30天互动粉丝数"); + assert.equal(getFieldLabel("fansSummary.engageFansBeyondRate"), "互动粉丝超越率"); + assert.equal(getFieldLabel("fansSummary.readFansIn30"), "近30天阅读粉丝数"); + assert.equal(getFieldLabel("fansSummary.readFansRate"), "阅读粉丝占比"); + assert.equal(getFieldLabel("fansSummary.readFansBeyondRate"), "阅读粉丝超越率"); + assert.equal(getFieldLabel("fansSummary.payFansUserRate30d"), "近30天支付粉丝占比"); + assert.equal(getFieldLabel("fansSummary.payFansUserNum30d"), "近30天支付粉丝数"); + assert.equal(getFieldLabel("fansSummary.cityDistribution"), "粉丝概览 - cityDistribution"); + assert.equal(getFieldLabel("fansProfile.ages"), "粉丝年龄分布"); + assert.equal(getFieldLabel("fansProfile.gender.male"), "粉丝男性占比"); + assert.equal(getFieldLabel("fansProfile.gender.female"), "粉丝女性占比"); + assert.equal(getFieldLabel("fansProfile.interests"), "粉丝兴趣分布"); + assert.equal(getFieldLabel("fansProfile.provinces"), "粉丝省份分布"); + assert.equal(getFieldLabel("fansProfile.cities"), "粉丝城市分布"); + assert.equal(getFieldLabel("fansProfile.devices"), "粉丝设备分布"); + assert.equal(getFieldLabel("fansProfile.dateKey"), "画像日期"); + assert.equal(getFieldLabel("metrics.customScore"), "metrics.customScore"); +}); + +test("buildSpreadsheetXml escapes xml-sensitive characters", () => { + const xml = buildSpreadsheetXml({ + columns: ["id", "name"], + rows: [{ id: "1", name: "A & B <达人>" }], + sheetName: "达人数据", + }); + + assert.match(xml, /A & B <达人>/); + assert.match(xml, //); +}); + +test("buildCsvContent adds BOM and escapes commas, quotes and newlines", () => { + const csv = buildCsvContent({ + columns: ["id", "name"], + headers: ["达人ID", "达人昵称"], + rows: [{ id: "1", name: 'A,"B"\n达人' }], + }); + + assert.equal(csv.charCodeAt(0), 0xfeff); + assert.match(csv, /达人ID,达人昵称/); + assert.match(csv, /1,"A,""B""\n达人"/); +}); + +test("createExportController previews and exports creator data", async () => { + const seenRequests = []; + const controller = createExportController({ + fetchImpl: async (url, options) => { + seenRequests.push({ url, options }); + if (!url.includes("/api/solar/cooperator/user/blogger/")) { + return { + ok: false, + status: 404, + json: async () => ({}), + }; + } + + const id = url.split("/").pop(); + return { + ok: true, + json: async () => ({ + success: true, + data: { + id, + name: `达人-${id.slice(-4)}`, + metrics: { + fans: Number(id.slice(-2)), + }, + }, + }), + }; + }, + now: () => new Date("2026-03-12T08:09:10Z"), + }); + + const preview = await controller.preview(` + 5776652682ec3912d6f508d5 + 5f1234567890abcdef123456 + `); + + assert.equal(seenRequests.length, 8); + assert.equal(preview.records.length, 2); + assert.deepEqual(preview.selectedFields, ["id", "metrics.fans", "name"]); + assert.equal(preview.fields.find((field) => field.path === "name").label, "达人昵称"); + assert.deepEqual( + preview.fields.map((field) => Object.keys(field).sort()), + preview.fields.map(() => ["label", "path"]), + ); + + const exported = controller.exportSheet(["id", "name", "metrics.fans"]); + assert.equal(exported.filename, "xhs-bloggers-20260312-160910.xls"); + assert.equal(exported.rows.length, 2); + assert.deepEqual(exported.headers, ["ID", "达人昵称", "粉丝数"]); + assert.match(exported.content, /<\?mso-application progid="Excel\.Sheet"\?>/); + assert.match(exported.content, //); + assert.match(exported.content, /达人昵称/); + assert.match(exported.content, /达人-08d5/); + assert.match(exported.content, /达人-3456/); +}); + +test("createExportController merges supplemental endpoint payloads into namespaced fields", async () => { + const seenUrls = []; + const controller = createExportController({ + fetchImpl: async (url) => { + seenUrls.push(url); + if (url.includes("/api/solar/cooperator/user/blogger/")) { + return { + ok: true, + json: async () => ({ + data: { + id: "61f27a60000000001000cb5f", + userId: "61f27a60000000001000cb5f", + name: "测试达人", + }, + }), + }; + } + + if (url.includes("/api/pgy/kol/data/data_summary")) { + return { + ok: true, + json: async () => ({ + data: { + avgRead: 1200, + avgInteract: 98, + }, + }), + }; + } + + if (url.includes("/api/solar/kol/data_v3/fans_summary")) { + return { + ok: true, + json: async () => ({ + data: { + cityDistribution: ["上海", "杭州"], + maleRate: 0.22, + }, + }), + }; + } + + if (url.includes("/api/solar/kol/data/61f27a60000000001000cb5f/fans_profile")) { + return { + ok: true, + json: async () => ({ + data: { + age18_24: 0.31, + age25_34: 0.44, + }, + }), + }; + } + + throw new Error(`unexpected url: ${url}`); + }, + }); + + const preview = await controller.preview("61f27a60000000001000cb5f"); + + assert.equal(seenUrls.length, 4); + assert.ok( + seenUrls.some((url) => + url.includes("/api/pgy/kol/data/data_summary?userId=61f27a60000000001000cb5f&business=1"), + ), + ); + assert.ok( + seenUrls.some((url) => + url.includes("/api/solar/kol/data_v3/fans_summary?userId=61f27a60000000001000cb5f"), + ), + ); + assert.ok( + seenUrls.some((url) => + url.includes("/api/solar/kol/data/61f27a60000000001000cb5f/fans_profile"), + ), + ); + assert.equal(preview.records[0].raw.dataSummary.avgRead, 1200); + assert.equal(preview.records[0].raw.fansSummary.maleRate, 0.22); + assert.equal(preview.records[0].raw.fansProfile.age18_24, 0.31); + assert.equal(preview.records[0].flattened["dataSummary.avgRead"], "1200"); + assert.equal(preview.records[0].flattened["fansSummary.cityDistribution"], "上海 | 杭州"); + assert.equal(preview.fields.find((field) => field.path === "dataSummary.avgRead").label, "平均阅读量"); + assert.equal( + preview.fields.find((field) => field.path === "fansProfile.age18_24"), + undefined, + ); +}); + +test("createExportController applies mapped Chinese headers for provided supplemental sample fields", async () => { + const controller = createExportController({ + fetchImpl: async (url) => { + if (url.includes("/api/solar/cooperator/user/blogger/")) { + return { + ok: true, + json: async () => ({ + data: { + id: "61f27a60000000001000cb5f", + userId: "61f27a60000000001000cb5f", + name: "李欢喜", + }, + }), + }; + } + + if (url.includes("/api/pgy/kol/data/data_summary")) { + return { + ok: true, + json: async () => ({ + data: { + fans30GrowthRate: "4.7", + activeDayInLast7: 7, + noteType: [{ contentTag: "母婴", percent: "100.0" }], + responseRate: "95.6", + }, + }), + }; + } + + if (url.includes("/api/solar/kol/data_v3/fans_summary")) { + return { + ok: true, + json: async () => ({ + data: {}, + }), + }; + } + + if (url.includes("/fans_profile")) { + return { + ok: true, + json: async () => ({ + data: { + ages: [{ group: "25-34", percent: 0.67 }], + gender: { male: 0.12, female: 0.88 }, + interests: [{ name: "母婴", percent: 0.17 }], + provinces: [{ name: "广东", percent: 0.14 }], + cities: [{ name: "广州", percent: 0.03 }], + devices: [{ name: "apple inc.", desc: "苹果", percent: 0.28 }], + dateKey: "2026-03-11", + }, + }), + }; + } + + throw new Error(`unexpected url: ${url}`); + }, + }); + + await controller.preview("61f27a60000000001000cb5f"); + const exported = controller.exportSheet([ + "name", + "dataSummary.fans30GrowthRate", + "dataSummary.activeDayInLast7", + "dataSummary.noteType", + "dataSummary.responseRate", + "fansProfile.ages", + "fansProfile.gender.male", + "fansProfile.gender.female", + "fansProfile.interests", + "fansProfile.provinces", + "fansProfile.cities", + "fansProfile.devices", + "fansProfile.dateKey", + ]); + + assert.deepEqual(exported.headers, [ + "达人昵称", + "近30天涨粉率", + "近7天活跃天数", + "笔记内容类型", + "响应率", + "粉丝年龄分布", + "粉丝男性占比", + "粉丝女性占比", + "粉丝兴趣分布", + "粉丝省份分布", + "粉丝城市分布", + "粉丝设备分布", + "画像日期", + ]); +}); + +test("createExportController applies mapped Chinese headers for provided fansSummary fields", async () => { + const controller = createExportController({ + fetchImpl: async (url) => { + if (url.includes("/api/solar/cooperator/user/blogger/")) { + return { + ok: true, + json: async () => ({ + data: { + id: "61f27a60000000001000cb5f", + userId: "61f27a60000000001000cb5f", + name: "李欢喜", + }, + }), + }; + } + + if (url.includes("/api/pgy/kol/data/data_summary")) { + return { ok: true, json: async () => ({ data: {} }) }; + } + + if (url.includes("/api/solar/kol/data_v3/fans_summary")) { + return { + ok: true, + json: async () => ({ + data: { + fansNum: 11824, + fansIncreaseNum: 534, + fansGrowthRate: "4.7", + fansGrowthBeyondRate: "90.3", + activeFansL28: 4329, + activeFansRate: "36.6", + activeFansBeyondRate: "31.4", + engageFansRate: "8.0", + engageFansL30: 946, + engageFansBeyondRate: "97.6", + readFansIn30: 1343, + readFansRate: "11.4", + readFansBeyondRate: "89.0", + payFansUserRate30d: "2.7", + payFansUserNum30d: 320, + }, + }), + }; + } + + if (url.includes("/fans_profile")) { + return { ok: true, json: async () => ({ data: {} }) }; + } + + throw new Error(`unexpected url: ${url}`); + }, + }); + + await controller.preview("61f27a60000000001000cb5f"); + const exported = controller.exportSheet([ + "fansSummary.fansNum", + "fansSummary.fansIncreaseNum", + "fansSummary.fansGrowthRate", + "fansSummary.fansGrowthBeyondRate", + "fansSummary.activeFansL28", + "fansSummary.activeFansRate", + "fansSummary.activeFansBeyondRate", + "fansSummary.engageFansRate", + "fansSummary.engageFansL30", + "fansSummary.engageFansBeyondRate", + "fansSummary.readFansIn30", + "fansSummary.readFansRate", + "fansSummary.readFansBeyondRate", + "fansSummary.payFansUserRate30d", + "fansSummary.payFansUserNum30d", + ]); + + assert.deepEqual(exported.headers, [ + "粉丝总数", + "涨粉数", + "粉丝增长率", + "粉丝增长超越率", + "近28天活跃粉丝数", + "活跃粉丝占比", + "活跃粉丝超越率", + "互动粉丝占比", + "近30天互动粉丝数", + "互动粉丝超越率", + "近30天阅读粉丝数", + "阅读粉丝占比", + "阅读粉丝超越率", + "近30天支付粉丝占比", + "近30天支付粉丝数", + ]); +}); + +test("createExportController tolerates supplemental endpoint failures", async () => { + const controller = createExportController({ + fetchImpl: async (url) => { + if (url.includes("/api/solar/cooperator/user/blogger/")) { + return { + ok: true, + json: async () => ({ + data: { + id: "61f27a60000000001000cb5f", + userId: "61f27a60000000001000cb5f", + name: "测试达人", + }, + }), + }; + } + + return { + ok: false, + status: 500, + json: async () => ({}), + }; + }, + }); + + const preview = await controller.preview("61f27a60000000001000cb5f"); + assert.equal(preview.records[0].raw.name, "测试达人"); + assert.equal(preview.records[0].raw.dataSummary, undefined); + assert.equal(preview.records[0].flattened.name, "测试达人"); +}); + +test("createExportController rejects empty input and request failures", async () => { + const controller = createExportController({ + fetchImpl: async () => ({ + ok: false, + status: 403, + json: async () => ({}), + }), + }); + + await assert.rejects( + controller.preview(""), + /请输入至少一个有效的达人主页链接或达人 ID/, + ); + + await assert.rejects( + controller.preview("5776652682ec3912d6f508d5"), + /请求达人 5776652682ec3912d6f508d5 失败,状态码:403/, + ); + + assert.throws( + () => controller.exportSheet(["id"]), + /请先读取字段并确认达人数据/, + ); +}); diff --git a/pugongying/xhs-pgy-export.user.js b/pugongying/xhs-pgy-export.user.js new file mode 100644 index 0000000..36cddbb --- /dev/null +++ b/pugongying/xhs-pgy-export.user.js @@ -0,0 +1,1472 @@ +// ==UserScript== +// @name 小红书蒲公英达人信息导出 +// @namespace https://pgy.xiaohongshu.com/ +// @version 0.1.0 +// @description 输入达人主页链接或达人 ID,勾选字段后导出 Excel +// @match https://pgy.xiaohongshu.com/* +// @grant none +// ==/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) { + const API_BASE = + "https://pgy.xiaohongshu.com/api/solar/cooperator/user/blogger/"; + const SUPPLEMENTAL_ENDPOINTS = [ + { + namespace: "dataSummary", + buildUrl: (userId) => + `https://pgy.xiaohongshu.com/api/pgy/kol/data/data_summary?userId=${encodeURIComponent( + userId, + )}&business=1`, + }, + { + namespace: "fansSummary", + buildUrl: (userId) => + `https://pgy.xiaohongshu.com/api/solar/kol/data_v3/fans_summary?userId=${encodeURIComponent( + userId, + )}`, + }, + { + namespace: "fansProfile", + buildUrl: (userId) => + `https://pgy.xiaohongshu.com/api/solar/kol/data/${encodeURIComponent( + userId, + )}/fans_profile`, + }, + ]; + const NAMESPACE_LABEL_MAP = { + dataSummary: "数据概览", + fansSummary: "粉丝概览", + fansProfile: "粉丝画像", + }; + const FIELD_LABEL_MAP = { + id: "ID", + "metrics.fans": "粉丝数", + dataSummary: "数据概览", + fansSummary: "粉丝概览", + fansProfile: "粉丝画像", + "dataSummary.fans30GrowthRate": "近30天粉丝量变化幅度", + "dataSummary.estimateVideoCpm": "预估视频CPM", + "dataSummary.estimatePictureCpm": "预估图文CPM", + "dataSummary.videoReadCost": "预估阅读单价(视频)", + "dataSummary.picReadCost": "预估阅读单价(图文)", + "dataSummary.mCpuvNum": "外溢进店中位数", + "fansProfile.ages": "粉丝年龄分布", + "fansProfile.gender.male": "粉丝男性占比", + "fansProfile.gender.female": "粉丝女性占比", + "fansSummary.activeFansRate": "活跃粉丝占比", + "fansSummary.engageFansRate": "互动粉丝占比", + "fansSummary.readFansRate": "阅读粉丝占比", + userId: "达人ID", + name: "达人昵称", + redId: "小红书号", + location: "地区", + travelAreaList: "可接受的出行地", + personalTags: "人设标签", + fansCount: "粉丝数", + likeCollectCountInfo: "获赞与收藏", + businessNoteCount: "商业笔记数", + totalNoteCount: "总笔记数", + picturePrice: "图文报价", + videoPrice: "视频报价", + lowerPrice: "最低报价", + userType: "用户类型", + tradeType: "合作行业", + clickMidNum: "阅读中位数", + accumCoopImpMedinNum30d: "近30天合作曝光中位数", + mEngagementNum: "互动中位数", + }; + const SELECTABLE_FIELD_PATHS = Object.keys(FIELD_LABEL_MAP).filter( + (path) => !(path in NAMESPACE_LABEL_MAP), + ); + const STORAGE_INPUT_KEY = "xhs-pgy-export:last-input"; + const SCRIPT_FLAG = "__xhsPgyExportMounted__"; + + 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)) { + baseTarget[nextPath] = summarizeArray(value); + continue; + } + + if (isPlainObject(value)) { + flattenRecord(value, nextPath, baseTarget); + continue; + } + + baseTarget[nextPath] = normalizeScalar(value); + } + + return baseTarget; + } + + 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 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 ""; + } + + function parseCreatorInputs(rawInput) { + const values = normalizeScalar(rawInput) + .split(/[\n,,\s]+/) + .map((item) => item.trim()) + .filter(Boolean); + + const ids = []; + const seen = new Set(); + + for (const value of values) { + const id = extractBloggerId(value); + 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 escapeCsvValue(value) { + const text = normalizeScalar(value); + if (/["\n,\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; + } + + function buildCsvContent(config) { + const columns = Array.isArray(config.columns) ? config.columns : []; + const headers = + Array.isArray(config.headers) && config.headers.length === columns.length + ? config.headers + : columns; + const rows = Array.isArray(config.rows) ? config.rows : []; + const headerLine = headers.map(escapeCsvValue).join(","); + const bodyLines = rows.map((row) => + columns + .map((column) => escapeCsvValue(row[column] === undefined ? "" : row[column])) + .join(","), + ); + + return `\uFEFF${[headerLine, ...bodyLines].join("\r\n")}`; + } + + function buildSpreadsheetXml(config) { + const sheetName = typeof config.sheetName === "string" ? config.sheetName : "达人数据"; + const columns = Array.isArray(config.columns) ? config.columns : []; + const headers = + Array.isArray(config.headers) && config.headers.length === columns.length + ? config.headers + : columns; + const rows = Array.isArray(config.rows) ? config.rows : []; + const headerCells = columns + .map( + (column, index) => + `${escapeXml(headers[index] ?? column)}`, + ) + .join(""); + + const dataRows = rows + .map((row) => { + const cells = columns + .map((column) => { + const value = row[column] === undefined ? "" : row[column]; + return `${escapeXml(value)}`; + }) + .join(""); + return `${cells}`; + }) + .join(""); + + return ` + + + + + ${headerCells} + ${dataRows} +
+
+
`; + } + + 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 response = await fetchImpl(config.buildUrl(userId), { + method: "GET", + credentials: "include", + headers: { + accept: "application/json, text/plain, */*", + }, + }); + + 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; + } + + 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); + let cachedRecords = []; + let cachedFields = []; + + return { + async preview(rawInput, onProgress) { + const ids = parseCreatorInputs(rawInput); + if (!ids.length) { + throw new Error("请输入至少一个有效的达人主页链接或达人 ID。"); + } + + const report = (current, total) => { + if (typeof onProgress === "function") { + onProgress(current, total); + } + }; + + const records = []; + report(0, ids.length); + for (const id of ids) { + const raw = await fetchMergedBloggerRecord(id, fetchImpl); + records.push({ + id, + raw, + flattened: flattenRecord(raw), + }); + report(records.length, ids.length); + } + + 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 rows = buildExportRows(cachedRecords, fields); + const headers = fields.map((field) => getFieldLabel(field)); + const content = buildSpreadsheetXml({ + columns: fields, + headers, + rows, + sheetName: "达人数据", + }); + + return { + filename: `xhs-bloggers-${formatTimestamp(now())}.xls`, + columns: fields, + headers, + rows, + 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 headerCells = headers + .map((header) => `${escapeXml(header)}`) + .join(""); + const parts = [ + `\n\n\n \n \n ${headerCells}\n`, + ]; + + const report = (percentage, message) => { + if (typeof onProgress !== "function") { + return; + } + onProgress(Math.max(0, Math.min(100, percentage)), message || ""); + }; + + report(0, "正在生成 Excel..."); + + const yieldEvery = 50; + for (let index = 0; index < total; index += 1) { + const record = cachedRecords[index]; + const cells = fields + .map((field) => { + const value = + record && record.flattened && record.flattened[field] !== undefined + ? record.flattened[field] + : ""; + return `${escapeXml(value)}`; + }) + .join(""); + parts.push(` ${cells}\n`); + + 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)); + } + } + + parts.push("
\n
\n
"); + const content = parts.join(""); + + return { + filename: `xhs-bloggers-${formatTimestamp(now())}.xls`, + columns: fields, + headers, + content, + rowCount: total, + }; + }, + + 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 downloadFile(filename, content) { + const blob = new Blob([content], { + type: "application/vnd.ms-excel;charset=utf-8", + }); + 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: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-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-all; + } + + .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"), + exportButton: panel.querySelector('[data-action="export"]'), + 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"); + + for (const field of fieldOptions) { + 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.innerHTML = ` +
+ + + + + ${escapeXml(labelText)} +
+
映射字段:${escapeXml(field.path)}
+ `; + 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; + } + }); + } + + 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, autoCloseMs) { + if (!refs || !refs.modalBackdrop) { + return; + } + if (typeof subtitle === "string" && refs.modalSubtitle) { + refs.modalSubtitle.textContent = subtitle; + } + refs.modalBackdrop.classList.add("is-open"); + refs.modalBackdrop.setAttribute("aria-hidden", "false"); + + if (refs.modalTimer) { + clearTimeout(refs.modalTimer); + } + const delay = + typeof autoCloseMs === "number" && Number.isFinite(autoCloseMs) && autoCloseMs > 0 + ? autoCloseMs + : 2500; + refs.modalTimer = setTimeout(() => closeModal(refs), delay); + } + + 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.modalBackdrop.addEventListener("click", (event) => { + if (event.target === refs.modalBackdrop) { + closeModal(refs); + } + }); + root.document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + closeModal(refs); + } + }); + + refs.toggle.addEventListener("click", () => { + refs.panel.classList.toggle("is-open"); + }); + + refs.exportButton.addEventListener("click", async () => { + try { + const checkedFields = getCheckedFields(refs.fields); + if (!checkedFields.length) { + throw new Error("请至少勾选一个导出字段。"); + } + + refs.exportButton.disabled = true; + hideProgress(refs); + setProgress(refs, 0, "准备导出...", false); + setStatus(refs.status, "正在读取达人数据,请稍候...", false); + + const rawInput = refs.input.value; + saveLocal(STORAGE_INPUT_KEY, rawInput); + await controller.preview(rawInput, (current, total) => { + const pct = total ? Math.floor((current / total) * 45) : 0; + setProgress(refs, pct, `正在读取达人数据 ${current}/${total || 0}`, false); + }); + setStatus(refs.status, "正在生成导出文件...", false); + 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}`, 2500); + 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 { + refs.exportButton.disabled = 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, + buildFieldOptions, + buildSpreadsheetXml, + createExportController, + extractBloggerId, + fetchMergedBloggerRecord, + flattenRecord, + getFieldLabel, + parseCreatorInputs, + }; +});