Enhance application with new features, improved error handling, and performance optimizations. Key updates include: added data validation, retry strategies for HTTP requests, detailed logging, and support for RabbitMQ exports. Updated dependencies and enhanced README documentation for better setup instructions.

This commit is contained in:
2025-07-05 19:53:03 +03:00
parent 0ddd9f0683
commit 97e6438e9b
16 changed files with 1795 additions and 403 deletions

2
src/core/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# ANCHOR: Core_Package_Init
# Семантика: Инициализация пакета core

View File

@@ -8,7 +8,7 @@ from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional
from src.core.models import ProductVariant, LogRecordModel # [FIX] Импорт моделей
from core.models import ProductVariant, LogRecordModel # [FIX] Импорт моделей
# [CONTRACT] DatabaseManager
# @description: Контекстный менеджер для управления соединением с SQLite.
@@ -146,33 +146,84 @@ def init_database(db_path: Path, run_id: str):
# - `data` должен быть списком словарей, каждый из которых соответствует ProductVariant.
# - `db_path` должен указывать на существующую и инициализированную БД.
# @post: Данные из `data` вставлены в таблицу `products`.
def save_data_to_db(data: List[Dict], db_path: Path, run_id: str):
def save_data_to_db(data: List[Dict], db_path: Path, run_id: str) -> bool:
"""
[ENHANCED] Сохраняет данные в базу данных с улучшенной обработкой ошибок.
Args:
data: Список словарей с данными для сохранения
db_path: Путь к файлу базы данных
run_id: Идентификатор запуска для логирования
Returns:
bool: True если сохранение прошло успешно, False в противном случае
"""
log_prefix = f"save_data_to_db(id={run_id})"
# [ENHANCEMENT] Валидация входных данных
if not data:
logging.warning(f"{log_prefix} - [CONTRACT_VIOLATION] Данные для сохранения отсутствуют. Пропуск сохранения.")
return
return False
if not isinstance(data, list):
logging.error(f"{log_prefix} - [TYPE_ERROR] Данные должны быть списком, получено: {type(data)}")
return False
logging.info(f"{log_prefix} - Начало сохранения {len(data)} записей в БД: {db_path}")
# [PRECONDITION] Проверка формата данных (хотя ProductVariant.model_dump() должен гарантировать)
if not all(isinstance(item, dict) and all(k in item for k in ['name', 'volume', 'price']) for item in data):
required_fields = ['name', 'volume', 'price']
if not all(isinstance(item, dict) and all(k in item for k in required_fields) for item in data):
logging.error(f"{log_prefix} - [CONTRACT_VIOLATION] Некорректный формат данных для сохранения в БД.", extra={"sample_data": data[:1]})
raise ValueError("Данные для сохранения в БД не соответствуют ожидаемому формату ProductVariant.")
return False
try:
# [ENHANCEMENT] Проверка существования файла БД
if not db_path.exists():
logging.warning(f"{log_prefix} - Файл БД не существует: {db_path}")
return False
# [CONTEXT_MANAGER] Используем with-statement для безопасного соединения и коммита
with sqlite3.connect(db_path) as con:
cur = con.cursor()
products_to_insert = []
for item in data:
# Преобразование к int и обработка возможных ошибок приведения типа
skipped_count = 0
for i, item in enumerate(data):
# [ENHANCEMENT] Детальная валидация каждого элемента
try:
price_int = int(item['price'])
except (ValueError, TypeError) as e:
logging.error(f"{log_prefix} - [DATA_CLEANUP_FAILED] Некорректное значение цены для '{item.get('name')}': {item.get('price')}. Пропуск записи. Ошибка: {e}")
# [COHERENCE_CHECK_FAILED] Данные не соответствуют схеме
continue # Пропускаем эту запись, но продолжаем для остальных
products_to_insert.append(
(run_id, item['name'], item['volume'], price_int)
)
# Проверка типов данных
if not isinstance(item['name'], str) or not item['name'].strip():
logging.warning(f"{log_prefix} - [INVALID_NAME] Элемент {i}: некорректное имя '{item.get('name')}'")
skipped_count += 1
continue
if not isinstance(item['volume'], str):
logging.warning(f"{log_prefix} - [INVALID_VOLUME] Элемент {i}: некорректный объем '{item.get('volume')}'")
skipped_count += 1
continue
# Преобразование к int и обработка возможных ошибок приведения типа
try:
price_int = int(item['price'])
if price_int <= 0:
logging.warning(f"{log_prefix} - [INVALID_PRICE] Элемент {i}: некорректная цена {price_int}")
skipped_count += 1
continue
except (ValueError, TypeError) as e:
logging.error(f"{log_prefix} - [DATA_CLEANUP_FAILED] Некорректное значение цены для '{item.get('name')}': {item.get('price')}. Пропуск записи. Ошибка: {e}")
skipped_count += 1
continue # Пропускаем эту запись, но продолжаем для остальных
products_to_insert.append(
(run_id, item['name'], item['volume'], price_int)
)
except KeyError as e:
logging.error(f"{log_prefix} - [MISSING_FIELD] Элемент {i} не содержит обязательное поле: {e}")
skipped_count += 1
continue
if products_to_insert:
cur.executemany(
"INSERT INTO products (run_id, name, volume, price) VALUES (?, ?, ?, ?)",
@@ -180,85 +231,21 @@ def save_data_to_db(data: List[Dict], db_path: Path, run_id: str):
)
con.commit()
logging.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] {len(products_to_insert)} записей успешно сохранено в базу данных.")
if skipped_count > 0:
logging.warning(f"{log_prefix} - Пропущено {skipped_count} некорректных записей.")
return True
else:
logging.warning(f"{log_prefix} - После фильтрации не осталось валидных записей для сохранения.")
return False
except sqlite3.Error as e:
logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка SQLite при сохранении данных: {e}", exc_info=True)
raise ConnectionError(f"Ошибка БД при сохранении: {e}") from e
return False
except PermissionError as e:
logging.error(f"{log_prefix} - [PERMISSION_ERROR] Нет прав на запись в БД {db_path}: {e}")
return False
except Exception as e:
logging.critical(f"{log_prefix} - [CRITICAL] Непредвиденная ошибка при сохранении данных в БД: {e}", exc_info=True)
raise
return False
# [CONTRACT] save_data_to_db
# @description: Сохраняет список объектов ProductVariant (представленных как словари) в таблицу `products`.
# @pre:
# - `data` должен быть списком словарей, каждый из которых соответствует ProductVariant.
# - `db_path` должен указывать на существующую и инициализированную БД.
# @post: Данные из `data` вставлены в таблицу `products`.
def save_data_to_db(data: List[Dict], db_path: Path, run_id: str):
log_prefix = f"save_data_to_db(id={run_id})"
if not data:
logging.warning(f"{log_prefix} - [CONTRACT_VIOLATION] Данные для сохранения отсутствуют. Пропуск сохранения.")
return
logging.info(f"{log_prefix} - Начало сохранения {len(data)} записей в БД: {db_path}")
# [PRECONDITION] Проверка формата данных (хотя ProductVariant.model_dump() должен гарантировать)
if not all(isinstance(item, dict) and all(k in item for k in ['name', 'volume', 'price']) for item in data):
logging.error(f"{log_prefix} - [CONTRACT_VIOLATION] Некорректный формат данных для сохранения в БД.", extra={"sample_data": data[:1]})
raise ValueError("Данные для сохранения в БД не соответствуют ожидаемому формату ProductVariant.")
try:
# [CONTEXT_MANAGER] Используем with-statement для безопасного соединения и коммита
with sqlite3.connect(db_path) as con:
cur = con.cursor()
products_to_insert = []
for item in data:
# Преобразование к int и обработка возможных ошибок приведения типа
try:
price_int = int(item['price'])
except (ValueError, TypeError) as e:
logging.error(f"{log_prefix} - [DATA_CLEANUP_FAILED] Некорректное значение цены для '{item.get('name')}': {item.get('price')}. Пропуск записи. Ошибка: {e}")
# [COHERENCE_CHECK_FAILED] Данные не соответствуют схеме
continue # Пропускаем эту запись, но продолжаем для остальных
products_to_insert.append(
(run_id, item['name'], item['volume'], price_int)
)
if products_to_insert:
cur.executemany(
"INSERT INTO products (run_id, name, volume, price) VALUES (?, ?, ?, ?)",
products_to_insert
)
con.commit()
logging.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] {len(products_to_insert)} записей успешно сохранено в базу данных.")
else:
logging.warning(f"{log_prefix} - После фильтрации не осталось валидных записей для сохранения.")
except sqlite3.Error as e:
logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка SQLite при сохранении данных: {e}", exc_info=True)
raise ConnectionError(f"Ошибка БД при сохранении: {e}") from e
except Exception as e:
logging.critical(f"{log_prefix} - [CRITICAL] Непредвиденная ошибка при сохранении данных в БД: {e}", exc_info=True)
raise
def save_data_to_db(data: List[Dict], db_path: Path, run_id: str):
# ... (код функции save_data_to_db без изменений) ...
log_prefix = f"save_data_to_db(id={run_id})"
if not data:
logging.warning(f"{log_prefix} - [CONTRACT_VIOLATION] Данные для сохранения отсутствуют.")
return
logging.info(f"{log_prefix} - Начало сохранения {len(data)} записей в БД: {db_path}")
try:
con = sqlite3.connect(db_path)
cur = con.cursor()
products_to_insert = [
(run_id, item['name'], item['volume'], int(item['price'])) for item in data
]
cur.executemany(
"INSERT INTO products (run_id, name, volume, price) VALUES (?, ?, ?, ?)",
products_to_insert
)
con.commit()
con.close()
logging.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Данные успешно сохранены в базу данных.")
except Exception as e:
logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка при сохранении в БД: {e}")
# [REFACTORING_COMPLETE] Дублированные функции удалены, улучшена обработка ошибок

