okn_objects_sync собирал 428 ОКН из ~798 ЕГРОКН и сохранял raw_props={id}
без названия/адреса — analyze показывал 'ОКН рядом' без указания КАКОГО.
Coverage: расширил _ADDRESS_PASSES с 2 до 11 подстрок (Екатеринбург,
г. Екатеринбург, 7 районов ЕКБ, 2 формы Свердловской обл.). _PASSES =
декартово (address × category). first-wins дедуп сохраняет высшую категорию.
Detail: fetch_okn_detail(source_id) — GET Show/Show?id=, BeautifulSoup
парсит HTML (dl/dt/dd и tr/td), извлекает name, address, protection_category,
object_kind, dating. _enrich_with_detail сливает в raw_props с graceful
failover (cap=1500, exception per-object не валит прогон).
Lookup: parcel_okn_objects возвращает name/address из raw_props->>'name'.
Existing ird_analyze тесты не сломаны.
49 okn-related тестов passed, ruff clean.
Closes #1159
312 lines
14 KiB
Python
312 lines
14 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).
|
||
"""
|
||
|
||
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) для тегов <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)
|
||
|
||
|
||
def _do_detail_request(source_id: str, *, verify: bool) -> dict[str, Any]:
|
||
"""HTTP GET карточки + парсинг. SSL-fallback аналогичен searchMap."""
|
||
params = {"id": source_id}
|
||
try:
|
||
with httpx.Client(
|
||
timeout=_DETAIL_TIMEOUT, verify=verify, headers=_DETAIL_HEADERS
|
||
) as client:
|
||
resp = client.get(_DETAIL_URL, params=params)
|
||
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)
|
||
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"]
|