gendesign/backend/app/services/scrapers/okn_egrkn_client.py
bot-backend a6e100b52a
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m29s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m39s
Deploy / deploy (push) Successful in 1m14s
fix(sf): корректное urlencode form-data в okn_egrkn_client (#1147)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 14:13:34 +00:00

147 lines
6.7 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
import urllib.parse
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 включён явно: при content= (raw bytes/str) httpx не выставляет его
# автоматически — нужно прописать вручную. Конфликта с data= нет, т.к. data= не используется.
_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",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
}
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."""
# urllib.parse.urlencode гарантированно кодирует список кортежей с повторяющимися ключами
# (data[0][name]=...&data[0][value]=...&data[1][name]=...). Использование data= с list[tuple]
# конфликтует с ручным Content-Type → httpx не urlencode-ит, бросает
# «expected bytes, tuple found». content= передаёт тело как raw str.
body = urllib.parse.urlencode(form_data)
try:
with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client:
resp = client.post(
_SEARCHMAP_URL,
content=body,
)
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"]