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>
This commit is contained in:
2026-04-20 11:35:44 +08:00
parent ce9a944b45
commit c428566c9a

View File

@@ -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_LAT = float(os.getenv("RADAR_LAT", "25.0478"))
RADAR_LNG = float(os.getenv("RADAR_LNG", "121.5170")) RADAR_LNG = float(os.getenv("RADAR_LNG", "121.5170"))
# ✅ 對齊 fetch_and_upload.shZOOM=11 # 多點掃描:'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_ZOOM = int(os.getenv("RADAR_ZOOM", "11"))
RADAR_SHOT = os.getenv("RADAR_SHOT", "false").lower() in ("1", "true", "yes", "y") RADAR_SHOT = os.getenv("RADAR_SHOT", "false").lower() in ("1", "true", "yes", "y")
@@ -223,6 +226,26 @@ def _postprocess(
return out 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: def _cache_to_redis(payload: Dict[str, Any]) -> None:
r = redis.Redis( r = redis.Redis(
host=REDIS_HOST, host=REDIS_HOST,
@@ -240,27 +263,38 @@ def _cache_to_redis(payload: Dict[str, Any]) -> None:
def main() -> int: def main() -> int:
try: try:
targets = _parse_targets()
logger.info( logger.info(
"ENV: RADAR_ZOOM={}, MIN_REMAINING={}, PERFECT_ONLY={}, UA_FROM_STATE={}, SCRIPT_PATH={}, STATE_PATH={}".format( "ENV: RADAR_ZOOM={}, MIN_REMAINING={}, PERFECT_ONLY={}, UA_FROM_STATE={}, targets={}".format(
os.getenv("RADAR_ZOOM"), RADAR_ZOOM, MIN_REMAINING, PERFECT_ONLY, UA_FROM_STATE, len(targets),
os.getenv("MIN_REMAINING"), )
os.getenv("PERFECT_ONLY"),
os.getenv("UA_FROM_STATE"),
SCRIPT_PATH,
STATE_PATH,
) )
)
script_text = _load_script_text(SCRIPT_PATH)
state_injected = _inject_state(script_text, STATE_PATH, UA_FROM_STATE)
code = _load_script_text(SCRIPT_PATH) all_raw: Dict[Any, Dict[str, Any]] = {} # dedupe by (id, lat, lng, expire_time)
code = _inject_target_and_shot(code, RADAR_LAT, RADAR_LNG, RADAR_ZOOM, RADAR_SHOT) merged_notes: List[str] = []
code = _inject_state(code, STATE_PATH, UA_FROM_STATE) 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) parsed = _call_function_server(code)
raw_items = parsed.get("results", []) raw_items = parsed.get("results", [])
debug = parsed.get("debug", {}) 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( filtered = _postprocess(
raw_items, raw_items,
@@ -270,7 +304,6 @@ def main() -> int:
species=SPECIES, species=SPECIES,
) )
# ✅ filtered=0 時印出原因線索remaining/perfect
if raw_items and not filtered: if raw_items and not filtered:
rems = [r.get("remaining") for r in raw_items if r.get("remaining") is not None] 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")) perfects = sum(1 for r in raw_items if r.get("is_perfect"))
@@ -290,8 +323,10 @@ def main() -> int:
"count_filtered": len(filtered), "count_filtered": len(filtered),
"items": filtered, "items": filtered,
"debug": { "debug": {
**(debug if isinstance(debug, dict) else {}), "notes": merged_notes,
"api_source": "Fresh from Function Server", "targets": [f"{n}:{la},{ln}" for n, la, ln in targets],
"target_stats": target_stats,
"api_source": "Fresh from Function Server (multi-target union)",
}, },
} }