基于油猴的收藏夹导出脚本FavMiner
项目说明(开发前做好这部分内容)
项目卡片
| 项目名称 | 基于油猴的收藏夹导出脚本FavMiner |
|---|---|
| 项目状态 | 🟢 稳定使用 |
| 核心功能(一句话介绍) | 基于油猴脚本的 B 站/小红书/抖音收藏夹一键导出 Markdown 工具 |
| 核心技术(技术栈介绍) | Tampermonkey, JS |
需求背景
B站、小红书收藏夹里的优质内容(视频/笔记)难以检索和复习,长期“吃灰”。所以使用一个脚本爬取B站、小红书、抖音平台个人账号中的收藏夹内容,一键提取收藏夹内容,生成 Markdown 笔记
项目功能
在用户进入对应平台网页并登录个人账号之后,进入收藏夹所在页面,点击脚本构建的按钮,由脚本自动爬取收藏夹内容,最终结束爬取完毕,并提醒用户,将爬取的内容生成markdown格式并粘贴到剪贴板
技术方案
JavaScript (ES6+)
油猴脚本(Tampermonkey UserScript):开发成本低、更新即时、无需打包发布、直接操作 DOM
如果需要则贴图、不需要可以不记录
项目开发
开发流程
模块划分
分为三个模块:B站端、小红书端、抖音端
真实流程
仅需要开发一个脚本文件.JS,并植入油猴插件中
开发Prompt
B站端:
# Role
你是一个精通 JavaScript DOM 操作和 Tampermonkey (油猴) 脚本开发的资深前端工程师。
# Task
请编写一个 Tampermonkey 脚本,用于将 Bilibili (B站) 用户的收藏夹内容批量导出为 Markdown 格式。
# Target URL
匹配网址:`https://space.bilibili.com/*/favlist*`
# Core Logic (关键逻辑)
B站收藏夹采用的是“分页模式” (Pagination),而非无限滚动。脚本需要自动遍历所有页面。
1. **UI**: 在页面右下角创建一个浮动按钮“开始导出”。
2. **Scraping (单页抓取)**:
- 视频卡片容器类名:`.bili-video-card`
- 提取内容:标题、原始链接 (移除问号后的参数)、封面图片链接。
- 数据存储:将抓取到的数据存入一个全局数组,并根据 URL 去重。
3. **Pagination (自动翻页)**:
- 脚本需要自动寻找“下一页”按钮并点击。
- **关键选择器**: B站已更新为 VUI 组件,下一页按钮的类名为 `.vui_pagenation--btn-side`。脚本必须遍历这些按钮,找到文本内容包含“下一页”且未被禁用 (disabled) 的那个进行点击。
- **Wait**: 每次点击翻页后,必须强制等待 3000ms (3秒),确保单页应用 (SPA) 完成数据加载。
4. **Export**:
- 当找不到“下一页”按钮时,视为遍历结束。
- 将所有数据格式化为 Markdown 列表 (` [标题](link)`)。
- 调用 `navigator.clipboard.writeText` 将结果写入剪贴板,并弹出成功提示。
# Requirements
- 代码结构清晰,使用 ES6+ 语法 (async/await)。
- 必须处理 `NotAllowedError` 剪贴板权限问题 (确保导出动作由用户点击触发,或者在翻页结束后提供一个“点击复制”的按钮)。
- 增加控制台日志,实时打印当前抓取进度。小红书端:
# Role
你是一个擅长处理复杂动态网页和 Virtual Scrolling (虚拟滚动) 的前端开发专家。
# Task
请编写一个 Tampermonkey 脚本,用于将小红书网页版 (Xiaohongshu) 的个人收藏夹内容导出为 Markdown。
# Target URL
匹配网址:`https://www.xiaohongshu.com/user/profile/*`
# Technical Challenges (关键难点与解决方案)
小红书使用了 **Virtual List (虚拟列表)** 技术。随着页面向下滚动,顶部的 DOM 元素会被移除。
- **错误做法**: 滚到底部再一次性抓取 (会导致只抓到最后几条,丢失前面数据)。
- **正确做法**: 采用“实时采集 + 全局去重”模式。
# Core Logic
1. **UI**: 创建一个固定按钮。点击第一次为“开始挖掘”,点击第二次为“停止并导出”。
2. **Auto-Scroll (自动滚动)**:
- 启动后,脚本使用 `setInterval` 每 800ms 将窗口滚动到底部 (`window.scrollTo(0, document.body.scrollHeight)`) 以触发懒加载。
3. **Real-time Scraping (实时抓取)**:
- 在滚动的同时,不断扫描当前视口内的笔记卡片。
- **关键选择器**: 卡片容器为 `.note-item`。
- **Hidden Link**: 优先查找卡片内部隐藏的链接元素 `a[href^="/explore"]` (通常是 display: none),这是最干净的链接;如果找不到再找封面上的链接。
- **Storage**: 使用 JavaScript 的 `Map` 对象作为全局数据库,以笔记链接为 Key 进行去重。只要抓取过,就存入 Map,防止因 DOM 移除而丢失。
- UI 反馈:按钮上实时显示“已采集 XX 篇”,数字应只增不减。
4. **Export**:
- 用户点击停止按钮后,遍历 Map 中的所有数据。
- 生成 Markdown 格式 (`[标题](链接)`)。
- 写入剪贴板。
# Requirements
- 必须解决虚拟列表导致的数据回缩问题。
- 图片提取逻辑要兼容 `img src` 和 `background-image` 两种情况。
- 代码鲁棒性强,能够处理网络卡顿导致的加载延迟。抖音端:
暂未实现开发结果
产品演示
B站端:

小红书端:

