# pokemon-radar-api 把 twpkinfo.com 的寶可夢雷達資料,透過 Redis 快取成一支 FastAPI 給前端 / n8n 呼叫。 這份 README 是給**一年後回來看的你自己**:最常用的指令放最上面,架構與決策放最下面。`CLAUDE.md` 是給 AI 助手用的,可以忽略。 跨 repo / 跨機器的部署地圖(前端、API、webhook 自動部署、NPM 反代、Gitea)放在 [`docs/INFRA.md`](docs/INFRA.md),回憶「當初怎麼設定的」從那篇下手。 --- ## 最常用的指令 全部用 `uv`(Python 3.12)。設定讀 `.env`(不在 git 裡,本機有)。 ```bash # 起本機 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` 時不會動,閒置零呼叫。 需要偵錯或手動拉資料時: ```bash 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` | 掃描中心 | | `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** ```json { "source": "redis" | "redis (empty)" | "redis (unreachable)", "ttl": 281, "count_raw": 79, "count_filtered": 24, "items": [ /* id, name, gender, iv_pct, cp, level, latitude, longitude, remaining, expire_at, fast_move, charge_move, is_perfect, ... */ ], "debug": { "notes": [...], "redis_ttl": 281 } } ``` ### Redis schema Key `pokemon:radar:latest`,String(JSON),TTL 由 `REDIS_TTL` 控制。Value 結構: ```json { "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()` 非同步更新;以 Redis `SET 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 import `PokemonRadarClient` 但傳空 url/token,只用它的 `postprocess` 方法。這是 workaround,理想做法是把篩選抽成獨立模組,兩份 fetcher 共用。 - **fetcher 端先做一輪粗篩再寫 Redis**(`MIN_REMAINING` 等),API 端再做第二輪 per-request 篩選。Redis 裡不是「原始資料」而是「粗篩後資料」,如果要在 API 端篩出 fetcher 濾掉的東西是做不到的。 ### 常見故障(依機率排序) 1. **`state.json` 過期** → Function Server 回 200 但 results 是空 / 登入頁 DOM,pipeline 不會炸只會吐 0 筆。`radar_fetcher.py` 有診斷:`raw_items` 非空但篩完為 0 時 log `remaining_min / remaining_max / perfect` 分佈。重登 twpkinfo.com 匯出新 state。 2. **Redis 連不上** → API 回 `source:"redis (unreachable)"`(不會 500)、`reader.py` 回 `Timeout connecting to server`。確認 docker container / `.env` 的 `REDIS_HOST`。 3. **`FUNCTION_TOKEN` 未設定** → fetcher `raise RuntimeError`。檢查 `.env`。 4. **`/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 助手的指引 |