gendesign/backend/app/services/scrapers/ekb_krt_registry.py
lekss361 57c6d119f5
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 2m40s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 3m22s
Deploy / deploy (push) Successful in 1m30s
feat(sf): scraper реестра КРТ-площадок ЕКБ → ekb_krt_sites (#1126)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 11:24:49 +00:00

350 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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