Skip to main content
Glama

hh-radar

ci python license

Своя база вакансий hh.ru, которую LLM-агент опрашивает как набор инструментов. Полнотекстовый поиск на PostgreSQL, поиск по смыслу на pgvector, семь MCP-инструментов и честный замер того, где поиск ошибается.

📊 Витрина — открывается за десять секунд, все цифры посчитаны программой из базы. Сейчас на ней демонстрационный корпус: вакансии hh нельзя ни выложить в публичный репозиторий, ни скачать без токена приложения, поэтому страница собрана из сгенерированных данных и говорит об этом первым же абзацем.


Зачем это написано

Я искал работу через hh.ru и делал руками одно и то же: открыть выдачу, прочитать тридцать описаний, выписать, какие навыки повторяются, прикинуть, куда вообще есть смысл писать. Поиск на hh отвечает на «есть ли слово в тексте». На вопросы, которые возникали у меня — «где чаще всего просят n8n и сколько за это платят», «какие вакансии возьмут человека без коммерческого опыта», «где нужно чинить чужие сломанные автоматизации» — он не отвечает вообще.

Поэтому вакансии складываются в свою базу, а поверх базы стоит MCP-сервер. Дальше вопрос задаётся обычными словами прямо в Claude Desktop, а агент сам решает, чем его обслужить: полнотекстовым поиском, агрегацией по навыкам, срезом зарплат или поиском по смыслу.

Ты: какие навыки чаще всего требуют в вакансиях по автоматизации за последний месяц?

Claude вызывает skill_stats(query="автоматизация", published_within_days=30)
→ Python — 61% вакансий, медиана 140 000 ₽
  n8n — 34%, медиана 150 000 ₽
  Docker — 29%, ...

Цифры в примере — иллюстрация формата ответа. Настоящие лежат на витрине и меняются с каждым сбором.

Related MCP server: HuntFlow MCP Server

Что внутри

Слой

Что делает

hh/

Клиент API hh.ru: OAuth-токен приложения, ограничение частоты, ретраи, обход потолка выдачи

ingest/

Идемпотентная запись в базу: UPSERT, двухфазная загрузка карточек

db/

Четыре сущности плюс связь многие-ко-многим, полнотекстовый индекс, слой запросов

rag/

Чанкинг, эмбеддинги, поиск по смыслу на pgvector, гибридный поиск, оценка качества

mcp_server/

Семь инструментов для LLM-агента поверх всего перечисленного

showcase/

Генератор статической витрины для GitHub Pages

Инструменты MCP

Инструмент

Отвечает на вопрос

search_vacancies

«Найди вакансии со словами X, удалённо, от 150 тысяч»

semantic_search

«Найди вакансии, где надо чинить чужие сломанные автоматизации»

get_vacancy

«Покажи эту вакансию целиком»

skill_stats

«Какие навыки требуют чаще всего и сколько за них платят»

market_overview

«Какая вилка на рынке, сколько удалёнки, кто больше всех нанимает»

compare_to_profile

«Мне сюда стоит писать?» — с честным ответом «нет, и вот почему»

db_status

«Что вообще есть в базе и за какой период» — чтобы агент не выдумывал

Быстрый старт

Нужны Docker и Python 3.12+.

git clone https://github.com/Denmurzik/hh-radar
cd hh-radar

uv sync --extra rag          # или: pip install -e ".[rag]"
docker compose up -d db      # PostgreSQL 17 + pgvector на порту 5433
uv run alembic upgrade head  # схема
uv run hh-radar seed         # 52 демонстрационные вакансии, без токена и без сети
uv run hh-radar status

Через минуту у вас работающая база и MCP-сервер. Дальше — либо подключить его к Claude Desktop (см. ниже), либо собрать настоящие данные.

Вакансии, которые кладёт seed, вымышлены: сорок восемь из них собирает scripts/make_demo_corpus.py из двенадцати ролевых архетипов с фиксированным зерном. Чужие объявления в публичном репозитории лежать не должны, а без токена приложения их и не получить. Проходят они через тот же разбор, тот же конвейер записи и тот же чанкинг, что и живые данные, — отдельной ветки «для демо» в коде нет. Подробности в samples/README.md.

Настоящие данные

GET /vacancies у hh закрыт для анонимных запросов — отвечает 403 bad_authorization. Нужен токен приложения:

  1. Зайдите на dev.hh.ru/admin → «Создать приложение».

  2. Скопируйте Client ID и Client Secret.

  3. cp .env.example .env и впишите их в HH_CLIENT_ID / HH_CLIENT_SECRET.

