Puppeteer爬蟲常用技巧

前言

現在不少網頁都會使用保護機制,在開啟網站前,會跳出檢查瀏覽器
cloudflare-ddos-protection
本意是保護網站避免遭受攻擊,也順便阻檔了爬蟲
大部份寫小爬蟲工具,多半是個人解決一些日常的繁瑣事
並不是真的想要攻擊或取得大量網頁資訊
也許自己對於web開發並不是那麼熟稔,這樣一阻檔,多半就很難開發下去了…

所幸Google推出了puppeteer ── 用來做自動化測試的工具
除了正規地做測試之外,拿來寫爬蟲,也是相當方便又好用!
畢竟他就像按鍵精靈那樣,完全模擬了真人作業,速度一定是不比純爬蟲套件來得快
但作為個人用自動化瑣事來說,仍是相當方便又實用
重點是相當容易上手!掌握幾個語法,接下來只要css selector能抓得到,剩下的就不會太難了!

可以在左邊文章目錄快速定位節省滾動時間並直接查閱有興趣的主題。
若是首次學習使用,建議照順序一路往下閱讀比較不會錯亂

啟動puppeteer(關鍵字: browser, page)

固定起手式:打開headless browser,並開啟一個頁面,接著去到目標網址
就像我們平常上網的動作一樣

(async ()=> {
    const browser = await puppeteer.launch({ headless: false }); // 打開chrome
    const page = await browser.newPage(); // 打開一個分頁
    await page.goto( 'https://www.google.com' ); // 到達目標網址
})();

若已完成所有爬蟲動作,可將headless設定成true,就不會看到他的動作流程
而開發期間設定成false,可以視覺化地觀看自己爬蟲的每一個動作!
而不必一直盯著程式碼空想為什麼取不到資料、現在到底卡在哪邊了?!難道真的有什麼反爬機制嗎?
結果發現其實只是因為要設定user-agent而已
但對於不熟悉web的人來說,一個簡單的設定,可能就足以讓他卡到放棄

而看著他一步一步動作,就像一步一步地指導小孩子操作指定動作,相當有趣

定位目標按鍵、文字、輸入框(關鍵字: $, $eval, evaluate)

到達目標頁面後,就像平常使用網頁一樣,不外乎就是這幾個動作:

  1. 找輸入框打字
  2. 找按鈕按下去
  3. 找畫面上的文字

若本身會一些基本的jQuery來抓dom,在puppeteer寫法就跟jQuery差不多
可以參考我的另一篇:在網頁注入jQuery,便於開發時期測試(Chrome套件)
先在chrome的F12抓到想要的資料或按鈕

在jQuery抓到之後,接著把他改成puppeteer的寫法

await page.$('.listItem ul li');

也就只要在前面加上await page.就好了!

這裡要注意的是,要取得dom的值,在jQuery可能是$(.listItem).text
但puppeteer只有抓dom的方式一樣,要取得值或著dom上其他標籤,就要另外使用evaluate

const target = await page.$('.listItem ul li');
const targetText = await page.evaluate((_target) => _target.textContent, target); // 相當於jQuery的$('...').text

還要寫成兩行太過冗長
puppeteer很貼心的提供了一個結合$evaluate快速寫法!
一行就可以直接取得目標操作

const targetText = await page.$eval('.listItem ul li', el=> el.textContent);
// 既然抓到dom了,當然dom該有的函數也通通都可以使用,下列寫法亦可!
const targetText = await page.$eval('.listItem ul li', el=> el.innerHTML);

若是要取得所有的dom節點呢?
$(a),取得頁面上所有a節點
也很簡單,一個$抓一個,要抓兩個節點以上就兩個$!

const targets = await page.$$('a');

此時的targets就會是所有節點,可以跑迴圈來做後面的事了!
同理,結合evaluate亦同

const targets = await page.$$eval('a', els => els.map(el => el.innerHTML));

要注意的是,$$eval傳入的也是複數dom,所以要取每一節點的值,要在裡面再跑迴圈!
若嵌套太深覺得難以理解的話,可以考慮拆成page.$$page.evaluate來作業

針對定位成功的dom,執行動作(關鍵字: click, type)

成功抓到節點後,接著就是要對該節點做事情了!大概還是這些事:

  1. 是按鈕,就按下去
  2. 是打字欄,就打字
  3. 取文字內容,前面提到的$eval就是嘍!

在puppeteer語意也相當直覺,打字就是type;按下去就是click

await page.type('#email', '我的帳號'); 
await page.type('#pass', '我的密碼'); 
await page.click('#loginbutton'); 

按下按鈕後的網頁跳轉等待(關鍵字: waitFor, waitForSelector, waitForNavigation, waitForResponse)

通常在按完按鈕後就會觸發網頁跳轉、出現特定畫面、去後端抓取資料…等
需要等待網頁回應後才能繼續

waitFor

最簡單粗暴的就是waitFor(秒數)了,秒數單位是毫秒,若是5秒記得傳入5000
當然這樣一定會有問題,畢竟你無法確定每次使用時的網路情況是否能順利在指定秒數內完成!

