很早很早之前,前端就有了对后端环境调用浏览器页面功能的需求,最多的应用场景有两个
- UI 自动化测试:摆脱手工浏览点击页面确认功能模式,使用接口自动化调用界面
- 爬虫:模拟页面真实渲染,解决内容异步加载等问题
市场上出现过很多优秀的解决方案,在 Puppeteer 出现之前最常用的是 PhantomJS 和 selenium-webdriver,但两个库有个共同特点——环境安装复杂,API 调用不语义化。2017 年 Chrome 团队连续放了两个大招 Headless Chrome 和对应的 NodeJS API Puppeteer,直接让 PhantomJS 和 Selenium IDE for Firefox 作者宣布没必要继续维护其产品
Puppeteer
如同其 github 项目介绍:Puppeteer 是一个通过 DevTools Protocol 控制 headless chrome 的 high-level Node 库,提供了高度封装、使用方便的 API 来模拟用户在页面的操作、对浏览器事件做出响应等
- 生成页面 PDF、截图
- 自动提交表单,进行 UI 测试,键盘输入等
- 抓取单页应用并生成预渲染内容(另一种思路的 SSR)
- 捕获网站的 timeline trace,用来帮助分析性能问题
- 测试浏览器扩展
手动可以在浏览器上做的大部分行为 Puppeteer 都能通过 API 真实模拟,API 使用非常简单,看下官网对网页截图的示例
const puppeteer = require('puppeteer');(async () => {const browser = await puppeteer.launch();const page = await browser.newPage();await page.goto('https://example.com');await page.screenshot({path: 'example.png'});await browser.close();})();
实现网页截图就这么简单,用过 selenium-webdriver 的同学看了会流泪,官方提供了一个 playground,可以快速体验一下
哲学
虽然 Puppeteer API 足够简单,但如果是从 webdriver 流转过来的同学会很不适应,主要是在 webdirver 中操作网页更多的是从程序的视角,而在 Puppeteer 中网页浏览者的视角。举个简单的例子,对一个表单的 input 做输入
使用 webdriver 流程
- 通过选择器找到页面 input 元素
给元素设置值
const input = await driver.findElement(By.id('kw'));await input.sendKeys('test');
使用 Puppeteer 流程
光标应该 focus 到元素上
- 键盘点击输入
await page.focus('#kw');await page.keyboard.type('test');
甚至可以简化为一条语句:向 input 输入字符
await page.type('#kw', 'test');
可以看到 Puppeteer 的使用流程几乎是在模拟人的操作,在使用过程中可以感受区别,会发现 Puppeteer 的使用自然很多
安装
npm i puppeteer
比起 PhantomJS 和 selenium-webdriver 实在简单了太多,安装 Puppeteer 时会下载最新版本的 Chromium,从 1.7 开始 Puppeteer 每次发布还会有一个 puppeteer-core 发布,相对于 puppeteer 有两个区别
- puppeteer-core 不会自动安装 Chromium
- puppeteer-core 忽略所有的
PUPPETEER_* env变量如同 Node.js 启动可以设置环境变量,puppeteer 也支持特定的环境变量
HTTP_PROXY,HTTPS_PROXY,NO_PROXY- 定义用于下载和运行 Chromium 的 HTTP 代理设置。PUPPETEER_SKIP_CHROMIUM_DOWNLOAD- 请勿在安装步骤中下载绑定的 Chromium。PUPPETEER_DOWNLOAD_HOST- 覆盖用于下载 Chromium 的 URL 的主机部分。PUPPETEER_CHROMIUM_REVISION- 在安装步骤中指定一个你喜欢 puppeteer 使用的特定版本的 Chromium。PUPPETEER_EXECUTABLE_PATH- 指定一个 Chrome 或者 Chromium 的可执行路径,会被用于puppeteer.launch。具体关于可执行路径参数的意义,可参考puppeteer.launch([options])。
API
Puppeteer API 设计和浏览器层次相对应(浅色框体内容目前不在 Puppeteer 中实现)
Puppeteer使用 DevTools 协议 与浏览器进行通信Browser实例可以拥有浏览器上下文BrowserContext实例定义了一个浏览会话并可拥有多个页面Page至少有一个框架:主框架。 可能还有其他框架由 iframe 或 框架标签 创建frame至少有一个执行上下文 - 默认的执行上下文 - 框架的 JavaScript 被执行。 一个框架可能有额外的与 扩展 关联的执行上下文Worker具有单一执行上下文,并且便于与 WebWorkers 进行交互
查找元素
这是 UI 自动化测试最常用的功能了,Puppeteer 的处理也相当简单——使用选择器
- page.$(selector)
- page.$$(selector)
这两个函数分别会在页面内执行 document.querySelector 和 document.querySelectorAll ,但返回值却不是 DOM 对象,如同 jQuery 的选择器,返回的是经过自己包装的 Promise
const puppeteer = require('puppeteer');puppeteer.launch().then(async browser => {const page = await browser.newPage();await page.goto('https://google.com');const inputElement = await page.$('input[type=submit]');await inputElement.click();// ...});
浏览器实例环境
通过 ElementHandle 并不能直接获取对应 DOM 元素的属性,需要使用专门的 API 操作
- page.$eval(selector, pageFunction [, …args])
- page.$$eval(selector, pageFunction [, …args])
pageFunction 的代码会在浏览器实例中执行,所以可以用 Window 等 dom 对象;其返回值是整个方法的返回值
const searchValue = await page.$eval('#search', el => el.value);const html = await page.$eval('.main-container', e => e.outerHTML);const divsCounts = await page.$$eval('div', divs => divs.length);
page.evaluate(pageFunction [, …args]) 是上述方法的抽象,可以在浏览器示例中执行任意方法
const result = await page.evaluate(x => {return Promise.resolve(8 * x);}, 7); // 7 会做为实参传入 pageFunctionconsole.log(result); // 输出 "56"
前面提到的 ElementHandle 实例 可以作为参数传给 page.evaluate
const bodyHandle = await page.$('body');const html = await page.evaluate(body => body.innerHTML, bodyHandle);
键盘
Puppeteer 通过 page.keyboard 对象暴露操作键盘的接口
- keyboard.down(key[, options])
- keyboard.press(key[, options])
- keyboard.sendCharacter(char)
- keyboard.type(text, options)
- keyboard.up(key)
```javascript await page.keyboard.type(‘Hello World!’, {delay: 100}); await page.keyboard.press(‘ArrowLeft’);
await page.keyboard.down(‘Shift’); for (let i = 0; i < ‘ World’.length; i++) await page.keyboard.press(‘ArrowLeft’); await page.keyboard.up(‘Shift’);
await page.keyboard.press(‘Backspace’); // 结果字符串最终为 ‘Hello!’
方法看名字就知道什么意思,type 和 sendCharacter 作用非常类似,区别是- sendCharacter 会触发 `keypress` 和 `input` 事件,不会触发 `keydown` 或 `keyup` 事件- type 会触发`keydown`, `keypress`/`input` 和 `keyup` 事件特殊键名参考:[https://github.com/puppeteer/puppeteer/blob/main/src/common/USKeyboardLayout.ts](https://github.com/puppeteer/puppeteer/blob/main/src/common/USKeyboardLayout.ts)<a name="rfpdd"></a>## 鼠标Puppeteer 通过 `page.mouse` 对象暴露操作键盘的接口- [mouse.click(x, y, [options])](https://zhaoqize.github.io/puppeteer-api-zh_CN/#?product=Puppeteer&version=v5.3.0&show=api-mouseclickx-y-options)- [mouse.down([options])](https://zhaoqize.github.io/puppeteer-api-zh_CN/#?product=Puppeteer&version=v5.3.0&show=api-mousedownoptions)- [mouse.move(x, y, [options])](https://zhaoqize.github.io/puppeteer-api-zh_CN/#?product=Puppeteer&version=v5.3.0&show=api-mousemovex-y-options)- [mouse.up([options])](https://zhaoqize.github.io/puppeteer-api-zh_CN/#?product=Puppeteer&version=v5.3.0&show=api-mouseupoptions)```javascript// 使用 ‘page.mouse’ 追踪 100x100 的矩形。await page.mouse.move(0, 0);await page.mouse.down();await page.mouse.move(0, 100);await page.mouse.move(100, 100);await page.mouse.move(100, 0);await page.mouse.move(0, 0);await page.mouse.up();
tap
手机页面经常使用 tap 事件,用 page.mouse.click() 是不能触发的,需要使用专门的 tap API
- touchscreen.tap(x, y):触发 touchstart 和 touchend 事件
- page.tap(selector):touchscreen.tap(x, y) 的快捷方式,不用自己去定位
页面跳转控制
- page.goto(url, options)
- page.goback(options)
- page.goForward(options)
几个页面跳转的 API 非常简单,options 的 waitUntil 参数用来指定满足什么条件认为页面跳转完成,如果值为事件数组,那么所有事件触发后才认为是跳转完成。事件包括:
load- 页面的load事件触发时(默认值)domcontentloaded- 页面的 DOMContentLoaded 事件触发时networkidle0- 不再有网络连接时触发(至少500毫秒后)networkidle2- 只有2个网络连接时触发(至少500毫秒后)事件支持
Puppeteer 提供了对一些页面常见事件的监听,用法和 jQuery 很类似
- page.on(‘console’)
- page.on(‘dialog’)
- page.on(‘domcontentloaded’)
- page.on(‘error’)
- page.on(‘frameattached’)
- page.on(‘framedetached’)
- page.on(‘framenavigated’)
- page.on(‘load’)
- page.on(‘metrics’)
- page.on(‘pageerror’)
- page.on(‘request’)
- page.on(‘requestfailed’)
- page.on(‘requestfinished’)
- page.on(‘response’)
- page.on(‘workercreated’)
- page.on(‘workerdestroyed’)
终端模拟
Puppeteer 提供了几个有用的方法用来修改设备信息
- page.setViewport(viewport)
page.setUserAgent(userAgent)
await page.setViewport({width: 1920,height: 1080});await page.setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36');
page.emulateMedia(mediaType):可以用来修改页面访问的媒体类型,但仅仅支持screen
- null:禁用 media emulation
page.emulate(options) :前面介绍的几个函数相当于这个函数的快捷方式,这个函数可以设置多个内容
- viewport
- width
- height
- deviceScaleFactor
- isMobile
- hasTouch
- isLandscape
- userAgent
因为使用太频繁,Puppeteer 通过 puppeteer/DeviceDescriptors 提供了全套的设备模拟
const puppeteer = require('puppeteer');const devices = require('puppeteer/DeviceDescriptors');const iPhone = devices['iPhone XR'];puppeteer.launch().then(async browser => {const page = await browser.newPage();await page.emulate(iPhone);await page.goto('https://www.google.com');// other actions...await browser.close();});
所有支持参考:https://github.com/puppeteer/puppeteer/blob/main/src/common/DeviceDescriptors.ts
性能
通过 page.getMetrics() 可以得到一些页面性能数据
- Timestamp The timestamp when the metrics sample was taken.
- Documents 页面文档数
- Frames 页面 frame 数
- JSEventListeners 页面内事件监听器数
- Nodes 页面 DOM 节点数
- LayoutCount 页面 layout 数
- RecalcStyleCount 样式重算数
- LayoutDuration 页面 layout 时间
- RecalcStyleDuration 样式重算时长
- ScriptDuration script 时间
- TaskDuration 所有浏览器任务时长
- JSHeapUsedSize JavaScript 占用堆大小
- JSHeapTotalSize JavaScript 堆总量
{Timestamp: 382305.912236,Documents: 5,Frames: 3,JSEventListeners: 129,Nodes: 8810,LayoutCount: 38,RecalcStyleCount: 56,LayoutDuration: 0.596341000346001,RecalcStyleDuration: 0.180430999898817,ScriptDuration: 1.24401400075294,TaskDuration: 2.21657899935963,JSHeapUsedSize: 15430816,JSHeapTotalSize: 23449600}
注册函数
page.exposeFunction(name, puppeteerFunction)用于在 window 对象注册一个函数,在自动化测试初始化测试环境时候很有用,举个例子:给 window 添加一个 window.readfile 函数 ```javascript const puppeteer = require(‘puppeteer’); const fs = require(‘fs’);
puppeteer.launch().then(async browser => { const page = await browser.newPage(); page.on(‘console’, msg => console.log(msg.text));
// 注册 window.readfile await page.exposeFunction(‘readfile’, async filePath => { return new Promise((resolve, reject) => { fs.readFile(filePath, ‘utf8’, (err, text) => { if (err) reject(err); else resolve(text); }); }); });
await page.evaluate(async () => { // use window.readfile to read contents of a file const content = await window.readfile(‘/etc/hosts’); console.log(content); }); await browser.close(); });
<a name="ANWZ3"></a>## 使用 headless 模式Puppeteer 默认运行 Chromium 的 [headless mode](https://developers.google.com/web/updates/2017/04/headless-chrome)。如果想要使用完全版本的 Chromium 设置 ['headless' option](https://github.com/GoogleChrome/puppeteer/blob/v1.10.0/docs/api.md#puppeteerlaunchoptions) 即可。```javascriptconst browser = await puppeteer.launch({headless: false});
使用自定义 Chromium
默认情况下,Puppeteer 下载并使用特定版本的 Chromium 以及其 API 保证开箱即用。 如果要将 Puppeteer 与不同版本的 Chrome 或 Chromium 一起使用,在创建Browser实例时传入 Chromium 可执行文件的路径即可:
const browser = await puppeteer.launch({executablePath: '/path/to/Chrome'});
简单示例
模拟 iPhone XR 截屏
const path = require('path');const puppeteer = require('puppeteer');const iPhoneXR = puppeteer.devices['iPhone XR'];(async () => {const browser = await puppeteer.launch();const page = await browser.newPage();await page.emulate(iPhoneXR);await page.goto('https://www.baidu.com', { waitUntil: ['load'] });await page.screenshot({path: path.join(__dirname, '../image', 'baidu.png'),fullPage: true,});await browser.close();})();
图片搜索 & 下载
const path = require('path');const fs = require('fs');const http = require('http');const https = require('https');const puppeteer = require('puppeteer');const ora = require('ora');// const devices = require('puppeteer/DeviceDescriptors');const iPhoneXR = puppeteer.devices['iPhone XR'];(async () => {const browser = await puppeteer.launch();const page = await browser.newPage();await page.emulate(iPhoneXR);await page.goto('https://iamge.baidu.com', { waitUntil: ['load'] });await page.type('#image-search-input', 'dog');await page.tap('#image-search-btn');page.on('load', async () => {const srcs = await page.$$eval('.sfc-image-content-waterfall img',images => images.map(img => img.src));await browser.close();let i = 0;srcs.forEach(src => {const request = src.trim().startsWith('https') ? https : http;const dest = path.join(__dirname, `../images/${i++}.jpg`);console.log(`正在下载 ${src}`);request.get(src, res => {res.pipe(fs.createWriteStream(dest));});});});})();
完整代码:https://github.com/Samaritan89/puppeteer
