Skip to content

基于油猴的收藏夹导出脚本FavMiner

项目说明(开发前做好这部分内容)

项目卡片

项目名称基于油猴的收藏夹导出脚本FavMiner
项目状态🟢 稳定使用
核心功能(一句话介绍)基于油猴脚本的 B 站/小红书/抖音收藏夹一键导出 Markdown 工具
核心技术(技术栈介绍)Tampermonkey, JS

需求背景

B站、小红书收藏夹里的优质内容(视频/笔记)难以检索和复习,长期“吃灰”。所以使用一个脚本爬取B站、小红书、抖音平台个人账号中的收藏夹内容,一键提取收藏夹内容,生成 Markdown 笔记

项目功能

在用户进入对应平台网页并登录个人账号之后,进入收藏夹所在页面,点击脚本构建的按钮,由脚本自动爬取收藏夹内容,最终结束爬取完毕,并提醒用户,将爬取的内容生成markdown格式并粘贴到剪贴板

技术方案

JavaScript (ES6+)

油猴脚本(Tampermonkey UserScript):开发成本低、更新即时、无需打包发布、直接操作 DOM

如果需要则贴图、不需要可以不记录

项目开发

开发流程

模块划分

分为三个模块:B站端、小红书端、抖音端

真实流程

仅需要开发一个脚本文件.JS,并植入油猴插件中

开发Prompt

B站端:

bash
# 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 列表 (`![封面](url) [标题](link)`)。
   - 调用 `navigator.clipboard.writeText` 将结果写入剪贴板,并弹出成功提示。

# Requirements
- 代码结构清晰,使用 ES6+ 语法 (async/await)。
- 必须处理 `NotAllowedError` 剪贴板权限问题 (确保导出动作由用户点击触发,或者在翻页结束后提供一个“点击复制”的按钮)。
- 增加控制台日志,实时打印当前抓取进度。

小红书端:

bash
# 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` 两种情况。
- 代码鲁棒性强,能够处理网络卡顿导致的加载延迟。

抖音端:

bash
暂未实现

开发结果

产品演示

B站端:

image-20251223201406029

小红书端:

image-20251223202405968

抖音端:

暂未实现

最终代码

B站端:

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 += `![封面](${video.coverUrl})\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;

})();

小红书端:

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 += `![封面](${note.coverUrl})\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 辅助维护

  1. F12 打开开发者工具,截图你需要抓取的元素(如标题、按钮)的 HTML 代码结构
  2. 把截图和旧代码发给 AI(如 ChatGPT/Gemini)
  3. Prompt(提示词):“B站/小红书 改版了,这是新的网页结构截图,这是我之前的油猴脚本,请帮我更新选择器,修复脚本。

附录:迭代日志

适配B站 开发&改Bug过程 - 单纯记录不必细看

🚀 第一步:Hello World 级别测试

既然决定了方向,我们立马动手!我们先不涉及复杂的爬虫逻辑,先定一个小目标:在 B站 的页面上,凭空变出一个“导出按钮”来。

操作步骤:

  1. 确保你的浏览器已经安装了 Tampermonkey (油猴) 扩展。
  2. 点击油猴图标 -> “添加新脚本”。
  3. 复制下面的代码,覆盖编辑器里的内容,保存 (Ctrl+S)。
  4. 打开任意一个 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);

})();

💡 技术合伙人的复盘

这段代码虽然短,但它已经验证了我们项目的核心逻辑

  1. 权限获取:通过 @match,我们告诉油猴只在 B站视频页运行。
  2. UI 注入:我们用 document.createElement 成功“入侵”了 B站的页面,放上了我们自己的 UI。
  3. 数据交互:我们成功读取了 document.title,这意味着以后读取收藏夹列表里的数据也是同样的道理!

👉 下一步行动: 你先试着运行这个脚本。如果成功看到了蓝色的按钮并弹出了标题,请告诉我!

结果:image-20251219174357348

🚀 正式开发:第二步 - 数据的“透视眼”

既然我们在 B站的收藏夹页面(space.bilibili.com),接下来的任务就是:让脚本学会“看”懂页面上的视频列表。

