// ==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 = /]*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 = '🧠
关注分组'; 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 = `
🧠 B站关注分组助手 ×

自定义要求(可选)


基于建议文件直接执行网页版分组

等待指令...
`; 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(); } })();