// ==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} 向下滑动页面,数据会自动增加。
📦 分包设置:
每包
笔记ID
链接
封面
标题
作者
点赞数
类型
已启用 API 拦截模式
导出的链接将包含 xsec_token,确保能在浏览器中直接访问,不会出现"笔记不存在"。
支持收藏夹、个人主页、搜索结果页。
📊 智能总结与分析
上传导出的 CSV/JSON,让 AI 分析趋势。
🏷️ 智能分类重构
AI 自动分类数据并生成新文件。
导出格式:
${ICONS.download} 笔记资源提取
从"数据导出"中复制笔记链接,粘贴到这里即可批量下载正文图片、无水印视频和全部评论,方便喂给 AI 分析训练爆款。
可提取内容:封面图 · 正文图片 · 无水印视频 · 笔记评论数据
${ICONS.gear} 全局 API 设置
此处的配置将应用于 AI 创作、分析和分类功能。
🛡️ 屏蔽登录弹窗
自动关闭烦人的登录提示,享受纯净浏览
${ICONS.link} 新标签页打开笔记
在首页/搜索页点击笔记卡片时,新标签页打开而非当前页跳转
${ICONS.mouse} 修复笔记点击跳转
在个人主页点击笔记时修复跳转,防止跳到下载页/App唤醒页
`; 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 += `![封面图](${row["封面图"]})\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 += `
${title}
${title}
👤 ${author}
${desc}
`; }); content = ` 小红书数据导出 - Part ${i + 1}

小红书采集数据 (Part ${i + 1}/${chunks.length}, 共${chunk.length}条)

导出时间: ${new Date().toLocaleString()}

${cardsHtml}
`; ext = "html"; type = "text/html;charset=utf-8"; } else if (format === "xls") { // Excel (HTML Table) let html = ` `; html += ""; headers.forEach( (h) => (html += ``), ); html += ""; chunk.forEach((row) => { html += ""; headers.forEach((h) => { const val = row[h] || ""; if (h === "封面图" && val) { html += ``; } else if (h === "链接" && val) { html += ``; } else { html += ``; } }); html += ""; }); html += "
${h}
${val}${val}
"; 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 = ` `; COLLECTED_COMMENTS.forEach((row) => { html += ``; }); html += "
笔记ID 笔记标题 笔记链接 评论内容 评论点赞 评论时间 用户昵称 用户ID
${row.noteId} ${row.noteTitle} 链接 ${row.commentContent} ${row.commentLikes} ${new Date(parseInt(row.commentTime)).toLocaleString()} ${row.userNickname} ${row.userId}
"; 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 = ` `; RES_COMMENTS.forEach((c) => { const imgCell = c.commentImages && c.commentImages.length > 0 ? c.commentImages.map(img => ``).join(" ") : ""; html += ``; }); html += "
笔记ID评论ID父评论ID回复给评论ID用户回复给评论内容评论图片点赞时间IP所属笔记链接
${c.noteId} ${c.commentId || ""} ${c.parentId || ""} ${c.replyToCommentId || ""} ${c.userName} ${c.replyToNickname || ""} ${c.content} ${imgCell} ${c.likes} ${c.time} ${c.ipLocation || ""} 链接
"; 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 嗅探提取 (从页面