使用 Clojure 擷取台灣銀行牌告匯率

最近開始在看理財相關的資訊,畢竟錢一直存著也只是存著,剛好看到了 台灣銀行牌告匯率 的訊息,就讓我們用 Clojure 來寫一個 parser 將這個 HTML 網頁轉換成 Clojure 常用的 edn 格式吧。

本文將簡述如何透過 clj-http 取得 台灣銀行牌告匯率 資訊後,再使用 hickory 對取得網頁的資料進行處理,最後回傳 edn 格式好方便我們未來的 Clojure 程式可以對這些訊息進行處理。

設定我們的目標

在開始寫程式前,先來確立我們的目標。我們希望取得 台灣銀行牌告匯率 的資訊,並將下圖裡面的資料轉換成 Clojure 的 edn 格式。

因此最後我們希望可以得到這樣的結果:

{:url "http://rate.bot.com.tw/Pages/Static/UIP003.zh-TW.htm",
 :date "2016/02/25 16:00",
 :count 19,
 :results {:USD {:name "美金 (USD)",
                 :cash-selling "32.92500",
                 :cash-buying "33.46700",
                 :spot-selling "33.22500",
                 :spot-buying "33.32500"},
           :EUR {:name "歐元 (EUR)",
                 :cash-selling "35.99000",
                 :cash-buying "37.13000",
                 :spot-selling "36.47000",
                 :spot-buying "36.87000"},
           ;; skip ...
           :PHP {:name "菲國比索 (PHP)",
                 :cash-selling "0.64980",
                 :cash-buying "0.78250",
                 :spot-selling "-",
                 :spot-buying "-"}}}

建立我們的專案

首先我們先透過 leinigen 產生我們 Clojure 專案的基本樣板,這邊命名該專案為 twbank-forex

coldnew@Rosia ~ $ lein new twbank-forex

專案建立完成後,我們要稍微修改一下 project.clj 添加 hickory 以及 clj-http 這兩個函式庫。

(defproject twbank-forex "0.1.0-SNAPSHOT"

  ;; skip ...
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [hickory "0.6.0"]
                 [clj-http "2.1.0"]]
  :main twbank-forex.core)

開始我們的程式

就讓我們來開始我們的專案吧,首先編輯 src/twbank_forex/core.clj 這個檔案,並添加一些我們需要的函式庫資訊:

(ns twbank-forex.core
  (:require [clj-http.client :as http]
            [hickory.core    :as hickory]
            [hickory.select  :as s]
            [clojure.string  :as str]))

由於我們要分析的目標是 台灣銀行牌告匯率 這個網頁,因此先設立一個全域變數,其內容存放我們想要分析的網址

;; The webpage we want to parse
(def ^:private
  twbank-url "http://rate.bot.com.tw/Pages/Static/UIP003.zh-TW.htm")

接著,讓我們一步一步來實作

取得網頁內容

我們的目的是下載 台灣銀行牌告匯率 網頁內容並進行解析,因此我們可以透過 clj-http 來處理這件事

(http/get twbank-url)

如果你的網路連得上我們的目標網址的話,你會得到如下的回應

{:status 200,
 :headers {"Server" "Microsoft-IIS/8.5",
           "Content-Type" "text/html",
           "Content-Length" "3664",
           "Connection" "close",
           "Accept-Ranges" "bytes",
           "ETag" "W/\"809337e3b70d11:0\"",
           "Date" "Fri, 26 Feb 2016 02:23:39 GMT",
           "Vary" "Accept-Encoding",
           "Last-Modified" "Fri, 26 Feb 2016 02:14:59 GMT",
           "X-Powered-By" "ASP.NET"},
 :body "\r\n\r\n<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">\r\n\r\n<html xmlns=\"http://www.w3.org/1999/xhtml\">\r\n<head><link href=\"../../App_Themes/Builtin/Default.css\" type=\"text/css\" rel=\"stylesheet\" /><meta http-equiv=\"Content-Type\" content=\"text/html;charset=utf-8\" /><title>\r\n\t 臺灣銀行牌告匯率\r\n</title>\r\n ", ;; skip ....
 :request-time 227,
 :trace-redirects ["http://rate.bot.com.tw/Pages/Static/UIP003.zh-TW.htm"],
 :orig-content-encoding "gzip",
 :cookies {"BankOfTaiwanCookie" {:discard false,
                                 :expires #inst "2016-02-26T05:23:42.000-00:00",
                                 :path "/",
                                 :secure false,
                                 :value "352626880.20480.0000",
                                 :version 0}}}

