"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте + detail-card (#1066, #1159). Источник: - list: ``POST https://okn-mk.mkrf.ru/Maps/searchMap`` (Content-Type: form-urlencoded). - detail: ``GET https://okn-mk.mkrf.ru/Show/Show?id={source_id}`` (HTML-карточка). Аутентификация: не требуется (открытый endpoint). Ответ searchMap: GeoJSON FeatureCollection. Каждая фича: {id, geometry: {type: "Point", coordinates: [lat, lon]}, properties: {id}}. ВНИМАНИЕ: ЕГРКН использует Яндекс-порядок координат [lat, lon], НЕ [lon, lat]. Для PostGIS: ST_MakePoint(coordinates[1], coordinates[0]) → (lon, lat). searchMap отдаёт в properties **только {id}** — название/адрес/датировка приходят отдельным detail-endpoint'ом ``Show/Show?id=`` (HTML-карточка ОКН с метаданными). Парсим название/адрес/категорию охраны через BeautifulSoup, ошибки detail-парсинга не валят прогон (raw_props сохраняется без detail-полей, баг #1159 acceptance 2). TLS: mkrf.ru использует сертификат, верификация по умолчанию включена. При SSLError автоматический fallback на verify=False (аналогично ekburg_permits #242). """ from __future__ import annotations import logging import re import urllib.parse from typing import Any import httpx from bs4 import BeautifulSoup logger = logging.getLogger(__name__) _SEARCHMAP_URL = "https://okn-mk.mkrf.ru/Maps/searchMap" _DETAIL_URL = "https://okn-mk.mkrf.ru/Show/Show" # Таймаут на один HTTP-запрос к ЕГРКН (секунды). # Detail-вызов короче (10 s) — карточки лёгкие, длинный timeout растягивал бы прогон # на сотни секунд при ~700 объектах. _REQUEST_TIMEOUT = 30.0 _DETAIL_TIMEOUT = 10.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", } # Detail-карточка отдаёт HTML, не JSON — Content-Type form-urlencoded убираем, # X-Requested-With оставляем для совместимости (на случай SSR-условного рендера). _DETAIL_HEADERS: dict[str, str] = { "User-Agent": _HEADERS["User-Agent"], "Referer": "https://okn-mk.mkrf.ru/Maps", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "ru-RU,ru;q=0.9", } 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 (обычно {id} — название/адрес/датировку нужно догружать через ``fetch_okn_detail``). Пустой список при ошибке или пустом ответе. 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 # ── Detail-карточка ────────────────────────────────────────────────────────── # Поля карточки ЕГРКН: «Наименование», «Адрес», «Категория историко-культурного значения», # «Вид объекта», «Датировка», «Документ о постановке на гос. охрану», «Регистрационный номер». # HTML-структура — labelled rows, label слева, value справа в одном tr/td-блоке. # Используем устойчивые keys-маркеры (lowercased substring match) для тегов
//