refactor(nspd): remove Playwright-based scraper, rosreestr2coord = default

УДАЛЕНО (~1700 строк):
* backend/app/services/scrapers/nspd_kn.py (Playwright + WAF-bypass)
* backend/app/workers/tasks/scrape_nspd.py
* frontend/src/app/admin/scrape/nspd/page.tsx
* 5 NSPD admin endpoints (POST /nspd, /nspd/release-lock,
  GET /nspd/runs, /logs, /coverage)
* NSPD beat schedule + worker_ready resume hook + nav tab + link
* _nspd_default_regions() helper

СОХРАНЕНО (история):
* nspd_scrape_runs / nspd_scrape_log таблицы (3 row + log) для аудита
* UNION ALL в v_scrape_runs_unified / v_scrape_log_unified —
  старые runs видны под фильтром scraper_type='nspd'
* settings scrape_nspd_* помечены DEPRECATED

CHANGED:
* nspd_geo_jobs.use_rosreestr2coord DEFAULT FALSE → TRUE
* UI checkbox "rosreestr2coord lib" по умолчанию = checked
* /admin/scrape/all + /admin/scrape/geo — единственный путь к NSPD-данным

NSPD-доступ теперь только через rosreestr2coord (community lib) —
upstream поддерживает обновления headers/WAF-tricks. Локальный
nspd_lite.py (urllib) остаётся как fallback (use_rosreestr2coord=FALSE).
This commit is contained in:
lekss361 2026-05-11 09:21:12 +03:00
parent 54dbc77348
commit 79e2e94e09
11 changed files with 165 additions and 1756 deletions

View file

