Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""Scraper реестра КРТ-площадок Екатеринбурга (#1060, GG-форсайт).
|
||
|
||
Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/крт
|
||
(екатеринбург.рф, SSR-страница без auth и JS, обычный TLS GET).
|
||
|
||
На странице ~15 площадок по годам (2022–2025). Каждая площадка = текстовый
|
||
блок прозой с нумерованными пунктами. Граница блока — тег
|
||
<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> с (необязательными) пробелами/ перед <strong>,
|
||
# в котором первый текст — «N. КРТ» (не img-title). Паттерн НЕ совпадает
|
||
# с <p><strong><img ...></strong></p> (img-блоки с фото площадки).
|
||
_BLOCK_HEADING_RE = re.compile(
|
||
r"<p[^>]*>\s*(?: )?\s*<strong[^>]*>(?!<img)\s*(?: )?\s*"
|
||
r"(\d+\.(?: )?\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
|