Puppeteer: 更友好的 Headless Chrome Node API

很早很早以前,前端就有了對後端環境調用瀏覽器頁面功能的需求,最多的應用場景有兩個javascript

  1. UI 自動化測試:擺脫手工瀏覽點擊頁面確認功能模式,使用接口自動化調用界面
  2. 爬蟲:模擬頁面真實渲染,解決內容異步加載等問題

市場上出現過不少優秀的解決方案,在 Puppeteer 出現以前最經常使用的是 PhantomJSselenium-webdriver,但兩個庫有個共同特色——環境安裝複雜,API 調用不語義化。2017 年 Chrome 團隊連續放了兩個大招 Headless Chrome 和對應的 NodeJS API Puppeteer,直接讓 PhantomJS 和 Selenium IDE for Firefox 做者宣佈不必繼續維護其產品html

Puppeteer

如同其 github 項目介紹:Puppeteer 是一個經過 DevTools Protocol 控制 headless chrome 的 high-level Node 庫,提供了高度封裝、使用方便的 API 來模擬用戶在頁面的操做、對瀏覽器事件作出響應等前端

  • 生成頁面 PDF、截圖
  • 自動提交表單,進行 UI 測試,鍵盤輸入等
  • 抓取單頁應用並生成預渲染內容(另外一種思路的 SSR)
  • 捕獲網站的 timeline trace,用來幫助分析性能問題
  • 測試瀏覽器擴展

手動能夠在瀏覽器上作的大部分行爲 Puppeteer 都能經過 API 真實模擬,API 使用很是簡單,看下官網對網頁截圖的示例java

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();
})();
複製代碼

實現網頁截圖就這麼簡單,用過 selenium-webdriver 的同窗看了會流淚,官方提供了一個 playground,能夠快速體驗一下git

哲學

雖然 Puppeteer API 足夠簡單,但若是是從 webdriver 流轉過來的同窗會很不適應,主要是在 webdirver 中操做網頁更多的是從程序的視角,而在 Puppeteer 中網頁瀏覽者的視角。舉個簡單的例子,對一個表單的 input 作輸入github

使用 webdriver 流程web

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

使用 Puppeteer 流程chrome

  1. 光標應該 focus 到元素上
  2. 鍵盤點擊輸入
await page.focus('#kw');
await page.keyboard.type('test');
複製代碼

甚至能夠簡化爲一條語句:向 input 輸入字符npm

await page.type('#kw', 'test');
複製代碼

能夠看到 Puppeteer 的使用流程幾乎是在模擬人的操做,在使用過程當中能夠感覺區別,會發現 Puppeteer 的使用天然不少後端

安裝

npm i puppeteer
複製代碼

比起 PhantomJS 和 selenium-webdriver 實在簡單了太多,安裝 Puppeteer 時會下載最新版本的 Chromium,從 1.7 開始 Puppeteer 每次發佈還會有一個 puppeteer-core 發佈,相對於 puppeteer 有兩個區別

  1. puppeteer-core 不會自動安裝 Chromium
  2. puppeteer-core 忽略全部的 PUPPETEER_* env 變量

如同 Node.js 啓動能夠設置環境變量,puppeteer 也支持特定的環境變量

  • HTTP_PROXY, HTTPS_PROXY, NO_PROXY - 定義用於下載和運行 Chromium 的 HTTP 代理設置。
  • PUPPETEER_SKIP_CHROMIUM_DOWNLOAD - 請勿在安裝步驟中下載綁定的 Chromium。
  • PUPPETEER_DOWNLOAD_HOST - 覆蓋用於下載 Chromium 的 URL 的主機部分。
  • PUPPETEER_CHROMIUM_REVISION - 在安裝步驟中指定一個你喜歡 puppeteer 使用的特定版本的 Chromium。
  • PUPPETEER_EXECUTABLE_PATH - 指定一個 Chrome 或者 Chromium 的可執行路徑,會被用於 puppeteer.launch。具體關於可執行路徑參數的意義,可參考puppeteer.launch([options])

