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

580
src/apps/parsers/tasks.py Normal file
View File

@@ -0,0 +1,580 @@
"""
Celery задачи для приложения парсеров.
Задачи являются тонкими обёртками над сервисами и клиентами.
Интегрируются с BackgroundJob для отслеживания прогресса.
"""
import logging
from datetime import datetime
from apps.core.services import BackgroundJobService
from apps.parsers.clients.minpromtorg import (
IndustrialProductionClient,
ManufacturesClient,
)
from apps.parsers.clients.proverki import ProverkiClient
from apps.parsers.models import ParserLoadLog
from apps.parsers.services import (
IndustrialCertificateService,
InspectionService,
ManufacturerService,
ParserLoadLogService,
ProxyService,
)
from celery import shared_task
logger = logging.getLogger(__name__)
# Константы для синхронизации проверок
DEFAULT_START_YEAR = 2025
DEFAULT_START_MONTH = 1
@shared_task(bind=True)
def parse_industrial_production(self, proxies: list[str] | None = None) -> dict:
"""
Задача парсинга сертификатов промышленного производства.
Args:
proxies: Список прокси-серверов (опционально).
Если не передан, берётся из БД.
Returns:
Результат: batch_id, saved, status
"""
source = ParserLoadLog.Source.INDUSTRIAL
batch_id = ParserLoadLogService.get_next_batch_id(source)
task_id = self.request.id
# Если прокси не переданы, берём из БД
if proxies is None:
proxies = ProxyService.get_active_proxies_or_none()
logger.info(
"Starting industrial production parsing (task_id=%s, batch_id=%d, proxies=%d)",
task_id,
batch_id,
len(proxies) if proxies else 0,
)
# Создаём запись BackgroundJob для отслеживания прогресса
job = BackgroundJobService.create_job(
task_id=task_id,
task_name="apps.parsers.tasks.parse_industrial_production",
meta={"source": source, "batch_id": batch_id},
)
job.mark_started()
job.update_progress(0, "Инициализация парсера...")
# Создаём запись лога
load_log = ParserLoadLogService.create_load_log(
source=source,
batch_id=batch_id,
status="in_progress",
)
try:
# Парсинг данных
job.update_progress(10, "Загрузка данных с API Минпромторга...")
with IndustrialProductionClient(proxies=proxies) as client:
certificates = client.fetch_certificates()
# Сохранение в БД
job.update_progress(50, f"Сохранение {len(certificates)} сертификатов...")
saved_count = IndustrialCertificateService.save_certificates(
certificates,
batch_id=batch_id,
)
# Обновляем лог
ParserLoadLogService.update(
load_log,
status="success",
records_count=saved_count,
)
# Завершаем BackgroundJob
job.complete(result={"batch_id": batch_id, "saved": saved_count})
logger.info(
"Industrial production parsing completed (batch_id=%d, saved=%d)",
batch_id,
saved_count,
)
return {
"batch_id": batch_id,
"saved": saved_count,
"status": "success",
}
except Exception as e:
logger.error("Industrial production parsing failed: %s", e, exc_info=True)
ParserLoadLogService.mark_failed(load_log, str(e))
job.fail(error=str(e))
return {
"batch_id": batch_id,
"saved": 0,
"status": "failed",
"error": str(e),
}
@shared_task(bind=True)
def parse_manufactures(self, proxies: list[str] | None = None) -> dict:
"""
Задача парсинга реестра производителей.
Args:
proxies: Список прокси-серверов (опционально).
Если не передан, берётся из БД.
Returns:
Результат: batch_id, saved, status
"""
source = ParserLoadLog.Source.MANUFACTURES
batch_id = ParserLoadLogService.get_next_batch_id(source)
task_id = self.request.id
# Если прокси не переданы, берём из БД
if proxies is None:
proxies = ProxyService.get_active_proxies_or_none()
logger.info(
"Starting manufactures parsing (task_id=%s, batch_id=%d, proxies=%d)",
task_id,
batch_id,
len(proxies) if proxies else 0,
)
# Создаём запись BackgroundJob для отслеживания прогресса
job = BackgroundJobService.create_job(
task_id=task_id,
task_name="apps.parsers.tasks.parse_manufactures",
meta={"source": source, "batch_id": batch_id},
)
job.mark_started()
job.update_progress(0, "Инициализация парсера...")
# Создаём запись лога
load_log = ParserLoadLogService.create_load_log(
source=source,
batch_id=batch_id,
status="in_progress",
)
try:
# Парсинг данных
job.update_progress(10, "Загрузка данных с API Минпромторга...")
with ManufacturesClient(proxies=proxies) as client:
manufacturers = client.fetch_manufacturers()
# Сохранение в БД
job.update_progress(50, f"Сохранение {len(manufacturers)} производителей...")
saved_count = ManufacturerService.save_manufacturers(
manufacturers,
batch_id=batch_id,
)
# Обновляем лог
ParserLoadLogService.update(
load_log,
status="success",
records_count=saved_count,
)
# Завершаем BackgroundJob
job.complete(result={"batch_id": batch_id, "saved": saved_count})
logger.info(
"Manufactures parsing completed (batch_id=%d, saved=%d)",
batch_id,
saved_count,
)
return {
"batch_id": batch_id,
"saved": saved_count,
"status": "success",
}
except Exception as e:
logger.error("Manufactures parsing failed: %s", e, exc_info=True)
ParserLoadLogService.mark_failed(load_log, str(e))
job.fail(error=str(e))
return {
"batch_id": batch_id,
"saved": 0,
"status": "failed",
"error": str(e),
}
@shared_task
def parse_all_minpromtorg(proxies: list[str] | None = None) -> dict:
"""
Запустить все парсеры Минпромторга.
Args:
proxies: Список прокси-серверов (опционально).
Если не передан, каждая задача возьмёт прокси из БД.
Returns:
Результаты всех парсеров
"""
logger.info("Starting all Minpromtorg parsers")
results = {
"industrial": parse_industrial_production.delay(proxies=proxies).id,
"manufactures": parse_manufactures.delay(proxies=proxies).id,
}
return results
@shared_task(bind=True)
def parse_inspections(
self,
*,
year: int | None = None,
month: int | None = None,
file_url: str | None = None,
proxies: list[str] | None = None,
) -> dict:
"""
Задача парсинга данных о проверках с proverki.gov.ru.
Args:
year: Год плана проверок (опционально)
month: Месяц (опционально)
file_url: Прямая ссылка на файл данных (опционально)
proxies: Список прокси-серверов (опционально).
Если не передан, берётся из БД.
Returns:
Результат: batch_id, saved, status
"""
source = ParserLoadLog.Source.INSPECTIONS
batch_id = ParserLoadLogService.get_next_batch_id(source)
task_id = self.request.id
# Если прокси не переданы, берём из БД
if proxies is None:
proxies = ProxyService.get_active_proxies_or_none()
logger.info(
"Starting inspections parsing (task_id=%s, batch_id=%d, year=%s, month=%s, proxies=%d)",
task_id,
batch_id,
year,
month,
len(proxies) if proxies else 0,
)
# Создаём запись BackgroundJob для отслеживания прогресса
job = BackgroundJobService.create_job(
task_id=task_id,
task_name="apps.parsers.tasks.parse_inspections",
meta={"source": source, "batch_id": batch_id, "year": year, "month": month},
)
job.mark_started()
job.update_progress(0, "Инициализация парсера...")
# Создаём запись лога
load_log = ParserLoadLogService.create_load_log(
source=source,
batch_id=batch_id,
status="in_progress",
)
def progress_callback(percent: int, message: str) -> None:
"""Callback для обновления прогресса."""
job.update_progress(percent, message)
try:
# Парсинг данных
job.update_progress(10, "Загрузка данных с proverki.gov.ru...")
with ProverkiClient(proxies=proxies) as client:
inspections = client.fetch_inspections(
year=year,
month=month,
file_url=file_url,
progress_callback=progress_callback,
)
# Сохранение в БД
job.update_progress(80, f"Сохранение {len(inspections)} проверок...")
saved_count = InspectionService.save_inspections(
inspections,
batch_id=batch_id,
)
# Обновляем лог
ParserLoadLogService.update(
load_log,
status="success",
records_count=saved_count,
)
# Завершаем BackgroundJob
job.complete(result={"batch_id": batch_id, "saved": saved_count})
logger.info(
"Inspections parsing completed (batch_id=%d, saved=%d)",
batch_id,
saved_count,
)
return {
"batch_id": batch_id,
"saved": saved_count,
"status": "success",
}
except Exception as e:
logger.error("Inspections parsing failed: %s", e, exc_info=True)
ParserLoadLogService.mark_failed(load_log, str(e))
job.fail(error=str(e))
return {
"batch_id": batch_id,
"saved": 0,
"status": "failed",
"error": str(e),
}
@shared_task
def parse_all_sources(proxies: list[str] | None = None) -> dict:
"""
Запустить все парсеры из всех источников.
Args:
proxies: Список прокси-серверов (опционально).
Если не передан, каждая задача возьмёт прокси из БД.
Returns:
Task IDs всех запущенных парсеров
"""
logger.info("Starting all parsers from all sources")
results = {
"industrial": parse_industrial_production.delay(proxies=proxies).id,
"manufactures": parse_manufactures.delay(proxies=proxies).id,
"inspections": parse_inspections.delay(proxies=proxies).id,
}
return results
def _get_next_month(year: int, month: int) -> tuple[int, int]:
"""Получить следующий месяц."""
if month == 12:
return year + 1, 1
return year, month + 1
@shared_task(bind=True)
def sync_inspections( # noqa: C901
self,
*,
proxies: list[str] | None = None,
) -> dict:
"""
Синхронизация данных о проверках с proverki.gov.ru.
Логика работы:
1. Проверяет последнюю загруженную дату в БД
2. Если данных нет - начинает с 01.01.2025
3. Загружает месяц за месяцем до конца текущего года
4. Загружает оба типа проверок (ФЗ-294 и ФЗ-248)
5. Если данных нет за период - прекращает загрузку для этого типа
Args:
proxies: Список прокси-серверов (опционально)
Returns:
Результат синхронизации
"""
source = ParserLoadLog.Source.INSPECTIONS
batch_id = ParserLoadLogService.get_next_batch_id(source)
task_id = self.request.id
# Если прокси не переданы, берём из БД
if proxies is None:
proxies = ProxyService.get_active_proxies_or_none()
logger.info(
"Starting inspections sync (task_id=%s, batch_id=%d)", task_id, batch_id
)
# Создаём запись BackgroundJob
job = BackgroundJobService.create_job(
task_id=task_id,
task_name="apps.parsers.tasks.sync_inspections",
meta={"source": source, "batch_id": batch_id},
)
job.mark_started()
job.update_progress(0, "Инициализация синхронизации...")
# Создаём запись лога
load_log = ParserLoadLogService.create_load_log(
source=source,
batch_id=batch_id,
status="in_progress",
)
current_year = datetime.now().year
current_month = datetime.now().month
total_saved = 0
results = {"fz294": [], "fz248": []}
try:
with ProverkiClient(proxies=proxies) as client:
# Обрабатываем оба типа проверок
for is_fz248 in [False, True]:
fz_key = "fz248" if is_fz248 else "fz294"
fz_name = "ФЗ-248" if is_fz248 else "ФЗ-294"
# Определяем начальную точку
last_year, last_month = InspectionService.get_last_loaded_period(
is_federal_law_248=is_fz248
)
if last_year and last_month:
# Начинаем со следующего месяца после последнего загруженного
start_year, start_month = _get_next_month(last_year, last_month)
logger.info(
"%s: continuing from %d/%d (last loaded: %d/%d)",
fz_name,
start_year,
start_month,
last_year,
last_month,
)
else:
# Начинаем с дефолтной даты
start_year, start_month = DEFAULT_START_YEAR, DEFAULT_START_MONTH
logger.info(
"%s: no data in DB, starting from %d/%d",
fz_name,
start_year,
start_month,
)
# Загружаем месяц за месяцем
year, month = start_year, start_month
empty_months_count = 0
while year < current_year or (
year == current_year and month <= current_month
):
# Прекращаем если 2 месяца подряд нет данных
if empty_months_count >= 2:
logger.info(
"%s: stopping after %d empty months",
fz_name,
empty_months_count,
)
break
job.update_progress(
20 + (50 if is_fz248 else 0),
f"Загрузка {fz_name} за {month:02d}/{year}...",
)
try:
inspections = client.fetch_inspections(
year=year,
month=month,
is_federal_law_248=is_fz248,
)
if inspections:
saved = InspectionService.save_inspections(
inspections,
batch_id=batch_id,
is_federal_law_248=is_fz248,
data_year=year,
data_month=month,
)
total_saved += saved
results[fz_key].append(
{
"year": year,
"month": month,
"fetched": len(inspections),
"saved": saved,
}
)
empty_months_count = 0
logger.info(
"%s %d/%d: fetched %d, saved %d",
fz_name,
year,
month,
len(inspections),
saved,
)
else:
empty_months_count += 1
logger.info(
"%s %d/%d: no data found (empty_count=%d)",
fz_name,
year,
month,
empty_months_count,
)
except Exception as e:
logger.warning(
"%s %d/%d: error - %s",
fz_name,
year,
month,
str(e),
)
empty_months_count += 1
# Переходим к следующему месяцу
year, month = _get_next_month(year, month)
# Обновляем лог
ParserLoadLogService.update(
load_log,
status="success",
records_count=total_saved,
)
# Завершаем BackgroundJob
job.complete(
result={
"batch_id": batch_id,
"total_saved": total_saved,
"results": results,
}
)
logger.info("Inspections sync completed (total_saved=%d)", total_saved)
return {
"batch_id": batch_id,
"total_saved": total_saved,
"status": "success",
"results": results,
}
except Exception as e:
logger.error("Inspections sync failed: %s", e, exc_info=True)
ParserLoadLogService.mark_failed(load_log, str(e))
job.fail(error=str(e))
return {
"batch_id": batch_id,
"total_saved": total_saved,
"status": "failed",
"error": str(e),
}