diff --git a/油猴脚本 copy.js b/油猴脚本 copy.js new file mode 100644 index 0000000..c0a0ca3 --- /dev/null +++ b/油猴脚本 copy.js @@ -0,0 +1,307 @@ +// ==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 +// ==/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 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(); + } + + // 获取已有收藏夹列表 (过滤掉默认收藏夹) + 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": getAuthHeader(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 = '🤖
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; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initUI); + } else { + initUI(); + } + +})(); \ No newline at end of file diff --git a/油猴脚本.js b/油猴脚本.js new file mode 100644 index 0000000..ff7c4d3 --- /dev/null +++ b/油猴脚本.js @@ -0,0 +1,509 @@ +// ==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(); + } + +})(); \ No newline at end of file diff --git a/油猴脚本_关注分组.js b/油猴脚本_关注分组.js new file mode 100644 index 0000000..30a5503 --- /dev/null +++ b/油猴脚本_关注分组.js @@ -0,0 +1,959 @@ +// ==UserScript== +// @name B站关注分组助手(火山方舟) +// @namespace http://tampermonkey.net/ +// @version 1.0.0 +// @description 抓取已关注UP,读取每个UP最近视频标题,AI分组并导出报告/取关UID +// @author Digouyou +// @match https://space.bilibili.com/* +// @grant GM_xmlhttpRequest +// @grant GM_download +// @connect api.bilibili.com +// @connect ark.cn-beijing.volces.com +// @connect *.volces.com +// @connect volces.com +// ==/UserScript== + +(function () { + 'use strict'; + + // ===== 配置区 ===== + const API_URL = 'https://ark.cn-beijing.volces.com/api/v3/chat/completions'; + const API_KEY = 'ffcead97-0c80-4ff7-8b71-ee255a512f07'; + const MODEL_NAME = 'deepseek-v3-1-terminus'; + + const TITLE_COUNT = 10; // 每个UP抓取标题数 + const PAGE_SIZE = 50; // 关注列表分页 + const AI_CONCURRENCY = 3; // AI并发数 + const AI_TIMEOUT_MS = 60000; // AI超时 + const REQUEST_GAP_MS = 200; // B站请求间隔 + const TAG_BATCH_SIZE = 40; // 关注分组接口单次批量数量 + + const PROMPT_CACHE_KEY = 'bili_follow_group_custom_prompt_v1'; + + const PRESET_GROUPS = [ + '核心每日必读', + '编程信息干货必留', + '硬核知识保留', + '技能学习保留', + '资讯快餐观察', + '娱乐消遣可取关', + '营销带货谨慎' + ]; + + // ===== 工具函数 ===== + 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('follow-group-log'); + if (logDiv) { + const line = document.createElement('div'); + line.textContent = `➜ ${msg}`; + line.style.marginTop = '4px'; + logDiv.appendChild(line); + 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 withNoCache(url) { + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}_ts=${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + } + + async function fetchJsonNoCache(url, options = {}) { + const headers = { + ...(options.headers || {}), + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + }; + + const resp = await fetch(withNoCache(url), { + ...options, + cache: 'no-store', + credentials: 'include', + headers + }); + + return resp.json(); + } + + function extractTopTitlesFromArcResponse(data, limit) { + const vlist = data && data.data && data.data.list && Array.isArray(data.data.list.vlist) + ? data.data.list.vlist + : []; + const titles = []; + for (const item of vlist) { + if (item && item.title) titles.push(String(item.title).trim()); + if (titles.length >= limit) break; + } + return titles; + } + + async function fetchUpTitles(mid, limit = TITLE_COUNT) { + const errors = []; + + // 先尝试官方接口 + try { + const url = `https://api.bilibili.com/x/space/arc/search?mid=${mid}&pn=1&ps=${limit}&order=pubdate&index=1&jsonp=jsonp`; + const data = await fetchJsonNoCache(url); + if (data && data.code === 0) { + const titles = extractTopTitlesFromArcResponse(data, limit); + if (titles.length > 0) return { titles, error: '' }; + errors.push('接口返回成功但标题为空'); + } else { + errors.push(`接口异常 code=${data && data.code}`); + } + } catch (e) { + errors.push(`接口请求失败: ${e && e.message ? e.message : e}`); + } + + // HTML 回退 + try { + const htmlUrl = `https://space.bilibili.com/${mid}/video`; + const resp = await fetch(withNoCache(htmlUrl), { + cache: 'no-store', + credentials: 'include', + headers: { + 'Cache-Control': 'no-cache', + Pragma: 'no-cache' + } + }); + const text = await resp.text(); + const regex = /]*class="[^"]*b-img__inner[^"]*"[^>]*alt="([^"]+)"/gi; + const set = new Set(); + const titles = []; + let m; + while ((m = regex.exec(text)) !== null) { + const t = String(m[1] || '').trim(); + if (!t || set.has(t)) continue; + set.add(t); + titles.push(t); + if (titles.length >= limit) break; + } + if (titles.length > 0) return { titles, error: '' }; + errors.push('HTML模式未提取到标题'); + } catch (e) { + errors.push(`HTML模式失败: ${e && e.message ? e.message : e}`); + } + + return { titles: [], error: errors.join('; ') }; + } + + function requestAiClassification(up, userRequirement) { + const ruleHint = [ + '分组候选仅允许如下值:', + ...PRESET_GROUPS.map((g) => `- ${g}`) + ].join('\n'); + + const custom = userRequirement + ? `\n\n用户额外要求:${userRequirement}` + : ''; + + const titleText = up.titles.length > 0 + ? up.titles.map((t) => `- ${t}`).join('\n') + : '- (未抓取到标题)'; + + const prompt = [ + '你是B站UP主内容分组助手。输出必须是合法JSON,且不允许输出JSON外文本。', + `UP主: ${up.name}`, + `mid: ${up.mid}`, + '最近10条标题:', + titleText, + '', + ruleHint, + '', + '请输出JSON对象,字段严格为: summary, group, action, reason', + '约束:', + '1) summary: 30-100字中文总结', + '2) group: 必须是候选分组之一', + '3) action: 只能是"保留关注"或"可以取关"', + '4) reason: 20-80字中文理由', + custom + ].join('\n'); + + const data = JSON.stringify({ + model: MODEL_NAME, + messages: [ + { role: 'system', content: '你是严格的JSON输出助手。' }, + { role: 'user', content: prompt } + ], + temperature: 0.3 + }); + + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + method: 'POST', + url: API_URL, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: getAuthHeader(API_KEY) + }, + data, + timeout: AI_TIMEOUT_MS, + onload: function (res) { + try { + if (res.status !== 200) { + reject(new Error(`HTTP ${res.status}: ${String(res.responseText || '').slice(0, 200)}`)); + return; + } + const raw = JSON.parse(res.responseText || '{}'); + let content = raw && raw.choices && raw.choices[0] && raw.choices[0].message + ? raw.choices[0].message.content + : ''; + + content = String(content || '').replace(/```json/gi, '').replace(/```/g, '').trim(); + const match = content.match(/\{[\s\S]*\}/); + const jsonText = match ? match[0] : content; + const parsed = JSON.parse(jsonText); + + const result = { + summary: String(parsed.summary || '').trim(), + group: String(parsed.group || '').trim(), + action: String(parsed.action || '').trim(), + reason: String(parsed.reason || '').trim() + }; + + if (!PRESET_GROUPS.includes(result.group)) { + throw new Error(`AI返回未知group: ${result.group || '空'}`); + } + if (!['保留关注', '可以取关'].includes(result.action)) { + throw new Error(`AI返回未知action: ${result.action || '空'}`); + } + if (!result.summary) result.summary = '(AI未返回有效总结)'; + if (!result.reason) result.reason = '(AI未返回有效依据)'; + resolve(result); + } catch (e) { + reject(new Error(`AI解析失败: ${e && e.message ? e.message : e}`)); + } + }, + onerror: function (err) { + reject(new Error(`AI请求失败: status=${err && err.status}`)); + }, + ontimeout: function () { + reject(new Error(`AI请求超时: ${AI_TIMEOUT_MS}ms`)); + } + }); + }); + } + + async function runWithConcurrency(items, workerCount, workerFn) { + const results = new Array(items.length); + let idx = 0; + + async function worker() { + while (true) { + const i = idx; + idx += 1; + if (i >= items.length) break; + results[i] = await workerFn(items[i], i); + } + } + + const tasks = []; + const n = Math.max(1, workerCount); + for (let i = 0; i < n; i++) tasks.push(worker()); + await Promise.all(tasks); + return results; + } + + function buildKeepReport(items) { + const keep = items.filter((x) => x.action === '保留关注'); + keep.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN', { sensitivity: 'base' })); + + const lines = []; + lines.push('# 保留关注UP主分析与分组建议'); + lines.push(''); + lines.push(`- 生成时间: ${new Date().toLocaleString()}`); + lines.push(`- 条目数: ${keep.length}`); + lines.push(''); + + keep.forEach((it, i) => { + lines.push(`## ${i + 1}. ${it.name} (mid: ${it.mid})`); + lines.push(''); + lines.push('### AI分析'); + lines.push(''); + lines.push(it.summary || '(待分析)'); + lines.push(''); + lines.push('### 分组建议'); + lines.push(''); + lines.push(`- 预设分组: ${it.group || '(待分组)'}`); + lines.push(`- 建议动作: ${it.action || '(待分组)'}`); + lines.push(`- 判断依据: ${it.reason || '(无)'}`); + lines.push(''); + if (it.error) { + lines.push('### 异常'); + lines.push(''); + lines.push(`- ${it.error}`); + lines.push(''); + } + }); + + return lines.join('\n'); + } + + function buildUnfollowMidText(items) { + const mids = items + .filter((x) => x.action === '可以取关') + .map((x) => String(x.mid)); + return mids.join(','); + } + + function downloadTextFile(filename, content) { + try { + if (typeof GM_download === 'function') { + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + GM_download({ + url, + name: filename, + saveAs: true, + onload: () => URL.revokeObjectURL(url), + onerror: () => URL.revokeObjectURL(url) + }); + return; + } + } catch (e) { + // fallback + } + + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + function parseSuggestionMarkdown(text) { + const result = []; + const pattern = /^##\s+\d+\.\s+(.*?)\s+\(mid:\s*(\d+)\)\s*$/gm; + const matches = Array.from(text.matchAll(pattern)); + if (matches.length === 0) return result; + + for (let i = 0; i < matches.length; i++) { + const m = matches[i]; + const start = m.index; + const end = i + 1 < matches.length ? matches[i + 1].index : text.length; + const section = text.slice(start, end); + + const name = String(m[1] || '').trim(); + const mid = String(m[2] || '').trim(); + const groupMatch = section.match(/-\s*预设分组:\s*(.+)/); + const actionMatch = section.match(/-\s*建议动作:\s*(.+)/); + const reasonMatch = section.match(/-\s*判断依据:\s*(.+)/); + + result.push({ + mid, + name, + group: groupMatch ? String(groupMatch[1]).trim() : '', + action: actionMatch ? String(actionMatch[1]).trim() : '', + reason: reasonMatch ? String(reasonMatch[1]).trim() : '' + }); + } + + return result; + } + + function parseSuggestionJson(text) { + const raw = JSON.parse(text); + if (!Array.isArray(raw)) return []; + return raw.map((x) => ({ + mid: String((x && x.mid) || '').trim(), + name: String((x && x.name) || '').trim(), + group: String((x && (x.group || x.category)) || '').trim(), + action: String((x && x.action) || '').trim(), + reason: String((x && x.reason) || '').trim() + })).filter((x) => x.mid); + } + + function parseSuggestionText(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) return []; + if (trimmed.startsWith('[') || trimmed.startsWith('{')) { + try { + return parseSuggestionJson(trimmed); + } catch (e) { + // fall through to markdown parsing + } + } + return parseSuggestionMarkdown(trimmed); + } + + function buildPlanFromSuggestions(items, onlyKeep) { + const plan = {}; + for (const it of items) { + const action = String(it.action || '').trim(); + const group = String(it.group || '').trim(); + const mid = String(it.mid || '').trim(); + if (!mid || !group) continue; + if (onlyKeep && action !== '保留关注') continue; + if (!plan[group]) plan[group] = []; + plan[group].push(mid); + } + + for (const k of Object.keys(plan)) { + plan[k] = Array.from(new Set(plan[k])); + } + return plan; + } + + function chunkArray(arr, size) { + const chunks = []; + const n = Math.max(1, size); + for (let i = 0; i < arr.length; i += n) { + chunks.push(arr.slice(i, i + n)); + } + return chunks; + } + + async function fetchFollowTags() { + // 关注分组(tag)列表接口。 + const url = 'https://api.bilibili.com/x/relation/tags'; + const res = await fetchJsonNoCache(url); + if (!res || res.code !== 0) { + throw new Error(`读取关注分组失败: code=${res && res.code}, msg=${res && res.message}`); + } + + const list = Array.isArray(res.data) + ? res.data + : (res.data && Array.isArray(res.data.list) ? res.data.list : []); + + const map = {}; + for (const t of list) { + const id = t && (t.tagid || t.id); + const name = t && (t.name || t.tag_name || t.tag); + if (!id || !name) continue; + map[String(name).trim()] = Number(id); + } + return map; + } + + async function createFollowTag(tagName, csrf) { + const body = new URLSearchParams({ tag: tagName, csrf }).toString(); + const res = await fetch('https://api.bilibili.com/x/relation/tag/create', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }).then((r) => r.json()); + + if (!res || res.code !== 0) { + throw new Error(`创建关注分组失败(${tagName}): code=${res && res.code}, msg=${res && res.message}`); + } + + const tagid = res.data && (res.data.tagid || res.data.id); + if (!tagid) { + throw new Error(`创建关注分组失败(${tagName}): 返回缺少tagid`); + } + return Number(tagid); + } + + async function addUsersToTag(mids, tagId, csrf) { + const fids = mids.join(','); + const body = new URLSearchParams({ + fids, + tagids: String(tagId), + csrf + }).toString(); + + const res = await fetch('https://api.bilibili.com/x/relation/tags/addUsers', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }).then((r) => r.json()); + + if (!res || res.code !== 0) { + throw new Error(`分配用户到分组失败: code=${res && res.code}, msg=${res && res.message}`); + } + } + + function renderPlanText(plan) { + const groups = Object.keys(plan); + if (groups.length === 0) return '(无可执行分组)'; + const lines = []; + lines.push(`分组数: ${groups.length}`); + let total = 0; + for (const g of groups) { + const c = plan[g].length; + total += c; + lines.push(`- ${g}: ${c}`); + } + lines.push(`总UP数: ${total}`); + return lines.join('\n'); + } + + async function validatePlanAgainstExistingTags(plan) { + // 校验计划中的分组是否都存在于B站现有分组中(不创建新分组)。 + logStatus('读取B站现有关注分组...'); + const tagMap = await fetchFollowTags(); + + const planGroups = Object.keys(plan); + const missing = planGroups.filter((g) => !tagMap[g]); + const valid = planGroups.filter((g) => !!tagMap[g]); + + if (missing.length > 0) { + logStatus(`⚠️ 以下分组在B站中不存在(将被跳过):`); + missing.forEach((g) => { + const count = plan[g] ? plan[g].length : 0; + logStatus(` - ${g} (${count}个UP)`); + }); + } + + if (valid.length === 0) { + return { valid: false, tagMap, plan: {} }; + } + + const validPlan = {}; + for (const g of valid) validPlan[g] = plan[g]; + return { valid: true, tagMap, plan: validPlan }; + } + + async function executeGroupingFromPlan(plan, csrf) { + // 仅分配模式:不创建新分组,只在现有分组中分配UP。 + const validation = await validatePlanAgainstExistingTags(plan); + if (!validation.valid) { + logStatus('❌ 没有有效的分组可执行(请先在B站创建对应关注分组)'); + return; + } + + const tagMap = validation.tagMap; + const planToRun = validation.plan; + + // 先读取当前关注列表,避免建议文件里有未关注mid导致整批失败(code=22105)。 + const bili = getBiliData(); + const followingList = await getFollowingList(bili.mid); + const followingSet = new Set(followingList.map((x) => String(x.mid))); + let dropped = 0; + const filteredPlan = {}; + for (const g of Object.keys(planToRun)) { + const kept = []; + for (const mid of planToRun[g]) { + if (followingSet.has(String(mid))) { + kept.push(String(mid)); + } else { + dropped += 1; + } + } + if (kept.length > 0) filteredPlan[g] = kept; + } + + if (Object.keys(filteredPlan).length === 0) { + logStatus('❌ 建议文件中的UP当前都不在关注列表,无法执行分组'); + return; + } + if (dropped > 0) { + logStatus(`已过滤未关注UP: ${dropped} 个,剩余可执行UP: ${Object.values(filteredPlan).reduce((a, b) => a + b.length, 0)}`); + } + + for (const groupName of Object.keys(filteredPlan)) { + const tagId = tagMap[groupName]; + if (!tagId) { + logStatus(`⚠️ 分组${groupName}不存在,已跳过`); + continue; + } + + const mids = filteredPlan[groupName]; + const chunks = chunkArray(mids, TAG_BATCH_SIZE); + logStatus(`执行分组 ${groupName}: ${mids.length} 个UP, ${chunks.length} 批`); + + for (let i = 0; i < chunks.length; i++) { + const batch = chunks[i]; + try { + await addUsersToTag(batch, tagId, csrf); + logStatus(` ${groupName}: ${i + 1}/${chunks.length} 批完成`); + } catch (e) { + // 批量失败时自动拆单,尽可能继续执行。 + logStatus(` ${groupName}: 第${i + 1}批失败,自动拆单重试 (${e && e.message ? e.message : e})`); + for (const mid of batch) { + try { + await addUsersToTag([mid], tagId, csrf); + } catch (singleErr) { + const msg = singleErr && singleErr.message ? singleErr.message : String(singleErr); + logStatus(` 跳过 mid=${mid}: ${msg}`); + } + await sleep(80); + } + logStatus(` ${groupName}: 第${i + 1}/${chunks.length} 批拆单处理完成`); + } + await sleep(REQUEST_GAP_MS); + } + } + + logStatus('✅ 分组执行完成(已按建议分配到B站现有分组)'); + } + + async function getFollowingList(vmid) { + const all = []; + let pn = 1; + + while (true) { + const url = `https://api.bilibili.com/x/relation/followings?vmid=${vmid}&pn=${pn}&ps=${PAGE_SIZE}&order=desc&order_type=attention`; + const res = await fetchJsonNoCache(url); + + if (!res || res.code !== 0 || !res.data || !Array.isArray(res.data.list)) { + throw new Error(`拉取关注列表失败: code=${res && res.code}, msg=${res && res.message}`); + } + + const list = res.data.list; + if (list.length === 0) break; + + for (const up of list) { + all.push({ + mid: up.mid, + name: up.uname || `mid_${up.mid}`, + sign: up.sign || '', + official: up.official_verify && up.official_verify.desc ? up.official_verify.desc : '' + }); + } + + logStatus(`关注列表分页 ${pn}:新增 ${list.length},累计 ${all.length}`); + + if (list.length < PAGE_SIZE) break; + pn += 1; + await sleep(REQUEST_GAP_MS); + } + + return all; + } + + async function startProcess() { + const startBtn = document.getElementById('follow-group-start-btn'); + const promptInput = document.getElementById('follow-group-custom-prompt'); + + if (!API_KEY || !API_KEY.trim() || API_KEY.includes('在这里填')) { + alert('请先在脚本顶部填写火山方舟 API Key'); + return; + } + + const bili = getBiliData(); + if (!bili.mid) { + alert('未检测到登录态,请先登录B站'); + return; + } + + const userRequirement = promptInput.value.trim(); + localStorage.setItem(PROMPT_CACHE_KEY, userRequirement); + + startBtn.disabled = true; + startBtn.textContent = '⏳ 处理中...'; + document.getElementById('follow-group-log').innerHTML = ''; + + try { + logStatus('开始抓取关注列表...'); + const follows = await getFollowingList(bili.mid); + logStatus(`已抓取关注UP: ${follows.length}`); + + if (follows.length === 0) { + logStatus('当前账号没有关注UP'); + return; + } + + logStatus('开始抓取每个UP最近视频标题...'); + const enriched = []; + for (let i = 0; i < follows.length; i++) { + const up = follows[i]; + const { titles, error } = await fetchUpTitles(up.mid, TITLE_COUNT); + enriched.push({ ...up, titles, error, summary: '', group: '', action: '', reason: '' }); + if ((i + 1) % 20 === 0 || i + 1 === follows.length) { + logStatus(`标题抓取进度: ${i + 1}/${follows.length}`); + } + await sleep(REQUEST_GAP_MS); + } + + logStatus('开始AI分组...'); + let done = 0; + await runWithConcurrency(enriched, AI_CONCURRENCY, async (up) => { + try { + const ai = await requestAiClassification(up, userRequirement); + up.summary = ai.summary; + up.group = ai.group; + up.action = ai.action; + up.reason = ai.reason; + } catch (e) { + up.summary = up.summary || '(待分析)'; + up.group = up.group || ''; + up.action = up.action || ''; + up.reason = up.reason || ''; + up.error = up.error + ? `${up.error}; ${e.message}` + : e.message; + } finally { + done += 1; + if (done % 10 === 0 || done === enriched.length) { + logStatus(`AI进度: ${done}/${enriched.length}`); + } + } + }); + + const keepReport = buildKeepReport(enriched); + const unfollowMidText = buildUnfollowMidText(enriched); + const jsonText = JSON.stringify(enriched, null, 2); + + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + downloadTextFile(`up_keep_follow_only_${ts}.md`, keepReport); + downloadTextFile(`unfollow_mids_${ts}.txt`, unfollowMidText); + downloadTextFile(`follow_group_full_${ts}.json`, jsonText); + + const keepCount = enriched.filter((x) => x.action === '保留关注').length; + const unfollowCount = enriched.filter((x) => x.action === '可以取关').length; + const failCount = enriched.filter((x) => !x.action).length; + + logStatus(`完成:保留 ${keepCount},可取关 ${unfollowCount},失败 ${failCount}`); + logStatus('已导出 3 个文件:保留报告 / 取关UID / 全量JSON'); + } catch (e) { + logStatus(`失败:${e && e.message ? e.message : e}`); + console.error(e); + } finally { + startBtn.disabled = false; + startBtn.textContent = '🚀 开始关注分组'; + } + } + + function runConnectivityCheck() { + const checkBtn = document.getElementById('follow-group-check-btn'); + if (!API_KEY || !API_KEY.trim() || API_KEY.includes('在这里填')) { + logStatus('❌ 自检失败:API Key 为空'); + return; + } + + checkBtn.disabled = true; + checkBtn.textContent = '⏳ 自检中...'; + + 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 (res) { + if (res.status === 200) { + logStatus('✅ 自检通过:模型接口可用'); + } else { + logStatus(`❌ 自检失败:HTTP ${res.status}`); + } + checkBtn.disabled = false; + checkBtn.textContent = '🔍 连通性自检'; + }, + onerror: function (err) { + logStatus(`❌ 自检失败:status=${err && err.status}`); + checkBtn.disabled = false; + checkBtn.textContent = '🔍 连通性自检'; + }, + ontimeout: function () { + logStatus('❌ 自检超时'); + checkBtn.disabled = false; + checkBtn.textContent = '🔍 连通性自检'; + } + }); + } + + async function previewPlanFromInput() { + const textArea = document.getElementById('follow-group-file-content'); + const onlyKeep = document.getElementById('follow-group-only-keep'); + const text = textArea ? textArea.value : ''; + const items = parseSuggestionText(text); + if (items.length === 0) { + logStatus('未解析到建议条目,请检查内容格式(md/json)'); + return; + } + const plan = buildPlanFromSuggestions(items, !!(onlyKeep && onlyKeep.checked)); + logStatus('建议文件解析成功'); + logStatus(renderPlanText(plan)); + } + + async function applyPlanFromInput() { + const textArea = document.getElementById('follow-group-file-content'); + const onlyKeep = document.getElementById('follow-group-only-keep'); + const applyBtn = document.getElementById('follow-group-apply-btn'); + + const bili = getBiliData(); + if (!bili.mid || !bili.csrf) { + alert('请先登录B站(需要cookie中的DedeUserID与bili_jct)'); + return; + } + + const text = textArea ? textArea.value : ''; + const items = parseSuggestionText(text); + if (items.length === 0) { + alert('未解析到建议条目,请先导入或粘贴分组建议文件内容'); + return; + } + + const plan = buildPlanFromSuggestions(items, !!(onlyKeep && onlyKeep.checked)); + const planGroups = Object.keys(plan).length; + if (planGroups === 0) { + alert('没有可执行分组(可能都被过滤掉)'); + return; + } + + applyBtn.disabled = true; + applyBtn.textContent = '⏳ 执行中...'; + document.getElementById('follow-group-log').innerHTML = ''; + try { + // 先校验分组映射 + const validation = await validatePlanAgainstExistingTags(plan); + if (!validation.valid) { + alert('没有有效的分组可执行。请先在B站创建对应的关注分组。'); + return; + } + + if (!confirm(`即将分配UP到B站现有分组:${Object.keys(validation.plan).length} 个分组,${Object.values(validation.plan).reduce((a, b) => a + b.length, 0)} 个UP,继续?`)) { + return; + } + + await executeGroupingFromPlan(validation.plan, validation.tagMap, bili.csrf); + } catch (e) { + logStatus(`❌ 执行失败: ${e && e.message ? e.message : e}`); + console.error(e); + } finally { + applyBtn.disabled = false; + applyBtn.textContent = '✅ 执行网页版分组'; + } + } + + function importSuggestionFile(evt) { + const file = evt && evt.target && evt.target.files ? evt.target.files[0] : null; + if (!file) return; + const reader = new FileReader(); + reader.onload = function () { + const text = String(reader.result || ''); + const textArea = document.getElementById('follow-group-file-content'); + if (textArea) textArea.value = text; + logStatus(`已导入建议文件: ${file.name}, 大小 ${file.size} bytes`); + }; + reader.onerror = function () { + logStatus('导入文件失败'); + }; + reader.readAsText(file, 'utf-8'); + } + + // ===== UI ===== + function initUI() { + if (document.getElementById('follow-group-wrapper')) return; + + const floatBtn = document.createElement('div'); + floatBtn.id = 'follow-group-float-btn'; + floatBtn.innerHTML = '🧠
关注分组'; + floatBtn.style.cssText = [ + 'position:fixed', + 'bottom:90px', + 'left:30px', + 'z-index:9999', + 'background:#00a1d6', + 'color:#fff', + 'width:58px', + 'height:58px', + 'border-radius:29px', + '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(0, 161, 214, 0.4)' + ].join(';'); + + const panel = document.createElement('div'); + panel.id = 'follow-group-wrapper'; + panel.style.cssText = [ + 'position:fixed', + 'bottom:90px', + 'left:30px', + 'z-index:10000', + 'width:360px', + 'display:none', + 'flex-direction:column', + 'box-shadow:0 5px 20px rgba(0,0,0,.2)', + 'border-radius:10px', + 'overflow:hidden' + ].join(';'); + + panel.innerHTML = ` +
+ 🧠 B站关注分组助手 + × +
+
+

自定义要求(可选)

+ + + + +
+

基于建议文件直接执行网页版分组

+ + + + + + +
等待指令...
+
+ `; + + document.body.appendChild(floatBtn); + document.body.appendChild(panel); + + floatBtn.onclick = () => { + floatBtn.style.display = 'none'; + panel.style.display = 'flex'; + }; + + document.getElementById('follow-group-close-btn').onclick = () => { + panel.style.display = 'none'; + floatBtn.style.display = 'flex'; + }; + + document.getElementById('follow-group-start-btn').onclick = startProcess; + document.getElementById('follow-group-check-btn').onclick = runConnectivityCheck; + document.getElementById('follow-group-preview-btn').onclick = previewPlanFromInput; + document.getElementById('follow-group-apply-btn').onclick = applyPlanFromInput; + document.getElementById('follow-group-file-input').addEventListener('change', importSuggestionFile); + + const input = document.getElementById('follow-group-custom-prompt'); + const cached = localStorage.getItem(PROMPT_CACHE_KEY); + if (cached) input.value = cached; + input.addEventListener('input', () => { + localStorage.setItem(PROMPT_CACHE_KEY, input.value); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initUI); + } else { + initUI(); + } +})();