574 lines
24 KiB
Python
574 lines
24 KiB
Python
"""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"
|
||
|
||
# Маппинг category_id → thematicSearchId для per-category override.
|
||
# Получен экспериментально (curl-probe, 2026-05-15):
|
||
# thematicSearchId=1 → ZU+Здания+Сооружения+Реестр адресов (без ЗОУИТ)
|
||
# thematicSearchId=5 → ЗОУИТ (36940, 469039, 469040, 469042)
|
||
# thematicSearchId=15 → ЕНК (39663)
|
||
# thematicSearchId=2 → Квартальная статистика (36381) — внутренний, не для override
|
||
# Ключ None → базовый тип поиска (thematic=1, возвращает все категории включая ЗОУИТ).
|
||
_CAT_TO_THEMATIC_SEARCH_ID: dict[int, int] = {
|
||
36940: 5, # ЗОУИТ — охранная зона
|
||
469039: 5, # ЗОУИТ subtype
|
||
469040: 5, # ЗОУИТ subtype
|
||
469042: 5, # ЗОУИТ subtype
|
||
39663: 15, # ЕНК
|
||
}
|
||
|
||
# Concurrency cap per HTTP fetch loop. Issue #260 (Sub-PR B re-review):
|
||
# module-level asyncio.Semaphore() bind'ится к event loop первого asyncio.run()
|
||
# вызова, на втором — RuntimeError: bound to a different event loop.
|
||
# search_by_quarter делает N sequential asyncio.run() per layer → semaphore надо
|
||
# создавать per-instance в __aenter__ (под текущим running loop).
|
||
_SEMAPHORE_LIMIT = 3
|
||
|
||
# Таймаут подключения и чтения
|
||
DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
||
|
||
# Headers — exact match to legacy nspd_lite.py (proven to bypass NSPD WAF
|
||
# on VPS IP since April 2026). All-lowercase keys + cache-control + Chrome 144
|
||
# UA. Initial HAR-extracted headers (Pascal-Case, Chrome 148, no cache-control)
|
||
# were blocked 50/50 в pilot run v2 (job_id=2, all 403 WAF).
|
||
DEFAULT_HEADERS: dict[str, str] = {
|
||
"accept": "*/*",
|
||
"accept-language": "en-US,en;q=0.9,ru-RU;q=0.8,ru;q=0.7,es;q=0.6",
|
||
"cache-control": "no-cache",
|
||
"pragma": "no-cache",
|
||
"referer": "https://nspd.gov.ru/map?thematic=PKK",
|
||
"origin": "https://nspd.gov.ru",
|
||
"user-agent": (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
|
||
),
|
||
}
|
||
|
||
# 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
|
||
# Issue #260: semaphore создаётся в __aenter__ под текущий event loop.
|
||
# Module-level не работал — sequential asyncio.run() bind'ил его к
|
||
# закрытому loop'у первого вызова.
|
||
self._sem: asyncio.Semaphore | None = None
|
||
|
||
async def __aenter__(self) -> NSPDBulkClient:
|
||
# NSPD prod chain contains a self-signed/internal CA on Beget VPS →
|
||
# default verify trips CERTIFICATE_VERIFY_FAILED. Existing legacy
|
||
# client (app.services.scrapers.nspd_lite) uses unverified context
|
||
# for the same reason — reuse pattern. Risk OK: public gov data,
|
||
# no sensitive payload, X-Admin-Token не уходит к NSPD.
|
||
self._client = httpx.AsyncClient(
|
||
headers=self._headers,
|
||
timeout=self._timeout,
|
||
follow_redirects=True,
|
||
verify=False,
|
||
)
|
||
# Создаём semaphore под running loop — safe для sequential asyncio.run().
|
||
self._sem = asyncio.Semaphore(_SEMAPHORE_LIMIT)
|
||
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 or self._sem is None:
|
||
raise RuntimeError("NSPDBulkClient не инициализирован — используй async with")
|
||
|
||
attempt = 0
|
||
while True:
|
||
async with self._sem:
|
||
# Небольшой 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,
|
||
category_id: int | None = None,
|
||
) -> QuarterSnapshot:
|
||
"""Поиск объектов в квартале через NSPD search endpoint.
|
||
|
||
Базовый вызов (category_id=None):
|
||
GET .../search/geoportal?thematic=1&query={quarter}
|
||
Возвращает до 20 объектов per категории для всех категорий включая ЗОУИТ.
|
||
|
||
Per-category вызов (category_id задан):
|
||
Выбирает thematicSearchId соответствующий группе категории:
|
||
- 36940/469039/469040/469042 (ЗОУИТ) → thematicSearchId=5
|
||
- 39663 (ЕНК) → thematicSearchId=15
|
||
- прочие (ЗУ/Здания/Сооружения/ОНС) → thematic=1 (общий, те же 20 per cat)
|
||
Используется в Phase 1.5 bulk_harvest для дозабора overflow-категорий,
|
||
которые не покрываются grid-walk из-за broken bbox.
|
||
|
||
NSPD API особенности (curl-probe 2026-05-15):
|
||
- API возвращает строго 20 объектов per категории, pagination недоступна.
|
||
- Параметр categoryId игнорируется сервером (не фильтрует категории).
|
||
- thematic=1 лучше thematicSearchId=1: включает ЗОУИТ в features и meta.
|
||
|
||
Args:
|
||
quarter: кадастровый номер квартала в формате `66:41:NNNNNN`.
|
||
category_id: если задан — использовать thematicSearchId специфичный
|
||
для группы этой категории. None = базовый тип (thematic=1).
|
||
|
||
Returns:
|
||
QuarterSnapshot с features + meta_counts.
|
||
При 404 или пустом ответе — snapshot с features=[].
|
||
|
||
Raises:
|
||
NspdBulkWafError при 403.
|
||
NspdBulkError при сетевых / прочих ошибках.
|
||
"""
|
||
fetched_at = _dt.datetime.now(_dt.UTC).isoformat()
|
||
|
||
# Выбираем параметры запроса в зависимости от category_id
|
||
if category_id is not None and category_id in _CAT_TO_THEMATIC_SEARCH_ID:
|
||
# Специфичный thematicSearchId для ЗОУИТ / ЕНК групп
|
||
params: dict[str, Any] = {
|
||
"thematicSearchId": _CAT_TO_THEMATIC_SEARCH_ID[category_id],
|
||
"query": quarter,
|
||
}
|
||
else:
|
||
# Базовый thematic=1: все категории включая ЗОУИТ (лучше thematicSearchId=1)
|
||
params = {"thematic": 1, "query": quarter}
|
||
|
||
try:
|
||
data = await self._get_json(NSPD_SEARCH_URL, params=params)
|
||
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"
|
||
" (category_id=%s)",
|
||
quarter,
|
||
category_id,
|
||
)
|
||
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"
|
||
" (category_id=%s), returning empty",
|
||
quarter,
|
||
category_id,
|
||
)
|
||
return QuarterSnapshot(quarter=quarter, fetched_at=fetched_at)
|
||
|
||
raw_features: list[dict[str, Any]] = data_block.get("features") or []
|
||
# meta лежит на TOP level: {"data": {features}, "meta": [...]}
|
||
# fallback на data.meta для legacy shape (just in case)
|
||
raw_meta: list[dict[str, Any]] = payload.get("meta") or 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 category_id=%s features=%d meta_cats=%d overflow=%d",
|
||
quarter,
|
||
category_id,
|
||
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. get_features_in_bbox_grid ─────────────────────────────────────────
|
||
|
||
async def get_features_in_bbox_grid(
|
||
self,
|
||
layer_id: int,
|
||
bbox: tuple[float, float, float, float],
|
||
*,
|
||
grid_n: int = 7,
|
||
tile_size: int = 512,
|
||
) -> list[dict]:
|
||
"""Grid-walk WMS GetFeatureInfo по layer_id в bbox.
|
||
|
||
Разбивает bbox на grid_n × grid_n ячеек, кликает центр каждой.
|
||
Дедуплицирует результаты по feature id.
|
||
|
||
Args:
|
||
layer_id: NSPD layer ID (например 875838 для ПЗЗ).
|
||
bbox: (xmin, ymin, xmax, ymax) в EPSG:3857 (метры).
|
||
grid_n: количество ячеек по каждой оси (7 → 49 запросов).
|
||
tile_size: размер виртуального WMS тайла в пикселях.
|
||
|
||
Returns:
|
||
Список raw feature dict'ов с полями id, geometry, properties.
|
||
Дедуплицированы по feature id.
|
||
"""
|
||
xmin, ymin, xmax, ymax = bbox
|
||
x_step = (xmax - xmin) / grid_n
|
||
y_step = (ymax - ymin) / grid_n
|
||
click_xy = (tile_size // 2, tile_size // 2)
|
||
|
||
tasks = []
|
||
for i in range(grid_n):
|
||
for j in range(grid_n):
|
||
cell_bbox = (
|
||
xmin + i * x_step,
|
||
ymin + j * y_step,
|
||
xmin + (i + 1) * x_step,
|
||
ymin + (j + 1) * y_step,
|
||
)
|
||
tasks.append(
|
||
self.wms_feature_info(
|
||
layer_id=layer_id,
|
||
bbox=cell_bbox,
|
||
click_xy=click_xy,
|
||
width=tile_size,
|
||
height=tile_size,
|
||
)
|
||
)
|
||
|
||
# Запускаем все ячейки конкурентно (semaphore ограничивает до 3 concurrent)
|
||
cell_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
seen_ids: set[str] = set()
|
||
results: list[dict] = []
|
||
|
||
for idx, cell_result in enumerate(cell_results):
|
||
if isinstance(cell_result, Exception):
|
||
logger.debug(
|
||
"get_features_in_bbox_grid: layer=%d cell=%d error: %s",
|
||
layer_id,
|
||
idx,
|
||
cell_result,
|
||
)
|
||
continue
|
||
for feature in cell_result:
|
||
fid = str(feature.id) if feature.id is not None else ""
|
||
if fid and fid in seen_ids:
|
||
continue
|
||
if fid:
|
||
seen_ids.add(fid)
|
||
results.append(
|
||
{
|
||
"id": feature.id,
|
||
"geometry": feature.geometry,
|
||
"properties": feature.properties,
|
||
}
|
||
)
|
||
|
||
logger.info(
|
||
"get_features_in_bbox_grid: layer=%d grid=%dx%d unique_features=%d",
|
||
layer_id,
|
||
grid_n,
|
||
grid_n,
|
||
len(results),
|
||
)
|
||
return results
|
||
|
||
async def get_territorial_zones_in_bbox(
|
||
self,
|
||
bbox: tuple[float, float, float, float],
|
||
*,
|
||
grid_n: int = 7,
|
||
) -> list[dict]:
|
||
"""Grid-walk WMS GetFeatureInfo для layer 875838 (ПЗЗ территориальные зоны).
|
||
|
||
Returns: list of feature dicts с полями id, geometry, properties.
|
||
Дедуплицирует по feature id.
|
||
"""
|
||
return await self.get_features_in_bbox_grid(875838, bbox, grid_n=grid_n)
|
||
|
||
# ── 4. 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",
|
||
]
|