Files
pokemon-radar-api/radar_fetcher.py
Timmy c428566c9a feat: fetcher 支援多 target union 掃描
- 新增 RADAR_TARGETS 環境變數('name:lat,lng;...')
- 未設時 fall back 到單點 RADAR_LAT/LNG(backward-compat)
- 主流程改為 loop 每個 target 呼叫 Function Server,以 (id, lat, lng, expire_time) dedupe 後 union
- 單點失敗不阻斷其他 target(try/except 包起來)
- debug 記錄每 target 原始筆數與完整 targets 清單
- 搭配 RADAR_ZOOM=15 解決 zoom 11 廣視角下單位面積密度過低的問題

本機驗證:5 target(台北/內湖/板橋/新竹/宜蘭)union 191 筆,
twpk 內湖南港街區 bounds(zoom 17 視角)從 0 筆變 2 筆。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:35:44 +08:00

344 lines
12 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"))
# 多點掃描:'name:lat,lng;name2:lat2,lng2;...'。未設時 fall back 到 RADAR_LAT/LNG 單點
RADAR_TARGETS = os.getenv("RADAR_TARGETS", "").strip()
# ✅ 對齊 fetch_and_upload.shZOOM=11多點模式建議改 15
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 _parse_targets() -> List[tuple]:
"""解析 RADAR_TARGETS未設時 fall back 單點 RADAR_LAT/LNG。
格式:'name:lat,lng;name2:lat2,lng2;...'
"""
if not RADAR_TARGETS:
return [("default", RADAR_LAT, RADAR_LNG)]
targets = []
for chunk in RADAR_TARGETS.split(";"):
chunk = chunk.strip()
if not chunk:
continue
name, _, coord = chunk.partition(":")
lat_s, _, lng_s = coord.partition(",")
try:
targets.append((name.strip(), float(lat_s), float(lng_s)))
except ValueError:
logger.warning(f"skip malformed RADAR_TARGETS entry: {chunk}")
return targets or [("default", RADAR_LAT, RADAR_LNG)]
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:
targets = _parse_targets()
logger.info(
"ENV: RADAR_ZOOM={}, MIN_REMAINING={}, PERFECT_ONLY={}, UA_FROM_STATE={}, targets={}".format(
RADAR_ZOOM, MIN_REMAINING, PERFECT_ONLY, UA_FROM_STATE, len(targets),
)
)
script_text = _load_script_text(SCRIPT_PATH)
state_injected = _inject_state(script_text, STATE_PATH, UA_FROM_STATE)
all_raw: Dict[Any, Dict[str, Any]] = {} # dedupe by (id, lat, lng, expire_time)
merged_notes: List[str] = []
target_stats: List[str] = []
for i, (name, lat, lng) in enumerate(targets, 1):
logger.info(f"[{i}/{len(targets)}] fetching '{name}' at ({lat},{lng}) zoom={RADAR_ZOOM}")
try:
code = _inject_target_and_shot(state_injected, lat, lng, RADAR_ZOOM, RADAR_SHOT)
parsed = _call_function_server(code)
raw_items = parsed.get("results", [])
target_stats.append(f"{name}:{len(raw_items)}")
for r in raw_items:
key = (r.get("id"), r.get("latitude"), r.get("longitude"), r.get("expire_time"))
all_raw.setdefault(key, r)
notes = parsed.get("debug", {}).get("notes", []) if isinstance(parsed.get("debug"), dict) else []
merged_notes.extend(f"[{name}] {n}" for n in notes)
except Exception as e:
logger.exception(f"[{i}/{len(targets)}] '{name}' failed: {e}")
target_stats.append(f"{name}:ERROR")
raw_items = list(all_raw.values())
logger.info(f"union total {len(raw_items)} unique (per-target: {', '.join(target_stats)})")
filtered = _postprocess(
raw_items,
min_remaining=MIN_REMAINING,
min_iv=MIN_IV,
perfect_only=PERFECT_ONLY,
species=SPECIES,
)
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": {
"notes": merged_notes,
"targets": [f"{n}:{la},{ln}" for n, la, ln in targets],
"target_stats": target_stats,
"api_source": "Fresh from Function Server (multi-target union)",
},
}
_cache_to_redis(out)
return 0
except Exception as e:
logger.exception(f"fetcher failed: {e}")
return 2
if __name__ == "__main__":
raise SystemExit(main())