这篇文章记录我写的一个油猴脚本:给 B 站收藏夹整理这件事加上 AI 能力,让“堆着不看”的收藏更容易被重新利用。
目前这版已经能直接跑通“读取收藏夹 -> 调 AI 分类 -> 自动新建收藏夹 -> 批量移动视频”的完整流程。
为了避免 AI 误操作,这个脚本只做“移动到其他收藏夹 / 新建收藏夹”,不包含删除收藏夹或删除视频收藏的 API 调用。删不删由我自己手动确认。
我为什么写这个脚本
B 站收藏夹用久了会越来越乱,尤其是学习类内容:
- 当时觉得有用就先收藏,后面根本找不到
- 标题像“保姆级教程”“一口气讲完”,信息密度很低
- 同类内容散落在多个收藏夹里
- 想复习某个主题时,需要一条条翻
手动整理太累,纯关键词规则又不够聪明,所以我做了个折中方案:
- 脚本负责批量读取和执行移动
- AI 负责按语义理解标题/简介并给出分类结果
这版脚本实际能做什么(按代码说话)
当前代码(V8.1)的核心能力:
- 在 B 站空间页面注入一个悬浮按钮(
🤖 AI整理) - 打开面板后可输入“额外整理要求”(可选)
- 自动读取当前收藏夹
media_id - 拉取你已创建的收藏夹列表(并过滤掉“默认收藏夹”)
- 分页抓取当前收藏夹中的全部视频(每页 20 条)
- 调用
gpt-4o(GitHub Models 接口)做分类决策 - 优先匹配你已有收藏夹名称,尽量不乱建新分类
- 对 AI 返回的新分类自动创建收藏夹(代码里创建为私密:
privacy: 1) - 批量把视频从当前收藏夹移动到目标收藏夹
- 在面板日志和浏览器控制台输出过程状态
一句话总结:这不是“给建议”的版本,而是一个能直接执行移动操作的版本。
当前版本的关键设计(也是我觉得最有用的部分)
1. 强制优先匹配已有收藏夹
这版不是让 AI 随便起名字,而是先把你已有收藏夹列表喂给模型,然后在 Prompt 里明确要求:
- 能放进已有收藏夹的,必须用已有收藏夹的准确名字
- 只有确实完全不匹配时,才允许新建大类
- 不允许漏掉任何一个视频 ID
这个约束非常关键,不然 AI 很容易生成一堆“看起来合理但你根本没用过”的新分类名。
2. 支持用户临时指令(而且优先级很高)
面板里有个输入框,你可以临时加规则,比如:
- 把 Vue/React 相关单独分一类
- 把某个方向统一并入你已有的收藏夹
- 希望分类更粗一点,别分太细
代码里把这段“用户特殊需求”单独插入 Prompt,并标成高优先级,实测会比只写在普通描述里更听话。
3. 结构化 JSON 输出,方便脚本直接执行
脚本要求 AI 返回固定格式 JSON,大概是这样:
thoughts: AI 的分析说明(只打印在控制台,方便排查)categories: 分类结果,键名是收藏夹名,值是{ id, type }数组
这样脚本可以直接遍历分类结果,自动创建收藏夹并执行移动,不需要再写一层文本解析。
脚本运行流程(你自己改代码时可以看这段)
1. 读取登录态与当前收藏夹 ID
脚本通过 Cookie 提取:
DedeUserID(用户 ID)bili_jct(CSRF)
再从 URL 参数里读取当前收藏夹 ID(支持 fid / media_id / id)。
如果没登录、或者不在具体收藏夹页面,会直接弹窗提示并终止。
2. 获取你现有收藏夹列表
通过 B 站接口:
x/v3/fav/folder/created/list-all
拿到收藏夹后,脚本会过滤掉“默认收藏夹”,避免 AI 把东西全塞进去。
3. 分页抓取当前收藏夹视频
通过接口:
x/v3/fav/resource/list
按 pn 分页读取,每页 ps=20,直到拿不到新数据为止。
提交给 AI 的数据是简化版:
idtypetitleintro(只截前 30 个字符)
这样可以减少 token 消耗,也足够做初步分类。
4. 调用 AI 做分类
当前代码使用的是 GitHub Models 接口:
https://models.inference.ai.azure.com/chat/completions
并用 GM_xmlhttpRequest 发请求(油猴环境里更稳)。
模型参数里把 temperature 设成 0.1,目的就是尽量减少“自由发挥”,让分类更稳定。
5. 自动创建收藏夹 + 批量移动视频
AI 返回结果后,脚本会:
- 先检查目标收藏夹是否已存在
- 不存在就调用
x/v3/fav/folder/add创建(私密) - 再调用
x/v3/fav/resource/move批量移动资源
最后提示“整理完成”,并让按钮变成重置/刷新状态。
使用前准备(按当前代码)
1. 安装油猴扩展
- Chrome / Edge:Tampermonkey
- Firefox:Tampermonkey 或 Violentmonkey
2. 填脚本配置(必须改)
代码顶部配置区需要改这几个值:
API_KEY:换成你自己的 GitHub Models Token(不要把真密钥发出来)MODEL_NAME:默认是gpt-4o,可按自己账户权限调整
目前 API_URL 已经写成 GitHub Models 的聊天接口地址,一般不用改。
3. 打开“某个具体收藏夹”页面再运行
脚本不是在整个空间页随便点就能跑,它需要识别当前收藏夹 ID,所以要进入某个具体收藏夹页面。
实际使用建议(踩坑总结)
1. 先拿小收藏夹测试
第一次跑建议先找一个 20-50 条的小收藏夹,确认:
- 你的 API key 可用
- 模型输出 JSON 稳定
- 你的分类习惯和 Prompt 匹配
2. 自定义要求尽量写具体
比起“帮我整理一下”,更推荐这种写法:
- 优先并入已有收藏夹,不要新建太多
- 前端框架统一放进
前端 - 算法题解和数据结构放进
算法 - 不确定的都放进
待复核
越具体,结果越稳定。
3. 跑的时候开着 F12 控制台
这版脚本会把 AI 的 thoughts 打到控制台里。分类奇怪时,先看它到底是怎么理解你的规则的,往往比盲猜原因快很多。
效果展示
运行时可以按 F12 查看控制台日志,面板里也会显示阶段进度(读取页数、调用 AI、创建收藏夹、移动完成等)。
后续可以继续优化的点
- 增加“仅预览不执行移动”模式(先看 AI 结果再确认)
- 增加“待复核”兜底分类,降低误分风险
- 增加失败重试和更细的错误提示(比如单个分类移动失败)
- 支持分批发送给模型,避免超大收藏夹 token 压力
- 把 Prompt 和配置抽成 UI 选项,减少改代码次数
脚本源码
// ==UserScript==// @name B站 AI 收藏夹自动细化整理 (V8.1 gpt-4o 究极听话版)// @namespace http://tampermonkey.net/// @version 8.1.0// @description 使用 gpt-4o 模型,强力约束大模型优先匹配已有收藏夹,听从对话框指令// @author 某不知名的根号三 & Gemini// @match https://space.bilibili.com/*// @grant GM_xmlhttpRequest// ==/UserScript==
(function() { 'use strict';
// ================= 配置区 ================= // GitHub Models 官方 API 完整聊天接口路径 const API_URL = 'https://models.inference.ai.azure.com/chat/completions'; // 你的 GitHub Token (已帮你拼接好 Bearer 格式) const API_KEY = 'Bearer ghp_换上你自己的apikey'; // 采用你指定的满血版 gpt-4o 模型 const MODEL_NAME = 'gpt-4o'; // ==========================================
const sleep = ms => new Promise(r => setTimeout(r, ms));
// 状态日志打印小工具 function logStatus(msg) { console.log(msg); const logDiv = document.getElementById('ai-status-log'); if (logDiv) { logDiv.innerHTML += `<div style="margin-top:4px;">➜ ${msg}</div>`; logDiv.scrollTop = logDiv.scrollHeight; } }
function getBiliData() { const midMatch = document.cookie.match(/DedeUserID=([^;]+)/); const csrfMatch = document.cookie.match(/bili_jct=([^;]+)/); return { mid: midMatch ? midMatch[1] : '', csrf: csrfMatch ? csrfMatch[1] : '' }; }
function getSourceMediaId() { const params = new URLSearchParams(window.location.search); return params.get('fid') || params.get('media_id') || params.get('id'); }
function buildFormData(obj) { return new URLSearchParams(obj).toString(); }
// 获取已有收藏夹列表 (过滤掉默认收藏夹) async function getMyFolders(biliData) { const url = `https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid=${biliData.mid}`; const res = await fetch(url, { credentials: 'include' }).then(r => r.json()); if (res.code === 0 && res.data && res.data.list) { const folderMap = {}; res.data.list.forEach(f => { if (f.title !== '默认收藏夹') folderMap[f.title] = f.id; }); return folderMap; } return {}; }
async function createFolder(title, biliData) { logStatus(`📁 正在新建收藏夹:【${title}】`); const url = 'https://api.bilibili.com/x/v3/fav/folder/add'; const data = buildFormData({ title: title, privacy: 1, csrf: biliData.csrf }); const res = await fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: data }).then(r => r.json()); if (res.code === 0) return res.data.id; throw new Error(`新建失败: ${res.message}`); }
async function moveVideos(sourceMediaId, tarMediaId, resourcesStr, biliData) { const url = 'https://api.bilibili.com/x/v3/fav/resource/move'; const data = buildFormData({ src_media_id: sourceMediaId, tar_media_id: tarMediaId, mid: biliData.mid, resources: resourcesStr, csrf: biliData.csrf }); const res = await fetch(url, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: data }).then(r => r.json()); if (res.code !== 0) console.error("移动失败:", res.message); }
async function startProcess() { const biliData = getBiliData(); const btn = document.getElementById('ai-start-btn'); const customPromptInput = document.getElementById('ai-custom-prompt');
if (!biliData.mid || !biliData.csrf) return alert("请确保你在 B 站已登录!"); const sourceMediaId = getSourceMediaId(); if (!sourceMediaId) return alert("未能识别当前页面的收藏夹 ID!请确保你在某个具体的收藏夹页面内。");
const userRequirement = customPromptInput.value.trim();
btn.innerText = '🔄 整理中,请看下方日志...'; btn.disabled = true; btn.style.background = '#ccc'; document.getElementById('ai-status-log').innerHTML = '';
try { // 1. 获取现有的“家底” logStatus(`正在获取现有的收藏夹列表...`); const existingFoldersMap = await getMyFolders(biliData); const existingFolderNames = Object.keys(existingFoldersMap); logStatus(`📦 发现 ${existingFolderNames.length} 个已有收藏夹`);
// 2. 全量抓取视频 logStatus(`开始全量抓取当前收藏夹视频...`); let allVideos = []; let pn = 1; const ps = 20;
while (true) { logStatus(`正在读取第 ${pn} 页...`); const listUrl = `https://api.bilibili.com/x/v3/fav/resource/list?media_id=${sourceMediaId}&pn=${pn}&ps=${ps}&platform=web`; const listRes = await fetch(listUrl, { credentials: 'include' }).then(r => r.json());
if (listRes.code !== 0) { logStatus(`❌ 读取出错: ${listRes.message}`); break; }
const videos = (listRes.data && listRes.data.medias) ? listRes.data.medias : []; if (videos.length === 0) break;
allVideos.push(...videos); if (videos.length < ps) break; pn++; await sleep(300); }
if (allVideos.length === 0) { logStatus("⚠️ 当前收藏夹是空的!"); resetButton(btn); return; }
logStatus(`✅ 读取完毕!共获取到 ${allVideos.length} 个视频。`); logStatus(`🧠 正在呼叫 ${MODEL_NAME} 进行匹配与思考,请耐心等待...`);
const videoDataForAI = allVideos.map(v => ({ id: v.id, type: v.type, title: v.title, intro: v.intro ? v.intro.substring(0, 30) : '' }));
// ====== 强化版:动态注入存量数据和用户需求 ====== const customRuleText = userRequirement ? `\n\n【⭐⭐⭐用户特殊需求 (最高优先级)⭐⭐⭐】\n用户的特别指示是:"${userRequirement}"\n请你务必听从!\n⚠️ 致命警告:如果用户的指示中提到了要把视频放入某个分类,请你务必在上面的【已有收藏夹】列表里寻找最匹配的准确名称!如果用户打字简写了(比如用户说“音乐”,但已有的是“我的音乐”),你必须输出已有收藏夹的完整名称“我的音乐”,绝不允许凭空新建近义词分类!` : '';
const combinedPrompt = `你是一个逻辑极其严密的文件整理专家。我现在需要你帮我把一批 B 站视频分类。非常重要:用户目前已经建好了以下这些收藏夹:[ ${existingFolderNames.length > 0 ? existingFolderNames.join(', ') : '暂无'} ]
请你严格按照以下 3 个步骤执行:【步骤 1:存量强制匹配】通读所有视频。只要视频内容沾边,就必须一字不差地使用上述【已有收藏夹】的名称作为分类键名。
【步骤 2:谨慎新建】只有当某几个视频确实与所有“已有收藏夹”都毫不相干时,你才可以创建一个新的涵盖面广的“大类”。绝不为单一视频建新分类,孤立视频请塞入最贴近的已有分类。
【步骤 3:绝无遗漏】确保列表中的**每一个视频**都被分配到了具体的分类中,绝对不可以遗漏任何一个 ID!${customRuleText}
请严格输出合法的纯 JSON 格式数据。包含 "thoughts" 和 "categories" 两个字段。示例:{ "thoughts": "分析发现,视频A符合已有的'游戏实况'分类。用户要求把XX放入YY,我发现已有收藏夹中叫'YY合集',因此我将它们放入'YY合集'中...", "categories": { "已有收藏夹准确名字1": [{"id": 111, "type": 2}], "新创建的大类名字": [{"id": 222, "type": 2}] }}
以下是待处理的所有视频:${JSON.stringify(videoDataForAI)}`;
GM_xmlhttpRequest({ method: "POST", url: API_URL, headers: { "Content-Type": "application/json", "Authorization": API_KEY }, data: JSON.stringify({ model: MODEL_NAME, messages: [{role: "user", content: combinedPrompt}], temperature: 0.1 // 保持极低的温度,确保严谨匹配,不乱造词 }), onload: async function(response) { if(response.status !== 200) { logStatus(`❌ API报错:${response.status}`); console.error(response.responseText); resetButton(btn); return; }
let content = JSON.parse(response.responseText).choices[0].message.content; content = content.replace(/```json/g, '').replace(/```/g, '').trim(); const aiResult = JSON.parse(content);
console.log("💡 AI 思考过程:\n", aiResult.thoughts); logStatus(`💡 思考完毕!(按F12可看AI脑洞)`);
let processedCount = 0; for (const [categoryName, vids] of Object.entries(aiResult.categories)) { if(!vids || vids.length === 0) continue;
let targetFolderId = existingFoldersMap[categoryName]; if (!targetFolderId) { targetFolderId = await createFolder(categoryName, biliData); existingFoldersMap[categoryName] = targetFolderId; await sleep(1000); }
logStatus(`🚚 正将 ${vids.length} 个视频移入【${categoryName}】...`); const resourcesStr = vids.map(v => `${v.id}:${v.type}`).join(','); await moveVideos(sourceMediaId, targetFolderId, resourcesStr, biliData); processedCount += vids.length; await sleep(500); }
logStatus(`🎉 整理完成!共处理了 ${processedCount} 个视频。请刷新页面!`); btn.innerText = '✅ 整理完成,点我重置'; btn.style.background = '#4CAF50'; btn.disabled = false; btn.onclick = () => window.location.reload(); }, onerror: function(err) { logStatus(`❌ 请求大模型失败,请检查网络或 API Key`); console.error(err); resetButton(btn); } });
} catch (error) { logStatus(`❌ 发生未知错误,请看 F12 控制台`); console.error(error); resetButton(btn); } }
function resetButton(btn) { btn.innerText = '🚀 开始深度整理'; btn.style.background = '#fb7299'; btn.disabled = false; btn.onclick = startProcess; }
// ================= UI 构建区 ================= function initUI() { if (document.getElementById('ai-sort-wrapper')) return;
const floatBtn = document.createElement('div'); floatBtn.id = 'ai-float-btn'; floatBtn.innerHTML = '🤖<br>AI整理'; floatBtn.style.cssText = 'position:fixed; bottom:30px; left:30px; z-index:9999; background:#fb7299; color:white; width:50px; height:50px; border-radius:25px; display:flex; align-items:center; justify-content:center; text-align:center; font-size:12px; font-weight:bold; cursor:pointer; box-shadow: 0 4px 10px rgba(251, 114, 153, 0.4); transition:0.3s;';
const panel = document.createElement('div'); panel.id = 'ai-sort-wrapper'; panel.style.cssText = 'position:fixed; bottom:30px; left:30px; z-index:10000; width:320px; display:none; flex-direction:column; box-shadow: 0 5px 20px rgba(0,0,0,0.2); border-radius:10px; overflow:hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;';
panel.innerHTML = ` <div style="background:#fb7299; color:#fff; padding:12px 15px; font-weight:bold; font-size:15px; display:flex; justify-content:space-between; align-items:center;"> <span>🤖 AI 收藏夹整理助理</span> <span id="ai-close-btn" style="cursor:pointer; font-size:18px; line-height:1;">×</span> </div> <div style="background:#fff; padding:15px; border:1px solid #eee; border-top:none;"> <p style="margin:0 0 8px 0; font-size:13px; color:#555;">有什么特定的整理要求吗?(选填)</p> <textarea id="ai-custom-prompt" placeholder="例如:\n- 把所有 Vue 相关的放一个文件夹\n- 把时长超过1小时的单独拎出来\n(不填则优先放入已有收藏夹,并由 AI 自由发挥补充分类)" style="width:100%; height:80px; padding:8px; box-sizing:border-box; border:1px solid #ddd; border-radius:6px; font-size:13px; resize:none; margin-bottom:12px; outline:none;"></textarea> <button id="ai-start-btn" style="width:100%; padding:10px; background:#fb7299; color:white; border:none; border-radius:6px; font-size:14px; font-weight:bold; cursor:pointer; transition:background 0.2s;">🚀 开始深度整理</button> <div id="ai-status-log" style="margin-top:15px; background:#f4f4f4; padding:8px; border-radius:6px; font-size:12px; color:#333; height:120px; overflow-y:auto; word-break:break-all;"> 等待指令... </div> </div> `;
document.body.appendChild(floatBtn); document.body.appendChild(panel);
floatBtn.onclick = () => { floatBtn.style.display = 'none'; panel.style.display = 'flex'; };
document.getElementById('ai-close-btn').onclick = () => { panel.style.display = 'none'; floatBtn.style.display = 'flex'; };
document.getElementById('ai-start-btn').onclick = startProcess; }
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initUI); } else { initUI(); }
})();