feat(parsers): add proverki.gov.ru parser with sync_inspections task
Some checks failed
CI/CD Pipeline / Code Quality Checks (push) Failing after 1m28s
CI/CD Pipeline / Build Docker Images (push) Has been cancelled
CI/CD Pipeline / Push to Gitea Registry (push) Has been cancelled
CI/CD Pipeline / Run Tests (push) Has been cancelled

- Add InspectionRecord model with is_federal_law_248, data_year, data_month fields
- Add ProverkiClient with Playwright support for JS-rendered portal
- Add streaming XML parser for large files (>50MB)
- Add sync_inspections task with incremental loading logic
  - Starts from 01.01.2025 if DB is empty
  - Loads both FZ-294 and FZ-248 inspections
  - Stops after 2 consecutive empty months
- Add InspectionService methods: get_last_loaded_period, has_data_for_period
- Add Minpromtorg parsers (certificates, manufacturers)
- Add Django Admin for parser models
- Update README with parsers documentation and changelog
This commit is contained in:
2026-01-21 20:16:25 +01:00
parent f121445313
commit 199d871923
45 changed files with 6810 additions and 97 deletions

View File

@@ -0,0 +1,22 @@
"""
Клиенты для парсинга внешних источников данных.
Каждый источник имеет изолированный клиент, который:
- Принимает настройки (proxy и т.д.) через конструктор
- Возвращает типизированные dataclass объекты
- Не зависит от Django ORM
"""
from apps.parsers.clients.base import BaseHTTPClient
from apps.parsers.clients.minpromtorg import (
IndustrialProductionClient,
ManufacturesClient,
)
from apps.parsers.clients.proverki import ProverkiClient
__all__ = [
"BaseHTTPClient",
"IndustrialProductionClient",
"ManufacturesClient",
"ProverkiClient",
]

View File

