// ==UserScript== // @name 小红书全能AI助手 // @namespace http://tampermonkey.net/ // @version 2.4 // @description 采用API拦截技术,支持自动滚动获取全部笔记,生成带xsec_token的永久有效链接,支持导出Excel/CSV/JSON。新增AI创作模块,内置多种写作模版,支持自定义模版和AI生成人设。提升创作效率,助力内容变现!新增excel带图片导出模式,方便直观查看封面图。新增资源下载功能,支持高清图片/视频批量下载。 // @author Coriander // @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 // @license MIT // ==/UserScript== (function () { "use strict"; // ========================================== // 0. 全局数据存储 (核心) // ========================================== const GLOBAL_DATA = new Map(); let isAutoScrolling = false; let currentPageUrl = location.href; // 记录当前页面URL // ========================================== // 0.1 页面切换监听 - 保持数据清洁性 // ========================================== 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(); // ========================================== // 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; return originalOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function (body) { 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("小红书助手: 解析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. 核心样式 // ========================================== const UI_CSS = ` @keyframes slideIn { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform: translateY(0); } } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(255, 36, 66, 0.4); } 70% { box-shadow: 0 0 0 10px rgba(255, 36, 66, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 36, 66, 0); } } #xhs-ai-helper { position: fixed; top: 100px; right: 20px; width: 380px; background: rgba(255, 255, 255, 0.98); box-shadow: 0 8px 32px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.05); border-radius: 16px; z-index: 999999; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; transition: all 0.3s; display: flex; flex-direction: column; color: #333; } .drag-handle { padding: 15px; cursor: move; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; user-select: none; } .ai-brand { font-weight: 800; font-size: 15px; display: flex; align-items: center; gap: 5px; color: #ff2442; } #xhs-ai-helper.minimized { width: 48px; height: 48px; border-radius: 50%; overflow: hidden; cursor: pointer; background: #ff2442; box-shadow: 0 4px 12px rgba(255, 36, 66, 0.4); } #xhs-ai-helper.minimized .minimized-icon { display: flex; width: 100%; height: 100%; align-items: center; justify-content: center; color: white; font-size: 24px; } #xhs-ai-helper.minimized .ai-main-wrapper { display: none; } /* 顶部 Tab 栏优化 - 可滚动 */ .ai-tabs { display: flex; gap: 6px; padding: 12px 12px 0; background: #fff; border-radius: 16px 16px 0 0; border-bottom: 1px solid #f0f0f0; user-select: none; overflow-x: auto; white-space: nowrap; /* 隐藏滚动条但保留功能 */ scrollbar-width: none; -ms-overflow-style: none; } .ai-tabs::-webkit-scrollbar { display: none; } .ai-tab-item { padding: 8px 12px; font-size: 13px; font-weight: 600; color: #666; cursor: pointer; border-radius: 8px 8px 0 0; transition: all 0.2s; position: relative; background: transparent; flex-shrink: 0; /* 防止子元素被压缩 */ } .ai-tab-item:hover { color: #333; background: #f8f8f8; } .ai-tab-item.active { color: #ff2442; background: #fff1f3; } .ai-tab-item.active::after { content: ''; position: absolute; bottom: -1px; left: 0; width: 100%; height: 2px; background: #ff2442; border-radius: 2px 2px 0 0; } .ai-content-body { padding: 15px; max-height: 70vh; overflow-y: auto; background: #fff; border-radius: 0 0 16px 16px; min-height: 200px; } .tab-panel { display: none; } .tab-panel.active { display: block; animation: slideIn 0.2s; } .ai-input, .ai-textarea, .ai-select { width: 100%; padding: 8px 10px; border: 1px solid #eee; border-radius: 8px; margin-bottom: 10px; box-sizing: border-box; background:#f9f9f9; } .ai-textarea { height: 80px; resize: vertical; } .ai-btn { width: 100%; padding: 10px; background: #ff2442; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; margin-top: 5px; } .ai-btn:hover { opacity: 0.9; } .ai-btn.secondary { background: #f0f0f0; color: #333; } .ai-btn.scrolling { background: #ff9800; animation: pulse 2s infinite; } .data-card { background: #f4f8ff; padding: 12px; border-radius: 8px; margin-bottom: 10px; border: 1px solid #e1eaff; } .export-tip { font-size: 12px; color: #666; margin-top: 10px; line-height: 1.5; background: #fffbe6; padding: 8px; border-radius: 6px; } /* AI分析面板样式优化 */ .file-upload-label { display: flex; align-items: center; justify-content: center; gap: 8px; background: #fff; border: 1px dashed #ccc; border-radius: 8px; padding: 15px; cursor: pointer; color: #666; font-size: 13px; transition: all 0.2s; margin-bottom: 5px; } .file-upload-label:hover { border-color: #ff2442; color: #ff2442; background: #fff5f6; } .analysis-result-box { margin-top: 10px; font-size: 13px; line-height: 1.6; background: #fff; padding: 12px; border-radius: 8px; border: 1px solid #eee; max-height: 250px; overflow-y: auto; color: #444; box-shadow: inset 0 2px 6px rgba(0,0,0,0.02); white-space: pre-wrap; display: none; } .ai-compact-box { background: #fff; padding: 10px; border-radius: 6px; border: 1px solid #ebd4b5; } /* 资源下载板块样式 */ .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: 1px solid #eee; cursor: pointer; } .res-item img, .res-item video { width: 100%; height: 100%; object-fit: cover; } .res-item .res-type { position: absolute; top: 2px; right: 2px; font-size: 10px; background: rgba(0,0,0,0.6); color: #fff; padding: 1px 4px; border-radius: 4px; } .res-item:hover::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.1); } .res-item.selected { border: 2px solid #ff2442; } .res-item .res-check { position: absolute; top: 2px; left: 2px; z-index: 2; width: 16px; height: 16px; background: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; opacity: 0.8; } .res-item.selected .res-check { background: #ff2442; color: #fff; opacity: 1; } /* 导出字段选择网格样式 */ .export-field-container { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; font-size: 11px; } .export-field-item { background: #fff; border: 1px solid #ddd; border-radius: 4px; padding: 6px 2px; text-align: center; cursor: pointer; user-select: none; transition: all 0.2s; color: #666; position: relative; } .export-field-item:hover { border-color: #ff2442; color: #ff2442; background: #fff5f6; } .export-field-item.selected { background: #ffeaea; border-color: #ff2442; color: #ff2442; font-weight: bold; } .export-field-item.selected::after { content: '✓'; position: absolute; top: -5px; right: -5px; font-size: 9px; background: #ff2442; color: white; width: 14px; height: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; border: 1px solid #fff; } /* 小屏幕/副屏适配 */ @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); // ========================================== // 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 = `
🔧
🔴 小红书助手
数据导出
AI创作
AI分析
资源下载
⚙️ 设置
当前已捕获:0 静止中
💡 提示:向下滑动页面,数据会自动增加。
笔记ID
链接
封面
标题
作者
点赞数
类型
已启用 API 拦截模式
导出的链接将包含 xsec_token,确保能在浏览器中直接访问,不会出现"笔记不存在"。
支持收藏夹、个人主页、搜索结果页。
📊 智能总结与分析
上传导出的 CSV/JSON,让 AI 分析趋势。
🏷️ 智能分类重构
AI 自动分类数据并生成新文件。
导出格式:
📥 笔记资源提取
输入笔记链接,或者在详情页直接使用。(暂时只支持无水印图片)
⚙️ 全局 API 设置
此处的配置将应用于 AI 创作、分析和分类功能。
`; document.body.appendChild(div); // 绑定事件 bindDrag(div); div.querySelector("#minimize-btn").onclick = () => div.classList.toggle("minimized"); div.querySelector(".minimized-icon").onclick = () => div.classList.toggle("minimized"); const tabs = div.querySelectorAll(".ai-tab-item"); 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"); }), ); // ========================== // 数据功能绑定 // ========================== 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; // 资源下载绑定 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 (CURRENT_NOTE_RAW) { renderResourceGrid(CURRENT_NOTE_RAW); } }; div.querySelector("#res-download-btn").onclick = handleBatchDownload; // AI功能绑定 // div.querySelector("#config-toggle").onclick = ... // Removed // ============================ // 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 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; } // 兜底策略:扫描当前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秒滚动一次,给接口加载留时间 } } function exportList(dataList, format, baseName, selectedCols) { if (!dataList || dataList.length === 0) return; // 默认全选 let headers = Object.keys(dataList[0]); // 如果有指定列,则强行使用指定列 (支持自定义字段顺序,且不依赖 dataList[0] 是否拥有该key) if (selectedCols && selectedCols.length > 0) { headers = selectedCols; } if (format === "json") { // JSON 也要过滤字段 const filteredList = dataList.map((row) => { const newRow = {}; headers.forEach((h) => (newRow[h] = row[h])); return newRow; }); download( JSON.stringify(filteredList, null, 2), `${baseName}.json`, "application/json", ); } else if (format === "xls") { // Excel (HTML Table 伪装) let html = ` `; // 表头 html += ""; headers.forEach( (h) => (html += ``), ); html += ""; // 内容 dataList.forEach((row) => { // 关键:给 tr 设置高度,确保能容纳图片 html += ""; headers.forEach((h) => { const val = row[h] || ""; if (h === "封面图" && val) { html += ``; } else if (h === "链接" && val) { html += ``; } else { html += ``; // 强制文本格式 } }); html += ""; }); html += "
${h}
点击跳转${val}
"; download(html, `${baseName}.xls`, "application/vnd.ms-excel"); } else { // CSV const csvBody = dataList .map((row) => headers .map((h) => { let v = row[h] || ""; v = String(v).replace(/"/g, '""'); return `"${v}"`; }) .join(","), ) .join("\n"); download( "\ufeff" + headers.join(",") + "\n" + csvBody, `${baseName}.csv`, "text/csv;charset=utf-8", ); } } function exportData() { if (GLOBAL_DATA.size === 0) { 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 dataList = Array.from(GLOBAL_DATA.values()); exportList(dataList, format, "xhs_data_full", selectedCols); } function download(content, name, type) { const blob = new Blob([content], { type: type }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // ========================================== // 6. 辅助功能 (拖拽等) // ========================================== function bindDrag(div) { const handle = div.querySelector(".drag-handle"); let isDragging = false, startX, startY, initL, initT; handle.addEventListener("mousedown", (e) => { // 允许在最小化状态下拖拽(可选,或者是用户可能只想点开) // 这里保持原逻辑如果用户想保持最小化不拖拽 if (div.classList.contains("minimized")) return; isDragging = true; 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"; }); document.addEventListener("mousemove", (e) => { if (isDragging) { e.preventDefault(); let newLeft = initL + e.clientX - startX; let newTop = initT + e.clientY - startY; // 边界保护:确保不会拖出屏幕 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) { isDragging = false; // 拖拽结束,恢复过渡动画和选择 div.style.transition = ""; document.body.style.userSelect = ""; GM_setValue("pos", { l: div.style.left, t: div.style.top }); } }); // 恢复位置时也检查边界 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 = "📂 智能分类并导出"; } } // ========================================== // 5. 资源下载模块逻辑 // ========================================== let CURRENT_NOTE_RAW = null; // 存储当前笔记原始数据,用于切换画质 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 url = input.value.trim(); if (!url) { url = location.href; } btn.disabled = true; btn.innerText = "提取中... (请稍候)"; resultArea.style.display = "none"; grid.innerHTML = ""; CURRENT_NOTE_RAW = null; try { let noteData = null; // 策略: 优先 fetch 页面源码,正则提取 state,这是最稳妥的方式(兼容性最好) // 直接读取 window 对象可能会因沙箱隔离失败 let targetUrl = url; // 处理短链或非详情页URL (略,假设用户输入正确详情页或在详情页操作) const html = await fetchHtml(targetUrl); noteData = extractNoteFromHtml(html); if (!noteData) throw new Error("无法提取笔记数据,请确认链接有效且为公开笔记"); CURRENT_NOTE_RAW = noteData; renderResourceGrid(noteData); resultArea.style.display = "block"; } catch (e) { alert("提取失败: " + e.message); console.error(e); } finally { 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) { // 核心提取逻辑: 寻找 window.__INITIAL_STATE__ const startMark = "window.__INITIAL_STATE__="; const idx = html.indexOf(startMark); if (idx === -1) return null; let endIdx = html.indexOf("", idx); if (endIdx === -1) endIdx = html.indexOf(";", idx); // 尝试不同闭合 if (endIdx === -1) return null; let jsonStr = html.substring(idx + startMark.length, endIdx); // 替换 undefined 为 null 保证 JSON parses jsonStr = jsonStr.replace(/undefined/g, "null"); try { const state = JSON.parse(jsonStr); // 路径通常为: note.noteDetailMap[id] -> note // 或者 direct note data 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.error("JSON State 解析失败", e); } return null; } function renderResourceGrid(data) { 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; // 'no_wm' or 'best_wm' grid.innerHTML = ""; // 清空旧数据 const resources = []; const noteId = note.noteId || note.id || "unknown"; // 常量定义 (参考 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\-]+(?=!)/; // 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 || ""; // 策略 A: origin_video_key (构造无水印原片链接) // 注意:原片(origin)往往是H.265编码,在部分Windows设备上可能只有声音无画面 if ( qualityMode === "no_wm" && note.video.consumer && note.video.consumer.origin_video_key ) { // 如果用户明确想要“无水印”且接受可能的不兼容,可以使用 origin // 即使有可能无画面,但只要是 "no_wm" 模式,我们应该优先保证无水印。 // 用户如果想要画面兼容性,可以选择 "best_wm" 或安装 HEVC 扩展。 videoUrl = `http://sns-video-bd.xhscdn.com/${note.video.consumer.origin_video_key}`; } // 策略 B: 从 media.stream 获取 (支持 h265/h264 master_url / masterUrl) // 兼容:不同 API 可能返回 snake_case 或 camelCase if (note.video.media && note.video.media.stream) { const stream = note.video.media.stream; let h264Url = ""; let h265Url = ""; const getUrl = (list) => { if (list && list.length > 0) { return list[0].master_url || list[0].masterUrl || ""; } return ""; }; h264Url = getUrl(stream.h264); h265Url = getUrl(stream.h265); // 选择逻辑: if (!videoUrl) { // 如果 origin (策略A) 没有找到链接 // 一般来说优先 H.264 以保证兼容性(但这往往带水印) // 如果用户选了 no_wm 但 origin 没找到,那也没办法,只能给一个能播的。 if (h264Url) { videoUrl = h264Url; } else if (h265Url) { videoUrl = h265Url; } } } // 策略 C: 再次尝试 origin_video_key (兜底) if ( !videoUrl && note.video.consumer && note.video.consumer.origin_video_key ) { videoUrl = `http://sns-video-bd.xhscdn.com/${note.video.consumer.origin_video_key}`; } if (videoUrl) { resources.push({ type: "video", url: videoUrl, cover: coverUrl, name: `video_${noteId}.mp4`, }); } } // 2. 图片处理 (含 Live Photo) const images = note.images_list || note.imageList || []; if (images && images.length > 0) { images.forEach((img, index) => { let targetUrl = img.url_default || img.url; // --- 图片无水印/高清优化 --- if (qualityMode === "no_wm") { try { // 尝试从 URL 提取 key 并拼接原图服务器 (Demo 方案) // 原 URL 通常类似 http://sns-webpic-qc.xhscdn.net/2024/.../...!... const keyMatch = targetUrl.match(keyReg); if (keyMatch) { targetUrl = imageServer[1] + keyMatch[0]; // 使用 sns-img-bd } else if (img.infoList && img.infoList.length > 0) { // 备用方案: 寻找 WB_DFT const noWmBest = img.infoList.find( (i) => i.imageScene === "WB_DFT", ); if (noWmBest && noWmBest.url) targetUrl = noWmBest.url; } } catch (e) { console.error("图片解析优化失败", e); } } else { // 此处维持原有的最高画质寻找逻辑 (通常 infoList 里会有水印大图) if (img.infoList && img.infoList.length > 0) { const best = img.infoList.find( (i) => i.imageScene === "CRD_WM_WEBP", ); // 往往最大 if (best && best.url) targetUrl = best.url; else { const largest = img.infoList.reduce( (p, c) => ((p.size || 0) > (c.size || 0) ? p : c), img.infoList[0], ); if (largest && largest.url) targetUrl = largest.url; } } } resources.push({ type: "image", url: targetUrl, cover: targetUrl, name: `image_${noteId}_${index + 1}.jpg`, }); // --- Live Photo (实况图) --- // 检查 stream 字段 (h264/h265 视频流) if (img.live_photo && img.stream) { // live_photo 可能是 boolean let liveUrl = ""; let h264Url = ""; let h265Url = ""; // 优先使用 H.264 (兼容性好),其次 H.265 // 兼容:同时尝试 master_url 和 masterUrl const getLiveUrl = (list) => { if (list && list.length > 0) { return list[0].master_url || list[0].masterUrl || ""; } return ""; }; h264Url = getLiveUrl(img.stream.h264); h265Url = getLiveUrl(img.stream.h265); liveUrl = h264Url || h265Url; if (liveUrl) { resources.push({ type: "video", subType: "live_photo", url: liveUrl, cover: targetUrl, name: `live_photo_${noteId}_${index + 1}.mp4`, }); } } }); } countEl.innerText = `(${resources.length} 个文件)`; resources.forEach((res, idx) => { const div = document.createElement("div"); div.className = "selected res-item"; // 默认选中 div.dataset.url = res.url; div.dataset.name = res.name; div.setAttribute("title", "点击选中/取消"); let inner = ""; if (res.type === "video") { const icon = res.subType === "live_photo" ? "📸" : "▶️"; const label = res.subType === "live_photo" ? "实况" : "视频"; inner = `
${label}
${icon}
`; } else { inner = `
图片
`; } div.innerHTML = `
${inner} `; div.onclick = (e) => { // 切换选中状态 if (div.classList.contains("selected")) { div.classList.remove("selected"); } else { div.classList.add("selected"); } }; grid.appendChild(div); }); } async function handleBatchDownload() { const items = document.querySelectorAll(".res-item.selected"); const btn = document.getElementById("res-download-btn"); const status = document.getElementById("res-status"); if (items.length === 0) return alert("请至少选择一个资源"); btn.disabled = true; const originalText = btn.innerText; btn.innerText = "下载中..."; status.innerText = "正在初始化下载..."; let success = 0; for (let i = 0; i < items.length; i++) { const url = items[i].dataset.url; const name = items[i].dataset.name; status.innerText = `正在下载 (${i + 1}/${items.length}) ...`; try { await downloadFile(url, name); success++; // 冷却 300ms await new Promise((r) => setTimeout(r, 300)); } catch (e) { console.error("Download fail", e); } } status.innerText = `✅ 完成!成功下载 ${success} 个文件`; btn.disabled = false; btn.innerText = originalText; } function downloadFile(url, filename) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, responseType: "blob", onload: (res) => { try { if (res.status !== 200) throw new Error("Status " + res.status); const blob = res.response; const u = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = u; a.download = filename; a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(u), 10000); resolve(); } catch (e) { reject(e); } }, onerror: reject, }); }); } setTimeout(createUI, 1500); })();