Puppeteer使用總結(jié)

Puppeteer使用總結(jié)

Puppeteer是 Google Chrome 團隊官方的 Headless Chrome 工具,平時常用它來完成一些煩雜的重復性工作,也寫過一些爬蟲,在瀏覽器中手動完成的大部分事情都可以使用 Puppeteer 完成。也算是測試同學手中的一大利器吧。

安裝

就按管方文檔中來吧,主要就是設置兩個環(huán)境變量:

# 如果不想安裝Chromium.app
# export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# 如果要安裝Chromium.app,國外的源太慢,切回到國內(nèi)的源
# 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 共享內(nèi)存,但是 docker 默認 /dev/shm 很小。所以啟動Chrome要添加參數(shù) -disable-dev-shm-usage ,不用/dev/shm共享內(nèi)存。

獲取Console內(nèi)容

page.on('console', async msg => {
  if (msg.text() === 'CONVEY_DONE') {
    await browser.close();
  }
});

加斷點調(diào)試

只要在前端 evaluate 的代碼中加入 debugger 就可以了,當執(zhí)行到此處時,會進入調(diào)試狀態(tài):

await page.evaluate(() => {debugger;});

添加自定義函數(shù)

添加MD5函數(shù)

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函數(shù)

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 讀取文件內(nèi)容
    const content = await window.readfile('/etc/hosts');
    console.log(content);
  });
  await browser.close();
});

向中 window 添加方法的功能很強大,可以避免瀏覽器的一些限制。

頁面加載前定制處理

evaluateOnNewDocument 可以指定函數(shù)在所屬的頁面被創(chuàng)建,并且所屬頁面的任意 script 執(zhí)行之前被調(diào)用??梢杂眠@個辦法修改頁面的javascript環(huán)境,比如給 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)
    }
});

請求攔截

舉個例子,通過請求攔截器取消所有圖片請求,這樣可以加快執(zhí)行的速度:

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 中的例子,回調(diào)函數(shù)可以接收多個參數(shù):

  for (let h = 1; h <= numPages; h++) {
    // 跳轉(zhuǎn)到指定頁碼
    await page.goto(`${searchUrl}&p=${h}`);
    // 執(zhí)行爬取
    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()之類的函數(shù)來執(zhí)行。它們有些區(qū)別。 page.evaluate ,可傳入多個參數(shù),或第二個參數(shù)作為句柄,而 page.$eval 則針對選中的一個 DOM 元素執(zhí)行操作。比如:

// 獲取 html
// 獲取上下文句柄
const bodyHandle = await page.$('body');
// 執(zhí)行計算
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();
});

兩個運行環(huán)境

Puppeteer代碼是分別跑在Node.js和瀏覽器兩個javascript運行時中的。Puppeteer腳本是運行在Node.js中的,但是 evaluate 、 evaluateHandle 等操作DOM的代碼卻是運行在瀏覽器中的。同樣,Puppeteer也提供了提供了 ElementHandleJsHandle 將 頁面中元素和DOM對象封裝成對應的 Node.js 對象,這樣可以直接這些對象的封裝函數(shù)進行操作 Page DOM。理解這些概念很重要。

所以在執(zhí)行前端代碼時,前端代碼函數(shù)會先被序列化傳給瀏覽器再運行。所以,兩個運行時不能共享變量:

// 不能工作,瀏覽器中訪問不到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);

等待

等待頁面加載

幾個打開頁面的函數(shù),如goto、waitForNavigation、reload等函數(shù)內(nèi)置有等待參數(shù):waitUtil 和 timeout,可以用它來等待頁面打開:

await page.goto('...', {
   timeout: 60000,
   waitUntil: [
       'load',              //等待 “l(fā)oad” 事件觸發(fā)
       'domcontentloaded',  //等待 “domcontentloaded” 事件觸發(fā)
       'networkidle0',      //在 500ms 內(nèi)沒有任何網(wǎng)絡連接
       'networkidle2'       //在 500ms 內(nèi)網(wǎng)絡連接個數(shù)不超過 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 :等待響應結(jié)束,返回 Response 實例
  • page.waitForRequest:等待請求發(fā)起,返回 Request 實例
await page.waitForXPath('//a');
await page.waitForSelector('#gameAccount');
await page.waitForResponse('.../api/user/123');
await page.waitForRequest('.../api/users');

自定義等待

如果現(xiàn)有的等待機制都不能滿足需求,puppeteer 還提供了兩個函數(shù):

  • page.waitForFunction:等待在頁面中自定義函數(shù)的執(zhí)行結(jié)果,返回 JsHandle 實例
  • page.waitFor:設置指定的等待時間
await page.goto('...', { 
    timeout: 60000, 
    waitUntil: 'networkidle2' 
});
// 業(yè)務代碼中設定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來寫。

性能優(yōu)化

  • 如有可能盡量使用同一個瀏覽器實例,或多個實例指定相同的緩存路徑,這樣緩存可以共用
  • 通過請求攔截沒必要加載的資源,比如圖片或媒體等
  • 減少打開的 tab 頁數(shù)量,以免占用太多的資源,長時間運行的Puppeteer腳本,最好定時重啟 Chrome 實例
  • 啟動Chrome時關(guān)閉沒必要的配置,比如:-no-sandbox(沙箱功能),--disable-extensions(擴展程序)等
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容