gendesign/backend/app/services/scrapers/nspd_lite.py
lekss361 b19ca1ed41 fix(nspd-geo): rosreestr2coord cache permission in non-root worker
rosreestr2coord.Area() by default creates ./tmp/ media cache relative
to CWD. In our worker container CWD=/app (owned by root), runs as
non-root user `app` → PermissionError: '/app/tmp' on every target.

Fix: pass explicit kwargs to Area():
  - use_cache=False (we don't need cache — each cad_num is unique)
  - media_path='/tmp/rosreestr2coord' (world-writable in container)

Vault: fixes/Bug_Nspd_Geo_Tmp_Permission_Fixed.md
2026-05-11 14:22:11 +03:00

185 lines
7.4 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:
return a.to_geojson_poly()
except Exception as e:
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
return None