From a2ea541b2d5a5b644ef9b2a118abc9fb264fb6f7 Mon Sep 17 00:00:00 2001
From: digouyou <2074920584@qq.com>
Date: Tue, 28 Apr 2026 22:42:16 +0800
Subject: [PATCH] =?UTF-8?q?=E6=B2=B9=E7=8C=B4=E8=84=9A=E6=9C=AC=E5=AE=9E?=
=?UTF-8?q?=E7=8E=B0=E8=87=AA=E5=8A=A8=E5=88=86=E7=BB=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
油猴脚本 copy.js | 307 ++++++++++++++
油猴脚本.js | 509 +++++++++++++++++++++++
油猴脚本_关注分组.js | 959 +++++++++++++++++++++++++++++++++++++++++++
3 files changed, 1775 insertions(+)
create mode 100644 油猴脚本 copy.js
create mode 100644 油猴脚本.js
create mode 100644 油猴脚本_关注分组.js
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();
+ }
+})();