Some checks failed
Co-authored-by: lekss361 <lekss361@gendsgn.local> Co-committed-by: lekss361 <lekss361@gendsgn.local>
167 lines
5.6 KiB
Python
167 lines
5.6 KiB
Python
"""Клиент для поиска и скачивания постановлений с 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",
|
||
]
|