From df5034fe295e14dcf9fed845286cead1bf86c456 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Fri, 15 May 2026 16:48:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(cadastre):=20per-category=20NSPD=20snapsho?= =?UTF-8?q?t=20=E2=80=94=20Phase=201.5=20(ZOUIT/ENK)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch base search_by_quarter from thematicSearchId=1 to thematic=1 (includes ZOUIT in features). Add optional category_id param that routes ZOUIT (36940/469039/469040/469042) -> thematicSearchId=5, ENK (39663) -> thematicSearchId=15. Add Phase 1.5 in harvest_quarter: for each PER_CATEGORY_PROBE_CATS category present in meta_counts (meta_total>0), run dedicated per-category search call. Skips if category in GRID_WALK_CATS and bbox is valid (grid-walk gives better coverage). Curl-probe confirmed NSPD API caps at 20/category with no pagination; categoryId param is server-ignored; thematic=1 strictly better than thematicSearchId=1 (includes ZOUIT). Closes part of #168 (ZOUIT/ENK coverage gap). --- backend/app/scrapers/nspd_bulk_client.py | 69 +++++-- backend/app/services/cadastre/bulk_harvest.py | 66 +++++++ backend/tests/services/test_cadastre_bulk.py | 186 ++++++++++++++++++ 3 files changed, 309 insertions(+), 12 deletions(-) diff --git a/backend/app/scrapers/nspd_bulk_client.py b/backend/app/scrapers/nspd_bulk_client.py index 725f6a5d..53d93c8e 100644 --- a/backend/app/scrapers/nspd_bulk_client.py +++ b/backend/app/scrapers/nspd_bulk_client.py @@ -42,6 +42,21 @@ 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, # ЕНК +} + # Лимит одновременных запросов (глобальный, shared между всеми instance'ами) _SEMAPHORE = asyncio.Semaphore(3) @@ -190,17 +205,34 @@ class NSPDBulkClient: # ── 1. search_by_quarter ────────────────────────────────────────────────── - async def search_by_quarter(self, quarter: str) -> QuarterSnapshot: + async def search_by_quarter( + self, + quarter: str, + category_id: int | None = None, + ) -> QuarterSnapshot: """Поиск объектов в квартале через NSPD search endpoint. - GET /api/geoportal/v2/search/geoportal?thematicSearchId=1&query={quarter} + Базовый вызов (category_id=None): + GET .../search/geoportal?thematic=1&query={quarter} + Возвращает до 20 объектов per категории для всех категорий включая ЗОУИТ. - Возвращает первые 20 объектов на категорию + meta с totalCount. - Категории с totalCount > 20 попадают в overflow_categories - и требуют grid-walk (wms_feature_info) для полноты. + 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. @@ -212,18 +244,28 @@ class NSPDBulkClient: """ 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={"thematicSearchId": 1, "query": quarter}, - ) + 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", + "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 @@ -236,8 +278,10 @@ class NSPDBulkClient: 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", + "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) @@ -257,8 +301,9 @@ class NSPDBulkClient: meta_counts[int(cat_id)] = int(total) logger.info( - "search_by_quarter: quarter=%s features=%d meta_cats=%d overflow=%d", + "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]), diff --git a/backend/app/services/cadastre/bulk_harvest.py b/backend/app/services/cadastre/bulk_harvest.py index f839c70a..bcc262a3 100644 --- a/backend/app/services/cadastre/bulk_harvest.py +++ b/backend/app/services/cadastre/bulk_harvest.py @@ -46,6 +46,14 @@ CAT_ZOUIT = frozenset({36940, 469039, 469040, 469042}) # ЗОУИТ # Категории для grid-walk overflow GRID_WALK_CATS = frozenset({CAT_PARCEL, CAT_BUILDING}) +# Категории с выделенным thematicSearchId в NSPD API (curl-probe 2026-05-15). +# Для них отдельный per-category вызов даёт ДРУГОЙ результат, чем базовый thematic=1: +# - ЗОУИТ: thematicSearchId=5 → до 20 per subtype × 4 subtypes +# - ЕНК: thematicSearchId=15 → до 20 ЕНК объектов +# ЗУ/Здания/Сооружения/ОНС не имеют dedicated thematicSearchId — отдельный вызов +# вернёт те же 20 что и базовый, поэтому per-category probe для них бессмыслен. +PER_CATEGORY_PROBE_CATS = frozenset({*CAT_ZOUIT, CAT_ENK}) + # ── Result type ────────────────────────────────────────────────────────────── @@ -142,6 +150,64 @@ async def harvest_quarter( } ) + # ── Phase 1.5: per-category snapshot для ЗОУИТ и ЕНК ──────────────────── + # ЗОУИТ (36940/469039/469040/469042) и ЕНК (39663) имеют dedicated thematicSearchId + # в NSPD API (5 и 15 соответственно) и не всегда попадают в базовый thematic=1 snapshot + # если totalCount в их категории > 20. + # Для ЗУ/Зданий/Сооружений/ОНС — dedicated thematicSearchId нет: отдельный вызов + # вернёт те же 20 что и базовый snapshot, поэтому их пропускаем. + # Если category ∈ GRID_WALK_CATS и bbox валиден — grid-walk даёт лучшее покрытие (225 точек). + for cat_id in PER_CATEGORY_PROBE_CATS: + meta_total = snapshot.meta_counts.get(cat_id, 0) + if meta_total == 0: + # NSPD не вернул meta для этой категории — объектов нет в квартале + continue + + # Проверяем: нужен ли per-category probe? + # Условия skip: category ∈ GRID_WALK_CATS И bbox валиден → grid-walk сделает это лучше. + # PER_CATEGORY_PROBE_CATS ∩ GRID_WALK_CATS = ∅ (ZOUIT/ENK не в GRID_WALK_CATS), + # но явная проверка оставлена как safety guard. + if cat_id in GRID_WALK_CATS and quarter_bbox_3857(db, quarter) is not None: + logger.debug( + "harvest_quarter: skip per-cat probe cat=%d quarter=%s — grid-walk preferred", + cat_id, + quarter, + ) + continue + + update_progress({"phase": f"per_cat_probe_{cat_id}_started", "quarter": quarter}) + + try: + cat_snapshot = await client.search_by_quarter(quarter, category_id=cat_id) + except Exception as e: + logger.warning( + "harvest_quarter: per-cat probe failed cat=%d quarter=%s: %s", + cat_id, + quarter, + e, + ) + continue + + if not cat_snapshot.features: + continue + + cat_stats = upsert_features(db, cat_snapshot.features, source="search_per_cat") + result.parcels_upserted += cat_stats["parcels"] + result.buildings_upserted += cat_stats["buildings"] + result.constructions_upserted += cat_stats["constructions"] + result.oncs_upserted += cat_stats["oncs"] + result.enks_upserted += cat_stats["enks"] + result.zouit_upserted += cat_stats["zouit"] + db.commit() + + new_total = sum(cat_stats.values()) + logger.info( + "harvest_quarter per-cat probe: quarter=%s cat=%d new_features=%d", + quarter, + cat_id, + new_total, + ) + # ── Phase 2-3: grid-walk для overflow категорий ─────────────────────────── overflow_cats = [c for c in snapshot.overflow_categories if c in GRID_WALK_CATS] diff --git a/backend/tests/services/test_cadastre_bulk.py b/backend/tests/services/test_cadastre_bulk.py index f33513a7..c5e9b722 100644 --- a/backend/tests/services/test_cadastre_bulk.py +++ b/backend/tests/services/test_cadastre_bulk.py @@ -13,6 +13,8 @@ import pytest from app.schemas.nspd_bulk import NSPDBulkFeature, QuarterSnapshot from app.services.cadastre.bulk_harvest import ( + CAT_ENK, + CAT_ZOUIT, _derive_quarter_cad, _safe_int, _safe_numeric, @@ -537,3 +539,187 @@ def test_zouit_dedup_different_category_ids() -> None: # Обе записи маршрутируются в upsert_zouit assert mock_zouit.call_count == 2 assert counts["zouit"] == 2 + + +# ── per-category snapshot (Phase 1.5) ──────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_harvest_quarter_calls_per_cat_probe_for_zouit_when_meta_nonzero() -> None: + """Phase 1.5: если в meta snapshot есть ЗОУИТ (36940), делаем per-cat probe.""" + from app.services.cadastre.bulk_harvest import harvest_quarter + + zouit_cat = next(iter(CAT_ZOUIT)) # один из ЗОУИТ categoryId + + # Snapshot Phase 1: 1 парцел + meta с ЗОУИТ + parcel = _make_parcel_feature() + zouit_feature = _make_zouit_feature() + base_snapshot = QuarterSnapshot( + quarter="66:41:0303161", + fetched_at="2026-05-15T10:00:00+00:00", + features=[parcel], + meta_counts={36368: 2, zouit_cat: 5}, # ЗОУИТ totalCount=5 > 0 + ) + # Per-category probe snapshot для ЗОУИТ + cat_snapshot = QuarterSnapshot( + quarter="66:41:0303161", + fetched_at="2026-05-15T10:00:01+00:00", + features=[zouit_feature], + meta_counts={zouit_cat: 5}, + ) + + db = MagicMock() + db.execute = MagicMock( + return_value=MagicMock( + mappings=MagicMock( + return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + ) + ) + ) + db.commit = MagicMock() + + client = AsyncMock() + # Первый вызов (без category_id) → базовый snapshot + # Последующие — per-category probes + client.search_by_quarter = AsyncMock(side_effect=[base_snapshot, cat_snapshot]) + + with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: + mock_upsert.return_value = { + "parcels": 0, + "buildings": 0, + "constructions": 0, + "oncs": 0, + "enks": 0, + "zouit": 1, + "skipped": 0, + } + result = await harvest_quarter( + db=db, + client=client, + quarter="66:41:0303161", + job_id=1, + update_progress=lambda s: None, + ) + + # search_by_quarter должен быть вызван минимум дважды: + # 1) без category_id (базовый) + # 2) с category_id=zouit_cat (per-cat probe) + assert client.search_by_quarter.await_count >= 2 + call_args_list = client.search_by_quarter.await_args_list + # Первый вызов без category_id + first_call_kwargs = call_args_list[0].kwargs + assert first_call_kwargs.get("category_id") is None + # Один из последующих вызовов с category_id ∈ CAT_ZOUIT + per_cat_calls = [c for c in call_args_list[1:] if c.kwargs.get("category_id") in CAT_ZOUIT] + assert len(per_cat_calls) >= 1, "Ожидался per-cat probe для ЗОУИТ" + assert result.zouit_upserted >= 1 + + +@pytest.mark.asyncio +async def test_harvest_quarter_skips_per_cat_probe_when_meta_zero() -> None: + """Phase 1.5: если meta не содержит ЗОУИТ/ЕНК — per-cat probe не делается.""" + from app.services.cadastre.bulk_harvest import harvest_quarter + + parcel = _make_parcel_feature() + # Snapshot без ЗОУИТ/ЕНК в meta (только ЗУ и Здания) + base_snapshot = QuarterSnapshot( + quarter="66:41:0303161", + fetched_at="2026-05-15T10:00:00+00:00", + features=[parcel], + meta_counts={36368: 2, 36369: 3}, # нет ЗОУИТ / ЕНК + ) + + db = MagicMock() + db.execute = MagicMock( + return_value=MagicMock( + mappings=MagicMock( + return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + ) + ) + ) + db.commit = MagicMock() + + client = AsyncMock() + client.search_by_quarter = AsyncMock(return_value=base_snapshot) + + with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: + mock_upsert.return_value = { + "parcels": 1, + "buildings": 0, + "constructions": 0, + "oncs": 0, + "enks": 0, + "zouit": 0, + "skipped": 0, + } + await harvest_quarter( + db=db, + client=client, + quarter="66:41:0303161", + job_id=1, + update_progress=lambda s: None, + ) + + # Только один вызов search_by_quarter (базовый), никаких per-cat probe + assert client.search_by_quarter.await_count == 1 + call_kwargs = client.search_by_quarter.await_args_list[0].kwargs + assert call_kwargs.get("category_id") is None + + +@pytest.mark.asyncio +async def test_harvest_quarter_per_cat_probe_enk_called_when_meta_nonzero() -> None: + """Phase 1.5: если в meta есть ЕНК (39663) — вызываем per-cat probe с category_id=39663.""" + from app.services.cadastre.bulk_harvest import harvest_quarter + + parcel = _make_parcel_feature() + base_snapshot = QuarterSnapshot( + quarter="66:41:0303161", + fetched_at="2026-05-15T10:00:00+00:00", + features=[parcel], + meta_counts={36368: 2, CAT_ENK: 3}, # ЕНК в meta + ) + enk_snapshot = QuarterSnapshot( + quarter="66:41:0303161", + fetched_at="2026-05-15T10:00:01+00:00", + features=[], + meta_counts={CAT_ENK: 3}, + ) + + db = MagicMock() + db.execute = MagicMock( + return_value=MagicMock( + mappings=MagicMock( + return_value=MagicMock(first=MagicMock(return_value={"phase_state": None})) + ) + ) + ) + db.commit = MagicMock() + + client = AsyncMock() + client.search_by_quarter = AsyncMock(side_effect=[base_snapshot, enk_snapshot]) + + with patch("app.services.cadastre.bulk_harvest.upsert_features") as mock_upsert: + mock_upsert.return_value = { + "parcels": 1, + "buildings": 0, + "constructions": 0, + "oncs": 0, + "enks": 0, + "zouit": 0, + "skipped": 0, + } + await harvest_quarter( + db=db, + client=client, + quarter="66:41:0303161", + job_id=1, + update_progress=lambda s: None, + ) + + # Ожидаем вызов с category_id=CAT_ENK + per_cat_enk_calls = [ + c + for c in client.search_by_quarter.await_args_list + if c.kwargs.get("category_id") == CAT_ENK + ] + assert len(per_cat_enk_calls) >= 1, "Ожидался per-cat probe с category_id=39663 (ENK)"