"""Клиент для поиска и скачивания постановлений с pravo.gov66.ru (#1062). Сайт pravo.gov66.ru — реестр правовых актов Правительства Свердловской области. HTML server-rendered (нет JS-рендеринга), httpx достаточно. TLS: сайт использует российский УЦ → verify=False (аналогично ekburg_permits #242). Politeness (#2445 D4): пауза ``_RATE_S`` перед каждым запросом (search / карточка документа / PDF) — тот же идиом, что nspd_client/objective/gisogd66 (300-500мс). Этот VPS уже словил WAF-бан от DOM.РФ 24.05.2026 (#2443) за непейсованные последовательные запросы; pravo.gov66.ru — сайт той же формы (гос-портал region, строгий WAF). Ретрай с backoff на 429/5xx — идиом gisogd66._get_json. Публичный 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 import time 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) # Politeness pacing (#2445 D4): пауза ПЕРЕД каждым запросом к pravo.gov66.ru — тот же # идиом (300-500мс), что nspd_client/objective/gisogd66. Не непейсованный WAF-риск, # аналогичный DOM.РФ-бану #2443. _RATE_S = 0.4 # Ретраи на 429/5xx: экспоненциальный backoff, суммарно до 1 + _MAX_RETRIES попыток. _MAX_RETRIES = 2 _RETRY_BACKOFF_S = 2.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 _get_with_backoff( client: httpx.Client, url: str, *, params: dict[str, str] | None = None, ) -> httpx.Response: """GET с politeness-паузой перед запросом + backoff-ретраем на 429/5xx. Пауза ``_RATE_S`` — ПЕРЕД каждой попыткой (в т.ч. первой): пейсинг последовательных запросов к pravo.gov66.ru, не только ретраев (#2445 D4). 429/5xx ретраятся до ``_MAX_RETRIES`` раз с экспоненциальным backoff; прочие статусы (включая 4xx кроме 429) — сразу ``raise_for_status()``. """ last_exc: Exception | None = None for attempt in range(_MAX_RETRIES + 1): time.sleep(_RATE_S) resp = client.get(url, params=params) if resp.status_code == 429 or resp.status_code >= 500: last_exc = httpx.HTTPStatusError( f"{resp.status_code} on {url}", request=resp.request, response=resp ) if attempt >= _MAX_RETRIES: raise last_exc logger.warning( "pravo_gov66_client: %s (попытка %d) на %s — backoff", resp.status_code, attempt + 1, url, ) time.sleep(_RETRY_BACKOFF_S * (attempt + 1)) continue resp.raise_for_status() return resp assert last_exc is not None raise last_exc 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 = _get_with_backoff(client, _SEARCH_URL, params=params) 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 = _get_with_backoff(client, doc_url) 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. Пейсинг + backoff на 429/5xx через ``_get_with_backoff`` (#2445 D4) — caller (reservation_ingest) НЕ должен добавлять свой sleep перед этим вызовом, пауза уже внутри клиента. Args: url: абсолютный URL PDF-файла. Returns: Байты PDF. Raises: httpx.HTTPError: при сетевой ошибке (caller должен обработать gracefully). """ with httpx.Client(timeout=_TIMEOUT, verify=False, follow_redirects=True) as client: resp = _get_with_backoff(client, url) return resp.content __all__ = [ "_LAW_TYPE_POSTANOVLENIE", "fetch_pdf", "get_document_pdf_url", "search_documents", ]