爬虫反检测PuppeteerElectron
Scraping Browser 自建方案(Puppeteer + Stealth + Electron)
自建反检测浏览器的三个维度:指纹、代理、真人行为;为什么 2023 年的 stealth 插件已经拦不住 Cloudflare 和 DataDome;完整的 Puppeteer 核心代码、Electron 封装与打包踩坑。
自己做一个类似 Bright Data Scraping Browser 的反检测浏览器,打包成桌面软件。 Bright Data 这类产品的底层同样是开源工具改造的,路子是通的——难的不是搭起来,是持续维护。
先明确一件事
「比普通 Playwright 更好用」不是靠写个自动化脚本,而是三个维度同时做: ① 浏览器指纹 ② 代理 IP ③ 真人行为。缺一个,另外两个做得再好也会被封。
一、三个维度
① 修改浏览器底层指纹(最核心)
原生 Playwright / Selenium 启动的 Chrome 自带一堆特征,最典型的是 navigator.webdriver === true,
Cloudflare 一测就出来。
- 不要用原生 Chromium
- Node.js → puppeteer-extra-plugin-stealth
- Python → undetected-chromedriver
- 这类库会抹掉 WebGL、Canvas、字体列表、音频指纹等 100 多个暴露「机器人」的特征
② 代理 IP 池
同一个 IP 一分钟打几百次请求,指纹做得再完美也会被封。
- 动态住宅代理(Dynamic Residential Proxies)——数据中心 IP 段基本都在黑名单里
- 轮换机制:每开一个新标签页、或每爬 10 页换一次 IP
③ 真人交互行为
现代反爬会监控鼠标轨迹和点击节奏。
- 随机延迟:绝对不要
sleep(2)这种固定值,用正态分布的随机时间 - 曲线轨迹:用 ghost-cursor,鼠标走贝塞尔曲线而不是直线瞬移
二、⚠️ 2026 年的现实:stealth 插件已经不够了
这一节是本文最重要的部分
上面那套(stealth 插件)是 2021–2023 年的标准答案。现在它拦不住主流商业反爬了。
核实时点:2026-08。 这个领域变化极快,用之前请自行复核各项目的最新状态。
问题 1:stealth 插件本身停更了
puppeteer-extra-plugin-stealth 最后一个正式版是 2.11.2(2023-04),此后基本无维护。
Cloudflare Turnstile、DataDome、Akamai 这三家都已针对它做了专门识别。
问题 2:Runtime.enable 的 CDP 泄漏
这是近两年最要命的一个检测点,stealth 插件完全不处理。
Puppeteer / Playwright 通过 CDP(Chrome DevTools Protocol)控制浏览器,
默认会调用 Runtime.enable 来拿到 JS 执行上下文。这个调用会在页面侧留下可观测的痕迹——
反爬脚本能据此直接判定「这是被 CDP 控制的浏览器」,跟你把 navigator.webdriver 藏得多好完全无关。
修这个问题需要改 Puppeteer 与 CDP 的交互方式,属于框架层改造:
- rebrowser-patches — 专门修这个泄漏,提供打过补丁的
rebrowser-puppeteer/rebrowser-playwright - patchright — Playwright 的同类补丁版
问题 3:JS 层 hook 本身就是特征
这是个绕不过去的根本矛盾:
你改写 WebGLRenderingContext.prototype.getParameter 来伪造显卡型号
↓
反爬调用 getParameter.toString()
↓
返回的不是 "function getParameter() { [native code] }"
↓
暴露
Function.prototype.toString 检测、原型链检测、Object.getOwnPropertyDescriptor 检测——
只要伪装发生在 JS 层,就存在被反查的余地。可以用 Proxy 或改写 toString 来对抗,
但 Proxy 本身也有特征,这是一场必输的军备竞赛。
真正干净的做法是在浏览器编译层改指纹,不在 JS 层 hook。
- camoufox — 基于 Firefox,指纹伪造做在 C++ 层,页面侧无 JS 痕迹可查
- nodriver — undetected-chromedriver 作者的后继项目(UC 本身已停更),不走 CDP 常规路径
选型建议
| 场景 | 选择 |
|---|---|
| 学习原理、目标站点防护弱 | puppeteer-extra + stealth(本文主线) |
| 目标站点上了 Cloudflare / DataDome | rebrowser-puppeteer 或 patchright |
| 防护很强、且能接受换 Firefox 内核 | camoufox |
| 只是要数据、不想打这场仗 | 直接买 Bright Data / ZenRows 的 API |
最后一行是认真的
自建的成本不在开发,在每周跟着对方更新改代码。 如果爬取量不大,商业 API 的钱大概率比你的时间便宜。
商业方案长什么样,可以看 BrowserAct Skills 拆解:一个 97 技能的 Agent 浏览器库 —— 那是 BrowserAct 把这套东西产品化之后的形态,含一条自建很难做的「人工接管」层。
三、技术栈
「客户端 + 本地后台」的桌面应用架构:
| 层 | 选型 | 说明 |
|---|---|---|
| 前端 UI | Electron | HTML/CSS/JS 写桌面软件,原生支持 Node.js |
| 自动化核心 | puppeteer + puppeteer-extra + puppeteer-extra-plugin-stealth |
或换成第二节的替代方案 |
| 真人轨迹 | ghost-cursor |
贝塞尔曲线鼠标 |
| 数据导出 | exceljs |
导 .xlsx |
| 本地暂存 | nedb / better-sqlite3 |
断点续爬 |
| 打包 | electron-builder |
出 .dmg / .exe |
npm install electron puppeteer puppeteer-extra puppeteer-extra-plugin-stealth ghost-cursor exceljs
npm install -D electron-builder
四、核心代码:scraper.js
反爬核心模块。这是原笔记里一直缺的部分。
4.1 启动与指纹
// scraper.js —— 反检测浏览器核心
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const { createCursor } = require('ghost-cursor');
puppeteer.use(StealthPlugin());
/** 正态分布随机延迟。固定 sleep 是最容易被抓的特征之一 */
function gaussianDelay(mean = 3000, stdev = 900, min = 1500, max = 6500) {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
return Math.min(max, Math.max(min, Math.round(mean + z * stdev)));
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
/** 每个 session 生成一套固定指纹 —— 见下方「为什么必须固定」 */
function makeFingerprint() {
return {
ua: pick([
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
]),
webglVendor: pick(['Intel Inc.', 'Apple Inc.']),
webglRenderer: pick(['Intel Iris OpenGL Engine', 'Apple M2', 'ANGLE (Intel, Intel(R) UHD Graphics 630)']),
cores: pick([4, 8, 12, 16]),
memory: pick([8, 16, 32]),
languages: ['ja-JP', 'ja', 'en-US', 'en'],
timezone: 'Asia/Tokyo',
viewport: pick([{ width: 1440, height: 900 }, { width: 1512, height: 982 }, { width: 1920, height: 1080 }]),
canvasSeed: Math.floor(Math.random() * 7) + 1, // 1–7 个色阶的偏移
};
}
async function launchBrowser({ proxy, headless = false, chromePath } = {}) {
const args = [
'--disable-blink-features=AutomationControlled',
'--disable-dev-shm-usage',
'--lang=ja-JP',
'--window-size=1440,900',
];
if (proxy?.server) args.push(`--proxy-server=${proxy.server}`);
return puppeteer.launch({
headless, // 建议 false,见「六、踩坑」
executablePath: chromePath, // 指向系统 Chrome → 打包体积从 ~300MB 降到几十 MB
args,
defaultViewport: null,
ignoreDefaultArgs: ['--enable-automation'], // 去掉「Chrome 正受自动化软件控制」横幅
});
}
4.2 页面加固
async function hardenPage(page, fp, proxy) {
if (proxy?.username) {
await page.authenticate({ username: proxy.username, password: proxy.password });
}
await page.setUserAgent(fp.ua);
await page.setViewport(fp.viewport);
await page.emulateTimezone(fp.timezone);
await page.setExtraHTTPHeaders({ 'Accept-Language': 'ja-JP,ja;q=0.9,en;q=0.8' });
// 必须在页面 JS 执行之前注入
await page.evaluateOnNewDocument((fp) => {
// —— WebGL 厂商 / 型号
const origGetParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function (p) {
if (p === 37445) return fp.webglVendor; // UNMASKED_VENDOR_WEBGL
if (p === 37446) return fp.webglRenderer; // UNMASKED_RENDERER_WEBGL
return origGetParameter.apply(this, [p]);
};
// —— Canvas 稀疏噪声。偏移量取自 fp.canvasSeed(固定值),不是每次随机
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function (...args) {
const ctx = this.getContext('2d');
if (ctx && this.width && this.height) {
const img = ctx.getImageData(0, 0, this.width, this.height);
for (let i = 0; i < img.data.length; i += 997) { // 稀疏采样,避免大图卡顿
img.data[i] = (img.data[i] + fp.canvasSeed) % 256;
}
ctx.putImageData(img, 0, 0);
}
return origToDataURL.apply(this, args);
};
// —— 硬件特征
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => fp.cores });
Object.defineProperty(navigator, 'deviceMemory', { get: () => fp.memory });
Object.defineProperty(navigator, 'languages', { get: () => fp.languages });
}, fp);
}
Canvas 噪声必须每个 session 固定****
很多教程写成「每次调用
toDataURL加随机噪声」——这是错的,反而更容易被抓。反爬只要在同一个页面里连着调两次
toDataURL()比对结果,正常浏览器必然完全一致。 如果你每次返回不同的哈希,等于主动举手说「我在伪造指纹」。正确做法:一个 session 生成一个固定偏移量(上面的
fp.canvasSeed), 整个 session 内保持不变,换 session 时才换。目的是「和别人不一样」,不是「每次都不一样」。
这段 hook 会被
toString()检测出来上面改写的
getParameter/toDataURL,一旦被调用.toString(), 返回的是我们的 JS 源码而不是[native code],直接暴露。可以再包一层
toString伪装,但 Proxy 本身也有特征——治标不治本。 这正是第二节说「用 camoufox 那种编译层方案」的原因。
4.3 采集循环
async function scrapeCategory(page, url, { maxPages = 10, onProgress } = {}) {
const cursor = createCursor(page); // 贝塞尔曲线鼠标
const rows = [];
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
for (let p = 1; p <= maxPages; p++) {
await page.waitForSelector('.product-item', { timeout: 30000 });
// 先滚动到底部触发懒加载,再取数据
await page.evaluate(async () => {
for (let y = 0; y < document.body.scrollHeight; y += 400) {
window.scrollTo(0, y);
await new Promise((r) => setTimeout(r, 80 + Math.random() * 120));
}
});
const pageRows = await page.evaluate(() =>
[...document.querySelectorAll('.product-item')].map((el) => ({
title: el.querySelector('.product-title')?.textContent?.trim() ?? '',
price: el.querySelector('.price')?.textContent?.trim() ?? '',
stock: el.querySelector('.stock-status')?.textContent?.trim() ?? '',
image: el.querySelector('img')?.src ?? '',
url: el.querySelector('a')?.href ?? '',
})),
);
rows.push(...pageRows);
onProgress?.({ page: p, total: rows.length });
const next = await page.$('a.pagination-next:not(.disabled)');
if (!next) break;
await cursor.click(next); // 曲线移动过去再点,而不是 next.click()
await sleep(gaussianDelay()); // 正态分布随机停顿
}
return rows;
}
module.exports = { launchBrowser, hardenPage, scrapeCategory, makeFingerprint, gaussianDelay };
选择器要按实际站点改
.product-item/.price这些是占位。实际用之前打开目标页面的 DevTools 确认。 选择器是最脆的一环——站点改版就得跟着改,比反爬对抗更常触发维护。
4.4 导出 Excel
const ExcelJS = require('exceljs');
async function exportXlsx(rows, filePath) {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet('商品一覧');
ws.columns = [
{ header: '商品名', key: 'title', width: 50 },
{ header: '価格', key: 'price', width: 14 },
{ header: '在庫', key: 'stock', width: 12 },
{ header: 'URL', key: 'url', width: 40 },
{ header: '画像', key: 'image', width: 40 },
];
ws.addRows(rows);
ws.getRow(1).font = { bold: true };
await wb.xlsx.writeFile(filePath);
}
五、Electron 封装
5.1 主进程 main.js
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');
const { launchBrowser, hardenPage, scrapeCategory, makeFingerprint } = require('./scraper');
const { exportXlsx } = require('./exporter');
let win;
function createWindow() {
win = new BrowserWindow({
width: 980, height: 700,
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
});
win.loadFile('index.html');
}
app.whenReady().then(createWindow);
ipcMain.handle('scrape:start', async (_evt, { url, proxy, headless }) => {
const fp = makeFingerprint();
const browser = await launchBrowser({ proxy, headless, chromePath: process.env.CHROME_PATH });
try {
const page = await browser.newPage();
await hardenPage(page, fp, proxy);
const rows = await scrapeCategory(page, url, {
onProgress: (s) => win.webContents.send('scrape:progress', s), // 实时回传进度
});
const { filePath, canceled } = await dialog.showSaveDialog(win, {
defaultPath: 'products.xlsx',
filters: [{ name: 'Excel', extensions: ['xlsx'] }],
});
if (!canceled && filePath) await exportXlsx(rows, filePath);
return { ok: true, count: rows.length, filePath };
} catch (e) {
return { ok: false, error: e.message };
} finally {
await browser.close();
}
});
5.2 preload.js(contextIsolation 下的桥)
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
start: (opts) => ipcRenderer.invoke('scrape:start', opts),
onProgress: (cb) => ipcRenderer.on('scrape:progress', (_e, s) => cb(s)),
});
5.3 渲染进程
document.getElementById('go').onclick = async () => {
const url = document.getElementById('url').value;
window.api.onProgress(({ page, total }) => {
document.getElementById('log').textContent = `第 ${page} 页完成,累计 ${total} 条`;
});
const r = await window.api.start({ url, headless: false });
document.getElementById('log').textContent = r.ok
? `完成:${r.count} 条 → ${r.filePath}`
: `失败:${r.error}`;
};
不要用
nodeIntegration: true老教程常写
nodeIntegration: true+contextIsolation: false图省事。 这等于把 Node 的全部能力暴露给渲染进程,页面里任何一段第三方 JS 都能读你本地文件。 用preload+contextBridge只多写十行。
六、打包与踩坑
打包配置
package.json:
{
"build": {
"appId": "com.yourname.scraper",
"mac": { "target": "dmg", "category": "public.app-category.utilities" },
"win": { "target": "nsis" },
"files": ["main.js", "preload.js", "scraper.js", "exporter.js", "index.html"],
"extraResources": []
}
}
npx electron-builder --mac --win
踩坑指南
| 坑 | 说明 | 解法 |
|---|---|---|
| Chromium 体积 | Puppeteer 默认下载 200–300MB 的 Chromium,打进包里就是几百 MB | 设 PUPPETEER_SKIP_DOWNLOAD=1,用 executablePath 指向用户机器上的 Chrome。体积降到几十 MB |
| Headless 极易被识别 | headless: "new" 仍有大量可检测差异 |
初期一律 headless: false。有头模式封率显著更低,还能让用户手动过验证码 |
| macOS 公证 | 未签名的 .dmg 打不开,报「已损坏」 |
需要 Apple Developer 账号做 codesign + notarize;自用可让用户右键打开绕过 |
| 代理认证 | --proxy-server 不带账密 |
用 page.authenticate(),每个 page 都要调 |
| 选择器易碎 | 站点改版就全挂 | 选择器集中放一个 config 文件,别散在代码里 |
| 打包后找不到 Chrome | 开发机有、用户机没有 | 启动时探测常见路径,找不到就弹窗让用户手动指定 |
macOS 上 Chrome 的默认路径:
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome
七、成本与维护的现实
自建方案真正的代价不在开发,在这两条:
1. 逆向工程是持续投入。 Akamai、Cloudflare、DataDome 每周都在更新。 今天调通的代码,下周对方升级后可能就被识别了。这是长期人力成本,不是一次性开发成本。
2. IP 资源必须付费。 浏览器你能自己写,代码你能自己改, 但高质量海外住宅 IP 无法自己生成,只能向供应商买流量。这项开销跑不掉。
合规
抓取前看一眼目标站的
robots.txt和利用规约。 商品价格这类公开信息通常问题不大,但请求频率要控制在不影响对方服务的水平—— 这既是合规问题,也是实用问题:频率一高,什么伪装都白搭。