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:
+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,
|
||||
})
|
||||
Reference in New Issue
Block a user