爬取 Yahoo Finance 的網頁爬蟲

    前言

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

    在本文中,我們撰寫前往 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 Yahoo
    【追蹤本站】
    Facebook Facebook Twitter Parler