feat(parsers): add proverki.gov.ru parser with sync_inspections task
- 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:
22
src/apps/parsers/clients/__init__.py
Normal file
22
src/apps/parsers/clients/__init__.py
Normal 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",
|
||||
]
|
||||
238
src/apps/parsers/clients/base.py
Normal file
238
src/apps/parsers/clients/base.py
Normal 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()
|
||||
18
src/apps/parsers/clients/minpromtorg/__init__.py
Normal file
18
src/apps/parsers/clients/minpromtorg/__init__.py
Normal 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",
|
||||
]
|
||||
225
src/apps/parsers/clients/minpromtorg/industrial.py
Normal file
225
src/apps/parsers/clients/minpromtorg/industrial.py
Normal 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()
|
||||
218
src/apps/parsers/clients/minpromtorg/manufactures.py
Normal file
218
src/apps/parsers/clients/minpromtorg/manufactures.py
Normal 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()
|
||||
59
src/apps/parsers/clients/minpromtorg/schemas.py
Normal file
59
src/apps/parsers/clients/minpromtorg/schemas.py
Normal 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
|
||||
"""Адрес организации."""
|
||||
14
src/apps/parsers/clients/proverki/__init__.py
Normal file
14
src/apps/parsers/clients/proverki/__init__.py
Normal 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",
|
||||
]
|
||||
1102
src/apps/parsers/clients/proverki/client.py
Normal file
1102
src/apps/parsers/clients/proverki/client.py
Normal file
File diff suppressed because it is too large
Load Diff
90
src/apps/parsers/clients/proverki/schemas.py
Normal file
90
src/apps/parsers/clients/proverki/schemas.py
Normal 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)."""
|
||||
Reference in New Issue
Block a user