gendesign/backend/app/services/scrapers/nspd_kn.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

536 lines
19 KiB
Python
Raw 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.

"""NSPD scraper — кадастровые кварталы и здания через nspd.gov.ru API.
Endpoints:
GET /api/geoportal/v2/search/geoportal?thematicSearchId=2&query={cad}
→ polygon кадастрового квартала (1 feature с label = cad-номер)
GET /api/geoportal/v2/search/geoportal?thematicSearchId=1&query={cad}
→ объекты внутри квартала (здания, земля, линейные).
Фильтруем categoryName='Здания' и cad_num LIKE '<region>:<district>:%'.
WAF nspd.gov.ru банит burst-запросы и нероссийские IP. Стратегия:
- rate-limit 600мс между запросами (≤100 req/min — наблюдаемый предел)
- exponential backoff на 403 (60s + 30s × attempt)
- запускать с RU IP (production worker должен быть в RU/CIS-zone)
Этот модуль — рефакторинг data/sql/62_scrape_nspd_full.py из stand-alone скрипта
в библиотечную функцию для Celery. Логика идентична, но:
- работает через SQLAlchemy Session (FK к cad_quarters_geom/cad_buildings)
- пишет heartbeat и progress в nspd_scrape_runs
- логирует структурированно в nspd_scrape_log
"""
from __future__ import annotations
import json
import logging
import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.core.db import SessionLocal
logger = logging.getLogger(__name__)
NSPD_BASE = "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",
}
SSL_CTX = ssl._create_unverified_context()
DEFAULT_RATE_MS = 600
DEFAULT_HEARTBEAT_EVERY = 5 # quarters
DEFAULT_COMMIT_EVERY = 10
DEFAULT_RETRIES = 5
DEFAULT_TIMEOUT_S = 30
# region_code (rosreestr) → cad-prefix фильтр для cad_buildings.
# 66 = Свердловская обл., 66:41 = ЕКБ. Для других регионов добавлять mapping.
REGION_CAD_PREFIX: dict[int, str] = {
66: "66:41:", # Екатеринбург
}
# ── HTTP layer ────────────────────────────────────────────────────────────────
class WafBlockedError(RuntimeError):
"""403 после всех retry — WAF банит, прогон не продолжаем."""
def nspd_fetch(
thematic_search_id: int,
query: str,
*,
retries: int = DEFAULT_RETRIES,
timeout: int = DEFAULT_TIMEOUT_S,
on_403: Any = None,
) -> dict | None:
"""Fetch NSPD с retry/backoff. on_403 callable(attempt) — для трекинга WAF."""
qs = urllib.parse.urlencode({"thematicSearchId": thematic_search_id, "query": query})
url = f"{NSPD_BASE}?{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, context=SSL_CTX) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
last_err = e
if e.code == 404:
return None
if e.code == 403:
if on_403:
on_403(attempt)
wait = 60 + 30 * attempt
logger.warning(
"WAF 403 для %s (попытка %d/%d), пауза %ds", query, attempt + 1, retries, wait
)
time.sleep(wait)
continue
logger.warning("HTTP %s для %s (попытка %d)", e.code, query, attempt + 1)
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = e
logger.warning("Сетевая ошибка для %s: %s (попытка %d)", query, e, attempt + 1)
time.sleep(min(2**attempt, 30))
if isinstance(last_err, urllib.error.HTTPError) and last_err.code == 403:
raise WafBlockedError(f"WAF banned after {retries} retries on {query}")
logger.error("FAILED %s после %d попыток: %s", query, retries, last_err)
return None
# ── Geometry helpers ──────────────────────────────────────────────────────────
def poly_to_wkt(geom: dict | None) -> str | None:
if not geom:
return None
def ring(r: list) -> str:
return "(" + ",".join(f"{p[0]} {p[1]}" for p in r) + ")"
t = geom.get("type")
if t == "Polygon":
return "POLYGON(" + ",".join(ring(r) for r in geom["coordinates"]) + ")"
if t == "MultiPolygon":
return (
"MULTIPOLYGON("
+ ",".join("(" + ",".join(ring(r) for r in p) + ")" for p in geom["coordinates"])
+ ")"
)
if t == "Point":
return f"POINT({geom['coordinates'][0]} {geom['coordinates'][1]})"
return None
# ── SQL helpers ───────────────────────────────────────────────────────────────
def _to_int(x: Any) -> int | None:
if x is None or x == "":
return None
try:
return int(x)
except (TypeError, ValueError):
return None
def _to_num(x: Any) -> float | None:
if x is None or x == "":
return None
try:
return float(x)
except (TypeError, ValueError):
return None
def _to_date(x: Any) -> str | None:
if not x:
return None
s = str(x).split(" ")[0]
return s or None
def get_pending_cads(db: Session, region_code: int) -> list[str]:
"""Cad-кварталы с ДДУ-сделками в регионе минус уже scraped."""
rows = db.execute(
text(
"""
SELECT DISTINCT quarter_cad_number
FROM rosreestr_deals
WHERE region_code = :rc
AND doc_type = 'ДДУ'
AND realestate_type_code = '002001003000'
AND quarter_cad_number IS NOT NULL
AND quarter_cad_number <> ''
AND quarter_cad_number !~ '^00:00:'
AND quarter_cad_number !~ ':0000000$'
"""
),
{"rc": region_code},
).all()
pending = {r[0] for r in rows}
done = {r[0] for r in db.execute(text("SELECT cad_number FROM cad_quarters_geom")).all()}
return sorted(pending - done)
def insert_quarter(db: Session, cad: str, wkt: str, raw_props: dict | None) -> None:
db.execute(
text(
"""
INSERT INTO cad_quarters_geom (cad_number, geom, raw_props, source)
VALUES (
:cad,
ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromText(:wkt), 3857), 4326))
::geometry(MultiPolygon, 4326),
CAST(:props AS jsonb),
'nspd'
)
ON CONFLICT (cad_number) DO UPDATE SET
geom = EXCLUDED.geom,
raw_props = COALESCE(EXCLUDED.raw_props, cad_quarters_geom.raw_props),
fetched_at = NOW()
"""
),
{
"cad": cad,
"wkt": wkt,
"props": json.dumps(raw_props, ensure_ascii=False) if raw_props else None,
},
)
def insert_buildings(db: Session, qcad: str, features: list[dict], cad_prefix: str) -> int:
n = 0
for f in features:
props_outer = f.get("properties") or {}
if props_outer.get("categoryName") != "Здания":
continue
opts = props_outer.get("options") or {}
cn = opts.get("cad_num")
if not cn or not cn.startswith(cad_prefix):
continue
wkt = poly_to_wkt(f.get("geometry"))
if not wkt:
continue
db.execute(
text(
"""
INSERT INTO cad_buildings (
cad_num, quarter_cad_num, geom, purpose, building_name,
readable_address, area, floors, year_built, year_commisioning,
cost_value, registration_date,
status, ownership_type, cultural_heritage, underground_floors,
build_record_area, build_record_type, common_data_status, obj_type,
raw_props
)
VALUES (
:cad_num, :qcad,
ST_Transform(ST_SetSRID(ST_GeomFromText(:wkt), 3857), 4326),
:purpose, :name, :addr, :area, :floors, :yb, :yc,
:cost, :reg_date,
:status, :ownership, :cultural, :underground,
:build_rec_area, :build_rec_type, :common_status, :obj_type,
CAST(:raw_props AS jsonb)
)
ON CONFLICT (cad_num) DO UPDATE SET
cost_value = EXCLUDED.cost_value,
area = EXCLUDED.area,
year_built = EXCLUDED.year_built,
year_commisioning = EXCLUDED.year_commisioning,
status = EXCLUDED.status,
ownership_type = EXCLUDED.ownership_type,
cultural_heritage = EXCLUDED.cultural_heritage,
underground_floors = EXCLUDED.underground_floors,
build_record_area = EXCLUDED.build_record_area,
build_record_type = EXCLUDED.build_record_type,
common_data_status = EXCLUDED.common_data_status,
obj_type = EXCLUDED.obj_type,
raw_props = COALESCE(EXCLUDED.raw_props, cad_buildings.raw_props),
fetched_at = NOW()
"""
),
{
"cad_num": cn,
"qcad": qcad,
"wkt": wkt,
"purpose": opts.get("purpose"),
"name": opts.get("building_name"),
"addr": opts.get("readable_address"),
"area": _to_num(opts.get("area")),
"floors": _to_int(opts.get("floors")),
"yb": _to_int(opts.get("year_built")),
"yc": _to_int(opts.get("year_commisioning")),
"cost": _to_num(opts.get("cost_value")),
"reg_date": _to_date(opts.get("registration_date")),
"status": opts.get("status"),
"ownership": opts.get("ownership_type"),
"cultural": opts.get("cultural_heritage"),
"underground": _to_int(opts.get("underground_floors")),
"build_rec_area": _to_num(opts.get("build_record_area")),
"build_rec_type": opts.get("build_record_type"),
"common_status": opts.get("common_data_status"),
"obj_type": opts.get("obj_type"),
"raw_props": json.dumps(opts, ensure_ascii=False) if opts else None,
},
)
n += 1
return n
# ── Run lifecycle ─────────────────────────────────────────────────────────────
def _start_run(db: Session, region_code: int, triggered_by: str, pending_count: int) -> int:
row = db.execute(
text(
"""
INSERT INTO nspd_scrape_runs (region_code, triggered_by, pending_count, status)
VALUES (:rc, :tb, :pc, 'running')
RETURNING run_id
"""
),
{"rc": region_code, "tb": triggered_by, "pc": pending_count},
).scalar_one()
db.commit()
return int(row)
def _heartbeat(db: Session, run_id: int, **counts: int) -> None:
sets = ["heartbeat_at = NOW()"]
params: dict[str, Any] = {"rid": run_id}
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
db.execute(
text(f"UPDATE nspd_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
params,
)
db.commit()
def _finish_run(
db: Session,
run_id: int,
*,
status: str,
error: str | None = None,
**counts: int,
) -> None:
sets = ["finished_at = NOW()", "status = :status"]
params: dict[str, Any] = {"rid": run_id, "status": status, "error": error}
sets.append("error = :error")
for k, v in counts.items():
sets.append(f"{k} = :{k}")
params[k] = v
db.execute(
text(f"UPDATE nspd_scrape_runs SET {', '.join(sets)} WHERE run_id = :rid"),
params,
)
db.commit()
def _log(
db: Session,
run_id: int | None,
*,
level: str,
stage: str,
message: str,
cad: str | None = None,
) -> None:
try:
db.execute(
text(
"""
INSERT INTO nspd_scrape_log (run_id, level, stage, cad_number, message)
VALUES (:rid, :lvl, :st, :cad, :msg)
"""
),
{"rid": run_id, "lvl": level, "st": stage, "cad": cad, "msg": message[:1000]},
)
db.commit()
except Exception as e:
logger.warning("nspd_scrape_log insert failed: %s", e)
# ── Main entrypoint ───────────────────────────────────────────────────────────
def run_region_scrape(
*,
region_code: int,
triggered_by: str = "beat",
limit: int | None = None,
rate_ms: int = DEFAULT_RATE_MS,
commit_every: int = DEFAULT_COMMIT_EVERY,
heartbeat_every: int = DEFAULT_HEARTBEAT_EVERY,
) -> dict[str, Any]:
"""Полный sweep одного региона. Безопасен для повторных запусков —
pending = (rosreestr cads) (cad_quarters_geom cads)."""
cad_prefix = REGION_CAD_PREFIX.get(region_code)
if not cad_prefix:
raise ValueError(f"region_code={region_code} unknown — добавь в REGION_CAD_PREFIX")
db = SessionLocal()
run_id: int | None = None
try:
pending = get_pending_cads(db, region_code)
if limit:
pending = pending[:limit]
run_id = _start_run(db, region_code, triggered_by, len(pending))
_log(
db,
run_id,
level="info",
stage="discover",
message=f"region={region_code} pending={len(pending)} cads",
)
if not pending:
_finish_run(db, run_id, status="done", quarters_ok=0, quarters_failed=0, buildings_ok=0)
return {"run_id": run_id, "pending": 0, "ok": 0, "failed": 0, "buildings": 0}
ok = 0
failed = 0
n_buildings = 0
n_requests = 0
n_waf = 0
started = time.time()
for i, cn in enumerate(pending, 1):
try:
j2 = nspd_fetch(2, cn, on_403=lambda _a: None)
n_requests += 1
qf = None
q_props: dict | None = None
if j2:
for f in j2.get("data", {}).get("features") or []:
if (f.get("properties") or {}).get("label") == cn:
qf = f
q_props = (f.get("properties") or {}).get("options") or {}
break
wkt = poly_to_wkt(qf.get("geometry") if qf else None) if qf else None
if wkt:
insert_quarter(db, cn, wkt, q_props)
ok += 1
else:
failed += 1
_log(
db,
run_id,
level="warn",
stage="quarter_fetch",
cad=cn,
message="no polygon returned",
)
j1 = nspd_fetch(1, cn, on_403=lambda _a: None)
n_requests += 1
if j1:
features = j1.get("data", {}).get("features") or []
n_buildings += insert_buildings(db, cn, features, cad_prefix)
if i % commit_every == 0:
db.commit()
if i % heartbeat_every == 0:
_heartbeat(
db,
run_id,
quarters_ok=ok,
quarters_failed=failed,
buildings_ok=n_buildings,
requests_count=n_requests,
waf_429_count=n_waf,
)
except WafBlockedError as e:
_log(
db,
run_id,
level="error",
stage="quarter_fetch",
cad=cn,
message=f"WAF blocked: {e}",
)
_finish_run(
db,
run_id,
status="failed",
error=str(e),
quarters_ok=ok,
quarters_failed=failed,
buildings_ok=n_buildings,
requests_count=n_requests,
waf_429_count=n_waf + 1,
)
raise
except Exception as e:
failed += 1
db.rollback()
_log(
db,
run_id,
level="error",
stage="quarter_fetch",
cad=cn,
message=f"{type(e).__name__}: {e}",
)
time.sleep(rate_ms / 1000.0)
db.commit()
elapsed = time.time() - started
_finish_run(
db,
run_id,
status="done",
quarters_ok=ok,
quarters_failed=failed,
buildings_ok=n_buildings,
requests_count=n_requests,
waf_429_count=n_waf,
)
_log(
db,
run_id,
level="info",
stage="done",
message=(
f"elapsed={elapsed:.0f}s ok={ok} failed={failed} "
f"buildings={n_buildings} reqs={n_requests}"
),
)
return {
"run_id": run_id,
"pending": len(pending),
"ok": ok,
"failed": failed,
"buildings": n_buildings,
"requests": n_requests,
"elapsed_s": int(elapsed),
"started_at": datetime.utcfromtimestamp(started).isoformat() + "Z",
}
except WafBlockedError:
raise
except Exception as e:
if run_id:
try:
_finish_run(db, run_id, status="failed", error=f"{type(e).__name__}: {e}")
except Exception:
pass
raise
finally:
db.close()