Files
pokemon-radar-api/docs/INFRA.md
Timmy ce9a944b45 docs: 更新 README 與 INFRA.md 記錄 generated_at、SWR 重試、updated badge
- README: /scan response 示例補 generated_at 欄位與說明
- INFRA.md:
  - 演進紀錄新增 3.12(前端空資料 auto-retry)、3.13(UI 顯示資料更新時間、後端加 generated_at)
  - 3.12 記錄 webhook pipeline 第一次真實使用(由前端 auto-retry 的 push 觸發)
  - follow-up 追加 pokemon-radar-api 後端自動部署的想法

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:21:02 +08:00

16 KiB
Raw Blame History

基礎設施地圖與 2026-04-20 演進紀錄

這份文件的受眾是一年後忘光的你自己。涵蓋:

  1. 目前整套 pokemon 家族的架構現況(哪台機跑什麼、流量怎麼走)
  2. 今天2026-04-20做了什麼、為什麼這麼做
  3. 日常操作 cookbook
  4. 已知限制與 follow-up

單一專案的使用手冊請看各 repo 自己的 README.md。本檔是跨 repo / 跨機器的全景圖


1. 架構現況

使用者瀏覽器
   │
   │ https://web.lotimmy.com/pokemon/
   ▼
NPM (192.168.42.124:443, LE 憑證)
   │ 反代
   ▼
caddy (192.168.42.104:80)  serve  /opt/caddy/www/pokemon/
   │ index.html + img/ 等靜態檔
   ▼
瀏覽器 JS  fetch  https://poke-api.lotimmy.com/scan
   │
   │ NPM (192.168.42.124:443, LE 憑證) 反代
   ▼
pokemon_radar_api (192.168.42.108:8008, supervisor 管)
   │   stale-while-revalidate
   │   - /scan 永遠秒回 Redis 當下資料
   │   - 若 TTL < STALE_THRESHOLD 或 key 不存在 → 背景 thread 呼叫 radar_fetcher.main()
   │     互斥 lock key: pokemon:radar:fetching (180s TTL)
   ▼
Redis (192.168.42.108:127.0.0.1:6379, Docker redis:7-alpine)
   │   key: pokemon:radar:latest (TTL 300s)
   ▲
   │ 寫入
radar_fetcher (192.168.42.108:8008 API 背景 thread 觸發)
   │
   │ POST code → twpk_radar.mjs
   ▼
Function Server (192.168.42.124:13000)
   │ Puppeteer + stealth + state.json cookies
   ▼
twpkinfo.com/ipoke.aspx

另一條:自動部署 pipeline

本機 git push → Gitea (192.168.42.124:31337)
                    │ POST /hooks/<name> + HMAC-SHA256 (X-Gitea-Signature)
                    ▼
       git-webhook (192.168.42.104:9000, Docker)
                    │ 動態讀 hooks/<name>/{secret,deploy.sh}
                    │ 簽章驗證通過 → subprocess deploy.sh
                    ▼
       git clone → rsync 到該 repo 配置的 target dir
                    │
                    ▼
       caddy (或其他 serve) 自動提供新版(無需 reload

2. 機器與端點速查

機器 功能 關鍵 port / 路徑 服務管理
192.168.42.108 (CT108) pokemon_radar_api + 本機 Redis + 其他 8 個 uvicorn service :8008 API、:127.0.0.1:6379 Redis supervisor (/etc/supervisor/conf.d/pokemon-radar-api.conf)
192.168.42.104 (CT104) caddy 靜態檔 + git-webhook 自動部署 :80/:443 caddy、:9000 webhook caddy 手動啟動、webhook 用 Docker Compose
192.168.42.124 Gitea + NPM + Function Serverbrowser automation+ Pi-hole 等 :31337 Gitea、:81 NPM UI、:13000 Function Server Docker Compose多個
192.168.42.211 已死 — 舊的生產 Redis網路不通

外部 domain

Domain A record 服務
web.lotimmy.com NPM public IP 前端地圖caddy 上)
poke-api.lotimmy.com 125.229.110.50NPM public 後端 API
<service>.lotimmy.com / <service>.timmy.us.kg / <service>.timmy.edu.kg 同上 NPM 管理的其他 30+ 服務

