Files
bili_follow_group/油猴脚本.js

509 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==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 += `<div style="margin-top:4px;">➜ ${msg}</div>`;
logDiv.scrollTop = logDiv.scrollHeight;
}
}
function getBiliData() {
const midMatch = document.cookie.match(/DedeUserID=([^;]+)/);
const csrfMatch = document.cookie.match(/bili_jct=([^;]+)/);
return { mid: midMatch ? midMatch[1] : '', csrf: csrfMatch ? csrfMatch[1] : '' };
}
function getSourceMediaId() {
const params = new URLSearchParams(window.location.search);
return params.get('fid') || params.get('media_id') || params.get('id');
}
function buildFormData(obj) {
return new URLSearchParams(obj).toString();
}
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 = '🤖<br>AI整理';
floatBtn.style.cssText = 'position:fixed; bottom:30px; left:30px; z-index:9999; background:#fb7299; color:white; width:50px; height:50px; border-radius:25px; display:flex; align-items:center; justify-content:center; text-align:center; font-size:12px; font-weight:bold; cursor:pointer; box-shadow: 0 4px 10px rgba(251, 114, 153, 0.4); transition:0.3s;';
const panel = document.createElement('div');
panel.id = 'ai-sort-wrapper';
panel.style.cssText = 'position:fixed; bottom:30px; left:30px; z-index:10000; width:320px; display:none; flex-direction:column; box-shadow: 0 5px 20px rgba(0,0,0,0.2); border-radius:10px; overflow:hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;';
panel.innerHTML = `
<div style="background:#fb7299; color:#fff; padding:12px 15px; font-weight:bold; font-size:15px; display:flex; justify-content:space-between; align-items:center;">
<span>🤖 AI 收藏夹整理助理</span>
<span id="ai-close-btn" style="cursor:pointer; font-size:18px; line-height:1;">×</span>
</div>
<div style="background:#fff; padding:15px; border:1px solid #eee; border-top:none;">
<p style="margin:0 0 8px 0; font-size:13px; color:#555;">有什么特定的整理要求吗?(选填)</p>
<textarea id="ai-custom-prompt" placeholder="例如:\n- 把所有 Vue 相关的放一个文件夹\n- 把时长超过1小时的单独拎出来\n(不填则优先放入已有收藏夹,并由 AI 自由发挥补充分类)" style="width:100%; height:80px; padding:8px; box-sizing:border-box; border:1px solid #ddd; border-radius:6px; font-size:13px; resize:none; margin-bottom:12px; outline:none;"></textarea>
<button id="ai-start-btn" style="width:100%; padding:10px; background:#fb7299; color:white; border:none; border-radius:6px; font-size:14px; font-weight:bold; cursor:pointer; transition:background 0.2s;">🚀 开始深度整理</button>
<button id="ai-check-btn" style="width:100%; padding:10px; margin-top:8px; background:#00a1d6; color:white; border:none; border-radius:6px; font-size:14px; font-weight:bold; cursor:pointer; transition:background 0.2s;">🔍 连通性自检</button>
<div id="ai-status-log" style="margin-top:15px; background:#f4f4f4; padding:8px; border-radius:6px; font-size:12px; color:#333; height:120px; overflow-y:auto; word-break:break-all;">
等待指令...
</div>
</div>
`;
document.body.appendChild(floatBtn);
document.body.appendChild(panel);
floatBtn.onclick = () => {
floatBtn.style.display = 'none';
panel.style.display = 'flex';
};
document.getElementById('ai-close-btn').onclick = () => {
panel.style.display = 'none';
floatBtn.style.display = 'flex';
};
document.getElementById('ai-start-btn').onclick = startProcess;
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();
}
})();