@ -358,212 +358,10 @@ def list_logs(
]
# ── NSPD (cad_quarters / cad_buildings) ─────────────────────────────────────
class TriggerNspdRequest(BaseModel):
region_code: int = Field(default=66, ge=1, le=99)
limit: int | None = Field(default=None, ge=1, le=2000)
rate_ms: int = Field(default=600, ge=200, le=5000)
force: bool = False
@router.post("/nspd")
def trigger_nspd_sweep(
payload: TriggerNspdRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> dict[str, Any]:
_check_token(x_admin_token)
from app.workers.tasks.scrape_nspd import (
force_release_lock as nspd_force_release_lock,
)
from app.workers.tasks.scrape_nspd import (
scrape_nspd_region,
)
lock_released = False
if payload.force:
lock_released = nspd_force_release_lock(payload.region_code)
result = scrape_nspd_region.apply_async(
kwargs={
"region_code": payload.region_code,
"triggered_by": "manual",
"limit": payload.limit,
"rate_ms": payload.rate_ms,
},
)
return {
"task_id": result.id,
"region_code": payload.region_code,
"limit": payload.limit,
"rate_ms": payload.rate_ms,
"force": payload.force,
"lock_was_released": lock_released,
}
class NspdReleaseLockRequest(BaseModel):
region_code: int = Field(..., ge=1, le=99)
@router.post("/nspd/release-lock")
def nspd_release_lock(
payload: NspdReleaseLockRequest,
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
) -> dict[str, Any]:
_check_token(x_admin_token)
from app.workers.tasks.scrape_nspd import force_release_lock as nspd_force_release_lock
released = nspd_force_release_lock(payload.region_code)
return {"region_code": payload.region_code, "released": released}
@router.get("/nspd/runs")
def list_nspd_runs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
limit: int = 20,
) -> list[dict[str, Any]]:
_check_token(x_admin_token)
rows = (
db.execute(
text(
"""
SELECT run_id, started_at, finished_at, heartbeat_at,
region_code, pending_count,
quarters_ok, quarters_failed, buildings_ok,
requests_count, waf_429_count,
status, error, triggered_by
FROM nspd_scrape_runs
ORDER BY started_at DESC
LIMIT :lim
"""
),
{"lim": min(limit, 100)},
)
.mappings()
.all()
)
return [
{
"run_id": r["run_id"],
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
"finished_at": r["finished_at"].isoformat() if r["finished_at"] else None,
"heartbeat_at": r["heartbeat_at"].isoformat() if r["heartbeat_at"] else None,
"region_code": r["region_code"],
"pending_count": r["pending_count"],
"quarters_ok": r["quarters_ok"],
"quarters_failed": r["quarters_failed"],
"buildings_ok": r["buildings_ok"],
"requests_count": r["requests_count"],
"waf_429_count": r["waf_429_count"],
"status": r["status"],
"error": r["error"],
"triggered_by": r["triggered_by"],
}
for r in rows
]
@router.get("/nspd/logs")
def list_nspd_logs(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
run_id: int | None = None,
since_id: int | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
_check_token(x_admin_token)
where: list[str] = []
params: dict[str, Any] = {"lim": min(limit, 1000)}
if run_id is not None:
where.append("run_id = :rid")
params["rid"] = run_id
if since_id is not None:
where.append("log_id > :sid")
params["sid"] = since_id
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
rows = (
db.execute(
text(
f"""
SELECT log_id, run_id, ts, level, stage, cad_number, message
FROM nspd_scrape_log
{where_sql}
ORDER BY log_id DESC
LIMIT :lim
"""
),
params,
)
.mappings()
.all()
)
return [
{
"log_id": r["log_id"],
"run_id": r["run_id"],
"ts": r["ts"].isoformat() if r["ts"] else None,
"level": r["level"],
"stage": r["stage"],
"cad_number": r["cad_number"],
"message": r["message"],
}
for r in rows
]
@router.get("/nspd/coverage")
def nspd_coverage(
db: Annotated[Session, Depends(get_db)],
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
region_code: int = 66,
) -> dict[str, Any]:
"""Покрытие cad_quarters_geom относительно ДДУ-сделок rosreestr.
Полезно перед запуском sweep видишь сколько ещё осталось скрейпить."""
_check_token(x_admin_token)
# ВАЖНО: regex с двоеточиями (':00:', ':0000000') ломают SQLAlchemy text() —
# двоеточие парсится как named-parameter prefix. Поэтому паттерны передаём
# через bindparams. NOT LIKE безопасен и быстрее regex.
row = (
db.execute(
text(
"""
WITH pending AS (
SELECT DISTINCT quarter_cad_number AS cad
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 NOT LIKE :bad_prefix
AND quarter_cad_number NOT LIKE :bad_suffix
),
done AS (SELECT cad_number AS cad FROM cad_quarters_geom)
SELECT
(SELECT COUNT(*) FROM pending) AS total_with_ddu,
(SELECT COUNT(*) FROM pending p
WHERE EXISTS (SELECT 1 FROM done d WHERE d.cad = p.cad))
AS already_scraped,
(SELECT COUNT(*) FROM cad_buildings) AS buildings_total
"""
),
{"rc": region_code, "bad_prefix": "00:00:%", "bad_suffix": "%:0000000"},
)
.mappings()
.first()
)
total = int(row["total_with_ddu"] or 0)
done = int(row["already_scraped"] or 0)
return {
"region_code": region_code,
"total_quarters_with_ddu": total,
"already_scraped": done,
"pending": max(total - done, 0),
"buildings_total": int(row["buildings_total"] or 0),
"coverage_pct": round(done / max(total, 1) * 100, 1) if total else None,
}
# NSPD-скрапер УДАЛЁН 2026-05-11: вместо Playwright-based nspd_kn.py теперь
# используется bulk geo-fetcher через rosreestr2coord. См. /admin/scrape/geo
# и backend/app/workers/tasks/nspd_geo.py + nspd_lite.py (urllib).
# История прогонов осталась в nspd_scrape_runs/log + v_scrape_runs_unified.
# ── Objective ETL (Антоновский SQLite → PG) ─────────────────────────────────

View file

@ -34,14 +34,12 @@ class Settings(BaseSettings):
# Empty string = endpoint disabled.
scrape_admin_token: str = ""
# NSPD-scraper schedule (кадастровые кварталы / здания).
# По умолчанию: 20-е число февраля/мая/августа/ноября в 03:30 МСК
# (после квартальных публикаций rosreestr_deals).
scrape_nspd_cron: str = "30 3 20 2,5,8,11 *"
# Регионы для NSPD-sweep (comma-separated rosreestr region codes).
scrape_nspd_default_regions: str = "66"
# Пауза между NSPD-запросами в мс. <600мс — высокий риск WAF 403.
scrape_nspd_rate_ms: int = 600
# NSPD-scraper (Playwright) УДАЛЁН 2026-05-11. Сменён на bulk geo-fetcher
# через rosreestr2coord — запускается вручную через /admin/scrape/geo.
# Settings оставлены deprecated на случай отката (можно удалить позже).
scrape_nspd_cron: str = "30 3 20 2,5,8,11 *" # DEPRECATED
scrape_nspd_default_regions: str = "66" # DEPRECATED
scrape_nspd_rate_ms: int = 600 # DEPRECATED — see nspd_lite_rate_ms
# Objective.ru API (api.objctv.ru) — платная аналитика первички/вторички.
# Лежит наряду с DOM.РФ kn + rosreestr CSV как 3-й источник истины с

View file

@ -1,576 +0,0 @@
"""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
# Heartbeat каждый cad — даёт UI видимый прогресс и быстрый zombie-detect
# на прод-VM, где worker может тихо стопать без error в логе.
DEFAULT_HEARTBEAT_EVERY = 1 # quarters
DEFAULT_COMMIT_EVERY = 10
# Retries × timeout × WAF backoff формирует worst-case на один cad.
# Старые значения (5×30s + 60+90+120+150+180s = 600+150 = ~12.5 мин) дают
# инвиз-зависание worker'а если NSPD WAF режет nonbrowser fingerprint.
# Новые: до ~3 мин на cad, чтобы при 'тихом' WAF run падал быстрее.
DEFAULT_RETRIES = 3
DEFAULT_TIMEOUT_S = 12
# 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.
NOTE: regex с двоеточиями ('!~ ":0000000$"') ломают SQLAlchemy text()
двоеточие парсится как named-parameter prefix. Используем NOT LIKE через
bindparam (быстрее regex и безопасно).
"""
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 NOT LIKE :bad_prefix
AND quarter_cad_number NOT LIKE :bad_suffix
"""
),
{"rc": region_code, "bad_prefix": "00:00:%", "bad_suffix": "%:0000000"},
).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
quarter_cad_num = EXCLUDED.quarter_cad_num,
geom = EXCLUDED.geom,
purpose = EXCLUDED.purpose,
building_name = EXCLUDED.building_name,
readable_address = EXCLUDED.readable_address,
area = EXCLUDED.area,
floors = EXCLUDED.floors,
year_built = EXCLUDED.year_built,
year_commisioning = EXCLUDED.year_commisioning,
cost_value = EXCLUDED.cost_value,
registration_date = EXCLUDED.registration_date,
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 / underground_floors хранятся TEXT — NSPD отдаёт
# диапазоны '1-2', '2-3'. _to_int съел бы их в NULL и потерял
# данные. Аналитика делает CAST через regex '^[0-9]+$'.
"floors": (
str(opts["floors"]).strip() if opts.get("floors") not in (None, "") else None
),
"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": (
str(opts["underground_floors"]).strip()
if opts.get("underground_floors") not in (None, "")
else None
),
"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:
# Pre-fetch лог в DB — чтобы видеть «застрял на cad X» если
# urllib зависнет в read(). Heartbeat не пишется во время
# nspd_fetch, и без этого лога run выглядит как «discover, тишина».
logger.info("nspd[%d/%d] %s — fetching polygon", i, len(pending), cn)
if i == 1 or i % 10 == 0:
_log(
db,
run_id,
level="info",
stage="quarter_fetch",
cad=cn,
message=f"fetching polygon ({i}/{len(pending)})",
)
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()

View file

@ -30,17 +30,12 @@ def _default_regions() -> list[int]:
return [int(x.strip()) for x in settings.scrape_kn_default_regions.split(",") if x.strip()]
def _nspd_default_regions() -> list[int]:
return [int(x.strip()) for x in settings.scrape_nspd_default_regions.split(",") if x.strip()]
celery_app = Celery(
"gendesign",
broker=settings.redis_url,
backend=settings.redis_url,
include=[
"app.workers.tasks.scrape_kn",
"app.workers.tasks.scrape_nspd",
"app.workers.tasks.refresh_analytics",
"app.workers.tasks.scrape_objective",
"app.workers.tasks.objective_etl",
@ -61,23 +56,10 @@ celery_app.conf.beat_schedule = {
for rc in _default_regions()
}
# NSPD-скрейп — квартально, после публикации rosreestr_deals.
# Pending = (новые quarter_cad_number из rosreestr_deals) (cad_quarters_geom).
# При limit=None прогон захватит все накопленные cad-кварталы за квартал.
celery_app.conf.beat_schedule.update(
{
f"nspd-region-{rc}": {
"task": "tasks.scrape_nspd.scrape_nspd_region",
"schedule": _parse_cron(settings.scrape_nspd_cron),
"kwargs": {
"region_code": rc,
"triggered_by": "beat",
"rate_ms": settings.scrape_nspd_rate_ms,
},
}
for rc in _nspd_default_regions()
}
)
# NSPD beat schedule УДАЛЁН 2026-05-11.
# Старый Playwright-based scraper (tasks.scrape_nspd) сменён на bulk geo-fetcher
# через rosreestr2coord — запускается вручную через UI /admin/scrape/geo.
# Расписания по умолчанию нет (cron можно добавить через beat позже если надо).
# Refresh ekb_districts медианы — ежемесячно 5-го числа в 04:00 МСК
# (после публикации новых rosreestr-кварталов и NSPD beat-cycle).
@ -194,71 +176,9 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
except Exception as e:
logger.warning("worker_ready: failed to enqueue resume for run=%s: %s", rid, e)
# NSPD-runs: помечаем зомби (>15 мин без heartbeat — NSPD сам по себе
# медленнее kn, дельта больше). Resume = просто заново ставим
# scrape_nspd_region с тем же region_code и triggered_by='resume':
# сервис идемпотентен — pending = (rosreestr ДДУ cads) (cad_quarters_geom done),
# уже скрейпнутые квартала пропустятся автоматом.
db = SessionLocal()
nspd_resume_targets: list[int] = []
try:
zombie_rows = (
db.execute(
text(
"""
UPDATE nspd_scrape_runs
SET status = 'zombie',
finished_at = NOW(),
error = COALESCE(error,
'auto-zombie at worker_ready, resume scheduled')
WHERE status = 'running'
AND COALESCE(heartbeat_at, started_at)
< NOW() - INTERVAL '15 minutes'
RETURNING run_id, region_code
"""
)
)
.mappings()
.all()
)
db.commit()
# Уникальные регионы для resume (разные ран-id для одного региона —
# повторно не enqueueum, lock и idempotency пропустят дубль).
seen: set[int] = set()
for r in zombie_rows:
rc = int(r["region_code"])
if rc not in seen:
seen.add(rc)
nspd_resume_targets.append(rc)
logger.info(
"worker_ready: NSPD zombie run=%s region=%s — resume scheduled",
r["run_id"],
rc,
)
except Exception as e:
logger.warning("worker_ready nspd zombie scan failed: %s", e)
try:
db.rollback()
except Exception:
pass
finally:
db.close()
if nspd_resume_targets:
from app.workers.tasks.scrape_nspd import scrape_nspd_region
for rc in nspd_resume_targets:
try:
scrape_nspd_region.apply_async(
kwargs={"region_code": rc, "triggered_by": "resume"},
)
logger.info("worker_ready: scrape_nspd_region enqueued region=%s", rc)
except Exception as e:
logger.warning(
"worker_ready: failed to enqueue NSPD resume for region=%s: %s",
rc,
e,
)
# NSPD-runs (старый Playwright-scraper) resume УДАЛЁН 2026-05-11. Скрапер
# снят с эксплуатации в пользу bulk geo-fetcher (nspd_geo). История
# старых runs сохранена в nspd_scrape_runs — никаких side-effects.
# NSPD geo-jobs: bulk-fetcher с собственной resume-логикой через
# nspd_geo_jobs / nspd_geo_targets. Жадный resume: status='running' со

View file

@ -45,6 +45,29 @@ WAF_BACKOFF_BASE_S = 30
# ── Helpers для job/target lifecycle ────────────────────────────────────────
def _log(
db: Session, job_id: int, level: str, stage: str, message: str, cad_num: str | None = None
) -> None:
"""Запись в nspd_geo_log (виден через v_scrape_log_unified в admin UI)."""
try:
db.execute(
text(
"""
INSERT INTO nspd_geo_log (job_id, level, stage, cad_num, message)
VALUES (:jid, :lvl, :stg, :cad, :msg)
"""
),
{"jid": job_id, "lvl": level, "stg": stage, "cad": cad_num, "msg": message[:1000]},
)
db.commit()
except Exception as e:
logger.warning("nspd_geo_log write failed for job=%s: %s", job_id, e)
try:
db.rollback()
except Exception:
pass
def _start_job(db: Session, job_id: int) -> None:
db.execute(
text(
@ -60,6 +83,7 @@ def _start_job(db: Session, job_id: int) -> None:
{"id": job_id},
)
db.commit()
_log(db, job_id, "info", "start", "job started/resumed")
def _heartbeat(db: Session, job_id: int, **counts: int) -> None:
@ -366,6 +390,14 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
)
# НЕ помечаем failed — остаётся pending для следующей итерации
_heartbeat(db, job_id, requests_count=n_requests, waf_blocked_count=n_waf)
_log(
db,
job_id,
"warn",
"waf",
f"WAF/429 (consecutive={consecutive_waf}), backoff {wait}s",
cad_num=cad,
)
time.sleep(wait)
if consecutive_waf >= 8:
# 8 подряд WAF — пауза job'а до следующего worker_ready
@ -375,11 +407,19 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
"paused",
error=f"WAF blocked {consecutive_waf} consecutive",
)
_log(
db,
job_id,
"error",
"paused",
f"job paused after {consecutive_waf} consecutive WAF blocks",
)
return {"job_id": job_id, "paused": True, "reason": "waf"}
continue
except (NspdLiteError, Exception) as e:
logger.warning("fetch failed for %s: %s", cad, e)
_log(db, job_id, "warn", "fetch_fail", str(e)[:300], cad_num=cad)
db.execute(
text(
"""
@ -418,6 +458,13 @@ def process_nspd_geo_job(self: Any, job_id: int) -> dict[str, Any]:
)
# done даже при failed targets — UI покажет failed_count отдельно
_finish_job(db, job_id, "done")
_log(
db,
job_id,
"info",
"done",
f"done: {done} ok / {failed} failed / {n_requests} req / {n_waf} WAF blocks",
)
logger.info(
"process_nspd_geo_job done: job=%s done=%d failed=%d req=%d waf=%d",
job_id,

View file

@ -1,104 +0,0 @@
"""Celery task wrapper для NSPD-скрейпа кадастровых кварталов."""
from __future__ import annotations
import logging
from contextlib import contextmanager
from typing import Any
import redis
from app.core.config import settings
from app.services.scrapers.nspd_kn import WafBlockedError, run_region_scrape
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
# NSPD-скрейпы 700+ cad-кварталов на регион занимают ~2 часа (600мс/req × 2 req/cad).
# Lock TTL 3 часа — потолок с запасом, не оставляет зомби-lock на долго.
_LOCK_TTL_SECONDS = 3 * 60 * 60
def _lock_key(region_code: int) -> str:
return f"scrape:nspd:lock:{region_code}"
def force_release_lock(region_code: int) -> bool:
"""Принудительно удалить Redis-lock для emergency manual trigger."""
key = _lock_key(region_code)
r = redis.Redis.from_url(settings.redis_url)
try:
return bool(r.delete(key))
except Exception as e:
logger.warning("force_release_lock %s failed: %s", key, e)
return False
@contextmanager
def _region_lock(region_code: int):
key = _lock_key(region_code)
r = redis.Redis.from_url(settings.redis_url)
acquired = r.set(key, "1", nx=True, ex=_LOCK_TTL_SECONDS)
try:
yield bool(acquired)
finally:
if acquired:
try:
r.delete(key)
except Exception:
pass
@celery_app.task(
bind=True,
name="tasks.scrape_nspd.scrape_nspd_region",
max_retries=2,
autoretry_for=(WafBlockedError,),
retry_backoff=600, # 10 мин начальный, удваивается
retry_backoff_max=7200, # потолок 2 часа
)
def scrape_nspd_region(
self: Any,
region_code: int = 66,
triggered_by: str = "beat",
limit: int | None = None,
rate_ms: int = 600,
) -> dict[str, Any]:
"""Запуск NSPD-скрейпа одного региона. По расписанию: 20-е число
февраля/мая/августа/ноября (после квартальных публикаций rosreestr).
Args:
region_code: код региона rosreestr (66 = Свердл).
triggered_by: 'beat' | 'manual' | 'resume' для журналирования.
limit: ограничить N cad-кварталов (smoke-тест).
rate_ms: пауза между запросами (по умолчанию 600мс наблюдаемый предел WAF).
Returns:
dict с run_id и итогами (pending/ok/failed/buildings/requests/elapsed_s),
либо {'skipped': True, 'reason': 'lock_held'} если уже идёт прогон.
"""
with _region_lock(region_code) as got_lock:
if not got_lock:
logger.warning(
"scrape_nspd_region SKIPPED region=%s — другой sweep идёт (Redis lock)",
region_code,
)
return {
"skipped": True,
"reason": "lock_held",
"region_code": region_code,
"lock_key": _lock_key(region_code),
}
logger.info(
"scrape_nspd_region start region=%s trigger=%s limit=%s",
region_code,
triggered_by,
limit,
)
return run_region_scrape(
region_code=region_code,
triggered_by=triggered_by,
limit=limit,
rate_ms=rate_ms,
)

View file

@ -28,7 +28,9 @@ CREATE TABLE IF NOT EXISTS nspd_geo_jobs (
source_params jsonb, -- что-то вроде {"region_codes":[66,74,72,59], "thematic_id":2}
-- Параметры fetch
rate_ms INTEGER NOT NULL DEFAULT 600,
use_rosreestr2coord BOOLEAN NOT NULL DEFAULT FALSE, -- если TRUE — community-lib, иначе наш nspd_lite
-- TRUE = community-lib rosreestr2coord (default с 2026-05-11),
-- FALSE = наш nspd_lite urllib-based (fallback).
use_rosreestr2coord BOOLEAN NOT NULL DEFAULT TRUE,
-- Состояние
status TEXT NOT NULL DEFAULT 'queued', -- queued | running | done | failed | paused
started_at timestamptz,
@ -83,3 +85,67 @@ CREATE INDEX IF NOT EXISTS nspd_geo_targets_failed_idx
COMMENT ON TABLE nspd_geo_targets IS
'Список cad-номеров для обработки в рамках job. Resume-state: '
'task просто SELECT WHERE status=pending и продолжает.';
-- ── nspd_geo_log: логи (видны через v_scrape_log_unified) ───────────────────
CREATE TABLE IF NOT EXISTS nspd_geo_log (
log_id BIGSERIAL PRIMARY KEY,
job_id BIGINT REFERENCES nspd_geo_jobs(job_id) ON DELETE CASCADE,
ts timestamptz NOT NULL DEFAULT NOW(),
level TEXT NOT NULL DEFAULT 'info',
stage TEXT,
cad_num TEXT,
message TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS nspd_geo_log_job_idx
ON nspd_geo_log(job_id, log_id DESC);
CREATE INDEX IF NOT EXISTS nspd_geo_log_ts_idx
ON nspd_geo_log(ts DESC);
-- ── Расширение unified-views на nspd_geo (UNION ALL) ────────────────────────
CREATE OR REPLACE VIEW v_scrape_runs_unified AS
SELECT 'kn' AS scraper_type, run_id, started_at, finished_at, heartbeat_at,
status, error, NULL::text AS triggered_by,
region_codes::text AS scope, requests_count,
objects_count AS items_ok, NULL::integer AS items_failed,
flats_count AS sub_items_ok,
progress_obj_index AS progress_idx, total_obj_count AS progress_total,
jsonb_build_object('developers', developer_ids, 'snapshot_date', snapshot_date,
'resumed_from', resumed_from_run_id, 'params', params) AS extra
FROM kn_scrape_runs
UNION ALL
SELECT 'nspd', run_id, started_at, finished_at, heartbeat_at, status, error,
triggered_by, region_code::text, requests_count,
quarters_ok, quarters_failed, buildings_ok,
NULL, pending_count,
jsonb_build_object('waf_429_count', waf_429_count)
FROM nspd_scrape_runs
UNION ALL
SELECT 'objective', run_id, started_at, finished_at, heartbeat_at, status, error,
triggered_by, group_name, requests_count,
reports_ok, reports_failed, rows_lots,
NULL, NULL,
jsonb_build_object('rows_corpus_room', rows_corpus_room, 'rows_history', rows_history)
FROM objective_scrape_runs
UNION ALL
SELECT 'nspd_geo', job_id, started_at, finished_at, heartbeat_at, status, error,
triggered_by, COALESCE(name, job_kind),
requests_count, targets_done, targets_failed, targets_skipped,
targets_done, targets_total,
jsonb_build_object('job_kind', job_kind, 'source_kind', source_kind,
'rate_ms', rate_ms, 'use_rosreestr2coord', use_rosreestr2coord,
'waf_blocked_count', waf_blocked_count,
'source_params', source_params)
FROM nspd_geo_jobs;
CREATE OR REPLACE VIEW v_scrape_log_unified AS
SELECT 'kn' AS scraper_type, log_id, run_id, ts, level, stage,
obj_id::text AS entity_id, message
FROM kn_scrape_log
UNION ALL
SELECT 'nspd', log_id, run_id, ts, level, stage, cad_number, message
FROM nspd_scrape_log
UNION ALL
SELECT 'nspd_geo', log_id, job_id AS run_id, ts, level, stage, cad_num, message
FROM nspd_geo_log;

View file

@ -6,8 +6,7 @@ import { usePathname } from "next/navigation";
const TABS = [
{ href: "/admin/scrape/all", label: "📊 Все скраперы (сводно)" },
{ href: "/admin/scrape", label: "Скрапер DOM.РФ" },
{ href: "/admin/scrape/nspd", label: "Скрапер NSPD" },
{ href: "/admin/scrape/geo", label: "🗺 Geo bulk-jobs" },
{ href: "/admin/scrape/geo", label: "🗺 NSPD geo (rosreestr2coord)" },
{ href: "/admin/scrape/objective", label: "ETL Objective" },
{ href: "/admin/leads", label: "Лиды PRINZIP" },
];

View file

@ -6,7 +6,7 @@ import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
interface UnifiedRunRow {
scraper_type: "kn" | "nspd" | "objective";
scraper_type: "kn" | "nspd" | "objective" | "nspd_geo";
run_id: number;
started_at: string | null;
finished_at: string | null;
@ -25,7 +25,7 @@ interface UnifiedRunRow {
}
interface UnifiedLogRow {
scraper_type: "kn" | "nspd";
scraper_type: "kn" | "nspd" | "nspd_geo";
log_id: number;
run_id: number | null;
ts: string | null;
@ -40,6 +40,7 @@ const SCRAPER_BADGE: Record<string, { bg: string; fg: string; label: string }> =
kn: { bg: "#dbeafe", fg: "#1d4ed8", label: "DOM.РФ kn" },
nspd: { bg: "#fef3c7", fg: "#a16207", label: "NSPD" },
objective: { bg: "#d1fae5", fg: "#059669", label: "Objective" },
nspd_geo: { bg: "#ede9fe", fg: "#6d28d9", label: "🗺 NSPD geo" },
};
const STATUS_COLOR: Record<string, string> = {
@ -68,9 +69,9 @@ export default function ScrapeAllAdminPage() {
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [filter, setFilter] = useState<"all" | "kn" | "nspd" | "objective">(
"all",
);
const [filter, setFilter] = useState<
"all" | "kn" | "nspd" | "objective" | "nspd_geo"
>("all");
const [logsRunId, setLogsRunId] = useState<number | "">("");
const saveToken = (v: string) => {
@ -124,20 +125,22 @@ export default function ScrapeAllAdminPage() {
Скраперы сводный dashboard
</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Все 3 скрапера в одной таблице (через <code>v_scrape_runs_unified</code>
). Для запуска / триггеров отдельные страницы:{" "}
Все скраперы в одной таблице (через <code>v_scrape_runs_unified</code>).
Для запуска / триггеров отдельные страницы:{" "}
<a href="/admin/scrape" style={{ color: "#1d4ed8" }}>
kn
DOM.РФ kn
</a>{" "}
·{" "}
<a href="/admin/scrape/nspd" style={{ color: "#1d4ed8" }}>
NSPD
<a href="/admin/scrape/geo" style={{ color: "#1d4ed8" }}>
🗺 NSPD geo
</a>{" "}
·{" "}
<a href="/admin/scrape/objective" style={{ color: "#1d4ed8" }}>
Objective
</a>
.
. Старый Playwright-based NSPD-скрапер снят с эксплуатации 2026-05-11
история его прогонов осталась под фильтром <code>nspd</code> только для
аудита.
</p>
<section style={cardStyle}>
@ -177,20 +180,22 @@ export default function ScrapeAllAdminPage() {
<section style={{ ...cardStyle, marginTop: 16 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<span style={{ ...labelStyle, marginBottom: 0 }}>Filter:</span>
{(["all", "kn", "nspd", "objective"] as const).map((t) => (
<button
key={t}
type="button"
onClick={() => setFilter(t)}
style={{
...filterTab,
background: filter === t ? "#1d4ed8" : "#f3f4f6",
color: filter === t ? "#fff" : "#1f2937",
}}
>
{t === "all" ? "Все" : (SCRAPER_BADGE[t]?.label ?? t)}
</button>
))}
{(["all", "kn", "nspd", "nspd_geo", "objective"] as const).map(
(t) => (
<button
key={t}
type="button"
onClick={() => setFilter(t)}
style={{
...filterTab,
background: filter === t ? "#1d4ed8" : "#f3f4f6",
color: filter === t ? "#fff" : "#1f2937",
}}
>
{t === "all" ? "Все" : (SCRAPER_BADGE[t]?.label ?? t)}
</button>
),
)}
</div>
</section>
@ -308,7 +313,8 @@ export default function ScrapeAllAdminPage() {
</td>
<td style={td}>
{(r.scraper_type === "kn" ||
r.scraper_type === "nspd") && (
r.scraper_type === "nspd" ||
r.scraper_type === "nspd_geo") && (
<button
type="button"
onClick={() => setLogsRunId(r.run_id)}

View file

@ -73,7 +73,7 @@ export default function GeoScrapeAdminPage() {
const [cadNumsText, setCadNumsText] = useState("");
const [regionCodes, setRegionCodes] = useState("66,74,72,59");
const [rateMs, setRateMs] = useState(600);
const [useLib, setUseLib] = useState(false);
const [useLib, setUseLib] = useState(true);
const [lastResp, setLastResp] = useState<EnqueueResp | null>(null);
const queryClient = useQueryClient();

View file

@ -1,745 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
interface NspdRunRow {
run_id: number;
started_at: string | null;
finished_at: string | null;
heartbeat_at: string | null;
region_code: number;
pending_count: number | null;
quarters_ok: number;
quarters_failed: number;
buildings_ok: number;
requests_count: number;
waf_429_count: number;
status: string;
error: string | null;
triggered_by: string;
}
interface NspdLogRow {
log_id: number;
run_id: number | null;
ts: string | null;
level: "debug" | "info" | "warn" | "error";
stage: string | null;
cad_number: string | null;
message: string;
}
interface NspdCoverage {
region_code: number;
total_quarters_with_ddu: number;
already_scraped: number;
pending: number;
buildings_total: number;
coverage_pct: number | null;
}
interface NspdTriggerResp {
task_id: string;
region_code: number;
limit: number | null;
rate_ms: number;
force: boolean;
lock_was_released: boolean;
}
export default function NspdScrapeAdminPage() {
const [token, setToken] = useState<string>(() =>
typeof window === "undefined"
? ""
: (localStorage.getItem("admin_token") ?? ""),
);
const [region, setRegion] = useState(66);
const [limit, setLimit] = useState<string>("");
const [rateMs, setRateMs] = useState(600);
const [forceRun, setForceRun] = useState(false);
const [logsRunId, setLogsRunId] = useState<number | "">("");
const queryClient = useQueryClient();
const saveToken = (v: string) => {
setToken(v);
localStorage.setItem("admin_token", v);
};
const coverage = useQuery({
queryKey: ["nspd-coverage", region, token],
queryFn: () =>
apiFetch<NspdCoverage>(
`/api/v1/admin/scrape/nspd/coverage?region_code=${region}`,
{
headers: { "X-Admin-Token": token },
},
),
enabled: !!token,
refetchInterval: 10000,
});
const runs = useQuery({
queryKey: ["nspd-runs", token],
queryFn: () =>
apiFetch<NspdRunRow[]>("/api/v1/admin/scrape/nspd/runs?limit=20", {
headers: { "X-Admin-Token": token },
}),
enabled: !!token,
refetchInterval: 5000,
});
const logs = useQuery({
queryKey: ["nspd-logs", logsRunId, token],
queryFn: () => {
const q =
logsRunId !== "" ? `?run_id=${logsRunId}&limit=200` : "?limit=200";
return apiFetch<NspdLogRow[]>(`/api/v1/admin/scrape/nspd/logs${q}`, {
headers: { "X-Admin-Token": token },
});
},
enabled: !!token,
refetchInterval: 3000,
});
const trigger = useMutation({
mutationFn: () =>
apiFetch<NspdTriggerResp>("/api/v1/admin/scrape/nspd", {
method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({
region_code: region,
limit: limit === "" ? null : Number(limit),
rate_ms: rateMs,
force: forceRun,
}),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["nspd-runs"] });
queryClient.invalidateQueries({ queryKey: ["nspd-coverage"] });
},
});
const releaseLock = useMutation({
mutationFn: () =>
apiFetch<{ region_code: number; released: boolean }>(
"/api/v1/admin/scrape/nspd/release-lock",
{
method: "POST",
headers: { "X-Admin-Token": token },
body: JSON.stringify({ region_code: region }),
},
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["nspd-runs"] });
},
});
return (
<>
<h2 style={{ marginTop: 0, fontSize: 18 }}>
Скрапер NSPD кадастровые кварталы и здания
</h2>
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
Берёт quarter_cad_number из rosreestr_deals (ДДУ-сделки на квартиры)
тянет полигон cad-квартала + здания внутри из NSPD geoportal. Расписание
(beat): 20-е число фев/мая/авг/нояб (после квартальных публикаций
rosreestr). См. <code>SCRAPE_NSPD_CRON</code>.
</p>
<section style={cardStyle}>
<label style={{ display: "block", marginBottom: 12 }}>
<span style={labelStyle}>Admin Token</span>
<input
type="password"
value={token}
onChange={(e) => saveToken(e.target.value)}
placeholder="SCRAPE_ADMIN_TOKEN"
style={inputStyle}
/>
</label>
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
<label>
<span style={labelStyle}>Регион</span>
<input
type="number"
value={region}
min={1}
max={99}
onChange={(e) => setRegion(Number(e.target.value))}
style={{ ...inputStyle, width: 100 }}
/>
</label>
<label>
<span style={labelStyle}>Limit (smoke)</span>
<input
type="number"
value={limit}
onChange={(e) => setLimit(e.target.value)}
placeholder="напр. 10"
min={1}
max={2000}
style={{ ...inputStyle, width: 120 }}
/>
</label>
<label>
<span style={labelStyle}>Rate (мс)</span>
<input
type="number"
value={rateMs}
onChange={(e) => setRateMs(Number(e.target.value))}
min={200}
max={5000}
step={100}
style={{ ...inputStyle, width: 110 }}
title="Пауза между NSPD-запросами. <600мс = WAF 403 риск"
/>
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 22,
}}
title="Перед запуском удалить existing Redis-lock"
>
<input
type="checkbox"
checked={forceRun}
onChange={(e) => setForceRun(e.target.checked)}
/>
<span style={{ color: forceRun ? "#b3261e" : "inherit" }}>
Force (снять lock)
</span>
</label>
</div>
{/* Pre-launch summary: сколько кварталов реально пойдёт в обработку
и сколько примерно займёт. Считаем тут, а не на бэке дешевле. */}
{token && coverage.data
? (() => {
const limitNum = limit === "" ? null : Number(limit);
const willProcess = limitNum
? Math.min(limitNum, coverage.data.pending)
: coverage.data.pending;
// 2 запроса на квартал (полигон + здания) × rate_ms, +1с на запись
const etaSec = willProcess * ((rateMs * 2) / 1000 + 1);
const etaMin = Math.round(etaSec / 60);
const etaH = Math.floor(etaMin / 60);
const etaText =
etaMin >= 60 ? `~${etaH}ч ${etaMin % 60}мин` : `~${etaMin} мин`;
return (
<div
style={{
marginTop: 14,
padding: 10,
background: willProcess === 0 ? "#f0fdf4" : "#eff6ff",
border: `1px solid ${
willProcess === 0 ? "#bbf7d0" : "#bfdbfe"
}`,
borderRadius: 6,
fontSize: 13,
lineHeight: 1.5,
}}
>
{willProcess === 0 ? (
<>
Всё уже скрейпнуто для региона {region}. Запускать
бесполезно pending=0.
</>
) : (
<>
При запуске будет обработано{" "}
<strong>
{willProcess.toLocaleString("ru")} кварталов
</strong>{" "}
{limitNum && limitNum < coverage.data.pending ? (
<span style={{ color: "#5b6066" }}>
(limit {limitNum} из {coverage.data.pending} pending)
</span>
) : (
<span style={{ color: "#5b6066" }}>
(вся очередь pending)
</span>
)}
. Расчётное время: <strong>{etaText}</strong> при rate{" "}
{rateMs}мс ({((rateMs * 2) / 1000).toFixed(1)}с / квартал
× 2 запроса).
</>
)}
</div>
);
})()
: null}
<div
style={{ marginTop: 16, display: "flex", gap: 12, flexWrap: "wrap" }}
>
<button
onClick={() => trigger.mutate()}
disabled={
!token || trigger.isPending || (coverage.data?.pending ?? 0) === 0
}
style={primaryBtn}
>
{trigger.isPending ? "Запускаю…" : "Запустить sweep"}
</button>
<button
onClick={() => {
if (
confirm(
"Снять Redis-lock для NSPD? Используй когда подозреваешь зомби-worker.",
)
) {
releaseLock.mutate();
}
}}
disabled={!token || releaseLock.isPending}
style={secondaryBtn}
>
{releaseLock.isPending ? "Снимаю…" : "🔓 Снять Redis-lock"}
</button>
{!token ? (
<span
style={{
marginLeft: 12,
color: "#9a6700",
fontSize: 13,
alignSelf: "center",
}}
>
введите Admin Token выше
</span>
) : null}
</div>
{trigger.isError ? (
<div
style={{
marginTop: 12,
padding: 12,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
fontSize: 14,
wordBreak: "break-word",
}}
>
<strong>Ошибка:</strong> {String(trigger.error)}
</div>
) : null}
{trigger.isSuccess ? (
<div
style={{
marginTop: 12,
padding: 12,
background: "#f0fdf4",
border: "1px solid #bbf7d0",
borderRadius: 6,
color: "#065f46",
fontSize: 14,
}}
>
<strong> Поставлено в очередь.</strong> task_id={" "}
<code
style={{
background: "#dcfce7",
padding: "2px 6px",
borderRadius: 3,
}}
>
{trigger.data.task_id}
</code>{" "}
· регион {trigger.data.region_code}
{trigger.data.limit ? ` · limit=${trigger.data.limit}` : ""} · rate=
{trigger.data.rate_ms}мс
</div>
) : null}
</section>
<section style={{ ...cardStyle, marginTop: 16 }}>
<h2 style={{ margin: 0, fontSize: 16, marginBottom: 12 }}>
Покрытие cad_quarters_geom (регион {region})
</h2>
{!token ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token выше.
</p>
) : coverage.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p>
) : coverage.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(coverage.error)}</p>
) : coverage.data ? (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 12,
}}
>
<CoverageCard
label="ДДУ-кварталов в rosreestr"
value={coverage.data.total_quarters_with_ddu.toLocaleString("ru")}
/>
<CoverageCard
label="Уже скрейпнуто"
value={coverage.data.already_scraped.toLocaleString("ru")}
color="#0a7a3a"
/>
<CoverageCard
label="Осталось"
value={coverage.data.pending.toLocaleString("ru")}
color={coverage.data.pending > 0 ? "#9a6700" : "#0a7a3a"}
/>
<CoverageCard
label="Зданий в БД"
value={coverage.data.buildings_total.toLocaleString("ru")}
/>
<CoverageCard
label="Покрытие, %"
value={
coverage.data.coverage_pct != null
? `${coverage.data.coverage_pct}%`
: "—"
}
color={
coverage.data.coverage_pct != null &&
coverage.data.coverage_pct >= 90
? "#0a7a3a"
: "#9a6700"
}
/>
</div>
) : null}
</section>
<section style={{ ...cardStyle, marginTop: 16 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 12,
}}
>
<h2 style={{ margin: 0, fontSize: 16 }}>
Лог скрейпа{" "}
<span style={{ fontWeight: 400, color: "#5b6066", fontSize: 13 }}>
· обновляется каждые 3 сек
</span>
</h2>
<input
type="number"
value={logsRunId}
onChange={(e) =>
setLogsRunId(e.target.value === "" ? "" : Number(e.target.value))
}
placeholder="run_id (опц.)"
style={{ ...inputStyle, width: 130 }}
/>
</div>
{!token ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Введите Admin Token чтобы видеть лог.
</p>
) : logs.isLoading ? (
<p style={{ color: "#5b6066" }}>Загрузка</p>
) : logs.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(logs.error)}</p>
) : (logs.data?.length ?? 0) === 0 ? (
<p style={{ color: "#5b6066", fontSize: 13 }}>
Нет событий{logsRunId !== "" ? ` для run_id=${logsRunId}` : ""}.
</p>
) : (
<div
style={{
maxHeight: 360,
overflowY: "auto",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
fontSize: 12,
border: "1px solid #eef0f3",
borderRadius: 6,
background: "#0f172a",
color: "#e2e8f0",
padding: 10,
}}
>
{(logs.data ?? []).map((l) => {
const color =
l.level === "error"
? "#fca5a5"
: l.level === "warn"
? "#fcd34d"
: "#a3e635";
return (
<div key={l.log_id} style={{ marginBottom: 2 }}>
<span style={{ color: "#94a3b8" }}>
{l.ts?.slice(11, 19)}
</span>{" "}
<span style={{ color }}>{l.level.toUpperCase()}</span>{" "}
<span style={{ color: "#7dd3fc" }}>
[run-{l.run_id}
{l.stage ? ` · ${l.stage}` : ""}
{l.cad_number ? ` · ${l.cad_number}` : ""}]
</span>{" "}
<span>{l.message}</span>
</div>
);
})}
</div>
)}
</section>
<section style={{ ...cardStyle, marginTop: 16 }}>
<h2 style={{ marginTop: 0, fontSize: 16 }}>Последние прогоны</h2>
{!token ? (
<p style={{ color: "#5b6066" }}>
Введите Admin Token чтобы увидеть журнал.
</p>
) : runs.isLoading ? (
<p>Загрузка</p>
) : runs.isError ? (
<p style={{ color: "#b3261e" }}>Ошибка: {String(runs.error)}</p>
) : (
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"#",
"Старт",
"HB",
"Статус",
"Прогресс",
"Pending",
"OK",
"Fail",
"Зданий",
"Req",
"WAF",
"Trigger",
"Ошибка",
].map((h) => (
<th key={h} style={th}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{(runs.data ?? []).map((r) => (
<tr
key={r.run_id}
style={{ borderBottom: "1px solid #eef0f3" }}
>
<td style={td}>{r.run_id}</td>
<td style={td}>
{r.started_at?.slice(0, 19).replace("T", " ")}
</td>
<td style={td}>
{r.heartbeat_at ? r.heartbeat_at.slice(11, 19) : "—"}
</td>
<td
style={{
...td,
color:
r.status === "done"
? "#0a7a3a"
: r.status === "failed" || r.status === "zombie"
? "#b3261e"
: "#9a6700",
fontWeight: 600,
}}
>
{r.status}
</td>
<td style={{ ...td, minWidth: 110 }}>
{r.pending_count && r.pending_count > 0
? (() => {
const pct = Math.min(
100,
Math.round(
((r.quarters_ok + r.quarters_failed) /
r.pending_count) *
100,
),
);
return (
<div
style={{
fontFamily: "ui-monospace, monospace",
fontSize: 12,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
<div
style={{
width: 70,
height: 6,
background: "#eef0f3",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
background:
pct === 100
? "#0a7a3a"
: pct > 0
? "#1d4ed8"
: "#9a6700",
}}
/>
</div>
<span>{pct}%</span>
</div>
);
})()
: "—"}
</td>
<td style={td}>{r.pending_count ?? "—"}</td>
<td style={td}>{r.quarters_ok}</td>
<td
style={{
...td,
color: r.quarters_failed > 0 ? "#b3261e" : undefined,
}}
>
{r.quarters_failed}
</td>
<td style={td}>{r.buildings_ok}</td>
<td style={td}>{r.requests_count}</td>
<td
style={{
...td,
color: r.waf_429_count > 0 ? "#b3261e" : undefined,
}}
>
{r.waf_429_count}
</td>
<td style={td}>{r.triggered_by}</td>
<td
style={{
...td,
color: "#b3261e",
maxWidth: 240,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={r.error ?? ""}
>
{r.error ?? ""}
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
);
}
function CoverageCard({
label,
value,
color,
}: {
label: string;
value: string;
color?: string;
}) {
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 8,
padding: "10px 12px",
}}
>
<div
style={{
fontSize: 11,
color: "#5b6066",
textTransform: "uppercase",
letterSpacing: 0.4,
marginBottom: 4,
}}
>
{label}
</div>
<div
style={{
fontSize: 22,
fontWeight: 700,
color: color ?? "#0f172a",
lineHeight: 1.1,
}}
>
{value}
</div>
</div>
);
}
const cardStyle = {
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
};
const labelStyle = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase" as const,
letterSpacing: 0.4,
};
const inputStyle = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box" as const,
};
const primaryBtn = {
padding: "10px 18px",
background: "#1d4ed8",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
};
const secondaryBtn = {
padding: "8px 14px",
background: "#f3f4f6",
color: "#1f2937",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 13,
cursor: "pointer",
};
const th = {
padding: "8px 10px",
textAlign: "left" as const,
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
};
const td = { padding: "8px 10px" };