Puppeteer使用示例详解:从页面自动化到截图与请求拦截实战
做前端或者自动化相关开发的同学基本都听过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/ChromiumPuppeteer 完全够用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_URLhttps://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 支持在一个浏览器实例里开多个 pageconst 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.addScriptTagawait 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 并发采集与性能调优跑批量任务时一个个顺序打开页面太慢了。我常用的提速方案是在同一个浏览器里控制并发数合理开多个 pageasync 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 行为。建议固定一个版本长期使用不要每次升级都随手改代码。等老项目稳定运行了再单独抽时间评估新版本迁移。版本锁定了你的自动化脚本就不会因为一个依赖升级莫名其妙断掉。