From f6b3b77e117a088c7fda976669ca6a3d5e4470ca Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 6 Jun 2026 21:32:15 +0300 Subject: [PATCH] feat(sf): EKB geoportal WFS client (#1067 C7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Клиент муниципального геопортала ЕКБ (ИСОГД/Geometa GeoServer, 1149 слоёв, public, без auth). Primary = OGC WFS GetFeature /api/gis/ows/3/portal/wfs (стабильнее reverse-eng urbanCard, один клиент ко всем слоям): ПЗЗ терзоны/подзоны, функц.зоны генплана (#1058), КРТ (#1060), ППТ/ПМТ, ЗОУИТ. features_at_point (CQL INTERSECTS), features_in_bbox (CQL BBOX), zone_index_at (crosswalk координата→индекс ПЗЗ для #1059). EPSG:4326 (X=lon Y=lat). НЕ GetFeatureInfo (reproject-баг GeoServer). Источник: vault research/EKB_Geoportal_WFS_Construction_Intel_Jun06. Refs #1067 (C7). Next: рич ПЗЗ-карточка urbanCard (C8), parcel-резолв (D9). --- .../services/scrapers/ekb_geoportal_client.py | 173 ++++++++++++++++++ backend/tests/test_ekb_geoportal_client.py | 120 ++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 backend/app/services/scrapers/ekb_geoportal_client.py create mode 100644 backend/tests/test_ekb_geoportal_client.py diff --git a/backend/app/services/scrapers/ekb_geoportal_client.py b/backend/app/services/scrapers/ekb_geoportal_client.py new file mode 100644 index 00000000..022471da --- /dev/null +++ b/backend/app/services/scrapers/ekb_geoportal_client.py @@ -0,0 +1,173 @@ +"""ЕКБ муниципальный геопортал (ИСОГД / 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 +(см. research/EKB_Geoportal_PZZ_Content_API_Jun06), здесь НЕ реализуется (follow-up). +""" + +from __future__ import annotations + +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" + +# Геометрическая колонка 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 + + +# ── 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 в тестах. + + Геопортал ЕКБ — нормальный TLS, без auth: обычный httpx без спец-обвязки (в отличие + от НСПД с нац-CA / WAF). Raises httpx.HTTPStatusError на 4xx/5xx (caller решает). + """ + resp = httpx.get(url, params=params, timeout=timeout) + 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 {}, + ) + + +# ── 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 + + +__all__ = [ + "EKB_GEOPORTAL_BASE", + "EKB_WFS_URL", + "WFS_LAYERS", + "EKBFeature", + "EKBGeoportalClient", +] diff --git a/backend/tests/test_ekb_geoportal_client.py b/backend/tests/test_ekb_geoportal_client.py new file mode 100644 index 00000000..85d3e57b --- /dev/null +++ b/backend/tests/test_ekb_geoportal_client.py @@ -0,0 +1,120 @@ +"""Тесты EKBGeoportalClient (#1067 C7) — WFS-клиент геопортала ЕКБ. + +Сеть не дёргается: мокаем module-level _http_get_json, проверяем построение CQL/params +и парсинг WFS GeoJSON. Образцы ответов — форма из live-разведки 2026-06-06 +(.playwright-mcp/ekb-direct-layers-proof.json). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from app.services.scrapers.ekb_geoportal_client import ( + WFS_LAYERS, + EKBFeature, + EKBGeoportalClient, +) + + +def _terzone_fc() -> dict[str, Any]: + """WFS FeatureCollection терзоны (форма как у live-ответа Ц-1).""" + return { + "type": "FeatureCollection", + "features": [ + { + "id": "urban_ter_zone.1", + "geometry": {"type": "Polygon", "coordinates": []}, + "properties": { + "urban_index": "Ц-1", + "full_name": "Ц-1 Общественно-деловая зона городского центра", + "urban_kind_obj": 42, + }, + } + ], + } + + +def test_features_at_point_builds_intersects_cql(monkeypatch: pytest.MonkeyPatch) -> None: + """features_at_point резолвит typeName + строит CQL INTERSECTS(geoloc, POINT(lon lat)).""" + captured: dict[str, Any] = {} + + def fake_http(url: str, params: dict[str, str], **kw: Any) -> dict[str, Any]: + captured["url"] = url + captured["params"] = params + return _terzone_fc() + + monkeypatch.setattr("app.services.scrapers.ekb_geoportal_client._http_get_json", fake_http) + feats = EKBGeoportalClient().features_at_point("territorial_zone", lon=60.6107, lat=56.8391) + + assert len(feats) == 1 + assert feats[0].properties["urban_index"] == "Ц-1" + assert feats[0].crs == "EPSG:4326" + # typeName резолвлен из WFS_LAYERS + assert captured["params"]["typeNames"] == "portal:portal_geo_urban_ter_zone_exist" + assert captured["params"]["request"] == "GetFeature" + assert captured["params"]["outputFormat"] == "application/json" + # CQL: X=lon, Y=lat + assert captured["params"]["CQL_FILTER"] == "INTERSECTS(geoloc, POINT(60.6107 56.8391))" + assert captured["url"].endswith("/api/gis/ows/3/portal/wfs") + + +def test_features_at_point_accepts_raw_type_name(monkeypatch: pytest.MonkeyPatch) -> None: + """Сырой typeName (не в WFS_LAYERS) прокидывается as-is.""" + captured: dict[str, Any] = {} + monkeypatch.setattr( + "app.services.scrapers.ekb_geoportal_client._http_get_json", + lambda url, params, **kw: captured.update(params) or {"features": []}, + ) + EKBGeoportalClient().features_at_point("portal:portal_geo_custom_layer", lon=60.0, lat=56.0) + assert captured["typeNames"] == "portal:portal_geo_custom_layer" + + +def test_features_in_bbox_builds_bbox_cql(monkeypatch: pytest.MonkeyPatch) -> None: + """features_in_bbox строит CQL BBOX с EPSG:4326.""" + captured: dict[str, Any] = {} + monkeypatch.setattr( + "app.services.scrapers.ekb_geoportal_client._http_get_json", + lambda url, params, **kw: captured.update(params) or _terzone_fc(), + ) + EKBGeoportalClient().features_in_bbox("krt", (60.5, 56.7, 60.7, 56.9)) + assert captured["typeNames"] == "portal:portal_geo_oks_krt" + assert captured["CQL_FILTER"] == "BBOX(geoloc, 60.5, 56.7, 60.7, 56.9, 'EPSG:4326')" + + +def test_zone_index_at_returns_index(monkeypatch: pytest.MonkeyPatch) -> None: + """zone_index_at вытаскивает urban_index терзоны в точке (crosswalk #1059).""" + monkeypatch.setattr( + "app.services.scrapers.ekb_geoportal_client._http_get_json", + lambda url, params, **kw: _terzone_fc(), + ) + assert EKBGeoportalClient().zone_index_at(60.6107, 56.8391) == "Ц-1" + + +def test_zone_index_at_none_when_empty(monkeypatch: pytest.MonkeyPatch) -> None: + """Нет терзоны в точке → None (не падает).""" + monkeypatch.setattr( + "app.services.scrapers.ekb_geoportal_client._http_get_json", + lambda url, params, **kw: {"type": "FeatureCollection", "features": []}, + ) + assert EKBGeoportalClient().zone_index_at(0.0, 0.0) is None + + +def test_ekb_feature_from_raw() -> None: + """EKBFeature.from_raw парсит id/geometry/properties; пустые → defaults.""" + f = EKBFeature.from_raw({"id": "x.1", "geometry": {"type": "Point"}, "properties": {"k": "v"}}) + assert f.feature_id == "x.1" + assert f.geometry == {"type": "Point"} + assert f.properties == {"k": "v"} + empty = EKBFeature.from_raw({}) + assert empty.feature_id is None + assert empty.geometry is None + assert empty.properties == {} + + +def test_wfs_layers_catalog_has_key_layers() -> None: + """Ключевые ИРД/supply-слои присутствуют в каталоге.""" + for key in ("territorial_zone", "functional_zone", "krt", "ppt", "zouit"): + assert key in WFS_LAYERS + assert WFS_LAYERS[key].startswith("portal:") -- 2.45.3