Git Repos

Repo 遠端 角色
pokemon-radar-api http://192.168.42.124:31337/timmy/pokemon-radar-api.git (master) 後端 API + fetcher
pokemon http://192.168.42.124:31337/timmy/pokemon.git (main) 前端 Leaflet 地圖

兩 repo 都是公開 Gitea repo內網sensitive 的 .env / state.json.gitignore 內不上傳。


3. 今天做了什麼2026-04-20

按時序與因果。中間發現不少 legacy 問題,列出來避免未來重蹈。

3.1 pokemon-radar-api 本機環境整理

  • 發現 repo 根目錄有 5 個 *_BACKUP_<timestamp> 手動備份檔,已清除
  • /scan:舊版 get_data_from_redis() 只 catch json.JSONDecodeErrorRedis 連不上時會直接 500。改成回 (data, ttl, err) tuple、scan()err 區分 redis/redis (empty)/redis (unreachable)所有情況都回 200
  • radar_fetcher._postprocess 與舊 CLI 的 PokemonRadarClient.postprocess 篩選邏輯重複實作,目前維持(改動 scope 太大)

3.2 建立 Gitea repo + 推送

  • 原本 repo 是 git init 但從未 push 過
  • 新建 Gitea timmy/pokemon-radar-apipublic
  • 關鍵:第一次 push 前先補 .gitignore.env / state.json / runtime 輸出,否則會連 FUNCTION_TOKEN 和 cookies 一起推上去
  • Push 用 git -c http.extraHeader="Authorization: token ..." 方式token 不存 .git/config

3.3 移除 hard-coded token

  • run_pokemon_radar_api.shpokemon_location_fetcher.py 裡都寫死 FUNCTION_TOKEN=6R0W53R135510
  • 改成從 env 讀(os.getenv("FUNCTION_TOKEN", "")),並在 pokemon_location_fetcher.pyload_dotenv()
  • pyproject.toml 補宣告 python-dotenv(專案已在用但未列 dep
  • 注意git 歷史第一個 commit f7ef105 仍含 token因 repo 是 public 且已 push。要徹底清需 git filter-repo,目前暫不做

3.4 實作 stale-while-revalidate (v2.1)

  • 問題:定期 cron 跑 fetcher → 沒人用時也一直打 Function Server + twpkinfo.com會被標記
  • 解法:/scan 永遠秒回當下 Redis 資料;若 TTL < STALE_THRESHOLD(預設 120s或 key 不存在,用 threading.Thread 背景呼叫 radar_fetcher.main() 非同步更新
  • 互斥 lockRedis SET NX EX on key pokemon:radar:fetchingTTL 180s避免並發
  • cron 可以完全停掉(之前的 fetch_and_upload.sh / radar_fetcher.py 排程在生產 crontab 裡都已註解)
  • 新增環境變數:STALE_THRESHOLDFETCH_LOCK_KEYFETCH_LOCK_TTL

3.5 前端 repo 改走 APIMixed Content 坑)

  • 使用者要前端直接打 API不走舊的 scp spots.json → caddy 流程
  • 第一次改:API_URL = http://192.168.42.108:8008/scan:使用者一開始說 88.108subagent 照做後使用者更正為 42.108
  • 第二次改:API_URL = http://192.168.42.108:8008/scan(對的 IP
  • 頁面是 https://web.lotimmy.com/pokemon/,打 HTTP API 是 Mixed Content瀏覽器擋
  • 第三次改:API_URL = https://poke-api.lotimmy.com/scan(走 NPM 反代)

3.6 生產機 42.108 部署(發現多層問題)

  • 發現 1:生產機 /opt/pokemon-radar-api 根本不是健康 git repo.git 存在但 master 0 commits是手工 scp 上去的 Dec 8 舊版)
  • 發現 2:舊版 API 還沒有 /scan fix連不到 Redis 直接 500
  • 發現 3:生產 .env REDIS_HOST=192.168.42.211 已經連不到(該機器不存在了)
  • 做法:
    1. sudo tar 備份整個目錄到 /root/pokemon-radar-api-backup-<ts>.tar.gz
    2. .env / state.json 搬到 /tmp
    3. rm -rf /opt/pokemon-radar-api + 從 Gitea git clone
    4. 還原 .env / state.json
    5. uv sync
    6. supervisorctl restart pokemon-radar-api
  • 驗證:/scan 回 200source=redis (unreachable) — Redis 死的事實被誠實暴露

