gendesign/backend/app/services/scrapers/nspd_client.py
lekss361 570c987e78 fix(#231): repair test_rate_limit_semaphore regression + docstring sync
Re-review feedback (PR #260 head 8191a85):

1. tests/scrapers/test_nspd_bulk_client.py:360 — test referenced bulk_mod._SEMAPHORE
   which was renamed to _SEMAPHORE_LIMIT in previous commit. AttributeError на
   полном pytest run. Заменено: assert bulk_mod._SEMAPHORE_LIMIT == 3 (smoke).
   max_concurrent <= 3 уже валидирует throttling — capacity check был лишним.

2. app/services/scrapers/nspd_client.py docstrings (lines 483, 506) — обновлены
   ссылки NSPDBulkClient._SEMAPHORE(3) → NSPDBulkClient._sem (per-instance,
   capacity=3) чтобы соответствовать новой архитектуре.
2026-05-17 10:22:16 +03:00

909 lines
40 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 API client — foundation для G1/G2/G3/E1/#93.
Issue #94: при исследовании НСПД через debug browser найдено что
REST `/api/geoportal/v2/search/geoportal?query={cad}` возвращает GeoJSON
+ полные properties (ВРИ, категория земель, кадастровая стоимость, адрес)
за один запрос. Плюс WMS endpoints для тематических слоёв (ПЗЗ, ЗОУИТ,
территориальные зоны, risk zones).
Этот клиент покрывает 4 базовых метода API:
- search_by_cad(cad) → SearchResult — поиск по cad-номеру
- get_feature_info(layer_id, lon, lat) → list[Feature] — feature data на точке
- get_features_in_bbox(layer_id, bbox) → list[Feature] — bbox bulk через
GetFeatureInfo (WFS GetCapabilities → 404, workaround)
- list_layers(theme_id) → list[Layer] — каталог слоёв в теме
Reuse: `fetch_geoportal` из `nspd_lite.py` (WAF-compatible urllib + rate limit).
Расширения сверх nspd_lite:
- Typed wrappers (NSPDFeature, NSPDLayer, NSPDSearchResult)
- WMS GetFeatureInfo / GetMap helpers (нет в nspd_lite)
- Coordinate transform EPSG:3857 ↔ 4326 (NSPD WMS работает в Mercator)
WMS endpoints (per #94 issue body, TIER 1-6 каталог слоёв):
- TIER 1 критичные: 36048 ЗУ, 36071 кварталы, 36049 здания, 36328 сооружения,
875838 территориальные зоны (G1), 879243 красные линии
- TIER 2 ЗОУИТ (G3): 37577 ОКН, 37578 энергетики/связи, 37580 природные,
37579 охраняемых объектов, 37581 иные
- TIER 3 риски: 872202 подтопление, 872205 затопление, 872203 заболачивание,
872210 обвально-осыпные, 872211 абразия, etc.
"""
from __future__ import annotations
import asyncio
import datetime as _dt
import json
import logging
import math
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any
from app.services.scrapers.nspd_denorm import classify_engineering_kind
from app.services.scrapers.nspd_lite import (
_SSL_CTX,
HEADERS,
NspdLiteError,
NspdLiteWafError,
fetch_geoportal,
)
logger = logging.getLogger(__name__)
# WMS base — отдельный путь от geoportal/v2/search
NSPD_WMS_BASE = "https://nspd.gov.ru/api/aeggis/v4"
# Layers catalog endpoint
NSPD_LAYERS_TREE = "https://nspd.gov.ru/api/geoportal/v1/layers-theme-tree"
# Themes list
NSPD_THEMES = "https://nspd.gov.ru/api/geoportal/v1/layers-theme"
# Стандартные theme ID
THEME_PKK = 1
THEME_ARN = 665
# ── Layer ID catalog (TIER 1-6 per #94) ──────────────────────────────────────
# Не enum, а module-level dict — для удобства интроспекции и тестов.
# Использовать как: LAYERS["territorial_zones"] = 875838.
LAYERS: dict[str, int] = {
# TIER 1 — критичные для МКД (Gate-факторы)
"parcels": 36048, # Земельные участки ЕГРН
"quarters": 36071, # Кадастровые кварталы
"buildings": 36049, # Здания ЕГРН
"engineering_structures": 36328, # Сооружения ЕГРН
"territorial_zones": 875838, # ПЗЗ (G1 #28)
"red_lines": 879243, # Красные линии
# TIER 2 — ЗОУИТ (G3 #30)
"zouit_okn": 37577, # ОКН
"zouit_engineering": 37578, # энергетики/связи/транспорта
"zouit_natural": 37580, # природные (водоохранные, СЗО)
"zouit_protected": 37579, # охраняемых объектов (СЗЗ предприятий)
"zouit_other": 37581, # иные (публичный сервитут, ОЭЗ)
# TIER 3 — Risk / Negative
"risk_flooding_underground": 872202, # подтопление
"risk_flooding": 872205, # затопление
"risk_swampification": 872203, # заболачивание
"risk_landslide": 872210, # обвально-осыпные
"risk_abrasion": 872211, # абразия
"risk_erosion_water": 872153, # водная эрозия
"risk_erosion_linear": 872155,
"risk_erosion_wind": 872164,
"risk_desertification": 872182,
"risk_clutter": 872206, # захламление
"risk_burns": 872222, # гари
# TIER 4 — Opportunity / nature
"protected_areas": 875845, # ООПТ
"coast_lines_river": 875832,
"coast_lines_sea": 875835,
"forestries": 875866,
"auction_parcels": 37299, # ЗУ на аукционе — opportunity
"scheme_parcels": 37294, # ЗУ по схеме расположения
"free_parcels": 37298, # ЗУ свободные от прав
"future_parcels": 36473, # ЗУ по проекту межевания
"okn_territory": 875840,
"cadastral_cost_heatmap": 37236,
"cadastral_cost_per_m2_heatmap": 37758,
}
# Layers where grid-walk (get_features_in_bbox_grid) must be used instead of
# the legacy single-pixel WMS probe (get_features_in_bbox). These are area/
# linear layers (territorial zones, red lines, engineering structures, ЗОУИТ,
# risks) that span large areas and are under-returned by a single GetFeatureInfo
# call. Point/polygon EGRN layers (parcels, buildings) stay on legacy for now.
_GRID_WALK_LAYERS: frozenset[str] = frozenset(
{
"territorial_zones",
"red_lines",
"engineering_structures",
# ЗОУИТ (TIER 2)
"zouit_okn",
"zouit_engineering",
"zouit_natural",
"zouit_protected",
"zouit_other",
# Risks (TIER 3)
"risk_flooding_underground",
"risk_flooding",
"risk_swampification",
"risk_landslide",
"risk_abrasion",
"risk_erosion_water",
"risk_erosion_linear",
"risk_erosion_wind",
"risk_desertification",
"risk_clutter",
"risk_burns",
}
)
# Default rate limit (мс между запросами) — баланс между скоростью и WAF
DEFAULT_RATE_MS = 600
# SSL context — reuse `_SSL_CTX` из nspd_lite (one source of truth для WAF-
# compatible TLS settings; нет дублирования если что-то поменяется).
# ── Typed responses ──────────────────────────────────────────────────────────
@dataclass(frozen=True, slots=True)
class NSPDFeature:
"""Парсенный GeoJSON Feature из NSPD API.
`geometry` — raw GeoJSON dict (Polygon/LineString/Point в EPSG:3857).
`properties` — все returned attributes (cad_num, ВРИ, кост и т.д.).
"""
feature_id: str | None
geometry: dict[str, Any] | None
properties: dict[str, Any]
crs: str = "EPSG:3857" # NSPD default; для search /v2 фактически 3857
@classmethod
def from_raw(cls, raw: dict[str, Any]) -> NSPDFeature:
"""Парсим GeoJSON Feature dict в typed NSPDFeature."""
props = raw.get("properties") or {}
return cls(
feature_id=str(raw.get("id")) if raw.get("id") is not None else None,
geometry=raw.get("geometry"),
properties=props,
)
@dataclass(frozen=True, slots=True)
class NSPDSearchResult:
"""Aggregated результат поиска по cad — typed wrapper над feature list."""
cad_num: str
features: list[NSPDFeature]
raw: dict[str, Any] # original response для debugging / future-proofing
@property
def first(self) -> NSPDFeature | None:
"""Удобный shortcut: features[0] или None."""
return self.features[0] if self.features else None
@property
def is_empty(self) -> bool:
return not self.features
@dataclass(frozen=True, slots=True)
class NSPDLayer:
"""Метаданные layer'а из layers-theme-tree response."""
layer_id: int
title: str
layer_type: str | None # 'wms' | 'wfs' | etc
metadata: dict[str, Any]
@dataclass(frozen=True, slots=True)
class QuarterDump:
"""Comprehensive snapshot of NSPD data в пределах одного quarter.
Foundation для PKK harvest pipeline (#94 follow-up):
- `quarter` — собственно квартал (geometry в EPSG:3857)
- per-layer feature lists в пределах квартала (bbox-filtered)
Один dump = достаточно данных чтобы analyze_parcel мог отдать ВСЕ Gate-
факторы (G1 ПЗЗ, G3 ЗОУИТ), neighbors, инженерку — без новых NSPD-вызовов
в request-цикле. Беём дешевле для пользователя (1 HTTP вместо N).
Layers распределены по 3 phases:
- core: parcels + buildings + territorial_zones + red_lines + engineering
- zouit: 5 ЗОУИТ layers (G3)
- risks: 11 risk-zone layers (TIER 3)
Default = только core, чтобы не сжигать rate-limit на 17 запросов.
"""
quarter_cad: str
quarter: NSPDFeature | None # сам polygon квартала (может быть None если NSPD пуст)
parcels: list[NSPDFeature]
buildings: list[NSPDFeature]
territorial_zones: list[NSPDFeature] # ПЗЗ зоны покрывающие/пересекающие
red_lines: list[NSPDFeature]
engineering_structures: list[NSPDFeature]
zouit: dict[str, list[NSPDFeature]] # {"okn": [...], "engineering": [...], ...}
risks: dict[str, list[NSPDFeature]] # {"flooding": [...], "landslide": [...], ...}
# TIER 4 opportunity layers (issue #94 PR2).
# {"auction_parcels": [...], "scheme_parcels": [...], "free_parcels": [...], ...}
opportunity: dict[str, list[NSPDFeature]]
# tuple, не list — frozen dataclass + immutable contents (audit/debug snapshot)
layers_fetched: tuple[str, ...]
bbox_3857: tuple[float, float, float, float] | None # bbox квартала
fetched_at_utc: str # ISO timestamp когда сделали запрос (для freshness check)
@property
def total_features(self) -> int:
"""Сумма всех features во всех layers — для smoke check."""
return (
len(self.parcels)
+ len(self.buildings)
+ len(self.territorial_zones)
+ len(self.red_lines)
+ len(self.engineering_structures)
+ sum(len(v) for v in self.zouit.values())
+ sum(len(v) for v in self.risks.values())
+ sum(len(v) for v in self.opportunity.values())
)
# ── HTTP helper ──────────────────────────────────────────────────────────────
def _http_get_json(url: str, *, timeout: int = 15, rate_ms: int = 0) -> Any:
"""Внутренний GET-JSON helper с WAF-handling, paralleling nspd_lite.
Используется для endpoints которые не покрыты `fetch_geoportal` (WMS,
layers-tree). Reuse HEADERS + SSL context из nspd_lite — общая защита от
WAF блокировки по TLS fingerprint.
"""
if rate_ms > 0:
time.sleep(rate_ms / 1000.0)
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
# ── Coordinate transforms ────────────────────────────────────────────────────
def lonlat_to_3857(lon: float, lat: float) -> tuple[float, float]:
"""WGS84 lon/lat → EPSG:3857 Web Mercator (метры).
Стандартная формула; стандартная для всех Web mapping. NSPD WMS принимает
bbox в 3857.
"""
x = lon * 20037508.34 / 180.0
y = math.log(math.tan((90.0 + lat) * math.pi / 360.0)) / (math.pi / 180.0)
y = y * 20037508.34 / 180.0
return (x, y)
def bbox_around_point_m(
lon: float,
lat: float,
buffer_m: int,
) -> tuple[float, float, float, float]:
"""Микро-bbox вокруг точки в EPSG:3857 — для WMS GetFeatureInfo.
Returns (xmin, ymin, xmax, ymax). buffer_m по m в каждую сторону.
"""
x, y = lonlat_to_3857(lon, lat)
return (x - buffer_m, y - buffer_m, x + buffer_m, y + buffer_m)
# ── Public client API ────────────────────────────────────────────────────────
class NSPDClient:
"""Foundation-level NSPD client. 4 core methods.
Stateless — все методы могут вызываться независимо. Используй один instance
на process (rate-limit shared между вызовами через time.sleep).
"""
def __init__(self, rate_ms: int = DEFAULT_RATE_MS, timeout: int = 15) -> None:
self.rate_ms = rate_ms
self.timeout = timeout
# ── 1. search_by_cad ────────────────────────────────────────────────────
def search_by_cad(self, cad_num: str, thematic_id: int = 1) -> NSPDSearchResult:
"""REST поиск /v2/search/geoportal — 1 запрос = geometry + ВРИ + кост.
Args:
cad_num: канонический формат — `NN:NN:NNNNNN[:NN[:NN]]`, цифры через `:`.
Примеры:
- 3-сегментный квартал: `'66:41:0204016'`
- 4-сегментный участок: `'66:41:0204016:10'`
- 5-сегментное здание: `'66:41:0204016:10:1'`
Спецсимволы / пробелы корректно URL-encode'нутся (нет SQL/URL
injection), но НСПД молча вернёт empty result. Caller обязан
сам валидировать формат если нужна defensive обработка (см.
`app.services.site_finder.cadastre_fetch.validate_cad_format`).
thematic_id: 1 ЗУ / 2 квартал / 5 здание / 7 территориальная зона.
Default 1 — соответствует use-case G2 ВРИ.
Returns:
NSPDSearchResult с typed features. `result.is_empty` → cad не найден.
Raises:
NspdLiteWafError при 403/429 — caller должен сделать backoff.
NspdLiteError при прочих 4xx/5xx.
Закрывает: G2 #29 ВРИ, поддержка on-demand #93/#51.
"""
raw = fetch_geoportal(
cad_num,
thematic_id=thematic_id,
timeout=self.timeout,
rate_ms=self.rate_ms,
)
# Response shape: {"data": {"type": "FeatureCollection", "features": [...]}}
data = raw.get("data") or {}
feats = data.get("features") or []
features = [NSPDFeature.from_raw(f) for f in feats]
return NSPDSearchResult(cad_num=cad_num, features=features, raw=raw)
# ── 2. get_feature_info ─────────────────────────────────────────────────
def get_feature_info(
self,
layer_id: int,
lon: float,
lat: float,
buffer_m: int = 100,
) -> list[NSPDFeature]:
"""WMS GetFeatureInfo на точке — какие feature'ы layer'а покрывают (lon, lat).
Использует микро-bbox вокруг точки в EPSG:3857 (WMS требует bbox + I/J
пиксель). Tile 256x256, I=128, J=128 — центр.
Returns features (может быть 0+).
Use cases:
- ВРИ конкретного участка (layer_id=875838 territorial_zones)
- ЗОУИТ на участке (layer_id=37577..37581)
- Risk zones overlap (872xxx)
"""
xmin, ymin, xmax, ymax = bbox_around_point_m(lon, lat, buffer_m)
params = {
"SERVICE": "WMS",
"VERSION": "1.3.0",
"REQUEST": "GetFeatureInfo",
"FORMAT": "image/png",
"TRANSPARENT": "true",
"QUERY_LAYERS": str(layer_id),
"LAYERS": str(layer_id),
"INFO_FORMAT": "application/json",
# STYLES обязателен (пустой), иначе INFO_FORMAT игнорируется per NSPD WAF
"STYLES": "",
"I": "128",
"J": "128",
"WIDTH": "256",
"HEIGHT": "256",
"CRS": "EPSG:3857",
"BBOX": f"{xmin},{ymin},{xmax},{ymax}",
}
url = f"{NSPD_WMS_BASE}/{layer_id}/wms?{urllib.parse.urlencode(params)}"
data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms)
feats = (data or {}).get("features") or []
return [NSPDFeature.from_raw(f) for f in feats]
# ── 3. get_features_in_bbox ─────────────────────────────────────────────
def get_features_in_bbox(
self,
layer_id: int,
bbox_3857: tuple[float, float, float, float],
*,
width: int = 4096,
height: int = 4096,
) -> list[NSPDFeature]:
"""WMS GetFeatureInfo на одном центральном пикселе bbox.
DEPRECATED: возвращает 0-3 features под одним пикселем (I=W/2, J=H/2).
НЕ является bulk fetch несмотря на исходный docstring — WMS GetFeatureInfo
по стандарту OGC возвращает объекты строго под одной pixel-точкой, а не
во всём bbox. Для получения всех объектов в bbox используй
`get_features_in_bbox_grid`.
See: fixes/Bug_NSPD_WMS_NotBulk_2026_May14.md
Args:
bbox_3857: (xmin, ymin, xmax, ymax) в EPSG:3857 метрах.
width/height: размер виртуального tile.
Returns:
list[NSPDFeature]; пусто если ничего не найдено.
"""
xmin, ymin, xmax, ymax = bbox_3857
params = {
"SERVICE": "WMS",
"VERSION": "1.3.0",
"REQUEST": "GetFeatureInfo",
"FORMAT": "image/png",
"TRANSPARENT": "true",
"QUERY_LAYERS": str(layer_id),
"LAYERS": str(layer_id),
"INFO_FORMAT": "application/json",
"STYLES": "",
"I": str(width // 2),
"J": str(height // 2),
"WIDTH": str(width),
"HEIGHT": str(height),
"CRS": "EPSG:3857",
"BBOX": f"{xmin},{ymin},{xmax},{ymax}",
}
url = f"{NSPD_WMS_BASE}/{layer_id}/wms?{urllib.parse.urlencode(params)}"
data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms)
feats = (data or {}).get("features") or []
return [NSPDFeature.from_raw(f) for f in feats]
# ── 3b. get_features_in_bbox_grid ───────────────────────────────────────
def get_features_in_bbox_grid(
self,
layer_id: int,
bbox: tuple[float, float, float, float],
*,
grid_n: int = 7,
step_m: float = 50.0,
tile_size: int = 512,
) -> list[NSPDFeature]:
"""Bulk-аппроксимация bbox через grid-walk WMS GetFeatureInfo.
Разбивает bbox на grid_n × grid_n равных ячеек. В каждой ячейке
вызывает WMS GetFeatureInfo в центральном пикселе. Дедуплицирует
результаты по feature_id / cad_num / reg_numb_border — возвращает
список уникальных NSPDFeature.
Делегирует HTTP через NSPDBulkClient.wms_feature_info (async httpx
с semaphore и retry), запуская asyncio event loop синхронно через
asyncio.run(). Предназначен для вызова из синхронного кода (Celery
task, FastAPI sync handler).
Concurrency: NSPDBulkClient._sem (per-instance, capacity=3) ограничивает параллельные
запросы. При grid_n=7 (49 ячеек) — все 49 ячеек запускаются одним
gather; семафор пропускает не более 3 одновременно. Thread-safety:
каждый вызов get_features_in_bbox_grid создаёт новый event loop
через asyncio.run() — безопасно из разных Celery workers (process-
уровень изоляции).
Args:
layer_id: NSPD layer ID (например 36328 сооружения, 37578 ЗОУИТ).
bbox: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры).
grid_n: размер сетки по каждой оси. 7 → 49 запросов (~coarse),
15 → 225 запросов (~fine). По умолчанию 7 для первичного scan.
step_m: минимальный шаг ячейки в метрах. Если bbox меньше
grid_n*step_m — grid_n уменьшается автоматически чтобы
ячейки не становились меньше step_m.
tile_size: размер виртуального WMS тайла (пиксели).
Returns:
Дедуплицированный list[NSPDFeature]. Может быть пуст если в bbox
нет объектов данного layer'а.
Note:
Не делает live HTTP вызовы если вызван с mock NSPDBulkClient.
Rate-limit управляется семафором NSPDBulkClient._sem (per-instance, capacity=3) +
asyncio.sleep(0.05) jitter — не через self.rate_ms.
"""
# Импортируем здесь чтобы избежать circular import:
# nspd_client ← nspd_bulk_client (оба top-level scrapers, не cross-domain)
from app.scrapers.nspd_bulk_client import NSPDBulkClient
xmin, ymin, xmax, ymax = bbox
width_m = xmax - xmin
height_m = ymax - ymin
# Авто-коррекция grid_n если bbox мал для шага step_m
effective_n = min(
grid_n,
max(1, int(width_m / step_m)),
max(1, int(height_m / step_m)),
)
if effective_n < grid_n:
logger.info(
"get_features_in_bbox_grid layer=%d: bbox %.0fx%.0fm < grid_n=%d×step_m=%.0f"
" — уменьшаем grid до %d×%d",
layer_id,
width_m,
height_m,
grid_n,
step_m,
effective_n,
effective_n,
)
x_step = width_m / effective_n
y_step = height_m / effective_n
# Генерируем список (sub_bbox, click_xy) ячеек
cells: list[tuple[tuple[float, float, float, float], tuple[int, int]]] = []
click_px = tile_size // 2
for i in range(effective_n):
for j in range(effective_n):
cell_xmin = xmin + i * x_step
cell_ymin = ymin + j * y_step
cell_xmax = cell_xmin + x_step
cell_ymax = cell_ymin + y_step
cells.append(((cell_xmin, cell_ymin, cell_xmax, cell_ymax), (click_px, click_px)))
async def _run_grid() -> list[NSPDFeature]:
async with NSPDBulkClient() as client:
tasks = [
client.wms_feature_info(layer_id, sub_bbox, click_xy, tile_size, tile_size)
for sub_bbox, click_xy in cells
]
results = await asyncio.gather(*tasks, return_exceptions=True)
features: list[NSPDFeature] = []
for r in results:
if isinstance(r, Exception):
logger.warning("get_features_in_bbox_grid layer=%d cell error: %s", layer_id, r)
continue
for bulk_feat in r:
raw = {
"id": bulk_feat.id,
"geometry": bulk_feat.geometry,
"properties": bulk_feat.properties,
}
features.append(NSPDFeature.from_raw(raw))
return features
raw_features = asyncio.run(_run_grid())
# Дедупликация — приоритет ключей: feature_id > cad_num > reg_numb_border
seen: set[str] = set()
deduped: list[NSPDFeature] = []
for feat in raw_features:
props = feat.properties
dedup_key = (
feat.feature_id
or props.get("cad_num")
or props.get("cad_number")
or props.get("reg_numb_border")
or props.get("label")
)
if dedup_key is not None:
if dedup_key in seen:
continue
seen.add(dedup_key)
deduped.append(feat)
logger.info(
"get_features_in_bbox_grid layer=%d grid=%dx%d cells=%d raw=%d deduped=%d",
layer_id,
effective_n,
effective_n,
len(cells),
len(raw_features),
len(deduped),
)
return deduped
# ── 4. list_layers ──────────────────────────────────────────────────────
def list_layers(self, theme_id: int = THEME_PKK) -> list[NSPDLayer]:
"""Полный каталог слоёв в теме (PKK / ARN).
Use case: admin UI «выбрать layer для bulk-импорта», discovery новых
слоёв при изменении NSPD каталога.
"""
url = f"{NSPD_LAYERS_TREE}?themeId={theme_id}"
data = _http_get_json(url, timeout=self.timeout, rate_ms=self.rate_ms)
# Shape defense: NSPD иногда оборачивает в {"data": [...]} — паттерн уже
# ловили в Bug_Nspd_Geo_Str_Object_No_Get_Fixed. Без проверки walker
# молча вернул бы [], а caller увидел бы пустой catalog.
if isinstance(data, dict) and "data" in data and not data.get("children"):
data = data.get("data") or []
if not isinstance(data, list | dict):
logger.warning(
"list_layers: unexpected NSPD response type %s for theme_id=%s",
type(data).__name__,
theme_id,
)
return []
layers: list[NSPDLayer] = []
# Tree response: nested {"children": [...], "id": N, "title": "..."}
# Используем walker по subtree чтобы вытащить все leaf layers
for entry in _walk_layer_tree(data):
layers.append(
NSPDLayer(
layer_id=int(entry["id"]),
title=str(entry.get("title", "")),
layer_type=entry.get("layerType") or entry.get("layer_type"),
metadata=entry,
)
)
return layers
# ── 5. search_by_quarter (PKK harvest orchestrator) ─────────────────────
# Layer groupings для search_by_quarter. Назван "phase" т.к. в Celery
# task может включаться выборочно (rate-limit budget).
QUARTER_CORE_LAYERS: dict[str, str] = { # noqa: RUF012 — class-level constant
"parcels": "parcels",
"buildings": "buildings",
"territorial_zones": "territorial_zones",
"red_lines": "red_lines",
"engineering_structures": "engineering_structures",
}
QUARTER_ZOUIT_LAYERS: dict[str, str] = { # noqa: RUF012
"okn": "zouit_okn",
"engineering": "zouit_engineering",
"natural": "zouit_natural",
"protected": "zouit_protected",
"other": "zouit_other",
}
QUARTER_RISK_LAYERS: dict[str, str] = { # noqa: RUF012
"flooding_underground": "risk_flooding_underground",
"flooding": "risk_flooding",
"swampification": "risk_swampification",
"landslide": "risk_landslide",
"abrasion": "risk_abrasion",
"erosion_water": "risk_erosion_water",
"erosion_linear": "risk_erosion_linear",
"erosion_wind": "risk_erosion_wind",
"desertification": "risk_desertification",
"clutter": "risk_clutter",
"burns": "risk_burns",
}
# TIER 4 — Opportunity layers (issue #94 PR2).
# short_name → LAYERS dict key (for get_features_in_bbox lookup).
# Features stored in features_json с layer = "opportunity_<short_name>".
QUARTER_OPPORTUNITY_LAYERS: dict[str, str] = { # noqa: RUF012
"auction_parcels": "auction_parcels", # 37299 — аукционные ЗУ
"scheme_parcels": "scheme_parcels", # 37294 — схемы расположения ЗУ
"free_parcels": "free_parcels", # 37298 — свободные ЗУ
"future_parcels": "future_parcels", # 36473 — планируемые ЗУ
"oopt": "protected_areas", # 875845 — ООПТ
}
def search_by_quarter(
self,
quarter_cad: str,
*,
include_zouit: bool = True,
include_risks: bool = False,
include_opportunity: bool = False,
) -> QuarterDump:
"""Harvest всех NSPD-данных для квартала: 1 vacuum, N layers.
Шаги:
1. `search_by_cad(quarter_cad, thematic_id=2)` — получить полигон квартала
2. Compute bbox в EPSG:3857 из quarter geometry (или None если NSPD пуст)
3. Для каждого core layer → `get_features_in_bbox(layer_id, bbox)`
4. Если include_zouit — то же для 5 ЗОУИТ layers
5. Если include_risks — то же для 11 risk layers
Стоимость HTTP:
- core only: 1 (search) + 5 (core layers) = 6 запросов
- +zouit: +5 = 11 запросов
- +risks: +11 = 22 запроса
При rate_ms=600 один dump = ~3.6с (core) / ~6.6с (+zouit) / ~13с (всё).
Args:
quarter_cad: 3-сегментный cad-номер квартала, e.g. '66:41:0204016'.
include_zouit: Включать TIER 2 ЗОУИТ layers (G3). Default True.
include_risks: Включать TIER 3 risk zones. Default False (rate-limit
budget; для отдельного D-N risk score можно включить).
include_opportunity: Включать TIER 4 opportunity layers (auction_parcels,
scheme_parcels, free_parcels, future_parcels, oopt). Default False.
+5 HTTP запросов при включении.
Returns:
QuarterDump с per-layer feature lists. Если NSPD пуст / quarter
не найден — `quarter=None`, `bbox_3857=None`, все feature lists
пустые (no bulk-fetch без bounds — нет смысла). При этом dict-
поля `zouit` / `risks` / `opportunity` всё равно populated с пустыми
lists для каждого включённого short_name
(структура контракта стабильна).
`layers_fetched` в этом случае содержит только `('search',)`.
Raises:
NspdLiteWafError при 403/429 на любом из layer запросов — caller
должен делать backoff. Partial-success НЕ возвращается; вся
операция атомарна (failure → exception).
Закрывает: foundation для G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors,
E1 #51 parcels backfill, #96 ЕГРН помещения, #94 PR2 opportunity.
"""
# 1. Quarter geometry через REST search
quarter_search = self.search_by_cad(quarter_cad, thematic_id=2)
quarter_feat: NSPDFeature | None = quarter_search.first
# 2. Compute bbox в 3857. Если NSPD ничего не нашёл — bbox=None,
# все layer-запросы возвращаем как empty (нет смысла bulk-fetch'ить
# без bounds).
bbox: tuple[float, float, float, float] | None = None
if quarter_feat and quarter_feat.geometry:
bbox = _geojson_bbox_3857(quarter_feat.geometry)
layers_fetched: list[str] = ["search"]
def _fetch_layer(name_in_dump: str, layer_key: str) -> list[NSPDFeature]:
"""Helper: безопасно получить features для одного layer.
Dispatch:
- area/linear layers (_GRID_WALK_LAYERS) → grid-walk
(get_features_in_bbox_grid, grid_n=7, step_m=50)
- point/polygon EGRN layers (parcels, buildings, ons, enk)
→ legacy single-pixel WMS (get_features_in_bbox)
Engineering structures features дополнительно обогащаются
properties["classified_kind"] через classify_engineering_kind.
"""
if bbox is None:
return []
layer_id = LAYERS.get(layer_key)
if layer_id is None:
logger.warning("search_by_quarter: unknown layer key %s", layer_key)
return []
layers_fetched.append(name_in_dump)
if layer_key in _GRID_WALK_LAYERS:
features = self.get_features_in_bbox_grid(layer_id, bbox, grid_n=7, step_m=50.0)
# Обогатить engineering_structures classified_kind
if layer_key == "engineering_structures":
for feat in features:
feat.properties["classified_kind"] = classify_engineering_kind(
feat.properties
)
logger.info(
"search_by_quarter layer=%s method=grid_walk count=%d quarter=%s",
name_in_dump,
len(features),
quarter_cad,
)
return features
features_legacy = self.get_features_in_bbox(layer_id, bbox)
logger.info(
"search_by_quarter layer=%s method=legacy count=%d quarter=%s",
name_in_dump,
len(features_legacy),
quarter_cad,
)
return features_legacy
# 3. Core layers
parcels = _fetch_layer("parcels", "parcels")
buildings = _fetch_layer("buildings", "buildings")
territorial_zones = _fetch_layer("territorial_zones", "territorial_zones")
red_lines = _fetch_layer("red_lines", "red_lines")
engineering_structures = _fetch_layer("engineering_structures", "engineering_structures")
# 4. ЗОУИТ (G3)
zouit: dict[str, list[NSPDFeature]] = {}
if include_zouit:
for short_name, layer_key in self.QUARTER_ZOUIT_LAYERS.items():
zouit[short_name] = _fetch_layer(f"zouit_{short_name}", layer_key)
# 5. Risks (TIER 3)
risks: dict[str, list[NSPDFeature]] = {}
if include_risks:
for short_name, layer_key in self.QUARTER_RISK_LAYERS.items():
risks[short_name] = _fetch_layer(f"risk_{short_name}", layer_key)
# 6. Opportunity layers (TIER 4, issue #94 PR2)
opportunity: dict[str, list[NSPDFeature]] = {}
if include_opportunity:
for short_name, layer_key in self.QUARTER_OPPORTUNITY_LAYERS.items():
opportunity[short_name] = _fetch_layer(f"opportunity_{short_name}", layer_key)
return QuarterDump(
quarter_cad=quarter_cad,
quarter=quarter_feat,
parcels=parcels,
buildings=buildings,
territorial_zones=territorial_zones,
red_lines=red_lines,
engineering_structures=engineering_structures,
zouit=zouit,
risks=risks,
opportunity=opportunity,
layers_fetched=tuple(layers_fetched),
bbox_3857=bbox,
fetched_at_utc=_dt.datetime.now(_dt.UTC).isoformat(),
)
# ── Geometry helpers (module-level — для unit-test'ов) ──────────────────────
def _geojson_bbox_3857(
geometry: dict[str, Any],
) -> tuple[float, float, float, float] | None:
"""Compute bbox в EPSG:3857 из GeoJSON geometry.
NSPD search /v2 возвращает coordinates в EPSG:3857 (Web Mercator metres) —
напрямую compute min/max по координатам. Если geometry уже в WGS84
(нестандартный сценарий) — caller должен трансформировать заранее.
Поддерживает Polygon/MultiPolygon/LineString/Point.
Возвращает None для пустой/некорректной geometry.
"""
coords = geometry.get("coordinates")
geom_type = geometry.get("type")
if not coords or not geom_type:
return None
def _walk(node: Any) -> list[tuple[float, float]]:
"""Рекурсивный extract координат до получения list of (x, y) tuples."""
if isinstance(node, int | float):
return []
if (
isinstance(node, list)
and len(node) >= 2
and all(isinstance(v, int | float) for v in node[:2])
):
return [(float(node[0]), float(node[1]))]
if isinstance(node, list):
out: list[tuple[float, float]] = []
for sub in node:
out.extend(_walk(sub))
return out
return []
pts = _walk(coords)
if not pts:
return None
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return (min(xs), min(ys), max(xs), max(ys))
def _walk_layer_tree(node: Any) -> list[dict[str, Any]]:
"""Рекурсивный walker для NSPD layers-theme-tree. Yields leaf nodes."""
out: list[dict[str, Any]] = []
if isinstance(node, dict):
# Leaf если есть id + нет children (или children пустой)
children = node.get("children") or []
if "id" in node and not children:
out.append(node)
elif "id" in node and children:
# Branch с id — может быть и сам layer, и группа. NSPD сейчас
# treats group nodes как "не имеющий wms-endpoint" — но включаем
# на случай если они оба leaves (хитрая branch).
out.append(node)
for ch in children:
out.extend(_walk_layer_tree(ch))
elif isinstance(node, list):
for item in node:
out.extend(_walk_layer_tree(item))
return out
__all__ = [
"LAYERS",
"NSPD_THEMES",
"NSPD_WMS_BASE",
"THEME_ARN",
"THEME_PKK",
"NSPDClient",
"NSPDFeature",
"NSPDLayer",
"NSPDSearchResult",
"NspdLiteError",
"NspdLiteWafError",
"QuarterDump",
"bbox_around_point_m",
"lonlat_to_3857",
]