本文将手把手教你开发一个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)
	// ...处理响应
}

关键点

  1. 超时设置:90秒,DeepSeek有时响应慢
  2. 先扣费再调用:防止恶意刷接口
  3. 非流式返回:前端处理简单,一次性拿到完整回复

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加载扩展

  1. 打开 Chrome,访问 chrome://extensions/
  2. 右上角打开「开发者模式」
  3. 点击「加载已解压的扩展程序」
  4. 选择目录:apps/extension/.plasmo/chrome-mv3-dev
  5. 看到扩展图标即成功

4.4 测试流程

  1. 打开小红书任意笔记页面
  2. 打开Chrome侧边栏(点击扩展图标或按快捷键)
  3. 侧边栏应显示当前页面评论列表
  4. 点击任意评论的「生成回复」按钮
  5. 应看到AI生成的回复建议
  6. 点击「填入」,回复应自动填入小红书回复框

五、常见问题排查

5.1 评论不显示

可能原因

  • 选择器失效(平台改版)
  • 页面未完全加载

排查步骤

  1. 打开浏览器开发者工具(F12)
  2. 检查Console是否有报错
  3. 检查评论元素的选择器是否匹配

5.2 填入失败

可能原因

  • 平台改版,编辑器结构变化
  • 填入时机不对(页面未就绪)

解决方案

  1. 检查输入框是否可见且可交互
  2. 尝试手动聚焦输入框后再填入
  3. 查看background.js日志

5.3 AI回复为空

可能原因

  • DeepSeek API密钥错误
  • 积分不足
  • 网络超时

排查

  1. 检查backend/config.yaml中的deepseek_api_key
  2. 查看后端日志错误信息

六、生产部署

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。


如果本文对你有帮助,欢迎点赞收藏!有任何问题评论区见~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