ОТКРЫТИЕ: stdlib urllib проходит WAF nspd.gov.ru (TLS-fingerprint stdlib отличается от mainstream HTTP-clients). Это позволяет полностью убрать Playwright + Chromium для NSPD-задач. * nspd_lite.py: urllib + ssl._create_unverified_context(). 4 публичных fetcher'а (geoportal/quarter/parcel/building) + fetch_via_rosreestr2coord fallback на community-lib * schema 77: nspd_geo_jobs (журнал) + nspd_geo_targets (cad-номера со статусом pending/done/failed). Resume-state в БД. * tasks/nspd_geo.py: Celery task process_nspd_geo_job(job_id). Heartbeat каждые 5 items, WAF backoff 30s × 2^N (max 8 → paused). UPSERT в cad_quarters_geom / cad_buildings → идемпотентно. * worker_ready hook: stale jobs (>10min без heartbeat) автоматически re-enqueue после redeploy. Никаких потерь прогресса. * 4 admin endpoint + UI /admin/scrape/geo с формой запуска (manual_list / rosreestr_pending для авто-наполнения из ДДУ-кварталов), progress-bars и cancel/resume кнопками per job. * settings: use_nspd_lite=True, nspd_lite_rate_ms=600. * dep: rosreestr2coord>=4.0.0 (community lib для fallback). TODO следующих шагов: - smoke-test на проде через SSH (validation RU-IP не банит urllib) - feature toggle USE_NSPD_LITE в scrape_nspd.py для переключения с старого Playwright-пути - docker lean image (-300 MB после убирания Chromium) - расширение SCRAPE_NSPD_DEFAULT_REGIONS на 66,74,72,59 (Челябинск/Тюмень/Пермь)
174 lines
6.4 KiB
Python
174 lines
6.4 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 = 1.0,
|
||
) -> dict[str, Any] | None:
|
||
"""Тот же запрос но через community-библиотеку rosreestr2coord.
|
||
|
||
Преимущество: они поддерживают upstream обновления headers/WAF-tricks.
|
||
Использовать когда наш fetch_* поломается из-за изменений WAF.
|
||
|
||
Returns:
|
||
GeoJSON Feature или None если participant не найден.
|
||
"""
|
||
try:
|
||
from rosreestr2coord.parser import Area
|
||
except ImportError as e:
|
||
raise NspdLiteError(f"rosreestr2coord не установлен (uv add rosreestr2coord): {e}") from e
|
||
|
||
a = Area(
|
||
code=cad_num,
|
||
area_type=area_type,
|
||
timeout=timeout,
|
||
delay=delay,
|
||
with_log=False,
|
||
)
|
||
try:
|
||
return a.to_geojson_poly()
|
||
except Exception as e:
|
||
logger.warning("rosreestr2coord failed for %s: %s", cad_num, e)
|
||
return None
|