Токен по client_credentials запрашивается автоматически и кэшируется на диск — /token не дёргается на каждый запуск.

uv run hh-radar ingest --days 30      # собрать вакансии
uv run hh-radar index                 # посчитать эмбеддинги
uv run hh-radar showcase              # пересобрать витрину

Подключение к Claude Desktop

%APPDATA%\Claude\claude_desktop_config.json (Windows) или ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):

{
  "mcpServers": {
    "hh-radar": {
      "command": "C:\\путь\\к\\hh-radar\\.venv\\Scripts\\python.exe",
      "args": ["-m", "hh_radar.mcp_server"],
      "env": { "DATABASE_URL": "postgresql+psycopg://hh:hh@localhost:5433/hh_radar" }
    }
  }
}

Перезапустите Claude Desktop — в списке инструментов появится hh-radar.

Схема базы

erDiagram
    EMPLOYERS ||--o{ VACANCIES : "нанимает"
    VACANCIES ||--o{ VACANCY_CHUNKS : "режется на"
    VACANCIES }o--o{ SKILLS : "требует"

    EMPLOYERS {
        bigint id PK "идентификатор hh"
        string name
        bool trusted
    }
    VACANCIES {
        bigint id PK "идентификатор hh"
        string name
        int salary_from_rub "приведено к рублям"
        bool is_remote
        text description
        tsvector search_vector "генерируемый, GIN"
        timestamptz published_at "индекс DESC"
    }
    SKILLS {
        int id PK
        string name UK "нормализованное"
        string display_name "как написал работодатель"
    }
    VACANCY_CHUNKS {
        bigint id PK
        int chunk_index
        text content
        vector embedding "384, HNSW cosine"
    }

Инженерные решения, которые стоит объяснить

Здесь то, что не видно из списка технологий, но именно это отличает работающий сборщик от примера из документации.

Потолок выдачи в 2000 элементов. hh не отдаёт больше двух тысяч вакансий на один поисковый запрос, сколько страниц ни проси. Сборщик сначала спрашивает, сколько всего найдено за интервал дат; если больше потолка — режет интервал пополам и повторяет для каждой половины рекурсивно. Дробление останавливается на часовом окне: если и там перебор, честнее записать предупреждение в лог, чем молча потерять данные.

tsvector — генерируемый столбец, а не вычисление на лету. Postgres поддерживает его сам при каждой записи, GIN-индекс строится по готовому значению. Название вакансии весит больше описания (setweight A против B) — совпадение в заголовке релевантнее совпадения где-то в середине абзаца про ДМС.

Проверить план: hh-radar explain "kubernetes airflow". Команда не просто печатает EXPLAIN ANALYZE, а разбирает его, потому что «Seq Scan» сам по себе ещё ничего не означает. На маленькой таблице последовательное чтение дешевле индекса, и планировщик прав. На большой, но при запросе, под который подходит половина базы, — тоже прав. Проблема только в третьем случае: запрос избирательный, таблица большая, а индекс не взят — обычно потому, что после массовой заливки не собрана статистика. Поэтому hh-radar ingest в конце сам выполняет ANALYZE: без этого первые же поиски по свежей базе идут мимо индекса.

Различение INSERT и UPDATE без лишнего SELECT. У INSERT ... ON CONFLICT DO UPDATE системный столбец xmax равен нулю у только что вставленной строки и содержит идентификатор транзакции у обновлённой. RETURNING (xmax = 0) даёт точную статистику прогона одним запросом.

Двухфазная загрузка. Поиск отдаёт сто вакансий за запрос, но без описания и навыков; полная карточка стоит одного запроса на вакансию. Поэтому сначала пишется всё, что дал поиск, и только потом дозагружаются карточки — по тем вакансиям, у которых их ещё нет. Прерванный на середине прогон ничего не теряет, следующий подхватывает с того же места.

Название вакансии в каждом чанке. Кусок текста «требуется опыт от года и знание Docker» не находится по запросу «python-разработчик»: слова «python» в самом куске нет. Поэтому каждый чанк начинается с названия вакансии. Мелочь, которая меняет recall в разы.

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

RRF, а не взвешенная сумма. Гибридный поиск объединяет полнотекстовую и семантическую выдачу по Reciprocal Rank Fusion. Складывать ts_rank с косинусной близостью нельзя: это числа из разных пространств, и коэффициент для их смешивания подбирался бы под конкретный набор запросов. RRF работает с рангами, а ранги сравнимы всегда.

Заглушка вместо модели в CI. EMBEDDING_BACKEND=hash — детерминированный хеш-эмбеддер той же размерности. Он ничего не знает о смысле, но позволяет прогнать всю проводку кода, не выкачивая 220 МБ ONNX на каждый пуш. Настоящая модель — paraphrase-multilingual-MiniLM-L12-v2 через fastembed, на CPU, без API-ключей: проверяющий должен уметь запустить проект, не заводя аккаунтов.

Где поиск ошибается

Чисел здесь нет, и это осознанно. Опубликовать recall@10 по вымышленному корпусу, который сам же и сгенерировал, — значит принять экзамен у самого себя: запросы и данные писал один человек, и метрика померит их совпадение, а не качество поиска. Настоящие числа появятся здесь после сбора с токеном приложения; до тех пор честнее пустая таблица, чем красивая.

Что при этом сделано и работает:

uv run hh-radar evaluate                      # в терминал
uv run hh-radar evaluate -o eval/report.md    # в файл

Команда гоняет размеченный набор из 25 запросов через три метода поиска — полнотекстовый, семантический и гибридный — и считает recall@k, precision@k, MRR и среднее время ответа для каждого. Эталон берётся из relevant_vacancy_ids, а пока ручной разметки нет — из регулярных выражений по названию вакансии. Запросы, для которых эталона не нашлось вообще, не идут в среднее и считаются отдельной колонкой: записать их в провалы было бы враньём в свою пользу наоборот.

Интереснее таблицы — то, что команда печатает под ней: список запросов, на которых метод не нашёл ни одной релевантной вакансии в топ-5, вместе с тем, что он вернул вместо неё. По нему видно класс запросов, который система не тянет. Даже на демонстрационном корпусе это уже видно: на перифразах вроде «нужен человек, который свяжет CRM и мессенджер через no-code» полнотекстовый поиск не возвращает ничего — websearch_to_tsquery требует все значимые слова разом, — а семантический возвращает ровно те вакансии, где про no-code не сказано ни разу. Ради этой разницы pgvector в проекте и стоит.

Разметка лежит в eval/queries.yaml и составлена с уклоном в то, что ключевыми словами не находится: перифразы, описания задачи вместо названия технологии, запросы про условия и про уровень кандидата.

Ограничения

Пишу прямо, потому что проверять всё равно будут:

  • Это личный проект, а не продакшен под нагрузкой. Написан для собственного поиска работы.

  • Нужен токен приложения hh. Без него доступны только справочники; вакансии не отдаются никому анонимно.

  • Курсы валют статичные. Приведение зарплат к рублям — приближение для сортировки, а не финансовый расчёт. Тянуть курсы ЦБ ради этого — усложнение без пользы.

  • Выгрузок вакансий в репозитории нет. База живёт локально. В samples/ лежит демонстрационный корпус на 52 вымышленные вакансии — его кладёт seed и на нём собрана витрина; настоящих объявлений здесь нет ни одного.

  • Модель эмбеддингов выбрана по весу, а не по качеству. Из многоязычных, доступных в fastembed, paraphrase-multilingual-MiniLM-L12-v2 — самая лёгкая (220 МБ против 2.24 ГБ у multilingual-e5-large). Насколько это стоит recall, видно в разделе выше.

  • compare_to_profile не проверяет требования к переезду и английскому: в данных hh нет полей, по которым это можно утверждать, а гадать по ключевым словам в описании — врать с уверенным лицом.

Разработка

# uv sync приводит окружение в точное соответствие набору extra,
# поэтому для разработки нужны оба: без rag не соберётся семантический поиск,
# без dev не будет pytest.
uv sync --extra dev --extra rag

uv run pytest -q                 # юнит-тесты, база не нужна
uv run ruff check src tests
uv run mypy
uv run alembic check             # схема и модели не разошлись

Тесты, которым нужна живая база или настоящая модель, помечены маркерами:

uv run pytest -m integration     # требует docker compose up -d db
uv run pytest -m embeddings      # скачивает модель

Лицензия

MIT — см. LICENSE.

Available Tools

7 tools
compare_to_profileA

Честно сопоставляет вакансию с профилем кандидата (profile.yaml): verdict good/stretch/no, совпавшие и недостающие навыки, а если verdict «no» — конкретные причины отказа (blockers): зарплата ниже минимума, требуемый опыт выше принимаемого, не подходящая занятость, не удалённая работа, стоп-слово из red_flags. Используйте перед тем, как рекомендовать вакансию кандидату как хороший вариант — не оценивайте пригодность вакансии самостоятельно. По умолчанию грузит profile.yaml из корня проекта (шаблон — profile.example.yaml); profile_path позволяет указать другой файл.

Inputs: vacancy_id (required), profile_path. Returns: object

db_statusA

Служебная информация о состоянии базы: сколько вакансий/работодателей/навыков загружено, сколько описаний уже проиндексировано эмбеддингами, какой моделью они посчитаны, за какой период данные и когда их в последний раз собирали. Вызывайте в начале работы, чтобы понимать границы доступных данных и не выдумывать вакансии или статистику, которых в базе нет. Параметров не принимает.

Inputs: no named parameters. Returns: object

get_vacancyA

Полная карточка одной вакансии по её id из hh.ru: незакрытое описание, работодатель, все требуемые навыки, ссылка на hh.ru. Используйте после search_vacancies/semantic_search, чтобы изучить конкретную вакансию подробно, или перед compare_to_profile.

Inputs: vacancy_id (required). Returns: object

market_overviewA

Общий срез рынка по фильтру: сколько вакансий, у скольких указана зарплата и её персентили (p25/p50/p75), доля удалённых, разбивка по требуемому опыту и топ-10 работодателей. Используйте для агрегированных вопросов вида «какие сейчас зарплаты» или «много ли удалёнки» — не для списка конкретных вакансий.

Inputs: query, area_id, published_within_days. Returns: object

search_vacanciesA

Полнотекстовый поиск вакансий по словам в названии и описании (Postgres tsvector, синтаксис websearch: кавычки для точной фразы, минус перед словом — исключить его). Используйте, когда запрос содержит конкретные термины: должность, технологию, компанию. Для поиска по смыслу и синонимам без точных слов используйте semantic_search. Пустой query — не ошибка, вернутся вакансии по фильтрам, отсортированные по дате публикации. Описание в каждом результате обрезано; полный текст — через get_vacancy по id.

Inputs: limit, query, area_id, experience, +3 more. Returns: object

skill_statsA

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

Inputs: query, top_n, area_id, published_within_days. Returns: object

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    TDQS

    A4.4/5.0

    Scored across 7 tools

    Disambiguation5/5

    Each tool targets a distinct function: lexical vs semantic search, individual vacancy detail, skill statistics, market aggregates, candidate-fit comparison, and database metadata. The overlapping search/stat tools explicitly cross-reference each other to prevent misselection.

    Naming Consistency4/5

    Most names are lowercase snake_case and use clear noun/verb tokens, but the pattern is mixed: search_vacancies/get_vacancy/compare_to_profile are verb-first, while skill_stats/market_overview/db_status are noun-first. Still predictable and readable.

    Tool Count5/5

    Seven tools cover the job-search niche without redundancy; each one serves a clear use case and none feel like filler.

    Completeness5/5

    The surface covers the full workflow: discover via search/semantic search, inspect via get_vacancy, analyze via skills/market tools, evaluate fit via compare_to_profile, and understand data boundaries via db_status. No critical missing operation for the stated purpose.

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      Not graded
      quality
      D
      maintenance
      Enables AI assistants to search job vacancies, manage resumes, and apply to jobs on HeadHunter (hh.ru), Russia's largest job search platform. Includes OAuth 2.0 integration for secure job applications and an automated vacancy hunter agent with intelligent matching.
      37
      MIT
    • A
      license
      C
      quality
      B
      maintenance
      Integrates with HuntFlow ATS to manage vacancies, candidates, resumes, and recruitment stages via 7 tools and 2 skill prompts.
      14
      37 npm
      1
      MIT
    • A
      license
      Not graded
      quality
      F
      maintenance
      Enables AI assistants to access and manage HeadHunter job platform data, including vacancies, resumes, negotiations, and employer settings via 167+ tools.
      124 npm
      5
      MIT
    • F
      license
      A
      quality
      D
      maintenance
      Enables to interact with hh.ru (a Russian job platform) through browser automation, allowing users to search for jobs, manage resumes, apply to vacancies with cover letters, and track application statuses via natural language.
      9
      3
      -