"""Клиент для поиска и скачивания постановлений с 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) # Числовые коды типов документов (select#id_law_type на сайте). # Текстовое значение «Постановление» — неверный параметр, сервер не матчит → 0 хитов. _LAW_TYPE_POSTANOVLENIE = "1" # Постановление Правительства Свердловской области # Паттерны для извлечения ссылок. # Числовой doc_id в пути: /35496/ (НЕ /documents/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 = _LAW_TYPE_POSTANOVLENIE, since: str | None = None, ) -> list[dict[str, Any]]: """Поиск документов на pravo.gov66.ru. Args: query: поисковый запрос (2-3 слова; 1 или 4+ слов → 0 результатов на сервере). law_type: числовой код типа документа (см. select#id_law_type). По умолчанию ``_LAW_TYPE_POSTANOVLENIE="1"`` (Постановление). НЕ передавать текст вроде «Постановление» — сервер игнорирует нечисловые коды → 0 хитов. 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__ = [ "_LAW_TYPE_POSTANOVLENIE", "fetch_pdf", "get_document_pdf_url", "search_documents", ]