開源技術教學文件網 撰寫第一隻 Puppeteer 程式

最後修改日期為 OCT 21, 2019

前言

在本文中,我們假定讀者知道 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