标签

Puppeteer

Puppeteer 是一个 Node.js 库,它提供了一个高级 API 来通过 DevTools 协议控制无头 Chrome 或 Chromium。它还可以配置为使用完整(非无头)Chrome 或 Chromium。

Puppeteer
服务端5月29日 00:50
Cheerio 和 Puppeteer 有什么区别?爬虫场景怎么选?Cheerio 是纯 HTML 解析器,不执行 JavaScript,解析速度比 Puppeteer 快 100 倍以上,内存占用极低;Puppeteer 启动真实 Chromium,能执行 JS、处理动态渲染、模拟用户交互,但资源消耗大、速度慢。选择依据很简单:目标页面内容是否由 JS 动态生成——查看网页源码(Ctrl+U)能看到数据就用 Cheerio,看不到就用 Puppeteer。 ## 追问 **Puppeteer 能不能用 Cheerio 解析页面?** 可以,这是常见的混合模式:Puppeteer 负责加载动态页面拿到渲染后的 HTML,再传给 Cheerio 做数据提取,兼顾动态能力与解析效率。 **怎么判断一个网站是否需要 Puppeteer?** 右键查看网页源码,搜索目标数据。如果源码中没有,说明是 JS 动态加载,需要 Puppeteer;源码中已有,直接 Cheerio 即可。也可以用 `curl` 请求看返回的原始 HTML。 **Cheerio 能否处理需要登录的页面?** Cheerio 本身只做解析,登录请求通过 axios/fetch 带 cookie 发送即可,不需要 Puppeteer。只有登录过程涉及 JS 渲染或验证码交互时才需要浏览器。 **两者的资源消耗差异有多大?** Cheerio 解析一个页面通常 <10ms、内存 <50MB;Puppeteer 启动浏览器就要 500ms+、内存 100-300MB。批量爬取时差异是数量级的,100 个页面 Cheerio 几秒搞定,Puppeteer 可能要几分钟。 ## 写段代码 ```javascript // Puppeteer 拿动态页面 + Cheerio 解析 const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); const html = await page.content(); await browser.close(); const $ = cheerio.load(html); $('.item').each((i, el) => { console.log($(el).text()); }); ```
前端5月28日 07:18
Puppeteer 性能优化有哪些核心策略?Puppeteer 在爬虫和自动化测试场景下,性能瓶颈主要来自 Chromium 的资源消耗——每次启动一个浏览器实例就要占 50-100MB 内存,每个 Page 再加 30-80MB,而页面加载时的网络 I/O 和 DOM 渲染又是时间上的最大开销。理解哪些环节最耗资源,才能对症下药。核心优化方向有三个:减少浏览器开销、降低页面加载成本、合理管理并发与内存。 ## 浏览器启动与实例管理 每次 `puppeteer.launch()` 都会启动一个完整的 Chromium 进程,开销约 50-100MB 内存。批量任务中复用浏览器实例是最基本也最有效的优化: ```javascript const browser = await puppeteer.launch({ headless: 'new', args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--disable-gpu', '--window-size=1920,1080' ] }); // 复用同一个 browser,每次任务只开新 page for (const url of urls) { const page = await browser.newPage(); await page.goto(url); // ... 执行任务 await page.close(); } await browser.close(); ``` 启动参数中几个关键项的作用: - `headless: 'new'` — 使用 Chrome 的新版 Headless 模式,比旧版 headless 快约 20-30%,因为它不再走单独的渲染路径,而是和有头模式共享同一套代码 - `--disable-dev-shm-usage` — 在 Docker 等共享内存受限的环境中必不可少,否则 Chromium 会因 `/dev/shm` 空间不足而崩溃,改用 `/tmp` 目录 - `--no-sandbox` — 在容器内运行时需要关闭沙盒,因为容器通常没有足够的权限创建命名空间 - `--disable-gpu` — 无头模式下不需要 GPU 加速,关闭后可减少一个 GPU 进程的内存开销 对于长时间运行的任务,Chromium 存在内存泄漏倾向,运行上千次后内存占用可能翻倍。建议定期重启浏览器实例: ```javascript let browser; let taskCount = 0; const RESTART_THRESHOLD = 500; async function getBrowser() { if (!browser || taskCount >= RESTART_THRESHOLD) { if (browser) await browser.close(); browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] }); taskCount = 0; } taskCount++; return browser; } ``` 重启阈值需要根据实际内存监控数据调整。一个实用的监控方式是在每次任务后检查进程内存: ```javascript const used = process.memoryUsage(); if (used.rss > 1024 * 1024 * 1024) { // 超过 1GB await browser.close(); browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] }); } ``` ## 页面加载策略 页面加载是时间消耗最大的环节。默认的 `waitUntil: 'load'` 会等待所有资源(图片、CSS、字体、JS)加载完成,对爬虫来说往往不必要。 ```javascript // 爬虫场景:DOM 就绪即可开始提取数据 await page.goto(url, { waitUntil: 'domcontentloaded' }); // 需要 JS 渲染完成后提取动态内容 await page.goto(url, { waitUntil: 'networkidle2' }); // 需要确保所有异步请求都完成(如懒加载图片) await page.goto(url, { waitUntil: 'networkidle0' }); ``` 四种策略的耗时对比:`domcontentloaded` 比 `load` 快 2-5 倍,比 `networkidle0` 快 5-10 倍,具体差距取决于页面资源量。选择策略时遵循一个原则:能用 `domcontentloaded` 就不用 `load`,能用 `networkidle2` 就不用 `networkidle0`。 还有一种更精细的做法:先用 `domcontentloaded` 完成初始加载,再手动 `waitForSelector` 等待关键元素出现: ```javascript await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.data-table', { timeout: 5000 }); // 比直接用 networkidle0 更精准,不会浪费时间等无关请求 ``` 拦截不必要的网络请求可以进一步降低加载时间和内存占用: ```javascript await page.setRequestInterception(true); page.on('request', (request) => { const blocked = ['image', 'font', 'media', 'stylesheet']; if (blocked.includes(request.resourceType())) { request.abort(); } else { request.continue(); } }); ``` 这个优化在抓取纯文本数据的场景下效果显著——页面加载速度可提升 50% 以上,内存占用降低 30-40%。但如果需要截屏或分析页面视觉布局,图片和样式表不能拦截,需根据场景灵活调整拦截列表。 设置合理的超时时间同样重要,避免因个别慢页面拖垮整体效率: ```javascript await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 // 15 秒超时,不给慢页面无限等待 }); ``` ## 并发控制与连接池 `Promise.all` 可以并行处理多个页面,但无限制的并发会导致内存飙升和 CPU 争抢,甚至触发系统 OOM Killer。实际生产中必须控制并发数: ```javascript async function processWithConcurrency(urls, concurrency = 3) { const browser = await puppeteer.launch({ headless: 'new' }); const results = []; for (let i = 0; i < urls.length; i += concurrency) { const batch = urls.slice(i, i + concurrency); const batchResults = await Promise.all( batch.map(async (url) => { const page = await browser.newPage(); try { await page.goto(url, { waitUntil: 'domcontentloaded' }); return await page.evaluate(() => document.body.innerText); } finally { await page.close(); } }) ); results.push(...batchResults); } await browser.close(); return results; } ``` 并发数的选择取决于机器配置:每打开一个 Page 大约需要 30-80MB 内存。一台 4GB 内存的机器,并发 5-10 个 Page 就接近极限。8GB 内存可以开 10-20 个并发,但还要考虑 CPU 核心数——Chromium 每个渲染进程都会占一个核心,并发数超过核心数时进程切换开销会抵消并发收益。 更推荐的做法是使用 `puppeteer-cluster` 库,它内置了并发控制、自动重试和错误处理: ```javascript const { Cluster } = require('puppeteer-cluster'); const cluster = await Cluster.launch({ concurrency: Cluster.CONCURRENCY_CONTEXT, maxConcurrency: 5, puppeteerOptions: { headless: 'new' } }); await cluster.task(async ({ page, data: url }) => { await page.goto(url, { waitUntil: 'domcontentloaded' }); const data = await page.evaluate(() => document.body.innerText); return data; }); urls.forEach(url => cluster.queue(url)); await cluster.idle(); await cluster.close(); ``` `puppeteer-cluster` 的 `CONCURRENCY_CONTEXT` 模式使用 BrowserContext 而非新 Page 来隔离任务。Context 的创建和销毁比 Page 更轻量,且不会共享 Cookie 和存储——这对爬虫场景很关键,避免不同任务的登录态互相干扰。如果需要更强的隔离(不同 User-Agent、不同代理),可以用 `CONCURRENCY_BROWSER` 模式,每个任务一个独立的浏览器实例,代价是内存开销更大。 对于更大规模的爬虫系统,可以实现一个浏览器连接池: ```javascript class BrowserPool { constructor(maxSize = 3) { this.maxSize = maxSize; this.browsers = []; this.queue = []; } async init() { for (let i = 0; i < this.maxSize; i++) { this.browsers.push(await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-dev-shm-usage'] })); } } async acquire() { if (this.browsers.length > 0) { return this.browsers.pop(); } return new Promise(resolve => this.queue.push(resolve)); } release(browser) { if (this.queue.length > 0) { this.queue.shift()(browser); } else { this.browsers.push(browser); } } async closeAll() { await Promise.all(this.browsers.map(b => b.close())); } } ``` ## 内存泄漏防治 内存泄漏是 Puppeteer 长时间运行的最大敌人。常见的泄漏源有三类:未关闭的 Page、未关闭的 BrowserContext、事件监听器未移除。 **务必在 finally 块中关闭资源:** ```javascript async function safeScrape(url) { const page = await browser.newPage(); try { await page.goto(url); return await page.evaluate(() => document.title); } finally { await page.close(); // 无论成功还是异常,都关闭 page } } ``` **使用 BrowserContext 隔离任务:** ```javascript const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); try { await page.goto(url); // ... 执行任务 } finally { await context.close(); // 关闭 context 会同时关闭所有属于它的 page } ``` `context.close()` 比 `page.close()` 更彻底,它会清理该上下文下的所有页面、Cookie、LocalStorage 和缓存,防止跨任务数据污染。特别是当一个任务的 Cookie 会影响另一个任务的结果时(比如不同账号登录态),Context 隔离是必须的。 **通过 CDP 定期清理浏览器数据:** ```javascript const client = await page.target().createCDPSession(); await client.send('Network.clearBrowserCache'); await client.send('Network.clearBrowserCookies'); ``` 相比 Puppeteer 的 `page.deleteCookie()` 和 `page.evaluate(() => localStorage.clear())`,CDP 方式更高效——一条命令就能清空所有缓存和 Cookie,而不需要逐个删除。 **移除不再需要的事件监听器:** ```javascript const handler = (request) => { /* ... */ }; page.on('request', handler); // 任务完成后移除 page.off('request', handler); ``` 未移除的监听器会持有对 page 对象的引用,阻止垃圾回收,是隐蔽但常见的泄漏源。 ## 选择器与执行效率 Puppeteer 的 Node.js 进程和 Chromium 进程是分离的,`page.$()`、`page.evaluate()` 之间的每次调用都需要跨进程通信(IPC),涉及数据的序列化和反序列化。减少 IPC 调用次数是提升执行速度的关键: ```javascript // 低效:3 次 IPC 调用 const title = await page.$eval('.title', el => el.textContent); const price = await page.$eval('.price', el => el.textContent); const desc = await page.$eval('.desc', el => el.textContent); // 高效:1 次 IPC 调用完成所有提取 const data = await page.evaluate(() => ({ title: document.querySelector('.title')?.textContent, price: document.querySelector('.price')?.textContent, desc: document.querySelector('.desc')?.textContent })); ``` 一次性提取所有数据比多次 `$eval` 快 3-5 倍,因为只产生一次 IPC 开销。这条规则在实际优化中经常被忽略,但对高频调用场景影响显著。 另一个常见的低效模式是反复查询同一个元素: ```javascript // 低效:每次都重新查找 DOM for (let i = 0; i < 10; i++) { const text = await page.$eval('.item', (el, i) => el.children[i].textContent, i); } // 高效:一次提取所有子元素文本 const texts = await page.evaluate(() => Array.from(document.querySelectorAll('.item')).map(el => el.textContent) ); ``` 选择器本身的效率也有差异:ID 选择器 > Class 选择器 > 标签选择器。但在爬虫场景下,选择器通常由目标页面的 DOM 结构决定,优化空间有限。真正值得投入精力的是减少 IPC 调用次数。 ## CDP 进阶:性能监控与分析 CDP(Chrome DevTools Protocol)是 Puppeteer 的底层协议,通过 `createCDPSession()` 可以访问比 Puppeteer API 更底层的功能,获取更详细的性能数据: ```javascript const client = await page.target().createCDPSession(); // 获取页面性能指标 await client.send('Performance.enable'); const { metrics } = await client.send('Performance.getMetrics'); // 关键指标: // - JSHeapUsedSize:JS 堆已使用大小 // - Nodes:DOM 节点数量(过多说明可能有泄漏) // - LayoutCount:布局重排次数(过多说明 DOM 操作低效) ``` `Performance.getMetrics` 返回的指标可以帮助判断瓶颈在哪:JSHeapUsedSize 持续增长说明有内存泄漏,Nodes 过多说明 DOM 操作需要优化,LayoutCount 高说明频繁触发了重排。 **性能追踪:** ```javascript await page.tracing.start({ path: 'trace.json' }); await page.goto(url); await page.tracing.stop(); // 用 chrome://tracing 打开 trace.json 进行可视化分析 ``` 生成的 `trace.json` 可以在 Chrome 的 `chrome://tracing` 页面加载,直观看到每个阶段的耗时分布——脚本执行、布局计算、绘制、网络请求各占多少时间。这在定位"页面加载慢到底是卡在哪里"时非常有效。 **网络监控:** ```javascript await client.send('Network.enable'); client.on('Network.responseReceived', ({ response }) => { if (response.status >= 400) { console.log(`请求失败: ${response.url} - ${response.status}`); } }); ``` 通过 CDP 监听网络事件,可以记录所有请求的状态码和耗时,帮助发现哪些第三方请求拖慢了页面,或者哪些接口返回了错误。 ## 反检测与稳定性 频繁请求同一站点会触发反爬机制,导致性能骤降(验证码、封 IP、返回空白页)。虽然这不算传统意义上的"性能优化",但反爬触发后带来的重试和超时会严重影响整体效率。几个基本措施: **隐藏 WebDriver 特征:** ```javascript await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); // 修复 permissions.query 在 headless 中的异常 const originalQuery = window.navigator.permissions.query; window.navigator.permissions.query = (parameters) => parameters.name === 'notifications' ? Promise.resolve({ state: Notification.permission }) : originalQuery(parameters); }); ``` `evaluateOnNewDocument` 在页面脚本执行前注入,确保页面检测时 `navigator.webdriver` 已经是 `false`。 **随机化操作间隔:** ```javascript const delay = Math.floor(Math.random() * 1000) + 500; // 500-1500ms 随机延迟 await new Promise(resolve => setTimeout(resolve, delay)); await page.click('.next-page'); ``` 匀速访问是最明显的机器特征。加入随机延迟后,请求模式更接近真实用户,降低被风控系统标记的概率。 **设置合理的 User-Agent:** ```javascript await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ); ``` 默认的 User-Agent 包含 "HeadlessChrome",是反爬系统最容易识别的特征之一。替换为真实浏览器的 UA 是最基本的反检测措施。 这些措施不能绕过所有检测(比如基于 TLS 指纹的检测),但能显著降低被初级反爬系统识别的概率,避免因触发反爬导致的重试和超时,间接提升整体效率。 ## 核心优化优先级 按照投入产出比排序,从高到低: 1. **复用浏览器实例** — 改动最小,收益最大,避免每次任务都启动 Chromium 进程 2. **选择合适的 waitUntil** — 一行代码的改动,可能节省数秒等待时间 3. **拦截无用资源** — 爬虫场景下效果最显著,加载速度和内存双赢 4. **控制并发数** — 防止资源耗尽导致整体性能下降甚至系统崩溃 5. **finally 中关闭 Page/Context** — 防止内存泄漏,保证长时间运行稳定 6. **合并 evaluate 调用** — 减少 IPC 开销,高频场景下效果明显 7. **定期重启浏览器** — 兜底策略,解决 Chromium 自身的内存泄漏问题 面试中回答这个问题的关键不是罗列所有策略,而是说清楚**每个优化解决了什么瓶颈**,以及**不同场景下的取舍**——比如拦截资源在纯数据抓取中合适,但截屏场景下不行;`domcontentloaded` 快但可能拿不到 JS 渲染后的内容;并发数不是越多越好,要结合内存和 CPU 核心数综合考量。
前端5月28日 07:18
Puppeteer 如何实现页面截图与 PDF 生成?## 核心答案 Puppeteer 通过 `page.screenshot()` 和 `page.pdf()` 两个核心方法实现截图与 PDF 生成。截图支持全页、元素级别、裁剪区域等多种模式,可输出 PNG/JPEG 格式;PDF 生成基于 Chrome 的打印渲染引擎,支持自定义纸张、边距、页眉页脚等配置。两者均依赖 Headless Chrome 的渲染能力,PDF 生成仅支持无头模式。 ## 截图 API 详解 ### page.screenshot() 的关键参数 `screenshot` 方法接受一个可选配置对象,以下参数在实际开发中使用频率最高: - **path**:文件保存路径,决定输出位置 - **type**:`png` 或 `jpeg`,PNG 支持透明通道,JPEG 体积更小 - **quality**:0-100,仅 JPEG 有效,推荐 80-90 之间平衡质量与体积 - **fullPage**:是否截取完整滚动区域,默认只截视口 - **clip**:`{x, y, width, height}` 裁剪指定区域 - **omitBackground**:设为 `true` 时背景透明,需配合 PNG 格式 - **captureBeyondViewport**:Puppeteer 9+ 新增,控制是否捕获视口外内容 ```javascript // 全页截图——最常用的场景 await page.screenshot({ path: 'full.png', fullPage: true }); // 裁剪区域截图 await page.screenshot({ path: 'clip.png', clip: { x: 100, y: 100, width: 800, height: 600 } }); // 透明背景截图(生成水印素材等场景) await page.screenshot({ path: 'transparent.png', type: 'png', omitBackground: true }); ``` ### 元素级截图 对特定 DOM 元素截图是自动化测试中的高频需求,直接调用元素实例的 `screenshot` 方法: ```javascript const element = await page.$('.chart-container'); await element.screenshot({ path: 'chart.png' }); ``` 元素截图时注意:不支持 `fullPage` 参数,截图范围由元素自身尺寸决定。如果元素存在 `overflow: hidden`,被裁剪的部分不会出现在截图中。 ### 视口控制与截图的关系 截图的默认范围是当前视口,视口尺寸通过 `setViewport` 设置: ```javascript await page.setViewport({ width: 1920, height: 1080 }); await page.screenshot({ path: 'desktop.png' }); await page.setViewport({ width: 375, height: 667 }); await page.screenshot({ path: 'mobile.png' }); ``` 响应式测试中通常会循环切换多种视口尺寸,每种尺寸截一张图做对比。 ## PDF 生成 API 详解 ### page.pdf() 的关键参数 `pdf` 方法基于 Chrome 的 `Page.printToPDF` 协议实现,核心参数如下: - **format**:纸张格式,A0 到 A6、Letter、Legal、Tabloid、Ledger - **landscape**:`true` 横向打印 - **margin**:`{top, right, bottom, left}` 页边距 - **printBackground**:是否渲染背景色和背景图,默认 `false` - **displayHeaderFooter**:是否显示页眉页脚 - **headerTemplate / footerTemplate**:HTML 模板字符串,支持 `<span class="pageNumber">` 和 `<span class="totalPages">` 特殊变量 - **pageRanges**:打印页码范围,如 `'1-5, 8'` - **scale**:缩放比例,默认 1 - **preferCSSPageSize**:优先使用 CSS `@page` 定义的尺寸 ```javascript // 标准 A4 PDF await page.pdf({ path: 'doc.pdf', format: 'A4' }); // 带页眉页脚的 PDF await page.pdf({ path: 'with-footer.pdf', format: 'A4', displayHeaderFooter: true, footerTemplate: '<div style="font-size:9px;text-align:center;width:100%;">第 <span class="pageNumber"></span> 页 / 共 <span class="totalPages"></span> 页</div>', margin: { top: '1cm', right: '1cm', bottom: '1.5cm', left: '1cm' } }); // 横向 + 自定义纸张 await page.pdf({ path: 'landscape.pdf', width: '297mm', height: '210mm', landscape: true }); ``` ### PDF 生成的限制与注意事项 **必须在无头模式下运行**——这是最容易被忽略的限制。有头模式下调用 `page.pdf()` 会直接抛异常。如果项目需要同时进行可视化调试和 PDF 生成,可以通过环境变量动态切换: ```javascript const browser = await puppeteer.launch({ headless: process.env.GENERATE_PDF ? 'new' : false }); ``` **字体缺失问题**在 Linux 服务器上尤为常见。中文字符渲染成方块或空白,是因为系统缺少中文字体。解决方案是安装字体包(如 `fonts-noto-cjk`)或将字体文件打包进项目。 **背景色丢失**是因为 `printBackground` 默认为 `false`。CSS 中的 `background-color` 和 `background-image` 不会出现在 PDF 中,必须显式设置 `printBackground: true`。 ## 截图与 PDF 的实战场景 ### 场景一:网页归档与合规存证 金融、法务等行业需要对网页内容做定期归档,保存为 PDF 是最常见的做法: ```javascript async function archivePage(url, outputPath) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); await page.pdf({ path: outputPath, format: 'A4', printBackground: true, margin: { top: '1cm', right: '1cm', bottom: '1cm', left: '1cm' } }); await browser.close(); } ``` `waitUntil: 'networkidle2'` 确保异步加载的内容全部渲染完毕。对于 SPA 页面,可能需要额外 `waitForSelector` 等待关键 DOM 挂载。 ### 场景二:批量截图的响应式测试 同时输出多种设备尺寸的截图,用于视觉回归检测: ```javascript async function responsiveScreenshots(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); const viewports = [ { name: 'mobile', width: 375, height: 667 }, { name: 'tablet', width: 768, height: 1024 }, { name: 'desktop', width: 1920, height: 1080 } ]; for (const vp of viewports) { await page.setViewport(vp); await page.goto(url, { waitUntil: 'networkidle2' }); await page.screenshot({ path: `${vp.name}.png`, fullPage: true }); } await browser.close(); } ``` ### 场景三:发票/报告 PDF 生成 用 `page.setContent()` 注入 HTML 模板,再调用 `page.pdf()` 生成 PDF,是后端动态生成文档的经典方案: ```javascript async function generateInvoice(data) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); await page.setContent(buildInvoiceHTML(data), { waitUntil: 'networkidle0' }); await page.pdf({ path: `invoice_${data.number}.pdf`, format: 'A4', printBackground: true, margin: { top: '20px', right: '20px', bottom: '20px', left: '20px' } }); await browser.close(); } ``` 模板中的样式使用内联 CSS 或 `<style>` 标签,不要依赖外部样式表——`setContent` 不会自动加载外部资源。 ## 性能优化策略 ### 浏览器实例复用 每次截图或生成 PDF 都启动浏览器实例开销很大,推荐复用同一个 browser 对象: ```javascript const browser = await puppeteer.launch(); for (const url of urls) { const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); await page.screenshot({ path: `${Date.now()}.png` }); await page.close(); } await browser.close(); ``` ### 并行处理 `Promise.all` 配合多个 page 实例实现并行,但要注意控制并发数量,防止内存溢出: ```javascript const CONCURRENCY = 3; for (let i = 0; i < urls.length; i += CONCURRENCY) { const batch = urls.slice(i, i + CONCURRENCY); await Promise.all(batch.map(async (url) => { const page = await browser.newPage(); await page.goto(url); await page.screenshot({ path: `${Date.now()}.png` }); await page.close(); })); } ``` ### 拦截无关资源 截图和 PDF 生成通常不需要图片、字体、音视频资源,拦截这些请求能显著提速: ```javascript await page.setRequestInterception(true); page.on('request', (req) => { const blocked = ['image', 'font', 'media', 'stylesheet']; if (blocked.includes(req.resourceType())) { req.abort(); } else { req.continue(); } }); ``` 注意:PDF 生成如果需要保留样式,不应拦截 `stylesheet` 资源。 ## 常见问题与排查思路 **截图出现空白或加载不全**——检查是否使用了正确的 `waitUntil` 策略。`domcontentloaded` 只等 DOM 解析,不等待图片和异步内容。推荐 `networkidle2`,它在网络连接不超过 2 个时认为加载完成。对于懒加载页面,需要手动滚动到底部触发加载后再截图。 **PDF 分页位置不理想**——Chrome 的分页算法基于内容高度计算,无法精确控制。可以通过 CSS `break-before`、`break-after`、`break-inside: avoid` 属性影响分页行为。 **中文字体渲染异常**——Linux 服务器需要安装中文字体包。Docker 环境下建议在 Dockerfile 中添加 `RUN apt-get install -y fonts-noto-cjk`。 **内存持续增长**——确保每次操作后调用 `page.close()`,避免 page 实例泄漏。长时间运行的脚本建议定期重启 browser 实例。 **超时错误**——复杂页面可能需要更长的加载时间,通过 `timeout` 参数调整: ```javascript await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 }); ``` ## 面试追问方向 - `page.pdf()` 为什么只支持无头模式?因为 PDF 生成调用的是 Chrome DevTools Protocol 的 `Page.printToPDF`,该协议只在 headless 模式下可用。有头模式下的打印走的是系统打印对话框,无法通过 CTP 直接输出文件。 - 如何实现懒加载页面的完整截图?需要先注入滚动脚本逐步触发懒加载,等所有内容挂载后再截图。 - Puppeteer 截图和 html2canvas 有什么区别?Puppeteer 在真实浏览器渲染后截图,结果与用户看到的一致;html2canvas 在 JS 层重新绘制 DOM,对 CSS 支持有限,跨域图片等场景容易出问题。
前端5月28日 07:18
什么是 Puppeteer?核心原理和实战场景有哪些?## 什么是 Puppeteer? Puppeteer 是 Google Chrome 团队开发的 Node.js 库,通过 Chrome DevTools 协议(CDP)提供高级 API 来控制无头或有头 Chrome/Chromium 浏览器。简单说,它让你用代码驱动浏览器完成截图、爬虫、自动化测试等操作,是前端工程师最常接触的浏览器自动化工具之一。 ## 核心架构 Puppeteer 的 API 围绕几个核心对象组织,理解它们的层级关系是掌握 Puppeteer 的基础: - **Browser**:浏览器实例,通过 `puppeteer.launch()` 创建,是所有操作的入口 - **BrowserContext**:隔离的浏览器会话,类似隐身模式,多个 Context 之间 Cookie、localStorage、缓存互不干扰,适合多账号并行场景 - **Page**:一个标签页,绝大部分操作(导航、点击、截图)都在 Page 上进行 - **Frame**:页面中的 iframe,每个 Page 有一个主 Frame,通过 `page.frames()` 访问子 Frame ```javascript const browser = await puppeteer.launch({ headless: true }); const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); await page.goto('https://example.com'); // 操作完成后 await browser.close(); ``` 面试要点:Browser 和 BrowserContext 的区别在于——一个 Browser 可以有多个 BrowserContext,它们之间完全隔离,这在爬虫需要多账号并行或测试需要干净环境时非常关键。 ## 主要特性 **1. 无头浏览器控制** 默认以 headless 模式运行,不显示浏览器界面但功能完整。Puppeteer 从 v20 起默认使用新的 Headless 模式(`headless: 'new'`),性能更接近有头模式。设置 `headless: false` 可打开可视化窗口调试脚本执行过程。 **2. 页面交互与等待机制** Puppeteer 的等待机制是面试高频考点,它决定了脚本的稳定性和效率: - `page.waitForSelector(selector)` — 等待元素出现在 DOM 中 - `page.waitForFunction(fn)` — 等待自定义 JS 函数返回 truthy - `page.waitForNavigation()` — 等待页面跳转完成 - `page.waitForResponse(urlOrPredicate)` — 等待特定网络响应 ```javascript await page.click('#submit-btn'); await page.waitForSelector('.result', { visible: true }); const text = await page.$eval('.result', el => el.textContent); ``` 面试常问:为什么不推荐用 `setTimeout` 硬等待?因为网络延迟不可控,硬等待要么浪费时间要么不够等导致报错。Puppeteer 的 waitFor 系列基于轮询 + 事件监听,条件满足时立即继续执行,既可靠又高效。 另一个高频问题:点击后等待导航应该怎么写? ```javascript // 错误写法:click 和 waitForNavigation 竞态 await page.click('#link'); await page.waitForNavigation(); // 正确写法:用 Promise.all 并行等待 await Promise.all([ page.waitForNavigation(), page.click('#link') ]); ``` **3. 网络拦截与请求控制** 通过 `page.setRequestInterception(true)` 可以拦截、修改或 abort 请求,这是爬虫和测试场景的核心能力: ```javascript await page.setRequestInterception(true); page.on('request', request => { if (request.resourceType() === 'image') { request.abort(); // 屏蔽图片,加速爬虫 } else if (request.url().includes('/api/data')) { request.continue({ headers: { ...request.headers(), 'X-Custom': 'value' } }); // 修改请求头 } else { request.continue(); } }); ``` 实际应用:屏蔽无用资源提升页面加载速度(图片、字体、CSS)、mock 接口返回进行前端测试、修改请求头绕过反爬检测。 **4. 截图与 PDF 生成** ```javascript // 整页截图 await page.screenshot({ path: 'full.png', fullPage: true }); // 指定元素截图 const element = await page.$('.chart'); await element.screenshot({ path: 'chart.png' }); // 生成 PDF await page.pdf({ path: 'output.pdf', format: 'A4', printBackground: true }); ``` 注意:PDF 生成仅在无头模式下支持,有头模式调用会报错。 **5. 执行上下文与 page.evaluate** `page.evaluate()` 在浏览器环境中执行 JS,可以访问 DOM 和 window 对象。这是一个容易踩坑的点: ```javascript // 正确:通过参数传入 const title = await page.evaluate((sel) => { return document.querySelector(sel)?.textContent; }, 'h1'); // 错误:闭包变量无法访问 const sel = 'h1'; const title = await page.evaluate(() => { return document.querySelector(sel)?.textContent; // sel 未定义! }); ``` 原因:`page.evaluate` 的回调函数会被序列化后发送到浏览器环境执行,Node.js 侧的闭包变量不会跟随过去。需要传参的变量必须是可以被结构化克隆算法处理的类型(基本类型、普通对象、数组等),函数和 DOM 元素不行。 如果需要传递复杂对象,可以用 `page.exposeFunction(name, callback)` 把 Node.js 函数暴露到浏览器环境中。 ## 主要应用场景 | 场景 | 说明 | 关键 API | |------|------|----------| | SPA 爬虫 | 抓取 Vue/React 等单页应用的动态渲染内容 | `page.goto` + `waitForSelector` | | E2E 自动化测试 | 模拟用户操作流程,验证功能正确性 | `page.click` + `page.type` + 断言 | | PDF/截图服务 | 将网页批量转成 PDF 或截图 | `page.pdf` + `page.screenshot` | | 性能监控 | 录制性能轨迹分析加载瓶颈 | `page.tracing.start/stop` | | Chrome 扩展测试 | 加载扩展并测试交互 | `launch({ args: ['--load-extension=...'] })` | | 预渲染(SSR 替代) | 构建时生成静态 HTML,提升 SEO | `rendertron` / `puppeteer-renderer` | ## 与 Selenium、Playwright 的对比 | 维度 | Puppeteer | Selenium | Playwright | |------|-----------|----------|------------| | 底层协议 | Chrome DevTools Protocol | WebDriver 协议 | CDP + 自有协议 | | 浏览器支持 | 仅 Chrome/Chromium | Chrome/Firefox/Safari/Edge | Chromium/Firefox/WebKit | | 自动等待 | waitFor 系列需手动调用 | 需显式等待(WebDriverWait) | 内置 auto-waiting | | 测试框架 | 无内置,常搭配 Jest/Mocha | 无内置 | 内置 test runner | | 多标签/多上下文 | 支持 BrowserContext | 支持 Window handles | 原生支持,API 更完善 | | 维护方 | Google | 社区(Selenium 4 由 W3C 标准驱动) | Microsoft | | 学习曲线 | 低,API 直观 | 中,需要理解 WebDriver 概念 | 中低,API 设计更现代 | 面试高频追问:2026 年还要学 Puppeteer 吗?Playwright 由原 Puppeteer 团队打造,功能更全面,跨浏览器支持好,新项目优先推荐。但 Puppeteer 在 Chrome 专属场景(扩展测试、CDP 深度调试、Chrome 特性验证)仍有优势,且生态成熟、Stack Overflow 上的资料更多。理解 Puppeteer 的 CDP 原理后迁移到 Playwright 成本很低,两者核心概念一致。 ## 反爬处理常见策略 实际用 Puppeteer 做爬虫时,网站的反爬检测是绕不开的问题: - **设置 User-Agent**:`page.setUserAgent('Mozilla/5.0 ...')` 模拟真实浏览器 - **隐藏 WebDriver 特征**:`page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', { get: () => false }); })` 去除自动控制标识 - **使用 puppeteer-extra-plugin-stealth**:社区插件,自动注入十余项反检测脚本,最省事的方案 - **代理轮换**:`puppeteer.launch({ args: ['--proxy-server=...'] })` 配合代理池避免 IP 封禁 - **模拟人类行为**:用 `page.type(selector, text, { delay: 100 })` 模拟逐字输入,避免瞬间填写触发风控
服务端5月28日 07:17
Puppeteer 如何实现网络请求拦截?有哪些实际应用场景?Puppeteer 通过 CDP(Chrome DevTools Protocol)提供的 Network 域能力实现请求拦截,核心 API 是 `page.setRequestInterception(true)`。启用后,每个请求都会被暂停,必须手动调用 `continue()`、`abort()` 或 `respond()` 才能放行。这一机制在爬虫加速、接口 Mock、安全测试等场景中非常实用。 ## 启用请求拦截的基本方式 ```javascript const puppeteer = require("puppeteer"); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); // 启用请求拦截 await page.setRequestInterception(true); page.on("request", (request) => { // 每个请求必须被处理,否则页面会卡住 request.continue(); }); await page.goto("https://example.com"); await browser.close(); })(); ``` 关键点:`setRequestInterception(true)` 必须在页面导航前调用;每个被拦截的请求必须调用 `continue()`、`abort()` 或 `respond()` 之一,否则请求会一直挂起。 ## 请求拦截的四种核心操作 ### continue —— 放行请求 直接放行原始请求,也可以在放行的同时修改请求参数: ```javascript page.on("request", (request) => { // 修改请求头后放行 request.continue({ headers: { ...request.headers(), Authorization: "Bearer token123", }, }); }); ``` `continue()` 支持覆盖 `url`、`method`、`postData`、`headers` 四个字段,可以实现请求重定向、修改 POST 数据等操作。 ### abort —— 终止请求 直接阻止请求发出,常用于屏蔽广告、图片、字体等非必要资源: ```javascript page.on("request", (request) => { if (request.resourceType() === "image") { request.abort(); } else { request.continue(); } }); ``` `abort()` 可传入错误码,默认是 `failed`,常用值包括 `aborted`、`accessdenied`、`connectionrefused` 等。 ### respond —— 直接返回响应 不向服务器发送请求,直接在本地构造响应返回。这是接口 Mock 的核心手段: ```javascript page.on("request", (request) => { if (request.url().includes("/api/user")) { request.respond({ status: 200, contentType: "application/json", body: JSON.stringify({ id: 1, name: "test-user" }), }); } else { request.continue(); } }); ``` `respond()` 支持 `status`、`headers`、`contentType`、`body` 四个字段,可以完整模拟服务器行为。 ### 响应监听 —— 获取服务端返回数据 通过 `response` 事件监听服务端实际返回的内容,常用于数据采集和接口监控: ```javascript page.on("response", async (response) => { if (response.url().includes("/api/data")) { const data = await response.json(); console.log("接口返回:", data); } }); ``` 注意:`response.json()` 只能调用一次,且只有 JSON 格式的响应才能解析。 ## 资源类型过滤 `request.resourceType()` 返回请求的资源类型,可用于批量过滤: ```javascript const blockedTypes = ["image", "font", "stylesheet", "media"]; page.on("request", (request) => { if (blockedTypes.includes(request.resourceType())) { request.abort(); } else { request.continue(); } }); ``` Puppeteer 支持的资源类型包括:`document`、`stylesheet`、`image`、`media`、`font`、`script`、`xhr`、`fetch`、`websocket`、`eventsource`、`manifest`、`texttrack`、`other`。 ## 实际应用场景 ### 爬虫加速:屏蔽非必要资源 爬取数据时,图片、字体、CSS 对数据提取无用,屏蔽后页面加载速度可提升 50% 以上: ```javascript await page.setRequestInterception(true); page.on("request", (request) => { const useless = ["image", "font", "stylesheet", "media"]; if (useless.includes(request.resourceType())) { request.abort(); } else { request.continue(); } }); ``` ### 接口 Mock:前后端联调 后端接口未就绪时,前端可以用 `respond()` 直接 Mock 数据,不依赖任何 Mock 服务: ```javascript const mockData = { "/api/users": { users: [{ id: 1, name: "Alice" }] }, "/api/posts": { posts: [{ id: 1, title: "Hello" }] }, }; page.on("request", (request) => { for (const [path, data] of Object.entries(mockData)) { if (request.url().includes(path)) { request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(data), }); return; } } request.continue(); }); ``` ### 广告与追踪屏蔽 屏蔽已知广告域名和追踪脚本,减少无关请求: ```javascript const blockedDomains = ["ads.example.com", "analytics.example.com", "tracker.example.com"]; page.on("request", (request) => { if (blockedDomains.some((d) => request.url().includes(d))) { request.abort(); } else { request.continue(); } }); ``` ### 自动注入认证头 需要对所有请求添加 Token 时,用 `continue()` 覆盖 headers 即可,无需在每个请求中手动处理: ```javascript page.on("request", (request) => { request.continue({ headers: { ...request.headers(), Authorization: "Bearer your-token-here", }, }); }); ``` ### 网络请求监控与性能分析 记录所有请求和响应的时间戳与状态码,用于性能分析和接口排查: ```javascript const logs = []; page.on("request", (request) => { logs.push({ type: "request", url: request.url(), method: request.method(), resourceType: request.resourceType(), time: Date.now(), }); request.continue(); }); page.on("response", (response) => { logs.push({ type: "response", url: response.url(), status: response.status(), time: Date.now(), }); }); await page.goto("https://example.com"); console.log("请求总数:", logs.filter((l) => l.type === "request").length); console.log("响应总数:", logs.filter((l) => l.type === "response").length); ``` ## 错误处理 ```javascript page.on("requestfailed", (request) => { console.error("请求失败:", request.url()); console.error("原因:", request.failure()?.errorText); }); ``` 常见失败原因包括:网络断开、DNS 解析失败、SSL 证书错误、被 `abort()` 主动终止等。 ## 面试追问与注意事项 **Q:拦截对所有请求都会生效吗?** 不是。导航请求(主文档请求)在部分场景下可能无法被拦截,且 WebSocket 升级请求的处理方式与普通 HTTP 请求不同。 **Q:请求拦截对性能有什么影响?** 启用拦截后,每个请求都要经过 JavaScript 事件循环处理,会增加请求延迟。对于高频请求场景(如 WebSocket 消息),建议按条件拦截而非全量拦截。 **Q:如何避免重复处理请求?** 调用 `request.isInterceptResolutionHandled()` 检查请求是否已被处理,避免在多个监听器中对同一请求重复调用 `continue()` 或 `abort()`。 **Q:与 Playwright 的请求拦截有什么区别?** Playwright 使用 `page.route()` API,支持路由模式匹配(如 `page.route("**/api/**", handler)`),语法更简洁。Puppeteer 则需要手动判断 URL。两者底层都基于 CDP,核心能力一致。
服务端5月28日 07:16
Puppeteer 无头模式和有头模式有什么区别?Puppeteer 的无头模式(Headless)和有头模式(Headful)是两种浏览器运行方式,核心差异在于是否渲染图形界面,这直接决定了它们的性能表现、调试能力和适用场景。 ## 核心区别 无头模式下浏览器不创建可视化窗口,所有页面渲染和脚本执行在内存中完成;有头模式则启动完整的 Chrome GUI 窗口,每一步操作都可以肉眼观察。 这个看似简单的差异会引发一系列连锁影响: - **资源消耗**:无头模式省去了 GUI 渲染的开销,内存占用通常低 30%-50%,启动速度快 20% 左右 - **User Agent 差异**:旧版无头模式的 UA 包含 `HeadlessChrome` 标识,网站可据此识别并拒绝请求;有头模式的 UA 与普通 Chrome 完全一致 - **渲染一致性**:部分网站在无头模式下的渲染结果与有头模式不同,原因包括 GPU 加速差异、字体渲染路径不同、视口默认值不一致等 - **反爬检测**:无头模式缺少 `navigator.plugins`、`window.chrome` 等浏览器特征,更容易被反爬系统检测 ## 三种无头模式的演进 Puppeteer 的无头模式并非一成不变,Chrome 的迭代带来了三种变体: **旧版无头模式(headless: true)** 默认值,基于独立的 HeadlessChrome 实现,与正常 Chrome 共享极少代码。问题在于它的行为与真实浏览器差异较大,容易被网站检测。 ```javascript const browser = await puppeteer.launch({ headless: true }); ``` **新版无头模式(headless: "new")** Chrome 112+ 引入,使用与有头模式完全相同的 Chrome 代码库,仅跳过可视化输出。渲染结果与有头模式几乎一致,推荐在新项目中优先使用。 ```javascript const browser = await puppeteer.launch({ headless: "new" }); ``` **chrome-headless-shell(headless: "shell")** Puppeteer 21+ 提供,是专为自动化设计的精简二进制文件,体积更小、启动更快,但不支持扩展和部分 Chrome 特性,适合纯服务端批处理场景。 ```javascript const browser = await puppeteer.launch({ headless: "shell" }); ``` ## 有头模式的使用方式 有头模式需要显式关闭 headless,同时可以配合 DevTools 和慢放模式辅助调试: ```javascript const browser = await puppeteer.launch({ headless: false, devtools: true, // 自动打开开发者工具 slowMo: 250 // 每步操作延迟 250ms,便于观察 }); ``` 关键配置项:`slowMo` 让操作可追踪,`devtools` 提供完整调试面板,`defaultViewport` 可设置视口大小。 ## 性能对比 | 指标 | 旧版 headless | 新版 headless | headless shell | 有头模式 | |------|-------------|-------------|--------------|--------| | 内存占用 | 低 | 中 | 最低 | 高 | | 启动速度 | 快 | 中 | 最快 | 慢 | | 渲染一致性 | 差 | 好 | 中 | 基准 | | 反检测能力 | 弱 | 较强 | 弱 | 强 | | 扩展支持 | 不支持 | 支持 | 不支持 | 支持 | ## 各模式适用场景 **无头模式适用于:** - CI/CD 流水线中的自动化测试——服务器通常没有显示器 - 大规模网页抓取——资源占用低,可并发更多实例 - 定时任务和批量处理——截图、PDF 生成、数据采集 - 性能基准测试——减少 GUI 对测试结果的干扰 **有头模式适用于:** - 脚本开发调试阶段——实时观察页面行为,快速定位问题 - 复杂交互场景调试——如动画、拖拽、弹窗等需要视觉确认的操作 - 反爬对抗——部分网站检测到无头特征后拒绝服务,有头模式可以绕过 - 教学演示——展示自动化流程的每一步 ## 无头模式被检测的常见原因及应对 实际项目中,无头模式最常见的坑就是被网站识别。以下是被检测的主要原因和解决思路: **User Agent 泄露**:旧版 headless 的 UA 包含 `HeadlessChrome`,解决方法是手动覆盖: ```javascript await page.setUserAgent( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ); ``` **浏览器特征缺失**:无头模式下 `navigator.plugins` 为空、`navigator.languages` 仅含 `"en-US"`、缺少 `window.chrome` 对象。可使用 `puppeteer-extra-plugin-stealth` 自动修补: ```javascript const puppeteer = require("puppeteer-extra"); const StealthPlugin = require("puppeteer-extra-plugin-stealth"); puppeteer.use(StealthPlugin()); const browser = await puppeteer.launch({ headless: "new" }); ``` **WebGL 和 Canvas 指纹**:无头模式下 GPU 加速不可用,Canvas 指纹与有头模式不同。新版 headless 模式已大幅改善此问题。 **最佳实践**:优先使用新版无头模式(`headless: "new"`)+ stealth 插件,绝大多数场景下可获得与有头模式一致的渲染和反检测效果。 ## 环境切换的工程实践 在实际项目中,通常需要根据运行环境动态切换模式: ```javascript const puppeteer = require("puppeteer"); const isDev = process.env.NODE_ENV === "development"; const browser = await puppeteer.launch({ headless: isDev ? false : "new", devtools: isDev, slowMo: isDev ? 100 : 0, args: isDev ? [] : ["--no-sandbox", "--disable-setuid-sandbox"] }); ``` 开发环境用有头模式便于调试,生产环境用新版无头模式兼顾性能和一致性。`--no-sandbox` 参数在 Docker 等容器环境中通常必需,因为默认的沙箱机制需要特定内核权限。 ## 面试追问方向 - Puppeteer 新版无头模式与旧版的核心实现差异是什么?(共享 Chrome 代码库 vs 独立实现) - 如何让无头模式通过反爬检测?(stealth 插件 + 新版 headless + UA 覆盖) - chrome-headless-shell 适合什么场景?有什么限制?(纯服务端批处理,不支持扩展) - 为什么同样的代码在无头和有头模式下渲染结果不同?(GPU 加速、字体渲染、视口默认值差异)
前端5月28日 07:16
Puppeteer 如何实现设备模拟和移动端测试?## 核心概念 Puppeteer 的设备模拟通过 `page.emulate()` 方法实现,它一次性设置视口(viewport)、用户代理(User-Agent)、设备像素比、触摸支持等属性,让无头浏览器完整模拟目标设备的浏览器环境。 从 Puppeteer v21 开始,设备预设从 `puppeteer.devices` 迁移到了 `KnownDevices`,这是面试中容易踩的坑。 ## 内置设备与 KnownDevices ### 使用内置设备预设 ```javascript const puppeteer = require('puppeteer'); const { KnownDevices } = require('puppeteer'); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); // v21+ 使用 KnownDevices,旧版用 puppeteer.devices const iPhone = KnownDevices['iPhone 12']; await page.emulate(iPhone); await page.goto('https://example.com'); await page.screenshot({ path: 'iphone12.png' }); await browser.close(); })(); ``` ### 常用内置设备 Puppeteer 内置了数十种设备预设,覆盖三大平台: | 平台 | 常用设备 | 典型视口 | |------|----------|----------| | iPhone | iPhone 12/13/14/15, iPhone SE, iPhone X | 375×667 ~ 390×844 | | iPad | iPad Pro, iPad Mini | 1024×1366, 768×1024 | | Android | Pixel 5, Pixel 4, Galaxy S5 | 393×815, 360×640 | 查看所有可用设备:`Object.keys(KnownDevices)` ### 单个设备的配置结构 ```javascript { name: 'iPhone 12', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 ...)', viewport: { width: 390, height: 844, deviceScaleFactor: 3, // Retina 屏幕像素比 isMobile: true, // 启用移动端行为 hasTouch: true, // 启用触摸事件 isLandscape: false } } ``` `isMobile: true` 会影响媒体查询 `@media (pointer: coarse)` 和部分 CSS 行为,不仅是视口大小的变化。 ## 自定义设备配置 当内置预设不满足需求时(比如测试未上市的新机型),可以手动构造设备描述符: ```javascript const customDevice = { name: 'Custom Foldable', userAgent: 'Mozilla/5.0 (Linux; Android 13; Foldable) ...', viewport: { width: 320, // 折叠态宽度 height: 820, deviceScaleFactor: 3, isMobile: true, hasTouch: true, isLandscape: false } }; await page.emulate(customDevice); ``` 也可以单独设置视口,而不使用完整的 `emulate()`: ```javascript await page.setViewport({ width: 375, height: 812, deviceScaleFactor: 3, isMobile: true, hasTouch: true }); ``` 单独设置用户代理: ```javascript await page.setUserAgent( 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) ...' ); ``` `emulate()` 与分开设置的区别:`emulate()` 是原子操作,保证视口和 UA 同时生效;分开设置可能在两次调用之间页面触发重排,导致布局闪烁。 ## 地理位置与权限模拟 移动端测试经常需要模拟位置信息: ```javascript const browser = await puppeteer.launch(); const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); // 授予地理定位权限(必须在导航前设置) await context.overridePermissions('https://example.com', ['geolocation']); // 设置坐标——东京涩谷 await page.setGeolocation({ latitude: 35.6580, longitude: 139.7016 }); await page.goto('https://example.com'); ``` 权限列表还包括 `'notifications'`、`'camera'`、`'microphone'` 等,对应移动端常见的权限弹窗场景。 ## 网络与 CPU 节流 这是移动端测试的核心但常被忽略的环节。真实移动网络的延迟和带宽与桌面完全不同。 ### 网络节流 ```javascript const client = await page.createCDPSession(); // 模拟 3G 网络 await client.send('Network.emulateNetworkConditions', { offline: false, latency: 200, // 往返延迟 200ms downloadThroughput: 750 * 1024 / 8, // 750kbps uploadThroughput: 250 * 1024 / 8, // 250kbps }); // 模拟离线 await client.send('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1, }); ``` ### CPU 节流 ```javascript // CPU 减速 4 倍,模拟移动端性能 await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); ``` 结合网络节流和 CPU 节流,才能真实还原移动端用户的使用体验。仅模拟视口大小而忽略网络和性能条件,测试结果往往偏乐观。 ## 时区与语言环境 国际化测试需要模拟不同地区的时区和语言: ```javascript // 设置时区 await page.emulateTimezone('Asia/Tokyo'); // 设置语言偏好 await page.setExtraHTTPHeaders({ 'Accept-Language': 'ja-JP,ja;q=0.9,en;q=0.8' }); ``` ## 触摸事件 移动端的核心交互是触摸而非鼠标点击: ```javascript await page.emulate(KnownDevices['iPhone 12']); // 触摸点击元素 await page.tap('#submit-btn'); // 底层触摸 API:精确坐标点击 await page.touchscreen.tap(200, 400); // 模拟滑动——从 (100, 500) 滑动到 (100, 200) await page.mouse.move(100, 500); await page.mouse.down(); await page.mouse.move(100, 200, { steps: 10 }); await page.mouse.up(); ``` `page.tap()` 内部会触发 `touchstart → touchend → click` 事件序列,与真实触屏行为一致。如果用 `page.click()` 在移动模拟下测试,部分依赖 touch 事件的组件不会响应。 ## 实战:响应式设计批量测试 将上述能力组合成一个实用的测试流程: ```javascript const { KnownDevices } = require('puppeteer'); const puppeteer = require('puppeteer'); async function testResponsive(url) { const browser = await puppeteer.launch(); const results = []; const profiles = [ { label: 'Desktop', viewport: { width: 1440, height: 900, isMobile: false, hasTouch: false } }, { label: 'Tablet', device: KnownDevices['iPad Pro'] }, { label: 'Mobile', device: KnownDevices['iPhone 12'] }, ]; for (const p of profiles) { const context = await browser.createIncognitoBrowserContext(); const page = await context.newPage(); if (p.device) { await page.emulate(p.device); } else { await page.setViewport(p.viewport); } // 添加 3G 网络节流 const client = await page.createCDPSession(); await client.send('Network.emulateNetworkConditions', { offline: false, latency: 150, downloadThroughput: 750 * 1024 / 8, uploadThroughput: 250 * 1024 / 8, }); await page.goto(url, { waitUntil: 'networkidle2' }); // 检测布局溢出 const overflow = await page.evaluate(() => { return document.documentElement.scrollWidth > document.documentElement.clientWidth; }); // 检测首屏加载时间 const timing = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; return nav ? nav.loadEventEnd - nav.startTime : -1; }); results.push({ profile: p.label, overflow, loadTime: timing }); await context.close(); } await browser.close(); return results; } ``` 关键设计:使用 `IncognitoBrowserContext` 隔离每个设备的 Cookie 和缓存,避免状态污染;加入网络节流让性能数据更真实。 ## 常见陷阱 **1. `puppeteer.devices` 已废弃** Puppeteer v21+ 必须使用 `KnownDevices`,旧写法会直接报错。 **2. `emulate()` 后切换页面不生效** `page.emulate()` 只对当前 page 生效。如果通过 `browser.newPage()` 创建新页面,需要重新调用 `emulate()`。建议使用 Browser Context 级别的设置来统一管理。 **3. 地理位置权限时机** `overridePermissions` 必须在 `page.goto()` 之前调用,否则页面会先收到定位拒绝,再获得权限也不会自动重新请求。 **4. 触摸事件与 click 的差异** 在 `isMobile: true` 模式下,部分框架(如 React 的 onClick)会响应 click,但原生 `touchstart` 监听器不会触发。测试时优先用 `page.tap()` 而非 `page.click()`。 **5. 横屏模式** 内置设备默认是竖屏。测试横屏需要手动设置 `isLandscape: true` 或交换 width/height,`emulate()` 不会自动旋转。 ## 追问:Puppeteer 与 Playwright 的设备模拟有何差异? Playwright 内置了类似的设备模拟,但 API 更简洁: ```javascript // Playwright 写法 const iphone = devices['iPhone 12']; const browser = await chromium.launch(); const context = await browser.newContext({ ...iphone }); // 设备配置在 context 级别生效,所有 page 自动继承 ``` 关键区别:Playwright 在 BrowserContext 级别设置设备,所有页面自动继承;Puppeteer 在 Page 级别设置,需要每个页面单独配置。Playwright 还内置了网络节流 API(`context.route()`),无需 CDP Session。
前端5月28日 07:15
Puppeteer 如何管理 Cookie 与存储实现会话持久化?## 核心回答 Puppeteer 通过 `page.cookies()` / `page.setCookie()` 管理 Cookie,通过 `page.evaluate()` 操作 LocalStorage、SessionStorage 和 IndexedDB,结合 `userDataDir` 或手动序列化实现会话持久化,利用 `browser.createIncognitoBrowserContext()` 实现多账户隔离。 三种会话持久化方案的对比: | 方案 | 适用场景 | 优点 | 缺点 | |------|---------|------|------| | userDataDir | 长期保持登录态 | 最简单,自动持久化所有数据 | 数据量大,不易清理 | | 手动序列化 Cookie + Storage | 精确控制需要持久化的数据 | 灵活可控,文件小 | 需要手动处理每种存储 | | Incognito Context + 手动保存 | 多账户并行 | 完全隔离,互不干扰 | 上下文关闭后数据丢失 | ## Cookie 管理 ### 读取与设置 Cookie Puppeteer 提供了简洁的 Cookie API: ```javascript // 获取当前页面所有 Cookie const cookies = await page.cookies(); // 获取指定 URL 的 Cookie(可跨域获取第三方 Cookie) const cookies = await page.cookies('https://api.example.com'); // 设置单个 Cookie await page.setCookie({ name: 'session_id', value: 'abc123', domain: '.example.com', path: '/', expires: Math.floor(Date.now() / 1000) + 3600, httpOnly: true, secure: true, sameSite: 'Lax' }); // 批量设置 await page.setCookie( { name: 'token', value: 'xxx', domain: '.example.com' }, { name: 'lang', value: 'zh', domain: '.example.com' } ); ``` ### 删除与清除 Cookie ```javascript // 删除指定 Cookie(需匹配 name 和 domain) await page.deleteCookie({ name: 'session_id', domain: '.example.com' }); // 清除所有 Cookie const allCookies = await page.cookies(); await page.deleteCookie(...allCookies); ``` ### Cookie 的 SameSite 策略 Chrome 80+ 默认将未声明 SameSite 的 Cookie 视为 `Lax`,这对跨域场景影响显著: - **Strict**:仅同站请求携带,最安全但体验差(从外部链接跳入不带 Cookie) - **Lax**:同站请求 + 顶级导航的 GET 请求携带(默认值) - **None**:跨站也携带,但必须同时设置 `secure: true` 在 Puppeteer 中模拟跨站场景时,如果目标站点依赖第三方 Cookie,需要显式设置 `sameSite: 'None'` 并确保 `secure: true`,否则请求可能不带 Cookie 导致鉴权失败。 ## 浏览器存储管理 ### LocalStorage LocalStorage 以键值对形式持久化数据,同源共享,无过期时间: ```javascript // 读取全部 const lsData = await page.evaluate(() => { return Object.fromEntries( Array.from({ length: localStorage.length }, (_, i) => { const key = localStorage.key(i); return [key, localStorage.getItem(key)]; }) ); }); // 写入 await page.evaluate(() => { localStorage.setItem('user_id', '12345'); localStorage.setItem('prefs', JSON.stringify({ theme: 'dark' })); }); // 删除指定项 / 清空 await page.evaluate(() => localStorage.removeItem('user_id')); await page.evaluate(() => localStorage.clear()); ``` ### SessionStorage SessionStorage 与 LocalStorage API 相同,但数据仅在当前标签页生命周期内有效,关闭标签页即清除: ```javascript await page.evaluate(() => { sessionStorage.setItem('temp_key', 'temp_value'); }); const data = await page.evaluate(() => { return Object.fromEntries( Array.from({ length: sessionStorage.length }, (_, i) => { const key = sessionStorage.key(i); return [key, sessionStorage.getItem(key)]; }) ); }); ``` ### IndexedDB IndexedDB 适合存储结构化数据,操作较为复杂,Puppeteer 中需要通过 `page.evaluate` 异步操作: ```javascript const dbData = await page.evaluate(async () => { return new Promise((resolve, reject) => { const req = indexedDB.open('myDB', 1); req.onsuccess = (e) => { const db = e.target.result; const tx = db.transaction(['store1'], 'readonly'); const store = tx.objectStore('store1'); const getAll = store.getAll(); getAll.onsuccess = () => resolve(getAll.result); getAll.onerror = () => reject(getAll.error); }; req.onerror = () => reject(req.error); }); }); ``` ## 会话持久化 ### 方案一:userDataDir(推荐用于长期持久化) 启动浏览器时指定 `userDataDir`,Chrome 会将 Cookie、LocalStorage、SessionStorage、IndexedDB 等所有用户数据保存到该目录,下次启动自动恢复: ```javascript const browser = await puppeteer.launch({ userDataDir: './user_data/session1' // 指定持久化目录 }); const page = await browser.newPage(); await page.goto('https://example.com'); // 所有登录状态、Cookie、Storage 自动持久化到磁盘 ``` 这种方式最简单,但要注意:目录会随使用逐渐增大,长期运行需要定期清理。 ### 方案二:手动序列化 Cookie + Storage 当只需要保存部分数据时,手动序列化更精确: ```javascript async function saveSession(page, filePath) { const fs = require('fs'); const cookies = await page.cookies(); const localStorage = await page.evaluate(() => { return Object.fromEntries( Array.from({ length: localStorage.length }, (_, i) => { const key = localStorage.key(i); return [key, localStorage.getItem(key)]; }) ); }); fs.writeFileSync(filePath, JSON.stringify({ cookies, localStorage })); } async function restoreSession(page, filePath) { const fs = require('fs'); const { cookies, localStorage } = JSON.parse(fs.readFileSync(filePath, 'utf8')); // 先设置 Cookie,再导航(确保域名匹配) await page.setCookie(...cookies); await page.evaluate((data) => { for (const [k, v] of Object.entries(data)) { localStorage.setItem(k, v); } }, localStorage); } ``` 恢复顺序很重要:先 `setCookie` 再导航到目标页面,这样页面加载时就能携带正确的 Cookie。 ## 多账户管理 ### 使用 Incognito Browser Context 每个 Incognito 上下文拥有独立的 Cookie 和 Storage,互不干扰: ```javascript const browser = await puppeteer.launch(); // 账户 A const ctxA = await browser.createIncognitoBrowserContext(); const pageA = await ctxA.newPage(); await pageA.goto('https://example.com/login'); // ... 登录账户 A // 账户 B(完全隔离) const ctxB = await browser.createIncognitoBrowserContext(); const pageB = await ctxB.newPage(); await pageB.goto('https://example.com/login'); // ... 登录账户 B // 操作完成后关闭上下文 await ctxA.close(); await ctxB.close(); ``` ### 多账户持久化方案 如果需要在不同运行间恢复多个账户的会话,可以结合 `userDataDir` 和手动序列化: ```javascript async function loginAndSave(account, sessionDir) { const browser = await puppeteer.launch({ userDataDir: sessionDir // 每个账户独立目录 }); const page = await browser.newPage(); await page.goto('https://example.com/login'); await page.type('#username', account.username); await page.type('#password', account.password); await page.click('#login-button'); await page.waitForNavigation(); await browser.close(); // 数据自动保存到 sessionDir } ``` ## 安全注意事项 - **敏感数据保护**:不要在代码中硬编码密码,使用环境变量 `process.env.PASSWORD`;将包含会话信息的文件加入 `.gitignore` - **Cookie 安全属性**:设置 `httpOnly: true` 防 XSS、`secure: true` 限 HTTPS 传输、`sameSite: 'Strict'` 防 CSRF - **会话过期处理**:检查 Cookie 的 `expires` 字段,过期后重新登录,避免用失效会话发请求 - **第三方 Cookie 限制**:Chrome 逐步限制第三方 Cookie,跨域场景需使用 `sameSite: 'None'` 并配合 `secure: true` ## 追问 **Q: userDataDir 和手动序列化如何选择?** 简单场景(只需保持登录)用 userDataDir,复杂场景(需要跨环境迁移、选择性恢复)用手动序列化。手动序列化的优势在于文件小、可审计、可跨机器使用;userDataDir 的优势是零配置、自动覆盖所有存储类型。 **Q: 如何检测 Cookie 是否生效?** 设置 Cookie 后,通过 `page.cookies(url)` 验证返回的 Cookie 列表中是否包含目标项,或在导航后检查页面行为(如是否仍处于登录态)。注意 Cookie 的 domain 和 path 必须匹配目标 URL,否则不会被发送。 **Q: Incognito Context 和 CDP Session 有什么区别?** Incognito Context 是浏览器层面的隔离,拥有独立的 Cookie 和 Storage;CDP Session 是 DevTools 协议层面的隔离,允许多个客户端独立与页面交互但不隔离存储数据。多账户场景应使用 Incognito Context。
前端5月28日 07:12
Puppeteer 如何实现页面交互和表单操作?Puppeteer 是 Google 维护的 Node.js 浏览器自动化库,通过 DevTools 协议控制 Chrome/Chromium,核心能力就是模拟用户在页面上的真实操作——导航、点击、输入、拖拽、截图等。前端面试中,Puppeteer 的页面交互与表单操作是高频考点,本文将系统梳理常用 API 和实际场景中的最佳实践。 ## 页面导航与基础操作 Puppeteer 的一切操作都围绕 `page` 对象展开。最基础的交互就是导航: ```javascript const browser = await puppeteer.launch(); const page = await browser.newPage(); // 基本导航 await page.goto('https://example.com'); // 等待网络空闲后再继续,适合需要等待异步资源的页面 await page.goto('https://example.com', { waitUntil: 'networkidle2' }); // 设置超时避免无限等待 await page.goto('https://example.com', { timeout: 30000 }); ``` `waitUntil` 参数常用取值:`load`(默认,window.onload 触发)、`domcontentloaded`(DOM 解析完成)、`networkidle0`(500ms 内无网络请求)、`networkidle2`(500ms 内不超过2个网络请求)。实际项目中 `networkidle2` 最常用,因为它能容忍长连接(如 WebSocket)同时确保页面主体资源加载完成。 页面刷新、前进后退: ```javascript await page.reload({ waitUntil: 'networkidle2' }); await page.goBack(); await page.goForward(); ``` ## 元素选择与定位 Puppeteer 提供三种选择器策略:CSS 选择器、XPath 和文本选择器。 **CSS 选择器**(最常用): ```javascript // 选择单个元素 const el = await page.$('#submit-btn'); // 选择多个元素 const items = await page.$$('.list-item'); // 批量获取数据(比逐个 evaluate 更高效) const texts = await page.$$eval('.item', els => els.map(e => e.textContent)); ``` **XPath**(适合按文本内容定位): ```javascript const [el] = await page.$x('//button[contains(text(), "提交")]'); ``` **文本选择器**(Puppeteer 较新版本支持): ```javascript await page.click('text/登录'); ``` 面试中常问:`page.$` 和 `page.evaluate(querySelector)` 的区别?前者返回 `ElementHandle` 对象(可继续调用 Puppeteer API),后者直接在浏览器上下文执行并返回序列化结果。理解这个区别是正确使用 Puppeteer 的关键。 ## 点击与输入操作 点击和输入是最核心的交互 API,也是面试必考项。 **点击操作**: ```javascript // 基本点击 await page.click('#button'); // 右键、双击 await page.click('#btn', { button: 'right' }); await page.click('#btn', { clickCount: 2 }); // 点击延迟,模拟真实用户 await page.click('#btn', { delay: 100 }); ``` **文本输入**: ```javascript // 逐字符输入,触发 keydown/keypress/keyup 事件 await page.type('#search', 'Puppeteer', { delay: 50 }); // 输入前先清空(v21.1+ 支持 clear 选项) await page.type('#input', 'new text', { clear: true }); // 旧版本清空方式 await page.click('#input', { clickCount: 3 }); await page.keyboard.press('Backspace'); ``` **面试追问:`page.type` 和 `page.evaluate` 直接设置 value 有什么区别?** `page.type` 逐字符输入,会触发完整的键盘事件链(keydown → keypress → input → keyup),对依赖事件监听的框架(React、Vue)有效;直接设置 `input.value` 不触发事件,可能导致框架状态不同步。所以表单自动化场景应优先使用 `page.type`。 ## 键盘与鼠标高级操作 当 CSS 选择器无法定位元素时,键盘和鼠标 API 是重要补充。 **键盘组合键**: ```javascript // Ctrl+A 全选 await page.keyboard.down('Control'); await page.keyboard.press('A'); await page.keyboard.up('Control'); // 常用按键 await page.keyboard.press('Enter'); await page.keyboard.press('Escape'); await page.keyboard.press('Tab'); ``` **鼠标拖拽与精确操作**: ```javascript // 拖拽操作 await page.mouse.move(100, 100); await page.mouse.down(); await page.mouse.move(300, 300, { steps: 10 }); // 平滑移动 await page.mouse.up(); // 滚轮 await page.mouse.wheel({ deltaY: 300 }); ``` 鼠标 API 的坐标是相对视口左上角的 CSS 像素,`steps` 参数控制中间插值点数,值越大移动越平滑,在需要模拟真实用户行为(避免被反爬检测)时很有用。 ## 表单操作全场景 面试中表单操作是重点,需要掌握各种控件类型的处理方式。 **文本与文本域**: ```javascript await page.type('#username', 'admin'); await page.type('#bio', '前端工程师'); ``` **下拉选择框**: ```javascript // 单选 await page.select('#country', 'CN'); // 多选 await page.select('#languages', ['zh', 'en']); // 获取当前选中值 const value = await page.$eval('#country', el => el.value); ``` **复选框与单选框**: ```javascript // 复选框——先检查再点击,避免取消选中 const checked = await page.$eval('#agree', el => el.checked); if (!checked) await page.click('#agree'); // 单选框 await page.click('input[value="male"]'); ``` **文件上传**: ```javascript // 单文件 await page.setInputFiles('#avatar', '/path/to/photo.jpg'); // 多文件 await page.setInputFiles('#docs', ['/path/to/a.pdf', '/path/to/b.pdf']); // 移除已选文件 await page.setInputFiles('#avatar', []); ``` **表单提交**: ```javascript // 方式一:点击提交按钮(最常用) await Promise.all([ page.waitForNavigation(), page.click('#submit') ]); // 方式二:通过 JavaScript 提交 await page.$eval('form', form => form.submit()); // 方式三:回车提交 await page.keyboard.press('Enter'); ``` 注意提交时用 `Promise.all` 包裹 `waitForNavigation` 和点击操作,否则导航可能在等待之前就完成了,导致后续操作失败。这是面试中经常考察的细节。 ## 等待策略 等待策略直接决定自动化脚本的稳定性,也是面试高频考点。 ```javascript // 等待元素出现 await page.waitForSelector('.result', { visible: true }); // 等待元素消失(如 loading 遮罩) await page.waitForSelector('.loading', { hidden: true }); // 等待 XPath await page.waitForXPath('//div[contains(@class, "result")]'); // 等待自定义条件 await page.waitForFunction(() => { return document.querySelectorAll('.item').length >= 10; }); // 等待特定请求完成 await page.waitForResponse(resp => resp.url().includes('/api/data')); ``` **面试追问:为什么不能直接用 `setTimeout` 代替 `waitForSelector`?** `setTimeout` 是固定等待,太短会失败、太长浪费时间;`waitForSelector` 是条件等待,元素出现立即继续,兼顾可靠性和效率。在实际项目中,硬编码等待时间是脚本不稳定的常见原因。 ## iframe 与弹窗处理 这两个场景在实际项目中非常常见,但很多开发者容易忽略。 **iframe 内操作**: ```javascript // 获取 iframe 的 frame 对象 const frame = await page.frames().find(f => f.name() === 'myiframe'); // 也可以通过选择器获取 const frameEl = await page.$('iframe'); const frame = await frameEl.contentFrame(); // 在 iframe 内操作,API 与 page 相同 await frame.type('#input', 'hello'); await frame.click('#btn'); ``` **Dialog 弹窗**: ```javascript // 监听并自动处理 alert/confirm/prompt page.on('dialog', async dialog => { console.log(dialog.type(), dialog.message()); await dialog.accept(); // 或 dialog.dismiss() }); // prompt 输入值 page.on('dialog', async dialog => { await dialog.accept('my input'); }); ``` Puppeteer 默认会自动 dismiss dialog,如果不手动监听处理,所有 confirm/prompt 都会被取消,导致表单提交行为异常。 ## 网络拦截与 Cookie 管理 这两个能力让 Puppeteer 不仅能操作页面,还能控制网络层和状态管理。 **请求拦截**(性能优化与 Mock 数据): ```javascript await page.setRequestInterception(true); page.on('request', request => { // 屏蔽图片和字体,加速页面加载 if (['image', 'font', 'media'].includes(request.resourceType())) { request.abort(); } // Mock API 响应 else if (request.url().includes('/api/user')) { request.respond({ status: 200, contentType: 'application/json', body: JSON.stringify({ name: 'mock user' }) }); } else { request.continue(); } }); ``` **Cookie 操作**: ```javascript // 设置 Cookie(常用于免登录) await page.setCookie({ name: 'token', value: 'abc123', domain: 'example.com' }); // 获取所有 Cookie const cookies = await page.cookies(); // 删除 Cookie await page.deleteCookie({ name: 'token' }); ``` Cookie 管理在爬虫和自动化测试中非常实用——通过预设登录态 Cookie 可以跳过登录流程,大幅简化脚本。 ## 设备模拟与截图 **设备模拟**(移动端测试必备): ```javascript const iPhone = puppeteer.devices['iPhone 13']; await page.emulate(iPhone); // 单独设置视口 await page.setViewport({ width: 375, height: 812, isMobile: true }); // 模拟地理位置 await page.setGeolocation({ latitude: 39.9, longitude: 116.4 }); ``` **截图与 PDF**: ```javascript // 页面截图 await page.screenshot({ path: 'home.png', fullPage: true }); // 指定元素截图 const el = await page.$('.chart'); await el.screenshot({ path: 'chart.png' }); // 生成 PDF await page.pdf({ path: 'report.pdf', format: 'A4' }); ``` ## 实际应用场景 **场景一:登录流程自动化**: ```javascript async function login(url, username, password) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url); await page.type('#username', username); await page.type('#password', password); // 提交并等待导航完成 await Promise.all([ page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#login-btn') ]); // 验证登录成功 const success = await page.$('.user-avatar') !== null; await browser.close(); return success; } ``` **场景二:滚动加载与数据采集**: ```javascript async function scrapeInfiniteScroll(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); let prevHeight = 0; while (true) { const currHeight = await page.evaluate(() => document.body.scrollHeight); if (currHeight === prevHeight) break; prevHeight = currHeight; await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(1500); } const items = await page.$$eval('.item', els => els.map(e => ({ title: e.querySelector('.title')?.textContent, url: e.querySelector('a')?.href })) ); await browser.close(); return items; } ``` **场景三:网络 Mock 与接口测试**: ```javascript async function testWithMock(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.setRequestInterception(true); page.on('request', req => { if (req.url().includes('/api/config')) { req.respond({ status: 200, contentType: 'application/json', body: '{"theme":"dark"}' }); } else { req.continue(); } }); await page.goto(url); const theme = await page.$eval('.theme-label', el => el.textContent); await browser.close(); return theme; } ``` ## 最佳实践总结 **1. 优先使用 Locator API**(Puppeteer v22+):自动等待、自动重试,比手动 waitForSelector + click 更可靠。 **2. 始终处理异步等待**:不要假设页面已经加载完成,显式等待目标元素或网络状态。 **3. 拦截无关资源**:测试和爬虫场景中屏蔽图片、字体、媒体,可显著提升速度。 **4. 资源释放**:`browser.close()` 放在 `finally` 块中确保执行,避免 Chromium 进程残留。 **5. 反检测意识**:使用 `puppeteer-extra` + `stealth` 插件规避反爬检测;模拟真实用户行为(随机延迟、自然鼠标轨迹);避免使用 WebDriver 等可被检测的标识。 **6. 错误重试机制**:网络波动和动态内容加载不可控,关键操作应有 try-catch 和重试逻辑。 **7. 与 Playwright 的选择**:新项目可考虑 Playwright,它由原 Puppeteer 团队打造,支持多浏览器、内置 auto-waiting、API 更现代。但 Puppeteer 生态更成熟、Chrome 支持最深,两者各有优势。 Puppeteer 的页面交互能力覆盖了从基础点击到网络拦截的完整链路。掌握核心 API(导航、选择器、输入、等待)、理解 `ElementHandle` 与浏览器上下文的区别、善用网络拦截和 Cookie 管理应对复杂场景,是面试和实际项目中的关键。面试回答时,先说核心 API 用法,再补充等待策略和最佳实践,最后提一下与 Playwright 的对比,基本就能覆盖大部分考察点。
前端5月28日 07:11
Puppeteer 如何进行错误处理和调试?Puppeteer 在浏览器自动化场景下,错误类型多、调试链路长,从脚本层到浏览器层再到网络层都可能出问题。掌握系统的错误处理策略和调试手段,是写出可靠自动化脚本的关键。 ## Puppeteer 常见的错误类型有哪些? Puppeteer 脚本运行时主要会遇到三类错误: **脚本层错误**——语法错误、逻辑错误,这类错误 Node.js 会直接抛出栈信息,属于常规调试范畴。 **协议层错误**——Puppeteer 通过 CDP(Chrome DevTools Protocol)与浏览器通信,协议调用失败时会抛出特定错误类: ```javascript const { TimeoutError } = require('puppeteer').errors; try { await page.waitForSelector('.dynamic-content', { timeout: 5000 }); } catch (error) { if (error instanceof TimeoutError) { console.error('等待元素超时,可能页面未加载完成'); } } ``` **浏览器层错误**——页面内的 JS 运行时错误、资源加载失败、网络请求异常,需要通过事件监听捕获: ```javascript // 捕获页面未处理的 JS 错误 page.on('pageerror', error => { console.error('[页面错误]', error.message); }); // 捕获资源加载失败 page.on('requestfailed', request => { console.error('[请求失败]', request.url(), request.failure().errorText); }); ``` ## 怎样构建健壮的错误处理机制? ### try-catch 配合 finally 管理生命周期 每个 Puppeteer 脚本都应确保浏览器实例被正确关闭,`finally` 块是关键: ```javascript async function runTask() { const browser = await puppeteer.launch(); const page = await browser.newPage(); try { await page.goto('https://example.com', { waitUntil: 'networkidle2' }); await page.click('#submit'); } catch (error) { // 区分超时和其他错误 if (error.name === 'TimeoutError') { console.error('操作超时:', error.message); } else { console.error('执行失败:', error.message); } // 出错时截图保存现场 await page.screenshot({ path: `error-${Date.now()}.png`, fullPage: true }); } finally { await browser.close(); } } ``` ### 重试策略处理临时性故障 网络波动、页面加载慢等临时性问题,适合用重试机制解决: ```javascript async function withRetry(fn, maxRetries = 3, delay = 2000) { for (let i = 0; i < maxRetries; i++) { try { return await fn(); } catch (error) { console.warn(`第 ${i + 1} 次尝试失败: ${error.message}`); if (i < maxRetries - 1) { await new Promise(r => setTimeout(r, delay * (i + 1))); } else { throw error; } } } } // 使用:自动重试页面导航 const page = await withRetry(() => browser.newPage().then(p => p.goto(url).then(() => p))); ``` ### 全局错误事件监听 在 page 级别设置错误监听,防止未捕获的异常导致脚本静默崩溃: ```javascript page.on('error', err => { console.error('[Page crash]', err.message); }); page.on('console', msg => { if (msg.type() === 'error') { console.error('[Console Error]', msg.text()); } }); ``` ## 有哪些实用的调试手段? ### headless: false + slowMo 可视化调试 最直接的方式是关掉无头模式,肉眼观察浏览器行为: ```javascript const browser = await puppeteer.launch({ headless: false, slowMo: 100, // 每步操作放慢 100ms devtools: true // 自动打开 DevTools }); ``` `slowMo` 的值根据脚本复杂度调整,一般 50-250ms 之间。值太大会导致超时,太小来不及观察。 ### DEBUG 环境变量追踪协议通信 Puppeteer 内部基于 CDP 协议通信,通过 `DEBUG` 环境变量可以看到所有协议交互: ```bash # 查看所有 Puppeteer 内部通信 DEBUG="puppeteer:*" node script.js # 只看 CDP 协议请求 DEBUG="puppeteer:protocol" node script.js # 只看 API 调用 DEBUG="puppeteer:api" node script.js ``` 这在排查"为什么操作没生效"时非常有效,能看到 Puppeteer 到底发送了什么指令、浏览器返回了什么。 ### 截图和 HTML 快照保留现场 在关键步骤截图,配合 HTML 快照,可以还原出错时的完整页面状态: ```javascript async function debugCheckpoint(page, name) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); await page.screenshot({ path: `debug-${name}-${ts}.png`, fullPage: true }); const html = await page.content(); require('fs').writeFileSync(`debug-${name}-${ts}.html`, html); console.log(`[调试快照] ${name} 已保存`); } // 在关键步骤之间插入 await debugCheckpoint(page, 'after-login'); await page.click('#next-step'); await debugCheckpoint(page, 'after-click'); ``` ### 网络请求拦截与监控 拦截和记录网络请求,能定位接口异常、资源加载失败等问题: ```javascript // 监控所有请求的状态 page.on('response', response => { if (response.status() >= 400) { console.warn(`[HTTP ${response.status()}] ${response.url()}`); } }); // 拦截并修改请求(模拟接口异常场景) await page.setRequestInterception(true); page.on('request', request => { if (request.url().includes('/api/user')) { // 模拟接口 500 错误 request.abort(); } else { request.continue(); } }); ``` ## 如何用 CDP Session 做高级调试? Puppeteer 提供的 API 覆盖了大部分场景,但有些高级调试功能需要直接使用 CDP Session: ```javascript // 创建 CDP 会话 const client = await page.target().createCDPSession(); // 性能指标采集 await client.send('Performance.enable'); const { metrics } = await client.send('Performance.getMetrics'); console.log('性能指标:', metrics.filter(m => m.name === 'FirstMeaningfulPaint')); // 追踪页面加载时间线 await page.tracing.start({ path: 'trace.json' }); await page.goto('https://example.com'); await page.tracing.stop(); // trace.json 可在 Chrome DevTools → Performance 面板中打开分析 // 模拟网络条件(测试弱网场景) await client.send('Network.emulateNetworkConditions', { offline: false, latency: 200, // 额外延迟 200ms downloadThroughput: 500 * 1024, // 下载 500KB/s uploadThroughput: 250 * 1024, // 上传 250KB/s }); ``` ## 面试高频追问:常见踩坑与解决方案 **导航超时怎么处理?** 默认超时 30 秒,可以用 `waitUntil` 参数降低等待条件,或针对性增加超时时间: ```javascript // 方案1:降低等待条件 await page.goto(url, { waitUntil: 'domcontentloaded' }); // 方案2:单独设置超时 await page.goto(url, { timeout: 60000, waitUntil: 'networkidle2' }); // 方案3:手动等待关键元素 await page.goto(url, { waitUntil: 'domcontentloaded' }); await page.waitForSelector('.main-content'); ``` **元素找不到或不可点击怎么办?** 大部分情况是元素还没渲染完成或被遮挡,按以下顺序排查: ```javascript // 1. 确认元素存在 const element = await page.$(selector); if (!element) throw new Error(`元素不存在: ${selector}`); // 2. 确认元素可见 const visible = await element.isIntersectingViewport(); if (!visible) { await element.scrollIntoView(); } // 3. 等待元素可交互 await page.waitForSelector(selector, { visible: true }); await element.click(); ``` **内存泄漏怎么排查?** Puppeteer 脚本中最常见的泄漏是浏览器实例未关闭和事件监听器未移除: ```javascript // 始终用 finally 保证关闭 let browser; try { browser = await puppeteer.launch(); const page = await browser.newPage(); // ... 操作 } finally { if (browser) await browser.close(); } // 长时间运行的脚本,用完后移除监听 const handler = msg => console.log(msg.text()); page.on('console', handler); // 用完后 page.off('console', handler); ``` **如何调试 headless 模式下的脚本?** headless 环境无法可视化,靠截图和日志定位: ```javascript // 开启详细日志 process.env.DEBUG = 'puppeteer:*'; // 在出错时自动保存完整上下文 page.on('pageerror', async error => { const debugInfo = { url: page.url(), error: error.message, html: await page.content().catch(() => '获取失败'), screenshot: await page.screenshot({ encoding: 'base64' }).catch(() => null) }; require('fs').writeFileSync('crash-debug.json', JSON.stringify(debugInfo, null, 2)); }); ``` 掌握以上错误处理和调试方法,可以在实际项目中快速定位 Puppeteer 脚本问题,写出更稳定的自动化流程。
前端5月28日 07:11
Puppeteer 如何使用 Chrome DevTools Protocol (CDP) 进行高级调试和性能分析?Puppeteer 通过 `page.target().createCDPSession()` 创建 CDP 会话,直接与 Chrome DevTools Protocol 通信,访问 Performance、Network、Runtime、DOM、HeapProfiler 等底层域,实现性能指标采集、网络请求拦截、运行时异常捕获、内存堆快照等高级调试能力。 ## CDP 会话的创建与基本用法 CDP 会话是所有操作的起点。每个 CDP 命令和事件监听都依赖这个会话对象: ```javascript const client = await page.target().createCDPSession(); ``` 创建会话后,需要显式启用对应的域才能使用该域的命令和事件。各域之间相互独立,未启用的域调用会报错: ```javascript await client.send('Performance.enable'); await client.send('Network.enable'); await client.send('Runtime.enable'); ``` 发送命令使用 `client.send(method, params)`,监听事件使用 `client.on(event, handler)`。这两个方法覆盖了 CDP 的全部能力,Puppeteer 高层 API 未暴露的功能都可以通过它们实现。 一个关键细节:多个 CDP 会话可以同时存在,但同一个域在不同会话中重复启用不会出错,只是会增加开销。最佳做法是复用同一个 client 实例,按需启用和禁用域: ```javascript try { await client.send('Performance.enable'); const { metrics } = await client.send('Performance.getMetrics'); // 处理指标数据... } finally { await client.send('Performance.disable'); } ``` ## 性能指标采集与追踪 ### 获取 Performance Metrics 启用 `Performance` 域后,可以获取浏览器内部的性能指标。这些指标与 Chrome DevTools Performance 面板中的数据一致: ```javascript await client.send('Performance.enable'); const { metrics } = await client.send('Performance.getMetrics'); const map = Object.fromEntries(metrics.map(m => [m.name, m.value])); ``` 核心指标含义: - `LayoutDuration` — 布局耗时(秒),频繁变动说明存在布局抖动 - `RecalcStyleDuration` — 样式重算耗时,CSS 选择器复杂或 DOM 节点过多时会升高 - `ScriptDuration` — JS 执行耗时,异常升高通常指向长任务或主线程阻塞 - `TaskDuration` — 总任务耗时,包含微任务 - `JSEventListeners` — 当前注册的事件监听器数量,持续增长预示内存泄漏 - `Nodes` — DOM 节点数,超过 1500 会影响渲染性能 这些指标的解读需要结合场景。单独一个指标偏高不一定是问题——比如 `ScriptDuration` 高可能只是因为页面有大量业务逻辑。关键是在相同场景下对比变化趋势,或者在性能优化前后做对照。 ### 性能追踪(Tracing) `Tracing` 域能生成与 Chrome DevTools 相同格式的 trace 文件,可在 `chrome://tracing` 中可视化分析: ```javascript await client.send('Tracing.start', { traceConfig: { includedCategories: ['devtools.timeline', 'blink.user_timing', 'v8.execute'] } }); await page.goto('https://example.com'); const { value: traceData } = await client.send('Tracing.end'); ``` 追踪数据可以写入文件后用 DevTools 的 Performance 面板加载,精确定位函数调用耗时和渲染瓶颈。需要注意的是,`Tracing.end` 返回的数据量可能很大(几十 MB),处理时要留意内存占用。 ### 监控长任务与布局偏移 CDP 的 `Performance.metrics` 事件会持续推送指标变化,可用于实时监控: ```javascript client.on('Performance.metrics', ({ metrics }) => { const map = Object.fromEntries(metrics.map(m => [m.name, m.value])); if (map.TaskDuration > 50) { console.warn('长任务:', map.TaskDuration, 'ms'); } }); ``` 布局偏移(CLS)无法直接从 `Performance.getMetrics` 获取,需要通过 `PerformanceObserver` 在页面内注入监听,或使用 `Tracing` 追踪 `LayoutShift` 事件。Puppeteer 中注入页面脚本的做法: ```javascript const cls = await page.evaluate(() => { return new Promise(resolve => { let cumulativeShift = 0; new PerformanceObserver(list => { for (const entry of list.getEntries()) { if (!entry.hadRecentInput) cumulativeShift += entry.value; } }).observe({ type: 'layout-shift', buffered: true }); setTimeout(() => resolve(cumulativeShift), 5000); }); }); ``` ## 网络请求监控与拦截 ### 请求与响应监控 启用 `Network` 域后,可以监听完整的请求生命周期: ```javascript await client.send('Network.enable'); client.on('Network.requestWillBeSent', ({ requestId, request, type }) => { console.log(`→ ${request.method} ${request.url} [${type}]`); }); client.on('Network.responseReceived', ({ requestId, response }) => { console.log(`← ${response.status} ${response.url} ${response.mimeType}`); }); ``` 获取响应体需要在响应完成后单独请求——这是初学者常踩的坑: ```javascript client.on('Network.loadingFinished', async ({ requestId }) => { const { body, base64Encoded } = await client.send( 'Network.getResponseBody', { requestId } ); // body 为响应内容,base64Encoded 标识是否需要 atob 解码 }); ``` 注意:响应体必须在 `Network.loadingFinished` 事件后获取,不能在 `responseReceived` 时获取,此时数据可能尚未传输完毕。在 `responseReceived` 中调用 `getResponseBody` 会抛异常。 ### 请求拦截与修改 CDP 提供了 `Fetch` 域(不是 `Network` 域)来实现请求拦截,功能比 Puppeteer 的 `page.setRequestInterception` 更灵活: ```javascript await client.send('Fetch.enable', { patterns: [ { urlPattern: '*api.example.com*', requestStage: 'Request' } ] }); client.on('Fetch.requestPaused', async ({ requestId, request }) => { // 修改请求头 const headers = { ...request.headers, 'X-Custom': 'value' }; await client.send('Fetch.continueRequest', { requestId, headers: Object.entries(headers).map(([name, value]) => ({ name, value })) }); }); ``` `Fetch` 域支持在 Request 和 Response 阶段分别拦截,可以修改请求头、请求体、响应内容,甚至直接模拟响应。相比 Puppeteer 的 `setRequestInterception`,`Fetch` 域不会阻塞所有请求,只拦截匹配 pattern 的请求,性能更好。 ### 网络限速模拟 CDP 可以模拟不同的网络条件,测试弱网表现: ```javascript await client.send('Network.emulateNetworkConditions', { offline: false, latency: 100, // 额外延迟 ms downloadThroughput: 500 * 1024, // 下载速度 500KB/s uploadThroughput: 250 * 1024, // 上传速度 250KB/s }); ``` 结合 Performance 指标采集,可以量化不同网络条件下页面的性能退化程度。 ## 运行时调试与异常捕获 ### JavaScript 执行与控制台监听 `Runtime` 域提供了比 `page.evaluate` 更底层的执行能力: ```javascript await client.send('Runtime.enable'); // 执行表达式并获取返回值 const { result } = await client.send('Runtime.evaluate', { expression: 'document.querySelectorAll("div").length', returnByValue: true }); console.log('DIV 数量:', result.value); ``` `Runtime.evaluate` 和 `page.evaluate` 的区别在于:前者可以指定执行上下文(`contextId`)、超时时间(`timeout`)、是否 await Promise(`awaitPromise`),控制粒度更细。 控制台输出和异常通过事件获取: ```javascript client.on('Runtime.consoleAPICalled', ({ type, args }) => { const values = args.map(a => a.value ?? a.description).join(' '); console.log(`[Console.${type}]`, values); }); client.on('Runtime.exceptionThrown', ({ exceptionDetails }) => { const desc = exceptionDetails.exception?.description ?? exceptionDetails.text; console.error('运行时异常:', desc); }); ``` ### 调试器(Debugger) 启用 `Debugger` 域可以设置断点、单步执行,实现真正的源码级调试: ```javascript await client.send('Debugger.enable'); // 按 URL 和行号设置断点 await client.send('Debugger.setBreakpointByUrl', { urlRegex: 'app\\.js', lineNumber: 42 }); client.on('Debugger.paused', async ({ reason, callFrames }) => { const top = callFrames[0]; console.log(`断点命中: ${top.url}:${top.location.lineNumber}`); console.log('作用域变量:', top.scopeChain[0]?.object); await client.send('Debugger.resume'); }); ``` 这在排查生产环境偶发问题时非常有用——可以远程附加到运行中的浏览器实例,设置条件断点而不影响正常请求: ```javascript await client.send('Debugger.setBreakpointByUrl', { urlRegex: 'checkout\\.js', lineNumber: 100, condition: 'amount > 10000' // 只在金额大于 10000 时命中 }); ``` ## 内存分析与泄漏检测 ### 堆快照与内存使用 `HeapProfiler` 域能生成与 DevTools Memory 面板相同的堆快照: ```javascript await client.send('HeapProfiler.enable'); const { totalSize, usedSize } = await client.send('Runtime.getHeapUsage'); console.log(`堆内存: ${(usedSize / 1024 / 1024).toFixed(2)}MB / ${(totalSize / 1024 / 1024).toFixed(2)}MB`); ``` 对比两个时间点的堆快照可以定位泄漏对象: ```javascript // 第一次快照 await page.goto('https://example.com'); const { usedSize: used1 } = await client.send('Runtime.getHeapUsage'); // 执行操作(如反复打开/关闭弹窗) for (let i = 0; i < 10; i++) { await page.click('#open-modal'); await page.click('#close-modal'); } // 第二次快照 const { usedSize: used2 } = await client.send('Runtime.getHeapUsage'); const growth = ((used2 - used1) / 1024 / 1024).toFixed(2); console.log(`内存增长: ${growth}MB`); ``` 如果 `usedSize` 持续增长且不回落,基本可以确认存在内存泄漏。进一步可以用 DevTools 加载堆快照,通过"比较"视图查看新增对象。 ### 分配时间线(Allocation Timeline) `HeapProfiler` 还支持记录内存分配过程: ```javascript await client.send('HeapProfiler.startSampling', { samplingInterval: 32768 }); // ... 执行操作 ... const { profile } = await client.send('HeapProfiler.stopSampling'); ``` 采样数据可以导入 DevTools 的 Memory 面板,以时间线形式查看内存分配的热点函数。 ## DOM 与 CSS 监控 ### DOM 节点操作 `DOM` 域提供了脱离 `page.$()` 的底层 DOM 操作能力,可以查询节点、获取属性、修改属性: ```javascript await client.send('DOM.enable'); const { root } = await client.send('DOM.getDocument'); // 查询节点 const { nodeId } = await client.send('DOM.querySelector', { nodeId: root.nodeId, selector: '#main-content' }); // 获取属性 const { attributes } = await client.send('DOM.getAttributes', { nodeId }); // attributes 是扁平数组: [name1, value1, name2, value2, ...] const attrMap = {}; for (let i = 0; i < attributes.length; i += 2) { attrMap[attributes[i]] = attributes[i + 1]; } // 设置属性值 await client.send('DOM.setAttributeValue', { nodeId, name: 'data-loaded', value: 'true' }); ``` ### CSS 覆盖 `CSS` 域可以在不修改源文件的情况下覆盖页面样式,常用于测试不同视觉方案或排查样式问题: ```javascript await client.send('CSS.enable'); await client.send('DOM.enable'); // 获取节点的匹配样式规则 const { matchedCSSRules } = await client.send('CSS.getMatchedStylesForNode', { nodeId }); // 强制设置元素伪类状态(如 :hover) await client.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: ['hover'] }); ``` ## 代码覆盖率分析 `Profiler` 域可以采集 JS 和 CSS 的代码覆盖率,量化未使用代码的比例。这是优化首屏加载性能的重要手段: ```javascript await client.send('Profiler.enable'); await client.send('Profiler.startPreciseCoverage', { callCount: true, detailed: true }); await page.goto('https://example.com'); await page.click('#navigate'); const { result } = await client.send('Profiler.takePreciseCoverage'); result.forEach(script => { const total = script.functions.reduce((s, f) => s + f.ranges[0].endOffset - f.ranges[0].startOffset, 0); const used = script.functions .filter(f => f.ranges.some(r => r.count > 0)) .reduce((s, f) => s + f.ranges.filter(r => r.count > 0).reduce((ss, r) => ss + r.endOffset - r.startOffset, 0), 0); console.log(`${script.url}: 使用率 ${((used / total) * 100).toFixed(1)}%`); }); ``` Puppeteer 也提供了 `page.coverage` 高层 API(`page.coverage.startJSCoverage()`),但 CDP 方式能获取更细粒度的调用次数信息,且可以同时采集 JS 和 CSS 覆盖率。 ## Page 域与页面生命周期 `Page` 域提供页面级别的生命周期事件,用于监控导航、加载状态和资源树: ```javascript await client.send('Page.enable'); client.on('Page.loadEventFired', () => { console.log('页面 load 事件触发'); }); client.on('Page.frameNavigated', ({ frame }) => { console.log('导航至:', frame.url); }); client.on('Page.domContentEventFired', () => { console.log('DOM 解析完成'); }); // 获取页面资源树 const { frameTree } = await client.send('Page.getResourceTree'); console.log('主框架:', frameTree.frame.url); console.log('子框架数量:', frameTree.childFrames?.length ?? 0); ``` `Page` 域在多 iframe 场景下特别有用,可以追踪每个子框架的导航状态和资源加载情况。 ## 完整实战:自动化性能诊断工具 将上述能力组合起来,可以构建一个自动化的页面性能诊断工具: ```javascript const puppeteer = require('puppeteer'); async function diagnose(url) { const browser = await puppeteer.launch(); const page = await browser.newPage(); const client = await page.target().createCDPSession(); // 启用所有需要的域 await client.send('Performance.enable'); await client.send('Network.enable'); await client.send('Runtime.enable'); const report = { url, performance: {}, network: { requests: [], summary: {} }, errors: [], memory: {} }; // 1. 采集网络数据 client.on('Network.requestWillBeSent', ({ request, type }) => { report.network.requests.push({ url: request.url, method: request.method, type }); }); client.on('Network.responseReceived', ({ response }) => { if (response.status >= 400) { report.errors.push({ type: 'HTTP', status: response.status, url: response.url }); } }); // 2. 捕获运行时异常 client.on('Runtime.exceptionThrown', ({ exceptionDetails }) => { report.errors.push({ type: 'JS', message: exceptionDetails.exception?.description ?? exceptionDetails.text }); }); // 3. 加载页面 const start = Date.now(); await page.goto(url, { waitUntil: 'networkidle2' }); report.loadTime = Date.now() - start; // 4. 性能指标 const { metrics } = await client.send('Performance.getMetrics'); const map = Object.fromEntries(metrics.map(m => [m.name, m.value])); report.performance = { layoutDuration: map.LayoutDuration, scriptDuration: map.ScriptDuration, domNodes: map.Nodes, jsListeners: map.JSEventListeners }; // 5. 内存使用 const heap = await client.send('Runtime.getHeapUsage'); report.memory = { usedMB: (heap.usedSize / 1024 / 1024).toFixed(2), totalMB: (heap.totalSize / 1024 / 1024).toFixed(2) }; // 6. 网络汇总 report.network.summary = { total: report.network.requests.length, byType: report.network.requests.reduce((acc, r) => { acc[r.type] = (acc[r.type] || 0) + 1; return acc; }, {}) }; // 7. 诊断建议 report.recommendations = []; if (map.LayoutDuration > 0.5) { report.recommendations.push('布局耗时过长,检查是否存在强制同步布局或布局抖动'); } if (map.Nodes > 1500) { report.recommendations.push('DOM 节点数量偏多,考虑虚拟滚动或懒加载'); } if (map.JSEventListeners > 200) { report.recommendations.push('事件监听器数量异常,可能存在未清理的监听器导致内存泄漏'); } if (report.errors.length > 0) { report.recommendations.push(`发现 ${report.errors.length} 个错误,需优先修复`); } await client.send('Performance.disable'); await client.send('Network.disable'); await client.send('Runtime.disable'); await browser.close(); return report; } diagnose('https://example.com').then(r => console.log(JSON.stringify(r, null, 2))); ``` ## 最佳实践与注意事项 **及时禁用不再使用的域。** 每个启用的域都会产生事件流开销,尤其是 `Network` 和 `Runtime` 域数据量很大。用完后调用 `client.send('XXX.disable')` 释放资源。 **事件监听器需要手动清理。** `client.on()` 注册的监听器不会随页面导航自动移除,在循环场景下会导致重复监听。使用 `client.off()` 或 `client.removeAllListeners()` 清理: ```javascript const handler = ({ request }) => { /* ... */ }; client.on('Network.requestWillBeSent', handler); // 操作完成后 client.off('Network.requestWillBeSent', handler); ``` **CDP 命令可能抛异常。** 浏览器版本差异可能导致某些 CDP 方法不可用,务必 try-catch 包裹并做降级处理。 **避免在事件回调中发送 CDP 命令。** 这可能导致命令顺序错乱。应该将数据收集到队列中,在主流程中批量处理。 **多页面共享 CDP 会话不可行。** 每个 CDP 会话绑定到特定的页面目标,跨页面操作需要为每个页面创建独立的会话。对于多 Tab 场景,可以为每个 `browser.newPage()` 创建对应的 CDP 会话。 **生产环境慎用 `HeapProfiler.takeHeapSnapshot`。** 堆快照会暂停主线程,在用户访问期间执行会直接造成页面卡顿。建议在无头模式下的自动化测试中使用,或选择 `startSampling` 采样方式以减少性能影响。
前端5月28日 07:10
Puppeteer 如何处理动态网页和单页应用(SPA)?Puppeteer 在处理动态网页和单页应用(SPA)时拥有天然优势——它运行完整的 Chromium 浏览器,能够执行 JavaScript、等待异步加载完成、捕获路由变化,这些都是传统 HTTP 爬虫无法做到的。但真正写出健壮的 SPA 爬虫,关键在于选择正确的等待策略、合理拦截网络请求、以及处理各种边界情况。 ## 等待动态内容加载的正确方式 SPA 的核心特征是页面内容由 JavaScript 动态渲染,因此"等待"是 Puppeteer 爬虫的第一要务。三种等待策略各有适用场景: **waitForSelector — 等待元素出现** 最常用的等待方式,适合目标元素有明确选择器的场景: ```javascript const puppeteer = require('puppeteer'); async function scrapeDynamicContent() { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('https://example.com'); // visible: true 确保元素不仅存在于 DOM,而且可见 await page.waitForSelector('.dynamic-content', { visible: true }); const content = await page.$eval('.dynamic-content', el => el.textContent); await browser.close(); return content; } ``` **waitForFunction — 等待自定义条件** 当等待条件无法用单一选择器表达时,用 `waitForFunction` 编写判断逻辑: ```javascript // 等待列表项数量超过阈值 await page.waitForFunction(() => { return document.querySelectorAll('.item').length > 10; }); // 等待全局状态就绪 await page.waitForFunction(() => { return window.__APP_READY__ === true; }); ``` **waitUntil 选项 — 等待网络状态** `page.goto` 的 `waitUntil` 参数控制何时认为页面加载完成: - `domcontentloaded`:DOM 解析完成,不等样式和图片 - `load`:所有资源加载完毕 - `networkidle0`:500ms 内无网络请求(适合纯 API 驱动的页面) - `networkidle2`:500ms 内不超过 2 个网络请求(适合有长连接或分析脚本的页面) ```javascript // SPA 最常用的加载策略 await page.goto('https://example.com', { waitUntil: 'networkidle2' }); ``` ## 处理无限滚动与懒加载 无限滚动是 SPA 中最常见的加载模式,核心思路是循环滚动并检测新内容是否出现。 **基础版:检测页面高度变化** ```javascript async function scrapeInfiniteScroll(page, maxItems = 100) { const items = []; let previousHeight = 0; while (items.length < maxItems) { await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等待新内容渲染,优先等待选择器而非固定时间 try { await page.waitForSelector('.item:last-child', { timeout: 3000 }); } catch { break; // 超时说明没有更多内容 } const currentHeight = await page.evaluate(() => document.body.scrollHeight); if (currentHeight === previousHeight) break; previousHeight = currentHeight; const newItems = await page.$$eval('.item', els => els.map(el => el.textContent.trim()) ); items.push(...newItems); } return [...new Set(items)]; // 去重 } ``` **进阶版:等待加载指示器消失** 更可靠的方式是观察"加载中"指示器的出现和消失: ```javascript async function scrapeInfiniteScrollRobust(page) { const items = []; let noNewItemsCount = 0; while (noNewItemsCount < 3) { const countBefore = items.length; await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight); }); // 等加载指示器消失 try { await page.waitForSelector('.loading-spinner', { hidden: true, timeout: 5000 }); } catch { noNewItemsCount++; continue; } const currentItems = await page.$$eval('.item', els => els.map(el => ({ id: el.dataset.id, text: el.textContent.trim() })) ); // 只添加新项目 const existingIds = new Set(items.map(i => i.id)); const freshItems = currentItems.filter(i => !existingIds.has(i.id)); if (freshItems.length === 0) { noNewItemsCount++; } else { noNewItemsCount = 0; items.push(...freshItems); } } return items; } ``` ## 处理 SPA 路由变化 SPA 的路由切换不会触发页面刷新,URL 变了但浏览器不会重新加载,因此需要主动监听和等待。 **等待 URL 变化到目标路径** ```javascript async function waitForRoute(page, targetPath, timeout = 10000) { await page.waitForFunction( (path) => window.location.pathname === path, { timeout }, targetPath ); } // 使用 await page.click('#about-link'); await waitForRoute(page, '/about'); ``` **监听所有路由变化** 通过 `framenavigated` 事件捕获 SPA 内的导航: ```javascript page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { console.log('路由变化:', frame.url()); } }); // 触发导航 await page.click('#nav-link'); ``` **等待 SPA 渲染完成再提取数据** 路由切换后,新页面的 DOM 还没渲染出来,直接提取会拿到空数据: ```javascript async function navigateAndExtract(page, linkSelector, contentSelector) { await Promise.all([ page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click(linkSelector), ]); // 路由已切换,等待新内容渲染 await page.waitForSelector(contentSelector, { visible: true }); return page.$eval(contentSelector, el => el.textContent); } ``` ## 拦截和监控网络请求 掌握 SPA 的网络请求是高效爬取的关键——你可以直接拿到 API 返回的 JSON 数据,无需解析 DOM。 **等待特定 API 响应** ```javascript async function waitForAPIResponse(page, urlPattern) { return page.waitForResponse( response => response.url().includes(urlPattern) && response.status() === 200 ); } // 点击触发请求,同时等待响应 const [response] = await Promise.all([ waitForAPIResponse(page, '/api/data'), page.click('#load-data'), ]); const data = await response.json(); console.log(data); ``` **拦截请求:屏蔽不需要的资源** 减少不必要的网络请求能显著提升爬取速度: ```javascript await page.setRequestInterception(true); page.on('request', (request) => { const blockedTypes = ['image', 'font', 'media', 'stylesheet']; if (blockedTypes.includes(request.resourceType())) { request.abort(); } else { request.continue(); } }); ``` **修改请求:注入认证信息** ```javascript await page.setRequestInterception(true); page.on('request', (request) => { if (request.url().includes('/api/')) { request.continue({ headers: { ...request.headers(), 'Authorization': 'Bearer your-token', }, }); } else { request.continue(); } }); ``` **注意:** `setRequestInterception` 开启后,所有请求都必须手动调用 `continue()` 或 `abort()`,否则请求会挂起。 ## 处理 WebSocket 实时数据 SPA 中的实时功能(聊天、行情、通知)通常依赖 WebSocket,Puppeteer 可以通过 Chrome DevTools Protocol 监听 WebSocket 消息。 ```javascript const client = await page.createCDPSession(); await client.send('Network.enable'); // 接收 WebSocket 消息 client.on('Network.webSocketFrameReceived', (params) => { console.log('收到:', params.response.payloadData); }); // 发送 WebSocket 消息 client.on('Network.webSocketFrameSent', (params) => { console.log('发送:', params.response.payloadData); }); // WebSocket 关闭 client.on('Network.webSocketClosed', () => { console.log('WebSocket 连接已关闭'); }); ``` 这种方式适合监听实时推送数据,比轮询 DOM 更高效。 ## 处理 React/Vue 等 SPA 框架 不同框架的渲染机制略有差异,但核心思路一致:等待框架渲染完成标志。 **React 应用** ```javascript async function scrapeReactApp(page, url) { await page.goto(url, { waitUntil: 'networkidle2' }); // React 18+ 使用 createRoot,应用挂载到 root 节点 await page.waitForSelector('#root'); // 等待数据加载(如果框架暴露了全局状态) await page.waitForFunction(() => { return window.__INITIAL_STATE__?.loaded === true; }); // 或直接等待目标元素 await page.waitForSelector('[data-loaded="true"]'); return page.$$eval('.data-item', els => els.map(el => el.textContent.trim()) ); } ``` **Vue 应用** ```javascript async function scrapeVueApp(page, url) { await page.goto(url, { waitUntil: 'networkidle2' }); // Vue 3 应用挂载到 app 节点 await page.waitForSelector('#app'); // 等待 Vue 组件渲染 await page.waitForFunction(() => { return document.querySelector('.v-cloak') === null; }); return page.content(); } ``` **通用方案:等待 DOM 稳定** 如果无法判断框架类型,可以等待 DOM 变化趋于稳定: ```javascript async function waitForDOMStable(page, checkInterval = 500, stableThreshold = 3) { let lastHTML = ''; let stableCount = 0; while (stableCount < stableThreshold) { const currentHTML = await page.evaluate(() => document.body.innerHTML.length); if (currentHTML === lastHTML) { stableCount++; } else { stableCount = 0; lastHTML = currentHTML; } await new Promise(r => setTimeout(r, checkInterval)); } } ``` ## 实战场景:完整的 SPA 爬虫 把上述技巧组合起来,写一个能应对真实 SPA 的爬虫: ```javascript async function scrapeSPA(url) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); // 屏蔽无关资源,加速加载 await page.setRequestInterception(true); page.on('request', (req) => { ['image', 'font', 'media'].includes(req.resourceType()) ? req.abort() : req.continue(); }); await page.goto(url, { waitUntil: 'networkidle2' }); // 收集 API 数据(比解析 DOM 更可靠) const apiData = []; page.on('response', async (response) => { if (response.url().includes('/api/') && response.status() === 200) { try { apiData.push(await response.json()); } catch {} } }); // 处理无限滚动 const allItems = await scrapeInfiniteScroll(page, 50); await browser.close(); return { allItems, apiData }; } ``` ## 关键陷阱与应对 **1. waitForTimeout 已废弃** `page.waitForTimeout()` 在 Puppeteer 21+ 中已移除,用原生 `setTimeout` 替代: ```javascript // 旧写法(已废弃) await page.waitForTimeout(2000); // 新写法 await new Promise(resolve => setTimeout(resolve, 2000)); ``` **2. 超时与错误处理** SPA 加载时间不确定,所有等待操作都应设置超时并提供降级方案: ```javascript try { await page.waitForSelector('.content', { timeout: 10000 }); } catch { // 降级:尝试其他选择器或返回默认值 const content = await page.evaluate(() => document.querySelector('.fallback-content')?.textContent || '' ); } ``` **3. SPA 中的内存泄漏** 长时间运行的爬虫中,事件监听器会累积: ```javascript // 用完即移除 const handler = (response) => { /* ... */ }; page.on('response', handler); // 完成后 page.off('response', handler); ``` **4. 反爬虫检测** SPA 站点通常有更复杂的反爬机制: ```javascript // 伪装浏览器指纹 await page.setViewport({ width: 1920, height: 1080 }); await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' ); // 注入 stealth 插件 const StealthPlugin = require('puppeteer-extra-plugin-stealth'); const puppeteerExtra = require('puppeteer-extra'); puppeteerExtra.use(StealthPlugin()); ```
前端5月28日 07:08
Puppeteer 中有哪些等待机制?如何正确使用它们来处理异步操作?## 核心答案 Puppeteer 提供了 8 种等待机制,按场景分为四类: - **导航等待**:`waitForNavigation()` — 等待页面跳转完成 - **元素等待**:`waitForSelector()`、`waitForXPath()` — 等待 DOM 元素出现 - **网络等待**:`waitForResponse()`、`waitForRequest()` — 等待网络请求或响应 - **自定义等待**:`waitForFunction()`、`waitForFrame()`、`waitForTimeout()`(已废弃) 选择原则:导航操作用 `waitForNavigation`,元素操作用 `waitForSelector`,API 调试用 `waitForResponse`,复杂条件用 `waitForFunction`。永远不要用 `waitForTimeout` 做硬等待,它已被废弃。 **追问:waitForNavigation 和 click 为什么要用 Promise.all 包裹?** 因为 click 触发的导航是异步的,如果先 click 再 await waitForNavigation,导航可能在 click 返回前就已经完成了,导致 waitForNavigation 永远等不到。用 Promise.all 让两者同时开始监听,才能确保不丢失导航事件。 ## 导航等待:waitForNavigation `page.waitForNavigation()` 等待页面发生导航并完成加载,典型场景是点击链接、提交表单。 ```javascript // 正确写法:用 Promise.all 并行等待 await Promise.all([ page.waitForNavigation(), page.click('#submit-button') ]); ``` `waitUntil` 参数决定"加载完成"的标准,四个选项适用场景不同: | 选项 | 触发条件 | 适用场景 | |------|---------|---------| | `load` | window.onload 触发 | 静态页面 | | `domcontentloaded` | DOM 解析完毕 | 只需 DOM 不等资源 | | `networkidle0` | 500ms 内无网络请求 | SPA 应用,等全部接口返回 | | `networkidle2` | 500ms 内 ≤2 个网络请求 | 有长连接或轮询的页面 | `networkidle2` 是实际项目中最常用的选项。很多页面有 WebSocket 长连接或统计上报,用 `networkidle0` 会永远等不到空闲,`networkidle2` 允许最多 2 个连接,正好覆盖这种情况。 超时时间通过 `timeout` 参数设置,默认 30 秒。如果页面加载慢,可以调大: ```javascript await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 60000 }); ``` ## 元素等待:waitForSelector 与 waitForXPath ### waitForSelector `page.waitForSelector(selector)` 等待匹配选择器的元素出现在 DOM 中。这是最常用的等待方法,大多数场景下用它就够了。 ```javascript // 等待元素出现 await page.waitForSelector('.result-item'); // 等待元素可见(不仅存在于 DOM,还要有尺寸) await page.waitForSelector('.modal', { visible: true }); // 等待元素隐藏或消失 await page.waitForSelector('.loading-spinner', { hidden: true }); ``` `visible` 和 `hidden` 的区别需要留意:不用这两个选项时,只要元素在 DOM 中就算满足条件,哪怕 `display: none`。加了 `visible: true` 才要求元素实际可见(有非零尺寸)。 ### waitForXPath `page.waitForXPath(xpath)` 是 XPath 版本的元素等待,在需要按文本内容或复杂层级关系定位时有用: ```javascript // 按文本内容定位 await page.waitForXPath('//button[contains(text(), "提交")]'); // 复杂层级关系 await page.waitForXPath('//div[@class="form"]/following-sibling::button'); ``` 实际项目中 CSS 选择器能覆盖 90% 的场景,`waitForXPath` 主要用于文本匹配这类选择器不好写的情况。 ## 网络等待:waitForResponse 与 waitForRequest ### waitForResponse `page.waitForResponse()` 等待特定的网络响应返回,在调试接口或等待异步数据加载时非常实用。 ```javascript // 等待特定 URL 的响应 const response = await page.waitForResponse( 'https://api.example.com/data' ); // 用谓词函数做更精确的匹配 const response = await page.waitForResponse( res => res.url().includes('/api/users') && res.status() === 200 ); const data = await response.json(); ``` 一个常见的使用模式是:触发操作的同时等待对应的接口响应,确保数据已经返回: ```javascript const [response] = await Promise.all([ page.waitForResponse(res => res.url().includes('/api/search')), page.type('#search-input', 'puppeteer') ]); ``` ### waitForRequest `page.waitForRequest()` 等待特定的网络请求发出。和 `waitForResponse` 的区别是:一个等请求发出,一个等响应回来。 ```javascript // 验证点击按钮后是否发出了正确的请求 const request = await page.waitForRequest( req => req.url().includes('/api/track') && req.method() === 'POST' ); ``` `waitForRequest` 在验证请求参数、检查埋点是否正确上报时比较常用。 ## 自定义等待:waitForFunction `page.waitForFunction(pageFunction, ...args)` 是最灵活的等待方式,可以等待任意 JavaScript 表达式为真。 ```javascript // 等待列表加载超过 5 项 await page.waitForFunction( () => document.querySelectorAll('.item').length > 5 ); // 带参数 await page.waitForFunction( (count) => document.querySelectorAll('.item').length >= count, {}, 10 ); // 等待某个全局变量赋值 await page.waitForFunction( () => window.__APP_READY__ === true ); // 等待 SPA 路由切换 await page.waitForFunction( () => window.location.pathname === '/dashboard' ); ``` 当 `waitForSelector` 和 `waitForResponse` 都无法满足需求时(比如等待元素数量变化、等待某个 JS 变量、等待 URL 变化),就用 `waitForFunction`。 `waitForFunction` 的第二个参数可以传入轮询策略: ```javascript await page.waitForFunction( () => document.querySelector('.price')?.textContent !== '', { polling: 'mutation' } // DOM 变化时检查,比定时轮询高效 ); ``` `polling` 支持 `'raf'`(每帧检查)、`'mutation'`(DOM 变化时检查)、数字(毫秒间隔)。DOM 相关等待用 `mutation` 最合理。 ## waitForFrame:等待 iframe 加载 `page.waitForFrame()` 等待指定的 iframe 加载完成,处理嵌入页面时使用: ```javascript const frame = await page.waitForFrame('iframe-name'); const button = await frame.waitForSelector('.btn'); await button.click(); ``` 多 iframe 场景下,操作前一定要先拿到正确的 frame 对象,再通过 frame 调用 waitForSelector,而不是直接用 page 调用,否则会找不到元素。 ## waitForTimeout:已废弃的硬等待 `page.waitForTimeout(milliseconds)` 等待固定时间,已被官方废弃。如果确实需要延时,用原生方式替代: ```javascript // 已废弃 await page.waitForTimeout(1000); // 替代方案 await new Promise(resolve => setTimeout(resolve, 1000)); ``` 硬等待的问题在于:时间设短了不够等,设长了浪费时间,而且无法适应网络波动。应该尽量用条件等待替代,只有在完全没有条件可判断的极端场景下才考虑延时。 ## 常见坑与解决方案 ### 坑 1:waitForNavigation 和 click 的竞态 这是 Puppeteer 新手最常见的 bug。先 click 再 waitForNavigation,导航可能在 click 返回前就完成了: ```javascript // 错误写法:可能永远等不到导航 await page.click('#link'); await page.waitForNavigation(); // 正确写法:并行等待 await Promise.all([ page.waitForNavigation(), page.click('#link') ]); ``` ### 坑 2:元素在 DOM 中但不可见 `waitForSelector` 默认只检查元素是否在 DOM 中,不关心是否可见。如果页面有 `display: none` 或 `visibility: hidden` 的元素,不加 `visible: true` 可能拿到不可操作的元素: ```javascript // 可能拿到隐藏元素 await page.waitForSelector('.dropdown-menu'); // 确保元素可见 await page.waitForSelector('.dropdown-menu', { visible: true }); ``` ### 坑 3:SPA 页面导航不会触发 waitForNavigation SPA 内部的路由切换(比如 React Router 或 Vue Router)不会触发浏览器级别的导航事件,`waitForNavigation` 不会触发。这种场景要用 `waitForFunction` 等待 URL 变化或特定元素出现: ```javascript // SPA 路由切换不能用 waitForNavigation await Promise.all([ page.waitForFunction(() => window.location.pathname === '/profile'), page.click('.nav-profile') ]); ``` ### 坑 4:networkidle0 在有长连接的页面上永远等不到 如果页面有 WebSocket 或 SSE 连接,网络请求永远不会归零,`networkidle0` 会超时。改用 `networkidle2`: ```javascript // 有长连接的页面 await page.goto(url, { waitUntil: 'networkidle2' }); ``` ## 超时处理 所有等待方法都支持 `timeout` 参数,默认 30 秒。可以在页面级别设置默认超时: ```javascript page.setDefaultTimeout(10000); // 全局默认 10 秒 // 也可以在单次调用中覆盖 await page.waitForSelector('.element', { timeout: 5000 }); ``` 超时会抛出 `TimeoutError`,用 try/catch 捕获后可以做降级处理: ```javascript try { await page.waitForSelector('.optional-banner', { timeout: 3000 }); // banner 出现了,关闭它 await page.click('.banner-close'); } catch (error) { // banner 没出现,继续执行 } ``` 这种"可选元素"的等待模式在实际项目中很常用:元素可能出现也可能不出现,出现了就处理,没出现也不影响主流程。 ## 方法选择速查 | 场景 | 方法 | 示例 | |------|------|------| | 点击后页面跳转 | waitForNavigation | `await Promise.all([page.waitForNavigation(), page.click('#link')])` | | 等待动态元素出现 | waitForSelector | `await page.waitForSelector('.item', { visible: true })` | | 等待接口返回数据 | waitForResponse | `await page.waitForResponse(res => res.url().includes('/api/data'))` | | 等待复杂条件满足 | waitForFunction | `await page.waitForFunction(() => document.querySelectorAll('.item').length > 5)` | | 等待 iframe 加载 | waitForFrame | `await page.waitForFrame('iframe-name')` | | SPA 路由切换 | waitForFunction | `await page.waitForFunction(() => location.pathname === '/new')` | | 按文本定位元素 | waitForXPath | `await page.waitForXPath('//button[contains(text(),"确认")]')` | | 验证请求发出 | waitForRequest | `await page.waitForRequest(req => req.url().includes('/track'))` | Puppeteer 的等待机制核心思路就是用条件等待替代硬编码延时。选对方法、处理好事物的并行和竞态关系,脚本才能既稳定又高效。遇到问题先判断是导航、元素、网络还是自定义条件,然后对号入座选方法,大部分不稳定用例都能解决。
前端5月28日 07:08
Puppeteer 和 Selenium 有什么区别?## 核心结论 Puppeteer 和 Selenium 的根本区别在于通信协议:Puppeteer 基于 Chrome DevTools Protocol (CDP) 直接与浏览器内核通信,而 Selenium 基于 WebDriver 协议通过中间驱动层间接控制浏览器。这决定了两者在性能、能力和适用场景上的所有差异。 **简单选择标准:** 只需要操作 Chrome 且追求性能 → Puppeteer;需要跨浏览器或企业级测试 → Selenium。 ## 通信协议的本质差异 这是理解两者所有区别的钥匙。 **CDP(Puppeteer):** 通过 WebSocket 直接连接浏览器的调试端口,指令直达渲染进程。没有中间层翻译,所以快。代价是只能控制实现了 CDP 的浏览器,实际上就是 Chrome/Chromium。 **WebDriver(Selenium):** 测试脚本 → WebDriver 客户端 → WebDriver 服务器(如 chromedriver)→ 浏览器。每一层都是一次进程间通信,不可避免地引入延迟。好处是 WebDriver 是 W3C 标准协议,任何浏览器只要实现 WebDriver 接口就能被 Selenium 控制。 ```javascript // Puppeteer:直接通信,一步到位 const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('https://example.com'); // Selenium:经过驱动层中转 const driver = await new Builder().forBrowser('chrome').build(); await driver.get('https://example.com'); // 命令经 chromedriver 转发 ``` ## 什么时候选择 Puppeteer **场景一:网页爬虫和数据抓取** Puppeteer 的网络拦截能力是爬虫场景的核心武器。可以在请求层面直接屏蔽图片、字体等无关资源,大幅提升抓取速度。Selenium 没有原生的请求拦截能力,只能依赖第三方代理。 ```javascript await page.setRequestInterception(true); page.on('request', request => { const blocked = ['image', 'font', 'stylesheet']; blocked.includes(request.resourceType()) ? request.abort() : request.continue(); }); ``` **场景二:性能监控和页面指标采集** 通过 CDP 可以直接读取浏览器内核的性能数据(LCP、FID、CLS 等),这是 Selenium 无法做到的。Chrome DevTools 的 Performance 面板能看到的指标,Puppeteer 都能程序化获取。 **场景三:截图和 PDF 生成** Puppeteer 的截图 API 支持全页截图、指定元素截图、自定义视口。PDF 生成直接调用 Chrome 的打印引擎,排版效果与浏览器打印预览一致。Selenium 的截图功能相对基础,不支持 PDF 生成。 **场景四:设备模拟和地理位置** ```javascript // 一行代码模拟 iPhone 12 await page.emulate(puppeteer.devices['iPhone 12']); // 设置地理位置 await page.setGeolocation({ latitude: 35.6895, longitude: 139.6917 }); ``` ## 什么时候选择 Selenium **场景一:跨浏览器兼容性测试** 这是 Selenium 最不可替代的能力。如果你的产品需要保证在 Safari、Firefox、Edge 上都能正常运行,Selenium 是唯一成熟的选择。Puppeteer 对非 Chromium 浏览器的支持非常有限。 **场景二:多语言技术栈** Selenium 支持 Java、Python、C#、Ruby、JavaScript 等主流语言。后端团队用 Java 写测试、数据团队用 Python 写测试、前端团队用 JavaScript 写测试,都能统一在 Selenium 体系下。Puppeteer 只支持 Node.js。 **场景三:企业级分布式测试** Selenium Grid 支持在多台机器上并行运行测试,结合 Docker 可以快速搭建大规模测试集群。Puppeteer 本身没有分布式能力,需要借助第三方工具。 **场景四:移动端测试** 通过 Appium(基于 WebDriver 协议),Selenium 生态可以覆盖原生移动应用测试。Puppeteer 只能测试移动端网页,无法触及原生层。 ## 性能对比的根因分析 不是"Puppeteer 更快"这么简单。快在哪里? - **启动速度:** Puppeteer 自带 Chromium,无需额外下载驱动;Selenium 需要匹配浏览器版本下载对应驱动,版本不匹配是常见报错来源 - **指令执行:** CDP 单次指令延迟 <10ms,WebDriver 经驱动中转延迟 50-200ms - **内存占用:** Puppeteer 可通过 `page.setRequestInterception` 屏蔽无关资源,减少内存消耗;Selenium 无法在请求层做控制 但 Selenium 在 4.x 版本引入了 CDP 支持(`SeV4CDP`),部分缩小了性能差距。不过 CDP 功能在 Selenium 中属于实验性特性,稳定性和 API 完整度不如 Puppeteer。 ## 2026 年的新变量:Playwright 讨论 Puppeteer vs Selenium 不能忽略 Playwright。微软推出的 Playwright 同时支持 Chromium、Firefox 和 WebKit,API 设计比 Puppeteer 更现代(自动等待、多页面上下文),性能接近 Puppeteer。 如果你正在做技术选型,决策逻辑应该是: - 只需要 Chrome → Puppeteer - 需要多浏览器 + 全新项目 → 优先考虑 Playwright - 已有 Selenium 基础设施 / 需要 Java 等非 JS 语言 → Selenium ## 追问:Puppeteer 能用来做自动化测试吗? 可以,但要认清局限。Puppeteer 本质是浏览器控制库,不是测试框架。它没有内置的断言库、测试运行器、用例管理。实际项目中通常配合 Jest 或 Mocha 使用。 与 Selenium 作为测试框架的定位不同,Puppeteer 更适合作为工具链中的一环——爬虫用它抓数据,CI 用它做冒烟测试,监控系统用它采集性能指标。如果你需要的是一套完整的端到端测试方案,Selenium + 测试框架的组合更成熟;如果只需要轻量级的浏览器控制能力,Puppeteer 更灵活。 反过来,如果你的爬虫需要绕过反爬检测,Puppeteer 需要配合 stealth 插件隐藏自动化特征,而 Selenium 同样需要类似的反检测处理。两个工具在反爬场景下的表现差异不大,关键在于如何模拟真实用户行为。
前端5月28日 07:07
Puppeteer 在实际项目中怎么用?Puppeteer 是 Google 维护的 Node.js 浏览器自动化库,通过 Chrome DevTools Protocol 控制无头浏览器。它的实际应用远不止"跑个脚本打开网页",在爬虫、测试、文档生成、性能监控等场景中都是生产级方案。 ## 核心应用场景一览 | 场景 | 典型用途 | 复杂度 | |------|---------|--------| | 网页爬虫 | SPA 数据采集、价格监控 | 中 | | 自动化测试 | E2E 测试、视觉回归 | 中高 | | PDF 生成 | 报表、发票批量输出 | 低 | | 性能监控 | 页面加载分析、Core Web Vitals | 中 | | SEO 审计 | 页面结构检查、可访问性扫描 | 低 | | 自动化运维 | 表单批量填写、数据录入 | 中 | 下面逐个场景拆解关键实现和踩坑要点。 ## 网页爬虫:SPA 和动态内容的克星 传统爬虫(requests/axios)面对 Vue、React 渲染的页面基本无能为力,因为拿到的 HTML 只是空壳。Puppeteer 的优势在于它能等 JavaScript 执行完毕再提取数据。 价格监控是最常见的爬虫场景。核心逻辑:启动浏览器 → 设置 User-Agent 伪装 → 等待目标元素出现 → 提取数据。一段精简实现: ```javascript const puppeteer = require('puppeteer'); async function monitorPrice(url) { const browser = await puppeteer.launch({ headless: 'new' }); const page = await browser.newPage(); await page.setUserAgent( 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' ); await page.goto(url, { waitUntil: 'networkidle2' }); await page.waitForSelector('.price', { timeout: 10000 }); const data = await page.evaluate(() => ({ title: document.querySelector('.product-title')?.textContent, price: document.querySelector('.price')?.textContent, })); await browser.close(); return data; } ``` **踩坑经验:** `networkidle2` 不等于页面完全加载。如果目标元素是懒加载的,建议用 `waitForSelector` 配合超时做二次保障。另外,大批量采集时务必控制并发数,同时打开 20 个标签页会直接把内存撑爆。 **反爬虫要点:** 裸跑 Puppeteer 会被大多数反爬系统识别——`navigator.webdriver` 属性默认为 `true`,WebGL 指纹也暴露无头浏览器特征。生产环境中需要配合 `puppeteer-extra-plugin-stealth` 插件修补这些泄露点,或者使用代理池轮换 IP。 ## 自动化测试:E2E 与视觉回归 Puppeteer 在测试领域有两个典型用法: **端到端流程测试**——模拟用户完整操作路径,验证业务逻辑正确性。比如注册-登录-下单流程,每个步骤的页面跳转和状态变化都能断言。 关键技巧是用 `Promise.all` 包裹点击和等待导航,避免竞态条件: ```javascript await Promise.all([ page.waitForNavigation(), page.click('#submit-button'), ]); ``` **视觉回归测试**——截取页面快照与基线图对比,像素级检测 UI 变更。核心依赖 `pixelmatch` 库做图片 diff,差异超过阈值(通常 0.5%)即判定为回归。 实际项目中建议把视觉回归集成到 CI 流程,每次提交自动跑一遍。注意截图的稳定性:字体渲染、动画状态、抗锯齿差异都可能产生误报。解决方法是截图前等动画完成,并用固定视口宽度。 ## PDF 生成:报表和发票的批量引擎 服务端生成 PDF 是个老大难问题。用 PDFKit 手动排版太痛苦,用 wkhtmltopdf 中文渲染经常出问题。Puppeteer 的方案最直接:渲染 HTML → 调用 `page.pdf()` 输出。 ```javascript await page.setContent(htmlContent); await page.pdf({ path: 'report.pdf', format: 'A4', printBackground: true, margin: { top: '20px', bottom: '20px', left: '20px', right: '20px' }, }); ``` 批量生成发票时,不要每个发票都启停浏览器。用一个浏览器实例复用 Page,速度能提升 5-10 倍。但要注意内存泄漏——每次 `setContent` 后如果页面越来越慢,说明需要定期 `page.close()` 再开新 Page。 ## 性能监控:比 Lighthouse 更灵活 Lighthouse 适合一次性审计,但线上持续监控需要自定义方案。Puppeteer 可以精确采集每个页面的 FCP、LCP、DOM 节点数等指标,写入时序数据库做趋势分析。 ```javascript const client = await page.target().createCDPSession(); await client.send('Performance.enable'); await page.goto(url, { waitUntil: 'networkidle2' }); const fcp = await page.evaluate(() => performance.getEntriesByType('paint') .find(e => e.name === 'first-contentful-paint')?.startTime ); ``` 通过 CDP Session 还能拦截网络请求、监控 JS 堆内存变化,这些是 Lighthouse 做不到的细粒度采集。 ## SEO 审计:自动化页面健康检查 Puppeteer 可以批量扫描网站的 SEO 问题:缺少 title 标签、meta description 过长、H1 缺失或重复、图片缺少 alt 属性等。核心是 `page.evaluate` 在页面上下文中执行 DOM 查询,把结果结构化返回。 相比纯 HTTP 请求的方式,Puppeteer 能检查 JS 渲染后的真实 DOM,对 SPA 应用尤其重要——很多 SPA 的 SEO 问题只有运行后才能发现。 ## 请求拦截与资源优化 这是一个跨场景的通用技巧。通过拦截请求可以大幅降低资源消耗: ```javascript await page.setRequestInterception(true); page.on('request', (req) => { const blocked = ['image', 'font', 'stylesheet']; blocked.includes(req.resourceType()) ? req.abort() : req.continue(); }); ``` 爬虫场景下屏蔽图片和字体能提速 40% 以上;测试场景下可以 mock 接口返回,实现更可控的测试环境。 ## 面试高频追问 **Q: Puppeteer 和 Playwright 怎么选?** Puppeteer 只支持 Chromium,API 简洁,适合 Chrome 专属场景。Playwright 支持三浏览器(Chromium/Firefox/WebKit),自动等待机制更智能,新增项目推荐 Playwright。但 Puppeteer 生态更成熟,`puppeteer-extra` 插件体系(stealth、recaptcha)在爬虫场景无可替代。选型看需求:爬虫偏 Puppeteer,跨浏览器测试偏 Playwright。 **Q: 无头浏览器如何降低被检测概率?** 三层防护:第一层用 stealth 插件修补 `navigator.webdriver`、Chrome 对象等指纹;第二层用 `ghost-cursor` 模拟真人鼠标轨迹,避免点击坐标过于精确;第三层用代理池轮换 IP 和 User-Agent,避免单 IP 高频请求触发风控。没有银弹,三层全上才能通过中高级反爬。 **Q: Puppeteer 采集任务如何稳定运行在生产环境?** 三个关键点:一是进程管理,用 `puppeteer.connect` 连接常驻浏览器实例而非每次启动,配合 `pm2` 做进程守护;二是内存控制,每处理 50 个页面重启一次浏览器,防止内存泄漏积累;三是错误恢复,`page.on('error')` 监听页面崩溃,`browser.on('disconnected')` 监听浏览器断连,两者都要有自动重连逻辑。 Puppeteer 的应用边界还在扩展——AI Agent 的浏览器操作层、RPA 流程自动化、竞品数据监控,都是 2026 年依然活跃的场景。掌握核心 API 再结合上述实战经验,基本能覆盖日常开发中 90% 的浏览器自动化需求。
前端5月28日 06:53
Puppeteer 如何与测试框架集成实现 E2E 和 CI/CD?Puppeteer 可以与 Jest、Mocha、Vitest 等主流测试框架深度集成,完成端到端测试、视觉回归测试和性能测试,再通过 GitHub Actions、Docker 等工具接入 CI/CD 流水线。以下是生产环境中经过验证的集成方式和最佳实践。 ## 与 Jest 集成 Jest 是与 Puppeteer 搭配最多的测试框架,jest-puppeteer 提供了开箱即用的预设配置。 安装核心依赖: ```bash npm install --save-dev puppeteer jest jest-puppeteer @types/puppeteer ``` jest-puppeteer 的配置文件: ```javascript // jest-puppeteer.config.js module.exports = { launch: { headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox'], }, browserContext: 'incognito', exitOnPageError: true, }; ``` Jest 配置文件: ```javascript // jest.config.js module.exports = { preset: 'jest-puppeteer', testMatch: ['**/e2e/**/*.test.js'], setupFilesAfterEnv: ['./e2e/setup.js'], testTimeout: 30000, }; ``` setup 文件中处理每个测试的前置条件: ```javascript // e2e/setup.js beforeEach(async () => { await page.setViewport({ width: 1280, height: 720 }); await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' }); }); ``` 编写一个完整的登录 E2E 测试: ```javascript // e2e/auth.test.js describe('用户登录流程', () => { test('使用正确的凭据登录成功', async () => { await page.type('[data-testid="username"]', 'testuser'); await page.type('[data-testid="password"]', 'password123'); await page.click('[data-testid="login-btn"]'); await page.waitForSelector('[data-testid="dashboard"]'); const welcome = await page.$eval( '[data-testid="welcome-msg"]', el => el.textContent ); expect(welcome).toContain('欢迎回来'); }); test('使用错误密码登录失败', async () => { await page.type('[data-testid="username"]', 'testuser'); await page.type('[data-testid="password"]', 'wrongpassword'); await page.click('[data-testid="login-btn"]'); await page.waitForSelector('[data-testid="error-msg"]'); const error = await page.$eval( '[data-testid="error-msg"]', el => el.textContent ); expect(error).toContain('用户名或密码错误'); }); }); ``` 注意这里使用 `data-testid` 选择器而非 CSS 类名或 ID,这能让测试不依赖 UI 样式变更,提升稳定性。 ## 与 Mocha 集成 Mocha 的灵活性更高,适合需要自定义测试生命周期的团队。Puppeteer 的浏览器生命周期需要手动管理。 ```javascript // test/setup.js const puppeteer = require('puppeteer'); const { expect } = require('chai'); let browser; before(async () => { browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'], }); }); after(async () => { await browser.close(); }); beforeEach(async function () { this.page = await browser.newPage(); await this.page.goto('http://localhost:3000'); }); afterEach(async function () { await this.page.close(); }); ``` Mocha 测试用例: ```javascript // test/user.spec.js describe('用户注册功能', function () { this.timeout(15000); it('填写完整信息后注册成功', async function () { const { page } = this; await page.click('[data-testid="register-link"]'); await page.type('[data-testid="reg-username"]', 'newuser'); await page.type('[data-testid="reg-email"]', 'new@example.com'); await page.type('[data-testid="reg-password"]', 'StrongP@ss1'); await page.click('[data-testid="reg-submit"]'); await page.waitForSelector('[data-testid="reg-success"]'); const text = await page.$eval( '[data-testid="reg-success"]', el => el.textContent ); expect(text).to.include('注册成功'); }); }); ``` Mocha 的 `this.timeout()` 需要显式设置,Puppeteer 测试通常需要 10-30 秒的超时时间,不要使用箭头函数,否则无法访问 Mocha 的 `this` 上下文。 ## Page Object 模式 无论使用 Jest 还是 Mocha,当测试用例超过 20 个时,必须引入 Page Object 模式。它把页面元素定位和操作封装成独立类,测试用例只关心业务逻辑。 ```javascript // pages/LoginPage.js class LoginPage { constructor(page) { this.page = page; this.usernameInput = '[data-testid="username"]'; this.passwordInput = '[data-testid="password"]'; this.submitBtn = '[data-testid="login-btn"]'; this.errorMessage = '[data-testid="error-msg"]'; } async login(username, password) { await this.page.type(this.usernameInput, username); await this.page.type(this.passwordInput, password); await this.page.click(this.submitBtn); } async getErrorMessage() { await this.page.waitForSelector(this.errorMessage); return this.page.$eval(this.errorMessage, el => el.textContent); } } module.exports = LoginPage; ``` 测试用例中使用 Page Object: ```javascript const LoginPage = require('../pages/LoginPage'); test('登录失败显示错误提示', async () => { const loginPage = new LoginPage(page); await loginPage.login('testuser', 'wrongpass'); const error = await loginPage.getErrorMessage(); expect(error).toContain('用户名或密码错误'); }); ``` 如果 UI 改了 `data-testid` 的值,只需修改 LoginPage 一处,所有引用它的测试自动更新。这就是 Page Object 的核心价值。 ## 视觉回归测试 视觉回归测试能捕获 CSS 改动导致的 UI 偏移,Puppeteer 结合 Percy 或 jest-image-snapshot 可以自动完成截图对比。 使用 jest-image-snapshot 的方式: ```bash npm install --save-dev jest-image-snapshot ``` ```javascript const { toMatchImageSnapshot } = require('jest-image-snapshot'); expect.extend({ toMatchImageSnapshot }); test('首页视觉一致性', async () => { await page.goto('http://localhost:3000'); await page.waitForSelector('#main-content'); const screenshot = await page.screenshot({ fullPage: true }); expect(screenshot).toMatchImageSnapshot({ failureThreshold: 0.03, failureThresholdType: 'percent', }); }); ``` `failureThreshold` 设为 3% 是比较合理的起点,太严格会导致大量误报,太宽松又漏掉真正的 UI 变化。首次运行会生成基准截图,后续运行自动对比。 ## 性能测试 Puppeteer 可以通过 Chrome DevTools Protocol 采集性能指标,结合 Lighthouse 做更全面的审计。 ```javascript const puppeteer = require('puppeteer'); test('首页核心性能指标', async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('http://localhost:3000', { waitUntil: 'networkidle0' }); // 采集 Core Web Vitals const metrics = await page.evaluate(() => { return new Promise(resolve => { new PerformanceObserver(list => { const entries = list.getEntries(); const lastEntry = entries[entries.length - 1]; resolve({ LCP: lastEntry.startTime, FID: 0, CLS: 0, }); }).observe({ type: 'largest-contentful-paint', buffered: true }); }); }); expect(metrics.LCP).toBeLessThan(2500); await browser.close(); }); ``` 性能测试中 `waitUntil: 'networkidle0'` 很关键,确保页面资源加载完成后再采集数据。 ## CI/CD 集成 CI/CD 是 Puppeteer 测试从本地走向生产的关键环节。主要解决三个问题:浏览器安装、无头模式运行、测试稳定性。 GitHub Actions 配置: ```yaml name: E2E Tests on: [push, pull_request] jobs: e2e: runs-on: ubuntu-latest container: image: node:18-slim steps: - uses: actions/checkout@v4 - name: Install Chrome dependencies run: | apt-get update apt-get install -y chromium libx11-xcb1 libxcomposite1 libxdamage1 libxi6 libxtst6 libnss3 libatk1.0-0 - name: Install dependencies run: npm ci - name: Run E2E tests run: npm run test:e2e env: CI: true PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium ``` Docker 配置: ```dockerfile FROM node:18-slim RUN apt-get update && apt-get install -y \ chromium \ --no-install-recommends && \ rm -rf /var/lib/apt/lists/* ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . CMD ["npm", "run", "test:e2e"] ``` Docker 环境下必须设置 `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true`,使用系统安装的 Chromium,避免 Puppeteer 自带浏览器在容器中启动失败。`--no-sandbox` 参数在 Docker 中也是必需的,因为容器默认以 root 运行,Chrome 要求沙箱模式下不能是 root。 ## 测试稳定性实践 Puppeteer 测试在 CI 环境中失败率较高,以下是提升稳定性的关键手段。 **等待策略**:永远不要使用 `waitForTimeout`,改用显式等待。 ```javascript // 错误做法:硬编码等待 await page.waitForTimeout(3000); // 正确做法:等待元素可见 await page.waitForSelector('[data-testid="result"]', { visible: true }); // 等待网络空闲 await page.waitForNavigation({ waitUntil: 'networkidle2' }); // 等待特定请求完成 await page.waitForResponse( resp => resp.url().includes('/api/user') && resp.status() === 200 ); ``` **测试隔离**:每个测试用例使用独立的浏览器上下文,避免 Cookie 和 Storage 污染。 ```javascript beforeEach(async () => { context = await browser.createIncognitoBrowserContext(); page = await context.newPage(); await page.goto('http://localhost:3000'); }); afterEach(async () => { await context.close(); }); ``` **失败截图**:测试失败时自动保存截图,方便排查 CI 中的问题。 ```javascript afterEach(async function () { if (this.currentTest.state === 'failed') { const timestamp = Date.now(); const testName = this.currentTest.title.replace(/\s+/g, '_'); await page.screenshot({ path: `screenshots/${testName}_${timestamp}.png`, fullPage: true, }); } }); ``` **重试机制**:CI 环境中网络和资源加载不稳定,给 E2E 测试加一层重试。 ```javascript // jest.config.js module.exports = { preset: 'jest-puppeteer', retryTimes: 2, }; ``` ## 测试分层与并行执行 当测试规模增长后,需要按速度和稳定性分层运行,并利用并行加速。 ```javascript // jest.config.js module.exports = { preset: 'jest-puppeteer', projects: [ { displayName: 'smoke', testMatch: ['**/e2e/smoke/**/*.test.js'], retryTimes: 1, }, { displayName: 'critical', testMatch: ['**/e2e/critical/**/*.test.js'], retryTimes: 2, }, { displayName: 'full', testMatch: ['**/e2e/full/**/*.test.js'], retryTimes: 2, maxWorkers: 4, }, ], }; ``` smoke 测试只覆盖核心路径(登录、关键业务流程),在每次提交时运行;critical 测试覆盖主要功能,在 PR 合并时运行;full 测试覆盖所有场景,在每日构建时运行。 并行执行时注意 `maxWorkers` 不要超过 CPU 核心数,每个 worker 会启动一个浏览器实例,过度并行反而会因资源争抢导致测试超时。 ## 从 Puppeteer 迁移到 Playwright 的考虑 如果团队需要跨浏览器测试(Firefox、Safari),或者对自动等待、网络拦截有更高要求,Playwright 是更合适的选择。Playwright 由微软维护,API 设计参考了 Puppeteer 并做了大量改进。 迁移路径:Puppeteer 的 `page` 对象与 Playwright 的 `page` 对象 API 相似但不完全兼容。最稳妥的方式是先保留 Puppeteer 的集成测试,新测试用 Playwright 编写,逐步替换。不要一次性迁移,风险太大。 如果你的项目只需要 Chrome 测试,Puppeteer 仍然是最轻量的选择。