"""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). Паттерн НЕ совпадает
# с ]*>\s*(?: )?\s*]*>(?!",
re.DOTALL,
)
def _extract_krt_section(soup: BeautifulSoup) -> str:
"""Возвращает HTML содержимого раздела КРТ.
Ищет первый
с текстом «202X год» и возвращает HTML его родительского
контейнера (