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:
ada
2025-10-25 20:31:45 +03:00
commit 12d5c6eaff
23 changed files with 2210 additions and 0 deletions
+15
View File
@@ -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",
]
+174
View File
@@ -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
+235
View File
@@ -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
+123
View File
@@ -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
+216
View File
@@ -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