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

308
radar_fetcher.py Normal file
View File

@@ -0,0 +1,308 @@
import json
import os
import re
from datetime import datetime
from typing import Any, Dict, List, Optional
from zoneinfo import ZoneInfo
import httpx
import redis
from dotenv import load_dotenv
from loguru import logger
load_dotenv()
TAIPEI_TZ = ZoneInfo("Asia/Taipei")
# Function Server真正會跑 JS / browser automation 的)
FUNCTION_URL = os.getenv("FUNCTION_URL", "http://192.168.42.124:13000/function")
FUNCTION_TOKEN = os.getenv("FUNCTION_TOKEN", "")
# JS worker
SCRIPT_PATH = os.getenv("SCRIPT_PATH", "twpk_radar.mjs")
STATE_PATH = os.getenv("STATE_PATH", "state.json")
# ✅ 對齊 fetch_and_upload.sh它有 --ua-from-state
UA_FROM_STATE = os.getenv("UA_FROM_STATE", "true").lower() in ("1", "true", "yes", "y")
# target / filters✅ 對齊 shell 預設)
RADAR_LAT = float(os.getenv("RADAR_LAT", "25.0478"))
RADAR_LNG = float(os.getenv("RADAR_LNG", "121.5170"))
# ✅ 對齊 fetch_and_upload.shZOOM=11
RADAR_ZOOM = int(os.getenv("RADAR_ZOOM", "11"))
RADAR_SHOT = os.getenv("RADAR_SHOT", "false").lower() in ("1", "true", "yes", "y")
# ✅ 對齊 fetch_and_upload.sh只用 min_remaining其他預設不啟用
MIN_REMAINING = os.getenv("MIN_REMAINING", "300").strip()
MIN_REMAINING = int(MIN_REMAINING) if MIN_REMAINING else None
MIN_IV = os.getenv("MIN_IV", "").strip()
MIN_IV = int(MIN_IV) if MIN_IV else None
# ✅ fetch_and_upload.sh 沒有 perfect-only所以預設要 False
PERFECT_ONLY = os.getenv("PERFECT_ONLY", "false").lower() in ("1", "true", "yes", "y")
SPECIES = os.getenv("SPECIES", "").strip() # "133,伊布"
# http
HTTP_TIMEOUT = float(os.getenv("HTTP_TIMEOUT", "160"))
# Redis
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB = int(os.getenv("REDIS_DB", 0))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD") or None
REDIS_TTL = int(os.getenv("REDIS_TTL", 300))
REDIS_KEY = os.getenv("REDIS_KEY", "pokemon:radar:latest")
def _load_script_text(path: str) -> str:
if not os.path.isfile(path):
if os.path.isfile("fn.mjs"):
logger.warning(f"{path} 不存在,改用 fn.mjs")
path = "fn.mjs"
else:
raise FileNotFoundError(f"找不到 JS 腳本:{path}(也找不到 fn.mjs")
with open(path, "r", encoding="utf-8") as f:
return f.read()
def _inject_target_and_shot(code: str, lat: float, lng: float, zoom: int, shot: bool) -> str:
code = re.sub(
r"const TARGET\s*=\s*\{[^}]+\};",
f"const TARGET = {{ lat: {lat}, lng: {lng}, zoom: {zoom} }};",
code,
)
code = re.sub(
r"const DO_SCREENSHOT\s*=\s*(true|false);",
f"const DO_SCREENSHOT = {'true' if shot else 'false'};",
code,
)
return code
def _inject_state(code: str, state_path: Optional[str], ua_from_state: bool) -> str:
payload: Dict[str, Any] = {"cookies": [], "localStorage": {}, "ua": None, "langs": None}
if state_path and os.path.isfile(state_path):
try:
with open(state_path, "r", encoding="utf-8") as sf:
raw = json.load(sf)
payload["cookies"] = raw.get("cookies", raw.get("Cookies", []))
payload["localStorage"] = raw.get("localStorage", raw.get("LocalStorage", {}))
if ua_from_state:
payload["ua"] = raw.get("ua")
payload["langs"] = raw.get("langs")
logger.info(
f"Loaded state: cookies={len(payload['cookies'])}, "
f"ls={len(payload['localStorage'])}, ua={'Y' if payload['ua'] else 'N'}"
)
except Exception as e:
logger.warning(f"load state error: {e}")
else:
logger.warning(f"state file not found: {state_path}")
inject = "globalThis.__POKE_STATE__ = " + json.dumps(payload, ensure_ascii=False) + ";\n"
return inject + code
def _call_function_server(code: str) -> Dict[str, Any]:
if not FUNCTION_TOKEN:
raise RuntimeError("FUNCTION_TOKEN 未設定(請在 .env 設 FUNCTION_TOKEN")
params = {"token": FUNCTION_TOKEN, "timeout": "150000"}
headers = {"Content-Type": "application/json"}
logger.info(f"Calling Function Server: {FUNCTION_URL}")
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.post(FUNCTION_URL, params=params, headers=headers, json={"code": code})
resp.raise_for_status()
outer = resp.json()
data_str = outer.get("data")
if not isinstance(data_str, str):
raise ValueError(f"Unexpected function response: keys={list(outer.keys())}")
return json.loads(data_str)
def _iv_percent_from_triplet(atk: Any, df: Any, sta: Any) -> Optional[int]:
try:
if atk is None or df is None or sta is None:
return None
return int(round((int(atk) + int(df) + int(sta)) * 100.0 / 45.0))
except Exception:
return None
def _postprocess(
items: List[Dict[str, Any]],
min_remaining: Optional[int],
min_iv: Optional[int],
perfect_only: bool,
species: str,
) -> List[Dict[str, Any]]:
norm: List[Dict[str, Any]] = []
for r in items:
row: Dict[str, Any] = {
"id": r.get("id"),
"name": r.get("name"),
"gender": r.get("gender"),
"remaining": r.get("remaining"),
"expire_time": r.get("expire_time"),
"latitude": r.get("latitude"),
"longitude": r.get("longitude"),
"iv_pct": r.get("iv_pct"),
"iv_atk": r.get("iv_atk"),
"iv_def": r.get("iv_def"),
"iv_sta": r.get("iv_sta"),
"cp": r.get("cp"),
"level": r.get("level"),
"is_perfect": r.get("is_perfect", False),
"fast_move": r.get("fast_move"),
"charge_move": r.get("charge_move"),
}
if row["iv_pct"] is None:
alt = _iv_percent_from_triplet(row["iv_atk"], row["iv_def"], row["iv_sta"])
if alt is not None:
row["iv_pct"] = alt
expire_ts = row.get("expire_time")
if expire_ts is not None:
try:
row["expire_at"] = datetime.fromtimestamp(int(expire_ts), tz=TAIPEI_TZ).isoformat()
except Exception:
row["expire_at"] = None
else:
row["expire_at"] = None
norm.append(row)
species_raw = [s.strip() for s in (species or "").split(",") if s.strip()]
species_ids, species_kw = set(), []
for s in species_raw:
if re.fullmatch(r"\d+", s):
species_ids.add(int(s))
else:
species_kw.append(s.lower())
def species_match(row: Dict[str, Any]) -> bool:
if not species_ids and not species_kw:
return True
ok_id = (row.get("id") in species_ids) if species_ids else False
nm = row.get("name") or ""
ok_kw = any(k in nm.lower() for k in species_kw) if species_kw and nm else False
return ok_id or ok_kw
def remaining_match(row: Dict[str, Any]) -> bool:
if min_remaining is None:
return True
rem = row.get("remaining")
return (rem is not None) and (int(rem) >= int(min_remaining))
def iv_match(row: Dict[str, Any]) -> bool:
if min_iv is None:
return True
ivp = row.get("iv_pct")
return (ivp is not None) and (int(ivp) >= int(min_iv))
out = [r for r in norm if species_match(r) and remaining_match(r) and iv_match(r)]
if perfect_only:
out = [r for r in out if r.get("is_perfect")]
out.sort(
key=lambda x: (
x["remaining"] if x["remaining"] is not None else 9_999_999,
x.get("id") or 0,
)
)
return out
def _cache_to_redis(payload: Dict[str, Any]) -> None:
r = redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
db=REDIS_DB,
password=REDIS_PASSWORD,
decode_responses=True,
socket_connect_timeout=5,
socket_timeout=5,
)
r.ping()
r.setex(REDIS_KEY, REDIS_TTL, json.dumps(payload, ensure_ascii=False))
logger.success(f"Redis write OK host={REDIS_HOST} key={REDIS_KEY} ttl={REDIS_TTL}s")
def main() -> int:
try:
logger.info(
"ENV: RADAR_ZOOM={}, MIN_REMAINING={}, PERFECT_ONLY={}, UA_FROM_STATE={}, SCRIPT_PATH={}, STATE_PATH={}".format(
os.getenv("RADAR_ZOOM"),
os.getenv("MIN_REMAINING"),
os.getenv("PERFECT_ONLY"),
os.getenv("UA_FROM_STATE"),
SCRIPT_PATH,
STATE_PATH,
)
)
code = _load_script_text(SCRIPT_PATH)
code = _inject_target_and_shot(code, RADAR_LAT, RADAR_LNG, RADAR_ZOOM, RADAR_SHOT)
code = _inject_state(code, STATE_PATH, UA_FROM_STATE)
parsed = _call_function_server(code)
raw_items = parsed.get("results", [])
debug = parsed.get("debug", {})
logger.info(f"Got {len(raw_items)} results (before filters)")
filtered = _postprocess(
raw_items,
min_remaining=MIN_REMAINING,
min_iv=MIN_IV,
perfect_only=PERFECT_ONLY,
species=SPECIES,
)
# ✅ filtered=0 時印出原因線索remaining/perfect
if raw_items and not filtered:
rems = [r.get("remaining") for r in raw_items if r.get("remaining") is not None]
perfects = sum(1 for r in raw_items if r.get("is_perfect"))
logger.warning(
"Filtered to 0. remaining_min={} remaining_max={} perfect={}/{}".format(
min(rems) if rems else None,
max(rems) if rems else None,
perfects,
len(raw_items),
)
)
out = {
"source": "fetcher",
"generated_at": datetime.now(tz=TAIPEI_TZ).isoformat(timespec="seconds"),
"count_raw": len(raw_items),
"count_filtered": len(filtered),
"items": filtered,
"debug": {
**(debug if isinstance(debug, dict) else {}),
"api_source": "Fresh from Function Server",
},
}
_cache_to_redis(out)
return 0
except Exception as e:
logger.exception(f"fetcher failed: {e}")
return 2
if __name__ == "__main__":
raise SystemExit(main())