v1tz@blog~$cat ./posts/2026-07-02-scraping-browser-puppeteer-stealth-electron.md

爬虫反检测PuppeteerElectron

Scraping Browser 自建方案(Puppeteer + Stealth + Electron)

category=爬虫/kind=笔记published=2026-07-02updated=2026-08-05

自建反检测浏览器的三个维度:指纹、代理、真人行为;为什么 2023 年的 stealth 插件已经拦不住 Cloudflare 和 DataDome;完整的 Puppeteer 核心代码、Electron 封装与打包踩坑。

自己做一个类似 Bright Data Scraping Browser 的反检测浏览器,打包成桌面软件。 Bright Data 这类产品的底层同样是开源工具改造的,路子是通的——难的不是搭起来,是持续维护。

先明确一件事

「比普通 Playwright 更好用」不是靠写个自动化脚本,而是三个维度同时做: ① 浏览器指纹 ② 代理 IP ③ 真人行为。缺一个,另外两个做得再好也会被封。


一、三个维度

① 修改浏览器底层指纹(最核心)

原生 Playwright / Selenium 启动的 Chrome 自带一堆特征,最典型的是 navigator.webdriver === true, Cloudflare 一测就出来。

② 代理 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 和利用规约。 商品价格这类公开信息通常问题不大,但请求频率要控制在不影响对方服务的水平—— 这既是合规问题,也是实用问题:频率一高,什么伪装都白搭。

 
[0] v1tz@blog · ~try: 'help'···--:--:--