[Puppeteer] 程式設計:台股價格提醒機器人

【分享本文】
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

    前言

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

    我們在前文提過,Puppeteer 程式比 Selenium 程式更容易在伺服器上部署。所以,我們可以把整個 Puppeteer 程式包成 NPM 套件,之後在遠端快速建置 Puppeteer 機器人。

    在本文中,我們利用 Puppeteer 撰寫抓取台股價格的機器人,再利用類 Unix 系統上常見的 Cron 定時執行該機器人程式,就成為定時提醒台股價格的機器人。透過這樣的機器人,我們不需要時時盯盤,只需在特定價格出現時進出場即可。

    系統需求

    要架這樣的機器人,需要以下條件:

    • Node.js 運行環境
    • GNU/Linux 虛擬主機
    • 在遠端主機需安裝相依的套件
    • 自己的網域名稱
    • Mailgun 帳號

    我們會租用遠端的 GNU/Linux 虛擬主機,並把寫好的 Puppeteer 程式架在上面。主機上別忘了要安裝相依的套件。我們會利用系統上的 Cron 程式來定時執行機器人以監控股價。由於爬蟲過於密集地抓資料會被封鎖,筆者是設一小時執行一次。不炒短線的話,這樣的頻率應該足夠了。

    另外,我們需要利用 Mailgun 在特定價格出現時發信提醒我們。由於 Mailgun 需要搭配一個網域才能使用,所以要自己額外租一個網域名稱。Mailgun 本來是用來大量發信的軟體,但有每個月的免費額度。如果只給個人使用的話,基本上不會用滿免費額度的上限。

    租用普通的網域名稱的費用約 15 美元/年,含個人隱私保護功能。而小型 GNU/Linux 虛擬主機約為 5 美元/月。一年約花費 75 美元,折合台幣約每年 2300 至 2400 元,一個月約 200 元左右。

    常見的虛擬主機商有 DigitalOceanLinodeVultr 等。功能基本上大同小異,讀者可自行比較看看。

    當然,我們的軟體功能很簡單,跟專業財經軟體不能比。但 Puppeteer 的功能不限抓取台股資料,讀者可以自行擴充範例程式,抓取其他的財經資料。

    設定 Mailgun 帳號

    Mailgun 是一個自動寄信軟體,其核心功能是一套網頁 API,給程式搭配自己的程式來寄信。本來 Mailgun 是設計給企業使用的,所以免費額度開到每個月 10,000 封。對於獨立開發者來說,基本上是用不完的。

    要使用 Mailgun 的服務,需要註冊一個 Mailgun 帳號,這部分請讀者自行完成。

    比較麻煩的是要設置 DNS。每個網域名稱提供者的 DNS 設置面版都不一樣,要請讀者自己查詢網域提供者的相關文件。除了使用內定的 DNS 伺服器,也可以把 DNS 主機指向第三方方案,像是 Cloudflare 等。

    使用本軟體

    我們在這裡預寫好一個範例專案,這個範例不僅拿來展示 Puppeteer 程式的程式碼,也是立即可用的程式。

    使用 Git 抓取此專案:

    $ git clone https://github.com/cwchentw/twse-price-notify
    

    移動到專案所在的根目錄:

    $ cd twse-price-notify
    

    安裝相關套件:

    $ npm install
    

    由於 Puppeteer 自帶相對應版本的 Chromium,會花一點時間下載,請稍等一下。

    在專案根目錄撰寫 .env 設定檔,用來讀取環境變數。以下是假想範例:

    MAILGUN_DOMAIN=example.com
    MAILGUN_KEY=enter-mailgun-api-key-here
    INVESTOR=your@gmail.com
    

    前兩個環境變數請依據自己的 Mailgun 帳號所提供的資料來寫。第三個環境變數請寫自己常用的信箱。筆者分別以 GMail 和 Outlook 網頁版來測,還是 GMail 收信比較即時。我們總不想讓投資機會溜走吧。

    .env 設定檔會被專案忽略,所以不會暴露私人資訊。

    另外要寫一個資訊預定價格的設定檔。我們暫且命名為 asset.json ,檔案名稱可以自訂。參考範例如下:

    {
        "0050": [75.0, 80.0],
        "0056": [25.0, 27.0]
    }
    

    以上的價格只是假想值,不代表真實市況,請勿據此數據投資,筆者不擔保任何損失。

    asset.json 可存在專案根目錄或其他位置。像筆者自己的習慣是存到專案的上一層目錄,以免不慎存入專案中。

    使用以下指令執行此機器人程式:

    $ npm start /path/to/asset.json
    

    這樣只是單次執行,而且守在電腦前手動執行不太經濟。我們會在後文簡介 Cron 的使用方式,就可利用 Cron 定期執行此程式。

    程式碼解說

    我們在本節中說明此範例程式的寫法,讓讀者熟悉 Puppeteer 機器人的寫法。日後讀者就可以依據自己的需求改寫這隻程式。

    一開始先利用 dotenv 套件讀取 .env 設定檔中所設置的環境變數:

    require('dotenv').config();
    

    dotenv 的概念在於從設定檔中讀取環境變數,會比直接設置環境變數來得簡單。我們只要在版本控制軟體中忽略 .env 設定檔,私人資訊就不會暴露出來。

    接著讀入其他 NPM 套件:

    const fs = require('fs');
    const puppeteer = require('puppeteer');
    const superagent = require('superagent');
    

    由於 Puppeteer 會大量使用 async / await 語法來寫程式,我們把 Puppeteer 機器人的部分包在一個 async 函式中:

    async function queryStockPrice(asset) {
        /* Implement Puppeteer bot here. */
    }
    

    一開始先確認傳入的參數不為空:

    if (!asset) {
        throw new Error("No valid asset");
    }
    

    利用字串模板生成該資產相對應的網址:

    let url = `https://mis.twse.com.tw/stock/fibest.jsp?stock=${asset}`;
    

    這個網址名稱是怎麼來的呢?這是我們藉由觀察網址列而知。不要死背這個網址,而要自己去觀察目標網頁的參數設置模式。

    生成瀏覽器物件和分頁物件:

    const browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    

    browserpage 分別對應 Chromium 瀏覽器和其分頁。這應該算是 Puppeteer 機器人固定的起手式了。

    實際前往該網頁:

    try {
        await page.goto(url);
    } catch (err) {
        await browser.close();
        throw err;
    }
    

    這個動作有可能失敗,像是網頁掛點或是被防火牆封鎖等,所以要檢查是否成功存取。此外,存取網頁失敗時要記得關掉瀏覽器,以免閒置的瀏覽器占掉主機的記憶體。

    抓出價格所在的網頁元素:

    const priceElem = await page.$(`#fibestrow > td`);
    

    這行本身很簡單,關鍵在於如何寫 CSS selector。這只能藉由多觀察網頁原始碼來加強撰寫有效的 CSS selector。使用滑鼠右鍵的「檢查元素」可以加快檢視網頁原始碼的效率,但還是要自己學著去觀看網頁原始碼。

    取出網頁元素的文字,也就是價格:

    const price = await page.evaluate(function (elem) {
        return elem.textContent;
    }, priceElem);
    

    別忘了關掉瀏覽器:

    await browser.close();
    

    最後回傳價格即可:

    return price;
    

    我們另外寫了一個用 Mailgun 寄信的函式:

    async function sendMail(subject, text) {
        superagent
            .post(`https://api.mailgun.net/v3/${process.env['MAILGUN_DOMAIN']}/messages`)
            .auth('api', `${process.env['MAILGUN_KEY']}`)
            .type('form')
            .send({
                "from": `Price Notify <notify@${process.env['MAILGUN_DOMAIN']}>`,
                "to": [`${process.env['INVESTOR']}`],
                "subject": subject,
                "text": text
            })
            .catch(function (err) {
                console.log(err);
            });
    }
    

    SuperAgent 是一個以非同步執行的 HTTP 客戶端程式程式。我們利用 SuperAgent 向 Mailgun 主機發出請求來奇信。我們把信件的主題和內文參數化,就可以依據不同的情境寄出不同的內容。

    由於 SuperAgent 不是 Mailgun 推薦的 HTTP 客戶端軟體,在官網是看不到這段範例程式的。那麼,我們要怎麼寫這段程式碼呢?還是得回歸到 Mailgun 的 API 文件。基本上都是要邊查 API 文件邊寫。對於網頁 API 來說,其本質是跨平台的,通常參考 curl 的使用範例來改寫即可。

    對於要公開展示的程式,不適合將私人資訊寫入,所以我們利用環境變數將固定的私人資訊參數化。這算是常見的手法。

    我們最後來看主程式的部分。主程式的部分以一個匿名的 async 函式包起來,該函式會以 IIFE 模式立即執行:

    (async function () {
        /* Implement main program here. */
    })();
    

    由於我們先前的函式都有用到 async / await 的特性,所以這裡也要用相對應的 async 函式包起來。這在 Puppeteer 程式中也算是固定手法了。

    一開始先讀入資產價格設定檔的路徑:

    let args = process.argv;
    
    if (args.length < 3) {
        throw new Error('No valid config');
    }
    
    const config = args[2];
    

    process.argv 本身是一個字串陣列,該陣列第三個變數起是外部輸入的命令列參數。我們的程式只需單一參數,而且不需要其他選擇性參數,故我們不使用解析命令列參數的函式庫。

    以同步的方式讀入資產價格設定檔:

    let file = fs.readFileSync(config, 'utf8');
    

    利用 Node.js 內建的 JSON 函式庫解析讀入的字串:

    let targets = JSON.parse(file);
    

    逐一走訪各目標資產,當該目標資產到達目標價格時,就寄信通知投資者:

    for (let target in targets) {
        const min = targets[target][0];
        const max = targets[target][1];
    
        console.log(`Query the price of ${target}...`)
    
        let price;
        try {
            price = await queryStockPrice(target);
        } catch (err) {
            await sendMail(`Unable to fetch stock data ${target}`, 'Please check your app');
            return;
        }
    
        if (price <= min) {
            await sendMail(`Buy ${target} at ${price}`, `Buy ${target} at ${price}`);
        } else if (max <= price) {
            await sendMail(`Sell ${target} at ${price}`, `Sell ${target} at ${price}`);
        }
    
        await delay(5000);
    }
    

    我們的 Puppeteer 程式到此結束其任務。

    使用 Cron 排程執行機器人

    我們的範例程式本身是單次執行的命令列程式,要藉由系統排程軟體才有定期提醒的功能。類 Unix 系統的 Cron 就是知名的系統排程軟體。自訂的 Cron 的設定檔寫在 /etc/cron.d 中,像是 /etc/cron.d/price-notify 就是一個不錯的名字。

    Cron 設定包含週期、使用者、指令三個部分。以我們的範例程式來說,我們假定每週一到週五執行,從 9AM 至 1PM 每小時執行一次。執行位於家目錄底下的範例程式。參考指令如下:

    5 9-13 * * 1-5 user cd /home/user/twse-price-notify && npm start ../assets.json
    

    前面時間的部分有固定的格式。可以到 crontab.guru 調整自己所需的週期。使用者和指令的部分則請依自己主機的實際情形修改,勿直接照抄。

    評語

    從技術層面來看,本範例程式充分展現 Puppeteer 的優點。我們利用瀏覽器的 headless 模式執行爬蟲程式,不需要安裝整個桌面環境,只需安裝一些相依函式庫。搭配 Cron 等系統排程軟體,就成了定時執行的機器人。

    從財經層面來看,本範例程式所用的策略過於簡單,只適合有循環波動的資產。若某項資產有長期衰退的可能性,或者是波動週期過長,就不適合用這項策略來投資。隨著市場變化,我們也要不定期調整我們的目標價格,才不會變成長期套牢或是獲利過少。

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