3.7 在 42.108 跑本機 Redis

  • docker run -d --name pokemon-radar-redis --restart unless-stopped -p 127.0.0.1:6379:6379 redis:7-alpine綁 loopback only,不對外暴露)
  • .envREDIS_HOST=127.0.0.1REDIS_PASSWORD= 留空
  • supervisorctl restart pokemon-radar-api
  • 第一次 /scansource=redis (empty) + 背景 fetcher 觸發lock 有值)
  • 背景 fetcher 完成51 筆進 Redis、TTL 299s
  • 第二次 /scansource=redis、12ms 回應、有資料

3.8 NPM 反代 HTTPS解 Mixed Content

  • DNS: poke-api.lotimmy.com A record → NPM public IP (125.229.110.50),僅 DNS 不走 CF proxyLE HTTP challenge 需要直達)
  • NPM API (http://192.168.42.124:81/api/)
    1. POST /api/tokensadmin@example.tw + password → JWT
    2. POST /api/nginx/proxy-hosts 建 proxy hostcertificate_id: "new" 一併申請 LE cert
  • 建好 proxy_host id=56 / cert id=84
  • curl https://poke-api.lotimmy.com/ping → 200

3.9 前端部署到 caddyPhase 1

  • caddy /opt/caddy/www/pokemon/ 目錄 perm 777、timmy 可直接寫(但本身嵌在 /opt/caddy/.git 的 repo 結構裡,所以不能單獨 git init 變新 repo
  • scp index.html 手動覆蓋一次,讓 prod 立即有新版
  • 同時為 Phase 2 的 webhook 自動化鋪路

3.10 Webhook-driven 自動部署Phase 2

先做版(單 repo 寫死 pokemon

  • 在 42.104 建 /opt/git-webhook/
    • server.pyPython stdlib http.server50 行HMAC-SHA256 驗證)
    • deploy.shgit clone depth=1 → rsync --exclude=.git 到 /opt/caddy/www/pokemon/
    • Dockerfilepython:3.12-alpine + apk add git rsync
    • docker-compose.ymlmount /opt/caddy/www/pokemon
  • Gitea API POST /api/v1/repos/timmy/pokemon/hooks 建 webhookevents=push, branch_filter=main, 帶 secret

卡關Gitea test webhook 204 但 container 沒收到。查 Gitea log 發現:

Unable to deliver webhook task[N]: ...
webhook can only call allowed HTTP servers
(check your webhook.ALLOWED_HOST_LIST setting),
deny '192.168.42.104:9000'

Gitea 預設 SSRF 保護擋所有 RFC1918 內網 IP。

解法:在 /opt/gitea/data/gitea/conf/app.ini 加:

[webhook]
ALLOWED_HOST_LIST = external,192.168.42.0/24,loopback

sudo docker restart gitea。修好後 webhook 立刻通。

3.11 Webhook 動態化 + helper

單 repo 寫死會限制未來。改成動態 hook loader

  • server.pyPOST /hooks/<name> → 從 /app/hooks/<name>/secret 讀該 hook 的 secret、/app/hooks/<name>/deploy.sh 跑部署
  • server.pyhooks/ 都是 volume mount不用 rebuild image 就可新增)
  • /opt/caddy/www 整個 mount 進 containercontainer 能寫任何子目錄
  • 新增 helper /opt/git-webhook/add-deploy.sh <owner> <repo> <target-dir> <token>
    1. 生 random secret 寫到 hooks/<repo>/secret
    2. hooks/<repo>/deploy.shgit clone + rsync
    3. 呼叫 Gitea API 建 webhook
    4. 觸發 test 驗證

舊 global secret 從 .env 遷到 hooks/pokemon/secret(沿用原值保持 Gitea 端不用改)。

3.12 前端 SWR 冷啟動 UX空資料自動重試

問題Redis 快取過期時,第一次 /scansource: "redis (empty)",要等 ~30-60 秒 fetcher 完成才有資料。使用者第一次開頁就撞上,看到紅框誤以為壞了。

前端 loadScan() 改成:

  • 空資料不走紅框錯誤分支,改顯示中性灰框 banner「正在更新資料請稍候…N/10
  • 每 8 秒 setTimeout 遞迴重試,上限 10 次80 秒)
  • 拿到資料自動清 timer + 隱藏 banner
  • 超時顯示中性警告「後端仍無資料,請稍後重新整理」
  • 真正網路 / HTTP / JSON 錯誤 → 維持原本紅框

