iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0
AI & Data

雲端情人 - AI 愛系列 第 15

Day 15讓女助理更有查資訊更「彈性」查詢-黃金、匯率與台股 ETF(含英字尾)的穩定多資料查詢策略

  • 分享至 

  • xImage
  •  

主題延續前面專案:LINE Bot 金融助理)。文末有參考來源連結

Day 15|讓資料來源更「韌性」:黃金、匯率與台股 ETF(含英字尾)的穩定查詢策略

昨天把主要功能串起來後,今天專注在「三個常卡點」的韌性與穩定性:

  1. 台銀黃金牌價爬不到或欄位變動
  2. 台股 ETF(例如 00937B)常被誤判或抓不到價
  3. 匯率/金融來源偶爾 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)。

https://ithelp.ithome.com.tw/upload/images/20250908/20112100K8wg5uR12V.png


上一篇
Day14 女朋友功能太多玩壞掉了 :把查股與翻譯「修到會飛」—0050、00937B股市、多國語翻譯直通一次解
下一篇
Day 16|讓 LINE 金融助理系統優化:更抗風險:重試、退避、快取、斷路器與可觀測性——包含設計思路、精簡可用的程式片段與參考資料
系列文
雲端情人 - AI 愛21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言