iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0

我的系統每天要知道二十幾檔美股和台股的價格:晨報要用、持倉損益要算、異動提醒要比對。付費行情 API 一個月最少十幾美金起跳——這筆固定支出比我整個 LLM 的用量還貴,而它買到的東西(幾十檔股票的日收盤價)其實免費管道就有。

所以我走了免費仔的路:Yahoo Finance 的公開端點。免費的代價很快就來了——某天凌晨的快取更新 job 連續失敗,log 裡整排 429 Too Many Requests。我像個貪心的爬蟲一樣,把二十幾檔股票的請求同時射出去,然後被暫時擋在門外。

免費行情 API 的宿命就是這句話:**抓太兇被擋,抓太慢資料過期。**今天講我怎麼在這兩端之間找到平衡。

為什麼要快取:一次抓、多次用

這件事的起點其實不是「架構潔癖」,是帳單。我的投資這一塊,對話和批次任務特別多——晨報、持倉損益、選股分析、每次我在 Telegram 隨口問「MU 現在多少」,全都要股價。早期的天真版本是「誰要價格誰自己打 API」:晨報 job 打一輪、損益同步 job 再打一輪、我問一句又打一次。同一天同樣的數據被抓了三、四輪,被 ban 的風險乘以四,而且每個使用方各自處理錯誤,程式碼重複到可笑——更別說如果走付費行情,這種重複抓法是照次數在燒錢

於是我把它反過來:不再是「每個需求各自去抓」,而是一個排程把價格抓進一份底層快取,所有需求都只讀那份檔案。抓取的次數從「每個消費者各打一輪」壓成「每天固定幾次」,費用與被 ban 風險同時降下來。至於這「幾次」該抓在什麼時候,留到後面〈抓取時機〉一節再談——先記住一個原則:新鮮度需求決定抓取頻率,不是反過來。

改成快取架構後,全世界只有一個地方碰外部 API:

https://ithelp.ithome.com.tw/upload/images/20260815/20182865Xd6Jlzv7D0.png

規則很簡單:抓的人只負責抓,用的人只准讀快取。快取是一份 JSON 檔,放在 Workspace 裡,帶著每檔股票的價格、漲跌幅,以及一個台北時間的時間戳。所有下游——晨報、持倉損益、觸發告警——都只看這份檔案,不各自去打外部 API。它的新鮮度是日線級的(最近一次排程抓到的),對我這種非當沖的個人投資決策完全夠用;我不做分鐘級即時,所以也沒讓誰為了「即時」去繞過快取現抓。

這個取捨值得說清楚:我不做分鐘級行情,因為我不當沖。先定義你需要的資料新鮮度,再決定抓取頻率——很多人反過來,先拚命抓,再想資料要幹嘛。

限速抓取的核心邏輯

快取更新腳本是一支 Node.js 程式,核心就三件事:限併發、加延遲、留退路。偽碼長這樣:

const CONCURRENCY = 3;      // 同時最多 3 個請求
const DELAY_MS = 300;       // 每個請求之間隔 300ms
const MAX_RETRY = 2;        // 失敗最多重試 2 次,指數退避

// 關鍵:抓取函式「自己把錯誤吞掉」,永遠不 throw——
// 失敗就回「舊值 + stale」或 null,所以整批用 Promise.all 也不會被單檔炸掉
async function fetchOne(sym) {
  try {
    return await fetchWithRetry(sym, MAX_RETRY);   // 成功 → 新報價
  } catch (e) {
    return oldCache[sym]
      ? { ...oldCache[sym], stale: true }          // 有舊值 → 沿用並標記 stale
      : null;                                      // 連舊值都沒有 → 回 null
  }
}

async function updateCache(symbols) {
  const results = {};
  // 把股票清單切成大小為 3 的批次
  for (const batch of chunk(symbols, CONCURRENCY)) {
    // 每檔各自 catch,Promise.all 不會因為單一檔失敗而整批 reject
    const quotes = await Promise.all(batch.map(fetchOne));
    batch.forEach((sym, i) => { if (quotes[i]) results[sym] = quotes[i]; });
    await sleep(DELAY_MS);   // 批次之間喘口氣
  }
  results._meta = { timestamp_taipei: nowTaipei() };
  writeJson('cache/market/stocks_latest.json', results);
}

幾個數字的由來:

併發 3、間隔 300ms 不是什麼精算出來的科學公式。被 429 教育之後,我沒有慢慢去試「到底幾條併發才安全」——直接照「禮貌爬蟲」的常識壓到保守值:同時最多 3 個請求、每批之間停 300ms。二十幾檔股票跑完大約 2–3 秒,對一個凌晨排程來說慢這幾秒毫無代價,被 ban 一天的代價卻是整天的報告全瞎。免費 API 的第一守則:你不是它的客戶,別把它當 SLA 用;與其去試它的底線,不如一開始就客氣。

失敗沿用舊值 + stale 標記是這支腳本最重要的設計。單檔抓取失敗不該讓整份快取開天窗——舊價格加上「這是舊的」標記,遠比空值好。下游看到 stale 旗標可以自己決定要不要警示(這件事在 Day 18 會變成主角)。

每一檔自己 catch,整批才敢用 Promise.all:批內三檔同時射出去,只要每一檔的抓取函式內部自己把例外吞掉、回傳 null 或舊值,整批就不會因為其中一檔 timeout 而集體 reject。我一開始偷懶,讓單檔的例外直接往上冒,結果一檔 timeout 就讓整批重來,反而加倍觸發限流——後來把 try/catch 收進每一檔內部,Promise.all 就穩了。效果跟 Promise.allSettled 一樣(一檔壞不影響其他檔),只是我選擇把隔離做在更裡層的抓取函式,而不是外層的批次收斂。

抓取時機:跟著市場節奏走

先誠實講一件事:目前我只有一個免費源(Yahoo 那個端點),還沒做備援源——單源抽風時,靠的是上一節那套「保留舊值 + 標 stale」硬撐,不是切換到第二家。備援源在待辦上,但個人規模還沒到非做不可。不過單源也有它的坑:同一個回應裡「前收盤」和「即時價」是不同欄位,取錯欄意思就差很多,所以我在正規化層明確選欄、寧可寫死也不含糊(這條跟明天 Day 18 的毒數據是同一個病:欄位語意不能想當然)。

抓取的時機不是「每天固定挑個時間」,而是跟著市場的節奏走。以美股一個交易日為軸,快取在三個關鍵點各刷新一次(台北時間):

時機 對應 抓什麼
21:00 開盤 美股開盤前後 開盤第一批報價;同一支 job 順手把台股收盤價(13:30 已收)帶進來
01:00 盤中 美股盤中 盤中更新,讓凌晨的觸發告警看到當日走勢
04:00 盤後 美股收盤 抓當日收盤價,餵當天早上的晨報 pipeline(Day 17)

一支 job 同時抓美股和台股——台股 13:30 收盤早於美股 21:00 開盤,所以台股的當日收盤價在第一次刷新就進了快取。關鍵是沒有行情變動的深夜不抓:沒有新價格的時段抓再多次,也只是重複燒配額、多冒一次被 ban 的險。這正是上一節「降低費用」的具體落地——抓取頻率對著新鮮度需求,不對著焦慮。

我踩過的坑

**坑一:重試邏輯放大災難。**早期版本失敗就立刻重試、最多五次。遇到 429 的時候,這等於「被擋 → 更用力敲門」,把暫時限流敲成長時間封鎖。

修正的方向是把「所有錯誤一視同仁」改成「按錯誤類型分流」,現在的規則是這樣:

錯誤 處理 理由
429 限流 重試,但等伺服器叫我等的時間 回應裡的 Retry-After 標頭就是對方明講「幾秒後再來」——照做比自己猜聰明
5xx 伺服器錯 重試,指數退避(2 秒、4 秒、8 秒) 對方暫時壞掉,等一下多半會好
404 / 解析失敗 立刻放棄,不重試 這種錯再試一百次也是同樣結果,重試只是浪費

上限三次,三次不成就回 null,讓上層去走「保留舊值」那條路(Day 18 會講為什麼舊值比空值好)。

我特別想強調 429 那條。早期我以為限流就該「直接放棄、等下個排程」,聽起來很禮貌——但那等於白白放棄一整輪資料,而對方其實已經明確告訴你「等 3 秒就好」。很多 API 的錯誤回應裡帶著解法,只是我們習慣只看狀態碼。

還有一個容易寫錯的地方:**非 2xx 的回應要在解析之前就擋掉。**我曾經讓 404 的錯誤頁面進到 JSON 解析階段——有些服務的錯誤頁剛好也是合法 JSON,解析不會噴錯,於是一份「錯誤說明」被當成行情資料寫進快取。這條跟 Day 18 是同一個病:HTTP 200 以外的東西,連看都不要看。

**坑二:時間戳沒寫時區。**快取初版的 timestamp 是裸的 ISO 字串,健檢 job 判斷「快取是否過期」時拿 UTC 去比台北時間,白白誤報了幾次「快取過期」。後來欄位直接改名叫 timestamp_taipei,把時區寫進名字裡——醜,但再也沒人搞錯。

小結+明日預告

免費行情的生存之道一句話:降低慾望(日線就好)、抓在市場節點上(不對著焦慮猛抓)、集中抓取(單一入口)、溫柔限速(3 併發 + 300ms)、按錯誤類型分流重試、永遠留退路(stale 舊值)。

快取躺好之後,數據要變成每天早上八點準時出現的晨報。明天講盤後 pipeline 的最後一哩:從 JSON 快取到 Telegram 晨報,中間三小時系統做了什麼——以及「程式算數、LLM 寫文」的分工哲學。


🔑 這篇的關鍵字
免費 API 的生存策略:降低慾望(日線 vs 即時)· 集中抓取(單一入口,抓用分離)· 限速(併發數 + 請求間隔,實驗值非公式)
重試按錯誤類型分流429 → 讀 Retry-After 標頭照做 · 5xx → 指數退避 · 404/解析錯 → 立刻放棄
非 2xx 在解析前就擋掉(錯誤頁可能也是合法 JSON)· 時間戳欄位名直接寫時區(timestamp_taipei)· 失敗回 null 讓上層走保留舊值


我是一名金融業資訊工程師,這是我半年來在家自架 AI Agent 系統的實錄。


上一篇
Day 15:一個系統,兩個排程器——後來我把大半的 AI 拿掉了
下一篇
Day 17:睡醒就有投資晨報——從快取到 Telegram 的最後一哩
系列文
生活中的 AI 應用:我在家用 NAS 養了一隻 Agent,幫我看盤、顧家、盯備考——30 天自架實錄22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言