API

Puppeteer API 設計和瀏覽器層次相對應(淺色框體內容目前不在 Puppeteer 中實現)

image.png

  • Puppeteer 使用 DevTools 協議 與瀏覽器進行通訊
  • Browser 實例能夠擁有瀏覽器上下文
  • BrowserContext 實例定義了一個瀏覽會話並可擁有多個頁面
  • Page 至少有一個框架:主框架。 可能還有其餘框架由 iframe框架標籤 建立
  • frame 至少有一個執行上下文 - 默認的執行上下文 - 框架的 JavaScript 被執行。 一個框架可能有額外的與 擴展 關聯的執行上下文
  • Worker 具備單一執行上下文,而且便於與 WebWorkers 進行交互

API 很是豐富,看幾個經常使用的功能

查找元素

這是 UI 自動化測試最經常使用的功能了,Puppeteer 的處理也至關簡單——使用選擇器

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

這兩個函數分別會在頁面內執行 document.querySelectordocument.querySelectorAll ,但返回值卻不是 DOM 對象,如同 jQuery 的選擇器,返回的是通過本身包裝的 Promise,ElementHandle 封裝了經常使用的 click 、boundingBox 等方法

const puppeteer = require('puppeteer');

puppeteer.launch().then(async browser => {
  const page = await browser.newPage();
  await page.goto('https://google.com');
  const inputElement = await page.$('input[type=submit]');
  await inputElement.click();
  // ...
});
複製代碼

瀏覽器實例環境

經過 ElementHandle 並不能直接獲取對應 DOM 元素的屬性,須要使用專門的 API 操做

  1. page.$eval(selector, pageFunction [, ...args])
  2. page.$$eval(selector, pageFunction [, ...args])

pageFunction 的代碼會在瀏覽器實例中執行,因此能夠用 Window 等 dom 對象;其返回值是整個方法的返回值

const searchValue = await page.$eval('#search', el => el.value);
const html = await page.$eval('.main-container', e => e.outerHTML);
const divsCounts = await page.$eval('div', divs => divs.length);
複製代碼

page.evaluate(pageFunction [, ...args]) 是上述方法的抽象,能夠在瀏覽器示例中執行任意方法

const result = await page.evaluate(x => {
  return Promise.resolve(8 * x);
}, 7); // 7 會作爲實參傳入 pageFunction
console.log(result); // 輸出 "56"
複製代碼

前面提到的 ElementHandle 實例 能夠做爲參數傳給 page.evaluate

const bodyHandle = await page.$('body');
const html = await page.evaluate(body => body.innerHTML, bodyHandle);
複製代碼

鍵盤

Puppeteer 經過 page.keyboard 對象暴露操做鍵盤的接口

await page.keyboard.type('Hello World!', {delay: 100});
await page.keyboard.press('ArrowLeft');

await page.keyboard.down('Shift');
for (let i = 0; i < ' World'.length; i++)
  await page.keyboard.press('ArrowLeft');
await page.keyboard.up('Shift');

await page.keyboard.press('Backspace');
// 結果字符串最終爲 'Hello!'
複製代碼

方法看名字就知道什麼意思,type 和 sendCharacter 做用很是相似,區別是

  • sendCharacter 會觸發 keypressinput 事件,不會觸發 keydownkeyup 事件
  • type 會觸發keydown, keypress/inputkeyup 事件

特殊鍵名參考:github.com/puppeteer/p…

鼠標

Puppeteer 經過 page.mouse 對象暴露操做鍵盤的接口

// 使用 ‘page.mouse’ 追蹤 100x100 的矩形。
await page.mouse.move(0, 0);
await page.mouse.down();
await page.mouse.move(0, 100);
await page.mouse.move(100, 100);
await page.mouse.move(100, 0);
await page.mouse.move(0, 0);
await page.mouse.up();
複製代碼

