// ==UserScript==
// @name 小红书全能AI助手
// @namespace http://tampermonkey.net/
// @version 2.5.1
// @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;
// ==========================================
// 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 {
// 【关键】从笔记详情 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);
}
return originalFetch.apply(this, arguments);
};
})();
// ==========================================
// 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();
// ==========================
// 屏蔽登录弹窗逻辑
// ==========================
const SHIELD_LOGIN_KEY = "pc-shield-login-dialog";
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);
}
// 绑定设置面板复选框变化
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();
});
}
// ==========================================
// 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. 核心样式
// ==========================================
GM_addStyle(
'@import url("https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css");',
);
const UI_CSS = `
/* 覆盖 Bootstrap 可能会影响全局的样式 */
#xhs-ai-helper * {
box-sizing: border-box;
}
@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: 400px;
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;
font-size: 14px;
}
.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;
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: 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; transition: all 0.2s; }
.ai-btn:hover { opacity: 0.9; box-shadow: 0 2px 8px rgba(255, 36, 66, 0.3); }
.ai-btn.secondary { background: #f8f9fa; color: #333; border: 1px solid #dee2e6; }
.ai-btn.secondary:hover { background: #e9ecef; }
.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; }
}
/* 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-color: #e4e4e4; transition: .3s; border-radius: 34px; }
.ai-slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; box-shadow: 0 2px 4px rgba(0,0,0,0.2); }
input:checked + .ai-slider { background-color: #ff2442; }
input:checked + .ai-slider:before { transform: translateX(20px); }
`;
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 条
静止中
💡 提示:向下滑动页面,数据会自动增加。
✅ 已启用 API 拦截模式
导出的链接将包含 xsec_token,确保能在浏览器中直接访问,不会出现"笔记不存在"。
支持收藏夹、个人主页、搜索结果页。
📊 智能总结与分析
上传导出的 CSV/JSON,让 AI 分析趋势。
AI 自动分类数据并生成新文件。
导出格式:
⚠️ 检测到未分类数据。这通常是因为 API 速率限制或额度不足。
建议:
1. 检查 API Key 额度。
2. 更换更稳定的模型 (如 gpt-3.5/4)。
3. 每次处理需要一定时间,请耐心等待。
资源列表
⚙️ 全局 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");
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 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 (CURRENT_NOTE_RAW) {
renderResourceGrid(CURRENT_NOTE_RAW);
}
};
div.querySelector("#res-download-btn").onclick = handleBatchDownload;
// AI功能绑定
// div.querySelector("#config-toggle").onclick = ... // Removed
// 初始化并绑定屏蔽登录弹窗设置
try {
initShieldLoginFromSetting();
bindShieldLoginSetting();
} 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();
};
// 营销工具绑定
div.querySelector("#mkt-search-btn").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;
});
}
});
div.querySelector("#mkt-run-btn").onclick = handleBatchCommentCrawl;
div.querySelector("#mkt-export-btn").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;
}
// 兜底策略:扫描当前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) {
return new Promise((resolve) => {
if (!url || !url.startsWith("http")) {
resolve(null);
return;
}
GM_xmlhttpRequest({
method: "GET",
url: url,
responseType: "arraybuffer",
timeout: 15000,
onload: (res) => {
if (res.status === 200) {
resolve(res.response);
} else {
resolve(null);
}
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
});
}
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: "点击跳转", hyperlink: rowData["链接"] };
cell.font = { color: { argb: "FF0000FF" }, underline: true };
}
}
});
// 执行图片下载任务
if (imageTasks.length > 0) {
// 分批下载提示
const totalTasks = imageTasks.length;
updateStatus(
`📦 处理分包 ${i + 1}/${chunks.length}
🖼️ 正在下载并嵌入 ${totalTasks} 张图片 (并发5)...
请勿关闭页面`,
);
const results = await processImagesInBatch(imageTasks, 5);
// 嵌入图片
results.forEach((res) => {
if (res && res.buffer) {
const imgId = wb.addImage({
buffer: res.buffer,
extension: "png",
});
// 按比例缩放 (适配 334px 高度)
let finalW = 250;
let finalH = 334;
if (res.w && res.h) {
finalW = finalH * (res.w / res.h);
}
sheet.addImage(imgId, {
tl: { col: res.colIndex, row: res.rowNum - 1 }, // row is 0-based for image pos
ext: { width: finalW, height: finalH },
editAs: "oneCell",
});
// 清空该单元格文本
sheet.getRow(res.rowNum).getCell(res.colIndex + 1).value = "";
}
});
}
const buffer = await wb.xlsx.writeBuffer();
const fileName =
chunks.length > 1
? `xhs_data_part_${i + 1}.xlsx`
: `xhs_data_full.xlsx`;
updateStatus(`✅ 导出分包 ${i + 1}/${chunks.length},正在下载...`);
const blob = new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
// 如果是多包下载,稍作停顿,避免浏览器拦截或卡顿
if (chunks.length > 1) {
await new Promise((r) => setTimeout(r, 1000));
}
}
closeStatus();
} catch (e) {
console.error(e);
updateStatus("❌ 导出出错: " + e.message);
setTimeout(() => closeStatus(), 5000);
}
}
async function exportList(
dataList,
format,
baseName,
selectedCols,
splitSize = 999999,
) {
if (!dataList || dataList.length === 0) return;
// 默认全选
let headers = Object.keys(dataList[0]);
if (selectedCols && selectedCols.length > 0) {
headers = selectedCols;
}
// 分包处理
const chunks = [];
for (let i = 0; i < dataList.length; i += splitSize) {
chunks.push(dataList.slice(i, i + splitSize));
}
// 简易提示
const statusDiv = document.createElement("div");
if (chunks.length > 1) {
statusDiv.style.cssText =
"position:fixed;top:20px;right:20px;background:rgba(0,0,0,0.8);color:#fff;padding:15px;border-radius:8px;z-index:999999;";
document.body.appendChild(statusDiv);
}
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const currentBaseName =
chunks.length > 1 ? `${baseName}_part_${i + 1}` : baseName;
if (chunks.length > 1) {
statusDiv.innerHTML = `📦 正在导出 ${format.toUpperCase()} 分包 ${i + 1}/${chunks.length}...`;
}
let content = "";
let type = "";
let ext = "";
if (format === "json") {
const filteredList = chunk.map((row) => {
const newRow = {};
headers.forEach((h) => (newRow[h] = row[h]));
return newRow;
});
content = JSON.stringify(filteredList, null, 2);
ext = "json";
type = "application/json";
} else if (format === "md") {
content =
"# 小红书采集数据导出\n\n> 导出时间: " +
new Date().toLocaleString() +
"\n\n---\n\n";
chunk.forEach((row, index) => {
const rowIdx = i * splitSize + index + 1;
content += `### ${rowIdx}. ${row["标题"] || "无标题"}\n\n`;
content += `**作者**: ${row["作者"] || "未知"} | **点赞**: ${row["点赞数"] || 0} | [🔗 原文链接](${row["链接"]})\n\n`;
if (row["封面图"]) {
content += `\n\n`;
}
if (row["内容"] || row["描述"]) {
content += `**内容描述**:\n\n${(row["内容"] || row["描述"]).replace(/\n/g, "\n\n")}\n\n`;
}
content += `---\n\n`;
});
ext = "md";
type = "text/markdown;charset=utf-8";
} else if (format === "html") {
// HTML: 尝试下载图片并转 Base64 用于本地保存
if (chunks.length <= 1) {
// 如果仅一包但也需要处理图片,必须显示提示 (因为下载图片较慢)
statusDiv.style.cssText =
"position:fixed;top:20px;right:20px;background:rgba(0,0,0,0.8);color:#fff;padding:15px;border-radius:8px;z-index:999999;";
if (!statusDiv.parentNode) document.body.appendChild(statusDiv);
}
statusDiv.innerHTML = `📦 正在导出 HTML (Part ${i + 1}/${chunks.length})
🖼️ 正在下载并将图片转为Base64以实现本地保存...`;
// 并发控制函数
const processImagesInBatch = async (tasks, limit = 5) => {
const results = [];
const executing = [];
for (const task of tasks) {
const p = Promise.resolve().then(() => task());
results.push(p);
if (limit <= tasks.length) {
const e = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= limit) {
await Promise.race(executing);
}
}
}
return Promise.all(results);
};
const imageTasks = chunk.map((row, idx) => async () => {
const url = row["封面图"];
if (!url) return { index: idx, base64: "" };
try {
const buffer = await fetchImageBuffer(url);
if (buffer) {
const blob = new Blob([buffer]);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () =>
resolve({ index: idx, base64: reader.result });
reader.readAsDataURL(blob);
});
}
} catch (e) {
console.warn("Image fetch failed", e);
}
return { index: idx, base64: url }; // 失败则使用原链接
});
// 执行图片下载任务
const imageResults = await processImagesInBatch(imageTasks, 5);
const base64Map = {};
imageResults.forEach((r) => {
if (r) base64Map[r.index] = r.base64;
});
let cardsHtml = "";
chunk.forEach((row, idx) => {
const cover = base64Map[idx] || row["封面图"] || "";
const title = row["标题"] || "无标题";
const author = row["作者"] || "未知";
const likes = row["点赞数"] || "0";
const link = row["链接"] || "#";
const desc = row["内容"] || row["描述"] || "";
cardsHtml += `
`;
});
content = `
小红书数据导出 - Part ${i + 1}
${cardsHtml}
`;
ext = "html";
type = "text/html;charset=utf-8";
} else if (format === "xls") {
// Excel (HTML Table)
let html = `
`;
html += "";
headers.forEach(
(h) =>
(html += `| ${h} | `),
);
html += "
";
chunk.forEach((row) => {
html += "";
headers.forEach((h) => {
const val = row[h] || "";
if (h === "封面图" && val) {
html += ` | `;
} else if (h === "链接" && val) {
html += `点击跳转 | `;
} else {
html += `${val} | `;
}
});
html += "
";
});
html += "
";
content = html;
ext = "xls";
type = "application/vnd.ms-excel";
} else {
// CSV
const csvBody = chunk
.map((row) =>
headers
.map((h) => {
let v = row[h] || "";
v = String(v).replace(/"/g, '""');
return `"${v}"`;
})
.join(","),
)
.join("\n");
content = "\ufeff" + headers.join(",") + "\n" + csvBody;
ext = "csv";
type = "text/csv;charset=utf-8";
}
download(content, `${currentBaseName}.${ext}`, type);
// 延时防拦截
if (chunks.length > 1) {
await new Promise((r) => setTimeout(r, 1000));
}
}
if (chunks.length > 1) {
statusDiv.innerHTML = "✅ 所有分包导出完成";
setTimeout(() => statusDiv.remove(), 2000);
}
}
function exportData() {
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());
// 统一获取分包设置
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 });
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 = "📂 智能分类并导出";
}
}
// ==========================================
// 9. 营销工具 - 批量评论抓取逻辑
// ==========================================
let COLLECTED_COMMENTS = [];
async function handleBatchCommentCrawl() {
const btn = document.getElementById("mkt-run-btn");
const progressArea = document.getElementById("mkt-progress-area");
const progressBar = document.getElementById("mkt-progress-bar");
const statusText = document.getElementById("mkt-status-text");
const resultArea = document.getElementById("mkt-result-area");
const resultCount = document.getElementById("mkt-result-count");
// Configs
const likeFilter =
parseInt(document.getElementById("mkt-filter-likes").value) || 0;
// const scope = document.getElementById('mkt-crawl-scope').value; // Currently only 'initial' supported
if (GLOBAL_DATA.size === 0)
return alert(
"当前没有捕获到任何笔记。\n请先进行搜索或浏览页面,确保“数据导出”面板有数据。",
);
btn.disabled = true;
progressArea.style.display = "block";
resultArea.style.display = "none";
COLLECTED_COMMENTS = [];
const notes = Array.from(GLOBAL_DATA.values());
const total = notes.length;
let processed = 0;
let successCount = 0;
for (let i = 0; i < total; i++) {
const note = notes[i];
const percent = Math.round(((i + 1) / total) * 100);
progressBar.style.width = percent + "%";
statusText.innerText = `正在处理 (${i + 1}/${total}): ${note.标题.substring(0, 10)}...`;
try {
let noteId = note.笔记ID;
let xsecToken = "";
let targetUrl = note.链接 || "";
// 尝试从链接提取 token
if (targetUrl) {
const match = targetUrl.match(/xsec_token=([^&]+)/);
if (match) xsecToken = match[1];
}
// 如果笔记ID缺失,尝试从链接提取
if (!noteId && targetUrl) {
const idMatch = targetUrl.match(/\/explore\/([a-zA-Z0-9]+)/);
if (idMatch) noteId = idMatch[1];
}
if (noteId) {
let apiUrl = `https://edith.xiaohongshu.com/api/sns/web/v2/comment/page?note_id=${noteId}&cursor=&top_comment_id=&image_formats=jpg,webp,avif`;
if (xsecToken) {
apiUrl += `&xsec_token=${xsecToken}`;
}
const responseText = await new Promise((resolve) => {
GM_xmlhttpRequest({
method: "GET",
url: apiUrl,
headers: {
"User-Agent": navigator.userAgent,
Referer: "https://www.xiaohongshu.com/",
Origin: "https://www.xiaohongshu.com",
},
onload: (res) => resolve(res.responseText),
onerror: () => resolve(null),
});
});
if (responseText) {
const json = JSON.parse(responseText);
// API 结构: data.comments [...]
if (
json.data &&
json.data.comments &&
Array.isArray(json.data.comments)
) {
const comments = json.data.comments;
const validComments = comments.filter((c) => {
const likes = parseInt(c.like_count) || 0;
return likes >= likeFilter;
});
validComments.forEach((c) => {
const u = c.user_info || c.user || {};
COLLECTED_COMMENTS.push({
noteId: noteId,
noteTitle: note.标题,
noteLink: targetUrl,
commentContent: c.content,
commentLikes: c.like_count || 0,
commentTime: c.create_time || 0,
userNickname: u.nickname || "未知",
userId: u.user_id || u.id || "",
userImage: u.image || "",
});
});
successCount++;
}
}
}
} catch (e) {
console.warn(`[MKTool] Failed to fetch note ${note.笔记ID}`, e);
}
// Random delay to avoid block (500ms - 1000ms) - API 请求频率限制
await new Promise((r) => setTimeout(r, 500 + Math.random() * 500));
processed++;
}
statusText.innerText = `✅ 完成!成功处理 ${successCount} 篇笔记,共提取 ${COLLECTED_COMMENTS.length} 条评论`;
resultCount.innerText = COLLECTED_COMMENTS.length;
resultArea.style.display = "block";
btn.disabled = false;
}
function exportCommentData() {
if (COLLECTED_COMMENTS.length === 0) return alert("没有可导出的评论数据");
// Excel Format
let html = `
| 笔记ID |
笔记标题 |
笔记链接 |
评论内容 |
评论点赞 |
评论时间 |
用户昵称 |
用户ID |
`;
COLLECTED_COMMENTS.forEach((row) => {
html += `
| ${row.noteId} |
${row.noteTitle} |
链接 |
${row.commentContent} |
${row.commentLikes} |
${new Date(parseInt(row.commentTime)).toLocaleString()} |
${row.userNickname} |
${row.userId} |
`;
});
html += "
";
download(
html,
`xhs_comments_${new Date().getTime()}.xls`,
"application/vnd.ms-excel",
);
}
// ==========================================
// 5. 资源下载模块逻辑
// ==========================================
let CURRENT_NOTE_RAW = null; // 存储当前笔记原始数据,用于切换画质
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;
// CACHED_VIDEO_URL = null; // 修改:不要清空缓存,保留用户浏览期间抓取的真实链接
try {
let noteData = null;
// 策略: 优先 fetch 页面源码,正则提取 state,这是最稳妥的方式(兼容性最好)
// 直接读取 window 对象可能会因沙箱隔离失败
let targetUrl = url;
// 处理短链或非详情页URL (略,假设用户输入正确详情页或在详情页操作)
const html = await fetchHtml(targetUrl);
noteData = extractNoteFromHtml(html);
// 如果 HTML 中没有 __INITIAL_STATE__,尝试使用 noteId 调用接口兜底
if (!noteData) {
const noteId = getNoteIdFromUrl(targetUrl);
noteData = await fetchNoteDetailViaApi(noteId);
}
if (!noteData)
throw new Error("无法提取笔记数据,请确认链接有效且为公开笔记");
// 补充步骤: 如果是当前页,尝试从 DOM 提取额外视频链接 (参考 Demo)
if (
noteData.type === "video" &&
(targetUrl.includes(location.pathname) || targetUrl === location.href)
) {
const domVideo = await extractVideoFromDOM();
if (domVideo) {
if (!noteData.video) noteData.video = {};
// 将 DOM 提取的 URL 存入 temporary field
noteData.video.dom_url = domVideo;
}
}
// 等待 100ms 让 XHR 拦截器有时间捕获缓存
await new Promise((r) => setTimeout(r, 100));
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) {
// 尝试多种写法的 __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;
}
// 参考视频解析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) {
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\-]+(?=!)/;
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 嗅探提取 (从页面