ESPN DeportesCuando las palabras pesan, golpean y ripostanThe Jerusalem PostBen-Gvir’s gallows betray the Jewish values at the heart of Zionism - opinionPunch2027: Osun APC rallies electorate for candidates, says current hardship temporaryESPN'She never stopped encouraging me': How Dawn Staley refused to let hearing loss hinder Ayla McDowellRTP DesportoCampeão FC Porto vence Sporting por 80-76 e conquista Supertaça de basquetebolVilaWebJoan Dausà fon en una sola cançó les històries de “Jo mai mai” i de “Judit”Collider‘The Twilight Zone’s Creepiest Episode Is Still an Unforgettable 25 MinutesSRF NewsWM-Strassenrennen der Frauen – Reusser verpasst Bronze bei Triumph von Vollering knappGuardian SportEngland v Spain: Nations League football – liveThe GuardianStrictly Come Dancing: week one – liveThe South AfricanThe Daily Lotto results for Saturday, 26 September 2026BBC SportLynn remaining positive despite Wales' USA setback
The Daily Newsstand · Free, Always
Saturday, September 26, 2026

Не зовут на собеседования? Написал бота, который увеличит твои шансы на это

Translate

Сейчас у большинства, кто ищет работу, проблема даже не пройти собес, а вообще до него дойти. Откликаешься, в ответ тишина или автоотказ. А hh в это время продаёт подписку hh PRO: ИИ-сопроводительные (первое бесплатно, дальше только по подписке), автоподнятие резюме, подсветку. 279 ₽ в неделю или 799 ₽ в месяц, и продлевается сама, пока не отключишь.

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

А пока про то, как бот мне врал. Заработал он быстро, в логе всё зелёное, [OK] на каждой строке. А потом открываю диалог с работодателем и вижу, что кнопка «Напомнить об отклике» не нажата. В логе при этом написано, что нажата. Ну и понеслось. Навыки в резюме «добавлены», а их там нет. «Резюме подходит вакансиям на 65,5%», а это просто константы в коде. «Причина отказа: опыт и грейд», а работодатель такого вообще не писал, это бот сам придумал.

Коротко, если лень читать:

  • бот бесплатно делает то, за что hh просит подписку: ИИ-сопроводительные и поднятие резюме, плюс сам откликается до 200 раз в день

  • один отклик через браузер это около 17 секунд, и 200 в день упираются в лимит hh, а не в бота

  • JS-клик на hh молча не срабатывает, работает только ActionChains

  • [OK] в лог сразу после клика без проверки страницы, нашёл такое в четырёх местах

  • if a in b отправлял работодателю «Не готов» вместо «Готов»

  • 80% причин отказов в базе бот придумал сам

hh PRO против бота

Что входит в подписку, я взял со страницы hh PRO, цены на 24 сентября 2026:

hh PRO

Бот

ИИ-сопроводительные

Первое бесплатно, дальше без лимита по подписке

Без лимита: бесплатные Gemini и Groq, дальше Claude и ChatGPT, если они у вас есть. Кончилось всё, пишет по шаблону

Поднятие резюме

Автоматически 5 раз в день

Бесплатное поднятие раз в 4 часа, бот жмёт его сам по расписанию

Отклики

Руками

Сам, до 200 в день (лимит самой платформы)

Анкеты работодателей

Руками

Отвечает ИИ, вопросы про оформление, полиграф и судимость только из настроек

Разбор отказов

Нет

Читает отказы в мессенджере и архиве, пишет, что поправить в резюме

Цена

279 ₽ в неделю, 799 ₽ в месяц, 1 499 ₽ за 3 месяца, автопродление

0 ₽

Подсветки резюме, статистики по конкурентам и «Хочу здесь работать» у бота нет. Если вам это нужно, это к hh. Всё остальное бесплатно.

Почему браузер, а не API

Первое, что пробуешь, это официальный API. Отклики через него сторонним приложениям закрыты: POST /negotiations отдаёт 403 Forbidden, свои резюме тоже не получить. Есть утилиты, которые всё равно откликаются через API: берут ключи из Android-приложения hh и притворяются им. Я так не делаю, ключи чужие, а банить будут ваш аккаунт.

