"""Pull תב"ע identity + validity from mavat (מנהל התכנון) — container/MCP side. The thin container-side half of the mavat plan fetcher. The actual browser work happens on the **host** (`court_fetch_service` + `mavat_client`, Camoufox over Xvfb) because mavat sits behind an F5 BIG-IP ASM bot-wall that only a real JS-executing browser clears — a scripted httpx from the container gets a 302→maintenance. This module just calls that host bridge over the docker0 loopback (same bridge, secret and bind as X13 court-fetch — G2: no second service/port/secret) and normalises the result into registry fields. INV-AH: every pulled value carries `source_url` (the mavat plan page); a field the source doesn't expose (notably yalkut on some plans) comes back empty rather than guessed. The chair still gates the row (review_status) before block-ט cites it — this fetcher never writes the registry, it only returns a candidate dict. """ from __future__ import annotations import logging import os import httpx logger = logging.getLogger(__name__) # Same host bridge as X13 (pm2 `legal-court-fetch-service`, docker0 gateway). The # container and the host MCP server both reach 10.0.1.1:8771; the secret is the # shared COURT_FETCH_SHARED_SECRET (Coolify env on the container). _SERVICE_URL = os.environ.get("COURT_FETCH_SERVICE_URL", "http://10.0.1.1:8771") _SHARED_SECRET = os.environ.get("COURT_FETCH_SHARED_SECRET", "").strip() # mavat is slow (F5 challenge + SPA hydration + SV4); give the browser room but # stay under the host driver's own hard cap. _TIMEOUT_S = float(os.environ.get("PLAN_FETCH_TIMEOUT_S", "180")) # The fields the bridge returns and we surface to the form / upsert. _PLAN_FIELDS = ( "plan_number", "display_name", "plan_type", "purpose", "gazette_date", "yalkut_number", "yalkut_page", "source_url", ) class PlanFetchUnavailable(RuntimeError): """The host browser bridge isn't reachable / not configured.""" class PlanFetchError(RuntimeError): """mavat was reached but the plan couldn't be fetched/parsed.""" async def fetch_plan(plan_number: str) -> dict: """Fetch one plan's metadata from mavat via the host bridge. Returns a dict with the keys in ``_PLAN_FIELDS`` (missing values empty, never invented). Raises ``PlanFetchUnavailable`` if the bridge is down/unset, or ``PlanFetchError`` if mavat was reached but the plan wasn't found/parsed. """ plan_number = (plan_number or "").strip() if not plan_number: raise PlanFetchError("חסר מספר-תכנית") if not _SHARED_SECRET: raise PlanFetchUnavailable( "COURT_FETCH_SHARED_SECRET אינו מוגדר — לא ניתן לפנות לשירות-המשיכה." ) headers = {"Authorization": f"Bearer {_SHARED_SECRET}"} try: async with httpx.AsyncClient(timeout=_TIMEOUT_S) as client: resp = await client.post( f"{_SERVICE_URL}/plan-fetch", json={"plan_number": plan_number}, headers=headers, ) except httpx.ConnectError as e: raise PlanFetchUnavailable( f"שירות-המשיכה (legal-court-fetch-service) אינו זמין ב-{_SERVICE_URL}: {e}" ) from e except httpx.HTTPError as e: raise PlanFetchUnavailable(f"שגיאת-תקשורת לשירות-המשיכה: {e}") from e if resp.status_code == 401: raise PlanFetchUnavailable("שירות-המשיכה דחה את הסוד (401) — בדוק drift של COURT_FETCH_SHARED_SECRET.") if resp.status_code != 200: raise PlanFetchError(f"שירות-המשיכה החזיר {resp.status_code}: {resp.text[:200]}") body = resp.json() if not body.get("ok"): raise PlanFetchError(body.get("reason") or "התכנית לא נמצאה ב-מנהל-התכנון") plan = body.get("plan") or {} # Normalise to exactly our fields; keep source_url mandatory (INV-AH). out = {k: (plan.get(k) or "") for k in _PLAN_FIELDS} out["plan_number"] = out["plan_number"] or plan_number if not out["source_url"]: raise PlanFetchError("התקבלה תכנית ללא source_url — נדחה (INV-AH).") return out