Puppeteer使用總結(jié)

Puppeteer使用總結(jié)

Puppeteer是 Google Chrome 團(tuán)隊(duì)官方的 Headless Chrome 工具球化,平時(shí)常用它來完成一些煩雜的重復(fù)性工作括丁,也寫過一些爬蟲,在瀏覽器中手動(dòng)完成的大部分事情都可以使用 Puppeteer 完成婶芭。也算是測(cè)試同學(xué)手中的一大利器吧堕花。

安裝

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

# 如果不想安裝Chromium.app
# export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# 如果要安裝Chromium.app祈噪,國(guó)外的源太慢,切回到國(guó)內(nèi)的源
# export PUPPETEER_DOWNLOAD_HOST=https://storage.googleapis.com.cnpmjs.org
npm i puppeteer

如果沒有安裝Chromium.app尚辑,要用本地的Chrome辑鲤,只要設(shè)置好本地的Chrome位置即可:

const browser = await puppeteer.launch({
   executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
   headless: false,
   slowMo: 500,
   devtools: true
 });

在Docker上運(yùn)行

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();

注意:
因?yàn)镃hrome默認(rèn)使用 /dev/shm 共享內(nèi)存,但是 docker 默認(rèn) /dev/shm 很小杠茬。所以啟動(dòng)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ǎn)調(diào)試

只要在前端 evaluate 的代碼中加入 debugger 就可以了瓢喉,當(dāng)執(zhí)行到此處時(shí)宁赤,會(huì)進(jìn)入調(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 計(jì)算哈希
    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 添加方法的功能很強(qiáng)大,可以避免瀏覽器的一些限制栓票。

頁面加載前定制處理

evaluateOnNewDocument 可以指定函數(shù)在所屬的頁面被創(chuàng)建决左,并且所屬頁面的任意 script 執(zhí)行之前被調(diào)用∽咛埃可以用這個(gè)辦法修改頁面的javascript環(huán)境佛猛,比如給 Math.random 設(shè)定種子等。

下面是在頁面加載前重寫 navigator.languages 屬性的例子:

// preload.js
// 重寫 `languages` 屬性坠狡,使其用一個(gè)新的get方法
Object.defineProperty(navigator, "languages", {
  get: function() {
    return ["en-US", "en", "bn"];
  }
});
// preload.js 和當(dāng)前的代碼在同一個(gè)目錄
const preloadFile = fs.readFileSync('./preload.js', 'utf8');
await page.evaluateOnNewDocument(preloadFile);

再舉個(gè)重置定位信息的例子:

//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)
    }
});

請(qǐng)求攔截

舉個(gè)例子继找,通過請(qǐng)求攔截器取消所有圖片請(qǐng)求,這樣可以加快執(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對(duì)象
      interceptedRequest.continue(
        headers: Object.assign({}, request.headers(), {
            'SlaveID': '4c625b7861a92c7971cd2029c2fd3c4a'
        });
  });
  await page.goto('https://example.com');
  await browser.close();
});

注意 啟用請(qǐng)求攔截器會(huì)禁用頁面緩存逃沿。

并行運(yùn)行

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()
  })
})();

前端運(yùn)行的代碼

在運(yùn)用Puppeteer過程中婴渡,免不得大量的運(yùn)行在前端的代碼,即運(yùn)行在瀏覽器中的代碼凯亮。主要用于查找元素边臼、獲取元元素的屬性等,以下舉幾個(gè)例子說明:

定位元素

// button的id和class等屬性變化假消,文本卻不變硼瓣,可以用innerText來準(zhǔn)確定位操作它
await page.evaluate(() => {
  let btns = [...document.querySelector(".HmktE").querySelectorAll("button")];
  btns.forEach(function (btn) {
    if (btn.innerText == "Log In")
      btn.click();
  });
});

獲取元素信息

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