Так что у меня гибрид. Вакансии ищу через API, где он пускает, и через обычную выдачу на сайте, потому что API отдаёт далеко не все. Первый же сбор по выдаче «вакансии для вашего резюме» дал 833 вакансии, которых в кеше из API не было. Состояние страницы поиска hh кладёт в <template id="HH-Lux-InitialState">, там даже флаг userTestPresent есть, это значит у вакансии анкета.

Откликается Selenium с моим профилем Chrome, залогинился один раз руками и всё. Письма и ответы на анкеты пишет ИИ, кончился ИИ, значит шаблон. Всё, что отправлено, лежит в SQLite. Можно держать несколько аккаунтов hh, у каждого своя сессия и история, и гонять бота по расписанию на сервере в Docker, а войти в hh там через браузер в noVNC. По желанию бот ещё дублирует письмо на почту работодателя из вакансии, не больше 30 писем в сутки.

Один прогон такой: разбор новых отказов, поднятие резюме, если прошло 4 часа, потом отклики, пока не наберётся 200.

А быстрее можно?

Это первое, что я спросил, когда запустил. Замерил по логу живого прогона, 63 отклика за 18 минут:

Этап

Медиана

Письмо от модели и открытие вакансии

6–9 с

Ввод письма

~5 с

Отправка и подтверждение

7–9 с

Пауза между вакансиями

1–2 с

200 откликов это час. Антибан-пауза тут 1–2 секунды, всё остальное это ждём сам hh и React. Ускорять смысла нет, раньше упрёшься в дневной лимит hh.

Письмо печатается посимвольно, отсюда 5 секунд. Вставить через JS было бы быстрее, но React в форме hh такую вставку не видит: поле вроде заполнено, а отклик уходит без письма. Проверял, письма терялись.

Кнопка нажата, а она не нажата

Интерфейс hh собран на React-компонентах их дизайн-системы Magritte. Кликнуть из Selenium можно тремя способами, работает один:

Способ

Что происходит

driver.execute_script("arguments[0].click()", el)

Обработчик React не срабатывает. Ошибки нет, клик просто теряется

el.click()

ElementClickInterceptedException: сверху оверлей

ActionChains(driver).move_to_element(el).click()

Работает

Хуже всех первый, он молчит. Замерил на своём аккаунте:

  • выбор навыка из списка: JS-клик 0 из 3, ActionChains 3 из 3

  • уровни владения навыками: JS-клик 11 из 30, ActionChains 30 из 30

  • кнопка мессенджера в шапке: от JS-клика виджет не открывается вообще

Enter в выпадающих списках hh вариант тоже не выбирает.

Но сам JS-клик это полбеды. Вот что стояло после него:

driver.execute_script("arguments[0].click()", remind_button)
logger.info("[+] Нажата кнопка «Напомнить об отклике»")
return True

Кликнул, написал в лог, что нажато, вернул True. Посмотреть, изменилось ли что-то на странице, никто не додумался. Сейчас так:

def _press_remind(self, el, context_name: str) -> bool:
    ActionChains(self.driver).move_to_element(el).pause(0.15).click().perform()
    time.sleep(1.5)
    if self._find_remind_buttons():          # кнопка всё ещё на месте
        logger.warning(f"Кнопка «Напомнить об отклике» не сработала: {context_name}")
        return False
    logger.info(f"[+] Нажата кнопка «Напомнить об отклике»: {context_name}")
    return True

Потом такое же нашлось ещё в трёх местах. Навыки в резюме бот «добавлял» кнопкой «Добавить», которой на странице нет, то есть за всё время не добавил ни одного. Проверка блока «О себе» искала недостающие слова в пустом поле, ничего не находила и писала «уже оптимизирован». Поднятие резюме писало [OK] Резюме успешно поднято сразу после клика. Сейчас поднятие засчитывается, только если hh убрал кнопку и написал время следующего, типа «Можно сегодня в 17:46». Это время бот и берёт в расписание.

Правило теперь одно на весь код: нажал, перечитай страницу и найди, что поменялось. Не нашёл, значит WARNING, а не OK.

Зачем всё сбросил

Однажды открываю резюме, а там из навыков снова 6. Было 6, бот добавил 26, стало 32, а у hh лимит 30 навыков. Больше 30 форма не сохраняет и молча откатывается к последнему сохранённому, то есть всё добавленное за проход пропадает.

