"""Базовый класс для парсеров — общая логика retry, sleep, счётчики ошибок. Конкретные парсеры (yandex_maps.py, two_gis.py, vk.py, ...) наследуют от него, переопределяют parse_category() и используют общие хелперы. """ import logging import random import time from typing import Callable, TypeVar from fake_useragent import UserAgent import config T = TypeVar("T") logger = logging.getLogger(__name__) class BaseParser: """Общая основа для всех парсеров. Subclasses переопределяют: - source_name: ключ в БД ('yandex_maps', '2gis', ...) - parse_category(query, city) -> list[dict] """ source_name: str = "base" def __init__(self): self._ua_pool = UserAgent() self.session_errors = 0 self.captcha_count = 0 # ─── Anti-bot хелперы ─────────────────────────────────────────────── def random_user_agent(self) -> str: return self._ua_pool.random def sleep_random(self, min_s: float | None = None, max_s: float | None = None) -> None: """Случайная пауза между запросами.""" lo = min_s if min_s is not None else config.MIN_DELAY hi = max_s if max_s is not None else config.MAX_DELAY delay = random.uniform(lo, hi) time.sleep(delay) def sleep_between_categories(self) -> None: """Длинная пауза между категориями (~30-60 сек).""" delay = random.uniform(config.CATEGORY_PAUSE_MIN, config.CATEGORY_PAUSE_MAX) logger.info(f"⏸ Пауза между категориями: {delay:.0f} сек") time.sleep(delay) # ─── Retry-обёртка с экспоненциальной задержкой ───────────────────── def retry(self, func: Callable[[], T], retries: int = 3) -> T | None: """Запустить func() с N попытками. Между попытками — exp.backoff.""" for attempt in range(retries): try: return func() except Exception as e: wait = (2 ** attempt) * 5 logger.warning(f"Попытка {attempt + 1}/{retries} упала: {e}. Ждём {wait}с.") if attempt < retries - 1: time.sleep(wait) self.session_errors += 1 return None # ─── Захват captcha и эскалация ───────────────────────────────────── def register_captcha(self) -> bool: """Зарегистрировать инцидент captcha. Возвращает True если надо остановиться.""" self.captcha_count += 1 logger.warning(f"⚠️ Captcha #{self.captcha_count}/{config.MAX_BLOCKED_TRIES}") if self.captcha_count >= config.MAX_BLOCKED_TRIES: logger.error( "🛑 Превышен лимит captcha. Останавливаемся — нужна смена тактики." ) return True return False # ─── Контракт для подклассов ──────────────────────────────────────── def parse_category(self, query: str, city: str = "Москва") -> list[dict]: """Должен вернуть list[dict] согласно схеме leads (database.py).""" raise NotImplementedError("Subclass must implement parse_category()")