- Регистрация
- 23 Август 2023
- Сообщения
- 3 600
- Лучшие ответы
- 0
- Реакции
- 0
- Баллы
- 243
Offline
Вступление: «рынок найма сломан», и виноваты… мы (и они тоже)
Рынок найма 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 "лид тестирования")
То, без чего вы не хотите даже открывать вакансию:
(python OR pytest OR playwright OR selenium)
Слишком длинные простыни в запросе иногда дают неожиданные результаты (и тяжело дебажить).
Хороший паттерн:
(ROLE) AND (STACK) AND (DOMAIN) NOT (BLACKLIST)
Например:
(ROLE) AND (python OR pytest) AND (API OR REST OR swagger) NOT (стажер OR intern OR junior)
Во второй части:
будем вытаскивать текст вакансии/резюме,
генерировать сопроводительное письма (и не шаблон “Здравствуйте, я лучший…”),
и отправлять отклик через UI-модалку (включая обязательное сопроводительное),
плюс разберём “тест/вопросы работодателя” (пока мы честно сдаёмся и пропускаем).
Если будут предложения по улучшению, пиши в комментариях ваши идеи и мнение