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())