Причём из 10 навыков, которые бот тогда пихал, 6 в резюме уже были, просто написаны по-другому: аббревиатура вместо полного названия, «A/B» вместо «A / B», по-русски вместо английского. Бот сравнивал строки как есть. Сейчас сравнивает после нормализации.

И сразу вопрос: а у других людей навыков может быть заполнено сколько угодно, это бот учтёт? Раньше нет. Теперь свободные места он считает по живой форме перед каждым раундом, а если места нет, чужие навыки сам не удаляет, пишет, что надо освободить.

if a in b

Этот оператор стоял почти везде, где бот сравнивал текст. Вот что он наделал:

Искали

Нашлось внутри

Что случилось

«готов»

«не готов»

Работодателю ушёл ответ, противоположный выбранному моделью

«да»

«данные», «дата»

Бот сам ставил галочку «Передать мои данные партнёрам»

«Go»

«Google Cloud», «договор»

Лишние галочки в анкетах; навык «Go» «подтверждался» словом «договор»

«ник»

«сотрудников», «источник»

На вопрос про число сотрудников уходил ник в мессенджере

«тг»

«отгулы»

То же

«cto»

«director»

Фильтр по грейду резал директоров как CTO

«руководитель»

«руководителя»

Стоп-слово в именительном падеже не ловило родительный

Первая строка самая жёсткая. В анкете варианты шли «Не готов / Готов», цикл шёл сверху и брал первое совпадение. Модель выбирает «Готов», а в форму уходит «Не готов».

Теперь вариант ищется в два прохода:

@staticmethod
def match_option_index(wanted, labels):
    w = ' '.join(str(wanted or '').split()).lower().strip()
    if not w:
        return None
    lowered = [' '.join(str(l or '').split()).lower().strip() for l in labels]

    # 1. Точное совпадение по ВСЕМ вариантам, иначе короткий
    #    отрицательный вариант, стоящий первым, перехватит ответ.
    for idx, lbl in enumerate(lowered):
        if lbl and lbl == w:
            return idx

    if len(w) < 3:
        return None

    # 2. Подстрока только по границам слов и без отрицаний.
    for idx, lbl in enumerate(lowered):
        if not lbl or HHSeleniumBot._is_negation_of(lbl, w):
            continue
        longer, shorter = (lbl, w) if len(lbl) >= len(w) else (w, lbl)
        if len(shorter) < 4:
            continue
        if re.search(r'(?<!\w)' + re.escape(shorter) + r'(?!\w)', longer):
            return idx
    return None

А если не нашлось ничего, бот раньше брал «первый содержательный вариант». На вопрос «Привлекались ли вы к уголовной ответственности?» он так отвечал «Да». Теперь такую вакансию просто пропускает.

В популярных утилитах для hh так сделано специально: на незнакомый вопрос по умолчанию «Да», а нет «Да», значит средний вариант. Мне такой отклик не нужен. Лучше пропустить вакансию, чем ответить «Да» про судимость.

Статистика, которую бот придумал сам

Я хотел, чтобы по отказам было видно, что править в резюме, и в итоге 8 из 10 откликов проходили ATS-фильтры. Бот складывает разобранные отказы в базу, и на экране статистики долго висело «Опыт и грейд: 62,9%». Я почти поверил, что мне стажа не хватает.

Посчитал таблицу rejections. Из 245 строк причина со слов работодателя есть в 48, это 19,6%. Остальные 197 это пять шаблонных фраз, которые бот подставлял, когда ИИ не ответил: «Автоматический скрининг…» (81), «Автоматический отсев по ключевым словам (ATS)…» (48), «Высокая конкуренция…» (44), «Недостаточный стаж или несоответствие грейду» (23) и ещё одна.

То есть 62,9% целиком из этих фраз. А в настоящих отказах чаще всего жалуются на сам отклик: пустой, без письма, ответ одним словом, одно сообщение два раза. Про стаж там почти ничего.

Есть мнение, что сопроводительные никто не читает, и в популярных утилитах их советуют не писать, а слать шаблон. По моим отказам выходит наоборот: в 22 переписках из 34, где работодатель реально объяснил отказ, претензия была к самому отклику, и чаще всего к тому, что он пустой, без письма.

