gendesign/backend/app/services/scrapers/okn_egrkn_client.py
bot-backend 3c00af227e
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
fix(scrapers): rate-limit/backoff/circuit-breaker для ОКН EGRKN-скрапера (#2445-D3)
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
2026-07-06 00:50:57 +05:00

399 lines
19 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.

"""Клиент АИС ЕГРКН Минкультуры — поиск ОКН-объектов на карте + 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"]