tap

手機頁面常用 tap 事件,用 page.mouse.click() 是不能觸發的,須要使用專門的 tap API

  • touchscreen.tap(x, y):觸發 touchstart 和 touchend 事件
  • page.tap(selector):touchscreen.tap(x, y) 的快捷方式,不用本身去定位

頁面跳轉控制

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

幾個頁面跳轉的 API 很是簡單,options 的 waitUntil 參數用來指定知足什麼條件認爲頁面跳轉完成,若是值爲事件數組,那麼全部事件觸發後才認爲是跳轉完成。事件包括:

  • load - 頁面的load事件觸發時(默認值)
  • domcontentloaded - 頁面的 DOMContentLoaded 事件觸發時
  • networkidle0 - 再也不有網絡鏈接時觸發(至少500毫秒後)
  • networkidle2 - 只有2個網絡鏈接時觸發(至少500毫秒後)

事件支持

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

終端模擬

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 經過 puppeteer/DeviceDescriptors 提供了全套的設備模擬

const puppeteer = require('puppeteer');
const devices = require('puppeteer/DeviceDescriptors');
const iPhone = devices['iPhone XR'];

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();
});
複製代碼

全部支持參考:github.com/puppeteer/p…

性能

經過 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 
}
複製代碼

註冊函數

page.exposeFunction(name, puppeteerFunction) 用於在 window 對象註冊一個函數,在自動化測試初始化測試環境時候頗有用,舉個例子:給 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();
});
複製代碼

使用 headless 模式

Puppeteer 默認運行 Chromium 的 headless mode。若是想要使用徹底版本的 Chromium 設置 'headless' option 便可。

const browser = await puppeteer.launch({headless: false});
複製代碼

使用自定義 Chromium

默認狀況下,Puppeteer 下載並使用特定版本的 Chromium 以及其 API 保證開箱即用。 若是要將 Puppeteer 與不一樣版本的 Chrome 或 Chromium 一塊兒使用,在建立Browser實例時傳入 Chromium 可執行文件的路徑便可:

const browser = await puppeteer.launch({executablePath: '/path/to/Chrome'});
複製代碼

簡單示例

模擬 iPhone XR 截屏

const path = require('path');
const puppeteer = require('puppeteer');

const iPhoneXR = puppeteer.devices['iPhone XR'];

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();

  await page.emulate(iPhoneXR);

  await page.goto('https://www.baidu.com', { waitUntil: ['load'] });

  await page.screenshot({
    path: path.join(__dirname, '../image', 'baidu.png'),
    fullPage: true,
  });

  await browser.close();
})();
複製代碼

圖片搜索 & 下載

const path = require('path');
const fs = require('fs');
const http = require('http');
const https = require('https');
const puppeteer = require('puppeteer');
const ora = require('ora');

// const devices = require('puppeteer/DeviceDescriptors');
const iPhoneXR = puppeteer.devices['iPhone XR'];

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.emulate(iPhoneXR);

  await page.goto('https://iamge.baidu.com', { waitUntil: ['load'] });
  await page.type('#image-search-input', 'dog');

  await page.tap('#image-search-btn');

  page.on('load', async () => {
    const srcs = await page.$eval(
      '.sfc-image-content-waterfall img',
      images => images.map(img => img.src)
    );

    await browser.close();

    let i = 0;
    srcs.forEach(src => {
      const request = src.trim().startsWith('https') ? https : http;
      const dest = path.join(__dirname, `../images/${i++}.jpg`);
      console.log(`正在下載 ${src}`);

      request.get(src, res => {
        res.pipe(fs.createWriteStream(dest));
      });
    });
  });
})();
複製代碼

完整代碼:github.com/Samaritan89…

  1. Puppeteer 中文 API
  2. puppeteer vs puppeteer-core
  3. Puppeteer playground
相關文章
相關標籤/搜索