我们需要找到页面上每一个视频卡片的“特征”(CSS 类名),然后把它们批量抓取出来。

🎯 本次任务目标

不是立刻导出,而是先打印在控制台 (Console) 里,确保我们抓到了正确的数据,没有漏掉,也没有抓错。

🛠️ 积木代码 (Step 2)

请更新你的油猴脚本代码,保存并刷新 B站收藏夹页面:

javascript
// ==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);

})();

🧐 你的任务

  1. 进入 B站 -> 个人空间 -> 收藏夹 (确保 URL 里有 favlist)。
  2. 刷新页面,点击粉色的 “🔍 分析当前列表” 按钮。
  3. 按 F12 打开开发者工具,切换 to "Console" (控制台) 标签。
  4. 告诉我结果:你看到一排排的标题和链接了吗?

如果成功了,我们就离“一键导出”只差最后一步拼接字符串了!💪

结果:image-20251220151527179image-20251219174801999

**错误原因:**这在爬虫开发里叫“选择器失效” —— 也就是我们想找的“积木块”改名字了,或者换了个形状。

这正是前端开发最“好玩”的地方: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);
})();

方案二:如果你想学习如何自己破解 (进阶) 🧐

如果上面的脚本还是不行,或者你想掌握“寻找积木”的原理,请按下面的步骤操作,把结果截图发给我:

  1. 在页面上,把鼠标移动到任意一个视频的标题上。
  2. 点击鼠标右键,选择 “检查” (Inspect)
  3. 浏览器的右侧(或下方)会弹出一个全是代码的窗口,并且会高亮显示你刚才选中的那一行代码。
  4. 请截一张图,要包含高亮代码的那一部分,以及它的上一级(父元素)。

有了这张图,我就能直接看到 B站 在你的电脑上把视频列表命名成什么了,然后我们就能“一击必杀”!

结果:image-20251219175203244image-20251220151552219

改Bug - Version2

仔细看你的控制台截图(image_5878ee.png),我们其实已经成功了一大半

  1. 容器定位成功:脚本准确找到了 40 个视频(matched: .bili-video-card)。
  2. 数据提取失败:脚本报出“没抓到标题”,说明虽然我们拿到了“盒子”,但我们手里的“钥匙”(标题的选择器)打不开盒子内部的结构。

B站的前端组件化非常复杂,.bili-video-card 内部的结构经常变动。为了最快解决问题,直接由你来“透视”内部结构是最高效的!


🛠️ 技术合伙人指令:手动定位(Inspect)指南

请按照以下步骤操作,这将直接决定我们下一版代码的写法:

  1. 保持当前页面不动(就在你截图的这个 B站收藏夹页面)。
  2. 鼠标悬停在任意一个视频的标题文字上(注意是标题,不是封面图)。
  3. 右键点击标题,选择菜单最后的 “检查” (Inspect)
  4. 浏览器会弹出一个 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站的新版卡片结构可能变成了以下几种情况之一:

  1. 层级变了:标题可能包在了一个 <span class="bili-video-card__info--tit"> 里,而不是 <a> 标签上。
  2. 类名变了:可能变成了简写(如 .tit)或者完全不同的名字(如 .video-name)。
  3. Shadow DOM:虽然在收藏夹列表不太可能,但也有可能是组件隔离导致无法直接 query。

只要你给我看一眼那个标题的 HTML 结构,我们马上就能写出 v0.4 终极版脚本!等待你的情报!

结果:随机悬停在一个视频标题上,右键点击检查image-20251219175644404

改Bug-Version3

不用再试错了,现在的局势已经完全明朗。作为你的技术合伙人,我通过你提供的截图(image_58cf7f.png)直接锁定了问题的核心:

🕵️‍♂️ 侦探报告:为什么之前找不到?

看截图里的代码结构:

  1. 外层容器确实是 .bili-video-card(我们之前的脚本找对了这一层)。
  2. 标题的真身藏在 .bili-video-card__title 这个类名下面,而且里面包裹着一个 <a> 标签。
  3. 之前的错误:我们之前在找 .bili-video-card__info--tit,名字差之毫厘,谬以千里。

