很早很早之前,前端就有了對 headless 瀏覽器的需求,最多的應用場景有兩個
- UI 自動化測試:擺脫手工瀏覽點擊頁面確認功能模式
- 爬蟲:解決頁面內容異步加載等問題
也就有了很多傑出的實現,前端經常使用的莫過於 PhantomJS 和 selenium-webdriver,但兩個庫有一個共性——難用!環境安裝復雜,API 調用不友好,1027 年 Chrome 團隊連續放了兩個大招 Headless Chrome 和對應的 NodeJS API Puppeteer,直接讓 PhantomJS 和 Selenium IDE for Firefox 作者懸宣布沒必要繼續維護其產品
Puppeteer
如同其 github 項目介紹:Puppeteer 是一個通過 DevTools Protocol 控制 headless chrome 的 high-level Node 庫,也可以通過設置使用 非 headless Chrome
我們手工可以在瀏覽器上做的事情 Puppeteer 都能勝任
- 生成網頁截圖或者 PDF
- 爬取大量異步渲染內容的網頁,基本就是人肉爬蟲
- 模擬鍵盤輸入、表單自動提交、UI 自動化測試
官方提供了一個 playground,可以快速體驗一下。關於其具體使用不在贅述,官網的 demo 足矣讓完全不了解的同學入門
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();
})();
實現網頁截圖就這么簡單,自己也實現了一個簡單的爬取百度圖片的搜索結果的 demo,代碼不過 40 行,用過 selenium-webdriver 的同學看了會流淚,接下來介紹幾個好玩的特性
哲學
雖然 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.sendCharacter('test');
在使用中可以多感受一下區別,會發現 Puppeteer 的使用會自然很多
async/await
看官方的例子就可以看出來,幾乎所有的操作都是異步的,如果堅持使用回調或者 Promise.then 寫出來的代碼會非常丑陋且難讀,Puppeteer 官方推薦的也是使用高版本 Node 用 async/await 語法
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('https://news.ycombinator.com', {waitUntil: 'networkidle'});
await page.pdf({path: 'hn.pdf', format: 'A4'});
await browser.close();
})();
查找元素
這是 UI 自動化測試最常用的功能了,Puppeteer 的處理也相當簡單
- page.$(selector)
- page.$$(selector)
這兩個函數分別會在頁面內執行 document.querySelector 和 document.querySelectorAll,但返回值卻不是 DOM 對象,如同 jQuery 的選擇器,返回的是經過自己包裝的 Promise<ElementHandle>,ElementHandle 幫我們封裝了常用的 click 、boundingBox 等方法
獲取 DOM 屬性
我們寫爬蟲爬取頁面圖片列表,感覺可以通過 page.$$(selector) 獲取到頁面的元素列表,然后再去轉成 DOM 對象,獲取 src,然后並不行,想做對獲取元素對應 DOM 屬性的獲取,需要用專門的 API
- page.$eval(selector, pageFunction[, ...args])
- page.$$eval(selector, pageFunction[, ...args])
大概用法
const searchValue = await page.$eval('#search', el => el.value);
const preloadHref = await page.$eval('link[rel=preload]', el => el.href);
const html = await page.$eval('.main-container', e => e.outerHTML);
const divsCounts = await page.$$eval('div', divs => divs.length);
值得注意的是如果 pageFunction 返回的是 Promise,那么 page.$eval 會等待方法 resolve
evaluate
如果我們有一些及其個性的需求,無法通過 page.$() 或者 page.$eval() 實現,可以用大招——evaluate,有幾個相關的 API
- page.evaluate(pageFunction, …args)
- page.evaluateHandle(pageFunction, …args):
- page.evaluateOnNewDocument(pageFunction, ...args)
這幾個函數非常類似,都是可以在頁面環境執行我們舒心的 JavaScript,區別主要在執行環境和返回值上
前兩個函數都是在當前頁面環境內執行,的主要區別在返回值上,第一個返回一個 Serializable 的 Promise,第二個返回值是前面提到的 ElementHandle 對象父類型 JSHandle 的 Promise
const result = await page.evaluate(() => {
return Promise.resolve(8 * 7);
});
console.log(result); // prints "56"
const aWindowHandle = await page.evaluateHandle(() => Promise.resolve(window));
aWindowHandle; // Handle for the window object. 相當於把返回對象做了一層包裹
page.evaluateOnNewDocument(pageFunction, ...args) 是在 browser 環境中執行,執行時機是文檔被創建完成但是 script 沒有執行階段,經常用於修改 JavaScript 環境
注冊函數
page.exposeFunction(name, puppeteerFunction) 用於在 window 對象注冊一個函數,我們可以添加一個 window.readfile 函數
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();
});
修改終端
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/DeviceDescriptors 還給我們提供了幾個大禮包
const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone 6'];
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();
});
鍵盤
- keyboard.down
- keyboard.up
- keyboard.press
- keyboard.type
- keyboard.sendCharacter
// 直接輸入、按鍵
page.keyboard.type('Hello World!');
page.keyboard.press('ArrowLeft');
// 按住不放
page.keyboard.down('Shift');
for (let i = 0; i < ' World'.length; i++)
page.keyboard.press('ArrowLeft');
page.keyboard.up('Shift');
page.keyboard.press('Backspace');
page.keyboard.sendCharacter('嗨');
鼠標 & 屏幕
- mouse.click(x, y, [options]): options 可以設置
- button
- clickCount
- mouse.move(x, y, [options]): options 可以設置
- steps
- mouse.down([options])
- mouse.up([options])
- touchscreen.tap(x, y)
頁面跳轉控制
這幾個 API 比較簡單,不在展開介紹
- page.goto(url, options)
- page.goback(options)
- page.goForward(options)
事件
Puppeteer 提供了對一些頁面常見事件的監聽,用法和 jQuery 很類似,常用的有
- console:調用 console API
- dialog:頁面出現彈窗
- error:頁面 crash
- load
- pageerror:頁面內未捕獲錯誤
page.on('load', async () => {
console.log('page loading done, start fetch...');
const srcs = await page.$$eval((img) => img.src);
console.log(`get ${srcs.length} images, start download`);
srcs.forEach(async (src) => {
// sleep
await page.waitFor(200);
await srcToImg(src, mn);
});
await browser.close();
});
性能
通過 page.getMetrics() 可以得到一些頁面性能數據
TimestampThe timestamp when the metrics sample was taken.Documents頁面文檔數Frames頁面 frame 數JSEventListeners頁面內事件監聽器數Nodes頁面 DOM 節點數LayoutCount頁面 layout 數RecalcStyleCount樣式重算數LayoutDuration頁面 layout 時間RecalcStyleDuration樣式重算時長ScriptDurationscript 時間TaskDuration所有瀏覽器任務時長JSHeapUsedSizeJavaScript 占用堆大小JSHeapTotalSizeJavaScript 堆總量
{
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
}
最后
本文知識介紹了部分常用的 API,全部的 API 可以在 github 上查看,由於 Puppeteer 還沒有發布正式版,API 迭代比較迅速,在使用中遇到問題也可以在 issue 中反饋。
在 0.11 版本中只有 page.$eval 並沒有 page.$$eval,使用的時候只能通過 page.evaluate,通過大家的反饋,在 0.12 中已經添加了該功能,總體而言 Puppeteer 還是一個十分值得期待的 Node headless API
參考
Getting Started with Headless Chrome
Getting started with Puppeteer and Chrome Headless for Web Scraping
