get_feature_info() шлёт WMS FEATURE_COUNT=10 (новый параметр feature_count, default 10). NSPD по умолчанию отдаёт 1 фичу/точку → перекрывающиеся зоны одного слоя теряются (37581 «иные ЗОУИТ» на ЕКБ = 2 фичи). Tile-геометрия не тронута. +2 теста (multi-feature parse, override), существующий обновлён. Refs #1067. Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
937 lines
42 KiB
Python
937 lines
42 KiB
Python
"""Унифицированный 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,
|
||
feature_count: int = 10,
|
||
) -> list[NSPDFeature]:
|
||
"""WMS GetFeatureInfo на точке — какие feature'ы layer'а покрывают (lon, lat).
|
||
|
||
Использует микро-bbox вокруг точки в EPSG:3857 (WMS требует bbox + I/J
|
||
пиксель). Tile 256x256, I=128, J=128 — центр.
|
||
|
||
Args:
|
||
feature_count: макс. число фич в ответе (WMS FEATURE_COUNT). Default 10
|
||
(паритет с порталом nspd.gov.ru). Без него NSPD по умолчанию отдаёт
|
||
ТОЛЬКО 1 фичу на точку — а на ЕКБ в одной точке часто пересекаются
|
||
несколько зон одного слоя (напр. 37581 «иные ЗОУИТ» вернул 2). Для
|
||
overlay-ингеста (#1067) нужно >1.
|
||
|
||
Returns features (может быть 0+, до feature_count).
|
||
|
||
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",
|
||
# FEATURE_COUNT: NSPD по умолчанию отдаёт 1 фичу/точку → теряем
|
||
# перекрывающиеся зоны. Портал шлёт 10. Без этого overlay-ингест неполон.
|
||
"FEATURE_COUNT": str(feature_count),
|
||
# 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")
|
||
and "layers" not in data
|
||
):
|
||
data = data.get("data") or []
|
||
# Реальная форма layers-theme-tree: {"layers": [...flat...], "tree": ...},
|
||
# где каждая запись = {"layerId": N, "title", "layerName", "layerType"}.
|
||
# Раньше walker искал id/children и для этой (актуальной) формы молча
|
||
# возвращал [] — слои не в children-дереве, а в плоском layers[]
|
||
# (см. research/NSPD_IRD_Layers_Automation_Recon_Jun06). Сначала пробуем
|
||
# плоский layers[], иначе fallback на nested-tree walker (id/children).
|
||
if isinstance(data, dict) and isinstance(data.get("layers"), list):
|
||
raw_entries: list[dict[str, Any]] = data["layers"]
|
||
elif isinstance(data, list | dict):
|
||
raw_entries = _walk_layer_tree(data)
|
||
else:
|
||
logger.warning(
|
||
"list_layers: unexpected NSPD response type %s for theme_id=%s",
|
||
type(data).__name__,
|
||
theme_id,
|
||
)
|
||
return []
|
||
layers: list[NSPDLayer] = []
|
||
for entry in raw_entries:
|
||
# актуальная форма использует layerId; старая nested-tree — id
|
||
raw_id = entry.get("layerId", entry.get("id"))
|
||
if raw_id is None:
|
||
continue
|
||
layers.append(
|
||
NSPDLayer(
|
||
layer_id=int(raw_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",
|
||
]
|