gendesign/backend/app/services/scrapers/pravo_gov66_client.py
bot-backend e2faab801c
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Successful in 1m30s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m21s
Deploy / deploy (push) Successful in 1m15s
fix(sf): pravo.gov66 law_type числовой код =1 Постановление (#1149)
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-06-07 14:17:52 +00:00

174 lines
6.4 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).
Публичный 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",
]