🚀 最终方案:v0.4 完工版脚本

既然“积木”的形状都看清了,我们直接进入实战阶段。 这一次,我们不再只是打印日志,而是直接生成 Markdown 笔记并复制到你的剪贴板

功能清单:

  1. 自动识别 B站 新版收藏夹列表。
  2. 提取 标题 + 链接 + 封面图
  3. 自动格式化为 Markdown。
  4. 一键复制到剪贴板,你直接去博客里 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) [标题](URL)
                // 或者更适合博客的卡片布局
                markdownContent += `### ${count + 1}. [${title}](${link})\n`;
                if(coverUrl) {
                    markdownContent += `![封面预览](${coverUrl})\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);

})();

📝 你的下一步行动

  1. 更新代码:把油猴里的代码全选替换成上面的 v0.4。
  2. 刷新页面:回到 B站收藏夹页面,刷新。
  3. 点击按钮:右下角会出现一个更好看的渐变色按钮。
  4. 粘贴验证:看到“成功导出”的提示后,去你的 Notion、Obsidian 或者任意文本编辑器里粘贴,看看效果是不是你要的!

如果这次成功了,那我们的 FavMiner v1.0 就算是 MVP(最小可行性产品)发布成功了!期待你的好消息!😎

结果:image-20251219180106861

太棒了!🥂 恭喜你,FavMiner (v1.0) 正式开发完成!

看到你发来的截图里那个“成功导出 36 个视频”的弹窗,还有你在 Markdown 里确认内容的反馈,这就意味着我们已经成功拿下了 B站收藏夹这块阵地。

作为技术合伙人,我必须为你点赞:从最初的 console.log 测试,到中间遇到选择器难题时的冷静排查,再到最后的一键导出,你完整走完了一个**“发现问题 -> 分析结构 -> 编写脚本 -> 解决Bug -> 交付产品”**的标准开发流程。这对于大四学生来说,是非常宝贵的实战经验。


🏁 阶段性复盘:B站导出助手

  • 状态:✅ 已交付 (MVP版本)
  • 核心功能
    • 智能识别新版 B站卡片结构 (.bili-video-card).
    • 提取 标题、链接、高清封面。
    • 自动净化链接参数。
    • 生成 Markdown 并写入剪贴板。
  • 当前局限
    • (温馨提示) 目前脚本只能导出当前屏幕已经加载出来的视频。如果你收藏夹有几百个视频,你需要先手动往下滚动页面,把它们都刷出来,再点导出按钮。

🗺️ 下一步计划:我们要去哪里?

既然 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 += `![封面](${coverUrl})\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;

})();

🎮 使用说明

  1. 更新脚本:老规矩,复制粘贴保存。
  2. 刷新页面:回到收藏夹,按钮应该变成了 “⬇️ 自动加载并导出”
  3. 点击它
    • 此时不要操作鼠标滚轮,把手放开。🙌
    • 你会看到页面一顿一顿地往下跳,右下角按钮上的数字会一直跳动(20... 40... 60...)。
    • 等它到底后,大概会停顿 3-5 秒(为了确认真的到底了),然后弹出成功的提示框。

快去试试你的大收藏夹吧!看看这次能不能一次性抓几百个出来?😎

结果:image-20251219180646814

改Bug-Version4

这是一个非常有价值的报错截图!🚨 所有的失败都是通往成功的台阶。

🕵️‍♂️ 问题诊断:为什么会报错?

看控制台的红字 NotAllowedError: Write permission denied,翻译过来就是“写入权限被拒绝”。

  • 原因:现代浏览器(Chrome/Edge)为了安全,规定 “只有用户亲自点击的那一瞬间,才允许脚本修改剪贴板”
  • 刚才发生了什么
    1. 你点击了按钮(获得了权限)。
    2. 脚本开始滚屏,滚了好几秒(这时候,浏览器觉得“刚才那个点击已经过期了”)。
    3. 脚本尝试复制(浏览器说:你现在没有用户点击,你是自己在后台偷偷运行的,驳回!🚫)。

🛠️ 解决方案:v0.6 (两步走策略)

