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

363
src/apps/parsers/models.py Normal file
View File

@@ -0,0 +1,363 @@
"""
Модели для приложения парсеров.
Используют миксины из apps.core для стандартных полей и поведения.
"""
from apps.core.mixins import TimestampMixin
from django.db import models
from django.utils.translation import gettext_lazy as _
class ParserLoadLog(TimestampMixin, models.Model):
"""
Лог загрузок парсеров.
Хранит информацию о каждой загрузке данных из внешнего источника.
"""
class Source(models.TextChoices):
INDUSTRIAL = "industrial", _("Промышленное производство")
MANUFACTURES = "manufactures", _("Реестр производителей")
INSPECTIONS = "inspections", _("Единый реестр проверок")
batch_id = models.PositiveIntegerField(
_("ID пакета"),
db_index=True,
help_text=_("Уникальный идентификатор пакета загрузки"),
)
source = models.CharField(
_("источник"),
max_length=50,
choices=Source.choices,
db_index=True,
help_text=_("Источник данных"),
)
records_count = models.PositiveIntegerField(
_("количество записей"),
default=0,
help_text=_("Количество загруженных записей"),
)
status = models.CharField(
_("статус"),
max_length=20,
default="success",
help_text=_("Статус загрузки"),
)
error_message = models.TextField(
_("сообщение об ошибке"),
blank=True,
help_text=_("Текст ошибки при неудачной загрузке"),
)
class Meta:
db_table = "parsers_load_log"
verbose_name = _("лог загрузки")
verbose_name_plural = _("логи загрузок")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["source", "batch_id"]),
]
def __str__(self) -> str:
return f"Load #{self.batch_id} ({self.source}) - {self.records_count} records"
class IndustrialCertificateRecord(TimestampMixin, models.Model):
"""
Сертификат промышленного производства РФ.
Данные загружаются из Минпромторга.
"""
load_batch = models.PositiveIntegerField(
_("ID пакета загрузки"),
db_index=True,
help_text=_("Идентификатор пакета загрузки"),
)
issue_date = models.CharField(
_("дата выдачи"),
max_length=15,
blank=True,
help_text=_("Дата выдачи сертификата"),
)
certificate_number = models.CharField(
_("номер сертификата"),
max_length=100,
db_index=True,
help_text=_("Номер сертификата"),
)
expiry_date = models.CharField(
_("дата окончания"),
max_length=15,
blank=True,
help_text=_("Дата окончания действия"),
)
certificate_file_url = models.TextField(
_("URL файла"),
blank=True,
help_text=_("Ссылка на файл сертификата"),
)
organisation_name = models.TextField(
_("наименование организации"),
help_text=_("Название организации"),
)
inn = models.CharField(
_("ИНН"),
max_length=20,
db_index=True,
help_text=_("ИНН организации"),
)
ogrn = models.CharField(
_("ОГРН"),
max_length=20,
db_index=True,
help_text=_("ОГРН организации"),
)
class Meta:
db_table = "parsers_industrial_certificate"
verbose_name = _("сертификат промпроизводства")
verbose_name_plural = _("сертификаты промпроизводства")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["inn", "certificate_number"]),
models.Index(fields=["load_batch", "inn"]),
]
constraints = [
models.UniqueConstraint(
fields=["certificate_number"],
name="unique_certificate_number",
),
]
def __str__(self) -> str:
return f"{self.certificate_number} - {self.organisation_name[:50]}"
class ManufacturerRecord(TimestampMixin, models.Model):
"""
Производитель из реестра Минпромторга.
Данные загружаются из Минпромторга.
"""
load_batch = models.PositiveIntegerField(
_("ID пакета загрузки"),
db_index=True,
help_text=_("Идентификатор пакета загрузки"),
)
full_legal_name = models.TextField(
_("полное наименование"),
help_text=_("Полное юридическое наименование организации"),
)
inn = models.CharField(
_("ИНН"),
max_length=15,
db_index=True,
help_text=_("ИНН организации"),
)
ogrn = models.CharField(
_("ОГРН"),
max_length=15,
db_index=True,
help_text=_("ОГРН организации"),
)
address = models.TextField(
_("адрес"),
blank=True,
help_text=_("Юридический адрес организации"),
)
class Meta:
db_table = "parsers_manufacturer"
verbose_name = _("производитель")
verbose_name_plural = _("производители")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["load_batch", "inn"]),
]
constraints = [
models.UniqueConstraint(
fields=["inn"],
name="unique_manufacturer_inn",
),
]
def __str__(self) -> str:
return f"{self.inn} - {self.full_legal_name[:50]}"
class Proxy(TimestampMixin, models.Model):
"""
Прокси-сервер для парсеров.
Хранит список доступных прокси для использования в клиентах.
"""
address = models.CharField(
_("адрес"),
max_length=255,
unique=True,
help_text=_("Адрес прокси (например: http://proxy:8080)"),
)
is_active = models.BooleanField(
_("активен"),
default=True,
db_index=True,
help_text=_("Доступен ли прокси для использования"),
)
last_used_at = models.DateTimeField(
_("последнее использование"),
null=True,
blank=True,
help_text=_("Время последнего использования"),
)
fail_count = models.PositiveIntegerField(
_("количество ошибок"),
default=0,
help_text=_("Количество неудачных попыток подключения"),
)
description = models.CharField(
_("описание"),
max_length=255,
blank=True,
help_text=_("Описание прокси (провайдер, локация и т.д.)"),
)
class Meta:
db_table = "parsers_proxy"
verbose_name = _("прокси")
verbose_name_plural = _("прокси")
ordering = ["fail_count", "-last_used_at"]
def __str__(self) -> str:
status = "active" if self.is_active else "inactive"
return f"{self.address} ({status})"
class InspectionRecord(TimestampMixin, models.Model):
"""
Проверка из Единого реестра проверок (proverki.gov.ru).
Данные загружаются из ФГИС "Единый реестр проверок" (Генпрокуратура РФ).
Поддерживает два типа проверок:
- ФЗ-294 (традиционные проверки)
- ФЗ-248 (новые проверки с 2021 года)
"""
load_batch = models.PositiveIntegerField(
_("ID пакета загрузки"),
db_index=True,
help_text=_("Идентификатор пакета загрузки"),
)
registration_number = models.CharField(
_("учётный номер"),
max_length=100,
db_index=True,
help_text=_("Учётный номер проверки в реестре"),
)
inn = models.CharField(
_("ИНН"),
max_length=20,
db_index=True,
help_text=_("ИНН проверяемого лица"),
)
ogrn = models.CharField(
_("ОГРН"),
max_length=20,
db_index=True,
blank=True,
help_text=_("ОГРН проверяемого лица"),
)
organisation_name = models.TextField(
_("наименование организации"),
help_text=_("Наименование проверяемого лица"),
)
control_authority = models.TextField(
_("контрольный орган"),
blank=True,
help_text=_("Наименование контрольного (надзорного) органа"),
)
inspection_type = models.CharField(
_("тип проверки"),
max_length=100,
blank=True,
help_text=_("Тип проверки (плановая/внеплановая)"),
)
inspection_form = models.CharField(
_("форма проверки"),
max_length=100,
blank=True,
help_text=_("Форма проверки (документарная/выездная)"),
)
start_date = models.CharField(
_("дата начала"),
max_length=20,
blank=True,
help_text=_("Дата начала проверки"),
)
end_date = models.CharField(
_("дата окончания"),
max_length=20,
blank=True,
help_text=_("Дата окончания проверки"),
)
status = models.CharField(
_("статус"),
max_length=100,
blank=True,
help_text=_("Статус проверки"),
)
legal_basis = models.CharField(
_("правовое основание"),
max_length=255,
blank=True,
help_text=_("Правовое основание проверки (ФЗ-294, ФЗ-248)"),
)
result = models.TextField(
_("результат"),
blank=True,
help_text=_("Результат проверки"),
)
is_federal_law_248 = models.BooleanField(
_("по ФЗ-248"),
default=False,
db_index=True,
help_text=_("Проверка по ФЗ-248 (новые проверки с 2021 года)"),
)
data_year = models.PositiveSmallIntegerField(
_("год данных"),
db_index=True,
null=True,
blank=True,
help_text=_("Год, за который загружены данные"),
)
data_month = models.PositiveSmallIntegerField(
_("месяц данных"),
db_index=True,
null=True,
blank=True,
help_text=_("Месяц, за который загружены данные"),
)
class Meta:
db_table = "parsers_inspection"
verbose_name = _("проверка")
verbose_name_plural = _("проверки")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["inn", "registration_number"]),
models.Index(fields=["load_batch", "inn"]),
models.Index(fields=["is_federal_law_248", "data_year", "data_month"]),
]
constraints = [
models.UniqueConstraint(
fields=["registration_number"],
name="unique_inspection_registration_number",
),
]
def __str__(self) -> str:
org_name = self.organisation_name[:50] if self.organisation_name else ""
return f"{self.registration_number} - {org_name}"