gendesign/backend/app/services/scrapers/pravo_gov66_client.py
bot-backend d1fa450f48
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
fix(backend): reservation_ingest пейсинг + короткоживущая DB-сессия (#2445-D4) (#2458)
2026-07-05 20:09:00 +00:00

227 lines
9.1 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).
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",
]