[Web] 使用 Next.js 及 Firebase 打造個人基金損益清單管理工具

  • 1648
  • 0
  • Web
  • 2021-05-20

使用 Next.js 及 Firebase 打造個人基金損益清單管理工具

前言


當基金透過好幾間銀行購入時,要定期審視時總是要開啟各銀行 app 才可以達成,身為軟體開發者絕對不允許這種事情發生,因為我們是很追求極致懶的人,所以這次的構想是要建立一個個人基金管理工具,能夠取得當日最新價格並即時運算損益,最重要本專案是非營利自用的工具,因此要使用最精簡最快速的方式達成,所以能免費就用免費的,能用線上服務的就用線上服務。

相關技術規劃如下:

next.js

  • 網站主體
  • 提供基金清單 API (使用 cheerio 爬公開資訊)
  • 提供基金現價 API (使用 cheerio 爬公開資訊)

firebase

  • 全站用戶管理 (Email/Password)
  • 存放用戶基金 (Realtime Database)
  • 僅授權用戶存取個人 uid 轄下資料

deployment

  • Vercel (next.js 最佳佈署環境)

 

 

資料取得策略


要完成這個工具最重要的事情就是要取得當日的基金參考淨值,平常可能是開啟瀏覽器去「查詢該基金」並在基金頁面上「查看淨值」資料,如果以系統的角度這些事情都是要自動化的,所以初步想法如下:

  1. 取得基金清單

    各大理財網站都有提供基金查詢功能,並且在查詢後提供連結導向該基金的專屬頁面,接著頁面上就會顯示淨值等資訊,而我們可以觀察一下查詢時該網站提供的 API 為何,在我們的工具網站中可經由此 API 列出各基金名稱,並進而得知該基金於理財網站中被定義的 ID 值,後續當選定該基金後所需要導向的基金專屬頁面 URL 也必定包含此 ID 參數。

     
  2. 取得基金當日淨值

    前一步驟取得指定基金的專屬頁面後,我們需要的所有資料都存在該頁面中,接著透過 cheerio 定義出 selector 抓取我們感興趣的「當日淨值」資訊即可。
     

多數站台 API 都會限制同源政策,也就表示只有他們認可的 domain 才能呼叫,若在我們的站台前端發出請求會被瀏覽器擋下,所以必須在後端去叫用該 API 才不會有 CORS 的阻礙,而我們使用的 Next.js 本身就可以在後端提供 API 服務,簡直是個最完美精簡的配置。

 

 

建立 API 爬出資料


筆者使用的資料來源是 MoneyDJ 理財網,在該網頁操作基金查詢時發現是由一隻 API 負責列出相關清單,因此我們直接在 Next.js 後端建立 pages/api/fundquery.js 檔案,使用 fundquery 這隻 API 負責接收關鍵字並轉呼該 API 回傳基金清單。

import axios from 'axios'

export default async (req, res) => {
  // req data
  const { query: { name } } = req

  // get data
  const url = 'https://www.moneydj.com/funddj/djjson/YFundSearchJSON.djjson?q=' + encodeURIComponent(name)
  const { data } = await axios.get(url, {
    responseType: 'arraybuffer',
    transformResponse: [function (data) {
      const iconv = require('iconv-lite')
      return iconv.decode(Buffer.from(data), 'big5')
    }]
  })

  // data = 'TLZF7|安聯主題趨勢基金-AT累積類股(美元)|2,TLZH8|安聯主題趨勢基金-BT累積類股(美元)|2,TLZF8|安聯主題趨勢基金-IT累積類股(美元)|2,'

  // parser
  let fundData = []
  if (data && data.length > 1) {
    const funds = data.split(',')
    fundData = funds.map(f => {
      const fundInfo = f.split('|')
      return { id: fundInfo[0], name: fundInfo[1], type: fundInfo[2] }
    })
  }

  res.status(200).json(fundData.filter(f => f.id && f.name))
}

 

接著建立 pages/api/fund.js 檔案,使用 fund 這隻 API 將個別基金主頁資料取回,再使用 cheerio 指定 selector 取得參考淨值及參考日期資料;由於本工具自用且非營利,因此就不花時間著墨在 selector 是否合理且持久性高,反正目前能先抓到正確資料就好。

import axios from 'axios'
import cheerio from 'cheerio'

export default async (req, res) => {
  // req data
  const { query: { id, type, key } } = req

  // get html
  let url = ''
  if (type === '1') {
    url = 'https://www.moneydj.com/funddj/ya/yp010000.djhtm?a=' + id
  } else if (type === '2') {
    url = 'https://www.moneydj.com/funddj/yp/yp010001.djhtm?a=' + id
  }

  const { data } = await axios.get(url)

  // parser
  const $ = cheerio.load(data)
  const price = $('#article > form > table.t01 > tbody > tr:nth-child(2) > td:nth-child(2)').html()
  const refdate = $('#article > form > table.t01 > tbody > tr:nth-child(2) > td:nth-child(1)').html()
  res.status(200).json({ price: price ? parseFloat(price) : null, id, key, refdate })
}

 

