gendesign/data/sql/62_scrape_nspd_full.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

250 lines
9.2 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 scrape для всех cad-кварталов ЕКБ с ДДУ-сделками.
Запускать с машины пользователя (РФ-IP, иначе WAF).
SSH-tunnel к prod БД на localhost:15432.
Что делает:
1. Берёт список cad-номеров из rosreestr_deals (region=66, ЕКБ, ДДУ, квартиры).
2. Пропускает уже скрейпнутые (есть в cad_quarters_geom).
3. Для каждого cad-квартала:
a. fetch /api/geoportal/v2/search/geoportal?thematicSearchId=2 → polygon
b. fetch /api/geoportal/v2/search/geoportal?thematicSearchId=1 → 20 зданий + земля
4. INSERT в cad_quarters_geom + cad_buildings (фильтр cad_num LIKE '66:41:%').
5. После каждого квартала commit + лог. Можно прервать и перезапустить.
Usage:
cd backend && uv run python ../data/sql/62_scrape_nspd_full.py [--limit N] [--rate-ms 600]
"""
from __future__ import annotations
import argparse
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
import psycopg
PG_DSN = os.environ.get(
"TUNNEL_DSN",
"postgresql://gendesign:2J2SBPMKuS998fiwhtQqDhMI@localhost:15432/gendesign",
)
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()
def nspd_fetch(thematic_search_id: int, query: str, *, retries: int = 5, timeout: int = 30) -> dict | None:
"""Fetch NSPD with backoff. WAF banит burst-запросы — на 403 ждём долго."""
qs = urllib.parse.urlencode({"thematicSearchId": thematic_search_id, "query": query})
url = f"{NSPD_BASE}?{qs}"
req = urllib.request.Request(url, headers=HEADERS)
last_err = 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:
if e.code == 404:
return None
last_err = e
if e.code == 403:
# WAF rate-limit — ждём дольше (60 сек)
wait = 60 + 30 * attempt
print(f" WAF 403 for {query} (attempt {attempt+1}/{retries}) — sleeping {wait}s", flush=True)
time.sleep(wait)
continue
print(f" HTTP {e.code} for {query} (attempt {attempt+1})", flush=True)
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = e
print(f" Network err for {query}: {e} (attempt {attempt+1})", flush=True)
time.sleep(min(2 ** attempt, 30))
print(f" FAILED {query}: {last_err}", flush=True)
return None
def poly_to_wkt(geom: dict | None) -> str | None:
if not geom:
return None
def ring(r):
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
def get_pending_cads(cur) -> list[str]:
"""Все cad-номера с ДДУ-сделками в ЕКБ — минус уже scraped."""
cur.execute("""
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 <> ''
AND quarter_cad_number <> '66:41:0000000'
""")
all_cads = {r[0] for r in cur.fetchall()}
cur.execute("SELECT cad_number FROM cad_quarters_geom")
done = {r[0] for r in cur.fetchall()}
pending = sorted(all_cads - done)
return pending
def insert_quarter(cur, cad: str, wkt: str) -> None:
cur.execute(
"""
INSERT INTO cad_quarters_geom (cad_number, geom, source)
VALUES (
%s,
ST_Multi(ST_Transform(ST_SetSRID(ST_GeomFromText(%s), 3857), 4326))::geometry(MultiPolygon, 4326),
'nspd'
)
ON CONFLICT (cad_number) DO UPDATE SET geom = EXCLUDED.geom, fetched_at = NOW()
""",
(cad, wkt),
)
def insert_buildings(cur, qcad: str, features: list[dict]) -> int:
"""Insert 'Здания' filtered by cad LIKE '66:41:%'."""
n = 0
for f in features:
if f.get("properties", {}).get("categoryName") != "Здания":
continue
opts = f["properties"].get("options", {}) or {}
cn = opts.get("cad_num")
if not cn or not cn.startswith("66:41:"):
continue
wkt = poly_to_wkt(f.get("geometry"))
if not wkt:
continue
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
)
VALUES (
%s, %s, ST_Transform(ST_SetSRID(ST_GeomFromText(%s), 3857), 4326),
%s, %s, %s, %s, %s, %s, %s, %s, %s
)
ON CONFLICT (cad_num) DO UPDATE SET
cost_value = EXCLUDED.cost_value,
area = EXCLUDED.area,
fetched_at = NOW()
""",
(
cn, qcad, wkt,
opts.get("purpose"), opts.get("building_name"),
opts.get("readable_address"), opts.get("area"), opts.get("floors"),
int(opts["year_built"]) if opts.get("year_built") else None,
int(opts["year_commisioning"]) if opts.get("year_commisioning") else None,
opts.get("cost_value"),
opts.get("registration_date", "").split(" ")[0] if opts.get("registration_date") else None,
),
)
n += 1
return n
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--limit", type=int, default=None, help="Limit N quarters (smoke)")
p.add_argument("--rate-ms", type=int, default=600, help="Pause between cads (ms)")
p.add_argument("--commit-every", type=int, default=10, help="Commit every N quarters")
args = p.parse_args()
conn = psycopg.connect(PG_DSN, connect_timeout=10)
cur = conn.cursor()
pending = get_pending_cads(cur)
if args.limit:
pending = pending[: args.limit]
print(f"Pending: {len(pending)} cad-quarters")
if not pending:
print("Nothing to do.")
return 0
started = time.time()
ok = 0
n_buildings = 0
failed = 0
for i, cn in enumerate(pending, 1):
try:
j2 = nspd_fetch(2, cn)
qf = None
if j2:
features = j2.get("data", {}).get("features") or []
for f in features:
if f.get("properties", {}).get("label") == cn:
qf = f
break
wkt = poly_to_wkt(qf.get("geometry") if qf else None) if qf else None
if wkt:
insert_quarter(cur, cn, wkt)
j1 = nspd_fetch(1, cn)
if j1:
features = j1.get("data", {}).get("features") or []
n_buildings += insert_buildings(cur, cn, features)
if wkt:
ok += 1
else:
failed += 1
if i % args.commit_every == 0:
conn.commit()
elapsed = time.time() - started
rate = ok / elapsed * 60 if elapsed > 0 else 0
eta = (len(pending) - i) / max(rate, 1) if rate > 0 else 0
print(
f"[{i}/{len(pending)}] {cn} "
f"ok={ok} fail={failed} buildings={n_buildings} "
f"rate={rate:.1f}/min eta={eta:.0f}min",
flush=True,
)
except Exception as e: # noqa: BLE001
failed += 1
conn.rollback()
print(f" ERR {cn}: {e}", flush=True)
time.sleep(args.rate_ms / 1000.0)
conn.commit()
elapsed = time.time() - started
cur.execute("SELECT COUNT(*) FROM cad_quarters_geom")
db_q = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM cad_buildings")
db_b = cur.fetchone()[0]
print(f"\nDONE in {elapsed:.0f}s. ok={ok} failed={failed} buildings_inserted={n_buildings}")
print(f"DB totals: cad_quarters_geom={db_q}, cad_buildings={db_b}")
conn.close()
return 0
if __name__ == "__main__":
sys.exit(main())