Files
pokemon-radar-api/radar_fetcher.py
Timmy f7ef105436 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>
2026-04-20 09:13:59 +08:00

309 lines
10 KiB
Python
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.
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())