gendesign/data/sql/61_import_nspd_batch.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

208 lines
7.3 KiB
Python

"""Импорт NSPD-сырого батча (cad_quarters + cad_buildings) из JSONL в Postgres.
Поддерживает 2 формата JSONL:
- v1 (старый): {cad, q_wkt, buildings: [{cn, p, n, a, ar, f, yb, yc, c, rd, w}]}
- v2 (новый): {cad, q_wkt, q_props, buildings: [{cn, w, props}]} — props это полный
options-объект из NSPD (сохраняется как raw_props в БД).
Подключение через SSH-тунель:
ssh -N -L 15432:localhost:5432 gendesign
psql порт: localhost:15432, db gendesign, user gendesign, pwd <см .env>
Usage:
cd backend && uv run python ../data/sql/61_import_nspd_batch.py <path/to/jsonl>
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
import psycopg
PG_DSN = os.environ.get(
"TUNNEL_DSN",
"postgresql://gendesign:2J2SBPMKuS998fiwhtQqDhMI@localhost:15432/gendesign",
)
def _to_int(x):
if x is None or x == "":
return None
try:
return int(x)
except (TypeError, ValueError):
return None
def _to_num(x):
if x is None or x == "":
return None
try:
return float(x)
except (TypeError, ValueError):
return None
def _to_date(x):
if not x:
return None
s = str(x)
return s[:10] if len(s) >= 10 else None
def insert_quarters(cur, quarters: list[dict]) -> int:
inserted = 0
for q in quarters:
if not q.get("q_wkt"):
continue
raw = q.get("q_props") # full options dict from NSPD (v2)
cur.execute(
"""
INSERT INTO cad_quarters_geom (cad_number, geom, raw_props, source)
VALUES (
%s,
ST_Multi(ST_Transform(
ST_SetSRID(ST_GeomFromText(%s), 3857),
4326
))::geometry(MultiPolygon, 4326),
CAST(%s 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()
""",
(
q["cad"],
q["q_wkt"],
json.dumps(raw, ensure_ascii=False) if raw else None,
),
)
inserted += 1
return inserted
def insert_buildings(cur, buildings: list[tuple[dict, str]]) -> int:
"""buildings: [(building_dict, parent_quarter_cad), ...]"""
inserted = 0
for b, qcad in buildings:
if not b.get("w"):
continue
# v2 has full props; v1 has cherry-picked top-level keys
props = b.get("props") or {}
# Prefer v2 (props), fallback to v1 (top-level)
cad_num = props.get("cad_num") or b.get("cn")
purpose = props.get("purpose") or b.get("p")
name = props.get("building_name") or b.get("n")
addr = props.get("readable_address") or b.get("a")
area = _to_num(props.get("area") if props else b.get("ar"))
floors = props.get("floors") or b.get("f")
year_built = _to_int(props.get("year_built") if props else b.get("yb"))
year_comm = _to_int(props.get("year_commisioning") if props else b.get("yc"))
cost = _to_num(props.get("cost_value") if props else b.get("c"))
reg_date = _to_date(props.get("registration_date") if props else b.get("rd"))
# New v2-only fields
status = props.get("status")
ownership = props.get("ownership_type")
cultural = props.get("cultural_heritage_object") or props.get("cultural_heritage_val")
if cultural is not None and not isinstance(cultural, str):
cultural = json.dumps(cultural, ensure_ascii=False)
underground = props.get("underground_floors")
build_rec_area = _to_num(props.get("build_record_area"))
build_rec_type = props.get("build_record_type_value")
common_status = props.get("common_data_status")
obj_type = props.get("type")
raw_props_json = json.dumps(props, ensure_ascii=False) if props else None
cur.execute(
"""
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 (
%s, %s,
ST_Transform(ST_SetSRID(ST_GeomFromText(%s), 3857), 4326),
%s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s,
CAST(%s 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, qcad, b["w"],
purpose, name, addr,
area, floors,
year_built, year_comm,
cost, reg_date,
status, ownership, cultural, underground,
build_rec_area, build_rec_type, common_status, obj_type,
raw_props_json,
),
)
inserted += 1
return inserted
def main() -> int:
if len(sys.argv) < 2:
print("usage: 61_import_nspd_batch.py <jsonl_path>")
return 1
fp = Path(sys.argv[1])
if not fp.exists():
print(f"file not found: {fp}")
return 1
quarters: list[dict] = []
buildings: list[tuple[dict, str]] = []
with fp.open(encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
q = json.loads(line)
quarters.append(q)
for b in q.get("buildings", []):
buildings.append((b, q["cad"]))
print(f"Parsed: {len(quarters)} quarters, {len(buildings)} buildings")
conn = psycopg.connect(PG_DSN, connect_timeout=10)
try:
cur = conn.cursor()
n_q = insert_quarters(cur, quarters)
n_b = insert_buildings(cur, buildings)
conn.commit()
print(f"Inserted: {n_q} quarters, {n_b} buildings")
cur.execute("SELECT COUNT(*) FROM cad_quarters_geom")
print(f"DB total cad_quarters_geom: {cur.fetchone()[0]}")
cur.execute("SELECT COUNT(*) FROM cad_buildings")
print(f"DB total cad_buildings: {cur.fetchone()[0]}")
finally:
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())