Files
pokemon-radar-api/README.md
Timmy e2710bcd24 docs: 更新 README 與 INFRA.md 記錄 RADAR_TARGETS 多點掃描
- README env 表格加入 RADAR_TARGETS、RADAR_ZOOM 備註(zoom 11 單點 vs zoom 15 多點的差異)
- INFRA.md 加 3.13.5 多點 union 掃描節,記錄 root cause(單點 zoom 11 密度過低)、解法、生產設定、二階段優化方向

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

178 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | 單點掃描中心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**
```json
{
"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 結構:
```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多人同時打直接壞。拆成 writerfetcher+ readerAPI 讀快取)後壓力從「每 request」降到「每 TTL」。
- **為什麼 stale-while-revalidatev2.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 給 APIB 走本地檔給靜態前端,**兩條都還在跑**。
- **`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 是空 / 登入頁 DOMpipeline 不會炸只會吐 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` | 所有 secretgitignore |
| `logging.ini` | uvicorn logging |
| `CLAUDE.md` | 給 AI 助手的指引 |