gemini-cli refactor

This commit is contained in:
2025-07-18 01:59:30 +03:00
parent 0868dd21cc
commit 840e2c4d6a
10 changed files with 956 additions and 908 deletions

View File

@@ -1,237 +1,133 @@
# [FILE] src/core/settings.py
# [REFACTORING_NOTE] Этот файл заменяет старый src/config.py, используя Pydantic.
# ANCHOR: Configuration_Module
# Семантика: Этот модуль является единственным источником истины для всех
# конфигурационных параметров приложения. Использует Pydantic для типизации и валидации.
# <MODULE name="core.settings" semantics="application_configuration" />
# <DESIGN_NOTE>
# Этот модуль является единственным источником истины для всех
# конфигурационных параметров приложения. Использует Pydantic для типизации,
# валидации и загрузки настроек из переменных окружения.
# Заменяет устаревший `src/config.py`.
# </DESIGN_NOTE>
# <IMPORTS>
import os
from pathlib import Path
from pydantic import BaseModel, Field, validator, HttpUrl
from typing import Optional
from pydantic import BaseModel, Field, validator
from dotenv import load_dotenv
from typing import List
# </IMPORTS>
# ANCHOR: Environment_Loading
# Семантика: Загрузка переменных окружения из .env файла
# <ACTION name="load_environment_variables">
# Загрузка переменных окружения из .env файла, если он существует.
load_dotenv()
# </ACTION>
# 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] Все настройки определены с разумными значениями по умолчанию.
# <CONSTANTS>
# <CONSTANT name="BASE_DIR" value="Path(__file__).parent.parent.parent" />
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# </CONSTANTS>
# <MAIN_CONTRACT for="ScraperSelectors">
# description: "Определяет CSS-селекторы для парсинга как строгий, типизированный контракт."
# invariant: "Все поля являются обязательными непустыми строками."
# </MAIN_CONTRACT>
class ScraperSelectors(BaseModel):
"""
[CONTRACT]
@description: Определяет CSS-селекторы для парсинга как строгий, типизированный контракт.
@invariant: Все поля являются обязательными строками.
"""
# [CONFIG] Используем Field с alias, чтобы Pydantic мог инициализировать
# модель из словаря с ключами в верхнем регистре, как было раньше.
# <CONFIG name="selectors_config">
catalog_product_link: str = Field(..., alias='CATALOG_PRODUCT_LINK')
variant_list_item: str = Field(..., alias='VARIANT_LIST_ITEM')
product_page_name: str = Field(..., alias='PRODUCT_PAGE_NAME')
active_volume: str = Field(..., alias='ACTIVE_VOLUME')
price_block: str = Field(..., alias='PRICE_BLOCK')
@validator('*')
product_unavailable: str = Field(..., alias='PRODUCT_UNAVAILABLE')
# </CONFIG>
# <CONTRACT for="validate_selectors">
# description: "Валидатор, проверяющий, что селекторы не являются пустыми строками."
# </CONTRACT>
# <HELPER name="validate_selectors">
@validator('*', pre=True, allow_reuse=True)
def validate_selectors(cls, v):
"""[VALIDATOR] Проверяет, что селекторы не пустые."""
if not v or not v.strip():
raise ValueError('Селектор не может быть пустым')
return v.strip()
# </HELPER>
# <MAIN_CONTRACT for="Settings">
# description: "Главный класс конфигурации приложения. Собирает все настройки в одном месте, используя переменные окружения."
# </MAIN_CONTRACT>
class Settings(BaseModel):
"""
[MAIN-CONTRACT]
@description: Главный класс конфигурации приложения. Собирает все настройки в одном месте.
"""
# [CONFIG] Основные настройки парсера
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 name="parser_settings">
base_url: str = Field(default=os.getenv('PARSER_BASE_URL', 'https://elixirpeptide.ru'), description="Базовый URL сайта")
catalog_url: str = Field(default=os.getenv('PARSER_CATALOG_URL', 'https://elixirpeptide.ru/catalog/'), description="URL каталога товаров")
headers: dict = Field(default={'User-Agent': os.getenv('PARSER_USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')})
# </CONFIG>
# [CONFIG] Настройки вывода
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 name="output_settings">
output_dir: Path = Field(default=BASE_DIR / "price_data_final", description="Директория для сохранения результатов")
save_to_csv: bool = Field(default=os.getenv('PARSER_SAVE_TO_CSV', 'true').lower() == 'true')
save_to_db: bool = Field(default=os.getenv('PARSER_SAVE_TO_DB', 'true').lower() == 'true')
# </CONFIG>
# [CONFIG] Настройки логирования
log_to_db: bool = Field(default=True, description="Сохранять ли логи в базу данных")
# <CONFIG name="logging_settings">
log_to_db: bool = Field(default=os.getenv('PARSER_LOG_TO_DB', 'true').lower() == 'true')
# </CONFIG>
# [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 name="performance_settings">
request_timeout: int = Field(default=int(os.getenv('PARSER_TIMEOUT', 30)))
delay_between_requests: float = Field(default=float(os.getenv('PARSER_DELAY', 1.0)))
max_retries: int = Field(default=int(os.getenv('PARSER_RETRIES', 3)))
# </CONFIG>
# [CONFIG] Вложенная модель с селекторами
# Мы инициализируем ее прямо здесь, передавая словарь со значениями.
# <CONFIG name="selectors_config_instance">
selectors: ScraperSelectors = ScraperSelectors(
CATALOG_PRODUCT_LINK='.product-card h4 a.product-link',
VARIANT_LIST_ITEM='.product-version-select li',
PRODUCT_PAGE_NAME='h1.product-h1',
ACTIVE_VOLUME='.product-version-select li.active',
PRICE_BLOCK='.product-sale-box .price span',
PRODUCT_UNAVAILABLE='.product-unavailable',
)
@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
# </CONFIG>
# <CONTRACT for="db_path">
# description: "Вычисляемое свойство для получения полного пути к файлу базы данных."
# </CONTRACT>
# <HELPER name="db_path" type="property">
@property
def db_path(self) -> Path:
"""
[HELPER] Вычисляемое свойство для пути к базе данных.
Гарантирует, что путь всегда будет актуальным, если изменится output_dir.
"""
return self.output_dir / 'parser_data.db'
def validate_configuration(self) -> list[str]:
"""
[NEW] Валидирует всю конфигурацию и возвращает список ошибок.
Returns:
list[str]: Список ошибок конфигурации (пустой, если все корректно)
"""
errors = []
# Проверка доступности директории
# </HELPER>
# <CONTRACT for="validate_configuration">
# description: "Про<D180><D0BE>еряет ключевые параметры конфигурации на доступность и корректность."
# postconditions: "Возвращает список строк с описанием ошибок."
# </CONTRACT>
# <ACTION name="validate_configuration">
def validate_configuration(self) -> List[str]:
errors: List[str] = []
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)
response = requests.head(self.base_url, timeout=self.request_timeout)
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
# </ACTION>
# [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 name="settings">
# Создаем единственный экземпляр настроек, который будет импортиров<D0BE><D0B2>ться
# и использоваться во всем приложении.
settings = Settings()
# </SINGLETON>
# [SINGLETON] Создаем единственный экземпляр настроек, который будет использоваться
# во всем приложении. Это стандартная практика для работы с конфигурацией.
try:
settings = load_settings_from_env()
except Exception as e:
# Fallback к настройкам по умолчанию
settings = Settings()
# <CONSTANTS name="export_flags">
ENABLE_RABBITMQ_EXPORT = os.getenv("ENABLE_RABBITMQ_EXPORT", "false").lower() == "true"
ENABLE_CSV_EXPORT = settings.save_to_csv
ENABLE_DATABASE_EXPORT = settings.save_to_db
# </CONSTANTS>
# [REFACTORING_COMPLETE] Этот модуль готов к использованию.
# <COHERENCE_CHECK status="PASSED" description="Модуль настроек полностью отрефакторен и является единственным источником конфигурации." />