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:
580
src/apps/parsers/tasks.py
Normal file
580
src/apps/parsers/tasks.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user