Puppeteer使用總結(jié)
Puppeteer是 Google Chrome 團(tuán)隊(duì)官方的 Headless Chrome 工具球化,平時(shí)常用它來完成一些煩雜的重復(fù)性工作括丁,也寫過一些爬蟲,在瀏覽器中手動(dòng)完成的大部分事情都可以使用 Puppeteer
完成婶芭。也算是測(cè)試同學(xué)手中的一大利器吧堕花。
安裝
就按管方文檔中來吧哮内,主要就是設(shè)置兩個(gè)環(huán)境變量:
# 如果不想安裝Chromium.app
# export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# 如果要安裝Chromium.app祈噪,國(guó)外的源太慢,切回到國(guó)內(nèi)的源
# export PUPPETEER_DOWNLOAD_HOST=https://storage.googleapis.com.cnpmjs.org
npm i puppeteer
如果沒有安裝Chromium.app尚辑,要用本地的Chrome辑鲤,只要設(shè)置好本地的Chrome位置即可:
const browser = await puppeteer.launch({
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
headless: false,
slowMo: 500,
devtools: true
});
在Docker上運(yùn)行
docker run -p 8080:3000 --restart always -d --name browserless browserless/chrome
然后在腳本中
const puppeteer = require('puppeteer');
// 從 puppeteer.launch() 為:
const browser = await puppeteer.connect({ browserWSEndpoint: 'ws://localhost:3000' });
const page = await browser.newPage();
...
await page.goto(...);
...
await browser.disconnect();
注意:
因?yàn)镃hrome默認(rèn)使用 /dev/shm
共享內(nèi)存,但是 docker 默認(rèn) /dev/shm
很小杠茬。所以啟動(dòng)Chrome要添加參數(shù) -disable-dev-shm-usage
月褥,不用/dev/shm
共享內(nèi)存。
獲取Console內(nèi)容
page.on('console', async msg => {
if (msg.text() === 'CONVEY_DONE') {
await browser.close();
}
});
加斷點(diǎn)調(diào)試
只要在前端 evaluate
的代碼中加入 debugger
就可以了瓢喉,當(dāng)執(zhí)行到此處時(shí)宁赤,會(huì)進(jìn)入調(diào)試狀態(tài):
await page.evaluate(() => {debugger;});
添加自定義函數(shù)
添加MD5函數(shù)
const puppeteer = require('puppeteer');
const crypto = require('crypto');
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.exposeFunction('md5', text =>
crypto.createHash('md5').update(text).digest('hex')
);
await page.evaluate(async () => {
// 使用 window.md5 計(jì)算哈希
const myString = 'PUPPETEER';
const myHash = await window.md5(myString);
console.log(md5 of ${myString} is ${myHash});
});
await browser.close();
});
添加readfile函數(shù)
const puppeteer = require('puppeteer');
const fs = require('fs');
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
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 () => {
// 使用 window.readfile 讀取文件內(nèi)容
const content = await window.readfile('/etc/hosts');
console.log(content);
});
await browser.close();
});
向中 window
添加方法的功能很強(qiáng)大,可以避免瀏覽器的一些限制栓票。
頁面加載前定制處理
evaluateOnNewDocument
可以指定函數(shù)在所屬的頁面被創(chuàng)建决左,并且所屬頁面的任意 script 執(zhí)行之前被調(diào)用∽咛埃可以用這個(gè)辦法修改頁面的javascript環(huán)境佛猛,比如給 Math.random
設(shè)定種子等。
下面是在頁面加載前重寫 navigator.languages
屬性的例子:
// preload.js
// 重寫 `languages` 屬性坠狡,使其用一個(gè)新的get方法
Object.defineProperty(navigator, "languages", {
get: function() {
return ["en-US", "en", "bn"];
}
});
// preload.js 和當(dāng)前的代碼在同一個(gè)目錄
const preloadFile = fs.readFileSync('./preload.js', 'utf8');
await page.evaluateOnNewDocument(preloadFile);
再舉個(gè)重置定位信息的例子:
//Firstly, we need to override the permissions
//so we don't have to click "Allow Location Access"
const context = browser.defaultBrowserContext();
await context.overridePermissions(url, ['geolocation']);
...
const page = await browser.newPage();
//whenever the location is requested, it will be set to our given lattitude, longitude
await page.evaluateOnNewDocument(function () {
navigator.geolocation.getCurrentPosition = function (cb) {
setTimeout(() => {
cb({
'coords': {
accuracy: 21,
altitude: null,
altitudeAccuracy: null,
heading: null,
latitude: 0.62896,
longitude: 77.3111303,
speed: null
}
})
}, 1000)
}
});
請(qǐng)求攔截
舉個(gè)例子继找,通過請(qǐng)求攔截器取消所有圖片請(qǐng)求,這樣可以加快執(zhí)行的速度:
const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', interceptedRequest => {
if (interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg'))
interceptedRequest.abort();
else
// 改寫request對(duì)象
interceptedRequest.continue(
headers: Object.assign({}, request.headers(), {
'SlaveID': '4c625b7861a92c7971cd2029c2fd3c4a'
});
});
await page.goto('https://example.com');
await browser.close();
});
注意 啟用請(qǐng)求攔截器會(huì)禁用頁面緩存逃沿。
并行運(yùn)行
const puppeteer = require('puppeteer')
const parallel = 5;
(async () => {
puppeteer.launch().then(async browser => {
const promises = []
for (let i = 0; i < parallel; i++) {
console.log('Page ID Spawned', i)
promises.push(browser.newPage().then(async page => {
await page.setViewport({ width: 1280, height: 800 })
await page.goto('https://en.wikipedia.org/wiki/' + i)
await page.screenshot({ path: 'wikipedia_' + i + '.png' })
}))
}
await Promise.all(promises)
await browser.close()
})
})();
前端運(yùn)行的代碼
在運(yùn)用Puppeteer過程中婴渡,免不得大量的運(yùn)行在前端的代碼,即運(yùn)行在瀏覽器中的代碼凯亮。主要用于查找元素边臼、獲取元元素的屬性等,以下舉幾個(gè)例子說明:
定位元素
// button的id和class等屬性變化假消,文本卻不變硼瓣,可以用innerText來準(zhǔn)確定位操作它
await page.evaluate(() => {
let btns = [...document.querySelector(".HmktE").querySelectorAll("button")];
btns.forEach(function (btn) {
if (btn.innerText == "Log In")
btn.click();
});
});
獲取元素信息
一個(gè)thal 中的例子,回調(diào)函數(shù)可以接收多個(gè)參數(shù):
for (let h = 1; h <= numPages; h++) {
// 跳轉(zhuǎn)到指定頁碼
await page.goto(`${searchUrl}&p=${h}`);
// 執(zhí)行爬取
const users = await page.evaluate((sInfo, sName, sEmail) => {
return Array.prototype.slice.apply(document.querySelectorAll(sInfo))
.map($userListItem => {
// 用戶名
const username = $userListItem.querySelector(sName).innerText;
// 郵箱
const $email = $userListItem.querySelector(sEmail);
const email = $email ? $email.innerText : undefined;
return {
username,
email,
};
})
// 不是所有用戶都顯示郵箱
.filter(u => !!u.email);
}, USER_LIST_INFO_SELECTOR, USER_LIST_USERNAME_SELECTOR, USER_LIST_EMAIL_SELECTOR);
await page.waitForSelector('.block-items');
const orders = await page.$eval('.block-items', element => {
const ordersHTMLCollection = element.querySelectorAll('.block-item');
const ordersElementArray = Array.prototype.slice.call(ordersHTMLCollection);
const orders = ordersElementArray.map(item => {
const a = item.querySelector('.order-img a');
return {
href: a.getAttribute('href'),
title: a.getAttribute('title'),
};
});
return orders;
});
console.log(`found ${orders.length} order`);
運(yùn)行于前端的代碼,主要是由 page.$eval()
堂鲤、page.evaluate()
之類的函數(shù)來執(zhí)行亿傅。它們有些區(qū)別。 page.evaluate
瘟栖,可傳入多個(gè)參數(shù)葵擎,或第二個(gè)參數(shù)作為句柄,而 page.$eval
則針對(duì)選中的一個(gè) DOM 元素執(zhí)行操作半哟。比如:
// 獲取 html
// 獲取上下文句柄
const bodyHandle = await page.$('body');
// 執(zhí)行計(jì)算
const bodyInnerHTML = await page.evaluate(dom => dom.innerHTML, bodyHandle);
// 銷毀句柄
await bodyHandle.dispose();
console.log('bodyInnerHTML:', bodyInnerHTML);
而 page.$eval
看上去簡(jiǎn)潔得多:
const bodyInnerHTML = await page.$eval('body', dom => dom.innerHTML);
console.log('bodyInnerHTML: ', bodyInnerHTML);
截圖
Puppeteer 既可以對(duì)某個(gè)頁面進(jìn)行截圖酬滤,也可以對(duì)頁面中的某個(gè)元素進(jìn)行截圖:
// 截屏
await page.screenshot({
path: './full.png',
fullPage: true
// 也可截部分
// clip: {x: 0, y: 0, width: 1920, height: 800}
});
// 截元素
let [el] = await page.$x('#order-item');
await el.screenshot({
path: './part.png'
});
避免頁面中DOM變化
如果頁面中DOM會(huì)被javascript改動(dòng)時(shí),可以考慮合并多個(gè) async
寓涨,不要用:
const $atag = await page.$('a.order-list');
const link = await $atag.getProperty('href');
await $atag.click();
而是用用一個(gè) async
代替:
await page.evaluate(() => {
const $atag = document.querySelector('a.order-list');
const text = $atag.href;
$atag.click();
});
兩個(gè)運(yùn)行環(huán)境
Puppeteer代碼是分別跑在Node.js和瀏覽器兩個(gè)javascript運(yùn)行時(shí)中的盯串。Puppeteer腳本是運(yùn)行在Node.js中的,但是 evaluate
戒良、 evaluateHandle
等操作DOM的代碼卻是運(yùn)行在瀏覽器中的体捏。同樣,Puppeteer也提供了提供了 ElementHandle
和 JsHandle
將 頁面中元素和DOM對(duì)象封裝成對(duì)應(yīng)的 Node.js 對(duì)象糯崎,這樣可以直接這些對(duì)象的封裝函數(shù)進(jìn)行操作 Page DOM几缭。理解這些概念很重要。
所以在執(zhí)行前端代碼時(shí)沃呢,前端代碼函數(shù)會(huì)先被序列化傳給瀏覽器再運(yùn)行年栓。所以,兩個(gè)運(yùn)行時(shí)不能共享變量:
// 不能工作薄霜,瀏覽器中訪問不到atag這個(gè)變量
const atag = 'a';
await page.goto(...);
const clicked = await page.evaluate(() => document.querySelector(atag).click());
只能用變量傳遞的方式:
const atag = 'a';
await page.goto(...);
const clicked = await page.evaluate(($sel) => document.querySelector($sel).click(), atag);
等待
等待頁面加載
幾個(gè)打開頁面的函數(shù)某抓,如goto、waitForNavigation惰瓜、reload等函數(shù)內(nèi)置有等待參數(shù):waitUtil 和 timeout搪缨,可以用它來等待頁面打開:
await page.goto('...', {
timeout: 60000,
waitUntil: [
'load', //等待 “l(fā)oad” 事件觸發(fā)
'domcontentloaded', //等待 “domcontentloaded” 事件觸發(fā)
'networkidle0', //在 500ms 內(nèi)沒有任何網(wǎng)絡(luò)連接
'networkidle2' //在 500ms 內(nèi)網(wǎng)絡(luò)連接個(gè)數(shù)不超過 2 個(gè)
]
});
另外,點(diǎn)擊了鏈接之后鸵熟,需要使用 page.waitForNavigation 來等待頁面加載副编。
await page.goto(...);
await Promise.all([
page.click('a'),
await page.waitForNavigation()
]);
等待元素或響應(yīng)
- page.waitForXPath:用XPath等待頁面元素,返回對(duì)應(yīng)的 ElementHandle 實(shí)例
- page.waitForSelector :用CSS選擇器等待頁面元素流强,返回對(duì)應(yīng)的 ElementHandle 實(shí)例
- page.waitForResponse :等待響應(yīng)結(jié)束痹届,返回 Response 實(shí)例
- page.waitForRequest:等待請(qǐng)求發(fā)起,返回 Request 實(shí)例
await page.waitForXPath('//a');
await page.waitForSelector('#gameAccount');
await page.waitForResponse('.../api/user/123');
await page.waitForRequest('.../api/users');
自定義等待
如果現(xiàn)有的等待機(jī)制都不能滿足需求打月,puppeteer 還提供了兩個(gè)函數(shù):
- page.waitForFunction:等待在頁面中自定義函數(shù)的執(zhí)行結(jié)果队腐,返回 JsHandle 實(shí)例
- page.waitFor:設(shè)置指定的等待時(shí)間
await page.goto('...', {
timeout: 60000,
waitUntil: 'networkidle2'
});
// 業(yè)務(wù)代碼中設(shè)定window中的對(duì)象,存在表示加載完成
let acquireHandle = await page.waitForFunction('window.ACQUIREDONE', {
polling: 120
});
const acquireResult = await acquireHandle.jsonValue();
console.info(acquireResult);
基于Puppeteer的框架
從上面看出Puppeteer編寫腳本并不是很直觀奏篙,可以考慮用其它更好的框架柴淘,比如Rize 迫淹。比如,用Rize寫的代碼類似于下面這樣的为严,明顯比原生的Puppeteer代碼要簡(jiǎn)潔敛熬、直觀的多。
原生的Puppeteer代碼:
const puppeteer = require('puppeteer')
void (async () => {
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.goto('https://github.com')
await page.screenshot({ path: 'github.png' })
await browser.close()
})()
對(duì)比用Rize寫的代碼:
const Rize = require('rize')
const rize = new Rize()
rize
.goto('https://github.com')
.saveScreenshot('github.png')
.end()
而且用Rize寫代碼時(shí)第股,仍然可以用原生Puppeteer的Api來寫应民。
性能優(yōu)化
- 如有可能盡量使用同一個(gè)瀏覽器實(shí)例,或多個(gè)實(shí)例指定相同的緩存路徑夕吻,這樣緩存可以共用
- 通過請(qǐng)求攔截沒必要加載的資源诲锹,比如圖片或媒體等
- 減少打開的 tab 頁數(shù)量,以免占用太多的資源涉馅,長(zhǎng)時(shí)間運(yùn)行的Puppeteer腳本归园,最好定時(shí)重啟 Chrome 實(shí)例
- 啟動(dòng)Chrome時(shí)關(guān)閉沒必要的配置,比如:-no-sandbox(沙箱功能)稚矿,--disable-extensions(擴(kuò)展程序)等