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:
308
radar_fetcher.py
Normal file
308
radar_fetcher.py
Normal 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.sh:ZOOM=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())
|
||||
|
||||
Reference in New Issue
Block a user