init: Redis-backed Pokémon 雷達 API

- radar_fetcher.py:呼叫 Function Server 抓 twpkinfo.com 資料後寫入 Redis (TTL 300s)
- pokemon_radar_api.py:FastAPI /scan,從 Redis 讀取並依 request 即時二次篩選;Redis 不可用時回 200 空結果,不再 500
- pokemon_location_fetcher.py:舊版 CLI,仍由 fetch_and_upload.sh 使用
- 文件:README (參考手冊)、QUICKSTART (操作指南)、SUMMARY (故事線)、CLAUDE.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 09:13:59 +08:00
commit f7ef105436
18 changed files with 2919 additions and 0 deletions

176
pokemon_radar_api.py Normal file
View File

@@ -0,0 +1,176 @@
import json
import os
import sys
from typing import Any, Dict, List, Optional
import redis
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
# 引用舊有的 Client 主要是為了使用它的 postprocess 篩選邏輯,不進行連線
from pokemon_location_fetcher import PokemonRadarClient
# 載入環境變數
load_dotenv()
# ========= 設定 =========
# Redis 連線資訊 (指向 192.168.42.211)
REDIS_HOST = os.getenv("REDIS_HOST", "192.168.42.211")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB = int(os.getenv("REDIS_DB", 0))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
REDIS_KEY = "pokemon:radar:latest"
# ========= FastAPI 初始化 =========
app = FastAPI(title="Pokemon Radar API (Redis Backed)", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========= Redis 連線池 =========
try:
redis_pool = redis.ConnectionPool(
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB,
password=REDIS_PASSWORD,
decode_responses=True,
socket_connect_timeout=5
)
except Exception as e:
print(f"❌ Redis 設定錯誤: {e}")
sys.exit(1)
# ========= Models (保持與原本相容) =========
class ScanRequest(BaseModel):
# 雖然改用 Redis但保留這些欄位讓前端/n8n 不用改
# 實際上 lat, lng, zoom, shot, state_path, ua... 在此模式下會被忽略
lat: float = Field(..., description="(已忽略) 緯度")
lng: float = Field(..., description="(已忽略) 經度")
zoom: int = Field(16, description="(已忽略) 地圖縮放")
shot: bool = Field(False, description="(已忽略) 是否擷取畫面")
state_path: Optional[str] = Field(None, description="(已忽略)")
ua_from_state: bool = Field(True, description="(已忽略)")
# === 這些篩選參數依然有效 ===
min_remaining: Optional[int] = Field(None, ge=0, description="只保留 剩餘秒數 ≥ 此值")
min_iv: Optional[int] = Field(None, ge=0, le=100, description="只保留 IV% ≥ 此值")
perfect_only: bool = Field(False, description="只保留 100% IV")
species: Optional[str] = Field("", description="dex id 或關鍵字,例如 '133,伊布'")
save_files: bool = Field(False, description="(已忽略) API 不再負責存檔")
class Item(BaseModel):
id: Optional[int]
name: Optional[str]
gender: Optional[str]
remaining: Optional[int]
expire_time: Optional[int] = None
expire_at: Optional[str] = None
latitude: Optional[float]
longitude: Optional[float]
iv_pct: Optional[int]
iv_atk: Optional[int]
iv_def: Optional[int]
iv_sta: Optional[int]
cp: Optional[int]
level: Optional[int]
is_perfect: bool
fast_move: Optional[str] = None
charge_move: Optional[str] = None
class ScanResponse(BaseModel):
source: str = "redis"
ttl: int
count_raw: int
count_filtered: int
items: List[Item]
debug: Dict[str, Any]
# ========= 工具Redis Helper =========
def get_data_from_redis():
try:
r = redis.Redis(connection_pool=redis_pool)
if not r.exists(REDIS_KEY):
return None, 0, None
ttl = r.ttl(REDIS_KEY)
raw_json = r.get(REDIS_KEY)
except redis.exceptions.RedisError as e:
return None, 0, f"redis unreachable: {e}"
try:
return json.loads(raw_json), ttl, None
except json.JSONDecodeError:
return None, 0, "redis payload not json"
# ========= 依賴Client (只用來做 postprocess) =========
# 我們不需要傳入真實的 url/token因為不會用到 fetch
_filter_helper = PokemonRadarClient(url="", token="")
# ========= 路由 =========
@app.get("/ping")
def ping():
return {"status": "ok", "backend": "redis"}
@app.post("/scan", response_model=ScanResponse)
def scan(req: ScanRequest) -> Any:
# 1. 從 Redis 讀取資料
data, ttl, err = get_data_from_redis()
if not data:
# Redis 沒資料或連不上,都回傳空結果而不是 500避免前端炸掉
return {
"source": "redis (unreachable)" if err else "redis (empty)",
"ttl": 0,
"count_raw": 0,
"count_filtered": 0,
"items": [],
"debug": {"note": err or "Redis key not found or expired"}
}
# 2. 取得原始列表
# 根據你的 twpk_radar.mjs 結構items 可能在 'results' 或 'items' 欄位
items_raw = data.get("results", data.get("items", []))
# 3. 使用 Helper 進行篩選 (Process & Filter)
# 這一步很重要,因為 Redis 存的是 raw data我們要根據 API 請求即時篩選
try:
items_filtered = _filter_helper.postprocess(
items_raw,
min_remaining=req.min_remaining,
min_iv=req.min_iv,
perfect_only=req.perfect_only,
species=req.species or "",
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Filtering error: {e}")
# 4. 準備 Debug 資訊
debug_info = data.get("debug", {})
# 可以在這裡加入來源資訊
debug_info["api_source"] = "Cached from Redis"
debug_info["redis_ttl"] = ttl
# 5. 回傳結果
return {
"source": "redis",
"ttl": ttl,
"count_raw": len(items_raw),
"count_filtered": len(items_filtered),
"items": items_filtered,
"debug": debug_info
}