Puppeteer 是 Chrome 開(kāi)發(fā)團(tuán)隊(duì)在 2017 年發(fā)布的一個(gè) Node.js 包,同時(shí)還有 Headless Chrome哆档。用來(lái)模擬 Chrome 瀏覽器的運(yùn)行诗良。它提供了高級(jí)API來(lái)通過(guò) DevTools 協(xié)議控制無(wú)頭 Chrome 或 Chromium 萝衩,它也可以配置為使用完整(非無(wú)頭)Chrome 或 Chromium侦锯。
學(xué)習(xí) Puppeteer 之前我們先來(lái)了解一下 Chrome DevTool Protocol 和 Headless Chrome液兽。
Chrome DevTool Protocol 是什么
- CDP 基于 WebSocket脸秽,利用 WebSocket 實(shí)現(xiàn)與瀏覽器內(nèi)核的快速數(shù)據(jù)通道儒老。
- CDP 分為多個(gè)域(DOM,Debugger豹储,Network贷盲,Profiler,Console...),每個(gè)域中都定義了相關(guān)的命令和事件(Commands and Events)巩剖。
- 我們可以基于 CDP 封裝一些工具對(duì) Chrome 瀏覽器進(jìn)行調(diào)試及分析铝穷,比如我們常用的 “Chrome 開(kāi)發(fā)者工具” 就是基于 CDP 實(shí)現(xiàn)的。
- 很多有用的工具都是基于 CDP 實(shí)現(xiàn)的佳魔,比如 Chrome 開(kāi)發(fā)者工具曙聂,chrome-remote-interface,Puppeteer 等鞠鲜。
Headless Chrome 是什么
- 可以在無(wú)界面的環(huán)境中運(yùn)行 Chrome宁脊。
- 通過(guò)命令行或者程序語(yǔ)言操作 Chrome。
- 無(wú)需人的干預(yù)贤姆,運(yùn)行更穩(wěn)定榆苞。
- 在啟動(dòng) Chrome 時(shí)添加參數(shù) --headless,便可以 headless 模式啟動(dòng) Chrome霞捡。
- chrome 啟動(dòng)時(shí)可以加一些什么參數(shù)坐漏,大家可以點(diǎn)擊這里查看。
總而言之 Headless Chrome 就是 Chrome 瀏覽器的無(wú)界面形態(tài)碧信,可以在不打開(kāi)瀏覽器的前提下赊琳,使用所有 Chrome 支持的特性運(yùn)行你的程序。
Puppeteer 是什么
- Puppeteer 是 Node.js 工具引擎砰碴。
- Puppeteer 提供了一系列 API躏筏,通過(guò) Chrome DevTools Protocol 協(xié)議控制 Chromium/Chrome 瀏覽器的行為。
- Puppeteer 默認(rèn)情況下是以 headless 啟動(dòng) Chrome 的呈枉,也可以通過(guò)參數(shù)控制啟動(dòng)有界面的 Chrome趁尼。
- Puppeteer 默認(rèn)綁定最新的 Chromium 版本,也可以自己設(shè)置不同版本的綁定猖辫。
- Puppeteer 讓我們不需要了解太多的底層 CDP 協(xié)議實(shí)現(xiàn)與瀏覽器的通信弱卡。
Puppeteer 能做什么
官方介紹:您可以在瀏覽器中手動(dòng)執(zhí)行的大多數(shù)操作都可以使用 Puppeteer 完成!示例:
- 生成頁(yè)面的屏幕截圖和PDF住册。
- 爬取 SPA 或 SSR 網(wǎng)站。
- 自動(dòng)化表單提交瓮具,UI測(cè)試荧飞,鍵盤(pán)輸入等。
- 創(chuàng)建最新的自動(dòng)化測(cè)試環(huán)境名党。使用最新的JavaScript和瀏覽器功能叹阔,直接在最新版本的Chrome中運(yùn)行測(cè)試。
- 捕獲站點(diǎn)的時(shí)間線跟蹤传睹,以幫助診斷性能問(wèn)題耳幢。
- 測(cè)試Chrome擴(kuò)展程序。
- ...
Puppeteer API 分層結(jié)構(gòu)
Puppeteer 中的 API 分層結(jié)構(gòu)基本和瀏覽器保持一致,下面對(duì)常使用到的幾個(gè)類(lèi)介紹一下:
- Browser: 對(duì)應(yīng)一個(gè)瀏覽器實(shí)例睛藻,一個(gè) Browser 可以包含多個(gè) BrowserContext
- BrowserContext: 對(duì)應(yīng)瀏覽器一個(gè)上下文會(huì)話启上,就像我們打開(kāi)一個(gè)普通的 Chrome 之后又打開(kāi)一個(gè)隱身模式的瀏覽器一樣,BrowserContext 具有獨(dú)立的 Session(cookie 和 cache 獨(dú)立不共享)店印,一個(gè) BrowserContext 可以包含多個(gè) Page
- Page:表示一個(gè) Tab 頁(yè)面冈在,通過(guò) browserContext.newPage()/browser.newPage() 創(chuàng)建,browser.newPage() 創(chuàng)建頁(yè)面時(shí)會(huì)使用默認(rèn)的 BrowserContext按摘,一個(gè) Page 可以包含多個(gè) Frame
- Frame: 一個(gè)框架包券,每個(gè)頁(yè)面有一個(gè)主框架(page.MainFrame()),也可以多個(gè)子框架,主要由 iframe 標(biāo)簽創(chuàng)建產(chǎn)生的
- ExecutionContext: 是 javascript 的執(zhí)行環(huán)境炫贤,每一個(gè) Frame 都一個(gè)默認(rèn)的 javascript 執(zhí)行環(huán)境
- ElementHandle: 對(duì)應(yīng) DOM 的一個(gè)元素節(jié)點(diǎn)溅固,通過(guò)該該實(shí)例可以實(shí)現(xiàn)對(duì)元素的點(diǎn)擊,填寫(xiě)表單等行為兰珍,我們可以通過(guò)選擇器侍郭,xPath 等來(lái)獲取對(duì)應(yīng)的元素
- JsHandle:對(duì)應(yīng) DOM 中的 javascript 對(duì)象,ElementHandle 繼承于 JsHandle俩垃,由于我們無(wú)法直接操作 DOM 中對(duì)象励幼,所以封裝成 JsHandle 來(lái)實(shí)現(xiàn)相關(guān)功能
- CDPSession:可以直接與原生的 CDP 進(jìn)行通信,通過(guò) session.send 函數(shù)直接發(fā)消息口柳,通過(guò) session.on 接收消息苹粟,可以實(shí)現(xiàn) Puppeteer API 中沒(méi)有涉及的功能
- Coverage:獲取 JavaScript 和 CSS 代碼覆蓋率
- Tracing:抓取性能數(shù)據(jù)進(jìn)行分析
- Response: 頁(yè)面收到的響應(yīng)
- Request: 頁(yè)面發(fā)出的請(qǐng)求
Puppeteer 安裝與環(huán)境
注意:在v1.18.1之前,Puppeteer至少需要Node v6.4.0跃闹。從v1.18.1到v2.1.0的版本依賴(lài)于Node 8.9.0+嵌削。從v3.0.0開(kāi)始,Puppeteer開(kāi)始依賴(lài)于Node 10.18.1+望艺。若要使用 async / await苛秕,只有Node v7.6.0或更高版本才支持。
Puppeteer是一個(gè)node.js包找默,所以安裝很簡(jiǎn)單:
npm install puppeteer
// 或者
yarn add puppeteer
npm 在安裝 puppeteer 的時(shí)候可能會(huì)報(bào)錯(cuò)艇劫!這是由于外網(wǎng)導(dǎo)致,使用科學(xué)上網(wǎng)或者使用淘寶鏡像 cnpm 安裝可解決惩激。
安裝Puppeteer時(shí)店煞,它將下載 Chromium 的最新版本。從1.7.0版開(kāi)始风钻,官方發(fā)布了該 puppeteer-core 軟件包顷蟀,默認(rèn)情況下不會(huì)下載任何瀏覽器,用于啟動(dòng)現(xiàn)有的瀏覽器或連接到遠(yuǎn)程瀏覽器骡技。需要注意安裝的 puppeteer-core 版本與打算連接的瀏覽器兼容鸣个。
Puppeteer 使用
Case1: 截圖
我們使用 Puppeteer 既可以對(duì)某個(gè)頁(yè)面進(jìn)行截圖,也可以對(duì)頁(yè)面中的某個(gè)元素進(jìn)行截圖:
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
//設(shè)置可視區(qū)域大小,默認(rèn)的頁(yè)面大小為800x600分辨率
await page.setViewport({width: 1920, height: 800});
await page.goto('https://www.baidu.com/');
//對(duì)整個(gè)頁(yè)面截圖
await page.screenshot({
path: './files/baidu_home.png', //圖片保存路徑
type: 'png',
fullPage: true //邊滾動(dòng)邊截圖
// clip: {x: 0, y: 0, width: 1920, height: 800}
});
//對(duì)頁(yè)面某個(gè)元素截圖
let element = await page.$('#s_lg_img');
await element.screenshot({
path: './files/baidu_logo.png'
});
await page.close();
await browser.close();
})();
我們?cè)趺慈カ@取頁(yè)面中的某個(gè)元素呢?
-
page.$('#uniqueId')
:獲取某個(gè)選擇器對(duì)應(yīng)的第一個(gè)元素 -
page.$$('div')
:獲取某個(gè)選擇器對(duì)應(yīng)的所有元素 -
page.$x('//img')
:獲取某個(gè) xPath 對(duì)應(yīng)的所有元素 -
page.waitForXPath('//img')
:等待某個(gè) xPath 對(duì)應(yīng)的元素出現(xiàn) -
page.waitForSelector('#uniqueId')
:等待某個(gè)選擇器對(duì)應(yīng)的元素出現(xiàn)
Case2: 模擬用戶操作
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({
slowMo: 100, //放慢速度
headless: false, //開(kāi)啟可視化
defaultViewport: {width: 1440, height: 780},
ignoreHTTPSErrors: false, //忽略 https 報(bào)錯(cuò)
args: ['--start-fullscreen'] //全屏打開(kāi)頁(yè)面
});
const page = await browser.newPage();
await page.goto('https://www.baidu.com/');
//輸入文本
const inputElement = await page.$('#kw');
await inputElement.type('hello word', {delay: 20});
//點(diǎn)擊搜索按鈕
let okButtonElement = await page.$('#su');
//等待頁(yè)面跳轉(zhuǎn)完成囤萤,一般點(diǎn)擊某個(gè)按鈕需要跳轉(zhuǎn)時(shí)昼窗,都需要等待 page.waitForNavigation() 執(zhí)行完畢才表示跳轉(zhuǎn)成功
await Promise.all([
okButtonElement.click(),
page.waitForNavigation()
]);
await page.close();
await browser.close();
})();
那么 ElementHandle 都提供了哪些操作元素的函數(shù)呢?
-
elementHandle.click()
:點(diǎn)擊某個(gè)元素 -
elementHandle.tap()
:模擬手指觸摸點(diǎn)擊 -
elementHandle.focus()
:聚焦到某個(gè)元素 -
elementHandle.hover()
:鼠標(biāo) hover 到某個(gè)元素上 -
elementHandle.type('hello')
:在輸入框輸入文本
Case3: 植入 javascript 代碼
Puppeteer 最強(qiáng)大的功能是阁将,你可以在瀏覽器里執(zhí)行任何你想要運(yùn)行的 javascript 代碼膏秫。下面代碼是對(duì)百度首頁(yè)新聞推薦爬取數(shù)據(jù)的例子。
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://www.baidu.com/');
//通過(guò) page.evaluate 在瀏覽器里執(zhí)行代碼
const resultData = await page.evaluate(async () => {
let data = {};
const ListEle = [...document.querySelectorAll('#hotsearch-content-wrapper .hotsearch-item')];
data = ListEle.map((ele) => {
const urlEle = ele.querySelector('a.c-link');
const titleEle = ele.querySelector('.title-content-title');
return {
href: urlEle.href,
title: titleEle.innerText,
};
});
return data;
});
console.log(resultData)
await page.close();
await browser.close();
})();
有哪些函數(shù)可以在瀏覽器環(huán)境中執(zhí)行代碼呢做盅?
-
page.evaluate(pageFunction[, ...args])
:在瀏覽器環(huán)境中執(zhí)行函數(shù) -
page.evaluateHandle(pageFunction[, ...args])
:在瀏覽器環(huán)境中執(zhí)行函數(shù)缤削,返回 JsHandle 對(duì)象 -
page.$$eval(selector, pageFunction[, ...args])
:把 selector 對(duì)應(yīng)的所有元素傳入到函數(shù)并在瀏覽器環(huán)境執(zhí)行 -
page.$eval(selector, pageFunction[, ...args])
:把 selector 對(duì)應(yīng)的第一個(gè)元素傳入到函數(shù)在瀏覽器環(huán)境執(zhí)行 -
page.evaluateOnNewDocument(pageFunction[, ...args])
:創(chuàng)建一個(gè)新的 Document 時(shí)在瀏覽器環(huán)境中執(zhí)行,會(huì)在頁(yè)面所有腳本執(zhí)行之前執(zhí)行 -
page.exposeFunction(name, puppeteerFunction)
:在 window 對(duì)象上注冊(cè)一個(gè)函數(shù)吹榴,這個(gè)函數(shù)在 Node 環(huán)境中執(zhí)行亭敢,有機(jī)會(huì)在瀏覽器環(huán)境中調(diào)用 Node.js 相關(guān)函數(shù)庫(kù)
Case4: 請(qǐng)求攔截
請(qǐng)求在有些場(chǎng)景下很有必要,攔截一下沒(méi)必要的請(qǐng)求提高性能图筹,我們可以在監(jiān)聽(tīng) Page 的 request 事件帅刀,并進(jìn)行請(qǐng)求攔截,前提是要開(kāi)啟請(qǐng)求攔截 page.setRequestInterception(true)
远剩。
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const blockTypes = new Set(['image', 'media', 'font']);
await page.setRequestInterception(true); //開(kāi)啟請(qǐng)求攔截
page.on('request', request => {
const type = request.resourceType();
const shouldBlock = blockTypes.has(type);
if(shouldBlock){
//直接阻止請(qǐng)求
return request.abort();
}else{
//對(duì)請(qǐng)求重寫(xiě)
return request.continue({
//可以對(duì) url扣溺,method,postData瓜晤,headers 進(jìn)行覆蓋
headers: Object.assign({}, request.headers(), {
'puppeteer-test': 'true'
})
});
}
});
await page.goto('https://www.baidu.com/');
await page.close();
await browser.close();
})();
那 page 頁(yè)面上都提供了哪些事件呢锥余?
-
page.on('close')
頁(yè)面關(guān)閉 -
page.on('console')
console API 被調(diào)用 -
page.on('error')
頁(yè)面出錯(cuò) -
page.on('load')
頁(yè)面加載完 -
page.on('request')
收到請(qǐng)求 -
page.on('requestfailed')
請(qǐng)求失敗 -
page.on('requestfinished')
請(qǐng)求成功 -
page.on('response')
收到響應(yīng) -
page.on('workercreated')
創(chuàng)建 webWorker -
page.on('workerdestroyed')
銷(xiāo)毀 webWorker
Case5: 獲取 WebSocket 響應(yīng)
Puppeteer 目前沒(méi)有提供原生的用于處理 WebSocket 的 API 接口里初,但是我們可以通過(guò)更底層的 Chrome DevTool Protocol (CDP) 協(xié)議獲得
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
//創(chuàng)建 CDP 會(huì)話
let cdpSession = await page.target().createCDPSession();
//開(kāi)啟網(wǎng)絡(luò)調(diào)試,監(jiān)聽(tīng) Chrome DevTools Protocol 中 Network 相關(guān)事件
await cdpSession.send('Network.enable');
//監(jiān)聽(tīng) webSocketFrameReceived 事件匹涮,獲取對(duì)應(yīng)的數(shù)據(jù)
cdpSession.on('Network.webSocketFrameReceived', frame => {
let payloadData = frame.response.payloadData;
if(payloadData.includes('push:query')){
//解析payloadData仙逻,拿到服務(wù)端推送的數(shù)據(jù)
let res = JSON.parse(payloadData.match(/\{.*\}/)[0]);
if(res.code !== 200){
console.log(`調(diào)用websocket接口出錯(cuò):code=${res.code},message=${res.message}`);
}else{
console.log('獲取到websocket接口數(shù)據(jù):', 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();
})();
Case6: 如何抓取 iframe 中的元素
一個(gè) Frame 包含了一個(gè)執(zhí)行上下文(Execution Context)揭措,我們不能跨 Frame 執(zhí)行函數(shù),一個(gè)頁(yè)面中可以有多個(gè) Frame脖捻,主要是通過(guò) iframe 標(biāo)簽嵌入的生成的愈诚。其中在頁(yè)面上的大部分函數(shù)其實(shí)是 page.mainFrame().xx 的一個(gè)簡(jiǎn)寫(xiě)荣堰,F(xiàn)rame 是樹(shù)狀結(jié)構(gòu)淹辞,我們可以通過(guò) frame.childFrames() 遍歷到所有的 Frame医舆,如果想在其它 Frame 中執(zhí)行函數(shù)必須獲取到對(duì)應(yīng)的 Frame 才能進(jìn)行相應(yīng)的處理
以下是在登錄 188 郵箱時(shí),其登錄窗口其實(shí)是嵌入的一個(gè) iframe象缀,以下代碼時(shí)我們?cè)讷@取 iframe 并進(jìn)行登錄
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({headless: false, slowMo: 50});
const page = await browser.newPage();
await page.goto('https://www.188.com');
for (const frame of page.mainFrame().childFrames()){
//根據(jù) url 找到登錄頁(yè)面對(duì)應(yīng)的 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();
})();
Case7: 頁(yè)面性能分析
Puppeteer 提供了對(duì)頁(yè)面性能分析的工具彬向,目前功能還是比較弱的,只能獲取到一個(gè)頁(yè)面性能執(zhí)行的數(shù)據(jù)攻冷,如何分析需要我們自己根據(jù)數(shù)據(jù)進(jìn)行分析,據(jù)說(shuō)在 2.0 版本會(huì)做大的改版: - 一個(gè)瀏覽器同一時(shí)間只能 trace 一次 - 在 devTools 的 Performance 可以上傳對(duì)應(yīng)的 json 文件并查看分析結(jié)果 - 我們可以寫(xiě)腳本來(lái)解析 trace.json 中的數(shù)據(jù)做自動(dòng)化分析 - 通過(guò) tracing 我們獲取頁(yè)面加載速度以及腳本的執(zhí)行性能
const puppeteer = require('puppeteer');
(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();
})();
Case8: 文件的上傳和下載
在自動(dòng)化測(cè)試中遍希,經(jīng)常會(huì)遇到對(duì)于文件的上傳和下載的需求等曼,那么在 Puppeteer 中如何實(shí)現(xiàn)呢?
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
//通過(guò) CDP 會(huì)話設(shè)置下載路徑
const cdp = await page.target().createCDPSession();
await cdp.send('Page.setDownloadBehavior', {
behavior: 'allow', //允許所有下載請(qǐng)求
downloadPath: 'path/to/download' //設(shè)置下載路徑
});
//點(diǎn)擊按鈕觸發(fā)下載
await (await page.waitForSelector('#someButton')).click();
//等待文件出現(xiàn),輪訓(xùn)判斷文件是否出現(xiàn)
await waitForFile('path/to/download/filename');
//上傳時(shí)對(duì)應(yīng)的 inputElement 必須是<input>元素
let inputElement = await page.waitForXPath('//input[@type="file"]');
await inputElement.uploadFile('/path/to/file');
browser.close();
})();
Case9: 跳轉(zhuǎn)新 tab 頁(yè)處理
在點(diǎn)擊一個(gè)按鈕跳轉(zhuǎn)到新的 Tab 頁(yè)時(shí)會(huì)新開(kāi)一個(gè)頁(yè)面禁谦,這個(gè)時(shí)候我們?nèi)绾潍@取改頁(yè)面對(duì)應(yīng)的 Page 實(shí)例呢胁黑?可以通過(guò)監(jiān)聽(tīng) Browser 上的 targetcreated 事件來(lái)實(shí)現(xiàn),表示有新的頁(yè)面創(chuàng)建:
let page = await browser.newPage();
await page.goto(url);
let btn = await page.waitForSelector('#btn');
//在點(diǎn)擊按鈕之前州泊,事先定義一個(gè) Promise丧蘸,用于返回新 tab 的 Page 對(duì)象
const newPagePromise = new Promise(res =>
browser.once('targetcreated',
target => res(target.page())
)
);
await btn.click();
//點(diǎn)擊按鈕后,等待新tab對(duì)象
let newPage = await newPagePromise;
Case10: 模擬不同的設(shè)備
Puppeteer 提供了模擬不同設(shè)備的功能遥皂,其中 puppeteer.devices 對(duì)象上定義很多設(shè)備的配置信息力喷,這些配置信息主要包含 viewport 和 userAgent,然后通過(guò)函數(shù) page.emulate 實(shí)現(xiàn)不同設(shè)備的模擬
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.baidu.com');
await browser.close();
});
性能和優(yōu)化
- 關(guān)于共享內(nèi)存:
Chrome 默認(rèn)使用 /dev/shm 共享內(nèi)存演训,但是 docker 默認(rèn)/dev/shm 只有64MB弟孟,顯然是不夠使用的,提供兩種方式來(lái)解決:
- 啟動(dòng) docker 時(shí)添加參數(shù) --shm-size=1gb 來(lái)增大 /dev/shm 共享內(nèi)存样悟,但是 swarm 目前不支持 shm-size 參數(shù)
- 啟動(dòng) Chrome 添加參數(shù) - disable-dev-shm-usage拂募,禁止使用 /dev/shm 共享內(nèi)存
- 盡量使用同一個(gè)瀏覽器實(shí)例,這樣可以實(shí)現(xiàn)緩存共用
- 通過(guò)請(qǐng)求攔截沒(méi)必要加載的資源
- 像我們自己打開(kāi) Chrome 一樣窟她,tab 頁(yè)多必然會(huì)卡陈症,所以必須有效控制 tab 頁(yè)個(gè)數(shù)
- 一個(gè) Chrome 實(shí)例啟動(dòng)時(shí)間長(zhǎng)了難免會(huì)出現(xiàn)內(nèi)存泄漏,頁(yè)面奔潰等現(xiàn)象震糖,所以定時(shí)重啟 Chrome 實(shí)例是有必要的
- 為了加快性能录肯,關(guān)閉沒(méi)必要的配置,比如:-no-sandbox(沙箱功能)试伙,--disable-extensions(擴(kuò)展程序)等
- 盡量避免使用 page.waifFor(1000)嘁信,讓程序自己決定效果會(huì)更好
- 因?yàn)楹?Chrome 實(shí)例連接時(shí)使用的 Websocket,會(huì)存在 Websocket sticky session 問(wèn)題.