gendesign/backend/app/services/scrapers/okn_egrkn_client.py
bot-backend 8b7f2ff247
All checks were successful
CI / changes (push) Successful in 6s
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m17s
CI / backend-tests (pull_request) Successful in 6m15s
fix(sf): браузерный User-Agent для okn-mk searchMap (обход 403) (#1066)
2026-06-07 16:53:07 +03:00

140 lines
6 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
# Browser headers -- required: without User-Agent the server returns 403.
# Content-Type intentionally omitted: httpx sets it automatically with data=
# (application/x-www-form-urlencoded); duplicating it would conflict.
_HEADERS: dict[str, str] = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
),
"X-Requested-With": "XMLHttpRequest",
"Referer": "https://okn-mk.mkrf.ru/Maps",
"Origin": "https://okn-mk.mkrf.ru",
}
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, headers=_HEADERS) as client:
resp = client.post(
_SEARCHMAP_URL,
data=form_data,
)
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"]