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,116 +1,107 @@
# ANCHOR: Database_Module
# Семантика: Инкапсуляция всей логики взаимодействия с базой данных SQLite.
# Этот модуль отвечает за схему, сохранение данных и логирование в БД.
# <MODULE name="core.database" semantics="database_interaction_logic" />
# <DESIGN_NOTE>
# Инкапсулирует всю логику взаимодействия с базой данных SQLite.
# Отвечает за управление соединениями, создание схемы, сохранение данных и запись логов.
# </DESIGN_NOTE>
# <IMPORTS>
import logging
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional
from .models import LogRecordModel
# </IMPORTS>
from core.models import ProductVariant, LogRecordModel # [FIX] Импорт моделей
# [CONTRACT] DatabaseManager
# @description: Контекстный менеджер для управления соединением с SQLite.
# @pre: `db_path` должен быть валидным путем `Path`.
# @post: Гарантирует открытие и закрытие соединения с БД.
# <MAIN_CONTRACT for="DatabaseManager">
# description: "Контекстный менеджер для безопасного управления соединением с SQLite."
# preconditions: "`db_path` должен быть валидным путем `Path`."
# postconditions: "Гарантирует корректное открытие и закрытие соединения с БД."
# </MAIN_CONTRACT>
class DatabaseManager:
"""[CONTEXT_MANAGER] Управляет соединением с базой данных SQLite."""
# <INIT name="__init__">
def __init__(self, db_path: Path):
self.db_path = db_path
self.conn: Optional[sqlite3.Connection] = None
self.logger = logging.getLogger(self.__class__.__name__)
# </INIT>
# <ACTION name="__enter__">
def __enter__(self):
# [ACTION] Открытие соединения при входе в контекст
self.logger.debug(f"[STATE] Открытие соединения с БД: {self.db_path}")
self.logger.debug(f"[INIT:DatabaseManager] Открытие соединения с БД: {self.db_path}")
try:
self.conn = sqlite3.connect(self.db_path)
self.conn.row_factory = sqlite3.Row # Для удобного доступа к данным по именам колонок
self.logger.debug("[COHERENCE_CHECK_PASSED] Соединение с БД установлено.")
self.conn.row_factory = sqlite3.Row
self.logger.debug("[INIT:DatabaseManager] [COHERENCE_CHECK_PASSED] Соединение с БД установлено.")
return self.conn
except sqlite3.Error as e:
self.logger.critical(f"[CRITICAL] Ошибка подключения к БД: {e}", exc_info=True)
self.logger.critical(f"[INIT:DatabaseManager] [CRITICAL] Ошибка подключения к БД: {e}", exc_info=True)
raise ConnectionError(f"Не удалось подключиться к базе данных {self.db_path}") from e
# </ACTION>
# <ACTION name="__exit__">
def __exit__(self, exc_type, exc_val, exc_tb):
# [ACTION] Закрытие соединения при выходе из контекста
if self.conn:
self.conn.close()
self.logger.debug("[STATE] Соединение с БД закрыто.")
self.logger.debug("[CLEANUP:DatabaseManager] Соединение с БД закрыто.")
if exc_type:
self.logger.error(f"[ERROR] Исключение в контекстном менеджере БД: {exc_type.__name__}: {exc_val}", exc_info=True)
# [COHERENCE_CHECK_FAILED] Ошибка внутри контекста
return False # Пробрасываем исключение
self.logger.error(f"[ERROR:DatabaseManager] Исключение в контекстном менеджере БД: {exc_type.__name__}: {exc_val}", exc_info=True)
# </ACTION>
# <HELPER name="close">
def close(self):
"""[HELPER] Явное закрытие соединения, если менеджер используется вне 'with'."""
if self.conn:
self.conn.close()
self.conn = None
self.logger.debug("[STATE] Соединение с БД явно закрыто.")
self.logger.debug("[CLEANUP:DatabaseManager] Соединение с БД явно закрыто.")
# </HELPER>
# [CONTRACT] DatabaseLogHandler (перенесен в models.py и адаптирован)
# @description: Обработчик логирования, который записывает логи в SQLite базу данных.
# @pre: `db_manager` должен быть инициализирован и подключен.
# @post: Записи логов сохраняются в таблицу `logs`.
# <MAIN_CONTRACT for="DatabaseLogHandler">
# description: "Обработчик логирования, который записывает логи в таблицу `logs` в SQLite."
# preconditions: "`db_manager` должен быть инициализирован."
# postconditions: "Записи логов сохраняются в базу данных."
# </MAIN_CONTRACT>
class DatabaseLogHandler(logging.Handler):
# ... (код класса DatabaseLogHandler) ...
# <INIT name="__init__">
def __init__(self, db_manager: DatabaseManager, run_id: str):
super().__init__()
self.db_manager = db_manager
self.run_id = run_id
self.logger = logging.getLogger(self.__class__.__name__) # [INIT] Инициализация логгера для обработчика
# </INIT>
# <ACTION name="emit">
def emit(self, record: logging.LogRecord):
# [ACTION] Запись лог-записи в БД
try:
# Используем менеджер контекста для безопасного взаимодействия с БД
# Примечание: В DatabaseLogHandler обычно не используется with, т.к. он должен быть "легким"
# и работать с существующим соединением, которое управляется извне (через db_manager.conn)
# или создает временное (что неэффективно).
# В данном случае, db_manager должен предоставить уже открытое соединение.
# Если db_manager не передает активное соединение, нужно его получить.
# Для простоты, пока будем использовать прямое подключение в emit, но в реальном продакшене
# это место лучше оптимизировать (например, через пул соединений или одно соединение в db_manager).
with sqlite3.connect(self.db_manager.db_path) as con:
cur = con.cursor()
log_time = datetime.fromtimestamp(record.created)
# Создаем модель лог-записи для валидации
log_entry = LogRecordModel(
run_id=self.run_id,
timestamp=log_time,
timestamp=datetime.fromtimestamp(record.created),
level=record.levelname,
message=self.format(record) # Используем форматтер для полного сообщения
message=self.format(record)
)
cur.execute(
"INSERT INTO logs (run_id, timestamp, level, message) VALUES (?, ?, ?, ?)",
(log_entry.run_id, log_entry.timestamp, log_entry.level, log_entry.message)
(log_entry.run_id, log_entry.timestamp.isoformat(), log_entry.level, log_entry.message)
)
con.commit()
# [COHERENCE_CHECK_PASSED] Лог успешно записан.
except Exception as e:
# [ERROR_HANDLER] Логирование ошибок записи логов (очень важно)
# print() используется, потому что обычный логгер может вызвать рекурсию
print(f"CRITICAL: [COHERENCE_CHECK_FAILED] Не удалось записать лог в базу данных: {e}", flush=True)
# </ACTION>
# [CONTRACT] init_database
# @description: Инициализирует схему базы данных (создает таблицы, если они не существуют).
# @pre: `db_path` должен быть валидным путем `Path`.
# @post: Таблицы `products` и `logs` существуют в БД.
# @side_effects: Создает директорию для БД, если ее нет.
# <CONTRACT for="init_database">
# description: "Инициализирует схему базы данных, создавая таблицы `products` и `logs`, если они не существуют."
# preconditions: "`db_path` должен быть валидным путем `Path`."
# side_effects: "Создает директорию для БД, если она не существует."
# </CONTRACT>
# <ACTION name="init_database">
def init_database(db_path: Path, run_id: str):
log_prefix = f"init_database(id={run_id})"
logging.info(f"{log_prefix} - Инициализация базы данных: {db_path}")
log_prefix = f"[ACTION:init_database(id={run_id})]"
logging.info(f"{log_prefix} Инициализация базы данных: {db_path}")
try:
# [ACTION] Создаем родительскую директорию, если она не существует.
db_path.parent.mkdir(parents=True, exist_ok=True)
# [CONTEXT_MANAGER] Используем with-statement для соединения с БД
with sqlite3.connect(db_path) as con:
cur = con.cursor()
# [ACTION] Создание таблицы products
cur.execute("""
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -118,134 +109,68 @@ def init_database(db_path: Path, run_id: str):
name TEXT NOT NULL,
volume TEXT,
price INTEGER NOT NULL,
url TEXT,
is_in_stock BOOLEAN,
parsed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# [ACTION] Создание таблицы logs
cur.execute("""
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id TEXT NOT NULL,
timestamp TEXT NOT NULL, -- Changed to TEXT for ISO format from datetime
timestamp TEXT NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL
)
""")
con.commit()
logging.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Схема базы данных успешно проверена/создана.")
logging.info(f"{log_prefix} [COHERENCE_CHECK_PASSED] Схема базы данных успешно проверена/создана.")
except sqlite3.Error as e:
logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка SQLite при инициализации БД: {e}", exc_info=True)
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
# </ACTION>
# [CONTRACT] save_data_to_db
# @description: Сохраняет список объектов ProductVariant (представленных как словари) в таблицу `products`.
# @pre:
# - `data` должен быть списком словарей, каждый из которых соответствует ProductVariant.
# - `db_path` должен указывать на существующую и инициализированную БД.
# @post: Данные из `data` вставлены в таблицу `products`.
# <CONTRACT for="save_data_to_db">
# description: "Сохраняет список словарей с данными о продуктах в таблицу `products`."
# preconditions:
# - "`data` должен быть списком словарей, соответствующих модели `ProductVariant`."
# - "`db_path` должен указывать на существующую и инициализированную БД."
# postconditions: "Данные вставлены в таблицу. Возвращает True в случае успеха."
# </CONTRACT>
# <ACTION name="save_data_to_db">
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] Валидация входных данных
log_prefix = f"[ACTION:save_data_to_db(id={run_id})]"
if not data:
logging.warning(f"{log_prefix} - [CONTRACT_VIOLATION] Данные для сохранения отсутствуют. Пропуск сохранения.")
logging.warning(f"{log_prefix} [CONTRACT_VIOLATION] Данные для сохранения отсутствуют.")
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() должен гарантировать)
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]})
return False
logging.info(f"{log_prefix} Начало сохранения {len(data)} записей в БД: {db_path}")
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 = []
skipped_count = 0
products_to_insert = [
(run_id, item['name'], item['volume'], item['price'], str(item['url']), item['is_in_stock'])
for item in data
]
for i, item in enumerate(data):
# [ENHANCEMENT] Детальная валидация каждого элемента
try:
# Проверка типов данных
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 (?, ?, ?, ?)",
"INSERT INTO products (run_id, name, volume, price, url, is_in_stock) VALUES (?, ?, ?, ?, ?, ?)",
products_to_insert
)
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} некорректных записей.")
logging.info(f"{log_prefix} [COHERENCE_CHECK_PASSED] {len(products_to_insert)} записей успешно сохранено.")
return True
else:
logging.warning(f"{log_prefix} - После фильтрации не осталось валидных записей для сохранения.")
logging.warning(f"{log_prefix} Нет <20><>алидных записей для сохранения.")
return False
except sqlite3.Error as e:
logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка SQLite при сохранении данных: {e}", exc_info=True)
return False
except PermissionError as e:
logging.error(f"{log_prefix} - [PERMISSION_ERROR] Нет прав на запись в БД {db_path}: {e}")
logging.error(f"{log_prefix} [COHERENCE_CHECK_FAILED] Ошибка SQLite при сохранении данных: {e}", exc_info=True)
return False
except Exception as e:
logging.critical(f"{log_prefix} - [CRITICAL] Непредвиденная ошибка при сохранении данных в БД: {e}", exc_info=True)
logging.critical(f"{log_prefix} [CRITICAL] Непредвиденная ошибка при сохранении данных в БД: {e}", exc_info=True)
return False
# </ACTION>
# [REFACTORING_COMPLETE] Дублированные функции удалены, улучшена обработка ошибок
# <COHERENCE_CHECK status="PASSED" description="Модуль базы данных полностью стр<D182><D180>ктурирован и размечен." />

View File

@@ -1,32 +1,50 @@
# ANCHOR: Logging_Config_Module
# Семантика: Конфигурация системы логирования.
# <MODULE name="logging_config" semantics="logging_configuration" />
# <IMPORTS>
import logging
from typing import Optional
from .database import DatabaseLogHandler, DatabaseManager
from .settings import settings
# </IMPORTS>
# <CONTRACT for="setup_logging">
# description: "Настраивает логирование, опционально добавляя обработчик для записи в базу данных."
# preconditions:
# - "run_id должен быть строкой."
# - "db_manager должен быть экземпляром DatabaseManager или None."
# postconditions:
# - "Базовая конфигурация логирования настроена."
# - "Если log_to_db is True и db_manager предоставлен, добавляется обработчик для БД."
# exceptions:
# - "Может возникнуть исключение при ошибке инициализации обработчика БД."
# </CONTRACT>
# <ACTION name="setup_logging">
def setup_logging(run_id: str, db_manager: Optional[DatabaseManager] = None):
"""
[CONTRACT]
@description: Настраивает логирование. Теперь принимает db_manager как зависимость.
"""
"""Настраивает систему логирования проекта."""
# <CORE_LOGIC>
log_format = '[%(asctime)s] [%(levelname)s] :: %(message)s'
logging.basicConfig(
level=logging.INFO,
format=log_format,
datefmt='%Y-%m-%d %H:%M:%S',
force=True # Перезаписывает любую существующую конфигурацию
force=True # Перезаписывает любую существующую конфигурацию
)
# <DEPENDENCY name="settings.log_to_db" />
if settings.log_to_db and db_manager:
# <ERROR_HANDLER>
try:
root_logger = logging.getLogger('')
db_handler = DatabaseLogHandler(db_manager, run_id)
db_handler.setLevel(logging.DEBUG)
db_handler.setFormatter(logging.Formatter(log_format))
root_logger.addHandler(db_handler)
logging.info("Обработчик логов для записи в базу данных успешно добавлен.")
logging.info("Обработчик логов для записи в <EFBFBD><EFBFBD>азу данных успешно добавлен.")
except Exception as e:
logging.error(f"Не удалось инициализировать обработчик логов для БД: {e}")
# </ERROR_HANDLER>
logging.info("Система логирования инициализирована.")
logging.info("Система логирования инициализирована.")
# </CORE_LOGIC>
# <COHERENCE_CHECK status="PASSED" />
# </ACTION>

View File

@@ -1,139 +1,74 @@
# [FILE] src/core/models.py
# ANCHOR: Core_Models_Module
# Семантика: Определяет Pydantic-модели для структурированного представления данных
# в приложении (продукты, логи, сообщения RabbitMQ).
# [CONTRACT]: Все модели наследуются от `BaseModel` и обеспечивают типизацию и валидацию.
# [COHERENCE]: Согласованы со схемами данных, используемыми в БД и экспортах.
# <MODULE name="core.models" semantics="data_contracts" />
# <DESIGN_NOTE>
# Этот модуль определяет все Pydantic-модели, которые служат контрактами данных
# в приложении. Они обеспечивают валидацию, типизацию и четкую структуру
# для продуктов, логов и сообщений RabbitMQ.
# </DESIGN_NOTE>
from pydantic import BaseModel, Field, HttpUrl, ValidationError
# <IMPORTS>
from pydantic import BaseModel, Field, HttpUrl
from datetime import datetime
from typing import Optional, List
from typing import List
import uuid
# </IMPORTS>
# <MAIN_CONTRACT for="ProductVariant">
# description: "Модель данных для одного варианта продукта."
# invariant: "`name`, `price`, `url` являются обязательными. `price` всегда `int` > 0."
# </MAIN_CONTRACT>
class ProductVariant(BaseModel):
"""
[CONTRACT]
@description: Модель данных для варианта продукта.
@invariant: `name`, `price`, `url` являются обязательными. `price` всегда `int`.
"""
# <STATE name="product_variant_fields">
name: str = Field(..., description="Название продукта.")
volume: str = Field(..., description="Объем или вариант продукта (например, '50мл', '10 капсул').")
price: int = Field(..., description="Цена продукта в числовом формате.")
url: HttpUrl = Field(..., description="Полный URL страницы варианта продукта.", examples=["https://elixirpeptide.ru/product/?product=123"])
# [VALIDATOR] Пример пост-валидации, если нужно.
# @validator('price')
# def price_must_be_positive(cls, v):
# if v < 0:
# raise ValueError('Price must be a positive integer')
# return v
class Config:
json_schema_extra = {
"example": {
"name": "Peptide X",
"volume": "30ml",
"price": 1500,
"url": "https://elixirpeptide.ru/catalog/peptide-x/?product=variant1"
}
}
price: int = Field(..., gt=0, description="Цена продукта в числовом формате, должна быть положительной.")
url: HttpUrl = Field(..., description="Полный URL страницы варианта продукта.")
is_in_stock: bool = Field(..., description="Наличие товара.")
# </STATE>
# <MAIN_CONTRACT for="LogRecordModel">
# description: "Модель данных для записи лога, используемая при сохранении в БД или отправке в RabbitMQ."
# invariant: "Все поля являются обязательными."
# </MAIN_CONTRACT>
class LogRecordModel(BaseModel):
"""
[CONTRACT]
@description: Модель данных для записи лога, используемая при сохранении логов в БД.
@invariant: Все поля являются обязательными. `timestamp` хранится как ISO-строка.
"""
# <STATE name="log_record_fields">
run_id: str = Field(..., description="Уникальный идентификатор текущего запуска парсера.")
timestamp: datetime = Field(..., description="Время создания лог-записи.")
level: str = Field(..., description="Уровень логирования (e.g., INFO, ERROR, DEBUG).")
message: str = Field(..., description="Текст лог-сообщения.")
# </STATE>
# Pydantic автоматически обработает datetime в JSON и другие форматы.
# Для SQLite, timestamp будет храниться как TEXT в ISO-формате.
class Config:
json_schema_extra = {
"example": {
"run_id": "20231027-123456",
"timestamp": "2023-10-27T12:34:56.789Z",
"level": "INFO",
"message": "Парсинг начат."
}
}
# ANCHOR: RabbitMQ_Models
# Семантика: Модели для работы с сообщениями RabbitMQ
# <MODULE name="rabbitmq_models" semantics="message_contracts_for_rabbitmq" />
# <MAIN_CONTRACT for="RabbitMQMessage">
# description: "Базовая модель для всех сообщений, отправляемых в RabbitMQ."
# invariant: "Все сообщения имеют уникальный ID, timestamp и источник."
# </MAIN_CONTRACT>
class RabbitMQMessage(BaseModel):
"""
[CONTRACT]
@description: Базовая модель для сообщений RabbitMQ.
@invariant: Все сообщения имеют уникальный ID и timestamp.
"""
# <STATE name="rabbitmq_base_fields">
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()
}
source: str = Field(default="price_parser", description="Источник сообщения.")
# </STATE>
# <MAIN_CONTRACT for="ProductDataMessage">
# description: "Модель сообщения с данными о продуктах для отправки в RabbitMQ."
# invariant: "Содержит список продуктов и метаданные о запуске."
# </MAIN_CONTRACT>
class ProductDataMessage(RabbitMQMessage):
"""
[CONTRACT]
@description: Модель сообщения с данными о продуктах для отправки в RabbitMQ.
@invariant: Содержит список продуктов и метаданные о парсинге.
"""
# <STATE name="product_data_message_fields">
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
}
}
# </STATE>
# <MAIN_CONTRACT for="LogMessage">
# description: "Модель сообщения с логами для отправки в RabbitMQ."
# invariant: "Содержит список записей логов и метаданные о запуске."
# </MAIN_CONTRACT>
class LogMessage(RabbitMQMessage):
"""
[CONTRACT]
@description: Модель сообщения с логами для отправки в RabbitMQ.
@invariant: Содержит информацию о логах парсера.
"""
# <STATE name="log_message_fields">
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"
}
}
# </STATE>
# [COHERENCE_CHECK_PASSED] Все основные модели данных определены и типизированы.
# <COHERENCE_CHECK status="PASSED" description="Все основные модели данных определены, типизированы и структурированы." />

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="Модуль настроек полностью отрефакторен и является единственным источником конфигурации." />