gendesign/backend/app/services/scrapers/ekb_geoportal_client.py
lekss361 dcafb32f31
Some checks failed
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Has been cancelled
Deploy / build-backend (push) Has been cancelled
feat(sf): gknspecial_zone ИРД-harvest → ird_overlays (#1114)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 09:54:53 +00:00

326 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ЕКБ муниципальный геопортал (ИСОГД / 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",
]