Add async NSPD client (nspd_bulk_client.py) with 3 new methods: - search_by_quarter: REST /api/geoportal/v2/search/geoportal — snapshot 20 per cat + meta totals - wms_feature_info: WMS GetFeatureInfo для grid-walk discovery - list_objects_in_building: tab-group-data (Q3 deferred — код ready) Rate limit: asyncio.Semaphore(3) + 0.05s jitter. 429 → exponential backoff 2→4→8s, max 3 retries. 403 (WAF) → raise immediately, no retry. Pydantic schemas (nspd_bulk.py): NSPDBulkFeature, QuarterSnapshot, ObjectsListing with extra='allow' to preserve unknown fields → raw_props JSONB downstream. 15 unit tests + 4 e2e (real NSPD, skipped in CI by default). Co-authored-by: lekss361 <claudestars@proton.me>
This commit is contained in:
parent
b4327635fa
commit
b7889d113b
4 changed files with 1032 additions and 0 deletions
134
backend/app/schemas/nspd_bulk.py
Normal file
134
backend/app/schemas/nspd_bulk.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Pydantic schemas для NSPD bulk cadastre ingest (PR 2/5, issue #168).
|
||||
|
||||
Отдельный модуль от legacy NSPDFeature dataclass (nspd_client.py) —
|
||||
там frozen dataclass для on-demand flow, тут Pydantic для bulk pipeline
|
||||
с extra='allow' чтобы не потерять неизвестные поля → raw_props JSONB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class NSPDOptions(BaseModel):
|
||||
"""Опции кадастрового объекта из NSPD.
|
||||
|
||||
Все поля Optional — разные categoryId возвращают разные subsets.
|
||||
extra='allow' → любые незнакомые поля сохраняются (forward-compatible).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
cad_num: str | None = None
|
||||
cad_number: str | None = None # некоторые категории используют cad_number
|
||||
quarter_cad_number: str | None = None
|
||||
parent_cad_number: str | None = None
|
||||
area: float | None = None
|
||||
cost_value: float | None = None
|
||||
cost_index: float | None = None
|
||||
purpose: str | None = None
|
||||
floors: str | int | None = None
|
||||
year_built: str | int | None = None
|
||||
readable_address: str | None = None
|
||||
ownership_type: str | None = None
|
||||
right_type: str | None = None
|
||||
status: str | None = None
|
||||
land_record_area: float | None = None
|
||||
specified_area: float | None = None
|
||||
declared_area: float | None = None
|
||||
permitted_use_established_by_document: str | None = None
|
||||
category_type: str | None = None
|
||||
build_record_type: str | None = None
|
||||
build_record_type_value: str | None = None
|
||||
objdoc_id: int | None = None
|
||||
registers_id: int | None = None
|
||||
okato: str | None = None
|
||||
kladr: str | None = None
|
||||
|
||||
@property
|
||||
def effective_cad_num(self) -> str | None:
|
||||
"""Нормализованный кад. номер: cad_num или cad_number (разные категории)."""
|
||||
return self.cad_num or self.cad_number
|
||||
|
||||
|
||||
class NSPDBulkFeature(BaseModel):
|
||||
"""GeoJSON Feature из NSPD bulk endpoints.
|
||||
|
||||
Pydantic-версия (не dataclass) — для удобной сериализации + extra='allow'.
|
||||
geometry — GeoJSON-dict в EPSG:3857 (transform на стороне Celery-таска).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
||||
|
||||
id: int | str | None = None
|
||||
type: str = "Feature"
|
||||
geometry: dict[str, Any] | None = None
|
||||
properties: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def category_id(self) -> int | None:
|
||||
"""categoryId из properties (ключ для routing к target table)."""
|
||||
val = self.properties.get("categoryId")
|
||||
return int(val) if val is not None else None
|
||||
|
||||
@property
|
||||
def options(self) -> NSPDOptions:
|
||||
"""Распарсить options из properties.options если есть, иначе пустой."""
|
||||
opts = self.properties.get("options") or {}
|
||||
return NSPDOptions.model_validate(opts)
|
||||
|
||||
@property
|
||||
def cad_num(self) -> str | None:
|
||||
"""Shortcut: cad_num из options (часто нужен в caller)."""
|
||||
return self.options.effective_cad_num
|
||||
|
||||
|
||||
class QuarterSnapshot(BaseModel):
|
||||
"""Результат search_by_quarter — snapshot данных для одного квартала.
|
||||
|
||||
Первые 20 объектов на категорию из NSPD search endpoint.
|
||||
overflow_categories — категории требующие grid-walk (totalCount > 20).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
quarter: str
|
||||
fetched_at: str # ISO datetime string
|
||||
features: list[NSPDBulkFeature] = []
|
||||
meta_counts: dict[int, int] = {} # {categoryId: totalCount}
|
||||
|
||||
@property
|
||||
def overflow_categories(self) -> list[int]:
|
||||
"""Категории где totalCount > 20 — нужен grid-walk для полноты."""
|
||||
return [cat_id for cat_id, count in self.meta_counts.items() if count > 20]
|
||||
|
||||
@property
|
||||
def total_meta_count(self) -> int:
|
||||
"""Суммарное количество объектов по meta (до cap)."""
|
||||
return sum(self.meta_counts.values())
|
||||
|
||||
|
||||
class ObjectsListing(BaseModel):
|
||||
"""Результат list_objects_in_building — список помещений и машино-мест здания.
|
||||
|
||||
Получается через /api/geoportal/v1/tab-group-data?tabClass=objectsList.
|
||||
Q3 deferred — код готов, но в bulk_harvest_quarter MVP не используется.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
objdoc_id: int
|
||||
flats_count: int = 0
|
||||
parking_count: int = 0
|
||||
flats_cad_nums: list[str] = []
|
||||
parking_cad_nums: list[str] = []
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NSPDBulkFeature",
|
||||
"NSPDOptions",
|
||||
"ObjectsListing",
|
||||
"QuarterSnapshot",
|
||||
]
|
||||
407
backend/app/scrapers/nspd_bulk_client.py
Normal file
407
backend/app/scrapers/nspd_bulk_client.py
Normal file
|
|
@ -0,0 +1,407 @@
|
|||
"""NSPD bulk cadastre client — async httpx-based, rate-limited.
|
||||
|
||||
PR 2/5 of GH issue #168: Bulk Cadastre Ingest (ЕКБ 2408 кварталов).
|
||||
|
||||
Три новых метода:
|
||||
- search_by_quarter(quarter) → QuarterSnapshot (phase 1 snapshot)
|
||||
- wms_feature_info(layer_id, bbox, click_xy) → list[NSPDBulkFeature] (grid-walk)
|
||||
- list_objects_in_building(objdoc_id) → ObjectsListing (Q3 deferred)
|
||||
|
||||
Ключевые отличия от nspd_client.py (sync urllib):
|
||||
- async httpx.AsyncClient (connection pooling, timeout objects)
|
||||
- asyncio.Semaphore(3) — не более 3 одновременных запросов
|
||||
- asyncio.sleep(0.05) jitter между запросами
|
||||
- 429 → exponential backoff (2 → 4 → 8 с, max 3 retries)
|
||||
- 403 WAF → raise немедленно (не retry, нужен manual investigate)
|
||||
|
||||
Geometry: NSPD возвращает geom в EPSG:3857. ST_Transform(geom, 4326)
|
||||
делается на стороне Celery-таска (PR 3) при upsert в DB.
|
||||
|
||||
Rate limit: 3 req/s sustained через Semaphore(3) + 0.05s jitter.
|
||||
При 429 — exponential backoff: 2s → 4s → 8s, max 3 retries.
|
||||
При 403 (WAF) — raise NspdBulkWafError, не retry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import datetime as _dt
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.nspd_bulk import NSPDBulkFeature, ObjectsListing, QuarterSnapshot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Константы ──────────────────────────────────────────────────────────────────
|
||||
|
||||
NSPD_BASE = "https://nspd.gov.ru/api"
|
||||
NSPD_SEARCH_URL = f"{NSPD_BASE}/geoportal/v2/search/geoportal"
|
||||
NSPD_WMS_BASE = f"{NSPD_BASE}/aeggis/v4"
|
||||
NSPD_TAB_GROUP_URL = f"{NSPD_BASE}/geoportal/v1/tab-group-data"
|
||||
|
||||
# Лимит одновременных запросов (глобальный, shared между всеми instance'ами)
|
||||
_SEMAPHORE = asyncio.Semaphore(3)
|
||||
|
||||
# Таймаут подключения и чтения
|
||||
DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||||
|
||||
# Headers из HAR audit 2026-05-15
|
||||
DEFAULT_HEADERS: dict[str, str] = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://nspd.gov.ru/map",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"x-timezone": "Europe/Moscow",
|
||||
# Origin только для POST endpoints — не включаем для GET (лишний header)
|
||||
}
|
||||
|
||||
# Retry settings
|
||||
_MAX_RETRIES = 3
|
||||
_BACKOFF_BASE_S = 2.0 # 2 → 4 → 8 секунд
|
||||
|
||||
|
||||
# ── Exceptions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NspdBulkError(RuntimeError):
|
||||
"""Неуспешный ответ от NSPD bulk endpoint."""
|
||||
|
||||
|
||||
class NspdBulkWafError(NspdBulkError):
|
||||
"""HTTP 403 WAF блокировка — не retry, нужно manual investigate."""
|
||||
|
||||
|
||||
class NspdBulkRateLimitError(NspdBulkError):
|
||||
"""HTTP 429 Rate limit — исчерпаны retries после backoff."""
|
||||
|
||||
|
||||
# ── NSPDBulkClient ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class NSPDBulkClient:
|
||||
"""Async NSPD client для bulk cadastre ingest.
|
||||
|
||||
Использовать как async context manager:
|
||||
|
||||
async with NSPDBulkClient() as client:
|
||||
snapshot = await client.search_by_quarter("66:41:0303161")
|
||||
|
||||
Или с explicit lifecycle (для Celery tasks):
|
||||
|
||||
client = NSPDBulkClient()
|
||||
await client.__aenter__()
|
||||
try:
|
||||
...
|
||||
finally:
|
||||
await client.__aexit__(None, None, None)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
|
||||
headers: dict[str, str] | None = None,
|
||||
max_retries: int = _MAX_RETRIES,
|
||||
) -> None:
|
||||
self._timeout = timeout
|
||||
self._headers = headers or DEFAULT_HEADERS
|
||||
self._max_retries = max_retries
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
async def __aenter__(self) -> NSPDBulkClient:
|
||||
self._client = httpx.AsyncClient(
|
||||
headers=self._headers,
|
||||
timeout=self._timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_: Any) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# ── Internal HTTP helper ───────────────────────────────────────────────────
|
||||
|
||||
async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""GET → JSON с rate-limit semaphore, jitter, retry при 429.
|
||||
|
||||
Raises:
|
||||
NspdBulkWafError при 403.
|
||||
NspdBulkRateLimitError при 429 после исчерпания retries.
|
||||
NspdBulkError при прочих 4xx/5xx.
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("NSPDBulkClient не инициализирован — используй async with")
|
||||
|
||||
attempt = 0
|
||||
while True:
|
||||
async with _SEMAPHORE:
|
||||
# Небольшой jitter чтобы N одновременных запросов не начинались в 0мс
|
||||
await asyncio.sleep(0.05)
|
||||
try:
|
||||
resp = await self._client.get(url, params=params)
|
||||
except httpx.TransportError as e:
|
||||
raise NspdBulkError(f"Network error: {e}") from e
|
||||
|
||||
# Обработка статусов — вне семафора чтобы не держать слот при sleep
|
||||
if resp.status_code == 403:
|
||||
body_preview = resp.text[:200]
|
||||
logger.warning("NSPD WAF 403 blocked: url=%s preview=%s", url, body_preview)
|
||||
raise NspdBulkWafError(f"HTTP 403 WAF block (manual investigate required): {url}")
|
||||
|
||||
if resp.status_code == 429:
|
||||
attempt += 1
|
||||
if attempt > self._max_retries:
|
||||
raise NspdBulkRateLimitError(
|
||||
f"HTTP 429 rate-limit: exhausted {self._max_retries} retries for {url}"
|
||||
)
|
||||
backoff = _BACKOFF_BASE_S * (2 ** (attempt - 1))
|
||||
logger.warning(
|
||||
"NSPD 429 rate-limit: retry %d/%d after %.0fs backoff, url=%s",
|
||||
attempt,
|
||||
self._max_retries,
|
||||
backoff,
|
||||
url,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
|
||||
if resp.status_code >= 400:
|
||||
body_preview = resp.text[:300]
|
||||
raise NspdBulkError(f"HTTP {resp.status_code}: {url} — {body_preview}")
|
||||
|
||||
return resp.json()
|
||||
|
||||
# ── 1. search_by_quarter ──────────────────────────────────────────────────
|
||||
|
||||
async def search_by_quarter(self, quarter: str) -> QuarterSnapshot:
|
||||
"""Поиск объектов в квартале через NSPD search endpoint.
|
||||
|
||||
GET /api/geoportal/v2/search/geoportal?thematicSearchId=1&query={quarter}
|
||||
|
||||
Возвращает первые 20 объектов на категорию + meta с totalCount.
|
||||
Категории с totalCount > 20 попадают в overflow_categories
|
||||
и требуют grid-walk (wms_feature_info) для полноты.
|
||||
|
||||
Args:
|
||||
quarter: кадастровый номер квартала в формате `66:41:NNNNNN`.
|
||||
|
||||
Returns:
|
||||
QuarterSnapshot с features + meta_counts.
|
||||
При 404 или пустом ответе — snapshot с features=[].
|
||||
|
||||
Raises:
|
||||
NspdBulkWafError при 403.
|
||||
NspdBulkError при сетевых / прочих ошибках.
|
||||
"""
|
||||
fetched_at = _dt.datetime.now(_dt.UTC).isoformat()
|
||||
|
||||
try:
|
||||
data = await self._get_json(
|
||||
NSPD_SEARCH_URL,
|
||||
params={"thematicSearchId": 1, "query": quarter},
|
||||
)
|
||||
except NspdBulkError as e:
|
||||
# 404 или пустой ответ → возвращаем пустой snapshot
|
||||
err_str = str(e)
|
||||
if "HTTP 404" in err_str or "HTTP 400" in err_str:
|
||||
logger.info(
|
||||
"search_by_quarter: quarter %s not found (404/400), returning empty",
|
||||
quarter,
|
||||
)
|
||||
return QuarterSnapshot(quarter=quarter, fetched_at=fetched_at)
|
||||
raise
|
||||
|
||||
# Парсим response
|
||||
# Shape: {"data": {"type": "FeatureCollection", "features": [...], "meta": [...]}, ...}
|
||||
# или: {"data": {"features": [...], "meta": [...]}}
|
||||
payload = data if isinstance(data, dict) else {}
|
||||
data_block = payload.get("data") or {}
|
||||
if isinstance(data_block, str):
|
||||
# Иногда NSPD оборачивает data в строку (Bug_Nspd_Geo_Str_Object_No_Get_Fixed)
|
||||
logger.warning(
|
||||
"search_by_quarter: data block is string for quarter %s, returning empty",
|
||||
quarter,
|
||||
)
|
||||
return QuarterSnapshot(quarter=quarter, fetched_at=fetched_at)
|
||||
|
||||
raw_features: list[dict[str, Any]] = data_block.get("features") or []
|
||||
raw_meta: list[dict[str, Any]] = data_block.get("meta") or []
|
||||
|
||||
features = [NSPDBulkFeature.model_validate(f) for f in raw_features]
|
||||
|
||||
# meta: [{categoryId: N, totalCount: M}, ...]
|
||||
meta_counts: dict[int, int] = {}
|
||||
for m in raw_meta:
|
||||
cat_id = m.get("categoryId")
|
||||
total = m.get("totalCount")
|
||||
if cat_id is not None and total is not None:
|
||||
meta_counts[int(cat_id)] = int(total)
|
||||
|
||||
logger.info(
|
||||
"search_by_quarter: quarter=%s features=%d meta_cats=%d overflow=%d",
|
||||
quarter,
|
||||
len(features),
|
||||
len(meta_counts),
|
||||
len([c for c, cnt in meta_counts.items() if cnt > 20]),
|
||||
)
|
||||
|
||||
return QuarterSnapshot(
|
||||
quarter=quarter,
|
||||
fetched_at=fetched_at,
|
||||
features=features,
|
||||
meta_counts=meta_counts,
|
||||
)
|
||||
|
||||
# ── 2. wms_feature_info ───────────────────────────────────────────────────
|
||||
|
||||
async def wms_feature_info(
|
||||
self,
|
||||
layer_id: int,
|
||||
bbox: tuple[float, float, float, float],
|
||||
click_xy: tuple[int, int],
|
||||
width: int = 512,
|
||||
height: int = 512,
|
||||
) -> list[NSPDBulkFeature]:
|
||||
"""WMS GetFeatureInfo — объекты layer'а в bbox на pixel-клике.
|
||||
|
||||
GET /api/aeggis/v4/{layer_id}/wms?REQUEST=GetFeatureInfo&...
|
||||
|
||||
Используется для grid-walk фазы: каждая ячейка bbox квартала
|
||||
кликается в центре (click_xy = (width//2, height//2)).
|
||||
|
||||
Args:
|
||||
layer_id: NSPD layer ID (например 36368 для ЗУ ЕГРН).
|
||||
bbox: (x_min, y_min, x_max, y_max) в EPSG:3857 (метры).
|
||||
click_xy: (I, J) пиксель-координаты клика в [0..width-1, 0..height-1].
|
||||
width: ширина виртуального tile.
|
||||
height: высота виртуального tile.
|
||||
|
||||
Returns:
|
||||
Список NSPDBulkFeature (может быть пуст).
|
||||
|
||||
Raises:
|
||||
NspdBulkWafError при 403.
|
||||
NspdBulkError при прочих ошибках.
|
||||
"""
|
||||
xmin, ymin, xmax, ymax = bbox
|
||||
click_i, click_j = click_xy
|
||||
|
||||
url = f"{NSPD_WMS_BASE}/{layer_id}/wms"
|
||||
params: dict[str, Any] = {
|
||||
"REQUEST": "GetFeatureInfo",
|
||||
"SERVICE": "WMS",
|
||||
"VERSION": "1.3.0",
|
||||
"INFO_FORMAT": "application/json",
|
||||
"LAYERS": str(layer_id),
|
||||
"QUERY_LAYERS": str(layer_id),
|
||||
"CRS": "EPSG:3857",
|
||||
"BBOX": f"{xmin},{ymin},{xmax},{ymax}",
|
||||
"WIDTH": str(width),
|
||||
"HEIGHT": str(height),
|
||||
"I": str(click_i),
|
||||
"J": str(click_j),
|
||||
"STYLES": "",
|
||||
"TRANSPARENT": "true",
|
||||
"FORMAT": "image/png",
|
||||
"FEATURE_COUNT": "10",
|
||||
}
|
||||
|
||||
data = await self._get_json(url, params=params)
|
||||
raw_features: list[dict[str, Any]] = (data or {}).get("features") or []
|
||||
return [NSPDBulkFeature.model_validate(f) for f in raw_features]
|
||||
|
||||
# ── 3. list_objects_in_building ───────────────────────────────────────────
|
||||
# Q3 deferred — метод реализован, но не вызывается в bulk_harvest_quarter MVP.
|
||||
# Готов для per-building помещения/парковка фазы.
|
||||
|
||||
async def list_objects_in_building(
|
||||
self,
|
||||
objdoc_id: int,
|
||||
registers_id: int = 36441,
|
||||
) -> ObjectsListing:
|
||||
"""Список помещений и машино-мест в здании.
|
||||
|
||||
GET /api/geoportal/v1/tab-group-data?tabClass=objectsList
|
||||
&objdocId={objdoc_id}®istersId={registers_id}
|
||||
|
||||
Response shape:
|
||||
{title: "Список объектов", object: [
|
||||
{title: "Помещения (количество)", value: ["264"]},
|
||||
{title: "Машино-места (количество)", value: ["4"]},
|
||||
{title: "Помещения (список)", value: [...cad_nums]},
|
||||
{title: "Машино-места (список)", value: [...cad_nums]},
|
||||
]}
|
||||
|
||||
NOTE: Q3 deferred — метод готов, но НЕ вызывается в PR 2/3 MVP.
|
||||
Использовать при реализации bulk_harvest_flats (отдельный task).
|
||||
|
||||
Args:
|
||||
objdoc_id: NSPD internal object document ID (из NSPDOptions.objdoc_id).
|
||||
registers_id: NSPD registers ID (36441 — здания ЕГРН по умолчанию).
|
||||
|
||||
Returns:
|
||||
ObjectsListing с counts и cad_nums списками.
|
||||
|
||||
Raises:
|
||||
NspdBulkWafError при 403.
|
||||
NspdBulkError при прочих ошибках.
|
||||
"""
|
||||
data = await self._get_json(
|
||||
NSPD_TAB_GROUP_URL,
|
||||
params={
|
||||
"tabClass": "objectsList",
|
||||
"objdocId": str(objdoc_id),
|
||||
"registersId": str(registers_id),
|
||||
},
|
||||
)
|
||||
|
||||
flats_count = 0
|
||||
parking_count = 0
|
||||
flats_cad_nums: list[str] = []
|
||||
parking_cad_nums: list[str] = []
|
||||
|
||||
obj_list: list[dict[str, Any]] = (data or {}).get("object") or []
|
||||
for entry in obj_list:
|
||||
title: str = (entry.get("title") or "").lower()
|
||||
values: list[Any] = entry.get("value") or []
|
||||
|
||||
if "помещения" in title and "количество" in title:
|
||||
try:
|
||||
flats_count = int(values[0]) if values else 0
|
||||
except (ValueError, TypeError):
|
||||
flats_count = 0
|
||||
|
||||
elif "машино-места" in title and "количество" in title:
|
||||
try:
|
||||
parking_count = int(values[0]) if values else 0
|
||||
except (ValueError, TypeError):
|
||||
parking_count = 0
|
||||
|
||||
elif "помещения" in title and "список" in title:
|
||||
flats_cad_nums = [str(v) for v in values if v]
|
||||
|
||||
elif "машино-места" in title and "список" in title:
|
||||
parking_cad_nums = [str(v) for v in values if v]
|
||||
|
||||
return ObjectsListing(
|
||||
objdoc_id=objdoc_id,
|
||||
flats_count=flats_count,
|
||||
parking_count=parking_count,
|
||||
flats_cad_nums=flats_cad_nums,
|
||||
parking_cad_nums=parking_cad_nums,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"NSPDBulkClient",
|
||||
"NspdBulkError",
|
||||
"NspdBulkRateLimitError",
|
||||
"NspdBulkWafError",
|
||||
]
|
||||
0
backend/tests/scrapers/__init__.py
Normal file
0
backend/tests/scrapers/__init__.py
Normal file
491
backend/tests/scrapers/test_nspd_bulk_client.py
Normal file
491
backend/tests/scrapers/test_nspd_bulk_client.py
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
"""Тесты для NSPDBulkClient (nspd_bulk_client.py) — PR 2/5 issue #168.
|
||||
|
||||
Структура:
|
||||
- Unit тесты (без сети) — параметры, семафор, parse-логика
|
||||
- E2E тесты (@pytest.mark.slow + @pytest.mark.skip по умолчанию) —
|
||||
реальные запросы к NSPD, CI не дёргает
|
||||
|
||||
Запуск e2e вручную:
|
||||
cd backend && uv run pytest tests/scrapers/test_nspd_bulk_client.py -m slow -s -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas.nspd_bulk import NSPDBulkFeature, ObjectsListing, QuarterSnapshot
|
||||
from app.scrapers.nspd_bulk_client import (
|
||||
NSPDBulkClient,
|
||||
NspdBulkError,
|
||||
NspdBulkWafError,
|
||||
)
|
||||
|
||||
# ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_quarter_response() -> dict[str, Any]:
|
||||
"""Минимальный валидный ответ search_by_quarter из NSPD."""
|
||||
return {
|
||||
"data": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"id": 1001,
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[6700000.0, 7700000.0],
|
||||
[6701000.0, 7700000.0],
|
||||
[6701000.0, 7701000.0],
|
||||
[6700000.0, 7701000.0],
|
||||
[6700000.0, 7700000.0],
|
||||
],
|
||||
],
|
||||
},
|
||||
"properties": {
|
||||
"categoryId": 36368,
|
||||
"categoryName": "Земельные участки ЕГРН",
|
||||
"options": {
|
||||
"cad_num": "66:41:0303161:123",
|
||||
"area": 500.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 2002,
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [6700500.0, 7700500.0]},
|
||||
"properties": {
|
||||
"categoryId": 36369,
|
||||
"categoryName": "Здания",
|
||||
"options": {
|
||||
"cad_num": "66:41:0303161:456:1",
|
||||
"floors": "5",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
"meta": [
|
||||
{"categoryId": 36368, "totalCount": 170},
|
||||
{"categoryId": 36369, "totalCount": 256},
|
||||
{"categoryId": 36383, "totalCount": 75},
|
||||
{"categoryId": 37163, "totalCount": 5877},
|
||||
{"categoryId": 39663, "totalCount": 1},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_wms_response() -> dict[str, Any]:
|
||||
"""Минимальный WMS GetFeatureInfo response."""
|
||||
return {
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
{
|
||||
"id": 3003,
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [
|
||||
[
|
||||
[6700100.0, 7700100.0],
|
||||
[6700900.0, 7700100.0],
|
||||
[6700900.0, 7700900.0],
|
||||
[6700100.0, 7700900.0],
|
||||
[6700100.0, 7700100.0],
|
||||
],
|
||||
],
|
||||
},
|
||||
"properties": {
|
||||
"categoryId": 36368,
|
||||
"options": {"cad_num": "66:41:0303161:789"},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_tab_group_response() -> dict[str, Any]:
|
||||
"""Минимальный tab-group-data response для здания."""
|
||||
return {
|
||||
"title": "Список объектов",
|
||||
"object": [
|
||||
{"title": "Помещения (количество)", "value": ["264"]},
|
||||
{"title": "Машино-места (количество)", "value": ["4"]},
|
||||
{
|
||||
"title": "Помещения (список)",
|
||||
"value": ["66:41:0303161:100:1", "66:41:0303161:100:2"],
|
||||
},
|
||||
{
|
||||
"title": "Машино-места (список)",
|
||||
"value": ["66:41:0303161:100:265", "66:41:0303161:100:266"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Unit tests: schemas ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_nspd_bulk_feature_parse_basic() -> None:
|
||||
"""NSPDBulkFeature парсит id, geometry, properties."""
|
||||
raw = {
|
||||
"id": 42,
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [6700000.0, 7700000.0]},
|
||||
"properties": {
|
||||
"categoryId": 36368,
|
||||
"options": {"cad_num": "66:41:0303161:1", "area": 300.0},
|
||||
},
|
||||
}
|
||||
feat = NSPDBulkFeature.model_validate(raw)
|
||||
assert feat.id == 42
|
||||
assert feat.category_id == 36368
|
||||
assert feat.cad_num == "66:41:0303161:1"
|
||||
assert feat.options.area == 300.0
|
||||
|
||||
|
||||
def test_nspd_bulk_feature_extra_fields_preserved() -> None:
|
||||
"""extra='allow' → неизвестные поля сохраняются."""
|
||||
raw = {
|
||||
"id": 1,
|
||||
"geometry": None,
|
||||
"properties": {},
|
||||
"unknown_future_field": "value",
|
||||
}
|
||||
feat = NSPDBulkFeature.model_validate(raw)
|
||||
assert feat.model_extra["unknown_future_field"] == "value" # type: ignore[index]
|
||||
|
||||
|
||||
def test_nspd_options_effective_cad_num_prefers_cad_num() -> None:
|
||||
"""effective_cad_num берёт cad_num если оба заданы."""
|
||||
from app.schemas.nspd_bulk import NSPDOptions
|
||||
|
||||
opts = NSPDOptions(cad_num="66:41:0303161:1", cad_number="66:41:0303161:2")
|
||||
assert opts.effective_cad_num == "66:41:0303161:1"
|
||||
|
||||
|
||||
def test_nspd_options_fallback_to_cad_number() -> None:
|
||||
"""effective_cad_num fallback → cad_number если cad_num is None."""
|
||||
from app.schemas.nspd_bulk import NSPDOptions
|
||||
|
||||
opts = NSPDOptions(cad_number="66:41:0303161:2")
|
||||
assert opts.effective_cad_num == "66:41:0303161:2"
|
||||
|
||||
|
||||
def test_quarter_snapshot_overflow_categories() -> None:
|
||||
"""overflow_categories = категории с totalCount > 20."""
|
||||
snap = QuarterSnapshot(
|
||||
quarter="66:41:0303161",
|
||||
fetched_at="2026-05-15T00:00:00+00:00",
|
||||
meta_counts={36368: 170, 36369: 256, 36383: 5, 39663: 1},
|
||||
)
|
||||
overflow = snap.overflow_categories
|
||||
assert 36368 in overflow
|
||||
assert 36369 in overflow
|
||||
assert 36383 not in overflow # 5 <= 20
|
||||
assert 39663 not in overflow # 1 <= 20
|
||||
|
||||
|
||||
def test_objects_listing_defaults() -> None:
|
||||
"""ObjectsListing дефолтные значения."""
|
||||
listing = ObjectsListing(objdoc_id=12345)
|
||||
assert listing.flats_count == 0
|
||||
assert listing.parking_count == 0
|
||||
assert listing.flats_cad_nums == []
|
||||
|
||||
|
||||
# ── Unit tests: client parse logic (mocked HTTP) ─────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_quarter_parses_features(
|
||||
sample_quarter_response: dict[str, Any],
|
||||
) -> None:
|
||||
"""search_by_quarter корректно парсит features и meta_counts."""
|
||||
async with NSPDBulkClient() as client:
|
||||
with patch.object(client, "_get_json", new=AsyncMock(return_value=sample_quarter_response)):
|
||||
snap = await client.search_by_quarter("66:41:0303161")
|
||||
|
||||
assert snap.quarter == "66:41:0303161"
|
||||
assert len(snap.features) == 2
|
||||
assert snap.meta_counts[36368] == 170
|
||||
assert snap.meta_counts[36369] == 256
|
||||
assert snap.meta_counts[37163] == 5877
|
||||
# overflow: категории с >20
|
||||
overflow = snap.overflow_categories
|
||||
assert 36368 in overflow
|
||||
assert 36369 in overflow
|
||||
assert 36383 in overflow
|
||||
assert 37163 in overflow
|
||||
assert 39663 not in overflow # totalCount=1 → no overflow
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_quarter_empty_on_404() -> None:
|
||||
"""search_by_quarter возвращает пустой snapshot при 404."""
|
||||
async with NSPDBulkClient() as client:
|
||||
with patch.object(
|
||||
client,
|
||||
"_get_json",
|
||||
new=AsyncMock(side_effect=NspdBulkError("HTTP 404: not found")),
|
||||
):
|
||||
snap = await client.search_by_quarter("66:41:9999999")
|
||||
|
||||
assert snap.features == []
|
||||
assert snap.meta_counts == {}
|
||||
assert snap.quarter == "66:41:9999999"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_quarter_raises_on_waf() -> None:
|
||||
"""search_by_quarter пробрасывает NspdBulkWafError (403) без retry."""
|
||||
async with NSPDBulkClient() as client:
|
||||
with patch.object(
|
||||
client,
|
||||
"_get_json",
|
||||
new=AsyncMock(side_effect=NspdBulkWafError("HTTP 403 WAF")),
|
||||
):
|
||||
with pytest.raises(NspdBulkWafError):
|
||||
await client.search_by_quarter("66:41:0303161")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wms_feature_info_parses_features(
|
||||
sample_wms_response: dict[str, Any],
|
||||
) -> None:
|
||||
"""wms_feature_info парсит features из GeoJSON response."""
|
||||
async with NSPDBulkClient() as client:
|
||||
with patch.object(client, "_get_json", new=AsyncMock(return_value=sample_wms_response)):
|
||||
features = await client.wms_feature_info(
|
||||
layer_id=36368,
|
||||
bbox=(6700000.0, 7700000.0, 6701000.0, 7701000.0),
|
||||
click_xy=(256, 256),
|
||||
)
|
||||
|
||||
assert len(features) == 1
|
||||
assert features[0].category_id == 36368
|
||||
assert features[0].cad_num == "66:41:0303161:789"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wms_feature_info_empty_response() -> None:
|
||||
"""wms_feature_info возвращает [] при пустом response."""
|
||||
async with NSPDBulkClient() as client:
|
||||
with patch.object(client, "_get_json", new=AsyncMock(return_value={"features": []})):
|
||||
features = await client.wms_feature_info(
|
||||
layer_id=36368,
|
||||
bbox=(6700000.0, 7700000.0, 6701000.0, 7701000.0),
|
||||
click_xy=(256, 256),
|
||||
)
|
||||
assert features == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_objects_in_building_parses(
|
||||
sample_tab_group_response: dict[str, Any],
|
||||
) -> None:
|
||||
"""list_objects_in_building парсит counts и cad_nums."""
|
||||
async with NSPDBulkClient() as client:
|
||||
with patch.object(
|
||||
client, "_get_json", new=AsyncMock(return_value=sample_tab_group_response)
|
||||
):
|
||||
listing = await client.list_objects_in_building(objdoc_id=42065602)
|
||||
|
||||
assert listing.objdoc_id == 42065602
|
||||
assert listing.flats_count == 264
|
||||
assert listing.parking_count == 4
|
||||
assert "66:41:0303161:100:1" in listing.flats_cad_nums
|
||||
assert "66:41:0303161:100:265" in listing.parking_cad_nums
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_semaphore_max_3_concurrent() -> None:
|
||||
"""Не более 3 одновременных запросов через _SEMAPHORE.
|
||||
|
||||
Мокируем httpx.AsyncClient.get (нижний уровень) с задержкой, чтобы
|
||||
реальный семафор работал. Считаем max in-flight внутри семафора.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
import app.scrapers.nspd_bulk_client as bulk_mod
|
||||
|
||||
max_concurrent = 0
|
||||
current = 0
|
||||
lock = asyncio.Lock()
|
||||
call_count = 0
|
||||
|
||||
async def slow_get(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
nonlocal max_concurrent, current, call_count
|
||||
# Фиксируем вход — уже внутри семафора (httpx.get вызывается после async with _SEMAPHORE)
|
||||
async with lock:
|
||||
current += 1
|
||||
call_count += 1
|
||||
if current > max_concurrent:
|
||||
max_concurrent = current
|
||||
await asyncio.sleep(0.02) # симулируем I/O задержку
|
||||
async with lock:
|
||||
current -= 1
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"data": {"features": [], "meta": []}},
|
||||
request=MagicMock(),
|
||||
)
|
||||
|
||||
async with NSPDBulkClient() as client:
|
||||
assert client._client is not None
|
||||
with patch.object(client._client, "get", new=AsyncMock(side_effect=slow_get)):
|
||||
tasks = [client.search_by_quarter(f"66:41:{i:07d}") for i in range(6)]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# _SEMAPHORE(3) → не более 3 одновременно внутри slow_get
|
||||
assert max_concurrent <= 3, f"Expected ≤3 concurrent, got {max_concurrent}"
|
||||
assert call_count == 6 # все 6 вызовов прошли
|
||||
# Убедимся что _SEMAPHORE в модуле имеет нужное значение capacity
|
||||
assert bulk_mod._SEMAPHORE._value >= 0 # семафор сброшен после всех задач
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_403_raises_waf_no_retry() -> None:
|
||||
"""403 → NspdBulkWafError немедленно, без retry."""
|
||||
import httpx
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(403, text="Forbidden", request=MagicMock())
|
||||
|
||||
async with NSPDBulkClient() as client:
|
||||
assert client._client is not None
|
||||
with patch.object(client._client, "get", new=AsyncMock(side_effect=fake_get)):
|
||||
with pytest.raises(NspdBulkWafError):
|
||||
await client._get_json("https://nspd.gov.ru/test")
|
||||
|
||||
assert call_count == 1 # ровно 1 попытка, нет retry
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_429_backoff_exhausted() -> None:
|
||||
"""429 → exponential backoff 3 retries → NspdBulkRateLimitError."""
|
||||
import httpx
|
||||
|
||||
from app.scrapers.nspd_bulk_client import NspdBulkRateLimitError
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def fake_get(*args: Any, **kwargs: Any) -> httpx.Response:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return httpx.Response(429, text="Too Many Requests", request=MagicMock())
|
||||
|
||||
# Патчим sleep чтобы тест не ждал 2+4+8 секунд
|
||||
async with NSPDBulkClient(max_retries=3) as client:
|
||||
assert client._client is not None
|
||||
with (
|
||||
patch.object(client._client, "get", new=AsyncMock(side_effect=fake_get)),
|
||||
patch("app.scrapers.nspd_bulk_client.asyncio.sleep", new=AsyncMock()),
|
||||
):
|
||||
with pytest.raises(NspdBulkRateLimitError):
|
||||
await client._get_json("https://nspd.gov.ru/test")
|
||||
|
||||
# 1 оригинальный + 3 retry = 4 вызова
|
||||
assert call_count == 4
|
||||
|
||||
|
||||
# ── E2E tests (network, skipped by default) ───────────────────────────────────
|
||||
|
||||
pytestmark_slow = pytest.mark.slow
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_quarter_real_ekb() -> None:
|
||||
"""E2E: поиск по реальному кварталу ЕКБ — 66:41:0303161.
|
||||
|
||||
Проверяет:
|
||||
- features > 100 (квартал непустой)
|
||||
- meta содержит ключи 36368/36369/36383 с totalCount > 0
|
||||
- overflow_categories не пуст (значит totalCount > 20 для хотя бы одной)
|
||||
- headers корректны (нет 403/429)
|
||||
"""
|
||||
async with NSPDBulkClient() as client:
|
||||
snap = await client.search_by_quarter("66:41:0303161")
|
||||
|
||||
assert len(snap.features) > 0, "Ожидали хотя бы 1 feature"
|
||||
# Meta должна содержать основные категории
|
||||
assert 36368 in snap.meta_counts, "Нет categoryId 36368 (ЗУ ЕГРН) в meta"
|
||||
assert 36369 in snap.meta_counts, "Нет categoryId 36369 (Здания) в meta"
|
||||
assert snap.meta_counts[36368] > 0, "ЗУ ЕГРН totalCount должен быть > 0"
|
||||
assert snap.meta_counts[36369] > 0, "Здания totalCount должен быть > 0"
|
||||
# В этом квартале >170 ЗУ и >256 зданий → overflow
|
||||
assert len(snap.overflow_categories) > 0, "Ожидали overflow_categories непустой"
|
||||
# total_meta_count > 100 (квартал богатый данными)
|
||||
assert snap.total_meta_count > 100
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_by_quarter_empty() -> None:
|
||||
"""E2E: несуществующий квартал → пустой snapshot."""
|
||||
async with NSPDBulkClient() as client:
|
||||
snap = await client.search_by_quarter("66:41:9999999")
|
||||
|
||||
# Должно вернуть пустой snapshot (не raise)
|
||||
assert snap.features == []
|
||||
assert snap.quarter == "66:41:9999999"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
|
||||
@pytest.mark.asyncio
|
||||
async def test_wms_feature_info_real() -> None:
|
||||
"""E2E: WMS GetFeatureInfo реальный bbox из ЕКБ.
|
||||
|
||||
Использует bbox центра квартала 66:41:0303161 в EPSG:3857.
|
||||
"""
|
||||
# Центральная точка квартала 66:41:0303161 в 3857 (примерно)
|
||||
# ЕКБ центр ≈ 60.6°E, 56.84°N → 3857 ≈ (6748000, 7772000)
|
||||
center_x = 6748000.0
|
||||
center_y = 7772000.0
|
||||
half = 500.0 # 500м bbox
|
||||
bbox = (center_x - half, center_y - half, center_x + half, center_y + half)
|
||||
|
||||
async with NSPDBulkClient() as client:
|
||||
features = await client.wms_feature_info(
|
||||
layer_id=36368, # ЗУ ЕГРН
|
||||
bbox=bbox,
|
||||
click_xy=(256, 256),
|
||||
width=512,
|
||||
height=512,
|
||||
)
|
||||
|
||||
# Хотя бы 1 feature в bbox центра ЕКБ квартала
|
||||
assert len(features) >= 1, f"Ожидали ≥1 feature, получили {len(features)}"
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.skip(reason="E2E: требует сеть NSPD — запускать вручную с -m slow")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_objects_in_building_real() -> None:
|
||||
"""E2E: реальный objdocId=42065602 → >150 flats_cad_nums."""
|
||||
async with NSPDBulkClient() as client:
|
||||
listing = await client.list_objects_in_building(objdoc_id=42065602)
|
||||
|
||||
assert listing.objdoc_id == 42065602
|
||||
assert listing.flats_count > 150, f"Ожидали >150 помещений, получили {listing.flats_count}"
|
||||
assert (
|
||||
len(listing.flats_cad_nums) > 150
|
||||
), f"Ожидали >150 cad_nums помещений, получили {len(listing.flats_cad_nums)}"
|
||||
Loading…
Add table
Reference in a new issue