油猴脚本(Tampermonkey)这类用户脚本工具,对不少 B 站重度用户来说并不陌生。B 站在动态页、消息中心里会持续累积点赞、回复、@提醒、系统通知,页面缺少足够强的一键清理入口时,时间久了就会形成大量未读红点和消息堆积。B站消息清理助手 v0.2 正是在这种场景下出现的实用油猴插件:它在浏览器已经正常登录 B 站的基础上,自动识别多个域名下的消息页面,提供可中断的批量已读与清理操作,并且不需要为脚本单独申请 token、手动复制 Cookie。社区里讨论这类工具时,偶尔也会把油猴脚本称为“油泼猴”,本质指向的都是同一个用户脚本生态。
写这篇文章不是为了让读者照抄一份线上完整脚本。B 站的页面类名、接口路径、按钮结构随时可能调整,直接复制一段依赖某个具体类名的代码,只能在某个时间窗口内生效。更值得掌握的是油猴脚本开发中的通用能力:元数据声明怎么写、多域名如何匹配、页面异步渲染如何等待、批量操作如何做到可停止、发布后如何定位问题。理解了这些机制之后,才能维护一个真正属于自己的“消息清理助手 v0.2”,并在页面升级后快速修好它。
v0.2 强调的“多端免登录”,也需要先讲清楚边界。多端指的是脚本能在 B 站多个子域名页面运行,例如动态页、消息中心、个人空间等;免登录指的是脚本本身不需要额外配置登录凭据,它直接复用当前浏览器里已经登录的 B 站会话。这里的“免登录”并不是绕过网站登录校验,也不是在未登录状态下访问他人私信。清理助手只作用于当前登录账号自己能看到的提醒与通知,本质是把用户已经具备的操作权自动化、批量化和可中断化。
下面从使用场景、运行环境、项目结构、核心实现、验证发布、问题排查和最佳实践几个层面展开。文章会给出框架级代码和关键机制说明,读者拿到后可以按自己的实际页面结构调整选择器和清理动作,而不是假设线上页面永远不变。
1. 先搞清楚消息“清理”的业务边界和脚本价值
1.1 消息积压的真实场景
B 站消息中心的常见入口包括回复我的、@我、收到的赞、系统通知,以及动态页右上角的红点提醒。普通用户如果长期不清理,可能积压几十上百条提醒。每一条都要单独进入详情、关闭加载、再返回列表,操作成本很高。
更麻烦的是,这类页面往往是异步渲染的。用户进入消息中心后,脚本如果只执行一次 DOM 查询,很可能只看到一部分内容;等待时间不够,又会漏掉后面懒加载出来的消息。这就是“清理助手”类脚本要解决的问题:替代用户重复点击,同时处理异步渲染带来的页面状态变化。
1.2 “清理”到底是删除还是标记已读
做这类脚本前要先定义业务语义,避免把用户数据搞坏。v0.2 的安全设计原则是:默认只执行“标记已读”和“移除红点”这类可逆性较高的操作,不直接删除私信会话或系统通知原文。理由很直接:标记已读出错,用户回到页面还能看到原消息;直接删除出错,可能连恢复入口都没有。
脚本的产品描述和落地实现都应该守住这条边界。如果后续版本确有必要支持删除,也应该设计成二次确认、回收站、日志记录同时具备的形态,而不是默认开启。
1.3 为什么用油猴脚本而不是独立插件或客户端
从工程实现角度看,可以有浏览器插件、独立客户端、油猴脚本三种方案。它们的核心差异并不只是安装方式,而是权限模型和交付链路。
| 方案 | 开发成本 | 安装复杂度 | 权限边界 | 更新机制 |
|---|---|---|---|---|
| 浏览器插件 | 高,需要 manifest、打包、审核 | 中,需要开发者模式或商店审核 | 权限独立声明,可访问各域 | 商店审核或手动加载新包 |
| 独立客户端 | 高,需要处理登录协议与设备适配 | 高,需要安装运行环境 | 权限最难控制,容易引发警惕 | 用户手动升级 |
| 油猴脚本 | 低,单文件即可 | 低,装好 Tampermonkey 后导入即可 | 受限于 @match 与 @grant 声明 | 用户脚本管理器自动检查更新 |
对“在 B 站页面内做批量已读”这个需求来说,油猴脚本是性价比最高的形态。它只需要在匹配的页面里运行,不需要单独申请权限;代码修改后,脚本管理器可以自动拉取新版本,用户感知很轻。这也是 v0.2 继续沿用油猴脚本路线的原因。
2. 油猴脚本的元数据、运行环境与多端匹配
2.1 元数据块是脚本的“安装声明”
油猴脚本以 JavaScript 注释块开头,这个块在 Tampermonkey 中被称为 UserScript Header。它不是可有可无的说明,而是脚本管理器识别脚本、分配授权、判断更新源的核心依据。
// ==UserScript== // @name B站消息清理助手 // @namespace https://your-domain.example.com/bili-cleaner // @version 0.2 // @description 在已登录 B 站会话内,为消息中心/动态页提供批量已读与清理能力;多域名匹配,无需单独配置登录凭据 // @author your-id // @license MIT // @match https://www.bilibili.com/* // @match https://message.bilibili.com/* // @match https://t.bilibili.com/* // @match https://space.bilibili.com/* // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_notification // @noframes // ==/UserScript==这个元数据里最重要的三组字段是@match、@run-at和@grant。@match决定脚本能进入哪些页面,@run-at决定脚本在页面加载的哪个阶段执行,@grant决定脚本可以使用哪些油猴专用 API,以及是否在沙盒中运行。
如果需要同时覆盖移动端页面,还可以追加m.bilibili.com等域名。但实际开发时不要无脑加*,匹配范围越宽,脚本误触发的概率越高。更合理的做法是只把已知消息页和动态页加入白名单。
2.2 页面上下文与沙盒之间的选择
当@grant声明了GM_系列函数时,Tampermonkey 默认会把脚本放进一个隔离沙盒里。沙盒可以保护页面变量不被污染,但也会让脚本无法直接访问页面自己的window对象和局部变量。
如果脚本只操作 DOM,沙盒影响不大。如果要调用页面上某个内部函数或读取页面级数据,就涉及unsafeWindow。unsafeWindow是油猴提供的窗口对象引用,但使用它时要警惕页面自身脚本在这些属性上做的改写。
对消息清理助手来说,推荐的主路径是纯 DOM 操作:通过querySelector找到消息条目,通过按钮文本或>bili-message-cleaner/ ├── src/ │ ├── index.js // 入口:读取配置、执行路由分派 │ ├── config.js // 默认参数与白名单配置 │ ├── routes/ │ │ ├── index.js // 路由分派表 │ │ ├── messageCenter.js // 消息中心清理模块 │ │ └── dynamicFeed.js // 动态页红点与提醒模块 │ ├── core/ │ │ ├── waitForElement.js // 异步等待元素 │ │ ├── cleaner.js // 批量执行器 │ │ └── logger.js // 日志输出与本地审计 │ └── ui/ │ └── overlay.js // 悬浮操作面板 ├── build/ │ └── bundle.user.js // 合并后的发布文件 └── README.md
这个结构适合用构建脚本把多个源文件合并成单个油猴脚本。如果不想引入构建工具,也可以在src/index.js中按照注释分区组织,确保每一段只负责一件事。
3.2 路由分派表的作用
入口脚本收到页面事件后,第一件事不是直接清理,而是判断当前页面属于哪个业务模块。把判断逻辑集中在一张路由表里,比在主流程里写十层if更容易维护。
const routes = [ { name: 'messageCenter', match: () => location.host === 'message.bilibili.com', setup: () => importModule('messageCenter'), }, { name: 'dynamicFeed', match: () => location.host === 't.bilibili.com' || (location.host === 'www.bilibili.com' && /^\/$/.test(location.pathname)), setup: () => importModule('dynamicFeed'), }, { name: 'spaceNotice', match: () => location.host === 'space.bilibili.com' && location.pathname.includes('notice'), setup: () => importModule('messageCenter'), }, ]; function dispatch() { const route = routes.find((item) => item.match()); if (route) return route.setup(); return null; }这里用match字段代表页面匹配逻辑,实际实现时可以返回 Promise,也可以返回模块对象。关键是让入口逻辑变得短而稳定,页面上具体变化只影响对应路由模块内部。
4. 核心实现:页面异步渲染与批量清理引擎
4.1 用 waitForElement 解决“元素还没加载出来”的问题
B 站消息中心采用异步渲染,脚本刚注入时,页面可能只显示骨架屏或加载状态。直接执行一次querySelector往往是拿不到目标元素的。比较稳妥的做法是把“等待元素出现”封装成一个独立函数。
function waitForElement(selector, { root = document, timeout = 20000 } = {}) { return new Promise((resolve, reject) => { const existing = root.querySelector(selector); if (existing) { resolve(existing); return; } const timer = setTimeout(() => { observer.disconnect(); reject(new Error(`等待元素超时: ${selector}`)); }, timeout); const observer = new MutationObserver(() => { const node = root.querySelector(selector); if (node) { clearTimeout(timer); observer.disconnect(); resolve(node); } }); observer.observe(root, { childList: true, subtree: true }); }); }MutationObserver会在 DOM 子树发生增加或删除时触发回调。相比固定setInterval轮询,它的响应更快,也不会在页面空闲时做无意义的空转。不过它只检测 DOM 变化,如果目标元素的出现依赖某个异步请求完成,那么需要在等待之前先确认触发条件存在,例如等待接口返回后页面刷新了某个列表容器。
4.2 批量执行器要做到可停止、可限速、可恢复
批量清理动作不能用一个for循环一股脑跑完。B 站消息页面的列表是动态的,处理完前几条后页面可能重新渲染,后几条的 DOM 节点会失效;连续高频点击也容易触发风控或接口限速。因此执行器需要具备三个能力:单条间隔、每轮上限、随时停止。
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); class Cleaner { constructor({ interval = 1200, maxBatch = 20, onProgress } = {}) { this.interval = interval; this.maxBatch = maxBatch; this.stopped = false; this.doneCount = 0; this.onProgress = onProgress; } stop() { this.stopped = true; } reset() { this.stopped = false; this.doneCount = 0; } async run(collector, action) { this.reset(); let round = 0; while (!this.stopped && round < 10) { const items = collector(); if (!items || items.length === 0) { break; } const batch = items.slice(0, this.maxBatch); for (const item of batch) { if (this.stopped) { return; } try { await action(item); this.doneCount += 1; } catch (error) { console.warn('[B站消息清理助手] 单项处理失败,已跳过', item, error); } await sleep(this.interval); } round += 1; } if (this.onProgress) { this.onProgress(this.doneCount); } } }collector用来从当前页面收集待处理项,action用来处理单条消息。每一轮处理完maxBatch条后,重新调用collector,这样可以拿到页面异步刷新后产生的新节点,同时避免对已经失效的旧节点继续操作。
这里最容易犯的错误是在action中直接修改 DOM 后继续遍历原有的items数组。正确的做法是每轮都重新收集,因为上一轮的 DOM 节点可能已经被页面框架替换了。
4.3 清理动作的抽象与页面适配层
不同消息页面的清理动作并不一样。有的页面通过按钮点击标记已读,有的页面通过勾选后批量操作,有的页面则需要在某个容器内部找“全不读”“清理”按钮。v0.2 不把所有逻辑写死,而是把清理动作当作一个可替换的适配层。
async function markItemRead(item) { const button = item.querySelector('[data-action="mark-read"] button, .read-btn'); if (button) { button.click(); return; } const itemId = item.getAttribute('data-item-id'); if (itemId && window.__biliCleanerMarkRead) { // 请求型动作,需要维护者在对应页面版本中确认接口与会话 CSRF await window.__biliCleanerMarkRead(itemId); return; } throw new Error('未找到该消息条目的可执行清理动作'); }上面的代码只是演示结构,实际类名与>const STORAGE_KEYS = { interval: 'cleaner.interval', maxBatch: 'cleaner.maxBatch', autoStart: 'cleaner.autoStart', lastRunAt: 'cleaner.lastRunAt', lastDoneCount: 'cleaner.lastDoneCount', }; function loadConfig() { return { interval: Number(GM_getValue(STORAGE_KEYS.interval, 1200)), maxBatch: Number(GM_getValue(STORAGE_KEYS.maxBatch, 20)), autoStart: Boolean(GM_getValue(STORAGE_KEYS.autoStart, false)), }; } function saveRunSummary(count) { GM_setValue(STORAGE_KEYS.lastRunAt, Date.now()); GM_setValue(STORAGE_KEYS.lastDoneCount, count); }
GM_setValue把数据保存在油猴脚本自己的存储空间里,不会随页面刷新丢失。它适合保存配置和运行摘要,不适合保存大量消息内容。
5. 参数设计、CSRF 边界与安全红线
5.1 运行参数与默认值
v0.2 准备提供一个简单的参数面板,让用户不必修改代码就能控制清理节奏。参数不宜过多,三个核心参数足够覆盖大多数场景。
| 参数 | 默认值 | 作用 | 调整影响 |
|---|---|---|---|
| interval | 1200 毫秒 | 每处理一条后等待的时间 | 调小会更快但更容易触发频控;调大更稳但更慢 |
| maxBatch | 20 条 | 每轮最多连续处理的条数 | 调大可以减少轮次,但页面重渲染造成节点失效的概率更高 |
| autoStart | false | 进入匹配页面后是否自动开始清理 | 开启后无需手动点击,但建议默认关闭,避免误触 |
合理选择参数比“追求最快”更重要。油猴脚本运行在用户自己的浏览器里,不确定因素很多:网络延迟、页面渲染速度、浏览器内存。默认值应偏向保守,用户想加速时再自行调小间隔。
5.2 CSRF token 只在当前会话内使用
如果清理动作需要走请求型接口,代码里需要使用 B 站 Cookie 中的bili_jct作为 CSRF 参数。社区里的常见做法是从当前页面的 Cookie 中取出这个值。
function getCsrfFromCookie() { const match = document.cookie.match(/(?:^|;\s*)bili_jct=([^;]+)/); return match ? match[1] : ''; }这段代码必须在 B 站自己的域名页面里运行,得到的结果也只能用于当前站点同源请求。任何把bili_jct、Cookie 内容发送到其他域名或服务器存储的设计,都属于数据外泄风险,不应出现在可发布版本里。
注意:如果当前页面脚本被站点安全策略限制,导致 Cookie 不可读,不要用注入第三方脚本的方式强行绕过。更稳妥的做法是把请求型动作降级为“仅通过页面原有按钮操作”,或者提示用户使用支持更完善的操作环境。
5.3 日志与误操作恢复
v0.2 应该在控制台输出清晰的运行日志,包括开始时间、批次序号、每轮收集到的条目数、结束时间。日志不仅用于调试,也能在用户执行后发现问题时帮助复盘。
建议至少输出以下几条日志:
- 开始运行:进入哪个路由模块,使用什么参数。
- 批次状态:第几轮、收集到多少条目、准备处理多少。
- 单项失败:失败的消息条目与错误原因。
- 结束状态:共处理多少条、是否被用户手动停止。
误操作恢复方面,默认只做标记已读和移除提醒,本质上不会删除持久数据。如果用户发现清理过多,可以到消息中心查看原始记录或系统通知历史,入口仍然存在。
6. 运行验证与发布:从本地调试到多端生效
6.1 在 Tampermonkey 中导入未压缩脚本
开发阶段不需要先发布到脚本平台。直接在 Tampermonkey 管理面板点击“新建脚本”,把源码粘贴进去,保存后进入目标页面测试即可。
如果在编辑器中修改后再保存,Tampermonkey 会重新注入脚本。此时建议在浏览器开发者工具中打开 Console 面板,确认没有语法错误和路由不匹配的提示。脚本被正确注入的一个明显特征是:控制台会打印出脚本版本号或者路由模块名称。
6.2 分别在消息中心、动态页、空间页验证
“多端”不是一句口号,它需要在每个匹配域名上都实际验证。由于不同子域名的 DOM 结构和页面框架可能不同,验证顺序建议是:
- 消息中心页面:验证批量清理入口是否存在,是否能标记一条消息已读。
- 动态页:验证红点或提醒组件是否被正确识别。
- 个人空间消息相关页面:验证路由模块是否能启动。
- 未登录状态:确认脚本给出登录提示,而不是执行异常操作。
每完成一个页面的验证,就记录一句实际结果。这个验证记录可以放到脚本更新的发布说明里,帮助用户确认新版本覆盖了哪些页面。
6.3 多浏览器与多脚本管理器兼容性
油猴脚本并不只在 Tampermonkey 上运行,常见的还有 Violentmonkey、Greasemonkey。它们在@grant支持度上有一些差异,尤其是GM_notification、GM_xmlhttpRequest这类 API。发布前可以在两个不同管理器上各做一次冒烟测试。
如果用户反馈某个浏览器不生效,优先检查三个因素:脚本管理器是否开启、@match是否把对应域名包含进来、页面是否跳转到了需要登录的认证域名。
6.4 发布时把版本号、变更记录和免责边界写清楚
在 Greasy Fork 发布脚本时,版本号建议严格对应元数据里的@version。每次页面适配更新都提升版本号,并在脚本描述或主页说明中记录变更点。例如 v0.2 的发布说明可能包含:
- 支持消息中心、动态页等多个入口。
- 增加可停止批量执行器。
- 无需单独配置登录凭据,只在已登录会话内运行。
- 只做当前账号的已读与提醒清理,不删除私信内容、不收集用户数据。
7. 常见问题排查:从“没反应”到“清理中断”
7.1 现象与原因速查表
油猴脚本的故障通常集中在匹配、选择器、异步等待和执行时序四类问题上。下面这张表可以直接用于排查。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 脚本完全没运行 | @match 没有覆盖当前页面 | 查看 Tampermonkey 图标是否亮起 | 调整 @match 并保存,刷新页面 |
| 页面弹了面板但没有消息列表 | 等待元素的选择器过期 | 打开开发者工具查看页面实际 DOM | 更新 waitForElement 里的 selector |
| 只处理了第一条就停止 | 第一轮处理完后 DOM 重建,旧节点未刷新 | 查看日志中的批次序号 | 让每个批次重新调用 collector |
| 点击按钮后页面无变化 | 页面使用框架内部事件,直接 click 不够 | 检查按钮是否绑定了 React/Vue 事件 | 改用派发 MouseEvent 或调用页面自带函数 |
| 清理速度很慢 | interval 默认值偏大 | 查看配置面板 | 适当调小间隔,但不要低于 300 毫秒 |
| 用户登录失效后脚本报错 | 会话过期,接口返回未登录 | 查看请求状态码 | 提示重新登录,暂停后续操作 |
| 页面改版后脚本失效 | B 站更新了页面结构和类名 | 对比线上 DOM 与脚本中的选择器 | 优先修改适配层,不要改执行器 |
7.2 日志定位与复现步骤
遇到问题时,让用户按固定顺序提供信息能大幅提升排查效率:
- 脚本管理器版本和浏览器版本。
- 访问的具体页面 URL。
- 控制台里脚本输出的最后几行日志。
- 是否能稳定复现,还是偶发。
如果是偶发问题,还要特别关注是否与网络慢、页面刷新时机有关。很多清理中断并不是脚本写错,而是用户在面板弹出前就切换了页面,或者消息列表还在加载时脚本已经收集过一轮空列表。
7.3 页面升级后的应对策略
B 站前端经常调整页面类名。油猴脚本维护者不可能追赶每一次改版,更合理的方式是把选择器集中在适配层,并为每个模块写一个“自检函数”:进入页面后先检查关键选择器是否存在,不存在时打印明确的提示。
function checkSelectors(selectors) { const missing = selectors.filter((selector) => !document.querySelector(selector)); if (missing.length) { console.warn('[B站消息清理助手] 以下选择器可能已失效:', missing); return false; } return true; }页面改版后,维护者可以先根据这段提示快速定位失效点,而不是通读全部代码。这也是 v0.2 相比早期版本最重要的可维护性改进。
8. 最佳实践与 v0.3 扩展方向
8.1 油猴脚本开发的可复用清单
每次发布新版本前,都可以对照下面这个清单逐项检查:
- 元数据中 @match 是否只包含需要的页面域名,没有多余通配。
- @run-at 是否与实际业务时机匹配,document-start、document-idle 不能随意混用。
- 所有 DOM 查询是否都有超时等待,是否能在元素缺失时友好退出。
- 批量执行器是否具备停止机制,是否会无限制轮询。
- 默认参数是否保守,autoStart 是否保持关闭。
- 是否只处理当前登录账号自己的消息,未读取、未导出任何第三方数据。
- 控制台是否有清晰的开始、批次、结束、失败四类日志。
- 是否在 Greasy Fork 描述中写明了默认行为和边界。
8.2 发布与沟通中的安全红线
消息处理类油猴脚本最容易在传播过程中被误解。作者在发布说明里主动写清“不做的事”,反而比反复强调“能做多少功能”更有利于保护用户和脚本口碑。
需要在说明中明确的内容包括:
- 需要浏览器里已经登录 B 站,不适用于未登录场景。
- 不提供跨账号、抓取私信、批量删除私聊消息等能力。
- 不会把消息内容或 Cookie 发送到任何第三方服务器。
- 清理操作可以手动停止,默认使用保守节奏。
8.3 v0.3 可以继续做的事
从 v0.2 往后扩展,有几个方向具备实际价值。其一是远端规则更新:把页面选择器配置放到一个带版本号的 JSON 配置中,脚本启动时拉取规则,在页面改版后无需重装脚本即可临时适配。这个设计要在“拉取规则”和“避免引入信息收集风险”之间做好平衡,只拉取选择器等纯配置,不要混入任何与业务数据相关的上报逻辑。
其二是规则回放或演练模式:先扫描页面能识别多少条消息、预计执行哪些操作,让用户在批量执行前确认,进一步降低误操作风险。
其三是把清理执行器抽成独立的可复用 npm 包或工具库。很多社区用户脚本都会遇到异步等待、批量处理、可停止执行器这一类公共问题,抽成通用模块后,不仅 B 站消息清理可以受益,未来处理其他网站的通知页也能直接用同一套执行框架。
开发油猴脚本的真正难点不在写代码那一刻,而在页面变化后如何快速定位、如何保证批量操作不出错、如何让用户安全地停止一次误触发的运行。B站消息清理助手 v0.2 的意义,不只是帮用户清掉一批未读红点,更是把“自动化页面操作”这件常见需求做成了一组可复用、可排查的工程能力。下一个版本无论选择哪个扩展方向,先保证执行器稳定、日志清晰、边界明确,路就不会走偏。