由上面的回應來看,我們可以發現網頁的 內容 是存放在 :body 裡面,而在本例中我們只需要取得網頁內容即可,因此可以這樣建立取得網頁內容的函式

(defn fetch-html []
  (:body (http/get twbank-url)))

取得最新掛牌時間

取得網頁內容後,我們可以來查找這個網頁的掛牌時間,從網頁上來看就是這個

因此使用瀏覽器查看原始碼時,你可以找到這樣的訊息

也就是說,我們只要針對關鍵字 牌價最新掛牌時間 使用正規表達式來進行處理,就可以取得掛牌時間的資訊,針對此問題,我們可以透過 Clojure 的 re-find 來對目標進行切割

(re-find #"(.*牌價最新掛牌時間:&nbsp;)([\d\/]+\s[\d:]+)(.*)"
         "<td style=\"width:326px;text-align:left;vertical-align:top;color:#0000FF;font-size:11pt;font-weight:bold;\">
      <img style=\"vertical-align:middle;width:20px;\" src=\"../../Images/Monitor.gif\" alt=\" \" />&nbsp;

      牌價最新掛牌時間:&nbsp;2016/02/26 10:28
    </td>")

;; =>
;; ["      牌價最新掛牌時間:&nbsp;2016/02/26 10:28"
;;  "      牌價最新掛牌時間:&nbsp;"
;;  "2016/02/26 10:28"
;;  ""]

re-find 會依照你設定的正規表達式,將目標分成不同群組,得到的結果如上。也因此我們可以知道我們的目標會出現在結果的 index 2 (Clojure 陣列是從 0 開始計算)

最後,將這函式整理一下即可獲得

(defn parse-date [html]
  (nth (re-find #"(.*牌價最新掛牌時間:&nbsp;)([\d\/]+\s[\d:]+)(.*)" html) 2))

將網頁轉換成 hickory 格式

在本程式中我們將透過 hickory 來讓我們解析 HTML 更為簡單,hickory 是一個可用於 Clojure/ClojureScript 的函式庫,可以簡化我們解析 HTML 文件的麻煩,唯一的缺點是,由於目前 node.js 缺少了 DOM 元件,因此使用 hickory 寫出的 ClojureScript 是無法在 node.js 上執行的。

我們首先使用 hickory/parse 將 HTML 轉換成 DOM 結構

(hickory/parse "<b>TEST</b>")

;; =>
;; #object[org.jsoup.nodes.Document
;;         "0x13e4a71e"
;;         "<html>\n <head></head>\n <body>\n  <b>TEST</b>\n </body>\n</html>"]

接著再使用 hickory/as-hickory 將剛剛獲得的 DOM 結構轉換成 hickory 的資料格式,其形式如下

(hickory/as-hickory (hickory/parse "<b>TEST</b>"))

;; =>
;; {:type :document,
;;  :content [{:type :element,
;;             :attrs nil,
;;             :tag :html,
;;             :content [{:type :element, :attrs nil, :tag :head, :content nil}
;;                       {:type :element,
;;                        :attrs nil,
;;                        :tag :body,
;;                        :content [{:type :element,
;;                                   :attrs nil,
;;                                   :tag :b,
;;                                   :content ["TEST"]}]}]}]}

將上面資訊整理一下,最後我們的函式即為

(defn html->hickory [html]
  (->> html
       hickory/parse
       hickory/as-hickory))

如果將這函式作用於前面所獲得的 HTML 內容,可以看到我們取得了以下的資訊

取得匯率轉換的資訊

接下來就是這個程式的重頭戲了,以網頁來看,我們的目標就是這些匯率資訊

為了方便尋找,我們先針對 美金 (USD) 的部分查找我們透過前面函式將 HTML 轉換成 Hickory 格式後的結果

由於我們的目標是 titleLeft 以及 decimal 這兩種 class, 因此就直接使用 hickory.select/select 來幫我們將目標資訊切割出來

(let [data (-> (fetch-html) html->hickory)]
  (s/select (s/child
             (s/or (s/class "titleLeft")
                   (s/class "decimal")))
            data))

透過這種方式,我們得到以下的陣列資訊

[{:type :element,
  :attrs {:class "titleLeft"},
  :tag :td,
  :content [{:type :element,
             :attrs {:class "paddingLeft16",
                     :src "/Images/Flags/America.gif",
                     :title "",
                     :alt ""},
             :tag :img,
             :content nil}
            " 美金 (USD)"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["32.89000"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["33.43200"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["33.19000"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["33.29000"]}
 ;; skip ...
 {:type :element,
  :attrs {:class "titleLeft"},
  :tag :td,
  :content [{:type :element,
             :attrs {:class "paddingLeft16",
                     :src "/Images/Flags/HongKong.gif",
                     :title "",
                     :alt ""},
             :tag :img,
             :content nil}
            " 港幣 (HKD)"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["4.12500"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["4.32400"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["4.24900"]}
 {:type :element, :attrs {:class "decimal"}, :tag :td, :content ["4.30900"]}]

實際上我們只對這份資料中的 :content 欄位有興趣,因此透過 map 將我們要的東西切割出來

(let [data (-> (fetch-html) html->hickory)]
  (->> (s/select (s/child
                  (s/or (s/class "titleLeft")
                        (s/class "decimal")))
                 data)
       (map #(-> % :content last))))   ; <= add this

這樣的話我們就會得到以下的陣列,裡面的東西都是我們要的資訊

(" 美金 (USD)"
 "32.89000"
 "33.43200"
 "33.19000"
 "33.29000"
 ;; skip ...
 " 人民幣 (CNY)"
 "4.98500"
 "5.14900"
 "5.05900"
 "5.10900")

我們發現到得到的標題,比如  美金 (USD) 前面都多了一個像是空格的東西無法顯示,那要怎樣濾掉呢? 實際上這個東西在 HTML 中是 &nbsp; 這種特殊符號, hickory 則是將它轉成一個特殊字元,在這邊我使用 replace 直接將這個字元替代掉 (註: 該字元網頁上看不到悠~)

(let [data (-> (fetch-html) html->hickory)]
  (->>  (s/select (s/child
                   (s/or (s/class "titleLeft")
                         (s/class "decimal")))
                  data)
        (map #(-> % :content last))
        (map #(str/replace % #" " "")))) ; <= add this

因此我們可以獲得

("美金 (USD)"
 "32.89000"
 "33.43200"
 "33.19000"
 "33.29000"
 ;; skip
 "人民幣 (CNY)"
 "4.98500"
 "5.14900"
 "5.05900"
 "5.10900")

仔細看這份資料,我們可以發現資料其實應該是 5 個一組 ,因此我們可以再使用 partition 將這份資料弄成每五個一組

(let [data (-> (fetch-html) html->hickory)]
  (->>  (s/select (s/child
                   (s/or (s/class "titleLeft")
                         (s/class "decimal")))
                  data)
        (map #(-> % :content last))
        (map #(str/replace % #" " ""))
        (partition 5)))                 ; <= add this

這樣子得到的資訊如下

(("美金 (USD)" "32.89000" "33.43200" "33.19000" "33.29000")
 ;; skip ...
 ("越南盾 (VND)" "0.00128" "0.00160" "-" "-")
 ("馬來幣 (MYR)" "6.70500" "8.43100" "-" "-")
 ("人民幣 (CNY)" "4.98500" "5.14900" "5.05900" "5.10900"))

將這邊的函式整理一下,得到以下的函式

(defn parse-data [data]
  (->> (s/select (s/child
                  (s/or (s/class "titleLeft")
                        (s/class "decimal")))
                 data)
       (map #(-> % :content last))
       (map #(str/replace % #" " ""))
       (partition 5)))

根據匯率轉換的資訊製作資料

在前面我們取得了匯率轉換的資訊,但是我們要怎樣把這些資訊做成以下的形式呢?

{:USD {:name "美金 (USD)",
       :cash-selling "32.92500",
       :cash-buying "33.46700",
       :spot-selling "33.22500",
       :spot-buying "33.32500"},
 ;; skip ...
 :PHP {:name "菲國比索 (PHP)",
       :cash-selling "0.64980",
       :cash-buying "0.78250",
       :spot-selling "-",
       :spot-buying "-"}}

就讓我們一步一步來吧,首先先透過 zipmap 替陣列裡面的每一元素加上 keyword

(map #(zipmap [:name :cash-selling :cash-buying :spot-selling :spot-buying] %)
     '(("美金 (USD)" "32.89000" "33.43200" "33.19000" "33.29000")
       ;; skip ...
       ("人民幣 (CNY)" "4.98500" "5.14900" "5.05900" "5.10900")))

這樣子我們的資料就弄出一部份囉 ~

({:name "美金 (USD)",
  :cash-selling "32.89000",
  :cash-buying "33.43200",
  :spot-selling "33.19000",
  :spot-buying "33.29000"}
 ;; skip ...
 {:name "人民幣 (CNY)",
  :cash-selling "4.98500",
  :cash-buying "5.14900",
  :spot-selling "5.05900",
  :spot-buying "5.10900"})

接下來,我們要想把法把 美金 (USD) 變成 :USD ,這個一樣可以透過 re-find 來解決

(-> (re-find #"(.*\()(.*)(\))" "美金 (USD)")
    (nth 2)
    keyword) ; => :USD

因此我們將前面寫的東西合併起來,將最後產生的資訊整合到 hash-map 中,就完成了

(defn build-data [data]
  (->> data
       (map #(zipmap [:name :cash-selling :cash-buying :spot-selling :spot-buying] %))
       (map #(hash-map (keyword
                        (nth (re-find #"(.*\()(.*)(\))" (:name %)) 2)) %))
       (into {})))

合併所有結果

在上一步驟中,我們已經幾乎完成我們的程式,是時候將他進行整合成我們期望的結果

(defn fetch []
  (let [html (fetch-html)
        ctx  (-> html html->hickory)
        data (-> ctx parse-data build-data)]
    {:url twbank-url
     :date (parse-date html)
     :count (count data)
     :results data}))

當你執行 fetch 這函式時,就會將 台灣銀行牌告匯率 轉變成這樣的資訊:

{:url "http://rate.bot.com.tw/Pages/Static/UIP003.zh-TW.htm",
 :date "2016/02/26 13:25",
 :count 19,
 :results {:CHF
           {:name "瑞士法郎 (CHF)",
            :cash-selling "32.91000",
            :cash-buying "34.02000",
            :spot-selling "33.51000",
            :spot-buying "33.80000"},
           ;; skip..
           :ZAR
           {:name "南非幣 (ZAR)",
            :cash-selling "-",
            :cash-buying "-",
            :spot-selling "2.09000",
            :spot-buying "2.19000"}}}

取得範例程式碼

本篇文章的範例程式碼已經上傳到 GitHub 上,你可以使用以下方式取得程式碼

git clone https://github.com/coldnew/blog-tutorial-examples.git

並切換到 2016/twbank-forex 資料夾去

coldnew@Rosia ~ $ cd blog-tutorial-examples/2016/twbank-forex

你可以透過 lein run 來測試本文取得的資訊 ~ Have Fun~~

coldnew@Rosia ~/twbank-forex $ lein run
{:url "http://rate.bot.com.tw/Pages/Static/UIP003.zh-TW.htm",
 :date "2016/02/26 13:25",
 :count 19,
 :results
 {:CHF
  {:name "瑞士法郎 (CHF)",
   :cash-selling "32.91000",
   :cash-buying "34.02000",
   :spot-selling "33.51000",
   :spot-buying "33.80000"},
skip .....