// ==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 = `
当前页面不是当前登录账号的个人空间,面板可查看但操作按钮已禁用。