// ==UserScript== // @name Bilibili 半自动关注清理助手 v0.6 // @namespace https://docs.scriptcat.org/ // @version 0.6.0 // @description B站关注页半自动清理:快速队列扫描当前页“已关注/已互粉”按钮,按队列快速点击;支持弹窗反馈、手动/自动刷新当前页继续。 // @author know634 + ChatGPT // @match https://space.bilibili.com/*/relation/follow* // @grant none // @noframes // @license MIT // ==/UserScript== (function () { 'use strict'; /** * Bilibili 半自动关注清理助手 v0.6 * * v0.6 修正: * 1. 保留快速队列模式:一次扫描当前页目标按钮,灌入快照队列。 * 2. 目标状态收敛为“已关注 / 已互粉”。 * 3. 删除“特别关注”入口,避免误操作。 * * 设计边界: * 1. 只模拟用户在当前页面点击,不调用 B 站内部关注接口。 * 2. 不读取 cookie/token,不绕过验证码/风控/登录限制。 * 3. 自动刷新模式只刷新当前 URL,不自动翻页、不切换分组、不修改 tagid。 * 4. 每次只处理一种关注状态:已关注 / 已互粉。 */ const STORAGE_KEY = 'bilibili_unfollow_helper_v0_6_state'; const LEGACY_STORAGE_KEYS = [ 'bilibili_unfollow_helper_v0_5_state', 'bilibili_unfollow_helper_v0_5_1_state', 'bilibili_unfollow_helper_v0_5_2_state', ]; const PANEL_ID = 'tm-bili-unfollow-helper-v06'; const FLOAT_ID = 'tm-bili-unfollow-float-v06'; const MODAL_ID = 'tm-bili-unfollow-modal-v06'; const TARGETS = { NORMAL: '已关注', MUTUAL: '已互粉', }; const ALLOWED_TARGET_TEXTS = new Set(Object.values(TARGETS)); const runtime = { running: false, stopRequested: false, activeTarget: '', currentRoundDone: 0, currentRoundFailed: 0, statusTimer: null, }; const defaultConfig = { autoRefresh: false, maxTotal: 100, batchMax: 50, minDelay: 120, maxDelay: 260, verifyDelay: 0, }; function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function clampNumber(value, fallback, min, max) { const n = Number(value); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); } function randomDelay(min, max) { const lo = Math.min(min, max); const hi = Math.max(min, max); return Math.floor(lo + Math.random() * Math.max(0, hi - lo)); } function normalizeText(el) { return (el?.innerText || el?.textContent || '') .replace(/\s+/g, ' ') .trim(); } function isVisible(el) { if (!el) return false; const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); return ( rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' && style.opacity !== '0' ); } function safeJsonParse(text, fallback) { try { return JSON.parse(text); } catch (_) { return fallback; } } function readState() { const state = safeJsonParse(localStorage.getItem(STORAGE_KEY), null); if (!state) return null; if (state.targetText && !ALLOWED_TARGET_TEXTS.has(state.targetText)) { clearState(); return null; } return state; } function writeState(state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } function clearState() { localStorage.removeItem(STORAGE_KEY); } function clearLegacyStates() { for (const key of LEGACY_STORAGE_KEYS) { localStorage.removeItem(key); } } function getConfigFromPanel() { return { autoRefresh: Boolean(document.querySelector('#tm-bili-auto-refresh')?.checked), maxTotal: clampNumber(document.querySelector('#tm-bili-max-total')?.value, defaultConfig.maxTotal, 1, 9999), batchMax: clampNumber(document.querySelector('#tm-bili-batch-max')?.value, defaultConfig.batchMax, 1, 1000), minDelay: clampNumber(document.querySelector('#tm-bili-min-delay')?.value, defaultConfig.minDelay, 0, 60000), maxDelay: clampNumber(document.querySelector('#tm-bili-max-delay')?.value, defaultConfig.maxDelay, 0, 60000), verifyDelay: defaultConfig.verifyDelay, }; } function setPanelConfig(config) { const autoRefresh = document.querySelector('#tm-bili-auto-refresh'); const maxTotal = document.querySelector('#tm-bili-max-total'); const batchMax = document.querySelector('#tm-bili-batch-max'); const minDelay = document.querySelector('#tm-bili-min-delay'); const maxDelay = document.querySelector('#tm-bili-max-delay'); if (autoRefresh) autoRefresh.checked = Boolean(config.autoRefresh); if (maxTotal) maxTotal.value = String(config.maxTotal ?? defaultConfig.maxTotal); if (batchMax) batchMax.value = String(config.batchMax ?? defaultConfig.batchMax); if (minDelay) minDelay.value = String(config.minDelay ?? defaultConfig.minDelay); if (maxDelay) maxDelay.value = String(config.maxDelay ?? defaultConfig.maxDelay); } function ensureStyle() { if (document.querySelector('#tm-bili-unfollow-style-v06')) return; const style = document.createElement('style'); style.id = 'tm-bili-unfollow-style-v06'; style.textContent = ` #${PANEL_ID} { box-sizing: border-box; width: 100%; margin: 12px 0; padding: 12px; border: 1px solid #e3e5e7; border-radius: 10px; background: #ffffff; color: #18191c; font-size: 13px; line-height: 1.6; font-family: Arial, "Microsoft YaHei", sans-serif; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); } #${PANEL_ID}.tm-bili-fixed-fallback { position: fixed; right: 18px; bottom: 18px; width: 380px; z-index: 999998; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID} .tm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; } #${PANEL_ID} .tm-title { font-weight: 700; font-size: 14px; margin-bottom: 4px; } #${PANEL_ID} .tm-desc { color: #61666d; font-size: 12px; } #${PANEL_ID} button { border: 1px solid #c9ccd0; border-radius: 6px; background: #fff; color: #18191c; padding: 6px 10px; cursor: pointer; font-size: 13px; } #${PANEL_ID} button:hover { background: #f6f7f8; } #${PANEL_ID} button.tm-primary { border-color: #00aeec; background: #00aeec; color: #ffffff; } #${PANEL_ID} button.tm-warning { border-color: #ff7f24; color: #d35f00; } #${PANEL_ID} button.tm-danger { border-color: #f85a54; color: #d93832; } #${PANEL_ID} button.tm-stop { border-color: #f85a54; background: #fff0ef; color: #d93832; } #${PANEL_ID} input[type="number"] { width: 76px; padding: 4px 6px; border: 1px solid #c9ccd0; border-radius: 5px; } #${PANEL_ID} label { display: inline-flex; align-items: center; gap: 4px; color: #61666d; font-size: 12px; } #${PANEL_ID} .tm-status-line { margin-top: 8px; padding: 8px; border-radius: 6px; background: #f6f7f8; color: #61666d; white-space: pre-wrap; max-height: 88px; overflow: auto; } #${FLOAT_ID} { position: fixed; left: 50%; top: 90px; transform: translateX(-50%); min-width: 340px; max-width: 620px; z-index: 999999; padding: 14px 18px; border-radius: 10px; background: rgba(24, 25, 28, 0.92); color: #fff; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25); font-size: 14px; line-height: 1.7; white-space: pre-wrap; font-family: Arial, "Microsoft YaHei", sans-serif; } #${MODAL_ID} { position: fixed; inset: 0; z-index: 1000000; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.35); font-family: Arial, "Microsoft YaHei", sans-serif; } #${MODAL_ID} .tm-modal-card { width: min(560px, calc(100vw - 48px)); border-radius: 12px; background: #fff; color: #18191c; box-shadow: 0 12px 38px rgba(0, 0, 0, 0.25); overflow: hidden; } #${MODAL_ID} .tm-modal-title { padding: 16px 18px 8px 18px; font-size: 16px; font-weight: 700; } #${MODAL_ID} .tm-modal-body { padding: 4px 18px 14px 18px; color: #61666d; line-height: 1.7; white-space: pre-wrap; font-size: 14px; } #${MODAL_ID} .tm-modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px 16px 18px; border-top: 1px solid #f1f2f3; } #${MODAL_ID} button { border: 1px solid #c9ccd0; border-radius: 6px; background: #fff; color: #18191c; padding: 7px 14px; cursor: pointer; } #${MODAL_ID} button.tm-ok { border-color: #00aeec; background: #00aeec; color: #fff; } #${MODAL_ID} button.tm-danger-ok { border-color: #f85a54; background: #f85a54; color: #fff; } `; document.head.appendChild(style); } function setStatusLine(text) { const el = document.querySelector('#tm-bili-status-line'); if (el) el.textContent = text; } function showFloat(message, autoCloseMs = 0) { let float = document.querySelector(`#${FLOAT_ID}`); if (!float) { float = document.createElement('div'); float.id = FLOAT_ID; document.body.appendChild(float); } float.textContent = message; float.style.display = 'block'; setStatusLine(message); if (runtime.statusTimer) { clearTimeout(runtime.statusTimer); runtime.statusTimer = null; } if (autoCloseMs > 0) { runtime.statusTimer = setTimeout(() => { const f = document.querySelector(`#${FLOAT_ID}`); if (f) f.style.display = 'none'; }, autoCloseMs); } } function showModal({ title = '确认操作', message = '', okText = '确定', cancelText = '取消', danger = false, showCancel = true }) { return new Promise(resolve => { const old = document.querySelector(`#${MODAL_ID}`); if (old) old.remove(); const modal = document.createElement('div'); modal.id = MODAL_ID; modal.innerHTML = `
${showCancel ? '' : ''}
`; modal.querySelector('.tm-modal-title').textContent = title; modal.querySelector('.tm-modal-body').textContent = message; const ok = modal.querySelector('.tm-ok'); ok.textContent = okText; if (danger) ok.classList.add('tm-danger-ok'); const cancel = modal.querySelector('.tm-cancel'); if (cancel) cancel.textContent = cancelText; ok.addEventListener('click', () => { modal.remove(); resolve(true); }); if (cancel) { cancel.addEventListener('click', () => { modal.remove(); resolve(false); }); } document.body.appendChild(modal); }); } function findVisibleTextElement(textList, selector = 'button, div, span, li, a, p, h1, h2, h3, [role="button"]') { const candidates = Array.from(document.querySelectorAll(selector)).filter(isVisible); return candidates.find(el => { const t = normalizeText(el); return textList.some(text => t === text || t.includes(text)); }); } function getTargetFollowButtons(targetText) { return Array.from(document.querySelectorAll('.follow-btn__trigger')) .filter(isVisible) .filter(el => normalizeText(el) === targetText); } function getAnyFollowTriggers() { return Array.from(document.querySelectorAll('.follow-btn__trigger')).filter(isVisible); } function hasRiskOrLoginGate() { const riskTexts = [ '安全验证', '请完成验证', '验证码', '登录失效', '请先登录', '操作频繁', '风控', ]; return Boolean(findVisibleTextElement(riskTexts)); } function simulateClick(el) { if (!el) return; const opts = { bubbles: true, cancelable: true, view: window }; el.dispatchEvent(new MouseEvent('mouseover', opts)); el.dispatchEvent(new MouseEvent('mousedown', opts)); el.dispatchEvent(new MouseEvent('mouseup', opts)); el.dispatchEvent(new MouseEvent('click', opts)); } function getCardBrief(btn) { const card = btn.closest('.relation-card') || btn.closest('[class*="relation-card"]') || btn.closest('li') || btn.closest('[class*="card"]'); const text = normalizeText(card || btn); return text.slice(0, 80) || '当前账号'; } async function waitForPageContent(timeout = 12000) { const start = Date.now(); while (Date.now() - start < timeout) { if (getAnyFollowTriggers().length > 0) return true; if (hasRiskOrLoginGate()) return true; await sleep(250); } return false; } async function verifyButtonChanged(btn, targetText, beforeCount, verifyDelay) { await sleep(verifyDelay); const afterCount = getTargetFollowButtons(targetText).length; if (!btn.isConnected) { return { ok: true, reason: '原按钮节点已被页面重绘/移除' }; } if (!isVisible(btn)) { return { ok: true, reason: '原按钮已不可见' }; } const afterText = normalizeText(btn); if (afterText !== targetText) { return { ok: true, reason: `按钮文字已变化:${afterText || '空'}` }; } if (afterCount < beforeCount) { return { ok: true, reason: `目标按钮数量减少:${beforeCount} → ${afterCount}` }; } return { ok: false, reason: `点击后仍看到相同状态【${targetText}】,目标按钮数 ${beforeCount} → ${afterCount}`, }; } function collectTargetButtonSnapshot(targetText, limit) { const seen = new Set(); const queue = []; for (const el of Array.from(document.querySelectorAll('.follow-btn__trigger'))) { if (queue.length >= limit) break; if (!el || seen.has(el)) continue; seen.add(el); if (!isVisible(el)) continue; if (normalizeText(el) !== targetText) continue; queue.push(el); } return queue; } async function clickSnapshotQueue(targetText, state) { if (hasRiskOrLoginGate()) { throw new Error('检测到登录/验证/风控相关提示,已停止。'); } const remainingTotal = Math.max(0, state.maxTotal - state.doneTotal); const limit = Math.min(state.batchMax, remainingTotal); const queue = collectTargetButtonSnapshot(targetText, limit); if (queue.length === 0) { return { type: 'no-target', queued: 0, clicked: 0, skipped: 0, failed: 0 }; } showFloat( `已扫描当前页【${targetText}】按钮:${queue.length} 个 ` + `进入快速队列模式:不再每点一次重扫整页。 ` + `随机间隔:${state.minDelay} ~ ${state.maxDelay} ms ` + `可随时点击“停止自动清理”。` ); let clicked = 0; let skipped = 0; let failed = 0; for (let i = 0; i < queue.length; i += 1) { if (runtime.stopRequested) break; if (hasRiskOrLoginGate()) { throw new Error('检测到登录/验证/风控相关提示,已停止。'); } const btn = queue[i]; const indexText = `${i + 1} / ${queue.length}`; try { if (!btn || !btn.isConnected) { skipped += 1; continue; } if (!isVisible(btn)) { skipped += 1; continue; } const currentText = normalizeText(btn); if (currentText !== targetText) { skipped += 1; continue; } const brief = getCardBrief(btn); simulateClick(btn); clicked += 1; runtime.currentRoundDone += 1; state.doneTotal += 1; state.failureStreak = 0; if (clicked === 1 || clicked % 5 === 0 || i === queue.length - 1) { writeState(state); showFloat( `快速点击中:【${targetText}】 ` + `队列进度:${indexText} ` + `本轮已点:${clicked} ` + `总计:${state.doneTotal} / ${state.maxTotal} ` + `当前:${brief}`, 0 ); } const delay = randomDelay(state.minDelay, state.maxDelay); if (delay > 0) await sleep(delay); } catch (err) { failed += 1; state.failTotal += 1; state.failureStreak += 1; writeState(state); showFloat( `快速点击异常:${err?.message || err} ` + `队列进度:${indexText} ` + `连续失败:${state.failureStreak} / 3`, 1800 ); if (state.failureStreak >= 3) break; } } writeState(state); return { type: clicked > 0 ? 'success' : 'failed', queued: queue.length, clicked, skipped, failed, }; } function stopAll(reason = '已停止。', clear = true) { runtime.running = false; runtime.stopRequested = true; if (clear) clearState(); showFloat(reason, 5000); updateButtonsRunning(false); } function updateButtonsRunning(isRunning) { const buttons = document.querySelectorAll(`#${PANEL_ID} button[data-target]`); buttons.forEach(btn => { btn.disabled = isRunning; btn.style.opacity = isRunning ? '0.55' : '1'; }); const stopBtn = document.querySelector('#tm-bili-stop'); if (stopBtn) { stopBtn.disabled = !isRunning && !readState()?.active; stopBtn.style.opacity = stopBtn.disabled ? '0.55' : '1'; } } async function runBatch(state) { if (runtime.running) return; runtime.running = true; runtime.stopRequested = false; runtime.activeTarget = state.targetText; runtime.currentRoundDone = 0; runtime.currentRoundFailed = 0; updateButtonsRunning(true); try { await waitForPageContent(); if (state.doneTotal >= state.maxTotal) { stopAll(`已达到最大总数:${state.doneTotal} / ${state.maxTotal}。`, true); return; } const result = await clickSnapshotQueue(state.targetText, state); if (result.type === 'no-target') { runtime.running = false; updateButtonsRunning(false); clearState(); showFloat(`当前页没有可处理的【${state.targetText}】按钮,清理结束。`, 8000); return; } runtime.currentRoundFailed = (result.skipped || 0) + (result.failed || 0); if (result.type === 'failed' && result.clicked === 0) { state.failureStreak += 1; writeState(state); if (state.failureStreak >= 3) { stopAll('连续失败 3 次,已停止。建议手动检查页面按钮是否仍能直接点击取消。', true); return; } } runtime.running = false; updateButtonsRunning(false); const summary = `本轮快速队列完成。 ` + `目标状态:【${state.targetText}】 ` + `本轮扫描:${result.queued} ` + `本轮点击:${result.clicked} ` + `跳过/失效:${result.skipped} ` + `异常:${result.failed} ` + `总计点击:${state.doneTotal} / ${state.maxTotal}`; if (runtime.stopRequested) { clearState(); showFloat(`已停止。 ${summary}`, 5000); return; } if (state.doneTotal >= state.maxTotal) { clearState(); showFloat(`已达到最大总数,自动结束。 ${summary}`, 8000); return; } if (state.autoRefresh) { if (result.clicked > 0) { state.round += 1; state.lastUrl = location.href; state.active = true; state.resumeAfterReload = true; writeState(state); showFloat( `${summary} 自动刷新模式已开启。 1.5 秒后刷新当前页,继续处理补位到第一页的【${state.targetText}】。`, 0 ); await sleep(1500); if (!runtime.stopRequested) { location.reload(); } return; } clearState(); showFloat( `${summary} 当前页没有成功点击项,自动刷新清理结束。`, 8000 ); return; } clearState(); showFloat( `${summary} 当前为手动刷新模式。你可以手动刷新页面或翻页后再点一次按钮。`, 8000 ); } catch (err) { clearState(); runtime.running = false; updateButtonsRunning(false); showFloat(`异常停止:${err?.message || err} 建议手动检查页面状态。`, 10000); } } async function startByTarget(targetText) { if (!ALLOWED_TARGET_TEXTS.has(targetText)) { showFloat(`不支持的目标状态:【${targetText}】,已拒绝执行。`, 5000); return; } const config = getConfigFromPanel(); const danger = targetText !== TARGETS.NORMAL; const message = `即将直接点击当前页可见的【${targetText}】按钮本身。\n` + `不会查找二级菜单“取消关注”。\n\n` + `单轮最大:${config.batchMax}\n` + `最大总数:${config.maxTotal}\n` + `快速点击间隔:${config.minDelay} ~ ${config.maxDelay} ms\n` + `刷新模式:${config.autoRefresh ? '自动刷新当前页并继续' : '手动刷新 / 手动翻页'}\n\n` + `${targetText === TARGETS.NORMAL ? '推荐用于清理低价值单向关注。' : '已互粉可能包含熟人、同行或曾经互动对象,请确认后再继续。'}`; const ok = await showModal({ title: `确认取消【${targetText}】`, message, okText: '开始处理', cancelText: '取消', danger, showCancel: true, }); if (!ok) { showFloat('已取消本次操作。', 2500); return; } const state = { active: config.autoRefresh, autoRefresh: config.autoRefresh, targetText, maxTotal: config.maxTotal, batchMax: config.batchMax, minDelay: config.minDelay, maxDelay: config.maxDelay, verifyDelay: config.verifyDelay, doneTotal: 0, failTotal: 0, failureStreak: 0, round: 1, startedAt: Date.now(), startedUrl: location.href, lastUrl: location.href, resumeAfterReload: false, }; if (config.autoRefresh) { writeState(state); } else { clearState(); } runBatch(state); } function createPanel() { if (document.querySelector(`#${PANEL_ID}`)) return; ensureStyle(); const panel = document.createElement('div'); panel.id = PANEL_ID; panel.innerHTML = `
关注清理助手 v0.6
快速队列模式:一次扫描当前页【已关注 / 已互粉】按钮,灌入列表后快速点击;自动刷新只刷新当前 URL,不翻页、不切分组。
脚本已加载。v0.6 为快速队列模式:先扫描本页【已关注 / 已互粉】按钮,再按队列快速点击。
`; panel.querySelectorAll('button[data-target]').forEach(btn => { btn.addEventListener('click', () => { const target = btn.getAttribute('data-target'); startByTarget(target); }); }); panel.querySelector('#tm-bili-stop').addEventListener('click', () => { stopAll('已手动停止,并清除了自动刷新续跑状态。', true); }); mountPanel(panel); updateButtonsRunning(false); } function elementTextIncludes(el, text) { return normalizeText(el).includes(text); } function findBatchOperationElement() { const candidates = Array.from(document.querySelectorAll('button, div, span, a')) .filter(isVisible) .filter(el => elementTextIncludes(el, '批量操作')); return candidates[0] || null; } function tryMountPanelUnderBatch(panel) { const batchEl = findBatchOperationElement(); if (!batchEl) return false; const host = batchEl.closest('div') || batchEl.parentElement; const parent = host?.parentElement; if (!parent) return false; panel.classList.remove('tm-bili-fixed-fallback'); parent.insertBefore(panel, host.nextSibling); panel.dataset.mountedUnderBatch = '1'; return true; } function mountPanel(panel) { if (tryMountPanelUnderBatch(panel)) return; const relationContainer = document.querySelector('[class*="relation"]') || document.querySelector('#app') || document.body; if (relationContainer && relationContainer !== document.body) { relationContainer.insertBefore(panel, relationContainer.firstChild); return; } panel.classList.add('tm-bili-fixed-fallback'); document.body.appendChild(panel); } function relocatePanelIfBatchAppears() { const panel = document.querySelector(`#${PANEL_ID}`); if (!panel || panel.dataset.mountedUnderBatch === '1') return; tryMountPanelUnderBatch(panel); } async function autoResumeIfNeeded() { const state = readState(); if (!state || !state.active || !state.autoRefresh) return; if (!ALLOWED_TARGET_TEXTS.has(state.targetText)) { clearState(); showFloat('检测到旧版本无效续跑状态,已清除。', 5000); return; } createPanel(); setPanelConfig(state); updateButtonsRunning(true); showFloat( `检测到自动刷新续跑任务。\n` + `目标状态:【${state.targetText}】\n` + `已完成:${state.doneTotal} / ${state.maxTotal}\n` + `轮次:${state.round}\n\n` + `页面加载完成后继续。可点击“停止自动清理”。`, 0 ); await sleep(1500); if (runtime.stopRequested) return; if (state.lastUrl && state.lastUrl !== location.href) { clearState(); showFloat('当前 URL 与自动刷新任务 URL 不一致,已停止续跑。', 8000); updateButtonsRunning(false); return; } runBatch(state); } function bootstrap() { clearLegacyStates(); createPanel(); const state = readState(); if (state && state.autoRefresh) { setPanelConfig(state); } autoResumeIfNeeded(); } function waitDomReady() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootstrap, { once: true }); } else { bootstrap(); } } waitDomReady(); // B 站是 Vue 动态页面;如果“批量操作”稍后才渲染,尝试补挂一次。 let retryCount = 0; const mountRetry = setInterval(() => { retryCount += 1; if (!document.querySelector(`#${PANEL_ID}`)) { createPanel(); } else { relocatePanelIfBatchAppears(); } const panel = document.querySelector(`#${PANEL_ID}`); if (retryCount >= 20 || panel?.dataset.mountedUnderBatch === '1') { clearInterval(mountRetry); } }, 500); })();