- 取代舊 5 target 記錄(台北/內湖/板橋/新竹/宜蘭) - 完整列出大台北 11 點、新北 12 點、新竹 1 點、宜蘭 5 點共 29 個座標 - FETCH_LOCK_TTL 從 400 調整到 600(29 點序列 ~5 分鐘) - 新增實測數據:760 筆 filtered(台北 577 / 新北 713 / 宜蘭 64) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
pokemon-radar-api
把 twpkinfo.com 的寶可夢雷達資料,透過 Redis 快取成一支 FastAPI 給前端 / n8n 呼叫。
這份 README 是給一年後回來看的你自己:最常用的指令放最上面,架構與決策放最下面。CLAUDE.md 是給 AI 助手用的,可以忽略。
跨 repo / 跨機器的部署地圖(前端、API、webhook 自動部署、NPM 反代、Gitea)放在 docs/INFRA.md,回憶「當初怎麼設定的」從那篇下手。
最常用的指令
全部用 uv(Python 3.12)。設定讀 .env(不在 git 裡,本機有)。
# 起本機 Redis(一次性)
docker run -d --name pokemon-radar-redis --restart unless-stopped -p 6379:6379 redis:7-alpine
# 起 API(日常只要這一個)
./run_pokemon_radar_api.sh
# 內部執行:uv run uvicorn pokemon_radar_api:app --host 0.0.0.0 --port 8008 --log-config logging.ini
# 測 API
curl -s http://127.0.0.1:8008/ping
curl -s -X POST http://127.0.0.1:8008/scan -H "Content-Type: application/json" \
-d '{"lat":25.0478,"lng":121.5170,"min_iv":90}'
通常不用手動跑 fetcher。API 採 stale-while-revalidate:/scan 永遠秒回當前快取,若快取剩餘 TTL 低於 STALE_THRESHOLD(預設 120s)或 key 不存在,會在背景 thread 觸發 radar_fetcher.main() 更新;同時用 Redis lock(pokemon:radar:fetching,TTL 180s)避免重複觸發。沒人打 /scan 時不會動,閒置零呼叫。
需要偵錯或手動拉資料時:
uv run python radar_fetcher.py # 手動拉一次寫入 Redis
uv run python reader.py # rich 表格看目前快取
uv run python check_redis.py # raw JSON dump
生產環境 cron:./fetch_and_upload.sh — 走的是舊 pipeline(pokemon_location_fetcher.py 直接輸出本地 JSON,scp 到 192.168.42.104 的 caddy),不經 Redis,不經 API。跟這條線沒關係、請不要混淆。
沒有 test / lint / build。
.env 速查
Secrets 放 .env(gitignore)。本機與生產共用同一份格式、只改 REDIS_HOST / REDIS_PASSWORD。
| 變數 | 誰讀 | 預設 | 備註 |
|---|---|---|---|
FUNCTION_URL |
fetcher / 舊 CLI | http://192.168.42.124:13000/function |
Function Server endpoint |
FUNCTION_TOKEN |
fetcher / 舊 CLI | (必填) | 空字串 fetcher 會 raise |
SCRIPT_PATH |
fetcher | twpk_radar.mjs |
JS worker 檔名 |
STATE_PATH |
fetcher | state.json |
twpkinfo.com 登入快照,最常壞的地方 |
UA_FROM_STATE |
fetcher | true |
是否從 state 帶 UA / langs |
RADAR_LAT / RADAR_LNG / RADAR_ZOOM |
fetcher | 25.0478 / 121.5170 / 11 |
單點掃描中心(fallback,當 RADAR_TARGETS 未設才用) |
RADAR_TARGETS |
fetcher | 空 | 多點掃描,格式 name:lat,lng;name2:lat2,lng2;...;搭配 RADAR_ZOOM=15 街區級密度 |
MIN_REMAINING |
fetcher | 300 |
寫入前先做一輪粗篩,低於此值不進 Redis |
MIN_IV / PERFECT_ONLY / SPECIES |
fetcher | 空 / false / 空 |
fetcher 端篩選(通常留空,由 API 端即時篩) |
REDIS_HOST / REDIS_PORT / REDIS_DB / REDIS_PASSWORD |
全部 | 本機 127.0.0.1 / 生產 192.168.42.211 |
預設本機無密碼 |
REDIS_TTL |
fetcher | 300 |
寫入 TTL |
REDIS_KEY |
全部 | pokemon:radar:latest |
|
STALE_THRESHOLD |
API | 120 |
快取剩餘 TTL 低於此值(秒)就在背景觸發 fetcher |
FETCH_LOCK_KEY |
API | pokemon:radar:fetching |
背景刷新互斥 lock 的 Redis key |
FETCH_LOCK_TTL |
API | 180 |
lock 自動過期時間,應 >= fetcher 最長執行時間 |
HTTP API
GET /ping
200 → {"status":"ok","backend":"redis"}
POST /scan
從 Redis 讀取 → 依 body 參數即時二次篩選 → 回傳。永遠回 200(Redis 空或不可用都回空陣列)。
Body
| 欄位 | 說明 |
|---|---|
min_remaining (int) |
剩餘秒數下限 |
min_iv (int 0-100) |
IV% 下限 |
perfect_only (bool) |
只保留 100% IV |
species (str) |
逗號分隔 dex id 或名稱關鍵字,如 "133,伊布" |
lat / lng / zoom / shot / state_path / ua_from_state / save_files |
相容性欄位,Redis 模式下忽略 |
Response
{
"source": "redis" | "redis (empty)" | "redis (unreachable)",
"ttl": 281,
"count_raw": 79,
"count_filtered": 24,
"generated_at": "2026-04-20T11:14:32+08:00",
"items": [ /* id, name, gender, iv_pct, cp, level, latitude, longitude,
remaining, expire_at, fast_move, charge_move, is_perfect, ... */ ],
"debug": { "notes": [...], "redis_ttl": 281 }
}
generated_at 是 fetcher 寫入 Redis 那份資料時的時間戳(ISO 8601,台北時區)。空資料分支會是 null。
Redis schema
Key pokemon:radar:latest,String(JSON),TTL 由 REDIS_TTL 控制。Value 結構:
{ "source":"fetcher", "generated_at":"2026-04-20T09:06:36+08:00",
"count_raw":120, "count_filtered":79,
"items":[...], "debug":{ "notes":[...] } }
架構(一年後回來看用)
資料流
twpkinfo.com/ipoke.aspx
▲
│ Puppeteer (stealth + 等黑影消退 + 解析 DOM)
│
Function Server (http://192.168.42.124:13000/function)
▲
│ POST { code: <注入後的 twpk_radar.mjs> }
│
┌─────┴─────────────────────────┐
│ │
│ A) radar_fetcher.py │ B) pokemon_location_fetcher.py(舊 CLI)
│ → Redis (SETEX, 300s) │ → pokemon_data.{json,csv} + screenshot.png
│ │ → fetch_and_upload.sh 跑這條,scp 給 caddy
└─────┬─────────────────────────┘
▼
pokemon_radar_api.py :8008
POST /scan → 從 Redis 讀,依 request 即時二次篩選
非看不可的設計決策
- 為什麼要 Redis:v1 是每個
/scan都打 Function Server,一次 browser automation 要 30-60s,多人同時打直接壞。拆成 writer(fetcher)+ reader(API 讀快取)後壓力從「每 request」降到「每 TTL」。 - 為什麼 stale-while-revalidate(v2.1):原本的 cron 方案「沒人用也一直打」,會被 twpkinfo 標記、也浪費 Function Server 資源。現在改為 API 端觸發:
/scan秒回當前 Redis 快取(即使快過期),若 TTL <STALE_THRESHOLD或 key 不存在,用threading.Thread呼叫radar_fetcher.main()非同步更新;以 RedisSET NX EX做互斥 lock 避免並發。閒置時完全靜止。 - 為什麼兩條 pipeline 並存:caddy 前端走靜態 JSON(舊 CLI 的產物),沒理由為它引 Redis。所以 A 走 Redis 給 API,B 走本地檔給靜態前端,兩條都還在跑。
twpk_radar.mjs靠 regex 注入參數:Function Server 的契約是「你丟 JS 過來我跑」,所以參數化只能在字串層做。三個錨點別改形狀:const TARGET = { lat: ..., lng: ..., zoom: ... };const DO_SCREENSHOT = true|false;globalThis.__POKE_STATE__ = ...;動到形狀 regex 會靜默失敗、用錯參數跑。
pokemon_radar_api.py重用舊 CLI 的 postprocess:為了省一份篩選邏輯,API importPokemonRadarClient但傳空 url/token,只用它的postprocess方法。這是 workaround,理想做法是把篩選抽成獨立模組,兩份 fetcher 共用。- fetcher 端先做一輪粗篩再寫 Redis(
MIN_REMAINING等),API 端再做第二輪 per-request 篩選。Redis 裡不是「原始資料」而是「粗篩後資料」,如果要在 API 端篩出 fetcher 濾掉的東西是做不到的。
常見故障(依機率排序)
state.json過期 → Function Server 回 200 但 results 是空 / 登入頁 DOM,pipeline 不會炸只會吐 0 筆。radar_fetcher.py有診斷:raw_items非空但篩完為 0 時 logremaining_min / remaining_max / perfect分佈。重登 twpkinfo.com 匯出新 state。- Redis 連不上 → API 回
source:"redis (unreachable)"(不會 500)、reader.py回Timeout connecting to server。確認 docker container /.env的REDIS_HOST。 FUNCTION_TOKEN未設定 → fetcherraise RuntimeError。檢查.env。/scan回空但 Redis 其實有資料 → body 的篩選條件太嚴,或 fetcher 的MIN_REMAINING已把你要的過濾掉了。
組件索引
| 檔案 | 角色 |
|---|---|
radar_fetcher.py |
現行 fetcher,寫入 Redis |
pokemon_radar_api.py |
FastAPI,讀 Redis |
twpk_radar.mjs |
Function Server 上跑的 Puppeteer worker |
pokemon_location_fetcher.py |
舊 CLI,fetch_and_upload.sh 還在用 |
pokemon_radar_example.py |
舊 CLI 的 Python 呼叫範例 |
reader.py |
Redis 內容表格檢視 |
check_redis.py |
Redis raw JSON dump |
fetch_and_upload.sh |
生產 cron:舊 CLI → scp 到 caddy |
run_pokemon_radar_api.sh |
啟動 uvicorn |
state.json |
twpkinfo.com 登入快照(gitignore) |
.env |
所有 secret(gitignore) |
logging.ini |
uvicorn logging |
CLAUDE.md |
給 AI 助手的指引 |