Ты всё ещё кликаешь по вакансиям пальцем? Серьёзно?
Проснулся, кофе, 20 вакансий, однотипное сопроводительное письмо, отклик, день прошёл, тишина. Знакомо? Поиск работы — это адская бюрократия, где выигрывает не самый умный, а самый быстрый. Или тот, кто умеет автоматизировать.
В этой статье я покажу, как собрать бесплатного ИИ-агента, который будет делать всё за тебя: мониторить новые вакансии, генерировать персонализированные сопроводительные письма под каждую компанию и откликаться без твоего участия. Всё на локальном железе, без платных API и риска утечки данных. Разберём код до последней запятой на реальном стеке: asyncio + Playwright + Ollama + Aiogram.
Предупреждение: HH.ru не любит ботов. Использовать этот агент стоит с умом — ставить задержки, рандомизировать поведение и не долбить сервер сотнями запросов в минуту. Один аккаунт живёт дольше, если вести себя как человек.
Почему агент, а не простой скрипт?
Можно взять requests, накидать парсер, захардкодить письмо. Но HH.ru за последние пару лет научился отлавливать таких. Они смотрят на поведение: как быстро ты заполняешь форму, как двигаешь мышь, какие заголовки шлёшь. Им нужен эмулятор человека.
Playwright (не путать с Puppeteer) — это браузер без головы, который может двигать мышкой с человеческой скоростью, скроллить, ждать случайное время. А Ollama с моделью Llama 4 Instruct (или, если железа мало, Qwen 2.5 7B) даёт осмысленный текст, который не палится одинаковыми фразами. Добавим сюда Telegram-бота на Aiogram 3 — управлять агентом можно будет с телефона: запустить поиск, остановить, посмотреть статистику.
И да, всё это бесплатно. Ни копейки за API, только электричество и желание разобраться.
Что будем строить: архитектура за 30 секунд
| Компонент | Назначение |
|---|---|
| Telegram-бот (Aiogram) | Получает команды от пользователя, отдаёт статус, управляет циклом |
| Brain-модуль (Ollama) | Генерирует сопроводительное письмо под каждую вакансию на основе резюме |
| Hunter-модуль (Playwright) | Авторизуется на HH.ru, парсит страницу поиска, заполняет форму отклика |
| Orchestrator (asyncio) | Координирует всё, ставит задачи в очередь, логирует ошибки |
Настройка окружения: с нуля за 10 минут
Предполагается, что у тебя Python 3.13+ и pip. Если нет — качаем с офсайта. Всё остальное ставится одной командой.
1 Установка зависимостей
pip install playwright aiogram ollama python-dotenv loguru
playwright install chromium
2 Установка Ollama и модели
Качаем Ollama с официального сайта (Windows, macOS, Linux — всё работает). После установки:
ollama pull llama4:7b-instruct-q4_K_M
Почему Llama 4 7B? Она достаточно легкая для домашнего ПК (4 ГБ VRAM или 8 RAM), но при этом даёт связный русский текст. Если есть 12+ ГБ — бери llama4:70b-instruct. Если совсем туго — qwen2.5:7b или gemma3:7b. Про локальные модели мы писали в статье «Российский локальный AI-агент: сборка с нуля без облака, VPN и подписок» — там подробно про выбор и оптимизацию.
Важно: Ollama должна быть запущена как сервис. По умолчанию после установки она уже висит на localhost:11434. Проверь: curl http://localhost:11434/api/tags
Пишем код: ядро агента
Создаём файл config.py — всё чувствительное (логин, пароль HH, токен бота) храним в .env. Никаких хардкодов.
3 Конфиг и .env
# config.py
from dotenv import load_dotenv
import os
load_dotenv()
HH_LOGIN = os.getenv('HH_LOGIN')
HH_PASSWORD = os.getenv('HH_PASSWORD')
BOT_TOKEN = os.getenv('BOT_TOKEN')
OLLAMA_URL = os.getenv('OLLAMA_URL', 'http://localhost:11434')
RESUME_TEXT = os.getenv('RESUME_TEXT', 'Я DevOps-инженер с 5-летним опытом...') # твоё резюме одной строкой
SEARCH_QUERY = os.getenv('SEARCH_QUERY', 'DevOps')
.env файл:
HH_LOGIN=your_email@example.com
HH_PASSWORD=your_secret_password
BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
RESUME_TEXT=Опыт работы 5 лет в DevOps, CI/CD, Docker, Kubernetes...
SEARCH_QUERY=DevOps
4 Модуль генерации писем (brain.py)
Тут мы дёргаем Ollama через клиентскую библиотеку. Промпт — ключевой момент. Если дать плохой промпт — получишь кашу. Об этом мы писали в «Agent Skills: как заставить ИИ-агента не тупить и помнить все инструкции». Наш промпт строится так: контекст (твоё резюме и текст вакансии), инструкция (написать письмо на 2 абзаца, выделить подходящие навыки), формат (только текст письма).
# brain.py
from ollama import Client
class ResumeGenerator:
def __init__(self, ollama_url: str):
self.client = Client(host=ollama_url)
self.model = 'llama4:7b-instruct-q4_K_M'
async def generate_cover_letter(self, vacancy_text: str, resume: str) -> str:
prompt = f"""Ты — профессиональный карьерный консультант. Напиши персонализированное сопроводительное письмо для отклика на вакансию.
Вот резюме кандидата: {resume}
Вот описание вакансии: {vacancy_text}
Письмо должно быть:
- На русском языке
- Не длиннее 3 абзацев
- Выделять релевантные навыки из резюме под конкретную вакансию
- Заканчиваться фразой о готовности к собеседованию
Напиши только текст письма, без лишних фраз."""
response = self.client.chat(model=self.model, messages=[{'role': 'user', 'content': prompt}])
return response['message']['content'].strip()
5 Модуль поиска и отклика (hunter.py)
Самый жирный кусок. Playwright открывает страницу HH, логинится, выполняет поиск, собирает ссылки на вакансии, а потом для каждой открывает форму отклика и вставляет сгенерированное письмо.
Критически важный момент: имитация поведения человека. Между действиями — случайные задержки (1-3 секунды), движения мыши, иногда прокрутка страницы. HH использует поведенческую аналитику (следят за перемещениями курсора).
# hunter.py
import asyncio
from playwright.async_api import async_playwright, Page
class HHHunter:
def __init__(self, login: str, password: str, search_query: str):
self.login = login
self.password = password
self.search_query = search_query
self.browser = None
self.page = None
async def __aenter__(self):
self.playwright = await async_playwright().start()
self.browser = await self.playwright.chromium.launch(
headless=True,
args=['--disable-blink-features=AutomationControlled']
)
self.context = await self.browser.new_context(
viewport={'width': 1280, 'height': 720},
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...'
)
self.page = await self.context.new_page()
await self._login()
return self
async def __aexit__(self, *args):
await self.browser.close()
await self.playwright.stop()
async def _login(self):
await self.page.goto('https://hh.ru/account/login', wait_until='networkidle')
# Ждём поле логина и вводим
await self.page.fill('input[name="login"]', self.login)
await asyncio.sleep(1)
await self.page.fill('input[type="password"]', self.password)
await self.page.click('button[data-qa="account-login-submit"]')
await self.page.wait_for_url('**/account/me*', timeout=30000)
print('Авторизация прошла успешно')
async def collect_vacancies(self) -> list[dict]:
"""Ищем вакансии и возвращаем список ссылок и текстов"""
await self.page.goto(f'https://hh.ru/search/vacancy?text={self.search_query}', wait_until='networkidle')
await asyncio.sleep(2)
links = await self.page.query_selector_all('a.bloko-link[data-qa="serp-item__title"]')
vacancies = []
for link in links[:10]: # берём первые 10
url = await link.get_attribute('href')
title = await link.inner_text()
# Собираем описание вакансии (открываем в соседней вкладке? Нет, лучше в этой)
# Для простоты — переходим по ссылке, ждём, читаем описание
await self.page.goto(url, wait_until='networkidle')
desc = await self.page.inner_text('div[data-qa="vacancy-description"]')
vacancies.append({'url': url, 'title': title, 'description': desc[:2000]})
await self.page.go_back()
await asyncio.sleep(1)
return vacancies
async def apply_to_vacancy(self, vacancy_url: str, cover_letter: str):
"""Открывает страницу вакансии и отправляет отклик"""
await self.page.goto(vacancy_url, wait_until='networkidle')
await asyncio.sleep(1)
# Кнопка отклика (селектор может меняться, проверяй вручную)
await self.page.click('button[data-qa="vacancy-response-link-top"]')
await self.page.wait_for_selector('textarea[data-qa="vacancy-response-popup-form-letter"]', timeout=10000)
await self.page.fill('textarea[data-qa="vacancy-response-popup-form-letter"]', cover_letter)
# Человеческая задержка перед отправкой
await asyncio.sleep(2)
await self.page.click('button[data-qa="vacancy-response-submit"]')
print(f'Отклик отправлен на {vacancy_url}')
await asyncio.sleep(5) # ждём между откликами
6 Telegram-бот (bot.py)
На Aiogram 3. Бот принимает команды /start_search и /stop, а также /status. Старт поиска запускает фоновую задачу. Полный код с управлением стейтом и очередью — в нашем репозитории (ссылка в конце).
# bot.py
import asyncio
from aiogram import Bot, Dispatcher, types
from aiogram.filters import Command
bot = Bot(token=config.BOT_TOKEN)
dp = Dispatcher()
@dp.message(Command('start_search'))
async def cmd_start(msg: types.Message):
# Запускаем цикл охоты в фоновом режиме
asyncio.create_task(run_hunting_cycle(msg.from_user.id))
await msg.answer('Охота началась! Буду присылать отчёты.')
@dp.message(Command('stop'))
async def cmd_stop(msg: types.Message):
# Останавливаем глобальный флаг
global is_running
is_running = False
await msg.answer('Остановлено.')
async def run_hunting_cycle(chat_id):
"""Главный цикл: сбор вакансий -> генерация -> отклик"""
async with HHHunter(config.HH_LOGIN, config.HH_PASSWORD, config.SEARCH_QUERY) as hunter:
brain = ResumeGenerator(config.OLLAMA_URL)
while is_running:
vacancies = await hunter.collect_vacancies()
for v in vacancies:
cover = await brain.generate_cover_letter(v['description'], config.RESUME_TEXT)
await hunter.apply_to_vacancy(v['url'], cover)
await bot.send_message(chat_id, f'Откликнулся на {v['title']}')
await asyncio.sleep(3600) # пауза час между раундами
Подводные камни и как их обходить
Капча на HH.ru
Иногда HH просит ввести капчу после нескольких действий. Решений два: либо ставить очень большие задержки (5-10 секунд между откликами, рандомные паузы 3-7 секунд), либо использовать прокси и менять их. Капчу можно решать через сервисы распознавания типа 2captcha, но это уже не бесплатно. Лучше минимизировать частоту: запускать агента раз в 2-3 часа и не на все вакансии подряд.
Селекторы ломаются
HH постоянно обновляет интерфейс. data-qa атрибуты могут меняться. Чтобы не переписывать код каждый месяц, используй fallback-селекторы: например, ищем кнопку по тексту «Откликнуться». А ещё лучше — в начале каждого запуска сохранять скриншот страницы и логировать, смог ли кликнуть.
Одинаковые письма
Если модель генерирует одно и то же из-за маленького контекста — добавь в промпт случайную «персонализацию»: упоминать название компании из вакансии. Для этого нужно доставать название компании при парсинге и вставлять в промпт. Это повышает качество и уменьшает подозрения.
Блокировка аккаунта
HH может временно заблокировать аккаунт при подозрительной активности. Всегда используй headless=False для первой отладки, смотри что происходит. Поставь лимит — не более 5 откликов в сутки для теста. Постепенно увеличивай.
Бонус: запуск 24/7
Очевидно, что держать домашний ПК включённым 24/7 — так себе идея. Арендуй дешёвый VPS за 300-500 рублей в месяц. Тебе подойдёт конфигурация 2 vCPU, 4 GB RAM, 30 GB SSD. Установи туда систему, разверни Ollama (можно через Docker) и запусти бота. Для модели 7B этого хватит. Если хочешь 70B — нужно 32 GB RAM, бюджетный VPS не потянет. Используй Timeweb или Selectel — у них есть тарифы с GPU, но для 7B хватит и CPU на современном железе.
А что по этике?
Ты автоматизируешь отклики — это нарушение условий пользования HH.ru. Будь готов, что аккаунт могут забанить. Используй для личных целей, не перепродавай услугу. А ещё лучше — сначала найди работу честно, а потом автоматизируй что-то другое. Но если ты дейли сталкиваешься с 100 вакансиями и хочешь ускориться — этот инструмент сэкономит тебе дни.
Кстати, чтобы защитить себя от злонамеренных агентов, почитай статью «Как агенты ИИ взламывают сами себя: разбор кейса Alibaba ROME» — там про honeypot для таких ботов.
Полный код и следующий шаг
Весь проект с Dockerfile, requirements.txt и готовой структурой лежит на GitHub (ссылка в моём профиле). Но собрать по статье — тоже вариант, чтобы понять каждую строчку. Если хочешь углубиться в тему AI-агентов для бизнеса — советую курс от Kaggle, бесплатный, 5 дней: «Бесплатный курс-бестселлер».
И последний совет: не делай агента слишком умным. Чем проще логика — тем меньше багов. Запусти на паре вакансий, посмотри логи, почини селекторы — и только потом отпускай в свободное плавание. Удачи на собеседованиях (теперь они точно будут).