我们不能让脚本“自作主张”去复制,必须把控制权交还给你

新流程:

  1. 点击按钮 -> 脚本只负责自动滚屏
  2. 滚完后 -> 按钮变绿,提示“✅ 准备就绪,点击导出”。
  3. 你再次点击 -> 这一次是新鲜热乎的点击,复制绝对成功!

🚀 代码清单: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 += `![封面](${coverUrl})\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;

})();

🎮 这次怎么玩?

  1. 点击 “⬇️ 自动滚屏加载”。
  2. 看着它滚,喝口水。🍵
  3. 等按钮变绿,变成 “📤 加载完毕,点击导出”。
  4. 再次点击 绿色按钮。
  5. 成功!

快去试试,这次一定能搞定那些成百上千的视频!😎

结果:image-20251219181310214image-20251219181246782

改Bug-Version5

这是一个典型的“假动作滚屏”问题。🔍

分析一下发生了什么:

  1. 现象:脚本只拿到了 40 个视频。这正好是 B站 页面默认加载的第一页数量。
  2. 原因:脚本确实执行了“向下滚动”的指令,但可能因为滚动的方式太温柔,或者 B站的懒加载感应器(Intersection Observer)没反应过来,导致页面高度没有变化。
  3. 结果:脚本连续检测了 3 次(约 4.5 秒),发现“高度没变”,误以为已经到底了,于是宣布工头收工。

技术合伙人策略:v0.7 暴力驱动版

这一次,我们要换一种更强硬的滚动策略:

  1. 不再只滚页面:改为直接找到当前列表的最后一个视频,强制命令浏览器把视角聚焦到它身上 (scrollIntoView)。这通常能更有效触发“加载更多”。
  2. 以数量定胜负:不再检测“页面高度”,改为检测**“视频数量”**。只要视频数量还在增加,我们就绝不停止。

🚀 代码清单: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 += `![封面](${coverUrl})\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 使用技巧

  1. 更新脚本:复制进去保存。
  2. 观察控制台:运行脚本时,按 F12 打开 Console。你会看到类似 ⏱️ 检查中: 当前 40 个... 的日志。
  3. 如果它还是卡住
    • 脚本现在会重试 5 次(约 7.5 秒)。
    • 如果在这 7 秒内,你发现屏幕没有动,或者它卡在 40 不动,请手动帮它滚一下鼠标滚轮。只要视频数量一变,脚本就会检测到“诶?有新货!”,然后继续自动干活。

这次应该能把你的收藏夹“榨干”了!期待看到那个数字突破 40!🚀

结果:image-20251219181600451image-20251219181616124

改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 += `![封面](${video.coverUrl})\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 关键变化

  1. 点击逻辑:脚本会自动寻找 .be-pager-next(下一页按钮)并点击它。
  2. 等待机制:每次翻页后,我设置了 await sleep(2500)(等待 2.5 秒)。这是为了防止B站加载慢,导致脚本抓到旧数据。
  3. 全局存储:我加了一个 allCollectedVideos 数组,就像一个购物车,每翻一页就把这页的 20-30 个视频扔进去,最后一起结账。

⚠️ 温馨提示

  • 请从第 1 页开始运行:如果你的收藏夹已经在第 3 页,建议先手动点回第 1 页,再点脚本按钮,这样才能抓全。
  • 不要碰鼠标:脚本运行时,你会看到页面自己一页一页地跳,这属于正常现象!

这次我们一定能跑通全流程!期待你的反馈!🤞

结果:image-20251219181935901

改Bug-Version7

我的回复:结果是这样的,是代码逻辑的问题,还是B站又藏了什么内容?你需要我的操作帮助吗?

太好了,不是代码逻辑的大问题,而是我们又遇到了**“门牌号不对”**的问题!🛑

看你的截图(image_595321.png),脚本成功抓取了第一页,然后汇报 没有下一页了。这意味着:脚本在页面上找了一圈,没找到它认识的“下一页”按钮。

B站的收藏夹页面有好几个版本(旧版、新版、重构版),看来你目前的版本所使用的“分页按钮”的类名,不在我的预设名单(.be-pager-next)里。

🛠️ 技术合伙人请求:我们需要再做一次侦探

只要找到这个按钮的名字,我们立马就能通关!请配合我做以下操作(和上次找标题一样):

  1. 手动滚动到收藏夹页面的最底部。
  2. 找到那个 “下一页” 的按钮(或者那个向右的小箭头 >)。
  3. 鼠标右键点击它,选择 “检查” (Inspect)
  4. 截图发给我(要能看清那个按钮的代码,特别是 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 终极版马上就来! 🚀

结果:image-20251219182210419

改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 += `![封面](${video.coverUrl})\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. 回到第一页:请务必手动点一下收藏夹底部的页码 1,确保我们从头开始。
  2. 点击脚本按钮:它会变成绿色渐变,写着“启动全量导出”。
  3. 放开鼠标
    • 你会看到页面每隔 3 秒闪一下(这是在翻页)。
    • 按钮上的数字会不断增加(40... 80... 120...)。
    • 直到翻到最后一页停下来。

