【实战教程】从零开发Chrome扩展:自动采集小红书评论并接入DeepSeek AI
本文将手把手教你开发一个Chrome扩展,自动读取小红书、抖音、B站评论,接入DeepSeek大模型生成回复,并一键填入页面输入框。包含完整代码和踩坑记录。
前言
作为一名开发者,身边的朋友经常抱怨:做小红书带货,视频爆了以后评论区999+,翻到手酸也找不全问"怎么买"的人。这些高意向评论散落在海量互动中,人工筛选效率极低。
于是我决定开发一个工具来自动解决这个问题。本文将完整记录开发过程,从环境搭建到上线,希望能帮助有同样需求的同学。
一、技术选型与架构设计
1.1 为什么选择Chrome扩展?
有三种技术方案可选:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 官方API | 稳定、规范 | 小红书等平台未开放评论读取接口 |
| 爬虫 | 灵活 | 反爬严格,需要维护登录态,合规风险 |
| Chrome扩展 | 读取用户可见内容,合规;直接操作页面DOM | 平台改版时需要更新选择器 |
最终选择 Chrome扩展 + Go后端 的架构:
┌─────────────────────────────────────────┐
│ Chrome Extension │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Content Script│ │ Sidepanel │ │
│ │ (小红书/抖音/ │◄───│ (React UI) │ │
│ │ B站DOM采集) │ └─────────────┘ │
│ └──────┬──────┘ │
│ │ 发送评论数据 │
└─────────┼───────────────────────────────┘
│
▼ HTTPS
┌─────────────────────────────────────────┐
│ Go Backend (Gin) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ JWT │ │DeepSeek │ │ 积分 │ │
│ │ 鉴权 │ │ API调用 │ │ 系统 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────┘
1.2 技术栈
- 前端扩展: Plasmo + React + TypeScript
- 后端: Go + Gin + GORM
- 数据库: PostgreSQL
- AI: DeepSeek-V3 API
二、环境搭建(详细步骤)
2.1 安装依赖
# 1. 克隆项目
git clone https://github.com/mustcanbedo/comment_copilot.git
cd comment_copilot
# 2. 安装Node依赖
npm install
# 3. 安装Go依赖(进入backend目录)
cd backend
go mod tidy
2.2 配置数据库
安装PostgreSQL,创建数据库:
CREATE DATABASE comment_copilot;
2.3 配置后端
复制配置文件模板:
cp backend/config.yaml.example backend/config.yaml
编辑 backend/config.yaml:
# 服务端口号
port: "3000"
# PostgreSQL连接字符串
database_url: "postgresql://用户名:密码@localhost:5432/comment_copilot?sslmode=disable"
# DeepSeek API密钥(必填)
deepseek_api_key: "sk-你的DeepSeek密钥"
# JWT签名密钥(随便填一个随机字符串)
auth_secret: "your-random-secret-key"
# 自动执行数据库迁移
auto_migrate: true
获取DeepSeek密钥:访问 https://platform.deepseek.com/ 注册并创建API Key。
2.4 配置扩展开发环境
扩展的环境变量配置在 apps/extension/.env.development:
# 本地开发默认连接本地后端
PLASMO_PUBLIC_API_URL=http://localhost:3000/api
这个文件已配置好,无需修改。
三、核心功能实现详解
3.1 内容脚本(Content Script)
内容脚本是扩展注入到网页中的代码,负责读取评论数据。
3.1.1 小红书内容脚本
创建 apps/extension/contents/xiaohongshu.ts:
import type { PlasmoCSConfig } from "plasmo"
// 配置:只在匹配的小红书页面注入
export const config: PlasmoCSConfig = {
matches: ["https://www.xiaohongshu.com/*"],
run_at: "document_idle", // 页面加载完成后执行
all_frames: false,
}
// 选择器配置(后续可从服务端热更新)
let SELECTORS = {
commentList: ".comment-item",
authorName: "a.name",
content: ".comment-inner-container > span:not([class])",
timestamp: "span:not([class]) + span:not([class])",
}
// 已采集评论ID集合(去重用)
const seenIds = new Set<string>()
const SEEN_IDS_MAX = 3000 // 防止内存无限增长
关键问题1:Vue3数据的解包
小红书使用Vue3,window.__INITIAL_STATE__中的数据被包装成ref,直接读取会得到undefined。
解决方案:递归解包Vue ref:
/**
* 解包Vue3的ref对象
* Vue3使用__v_isRef标记ref对象,实际数据在_value或value中
*/
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>
// 判断是否为Vue ref
if (r.__v_isRef === true) {
// Vue3 ref的数据可能在_rawValue或value中
const inner = r._rawValue !== undefined
? r._rawValue
: r.value
// 递归解包(可能嵌套多层ref)
return unwrapVueRef(inner)
}
return value
}
// 使用示例:获取当前登录用户昵称
const user = unwrapVueRef(window.__INITIAL_STATE__?.user)
const nickname = user?.nickname // 现在能正确读取了
关键问题2:过滤自己的评论
避免在侧栏显示"自己回复自己"的尴尬情况:
/**
* 判断是否为当前登录用户的评论
* 考虑昵称可能被截断的情况(如"张三"显示为"张...")
*/
function isSelfComment(authorName: string, selfNick: string): boolean {
// 标准化:去空格、转小写
const normalize = (s: string) =>
s.replace(/\s+/g, " ").trim().toLowerCase()
const a = normalize(authorName)
const b = normalize(selfNick)
// 完全匹配
if (a === b) return true
// 前缀匹配(处理截断情况,如"张..."匹配"张三")
if (a.length >= 5 && b.startsWith(a)) return true
if (b.length >= 5 && a.startsWith(b)) return true
return false
}
3.1.2 抖音内容脚本
抖音的坑更多:Shadow DOM + 零宽字符。
坑1:Shadow DOM穿透
抖音评论区使用了Shadow DOM,需要递归穿透:
const DEEP_QUERY_MAX_ROOTS = 48_000 // 节点预算,防止卡死
/**
* 穿透Shadow DOM查询元素
* 从document开始,递归进入所有shadowRoot查找匹配元素
*/
function querySelectorAllDeep(selector: string): Element[] {
const results: Element[] = []
const queue: (Document | ShadowRoot)[] = [document]
const seen = new Set<Document | ShadowRoot>()
let count = 0
while (queue.length && count < DEEP_QUERY_MAX_ROOTS) {
const root = queue.shift()!
if (seen.has(root)) continue
seen.add(root)
count++
// 在当前root中查找
root.querySelectorAll(selector).forEach(el => results.push(el))
// 查找所有带shadowRoot的元素,加入队列
root.querySelectorAll("*").forEach(el => {
if (el.shadowRoot) queue.push(el.shadowRoot)
})
}
return results
}
坑2:零宽字符
抖音昵称里可能插入零宽字符(肉眼看不见),导致正则匹配失败:
/**
* 去除零宽字符并标准化空格
* 零宽字符:\u200b-\u200d, \uFEFF
*/
function cleanText(s: string): string {
return s
.replace(/[\u200b-\u200d\uFEFF]/g, "") // 删除零宽字符
.replace(/\s+/g, " ") // 多个空格合并
.trim()
}
// 使用:比较昵称前先clean
cleanText("张三") === cleanText("张\u200b三") // true
3.1.3 节流扫描优化
评论列表随滚动动态加载,不能每次DOM变动都全量扫描,会卡死页面:
/**
* 创建节流函数
* @param fn 要执行的函数
* @param intervalMs 最小执行间隔(毫秒)
*/
function createThrottledScan(fn: () => void, intervalMs: number) {
let lastTime = 0
return function() {
const now = Date.now()
// 只有超过间隔时间才执行
if (now - lastTime >= intervalMs) {
lastTime = now
fn()
}
}
}
// 使用:每500ms最多执行一次扫描
const throttledScan = createThrottledScan(scanComments, 500)
// 监听DOM变化,但使用节流版本
document.addEventListener('scroll', throttledScan)
3.2 后端AI接入
3.2.1 DeepSeek API调用
package handler
import (
"bytes"
"encoding/json"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// 设置超时时间,DeepSeek可能较慢
var deepSeekClient = &http.Client{
Timeout: 90 * time.Second,
}
const deepSeekURL = "https://api.deepseek.com/v1/chat/completions"
func (h *AIHandler) Reply(c *gin.Context) {
// 1. 获取用户信息
userID := c.GetString("userId")
if userID == "" {
c.JSON(401, gin.H{"ok": false, "error": "未登录"})
return
}
// 2. 扣费(前置,防止刷接口)
pointsPerCall := 1
if err := h.userRepo.DeductPoints(userID, pointsPerCall); err != nil {
c.JSON(403, gin.H{"ok": false, "error": "积分不足"})
return
}
// 3. 构建Prompt
prompt := buildPrompt(req.CommentContent, req.Persona)
// 4. 调用DeepSeek
body, _ := json.Marshal(map[string]interface{}{
"model": "deepseek-chat",
"messages": []map[string]string{
{"role": "system", "content": "你是一个专业的小红书运营助手..."},
{"role": "user", "content": prompt},
},
"temperature": 0.7,
})
req2, _ := http.NewRequest("POST", deepSeekURL, bytes.NewReader(body))
req2.Header.Set("Authorization", "Bearer "+h.deepSeekAPIKey)
req2.Header.Set("Content-Type", "application/json")
resp, err := deepSeekClient.Do(req2)
// ...处理响应
}
关键点:
- 超时设置:90秒,DeepSeek有时响应慢
- 先扣费再调用:防止恶意刷接口
- 非流式返回:前端处理简单,一次性拿到完整回复
3.3 填入输入框的技术细节
这是整个项目最难的部分。不同平台使用不同的编辑器:
| 平台 | 编辑器类型 | 填入方法 |
|---|---|---|
| 小红书 | 普通textarea | element.value = text |
| 抖音 | Draft.js (contenteditable) | 模拟输入事件 |
| B站 | 自定义组件 | 模拟键盘事件 |
3.3.1 Draft.js编辑器填入
抖音使用Draft.js,直接改innerHTML无效,必须模拟真实用户输入:
/**
* 向Draft.js编辑器填入文本
* Draft.js依赖React的受控组件状态,必须模拟真实输入事件
*/
function fillDraftEditor(element: HTMLElement, text: string): boolean {
try {
// 1. 聚焦编辑器
element.focus()
// 2. 创建选区(全选现有内容)
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
selection?.removeAllRanges()
selection?.addRange(range)
// 3. 模拟输入事件(这是关键)
const result = document.execCommand("insertText", false, text)
// 4. 触发input事件,让React更新状态
element.dispatchEvent(new InputEvent("input", {
bubbles: true,
cancelable: true,
}))
return result
} catch (e) {
console.error("填入失败:", e)
return false
}
}
坑点说明:
- 必须用
execCommand("insertText"),直接改textContentDraft.js感知不到 - 必须触发
input事件,否则React状态不同步,点发送时内容消失
3.3.2 通用填入函数
封装一个兼容各平台的填入函数:
/**
* 向任意输入框填入文本
* 自动判断编辑器类型,选择最佳填入策略
*/
export function fillInput(element: HTMLElement, text: string): boolean {
// 1. 普通input/textarea
if (element instanceof HTMLInputElement ||
element instanceof HTMLTextAreaElement) {
element.value = text
element.dispatchEvent(new Event("input", { bubbles: true }))
return true
}
// 2. contenteditable(Draft.js等富文本编辑器)
if (element.isContentEditable) {
return fillDraftEditor(element, text)
}
// 3. 兜底:直接修改textContent(成功率低,仅作备用)
element.textContent = text
return true
}
四、完整启动流程
4.1 启动后端服务
# 终端1:启动后端
cd backend
go run ./cmd/server
# 看到以下输出表示成功
# Go backend running at http://localhost:3000/api
4.2 启动扩展开发模式
# 终端2:启动扩展
cd apps/extension
npm run dev
# 看到以下输出表示成功
# 🟡 Plasmo v0.86.0
# 🔥 http://localhost:1815
4.3 Chrome加载扩展
- 打开 Chrome,访问
chrome://extensions/ - 右上角打开「开发者模式」
- 点击「加载已解压的扩展程序」
- 选择目录:
apps/extension/.plasmo/chrome-mv3-dev - 看到扩展图标即成功
4.4 测试流程
- 打开小红书任意笔记页面
- 打开Chrome侧边栏(点击扩展图标或按快捷键)
- 侧边栏应显示当前页面评论列表
- 点击任意评论的「生成回复」按钮
- 应看到AI生成的回复建议
- 点击「填入」,回复应自动填入小红书回复框
五、常见问题排查
5.1 评论不显示
可能原因:
- 选择器失效(平台改版)
- 页面未完全加载
排查步骤:
- 打开浏览器开发者工具(F12)
- 检查Console是否有报错
- 检查评论元素的选择器是否匹配
5.2 填入失败
可能原因:
- 平台改版,编辑器结构变化
- 填入时机不对(页面未就绪)
解决方案:
- 检查输入框是否可见且可交互
- 尝试手动聚焦输入框后再填入
- 查看background.js日志
5.3 AI回复为空
可能原因:
- DeepSeek API密钥错误
- 积分不足
- 网络超时
排查:
- 检查
backend/config.yaml中的deepseek_api_key - 查看后端日志错误信息
六、生产部署
6.1 构建生产包
cd apps/extension
# 1. 创建生产配置
cp .env.production.example .env.production
# 2. 编辑.env.production,填入生产API地址
# PLASMO_PUBLIC_API_URL=https://your-api.com/api
# 3. 构建商店包(自动注入host_permissions)
npm run package:store
# 产物:build/chrome-mv3-prod.zip
6.2 后端部署
推荐使用Docker部署:
FROM golang:1.21-alpine
WORKDIR /app
COPY . .
RUN go build -o server ./cmd/server
EXPOSE 3000
CMD ["./server"]
七、开源地址
项目已开源,欢迎Star和Fork:
GitHub: https://github.com/mustcanbedo/comment_copilot
包含完整代码、详细文档、部署指南。有问题欢迎提Issue。
如果本文对你有帮助,欢迎点赞收藏!有任何问题评论区见~
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)