gendesign/backend/app/services/scrapers/izyatie_client.py
bot-backend 5da4271235
All checks were successful
CI / frontend-tests (push) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 6s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m22s
CI / backend-tests (pull_request) Successful in 6m19s
feat(sf): OCR-пайплайн изъятия ЕКБ (Tesseract rus) → land_reservation (#1062)
- Dockerfile: добавлены tesseract-ocr tesseract-ocr-rus в runner apt-слой
- pyproject.toml: pymupdf>=1.24 (рендер PDF без poppler) + pytesseract>=0.3.13
- izyatie_client.py: list_izyatie_documents() + fetch_pdf() с екатеринбург.рф
- izyatie_ocr.py: ocr_pdf_text() (PyMuPDF→PIL→Tesseract rus) + extract_izyatie_records()
  с нормализацией OCR-шума (пробелы в кад-номерах, кирилл. О→0, б→6)
- izyatie_ocr_ingest.py: Celery task → land_reservation UPSERT (SAVEPOINT per-row)
- beat_schedule.py: izyatie-ocr-ingest-weekly (пятница 07:00 МСК)
- 33 теста, все зелёные; без реального Tesseract/сети в CI
2026-06-07 18:07:06 +03:00

128 lines
5.2 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.

"""HTTP-клиент для раздела «Изъятие для муниципальных нужд» екатеринбург.рф (#1062).
Источник: https://xn--80acgfbsl1azdqr.xn--p1ai/дляработы/гиз/градостроительство/изъятие
Страница содержит список ссылок /file/<md5hash> на PDF-сканы «Сообщений о планируемом
изъятии…» под муниципальные нужды.
TLS: екатеринбург.рф использует российский УЦ → verify=False (как в ekb_geoportal_client.py,
issue #242). Прод-IP имеет доступ к домену (мы уже скрапим РНС/КРТ с того же домена).
"""
from __future__ import annotations
import logging
import re
import urllib.parse
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
# Punycode URL раздела изъятия (ekb.rf → xn--80acgfbsl1azdqr.xn--p1ai).
_IZYATIE_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%B3%D1%80%D0%B0%D0%B4%D0%BE%D1%81%D1%82%D1%80%D0%BE%D0%B8%D1%82%D0%B5%D0%BB%D1%8C%D1%81%D1%82%D0%B2%D0%BE"
"/%D0%B8%D0%B7%D1%8A%D1%8F%D1%82%D0%B8%D0%B5"
)
_BASE_URL = "https://xn--80acgfbsl1azdqr.xn--p1ai"
# Браузерный User-Agent — домен отдаёт robots.txt только для ботов.
_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
# Паттерн file-ссылки — /file/<md5hash> (32 hex-символа).
_RE_FILE_HREF = re.compile(r"^/file/[0-9a-f]{32}$", re.IGNORECASE)
# Ключевые слова для отфильтровки ссылок раздела изъятия.
# Берём все /file/-ссылки на странице: раздел посвящён только изъятию, других PDF там нет.
_IZYATIE_KEYWORDS = re.compile(
r"изъят|планируемом|сообщени|муниципальн|татищева|базовый|комсомольск|"
r"энергостроит|павлодарск|иркутск|щербак|шаумян",
re.IGNORECASE | re.UNICODE,
)
def list_izyatie_documents(url: str = _IZYATIE_URL) -> list[dict[str, str]]:
"""Получает список PDF-документов об изъятии с раздела екатеринбург.рф.
Парсит страницу раздела и возвращает все /file/<hash>-ссылки.
Если страница вернула пустой список — логирует предупреждение (не падает).
Args:
url: URL раздела изъятия (по умолчанию prod-URL).
Returns:
Список словарей {'title': str, 'url': str} — название ссылки и абсолютный URL PDF.
Пустой список при сетевой ошибке или отсутствии ссылок.
"""
try:
with httpx.Client(
timeout=30.0,
verify=False,
follow_redirects=True,
headers={"User-Agent": _UA},
) as client:
resp = client.get(url)
resp.raise_for_status()
html = resp.text
except Exception as exc:
logger.error("list_izyatie_documents: не удалось загрузить страницу %s: %s", url, exc)
return []
soup = BeautifulSoup(html, "html.parser")
docs: list[dict[str, str]] = []
for a_tag in soup.find_all("a", href=True):
href: str = a_tag["href"]
# Берём только /file/<hash>-ссылки.
if not _RE_FILE_HREF.match(href):
continue
title: str = a_tag.get_text(strip=True) or href
# Если заголовок пустой — берём title-атрибут или href.
if not title:
title = a_tag.get("title", href)
absolute_url = urllib.parse.urljoin(_BASE_URL, href)
docs.append({"title": title, "url": absolute_url})
if not docs:
logger.warning(
"list_izyatie_documents: не найдено /file/-ссылок на странице %s "
"(возможно, изменилась структура HTML или все документы удалены)",
url,
)
else:
logger.info("list_izyatie_documents: найдено %d документов на %s", len(docs), url)
return docs
def fetch_pdf(url: str) -> bytes:
"""Загружает PDF по URL с екатеринбург.рф.
Args:
url: абсолютный URL PDF-файла (/file/<hash>).
Returns:
Сырые байты PDF.
Raises:
httpx.HTTPError: при ошибке сети или не-200 статусе.
"""
with httpx.Client(
timeout=60.0,
verify=False,
follow_redirects=True,
headers={"User-Agent": _UA},
) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
__all__ = ["fetch_pdf", "list_izyatie_documents"]