From 5f0bd817f73cd4377aa499b89c237e77f27c6a71 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 6 Jun 2026 21:59:38 +0300 Subject: [PATCH] =?UTF-8?q?feat(sf):=20EKB=20geoportal=20urbanCard=20?= =?UTF-8?q?=E2=80=94=20=D0=9F=D0=97=D0=97-=D1=80=D0=B5=D0=B3=D0=BB=D0=B0?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=20content=20(#1067=20C8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расширение ekb_geoportal_client: рич ПЗЗ-регламент (ИРД-content для #1059). searchByGeom (точка → ключи UrbanTerZone/GknParcel) → urbanCard (ключи → карточка) → parse_zone_regulation → ZoneRegulation {zone_index, zone_full_name, main/conditional/ auxiliary_vri (коды 540н), limit_params (предельные параметры текстом)}. Метод zone_regulation_at(lon,lat). Shape подтверждён live 2026-06-06 (zone Ц-1, 24 ВРИ). Кэшировать по zone_index (регламент идентичен для всех участков зоны) — C8b (таблица). Refs #1067 (C8). Тесты: 11 passed (parse + pipeline + empty/none ветки). --- .../services/scrapers/ekb_geoportal_client.py | 131 +++++++++++++++++- backend/tests/test_ekb_geoportal_client.py | 82 +++++++++++ 2 files changed, 211 insertions(+), 2 deletions(-) diff --git a/backend/app/services/scrapers/ekb_geoportal_client.py b/backend/app/services/scrapers/ekb_geoportal_client.py index 022471da..9ddeb183 100644 --- a/backend/app/services/scrapers/ekb_geoportal_client.py +++ b/backend/app/services/scrapers/ekb_geoportal_client.py @@ -16,12 +16,13 @@ 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). +Рич ПЗЗ-карточка (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 @@ -35,6 +36,17 @@ logger = logging.getLogger(__name__) 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" @@ -73,6 +85,13 @@ def _http_get_json(url: str, params: dict[str, str], *, timeout: int = DEFAULT_T 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 в тестах.""" + resp = httpx.post(url, json=body, timeout=timeout) + resp.raise_for_status() + return resp.json() + + # ── Typed response ─────────────────────────────────────────────────────────── @@ -99,6 +118,64 @@ class EKBFeature: ) +@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 ─────────────────────────────────────────────────────────────────── @@ -163,6 +240,54 @@ class EKBGeoportalClient: 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) + __all__ = [ "EKB_GEOPORTAL_BASE", @@ -170,4 +295,6 @@ __all__ = [ "WFS_LAYERS", "EKBFeature", "EKBGeoportalClient", + "ZoneRegulation", + "parse_zone_regulation", ] diff --git a/backend/tests/test_ekb_geoportal_client.py b/backend/tests/test_ekb_geoportal_client.py index 85d3e57b..73883e02 100644 --- a/backend/tests/test_ekb_geoportal_client.py +++ b/backend/tests/test_ekb_geoportal_client.py @@ -12,9 +12,12 @@ from typing import Any import pytest from app.services.scrapers.ekb_geoportal_client import ( + EKB_SEARCH_BY_GEOM_URL, + EKB_URBAN_CARD_URL, WFS_LAYERS, EKBFeature, EKBGeoportalClient, + parse_zone_regulation, ) @@ -118,3 +121,82 @@ def test_wfs_layers_catalog_has_key_layers() -> None: for key in ("territorial_zone", "functional_zone", "krt", "ppt", "zouit"): assert key in WFS_LAYERS assert WFS_LAYERS[key].startswith("portal:") + + +# ── urbanCard (рич ПЗЗ-регламент) ──────────────────────────────────────────── + + +def _card_fixture() -> dict[str, Any]: + """urbanCard ответ (форма live-ответа Ц-1, vault recon).""" + return { + "pzzList": [ + { + "urbanTerZone": "Ц-1 Общественно-деловая зона городского центра", + "mainTypesPermittedUse": [ + {"name": "2.6 Среднеэтажная жилая застройка", "restriction": ""}, + {"name": "4.1 Деловое управление", "restriction": ""}, + ], + "conditionTypesPermittedUse": [{"name": "4.9 Служебные гаражи"}], + "additionTypesPermittedUse": [{"name": "12.0.2 Благоустройство территории"}], + "limitTypesPermittedUse": [ + { + "typePermittedUsePars": [ + {"otherDescription": "минимальная площадь ЗУ - 1500 кв. м"}, + {"otherDescription": ""}, + ] + } + ], + } + ] + } + + +def test_parse_zone_regulation() -> None: + """parse_zone_regulation вытаскивает index + 3 списка ВРИ + предельные параметры.""" + reg = parse_zone_regulation(_card_fixture()) + assert reg is not None + assert reg.zone_index == "Ц-1" + assert reg.zone_full_name.startswith("Ц-1 Общественно-деловая") + assert reg.main_vri == ["2.6 Среднеэтажная жилая застройка", "4.1 Деловое управление"] + assert reg.conditional_vri == ["4.9 Служебные гаражи"] + assert reg.auxiliary_vri == ["12.0.2 Благоустройство территории"] + # пустой otherDescription отброшен → только непустой параметр + assert reg.limit_params == ["минимальная площадь ЗУ - 1500 кв. м"] + + +def test_parse_zone_regulation_empty() -> None: + """Нет pzzList → None (участок без регламента).""" + assert parse_zone_regulation({"pzzList": []}) is None + assert parse_zone_regulation({}) is None + + +def test_zone_regulation_at_pipeline(monkeypatch: pytest.MonkeyPatch) -> None: + """zone_regulation_at: searchByGeom → urbanCard → parse (мок по URL).""" + search_resp = { + "10019": [{"typeAlias": "GknParcel", "key": 111}], + "17041": [{"typeAlias": "UrbanTerZone", "key": 222}], + } + + def fake_post(url: str, body: dict[str, Any], **kw: Any) -> dict[str, Any]: + if url == EKB_SEARCH_BY_GEOM_URL: + return search_resp + if url == EKB_URBAN_CARD_URL: + assert body["pzz"] == [222] # терзона-ключ прокинут + assert body["parcel"] == [111] + return _card_fixture() + raise AssertionError(f"unexpected url {url}") + + monkeypatch.setattr("app.services.scrapers.ekb_geoportal_client._http_post_json", fake_post) + reg = EKBGeoportalClient().zone_regulation_at(60.6107, 56.8391) + assert reg is not None + assert reg.zone_index == "Ц-1" + assert len(reg.main_vri) == 2 + + +def test_zone_regulation_at_none_without_terzone(monkeypatch: pytest.MonkeyPatch) -> None: + """Нет UrbanTerZone в searchByGeom → None (urbanCard не дёргается).""" + monkeypatch.setattr( + "app.services.scrapers.ekb_geoportal_client._http_post_json", + lambda url, body, **kw: {"10019": [{"typeAlias": "GknParcel", "key": 1}]}, + ) + assert EKBGeoportalClient().zone_regulation_at(0.0, 0.0) is None