主題延續前面專案:LINE Bot 金融助理)。文末有參考來源連結
⸻
Day 15|讓資料來源更「韌性」:黃金、匯率與台股 ETF(含英字尾)的穩定查詢策略
昨天把主要功能串起來後,今天專注在「三個常卡點」的韌性與穩定性:
- 台銀黃金牌價爬不到或欄位變動
- 台股 ETF(例如 00937B)常被誤判或抓不到價
- 匯率/金融來源偶爾 401、404 或節流(rate limit)
這篇整理我最後採用的「多來源回退(fallback)+更嚴謹的代碼正規化+阻塞 I/O 與非同步橋接」做法,並附上可直接用的程式片段。
⸻
01|黃金牌價:以「表頭判位」+ 多來源回退
為什麼先抓台銀
台銀頁面是日常查價很常用的參考來源,但偶而會調整表格欄位或 class。與其綁死 CSS 選擇器,不如「掃所有表格→用表頭文字定位『賣出』欄」,成功率更高。頁面在這裡:台銀黃金走勢/資訊(同站下的金價內容頁,實務上會同步更新) 
程式片段(BeautifulSoup,表頭判位)
import requests
from bs4 import BeautifulSoup
def get_bot_gold_quote():
url = "https://rate.bot.com.tw/gold?Lang=zh-TW"
headers = {"User-Agent": "Mozilla/5.0"}
r = requests.get(url, headers=headers, timeout=10)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
tables = soup.find_all("table")
for tb in tables:
# 取表頭文字
ths = [th.get_text(strip=True) for th in tb.select("thead th")]
if not ths:
# 有些頁面只有 tbody,改從第一列取表頭
first = tb.find("tr")
ths = [t.get_text(strip=True) for t in first.find_all(["th", "td"])] if first else []
if not ths:
continue
# 嘗試找「賣出」欄位索引
try:
sell_idx = next(i for i, t in enumerate(ths) if "賣出" in t)
except StopIteration:
continue
# 從內容列抓第一筆黃金賣出價
for tr in tb.find_all("tr")[1:]:
tds = [td.get_text(strip=True) for td in tr.find_all("td")]
if len(tds) > sell_idx:
price = tds[sell_idx]
if price and price.replace(".", "", 1).isdigit():
return float(price)
raise RuntimeError("找不到含『賣出』欄位的表格")
Fallback:改抓國際黃金(Yahoo Finance)
若台銀頁面臨時改版或擋爬,就回退抓「國際現貨金/期金」。我的順序是:
• 先試 現貨金 XAUUSD=X
• 失敗再試 COMEX 期金 GC=F(兩者都能在 Yahoo Finance 查到對應商品頁) 
註:yfinance 並非官方 API,實務上會遇到節流或封鎖;多做重試與替代資料源很重要。 
import yfinance as yf
def xauusd_fallback():
for symbol in ("XAUUSD=X", "GC=F"):
try:
tkr = yf.Ticker(symbol)
hist = tkr.history(period="1d")
# 取收盤價(若是當日即時盤,yfinance 會給出最新一筆)
close = float(hist["Close"].iloc[-1])
return close
except Exception:
continue
raise RuntimeError("XAUUSD/GC=F 抓不到價格")
⸻
02|台股 ETF(含英字尾代碼)的正規化與查價
有些台灣 ETF 代碼最後帶英文字母(例如 00937B)。若你只做「純數字→補 .TW」,就會漏掉 B;解法是 允許「4~6位數 + 可選英文字尾」,統一轉大寫再補 .TW:
import re
def normalize_tw_symbol(user_input: str) -> str:
s = user_input.strip().upper()
# 允許 4~6位數 + 可選 1字母(例:00937B)
if re.fullmatch(r"\d{4,6}[A-Z]?", s):
return f"{s}.TW"
return s
若你使用 yfinance 的 download()/history(),遇到 possibly delisted; no timezone found 這類錯誤,多半是資料本身或區段有問題,或 Yahoo 端暫時不回應。此錯誤在社群討論也很常見,結論是:加上重試與替代路徑(例如改抓 quote 類即時快照,或乾脆只回報「成交量/新聞摘錄」)會比較穩。 
⸻
03|匯率來源(免金鑰):open.er-api
匯率這塊我最後選擇 open.er-api 的 latest 端點,像這樣:
https://open.er-api.com/v6/latest/JPY → 取回 JSON,從 rates.TWD 取得「1 JPY 換多少 TWD」。官方回應格式與 result: "success" 的語義在文件有清楚說明。 
import requests
def get_twd_per_jpy() -> float:
r = requests.get("https://open.er-api.com/v6/latest/JPY", timeout=10)
r.raise_for_status()
data = r.json()
if data.get("result") != "success":
raise RuntimeError("FX API 失敗")
return float(data["rates"]["TWD"])
(若要多來源備援,可再串 exchangerate.host 的 latest 端點,格式非常直觀。) 
04|阻塞 I/O 與 FastAPI 的橋接
LINE Webhook 進來後,我會把 阻塞性工作(requests、yfinance) 丟到 threadpool:
await run_in_threadpool(your_blocking_func, args...)
這是 FastAPI/Starlette 官方建議的做法,能避免把 event loop 卡死。 
from fastapi.concurrency import run_in_threadpool
@router.post("/callback")
async def callback(request: Request):
body = await request.body()
# ...驗簽略...
await run_in_threadpool(handler.handle, body.decode("utf-8"), signature)
⸻
05|Webhook 自動設定(部署時一次到位)
我在 lifespan 中開機自動呼叫 設定 Webhook 的 API,把 BASE_URL/callback 註冊上去。這個端點在官方 SDK 文件裡面也有對應方法(set_webhook_endpoint_url),原理就是呼叫 /v2/bot/channel/webhook/endpoint。
⸻
06|整合:對話流程裡的多層回退
以「金價」為例,我的處理順序與回覆策略:
1. 先抓台銀賣出價(表頭判位)→ 成功就用它
2. 失敗 → 換抓 Yahoo 現貨金 XAUUSD=X;再失敗 → GC=F
3. 仍失敗 → 明確回覆「來源暫時無法取得」,同時寫 log 便於追蹤
4. 產出文案時,僅就「可取得的來源」做簡短分析,不瞎掰
為什麼要保留多來源?因為 Yahoo 端並非正式 API,社群實測指出會有節流與封鎖情形,務必設計 retry 與備援。 
⸻
07|常見坑位清單與排障提示
• ETF 英字尾沒補 .TW:用正規化 \d{4,6}[A-Z]?,記得 upper()。
• yfinance 報 possibly delisted / no timezone:加重試、縮短期間、換商品、或改抓快照;需要時切回官方/付費 API。 
• Webhook 沒生效:部署時程式內自動設定端點,或用官方工具檢查頻寬/錯誤。
• 阻塞 I/O 造成延遲:把 requests/yfinance 丟 run_in_threadpool。 
⸻
08|今天的 Commit 摘要(重點段)
• 新增 get_bot_gold_quote()(表頭判位)+ xauusd_fallback()
• normalize_tw_symbol() 支援 00937B 等英字尾
• 金價/匯率/股票皆加上 retry 與訊息級別的 fallback
• Webhook 開機自動註冊
• 阻塞 I/O 全面走 run_in_threadpool
⸻
明天我會把「自選股提醒」與「群組控制(@Bot 才回)」再整理一次,把錯誤訊息的語氣統一,並補上更多實戰測試範例。
⸻
參考資料
• open.er-api(免金鑰匯率 API)— latest 端點格式與欄位說明。 
• Yahoo Finance:GC=F(Comex Gold Futures)與 XAUUSD=X(現貨金)商品頁。 
• yfinance 不是官方 API、可能被節流/封鎖(說明文)。 
• yfinance「no timezone found / possibly delisted」問題討論。 
• FastAPI/Starlette:在協程中執行阻塞 I/O 的官方建議(run_in_threadpool)。 
• LINE 官方 SDK:設定 Webhook 端點方法(對應 /v2/bot/channel/webhook/endpoint)。
⸻