Вступление: «рынок найма сломан», и виноваты… мы (и они тоже)

Рынок найма IT-специалистов в России, кажется, реально «сломался» под натиском автоматизации. Соискатели массово вооружились нейросетями: автогенерация резюме, шаблонные сопроводительные письма и скрипты, которые пачками откликаются на вакансии. В ответ работодатели подкручивают фильтры, ATS и чат-ботов для первичного отбора — по сути, соискатели штурмуют рынок ИИ-откликами, а работодатели отбиваются ИИ-фильтрами. Флоу превращается в «битву двух ИИ», где люди — где-то рядом, иногда даже живые. (Habr)

Доходит до абсурда: HR пишет кандидату «Вы откликались на вакансию…», а кандидат отвечает «Это не я, это робот откликнулся». И вроде бы смешно, но рекрутеру — не всегда. (Сетка)

Решение hh.ru: с 15 декабря 2025 закрыли публичный API для соискателей. Старый добрый автоотклик через API (когда сервисы отправляли отклики «по кнопке» программно) — ВСЁ.
Теперь, чтобы автоматизация продолжала жить, приходится возвращаться в «ручной режим 2.0»: парсить HTML, эмулировать браузер и нажимать кнопки так, будто вы очень мотивированный человек с бесконечным терпением.

Немного личного контекста: что было раньше и что будет во 2 части

Я уже делал автоотклик через API:

  • собирал вакансии,

  • вытаскивал описание,

  • генерировал сопроводительное письмо на основе вакансии + резюме,

  • и отправлял отклик программно.

Но после закрытия API этот подход умер (ну или ушёл в «требует шаманства»). Во второй части я соберу тот же пайплайн (вакансия → LLM → письмо), но уже в новом стиле — через UI-автоматизацию Playwright, с обработкой попапов/тестов/прочих сюрпризов.

А эта статья — подробный гайд: как собрать автоотклик на hh через UI, что бы не умереть от выгорания.

Дисклеймеры (чтобы не превращать статью в “как получить бан за 3 минуты”)

  1. Капча. Иногда HH показывает капчу. Её придётся решать руками. Обход капчи — плохая идея и обычно заканчивается грустью (если что, в инете можно найти способы обхода капчи на питоне).

  2. Не надо долбить сайт сотнями откликов в минуту. Делайте паузы/лимиты.

  3. Учитывайте правила площадки и здравый смысл: автоматизация ≠ спам.

Что именно мы автоматизируем

Мы работаем на странице выдачи вакансий и делаем так:

  1. Логинимся (телефон + SMS).

  2. Ищем вакансии по запросу.

  3. Доскролливаем вниз, чтобы HH подгрузил все карточки.

  4. Парсим карточки [data-qa="vacancy-serp__vacancy"], печатаем план откликов.

  5. Для каждой вакансии:

  6. нажимаем “Откликнуться” прямо в карточке (без открытия вакансии),

  7. ловим snackbar «Отклик отправлен»,

  8. если нас редиректнуло на “вопросы работодателя/тест” — возвращаемся назад и пропускаем,

  9. если вылезла модалка с обязательным сопроводительным — закрываем и пропускаем,

  10. если отклик не отправился — скрываем вакансию (чтобы не бесила дальше).

Установка

  • 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.

Если будут предложения по улучшению, пиши в комментариях ваши идеи и мнение:)

