// ==UserScript== // @name B站全部取消关注(自动续跑) // @namespace codex.local // @version 1.0.0 // @description 使用B站关注页原生按钮分批取消关注,自动刷新续跑,遇到手机号验证自动暂停。 // @match https://space.bilibili.com/*/relation/follow* // @grant none // @run-at document-idle // ==/UserScript== (() => { "use strict"; if (window.__CODEX_BILI_UNFOLLOW_ALL__) return; window.__CODEX_BILI_UNFOLLOW_ALL__ = true; const STORAGE_KEY = "codex:bilibili-unfollow-all:v1"; const MIN_DELAY_MS = 600; const MAX_DELAY_MS = 900; const PAGE_SIZE = 24; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const readState = () => { try { return { running: false, success: 0, startedAt: null, ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"), }; } catch { return { running: false, success: 0, startedAt: null }; } }; const writeState = patch => { const next = { ...readState(), ...patch }; localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); return next; }; const isRunning = () => readState().running === true; const currentMid = () => { const match = location.pathname.match(/^\/(\d+)\/relation\/follow/); return match?.[1] || null; }; const getFollowButtons = () => [...document.querySelectorAll(".follow-btn__trigger.gray")].filter(el => (el.textContent || "").includes("已关注"), ); const isVisible = el => Boolean(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length)); const hasPhoneVerification = () => [...document.querySelectorAll("body *")].some( el => isVisible(el) && (el.textContent || "").trim() === "请输入您绑定的手机号码", ); const pressEscape = () => { for (const type of ["keydown", "keyup"]) { document.dispatchEvent( new KeyboardEvent(type, { key: "Escape", code: "Escape", keyCode: 27, which: 27, bubbles: true, }), ); } }; const identifyAccount = button => { let node = button; for (let i = 0; i < 8 && node; i++, node = node.parentElement) { const link = [...node.querySelectorAll('a[href*="follow.user_card.click"]')] .find(el => (el.textContent || "").trim()); if (!link) continue; const mid = (link.getAttribute("href") || "").match( /space\.bilibili\.com\/(\d+)/, )?.[1]; return { mid: mid || "unknown", name: (link.textContent || "").trim() || "未知账号", }; } return { mid: "unknown", name: "未知账号" }; }; const waitForButtonDecrease = async (before, timeoutMs = 1800) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { await sleep(100); const count = getFollowButtons().length; if (count === before - 1) return count; if (hasPhoneVerification()) return count; } return getFollowButtons().length; }; const fetchLiveTotal = async () => { const mid = currentMid(); if (!mid) return null; const params = new URLSearchParams({ order: "desc", order_type: "", vmid: mid, pn: "1", ps: "1", gaia_source: "main_web", web_location: "333.1387", }); try { const response = await fetch( `https://api.bilibili.com/x/relation/followings?${params}`, { credentials: "include" }, ); const result = await response.json(); return result.code === 0 ? Number(result.data?.total ?? 0) : null; } catch { return null; } }; const waitForPageReady = async (timeoutMs = 30000) => { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline && isRunning()) { if (hasPhoneVerification()) return "verification"; if (getFollowButtons().length > 0) return "ready"; const total = await fetchLiveTotal(); if (total === 0) return "complete"; await sleep(500); } return "timeout"; }; const panel = document.createElement("div"); panel.id = "codex-bili-unfollow-panel"; panel.style.cssText = [ "position:fixed", "right:20px", "bottom:20px", "z-index:2147483647", "width:280px", "padding:14px", "border-radius:10px", "background:rgba(25,25,25,.94)", "color:#fff", "font:13px/1.6 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif", "box-shadow:0 8px 28px rgba(0,0,0,.35)", ].join(";"); panel.innerHTML = `
B站全部取消关注
等待操作
`; document.body.appendChild(panel); const statusElement = panel.querySelector("#codex-bili-unfollow-status"); const startButton = panel.querySelector("#codex-bili-unfollow-start"); const stopButton = panel.querySelector("#codex-bili-unfollow-stop"); const setStatus = text => { statusElement.textContent = text; console.log(`[B站取消关注] ${text}`); }; const pauseForVerification = async () => { setStatus("B站要求手机号验证,请手动完成;验证消失后会自动继续。请勿关闭此页面。"); while (isRunning() && hasPhoneVerification()) { await sleep(1500); } if (isRunning()) { setStatus("验证已解除,准备继续……"); await sleep(1000); } }; const unfollowOne = async () => { pressEscape(); await sleep(150); let buttons = getFollowButtons(); const before = buttons.length; if (before === 0) return { status: "empty" }; const account = identifyAccount(buttons[0]); buttons[0].click(); let after = await waitForButtonDecrease(before); pressEscape(); if (hasPhoneVerification()) { return { status: "verification", account }; } // 偶尔第一次点击只会关闭上一条遗留的菜单,确认数量没变化后补点一次。 if (after !== before - 1) { await sleep(250); pressEscape(); buttons = getFollowButtons(); if (buttons.length === before) { buttons[0].click(); after = await waitForButtonDecrease(before); pressEscape(); } } if (hasPhoneVerification()) { return { status: "verification", account }; } if (after !== before - 1) { return { status: "failed", account, before, after }; } return { status: "success", account }; }; const run = async () => { if (!isRunning()) return; const ready = await waitForPageReady(); if (ready === "verification") { await pauseForVerification(); if (isRunning()) run(); return; } if (ready === "complete") { writeState({ running: false }); setStatus(`完成:当前关注数为 0。本次确认取消 ${readState().success} 个。`); alert("B站关注数已为 0,自动清理完成。"); return; } if (ready !== "ready") { writeState({ running: false }); setStatus("没有等到关注列表加载,已停止。请刷新页面后重试。"); return; } let pageSuccess = 0; while (isRunning() && pageSuccess < PAGE_SIZE) { if (hasPhoneVerification()) { await pauseForVerification(); continue; } const result = await unfollowOne(); if (result.status === "empty") break; if (result.status === "verification") { await pauseForVerification(); continue; } if (result.status === "failed") { writeState({ running: false }); setStatus(`账号“${result.account.name}”状态没有变化,已停止,避免误操作。`); return; } pageSuccess++; const state = writeState({ success: readState().success + 1 }); setStatus( `本页 ${pageSuccess}/${PAGE_SIZE};本次累计 ${state.success}。刚取消:${result.account.name}`, ); const delay = MIN_DELAY_MS + Math.floor(Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS + 1)); await sleep(delay); } if (!isRunning()) { setStatus(`已手动停止。本次确认取消 ${readState().success} 个。`); return; } const total = await fetchLiveTotal(); if (total === 0) { writeState({ running: false }); setStatus(`完成:当前关注数为 0。本次确认取消 ${readState().success} 个。`); alert("B站关注数已为 0,自动清理完成。"); return; } setStatus( total == null ? "本页完成,2 秒后刷新并继续。" : `当前剩余 ${total} 个,2 秒后刷新并继续。`, ); await sleep(2000); if (isRunning()) location.reload(); }; startButton.addEventListener("click", () => { if (isRunning()) { setStatus("任务已经在运行。"); return; } const confirmed = confirm( "将持续取消此账号的全部关注,并在每页完成后自动刷新续跑。\n\n" + "包括特别关注、自建分组和互关账号。是否开始?", ); if (!confirmed) return; writeState({ running: true, success: 0, startedAt: new Date().toISOString() }); setStatus("任务已开始,正在等待关注列表……"); run(); }); stopButton.addEventListener("click", () => { writeState({ running: false }); setStatus(`停止指令已生效。本次确认取消 ${readState().success} 个。`); }); const state = readState(); if (state.running) { setStatus(`检测到未完成任务,本次已确认取消 ${state.success} 个,自动继续……`); run(); } else { setStatus("已暂停。点击“开始”后自动处理、刷新并续跑。"); } })();