"""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, })