Enhance application with new features, improved error handling, and performance optimizations. Key updates include: added data validation, retry strategies for HTTP requests, detailed logging, and support for RabbitMQ exports. Updated dependencies and enhanced README documentation for better setup instructions.

This commit is contained in:
2025-07-05 19:53:03 +03:00
parent 0ddd9f0683
commit 97e6438e9b
16 changed files with 1795 additions and 403 deletions

View File

@@ -9,9 +9,11 @@ from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from typing import List, Optional
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from src.core.models import ProductVariant # [FIX] Импорт ProductVariant
from src.core.settings import ScraperSelectors
from core.models import ProductVariant # [FIX] Импорт ProductVariant
from core.settings import ScraperSelectors
class Scraper:
"""
@@ -25,27 +27,79 @@ class Scraper:
self.selectors = selectors
self.base_url = base_url
self.logger = logging.getLogger(self.__class__.__name__)
# [ENHANCEMENT] Настройка retry стратегии для HTTP запросов
self._setup_retry_strategy()
def _setup_retry_strategy(self):
"""[HELPER] Настраивает retry стратегию для HTTP запросов."""
retry_strategy = Retry(
total=3, # Максимум 3 попытки
backoff_factor=1, # Экспоненциальная задержка: 1, 2, 4 секунды
status_forcelist=[429, 500, 502, 503, 504], # Коды ошибок для retry
allowed_methods=["HEAD", "GET", "OPTIONS"] # Разрешенные методы
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
self.logger.debug("[DEBUG] Retry стратегия настроена для HTTP запросов.")
def _clean_price(self, price_str: str) -> int:
"""[HELPER] Очищает строку цены и возвращает целое число."""
self.logger.debug(f"[DEBUG] Очистка цены: '{price_str}'")
digits = ''.join(filter(str.isdigit, price_str))
cleaned_price = int(digits) if digits else 0
self.logger.debug(f"[DEBUG] Цена после очистки: {cleaned_price}")
return cleaned_price
try:
# Удаляем все символы кроме цифр
digits = ''.join(filter(str.isdigit, price_str))
if not digits:
self.logger.warning(f"[WARNING] Не удалось извлечь цифры из цены: '{price_str}'")
return 0
cleaned_price = int(digits)
if cleaned_price <= 0:
self.logger.warning(f"[WARNING] Некорректная цена (<= 0): {cleaned_price}")
return 0
self.logger.debug(f"[DEBUG] Цена после очистки: {cleaned_price}")
return cleaned_price
except (ValueError, TypeError) as e:
self.logger.error(f"[ERROR] Ошибка при обработке цены '{price_str}': {e}")
return 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 кодов.
response = self.session.get(url, timeout=30) # Увеличил timeout до 30 секунд
response.raise_for_status()
# [ENHANCEMENT] Проверка на валидный HTML
if not response.text.strip():
self.logger.warning(f"{log_prefix} - Получен пустой ответ от {url}")
return None
# [ENHANCEMENT] Проверка на блокировку или капчу
if "captcha" in response.text.lower() or "blocked" in response.text.lower():
self.logger.error(f"{log_prefix} - [BLOCKED] Обнаружена капча или блокировка на {url}")
return None
self.logger.debug(f"{log_prefix} - [COHERENCE_CHECK_PASSED] Страница успешно получена, статус {response.status_code}.")
return response.text
except requests.exceptions.Timeout:
self.logger.error(f"{log_prefix} - [TIMEOUT] Превышено время ожидания для {url}")
return None
except requests.exceptions.ConnectionError as e:
self.logger.error(f"{log_prefix} - [CONNECTION_ERROR] Ошибка соединения для {url}: {e}")
return None
except requests.exceptions.HTTPError as e:
self.logger.error(f"{log_prefix} - [HTTP_ERROR] HTTP ошибка для {url}: {e.response.status_code}")
return None
except requests.RequestException as e:
self.logger.error(f"{log_prefix} - [COHERENCE_CHECK_FAILED] Сетевая ошибка при запросе {url}: {e}", exc_info=True)
return None
except Exception as e:
self.logger.critical(f"{log_prefix} - [CRITICAL] Непредвиденная ошибка при запросе {url}: {e}", exc_info=True)
return None
def get_base_product_urls(self, catalog_url: str, run_id: str) -> List[str]:
"""[ACTION] Собирает URL всех товаров с основной страницы каталога.
@@ -54,16 +108,36 @@ class Scraper:
"""
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 страницы каталога, возвращаю пустой список.")
self.logger.error(f"{log_prefix} - [CRITICAL] Не удалось получить HTML страницы каталога, возвращаю пустой список.")
return []
try:
soup = BeautifulSoup(html, 'html.parser')
links = soup.select(self.selectors.catalog_product_link)
if not links:
self.logger.warning(f"{log_prefix} - [WARNING] Не найдено ни одной ссылки на товар с селектором: {self.selectors.catalog_product_link}")
return []
unique_urls = set()
for link in links:
href = link.get('href')
if href:
full_url = urljoin(self.base_url, href)
unique_urls.add(full_url)
else:
self.logger.debug(f"{log_prefix} - Пропуск ссылки без href: {link}")
self.logger.info(f"{log_prefix} - Найдено {len(unique_urls)} уникальных базовых URL.")
# [COHERENCE_CHECK_PASSED] Базовые URL успешно собраны.
return list(unique_urls)
except Exception as e:
self.logger.error(f"{log_prefix} - [CRITICAL] Ошибка при парсинге каталога: {e}", exc_info=True)
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 всех их вариантов.
@@ -77,24 +151,36 @@ class Scraper:
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}")
try:
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)
else:
self.logger.debug(f"{log_prefix} - Пропуск варианта без data-id: {item}")
self.logger.debug(f"{log_prefix} - Найдено {len(variant_items)} вариантов для товара {base_url.split('/')[-1]}.")
except Exception as e:
self.logger.error(f"{log_prefix} - [ERROR] Ошибка при обработке вариантов для {base_url}: {e}")
# Добавляем базовый URL как fallback
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
@@ -105,188 +191,59 @@ class Scraper:
"""
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:
soup = BeautifulSoup(html, 'html.parser')
# [ENHANCEMENT] Более детальная проверка элементов
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}.")
if not name_el:
self.logger.warning(f"{log_prefix} - [MISSING_ELEMENT] Не найден элемент имени продукта с селектором: {self.selectors.product_page_name}")
return None
# [ACTION] Извлечение данных
if not price_el:
self.logger.warning(f"{log_prefix} - [MISSING_ELEMENT] Не найден элемент цены с селектором: {self.selectors.price_block}")
return None
# [ACTION] Извлечение данных с дополнительной валидацией
name = name_el.get_text(strip=True)
price = self._clean_price(price_el.get_text(strip=True))
if not name:
self.logger.warning(f"{log_prefix} - [EMPTY_DATA] Пустое имя продукта")
return None
price_text = price_el.get_text(strip=True)
if not price_text:
self.logger.warning(f"{log_prefix} - [EMPTY_DATA] Пустая цена")
return None
price = self._clean_price(price_text)
if price <= 0:
self.logger.warning(f"{log_prefix} - [INVALID_PRICE] Некорректная цена: {price}")
return None
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
try:
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} - [VALIDATION_ERROR] Ошибка валидации ProductVariant: {e}")
return None
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]:
"""[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]
# [REFACTORING_COMPLETE] Дублированные методы удалены, улучшена обработка ошибок