Files
pokemon-radar-api/pokemon_location_fetcher.py
Timmy da5a36e8ad security: 移除 hard-coded FUNCTION_TOKEN,改從 env 讀取
- pokemon_location_fetcher.py:DEFAULT_URL/TOKEN 改走 os.getenv,並補 load_dotenv() 讓 fetch_and_upload.sh 能從 .env 取值
- run_pokemon_radar_api.sh:POKER_* 改為可由環境覆蓋(實務上未被 API 使用,保留結構)
- pyproject.toml:補宣告 python-dotenv(專案已使用但原本未列)

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

368 lines
12 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 argparse
import base64
import csv as _csv
import json
import os
import re
import requests
from datetime import datetime, timezone
from typing import Optional, List, Dict, Any
from zoneinfo import ZoneInfo
from dotenv import load_dotenv
load_dotenv()
TAIPEI_TZ = ZoneInfo("Asia/Taipei")
DEFAULT_URL = os.getenv("FUNCTION_URL", "http://192.168.42.124:13000/function")
DEFAULT_TOKEN = os.getenv("FUNCTION_TOKEN", "")
CSV_FIELDS = [
"id",
"name",
"gender",
"remaining",
"expire_time",
"expire_at",
"latitude",
"longitude",
"iv_pct",
"iv_atk",
"iv_def",
"iv_sta",
"cp",
"level",
"is_perfect",
"fast_move",
"charge_move",
]
class PokemonRadarClient:
"""
調用 twpk_radar.mjs 的封裝客戶端。
- 注入座標/截圖旗標
- 注入 statecookies/localStorage/UA/langs
- 呼叫 Function Server
- 套用篩選、輸出 JSON/CSV/截圖
"""
def __init__(
self,
url: str = DEFAULT_URL,
token: str = DEFAULT_TOKEN,
script: str = "twpk_radar.mjs",
):
self.url = url
self.token = token
self.script = script
# ---------- 準備 JS 代碼 ----------
def _load_script(self) -> str:
path = self.script
if not os.path.isfile(path):
fallback = "fn.mjs"
if os.path.isfile(fallback):
print(" {} 不存在,改用 {}".format(path, fallback))
path = fallback
else:
raise FileNotFoundError(
"找不到 JS 腳本:{}(也找不到 fn.mjs".format(self.script)
)
with open(path, "r", encoding="utf-8") as f:
return f.read()
def _inject_target_and_shot(
self, code: str, lat: float, lng: float, zoom: int, shot: bool
) -> str:
code = re.sub(
r"const TARGET\s*=\s*\{[^}]+\};",
"const TARGET = {{ lat: {}, lng: {}, zoom: {} }};".format(lat, lng, zoom),
code,
)
code = re.sub(
r"const DO_SCREENSHOT\s*=\s*(true|false);",
"const DO_SCREENSHOT = {};".format("true" if shot else "false"),
code,
)
return code
def _inject_state(
self, code: str, state_path: Optional[str], ua_from_state: bool
) -> str:
payload: Dict[str, Any] = {
"cookies": [],
"localStorage": {},
"ua": None,
"langs": None,
}
if 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")
print(
"[{}] 🧩 Loaded state: cookies={}, ls={}, ua={}".format(
datetime.now().astimezone().isoformat(timespec="seconds"),
len(payload["cookies"]),
len(payload["localStorage"]),
"Y" if payload["ua"] else "N",
)
)
except Exception as e:
print("⚠️ load state error: {}".format(e))
inject = (
"globalThis.__POKE_STATE__ = "
+ json.dumps(payload, ensure_ascii=False)
+ ";\n"
)
return inject + code
# ---------- 呼叫 function server ----------
def _call_server(self, code: str) -> Dict[str, Any]:
resp = requests.post(
self.url,
params={"token": self.token, "timeout": "150000"}, # 告知 Function Server 150秒超時
headers={"Content-Type": "application/json"},
json={"code": code},
timeout=160, # Python 客戶端 160秒就斷線不要等到 200
)
resp.raise_for_status()
return json.loads(resp.json()["data"])
# ---------- 對外:抓取 ----------
def fetch(
self,
lat: float,
lng: float,
zoom: int = 16,
shot: bool = False,
state: Optional[str] = None,
ua_from_state: bool = False,
) -> Dict[str, Any]:
code = self._load_script()
code = self._inject_target_and_shot(code, lat, lng, zoom, shot)
code = self._inject_state(code, state, ua_from_state)
parsed = self._call_server(code)
return {
"items": parsed.get("results", []),
"debug": parsed.get("debug", {}),
"screenshot": parsed.get("screenshot"),
}
# ---------- 工具IV% 回推 ----------
@staticmethod
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(
self,
items: List[Dict[str, Any]],
min_remaining: Optional[int] = None,
min_iv: Optional[int] = None,
perfect_only: bool = False,
species: str = "",
) -> List[Dict[str, Any]]:
# 標準化 + 回填 iv_pct + 算 expire_at
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 = self._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 save(
self,
items: List[Dict[str, Any]],
debug: Dict[str, Any],
screenshot_b64: Optional[str],
json_name: str = "pokemon_data.json",
csv_name: str = "pokemon_data.csv",
shot_name: str = "screenshot.png",
) -> None:
with open(json_name, "w", encoding="utf-8") as f:
json.dump({"items": items, "debug": debug}, f, ensure_ascii=False, indent=2)
print("💾 Saved JSON to {}".format(json_name))
with open(csv_name, "w", newline="", encoding="utf-8") as f:
w = _csv.DictWriter(f, fieldnames=CSV_FIELDS)
w.writeheader()
for row in items:
w.writerow({k: row.get(k) for k in CSV_FIELDS})
print("🧾 Saved CSV to {}".format(csv_name))
if screenshot_b64:
try:
b64 = (
screenshot_b64.split(",", 1)[1]
if "," in screenshot_b64
else screenshot_b64
)
with open(shot_name, "wb") as f:
f.write(base64.b64decode(b64))
print("📸 Screenshot saved to {}".format(shot_name))
except Exception as e:
print("⚠️ screenshot save error: {}".format(e))
# ---------------- CLI ----------------
def main() -> None:
ap = argparse.ArgumentParser(
description="Fetch Pokémon locations (state/UA + filters)"
)
ap.add_argument("--lat", type=float, default=25.0478)
ap.add_argument("--lng", type=float, default=121.5170)
ap.add_argument("--zoom", type=int, default=16)
ap.add_argument("--shot", action="store_true")
ap.add_argument("--perfect-only", action="store_true")
ap.add_argument("--state", type=str, help="Path to state.json or state_full.json")
ap.add_argument(
"--ua-from-state",
action="store_true",
help="Use UA/langs from state if available",
)
ap.add_argument(
"--min-remaining",
type=int,
default=None,
help="Keep entries with remaining >= SECONDS",
)
ap.add_argument("--min-iv", type=int, default=None, help="Keep IV% >= N")
ap.add_argument(
"--species",
type=str,
default="",
help="Comma list of dex ids or name keywords, e.g. '133,669,伊布'",
)
ap.add_argument(
"--script", type=str, default="twpk_radar.mjs", help="JS worker filename"
)
ap.add_argument("--url", type=str, default=DEFAULT_URL)
ap.add_argument("--token", type=str, default=DEFAULT_TOKEN)
args = ap.parse_args()
client = PokemonRadarClient(url=args.url, token=args.token, script=args.script)
print(
"🚀 Fetching at {},{} zoom={} (script={})".format(
args.lat, args.lng, args.zoom, os.path.basename(args.script)
)
)
payload = client.fetch(
args.lat, args.lng, args.zoom, args.shot, args.state, args.ua_from_state
)
print("----- debug notes -----")
for n in payload.get("debug", {}).get("notes", []):
print(n)
print("-----------------------")
print("🎯 Got {} results (before filters)".format(len(payload.get("items", []))))
filtered = client.postprocess(
payload["items"],
min_remaining=args.min_remaining,
min_iv=args.min_iv,
perfect_only=args.perfect_only,
species=args.species,
)
print("🔎 After filters: {} results".format(len(filtered)))
client.save(filtered, payload.get("debug", {}), payload.get("screenshot"))
if __name__ == "__main__":
main()