@@ -0,0 +1,238 @@
"""
Базовый HTTP клиент для парсеров.
Изолирован от Django, использует только стандартные библиотеки и requests.
"""
import logging
import random
from dataclasses import dataclass, field
from typing import Any
import requests
logger = logging.getLogger(__name__)
class HTTPClientError(Exception):
"""Базовое исключение HTTP клиента."""
def __init__(
self, message: str, status_code: int | None = None, url: str | None = None
):
self.message = message
self.status_code = status_code
self.url = url
super().__init__(message)
class ConnectionError(HTTPClientError):
"""Ошибка подключения."""
pass
class HTTPError(HTTPClientError):
"""HTTP ошибка (4xx, 5xx)."""
pass
@dataclass
class BaseHTTPClient:
"""
Базовый HTTP клиент для парсинга внешних источников.
Изолирован от Django. Принимает все настройки через конструктор.
Поддерживает работу со списком прокси — при каждом запросе выбирается случайный.
Использование:
# Без прокси
client = BaseHTTPClient(base_url="https://api.example.com")
# С одним прокси
client = BaseHTTPClient(
base_url="https://api.example.com",
proxies=["http://proxy:8080"]
)
# Со списком прокси (выбор случайный)
client = BaseHTTPClient(
base_url="https://api.example.com",
proxies=["http://proxy1:8080", "http://proxy2:8080"]
)
"""
base_url: str
proxies: list[str] | None = None
timeout: int = 30
headers: dict[str, str] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Инициализация после создания dataclass."""
self._session: requests.Session | None = None
self._current_proxy: str | None = None
# Убираем trailing slash
self.base_url = self.base_url.rstrip("/")
def _select_proxy(self) -> str | None:
"""Выбрать случайный прокси из списка."""
if not self.proxies:
return None
return random.choice(self.proxies) # noqa: S311 - not for cryptographic use
@property
def session(self) -> requests.Session:
"""Ленивая инициализация сессии."""
if self._session is None:
self._session = self._create_session()
return self._session
def _create_session(self) -> requests.Session:
"""Создать и настроить сессию requests."""
session = requests.Session()
# Настройка прокси
self._current_proxy = self._select_proxy()
if self._current_proxy:
session.proxies = {
"http": self._current_proxy,
"https": self._current_proxy,
}
logger.debug("Proxy configured: %s", self._current_proxy)
# Базовые заголовки
default_headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Encoding": "gzip, deflate, br",
}
default_headers.update(self.headers)
session.headers.update(default_headers)
return session
def rotate_proxy(self) -> str | None:
"""
Сменить прокси на другой из списка.
Пересоздаёт сессию с новым прокси.
Returns:
Новый прокси или None
"""
if self._session is not None:
self._session.close()
self._session = None
self._current_proxy = self._select_proxy()
logger.info("Rotated proxy to: %s", self._current_proxy)
return self._current_proxy
@property
def current_proxy(self) -> str | None:
"""Текущий используемый прокси."""
return self._current_proxy
def _build_url(self, endpoint: str) -> str:
"""Построить полный URL."""
if endpoint.startswith(("http://", "https://")):
return endpoint
endpoint = endpoint.lstrip("/")
return f"{self.base_url}/{endpoint}"
def get(
self, endpoint: str, params: dict[str, Any] | None = None
) -> requests.Response:
"""
Выполнить GET запрос.
Args:
endpoint: Путь или полный URL
params: Query параметры
Returns:
Response объект
Raises:
ConnectionError: При ошибке подключения
HTTPError: При HTTP ошибке (4xx, 5xx)
"""
url = self._build_url(endpoint)
logger.info("GET %s (proxy: %s)", url, self._current_proxy)
try:
response = self.session.get(url, params=params, timeout=self.timeout)
except requests.exceptions.ConnectionError as e:
logger.error("Connection error: %s - %s", url, e)
raise ConnectionError(f"Failed to connect to {url}", url=url) from e
except requests.exceptions.Timeout as e:
logger.error("Timeout: %s", url)
raise ConnectionError(f"Request timeout for {url}", url=url) from e
except requests.exceptions.RequestException as e:
logger.error("Request error: %s - %s", url, e)
raise HTTPClientError(f"Request failed: {e}", url=url) from e
if not response.ok:
logger.error("HTTP error %d: %s", response.status_code, url)
raise HTTPError(
f"HTTP {response.status_code} for {url}",
status_code=response.status_code,
url=url,
)
logger.debug("Response %d from %s", response.status_code, url)
return response
def get_json(self, endpoint: str, params: dict[str, Any] | None = None) -> dict:
"""
Выполнить GET запрос и вернуть JSON.
Args:
endpoint: Путь или полный URL
params: Query параметры
Returns:
Распарсенный JSON как dict
"""
response = self.get(endpoint, params=params)
return response.json()
def download_file(self, endpoint: str) -> bytes:
"""
Скачать файл.
Args:
endpoint: Путь или полный URL файла
Returns:
Содержимое файла как bytes
"""
url = self._build_url(endpoint)
logger.info("Downloading file: %s", url)
response = self.get(endpoint)
content = response.content
logger.info("Downloaded %d bytes from %s", len(content), url)
return content
def close(self) -> None:
"""Закрыть сессию."""
if self._session is not None:
self._session.close()
self._session = None
logger.debug("Session closed")
def __enter__(self) -> "BaseHTTPClient":
"""Поддержка context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Закрытие при выходе из context manager."""
self.close()

View File

@@ -0,0 +1,18 @@
"""
Клиенты для парсинга данных с портала Минпромторга.
Источники:
- IndustrialProductionClient: сертификаты промышленного производства
- ManufacturesClient: реестр производителей
"""
from apps.parsers.clients.minpromtorg.industrial import IndustrialProductionClient
from apps.parsers.clients.minpromtorg.manufactures import ManufacturesClient
from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate, Manufacturer
__all__ = [
"IndustrialProductionClient",
"ManufacturesClient",
"IndustrialCertificate",
"Manufacturer",
]

View File

@@ -0,0 +1,225 @@
"""
Клиент для парсинга данных о промышленном производстве РФ.
Источник: Минпромторг, раздел "Заключения о подтверждении производства".
"""
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO
from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError
from apps.parsers.clients.minpromtorg.schemas import IndustrialCertificate
from openpyxl import load_workbook
logger = logging.getLogger(__name__)
# Конфигурация по умолчанию
DEFAULT_HOST = "minpromtorg.gov.ru"
DEFAULT_API_PATH = "/api/kss-document-preview"
DEFAULT_DOC_TYPE = "668d4f2a-966a-4b65-9fb9-2f1ad19a3d1f"
DEFAULT_QUERY = "Заключения о подтверждении производства промышленной продукции"
FILE_PATTERN = re.compile(r"data_resolutions_(\d{8})")
class IndustrialProductionClientError(HTTPClientError):
"""Ошибка клиента промышленного производства."""
pass
@dataclass
class IndustrialProductionClient:
"""
Клиент для получения данных о сертификатах промышленного производства.
Полностью изолирован от Django. Все настройки передаются через конструктор.
Использование:
# Без прокси
client = IndustrialProductionClient()
# Со списком прокси
client = IndustrialProductionClient(proxies=["http://proxy1:8080"])
certificates = client.fetch_certificates()
for cert in certificates:
print(cert.certificate_number, cert.organisation_name)
"""
proxies: list[str] | None = None
host: str = DEFAULT_HOST
api_path: str = DEFAULT_API_PATH
doc_type: str = DEFAULT_DOC_TYPE
query: str = DEFAULT_QUERY
timeout: int = 120
_http_client: BaseHTTPClient | None = field(default=None, repr=False)
def __post_init__(self) -> None:
"""Инициализация HTTP клиента."""
self._http_client = None
@property
def http_client(self) -> BaseHTTPClient:
"""Ленивая инициализация HTTP клиента."""
if self._http_client is None:
self._http_client = BaseHTTPClient(
base_url=f"https://{self.host}",
proxies=self.proxies,
timeout=self.timeout,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0",
"Accept": "application/json",
},
)
return self._http_client
def fetch_certificates(self) -> list[IndustrialCertificate]:
"""
Получить список сертификатов промышленного производства.
Процесс:
1. Запрос к API для получения списка файлов
2. Поиск последнего файла по дате
3. Скачивание и парсинг Excel файла
4. Преобразование в список IndustrialCertificate
Returns:
Список сертификатов
Raises:
IndustrialProductionClientError: При ошибке получения данных
"""
logger.info("Fetching industrial production certificates")
try:
# 1. Получить список файлов
files_data = self._fetch_files_list()
# 2. Найти последний файл
file_url = self._get_latest_file_url(files_data)
if not file_url:
logger.warning("No files found matching pattern")
return []
# 3. Скачать и распарсить Excel
certificates = self._download_and_parse(file_url)
logger.info("Fetched %d certificates", len(certificates))
return certificates
except HTTPClientError:
raise
except Exception as e:
logger.error("Error fetching certificates: %s", e)
raise IndustrialProductionClientError(
f"Failed to fetch certificates: {e}"
) from e
def _fetch_files_list(self) -> list[dict]:
"""Получить список файлов с API."""
params = {
"types[]": self.doc_type,
"fragment": self.query,
}
data = self.http_client.get_json(self.api_path, params=params)
# Найти документ с файлами
for item in data.get("data", []):
if self.query in item.get("name", ""):
return item.get("files", [])
return []
def _get_latest_file_url(self, files_data: list[dict]) -> str | None:
"""Найти URL последнего файла по дате в имени."""
if not files_data:
return None
latest_file = None
latest_date = None
for file_info in files_data:
name = file_info.get("name", "")
match = FILE_PATTERN.search(name)
if not match:
continue
try:
file_date = datetime.strptime(match.group(1), "%Y%m%d")
if latest_date is None or file_date > latest_date:
latest_date = file_date
latest_file = file_info
except ValueError:
continue
if latest_file:
url = latest_file.get("url", "")
logger.info(
"Latest file: %s (date: %s)", latest_file.get("name"), latest_date
)
# URL может быть относительным
if url and not url.startswith("http"):
return f"https://{self.host}{url}"
return url
return None
def _download_and_parse(self, file_url: str) -> list[IndustrialCertificate]:
"""Скачать Excel файл и распарсить его."""
logger.info("Downloading Excel file: %s", file_url)
content = self.http_client.download_file(file_url)
logger.info("Downloaded %d bytes", len(content))
excel_data = BytesIO(content)
wb = load_workbook(filename=excel_data, data_only=True)
ws = wb.active
certificates = []
# Пропускаем заголовок (первая строка)
# Колонки: Dateofcon, Numberofcon, Expirationdate, Document, Nameoforg, INN, OGRN
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or not any(row):
continue
cert = self._parse_row(row)
if cert:
certificates.append(cert)
wb.close()
return certificates
def _parse_row(self, row: tuple) -> IndustrialCertificate | None:
"""Преобразовать строку Excel в IndustrialCertificate."""
try:
# Порядок колонок в Excel:
# Dateofcon, Numberofcon, Expirationdate, Document, Nameoforg, INN, OGRN
return IndustrialCertificate(
issue_date=str(row[0] or ""),
certificate_number=str(row[1] or ""),
expiry_date=str(row[2] or ""),
certificate_file_url=str(row[3] or ""),
organisation_name=str(row[4] or ""),
inn=str(row[5] or ""),
ogrn=str(row[6] or ""),
)
except (IndexError, TypeError) as e:
logger.warning("Failed to parse row: %s - %s", row, e)
return None
def close(self) -> None:
"""Закрыть клиент и освободить ресурсы."""
if self._http_client is not None:
self._http_client.close()
self._http_client = None
def __enter__(self) -> "IndustrialProductionClient":
"""Поддержка context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Закрытие при выходе из context manager."""
self.close()

View File

@@ -0,0 +1,218 @@
"""
Клиент для парсинга реестра производителей Минпромторга.
Источник: Минпромторг, реестр производителей.
"""
import logging
import re
from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO
from apps.parsers.clients.base import BaseHTTPClient, HTTPClientError
from apps.parsers.clients.minpromtorg.schemas import Manufacturer
from openpyxl import load_workbook
logger = logging.getLogger(__name__)
# Конфигурация по умолчанию
DEFAULT_HOST = "minpromtorg.gov.ru"
DEFAULT_API_PATH = "/api/kss-document-preview"
DEFAULT_DOC_TYPE = "668d4f2a-966a-4b65-9fb9-2f1ad19a3d1f"
DEFAULT_QUERY = "Производители промышленной продукции"
FILE_PATTERN = re.compile(r"data_orgs_(\d{8})")
class ManufacturesClientError(HTTPClientError):
"""Ошибка клиента реестра производителей."""
pass
@dataclass
class ManufacturesClient:
"""
Клиент для получения данных о производителях из реестра Минпромторга.
Полностью изолирован от Django. Все настройки передаются через конструктор.
Использование:
# Без прокси
client = ManufacturesClient()
# Со списком прокси
client = ManufacturesClient(proxies=["http://proxy1:8080"])
manufacturers = client.fetch_manufacturers()
for m in manufacturers:
print(m.full_legal_name, m.inn)
"""
proxies: list[str] | None = None
host: str = DEFAULT_HOST
api_path: str = DEFAULT_API_PATH
doc_type: str = DEFAULT_DOC_TYPE
query: str = DEFAULT_QUERY
timeout: int = 120
_http_client: BaseHTTPClient | None = field(default=None, repr=False)
def __post_init__(self) -> None:
"""Инициализация HTTP клиента."""
self._http_client = None
@property
def http_client(self) -> BaseHTTPClient:
"""Ленивая инициализация HTTP клиента."""
if self._http_client is None:
self._http_client = BaseHTTPClient(
base_url=f"https://{self.host}",
proxies=self.proxies,
timeout=self.timeout,
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0",
"Accept": "application/json",
},
)
return self._http_client
def fetch_manufacturers(self) -> list[Manufacturer]:
"""
Получить список производителей из реестра.
Процесс:
1. Запрос к API для получения списка файлов
2. Поиск последнего файла по дате
3. Скачивание и парсинг Excel файла
4. Преобразование в список Manufacturer
Returns:
Список производителей
Raises:
ManufacturesClientError: При ошибке получения данных
"""
logger.info("Fetching manufacturers registry")
try:
# 1. Получить список файлов
files_data = self._fetch_files_list()
# 2. Найти последний файл
file_url = self._get_latest_file_url(files_data)
if not file_url:
logger.warning("No files found matching pattern")
return []
# 3. Скачать и распарсить Excel
manufacturers = self._download_and_parse(file_url)
logger.info("Fetched %d manufacturers", len(manufacturers))
return manufacturers
except HTTPClientError:
raise
except Exception as e:
logger.error("Error fetching manufacturers: %s", e)
raise ManufacturesClientError(f"Failed to fetch manufacturers: {e}") from e
def _fetch_files_list(self) -> list[dict]:
"""Получить список файлов с API."""
params = {
"types[]": self.doc_type,
"fragment": self.query,
}
data = self.http_client.get_json(self.api_path, params=params)
# Найти документ с файлами
for item in data.get("data", []):
if self.query in item.get("name", ""):
return item.get("files", [])
return []
def _get_latest_file_url(self, files_data: list[dict]) -> str | None:
"""Найти URL последнего файла по дате в имени."""
if not files_data:
return None
latest_file = None
latest_date = None
for file_info in files_data:
name = file_info.get("name", "")
match = FILE_PATTERN.search(name)
if not match:
continue
try:
file_date = datetime.strptime(match.group(1), "%Y%m%d")
if latest_date is None or file_date > latest_date:
latest_date = file_date
latest_file = file_info
except ValueError:
continue
if latest_file:
url = latest_file.get("url", "")
logger.info(
"Latest file: %s (date: %s)", latest_file.get("name"), latest_date
)
if url and not url.startswith("http"):
return f"https://{self.host}{url}"
return url
return None
def _download_and_parse(self, file_url: str) -> list[Manufacturer]:
"""Скачать Excel файл и распарсить его."""
logger.info("Downloading Excel file: %s", file_url)
content = self.http_client.download_file(file_url)
logger.info("Downloaded %d bytes", len(content))
excel_data = BytesIO(content)
wb = load_workbook(filename=excel_data, data_only=True)
ws = wb.active
manufacturers = []
# Пропускаем заголовок (первая строка)
for row in ws.iter_rows(min_row=2, values_only=True):
if not row or not any(row):
continue
manufacturer = self._parse_row(row)
if manufacturer:
manufacturers.append(manufacturer)
wb.close()
return manufacturers
def _parse_row(self, row: tuple) -> Manufacturer | None:
"""Преобразовать строку Excel в Manufacturer."""
try:
# Порядок колонок в Excel:
# full_legal_name, inn, ogrn, address
return Manufacturer(
full_legal_name=str(row[0] or ""),
inn=str(row[1] or ""),
ogrn=str(row[2] or ""),
address=str(row[3] or "") if len(row) > 3 else "",
)
except (IndexError, TypeError) as e:
logger.warning("Failed to parse row: %s - %s", row, e)
return None
def close(self) -> None:
"""Закрыть клиент и освободить ресурсы."""
if self._http_client is not None:
self._http_client.close()
self._http_client = None
def __enter__(self) -> "ManufacturesClient":
"""Поддержка context manager."""
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Закрытие при выходе из context manager."""
self.close()

