Puppeteer: 更友好的 Headless Chrome Node API


很早很早之前,前端就有了對 headless 瀏覽器的需求,最多的應用場景有兩個

  1. UI 自動化測試:擺脫手工瀏覽點擊頁面確認功能模式
  2. 爬蟲:解決頁面內容異步加載等問題

也就有了很多傑出的實現,前端經常使用的莫過於 PhantomJSselenium-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 都能勝任

  1. 生成網頁截圖或者 PDF
  2. 爬取大量異步渲染內容的網頁,基本就是人肉爬蟲
  3. 模擬鍵盤輸入、表單自動提交、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 流程

  1. 通過選擇器找到頁面 input 元素
  2. 給元素設置值
    const input = await driver.findElement(By.id('kw'));
    await input.sendKeys('test');

Puppeteer 流程

  1. 光標應該 focus 到元素上
  2. 鍵盤點擊輸入
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 的處理也相當簡單

  1. page.$(selector)
  2. page.$$(selector)

這兩個函數分別會在頁面內執行 document.querySelectordocument.querySelectorAll,但返回值卻不是 DOM 對象,如同 jQuery 的選擇器,返回的是經過自己包裝的 Promise<ElementHandle>,ElementHandle 幫我們封裝了常用的 clickboundingBox 等方法

獲取 DOM 屬性

我們寫爬蟲爬取頁面圖片列表,感覺可以通過 page.$$(selector) 獲取到頁面的元素列表,然后再去轉成 DOM 對象,獲取 src,然后並不行,想做對獲取元素對應 DOM 屬性的獲取,需要用專門的 API

  1. page.$eval(selector, pageFunction[, ...args])
  2. 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

  1. page.evaluate(pageFunction, …args)
  2. page.evaluateHandle(pageFunction, …args):
  3. 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 提供了幾個有用的方法讓我們可以修改設備信息

  1. page.setViewport(viewport)
  2. 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):可以用來修改頁面訪問的媒體類型,但僅僅支持

  1. screen
  2. print
  3. null:禁用 media emulation

page.emulate(options):前面介紹的幾個函數相當於這個函數的快捷方式,這個函數可以設置多個內容

  1. viewport
    1. width
    2. height
    3. deviceScaleFactor
    4. isMobile
    5. hasTouch
    6. isLandscape
  2. 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();
});

鍵盤

  1. keyboard.down
  2. keyboard.up
  3. keyboard.press
  4. keyboard.type
  5. 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('嗨');

鼠標 & 屏幕

  1. mouse.click(x, y, [options]): options 可以設置
    1. button
    2. clickCount
  2. mouse.move(x, y, [options]): options 可以設置
    1. steps
  3. mouse.down([options])
  4. mouse.up([options])
  5. touchscreen.tap(x, y)

頁面跳轉控制

這幾個 API 比較簡單,不在展開介紹

  1. page.goto(url, options)
  2. page.goback(options)
  3. page.goForward(options)

事件

Puppeteer 提供了對一些頁面常見事件的監聽,用法和 jQuery 很類似,常用的有

  1. console:調用 console API
  2. dialog:頁面出現彈窗
  3. error:頁面 crash
  4. load
  5. 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() 可以得到一些頁面性能數據

  • Timestamp The timestamp when the metrics sample was taken.
  • Documents 頁面文檔數
  • Frames 頁面 frame 數
  • JSEventListeners 頁面內事件監聽器數
  • Nodes 頁面 DOM 節點數
  • LayoutCount 頁面 layout 數
  • RecalcStyleCount 樣式重算數
  • LayoutDuration 頁面 layout 時間
  • RecalcStyleDuration 樣式重算時長
  • ScriptDuration script 時間
  • TaskDuration 所有瀏覽器任務時長
  • JSHeapUsedSize JavaScript 占用堆大小
  • JSHeapTotalSize JavaScript 堆總量
{ 
  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

無頭瀏覽器 Puppeteer 初探

Getting started with Puppeteer and Chrome Headless for Web Scraping


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM