gendesign/backend/app/services/scrapers/nspd_lite.py
bot-backend 14f3ef2019
All checks were successful
Deploy / build-worker (push) Successful in 2m47s
Deploy / deploy (push) Successful in 1m20s
Deploy / changes (push) Successful in 9s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m52s
fix(week-review): backend-аудит v2 — 82 фиксов (#1660)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-17 17:13:38 +00:00

218 lines
10 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")
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