waitForNavigation

像是登入操作,是整個頁面的跳轉,可以直接使用await page.waitForNavigation()

page.goto本身就含waitForNavigation(),故不必特別寫。
通常是點擊按鈕後的頁面跳轉,由於是到達頁面後再觸發跳轉,所以會需要多加waitForNavigation()來等待新頁面

waitForSelector

若按下按鈕後,不是跳轉頁面,而是網頁會計算或向後端再要資料回來,接著呈現在當前頁面的話
可以使用await page.waitForSelector('.navSubmenu')來等待該資料的出現
預設是30秒
若一直等不到,puppeteer會timeout掉,可以用try catch包覆來做exception處理
通常像是登入動作就可以配合使用
如下登入fb頁面的範例

try{
    await page.goto('https://www.facebook.com');
    await page.waitForSelector('#email', {timeout: 5000}); // 超過5秒就timeout

    const username = '帳號';
    const password = '密碼'
    await page.type('#email', username);
    await page.type('#pass', password);
    await page.click('#loginbutton');
    await page.waitForNavigation();
    if (page.url() !== 'https://www.facebook.com/') {
        console.log('登入FB失敗!請檢查您的帳號密碼是否正確!');
    }
} catch(e) {
    console.log('無法找到輸入帳號密碼的欄位!');
}

waitForResponse

在部份互動式的網頁中,通常不會有跳轉的動作
所有資料都在同一頁面即時刷新呈現,waitForSelector足以解決大部份使用情境
但在有些情況,waitForSelector可能就不敷使用了
尤其是fb社團裡的成員頁面
想要蒐集社團裡的成員資料,但又覺得Graph API提供的資訊不足
若是用waitForSelector,就會發現:
輸入完名字後,資料還沒回應完,程式動作太快,會抓到前一筆殘留的欄位
導致後面都會抓不到資料,使用waitFor當然也可以解決
但就會衍生設定太長會浪費許多時間在等待,設定太短無用。仍是抓到殘留欄位
又會受自身網路影響,很難剛好精確抓到一個秒數可以解決
這時候就需要waitForResponse了!
就像人為使用一樣,精確地等待資料出現後,再取得欄位!

const items = []; // 存放資料
for(const name of nameList) {
    // 輸入名字
    await page.type('#groupsMemberBrowser input', name);
    // 等待完成後再繼續動作
    // 由於網址會編碼如空白→%20,故要使用encodeURIComponent轉換名字,才能對應fb後端api的queryString
    await page.waitForResponse(response =>  response.url().match(encodeURIComponent(name)) && response.ok());
    items.push(await page.$('.uiList .fbProfileBrowserListItem'));
}

這樣就不會浪費任何秒數,只要資料一回應完成,就馬上爬取資料!

網頁組合鍵操作,如shift+enter(關鍵字: keyboard.down, keyboard.up)

若是類似聊天室或留言版這種打字欄
通常都會提供shift + enter換行,都打完後再enter送出
或快速鍵可以叫出特定畫面
puppeteer亦可直接模擬此動作!就像是人為操作一樣!

await page.keyboard.type('第一行訊息');
await page.keyboard.down('Shift');
await page.keyboard.press('Enter');
await page.keyboard.up('Shift');
await page.keyboard.type('第二行訊息');

就像人為在聊天視窗會做的動作

  1. 打完第一行訊息
  2. 按住shift不放(down),再按enter換行(press)
  3. 接著放在shift(up)
  4. 繼續輸入第二行訊息

目標網頁須先登入才能取得想要的資料,需要保留cookie(關鍵字: userDataDir)

在啟動headless chrome時,加上userDataDir參數,可以指定userData,就像平常使用chrome那樣會保存所有瀏覽記錄、登入記錄…等

const browser = await puppeteer.launch({ 
    headless: false, 
    userDataDir: "./userData" });

有設定好userDataDir,首次登錄成功後,之後再進到目標網頁,就會直接是登錄狀態

headless browser啟動時不顯示自動化測試工具控制中

在launch加上參數

const browser = await puppeteer.launch({ignoreDefaultArgs: ["--enable-automation"]});

設定user-agent

user-agent就不是放在launch裡面了
而在page有個函式可以設定

await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36');

心得

puppeteer 當然還有提供相當多api可以操作headless browser
但只要掌握以上幾個語法後,幾乎8、90%的網頁都可以處理了!
個人遇過最難搞的網站,就是Facebook了
Facebook為了避免這種機器人爬取,連class, id, css名稱都隨機化
發送到後端的資料也會加上requpest_id來防止開發者寫判斷式
但大部份的網頁並不會做出這麼強烈的反爬手段
通常都會有固定且明確的id、class可以直接抓到dom,再輔以wait系列做相應的等待
最後再用evaluate抓到想要的內容
取到資料後,就可以繼續做自己所想要的分析、計算、整理、留存了!

參考資料

stackoverflow/how to manage log in session through headless chrome?
github / puppeteer api
如何避免Puppeteer被前端JS检测