做前端或者自动化相关开发的同学,基本都听过Puppeteer使用示例详解这类资料。但很多教程贴一上来就丢一段 launch 代码,跑通就完事,真正遇到坑反而没人讲。作为一个常年拿 Puppeteer 做页面自动化、批量截图和线上巡检的人,我打算把平时用得最多的几组示例跟背后的原理一起拆开讲讲,代码可以直接抄,坑也先替你踩一遍。Puppeteer 是 Node.js 生态里最经典的浏览器自动化库,通过 DevTools 协议驱动无头 Chrome/Chromium,能模拟点击、填表单、截图、导出 PDF、拦截请求、跑性能分析。适合做自动化测试、页面监控、批量生成报告、定时抓取公开页面数据这类场景。不管你是测试工程师、前端开发还是运维,只要需要和网页打交道,这套工具值得花半小时掌握。1. 先搞清楚 Puppeteer 到底解决什么问题1.1 一句话讲明白它的定位很多新手第一次看到 Puppeteer 的代码会困惑:这不就是操作浏览器吗?我用按键精灵不行吗?区别在于,Puppeteer 不是靠模拟鼠标键盘那种“物理外挂”方式,而是直接通过 Chrome 的 DevTools 协议和浏览器内核通信。这意味着它能做到很多人类操作做不到的事,比如拦截网络请求、直接修改页面 JavaScript 上下文、读取浏览器性能指标。换个角度理解:它是把浏览器当成一台可以编程控制的设备。你不需要真的打开一个窗口、用眼睛去看页面长什么样,只要给它一个网址,它就能在后台帮你去访问、解析、操作、收集数据。传统手工操作浏览器需要几十分钟的事情,脚本批量跑几分钟就能完成。我自己的项目里,最常见的使用场景是这么几类:页面自动化测试:特别是端到端测试,模拟用户走完注册、登录、下单流程,比纯单元测试更有说服力批量截图:定时给公司官网首页截图,做视觉回归测试;或者给文档站点生成预览图生成 PDF:把后台报表页面导出成 PDF,按周推送邮件,省去手动打印的环节公开页面数据采集:对无登录墙的公开信息做定时采集,比如商品价格对比、行业排行榜预渲染:把单页应用在服务端跑一遍,输出完整的 HTML,解决部分 SEO 需求的兜底方案1.2 它和 Selenium、Playwright 的差别在哪选型的时候大家总喜欢对比这几个工具,我直接说结论:Puppeteer 优势在于和 Chrome 结合最紧密、依赖最轻,因为它是 Chrome 团队官方维护的。Selenium 支持多浏览器,但配置更重,要装驱动,还要维护浏览器版本匹配;Playwright 是后起之秀,功能很强,支持多浏览器,但如果你只需要 Chrome/Chromium,Puppeteer 完全够用,API 设计也更符合前端直觉。选型建议:大厂多浏览器兼容测试选 Playwright,传统企业测试平台选 Selenium,个人项目或数据自动化直接上 Puppeteer。一个项目里如果只瞄准 Chromium 内核,Puppeteer 的安装成本和体积都是最小的。2. 环境准备与安装实操2.1 安装以及下载 Chromium 的坑Puppeteer 是一个 Node.js 包,安装没什么特殊要求,Node 版本建议 14 以上。我的建议是遇到问题先用 LTS 版本,避免因为 Node 过新导致原生模块编译失败。新建项目目录后执行:npm init -y npm install puppeteer默认情况下,这条命令会把兼容版本的 Chromium 一起下载到本地。这一步通常是最容易出现问题的,因为 Chromium 包体积不小,而且下载地址在国外,网络环境不好时会非常慢,甚至直接卡住。如果你遇到下载超时,有两个思路。第一个思路是用镜像源下载,设置环境变量指向国内镜像地址,再重新安装:PUPPETEER_DOWNLOAD_BASE_URL=https://npmmirror.com/mirrors/chrome-for-testing npm install第二个思路是放弃自动下载,使用 puppeteer-core,只装 API 不装浏览器,然后指向系统里已有的 Chrome。这也是我推荐的方式,省空间还能避免反复踩下载的坑:npm install puppeteer-core注意:puppeteer-core 不会自动下载 Chromium。launch 时必须手动传 executablePath 指定浏览器路径,否则会报“No executable found”的错。2.2 连接本地 Chrome 的实战配置安装完之后先做一次最小启动验证。下面这段代码确认环境是否正常:const puppeteer = require('puppeteer-core'); (async () => { const browser = await puppeteer.launch({ headless: 'new', executablePath: '/usr/bin/chromium' }); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })();headless 这个参数值得单独说一下。老版本里写headless: true就行,新版本 Chrome 推出了新的无头模式,写headless: 'new'可以获得和旧模式更接近的真实渲染效果。实测下来,新无头模式在字体渲染、WebGL 支持方面都比旧模式更接近有头浏览器,建议默认使用。如果你是在服务器上以 root 用户运行,经常会遇到沙箱报错。这时候可以加两个 Chrome 启动参数:const browser = await puppeteer.launch({ headless: 'new', executablePath: '/usr/bin/chromium', args: ['--no-sandbox', '--disable-setuid-sandbox'] });我在多台服务器上跑过,这条基本能解决启动即崩溃的问题。但要注意,关闭沙箱有安全隐患,只建议在隔离的容器或专用服务器上这样做,不要在有其他服务的机器上随便关。3. 截图与 PDF 生成示例3.1 全屏截图和视口设置的正确姿势截图应该算 Puppeteer 最常用的功能了。最简单的截图代码三行就能完成:const page = await browser.newPage(); await page.goto('https://example.com', { waitUntil: 'networkidle2' }); await page.screenshot({ path: 'homepage.png', fullPage: true });fullPage: true表示截取整个页面高度,而不是只截可视区域。这个参数背后的逻辑是浏览器会临时调整视口高度到整个页面的滚动高度再截图,截完再恢复。所以如果页面里有用到固定定位的浮层,截长图时可能出现浮层重复出现的情况,这是 Chrome 渲染机制决定的,遇到的话把浮层隐藏后再截。视口设置的坑最多。移动端页面截图必须提前设置好宽度和高度,不然默认 800x600 的桌面视口会让移动端布局完全走样:await page.setViewport({ width: 375, height: 812, deviceScaleFactor: 2 });deviceScaleFactor: 2模拟的是 iPhone 的 2 倍像素密度,截出来的图清晰度更高,适合做设计稿对比。但要注意,这个值会直接影响截图输出文件的体积,大批量截图时谨慎使用。如果只想截页面里某一个区域,比如某个图表卡片、某段代码块,不要自己算坐标去裁剪,直接用元素选择器:const el = await page.$('.chart-card'); await el.screenshot({ path: 'chart.png' });用元素截图的好处是页面滚动后元素位置变了也能正确定位,比自己裁图片靠谱得多。3.2 生成 PDF 的参数细节把页面转 PDF 是另一个高频需求。Puppeteer 的 page.pdf 接口可以直接输出带排版的文件,不需要安装额外的打印组件:await page.goto('https://example.com/report', { waitUntil: 'networkidle2' }); await page.pdf({ path: 'report.pdf', format: 'A4', printBackground: true, preferCSSPageSize: true, margin: { top: '15mm', right: '10mm', bottom: '15mm', left: '10mm' } });两个重要细节:第一个,printBackground: true一定要加。如果不加,PDF 里所有背景色和 background-image 都会消失,页面瞬间变成白底黑字,视觉上跟丢了一半样式一样。我第一次生成报表 PDF 时忘了这个参数,结果图表区域一片空白,排查了半小时才发现是背景色被自动去掉了。第二个,preferCSSPageSize: true表示优先使用 CSS 里的 @page 规则设置的页面大小。如果前端页面已经按 A4 设计好了打印样式,加上这个参数能精确控制页面尺寸,反之则用 format 参数统一指定。还有一点要记住:page.pdf 在旧版无头模式(headless: true)下工作正常,但在某些非无头版本下会报错。如果你发现 pdf 调用失败,优先检查是不是 headless 配置不对。提示:截图和 PDF 都建议在 waitUntil: 'networkidle2' 之后再执行。networkidle2 表示 500ms 内网络请求数不超过 2 个,这时候页面资源基本加载完毕,比默认的 load 事件更可靠。4. 页面自动化交互示例4.1 等待策略:从 sleep 到 waitFor新手写交互代码最常犯的错误就是到处用await page.waitForTimeout(3000)这种死等。死等的问题是网络快的时候浪费时间,网络慢的时候又不够用,属于两头都吃亏。正确思路是“等条件,而不是等时间”。Puppeteer 提供了一套完整的等待方法:方法作用适用场景page.waitForSelector等某个选择器出现在 DOM 中最常用,等按钮、等列表加载page.waitForFunction等待页面内 JS 执行结果为真等数据渲染完成、等状态变化page.waitForNavigation等待跳转完成点击后触发页面跳转时用page.waitForRequest等待某个网络请求发出配合接口触发判断使用page.waitForResponse等待某个网络请求返回等接口数据返回后继续操作举个例子,页面上有个列表是异步加载的,如果用死等大概率会踩中加载时序问题。正确写法是这样:await page.click('.load-more-btn'); await page.waitForSelector('.item-card');点击按钮后,等待新卡片元素出现在 DOM 里,再继续下面的断言或采集。这样不管接口响应是 200ms 还是 2000ms,代码都能正确工作。最实用的组合是 waitForFunction 配 document.querySelector,处理单页应用里数据刷新的场景:await page.waitForFunction( (selector, text) => { const el = document.querySelector(selector); return el && el.textContent.includes(text); }, { timeout: 10000 }, '.status', '已完成' );等页面上的状态文本变成“已完成”再继续,比任何固定超时都准确。4.2 表单填写、点击与弹窗处理模拟用户提交表单是端到端测试的核心场景。基础操作包括输入文本、点击按钮。一段完整的登录表单自动化一般是这样的:await page.type('#username', 'test_user', { delay: 30 }); await page.type('#password', 'secret123', { delay: 30 }); await page.click('#login-btn'); await page.waitForNavigation({ waitUntil: 'networkidle2' });delay参数模拟真实用户逐字输入的速度,主要为了让页面事件监听器有足够时间响应。很多框架在 input 事件里做了防抖处理,如果 value 是一瞬间设进去的,可能触发不了后续逻辑,所以模拟真实输入反而比直接赋值更可靠。如果你要填的内容是纯数字验证码或者需要清空再输入,先要清掉输入框默认值:await page.$eval('#phone', (el) => { el.value = ''; }); await page.type('#phone', '13800138000', { delay: 10 });弹窗和对话框是经常被忽略的。页面里的 alert 如果不处理,脚本会一直卡住,因为无头模式下不会有人去点确定。要提前挂上事件监听:page.on('dialog', async (dialog) => { console.log('弹窗内容:', dialog.message()); await dialog.accept(); });不要小看这个监听,少了它脚本可能会静默挂起十分钟直到超时。我用一个演示项目测试过,加了 dialog 监听后整体稳定性提升非常明显。4.3 多标签页与 iframe 处理实际操作中经常要打开多个页面。比如巡检场景下,需要同时打开十几个页面做健康检查。Puppeteer 支持在一个浏览器实例里开多个 page:const pageA = await browser.newPage(); const pageB = await browser.newPage();每个 page 是独立的标签页,拥有独立的会话和存储。但注意,同一浏览器实例里的多个 page 共享同一个 userDataDir,也就是共享 Cookie 和本地存储。如果你需要隔离登录态,必须用browser.createIncognitoBrowserContext()创建隐身上下文:const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage();iframe 的处理是另一个麻烦。页面里有内嵌 iframe 时,:hover和普通的 selector 都定位不进去,必须切换到 frame 对象:const frame = await page.frames().find(f => f.url().includes('widget')); const button = await frame.$('.widget-submit'); await button.click();如果 iframe 加载较慢,可以先等 frame 出现:await page.waitForFrame('https://widget.example.com');我实测过很多页面,iframe 是自动化测试里最容易出问题的点,特别是跨域 iframe 会限制很多 DOM 操作。遇到跨域限制场景,尽量从主页面入手,或者用接口测试代替页面自动化。5. 请求拦截与脚本注入5.1 拦截请求,屏蔽图片和统计接口Puppeteer 有一个杀手级功能,就是能拦截页面发出的所有网络请求。这可以大幅提升页面加载速度和稳定性。最典型的场景是采集公开列表数据时,根本不需要图片资源,把图片请求全部丢弃,页面加载速度能快出好几倍:await page.setRequestInterception(true); page.on('request', (request) => { const type = request.resourceType(); if (['image', 'stylesheet', 'font'].includes(type)) { request.abort(); } else { request.continue(); } });同样的逻辑还能用来屏蔽统计类接口。很多站点会加载第三方数据上报脚本,这类请求既占带宽又无关紧要,可以直接忽略:page.on('request', (request) => { const url = request.url(); if (url.includes('analytics') || url.includes('tracking')) { request.abort(); } else { request.continue(); } });请求拦截还有一个实用场景是修改接口返回数据。配合request.respond能伪造接口响应,做前端异常场景的测试非常方便:page.on('request', (request) => { if (request.url().includes('/api/user')) { request.respond({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: '服务异常' }) }); } else { request.continue(); } });用这个方案可以模拟接口 500、超时、返回空数据等情况,不需要真的把后端服务弄挂。前端异常提示写得好不好,一张伪造响应就能验证出来。5.2 在页面里注入自定义脚本有时你需要直接在页面上下文里执行 JavaScript,读取或修改数据。page.evaluate就是干这个的:const data = await page.evaluate(() => { const rows = document.querySelectorAll('.list-item'); return Array.from(rows).map(row => { return { title: row.querySelector('.title')?.textContent?.trim(), price: row.querySelector('.price')?.textContent?.trim() }; }); }); console.log(data);evaluate 里写的是浏览器环境里的 JS,不是 Node 环境。所以不能调用 Node 的 require、module 之类的 API,这一点新手经常搞混。如果你要在页面里先注入一段通用的工具函数再执行采集逻辑,可以用page.addScriptTag:await page.addScriptTag({ url: 'https://cdn.example.com/helper.js' }); // 或者注入内联代码 await page.addScriptTag({ content: `window.__helper = { formatPrice: (p) => '¥' + Number(p).toFixed(2) }` });还有一种更进阶的注入手法,利用page.evaluateOnNewDocument在每次新页面创建时就注入代码。它会在页面任何脚本运行之前执行,因此可以用来改写全局变量,模拟特殊环境:await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); });这行代码把 navigator.webdriver 改成了 false,可以在一定程度上减少被页面反自动化脚本识别的概率。这类技巧属于双方博弈的范畴,我只建议在合法合规的测试场景里使用,不要拿去做突破访问限制的事情。5.3 并发采集与性能调优跑批量任务时,一个个顺序打开页面太慢了。我常用的提速方案是在同一个浏览器里控制并发数,合理开多个 page:async function processUrls(urls, concurrency = 5) { const browser = await puppeteer.launch({ headless: 'new' }); const results = []; const workers = Array.from({ length: concurrency }, async () => { while (urls.length > 0) { const url = urls.pop(); if (!url) break; const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); const data = await page.evaluate(() => document.title); results.push(data); } catch (err) { console.error('处理失败:', url, err.message); } finally { await page.close(); } } }); await Promise.all(workers); await browser.close(); return results; }这里的关键工程点是每次处理完都调用 page.close()。不关页面会导致浏览器内存持续上涨,批量跑上千个 URL 时内存直接被打满,这是我踩过最大的坑之一。并发数也不是越大越好。实践下来,单机场景并发 5 到 8 个页面的性价比最高。并发太大会导致 CPU 和内存争抢,页面加载反而变慢,还可能被目标站点限制访问。采集过程中加延时也是很有必要的。给每个页面加 200 到 800 毫秒的随机延迟,能降低对目标服务器的瞬时压力,也更接近真实用户行为。这个值不用太大,但完全不加的话,短时间大量请求会显得非常“机器化”。6. 常见问题与排查心得6.1 启动失败的几种典型场景这么多年用下来,启动失败排行前几的原因无非就这几个。沙箱问题表现最直接,报错里带有sandbox字样。解决方式就是前面提到过的加--no-sandbox参数。这个报错常见于 CentOS 7 的服务器和部分 Docker 镜像,原因通常是系统没有正确配置用户命名空间。executablePath报错指的是你给 puppeteer-core 配置的浏览器路径不存在。这个坑很隐蔽,因为很多 Linux 发行版的 Chromium 可执行文件不一定在/usr/bin/chromium,有的是/usr/bin/chromium-browser,还有 Chrome for Testing 的路径里带着版本号。解决方案是先which chromium或者find / -name '*chrome*' 2>/dev/null确认真实路径。还有一类是缺少依赖库。Chromium 在 Linux 上依赖很多动态链接库,尤其是libX11、libnss3、libatk之类的图形库。最典型的报错是:error while loading shared libraries: libnss3.so这类问题在裸的容器镜像里最常见,需要安装对应依赖包,比如 CentOS 执行yum install -y nss atk at-spi2-atk cups-libs libdrm libxkbcommon-x11。如果你用的是别人维护的 Docker 镜像,最好先确认镜像里带不带这些默认库。6.2 元素找不到与超时处理waitForSelector 超时是最常见的运行期报错。问题往往是选择器写错了,或者页面元素在鼠标点击之后才异步渲染。排查思路可以从这几个角度入手:先确认页面中是否真的存在目标元素,用page.$试一下用page.content()打印当前 HTML,看元素到底是什么时候出现的选择器是否太脆弱,比如依赖了动态生成的 class 名动态 class 是另一个大坑。很多现代前端框架(各种组件库、CSS-in-JS 方案)会生成哈希类名,你选中一次,下一次重新部署可能就变了。这个问题的解决思路不是每次去改选择器,而是优先用相对稳定的属性定位,比如>const el = await page.$('button:contains("确认")'); // 或者用 XPath const xpath = '//button[contains(text(), "确认")]'; const button = await page.$x(xpath);$X 返回的是数组,用的时候记得取第一个元素。如果某个元素时有时无,多半是接口偶发超时或前端重试机制造成的。稳妥做法是包一层重试逻辑:async function waitForSelectorWithRetry(selector, page, attempts = 3) { for (let i = 0; i < attempts; i++) { try { await page.waitForSelector(selector, { timeout: 8000 }); return; } catch (err) { if (i === attempts - 1) throw err; await page.reload({ waitUntil: 'domcontentloaded' }); } } }6.3 内存管理与异常退出长期运行的定时任务必须重视内存管理。这里说的不是内存泄漏,而是浏览器进程本身很吃资源。无头 Chromium 每个页面默认占用几十 MB 到几百 MB 内存,跑完不关闭,堆积起来再稳的服务也会 OOM。我自己的习惯是每次任务结束都显式调用 browser.close(),而不是只关页面。单纯page.close()不会释放浏览器进程占用的全部资源,反复 launch 和 close 也会留下僵尸进程,时间长了服务器负载会一直升高。排查僵尸进程可以执行:ps -ef | grep chrome如果发现大量残留进程,说明程序在异常退出时没有正确清理。这就要在代码里加上兜底逻辑:try { // 主要业务逻辑 } finally { await browser.close().catch(() => {}); }Node.js 默认只在uncaughtException时不会自动退出,但异步异常会导致任务中断。遇到这种问题,我建议用一个简单的守护方式:外层包一个Promise,加上 total timeout,超过 50 秒就强制退出:const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('总超时, 强制退出')), 50000); }); await Promise.race([runTask(), timeoutPromise]);用这套方案,一个批量巡检脚本在服务器上跑了几百轮没出现过卡死的情况。调试的时候还有一个小技巧:不要一上来就跑无头模式,先用headless: false加slowMo: 200打开浏览器,看真实页面操作过程。可以设置devtools: true甚至打开开发者工具看网络请求。一旦业务逻辑验证通过,再切回headless: 'new'投入生产运行。这个调试验证方式帮我定位了无数个诡异问题。收个尾,说点实在的最后再分享几点我的实际体会。Puppeteer 看起来是个简单的自动化工具,但项目里大量脚本不稳定的根源,往往不是 Puppeteer 本身,而是对页面加载时序的理解不够。你等待的元素到底什么时候出现,依赖的接口什么时候返回,页面跳转是原生路由还是整页刷新,这些细节决定了脚本的稳定性。我习惯是刚开始写脚本时多花几分钟,用page.on('console')和page.on('requestfailed')把页面的关键日志监听起来,一旦后续运行出问题,能看到运行现场的第一手信息。这比猜原因高效太多。这个工具目前还在持续进化,新版本不断调整无头模式内核和 API 行为。建议固定一个版本长期使用,不要每次升级都随手改代码。等老项目稳定运行了,再单独抽时间评估新版本迁移。版本锁定了,你的自动化脚本就不会因为一个依赖升级莫名其妙断掉。
做前端或者自动化相关开发的同学,基本都听过Puppeteer使用示例详解这类资料。但很多教程贴一上来就丢一段 launch 代码,跑通就完事,真正遇到坑反而没人讲。作为一个常年拿 Puppeteer 做页面自动化、批量截图和线上巡检的人,我打算把平时用得最多的几组示例跟背后的原理一起拆开讲讲,代码可以直接抄,坑也先替你踩一遍。Puppeteer 是 Node.js 生态里最经典的浏览器自动化库,通过 DevTools 协议驱动无头 Chrome/Chromium,能模拟点击、填表单、截图、导出 PDF、拦截请求、跑性能分析。适合做自动化测试、页面监控、批量生成报告、定时抓取公开页面数据这类场景。不管你是测试工程师、前端开发还是运维,只要需要和网页打交道,这套工具值得花半小时掌握。1. 先搞清楚 Puppeteer 到底解决什么问题1.1 一句话讲明白它的定位很多新手第一次看到 Puppeteer 的代码会困惑:这不就是操作浏览器吗?我用按键精灵不行吗?区别在于,Puppeteer 不是靠模拟鼠标键盘那种“物理外挂”方式,而是直接通过 Chrome 的 DevTools 协议和浏览器内核通信。这意味着它能做到很多人类操作做不到的事,比如拦截网络请求、直接修改页面 JavaScript 上下文、读取浏览器性能指标。换个角度理解:它是把浏览器当成一台可以编程控制的设备。你不需要真的打开一个窗口、用眼睛去看页面长什么样,只要给它一个网址,它就能在后台帮你去访问、解析、操作、收集数据。传统手工操作浏览器需要几十分钟的事情,脚本批量跑几分钟就能完成。我自己的项目里,最常见的使用场景是这么几类:页面自动化测试:特别是端到端测试,模拟用户走完注册、登录、下单流程,比纯单元测试更有说服力批量截图:定时给公司官网首页截图,做视觉回归测试;或者给文档站点生成预览图生成 PDF:把后台报表页面导出成 PDF,按周推送邮件,省去手动打印的环节公开页面数据采集:对无登录墙的公开信息做定时采集,比如商品价格对比、行业排行榜预渲染:把单页应用在服务端跑一遍,输出完整的 HTML,解决部分 SEO 需求的兜底方案1.2 它和 Selenium、Playwright 的差别在哪选型的时候大家总喜欢对比这几个工具,我直接说结论:Puppeteer 优势在于和 Chrome 结合最紧密、依赖最轻,因为它是 Chrome 团队官方维护的。Selenium 支持多浏览器,但配置更重,要装驱动,还要维护浏览器版本匹配;Playwright 是后起之秀,功能很强,支持多浏览器,但如果你只需要 Chrome/Chromium,Puppeteer 完全够用,API 设计也更符合前端直觉。选型建议:大厂多浏览器兼容测试选 Playwright,传统企业测试平台选 Selenium,个人项目或数据自动化直接上 Puppeteer。一个项目里如果只瞄准 Chromium 内核,Puppeteer 的安装成本和体积都是最小的。2. 环境准备与安装实操2.1 安装以及下载 Chromium 的坑Puppeteer 是一个 Node.js 包,安装没什么特殊要求,Node 版本建议 14 以上。我的建议是遇到问题先用 LTS 版本,避免因为 Node 过新导致原生模块编译失败。新建项目目录后执行:npm init -y npm install puppeteer默认情况下,这条命令会把兼容版本的 Chromium 一起下载到本地。这一步通常是最容易出现问题的,因为 Chromium 包体积不小,而且下载地址在国外,网络环境不好时会非常慢,甚至直接卡住。如果你遇到下载超时,有两个思路。第一个思路是用镜像源下载,设置环境变量指向国内镜像地址,再重新安装:PUPPETEER_DOWNLOAD_BASE_URL=https://npmmirror.com/mirrors/chrome-for-testing npm install第二个思路是放弃自动下载,使用 puppeteer-core,只装 API 不装浏览器,然后指向系统里已有的 Chrome。这也是我推荐的方式,省空间还能避免反复踩下载的坑:npm install puppeteer-core注意:puppeteer-core 不会自动下载 Chromium。launch 时必须手动传 executablePath 指定浏览器路径,否则会报“No executable found”的错。2.2 连接本地 Chrome 的实战配置安装完之后先做一次最小启动验证。下面这段代码确认环境是否正常:const puppeteer = require('puppeteer-core'); (async () => { const browser = await puppeteer.launch({ headless: 'new', executablePath: '/usr/bin/chromium' }); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })();headless 这个参数值得单独说一下。老版本里写headless: true就行,新版本 Chrome 推出了新的无头模式,写headless: 'new'可以获得和旧模式更接近的真实渲染效果。实测下来,新无头模式在字体渲染、WebGL 支持方面都比旧模式更接近有头浏览器,建议默认使用。如果你是在服务器上以 root 用户运行,经常会遇到沙箱报错。这时候可以加两个 Chrome 启动参数:const browser = await puppeteer.launch({ headless: 'new', executablePath: '/usr/bin/chromium', args: ['--no-sandbox', '--disable-setuid-sandbox'] });我在多台服务器上跑过,这条基本能解决启动即崩溃的问题。但要注意,关闭沙箱有安全隐患,只建议在隔离的容器或专用服务器上这样做,不要在有其他服务的机器上随便关。3. 截图与 PDF 生成示例3.1 全屏截图和视口设置的正确姿势截图应该算 Puppeteer 最常用的功能了。最简单的截图代码三行就能完成:const page = await browser.newPage(); await page.goto('https://example.com', { waitUntil: 'networkidle2' }); await page.screenshot({ path: 'homepage.png', fullPage: true });fullPage: true表示截取整个页面高度,而不是只截可视区域。这个参数背后的逻辑是浏览器会临时调整视口高度到整个页面的滚动高度再截图,截完再恢复。所以如果页面里有用到固定定位的浮层,截长图时可能出现浮层重复出现的情况,这是 Chrome 渲染机制决定的,遇到的话把浮层隐藏后再截。视口设置的坑最多。移动端页面截图必须提前设置好宽度和高度,不然默认 800x600 的桌面视口会让移动端布局完全走样:await page.setViewport({ width: 375, height: 812, deviceScaleFactor: 2 });deviceScaleFactor: 2模拟的是 iPhone 的 2 倍像素密度,截出来的图清晰度更高,适合做设计稿对比。但要注意,这个值会直接影响截图输出文件的体积,大批量截图时谨慎使用。如果只想截页面里某一个区域,比如某个图表卡片、某段代码块,不要自己算坐标去裁剪,直接用元素选择器:const el = await page.$('.chart-card'); await el.screenshot({ path: 'chart.png' });用元素截图的好处是页面滚动后元素位置变了也能正确定位,比自己裁图片靠谱得多。3.2 生成 PDF 的参数细节把页面转 PDF 是另一个高频需求。Puppeteer 的 page.pdf 接口可以直接输出带排版的文件,不需要安装额外的打印组件:await page.goto('https://example.com/report', { waitUntil: 'networkidle2' }); await page.pdf({ path: 'report.pdf', format: 'A4', printBackground: true, preferCSSPageSize: true, margin: { top: '15mm', right: '10mm', bottom: '15mm', left: '10mm' } });两个重要细节:第一个,printBackground: true一定要加。如果不加,PDF 里所有背景色和 background-image 都会消失,页面瞬间变成白底黑字,视觉上跟丢了一半样式一样。我第一次生成报表 PDF 时忘了这个参数,结果图表区域一片空白,排查了半小时才发现是背景色被自动去掉了。第二个,preferCSSPageSize: true表示优先使用 CSS 里的 @page 规则设置的页面大小。如果前端页面已经按 A4 设计好了打印样式,加上这个参数能精确控制页面尺寸,反之则用 format 参数统一指定。还有一点要记住:page.pdf 在旧版无头模式(headless: true)下工作正常,但在某些非无头版本下会报错。如果你发现 pdf 调用失败,优先检查是不是 headless 配置不对。提示:截图和 PDF 都建议在 waitUntil: 'networkidle2' 之后再执行。networkidle2 表示 500ms 内网络请求数不超过 2 个,这时候页面资源基本加载完毕,比默认的 load 事件更可靠。4. 页面自动化交互示例4.1 等待策略:从 sleep 到 waitFor新手写交互代码最常犯的错误就是到处用await page.waitForTimeout(3000)这种死等。死等的问题是网络快的时候浪费时间,网络慢的时候又不够用,属于两头都吃亏。正确思路是“等条件,而不是等时间”。Puppeteer 提供了一套完整的等待方法:方法作用适用场景page.waitForSelector等某个选择器出现在 DOM 中最常用,等按钮、等列表加载page.waitForFunction等待页面内 JS 执行结果为真等数据渲染完成、等状态变化page.waitForNavigation等待跳转完成点击后触发页面跳转时用page.waitForRequest等待某个网络请求发出配合接口触发判断使用page.waitForResponse等待某个网络请求返回等接口数据返回后继续操作举个例子,页面上有个列表是异步加载的,如果用死等大概率会踩中加载时序问题。正确写法是这样:await page.click('.load-more-btn'); await page.waitForSelector('.item-card');点击按钮后,等待新卡片元素出现在 DOM 里,再继续下面的断言或采集。这样不管接口响应是 200ms 还是 2000ms,代码都能正确工作。最实用的组合是 waitForFunction 配 document.querySelector,处理单页应用里数据刷新的场景:await page.waitForFunction( (selector, text) => { const el = document.querySelector(selector); return el && el.textContent.includes(text); }, { timeout: 10000 }, '.status', '已完成' );等页面上的状态文本变成“已完成”再继续,比任何固定超时都准确。4.2 表单填写、点击与弹窗处理模拟用户提交表单是端到端测试的核心场景。基础操作包括输入文本、点击按钮。一段完整的登录表单自动化一般是这样的:await page.type('#username', 'test_user', { delay: 30 }); await page.type('#password', 'secret123', { delay: 30 }); await page.click('#login-btn'); await page.waitForNavigation({ waitUntil: 'networkidle2' });delay参数模拟真实用户逐字输入的速度,主要为了让页面事件监听器有足够时间响应。很多框架在 input 事件里做了防抖处理,如果 value 是一瞬间设进去的,可能触发不了后续逻辑,所以模拟真实输入反而比直接赋值更可靠。如果你要填的内容是纯数字验证码或者需要清空再输入,先要清掉输入框默认值:await page.$eval('#phone', (el) => { el.value = ''; }); await page.type('#phone', '13800138000', { delay: 10 });弹窗和对话框是经常被忽略的。页面里的 alert 如果不处理,脚本会一直卡住,因为无头模式下不会有人去点确定。要提前挂上事件监听:page.on('dialog', async (dialog) => { console.log('弹窗内容:', dialog.message()); await dialog.accept(); });不要小看这个监听,少了它脚本可能会静默挂起十分钟直到超时。我用一个演示项目测试过,加了 dialog 监听后整体稳定性提升非常明显。4.3 多标签页与 iframe 处理实际操作中经常要打开多个页面。比如巡检场景下,需要同时打开十几个页面做健康检查。Puppeteer 支持在一个浏览器实例里开多个 page:const pageA = await browser.newPage(); const pageB = await browser.newPage();每个 page 是独立的标签页,拥有独立的会话和存储。但注意,同一浏览器实例里的多个 page 共享同一个 userDataDir,也就是共享 Cookie 和本地存储。如果你需要隔离登录态,必须用browser.createIncognitoBrowserContext()创建隐身上下文:const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage();iframe 的处理是另一个麻烦。页面里有内嵌 iframe 时,:hover和普通的 selector 都定位不进去,必须切换到 frame 对象:const frame = await page.frames().find(f => f.url().includes('widget')); const button = await frame.$('.widget-submit'); await button.click();如果 iframe 加载较慢,可以先等 frame 出现:await page.waitForFrame('https://widget.example.com');我实测过很多页面,iframe 是自动化测试里最容易出问题的点,特别是跨域 iframe 会限制很多 DOM 操作。遇到跨域限制场景,尽量从主页面入手,或者用接口测试代替页面自动化。5. 请求拦截与脚本注入5.1 拦截请求,屏蔽图片和统计接口Puppeteer 有一个杀手级功能,就是能拦截页面发出的所有网络请求。这可以大幅提升页面加载速度和稳定性。最典型的场景是采集公开列表数据时,根本不需要图片资源,把图片请求全部丢弃,页面加载速度能快出好几倍:await page.setRequestInterception(true); page.on('request', (request) => { const type = request.resourceType(); if (['image', 'stylesheet', 'font'].includes(type)) { request.abort(); } else { request.continue(); } });同样的逻辑还能用来屏蔽统计类接口。很多站点会加载第三方数据上报脚本,这类请求既占带宽又无关紧要,可以直接忽略:page.on('request', (request) => { const url = request.url(); if (url.includes('analytics') || url.includes('tracking')) { request.abort(); } else { request.continue(); } });请求拦截还有一个实用场景是修改接口返回数据。配合request.respond能伪造接口响应,做前端异常场景的测试非常方便:page.on('request', (request) => { if (request.url().includes('/api/user')) { request.respond({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: '服务异常' }) }); } else { request.continue(); } });用这个方案可以模拟接口 500、超时、返回空数据等情况,不需要真的把后端服务弄挂。前端异常提示写得好不好,一张伪造响应就能验证出来。5.2 在页面里注入自定义脚本有时你需要直接在页面上下文里执行 JavaScript,读取或修改数据。page.evaluate就是干这个的:const data = await page.evaluate(() => { const rows = document.querySelectorAll('.list-item'); return Array.from(rows).map(row => { return { title: row.querySelector('.title')?.textContent?.trim(), price: row.querySelector('.price')?.textContent?.trim() }; }); }); console.log(data);evaluate 里写的是浏览器环境里的 JS,不是 Node 环境。所以不能调用 Node 的 require、module 之类的 API,这一点新手经常搞混。如果你要在页面里先注入一段通用的工具函数再执行采集逻辑,可以用page.addScriptTag:await page.addScriptTag({ url: 'https://cdn.example.com/helper.js' }); // 或者注入内联代码 await page.addScriptTag({ content: `window.__helper = { formatPrice: (p) => '¥' + Number(p).toFixed(2) }` });还有一种更进阶的注入手法,利用page.evaluateOnNewDocument在每次新页面创建时就注入代码。它会在页面任何脚本运行之前执行,因此可以用来改写全局变量,模拟特殊环境:await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); });这行代码把 navigator.webdriver 改成了 false,可以在一定程度上减少被页面反自动化脚本识别的概率。这类技巧属于双方博弈的范畴,我只建议在合法合规的测试场景里使用,不要拿去做突破访问限制的事情。5.3 并发采集与性能调优跑批量任务时,一个个顺序打开页面太慢了。我常用的提速方案是在同一个浏览器里控制并发数,合理开多个 page:async function processUrls(urls, concurrency = 5) { const browser = await puppeteer.launch({ headless: 'new' }); const results = []; const workers = Array.from({ length: concurrency }, async () => { while (urls.length > 0) { const url = urls.pop(); if (!url) break; const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); const data = await page.evaluate(() => document.title); results.push(data); } catch (err) { console.error('处理失败:', url, err.message); } finally { await page.close(); } } }); await Promise.all(workers); await browser.close(); return results; }这里的关键工程点是每次处理完都调用 page.close()。不关页面会导致浏览器内存持续上涨,批量跑上千个 URL 时内存直接被打满,这是我踩过最大的坑之一。并发数也不是越大越好。实践下来,单机场景并发 5 到 8 个页面的性价比最高。并发太大会导致 CPU 和内存争抢,页面加载反而变慢,还可能被目标站点限制访问。采集过程中加延时也是很有必要的。给每个页面加 200 到 800 毫秒的随机延迟,能降低对目标服务器的瞬时压力,也更接近真实用户行为。这个值不用太大,但完全不加的话,短时间大量请求会显得非常“机器化”。6. 常见问题与排查心得6.1 启动失败的几种典型场景这么多年用下来,启动失败排行前几的原因无非就这几个。沙箱问题表现最直接,报错里带有sandbox字样。解决方式就是前面提到过的加--no-sandbox参数。这个报错常见于 CentOS 7 的服务器和部分 Docker 镜像,原因通常是系统没有正确配置用户命名空间。executablePath报错指的是你给 puppeteer-core 配置的浏览器路径不存在。这个坑很隐蔽,因为很多 Linux 发行版的 Chromium 可执行文件不一定在/usr/bin/chromium,有的是/usr/bin/chromium-browser,还有 Chrome for Testing 的路径里带着版本号。解决方案是先which chromium或者find / -name '*chrome*' 2>/dev/null确认真实路径。还有一类是缺少依赖库。Chromium 在 Linux 上依赖很多动态链接库,尤其是libX11、libnss3、libatk之类的图形库。最典型的报错是:error while loading shared libraries: libnss3.so这类问题在裸的容器镜像里最常见,需要安装对应依赖包,比如 CentOS 执行yum install -y nss atk at-spi2-atk cups-libs libdrm libxkbcommon-x11。如果你用的是别人维护的 Docker 镜像,最好先确认镜像里带不带这些默认库。6.2 元素找不到与超时处理waitForSelector 超时是最常见的运行期报错。问题往往是选择器写错了,或者页面元素在鼠标点击之后才异步渲染。排查思路可以从这几个角度入手:先确认页面中是否真的存在目标元素,用page.$试一下用page.content()打印当前 HTML,看元素到底是什么时候出现的选择器是否太脆弱,比如依赖了动态生成的 class 名动态 class 是另一个大坑。很多现代前端框架(各种组件库、CSS-in-JS 方案)会生成哈希类名,你选中一次,下一次重新部署可能就变了。这个问题的解决思路不是每次去改选择器,而是优先用相对稳定的属性定位,比如>const el = await page.$('button:contains("确认")'); // 或者用 XPath const xpath = '//button[contains(text(), "确认")]'; const button = await page.$x(xpath);$X 返回的是数组,用的时候记得取第一个元素。如果某个元素时有时无,多半是接口偶发超时或前端重试机制造成的。稳妥做法是包一层重试逻辑:async function waitForSelectorWithRetry(selector, page, attempts = 3) { for (let i = 0; i < attempts; i++) { try { await page.waitForSelector(selector, { timeout: 8000 }); return; } catch (err) { if (i === attempts - 1) throw err; await page.reload({ waitUntil: 'domcontentloaded' }); } } }6.3 内存管理与异常退出长期运行的定时任务必须重视内存管理。这里说的不是内存泄漏,而是浏览器进程本身很吃资源。无头 Chromium 每个页面默认占用几十 MB 到几百 MB 内存,跑完不关闭,堆积起来再稳的服务也会 OOM。我自己的习惯是每次任务结束都显式调用 browser.close(),而不是只关页面。单纯page.close()不会释放浏览器进程占用的全部资源,反复 launch 和 close 也会留下僵尸进程,时间长了服务器负载会一直升高。排查僵尸进程可以执行:ps -ef | grep chrome如果发现大量残留进程,说明程序在异常退出时没有正确清理。这就要在代码里加上兜底逻辑:try { // 主要业务逻辑 } finally { await browser.close().catch(() => {}); }Node.js 默认只在uncaughtException时不会自动退出,但异步异常会导致任务中断。遇到这种问题,我建议用一个简单的守护方式:外层包一个Promise,加上 total timeout,超过 50 秒就强制退出:const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('总超时, 强制退出')), 50000); }); await Promise.race([runTask(), timeoutPromise]);用这套方案,一个批量巡检脚本在服务器上跑了几百轮没出现过卡死的情况。调试的时候还有一个小技巧:不要一上来就跑无头模式,先用headless: false加slowMo: 200打开浏览器,看真实页面操作过程。可以设置devtools: true甚至打开开发者工具看网络请求。一旦业务逻辑验证通过,再切回headless: 'new'投入生产运行。这个调试验证方式帮我定位了无数个诡异问题。收个尾,说点实在的最后再分享几点我的实际体会。Puppeteer 看起来是个简单的自动化工具,但项目里大量脚本不稳定的根源,往往不是 Puppeteer 本身,而是对页面加载时序的理解不够。你等待的元素到底什么时候出现,依赖的接口什么时候返回,页面跳转是原生路由还是整页刷新,这些细节决定了脚本的稳定性。我习惯是刚开始写脚本时多花几分钟,用page.on('console')和page.on('requestfailed')把页面的关键日志监听起来,一旦后续运行出问题,能看到运行现场的第一手信息。这比猜原因高效太多。这个工具目前还在持续进化,新版本不断调整无头模式内核和 API 行为。建议固定一个版本长期使用,不要每次升级都随手改代码。等老项目稳定运行了,再单独抽时间评估新版本迁移。版本锁定了,你的自动化脚本就不会因为一个依赖升级莫名其妙断掉。