Вступление: «рынок найма сломан», и виноваты… мы (и они тоже)
Рынок найма 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.
Если будут предложения по улучшению, пиши в комментариях ваши идеи и мнение:)
