Puppeteer已經取代PhantomJs

  • 2020 年 4 月 10 日
  • 筆記

記得前幾年,我們通常會用PhantomJs做一下自動化測試,或者為了SEO優化,會用它對SPA頁面進行預渲染,現在有更好的Puppeteer來代替它的工作了,性能更好,使用起來也更加方便,Puppeteer 是 Chrome 開發團隊在 2017 年發佈的一個 Node.js 包,用來模擬 Chrome 瀏覽器的運行。

官網

https://pptr.dev/

就如官網所介紹的,pptr可以做以下的事情:

  • 生成頁面的屏幕截圖和PDF。
  • 爬取SPA(單頁應用程序)並生成預渲染的內容(即「 SSR」(服務器端渲染))。
  • 自動執行表單提交,UI測試,鍵盤輸入等。
  • 創建最新的自動化測試環境。使用最新的JavaScript和瀏覽器功能,直接在最新版本的Chrome中運行測試。
  • 捕獲時間線跟蹤 您的網站以幫助診斷性能問題。
  • 測試Chrome擴展程序。

以下片段僅收集一些簡單的介紹以及一些例子,具體使用時,可以在官網進行更詳細的查詢

簡單入門介紹

Puppeteer 中的 API 分層結構基本和瀏覽器保持一致,下面對常使用到的幾個類介紹一下:

  • Browser: 對應一個瀏覽器實例,一個 Browser 可以包含多個 BrowserContext
  • BrowserContext: 對應瀏覽器一個上下文會話,就像我們打開一個普通的 Chrome 之後又打開一個隱身模式的瀏覽器一樣,BrowserContext 具有獨立的 Session(cookie 和 cache 獨立不共享),一個 BrowserContext 可以包含多個 Page
  • Page:表示一個 Tab 頁面,通過 browserContext.newPage()/browser.newPage() 創建,browser.newPage() 創建頁面時會使用默認的 BrowserContext,一個 Page 可以包含多個 Frame
  • Frame: 一個框架,每個頁面有一個主框架(page.MainFrame()),也可以多個子框架,主要由 iframe 標籤創建產生的
  • ExecutionContext: 是 javascript 的執行環境,每一個 Frame 都一個默認的 javascript 執行環境
  • ElementHandle: 對應 DOM 的一個元素節點,通過該該實例可以實現對元素的點擊,填寫表單等行為,我們可以通過選擇器,xPath 等來獲取對應的元素
  • JsHandle:對應 DOM 中的 javascript 對象,ElementHandle 繼承於 JsHandle,由於我們無法直接操作 DOM 中對象,所以封裝成 JsHandle 來實現相關功能
  • CDPSession:可以直接與原生的 CDP 進行通信,通過 session.send 函數直接發消息,通過 session.on 接收消息,可以實現 Puppeteer API 中沒有涉及的功能
  • Coverage:獲取 JavaScript 和 CSS 代碼覆蓋率
  • Tracing:抓取性能數據進行分析
  • Response: 頁面收到的響應
  • Request: 頁面發出的請求

如何創建一個 Browser 實例

puppeteer 提供了兩種方法用於創建一個 Browser 實例:

  • puppeteer.connect: 連接一個已經存在的 Chrome 實例
  • puppeteer.launch: 每次都啟動一個 Chrome 實例
const puppeteer = require('puppeteer');  let request = require('request-promise-native');    //使用 puppeteer.launch 啟動 Chrome  (async () => {      const browser = await puppeteer.launch({          headless: false,   //有瀏覽器界面啟動          slowMo: 100,       //放慢瀏覽器執行速度,方便測試觀察          args: [            //啟動 Chrome 的參數,詳見上文中的介紹              '–no-sandbox',              '--window-size=1280,960'          ],      });      const page = await browser.newPage();      await page.goto('https://www.baidu.com');      await page.close();      await browser.close();  })();    //使用 puppeteer.connect 連接一個已經存在的 Chrome 實例  (async () => {      //通過 9222 端口的 http 接口獲取對應的 websocketUrl      let version = await request({          uri:  "http://127.0.0.1:9222/json/version",          json: true      });      //直接連接已經存在的 Chrome      let browser = await puppeteer.connect({          browserWSEndpoint: version.webSocketDebuggerUrl      });      const page = await browser.newPage();      await page.goto('https://www.baidu.com');      await page.close();      await browser.disconnect();  })();

