scriptCat/pugongying/xhs-pgy-export.user.js
2026-03-16 12:15:51 +08:00

1612 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

// ==UserScript==
// @name 小红书蒲公英达人信息导出
// @namespace https://pgy.xiaohongshu.com/
// @version 0.1.1
// @description 输入达人主页链接或达人 ID勾选字段后导出 Excel
// @match https://pgy.xiaohongshu.com/*
// @grant GM_xmlhttpRequest
// @connect api.internal.intelligrow.cn
// @connect xhslink.com
// @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 : {};
GM_xmlhttpRequest({
method: (options && options.method) || "GET",
url,
headers,
onload(res) {
resolve({
ok: res.status >= 200 && res.status < 300,
status: res.status,
json: () => Promise.resolve(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 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 isPlainObject(value) {
return Object.prototype.toString.call(value) === "[object Object]";
}
function normalizeScalar(value) {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value.trim();
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
if (value instanceof Date) {
return value.toISOString();
}
return String(value);
}
function summarizeArray(list) {
if (!Array.isArray(list) || list.length === 0) {
return "";
}
const allScalar = list.every(
(item) =>
item === null ||
item === undefined ||
["string", "number", "boolean", "bigint"].includes(typeof item),
);
if (allScalar) {
return list.map(normalizeScalar).filter(Boolean).join(" | ");
}
return list
.map((item) => {
if (isPlainObject(item) || Array.isArray(item)) {
try {
return JSON.stringify(item);
} catch (error) {
return String(item);
}
}
return normalizeScalar(item);
})
.filter(Boolean)
.join(" | ");
}
function flattenRecord(record, prefix, target) {
const baseTarget = target || {};
const currentPrefix = prefix || "";
if (!isPlainObject(record)) {
if (currentPrefix) {
baseTarget[currentPrefix] = normalizeScalar(record);
}
return baseTarget;
}
const keys = Object.keys(record);
if (keys.length === 0 && currentPrefix) {
baseTarget[currentPrefix] = "";
return baseTarget;
}
for (const key of keys) {
const nextPath = currentPrefix ? `${currentPrefix}.${key}` : key;
const value = record[key];
if (Array.isArray(value)) {
if (
value.length &&
value.every(
(item) =>
isPlainObject(item) && "group" in item && "percent" in item,
)
) {
for (const item of value) {
baseTarget[`${nextPath}.${item.group}`] = item.percent;
}
} else {
baseTarget[nextPath] = summarizeArray(value);
}
continue;
}
if (isPlainObject(value)) {
flattenRecord(value, nextPath, baseTarget);
continue;
}
baseTarget[nextPath] = normalizeScalar(value);
}
return baseTarget;
}
function resolveShortUrl(url) {
return new Promise((resolve) => {
if (typeof GM_xmlhttpRequest !== "function") {
resolve(url);
return;
}
GM_xmlhttpRequest({
method: "GET",
url,
onload(res) {
if (res.finalUrl && res.finalUrl !== url) {
resolve(res.finalUrl);
return;
}
const match = res.responseText && res.responseText.match(/href="([^"]+)"/);
if (match) {
resolve(match[1].replace(/&amp;/g, "&"));
} else {
resolve(url);
}
},
onerror() {
resolve(url);
},
});
});
}
const SHORT_LINK_HOSTS = ["xhslink.com"];
function extractIdFromUrl(parsedUrl) {
const queryCandidates = ["id", "user_id", "userId", "bloggerId", "creatorId"];
for (const key of queryCandidates) {
const queryValue = parsedUrl.searchParams.get(key);
if (queryValue && /^[0-9a-f]{24}$/i.test(queryValue)) {
return queryValue;
}
}
const segments = parsedUrl.pathname
.split("/")
.map((segment) => segment.trim())
.filter(Boolean)
.reverse();
for (const segment of segments) {
if (/^[0-9a-f]{24}$/i.test(segment)) {
return segment;
}
}
return "";
}
async function extractBloggerId(value) {
const raw = normalizeScalar(value);
if (!raw) {
return "";
}
if (/^[0-9a-f]{24}$/i.test(raw)) {
return raw;
}
if (!/^https?:\/\//i.test(raw)) {
return "";
}
let parsedUrl;
try {
parsedUrl = new URL(raw);
} catch (error) {
return "";
}
const directId = extractIdFromUrl(parsedUrl);
if (directId) {
return directId;
}
if (SHORT_LINK_HOSTS.some((h) => parsedUrl.hostname.endsWith(h))) {
const realUrl = await resolveShortUrl(raw);
try {
return extractIdFromUrl(new URL(realUrl));
} catch (error) {
return "";
}
}
return "";
}
async function parseCreatorInputs(rawInput) {
const values = normalizeScalar(rawInput)
.split(/[\n,\s]+/)
.map((item) => item.trim())
.filter(Boolean);
const ids = [];
const seen = new Set();
const resolved = await Promise.all(values.map((v) => extractBloggerId(v)));
for (const id of resolved) {
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
ids.push(id);
}
return ids;
}
function buildFieldOptions(records) {
const fieldMap = new Map();
for (const record of records) {
const flattened = record.flattened || {};
for (const path of Object.keys(flattened)) {
if (!FIELD_LABEL_MAP[path]) {
continue;
}
if (!fieldMap.has(path)) {
fieldMap.set(path, {
path,
label: getFieldLabel(path),
});
}
}
}
return Array.from(fieldMap.values()).sort((left, right) =>
left.path.localeCompare(right.path, "zh-CN"),
);
}
function buildSelectableFieldOptions() {
return SELECTABLE_FIELD_PATHS.map((path) => ({
path,
label: getFieldLabel(path),
}));
}
function getFieldLabel(path) {
if (FIELD_LABEL_MAP[path]) {
return FIELD_LABEL_MAP[path];
}
for (const [namespace, namespaceLabel] of Object.entries(NAMESPACE_LABEL_MAP)) {
if (path === namespace) {
return namespaceLabel;
}
if (path.startsWith(`${namespace}.`)) {
return `${namespaceLabel} - ${path.slice(namespace.length + 1)}`;
}
}
return path;
}
function pickDefaultFields(fieldOptions) {
return fieldOptions.slice(0, 12).map((field) => field.path);
}
function buildExportRows(records, selectedFields) {
return records.map((record) => {
const row = {};
for (const field of selectedFields) {
row[field] = record.flattened[field] || "";
}
return row;
});
}
function formatTimestamp(date) {
const safeDate = date instanceof Date ? date : new Date();
const parts = [
safeDate.getFullYear(),
String(safeDate.getMonth() + 1).padStart(2, "0"),
String(safeDate.getDate()).padStart(2, "0"),
"-",
String(safeDate.getHours()).padStart(2, "0"),
String(safeDate.getMinutes()).padStart(2, "0"),
String(safeDate.getSeconds()).padStart(2, "0"),
];
return parts.join("");
}
function unwrapResponsePayload(json) {
if (isPlainObject(json?.data)) {
return json.data;
}
if (isPlainObject(json?.result)) {
return json.result;
}
if (isPlainObject(json)) {
return json;
}
return { value: json };
}
async function fetchBloggerRecord(id, fetchImpl) {
if (typeof fetchImpl !== "function") {
throw new Error("当前环境不支持 fetch无法请求达人数据。");
}
const response = await fetchImpl(`${API_BASE}${encodeURIComponent(id)}`, {
method: "GET",
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
},
});
if (!response || !response.ok) {
const status = response ? response.status : "unknown";
throw new Error(`请求达人 ${id} 失败,状态码:${status}`);
}
const json = await response.json();
const payload = unwrapResponsePayload(json);
if (!Object.prototype.hasOwnProperty.call(payload, "id")) {
payload.id = id;
}
return payload;
}
async function fetchSupplementalPayload(userId, fetchImpl, config) {
const extra =
typeof config.extraHeaders === "function" ? config.extraHeaders() : {};
const hasExtra = Object.keys(extra).length > 0;
const fetcher = hasExtra && typeof GM_xmlhttpRequest === "function" ? gmFetch : fetchImpl;
const response = await fetcher(config.buildUrl(userId), {
method: "GET",
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
...extra,
},
});
if (!response || !response.ok) {
const status = response ? response.status : "unknown";
throw new Error(
`请求补充数据 ${config.namespace} 失败userId=${userId},状态码:${status}`,
);
}
const json = await response.json();
return unwrapResponsePayload(json);
}
async function fetchMergedBloggerRecord(id, fetchImpl) {
const primaryPayload = await fetchBloggerRecord(id, fetchImpl);
const userId = primaryPayload.userId || primaryPayload.id || id;
const settledPayloads = await Promise.allSettled(
SUPPLEMENTAL_ENDPOINTS.map((config) =>
fetchSupplementalPayload(userId, fetchImpl, config).then((payload) => ({
namespace: config.namespace,
payload,
})),
),
);
const mergedPayload = {
...primaryPayload,
};
for (const result of settledPayloads) {
if (result.status !== "fulfilled") {
continue;
}
mergedPayload[result.value.namespace] = result.value.payload;
}
return mergedPayload;
}
async function mapWithConcurrency(items, limit, mapper, onDone) {
const list = Array.isArray(items) ? items : [];
if (!list.length) {
return [];
}
const size = Math.max(1, Number(limit) || 1);
const workerCount = Math.min(size, list.length);
const results = new Array(list.length);
let nextIndex = 0;
let doneCount = 0;
const worker = async () => {
while (true) {
const index = nextIndex;
nextIndex += 1;
if (index >= list.length) {
return;
}
results[index] = await mapper(list[index], index);
doneCount += 1;
if (typeof onDone === "function") {
onDone(doneCount, list.length);
}
}
};
const workers = Array.from({ length: workerCount }, () => worker());
await Promise.all(workers);
return results;
}
function createExportController(options) {
const settings = options || {};
const now = settings.now || (() => new Date());
const fetchImpl =
settings.fetchImpl || (typeof root.fetch === "function" ? root.fetch.bind(root) : null);
const concurrency = Math.max(1, Number(settings.concurrency) || 4);
let cachedRecords = [];
let cachedFields = [];
return {
async preview(rawInput, onProgress) {
const ids = await parseCreatorInputs(rawInput);
if (!ids.length) {
throw new Error("请输入至少一个有效的达人主页链接或达人 ID。");
}
const report = (current, total) => {
if (typeof onProgress === "function") {
onProgress(current, total);
}
};
report(0, ids.length);
const records = await mapWithConcurrency(
ids,
concurrency,
async (id) => {
const raw = await fetchMergedBloggerRecord(id, fetchImpl);
return {
id,
raw,
flattened: flattenRecord(raw),
};
},
(done, total) => report(done, total),
);
cachedRecords = records;
cachedFields = buildFieldOptions(records);
return {
ids,
records,
fields: cachedFields,
selectedFields: pickDefaultFields(cachedFields),
};
},
exportSheet(selectedFields) {
if (!cachedRecords.length) {
throw new Error("请先读取字段并确认达人数据。");
}
const fields =
Array.isArray(selectedFields) && selectedFields.length
? selectedFields
: cachedFields.map((field) => field.path);
const headers = fields.map((field) => getFieldLabel(field));
if (!root.XLSX) {
throw new Error("未加载 SheetJS无法导出 xlsx。");
}
const aoa = [headers.slice()];
for (const record of cachedRecords) {
aoa.push(fields.map((field) => record.flattened[field] || ""));
}
const ws = root.XLSX.utils.aoa_to_sheet(aoa);
const wb = root.XLSX.utils.book_new();
root.XLSX.utils.book_append_sheet(wb, ws, "达人数据");
const content = root.XLSX.write(wb, { bookType: "xlsx", type: "array" });
return {
filename: `xhs-bloggers-${formatTimestamp(now())}.xlsx`,
columns: fields,
headers,
content,
};
},
async exportSheetAsync(selectedFields, onProgress) {
if (!cachedRecords.length) {
throw new Error("请先读取字段并确认达人数据。");
}
const fields =
Array.isArray(selectedFields) && selectedFields.length
? selectedFields
: cachedFields.map((field) => field.path);
const headers = fields.map((field) => getFieldLabel(field));
const total = cachedRecords.length;
const report = (percentage, message) => {
if (typeof onProgress !== "function") {
return;
}
onProgress(Math.max(0, Math.min(100, percentage)), message || "");
};
report(0, "正在生成 Excel(.xlsx)...");
const aoa = [headers.slice()];
const yieldEvery = 50;
for (let index = 0; index < total; index += 1) {
const record = cachedRecords[index];
aoa.push(fields.map((field) => record.flattened[field] || ""));
const isLast = index === total - 1;
if (isLast || (index + 1) % yieldEvery === 0) {
const pct = total ? Math.floor(((index + 1) / total) * 100) : 100;
report(pct, `正在生成 ${index + 1}/${total}`);
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
report(100, "正在打包 xlsx...");
const XLSX = await ensureXlsx();
const ws = XLSX.utils.aoa_to_sheet(aoa);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "达人数据");
const content = XLSX.write(wb, { bookType: "xlsx", type: "array" });
return {
filename: `xhs-bloggers-${formatTimestamp(now())}.xlsx`,
columns: fields,
headers,
content,
rowCount: total,
};
},
getState() {
return {
records: cachedRecords.slice(),
fields: cachedFields.slice(),
};
},
};
}
function escapeXml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
function saveLocal(key, value) {
try {
root.localStorage.setItem(key, JSON.stringify(value));
} catch (error) {
return;
}
}
function loadLocal(key, fallbackValue) {
try {
const raw = root.localStorage.getItem(key);
if (!raw) {
return fallbackValue;
}
return JSON.parse(raw);
} catch (error) {
return fallbackValue;
}
}
const XLSX_CDN_URLS = [
"https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js",
"https://cdn.bootcdn.net/ajax/libs/xlsx/0.18.5/xlsx.full.min.js",
];
const loadedScripts = new Map();
function loadScript(url) {
if (loadedScripts.has(url)) {
return loadedScripts.get(url);
}
const promise = new Promise((resolve, reject) => {
const script = root.document.createElement("script");
script.src = url;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`加载脚本失败:${url}`));
root.document.head.appendChild(script);
});
loadedScripts.set(url, promise);
return promise;
}
async function ensureXlsx() {
if (root.XLSX && root.XLSX.utils && typeof root.XLSX.write === "function") {
return root.XLSX;
}
for (const url of XLSX_CDN_URLS) {
try {
await loadScript(url);
if (root.XLSX && root.XLSX.utils && typeof root.XLSX.write === "function") {
return root.XLSX;
}
} catch (error) {
// try next url
}
}
throw new Error("加载 SheetJS 失败,可能被网络或页面 CSP 限制。");
}
function downloadFile(filename, content) {
const blob = new Blob([content], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const link = root.document.createElement("a");
const blobUrl = root.URL.createObjectURL(blob);
link.href = blobUrl;
link.download = filename;
root.document.body.appendChild(link);
link.click();
link.remove();
root.URL.revokeObjectURL(blobUrl);
}
function injectStyles(doc) {
if (doc.getElementById("xhs-pgy-export-style")) {
return;
}
const style = doc.createElement("style");
style.id = "xhs-pgy-export-style";
style.textContent = `
.xhs-export-toggle {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 99999;
border: 0;
border-radius: 999px;
padding: 12px 18px;
font-size: 14px;
font-weight: 700;
color: #fff8eb;
background: linear-gradient(135deg, #f45d01, #d72638);
box-shadow: 0 12px 28px rgba(187, 61, 14, 0.28);
cursor: pointer;
}
.xhs-export-panel {
position: fixed;
right: 24px;
bottom: 84px;
z-index: 99999;
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 120px);
overflow: hidden;
display: none;
flex-direction: column;
border-radius: 20px;
background:
radial-gradient(circle at top right, rgba(255, 229, 205, 0.95), rgba(255, 245, 236, 0.98) 46%),
linear-gradient(160deg, rgba(255, 250, 246, 0.98), rgba(255, 238, 225, 0.98));
color: #31241d;
box-shadow: 0 24px 60px rgba(76, 34, 15, 0.22);
border: 1px solid rgba(190, 110, 61, 0.18);
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
}
.xhs-export-panel.is-open {
display: flex;
}
.xhs-export-header {
padding: 18px 18px 10px;
}
.xhs-export-title {
margin: 0;
font-size: 18px;
font-weight: 700;
}
.xhs-export-subtitle {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.5;
color: #7c5b48;
}
.xhs-export-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 18px 92px;
overflow: auto;
}
.xhs-export-input {
min-height: 104px;
resize: vertical;
border: 1px solid rgba(141, 88, 51, 0.2);
border-radius: 14px;
padding: 12px 14px;
font-size: 13px;
line-height: 1.6;
background: rgba(255, 255, 255, 0.75);
color: #2e211a;
}
.xhs-export-actions,
.xhs-export-mini-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.xhs-export-actions {
align-items: center;
}
.xhs-export-btn {
border: 0;
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.xhs-export-btn.primary {
background: linear-gradient(135deg, #ef6a00, #d72638);
color: #fff8ef;
}
.xhs-export-fab {
position: absolute;
right: 18px;
bottom: 18px;
z-index: 2;
border: 0;
border-radius: 999px;
padding: 14px 18px;
font-size: 14px;
font-weight: 900;
letter-spacing: 0.3px;
cursor: pointer;
color: #fff8ef;
background: linear-gradient(135deg, #ef6a00, #d72638);
box-shadow: 0 16px 34px rgba(187, 61, 14, 0.28);
}
.xhs-export-fab: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-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 = `
<div class="xhs-export-header">
<h2 class="xhs-export-title">蒲公英达人导出</h2>
<p class="xhs-export-subtitle">输入达人主页链接或达人 ID选择需要的 Excel 表头后直接导出。每行一个达人链接或达人 ID。</p>
</div>
<div class="xhs-export-body">
<textarea class="xhs-export-input" placeholder="每行一个达人主页链接或达人 ID支持批量。"></textarea>
<div class="xhs-export-status"></div>
<div class="xhs-export-progress is-hidden">
<div class="xhs-export-progress-meta">
<span class="xhs-export-progress-text">准备就绪</span>
<span class="xhs-export-progress-pct">0%</span>
</div>
<div class="xhs-export-progress-track">
<div class="xhs-export-progress-bar"></div>
</div>
</div>
<div class="xhs-export-field-select"></div>
</div>
<button class="xhs-export-fab" data-action="export">导出表格</button>
`;
const modalBackdrop = doc.createElement("div");
modalBackdrop.className = "xhs-export-modal-backdrop";
modalBackdrop.setAttribute("role", "dialog");
modalBackdrop.setAttribute("aria-modal", "true");
modalBackdrop.setAttribute("aria-hidden", "true");
modalBackdrop.innerHTML = `
<div class="xhs-export-modal">
<div class="xhs-export-modal-icon" aria-hidden="true">
<svg viewBox="0 0 200 200" width="150" height="150">
<circle cx="100" cy="100" r="78" fill="none" stroke="#9adf86" stroke-width="10" />
<path d="M62 105 L92 132 L146 78" fill="none" stroke="#9adf86" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<h3 class="xhs-export-modal-title">下载已完成</h3>
<p class="xhs-export-modal-subtitle"></p>
<div class="xhs-export-modal-actions">
<button class="xhs-export-modal-btn" type="button">知道了</button>
</div>
</div>
`;
doc.body.appendChild(toggle);
doc.body.appendChild(panel);
doc.body.appendChild(modalBackdrop);
return {
toggle,
panel,
input: panel.querySelector(".xhs-export-input"),
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 = `<div class="xhs-export-field"><div class="xhs-export-field-sample">读取成功后,这里会列出可选表头与字段映射。</div></div>`;
return;
}
const wrapper = root.document.createElement("div");
wrapper.className = "xhs-export-select";
wrapper.innerHTML = `
<button class="xhs-export-select-trigger" type="button" aria-expanded="false"></button>
<div class="xhs-export-select-panel">
<input class="xhs-export-select-search" type="search" placeholder="搜索字段(支持表头/路径)">
<div class="xhs-export-select-list"></div>
</div>
`;
container.appendChild(wrapper);
const trigger = wrapper.querySelector(".xhs-export-select-trigger");
const panel = wrapper.querySelector(".xhs-export-select-panel");
const search = wrapper.querySelector(".xhs-export-select-search");
const list = wrapper.querySelector(".xhs-export-select-list");
const fieldByPath = new Map(fieldOptions.map((f) => [f.path, f]));
for (const group of FIELD_GROUPS) {
const groupFields = group.fields.filter((p) => fieldByPath.has(p));
if (!groupFields.length) continue;
const header = root.document.createElement("div");
header.className = "xhs-export-group-header";
header.textContent = group.label;
header.dataset.group = group.label;
list.appendChild(header);
for (const path of groupFields) {
const field = fieldByPath.get(path);
const labelText = field.label || field.path;
const item = root.document.createElement("label");
item.className = "xhs-export-select-item";
item.dataset.path = field.path;
item.dataset.label = labelText;
item.dataset.group = group.label;
item.innerHTML = `
<div class="xhs-export-select-item-row">
<span class="xhs-export-checkbox">
<input class="xhs-export-checkbox-input" type="checkbox" value="${escapeXml(
field.path,
)}" ${selected.has(field.path) ? "checked" : ""}>
<span class="xhs-export-checkbox-box" aria-hidden="true"></span>
</span>
<span class="xhs-export-field-name">${escapeXml(labelText)}</span>
</div>
`;
list.appendChild(item);
}
}
const setOpenState = (open) => {
wrapper.classList.toggle("is-open", Boolean(open));
if (trigger) {
trigger.setAttribute("aria-expanded", open ? "true" : "false");
}
if (open && search) {
search.focus();
}
};
if (trigger) {
trigger.addEventListener("click", () => {
setOpenState(!wrapper.classList.contains("is-open"));
});
}
if (search) {
search.addEventListener("input", () => {
const q = String(search.value || "").trim().toLowerCase();
for (const item of list.querySelectorAll(".xhs-export-select-item")) {
const label = String(item.dataset.label || "").toLowerCase();
const path = String(item.dataset.path || "").toLowerCase();
item.hidden = q ? !(label.includes(q) || path.includes(q)) : false;
}
for (const header of list.querySelectorAll(".xhs-export-group-header")) {
const group = header.dataset.group;
const hasVisible = list.querySelector(
`.xhs-export-select-item[data-group="${group}"]:not([hidden])`,
);
header.hidden = !hasVisible;
}
});
}
wrapper.addEventListener("change", () => updateFieldSelectSummary(container));
updateFieldSelectSummary(container);
}
function getCheckedFields(container) {
return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map((checkbox) => checkbox.value)
.filter(Boolean);
}
function setStatus(node, message, isError) {
node.textContent = message;
node.classList.toggle("is-error", Boolean(isError));
}
function hideProgress(refs) {
if (!refs || !refs.progress) {
return;
}
refs.progress.classList.add("is-hidden");
refs.progress.classList.remove("is-error");
if (refs.progressBar) {
refs.progressBar.style.width = "0%";
}
if (refs.progressPct) {
refs.progressPct.textContent = "0%";
}
if (refs.progressText) {
refs.progressText.textContent = "准备就绪";
}
}
function setProgress(refs, percentage, message, isError) {
if (!refs || !refs.progress) {
return;
}
const pct = Math.max(0, Math.min(100, Number(percentage) || 0));
refs.progress.classList.remove("is-hidden");
refs.progress.classList.toggle("is-error", Boolean(isError));
if (refs.progressBar) {
refs.progressBar.style.width = `${pct}%`;
}
if (refs.progressPct) {
refs.progressPct.textContent = `${Math.round(pct)}%`;
}
if (refs.progressText && typeof message === "string" && message) {
refs.progressText.textContent = message;
}
}
function closeModal(refs) {
if (!refs || !refs.modalBackdrop) {
return;
}
if (refs.modalTimer) {
clearTimeout(refs.modalTimer);
refs.modalTimer = null;
}
refs.modalBackdrop.classList.remove("is-open");
refs.modalBackdrop.setAttribute("aria-hidden", "true");
}
function openModal(refs, subtitle, 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,
};
});