Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
218 lines
10 KiB
Python
218 lines
10 KiB
Python
"""NSPD lite-fetcher через urllib (без Playwright/Chromium).
|
||
|
||
ОТКРЫТИЕ 2026-05-11: WAF nspd.gov.ru блокирует httpx/curl/requests по
|
||
TLS-fingerprint, но **stdlib urllib проходит**. Это позволяет полностью
|
||
избавиться от Playwright + Chromium (~300 MB Docker image, медленный
|
||
browser-startup) для NSPD-задач.
|
||
|
||
Стратегия:
|
||
1. urllib.request.Request + ssl._create_unverified_context()
|
||
2. полный набор Chrome-headers (referer, origin, sec-ch-ua, ...)
|
||
3. простой rate-limiter между запросами
|
||
|
||
Эндпоинты:
|
||
- thematicSearchId=1: участки (ЗУ)
|
||
- thematicSearchId=2: кадастровые кварталы
|
||
- thematicSearchId=4: административно-территориальное деление
|
||
- thematicSearchId=5: ОКС (здания)
|
||
- thematicSearchId=7: территориальные зоны
|
||
- thematicSearchId=15: комплексы объектов
|
||
|
||
Совместимость: API-контракт совместим с nspd_kn.py (те же ResponseShape),
|
||
чтобы переключение через feature flag было безболезненным.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
import ssl
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
NSPD_BASE = "https://nspd.gov.ru/api/geoportal/v2/search/geoportal"
|
||
|
||
# Headers критичны: order, casing — как в браузере.
|
||
HEADERS = {
|
||
"accept": "*/*",
|
||
"accept-language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,es;q=0.6",
|
||
"cache-control": "no-cache",
|
||
"pragma": "no-cache",
|
||
"referer": "https://nspd.gov.ru/map?thematic=PKK",
|
||
"origin": "https://nspd.gov.ru",
|
||
"user-agent": (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
|
||
),
|
||
}
|
||
|
||
# Глобальный SSL-контекст — используется во всех запросах.
|
||
_SSL_CTX = ssl._create_unverified_context()
|
||
|
||
|
||
# Семантические alias-ы для thematicSearchId (понятнее в коде)
|
||
THEMATIC = {
|
||
"parcel": 1, # земельные участки (ЗУ)
|
||
"quarter": 2, # кадастровые кварталы
|
||
"admin": 4, # административно-территориальное деление
|
||
"building": 5, # ОКС (здания)
|
||
"zone": 7, # территориальные зоны
|
||
"complex": 15, # комплексы объектов
|
||
}
|
||
|
||
|
||
class NspdLiteError(RuntimeError):
|
||
"""Неуспешный ответ от NSPD."""
|
||
|
||
|
||
class NspdLiteWafError(NspdLiteError):
|
||
"""WAF/Rate-limit (HTTP 403/429). Триггер для backoff."""
|
||
|
||
|
||
def fetch_geoportal(
|
||
query: str,
|
||
thematic_id: int | str = 2,
|
||
timeout: int = 15,
|
||
rate_ms: int = 600,
|
||
) -> dict[str, Any]:
|
||
"""Запрос к /api/geoportal/v2/search/geoportal через urllib.
|
||
|
||
Args:
|
||
query: cad-номер ('66:41:0204016' для квартала, '66:41:0204016:10' для участка)
|
||
thematic_id: int (1/2/4/5/7/15) или alias из THEMATIC
|
||
timeout: HTTP timeout (сек)
|
||
rate_ms: пауза перед запросом (для rate-limiting; вызывается до urlopen)
|
||
|
||
Returns:
|
||
{"data": {"type": "FeatureCollection", "features": [...]}, "meta": {...}}
|
||
|
||
Raises:
|
||
NspdLiteWafError при 403/429 — сигнал для caller'а сделать backoff
|
||
NspdLiteError при прочих 4xx/5xx
|
||
"""
|
||
if rate_ms > 0:
|
||
time.sleep(rate_ms / 1000.0)
|
||
|
||
if isinstance(thematic_id, str):
|
||
thematic_id = THEMATIC.get(thematic_id, thematic_id)
|
||
|
||
qs = urllib.parse.urlencode(
|
||
{
|
||
"thematicSearchId": int(thematic_id),
|
||
"query": query,
|
||
}
|
||
)
|
||
url = f"{NSPD_BASE}?{qs}"
|
||
req = urllib.request.Request(url, headers=HEADERS)
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, context=_SSL_CTX, timeout=timeout) as r:
|
||
body = r.read().decode("utf-8", "ignore")
|
||
try:
|
||
return json.loads(body)
|
||
except json.JSONDecodeError as e:
|
||
# HTTP 200, но тело — не JSON. Это WAF/прокси-челлендж (HTML или
|
||
# пустое тело) с кодом 200 вместо 403/429. Та же transient-ситуация,
|
||
# что и явный WAF → NspdLiteWafError, чтобы caller сделал backoff
|
||
# (harvest_quarter autoretry_for=(NspdLiteWafError,)), а не пометил
|
||
# строку постоянным harvest_error.
|
||
raise NspdLiteWafError(
|
||
f"HTTP 200 but non-JSON body (WAF challenge?): {body[:300]}"
|
||
) from e
|
||
except urllib.error.HTTPError as e:
|
||
body = e.read().decode("utf-8", "ignore")[:300] if e.fp else ""
|
||
if e.code in (403, 429):
|
||
raise NspdLiteWafError(f"HTTP {e.code} (WAF/rate-limit): {body}") from e
|
||
raise NspdLiteError(f"HTTP {e.code}: {body}") from e
|
||
except urllib.error.URLError as e:
|
||
raise NspdLiteError(f"Network error: {e}") from e
|
||
|
||
|
||
def fetch_quarter(quarter_cad_num: str, **kwargs) -> dict[str, Any]:
|
||
"""Конкретный кадастровый квартал (typed wrapper)."""
|
||
return fetch_geoportal(quarter_cad_num, thematic_id=THEMATIC["quarter"], **kwargs)
|
||
|
||
|
||
def fetch_parcel(parcel_cad_num: str, **kwargs) -> dict[str, Any]:
|
||
"""Конкретный участок ЗУ (typed wrapper)."""
|
||
return fetch_geoportal(parcel_cad_num, thematic_id=THEMATIC["parcel"], **kwargs)
|
||
|
||
|
||
def fetch_building(building_cad_num: str, **kwargs) -> dict[str, Any]:
|
||
"""Конкретное здание ОКС (typed wrapper)."""
|
||
return fetch_geoportal(building_cad_num, thematic_id=THEMATIC["building"], **kwargs)
|
||
|
||
|
||
# ── Bulk через rosreestr2coord library ──────────────────────────────────────
|
||
|
||
|
||
def fetch_via_rosreestr2coord(
|
||
cad_num: str,
|
||
area_type: int = 1,
|
||
timeout: int = 15,
|
||
delay: float = 0.0, # legacy-параметр, оставлен для совместимости signature
|
||
) -> dict[str, Any] | None:
|
||
"""Тот же запрос но через community-библиотеку rosreestr2coord (v5+).
|
||
|
||
Преимущество: они поддерживают upstream обновления headers/WAF-tricks.
|
||
Использовать когда наш fetch_* поломается из-за изменений WAF.
|
||
|
||
Параметр `delay` сохранён в signature для обратной совместимости с воркером,
|
||
но в rosreestr2coord v5 он убран — rate limiting между запросами теперь
|
||
делает наш воркер через `rate_ms` (см. nspd_geo.py).
|
||
|
||
Returns:
|
||
GeoJSON Feature или None если participant не найден.
|
||
"""
|
||
_ = delay # silence unused — см. docstring выше
|
||
try:
|
||
from rosreestr2coord.parser import Area
|
||
from rosreestr2coord.request.exceptions import (
|
||
HTTPErrorException,
|
||
HTTPForbiddenException,
|
||
RequestException,
|
||
TimeoutException,
|
||
)
|
||
except ImportError as e:
|
||
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
||
|
||
# rosreestr2coord по дефолту пишет media-cache в `./tmp/` (относительно CWD).
|
||
# У нас CWD = /app, контейнер worker'а запущен под non-root user → Permission
|
||
# denied при первом fetch.
|
||
# Фикс: use_cache=False + media_path=/tmp/rosreestr2coord (writable для всех).
|
||
# Кеш нам не нужен — каждый cad_num уникален, обращаемся раз.
|
||
#
|
||
# NB: with_log=False → конструктор Area() сразу делает HTTP-запрос
|
||
# (get_geometry без try/except), поэтому WAF/сетевые исключения летят уже
|
||
# отсюда, а не из to_geojson_poly(). Оба вызова под одним try.
|
||
try:
|
||
a = Area(
|
||
code=cad_num,
|
||
area_type=area_type,
|
||
timeout=timeout,
|
||
with_log=False,
|
||
use_cache=False,
|
||
media_path="/tmp/rosreestr2coord",
|
||
)
|
||
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
|
||
# строку. Default в v5 = True → строка → крах в `_persist_target` который
|
||
# ожидает dict с `.get("properties")` etc.
|
||
return a.to_geojson_poly(dumps=False)
|
||
except (HTTPForbiddenException, HTTPErrorException, TimeoutException) as e:
|
||
# WAF/rate-limit (HTTP 403 → HTTPForbiddenException, 429/прочие HTTP →
|
||
# HTTPErrorException) и таймауты — transient. Поднимаем NspdLiteWafError,
|
||
# чтобы воркер process_nspd_geo_job сделал backoff (exponential, инкремент
|
||
# waf_blocked_count, пауза после серии WAF), а не пометил цель 'done' с 0
|
||
# features (false run-status). См. nspd_geo.py:464.
|
||
raise NspdLiteWafError(f"rosreestr2coord WAF/transient for {cad_num}: {e}") from e
|
||
except RequestException as e:
|
||
# Прочие ошибки запроса (RequestException, в т.ч. is_error_response с
|
||
# сообщением об ошибке от NSPD) — не отличить от transient WAF надёжно,
|
||
# но это НЕ легитимное 'участок не найден' (то возвращает feature=None →
|
||
# None без исключения). Классифицируем как ошибку, не как пустой результат.
|
||
raise NspdLiteError(f"rosreestr2coord request failed for {cad_num}: {e}") from e
|