前言
在本文中,我們假定讀者知道 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 的優點,讀者可視自己的需求選用。
標準寫法:使用 async
和 await
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 有興趣的讀者參考。