gendesign/backend/app/services/scrapers/okn_egrkn_client.py
lekss361 50fcba1ca0
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m23s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m21s
Deploy / deploy (push) Successful in 1m15s
feat(sf): ingest ОКН-объектов ЕКБ из АИС ЕГРКН (точки+категория) → okn_objects (#1141)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 12:51:18 +00:00

128 lines
5.5 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.

"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте (#1066).
Источник: https://okn-mk.mkrf.ru/Maps/searchMap
Метод: POST, Content-Type: application/x-www-form-urlencoded
Аутентификация: не требуется (открытый endpoint).
Ответ: GeoJSON FeatureCollection.
Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {...}}.
ВНИМАНИЕ: ЕГРКН использует Яндекс-порядок координат [lat, lon], НЕ [lon, lat].
Для PostGIS: ST_MakePoint(coordinates[1], coordinates[0]) → (lon, lat).
TLS: mkrf.ru использует сертификат, верификация по умолчанию включена.
При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242).
"""
from __future__ import annotations
import logging
from typing import Any
import httpx
logger = logging.getLogger(__name__)
_SEARCHMAP_URL = "https://okn-mk.mkrf.ru/Maps/searchMap"
# Таймаут на один HTTP-запрос к ЕГРКН (секунды)
_REQUEST_TIMEOUT = 30.0
def _build_form_data(address: str, category_type: int) -> list[tuple[str, str]]:
"""Построить form-data в формате jQuery serializeArray для поиска по карте.
Формат: повторяющиеся пары data[i][name] / data[i][value].
httpx сохраняет порядок и дублирующиеся имена при передаче списком кортежей.
"""
return [
("data[0][name]", "CultureObjects[cob_address]"),
("data[0][value]", address),
("data[1][name]", "CultureObjects[cob_category_type]"),
("data[1][value]", str(category_type)),
]
def fetch_okn_points(address: str, category_type: int) -> list[dict[str, Any]]:
"""Запросить список ОКН-объектов из ЕГРКН по адресу и категории.
Args:
address: строка поиска (например «Екатеринбург» или «Свердловская»).
category_type: 1 — федеральный, 2 — региональный, 4 — местного значения.
Returns:
Список feature-dict'ов из GeoJSON FeatureCollection.
Каждый dict содержит поля: id, geometry (с coordinates=[lat, lon]),
properties (сырые свойства из ЕГРКН, часть полей требует авторизации).
Пустой список при ошибке или пустом ответе.
Note:
ЕГРКН возвращает coordinates в порядке [lat, lon] (Яндекс-конвенция).
Для PostGIS-вставки: lon=coordinates[1], lat=coordinates[0].
"""
form_data = _build_form_data(address, category_type)
features = _do_request(form_data, verify=True)
return features
def _is_ssl_error(exc: Exception) -> bool:
"""Проверить, является ли исключение SSL-ошибкой.
httpx >= 0.28 не экспортирует отдельный SSLError — SSL-ошибки приходят
как httpx.ConnectError с SSL-сообщением в тексте или через цепочку __cause__.
"""
msg = str(exc).lower()
ssl_keywords = ("ssl", "certificate", "cert verify", "tls", "handshake")
if any(kw in msg for kw in ssl_keywords):
return True
cause = getattr(exc, "__cause__", None) or getattr(exc, "__context__", None)
if cause is not None:
return any(kw in str(cause).lower() for kw in ssl_keywords)
return False
def _do_request(
form_data: list[tuple[str, str]],
*,
verify: bool,
) -> list[dict[str, Any]]:
"""Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False."""
try:
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify) as client:
resp = client.post(
_SEARCHMAP_URL,
data=form_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
logger.error("okn_egrkn_client: HTTP %d от ЕГРКН: %s", exc.response.status_code, exc)
return []
except httpx.ConnectError as exc:
if verify and _is_ssl_error(exc):
logger.warning(
"okn_egrkn_client: SSL-ошибка при verify=True, повторяем с verify=False: %s", exc
)
return _do_request(form_data, verify=False)
logger.error("okn_egrkn_client: ошибка подключения к ЕГРКН: %s", exc)
return []
except Exception as exc:
logger.error("okn_egrkn_client: ошибка запроса к ЕГРКН: %s", exc)
return []
try:
payload: dict[str, Any] = resp.json()
except Exception as exc:
logger.error("okn_egrkn_client: не удалось распарсить JSON-ответ ЕГРКН: %s", exc)
return []
features: list[dict[str, Any]] = payload.get("features") or []
if not isinstance(features, list):
logger.warning("okn_egrkn_client: неожиданный тип features: %s", type(features).__name__)
return []
logger.info("okn_egrkn_client: получено %d фич (verify=%s)", len(features), verify)
return features
__all__ = ["fetch_okn_points"]