Compare commits

..

6 Commits

Author SHA1 Message Date
a2ea541b2d 油猴脚本实现自动分组 2026-04-28 22:42:16 +08:00
cdf0ff74a2 done 2026-04-27 00:22:25 +08:00
fe44d482b0 强制停止跟踪 all_i_need 目录 2026-04-27 00:08:43 +08:00
62fa9d292b add .gitignore to root, remove .all_i_need output from tracking 2026-04-27 00:04:53 +08:00
4f2d338101 强制停止跟踪 all_i_need 目录 2026-04-26 23:47:06 +08:00
e79651eb04 wfl 2026-04-26 23:44:33 +08:00
16 changed files with 1776 additions and 13 deletions

View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
1044673687,1481344732,1858861103,444728505,23947287,35807625,111714204,1587138171,440798355,33291981,11914415,436700803,3493282273299102,612593877,2125857107,2000819931,507448807,505935166,14524124,385200931,1769820463,1562896062,3493285194632125,3493264275540254,479424216,604710494,1016523517,1428318343,700817047,543931674,1590538073,1574721168,432752294,3494376355400290,1795221360,4848323,495224316,3493258518858434,379247856,32360194,381653678,274928598,475656605,365212208,3546378525477862,35339643,1747335,1263990139,3493263038220393,251642119,387412319,1212367465,589747109,1025542770,23770618,3494350482836026,54091976,599449178,1715594148,3493127266503448,1767282898,487505057,630874464,1264711195,3537118481615036,319358609,518742534,385172962,4401694,474803476,525382468,3546595513600180,295993972,476819048,21435789,1725223092,2114928296,174471602,1480366563,17095888,295100453,1305776725,25694274,14797570,166828,385126080,3461582166166488,3537120815745590,489302782,73674032,1500074803,68134500,1047158092,3546571071293861,124806013,26055664,441631812,243680430,601300995,108526737,2100151539,3546603229023143,1749224369,3493133887211865,56300844,255139870,23244398,3493291869866324,3494354444355822,3546593938639500,1098004826,94577838,21849780,35105301,423319981,535023713,224560702,3546637651675315,3494361759221832,1640934198,1710911403,14342271,2031277323,603430640,3546568640694467,1741962246,1304346514,283389925,3461575868418125,3546622413768823,3494364269513335,185549749,502539494,73528331,510767506,3461579156752681,238171381,3546627212052911,448165099,1975692083,542824499,16243913,3494354016537425,316627722,1944667205,1433031509,3546387566299549,496787581,3546643550963789,382423121,600428973,430426421,325848853,735958,35162124,668794433,3546390949005555,478548163,3546672034482563,250584301,485234598,1555665460,6776617,108709998,437840703,28378491,67079745,1606682745,629101318,452161580,3493089637305282,374377163,213845897,323713206,272107494,622986240,1773278179,3546656899336980,67141499,318331,285027361,114366178,203983793,1283676771,1965933018,470624011,3546583482239276,3493281239402498,1475977561,2016676980,1209319826,1335124945,416206486,129860965,1780480185,1809567655,245645656,1937416537,1060544882,1335713025,3546617688886097,3546752326044595,3546613148551357,652060948,2116071253,97407861,3546908731639909,3546693165386233,278761367,323588182,486989780,3494353494345852,96609715,264869770,478849208,1679822121,19414347,3493127314737312,702915816,482867012,3546969421122388,3546590214097572,501642082,458165375,3546662484052067,481153145,1159873315,3546857594685834,1508100119,111900,1732848825,3546606469123022,106685726,490494088,1511660367

View File

