记得前几年,我们通常会用PhantomJs做一下自动化测试,或者为了SEO优化,会用它对SPA页面进行预渲染,现在有更好的Puppeteer来代替它的工作了,性能更好,使用起来也更加方便,Puppeteer 是 Chrome 开发团队在 2017 年发布的一个 Node.js 包,用来模拟 Chrome 浏览器的运行。
就如官网所介绍的,pptr可以做以下的事情:
以下片段仅收集一些简单的介绍以及一些例子,具体使用时,可以在官网进行更详细的查询
Puppeteer 中的 API 分层结构基本和浏览器保持一致,下面对常使用到的几个类介绍一下:
puppeteer 提供了两种方法用于创建一个 Browser 实例:
const puppeteer = require('puppeteer');
let request = require('request-promise-native');
//使用 puppeteer.launch 启动 Chrome
(async () => {
const browser = await puppeteer.launch({
headless: false, //有浏览器界面启动
slowMo: 100, //放慢浏览器执行速度,方便测试观察
args: [ //启动 Chrome 的参数,详见上文中的介绍
'–no-sandbox',
'--window-size=1280,960'
],
});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
await page.close();
await browser.close();
})();
//使用 puppeteer.connect 连接一个已经存在的 Chrome 实例
(async () => {
//通过 9222 端口的 http 接口获取对应的 websocketUrl
let version = await request({
uri: "http://127.0.0.1:9222/json/version",
json: true
});
//直接连接已经存在的 Chrome
let browser = await puppeteer.connect({
browserWSEndpoint: version.webSocketDebuggerUrl
});
const page = await browser.newPage();
await page.goto('https://www.baidu.com');
await page.close();
await browser.disconnect();
})();
这两种方式的对比:
在实践中我们经常会遇到如何判断一个页面加载完成了,什么时机去截图,什么时机去点击某个按钮等问题,那我们到底如何去等待加载呢?
下面我们把等待加载的 API 分为三类进行介绍:
Pupeeteer 中的基本上所有的操作都是异步的,以上几个 API 都涉及到关于打开一个页面,什么情况下才能判断这个函数执行完毕呢,这些函数都提供了两个参数 waitUtil 和 timeout,waitUtil 表示直到什么出现就算执行完毕,timeout 表示如果超过这个时间还没有结束就抛出异常。
await page.goto('https://www.baidu.com', {
timeout: 30 * 1000,
waitUntil: [
'load', //等待 “load” 事件触发
'domcontentloaded', //等待 “domcontentloaded” 事件触发
'networkidle0', //在 500ms 内没有任何网络连接
'networkidle2' //在 500ms 内网络连接个数不超过 2 个
]
});
以上 waitUtil 有四个事件,业务可以根据需求来设置其中一个或者多个触发才以为结束,networkidle0 和 networkidle2 中的 500ms 对时间性能要求高的用户来说,还是有点长的
await page.waitForXPath('//img');
await page.waitForSelector('#uniqueId');
await page.waitForResponse('https://d.youdata.netease.com/api/dash/hello');
await page.waitForRequest('https://d.youdata.netease.com/api/dash/hello');
如果上面提供的等待方式都不能满足我们的需求,puppeteer 还提供我们提供两个函数:
await page.goto(url, {
timeout: 120000,
waitUntil: 'networkidle2'
});
//我们可以在页面中定义自己认为加载完的事件,在合适的时间点我们将该事件设置为 true
//以下是我们项目在触发截图时的判断逻辑,如果 renderdone 出现且为 true 那么就截图,如果是 Object,说明页面加载出错了,我们可以捕获该异常进行提示
let renderdoneHandle = await page.waitForFunction('window.renderdone', {
polling: 120
});
const renderdone = await renderdoneHandle.jsonValue();
if (typeof renderdone === 'object') {
console.log(`加载页面失败:报表${renderdone.componentId}出错 -- ${renderdone.message}`);
}else{
console.log('页面加载成功');
}
在使用 Puppeteer 时我们几乎一定会遇到在这两个环境之间交换数据:运行 Puppeteer 的 Node.js 环境和 Puppeteer 操作的页面 Page DOM,理解这两个环境很重要
我们使用 Puppeteer 既可以对某个页面进行截图,也可以对页面中的某个元素进行截图:
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
//设置可视区域大小
await page.setViewport({width: 1920, height: 800});
await page.goto('https://youdata.163.com');
//对整个页面截图
await page.screenshot({
path: './files/capture.png', //图片保存路径
type: 'png',
fullPage: true //边滚动边截图
// clip: {x: 0, y: 0, width: 1920, height: 800}
});
//对页面某个元素截图
let [element] = await page.$x('/html/body/section[4]/div/div[2]');
await element.screenshot({
path: './files/element.png'
});
await page.close();
await browser.close();
})();
我们怎么去获取页面中的某个元素呢?
(async () => {
const browser = await puppeteer.launch({
slowMo: 100, //放慢速度
headless: false,
defaultViewport: {width: 1440, height: 780},
ignoreHTTPSErrors: false, //忽略 https 报错
args: ['--start-fullscreen'] //全屏打开页面
});
const page = await browser.newPage();
await page.goto('https://demo.youdata.com');
//输入账号密码
const uniqueIdElement = await page.$('#uniqueId');
await uniqueIdElement.type('admin@admin.com', {delay: 20});
const passwordElement = await page.$('#password', {delay: 20});
await passwordElement.type('123456');
//点击确定按钮进行登录
let okButtonElement = await page.$('#btn-ok');
//等待页面跳转完成,一般点击某个按钮需要跳转时,都需要等待 page.waitForNavigation() 执行完毕才表示跳转成功
await Promise.all([
okButtonElement.click(),
page.waitForNavigation()
]);
console.log('admin 登录成功');
await page.close();
await browser.close();
})();
那么 ElementHandle 都提供了哪些操作元素的函数呢?
请求在有些场景下很有必要,拦截一下没必要的请求提高性能,我们可以在监听 Page 的 request 事件,并进行请求拦截,前提是要开启请求拦截 page.setRequestInterception(true)。
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const blockTypes = new Set(['image', 'media', 'font']);
await page.setRequestInterception(true); //开启请求拦截
page.on('request', request => {
const type = request.resourceType();
const shouldBlock = blockTypes.has(type);
if(shouldBlock){
//直接阻止请求
return request.abort();
}else{
//对请求重写
return request.continue({
//可以对 url,method,postData,headers 进行覆盖
headers: Object.assign({}, request.headers(), {
'puppeteer-test': 'true'
})
});
}
});
await page.goto('https://demo.youdata.com');
await page.close();
await browser.close();
})();
那 page 页面上都提供了哪些事件呢?
Puppeteer 目前没有提供原生的用于处理 WebSocket 的 API 接口,但是我们可以通过更底层的 Chrome DevTool Protocol (CDP) 协议获得
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
//创建 CDP 会话
let cdpSession = await page.target().createCDPSession();
//开启网络调试,监听 Chrome DevTools Protocol 中 Network 相关事件
await cdpSession.send('Network.enable');
//监听 webSocketFrameReceived 事件,获取对应的数据
cdpSession.on('Network.webSocketFrameReceived', frame => {
let payloadData = frame.response.payloadData;
if(payloadData.includes('push:query')){
//解析payloadData,拿到服务端推送的数据
let res = JSON.parse(payloadData.match(/\{.*\}/)[0]);
if(res.code !== 200){
console.log(`调用websocket接口出错:code=${res.code},message=${res.message}`);
}else{
console.log('获取到websocket接口数据:', res.result);
}
}
});
await page.goto('https://netease.youdata.163.com/dash/142161/reportExport?pid=700209493');
await page.waitForFunction('window.renderdone', {polling: 20});
await page.close();
await browser.close();
})();
Puppeteer 最强大的功能是,你可以在浏览器里执行任何你想要运行的 javascript 代码,下面是我在爬邮箱的收件箱用户列表时,发现每次打开收件箱再关掉都会多处一个 iframe 来,随着打开收件箱的增多,iframe 增多到浏览器卡到无法运行,所以我在爬虫代码里加了删除无用 iframe 的脚本:
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://webmail.vip.188.com');
//注册一个 Node.js 函数,在浏览器里运行
await page.exposeFunction('md5', text =>
crypto.createHash('md5').update(text).digest('hex')
);
//通过 page.evaluate 在浏览器里执行删除无用的 iframe 代码
await page.evaluate(async () => {
let iframes = document.getElementsByTagName('iframe');
for(let i = 3; i < iframes.length - 1; i++){
let iframe = iframes[i];
if(iframe.name.includes("frameBody")){
iframe.src = 'about:blank';
try{
iframe.contentWindow.document.write('');
iframe.contentWindow.document.clear();
}catch(e){}
//把iframe从页面移除
iframe.parentNode.removeChild(iframe);
}
}
//在页面中调用 Node.js 环境中的函数
const myHash = await window.md5('PUPPETEER');
console.log(`md5 of ${myString} is ${myHash}`);
});
await page.close();
await browser.close();
})();
有哪些函数可以在浏览器环境中执行代码呢?
一个 Frame 包含了一个执行上下文(Execution Context),我们不能跨 Frame 执行函数,一个页面中可以有多个 Frame,主要是通过 iframe 标签嵌入的生成的。其中在页面上的大部分函数其实是 page.mainFrame().xx 的一个简写,Frame 是树状结构,我们可以通过 frame.childFrames() 遍历到所有的 Frame,如果想在其它 Frame 中执行函数必须获取到对应的 Frame 才能进行相应的处理
以下是在登录 188 邮箱时,其登录窗口其实是嵌入的一个 iframe,以下代码时我们在获取 iframe 并进行登录
(async () => {
const browser = await puppeteer.launch({headless: false, slowMo: 50});
const page = await browser.newPage();
await page.goto('https://www.188.com');
//点击使用密码登录
let passwordLogin = await page.waitForXPath('//*[@id="qcode"]/div/div[2]/a');
await passwordLogin.click();
for (const frame of page.mainFrame().childFrames()){
//根据 url 找到登录页面对应的 iframe
if (frame.url().includes('passport.188.com')){
await frame.type('.dlemail', 'admin@admin.com');
await frame.type('.dlpwd', '123456');
await Promise.all([
frame.click('#dologin'),
page.waitForNavigation()
]);
break;
}
}
await page.close();
await browser.close();
})();
Puppeteer 提供了对页面性能分析的工具,目前功能还是比较弱的,只能获取到一个页面性能执行的数据,如何分析需要我们自己根据数据进行分析,据说在 2.0 版本会做大的改版: – 一个浏览器同一时间只能 trace 一次 – 在 devTools 的 Performance 可以上传对应的 json 文件并查看分析结果 – 我们可以写脚本来解析 trace.json 中的数据做自动化分析 – 通过 tracing 我们获取页面加载速度以及脚本的执行性能
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.tracing.start({path: './files/trace.json'});
await page.goto('https://www.google.com');
await page.tracing.stop();
/*
continue analysis from 'trace.json'
*/
browser.close();
})();
在自动化测试中,经常会遇到对于文件的上传和下载的需求,那么在 Puppeteer 中如何实现呢?
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
//通过 CDP 会话设置下载路径
const cdp = await page.target().createCDPSession();
await cdp.send('Page.setDownloadBehavior', {
behavior: 'allow', //允许所有下载请求
downloadPath: 'path/to/download' //设置下载路径
});
//点击按钮触发下载
await (await page.waitForSelector('#someButton')).click();
//等待文件出现,轮训判断文件是否出现
await waitForFile('path/to/download/filename');
//上传时对应的 inputElement 必须是<input>元素
let inputElement = await page.waitForXPath('//input[@type="file"]');
await inputElement.uploadFile('/path/to/file');
browser.close();
})();
在点击一个按钮跳转到新的 Tab 页时会新开一个页面,这个时候我们如何获取改页面对应的 Page 实例呢?可以通过监听 Browser 上的 targetcreated 事件来实现,表示有新的页面创建:
let page = await browser.newPage();
await page.goto(url);
let btn = await page.waitForSelector('#btn');
//在点击按钮之前,事先定义一个 Promise,用于返回新 tab 的 Page 对象
const newPagePromise = new Promise(res =>
browser.once('targetcreated',
target => res(target.page())
)
);
await btn.click();
//点击按钮后,等待新tab对象
let newPage = await newPagePromise;
Puppeteer 提供了模拟不同设备的功能,其中 puppeteer.devices 对象上定义很多设备的配置信息,这些配置信息主要包含 viewport 和 userAgent,然后通过函数 page.emulate 实现不同设备的模拟
const puppeteer = require('puppeteer');
const iPhone = puppeteer.devices['iPhone 6'];
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.emulate(iPhone);
await page.goto('https://www.google.com');
await browser.close();
});