Вступление: «рынок найма сломан», и виноваты… мы (и они тоже)
Рынок найма IT-специалистов в России, кажется, реально «сломался» под натиском автоматизации. Соискатели массово вооружились нейросетями: автогенерация резюме, шаблонные сопроводительные письма и скрипты, которые пачками откликаются на вакансии. В ответ работодатели подкручивают фильтры, ATS и чат-ботов для первичного отбора — по сути, соискатели штурмуют рынок ИИ-откликами, а работодатели отбиваются ИИ-фильтрами. Флоу превращается в «битву двух ИИ», где люди — где-то рядом, иногда даже живые. (Habr)
Доходит до абсурда: HR пишет кандидату «Вы откликались на вакансию…», а кандидат отвечает «Это не я, это робот откликнулся». И вроде бы смешно, но рекрутеру — не всегда. (Сетка)
Решение hh.ru: с 15 декабря 2025 закрыли публичный API для соискателей. Старый добрый автоотклик через API (когда сервисы отправляли отклики «по кнопке» программно) — ВСЁ.
Теперь, чтобы автоматизация продолжала жить, приходится возвращаться в «ручной режим 2.0»: парсить HTML, эмулировать браузер и нажимать кнопки так, будто вы очень мотивированный человек с бесконечным терпением.
Немного личного контекста: что было раньше и что будет во 2 части
Я уже делал автоотклик через API:
собирал вакансии,
вытаскивал описание,
генерировал сопроводительное письмо на основе вакансии + резюме,
и отправлял отклик программно.
Но после закрытия API этот подход умер (ну или ушёл в «требует шаманства»). Во второй части я соберу тот же пайплайн (вакансия → LLM → письмо), но уже в новом стиле — через UI-автоматизацию Playwright, с обработкой попапов/тестов/прочих сюрпризов.
А эта статья — подробный гайд: как собрать автоотклик на hh через UI, что бы не умереть от выгорания.
Дисклеймеры (чтобы не превращать статью в “как получить бан за 3 минуты”)
Капча. Иногда HH показывает капчу. Её придётся решать руками. Обход капчи — плохая идея и обычно заканчивается грустью (если что, в инете можно найти способы обхода капчи на питоне).
Не надо долбить сайт сотнями откликов в минуту. Делайте паузы/лимиты.
Учитывайте правила площадки и здравый смысл: автоматизация ≠ спам.
Что именно мы автоматизируем
Мы работаем на странице выдачи вакансий и делаем так:
Логинимся (телефон + SMS).
Ищем вакансии по запросу.
Доскролливаем вниз, чтобы HH подгрузил все карточки.
Парсим карточки
[data-qa="vacancy-serp__vacancy"], печатаем план откликов.Для каждой вакансии:
нажимаем “Откликнуться” прямо в карточке (без открытия вакансии),
ловим snackbar «Отклик отправлен»,
если нас редиректнуло на “вопросы работодателя/тест” — возвращаемся назад и пропускаем,
если вылезла модалка с обязательным сопроводительным — закрываем и пропускаем,
если отклик не отправился — скрываем вакансию (чтобы не бесила дальше).
Установка
Python 3.10+
pip install playwright playwright install chromium
Полный скрипт (Playwright Sync)
Скрипт использует
data-qa(на HH это обычно самый стабильный вариант).
Если какие-то селекторы поплывут — править нужно в одном месте.
import re import time from dataclasses import dataclass from playwright.sync_api import Playwright, sync_playwright, TimeoutError as PlaywrightTimeoutError, expect # -------------------- МОДЕЛИ -------------------- @dataclass(frozen=True) class Vacancy: vacancy_id: str title: str watchers_text: str watchers_count: int | None def _parse_int(text: str) -> int | None: if not text: return None text = text.replace("\xa0", " ") m = re.search(r"(\d+)", text) return int(m.group(1)) if m else None # -------------------- SERP: ПРОГРУЗКА -------------------- def scroll_until_all_loaded(page, pause_ms: int = 900, max_scrolls: int = 50, stable_rounds_needed: int = 3) -> None: cards = page.locator('[data-qa="vacancy-serp__vacancy"]') stable = 0 prev = cards.count() print(f"Начинаю прогрузку скроллом. Сейчас карточек: {prev}") for i in range(1, max_scrolls + 1): page.evaluate("window.scrollTo(0, document.body.scrollHeight)") page.wait_for_timeout(pause_ms) page.wait_for_timeout(int(pause_ms * 0.6)) cur = cards.count() if cur > prev: print(f" Скролл {i}: +{cur - prev} (стало {cur})") prev = cur stable = 0 else: stable += 1 print(f" Скролл {i}: новых нет (стало {cur}), стабильность {stable}/{stable_rounds_needed}") if stable >= stable_rounds_needed: break print(f"Прогрузка завершена. Итого карточек: {prev}") # -------------------- SERP: ПАРСИНГ -------------------- def collect_vacancies_for_apply(page, limit: int = 10) -> list[Vacancy]: page.wait_for_selector('[data-qa="vacancy-serp__vacancy"]', timeout=30_000) cards = page.locator('[data-qa="vacancy-serp__vacancy"]') result: list[Vacancy] = [] for i in range(cards.count()): card = cards.nth(i) # есть кнопка "Откликнуться" в карточке? resp = card.locator('[data-qa="vacancy-serp__vacancy_response"]').first if resp.count() == 0: continue title = card.locator('[data-qa="serp-item__title-text"]').first.inner_text().strip() href = card.locator('a[data-qa="serp-item__title"]').first.get_attribute("href") or "" m = re.search(r"/vacancy/(\d+)", href) if not m: continue vacancy_id = m.group(1) watchers_loc = card.locator('span:has-text("Сейчас смотрят")').first watchers_text = watchers_loc.inner_text().strip() if watchers_loc.count() else "Сейчас смотрят —" watchers_count = _parse_int(watchers_text) result.append(Vacancy(vacancy_id=vacancy_id, title=title, watchers_text=watchers_text, watchers_count=watchers_count)) if len(result) >= limit: break return result def find_card_by_vacancy_id(page, vacancy_id: str): return page.locator( '[data-qa="vacancy-serp__vacancy"]', has=page.locator(f'a[data-qa="serp-item__title"][href*="/vacancy/{vacancy_id}"]'), ).first # -------------------- ТЕСТ/ВОПРОСЫ (РЕДИРЕКТ) -------------------- def is_test_page(page) -> bool: """ Детект "вопросов работодателя": - data-qa="title-container" - data-qa="title-description" содержит "Для отклика необходимо ответить..." """ container = page.locator('[data-qa="title-container"]').first if container.count() == 0: return False desc = page.locator('[data-qa="title-description"]:has-text("Для отклика необходимо ответить")').first return desc.count() > 0 def safe_go_back_to_serp(page, fallback_url: str) -> None: """ ВАЖНО: networkidle на HH часто не наступает, поэтому ждём выдачу селектором. """ try: page.go_back(wait_until="domcontentloaded") except Exception: page.goto(fallback_url, wait_until="domcontentloaded") # ждём возвращение выдачи page.wait_for_selector('[data-qa="vacancy-serp__vacancy"]', timeout=15_000) # -------------------- МОДАЛКА: ОБЯЗАТЕЛЬНОЕ СОПРОВОДИТЕЛЬНОЕ -------------------- def is_cover_letter_required_modal(page) -> bool: dlg = page.locator('[role="dialog"]').first if dlg.count() == 0: return False required_hint = dlg.locator('[data-qa="form-helper-description"]:has-text("Сопроводительное письмо обязательное")').first letter_input = dlg.locator('[data-qa="vacancy-response-popup-form-letter-input"]').first return required_hint.count() > 0 and letter_input.count() > 0 def close_response_modal_if_open(page) -> None: close_btn = page.locator('[data-qa="response-popup-close"]').first if close_btn.count(): close_btn.click() try: page.locator('[role="dialog"]').first.wait_for(state="hidden", timeout=5000) except Exception: pass # -------------------- СКРЫТИЕ ВАКАНСИИ -------------------- def hide_vacancy_card(page, card, *, timeout_ms: int = 5000) -> bool: """ 1) В карточке: button[data-qa="vacancy__blacklist-show-add"] 2) В меню: button[data-qa="vacancy__blacklist-menu-add-vacancy"] """ hide_icon = card.locator('button[data-qa="vacancy__blacklist-show-add"]').first if hide_icon.count() == 0: return False card.scroll_into_view_if_needed(timeout=timeout_ms) try: hide_icon.click(timeout=timeout_ms) except Exception: return False menu_item = page.locator('button[data-qa="vacancy__blacklist-menu-add-vacancy"]').first try: menu_item.wait_for(state="visible", timeout=timeout_ms) menu_item.click(timeout=timeout_ms) except Exception: return False # иногда карточка реально удаляется из DOM try: card.wait_for(state="detached", timeout=3000) except Exception: pass return True # -------------------- ОТКЛИК "В ОДИН КЛИК" -------------------- def click_apply_on_card(page, card, *, poll_timeout_sec: float = 6.0) -> str: """ Возвращаем: - sent - test_required - cover_letter_required - extra_steps - unknown """ original_url = page.url card.scroll_into_view_if_needed(timeout=10_000) apply_btn = card.locator('[data-qa="vacancy-serp__vacancy_response"]').first if apply_btn.count() == 0: return "no_apply_button" apply_btn.click() deadline = time.time() + poll_timeout_sec while time.time() < deadline: # 1) snackbar успеха if page.locator('#dialog-description:has-text("Отклик отправлен")').count(): return "sent" # 2) модалка с обязательным сопроводительным if is_cover_letter_required_modal(page): close_response_modal_if_open(page) return "cover_letter_required" # 3) редирект на доп.страницу (вопросы/тест) if page.url != original_url: if is_test_page(page): safe_go_back_to_serp(page, fallback_url=original_url) return "test_required" safe_go_back_to_serp(page, fallback_url=original_url) return "extra_steps" page.wait_for_timeout(200) return "unknown" # -------------------- MAIN -------------------- def run(playwright: Playwright) -> None: browser = playwright.chromium.launch(headless=False) context = browser.new_context() page = context.new_page() page.goto("https://hh.ru/", wait_until="domcontentloaded") # Логин по телефону/SMS page.get_by_role("link", name="Войти").click() page.get_by_role("button", name="Войти").click() page.get_by_role("textbox").nth(1).click() page.get_by_role("textbox").nth(1).fill(input("Введите номер телефона: ")) page.get_by_role("button", name="Дальше").click() page.get_by_role("textbox", name="Введите код").click() page.get_by_role("textbox", name="Введите код").fill(input("Введите код из смс: ")) # Поиск page.get_by_role("textbox", name="Профессия, должность или компания").click() page.get_by_role("textbox", name="Профессия, должность или компания").fill(input("Введите поиск вакансий: ")) page.get_by_role("button", name="Найти").click() expect(page.locator('[data-qa="vacancy-serp__vacancy"]').first).to_be_visible(timeout=30_000) # Полная прогрузка scroll_until_all_loaded(page) # План откликов vacancies = collect_vacancies_for_apply(page, limit=10) print("\nПлан отклика (только вакансии с кнопкой «Откликнуться»):") for idx, v in enumerate(vacancies, start=1): w = v.watchers_count if v.watchers_count is not None else "—" print(f"{idx:02d}. {v.title} | сейчас смотрят: {w} | vacancy_id={v.vacancy_id}") # Отклики for idx, v in enumerate(vacancies, start=1): w = v.watchers_count if v.watchers_count is not None else "—" print(f"\n[{idx}/{len(vacancies)}] Отклик на вакансию: {v.title}") print(f" Сейчас ее просматривает: {w}") card = find_card_by_vacancy_id(page, v.vacancy_id) if card.count() == 0: print(" ⚠️ Карточка не найдена (выдача могла обновиться). Пропускаю.") continue status = click_apply_on_card(page, card) if status == "sent": print(" ✅ Отклик отправлен.") continue # Иначе — скрываем вакансию (чтобы не маячила) card_again = find_card_by_vacancy_id(page, v.vacancy_id) if card_again.count() > 0: hidden = hide_vacancy_card(page, card_again) print(" 🫥 Вакансия скрыта." if hidden else " ⚠️ Не удалось скрыть вакансию.") else: print(" ⚠️ Карточку для скрытия не нашёл.") if status == "test_required": print(" 🧠 Требуется тест/вопросы работодателя — пропуск.") elif status == "cover_letter_required": print(" ✍️ Обязательное сопроводительное — пропуск.") elif status == "extra_steps": print(" ℹ️ Нужны доп.шаги — пропуск.") else: print(f" ❓ Статус: {status} — пропуск.") context.close() browser.close() if __name__ == "__main__": with sync_playwright() as p: run(p)
Как составлять поисковые запросы на hh
Главная идея: запрос должен быть достаточно широким, чтобы давать поток вакансий, и достаточно точным, чтобы не собирать мусор.
1) Сначала делаем словарь названий роли
Пример для QA-лида:
("QA Lead" OR "Lead QA" OR "Test Lead" OR "QA Team Lead" OR "Head of QA" OR "руководитель тестирования" OR "лид тестирования")
2) Потом добавляем якоря
То, без чего вы не хотите даже открывать вакансию:
(python OR pytest OR playwright OR selenium)
3) Потом уточняем
Слишком длинные простыни в запросе иногда дают неожиданные результаты (и тяжело дебажить).
Хороший паттерн:
(ROLE) AND (STACK) AND (DOMAIN) NOT (BLACKLIST)
Например:
(ROLE) AND (python OR pytest) AND (API OR REST OR swagger) NOT (стажер OR intern OR junior)
Что дальше (часть 2)
Во второй части:
будем вытаскивать текст вакансии/резюме,
генерировать сопроводительное письма (и не шаблон “Здравствуйте, я лучший…”),
и отправлять отклик через UI-модалку (включая обязательное сопроводительное),
плюс разберём “тест/вопросы работодателя” (пока мы честно сдаёмся и пропускаем).
P.S.
Если будут предложения по улучшению, пиши в комментариях ваши идеи и мнение:)
