From 54827a51525c37b426f0b9bf2d7e463e2d0e3d93 Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 3 Jul 2025 19:56:10 +0300 Subject: [PATCH] initial --- .gitignore | 19 ++++++ README.md | 54 ++++++++++++++++ requirements.txt | 4 ++ src/config.py | 28 +++++++++ src/core/database.py | 89 ++++++++++++++++++++++++++ src/core/logging_config.py | 32 ++++++++++ src/core/settings.py | 66 ++++++++++++++++++++ src/main.py | 15 +++++ src/orchestrator.py | 117 ++++++++++++++++++++++++++++++++++ src/scraper/engine.py | 125 +++++++++++++++++++++++++++++++++++++ src/utils/exporters.py | 26 ++++++++ 11 files changed, 575 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 src/config.py create mode 100644 src/core/database.py create mode 100644 src/core/logging_config.py create mode 100644 src/core/settings.py create mode 100644 src/main.py create mode 100644 src/orchestrator.py create mode 100644 src/scraper/engine.py create mode 100644 src/utils/exporters.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d00e083 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Virtualenv +.venv +venv/ +ENV/ + +# Output data +price_data_final/ +*.db +*.db-journal + +# IDEs +.idea/ +.vscode/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..947cea1 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# ANCHOR: Project_README +# Семантика: Документация, описывающая проект, его структуру и способ использования. + +# Парсер цен для ElixirPeptide + +Это структурированное Python-приложение для парсинга каталога товаров с сайта `elixirpeptide.ru`, сбора информации о вариантах товаров и их ценах. + +## Структура Проекта + +Проект организован по принципу семантического разделения ответственности для удобства поддержки и дальнейшей разработки. + +- `src/`: Основная директория с исходным кодом. + - `config.py`: Все настройки (URL, селекторы, флаги сохранения). + - `main.py`: Точка входа в приложение, оркестратор процесса. + - `core/`: Пакет с ядром приложения. + - `database.py`: Логика работы с базой данных SQLite. + - `logging_config.py`: Настройка системы логирования. + - `scraper/`: Пакет с логикой парсинга. + - `engine.py`: Функции для скачивания и анализа HTML-страниц. + - `utils/`: Пакет со вспомогательными утилитами. + - `exporters.py`: Функции для сохранения данных в разные форматы (CSV). +- `requirements.txt`: Список зависимостей проекта. +- `price_data_final/`: Директория для хранения результатов (создается автоматически). + +## Установка и Запуск + +1. **Клонируйте репозиторий:** + ```bash + git clone + cd peptide_parser_project + ``` + +2. **Создайте и активируйте виртуальное окружение:** + ```bash + python -m venv venv + source venv/bin/activate # Для Windows: venv\Scripts\activate + ``` + +3. **Установите зависимости:** + ```bash + pip install -r requirements.txt + ``` + +4. **Запустите парсер:** + Все настройки находятся в файле `src/config.py`. Вы можете изменить их перед запуском. + ```bash + python src/main.py + ``` + +## Результаты + +- Если `SAVE_TO_CSV = True`, в директории `price_data_final/` будет создан CSV-файл с ценами. +- Если `SAVE_TO_DB = True`, в той же директории будет создан или обновлен файл `parser_data.db`. +- Если `LOG_TO_DB = True`, все логи сессии будут также записаны в таблицу `logs` в базе данных. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..66879ed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +# ANCHOR: Requirements +# Семантика: Список внешних библиотек, необходимых для запуска приложения. +requests +beautifulsoup4 \ No newline at end of file diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..bb3ffd0 --- /dev/null +++ b/src/config.py @@ -0,0 +1,28 @@ +# ANCHOR: Configuration_Module +# Семантика: Этот модуль является единственным источником истины для всех +# конфигурационных параметров приложения. Он не содержит исполняемой логики. + +from pathlib import Path + +# --- Основные настройки парсера --- +BASE_URL = 'https://elixirpeptide.ru' +CATALOG_URL = 'https://elixirpeptide.ru/catalog/' +HEADERS = {'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'} + +# --- Настройки вывода --- +OUTPUT_DIR = Path('price_data_final') +SAVE_TO_CSV = True +SAVE_TO_DB = True +DB_PATH = OUTPUT_DIR / 'parser_data.db' + +# --- Настройки логирования --- +LOG_TO_DB = True + +# --- CSS Селекторы для парсинга --- +SELECTORS = { + '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', +} \ No newline at end of file diff --git a/src/core/database.py b/src/core/database.py new file mode 100644 index 0000000..3205268 --- /dev/null +++ b/src/core/database.py @@ -0,0 +1,89 @@ +# ANCHOR: Database_Module +# Семантика: Инкапсуляция всей логики взаимодействия с базой данных SQLite. +# Этот модуль отвечает за схему, сохранение данных и логирование в БД. + +import logging +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import List, Dict + +# Контракты для функций здесь остаются такими же, как в предыдущей версии. + +class DatabaseLogHandler(logging.Handler): + # ... (код класса DatabaseLogHandler без изменений) ... + def __init__(self, db_path: Path, run_id: str): + super().__init__() + self.db_path = db_path + self.run_id = run_id + + def emit(self, record: logging.LogRecord): + 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}") + +def init_database(db_path: Path, request_id: str): + # ... (код функции init_database без изменений) ... + log_prefix = f"init_database(id={request_id})" + logging.info(f"{log_prefix} - Инициализация базы данных: {db_path}") + try: + 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() + logging.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Схема базы данных успешно проверена/создана.") + except Exception as e: + logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка при инициализации БД: {e}") + 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}") \ No newline at end of file diff --git a/src/core/logging_config.py b/src/core/logging_config.py new file mode 100644 index 0000000..c3da2e5 --- /dev/null +++ b/src/core/logging_config.py @@ -0,0 +1,32 @@ +# ANCHOR: Logging_Config_Module +# Семантика: Конфигурация системы логирования. + +import logging +from typing import Optional +from .database import DatabaseLogHandler, DatabaseManager +from .settings import settings + +def setup_logging(run_id: str, db_manager: Optional[DatabaseManager] = None): + """ + [CONTRACT] + @description: Настраивает логирование. Теперь принимает db_manager как зависимость. + """ + 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 # Перезаписывает любую существующую конфигурацию + ) + if settings.log_to_db and db_manager: + 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("Обработчик логов для записи в базу данных успешно добавлен.") + except Exception as e: + logging.error(f"Не удалось инициализировать обработчик логов для БД: {e}") + + logging.info("Система логирования инициализирована.") \ No newline at end of file diff --git a/src/core/settings.py b/src/core/settings.py new file mode 100644 index 0000000..c43d6c6 --- /dev/null +++ b/src/core/settings.py @@ -0,0 +1,66 @@ +# [FILE] src/core/settings.py +# [REFACTORING_NOTE] Этот файл заменяет старый src/config.py, используя Pydantic. +# ANCHOR: Configuration_Module +# Семантика: Этот модуль является единственным источником истины для всех +# конфигурационных параметров приложения. Использует Pydantic для типизации и валидации. + +from pathlib import Path +from pydantic import BaseModel, Field + +class ScraperSelectors(BaseModel): + """ + [CONTRACT] + @description: Определяет CSS-селекторы для парсинга как строгий, типизированный контракт. + @invariant: Все поля являются обязательными строками. + """ + # [CONFIG] Используем Field с alias, чтобы Pydantic мог инициализировать + # модель из словаря с ключами в верхнем регистре, как было раньше. + 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') + +class Settings(BaseModel): + """ + [MAIN-CONTRACT] + @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' + } + + # [CONFIG] Настройки вывода + output_dir: Path = Path('price_data_final') + save_to_csv: bool = True + save_to_db: bool = True + + # [CONFIG] Настройки логирования + log_to_db: bool = True + + # [CONFIG] Вложенная модель с селекторами + # Мы инициализируем ее прямо здесь, передавая словарь со значениями. + 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', + ) + + @property + def db_path(self) -> Path: + """ + [HELPER] Вычисляемое свойство для пути к базе данных. + Гарантирует, что путь всегда будет актуальным, если изменится output_dir. + """ + return self.output_dir / 'parser_data.db' + +# [SINGLETON] Создаем единственный экземпляр настроек, который будет использоваться +# во всем приложении. Это стандартная практика для работы с конфигурацией. +settings = Settings() + +# [REFACTORING_COMPLETE] Этот модуль готов к использованию. \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..51d7e1a --- /dev/null +++ b/src/main.py @@ -0,0 +1,15 @@ +# [FILE] src/main.py +# ANCHOR: Main_Entrypoint +# Семантика: Единственная задача этого модуля - создать и запустить оркестратор. +# Он не содержит никакой логики, только инициализирует процесс. + +from src.orchestrator import AppOrchestrator +from src.core.settings import settings + +def main(): + """Точка входа в приложение.""" + orchestrator = AppOrchestrator(settings=settings) + orchestrator.run() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/orchestrator.py b/src/orchestrator.py new file mode 100644 index 0000000..fcdf98e --- /dev/null +++ b/src/orchestrator.py @@ -0,0 +1,117 @@ +# [FILE] src/orchestrator.py +# ANCHOR: Main_Application_Orchestrator_Class +# Семантика: Инкапсулирует весь поток выполнения парсинга. +# Хранит состояние (конфигурацию, сессии, результаты) и управляет процессом. +# [REFACTORING_NOTE] Обновлен для использования класса Scraper вместо модуля engine. + +import logging +import time +import requests +from datetime import datetime +from typing import List + +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.utils.exporters import save_data_to_csv + +class AppOrchestrator: + """ + [MAIN-CONTRACT] + @description: Класс-оркестратор, управляющий всем процессом парсинга. + @invariant: Экземпляр `settings` и `run_id` неизменны в течение жизненного цикла. + """ + def __init__(self, settings: Settings): + # [INIT] Инициализация оркестратора + self.settings = settings + 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.final_data: List[ProductVariant] = [] + + # [DELEGATES] Создаем экземпляр скрейпера, передавая ему зависимости. + # Оркестратор владеет скрейпером. + self.scraper = Scraper( + session=self.http_session, + selectors=self.settings.selectors, + base_url=self.settings.base_url + ) + + def _setup(self): + """[ACTION] Шаг 0: Инициализация всех систем.""" + if self.settings.save_to_db or self.settings.log_to_db: + self.db_manager = DatabaseManager(self.settings.db_path) + init_database(self.db_manager, self.run_id) + + setup_logging(self.run_id, self.db_manager) + logging.info(f"Запуск парсера. Архитектура v2.0. Run ID: {self.run_id}") + + def _collect_urls(self) -> List[str]: + """[ACTION] Шаги 1 и 2: Сбор всех 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. Завершение работы.") + 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 для парсинга. Завершение работы.") + return all_urls_to_scrape + + def _scrape_data(self, urls: List[str]): + """[ACTION] Шаг 3: Итеративный парсинг данных.""" + total_to_scrape = len(urls) + for i, url in enumerate(urls): + logging.info(f"Парсинг URL {i+1}/{total_to_scrape}") + time.sleep(1) # Задержка между запросами + + # [DELEGATES] Делегируем парсинг одной страницы скрейперу. + variant_data = self.scraper.scrape_variant_page( + variant_url=url, + run_id=self.run_id + ) + if variant_data: + self.final_data.append(variant_data) + + def _save_results(self): + """[ACTION] Шаг 4: Сохранение результатов.""" + if not self.final_data: + logging.warning("Итоговый набор данных пуст. Файлы не будут созданы.") + return + + logging.info(f"Сбор данных завершен. Всего найдено валидных вариантов: {len(self.final_data)}") + + if self.settings.save_to_csv: + timestamp = datetime.now().strftime('%Y-%m-%d') + output_filename = self.settings.output_dir / f'prices_full_catalog_{timestamp}.csv' + save_data_to_csv(self.final_data, output_filename, self.run_id) + + if self.settings.save_to_db and self.db_manager: + save_data_to_db(self.final_data, self.db_manager, self.run_id) + + def _cleanup(self): + """[ACTION] Шаг 5: Корректное завершение работы.""" + self.http_session.close() + if self.db_manager: + self.db_manager.close() + logging.info(f"Работа парсера завершена. 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 diff --git a/src/scraper/engine.py b/src/scraper/engine.py new file mode 100644 index 0000000..4eb8e40 --- /dev/null +++ b/src/scraper/engine.py @@ -0,0 +1,125 @@ +# [FILE] src/scraper/engine.py +# [REFACTORING_TARGET] Преобразование модуля с функциями в класс Scraper. +# ANCHOR: Scraper_Class_Module +# Семантика: Инкапсулирует всю логику, связанную с HTTP-запросами и парсингом HTML. + +import logging +import time +from urllib.parse import urljoin +import requests +from bs4 import BeautifulSoup +from typing import List, Optional + +from src.core.models import ProductVariant +from src.core.settings import ScraperSelectors + +class Scraper: + """ + [MAIN-CONTRACT] + @description: Класс, ответственный за взаимодействие с сайтом и извлечение данных. + @invariant: Использует одну и ту же HTTP-сессию для всех запросов. + """ + def __init__(self, session: requests.Session, selectors: ScraperSelectors, base_url: str): + # [INIT] Инициализация с зависимостями. + self.session = session + self.selectors = selectors + self.base_url = base_url + self.logger = logging.getLogger(self.__class__.__name__) + + def _clean_price(self, price_str: str) -> int: + """[HELPER] Очищает строку цены и возвращает целое число.""" + digits = ''.join(filter(str.isdigit, price_str)) + return int(digits) if digits else 0 + + def _fetch_page(self, url: str, request_id: str) -> Optional[str]: + """[HELPER] Приватный метод для скачивания HTML-содержимого страницы.""" + log_prefix = f"_fetch_page(id={request_id})" + self.logger.debug(f"{log_prefix} - Запрос к URL: {url}") + try: + response = self.session.get(url, timeout=20) + response.raise_for_status() # Вызовет исключение для 4xx/5xx кодов. + 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}") + return None + + def get_base_product_urls(self, catalog_url: str, run_id: str) -> List[str]: + """[ACTION] Собирает 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: + 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) + + def get_all_variant_urls(self, base_product_urls: List[str], run_id: str) -> List[str]: + """[ACTION] Проходит по базовым URL и собирает URL всех их вариантов.""" + all_variant_urls = [] + total_base = len(base_product_urls) + log_prefix = f"get_variant_urls(id={run_id})" + + 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: + 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)} вариантов для товара.") + + time.sleep(0.5) + + self.logger.info(f"Обнаружено всего {len(all_variant_urls)} URL вариантов для парсинга.") + return all_variant_urls + + def scrape_variant_page(self, variant_url: str, run_id: str) -> Optional[ProductVariant]: + """[ACTION] Парсит страницу одного варианта и возвращает Pydantic-модель.""" + log_prefix = f"scrape_variant(id={run_id}, url={variant_url.split('/')[-1]})" + 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" + + # [POSTCONDITION] Создаем экземпляр контракта данных. + product = ProductVariant(name=name, volume=volume, price=price, url=variant_url) + self.logger.debug(f"{log_prefix} - Успешно: '{product.name}', '{product.volume}', '{product.price}'") + return product + + except Exception as e: + self.logger.error(f"{log_prefix} - Исключение при парсинге страницы: {e}", exc_info=True) + return None + +# [REFACTORING_COMPLETE] \ No newline at end of file diff --git a/src/utils/exporters.py b/src/utils/exporters.py new file mode 100644 index 0000000..c5465aa --- /dev/null +++ b/src/utils/exporters.py @@ -0,0 +1,26 @@ +# ANCHOR: Exporters_Module +# Семантика: Модуль для сохранения данных в различные форматы. +# В будущем сюда можно добавить save_to_json, save_to_xml и т.д. + +import logging +import csv +from pathlib import Path +from typing import List, Dict + +def save_data_to_csv(data: List[Dict], filename: Path, request_id: str): + # ... (код функции save_data_to_csv без изменений) ... + log_prefix = f"save_data_to_csv(id={request_id})" + if not data: + logging.warning(f"{log_prefix} - [CONTRACT_VIOLATION] Данные для сохранения отсутствуют.") + return + logging.info(f"{log_prefix} - Начало сохранения {len(data)} записей в файл: {filename}") + try: + filename.parent.mkdir(parents=True, exist_ok=True) + fieldnames = ['name', 'volume', 'price'] + with open(filename, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(data) + logging.info(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Данные успешно сохранены.") + except Exception as e: + logging.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Ошибка при сохранении CSV: {e}") \ No newline at end of file