All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m1s
CI / backend-tests (pull_request) Successful in 14m54s
WAF-гигиена (#2443): okn-mk.mkrf.ru имеет тот же unthrottled-request risk shape, что забанил VPS в WAF ДОМ.РФ. okn_egrkn_client не имел пейсинга/backoff, okn_objects_sync шлёт до 1500 последовательных GET без пауз. Добавлено: _wait_rate_limit() (shared 400ms gate между searchMap+detail на одном WAF-хосте), 429/5xx экспоненциальный backoff (base 2с, cap 60с, Retry-After honored) в _do_request/_do_detail_request (recursion bounded _MAX_RETRIES=3), circuit-breaker _DETAIL_FAIL_BREAKER=20 (consecutive fails, reset on success, break только detail-цикла). Идиомы из nspd_client/objective/gisogd66. Refs #2445
399 lines
19 KiB
Python
399 lines
19 KiB
Python
"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте + 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) для тегов <dt>/<th>/<label>.
|
||
_FIELD_MARKERS: dict[str, list[str]] = {
|
||
"name": ["наименование"],
|
||
"address": ["адрес"],
|
||
"protection_category": ["категория", "историко-культурного значения"],
|
||
"object_kind": ["вид объекта"],
|
||
"dating": ["датировка"],
|
||
"protection_doc": ["документ о постановке"],
|
||
"regnumber": ["регистрационный номер"],
|
||
}
|
||
|
||
# Лёгкая нормализация пробелов в извлечённом тексте.
|
||
_WS_RE = re.compile(r"\s+")
|
||
|
||
|
||
def _norm_text(s: str | None) -> str | None:
|
||
"""Сжать whitespace и trim. Пустую строку → None."""
|
||
if not s:
|
||
return None
|
||
value = _WS_RE.sub(" ", s).strip()
|
||
return value or None
|
||
|
||
|
||
def _match_marker(label: str, markers: list[str]) -> bool:
|
||
"""Все маркеры присутствуют в lowercased label (substring AND)."""
|
||
lab = label.lower()
|
||
return all(m in lab for m in markers)
|
||
|
||
|
||
def _extract_label_value_pairs(soup: BeautifulSoup) -> dict[str, str]:
|
||
"""Извлечь пары (label → value) из карточки ОКН.
|
||
|
||
HTML ЕГРКН построен на стандартных Bootstrap-«row»'ах: первая колонка — label,
|
||
вторая — value. Также встречаются <dl><dt>/<dd>. Парсер собирает пары по обоим
|
||
шаблонам; пары накапливаются в порядке появления.
|
||
|
||
Возвращает {raw_label_lower: value}, без интерпретации.
|
||
"""
|
||
pairs: dict[str, str] = {}
|
||
|
||
# 1) <dl><dt>...</dt><dd>...</dd>
|
||
for dt in soup.find_all("dt"):
|
||
dd = dt.find_next_sibling("dd")
|
||
if dd is None:
|
||
continue
|
||
label = _norm_text(dt.get_text(" "))
|
||
value = _norm_text(dd.get_text(" "))
|
||
if label and value:
|
||
pairs.setdefault(label.lower(), value)
|
||
|
||
# 2) Bootstrap-row / table-row: пара дочерних элементов в одном контейнере.
|
||
for row in soup.find_all(["tr", "div"]):
|
||
kids = [c for c in row.find_all(recursive=False) if c.name in {"td", "th", "div", "span"}]
|
||
if len(kids) < 2:
|
||
continue
|
||
label = _norm_text(kids[0].get_text(" "))
|
||
value = _norm_text(kids[1].get_text(" "))
|
||
if label and value and len(label) < 120:
|
||
pairs.setdefault(label.lower(), value)
|
||
|
||
return pairs
|
||
|
||
|
||
def _parse_detail_html(html: str) -> dict[str, Any]:
|
||
"""Распарсить HTML-карточку ОКН в {name, address, …}.
|
||
|
||
Невалидный HTML или отсутствие распознанных полей → пустой dict. Не raise.
|
||
"""
|
||
if not html or not html.strip():
|
||
return {}
|
||
try:
|
||
soup = BeautifulSoup(html, "html.parser")
|
||
except Exception as exc:
|
||
logger.warning("okn_egrkn_client: BS4 parse failed: %s", exc)
|
||
return {}
|
||
|
||
pairs = _extract_label_value_pairs(soup)
|
||
if not pairs:
|
||
return {}
|
||
|
||
out: dict[str, Any] = {}
|
||
for field, markers in _FIELD_MARKERS.items():
|
||
for label, value in pairs.items():
|
||
if _match_marker(label, markers):
|
||
out[field] = value
|
||
break
|
||
return out
|
||
|
||
|
||
def fetch_okn_detail(source_id: str) -> dict[str, Any]:
|
||
"""Запросить detail-карточку ОКН и распарсить метаданные.
|
||
|
||
Args:
|
||
source_id: id фичи из searchMap (он же id записи в ЕГРКН-реестре).
|
||
|
||
Returns:
|
||
dict с подмножеством {name, address, protection_category, object_kind,
|
||
dating, protection_doc, regnumber}. Пустой dict при ошибке (любая)
|
||
— never raise, ingest продолжается с базовыми полями.
|
||
"""
|
||
if not source_id:
|
||
return {}
|
||
return _do_detail_request(source_id, verify=True, attempt=0)
|
||
|
||
|
||
def _do_detail_request(source_id: str, *, verify: bool, attempt: int = 0) -> dict[str, Any]:
|
||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap.
|
||
|
||
WAF-hygiene: rate-limit перед каждой попыткой (общий с searchMap — один WAF-хост),
|
||
на 429/5xx — экспоненциальный backoff и retry до ``_MAX_RETRIES`` раз.
|
||
"""
|
||
params = {"id": source_id}
|
||
_wait_rate_limit()
|
||
try:
|
||
with httpx.Client(
|
||
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
|
||
) as client:
|
||
resp = client.get(_DETAIL_URL, params=params)
|
||
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: detail HTTP %d source_id=%s "
|
||
"(попытка %d/%d), retry через %.1fs",
|
||
resp.status_code,
|
||
source_id,
|
||
attempt + 1,
|
||
_MAX_RETRIES,
|
||
wait,
|
||
)
|
||
time.sleep(wait)
|
||
return _do_detail_request(source_id, verify=verify, attempt=attempt + 1)
|
||
resp.raise_for_status()
|
||
except httpx.HTTPStatusError as exc:
|
||
logger.warning(
|
||
"okn_egrkn_client: detail HTTP %d source_id=%s",
|
||
exc.response.status_code,
|
||
source_id,
|
||
)
|
||
return {}
|
||
except httpx.ConnectError as exc:
|
||
if verify and _is_ssl_error(exc):
|
||
logger.warning(
|
||
"okn_egrkn_client: SSL-ошибка detail с verify=True, retry verify=False: %s", exc
|
||
)
|
||
return _do_detail_request(source_id, verify=False, attempt=attempt)
|
||
logger.warning("okn_egrkn_client: detail connect error source_id=%s: %s", source_id, exc)
|
||
return {}
|
||
except Exception as exc:
|
||
logger.warning("okn_egrkn_client: detail request error source_id=%s: %s", source_id, exc)
|
||
return {}
|
||
|
||
return _parse_detail_html(resp.text)
|
||
|
||
|
||
__all__ = ["fetch_okn_detail", "fetch_okn_points"]
|