- 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).
250 lines
9.2 KiB
Python
250 lines
9.2 KiB
Python
"""Полный 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())
|