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
188 lines
7.6 KiB
Python
188 lines
7.6 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")
|
||
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
|