This commit is contained in:
2025-07-03 19:56:10 +03:00
commit 54827a5152
11 changed files with 575 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
# Virtualenv
.venv
venv/
ENV/
# Output data
price_data_final/
*.db
*.db-journal
# IDEs
.idea/
.vscode/

54
README.md Normal file
View File

@@ -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 <your-repo-url>
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` в базе данных.

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
# ANCHOR: Requirements
# Семантика: Список внешних библиотек, необходимых для запуска приложения.
requests
beautifulsoup4

28
src/config.py Normal file
View File

@@ -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',
}

89
src/core/database.py Normal file
View File

@@ -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}")

View File

@@ -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("Система логирования инициализирована.")

66
src/core/settings.py Normal file
View File

@@ -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] Этот модуль готов к использованию.

15
src/main.py Normal file
View File

@@ -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()

117
src/orchestrator.py Normal file
View File

@@ -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()

125
src/scraper/engine.py Normal file
View File

@@ -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]

26
src/utils/exporters.py Normal file
View File

@@ -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}")