[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 抓資料外,最好還是搭配其他的資料源,像是證交所網站,比較不會錯估資產。

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
    【追蹤新文章】
    Facebook Twitter Plurk