Теперь у каждой причины есть пометка evidence_based, проценты считаются только по подтверждённым, догадки идут отдельной строкой. Всё с «вероятно» и «скорее всего» тоже догадка:

def is_generic_reason(text) -> bool:
    flat = ' '.join(str(text or '').split()).lower().rstrip('.')
    if not flat or flat in GENERIC_REASONS:
        return True
    hedges = ('вероятно', 'скорее всего', 'возможно', 'похоже',
              'предположительно', 'судя по всему', 'не исключено')
    return any(h in flat for h in hedges)

Рядом висело ещё «Резюме подходило вакансиям на 65,5%». 235 значений из 245 оказались константами прямо из кода: 70, 85, 0 и 100. У 47 из 48 настоящих отказов стояло ровно 70. Убрал вместе с константами.

Письмо кандидата, которое написал рекрутер

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

После чистки настоящее письмо нашлось в 22 диалогах из 63. В остальных были подписи типа «HR …», системные строки hh вроде «ИИ-помощник присоединился к чату» и контакты.

А потом я перегнул в другую сторону, и письмо осталось в 0 диалогах из 36. Первой репликой hh ставит системную строку, в ней неразрывный пробел U+00A0, и сравнение с шаблоном её не ловило. После исправления стало 5 из 36, и это похоже на правду, остальные и правда ушли без письма. Так что ужесточил фильтр, проверь, что он не отрезал лишнего.

Правка резюме по догадкам

С правкой резюме была та же беда. Старая автоправка брала навыки из базы отказов, то есть в том числе из тех самых догадок, проставляла уровни владения вслепую и дописывала в «О себе» абзац, зашитый прямо в код, один и тот же для всех, кто запустит бота. Теперь в резюме уходят только навыки, которые есть в профиле, «О себе» и уровни бот сам не трогает, а всё остальное показывает как совет. Профиль при этом заполняется из резюме на hh одной кнопкой, руками ничего вбивать не надо.

Откликаться на всё

Был соблазн не тратить отклики на вакансии не по грейду, и я написал фильтр. Потом смотрю в лог, а он режет «замначальника отдела», «замдиректора», «техдиректора». Оказалось, в настройках стаж стоял раза в два меньше реального: его когда-то вбили руками и забыли. Фильтр верил настройке и молча выкидывал нормальные вакансии.

Фильтр я выключил и откликаюсь теперь на всё, даже на топ-менеджмент. Отклик ничего не стоит, а откажут, так анализ отказов потом скажет почему. С одного такого отказа ИИ выдал разбор, мол с вашим опытом смотрите позиции уровня Lead, и это полезнее, чем если бы фильтр выкинул вакансию и ничего не сказал.

ИИ за ноль рублей

Письма, ответы на анкеты и разбор отказов пишет ИИ. Платить за API я не хотел, поэтому очередь такая: бесплатные Gemini и Groq, потом Claude Code и Codex CLI, если у вас уже есть подписка, потом любой OpenAI-совместимый сервис, в конце шаблон. Подписку Claude я и сам использую для работы, так что бесплатные идут первыми. Gemini только 3.1 и новее, старые 2.x я не использую.

У Gemini бесплатно 20 запросов в сутки на каждую модель, а не на ключ. Восемь живых моделей в цепочке дают около 160 запросов. Первыми стоят алиасы вроде gemini-flash-latest, потому что конкретные версии со временем выводят из обращения и они начинают отдавать 404.

429

Бывает суточный и минутный. Суточный (...PerDay...) держит до полуночи, ждать его бесполезно, а бот ждал: 45 отказов, 45 минут пауз впустую.

С минутным хитрее. Бот делал три попытки с паузами 20, 40 и 60 секунд, и так на каждое письмо. На 540 вакансиях это 18 часов. И паузы не помогали, минутное окно общее на ключ, следующее письмо упиралось в тот же лимит.

Сейчас провайдер после минутного 429 отдыхает столько, сколько сам попросил, а бот не ждёт и пишет письма шаблоном:

