init: Parser v1 — Lead Generation Engine

Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aks
2026-06-09 12:56:06 +03:00
commit f78f35fb3f
33 changed files with 9198 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
"""Базовый класс для парсеров — общая логика 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()")