这次,我看不到失败的理由!期待你那几百个视频被一次性拿下的壮观场面!😎

结果:image-20251219182608753image-20251219182545422

适配小红书 开发&改Bug过程 - 单纯记录不必细看

脚本调整

我的信息告知

小红书里面,我的收藏网址为:https://www.xiaohongshu.com/user/profile/xxxx?tab=fav&subTab=note类似路径,

收到!新的战场已经确定:小红书 (Xiaohongshu) 📕

这个 URL 结构 .../profile/... ?tab=fav 非常标准,确认是你的个人收藏夹页面无疑。

作为你的技术合伙人,在正式写代码之前,我必须先带你进行一次**“战前侦察”**。因为小红书和 B站 有两个巨大的不同,这决定了我们代码的写法:

  1. 加载模式不同:B站是“分页点击”(点下一页),小红书通常是**“瀑布流滚动”**(一直往下滑,内容自动出来,没有“下一页”按钮)。我们需要确认这一点。
  2. 防御工事不同:小红书的网页代码里,类名通常是乱码(比如叫 css-1g805x 而不是 note-card),这会让我们的脚本很难定位。

🕵️‍♂️ 第一步:侦察任务 (不需要写代码)

请打开你提供的小红书网址,配合我完成以下两个简单的确认,并发截图给我

任务 A:确认加载方式

请试着往下滑动页面,到底部时:

  • 情况 1:页面自动转圈圈,刷出了新内容(无限滚动)。
  • 情况 2:到底了不动,需要点一个数字或者“下一页”。 (凭经验大概率是情况 1,但需要你确认)

任务 B:获取“积木”形状 (最关键!) 📸

我们需要找到笔记卡片的类名。请按以下步骤操作(和做 B站 时一样):

  1. 鼠标悬停在任意一个笔记的标题上。
  2. 右键 -> 检查 (Inspect)
  3. 截图:请把右侧弹出的代码面板截下来发给我。
    • 重点:我要看包裹着标题 (.title)、封面图 (img) 的那个外层大盒子(通常是一个 sectiondiv),以及它上面奇怪的类名。

一旦拿到这张截图,我就能为你定制出适用于小红书的“自动挖掘机”代码了!等你情报! 😎

信息返回

任务A:页面可以一直向下滑动,会一直刷新内容

任务B:截图已发image-20251219183637302

太棒了!这次侦察任务完成得非常出色!💯

通过你提供的截图,我发现了一个惊人的好消息:小红书的工程师其实留了一个“后门”给我们!

🕵️‍♂️ 技术分析报告 (XHS)

  1. “积木”结构非常清晰
    • 虽然有很多乱码属性(如 data-v-54c83582),但最外层的盒子竟然有一个非常规范的类名:.note-item。这就是我们要找的“黄金积木”!
  2. 发现隐藏的“捷径”
    • 请仔细看你截图的第 6 行代码:<a href="/explore/..." style="display: none;">
    • 这是天大的惊喜! 小红书把最干净的笔记链接(/explore/id)直接放在了代码里,只是用 display: none 把它藏起来了。我们的脚本可以直接读取这个隐藏元素,完全不需要去处理那些复杂的重定向链接!
  3. 滚动机制
    • 确认是“无限瀑布流”。这意味着我们需要使用类似 B站 v0.7 的“暴力滚动”逻辑,但要加上**“手动停止”**的功能,因为收藏夹可能深不见底,我们不能让它无限滚下去。

