Files
bili_follow_group/油猴脚本_关注分组.js

960 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name B站关注分组助手火山方舟
// @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();
}
})();