位元詩人 [Puppeteer] 程式設計教學:撰寫第一隻 Puppeteer 程式

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

在本文中,我們假定讀者知道 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 有興趣的讀者參考。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。