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:
@@ -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()")
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
"""HH.ru парсер — собирает компании которые ищут "руки" вместо систем.
|
||||
|
||||
Архитектура:
|
||||
api.hh.ru блокирует bulk-запросы без OAuth-токена → парсим через Botasaurus
|
||||
публичную страницу hh.ru/search/vacancy. Дешевле чем регистрация приложения.
|
||||
|
||||
Принцип:
|
||||
1. Открываем https://hh.ru/search/vacancy?text={query}&area={area_id}&page={N}
|
||||
2. Botasaurus имитирует браузер → Cloudflare пропускает
|
||||
3. С каждой страницы выдачи парсим карточки `[data-qa="vacancy-serp__vacancy"]`
|
||||
4. Из каждой берём:
|
||||
- название работодателя `[data-qa="vacancy-serp__vacancy-employer"]`
|
||||
- employer_id (из href ссылки на работодателя)
|
||||
- vacancy_title (для контекста — это и есть signal)
|
||||
5. Дедуп по employer_id (одна компания может открыть много вакансий)
|
||||
6. Возвращаем list[dict] под нашу схему leads (source="hh")
|
||||
|
||||
Дальнейшее обогащение:
|
||||
- --enrich-egrul → ИНН + директор + дата регистрации (по name)
|
||||
- --enrich → анализ сайта (только если в name найдём через ЕГРЮЛ домен)
|
||||
|
||||
Сигнал боли:
|
||||
Если компания ищет "оператора ПК" — у них нет CRM. +3 к scoring.hh_signal.
|
||||
|
||||
Селекторы взяты из работающего друг-парсера (services/search.py):
|
||||
- vacancy-serp__vacancy — карточка
|
||||
- vacancy-serp__vacancy-employer — название работодателя (с ссылкой)
|
||||
- vacancy-serp__vacancy-compensation — зарплата
|
||||
- serp-item__title — заголовок вакансии
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from botasaurus.browser import browser, Driver
|
||||
|
||||
import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Селекторы карточек выдачи HH
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
SEL = {
|
||||
"card": '[data-qa="vacancy-serp__vacancy"]',
|
||||
"vacancy_title": '[data-qa="serp-item__title"]',
|
||||
"employer_link": '[data-qa="vacancy-serp__vacancy-employer"]',
|
||||
"salary": '[data-qa="vacancy-serp__vacancy-compensation"]',
|
||||
}
|
||||
|
||||
# Маппинг наших city → HH area_id
|
||||
HH_AREAS = {
|
||||
"Москва": 1,
|
||||
"Санкт-Петербург": 2,
|
||||
"Москва и МО": 2114,
|
||||
"Россия": 113,
|
||||
}
|
||||
|
||||
# HH разрешает до page=39 (40 страниц × 50 вакансий = 2000 max).
|
||||
# Для signal-парсинга достаточно 5 страниц = 250 вакансий.
|
||||
# Единый источник правды — config (не дублируем число здесь).
|
||||
HH_MAX_PAGES = config.HH_MAX_PAGES_PER_QUERY
|
||||
|
||||
|
||||
def _resolve_area(city: str) -> int:
|
||||
"""Из CLI city → HH area_id. Fallback на Россия."""
|
||||
if city in HH_AREAS:
|
||||
return HH_AREAS[city]
|
||||
logger.warning(
|
||||
f" Город '{city}' не имеет HH area_id. Используем area=113 (Россия). "
|
||||
f"Известные: {list(HH_AREAS.keys())}"
|
||||
)
|
||||
return HH_AREAS["Россия"]
|
||||
|
||||
|
||||
def _extract_employer_id(href: str | None) -> Optional[str]:
|
||||
"""Из ссылки '/employer/12345?from=vacancy' → '12345'."""
|
||||
if not href:
|
||||
return None
|
||||
m = re.search(r"/employer/(\d+)", href)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def _normalize_employer_link(href: str | None) -> Optional[str]:
|
||||
"""Привести ссылку на работодателя к абсолютному URL."""
|
||||
if not href:
|
||||
return None
|
||||
if href.startswith("/"):
|
||||
return "https://hh.ru" + href
|
||||
return href
|
||||
|
||||
|
||||
def _dismiss_hh_modals(driver: Driver) -> bool:
|
||||
"""Закрыть всплывающие модалы HH (региональный селектор и пр.).
|
||||
|
||||
Возвращает True если что-то закрыли.
|
||||
"""
|
||||
js = """
|
||||
// 1. Региональный модал "Вы из Москвы?" — кликаем "Да, верно"
|
||||
const regionBtns = document.querySelectorAll(
|
||||
'[data-qa*="confirmRegion"], [data-qa*="region-clarification"], '
|
||||
+ '[data-qa="confirm-region__yes"], [data-qa="region__confirm"]'
|
||||
);
|
||||
for (const b of regionBtns) {
|
||||
const txt = (b.textContent || '').toLowerCase();
|
||||
if (txt.includes('да') || txt.includes('верно')) {
|
||||
b.click();
|
||||
return 'region:' + (b.getAttribute('data-qa') || '');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Любой button с текстом "Да" в модальном окне
|
||||
const allBtns = document.querySelectorAll('button');
|
||||
for (const b of allBtns) {
|
||||
const txt = (b.textContent || '').trim().toLowerCase();
|
||||
if (txt === 'да' || txt === 'верно' || txt === 'да, верно') {
|
||||
// Проверяем что кнопка реально видна (не скрыта)
|
||||
const r = b.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
b.click();
|
||||
return 'button-text';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Закрытие баннера cookies / уведомлений
|
||||
const closes = document.querySelectorAll(
|
||||
'[data-qa*="close"], [aria-label*="закрыть"], [aria-label*="Close"]'
|
||||
);
|
||||
for (const c of closes) {
|
||||
const r = c.getBoundingClientRect();
|
||||
if (r.width > 0 && r.height > 0) {
|
||||
c.click();
|
||||
return 'close';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
"""
|
||||
try:
|
||||
result = driver.run_js(js)
|
||||
if result:
|
||||
logger.info(f" 🪟 Закрыт модал: {result}")
|
||||
# После клика на "Да, верно" HH делает reload страницы — ждём
|
||||
# дольше чтобы navigation завершился. Раньше было sleep(1) и
|
||||
# парсер зависал на ожидании DOM который ещё не отрисовался.
|
||||
driver.sleep(3)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f" dismiss_modals js error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Browser-парсер: одна страница выдачи → list[dict]
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
@browser(
|
||||
headless=False,
|
||||
block_images=True,
|
||||
reuse_driver=True,
|
||||
)
|
||||
def _parse_hh_pages(driver: Driver, data: dict) -> list[dict]:
|
||||
"""Browser-функция: открывает N страниц выдачи и собирает работодателей.
|
||||
|
||||
data: {
|
||||
"query": "оператор колл-центра",
|
||||
"area": 1,
|
||||
"max_pages": 5,
|
||||
"city": "Москва",
|
||||
"region": "Москва",
|
||||
}
|
||||
"""
|
||||
query = data["query"]
|
||||
area = data["area"]
|
||||
max_pages = data.get("max_pages", HH_MAX_PAGES)
|
||||
city = data["city"]
|
||||
region = data.get("region", city)
|
||||
|
||||
seen_employer_ids: set[str] = set()
|
||||
leads: list[dict] = []
|
||||
|
||||
for page_num in range(max_pages):
|
||||
url = (
|
||||
f"https://hh.ru/search/vacancy?"
|
||||
f"text={quote(query)}"
|
||||
f"&area={area}"
|
||||
f"&page={page_num}"
|
||||
f"&hhtmFrom=vacancy_search_list"
|
||||
)
|
||||
logger.info(f" страница {page_num}: {url[:100]}...")
|
||||
|
||||
try:
|
||||
driver.get(url)
|
||||
driver.sleep(2) # ждём загрузку (2026-05-18: 3 → 2 для ускорения)
|
||||
except Exception as e:
|
||||
logger.warning(f" страница {page_num}: ошибка загрузки {e}")
|
||||
break
|
||||
|
||||
# Закрыть модалы HH (региональный, cookies и т.п.).
|
||||
# Один проход достаточно — после клика на "Да" HH перезагружает страницу,
|
||||
# повторные попытки только зависают driver в navigation-wait.
|
||||
# Пробуем максимум 2 раза с большим интервалом.
|
||||
_dismiss_hh_modals(driver)
|
||||
_dismiss_hh_modals(driver) # второй заход на случай каскадных модалов
|
||||
|
||||
# Анти-бот проверки
|
||||
detected = driver.get_bot_detected_by()
|
||||
if detected:
|
||||
logger.error(f"⚠️ Бот-детектор: {detected}. Останавливаемся.")
|
||||
break
|
||||
|
||||
# Парсим карточки. wait=None — мгновенный возврат None если не найдено,
|
||||
# иначе на каждом select будет 5-10 сек таймаут × 50 карточек = 8 минут зависа
|
||||
try:
|
||||
# Сначала ждём что хотя бы одна карточка появилась (с явным таймаутом 5 сек)
|
||||
first_card = driver.select(SEL["card"], wait=5)
|
||||
if not first_card:
|
||||
logger.info(f" страница {page_num}: карточек нет на странице")
|
||||
cards = []
|
||||
else:
|
||||
# Теперь все карточки — без ожидания
|
||||
cards = driver.select_all(SEL["card"], wait=None)
|
||||
except Exception as e:
|
||||
logger.warning(f" страница {page_num}: ошибка select_all: {e}")
|
||||
cards = []
|
||||
|
||||
if not cards:
|
||||
logger.info(f" страница {page_num}: карточек нет → конец выдачи")
|
||||
break
|
||||
|
||||
new_employers_on_page = 0
|
||||
for card_idx, card in enumerate(cards, start=1):
|
||||
# Прогресс каждые 10 карточек — чтобы видеть зависает ли парсинг карточек
|
||||
if card_idx % 10 == 0:
|
||||
logger.info(f" обработано {card_idx}/{len(cards)} карточек, лидов добавлено: {new_employers_on_page}")
|
||||
try:
|
||||
# wait=None — мгновенно, иначе 5 сек × 50 карточек × 3 поля = 12 минут
|
||||
vt_el = card.select(SEL["vacancy_title"], wait=None)
|
||||
vacancy_title = vt_el.text.strip() if (vt_el and vt_el.text) else query
|
||||
|
||||
emp_el = card.select(SEL["employer_link"], wait=None)
|
||||
if not emp_el or not emp_el.text:
|
||||
continue
|
||||
|
||||
emp_name = emp_el.text.strip()
|
||||
emp_href = emp_el.get_attribute("href")
|
||||
emp_id = _extract_employer_id(emp_href)
|
||||
|
||||
# Дедуп по id (если есть), иначе по name
|
||||
dedup_key = f"id:{emp_id}" if emp_id else f"name:{emp_name.lower()}"
|
||||
if dedup_key in seen_employer_ids:
|
||||
continue
|
||||
seen_employer_ids.add(dedup_key)
|
||||
|
||||
# Blacklist — отфильтровать крупных не-клиентов (банки, госструктуры,
|
||||
# сети ритейла, Яндекс/МТС/Газпром и пр.). Им outreach бесполезен.
|
||||
from enricher.blacklist import is_blacklisted
|
||||
is_bl, bl_reason = is_blacklisted(emp_name)
|
||||
if is_bl:
|
||||
logger.debug(f" skip blacklisted: {emp_name!r} ({bl_reason})")
|
||||
continue
|
||||
|
||||
new_employers_on_page += 1
|
||||
|
||||
# Зарплата (опционально, для контекста)
|
||||
salary_text = None
|
||||
sal_el = card.select(SEL["salary"], wait=None)
|
||||
if sal_el and sal_el.text:
|
||||
salary_text = sal_el.text.strip()
|
||||
|
||||
# category = поисковый запрос Яна (то что он искал в HH),
|
||||
# а не название вакансии. Так в CRM удобнее: искали "кафе" →
|
||||
# в столбце "Категория" видим "кафе" (как в Я.Картах).
|
||||
# vacancy_title логируется ниже как контекст signal'а.
|
||||
lead = {
|
||||
"name": emp_name,
|
||||
"city": city,
|
||||
"region": region,
|
||||
"category": query,
|
||||
"source": "hh",
|
||||
"source_id": f"hh_{emp_id}" if emp_id else None,
|
||||
"source_url": _normalize_employer_link(emp_href),
|
||||
}
|
||||
# salary не входит в схему leads, но логируем
|
||||
if salary_text:
|
||||
logger.debug(f" {emp_name} | {vacancy_title} | {salary_text}")
|
||||
|
||||
leads.append(lead)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f" Ошибка парсинга карточки: {e}")
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f" страница {page_num}: карточек {len(cards)}, "
|
||||
f"новых работодателей {new_employers_on_page}, "
|
||||
f"всего уникальных {len(seen_employer_ids)}"
|
||||
)
|
||||
|
||||
# Если на странице меньше карточек чем обычно — это последняя
|
||||
if len(cards) < 20:
|
||||
logger.info(f" карточек < 20 → выходим из цикла страниц")
|
||||
break
|
||||
|
||||
# Anti-rate-limit пауза (2026-05-18: 2 → 1 сек)
|
||||
logger.info(f" пауза 1 сек перед следующей страницей...")
|
||||
time.sleep(1)
|
||||
|
||||
logger.info(
|
||||
f" цикл страниц завершён, готовим возврат {len(leads)} лидов"
|
||||
)
|
||||
logger.info(
|
||||
f"✅ HH '{query}': обработано {max_pages} страниц → "
|
||||
f"{len(leads)} уникальных работодателей"
|
||||
)
|
||||
return leads
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Публичная функция (вызывается из main.py)
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
def parse_hh_signal(
|
||||
query: str,
|
||||
city: str = "Москва",
|
||||
max_pages: int = HH_MAX_PAGES,
|
||||
period_days: int = 30,
|
||||
) -> list[dict]:
|
||||
"""Найти компании по signal-запросу через HH.
|
||||
|
||||
Возвращает list[dict] под схему leads. source="hh".
|
||||
Уникальные employers (одна компания = один лид).
|
||||
"""
|
||||
area_id = _resolve_area(city)
|
||||
region = "Москва" if city == "Москва" else "Москва и МО" if city == "Москва и МО" else city
|
||||
|
||||
logger.info(f"\n→ HH search: '{query}' (area={area_id})")
|
||||
|
||||
return _parse_hh_pages({
|
||||
"query": query,
|
||||
"area": area_id,
|
||||
"max_pages": max_pages,
|
||||
"city": city,
|
||||
"region": region,
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
"""HH employer-page parser — добирает website компании со страницы работодателя.
|
||||
|
||||
Контекст:
|
||||
Сама выдача HH (`hh.ru/search/vacancy`) не показывает сайт работодателя —
|
||||
только название и employer_id. Но на странице компании
|
||||
(`hh.ru/employer/{id}`) работодатель часто указывает свой сайт.
|
||||
|
||||
Эффект:
|
||||
После основного HH-парсинга → запуск этого enricher → у HH-лидов
|
||||
появляется поле `website` → обычный Tier 2 (`--enrich`) проходит по
|
||||
этим сайтам и собирает email/доп.телефоны.
|
||||
|
||||
Workflow:
|
||||
HH parse → ЕГРЮЛ enrich → HH website enrich → Tier 2 enrich → email/phones
|
||||
|
||||
Покрытие:
|
||||
На HH сайт компании указан у ~50-70% работодателей малого бизнеса.
|
||||
|
||||
Селекторы:
|
||||
На странице employer'а сайт может быть в нескольких местах. Перебираем
|
||||
в порядке приоритета + fallback на body-text scan через regex.
|
||||
|
||||
Дополнительно:
|
||||
Также извлекаем email со страницы employer'а (некоторые компании указывают
|
||||
HR-почту в описании) и доп.телефоны.
|
||||
"""
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from botasaurus.browser import browser, Driver
|
||||
|
||||
from normalization import extract_emails_from_text, extract_phones_from_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Селекторы сайта компании на странице employer'а
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
SITE_SELECTORS = [
|
||||
'[data-qa="sidebar-company-site"] a',
|
||||
'[data-qa="sidebar-company-site"]',
|
||||
'[data-qa="employer-page__website"]',
|
||||
'[data-qa="employer-site-url"]',
|
||||
'[data-qa="company-site-url"]',
|
||||
'a[data-qa*="company-site"]',
|
||||
'a[data-qa*="employer-site"]',
|
||||
]
|
||||
|
||||
# Домены которые НЕ считаются сайтом компании (HH-инфраструктура, соцсети,
|
||||
# и обычные ссылки на партнёров/новости которые часто встречаются в описании).
|
||||
SKIP_DOMAINS = (
|
||||
"hh.ru", "hhcdn.ru", "headhunter", "yastatic.net", "yandex.",
|
||||
"vk.com", "vk.ru", "t.me", "telegram.", "instagram.com",
|
||||
"facebook.com", "linkedin.com", "youtube.com", "youtu.be",
|
||||
"twitter.com", "x.com", "ok.ru", "rutube.ru",
|
||||
"google.com", "googleusercontent.com",
|
||||
"wikipedia.org",
|
||||
# Сервисы вакансий
|
||||
"superjob.ru", "rabota.ru", "avito.ru",
|
||||
)
|
||||
|
||||
|
||||
def _is_company_site(url: str) -> bool:
|
||||
"""True если URL похож на собственный сайт компании, а не на соцсеть/HH."""
|
||||
if not url:
|
||||
return False
|
||||
u = url.lower().strip()
|
||||
# Должен быть http(s) и не пустой
|
||||
if not (u.startswith("http://") or u.startswith("https://")):
|
||||
return False
|
||||
for skip in SKIP_DOMAINS:
|
||||
if skip in u:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _extract_website_from_employer_page(driver: Driver) -> Optional[str]:
|
||||
"""Перебор data-qa селекторов + fallback на body-text scan на http-ссылки.
|
||||
|
||||
Возвращает первый найденный сайт компании, либо None.
|
||||
"""
|
||||
# 1. Структурные селекторы (приоритет — наиболее надёжные)
|
||||
for sel in SITE_SELECTORS:
|
||||
try:
|
||||
el = driver.select(sel, wait=None)
|
||||
if not el:
|
||||
continue
|
||||
# У <a> ссылка в href; у других элементов — в тексте
|
||||
href = el.get_attribute("href")
|
||||
candidate = (href or el.text or "").strip()
|
||||
# Если в тексте — может быть без https:// в начале
|
||||
if candidate and not (candidate.startswith("http://") or candidate.startswith("https://")):
|
||||
if "." in candidate and " " not in candidate:
|
||||
candidate = "https://" + candidate
|
||||
if _is_company_site(candidate):
|
||||
return candidate
|
||||
except Exception as e:
|
||||
logger.debug(f" selector {sel} failed: {e}")
|
||||
continue
|
||||
|
||||
# 2. Fallback: сканируем весь видимый текст страницы regex'ом на http-ссылки.
|
||||
# У employer'ов сайт часто упомянут в описании компании.
|
||||
try:
|
||||
text = driver.run_js("return document.body.innerText") or ""
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
if text:
|
||||
urls = re.findall(r"https?://[^\s)\"'>\]]+", text)
|
||||
for url in urls:
|
||||
url_clean = url.rstrip(".,;:")
|
||||
if _is_company_site(url_clean):
|
||||
return url_clean
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_extra_contacts(driver: Driver) -> tuple[list[str], list[str]]:
|
||||
"""Извлечь email и доп.телефоны со страницы employer'а.
|
||||
|
||||
Returns: (emails, phones)
|
||||
"""
|
||||
try:
|
||||
text = driver.run_js("return document.body.innerText") or ""
|
||||
except Exception:
|
||||
return [], []
|
||||
if not text:
|
||||
return [], []
|
||||
|
||||
emails: list[str] = []
|
||||
for e in extract_emails_from_text(text):
|
||||
el = e.lower()
|
||||
# Фильтр служебных HH адресов и явно технических
|
||||
if any(bad in el for bad in (
|
||||
"@hh.ru", "@headhunter", "no-reply", "noreply", "postmaster",
|
||||
"support@hh", "feedback@hh",
|
||||
)):
|
||||
continue
|
||||
if el not in emails:
|
||||
emails.append(el)
|
||||
|
||||
phones: list[str] = []
|
||||
for p in extract_phones_from_text(text):
|
||||
if p and p not in phones:
|
||||
phones.append(p)
|
||||
|
||||
return emails, phones
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Browser-парсер
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
@browser(
|
||||
headless=False,
|
||||
block_images=True,
|
||||
reuse_driver=True,
|
||||
)
|
||||
def parse_hh_employer_pages(driver: Driver, data: dict) -> list[dict]:
|
||||
"""Browser-функция: пройти по employer-page'ам, забрать website/emails/phones.
|
||||
|
||||
data: {
|
||||
"leads": [{"lead_id": int, "employer_id": str, "name": str}, ...],
|
||||
"delay_min": float, # пауза между запросами
|
||||
"delay_max": float,
|
||||
}
|
||||
|
||||
Возвращает список dict'ов:
|
||||
[{"lead_id": int, "website": str|None, "emails": [...], "phones": [...]}]
|
||||
"""
|
||||
leads = data.get("leads", [])
|
||||
delay_min = data.get("delay_min", 1.5)
|
||||
delay_max = data.get("delay_max", 4.0)
|
||||
|
||||
results: list[dict] = []
|
||||
|
||||
for idx, lead in enumerate(leads, start=1):
|
||||
employer_id = lead.get("employer_id")
|
||||
lead_id = lead.get("lead_id")
|
||||
name = lead.get("name", "?")
|
||||
if not employer_id:
|
||||
continue
|
||||
|
||||
url = f"https://hh.ru/employer/{employer_id}"
|
||||
logger.info(f"[{idx}/{len(leads)}] employer {employer_id} ({name[:40]})")
|
||||
|
||||
try:
|
||||
driver.get(url)
|
||||
driver.sleep(1.5) # 2026-05-18: 2 → 1.5 (employer-страницы лёгкие)
|
||||
|
||||
# Anti-bot check
|
||||
detected = driver.get_bot_detected_by()
|
||||
if detected:
|
||||
logger.error(f"⚠️ Бот-детектор: {detected}. Останавливаемся.")
|
||||
break
|
||||
|
||||
# Сайт компании
|
||||
website = _extract_website_from_employer_page(driver)
|
||||
# Дополнительные email/телефоны со страницы employer'а
|
||||
emails, phones = _extract_extra_contacts(driver)
|
||||
|
||||
res = {
|
||||
"lead_id": lead_id,
|
||||
"website": website,
|
||||
"emails": emails,
|
||||
"phones": phones,
|
||||
}
|
||||
results.append(res)
|
||||
|
||||
# Краткий лог что нашли
|
||||
site_short = website[:50] if website else "—"
|
||||
extras = []
|
||||
if emails:
|
||||
extras.append(f"+{len(emails)}email")
|
||||
if phones:
|
||||
extras.append(f"+{len(phones)}тел")
|
||||
extras_str = (" | " + " ".join(extras)) if extras else ""
|
||||
logger.info(f" site: {site_short}{extras_str}")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f" ⚠ ошибка: {e}")
|
||||
continue
|
||||
|
||||
# Anti-rate-limit пауза
|
||||
if idx < len(leads):
|
||||
time.sleep(random.uniform(delay_min, delay_max))
|
||||
|
||||
logger.info(f"\n✅ HH employer-pages обработано: {len(results)} из {len(leads)}")
|
||||
return results
|
||||
@@ -0,0 +1,373 @@
|
||||
"""Боевой парсер Яндекс.Карт.
|
||||
|
||||
Алгоритм:
|
||||
1. Открываем `https://yandex.ru/maps/{city_id}/moscow/search/{query}/`
|
||||
2. Скроллим .scroll__container пока появляются новые .search-snippet-view
|
||||
(или пока не упрёмся в MAX_SCROLLS / MAX_CARDS_PER_CATEGORY)
|
||||
3. Из каждой карточки забираем href ссылки + категорию из листинга
|
||||
(категория есть в листинге, но НЕТ на детальной странице)
|
||||
4. По каждому href: driver.get(href) + парсинг боковой панели
|
||||
5. Возвращаем list[dict] под схему leads
|
||||
|
||||
Селекторы подтверждены эмпирически в Phase 0 (test_yandex_v3.py, 3/3 карточек).
|
||||
См. sessions/2026-05-01_phase0_research.md.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from botasaurus.browser import browser, Driver
|
||||
|
||||
import config
|
||||
from normalization import (
|
||||
is_garbage_social,
|
||||
normalize_phone,
|
||||
parse_rating,
|
||||
parse_reviews_count,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# СЕЛЕКТОРЫ — финал из Phase 0
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
SEL = {
|
||||
# листинг
|
||||
"list_container": ".scroll__container",
|
||||
"card_in_list": ".search-snippet-view",
|
||||
"card_link": "a", # внутри .search-snippet-view
|
||||
"card_category": ".search-business-snippet-view__category",
|
||||
|
||||
# детальная страница
|
||||
"title": "h1.orgpage-header-view__header",
|
||||
"address": ".orgpage-header-view__address",
|
||||
"phone_container": ".orgpage-phones-view__phone-number", # текст, не tel:!
|
||||
"website_link": ".business-urls-view__link",
|
||||
"rating": ".business-rating-badge-view__rating-text",
|
||||
"reviews_count": ".business-header-rating-view__text",
|
||||
}
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Этап 1: собрать ссылки + категории из листинга
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
def _scroll_list_via_js(driver: Driver, by: int = 2000) -> bool:
|
||||
"""Скроллит панель списка чистым JS. Возвращает True если контейнер найден и проскроллен."""
|
||||
js = f"""
|
||||
const containers = document.querySelectorAll('{SEL["list_container"]}');
|
||||
let scrolled = false;
|
||||
for (const c of containers) {{
|
||||
// Берём только тот контейнер, у которого есть прокручиваемый контент
|
||||
if (c.scrollHeight > c.clientHeight) {{
|
||||
c.scrollTop = c.scrollTop + {by};
|
||||
scrolled = true;
|
||||
}}
|
||||
}}
|
||||
return scrolled;
|
||||
"""
|
||||
try:
|
||||
return bool(driver.run_js(js))
|
||||
except Exception as e:
|
||||
logger.warning(f" JS-скролл упал: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _collect_card_links(driver: Driver, max_cards: int) -> list[dict]:
|
||||
"""Скроллит список и собирает [{href, category}, ...]"""
|
||||
cards_before = driver.count(SEL["card_in_list"])
|
||||
logger.info(f" Стартовое количество карточек: {cards_before}")
|
||||
|
||||
stagnant_iterations = 0 # счётчик итераций без новых карточек
|
||||
MAX_STAGNANT = 3 # после стольких безуспешных скроллов — выходим
|
||||
|
||||
for i in range(1, config.MAX_SCROLLS + 1):
|
||||
scrolled_ok = _scroll_list_via_js(driver, by=2000)
|
||||
if not scrolled_ok:
|
||||
logger.warning(f" Скролл #{i}: контейнер не найден / не прокручиваемый")
|
||||
break
|
||||
|
||||
driver.sleep(1.0) # ждём ленивую подгрузку (2026-05-18: 1.5 → 1.0)
|
||||
cards_now = driver.count(SEL["card_in_list"])
|
||||
delta = cards_now - cards_before
|
||||
logger.info(f" Скролл #{i}: карточек {cards_now} (+{delta})")
|
||||
|
||||
if cards_now >= max_cards:
|
||||
logger.info(f" Достигнут лимит карточек ({max_cards}) — стоп")
|
||||
break
|
||||
|
||||
if delta == 0:
|
||||
stagnant_iterations += 1
|
||||
if stagnant_iterations >= MAX_STAGNANT:
|
||||
logger.info(
|
||||
f" Карточки не растут {MAX_STAGNANT} итерации подряд → конец листинга"
|
||||
)
|
||||
break
|
||||
else:
|
||||
stagnant_iterations = 0
|
||||
|
||||
cards_before = cards_now
|
||||
|
||||
# Сбор ссылок
|
||||
cards = driver.select_all(SEL["card_in_list"])[:max_cards]
|
||||
items: list[dict] = []
|
||||
for card in cards:
|
||||
link_el = card.select(SEL["card_link"])
|
||||
if not link_el:
|
||||
continue
|
||||
href = link_el.get_attribute("href") or ""
|
||||
if not href:
|
||||
continue
|
||||
if href.startswith("/"):
|
||||
href = "https://yandex.ru" + href
|
||||
|
||||
# Пропускаем рекомендательные подборки Яндекса (не organization-карточки).
|
||||
# Признаки: /maps/discovery/ — подборки, /maps/category/ — каталог категорий.
|
||||
if "/maps/discovery/" in href or "/maps/category/" in href:
|
||||
continue
|
||||
|
||||
cat_el = card.select(SEL["card_category"])
|
||||
category = cat_el.text.strip() if (cat_el and cat_el.text) else None
|
||||
|
||||
items.append({"href": href, "category": category})
|
||||
|
||||
logger.info(f" Собрано ссылок на детальные карточки: {len(items)}")
|
||||
return items
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Этап 2: парсинг детальной карточки
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
def _safe_text(driver: Driver, selector: str) -> Optional[str]:
|
||||
el = driver.select(selector, wait=None) # wait=None: не висеть 4с на отсутствующем
|
||||
if el and el.text:
|
||||
return el.text.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _safe_attr(driver: Driver, selector: str, attr: str) -> Optional[str]:
|
||||
el = driver.select(selector, wait=None)
|
||||
return el.get_attribute(attr) if el else None
|
||||
|
||||
|
||||
def _parse_phones_on_page(driver: Driver) -> list[str]:
|
||||
"""В Яндекс.Картах телефон — текст в .orgpage-phones-view__phone-number, не tel:."""
|
||||
phones: list[str] = []
|
||||
# Может быть несколько телефонов на одной карточке
|
||||
elements = driver.select_all(SEL["phone_container"], wait=None)
|
||||
for el in elements:
|
||||
if el.text:
|
||||
normalized = normalize_phone(el.text.strip())
|
||||
if normalized and normalized not in phones:
|
||||
phones.append(normalized)
|
||||
return phones
|
||||
|
||||
|
||||
def _parse_socials_on_page(driver: Driver) -> dict[str, str]:
|
||||
"""Соцсети с фильтром мусора (vk.com/yandex.maps и т.п.)."""
|
||||
socials: dict[str, str] = {}
|
||||
patterns = [
|
||||
("vk_url", 'a[href*="vk.com"]'),
|
||||
("telegram_url", 'a[href*="t.me"]'),
|
||||
("instagram_url", 'a[href*="instagram.com"]'),
|
||||
("youtube_url", 'a[href*="youtube.com"]'),
|
||||
]
|
||||
for field, selector in patterns:
|
||||
# Берём все ссылки и ищем первую "не мусорную"
|
||||
links = driver.select_all(selector, wait=None)
|
||||
for link in links:
|
||||
href = link.get_attribute("href")
|
||||
if href and not is_garbage_social(href):
|
||||
socials[field] = href
|
||||
break
|
||||
return socials
|
||||
|
||||
|
||||
def _parse_card_detail(
|
||||
driver: Driver,
|
||||
card_meta: dict,
|
||||
city: str = "Москва",
|
||||
region: str | None = None,
|
||||
district: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""Открыть детальную страницу и собрать поля. Возвращает dict под схему leads."""
|
||||
href = card_meta["href"]
|
||||
category = card_meta.get("category")
|
||||
|
||||
driver.get(href)
|
||||
# Ждём появления заголовка организации (адаптивно вместо фикс. sleep(2.5)).
|
||||
# wait_for_complete_page_load=False → get возвращается рано; заголовок ждём явно.
|
||||
driver.select(SEL["title"], wait=8)
|
||||
|
||||
# Базовые поля. ВАЖНО: все select ниже — с wait=None (мгновенный возврат).
|
||||
# К моменту появления заголовка панель организации уже в DOM, а отсутствующие
|
||||
# поля (особенно соцсети — у кафе их обычно нет) НЕ должны висеть по 4с
|
||||
# default-таймаута каждое. Именно это давало ~11с/карточку (а не навигация).
|
||||
name = _safe_text(driver, SEL["title"])
|
||||
if not name:
|
||||
logger.warning(f" Не нашли name на {href[:80]}")
|
||||
return None
|
||||
|
||||
address = _safe_text(driver, SEL["address"])
|
||||
rating_raw = _safe_text(driver, SEL["rating"])
|
||||
reviews_raw = _safe_text(driver, SEL["reviews_count"])
|
||||
|
||||
# Телефоны
|
||||
phones = _parse_phones_on_page(driver)
|
||||
|
||||
# Сайт (с фильтром на яндексовские внутренние ссылки)
|
||||
website = None
|
||||
web_el = driver.select(SEL["website_link"], wait=None)
|
||||
if web_el:
|
||||
href_web = web_el.get_attribute("href")
|
||||
if href_web and "yandex" not in href_web.lower():
|
||||
website = href_web
|
||||
|
||||
# Соцсети
|
||||
socials = _parse_socials_on_page(driver)
|
||||
|
||||
# Контакты НЕ собираем из document.body.innerText — это ВЕСЬ видимый текст
|
||||
# страницы (реклама, блок «Похожие места», футер Яндекса), откуда регулярно
|
||||
# подмешивались ЧУЖИЕ телефоны/email и мусор: placeholder-номера (+73333333333),
|
||||
# несуществующие коды, имена картинок-как-email (asset@4x.png), почты
|
||||
# разработчиков тем сайтов (lella@elated-themes.com).
|
||||
# Достоверный телефон компании — только из спец-селектора (phones выше).
|
||||
# Email и доп.телефоны добираются отдельно с САЙТА компании через
|
||||
# enricher/website_analyzer (Tier 2): там приоритет mailto:/tel: и привязка
|
||||
# к домену сайта → контакты гарантированно принадлежат компании.
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"phones": phones,
|
||||
"phone_primary": phones[0] if phones else None,
|
||||
"emails": [],
|
||||
"email_primary": None,
|
||||
"website": website,
|
||||
"vk_url": socials.get("vk_url"),
|
||||
"telegram_url": socials.get("telegram_url"),
|
||||
"instagram_url": socials.get("instagram_url"),
|
||||
"youtube_url": socials.get("youtube_url"),
|
||||
"address": address,
|
||||
"city": city,
|
||||
"region": region,
|
||||
"district": district,
|
||||
"category": category,
|
||||
"reviews_count": parse_reviews_count(reviews_raw),
|
||||
"reviews_avg": parse_rating(rating_raw) or 0.0,
|
||||
"source": "yandex_maps",
|
||||
"source_url": href,
|
||||
}
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# ПУБЛИЧНАЯ ФУНКЦИЯ — это её вызывает main.py
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
@browser(
|
||||
headless=False, # Phase 1: пока видим что происходит
|
||||
block_images_and_css=True, # + CSS: рендер быстрее; на DOM-парсинг не влияет
|
||||
reuse_driver=True,
|
||||
wait_for_complete_page_load=False, # НЕ ждать полный рендер SPA (карта/тайлы/аналитика) —
|
||||
# парсим, как только готов нужный селектор (см. ниже).
|
||||
# Это срезает ~11с/карточку (узкое место по замеру).
|
||||
)
|
||||
def parse_yandex_maps(driver: Driver, data: dict) -> list[dict]:
|
||||
"""
|
||||
Парсит 1 категорию в 1 городе.
|
||||
|
||||
data: {
|
||||
"query": "кафе", # категория
|
||||
"city": "Москва",
|
||||
"district": "Митино", # опц. район внутри города (добавляется в query)
|
||||
"max_cards": 30,
|
||||
}
|
||||
|
||||
Возвращает list[dict] под схему leads.
|
||||
"""
|
||||
query = data.get("query", "кафе")
|
||||
city = data.get("city", "Москва")
|
||||
district = (data.get("district") or "").strip() or None
|
||||
max_cards = data.get("max_cards", config.MAX_CARDS_PER_CATEGORY)
|
||||
|
||||
city_cfg = config.CITIES.get(city)
|
||||
if not city_cfg:
|
||||
# Fallback: неизвестный город → используем "Москва и МО" как geo,
|
||||
# а сам город уходит в district (как часть поискового запроса).
|
||||
if district:
|
||||
logger.error(
|
||||
f"Город '{city}' не в config.CITIES, и при этом указан --district '{district}'. "
|
||||
f"Так нельзя — добавь '{city}' в config.CITIES с правильным yandex_id "
|
||||
f"либо убери --district. Доступные города: {list(config.CITIES.keys())}"
|
||||
)
|
||||
return []
|
||||
|
||||
fallback_name = "Москва и МО"
|
||||
fallback_cfg = config.CITIES.get(fallback_name)
|
||||
if not fallback_cfg:
|
||||
logger.error(
|
||||
f"Город '{city}' не найден в config.CITIES и нет fallback '{fallback_name}'. "
|
||||
f"Доступные: {list(config.CITIES.keys())}"
|
||||
)
|
||||
return []
|
||||
|
||||
logger.warning(
|
||||
f"⚠️ Город '{city}' не в config.CITIES. Использую fallback: "
|
||||
f"'{fallback_name}' + '{city}' как район. Точность поиска ниже. "
|
||||
f"Для точного парсинга добавь '{city}' в config.CITIES (yandex_id с yandex.ru/maps)."
|
||||
)
|
||||
district = city # неизвестный город теперь в районе
|
||||
city = fallback_name
|
||||
city_cfg = fallback_cfg
|
||||
|
||||
yandex_id = city_cfg["yandex_id"]
|
||||
yandex_slug = city_cfg.get("yandex_slug", "moscow")
|
||||
region = city_cfg.get("region", city)
|
||||
|
||||
# Если указан район — добавляем его к поисковому запросу.
|
||||
# Яндекс ищет в зоне района ("кафе Митино" в geo_id=213 → кафе в Митино)
|
||||
search_query = f"{query} {district}" if district else query
|
||||
|
||||
url = f"https://yandex.ru/maps/{yandex_id}/{yandex_slug}/search/{search_query}/"
|
||||
region_label = f"{region}, район {district}" if district else region
|
||||
logger.info(f"\n→ Открываю: {url} (регион: {region_label})")
|
||||
driver.get(url)
|
||||
# Ждём появления карточек списка (адаптивно вместо фикс. sleep(5)).
|
||||
# Если капча/блок — карточек не будет, select вернёт None по таймауту,
|
||||
# и нижние проверки (bot_detected / пустой card_links) штатно остановят прогон.
|
||||
driver.select(SEL["card_in_list"], wait=12)
|
||||
|
||||
# Анти-бот проверки
|
||||
detected = driver.get_bot_detected_by()
|
||||
if detected:
|
||||
logger.error(f"⚠️ Бот-детектор: {detected}. Останавливаемся.")
|
||||
return []
|
||||
|
||||
if "showcaptcha" in driver.current_url or driver.select(".CheckboxCaptcha"):
|
||||
logger.error("⚠️ CAPTCHA. Останавливаемся.")
|
||||
return []
|
||||
|
||||
# 1. Собрать ссылки на детальные карточки
|
||||
card_links = _collect_card_links(driver, max_cards)
|
||||
if not card_links:
|
||||
logger.warning("Карточек не найдено")
|
||||
return []
|
||||
|
||||
# 2. По каждой ссылке — переход и парсинг.
|
||||
# Подменяем category на поисковый запрос Яна (то что он искал) —
|
||||
# для CRM удобнее видеть "кафе" вместо "Ресторан, бар", а у HH/WB
|
||||
# такая же логика (category = query) для консистентности.
|
||||
leads: list[dict] = []
|
||||
for idx, meta in enumerate(card_links, start=1):
|
||||
meta = {**meta, "category": query}
|
||||
logger.info(f"\n[{idx}/{len(card_links)}] {meta['href'][:80]}...")
|
||||
try:
|
||||
lead = _parse_card_detail(driver, meta, city=city, region=region, district=district)
|
||||
if lead:
|
||||
leads.append(lead)
|
||||
phones_str = ", ".join(lead["phones"]) if lead["phones"] else "—"
|
||||
logger.info(f" ✓ {lead['name']} | tel: {phones_str} | site: {lead.get('website') or '—'}")
|
||||
# Анти-бот пауза между карточками
|
||||
import random, time
|
||||
time.sleep(random.uniform(config.MIN_DELAY, config.MAX_DELAY))
|
||||
except Exception as e:
|
||||
logger.exception(f" Ошибка парсинга {meta['href']}: {e}")
|
||||
|
||||
logger.info(f"\n✅ Собрано лидов: {len(leads)} из {len(card_links)} карточек")
|
||||
return leads
|
||||
Reference in New Issue
Block a user