- 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).
208 lines
7.3 KiB
Python
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())
|