View File

@@ -0,0 +1,59 @@
"""
Dataclass схемы для данных Минпромторга.
Эти классы представляют данные, возвращаемые клиентами.
Они не зависят от Django ORM и используются как DTO.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class IndustrialCertificate:
"""
Сертификат промышленного производства РФ.
Источник: Минпромторг, раздел "Промышленное производство".
"""
issue_date: str
"""Дата выдачи сертификата."""
certificate_number: str
"""Номер сертификата."""
expiry_date: str
"""Дата окончания действия."""
certificate_file_url: str
"""URL файла сертификата."""
organisation_name: str
"""Наименование организации."""
inn: str
"""ИНН организации."""
ogrn: str
"""ОГРН организации."""
@dataclass(frozen=True)
class Manufacturer:
"""
Производитель из реестра Минпромторга.
Источник: Минпромторг, реестр производителей.
"""
full_legal_name: str
"""Полное наименование организации."""
inn: str
"""ИНН организации."""
ogrn: str
"""ОГРН организации."""
address: str
"""Адрес организации."""

View File

@@ -0,0 +1,14 @@
"""
Клиенты для proverki.gov.ru - Единый реестр проверок.
Источник: ФГИС "Единый реестр проверок" (Генпрокуратура РФ).
"""
from apps.parsers.clients.proverki.client import ProverkiClient
from apps.parsers.clients.proverki.schemas import Inspection, InspectionPlan
__all__ = [
"ProverkiClient",
"Inspection",
"InspectionPlan",
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
"""
Dataclass схемы для данных proverki.gov.ru.
Эти классы представляют данные о проверках, возвращаемые клиентом.
Они не зависят от Django ORM и используются как DTO.
"""
from dataclasses import dataclass
@dataclass(frozen=True)
class Inspection:
"""
Проверка из Единого реестра проверок.
Источник: ФГИС "Единый реестр проверок" (proverki.gov.ru).
Содержит данные о проведённых или запланированных проверках
юридических лиц и индивидуальных предпринимателей.
Поддерживает два типа проверок:
- ФЗ-294 (традиционные проверки)
- ФЗ-248 (новые проверки с 2021 года)
"""
registration_number: str
"""Учётный номер проверки в реестре."""
inn: str
"""ИНН проверяемого лица."""
ogrn: str
"""ОГРН проверяемого лица."""
organisation_name: str
"""Наименование проверяемого лица."""
control_authority: str
"""Наименование контрольного (надзорного) органа."""
inspection_type: str
"""Тип проверки (плановая/внеплановая)."""
inspection_form: str
"""Форма проверки (документарная/выездная)."""
start_date: str
"""Дата начала проверки (строка формата YYYY-MM-DD или DD.MM.YYYY)."""
end_date: str
"""Дата окончания проверки."""
status: str
"""Статус проверки."""
legal_basis: str
"""Правовое основание проверки (ФЗ-294, ФЗ-248 и т.д.)."""
result: str = ""
"""Результат проверки (если завершена)."""
is_federal_law_248: bool = False
"""Признак проверки по ФЗ-248 (новые проверки с 2021 года)."""
@dataclass(frozen=True)
class InspectionPlan:
"""
План проверок на определённый период.
Содержит метаданные о плане проверок (год, период, количество записей).
"""
year: int
"""Год плана проверок."""
month: int | None
"""Месяц (если план помесячный, иначе None)."""
file_url: str
"""URL файла с данными плана."""
file_name: str
"""Имя файла."""
records_count: int = 0
"""Количество записей в плане (если известно)."""
file_format: str = "xml"
"""Формат файла (xml, csv, xlsx)."""