feat(sf): EKB geoportal WFS client (#1083)
Some checks failed
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m24s
Deploy / build-worker (push) Successful in 2m26s
Deploy / deploy (push) Has been cancelled
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
Some checks failed
Deploy / changes (push) Successful in 6s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m24s
Deploy / build-worker (push) Successful in 2m26s
Deploy / deploy (push) Has been cancelled
CI / changes (push) Successful in 7s
CI / backend-tests (push) Has been skipped
Новый ekb_geoportal_client.py — WFS-клиент муниципального геопортала ЕКБ (Geometa GeoServer, public). features_at_point (CQL INTERSECTS), features_in_bbox (CQL BBOX), zone_index_at (crosswalk координата→индекс ПЗЗ). EPSG:4326, OGC WFS GetFeature (не WMS — reproject-баг). Единый источник землепользование/КРТ/ППТ/ЗОУИТ для ЕКБ. 7 тестов. Foundation #1067 C7; wiring в C8/D9. Refs #1067. Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
This commit is contained in:
parent
c2e0428803
commit
4e41f9be73
2 changed files with 293 additions and 0 deletions
173
backend/app/services/scrapers/ekb_geoportal_client.py
Normal file
173
backend/app/services/scrapers/ekb_geoportal_client.py
Normal file
|
|
@ -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",
|
||||||
|
]
|
||||||
120
backend/tests/test_ekb_geoportal_client.py
Normal file
120
backend/tests/test_ekb_geoportal_client.py
Normal file
|
|
@ -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:")
|
||||||
Loading…
Add table
Reference in a new issue