gendesign/backend/app/services/scrapers/pravo_gov66_client.py
lekss361 396db20c48
Some checks failed
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been cancelled
Deploy / build-worker (push) Has been cancelled
feat(sf): enumerate изъятия/резервирования с pravo.gov66 → land_reservation (#1145)
Co-authored-by: lekss361 <lekss361@gendsgn.local>
Co-committed-by: lekss361 <lekss361@gendsgn.local>
2026-06-07 13:56:29 +00:00

167 lines
5.6 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.

"""Клиент для поиска и скачивания постановлений с pravo.gov66.ru (#1062).
Сайт pravo.gov66.ru — реестр правовых актов Правительства Свердловской области.
HTML server-rendered (нет JS-рендеринга), httpx достаточно.
TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242).
Публичный API:
search_documents(query, law_type, since) → list[dict]
get_document_pdf_url(doc_id) → str | None
fetch_pdf(url) → bytes
"""
from __future__ import annotations
import logging
import re
from typing import Any
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
_BASE_URL = "https://www.pravo.gov66.ru"
_SEARCH_URL = f"{_BASE_URL}/search/"
# Тайм-аут щедрый: сервер иногда медленный.
_TIMEOUT = httpx.Timeout(60.0, connect=15.0)
# Паттерны для извлечения ссылок.
# Числовой doc_id в пути: /35496/
_RE_DOC_PATH = re.compile(r"^/(\d+)/$")
# PDF в /media/pravo/ (не ecp-вариант: ecp — подписанная копия, не всегда парсится).
_RE_PDF_PATH = re.compile(r"/media/pravo/(?!ecp/)[^\s\"']+\.pdf", re.IGNORECASE)
# ecp-вариант как fallback.
_RE_PDF_ECP_PATH = re.compile(r"/media/pravo/ecp/[^\s\"']+\.pdf", re.IGNORECASE)
def search_documents(
query: str,
law_type: str = "Постановление",
since: str | None = None,
) -> list[dict[str, Any]]:
"""Поиск документов на pravo.gov66.ru.
Args:
query: поисковый запрос (2-3 слова; 1 или 4+ слов → 0 результатов на сервере).
law_type: тип документа, напр. «Постановление».
since: дата начала периода в формате «DD.MM.YYYY» (поле signed_start_date).
Returns:
Список словарей {'doc_id': str, 'title': str, 'url': str}.
Пустой список при любой сетевой ошибке.
"""
params: dict[str, str] = {"q": query, "law_type": law_type}
if since:
params["signed_start_date"] = since
try:
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = client.get(_SEARCH_URL, params=params)
resp.raise_for_status()
html = resp.text
except Exception as exc:
logger.error("pravo_gov66_client.search_documents: ошибка сети q=%r: %s", query, exc)
return []
return _parse_search_html(html)
def _parse_search_html(html: str) -> list[dict[str, Any]]:
"""Извлекает doc_id / title / url из HTML страницы поиска."""
soup = BeautifulSoup(html, "html.parser")
results: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for a_tag in soup.find_all("a", href=True):
href: str = a_tag["href"]
m = _RE_DOC_PATH.match(href)
if not m:
continue
doc_id = m.group(1)
if doc_id in seen_ids:
continue
seen_ids.add(doc_id)
title = a_tag.get_text(separator=" ", strip=True)
results.append(
{
"doc_id": doc_id,
"title": title,
"url": f"{_BASE_URL}{href}",
}
)
logger.debug("_parse_search_html: найдено %d документов", len(results))
return results
def get_document_pdf_url(doc_id: str) -> str | None:
"""Получает прямую ссылку на PDF со страницы документа.
Предпочитает /media/pravo/ (не ecp); если не найдена — берёт ecp-вариант.
Args:
doc_id: числовой идентификатор документа (из пути /{doc_id}/).
Returns:
Абсолютный URL PDF или None если не найдена / сетевая ошибка.
"""
doc_url = f"{_BASE_URL}/{doc_id}/"
try:
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = client.get(doc_url)
resp.raise_for_status()
html = resp.text
except Exception as exc:
logger.error(
"pravo_gov66_client.get_document_pdf_url: ошибка загрузки doc_id=%s: %s",
doc_id,
exc,
)
return None
return _extract_pdf_url(html)
def _extract_pdf_url(html: str) -> str | None:
"""Извлекает URL PDF из HTML страницы документа."""
# Приоритет: /media/pravo/ (не ecp).
m = _RE_PDF_PATH.search(html)
if m:
path = m.group(0)
return path if path.startswith("http") else f"{_BASE_URL}{path}"
# Fallback: ecp-вариант.
m2 = _RE_PDF_ECP_PATH.search(html)
if m2:
path2 = m2.group(0)
return path2 if path2.startswith("http") else f"{_BASE_URL}{path2}"
return None
def fetch_pdf(url: str) -> bytes:
"""Загружает PDF по URL.
Args:
url: абсолютный URL PDF-файла.
Returns:
Байты PDF.
Raises:
httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully).
"""
with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client:
resp = client.get(url)
resp.raise_for_status()
return resp.content
__all__ = [
"fetch_pdf",
"get_document_pdf_url",
"search_documents",
]