""" Web application: FastAPI REST endpoints + FastMCP mount + React static. Layout: / → React SPA (index.html + assets) /api/... → FastAPI REST endpoints used by the React UI /api/docs → Swagger UI for the REST API /mcp → FastMCP streamable-http endpoint (for LLM clients) /health → liveness probe (also mounted under /api/health) """ from __future__ import annotations import contextlib import logging import os from pathlib import Path import re from typing import Any from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import StreamingResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, field_validator from starlette.responses import FileResponse, JSONResponse from nadlan_mcp.fastmcp_server import ( client as govmap_client, ) from nadlan_mcp.fastmcp_server import ( decisive_appraiser_client, mcp, ) from nadlan_mcp.govil.client import ALLOWED_PDF_HOSTS logger = logging.getLogger(__name__) # Environment switch — controls visibility of internal-leak surfaces like # Swagger and openapi.json. Defaults to "production" so an unset env var # is the safe choice. ENVIRONMENT = os.getenv("ENVIRONMENT", "production").lower() IS_PROD = ENVIRONMENT in {"production", "prod"} # Hard cap for proxied PDF downloads (bytes). Above this we refuse rather # than buffer/stream. Justice-ministry decisions are rarely >5MB; 50MB is # a generous ceiling that still bounds memory use. MAX_PDF_SIZE = 50 * 1024 * 1024 # Generic error string returned to clients in lieu of `str(e)` — see H-1. GENERIC_UPSTREAM_ERROR = "Service temporarily unavailable, please retry" # ────────────────────────────────────────────────────────────────────── # Request / response schemas # ────────────────────────────────────────────────────────────────────── _RE_BLOCK = re.compile(r"^\d{1,8}$") _RE_PLOT = re.compile(r"^\d{1,6}$") _RE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") # Address: Hebrew letters, Latin letters, digits, spaces, common punctuation # (comma, dash, dot, slash, quote, apostrophe). Excludes control chars and # null bytes that could trip downstream APIs. _RE_ADDRESS = re.compile(r"^[֐-׿A-Za-z0-9\s,\-./'\"\(\)״׳]+$") # Person/committee names (Hebrew + Latin + spaces + dashes + dots) _RE_NAME = re.compile(r"^[֐-׿A-Za-z\s\-.׳״']+$") def _validate_optional_pattern(value: str | None, pattern: re.Pattern, field: str) -> str | None: if value is None: return None value = value.strip() if not value: return None if not pattern.match(value): raise ValueError(f"{field}: invalid format") return value class DealsSearchRequest(BaseModel): address: str = Field(..., min_length=2, max_length=200) years_back: int = Field(2, ge=1, le=10) radius_meters: int = Field(100, ge=10, le=2000) max_deals: int = Field(50, ge=1, le=200) deal_type: int = Field(2, ge=1, le=2) @field_validator("address") @classmethod def _check_address(cls, v: str) -> str: v = v.strip() if not _RE_ADDRESS.match(v): raise ValueError("address contains disallowed characters") return v class AppraisalsSearchRequest(BaseModel): block: str | None = Field(None, max_length=20) plot: str | None = Field(None, max_length=20) decisive_appraiser: str | None = Field(None, max_length=100) committee: str | None = Field(None, max_length=100) decision_date_from: str | None = Field(None, max_length=20) decision_date_to: str | None = Field(None, max_length=20) publicity_date_from: str | None = Field(None, max_length=20) publicity_date_to: str | None = Field(None, max_length=20) search_text: str | None = Field(None, max_length=200) appraisal_header: str | None = Field(None, max_length=200) max_results: int = Field(30, ge=1, le=100) # When true and `block` is supplied, also fetch decisions from other # plots in the same block and from neighbouring blocks. Returned in # separate buckets in the response. include_nearby: bool = Field(False) nearby_block_radius: int = Field(2, ge=1, le=10) @field_validator("block") @classmethod def _check_block(cls, v): return _validate_optional_pattern(v, _RE_BLOCK, "block") @field_validator("plot") @classmethod def _check_plot(cls, v): return _validate_optional_pattern(v, _RE_PLOT, "plot") @field_validator("decisive_appraiser", "committee") @classmethod def _check_name(cls, v): return _validate_optional_pattern(v, _RE_NAME, "name") @field_validator( "decision_date_from", "decision_date_to", "publicity_date_from", "publicity_date_to", ) @classmethod def _check_date(cls, v): return _validate_optional_pattern(v, _RE_DATE, "date") @field_validator("search_text", "appraisal_header") @classmethod def _check_freetext(cls, v): if v is None: return None v = v.strip() if not v: return None if not _RE_ADDRESS.match(v): raise ValueError("text contains disallowed characters") return v # ────────────────────────────────────────────────────────────────────── # App factory # ────────────────────────────────────────────────────────────────────── def create_app() -> FastAPI: """ Build the combined FastAPI + FastMCP application. The static-file mount at "/" must be the LAST mount so that more specific paths (/api, /mcp, /health) take precedence. """ # FastMCP needs its lifespan to run, so we forward it to FastAPI. if hasattr(mcp, "streamable_http_app"): mcp_app = mcp.streamable_http_app() elif hasattr(mcp, "http_app"): mcp_app = mcp.http_app() else: raise RuntimeError("FastMCP version does not expose an HTTP app") app = FastAPI( title="Nadlan-MCP Web", description=( "REST API for Israeli real-estate (Govmap) and decisive-appraiser " "(Ministry of Justice) data. Powers the React UI; also exposes the " "raw FastMCP endpoint at /mcp for LLM clients." ), version="2.1.0", # Hide API schema in production to avoid handing a roadmap to attackers. docs_url=None if IS_PROD else "/api/docs", redoc_url=None, openapi_url=None if IS_PROD else "/api/openapi.json", lifespan=mcp_app.router.lifespan_context, ) # CORS — origins overridable via ALLOWED_ORIGINS env (comma-separated). # Default keeps the local-dev Vite origins so contributors aren't broken; # production sets ALLOWED_ORIGINS explicitly (or empty to disable cross-origin). cors_default = "http://localhost:5173,http://127.0.0.1:5173" cors_origins = [ o.strip() for o in os.getenv("ALLOWED_ORIGINS", cors_default).split(",") if o.strip() ] app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["Content-Type"], allow_credentials=False, max_age=600, ) # ── Optional API-key gate (C-2) ───────────────────────────────── # Defense-in-depth complement to Traefik mTLS (which is the canonical # solution but not yet enabled). When NADLAN_API_KEY is set, requests # to non-public paths must present a matching X-API-Key header. # When unset, the gate is bypassed — preserving the current public # behavior so this is safe to ship before mTLS lands. API_KEY = os.getenv("NADLAN_API_KEY", "").strip() or None AUTH_REQUIRED_PATHS = ("/api/search/", "/api/appraisals/pdf", "/mcp") PUBLIC_PATHS = ("/api/health", "/health") @app.middleware("http") async def api_key_gate(request, call_next): if API_KEY is None: return await call_next(request) path = request.url.path if path in PUBLIC_PATHS: return await call_next(request) if not any(path.startswith(p) for p in AUTH_REQUIRED_PATHS): return await call_next(request) provided = request.headers.get("x-api-key", "").strip() # Constant-time compare to defeat timing oracles. import hmac if not provided or not hmac.compare_digest(provided, API_KEY): return JSONResponse({"detail": "Unauthorized"}, status_code=401) return await call_next(request) # ── Per-IP rate limiter (H-5) ─────────────────────────────────── # Token bucket per client IP — protects upstream Govmap/gov.il from # bulk scraping via /api/* and /mcp, and prevents one attacker from # exhausting the shared rate-limit budget. Buckets are kept in # process-local memory; a single uvicorn worker is enough at our # scale, and the limit is a defense-in-depth complement to Traefik's # own RateLimit middleware (when added). import asyncio from collections import defaultdict RATE_LIMIT_PATHS = ( ("/mcp", 30, 60.0), # 30 req / 60s on /mcp/* ("/api/search/", 60, 60.0), # 60 req / 60s on /api/search/* ("/api/appraisals/pdf", 30, 60.0), # 30 PDF fetches / 60s ) _rl_buckets: dict[tuple[str, str], list[float]] = defaultdict(list) _rl_lock = asyncio.Lock() def _client_ip(request) -> str: # Coolify/Traefik forward the real client IP via X-Forwarded-For. # Take the first entry (left-most), which by convention is the # actual remote client. xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip() if xff: return xff return request.client.host if request.client else "unknown" @app.middleware("http") async def rate_limit(request, call_next): path = request.url.path rule = next( (r for r in RATE_LIMIT_PATHS if path.startswith(r[0])), None, ) if rule is None: return await call_next(request) prefix, limit, window = rule ip = _client_ip(request) key = (prefix, ip) import time now = time.monotonic() async with _rl_lock: bucket = _rl_buckets[key] cutoff = now - window # Drop timestamps that fell out of the window. bucket[:] = [t for t in bucket if t > cutoff] if len(bucket) >= limit: retry_after = max(1, int(window - (now - bucket[0]))) resp = JSONResponse({"detail": "Rate limit exceeded"}, status_code=429) resp.headers["Retry-After"] = str(retry_after) return resp bucket.append(now) return await call_next(request) # Reject oversized request bodies before parsing — this app's largest # legitimate POST is a few hundred bytes. 1MB is generous. MAX_REQUEST_BODY = 1 * 1024 * 1024 @app.middleware("http") async def limit_body_size(request, call_next): cl = request.headers.get("content-length") if cl is not None: try: if int(cl) > MAX_REQUEST_BODY: return JSONResponse({"detail": "Request body too large"}, status_code=413) except ValueError: return JSONResponse({"detail": "Invalid Content-Length"}, status_code=400) return await call_next(request) # Set HTTP security headers on every response. Done as a single # middleware because we want one place to manage the CSP allowlist # as the UI evolves (e.g. adding Mapbox would require connect-src). @app.middleware("http") async def security_headers(request, call_next): response = await call_next(request) # MIME-sniffing protection — important for the PDF proxy. response.headers.setdefault("X-Content-Type-Options", "nosniff") # Clickjacking — also enforced by CSP frame-ancestors below. response.headers.setdefault("X-Frame-Options", "DENY") # Don't leak query-string PII (searched addresses, gush/חלקה) to # third parties via the Referer header. response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") # No need for the camera/microphone/geo APIs in this product. response.headers.setdefault( "Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=(), usb=()", ) # CSP — UI is React+Vite (no eval, no inline scripts at build time). # Tailwind injects style attributes at runtime, hence 'unsafe-inline' # for style-src. Heebo is now self-hosted via @fontsource so we no # longer need to allow Google Fonts. response.headers.setdefault( "Content-Security-Policy", "default-src 'self'; " "script-src 'self'; " "style-src 'self' 'unsafe-inline'; " "font-src 'self' data:; " "img-src 'self' data: blob:; " "connect-src 'self'; " "frame-ancestors 'none'; " "form-action 'self'; " "base-uri 'self'; " "object-src 'none'", ) # HSTS — only meaningful behind HTTPS, but our Traefik enforces it # in both dev and prod, so always set. response.headers.setdefault( "Strict-Transport-Security", "max-age=31536000; includeSubDomains" ) # Drop the server-version banner — MutableHeaders has no .pop(), # use the mapping protocol instead. if "server" in response.headers: del response.headers["server"] # Cache hashed asset bundles aggressively (Vite already adds a hash # to filenames so cache invalidation is a build problem, not a # runtime one). Everything else: don't cache. path = request.url.path if path.startswith("/assets/"): response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable") elif path == "/" or path.endswith(".html"): response.headers.setdefault("Cache-Control", "no-cache, no-store, must-revalidate") return response # ── Health ──────────────────────────────────────────────────────── @app.get("/api/health") @app.get("/health") async def health() -> dict[str, str]: return {"status": "ok", "service": "nadlan-mcp"} # ── /api/search/address ─────────────────────────────────────────── @app.get("/api/search/address") async def search_address(q: str = Query(..., min_length=2, max_length=120)): """Autocomplete an Israeli address. Also returns coordinates and (when available) the gush/חלקה for the top match.""" try: response = govmap_client.autocomplete_address(q) except Exception: logger.exception("autocomplete_address failed") raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR) results = [] for r in response.results: item: dict[str, Any] = { "text": r.text, "type": r.type, "score": r.score, "id": r.id, } if r.coordinates: item["coordinates"] = { "longitude": r.coordinates.longitude, "latitude": r.coordinates.latitude, } results.append(item) # Best-effort gush/חלקה for the top result. Use PARCEL_ALL layer # explicitly — the default layer 16 in get_gush_helka is `nadlan` # (deals) which has null gush/parcel for many addresses. gush_helka = None if response.results and response.results[0].coordinates: try: coords = response.results[0].coordinates gh = _query_parcel_layer(govmap_client, coords.longitude, coords.latitude) gush_helka = _extract_gush_helka(gh) except Exception: # Don't log the user-supplied query — it's PII (address) # and goes to whoever has access to the container logs. logger.warning("parcel lookup failed (query length=%d)", len(q)) return {"query": q, "results": results, "gush_helka": gush_helka} # ── /api/search/deals ───────────────────────────────────────────── @app.post("/api/search/deals") async def search_deals(req: DealsSearchRequest): """Find recent real-estate deals around an address (Govmap).""" try: deals = govmap_client.find_recent_deals_for_address( req.address, req.years_back, req.radius_meters, req.max_deals, req.deal_type, ) except Exception: logger.exception("find_recent_deals_for_address failed") raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR) # Search-center coords + resolved address text for the UI (best-effort). search_coords = None resolved_address = None try: ar = govmap_client.autocomplete_address(req.address) if ar.results and ar.results[0].coordinates: c = ar.results[0].coordinates search_coords = {"longitude": c.longitude, "latitude": c.latitude} resolved_address = ar.results[0].text except Exception: pass # Strip bloat to keep payload small for the UI. bloat = { "shape", "sourceorder", "objectid", "priority", "source_polygon_id", "settlementId", "streetCode", "dealId", "polygonId", } deal_dicts = [] for idx, deal in enumerate(deals, start=1): d = deal.model_dump(mode="json", exclude_none=True) d = {k: v for k, v in d.items() if k not in bloat} d["id"] = idx d["deal_source"] = getattr(deal, "deal_source", None) deal_dicts.append(d) return { "search": { "address": req.address, "resolved_address": resolved_address, "coordinates": search_coords, "years_back": req.years_back, "radius_meters": req.radius_meters, "deal_type": req.deal_type, }, "total": len(deal_dicts), "deals": deal_dicts, } # ── /api/search/appraisals ──────────────────────────────────────── @app.post("/api/search/appraisals") async def search_appraisals(req: AppraisalsSearchRequest): """Search decisive-appraiser decisions (gov.il / Ministry of Justice). If `include_nearby=True` and a block is provided, also runs follow-up searches for the same block (different plots) and for adjacent blocks, returned in separate buckets. The buckets are mutually exclusive (deduped by upstream decision id). """ def _run(block: str | None, plot: str | None) -> Any: return decisive_appraiser_client.search_decisions_paged( max_results=req.max_results, block=block, plot=plot, decisive_appraiser=req.decisive_appraiser, committee=req.committee, decision_date_from=req.decision_date_from, decision_date_to=req.decision_date_to, publicity_date_from=req.publicity_date_from, publicity_date_to=req.publicity_date_to, search_text=req.search_text, appraisal_header=req.appraisal_header, ) try: exact_response = _run(req.block, req.plot) except Exception: logger.exception("search_decisions_paged (exact) failed") raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR) # Dedupe across buckets by a composite key (gov.il doesn't expose a # single stable id we can rely on). seen_keys: set[tuple] = set() def _serialize(decisions_iter: Any, start_idx: int = 1) -> list[dict]: out: list[dict] = [] running = start_idx for decision in decisions_iter: key = ( decision.appraisal_header, decision.decision_date, decision.decisive_appraiser, decision.block, decision.plot, ) if key in seen_keys: continue seen_keys.add(key) d = decision.model_dump(mode="json", exclude_none=True) documents = d.pop("documents", []) or [] d["id"] = running d["pdf_url"] = documents[0]["file_url"] if documents else None d["all_documents"] = documents out.append(d) running += 1 return out exact_decisions = _serialize(exact_response.results) same_block_decisions: list[dict] = [] nearby_block_decisions: list[dict] = [] if req.include_nearby and req.block: # Same block, different plots — only meaningful if a plot was given; # otherwise the exact search already covers the whole block. if req.plot: try: sb = _run(req.block, None) same_block_decisions = _serialize( sb.results, start_idx=len(exact_decisions) + 1 ) except Exception: logger.exception("same-block appraisals lookup failed") # Neighbouring blocks (numeric ±radius). Israeli cadastral blocks # are usually sequential within a city, so this is a reasonable # heuristic for "nearby parcels" without needing a spatial query. try: block_int = int(req.block) except ValueError: block_int = None if block_int is not None: base_idx = len(exact_decisions) + len(same_block_decisions) + 1 deltas = list(range(1, req.nearby_block_radius + 1)) ordered_neighbours = [] for delta in deltas: ordered_neighbours.extend([block_int - delta, block_int + delta]) for nb in ordered_neighbours: if nb <= 0: continue try: nbr = _run(str(nb), None) chunk = _serialize(nbr.results, start_idx=base_idx) nearby_block_decisions.extend(chunk) base_idx += len(chunk) except Exception: logger.exception(f"nearby block {nb} lookup failed") return { "total_in_db": exact_response.total_results, "returned": len(exact_decisions), "filters": req.model_dump(exclude_none=True), "decisions": exact_decisions, "same_block": { "count": len(same_block_decisions), "decisions": same_block_decisions, }, "nearby_blocks": { "count": len(nearby_block_decisions), "decisions": nearby_block_decisions, }, } # ── /api/appraisals/pdf ─────────────────────────────────────────── @app.get("/api/appraisals/pdf") async def proxy_appraisal_pdf(url: str = Query(..., min_length=20, max_length=2048)): """ Stream a decisive-appraiser PDF through this server. Required because the upstream PDF host demands the same x-client-id header as the search API; a normal browser fetch would fail. """ from urllib.parse import urlparse # ── URL validation (C-1: defeat userinfo-bypass SSRF) ─────────── # urlparse(...).hostname returns the value after the optional # `user:pass@` userinfo, so a URL like # https://attacker.com@pub-justice.openapi.gov.il/x.pdf # has hostname == pub-justice.openapi.gov.il *and* would be fetched # from attacker.com by curl. Reject any URL with userinfo, and any # URL whose port differs from the default for its scheme. try: parsed = urlparse(url) except ValueError: raise HTTPException(status_code=400, detail="Malformed URL") if parsed.scheme != "https": raise HTTPException(status_code=400, detail="Only https URLs are allowed") if parsed.hostname not in ALLOWED_PDF_HOSTS: raise HTTPException(status_code=400, detail="URL host not allowed") if parsed.username is not None or parsed.password is not None: raise HTTPException(status_code=400, detail="URL must not contain userinfo") if parsed.port not in (None, 443): raise HTTPException(status_code=400, detail="URL port not allowed") # Final sanity: the netloc the URL library will dial must be exactly # the hostname (no leftover @ or port). This catches edge cases the # individual checks above miss. if parsed.netloc.lower() != parsed.hostname.lower(): raise HTTPException(status_code=400, detail="URL netloc mismatch") try: # Reuse the curl_cffi session that already carries x-client-id. # stream=True so we read in chunks rather than buffering the # whole body in memory (H-4: defeat OOM via large upstream). response = decisive_appraiser_client._session.get( url, timeout=( decisive_appraiser_client.config.connect_timeout, decisive_appraiser_client.config.read_timeout * 4, ), headers={"Accept": "application/pdf,*/*"}, stream=True, allow_redirects=False, ) except Exception: logger.exception("PDF proxy upstream call failed") raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR) if response.status_code != 200: with contextlib.suppress(Exception): response.close() raise HTTPException( status_code=502, detail=f"Upstream returned {response.status_code}", ) # Refuse before reading anything if upstream advertises an oversized body. try: advertised = int(response.headers.get("Content-Length", "0") or 0) except ValueError: advertised = 0 if advertised > MAX_PDF_SIZE: with contextlib.suppress(Exception): response.close() raise HTTPException(status_code=413, detail="Upstream PDF too large") # Suggest a reasonable filename for the browser. filename = parsed.path.rstrip("/").split("/")[-1] or "appraisal" if not filename.lower().endswith(".pdf"): filename = f"{filename}.pdf" # Peek the first chunk synchronously so we can verify the PDF # magic header before the StreamingResponse takes over (we can't # raise HTTPException from inside the generator). chunks = response.iter_content(chunk_size=8192) try: first_chunk = next(chunks) except StopIteration: with contextlib.suppress(Exception): response.close() raise HTTPException(status_code=502, detail="Empty upstream response") except Exception: logger.exception("PDF proxy: upstream stream failed") with contextlib.suppress(Exception): response.close() raise HTTPException(status_code=502, detail=GENERIC_UPSTREAM_ERROR) if not first_chunk.startswith(b"%PDF"): with contextlib.suppress(Exception): response.close() raise HTTPException(status_code=502, detail="Upstream did not return a PDF") def _iter() -> Any: seen = len(first_chunk) yield first_chunk try: for chunk in chunks: if not chunk: continue seen += len(chunk) if seen > MAX_PDF_SIZE: logger.warning( f"PDF proxy: upstream exceeded {MAX_PDF_SIZE} bytes; truncating" ) return yield chunk finally: with contextlib.suppress(Exception): response.close() return StreamingResponse( _iter(), media_type="application/pdf", headers={ "Content-Disposition": f'inline; filename="{filename}"', "Cache-Control": "private, max-age=3600", }, ) # ── /mcp/ → FastMCP ─────────────────────────────────────────────── app.mount("/mcp", mcp_app) # ── / → React static (must be LAST) ─────────────────────────────── web_dist = Path(__file__).resolve().parent.parent / "web" / "dist" if web_dist.is_dir(): app.mount( "/assets", StaticFiles(directory=web_dist / "assets"), name="assets", ) @app.get("/{full_path:path}", include_in_schema=False) async def spa_fallback(full_path: str): # Unknown paths fall back to index.html so React Router can take over. index = web_dist / "index.html" if index.is_file(): return FileResponse(index) return JSONResponse({"detail": "UI not built"}, status_code=404) else: logger.warning( f"Web build directory not found at {web_dist}; UI will not be served. " f"Run `cd web && pnpm build` to populate it." ) @app.get("/", include_in_schema=False) async def no_ui(): return JSONResponse( { "service": "nadlan-mcp", "ui": "not built", "api_docs": "/api/docs", "mcp": "/mcp", } ) return app # ────────────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────────────── def _query_parcel_layer(client: Any, longitude: float, latitude: float) -> dict[str, Any]: """ Query Govmap's PARCEL_ALL layer at a point. Returns the raw JSON. The default `client.get_gush_helka` queries layer 16 (`nadlan`), which has null gush/parcel for many address points. PARCEL_ALL is the cadastral layer that reliably exposes them. """ url = f"{client.base_url}/layers-catalog/entitiesByPoint" payload = { "point": [longitude, latitude], "layers": [{"layerId": "PARCEL_ALL"}], "tolerance": 5, } timeout = (client.config.connect_timeout, client.config.read_timeout) response = client.session.post(url, json=payload, timeout=timeout) response.raise_for_status() return response.json() def _extract_gush_helka(govmap_response: Any) -> dict[str, Any] | None: """ Pull the (gush, helka) pair out of the layered Govmap response. Real shape (layerId 16 / nadlan layer): {"data": [{"name": "...", "entities": [ {"fields": [{"fieldName": "גוש", "fieldValue": "6212"}, {"fieldName": "חלקה", "fieldValue": "894"}, ...]} ]}]} Values can be null when the layer has no data at that point — we skip those and keep looking through every entity in every layer. """ if not isinstance(govmap_response, dict): return None block: str | None = None plot: str | None = None for layer in govmap_response.get("data") or []: if not isinstance(layer, dict): continue for entity in layer.get("entities") or []: if not isinstance(entity, dict): continue for f in entity.get("fields") or []: name = (f.get("fieldName") or "").strip() value = f.get("fieldValue") if value is None: continue value = str(value).strip() if not value: continue if ( name in ("גוש", "מספר גוש", "gush", "block", "GUSH_NUM", "gush_num") and not block ): block = value elif ( name in ("חלקה", "מספר חלקה", "helka", "parcel", "PARCEL", "PARCEL_NUM") and not plot ): plot = value if block and plot: return {"block": block, "plot": plot} if block or plot: return {"block": block, "plot": plot} return None