// ==UserScript==
// @name 小红书AI超能助手
// @namespace http://tampermonkey.net/
// @version 1.0
// @description 采用API拦截技术,支持自动滚动获取全部笔记,生成带xsec_token的永久有效链接,支持导出Excel/CSV/JSON。新增AI创作模块,内置多种写作模版,支持自定义模版和AI生成人设。提升创作效率,助力内容变现!新增excel带图片导出模式,方便直观查看封面图。新增资源下载功能,支持高清图片/视频批量下载。
// @author Coriander
// @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAx9JREFUWEfNl09MU0EQxn/beFTDDRI41FAMcNGbBw62oPEGid6UULxg1EhEEzExgdBEEzRqlKDxZCHgDZJ6U8TWAyaQGIsHMQLSA0S8VYQT2NXp9tnX0vKnpi2TNH1vd3bmm5lv9+0o0kQ73SXsc7QCx1EcjU9rnOl6O3pXRNAqCjqCIsB6LKQioYh9rbK/6MMnWojFHgElO3KwWyUBBD1q9q3fWvoPgHY1dIHu2a3N3PRVt5ob98naOABdVd+K5nluxnJc5dBe9TU4qHS128lvRzDnOufoH4iyETukihJ9EnSH0i5PAFRj7oH8z0r9UmlXw0fQZrsVWhQRKcFCEepvQo0DcNXrQgeechDtbQAVpbCyBiurqUmqqYSD+2FyOnPyZE50ln7A4vKWCc5egvIyCA3DzV4YeZ00UlEGQ/eN88670HsjOTczZ8bbvXCiDqbC8HkeBkahuhLE5sBICqDdAzh9yjh1n4OlZZgdTxqcDEPfIAw9SI1aMjg1DVrDpe5tAIRewOJ36LyXzIAgv+IFz1ljXN5FJAOjrwwIcd583YwfO2L0JHvW2qqGjKXYnAExJkYfDyYBaGWibmyDGhe0t/z9bikDSMQO4NZlEO5YJTggfHCBf8SUIo0TqQCEPB8C0Ddg6m5xQIj4xAcXu+DLPASHjY5/1BDUDkAyWF6amXjCkcYLW5Sg1gWBZ3C7H6Y+mWdJ48y35LiQ0HvGGLHzIFsJLAJLSSQzssYmmzMg0TVfM9vMqqMYkcwIejEiv59rhliy3URP2H6n3/zXJsbsO+ipz+huCUCQSb2E3eJQRNL+ZsIQS/a1ALQIKDtCxu0i4EUs8GPvk7YEXFPbNrvAmj5ZJ3dB49wSYbTlUIgqANJFzoFfq4aE8izBiC0h49iEmctagszUyevoHvgYFf1zXEwA6PBeuJLVXwUe5pVp2Yyr2HmVaMUW8tYNZXWuI6xrT6IxcbeiHYVtTCT62ZDf1pp5ekB1FaYU2qfmgvGLQWpzKi0adOfxlhxF0ZGxObUiT7RqbjRNoJ0oVZIzINMNy5Eehtg7NvCrSChqz/IfgUZkW/BhLsQAAAAASUVORK5CYII=
// @match https://creator.xiaohongshu.com/publish/*
// @match https://www.xiaohongshu.com/*
// @connect *
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_download
// @require https://cdn.jsdelivr.net/npm/exceljs@4.4.0/dist/exceljs.min.js
// @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js
// @license MIT
// ==/UserScript==
(function () {
"use strict";
// ==========================================
// 0. 全局数据存储 (核心)
// ==========================================
const GLOBAL_DATA = new Map();
let isAutoScrolling = false;
let currentPageUrl = location.href; // 记录当前页面URL
// 全局视频URL存储 - 页面加载时捕获的真实视频链接
let CACHED_VIDEO_URL = null;
// 全局评论缓存 - 拦截器捕获页面真实评论响应,按 noteId 聚合,去重合并
// 用法:CACHED_COMMENTS_MAP.get(noteId) -> [c1, c2, ...](主评论数组,sub_comments 已内嵌)
const CACHED_COMMENTS_MAP = new Map();
// ==========================================
// 0.1 XHR 拦截 - 捕获实际视频流URL
// ==========================================
(function () {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._xhrUrl = url;
// 【新增】直接捕获 stream 类型的 mp4 链接 (最高优先级)
if (
url &&
typeof url === "string" &&
url.includes("xhscdn.com") &&
url.includes("/stream/") &&
url.includes(".mp4")
) {
CACHED_VIDEO_URL = url;
console.log("[XHS-Stream] 捕获无水印流地址:", url);
}
return originalOpen.apply(this, [method, url, ...rest]);
};
XMLHttpRequest.prototype.send = function (data, ...rest) {
const self = this;
const origStateChange = this.onreadystatechange;
this.onreadystatechange = function () {
if (self.readyState === 4 && self.status === 200 && self._xhrUrl) {
try {
// 【评论拦截】捕获页面真实发出的评论接口响应,作为主动抓取失败时的兜底
if (
typeof self._xhrUrl === "string" &&
self._xhrUrl.includes("/api/sns/web/") &&
self._xhrUrl.includes("/comment/")
) {
try {
const cm = JSON.parse(self.responseText);
cacheCommentResponse(self._xhrUrl, cm);
} catch (cmErr) {
/* 忽略非 JSON */
}
}
// 【关键】从笔记详情 API 提取无水印视频链接
if (
self._xhrUrl.includes("/api/sns/") &&
(self._xhrUrl.includes("/feed") || self._xhrUrl.includes("/note"))
) {
const resp = JSON.parse(self.responseText);
if (resp && resp.data) {
// 遍历所有笔记数据
const processNote = (note) => {
if (note && note.type === "video" && note.video) {
const v = note.video;
// 【最优】尝试 origin_video_key (真正无水印原始版本)
// 修改:不再主动覆盖 CACHED_VIDEO_URL,仅做日志记录,由 renderResourceGrid 决定是否使用
if (v.consumer && v.consumer.origin_video_key) {
const url = `https://sns-video-bd.xhscdn.com/${v.consumer.origin_video_key}`;
// CACHED_VIDEO_URL = url; // 禁用:避免覆盖真实抓取的 Stream 链接
console.log(
"[XHS-无水印] 发现 origin_video_key:",
url.substring(0, 60),
);
return;
}
// 【备选】H.264 master URL (通常带水印)
if (
v.media &&
v.media.stream &&
v.media.stream.h264 &&
v.media.stream.h264[0]
) {
const url =
v.media.stream.h264[0].master_url ||
v.media.stream.h264[0].masterUrl;
// if (url && url.startsWith("http") && !CACHED_VIDEO_URL) {
// CACHED_VIDEO_URL = url; // 禁用
// }
}
// 备选 H.265
else if (
v.media &&
v.media.stream &&
v.media.stream.h265 &&
v.media.stream.h265[0]
) {
const url =
v.media.stream.h265[0].master_url ||
v.media.stream.h265[0].masterUrl;
// if (url && url.startsWith("http")) {
// CACHED_VIDEO_URL = url; // 禁用
// }
}
}
};
if (resp.data.items) {
resp.data.items.forEach((item) => {
const note = item.note_card || item.note || item;
processNote(note);
});
}
}
}
} catch (e) {
console.warn("[XHS] API 拦截异常:", e.message);
}
}
if (origStateChange) origStateChange.call(this);
};
return originalSend.apply(this, [data, ...rest]);
};
})();
// ==========================================
// 0.1.1 Fetch 拦截 - 补充捕获
// ==========================================
(function () {
const originalFetch = window.fetch;
window.fetch = function (input, init) {
let url = input;
if (input instanceof Request) {
url = input.url;
}
if (
url &&
typeof url === "string" &&
url.includes("xhscdn.com") &&
url.includes("/stream/") &&
url.includes(".mp4")
) {
CACHED_VIDEO_URL = url;
console.log("[XHS-Stream-Fetch] 捕获无水印流地址:", url);
}
const result = originalFetch.apply(this, arguments);
// 【评论拦截】捕获页面真实评论 fetch 响应
if (url && typeof url === "string" &&
url.includes("/api/sns/web/") && url.includes("/comment/")) {
try {
result.then(res => {
if (!res || !res.ok) return;
const clone = res.clone();
clone.json().then(cm => cacheCommentResponse(url, cm)).catch(() => {});
}).catch(() => {});
} catch (e) { /* ignore */ }
}
return result;
};
})();
// ==========================================
// 0.1.2 评论响应缓存与合并工具
// ==========================================
function extractNoteIdFromUrl(url) {
try {
const m = url.match(/[?&]note_id=([a-z0-9A-Z]+)/);
return m ? m[1] : null;
} catch (e) { return null; }
}
function extractRootCommentId(url) {
try {
const m = url.match(/[?&]root_comment_id=([a-z0-9A-Z]+)/);
return m ? m[1] : null;
} catch (e) { return null; }
}
// 把响应中的评论数据并入 CACHED_COMMENTS_MAP(按 noteId)
// 支持主评论页(/comment/page)与子评论页(/comment/sub_page or /sub/page)
function cacheCommentResponse(url, json) {
if (!json || !json.data) return;
const noteId = extractNoteIdFromUrl(url);
if (!noteId) return;
const isSub = url.includes("/comment/sub_page") || url.includes("/comment/sub/page");
const list = json.data.comments;
if (!Array.isArray(list) || list.length === 0) return;
if (isSub) {
// 子评论:附加到对应主评论的 sub_comments
const rootId = extractRootCommentId(url);
if (!rootId) return;
const mainArr = CACHED_COMMENTS_MAP.get(noteId);
if (!mainArr) return;
const root = mainArr.find(c => c && (c.id === rootId || c.commentId === rootId));
if (!root) return;
const existing = root.sub_comments || [];
const existingIds = new Set(existing.map(s => s && s.id).filter(Boolean));
for (const s of list) {
if (s && s.id && !existingIds.has(s.id)) existing.push(s);
}
root.sub_comments = existing;
} else {
// 主评论:按 id 去重合并
const existing = CACHED_COMMENTS_MAP.get(noteId) || [];
const existingIds = new Set(existing.map(c => c && c.id).filter(Boolean));
for (const c of list) {
if (c && c.id && !existingIds.has(c.id)) existing.push(c);
}
CACHED_COMMENTS_MAP.set(noteId, existing);
}
}
// ==========================================
// 0.2 页面切换监听 - 保持数据清洁性
// ==========================================
function getPageKey(url) {
try {
const u = new URL(url);
// 用户主页: /user/profile/xxx
// 收藏夹: /user/profile/xxx/collect 或 /board/xxx
// 搜索: /search_result
// 首页: /
return u.pathname;
} catch {
return url;
}
}
function checkPageChange() {
const newUrl = location.href;
const oldKey = getPageKey(currentPageUrl);
const newKey = getPageKey(newUrl);
if (oldKey !== newKey) {
console.log(`[XHS助手] 页面切换: ${oldKey} -> ${newKey},清空数据`);
GLOBAL_DATA.clear();
updateCountUI();
// 停止自动滚动(如果正在进行)
if (isAutoScrolling) {
const btn = document.getElementById("auto-scroll-btn");
if (btn) btn.click(); // 触发停止
}
currentPageUrl = newUrl;
// 检查是否为详情页,提示资源下载
if (document.getElementById("xhs-ai-helper")) {
checkResourcePage(newUrl);
}
}
}
function checkResourcePage(url) {
const isDetail = url.includes("/explore/");
const input = document.getElementById("res-url-input");
const tab = document.querySelector('.ai-tab-item[data-tab="download"]');
if (input && isDetail) {
input.value = url;
// 高亮提示
if (tab) {
const originText = tab.innerText;
tab.style.color = "#ff2442";
tab.innerText = "资源下载 ●";
setTimeout(() => {
tab.style.color = "";
tab.innerText = "资源下载";
}, 8000);
}
} else if (input) {
input.value = "";
}
}
// 监听 URL 变化(小红书是 SPA,用 History API)
function setupPageChangeListener() {
// 监听 popstate(浏览器前进/后退)
window.addEventListener("popstate", checkPageChange);
// 拦截 pushState 和 replaceState
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function (...args) {
originalPushState.apply(this, args);
setTimeout(checkPageChange, 100);
};
history.replaceState = function (...args) {
originalReplaceState.apply(this, args);
setTimeout(checkPageChange, 100);
};
// 兜底:定时检查(防止某些情况遗漏)
setInterval(checkPageChange, 2000);
}
// 启动页面切换监听
setupPageChangeListener();
function escapeHtml(text) {
if (!text) return "";
const d = document.createElement("div");
d.appendChild(document.createTextNode(text));
return d.innerHTML;
}
function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
// ==========================
// 屏蔽登录弹窗逻辑
// ==========================
const SHIELD_LOGIN_KEY = "pc-shield-login-dialog";
const OPEN_BLANK_KEY = "pc-xhs-open-blank";
const FIX_CLICK_KEY = "pc-xhs-fix-click";
let _shieldLoginObserver = null;
function addShieldLoginObserver() {
if (_shieldLoginObserver) return;
try {
console.log("[XHS助手] 启用屏蔽登录弹窗");
_shieldLoginObserver = new MutationObserver(() => {
const closeBtn = document.querySelector(
".login-container .icon-btn-wrapper",
);
if (closeBtn) {
try {
closeBtn.click();
console.log("[XHS助手] 登录弹窗出现,已自动关闭");
} catch (e) {
console.warn("[XHS助手] 关闭登录弹窗失败", e);
}
}
});
const attachObserver = () => {
try {
if (document.body)
_shieldLoginObserver.observe(document.body, {
childList: true,
subtree: true,
});
} catch (e) {
console.warn("[XHS助手] attach observer 失败", e);
}
};
if (document.body) {
attachObserver();
} else {
window.addEventListener("DOMContentLoaded", attachObserver, {
once: true,
});
}
// 立即尝试隐藏已存在的节点
const loginNode = document.querySelector(".login-container");
if (loginNode) {
try {
loginNode.style.display = "none";
} catch (e) {}
}
} catch (e) {
console.warn("[XHS助手] 添加屏蔽登录弹窗 observer 失败", e);
}
}
function removeShieldLoginObserver() {
if (_shieldLoginObserver) {
try {
_shieldLoginObserver.disconnect();
} catch (e) {}
_shieldLoginObserver = null;
console.log("[XHS助手] 已停止屏蔽登录弹窗");
}
}
// 读取设置并初始化
function initShieldLoginFromSetting() {
try {
const enable = GM_getValue ? GM_getValue(SHIELD_LOGIN_KEY, false) : false;
const $cb = document.getElementById("pc-shield-login-dialog");
if ($cb) $cb.checked = !!enable;
if (enable) addShieldLoginObserver();
else removeShieldLoginObserver();
} catch (e) {
console.warn("[XHS助手] 读取屏蔽登录设置失败", e);
}
}
// 立即在脚本启动时应用用户设置(若用户此前已开启,则生效)
try {
initShieldLoginFromSetting();
} catch (e) {
console.warn("[XHS助手] 启动时初始化屏蔽登录设置失败", e);
}
// 启动点击拦截器(检查设置开关后决定是否拦截)
try {
setupClickInterceptors();
} catch (e) {
console.warn("[XHS助手] 启动点击拦截器失败", e);
}
// 绑定设置面板复选框变化
function bindShieldLoginSetting() {
const $cb = document.getElementById("pc-shield-login-dialog");
if (!$cb) return;
$cb.addEventListener("change", (e) => {
const checked = !!e.target.checked;
try {
if (GM_setValue) GM_setValue(SHIELD_LOGIN_KEY, checked);
} catch (err) {
console.warn("[XHS助手] 保存屏蔽登录设置失败", err);
}
if (checked) addShieldLoginObserver();
else removeShieldLoginObserver();
});
}
// ==========================
// 新标签页打开笔记 & 修复点击跳转
// ==========================
function initOpenBlankFromSetting() {
try {
const $cb = document.getElementById("pc-xhs-open-blank");
if ($cb) $cb.checked = !!GM_getValue(OPEN_BLANK_KEY, false);
} catch (e) {
console.warn("[XHS助手] 读取新标签页设置失败", e);
}
}
function bindOpenBlankSetting() {
const $cb = document.getElementById("pc-xhs-open-blank");
if (!$cb) return;
$cb.addEventListener("change", (e) => {
try { GM_setValue(OPEN_BLANK_KEY, !!e.target.checked); } catch (err) {}
});
}
function initFixClickFromSetting() {
try {
const $cb = document.getElementById("pc-xhs-fix-click");
if ($cb) $cb.checked = !!GM_getValue(FIX_CLICK_KEY, false);
} catch (e) {
console.warn("[XHS助手] 读取修复点击设置失败", e);
}
}
function bindFixClickSetting() {
const $cb = document.getElementById("pc-xhs-fix-click");
if (!$cb) return;
$cb.addEventListener("change", (e) => {
try { GM_setValue(FIX_CLICK_KEY, !!e.target.checked); } catch (err) {}
});
}
function setupClickInterceptors() {
document.addEventListener("click", (event) => {
try {
if (!GM_getValue(OPEN_BLANK_KEY, false)) return;
const $card = event.target.closest(".feeds-container .note-item, section.note-item, .note-scard");
if (!$card) return;
const $link = $card.querySelector("a.cover[href]");
if (!$link) return;
let url = $link.href;
try {
const urlInst = new URL(url);
urlInst.pathname = urlInst.pathname.replace(/^\/user\/profile\/[a-z0-9A-Z]+\//i, "/discovery/item/");
url = urlInst.toString();
} catch (e) {}
event.preventDefault();
event.stopPropagation();
window.open(url, "_blank");
} catch (e) { console.warn("[XHS助手] 新标签页打开失败", e); }
}, true);
document.addEventListener("click", (event) => {
try {
if (!GM_getValue(FIX_CLICK_KEY, false)) return;
const $section = event.target.closest("section.reds-note-card");
if (!$section) return;
const note_id = $section.getAttribute("id");
if (!note_id) return;
event.preventDefault();
event.stopPropagation();
window.open(`https://www.xiaohongshu.com/explore/${note_id}`, "_blank");
} catch (e) { console.warn("[XHS助手] 修复点击跳转失败", e); }
}, true);
}
// ==========================================
// 1. API 拦截器 (Hook XHR)
// ==========================================
function hookXHR() {
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._url = url;
// 图片 CDN 请求:标记跳过钩子,直通原始 send(避免与 ScriptCat GM_xmlhttpRequest 冲突)
if (typeof url === "string" && (url.includes("xhscdn.com") || url.includes("xhscdn.net"))) {
this._xhsImageDownload = true;
}
return originalOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
// 图片下载标记:跳过事件监听,直通原始 send
if (this._xhsImageDownload) {
return originalSend.apply(this, arguments);
}
this.addEventListener("load", function () {
// 监听特定 API 接口
// /api/sns/web/v1/user_posted -> 用户主页(笔记)
// /api/sns/web/v2/note/collect/page -> 收藏夹
// /api/sns/web/v1/note/like/page -> 点赞列表
// /api/sns/web/v1/user/like -> 点赞列表(备选)
// /api/sns/web/v1/homefeed -> 首页推荐/搜索
// /api/sns/web/v1/search/notes -> 搜索结果
if (
this._url &&
(this._url.includes("/api/sns/web/v1/user_posted") ||
this._url.includes("/api/sns/web/v2/note/collect/page") ||
this._url.includes("/api/sns/web/v1/note/like/page") ||
this._url.includes("/api/sns/web/v1/user/like") ||
this._url.includes("/api/sns/web/v1/homefeed") ||
this._url.includes("/api/sns/web/v1/search/notes"))
) {
try {
const res = JSON.parse(this.responseText);
if (res.data && (res.data.notes || res.data.items)) {
const list = res.data.notes || res.data.items || [];
processNotes(list);
}
} catch (e) {
console.error("小红书AI超能助手: 解析API数据失败", e);
}
}
});
return originalSend.apply(this, arguments);
};
}
// 处理并存储笔记数据
function processNotes(notes) {
let newCount = 0;
// 兼容搜索结果页结构(如 /api/sns/web/v1/search/notes)及 首页推荐(/api/sns/web/v1/homefeed)
// notes 可能是 [{note: {...}}, ...] 或直接 [{...}]
notes.forEach((raw) => {
let note = raw;
// 搜索结果页结构:{note: {...}, ...}
if (raw && raw.note && typeof raw.note === "object") {
note = raw.note;
}
// 兼容 note_card 层
if (note && note.note_card) {
note = note.note_card;
}
// 兼容 note_info 层
if (note && note.note_info) {
note = note.note_info;
}
// 兼容 item 层(部分搜索结果)
if (note && note.item) {
note = note.item;
}
// 兼容 feed_note 层(部分推荐/搜索结果)
if (note && note.feed_note) {
note = note.feed_note;
}
// 统一提取字段
// 优先从深层对象取,取不到尝试从原始对象取(防止层级下钻导致外层属性丢失)
const id =
note.id || note.note_id || note.noteId || raw.id || raw.note_id;
if (!id) return;
const token = note.xsec_token || raw.xsec_token || "";
let link = `https://www.xiaohongshu.com/explore/${id}`;
if (token) {
link += `?xsec_token=${token}&xsec_source=pc_user`;
}
const title =
note.title ||
note.display_title ||
note.desc ||
raw.title ||
raw.display_title ||
"无标题";
const type = note.type || raw.type || "normal";
const user = note.user || raw.user || {};
const authorName =
user.nickname ||
user.name ||
note.author ||
raw.author ||
(raw.user && raw.user.nickname) ||
"未知作者";
const likes =
note.likes ||
note.liked_count ||
note.like_count ||
(note.interact_info && note.interact_info.liked_count) ||
raw.likes ||
raw.liked_count ||
(raw.interact_info && raw.interact_info.liked_count) ||
0;
const coverUrl =
(note.cover && note.cover.url_default) ||
(note.images_list && note.images_list[0] && note.images_list[0].url) ||
note.cover_url ||
(raw.cover && raw.cover.url_default) ||
raw.cover_url ||
"";
if (!GLOBAL_DATA.has(id)) {
GLOBAL_DATA.set(id, {
笔记ID: id,
标题: title,
链接: link,
作者: authorName,
点赞数: likes,
封面图: coverUrl,
类型: type,
xsec_token: token,
});
newCount++;
}
});
// 更新 UI 计数
updateCountUI();
}
// 启动 Hook
hookXHR();
// ==========================================
// 2. 默认模板 (AI部分 - 保持不变)
// ==========================================
const DEFAULT_TEMPLATES = [
{
id: "novel_default",
name: "📖 小说推文 (情绪爆款)",
desc1: "小说名称",
desc2: "精彩片段/剧情",
placeholder1: "例如:重生之将门毒后",
placeholder2: "粘贴这一章的剧情...",
system:
"你是一个小红书推文博主,风格非常情绪化、激动,喜欢用'啊啊啊'、'高开暴走'、'Top1'等词汇。",
prompt: `请模仿以下风格推荐小说《{{title}}》。\n【参考风格】:"啊啊啊高开暴走!!..."\n【小说内容】:{{content}}\n要求:标题带悬念含Emoji,正文情绪化分段,JSON格式输出 {"title": "...", "content": "..."}`,
},
{
id: "product_default",
name: "💄 好物种草 (痛点直击)",
desc1: "产品名称",
desc2: "核心卖点/痛点",
placeholder1: "例如:戴森吹风机",
placeholder2: "痛点:头发干枯... 体验:柔顺...",
system:
"你是一个小红书金牌种草官,擅长挖掘痛点和场景,说话就像闺蜜聊天。",
prompt: `请为产品【{{title}}】写一篇种草笔记。\n信息:{{content}}\n要求:痛点+场景+Emoji,JSON格式输出 {"title": "...", "content": "..."}`,
},
];
// ==========================================
// 3. SVG 图标 (16x16, currentColor)
// ==========================================
const ICONS = {
gear: ``,
download: ``,
heart: ``,
check: ``,
bulb: ``,
tools: ``,
star: ``,
sparkle: ``,
package: ``,
link: ``,
mouse: ``,
search: ``,
chart: ``,
tag: ``,
folder: ``,
chat: ``,
doc: ``,
trash: ``,
plus: ``,
refresh: ``,
rocket: ``,
warn: ``,
note: ``,
arrowDn: ``,
xmark: ``,
minus: ``,
};
// ==========================================
// 4. 核心样式
// ==========================================
GM_addStyle(
'@import url("https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css");',
);
const UI_CSS = `
#xhs-ai-helper * { box-sizing: border-box; }
/* ===== 动画 ===== */
@keyframes fadeInUp { from { opacity:0; transform: translateY(8px); } to { opacity:1; transform: translateY(0); } }
@keyframes badgePop { 0% { transform: scale(0); } 60% { transform: scale(1.15); } 100% { transform: scale(1); } }
/* ===== 主面板 ===== */
#xhs-ai-helper {
position: fixed; top: 80px; right: 20px; width: 420px;
background: #fff;
box-shadow: 0 4px 24px rgba(0,0,0,0.08);
border: 1px solid rgba(0,0,0,0.06);
border-radius: 12px; z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", sans-serif;
display: flex; flex-direction: column; color: #222; font-size: 14px;
transition: opacity 0.2s ease;
}
/* ===== 最小化浮窗按钮 ===== */
#xhs-ai-helper.minimized {
width: 50px; height: 50px; border-radius: 50%; overflow: visible;
cursor: pointer;
background: #fff;
border: 1px solid rgba(0,0,0,0.08);
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
transition: box-shadow 0.2s ease;
}
#xhs-ai-helper.minimized:hover {
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
#xhs-ai-helper.minimized .minimized-icon {
display: flex; width:100%; height:100%; align-items:center; justify-content:center;
color: #ff2442;
position: relative; z-index: 1;
user-select: none; -webkit-user-select: none;
}
#xhs-ai-helper.minimized .ai-main-wrapper { display: none; }
#xhs-ai-helper.minimized::after {
content: ''; position: absolute; top: 1px; right: 1px;
width: 8px; height: 8px; background: #ff2442; border-radius: 50%;
border: 1.5px solid #fff; z-index: 3;
animation: badgePop 0.4s ease;
}
/* ===== 标题栏 ===== */
.drag-handle {
padding: 12px 16px; cursor: move;
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid rgba(0,0,0,0.06); user-select: none;
background: #ff2442;
border-radius: 12px 12px 0 0;
}
.ai-brand { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 6px; color: #fff; }
.minimize-btn {
width: 28px; height: 28px; border: none; background: rgba(255,255,255,0.15);
border-radius: 6px; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: background 0.15s; line-height: 1;
}
.minimize-btn:hover { background: rgba(255,255,255,0.28); }
/* ===== Tab 栏 ===== */
.ai-tabs {
display: flex; gap: 0; padding: 8px 12px 0;
background: #fff; border-radius: 0;
border-bottom: 1px solid #eee;
overflow-x: auto; white-space: nowrap; position: relative;
scrollbar-width: none; -ms-overflow-style: none;
}
.ai-tabs::-webkit-scrollbar { display: none; }
.ai-tab-item {
padding: 8px 12px; font-size: 13px; font-weight: 500; color: #888; cursor: pointer;
border-radius: 6px 6px 0 0; transition: color 0.15s; position: relative;
background: transparent; flex-shrink: 0;
}
.ai-tab-item:hover { color: #ff2442; }
.ai-tab-item.active { color: #ff2442; font-weight: 500; }
.ai-tab-item.active::after {
content: ''; position: absolute; bottom: -1px; left: 8px; right: 8px;
height: 1.5px; background: #ff2442; border-radius: 2px 2px 0 0;
}
/* ===== 内容区 ===== */
.ai-content-body {
padding: 16px; max-height: 70vh; overflow-y: auto;
background: #fafafa; border-radius: 0 0 12px 12px; min-height: 200px;
}
.tab-panel { display: none; animation: fadeInUp 0.2s ease; }
.tab-panel.active { display: block; }
/* ===== 卡片 ===== */
.data-card {
background: #fff; padding: 14px; border-radius: 8px;
margin-bottom: 10px;
border: 1px solid rgba(0,0,0,0.06);
}
.ai-compact-box {
background: #fff; padding: 10px; border-radius: 8px;
border: 1px solid rgba(0,0,0,0.06);
}
/* ===== 表单控件 ===== */
.ai-input, .ai-textarea, .ai-select {
width: 100%; padding: 8px 12px;
border: 1px solid #e0e0e0; border-radius: 6px;
margin-bottom: 10px; box-sizing: border-box;
background: #fff; font-size: 13px;
transition: border-color 0.15s; outline: none;
font-family: inherit;
}
.ai-input:focus, .ai-textarea:focus, .ai-select:focus {
border-color: #ff2442;
box-shadow: 0 0 0 2px rgba(255,36,66,0.12);
}
.ai-textarea { height: 80px; resize: vertical; }
/* ===== 按钮 ===== */
.ai-btn {
width: 100%; padding: 9px 16px;
background: #ff2442;
color: #fff; border: none; border-radius: 6px;
cursor: pointer; font-weight: 500; font-size: 13px;
margin-top: 6px; transition: opacity 0.15s;
font-family: inherit;
}
.ai-btn:hover { opacity: 0.9; }
.ai-btn:active { opacity: 0.85; }
.ai-btn.secondary {
background: #fff; color: #333;
border: 1px solid #e0e0e0;
}
.ai-btn.secondary:hover {
background: #f5f5f5;
}
.ai-btn.scrolling { background: #ff9800; }
.ai-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.ai-btn .btn-icon { margin-right: 6px; }
/* ===== 信息条 ===== */
.info-bar {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 6px; font-size: 12px;
margin-bottom: 8px;
}
.info-bar.info { background: #f0f7ff; border: 1px solid #d6e4ff; color: #1a5a9e; }
.info-bar.success { background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46; }
.info-bar.warn { background: #fff7ed; border: 1px solid #fed7aa; color: #9a3412; }
.info-bar.like { background: #fff1f3; border: 1px solid #fecdd3; color: #be123c; }
/* ===== 导出字段标签 ===== */
.export-field-container {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 6px; font-size: 11px;
}
.export-field-item {
background: #fff; border: 1px solid #e0e0e0; border-radius: 6px;
padding: 7px 2px; text-align: center; cursor: pointer;
user-select: none; transition: all 0.15s;
color: #666; position: relative; font-weight: 500;
}
.export-field-item:hover {
border-color: #ff2442; color: #ff2442; background: #fff5f6;
}
.export-field-item.selected {
background: rgba(255,36,66,0.06);
border-color: #ff2442; color: #ff2442; font-weight: 600;
}
.export-field-item.selected::after {
content: '✓'; position: absolute; top: -6px; right: -6px;
font-size: 9px; background: #ff2442; color: #fff;
width: 16px; height: 16px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
border: 1.5px solid #fff;
animation: badgePop 0.3s ease;
}
/* ===== 文件上传 ===== */
.file-upload-label {
display: flex; align-items: center; justify-content: center; gap: 8px;
background: #fff; border: 1.5px dashed #d0d0d0; border-radius: 8px;
padding: 18px; cursor: pointer; color: #888; font-size: 13px;
transition: all 0.15s; margin-bottom: 6px;
}
.file-upload-label:hover { border-color: #ff2442; color: #ff2442; background: #fff5f6; }
.analysis-result-box {
margin-top: 10px; font-size: 13px; line-height: 1.7;
background: #fff; padding: 14px; border-radius: 8px;
border: 1px solid #eee; max-height: 250px; overflow-y: auto;
color: #444; white-space: pre-wrap; display: none;
}
/* ===== 资源网格 ===== */
.res-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 8px; margin-top: 10px;
}
.res-item {
position: relative; aspect-ratio: 1; border-radius: 6px;
overflow: hidden; border: 2px solid #eee; cursor: pointer;
transition: border-color 0.15s;
}
.res-item:hover { border-color: #ff2442; }
.res-item img, .res-item video { width: 100%; height: 100%; object-fit: cover; }
.res-item .res-type {
position: absolute; top: 4px; right: 4px; font-size: 10px;
background: rgba(0,0,0,0.6); color: #fff; padding: 2px 6px;
border-radius: 4px;
}
.res-item.selected { border-color: #ff2442; box-shadow: 0 0 0 2px rgba(255,36,66,0.15); }
.res-item .res-check {
position: absolute; top: 4px; left: 4px; z-index: 2;
width: 18px; height: 18px; background: #fff;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
opacity: 0.7;
}
.res-item.selected .res-check {
background: #ff2442; color: #fff; opacity: 1;
}
/* ===== Toggle Switch ===== */
.ai-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
.ai-switch input { opacity: 0; width: 0; height: 0; }
.ai-slider {
position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0;
background: #d9d9d9; transition: .2s; border-radius: 24px;
}
.ai-slider:before {
position: absolute; content: ""; height: 18px; width: 18px;
left: 3px; bottom: 3px; background: #fff;
transition: .2s; border-radius: 50%;
}
input:checked + .ai-slider { background: #ff2442; }
input:checked + .ai-slider:before { transform: translateX(20px); }
.ai-switch-label {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 14px; background: #fff; border-radius: 8px;
border: 1px solid #eee; margin-top: 8px;
cursor: pointer;
}
.ai-switch-label .switch-text { font-size: 14px; font-weight: 500; color: #333; }
.ai-switch-label .switch-desc { font-size: 11px; color: #999; margin-top: 2px; }
/* ===== 导出提示 ===== */
.export-tip {
font-size: 12px; color: #555; margin-top: 12px; line-height: 1.6;
background: #fafafa;
padding: 12px 14px; border-radius: 6px;
border: 1px solid #eee;
}
/* ===== 徽章计数 ===== */
.badge-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 5px;
background: #ff2442;
color: #fff; font-size: 11px; font-weight: 600;
border-radius: 10px; margin-left: 4px;
}
/* ===== 进度条 ===== */
.ai-progress { height: 4px; background: #eee; border-radius: 2px; overflow: hidden; margin-bottom: 10px; }
.ai-progress-bar {
height: 100%; border-radius: 2px;
background: #ff2442;
transition: width 0.3s ease; width: 0%;
}
/* ===== 小屏适配 ===== */
@media screen and (max-width: 500px), screen and (max-height: 600px) {
#xhs-ai-helper {
width: calc(100vw - 20px) !important; max-width: 360px;
right: 10px !important; left: auto !important;
top: 10px !important; max-height: calc(100vh - 20px);
}
#xhs-ai-helper .ai-content-body { max-height: calc(100vh - 150px); }
}
@media screen and (max-width: 400px) {
#xhs-ai-helper { width: calc(100vw - 10px) !important; right: 5px !important; font-size: 13px; }
#xhs-ai-helper .ai-tabs { padding: 0 8px; }
#xhs-ai-helper .ai-tab-item { padding: 10px 8px; font-size: 12px; }
#xhs-ai-helper .ai-content-body { padding: 10px; }
}
`;
GM_addStyle(UI_CSS);
// ==========================================
// 3.5 激活页面
// ==========================================
function showActivation() {
if (document.getElementById("xhs-activation")) return;
const div = document.createElement("div");
div.id = "xhs-activation";
div.innerHTML = `
🔑
小红书AI超能助手
v1.0 · 永久免费
🎁 本工具完全免费,不花一分钱
批量下载小红书笔记 · 正文图片 · 无水印视频 · 批量下载评论区评论
激活码请加 QQ 群获取
${"75"+"7888"+"510"}
`;
document.body.appendChild(div);
const input = div.querySelector("#activation-code");
const btn = div.querySelector("#activation-btn");
const err = div.querySelector("#activation-error");
const doActivate = () => {
const code = input.value.trim();
if (code === atob("eGhzdXBlcg==")) {
GM_setValue("activated", true);
div.remove();
setTimeout(createUI, 300);
} else {
err.textContent = "激活码不正确,请加 QQ 群 "+"75"+"7888"+"510"+" 获取";
err.style.display = "block";
input.style.borderColor = "#ff2442";
}
};
btn.onclick = doActivate;
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") doActivate();
err.style.display = "none";
input.style.borderColor = "#e0e0e0";
});
input.focus();
}
// ==========================================
// 4. UI 构建
// ==========================================
let templates = [];
function loadTemplates() {
const stored = GM_getValue("user_templates", null);
templates = stored
? JSON.parse(stored)
: JSON.parse(JSON.stringify(DEFAULT_TEMPLATES));
}
function saveTemplates() {
GM_setValue("user_templates", JSON.stringify(templates));
}
function createUI() {
if (document.getElementById("xhs-ai-helper")) return;
loadTemplates();
const div = document.createElement("div");
div.id = "xhs-ai-helper";
div.innerHTML = `
${ICONS.star}
数据导出
资源下载
AI创作
AI分析
${ICONS.gear} 设置
当前已捕获:0 条
静止中
${ICONS.bulb} 向下滑动页面,数据会自动增加。
❤️ 点赞过滤:
符合条件:0 条
✅ 已启用 API 拦截模式
导出的链接将包含 xsec_token,确保能在浏览器中直接访问,不会出现"笔记不存在"。
支持收藏夹、个人主页、搜索结果页。
📊 智能总结与分析
上传导出的 CSV/JSON,让 AI 分析趋势。
🏷️ 智能分类重构
AI 自动分类数据并生成新文件。
导出格式:
⚠️ 检测到未分类数据。这通常是因为 API 速率限制或额度不足。
建议:
1. 检查 API Key 额度。
2. 更换更稳定的模型 (如 gpt-3.5/4)。
3. 每次处理需要一定时间,请耐心等待。
${ICONS.download} 笔记资源提取
从"数据导出"中复制笔记链接,粘贴到这里即可批量下载正文图片、无水印视频和全部评论,方便喂给 AI 分析训练爆款。
可提取内容:封面图 · 正文图片 · 无水印视频 · 笔记评论数据
资源列表
${ICONS.gear} 全局 API 设置
此处的配置将应用于 AI 创作、分析和分类功能。
`;
document.body.appendChild(div);
// 绑定事件
bindDrag(div);
div.querySelector("#minimize-btn").onclick = () =>
div.classList.toggle("minimized");
const tabs = div.querySelectorAll(".ai-tab-item");
const tabsContainer = div.querySelector(".ai-tabs");
tabs.forEach(
(t) =>
(t.onclick = () => {
tabs.forEach((x) => x.classList.remove("active"));
t.classList.add("active");
div
.querySelectorAll(".tab-panel")
.forEach((p) => p.classList.remove("active"));
div.querySelector("#panel-" + t.dataset.tab).classList.add("active");
// 选中 Tab 自动居中滚动逻辑
if (tabsContainer) {
const targetScroll =
t.offsetLeft - tabsContainer.offsetWidth / 2 + t.offsetWidth / 2;
tabsContainer.scrollTo({
left: targetScroll,
behavior: "smooth",
});
}
}),
);
// ==========================
// 数据功能绑定
// ==========================
div.querySelector("#auto-scroll-btn").onclick = toggleAutoScroll;
div.querySelector("#clean-data-btn").onclick = () => {
if (confirm("确定清空已捕获的数据吗?")) {
GLOBAL_DATA.clear();
updateCountUI();
}
};
div.querySelector("#export-btn").onclick = exportData;
// 点赞过滤输入 - 实时更新计数
const minLikesInput = div.querySelector("#min-likes-filter");
if (minLikesInput) {
minLikesInput.addEventListener("input", updateFilteredCount);
}
const fmtSel = div.querySelector("#export-format");
const splitArea = div.querySelector("#split-setting-area");
// 全类型支持分包,不再隐藏 splitArea
// if (fmtSel && splitArea) { ... }
// 资源下载绑定
div.querySelector("#res-fetch-btn").onclick = handleFetchResources;
div.querySelector("#res-select-all").onchange = (e) => {
const checked = e.target.checked;
div.querySelectorAll(".res-item").forEach((item) => {
if (checked) item.classList.add("selected");
else item.classList.remove("selected");
});
};
div.querySelector("#res-quality-select").onchange = () => {
if (BATCH_NOTE_DATAS.length > 0) {
const grid = document.getElementById("res-grid");
grid.innerHTML = "";
BATCH_NOTE_DATAS.forEach((data, idx) => {
renderResourceGrid(data, idx > 0);
});
} else if (CURRENT_NOTE_RAW) {
renderResourceGrid(CURRENT_NOTE_RAW);
}
};
div.querySelector("#res-download-btn").onclick = handleBatchDownload;
div.querySelector("#res-comment-export-btn").onclick = exportResComments;
div.querySelector("#res-export-all-btn").onclick = exportAllNoteData;
div.querySelector("#res-export-zip-btn").onclick = exportAsFolder;
// AI功能绑定
// div.querySelector("#config-toggle").onclick = ... // Removed
// 初始化并绑定屏蔽登录弹窗设置
try {
initShieldLoginFromSetting();
bindShieldLoginSetting();
} catch (e) {
console.warn("[XHS助手] 初始化屏蔽登录设置失败", e);
}
// 初始化新标签页打开笔记 & 修复点击跳转设置
try {
initOpenBlankFromSetting();
bindOpenBlankSetting();
initFixClickFromSetting();
bindFixClickSetting();
} catch (e) {
console.warn("[XHS助手] 初始化页面跳转设置失败", e);
}
// ============================
// Config Manager Logic
// ============================
const DEFAULT_CONFIGS = [
{
id: "moonshot",
name: "🌙 Moonshot (Kimi)",
baseUrl: "https://api.moonshot.cn/v1/chat/completions",
key: "",
model: "moonshot-v1-8k",
builtIn: true,
},
{
id: "deepseek",
name: "🐋 DeepSeek",
baseUrl: "https://api.deepseek.com/chat/completions",
key: "",
model: "deepseek-chat",
builtIn: true,
},
{
id: "openai",
name: "🤖 OpenAI (GPT)",
baseUrl: "https://api.openai.com/v1/chat/completions",
key: "",
model: "gpt-3.5-turbo",
builtIn: true,
},
{
id: "custom",
name: "自定义配置",
baseUrl: "",
key: "",
model: "",
builtIn: false,
},
];
let apiConfigs = [];
try {
apiConfigs = JSON.parse(GM_getValue("api_configs", "[]"));
if (!Array.isArray(apiConfigs) || apiConfigs.length === 0)
apiConfigs = JSON.parse(JSON.stringify(DEFAULT_CONFIGS));
} catch (e) {
apiConfigs = JSON.parse(JSON.stringify(DEFAULT_CONFIGS));
}
let currentConfigId = GM_getValue("current_api_config_id", "moonshot");
function renderConfigSelect() {
const sel = document.getElementById("api-config-select");
sel.innerHTML = "";
apiConfigs.forEach((c) => {
const opt = document.createElement("option");
opt.value = c.id;
opt.innerText = c.name;
sel.appendChild(opt);
});
// Ensure current ID exists
if (!apiConfigs.some((c) => c.id === currentConfigId)) {
currentConfigId = apiConfigs[0].id;
}
sel.value = currentConfigId;
loadConfigToUI(currentConfigId);
}
function loadConfigToUI(id) {
const config = apiConfigs.find((c) => c.id === id) || apiConfigs[0];
currentConfigId = config.id;
GM_setValue("current_api_config_id", currentConfigId);
const baseUrlInput = document.getElementById("api-base-url");
const keyInput = document.getElementById("api-key");
const modelInput = document.getElementById("api-model");
const delBtn = document.getElementById("api-config-del");
const modelSelect = document.getElementById("api-model-select");
baseUrlInput.value = config.baseUrl;
keyInput.value = config.key;
modelInput.value = config.model;
// Hide model select on config switch
modelSelect.style.display = "none";
if (config.builtIn) {
delBtn.style.display = "none";
} else {
delBtn.style.display = "block";
}
}
function saveCurrentConfigFromUI() {
const configIndex = apiConfigs.findIndex((c) => c.id === currentConfigId);
if (configIndex !== -1) {
apiConfigs[configIndex].baseUrl =
document.getElementById("api-base-url").value;
apiConfigs[configIndex].key = document.getElementById("api-key").value;
apiConfigs[configIndex].model =
document.getElementById("api-model").value;
GM_setValue("api_configs", JSON.stringify(apiConfigs));
// Sync legacy values for compatibility if needed elsewhere
GM_setValue("api_base_url", apiConfigs[configIndex].baseUrl);
GM_setValue("api_key", apiConfigs[configIndex].key);
GM_setValue("api_model", apiConfigs[configIndex].model);
}
}
// Bind Config Events
document.getElementById("api-config-select").onchange = (e) =>
loadConfigToUI(e.target.value);
["api-base-url", "api-key", "api-model"].forEach((id) => {
const el = document.getElementById(id);
el.onchange = saveCurrentConfigFromUI;
el.oninput = saveCurrentConfigFromUI;
});
document.getElementById("api-config-add").onclick = () => {
const name = prompt(
"请输入新配置名称",
"我的配置 " + (apiConfigs.length + 1),
);
if (name) {
const newId = "custom_" + Date.now();
apiConfigs.push({
id: newId,
name: name,
baseUrl: "https://",
key: "",
model: "",
builtIn: false,
});
GM_setValue("api_configs", JSON.stringify(apiConfigs));
renderConfigSelect();
// Select new
document.getElementById("api-config-select").value = newId;
loadConfigToUI(newId);
}
};
document.getElementById("api-config-del").onclick = () => {
if (confirm("确定删除当前配置吗?")) {
apiConfigs = apiConfigs.filter((c) => c.id !== currentConfigId);
if (apiConfigs.length === 0)
apiConfigs = JSON.parse(JSON.stringify(DEFAULT_CONFIGS));
GM_setValue("api_configs", JSON.stringify(apiConfigs));
currentConfigId = apiConfigs[0].id;
renderConfigSelect();
}
};
document.getElementById("api-model-fetch-btn").onclick = async () => {
const btn = document.getElementById("api-model-fetch-btn");
const modelSel = document.getElementById("api-model-select");
const originalText = btn.innerText;
btn.innerText = "...";
btn.disabled = true;
const baseUrl = document.getElementById("api-base-url").value;
const key = document.getElementById("api-key").value;
if (!baseUrl) {
alert("请先填写 Base URL");
btn.innerText = originalText;
btn.disabled = false;
return;
}
if (!key) {
alert("请先填写 API Key");
btn.innerText = originalText;
btn.disabled = false;
return;
}
// Try to deduce models endpoint
// Standard: .../v1/chat/completions -> .../v1/models
let modelsUrl = baseUrl;
if (modelsUrl.endsWith("/chat/completions")) {
modelsUrl = modelsUrl.replace("/chat/completions", "/models");
} else if (modelsUrl.endsWith("/")) {
modelsUrl = modelsUrl + "models";
} else {
// If base url is just host, add /v1/models? Not sure, user usually puts chat/completions
// Let's try replacing last segment if it is not models
// Fallback: assume user put full chat url
modelsUrl = modelsUrl.replace(/\/chat\/completions\/?$/, "/models");
}
console.log("[AI] Fetching models from:", modelsUrl);
try {
const res = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: modelsUrl,
headers: {
Authorization: `Bearer ${key}`,
"Content-Type": "application/json",
},
onload: (r) => {
if (r.status === 200) resolve(JSON.parse(r.responseText));
else
reject(
"Status " +
r.status +
"\n" +
r.responseText.substring(0, 100),
);
},
onerror: (e) => reject("Network Error"),
});
});
let models = [];
if (res && Array.isArray(res.data)) {
models = res.data.map((m) => m.id);
} else if (Array.isArray(res)) {
models = res.map((m) => m.id || m); // Some APIs return array directly
}
if (models.length > 0) {
modelSel.innerHTML =
'';
// 简单规则:包含特定关键词的模型被认为支持 AI 分析/分类 (即支持长文本或通用能力强)
const capableKeywords = [
"moonshot",
"gpt-4",
"claude-3",
"deepseek",
"128k",
"32k",
"200k",
"pro",
];
models.forEach((m) => {
const opt = document.createElement("option");
opt.value = m;
let label = m;
if (capableKeywords.some((k) => m.toLowerCase().includes(k))) {
label += " (支持AI分析)";
}
opt.innerText = label;
modelSel.appendChild(opt);
});
modelSel.style.display = "block";
modelSel.onchange = () => {
document.getElementById("api-model").value = modelSel.value;
saveCurrentConfigFromUI();
};
// Auto expand?
modelSel.click();
} else {
alert(
"API请求成功,但未能解析出模型列表。请手动输入。\n" +
JSON.stringify(res).slice(0, 100),
);
}
} catch (e) {
alert("获取模型失败: " + e + "\n尝试URL: " + modelsUrl);
} finally {
btn.innerText = originalText;
btn.disabled = false;
}
};
// Initialize
renderConfigSelect();
div.querySelector("#ai-gen-btn").onclick = handleAI;
// 模版管理
const managePanel = document.getElementById("template-manage-panel");
document.getElementById("template-manage-toggle").onclick = () => {
managePanel.style.display =
managePanel.style.display === "none" ? "block" : "none";
};
document.getElementById("template-manage-select").onchange = () => {
const t = templates.find(
(x) => x.id === document.getElementById("template-manage-select").value,
);
if (t) fillTemplateForm(t);
};
document.getElementById("template-new-btn").onclick = () => {
const empty = {
id: "tmp_" + Date.now(),
name: "新模版",
desc1: "输入1",
desc2: "输入2",
placeholder1: "",
placeholder2: "",
system: "你是一个专业的创作者助手。",
prompt: "请基于{{title}}和{{content}}生成创作内容。",
};
templates.push(empty);
saveTemplates();
refreshTemplates();
refreshManageSelect(empty.id);
fillTemplateForm(empty);
toastManage("已创建空白模版,可编辑后保存");
};
document.getElementById("template-save-btn").onclick = () => {
const id = document.getElementById("template-manage-select").value;
const idx = templates.findIndex((x) => x.id === id);
if (idx === -1) return alert("请选择要保存的模版");
templates[idx] = collectTemplateForm(id);
saveTemplates();
refreshTemplates();
refreshManageSelect(id);
fillTemplateForm(templates[idx]);
toastManage("已保存模版");
};
document.getElementById("template-save-new-btn").onclick = () => {
const id = "tpl_" + Date.now();
const t = collectTemplateForm(id);
templates.push(t);
saveTemplates();
refreshTemplates();
refreshManageSelect(id);
fillTemplateForm(t);
toastManage("已另存为新模版");
};
document.getElementById("template-delete-btn").onclick = () => {
const id = document.getElementById("template-manage-select").value;
if (!confirm("确认删除该模版吗?")) return;
templates = templates.filter((x) => x.id !== id);
if (!templates.length)
templates = JSON.parse(JSON.stringify(DEFAULT_TEMPLATES));
saveTemplates();
refreshTemplates();
const first = templates[0];
refreshManageSelect(first.id);
fillTemplateForm(first);
toastManage("模版已删除");
};
document.getElementById("persona-gen-btn").onclick = handlePersonaGenerate;
// AI分析功能绑定
div.querySelector("#analysis-file-input").onchange = function () {
const file = this.files[0];
const label = div.querySelector("#analysis-file-label");
const nameDisplay = div.querySelector("#analysis-file-name");
if (file) {
nameDisplay.textContent = "📄 已选择: " + file.name;
nameDisplay.style.display = "block";
label.style.borderColor = "#4a90e2";
label.style.background = "#eff6ff";
label.textContent = "📂 更换文件";
}
};
div.querySelector("#analysis-summary-btn").onclick = handleAnalysisSummary;
div.querySelector("#analysis-classify-btn").onclick =
handleAnalysisClassify;
div.querySelector("#analysis-config-btn").onclick = () => {
// Switch to Settings tab
const settingsTab = div.querySelector(
'.ai-tab-item[data-tab="settings"]',
);
if (settingsTab) settingsTab.click();
};
// 营销工具绑定
const mktSearchBtn = div.querySelector("#mkt-search-btn");
if (mktSearchBtn) mktSearchBtn.onclick = () => {
const kw = div.querySelector("#mkt-keyword").value.trim();
if (kw) {
const url = `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(kw)}&source=web_search_result_notes`;
if (
confirm(
`即将跳转到搜索页:${kw}\n\n跳转后请点击“自动滚动”以加载更多笔记,然后再回到这里抓取评论。`,
)
) {
location.href = url;
}
}
};
// 更新营销工具面板的数据计数
// Add an observer or hook to update count when tab is switched
tabs.forEach((t) => {
if (t.dataset.tab === "marketing") {
t.addEventListener("click", () => {
const cnt = document.getElementById("mkt-note-count");
if (cnt) cnt.innerText = GLOBAL_DATA.size;
});
}
});
const mktRunBtn = div.querySelector("#mkt-run-btn");
if (mktRunBtn) mktRunBtn.onclick = handleBatchCommentCrawl;
const mktExportBtn = div.querySelector("#mkt-export-btn");
if (mktExportBtn) mktExportBtn.onclick = exportCommentData;
// 绑定导出字段点击事件
const exportContainer = div.querySelector("#export-field-container");
if (exportContainer) {
exportContainer.addEventListener("click", (e) => {
const item = e.target.closest(".export-field-item");
if (item) {
item.classList.toggle("selected");
}
});
}
refreshManageSelect();
if (templates[0]) fillTemplateForm(templates[0]);
refreshTemplates();
// 初始化时,如果页面已经有数据(SSR),尝试简单抓取一下当前DOM补充(作为兜底)
setTimeout(scanInitialDOM, 2000);
}
// ==========================================
// 5. 自动滚动与数据逻辑
// ==========================================
function updateCountUI() {
const el = document.getElementById("page-obj-count");
if (el) el.innerText = GLOBAL_DATA.size;
updateFilteredCount();
}
function getMinLikesFilter() {
const input = document.getElementById("min-likes-filter");
if (!input) return 0;
return parseInt(input.value) || 0;
}
function getFilteredData() {
const minLikes = getMinLikesFilter();
if (minLikes <= 0) return Array.from(GLOBAL_DATA.values());
return Array.from(GLOBAL_DATA.values()).filter((item) => {
const likes = parseInt(item["点赞数"]) || 0;
return likes >= minLikes;
});
}
function updateFilteredCount() {
const el = document.getElementById("filtered-count");
if (!el) return;
const minLikes = getMinLikesFilter();
if (minLikes <= 0) {
el.innerText = GLOBAL_DATA.size;
} else {
const filtered = getFilteredData();
el.innerText = filtered.length;
}
}
// 兜底策略:扫描当前DOM (针对页面刚打开时已经存在的数据)
function scanInitialDOM() {
const cards = document.querySelectorAll("section.note-item, .feed-card");
let count = 0;
cards.forEach((card) => {
// 尝试获取ID和链接
// fix: 优先找 a.cover,因为它通常包含带 xsec_token 的完整链接,且覆盖在卡片上
// 其次才是普通的 explore 链接
let linkEl =
card.querySelector("a.cover") ||
card.querySelector('a[href*="/explore/"]') ||
card.querySelector('a[href*="/user/profile/"]');
let href = "";
if (linkEl) href = linkEl.href; // .href 获取的是绝对路径
if (href) {
// 提取 ID
// 匹配逻辑:获取 URL path 的最后一段作为 ID
// 例如: /explore/66... 或 /user/profile/xxx/66...
let id = "";
try {
const urlObj = new URL(href);
const pathParts = urlObj.pathname.split("/").filter((p) => p);
// 假设最后一部分是ID (通常是24位ObjectId)
const lastPart = pathParts[pathParts.length - 1];
// 简单的校验:ID通常由字母数字组成,长度24位左右
if (lastPart && /^[a-fA-F0-9]{24}$/.test(lastPart)) {
id = lastPart;
} else if (href.includes("/explore/")) {
// 兼容旧的 explore 提取方式
const m = href.match(/\/explore\/(\w+)/);
if (m) id = m[1];
}
} catch (e) {}
if (id && !GLOBAL_DATA.has(id)) {
const title =
(
card.querySelector(".title span") ||
card.querySelector(".title") ||
{}
).innerText || "未获取";
const author =
(card.querySelector(".author") || {}).innerText || "未获取";
// 尝试获取封面
let coverUrl = "";
const coverDiv = card.querySelector(".cover");
if (coverDiv) {
const style = coverDiv.getAttribute("style");
const bgMatch = style && style.match(/url\("?(.+?)"?\)/);
if (bgMatch) coverUrl = bgMatch[1];
}
if (!coverUrl) {
const img = card.querySelector("img");
if (img) coverUrl = img.src;
}
GLOBAL_DATA.set(id, {
笔记ID: id,
标题: title,
链接: href, // 使用从 DOM 获取的完整 href(包含 token)
作者: author,
点赞数: (card.querySelector(".count") || {}).innerText || "0",
封面图: coverUrl,
类型: "dom_scan",
});
count++;
}
}
});
if (count > 0) updateCountUI();
console.log(`[XHS助手] 初始DOM扫描发现 ${count} 条数据`);
}
// 自动滚动逻辑
let scrollInterval;
function toggleAutoScroll() {
const btn = document.getElementById("auto-scroll-btn");
const status = document.getElementById("scroll-status");
if (isAutoScrolling) {
// 停止
isAutoScrolling = false;
clearInterval(scrollInterval);
btn.innerText = "⏬ 自动滚动加载全部";
btn.classList.remove("scrolling");
status.innerText = "已停止";
} else {
// 开始
isAutoScrolling = true;
btn.innerText = "⏹️ 停止滚动 (抓取中...)";
btn.classList.add("scrolling");
status.innerText = "滚动中...";
let lastHeight = 0;
let sameHeightCount = 0;
scrollInterval = setInterval(() => {
window.scrollTo(0, document.body.scrollHeight);
const currentHeight = document.body.scrollHeight;
if (currentHeight === lastHeight) {
sameHeightCount++;
if (sameHeightCount > 10) {
// 连续10次高度不变(约10-15秒),认为到底了
toggleAutoScroll(); // 自动停止
alert(
`滚动结束!共捕获 ${GLOBAL_DATA.size} 条数据。\n请点击导出。`,
);
}
} else {
sameHeightCount = 0;
lastHeight = currentHeight;
}
}, 1200); // 间隔1.2秒滚动一次,给接口加载留时间
}
}
// ==========================================
// 新增核心功能:ExcelJS 导出与图片嵌入
// ==========================================
function fetchImageBuffer(url, timeoutMs = 8000) {
return new Promise((resolve) => {
if (!url || !url.startsWith("http")) { resolve(null); return; }
const safeUrl = url.startsWith("http:") ? "https" + url.slice(4) : url;
let done = false;
GM_xmlhttpRequest({
method: "GET",
url: safeUrl,
responseType: "blob",
timeout: timeoutMs,
onload: (res) => {
if (done) return;
done = true;
if ((res.status === 200 || res.status === 206) && res.response) {
resolve(res.response);
} else {
resolve(null);
}
},
onerror: () => { if (!done) { done = true; resolve(null); } },
ontimeout: () => { if (!done) { done = true; resolve(null); } },
});
setTimeout(() => { if (!done) { done = true; resolve(null); } }, timeoutMs + 1000);
});
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader();
reader.onload = () => {
try {
const dataUrl = reader.result || "";
const idx = dataUrl.indexOf(",");
resolve(idx >= 0 ? dataUrl.slice(idx + 1) : "");
} catch (e) { reject(e); }
};
reader.onerror = () => reject(reader.error || new Error("FileReader error"));
reader.readAsDataURL(blob);
} catch (e) {
reject(e);
}
});
}
function base64ToUint8Array(b64) {
const bin = atob(b64);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
async function blobToUint8Array(blob) {
const b64 = await blobToBase64(blob);
return base64ToUint8Array(b64);
}
// ==========================================
// TinyZip - 手写 ZIP 编码器,专门用于绕开脚本猫沙箱中 JSZip 卡死的问题
// 实现 ZIP 规范 STORE (不压缩) 模式,足够覆盖图片/文本打包需求
// ==========================================
const TINYZIP_CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function tinyzipCrc32(buf) {
let crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
crc = TINYZIP_CRC_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
function createTinyZip() {
const entries = [];
const files = {};
const api = {
files,
file(path, data, options) {
let dataPromise;
try {
if (data == null) {
dataPromise = Promise.resolve(new Uint8Array(0));
} else if (typeof data === "string") {
if (options && options.base64) {
dataPromise = Promise.resolve(base64ToUint8Array(data));
} else {
dataPromise = Promise.resolve(new TextEncoder().encode(data));
}
} else if (data instanceof Uint8Array) {
dataPromise = Promise.resolve(data);
} else if (data instanceof ArrayBuffer) {
dataPromise = Promise.resolve(new Uint8Array(data));
} else if (data && typeof data.size === "number" && typeof data.arrayBuffer === "function") {
// Blob 或类 Blob,走 FileReader 安全路径
dataPromise = blobToUint8Array(data);
} else {
dataPromise = Promise.resolve(new TextEncoder().encode(String(data)));
}
} catch (e) {
console.warn("[TinyZip] file() 入参异常:", path, e);
dataPromise = Promise.resolve(new Uint8Array(0));
}
const entry = { name: path, dataPromise };
entries.push(entry);
files[path] = entry;
},
async generateAsync(options, onUpdate) {
const encoder = new TextEncoder();
const now = new Date();
const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | (Math.floor(now.getSeconds() / 2));
const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate();
const localParts = [];
const centralParts = [];
let offset = 0;
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
let bytes;
try {
bytes = await e.dataPromise;
if (!(bytes instanceof Uint8Array)) bytes = new Uint8Array(bytes || 0);
} catch (err) {
console.warn("[TinyZip] 读取条目失败, 用空数据替代:", e.name, err);
bytes = new Uint8Array(0);
}
const nameBytes = encoder.encode(e.name);
const crc = tinyzipCrc32(bytes);
const size = bytes.length;
const lfh = new Uint8Array(30 + nameBytes.length);
const lfhView = new DataView(lfh.buffer);
lfhView.setUint32(0, 0x04034b50, true);
lfhView.setUint16(4, 20, true);
lfhView.setUint16(6, 0x0800, true); // UTF-8 文件名标志
lfhView.setUint16(8, 0, true);
lfhView.setUint16(10, dosTime, true);
lfhView.setUint16(12, dosDate, true);
lfhView.setUint32(14, crc, true);
lfhView.setUint32(18, size, true);
lfhView.setUint32(22, size, true);
lfhView.setUint16(26, nameBytes.length, true);
lfhView.setUint16(28, 0, true);
lfh.set(nameBytes, 30);
localParts.push(lfh, bytes);
const cdfh = new Uint8Array(46 + nameBytes.length);
const cdView = new DataView(cdfh.buffer);
cdView.setUint32(0, 0x02014b50, true);
cdView.setUint16(4, 20, true);
cdView.setUint16(6, 20, true);
cdView.setUint16(8, 0x0800, true);
cdView.setUint16(10, 0, true);
cdView.setUint16(12, dosTime, true);
cdView.setUint16(14, dosDate, true);
cdView.setUint32(16, crc, true);
cdView.setUint32(20, size, true);
cdView.setUint32(24, size, true);
cdView.setUint16(28, nameBytes.length, true);
cdView.setUint16(30, 0, true);
cdView.setUint16(32, 0, true);
cdView.setUint16(34, 0, true);
cdView.setUint16(36, 0, true);
cdView.setUint32(38, 0, true);
cdView.setUint32(42, offset, true);
cdfh.set(nameBytes, 46);
centralParts.push(cdfh);
offset += lfh.length + bytes.length;
if (typeof onUpdate === "function") {
try { onUpdate({ percent: Math.round((i + 1) / entries.length * 100), currentFile: e.name }); }
catch (cbErr) { console.warn("[TinyZip] onUpdate 回调抛错:", cbErr); }
}
}
const cdSize = centralParts.reduce((s, p) => s + p.length, 0);
const cdOffset = offset;
const eocd = new Uint8Array(22);
const eocdView = new DataView(eocd.buffer);
eocdView.setUint32(0, 0x06054b50, true);
eocdView.setUint16(4, 0, true);
eocdView.setUint16(6, 0, true);
eocdView.setUint16(8, entries.length, true);
eocdView.setUint16(10, entries.length, true);
eocdView.setUint32(12, cdSize, true);
eocdView.setUint32(16, cdOffset, true);
eocdView.setUint16(20, 0, true);
const blob = new Blob([...localParts, ...centralParts, eocd], { type: "application/zip" });
const type = options && options.type;
if (type === "blob") return blob;
if (type === "arraybuffer") return await blob.arrayBuffer();
if (type === "uint8array") return new Uint8Array(await blob.arrayBuffer());
return blob;
},
};
return api;
}
function ensureJSZip() {
return new Promise((resolve, reject) => {
const existingJSZip = typeof JSZip !== "undefined" ? JSZip : window.JSZip;
if (existingJSZip) {
resolve(existingJSZip);
return;
}
const cdns = [
"https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js",
"https://unpkg.com/jszip@3.10.1/dist/jszip.min.js",
"https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js",
];
let index = 0;
const loadNext = () => {
if (index >= cdns.length) {
reject(new Error("JSZip 加载失败,请检查脚本管理器是否允许 @require/跨域请求"));
return;
}
GM_xmlhttpRequest({
method: "GET",
url: cdns[index++],
timeout: 15000,
onload: (res) => {
try {
if (res.status !== 200 || !res.responseText) {
loadNext();
return;
}
(0, eval)(res.responseText);
const loadedJSZip = typeof JSZip !== "undefined" ? JSZip : window.JSZip;
if (loadedJSZip) resolve(loadedJSZip);
else loadNext();
} catch (e) {
console.warn("[XHS助手] JSZip 备用加载失败", e);
loadNext();
}
},
onerror: loadNext,
ontimeout: loadNext,
});
};
loadNext();
});
}
async function exportAdvanced(dataList, selectedCols, splitSize = 200) {
const statusDiv = document.createElement("div");
statusDiv.id = "xhs-export-status";
statusDiv.style.cssText =
"position:fixed;top:20px;right:20px;background:rgba(0,0,0,0.85);color:#fff;padding:15px 20px;border-radius:8px;z-index:9999999;font-size:14px;box-shadow:0 10px 30px rgba(0,0,0,0.2);backdrop-filter:blur(5px);max-width:300px;";
document.body.appendChild(statusDiv);
const updateStatus = (msg) => {
if (statusDiv) statusDiv.innerHTML = msg;
};
const closeStatus = () => {
setTimeout(() => {
if (statusDiv) statusDiv.remove();
}, 3000);
};
try {
updateStatus("🚀 正在初始化数据...");
// 1. 数据分包
const chunks = [];
for (let i = 0; i < dataList.length; i += splitSize) {
chunks.push(dataList.slice(i, i + splitSize));
}
// 并发控制辅助函数
const processImagesInBatch = async (tasks, limit = 5) => {
const results = [];
const executing = [];
for (const task of tasks) {
const p = Promise.resolve().then(() => task());
results.push(p);
if (limit <= tasks.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.all(results);
};
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet("小红书数据");
// 设置列
sheet.columns = selectedCols.map((key) => {
let w = 25;
if (key === "封面图") w = 35; // 适配 250px 宽度
if (key === "链接" || key === "标题") w = 40;
if (key === "内容" || key === "描述") w = 60;
if (key === "笔记ID") w = 30;
return { header: key, key: key, width: w };
});
// 收集图片任务
const imageTasks = [];
// 填充数据文本
chunk.forEach((rowData) => {
const row = sheet.addRow(rowData);
const rowNum = row.number;
// 如果有封面图列,准备下载
if (selectedCols.includes("封面图")) {
const url = rowData["封面图"];
if (url) {
row.height = 250; // 适配 334px 高度 (approx 250 points)
// 记录任务
imageTasks.push(async () => {
const buffer = await fetchImageBuffer(url);
// 获取图片尺寸用于按比例缩放
let w = 0,
h = 0;
if (buffer) {
try {
const blob = new Blob([buffer]);
const u = URL.createObjectURL(blob);
const img = new Image();
await new Promise((resolve) => {
img.onload = () => {
w = img.naturalWidth;
h = img.naturalHeight;
resolve();
};
img.onerror = resolve;
img.src = u;
});
URL.revokeObjectURL(u);
} catch (e) {}
}
return {
buffer,
rowNum,
colIndex: selectedCols.indexOf("封面图"),
w,
h,
};
});
} else {
row.height = 20;
}
} else {
row.height = 20;
}
// 处理链接样式
if (selectedCols.includes("链接")) {
const idx = selectedCols.indexOf("链接");
const cell = row.getCell(idx + 1);
if (cell.value && String(cell.value).startsWith("http")) {
cell.value = { text: rowData["链接"], hyperlink: rowData["链接"] };
cell.font = { color: { argb: "FF0000FF" }, underline: true };
}
}
});
// 执行图片下载任务
if (imageTasks.length > 0) {
// 分批下载提示
const totalTasks = imageTasks.length;
updateStatus(
`📦 处理分包 ${i + 1}/${chunks.length}
🖼️ 正在下载并嵌入 ${totalTasks} 张图片 (并发5)...
请勿关闭页面`,
);
const results = await processImagesInBatch(imageTasks, 5);
// 嵌入图片
results.forEach((res) => {
if (res && res.buffer) {
const imgId = wb.addImage({
buffer: res.buffer,
extension: "png",
});
// 按比例缩放 (适配 334px 高度)
let finalW = 250;
let finalH = 334;
if (res.w && res.h) {
finalW = finalH * (res.w / res.h);
}
sheet.addImage(imgId, {
tl: { col: res.colIndex, row: res.rowNum - 1 }, // row is 0-based for image pos
ext: { width: finalW, height: finalH },
editAs: "oneCell",
});
// 清空该单元格文本
sheet.getRow(res.rowNum).getCell(res.colIndex + 1).value = "";
}
});
}
const buffer = await wb.xlsx.writeBuffer();
const fileName =
chunks.length > 1
? `xhs_data_part_${i + 1}.xlsx`
: `xhs_data_full.xlsx`;
updateStatus(`✅ 导出分包 ${i + 1}/${chunks.length},正在下载...`);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
// 如果是多包下载,稍作停顿,避免浏览器拦截或卡顿
if (chunks.length > 1) {
await new Promise((r) => setTimeout(r, 1000));
}
}
closeStatus();
} catch (e) {
console.error(e);
updateStatus("❌ 导出出错: " + e.message);
setTimeout(() => closeStatus(), 5000);
}
}
async function exportList(
dataList,
format,
baseName,
selectedCols,
splitSize = 999999,
) {
if (!dataList || dataList.length === 0) return;
// 默认全选
let headers = Object.keys(dataList[0]);
if (selectedCols && selectedCols.length > 0) {
headers = selectedCols;
}
// 分包处理
const chunks = [];
for (let i = 0; i < dataList.length; i += splitSize) {
chunks.push(dataList.slice(i, i + splitSize));
}
// 简易提示
const statusDiv = document.createElement("div");
if (chunks.length > 1) {
statusDiv.style.cssText =
"position:fixed;top:20px;right:20px;background:rgba(0,0,0,0.8);color:#fff;padding:15px;border-radius:8px;z-index:999999;";
document.body.appendChild(statusDiv);
}
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const currentBaseName =
chunks.length > 1 ? `${baseName}_part_${i + 1}` : baseName;
if (chunks.length > 1) {
statusDiv.innerHTML = `📦 正在导出 ${format.toUpperCase()} 分包 ${i + 1}/${chunks.length}...`;
}
let content = "";
let type = "";
let ext = "";
if (format === "json") {
const filteredList = chunk.map((row) => {
const newRow = {};
headers.forEach((h) => (newRow[h] = row[h]));
return newRow;
});
content = JSON.stringify(filteredList, null, 2);
ext = "json";
type = "application/json";
} else if (format === "md") {
content =
"# 小红书采集数据导出\n\n> 导出时间: " +
new Date().toLocaleString() +
"\n\n---\n\n";
chunk.forEach((row, index) => {
const rowIdx = i * splitSize + index + 1;
content += `### ${rowIdx}. ${row["标题"] || "无标题"}\n\n`;
content += `**作者**: ${row["作者"] || "未知"} | **点赞**: ${row["点赞数"] || 0} | [🔗 原文链接](${row["链接"]})\n\n`;
if (row["封面图"]) {
content += `\n\n`;
}
if (row["内容"] || row["描述"]) {
content += `**内容描述**:\n\n${(row["内容"] || row["描述"]).replace(/\n/g, "\n\n")}\n\n`;
}
content += `---\n\n`;
});
ext = "md";
type = "text/markdown;charset=utf-8";
} else if (format === "html") {
// HTML: 尝试下载图片并转 Base64 用于本地保存
if (chunks.length <= 1) {
// 如果仅一包但也需要处理图片,必须显示提示 (因为下载图片较慢)
statusDiv.style.cssText =
"position:fixed;top:20px;right:20px;background:rgba(0,0,0,0.8);color:#fff;padding:15px;border-radius:8px;z-index:999999;";
if (!statusDiv.parentNode) document.body.appendChild(statusDiv);
}
statusDiv.innerHTML = `📦 正在导出 HTML (Part ${i + 1}/${chunks.length})
🖼️ 正在下载并将图片转为Base64以实现本地保存...`;
// 并发控制函数
const processImagesInBatch = async (tasks, limit = 5) => {
const results = [];
const executing = [];
for (const task of tasks) {
const p = Promise.resolve().then(() => task());
results.push(p);
if (limit <= tasks.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.all(results);
};
const imageTasks = chunk.map((row, idx) => async () => {
const url = row["封面图"];
if (!url) return { index: idx, base64: "" };
try {
const buffer = await fetchImageBuffer(url);
if (buffer) {
const blob = new Blob([buffer]);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () =>
resolve({ index: idx, base64: reader.result });
reader.readAsDataURL(blob);
});
}
} catch (e) {
console.warn("Image fetch failed", e);
}
return { index: idx, base64: url }; // 失败则使用原链接
});
// 执行图片下载任务
const imageResults = await processImagesInBatch(imageTasks, 5);
const base64Map = {};
imageResults.forEach((r) => {
if (r) base64Map[r.index] = r.base64;
});
let cardsHtml = "";
chunk.forEach((row, idx) => {
const cover = base64Map[idx] || row["封面图"] || "";
const title = row["标题"] || "无标题";
const author = row["作者"] || "未知";
const likes = row["点赞数"] || "0";
const link = row["链接"] || "#";
const desc = row["内容"] || row["描述"] || "";
cardsHtml += `
`;
});
content = `
小红书数据导出 - Part ${i + 1}
${cardsHtml}
`;
ext = "html";
type = "text/html;charset=utf-8";
} else if (format === "xls") {
// Excel (HTML Table)
let html = `
`;
html += "";
headers.forEach(
(h) =>
(html += `| ${h} | `),
);
html += "
";
chunk.forEach((row) => {
html += "";
headers.forEach((h) => {
const val = row[h] || "";
if (h === "封面图" && val) {
html += ` | `;
} else if (h === "链接" && val) {
html += `${val} | `;
} else {
html += `${val} | `;
}
});
html += "
";
});
html += "
";
content = html;
ext = "xls";
type = "application/vnd.ms-excel";
} else {
// CSV
const csvBody = chunk
.map((row) =>
headers
.map((h) => {
let v = row[h] || "";
v = String(v).replace(/"/g, '""');
return `"${v}"`;
})
.join(","),
)
.join("\n");
content = "\ufeff" + headers.join(",") + "\n" + csvBody;
ext = "csv";
type = "text/csv;charset=utf-8";
}
download(content, `${currentBaseName}.${ext}`, type);
// 延时防拦截
if (chunks.length > 1) {
await new Promise((r) => setTimeout(r, 1000));
}
}
if (chunks.length > 1) {
statusDiv.innerHTML = "✅ 所有分包导出完成";
setTimeout(() => statusDiv.remove(), 2000);
}
}
function exportData() {
const dataList = getFilteredData();
if (dataList.length === 0) {
const minLikes = getMinLikesFilter();
if (minLikes > 0) {
alert(`点赞过滤后无数据(当前阈值 ≥ ${minLikes}),请降低阈值或关闭过滤`);
} else {
alert("数据为空!请先点击「自动滚动」或手动浏览页面。");
}
return;
}
const format = document.getElementById("export-format").value;
// 获取勾选的列
let selectedCols = [];
const exportContainer = document.getElementById("export-field-container");
if (exportContainer) {
// 新版:表格选择
const selectedItems = exportContainer.querySelectorAll(
".export-field-item.selected",
);
selectedCols = Array.from(selectedItems).map((el) =>
el.getAttribute("data-value"),
);
} else {
// ... fallback ...
const selectEl = document.getElementById("export-col-select");
if (selectEl) {
selectedCols = Array.from(selectEl.selectedOptions).map(
(opt) => opt.value,
);
} else {
const checks = document.querySelectorAll(".export-col-check");
if (checks.length > 0) {
selectedCols = Array.from(checks)
.filter((c) => c.checked)
.map((c) => c.value);
}
}
}
// 如果用户什么都没选,提醒一下(或者默认全选)
if (selectedCols.length === 0) {
alert("请至少选择一个导出字段!");
return;
}
// 统一获取分包设置
const enableSplit = document.getElementById("enable-split").checked;
const splitSizeInput =
parseInt(document.getElementById("split-size").value) || 200;
const splitSize = enableSplit ? splitSizeInput : 999999;
if (format === "xlsx_embed") {
exportAdvanced(dataList, selectedCols, splitSize);
} else {
exportList(dataList, format, "xhs_data_full", selectedCols, splitSize);
}
}
function download(content, name, type) {
const blob = new Blob([content], { type: type });
saveBlobFile(blob, name);
}
function saveBlobFile(blob, name) {
console.log("[XHS助手][SAVE] saveBlobFile 开始, name:", name, "blob.size:", blob ? blob.size : "null");
let url;
try {
url = URL.createObjectURL(blob);
console.log("[XHS助手][SAVE] createObjectURL 成功:", url.slice(0, 60));
} catch (e) {
console.error("[XHS助手][SAVE] createObjectURL 失败:", e);
showManualDownloadLink(null, name);
return;
}
const a = document.createElement("a");
a.href = url;
a.download = name;
a.style.display = "none";
document.body.appendChild(a);
console.log("[XHS助手][SAVE] 即将执行 a.click()...");
a.click();
console.log("[XHS助手][SAVE] a.click() 已执行");
document.body.removeChild(a);
showManualDownloadLink(url, name);
}
function showManualDownloadLink(url, name) {
try {
const st = document.getElementById("res-status");
const holder = st && st.parentElement;
if (!holder) return;
const old = document.getElementById("xhs-manual-download-link");
if (old) {
try { URL.revokeObjectURL(old.href); } catch (e) {}
old.remove();
}
const link = document.createElement("a");
link.id = "xhs-manual-download-link";
link.href = url;
link.download = name;
link.innerText = `如果没有自动下载,点这里手动保存:${name}`;
link.style.cssText = "display:block;margin-top:6px;text-align:center;font-size:12px;color:#1677ff;text-decoration:underline;word-break:break-all;";
holder.appendChild(link);
} catch (e) {
console.warn("[XHS助手] 创建手动下载链接失败", e);
}
}
async function saveBlobWithFilePicker(blob, name, handle) {
if (handle) {
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
return true;
}
return false;
}
async function buildZipBlob(zip, onUpdate) {
const fileCount = Object.keys(zip.files).length;
console.log("[XHS助手][ZIP] buildZipBlob 开始, 文件数:", fileCount);
let firstUpdateAt = null;
const safeOnUpdate = (meta) => {
try {
if (firstUpdateAt === null) {
firstUpdateAt = Date.now();
console.log("[XHS助手][ZIP] generateAsync 首次 onUpdate, percent:", meta.percent);
}
if (typeof onUpdate === "function") onUpdate(meta);
} catch (e) {
console.warn("[XHS助手][ZIP] onUpdate 回调抛错(已忽略):", e);
}
};
const generatePromise = zip.generateAsync(
{
type: "arraybuffer",
compression: "STORE",
mimeType: "application/zip",
platform: "DOS",
},
safeOnUpdate,
);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error(
"ZIP 生成超时 30s(如仍出现请刷新页面后重试)"
)), 30000)
);
const buffer = await Promise.race([generatePromise, timeoutPromise]);
if (!buffer || buffer.byteLength < 22) {
console.error("[XHS助手][ZIP] buffer 异常, byteLength:", buffer ? buffer.byteLength : "null");
throw new Error("生成的 ZIP 为空或不完整");
}
console.log("[XHS助手][ZIP] generateAsync 完成, byteLength:", buffer.byteLength);
const header = new Uint8Array(buffer, 0, Math.min(4, buffer.byteLength));
const isZip =
header[0] === 0x50 &&
header[1] === 0x4b &&
(header[2] === 0x03 || header[2] === 0x05 || header[2] === 0x07) &&
(header[3] === 0x04 || header[3] === 0x06 || header[3] === 0x08);
if (!isZip) {
const hexHeader = Array.from(header).map(b => b.toString(16).padStart(2, "0")).join(" ");
console.error("[XHS助手][ZIP] ZIP 头校验失败, 前4字节:", hexHeader);
throw new Error("ZIP 文件头校验失败,已阻止保存损坏文件");
}
return new Blob([buffer], { type: "application/zip" });
}
// ==========================================
// 6. 辅助功能 (拖拽等)
// ==========================================
function bindDrag(div) {
const handle = div.querySelector(".drag-handle");
const minimizedIcon = div.querySelector(".minimized-icon");
let isDragging = false,
didDrag = false,
startX,
startY,
initL,
initT;
function onMouseDown(e) {
// 拖拽手柄只在非最小化时响应;最小化图标在最小化时响应
isDragging = true;
didDrag = false;
startX = e.clientX;
startY = e.clientY;
const rect = div.getBoundingClientRect();
initL = rect.left;
initT = rect.top;
div.style.transition = "none";
document.body.style.userSelect = "none";
}
handle.addEventListener("mousedown", (e) => {
if (div.classList.contains("minimized")) return;
onMouseDown(e);
});
minimizedIcon.addEventListener("mousedown", (e) => {
if (!div.classList.contains("minimized")) return;
onMouseDown(e);
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// 移动超过 3px 才算拖拽
if (!didDrag && Math.abs(dx) < 3 && Math.abs(dy) < 3) return;
didDrag = true;
e.preventDefault();
let newLeft = initL + dx;
let newTop = initT + dy;
const maxLeft = window.innerWidth - 60;
const maxTop = window.innerHeight - 60;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
requestAnimationFrame(() => {
div.style.left = newLeft + "px";
div.style.top = newTop + "px";
div.style.right = "auto";
});
});
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
div.style.transition = "";
document.body.style.userSelect = "";
GM_setValue("pos", { l: div.style.left, t: div.style.top });
// 没拖拽且在最小化状态 → 点击展开
if (!didDrag && div.classList.contains("minimized")) {
div.classList.remove("minimized");
}
});
// 恢复位置时也检查边界
const pos = GM_getValue("pos");
if (pos) {
let savedLeft = parseInt(pos.l) || 0;
let savedTop = parseInt(pos.t) || 0;
savedLeft = Math.max(0, Math.min(savedLeft, window.innerWidth - 100));
savedTop = Math.max(0, Math.min(savedTop, window.innerHeight - 100));
div.style.left = savedLeft + "px";
div.style.top = savedTop + "px";
div.style.right = "auto";
}
}
// ==========================================
// 7. AI 逻辑 (保持原样)
// ==========================================
function refreshTemplates() {
const sel = document.getElementById("template-select");
sel.innerHTML = "";
templates.forEach((t) => {
const opt = document.createElement("option");
opt.value = t.id;
opt.innerText = t.name;
sel.appendChild(opt);
});
sel.onchange = () => {
const t = templates.find((x) => x.id === sel.value);
document.getElementById("input-1").placeholder =
t.placeholder1 || t.desc1;
document.getElementById("input-2").placeholder =
t.placeholder2 || t.desc2;
};
sel.onchange();
}
function refreshManageSelect(selectId) {
const sel = document.getElementById("template-manage-select");
if (!sel) return;
sel.innerHTML = "";
templates.forEach((t) => {
const opt = document.createElement("option");
opt.value = t.id;
opt.innerText = t.name;
sel.appendChild(opt);
});
if (selectId && templates.some((t) => t.id === selectId))
sel.value = selectId;
else if (templates[0]) sel.value = templates[0].id;
}
function fillTemplateForm(t) {
document.getElementById("template-manage-select").value = t.id;
document.getElementById("tpl-name").value = t.name || "";
document.getElementById("tpl-desc1").value = t.desc1 || "";
document.getElementById("tpl-desc2").value = t.desc2 || "";
document.getElementById("tpl-system").value = t.system || "";
document.getElementById("tpl-prompt").value = t.prompt || "";
document.getElementById("tpl-persona").value = t.persona || "";
}
function collectTemplateForm(id) {
return {
id,
name: document.getElementById("tpl-name").value || "未命名模版",
desc1: document.getElementById("tpl-desc1").value || "输入1",
desc2: document.getElementById("tpl-desc2").value || "输入2",
placeholder1: document.getElementById("tpl-desc1").value || "",
placeholder2: document.getElementById("tpl-desc2").value || "",
system: document.getElementById("tpl-system").value || "",
prompt:
document.getElementById("tpl-prompt").value ||
"请基于{{title}}和{{content}}生成创作内容。",
};
}
function toastManage(msg) {
const el = document.getElementById("template-manage-status");
if (el) {
el.innerText = msg;
setTimeout(() => {
if (el.innerText === msg) el.innerText = "";
}, 2000);
}
}
async function handlePersonaGenerate() {
const persona = document.getElementById("tpl-persona").value.trim();
const status = document.getElementById("template-manage-status");
if (!persona) return alert("请先填写模版人设/要求");
status.innerText = "AI 生成人设中...";
try {
const res = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: document.getElementById("api-base-url").value,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${document.getElementById("api-key").value}`,
},
data: JSON.stringify({
model: document.getElementById("api-model").value,
messages: [
{
role: "system",
content:
'你是提示词工程师,请根据用户的人设描述,返回JSON {"system":"...","prompt":"..."},prompt内使用 {{title}} 和 {{content}} 作为占位符。',
},
{
role: "user",
content: persona,
},
],
}),
onload: (r) => resolve(JSON.parse(r.responseText)),
onerror: reject,
});
});
const content = res.choices?.[0]?.message?.content || "";
let json = {};
try {
json = JSON.parse(content.replace(/```json|```/g, "").trim());
} catch (e) {
json = {};
}
if (json.system)
document.getElementById("tpl-system").value = json.system;
if (json.prompt)
document.getElementById("tpl-prompt").value = json.prompt;
toastManage("AI 已生成模版提示");
} catch (e) {
console.error(e);
status.innerText = "AI 生成失败";
alert("AI生成失败: " + e);
}
}
async function handleAI() {
const btn = document.getElementById("ai-gen-btn");
const status = document.getElementById("ai-status");
const tId = document.getElementById("template-select").value;
const v1 = document.getElementById("input-1").value;
const v2 = document.getElementById("input-2").value;
if (!v1 && !v2) return alert("请输入内容");
btn.disabled = true;
btn.innerText = "生成中...";
try {
const t = templates.find((x) => x.id === tId);
const prompt = t.prompt
.replace("{{title}}", v1)
.replace("{{content}}", v2);
const res = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: document.getElementById("api-base-url").value,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${document.getElementById("api-key").value}`,
},
data: JSON.stringify({
model: document.getElementById("api-model").value,
messages: [
{ role: "system", content: t.system },
{ role: "user", content: prompt },
],
}),
onload: (r) => resolve(JSON.parse(r.responseText)),
onerror: reject,
});
});
const content = res.choices[0].message.content;
let json = {};
try {
json = JSON.parse(content.replace(/```json|```/g, "").trim());
} catch (e) {
json = { title: "Error", content: content };
}
const titleInput = document.querySelector('input[placeholder*="标题"]');
if (titleInput) {
titleInput.value = json.title;
titleInput.dispatchEvent(new Event("input", { bubbles: true }));
}
const editor = document.getElementById("post-textarea");
if (editor) {
editor.innerText = json.content;
editor.dispatchEvent(new Event("input", { bubbles: true }));
} else {
navigator.clipboard.writeText(json.content);
alert("已复制到剪贴板");
}
status.innerText = "✅ 完成";
} catch (e) {
console.error(e);
status.innerText = "❌ 失败";
alert("API错误: " + e);
} finally {
btn.disabled = false;
btn.innerText = "✨ 生成文案";
}
}
// ==========================================
// 8. AI 分析与分类逻辑
// ==========================================
function readFileData(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
function parseUploadedData(text, type) {
if (type.includes("json")) {
return JSON.parse(text);
} else {
// Simple CSV Parser
const lines = text.split("\n").filter((l) => l.trim());
if (lines.length < 2) return [];
const headers = lines[0].split(","); // 简单分割,未处理复杂CSV
// 这里的CSV解析较为简陋,建议使用JSON上传
const result = [];
for (let i = 1; i < lines.length; i++) {
const row = lines[i].split(","); // 同样简单分割
if (row.length === headers.length) {
let obj = {};
headers.forEach((h, idx) => {
obj[h.replace(/"/g, "").trim()] = row[idx]
? row[idx].replace(/"/g, "").trim()
: "";
});
result.push(obj);
}
}
return result;
}
}
async function callAI(messages) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: document.getElementById("api-base-url").value,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${document.getElementById("api-key").value}`,
},
data: JSON.stringify({
model: document.getElementById("api-model").value,
messages: messages,
}),
onload: (r) => {
try {
resolve(JSON.parse(r.responseText));
} catch (e) {
reject(e);
}
},
onerror: reject,
});
});
}
async function handleAnalysisSummary() {
const fileInput = document.getElementById("analysis-file-input");
const resultDiv = document.getElementById("analysis-result");
const btn = document.getElementById("analysis-summary-btn");
if (!fileInput.files.length) return alert("请先上传文件");
const file = fileInput.files[0];
btn.disabled = true;
btn.innerText = "分析中...";
resultDiv.style.display = "block";
resultDiv.innerText = "正在读取文件并进行 AI 分析,请稍候...";
try {
const text = await readFileData(file);
let data = parseUploadedData(text, file.name);
if (!data || data.length === 0) throw new Error("解析数据失败或数据为空");
// 截取前 50 条数据,防止 Token 溢出
const sample = data.slice(0, 50).map((item) => ({
标题: item.标题 || item.display_title,
点赞: item.点赞数 || item.likes,
作者: item.作者 || item.user?.nickname,
}));
const prompt = `请分析以下小红书笔记数据(仅展示前${sample.length}条):\n${JSON.stringify(
sample,
)}\n\n请给出:\n1. 热门话题/关键词总结\n2. 高赞笔记的共同特点\n3. 内容创作建议`;
const res = await callAI([
{
role: "system",
content: "你是一个资深的小红书数据分析师,擅长洞察趋势。",
},
{ role: "user", content: prompt },
]);
const content = res.choices?.[0]?.message?.content || "AI 未返回内容";
resultDiv.innerText = content;
} catch (e) {
console.error(e);
resultDiv.innerText = "❌ 分析失败: " + e.message;
} finally {
btn.disabled = false;
btn.innerText = "🧠 生成总结分析";
}
}
async function handleAnalysisClassify() {
const fileInput = document.getElementById("analysis-file-input");
const statusDiv = document.getElementById("classify-status");
const btn = document.getElementById("analysis-classify-btn");
const tipDiv = document.getElementById("api-limit-tip");
const format = document.getElementById("analysis-export-format").value;
const categories = document
.getElementById("analysis-categories")
.value.trim();
if (!fileInput.files.length) return alert("请先上传文件");
const file = fileInput.files[0];
btn.disabled = true;
btn.innerText = "分类中...";
statusDiv.innerText = "正在读取文件...";
if (tipDiv) tipDiv.style.display = "none";
try {
const text = await readFileData(file);
let data = parseUploadedData(text, file.name);
if (!data || data.length === 0) throw new Error("解析数据失败或数据为空");
// 分批处理逻辑
const BATCH_SIZE = 20;
const totalItems = data.length;
const batches = Math.ceil(totalItems / BATCH_SIZE);
const classifiedMap = new Map();
// 自动生成的默认分类
const defaultCats = "教程,好物分享,情感,新闻,搞笑,其他";
const targetCats = categories || defaultCats;
for (let i = 0; i < batches; i++) {
const start = i * BATCH_SIZE;
const end = Math.min((i + 1) * BATCH_SIZE, totalItems);
const batchData = data.slice(start, end);
statusDiv.innerText = `正在分类第 ${i + 1}/${batches} 批数据 (${start + 1}-${end})...`;
// 构建请求数据,仅包含必要字段以节省 Token
const sample = batchData.map((item) => ({
id: item.笔记ID || item.note_id || item.id,
title: item.标题 || item.display_title,
desc: (item.desc || "").substring(0, 50),
}));
const prompt = `请将以下笔记归类到这些类别中:[${targetCats}]。\n输入数据:\n${JSON.stringify(
sample,
)}\n\n要求:返回 JSON 格式,数组包含对象 { "id": "...", "category": "..." }。`;
try {
const res = await callAI([
{
role: "system",
content:
"你是一个数据分类助手。只返回纯 JSON,不要 Markdown 格式。",
},
{ role: "user", content: prompt },
]);
let content = res.choices?.[0]?.message?.content || "[]";
content = content.replace(/```json|```/g, "").trim();
let batchResult = [];
try {
batchResult = JSON.parse(content);
} catch (e) {
console.warn(`Batch ${i + 1} JSON parse failed`, content);
}
if (Array.isArray(batchResult)) {
batchResult.forEach((item) => {
if (item && item.id) {
classifiedMap.set(String(item.id), item.category);
}
});
}
} catch (apiError) {
console.error(`Batch ${i + 1} API failed`, apiError);
if (tipDiv) tipDiv.style.display = "block";
}
// 简单延时,避免 QPS 限制
if (i < batches - 1) {
await new Promise((r) => setTimeout(r, 1000));
}
}
data.forEach((item) => {
const id = item.笔记ID || item.note_id || item.id;
if (id && classifiedMap.has(String(id))) {
item["智能分类"] = classifiedMap.get(String(id));
} else {
item["智能分类"] = "未分类/API限制";
}
});
const unclassifiedCount = data.filter(
(x) => x["智能分类"] === "未分类/API限制",
).length;
if (unclassifiedCount > 0 && tipDiv) {
tipDiv.innerHTML = `⚠️ 完成,但有 ${unclassifiedCount} 条数据未成功分类。
可能是API超时或额度限制,建议检查API设置。`;
tipDiv.style.display = "block";
}
// 导出新文件
exportList(data, format, "classified_xhs_data");
statusDiv.innerText = "✅ 分类完成!已自动下载新文件。";
} catch (e) {
console.error(e);
statusDiv.innerText = "❌ 分类失败: " + e.message;
} finally {
btn.disabled = false;
btn.innerText = "📂 智能分类并导出";
}
}
// ==========================================
// 9. 营销工具 - 批量评论抓取逻辑
// ==========================================
let COLLECTED_COMMENTS = [];
async function handleBatchCommentCrawl() {
const btn = document.getElementById("mkt-run-btn");
const progressArea = document.getElementById("mkt-progress-area");
const progressBar = document.getElementById("mkt-progress-bar");
const statusText = document.getElementById("mkt-status-text");
const resultArea = document.getElementById("mkt-result-area");
const resultCount = document.getElementById("mkt-result-count");
// Configs
const likeFilter =
parseInt(document.getElementById("mkt-filter-likes").value) || 0;
// const scope = document.getElementById('mkt-crawl-scope').value; // Currently only 'initial' supported
if (GLOBAL_DATA.size === 0)
return alert(
"当前没有捕获到任何笔记。\n请先进行搜索或浏览页面,确保“数据导出”面板有数据。",
);
btn.disabled = true;
progressArea.style.display = "block";
resultArea.style.display = "none";
COLLECTED_COMMENTS = [];
const notes = Array.from(GLOBAL_DATA.values());
const total = notes.length;
let processed = 0;
let successCount = 0;
for (let i = 0; i < total; i++) {
const note = notes[i];
const percent = Math.round(((i + 1) / total) * 100);
progressBar.style.width = percent + "%";
statusText.innerText = `正在处理 (${i + 1}/${total}): ${note.标题.substring(0, 10)}...`;
try {
let noteId = note.笔记ID;
let xsecToken = "";
let targetUrl = note.链接 || "";
// 尝试从链接提取 token
if (targetUrl) {
const match = targetUrl.match(/xsec_token=([^&]+)/);
if (match) xsecToken = match[1];
}
// 如果笔记ID缺失,尝试从链接提取
if (!noteId && targetUrl) {
const idMatch = targetUrl.match(/\/explore\/([a-zA-Z0-9]+)/);
if (idMatch) noteId = idMatch[1];
}
if (noteId) {
let apiUrl = `https://edith.xiaohongshu.com/api/sns/web/v2/comment/page?note_id=${noteId}&cursor=&top_comment_id=&image_formats=jpg,webp,avif`;
if (xsecToken) {
apiUrl += `&xsec_token=${xsecToken}`;
}
const responseText = await new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
headers: {
"User-Agent": navigator.userAgent,
Referer: "https://www.xiaohongshu.com/",
Origin: "https://www.xiaohongshu.com",
},
onload: (res) => resolve(res.responseText),
onerror: () => resolve(null),
});
});
if (responseText) {
const json = JSON.parse(responseText);
// API 结构: data.comments [...]
if (
json.data &&
json.data.comments &&
Array.isArray(json.data.comments)
) {
const comments = json.data.comments;
const validComments = comments.filter((c) => {
const likes = parseInt(c.like_count) || 0;
return likes >= likeFilter;
});
validComments.forEach((c) => {
const u = c.user_info || c.user || {};
COLLECTED_COMMENTS.push({
noteId: noteId,
noteTitle: note.标题,
noteLink: targetUrl,
commentContent: c.content,
commentLikes: c.like_count || 0,
commentTime: c.create_time || 0,
userNickname: u.nickname || "未知",
userId: u.user_id || u.id || "",
userImage: u.image || "",
});
});
successCount++;
}
}
}
} catch (e) {
console.warn(`[MKTool] Failed to fetch note ${note.笔记ID}`, e);
}
// Random delay to avoid block (500ms - 1000ms) - API 请求频率限制
await new Promise((r) => setTimeout(r, 500 + Math.random() * 500));
processed++;
}
statusText.innerText = `✅ 完成!成功处理 ${successCount} 篇笔记,共提取 ${COLLECTED_COMMENTS.length} 条评论`;
resultCount.innerText = COLLECTED_COMMENTS.length;
resultArea.style.display = "block";
btn.disabled = false;
}
function exportCommentData() {
if (COLLECTED_COMMENTS.length === 0) return alert("没有可导出的评论数据");
// Excel Format
let html = `
| 笔记ID |
笔记标题 |
笔记链接 |
评论内容 |
评论点赞 |
评论时间 |
用户昵称 |
用户ID |
`;
COLLECTED_COMMENTS.forEach((row) => {
html += `
| ${row.noteId} |
${row.noteTitle} |
链接 |
${row.commentContent} |
${row.commentLikes} |
${new Date(parseInt(row.commentTime)).toLocaleString()} |
${row.userNickname} |
${row.userId} |
`;
});
html += "
";
download(
html,
`xhs_comments_${new Date().getTime()}.xls`,
"application/vnd.ms-excel",
);
}
// ==========================================
// 5. 资源下载模块逻辑
// ==========================================
let CURRENT_NOTE_RAW = null;
let BATCH_NOTE_DATAS = [];
let RES_COMMENTS = [];
let BATCH_RESOURCE_MAP = {};
async function handleFetchResources() {
const input = document.getElementById("res-url-input");
const btn = document.getElementById("res-fetch-btn");
const resultArea = document.getElementById("res-result-area");
const grid = document.getElementById("res-grid");
const countEl = document.getElementById("res-count");
let rawText = input.value.trim();
let urls = [];
if (!rawText) {
urls = [location.href];
} else {
urls = rawText.split('\n').map(u => u.trim()).filter(u => u.length > 0);
}
btn.disabled = true;
btn.innerText = `提取中 0/${urls.length}...`;
resultArea.style.display = "none";
grid.innerHTML = "";
CURRENT_NOTE_RAW = null;
BATCH_NOTE_DATAS = [];
RES_COMMENTS = [];
BATCH_RESOURCE_MAP = {};
let successCount = 0;
let totalUrls = urls.length;
for (let i = 0; i < totalUrls; i++) {
const targetUrl = urls[i];
btn.innerText = `提取中 ${i+1}/${totalUrls}...`;
try {
let noteData = null;
const html = await fetchHtml(targetUrl);
noteData = extractNoteFromHtml(html);
if (!noteData) {
const noteId = getNoteIdFromUrl(targetUrl);
noteData = await fetchNoteDetailViaApi(noteId);
}
if (!noteData) {
console.warn(`[XHS助手] 跳过无效链接: ${targetUrl}`);
continue;
}
if (
noteData.type === "video" &&
(targetUrl.includes(location.pathname) || targetUrl === location.href)
) {
const domVideo = await extractVideoFromDOM();
if (domVideo) {
if (!noteData.video) noteData.video = {};
noteData.video.dom_url = domVideo;
}
}
await new Promise((r) => setTimeout(r, 100));
BATCH_NOTE_DATAS.push(noteData);
if (successCount === 0) {
CURRENT_NOTE_RAW = noteData;
renderResourceGrid(noteData, false);
} else {
renderResourceGrid(noteData, true);
}
successCount++;
const noteId = getNoteIdFromUrl(targetUrl);
if (noteId) {
let xsecToken = "";
let xsecSource = "";
try {
const m = targetUrl.match(/xsec_token=([^&\s]+)/);
if (m) xsecToken = decodeURIComponent(m[1]);
const m2 = targetUrl.match(/xsec_source=([^&\s]+)/);
if (m2) xsecSource = decodeURIComponent(m2[1]);
// 默认 pc_feed,兼容大多数场景
if (!xsecSource) xsecSource = "pc_feed";
} catch (e) {}
console.log("[XHS助手][评论] 准备抓取, noteId:", noteId, "xsec_source:", xsecSource, "xsec_token长度:", xsecToken.length, "noteUrl:", targetUrl);
btn.innerText = `提取评论 ${i+1}/${totalUrls}...`;
const comments = await fetchCommentsForNote(noteId, xsecToken, xsecSource, targetUrl);
btn.innerText = `提取中 ${i+1}/${totalUrls}...`;
const pushOne = (c, parentId) => {
const u = c.user_info || c.user || {};
const imgs = c.pictures || c.pictures_list || c.image_list || [];
const imgUrl = (img) =>
img && (
img.url_default ||
img.url ||
(img.info_list && img.info_list[0] && img.info_list[0].url) ||
""
);
// 兼容 API 数据:API 用 target_comment.id / target_comment.user_info
let replyToId = c.reply_to_id || "";
let replyToUserId = c.reply_to_user_id || "";
let replyToNickname = c.reply_to_nickname || "";
if (!replyToId && c.target_comment) {
replyToId = c.target_comment.id || "";
if (!replyToUserId && c.target_comment.user_info) {
replyToUserId = c.target_comment.user_info.user_id || "";
}
if (!replyToNickname && c.target_comment.user_info) {
replyToNickname = c.target_comment.user_info.nickname || "";
}
}
RES_COMMENTS.push({
noteId: noteId,
noteLink: targetUrl,
commentId: c.id || "",
parentId: parentId || "",
userName: u.nickname || "未知",
userId: u.user_id || u.id || "",
content: c.content || "",
commentImages: imgs.map(imgUrl).filter(Boolean),
likes: parseInt(c.like_count) || 0,
time: c.create_time
? new Date(parseInt(c.create_time)).toLocaleString()
: (c.time_text || ""),
ipLocation: c.ip_location || "",
replyToNickname: replyToNickname,
replyToUserId: replyToUserId,
replyToCommentId: replyToId,
isAuthor: !!(c.target_comment ? false : (u.user_id && c.note_user_id && u.user_id === c.note_user_id)),
});
const subs = c.sub_comments || [];
subs.forEach((s) => pushOne(s, c.id));
};
comments.forEach((c) => pushOne(c, ""));
}
} catch (e) {
console.warn(`[XHS助手] 提取失败: ${targetUrl}`, e.message);
}
}
if (successCount === 0) {
alert("所有链接提取均失败,请确认链接有效");
} else {
resultArea.style.display = "block";
const commentArea = document.getElementById("res-comment-area");
const commentCount = document.getElementById("res-comment-count");
if (commentCount) commentCount.innerText = RES_COMMENTS.length;
if (commentArea) commentArea.style.display = RES_COMMENTS.length > 0 ? "block" : "none";
}
btn.disabled = false;
btn.innerText = "🔍 提取资源";
}
function fetchHtml(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
headers: {
"User-Agent": navigator.userAgent,
Referer: "https://www.xiaohongshu.com/",
},
onload: (res) => resolve(res.responseText),
onerror: (err) => reject(new Error("网络请求失败")),
});
});
}
function extractNoteFromHtml(html) {
// 尝试多种写法的 __INITIAL_STATE__,避免因前端调整导致解析失败
const candidates = [];
// 1) 直接对象形式 window.__INITIAL_STATE__ = {...}
const directMatch = html.match(
/__INITIAL_STATE__=({[\s\S]*?})\s*<\/script>/,
);
if (directMatch && directMatch[1]) {
candidates.push(directMatch[1]);
}
// 2) JSON.parse(decodeURIComponent("...")) 形式
const decodeMatch = html.match(
/__INITIAL_STATE__=JSON\.parse\(decodeURIComponent\("([^"]+)"\)\)/,
);
if (decodeMatch && decodeMatch[1]) {
try {
candidates.push(decodeURIComponent(decodeMatch[1]));
} catch (e) {
console.warn("decodeURIComponent 失败", e);
}
}
// 逐个尝试解析
for (const raw of candidates) {
try {
const jsonStr = raw.replace(/undefined/g, "null");
const state = JSON.parse(jsonStr);
if (state && state.note && state.note.noteDetailMap) {
const keys = Object.keys(state.note.noteDetailMap);
if (keys.length > 0) return state.note.noteDetailMap[keys[0]];
}
if (state && state.note && state.note.firstNoteId) {
return state.note.noteDetailMap[state.note.firstNoteId];
}
} catch (e) {
console.warn("__INITIAL_STATE__ 解析失败,尝试下一个候选", e);
}
}
return null;
}
function getNoteIdFromUrl(url) {
const match = url.match(/explore\/([a-zA-Z0-9_-]+)/);
return match ? match[1] : null;
}
async function fetchNoteDetailViaApi(noteId) {
if (!noteId) return null;
// 使用 web feed 接口兜底(需登录态,但与页面一致,成功率更高)
const apiUrl = `https://edith.xiaohongshu.com/api/sns/web/v1/feed?source=explore_note¬e_id=${noteId}`;
try {
const res = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
headers: {
"Content-Type": "application/json",
Referer: "https://www.xiaohongshu.com/",
"User-Agent": navigator.userAgent,
},
onload: (r) => {
try {
resolve(JSON.parse(r.responseText));
} catch (e) {
reject(e);
}
},
onerror: () => reject(new Error("接口请求失败")),
});
});
if (res && res.data && res.data.items && res.data.items[0]) {
const card = res.data.items[0].note_card || res.data.items[0].note;
return card || null;
}
} catch (e) {
console.warn("接口兜底获取失败", e);
}
return null;
}
// 通过页面自身的 fetch 调用(会经过小红书的 sign 拦截器自动加签名头)
async function pageFetchJson(url) {
const ctx = (typeof unsafeWindow !== "undefined" && unsafeWindow.fetch)
? unsafeWindow
: (typeof window !== "undefined" ? window : self);
const fetcher = ctx.fetch;
if (typeof fetcher !== "function") throw new Error("page fetch 不可用");
const res = await fetcher.call(ctx, url, {
credentials: "include",
headers: { Accept: "application/json, text/plain, */*" },
});
if (!res || !res.ok) throw new Error("HTTP " + (res && res.status));
return await res.json();
}
// 兜底:GM_xmlhttpRequest(不带签名,多数情况会被服务器拒,但有的浏览器/cookie 状态可用)
function gmFetchJson(url, timeoutMs = 15000) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: url,
timeout: timeoutMs,
headers: {
"User-Agent": navigator.userAgent,
Referer: "https://www.xiaohongshu.com/",
Origin: "https://www.xiaohongshu.com",
Accept: "application/json, text/plain, */*",
},
onload: (r) => {
try { resolve(JSON.parse(r.responseText)); }
catch (e) { reject(e); }
},
onerror: () => reject(new Error("GM_xmlhttpRequest error")),
ontimeout: () => reject(new Error("GM_xmlhttpRequest timeout")),
});
});
}
async function fetchJsonAny(url) {
try {
return await pageFetchJson(url);
} catch (e1) {
try {
return await gmFetchJson(url);
} catch (e2) {
throw new Error(`pageFetch:${e1.message}; gm:${e2.message}`);
}
}
}
// 抓取一条主评论的全部子评论(包含 sub_comment_cursor 之后的)
async function fetchAllSubComments(noteId, rootCommentId, initialCursor, xsecToken, xsecSource) {
const all = [];
let cursor = initialCursor || "";
let page = 0;
const MAX = 10; // 最多 10 页 100 条子评论
while (page < MAX) {
page++;
const params = new URLSearchParams({
note_id: noteId,
root_comment_id: rootCommentId,
num: "10",
cursor: cursor,
image_formats: "jpg,webp,avif",
});
if (xsecToken) params.set("xsec_token", xsecToken);
if (xsecSource) params.set("xsec_source", xsecSource);
// 两个可能的路径都试一下(不同版本路径不同)
const urls = [
`https://edith.xiaohongshu.com/api/sns/web/v2/comment/sub_page?${params}`,
`https://edith.xiaohongshu.com/api/sns/web/v2/comment/sub/page?${params}`,
];
let json = null;
for (const u of urls) {
try { json = await fetchJsonAny(u); break; } catch (e) { /* try next */ }
}
if (!json || !json.data) break;
const list = json.data.comments || [];
all.push(...list);
if (!json.data.has_more || !json.data.cursor) break;
cursor = json.data.cursor;
await new Promise(r => setTimeout(r, 200));
}
return all;
}
// 读取页面上的"共 N 条评论"总数
function readCommentTotalFromDOM(doc) {
const d = doc || document;
const totalEl = d.querySelector(".comments-container .total, .comments-el .total");
if (!totalEl) return 0;
const m = (totalEl.textContent || "").match(/(\d+)/);
return m ? parseInt(m[1]) : 0;
}
// 从页面 DOM 抓评论 (兜底方案 / 主路径)
// DOM 结构(小红书 web 2026 版):
// .parent-comment
// > .comment-item ← 主评论
// > .reply-container
// > .list-container
// > .comment-item.comment-item-sub ← 子评论
function extractCommentsFromDOM(doc) {
const d = doc || document;
const results = [];
const seen = new Set(); // 全局评论 ID 去重
function extractOne(itemEl, parentId) {
const commentId = (itemEl.id || "").replace(/^comment-/, "");
if (commentId && seen.has(commentId)) return null;
if (commentId) seen.add(commentId);
const inner = itemEl.querySelector(".comment-inner-container");
if (!inner) return null;
const nameEl = inner.querySelector(".author .name, .author a");
const userName = nameEl ? (nameEl.textContent || "").trim() : "";
const userId = nameEl ? (nameEl.getAttribute("data-user-id") || "") : "";
// 内容 + "回复给"提取(多重兜底,覆盖小红书各种 DOM 变体)
const contentEl = inner.querySelector(".content");
let content = "";
let replyToName = "";
let replyToUserId = "";
if (contentEl) {
// —— 策略 1:显式的 .nickname / .reply-to / .target-nickname / .at-name 节点
const nickEl = contentEl.querySelector(
".nickname, .reply-to, .target-nickname, .at-name, .reply-nickname"
);
if (nickEl) {
replyToName = (nickEl.textContent || "").trim().replace(/^@/, "").replace(/[::]\s*$/, "");
// 该节点可能本身是 a[href] 或包了 a[href]
const linkInNick = (nickEl.tagName === "A" ? nickEl : nickEl.querySelector("a[href*='/user/']"));
if (linkInNick) {
const m = (linkInNick.getAttribute("href") || "").match(/\/user\/profile\/([a-zA-Z0-9]+)/);
if (m) replyToUserId = m[1];
}
}
// —— 策略 2:内容里的第一个用户主页链接(小红书把"@xxx"渲染成 a[href*="/user/profile/"])
if (!replyToName) {
const userLink = contentEl.querySelector("a[href*='/user/profile/']");
if (userLink) {
replyToName = (userLink.textContent || "").trim().replace(/^@/, "");
const m = (userLink.getAttribute("href") || "").match(/\/user\/profile\/([a-zA-Z0-9]+)/);
if (m) replyToUserId = m[1];
}
}
// —— 提取正文 ——
const noteText = contentEl.querySelector(".note-text");
if (noteText) {
content = (noteText.textContent || "").replace(/\s+/g, " ").trim();
} else {
content = (contentEl.textContent || "").replace(/\s+/g, " ").trim();
}
// —— 策略 3:正文以 "回复 @xxx:" 开头,从文本里提取并剥离前缀
if (content) {
const m = content.match(/^回复\s*@?([^\s::][^::]{0,40}?)\s*[::]\s*/);
if (m) {
if (!replyToName) replyToName = m[1].trim();
content = content.replace(m[0], "").trim();
}
}
}
// 时间和 IP
const dateContainer = inner.querySelector(".info .date");
let timeText = "";
let ipLocation = "";
if (dateContainer) {
dateContainer.querySelectorAll("span").forEach(s => {
if (s.classList.contains("location")) ipLocation = (s.textContent || "").trim();
else if (!timeText) timeText = (s.textContent || "").trim();
});
}
const likeEl = inner.querySelector(".interactions .like .count");
const likeText = likeEl ? (likeEl.textContent || "").trim() : "";
const likes = parseInt(likeText.replace(/\D/g, "")) || 0;
const replyEl = inner.querySelector(".interactions .reply .count");
const replyText = replyEl ? (replyEl.textContent || "").trim() : "";
const replyCount = parseInt(replyText.replace(/\D/g, "")) || 0;
// 评论里的图片(不是头像、不是 emoji)
const imgs = [];
const pictureBox = inner.querySelector(".comment-picture");
if (pictureBox) {
pictureBox.querySelectorAll("img").forEach(img => {
const src = img.getAttribute("src") || "";
if (src && src.startsWith("http")) imgs.push(src);
});
}
if (!userName && !content) return null;
return {
id: commentId || ("dom_" + results.length),
user_info: { nickname: userName, user_id: userId },
content: content,
like_count: likes,
sub_comment_count: parentId ? 0 : replyCount,
create_time: 0,
time_text: timeText,
ip_location: ipLocation,
reply_to_nickname: replyToName,
reply_to_user_id: replyToUserId,
reply_to_id: "", // 下一步反查填充
pictures: imgs.map(u => ({ url: u })),
_parent_id_dom: parentId || "",
_from: "dom",
};
}
// 按 .parent-comment 精确分组
const parents = d.querySelectorAll(".parent-comment");
parents.forEach(parent => {
// 主评论:parent 下、非 .comment-item-sub 的 .comment-item
const mainItems = parent.querySelectorAll(":scope > .comment-item:not(.comment-item-sub)");
mainItems.forEach(mainItem => {
const main = extractOne(mainItem, "");
if (!main) return;
// 子评论:parent 下的 .reply-container 内 .comment-item-sub
const subs = parent.querySelectorAll(":scope > .reply-container .comment-item-sub");
const subList = [];
subs.forEach(sub => {
const s = extractOne(sub, main.id);
if (s) subList.push(s);
});
if (subList.length > 0) main.sub_comments = subList;
results.push(main);
});
});
// 兜底:旧版/不规则结构,平铺所有 .comment-item
if (results.length === 0) {
d.querySelectorAll(".comment-item").forEach(item => {
const isSub = item.classList.contains("comment-item-sub");
if (!isSub) {
const c = extractOne(item, "");
if (c) results.push(c);
}
});
}
// === 二次处理:通过 reply_to_nickname / reply_to_user_id 反查 reply_to_id ===
// 子评论之间的对话关系:每条子评论的 parentId 都指向顶层主评论,
// 但"它具体是回复哪一条子评论的"靠 reply_to_nickname 体现。
// 这里用 DOM 顺序(自然就是时间顺序)从后往前找最近的同名/同 userId 子评论。
results.forEach(main => {
const subs = main.sub_comments || [];
if (subs.length === 0) return;
subs.forEach((sub, idx) => {
if (!sub.reply_to_nickname && !sub.reply_to_user_id) return;
// 从当前位置往前找
for (let i = idx - 1; i >= 0; i--) {
const cand = subs[i];
const candNick = cand.user_info && cand.user_info.nickname;
const candUid = cand.user_info && cand.user_info.user_id;
// 优先 userId 匹配,否则 nickname 匹配
if (sub.reply_to_user_id && candUid && sub.reply_to_user_id === candUid) {
sub.reply_to_id = cand.id;
break;
}
if (sub.reply_to_nickname && candNick && sub.reply_to_nickname === candNick) {
sub.reply_to_id = cand.id;
// 若 reply_to_user_id 没填,顺手补
if (!sub.reply_to_user_id && candUid) sub.reply_to_user_id = candUid;
break;
}
}
// 没匹配上 = 回复的是顶层主评论本身(小红书有时不显示 "回复 @主评论作者"),
// 不强行回填 reply_to_id(保留为空,含义更清晰)
});
});
return results;
}
// 自动滚动评论区 + 自动点"展开 N 条回复",触发懒加载更多评论
async function autoLoadCommentsInPage(maxRounds = 30, win, maxMs = 30000) {
const w = win || window;
const d = w.document;
const tag = win ? "[评论DOM-iframe]" : "[评论DOM]";
const deadline = Date.now() + maxMs;
// 优先评论区自身的滚动容器(小红书 2026 版评论区是固定高度的滚动 div)
const findScrollers = () => {
const cands = [
d.querySelector(".comments-container"),
d.querySelector(".comments-el .list-container"),
d.querySelector(".comments-el"),
d.querySelector(".note-scroller"),
d.querySelector(".feed-comment-component"),
].filter(Boolean);
return cands;
};
const totalExpected = readCommentTotalFromDOM(d);
console.log(`[XHS助手]${tag} 共 ${totalExpected} 条评论(页面 .total 字样)`);
const countAll = () =>
d.querySelectorAll(".comment-item").length;
// 已经点过的按钮集合(按 DOM 节点引用做去重,避免反复点同一批按钮死循环)
const clickedBtns = new WeakSet();
let lastCount = countAll();
let stableRounds = 0;
let totalClicked = 0;
console.log(`[XHS助手]${tag} 自动加载前 .comment-item 数量: ${lastCount}`);
for (let i = 0; i < maxRounds; i++) {
// 1) 点击所有"展开 N 条回复" / "展开更多回复" 按钮(每个按钮只点 1 次)
const expandBtns = Array.from(d.querySelectorAll(".show-more")).filter(el => {
if (clickedBtns.has(el)) return false; // 已点过的跳过
const txt = (el.textContent || "").trim();
return /展开|更多/.test(txt) && !/收起/.test(txt);
});
let expandedThisRound = 0;
for (const btn of expandBtns) {
try {
btn.click();
clickedBtns.add(btn);
expandedThisRound++;
totalClicked++;
} catch (e) {}
await new Promise(r => setTimeout(r, 60));
}
// 2) 滚动各个可能的容器到底
findScrollers().forEach(s => {
try {
s.scrollTop = s.scrollHeight;
s.dispatchEvent(new Event("scroll", { bubbles: true }));
} catch (e) {}
});
try { w.scrollTo(0, d.body.scrollHeight); } catch (e) {}
await new Promise(r => setTimeout(r, 600));
const newCount = countAll();
console.log(`[XHS助手]${tag} 第${i + 1}轮: .comment-item=${newCount} (新增 ${newCount - lastCount}, 本轮点击 ${expandedThisRound}, 累计点击 ${totalClicked})`);
// 退出条件(任一满足即退出):
// - 连续 4 轮 .comment-item 数量没增加(无论按钮还在不在点)
// - 已加载 >= 页面声明的 .total
if (newCount === lastCount) {
stableRounds++;
if (stableRounds >= 4) {
console.log(`[XHS助手]${tag} 连续 ${stableRounds} 轮无新增, 退出`);
break;
}
} else {
stableRounds = 0;
}
if (totalExpected > 0 && newCount >= totalExpected) {
console.log(`[XHS助手]${tag} 已加载 ${newCount} >= 总计 ${totalExpected},结束滚动`);
break;
}
if (Date.now() > deadline) {
console.log(`[XHS助手]${tag} 已达耗时上限 ${maxMs}ms, 退出, 当前 ${newCount} 条`);
break;
}
lastCount = newCount;
}
console.log(`[XHS助手]${tag} 自动加载结束, 最终 .comment-item 数量: ${countAll()}, 总点击 ${totalClicked}`);
return countAll();
}
// 在隐藏 iframe 中加载笔记 URL,等评论 DOM 渲染后抓取(用于"非当前页"批量抓评论)
// 返回兼容 fetchCommentsForNote 内部使用的 main[] 结构(含 sub_comments)
async function loadCommentsViaIframe(noteUrl, timeoutMs = 60000) {
if (!noteUrl) {
console.warn("[XHS助手][评论iframe] 无 noteUrl,跳过");
return [];
}
console.log("[XHS助手][评论iframe] 开始加载:", noteUrl);
// 1) 创建隐藏 iframe(尺寸要足够大,避免懒加载因 viewport=0 不触发)
const iframe = document.createElement("iframe");
iframe.style.cssText = [
"position:fixed",
"right:0",
"bottom:0",
"width:1280px",
"height:900px",
"border:0",
"opacity:0",
"pointer-events:none",
"z-index:-1",
].join(";");
iframe.setAttribute("aria-hidden", "true");
iframe.setAttribute("referrerpolicy", "no-referrer-when-downgrade");
iframe.setAttribute("allow", "autoplay");
iframe.src = noteUrl;
document.body.appendChild(iframe);
const cleanup = () => {
try { iframe.remove(); } catch (e) {}
};
try {
// 2) 等待 load 事件 / 超时
await Promise.race([
new Promise(resolve => {
if (iframe.contentDocument && iframe.contentDocument.readyState === "complete") {
return resolve();
}
iframe.addEventListener("load", () => resolve(), { once: true });
}),
new Promise((_, rej) => setTimeout(() => rej(new Error("iframe load 超时")), Math.min(20000, timeoutMs))),
]);
// 3) 验证可访问性(同源)
let win = null;
try {
win = iframe.contentWindow;
// eslint-disable-next-line no-unused-expressions
win && win.document && win.document.body;
} catch (e) {
console.warn("[XHS助手][评论iframe] 跨源拒绝访问:", e.message);
return [];
}
if (!win || !win.document || !win.document.body) {
console.warn("[XHS助手][评论iframe] 无 contentDocument(可能被 X-Frame-Options 拒绝)");
return [];
}
// 4) 等待评论容器出现(SPA 初始化 + 评论懒加载初始)
const waitFor = async (selector, ms) => {
const start = Date.now();
while (Date.now() - start < ms) {
try {
if (win.document.querySelector(selector)) return true;
} catch (e) {}
await new Promise(r => setTimeout(r, 300));
}
return false;
};
const hasContainer = await waitFor(".comments-container, .comments-el, .comment-item", 15000);
if (!hasContainer) {
console.warn("[XHS助手][评论iframe] 15s 内未出现评论容器,放弃");
return [];
}
// 给 SPA 多一点初始化时间
await new Promise(r => setTimeout(r, 800));
const total = readCommentTotalFromDOM(win.document);
console.log(`[XHS助手][评论iframe] 容器已出现, 共 ${total} 条`);
// 5) 在 iframe 内自动滚动 + 展开
try {
await autoLoadCommentsInPage(30, win);
} catch (e) {
console.warn("[XHS助手][评论iframe] autoLoad 失败:", e.message);
}
// 6) 提取
const list = extractCommentsFromDOM(win.document);
const subTotal = list.reduce((s, c) => s + ((c.sub_comments && c.sub_comments.length) || 0), 0);
console.log(`[XHS助手][评论iframe] 完成, 主评论 ${list.length} 条, 子评论 ${subTotal} 条`);
return list;
} catch (e) {
console.warn("[XHS助手][评论iframe] 异常:", e.message);
return [];
} finally {
// 立即销毁,避免批量场景下 iframe 累积占用大量内存/CPU
cleanup();
}
}
async function fetchCommentsForNote(noteId, xsecToken, xsecSource, noteUrl) {
console.log("[XHS助手][评论] 开始抓取 noteId:", noteId, "xsec_source:", xsecSource);
const allMain = [];
// 第一步:主评论分页
let cursor = "";
let pageNum = 0;
const MAX_PAGES = 50;
while (pageNum < MAX_PAGES) {
pageNum++;
const params = new URLSearchParams({
note_id: noteId,
cursor: cursor,
top_comment_id: "",
image_formats: "jpg,webp,avif",
});
if (xsecToken) params.set("xsec_token", xsecToken);
if (xsecSource) params.set("xsec_source", xsecSource);
const apiUrl = `https://edith.xiaohongshu.com/api/sns/web/v2/comment/page?${params}`;
let json = null;
try {
json = await fetchJsonAny(apiUrl);
} catch (e) {
console.warn(`[XHS助手][评论] 第${pageNum}页抓取失败:`, e.message);
break;
}
if (pageNum === 1) {
// 首页响应留底,便于诊断
try {
const previewStr = JSON.stringify(json);
console.log(`[XHS助手][评论] 第1页完整响应预览(${previewStr.length}字):`, previewStr.slice(0, 600));
} catch (e) {}
}
if (!json || !json.data) {
console.warn(`[XHS助手][评论] 第${pageNum}页响应异常 code=${json && json.code} msg=${json && json.msg}:`, json);
break;
}
const pageComments = json.data.comments || [];
allMain.push(...pageComments);
console.log(`[XHS助手][评论] 第${pageNum}页拿到 ${pageComments.length} 条, 累计 ${allMain.length} | has_more=${json.data.has_more} | code=${json.code}`);
if (!json.data.has_more || !json.data.cursor || pageComments.length === 0) break;
cursor = json.data.cursor;
await new Promise(r => setTimeout(r, 300));
}
// 第二步:每条主评论的子评论补全
for (let i = 0; i < allMain.length; i++) {
const main = allMain[i];
const totalSub = parseInt(main.sub_comment_count) || 0;
const currentSub = (main.sub_comments && main.sub_comments.length) || 0;
if (totalSub > currentSub) {
try {
const subs = await fetchAllSubComments(
noteId, main.id, main.sub_comment_cursor || "", xsecToken, xsecSource,
);
if (subs.length > 0) {
const seen = new Set((main.sub_comments || []).map(s => s.id));
const merged = [...(main.sub_comments || [])];
for (const s of subs) if (s && s.id && !seen.has(s.id)) merged.push(s);
main.sub_comments = merged;
console.log(`[XHS助手][评论] 主评论 ${main.id} 子评论补全到 ${merged.length}/${totalSub}`);
}
} catch (e) {
console.warn(`[XHS助手][评论] 主评论 ${main.id} 子评论抓取失败:`, e);
}
}
}
// 第三步:合并拦截器缓存(用户浏览过程中页面已加载的评论)
const cached = CACHED_COMMENTS_MAP.get(noteId) || [];
if (cached.length > 0) {
const idsInResult = new Set(allMain.map(c => c.id));
let merged = 0;
for (const c of cached) {
if (c && c.id && !idsInResult.has(c.id)) {
allMain.push(c);
merged++;
}
}
if (merged > 0) console.log(`[XHS助手][评论] 从拦截器缓存合并 ${merged} 条`);
// 同步主评论的子评论缓存
for (const main of allMain) {
const cachedMain = cached.find(c => c && c.id === main.id);
if (cachedMain && Array.isArray(cachedMain.sub_comments)) {
const seen = new Set((main.sub_comments || []).map(s => s.id));
const list = main.sub_comments || [];
for (const s of cachedMain.sub_comments) {
if (s && s.id && !seen.has(s.id)) list.push(s);
}
main.sub_comments = list;
}
}
}
// 同时把抓取结果回写到缓存供后续复用
CACHED_COMMENTS_MAP.set(noteId, allMain);
// ===== DOM 兜底:API 拿不到时(0 条或远少于实际) =====
// 判断"是否当前正在浏览这条笔记":
// - 当前 URL 含同样的 noteId -> 直接抓当前页 DOM
// - 否则 -> 起隐藏 iframe 加载该笔记 URL,再抓 iframe DOM
const currentPageMatches = location.href.includes(noteId);
const domTotal = currentPageMatches ? readCommentTotalFromDOM() : 0;
const hasCommentsDOM = currentPageMatches && document.querySelectorAll(".comment-item").length > 0;
// 触发兜底的条件:API 拿到 0 条(绝大多数情况就是被风控)
// 或 API 数量明显少于页面总数(防错过)
const apiInsufficient =
allMain.length === 0 ||
(currentPageMatches && domTotal > 0 && allMain.length < Math.min(domTotal, 5));
if (apiInsufficient) {
let domList = [];
if (currentPageMatches && hasCommentsDOM) {
// —— 路径 A:当前页 DOM 兜底 ——
console.log(`[XHS助手][评论] 启用当前页 DOM 兜底(API=${allMain.length}, DOM总计=${domTotal})`);
try {
await autoLoadCommentsInPage(30);
} catch (e) {
console.warn("[XHS助手][评论DOM] autoLoad 失败:", e);
}
domList = extractCommentsFromDOM();
} else if (noteUrl) {
// —— 路径 B:跨页 iframe 兜底(批量抓非当前笔记的评论) ——
console.log(`[XHS助手][评论] 启用 iframe 兜底(noteUrl=${noteUrl})`);
try {
domList = await loadCommentsViaIframe(noteUrl, 60000);
} catch (e) {
console.warn("[XHS助手][评论iframe] 异常:", e);
}
} else {
console.warn("[XHS助手][评论] API 返回 0 条且非当前页,又没有 noteUrl,无法兜底");
}
if (domList.length > 0) {
const subTotal = domList.reduce((s, c) => s + ((c.sub_comments && c.sub_comments.length) || 0), 0);
console.log(`[XHS助手][评论] 兜底成功: 主评论 ${domList.length} 条, 子评论 ${subTotal} 条`);
CACHED_COMMENTS_MAP.set(noteId, domList);
return domList;
}
}
console.log(`[XHS助手][评论] 完成, 主评论 ${allMain.length} 条, 子评论合计 ${allMain.reduce((s, c) => s + ((c.sub_comments && c.sub_comments.length) || 0), 0)} 条`);
return allMain;
}
function exportResComments() {
if (RES_COMMENTS.length === 0) return alert("没有可导出的评论数据");
let html = `
| 笔记ID | 评论ID | 父评论ID | 回复给评论ID | 用户 | 回复给 | 评论内容 | 评论图片 | 点赞 | 时间 | IP | 所属笔记链接 |
`;
RES_COMMENTS.forEach((c) => {
const imgCell = c.commentImages && c.commentImages.length > 0
? c.commentImages.map(img => `
`).join(" ")
: "";
html += `
| ${c.noteId} |
${c.commentId || ""} |
${c.parentId || ""} |
${c.replyToCommentId || ""} |
${c.userName} |
${c.replyToNickname || ""} |
${c.content} |
${imgCell} |
${c.likes} |
${c.time} |
${c.ipLocation || ""} |
链接 |
`;
});
html += "
";
download(html, `xhs_res_comments.xls`, "application/vnd.ms-excel");
const st = document.getElementById("res-comment-status");
if (st) st.innerText = `✅ 导出 ${RES_COMMENTS.length} 条评论`;
}
function exportAllNoteData() {
if (BATCH_NOTE_DATAS.length === 0 && RES_COMMENTS.length === 0) {
return alert("没有可导出的数据,请先提取笔记资源");
}
const output = [];
BATCH_NOTE_DATAS.forEach((data) => {
const note = data.note || data;
const noteId = note.noteId || note.id || "unknown";
const imageList = note.images_list || note.imageList || [];
const title = note.title || note.display_title || "";
const desc = note.desc || "";
const authorName = (note.user && (note.user.nickname || note.user.name)) || "";
const likes = note.likes || note.liked_count || note.like_count ||
(note.interact_info && note.interact_info.liked_count) || 0;
const type = note.type || "normal";
const item = {
笔记ID: noteId,
标题: title,
描述: desc,
作者: authorName,
点赞数: likes,
类型: type,
全部图片: imageList.map(img => img.url_default || img.url || "").filter(Boolean),
视频链接: "",
评论: [],
};
if (note.type === "video" && note.video) {
let videoUrl = "";
if (note.video.consumer && note.video.consumer.origin_video_key) {
videoUrl = `https://sns-video-bd.xhscdn.com/${note.video.consumer.origin_video_key}`;
} else if (CACHED_VIDEO_URL) {
videoUrl = CACHED_VIDEO_URL;
}
item.视频链接 = videoUrl;
}
const relatedComments = RES_COMMENTS.filter(c => c.noteId === noteId);
relatedComments.forEach(c => {
item.评论.push({
用户: c.userName,
内容: c.content,
评论图片: c.commentImages,
点赞: c.likes,
时间: c.time,
});
});
output.push(item);
});
const jsonStr = JSON.stringify(output, null, 2);
download(jsonStr, "xhs_full_data.json", "application/json");
const st = document.getElementById("res-status");
if (st) st.innerText = `✅ 导出 ${output.length} 条笔记(含全部图片链接+评论)`;
}
function sanitizeFileName(name) {
if (!name) return "未命名";
return name.replace(/[\\/:*?"<>|]/g, "_").substring(0, 60);
}
function exportResourceHtmlFallback(errorMessage) {
const rows = [];
BATCH_NOTE_DATAS.forEach((data, i) => {
const note = data.note || data;
const noteId = note.noteId || note.id || "unknown";
const title = note.title || note.display_title || noteId;
const resources = BATCH_RESOURCE_MAP[noteId] || [];
rows.push(`${i + 1}. ${escapeHtml(title)}
`);
rows.push(`${escapeHtml(JSON.stringify({
标题: title,
正文: note.desc || note.display_desc || "",
作者: (note.user && (note.user.nickname || note.user.name)) || "",
笔记ID: noteId,
}, null, 2))}`);
if (resources.length > 0) {
rows.push("");
}
});
const html = `小红书资源导出
小红书资源导出
ZIP 打包失败,已改为 HTML 链接包。失败原因:${escapeHtml(errorMessage || "未知")}
${rows.join("\n")}`;
download(html, `小红书资源链接_${new Date().toISOString().slice(0, 10)}.html`, "text/html;charset=utf-8");
}
async function exportAsFolder() {
if (BATCH_NOTE_DATAS.length === 0) {
return alert("没有可导出的笔记,请先提取资源");
}
const st = document.getElementById("res-status");
const btn = document.getElementById("res-export-zip-btn");
const fileName = `小红书笔记_${new Date().toISOString().slice(0, 10)}.zip`;
console.log("[XHS助手][ZIP-FLOW] exportAsFolder 开始, 笔记数:", BATCH_NOTE_DATAS.length,
"| BATCH_RESOURCE_MAP keys:", Object.keys(BATCH_RESOURCE_MAP).length,
"| RES_COMMENTS:", RES_COMMENTS.length);
btn.disabled = true;
btn.innerText = "准备中...";
if (st) st.innerText = "正在准备数据...";
await sleep(50);
try {
const zip = createTinyZip();
console.log("[XHS助手][ZIP-FLOW] 使用 TinyZip 编码器(已绕开 JSZip 沙箱卡死问题)");
const total = BATCH_NOTE_DATAS.length;
let processed = 0;
const imageTasks = [];
const allLinks = [];
const failedLinks = [];
for (let i = 0; i < total; i++) {
const data = BATCH_NOTE_DATAS[i];
const note = data.note || data;
const noteId = note.noteId || note.id || "unknown";
const title = note.title || note.display_title || noteId;
const desc = note.desc || note.display_desc || "";
const authorName = (note.user && (note.user.nickname || note.user.name)) || "";
// 兼容两套字段:__INITIAL_STATE__ 用 camelCase (interactInfo.likedCount)、
// API feed 用 snake_case (interact_info.liked_count),数值可能是字符串
const interactSnake = note.interact_info || {};
const interactCamel = note.interactInfo || {};
const toNum = (v) => {
if (v == null || v === "") return 0;
if (typeof v === "number") return v;
const s = String(v).replace(/,/g, "").trim();
if (/^\d+(\.\d+)?[wW]\+?$/.test(s)) return Math.round(parseFloat(s) * 10000);
if (/^\d+(\.\d+)?[kK]\+?$/.test(s)) return Math.round(parseFloat(s) * 1000);
const n = parseInt(s, 10);
return isNaN(n) ? 0 : n;
};
const likes = toNum(
note.likes || note.liked_count || note.like_count ||
interactSnake.liked_count || interactCamel.likedCount ||
note.likedCount
);
const collects = toNum(
interactSnake.collected_count || interactCamel.collectedCount ||
note.collected_count || note.collectedCount
);
const commentCount = toNum(
interactSnake.comment_count || interactCamel.commentCount ||
note.comment_count || note.commentCount
);
const shareCount = toNum(
interactSnake.share_count || interactCamel.shareCount ||
note.share_count || note.shareCount
);
if (i === 0) {
console.log("[XHS助手][ZIP-FLOW] 首篇笔记互动字段调试 ===");
console.log(" interact_info(snake):", interactSnake);
console.log(" interactInfo(camel):", interactCamel);
console.log(" 解析后 likes/collects/comments/shares:", likes, collects, commentCount, shareCount);
}
const folderName = `${i + 1}_${sanitizeFileName(title)}`;
const msg = `打包笔记 (${i + 1}/${total})`;
btn.innerText = msg;
if (st) st.innerText = `${msg}: ${title.substring(0, 20)}...`;
const noteType = note.type || (note.noteType) || "";
const publishTime = note.time || note.last_update_time || note.lastUpdateTime || note.create_time || note.createTime || "";
const publishTimeText = publishTime
? (typeof publishTime === "number"
? new Date(publishTime > 1e12 ? publishTime : publishTime * 1000).toLocaleString()
: String(publishTime))
: "";
const ipLocation = note.ip_location || note.ipLocation || "";
const contentJson = {
标题: title,
正文: desc,
作者: authorName,
点赞数: likes,
收藏数: collects,
评论数: commentCount,
分享数: shareCount,
笔记类型: noteType,
发布时间: publishTimeText,
IP归属: ipLocation,
笔记ID: noteId,
};
zip.file(`${folderName}/内容.json`, JSON.stringify(contentJson, null, 2));
const relatedComments = RES_COMMENTS.filter(c => c.noteId === noteId);
if (relatedComments.length > 0) {
const commentsData = relatedComments.map(c => ({
评论ID: c.commentId || "",
父评论ID: c.parentId || "",
回复给评论ID: c.replyToCommentId || "",
用户: c.userName,
用户ID: c.userId || "",
回复给: c.replyToNickname || "",
回复给用户ID: c.replyToUserId || "",
内容: c.content,
评论图片: c.commentImages,
点赞: c.likes,
时间: c.time,
IP归属: c.ipLocation || "",
}));
zip.file(`${folderName}/评论.json`, JSON.stringify(commentsData, null, 2));
}
// 复用 renderResourceGrid 已生成的资源(含无水印处理)
const resources = BATCH_RESOURCE_MAP[noteId] || [];
const imageResources = resources.filter(r => r.type === "image");
imageResources.forEach((r, j) => {
const imgName = j === 0 ? "封面图" : `内容图${j}`;
const ext = r.name.match(/\.(\w+)$/)?.[1] || "jpg";
imageTasks.push({ url: r.url, path: `${folderName}/图片/${imgName}.${ext}`, type: "image" });
allLinks.push(`${folderName}/图片/${imgName}.${ext}\n${r.url}`);
});
const videoResources = resources.filter(r => r.type === "video" || r.subType === "live_photo");
if (videoResources.length > 0) {
const videoText = videoResources
.map((r, idx) => `${idx + 1}. ${r.name}\n${r.url}`)
.join("\n\n");
zip.file(`${folderName}/视频下载链接.txt`, videoText);
videoResources.forEach((r) => {
allLinks.push(`${folderName}/视频/${r.name}\n${r.url}`);
});
}
processed++;
await sleep(10);
}
if (allLinks.length > 0) {
zip.file("全部资源链接.txt", allLinks.join("\n\n"));
}
const totalImages = imageTasks.length;
console.log("[XHS助手][ZIP-FLOW] 图片任务总数:", totalImages, "| zip 已有文件:", Object.keys(zip.files).length);
if (totalImages > 0) {
const concurrency = 2;
let completedImages = 0;
let failedImages = 0;
btn.innerText = `下载图片 0/${totalImages}`;
if (st) st.innerText = `正在下载图片 0/${totalImages}...(视频已写入链接,避免ZIP过大失败)`;
await sleep(50);
for (let start = 0; start < totalImages; start += concurrency) {
const batch = imageTasks.slice(start, start + concurrency);
console.log(`[XHS助手][ZIP-FLOW] 图片批次 ${start}-${start + batch.length - 1} 开始下载`);
await Promise.all(batch.map(async (task) => {
try {
const blob = await fetchImageBuffer(task.url, 20000);
if (blob) {
zip.file(task.path, blob);
} else {
zip.file(`${task.path}.下载失败.txt`, `资源下载失败,可手动打开链接:\n${task.url}`);
failedLinks.push(`${task.path}\n${task.url}`);
failedImages++;
}
} catch (e) {
console.warn(`[XHS助手] 下载失败: ${task.url}`, e);
zip.file(`${task.path}.下载失败.txt`, `资源下载失败,可手动打开链接:\n${task.url}\n\n错误:${e.message || e}`);
failedLinks.push(`${task.path}\n${task.url}\n错误:${e.message || e}`);
failedImages++;
}
completedImages++;
if (completedImages % concurrency === 0 || completedImages === totalImages) {
const pct = Math.round(completedImages / totalImages * 100);
btn.innerText = `下载图片 ${completedImages}/${totalImages}`;
if (st) st.innerText = `正在下载图片 ${completedImages}/${totalImages} (${pct}%)...`;
}
}));
console.log(`[XHS助手][ZIP-FLOW] 图片批次完成, 累计完成:${completedImages} 失败:${failedImages} zip文件数:${Object.keys(zip.files).length}`);
}
}
if (failedLinks.length > 0) {
zip.file("下载失败资源汇总.txt", failedLinks.join("\n\n"));
}
btn.innerText = "生成 ZIP...";
if (st) st.innerText = "正在生成 ZIP 文件...";
await sleep(50);
console.log("[XHS助手][ZIP-FLOW] 调用 buildZipBlob, zip 文件数:", Object.keys(zip.files).length);
const blob = await buildZipBlob(
zip,
(meta) => {
const pct = Math.round(meta.percent || 0);
btn.innerText = `生成 ZIP ${pct}%`;
if (st) st.innerText = `正在生成 ZIP 文件 ${pct}%...`;
},
);
console.log("[XHS助手][ZIP-FLOW] buildZipBlob 返回, blob.size:", blob ? blob.size : "null");
if (!blob || blob.size === 0) throw new Error("生成的 ZIP 为空");
console.log("[XHS助手][ZIP-FLOW] 调用 saveBlobFile, fileName:", fileName);
saveBlobFile(blob, fileName);
btn.innerText = "✅ 导出完成";
if (st) st.innerText = `✅ ZIP已生成!共 ${processed} 篇笔记,视频链接已写入包内;若未自动下载请点下方手动链接`;
setTimeout(() => {
btn.innerText = "📂 导出为文件夹 (ZIP)";
btn.disabled = false;
}, 3000);
} catch (e) {
console.error("[XHS助手][ZIP-FLOW] ZIP导出失败 ===");
console.error(" name:", e && e.name);
console.error(" message:", e && e.message);
console.error(" stack:", e && e.stack);
btn.innerText = "❌ 失败";
if (st) st.innerText = "❌ 导出失败: " + e.message;
try {
exportResourceHtmlFallback(e.message);
if (st) st.innerText = "ZIP失败,已改为导出HTML资源链接包";
} catch (fallbackError) {
console.error("[XHS助手] HTML兜底导出失败", fallbackError);
}
btn.disabled = false;
setTimeout(() => { btn.innerText = "📂 导出为文件夹 (ZIP)"; }, 3000);
}
}
// 参考视频解析demo.js: 尝试从 DOM 提取视频链接
function extractVideoFromDOM() {
return new Promise((resolve) => {
const video = document.querySelector("video");
if (!video) return resolve(null);
// 1. 检查 src
if (video.src && video.src.startsWith("http")) {
return resolve(video.src);
}
// 2. 检查 source 标签
const sources = video.querySelectorAll("source");
for (let s of sources) {
if (s.src && s.src.startsWith("http")) return resolve(s.src);
}
// 3. Demo 策略: 轮询 source (针对动态注入)
let checks = 0;
const timer = setInterval(() => {
checks++;
const dynamicSources = video.querySelectorAll("source");
for (let s of dynamicSources) {
if (s.src && s.src.startsWith("http")) {
clearInterval(timer);
resolve(s.src);
return;
}
}
if (checks > 20) {
// 2秒超时
clearInterval(timer);
resolve(null);
}
}, 100);
});
}
function renderResourceGrid(data, append = false) {
const note = data.note || data;
const grid = document.getElementById("res-grid");
const countEl = document.getElementById("res-count");
const qualityMode = document.getElementById("res-quality-select").value;
if (!append) grid.innerHTML = "";
// 渲染笔记文字信息
const noteId = note.noteId || note.id || "unknown";
const title = note.title || note.display_title || "";
const desc = note.desc || "";
const authorName = (note.user && (note.user.nickname || note.user.name)) || "";
const likes = note.likes || note.liked_count || note.like_count ||
(note.interact_info && note.interact_info.liked_count) ||
(note.interactInfo && note.interactInfo.likedCount) ||
note.likedCount || 0;
if (title || authorName) {
const infoDiv = document.createElement("div");
infoDiv.style.cssText = "width:100%;padding:6px 4px 4px;font-size:12px;color:#333;border-bottom:1px solid #f0f0f0;margin-bottom:4px;grid-column:1/-1;";
let html = "";
if (title) html += `📌 ${escapeHtml(title)}
`;
if (desc) html += `📝 ${escapeHtml(desc.substring(0, 100))}
`;
if (authorName || likes) {
html += ``;
if (authorName) html += `👤 ${escapeHtml(authorName)}`;
if (likes) html += `❤️ ${likes}`;
html += `
`;
}
infoDiv.innerHTML = html;
grid.appendChild(infoDiv);
}
const resources = [];
// 常量定义 (参考 Demo)
const imageServer = [
"https://sns-img-hw.xhscdn.net/",
"https://sns-img-bd.xhscdn.com/",
"https://sns-img-qc.xhscdn.com/",
"https://ci.xiaohongshu.com/",
];
// 正则提取图片 Key
const keyReg = /(?<=\/)(spectrum\/)?[a-z0-9A-Z\-]+(?=!)/;
const videoServer = [
"https://sns-video-hw.xhscdn.com/",
"https://sns-video-bd.xhscdn.com/",
"https://sns-video-al.xhscdn.com/",
];
// 1. 视频处理
if (note.type === "video" && note.video) {
let videoUrl = "";
let coverUrl = "";
// 兼容 imageList / images_list
const imgs = note.images_list || note.imageList || [];
if (imgs.length > 0 && imgs[0])
coverUrl = imgs[0].url || imgs[0].url_default || "";
// 【新增】策略 Special: 优先使用捕获到的 Stream 无水印 MP4
if (
CACHED_VIDEO_URL &&
CACHED_VIDEO_URL.includes("/stream/") &&
CACHED_VIDEO_URL.includes(".mp4")
) {
videoUrl = CACHED_VIDEO_URL;
console.log("[资源下载] ✅ 命中 Stream 无水印直链(VIP):", videoUrl);
}
// 策略 A: origin_video_key (构造无水印原片链接)
// 注意:原片(origin)往往是H.265编码,在部分Windows设备上可能只有声音无画面
if (
!videoUrl &&
note.video.consumer &&
note.video.consumer.origin_video_key
) {
// 如果用户明确想要“无水印”且接受可能的不兼容,可以使用 origin
// 即使有可能无画面,但只要是 "no_wm" 模式,我们应该优先保证无水印。
// 使用 https 协议,并默认使用 bd 节点
videoUrl = `${videoServer[1]}${note.video.consumer.origin_video_key}`;
}
// 策略 B: 优先使用缓存的真实视频URL (来自 XHR 拦截,无水印)
if (!videoUrl && CACHED_VIDEO_URL) {
videoUrl = CACHED_VIDEO_URL;
console.log(
"[资源下载] ✅ 使用XHR捕获的无水印视频:",
CACHED_VIDEO_URL.substring(0, 70),
);
}
// 策略 C: DOM 嗅探提取 (从页面