From c428566c9afde571d5677b2f4b8a38564898410d Mon Sep 17 00:00:00 2001 From: Timmy Date: Mon, 20 Apr 2026 11:35:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20fetcher=20=E6=94=AF=E6=8F=B4=E5=A4=9A?= =?UTF-8?q?=20target=20union=20=E6=8E=83=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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) --- radar_fetcher.py | 75 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/radar_fetcher.py b/radar_fetcher.py index 696dcca..deecc70 100644 --- a/radar_fetcher.py +++ b/radar_fetcher.py @@ -29,7 +29,10 @@ UA_FROM_STATE = os.getenv("UA_FROM_STATE", "true").lower() in ("1", "true", "yes RADAR_LAT = float(os.getenv("RADAR_LAT", "25.0478")) RADAR_LNG = float(os.getenv("RADAR_LNG", "121.5170")) -# ✅ 對齊 fetch_and_upload.sh:ZOOM=11 +# 多點掃描:'name:lat,lng;name2:lat2,lng2;...'。未設時 fall back 到 RADAR_LAT/LNG 單點 +RADAR_TARGETS = os.getenv("RADAR_TARGETS", "").strip() + +# ✅ 對齊 fetch_and_upload.sh:ZOOM=11(多點模式建議改 15) RADAR_ZOOM = int(os.getenv("RADAR_ZOOM", "11")) RADAR_SHOT = os.getenv("RADAR_SHOT", "false").lower() in ("1", "true", "yes", "y") @@ -223,6 +226,26 @@ def _postprocess( 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, @@ -240,27 +263,38 @@ def _cache_to_redis(payload: Dict[str, Any]) -> None: def main() -> int: try: + targets = _parse_targets() 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, - ) -) + "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) - 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) + all_raw: Dict[Any, Dict[str, Any]] = {} # dedupe by (id, lat, lng, expire_time) + merged_notes: List[str] = [] + target_stats: List[str] = [] - parsed = _call_function_server(code) - raw_items = parsed.get("results", []) - debug = parsed.get("debug", {}) + 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") - logger.info(f"Got {len(raw_items)} results (before filters)") + raw_items = list(all_raw.values()) + logger.info(f"union total {len(raw_items)} unique (per-target: {', '.join(target_stats)})") filtered = _postprocess( raw_items, @@ -270,7 +304,6 @@ def main() -> int: 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")) @@ -290,8 +323,10 @@ def main() -> int: "count_filtered": len(filtered), "items": filtered, "debug": { - **(debug if isinstance(debug, dict) else {}), - "api_source": "Fresh from Function Server", + "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)", }, }