- 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).
281 lines
11 KiB
Python
281 lines
11 KiB
Python
"""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())
|