// ==UserScript== // @name B站批量移除粉丝(支持批量移除非互粉用户) // @namespace bilibili-fans-cleaner-v4 // @version 1.0.1 // @author Kaesinol, aryayaya // @description 批量移除 B 站粉丝,清理僵尸粉(支持批量移除非互粉用户) // @license GPL-3.0 // @icon https://www.bilibili.com/favicon.ico // @website https://github.com/kaixinol/bilibili-fans-remover-userscript // @supportURL https://github.com/kaixinol/bilibili-fans-remover-userscript/issues // @match https://space.bilibili.com/* // @require https://cdn.jsdelivr.net/npm/alpinejs@3.15.11/dist/cdn.min.js // @connect api.bilibili.com // @grant GM_getValue // @grant GM_setValue // @noframes // ==/UserScript== (function (Alpine) { 'use strict'; const APP_LOG_PREFIX = "[Bilibili Fans Cleaner]"; const APP_VERSION = "1.0.1"; const CONFIG_STORAGE_KEY = "bk-fans-cleaner-config"; const DEFAULT_CONFIG = { pageSize: 50, removeDelayMs: 800, bulkFetchDelayMinMs: 1e3, bulkFetchDelayMaxMs: 1500 }; function normalizePositiveInt(value, fallback) { if (typeof value !== "number" || !Number.isFinite(value)) { return fallback; } const normalized = Math.round(value); return normalized > 0 ? normalized : fallback; } function normalizeConfig(config) { const pageSize = normalizePositiveInt(config.pageSize, DEFAULT_CONFIG.pageSize); const removeDelayMs = normalizePositiveInt(config.removeDelayMs, DEFAULT_CONFIG.removeDelayMs); const bulkFetchDelayMinMs = normalizePositiveInt( config.bulkFetchDelayMinMs, DEFAULT_CONFIG.bulkFetchDelayMinMs ); const bulkFetchDelayMaxMs = normalizePositiveInt( config.bulkFetchDelayMaxMs, DEFAULT_CONFIG.bulkFetchDelayMaxMs ); return { pageSize, removeDelayMs, bulkFetchDelayMinMs: Math.min(bulkFetchDelayMinMs, bulkFetchDelayMaxMs), bulkFetchDelayMaxMs: Math.max(bulkFetchDelayMinMs, bulkFetchDelayMaxMs) }; } async function getFansCleanerConfig() { try { const storedConfig = await Promise.resolve( GM_getValue(CONFIG_STORAGE_KEY, {}) ); return normalizeConfig(storedConfig); } catch { return DEFAULT_CONFIG; } } async function setFansCleanerConfig(config) { const normalizedConfig = normalizeConfig(config); await Promise.resolve(GM_setValue(CONFIG_STORAGE_KEY, normalizedConfig)); return normalizedConfig; } function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length !== 2) { return null; } return parts.pop()?.split(";").shift() ?? null; } function parseMidFromLocation(url) { return url.match(/space\.bilibili\.com\/(\d+)/)?.[1] ?? null; } function injectStyle(css) { const style = document.createElement("style"); style.textContent = css; document.head.appendChild(style); } function normalizeError(error) { return error instanceof Error ? error.message : String(error); } function randomBetween(min, max) { return Math.round(min + Math.random() * (max - min)); } function logInfo(message, payload) { if (payload === void 0) { console.info(APP_LOG_PREFIX, message); return; } console.info(APP_LOG_PREFIX, message, payload); } const md5 = (message) => { const buffer = new TextEncoder().encode(message); const n = buffer.length; const words = new Uint32Array((n + 8 >> 6) + 1 << 4); for (let i = 0; i < n; i++) words[i >> 2] |= buffer[i] << i % 4 * 8; words[n >> 2] |= 128 << n % 4 * 8; words[words.length - 2] = n * 8; let [a, b, c, d] = [1732584193, 4023233417, 2562383102, 271733878]; const K = Uint32Array.from( { length: 64 }, (_, i) => Math.abs(Math.sin(i + 1)) * 4294967296 >>> 0 ); const S = [ 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21 ]; const rotl = (x, n2) => x << n2 | x >>> 32 - n2; for (let i = 0; i < words.length; i += 16) { let [A, B, C, D] = [a, b, c, d]; for (let j = 0; j < 64; j++) { let f, g; if (j < 16) { f = B & C | ~B & D; g = j; } else if (j < 32) { f = D & B | ~D & C; g = (5 * j + 1) % 16; } else if (j < 48) { f = B ^ C ^ D; g = (3 * j + 5) % 16; } else { f = C ^ (B | ~D); g = 7 * j % 16; } const temp = D; D = C; C = B; const x = A + f + K[j] + words[i + g] | 0; B = B + rotl(x, S[j]) | 0; A = temp; } a = a + A | 0; b = b + B | 0; c = c + C | 0; d = d + D | 0; } const outBuf = new ArrayBuffer(16); const view = new DataView(outBuf); [a, b, c, d].forEach((val, i) => view.setUint32(i * 4, val, true)); return Array.from(new Uint8Array(outBuf)).map((b2) => b2.toString(16).padStart(2, "0")).join(""); }; const mixinKeyEncTab = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52 ]; const getMixinKey = (origin) => mixinKeyEncTab.map((index) => origin[index]).join("").slice(0, 32); function encWbi(params, imgKey, subKey) { const mixinKey = getMixinKey(imgKey + subKey); const currentTime = Math.round(Date.now() / 1e3); const characterFilter = /[!'()*]/g; const enrichedParams = { ...params, wts: currentTime }; const query = Object.keys(enrichedParams).sort().map((key) => { const value = String(enrichedParams[key]).replace(characterFilter, ""); return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; }).join("&"); return `${query}&w_rid=${md5(query + mixinKey)}`; } async function getWbiKeys() { logInfo("API 调用: 获取 WBI keys", { url: "https://api.bilibili.com/x/web-interface/nav" }); const response = await fetch("https://api.bilibili.com/x/web-interface/nav", { credentials: "include", cache: "no-store" }); const payload = await response.json(); const { img_url: imageUrl, sub_url: subUrl } = payload.data.wbi_img; logInfo("API 返回: 获取 WBI keys", { imgKey: imageUrl.slice(imageUrl.lastIndexOf("/") + 1, imageUrl.lastIndexOf(".")), subKey: subUrl.slice(subUrl.lastIndexOf("/") + 1, subUrl.lastIndexOf(".")) }); return { imgKey: imageUrl.slice(imageUrl.lastIndexOf("/") + 1, imageUrl.lastIndexOf(".")), subKey: subUrl.slice(subUrl.lastIndexOf("/") + 1, subUrl.lastIndexOf(".")) }; } async function getNavData() { logInfo("API 调用: 获取登录用户信息", { url: "https://api.bilibili.com/x/web-interface/nav" }); const response = await fetch("https://api.bilibili.com/x/web-interface/nav", { credentials: "include", cache: "no-store" }); const payload = await response.json(); logInfo("API 返回: 获取登录用户信息", { mid: payload.data.mid }); return payload.data; } async function fetchFansPage(mid, page, wbiKeys, pageSize) { const query = encWbi( { vmid: mid, pn: page, ps: pageSize, order: "desc", order_type: "attention" }, wbiKeys.imgKey, wbiKeys.subKey ); logInfo("API 调用: 获取粉丝列表", { endpoint: "x/relation/followers", mid, page, pageSize }); const response = await fetch(`https://api.bilibili.com/x/relation/followers?${query}`, { credentials: "include" }); const payload = await response.json(); logInfo("API 返回: 获取粉丝列表", { mid, page, code: payload.code, total: payload.data?.total ?? 0, listCount: payload.data?.list?.length ?? 0 }); return payload; } async function fetchFollowingsPage(mid, page, wbiKeys, pageSize) { const query = encWbi( { vmid: mid, pn: page, ps: pageSize, order: "desc", order_type: "attention" }, wbiKeys.imgKey, wbiKeys.subKey ); logInfo("API 调用: 获取关注列表", { endpoint: "x/relation/followings", mid, page, pageSize }); const response = await fetch(`https://api.bilibili.com/x/relation/followings?${query}`, { credentials: "include", headers: { Referer: "https://space.bilibili.com/" } }); const payload = await response.json(); logInfo("API 返回: 获取关注列表", { mid, page, code: payload.code, total: payload.data?.total ?? 0, listCount: payload.data?.list?.length ?? 0 }); return payload; } async function loadAllFollowings(mid, wbiKeys, pageSize, onProgress) { const firstPage = await fetchFollowingsPage(mid, 1, wbiKeys, pageSize); if (firstPage.code !== 0) { return { code: firstPage.code, message: firstPage.message, data: new Set() }; } const followingMidSet = new Set((firstPage.data.list ?? []).map((item) => String(item.mid))); const totalPages = Math.max(1, Math.ceil(firstPage.data.total / pageSize)); for (let page = 2; page <= totalPages; page += 1) { await onProgress?.(page, totalPages); const response = await fetchFollowingsPage(mid, page, wbiKeys, pageSize); if (response.code !== 0) { return { code: response.code, message: response.message, data: followingMidSet }; } for (const item of response.data.list ?? []) { followingMidSet.add(String(item.mid)); } } return { code: 0, message: "0", data: followingMidSet }; } async function kickFan(fid, csrf) { logInfo("API 调用: 移除粉丝", { endpoint: "x/relation/modify", fid, act: 7 }); const body = new URLSearchParams({ fid, act: "7", re_src: "11", csrf }); const response = await fetch("https://api.bilibili.com/x/relation/modify", { method: "POST", body, headers: { "Content-Type": "application/x-www-form-urlencoded" }, credentials: "include" }); const payload = await response.json(); logInfo("API 返回: 移除粉丝", { fid, code: payload.code, message: payload.message }); return payload; } const RISK_CONTROL_CODE = -352; const LIST_CONTAINER_ID = "bk-list-scroll"; const VIRTUAL_ITEM_HEIGHT = 78; const VIRTUAL_OVERSCAN = 10; const LOAD_MORE_THRESHOLD_PX = 320; const toFanId = (mid) => String(mid); const createEmptyStatusMap = () => ({}); const isRiskControlTriggered = (code) => code === RISK_CONTROL_CODE; const buildBulkLoadWarning = (totalFans, totalPages, bulkFetchDelayMinMs, bulkFetchDelayMaxMs) => `警告:你有 ${totalFans} 个粉丝,需要连续请求 ${totalPages} 次。 连续高频请求很容易触发风控,程序会在每次请求间强制等待 ${(bulkFetchDelayMinMs / 1e3).toFixed(1)}~${(bulkFetchDelayMaxMs / 1e3).toFixed(1)} 秒。 是否继续加载全部?`; const buildNonMutualWarning = (count, removeDelayMs) => `即将移除 ${count} 个非互粉粉丝。 系统会先按节流策略逐个调用移除接口,间隔 ${(removeDelayMs / 1e3).toFixed(1)} 秒。 确定继续吗?`; const ownSpaceOnlyMessage = "仅支持当前登录用户自己的个人空间"; const getListContainer = () => document.getElementById(LIST_CONTAINER_ID); function createFansCleanerApp({ mid, csrf, isOwnSpace, config }) { const updateVirtualWindow = (app, resetScroll = false) => { const container = getListContainer(); const viewportHeight = container?.clientHeight ?? 560; if (resetScroll && container) { container.scrollTop = 0; } const scrollTop = container?.scrollTop ?? 0; const visibleCount = Math.max( 1, Math.ceil(viewportHeight / VIRTUAL_ITEM_HEIGHT) + VIRTUAL_OVERSCAN * 2 ); const start = Math.max(0, Math.floor(scrollTop / VIRTUAL_ITEM_HEIGHT) - VIRTUAL_OVERSCAN); const end = Math.min(app.fans.length, start + visibleCount); app.visibleStartIndex = start; app.visibleEndIndex = end; }; const replaceFans = (app, nextFans, resetScroll = true) => { app.fans = nextFans; updateVirtualWindow(app, resetScroll); }; const appendFans = (app, nextFans) => { app.fans = [...app.fans, ...nextFans]; updateVirtualWindow(app); }; const syncLocalStateAfterRemoval = (app, removedIds) => { if (removedIds.length === 0) { return; } const removedIdSet = new Set(removedIds); app.fans = app.fans.filter(({ mid: fanMid }) => !removedIdSet.has(toFanId(fanMid))); app.selectedFanIds = app.selectedFanIds.filter((fanId) => !removedIdSet.has(fanId)); app.nonMutualFanIds = app.nonMutualFanIds.filter((fanId) => !removedIdSet.has(fanId)); app.totalFans = Math.max(0, app.totalFans - removedIds.length); app.totalPages = Math.max(1, Math.ceil(app.totalFans / app.config.pageSize)); app.hasMoreFansToLoad = !app.showingAllFans && app.currentPage < app.totalPages; updateVirtualWindow(app); }; const removeFansByIds = async (app, fanIds) => { app.removing = true; app.statusBar = "正在处理..."; let successCount = 0; let failCount = 0; for (const [index, fanId] of fanIds.entries()) { app.statuses[fanId] = { text: "处理中...", tone: "pending" }; try { const response = await kickFan(fanId, csrf); if (response.code === 0) { app.statuses[fanId] = { text: "已移除", tone: "success" }; app.removedFanIds = app.removedFanIds.includes(fanId) ? app.removedFanIds : [...app.removedFanIds, fanId]; successCount += 1; } else { app.statuses[fanId] = { text: "失败", tone: "error", title: response.message }; failCount += 1; } } catch (error) { app.statuses[fanId] = { text: "错误", tone: "error", title: normalizeError(error) }; failCount += 1; } app.statusBar = `进度: ${index + 1}/${fanIds.length}`; if (index < fanIds.length - 1) { await sleep(app.config.removeDelayMs); } } app.removing = false; syncLocalStateAfterRemoval( app, fanIds.filter((fanId) => app.statuses[fanId]?.tone === "success") ); app.statusBar = `操作完成。成功 ${successCount},失败 ${failCount}`; window.alert(`操作完成。成功: ${successCount},失败: ${failCount}`); }; return { panelOpen: false, settingsOpen: false, loading: false, bulkLoading: false, loadingFollowings: false, removing: false, savingSettings: false, requiresRiskVerification: false, errorMessage: "", statusBar: `就绪 v${APP_VERSION}`, currentPage: 1, totalPages: 1, totalFans: 0, showingAllFans: false, fans: [], selectedFanIds: [], removedFanIds: [], nonMutualFanIds: [], statuses: createEmptyStatusMap(), wbiKeys: null, followingMidSet: null, visibleStartIndex: 0, visibleEndIndex: 0, hasMoreFansToLoad: true, config: { ...config }, get actionsDisabled() { return !isOwnSpace; }, get isBusy() { return this.loading || this.bulkLoading || this.loadingFollowings || this.removing; }, get pageInfo() { if (!isOwnSpace) { return ownSpaceOnlyMessage; } return this.showingAllFans ? `已载入全部 ${this.fans.length} 人,滚动查看预览` : `已加载 ${this.fans.length}/${this.totalFans || 0} 人,滚动到底继续加载`; }, get visibleFans() { return this.fans.slice(this.visibleStartIndex, this.visibleEndIndex); }, get topSpacerHeight() { return this.visibleStartIndex * VIRTUAL_ITEM_HEIGHT; }, get bottomSpacerHeight() { return Math.max(0, (this.fans.length - this.visibleEndIndex) * VIRTUAL_ITEM_HEIGHT); }, async togglePanel() { this.panelOpen = !this.panelOpen; if (this.panelOpen && !isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } if (this.panelOpen && this.fans.length === 0 && !this.loading) { await this.loadFans(1); } else if (this.panelOpen) { this.resetViewport(); } }, closePanel() { this.panelOpen = false; this.settingsOpen = false; }, toggleSettings() { this.settingsOpen = !this.settingsOpen; }, async saveSettings() { this.savingSettings = true; try { const nextConfig = await setFansCleanerConfig(this.config); this.config = { ...nextConfig }; this.statusBar = "设置已保存,后续请求将使用新配置"; this.settingsOpen = false; } catch (error) { this.errorMessage = `设置保存失败: ${normalizeError(error)}`; this.statusBar = "设置保存失败"; } finally { this.savingSettings = false; } }, async resetSettings() { this.savingSettings = true; try { const nextConfig = await setFansCleanerConfig(DEFAULT_CONFIG); this.config = { ...nextConfig }; this.statusBar = "设置已重置为默认值"; } catch (error) { this.errorMessage = `设置重置失败: ${normalizeError(error)}`; this.statusBar = "设置重置失败"; } finally { this.savingSettings = false; } }, async refreshCurrentPage() { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } return this.loadFans(1); }, async loadFans(page = 1) { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } this.loading = true; this.requiresRiskVerification = false; this.errorMessage = ""; this.showingAllFans = false; this.hasMoreFansToLoad = true; this.statusBar = "读取中..."; try { this.wbiKeys ??= await getWbiKeys(); const response = await fetchFansPage(mid, page, this.wbiKeys, this.config.pageSize); if (isRiskControlTriggered(response.code)) { this.requiresRiskVerification = true; this.statusBar = "需要验证"; replaceFans(this, []); return; } if (response.code !== 0) { this.errorMessage = `API 错误: ${response.message} (${response.code})`; this.statusBar = "请求失败"; replaceFans(this, []); return; } this.currentPage = page; this.totalFans = response.data.total; this.totalPages = Math.max(1, Math.ceil(response.data.total / this.config.pageSize)); this.hasMoreFansToLoad = this.currentPage < this.totalPages; replaceFans(this, response.data.list ?? []); this.selectedFanIds = []; this.nonMutualFanIds = []; this.statusBar = this.hasMoreFansToLoad ? `已加载 ${this.fans.length}/${this.totalFans} 粉丝,可继续下滑追加` : `已加载 ${this.totalFans} 粉丝`; } catch (error) { this.errorMessage = `请求失败: ${normalizeError(error)}`; this.statusBar = "请求失败"; } finally { this.loading = false; } }, async loadAllFans() { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } if (this.totalFans > this.config.pageSize && !window.confirm( buildBulkLoadWarning( this.totalFans, this.totalPages, this.config.bulkFetchDelayMinMs, this.config.bulkFetchDelayMaxMs ) )) { return; } this.bulkLoading = true; this.requiresRiskVerification = false; this.errorMessage = ""; this.statusBar = "起步中,准备全量抓取..."; try { this.wbiKeys ??= await getWbiKeys(); const firstPage = await fetchFansPage(mid, 1, this.wbiKeys, this.config.pageSize); if (isRiskControlTriggered(firstPage.code)) { this.requiresRiskVerification = true; this.statusBar = "需要验证"; return; } if (firstPage.code !== 0) { this.errorMessage = `API 错误: ${firstPage.message} (${firstPage.code})`; this.statusBar = "请求失败"; return; } const allFans = [...firstPage.data.list ?? []]; const targetPages = Math.max(1, Math.ceil(firstPage.data.total / this.config.pageSize)); let loadedPages = 1; let interruptedByRiskControl = false; let partialFailureMessage = ""; this.totalFans = firstPage.data.total; this.totalPages = targetPages; for (const page of Array.from({ length: Math.max(0, targetPages - 1) }, (_, index) => index + 2)) { this.statusBar = `正在拉取第 ${page} 页...`; await sleep( randomBetween(this.config.bulkFetchDelayMinMs, this.config.bulkFetchDelayMaxMs) ); const response = await fetchFansPage(mid, page, this.wbiKeys, this.config.pageSize); if (isRiskControlTriggered(response.code)) { interruptedByRiskControl = true; loadedPages = page - 1; this.requiresRiskVerification = true; this.errorMessage = `拉取第 ${page} 页时触发风控,当前只保留前 ${page - 1} 页缓存。`; window.alert(`拉取第 ${page} 页时触发了风控拦截,已保留前 ${page - 1} 页数据。`); break; } if (response.code !== 0) { loadedPages = page - 1; partialFailureMessage = `拉取第 ${page} 页失败: ${response.message} (${response.code})`; this.errorMessage = partialFailureMessage; break; } allFans.push(...response.data.list ?? []); loadedPages = page; } this.currentPage = loadedPages; this.hasMoreFansToLoad = loadedPages < targetPages; this.showingAllFans = loadedPages >= targetPages; replaceFans(this, allFans, true); this.selectedFanIds = []; this.nonMutualFanIds = []; this.statusBar = interruptedByRiskControl ? `全量拉取被风控中断,已缓存 ${allFans.length}/${this.totalFans} 粉丝` : partialFailureMessage ? `全量拉取中断,已缓存 ${allFans.length}/${this.totalFans} 粉丝` : `全量获取完成,共缓存 ${allFans.length} 粉丝`; } catch (error) { this.errorMessage = `请求中断: ${normalizeError(error)}`; this.statusBar = "请求中断"; } finally { this.bulkLoading = false; } }, async loadAllFollowings() { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return null; } if (this.followingMidSet && this.followingMidSet.size > 0) { logInfo("复用关注集合缓存", { size: this.followingMidSet.size }); this.statusBar = `已复用关注缓存,共 ${this.followingMidSet.size} 人`; return this.followingMidSet; } this.loadingFollowings = true; this.requiresRiskVerification = false; this.errorMessage = ""; this.statusBar = "正在拉取全部关注..."; try { this.wbiKeys ??= await getWbiKeys(); const response = await loadAllFollowings( mid, this.wbiKeys, this.config.pageSize, async (page, totalPages) => { this.statusBar = `正在拉取全部关注... ${page}/${totalPages}`; await sleep( randomBetween(this.config.bulkFetchDelayMinMs, this.config.bulkFetchDelayMaxMs) ); } ); if (isRiskControlTriggered(response.code)) { this.requiresRiskVerification = true; this.statusBar = "需要验证"; return null; } if (response.code !== 0) { this.errorMessage = `关注列表获取失败: ${response.message} (${response.code})`; this.statusBar = "请求失败"; return null; } this.followingMidSet = response.data; this.statusBar = `关注列表获取完成,共 ${response.data.size} 人`; return response.data; } catch (error) { this.errorMessage = `关注列表获取失败: ${normalizeError(error)}`; this.statusBar = "请求失败"; return null; } finally { this.loadingFollowings = false; } }, async loadNextFansPage() { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } if (this.showingAllFans || this.loading || this.bulkLoading || !this.hasMoreFansToLoad) { return; } const nextPage = this.currentPage + 1; if (nextPage > this.totalPages) { this.hasMoreFansToLoad = false; return; } this.loading = true; this.errorMessage = ""; this.statusBar = `正在追加第 ${nextPage} 页...`; try { this.wbiKeys ??= await getWbiKeys(); const response = await fetchFansPage(mid, nextPage, this.wbiKeys, this.config.pageSize); if (isRiskControlTriggered(response.code)) { this.requiresRiskVerification = true; this.statusBar = "需要验证"; this.hasMoreFansToLoad = false; return; } if (response.code !== 0) { this.errorMessage = `API 错误: ${response.message} (${response.code})`; this.statusBar = "请求失败"; this.hasMoreFansToLoad = false; return; } this.currentPage = nextPage; this.totalFans = response.data.total; this.totalPages = Math.max(1, Math.ceil(response.data.total / this.config.pageSize)); this.hasMoreFansToLoad = this.currentPage < this.totalPages; appendFans(this, response.data.list ?? []); this.statusBar = this.hasMoreFansToLoad ? `已加载 ${this.fans.length}/${this.totalFans} 人,继续向下滚动可追加` : `已加载完全部 ${this.fans.length} 人`; } catch (error) { this.errorMessage = `请求失败: ${normalizeError(error)}`; this.statusBar = "请求失败"; this.hasMoreFansToLoad = false; } finally { this.loading = false; } }, toggleSelectAll() { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } const fanIds = this.fans.map(({ mid: fanMid }) => toFanId(fanMid)); const allChecked = fanIds.length > 0 && fanIds.every((id) => this.selectedFanIds.includes(id)); this.selectedFanIds = allChecked ? [] : fanIds; }, async kickSelectedFans() { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } if (this.selectedFanIds.length === 0) { window.alert("请先勾选需要移除的粉丝。"); return; } if (!window.confirm(`即将移除 ${this.selectedFanIds.length} 个粉丝,确定继续吗?`)) { return; } await removeFansByIds(this, [...this.selectedFanIds]); }, async kickNonMutualFans() { if (!isOwnSpace) { this.statusBar = ownSpaceOnlyMessage; return; } if (!this.showingAllFans) { await this.loadAllFans(); } if (this.requiresRiskVerification || this.errorMessage) { return; } const followingMidSet = await this.loadAllFollowings(); if (!followingMidSet) { return; } this.statusBar = "正在比对互粉..."; const nonMutualFans = this.fans.filter(({ mid: fanMid }) => !followingMidSet.has(toFanId(fanMid))); const nonMutualFanIds = this.fans.map(({ mid: fanMid }) => toFanId(fanMid)).filter((fanId) => !followingMidSet.has(fanId)); this.nonMutualFanIds = nonMutualFanIds; this.selectedFanIds = nonMutualFanIds; logInfo("将要被移除的非互粉用户列表", nonMutualFans); if (nonMutualFanIds.length === 0) { this.statusBar = "当前缓存中没有非互粉粉丝"; window.alert("当前缓存中没有非互粉粉丝。"); return; } this.statusBar = `即将移除 ${nonMutualFanIds.length} 个非互粉粉丝`; if (!window.confirm(buildNonMutualWarning(nonMutualFanIds.length, this.config.removeDelayMs))) { return; } await removeFansByIds(this, nonMutualFanIds); }, async handleListScroll() { if (!isOwnSpace) { return; } this.resetViewport(false); const container = getListContainer(); if (!container || this.showingAllFans) { return; } const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight; if (distanceToBottom <= LOAD_MORE_THRESHOLD_PX) { await this.loadNextFansPage(); } }, resetViewport(resetScroll = false) { updateVirtualWindow(this, resetScroll); }, isRemoved(itemMid) { return this.removedFanIds.includes(toFanId(itemMid)); }, statusText(itemMid) { return this.statuses[toFanId(itemMid)]?.text ?? ""; }, statusToneClass(itemMid) { return this.statuses[toFanId(itemMid)]?.tone ?? ""; }, statusTitle(itemMid) { return this.statuses[toFanId(itemMid)]?.title ?? ""; } }; } const panelTemplate = `
粉丝清理大师
TypeScript + Alpine.js
保存后对后续请求生效,并写入油猴存储。
`; const styles = '[x-cloak] {\n display: none !important;\n}\n\n#bk-cleaner-root {\n font-family: "Segoe UI", "PingFang SC", "Hiragino Sans GB", sans-serif;\n}\n\n.bk-float-btn {\n position: fixed;\n right: 0;\n top: 40vh;\n z-index: 9999;\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 10px 12px;\n border: 0;\n border-radius: 10px 0 0 10px;\n background: linear-gradient(135deg, #0f172a, #1d4ed8);\n color: #fff;\n cursor: pointer;\n box-shadow: 0 16px 36px rgba(15, 23, 42, 0.35);\n transition: transform 0.2s ease, padding 0.2s ease;\n}\n\n.bk-float-btn:hover {\n transform: translateX(-4px);\n padding-left: 16px;\n}\n\n.bk-overlay {\n position: fixed;\n inset: 0;\n z-index: 10000;\n background: rgba(15, 23, 42, 0.38);\n backdrop-filter: blur(4px);\n}\n\n.bk-panel {\n position: fixed;\n top: 50%;\n left: 50%;\n z-index: 10001;\n display: flex;\n flex-direction: column;\n width: min(680px, calc(100vw - 32px));\n height: min(780px, calc(100vh - 32px));\n transform: translate(-50%, -50%);\n border: 1px solid rgba(148, 163, 184, 0.25);\n border-radius: 20px;\n background:\n radial-gradient(circle at top right, rgba(56, 189, 248, 0.18), transparent 30%),\n linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98));\n box-shadow: 0 32px 80px rgba(15, 23, 42, 0.28);\n overflow: hidden;\n}\n\n.bk-header,\n.bk-toolbar,\n.bk-footer {\n display: flex;\n align-items: center;\n}\n\n.bk-header {\n justify-content: space-between;\n padding: 18px 20px;\n border-bottom: 1px solid rgba(226, 232, 240, 0.95);\n}\n\n.bk-title-wrap {\n display: flex;\n flex-direction: column;\n gap: 4px;\n}\n\n.bk-title {\n font-size: 20px;\n font-weight: 700;\n color: #0f172a;\n}\n\n.bk-subtitle {\n font-size: 12px;\n color: #64748b;\n}\n\n.bk-close-btn,\n.bk-action-btn {\n border: 0;\n cursor: pointer;\n}\n\n.bk-close-btn {\n display: grid;\n place-items: center;\n width: 36px;\n height: 36px;\n border-radius: 999px;\n background: #e2e8f0;\n color: #334155;\n font-size: 20px;\n}\n\n.bk-toolbar {\n flex-wrap: wrap;\n gap: 10px;\n padding: 14px 20px;\n border-bottom: 1px solid rgba(226, 232, 240, 0.95);\n}\n\n.bk-action-btn {\n padding: 10px 14px;\n border-radius: 999px;\n font-size: 13px;\n font-weight: 600;\n transition: transform 0.2s ease, opacity 0.2s ease;\n}\n\n.bk-action-btn:hover:not(:disabled) {\n transform: translateY(-1px);\n}\n\n.bk-action-btn:disabled {\n cursor: not-allowed;\n opacity: 0.5;\n}\n\n.bk-action-btn.primary {\n background: #0ea5e9;\n color: #fff;\n}\n\n.bk-action-btn.warning {\n background: #f59e0b;\n color: #fff;\n}\n\n.bk-action-btn.secondary {\n background: #334155;\n color: #fff;\n}\n\n.bk-action-btn.accent {\n background: #ec4899;\n color: #fff;\n}\n\n.bk-action-btn.danger {\n background: #ef4444;\n color: #fff;\n}\n\n.bk-action-btn.ghost {\n background: rgba(255, 255, 255, 0.92);\n color: #0f172a;\n box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.45);\n}\n\n.bk-status-bar {\n margin-left: auto;\n font-size: 12px;\n color: #64748b;\n}\n\n.bk-settings-card {\n padding: 16px 20px;\n border-bottom: 1px solid rgba(226, 232, 240, 0.95);\n background: linear-gradient(180deg, rgba(248, 250, 252, 0.96), rgba(255, 255, 255, 0.98));\n}\n\n.bk-settings-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 12px;\n}\n\n.bk-field {\n display: flex;\n flex-direction: column;\n gap: 6px;\n}\n\n.bk-field span {\n font-size: 12px;\n font-weight: 600;\n color: #334155;\n}\n\n.bk-field input {\n width: 100%;\n padding: 10px 12px;\n border: 1px solid rgba(148, 163, 184, 0.35);\n border-radius: 12px;\n background: rgba(255, 255, 255, 0.96);\n color: #0f172a;\n font-size: 13px;\n}\n\n.bk-settings-hint {\n margin-top: 10px;\n font-size: 12px;\n color: #64748b;\n}\n\n.bk-settings-actions {\n display: flex;\n gap: 10px;\n margin-top: 14px;\n}\n\n.bk-list {\n flex: 1;\n padding: 12px 20px 20px;\n overflow-y: auto;\n}\n\n.bk-empty-state,\n.bk-alert-card,\n.bk-info-card {\n margin-top: 10px;\n padding: 18px;\n border-radius: 16px;\n text-align: center;\n}\n\n.bk-empty-state {\n background: rgba(241, 245, 249, 0.95);\n color: #475569;\n}\n\n.bk-alert-card {\n background: rgba(254, 242, 242, 0.96);\n color: #b91c1c;\n}\n\n.bk-info-card {\n background: rgba(239, 246, 255, 0.96);\n color: #1d4ed8;\n}\n\n.bk-alert-card a {\n display: inline-flex;\n margin-top: 12px;\n padding: 10px 14px;\n border-radius: 999px;\n background: #0ea5e9;\n color: #fff;\n text-decoration: none;\n}\n\n.bk-items {\n display: grid;\n gap: 10px;\n}\n\n.bk-spacer {\n width: 100%;\n}\n\n.bk-item {\n display: grid;\n grid-template-columns: auto auto minmax(0, 1fr) auto;\n align-items: center;\n gap: 12px;\n padding: 12px;\n border: 1px solid rgba(226, 232, 240, 0.95);\n border-radius: 16px;\n background: rgba(255, 255, 255, 0.82);\n cursor: pointer;\n transition: transform 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;\n}\n\n.bk-item:hover {\n transform: translateY(-1px);\n border-color: rgba(14, 165, 233, 0.45);\n}\n\n.bk-item.is-removed {\n opacity: 0.45;\n}\n\n.bk-avatar {\n width: 40px;\n height: 40px;\n border-radius: 999px;\n object-fit: cover;\n}\n\n.bk-item-name {\n font-size: 14px;\n font-weight: 700;\n color: #0f172a;\n}\n\n.bk-item-sign {\n margin-top: 4px;\n overflow: hidden;\n color: #64748b;\n font-size: 12px;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.bk-status {\n font-size: 12px;\n font-weight: 600;\n}\n\n.bk-status.pending {\n color: #d97706;\n}\n\n.bk-status.success {\n color: #16a34a;\n}\n\n.bk-status.error {\n color: #dc2626;\n}\n\n.bk-footer {\n justify-content: center;\n gap: 16px;\n padding: 14px 20px 20px;\n border-top: 1px solid rgba(226, 232, 240, 0.95);\n}\n\n.bk-page-info {\n font-size: 13px;\n color: #475569;\n text-align: center;\n}\n\n@media (max-width: 720px) {\n .bk-panel {\n width: calc(100vw - 20px);\n height: calc(100vh - 20px);\n }\n\n .bk-toolbar {\n padding-right: 14px;\n padding-left: 14px;\n }\n\n .bk-status-bar {\n width: 100%;\n margin-left: 0;\n }\n\n .bk-settings-card {\n padding-right: 14px;\n padding-left: 14px;\n }\n\n .bk-settings-grid {\n grid-template-columns: 1fr;\n }\n\n .bk-list {\n padding-right: 14px;\n padding-left: 14px;\n }\n\n .bk-item {\n grid-template-columns: auto auto minmax(0, 1fr);\n }\n\n .bk-status {\n grid-column: 1 / -1;\n justify-self: end;\n }\n}\n'; const rootId = "bk-cleaner-root"; async function mountApp() { if (document.getElementById(rootId)) { return; } const mid = parseMidFromLocation(window.location.href); const csrf = getCookie("bili_jct"); if (!mid || !csrf) { logInfo("未检测到登录状态或不在个人空间"); return; } let isOwnSpace = false; const config = await getFansCleanerConfig(); try { const navData = await getNavData(); isOwnSpace = String(navData.mid) === mid; logInfo("空间归属判断", { pageMid: mid, loginMid: String(navData.mid), isOwnSpace }); } catch (error) { logInfo("获取登录用户信息失败,默认按非本人空间处理", error); } injectStyle(styles); const root = document.createElement("div"); root.innerHTML = panelTemplate; const appRoot = root.firstElementChild; if (!appRoot) { return; } appRoot.setAttribute("x-data", "fansCleanerApp"); document.body.appendChild(appRoot); Alpine.data("fansCleanerApp", () => createFansCleanerApp({ mid, csrf, isOwnSpace, config })); window.Alpine = Alpine; Alpine.start(); } window.setTimeout(mountApp, 1500); })(Alpine);