diff --git a/backend/app/services/scrapers/ekb_krt_registry.py b/backend/app/services/scrapers/ekb_krt_registry.py new file mode 100644 index 00000000..8f02e67d --- /dev/null +++ b/backend/app/services/scrapers/ekb_krt_registry.py @@ -0,0 +1,350 @@ +"""Scraper реестра КРТ-площадок Екатеринбурга (#1060, GG-форсайт). + +Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/крт +(екатеринбург.рф, SSR-страница без auth и JS, обычный TLS GET). + +На странице ~15 площадок по годам (2022–2025). Каждая площадка = текстовый +блок прозой с нумерованными пунктами. Граница блока — тег +

...N. КРТ (жилой|нежилой) застройки......

. + +Публичный 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 ---------------------------------------------------------- + + +# Граница блока —

с (необязательными) пробелами/  перед , +# в котором первый текст — «N. КРТ» (не img-title). Паттерн НЕ совпадает +# с

(img-блоки с фото площадки). +_BLOCK_HEADING_RE = re.compile( + r"]*>\s*(?: )?\s*]*>(?!", + re.DOTALL, +) + + +def _extract_krt_section(soup: BeautifulSoup) -> str: + """Возвращает HTML содержимого раздела КРТ. + + Ищет первый

с текстом «202X год» и возвращает HTML его родительского + контейнера (
или
) — всё содержимое раздела с площадками. + """ + 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("
", 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 diff --git a/backend/app/workers/beat_schedule.py b/backend/app/workers/beat_schedule.py index 4c6031b2..dc888c2e 100644 --- a/backend/app/workers/beat_schedule.py +++ b/backend/app/workers/beat_schedule.py @@ -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) + # КРТ-площадки ЕКБ (#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 @@ -176,6 +184,14 @@ def _build_beat_schedule_fallback() -> dict: except Exception as 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 @@ -369,4 +385,12 @@ def build_beat_schedule() -> dict: "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 diff --git a/backend/app/workers/celery_app.py b/backend/app/workers/celery_app.py index 3a97600e..44f49fe3 100644 --- a/backend/app/workers/celery_app.py +++ b/backend/app/workers/celery_app.py @@ -64,6 +64,7 @@ celery_app = Celery( "app.workers.tasks.supply_layers_refresh", "app.workers.tasks.forecast", "app.workers.tasks.ird_harvest", + "app.workers.tasks.ekb_krt_sync", "app.workers.tasks.gknspecial_harvest", "app.workers.tasks.opportunity_harvest", "app.workers.tasks.planning_harvest", diff --git a/backend/app/workers/tasks/ekb_krt_sync.py b/backend/app/workers/tasks/ekb_krt_sync.py new file mode 100644 index 00000000..5d0271a7 --- /dev/null +++ b/backend/app/workers/tasks/ekb_krt_sync.py @@ -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} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c3ead1c9..a0042e87 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "ijson>=3.2.0", "pyyaml>=6.0.0", # RBAC roles.yaml loader (app/core/auth.py) "pdfplumber>=0.10.0", # PDF-парсер постановлений об изъятии/резервировании (#1091) + "beautifulsoup4>=4.12.0", # HTML-парсер реестра КРТ-площадок ЕКБ (#1060) ] [dependency-groups] diff --git a/backend/tests/services/scrapers/test_ekb_krt_registry.py b/backend/tests/services/scrapers/test_ekb_krt_registry.py new file mode 100644 index 00000000..2463fc8b --- /dev/null +++ b/backend/tests/services/scrapers/test_ekb_krt_registry.py @@ -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( + """\ +

2022 год

+

 2. КРТ жилой застройки в квартале улицы Донбасской – \ +переулков Черниговского – Суворовского:

+

+

Площадь территории: 2,79 га;
Градостроительный потенциал: 76 650 кв. м;
\ +Всего многоквартирных домов: 10;
\ +3. Издано Постановление Администрации города Екатеринбурга от 10.03.2022 № 621 \ +«О комплексном развитии территории жилой застройки в квартале улицы Донбасской»;\ +
\ +4. Постановление Администрации города Екатеринбурга от 19.05.2022 № 1334 \ +«О проведении аукциона»;
\ +5. Аукцион:
Начальная стоимость: 641 018,64 рублей;
\ +Цена по результатам торгов: 671 018 рублей.
\ +6. Заключен договор о комплексном развитии территории жилой застройки от 10.08.2022 \ +№ 1-2/21.7-14 (застройщик – ООО «Брусника. Специализированный застройщик»).\ +
\ +7. Постановление Администрации города Екатеринбурга от 28.12.2022 № 4051 \ +«Об утверждении проекта планировки территории».

+

Стадия реализации: строительство

+ """ +) + + +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("

Привет

") == [] + + def test_no_krt_section(self): + html = "

Другой раздел

Текст

" + assert parse_krt_page(html) == [] + + +# ── Синтетический блок: площадка без застройщика / договора / цен ───────────── +# Имитирует площадки «в работе» (только geography + технические параметры). +# Не требует локального дампа — всегда исполняется в CI. +_BABUSHKINA_BLOCK = textwrap.dedent( + """\ +

2024 год

+

 1. КРТ жилой застройки в районе ул. Бабушкина:

+

Площадь территории: 5,10 га;
\ +Градостроительный потенциал: 120 000 кв. м;
\ +Всего многоквартирных домов: 18;
\ +3. Издано Постановление Администрации города Екатеринбурга от 15.01.2024 № 42 \ +«О комплексном развитии территории».

+

Стадия реализации: проектирование

+ """ +) + + +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 в результате" diff --git a/backend/uv.lock b/backend/uv.lock index a30f6b6c..a4b36a00 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ] +[[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]] name = "billiard" version = "4.2.4" @@ -561,6 +574,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "beautifulsoup4" }, { name = "celery" }, { name = "ezdxf" }, { name = "fastapi" }, @@ -606,6 +620,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.13.0" }, + { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "celery", specifier = ">=5.4.0" }, { name = "ezdxf", specifier = ">=1.3.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" }, ] +[[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]] name = "sqlalchemy" version = "2.0.49" diff --git a/data/sql/137_ekb_krt_sites.sql b/data/sql/137_ekb_krt_sites.sql new file mode 100644 index 00000000..c96e0e2b --- /dev/null +++ b/data/sql/137_ekb_krt_sites.sql @@ -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;