"""ЕКБ муниципальный геопортал (ИСОГД / Geometa) — WFS-клиент (#1067 C7). Геопортал Екатеринбурга (`геопортал.екатеринбург.рф`) — открытый GeoServer на ~1149 слоёв, без авторизации, нормальный TLS. Единый источник «что построят / землепользование» для ЕКБ: ПЗЗ-регламент, функц.зоны генплана, КРТ, ППТ/ПМТ, ЗОУИТ — то, чего НСПД (только геометрия + reg-номер) и городские документы по-отдельности не дают структурно. Primary интерфейс — **стандартный OGC WFS GetFeature** (`/api/gis/ows/3/portal/wfs`): OGC-стандарт стабильнее reverse-engineered `urbanCard`-API и покрывает все слои одним вызовом. ⚠️ WMS GetFeatureInfo на этом GeoServer багует на reproject («too close to a pole») — использовать ТОЛЬКО WFS GetFeature. CRS: WFS отдаёт `geoloc` (геометрия) в EPSG:4326 (lon/lat). В CQL `POINT(lon lat)` порядок X=lon, Y=lat. `geoloc_orig` (МСК-66) НЕ запрашиваем. Источник разведки (live-verified 2026-06-06): vault research/EKB_Geoportal_WFS_Construction_Intel_Jun06. Рич ПЗЗ-карточка (3 списка ВРИ + предельные параметры) — через urbanCard (#1067 C8): searchByGeom → urbanCard → parse_zone_regulation (см. research/EKB_Geoportal_PZZ_Content_API_Jun06). """ from __future__ import annotations import json import logging from dataclasses import dataclass from typing import Any import httpx logger = logging.getLogger(__name__) # Punycode-хост геопортала (= геопортал.екатеринбург.рф). # В коде только punycode — кириллица в URL ломает часть HTTP-стеков/логов. EKB_GEOPORTAL_BASE = "https://xn--80afgznagjs.xn--80acgfbsl1azdqr.xn--p1ai" EKB_WFS_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/ows/3/portal/wfs" # Reverse-eng JSON-API карточки территории (рич ПЗЗ-регламент). Менее стабилен чем WFS (OGC) — # поэтому supplement к WFS, а не primary. Подтверждено live 2026-06-06 (vault # research/EKB_Geoportal_PZZ_Content_API_Jun06): searchByGeom (точка → ключи объектов по слоям), # urbanCard (ключи → полный градрегламент: 3 списка ВРИ 540н + предельные параметры). EKB_SEARCH_BY_GEOM_URL = f"{EKB_GEOPORTAL_BASE}/api/gis/spatialObject/searchByGeom" EKB_URBAN_CARD_URL = f"{EKB_GEOPORTAL_BASE}/api/urbanCard/portal" # layerKeys searchByGeom (внутренние числовые id портала, НЕ WFS typeNames): # 17041 терзона ПЗЗ · 10019 участок ЕГРН · 16619 ЗОУИТ · 10079 кадастр.деление. Ответ: # {layerKey: [{typeAlias, key, ...}]} — typeAlias UrbanTerZone/GknParcel/SpecialZone. _SEARCH_LAYER_KEYS: tuple[int, ...] = (10079, 17041, 10019, 16619) # Геометрическая колонка GeoServer-слоёв портала (таргет CQL-фильтра). GEOM_COLUMN = "geoloc" # Читаемый ключ → GeoServer typeName. Проверены live WFS GetFeature (vault recon 2026-06-06). # Полный каталог — 1149 typeNames; тут только релевантные ИРД/supply-слои. WFS_LAYERS: dict[str, str] = { # Зонирование / регламент (ИРД п.2 ПЗЗ, capacity) "territorial_zone": "portal:portal_geo_urban_ter_zone_exist", # ПЗЗ терзоны (urban_index) "subzone": "portal:portal_geo_subzone_exist", # ПЗЗ подзоны "functional_zone": "portal:portal_geo_urban_10_functional_zone", # функц.зоны ГП (#1058) "genplan": "portal:portal_geo_dmd_genplan", # генплан (документ-слой) # Future supply / что построят "krt": "portal:portal_geo_oks_krt", # КРТ объекты/границы (ИРД #1060) "ppt": "portal:portal_geo_dmd_prj_plan", # проекты планировки территории "pmt": "portal:portal_geo_dmd_prj_mej", # проекты межевания территории "investment_zone": "portal:portal_geo_urban_10_investment_zone_polygon", # инвест-зоны "draft_parcel": "portal:portal_geo_art_zu_draft", # проектируемые/черновые ЗУ # Ограничения "zouit": "portal:portal_geo_gknspecial_zone", # ЗОУИТ } DEFAULT_TIMEOUT = 20 # Геопортал ЕКБ отдаёт TLS-цепочку, корневой CA которой НЕ в trust-store наших контейнеров # (российский гос-УЦ, как у НСПД). С verify=True из контейнера падает CERTIFICATE_VERIFY_FAILED # (подтверждено live на проде 2026-06-06). Отключаем верификацию — данные ПУБЛИЧНЫЕ open-data # (ПЗЗ/ППТ/КРТ), секреты не передаются → MITM-риск незначим. Прецедент: nspd_lite._SSL_CTX = # ssl._create_unverified_context() для тех же росс-гос-эндпоинтов. _VERIFY_TLS = False # ── HTTP helper (module-level — мокается в тестах) ─────────────────────────── def _http_get_json(url: str, params: dict[str, str], *, timeout: int = DEFAULT_TIMEOUT) -> Any: """GET WFS-эндпоинта → JSON. Вынесен на module-level для monkeypatch в тестах. verify=_VERIFY_TLS (False): росс-гос-CA геопортала не в trust-store контейнера (см. выше). Raises httpx.HTTPStatusError на 4xx/5xx (caller решает). """ resp = httpx.get(url, params=params, timeout=timeout, verify=_VERIFY_TLS) resp.raise_for_status() return resp.json() def _http_post_json(url: str, body: dict[str, Any], *, timeout: int = DEFAULT_TIMEOUT) -> Any: """POST JSON → JSON (searchByGeom / urbanCard). Module-level для monkeypatch в тестах. verify=_VERIFY_TLS (False): см. _http_get_json — росс-гос-CA не в trust-store контейнера. """ resp = httpx.post(url, json=body, timeout=timeout, verify=_VERIFY_TLS) resp.raise_for_status() return resp.json() # ── Typed response ─────────────────────────────────────────────────────────── @dataclass(frozen=True, slots=True) class EKBFeature: """Парсенный WFS GeoJSON Feature геопортала ЕКБ. `geometry` — raw GeoJSON dict в EPSG:4326 (lon/lat). `properties` — все атрибуты слоя (для терзоны: urban_index/full_name/urban_kind_obj; для КРТ: num_oks_krt/status/type/area; для ППТ: doc_full_name/doc_status/dmd_actual_year). """ feature_id: str | None geometry: dict[str, Any] | None properties: dict[str, Any] crs: str = "EPSG:4326" @classmethod def from_raw(cls, raw: dict[str, Any]) -> EKBFeature: return cls( feature_id=str(raw.get("id")) if raw.get("id") is not None else None, geometry=raw.get("geometry"), properties=raw.get("properties") or {}, ) @dataclass(frozen=True, slots=True) class ZoneRegulation: """Градрегламент ПЗЗ-зоны из urbanCard (#1067 C8) — ИРД-content для #1059. `zone_index` — индекс зоны ('Ц-1'); `zone_full_name` — полное имя ('Ц-1 Общественно-деловая зона городского центра'). ВРИ — строки «код 540н + текст» (напр. '2.6 Среднеэтажная жилая застройка'). `limit_params` — предельные параметры свободным текстом (min площадь ЗУ / max % застройки / этажность / отступы — нуждается в regex/LLM-экстракте в числовые поля, #1067 далее). """ zone_index: str | None zone_full_name: str | None main_vri: list[str] conditional_vri: list[str] auxiliary_vri: list[str] limit_params: list[str] def _vri_names(items: Any) -> list[str]: """Извлечь непустые .name из списка *TypesPermittedUse.""" out: list[str] = [] for it in items or []: name = (it or {}).get("name") if name: out.append(str(name).strip()) return out def parse_zone_regulation(card: dict[str, Any]) -> ZoneRegulation | None: """Парс ответа urbanCard → ZoneRegulation (берёт первый pzzList). None если в карточке нет pzzList (участок без ПЗЗ-регламента). Shape подтверждён live 2026-06-06 (vault research/EKB_Geoportal_PZZ_Content_API_Jun06). """ pzz_list = (card or {}).get("pzzList") or [] if not pzz_list: return None pzz = pzz_list[0] or {} full = pzz.get("urbanTerZone") full_name = str(full).strip() if full else None index = full_name.split()[0] if full_name else None limit_params: list[str] = [] for limit in pzz.get("limitTypesPermittedUse") or []: for par in (limit or {}).get("typePermittedUsePars") or []: desc = (par or {}).get("otherDescription") if desc and str(desc).strip(): limit_params.append(str(desc).strip()) return ZoneRegulation( zone_index=index, zone_full_name=full_name, main_vri=_vri_names(pzz.get("mainTypesPermittedUse")), conditional_vri=_vri_names(pzz.get("conditionTypesPermittedUse")), auxiliary_vri=_vri_names(pzz.get("additionTypesPermittedUse")), limit_params=limit_params, ) # ── Client ─────────────────────────────────────────────────────────────────── class EKBGeoportalClient: """WFS-клиент геопортала ЕКБ. Stateless; один instance на процесс. Методы возвращают list[EKBFeature]; пусто если по фильтру ничего не найдено. """ def __init__(self, timeout: int = DEFAULT_TIMEOUT) -> None: self.timeout = timeout def _resolve_type_name(self, layer: str) -> str: """Читаемый ключ WFS_LAYERS → typeName; либо сырой typeName (`portal:...`) as-is.""" return WFS_LAYERS.get(layer, layer) def _get_feature(self, type_name: str, cql_filter: str) -> list[EKBFeature]: """WFS 2.0.0 GetFeature с CQL_FILTER → typed features (EPSG:4326).""" params = { "service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": type_name, "outputFormat": "application/json", "srsName": "EPSG:4326", "CQL_FILTER": cql_filter, } data = _http_get_json(EKB_WFS_URL, params, timeout=self.timeout) feats = (data or {}).get("features") or [] return [EKBFeature.from_raw(f) for f in feats] def features_at_point(self, layer: str, lon: float, lat: float) -> list[EKBFeature]: """Фичи слоя, покрывающие точку (lon, lat) — CQL INTERSECTS. Порядок координат в CQL POINT — X=lon, Y=lat (EPSG:4326). Args: layer: ключ WFS_LAYERS ('territorial_zone', 'krt', ...) или сырой typeName. """ type_name = self._resolve_type_name(layer) cql = f"INTERSECTS({GEOM_COLUMN}, POINT({lon} {lat}))" return self._get_feature(type_name, cql) def features_in_bbox( self, layer: str, bbox_4326: tuple[float, float, float, float] ) -> list[EKBFeature]: """Фичи слоя в bbox (minlon, minlat, maxlon, maxlat) EPSG:4326 — CQL BBOX.""" minx, miny, maxx, maxy = bbox_4326 type_name = self._resolve_type_name(layer) cql = f"BBOX({GEOM_COLUMN}, {minx}, {miny}, {maxx}, {maxy}, 'EPSG:4326')" return self._get_feature(type_name, cql) def zone_index_at(self, lon: float, lat: float) -> str | None: """Удобный shortcut: индекс ПЗЗ-терзоны ('Ц-1', 'Ж-2') в точке, либо None. Закрывает crosswalk «координата → индекс зоны ЕКБ» для резолва ИРД-content (#1059): НСПД даёт геометрию зоны без индекса, геопортал — индекс по точке. """ feats = self.features_at_point("territorial_zone", lon, lat) if not feats: return None idx = feats[0].properties.get("urban_index") return str(idx) if idx is not None else None # ── urbanCard (рич ПЗЗ-регламент, supplement к WFS) ───────────────────── def _search_keys(self, lon: float, lat: float) -> dict[str, int]: """searchByGeom: точка → {typeAlias: key} (UrbanTerZone / GknParcel / SpecialZone).""" body = { "project": "portal", "geoJsonGeometries": [json.dumps({"type": "Point", "coordinates": [lon, lat]})], "layerKeys": list(_SEARCH_LAYER_KEYS), "countByLayers": [], } data = _http_post_json(EKB_SEARCH_BY_GEOM_URL, body, timeout=self.timeout) keys: dict[str, int] = {} if isinstance(data, dict): for objs in data.values(): for o in objs or []: alias = (o or {}).get("typeAlias") key = (o or {}).get("key") if alias and key is not None: keys[str(alias)] = int(key) return keys def _urban_card(self, terzone_key: int | None, parcel_key: int | None) -> dict[str, Any]: """urbanCard: ключи терзоны/участка → полная карточка территории (с pzzList).""" body = { "construction": [], "adrCapitalBuilding": [], "pzz": [terzone_key] if terzone_key is not None else [], "parcel": [parcel_key] if parcel_key is not None else [], "urbanOtherAreaPZZ": [], "urbanProtAreaPZZ": [], "urbanSanGapPZZ": [], } return _http_post_json(EKB_URBAN_CARD_URL, body, timeout=self.timeout) def zone_regulation_at(self, lon: float, lat: float) -> ZoneRegulation | None: """Полный градрегламент ПЗЗ-зоны в точке (#1059 content): индекс + 3 списка ВРИ + предельные параметры. None если в точке нет терзоны/регламента. Pipeline: searchByGeom (точка → UrbanTerZone+GknParcel ключи) → urbanCard → parse. Кэшировать вызывающему по zone_index (регламент идентичен для всех участков зоны). """ keys = self._search_keys(lon, lat) terzone_key = keys.get("UrbanTerZone") if terzone_key is None: return None card = self._urban_card(terzone_key, keys.get("GknParcel")) return parse_zone_regulation(card) def features_by_cql(self, layer: str, cql_filter: str) -> list[EKBFeature]: """Фичи слоя по произвольному CQL-фильтру (напр. ``full_name LIKE 'X%'``). Тонкая обёртка над ``_resolve_type_name`` + ``_get_feature``. Используется для harvest ЗОУИТ-слоя по префиксу full_name (#1063/#1064/#1065/#1066). Args: layer: ключ WFS_LAYERS ('zouit', ...) или сырой typeName. cql_filter: CQL-выражение (WFS 2.0 OGC CQL). Кириллица в одиночных LIKE-выражениях работает. Составные AND с кириллицей могут давать HTTP-ошибку геопортала — фильтруй client-side. """ type_name = self._resolve_type_name(layer) return self._get_feature(type_name, cql_filter) __all__ = [ "EKB_GEOPORTAL_BASE", "EKB_WFS_URL", "WFS_LAYERS", "EKBFeature", "EKBGeoportalClient", "ZoneRegulation", "parse_zone_regulation", ]