首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >Chrome MV3 插件实战:模拟真人自动评论的架构设计与三个真实踩坑

Chrome MV3 插件实战:模拟真人自动评论的架构设计与三个真实踩坑

原创
作者头像
用户11922017
发布于 2026-10-15 17:07:07
发布于 2026-10-15 17:07:07
30
举报

Chrome MV3 插件实战:模拟真人自动评论的架构设计与三个真实踩坑

一、背景

我做了一个 Chrome 侧边栏插件,用来解决一个具体而重复的劳动:在 B 站按关键词找视频,然后逐个去评论区留言。每天几十条,纯手工做下来手酸不说,还容易重复评论同一条视频。

技术上这件事并不难,但"能跑起来"和"跑得像人"之间隔着不少工程细节。这篇文章不讲产品,只讲实现,以及过程中踩到的三个真实坑。

声明:本文所有代码逻辑均为浏览器端模拟用户操作,不涉及任何接口绕过或平台漏洞利用。频率控制、内容合规、可中断是设计里的硬性约束,请务必遵守平台社区规范,合理使用。

二、技术选型:为什么是 MV3 + sidePanel

Chrome 在 2024 年彻底关闭了 MV2 的支持,所以新项目没有选择余地,只能是 MV3。MV3 带来几个关键变化,也带来了几个必须绕开的坑:

MV2 习惯

MV3 现实

常驻 background page(可以长期持有全局变量)

background service worker,随时可能被系统杀掉,全局状态会丢

browserAction 弹窗

侧边栏 sidePanel 更适合"常驻操作面板"场景

远程可执行代码

禁止,只能加载本地脚本

选 sidePanel 而不是 popup,是因为这个插件的操作面板需要长时间显示——用户填关键词、点开始、看进度。popup 一点别处就关了,sidePanel 则可以固定在浏览器右侧,切标签页都不消失。

三、架构总览

三层分工,职责清晰:

代码语言:javascript
复制
┌──────────────────────────────────────────────┐
│ 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 秒新鲜度窗口:

代码语言:javascript
复制
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 当然更快、更省资源,但它和真实用户的差异太大:没有页面访问记录、没有滚动行为、没有输入过程,频率也难以伪装。风控模型很容易分辨。

所以流程是这样的:

  1. 自动打开视频页
  2. 等待页面稳定(不是固定 sleep,是轮询关键节点是否就绪)
  3. 滚动到评论区
  4. 定位评论框
  5. 逐字输入(不是一次性 set value)
  6. 点击发布按钮
  7. 轮询确认发布成功

第 5 步的"逐字输入"值得展开。直接给 <textarea> 赋值是不行的——B 站的编辑器监听的是 input 事件,赋值不触发事件,React 状态不同步,后续提交还是空的。

正确做法是模拟真实键盘输入序列:

代码语言:javascript
复制
// 逐字输入,每字间隔 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,发送前二次检查:

代码语言:javascript
复制
async function stopRequested() {
  const st = await chrome.storage.local.get("stopFlag");
  return !!st.stopFlag;
}
// 在真正发送前必须再查一次
if (await stopRequested()) { finish(task, false, "已手动停止"); return; }

风控信号必须硬停:B 站会返回明确的错误码,这些码代表"你在违规",绝不能重试:

代码语言:javascript
复制
const STOP_CODES = {
  "12045": "触发验证码风控,已停止,请稍后再试",
  "12002": "评论频率过快,已停止,请加大间隔",
  "12008": "今日评论已达上限,明天再来",
  "12010": "账号异常需验证手机",
  "-101":  "账号未登录或登录已过期",
};

命中这些码,立刻终止整个任务,而不是跳过这条继续下一条。这是保护用户账号的最后一道闸。

六、踩坑一:Blob 不带 MIME,CDN 错误页被存成 .txt

插件里有个附带功能:下载当前页面的视频。实现思路是拿到视频直链、fetch 成 Blob、再触发保存。

第一版代码是这样的:

代码语言:javascript
复制
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。

