从Plasmo到DeepSeek,从Vue3解包到Shadow DOM,记录一个多平台评论区AI助手的开发实录。
做小红书、抖音带货的朋友都知道,视频爆了之后评论区999+,翻到手酸也找不全问"怎么买"的人。这些评论不是字,是钱在跟你打招呼,你给错过了。
我决定用技术解决这个问题。两周时间,做了一个Chrome扩展+Go后端的组合拳,自动采集评论、AI识别购买意向、一键生成回复。
一、为什么要用 Plasmo?
一开始打算用原生 Manifest V3 写,但 Plasmo 的 Content Script 自动注入、热更新、类型支持太香了:
// 小红书内容脚本配置
export const config: PlasmoCSConfig = {
matches: ["https://www.xiaohongshu.com/*"],
run_at: "document_idle",
all_frames: false,
}
一个文件对应一个平台,自动打包成独立的 content script,不用手动写 webpack。
二、小红书的坑:Vue3 的 ref 怎么解?
小红书页面用 Vue3,window.__INITIAL_STATE__ 里存了当前登录用户信息。但直接读 .nickname 是 undefined —— 因为 Vue3 把数据包成 ref 了。
必须递归解包:
function unwrapVueRef(value: unknown): unknown {
if (value === null || value === undefined) return value
if (typeof value !== "object") return value
const r = value as Record<string, unknown>
if (r.__v_isRef === true) {
const inner = r._rawValue !== undefined ? r._rawValue : r.value
return unwrapVueRef(inner)
}
return value
}
不解包就抓不到登录用户昵称,导致"自己回复自己"的尴尬场面。
三、抖音的坑:Shadow DOM + 零宽字符
抖音评论区用了 Shadow DOM,还得穿透多层 shadow root 找输入框。更恶心的是昵称里插零宽字符(\u200b),正则匹配直接失效:
function stripZeroWidthAndNormalizeSpaces(s: string): string {
return s
.replace(/[\u200b-\u200d\uFEFF]/g, "") // 干掉零宽
.replace(/\s+/g, " ")
.trim()
}
输入框选择器也得兼容 Draft.js 的 contenteditable 和自定义组件,不是简单的 input。
四、节流扫描:页面滚动时不卡死
评论列表随滚动动态加载,如果每次 DOM 变动都全量扫描,页面直接卡爆。实现了一个带预算的节流扫描:
const DEEP_QUERY_MAX_ROOTS = 48_000 // 节点预算上限
function createThrottledScan(fn: () => void, intervalMs: number) {
let last = 0
return () => {
const now = Date.now()
if (now - last >= intervalMs) {
last = now
fn()
}
}
}
配合 MutationObserver 监听评论列表变化,只有滚动停止后才触发扫描。
五、DeepSeek 对接:不是简单调 API
直接调 OpenAI 格式的 API 有坑:
- 超时时间太短会挂死,得设 90 秒
- 流式输出前端不好处理,改成非流式一次性返回
- 人设 prompt 要精简,token 贵
var deepSeekHTTPClient = &http.Client{Timeout: 90 * time.Second}
// 扣费逻辑前置,防止刷接口
pointsPerCall := 1
user.DeductPoints(pointsPerCall)
六、填入输入框:平台兼容性地狱
小红书用普通 textarea,抖音用 Draft.js 的 contenteditable,B站又是另一种结构。最通用的方法是模拟用户输入事件:
// 填充 Draft.js 编辑器
function fillDraftJS(element: HTMLElement, text: string) {
// 1. 聚焦
element.focus()
// 2. 创建选区
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
// 3. 模拟输入事件
document.execCommand("insertText", false, text)
}
不是简单的 element.value = text,否则 React/Draft.js 的状态不同步,点发送时内容消失。
七、架构图
Chrome Extension (Plasmo)
├── Content Script (小红书/抖音/B站)
│ └── DOM 采集 → 节流 → 去重
├── Sidepanel (React)
│ └── 展示 → AI生成 → 填入
└── Background
└── API 通信
Go Backend (Gin)
├── JWT 鉴权
├── 租户隔离 (x-tenant-id)
├── DeepSeek 调用
└── 积分扣费
总结
做浏览器扩展最大的坑不是代码,是平台的 DOM 随时会变。抖音一个月改两次选择器结构,得靠服务端热更新 selector 配置,否则每次都要发新版扩展等 Chrome Web Store 审核(通常 2-7 天)。
代码开源了,欢迎参考和吐槽:
GitHub: github.com/mustcanbedo…
如果这篇对你有帮助,点个赞吧~有问题评论区见