feat(scrapers): NSPD client foundation (#94) — search + WMS + layers #98

Merged
lekss361 merged 2 commits from feat/nspd-client-foundation into main 2026-05-12 06:47:45 +00:00
lekss361 commented 2026-05-12 06:35:28 +00:00 (Migrated from github.com)

Summary

Foundation для G1 #28, G2 #29, G3 #30, E1 #51, I3 #44 и поддержки on-demand #93.

Полное закрытие #94 разбито на несколько PR (sequential — per memory rule):

  • Этот PR: client + types + tests (foundation)
  • 🔜 Next: schema migrations (новые таблицы / колонки) — отдельный PR на основе client
  • 🔜 Затем: Celery tasks (sync_territorial_zones_bbox, sync_zouit_*) — используют client
  • 🔜 Затем: admin endpoints + интеграция в cadastre_fetch.py (заменит rosreestr2coord)

Что внутри

backend/app/services/scrapers/nspd_client.py

Method Endpoint Use case
search_by_cad(cad, thematic_id) /api/geoportal/v2/search/geoportal?query=... G2 ВРИ + категория земель + cost_value одним запросом
get_feature_info(layer_id, lon, lat) /api/aeggis/v4/{layer}/wms?REQUEST=GetFeatureInfo Per-point feature lookup (ЗОУИТ на участке, risk overlap)
get_features_in_bbox(layer_id, bbox_3857) WMS GetFeatureInfo с large bbox Bulk-импорт зон/ЗОУИТ (workaround: WFS → 404)
list_layers(theme_id) /api/geoportal/v1/layers-theme-tree?themeId= Discovery catalog (admin UI)

Typed responses (frozen dataclasses):

  • NSPDFeature — GeoJSON Feature с typed feature_id/geometry/properties
  • NSPDSearchResult — обёртка над features + raw + helpers (.first, .is_empty)
  • NSPDLayer — layer metadata из layers-theme-tree

LAYERS catalog — 32 layer_id с семантическими именами по 6 TIER per #94:

  • TIER 1 критичные: territorial_zones=875838 (G1 ПЗЗ), parcels=36048, buildings=36049, engineering_structures=36328, red_lines=879243
  • TIER 2 ЗОУИТ (G3): zouit_okn=37577, zouit_engineering=37578, zouit_natural=37580, zouit_protected=37579, zouit_other=37581
  • TIER 3 риски: 872xxx (подтопление, затопление, заболачивание, эрозии, обвалы)
  • TIER 4 opportunity: ООПТ, береговые линии, ЗУ на аукционе
  • TIER 5/6 ARN heatmaps + administrative

Reuse existing infra:

  • fetch_geoportal из nspd_lite (WAF-compatible urllib + rate limit)
  • HEADERS + SSL_CTX из nspd_lite — общая защита от TLS-fingerprint detection
  • NspdLiteWafError (403/429) → caller делает backoff

Coordinate transforms:

  • lonlat_to_3857() — WGS84 → Web Mercator
  • bbox_around_point_m() — буфер вокруг точки в метрах

Tests: backend/tests/test_nspd_client.py

14 unit tests, no network calls:

  • LAYERS catalog sanity (TIER 1/2 IDs match #94 spec)
  • Coordinate transforms (zero, ЕКБ center, bbox buffer)
  • NSPDFeature.from_raw parsing (full, missing fields)
  • NSPDSearchResult (empty / nonempty)
  • _walk_layer_tree (flat / nested / empty inputs)
  • NSPDClient.search_by_cad через monkeypatched fetch_geoportal

uv run pytest tests/test_nspd_client.py -v → 14 passed in 0.08s.

Также включено

Micro-fix из PR #95 review (cosmetic, non-blocking): inline comment "до 25s" → "до 15s" чтобы matched _INLINE_FETCH_WAIT_S (был уже снижен).

Acceptance (per #94)

  • nspd_client.py с 4 методами + типизация
  • search_by_cad(...) контракт parsed correctly (test)
  • Тер.зоны (875838) подгружены для Свердл — в следующем PR (нужны schema + Celery task)
  • ЗОУИТ 5 layers подгружены для test bbox — в следующем PR
  • Rate limit + retry на WAF (reuse nspd_lite)
  • Тесты (Sentry alerts — в следующем PR с celery tasks)

Test plan

  • ruff check + format — clean
  • pytest tests/test_nspd_client.py — 14/14 passed
  • После deploy — manual smoke:
    from app.services.scrapers.nspd_client import NSPDClient
    c = NSPDClient()
    r = c.search_by_cad("66:41:0204016:10")
    print(r.first.properties.get("permitted_use_established_by_document"))
    # Expected: ВРИ строка из НСПД (для уч-ка с этим кад)
    

Why foundation-first split

Per sequential PR rule (memory: feedback_sequential_prs.md): один задача → один PR. Полная реализация #94 = client + schema + Celery + admin + интеграция = ~3-4 дня. Делать всё в одном PR — review impossible, conflict-prone. Этот PR = только client (foundation), следующие PR строят на нём.

## Summary Foundation для **G1 #28**, **G2 #29**, **G3 #30**, **E1 #51**, **I3 #44** и поддержки **on-demand #93**. Полное закрытие #94 разбито на несколько PR (sequential — per memory rule): - ✅ **Этот PR**: client + types + tests (foundation) - 🔜 Next: schema migrations (новые таблицы / колонки) — отдельный PR на основе client - 🔜 Затем: Celery tasks (sync_territorial_zones_bbox, sync_zouit_*) — используют client - 🔜 Затем: admin endpoints + интеграция в `cadastre_fetch.py` (заменит rosreestr2coord) ## Что внутри ### `backend/app/services/scrapers/nspd_client.py` | Method | Endpoint | Use case | |---|---|---| | `search_by_cad(cad, thematic_id)` | `/api/geoportal/v2/search/geoportal?query=...` | G2 ВРИ + категория земель + cost_value одним запросом | | `get_feature_info(layer_id, lon, lat)` | `/api/aeggis/v4/{layer}/wms?REQUEST=GetFeatureInfo` | Per-point feature lookup (ЗОУИТ на участке, risk overlap) | | `get_features_in_bbox(layer_id, bbox_3857)` | WMS GetFeatureInfo с large bbox | Bulk-импорт зон/ЗОУИТ (workaround: WFS → 404) | | `list_layers(theme_id)` | `/api/geoportal/v1/layers-theme-tree?themeId=` | Discovery catalog (admin UI) | **Typed responses** (frozen dataclasses): - `NSPDFeature` — GeoJSON Feature с typed feature_id/geometry/properties - `NSPDSearchResult` — обёртка над features + raw + helpers (.first, .is_empty) - `NSPDLayer` — layer metadata из layers-theme-tree **LAYERS catalog** — 32 layer_id с семантическими именами по 6 TIER per #94: - TIER 1 критичные: `territorial_zones=875838` (G1 ПЗЗ), `parcels=36048`, `buildings=36049`, `engineering_structures=36328`, `red_lines=879243` - TIER 2 ЗОУИТ (G3): `zouit_okn=37577`, `zouit_engineering=37578`, `zouit_natural=37580`, `zouit_protected=37579`, `zouit_other=37581` - TIER 3 риски: 872xxx (подтопление, затопление, заболачивание, эрозии, обвалы) - TIER 4 opportunity: ООПТ, береговые линии, ЗУ на аукционе - TIER 5/6 ARN heatmaps + administrative **Reuse** existing infra: - `fetch_geoportal` из nspd_lite (WAF-compatible urllib + rate limit) - HEADERS + SSL_CTX из nspd_lite — общая защита от TLS-fingerprint detection - `NspdLiteWafError` (403/429) → caller делает backoff **Coordinate transforms**: - `lonlat_to_3857()` — WGS84 → Web Mercator - `bbox_around_point_m()` — буфер вокруг точки в метрах ### Tests: `backend/tests/test_nspd_client.py` 14 unit tests, **no network calls**: - LAYERS catalog sanity (TIER 1/2 IDs match #94 spec) - Coordinate transforms (zero, ЕКБ center, bbox buffer) - NSPDFeature.from_raw parsing (full, missing fields) - NSPDSearchResult (empty / nonempty) - _walk_layer_tree (flat / nested / empty inputs) - NSPDClient.search_by_cad через monkeypatched fetch_geoportal `uv run pytest tests/test_nspd_client.py -v` → 14 passed in 0.08s. ### Также включено Micro-fix из PR #95 review (cosmetic, non-blocking): inline comment "до 25s" → "до 15s" чтобы matched `_INLINE_FETCH_WAIT_S` (был уже снижен). ## Acceptance (per #94) - [x] `nspd_client.py` с 4 методами + типизация - [x] `search_by_cad(...)` контракт parsed correctly (test) - [ ] Тер.зоны (875838) подгружены для Свердл — **в следующем PR** (нужны schema + Celery task) - [ ] ЗОУИТ 5 layers подгружены для test bbox — **в следующем PR** - [x] Rate limit + retry на WAF (reuse nspd_lite) - [x] Тесты (Sentry alerts — в следующем PR с celery tasks) ## Test plan - [x] `ruff check + format` — clean - [x] `pytest tests/test_nspd_client.py` — 14/14 passed - [ ] После deploy — manual smoke: ```python from app.services.scrapers.nspd_client import NSPDClient c = NSPDClient() r = c.search_by_cad("66:41:0204016:10") print(r.first.properties.get("permitted_use_established_by_document")) # Expected: ВРИ строка из НСПД (для уч-ка с этим кад) ``` ## Why foundation-first split Per sequential PR rule (memory: feedback_sequential_prs.md): один задача → один PR. Полная реализация #94 = client + schema + Celery + admin + интеграция = ~3-4 дня. Делать всё в одном PR — review impossible, conflict-prone. Этот PR = только client (foundation), следующие PR строят на нём.
lekss361 commented 2026-05-12 06:37:42 +00:00 (Migrated from github.com)

⚠️ Auto-review: minor (non-blocking)

backend/app/services/scrapers/nspd_client.py:

  • Line ≤100 нарушено: :235def bbox_around_point_m(lon: float, lat: float, buffer_m: int) -> tuple[float, float, float, float]: = 101 символ. Pre-commit ruff завалит commit. Разбить сигнатуру на несколько строк.
  • Lazy imports :201-202import json, time внутри тела _http_get_json нестандартно для кодовой базы (нет аналогов в nspd_lite.py). Перенести на top-level.
  • Дублирование SSL контекста :133ssl._create_unverified_context() дублирует _SSL_CTX из nspd_lite.py:55. Переиспользовать: from app.services.scrapers.nspd_lite import _SSL_CTX. Так workaround не дублируется, изменения SSL-конфига применяются глобально.
  • Silent failure risk в list_layers :394_walk_layer_tree(data) получает response напрямую без проверки shape. Если NSPD вернёт dict с обёрткой {"data": [...]}, walker молча вернёт []. Добавить data = data if isinstance(data, list) else data.get("data", []) или хотя бы залогировать неожиданный тип. (Паттерн ловили в Bug_Nspd_Geo_Str_Object_No_Get_Fixed.)
  • search_by_cad :260 без валидации формата cad_num — спецсимволы/пробелы корректно URL-encode'нутся (нет injection), но молча вернут пустой результат. Задокументировать ожидаемый формат в docstring.

backend/tests/test_nspd_client.py:

  • Missing mock-тесты для get_feature_info, get_features_in_bbox, list_layers_http_get_json не покрыт ни одним тестом. URL-сборка с bbox в EPSG:3857 не верифицируется. Foundation PR — желательно добавить по одному монкипатчу на _http_get_json per method.

backend/app/api/v1/parcels.py:1060:

  • Висячая ссылка — комментарий упоминает _INLINE_FETCH_WAIT_S, которой нет в файле (grep 0 совпадений). Либо переименовано, либо в другом модуле. Убрать или поправить.

Positive:

  • User-Agent header реиспользован из nspd_lite.py (не повторит баг 93f1379 с 406).
  • Timeout 15s default, rate limit DEFAULT_RATE_MS=600 — WAF-safety соблюдена.
  • NspdLiteWafError для 403/429 — корректный контракт (backoff — ответственность caller).
  • URL encoding через urlencode/typed params — нет injection.
  • frozen=True, slots=True dataclasses — immutable, memory-efficient.
  • __all__ явный экспорт.
  • _walk_layer_tree покрыт 3 тестами (flat/nested/empty).
⚠️ Auto-review: minor (non-blocking) **`backend/app/services/scrapers/nspd_client.py`:** - **Line ≤100 нарушено: `:235`** — `def bbox_around_point_m(lon: float, lat: float, buffer_m: int) -> tuple[float, float, float, float]:` = 101 символ. Pre-commit ruff завалит commit. Разбить сигнатуру на несколько строк. - **Lazy imports `:201-202`** — `import json, time` внутри тела `_http_get_json` нестандартно для кодовой базы (нет аналогов в `nspd_lite.py`). Перенести на top-level. - **Дублирование SSL контекста `:133`** — `ssl._create_unverified_context()` дублирует `_SSL_CTX` из `nspd_lite.py:55`. Переиспользовать: `from app.services.scrapers.nspd_lite import _SSL_CTX`. Так workaround не дублируется, изменения SSL-конфига применяются глобально. - **Silent failure risk в `list_layers` `:394`** — `_walk_layer_tree(data)` получает response напрямую без проверки shape. Если NSPD вернёт dict с обёрткой `{"data": [...]}`, walker молча вернёт `[]`. Добавить `data = data if isinstance(data, list) else data.get("data", [])` или хотя бы залогировать неожиданный тип. (Паттерн ловили в `Bug_Nspd_Geo_Str_Object_No_Get_Fixed`.) - **`search_by_cad` `:260` без валидации формата `cad_num`** — спецсимволы/пробелы корректно URL-encode'нутся (нет injection), но молча вернут пустой результат. Задокументировать ожидаемый формат в docstring. **`backend/tests/test_nspd_client.py`:** - **Missing mock-тесты для `get_feature_info`, `get_features_in_bbox`, `list_layers`** — `_http_get_json` не покрыт ни одним тестом. URL-сборка с bbox в EPSG:3857 не верифицируется. Foundation PR — желательно добавить по одному монкипатчу на `_http_get_json` per method. **`backend/app/api/v1/parcels.py:1060`:** - **Висячая ссылка** — комментарий упоминает `_INLINE_FETCH_WAIT_S`, которой нет в файле (grep 0 совпадений). Либо переименовано, либо в другом модуле. Убрать или поправить. **Positive:** - User-Agent header реиспользован из `nspd_lite.py` (не повторит баг 93f1379 с 406). - Timeout 15s default, rate limit `DEFAULT_RATE_MS=600` — WAF-safety соблюдена. - `NspdLiteWafError` для 403/429 — корректный контракт (backoff — ответственность caller). - URL encoding через `urlencode`/typed params — нет injection. - `frozen=True, slots=True` dataclasses — immutable, memory-efficient. - `__all__` явный экспорт. - `_walk_layer_tree` покрыт 3 тестами (flat/nested/empty). <!-- gendesign-review-bot: sha=be14388 verdict=minor -->
lekss361 commented 2026-05-12 06:46:02 +00:00 (Migrated from github.com)

Все 6 findings применены в `6ce7322`:

nspd_client.py:

  1. Line ≤100 — `bbox_around_point_m` signature split на multi-line.
  2. Lazy imports — `import json, time` перенесены на module-level (matches nspd_lite pattern).
  3. Duplicate `_SSL_CTX` — теперь `from app.services.scrapers.nspd_lite import _SSL_CTX`. One source of truth для WAF TLS settings.
  4. `list_layers` shape defense — checks `{"data": [...]}` wrapper + non-dict/list garbage → warning log + empty result. Ловит regression на `Bug_Nspd_Geo_Str_Object_No_Get_Fixed`.
  5. `search_by_cad` cad_num format задокументирован: `NN:NN:NNNNNN[:NN[:NN]]` + ссылка на `validate_cad_format` в cadastre_fetch.

test_nspd_client.py: +5 mock тестов (19/19 passed):

  • `test_get_feature_info_parses_response` — URL builder + BBOX 100m buffer + STYLES required
  • `test_get_features_in_bbox_url_builds_correctly` — bbox propagation, WIDTH=4096, I=J=center
  • `test_list_layers_walks_tree_response` — nested tree → NSPDLayer list
  • `test_list_layers_handles_data_wrapper` — `{"data": [...]}` shape
  • `test_list_layers_handles_garbage_response` — non-list/dict garbage → empty

parcels.py: docstring stale '~25с' → '~15с'.

Bonus: ruff UP038 `isinstance(data, (list, dict))` → `isinstance(data, list | dict)` (PY3.10+ union).

`ruff` + 19/19 tests passed. Ждём re-review.

Все 6 findings применены в \`6ce7322\`: **nspd_client.py:** 1. ✅ Line ≤100 — \`bbox_around_point_m\` signature split на multi-line. 2. ✅ Lazy imports — \`import json, time\` перенесены на module-level (matches nspd_lite pattern). 3. ✅ Duplicate \`_SSL_CTX\` — теперь \`from app.services.scrapers.nspd_lite import _SSL_CTX\`. One source of truth для WAF TLS settings. 4. ✅ \`list_layers\` shape defense — checks \`{"data": [...]}\` wrapper + non-dict/list garbage → warning log + empty result. Ловит regression на \`Bug_Nspd_Geo_Str_Object_No_Get_Fixed\`. 5. ✅ \`search_by_cad\` cad_num format задокументирован: \`NN:NN:NNNNNN[:NN[:NN]]\` + ссылка на \`validate_cad_format\` в cadastre_fetch. **test_nspd_client.py:** +5 mock тестов (19/19 passed): - \`test_get_feature_info_parses_response\` — URL builder + BBOX 100m buffer + STYLES required - \`test_get_features_in_bbox_url_builds_correctly\` — bbox propagation, WIDTH=4096, I=J=center - \`test_list_layers_walks_tree_response\` — nested tree → NSPDLayer list - \`test_list_layers_handles_data_wrapper\` — \`{"data": [...]}\` shape - \`test_list_layers_handles_garbage_response\` — non-list/dict garbage → empty **parcels.py:** docstring stale '~25с' → '~15с'. Bonus: ruff UP038 \`isinstance(data, (list, dict))\` → \`isinstance(data, list | dict)\` (PY3.10+ union). \`ruff\` + 19/19 tests passed. Ждём re-review.
lekss361 commented 2026-05-12 06:47:30 +00:00 (Migrated from github.com)

Auto-review passed (LGTM) — все 6 findings корректно применены, 5 новых тестов non-vacuous:

Fixes verified:

  1. bbox_around_point_m signature split на 3 строки — line ≤100 OK.
  2. import json / import time → top-level (lines 63, 66).
  3. _SSL_CTX imported from nspd_lite (line 74), single source of truth.
  4. list_layers shape defense (lines 419-427): isinstance(data, dict) and "data" in data and not data.get("children") для unwrap + not isinstance(data, list | dict) для garbage. Guard на "children" предотвращает unwrap легитимного tree-root.
  5. search_by_cad docstring: NN:NN:NNNNNN[:NN[:NN]] + примеры 3/4/5-segment + ссылка на validate_cad_format.
  6. parcels.py ~25с~15с в docstring и inline comment, + добавлено rationale про threadpool safety.

Tests verified non-vacuous (5 new):

  • test_get_feature_info_parses_response — URL contains /api/aeggis/v4/875838/wms, REQUEST=GetFeatureInfo, STYLES=, CRS=EPSG%3A3857.
  • test_get_features_in_bbox_url_builds_correctlyBBOX propagation, WIDTH=4096, I=J=2048 (center pixel).
  • test_list_layers_walks_tree_response — nested tree → 2 leaves with metadata preserved.
  • test_list_layers_handles_data_wrapper{"data": [...], "meta": ...} exercise shape-defense.
  • test_list_layers_handles_garbage_response — string input → [] (early-return path).

Bonus: ruff UP038 fix consistent с PY3.12 target.

Merging.

✅ Auto-review passed (LGTM) — все 6 findings корректно применены, 5 новых тестов non-vacuous: **Fixes verified:** 1. `bbox_around_point_m` signature split на 3 строки — line ≤100 OK. 2. `import json` / `import time` → top-level (lines 63, 66). 3. `_SSL_CTX` imported from `nspd_lite` (line 74), single source of truth. 4. `list_layers` shape defense (lines 419-427): `isinstance(data, dict) and "data" in data and not data.get("children")` для unwrap + `not isinstance(data, list | dict)` для garbage. Guard на "children" предотвращает unwrap легитимного tree-root. 5. `search_by_cad` docstring: `NN:NN:NNNNNN[:NN[:NN]]` + примеры 3/4/5-segment + ссылка на `validate_cad_format`. 6. `parcels.py` `~25с` → `~15с` в docstring и inline comment, + добавлено rationale про threadpool safety. **Tests verified non-vacuous (5 new):** - `test_get_feature_info_parses_response` — URL contains `/api/aeggis/v4/875838/wms`, `REQUEST=GetFeatureInfo`, `STYLES=`, `CRS=EPSG%3A3857`. - `test_get_features_in_bbox_url_builds_correctly` — `BBOX` propagation, `WIDTH=4096`, `I=J=2048` (center pixel). - `test_list_layers_walks_tree_response` — nested tree → 2 leaves with metadata preserved. - `test_list_layers_handles_data_wrapper` — `{"data": [...], "meta": ...}` exercise shape-defense. - `test_list_layers_handles_garbage_response` — string input → `[]` (early-return path). Bonus: ruff UP038 fix consistent с PY3.12 target. Merging. <!-- gendesign-review-bot: sha=6ce7322 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#98
No description provided.