"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте + 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). WAF-hygiene (#2445 item D3): sibling-скрапер без backoff словил бан IP по WAF DOM.РФ 24.05.2026 (#2443) — okn-mk.mkrf.ru имеет тот же risk shape (гос. CMS, десятки последовательных запросов без паузы). Идиома пэйсинга/backoff/ретраев портирована из ``gisogd66.py`` (``_RETRY_BACKOFF_S`` + сон между попытками) и ``objective.py`` (явная обработка 429/Retry-After): rate-limit между запросами (``_wait_rate_limit``), экспоненциальный backoff на 429/5xx с капом (``_do_request``/``_do_detail_request``), уважение заголовка Retry-After. """ from __future__ import annotations import logging import re import time 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 # ── WAF-hygiene: пэйсинг + backoff (#2445 item D3) ──────────────────────────── # Вежливая пауза между последовательными запросами (мс) — тот же порядок величины, # что и у objective.py (_DEFAULT_RATE_MS=500) / nspd_client.py (DEFAULT_RATE_MS=600). _RATE_LIMIT_MS = 400 # Ретраи на 429/5xx (helper делает суммарно до 1 + _MAX_RETRIES попыток). _MAX_RETRIES = 3 # Экспоненциальный backoff на 429/5xx: base * 2**attempt, капается _MAX_BACKOFF_S # (аналогично objective.py: 30 * 2**attempt, cap 300s — здесь эндпоинт легче, # поэтому база/потолок меньше, но идиома та же). _RETRY_BACKOFF_BASE_S = 2.0 _MAX_BACKOFF_S = 60.0 # process-локальная метка времени последнего запроса (обеих эндпоинтов) — # rate-limit общий, чтобы 33 list-запроса и до 1500 detail-запросов не долбили # WAF без пауз даже при чередовании вызовов. _last_request_ts: float = 0.0 def _wait_rate_limit() -> None: """Выдержать паузу ``_RATE_LIMIT_MS`` с последнего запроса к okn-mk.mkrf.ru. Общий для searchMap и detail — обе цели один и тот же WAF-хост. """ global _last_request_ts elapsed_ms = (time.time() - _last_request_ts) * 1000.0 if elapsed_ms < _RATE_LIMIT_MS: time.sleep((_RATE_LIMIT_MS - elapsed_ms) / 1000.0) _last_request_ts = time.time() def _backoff_seconds(attempt: int, retry_after_hdr: str | None) -> float: """Секунды ожидания перед повтором после 429/5xx. Retry-After (если валиден) уважается первым, но капается _MAX_BACKOFF_S — аналогично objective.py. Иначе экспоненциальный backoff от attempt (0-based). """ if retry_after_hdr and retry_after_hdr.isdigit(): return min(float(retry_after_hdr), _MAX_BACKOFF_S) return min(_RETRY_BACKOFF_BASE_S * (2**attempt), _MAX_BACKOFF_S) # 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, attempt=0) 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, attempt: int = 0, ) -> list[dict[str, Any]]: """Выполнить HTTP POST к searchMap. При SSL-ошибке с verify=True повторяет с verify=False. WAF-hygiene: rate-limit перед каждой попыткой (``_wait_rate_limit``), на 429/5xx — экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз (аналогично objective.py). """ # 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) _wait_rate_limit() try: with httpx.Client(timeout=_REQUEST_TIMEOUT, verify=verify, headers=_HEADERS) as client: resp = client.post( _SEARCHMAP_URL, content=body, ) if resp.status_code == 429 or resp.status_code >= 500: if attempt < _MAX_RETRIES: wait = _backoff_seconds(attempt, resp.headers.get("Retry-After")) logger.warning( "okn_egrkn_client: HTTP %d от ЕГРКН (попытка %d/%d), retry через %.1fs", resp.status_code, attempt + 1, _MAX_RETRIES, wait, ) time.sleep(wait) return _do_request(form_data, verify=verify, attempt=attempt + 1) 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, attempt=attempt) 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) для тегов