gendesign/data/sql/58_fetch_cad_quarters_nspd.py
lekss361 25b73035a1 sprint1: nspd scraper industrialization, per-bucket elasticity, cadastre cross-check, sentry releases
- NSPD-skraper переехал в backend/app/services/scrapers/nspd_kn.py +
  Celery task scrape_nspd_region (beat: 20-е февраля/мая/авг/нояб).
  Redis lock 3h, WAF auto-retry, heartbeat в nspd_scrape_runs.
- Recommend_mix Tier 3: per-bucket elasticity через регрессию по
  «доминирующему bucket» каждого ЖК. Weighted-elasticity для inverse-mode.
  UI показывает разброс эластичностей и переключение regression/fallback.
- Cadastre vs market cross-check: spatial-join cad_buildings →
  ekb_districts_geom; cadastre_vs_market_pct в scope, аномалии
  (>+50% / <-30%) подсвечены в UI.
- Sentry release tracking (#4): IMAGE_TAG → backend/.env.runtime →
  sentry_sdk.init(release=...). Compose v2 env_file optional path.

Schemas: 63_schema_nspd_runs.sql (cad_buildings + nspd_scrape_runs/log
формализуют то, что уже жило в проде через 61_import_nspd_batch.py),
64_v_zk_rosreestr_velocity.sql (refresh с cad_buildings).
2026-04-30 21:51:19 +03:00

281 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Fetch кадастровые квартал-полигоны из NSPD и загрузить в PostGIS.
Usage:
cd backend && uv run python ../data/sql/58_fetch_cad_quarters_nspd.py [options]
Options:
--scope ekb|region ekb (default): только cad-кварталы с ДДУ-сделками
в ЕКБ (~674 кварталов). region: вся Свердл (~10 853).
--batch-size N commit каждые N кварталов (default 25).
--rate-limit-ms N пауза между запросами в мс (default 700).
--resume пропустить cad-кварталы уже в cad_quarters_geom.
--max N ограничить N запросами (для smoke-теста).
--dry-run fetch+parse only, skip DB upsert.
ВАЖНО: NSPD блокирует запросы с не-российских IP (WAF). Скрипт нужно запускать
с машины в РФ. Если получаешь HTTP 403 + 'Client IP: ...' — это блок гео.
Endpoint: GET https://nspd.gov.ru/api/geoportal/v2/search/geoportal
?thematicSearchId=1&query={cad_number}
Returns: GeoJSON FeatureCollection. Геометрия MultiPolygon в EPSG:3857 (web
mercator). Скрипт переводит в EPSG:4326 через ST_Transform на стороне
БД при INSERT.
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# Add backend to path so we can import app.* in standalone CLI mode.
ROOT = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(ROOT / "backend"))
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
NSPD_URL = "https://nspd.gov.ru/api/geoportal/v2/search/geoportal"
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Accept-Language": "ru-RU,ru;q=0.9",
"Referer": "https://nspd.gov.ru/map",
}
def fetch_cad_quarter(cad_number: str, *, retries: int = 3, timeout: int = 30) -> dict | None:
"""Запрос NSPD API для одного cad-номера.
Returns: GeoJSON FeatureCollection or None если квартал не найден / ошибка.
"""
qs = urllib.parse.urlencode(
{
"thematicSearchId": 1, # 1 = кадастровые квартал
"query": cad_number,
}
)
url = f"{NSPD_URL}?{qs}"
req = urllib.request.Request(url, headers=HEADERS)
last_err: Exception | None = None
for attempt in range(retries):
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 403:
# WAF: гео-блок не лечится retry. Бросаем выше — оператор решит.
body = e.read().decode("utf-8", errors="ignore")[:300]
log.error("HTTP 403 from NSPD (likely geo-WAF). Body: %s", body)
raise
if e.code == 404:
return None # cad-квартал не найден — норма для глухих номеров.
last_err = e
log.warning("HTTP %d for %s (attempt %d/%d)", e.code, cad_number, attempt + 1, retries)
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = e
log.warning("Network err for %s: %s (attempt %d/%d)", cad_number, e, attempt + 1, retries)
time.sleep(2 ** attempt)
log.error("Failed %s after %d retries: %s", cad_number, retries, last_err)
return None
def extract_geometry(geojson: dict | None, cad_number: str) -> tuple[dict | None, dict | None]:
"""Из GeoJSON выбрать первый MultiPolygon (или Polygon) под точный
cad-номер. Returns (geometry_dict, raw_properties)."""
if not geojson or "features" not in geojson:
return None, None
for feat in geojson["features"]:
props = feat.get("properties") or {}
# NSPD возвращает кадастровый номер в одном из этих полей.
cn = (
props.get("cn")
or props.get("cadastralNumber")
or props.get("cad_num")
or props.get("CAD_NUMBER")
)
if cn and str(cn).strip() == cad_number:
geom = feat.get("geometry")
if not geom:
continue
# Принимаем Polygon → MultiPolygon (для unification).
if geom.get("type") == "Polygon":
geom = {"type": "MultiPolygon", "coordinates": [geom["coordinates"]]}
return geom, props
# Fallback: если features=1 и cn совпадает по prefix — берём первую.
if len(geojson["features"]) == 1:
feat = geojson["features"][0]
geom = feat.get("geometry")
if geom:
if geom.get("type") == "Polygon":
geom = {"type": "MultiPolygon", "coordinates": [geom["coordinates"]]}
return geom, feat.get("properties") or {}
return None, None
def candidates_to_fetch(scope: str, resume: bool) -> list[str]:
"""Возвращает список cad-номеров для скрейпа из rosreestr_deals."""
from sqlalchemy import text
from app.core.db import SessionLocal
db = SessionLocal()
try:
if scope == "ekb":
rows = db.execute(
text(
"""
SELECT DISTINCT quarter_cad_number
FROM rosreestr_deals
WHERE region_code = 66
AND doc_type = 'ДДУ'
AND realestate_type_code = '002001003000'
AND (district ILIKE '%Екатеринбург%' OR city ILIKE '%Екатеринбург%')
AND quarter_cad_number IS NOT NULL
AND quarter_cad_number <> ''
"""
)
).all()
else: # region
rows = db.execute(
text(
"""
SELECT DISTINCT quarter_cad_number
FROM rosreestr_deals
WHERE region_code = 66
AND quarter_cad_number IS NOT NULL
AND quarter_cad_number <> ''
"""
)
).all()
cads = sorted({r[0] for r in rows if r[0]})
if resume:
done = {
r[0]
for r in db.execute(
text("SELECT cad_number FROM cad_quarters_geom")
).all()
}
cads = [c for c in cads if c not in done]
log.info("Resume: skipping %d already-loaded quarters", len(done))
return cads
finally:
db.close()
def upsert_quarter(db, cad_number: str, geom_dict: dict, props: dict) -> None:
"""INSERT или UPDATE в cad_quarters_geom. Полигон приходит в EPSG:3857
(web mercator); конвертируем в 4326 на стороне БД через ST_Transform."""
from sqlalchemy import text
db.execute(
text(
"""
INSERT INTO cad_quarters_geom (cad_number, geom, raw_props, fetched_at, source)
VALUES (
:cn,
ST_Multi(
ST_Transform(
ST_SetSRID(ST_GeomFromGeoJSON(:gj), 3857),
4326
)
)::geometry(MultiPolygon, 4326),
CAST(:props AS jsonb),
NOW(),
'nspd'
)
ON CONFLICT (cad_number) DO UPDATE SET
geom = EXCLUDED.geom,
raw_props = EXCLUDED.raw_props,
fetched_at = NOW()
"""
),
{
"cn": cad_number,
"gj": json.dumps(geom_dict),
"props": json.dumps(props, ensure_ascii=False),
},
)
def main() -> int:
p = argparse.ArgumentParser(description="Fetch+load NSPD cad-quarter polygons")
p.add_argument("--scope", choices=["ekb", "region"], default="ekb")
p.add_argument("--batch-size", type=int, default=25)
p.add_argument("--rate-limit-ms", type=int, default=700)
p.add_argument("--resume", action="store_true", help="Skip already-loaded cad-numbers")
p.add_argument("--max", type=int, default=None, help="Limit N requests (smoke test)")
p.add_argument("--dry-run", action="store_true")
args = p.parse_args()
cads = candidates_to_fetch(args.scope, resume=args.resume)
if args.max:
cads = cads[: args.max]
log.info("Plan to fetch %d cad-quarters (scope=%s)", len(cads), args.scope)
if not cads:
log.info("Nothing to do.")
return 0
if args.dry_run:
log.info("Dry-run: would fetch %s", cads[:5])
return 0
from sqlalchemy import text # noqa: F401
from app.core.db import SessionLocal
db = SessionLocal()
ok = 0
skipped = 0
failed = 0
try:
for i, cn in enumerate(cads, 1):
try:
gj = fetch_cad_quarter(cn)
geom, props = extract_geometry(gj, cn)
if geom is None:
log.warning("[%d/%d] %s — no geometry returned", i, len(cads), cn)
skipped += 1
else:
upsert_quarter(db, cn, geom, props or {})
ok += 1
if i % 10 == 0 or i == len(cads):
log.info("[%d/%d] %s ✓ (ok=%d skip=%d fail=%d)",
i, len(cads), cn, ok, skipped, failed)
except urllib.error.HTTPError as e:
if e.code == 403:
db.commit()
log.error(
"Aborting: NSPD WAF блокирует запросы (HTTP 403). "
"Скрипт нужно запускать с российского IP. Saved %d so far.",
ok,
)
return 2
failed += 1
except Exception as e: # noqa: BLE001
failed += 1
log.exception("[%d/%d] %s%s", i, len(cads), cn, e)
if i % args.batch_size == 0:
db.commit()
log.info(" → committed batch (i=%d, ok=%d)", i, ok)
time.sleep(args.rate_limit_ms / 1000.0)
db.commit()
log.info("Done: ok=%d, skipped=%d, failed=%d", ok, skipped, failed)
return 0
finally:
db.close()
if __name__ == "__main__":
sys.exit(main())