浏览器扩展:抓 DOM、录 HAR、把数据写进服务器

什么时候需要自己写插件

能复用工具就别造轮子,但下面三种场景通常只能自己上:

  • 目标站频繁改 DOM,油猴脚本越堆越乱,需要工程化
  • 需要精确网络层数据(请求体、响应体、时序),DevTools 截图不够
  • 要把采集结果写进自己系统的数据库,需要稳定的上传通道和重试
💡
一句话理解

扩展 = 三个独立 JS 上下文(popup / content / background),靠消息桥对话。其中 content 站用户页面里、background 跑在独立进程里,是「能跨域、能拿浏览器 API」的那个。

MV3 架构长什么样

MV3 三层 + DevTools Mermaid

关键约束:

  • Content script 与页面共享 DOM,但不共享 JS 上下文(不能直接调页面变量)
  • Background 是独立 Service Worker,可能被休眠,长任务要主动唤醒
  • DevTools 面板只在开发者打开时存在,不参与普通数据采集

manifest.json 骨架

MV3 最精简可跑的 manifest:

{
  "manifest_version": 3,
  "name": "Data Collector",
  "version": "0.1.0",
  "description": "抓 DOM + 网络,批量上传到自有后端",
  "permissions": ["storage", "tabs"],
  "host_permissions": [
    "https://target.example.com/*"
  ],
  "background": {
    "service_worker": "background.ts",
    "type": "module"
  },
  "content_scripts": [{
    "matches": ["https://target.example.com/*"],
    "js": ["content.ts"],
    "run_at": "document_idle"
  }],
  "action": {
    "default_popup": "popup.html",
    "default_icon": "icon-128.png"
  },
  "minimum_chrome_version": "116"
}
⚠️
host_permissions 是敏感字段

只声明真正需要的站点。审核商店时过宽的 host_permissions 是最常见的驳回原因,也容易让用户对你的扩展产生不信任。

一、Content Script 抓 DOM

content script 注入到目标页面,能访问 DOM 但不能访问页面的 JS 变量(包括 window.appvue 实例等)。

单次抓取

function snapshot() {
  const items = [...document.querySelectorAll('.item-card')].map(el => ({
    id:    el.getAttribute('data-id'),
    title: el.querySelector('.title')?.textContent?.trim(),
    price: el.querySelector('.price')?.textContent,
    href:  (el.querySelector('a') as HTMLAnchorElement)?.href,
  }));
  return items;
}

监听 DOM 变化(列表翻页 / 无限滚动)

const seen = new Set<string>();

const mo = new MutationObserver((mutations) => {
  for (const m of mutations) {
    m.addedNodes.forEach((node) => {
      if (!(node instanceof HTMLElement)) return;
      const cards = node.matches('.item-card')
        ? [node]
        : [...node.querySelectorAll('.item-card')];
      for (const card of cards) {
        const id = card.getAttribute('data-id');
        if (id && !seen.has(id)) {
          seen.add(id);
          chrome.runtime.sendMessage({ type: 'NEW_ITEM', payload: extract(card) });
        }
      }
    });
  }
});

mo.observe(document.body, { childList: true, subtree: true });

Shadow DOM 与 iframe

场景取法
Shadow DOMel.shadowRoot.querySelector(...),普通选择器穿透不到
同源 iframeiframe.contentDocument.querySelector(...)
跨域 iframe取不到,需要目标站自己配合或在 iframe 内再注入一个 content script
动态插入的 iframeMutationObserver 监听后注入
🚫
硬性规则

不要采集输入框的 value、密码字段、cookie。一旦你的扩展把这些数据发出去,无论协议多安全,用户的信任都已经丢了

二、录网络拿到 HAR

content script 看不到页面的 fetch / XMLHttpRequest,要拿网络层数据,必须从 background 一侧动手。

方案 A:chrome.webRequest(轻、但拿不到 body)

chrome.webRequest.onBeforeRequest.addListener(
  (details) => {
    if (details.method !== 'GET') return;
    if (details.tabId < 0) return;
    chrome.tabs.sendMessage(details.tabId, {
      type: 'REQ_CAPTURED',
      url: details.url,
      method: details.method,
      ts: details.timeStamp,
    });
  },
  { urls: ['https://target.example.com/api/*'] },
  []
);

