Some checks failed
Deploy / build-backend (push) Blocked by required conditions
Deploy / build-worker (push) Blocked by required conditions
Deploy / build-frontend (push) Blocked by required conditions
Deploy / deploy (push) Blocked by required conditions
Deploy / changes (push) Has been cancelled
227 lines
9.1 KiB
Python
227 lines
9.1 KiB
Python
"""Клиент для поиска и скачивания постановлений с 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",
|
||
]
|