雖然這些都是公開資料,但還是需要謹記兩個原則,第一請遵守該網站訂定於 robots.txt 的規範,可以在網站根目錄下的 robots.txt 查看那些頁面資料是 Disallow 不允許被取得;第二不要造成網站伺服器的負擔,也就是不要密集頻繁且大量的取得資訊;讓我們一起當個有禮貌的爬蟲吧!

 

 

啟用 Firebase 身分驗證


雖然是個小工具,但是也要顧及使用者的隱私,所以建立一個簡單的登入機制是必要的,讓用戶僅能查詢並維護自己的基金清單;我們可簡單使用 Firebase 提供的授權機制,它除提供 Facebook, Google 等第三方授權機制外,也有單純 Email / Password 認證機制,而本專案就以最單純的方式進行吧。

首先建立一個 Firesbase 帳號,加入名為 asset-pool 的 Project 後,就可以在 Authentication 頁籤中的 Sign-in method 中選擇需要 Enable 的登入方式,筆者使用最單純的 Email / Password 就可以了。

 

什麼!這樣就結束了?沒錯,後續只要透過 Firebase 提供的 API 就可以輕鬆新增用戶、登入、登出,並且在用戶忘記密碼時,也可以透過 Firebase 的 API 觸發發出重設密碼信件,用戶就可以依照連結導向一個 Firebase 提供的介面自行修改密碼,這對筆者這種小小小工具的應用真的是非常足夠且省事。

 

 

啟用 Realtime Database


由於我們需要保存基金清單資料在 Firebase 資料庫中,因此先把 Realtime Database 功能啟用,並且先在授權規則中設定 users 下層資料僅能當用戶登入後且 uid 相同時才能讀寫。

 

比對一下資料結構可能會比較有感覺,一開始筆者手動在根目錄中加入 users 目錄,接著當用戶透過 Firebase  API 註冊後,筆者會主動在 users 路徑下以用戶 uid 為 key 新增一筆用戶資料,包含 email, nickname 及 signup 等基本資訊,後續用戶所新增的基金資料會放在 funds 中維護,所以就以剛剛 rules 設置可確保目前用戶僅能異動 users 下 key 為用戶自己 uid 的資料,這樣是比較安全的做法。

 

 

加入 Firebase 於網站中


首先需要在 Firebase 剛剛建立的 asset-pool 專案中新增名稱為 asset-pool-web 的 web 應用項目,不論是 ios、android 手機應用程式或者 web 網站要使用 Firebase 都需要進行新增,所以可以點擊 Add app 按鈕進行新增。

 

接著在 Project Settings 介面中的 General 頁籤的 Your apps 項目中就可以看到 asset-pool-web 的 web 應用項目設定檔,這些參數就是 Firebase 連線的資訊。

 

接著在你的站台中安裝 Firebase 的 client 端套件。

npm install firebase --save

 

筆者習慣建立 firebaseHelper.js 放置所有 Firebase 的相關操作,以剛在 Firebase 中取得的 config 資訊來呼叫 firebase.initializeApp(config) 進行初始。

/* firebaseHelper.js */


// Firebase App (the core Firebase SDK) is always required and must be listed first
import firebase from 'firebase/app'

// If you enabled Analytics in your project, add the Firebase SDK for Analytics
import 'firebase/analytics'

// Add the Firebase products that you want to use
import 'firebase/database'
import 'firebase/auth' 

// Your web app's Firebase configuration
const firebaseConfig = {
  apiKey: 'AIzaSyDFQW_ml1MLdG1pHN9eX8hOnEAQiNhluWs',
  authDomain: 'asset-pool.firebaseapp.com',
  databaseURL: 'https://asset-pool-default-rtdb.firebaseio.com',
  projectId: 'asset-pool',
  storageBucket: 'asset-pool.appspot.com',
  messagingSenderId: '239900430969',
  appId: '1:239900430969:web:ee98bf5acc0eecc5083aa8',
  measurementId: 'G-4QM3SBTXGT'
}

const initFirebase = () => {
  // preventing Next.js from accidentally re-initalizing your SDK when Next.js hot reloads your application!
  if (!firebase.apps.length) {
    firebase.initializeApp(firebaseConfig)
  } else {
    firebase.app() // if already initialized, use that one
  }
}

export default { initFirebase }

 

