initial
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
54
README.md
Normal 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
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# ANCHOR: Requirements
|
||||||
|
# Семантика: Список внешних библиотек, необходимых для запуска приложения.
|
||||||
|
requests
|
||||||
|
beautifulsoup4
|
||||||
28
src/config.py
Normal file
28
src/config.py
Normal 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
89
src/core/database.py
Normal 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}")
|
||||||
32
src/core/logging_config.py
Normal file
32
src/core/logging_config.py
Normal 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
66
src/core/settings.py
Normal 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
15
src/main.py
Normal 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
117
src/orchestrator.py
Normal 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
125
src/scraper/engine.py
Normal 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
26
src/utils/exporters.py
Normal 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}")
|
||||||
Reference in New Issue
Block a user