960 lines
33 KiB
JavaScript
960 lines
33 KiB
JavaScript
// ==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();
|
||
}
|
||
})();
|