// ==UserScript== // @name B站 AI 收藏夹自动细化整理 (V8.2 火山方舟版) // @namespace http://tampermonkey.net/ // @version 8.2.0 // @description 使用火山方舟大模型接口,强力约束优先匹配已有收藏夹,听从对话框指令 // @author 某不知名的根号三 & Gemini // @match https://space.bilibili.com/* // @grant GM_xmlhttpRequest // @connect ark.cn-beijing.volces.com // @connect *.volces.com // @connect volces.com // ==/UserScript== (function() { 'use strict'; // ================= 配置区 ================= // 火山方舟 Chat Completions 接口 const API_URL = 'https://ark.cn-beijing.volces.com/api/v3/chat/completions'; // 你的火山方舟 API Key(支持填写原始 Key 或 Bearer 前缀格式) const API_KEY = 'ffcead97-0c80-4ff7-8b71-ee255a512f07'; // 火山方舟模型名称(示例:deepseek-v3-1-terminus) const MODEL_NAME = 'deepseek-v3-1-terminus'; // 本地存储:保存用户在输入框里的自定义需求,避免刷新后丢失 const PROMPT_CACHE_KEY = 'bili_ai_custom_prompt_v1'; // ========================================== const sleep = ms => new Promise(r => setTimeout(r, ms)); function getAuthHeader(apiKey) { const key = (apiKey || '').trim(); if (!key) return ''; return /^Bearer\s+/i.test(key) ? key : `Bearer ${key}`; } // 状态日志打印小工具 function logStatus(msg) { console.log(msg); const logDiv = document.getElementById('ai-status-log'); if (logDiv) { logDiv.innerHTML += `
➜ ${msg}
`; 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(); } function withNoCache(url) { const sep = url.includes('?') ? '&' : '?'; return `${url}${sep}_ts=${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } function fetchJsonNoCache(url, options = {}) { const headers = { ...(options.headers || {}), 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' }; return fetch(withNoCache(url), { ...options, cache: 'no-store', headers }).then(r => r.json()); } // 获取已有收藏夹列表 (过滤掉默认收藏夹) async function getMyFolders(biliData) { const url = `https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid=${biliData.mid}`; const res = await fetchJsonNoCache(url, { credentials: 'include' }); 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); } function runConnectivityCheck() { const checkBtn = document.getElementById('ai-check-btn'); if (checkBtn) { checkBtn.disabled = true; checkBtn.style.background = '#bbb'; checkBtn.innerText = '⏳ 自检中...'; } const finish = () => { if (checkBtn) { checkBtn.disabled = false; checkBtn.style.background = '#00a1d6'; checkBtn.innerText = '🔍 连通性自检'; } }; if (!API_KEY || !API_KEY.trim()) { logStatus('❌ 自检失败:API Key 为空'); finish(); return; } logStatus('🧪 开始连通性自检:发送最小测试请求...'); GM_xmlhttpRequest({ method: 'POST', url: API_URL, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': getAuthHeader(API_KEY) }, data: JSON.stringify({ model: MODEL_NAME, messages: [{ role: 'user', content: 'ping' }], temperature: 0, max_tokens: 8 }), timeout: 20000, onload: function(response) { const bodyText = (response && response.responseText) ? response.responseText : ''; if (response.status === 200) { logStatus('✅ 自检通过:模型接口可连通,鉴权与模型参数基本可用'); try { const parsed = JSON.parse(bodyText); const brief = parsed && parsed.choices && parsed.choices[0] && parsed.choices[0].message ? parsed.choices[0].message.content : ''; if (brief) logStatus(`🧪 模型返回片段:${String(brief).slice(0, 40)}`); } catch (e) { // 自检只做可用性判断,解析失败不影响网络连通结论 } } else { logStatus(`❌ 自检失败:HTTP ${response.status}`); logStatus(`🧾 响应片段:${String(bodyText).slice(0, 160) || '空响应体'}`); logStatus('🔎 若提示 model 无效,请把 MODEL_NAME 换成你在火山方舟创建的 endpoint/model 标识'); } console.log('ConnectivityCheck onload:', response); finish(); }, onerror: function(err) { const errMsg = [ `status=${err && typeof err.status !== 'undefined' ? err.status : 'unknown'}`, `readyState=${err && typeof err.readyState !== 'undefined' ? err.readyState : 'unknown'}`, `finalUrl=${err && err.finalUrl ? err.finalUrl : API_URL}` ].join(' | '); logStatus(`❌ 自检失败(网络层):${errMsg}`); logStatus('🔎 网络层失败通常是代理/防火墙/扩展拦截,不是模型业务错误'); console.error('ConnectivityCheck onerror:', err); finish(); }, ontimeout: function() { logStatus('❌ 自检超时(20s):网络不稳定或请求被拦截'); finish(); } }); } function splitIntoChunks(arr, chunkSize) { const chunks = []; for (let i = 0; i < arr.length; i += chunkSize) { chunks.push(arr.slice(i, i + chunkSize)); } return chunks; } function buildAnalysisPrompt(videos, existingFolderNames, userRequirement) { const customRuleText = userRequirement ? `\n\n【⭐⭐⭐用户特殊需求 (最高优先级)⭐⭐⭐】\n用户的特别指示是:"${userRequirement}"\n请你务必听从!\n⚠️ 致命警告:如果用户的指示中提到了要把视频放入某个分类,请你务必在上面的【已有收藏夹】列表里寻找最匹配的准确名称!如果用户打字简写了(比如用户说“音乐”,但已有的是“我的音乐”),你必须输出已有收藏夹的完整名称“我的音乐”,绝不允许凭空新建近义词分类!` : ''; return `你是一个逻辑极其严密的文件整理专家。我现在需要你帮我把一批 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(videos)}`; } function requestAiClassification(prompt, maxRetryCount) { const requestData = JSON.stringify({ model: MODEL_NAME, messages: [{role: 'user', content: prompt}], temperature: 0.1 }); return new Promise((resolve, reject) => { let retried = 0; const send = () => { GM_xmlhttpRequest({ method: 'POST', url: API_URL, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': getAuthHeader(API_KEY) }, data: requestData, timeout: 60000, onload: function(response) { if (response.status !== 200) { reject(new Error(`HTTP ${response.status}: ${response.responseText || ''}`)); return; } try { const raw = JSON.parse(response.responseText); let content = raw.choices[0].message.content; content = content.replace(/```json/g, '').replace(/```/g, '').trim(); const parsed = JSON.parse(content); resolve(parsed); } catch (e) { reject(new Error(`模型返回解析失败: ${e && e.message ? e.message : e}`)); } }, onerror: function(err) { const statusVal = err && typeof err.status !== 'undefined' ? err.status : 'unknown'; if ((statusVal === 0 || statusVal === '0') && retried < maxRetryCount) { retried++; setTimeout(send, 1500 * retried); return; } reject(new Error([ `status=${statusVal}`, `readyState=${err && typeof err.readyState !== 'undefined' ? err.readyState : 'unknown'}`, `finalUrl=${err && err.finalUrl ? err.finalUrl : API_URL}` ].join(' | '))); }, ontimeout: function() { if (retried < maxRetryCount) { retried++; setTimeout(send, 1500 * retried); return; } reject(new Error('请求超时(60s)')); } }); }; send(); }); } async function startProcess() { const biliData = getBiliData(); const btn = document.getElementById('ai-start-btn'); const customPromptInput = document.getElementById('ai-custom-prompt'); if (!API_KEY || !API_KEY.trim()) { alert("请先在脚本配置区填写火山方舟 API Key!"); return; } 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 fetchJsonNoCache(listUrl, { credentials: 'include' }); 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 batchSize = 30; const batches = splitIntoChunks(videoDataForAI, batchSize); const mergedCategories = {}; const seenVideoSet = new Set(); for (let i = 0; i < batches.length; i++) { const batchVideos = batches[i]; const percent = Math.floor(((i + 1) / batches.length) * 100); logStatus(`🧩 分析进度:第 ${i + 1}/${batches.length} 批(${batchVideos.length} 条,${percent}%)`); const prompt = buildAnalysisPrompt(batchVideos, existingFolderNames, userRequirement); let aiResult; try { aiResult = await requestAiClassification(prompt, 2); } catch (err) { logStatus(`❌ 第 ${i + 1} 批分析失败:${err && err.message ? err.message : err}`); logStatus('🔎 排查建议:1) 保持网络稳定 2) 点击“连通性自检”确认服务可达 3) 重新点击开始继续'); resetButton(btn); return; } const categories = aiResult && aiResult.categories ? aiResult.categories : {}; for (const [categoryName, vids] of Object.entries(categories)) { if (!Array.isArray(vids) || vids.length === 0) continue; if (!mergedCategories[categoryName]) mergedCategories[categoryName] = []; for (const v of vids) { if (!v || typeof v.id === 'undefined' || typeof v.type === 'undefined') continue; const uniqKey = `${v.id}:${v.type}`; if (seenVideoSet.has(uniqKey)) continue; seenVideoSet.add(uniqKey); mergedCategories[categoryName].push({ id: v.id, type: v.type }); } } await sleep(500); } console.log('💡 AI 分析结果(合并后):\n', mergedCategories); logStatus('💡 思考完毕!开始执行移动...'); let processedCount = 0; for (const [categoryName, vids] of Object.entries(mergedCategories)) { 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} 个视频。请刷新页面!`); logStatus('ℹ️ 页面数据可能有缓存延迟,如列表未即时变化可稍后手动刷新;无需强制刷新即可继续下一轮整理。'); btn.innerText = '✅ 已完成,可继续整理'; btn.style.background = '#4CAF50'; btn.disabled = false; btn.onclick = startProcess; } 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 = '🤖
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 = `
🤖 AI 收藏夹整理助理 ×

有什么特定的整理要求吗?(选填)

等待指令...
`; 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; document.getElementById('ai-check-btn').onclick = runConnectivityCheck; const promptInput = document.getElementById('ai-custom-prompt'); const cachedPrompt = localStorage.getItem(PROMPT_CACHE_KEY); if (cachedPrompt) { promptInput.value = cachedPrompt; } promptInput.addEventListener('input', () => { localStorage.setItem(PROMPT_CACHE_KEY, promptInput.value); }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initUI); } else { initUI(); } })();