1438 lines
40 KiB
JavaScript
1438 lines
40 KiB
JavaScript
// ==UserScript==
|
||
// @name 小红书蒲公英达人信息导出
|
||
// @namespace https://pgy.xiaohongshu.com/
|
||
// @version 0.1.1
|
||
// @description 输入达人主页链接或达人 ID,勾选字段后导出 Excel
|
||
// @match https://pgy.xiaohongshu.com/*
|
||
// @grant none
|
||
// @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) {
|
||
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 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 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, "&")
|
||
.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;
|
||
}
|
||
}
|
||
|
||
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-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");
|
||
|
||
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 = `
|
||
<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>
|
||
<div class="xhs-export-field-sample">映射字段:${escapeXml(field.path)}</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;
|
||
}
|
||
});
|
||
}
|
||
|
||
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,
|
||
};
|
||
});
|