@staticmethod
def retry_after_seconds(error_text: str) -> float:
    text = str(error_text or '')
    # Gemini: «retry_delay { seconds: 23 }», Groq: «try again in 7.66s»
    match = (re.search(r'retry[_-]?delay\s*\{\s*seconds:\s*(\d+)()', text, re.I)
             or re.search(r'(?:try again in|retry after|retry[_-]?delay["\':\s]+)'
                          r'\s*(\d+(?:\.\d+)?)\s*(ms|m|s)?', text, re.I))
    if not match:
        return 60.0
    value = float(match.group(1))
    unit = (match.group(2) or 's').lower()
    if unit == 'ms':
        value /= 1000.0
    elif unit == 'm':
        value *= 60.0
    return max(1.0, min(value, 300.0))

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

Ещё одно ожидание сидело в клиенте Groq: там стоял max_retries=1, и библиотека сама засыпала до 23 секунд прямо внутри вызова. Поставил 0, повторы считаю сам.

Таймаут, который ничего не ограничивает

Однажды прогон завис на 57 минут. Процесс живой, лог не растёт.

Сначала нашлось простое: таймаут из настроек передавался в клиенты OpenAI и Groq, а в generate_content() у Gemini нет. Поправил, и в тот же день прогон опять встал на первом же письме. py-spy dump --pid <pid> показал где:

retry_target (api_core/retry/retry_unary.py:167)
generate_content (generativeai/generative_models.py:331)
_retry_on_other_gemini_model (ai_assistant.py:496)

Это собственные повторы библиотеки. У google.api_core.retry.Retry свой срок, по умолчанию 120 секунд, и request_options={'timeout': N} на него не влияет. На восьми моделях это 16 минут на одно письмо. Помогло только выключить их повторы явно:

def gemini_request_options(self) -> dict:
    seconds = getattr(self, 'timeout', 15)
    options = {'timeout': seconds}
    try:
        from google.api_core import retry as _retry
        options['retry'] = _retry.Retry(predicate=lambda exc: False,
                                        timeout=seconds)
    except Exception:
        pass
    return options

Снаружи оба зависания выглядят одинаково: процесс живой, лог пустой. Отличить можно только по стеку, так что на любое «зависло» я теперь сначала запускаю py-spy dump.

Был и третий вариант, вообще без ИИ. Ночью пропала сеть, прогон встал на «Составляю письмо…» и больше ни строчки. Письмо пишется в фоне, а бот ждал его без всякого предела. Теперь ждёт 4 минуты и берёт шаблон. Если сеть отвалилась прямо на отклике, бот ждёт её до 10 минут и повторяет эту вакансию один раз.

А ИИ у нас вообще работает?

Этот вопрос я задал, когда узнал, что Gemini всё это время упирался в лимиты, а отчёты по отказам при этом были заполнены. Ошибки Gemini писались в debug, разбор тихо переключался на шаблонные фразы, и отчёт выглядел полным, хотя модель не ответила ни разу. А потом оказалось, что debug вообще никуда не пишется: логгер стоял на INFO и отсекал отладку раньше, чем она доходила до файла. Все «подробности записаны в журнал» по проекту были враньём.

Теперь в логе видно, кто конкретно пишет письмо, не общее «ИИ», а модель:

[ИИ: gemini-flash-latest]   основная модель
[ИИ: openai/gpt-oss-120b]   переехали на Groq
[ИИ: шаблон]                квоты кончились

Модель прислала объявление вместо письма

Одна модель через OpenAI-совместимый сервис вернула обычным ответом «Gemini 3.5 Flash is no longer available. Please switch to Gemini 3.7 Flash…». Бот честно отправил это в чаты двум работодателям и вписал в анкету третьему. Ошибки же не было, ответ пришёл.

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

def letter_quality_problem(text: str, company: str = '', title: str = '') -> Optional[str]:
    t = (text or '').strip()
    if len(t) < 200:
        return 'слишком короткое'
    if len(t) > 2500:
        return 'слишком длинное'
    if re.search(r'\*\*|^\s*#|^\s*[-*•]\s', t, re.M):
        return 'разметка вместо обычного текста'
    letters = [ch for ch in t.lower() if ch.isalpha()]
    cyr = sum('а' <= ch <= 'я' or ch == 'ё' for ch in letters)
    if letters and cyr / len(letters) < 0.6:
        return 'написано не по-русски'
    low = t.lower()
    words = [w for w in re.findall(r'[a-zа-яё0-9]+', f'{company} {title}'.lower())
             if len(w) >= 4 and w not in _LEGAL_FORMS]
    if words and not any(w[:max(4, len(w) - 2)] in low for w in words):
        return 'не упоминает ни компанию, ни должность'
    if re.search(r'\d+\s*%', t):
        return 'проценты, которых нет в профиле'
    return None