運(yùn)行于前端的代碼,主要是由 page.$eval() 堂鲤、page.evaluate()之類的函數(shù)來執(zhí)行亿傅。它們有些區(qū)別。 page.evaluate 瘟栖,可傳入多個(gè)參數(shù)葵擎,或第二個(gè)參數(shù)作為句柄,而 page.$eval 則針對(duì)選中的一個(gè) DOM 元素執(zhí)行操作半哟。比如:

// 獲取 html
// 獲取上下文句柄
const bodyHandle = await page.$('body');
// 執(zhí)行計(jì)算
const bodyInnerHTML = await page.evaluate(dom => dom.innerHTML, bodyHandle);
// 銷毀句柄
await bodyHandle.dispose();
console.log('bodyInnerHTML:', bodyInnerHTML);

page.$eval看上去簡(jiǎn)潔得多:

const bodyInnerHTML = await page.$eval('body', dom => dom.innerHTML);
console.log('bodyInnerHTML: ', bodyInnerHTML);

截圖

Puppeteer 既可以對(duì)某個(gè)頁面進(jìn)行截圖酬滤,也可以對(duì)頁面中的某個(gè)元素進(jìn)行截圖:

// 截屏
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會(huì)被javascript改動(dòng)時(shí),可以考慮合并多個(gè) async 寓涨,不要用:

const $atag = await page.$('a.order-list');
const link = await $atag.getProperty('href');
await $atag.click();

而是用用一個(gè) async 代替:

await page.evaluate(() => {
    const $atag = document.querySelector('a.order-list');
    const text = $atag.href;
    $atag.click();
});

兩個(gè)運(yùn)行環(huán)境

Puppeteer代碼是分別跑在Node.js和瀏覽器兩個(gè)javascript運(yùn)行時(shí)中的盯串。Puppeteer腳本是運(yùn)行在Node.js中的,但是 evaluate 戒良、 evaluateHandle 等操作DOM的代碼卻是運(yùn)行在瀏覽器中的体捏。同樣,Puppeteer也提供了提供了 ElementHandleJsHandle 將 頁面中元素和DOM對(duì)象封裝成對(duì)應(yīng)的 Node.js 對(duì)象糯崎,這樣可以直接這些對(duì)象的封裝函數(shù)進(jìn)行操作 Page DOM几缭。理解這些概念很重要。

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

// 不能工作薄霜,瀏覽器中訪問不到atag這個(gè)變量
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);

等待

等待頁面加載

幾個(gè)打開頁面的函數(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)絡(luò)連接
       'networkidle2'       //在 500ms 內(nèi)網(wǎng)絡(luò)連接個(gè)數(shù)不超過 2 個(gè)
   ]
});

另外,點(diǎn)擊了鏈接之后鸵熟,需要使用 page.waitForNavigation 來等待頁面加載副编。

await page.goto(...);
await Promise.all([
    page.click('a'),
    await page.waitForNavigation()
]);

等待元素或響應(yīng)

  • page.waitForXPath:用XPath等待頁面元素,返回對(duì)應(yīng)的 ElementHandle 實(shí)例
  • page.waitForSelector :用CSS選擇器等待頁面元素流强,返回對(duì)應(yīng)的 ElementHandle 實(shí)例
  • page.waitForResponse :等待響應(yīng)結(jié)束痹届,返回 Response 實(shí)例
  • page.waitForRequest:等待請(qǐng)求發(fā)起,返回 Request 實(shí)例
await page.waitForXPath('//a');
await page.waitForSelector('#gameAccount');
await page.waitForResponse('.../api/user/123');
await page.waitForRequest('.../api/users');

自定義等待

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

  • page.waitForFunction:等待在頁面中自定義函數(shù)的執(zhí)行結(jié)果队腐,返回 JsHandle 實(shí)例
  • page.waitFor:設(shè)置指定的等待時(shí)間
await page.goto('...', { 
    timeout: 60000, 
    waitUntil: 'networkidle2' 
});
// 業(yè)務(wù)代碼中設(shè)定window中的對(duì)象,存在表示加載完成
let acquireHandle = await page.waitForFunction('window.ACQUIREDONE', {
    polling: 120
});
const acquireResult = await acquireHandle.jsonValue();
console.info(acquireResult);