@@ -1 +0,0 @@
1044673687,1481344732,1858861103,444728505,23947287,35807625,111714204,1587138171,440798355,33291981,11914415,436700803,3493282273299102,612593877,2125857107,2000819931,507448807,505935166,14524124,385200931,1769820463,1562896062,3493285194632125,3493264275540254,479424216,604710494,1016523517,1428318343,700817047,543931674,1590538073,1574721168,432752294,3494376355400290,1795221360,4848323,495224316,3493258518858434,379247856,32360194,381653678,274928598,475656605,365212208,3546378525477862,35339643,1747335,1263990139,3493263038220393,251642119,387412319,1212367465,589747109,1025542770,23770618,3494350482836026,54091976,599449178,1715594148,3493127266503448,1767282898,487505057,630874464,1264711195,3537118481615036,319358609,518742534,385172962,4401694,474803476,525382468,3546595513600180,295993972,476819048,21435789,1725223092,2114928296,174471602,1480366563,17095888,295100453,1305776725,25694274,14797570,166828,385126080,3461582166166488,3537120815745590,489302782,73674032,1500074803,68134500,1047158092,3546571071293861,124806013,26055664,441631812,243680430,601300995,108526737

View File

@@ -1 +0,0 @@
2100151539,3546603229023143,1749224369,3493133887211865,56300844,255139870,23244398,3493291869866324,3494354444355822,3546593938639500,1098004826,94577838,21849780,35105301,423319981,535023713,224560702,3546637651675315,3494361759221832,1640934198,1710911403,14342271,2031277323,603430640,3546568640694467,1741962246,1304346514,283389925,3461575868418125,3546622413768823,3494364269513335,185549749,502539494,73528331,510767506,3461579156752681,238171381,3546627212052911,448165099,1975692083,542824499,16243913,3494354016537425,316627722,1944667205,1433031509,3546387566299549,496787581,3546643550963789,382423121,600428973,430426421,325848853,735958,35162124,668794433,3546390949005555,478548163,3546672034482563,250584301,485234598,1555665460,6776617,108709998,437840703,28378491,67079745,1606682745,629101318,452161580,3493089637305282,374377163,213845897,323713206,272107494,622986240,1773278179,3546656899336980,67141499,318331,285027361,114366178,203983793,1283676771,1965933018,470624011,3546583482239276,3493281239402498,1475977561,2016676980,1209319826,1335124945,416206486,129860965,1780480185,1809567655,245645656,1937416537,1060544882,1335713025

View File

@@ -1 +0,0 @@
3546617688886097,3546752326044595,3546613148551357,652060948,2116071253,97407861,3546908731639909,3546693165386233,278761367,323588182,486989780,3494353494345852,96609715,264869770,478849208,1679822121,19414347,3493127314737312,702915816,482867012,3546969421122388,3546590214097572,501642082,458165375,3546662484052067,481153145,1159873315,3546857594685834,1508100119,111900,1732848825,3546606469123022,106685726,490494088,1511660367

View File

@@ -1 +0,0 @@
321583894,439478093,1044673687,1031543543,1481344732

View File

@@ -1 +0,0 @@
321583894,439478093,1044673687,1031543543,1481344732

View File

@@ -1 +0,0 @@
1044673687,1481344732,1858861103,444728505,23947287,35807625,111714204,1587138171,440798355,33291981,11914415,436700803,3493282273299102,612593877,2125857107,2000819931,507448807,505935166,14524124,385200931,1769820463,1562896062,3493285194632125,3493264275540254,479424216,604710494,1016523517,1428318343,700817047,543931674,1590538073,1574721168,432752294,3494376355400290,1795221360,4848323,495224316,3493258518858434,379247856,32360194,381653678,274928598,475656605,365212208,3546378525477862,35339643,1747335,1263990139,3493263038220393,251642119,387412319,1212367465,589747109,1025542770,23770618,3494350482836026,54091976,599449178,1715594148,3493127266503448,1767282898,487505057,630874464,1264711195,3537118481615036,319358609,518742534,385172962,4401694,474803476,525382468,3546595513600180,295993972,476819048,21435789,1725223092,2114928296,174471602,1480366563,17095888,295100453,1305776725,25694274,14797570,166828,385126080,3461582166166488,3537120815745590,489302782,73674032,1500074803,68134500,1047158092,3546571071293861,124806013,26055664,441631812,243680430,601300995,108526737,2100151539,3546603229023143,1749224369,3493133887211865,56300844,255139870,23244398,3493291869866324,3494354444355822,3546593938639500,1098004826,94577838,21849780,35105301,423319981,535023713,224560702,3546637651675315,3494361759221832,1640934198,1710911403,14342271,2031277323,603430640,3546568640694467,1741962246,1304346514,283389925,3461575868418125,3546622413768823,3494364269513335,185549749,502539494,73528331,510767506,3461579156752681,238171381,3546627212052911,448165099,1975692083,542824499,16243913,3494354016537425,316627722,1944667205,1433031509,3546387566299549,496787581,3546643550963789,382423121,600428973,430426421,325848853,735958,35162124,668794433,3546390949005555,478548163,3546672034482563,250584301,485234598,1555665460,6776617,108709998,437840703,28378491,67079745,1606682745,629101318,452161580,3493089637305282,374377163,213845897,323713206,272107494,622986240,1773278179,3546656899336980,67141499,318331,285027361,114366178,203983793,1283676771,1965933018,470624011,3546583482239276,3493281239402498,1475977561,2016676980,1209319826,1335124945,416206486,129860965,1780480185,1809567655,245645656,1937416537,1060544882,1335713025,3546617688886097,3546752326044595,3546613148551357,652060948,2116071253,97407861,3546908731639909,3546693165386233,278761367,323588182,486989780,3494353494345852,96609715,264869770,478849208,1679822121,19414347,3493127314737312,702915816,482867012,3546969421122388,3546590214097572,501642082,458165375,3546662484052067,481153145,1159873315,3546857594685834,1508100119,111900,1732848825,3546606469123022,106685726,490494088,1511660367

