mirror of
https://github.com/ada-dmitry/osint_parser_ada.git
synced 2026-09-24 01:10:20 +00:00
feat: Add data models for disciplinary actions, organizations, and training centers
- Implemented DisciplinaryAction model for tracking disciplinary measures. - Created Organization model to represent auditing organizations with relevant details. - Developed TrainingCenter model for educational and methodological centers. - Added methods to convert models to dictionaries for easy export. feat: Implement parsers for auditors and organizations - Developed AuditorsParser to scrape and parse auditor registry data. - Created OrganizationsParser for scraping auditing organizations' registry. - Implemented GenericRegistryParser for handling generic tabular data. chore: Set up base parser functionality - Established BaseParser class with common methods for making requests and parsing HTML. - Added pagination handling and detailed page parsing capabilities. feat: Implement Excel export functionality - Created ExcelExporter class for exporting data to Excel files with formatting options. - Added support for exporting multiple sheets and organization data specifically. chore: Add logging utilities - Implemented setup_logger function for consistent logging across the application. - Configured logging to output to both console and file. chore: Update requirements and scripts - Added necessary dependencies for parsing, Excel handling, and logging. - Created run.sh and run.fish scripts for easy execution of the parser.
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# Gitignore для парсера СРО ААС
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Виртуальное окружение
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Данные и логи
|
||||||
|
data/exports/*.xlsx
|
||||||
|
data/exports/*.csv
|
||||||
|
logs/*.log
|
||||||
|
|
||||||
|
# Временные файлы
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Конфигурация (если содержит секреты)
|
||||||
|
# config_local.py
|
||||||
|
|
||||||
|
# Тестовые данные
|
||||||
|
test_data/
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Парсер реестров СРО ААС
|
||||||
|
|
||||||
|
Автоматизированный инструмент для сбора данных из реестров Саморегулируемой организации аудиторов Ассоциация «Содружество» (СРО ААС).
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Парсинг всех 14 официальных реестров СРО ААС
|
||||||
|
- Два режима работы: быстрое сканирование (первая страница) и полное сканирование (все страницы)
|
||||||
|
- Экспорт данных в формат Excel (.xlsx) с форматированием
|
||||||
|
- Система логирования и обработки ошибок
|
||||||
|
- Поддержка пагинации и повторных попыток при сбоях
|
||||||
|
|
||||||
|
## Поддерживаемые реестры
|
||||||
|
|
||||||
|
**Действующие члены:**
|
||||||
|
1. Реестр аудиторов и индивидуальных аудиторов (14,691+ записей)
|
||||||
|
2. Реестр аудиторских организаций (256+ записей)
|
||||||
|
3. Реестр квалификационных аттестатов
|
||||||
|
4. Перечень индивидуальных аудиторов
|
||||||
|
5. Реестр учебно-методических центров
|
||||||
|
6. Сведения о подтверждении ОППК
|
||||||
|
7. Сведения о прохождении ПК руководителем аудита ОЗО ФР
|
||||||
|
|
||||||
|
**Исключенные члены:**
|
||||||
|
8. Аудиторы, прекратившие членство
|
||||||
|
9. Аудиторские организации, прекратившие членство
|
||||||
|
10. Аннулированные квалификационные аттестаты
|
||||||
|
11. УМЦ, исключенные из реестра
|
||||||
|
|
||||||
|
**Дисциплинарные меры:**
|
||||||
|
12. Меры воздействия к аудиторам
|
||||||
|
13. Меры воздействия к организациям
|
||||||
|
|
||||||
|
**Сети:**
|
||||||
|
14. Российские и международные сети аудиторских организаций
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Клонирование репозитория
|
||||||
|
git clone <repository-url>
|
||||||
|
cd osint_parser_ada
|
||||||
|
|
||||||
|
# Создание виртуального окружения
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# или
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Установка зависимостей
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Интерактивный режим
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Выберите реестр из меню и режим парсинга:
|
||||||
|
- `n` (No) — быстрое сканирование (только первая страница, ~50 записей, 1-2 сек)
|
||||||
|
- `y` (Yes) — полное сканирование (все страницы, все записи, несколько минут)
|
||||||
|
|
||||||
|
### Программный режим
|
||||||
|
|
||||||
|
```python
|
||||||
|
from parsers.auditors_parser import AuditorsParser
|
||||||
|
from utils.excel_exporter import ExcelExporter
|
||||||
|
|
||||||
|
# Быстрое сканирование
|
||||||
|
parser = AuditorsParser()
|
||||||
|
data = parser.parse_registry(detailed=False) # Только первая страница
|
||||||
|
|
||||||
|
# Полное сканирование
|
||||||
|
data = parser.parse_registry(detailed=True) # Все страницы
|
||||||
|
|
||||||
|
# Экспорт в Excel
|
||||||
|
exporter = ExcelExporter()
|
||||||
|
exporter.export_to_excel(data, "auditors", "output.xlsx")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
osint_parser_ada/
|
||||||
|
├── config.py # Конфигурация и URL реестров
|
||||||
|
├── main.py # CLI-интерфейс
|
||||||
|
├── parsers/ # Парсеры реестров
|
||||||
|
│ ├── base_parser.py # Базовый класс с пагинацией
|
||||||
|
│ ├── auditors_parser.py # Парсер аудиторов
|
||||||
|
│ ├── organizations_parser.py
|
||||||
|
│ └── generic_parser.py # Универсальный парсер
|
||||||
|
├── models/ # Модели данных
|
||||||
|
│ ├── organization.py
|
||||||
|
│ ├── auditor.py
|
||||||
|
│ └── ...
|
||||||
|
├── utils/ # Утилиты
|
||||||
|
│ ├── logger.py # Логирование
|
||||||
|
│ └── excel_exporter.py # Экспорт в Excel
|
||||||
|
└── data/exports/ # Экспортированные файлы
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Технические характеристики
|
||||||
|
|
||||||
|
- **Python:** 3.8+
|
||||||
|
- **Зависимости:** requests, beautifulsoup4, lxml, pandas, openpyxl
|
||||||
|
- **Архитектура:** Паттерн Template Method, модульная структура
|
||||||
|
- **Обработка ошибок:** Повторные попытки (3x), логирование всех операций
|
||||||
|
- **Производительность:** ~1.5 сек/страница с задержками для защиты от блокировки
|
||||||
|
|
||||||
|
## Конфигурация
|
||||||
|
|
||||||
|
Основные настройки в `config.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PARSER_CONFIG = {
|
||||||
|
"user_agent": "Mozilla/5.0...",
|
||||||
|
"timeout": 30, # Таймаут запроса (сек)
|
||||||
|
"delay_between_requests": 1, # Задержка между запросами (сек)
|
||||||
|
"max_retries": 3 # Максимум повторных попыток
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Логирование
|
||||||
|
|
||||||
|
Логи сохраняются в `logs/parser_YYYY-MM-DD.log`:
|
||||||
|
- INFO: Прогресс парсинга, количество найденных записей
|
||||||
|
- WARNING: Пропущенные страницы, проблемы с данными
|
||||||
|
- ERROR: Критические ошибки запросов
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Источник данных:** [https://sroaas.ru](https://sroaas.ru)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Конфигурационный файл парсера СРО ААС
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Базовые настройки
|
||||||
|
BASE_URL = "https://sroaas.ru"
|
||||||
|
|
||||||
|
# URL реестров
|
||||||
|
REGISTRIES = {
|
||||||
|
# Реестры действительных членов и выданных аттестатов
|
||||||
|
"auditors": {
|
||||||
|
"name": "Реестр членов — аудиторов и индивидуальных аудиторов",
|
||||||
|
"url": f"{BASE_URL}/reestr/auditory/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"organizations": {
|
||||||
|
"name": "Реестр членов — аудиторских организаций",
|
||||||
|
"url": f"{BASE_URL}/reestr/organizatsiy/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"certificates": {
|
||||||
|
"name": "Реестр выданных СРО ААС квалификационных аттестатов аудитора",
|
||||||
|
"url": f"{BASE_URL}/reestr/reestr-vydannykh-sro-aas-kvalifikatsionnykh-attestatov-auditora/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"individual_auditors": {
|
||||||
|
"name": "Перечень индивидуальных аудиторов",
|
||||||
|
"url": f"{BASE_URL}/reestr/ia/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"training_centers": {
|
||||||
|
"name": "Реестр учебно-методических центров",
|
||||||
|
"url": f"{BASE_URL}/reestr/umc/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"oppk_confirmation": {
|
||||||
|
"name": "Сведения о подтверждении ОППК аудиторами",
|
||||||
|
"url": f"{BASE_URL}/reestr/oppk/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"ozo_training": {
|
||||||
|
"name": "Сведения о прохождении ПК руководителем аудита ОЗО ФР",
|
||||||
|
"url": f"{BASE_URL}/reestr/pcozo/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
# Реестры исключенных членов и аннулированных аттестатов
|
||||||
|
"excluded_auditors": {
|
||||||
|
"name": "Реестр аудиторов, прекративших членство в СРО ААС",
|
||||||
|
"url": f"{BASE_URL}/reestr/reestr-auditorov-prekrativshikh-chlenstvo-v-sro-aas/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"excluded_organizations": {
|
||||||
|
"name": "Реестр аудиторских организаций, прекративших членство в СРО ААС",
|
||||||
|
"url": f"{BASE_URL}/reestr/reestr-auditorskikh-organizatsiy-prekrativshikh-chlenstvo-v-sro-aas/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"cancelled_certificates": {
|
||||||
|
"name": "Реестр аннулированных СРО ААС квалификационных аттестатов аудитора",
|
||||||
|
"url": f"{BASE_URL}/reestr/reestr-annulirovannykh-sro-aas-kvalifikatsionnykh-attestatov-auditora/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"excluded_training_centers": {
|
||||||
|
"name": "УМЦ, исключенные из реестра СРО ААС",
|
||||||
|
"url": f"{BASE_URL}/reestr/umc-prekrativshikh-chlenstvo/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
# Реестры дисциплинарных мер
|
||||||
|
"disciplinary_auditors": {
|
||||||
|
"name": "Меры дисциплинарного воздействия к членам СРО ААС - аудиторам",
|
||||||
|
"url": f"{BASE_URL}/reestr/mery-distsiplinarnogo-vozdeystviya-k-chlenam-sro-aas/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
"disciplinary_organizations": {
|
||||||
|
"name": "Меры дисциплинарного воздействия к членам СРО – аудиторским организациям",
|
||||||
|
"url": f"{BASE_URL}/reestr/mery-distsiplinarnogo-vozdeystviya-k-chlenam-sro-aas-organizatsiy/",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
# Перечень сетей
|
||||||
|
"audit_networks": {
|
||||||
|
"name": "Перечень российских и международных сетей аудиторских организаций",
|
||||||
|
"url": f"{BASE_URL}/reestr/seti-auditorskikh-organizatsiy",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Настройки парсера
|
||||||
|
PARSER_CONFIG = {
|
||||||
|
"timeout": 30, # таймаут запроса в секундах
|
||||||
|
"delay_between_requests": 1, # задержка между запросами в секундах
|
||||||
|
"max_retries": 3, # максимальное количество попыток
|
||||||
|
"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",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Настройки экспорта
|
||||||
|
EXPORT_CONFIG = {
|
||||||
|
"output_dir": "data/exports/",
|
||||||
|
"date_format": "%Y-%m-%d_%H-%M-%S",
|
||||||
|
"encoding": "utf-8",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Настройки логирования
|
||||||
|
LOGGING_CONFIG = {
|
||||||
|
"level": "INFO",
|
||||||
|
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
"log_dir": "logs/",
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Директория для экспортированных файлов
|
||||||
|
|
||||||
|
Все файлы, экспортированные парсером, будут сохраняться здесь.
|
||||||
|
|
||||||
|
Формат имени файла: `organizations_YYYY-MM-DD_HH-MM-SS.xlsx`
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
- organizations_2025-10-24_15-30-45.xlsx
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Парсер реестров СРО ААС (Саморегулируемая организация аудиторов)
|
||||||
|
|
||||||
|
Автор: [ada]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config import REGISTRIES
|
||||||
|
from parsers.organizations_parser import OrganizationsParser
|
||||||
|
from parsers.auditors_parser import AuditorsParser
|
||||||
|
from parsers.generic_parser import GenericRegistryParser
|
||||||
|
from utils.excel_exporter import ExcelExporter
|
||||||
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
|
|
||||||
|
def print_banner():
|
||||||
|
"""Вывод баннера приложения"""
|
||||||
|
banner = """
|
||||||
|
╔═══════════════════════════════════════════════════════════╗
|
||||||
|
║ ПАРСЕР РЕЕСТРОВ СРО ААС ║
|
||||||
|
║ Саморегулируемая организация аудиторов ║
|
||||||
|
║ Ассоциация «Содружество» ║
|
||||||
|
╚═══════════════════════════════════════════════════════════╝
|
||||||
|
"""
|
||||||
|
print(banner)
|
||||||
|
|
||||||
|
|
||||||
|
def print_menu():
|
||||||
|
"""Вывод главного меню"""
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ДОСТУПНЫЕ РЕЕСТРЫ:")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
print("\n📋 ДЕЙСТВИТЕЛЬНЫЕ ЧЛЕНЫ И ВЫДАННЫЕ АТТЕСТАТЫ:")
|
||||||
|
print(" 1. Реестр членов — аудиторов и индивидуальных аудиторов ⭐")
|
||||||
|
print(" 2. Реестр членов — аудиторских организаций ⭐")
|
||||||
|
print(" 3. Реестр выданных СРО ААС квалификационных аттестатов ⭐")
|
||||||
|
print(" 4. Перечень индивидуальных аудиторов ⭐")
|
||||||
|
print(" 5. Реестр учебно-методических центров ⭐")
|
||||||
|
print(" 6. Сведения о подтверждении ОППК аудиторами ⭐")
|
||||||
|
print(" 7. Сведения о прохождении ПК руководителем аудита ОЗО ФР ⭐")
|
||||||
|
|
||||||
|
print("\n❌ ИСКЛЮЧЕННЫЕ ЧЛЕНЫ И АННУЛИРОВАННЫЕ АТТЕСТАТЫ:")
|
||||||
|
print(" 8. Реестр аудиторов, прекративших членство в СРО ААС ⭐")
|
||||||
|
print(" 9. Реестр аудиторских организаций, прекративших членство ⭐")
|
||||||
|
print(" 10. Реестр аннулированных квалификационных аттестатов ⭐")
|
||||||
|
print(" 11. УМЦ, исключенные из реестра СРО ААС ⭐")
|
||||||
|
|
||||||
|
print("\n⚠️ ДИСЦИПЛИНАРНЫЕ МЕРЫ:")
|
||||||
|
print(" 12. Меры дисциплинарного воздействия к аудиторам ⭐")
|
||||||
|
print(" 13. Меры дисциплинарного воздействия к организациям ⭐")
|
||||||
|
|
||||||
|
print("\n🌐 СЕТИ АУДИТОРСКИХ ОРГАНИЗАЦИЙ:")
|
||||||
|
print(" 14. Перечень российских и международных сетей ⭐")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" 0. Выход")
|
||||||
|
print("=" * 60)
|
||||||
|
print("\n⭐ - Полностью реализованный парсер")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_organizations(detailed=False):
|
||||||
|
"""
|
||||||
|
Парсинг реестра аудиторских организаций
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detailed: Парсить ли детальные страницы
|
||||||
|
"""
|
||||||
|
logger = setup_logger("main")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ПАРСИНГ РЕЕСТРА АУДИТОРСКИХ ОРГАНИЗАЦИЙ")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Подтверждение пользователя
|
||||||
|
if detailed:
|
||||||
|
print("\n⚠️ ВНИМАНИЕ: Детальный парсинг может занять продолжительное время!")
|
||||||
|
choice = input("Продолжить? (y/n): ").lower()
|
||||||
|
if choice != "y":
|
||||||
|
print("Операция отменена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создание парсера
|
||||||
|
parser = OrganizationsParser()
|
||||||
|
|
||||||
|
# Парсинг данных
|
||||||
|
print("\n🔄 Начало парсинга...")
|
||||||
|
organizations = parser.parse_to_objects(detailed=detailed)
|
||||||
|
|
||||||
|
if not organizations:
|
||||||
|
print("\n❌ Не удалось получить данные из реестра.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n✅ Успешно собрано записей: {len(organizations)}")
|
||||||
|
|
||||||
|
# Экспорт в Excel
|
||||||
|
print("\n📊 Экспорт данных в Excel...")
|
||||||
|
exporter = ExcelExporter()
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
filename = f"organizations_{timestamp}"
|
||||||
|
|
||||||
|
filepath = exporter.export_organizations(organizations, filename=filename)
|
||||||
|
|
||||||
|
if filepath:
|
||||||
|
print(f"\n✅ Данные успешно экспортированы!")
|
||||||
|
print(f"📁 Файл: {os.path.abspath(filepath)}")
|
||||||
|
print(f"📊 Количество записей: {len(organizations)}")
|
||||||
|
else:
|
||||||
|
print("\n❌ Ошибка при экспорте данных.")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Операция прервана пользователем.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при парсинге: {e}")
|
||||||
|
print(f"\n❌ Произошла ошибка: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_generic_registry(registry_key: str, registry_name: str, detailed=False):
|
||||||
|
"""
|
||||||
|
Универсальная функция парсинга любого реестра
|
||||||
|
|
||||||
|
Args:
|
||||||
|
registry_key: Ключ реестра из config.REGISTRIES
|
||||||
|
registry_name: Название реестра для отображения
|
||||||
|
detailed: Парсить ли детальные страницы
|
||||||
|
"""
|
||||||
|
logger = setup_logger("main")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(f"ПАРСИНГ: {registry_name.upper()}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Подтверждение пользователя
|
||||||
|
if detailed:
|
||||||
|
print("\n⚠️ ВНИМАНИЕ: Детальный парсинг может занять продолжительное время!")
|
||||||
|
choice = input("Продолжить? (y/n): ").lower()
|
||||||
|
if choice != "y":
|
||||||
|
print("Операция отменена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создание парсера
|
||||||
|
parser = GenericRegistryParser(registry_key)
|
||||||
|
|
||||||
|
# Парсинг данных
|
||||||
|
print("\n🔄 Начало парсинга...")
|
||||||
|
data = parser.parse_registry(detailed=detailed)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
print("\n❌ Не удалось получить данные из реестра.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n✅ Успешно собрано записей: {len(data)}")
|
||||||
|
|
||||||
|
# Экспорт в Excel
|
||||||
|
print("\n📊 Экспорт данных в Excel...")
|
||||||
|
exporter = ExcelExporter()
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
filename = f"{registry_key}_{timestamp}"
|
||||||
|
|
||||||
|
filepath = exporter.export_to_excel(
|
||||||
|
data=data,
|
||||||
|
filename=filename,
|
||||||
|
sheet_name=registry_name[:30], # Ограничение длины для Excel
|
||||||
|
)
|
||||||
|
|
||||||
|
if filepath:
|
||||||
|
print(f"\n✅ Данные успешно экспортированы!")
|
||||||
|
print(f"📁 Файл: {os.path.abspath(filepath)}")
|
||||||
|
print(f"📊 Количество записей: {len(data)}")
|
||||||
|
else:
|
||||||
|
print("\n❌ Ошибка при экспорте данных.")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Операция прервана пользователем.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при парсинге: {e}")
|
||||||
|
print(f"\n❌ Произошла ошибка: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_auditors(detailed=False):
|
||||||
|
"""Парсинг реестра аудиторов"""
|
||||||
|
logger = setup_logger("main")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ПАРСИНГ РЕЕСТРА АУДИТОРОВ")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
if detailed:
|
||||||
|
print("\n⚠️ ВНИМАНИЕ: Детальный парсинг может занять продолжительное время!")
|
||||||
|
choice = input("Продолжить? (y/n): ").lower()
|
||||||
|
if choice != "y":
|
||||||
|
print("Операция отменена.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = AuditorsParser()
|
||||||
|
print("\n🔄 Начало парсинга...")
|
||||||
|
auditors = parser.parse_to_objects(detailed=detailed)
|
||||||
|
|
||||||
|
if not auditors:
|
||||||
|
print("\n❌ Не удалось получить данные из реестра.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n✅ Успешно собрано записей: {len(auditors)}")
|
||||||
|
|
||||||
|
# Экспорт в Excel
|
||||||
|
print("\n📊 Экспорт данных в Excel...")
|
||||||
|
exporter = ExcelExporter()
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||||
|
filename = f"auditors_{timestamp}"
|
||||||
|
|
||||||
|
# Преобразуем в словари
|
||||||
|
data = [auditor.to_dict() for auditor in auditors]
|
||||||
|
filepath = exporter.export_to_excel(
|
||||||
|
data=data, filename=filename, sheet_name="Аудиторы"
|
||||||
|
)
|
||||||
|
|
||||||
|
if filepath:
|
||||||
|
print(f"\n✅ Данные успешно экспортированы!")
|
||||||
|
print(f"📁 Файл: {os.path.abspath(filepath)}")
|
||||||
|
print(f"📊 Количество записей: {len(auditors)}")
|
||||||
|
else:
|
||||||
|
print("\n❌ Ошибка при экспорте данных.")
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n⚠️ Операция прервана пользователем.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ошибка при парсинге: {e}")
|
||||||
|
print(f"\n❌ Произошла ошибка: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def not_implemented():
|
||||||
|
"""Заглушка для нереализованных функций"""
|
||||||
|
print("\n⚠️ Данный парсер находится в разработке.")
|
||||||
|
print("💡 Вы можете реализовать его по аналогии с OrganizationsParser.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Главная функция приложения"""
|
||||||
|
print_banner()
|
||||||
|
|
||||||
|
# Маппинг выбора на реестры
|
||||||
|
registry_map = {
|
||||||
|
"1": ("auditors", "Реестр аудиторов и индивидуальных аудиторов"),
|
||||||
|
"2": ("organizations", "Реестр аудиторских организаций"),
|
||||||
|
"3": ("certificates", "Реестр квалификационных аттестатов"),
|
||||||
|
"4": ("individual_auditors", "Перечень индивидуальных аудиторов"),
|
||||||
|
"5": ("training_centers", "Реестр учебно-методических центров"),
|
||||||
|
"6": ("oppk_confirmation", "Сведения о подтверждении ОППК"),
|
||||||
|
"7": ("ozo_training", "Сведения о прохождении ПК руководителем"),
|
||||||
|
"8": ("excluded_auditors", "Реестр исключенных аудиторов"),
|
||||||
|
"9": ("excluded_organizations", "Реестр исключенных организаций"),
|
||||||
|
"10": ("cancelled_certificates", "Реестр аннулированных аттестатов"),
|
||||||
|
"11": ("excluded_training_centers", "УМЦ исключенные из реестра"),
|
||||||
|
"12": ("disciplinary_auditors", "Дисциплинарные меры к аудиторам"),
|
||||||
|
"13": ("disciplinary_organizations", "Дисциплинарные меры к организациям"),
|
||||||
|
"14": ("audit_networks", "Перечень сетей аудиторских организаций"),
|
||||||
|
}
|
||||||
|
|
||||||
|
while True:
|
||||||
|
print_menu()
|
||||||
|
|
||||||
|
choice = input("\n👉 Выберите реестр (0-14): ").strip()
|
||||||
|
|
||||||
|
if choice == "0":
|
||||||
|
print("\n👋 До свидания!")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
elif choice == "1":
|
||||||
|
# Реестр аудиторов - специализированный парсер
|
||||||
|
print(f"\n📋 Выбран: {registry_map[choice][1]}")
|
||||||
|
detail_choice = input("Парсить детальные страницы? (y/n): ").lower()
|
||||||
|
detailed = detail_choice == "y"
|
||||||
|
parse_auditors(detailed=detailed)
|
||||||
|
|
||||||
|
elif choice == "2":
|
||||||
|
# Реестр аудиторских организаций - специализированный парсер
|
||||||
|
print(f"\n📋 Выбран: {registry_map[choice][1]}")
|
||||||
|
detail_choice = input("Парсить детальные страницы? (y/n): ").lower()
|
||||||
|
detailed = detail_choice == "y"
|
||||||
|
parse_organizations(detailed=detailed)
|
||||||
|
|
||||||
|
elif choice in registry_map:
|
||||||
|
# Все остальные реестры - универсальный парсер
|
||||||
|
registry_key, registry_name = registry_map[choice]
|
||||||
|
print(f"\n📋 Выбран: {registry_name}")
|
||||||
|
detail_choice = input("Парсить детальные страницы? (y/n): ").lower()
|
||||||
|
detailed = detail_choice == "y"
|
||||||
|
parse_generic_registry(registry_key, registry_name, detailed=detailed)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("\n❌ Неверный выбор. Попробуйте снова.")
|
||||||
|
|
||||||
|
# Пауза перед возвратом в меню
|
||||||
|
input("\n⏸️ Нажмите Enter для возврата в меню...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\n👋 Программа завершена.")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\n❌ Критическая ошибка: {e}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Модели данных
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .organization import Organization
|
||||||
|
from .auditor import Auditor
|
||||||
|
from .certificate import Certificate
|
||||||
|
from .training_center import TrainingCenter
|
||||||
|
from .disciplinary_action import DisciplinaryAction
|
||||||
|
from .audit_network import AuditNetwork
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Organization",
|
||||||
|
"Auditor",
|
||||||
|
"Certificate",
|
||||||
|
"TrainingCenter",
|
||||||
|
"DisciplinaryAction",
|
||||||
|
"AuditNetwork",
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Модель данных для сети аудиторских организаций
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AuditNetwork:
|
||||||
|
"""Модель сети аудиторских организаций"""
|
||||||
|
|
||||||
|
# Основная информация
|
||||||
|
name: str
|
||||||
|
network_type: str # "Российская" или "Международная"
|
||||||
|
registration_number: Optional[str] = None
|
||||||
|
|
||||||
|
# Участники
|
||||||
|
member_organizations: List[str] = field(default_factory=list)
|
||||||
|
member_count: Optional[int] = None
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
country: Optional[str] = None
|
||||||
|
headquarters: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
# Контакты
|
||||||
|
contact_person: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
|
||||||
|
# Метаинформация
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
parsed_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Преобразование в словарь для экспорта"""
|
||||||
|
return {
|
||||||
|
"Название сети": self.name,
|
||||||
|
"Тип": self.network_type,
|
||||||
|
"Регистрационный номер": self.registration_number or "",
|
||||||
|
"Количество участников": self.member_count or "",
|
||||||
|
"Участники": ", ".join(self.member_organizations[:10]), # Первые 10
|
||||||
|
"Страна": self.country or "",
|
||||||
|
"Штаб-квартира": self.headquarters or "",
|
||||||
|
"Сайт": self.website or "",
|
||||||
|
"Описание": self.description or "",
|
||||||
|
"Контактное лицо": self.contact_person or "",
|
||||||
|
"Телефон": self.phone or "",
|
||||||
|
"Email": self.email or "",
|
||||||
|
"URL источника": self.source_url or "",
|
||||||
|
"Дата сбора данных": self.parsed_at.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""
|
||||||
|
Модель данных для аудитора
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Auditor:
|
||||||
|
"""Модель аудитора или индивидуального аудитора"""
|
||||||
|
|
||||||
|
# Основная информация
|
||||||
|
full_name: str
|
||||||
|
ornz: str # Основной регистрационный номер записи
|
||||||
|
certificate_number: str
|
||||||
|
region: str
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
inn: Optional[str] = None
|
||||||
|
snils: Optional[str] = None
|
||||||
|
qualification: Optional[str] = None
|
||||||
|
|
||||||
|
# Организация (для аудиторов)
|
||||||
|
organization_name: Optional[str] = None
|
||||||
|
organization_inn: Optional[str] = None
|
||||||
|
|
||||||
|
# Даты
|
||||||
|
certificate_issue_date: Optional[datetime] = None
|
||||||
|
membership_start_date: Optional[datetime] = None
|
||||||
|
membership_end_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Дополнительные данные
|
||||||
|
education: Optional[str] = None
|
||||||
|
experience_years: Optional[int] = None
|
||||||
|
specializations: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Метаинформация
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
parsed_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Преобразование в словарь для экспорта"""
|
||||||
|
return {
|
||||||
|
"ФИО": self.full_name,
|
||||||
|
"ОРНЗ": self.ornz,
|
||||||
|
"Номер аттестата": self.certificate_number,
|
||||||
|
"Регион": self.region,
|
||||||
|
"Статус": self.status or "",
|
||||||
|
"ИНН": self.inn or "",
|
||||||
|
"СНИЛС": self.snils or "",
|
||||||
|
"Квалификация": self.qualification or "",
|
||||||
|
"Организация": self.organization_name or "",
|
||||||
|
"ИНН организации": self.organization_inn or "",
|
||||||
|
"Дата выдачи аттестата": (
|
||||||
|
self.certificate_issue_date.strftime("%d.%m.%Y")
|
||||||
|
if self.certificate_issue_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Дата начала членства": (
|
||||||
|
self.membership_start_date.strftime("%d.%m.%Y")
|
||||||
|
if self.membership_start_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Дата окончания членства": (
|
||||||
|
self.membership_end_date.strftime("%d.%m.%Y")
|
||||||
|
if self.membership_end_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Образование": self.education or "",
|
||||||
|
"Стаж (лет)": self.experience_years or "",
|
||||||
|
"Специализации": ", ".join(self.specializations),
|
||||||
|
"URL источника": self.source_url or "",
|
||||||
|
"Дата сбора данных": self.parsed_at.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
Модель данных для квалификационного аттестата
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Certificate:
|
||||||
|
"""Модель квалификационного аттестата аудитора"""
|
||||||
|
|
||||||
|
# Основная информация
|
||||||
|
certificate_number: str
|
||||||
|
auditor_full_name: str
|
||||||
|
issue_date: datetime
|
||||||
|
status: str
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
qualification_type: Optional[str] = None
|
||||||
|
issuer: Optional[str] = None
|
||||||
|
validity_period: Optional[str] = None
|
||||||
|
|
||||||
|
# Данные аудитора
|
||||||
|
auditor_inn: Optional[str] = None
|
||||||
|
auditor_snils: Optional[str] = None
|
||||||
|
|
||||||
|
# Причина аннулирования (для аннулированных)
|
||||||
|
cancellation_reason: Optional[str] = None
|
||||||
|
cancellation_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Метаинформация
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
parsed_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Преобразование в словарь для экспорта"""
|
||||||
|
return {
|
||||||
|
"Номер аттестата": self.certificate_number,
|
||||||
|
"ФИО аудитора": self.auditor_full_name,
|
||||||
|
"Дата выдачи": (
|
||||||
|
self.issue_date.strftime("%d.%m.%Y") if self.issue_date else ""
|
||||||
|
),
|
||||||
|
"Статус": self.status,
|
||||||
|
"Тип квалификации": self.qualification_type or "",
|
||||||
|
"Выдан": self.issuer or "",
|
||||||
|
"Срок действия": self.validity_period or "",
|
||||||
|
"ИНН аудитора": self.auditor_inn or "",
|
||||||
|
"СНИЛС аудитора": self.auditor_snils or "",
|
||||||
|
"Причина аннулирования": self.cancellation_reason or "",
|
||||||
|
"Дата аннулирования": (
|
||||||
|
self.cancellation_date.strftime("%d.%m.%Y")
|
||||||
|
if self.cancellation_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"URL источника": self.source_url or "",
|
||||||
|
"Дата сбора данных": self.parsed_at.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Модель данных для дисциплинарной меры
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DisciplinaryAction:
|
||||||
|
"""Модель меры дисциплинарного воздействия"""
|
||||||
|
|
||||||
|
# Основная информация
|
||||||
|
subject_name: str # ФИО аудитора или название организации
|
||||||
|
subject_type: str # "auditor" или "organization"
|
||||||
|
ornz: str
|
||||||
|
action_type: str # Тип меры воздействия
|
||||||
|
|
||||||
|
# Детали
|
||||||
|
violation_description: str
|
||||||
|
decision_date: datetime
|
||||||
|
decision_number: Optional[str] = None
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
inn: Optional[str] = None
|
||||||
|
region: Optional[str] = None
|
||||||
|
effective_date: Optional[datetime] = None
|
||||||
|
expiry_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Решение
|
||||||
|
decision_body: Optional[str] = None
|
||||||
|
appeal_status: Optional[str] = None
|
||||||
|
|
||||||
|
# Метаинформация
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
parsed_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Преобразование в словарь для экспорта"""
|
||||||
|
return {
|
||||||
|
"Субъект": self.subject_name,
|
||||||
|
"Тип субъекта": (
|
||||||
|
"Аудитор" if self.subject_type == "auditor" else "Организация"
|
||||||
|
),
|
||||||
|
"ОРНЗ": self.ornz,
|
||||||
|
"Мера воздействия": self.action_type,
|
||||||
|
"Описание нарушения": self.violation_description,
|
||||||
|
"Дата решения": (
|
||||||
|
self.decision_date.strftime("%d.%m.%Y") if self.decision_date else ""
|
||||||
|
),
|
||||||
|
"Номер решения": self.decision_number or "",
|
||||||
|
"ИНН": self.inn or "",
|
||||||
|
"Регион": self.region or "",
|
||||||
|
"Дата вступления в силу": (
|
||||||
|
self.effective_date.strftime("%d.%m.%Y") if self.effective_date else ""
|
||||||
|
),
|
||||||
|
"Дата окончания": (
|
||||||
|
self.expiry_date.strftime("%d.%m.%Y") if self.expiry_date else ""
|
||||||
|
),
|
||||||
|
"Орган принявший решение": self.decision_body or "",
|
||||||
|
"Статус обжалования": self.appeal_status or "",
|
||||||
|
"URL источника": self.source_url or "",
|
||||||
|
"Дата сбора данных": self.parsed_at.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Модель данных для аудиторской организации
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Organization:
|
||||||
|
"""Модель аудиторской организации"""
|
||||||
|
|
||||||
|
# Основная информация
|
||||||
|
name: str
|
||||||
|
ornz: str # Основной регистрационный номер записи
|
||||||
|
inn: str
|
||||||
|
region: str
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
# Дополнительная информация (заполняется при детальном парсе)
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
ogrn: Optional[str] = None
|
||||||
|
kpp: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
director: Optional[str] = None
|
||||||
|
|
||||||
|
# Даты
|
||||||
|
registration_date: Optional[datetime] = None
|
||||||
|
membership_start_date: Optional[datetime] = None
|
||||||
|
membership_end_date: Optional[datetime] = None
|
||||||
|
|
||||||
|
# Дополнительные данные
|
||||||
|
auditors_count: Optional[int] = None
|
||||||
|
certificates: List[str] = field(default_factory=list)
|
||||||
|
networks: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Метаинформация
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
parsed_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Преобразование в словарь для экспорта"""
|
||||||
|
return {
|
||||||
|
"Наименование": self.name,
|
||||||
|
"Полное наименование": self.full_name or "",
|
||||||
|
"ОРНЗ": self.ornz,
|
||||||
|
"ИНН": self.inn,
|
||||||
|
"КПП": self.kpp or "",
|
||||||
|
"ОГРН": self.ogrn or "",
|
||||||
|
"Регион": self.region,
|
||||||
|
"Статус": self.status or "",
|
||||||
|
"Адрес": self.address or "",
|
||||||
|
"Телефон": self.phone or "",
|
||||||
|
"Email": self.email or "",
|
||||||
|
"Сайт": self.website or "",
|
||||||
|
"Руководитель": self.director or "",
|
||||||
|
"Дата регистрации": (
|
||||||
|
self.registration_date.strftime("%d.%m.%Y")
|
||||||
|
if self.registration_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Дата начала членства": (
|
||||||
|
self.membership_start_date.strftime("%d.%m.%Y")
|
||||||
|
if self.membership_start_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Дата окончания членства": (
|
||||||
|
self.membership_end_date.strftime("%d.%m.%Y")
|
||||||
|
if self.membership_end_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Количество аудиторов": self.auditors_count or "",
|
||||||
|
"Сертификаты": ", ".join(self.certificates),
|
||||||
|
"Сети": ", ".join(self.networks),
|
||||||
|
"URL источника": self.source_url or "",
|
||||||
|
"Дата сбора данных": self.parsed_at.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""
|
||||||
|
Модель данных для учебно-методического центра
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List, Dict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainingCenter:
|
||||||
|
"""Модель учебно-методического центра (УМЦ)"""
|
||||||
|
|
||||||
|
# Основная информация
|
||||||
|
name: str
|
||||||
|
registration_number: str
|
||||||
|
inn: str
|
||||||
|
region: str
|
||||||
|
status: Optional[str] = None
|
||||||
|
|
||||||
|
# Дополнительная информация
|
||||||
|
full_name: Optional[str] = None
|
||||||
|
ogrn: Optional[str] = None
|
||||||
|
kpp: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
email: Optional[str] = None
|
||||||
|
website: Optional[str] = None
|
||||||
|
director: Optional[str] = None
|
||||||
|
|
||||||
|
# Аккредитация
|
||||||
|
accreditation_date: Optional[datetime] = None
|
||||||
|
accreditation_number: Optional[str] = None
|
||||||
|
programs: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Даты
|
||||||
|
registration_date: Optional[datetime] = None
|
||||||
|
exclusion_date: Optional[datetime] = None
|
||||||
|
exclusion_reason: Optional[str] = None
|
||||||
|
|
||||||
|
# Метаинформация
|
||||||
|
source_url: Optional[str] = None
|
||||||
|
parsed_at: datetime = field(default_factory=datetime.now)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict:
|
||||||
|
"""Преобразование в словарь для экспорта"""
|
||||||
|
return {
|
||||||
|
"Наименование": self.name,
|
||||||
|
"Полное наименование": self.full_name or "",
|
||||||
|
"Регистрационный номер": self.registration_number,
|
||||||
|
"ИНН": self.inn,
|
||||||
|
"КПП": self.kpp or "",
|
||||||
|
"ОГРН": self.ogrn or "",
|
||||||
|
"Регион": self.region,
|
||||||
|
"Статус": self.status or "",
|
||||||
|
"Адрес": self.address or "",
|
||||||
|
"Телефон": self.phone or "",
|
||||||
|
"Email": self.email or "",
|
||||||
|
"Сайт": self.website or "",
|
||||||
|
"Руководитель": self.director or "",
|
||||||
|
"Дата аккредитации": (
|
||||||
|
self.accreditation_date.strftime("%d.%m.%Y")
|
||||||
|
if self.accreditation_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Номер аккредитации": self.accreditation_number or "",
|
||||||
|
"Программы": ", ".join(self.programs),
|
||||||
|
"Дата регистрации": (
|
||||||
|
self.registration_date.strftime("%d.%m.%Y")
|
||||||
|
if self.registration_date
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"Дата исключения": (
|
||||||
|
self.exclusion_date.strftime("%d.%m.%Y") if self.exclusion_date else ""
|
||||||
|
),
|
||||||
|
"Причина исключения": self.exclusion_reason or "",
|
||||||
|
"URL источника": self.source_url or "",
|
||||||
|
"Дата сбора данных": self.parsed_at.strftime("%d.%m.%Y %H:%M:%S"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Модуль парсеров для различных реестров СРО ААС
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .base_parser import BaseParser
|
||||||
|
from .organizations_parser import OrganizationsParser
|
||||||
|
from .auditors_parser import AuditorsParser
|
||||||
|
from .generic_parser import GenericRegistryParser
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseParser",
|
||||||
|
"OrganizationsParser",
|
||||||
|
"AuditorsParser",
|
||||||
|
"GenericRegistryParser",
|
||||||
|
]
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Парсер реестра аудиторов и индивидуальных аудиторов
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
import re
|
||||||
|
|
||||||
|
from parsers.base_parser import BaseParser
|
||||||
|
from models.auditor import Auditor
|
||||||
|
from config import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
class AuditorsParser(BaseParser):
|
||||||
|
"""
|
||||||
|
Парсер для реестра аудиторов и индивидуальных аудиторов
|
||||||
|
https://sroaas.ru/reestr/auditory/
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
from config import REGISTRIES
|
||||||
|
|
||||||
|
registry = REGISTRIES["auditors"]
|
||||||
|
super().__init__(registry["url"], registry["name"])
|
||||||
|
|
||||||
|
def parse_list_page(self, soup: BeautifulSoup) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Парсинг страницы со списком аудиторов
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: Parsed HTML страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей с данными аудиторов
|
||||||
|
"""
|
||||||
|
auditors = []
|
||||||
|
|
||||||
|
# Поиск таблицы с данными
|
||||||
|
table = soup.find("table")
|
||||||
|
if not table:
|
||||||
|
self.logger.warning("Таблица с данными не найдена на странице")
|
||||||
|
return auditors
|
||||||
|
|
||||||
|
# Парсинг строк таблицы
|
||||||
|
rows = table.find_all("tr")[1:] # Пропускаем заголовок
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
cols = row.find_all("td")
|
||||||
|
if len(cols) < 4:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Извлечение данных
|
||||||
|
full_name = cols[0].get_text(strip=True)
|
||||||
|
ornz = cols[1].get_text(strip=True)
|
||||||
|
certificate_number = cols[2].get_text(strip=True)
|
||||||
|
region = cols[3].get_text(strip=True)
|
||||||
|
|
||||||
|
# Статус (если есть)
|
||||||
|
status = cols[4].get_text(strip=True) if len(cols) > 4 else None
|
||||||
|
|
||||||
|
# URL детальной страницы
|
||||||
|
detail_url = None
|
||||||
|
link = row.find("a")
|
||||||
|
if link:
|
||||||
|
href = link.get("href")
|
||||||
|
# Проверяем, что href - строка, а не список
|
||||||
|
if isinstance(href, list):
|
||||||
|
href = href[0] if href else None
|
||||||
|
if href:
|
||||||
|
detail_url = urljoin(BASE_URL, href)
|
||||||
|
|
||||||
|
auditor_data = {
|
||||||
|
"full_name": full_name,
|
||||||
|
"ornz": ornz,
|
||||||
|
"certificate_number": certificate_number,
|
||||||
|
"region": region,
|
||||||
|
"status": status,
|
||||||
|
"detail_url": detail_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
auditors.append(auditor_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при парсинге строки таблицы: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return auditors
|
||||||
|
|
||||||
|
def parse_detail_page(self, url: str, soup: BeautifulSoup) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Парсинг детальной страницы аудитора
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL детальной страницы
|
||||||
|
soup: Parsed HTML страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с детальными данными
|
||||||
|
"""
|
||||||
|
detail_data = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
info_blocks = soup.find_all("div", class_=["info-block", "detail-info"])
|
||||||
|
|
||||||
|
for block in info_blocks:
|
||||||
|
labels = block.find_all(["dt", "div"], class_=["label", "info-label"])
|
||||||
|
values = block.find_all(["dd", "div"], class_=["value", "info-value"])
|
||||||
|
|
||||||
|
for label, value in zip(labels, values):
|
||||||
|
key = label.get_text(strip=True).lower()
|
||||||
|
val = value.get_text(strip=True)
|
||||||
|
|
||||||
|
if "инн" in key:
|
||||||
|
detail_data["inn"] = val
|
||||||
|
elif "снилс" in key:
|
||||||
|
detail_data["snils"] = val
|
||||||
|
elif "квалификация" in key:
|
||||||
|
detail_data["qualification"] = val
|
||||||
|
elif "организация" in key and "название" in key:
|
||||||
|
detail_data["organization_name"] = val
|
||||||
|
elif "организация" in key and "инн" in key:
|
||||||
|
detail_data["organization_inn"] = val
|
||||||
|
elif "образование" in key:
|
||||||
|
detail_data["education"] = val
|
||||||
|
elif "стаж" in key:
|
||||||
|
match = re.search(r"\d+", val)
|
||||||
|
if match:
|
||||||
|
detail_data["experience_years"] = int(match.group())
|
||||||
|
|
||||||
|
detail_data["source_url"] = url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при парсинге детальной страницы {url}: {e}")
|
||||||
|
|
||||||
|
return detail_data
|
||||||
|
|
||||||
|
def parse_to_objects(self, detailed: bool = False) -> List[Auditor]:
|
||||||
|
"""
|
||||||
|
Парсинг реестра с преобразованием в объекты Auditor
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detailed: Парсить ли детальные страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список объектов Auditor
|
||||||
|
"""
|
||||||
|
data = self.parse_registry(detailed=detailed)
|
||||||
|
auditors = []
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
try:
|
||||||
|
auditor = Auditor(
|
||||||
|
full_name=item.get("full_name", ""),
|
||||||
|
ornz=item.get("ornz", ""),
|
||||||
|
certificate_number=item.get("certificate_number", ""),
|
||||||
|
region=item.get("region", ""),
|
||||||
|
status=item.get("status"),
|
||||||
|
inn=item.get("inn"),
|
||||||
|
snils=item.get("snils"),
|
||||||
|
qualification=item.get("qualification"),
|
||||||
|
organization_name=item.get("organization_name"),
|
||||||
|
organization_inn=item.get("organization_inn"),
|
||||||
|
education=item.get("education"),
|
||||||
|
experience_years=item.get("experience_years"),
|
||||||
|
source_url=item.get("source_url"),
|
||||||
|
)
|
||||||
|
auditors.append(auditor)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при создании объекта Auditor: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return auditors
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""
|
||||||
|
Базовый парсер для реестров СРО ААС
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from config import PARSER_CONFIG, BASE_URL
|
||||||
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
|
|
||||||
|
class BaseParser(ABC):
|
||||||
|
"""
|
||||||
|
Базовый класс для парсеров реестров СРО ААС
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, registry_url: str, registry_name: str):
|
||||||
|
"""
|
||||||
|
Инициализация парсера
|
||||||
|
|
||||||
|
Args:
|
||||||
|
registry_url: URL реестра
|
||||||
|
registry_name: Название реестра
|
||||||
|
"""
|
||||||
|
self.registry_url = registry_url
|
||||||
|
self.registry_name = registry_name
|
||||||
|
self.logger = setup_logger(f"{self.__class__.__name__}")
|
||||||
|
|
||||||
|
# Настройки сессии
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"User-Agent": PARSER_CONFIG["user_agent"]})
|
||||||
|
|
||||||
|
self.timeout = PARSER_CONFIG["timeout"]
|
||||||
|
self.delay = PARSER_CONFIG["delay_between_requests"]
|
||||||
|
self.max_retries = PARSER_CONFIG["max_retries"]
|
||||||
|
|
||||||
|
def _make_request(
|
||||||
|
self, url: str, params: Optional[Dict] = None
|
||||||
|
) -> Optional[requests.Response]:
|
||||||
|
"""
|
||||||
|
Выполнение HTTP-запроса с повторными попытками
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL для запроса
|
||||||
|
params: Параметры запроса
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response объект или None в случае ошибки
|
||||||
|
"""
|
||||||
|
for attempt in range(self.max_retries):
|
||||||
|
try:
|
||||||
|
self.logger.debug(
|
||||||
|
f"Запрос к {url} (попытка {attempt + 1}/{self.max_retries})"
|
||||||
|
)
|
||||||
|
response = self.session.get(url, params=params, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Задержка между запросами
|
||||||
|
time.sleep(self.delay)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
self.logger.warning(f"Ошибка при запросе {url}: {e}")
|
||||||
|
if attempt < self.max_retries - 1:
|
||||||
|
time.sleep(self.delay * (attempt + 1))
|
||||||
|
else:
|
||||||
|
self.logger.error(
|
||||||
|
f"Не удалось выполнить запрос к {url} после {self.max_retries} попыток"
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _parse_html(self, html: str) -> BeautifulSoup:
|
||||||
|
"""
|
||||||
|
Парсинг HTML
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: HTML-контент
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BeautifulSoup объект
|
||||||
|
"""
|
||||||
|
return BeautifulSoup(html, "lxml")
|
||||||
|
|
||||||
|
def _get_pagination_urls(self, base_url: str, soup: BeautifulSoup) -> List[str]:
|
||||||
|
"""
|
||||||
|
Получение списка URL всех страниц с пагинацией
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Базовый URL реестра
|
||||||
|
soup: Parsed HTML основной страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список URL всех страниц
|
||||||
|
"""
|
||||||
|
urls = [base_url]
|
||||||
|
|
||||||
|
# Поиск пагинации (разные варианты классов)
|
||||||
|
pagination = (
|
||||||
|
soup.find("div", class_="b-pagination-block")
|
||||||
|
or soup.find("div", class_="pagination")
|
||||||
|
or soup.find("ul", class_="pagination")
|
||||||
|
)
|
||||||
|
|
||||||
|
if pagination:
|
||||||
|
# Находим максимальный номер страницы
|
||||||
|
max_page = 1
|
||||||
|
links = pagination.find_all("a")
|
||||||
|
|
||||||
|
for link in links:
|
||||||
|
href = link.get("href")
|
||||||
|
if href and href not in ["#", ""]:
|
||||||
|
# Проверяем, что href - строка, а не список
|
||||||
|
if isinstance(href, list):
|
||||||
|
href = href[0] if href else None
|
||||||
|
if href:
|
||||||
|
# Извлекаем номер страницы из URL
|
||||||
|
# Формат: ?PAGEN_1=page-N
|
||||||
|
match = re.search(r"page-(\d+)", str(href))
|
||||||
|
if match:
|
||||||
|
page_num = int(match.group(1))
|
||||||
|
max_page = max(max_page, page_num)
|
||||||
|
else:
|
||||||
|
# Добавляем URL как есть (для других форматов пагинации)
|
||||||
|
page_url = urljoin(BASE_URL, href)
|
||||||
|
if page_url not in urls:
|
||||||
|
urls.append(page_url)
|
||||||
|
|
||||||
|
# Генерируем URL-ы для всех страниц
|
||||||
|
if max_page > 1:
|
||||||
|
self.logger.info(f"Обнаружено страниц: {max_page}")
|
||||||
|
urls = [base_url] # Очищаем и добавляем первую
|
||||||
|
for page_num in range(2, max_page + 1):
|
||||||
|
# Формируем URL для каждой страницы
|
||||||
|
page_url = f"{base_url}?PAGEN_1=page-{page_num}"
|
||||||
|
urls.append(page_url)
|
||||||
|
|
||||||
|
self.logger.info(f"Найдено страниц с пагинацией: {len(urls)}")
|
||||||
|
return urls
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_list_page(self, soup: BeautifulSoup) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Парсинг страницы со списком записей реестра
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: Parsed HTML страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей с данными
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def parse_detail_page(self, url: str, soup: BeautifulSoup) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Парсинг детальной страницы записи
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL детальной страницы
|
||||||
|
soup: Parsed HTML страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с детальными данными
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def parse_registry(self, detailed: bool = False) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Парсинг всего реестра
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detailed: Парсить ли детальные страницы
|
||||||
|
True - парсить все страницы с детальной информацией
|
||||||
|
False - парсить только первую страницу без деталей
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список собранных данных
|
||||||
|
"""
|
||||||
|
self.logger.info(f"Начало парсинга реестра: {self.registry_name}")
|
||||||
|
all_data = []
|
||||||
|
|
||||||
|
# Получение первой страницы
|
||||||
|
response = self._make_request(self.registry_url)
|
||||||
|
if not response:
|
||||||
|
self.logger.error("Не удалось получить первую страницу реестра")
|
||||||
|
return all_data
|
||||||
|
|
||||||
|
soup = self._parse_html(response.text)
|
||||||
|
|
||||||
|
# В режиме detailed=True парсим все страницы, иначе только первую
|
||||||
|
if detailed:
|
||||||
|
pagination_urls = self._get_pagination_urls(self.registry_url, soup)
|
||||||
|
else:
|
||||||
|
pagination_urls = [self.registry_url]
|
||||||
|
self.logger.info("Режим быстрого сканирования: только первая страница")
|
||||||
|
|
||||||
|
# Парсинг каждой страницы
|
||||||
|
for page_num, page_url in enumerate(pagination_urls, 1):
|
||||||
|
self.logger.info(
|
||||||
|
f"Парсинг страницы {page_num}/{len(pagination_urls)}: {page_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if page_num > 1: # Первую страницу уже получили
|
||||||
|
response = self._make_request(page_url)
|
||||||
|
if not response:
|
||||||
|
continue
|
||||||
|
soup = self._parse_html(response.text)
|
||||||
|
|
||||||
|
# Парсинг списка на странице
|
||||||
|
page_data = self.parse_list_page(soup)
|
||||||
|
self.logger.info(f"Найдено записей на странице: {len(page_data)}")
|
||||||
|
|
||||||
|
# Детальный парсинг, если требуется
|
||||||
|
if detailed:
|
||||||
|
for item in page_data:
|
||||||
|
if "detail_url" in item and item["detail_url"] is not None:
|
||||||
|
detail_response = self._make_request(item["detail_url"])
|
||||||
|
if detail_response:
|
||||||
|
detail_soup = self._parse_html(detail_response.text)
|
||||||
|
detail_data = self.parse_detail_page(
|
||||||
|
item["detail_url"], detail_soup
|
||||||
|
)
|
||||||
|
item.update(detail_data)
|
||||||
|
|
||||||
|
all_data.extend(page_data)
|
||||||
|
|
||||||
|
self.logger.info(f"Парсинг завершен. Всего записей: {len(all_data)}")
|
||||||
|
return all_data
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Универсальный парсер для реестров с табличной структурой
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from parsers.base_parser import BaseParser
|
||||||
|
from config import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
class GenericRegistryParser(BaseParser):
|
||||||
|
"""
|
||||||
|
Универсальный парсер для реестров с простой табличной структурой
|
||||||
|
Может использоваться для реестров, где не требуется специальная обработка
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, registry_key: str):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
registry_key: Ключ реестра из config.REGISTRIES
|
||||||
|
"""
|
||||||
|
from config import REGISTRIES
|
||||||
|
|
||||||
|
registry = REGISTRIES[registry_key]
|
||||||
|
super().__init__(registry["url"], registry["name"])
|
||||||
|
self.registry_key = registry_key
|
||||||
|
|
||||||
|
def parse_list_page(self, soup: BeautifulSoup) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Универсальный парсинг таблицы
|
||||||
|
"""
|
||||||
|
items = []
|
||||||
|
|
||||||
|
table = soup.find("table")
|
||||||
|
if not table:
|
||||||
|
self.logger.warning("Таблица с данными не найдена")
|
||||||
|
return items
|
||||||
|
|
||||||
|
# Получаем заголовки
|
||||||
|
headers = []
|
||||||
|
header_row = table.find("tr")
|
||||||
|
if header_row:
|
||||||
|
headers = [
|
||||||
|
th.get_text(strip=True) for th in header_row.find_all(["th", "td"])
|
||||||
|
]
|
||||||
|
|
||||||
|
# Парсим строки
|
||||||
|
rows = table.find_all("tr")[1:]
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
cols = row.find_all("td")
|
||||||
|
if not cols:
|
||||||
|
continue
|
||||||
|
|
||||||
|
item_data = {}
|
||||||
|
|
||||||
|
# Сопоставляем колонки с заголовками
|
||||||
|
for i, col in enumerate(cols):
|
||||||
|
header_name = headers[i] if i < len(headers) else f"col_{i}"
|
||||||
|
item_data[header_name] = col.get_text(strip=True)
|
||||||
|
|
||||||
|
# Ищем ссылку на детальную страницу
|
||||||
|
link = row.find("a")
|
||||||
|
if link:
|
||||||
|
href = link.get("href")
|
||||||
|
# Проверяем, что href - строка, а не список
|
||||||
|
if isinstance(href, list):
|
||||||
|
href = href[0] if href else None
|
||||||
|
if href and isinstance(href, str):
|
||||||
|
item_data["detail_url"] = urljoin(BASE_URL, href)
|
||||||
|
|
||||||
|
items.append(item_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при парсинге строки: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def parse_detail_page(self, url: str, soup: BeautifulSoup) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Универсальный парсинг детальной страницы
|
||||||
|
"""
|
||||||
|
detail_data = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ищем все блоки с информацией
|
||||||
|
info_blocks = soup.find_all(
|
||||||
|
["div", "table"], class_=["info-block", "detail-info", "info"]
|
||||||
|
)
|
||||||
|
|
||||||
|
for block in info_blocks:
|
||||||
|
# Пробуем различные варианты структуры
|
||||||
|
|
||||||
|
# Вариант 1: dt/dd
|
||||||
|
labels = block.find_all("dt")
|
||||||
|
values = block.find_all("dd")
|
||||||
|
|
||||||
|
if labels and values:
|
||||||
|
for label, value in zip(labels, values):
|
||||||
|
key = label.get_text(strip=True)
|
||||||
|
val = value.get_text(strip=True)
|
||||||
|
detail_data[key] = val
|
||||||
|
|
||||||
|
# Вариант 2: div с классами
|
||||||
|
label_divs = block.find_all("div", class_=["label", "info-label"])
|
||||||
|
value_divs = block.find_all("div", class_=["value", "info-value"])
|
||||||
|
|
||||||
|
if label_divs and value_divs:
|
||||||
|
for label, value in zip(label_divs, value_divs):
|
||||||
|
key = label.get_text(strip=True)
|
||||||
|
val = value.get_text(strip=True)
|
||||||
|
detail_data[key] = val
|
||||||
|
|
||||||
|
detail_data["source_url"] = url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при парсинге детальной страницы: {e}")
|
||||||
|
|
||||||
|
return detail_data
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Парсер реестра аудиторских организаций
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
import re
|
||||||
|
|
||||||
|
from parsers.base_parser import BaseParser
|
||||||
|
from models.organization import Organization
|
||||||
|
from config import BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationsParser(BaseParser):
|
||||||
|
"""
|
||||||
|
Парсер для реестра аудиторских организаций
|
||||||
|
https://sroaas.ru/reestr/organizatsiy/
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
from config import REGISTRIES
|
||||||
|
|
||||||
|
registry = REGISTRIES["organizations"]
|
||||||
|
super().__init__(registry["url"], registry["name"])
|
||||||
|
|
||||||
|
def parse_list_page(self, soup: BeautifulSoup) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Парсинг страницы со списком аудиторских организаций
|
||||||
|
|
||||||
|
Args:
|
||||||
|
soup: Parsed HTML страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список словарей с данными организаций
|
||||||
|
"""
|
||||||
|
organizations = []
|
||||||
|
|
||||||
|
# Поиск таблицы с данными
|
||||||
|
table = soup.find("table")
|
||||||
|
if not table:
|
||||||
|
self.logger.warning("Таблица с данными не найдена на странице")
|
||||||
|
return organizations
|
||||||
|
|
||||||
|
# Парсинг строк таблицы
|
||||||
|
rows = table.find_all("tr")[1:] # Пропускаем заголовок
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
cols = row.find_all("td")
|
||||||
|
if len(cols) < 4:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Извлечение данных
|
||||||
|
name = cols[0].get_text(strip=True)
|
||||||
|
ornz = cols[1].get_text(strip=True)
|
||||||
|
inn = cols[2].get_text(strip=True)
|
||||||
|
region = cols[3].get_text(strip=True)
|
||||||
|
|
||||||
|
# Статус (если есть)
|
||||||
|
status = cols[4].get_text(strip=True) if len(cols) > 4 else None
|
||||||
|
|
||||||
|
# URL детальной страницы (если есть ссылка)
|
||||||
|
detail_url = None
|
||||||
|
link = row.find("a")
|
||||||
|
if link:
|
||||||
|
href = link.get("href")
|
||||||
|
# Проверяем, что href - строка, а не список
|
||||||
|
if isinstance(href, list):
|
||||||
|
href = href[0] if href else None
|
||||||
|
if href:
|
||||||
|
detail_url = urljoin(BASE_URL, href)
|
||||||
|
|
||||||
|
org_data = {
|
||||||
|
"name": name,
|
||||||
|
"ornz": ornz,
|
||||||
|
"inn": inn,
|
||||||
|
"region": region,
|
||||||
|
"status": status,
|
||||||
|
"detail_url": detail_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
organizations.append(org_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при парсинге строки таблицы: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return organizations
|
||||||
|
|
||||||
|
def parse_detail_page(self, url: str, soup: BeautifulSoup) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Парсинг детальной страницы организации
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: URL детальной страницы
|
||||||
|
soup: Parsed HTML страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Словарь с детальными данными
|
||||||
|
"""
|
||||||
|
detail_data = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Поиск блока с детальной информацией
|
||||||
|
info_blocks = soup.find_all("div", class_=["info-block", "detail-info"])
|
||||||
|
|
||||||
|
for block in info_blocks:
|
||||||
|
# Извлечение пар ключ-значение
|
||||||
|
labels = block.find_all(["dt", "div"], class_=["label", "info-label"])
|
||||||
|
values = block.find_all(["dd", "div"], class_=["value", "info-value"])
|
||||||
|
|
||||||
|
for label, value in zip(labels, values):
|
||||||
|
key = label.get_text(strip=True).lower()
|
||||||
|
val = value.get_text(strip=True)
|
||||||
|
|
||||||
|
# Маппинг полей
|
||||||
|
if "полное наименование" in key or "полное название" in key:
|
||||||
|
detail_data["full_name"] = val
|
||||||
|
elif "огрн" in key:
|
||||||
|
detail_data["ogrn"] = val
|
||||||
|
elif "кпп" in key:
|
||||||
|
detail_data["kpp"] = val
|
||||||
|
elif "адрес" in key:
|
||||||
|
detail_data["address"] = val
|
||||||
|
elif "телефон" in key or "тел" in key:
|
||||||
|
detail_data["phone"] = val
|
||||||
|
elif "email" in key or "e-mail" in key or "почта" in key:
|
||||||
|
detail_data["email"] = val
|
||||||
|
elif "сайт" in key or "веб-сайт" in key:
|
||||||
|
detail_data["website"] = val
|
||||||
|
elif "руководитель" in key or "директор" in key:
|
||||||
|
detail_data["director"] = val
|
||||||
|
elif "дата регистрации" in key:
|
||||||
|
detail_data["registration_date"] = val
|
||||||
|
elif "дата начала членства" in key or "дата вступления" in key:
|
||||||
|
detail_data["membership_start_date"] = val
|
||||||
|
elif "количество аудиторов" in key:
|
||||||
|
# Извлечение числа
|
||||||
|
match = re.search(r"\d+", val)
|
||||||
|
if match:
|
||||||
|
detail_data["auditors_count"] = int(match.group())
|
||||||
|
|
||||||
|
# Поиск информации о сертификатах
|
||||||
|
certificates_section = soup.find(
|
||||||
|
"div", class_=["certificates", "attestaty"]
|
||||||
|
)
|
||||||
|
if certificates_section:
|
||||||
|
certs = []
|
||||||
|
cert_items = certificates_section.find_all(
|
||||||
|
["li", "div"], class_="certificate-item"
|
||||||
|
)
|
||||||
|
for cert in cert_items:
|
||||||
|
certs.append(cert.get_text(strip=True))
|
||||||
|
if certs:
|
||||||
|
detail_data["certificates"] = certs
|
||||||
|
|
||||||
|
# Поиск информации о сетях
|
||||||
|
networks_section = soup.find("div", class_=["networks", "seti"])
|
||||||
|
if networks_section:
|
||||||
|
nets = []
|
||||||
|
net_items = networks_section.find_all(
|
||||||
|
["li", "div"], class_="network-item"
|
||||||
|
)
|
||||||
|
for net in net_items:
|
||||||
|
nets.append(net.get_text(strip=True))
|
||||||
|
if nets:
|
||||||
|
detail_data["networks"] = nets
|
||||||
|
|
||||||
|
detail_data["source_url"] = url
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при парсинге детальной страницы {url}: {e}")
|
||||||
|
|
||||||
|
return detail_data
|
||||||
|
|
||||||
|
def parse_to_objects(self, detailed: bool = False) -> List[Organization]:
|
||||||
|
"""
|
||||||
|
Парсинг реестра с преобразованием в объекты Organization
|
||||||
|
|
||||||
|
Args:
|
||||||
|
detailed: Парсить ли детальные страницы
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список объектов Organization
|
||||||
|
"""
|
||||||
|
data = self.parse_registry(detailed=detailed)
|
||||||
|
organizations = []
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
try:
|
||||||
|
org = Organization(
|
||||||
|
name=item.get("name", ""),
|
||||||
|
ornz=item.get("ornz", ""),
|
||||||
|
inn=item.get("inn", ""),
|
||||||
|
region=item.get("region", ""),
|
||||||
|
status=item.get("status"),
|
||||||
|
full_name=item.get("full_name"),
|
||||||
|
ogrn=item.get("ogrn"),
|
||||||
|
kpp=item.get("kpp"),
|
||||||
|
address=item.get("address"),
|
||||||
|
phone=item.get("phone"),
|
||||||
|
email=item.get("email"),
|
||||||
|
website=item.get("website"),
|
||||||
|
director=item.get("director"),
|
||||||
|
auditors_count=item.get("auditors_count"),
|
||||||
|
certificates=item.get("certificates", []),
|
||||||
|
networks=item.get("networks", []),
|
||||||
|
source_url=item.get("source_url"),
|
||||||
|
)
|
||||||
|
organizations.append(org)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при создании объекта Organization: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return organizations
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Основные библиотеки для парсинга
|
||||||
|
requests>=2.31.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
lxml>=4.9.0
|
||||||
|
|
||||||
|
# Работа с Excel
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
pandas>=2.0.0
|
||||||
|
|
||||||
|
# Selenium для динамического контента (опционально)
|
||||||
|
selenium>=4.15.0
|
||||||
|
|
||||||
|
# Утилиты
|
||||||
|
python-dateutil>=2.8.0
|
||||||
|
tqdm>=4.66.0
|
||||||
|
|
||||||
|
# Логирование
|
||||||
|
colorlog>=6.7.0
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env fish
|
||||||
|
|
||||||
|
# Скрипт быстрого запуска для Fish Shell
|
||||||
|
# Использование: ./run.fish
|
||||||
|
|
||||||
|
echo "=============================================="
|
||||||
|
echo " ПАРСЕР РЕЕСТРОВ СРО ААС"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка наличия Python
|
||||||
|
if not command -v python3 &> /dev/null
|
||||||
|
echo "❌ Python 3 не найден. Установите Python 3.8 или выше."
|
||||||
|
exit 1
|
||||||
|
end
|
||||||
|
|
||||||
|
echo "✅ Python найден: "(python3 --version)
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка виртуального окружения
|
||||||
|
if not test -d venv
|
||||||
|
echo "📦 Виртуальное окружение не найдено. Создание..."
|
||||||
|
python3 -m venv venv
|
||||||
|
echo "✅ Виртуальное окружение создано."
|
||||||
|
echo ""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Активация виртуального окружения
|
||||||
|
echo "🔧 Активация виртуального окружения..."
|
||||||
|
source venv/bin/activate.fish
|
||||||
|
|
||||||
|
# Проверка зависимостей
|
||||||
|
if not python -c "import requests" 2>/dev/null
|
||||||
|
echo "📥 Установка зависимостей..."
|
||||||
|
pip install -q --upgrade pip
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
echo "✅ Зависимости установлены."
|
||||||
|
echo ""
|
||||||
|
end
|
||||||
|
|
||||||
|
# Создание необходимых директорий
|
||||||
|
mkdir -p data/exports
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# Запуск парсера
|
||||||
|
echo "🚀 Запуск парсера..."
|
||||||
|
echo ""
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Деактивация виртуального окружения
|
||||||
|
deactivate
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Скрипт быстрого запуска парсера СРО ААС
|
||||||
|
# Использование: ./run.sh
|
||||||
|
|
||||||
|
echo "=============================================="
|
||||||
|
echo " ПАРСЕР РЕЕСТРОВ СРО ААС"
|
||||||
|
echo "=============================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка наличия Python
|
||||||
|
if ! command -v python3 &> /dev/null; then
|
||||||
|
echo "❌ Python 3 не найден. Установите Python 3.8 или выше."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Python найден: $(python3 --version)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Проверка виртуального окружения
|
||||||
|
if [ ! -d "venv" ]; then
|
||||||
|
echo "📦 Виртуальное окружение не найдено. Создание..."
|
||||||
|
python3 -m venv venv
|
||||||
|
echo "✅ Виртуальное окружение создано."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Активация виртуального окружения
|
||||||
|
echo "🔧 Активация виртуального окружения..."
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Проверка зависимостей
|
||||||
|
if ! python -c "import requests" 2>/dev/null; then
|
||||||
|
echo "📥 Установка зависимостей..."
|
||||||
|
pip install -q --upgrade pip
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
echo "✅ Зависимости установлены."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Создание необходимых директорий
|
||||||
|
mkdir -p data/exports
|
||||||
|
mkdir -p logs
|
||||||
|
|
||||||
|
# Запуск парсера
|
||||||
|
echo "🚀 Запуск парсера..."
|
||||||
|
echo ""
|
||||||
|
python main.py
|
||||||
|
|
||||||
|
# Деактивация виртуального окружения
|
||||||
|
deactivate
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""
|
||||||
|
Вспомогательные утилиты
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .logger import setup_logger
|
||||||
|
from .excel_exporter import ExcelExporter
|
||||||
|
|
||||||
|
__all__ = ["setup_logger", "ExcelExporter"]
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
Экспорт данных в Excel
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
import pandas as pd
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.styles import Font, Alignment, PatternFill
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
from config import EXPORT_CONFIG
|
||||||
|
from utils.logger import setup_logger
|
||||||
|
|
||||||
|
|
||||||
|
class ExcelExporter:
|
||||||
|
"""
|
||||||
|
Класс для экспорта данных в Excel
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, output_dir: str = None):
|
||||||
|
"""
|
||||||
|
Инициализация экспортера
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Директория для сохранения файлов
|
||||||
|
"""
|
||||||
|
self.output_dir = output_dir or EXPORT_CONFIG["output_dir"]
|
||||||
|
self.logger = setup_logger(self.__class__.__name__)
|
||||||
|
|
||||||
|
# Создание директории, если не существует
|
||||||
|
os.makedirs(self.output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
def export_to_excel(
|
||||||
|
self,
|
||||||
|
data: List[Dict[str, Any]],
|
||||||
|
filename: str = None,
|
||||||
|
sheet_name: str = "Данные",
|
||||||
|
auto_format: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Экспорт данных в Excel файл
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Список словарей с данными
|
||||||
|
filename: Имя файла (без расширения)
|
||||||
|
sheet_name: Название листа
|
||||||
|
auto_format: Применять ли автоформатирование
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Путь к созданному файлу
|
||||||
|
"""
|
||||||
|
if not data:
|
||||||
|
self.logger.warning("Нет данных для экспорта")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Генерация имени файла
|
||||||
|
if not filename:
|
||||||
|
timestamp = datetime.now().strftime(EXPORT_CONFIG["date_format"])
|
||||||
|
filename = f"export_{timestamp}"
|
||||||
|
|
||||||
|
filepath = os.path.join(self.output_dir, f"{filename}.xlsx")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создание DataFrame
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
|
||||||
|
# Экспорт в Excel
|
||||||
|
self.logger.info(f"Экспорт {len(data)} записей в {filepath}")
|
||||||
|
df.to_excel(filepath, sheet_name=sheet_name, index=False, engine="openpyxl")
|
||||||
|
|
||||||
|
# Применение форматирования
|
||||||
|
if auto_format:
|
||||||
|
self._format_excel(filepath, sheet_name)
|
||||||
|
|
||||||
|
self.logger.info(f"Данные успешно экспортированы в {filepath}")
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при экспорте в Excel: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _format_excel(self, filepath: str, sheet_name: str):
|
||||||
|
"""
|
||||||
|
Применение форматирования к Excel файлу
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Путь к файлу
|
||||||
|
sheet_name: Название листа
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
|
||||||
|
# Стили
|
||||||
|
header_fill = PatternFill(
|
||||||
|
start_color="4472C4", end_color="4472C4", fill_type="solid"
|
||||||
|
)
|
||||||
|
header_font = Font(bold=True, color="FFFFFF", size=11)
|
||||||
|
header_alignment = Alignment(
|
||||||
|
horizontal="center", vertical="center", wrap_text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
cell_alignment = Alignment(
|
||||||
|
horizontal="left", vertical="top", wrap_text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Форматирование заголовков
|
||||||
|
for cell in ws[1]:
|
||||||
|
cell.fill = header_fill
|
||||||
|
cell.font = header_font
|
||||||
|
cell.alignment = header_alignment
|
||||||
|
|
||||||
|
# Форматирование ячеек данных
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||||
|
for cell in row:
|
||||||
|
cell.alignment = cell_alignment
|
||||||
|
|
||||||
|
# Автоподбор ширины столбцов
|
||||||
|
for column in ws.columns:
|
||||||
|
max_length = 0
|
||||||
|
column_letter = get_column_letter(column[0].column)
|
||||||
|
|
||||||
|
for cell in column:
|
||||||
|
try:
|
||||||
|
if cell.value:
|
||||||
|
max_length = max(max_length, len(str(cell.value)))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
adjusted_width = min(max_length + 2, 50) # Максимум 50 символов
|
||||||
|
ws.column_dimensions[column_letter].width = adjusted_width
|
||||||
|
|
||||||
|
# Закрепление первой строки
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
|
||||||
|
# Сохранение
|
||||||
|
wb.save(filepath)
|
||||||
|
self.logger.debug(f"Форматирование применено к {filepath}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при форматировании Excel: {e}")
|
||||||
|
|
||||||
|
def export_organizations(
|
||||||
|
self, organizations: List[Any], filename: str = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Экспорт списка организаций (объектов Organization)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
organizations: Список объектов Organization
|
||||||
|
filename: Имя файла
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Путь к созданному файлу
|
||||||
|
"""
|
||||||
|
# Преобразование объектов в словари
|
||||||
|
data = []
|
||||||
|
for org in organizations:
|
||||||
|
if hasattr(org, "to_dict"):
|
||||||
|
data.append(org.to_dict())
|
||||||
|
else:
|
||||||
|
data.append(vars(org))
|
||||||
|
|
||||||
|
return self.export_to_excel(
|
||||||
|
data=data,
|
||||||
|
filename=filename or "organizations",
|
||||||
|
sheet_name="Аудиторские организации",
|
||||||
|
)
|
||||||
|
|
||||||
|
def export_multiple_sheets(
|
||||||
|
self, sheets_data: Dict[str, List[Dict[str, Any]]], filename: str = None
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Экспорт данных в Excel с несколькими листами
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sheets_data: Словарь {название_листа: данные}
|
||||||
|
filename: Имя файла
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Путь к созданному файлу
|
||||||
|
"""
|
||||||
|
if not sheets_data:
|
||||||
|
self.logger.warning("Нет данных для экспорта")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Генерация имени файла
|
||||||
|
if not filename:
|
||||||
|
timestamp = datetime.now().strftime(EXPORT_CONFIG["date_format"])
|
||||||
|
filename = f"export_multi_{timestamp}"
|
||||||
|
|
||||||
|
filepath = os.path.join(self.output_dir, f"{filename}.xlsx")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with pd.ExcelWriter(filepath, engine="openpyxl") as writer:
|
||||||
|
for sheet_name, data in sheets_data.items():
|
||||||
|
if data:
|
||||||
|
df = pd.DataFrame(data)
|
||||||
|
df.to_excel(writer, sheet_name=sheet_name, index=False)
|
||||||
|
|
||||||
|
self.logger.info(f"Данные успешно экспортированы в {filepath}")
|
||||||
|
|
||||||
|
# Форматирование каждого листа
|
||||||
|
wb = load_workbook(filepath)
|
||||||
|
for sheet_name in sheets_data.keys():
|
||||||
|
if sheet_name in wb.sheetnames:
|
||||||
|
self._format_excel(filepath, sheet_name)
|
||||||
|
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Ошибка при экспорте в Excel: {e}")
|
||||||
|
return None
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
Настройка логирования
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from config import LOGGING_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logger(name: str = "sro_parser") -> logging.Logger:
|
||||||
|
"""
|
||||||
|
Настройка логгера для парсера
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Имя логгера
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Настроенный логгер
|
||||||
|
"""
|
||||||
|
# Создание директории для логов
|
||||||
|
log_dir = LOGGING_CONFIG.get("log_dir", "logs/")
|
||||||
|
os.makedirs(log_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Создание логгера
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
logger.setLevel(LOGGING_CONFIG.get("level", "INFO"))
|
||||||
|
|
||||||
|
# Очистка существующих обработчиков
|
||||||
|
if logger.hasHandlers():
|
||||||
|
logger.handlers.clear()
|
||||||
|
|
||||||
|
# Формат логов
|
||||||
|
formatter = logging.Formatter(LOGGING_CONFIG.get("format"))
|
||||||
|
|
||||||
|
# Обработчик для консоли
|
||||||
|
console_handler = logging.StreamHandler()
|
||||||
|
console_handler.setLevel(logging.INFO)
|
||||||
|
console_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# Обработчик для файла
|
||||||
|
log_file = os.path.join(
|
||||||
|
log_dir, f"parser_{datetime.now().strftime('%Y-%m-%d')}.log"
|
||||||
|
)
|
||||||
|
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||||
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
return logger
|
||||||
Reference in New Issue
Block a user