這次的 push 也是 webhook pipeline 第一次真實使用push → Gitea 觸發 webhook → 42.104 自動 rsync → caddy 下次 request 即新版。之前都是用 Gitea API 的 test endpoint 模擬。

3.13 UI 顯示資料更新時間

使用者想看到「這份資料什麼時候抓的」:

  • 後端 /scan response 新增 generated_at 欄位ISO 8601台北時區從 Redis 存的 fetcher payload 頂層讀出
  • ScanResponse model 補 Optional[str] 對應
  • 前端右下角 <div id="updated-badge"> 半透明深色 banner文字格式「資料更新於 HH:MM:SSN 分鐘前)」
    • 相對時間:<30s 剛剛 / <60m 分鐘前 / <24h 小時前 / 否則日期
    • setInterval 每 10 秒重算顯示(不重 fetch
    • 只在成功拿到有 items 的 response 時更新,空/錯誤分支保留上次值

後端變更需手動部署到 42.108git pull && uv sync && supervisorctl restart。前端透過 webhook 自動部署。


4. 日常操作 Cookbook

新增一個 repo 自動部署

前提repo 已在 Gitea且有 main branch。

ssh 192.168.42.104 'sudo /opt/git-webhook/add-deploy.sh \
  <owner> <repo> <target-dir> <GITEA_TOKEN>'

範例(把 timmy/blog 自動部署到 caddy

ssh 192.168.42.104 'sudo /opt/git-webhook/add-deploy.sh \
  timmy blog /opt/caddy/www/blog 737e5eb08811...'

完成後push 該 repo main → 自動部署。

查 webhook deploy log

ssh 192.168.42.104 'sudo docker logs git-webhook --tail 50'

手動重新部署

ssh 192.168.42.104 'sudo /opt/git-webhook/hooks/<repo>/deploy.sh'

暫停自動部署

ssh 192.168.42.104 'sudo docker stop git-webhook'   # 停掉 webhook receiver
# 或
# 在 Gitea UI (http://192.168.42.124:31337/<owner>/<repo>/settings/hooks) disable

API 端42.108)操作

# 重啟
ssh 192.168.42.108 'sudo supervisorctl restart pokemon-radar-api'

# 查 log
ssh 192.168.42.108 'sudo tail -f /var/log/supervisor/pokemon-radar-api.log'

# 看 Redis 內容
ssh 192.168.42.108 'sudo docker exec pokemon-radar-redis redis-cli GET pokemon:radar:latest' | head -100

# 手動觸發 fetcher
ssh 192.168.42.108 'cd /opt/pokemon-radar-api && sudo /root/.local/bin/uv run python radar_fetcher.py'

更新生產 API 程式碼

後續 pokemon-radar-api 的 commit push 到 Gitea 後,生產機還要手動 pull + restart

ssh 192.168.42.108 'cd /opt/pokemon-radar-api && sudo git pull && sudo /root/.local/bin/uv sync && sudo supervisorctl restart pokemon-radar-api'

(若要把 API 端也做成 webhook 自動部署需要設計一個能「git pull + uv sync + supervisorctl restart」的 deploy.sh並讓 webhook container 有權限 ssh 或 supervisorctl — 比前端複雜。先保持手動)

新增 NPM 反代 + LE cert

# 1. DNS: 在 Cloudflare 加 <service>.lotimmy.com A record → 125.229.110.50(僅 DNS不走 CF proxy
# 2. NPM API 建 proxy host或到 http://192.168.42.124:81/ UI 按按鈕)
TOKEN=$(curl -sS -X POST http://192.168.42.124:81/api/tokens \
  -H "Content-Type: application/json" \
  -d '{"identity":"admin@example.tw","secret":"<password>"}' \
  | python3 -c 'import sys,json;print(json.load(sys.stdin)["token"])')

curl -sS -X POST http://192.168.42.124:81/api/nginx/proxy-hosts \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "domain_names": ["<service>.lotimmy.com"],
    "forward_scheme": "http", "forward_host": "192.168.42.X", "forward_port": NNNN,
    "certificate_id": "new",
    "ssl_forced": true, "http2_support": true, "block_exploits": true,
    "meta": {"letsencrypt_agree": true, "letsencrypt_email": "admin@example.tw", "dns_challenge": false},
    "locations": []
  }'

