我做了一个 Chrome 侧边栏插件,用来解决一个具体而重复的劳动:在 B 站按关键词找视频,然后逐个去评论区留言。每天几十条,纯手工做下来手酸不说,还容易重复评论同一条视频。
技术上这件事并不难,但"能跑起来"和"跑得像人"之间隔着不少工程细节。这篇文章不讲产品,只讲实现,以及过程中踩到的三个真实坑。
声明:本文所有代码逻辑均为浏览器端模拟用户操作,不涉及任何接口绕过或平台漏洞利用。频率控制、内容合规、可中断是设计里的硬性约束,请务必遵守平台社区规范,合理使用。
Chrome 在 2024 年彻底关闭了 MV2 的支持,所以新项目没有选择余地,只能是 MV3。MV3 带来几个关键变化,也带来了几个必须绕开的坑:
MV2 习惯 | MV3 现实 |
|---|---|
常驻 background page(可以长期持有全局变量) | background service worker,随时可能被系统杀掉,全局状态会丢 |
browserAction 弹窗 | 侧边栏 sidePanel 更适合"常驻操作面板"场景 |
远程可执行代码 | 禁止,只能加载本地脚本 |
选 sidePanel 而不是 popup,是因为这个插件的操作面板需要长时间显示——用户填关键词、点开始、看进度。popup 一点别处就关了,sidePanel 则可以固定在浏览器右侧,切标签页都不消失。
三层分工,职责清晰:
┌──────────────────────────────────────────────┐
│ sidepanel.js/html (用户界面层) │
│ 配置、进度、日志、授权横幅、统计 │
└──────────────┬───────────────────────────────┘
│ chrome.runtime.sendMessage
┌──────────────▼───────────────────────────────┐
│ background.js (任务调度层 SW) │
│ 授权校验、找B站标签页、下发任务、持有任务副本 │
└──────────────┬───────────────────────────────┘
│ chrome.tabs.sendMessage
┌──────────────▼───────────────────────────────┐
│ content.js (执行层,注入视频页) │
│ 搜索→筛选→打开→滚动→输入→发布→等待 │
└──────────────────────────────────────────────┘一个关键设计:任务由 background 持有副本。 用户点「开始执行」后,插件会跳转到下一个视频页。跳转意味着原页面被销毁,如果任务状态只存在那个页面里,任务就断了。所以每次任务状态变更都会 chrome.runtime.sendMessage({action:"setTask"}) 推给 background 存一份,新页面加载完成后主动去 background pullTask 领取任务接着跑。
但这个"续跑"要防误触发:旧任务可能残留几天,突然被一个无关的 B 站标签页捡起来继续跑就麻烦了。加一个 120 秒新鲜度窗口:
if (msg.action === "pullTask") {
if (ACTIVE_TASK && Date.now() - (ACTIVE_TASK.ts || 0) <= 120000) {
sendResponse({ task: ACTIVE_TASK });
} else {
ACTIVE_TASK = null;
sendResponse({ task: null });
}
}这是整个插件最关键的部分。不走接口,走真实 DOM 操作——这是设计底线。
直接调 api.bilibili.com/x/v2/reply/add 当然更快、更省资源,但它和真实用户的差异太大:没有页面访问记录、没有滚动行为、没有输入过程,频率也难以伪装。风控模型很容易分辨。
所以流程是这样的:
第 5 步的"逐字输入"值得展开。直接给 <textarea> 赋值是不行的——B 站的编辑器监听的是 input 事件,赋值不触发事件,React 状态不同步,后续提交还是空的。
正确做法是模拟真实键盘输入序列:
// 逐字输入,每字间隔 40~120ms 随机
async function typeText(el, text) {
el.focus();
for (const ch of text) {
el.value += ch;
el.dispatchEvent(new Event("input", { bubbles: true }));
await sleep(40 + Math.random() * 80);
}
}注意 Math.random() 是可以在这里用的——这里不是渲染逻辑,不需要可重现。真正需要确定性的只有时间戳和布局计算。
防重复:B 站视频有唯一的 BV 号。每次成功评论后把 BV 号写进 chrome.storage.local 的本地历史,下次执行前过滤掉。存储只在用户自己的浏览器里,不上传。
频率控制:每条之间随机等待 60~120 秒。这个区间不是拍脑袋的——太短的固定间隔是最容易被识别的模式。随机化是为了打断"等间隔"这个特征。
可中断:停止指令必须跨页面生效。做法是把停止标记写进 chrome.storage.local,发送前二次检查:
async function stopRequested() {
const st = await chrome.storage.local.get("stopFlag");
return !!st.stopFlag;
}
// 在真正发送前必须再查一次
if (await stopRequested()) { finish(task, false, "已手动停止"); return; }风控信号必须硬停:B 站会返回明确的错误码,这些码代表"你在违规",绝不能重试:
const STOP_CODES = {
"12045": "触发验证码风控,已停止,请稍后再试",
"12002": "评论频率过快,已停止,请加大间隔",
"12008": "今日评论已达上限,明天再来",
"12010": "账号异常需验证手机",
"-101": "账号未登录或登录已过期",
};命中这些码,立刻终止整个任务,而不是跳过这条继续下一条。这是保护用户账号的最后一道闸。
插件里有个附带功能:下载当前页面的视频。实现思路是拿到视频直链、fetch 成 Blob、再触发保存。
第一版代码是这样的:
const chunks = [];
const res = await fetch(url, { headers: { Referer: location.href } });
const reader = res.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const blob = new Blob(chunks); // ← 这里出事了用户反馈:下载得到的是 .txt 文件,不是 .mp4。
排查后发现是两个叠加的问题:
new Blob(chunks) 没传 type。Blob 的 MIME 为空时,Chrome 会去嗅探内容。而 CDN 在需要验证时返回的是 HTML 错误页,Chrome 嗅探出这是 HTML,于是保存成 .html 或被某些场景判为 .txt。
res.ok 拦不住这种情况。HTTP 状态码是 200,但响应体是 HTML。只判断 res.ok 完全不够。
修复是两层的:
const resp = await fetch(url, { headers: { Referer: location.href } });
if (!resp.ok) throw new Error("HTTP " + resp.status);
const ct = resp.headers.get("content-type") || "";
if (ct.includes("text/html")) throw new Error("CDN 返回的不是视频,是网页/错误页");
const buf = await resp.arrayBuffer();
const head = new Uint8Array(buf.slice(0, 16));
// 魔数校验:mp4 ftyp、flv、mp3 ID3、webm EBML
const MAGIC = {
"video/mp4": [0x66, 0x74, 0x79, 0x70], // ftyp @4
"video/x-flv": [0x46, 0x4C, 0x56], // FLV
"audio/aac": [0x49, 0x44, 0x33], // ID3
"video/webm": [0x1A, 0x45, 0xDF, 0xA3], // EBML
};
function sniff(head) {
if (String.fromCharCode(...head.slice(4, 8)) === "ftyp") return "video/mp4";
if (head[0] === 0x46 && head[1] === 0x4C) return "video/x-flv";
if (head[0] === 0x49 && head[1] === 0x44) return "audio/aac";
if (head[0] === 0x1A && head[1] === 0x45) return "video/webm";
return null;
}
const mime = sniff(head) || ct;
if (!mime.startsWith("video/") && !mime.startsWith("audio/")) {
throw new Error("CDN 返回的不是视频(是网页/错误页),已拦截");
}
const blob = new Blob([buf], { type: mime }); // ← 必须带 type教训:fetch 成功 ≠ 内容正确。凡是"下载文件"类功能,都要校验内容魔数,不能只看状态码和响应头。
MV3 的 background 是 service worker,浏览器会随时回收它。带来的连锁问题:
问题 1:onInstalled 不一定触发。扩展更新后如果用户没打开过面板,安装上报就永远不会发生。我的解法是在面板每次打开时补一次登记,上报逻辑做成幂等的(按机器码去重)。
问题 2:改了代码不生效。用户重装扩展后,已经打开的 B 站标签页里跑的还是旧版 content script——扩展的热更新不会通知已注入的页面。表现就是"我明明改了,怎么还是老行为"。
解法是加版本握手。content script 暴露自己的版本,background 对比 manifest 版本,不一致就强制刷新页面:
const fresh = await ensureFreshContent(tab.id);
if (!fresh) {
sendResponse({ ok: false, error: "B站页面脚本未就绪,请手动刷新该标签页后重试" });
return;
}问题 3:SW 冷启动后消息丢失。面板调用 background 时如果 SW 刚被杀,第一条消息可能失败。处理办法是捕获异常后给用户明确提示,而不是静默失败——用户至少知道该刷新一下。
插件有授权系统:免费试用 + 卡密激活,机器码绑定。
设计原则:授权服务器故障时放行(fail-open),绝不因服务器故障锁死用户。
这不是技术选择,是产品判断。设想一下这个场景:用户正在跑任务,我的授权服务器因为网络抖动超时了。如果这时判定"未授权"并锁死功能,用户的账号、他正在执行的任务、他的信任会同时受损。而我作为开发者,损失的是零——试用期的用户本来就还没付费。
let authorized = false, offline = false;
try {
const r = await authFetch("/public/authorizations/query?bot_qq=" + machine);
if (r && r.code === 200 && Array.isArray(r.data?.list)) {
authorized = r.data.list.some(it =>
it.project_name === PROJECT_NAME && it.status === "active" && !it.is_expired);
}
} catch (e) {
offline = true; // 网络/超时 → 视为放行
}
return {
authorized: authorized || offline,
offline: offline,
realAuthorized: authorized,
};注意 offline 和 realAuthorized 是分开返回的——界面上要如实告诉用户"授权服务器暂不可用,已临时放行",而不是假装一切正常。这个诚实很重要,它让用户知道这次是侥幸,而不是真的有效。
评论内容在发送前必须过一道本地校验,这既是给平台的尊重,也是给用户的保护:
function verifyMessage(text) {
const t = String(text || "").trim();
if (t.length < 3) return { ok: false, reason: "至少3个字" };
if (t.length > 200) return { ok: false, reason: "最多200字" };
if (/https?:\/\/|www\./i.test(t)) return { ok: false, reason: "不能含外链" };
// 重复字检测:按长度自适应阈值
const uniq = new Set(t.replace(/\s/g, "")).size;
const threshold = Math.max(2, Math.floor(t.length * 0.3));
if (uniq < threshold) return { ok: false, reason: "内容重复度过高" };
return { ok: true, text: t };
}两个细节值得注意:
回头看,这个插件的技术含量不在"自动评论"本身,而在几个朴素但容易被忽略的工程问题:
最后重复一次声明:这类工具的使用前提是尊重平台规则。设计上我做了频率限制、内容校验、风控信号硬停、随时可中断,但工具只是工具,使用者的账号行为由使用者自己负责。建议每天不超过 10 条,细水长流,别把它当刷量工具。
代码仓库(开源):https://github.com/personal82555/bili-auto-comment
本文的三个踩坑都来自实际开发记录,修复方案已在 v1.8.1 / v1.9.0 中上线验证。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。