使用 Next.js 及 Firebase 打造個人基金損益清單管理工具
前言
當基金透過好幾間銀行購入時,要定期審視時總是要開啟各銀行 app 才可以達成,身為軟體開發者絕對不允許這種事情發生,因為我們是很追求極致懶的人,所以這次的構想是要建立一個個人基金管理工具,能夠取得當日最新價格並即時運算損益,最重要本專案是非營利自用的工具,因此要使用最精簡最快速的方式達成,所以能免費就用免費的,能用線上服務的就用線上服務。
相關技術規劃如下:
next.js
- 網站主體
- 提供基金清單 API (使用 cheerio 爬公開資訊)
- 提供基金現價 API (使用 cheerio 爬公開資訊)
firebase
- 全站用戶管理 (Email/Password)
- 存放用戶基金 (Realtime Database)
- 僅授權用戶存取個人 uid 轄下資料
deployment
- Vercel (next.js 最佳佈署環境)
資料取得策略
要完成這個工具最重要的事情就是要取得當日的基金參考淨值,平常可能是開啟瀏覽器去「查詢該基金」並在基金頁面上「查看淨值」資料,如果以系統的角度這些事情都是要自動化的,所以初步想法如下:
- 取得基金清單
各大理財網站都有提供基金查詢功能,並且在查詢後提供連結導向該基金的專屬頁面,接著頁面上就會顯示淨值等資訊,而我們可以觀察一下查詢時該網站提供的 API 為何,在我們的工具網站中可經由此 API 列出各基金名稱,並進而得知該基金於理財網站中被定義的 ID 值,後續當選定該基金後所需要導向的基金專屬頁面 URL 也必定包含此 ID 參數。
- 取得基金當日淨值
前一步驟取得指定基金的專屬頁面後,我們需要的所有資料都存在該頁面中,接著透過 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 走走。
希望此篇文章可以幫助到需要的人
若內容有誤或有其他建議請不吝留言給筆者喔 !