gendesign/backend/app/services/scrapers/gisogd_so_client.py
lekss361 189ecc81f8
All checks were successful
Deploy / build-backend (push) Successful in 1m18s
Deploy / build-frontend (push) Has been skipped
Deploy / changes (push) Successful in 5s
Deploy / build-worker (push) Successful in 2m28s
Deploy / deploy (push) Successful in 1m17s
feat(sf): ingest функц.зон генплана ЕКБ-2045 из ГИСОГД-СО WFS → ekb_genplan_functional_zone (#1137)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 12:03:39 +00:00

214 lines
9.2 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.

"""ГИСОГД-СО 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",
]