- 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>
177 lines
5.5 KiB
Python
177 lines
5.5 KiB
Python
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
|
||
}
|