feat(scrapers): search_by_quarter + QuarterDump (#94 pt.2/4) #109

Merged
lekss361 merged 1 commit from feat/nspd-quarter-dump into main 2026-05-12 15:11:30 +00:00
lekss361 commented 2026-05-12 15:05:43 +00:00 (Migrated from github.com)

Summary

Sprint 1.1 item #1 из 4-частного плана #94 part 2 (foundation для NSPD harvest pipeline).

Замыкает: G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, E1 #51 parcels backfill, #96 ЕГРН помещения — все они смогут читать QuarterDump вместо собственных NSPD-вызовов.

Что внутри

backend/app/services/scrapers/nspd_client.py (+225 lines)

QuarterDump — frozen dataclass (slots=True) snapshot структура:

  • quarter: NSPDFeature | None — сам polygon квартала
  • per-layer feature lists: parcels, buildings, territorial_zones, red_lines, engineering_structures
  • zouit: dict[str, list[NSPDFeature]] — 5 ЗОУИТ groups (okn/engineering/natural/protected/other)
  • risks: dict[str, list[NSPDFeature]] — 11 risk groups (flooding/landslide/erosion/etc)
  • bbox_3857, layers_fetched (tuple для immutability), fetched_at_utc
  • .total_features property для smoke checks

NSPDClient.search_by_quarter(quarter_cad, include_zouit=True, include_risks=False) — orchestrator:

Step Action Cost
1 search_by_cad(thematic_id=2) для quarter geometry 1 HTTP
2 compute bbox через _geojson_bbox_3857 0 HTTP
3 per core layer (parcels/buildings/TZ/red_lines/engineering) → get_features_in_bbox 5 HTTP
4 если include_zouit: 5 ЗОУИТ layers +5 HTTP
5 если include_risks: 11 risk layers +11 HTTP

Total cost: 6 / 11 / 22 HTTP в зависимости от флагов (~3.6с / ~6.6с / ~13с при rate_ms=600).

_geojson_bbox_3857(geometry) — module-level recursive coord walker. Поддерживает Polygon/MultiPolygon/LineString/Point, returns None для empty/garbage geometry.

Class constants QUARTER_CORE_LAYERS, QUARTER_ZOUIT_LAYERS, QUARTER_RISK_LAYERS маппят short_name → key в LAYERS catalog (из PR #98).

backend/tests/test_nspd_client.py (+334 lines)

+12 unit tests (31 total in file), no network:

  • _geojson_bbox_3857: 6 tests (polygon / multipolygon / linestring / point / empty / no_type)
  • QuarterDump: 2 tests (total_features sum verification, frozen immutability via AttributeError)
  • search_by_quarter: 4 mock tests (core_only / with_zouit / empty_quarter / layers_fetched_with_risks) через monkeypatch.setattr на fetch_geoportal + _http_get_json

Empty-quarter semantics

Когда NSPD не нашёл cad-номер квартала:

  • quarter=None, bbox_3857=None
  • Все feature lists пустые (раннний exit в _fetch_layer)
  • zouit / risks dicts populated с пустыми lists для каждого short_name (контракт структуры стабилен — frontend знает какие keys ожидать)
  • layers_fetched=('search',) — никаких bulk-fetches не было сделано

Pre-push review (code-reviewer agent)

Verdict: MINOR, 0 blocking. Применено 3 из 5 рекомендаций перед push:

  • import datetime перенесён на module-level (был inline)
  • layers_fetched тип → tuple[str, ...] (immutable contents в frozen dataclass; bbox_3857 уже был tuple — теперь consistency)
  • Docstring уточнил empty-quarter semantics
  • ⏭ Deferred (acceptable): RUF012 ClassVar annotation на class-level constants; inline LAYERS as _LAYERS import в тестовом helper'е

Bonus: ruff UP038 isinstance(node, (int, float))isinstance(node, int | float) (PEP 604 union syntax).

Acceptance (per #94 plan)

  • search_by_quarter() метод реализован
  • QuarterDump тип готов как контракт для downstream consumers
  • Tests: 12 новых (31 total), 100% pass
  • Reuse existing search_by_cad + get_features_in_bbox (no new HTTP layer)
  • Schema migration nspd_quarter_dumps table → отдельный PR (Sprint 1.1 item #2)
  • Celery harvest_quarter task + beat → отдельный PR (Sprint 1.1 item #3)
  • analyze_parcel integration через QuarterDump → отдельный PR (Sprint 1.1 item #4)

Test plan

  • ruff check + format — clean
  • pytest tests/test_nspd_client.py -v — 31/31 passed in 0.05s
  • After deploy: manual smoke
    from app.services.scrapers.nspd_client import NSPDClient
    dump = NSPDClient().search_by_quarter("66:41:0204016")
    print(f"quarter={dump.quarter.feature_id if dump.quarter else None}")
    print(f"parcels={len(dump.parcels)} buildings={len(dump.buildings)}")
    print(f"layers_fetched={dump.layers_fetched}")
    # Expected: квартал ЕКБ → ~100+ parcels, ~30 buildings, 1+ TZ
    

Part of #94. Sequential rule — следующий PR создаётся ТОЛЬКО после merge этого.

## Summary **Sprint 1.1 item #1** из 4-частного плана #94 part 2 (foundation для NSPD harvest pipeline). Замыкает: G1 #28 ПЗЗ, G3 #30 ЗОУИТ, P2 #46 neighbors, E1 #51 parcels backfill, #96 ЕГРН помещения — все они смогут читать `QuarterDump` вместо собственных NSPD-вызовов. ## Что внутри ### `backend/app/services/scrapers/nspd_client.py` (+225 lines) **`QuarterDump`** — frozen dataclass (slots=True) snapshot структура: - `quarter`: NSPDFeature | None — сам polygon квартала - per-layer feature lists: `parcels`, `buildings`, `territorial_zones`, `red_lines`, `engineering_structures` - `zouit: dict[str, list[NSPDFeature]]` — 5 ЗОУИТ groups (okn/engineering/natural/protected/other) - `risks: dict[str, list[NSPDFeature]]` — 11 risk groups (flooding/landslide/erosion/etc) - `bbox_3857`, `layers_fetched` (tuple для immutability), `fetched_at_utc` - `.total_features` property для smoke checks **`NSPDClient.search_by_quarter(quarter_cad, include_zouit=True, include_risks=False)`** — orchestrator: | Step | Action | Cost | |---|---|---| | 1 | `search_by_cad(thematic_id=2)` для quarter geometry | 1 HTTP | | 2 | compute bbox через `_geojson_bbox_3857` | 0 HTTP | | 3 | per core layer (parcels/buildings/TZ/red_lines/engineering) → `get_features_in_bbox` | 5 HTTP | | 4 | если `include_zouit`: 5 ЗОУИТ layers | +5 HTTP | | 5 | если `include_risks`: 11 risk layers | +11 HTTP | Total cost: **6 / 11 / 22 HTTP** в зависимости от флагов (~3.6с / ~6.6с / ~13с при `rate_ms=600`). **`_geojson_bbox_3857(geometry)`** — module-level recursive coord walker. Поддерживает Polygon/MultiPolygon/LineString/Point, returns None для empty/garbage geometry. Class constants `QUARTER_CORE_LAYERS`, `QUARTER_ZOUIT_LAYERS`, `QUARTER_RISK_LAYERS` маппят short_name → key в `LAYERS` catalog (из PR #98). ### `backend/tests/test_nspd_client.py` (+334 lines) +12 unit tests (31 total in file), **no network**: - `_geojson_bbox_3857`: 6 tests (polygon / multipolygon / linestring / point / empty / no_type) - `QuarterDump`: 2 tests (`total_features` sum verification, frozen immutability via AttributeError) - `search_by_quarter`: 4 mock tests (`core_only` / `with_zouit` / `empty_quarter` / `layers_fetched_with_risks`) через `monkeypatch.setattr` на `fetch_geoportal` + `_http_get_json` ## Empty-quarter semantics Когда NSPD не нашёл cad-номер квартала: - `quarter=None`, `bbox_3857=None` - Все feature lists пустые (раннний exit в `_fetch_layer`) - `zouit` / `risks` dicts populated с пустыми lists для каждого short_name (контракт структуры стабилен — frontend знает какие keys ожидать) - `layers_fetched=('search',)` — никаких bulk-fetches не было сделано ## Pre-push review (code-reviewer agent) Verdict: **MINOR**, 0 blocking. Применено 3 из 5 рекомендаций перед push: - ✅ `import datetime` перенесён на module-level (был inline) - ✅ `layers_fetched` тип → `tuple[str, ...]` (immutable contents в frozen dataclass; bbox_3857 уже был tuple — теперь consistency) - ✅ Docstring уточнил empty-quarter semantics - ⏭ Deferred (acceptable): `RUF012` ClassVar annotation на class-level constants; inline `LAYERS as _LAYERS` import в тестовом helper'е Bonus: ruff UP038 `isinstance(node, (int, float))` → `isinstance(node, int | float)` (PEP 604 union syntax). ## Acceptance (per #94 plan) - [x] `search_by_quarter()` метод реализован - [x] `QuarterDump` тип готов как контракт для downstream consumers - [x] Tests: 12 новых (31 total), 100% pass - [x] Reuse existing `search_by_cad` + `get_features_in_bbox` (no new HTTP layer) - [ ] Schema migration `nspd_quarter_dumps` table → **отдельный PR** (Sprint 1.1 item #2) - [ ] Celery `harvest_quarter` task + beat → **отдельный PR** (Sprint 1.1 item #3) - [ ] `analyze_parcel` integration через QuarterDump → **отдельный PR** (Sprint 1.1 item #4) ## Test plan - [x] `ruff check + format` — clean - [x] `pytest tests/test_nspd_client.py -v` — 31/31 passed in 0.05s - [ ] After deploy: manual smoke ```python from app.services.scrapers.nspd_client import NSPDClient dump = NSPDClient().search_by_quarter("66:41:0204016") print(f"quarter={dump.quarter.feature_id if dump.quarter else None}") print(f"parcels={len(dump.parcels)} buildings={len(dump.buildings)}") print(f"layers_fetched={dump.layers_fetched}") # Expected: квартал ЕКБ → ~100+ parcels, ~30 buildings, 1+ TZ ``` Part of #94. Sequential rule — следующий PR создаётся ТОЛЬКО после merge этого.
lekss361 commented 2026-05-12 15:11:14 +00:00 (Migrated from github.com)

Auto-review passed (LGTM) — pt.2/4 NSPD scraper rewrite, чисто.

Architecture & contracts:

  • Atomic failure: NspdLiteWafError propagается, не возвращаются partial results — соответствует vault research про WAF.
  • bbox is None guard в _fetch_layer корректно предотвращает HTTP calls когда quarter не найден (verified test).
  • Rate limit делегирован существующему get_features_in_bbox (foundation PR).
  • frozen=True, slots=True на QuarterDump — правильный выбор для immutable snapshot.

Geometry handling:

  • _geojson_bbox_3857 обрабатывает Point/LineString/Polygon/MultiPolygon через recursive _walk — covered all 4 types + edge cases.
  • thematic_id=2 correct (quarters per NSPD_API_Map_May12.md).

Tests:

  • Все mock-based, нет real NSPD.
  • Cover: empty quarter, core-only, +zouit, +risks phases.

Conventions:

  • No requests, no print(), no hardcoded creds, no injection. Full type annotations.

Minor nits (non-blocker, для follow-up):

  • layers_fetched семантика = "layer was requested" (не "non-empty"). Рассмотреть rename → layers_requested в pt.3/4.
  • fetched_at_utc: str вместо datetime — pragmatic для frozen+JSON-serializable, но property возвращающий datetime был бы чище.
  • QUARTER_*_LAYERS использует # noqa: RUF012 — рассмотреть ClassVar[dict[str, str]] в cleanup pass.
  • test_nspd_client.py:447 URL parsing parts[6] хрупкое — добавить комментарий о assumed URL structure.

Merging.

✅ Auto-review passed (LGTM) — pt.2/4 NSPD scraper rewrite, чисто. **Architecture & contracts:** - Atomic failure: `NspdLiteWafError` propagается, не возвращаются partial results — соответствует vault research про WAF. - `bbox is None` guard в `_fetch_layer` корректно предотвращает HTTP calls когда quarter не найден (verified test). - Rate limit делегирован существующему `get_features_in_bbox` (foundation PR). - `frozen=True, slots=True` на `QuarterDump` — правильный выбор для immutable snapshot. **Geometry handling:** - `_geojson_bbox_3857` обрабатывает Point/LineString/Polygon/MultiPolygon через recursive `_walk` — covered all 4 types + edge cases. - `thematic_id=2` correct (quarters per NSPD_API_Map_May12.md). **Tests:** - Все mock-based, нет real NSPD. - Cover: empty quarter, core-only, +zouit, +risks phases. **Conventions:** - No `requests`, no `print()`, no hardcoded creds, no injection. Full type annotations. **Minor nits (non-blocker, для follow-up):** - `layers_fetched` семантика = "layer was requested" (не "non-empty"). Рассмотреть rename → `layers_requested` в pt.3/4. - `fetched_at_utc: str` вместо `datetime` — pragmatic для frozen+JSON-serializable, но property возвращающий datetime был бы чище. - `QUARTER_*_LAYERS` использует `# noqa: RUF012` — рассмотреть `ClassVar[dict[str, str]]` в cleanup pass. - `test_nspd_client.py:447` URL parsing `parts[6]` хрупкое — добавить комментарий о assumed URL structure. Merging. <!-- gendesign-review-bot: sha=8196f0b verdict=approve -->
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#109
No description provided.