這兩種方式的對比:

  • puppeteer.launch 每次都要重新啟動一個 Chrome 進程,啟動平均耗時 100 到 150 ms,性能欠佳
  • puppeteer.connect 可以實現對於同一個 Chrome 實例的共用,減少啟動關閉瀏覽器的時間消耗
  • puppeteer.launch 啟動時參數可以動態修改
  • 通過 puppeteer.connect 我們可以遠程連接一個 Chrome 實例,部署在不同的機器上
  • puppeteer.connect 多個頁面共用一個 chrome 實例,偶爾會出現 Page Crash 現象,需要進行並發控制,並定時重啟 Chrome 實例

如何等待加載?

在實踐中我們經常會遇到如何判斷一個頁面加載完成了,什麼時機去截圖,什麼時機去點擊某個按鈕等問題,那我們到底如何去等待加載呢?

下面我們把等待加載的 API 分為三類進行介紹:

加載導航頁面

  • page.goto:打開新頁面
  • page.goBack :回退到上一個頁面
  • page.goForward :前進到下一個頁面
  • page.reload :重新加載頁面
  • page.waitForNavigation:等待頁面跳轉

Pupeeteer 中的基本上所有的操作都是異步的,以上幾個 API 都涉及到關於打開一個頁面,什麼情況下才能判斷這個函數執行完畢呢,這些函數都提供了兩個參數 waitUtil 和 timeout,waitUtil 表示直到什麼出現就算執行完畢,timeout 表示如果超過這個時間還沒有結束就拋出異常。

