feat(sf): scraper реестра КРТ-площадок ЕКБ → ekb_krt_sites (#1060) #1126

Merged
bot-reviewer merged 2 commits from feat/ekb-krt-registry into main 2026-06-07 11:24:50 +00:00
8 changed files with 846 additions and 0 deletions

View file

@ -0,0 +1,350 @@
"""Scraper реестра КРТ-площадок Екатеринбурга (#1060, GG-форсайт).
Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/крт
(екатеринбург.рф, SSR-страница без auth и JS, обычный TLS GET).
На странице ~15 площадок по годам (20222025). Каждая площадка = текстовый
блок прозой с нумерованными пунктами. Граница блока тег
<p>...<strong>N. КРТ (жилой|нежилой) застройки...</strong>...</p>.
Публичный API:
fetch_krt_page() -> str (HTTP GET, сеть)
parse_krt_page(html) -> list[KRTSite] (чистая функция, без сети)
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from datetime import date
from html import unescape
from typing import Any
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
KRT_URL = (
"https://xn--80acgfbsl1azdqr.xn--p1ai"
"/%D0%B4%D0%BB%D1%8F%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D1%8B"
"/%D0%B3%D0%B8%D0%B7/%D0%BA%D1%80%D1%82"
)
_HEADERS = {
"User-Agent": ("Mozilla/5.0 (compatible; GenDesign-SF/1.0; +https://gendsgn.ru/)"),
"Accept-Language": "ru-RU,ru;q=0.9",
}
# -- dataclass ---------------------------------------------------------------
@dataclass
class KRTSite:
"""Одна КРТ-площадка из реестра екатеринбург.рф/гиз/крт."""
site_name: str
district: str | None
krt_kind: str | None
developer_name: str | None
contract_number: str | None
contract_date: date | None
auction_start_price: float | None
auction_final_price: float | None
gradpotential_sqm: float | None
area_ha: float | None
mkd_count: int | None
paga_decisions: list[dict[str, Any]]
raw_block: str
source_url: str = KRT_URL
# -- HTTP fetch ---------------------------------------------------------------
def fetch_krt_page() -> str:
"""Загружает HTML страницы реестра КРТ-площадок ЕКБ.
Сеть отделена от парсинга для testability.
Raises: httpx.HTTPError при недоступности сервера.
"""
with httpx.Client(
headers=_HEADERS,
timeout=30,
follow_redirects=True,
verify=False, # CA Минцифры РФ — нет в certifi. Аналог ekburg_permits.py #242.
) as client:
resp = client.get(KRT_URL)
resp.raise_for_status()
logger.info(
"ekb_krt_registry: fetched %s bytes from %s (status=%s)",
len(resp.content),
KRT_URL,
resp.status_code,
)
return resp.text
# -- Parse helpers ------------------------------------------------------------
def _normalize_num(raw: str) -> float | None:
"""Нормализует числовую строку: убирает пробелы/NBSP, запятая -> точка."""
cleaned = raw.replace("\xa0", "").replace(" ", "").replace(",", ".")
cleaned = re.sub(r"[^\d.\-]", "", cleaned)
try:
return float(cleaned)
except ValueError:
return None
def _extract_kind(title: str) -> str | None:
"""Извлекает «жилой застройки» / «нежилой застройки» из названия блока."""
m = re.search(r"((?:не)?жилой\s+застройки)", title, re.IGNORECASE)
if m:
return m.group(1).strip().lower()
return None
def _extract_district(title: str) -> str | None:
"""Извлекает район/квартал из названия блока.
Примеры:
«в районе ул. Краснофлотцев» -> «ул. Краснофлотцев»
«в квартале улицы Донбасской переулков...» -> «улицы Донбасской...»
"""
m = re.search(r"(?:в\s+районе|в\s+квартале)\s+(.+?)(?::|$)", title)
if m:
return m.group(1).strip().rstrip(":")
return None
def _extract_developer(block_text: str) -> str | None:
"""Застройщик из текста вида «(застройщик — ООО «Брусника...»)»."""
m = re.search(
r"застройщик\s*[–—\-]\s*(.+?)\s*\)",
block_text,
re.IGNORECASE,
)
if m:
raw = m.group(1).strip().replace("«", "").replace("»", "").strip()
return raw or None
return None
def _extract_contract(block_text: str) -> tuple[str | None, date | None]:
"""Номер и дата договора КРТ.
Образец: «Заключен договор о комплексном развитии ... от 10.08.2022 N 1-2/21.7-14»
Ищем «Заключен договор», чтобы не путать с «в соответствии с договором» (родительный
падеж «договора») они не содержат «Заключен» и дают ложное совпадение.
"""
m = re.search(
r"[Зз]аключен\s+договор[^.]*?\bот\s+(\d{2}\.\d{2}\.\d{4})\s*№\s*([\w/.\-]+)",
block_text,
re.IGNORECASE,
)
if not m:
return None, None
raw_date, number = m.group(1), m.group(2).strip()
try:
day, month, year = raw_date.split(".")
contract_dt: date | None = date(int(year), int(month), int(day))
except (ValueError, AttributeError):
contract_dt = None
return number or None, contract_dt
def _extract_auction_prices(
block_text: str,
) -> tuple[float | None, float | None]:
"""Начальная стоимость и цена по результатам торгов аукциона.
Образец:
«Начальная стоимость: 641 018,64 рублей;
Цена по результатам торгов: 671 018 рублей»
Также встречается «Начальная цена:».
"""
start: float | None = None
final: float | None = None
m_start = re.search(
r"Начальная\s+(?:стоимость|цена)\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*руб",
block_text,
re.IGNORECASE,
)
if m_start:
start = _normalize_num(m_start.group(1))
m_final = re.search(
r"Цена\s+по\s+результатам\s+торгов\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*руб",
block_text,
re.IGNORECASE,
)
if m_final:
final = _normalize_num(m_final.group(1))
return start, final
def _extract_area_ha(block_text: str) -> float | None:
"""Площадь территории в га."""
m = re.search(
r"[Пп]лощадь\s+территории\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*га",
block_text,
)
return _normalize_num(m.group(1)) if m else None
def _extract_gradpotential(block_text: str) -> float | None:
"""Градостроительный потенциал в кв. м."""
m = re.search(
r"[Гг]радостроительный\s+потенциал\s*:\s*([\d\s\xa0]+[,.]?\d*)\s*кв",
block_text,
)
return _normalize_num(m.group(1)) if m else None
def _extract_mkd_count(block_text: str) -> int | None:
"""Общее количество МКД в блоке.
Варианты: «Всего многоквартирных домов: N» или «Количество домов: N».
"""
m = re.search(
r"(?:Всего\s+многоквартирных\s+домов|Количество\s+домов)\s*:\s*(\d+)",
block_text,
re.IGNORECASE,
)
if m:
try:
return int(m.group(1))
except ValueError:
return None
return None
_PAGA_PATTERN = re.compile(
r"Постановление\s+Администрации\s+города\s+Екатеринбурга\s+"
r"от\s+(\d{2}\.\d{2}\.\d{4})\s+№\s+(\d+)\s*(?:«([^»]*)»)?",
re.IGNORECASE,
)
def _extract_paga_decisions(block_text: str) -> list[dict[str, Any]]:
"""Собирает все постановления ПАГЕ из блока в список словарей."""
results: list[dict[str, Any]] = []
for m in _PAGA_PATTERN.finditer(block_text):
entry: dict[str, Any] = {
"date": m.group(1),
"number": m.group(2),
}
if m.group(3):
entry["title"] = m.group(3).strip()
results.append(entry)
return results
# -- Block extractor ----------------------------------------------------------
# Граница блока — <p> с (необязательными) пробелами/&nbsp; перед <strong>,
# в котором первый текст — «N. КРТ» (не img-title). Паттерн НЕ совпадает
# с <p><strong><img ...></strong></p> (img-блоки с фото площадки).
_BLOCK_HEADING_RE = re.compile(
r"<p[^>]*>\s*(?:&nbsp;)?\s*<strong[^>]*>(?!<img)\s*(?:&nbsp;)?\s*"
r"(\d+\.(?:&nbsp;)?\s*КРТ(?:(?!<strong).)*?)</strong>",
re.DOTALL,
)
def _extract_krt_section(soup: BeautifulSoup) -> str:
"""Возвращает HTML содержимого раздела КРТ.
Ищет первый <h2> с текстом «202X год» и возвращает HTML его родительского
контейнера (<section> или <div>) всё содержимое раздела с площадками.
"""
for tag in soup.find_all("h2"):
text = tag.get_text()
if re.search(r"202\d\s*год", text):
parent = tag.find_parent("section") or tag.find_parent("div")
if parent:
return str(parent)
full_html = str(soup)
idx = full_html.find(str(tag))
if idx >= 0:
end = full_html.find("</section>", idx)
return full_html[idx : end if end > 0 else idx + 100_000]
return ""
def parse_krt_page(html: str) -> list[KRTSite]:
"""Парсит HTML страницы реестра КРТ-площадок ЕКБ.
Чистая функция (без сети). Возвращает список KRTSite.
Пустой/мусорный HTML -> [].
"""
if not html or not html.strip():
return []
soup = BeautifulSoup(html, "html.parser")
section_html = _extract_krt_section(soup)
if not section_html:
logger.warning("ekb_krt_registry: КРТ-раздел не найден в HTML")
return []
heading_matches = list(_BLOCK_HEADING_RE.finditer(section_html))
if not heading_matches:
logger.warning("ekb_krt_registry: не найдено ни одного блока площадки")
return []
sites: list[KRTSite] = []
block_starts = [m.start() for m in heading_matches]
block_ends = [*block_starts[1:], len(section_html)]
for match, block_end in zip(heading_matches, block_ends, strict=True):
block_html = section_html[match.start() : block_end]
raw_title_html = match.group(1)
title = unescape(re.sub(r"<[^>]+>", " ", raw_title_html))
title = title.replace("\xa0", " ").strip().rstrip(":")
block_text = unescape(re.sub(r"<[^>]+>", " ", block_html))
block_text = re.sub(r"\s+", " ", block_text).strip()
developer = _extract_developer(block_text)
contract_number, contract_date = _extract_contract(block_text)
start_price, final_price = _extract_auction_prices(block_text)
area_ha = _extract_area_ha(block_text)
gradpotential = _extract_gradpotential(block_text)
mkd_count = _extract_mkd_count(block_text)
paga = _extract_paga_decisions(block_text)
krt_kind = _extract_kind(title)
district = _extract_district(title)
site = KRTSite(
site_name=title.strip(),
district=district,
krt_kind=krt_kind,
developer_name=developer,
contract_number=contract_number,
contract_date=contract_date,
auction_start_price=start_price,
auction_final_price=final_price,
gradpotential_sqm=gradpotential,
area_ha=area_ha,
mkd_count=mkd_count,
paga_decisions=paga,
raw_block=block_text[:4000],
)
sites.append(site)
logger.debug(
"ekb_krt_registry: parsed '%s' developer=%s contract=%s",
title[:60],
developer,
contract_number,
)
logger.info("ekb_krt_registry: parsed %d sites", len(sites))
return sites

View file

@ -129,6 +129,14 @@ def _build_beat_schedule_from_db() -> dict:
} }
logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue) logger.info("build_beat_schedule: %s cron=%r queue=%s", jt, cron_str, queue)
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
return schedule return schedule
@ -176,6 +184,14 @@ def _build_beat_schedule_fallback() -> dict:
except Exception as e: except Exception as e:
logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e) logger.warning("beat_schedule fallback: refresh_analytics failed: %s", e)
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
return schedule return schedule
@ -369,4 +385,12 @@ def build_beat_schedule() -> dict:
"options": {"queue": "celery"}, "options": {"queue": "celery"},
} }
# КРТ-площадки ЕКБ (#1060): реестр обновляется редко (квартальные решения ПАГЕ),
# раз в неделю достаточно. Среда 05:00 МСК — не пересекается с permits (вт/ср 02:00).
schedule["ekb-krt-sync-weekly"] = {
"task": "tasks.ekb_krt_sync.sync_krt_sites",
"schedule": _parse_cron("0 5 * * wed"),
"options": {"queue": "celery"},
}
return schedule return schedule

View file

@ -64,6 +64,7 @@ celery_app = Celery(
"app.workers.tasks.supply_layers_refresh", "app.workers.tasks.supply_layers_refresh",
"app.workers.tasks.forecast", "app.workers.tasks.forecast",
"app.workers.tasks.ird_harvest", "app.workers.tasks.ird_harvest",
"app.workers.tasks.ekb_krt_sync",
"app.workers.tasks.gknspecial_harvest", "app.workers.tasks.gknspecial_harvest",
"app.workers.tasks.opportunity_harvest", "app.workers.tasks.opportunity_harvest",
"app.workers.tasks.planning_harvest", "app.workers.tasks.planning_harvest",

View file

@ -0,0 +1,122 @@
"""Celery task: еженедельная синхронизация реестра КРТ-площадок ЕКБ (#1060).
Fetch parse UPSERT в ekb_krt_sites (ON CONFLICT по site_name).
SAVEPOINT per-row: одна упавшая запись не откатывает всю транзакцию.
"""
from __future__ import annotations
import json
import logging
from sqlalchemy import text
from app.core.db import SessionLocal
from app.services.scrapers.ekb_krt_registry import (
KRT_URL,
KRTSite,
fetch_krt_page,
parse_krt_page,
)
from app.workers.celery_app import celery_app
logger = logging.getLogger(__name__)
_UPSERT_SQL = text("""
INSERT INTO ekb_krt_sites (
site_name, district, krt_kind,
developer_name, contract_number, contract_date,
auction_start_price, auction_final_price,
gradpotential_sqm, area_ha, mkd_count,
paga_decisions, raw_block, source_url, fetched_at
)
VALUES (
:site_name, :district, :krt_kind,
:developer_name, :contract_number, CAST(:contract_date AS date),
CAST(:auction_start_price AS numeric), CAST(:auction_final_price AS numeric),
CAST(:gradpotential_sqm AS numeric), CAST(:area_ha AS numeric),
CAST(:mkd_count AS integer),
CAST(:paga_decisions AS jsonb), :raw_block, :source_url, now()
)
ON CONFLICT (site_name) DO UPDATE SET
district = EXCLUDED.district,
krt_kind = EXCLUDED.krt_kind,
developer_name = EXCLUDED.developer_name,
contract_number = EXCLUDED.contract_number,
contract_date = EXCLUDED.contract_date,
auction_start_price = EXCLUDED.auction_start_price,
auction_final_price = EXCLUDED.auction_final_price,
gradpotential_sqm = EXCLUDED.gradpotential_sqm,
area_ha = EXCLUDED.area_ha,
mkd_count = EXCLUDED.mkd_count,
paga_decisions = EXCLUDED.paga_decisions,
raw_block = EXCLUDED.raw_block,
source_url = EXCLUDED.source_url,
fetched_at = now()
""")
def _site_to_params(site: KRTSite) -> dict:
return {
"site_name": site.site_name,
"district": site.district,
"krt_kind": site.krt_kind,
"developer_name": site.developer_name,
"contract_number": site.contract_number,
"contract_date": site.contract_date.isoformat() if site.contract_date else None,
"auction_start_price": site.auction_start_price,
"auction_final_price": site.auction_final_price,
"gradpotential_sqm": site.gradpotential_sqm,
"area_ha": site.area_ha,
"mkd_count": site.mkd_count,
"paga_decisions": json.dumps(site.paga_decisions, ensure_ascii=False),
"raw_block": site.raw_block,
"source_url": site.source_url,
}
@celery_app.task(name="tasks.ekb_krt_sync.sync_krt_sites")
def sync_krt_sites() -> dict[str, int]:
"""Fetch + parse + UPSERT реестра КРТ-площадок ЕКБ.
Returns: {"sites": N} число обработанных площадок.
"""
logger.info("ekb_krt_sync: start (url=%s)", KRT_URL)
html = fetch_krt_page()
sites = parse_krt_page(html)
if not sites:
logger.warning("ekb_krt_sync: parse returned 0 sites — страница изменилась?")
return {"sites": 0}
upserted = 0
failed = 0
db = SessionLocal()
try:
for site in sites:
try:
with db.begin_nested():
db.execute(_UPSERT_SQL, _site_to_params(site))
upserted += 1
except Exception as exc:
failed += 1
logger.warning(
"ekb_krt_sync: UPSERT failed for '%s': %s",
site.site_name[:60],
exc,
)
db.commit()
except Exception:
db.rollback()
raise
finally:
db.close()
logger.info(
"ekb_krt_sync: done — upserted=%d failed=%d",
upserted,
failed,
)
return {"sites": upserted}

View file

@ -35,6 +35,7 @@ dependencies = [
"ijson>=3.2.0", "ijson>=3.2.0",
"pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py) "pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py)
"pdfplumber>=0.10.0", # PDF-парсер постановлений об изъятии/резервировании (#1091) "pdfplumber>=0.10.0", # PDF-парсер постановлений об изъятии/резервировании (#1091)
"beautifulsoup4>=4.12.0", # HTML-парсер реестра КРТ-площадок ЕКБ (#1060)
] ]
[dependency-groups] [dependency-groups]

View file

@ -0,0 +1,228 @@
"""Тесты парсера реестра КРТ-площадок ЕКБ (#1060).
Все тесты без живой сети.
"""
from __future__ import annotations
import textwrap
import pytest
from app.services.scrapers.ekb_krt_registry import parse_krt_page
# ── Фикстура: реальный фрагмент блока площадки 2 (Донбасская — Брусника) ────
# Вставлен из krt_page_raw.html (beksburg.rf/дляработы/гиз/крт).
# Охватывает обе цены аукциона, договор, застройщика, 2 постановления ПАГЕ.
_BRUSNIKA_BLOCK = textwrap.dedent(
"""\
<h2><strong>2022 год</strong></h2>
<p>&nbsp;<strong>2. КРТ жилой застройки в квартале улицы Донбасской &ndash; \
переулков Черниговского &ndash; Суворовского:</strong></p>
<p><strong><img class="js-has-preview img-float-left" \
title="2.&Tab;КРТ жилой застройки" src="/media/gallery/fe.jpg" width="100%" /></strong></p>
<p>Площадь территории: 2,79 га;<br />Градостроительный потенциал: 76 650 кв. м;<br />\
Всего многоквартирных домов: 10;<br />\
3. Издано Постановление Администрации города Екатеринбурга от 10.03.2022 621 \
&laquo;О комплексном развитии территории жилой застройки в квартале улицы Донбасской&raquo;;\
<br />\
4. Постановление Администрации города Екатеринбурга от 19.05.2022 1334 \
&laquo;О проведении аукциона&raquo;;<br />\
5. Аукцион:<br />Начальная стоимость: 641 018,64 рублей;<br />\
Цена по результатам торгов: 671 018 рублей.<br />\
6. Заключен договор о комплексном развитии территории жилой застройки от 10.08.2022 \
1-2/21.7-14 (застройщик &ndash; ООО &laquo;Брусника. Специализированный застройщик&raquo;).\
<br />\
7. Постановление Администрации города Екатеринбурга от 28.12.2022 4051 \
&laquo;Об утверждении проекта планировки территории&raquo;.</p>
<p><strong>Стадия реализации: строительство</strong></p>
"""
)
class TestParseBrusnikaBlock:
"""Тесты парсинга блока площадки Донбасская (Брусника)."""
@pytest.fixture(scope="class")
def site(self):
sites = parse_krt_page(_BRUSNIKA_BLOCK)
assert sites, "parse_krt_page вернул пустой список"
return sites[0]
def test_krt_kind(self, site):
assert site.krt_kind == "жилой застройки"
def test_developer(self, site):
assert site.developer_name is not None
assert "Брусника" in site.developer_name
def test_auction_start_price(self, site):
"""641 018,64 → 641018.64"""
assert site.auction_start_price == pytest.approx(641018.64, rel=1e-4)
def test_auction_final_price(self, site):
"""671 018 → 671018.0"""
assert site.auction_final_price == pytest.approx(671018.0, rel=1e-4)
def test_contract_number(self, site):
assert site.contract_number == "1-2/21.7-14"
def test_contract_date(self, site):
from datetime import date
assert site.contract_date == date(2022, 8, 10)
def test_paga_decisions_at_least_one(self, site):
assert len(site.paga_decisions) >= 1
# первое постановление — 10.03.2022 № 621
dates = [d["date"] for d in site.paga_decisions]
assert "10.03.2022" in dates
def test_area_ha(self, site):
assert site.area_ha == pytest.approx(2.79, rel=1e-3)
def test_gradpotential(self, site):
# «76 650 кв. м» → 76650.0
assert site.gradpotential_sqm == pytest.approx(76650.0, rel=1e-3)
def test_mkd_count(self, site):
assert site.mkd_count == 10
def test_site_name_contains_number(self, site):
assert "2." in site.site_name
assert "Донбасской" in site.site_name
def test_raw_block_nonempty(self, site):
assert len(site.raw_block) > 50
class TestNumericNormalization:
"""Тесты числовой нормализации (NBSP, запятая как десятичный разделитель)."""
def test_nbsp_price(self):
"""«641\xa0018,64» → 641018.64"""
from app.services.scrapers.ekb_krt_registry import _normalize_num
assert _normalize_num("641\xa0018,64") == pytest.approx(641018.64, rel=1e-4)
def test_space_price(self):
"""«671 018» → 671018.0"""
from app.services.scrapers.ekb_krt_registry import _normalize_num
assert _normalize_num("671 018") == pytest.approx(671018.0)
def test_plain_decimal(self):
from app.services.scrapers.ekb_krt_registry import _normalize_num
assert _normalize_num("3,65") == pytest.approx(3.65)
def test_invalid_returns_none(self):
from app.services.scrapers.ekb_krt_registry import _normalize_num
assert _normalize_num("нет данных") is None
class TestEmptyAndGarbage:
"""Пустой / мусорный HTML → []."""
def test_empty_string(self):
assert parse_krt_page("") == []
def test_whitespace_only(self):
assert parse_krt_page(" \n\t ") == []
def test_irrelevant_html(self):
assert parse_krt_page("<html><body><p>Привет</p></body></html>") == []
def test_no_krt_section(self):
html = "<html><body><h2>Другой раздел</h2><p>Текст</p></body></html>"
assert parse_krt_page(html) == []
# ── Синтетический блок: площадка без застройщика / договора / цен ─────────────
# Имитирует площадки «в работе» (только geography + технические параметры).
# Не требует локального дампа — всегда исполняется в CI.
_BABUSHKINA_BLOCK = textwrap.dedent(
"""\
<h2><strong>2024 год</strong></h2>
<p>&nbsp;<strong>1. КРТ жилой застройки в районе ул. Бабушкина:</strong></p>
<p>Площадь территории: 5,10 га;<br />\
Градостроительный потенциал: 120 000 кв. м;<br />\
Всего многоквартирных домов: 18;<br />\
3. Издано Постановление Администрации города Екатеринбурга от 15.01.2024 42 \
&laquo;О комплексном развитии территории&raquo;.</p>
<p><strong>Стадия реализации: проектирование</strong></p>
"""
)
class TestParseBabushkinaBlock:
"""Синтетический тест: площадка без застройщика, договора и цен аукциона.
Не зависит от локального дампа всегда запускается в CI.
"""
@pytest.fixture(scope="class")
def site(self):
sites = parse_krt_page(_BABUSHKINA_BLOCK)
assert sites, "parse_krt_page вернул пустой список для синтетического блока"
return sites[0]
def test_parses_exactly_one_site(self):
"""Синтетический блок содержит ровно одну площадку."""
assert len(parse_krt_page(_BABUSHKINA_BLOCK)) == 1
def test_developer_is_none(self, site):
"""Застройщик не указан → None."""
assert site.developer_name is None
def test_contract_number_is_none(self, site):
"""Договор не заключён → None."""
assert site.contract_number is None
def test_area_ha_filled(self, site):
"""Площадь территории заполнена."""
assert site.area_ha == pytest.approx(5.10, rel=1e-3)
def test_gradpotential_filled(self, site):
"""Градостроительный потенциал заполнен."""
assert site.gradpotential_sqm == pytest.approx(120000.0, rel=1e-3)
def test_district_filled(self, site):
"""Район извлечён из названия блока."""
assert site.district is not None
assert "Бабушкина" in site.district
def test_krt_kind(self, site):
assert site.krt_kind == "жилой застройки"
class TestMultiSiteCountFromDump:
"""Проверка количества площадок из реального дампа страницы."""
@pytest.fixture(scope="class")
def dump_html(self):
"""Загружает локальный дамп страницы.
Тест пропускается если файл отсутствует (CI без дампа).
"""
import os
dump_path = os.path.join(
os.path.dirname(__file__),
"../../../../.playwright-mcp/ekb-docs/krt_page_raw.html",
)
if not os.path.exists(dump_path):
pytest.skip("krt_page_raw.html dump not found — skip real-dump test")
with open(dump_path, encoding="utf-8") as fh:
return fh.read()
def test_site_count_from_dump(self, dump_html):
"""Из дампа должно распарситься ≥ 14 площадок (реальных на странице 15)."""
sites = parse_krt_page(dump_html)
assert len(sites) >= 14, f"Expected ≥ 14 sites, got {len(sites)}"
def test_no_duplicate_site_names(self, dump_html):
sites = parse_krt_page(dump_html)
names = [s.site_name for s in sites]
assert len(names) == len(set(names)), "Дубликаты site_name в результате"

24
backend/uv.lock generated
View file

@ -73,6 +73,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
] ]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]] [[package]]
name = "billiard" name = "billiard"
version = "4.2.4" version = "4.2.4"
@ -561,6 +574,7 @@ version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },
{ name = "beautifulsoup4" },
{ name = "celery" }, { name = "celery" },
{ name = "ezdxf" }, { name = "ezdxf" },
{ name = "fastapi" }, { name = "fastapi" },
@ -606,6 +620,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" }, { name = "alembic", specifier = ">=1.13.0" },
{ name = "beautifulsoup4", specifier = ">=4.12.0" },
{ name = "celery", specifier = ">=5.4.0" }, { name = "celery", specifier = ">=5.4.0" },
{ name = "ezdxf", specifier = ">=1.3.0" }, { name = "ezdxf", specifier = ">=1.3.0" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
@ -2234,6 +2249,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
] ]
[[package]]
name = "soupsieve"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.49" version = "2.0.49"

View file

@ -0,0 +1,96 @@
-- 137_ekb_krt_sites.sql
-- #1060 (Site Finder v2 / GG-форсайт) — реестр КРТ-площадок ЕКБ.
--
-- Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/крт
-- (екатеринбург.рф, SSR-страница, 15+ площадок).
--
-- Реквизиты каждой площадки: застройщик, вид застройки (жилая/нежилая),
-- параметры договора КРТ (номер/дата/цена аукциона), градпотенциал, площадь,
-- список постановлений ПАГЕ. Геометрия отсутствует в источнике — join к
-- opportunity_krt запланирован отдельно.
--
-- Ключ: contract_number — присутствует у площадок с заключённым договором КРТ
-- (большинство). Площадки без договора (ранние стадии «подготовки к аукциону»)
-- используют site_name как резервный ключ (UNIQUE(site_name) + partial UNIQUE
-- по contract_number WHERE contract_number IS NOT NULL реализован через два
-- раздельных индекса + DO-блоки ниже).
--
-- Deploy: auto-applied deploy.yml через _schema_migrations (ровно один раз по NN).
-- Idempotent (CREATE TABLE/INDEX IF NOT EXISTS + guarded DO-блоки по pg_constraint).
-- Без INSERT — данные приходят из ekb_krt_sync Celery-воркера (#1060).
--
-- Параллельный PR берёт номер 138 — этот файл 137 зарезервирован для этого PR.
BEGIN;
-- ── 1. Таблица ekb_krt_sites ─────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS ekb_krt_sites (
id BIGSERIAL PRIMARY KEY,
site_name TEXT NOT NULL, -- «N. КРТ жилой/нежилой застройки в районе ул. X»
district TEXT, -- район/квартал (извлечён из названия best-effort)
krt_kind TEXT, -- «жилой застройки» | «нежилой застройки»
developer_name TEXT, -- застройщик из текста договора
contract_number TEXT, -- «1-2/21.7-14» (NULL у незавершённых площадок)
contract_date DATE, -- дата договора КРТ
auction_start_price NUMERIC, -- начальная стоимость аукциона, руб.
auction_final_price NUMERIC, -- цена по результатам торгов, руб.
gradpotential_sqm NUMERIC, -- градостроительный потенциал, кв. м
area_ha NUMERIC, -- площадь территории, га
mkd_count INTEGER, -- всего многоквартирных домов (если есть)
paga_decisions JSONB, -- [{date, number, title}] постановления ПАГЕ
raw_block TEXT, -- исходный текст блока (для отладки/re-parse)
source_url TEXT, -- URL страницы-источника
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ekb_krt_sites IS
'Реестр КРТ-площадок Екатеринбурга (#1060, GG-форсайт). '
'Источник: екатеринбург.рф/дляработы/гиз/крт. '
'Обновляется еженедельно воркером ekb_krt_sync. '
'Геометрия отсутствует — join к opportunity_krt планируется отдельно.';
COMMENT ON COLUMN ekb_krt_sites.contract_number IS
'Номер договора КРТ («1-2/21.7-14»). NULL у площадок на стадии подготовки к аукциону.';
COMMENT ON COLUMN ekb_krt_sites.paga_decisions IS
'JSON-массив постановлений ПАГЕ: [{date: «DD.MM.YYYY», number: «NNN», title: «...»}]. '
'Все постановления из блока, включая об утверждении ППТ/ПМТ.';
COMMENT ON COLUMN ekb_krt_sites.krt_kind IS
'«жилой застройки» или «нежилой застройки» — вид КРТ из названия площадки.';
-- ── 2. UNIQUE по site_name — надёжный ключ для уникальности (присутствует всегда) ──
-- Служит ON CONFLICT target для UPSERT воркера. contract_number NULL у ранних стадий,
-- поэтому site_name выбран основным дедуп-ключом. Дополнительно: уникальный индекс
-- по contract_number WHERE NOT NULL — для быстрого lookup по номеру договора.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_ekb_krt_sites_site_name'
) THEN
ALTER TABLE ekb_krt_sites
ADD CONSTRAINT uq_ekb_krt_sites_site_name UNIQUE (site_name);
END IF;
END
$$;
-- ── 3. Partial unique index по contract_number (где не NULL) ─────────────────
-- Не через DO-блок + ALTER (partial UNIQUE через ALTER ADD CONSTRAINT не поддерживает
-- WHERE clause в PostgreSQL). Вместо этого — CREATE UNIQUE INDEX IF NOT EXISTS с WHERE.
CREATE UNIQUE INDEX IF NOT EXISTS uix_ekb_krt_sites_contract_number
ON ekb_krt_sites (contract_number)
WHERE contract_number IS NOT NULL;
-- ── 4. Индексы ───────────────────────────────────────────────────────────────
-- Поиск по виду застройки (жилая/нежилая).
CREATE INDEX IF NOT EXISTS idx_ekb_krt_sites_krt_kind
ON ekb_krt_sites (krt_kind);
-- Поиск по застройщику (JOIN с domrf_kn / анализ по девелоперу).
CREATE INDEX IF NOT EXISTS idx_ekb_krt_sites_developer
ON ekb_krt_sites (developer_name)
WHERE developer_name IS NOT NULL;
-- Свежесть данных / ORDER BY fetched_at для мониторинга.
CREATE INDEX IF NOT EXISTS idx_ekb_krt_sites_fetched_at
ON ekb_krt_sites (fetched_at DESC);
COMMIT;