局限:只能拿到 URL、Header、requestBody(仅 formData / raw 有限字段),拿不到响应体

方案 B:chrome.devtools.network.getHAR()(拿得到真 HAR)

只能在 DevTools 面板里调用:

chrome.devtools.network.onRequestFinished.addListener(async (req) => {
  const har = await chrome.devtools.network.getHAR();
  // har.log.entries 含 method / url / request / response / timings / size
  // 响应体在 entry.response.content.text
  saveToQueue(har.log.entries);
});

适用:你给目标用户装的是带「打开 DevTools 工具」的产品线。

方案 C:chrome.debugger 走 CDP(最全,但提示栏会有「正在调试浏览器」)

async function attach(tabId: number) {
  await chrome.debugger.attach({ tabId }, '1.3');
  await chrome.debugger.sendCommand({ tabId }, 'Network.enable');
  // 然后监听 chrome.debugger.onEvent 的 Network.responseReceived / LoadingFinished
  // 完整拿 method / url / requestHeaders / responseHeaders / responseBody
}
⚠️
提示栏风险

chrome.debugger 一旦 attach,浏览器顶部会显示「正在调试浏览器」黄条。生产环境只在用户主动点击「开始录制」时才挂上。

方案请求头请求体响应头响应体用户感知
webRequest部分
devtools.network仅开发者开 DevTools
debugger (CDP)黄色调试提示条

三、消息桥:三个脚本怎么对话

content 抓到数据后推给 background,最稳妥的写法:

// content.ts
const port = chrome.runtime.connect({ name: 'data-collector' });
port.postMessage({ type: 'BATCH', items: snapshot() });

// background.ts
chrome.runtime.onConnect.addListener((port) => {
  if (port.name !== 'data-collector') return;
  port.onMessage.addListener(async (msg) => {
    if (msg.type === 'BATCH') await enqueue(msg.items);
  });
});

sendMessage 强在两点:长连接(不上传完毕不断开)+ 能反向推送(background 可以主动告诉 content 暂停)。

💡
别忘心跳

Service Worker 长时间无活动会被回收。长跑任务每 25 秒发一次心跳(chrome.runtime.sendMessageport.postMessage({type:'PING'})),让 worker 保持唤醒。

四、数据上传到你的服务器

鉴权:别把 API Key 直接塞进扩展

把长 API Key 写进源码 = 公开密钥。正确做法:

// 用户首次打开 popup 时,引导登录,后端签发短期 JWT
const { jwt } = await fetch('https://api.example.com/auth/exchange', {
  method: 'POST',
  body: JSON.stringify({ install_id: await getOrCreateInstallId() }),
}).then(r => r.json());
await chrome.storage.session.set({ jwt });

// 上传时带上
await fetch('https://api.example.com/extension/ingest', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${jwt}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload),
});

chrome.storage.session 是会话级(关浏览器即清),适合放短期凭证。

CORS 与 Preflight

浏览器扩展的 fetch 不受第三方 CORS 限制——但受目标服务器 CORS 配置限制。后端要允许:

Access-Control-Allow-Origin: https://api.example.com  (或装扩展的源)
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
⚠️
别用 wildcard origin

后端允许 * + 凭证头是浏览器禁止的组合。生产里写具体的扩展 origin,或者用 CORS 白名单。

离线缓存 + 重试

网络随时断,不能丢数据。用 IndexedDB 做本地队列:

// 入队(content / background 都能用)
async function enqueue(items: any[]) {
  const db = await openIDB();
  const tx = db.transaction('queue', 'readwrite');
  tx.objectStore('queue').put({ id: crypto.randomUUID(), items, ts: Date.now() });
  await tx.done;
}

// 定时上传(background 的 chrome.alarms)
chrome.alarms.create('flush', { periodInMinutes: 1 });
chrome.alarms.onAlarm.addListener(flush);

async function flush() {
  const db = await openIDB();
  const all = await db.getAll('queue');
  if (!all.length) return;
  try {
    await fetch('/extension/ingest', { method: 'POST', body: JSON.stringify(all) });
    // 成功才删
    const tx = db.transaction('queue', 'readwrite');
    for (const r of all) tx.objectStore('queue').delete(r.id);
    await tx.done;
  } catch (e) {
    // 留到下次再试
  }
}

