"""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