"""ГИСОГД-СО WFS-клиент — функциональные зоны генплана ЕКБ-2045 (#1058). Источник: ``gisogd66.midural.ru`` — региональная ГИС органов государственной власти Свердловской области. Открытый WFS без авторизации. Два метода: ``resolve_functional_zone_layer()`` — резолвит актуальное typeName слоя «Функциональные зоны» из каталога слоёв. Суффикс-хеш в имени слоя меняется при обновлении генплана, поэтому хардкодить нельзя — резолвим динамически. Fallback-хардкод если каталог недоступен. ``fetch_functional_zones()`` — WFS GetFeature → список feature-dict'ов GeoJSON (FeatureCollection), ~6436 фич, Polygon, EPSG:4326. TLS: midural.ru использует российский CA (по аналогии с #242 ekburg_permits). При SSLError используем verify=False с explicit-комментом — без этого соединение рвётся на prod VPS. """ from __future__ import annotations import logging from typing import Any import httpx logger = logging.getLogger(__name__) # ── Константы ───────────────────────────────────────────────────────────────── GISOGD_BASE = "https://gisogd66.midural.ru" CATALOG_URL = f"{GISOGD_BASE}/api/catalogs/agate_ekbgo" WFS_URL = f"{GISOGD_BASE}/map/tiles/agate_ekbgo/wfs" # Fallback-хардкод если каталог недоступен (последний известный хеш, 2026-06-07). _LAYER_FALLBACK = "agate_ekbgo:agate_ekbgo_geo_urban_10_functional_zone_f6c5926" # Имя узла в каталоге (case-insensitive поиск). _CATALOG_NODE_NAME = "Функциональные зоны" # Параметры запроса к WFS. _WFS_OUTPUT_FORMAT = "application/json" _WFS_VERSION = "2.0.0" # Щедрый таймаут — ответ ~6436 фич (полный слой одним запросом, ~несколько МБ). _WFS_TIMEOUT = 120 _CATALOG_TIMEOUT = 20 # Размер страницы для постраничного fallback (если целиком не забирается). _PAGE_SIZE = 2000 # ── Сетевые helpers (module-level — мокаются в тестах) ─────────────────────── def _http_get_json(url: str, params: dict[str, str] | None = None, *, timeout: int) -> Any: """GET → JSON. verify=False: midural.ru — российский CA (аналог #242 ekburg_permits). Сначала пробуем verify=True; при SSLError fallback на verify=False с предупреждением. """ try: resp = httpx.get(url, params=params, timeout=timeout, verify=True) resp.raise_for_status() return resp.json() except httpx.ConnectError as exc: # SSLError обёрнут в ConnectError у httpx — переключаемся на verify=False. logger.warning( "gisogd_so_client: SSL verify=True failed (%s), retry with verify=False", exc ) resp = httpx.get(url, params=params, timeout=timeout, verify=False) resp.raise_for_status() return resp.json() # ── Каталог → резолв слоя ──────────────────────────────────────────────────── def _find_layer_in_tree(node: Any) -> str | None: """Рекурсивный обход JSON-дерева каталога. Ищет узел с name==_CATALOG_NODE_NAME. Возвращает ``value`` найденного узла (typeName слоя) или None. """ if not isinstance(node, dict): return None name = node.get("name") or "" if name.strip() == _CATALOG_NODE_NAME: value = node.get("value") return str(value) if value else None # Рекурсивно по children (разные ключи в каталоге: children / items / layers). for key in ("children", "items", "layers", "nodes"): children = node.get(key) if isinstance(children, list): for child in children: found = _find_layer_in_tree(child) if found: return found return None def resolve_functional_zone_layer() -> str: """Резолвит actуальное typeName слоя «Функциональные зоны» из каталога ГИСОГД-СО. Суффикс-хеш в имени слоя меняется при обновлении генплана → не хардкодить. При недоступности каталога возвращает fallback-хардкод. Returns: typeName вида ``agate_ekbgo:agate_ekbgo_geo_urban_10_functional_zone_``. """ try: catalog = _http_get_json(CATALOG_URL, timeout=_CATALOG_TIMEOUT) except Exception as exc: logger.warning( "gisogd_so_client: каталог недоступен (%s), использую fallback layer=%s", exc, _LAYER_FALLBACK, ) return _LAYER_FALLBACK layer = _find_layer_in_tree(catalog) if layer: logger.info("gisogd_so_client: резолвлен слой из каталога: %s", layer) return layer logger.warning( "gisogd_so_client: узел '%s' не найден в каталоге, использую fallback layer=%s", _CATALOG_NODE_NAME, _LAYER_FALLBACK, ) return _LAYER_FALLBACK # ── WFS GetFeature → список фич ────────────────────────────────────────────── def _wfs_params(type_name: str, start_index: int = 0, count: int | None = None) -> dict[str, str]: """Строит параметры WFS GetFeature-запроса.""" params: dict[str, str] = { "service": "WFS", "version": _WFS_VERSION, "request": "GetFeature", "typeNames": type_name, "outputFormat": _WFS_OUTPUT_FORMAT, } if count is not None: params["count"] = str(count) if start_index > 0: params["startIndex"] = str(start_index) return params def fetch_functional_zones(type_name: str | None = None) -> list[dict[str, Any]]: """Загружает все фичи слоя функциональных зон из ГИСОГД-СО WFS. Сначала пробует забрать весь слой одним запросом (~6436 фич). При таймауте автоматически переключается на постраничный режим (count=_PAGE_SIZE). Args: type_name: typeName WFS-слоя. None → вызывает resolve_functional_zone_layer(). Returns: Список feature-dict'ов (GeoJSON Feature): ``{"geometry": ..., "properties": ...}``. Пусто если WFS недоступен или вернул 0 фич. """ if type_name is None: type_name = resolve_functional_zone_layer() # Попытка 1: весь слой одним запросом. try: data = _http_get_json(WFS_URL, _wfs_params(type_name), timeout=_WFS_TIMEOUT) features: list[dict[str, Any]] = (data or {}).get("features") or [] if features: logger.info("gisogd_so_client: WFS загружено фич=%d (один запрос)", len(features)) return features except httpx.TimeoutException: logger.warning("gisogd_so_client: WFS таймаут одним запросом, переключаюсь на страницы") except Exception as exc: logger.error("gisogd_so_client: WFS ошибка одним запросом: %s", exc) return [] # Попытка 2: постраничная загрузка. features = [] start = 0 while True: try: data = _http_get_json( WFS_URL, _wfs_params(type_name, start_index=start, count=_PAGE_SIZE), timeout=_WFS_TIMEOUT, ) except Exception as exc: logger.error("gisogd_so_client: WFS постраничная ошибка start=%d: %s", start, exc) break page: list[dict[str, Any]] = (data or {}).get("features") or [] if not page: break features.extend(page) logger.info( "gisogd_so_client: WFS страница start=%d, фич на странице=%d, итого=%d", start, len(page), len(features), ) if len(page) < _PAGE_SIZE: break start += _PAGE_SIZE logger.info("gisogd_so_client: WFS итого загружено фич=%d (постраничная)", len(features)) return features __all__ = [ "CATALOG_URL", "WFS_URL", "fetch_functional_zones", "resolve_functional_zone_layer", ]