gendesign/backend/app/services/scrapers/nspd_lite.py
lekss361 3919a40c49 refactor(nspd-geo): unify on rosreestr2coord v5, drop legacy column
Объединяю несколько связанных изменений вокруг NSPD geo bulk-fetcher:

Adapt to rosreestr2coord v5 API:
- nspd_lite.fetch_via_rosreestr2coord: drop `delay` kwarg from Area()
  (removed upstream in v5); keep it in our function signature for
  backward-compat, comment why.
- nspd_geo worker: add explicit time.sleep(rate_ms/1000) after lib-branch
  fetch — in v4 the library throttled internally via delay, in v5
  rate limiting is the caller's job. Без этого получали Area.__init__()
  unexpected kwarg `delay` на каждом target.

Drop use_rosreestr2coord switch:
- Removed urllib-vs-lib choice everywhere. We always use community
  rosreestr2coord library — авторы регулярно обновляют WAF-tricks,
  наш urllib-fetcher (fetch_geoportal) уже неактуален.
- admin_scrape.py: Pydantic schema, INSERT, SELECT, API response
  cleaned of `use_rosreestr2coord`.
- nspd_geo.enqueue_geo_job: param dropped, INSERT shrunk.
- worker process loop: dropped `if use_lib:` branch + import of
  fetch_geoportal.
- frontend/geo/page.tsx: removed checkbox + GeoJob.use_rosreestr2coord
  field + POST body field.

DB column drop:
- data/sql/78_drop_use_rosreestr2coord.sql (NEW):
  DROP COLUMN nspd_geo_jobs.use_rosreestr2coord + CREATE OR REPLACE
  VIEW v_scrape_runs_unified (которая depended on the column).
- data/sql/77_nspd_geo_jobs.sql: cleaned historical DDL for fresh setups.
- Migration applied to prod (in-conversation via postgres MCP).

Frontend polish:
- Thematic ID changed from free-form number input to labeled select
  (1=parcel / 2=quarter / 4=admin / 5=building / 7=zone / 15=complex).
- Auto-sync thematic_id from Job kind on change (override possible).
- ScrapeLogsPanel: extended union type with "nspd_geo" + fixed
  /admin/scrape/geo to pass scraperType="nspd_geo" (was "nspd",
  filtering empty legacy nspd_scrape_log table; real logs live in
  nspd_geo_log via v_scrape_log_unified).

Verified: ruff ✓, tsc --noEmit ✓, migration ran (BEGIN..COMMIT clean).

Deploy order safe: prod column уже удалена → новый backend код, который
не INSERT'ит use_rosreestr2coord, совпадёт со схемой после deploy.
2026-05-11 14:13:33 +03:00

178 lines
6.8 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
a = Area(
code=cad_num,
area_type=area_type,
timeout=timeout,
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