基于Puppeteer的框架

從上面看出Puppeteer編寫腳本并不是很直觀奏篙,可以考慮用其它更好的框架柴淘,比如Rize 迫淹。比如,用Rize寫的代碼類似于下面這樣的为严,明顯比原生的Puppeteer代碼要簡(jiǎn)潔敛熬、直觀的多。

原生的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()
})()

對(duì)比用Rize寫的代碼:

const Rize = require('rize')
const rize = new Rize()
rize
  .goto('https://github.com')
  .saveScreenshot('github.png')
  .end()

而且用Rize寫代碼時(shí)第股,仍然可以用原生Puppeteer的Api來寫应民。

性能優(yōu)化

  • 如有可能盡量使用同一個(gè)瀏覽器實(shí)例,或多個(gè)實(shí)例指定相同的緩存路徑夕吻,這樣緩存可以共用
  • 通過請(qǐng)求攔截沒必要加載的資源诲锹,比如圖片或媒體等
  • 減少打開的 tab 頁數(shù)量,以免占用太多的資源涉馅,長(zhǎng)時(shí)間運(yùn)行的Puppeteer腳本归园,最好定時(shí)重啟 Chrome 實(shí)例
  • 啟動(dòng)Chrome時(shí)關(guān)閉沒必要的配置,比如:-no-sandbox(沙箱功能)稚矿,--disable-extensions(擴(kuò)展程序)等
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末庸诱,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子盐捷,更是在濱河造成了極大的恐慌偶翅,老刑警劉巖默勾,帶你破解...
    沈念sama閱讀 218,858評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件碉渡,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡母剥,警方通過查閱死者的電腦和手機(jī)滞诺,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,372評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來环疼,“玉大人习霹,你說我怎么就攤上這事§帕ィ” “怎么了淋叶?”我有些...
    開封第一講書人閱讀 165,282評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)伪阶。 經(jīng)常有香客問我煞檩,道長(zhǎng),這世上最難降的妖魔是什么栅贴? 我笑而不...
    開封第一講書人閱讀 58,842評(píng)論 1 295
  • 正文 為了忘掉前任斟湃,我火速辦了婚禮,結(jié)果婚禮上檐薯,老公的妹妹穿的比我還像新娘凝赛。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,857評(píng)論 6 392
  • 文/花漫 我一把揭開白布墓猎。 她就那樣靜靜地躺著捆昏,像睡著了一般。 火紅的嫁衣襯著肌膚如雪陶衅。 梳的紋絲不亂的頭發(fā)上屡立,一...
    開封第一講書人閱讀 51,679評(píng)論 1 305
  • 那天,我揣著相機(jī)與錄音搀军,去河邊找鬼膨俐。 笑死,一個(gè)胖子當(dāng)著我的面吹牛罩句,可吹牛的內(nèi)容都是我干的焚刺。 我是一名探鬼主播,決...
    沈念sama閱讀 40,406評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼门烂,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼乳愉!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起屯远,我...
    開封第一講書人閱讀 39,311評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤蔓姚,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后慨丐,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體坡脐,經(jīng)...
    沈念sama閱讀 45,767評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年房揭,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了备闲。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,090評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡捅暴,死狀恐怖恬砂,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情蓬痒,我是刑警寧澤泻骤,帶...
    沈念sama閱讀 35,785評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站梧奢,受9級(jí)特大地震影響狱掂,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜粹断,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,420評(píng)論 3 331
  • 文/蒙蒙 一符欠、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧瓶埋,春花似錦希柿、人聲如沸诊沪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,988評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽端姚。三九已至,卻和暖如春挤悉,著一層夾襖步出監(jiān)牢的瞬間渐裸,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,101評(píng)論 1 271
  • 我被黑心中介騙來泰國(guó)打工装悲, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留昏鹃,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,298評(píng)論 3 372
  • 正文 我出身青樓诀诊,卻偏偏與公主長(zhǎng)得像洞渤,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子属瓣,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,033評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容