排查后发现是两个叠加的问题:

  1. new Blob(chunks) 没传 type。Blob 的 MIME 为空时,Chrome 会去嗅探内容。而 CDN 在需要验证时返回的是 HTML 错误页,Chrome 嗅探出这是 HTML,于是保存成 .html 或被某些场景判为 .txt。
  2. res.ok 拦不住这种情况。HTTP 状态码是 200,但响应体是 HTML。只判断 res.ok 完全不够。

修复是两层的:

代码语言:javascript
复制
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 Service Worker 的冷启动与版本握手

MV3 的 background 是 service worker,浏览器会随时回收它。带来的连锁问题:

问题 1:onInstalled 不一定触发。扩展更新后如果用户没打开过面板,安装上报就永远不会发生。我的解法是在面板每次打开时补一次登记,上报逻辑做成幂等的(按机器码去重)。

问题 2:改了代码不生效。用户重装扩展后,已经打开的 B 站标签页里跑的还是旧版 content script——扩展的热更新不会通知已注入的页面。表现就是"我明明改了,怎么还是老行为"。

解法是加版本握手。content script 暴露自己的版本,background 对比 manifest 版本,不一致就强制刷新页面:

代码语言:javascript
复制
const fresh = await ensureFreshContent(tab.id);
if (!fresh) {
  sendResponse({ ok: false, error: "B站页面脚本未就绪,请手动刷新该标签页后重试" });
  return;
}

问题 3:SW 冷启动后消息丢失。面板调用 background 时如果 SW 刚被杀,第一条消息可能失败。处理办法是捕获异常后给用户明确提示,而不是静默失败——用户至少知道该刷新一下。

八、踩坑三:授权校验的 fail-open 设计

插件有授权系统:免费试用 + 卡密激活,机器码绑定。

设计原则:授权服务器故障时放行(fail-open),绝不因服务器故障锁死用户。

这不是技术选择,是产品判断。设想一下这个场景:用户正在跑任务,我的授权服务器因为网络抖动超时了。如果这时判定"未授权"并锁死功能,用户的账号、他正在执行的任务、他的信任会同时受损。而我作为开发者,损失的是零——试用期的用户本来就还没付费。

代码语言:javascript
复制
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 是分开返回的——界面上要如实告诉用户"授权服务器暂不可用,已临时放行",而不是假装一切正常。这个诚实很重要,它让用户知道这次是侥幸,而不是真的有效。

九、内容合规校验

评论内容在发送前必须过一道本地校验,这既是给平台的尊重,也是给用户的保护:

代码语言:javascript
复制
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 };
}

两个细节值得注意:

  • 长度下限是 3 字而不是 10 字。一开始我设了 10 字,结果"支持""说得对"这类正常短评全被误杀,用户体验很差。下限降到 3 字,配合重复字检测,平衡才对。
  • 重复字阈值按长度自适应。固定阈值对短文本过于苛刻,对长文本又太宽松。

十、小结

回头看,这个插件的技术含量不在"自动评论"本身,而在几个朴素但容易被忽略的工程问题:

  1. MV3 的状态管理——任务必须跨页面、跨 SW 生命周期存活,还要防误续跑
  2. 模拟真人的细节——逐字输入、随机间隔、真实页面操作,这三件事缺一不可
  3. 边界情况的处理——HTTP 200 但内容是 HTML,扩展更新但页面没刷新,服务器故障但用户正在用

最后重复一次声明:这类工具的使用前提是尊重平台规则。设计上我做了频率限制、内容校验、风控信号硬停、随时可中断,但工具只是工具,使用者的账号行为由使用者自己负责。建议每天不超过 10 条,细水长流,别把它当刷量工具。

代码仓库(开源):https://github.com/personal82555/bili-auto-comment


本文的三个踩坑都来自实际开发记录,修复方案已在 v1.8.1 / v1.9.0 中上线验证。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • Chrome MV3 插件实战:模拟真人自动评论的架构设计与三个真实踩坑
    • 一、背景
    • 二、技术选型:为什么是 MV3 + sidePanel
    • 三、架构总览
    • 四、核心:模拟真人操作流程
    • 五、防重复与频率控制
    • 六、踩坑一:Blob 不带 MIME,CDN 错误页被存成 .txt
    • 七、踩坑二:MV3 Service Worker 的冷启动与版本握手
    • 八、踩坑三:授权校验的 fail-open 设计
    • 九、内容合规校验
    • 十、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档