await page.goto('https://www.baidu.com', {     timeout: 30 * 1000,     waitUntil: [         'load',              //等待 「load」 事件觸發         'domcontentloaded',  //等待 「domcontentloaded」 事件觸發         'networkidle0',      //在 500ms 內沒有任何網絡連接         'networkidle2'       //在 500ms 內網絡連接個數不超過 2 個     ]  });

以上 waitUtil 有四個事件,業務可以根據需求來設置其中一個或者多個觸發才以為結束,networkidle0 和 networkidle2 中的 500ms 對時間性能要求高的用戶來說,還是有點長的

等待元素、請求、響應

  • page.waitForXPath:等待 xPath 對應的元素出現,返回對應的 ElementHandle 實例
  • page.waitForSelector :等待選擇器對應的元素出現,返回對應的 ElementHandle 實例
  • page.waitForResponse :等待某個響應結束,返回 Response 實例
  • page.waitForRequest:等待某個請求出現,返回 Request 實例
await page.waitForXPath('//img');  await page.waitForSelector('#uniqueId');  await page.waitForResponse('https://d.youdata.netease.com/api/dash/hello');  await page.waitForRequest('https://d.youdata.netease.com/api/dash/hello');

自定義等待

如果上面提供的等待方式都不能滿足我們的需求,puppeteer 還提供我們提供兩個函數:

  • page.waitForFunction:等待在頁面中自定義函數的執行結果,返回 JsHandle 實例
  • page.waitFor:設置等待時間,實在沒辦法的做法
await page.goto(url, {      timeout: 120000,      waitUntil: 'networkidle2'  });  //我們可以在頁面中定義自己認為加載完的事件,在合適的時間點我們將該事件設置為 true  //以下是我們項目在觸發截圖時的判斷邏輯,如果 renderdone 出現且為 true 那麼就截圖,如果是 Object,說明頁面加載出錯了,我們可以捕獲該異常進行提示  let renderdoneHandle = await page.waitForFunction('window.renderdone', {      polling: 120  });  const renderdone = await renderdoneHandle.jsonValue();  if (typeof renderdone === 'object') {      console.log(`加載頁面失敗:報表${renderdone.componentId}出錯 -- ${renderdone.message}`);  }else{      console.log('頁面加載成功');  }

兩個獨立的環境

在使用 Puppeteer 時我們幾乎一定會遇到在這兩個環境之間交換數據:運行 Puppeteer 的 Node.js 環境和 Puppeteer 操作的頁面 Page DOM,理解這兩個環境很重要

  • 首先 Puppeteer 提供了很多有用的函數去 Page DOM Environment 中執行代碼,這個後面會介紹到
  • 其次 Puppeteer 提供了 ElementHandle 和 JsHandle 將 Page DOM Environment 中元素和對象封裝成對應的 Node.js 對象,這樣可以直接這些對象的封裝函數進行操作 Page DOM

一些簡單的使用例子

1、頁面截圖

我們使用 Puppeteer 既可以對某個頁面進行截圖,也可以對頁面中的某個元素進行截圖:

(async () => {      const browser = await puppeteer.launch();      const page = await browser.newPage();      //設置可視區域大小      await page.setViewport({width: 1920, height: 800});      await page.goto('https://youdata.163.com');      //對整個頁面截圖      await page.screenshot({          path: './files/capture.png',  //圖片保存路徑          type: 'png',          fullPage: true //邊滾動邊截圖          // clip: {x: 0, y: 0, width: 1920, height: 800}      });      //對頁面某個元素截圖      let [element] = await page.$x('/html/body/section[4]/div/div[2]');      await element.screenshot({          path: './files/element.png'      });      await page.close();      await browser.close();  })();

我們怎麼去獲取頁面中的某個元素呢?

  • page.$(『#uniqueId』):獲取某個選擇器對應的第一個元素
  • page.$$(『div』):獲取某個選擇器對應的所有元素
  • page.$x(『//img』):獲取某個 xPath 對應的所有元素
  • page.waitForXPath(『//img』):等待某個 xPath 對應的元素出現
  • page.waitForSelector(『#uniqueId』):等待某個選擇器對應的元素出現

2、 模擬用戶登錄

(async () => {      const browser = await puppeteer.launch({          slowMo: 100,    //放慢速度          headless: false,          defaultViewport: {width: 1440, height: 780},          ignoreHTTPSErrors: false, //忽略 https 報錯          args: ['--start-fullscreen'] //全屏打開頁面      });      const page = await browser.newPage();      await page.goto('https://demo.youdata.com');      //輸入賬號密碼      const uniqueIdElement = await page.$('#uniqueId');      await uniqueIdElement.type('[email protected]', {delay: 20});      const passwordElement = await page.$('#password', {delay: 20});      await passwordElement.type('123456');      //點擊確定按鈕進行登錄      let okButtonElement = await page.$('#btn-ok');      //等待頁面跳轉完成,一般點擊某個按鈕需要跳轉時,都需要等待 page.waitForNavigation() 執行完畢才表示跳轉成功      await Promise.all([          okButtonElement.click(),          page.waitForNavigation()      ]);      console.log('admin 登錄成功');      await page.close();      await browser.close();  })();

那麼 ElementHandle 都提供了哪些操作元素的函數呢?

  • elementHandle.click():點擊某個元素
  • elementHandle.tap():模擬手指觸摸點擊
  • elementHandle.focus():聚焦到某個元素
  • elementHandle.hover():鼠標 hover 到某個元素上
  • elementHandle.type(『hello』):在輸入框輸入文本

3、攔截請求

請求在有些場景下很有必要,攔截一下沒必要的請求提高性能,我們可以在監聽 Page 的 request 事件,並進行請求攔截,前提是要開啟請求攔截 page.setRequestInterception(true)。

(async () => {      const browser = await puppeteer.launch();      const page = await browser.newPage();      const blockTypes = new Set(['image', 'media', 'font']);      await page.setRequestInterception(true); //開啟請求攔截      page.on('request', request => {          const type = request.resourceType();          const shouldBlock = blockTypes.has(type);          if(shouldBlock){              //直接阻止請求              return request.abort();          }else{              //對請求重寫              return request.continue({                  //可以對 url,method,postData,headers 進行覆蓋                  headers: Object.assign({}, request.headers(), {                      'puppeteer-test': 'true'                  })              });          }      });      await page.goto('https://demo.youdata.com');      await page.close();      await browser.close();  })();

那 page 頁面上都提供了哪些事件呢?

  • page.on(『close』) 頁面關閉
  • page.on(『console』) console API 被調用
  • page.on(『error』) 頁面出錯
  • page.on(『load』) 頁面加載完
  • page.on(『request』) 收到請求
  • page.on(『requestfailed』) 請求失敗
  • page.on(『requestfinished』) 請求成功
  • page.on(『response』) 收到響應
  • page.on(『workercreated』) 創建 webWorker
  • page.on(『workerdestroyed』) 銷毀 webWorker

4、獲取 WebSocket 響應

Puppeteer 目前沒有提供原生的用於處理 WebSocket 的 API 接口,但是我們可以通過更底層的 Chrome DevTool Protocol (CDP) 協議獲得

(async () => {      const browser = await puppeteer.launch();      const page = await browser.newPage();      //創建 CDP 會話      let cdpSession = await page.target().createCDPSession();      //開啟網絡調試,監聽 Chrome DevTools Protocol 中 Network 相關事件      await cdpSession.send('Network.enable');      //監聽 webSocketFrameReceived 事件,獲取對應的數據      cdpSession.on('Network.webSocketFrameReceived', frame => {          let payloadData = frame.response.payloadData;          if(payloadData.includes('push:query')){              //解析payloadData,拿到服務端推送的數據              let res = JSON.parse(payloadData.match(/{.*}/)[0]);              if(res.code !== 200){                  console.log(`調用websocket接口出錯:code=${res.code},message=${res.message}`);              }else{                  console.log('獲取到websocket接口數據:', res.result);              }          }      });      await page.goto('https://netease.youdata.163.com/dash/142161/reportExport?pid=700209493');      await page.waitForFunction('window.renderdone', {polling: 20});      await page.close();      await browser.close();  })();

5、在頁面插入 JS腳本

Puppeteer 最強大的功能是,你可以在瀏覽器里執行任何你想要運行的 javascript 代碼,下面是我在爬郵箱的收件箱用戶列表時,發現每次打開收件箱再關掉都會多處一個 iframe 來,隨着打開收件箱的增多,iframe 增多到瀏覽器卡到無法運行,所以我在爬蟲代碼里加了刪除無用 iframe 的腳本:

(async () => {      const browser = await puppeteer.launch();      const page = await browser.newPage();      await page.goto('https://webmail.vip.188.com');      //註冊一個 Node.js 函數,在瀏覽器里運行      await page.exposeFunction('md5', text =>          crypto.createHash('md5').update(text).digest('hex')      );      //通過 page.evaluate 在瀏覽器里執行刪除無用的 iframe 代碼      await page.evaluate(async () =>  {          let iframes = document.getElementsByTagName('iframe');          for(let i = 3; i <  iframes.length - 1; i++){              let iframe = iframes[i];              if(iframe.name.includes("frameBody")){                  iframe.src = 'about:blank';                  try{                      iframe.contentWindow.document.write('');                      iframe.contentWindow.document.clear();                  }catch(e){}                  //把iframe從頁面移除                  iframe.parentNode.removeChild(iframe);              }          }          //在頁面中調用 Node.js 環境中的函數          const myHash = await window.md5('PUPPETEER');          console.log(`md5 of ${myString} is ${myHash}`);      });      await page.close();      await browser.close();  })();

有哪些函數可以在瀏覽器環境中執行代碼呢?

  • page.evaluate(pageFunction[, …args]):在瀏覽器環境中執行函數
  • page.evaluateHandle(pageFunction[, …args]):在瀏覽器環境中執行函數,返回 JsHandle 對象
  • page.$$eval(selector, pageFunction[, …args]):把 selector 對應的所有元素傳入到函數並在瀏覽器環境執行
  • page.$eval(selector, pageFunction[, …args]):把 selector 對應的第一個元素傳入到函數在瀏覽器環境執行
  • page.evaluateOnNewDocument(pageFunction[, …args]):創建一個新的 Document 時在瀏覽器環境中執行,會在頁面所有腳本執行之前執行
  • page.exposeFunction(name, puppeteerFunction):在 window 對象上註冊一個函數,這個函數在 Node 環境中執行,有機會在瀏覽器環境中調用 Node.js 相關函數庫

6、 抓取 iframe 中的元素

一個 Frame 包含了一個執行上下文(Execution Context),我們不能跨 Frame 執行函數,一個頁面中可以有多個 Frame,主要是通過 iframe 標籤嵌入的生成的。其中在頁面上的大部分函數其實是 page.mainFrame().xx 的一個簡寫,Frame 是樹狀結構,我們可以通過 frame.childFrames() 遍歷到所有的 Frame,如果想在其它 Frame 中執行函數必須獲取到對應的 Frame 才能進行相應的處理

以下是在登錄 188 郵箱時,其登錄窗口其實是嵌入的一個 iframe,以下代碼時我們在獲取 iframe 並進行登錄

(async () => {      const browser = await puppeteer.launch({headless: false, slowMo: 50});      const page = await browser.newPage();      await page.goto('https://www.188.com');      //點擊使用密碼登錄      let passwordLogin = await page.waitForXPath('//*[@id="qcode"]/div/div[2]/a');      await passwordLogin.click();      for (const frame of page.mainFrame().childFrames()){          //根據 url 找到登錄頁面對應的 iframe          if (frame.url().includes('passport.188.com')){              await frame.type('.dlemail', '[email protected]');              await frame.type('.dlpwd', '123456');              await Promise.all([                  frame.click('#dologin'),                  page.waitForNavigation()              ]);              break;          }      }      await page.close();      await browser.close();  })();

7、頁面性能分析

Puppeteer 提供了對頁面性能分析的工具,目前功能還是比較弱的,只能獲取到一個頁面性能執行的數據,如何分析需要我們自己根據數據進行分析,據說在 2.0 版本會做大的改版: – 一個瀏覽器同一時間只能 trace 一次 – 在 devTools 的 Performance 可以上傳對應的 json 文件並查看分析結果 – 我們可以寫腳本來解析 trace.json 中的數據做自動化分析 – 通過 tracing 我們獲取頁面加載速度以及腳本的執行性能

(async () => {      const browser = await puppeteer.launch();      const page = await browser.newPage();      await page.tracing.start({path: './files/trace.json'});      await page.goto('https://www.google.com');      await page.tracing.stop();      /*          continue analysis from 'trace.json'      */      browser.close();  })();

8、文件的上傳和下載

在自動化測試中,經常會遇到對於文件的上傳和下載的需求,那麼在 Puppeteer 中如何實現呢?

(async () => {      const browser = await puppeteer.launch();      const page = await browser.newPage();      //通過 CDP 會話設置下載路徑      const cdp = await page.target().createCDPSession();      await cdp.send('Page.setDownloadBehavior', {          behavior: 'allow', //允許所有下載請求          downloadPath: 'path/to/download'  //設置下載路徑      });      //點擊按鈕觸發下載      await (await page.waitForSelector('#someButton')).click();      //等待文件出現,輪訓判斷文件是否出現      await waitForFile('path/to/download/filename');        //上傳時對應的 inputElement 必須是<input>元素      let inputElement = await page.waitForXPath('//input[@type="file"]');      await inputElement.uploadFile('/path/to/file');      browser.close();  })();

9、跳轉新 tab 頁處理

在點擊一個按鈕跳轉到新的 Tab 頁時會新開一個頁面,這個時候我們如何獲取改頁面對應的 Page 實例呢?可以通過監聽 Browser 上的 targetcreated 事件來實現,表示有新的頁面創建:

let page = await browser.newPage();  await page.goto(url);  let btn = await page.waitForSelector('#btn');  //在點擊按鈕之前,事先定義一個 Promise,用於返回新 tab 的 Page 對象  const newPagePromise = new Promise(res =>    browser.once('targetcreated',      target => res(target.page())    )  );  await btn.click();  //點擊按鈕後,等待新tab對象  let newPage = await newPagePromise;

10、 模擬不同的設備

Puppeteer 提供了模擬不同設備的功能,其中 puppeteer.devices 對象上定義很多設備的配置信息,這些配置信息主要包含 viewport 和 userAgent,然後通過函數 page.emulate 實現不同設備的模擬

const puppeteer = require('puppeteer');  const iPhone = puppeteer.devices['iPhone 6'];  puppeteer.launch().then(async browser => {    const page = await browser.newPage();    await page.emulate(iPhone);    await page.goto('https://www.google.com');    await browser.close();  });