批大小与限流

维度建议
单批条数50–200 条
单批字节≤ 1 MB
上传频率1 分钟一次(chrome.alarms
失败退避1 分钟 → 5 分钟 → 30 分钟,封顶 30 分钟

五、后端最小骨架(FastAPI)

给数据一个落地的地方:

from fastapi import FastAPI, Depends, Header
from pydantic import BaseModel
from datetime import datetime

app = FastAPI()

class Item(BaseModel):
    id: str
    title: str | None = None
    price: str | None = None
    href: str | None = None

class Batch(BaseModel):
    items: list[Item]
    captured_at: datetime

@app.post("/extension/ingest")
async def ingest(batch: Batch, jwt: str = Header(...)):
    # 1. 验 JWT
    user = verify_jwt(jwt)
    # 2. 字段裁剪(去掉 None、限制长度)
    cleaned = [
        {**i.model_dump(exclude_none=True), "user_id": user.id}
        for i in batch.items if i.id
    ]
    # 3. 写库(这里示例用 SQLite + 参数化 SQL,防注入)
    async with db.transaction() as tx:
        await tx.execute_many(
            "INSERT INTO items (id, user_id, title, price, href, captured_at) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            [(c.get("id"), c["user_id"], c.get("title"), c.get("price"),
              c.get("href"), batch.captured_at) for c in cleaned],
        )
    return {"ingested": len(cleaned)}
💡
写库之前必做的事

字段裁剪 + 长度限制 + 参数化 SQL。扩展直接传过来的数据 = 不可信输入,和处理公网表单没有任何区别。

安全合规边界

  • 最小权限原则host_permissions 只声明目标站,permissions 只声明 storage / alarms / tabs 等真用到的
  • 不抓敏感字段:密码、token、身份证号、手机号(除非用户明确要求且告知用途)
  • 数据驻留:把数据送到用户自己控制的服务器;不自建云端中转
  • 可卸载即停:扩展被卸载后,后端鉴权应能感知并停止授权(短期 JWT 自然过期即可)
  • 首次启动告知:用 onboarding 页告诉用户采集了什么、发到哪里、能不能关

常见坑

host_permissions 没声明导致 fetch 失败
扩展里 fetch 跨域请求仍然受普通 CORS 限制,不靠 host_permissions 解决 CORS。host_permissions 解决的是「能不能拦截 webRequest」和「能不能注入 content script」。
Service Worker 跑长任务被杀
MV3 的 background 不能跑超过 5 分钟的活动。耗时操作要么拆短、要么放进 chrome.alarms 唤醒、要么用 OffscreenCanvas/Worker 隔离。
content script 里 import 浏览器 API 报错
chrome.* API 只在 background / popup / devtools 页面里能用,content script 里只能用有限子集(chrome.runtime.sendMessage 等消息类)。要拿 storage / alarms 就把数据 postMessage 给 background。
IndexedDB 写完忘了 await tx.done
tx.done 是事务结束的 Promise,不等它就继续下一行会导致「数据看起来写了其实没写」。永远 await tx.done
上传逻辑里没处理 429 / 5xx
批量场景一定遇到限流。看到 429 退避重试,5xx 走指数退避,不要无限重试把服务器打挂。

速查表

场景用什么
抓 DOM 一次性快照document.querySelectorAll + Array.from
抓 DOM 持续变化MutationObserver(subtree: true)
抓 Shadow DOMel.shadowRoot.querySelector
抓网络只看 URL / Headerchrome.webRequest
抓完整 HARchrome.devtools.network.getHAR()chrome.debugger
三脚本通信(短)chrome.runtime.sendMessage
三脚本通信(长)chrome.runtime.connect (Long-lived port)
离线队列IndexedDB + chrome.alarms 定时上传
鉴权凭证短期 JWT 存 chrome.storage.session
后端字段安全裁剪 + 长度限制 + 参数化 SQL
能复用油猴就别上扩展;非要上扩展,就把鉴权和离线缓存做到位——前者决定能不能用,后者决定能不能信。

目录

图表