5. 已知限制與 follow-up

必看

  • state.json 是整條 pipeline 最脆弱的點:過期時 Function Server 返回 200 但 results 空或登入頁pipeline 靜默吐 0 筆。目前 state 是 2025-11-04 的,仍有效但總有一天會壞。重登 twpkinfo.com 匯出新 state 即可
  • 42.108 supervisor conf 裡仍硬寫 POKER_TOKEN="6R0W53R135510"/etc/supervisor/conf.d/pokemon-radar-api.confenvironment= 那行),程式碼已不讀這個變數,但機器上 token 還在。非緊急,可空檔時清掉
  • 第一個 commit f7ef105 含歷史 tokenpublic repo 歷史檔仍含當時 FUNCTION_TOKEN=6R0W53R135510,要徹底清需 git filter-repo + force push。目前暫不處理

小疵

  • .env.bak.<ts> 備份檔留在 /opt/pokemon-radar-api//opt/gitea/data/gitea/conf/,確認系統穩定後可刪
  • 兩個 fetcherradar_fetcher.py + pokemon_location_fetcher.py)篩選邏輯重複實作,合併到共用 module 是理想做法(但兩條 pipeline 短期共存,現在不急)
  • 前端 API_URL 仍 hardcode 在 index.html,未來要做 dev/prod 切換可抽 config.js 或用 window.location.hostname 判斷
  • 前端空 items 用紅色 showLoadError 呈現,嚴格說這不是錯誤而是 SWR 暫態,可改中性 banner + auto retry
  • pokemon-radar-api 沒做 webhook 自動部署(還是手動 git pull + restart若希望一致化可仿 add-deploy.sh 但加上 supervisorctl restart 步驟

沒做但值得之後做

  • 監控webhook deploy 失敗時沒通知、API 500 時沒通知;可考慮 ntfy / Discord webhook 串起來
  • Redis 在 42.108 Docker 裡,沒設 persistence volumerestart container 資料丟掉但 SWR 會自動重建,實務上無感。若以後需要 persist-v redis-data:/data
  • add-deploy.sh 需要 Gitea token 當 arg使用上每次要貼。可在 42.104 寫個 /opt/git-webhook/.envGITEA_TOKENhelper 自動 source
  • pokemon-radar-api 後端還是手動 git pull + restart。仿 add-deploy.sh 做一個帶 supervisorctl restart 的 deploy.sh 即可(需要 webhook container 能 ssh 進 host 執行 supervisorctl或改用 host-level daemon