// ==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 条
静止中
💡 提示:向下滑动页面,数据会自动增加。
✅ 已启用 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");
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 += `| ${h} | `),
);
html += "
";
// 内容
dataList.forEach((row) => {
// 关键:给 tr 设置高度,确保能容纳图片
html += "";
headers.forEach((h) => {
const val = row[h] || "";
if (h === "封面图" && val) {
html += ` | `;
} else if (h === "链接" && val) {
html += `点击跳转 | `;
} else {
html += `${val} | `; // 强制文本格式
}
});
html += "
";
});
html += "
";
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);
})();