gendesign/backend/app/services/scrapers/nspd_lite.py
lekss361 ce5e29f92e fix(nspd-geo): rosreestr2coord v5 compat — delay arg, tmp cache, dumps default
Three incremental bugs surfaced while wiring rosreestr2coord v5 into our
worker. All in backend/app/services/scrapers/nspd_lite.py.

1. `Area.__init__()` removed `delay` kwarg in v5 (was throttling). Drop
   it from our call. Rate limit now happens in worker via
   time.sleep(rate_ms/1000) after each fetch.

2. `Area(..., use_cache=True)` default writes media cache to ./tmp/
   relative to CWD. In our docker image CWD=/app (owned by root), worker
   runs as non-root → PermissionError on every target. Fix:
   use_cache=False, media_path="/tmp/rosreestr2coord".

3. `Area.to_geojson_poly(dumps=True)` default returns JSON-serialized
   STRING, not dict (changed in v5). Worker's _persist_target expects
   dict with .get("properties") → AttributeError. Fix: dumps=False.

Vault entries:
- fixes/Bug_Rosreestr2coord_Delay_Arg_v5_Fixed.md
- fixes/Bug_Nspd_Geo_Tmp_Permission_Fixed.md
- fixes/Bug_Nspd_Geo_Str_Object_No_Get_Fixed.md
2026-05-11 14:43:44 +03:00

188 lines
7.6 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.

"""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")
return json.loads(body)
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
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 уникален, обращаемся раз.
a = Area(
code=cad_num,
area_type=area_type,
timeout=timeout,
with_log=False,
use_cache=False,
media_path="/tmp/rosreestr2coord",
)
try:
# dumps=False — возвращает dict (GeoJSON Feature), а не JSON-сериализованную
# строку. Default в v5 = True → строка → крах в `_persist_target` который
# ожидает dict с `.get("properties")` etc.
return a.to_geojson_poly(dumps=False)
except Exception as e:
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
return None