这一篇我们接着上一篇往下聊。
如果你还没看过第一篇,简单回顾一下大体框架:我们计划用浏览器插件把抖音直播间页面作为一个“数据源”,实时捕捉评论区的新消息,然后把这些消息交给豆包API处理,最后再把AI生成的回复自动填写到直播间的输入框里,实现半自动或全自动互动。上一篇文章已经把豆包API的接入方式、鉴权流程、以及最基础的“发一条消息给豆包,拿到回复”这条链路跑通了。
这篇文章要解决的是整个流程里最关键的中间环节:插件开发和网页捕捉。
说白了,就是两件事:
- 怎么做一个浏览器插件,让它能在直播间页面后台运行。
- 怎么从这个不断滚动的评论区里,精准、不重复、不遗漏地抓到我们需要的评论内容。
这篇文章会把这套方案的架构思路、插件的核心代码结构、DOM捕捉的技巧、常见的坑和排查方法全部讲透。文章偏实战,代码会直接贴出来,你可以照着抄,改一改就能用在自己的项目里。
1. 整体架构设计:为什么非要绕一圈用插件,而不是直接调API
在动手写代码之前,先聊清楚一个很多人都会问的问题:抖音直播间的评论数据,直接通过JavaScript请求接口拿不就行了吗?为什么还要费劲搞一个浏览器插件去“捕捉”网页内容?
这个问题的答案,决定了整个项目的架构走向。
首先,是数据接口的不可控性。
抖音直播间的评论数据确实是通过内部接口传输的,但这些接口通常是加签名的、动态变化的、并且依赖特定的请求头。你可以在浏览器控制台里看到这些请求,但想要在外部环境中模拟出来,需要逆向分析签名算法,而且这个算法随时可能更新。一旦更新,你的代码就会彻底失效,需要重新分析。而浏览器插件的方式完全绕开了这个环节——我们不碰接口,我们只“看”页面。页面显示什么,我们就读什么。抖音前端团队升级、改版,只要页面上的文字还在渲染,我们的插件就能继续工作。这是一种“寄生”式的方案,稳定性反而更高。
其次,是跨域限制和登录态的问题。
就算你逆向出了接口,请求的时候还需要带上完整的Cookie和鉴权信息。这些信息在外部环境(比如Node.js脚本)里很难持久化维护,而且频繁调用还可能触发账号风险。而浏览器插件运行在页面上下文中,天然继承了你的登录状态。你不需要处理任何Cookie、Token、签名,因为你在浏览器里看到的页面本身,就是已经通过验证的。
第三,是互动回写的需求。
别忘了,我们的目标不只是“读取”评论,还要“回复”评论。回复这个动作,本质上就是往直播间的输入框里填入文字、模拟发送。这个操作如果脱离浏览器环境,需要自己去构造WebSocket消息或者调用发消息的接口,复杂度直接翻倍。而在插件里,只需要操作DOM元素,模拟键盘输入和点击事件就行了。
所以,架构就清晰了,整个链路分为四层:
- 数据源层:抖音直播间页面,我们捕捉的对象。
- 捕捉层:Content Script(内容脚本),注入到页面中,监听DOM变化,提取评论数据。
- 处理层:Background Service Worker(后台脚本),接收Content Script发来的数据,调用豆包API,拿到AI回复。
- 回写层:Content Script把AI回复填入直播间输入框,完成互动闭环。
用一张简单的流程图表示就是:
code复制直播间页面(评论)
↓ (DOM监听)
Content Script 捕捉模块
↓ (chrome.runtime.sendMessage)
Background Service Worker
↓ (fetch调用)
豆包API
↓ (返回回复)
Background Service Worker
↓ (chrome.tabs.sendMessage)
Content Script 注入回复模块
↓ (模拟输入)
直播间输入框
这四层各司其职,使用浏览器插件最标准的Manifest V3架构来组织。下面我把每一层的实现细节拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 插件骨架:Manifest V3下的项目结构
网上很多插件教程还在用Manifest V2,但Chrome已经在逐步淘汰V2了,新开发的插件直接用V3。V3和V2最大的区别在于后台脚本从常驻的background.html换成了事件驱动的service worker,而且不能用XMLHttpRequest,只能用fetch。好在fetch对Service Worker支持得很好,调用豆包API完全没问题。
这部分的代码结构如下:
code复制douyin-ai-plugin/
├── manifest.json # 插件清单文件,核心配置
├── background.js # 后台服务,负责调用豆包API
├── content.js # 内容脚本,捕捉评论和注入回复
├── popup.html # 弹出页面,显示状态和配置
├── popup.js # 弹出页面的逻辑
└── icons/
├── icon16.png
├── icon48.png
└── icon128.png
先看最重要的manifest.json,这是插件的入口配置:
json复制{
"manifest_version": 3,
"name": "直播间AI互动助手",
"version": "1.0.0",
"description": "捕捉直播间评论,调用豆包API自动生成回复",
"permissions": [
"storage",
"activeTab",
"scripting"
],
"host_permissions": [
"https://live.douyin.com/*",
"https://ark.cn-beijing.volces.com/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://live.douyin.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
}
很多人在host_permissions这里容易忽略细节。如果你的豆包API是通过https://ark.cn-beijing.volces.com这个域名访问的,那这里一定要加上这个域名的权限。我之前见过一个案例,fetch请求在页面上正常,放到Service Worker里就报跨域错误,原因就是漏配了这个字段。V3里跨域请求的域名白名单是严格受host_permissions控制的,少了它,请求根本发不出去。
另外,content_scripts的matches字段限定了插件只在抖音直播页面生效,别搞成<all_urls>,否则每个页面都执行脚本,既影响性能,也很容易被识别为可疑行为。
run_at设置为document_idle是为了确保页面DOM基本加载完成后再注入脚本,避免过早执行导致找不到目标节点。虽然我们后面要用MutationObserver监听动态节点,但首屏的基础结构还是要等加载完才好定位。
3. 网页捕捉的核心:如何精准抓取直播间评论
这是整个项目技术含量最高的部分,也是大部分新手最容易卡住的地方。直播间的评论是实时刷新的,DOM节点不断被创建和移除,而且评论数据分布在不同的元素里,怎么稳定地拿到每一条新评论,并且保证不重复、不遗漏?
3.1 定位评论容器:选择器不是写死就能万事大吉
抖音直播间的DOM结构隔一段时间就会调整。你在今天写死的css选择器,可能下周就失效了。所以第一步不是急着写选择器,而是打开开发者工具,花点时间分析当前直播间的DOM结构。
以网页版抖音直播间为例(不同版本的结构可能有差异),评论区通常在一个带有滚动条的子容器内,每一条评论是一个独立的元素,类似这样:
html复制<div class="webcast-chatroom___item">
<div class="webcast-chatroom___nick-name">用户名</div>
<div class="webcast-chatroom___content">评论内容</div>
</div>
注意,这里的类名是动态生成的,每次刷新页面可能都不一样。所以不能硬编码类名,而是要用更稳定的特征去定位。我的做法是:优先找class中包含chatroom或chat字样且具有列表特征的父容器,然后再遍历子元素。
为了应对类名可能变化的问题,建议写一个“多级回退”的选择器函数,比如:
javascript复制function findChatContainer() {
const candidates = [
'.webcast-chatroom___item',
'[class*="chatroom"] [class*="item"]',
'[class*="ChatRoom"] [class*="Item"]',
'[class*="chat"] [class*="item"]'
];
for (const selector of candidates) {
const el = document.querySelectorAll(selector);
if (el && el.length > 0) {
return el;
}
}
return null;
}
这个函数会尝试多个选择器,哪个能匹配到就用哪个。这样即使抖音改了部分类名,只要还保留着chatroom或item这种特征词,就能兜底找到。
3.2 MutationObserver监听:评论滚动的秘密都在这里
定位到容器后,下一步就是监听变化。MutationObserver是浏览器提供的原生API,可以监听DOM节点的增删改。这里我们不监听整个页面,只监听评论区容器,减少性能开销。
javascript复制const chatContainer = findChatContainer();
if (chatContainer) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
handleNewComment(node);
}
});
}
}
});
observer.observe(chatContainer, {
childList: true,
subtree: true
});
}
这里面有个关键细节:observer.observe的配置是childList: true加subtree: true。childList: true表示监听子节点的增加和移除,subtree: true表示监听所有后代节点,而不只是直接子节点。因为评论元素内部还有孙子节点(比如头像、昵称、内容这些子元素),如果只监听直接子节点(不设subtree),你只能看到评论条目的外层div被添加,但内部的文字内容还没渲染出来,你拿到的节点是“空”的,提取不到文本。
3.3 提取评论信息:用户名、内容和校验去重
当MutationObserver回调拿到新增节点后,下一步就是从这个节点里提取出用户名和评论内容。
javascript复制function handleNewComment(node) {
// 尝试获取用户名
const nicknameEl = node.querySelector('[class*="nick-name"], [class*="nickname"], [class*="name"]');
const nickname = nicknameEl ? nicknameEl.textContent.trim() : '未知用户';
// 尝试获取评论内容
const contentEl = node.querySelector('[class*="content"], [class*="text"], [class*="desc"]');
const content = contentEl ? contentEl.textContent.trim() : '';
if (!content) return;
// 构造一个唯一ID,用于去重
const commentId = `${nickname}_${content}_${Date.now()}`;
// 发往后台处理
chrome.runtime.sendMessage({
type: 'NEW_COMMENT',
payload: {
id: commentId,
nickname: nickname,
content: content,
timestamp: Date.now()
}
});
}
这里有几个注意事项:
选择器要用包含匹配。抖音的类名是有混淆的,你看到的可能是webcast-chatroom___nick-name这样带前缀的。用[class*="nick-name"]属性包含选择器,比class="nick-name"精确匹配要稳妥得多。
去重逻辑不能省。评论区滚动时,尤其是用户刷屏的情况下,MutationObserver会触发非常频繁。有时候同一条评论会因为DOM的重绘被多次触发。所以每条评论要生成一个指纹ID。最理想的情况是用抖音自己的评论ID,但这需要从DOM属性里解析,实现成本较高。一个折中方案是“用户名+内容+时间戳”的组合,并用一个Set来保存最近N条评论的ID,超过容量后自动淘汰旧的。
javascript复制const recentCommentIds = new Set();
const MAX_CACHE_SIZE = 200;
function isDuplicate(commentId) {
if (recentCommentIds.has(commentId)) return true;
recentCommentIds.add(commentId);
// 控制缓存大小,防止内存无限增长
if (recentCommentIds.size > MAX_CACHE_SIZE) {
const oldestKey = recentCommentIds.values().next().value;
recentCommentIds.delete(oldestKey);
}
return false;
}
3.4 评论频率控制:别让你的插件把页面搞崩溃
直播间评论的量大到超乎想象。热闹的直播间一秒钟可能涌进来几十条甚至上百条评论。如果每一条都发消息给后台、每一条都去调豆包API,那无论是浏览器还是API的配额都会很快被打爆。
所以,在handleNewComment这个环节必须做频率控制。常见的策略有两种:
节流(throttle):固定时间窗口内只处理一条评论。比如每3秒最多向后台发送一条新的评论数据。
防抖(debounce):等评论流“静默”下来再批量处理。但直播间的评论通常不会真正静默,所以防抖不太适合这个场景。
更合理的做法是滑动窗口 + 优先级队列。我用的是一个简化版:每秒最多处理1条普通评论,但如果检测到评论里包含“主播”、“多少钱”、“怎么买”这类高意向关键词,就允许插队优先处理。
javascript复制const QUEUE = [];
let isProcessing = false;
function enqueueComment(comment) {
// 高优先级评论插队
if (isHighPriority(comment.content)) {
QUEUE.unshift(comment);
} else {
QUEUE.push(comment);
}
processQueue();
}
function processQueue() {
if (isProcessing) return;
if (QUEUE.length === 0) return;
isProcessing = true;
const comment = QUEUE.shift();
chrome.runtime.sendMessage({
type: 'NEW_COMMENT',
payload: comment
}, () => {
// 每处理完一条,间隔1秒再处理下一条
setTimeout(() => {
isProcessing = false;
processQueue();
}, 1000);
});
}
这里把评论发送从MutationObserver回调里剥离出来,放入一个独立队列,由processQueue控制节奏。这是一个很关键的设计——回调只负责“收集”,真正的“分发”由队列管理。好处是即使某一瞬间评论量暴增,也不会阻塞页面渲染,更不会丢失评论——它们只是排队等待处理。
4. 后台服务:把评论交给豆包API并拿回回复
Content Script拿到评论后,通过chrome.runtime.sendMessage发给background.js。这一步的实质是消息通信,但很多人在这里会犯一个很隐蔽的错误:Content Script和Background Service Worker各自有独立的JavaScript执行环境,它们不共享变量,也不能直接互相调用。通信只能通过消息机制进行。
4.1 消息接收与状态管理
后台脚本的核心逻辑很简单,就是用chrome.runtime.onMessage监听消息,然后根据消息类型分发处理:
javascript复制chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'NEW_COMMENT') {
handleComment(message.payload)
.then((reply) => {
sendResponse({ ok: true, reply: reply });
})
.catch((error) => {
sendResponse({ ok: false, error: error.message });
});
return true; // 表示异步响应
}
});
注意这个return true。在Manifest V3里,如果监听器中使用了异步操作(比如fetch),必须返回true来告诉浏览器“这个监听器还在运行,响应是异步的”。不写这个返回值,sendResponse可能永远不会被调用,发消息的一方会一直等到超时。
4.2 调用豆包API:在Service Worker里没有跨域问题
在handleComment函数里,我们要做的就是调用豆包API。豆包的接口是OpenAI兼容的格式,端点是/api/v3/chat/completions。在上一篇文章里已经详细讲过如何申请API Key和配置Endpoint,这里直接给出代码:
javascript复制async function handleComment(comment) {
const apiKey = await getApiKeyFromStorage();
const model = await getModelFromStorage();
const response = await fetch('https://ark.cn-beijing.volces.com/api/v3/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: model,
messages: [
{
role: 'system',
content: '你是一个直播间互动助手。请用简短的、口语化的语言回复观众评论。回复长度不要超过30个字。'
},
{
role: 'user',
content: `观众${comment.nickname}说:${comment.content}。请给出你的回复。`
}
],
temperature: 0.8,
max_tokens: 100
})
});
if (!response.ok) {
throw new Error(`API请求失败: ${response.status}`);
}
const data = await response.json();
const reply = data.choices[0].message.content.trim();
return reply;
}
这里有两个细节值得展开说一下。
第一,system Prompt的设计很关键。 直播间回复和个人对话的Prompt逻辑完全不同。直播间回复需要短、快、口语化,能接梗。我不建议让AI生成一大段有条理的答案,那不符合直播场景。max_tokens控制在100以内,并且明确要求“不超过30个字”,能从源头约束输出长度,降低生成时间。
第二,API Key的存储。 千万别把API Key硬编码在代码里。我使用的是chrome.storage.local,通过popup.html里的配置页面让用户自己填写,然后保存。chrome.storage是插件专用的存储区域,数据不会随着页面刷新丢失,也不容易被其他页面读取。
4.3 高并发控制:直播间场景的性能瓶颈
如果你在真实直播间里测试过,一定会碰到一个问题:评论区每秒都有几十条评论,虽然我们在Content Script这边做了1秒1条的节流,但直播间有多个用户时,比如用户A发了“你好”,用户B发了“多少钱”,这两条评论间隔不到1秒,依然会同时到达后台。
Background Service Worker的fetch是并发的,如果同时有3条评论进来,就会有3个并发的API请求。豆包API的并发限制一般是按账号等级来的,免费或低等级账号的RPM(每分钟请求数)和TPM(每分钟Token数)很有限,很容易触发429限流错误。
所以,后台也需要一个队列。处理和发送评论的队列是两套,我分别叫它们“上行队列”和“下行队列”。
后台的处理策略是串行处理。每条评论处理完,拿到AI回复,再处理下一条。这样虽然会牺牲一些响应速度,但能最大程度避免触发限流。
javascript复制let processing = false;
const pendingQueue = [];
async function processQueue() {
if (processing) return;
if (pendingQueue.length === 0) return;
processing = true;
const item = pendingQueue.shift();
try {
const reply = await handleComment(item.comment);
// 将回复发回给Content Script,由它填入输入框
chrome.tabs.sendMessage(item.tabId, {
type: 'AI_REPLY',
payload: {
reply: reply,
originalComment: item.comment
}
});
} catch (error) {
console.error('处理评论失败:', error);
} finally {
processing = false;
processQueue();
}
}
这里需要把tabId也传过来,因为chrome.tabs.sendMessage需要指定往哪个标签页发送消息。
5. 回写机制:让AI的回复自动出现在输入框中
到这里,我们拿到了AI生成的回复,最后一步是把它注入到直播间的输入框,并触发发送。
5.1 定位输入框
直播间输入框的特征比较明显,通常是一个textarea或div,带有contenteditable="true"属性。定位逻辑类似评论区的多级回退策略:
javascript复制function findInputBox() {
const candidates = [
'textarea[class*="chat"]',
'textarea[class*="input"]',
'div[contenteditable="true"][class*="input"]',
'div[contenteditable="true"]'
];
for (const selector of candidates) {
const el = document.querySelector(selector);
if (el) return el;
}
return null;
}
这里有个重要的兼容性问题:抖音直播间的输入框有时是一个标准的textarea,有时是一个div加contenteditable="true"。后面这一种并不是原生输入框,直接设置value或textContent不会触发框架的状态更新,导致发送按钮仍然是灰色不可点状态。
5.2 使用原生setter模拟输入
为了触发前端框架(通常是Vue或React)的响应式更新,必须使用原生HTMLInputElement.prototype上的value setter,并派发input事件。
javascript复制function setNativeValue(element, value) {
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
valueSetter.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
}
对于contenteditable的div,做法略有不同,要直接操作文本节点:
javascript复制function setContentEditable(element, text) {
element.focus();
element.textContent = text;
element.dispatchEvent(new InputEvent('input', {
bubbles: true,
data: text,
inputType: 'insertText'
}));
}
然后模拟回车键发送:
javascript复制function simulateSend(inputElement) {
const event = new KeyboardEvent('keydown', {
key: 'Enter',
code: 'Enter',
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
});
inputElement.dispatchEvent(event);
}
这几段代码是后台回复回写时的核心工具函数。很多初学插件开发的朋友把它们直接写死在业务逻辑里,导致换一个直播间结构就全面崩溃。我习惯把这些DOM操作单独封装成一个模块,出现问题时只需要改这个文件就行,不用动业务逻辑代码。
5.3 发送前的人工审核模式
这里我要多说一句。全自动回复在直播间里有很大风险。AI偶尔会生成不合适的内容,或者被用户用“套话”诱导输出不当回复。所以我建议在正式使用前,至少保留一个“半自动模式”:AI生成回复后,不在后台直接发送,而是先把回复填入输入框,但不自动按回车。
javascript复制chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'AI_REPLY') {
const inputBox = findInputBox();
if (!inputBox) {
sendResponse({ ok: false, error: '未找到输入框' });
return;
}
if (getAutoSendMode()) {
setNativeValue(inputBox, message.payload.reply);
setTimeout(() => simulateSend(inputBox), 500);
} else {
// 半自动模式:只填入,不自动发送
setNativeValue(inputBox, message.payload.reply);
}
sendResponse({ ok: true });
}
});
半自动模式下,你只需要在发送前用余光扫一眼输入框里的文字,确认没问题按一下回车,有问题删掉就行。这个模式既保留了效率,又给自己留了一道安全阀。
6. 配置界面:一个简单的Popup页面
一个插件的完整度,很大程度取决于它的配置界面。我们的插件至少需要允许用户配置这几项内容:
- 豆包API Key
- 使用的模型名称(如
doubao-pro-32k或doubao-lite-4k) - AI回复风格(简短/热情/专业)
- 自动发送/半自动模式切换
- 回复频率上限
popup.html我不需要贴完整代码,只展示核心的表单和对应的存储逻辑:
html复制<div class="config-item">
<label>API Key</label>
<input type="password" id="apiKey" placeholder="请输入你的豆包API Key">
</div>
<div class="config-item">
<label>模型名称</label>
<select id="model">
<option value="doubao-pro-32k">doubao-pro-32k</option>
<option value="doubao-lite-4k">doubao-lite-4k</option>
</select>
</div>
<div class="config-item">
<label>发送模式</label>
<select id="sendMode">
<option value="auto">自动发送</option>
<option value="manual">半自动(需手动确认)</option>
</select>
</div>
<button id="saveBtn">保存配置</button>
popup.js的逻辑也很直接,保存时写入chrome.storage.local,加载时读取并回填:
javascript复制document.getElementById('saveBtn').addEventListener('click', async () => {
const config = {
apiKey: document.getElementById('apiKey').value.trim(),
model: document.getElementById('model').value,
sendMode: document.getElementById('sendMode').value
};
await chrome.storage.local.set({ config });
alert('配置已保存');
});
存储的命名空间建议只用一份config对象,不要用多个分散的key管理,这样在后台读取的时候一次get就能拿到全部配置,不用多次异步调用。
7. 实际测试中的问题与排查技巧
这个项目从开发到上线,我在不同直播间测试过很多次,踩了不少坑。下面这些问题有很高的复现率,如果你遇到,可以按这个思路排查。
7.1 选择器失效:为什么昨天还能用,今天就抓不到了
不管是评论区还是输入框,最常出现的问题就是页面结构调整。抖音前端每次发版,都有可能改类名。遇到这种情况,第一反应不是改代码,而是先打开控制台,重新审查元素,确认当前的DOM结构是什么样的。
一个实用的技巧是:在content.js里加一个调试模式,当配置项debug为true时,把捕捉到的容器信息打印在控制台。
javascript复制if (debugMode) {
console.log('[插件调试] 找到评论区容器:', chatContainer);
console.log('[插件调试] 容器内评论数量:', chatContainer.children.length);
}
这样即使出了线上问题,也能快速定位是“根本没找到容器”还是“找到了但提取内容失败”。两个问题的解决方向完全不同。
7.2 发不出消息:sendMessage 无响应
这是一个典型的异步陷阱。当你使用chrome.runtime.sendMessage从Content Script发消息到Background时,如果Background的监听器内部有异步操作,而你没有return true,Content Script的sendResponse回调会立即执行,并且返回undefined。
检查一下你的Background监听器末尾是否添加了return true。如果没有,加上它。同时,Content Script里接收响应的地方要判断response是否有值:
javascript复制chrome.runtime.sendMessage({
type: 'NEW_COMMENT',
payload: comment
}, (response) => {
if (chrome.runtime.lastError) {
console.error('发送消息失败:', chrome.runtime.lastError.message);
return;
}
if (response && response.ok) {
console.log('收到AI回复:', response.reply);
}
});
chrome.runtime.lastError是必须处理的,否则消息发送失败时,控制台会报“Unchecked runtime.lastError: The message port closed before a response was received”,而且这个报错会干扰你排查真正的bug。
7.3 输入框无法填入内容
这个问题上面讲过,本质是框架的响应式系统没被触发。如果你用input.value = 'xxx'的方式赋值,然后点击发送按钮没反应,那基本可以断定是这个原因。解决方案就是用原生setter加上派发Event('input', { bubbles: true })。
还有一个小细节,抖音的输入框如果是contenteditable的div,你需要先用element.focus()聚焦它,再设置内容。不聚焦的话,即使内容填写进去了,发送按钮也可能不会激活。
7.4 评论抓取不完整或漏抓
有时候评论区滚动太快,MutationObserver回调处理不过来,导致部分评论还没被提取就被移出了DOM。这是真实存在的性能瓶颈。
我的解决方案是:把“提取节点信息”的操作尽量轻量化,不要在主线程里做复杂的DOM查询和正则匹配。可以把提取到的原始节点textContent直接发送到后台,让后台去做关键词过滤和内容分析。这样Content Script这边的处理时间能压缩到1毫秒以内。
另外,MutationObserver的回调里虽然使用了addedNodes,但要特别注意新增节点里可能有子节点嵌套。如果你只取node.textContent,可能会把内部子元素的文本也包含进去,导致提取到的内容根本不是一条干净的评论,而是包含用户头像、时间戳等噪声。稳妥的做法是递归查找[class*="content"]这样的子元素,只取目标内容。
7.5 Service Worker 被回收
Manifest V3里,Service Worker是事件驱动的,如果一段时间没有事件触发,它会被浏览器挂起。下次有新消息进来时,再重新唤醒。这意味着Service Worker无法保存持久的“状态”,比如一个全局的队列数组,在Service Worker休眠后会被清空。
解决方法是把关键状态持久化到chrome.storage.session或chrome.storage.local中。注意,chrome.storage.session是V3新增的,可以保证在浏览器会话期间数据不丢失,而且不会同步到其他设备。非常适合存队列状态。
javascript复制// 在Service Worker中保存队列
async function saveQueueToStorage() {
await chrome.storage.session.set({ pendingQueue: pendingQueue });
}
// Service Worker被唤醒后,从存储中恢复队列
async function restoreQueueFromStorage() {
const data = await chrome.storage.session.get('pendingQueue');
pendingQueue.push(...(data.pendingQueue || []));
}
这一步解决了Service Worker生命周期导致的队列丢失问题。
8. 完整实操:从零跑通一个最小可用插件
前面的原理讲了不少,这里我给你梳理一个最精简的清单,照着这个顺序走,半小时内就能跑通整个流程。
第一步:准备API Key和模型ID
去火山引擎方舟平台开通豆包API,拿到API Key和Endpoint地址。确认你的账号有可用的模型,比如doubao-pro-32k,记录下模型ID。
第二步:搭建插件骨架
新建一个目录,把manifest.json、background.js、content.js三个文件建好,内容直接用上面贴的代码。图标可以先不放,Manifest里的icons字段暂时注释掉,不影响加载。
第三步:在后台配置API Key
在popup.html中填入API Key和模型名称,点击保存。这一步会在chrome.storage.local里写入配置。如果你还没做popup页面,也可以先在background.js里写死一个测试Key,后续再改。
第四步:打开抖音直播间
打开任意一个正常直播的直播间,然后进入chrome://extensions,打开开发者模式,点击“加载已解压的扩展程序”,选择你的插件目录。此时插件图标会出现在浏览器工具栏。
第五步:验证评论捕捉
点击插件图标打开popup页面,打开控制台,随便发一条评论,看看是否能在background.js的控制台里看到消息日志。如果看不到,先检查content_scripts是否注入成功,在直播间页面控制台执行document.querySelectorAll('[class*="chatroom"]'),看看能不能选中元素。
第六步:验证AI回复与回写
评论捕捉成功后,后台会自动调用豆包API。如果配置正确,几秒内你会看到输入框里自动填入了一条AI回复(取决于你设置的发送模式)。整个链路就通了。
第七步:调优频率和Prompt
根据你直播间的互动强度,调整Content Script的队列发送间隔(比如从1秒改成2秒),以及后台队列的串行处理逻辑,确保在稳定不触发API限流的前提下,尽可能提升回复的及时性。
9. 经验总结与后续扩展方向
到这里,我们这篇内容已经覆盖了“插件开发”和“网页捕捉”这两个核心环节。和第一篇结合起来,你手上已经有了一个能捕捉直播间评论、自动调用豆包API、并把回复回写到输入框的完整插件。
如果你发现实际使用中还需要处理更复杂的情况,有几个方向可以继续深入:
多直播间同时管理。现在的插件是针对单个标签页的,如果你想同时监控多个直播间,需要在Content Script里维护一个tabId映射表,把不同标签页的评论数据分流到不同的对话上下文。
评论内容的语义理解。现在的Prompt只是简单地把评论拼接进user消息里。你可以进一步在system消息中定义更精细的规则,比如识别带货意图、情感倾向、竞品比价等,让AI回复更对观众口味。
与本地服务对接。如果你不想把所有逻辑都堆在浏览器插件里,可以在本地起一个WebSocket服务器,插件把捕捉到的评论转发给本地服务,由本地服务统一调度豆包API,再回传结果。这种架构下,数据分析和内容审核都可以放到更灵活的环境中。
我在实际跑这个项目时,最大的体会是:浏览器插件方案虽然在工程上多了一层,但换来的是极高的稳定性和可维护性。因为你永远不需要去逆向复杂的内部接口,只需要跟DOM打交道。而DOM再变化,它终究是给人看的,只要页面还在显示内容,就一定能找到提取的方式。
下一篇我会继续讲数据存储和对话上下文的处理,也就是怎么让AI在整场直播中保持“记忆”,而不是每次都被当作一个全新的对话。这个点很关键,因为它直接决定了AI回复的连贯性和“人味”。如果你在实战中遇到了什么问题,也欢迎按这篇文章里的排查思路先自查一遍,大部分坑都能自己解决。