最後在站台 app 進入點呼叫 firebaseHelper.initFirebase() 初始即可,以 next.js 為例就可以考慮在 _app.js 中執行;完成這個階段操作後,你就可以在網站中針對 Firebase 進行操作了。

import firebaseHelper from '../helpers/firebaseHelper.js'

export default function App ({ Component, pageProps }) {
  // 初始 firebase
  firebaseHelper.initFirebase()

  return (
    <>
      <Component {...pageProps} />
    </>
  )
}

 

 

新增用戶


如果只是想測試的朋友可以直接在 Fireabse 網站上面針對你的 Project 手動新增用戶。

 

在此我們新增一個會員註冊頁面

 

透過 firebase.auth().createUserWithEmailAndPassword(email, password) 新增用戶,成功後可以使用 firebase.database().ref() 指定到 users 下的用戶本人 uid 位置中,再將用戶註冊時間、郵件及暱稱寫入資料庫中;若新增失敗,則可依據 error.code 與 error.message 將錯誤資訊印出。

import firebase from 'firebase/app'


// 透過 auth().createUserWithEmailAndPassword 建立使用者
const database = firebase.database()
firebase.auth().createUserWithEmailAndPassword(email, password)
  .then(u => {
    // 取得註冊當下的時間
    const now = (new Date()).getTime()

    // 記錄相關資訊到 firebase realtime database
    database.ref(`users/${u.user.uid}`).set({
      signup: now,
      email,
      nickname
    }).then(() => {
      // 儲存成功後顯示訊息
      message.info('註冊用戶成功,請登入系統。')
      router.push('/login')
    })
  }).catch(error => {
    // 註冊失敗時顯示錯誤訊息
    let errorMsg = ''
    switch (error.code) {
      case 'auth/invalid-email':
        errorMsg = '電子信箱格式錯誤'
        break
      case 'auth/email-already-in-use':
        errorMsg = '此電子信箱用戶已存在'
        break
      case 'auth/operation-not-allowed':
        errorMsg = '未啟用 email/password 授權機制 (系統設置)'
        break
      case 'auth/weak-password':
        errorMsg = '密碼強度不足'
        break

      default:
        errorMsg = error.code + ':' + error.message
    }
    message.error('註冊失敗: ' + errorMsg)
  })

 

 

忘記密碼


當用戶忘記自己密碼時,全球唯一解決方案就是重設密碼了,而開發人員千萬不要想紀錄用戶明碼密碼於資料庫中來提供「自以為友善」的忘記密碼流程喔!誰會希望自己慣用的密碼組合被你記錄下來呢?而 Firebase 提供 firebase.auth().sendPasswordResetEmail(email) 方法可發送密碼重設郵件給用戶。

import firebase from 'firebase/app'

const handleForgetPwd = () => {
  const email = form.getFieldValue('email')
  if (email) {
    firebase.auth().sendPasswordResetEmail(email)
      .then(function () {
        message.info('密碼重設信件已寄出,請依照信中連結進行重設。')
      })
      .catch(function (error) {
        let errorMsg = ''
        switch (error.code) {
          case 'auth/invalid-email':
            errorMsg = '電子信箱格式錯誤'
            break
          case 'auth/user-not-found':
            errorMsg = '此用戶不存在'
            break
          default:
            errorMsg = error.code + ':' + error.message
        }
        message.error('忘記密碼: ' + errorMsg)
      })
  } else {
    message.warn('請輸入電子信箱')
  }
}

 

預設的信件內容如下,用戶可依照信中連結至 Firebase 提供的介面自行修改密碼。

 

若想調整電子郵件內容可至 Authentication 中編輯,其中 %APP_NAME% 參數可以於 Project Settings 中 General 頁籤內的 Public-facing name 資訊做調整。

 

 

登入系統


註冊後用戶隨即就可以透過 firebase.auth().signInWithEmailAndPassword(email, password) 登入系統,而 Firebase 會知道目前使用的用戶是誰,並且在操作 Realtime Database 時,依據剛剛設定的授權規則來限制用戶可操作的資料範圍。

import firebase from 'firebase/app'

try {
  // login firebase by email & password
  await firebase.auth().signInWithEmailAndPassword(email, password)
  router.push('/')
  
} catch (error) {
  let errorMsg = ''
  switch (error.code) {
    case 'auth/invalid-email':
      errorMsg = '電子信箱格式錯誤'
      break
    case 'auth/user-disabled':
      errorMsg = '此用戶已失效'
      break
    case 'auth/user-not-found':
      errorMsg = '此用戶不存在'
      break
    case 'auth/wrong-password':
      errorMsg = '密碼錯誤'
      break

    default:
      errorMsg = error.code + ':' + error.message
  }
  message.error('登入失敗: ' + errorMsg)
}

 