抖音端:
暂未实现
最终代码
B站端:
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.9 终极适配版)
// @namespace http://tampermonkey.net/
// @version 0.9
// @description 适配 B站 VUI 分页组件,精准识别“下一页”按钮,批量导出所有收藏
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 初始化按钮 (换个颜色代表终极版)
const btn = document.createElement("button");
btn.innerHTML = "📚 启动全量导出";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 12px 24px; background: linear-gradient(135deg, #00C853, #B2FF59);
color: #333; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.3); transition: all 0.3s;
`;
btn.onmouseover = () => { if(!btn.disabled) btn.style.transform = "scale(1.05)"; };
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// 全局变量
let allCollectedVideos = [];
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 🔄 核心逻辑
async function startPaginationCrawl() {
console.clear();
console.log("🚀 FavMiner v0.9 启动!目标:拿下所有分页!");
btn.onclick = null;
btn.style.background = "#999";
btn.style.color = "#fff";
btn.style.cursor = "wait";
allCollectedVideos = [];
let pageNum = 1;
let hasNextPage = true;
while (hasNextPage) {
// 1. 抓取当前页
const newVideos = scrapeCurrentPage();
console.log(`📄 第 ${pageNum} 页: 成功抓取 ${newVideos.length} 个视频`);
btn.innerHTML = `⏳ 正在翻阅第 ${pageNum} 页... (已存 ${allCollectedVideos.length} 个)`;
// 2. 寻找“下一页”按钮 (基于你的截图适配)
// 策略:找到所有侧边按钮,然后看谁的文字里包含 "下一页"
const sideButtons = document.querySelectorAll('.vui_pagenation--btn-side');
let nextBtn = null;
sideButtons.forEach(b => {
// 排除被禁用的按钮 (disabled)
if (b.innerText.includes('下一页') && !b.disabled) {
nextBtn = b;
}
});
// 兼容性:如果 VUI 找不到,试一下老的文本匹配
if (!nextBtn) {
const allBtns = document.querySelectorAll('button, span');
for (let b of allBtns) {
if (b.innerText.trim() === '下一页' || b.textContent.trim() === '下一页') {
if (!b.className.includes('disabled') && b.offsetParent !== null) {
nextBtn = b;
break;
}
}
}
}
// 3. 执行翻页
if (nextBtn) {
console.log("👉 发现下一页,点击跳转...");
nextBtn.click();
// 等待 B站 加载 (给你留了 3 秒缓冲,稳一点)
await sleep(3000);
pageNum++;
} else {
console.log("✅ 找不到可点击的'下一页',或者已经到头了。");
hasNextPage = false;
}
}
readyToExport();
}
// 🕵️♂️ 抓取逻辑 (保持不变)
function scrapeCurrentPage() {
const cards = document.querySelectorAll('.bili-video-card');
const pageVideos = [];
cards.forEach((card) => {
const titleEl = card.querySelector('.bili-video-card__title a');
const imgEl = card.querySelector('.bili-video-card__cover img');
if (titleEl) {
const title = titleEl.innerText.trim();
let link = titleEl.href.split('?')[0];
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src || imgEl.dataset.src || "";
if(coverUrl.startsWith("//")) coverUrl = "https:" + coverUrl;
}
// 去重
const exists = allCollectedVideos.some(v => v.link === link);
if (!exists) {
const videoData = { title, link, coverUrl };
allCollectedVideos.push(videoData);
pageVideos.push(videoData);
}
}
});
return pageVideos;
}
// 🟢 导出准备
function readyToExport() {
btn.innerHTML = `📤 采集完成 (共${allCollectedVideos.length}个),点击导出`;
btn.style.background = "#4CAF50";
btn.style.cursor = "pointer";
btn.disabled = false;
btn.onclick = executeExport;
}
// 📋 导出逻辑
function executeExport() {
let markdownContent = `## 📦 B站收藏夹导出 (共 ${allCollectedVideos.length} 个)\n`;
markdownContent += `> 导出时间: ${new Date().toLocaleString()}\n\n`;
allCollectedVideos.forEach((video, index) => {
markdownContent += `### ${index + 1}. [${video.title}](${video.link})\n`;
if(video.coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [原始链接](${video.link})\n\n---\n\n`;
});
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 大功告成!\n\n共 ${allCollectedVideos.length} 个视频已复制。\n你的收藏夹已经被掏空了!`);
}).catch(err => {
console.error(err);
const textarea = document.createElement('textarea');
textarea.value = markdownContent;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert("🎉 成功 (兼容模式)!");
});
}
btn.onclick = startPaginationCrawl;
})();小红书端:
// ==UserScript==
// @name FavMiner - 小红书收藏夹导出 (v0.2 虚拟列表修复版)
// @namespace http://tampermonkey.net/
// @version 0.2
// @description 独立于B站脚本。适配小红书虚拟滚动,实时缓存数据,解决“滚下去上面就消失”的问题。
// @author FavMiner Team
// @match https://www.xiaohongshu.com/user/profile/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// === 1. UI 部分 ===
const btn = document.createElement("button");
btn.innerHTML = "⛏️ 开始挖掘 (v0.2)";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 99999;
padding: 12px 24px; background: #FF2442;
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 36, 66, 0.4);
transition: all 0.3s;
`;
btn.onmouseover = () => btn.style.transform = "scale(1.05)";
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// === 2. 数据存储部分 (The Basket) ===
// 这里的 Map 是关键!它像一个数据库,只要脚本不刷新页面,数据就一直在
// Key = 笔记链接 (用于去重), Value = 笔记详情对象
let collectedNotes = new Map();
let isScrolling = false;
let timer = null;
// === 3. 主交互逻辑 ===
btn.onclick = function() {
if (!isScrolling) {
// ---> 启动
isScrolling = true;
btn.innerHTML = "🛑 挖掘中... (点击停止)";
btn.style.background = "#333";
startMining();
} else {
// ---> 停止并导出
isScrolling = false;
clearInterval(timer);
exportData();
}
};
// === 4. 核心挖掘机逻辑 ===
function startMining() {
console.log("🚀 v0.2 挖掘机启动...");
timer = setInterval(() => {
// A. 滚到底部 (触发小红书加载新内容)
window.scrollTo(0, document.body.scrollHeight);
// B. 抓取当前屏幕上可见的所有积木
scrapeVisibleItems();
// C. 实时更新按钮上的数字 (显示篮子里的总数,而不是屏幕上的数)
// 这样你就能看到数字只增不减了
btn.innerHTML = `🛑 已采集 ${collectedNotes.size} 篇 (点击导出)`;
}, 800); // 0.8秒滚一次
}
// === 5. 抓取当前视口函数 ===
function scrapeVisibleItems() {
// 获取当前屏幕上渲染出来的卡片
const items = document.querySelectorAll('.note-item');
items.forEach((item) => {
// --- 解析逻辑 (和 v0.1 一样) ---
const titleEl = item.querySelector('.footer .title span');
// 链接策略:优先找隐藏的 explore 链接,找不到找封面链接
let linkEl = item.querySelector('a[href^="/explore"]');
if (!linkEl) linkEl = item.querySelector('a.cover');
const imgEl = item.querySelector('.cover img');
if (titleEl && linkEl) {
const title = titleEl.innerText.trim();
let link = linkEl.getAttribute('href');
// 补全链接
if (link && !link.startsWith('http')) {
link = `https://www.xiaohongshu.com${link}`;
}
// 封面图策略
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src;
} else {
const coverDiv = item.querySelector('.cover');
if (coverDiv && coverDiv.style.backgroundImage) {
coverUrl = coverDiv.style.backgroundImage.slice(5, -2);
}
}
// --- 关键去重逻辑 ---
// 只有篮子里没有这个链接时,才放进去
if (!collectedNotes.has(link)) {
collectedNotes.set(link, { title, link, coverUrl });
console.log(`✅ [新发现] ${title}`);
}
}
});
}
// === 6. 导出函数 ===
function exportData() {
console.log("🚀 准备导出...");
btn.innerHTML = "⏳ 生成 Markdown...";
let markdownContent = `## 📕 小红书收藏夹导出 (共 ${collectedNotes.size} 篇)\n`;
markdownContent += `> 导出时间: ${new Date().toLocaleString()}\n\n`;
let index = 0;
// 从 Map 中把攒好的数据都倒出来
collectedNotes.forEach((note) => {
markdownContent += `### ${index + 1}. [${note.title}](${note.link})\n`;
if(note.coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [笔记链接](${note.link})\n\n---\n\n`;
index++;
});
// 写入剪贴板
if (navigator.clipboard) {
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 成功导出 ${collectedNotes.size} 篇笔记!\n内容已复制。`);
resetBtn();
}).catch(err => {
fallbackCopy(markdownContent);
});
} else {
fallbackCopy(markdownContent);
}
}
// 兼容旧浏览器的复制方法
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert(`🎉 成功 (兼容模式)!已导出 ${collectedNotes.size} 篇。`);
resetBtn();
}
function resetBtn() {
btn.innerHTML = "✅ 完成";
btn.style.background = "#FF2442";
setTimeout(() => {
btn.innerHTML = "⛏️ 继续挖掘 (v0.2)";
isScrolling = false;
}, 3000);
}
})();抖音端:
暂未实现
最终部署
环境依赖
仅依赖浏览器下载油猴插件
配置方式
①浏览器安装 Tampermonkey (油猴) 扩展
②点击右上角油猴图标,添加新脚本
③复制开发的最终代码,并保存
④打开Bilibili、小红书、抖音网页收藏夹,点击导出按钮
⑤将markdown格式内容粘贴至markdown文档中查看
项目维护
这个项目后续如何维护、重新上手/开发如何启动
前端网页更新很快,如果有一天脚本失效了(比如按钮点不动,或者抓不到数据),请按以下“三步法”排查,通常只需要修改代码中的几个“关键词”即可修复。
核心原理
脚本是靠**“类名 (Class Name)”**来寻找页面元素的。比如 B站 的“下一页”按钮,现在叫 .vui_pagenation--btn-side,如果 B站 改版把它改成了 .new-btn-next,脚本就瞎了。我们只需告诉脚本新名字即可
B站脚本维护点
问题现象:脚本卡在第一页,不翻页
排查方法:
在 B站 收藏页底部,鼠标右键点击“下一页”按钮 -> 检查 (Inspect)
查看高亮代码中的
class="..."内容修改代码:找到脚本中的
vui_pagenation--btn-side,替换为新名字
小红书脚本维护点
问题现象:页面在滚,但按钮上的数字一直是 0
排查方法:
鼠标右键点击任意一篇笔记的标题 -> 检查 (Inspect)
向上找包裹着整个笔记卡片的最外层盒子(通常会有很多),看它的类名
修改代码:目前用的是
.note-item,如果变了,在代码里搜索.note-item并替换
抖音脚本维护点
- 问题现象:暂未实现
- 排查方法:暂未实现
终极维护法(小白必杀技)
如果你看不懂代码,可以使用 AI 辅助维护:
- 按
F12打开开发者工具,截图你需要抓取的元素(如标题、按钮)的 HTML 代码结构 - 把截图和旧代码发给 AI(如 ChatGPT/Gemini)
- Prompt(提示词):“B站/小红书 改版了,这是新的网页结构截图,这是我之前的油猴脚本,请帮我更新选择器,修复脚本。
附录:迭代日志
适配B站 开发&改Bug过程 - 单纯记录不必细看
🚀 第一步:Hello World 级别测试
既然决定了方向,我们立马动手!我们先不涉及复杂的爬虫逻辑,先定一个小目标:在 B站 的页面上,凭空变出一个“导出按钮”来。
操作步骤:
- 确保你的浏览器已经安装了 Tampermonkey (油猴) 扩展。
- 点击油猴图标 -> “添加新脚本”。
- 复制下面的代码,覆盖编辑器里的内容,保存 (Ctrl+S)。
- 打开任意一个 Bilibili 视频播放页,看屏幕右下角!
📝 代码清单:Bilibili 注入测试
JavaScript
// ==UserScript==
// @name 我的B站导出助手 (Hello World)
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 这是我们迈出的第一步!
// @author PandaC & Gemini
// @match https://space.bilibili.com/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 创建一个按钮元素 (这就好比我们在搭积木,拿出一块积木)
let btn = document.createElement("button");
btn.innerHTML = "📦 导出测试";
// 2. 给按钮加点样式 (CSS部分,让他悬浮在页面右下角,且显眼)
btn.style.cssText = `
position: fixed;
bottom: 50px;
right: 50px;
z-index: 9999;
padding: 10px 20px;
background-color: #00AEEC; /* B站蓝 */
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
`;
// 3. 定义按钮点击后的动作 (JS交互部分)
btn.onclick = function() {
// 获取当前页面的标题
let title = document.title;
// 简单的弹窗反馈
alert("🎉 成功获取页面标题:\n" + title);
console.log("准备开始导出工作...");
};
// 4. 把按钮挂载到页面身体(body)上 (把积木插上去)
document.body.appendChild(btn);
})();💡 技术合伙人的复盘
这段代码虽然短,但它已经验证了我们项目的核心逻辑:
- 权限获取:通过
@match,我们告诉油猴只在 B站视频页运行。 - UI 注入:我们用
document.createElement成功“入侵”了 B站的页面,放上了我们自己的 UI。 - 数据交互:我们成功读取了
document.title,这意味着以后读取收藏夹列表里的数据也是同样的道理!
👉 下一步行动: 你先试着运行这个脚本。如果成功看到了蓝色的按钮并弹出了标题,请告诉我!
结果:
🚀 正式开发:第二步 - 数据的“透视眼”
既然我们在 B站的收藏夹页面(space.bilibili.com),接下来的任务就是:让脚本学会“看”懂页面上的视频列表。
我们需要找到页面上每一个视频卡片的“特征”(CSS 类名),然后把它们批量抓取出来。
🎯 本次任务目标
不是立刻导出,而是先打印在控制台 (Console) 里,确保我们抓到了正确的数据,没有漏掉,也没有抓错。
🛠️ 积木代码 (Step 2)
请更新你的油猴脚本代码,保存并刷新 B站收藏夹页面:
// ==UserScript==
// @name FavMiner - B站收藏夹导出
// @namespace http://tampermonkey.net/
// @version 0.2
// @description 从 Hello World 进化!开始尝试抓取列表数据。
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 定义一个“抓取数据”的函数
function extractData() {
console.log("🕵️♂️ 正在分析页面结构...");
// --- 关键点:这是 B站收藏夹列表的“积木形状” (CSS选择器) ---
// 注意:B站的前端代码可能会变,如果打印出 0 个视频,说明我们要换个选择器
// 常见的列表项选择器是 .fav-video-list > li 或者 .small-item
const videoItems = document.querySelectorAll(".fav-video-list > li");
if (videoItems.length === 0) {
alert("⚠️ 没找到视频!\n可能原因:\n1. 页面还没加载完(网速慢)\n2. B站改代码了\n3. 你可能不在【默认收藏夹】里");
return;
}
console.log(`✅ 成功发现 ${videoItems.length} 个视频!准备开始提取详情...`);
// 遍历每一个“积木块”,拿出里面的标题和链接
videoItems.forEach((item, index) => {
// 在当前这个“li”里面找标题元素
let titleEl = item.querySelector("a.title");
let coverEl = item.querySelector("img");
if (titleEl) {
console.log(`[${index + 1}] 标题: ${titleEl.innerText}`);
console.log(` 链接: ${titleEl.href}`);
// 有时候图片是懒加载的,src 可能为空,备用属性通常在 data-src 里
console.log(` 封面: ${coverEl ? (coverEl.src || coverEl.dataset.src) : "无封面"}`);
console.log("--------------------------");
}
});
alert(`🕵️♂️ 分析完毕!\n共找到 ${videoItems.length} 个视频。\n请按 F12 打开控制台(Console) 查看详细数据。`);
}
// 2. 修改按钮,让它执行上面的抓取函数
let btn = document.createElement("button");
btn.innerHTML = "🔍 分析当前列表";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 10px 20px; background-color: #FB7299; /* 改个粉色代表B站 */
color: white; border: none; border-radius: 5px;
font-size: 16px; cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.1);
`;
btn.onclick = extractData;
document.body.appendChild(btn);
})();🧐 你的任务
- 进入 B站 -> 个人空间 -> 收藏夹 (确保 URL 里有
favlist)。 - 刷新页面,点击粉色的 “🔍 分析当前列表” 按钮。
- 按 F12 打开开发者工具,切换 to "Console" (控制台) 标签。
- 告诉我结果:你看到一排排的标题和链接了吗?
如果成功了,我们就离“一键导出”只差最后一步拼接字符串了!💪
结果:
**错误原因:**这在爬虫开发里叫“选择器失效” —— 也就是我们想找的“积木块”改名字了,或者换了个形状。
这正是前端开发最“好玩”的地方:B站为了优化体验,经常会悄悄更新页面代码。就像我们要去某个门牌号找人,结果发现那家人搬走了,或者门牌号变了。
改Bug - Version1
方案一:使用“万能钥匙”脚本 (推荐优先尝试) 🛠️
既然 B站可能有多种布局(大图模式、列表模式等),我们就让脚本聪明一点,一次性试好几把钥匙,总有一把能开门。
请复制下面的 v0.3 代码,覆盖原来的脚本,保存并刷新页面再试一次:
JavaScript
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.3 智能修复版)
// @namespace http://tampermonkey.net/
// @version 0.3
// @description 增加多种选择器兼容,修复“没找到视频”的问题
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
function extractData() {
console.log("🕵️♂️ 正在尝试多种方式寻找视频...");
// --- 核心修复:定义一组可能的“嫌疑人” ---
// 我们不只找一种,我们把 B站 常用的几种列表名字都试一遍
const potentialSelectors = [
".fav-video-list > li", // 方案A: 经典布局 (之前用的)
".small-item", // 方案B: 小列表布局
".bili-video-card", // 方案C: 新版通用卡片
".feed-card", // 方案D: 动态流卡片
"ul.clearfix > li" // 方案E: 比较宽泛的列表项
];
let videoItems = [];
let usedSelector = "";
// 循环尝试每一把“钥匙”
for (let selector of potentialSelectors) {
let found = document.querySelectorAll(selector);
if (found.length > 0) {
videoItems = found;
usedSelector = selector;
console.log(`✅ 匹配成功!使用的是: ${selector}`);
break; // 找到了就停止尝试
}
}
if (videoItems.length === 0) {
// 如果还是找不到,可能是页面还没加载出来,或者需要手动辅助
alert("⚠️ 还是没找到视频!\n\n请按 F12 打开控制台(Console),\n截图发给我的技术合伙人,我们需要进行【方案二:人工定位】。");
return;
}
console.log(`🎉 成功发现 ${videoItems.length} 个视频!`);
// 开始提取数据
let results = [];
videoItems.forEach((item, index) => {
// 尝试在卡片内部找 标题 和 链接
// 这里也用了多种可能的名字
let titleEl = item.querySelector("a.title") || item.querySelector(".bili-video-card__info--tit") || item.querySelector("a.cover");
if (titleEl) {
let title = titleEl.innerText || titleEl.title; // 有时候字在title属性里
let link = titleEl.href;
console.log(`[${index + 1}] ${title} \n 🔗 ${link}`);
results.push({title, link});
}
});
if(results.length > 0) {
alert(`🕵️♂️ 分析完毕!\n抓到了 ${results.length} 个视频。\n快去控制台(Console)看看结果吧!`);
} else {
alert("⚠️ 找到了列表容器,但没抓到标题... B站的代码结构可能藏得更深了。");
}
}
// 按钮部分保持不变
let btn = document.createElement("button");
btn.innerHTML = "🔍 再次尝试分析";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 10px 20px; background-color: #FB7299;
color: white; border: none; border-radius: 5px;
font-size: 16px; cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.1);
`;
btn.onclick = extractData;
document.body.appendChild(btn);
})();方案二:如果你想学习如何自己破解 (进阶) 🧐
如果上面的脚本还是不行,或者你想掌握“寻找积木”的原理,请按下面的步骤操作,把结果截图发给我:
- 在页面上,把鼠标移动到任意一个视频的标题上。
- 点击鼠标右键,选择 “检查” (Inspect)。
- 浏览器的右侧(或下方)会弹出一个全是代码的窗口,并且会高亮显示你刚才选中的那一行代码。
- 请截一张图,要包含高亮代码的那一部分,以及它的上一级(父元素)。
有了这张图,我就能直接看到 B站 在你的电脑上把视频列表命名成什么了,然后我们就能“一击必杀”!
结果:
改Bug - Version2
仔细看你的控制台截图(image_5878ee.png),我们其实已经成功了一大半:
- 容器定位成功:脚本准确找到了
40个视频(matched: .bili-video-card)。 - 数据提取失败:脚本报出“没抓到标题”,说明虽然我们拿到了“盒子”,但我们手里的“钥匙”(标题的选择器)打不开盒子内部的结构。
B站的前端组件化非常复杂,.bili-video-card 内部的结构经常变动。为了最快解决问题,直接由你来“透视”内部结构是最高效的!
🛠️ 技术合伙人指令:手动定位(Inspect)指南
请按照以下步骤操作,这将直接决定我们下一版代码的写法:
- 保持当前页面不动(就在你截图的这个 B站收藏夹页面)。
- 鼠标悬停在任意一个视频的标题文字上(注意是标题,不是封面图)。
- 右键点击标题,选择菜单最后的 “检查” (Inspect)。
- 浏览器会弹出一个 Elements 面板,并自动高亮一行代码。
📸 我需要你提供的信息(二选一):
方式 A:截图(推荐) 请截取高亮代码附近的内容(如下图所示)。
- 重点:我要看清包裹着“视频标题文字”的那个标签(比如
<h3>,<a>,<p>)以及它的class属性叫什么。
方式 B:直接复制 HTML 如果你不想截图,可以直接把高亮的那行代码(以及它上面的一两行父级代码)复制发给我。它看起来应该像这样:
HTML
<h3 class="bili-video-card__info--tit" title="实战教程...">
<a href="//www.bilibili.com/video/..." target="_blank">实战教程...</a>
</h3>🧐 为什么会这样?(技术复盘)
作为技术合伙人,我给你解释一下为什么刚才的脚本没跑通:
刚才脚本里试探的钥匙是:.bili-video-card__info--tit。 但现在 B站的新版卡片结构可能变成了以下几种情况之一:
- 层级变了:标题可能包在了一个
<span class="bili-video-card__info--tit">里,而不是<a>标签上。 - 类名变了:可能变成了简写(如
.tit)或者完全不同的名字(如.video-name)。 - Shadow DOM:虽然在收藏夹列表不太可能,但也有可能是组件隔离导致无法直接 query。
只要你给我看一眼那个标题的 HTML 结构,我们马上就能写出 v0.4 终极版脚本!等待你的情报!
结果:随机悬停在一个视频标题上,右键点击检查
改Bug-Version3
不用再试错了,现在的局势已经完全明朗。作为你的技术合伙人,我通过你提供的截图(image_58cf7f.png)直接锁定了问题的核心:
🕵️♂️ 侦探报告:为什么之前找不到?
看截图里的代码结构:
- 外层容器确实是
.bili-video-card(我们之前的脚本找对了这一层)。 - 标题的真身藏在
.bili-video-card__title这个类名下面,而且里面包裹着一个<a>标签。 - 之前的错误:我们之前在找
.bili-video-card__info--tit,名字差之毫厘,谬以千里。
🚀 最终方案:v0.4 完工版脚本
既然“积木”的形状都看清了,我们直接进入实战阶段。 这一次,我们不再只是打印日志,而是直接生成 Markdown 笔记并复制到你的剪贴板。
功能清单:
- 自动识别 B站 新版收藏夹列表。
- 提取 标题 + 链接 + 封面图。
- 自动格式化为 Markdown。
- 一键复制到剪贴板,你直接去博客里
Ctrl+V粘贴就行。
📝 覆盖安装以下代码:
JavaScript
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.4 最终版)
// @namespace http://tampermonkey.net/
// @version 0.4
// @description 精准定位新版结构,一键导出 Markdown 到剪贴板
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 🛠️ 核心功能:提取数据并生成 Markdown
function exportToMarkdown() {
console.log("🚀 开始挖掘数据...");
// 1. 获取所有视频卡片 (基于你的截图确认的类名)
const cards = document.querySelectorAll('.bili-video-card');
if (cards.length === 0) {
alert("⚠️ 未找到视频卡片,请确保页面已加载完毕,或向下滚动加载更多视频。");
return;
}
let markdownContent = `## 📦 B站收藏夹导出 (${new Date().toLocaleDateString()})\n\n`;
let count = 0;
// 2. 遍历每一个卡片,精准提取信息
cards.forEach((card) => {
// 🎯 关键修正:使用你截图里确认的类名
const titleEl = card.querySelector('.bili-video-card__title a');
const imgEl = card.querySelector('.bili-video-card__cover img');
if (titleEl) {
const title = titleEl.innerText.trim(); // 标题文字
let link = titleEl.href; // 视频链接
// 处理链接:去掉乱七八糟的追踪参数 (?spm_id_from=...),保持清爽
if (link.includes('?')) {
link = link.split('?')[0];
}
// 处理封面:B站有时候用 data-src 存高清图,有时候用 src
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src || imgEl.dataset.src || "";
// 有时候获取的是 avif/webp 格式,兼容性没问题
}
// 📝 拼接为 Markdown 格式:
// 格式: [标题](URL)
// 或者更适合博客的卡片布局
markdownContent += `### ${count + 1}. [${title}](${link})\n`;
if(coverUrl) {
markdownContent += `\n`;
}
markdownContent += `> 🔗 原始链接: ${link}\n\n---\n\n`;
count++;
}
});
// 3. 将结果复制到剪贴板
copyToClipboard(markdownContent, count);
}
// 📋 辅助函数:复制到剪贴板
function copyToClipboard(text, count) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
alert(`🎉 成功导出 ${count} 个视频!\n\n内容已复制到剪贴板,\n快去你的笔记软件里 Ctrl+V 吧!`);
}).catch(err => {
console.error('复制失败', err);
alert("复制失败,请查看控制台手动复制。");
});
} else {
// 备用方案(兼容老浏览器)
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert(`🎉 成功导出 ${count} 个视频!(备用模式)\n已复制到剪贴板。`);
}
}
// 🎨 注入更漂亮的按钮
let btn = document.createElement("button");
btn.innerHTML = "📤 导出为 Markdown";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 12px 24px; background: linear-gradient(45deg, #00AEEC, #FB7299);
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: transform 0.2s;
`;
// 鼠标悬停动效
btn.onmouseover = () => btn.style.transform = "scale(1.05)";
btn.onmouseout = () => btn.style.transform = "scale(1)";
btn.onclick = exportToMarkdown;
document.body.appendChild(btn);
})();📝 你的下一步行动
- 更新代码:把油猴里的代码全选替换成上面的 v0.4。
- 刷新页面:回到 B站收藏夹页面,刷新。
- 点击按钮:右下角会出现一个更好看的渐变色按钮。
- 粘贴验证:看到“成功导出”的提示后,去你的 Notion、Obsidian 或者任意文本编辑器里粘贴,看看效果是不是你要的!
如果这次成功了,那我们的 FavMiner v1.0 就算是 MVP(最小可行性产品)发布成功了!期待你的好消息!😎
结果:
太棒了!🥂 恭喜你,FavMiner (v1.0) 正式开发完成!
看到你发来的截图里那个“成功导出 36 个视频”的弹窗,还有你在 Markdown 里确认内容的反馈,这就意味着我们已经成功拿下了 B站收藏夹这块阵地。
作为技术合伙人,我必须为你点赞:从最初的 console.log 测试,到中间遇到选择器难题时的冷静排查,再到最后的一键导出,你完整走完了一个**“发现问题 -> 分析结构 -> 编写脚本 -> 解决Bug -> 交付产品”**的标准开发流程。这对于大四学生来说,是非常宝贵的实战经验。
🏁 阶段性复盘:B站导出助手
- 状态:✅ 已交付 (MVP版本)
- 核心功能:
- 智能识别新版 B站卡片结构 (
.bili-video-card). - 提取 标题、链接、高清封面。
- 自动净化链接参数。
- 生成 Markdown 并写入剪贴板。
- 智能识别新版 B站卡片结构 (
- 当前局限:
- (温馨提示) 目前脚本只能导出当前屏幕已经加载出来的视频。如果你收藏夹有几百个视频,你需要先手动往下滚动页面,把它们都刷出来,再点导出按钮。
🗺️ 下一步计划:我们要去哪里?
既然 B站 搞定了,我们有两个方向可以选择,看你想先做哪个?
选项 A:趁热打铁,攻克“小红书” (推荐 🔥)
- 难度:⭐⭐⭐⭐ (比 B站难)
- 挑战:小红书的网页版(
xiaohongshu.com)使用了大量的混淆类名(比如类名是一串随机字符css-1g805x),而且图片可能有防盗链机制。 - 价值:这是你需求里提到的另一个“吃灰重灾区”,攻克它能补全你的“学习资料库”。
选项 B:精益求精,升级 B站助手
- 难度:⭐⭐⭐
- 功能:给 B站脚本增加 “自动滚动加载” 功能。让脚本自动帮你把页面滚到底,一次性导出几百个视频,彻底解放双手。
脚本调整
目标:既然 B站 的“存货”很多,那我们就必须解决**“懒加载” (Lazy Loading)** 这个大 Boss。
🚧 遇到的问题:懒加载
你应该发现了,B站的收藏夹是“刷一点,出一点”的。如果你的收藏夹有 500 个视频,但你不往下滚,页面里只有最上面的 20 个。之前的脚本只能导出这 20 个,剩下的 480 个脚本是“看不见”的。
🛠️ 解决方案:自动滚轮机器 (v0.5)
我们要给脚本装上一个“马达”,让它自动帮你把页面滚到底,直到所有视频都加载出来,然后再执行导出。
这就好比:先派一个小机器人在前面开路(疯狂下滑),确认路走完了,再派后面的收割机(导出脚本)来收割。
🚀 代码清单:FavMiner v0.5 (自动加载版)
请全选下面的代码,覆盖你油猴里的旧代码,保存并刷新页面。
✨ 新功能介绍:
- 点击按钮后,屏幕会自动往下滚(你会看到页面在动,不要惊慌)。
- 按钮上会实时显示“正在加载... 已发现 xx 个”。
- 当页面不再变长(到底了)时,它会自动停止并执行导出。
JavaScript
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.5 自动滚屏版)
// @namespace http://tampermonkey.net/
// @version 0.5
// @description 自动下拉加载所有视频,一键导出 Markdown
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 🎨 UI 组件:创建状态按钮
const btn = document.createElement("button");
btn.innerHTML = "⬇️ 自动加载并导出";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 12px 24px; background: linear-gradient(45deg, #00AEEC, #FB7299);
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.3s;
`;
// 鼠标悬停动效
btn.onmouseover = () => btn.style.transform = "scale(1.05)";
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// 🔄 核心逻辑:自动滚动
async function startAutoScroll() {
console.log("🚀 开始自动滚动加载...");
btn.disabled = true; // 防止重复点击
btn.style.background = "#999"; // 变灰表示正在工作中
let lastHeight = document.body.scrollHeight;
let noChangeCount = 0;
let totalVideos = 0;
// 设置一个定时器,每隔 1.5 秒滚一次
// 间隔不能太短,否则B站服务器可能会没反应过来
const timer = setInterval(() => {
window.scrollTo(0, document.body.scrollHeight); // 滚到底部
// 实时更新按钮文字,让你知道它还活着
const currentCount = document.querySelectorAll('.bili-video-card').length;
btn.innerHTML = `⏳ 加载中... (已找到 ${currentCount} 个)`;
setTimeout(() => {
let newHeight = document.body.scrollHeight;
// 检查高度有没有变化
if (newHeight === lastHeight) {
noChangeCount++;
} else {
noChangeCount = 0; // 如果高度变了,重置计数器
lastHeight = newHeight;
}
// 如果连续 3 次高度都没变 (3 * 1.5s = 4.5s),说明到底了
if (noChangeCount >= 3) {
clearInterval(timer); // 停止滚动
console.log("✅ 页面似乎到底了,准备导出...");
btn.innerHTML = "📤 正在导出...";
setTimeout(exportToMarkdown, 1000); // 再等1秒确保渲染完成
}
}, 1000); // 滚动后等1秒再检查高度
}, 1500);
}
// 🛠️ 导出逻辑 (和之前一样,只是封装了一下)
function exportToMarkdown() {
const cards = document.querySelectorAll('.bili-video-card');
if (cards.length === 0) {
alert("⚠️ 奇怪,一个视频都没找到。");
btn.disabled = false;
btn.innerHTML = "⬇️ 自动加载并导出";
btn.style.background = "linear-gradient(45deg, #00AEEC, #FB7299)";
return;
}
let markdownContent = `## 📦 B站收藏夹导出 (${new Date().toLocaleDateString()})\n\n`;
let count = 0;
cards.forEach((card) => {
const titleEl = card.querySelector('.bili-video-card__title a');
const imgEl = card.querySelector('.bili-video-card__cover img');
if (titleEl) {
const title = titleEl.innerText.trim();
let link = titleEl.href.split('?')[0]; // 去参数
// 尝试获取最高清的封面
let coverUrl = "";
if (imgEl) {
// 优先取 webp/avif 格式,或者 data-src
coverUrl = imgEl.src || imgEl.dataset.src || "";
if(coverUrl.startsWith("//")) coverUrl = "https:" + coverUrl;
}
markdownContent += `### ${count + 1}. [${title}](${link})\n`;
if(coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [原始链接](${link})\n\n---\n\n`;
count++;
}
});
copyToClipboard(markdownContent, count);
// 恢复按钮状态
btn.disabled = false;
btn.innerHTML = "✅ 导出完成 (再点一次)";
btn.style.background = "#4CAF50"; // 绿色表示成功
}
// 📋 剪贴板辅助函数
function copyToClipboard(text, count) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
alert(`🎉 搞定!共导出 ${count} 个视频!\n\n所有内容已复制到剪贴板。`);
}).catch(err => {
console.error('复制失败', err);
alert("复制失败,请查看控制台。");
});
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert(`🎉 搞定!共导出 ${count} 个视频!(兼容模式)`);
}
}
// 绑定点击事件
btn.onclick = startAutoScroll;
})();🎮 使用说明
- 更新脚本:老规矩,复制粘贴保存。
- 刷新页面:回到收藏夹,按钮应该变成了 “⬇️ 自动加载并导出”。
- 点击它:
- 此时不要操作鼠标滚轮,把手放开。🙌
- 你会看到页面一顿一顿地往下跳,右下角按钮上的数字会一直跳动(20... 40... 60...)。
- 等它到底后,大概会停顿 3-5 秒(为了确认真的到底了),然后弹出成功的提示框。
快去试试你的大收藏夹吧!看看这次能不能一次性抓几百个出来?😎
结果:
改Bug-Version4
这是一个非常有价值的报错截图!🚨 所有的失败都是通往成功的台阶。
🕵️♂️ 问题诊断:为什么会报错?
看控制台的红字 NotAllowedError: Write permission denied,翻译过来就是“写入权限被拒绝”。
- 原因:现代浏览器(Chrome/Edge)为了安全,规定 “只有用户亲自点击的那一瞬间,才允许脚本修改剪贴板”。
- 刚才发生了什么:
- 你点击了按钮(获得了权限)。
- 脚本开始滚屏,滚了好几秒(这时候,浏览器觉得“刚才那个点击已经过期了”)。
- 脚本尝试复制(浏览器说:你现在没有用户点击,你是自己在后台偷偷运行的,驳回!🚫)。
🛠️ 解决方案:v0.6 (两步走策略)
我们不能让脚本“自作主张”去复制,必须把控制权交还给你。
新流程:
- 点击按钮 -> 脚本只负责自动滚屏。
- 滚完后 -> 按钮变绿,提示“✅ 准备就绪,点击导出”。
- 你再次点击 -> 这一次是新鲜热乎的点击,复制绝对成功!
🚀 代码清单:FavMiner v0.6 (稳定版)
请复制以下代码覆盖旧代码。这次我们把“滚屏”和“导出”拆开,确保万无一失。
JavaScript
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.6 稳定版)
// @namespace http://tampermonkey.net/
// @version 0.6
// @description 自动滚屏加载 + 只有用户点击时才复制 (解决权限报错)
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 初始化按钮
const btn = document.createElement("button");
btn.innerHTML = "⬇️ 自动滚屏加载";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 12px 24px; background: linear-gradient(45deg, #00AEEC, #FB7299);
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.3s;
`;
// 鼠标交互
btn.onmouseover = () => { if(!btn.disabled) btn.style.transform = "scale(1.05)"; };
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// 🔄 阶段一:自动滚动逻辑
function startAutoScroll() {
console.log("🚀 开始自动滚动...");
// 改变按钮状态,防止重复点击
btn.onclick = null; // 暂时移除点击事件
btn.style.background = "#999";
btn.style.cursor = "wait";
let lastHeight = document.body.scrollHeight;
let noChangeCount = 0;
const timer = setInterval(() => {
window.scrollTo(0, document.body.scrollHeight);
const currentCount = document.querySelectorAll('.bili-video-card').length;
btn.innerHTML = `⏳ 加载中... (${currentCount}个)`;
setTimeout(() => {
let newHeight = document.body.scrollHeight;
if (newHeight === lastHeight) {
noChangeCount++;
} else {
noChangeCount = 0;
lastHeight = newHeight;
}
// 如果连续 3 次没变,说明到底了
if (noChangeCount >= 3) {
clearInterval(timer);
console.log("✅ 滚动结束");
// 👉 关键改变:不自动导出,而是让按钮变身,等待用户点击
readyToExport(currentCount);
}
}, 1000);
}, 1500);
}
// 🟢 阶段二:准备就绪状态
function readyToExport(count) {
btn.innerHTML = `📤 加载完毕(共${count}个),点击导出`;
btn.style.background = "#4CAF50"; // 绿色
btn.style.cursor = "pointer";
btn.disabled = false;
// 重新绑定点击事件,这次点击将触发导出
btn.onclick = executeExport;
}
// 📋 阶段三:执行导出 (用户点击触发,拥有最高权限)
function executeExport() {
const cards = document.querySelectorAll('.bili-video-card');
let markdownContent = `## 📦 B站收藏夹导出 (${new Date().toLocaleDateString()})\n\n`;
let count = 0;
cards.forEach((card) => {
const titleEl = card.querySelector('.bili-video-card__title a');
const imgEl = card.querySelector('.bili-video-card__cover img');
if (titleEl) {
const title = titleEl.innerText.trim();
let link = titleEl.href.split('?')[0];
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src || imgEl.dataset.src || "";
if(coverUrl.startsWith("//")) coverUrl = "https:" + coverUrl;
}
markdownContent += `### ${count + 1}. [${title}](${link})\n`;
if(coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [原始链接](${link})\n\n---\n\n`;
count++;
}
});
// 尝试写入剪贴板
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 成功!\n共 ${count} 个视频已复制到剪贴板。`);
btn.innerHTML = "✨ 已复制";
}).catch(err => {
console.error(err);
// 备用方案:如果 writeText 还是失败,用老方法
fallbackCopy(markdownContent);
});
}
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert("🎉 成功 (兼容模式)!内容已复制。");
}
// 初始绑定
btn.onclick = startAutoScroll;
})();🎮 这次怎么玩?
- 点击 “⬇️ 自动滚屏加载”。
- 看着它滚,喝口水。🍵
- 等按钮变绿,变成 “📤 加载完毕,点击导出”。
- 再次点击 绿色按钮。
- 成功!
快去试试,这次一定能搞定那些成百上千的视频!😎
结果:
改Bug-Version5
这是一个典型的“假动作滚屏”问题。🔍
分析一下发生了什么:
- 现象:脚本只拿到了 40 个视频。这正好是 B站 页面默认加载的第一页数量。
- 原因:脚本确实执行了“向下滚动”的指令,但可能因为滚动的方式太温柔,或者 B站的懒加载感应器(Intersection Observer)没反应过来,导致页面高度没有变化。
- 结果:脚本连续检测了 3 次(约 4.5 秒),发现“高度没变”,误以为已经到底了,于是宣布工头收工。
技术合伙人策略:v0.7 暴力驱动版
这一次,我们要换一种更强硬的滚动策略:
- 不再只滚页面:改为直接找到当前列表的最后一个视频,强制命令浏览器把视角聚焦到它身上 (
scrollIntoView)。这通常能更有效触发“加载更多”。 - 以数量定胜负:不再检测“页面高度”,改为检测**“视频数量”**。只要视频数量还在增加,我们就绝不停止。
🚀 代码清单:FavMiner v0.7 (暴力滚屏版)
请覆盖安装此版本。这个版本在滚动时会更具“侵略性”,并且我会把调试信息直接打印在控制台,方便我们盯着它干活。
JavaScript
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.7 暴力滚屏版)
// @namespace http://tampermonkey.net/
// @version 0.7
// @description 利用 scrollIntoView 暴力触发加载,以数量变化为判定标准
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 初始化按钮
const btn = document.createElement("button");
btn.innerHTML = "⬇️ 强力加载并导出";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 12px 24px; background: linear-gradient(45deg, #FF5722, #FB7299);
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.3s;
`;
// 鼠标交互
btn.onmouseover = () => { if(!btn.disabled) btn.style.transform = "scale(1.05)"; };
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// 🔄 阶段一:暴力滚动逻辑
function startAutoScroll() {
console.clear();
console.log("🚀 开始暴力滚动 (v0.7)...");
btn.onclick = null;
btn.style.background = "#999";
btn.style.cursor = "wait";
let retryCount = 0;
let lastVideoCount = 0;
const maxRetries = 5; // 给它更多机会(5次 x 1.5秒 = 7.5秒无变化才停止)
const timer = setInterval(() => {
// 1. 获取所有卡片
const cards = document.querySelectorAll('.bili-video-card');
const currentCount = cards.length;
console.log(`⏱️ 检查中: 当前 ${currentCount} 个 (上次 ${lastVideoCount} 个) | 重试: ${retryCount}/${maxRetries}`);
btn.innerHTML = `⏳ 暴力加载中... (${currentCount}个)`;
// 2. 暴力操作:找到最后一个卡片,盯着它看!
if (cards.length > 0) {
const lastCard = cards[cards.length - 1];
lastCard.scrollIntoView({ behavior: 'smooth', block: 'end' });
} else {
// 如果没找到卡片,就滚窗口
window.scrollTo(0, document.body.scrollHeight);
}
// 3. 判定逻辑:比对数量
if (currentCount > lastVideoCount) {
// 🎉 发现新视频了!重置计数器
lastVideoCount = currentCount;
retryCount = 0;
console.log("✨ 成功加载新内容!继续...");
} else {
// 🐢 数量没变,可能是网速慢,也可能是到底了
retryCount++;
}
// 4. 结束条件
if (retryCount >= maxRetries) {
clearInterval(timer);
console.log("✅ 判定结束:长时间未检测到新视频。");
readyToExport(currentCount);
}
}, 1500); // 每 1.5 秒执行一次
}
// 🟢 阶段二:准备就绪
function readyToExport(count) {
btn.innerHTML = `📤 加载结束(共${count}个),点击导出`;
btn.style.background = "#4CAF50";
btn.style.cursor = "pointer";
btn.disabled = false;
// 绑定导出事件
btn.onclick = executeExport;
}
// 📋 阶段三:执行导出 (用户点击触发)
function executeExport() {
const cards = document.querySelectorAll('.bili-video-card');
console.log(`🚀 开始导出 ${cards.length} 个视频...`);
let markdownContent = `## 📦 B站收藏夹导出 (${new Date().toLocaleDateString()})\n\n`;
let count = 0;
cards.forEach((card) => {
const titleEl = card.querySelector('.bili-video-card__title a');
const imgEl = card.querySelector('.bili-video-card__cover img');
if (titleEl) {
const title = titleEl.innerText.trim();
let link = titleEl.href.split('?')[0];
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src || imgEl.dataset.src || "";
if(coverUrl.startsWith("//")) coverUrl = "https:" + coverUrl;
}
markdownContent += `### ${count + 1}. [${title}](${link})\n`;
if(coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [原始链接](${link})\n\n---\n\n`;
count++;
}
});
// 写入剪贴板
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 成功!\n共 ${count} 个视频已复制到剪贴板。`);
btn.innerHTML = "✨ 已复制";
}).catch(err => {
console.error(err);
fallbackCopy(markdownContent);
});
}
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert("🎉 成功 (兼容模式)!内容已复制。");
}
btn.onclick = startAutoScroll;
})();🥊 v0.7 使用技巧
- 更新脚本:复制进去保存。
- 观察控制台:运行脚本时,按 F12 打开 Console。你会看到类似
⏱️ 检查中: 当前 40 个...的日志。 - 如果它还是卡住:
- 脚本现在会重试 5 次(约 7.5 秒)。
- 如果在这 7 秒内,你发现屏幕没有动,或者它卡在 40 不动,请手动帮它滚一下鼠标滚轮。只要视频数量一变,脚本就会检测到“诶?有新货!”,然后继续自动干活。
这次应该能把你的收藏夹“榨干”了!期待看到那个数字突破 40!🚀
结果:
改Bug-Version6
发现问题:
还是只有40个视频,b站收藏夹应该不是滚轮吧?是视频下方有一个页码,默认是第一页,要手动点击下一页或者直接点第二页页码才能继续跳转吧?是因为这个逻辑问题写错了吗?
你太敏锐了!你说得完全正确! 🎯
这就是为什么我特别喜欢和你这样的“技术合伙人”合作——当你发现代码逻辑违背了业务事实(Pagination vs Infinite Scroll)时,你能立刻指出来。
我犯了一个经验主义错误:我把 B站首页/动态页的“无限流”逻辑套用到了“收藏夹”页面。实际上,B站网页版的收藏夹确实是传统的“分页模式”(底部有 1, 2, 3... 下一页 的按钮),这也是为什么之前的脚本死活卡在 40 个的原因——因为它根本没去点那一页!
🔄 战略转型:从“滚轮机”变成“自动翻页机”
既然逻辑变了,我们的战术也要变:
- Old (v0.7): 疯狂往下滚 -> 没动静 -> 放弃。
- New (v0.8): 抓取当前页 -> 找到“下一页”按钮 -> 点击 -> 等待刷新 -> 再抓取 -> 直到没有下一页。
这个逻辑更符合传统的爬虫思维。
🚀 代码清单:FavMiner v0.8 (自动翻页版)
请覆盖安装这个版本。这次我们直捣黄龙,脚本会自动帮你一页一页地点,直到把所有库存都搬空。
JavaScript
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.8 自动翻页版)
// @namespace http://tampermonkey.net/
// @version 0.8
// @description 自动识别分页,模拟点击“下一页”,遍历所有页面导出
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 初始化按钮
const btn = document.createElement("button");
btn.innerHTML = "📖 自动翻页并导出";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 12px 24px; background: linear-gradient(45deg, #FF9800, #FB7299);
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.3s;
`;
btn.onmouseover = () => { if(!btn.disabled) btn.style.transform = "scale(1.05)"; };
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// 全局变量存储所有抓到的数据
let allCollectedVideos = [];
// 💤 辅助函数:睡觉(等待页面加载)
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 🔄 核心逻辑:分页爬虫
async function startPaginationCrawl() {
console.clear();
console.log("🚀 开始自动翻页抓取...");
btn.onclick = null;
btn.style.background = "#999";
btn.style.cursor = "wait";
// 重置数据
allCollectedVideos = [];
let pageNum = 1;
let hasNextPage = true;
while (hasNextPage) {
// 1. 抓取当前页的数据
const newVideos = scrapeCurrentPage();
console.log(`📄 第 ${pageNum} 页: 抓取到 ${newVideos.length} 个视频`);
btn.innerHTML = `⏳ 第 ${pageNum} 页... (共 ${allCollectedVideos.length} 个)`;
// 2. 检查是否有“下一页”按钮
// B站分页器的“下一页”按钮通常类名是 .be-pager-next
// 如果不能点,会有 .be-pager-disabled
const nextBtn = document.querySelector('.be-pager-next:not(.be-pager-disabled)') ||
document.querySelector('.q-pager-next:not(.q-pager-disabled)'); // 兼容部分旧版页面
if (nextBtn && nextBtn.style.display !== 'none') {
console.log("👉 点击下一页...");
nextBtn.click();
// 3. 等待加载:这里很重要!
// 点击后 B站是无刷新加载 (SPA),我们给它 2.5 秒缓冲
await sleep(2500);
pageNum++;
} else {
console.log("✅ 没有下一页了,停止翻页。");
hasNextPage = false;
}
}
// 循环结束,准备导出
readyToExport();
}
// 🕵️♂️ 抓取单页数据函数
function scrapeCurrentPage() {
const cards = document.querySelectorAll('.bili-video-card');
const pageVideos = [];
cards.forEach((card) => {
const titleEl = card.querySelector('.bili-video-card__title a');
const imgEl = card.querySelector('.bili-video-card__cover img');
if (titleEl) {
const title = titleEl.innerText.trim();
let link = titleEl.href.split('?')[0];
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src || imgEl.dataset.src || "";
if(coverUrl.startsWith("//")) coverUrl = "https:" + coverUrl;
}
// 🌟 数据去重:防止网络卡顿导致同一页抓了两次
// 我们用链接作为唯一标识
const exists = allCollectedVideos.some(v => v.link === link);
if (!exists) {
const videoData = { title, link, coverUrl };
allCollectedVideos.push(videoData);
pageVideos.push(videoData);
}
}
});
return pageVideos;
}
// 🟢 准备就绪
function readyToExport() {
btn.innerHTML = `📤 抓取完毕(共${allCollectedVideos.length}个),点击导出`;
btn.style.background = "#4CAF50";
btn.style.cursor = "pointer";
btn.disabled = false;
btn.onclick = executeExport;
}
// 📋 执行导出
function executeExport() {
console.log(`🚀 开始生成 Markdown...`);
let markdownContent = `## 📦 B站收藏夹导出 (共 ${allCollectedVideos.length} 个)\n`;
markdownContent += `> 导出时间: ${new Date().toLocaleString()}\n\n`;
allCollectedVideos.forEach((video, index) => {
markdownContent += `### ${index + 1}. [${video.title}](${video.link})\n`;
if(video.coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [原始链接](${video.link})\n\n---\n\n`;
});
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 成功!\n共 ${allCollectedVideos.length} 个视频已复制到剪贴板。`);
}).catch(err => {
console.error(err);
// 备用方案
const textarea = document.createElement('textarea');
textarea.value = markdownContent;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert("🎉 成功 (兼容模式)!");
});
}
btn.onclick = startPaginationCrawl;
})();🥊 v0.8 关键变化
- 点击逻辑:脚本会自动寻找
.be-pager-next(下一页按钮)并点击它。 - 等待机制:每次翻页后,我设置了
await sleep(2500)(等待 2.5 秒)。这是为了防止B站加载慢,导致脚本抓到旧数据。 - 全局存储:我加了一个
allCollectedVideos数组,就像一个购物车,每翻一页就把这页的 20-30 个视频扔进去,最后一起结账。
⚠️ 温馨提示:
- 请从第 1 页开始运行:如果你的收藏夹已经在第 3 页,建议先手动点回第 1 页,再点脚本按钮,这样才能抓全。
- 不要碰鼠标:脚本运行时,你会看到页面自己一页一页地跳,这属于正常现象!
这次我们一定能跑通全流程!期待你的反馈!🤞
结果:
改Bug-Version7
我的回复:结果是这样的,是代码逻辑的问题,还是B站又藏了什么内容?你需要我的操作帮助吗?
太好了,不是代码逻辑的大问题,而是我们又遇到了**“门牌号不对”**的问题!🛑
看你的截图(image_595321.png),脚本成功抓取了第一页,然后汇报 没有下一页了。这意味着:脚本在页面上找了一圈,没找到它认识的“下一页”按钮。
B站的收藏夹页面有好几个版本(旧版、新版、重构版),看来你目前的版本所使用的“分页按钮”的类名,不在我的预设名单(.be-pager-next)里。
🛠️ 技术合伙人请求:我们需要再做一次侦探
只要找到这个按钮的名字,我们立马就能通关!请配合我做以下操作(和上次找标题一样):
- 手动滚动到收藏夹页面的最底部。
- 找到那个 “下一页” 的按钮(或者那个向右的小箭头
>)。 - 鼠标右键点击它,选择 “检查” (Inspect)。
- 截图发给我(要能看清那个按钮的代码,特别是
class="..."这一部分)。
⚡️ 备用方案:如果你想直接动手试
作为 CS 专业的学生,如果你不想截图,也可以直接在浏览器的 Console (控制台) 里输入下面这行代码,回车运行。它会帮你把页面上所有写着“下一页”的东西都打印出来:
JavaScript
// 遍历页面上所有元素,找找谁包含了 "下一页" 或者是箭头符号
document.querySelectorAll('*').forEach(el => {
if (el.innerText === '下一页' || el.innerText === '>' || el.textContent.includes('下一页')) {
// 过滤掉无关的隐藏元素
if(el.offsetParent !== null) {
console.log("🕵️♂️ 嫌疑人:", el);
console.log(" 类名:", el.className);
}
}
});把打印出来的结果(或者按钮的截图)发给我,v0.9 终极版马上就来! 🚀
结果:
改Bug-Version8
这一张截图 价值连城!这就是我们要找的“嫌疑人”!🕵️♂️
🔍 侦探报告:破案了!
根据你提供的截图,B站这次用的不是旧版的 .be-pager,也不是新版的 .q-pager,而是采用了最新的 VUI (Vue UI) 组件系统。
- 原来的剧本:找
.be-pager-next。 - 实际的演员:
<button class="vui_button ... vui_pagenation--btn-side">下一页</button>
之前的脚本就像是去火车站接人,举着“张三”的牌子,但目标人物其实改名叫“VUI_Side_Btn”了,所以自然接不到人。
🚀 FavMiner v0.9 (终极适配版)
这一版代码不仅能识别这个新的按钮,我还加入了一个**“文字锁定”机制**。脚本不再只看衣服(类名),还会直接问名字(检查按钮上的字是不是“下一页”),这样准确率是 100%。
请覆盖安装此代码,这应该是我们的最后一块拼图! 🧩
JavaScript
// ==UserScript==
// @name FavMiner - B站收藏夹导出 (v0.9 终极适配版)
// @namespace http://tampermonkey.net/
// @version 0.9
// @description 适配 B站 VUI 分页组件,精准识别“下一页”按钮,批量导出所有收藏
// @author FavMiner Team
// @match https://space.bilibili.com/*/favlist*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 初始化按钮 (换个颜色代表终极版)
const btn = document.createElement("button");
btn.innerHTML = "📚 启动全量导出";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 9999;
padding: 12px 24px; background: linear-gradient(135deg, #00C853, #B2FF59);
color: #333; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.3); transition: all 0.3s;
`;
btn.onmouseover = () => { if(!btn.disabled) btn.style.transform = "scale(1.05)"; };
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// 全局变量
let allCollectedVideos = [];
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// 🔄 核心逻辑
async function startPaginationCrawl() {
console.clear();
console.log("🚀 FavMiner v0.9 启动!目标:拿下所有分页!");
btn.onclick = null;
btn.style.background = "#999";
btn.style.color = "#fff";
btn.style.cursor = "wait";
allCollectedVideos = [];
let pageNum = 1;
let hasNextPage = true;
while (hasNextPage) {
// 1. 抓取当前页
const newVideos = scrapeCurrentPage();
console.log(`📄 第 ${pageNum} 页: 成功抓取 ${newVideos.length} 个视频`);
btn.innerHTML = `⏳ 正在翻阅第 ${pageNum} 页... (已存 ${allCollectedVideos.length} 个)`;
// 2. 寻找“下一页”按钮 (基于你的截图适配)
// 策略:找到所有侧边按钮,然后看谁的文字里包含 "下一页"
const sideButtons = document.querySelectorAll('.vui_pagenation--btn-side');
let nextBtn = null;
sideButtons.forEach(b => {
// 排除被禁用的按钮 (disabled)
if (b.innerText.includes('下一页') && !b.disabled) {
nextBtn = b;
}
});
// 兼容性:如果 VUI 找不到,试一下老的文本匹配
if (!nextBtn) {
const allBtns = document.querySelectorAll('button, span');
for (let b of allBtns) {
if (b.innerText.trim() === '下一页' || b.textContent.trim() === '下一页') {
if (!b.className.includes('disabled') && b.offsetParent !== null) {
nextBtn = b;
break;
}
}
}
}
// 3. 执行翻页
if (nextBtn) {
console.log("👉 发现下一页,点击跳转...");
nextBtn.click();
// 等待 B站 加载 (给你留了 3 秒缓冲,稳一点)
await sleep(3000);
pageNum++;
} else {
console.log("✅ 找不到可点击的'下一页',或者已经到头了。");
hasNextPage = false;
}
}
readyToExport();
}
// 🕵️♂️ 抓取逻辑 (保持不变)
function scrapeCurrentPage() {
const cards = document.querySelectorAll('.bili-video-card');
const pageVideos = [];
cards.forEach((card) => {
const titleEl = card.querySelector('.bili-video-card__title a');
const imgEl = card.querySelector('.bili-video-card__cover img');
if (titleEl) {
const title = titleEl.innerText.trim();
let link = titleEl.href.split('?')[0];
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src || imgEl.dataset.src || "";
if(coverUrl.startsWith("//")) coverUrl = "https:" + coverUrl;
}
// 去重
const exists = allCollectedVideos.some(v => v.link === link);
if (!exists) {
const videoData = { title, link, coverUrl };
allCollectedVideos.push(videoData);
pageVideos.push(videoData);
}
}
});
return pageVideos;
}
// 🟢 导出准备
function readyToExport() {
btn.innerHTML = `📤 采集完成 (共${allCollectedVideos.length}个),点击导出`;
btn.style.background = "#4CAF50";
btn.style.cursor = "pointer";
btn.disabled = false;
btn.onclick = executeExport;
}
// 📋 导出逻辑
function executeExport() {
let markdownContent = `## 📦 B站收藏夹导出 (共 ${allCollectedVideos.length} 个)\n`;
markdownContent += `> 导出时间: ${new Date().toLocaleString()}\n\n`;
allCollectedVideos.forEach((video, index) => {
markdownContent += `### ${index + 1}. [${video.title}](${video.link})\n`;
if(video.coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [原始链接](${video.link})\n\n---\n\n`;
});
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 大功告成!\n\n共 ${allCollectedVideos.length} 个视频已复制。\n你的收藏夹已经被掏空了!`);
}).catch(err => {
console.error(err);
const textarea = document.createElement('textarea');
textarea.value = markdownContent;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert("🎉 成功 (兼容模式)!");
});
}
btn.onclick = startPaginationCrawl;
})();🥊 决战操作指南
- 回到第一页:请务必手动点一下收藏夹底部的页码
1,确保我们从头开始。 - 点击脚本按钮:它会变成绿色渐变,写着“启动全量导出”。
- 放开鼠标:
- 你会看到页面每隔 3 秒闪一下(这是在翻页)。
- 按钮上的数字会不断增加(40... 80... 120...)。
- 直到翻到最后一页停下来。
这次,我看不到失败的理由!期待你那几百个视频被一次性拿下的壮观场面!😎
结果:
适配小红书 开发&改Bug过程 - 单纯记录不必细看
脚本调整
我的信息告知
小红书里面,我的收藏网址为:https://www.xiaohongshu.com/user/profile/xxxx?tab=fav&subTab=note类似路径,
收到!新的战场已经确定:小红书 (Xiaohongshu) 📕
这个 URL 结构 .../profile/... ?tab=fav 非常标准,确认是你的个人收藏夹页面无疑。
作为你的技术合伙人,在正式写代码之前,我必须先带你进行一次**“战前侦察”**。因为小红书和 B站 有两个巨大的不同,这决定了我们代码的写法:
- 加载模式不同:B站是“分页点击”(点下一页),小红书通常是**“瀑布流滚动”**(一直往下滑,内容自动出来,没有“下一页”按钮)。我们需要确认这一点。
- 防御工事不同:小红书的网页代码里,类名通常是乱码(比如叫
css-1g805x而不是note-card),这会让我们的脚本很难定位。
🕵️♂️ 第一步:侦察任务 (不需要写代码)
请打开你提供的小红书网址,配合我完成以下两个简单的确认,并发截图给我:
任务 A:确认加载方式
请试着往下滑动页面,到底部时:
- 情况 1:页面自动转圈圈,刷出了新内容(无限滚动)。
- 情况 2:到底了不动,需要点一个数字或者“下一页”。 (凭经验大概率是情况 1,但需要你确认)
任务 B:获取“积木”形状 (最关键!) 📸
我们需要找到笔记卡片的类名。请按以下步骤操作(和做 B站 时一样):
- 鼠标悬停在任意一个笔记的标题上。
- 右键 -> 检查 (Inspect)。
- 截图:请把右侧弹出的代码面板截下来发给我。
- 重点:我要看包裹着标题 (
.title)、封面图 (img) 的那个外层大盒子(通常是一个section或div),以及它上面奇怪的类名。
- 重点:我要看包裹着标题 (
一旦拿到这张截图,我就能为你定制出适用于小红书的“自动挖掘机”代码了!等你情报! 😎
信息返回
任务A:页面可以一直向下滑动,会一直刷新内容
任务B:截图已发
太棒了!这次侦察任务完成得非常出色!💯
通过你提供的截图,我发现了一个惊人的好消息:小红书的工程师其实留了一个“后门”给我们!
🕵️♂️ 技术分析报告 (XHS)
- “积木”结构非常清晰:
- 虽然有很多乱码属性(如
data-v-54c83582),但最外层的盒子竟然有一个非常规范的类名:.note-item。这就是我们要找的“黄金积木”!
- 虽然有很多乱码属性(如
- 发现隐藏的“捷径”:
- 请仔细看你截图的第 6 行代码:
<a href="/explore/..." style="display: none;">。 - 这是天大的惊喜! 小红书把最干净的笔记链接(
/explore/id)直接放在了代码里,只是用display: none把它藏起来了。我们的脚本可以直接读取这个隐藏元素,完全不需要去处理那些复杂的重定向链接!
- 请仔细看你截图的第 6 行代码:
- 滚动机制:
- 确认是“无限瀑布流”。这意味着我们需要使用类似 B站 v0.7 的“暴力滚动”逻辑,但要加上**“手动停止”**的功能,因为收藏夹可能深不见底,我们不能让它无限滚下去。
🚀 FavMiner - 小红书专用版 (v1.0)
针对小红书的特性,我设计了**“半自动挖掘模式”**:
- 启动:点击按钮,脚本开始自动往下滚。
- 监控:你会看到数字不断上涨。
- 收网:由你决定什么时候停。当你觉得“差不多了”或者“到底了”,点击同一个按钮(它会变成红色“停止并导出”),脚本就会立刻打包数据。
请新建一个油猴脚本,粘贴以下代码:
JavaScript
// ==UserScript==
// @name FavMiner - 小红书收藏夹导出
// @namespace http://tampermonkey.net/
// @version 0.1
// @description 适配小红书瀑布流,自动滚动,手动停止并导出 Markdown
// @author FavMiner Team
// @match https://www.xiaohongshu.com/user/profile/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// 1. 样式定义 (搞个显眼的小红书红)
const btn = document.createElement("button");
btn.innerHTML = "⛏️ 开始挖掘收藏";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 99999;
padding: 12px 24px; background: #FF2442; /* 小红书品牌色 */
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 36, 66, 0.4);
transition: all 0.3s;
`;
// 悬停效果
btn.onmouseover = () => btn.style.transform = "scale(1.05)";
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// 全局控制变量
let isScrolling = false;
let timer = null;
let collectedCount = 0;
// 🔄 主逻辑:点击按钮切换 开始/停止
btn.onclick = function() {
if (!isScrolling) {
// === 状态:开始滚动 ===
isScrolling = true;
btn.innerHTML = "🛑 正在挖掘... (点击停止并导出)";
btn.style.background = "#333"; // 变黑表示正在录制
startScroll();
} else {
// === 状态:停止并导出 ===
isScrolling = false;
clearInterval(timer); // 停止定时器
exportData(); // 执行导出
}
};
// 📜 滚动函数
function startScroll() {
console.log("🚀 开始自动滚动...");
timer = setInterval(() => {
// 1. 暴力滚到底部
window.scrollTo(0, document.body.scrollHeight);
// 2. 实时统计当前页面有多少个笔记
// 使用你截图中确认的 .note-item 类名
const items = document.querySelectorAll('.note-item');
collectedCount = items.length;
// 3. 更新按钮上的数字
btn.innerHTML = `🛑 已发现 ${collectedCount} 篇笔记 (点击导出)`;
}, 1000); // 每1秒滚一次,给小红书一点加载时间
}
// 📤 导出函数 (核心解析逻辑)
function exportData() {
console.log("🚀 准备导出数据...");
btn.innerHTML = "⏳ 正在生成 Markdown...";
const items = document.querySelectorAll('.note-item');
let markdownContent = `## 📕 小红书收藏夹导出 (${new Date().toLocaleDateString()})\n\n`;
let validCount = 0;
items.forEach((item, index) => {
// 🕵️♂️ 1. 找标题
// 路径:.footer -> .title -> span (基于你的截图)
const titleEl = item.querySelector('.footer .title span');
// 🕵️♂️ 2. 找链接 (利用隐藏的捷径!)
// 优先找那个 display: none 的 href="/explore/..."
let linkEl = item.querySelector('a[href^="/explore"]');
// 如果隐藏的找不到,就找封面上的链接作为备选
if (!linkEl) {
linkEl = item.querySelector('a.cover');
}
// 🕵️♂️ 3. 找封面图
// 通常在 cover 里面会有 img 或者 style="background-image"
// 小红书为了防爬,有时候用 div 的背景图,我们都试一下
const imgEl = item.querySelector('.cover img');
if (titleEl && linkEl) {
const title = titleEl.innerText.trim();
let link = linkEl.getAttribute('href'); // 获取相对路径
// 补全域名
if (link && !link.startsWith('http')) {
link = `https://www.xiaohongshu.com${link}`;
}
// 尝试获取封面
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src;
} else {
// 备选:有时候是背景图
const coverDiv = item.querySelector('.cover');
if (coverDiv && coverDiv.style.backgroundImage) {
coverUrl = coverDiv.style.backgroundImage.slice(5, -2);
}
}
// 📝 拼接 Markdown
markdownContent += `### ${validCount + 1}. [${title}](${link})\n`;
if(coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [笔记链接](${link})\n\n---\n\n`;
validCount++;
}
});
// 📋 写入剪贴板
if (navigator.clipboard) {
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 成功导出 ${validCount} 篇笔记!\n内容已复制到剪贴板。`);
btn.innerHTML = "✅ 导出完成";
btn.style.background = "#FF2442";
// 3秒后恢复初始状态
setTimeout(() => {
btn.innerHTML = "⛏️ 继续挖掘";
isScrolling = false;
}, 3000);
}).catch(err => {
alert("复制失败,请查看控制台。");
});
} else {
alert("你的浏览器不支持自动复制,请手动复制控制台输出。");
}
}
})();🎮 操作指南
- 安装并刷新:保存脚本,刷新小红书收藏页面。
- 点击红色按钮:“⛏️ 开始挖掘收藏”。
- 观察页面:
- 页面会自动向下滑动。
- 按钮上的数字会一直跳动(10... 20... 50...)。
- 注意:小红书有时候加载会卡顿,如果数字不动了,你可以手动帮它滚两下,它会继续识别。
- 收网:
- 当你看到所有想导出的内容都加载出来了(或者到底了)。
- 再次点击 那个变成了黑色的按钮。
- 粘贴:去你的笔记软件里
Ctrl+V。
这次我们利用了小红书代码里的“隐藏捷径”,应该能抓取到非常干净的链接!快去试试吧!
改Bug-Version1
bug如下:1.在我点击开始之后,页面一直是14篇已复制,然后到最后的几个收藏作品时,数量变为8然后是6,我怀疑是不是只会记录当前页面内的作品?2.控制台一直有截图的这些报错
你的直觉非常敏锐!🕵️♂️ 你完全说对了。
📉 问题分析:为什么数量会“缩水”?
你遇到的现象(14 -> 8 -> 6),是因为小红书使用了前端开发中一种叫 “虚拟列表” (Virtual Scrolling) 的技术。
- 原理:为了省内存,浏览器里只渲染你当前屏幕看得到的那几个积木。当你往下滑时,它会把上面的积木拆掉(从 DOM 中删除),填到下面去。
- 后果:之前的 v1.0 脚本太“老实”了,它只在最后那一刻数一下屏幕上有几个积木。因为上面的积木已经被拆了,所以它数出来的数量变少了。
至于控制台的那些红色报错 (400 Bad Request),那是小红书的后台埋点系统在报错(可能是因为滚动太快,它的日志发送失败了),这对我们的导出功能没有影响,直接忽略即可。
🛠️ 解决方案:FavMiner v0.2(边走边捡版)
我们需要改变策略:不要等到最后再数,而是一边滚,一边就把看到的积木捡到篮子里(存起来)。 这样即使屏幕上的积木消失了,我们篮子里的数据还在。
v1.1 核心升级点:
- 实时入库:滚动过程中,每秒钟都在抓取当前屏幕上的数据。
- 自动去重:利用笔记的链接作为“身份证”,如果篮子里有了就不重复捡。
- 数据持久化:解决“回头看”数据消失的问题。
🚀 请覆盖安装以下代码:
JavaScript
// ==UserScript==
// @name FavMiner - 小红书收藏夹导出 (v0.2 虚拟列表修复版)
// @namespace http://tampermonkey.net/
// @version 0.2
// @description 独立于B站脚本。适配小红书虚拟滚动,实时缓存数据,解决“滚下去上面就消失”的问题。
// @author FavMiner Team
// @match https://www.xiaohongshu.com/user/profile/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
// === 1. UI 部分 ===
const btn = document.createElement("button");
btn.innerHTML = "⛏️ 开始挖掘 (v0.2)";
btn.style.cssText = `
position: fixed; bottom: 50px; right: 50px; z-index: 99999;
padding: 12px 24px; background: #FF2442;
color: white; border: none; border-radius: 25px;
font-weight: bold; font-size: 14px; cursor: pointer;
box-shadow: 0 4px 15px rgba(255, 36, 66, 0.4);
transition: all 0.3s;
`;
btn.onmouseover = () => btn.style.transform = "scale(1.05)";
btn.onmouseout = () => btn.style.transform = "scale(1)";
document.body.appendChild(btn);
// === 2. 数据存储部分 (The Basket) ===
// 这里的 Map 是关键!它像一个数据库,只要脚本不刷新页面,数据就一直在
// Key = 笔记链接 (用于去重), Value = 笔记详情对象
let collectedNotes = new Map();
let isScrolling = false;
let timer = null;
// === 3. 主交互逻辑 ===
btn.onclick = function() {
if (!isScrolling) {
// ---> 启动
isScrolling = true;
btn.innerHTML = "🛑 挖掘中... (点击停止)";
btn.style.background = "#333";
startMining();
} else {
// ---> 停止并导出
isScrolling = false;
clearInterval(timer);
exportData();
}
};
// === 4. 核心挖掘机逻辑 ===
function startMining() {
console.log("🚀 v0.2 挖掘机启动...");
timer = setInterval(() => {
// A. 滚到底部 (触发小红书加载新内容)
window.scrollTo(0, document.body.scrollHeight);
// B. 抓取当前屏幕上可见的所有积木
scrapeVisibleItems();
// C. 实时更新按钮上的数字 (显示篮子里的总数,而不是屏幕上的数)
// 这样你就能看到数字只增不减了
btn.innerHTML = `🛑 已采集 ${collectedNotes.size} 篇 (点击导出)`;
}, 800); // 0.8秒滚一次
}
// === 5. 抓取当前视口函数 ===
function scrapeVisibleItems() {
// 获取当前屏幕上渲染出来的卡片
const items = document.querySelectorAll('.note-item');
items.forEach((item) => {
// --- 解析逻辑 (和 v0.1 一样) ---
const titleEl = item.querySelector('.footer .title span');
// 链接策略:优先找隐藏的 explore 链接,找不到找封面链接
let linkEl = item.querySelector('a[href^="/explore"]');
if (!linkEl) linkEl = item.querySelector('a.cover');
const imgEl = item.querySelector('.cover img');
if (titleEl && linkEl) {
const title = titleEl.innerText.trim();
let link = linkEl.getAttribute('href');
// 补全链接
if (link && !link.startsWith('http')) {
link = `https://www.xiaohongshu.com${link}`;
}
// 封面图策略
let coverUrl = "";
if (imgEl) {
coverUrl = imgEl.src;
} else {
const coverDiv = item.querySelector('.cover');
if (coverDiv && coverDiv.style.backgroundImage) {
coverUrl = coverDiv.style.backgroundImage.slice(5, -2);
}
}
// --- 关键去重逻辑 ---
// 只有篮子里没有这个链接时,才放进去
if (!collectedNotes.has(link)) {
collectedNotes.set(link, { title, link, coverUrl });
console.log(`✅ [新发现] ${title}`);
}
}
});
}
// === 6. 导出函数 ===
function exportData() {
console.log("🚀 准备导出...");
btn.innerHTML = "⏳ 生成 Markdown...";
let markdownContent = `## 📕 小红书收藏夹导出 (共 ${collectedNotes.size} 篇)\n`;
markdownContent += `> 导出时间: ${new Date().toLocaleString()}\n\n`;
let index = 0;
// 从 Map 中把攒好的数据都倒出来
collectedNotes.forEach((note) => {
markdownContent += `### ${index + 1}. [${note.title}](${note.link})\n`;
if(note.coverUrl) markdownContent += `\n`;
markdownContent += `> 🔗 [笔记链接](${note.link})\n\n---\n\n`;
index++;
});
// 写入剪贴板
if (navigator.clipboard) {
navigator.clipboard.writeText(markdownContent).then(() => {
alert(`🎉 成功导出 ${collectedNotes.size} 篇笔记!\n内容已复制。`);
resetBtn();
}).catch(err => {
fallbackCopy(markdownContent);
});
} else {
fallbackCopy(markdownContent);
}
}
// 兼容旧浏览器的复制方法
function fallbackCopy(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
alert(`🎉 成功 (兼容模式)!已导出 ${collectedNotes.size} 篇。`);
resetBtn();
}
function resetBtn() {
btn.innerHTML = "✅ 完成";
btn.style.background = "#FF2442";
setTimeout(() => {
btn.innerHTML = "⛏️ 继续挖掘 (v0.2)";
isScrolling = false;
}, 3000);
}
})();🎮 这次怎么玩?
- 刷新页面。
- 点击 “⛏️ 开始挖掘”。
- 盯着那个数字:你会发现即使页面滚得很远,上面的内容消失了,按钮上的数字只会增加,不会减少(比如 14 -> 20 -> 35...)。
- 当你觉得滚到底了(或者数字不再变了),点击黑色按钮停止。
这次应该稳了!试试看那个数字能不能一直涨上去? 📈