Комментарии (27)


  1. kutuzenok
    30.12.2025 00:03

    А я за это платила :))


    1. M_AJ
      30.12.2025 00:03

      Прикол том, что у работодателя есть галочка "игнорировать автоотклики", то есть платные автоотклики — откровенный развод на деньги.


  1. sic
    30.12.2025 00:03

    А защиту от scriptkiddies в код вносили, вносили же, да?)


    1. kbones Автор
      30.12.2025 00:03

      Неа, но это не финальная версия так-то. Можно потом добавить паузы и джиттеры, сохранение состояний и прочее. Пока что это простой скрипт который чета куда-то тыкает и вроде бы полезно. Больше как задумка)))


      1. sic
        30.12.2025 00:03

        Вот мы и достигли непонимания целой культуры, вообще защита от scriptkiddies это не о заботе о потенциальных пользователях, все с точностью до наоборот, - это намеренные баги, которые в лучшем случае роняют скрипт в определенных условиях, а в худших то, что приводит, например к бану на сервисе. И это раньше считалось правильно, потому, что человек, близкий к области может (скажем так, имеет шанс) разобраться, исправить, улучшить, и тем самым заслуживает право на использование. А вот остальные пусть лучше кнопки тыркают по старинке. А еще лучше - не тыркают.


        1. kenomimi
          30.12.2025 00:03

          Это не "культура", это терминальная илитка головного мозга и вредительство уровня "мы заложим мину в этот дешевый автомобиль, ну если покупатель автомеханик и сапер, он найдет ее легко, остальные же по нашему мнению грязные гои, и путь взрываются к чертям".

          Либо уж не выкладываешь ничего в паблик, если считаешь это неприемлимым, либо выкладываешь без вишмасера.


          1. sic
            30.12.2025 00:03

            Было бы все так просто, вот просто бы и было тогда. Аналогия, считаю, неуместная совсем.

            Когда культуры ИБ еще не было почти никакой, PoC эксплойты выкладывали, чтобы привлечь внимание к проблеме, и цель в этом положительная, - так проблемы иногда решались. Но когда любой школьник может скачать готовый бинарник эксплойта и устроить ад соседям, добра в этом ноль, а морали глубокой от школьников ожидать как бы ну рановато. Не выкладываем - проблема вообще не решается. Выкладываем в красивой упаковке с кнопочкой Do It, так проблема временно обостряется, появляются реальные пострадавшие в большом количестве.

            Здесь ситуация очень близка. С одной стороны автор абсолютно верно обнажает проблему, молча с этим сидеть никакой пользы не создает. Есть пост, есть доказательство в виде программы, - есть некая надежда, что проблему решать будут. Окей. Но здесь же высок риск, что готовым инструментом воспользуются те люди, которым в принципе пофиг на какие-то высокие морали, и раз уж на работу не берут, то чего бы не задудосить этот ваш hh к такой-то женщине. А вот это временно еще больше обостряет проблему, что людям которые в этот момент осознанно ищут работу, пробиться становится еще сложнее. Хотя, ну куда уж сложнее-то. Если "настоящие" автоотклики можно просто фильтром убрать, то такие автоотклики поди попробуй отличи от ручных.

            Будут многие пользоваться скриптом, - ну так введут, в худшем случае, какие-нибудь теневые баны на уровне, что если больше 12 откликов в день, то отклики такого человека еще пол года вообще не показываем. В лучшем - капчу на любой отклик.

            Но это мое видение ситуации.


  1. Griggon
    30.12.2025 00:03

    Что боярам можно, кривозубой челяди не позволено?

    Прикольно, пускай тогда и у работодателей забирают такую функцию, потому что соискатели использовали автоматизацию как средство против нейроХрюшек - теперь будут вынуждены сами с роботом общаться. Куда катимся..


    1. Arhammon
      30.12.2025 00:03

      Ну вообще очень странно, что на ХХ нет премиум подписки с собственным автооткликом...


      1. Cordekk
        30.12.2025 00:03

        вроде же была


        1. kbones Автор
          30.12.2025 00:03

          А он по 5 откликов в день отправляет


  1. redfoxxy12
    30.12.2025 00:03

    Капча. Иногда HH показывает капчу. Её придётся решать руками.

    Её можно решать чужими руками :) На соответствующих сервисах решение тысячи капч обойдется вам примерно в 40р. (если капча - ребус или иная смарт - капча, то несколько дороже).


    1. kbones Автор
      30.12.2025 00:03

      Да это понятно. Я так и написал, что решения этому есть, но я не добавлял. Капча только на авторизации появляется и то, раз в 10-15 запусков


  1. VanilDuck
    30.12.2025 00:03

    Парсингу сайту по HTML уже сто лет в обед, да и способы обхода каптчи и защиты (useragent, проверка реальности браузера и тд) уже давно придумали. Правда в проде обычно Puppeteer используют вроде.


    1. redfoxxy12
      30.12.2025 00:03

      useragent, проверка реальности браузера

      Подменять юзерагент вообще как угодно можно, а если уж там какие-то совсем хитросделанные защиты, то тогда вы реальный браузер и автоматизируйте сразу! Selenium называется :)


      1. VanilDuck
        30.12.2025 00:03

        Selenium это в целом альтернатива Puppeteer. И насколько я помню, даже они не панацея в 100% случаях, банально та же проверка на наличие видеокарты или headful режима у браузера.


        1. redfoxxy12
          30.12.2025 00:03

          Погодите, какая проверка на наличие видеокарты? Там реально запускается инстанс браузера, полностью (ну почти) аналогичное обычному. И это "почти" тоже можно исправить.


          1. VanilDuck
            30.12.2025 00:03

            Есть способы защиты вебсайтов, которые проверяют наличие у конечного пользователя оборудования, способного рендерить WebGL и подобный ему контент. Если инстанс запускается не на локальном компьютере, а на удаленном headless сервере, то даже настоящий браузер не всегда может пройти тесты. И даже симуляция наличия полноценного экрана для рендера контента страницы (например с помощью Xvfb) не всегда помогает обойти эти тесты.

            Чтобы проверить возможность парсера пройти все проверки обычно используются сервисы по типу browserleaks.com и https://scrapfly.io/web-scraping-tools/browser-fingerprint

            Я бы написал на Хабре полноценную статью на эту тему, но мне кажется меня за это уволят)


  1. Hlad
    30.12.2025 00:03

    Мде. Play stupid games, win stupid prizes. Найм IT-шников через ХХ практически мёртв, но вместо того, чтобы это признать, и начать искать другие способы поиска работы, люди начинают пытаться гальванизировать умирающего. Тем самым, по сути, добивая его.


    1. AdrianoVisoccini
      30.12.2025 00:03

      начать искать другие способы поиска работы

      по тому что на хабре все ещё работу найти можно, а "начав искать другие способы" можно повторить судьбу тех, кто в свое время решил "начать искать" новый континент и не вернулся, как вам такая мысль?


      1. Hlad
        30.12.2025 00:03

        Понимаете, вот то, что описано, в популярной культуре называется трагедией общинных земель. Когда каждый решает, что он самый хитрый, и в результате гробит общий ресурс, в результате чего и сам его лишается. И все радостно плюсуют автора за то, что он описывает, как убивает систему.


        1. kenomimi
          30.12.2025 00:03

          Эта система уже сто лет как труп, сшитый из кусков других трупов, и оживленный некромантом. Так что автор ничего плохого не делает: нельзя убить то, что уже мертво.


          1. Hlad
            30.12.2025 00:03

            Вы о системе найма в целом, или о ХХ в частности? Потому что ХХ пару лет назад вполне себе помогал успешно найти работу в около-ИТ.


            1. kbones Автор
              30.12.2025 00:03

              Если честно, все полученные офферы в айти я получал только с хх. А где вы ищите?


              1. Hlad
                30.12.2025 00:03

                Профильные телеграм-чаты с вакансиями. Реально работает.


  1. mlapkin
    30.12.2025 00:03

    тоже похожее решение доделываю, у некоторых вакансия ежедневно появляется одна и та жа как бы новая, и в итоге каждый день лепятся отказы на одну и ту же вакансию, вот как это отсортировать или оставить как есть


    1. kbones Автор
      30.12.2025 00:03

      Банально просто скрой вакансии от этой компании, что бы в поиске у тебя не показывались