Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
214 lines
9.2 KiB
Python
214 lines
9.2 KiB
Python
"""ГИСОГД-СО WFS-клиент — функциональные зоны генплана ЕКБ-2045 (#1058).
|
||
|
||
Источник: ``gisogd66.midural.ru`` — региональная ГИС органов государственной власти
|
||
Свердловской области. Открытый WFS без авторизации.
|
||
|
||
Два метода:
|
||
``resolve_functional_zone_layer()`` — резолвит актуальное typeName слоя «Функциональные
|
||
зоны» из каталога слоёв. Суффикс-хеш в имени слоя меняется при обновлении генплана,
|
||
поэтому хардкодить нельзя — резолвим динамически. Fallback-хардкод если каталог недоступен.
|
||
|
||
``fetch_functional_zones()`` — WFS GetFeature → список feature-dict'ов GeoJSON
|
||
(FeatureCollection), ~6436 фич, Polygon, EPSG:4326.
|
||
|
||
TLS: midural.ru использует российский CA (по аналогии с #242 ekburg_permits). При SSLError
|
||
используем verify=False с explicit-комментом — без этого соединение рвётся на prod VPS.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ── Константы ─────────────────────────────────────────────────────────────────
|
||
|
||
GISOGD_BASE = "https://gisogd66.midural.ru"
|
||
CATALOG_URL = f"{GISOGD_BASE}/api/catalogs/agate_ekbgo"
|
||
WFS_URL = f"{GISOGD_BASE}/map/tiles/agate_ekbgo/wfs"
|
||
|
||
# Fallback-хардкод если каталог недоступен (последний известный хеш, 2026-06-07).
|
||
_LAYER_FALLBACK = "agate_ekbgo:agate_ekbgo_geo_urban_10_functional_zone_f6c5926"
|
||
|
||
# Имя узла в каталоге (case-insensitive поиск).
|
||
_CATALOG_NODE_NAME = "Функциональные зоны"
|
||
|
||
# Параметры запроса к WFS.
|
||
_WFS_OUTPUT_FORMAT = "application/json"
|
||
_WFS_VERSION = "2.0.0"
|
||
|
||
# Щедрый таймаут — ответ ~6436 фич (полный слой одним запросом, ~несколько МБ).
|
||
_WFS_TIMEOUT = 120
|
||
_CATALOG_TIMEOUT = 20
|
||
|
||
# Размер страницы для постраничного fallback (если целиком не забирается).
|
||
_PAGE_SIZE = 2000
|
||
|
||
|
||
# ── Сетевые helpers (module-level — мокаются в тестах) ───────────────────────
|
||
|
||
|
||
def _http_get_json(url: str, params: dict[str, str] | None = None, *, timeout: int) -> Any:
|
||
"""GET → JSON. verify=False: midural.ru — российский CA (аналог #242 ekburg_permits).
|
||
|
||
Сначала пробуем verify=True; при SSLError fallback на verify=False с предупреждением.
|
||
"""
|
||
try:
|
||
resp = httpx.get(url, params=params, timeout=timeout, verify=True)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
except httpx.ConnectError as exc:
|
||
# SSLError обёрнут в ConnectError у httpx — переключаемся на verify=False.
|
||
logger.warning(
|
||
"gisogd_so_client: SSL verify=True failed (%s), retry with verify=False", exc
|
||
)
|
||
resp = httpx.get(url, params=params, timeout=timeout, verify=False)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
# ── Каталог → резолв слоя ────────────────────────────────────────────────────
|
||
|
||
|
||
def _find_layer_in_tree(node: Any) -> str | None:
|
||
"""Рекурсивный обход JSON-дерева каталога. Ищет узел с name==_CATALOG_NODE_NAME.
|
||
|
||
Возвращает ``value`` найденного узла (typeName слоя) или None.
|
||
"""
|
||
if not isinstance(node, dict):
|
||
return None
|
||
name = node.get("name") or ""
|
||
if name.strip() == _CATALOG_NODE_NAME:
|
||
value = node.get("value")
|
||
return str(value) if value else None
|
||
# Рекурсивно по children (разные ключи в каталоге: children / items / layers).
|
||
for key in ("children", "items", "layers", "nodes"):
|
||
children = node.get(key)
|
||
if isinstance(children, list):
|
||
for child in children:
|
||
found = _find_layer_in_tree(child)
|
||
if found:
|
||
return found
|
||
return None
|
||
|
||
|
||
def resolve_functional_zone_layer() -> str:
|
||
"""Резолвит actуальное typeName слоя «Функциональные зоны» из каталога ГИСОГД-СО.
|
||
|
||
Суффикс-хеш в имени слоя меняется при обновлении генплана → не хардкодить.
|
||
При недоступности каталога возвращает fallback-хардкод.
|
||
|
||
Returns:
|
||
typeName вида ``agate_ekbgo:agate_ekbgo_geo_urban_10_functional_zone_<hash>``.
|
||
"""
|
||
try:
|
||
catalog = _http_get_json(CATALOG_URL, timeout=_CATALOG_TIMEOUT)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"gisogd_so_client: каталог недоступен (%s), использую fallback layer=%s",
|
||
exc,
|
||
_LAYER_FALLBACK,
|
||
)
|
||
return _LAYER_FALLBACK
|
||
|
||
layer = _find_layer_in_tree(catalog)
|
||
if layer:
|
||
logger.info("gisogd_so_client: резолвлен слой из каталога: %s", layer)
|
||
return layer
|
||
|
||
logger.warning(
|
||
"gisogd_so_client: узел '%s' не найден в каталоге, использую fallback layer=%s",
|
||
_CATALOG_NODE_NAME,
|
||
_LAYER_FALLBACK,
|
||
)
|
||
return _LAYER_FALLBACK
|
||
|
||
|
||
# ── WFS GetFeature → список фич ──────────────────────────────────────────────
|
||
|
||
|
||
def _wfs_params(type_name: str, start_index: int = 0, count: int | None = None) -> dict[str, str]:
|
||
"""Строит параметры WFS GetFeature-запроса."""
|
||
params: dict[str, str] = {
|
||
"service": "WFS",
|
||
"version": _WFS_VERSION,
|
||
"request": "GetFeature",
|
||
"typeNames": type_name,
|
||
"outputFormat": _WFS_OUTPUT_FORMAT,
|
||
}
|
||
if count is not None:
|
||
params["count"] = str(count)
|
||
if start_index > 0:
|
||
params["startIndex"] = str(start_index)
|
||
return params
|
||
|
||
|
||
def fetch_functional_zones(type_name: str | None = None) -> list[dict[str, Any]]:
|
||
"""Загружает все фичи слоя функциональных зон из ГИСОГД-СО WFS.
|
||
|
||
Сначала пробует забрать весь слой одним запросом (~6436 фич). При таймауте
|
||
автоматически переключается на постраничный режим (count=_PAGE_SIZE).
|
||
|
||
Args:
|
||
type_name: typeName WFS-слоя. None → вызывает resolve_functional_zone_layer().
|
||
|
||
Returns:
|
||
Список feature-dict'ов (GeoJSON Feature): ``{"geometry": ..., "properties": ...}``.
|
||
Пусто если WFS недоступен или вернул 0 фич.
|
||
"""
|
||
if type_name is None:
|
||
type_name = resolve_functional_zone_layer()
|
||
|
||
# Попытка 1: весь слой одним запросом.
|
||
try:
|
||
data = _http_get_json(WFS_URL, _wfs_params(type_name), timeout=_WFS_TIMEOUT)
|
||
features: list[dict[str, Any]] = (data or {}).get("features") or []
|
||
if features:
|
||
logger.info("gisogd_so_client: WFS загружено фич=%d (один запрос)", len(features))
|
||
return features
|
||
except httpx.TimeoutException:
|
||
logger.warning("gisogd_so_client: WFS таймаут одним запросом, переключаюсь на страницы")
|
||
except Exception as exc:
|
||
logger.error("gisogd_so_client: WFS ошибка одним запросом: %s", exc)
|
||
return []
|
||
|
||
# Попытка 2: постраничная загрузка.
|
||
features = []
|
||
start = 0
|
||
while True:
|
||
try:
|
||
data = _http_get_json(
|
||
WFS_URL,
|
||
_wfs_params(type_name, start_index=start, count=_PAGE_SIZE),
|
||
timeout=_WFS_TIMEOUT,
|
||
)
|
||
except Exception as exc:
|
||
logger.error("gisogd_so_client: WFS постраничная ошибка start=%d: %s", start, exc)
|
||
break
|
||
page: list[dict[str, Any]] = (data or {}).get("features") or []
|
||
if not page:
|
||
break
|
||
features.extend(page)
|
||
logger.info(
|
||
"gisogd_so_client: WFS страница start=%d, фич на странице=%d, итого=%d",
|
||
start,
|
||
len(page),
|
||
len(features),
|
||
)
|
||
if len(page) < _PAGE_SIZE:
|
||
break
|
||
start += _PAGE_SIZE
|
||
|
||
logger.info("gisogd_so_client: WFS итого загружено фич=%d (постраничная)", len(features))
|
||
return features
|
||
|
||
|
||
__all__ = [
|
||
"CATALOG_URL",
|
||
"WFS_URL",
|
||
"fetch_functional_zones",
|
||
"resolve_functional_zone_layer",
|
||
]
|