Puppeteer是 Google Chrome 團隊官方的 Headless Chrome 工具,平時常用它來完成一些煩雜的重復性工作,也寫過一些爬蟲,在瀏覽器中手動完成的大部分事情都可以使用 Puppeteer
完成。也算是測試同學手中的一大利器吧。
安裝
就按管方文檔中來吧,主要就是設置兩個環境變量:
# 如果不想安裝Chromium.app
# export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# 如果要安裝Chromium.app,國外的源太慢,切回到國內的源
# export PUPPETEER_DOWNLOAD_HOST=https://storage.googleapis.com.cnpmjs.org
npm i puppeteer
如果沒有安裝Chromium.app,要用本地的Chrome,只要設置好本地的Chrome位置即可:
const browser = await puppeteer.launch({ executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", headless: false, slowMo: 500, devtools: true });
在Docker上運行
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();
注意:
因為Chrome默認使用 /dev/shm
共享內存,但是 docker 默認 /dev/shm
很小。所以啟動Chrome要添加參數 -disable-dev-shm-usage
,不用/dev/shm
共享內存。
獲取Console內容
page.on('console', async msg => { if (msg.text() === 'CONVEY_DONE') { await browser.close(); } });
加斷點調試
只要在前端 evaluate
的代碼中加入 debugger
就可以了,當執行到此處時,會進入調試狀態:
await page.evaluate(() => {debugger;});
添加自定義函數
添加MD5函數
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 計算哈希 const myString = 'PUPPETEER'; const myHash = await window.md5(myString); console.log(md5 of ${myString} is ${myHash}); }); await browser.close(); });
添加readfile函數
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 讀取文件內容 const content = await window.readfile('/etc/hosts'); console.log(content); }); await browser.close(); });
向中 window
添加方法的功能很強大,可以避免瀏覽器的一些限制。
頁面加載前定制處理
evaluateOnNewDocument
可以指定函數在所屬的頁面被創建,並且所屬頁面的任意 script 執行之前被調用。可以用這個辦法修改頁面的javascript環境,比如給 Math.random
設定種子等。
下面是在頁面加載前重寫 navigator.languages
屬性的例子:
// preload.js // 重寫 `languages` 屬性,使其用一個新的get方法 Object.defineProperty(navigator, "languages", { get: function() { return ["en-US", "en", "bn"]; } }); // preload.js 和當前的代碼在同一個目錄 const preloadFile = fs.readFileSync('./preload.js', 'utf8'); await page.evaluateOnNewDocument(preloadFile);
再舉個重置定位信息的例子:
//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) } });
請求攔截
舉個例子,通過請求攔截器取消所有圖片請求,這樣可以加快執行的速度:
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對象 interceptedRequest.continue( headers: Object.assign({}, request.headers(), { 'SlaveID': '4c625b7861a92c7971cd2029c2fd3c4a' }); }); await page.goto('https://example.com'); await browser.close(); });
注意 啟用請求攔截器會禁用頁面緩存。
並行運行
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() }) })();
前端運行的代碼
在運用Puppeteer過程中,免不得大量的運行在前端的代碼,即運行在瀏覽器中的代碼。主要用於查找元素、獲取元元素的屬性等,以下舉幾個例子說明:
定位元素
// button的id和class等屬性變化,文本卻不變,可以用innerText來准確定位操作它 await page.evaluate(() => { let btns = [...document.querySelector(".HmktE").querySelectorAll("button")]; btns.forEach(function (btn) { if (btn.innerText == "Log In") btn.click(); }); });
獲取元素信息
一個thal 中的例子,回調函數可以接收多個參數:
for (let h = 1; h <= numPages; h++) { // 跳轉到指定頁碼 await page.goto(`${searchUrl}&p=${h}`); // 執行爬取 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`);
運行於前端的代碼,主要是由 page.$eval()
、page.evaluate()
之類的函數來執行。它們有些區別。 page.evaluate
,可傳入多個參數,或第二個參數作為句柄,而 page.$eval
則針對選中的一個 DOM 元素執行操作。比如:
// 獲取 html // 獲取上下文句柄 const bodyHandle = await page.$('body'); // 執行計算 const bodyInnerHTML = await page.evaluate(dom => dom.innerHTML, bodyHandle); // 銷毀句柄 await bodyHandle.dispose(); console.log('bodyInnerHTML:', bodyInnerHTML);
而 page.$eval
看上去簡潔得多:
const bodyInnerHTML = await page.$eval('body', dom => dom.innerHTML); console.log('bodyInnerHTML: ', bodyInnerHTML);
截圖
Puppeteer 既可以對某個頁面進行截圖,也可以對頁面中的某個元素進行截圖:
// 截屏 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會被javascript改動時,可以考慮合並多個 async
,不要用:
const $atag = await page.$('a.order-list'); const link = await $atag.getProperty('href'); await $atag.click();
而是用用一個 async
代替:
await page.evaluate(() => { const $atag = document.querySelector('a.order-list'); const text = $atag.href; $atag.click(); });
兩個運行環境
Puppeteer代碼是分別跑在Node.js和瀏覽器兩個javascript運行時中的。Puppeteer腳本是運行在Node.js中的,但是 evaluate
、 evaluateHandle
等操作DOM的代碼卻是運行在瀏覽器中的。同樣,Puppeteer也提供了提供了 ElementHandle
和 JsHandle
將 頁面中元素和DOM對象封裝成對應的 Node.js 對象,這樣可以直接這些對象的封裝函數進行操作 Page DOM。理解這些概念很重要。
所以在執行前端代碼時,前端代碼函數會先被序列化傳給瀏覽器再運行。所以,兩個運行時不能共享變量:
// 不能工作,瀏覽器中訪問不到atag這個變量 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);
等待
等待頁面加載
幾個打開頁面的函數,如goto、waitForNavigation、reload等函數內置有等待參數:waitUtil 和 timeout,可以用它來等待頁面打開:
await page.goto('...', { timeout: 60000, waitUntil: [ 'load', //等待 “load” 事件觸發 'domcontentloaded', //等待 “domcontentloaded” 事件觸發 'networkidle0', //在 500ms 內沒有任何網絡連接 'networkidle2' //在 500ms 內網絡連接個數不超過 2 個 ] });
另外,點擊了鏈接之后,需要使用 page.waitForNavigation 來等待頁面加載。
await page.goto(...); await Promise.all([ page.click('a'), await page.waitForNavigation() ]);
等待元素或響應
- page.waitForXPath:用XPath等待頁面元素,返回對應的 ElementHandle 實例
- page.waitForSelector :用CSS選擇器等待頁面元素,返回對應的 ElementHandle 實例
- page.waitForResponse :等待響應結束,返回 Response 實例
- page.waitForRequest:等待請求發起,返回 Request 實例
await page.waitForXPath('//a'); await page.waitForSelector('#gameAccount'); await page.waitForResponse('.../api/user/123'); await page.waitForRequest('.../api/users');
自定義等待
如果現有的等待機制都不能滿足需求,puppeteer 還提供了兩個函數:
- page.waitForFunction:等待在頁面中自定義函數的執行結果,返回 JsHandle 實例
- page.waitFor:設置指定的等待時間
await page.goto('...', { timeout: 60000, waitUntil: 'networkidle2' }); // 業務代碼中設定window中的對象,存在表示加載完成 let acquireHandle = await page.waitForFunction('window.ACQUIREDONE', { polling: 120 }); const acquireResult = await acquireHandle.jsonValue(); console.info(acquireResult);
基於Puppeteer的框架
從上面看出Puppeteer編寫腳本並不是很直觀,可以考慮用其它更好的框架,比如Rize 。比如,用Rize寫的代碼類似於下面這樣的,明顯比原生的Puppeteer代碼要簡潔、直觀的多。
原生的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() })()
對比用Rize寫的代碼:
const Rize = require('rize') const rize = new Rize() rize .goto('https://github.com') .saveScreenshot('github.png') .end()
而且用Rize寫代碼時,仍然可以用原生Puppeteer的Api來寫。
性能優化
- 如有可能盡量使用同一個瀏覽器實例,或多個實例指定相同的緩存路徑,這樣緩存可以共用
- 通過請求攔截沒必要加載的資源,比如圖片或媒體等
- 減少打開的 tab 頁數量,以免占用太多的資源,長時間運行的Puppeteer腳本,最好定時重啟 Chrome 實例
- 啟動Chrome時關閉沒必要的配置,比如:-no-sandbox(沙箱功能),--disable-extensions(擴展程序)等
作者:OF656
鏈接:https://www.jianshu.com/p/2f233e60bf33
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。