feat(sf): EKB geoportal urbanCard — ПЗЗ-регламент content (#1067 C8)
Расширение 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 ветки).
This commit is contained in:
parent
a15265c594
commit
5f0bd817f7
2 changed files with 211 additions and 2 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue