Compare commits
3 Commits
62fa9d292b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2ea541b2d | |||
| cdf0ff74a2 | |||
| fe44d482b0 |
80
source/.gitignore
vendored
80
source/.gitignore
vendored
@@ -1,80 +0,0 @@
|
|||||||
# 1. 忽略操作系统自动生成的文件
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
*.lnk
|
|
||||||
|
|
||||||
# 2. 忽略编译/构建产物
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
*.so
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# 3. 忽略IDE配置
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
|
|
||||||
# 4. 忽略日志文件
|
|
||||||
*.log
|
|
||||||
*.tmp
|
|
||||||
*.temp
|
|
||||||
*.md
|
|
||||||
|
|
||||||
|
|
||||||
# 5. 忽略敏感数据
|
|
||||||
*.env
|
|
||||||
*.key
|
|
||||||
*.pem
|
|
||||||
*.cert
|
|
||||||
config.yaml
|
|
||||||
secrets/
|
|
||||||
|
|
||||||
# 6. 忽略大型媒体文件
|
|
||||||
*.mp4
|
|
||||||
*.mov
|
|
||||||
*.avi
|
|
||||||
*.wav
|
|
||||||
*.mp3
|
|
||||||
*.zip
|
|
||||||
*.tar
|
|
||||||
*.gz
|
|
||||||
*.7z
|
|
||||||
*.rar
|
|
||||||
|
|
||||||
# 7. 忽略数据分析/机器学习特有
|
|
||||||
*.model
|
|
||||||
*.h5
|
|
||||||
*.pkl
|
|
||||||
*.joblib
|
|
||||||
.ipynb_checkpoints/
|
|
||||||
|
|
||||||
# 8. 忽略你项目中的自动生成目录
|
|
||||||
# 根据你的目录结构,忽略source/output/和source/reports/下的所有文件
|
|
||||||
# 但保留目录结构本身(可以添加空的.gitkeep文件来保持空目录)
|
|
||||||
source/output/**/*
|
|
||||||
!source/output/.gitkeep
|
|
||||||
source/reports/**/*
|
|
||||||
!source/reports/.gitkeep
|
|
||||||
source/.note
|
|
||||||
source/.test_output
|
|
||||||
source/.all_i_need
|
|
||||||
source/.all_i_need/
|
|
||||||
1
source/resources/export_uids.json
Normal file
1
source/resources/export_uids.json
Normal file
File diff suppressed because one or more lines are too long
1
source/resources/export_uids.txt
Normal file
1
source/resources/export_uids.txt
Normal file
File diff suppressed because one or more lines are too long
1
source/resources/export_uids_test5.json
Normal file
1
source/resources/export_uids_test5.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[{"mid":1357612844,"name":"考研英语马天艺老师","tag":[]},{"mid":321583894,"name":"我是蓝同学啊","tag":["实力派"]},{"mid":439478093,"name":"中国食品报融媒体","tag":["纪录片","新闻"]},{"mid":1044673687,"name":"心理述","tag":[]},{"mid":62224043,"name":"栗之from一直夫妇","tag":[]},{"mid":1031543543,"name":"Java面试突击-Mic","tag":[]},{"mid":1481344732,"name":"我们都是社畜","tag":["准备取关"]},{"mid":475443398,"name":"黑毛羊驼","tag":["准备取关"]}]
|
||||||
1
source/resources/export_uids_test5.txt
Normal file
1
source/resources/export_uids_test5.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
1357612844,321583894,439478093,1044673687,62224043
|
||||||
307
油猴脚本 copy.js
Normal file
307
油猴脚本 copy.js
Normal 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
509
油猴脚本.js
Normal 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
959
油猴脚本_关注分组.js
Normal file
959
油猴脚本_关注分组.js
Normal 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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user