前言
免責聲明:我們盡力確保本文的正確性,但本文不代表任何投資的建議,我們也無法擔保因使用本文的內容所造成的任何損失。如對本文內容有疑問,請詢問財經相關的專家。
在本文中,我們撰寫前往 Yahoo Finance 網站抓取股票、ETF等資產的歷史交易資料的網頁爬蟲。由於 Yahoo Finance 已經提供這些交易記錄的 CSV 格式資料表,我們的爬蟲並沒有存取頁面上的資料,只是將拜訪網頁及下載資料的過程自動化。
在先前的文章中,我們用 Selenium 的 Python binding 寫網頁爬蟲,讀者可以相互比較一下兩種工具的差異。
標準寫法:使用 async
和 await
由於程式碼稍長,我們把程式碼放在 GitHub Gist 上,有興趣的讀者可自行追蹤程式碼。我們會節錄部分程式碼,以利說明。此外,這個爬蟲程式也是立即可用的命令列小工具,有需要的讀者歡迎自行取用;但該程式不是投資建議,我們不擔保使用該程式的結果。
使用本程式時參數的部分會填入資產的名稱,像是 2330.TW
代表台積電 (2330)。在命令列上輸入指令如下:
$ node yahoo-finance-async.js 2330.TW
爬完網站會得到 2330.TW.csv ,即為台積電近五年的交易資料表。
由於我們使用 async
/ await
模式,我們把整段程式碼包在一整個 async
匿名函式中,結合 IIFE 模式來執行:
(async function () {
// Implement code here.
})();
先解析命令列參數:
let args = process.argv;
if (args.length < 3) {
throw new Error('No valid asset');
}
const asset = args[2];
我們在前文提過,Node.js 程式的前兩個參數並非實際的參數,真正的參數要從第三個參數開始計算。我們這個程式沒有選擇性參數,只要確保有一個必要性參數即可。
同樣地,要執行爬蟲時,要先建 browser
(瀏覽器) 物件和 page
(分頁) 物件:
const browser = await puppeteer.launch();
const page = await browser.newPage();
由於我們會下載檔案,在這裡先設定檔案下載的位置:
await page._client.send('Page.setDownloadBehavior', {
behavior: 'allow',
downloadPath: path.dirname(__filename)
});
下載的位置是該 Node.js 命令稿所在的目錄。
如果讀者去查 Page 物件的 API,基本上找不到這段函式呼叫。這應該是實驗性質的 API,被歐美的強者網友從 Puppeteer 的原始碼挖出來。這個 API 並不穩固,日後仍可能再變動。
實際拜訪 Yahoo Finance 所在的頁面:
try {
await page.goto('https://finance.yahoo.com/');
} catch (err) {
throw err;
}
由於存取網頁受到外部環境的影響,有可能會失敗,所以要用 try
區塊捕捉在存取網頁失敗時所拋出的例外。
實際的效果就是開啟瀏覽器後開啟新分頁。
將資產 (asset) 名稱輸入 Yahoo Finance 的搜尋框中:
const input = await page.$('#fin-srch-assist input');
await input.type(asset, {delay: 100});
我們用 page.$() 函式找出搜尋框所在的網頁元素。該函式相當於網頁 API 的 document.querySelector() 函式,會找出符合 CSS selector 的第一個網頁元素。
為什麼要用 $
(錢字號) 當函式名稱呢?可能是向 jQuery 的 $
致敬吧。
我們藉由 elementHandle.type() 函式向 Yahoo Finance 的搜尋框輸入資產名稱時,額外加上了 {delay: 100}
參數,這是為了模擬真人慢慢輸入文字的速度感。
在該輸入框按下 Enter 鍵,執行查詢動作:
await input.press('Enter');
await page.waitForNavigation();
為什麼我們要多寫一行 page.waitForNavigation() 呢?單從程式碼不容易看出其必要性,要從程式的行為來看。因為 Node.js 函式是非同步性的,在 await input.press('Enter');
執行完後就會馬上執行下一個動作,但這時候頁面還沒載入完成,造成後續程式的錯誤,所以要等頁面完全載入後才繼續執行下一個動作。
接著,我們找出 'Historical Data'
所在的分頁,按下該分頁的按鈕:
let items = await page.$$('a span');
for (let i = 0; i < items.length; i++) {
const text = await page.evaluate(function (elem) {
return elem.innerText;
}, items[i]);
if (text.match('Historical Data')) {
await items[i].click();
break;
}
}
await page.waitForNavigation();
以筆者觀察 Yahoo Finance 的頁面來說,'Historical Data'
所在的網頁元素沒有什麼很好的定錨處,故我們退而求其次,先用比較通用的 CSS selector 一次找出較多的網頁元素,再藉由網頁元素內部的文字篩出我們所要的網頁元素。
在此處,我們用 page.$$() 函式選出所有符合 CSS selector 的網頁元素。該函式相當於網頁 API 的 document.querySelectorAll() 函式。
接著,我們用 page.evaluate() 函式取出網頁元素的文字。實際取出的內容為 HTMLElement.innerText。當文字符合 'Historical Data'
時,我們用機器人按下該網頁元素,然後結束迴圈。
我們按下該分頁的一個小箭頭鈕:
const arrow = await page.$('.historical div div span svg');
await arrow.click();
await page.waitForNavigation();
這時候頁面會浮出一個小視窗,我們待會會在該視窗中選取資料的時距 (duration)。為了簡化程式,我們皆選取五年份的資料,對於觀察資產的波動應該足夠。
我們使用機器人選取 '5Y'
(五年) 的時距:
const durations = await page.$$('[data-test=\"date-picker-menu\"] div span');
for (let i = 0; i < durations.length; i++) {
const text = await page.evaluate(function (elem) {
return elem.innerText;
}, durations[i]);
if (text.match('5Y')) {
await durations[i].click();
break;
}
}
await delay(3000);
基本上,選取的方式和先前相同,故不詳談。倒是讀者可以注意一下我們在程式結束後加上 await delay(3000);
,這是為了讓爬蟲暫停三秒,等待頁面反應完。
我們來看一下 delay()
函式如何實作:
const delay = function (ms) {
return new Promise(function (resolve) {
setTimeout(resolve, ms);
});
};
由於 Node.js 程式是非同步性的,我們要如何停住整隻程式呢?我們這裡用 promise 結合 setTimeout() 函式來暫停程式。setTimeout()
函式會在指定的 ms
(毫秒) 後執行第一個參數帶入的回呼函式 (callback)。在這裡,我們把 promise 的 resolve
當成 setTimeout()
的參數帶入。實際的效果就是在指定毫秒後完成該 promise。
然後這隻爬蟲依序按下 Done 和 Apply 按鈕。由於程式的模式雷同,這裡不展示程式碼,有興趣的讀者請自行追蹤我們的範例程式。
程式下載後,會以 CSV 格式存在外部檔案中。我們要確認該樣式表已經下載完成,所以我們寫了兩隻函式,分別是 watcher
和 timer
。watcher
用來監測檔案是否下載完成。timer
用來預防檔案下載時間過長,會在 30 秒後停掉程式。我們把整個程式包在一個 promise 中:
await new Promise(function (resolve) {
var watcher = fs.watch(path.dirname(__filename), function (et, filename) {
if (et === 'rename' && filename === `${asset}.csv`) {
clearTimeout(timer);
watcher.close();
resolve();
}
});
var timer = setTimeout(function () {
watcher.close();
throw new Error('No file');
}, 30000);
});
watcher
的部分是利用 fs.watch() 函式監測專案所在目錄。當檔案確實下載完成時,將 timer
和 watcher
停掉,並完成此 promise。
反之,若檔案超過 30 秒還沒下載完成時,我們將 watcher
終止,接著拋出例外。我們無法預知檔案什麼時候會下載完,所以只能先設一個合理的時距。根據實際的測試,資產交易記錄樣式表檔案都蠻小的,30 秒應該是合理的等待時間。
最後同樣要關掉瀏覽器:
await browser.close();
替代寫法:使用 promise
我們同樣用 promise 改寫這隻爬蟲,有興趣的讀者可以參考一下,我們不逐一講解。同樣地,該程式不是投資建議,我們不擔保使用該程式的結果。
由於 Node.js 環境不是瀏覽器,不用守在舊語法,能夠相對自在地使用 ES6+ 的語法寫 JavaScript 程式。所以我們之後不會再刻意用 promise 來寫 Puppeteer 爬蟲,而會用官方建議的 async
/ await
模式來寫。
附註
經筆者實測,本爬蟲使用 headless 模式執行時,有時候會下載失敗。由於沒有錯誤訊息,很難猜出到底發生了什麼事。
我們可以取消 headless 模式來執行 Puppeteer 爬蟲:
const browser = await puppeteer.launch({ headless: false });
使用一般模式便失去了 Puppeteer 一部分的優勢,但為了正確地執行 Puppeteer 爬蟲,有時候是不得不的措施。
有些細心的讀者會想到在伺服端環境部署 Puppeteer 爬蟲時,要如何以一般模式執行該爬蟲?在 GNU/Linux 等類 Unix 系統上,可以用 XVFB 這種虛擬螢幕「騙」爬蟲,就可以在不輸出畫面的前提下以一般模式執行爬蟲。
最後講一個和 Puppeteer 無關的事情。Yahoo Finance 所提供的交易記錄有時會漏失 (missing data),某種程式影響對資產的評估。除了使用 Yahoo Finance 抓資料外,最好還是搭配其他的資料源,像是證交所網站,比較不會錯估資產。