View File

@@ -1 +0,0 @@
1044673687,1481344732,1858861103,444728505,23947287,35807625,111714204,1587138171,440798355,33291981,11914415,436700803,3493282273299102,612593877,2125857107,2000819931,507448807,505935166,14524124,385200931,1769820463,1562896062,3493285194632125,3493264275540254,479424216,604710494,1016523517,1428318343,700817047,543931674,1590538073,1574721168,432752294,3494376355400290,1795221360,4848323,495224316,3493258518858434,379247856,32360194,381653678,274928598,475656605,365212208,3546378525477862,35339643,1747335,1263990139,3493263038220393,251642119,387412319,1212367465,589747109,1025542770,23770618,3494350482836026,54091976,599449178,1715594148,3493127266503448,1767282898,487505057,630874464,1264711195,3537118481615036,319358609,518742534,385172962,4401694,474803476,525382468,3546595513600180,295993972,476819048,21435789,1725223092,2114928296,174471602,1480366563,17095888,295100453,1305776725,25694274,14797570,166828,385126080,3461582166166488,3537120815745590,489302782,73674032,1500074803,68134500,1047158092,3546571071293861,124806013,26055664,441631812,243680430,601300995,108526737

View File

@@ -1 +0,0 @@
2100151539,3546603229023143,1749224369,3493133887211865,56300844,255139870,23244398,3493291869866324,3494354444355822,3546593938639500,1098004826,94577838,21849780,35105301,423319981,535023713,224560702,3546637651675315,3494361759221832,1640934198,1710911403,14342271,2031277323,603430640,3546568640694467,1741962246,1304346514,283389925,3461575868418125,3546622413768823,3494364269513335,185549749,502539494,73528331,510767506,3461579156752681,238171381,3546627212052911,448165099,1975692083,542824499,16243913,3494354016537425,316627722,1944667205,1433031509,3546387566299549,496787581,3546643550963789,382423121,600428973,430426421,325848853,735958,35162124,668794433,3546390949005555,478548163,3546672034482563,250584301,485234598,1555665460,6776617,108709998,437840703,28378491,67079745,1606682745,629101318,452161580,3493089637305282,374377163,213845897,323713206,272107494,622986240,1773278179,3546656899336980,67141499,318331,285027361,114366178,203983793,1283676771,1965933018,470624011,3546583482239276,3493281239402498,1475977561,2016676980,1209319826,1335124945,416206486,129860965,1780480185,1809567655,245645656,1937416537,1060544882,1335713025

View File

