文章出處

很早很早之前,前端就有了對 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
  2. width
  3. height
  4. deviceScaleFactor
  5. isMobile
  6. hasTouch
  7. isLandscape
  8. 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 可以設置
  2. button
  3. clickCount
  4. mouse.move(x, y, [options]): options 可以設置
  5. steps
  6. mouse.down([options])
  7. mouse.up([options])
  8. 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


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()