View File

@@ -1,13 +1,14 @@
# [FILE] src/core/models.py
# ANCHOR: Core_Models_Module
# Семантика: Определяет Pydantic-модели для структурированного представления данных
# в приложении (продукты, логи).
# в приложении (продукты, логи, сообщения RabbitMQ).
# [CONTRACT]: Все модели наследуются от `BaseModel` и обеспечивают типизацию и валидацию.
# [COHERENCE]: Согласованы со схемами данных, используемыми в БД и экспортах.
from pydantic import BaseModel, Field, HttpUrl, ValidationError
from datetime import datetime
from typing import Optional
from typing import Optional, List
import uuid
class ProductVariant(BaseModel):
"""
@@ -61,4 +62,78 @@ class LogRecordModel(BaseModel):
}
}
# ANCHOR: RabbitMQ_Models
# Семантика: Модели для работы с сообщениями RabbitMQ
class RabbitMQMessage(BaseModel):
"""
[CONTRACT]
@description: Базовая модель для сообщений RabbitMQ.
@invariant: Все сообщения имеют уникальный ID и timestamp.
"""
message_id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Уникальный идентификатор сообщения.")
timestamp: datetime = Field(default_factory=datetime.utcnow, description="Время создания сообщения.")
source: str = Field(..., description="Источник сообщения (например, 'price_parser').")
class Config:
json_encoders = {
datetime: lambda v: v.isoformat()
}
class ProductDataMessage(RabbitMQMessage):
"""
[CONTRACT]
@description: Модель сообщения с данными о продуктах для отправки в RabbitMQ.
@invariant: Содержит список продуктов и метаданные о парсинге.
"""
products: List[ProductVariant] = Field(..., description="Список продуктов для обработки.")
run_id: str = Field(..., description="Идентификатор запуска парсера.")
total_count: int = Field(..., description="Общее количество продуктов в сообщении.")
class Config:
json_schema_extra = {
"example": {
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2023-10-27T12:34:56.789Z",
"source": "price_parser",
"products": [
{
"name": "Peptide X",
"volume": "30ml",
"price": 1500,
"url": "https://elixirpeptide.ru/catalog/peptide-x/?product=variant1"
}
],
"run_id": "20231027-123456",
"total_count": 1
}
}
class LogMessage(RabbitMQMessage):
"""
[CONTRACT]
@description: Модель сообщения с логами для отправки в RabbitMQ.
@invariant: Содержит информацию о логах парсера.
"""
log_records: List[LogRecordModel] = Field(..., description="Список записей логов.")
run_id: str = Field(..., description="Идентификатор запуска парсера.")
class Config:
json_schema_extra = {
"example": {
"message_id": "550e8400-e29b-41d4-a716-446655440001",
"timestamp": "2023-10-27T12:34:56.789Z",
"source": "price_parser",
"log_records": [
{
"run_id": "20231027-123456",
"timestamp": "2023-10-27T12:34:56.789Z",
"level": "INFO",
"message": "Парсинг начат."
}
],
"run_id": "20231027-123456"
}
}
# [COHERENCE_CHECK_PASSED] Все основные модели данных определены и типизированы.

350
src/core/rabbitmq.py Normal file
View File

@@ -0,0 +1,350 @@
# [FILE] src/core/rabbitmq.py
# ANCHOR: RabbitMQ_Module
# Семантика: Модуль для работы с очередью сообщений RabbitMQ.
# [CONTRACT]: Обеспечивает надежное подключение, отправку сообщений и обработку ошибок.
# [COHERENCE]: Интегрирован с моделями данных и настройками приложения.
import logging
import json
from typing import Optional, Dict, Any
from contextlib import contextmanager
import pika
from pika.exceptions import AMQPConnectionError, AMQPChannelError, ConnectionClosed
from .settings import (
RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_USERNAME, RABBITMQ_PASSWORD,
RABBITMQ_VIRTUAL_HOST, RABBITMQ_CONNECTION_TIMEOUT, RABBITMQ_HEARTBEAT,
RABBITMQ_BLOCKED_CONNECTION_TIMEOUT, RABBITMQ_PRODUCTS_QUEUE,
RABBITMQ_LOGS_QUEUE, RABBITMQ_EXCHANGE
)
logger = logging.getLogger(__name__)
class RabbitMQConnection:
"""
[CONTRACT]
@description: Класс для управления подключением к RabbitMQ.
@invariant: Обеспечивает надежное подключение с автоматическим переподключением.
"""
def __init__(self):
"""[INIT] Инициализация подключения к RabbitMQ."""
self.connection: Optional[pika.BlockingConnection] = None
self.channel: Optional[pika.channel.Channel] = None
self._connection_params = self._build_connection_params()
def _build_connection_params(self) -> pika.ConnectionParameters:
"""
[HELPER] Строит параметры подключения к RabbitMQ.
Returns:
pika.ConnectionParameters: Параметры подключения
"""
credentials = pika.PlainCredentials(RABBITMQ_USERNAME, RABBITMQ_PASSWORD)
return pika.ConnectionParameters(
host=RABBITMQ_HOST,
port=RABBITMQ_PORT,
virtual_host=RABBITMQ_VIRTUAL_HOST,
credentials=credentials,
connection_attempts=3,
retry_delay=5,
socket_timeout=RABBITMQ_CONNECTION_TIMEOUT,
heartbeat=RABBITMQ_HEARTBEAT,
blocked_connection_timeout=RABBITMQ_BLOCKED_CONNECTION_TIMEOUT
)
def connect(self) -> bool:
"""
[CONTRACT]
@description: Устанавливает подключение к RabbitMQ.
@precondition: Параметры подключения корректны.
@postcondition: Подключение установлено или False в случае ошибки.
Returns:
bool: True если подключение успешно, False в противном случае
"""
try:
logger.info(f"[RABBITMQ] Подключение к {RABBITMQ_HOST}:{RABBITMQ_PORT}")
self.connection = pika.BlockingConnection(self._connection_params)
self.channel = self.connection.channel()
# [SETUP] Настройка exchange и очередей
self._setup_exchange_and_queues()
logger.info("[RABBITMQ] Подключение успешно установлено")
return True
except AMQPConnectionError as e:
logger.error(f"[RABBITMQ] Ошибка подключения: {e}")
return False
except Exception as e:
logger.error(f"[RABBITMQ] Непредвиденная ошибка при подключении: {e}")
return False
def _setup_exchange_and_queues(self):
"""
[HELPER] Настраивает exchange и очереди в RabbitMQ.
@invariant: Создает необходимые exchange и очереди, если они не существуют.
"""
try:
# Создание exchange
self.channel.exchange_declare(
exchange=RABBITMQ_EXCHANGE,
exchange_type='direct',
durable=True
)
# Создание очереди для продуктов
self.channel.queue_declare(
queue=RABBITMQ_PRODUCTS_QUEUE,
durable=True
)
self.channel.queue_bind(
exchange=RABBITMQ_EXCHANGE,
queue=RABBITMQ_PRODUCTS_QUEUE,
routing_key='products'
)
# Создание очереди для логов
self.channel.queue_declare(
queue=RABBITMQ_LOGS_QUEUE,
durable=True
)
self.channel.queue_bind(
exchange=RABBITMQ_EXCHANGE,
queue=RABBITMQ_LOGS_QUEUE,
routing_key='logs'
)
logger.info("[RABBITMQ] Exchange и очереди настроены")
except AMQPChannelError as e:
logger.error(f"[RABBITMQ] Ошибка настройки очередей: {e}")
raise
def disconnect(self):
"""
[CONTRACT]
@description: Закрывает подключение к RabbitMQ.
@postcondition: Подключение закрыто корректно.
"""
try:
if self.channel and not self.channel.is_closed:
self.channel.close()
if self.connection and not self.connection.is_closed:
self.connection.close()
logger.info("[RABBITMQ] Подключение закрыто")
except Exception as e:
logger.error(f"[RABBITMQ] Ошибка при закрытии подключения: {e}")
def is_connected(self) -> bool:
"""
[HELPER] Проверяет, активно ли подключение.
Returns:
bool: True если подключение активно, False в противном случае
"""
return (
self.connection is not None and
not self.connection.is_closed and
self.channel is not None and
not self.channel.is_closed
)
def send_message(self, queue: str, message: Dict[str, Any], routing_key: str = None) -> bool:
"""
[CONTRACT]
@description: Отправляет сообщение в указанную очередь.
@precondition: Подключение активно, сообщение валидно.
@postcondition: Сообщение отправлено или False в случае ошибки.
Args:
queue: Название очереди
message: Сообщение для отправки
routing_key: Ключ маршрутизации (по умолчанию равен названию очереди)
Returns:
bool: True если сообщение отправлено, False в противном случае
"""
if not self.is_connected():
logger.error("[RABBITMQ] Попытка отправить сообщение без активного подключения")
return False
try:
routing_key = routing_key or queue
message_body = json.dumps(message, ensure_ascii=False, default=str)
self.channel.basic_publish(
exchange=RABBITMQ_EXCHANGE,
routing_key=routing_key,
body=message_body,
properties=pika.BasicProperties(
delivery_mode=2, # Сохранять сообщения на диск
content_type='application/json'
)
)
logger.info(f"[RABBITMQ] Сообщение отправлено в очередь {queue}")
return True
except AMQPChannelError as e:
logger.error(f"[RABBITMQ] Ошибка отправки сообщения: {e}")
return False
except Exception as e:
logger.error(f"[RABBITMQ] Непредвиденная ошибка при отправке: {e}")
return False
@contextmanager
def get_rabbitmq_connection():
"""
[CONTEXT_MANAGER]
@description: Контекстный менеджер для работы с RabbitMQ.
@invariant: Автоматически закрывает подключение при выходе из контекста.
Yields:
RabbitMQConnection: Объект подключения к RabbitMQ
"""
connection = RabbitMQConnection()
try:
if connection.connect():
yield connection
else:
logger.error("[RABBITMQ] Не удалось установить подключение")
yield None
finally:
connection.disconnect()
class RabbitMQExporter:
"""
[CONTRACT]
@description: Класс для экспорта данных в RabbitMQ.
@invariant: Обеспечивает надежную отправку данных о продуктах и логов.
"""
def __init__(self):
"""[INIT] Инициализация экспортера RabbitMQ."""
self.connection = RabbitMQConnection()
def export_products(self, products: list, run_id: str) -> bool:
"""
[CONTRACT]
@description: Экспортирует данные о продуктах в RabbitMQ.
@precondition: Список продуктов не пустой, run_id валиден.
@postcondition: Данные отправлены в очередь или False в случае ошибки.
Args:
products: Список продуктов для экспорта
run_id: Идентификатор запуска парсера
Returns:
bool: True если экспорт успешен, False в противном случае
"""
if not products:
logger.warning("[RABBITMQ] Попытка экспорта пустого списка продуктов")
return False
try:
from .models import ProductDataMessage, ProductVariant
# Преобразование данных в Pydantic модели
product_variants = []
for product in products:
try:
variant = ProductVariant(**product)
product_variants.append(variant)
except Exception as e:
logger.error(f"[RABBITMQ] Ошибка валидации продукта: {e}")
continue
if not product_variants:
logger.error("[RABBITMQ] Нет валидных продуктов для экспорта")
return False
# Создание сообщения
message = ProductDataMessage(
source="price_parser",
products=product_variants,
run_id=run_id,
total_count=len(product_variants)
)
# Отправка сообщения
if not self.connection.is_connected() and not self.connection.connect():
return False
return self.connection.send_message(
queue=RABBITMQ_PRODUCTS_QUEUE,
message=message.dict(),
routing_key='products'
)
except Exception as e:
logger.error(f"[RABBITMQ] Ошибка экспорта продуктов: {e}")
return False
def export_logs(self, log_records: list, run_id: str) -> bool:
"""
[CONTRACT]
@description: Экспортирует логи в RabbitMQ.
@precondition: Список логов не пустой, run_id валиден.
@postcondition: Логи отправлены в очередь или False в случае ошибки.
Args:
log_records: Список записей логов
run_id: Идентификатор запуска парсера
Returns:
bool: True если экспорт успешен, False в противном случае
"""
if not log_records:
logger.warning("[RABBITMQ] Попытка экспорта пустого списка логов")
return False
try:
from .models import LogMessage, LogRecordModel
# Преобразование данных в Pydantic модели
log_models = []
for log_record in log_records:
try:
log_model = LogRecordModel(**log_record)
log_models.append(log_model)
except Exception as e:
logger.error(f"[RABBITMQ] Ошибка валидации лога: {e}")
continue
if not log_models:
logger.error("[RABBITMQ] Нет валидных логов для экспорта")
return False
# Создание сообщения
message = LogMessage(
source="price_parser",
log_records=log_models,
run_id=run_id
)
# Отправка сообщения
if not self.connection.is_connected() and not self.connection.connect():
return False
return self.connection.send_message(
queue=RABBITMQ_LOGS_QUEUE,
message=message.dict(),
routing_key='logs'
)
except Exception as e:
logger.error(f"[RABBITMQ] Ошибка экспорта логов: {e}")
return False
def close(self):
"""
[CONTRACT]
@description: Закрывает подключение к RabbitMQ.
@postcondition: Подключение закрыто корректно.
"""
self.connection.disconnect()
# [COHERENCE_CHECK_PASSED] Модуль RabbitMQ создан с полной поддержкой контрактов и обработки ошибок.

View File

@@ -4,8 +4,69 @@
# Семантика: Этот модуль является единственным источником истины для всех
# конфигурационных параметров приложения. Использует Pydantic для типизации и валидации.
import os
from pathlib import Path
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, validator, HttpUrl
from typing import Optional
from dotenv import load_dotenv
# ANCHOR: Environment_Loading
# Семантика: Загрузка переменных окружения из .env файла
load_dotenv()
# ANCHOR: Base_Paths
# Семантика: Базовые пути для приложения
BASE_DIR = Path(__file__).parent.parent.parent
DATA_DIR = BASE_DIR / "price_data_final"
# ANCHOR: Database_Settings
# Семантика: Настройки базы данных SQLite
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR}/price_parser.db")
# ANCHOR: Scraping_Settings
# Семантика: Настройки для веб-скрапинга
SCRAPING_DELAY = float(os.getenv("SCRAPING_DELAY", "1.0")) # Задержка между запросами в секундах
MAX_RETRIES = int(os.getenv("MAX_RETRIES", "3")) # Максимальное количество попыток
REQUEST_TIMEOUT = int(os.getenv("REQUEST_TIMEOUT", "30")) # Таймаут запросов в секундах
USER_AGENT = os.getenv("USER_AGENT", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
# ANCHOR: Logging_Settings
# Семантика: Настройки логирования
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
LOG_FORMAT = os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s")
LOG_FILE = os.getenv("LOG_FILE", str(BASE_DIR / "logs" / "price_parser.log"))
# ANCHOR: RabbitMQ_Settings
# Семантика: Настройки для подключения к RabbitMQ
RABBITMQ_HOST = os.getenv("RABBITMQ_HOST", "localhost")
RABBITMQ_PORT = int(os.getenv("RABBITMQ_PORT", "5672"))
RABBITMQ_USERNAME = os.getenv("RABBITMQ_USERNAME", "guest")
RABBITMQ_PASSWORD = os.getenv("RABBITMQ_PASSWORD", "guest")
RABBITMQ_VIRTUAL_HOST = os.getenv("RABBITMQ_VIRTUAL_HOST", "/")
# ANCHOR: RabbitMQ_Queue_Settings
# Семантика: Настройки очередей RabbitMQ
RABBITMQ_PRODUCTS_QUEUE = os.getenv("RABBITMQ_PRODUCTS_QUEUE", "price_parser.products")
RABBITMQ_LOGS_QUEUE = os.getenv("RABBITMQ_LOGS_QUEUE", "price_parser.logs")
RABBITMQ_EXCHANGE = os.getenv("RABBITMQ_EXCHANGE", "price_parser.exchange")
# ANCHOR: RabbitMQ_Connection_Settings
# СEMАНТИКА: Настройки подключения к RabbitMQ
RABBITMQ_CONNECTION_TIMEOUT = int(os.getenv("RABBITMQ_CONNECTION_TIMEOUT", "30"))
RABBITMQ_HEARTBEAT = int(os.getenv("RABBITMQ_HEARTBEAT", "600"))
RABBITMQ_BLOCKED_CONNECTION_TIMEOUT = int(os.getenv("RABBITMQ_BLOCKED_CONNECTION_TIMEOUT", "300"))
# ANCHOR: Export_Settings
# Семантика: Настройки экспорта данных
ENABLE_RABBITMQ_EXPORT = os.getenv("ENABLE_RABBITMQ_EXPORT", "false").lower() == "true"
ENABLE_CSV_EXPORT = os.getenv("ENABLE_CSV_EXPORT", "true").lower() == "true"
ENABLE_DATABASE_EXPORT = os.getenv("ENABLE_DATABASE_EXPORT", "true").lower() == "true"
# ANCHOR: Validation_Settings
# Семантика: Настройки валидации данных
VALIDATE_DATA_BEFORE_EXPORT = os.getenv("VALIDATE_DATA_BEFORE_EXPORT", "true").lower() == "true"
# [COHERENCE_CHECK_PASSED] Все настройки определены с разумными значениями по умолчанию.
class ScraperSelectors(BaseModel):
"""
@@ -20,6 +81,13 @@ class ScraperSelectors(BaseModel):
product_page_name: str = Field(..., alias='PRODUCT_PAGE_NAME')
active_volume: str = Field(..., alias='ACTIVE_VOLUME')
price_block: str = Field(..., alias='PRICE_BLOCK')
@validator('*')
def validate_selectors(cls, v):
"""[VALIDATOR] Проверяет, что селекторы не пустые."""
if not v or not v.strip():
raise ValueError('Селектор не может быть пустым')
return v.strip()
class Settings(BaseModel):
"""
@@ -27,19 +95,27 @@ class Settings(BaseModel):
@description: Главный класс конфигурации приложения. Собирает все настройки в одном месте.
"""
# [CONFIG] Основные настройки парсера
base_url: str = 'https://elixirpeptide.ru'
catalog_url: str = 'https://elixirpeptide.ru/catalog/'
headers: dict = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
base_url: str = Field(default='https://elixirpeptide.ru', description="Базовый URL сайта")
catalog_url: str = Field(default='https://elixirpeptide.ru/catalog/', description="URL каталога товаров")
headers: dict = Field(
default={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
},
description="HTTP заголовки для запросов"
)
# [CONFIG] Настройки вывода
output_dir: Path = Path('price_data_final')
save_to_csv: bool = True
save_to_db: bool = True
output_dir: Path = Field(default=Path('price_data_final'), description="Директория для сохранения результатов")
save_to_csv: bool = Field(default=True, description="Сохранять ли данные в CSV")
save_to_db: bool = Field(default=True, description="Сохранять ли данные в базу данных")
# [CONFIG] Настройки логирования
log_to_db: bool = True
log_to_db: bool = Field(default=True, description="Сохранять ли логи в базу данных")
# [ENHANCEMENT] Настройки производительности
request_timeout: int = Field(default=30, description="Таймаут HTTP запросов в секундах")
delay_between_requests: float = Field(default=1.0, description="Задержка между запросами в секундах")
max_retries: int = Field(default=3, description="Максимальное количество попыток для запросов")
# [CONFIG] Вложенная модель с селекторами
# Мы инициализируем ее прямо здесь, передавая словарь со значениями.
@@ -50,6 +126,40 @@ class Settings(BaseModel):
ACTIVE_VOLUME='.product-version-select li.active',
PRICE_BLOCK='.product-sale-box .price span',
)
@validator('base_url', 'catalog_url')
def validate_urls(cls, v):
"""[VALIDATOR] Проверяет корректность URL."""
if not v.startswith(('http://', 'https://')):
raise ValueError('URL должен начинаться с http:// или https://')
return v
@validator('request_timeout')
def validate_timeout(cls, v):
"""[VALIDATOR] Проверяет корректность таймаута."""
if v <= 0:
raise ValueError('Таймаут должен быть положительным числом')
if v > 300: # 5 минут максимум
raise ValueError('Таймаут не может превышать 300 секунд')
return v
@validator('delay_between_requests')
def validate_delay(cls, v):
"""[VALIDATOR] Проверяет корректность задержки."""
if v < 0:
raise ValueError('Задержка не может быть отрицательной')
if v > 60: # 1 минута максимум
raise ValueError('Задержка не может превышать 60 секунд')
return v
@validator('max_retries')
def validate_retries(cls, v):
"""[VALIDATOR] Проверяет корректность количества попыток."""
if v < 0:
raise ValueError('Количество попыток не может быть отрицательным')
if v > 10: # 10 попыток максимум
raise ValueError('Количество попыток не может превышать 10')
return v
@property
def db_path(self) -> Path:
@@ -58,9 +168,70 @@ class Settings(BaseModel):
Гарантирует, что путь всегда будет актуальным, если изменится output_dir.
"""
return self.output_dir / 'parser_data.db'
def validate_configuration(self) -> list[str]:
"""
[NEW] Валидирует всю конфигурацию и возвращает список ошибок.
Returns:
list[str]: Список ошибок конфигурации (пустой, если все корректно)
"""
errors = []
# Проверка доступности директории
try:
self.output_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
errors.append(f"Не удается создать директорию {self.output_dir}: {e}")
# Проверка URL
try:
import requests
response = requests.head(self.base_url, timeout=10)
if response.status_code >= 400:
errors.append(f"Базовый URL недоступен: {self.base_url} (статус: {response.status_code})")
except Exception as e:
errors.append(f"Не удается подключиться к базовому URL {self.base_url}: {e}")
return errors
# [ENHANCEMENT] Загрузка настроек из переменных окружения
def load_settings_from_env() -> Settings:
"""
[NEW] Загружает настройки из переменных окружения.
Returns:
Settings: Объект настроек
"""
# Загружаем .env файл, если он существует
env_file = Path('.env')
if env_file.exists():
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # python-dotenv не установлен
# Создаем настройки с возможностью переопределения через переменные окружения
settings_data = {
'base_url': os.getenv('PARSER_BASE_URL', 'https://elixirpeptide.ru'),
'catalog_url': os.getenv('PARSER_CATALOG_URL', 'https://elixirpeptide.ru/catalog/'),
'save_to_csv': os.getenv('PARSER_SAVE_TO_CSV', 'true').lower() == 'true',
'save_to_db': os.getenv('PARSER_SAVE_TO_DB', 'true').lower() == 'true',
'log_to_db': os.getenv('PARSER_LOG_TO_DB', 'true').lower() == 'true',
'request_timeout': int(os.getenv('PARSER_TIMEOUT', '30')),
'delay_between_requests': float(os.getenv('PARSER_DELAY', '1.0')),
'max_retries': int(os.getenv('PARSER_RETRIES', '3')),
}
return Settings(**settings_data)
# [SINGLETON] Создаем единственный экземпляр настроек, который будет использоваться
# во всем приложении. Это стандартная практика для работы с конфигурацией.
settings = Settings()
try:
settings = load_settings_from_env()
except Exception as e:
# Fallback к настройкам по умолчанию
settings = Settings()
# [REFACTORING_COMPLETE] Этот модуль готов к использованию.