From 12d5c6eaffc5e4d426386ea40d86ffac618850db Mon Sep 17 00:00:00 2001 From: ada Date: Sat, 25 Oct 2025 20:31:45 +0300 Subject: [PATCH] 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. --- .gitignore | 53 ++++++ README.md | 136 ++++++++++++++ config.py | 106 +++++++++++ data/exports/.gitkeep | 8 + main.py | 315 ++++++++++++++++++++++++++++++++ models/__init__.py | 19 ++ models/audit_network.py | 55 ++++++ models/auditor.py | 77 ++++++++ models/certificate.py | 59 ++++++ models/disciplinary_action.py | 65 +++++++ models/organization.py | 81 ++++++++ models/training_center.py | 79 ++++++++ parsers/__init__.py | 15 ++ parsers/auditors_parser.py | 174 ++++++++++++++++++ parsers/base_parser.py | 235 ++++++++++++++++++++++++ parsers/generic_parser.py | 123 +++++++++++++ parsers/organizations_parser.py | 216 ++++++++++++++++++++++ requirements.txt | 18 ++ run.fish | 51 ++++++ run.sh | 51 ++++++ utils/__init__.py | 8 + utils/excel_exporter.py | 215 ++++++++++++++++++++++ utils/logger.py | 51 ++++++ 23 files changed, 2210 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.py create mode 100644 data/exports/.gitkeep create mode 100755 main.py create mode 100644 models/__init__.py create mode 100644 models/audit_network.py create mode 100644 models/auditor.py create mode 100644 models/certificate.py create mode 100644 models/disciplinary_action.py create mode 100644 models/organization.py create mode 100644 models/training_center.py create mode 100644 parsers/__init__.py create mode 100644 parsers/auditors_parser.py create mode 100644 parsers/base_parser.py create mode 100644 parsers/generic_parser.py create mode 100644 parsers/organizations_parser.py create mode 100644 requirements.txt create mode 100755 run.fish create mode 100755 run.sh create mode 100644 utils/__init__.py create mode 100644 utils/excel_exporter.py create mode 100644 utils/logger.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..424bd96 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..337f58b --- /dev/null +++ b/README.md @@ -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 +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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..fadd7af --- /dev/null +++ b/config.py @@ -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/", +} diff --git a/data/exports/.gitkeep b/data/exports/.gitkeep new file mode 100644 index 0000000..4f76f08 --- /dev/null +++ b/data/exports/.gitkeep @@ -0,0 +1,8 @@ +# Директория для экспортированных файлов + +Все файлы, экспортированные парсером, будут сохраняться здесь. + +Формат имени файла: `organizations_YYYY-MM-DD_HH-MM-SS.xlsx` + +Пример: +- organizations_2025-10-24_15-30-45.xlsx diff --git a/main.py b/main.py new file mode 100755 index 0000000..c916261 --- /dev/null +++ b/main.py @@ -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) diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..8f06ce7 --- /dev/null +++ b/models/__init__.py @@ -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", +] diff --git a/models/audit_network.py b/models/audit_network.py new file mode 100644 index 0000000..6996808 --- /dev/null +++ b/models/audit_network.py @@ -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"), + } diff --git a/models/auditor.py b/models/auditor.py new file mode 100644 index 0000000..7df63f4 --- /dev/null +++ b/models/auditor.py @@ -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"), + } diff --git a/models/certificate.py b/models/certificate.py new file mode 100644 index 0000000..6d5ef6e --- /dev/null +++ b/models/certificate.py @@ -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"), + } diff --git a/models/disciplinary_action.py b/models/disciplinary_action.py new file mode 100644 index 0000000..c9502f8 --- /dev/null +++ b/models/disciplinary_action.py @@ -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"), + } diff --git a/models/organization.py b/models/organization.py new file mode 100644 index 0000000..ea30232 --- /dev/null +++ b/models/organization.py @@ -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"), + } diff --git a/models/training_center.py b/models/training_center.py new file mode 100644 index 0000000..a9d2b62 --- /dev/null +++ b/models/training_center.py @@ -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"), + } diff --git a/parsers/__init__.py b/parsers/__init__.py new file mode 100644 index 0000000..256dc91 --- /dev/null +++ b/parsers/__init__.py @@ -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", +] diff --git a/parsers/auditors_parser.py b/parsers/auditors_parser.py new file mode 100644 index 0000000..69560f0 --- /dev/null +++ b/parsers/auditors_parser.py @@ -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 diff --git a/parsers/base_parser.py b/parsers/base_parser.py new file mode 100644 index 0000000..38b189b --- /dev/null +++ b/parsers/base_parser.py @@ -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 diff --git a/parsers/generic_parser.py b/parsers/generic_parser.py new file mode 100644 index 0000000..c735882 --- /dev/null +++ b/parsers/generic_parser.py @@ -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 diff --git a/parsers/organizations_parser.py b/parsers/organizations_parser.py new file mode 100644 index 0000000..c3527bd --- /dev/null +++ b/parsers/organizations_parser.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5d2816a --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run.fish b/run.fish new file mode 100755 index 0000000..143044e --- /dev/null +++ b/run.fish @@ -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 diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..1ea2ca5 --- /dev/null +++ b/run.sh @@ -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 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..921abef --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,8 @@ +""" +Вспомогательные утилиты +""" + +from .logger import setup_logger +from .excel_exporter import ExcelExporter + +__all__ = ["setup_logger", "ExcelExporter"] diff --git a/utils/excel_exporter.py b/utils/excel_exporter.py new file mode 100644 index 0000000..38bdfda --- /dev/null +++ b/utils/excel_exporter.py @@ -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 diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..7261ed1 --- /dev/null +++ b/utils/logger.py @@ -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