f78f35fb3f
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
348 lines
16 KiB
Python
348 lines
16 KiB
Python
"""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,
|
||
})
|