Ещё при запуске бот спрашивает каждый ИИ, жив ли он, и ждёт в ответ «готов». Кто не ответил, уходит в конец очереди. А в режиме «Авто» очередь сама сортируется по скорости и доле удачных ответов. Для примера: Claude через CLI пишет письмо за 6–13 секунд, Codex за 14–31. И у Claude CLI нельзя ставить флаг --bare, с ним он не видит вход по подписке и отвечает «Not logged in».

Шаблонов было пять, и все начинались одной фразой, так что сотни работодателей получали одно и то же слово в слово. Спинтаксис уложился в десять строк:

def rand_text(template: str) -> str:
    """«{Здравствуйте|Добрый день}» -> один из вариантов, вложенность поддерживается."""
    text = str(template or '')
    pattern = re.compile(r'\{([^{}]*)\}')     # самые внутренние скобки
    for _ in range(20):                       # предохранитель от битого шаблона
        m = pattern.search(text)
        if not m:
            break
        options = m.group(1).split('|')
        text = text[:m.start()] + random.choice(options) + text[m.end():]
    return text

Анкеты работодателей

Если у вакансии анкета, hh открывает отклик не во всплывающем окне, а отдельной страницей. Вопросов там бывает до 50: текст, радиокнопки, списки, галочки. ИИ вообще правильно на них отвечает? Проверил, и нет.

Сопроводительное письмо стояло в поле «На какую зарплату вы сейчас ориентируетесь?». Из-за одной строки:

is_textarea_fallback = (
    element.tag_name.lower() == 'textarea'
    and 'сопровод' in self.get_visible_page_text()   # слово где угодно на странице
)

Любое большое текстовое поле считалось полем для письма, если где угодно на странице было слово «сопроводительное». Теперь бот сначала собирает поля внутри вопросов и их не трогает, а если свободных полей несколько, не выбирает ни одно.

Спрашивать модель по одному вопросу нельзя, большая анкета съест всю дневную квоту. Вся анкета уходит одним запросом вместе с вариантами ответа, и модель должна вернуть вариант слово в слово. Контакты, зарплату и стаж бот берёт из настроек и модель про них не спрашивает.

zip(questions, answers) не сверял длины. Модель пропустила один вопрос, и все ответы после него съехали: ответ про переезд оказался в поле про судимость. Теперь если длины не совпали, весь пакет в мусор.

Фильтр отписок знал две фразы, и «Извините, я не могу ответить на этот вопрос» ушло бы работодателю. Расширил. Нет ответа на вопрос, значит нет и отклика.

Бот отвечал «Да» на «Готовы работать без оформления по ТК?» и «Согласны пройти полиграф?». На такие темы (оформление, серая зарплата, полиграф, судимость, переработки) теперь только ответ из настроек, а нет его там, вакансия пропускается.

А вот на вопросы про условия, типа офис, переезд, работа через ИП, бот отвечает «да», мы же массово подаёмся, обсудить можно потом. Но факты о себе, город и гражданство, он не врёт. Зарплату цифрой не называет нигде, ни в анкете, ни в чате. Если в форме отклика вообще нет поля для письма, бот дописывает письмо в чат после отклика.

Бот уговаривает бота рекрутера

Часть «отказов» отказами не была. ИИ-ассистент рекрутера задавал вопрос в чате, никто не отвечал, и отклик закрывался. Мой бот записывал это в отказы и начинал разбирать причину.

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

Первые живые ответы пришлось поправить. На вопрос про зарплату бот назвал цифру. Нельзя, кто первый назвал, тот сам себе поставил потолок. Теперь он отвечает типа «ориентируюсь на рыночный уровень, какая у вас вилка?». Ещё он написал рекрутеру, что я живу в городе, где я не живу, потому что в профиле был старый город.

А ещё бот меня занижал. На вопрос про одну технологию ответил «работал с ней меньше, но быстро разберусь», хотя она есть у меня в резюме на текущем месте работы. В профиле в настройках её просто не было. Сравнил:

Профиль в настройках