🚀 FavMiner - 小红书专用版 (v1.0)

针对小红书的特性,我设计了**“半自动挖掘模式”**:

  1. 启动:点击按钮,脚本开始自动往下滚。
  2. 监控:你会看到数字不断上涨。
  3. 收网由你决定什么时候停。当你觉得“差不多了”或者“到底了”,点击同一个按钮(它会变成红色“停止并导出”),脚本就会立刻打包数据。

请新建一个油猴脚本,粘贴以下代码:

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 += `![封面预览](${coverUrl})\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("你的浏览器不支持自动复制,请手动复制控制台输出。");
        }
    }

})();

🎮 操作指南

  1. 安装并刷新:保存脚本,刷新小红书收藏页面。
  2. 点击红色按钮:“⛏️ 开始挖掘收藏”。
  3. 观察页面
    • 页面会自动向下滑动。
    • 按钮上的数字会一直跳动(10... 20... 50...)。
    • 注意:小红书有时候加载会卡顿,如果数字不动了,你可以手动帮它滚两下,它会继续识别。
  4. 收网
    • 当你看到所有想导出的内容都加载出来了(或者到底了)。
    • 再次点击 那个变成了黑色的按钮。
  5. 粘贴:去你的笔记软件里 Ctrl+V

这次我们利用了小红书代码里的“隐藏捷径”,应该能抓取到非常干净的链接!快去试试吧!

改Bug-Version1

bug如下:1.在我点击开始之后,页面一直是14篇已复制,然后到最后的几个收藏作品时,数量变为8然后是6,我怀疑是不是只会记录当前页面内的作品?2.控制台一直有截图的这些报错image-20251219184245663

你的直觉非常敏锐!🕵️‍♂️ 你完全说对了。

📉 问题分析:为什么数量会“缩水”?

你遇到的现象(14 -> 8 -> 6),是因为小红书使用了前端开发中一种叫 “虚拟列表” (Virtual Scrolling) 的技术。

  • 原理:为了省内存,浏览器里只渲染你当前屏幕看得到的那几个积木。当你往下滑时,它会把上面的积木拆掉(从 DOM 中删除),填到下面去。
  • 后果:之前的 v1.0 脚本太“老实”了,它只在最后那一刻数一下屏幕上有几个积木。因为上面的积木已经被拆了,所以它数出来的数量变少了。

至于控制台的那些红色报错 (400 Bad Request),那是小红书的后台埋点系统在报错(可能是因为滚动太快,它的日志发送失败了),这对我们的导出功能没有影响,直接忽略即可。


🛠️ 解决方案:FavMiner v0.2(边走边捡版)

我们需要改变策略:不要等到最后再数,而是一边滚,一边就把看到的积木捡到篮子里(存起来)。 这样即使屏幕上的积木消失了,我们篮子里的数据还在。

v1.1 核心升级点:

  1. 实时入库:滚动过程中,每秒钟都在抓取当前屏幕上的数据。
  2. 自动去重:利用笔记的链接作为“身份证”,如果篮子里有了就不重复捡。
  3. 数据持久化:解决“回头看”数据消失的问题。

🚀 请覆盖安装以下代码:

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 += `![封面](${note.coverUrl})\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);
    }

})();

🎮 这次怎么玩?

  1. 刷新页面
  2. 点击 “⛏️ 开始挖掘”
  3. 盯着那个数字:你会发现即使页面滚得很远,上面的内容消失了,按钮上的数字只会增加,不会减少(比如 14 -> 20 -> 35...)。
  4. 当你觉得滚到底了(或者数字不再变了),点击黑色按钮停止。

这次应该稳了!试试看那个数字能不能一直涨上去? 📈

结果:image-20251219184949751image-20251219185009189

基于 VitePress 搭建 | 记录生活与代码