AI-агент-исследователь вакансий hh.ru. Основные функции:
- Сканирование вакансий в браузере Яндекс с версионностью описаний (Playwright + SQLite).
- 1.2. Проверка архивных вакансий — помечает ушедшие в архив, чтобы не рекомендовать закрытые позиции.
- Ранжирование относительно вашего резюме по 10-балльной шкале (LLM, OpenAI-совместимый API).
- Советы по улучшению резюме и навыков на основе результатов скрининга (LLM).
- Подборка вакансий для отклика — топ-N по баллу + бонусу за свежесть, с опциональной «тёмной лошадкой» от LLM. Сохраняет историю подборок и отметки об откликах (отклики делаются вручную в том же браузере).
- Синхронизация откликов и отказов с hh.ru: текущие статусы + журнал отказов по работодателям для cooldown.
Управление — через opencode с slash-командами и скиллами, CLI-скрипты, локальный веб-UI.
- Архитектура и принципы
- Что внутри
- Требования
- Установка
- Конфигурация
- Использование
- Интеграция с opencode
- Схема БД и версионность
- Часто задаваемые вопросы
- Повторяющиеся алгоритмы (сканирование, нормализация, диф, сохранение, выборки) написаны на Python.
- Творческие / исследовательские шаги (понимание соответствия, генерация советов) делегированы LLM через slash-команды .
- Браузер: установленный у вас Яндекс.Браузер запускается через Playwright
в отдельном профиле
~/.hh-agent/browser_profile/— не конфликтует с вашим обычным сёрфингом, отдельный Chromium качать не нужно. - Безопасность: пароль hh.ru вы вводите лично в окне браузера (агент его не видит и не хранит).
API-ключ LLM-провайдера берётся из переменной окружения (имя — в
config.yaml → llm.api_key_env, по умолчаниюYOUR_PROVIDER_API_KEY; добавьтеexport YOUR_PROVIDER_API_KEY=...в~/.zshrc).
┌──────────────┐ slash-команды ┌────────────────────────────┐
│ opencode │ ─────────────────────▶ │ CLI скрипты (Python) │
│ (LLM-агент)│ │ scan / rank / advise / UI │
└──────────────┘ └──────────┬─────────────────┘
│
┌──────────────────────┼─────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌───────────────────┐ ┌──────────────┐
│ Playwright + │ │ OpenAI-совмест. │ │ SQLite │
│ Яндекс.Браузер │ │ LLM (DeepSeek и │ │ (hh.db) │
│ (отд. профиль) │ │ др., через base_url)│ │ + версии │
└──────────────────┘ └───────────────────┘ └──────┬───────┘
│
FastAPI UI ◄──┘
(localhost:8765)
hh-agent/
├── README.md # этот файл
├── setup.sh # установка
├── requirements.txt
├── config.example.yaml # шаблон конфига
├── hh_agent/ # Python-пакет
│ ├── config.py # YAML + интерактивные секреты
│ ├── db.py # SQLite-схема и операции с версионностью
│ ├── scraper.py # Playwright + Яндекс.Браузер
│ ├── llm_client.py # OpenAI-совместимый клиент
│ ├── scoring.py # алгоритм 10-балльного ранжирования
│ ├── advisor.py # генерация советов по резюме
│ ├── recommender.py # Функция 4: отбор + freshness + llm_pick (учитывает cooldown отказов)
│ ├── delays.py # human-like log-normal задержки
│ └── notifier.py # macOS уведомления (osascript)
│ # scraper.py также реализует Функцию 1.2 (архив) и Функцию 5 (/applicant/negotiations)
├── scripts/ # CLI точки входа
│ ├── init_db.py
│ ├── scan_vacancies.py # Функция 1
│ ├── check_archived.py # Функция 1.2: проверка архивных вакансий
│ ├── rank_vacancies.py # Функция 2 (включая --uid для переоценки конкретной вакансии)
│ ├── advise_resume.py # Функция 3
│ ├── recommend.py # Функция 4: подборка на отклик (+ флаги --sync, --no-cooldown)
│ ├── sync_responses.py # Функция 5: синхронизация откликов и статусов с hh.ru
│ ├── apply_mark.py # отметка «откликнулся вручную»
│ ├── delete_vacancy.py # удаление вакансии из БД
│ ├── add_resume.py
│ └── serve_ui.py
├── ui/ # FastAPI + HTML/JS
│ ├── app.py
│ └── static/
│ ├── index.html
│ ├── styles.css
│ └── app.js
├── .opencode/ # конфигурация opencode
│ ├── command/ # slash-команды (/scan, /rank, /advise и др.)
│ └── skills/ # скиллы — инструкции для агента (на русском)
│ ├── func1_scan/
│ ├── func1_check_archived/
│ ├── func2_rank/
│ ├── func3_advise/
│ ├── func4_recommend/
│ ├── func4_llm_pick/
│ ├── func5_sync_responses/
│ ├── util_resume_add/
│ ├── util_open_ui/
│ ├── util_adhoc/
│ └── func_adhoc_skills_top/ # ad-hoc: топ востребованных навыков
- macOS (тестируется на Mac mini M4)
- conda (Miniconda/Anaconda)
- Python 3.11 (поставит conda)
- Яндекс.Браузер, установленный в
/Applications/Yandex.app(если у вас другой путь — пропишите вconfig.yaml→browser.executable_path) - Учётная запись на hh.ru (войти нужно лично, агент пароль не хранит)
- API-ключ OpenAI-совместимого LLM-провайдера для функций 2 и 3 (DeepSeek, VseGPT, ProxyAPI, OpenRouter, локальная Ollama — любой)
Проект устанавливается на чистый macOS из репозитория:
git clone <URL-репозитория> hh-agent
cd hh-agent
chmod +x setup.sh
./setup.shsetup.sh идемпотентен: создаст conda env hh-agent (Python 3.11), установит
зависимости, скопирует config.example.yaml → config.yaml (если того ещё нет),
проверит наличие Яндекс.Браузера и инициализирует пустую БД в ~/.hh-agent/hh.db.
Отдельный Chromium не качается — используется ваш установленный Яндекс.Браузер.
Подробная пошаговая инструкция (для новичков, с разбором каждого шага и типичных
ошибок) — в INSTALL.md. Минимум, что нужно поправить в config.yaml
после установки:
browser:
executable_path: "/Applications/Yandex.app/Contents/MacOS/Yandex"
headless: false # лучше false — увидите, как агент работает
hh:
search_query: "" # пусто = что предлагает hh; или впишите "python backend"
search_params: {} # доп. GET-параметры поиска; {} = ничего не добавлять
max_pages: 100
llm:
base_url: "https://api.aitunnel.ru/v1"
model: "deepseek-v4-flash" # или другая модель вашего провайдераКлюч LLM — в env-переменной (имя — из config.yaml → llm.api_key_env; добавьте export YOUR_PROVIDER_API_KEY=... в ~/.zshrc).
| Файл / каталог | Назначение |
|---|---|
config.yaml |
основной конфиг проекта (в репозитории — игнорируется git'ом) |
~/.hh-agent/hh.db |
SQLite-база со всеми данными |
~/.hh-agent/browser_profile/ |
persistent-профиль Яндекс.Браузера для агента |
YOUR_PROVIDER_API_KEY (env) |
API-ключ LLM (имя переменной — в config.yaml → llm.api_key_env) |
Все пути берутся из paths: секции config.yaml — можно поменять.
В config.yaml → scoring.weights. По умолчанию:
scoring:
weights:
hard_skills: 0.35
experience_and_role: 0.25
domain_knowledge: 0.15
soft_and_format: 0.10
conditions: 0.15
algorithm_version: "v1.0"Важно: если меняете веса или системный промпт скоринга — увеличьте algorithm_version
(например v1.1). Тогда старые оценки в БД останутся, но по algorithm_version вы
сможете отделить их от новых.
Все команды запускаются из корня проекта, в активированном env:
cd ~/projects/hh-agent
conda activate hh-agentПеред Функциями 2 и 3 нужно загрузить ваше резюме (любой .txt / .md):
python scripts/add_resume.py ~/Documents/resume.txt --title "Senior Python v2"Резюме сохраняется как новая версия и делается активным. Все старые версии остаются в БД.
python scripts/scan_vacancies.pyЧто произойдёт:
- Откроется окно Яндекс.Браузера с пустым профилем агента.
- Первый раз — попросит вас войти в hh.ru вручную в этом окне. Когда увидите свой кабинет — вернитесь в терминал и нажмите Enter. Cookies сохранятся, второй раз логиниться не нужно.
- Скрипт обойдёт страницы поиска (по умолчанию из
hh.search_query, можно переопределить--query "..."). - По каждой вакансии:
- извлечёт всю текстовую информацию;
- посчитает sha256 от нормализованного содержимого;
- сравнит с тем, что уже в БД:
- новая → запишет первую версию;
- изменилась → запишет новую версию (старые не трогает);
- без изменений → обновит только
last_seen_at.
- В конце — лог + macOS-уведомление.
Опции:
python scripts/scan_vacancies.py --query "data engineer"
python scripts/scan_vacancies.py --limit 20 # жёсткий потолок обработанных вакансий (быстрый прогон)
python scripts/scan_vacancies.py --recheck-after-days 7 # свежее N дней — не заходить в карточку
python scripts/scan_vacancies.py --consecutive-known-pages 3 # выход при N «скучных» страницах подряд
python scripts/scan_vacancies.py --captcha-wait-minutes 30 # сколько ждать решения капчиpython scripts/check_archived.pyПроходит по ранее собранным вакансиям и проверяет, не ушли ли они в архив.
Для каждой открывает её страницу в браузере и смотрит <title>: если там
«вакансия в архиве» — ставит is_archived = 1, чтобы закрытые позиции не
попадали в подборки Функции 4.
Опции:
python scripts/check_archived.py --older-than-days 14 # проверять вакансии старше N дней (по умолчанию 14)
python scripts/check_archived.py --limit 50 # потолок проверяемых за прогон (по умолчанию 50)Использует тот же профиль Яндекс.Браузера, что и Функции 1/5.
python scripts/rank_vacancies.pyЧто произойдёт:
- Перед запуском убедитесь, что в сессии выставлена env-переменная с ключом (имя — в
config.yaml → llm.api_key_env);base_urlи модель — изconfig.yaml → llm. - Возьмёт активное резюме.
- Для каждой вакансии, у которой нет оценки по её последней версии и по этому резюме:
- вызовет LLM с строгим JSON-промптом (см.
hh_agent/scoring.py); - получит баллы по 5 критериям + текстовые комментарии;
- пересчитает итог с весами из конфига (для согласованности);
- сохранит запись в
rankingsсalgorithm_version,modelи временем.
- вызовет LLM с строгим JSON-промптом (см.
Опции:
python scripts/rank_vacancies.py --all # переоценить всё (например после правки весов)
python scripts/rank_vacancies.py --uid 123456789 # переоценить конкретную вакансию по UID
python scripts/rank_vacancies.py --uid 123456789 --resume-id 3 # ... по конкретному резюме
python scripts/rank_vacancies.py --limit 20 # ограничить пачку для теста
python scripts/rank_vacancies.py --resume-id 3python scripts/advise_resume.py- Соберёт топ-20 лучших и топ-20 худших оценок по активному резюме.
- Скормит LLM с промптом из
hh_agent/advisor.py. - Получит структурированный JSON: что добавить в резюме, какие hard/soft-навыки прокачать, какие домены освоить — с привязкой к UID вакансий-доказательств.
- Сохранит
report_md+ полный JSON вadvice_reports(сresume_id, датой, моделью,skill_version).
Опции:
python scripts/advise_resume.py --top 30 --bottom 30
python scripts/advise_resume.py --resume-id 2Количество вакансий в подборке вы задаёте при каждом вызове:
# Топ-10 по баллу + бонусу за свежесть:
python scripts/recommend.py -n 10
# Топ-10 + 1 «тёмная лошадка» от LLM:
python scripts/recommend.py -n 10 --llm-pick
# С пометкой и выбором резюме:
python scripts/recommend.py -n 5 --notes "пятница, фокус на удалёнку" --resume-id 2Что происходит:
-
Скрипт берёт последние оценки из Функции 2 (по текущей версии каждой вакансии и выбранному резюме).
-
Исключаются вакансии, помеченные как «откликнулся вручную».
-
К базовому баллу добавляется бонус за свежесть:
final_score = score + freshness_bonus_max * exp(-age_days / freshness_halflife_days)(по умолчанию:
freshness_bonus_max = 1.5, период полураспада 7 дней). -
Отбираются топ-N и сохраняются в новую «партию» (
recommendation_batches,recommendation_items) с датой/временем — история неизменяема. -
При флаге
--llm-pickдополнительно LLM получает оставшиеся кандидаты (без тех, что уже в python_pick) и выбирает 1 вакансию‐«тёмную лошадку» с обоснованием в полеreason. Подробности — в.opencode/skills/func4_llm_pick/SKILL.md.
Сами отклики вы делаете вручную в Яндекс.Браузере на hh.ru. Чтобы вакансия перестала появляться в будущих подборках — отметьте это:
python scripts/apply_mark.py <vacancy_uid> --note "по совету LLM"
python scripts/apply_mark.py <vacancy_uid> --unmark
python scripts/apply_mark.py --listИли в UI: вкладка «Вакансии» → карточка вакансии → кнопка «Откликнулся».
recommend:
freshness_bonus_max: 1.5 # максимальная прибавка к баллу
freshness_halflife_days: 7 # период полураспада бонуса (дни)
min_score: 5 # слабые вакансии не предлагаемЗаходит на https://hh.ru/applicant/negotiations, собирает все ваши отклики
и их текущие статусы (приглашение, отказ, в работе, просмотрено, без ответа),
сохраняет в БД с историей переходов между статусами. Отдельно ведётся
журнал отказов по работодателям — он используется Функцией 4: компании,
которые отказали в последние responses.rejection_cooldown_days дней
(по умолчанию 270 = ~9 месяцев), исключаются из подборок Python-алгоритма.
LLM (--llm-pick) может в виде исключения предложить такую компанию,
но обязан явно пометить это в reason фразой «несмотря на прошлый отказ».
# Полная синхронизация (до responses.sync_max_pages страниц):
python scripts/sync_responses.py
# Ограничить число страниц (для быстрого прогона):
python scripts/sync_responses.py --pages 5
# Сначала синхронизировать, потом сразу сформировать подборку:
python scripts/recommend.py -n 10 --sync
python scripts/recommend.py -n 10 --sync --llm-pick
# Полностью отключить cooldown отказов (диагностика):
python scripts/recommend.py -n 10 --no-cooldownЧто происходит:
- Скрипт открывает Яндекс.Браузер с тем же профилем, что и Функции 1/2,
и листает страницы откликов с задержкой
responses.page_delay_ms. - По каждому отклику определяется статус по русским ключевым словам («приглаш», «отказ»/«не подош»/«к сожалению», «тестов»/«собеседов», «просмотр» и т. д.).
- Делается
UPSERTвresponsesпоvacancy_uid. Если статус изменился — вresponse_status_historyдобавляется строка. - Для статуса
rejectionдобавляется/обновляется запись вemployer_rejections(ключ — нормализованное имя работодателяvacancy_uid). Нормализация: lowercase, схлопывание пробелов, очистка кавычек/тире.
- В
runsзаписывается прогонfunction='sync_responses'со статистикой.
responses:
sync_max_pages: 20 # сколько страниц /applicant/negotiations листать
page_delay_ms: 1500 # задержка между страницами (анти-бан)
rejection_cooldown_days: 270 # сколько дней работодатель «на карантине»python scripts/serve_ui.pyОткройте http://localhost:8765. Доступно:
- Вакансии — таблица с фильтрами (поиск, работодатель, минимальный балл, выбор резюме).
Фильтр
min_scoreучитывает последнюю оценку (не лучшую из всех) — корректно работает с переоценками. Клик по строке → карточка с историей версий, всеми оценками и кнопкой «Откликнулся». - Резюме — список версий, активация одной кнопкой.
- Советы — отчёты Функции 3.
- Отклики — история подборок (факт + LLM) и список вакансий, на которые вы уже откликнулись вручную.
- Отклики на hh.ru — собранные Функцией 5 отклики с цветовыми бейджами статусов, фильтр по статусу, а также журнал отказов по работодателям для прозрачности cooldown.
- Журнал — последние запуски Функций 1/2/3/4/5.
Остановить: Ctrl+C.
Оркестратор агента — opencode. Всё поведение задаётся тремя компонентами:
AGENTS.md— системные правила (язык, архитектура, edit=ask, «один fork»)..opencode/command/*.md— slash-команды для запуска функций (/scan,/rank,/advise,/recommend,/sync,/db,/ui,/resume_add,/adhoc)..opencode/skills/— подробные инструкции в форматеSKILL.md, которые агент читает перед запуском.
В opencode.json выставлен edit=ask: любые правки файлов проходят через подтверждение.
Главные функции (1–5) запускаете вы явной командой — никаких авто-запусков и cron.
Примеры команд:
/scan --limit 20— быстрый прогон сбора (Функция 1)./rank --limit 5— оценить 5 вакансий (Функция 2)./advise --top 30 --bottom 30— советы по резюме (Функция 3)./recommend -n 10 --llm-pick— топ-10 на отклик + «тёмная лошадка» (Функция 4)./sync— синхронизация откликов и отказов с hh.ru (Функция 5)./db "SELECT ..."— быстрый SQL-запрос к БД./ui— поднять локальную панель./resume_add <путь> --title "..."— добавить новую версию резюме./adhoc <задача>— разовые исследования без записи в БД.
Помимо пяти основных функций агенту можно поручать разовые
исследовательские задачи — те, что не должны попадать в БД и журнал runs:
- «Найди вакансии по LLM-ops в Москве за неделю, топ-15 по моему резюме»
- «Посмотри отзывы про компанию X на dreamjob.ru»
- «Сравни моё резюме с топ-10 высокооценёнными вакансиями в БД»
- «Какие компании активно ищут Drools/Kogito? Сделай SQL-запрос к базе»
- «Возьми последние 50 отказов, найди общие признаки вакансий»
- «Покажи топ-20 самых востребованных навыков в вакансиях»
Для таких задач есть скилл-памятка .opencode/skills/util_adhoc/SKILL.md (вызывается через /adhoc).
Для анализа востребованных навыков — отдельный скилл .opencode/skills/func_adhoc_skills_top/SKILL.md:
топ-N навыков из vacancy_versions.key_skills с исключением обобщённых и объединением дубликатов.
Агенту доступны:
- БД SQLite —
~/.hh-agent/hh.db, читается черезsqlite3в shell. - REST API —
http://localhost:8765/api/...(все эндпоинты UI). - Яндекс.Браузер с залогиненным hh.ru в
~/.hh-agent/browser_profile/. - LLM с тем же
base_url/ключом, что и функции 2/3/4.
Главные правила (в скилле подробнее):
- Разовая задача — без запуска
funcN_*.py. Они пишут в БД новые версии/партии/записи вrunsи зашумят историю. - Сначала — данные из БД, потом браузер (1–2 страницы, без записи).
- Результат в чат; в БД не сохранять, если явно не просили.
- Источник каждого числа в ответе обязателен («из локальной БД», «свежий поиск hh.ru, страница 1»).
vacancies vacancy_versions
───────── ────────────────
uid PK id PK
url vacancy_uid FK → vacancies.uid
title version_num
employer_name content_hash
current_hash ← sha256(actual) title, employer_name, salary,
first_seen_at experience, employment,
last_seen_at schedule, area, description,
last_changed_at key_skills (JSON), raw_json,
versions_count captured_at
resumes rankings
─────── ────────
id PK id PK
version vacancy_uid FK
title vacancy_version_id FK
text resume_id FK
content_hash score 1..10
is_active breakdown_json JSON
created_at summary
notes model
algorithm_version
scored_at
recommendation_batches recommendation_items
────────────────────── ────────────────────
id PK id PK
created_at batch_id FK
resume_id FK vacancy_uid FK
requested_n vacancy_version_id FK
llm_pick_used (bool) ranking_id FK
notes rank_in_batch 1..N+1
stats_json source python|llm_pick
base_score из ranking
freshness_bonus bonus_max*exp(-age/halflife)
final_score base + bonus
reason текст (для llm_pick)
applications advice_reports runs
──────────── ────────────── ────
id PK id PK id PK
vacancy_uid UNIQUE resume_id FK function scan|rank|advise|recommend|sync_responses
resume_id FK (nullable) report_md status started|finished|failed
batch_id FK (nullable) payload_json JSON started_at
applied_at model finished_at
note skill_version stats_json
created_at error
sample_size
responses response_status_history employer_rejections
───────── ─────────────────────── ───────────────────
id PK id PK id PK
vacancy_uid UNIQUE response_id FK → responses.id employer_name (нормализовано)
employer_name status vacancy_uid
applied_at status_at rejected_at
current_status invitation|rejection captured_at UNIQUE(employer_name, vacancy_uid)
|viewed|in_progress
|no_response
last_status_at
last_message_text
last_seen_at
Ключевые принципы:
- Версионируем содержимое, не запись. В
vacanciesвсегда одна строка наuid; новые версии описания — вvacancy_versions. - Дифф по хешу. Сравнение нормализованного JSON через sha256 — быстро и стабильно.
- Оценки не перезаписываются. Каждый запуск Функции 2 пишет новые строки в
rankings. Можно сравнить эффект правок весов / промпта / резюме. - Резюме версионируется. Все правки сохраняются; «активное» — то, по которому идёт скрининг сейчас.
- Отклики и отказы версионируются. В
responsesодна строка наvacancy_uid; каждое изменение статуса фиксируется вresponse_status_history. Журнал отказов по работодателям (employer_rejections) используется для cooldown Функцией 4.
Проверьте путь:
ls -la /Applications/Yandex.app/Contents/MacOS/YandexЕсли нет — установите browser.yandex.ru или поправьте
config.yaml → browser.executable_path.
Это значит, что Playwright всё-таки пытается использовать свой Chromium вместо Яндекса.
Убедитесь, что в config.yaml указан корректный executable_path — скрипт его передаёт
в launch_persistent_context(executable_path=...).
- Увеличьте верхние границы в
hh.delays.between_pages.bandsиbetween_vacancies.bands. - Запускайте реже.
- Включите
browser.headless: false— на реальном окне капча решается легче. - Скрипт сам ждёт решения капчи до
hh.captcha_wait_minutesминут.
Проверьте, что ключ выставлен в текущей сессии:
echo $YOUR_PROVIDER_API_KEYЕсли пусто — добавьте в ~/.zshrc:
export YOUR_PROVIDER_API_KEY="sk-..."и откройте новый терминал (или source ~/.zshrc). base_url и модель — из config.yaml → llm.
python scripts/rank_vacancies.py --allЕсли меняли веса в config.yaml — обязательно увеличьте algorithm_version,
чтобы старые и новые оценки можно было отличить.
rm -rf ~/.hh-agent/
python scripts/init_db.py(Останется только config.yaml в корне проекта.)
В config.yaml:
llm:
base_url: "http://localhost:11434/v1"
model: "qwen2.5:9b"Ключ можно ввести любой не пустой (Ollama его не проверяет). Качество ранжирования будет заметно хуже, чем у DeepSeek/Claude/GPT, особенно на длинных описаниях — учтите при интерпретации баллов.
Проект распространяется по лицензии MIT — см. файл LICENSE. Можно свободно использовать, изменять и распространять при сохранении текста лицензии и указания авторства.