登出時可使用 firebase.auth().signOut() 通知 Firebase 此執行此用戶的登出行為。

 

 

新增基金


建立一個新增基金頁面,把你感興趣的相關基金資訊都放進去。

 

我們可以透過用戶輸入的關鍵字進行搜尋基金,而輸入時呼叫 Next.js 提供的後端 fundquery API 服務,將關鍵字送到後端並取得關鍵字相關的基金清單供用戶挑選。

 

針對這類需求請避免每輸入一個字就觸發查詢一次,可以利用 debounce 特性來限制僅在停止輸入後才進行查詢,以此減少過多不必要的請求發生。

import debounce from 'lodash/debounce'

const onSearch = debounce(async (val) => {
    if (val && val.length > 0) {
      const url = '/api/fundquery?name=' + val
      const { data } = await axios.get(url)
      const options = data.map(d => ({ name: d.name, id: d.id, type: d.type }))
      setFundOptions(options)
    }
  }, 800)

 

最後把資料放入 database 中,筆者為求快速簡便都使用 set 來覆蓋該用戶 users/{登入用戶uid}/funds 下的所有基金資訊,比較合理的處理方式應該要僅加入一筆新增基金至 funds 清單中就好了。

import firebase from 'firebase/app'

const saveMyFunds = (uid, newFunds) => {
  const db = firebase.database()
  const eventref = db.ref(`users/${uid}/funds`)
  eventref.set(newFunds)
}

 

 

取得基金清單


用戶登入系統後,我們須將用戶記錄的所有基金清單從 Realtime Database 取出,因此可透過 once 進行一次性的資料撈取,將 users/{登入用戶uid}/funds 所有基金一次撈出;另外,若是有多方應用程式會同時異動此基金清單的狀況下,可以考慮使用 on 來撈取基金清單,其與 once 的差異是 on 會持續監聽,當 Realtime Database 中的基金清單被異動時會再次觸發 on 的事件,因此可依照自己使用情境選擇資料撈取方式。

import firebase from 'firebase/app'

const loadMyFunds = async (uid) => {
  const db = firebase.database()
  const eventref = db.ref(`users/${uid}/funds`)
  const snapshot = await eventref.once('value')
  const myFunds = snapshot.val()

  return myFunds || []
}

 

 

基金淨值呈現


取出資料後就是要逐筆呼叫 API 取得今日的參考淨值,這邊要注意的是不要逐筆呼叫並等待回應後再執行下一筆,我們可以使用非同步的方式同時送出 API 請求來節省時間,最後使用 Promise.all() 等待所有請求都回應後一次更新畫面上的資訊。

async function fetchData () {
  const newFundDetails = []
  const apiResonses = []

  // call all api to get current prices at the same time
  setIsLoading(true)
  for (const myFund of myFunds) {
    const url = '/api/fund?id=' + myFund.id + '&type=' + myFund.type + '&key=' + myFund.key
    apiResonses.push(axios.get(url))
  }

  // wait for all prices back
  Promise.all(apiResonses)
    .then(responses => {
      // deal with each api response to get the current price
      responses.forEach(response => {
        const { data } = response
        const fund = myFunds.find(f => f.key === data.key)
        if (fund) {
          const { key, id, name, date, amount, price, interest } = fund
          const currentPrice = data.price
          const currentPriceRefDate = data.refdate
          newFundDetails.push({
            key,
            id,
            name,
            date,
            amount,
            price,
            currentPrice,
            currentPriceRefDate,
            returnRate: getReturnRatePercentage({ price, currentPrice }),
            returnAmount: getReturnAmount({ price, currentPrice, amount }),
            interest,
            returnRateWithInterest: getReturnRateWithInterestPercentage({ price, currentPrice, amount, interest })
          })
        }
      })

      setFundDetails(newFundDetails)
    })
    // eslint-disable-next-line node/handle-callback-err
    .catch(error => { message.error('無法取得參考淨值') })
    .finally(() => { setIsLoading(false) })
}

這部分也可以考慮一次將基金清單送回後端,由後端非同步一次併發多筆 request 向資料來源端取得當日參考淨值;這樣的好處就是節省前後端的請求數量,另外當基金數量超過瀏覽器一次可同時發出的請求上限時,就必須花費更多時間來處理,而這部分在後端就不會受到限制,因此以上是後續還可以進行優化的部分。

 

 

成果展現


最終就可呈現每檔基金截至當日的損益狀況為何,終於擺脫一次要開好幾個 app 才看得完的資訊了。 (工程師的快樂就是這麼樸實無華阿!)

 

想玩玩的朋友可至 Asset Pool 體驗看看,另外若對程式細節感興趣的可以到筆者 Github 走走。

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !