撰寫第一隻 Puppeteer 程式

    前言

    在本文中,我們假定讀者知道 JavaScript 語法,也使用過 Node.js 運行環境。如果沒寫過 JavaScript,最好先到這裡熟悉一下 JavaScript 的語法。如果沒用過 Node.js,可以到這裡看一下相關資料。

    在本文中,我們會撰寫第一個 Puppeteer 爬蟲程式,以熟悉 Puppeteer 爬蟲撰寫的方式。

    建立 NPM 套件

    NPM 套件以單一資料夾為中心,程式碼和相依套件都放在一起,不會影響系統檔案。參考以下指令快速建立 NPM 套件:

    $ mkdir myproject
    $ cd myproject
    $ npm init -y
    

    如果 Node.js 程式只是要自用,只要用上述指令就可以快速建立 NPM 專案。如果是要對外發佈的 Node.js 程式,最好修改一下 package.json ,修正元資料和 NPM 指令。

    使用以下指令安裝 Puppeteer:

    $ npm install --save puppeteer
    

    由於我們在運行 Puppeteer 程式時,會相依於 Puppeteer 套件,故要用 --save 參數指定運行時期相依性。

    除了預設的 puppeteer 套件外,Puppeteer 官方團隊還提供替代性套件。這是因為預設套件會包入整個 Chronium,比較肥大。替代性套件移除 Chronium,由外部提供瀏覽器。使用外部瀏覽器的話,就沒有原本 Puppeteer 的優點,讀者可視自己的需求選用。

    標準寫法:使用 asyncawait

    Node.js 的 I/O 皆為非同步的,在程式撰寫上較為困難。為了簡化 Puppeteer 爬蟲程式的撰寫,Puppeteer 的物件皆以 promise 包裝,並搭配 async / await 模式來寫。因此,Node.js 最好用 7.10 以上的版本 (參考這裡)。7.10 是西元 2017 年 5 月發佈的,離目前 (西元 2019 年 10 月) 已經三年多,應該不需要刻意守在那麼舊的版本。

    我們第一個範例根據 Puppeteer 官方範例改寫。由於程式不長,我們直接把程式碼列出來,待會會逐步講解。

    const puppeteer = require('puppeteer');
    
    (async function () {
        /* Extract command-line arguments. */
        let args = process.argv;
    
        if (args.length < 3) {
            throw new Error("No valid URL");
        }
    
        // Consume the parameters.
        args = args.slice(2);
    
        /* Parse command-line arguments. */
        let output;
        while (true) {
            if (args[0] == '-o' || args[0] == '--output') {
                output = args[1];
    
                // Consume the parameters.
                args = args.slice(2);
            } else {
                break;
            }
        }
    
        if (!output) {
            output = 'screenshot.png';
        }
    
        const url = args[0];
    
        /* Create a `browser` object. */
        let browser = await puppeteer.launch();
        /* Create a `page` object. */
        let page = await browser.newPage();
    
        /* Visit target URL. */
        try {
            await page.goto(url);
        } catch (err) {
            throw err;
        }
    
        /* Take a screenshot of the URL. */
        await page.screenshot({ path: output });
        
        /* Close the website. */
        await browser.close();
    })();
    

    由於我們使用 async / await 模式來撰寫爬蟲,我們把整段程式包在一個 async 函式中。這個函式只是一段程式碼區塊,不需要名稱,所以我們結合 IIFE 模式,用以下區塊包裝程式碼:

    (async function () {
        // Implement code here.
    })();
    

    一開始先取得當下的命令列參數:

    let args = process.argv;
    
    if (args.length < 3) {
        throw new Error("No valid URL");
    }
    
    args = args.slice(2);
    

    為什麼要截掉前兩個命令列參數呢?因為 Node.js 程式的前兩個參數並非真正的命令列參數。在本程式中用不到這兩個參數,故將其去除。

    接下來,我們開始解析命令列參數:

    let output;
    while (true) {
        if (args[0] == '-o' || args[0] == '--output') {
            output = args[1];
    
            args = args.slice(2);
        } else {
            break;
        }
    }
    
    if (!output) {
        output = 'screenshot.png';
    }
    
    const url = args[0];
    

    我們並沒有使用任何解析命令列參數的函式庫,而是直接土炮法解析,因為我們的參數很少。

    參數本身是字串陣列,我們只要逐一走訪此陣列即可。當我們把選擇性參數以迴圈逐一檢查並吃入後,剩下的就是必要性參數。對於複雜的命令列參數,仍然可以用社群函式庫來處理。

    接著,實際撰寫和爬蟲相關的程式。先啟動 Puppeteer 以建立 browser (瀏覽器) 物件:

    let browser = await puppeteer.launch();
    

    browser 物件中建立 page (分頁) 物件:

    let page = await browser.newPage();
    

    實際上的效果是在瀏覽器中開新的分頁。

    什麼時候要加上 await 保留字呢?由於 Puppeteer 的函式皆回傳 promise,這些回傳值本質上是非同步的。但爬蟲執行任務是同步性的,需循序完成。故幾乎每行 Puppeteer 指令都會加上 await,等該行指令跑完再執行下一行指令。如果不確定,可查詢 Puppeteer 的 API,只要函式回傳 promise 的,就要用 await 讓該函式跑完。

    用爬蟲拜訪網頁:

    try {
        await page.goto(url);
    } catch (err) {
        throw err;
    }
    

    如同一般的上網,爬蟲存取網頁有可能因外部因素而失敗,所以要用 try 區塊接住 page.goto() 在存取網路失敗時拋出的例外。

    對頁面進行截圖:

    await page.screenshot({ path: output });
    

    由於 Puppeteer 預設是以 headless 模式進行,無法看到爬蟲的動作,所以 Puppeteer 官方範例用這行指令保留網頁當下的狀態。

    在預設情形下,Puppeteer 開啟的瀏覽器視窗大小是 800x600,所以截出來的圖偏小。如果想要把本程式當成截圖軟體,可以試著修改程式,調整瀏覽器的視窗大小。這個動作留給讀者自己玩玩看。

    最後記得要關掉瀏覽器:

    await browser.close();
    

    若沒有寫這行指令,瀏覽器不會自動關閉,整個程式就會進入閒置狀態,不會自行結束。

    替代寫法:使用 promise

    在前一節中,我們使用 async / await 模式寫 Puppeteer 爬蟲,這是 Puppeteer 官方團隊所建議的模式。但 Puppeteer 函式的回傳值多以 promise 包裝,其實不一定要用該模式來寫程式。在本節中,我們將同一隻程式用 promise 改寫,以下是改寫後的程式碼:

    const puppeteer = require('puppeteer');
    
    let _browser;
    let _page;
    let url;
    let output;
    
    puppeteer.launch()
         .then(function (browser) {
              _browser = browser;
              return _browser;
         })
         .then(function () {
              let args = process.argv;
    
              // Consume the parameters.
              args = args.slice(2);
    
              while (true) {
                   if (args[0] == '-o' || args[0] == '--output') {
                        output = args[1];
    
                        // Consume the parameters.
                        args = args.slice(2);
                   } else {
                        break;
                   }
              }
    
              if (!output) {
                   output = 'screenshot.png';
              }
    
              url = args[0];
              return url;
         })
         .then(function () {
              return _browser;
         })
         .then(function (browser) {
              _page = browser.newPage();
              return _page;
         })
         .then(function (page) {
              try {
                   return page.goto(url);
              } catch (err) {
                   throw err;
              }
         })
         .then(function () {
              return _page;
         })
         .then(function (page) {
              return page.screenshot({ path: output });
         })
         .then(function () {
              return _browser.close();
         })
         .catch(function (err) {
              console.log(err);
         });
    

    由於這兩隻程式做相同的事,我們就不逐行講解,讀者可以相互比較一下。

    此程式的關鍵是 promise 的 then() 函式的 chaining 模式。藉由 then() chaining 模式,我們可以用類似同步性程式的方式逐一寫爬蟲的動作。

    這個模式的重點在於傳接參數到下一個 then() 函式的過程。有些步驟是固定的,像是 puppeteer.launch() 函式會回傳一個用 promise 包住的 Browser 物件,所以在下一個 then() 函式要把該 browser 物件接住。反之,若我們不需要接住前一個 promise 帶入的參數,就可以按需求自行撰寫下一個步驟。

    用 promise 寫的缺點是程式碼會變長,因為每個步驟都要包在回呼函式 (callback) 中。但程式並沒有複雜多少,只是寫起來沒 async / await 模式來得直觀。

    結語

    在本文中,我們分別用 async / await 模式和 promise 的 then() chaining 模式來寫同一隻 Puppeteer 爬蟲。前者是官方推薦的模式,後者則是替代性的寫法。由於第一個模式比較直覺,寫起來也比較短,應該優先採用。第二個模式的範例就留給對 JavaScript 的 promise 有興趣的讀者參考。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Yahoo
    【追蹤本站】
    Facebook Facebook Twitter Plurk