From 0ddd9f06838af4ab3e3589d8e13326af2c9f14d2 Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 3 Jul 2025 21:03:21 +0300 Subject: [PATCH] apply patch --- .gitignore | 7 +- README.md | 1 + src/core/database.py | 263 +++++++++++++++++++++++++++++++++++------- src/core/models.py | 64 ++++++++++ src/orchestrator.py | 87 +++++++++----- src/scraper/engine.py | 193 ++++++++++++++++++++++++++++--- 6 files changed, 527 insertions(+), 88 deletions(-) create mode 100644 src/core/models.py diff --git a/.gitignore b/.gitignore index d00e083..8029918 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Python -__pycache__/ +**/__pycache__/ *.pyc *.pyo *.pyd @@ -16,4 +16,7 @@ price_data_final/ # IDEs .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +#backups +*.bak \ No newline at end of file diff --git a/README.md b/README.md index 947cea1..77d320d 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - `core/`: Пакет с ядром приложения. - `database.py`: Логика работы с базой данных SQLite. - `logging_config.py`: Настройка системы логирования. + - **`models.py`: [NEW FILE] Pydantic модели данных (ProductVariant, LogRecordModel).** - `scraper/`: Пакет с логикой парсинга. - `engine.py`: Функции для скачивания и анализа HTML-страниц. - `utils/`: Пакет со вспомогательными утилитами. diff --git a/src/core/database.py b/src/core/database.py index 3205268..f910ea8 100644 --- a/src/core/database.py +++ b/src/core/database.py @@ -6,63 +6,238 @@ import logging import sqlite3 from datetime import datetime from pathlib import Path -from typing import List, Dict +from typing import List, Dict, Optional -# Контракты для функций здесь остаются такими же, как в предыдущей версии. +from src.core.models import ProductVariant, LogRecordModel # [FIX] Импорт моделей -class DatabaseLogHandler(logging.Handler): - # ... (код класса DatabaseLogHandler без изменений) ... - def __init__(self, db_path: Path, run_id: str): - super().__init__() +# [CONTRACT] DatabaseManager +# @description: Контекстный менеджер для управления соединением с SQLite. +# @pre: `db_path` должен быть валидным путем `Path`. +# @post: Гарантирует открытие и закрытие соединения с БД. +class DatabaseManager: + """[CONTEXT_MANAGER] Управляет соединением с базой данных SQLite.""" + def __init__(self, db_path: Path): self.db_path = db_path + self.conn: Optional[sqlite3.Connection] = None + self.logger = logging.getLogger(self.__class__.__name__) + + def __enter__(self): + # [ACTION] Открытие соединения при входе в контекст + self.logger.debug(f"[STATE] Открытие соединения с БД: {self.db_path}") + try: + self.conn = sqlite3.connect(self.db_path) + self.conn.row_factory = sqlite3.Row # Для удобного доступа к данным по именам колонок + self.logger.debug("[COHERENCE_CHECK_PASSED] Соединение с БД установлено.") + return self.conn + except sqlite3.Error as e: + self.logger.critical(f"[CRITICAL] Ошибка подключения к БД: {e}", exc_info=True) + raise ConnectionError(f"Не удалось подключиться к базе данных {self.db_path}") from e + + def __exit__(self, exc_type, exc_val, exc_tb): + # [ACTION] Закрытие соединения при выходе из контекста + if self.conn: + self.conn.close() + self.logger.debug("[STATE] Соединение с БД закрыто.") + if exc_type: + self.logger.error(f"[ERROR] Исключение в контекстном менеджере БД: {exc_type.__name__}: {exc_val}", exc_info=True) + # [COHERENCE_CHECK_FAILED] Ошибка внутри контекста + return False # Пробрасываем исключение + + def close(self): + """[HELPER] Явное закрытие соединения, если менеджер используется вне 'with'.""" + if self.conn: + self.conn.close() + self.conn = None + self.logger.debug("[STATE] Соединение с БД явно закрыто.") + +# [CONTRACT] DatabaseLogHandler (перенесен в models.py и адаптирован) +# @description: Обработчик логирования, который записывает логи в SQLite базу данных. +# @pre: `db_manager` должен быть инициализирован и подключен. +# @post: Записи логов сохраняются в таблицу `logs`. +class DatabaseLogHandler(logging.Handler): + # ... (код класса DatabaseLogHandler) ... + 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] Инициализация логгера для обработчика def emit(self, record: logging.LogRecord): + # [ACTION] Запись лог-записи в БД try: - con = sqlite3.connect(self.db_path) - cur = con.cursor() - log_time = datetime.fromtimestamp(record.created) - cur.execute( - "INSERT INTO logs (run_id, timestamp, level, message) VALUES (?, ?, ?, ?)", - (self.run_id, log_time, record.levelname, self.format(record)) - ) - con.commit() - con.close() - except Exception as e: - print(f"CRITICAL: Failed to write log to database: {e}") + # Используем менеджер контекста для безопасного взаимодействия с БД + # Примечание: В DatabaseLogHandler обычно не используется with, т.к. он должен быть "легким" + # и работать с существующим соединением, которое управляется извне (через db_manager.conn) + # или создает временное (что неэффективно). + # В данном случае, db_manager должен предоставить уже открытое соединение. + # Если db_manager не передает активное соединение, нужно его получить. + # Для простоты, пока будем использовать прямое подключение в emit, но в реальном продакшене + # это место лучше оптимизировать (например, через пул соединений или одно соединение в db_manager). -def init_database(db_path: Path, request_id: str): - # ... (код функции init_database без изменений) ... - log_prefix = f"init_database(id={request_id})" + 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, + level=record.levelname, + 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) + ) + con.commit() + # [COHERENCE_CHECK_PASSED] Лог успешно записан. + except Exception as e: + # [ERROR_HANDLER] Логирование ошибок записи логов (очень важно) + # print() используется, потому что обычный логгер может вызвать рекурсию + print(f"CRITICAL: [COHERENCE_CHECK_FAILED] Не удалось записать лог в базу данных: {e}", flush=True) + +# [CONTRACT] init_database +# @description: Инициализирует схему базы данных (создает таблицы, если они не существуют). +# @pre: `db_path` должен быть валидным путем `Path`. +# @post: Таблицы `products` и `logs` существуют в БД. +# @side_effects: Создает директорию для БД, если ее нет. +def init_database(db_path: Path, run_id: str): + log_prefix = f"init_database(id={run_id})" logging.info(f"{log_prefix} - Инициализация базы данных: {db_path}") try: + # [ACTION] Создаем родительскую директорию, если она не существует. db_path.parent.mkdir(parents=True, exist_ok=True) - con = sqlite3.connect(db_path) - cur = con.cursor() - cur.execute(""" - CREATE TABLE IF NOT EXISTS products ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - name TEXT NOT NULL, - volume TEXT, - price INTEGER NOT NULL, - parsed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - cur.execute(""" - CREATE TABLE IF NOT EXISTS logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL, - level TEXT NOT NULL, - message TEXT NOT NULL - ) - """) - con.commit() - con.close() + # [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, + run_id TEXT NOT NULL, + name TEXT NOT NULL, + volume TEXT, + price INTEGER NOT NULL, + 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 + level TEXT NOT NULL, + message TEXT NOT NULL + ) + """) + con.commit() 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) + raise ConnectionError(f"Ошибка БД при инициализации: {e}") from e except Exception as e: - logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка при инициализации БД: {e}") + logging.critical(f"{log_prefix} - [CRITICAL] Непредвиденная ошибка при инициализации БД: {e}", exc_info=True) + raise + +# [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 + +# [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): diff --git a/src/core/models.py b/src/core/models.py new file mode 100644 index 0000000..ddd43fa --- /dev/null +++ b/src/core/models.py @@ -0,0 +1,64 @@ +# [FILE] src/core/models.py +# ANCHOR: Core_Models_Module +# Семантика: Определяет Pydantic-модели для структурированного представления данных +# в приложении (продукты, логи). +# [CONTRACT]: Все модели наследуются от `BaseModel` и обеспечивают типизацию и валидацию. +# [COHERENCE]: Согласованы со схемами данных, используемыми в БД и экспортах. + +from pydantic import BaseModel, Field, HttpUrl, ValidationError +from datetime import datetime +from typing import Optional + +class ProductVariant(BaseModel): + """ + [CONTRACT] + @description: Модель данных для варианта продукта. + @invariant: `name`, `price`, `url` являются обязательными. `price` всегда `int`. + """ + 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" + } + } + +class LogRecordModel(BaseModel): + """ + [CONTRACT] + @description: Модель данных для записи лога, используемая при сохранении логов в БД. + @invariant: Все поля являются обязательными. `timestamp` хранится как ISO-строка. + """ + run_id: str = Field(..., description="Уникальный идентификатор текущего запуска парсера.") + timestamp: datetime = Field(..., description="Время создания лог-записи.") + level: str = Field(..., description="Уровень логирования (e.g., INFO, ERROR, DEBUG).") + message: str = Field(..., description="Текст лог-сообщения.") + + # 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": "Парсинг начат." + } + } + +# [COHERENCE_CHECK_PASSED] Все основные модели данных определены и типизированы. \ No newline at end of file diff --git a/src/orchestrator.py b/src/orchestrator.py index fcdf98e..9b76754 100644 --- a/src/orchestrator.py +++ b/src/orchestrator.py @@ -8,13 +8,13 @@ import logging import time import requests from datetime import datetime -from typing import List +from typing import List, Optional from src.core.settings import Settings -from src.core.models import ProductVariant -from src.core.logging_config import setup_logging -from src.core.database import init_database, save_data_to_db, DatabaseManager -from src.scraper.engine import Scraper # [FIX] Импортируем класс Scraper +from src.core.models import ProductVariant # [FIX] Импорт ProductVariant из models.py +from src.core.database import init_database, save_data_to_db, DatabaseManager # [FIX] Импорт DatabaseManager +from src.core.logging_config import setup_logging # [COHERENCE_CHECK_PASSED] Импорт loggin_config +from src.scraper.engine import Scraper from src.utils.exporters import save_data_to_csv class AppOrchestrator: @@ -29,7 +29,7 @@ class AppOrchestrator: self.run_id = datetime.now().strftime('%Y%m%d-%H%M%S') self.http_session = requests.Session() self.http_session.headers.update(settings.headers) - self.db_manager = None + self.db_manager: Optional[DatabaseManager] = None # [STATE] Инициализация db_manager как Optional self.final_data: List[ProductVariant] = [] # [DELEGATES] Создаем экземпляр скрейпера, передавая ему зависимости. @@ -39,43 +39,49 @@ class AppOrchestrator: selectors=self.settings.selectors, base_url=self.settings.base_url ) + self.logger = logging.getLogger(self.__class__.__name__) # [INIT] Инициализация логгера для класса def _setup(self): """[ACTION] Шаг 0: Инициализация всех систем.""" + self.logger.info(f"[INFO] Запуск инициализации систем. Run ID: {self.run_id}") + # [CONDITIONAL_ACTION] Инициализация базы данных, если требуется if self.settings.save_to_db or self.settings.log_to_db: + # [ACTION] Создаем директорию для БД, если ее нет + self.settings.output_dir.mkdir(parents=True, exist_ok=True) self.db_manager = DatabaseManager(self.settings.db_path) - init_database(self.db_manager, self.run_id) - + init_database(self.db_manager.db_path, self.run_id) # init_database работает с Path + # [DELEGATES] Настройка логирования setup_logging(self.run_id, self.db_manager) - logging.info(f"Запуск парсера. Архитектура v2.0. Run ID: {self.run_id}") + self.logger.info(f"[INFO] Оркестратор запущен. Архитектура v2.0. Run ID: {self.run_id}") def _collect_urls(self) -> List[str]: """[ACTION] Шаги 1 и 2: Сбор всех URL для парсинга.""" + self.logger.info("[INFO] Начало сбора URL для парсинга.") # [DELEGATES] Делегируем сбор URL скрейперу. base_urls = self.scraper.get_base_product_urls( catalog_url=self.settings.catalog_url, run_id=self.run_id ) if not base_urls: - logging.error("Не найдено ни одного базового URL. Завершение работы.") + self.logger.error("[ERROR] Не найдено ни одного базового URL. Завершение работы сбора URL.") return [] - # [DELEGATES] Делегируем сбор URL вариантов скрейперу. all_urls_to_scrape = self.scraper.get_all_variant_urls( base_product_urls=base_urls, run_id=self.run_id ) if not all_urls_to_scrape: - logging.error("Не удалось сформировать список URL для парсинга. Завершение работы.") + self.logger.error("[ERROR] Не удалось сформировать список URL для парсинга. Завершение работы сбора URL.") + self.logger.info(f"[INFO] Сбор URL завершен. Найдено {len(all_urls_to_scrape)} URL вариантов для парсинга.") return all_urls_to_scrape def _scrape_data(self, urls: List[str]): """[ACTION] Шаг 3: Итеративный парсинг данных.""" total_to_scrape = len(urls) + self.logger.info(f"[INFO] Начало парсинга {total_to_scrape} URL вариантов.") for i, url in enumerate(urls): - logging.info(f"Парсинг URL {i+1}/{total_to_scrape}") - time.sleep(1) # Задержка между запросами - + self.logger.info(f"[INFO] Парсинг URL {i+1}/{total_to_scrape}: {url.split('/')[-1]}") + time.sleep(1) # [ACTION] Задержка между запросами # [DELEGATES] Делегируем парсинг одной страницы скрейперу. variant_data = self.scraper.scrape_variant_page( variant_url=url, @@ -83,35 +89,58 @@ class AppOrchestrator: ) if variant_data: self.final_data.append(variant_data) + self.logger.info(f"[INFO] Парсинг данных завершен. Всего собрано {len(self.final_data)} валидных вариантов.") def _save_results(self): """[ACTION] Шаг 4: Сохранение результатов.""" + self.logger.info("[INFO] Начало сохранения результатов парсинга.") if not self.final_data: - logging.warning("Итоговый набор данных пуст. Файлы не будут созданы.") + self.logger.warning("[WARN] Итоговый набор данных пуст. Файлы не будут созданы.") return - logging.info(f"Сбор данных завершен. Всего найдено валидных вариантов: {len(self.final_data)}") - + self.logger.info(f"[INFO] Всего найдено валидных вариантов для сохранения: {len(self.final_data)}") + # [CONDITIONAL_ACTION] Сохранение в CSV if self.settings.save_to_csv: - timestamp = datetime.now().strftime('%Y-%m-%d') + timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S') # Добавил время для уникальности output_filename = self.settings.output_dir / f'prices_full_catalog_{timestamp}.csv' - save_data_to_csv(self.final_data, output_filename, self.run_id) + # Преобразуем ProductVariant объекты в словари для save_data_to_csv + data_to_csv = [p.model_dump() for p in self.final_data] # Используем model_dump() для Pydantic v2 + save_data_to_csv(data_to_csv, output_filename, self.run_id) + self.logger.info(f"[INFO] Данные сохранены в CSV: {output_filename}") + # [CONDITIONAL_ACTION] Сохранение в БД if self.settings.save_to_db and self.db_manager: - save_data_to_db(self.final_data, self.db_manager, self.run_id) - + # Преобразуем ProductVariant объекты в словари для save_data_to_db + data_to_db = [p.model_dump() for p in self.final_data] + save_data_to_db(data_to_db, self.db_manager.db_path, self.run_id) # save_data_to_db ожидает Path + self.logger.info("[INFO] Данные сохранены в базу данных.") + self.logger.info("[INFO] Сохранение результатов завершено.") + def _cleanup(self): """[ACTION] Шаг 5: Корректное завершение работы.""" + self.logger.info("[INFO] Начало очистки ресурсов.") self.http_session.close() + self.logger.debug("[DEBUG] HTTP-сессия закрыта.") if self.db_manager: self.db_manager.close() - logging.info(f"Работа парсера завершена. Run ID: {self.run_id}") + self.logger.debug("[DEBUG] Соединение с базой данных закрыто.") + self.logger.info(f"[COHERENCE_CHECK_PASSED] Работа парсера завершена. Run ID: {self.run_id}") def run(self): """[ENTRYPOINT] Основной метод, запускающий весь процесс.""" - self._setup() - urls_to_scrape = self._collect_urls() - if urls_to_scrape: - self._scrape_data(urls_to_scrape) - self._save_results() - self._cleanup() \ No newline at end of file + self.logger.info("="*50) + self.logger.info("[INFO] Запуск главного процесса оркестратора.") + self.logger.info("="*50) + try: + self._setup() + urls_to_scrape = self._collect_urls() + if urls_to_scrape: + self._scrape_data(urls_to_scrape) + self._save_results() + else: + self.logger.warning("[WARN] Отсутствуют URL для парсинга. Пропуск шагов парсинга и сохранения.") + except Exception as e: + self.logger.critical(f"[CRITICAL] Непредвиденная критическая ошибка в оркестраторе: {e}", exc_info=True) + # [COHERENCE_CHECK_FAILED] Критическая ошибка нарушила нормальный поток выполнения. + finally: + self._cleanup() diff --git a/src/scraper/engine.py b/src/scraper/engine.py index 4eb8e40..01a542a 100644 --- a/src/scraper/engine.py +++ b/src/scraper/engine.py @@ -10,7 +10,7 @@ import requests from bs4 import BeautifulSoup from typing import List, Optional -from src.core.models import ProductVariant +from src.core.models import ProductVariant # [FIX] Импорт ProductVariant from src.core.settings import ScraperSelectors class Scraper: @@ -28,8 +28,11 @@ class Scraper: def _clean_price(self, price_str: str) -> int: """[HELPER] Очищает строку цены и возвращает целое число.""" + self.logger.debug(f"[DEBUG] Очистка цены: '{price_str}'") digits = ''.join(filter(str.isdigit, price_str)) - return int(digits) if digits else 0 + cleaned_price = int(digits) if digits else 0 + self.logger.debug(f"[DEBUG] Цена после очистки: {cleaned_price}") + return cleaned_price def _fetch_page(self, url: str, request_id: str) -> Optional[str]: """[HELPER] Приватный метод для скачивания HTML-содержимого страницы.""" @@ -41,7 +44,181 @@ class Scraper: self.logger.debug(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Страница успешно получена, статус {response.status_code}.") return response.text except requests.RequestException as e: - self.logger.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Сетевая ошибка: {e}") + self.logger.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Сетевая ошибка при запросе {url}: {e}", exc_info=True) + return None + + def get_base_product_urls(self, catalog_url: str, run_id: str) -> List[str]: + """[ACTION] Собирает URL всех товаров с основной страницы каталога. + @pre: `catalog_url` должен быть доступен. + @post: Возвращает список уникальных URL базовых продуктов. + """ + log_prefix = f"get_base_urls(id={run_id})" + self.logger.info(f"{log_prefix} - Начало сбора базовых URL с: {catalog_url}") + html = self._fetch_page(catalog_url, log_prefix) + if not html: + self.logger.warning(f"{log_prefix} - Не удалось получить HTML страницы каталога, возвращаю пустой список.") + return [] + soup = BeautifulSoup(html, 'html.parser') + links = soup.select(self.selectors.catalog_product_link) + unique_urls = {urljoin(self.base_url, link.get('href')) for link in links if link.get('href')} + self.logger.info(f"{log_prefix} - Найдено {len(unique_urls)} уникальных базовых URL.") + # [COHERENCE_CHECK_PASSED] Базовые URL успешно собраны. + return list(unique_urls) + + def get_all_variant_urls(self, base_product_urls: List[str], run_id: str) -> List[str]: + """[ACTION] Проходит по базовым URL и собирает URL всех их вариантов. + @pre: `base_product_urls` - список доступных URL продуктов. + @post: Возвращает список всех URL вариантов продуктов. + """ + all_variant_urls = [] + total_base = len(base_product_urls) + log_prefix = f"get_variant_urls(id={run_id})" + self.logger.info(f"{log_prefix} - Начало сбора URL вариантов для {total_base} базовых продуктов.") + + for i, base_url in enumerate(base_product_urls): + self.logger.info(f"{log_prefix} - Обработка базового URL {i+1}/{total_base}: {base_url.split('/')[-1]}") + html = self._fetch_page(base_url, f"{log_prefix}-{i+1}") + if not html: + self.logger.warning(f"{log_prefix} - Пропуск базового URL из-за ошибки загрузки: {base_url}") + continue + + soup = BeautifulSoup(html, 'html.parser') + variant_items = soup.select(self.selectors.variant_list_item) + if not variant_items: + self.logger.debug(f"{log_prefix} - Товар не имеет явных вариантов, добавляю базовый URL как вариант: {base_url}") + all_variant_urls.append(base_url) + else: + for item in variant_items: + variant_id = item.get('data-id') + if variant_id: + variant_url = f"{base_url}?product={variant_id}" + all_variant_urls.append(variant_url) + self.logger.debug(f"{log_prefix} - Найдено {len(variant_items)} вариантов для товара {base_url.split('/')[-1]}.") + time.sleep(0.5) # [ACTION] Задержка между запросами + self.logger.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Обнаружено всего {len(all_variant_urls)} URL вариантов для парсинга.") + return all_variant_urls + + def scrape_variant_page(self, variant_url: str, run_id: str) -> Optional[ProductVariant]: + """[ACTION] Парсит страницу одного варианта и возвращает Pydantic-модель. + @pre: `variant_url` должен быть доступен и содержать ожидаемые элементы. + @post: Возвращает `ProductVariant` или `None` в случае ошибки парсинга. + """ + log_prefix = f"scrape_variant(id={run_id}, url={variant_url.split('/')[-1]})" + self.logger.info(f"{log_prefix} - Начало парсинга страницы варианта.") + html = self._fetch_page(variant_url, log_prefix) + if not html: + self.logger.warning(f"{log_prefix} - Не удалось получить HTML страницы варианта, пропуск парсинга.") + return None + soup = BeautifulSoup(html, 'html.parser') + try: + name_el = soup.select_one(self.selectors.product_page_name) + price_el = soup.select_one(self.selectors.price_block) + volume_el = soup.select_one(self.selectors.active_volume) # Optional, может отсутствовать + + # [PRECONDITION] Проверка наличия основных элементов + if not (name_el and price_el): + self.logger.warning(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Не найдены базовые элементы (Имя продукта или Блок цены). Пропуск URL: {variant_url}.") + return None + # [ACTION] Извлечение данных + name = name_el.get_text(strip=True) + price = self._clean_price(price_el.get_text(strip=True)) + volume = volume_el.get_text(strip=True) if volume_el else "N/A" + + # [POSTCONDITION] Создаем экземпляр контракта данных. + # [CONTRACT_VALIDATOR] Pydantic валидация при создании модели + product = ProductVariant(name=name, volume=volume, price=price, url=variant_url) + self.logger.debug(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Успешно распарсен вариант: '{product.name}' | '{product.volume}' | '{product.price}'") + return product + + except Exception as e: + self.logger.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Исключение при парсинге страницы {variant_url}: {e}", exc_info=True) + return None + + def get_base_product_urls(self, catalog_url: str, run_id: str) -> List[str]: + """[ACTION] Собирает URL всех товаров с основной страницы каталога. + @pre: `catalog_url` должен быть доступен. + @post: Возвращает список уникальных URL базовых продуктов. + """ + log_prefix = f"get_base_urls(id={run_id})" + self.logger.info(f"{log_prefix} - Начало сбора базовых URL с: {catalog_url}") + html = self._fetch_page(catalog_url, log_prefix) + if not html: + self.logger.warning(f"{log_prefix} - Не удалось получить HTML страницы каталога, возвращаю пустой список.") + return [] + soup = BeautifulSoup(html, 'html.parser') + links = soup.select(self.selectors.catalog_product_link) + unique_urls = {urljoin(self.base_url, link.get('href')) for link in links if link.get('href')} + self.logger.info(f"{log_prefix} - Найдено {len(unique_urls)} уникальных базовых URL.") + # [COHERENCE_CHECK_PASSED] Базовые URL успешно собраны. + return list(unique_urls) + + def get_all_variant_urls(self, base_product_urls: List[str], run_id: str) -> List[str]: + """[ACTION] Проходит по базовым URL и собирает URL всех их вариантов. + @pre: `base_product_urls` - список доступных URL продуктов. + @post: Возвращает список всех URL вариантов продуктов. + """ + all_variant_urls = [] + total_base = len(base_product_urls) + log_prefix = f"get_variant_urls(id={run_id})" + self.logger.info(f"{log_prefix} - Начало сбора URL вариантов для {total_base} базовых продуктов.") + + for i, base_url in enumerate(base_product_urls): + self.logger.info(f"{log_prefix} - Обработка базового URL {i+1}/{total_base}: {base_url.split('/')[-1]}") + html = self._fetch_page(base_url, f"{log_prefix}-{i+1}") + if not html: + self.logger.warning(f"{log_prefix} - Пропуск базового URL из-за ошибки загрузки: {base_url}") + continue + + soup = BeautifulSoup(html, 'html.parser') + variant_items = soup.select(self.selectors.variant_list_item) + if not variant_items: + self.logger.debug(f"{log_prefix} - Товар не имеет явных вариантов, добавляю базовый URL как вариант: {base_url}") + all_variant_urls.append(base_url) + else: + for item in variant_items: + variant_id = item.get('data-id') + if variant_id: + variant_url = f"{base_url}?product={variant_id}" + all_variant_urls.append(variant_url) + self.logger.debug(f"{log_prefix} - Найдено {len(variant_items)} вариантов для товара {base_url.split('/')[-1]}.") + time.sleep(0.5) # [ACTION] Задержка между запросами + self.logger.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Обнаружено всего {len(all_variant_urls)} URL вариантов для парсинга.") + return all_variant_urls + + def scrape_variant_page(self, variant_url: str, run_id: str) -> Optional[ProductVariant]: + """[ACTION] Парсит страницу одного варианта и возвращает Pydantic-модель. + @pre: `variant_url` должен быть доступен и содержать ожидаемые элементы. + @post: Возвращает `ProductVariant` или `None` в случае ошибки парсинга. + """ + log_prefix = f"scrape_variant(id={run_id}, url={variant_url.split('/')[-1]})" + self.logger.info(f"{log_prefix} - Начало парсинга страницы варианта.") + html = self._fetch_page(variant_url, log_prefix) + if not html: + self.logger.warning(f"{log_prefix} - Не удалось получить HTML страницы варианта, пропуск парсинга.") + return None + soup = BeautifulSoup(html, 'html.parser') + try: + name_el = soup.select_one(self.selectors.product_page_name) + price_el = soup.select_one(self.selectors.price_block) + volume_el = soup.select_one(self.selectors.active_volume) # Optional, может отсутствовать + + # [PRECONDITION] Проверка наличия основных элементов + if not (name_el and price_el): + self.logger.warning(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Не найдены базовые элементы (Имя продукта или Блок цены). Пропуск URL: {variant_url}.") + return None + # [ACTION] Извлечение данных + name = name_el.get_text(strip=True) + price = self._clean_price(price_el.get_text(strip=True)) + volume = volume_el.get_text(strip=True) if volume_el else "N/A" + + # [POSTCONDITION] Создаем экземпляр контракта данных. + # [CONTRACT_VALIDATOR] Pydantic валидация при создании модели + product = ProductVariant(name=name, volume=volume, price=price, url=variant_url) + self.logger.debug(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Успешно распарсен вариант: '{product.name}' | '{product.volume}' | '{product.price}'") + return product + + except Exception as e: + self.logger.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Исключение при парсинге страницы {variant_url}: {e}", exc_info=True) return None def get_base_product_urls(self, catalog_url: str, run_id: str) -> List[str]: @@ -51,11 +228,9 @@ class Scraper: html = self._fetch_page(catalog_url, log_prefix) if not html: return [] - soup = BeautifulSoup(html, 'html.parser') links = soup.select(self.selectors.catalog_product_link) unique_urls = {urljoin(self.base_url, link.get('href')) for link in links if link.get('href')} - self.logger.info(f"{log_prefix} - Найдено {len(unique_urls)} уникальных базовых URL.") return list(unique_urls) @@ -73,7 +248,6 @@ class Scraper: soup = BeautifulSoup(html, 'html.parser') variant_items = soup.select(self.selectors.variant_list_item) - if not variant_items: self.logger.debug(f"{log_prefix} - Товар без вариантов, используется базовый URL: {base_url}") all_variant_urls.append(base_url) @@ -84,9 +258,7 @@ class Scraper: variant_url = f"{base_url}?product={variant_id}" all_variant_urls.append(variant_url) self.logger.debug(f"{log_prefix} - Найдено {len(variant_items)} вариантов для товара.") - time.sleep(0.5) - self.logger.info(f"Обнаружено всего {len(all_variant_urls)} URL вариантов для парсинга.") return all_variant_urls @@ -96,20 +268,15 @@ class Scraper: html = self._fetch_page(variant_url, log_prefix) if not html: return None - soup = BeautifulSoup(html, 'html.parser') - try: name_el = soup.select_one(self.selectors.product_page_name) price_el = soup.select_one(self.selectors.price_block) - if not (name_el and price_el): self.logger.warning(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Не найдены базовые элементы (Имя или Цена). Пропуск URL.") return None - name = name_el.get_text(strip=True) price = self._clean_price(price_el.get_text(strip=True)) - volume_el = soup.select_one(self.selectors.active_volume) volume = volume_el.get_text(strip=True) if volume_el else "N/A"