Резюме на hh

Навыков

13

51

Мест работы

0

3

Сертификатов

0

4

«О себе»

245 символов

938 символов

Перенёс всё из резюме, и на тот же вопрос пришло «имею практический опыт». Правда, не сразу: новые поля надо было подключить в два промпта, а я сначала подключил в один.

С выдуманными цифрами та же история. В примерах писем модель сочиняла «провёл 150 аудитов» и «2 млн пользователей». Запрет я сначала поставил в промпт ответов в чат, а эти письма шли из промпта разбора отказов. Перенёс туда, но только в поле письма, и на следующем прогоне модель предложила переписать «О себе» под Go, которого у меня в резюме нет. Сейчас запрет стоит во всех четырёх полях ответа.

Мелочи, которые съели часы

Зачем он прокручивает страницу вниз? Бот прокручивал страницу вакансии до конца, а внизу подгружаются карточки других вакансий. У архивной среди них написано «Вакансия в архиве». Проверка искала эту фразу по всей странице и отменяла отклик на живую вакансию. Повторить руками не получалось, потому что без прокрутки фразы на странице нет. Теперь фраза ищется только вне карточек:

def page_says_archived(self) -> bool:
    return True is self.driver.execute_script(r"""
        const re = /вакансия\s+(в\s+архиве|перемещена\s+в\s+архив)/i;
        for (const el of document.querySelectorAll('body *')) {
            if (el.children.length) continue;
            if (!re.test(el.textContent || '')) continue;
            if (el.closest('[data-qa^="vacancy-serp"]')) continue;
            if (el.offsetParent === null) continue;
            return true;
        }
        return false;
    """)

Резюме, которое hh прячет на несколько минут. Отклики пачками по 7–15 минут не отправлялись: «кнопка отправки недоступна». В форме при этом написано поменять видимость резюме на «Видно всем работодателям», хотя она и так такая. Через несколько минут та же вакансия отклик принимает. Одна пачка началась сразу после поднятия резюме, другая вообще без повода. Почему так, я не знаю. Бот видит эту фразу до печати письма, оставляет вакансию в очереди и после двух таких подряд ждёт 5 минут.

Текст этой формы я вытащил через DevTools-порт самого бота. Профиль Chrome занят, второй браузер с ним не откроешь, а номер порта лежит в chrome_profile/DevToolsActivePort. Дальше /json, webSocketDebuggerUrl и Runtime.evaluate, только на чтение.

Английский интерфейс. Однажды hh переключился на английский. Язык хранится в куках браузера, а не в аккаунте, а селекторы у меня по русским надписям: 8 откликов из 12 ушли без письма, 3 не ушли совсем. Теперь бот на старте смотрит document.documentElement.lang и сам возвращает русский.

Мессенджер. hh.ru/applicant/chats отдаёт 404, мессенджер живёт на hh.ru/chat. Кнопки «назад к списку» там нет. Старый код её нажимал, ничего не проверял и работал только потому, что список диалогов и так не исчезает. Два селектора карточек находили 13 и 16 диалогов, код брал первый непустой и терял три.

Непрочитанные. Чтобы сбить счётчик непрочитанных, бот открывал все диалоги подряд, и приглашения переставали быть непрочитанными. А их как раз нельзя пропустить. Теперь открывает только отказы.

Тесты писали в боевую базу

В тестах анализатор отказов создавал базу по пути по умолчанию и через INSERT OR REPLACE заливал выдуманные примеры в мою настоящую hh_data.db. Конструктор бота вызывал load_config(), а тот всегда вызывал save_config(), и каждый прогон pytest перезаписывал мои настройки. Тест поднятия резюме однажды записал боевое расписание, и живой прогон резюме не поднял, решил, что уже поднимал.

Закрыл одной фикстурой в conftest.py, она уводит базу, настройки и логи во временную папку. И до и после pytest сверяю md5 боевых файлов.

Оговорки

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

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

Бот: Python 3.10+, Selenium, SQLite, есть Docker. Работает на Windows, Linux и macOS. Ключи ИИ необязательны, без них письма собираются из шаблонов. Запуск:

git clone https://github.com/madnessbrainsbl/hh-auto-apply.git
cd hh-auto-apply
pip install -r requirements.txt
python hh.py menu
View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.