位元詩人 [Puppeteer] 程式設計教學:爬取 Yahoo Finance 的網頁爬蟲

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

免責聲明:我們盡力確保本文的正確性,但本文不代表任何投資的建議,我們也無法擔保因使用本文的內容所造成的任何損失。如對本文內容有疑問,請詢問財經相關的專家。

在本文中,我們撰寫前往 Yahoo Finance 網站抓取股票、ETF等資產的歷史交易資料的網頁爬蟲。由於 Yahoo Finance 已經提供這些交易記錄的 CSV 格式資料表,我們的爬蟲並沒有存取頁面上的資料,只是將拜訪網頁及下載資料的過程自動化。

在先前的文章中,我們用 Selenium 的 Python binding 寫網頁爬蟲,讀者可以相互比較一下兩種工具的差異。

標準寫法:使用 asyncawait

由於程式碼稍長,我們把程式碼放在 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。

然後這隻爬蟲依序按下 DoneApply 按鈕。由於程式的模式雷同,這裡不展示程式碼,有興趣的讀者請自行追蹤我們的範例程式。

程式下載後,會以 CSV 格式存在外部檔案中。我們要確認該樣式表已經下載完成,所以我們寫了兩隻函式,分別是 watchertimerwatcher 用來監測檔案是否下載完成。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() 函式監測專案所在目錄。當檔案確實下載完成時,將 timerwatcher 停掉,並完成此 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 抓資料外,最好還是搭配其他的資料源,像是證交所網站,比較不會錯估資產。

關於作者

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

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