記得前幾年,我們通常會用PhantomJs做一下自動化測試,或者為了SEO優化,會用它對SPA頁面進行預渲染,現在有更好的Puppeteer來代替它的工作了,性能更好,使用起來也更加友善,Puppeteer 是 Chrome 開發團隊在 2017 年釋出的一個 Node.js 包,用來模拟 Chrome 浏覽器的運作。
官網
https://pptr.dev/
就如官網所介紹的,pptr可以做以下的事情:
- 生成頁面的螢幕截圖和PDF。
- 爬取SPA(單頁應用程式)并生成預渲染的内容(即“ SSR”(伺服器端渲染))。
- 自動執行表單送出,UI測試,鍵盤輸入等。
- 建立最新的自動化測試環境。使用最新的JavaScript和浏覽器功能,直接在最新版本的Chrome中運作測試。
- 捕獲時間線跟蹤 您的網站以幫助診斷性能問題。
- 測試Chrome擴充程式。
以下片段僅收集一些簡單的介紹以及一些例子,具體使用時,可以在官網進行更詳細的查詢
簡單入門介紹
Puppeteer 中的 API 分層結構基本和浏覽器保持一緻,下面對常使用到的幾個類介紹一下:
- Browser: 對應一個浏覽器執行個體,一個 Browser 可以包含多個 BrowserContext
- BrowserContext: 對應浏覽器一個上下文會話,就像我們打開一個普通的 Chrome 之後又打開一個隐身模式的浏覽器一樣,BrowserContext 具有獨立的 Session(cookie 和 cache 獨立不共享),一個 BrowserContext 可以包含多個 Page
- Page:表示一個 Tab 頁面,通過 browserContext.newPage()/browser.newPage() 建立,browser.newPage() 建立頁面時會使用預設的 BrowserContext,一個 Page 可以包含多個 Frame
- Frame: 一個架構,每個頁面有一個主架構(page.MainFrame()),也可以多個子架構,主要由 iframe 标簽建立産生的
- ExecutionContext: 是 javascript 的執行環境,每一個 Frame 都一個預設的 javascript 執行環境
- ElementHandle: 對應 DOM 的一個元素節點,通過該該執行個體可以實作對元素的點選,填寫表單等行為,我們可以通過選擇器,xPath 等來擷取對應的元素
- JsHandle:對應 DOM 中的 javascript 對象,ElementHandle 繼承于 JsHandle,由于我們無法直接操作 DOM 中對象,是以封裝成 JsHandle 來實作相關功能
- CDPSession:可以直接與原生的 CDP 進行通信,通過 session.send 函數直接發消息,通過 session.on 接收消息,可以實作 Puppeteer API 中沒有涉及的功能
- Coverage:擷取 JavaScript 和 CSS 代碼覆寫率
- Tracing:抓取性能資料進行分析
- Response: 頁面收到的響應
- Request: 頁面發出的請求
如何建立一個 Browser 執行個體
puppeteer 提供了兩種方法用于建立一個 Browser 執行個體:
- puppeteer.connect: 連接配接一個已經存在的 Chrome 執行個體
- puppeteer.launch: 每次都啟動一個 Chrome 執行個體
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();
})();
複制
這兩種方式的對比:
- puppeteer.launch 每次都要重新啟動一個 Chrome 程序,啟動平均耗時 100 到 150 ms,性能欠佳
- puppeteer.connect 可以實作對于同一個 Chrome 執行個體的共用,減少啟動關閉浏覽器的時間消耗
- puppeteer.launch 啟動時參數可以動态修改
- 通過 puppeteer.connect 我們可以遠端連接配接一個 Chrome 執行個體,部署在不同的機器上
- puppeteer.connect 多個頁面共用一個 chrome 執行個體,偶爾會出現 Page Crash 現象,需要進行并發控制,并定時重新開機 Chrome 執行個體
如何等待加載?
在實踐中我們經常會遇到如何判斷一個頁面加載完成了,什麼時機去截圖,什麼時機去點選某個按鈕等問題,那我們到底如何去等待加載呢?
下面我們把等待加載的 API 分為三類進行介紹:
加載導航頁面
- page.goto:打開新頁面
- page.goBack :回退到上一個頁面
- page.goForward :前進到下一個頁面
- page.reload :重新加載頁面
- page.waitForNavigation:等待頁面跳轉
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 對時間性能要求高的使用者來說,還是有點長的
等待元素、請求、響應
- page.waitForXPath:等待 xPath 對應的元素出現,傳回對應的 ElementHandle 執行個體
- page.waitForSelector :等待選擇器對應的元素出現,傳回對應的 ElementHandle 執行個體
- page.waitForResponse :等待某個響應結束,傳回 Response 執行個體
- page.waitForRequest:等待某個請求出現,傳回 Request 執行個體
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 還提供我們提供兩個函數:
- page.waitForFunction:等待在頁面中自定義函數的執行結果,傳回 JsHandle 執行個體
- page.waitFor:設定等待時間,實在沒辦法的做法
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 提供了很多有用的函數去 Page DOM Environment 中執行代碼,這個後面會介紹到
- 其次 Puppeteer 提供了 ElementHandle 和 JsHandle 将 Page DOM Environment 中元素和對象封裝成對應的 Node.js 對象,這樣可以直接這些對象的封裝函數進行操作 Page DOM
一些簡單的使用例子
1、頁面截圖
我們使用 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();
})();
複制
我們怎麼去擷取頁面中的某個元素呢?
- page.$(‘#uniqueId’):擷取某個選擇器對應的第一個元素
- page.$$(‘div’):擷取某個選擇器對應的所有元素
- page.$x(‘//img’):擷取某個 xPath 對應的所有元素
- page.waitForXPath(‘//img’):等待某個 xPath 對應的元素出現
- page.waitForSelector(‘#uniqueId’):等待某個選擇器對應的元素出現
2、 模拟使用者登入
(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('[email protected]', {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 都提供了哪些操作元素的函數呢?
- elementHandle.click():點選某個元素
- elementHandle.tap():模拟手指觸摸點選
- elementHandle.focus():聚焦到某個元素
- elementHandle.hover():滑鼠 hover 到某個元素上
- elementHandle.type(‘hello’):在輸入框輸入文本
3、攔截請求
請求在有些場景下很有必要,攔截一下沒必要的請求提高性能,我們可以在監聽 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 頁面上都提供了哪些事件呢?
- page.on(‘close’) 頁面關閉
- page.on(‘console’) console API 被調用
- page.on(‘error’) 頁面出錯
- page.on(‘load’) 頁面加載完
- page.on(‘request’) 收到請求
- page.on(‘requestfailed’) 請求失敗
- page.on(‘requestfinished’) 請求成功
- page.on(‘response’) 收到響應
- page.on(‘workercreated’) 建立 webWorker
- page.on(‘workerdestroyed’) 銷毀 webWorker
4、擷取 WebSocket 響應
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();
})();
複制
5、在頁面插入 JS腳本
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();
})();
複制
有哪些函數可以在浏覽器環境中執行代碼呢?
- page.evaluate(pageFunction[, …args]):在浏覽器環境中執行函數
- page.evaluateHandle(pageFunction[, …args]):在浏覽器環境中執行函數,傳回 JsHandle 對象
- page.$$eval(selector, pageFunction[, …args]):把 selector 對應的所有元素傳入到函數并在浏覽器環境執行
- page.$eval(selector, pageFunction[, …args]):把 selector 對應的第一個元素傳入到函數在浏覽器環境執行
- page.evaluateOnNewDocument(pageFunction[, …args]):建立一個新的 Document 時在浏覽器環境中執行,會在頁面所有腳本執行之前執行
- page.exposeFunction(name, puppeteerFunction):在 window 對象上注冊一個函數,這個函數在 Node 環境中執行,有機會在浏覽器環境中調用 Node.js 相關函數庫
6、 抓取 iframe 中的元素
一個 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', '[email protected]');
await frame.type('.dlpwd', '123456');
await Promise.all([
frame.click('#dologin'),
page.waitForNavigation()
]);
break;
}
}
await page.close();
await browser.close();
})();
複制
7、頁面性能分析
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();
})();
複制
8、檔案的上傳和下載下傳
在自動化測試中,經常會遇到對于檔案的上傳和下載下傳的需求,那麼在 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();
})();
複制
9、跳轉新 tab 頁處理
在點選一個按鈕跳轉到新的 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;
複制
10、 模拟不同的裝置
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();
});
複制