浏览器扩展:抓 DOM、录 HAR、把数据写进服务器
什么时候需要自己写插件
能复用工具就别造轮子,但下面三种场景通常只能自己上:
- 目标站频繁改 DOM,油猴脚本越堆越乱,需要工程化
- 需要精确网络层数据(请求体、响应体、时序),DevTools 截图不够
- 要把采集结果写进自己系统的数据库,需要稳定的上传通道和重试
扩展 = 三个独立 JS 上下文(popup / content / background),靠消息桥对话。其中 content 站用户页面里、background 跑在独立进程里,是「能跨域、能拿浏览器 API」的那个。
MV3 架构长什么样
关键约束:
- 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 是最常见的驳回原因,也容易让用户对你的扩展产生不信任。
一、Content Script 抓 DOM
content script 注入到目标页面,能访问 DOM 但不能访问页面的 JS 变量(包括 window.app、vue 实例等)。
单次抓取
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 DOM | 用 el.shadowRoot.querySelector(...),普通选择器穿透不到 |
| 同源 iframe | iframe.contentDocument.querySelector(...) |
| 跨域 iframe | 取不到,需要目标站自己配合或在 iframe 内再注入一个 content script |
| 动态插入的 iframe | MutationObserver 监听后注入 |
不要采集输入框的 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.sendMessage 或 port.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
后端允许 * + 凭证头是浏览器禁止的组合。生产里写具体的扩展 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 页告诉用户采集了什么、发到哪里、能不能关
常见坑
fetch 跨域请求仍然受普通 CORS 限制,不靠 host_permissions 解决 CORS。host_permissions 解决的是「能不能拦截 webRequest」和「能不能注入 content script」。chrome.alarms 唤醒、要么用 OffscreenCanvas/Worker 隔离。chrome.* API 只在 background / popup / devtools 页面里能用,content script 里只能用有限子集(chrome.runtime.sendMessage 等消息类)。要拿 storage / alarms 就把数据 postMessage 给 background。tx.done 是事务结束的 Promise,不等它就继续下一行会导致「数据看起来写了其实没写」。永远 await tx.done。速查表
| 场景 | 用什么 |
|---|---|
| 抓 DOM 一次性快照 | document.querySelectorAll + Array.from |
| 抓 DOM 持续变化 | MutationObserver(subtree: true) |
| 抓 Shadow DOM | el.shadowRoot.querySelector |
| 抓网络只看 URL / Header | chrome.webRequest |
| 抓完整 HAR | chrome.devtools.network.getHAR() 或 chrome.debugger |
| 三脚本通信(短) | chrome.runtime.sendMessage |
| 三脚本通信(长) | chrome.runtime.connect (Long-lived port) |
| 离线队列 | IndexedDB + chrome.alarms 定时上传 |
| 鉴权凭证 | 短期 JWT 存 chrome.storage.session |
| 后端字段安全 | 裁剪 + 长度限制 + 参数化 SQL |
能复用油猴就别上扩展;非要上扩展,就把鉴权和离线缓存做到位——前者决定能不能用,后者决定能不能信。