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
+347
View File
@@ -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,
})