@@ -1 +0,0 @@
3546617688886097,3546752326044595,3546613148551357,652060948,2116071253,97407861,3546908731639909,3546693165386233,278761367,323588182,486989780,3494353494345852,96609715,264869770,478849208,1679822121,19414347,3493127314737312,702915816,482867012,3546969421122388,3546590214097572,501642082,458165375,3546662484052067,481153145,1159873315,3546857594685834,1508100119,111900,1732848825,3546606469123022,106685726,490494088,1511660367

307
油猴脚本 copy.js Normal file
View File

@@ -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 += `<div style="margin-top:4px;">➜ ${msg}</div>`;
logDiv.scrollTop = logDiv.scrollHeight;
}
}
function getBiliData() {
const midMatch = document.cookie.match(/DedeUserID=([^;]+)/);
const csrfMatch = document.cookie.match(/bili_jct=([^;]+)/);
return { mid: midMatch ? midMatch[1] : '', csrf: csrfMatch ? csrfMatch[1] : '' };
}
function getSourceMediaId() {
const params = new URLSearchParams(window.location.search);
return params.get('fid') || params.get('media_id') || params.get('id');
}
function buildFormData(obj) {
return new URLSearchParams(obj).toString();
}
// 获取已有收藏夹列表 (过滤掉默认收藏夹)
async function getMyFolders(biliData) {
const url = `https://api.bilibili.com/x/v3/fav/folder/created/list-all?up_mid=${biliData.mid}`;
const res = await fetch(url, { credentials: 'include' }).then(r => r.json());
if (res.code === 0 && res.data && res.data.list) {
const folderMap = {};
res.data.list.forEach(f => {
if (f.title !== '默认收藏夹') folderMap[f.title] = f.id;
});
return folderMap;
}
return {};
}
async function createFolder(title, biliData) {
logStatus(`📁 正在新建收藏夹:【${title}`);
const url = 'https://api.bilibili.com/x/v3/fav/folder/add';
const data = buildFormData({ title: title, privacy: 1, csrf: biliData.csrf });
const res = await fetch(url, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: data
}).then(r => r.json());
if (res.code === 0) return res.data.id;
throw new Error(`新建失败: ${res.message}`);
}
async function moveVideos(sourceMediaId, tarMediaId, resourcesStr, biliData) {
const url = 'https://api.bilibili.com/x/v3/fav/resource/move';
const data = buildFormData({
src_media_id: sourceMediaId, tar_media_id: tarMediaId, mid: biliData.mid, resources: resourcesStr, csrf: biliData.csrf
});
const res = await fetch(url, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: data
}).then(r => r.json());
if (res.code !== 0) console.error("移动失败:", res.message);
}
async function startProcess() {
const biliData = getBiliData();
const btn = document.getElementById('ai-start-btn');
const customPromptInput = document.getElementById('ai-custom-prompt');
if (!biliData.mid || !biliData.csrf) return alert("请确保你在 B 站已登录!");
const sourceMediaId = getSourceMediaId();
if (!sourceMediaId) return alert("未能识别当前页面的收藏夹 ID请确保你在某个具体的收藏夹页面内。");
const userRequirement = customPromptInput.value.trim();
btn.innerText = '🔄 整理中,请看下方日志...';
btn.disabled = true;
btn.style.background = '#ccc';
document.getElementById('ai-status-log').innerHTML = '';
try {
// 1. 获取现有的“家底”
logStatus(`正在获取现有的收藏夹列表...`);
const existingFoldersMap = await getMyFolders(biliData);
const existingFolderNames = Object.keys(existingFoldersMap);
logStatus(`📦 发现 ${existingFolderNames.length} 个已有收藏夹`);
// 2. 全量抓取视频
logStatus(`开始全量抓取当前收藏夹视频...`);
let allVideos = [];
let pn = 1;
const ps = 20;
while (true) {
logStatus(`正在读取第 ${pn} 页...`);
const listUrl = `https://api.bilibili.com/x/v3/fav/resource/list?media_id=${sourceMediaId}&pn=${pn}&ps=${ps}&platform=web`;
const listRes = await fetch(listUrl, { credentials: 'include' }).then(r => r.json());
if (listRes.code !== 0) {
logStatus(`❌ 读取出错: ${listRes.message}`);
break;
}
const videos = (listRes.data && listRes.data.medias) ? listRes.data.medias : [];
if (videos.length === 0) break;
allVideos.push(...videos);
if (videos.length < ps) break;
pn++;
await sleep(300);
}
if (allVideos.length === 0) {
logStatus("⚠️ 当前收藏夹是空的!");
resetButton(btn);
return;
}
logStatus(`✅ 读取完毕!共获取到 ${allVideos.length} 个视频。`);
logStatus(`🧠 正在呼叫 ${MODEL_NAME} 进行匹配与思考,请耐心等待...`);
const videoDataForAI = allVideos.map(v => ({
id: v.id, type: v.type, title: v.title, intro: v.intro ? v.intro.substring(0, 30) : ''
}));
// ====== 强化版:动态注入存量数据和用户需求 ======
const customRuleText = userRequirement ? `\n\n【⭐⭐⭐用户特殊需求 (最高优先级)⭐⭐⭐】\n用户的特别指示是:"${userRequirement}"\n请你务必听从!\n⚠️ 致命警告:如果用户的指示中提到了要把视频放入某个分类,请你务必在上面的【已有收藏夹】列表里寻找最匹配的准确名称!如果用户打字简写了(比如用户说“音乐”,但已有的是“我的音乐”),你必须输出已有收藏夹的完整名称“我的音乐”,绝不允许凭空新建近义词分类!` : '';
const combinedPrompt = `你是一个逻辑极其严密的文件整理专家。我现在需要你帮我把一批 B 站视频分类。
非常重要:用户目前已经建好了以下这些收藏夹:
[ ${existingFolderNames.length > 0 ? existingFolderNames.join(', ') : '暂无'} ]
请你严格按照以下 3 个步骤执行:
【步骤 1存量强制匹配】
通读所有视频。只要视频内容沾边,就必须一字不差地使用上述【已有收藏夹】的名称作为分类键名。
【步骤 2谨慎新建】
只有当某几个视频确实与所有“已有收藏夹”都毫不相干时,你才可以创建一个新的涵盖面广的“大类”。绝不为单一视频建新分类,孤立视频请塞入最贴近的已有分类。
【步骤 3绝无遗漏】
确保列表中的**每一个视频**都被分配到了具体的分类中,绝对不可以遗漏任何一个 ID${customRuleText}
请严格输出合法的纯 JSON 格式数据。包含 "thoughts" 和 "categories" 两个字段。
示例:
{
"thoughts": "分析发现视频A符合已有的'游戏实况'分类。用户要求把XX放入YY我发现已有收藏夹中叫'YY合集',因此我将它们放入'YY合集'中...",
"categories": {
"已有收藏夹准确名字1": [{"id": 111, "type": 2}],
"新创建的大类名字": [{"id": 222, "type": 2}]
}
}
以下是待处理的所有视频:
${JSON.stringify(videoDataForAI)}`;
GM_xmlhttpRequest({
method: "POST",
url: API_URL,
headers: {
"Content-Type": "application/json",
"Authorization": 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 = '🤖<br>AI整理';
floatBtn.style.cssText = 'position:fixed; bottom:30px; left:30px; z-index:9999; background:#fb7299; color:white; width:50px; height:50px; border-radius:25px; display:flex; align-items:center; justify-content:center; text-align:center; font-size:12px; font-weight:bold; cursor:pointer; box-shadow: 0 4px 10px rgba(251, 114, 153, 0.4); transition:0.3s;';
const panel = document.createElement('div');
panel.id = 'ai-sort-wrapper';
panel.style.cssText = 'position:fixed; bottom:30px; left:30px; z-index:10000; width:320px; display:none; flex-direction:column; box-shadow: 0 5px 20px rgba(0,0,0,0.2); border-radius:10px; overflow:hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;';
panel.innerHTML = `
<div style="background:#fb7299; color:#fff; padding:12px 15px; font-weight:bold; font-size:15px; display:flex; justify-content:space-between; align-items:center;">
<span>🤖 AI 收藏夹整理助理</span>
<span id="ai-close-btn" style="cursor:pointer; font-size:18px; line-height:1;">×</span>
</div>
<div style="background:#fff; padding:15px; border:1px solid #eee; border-top:none;">
<p style="margin:0 0 8px 0; font-size:13px; color:#555;">有什么特定的整理要求吗?(选填)</p>
<textarea id="ai-custom-prompt" placeholder="例如:\n- 把所有 Vue 相关的放一个文件夹\n- 把时长超过1小时的单独拎出来\n(不填则优先放入已有收藏夹,并由 AI 自由发挥补充分类)" style="width:100%; height:80px; padding:8px; box-sizing:border-box; border:1px solid #ddd; border-radius:6px; font-size:13px; resize:none; margin-bottom:12px; outline:none;"></textarea>
<button id="ai-start-btn" style="width:100%; padding:10px; background:#fb7299; color:white; border:none; border-radius:6px; font-size:14px; font-weight:bold; cursor:pointer; transition:background 0.2s;">🚀 开始深度整理</button>
<div id="ai-status-log" style="margin-top:15px; background:#f4f4f4; padding:8px; border-radius:6px; font-size:12px; color:#333; height:120px; overflow-y:auto; word-break:break-all;">
等待指令...
</div>
</div>
`;
document.body.appendChild(floatBtn);
document.body.appendChild(panel);
floatBtn.onclick = () => {
floatBtn.style.display = 'none';
panel.style.display = 'flex';
};
document.getElementById('ai-close-btn').onclick = () => {
panel.style.display = 'none';
floatBtn.style.display = 'flex';
};
document.getElementById('ai-start-btn').onclick = startProcess;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initUI);
} else {
initUI();
}
})();

509
油猴脚本.js Normal file
View File

@@ -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 += `<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();
}
})();

View File

@@ -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 = /<img[^>]*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 = '🧠<br>关注分组';
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 = `
<div style="background:#00a1d6;color:#fff;padding:12px 15px;font-weight:bold;font-size:15px;display:flex;justify-content:space-between;align-items:center;">
<span>🧠 B站关注分组助手</span>
<span id="follow-group-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="follow-group-custom-prompt" placeholder="例如:更严格,只保留硬核技术;把英语学习优先保留" style="width:100%;height:72px;padding:8px;box-sizing:border-box;border:1px solid #ddd;border-radius:6px;font-size:13px;resize:none;margin-bottom:10px;"></textarea>
<button id="follow-group-start-btn" style="width:100%;padding:10px;background:#00a1d6;color:#fff;border:none;border-radius:6px;font-size:14px;font-weight:bold;cursor:pointer;">🚀 开始关注分组</button>
<button id="follow-group-check-btn" style="width:100%;padding:10px;margin-top:8px;background:#2ca24f;color:#fff;border:none;border-radius:6px;font-size:14px;font-weight:bold;cursor:pointer;">🔍 连通性自检</button>
<hr style="margin:12px 0;border:none;border-top:1px solid #eee;" />
<p style="margin:0 0 8px 0;font-size:13px;color:#555;">基于建议文件直接执行网页版分组</p>
<input id="follow-group-file-input" type="file" accept=".md,.json,.txt" style="margin-bottom:8px;width:100%;" />
<textarea id="follow-group-file-content" placeholder="可粘贴分组建议文件内容支持markdown/json" style="width:100%;height:80px;padding:8px;box-sizing:border-box;border:1px solid #ddd;border-radius:6px;font-size:12px;resize:vertical;margin-bottom:8px;"></textarea>
<label style="display:flex;align-items:center;font-size:12px;color:#666;gap:6px;margin-bottom:8px;">
<input id="follow-group-only-keep" type="checkbox" checked />
仅执行“建议动作=保留关注”
</label>
<button id="follow-group-preview-btn" style="width:100%;padding:9px;background:#ff9800;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:bold;cursor:pointer;">🧪 预览分组计划</button>
<button id="follow-group-apply-btn" style="width:100%;padding:9px;margin-top:8px;background:#7b61ff;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:bold;cursor:pointer;">✅ 执行网页版分组</button>
<div id="follow-group-log" style="margin-top:12px;background:#f5f5f5;padding:8px;border-radius:6px;font-size:12px;color:#333;height:140px;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('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();
}
})();