Files
Aks f78f35fb3f init: Parser v1 — Lead Generation Engine
Парсер лидов МБ РФ: Яндекс.Карты + HH.ru + обогащение DaData/ЕГРЮЛ/Rusprofile + Streamlit CRM.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:56:06 +03:00

348 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
})