Я построил себе ATS наоборот
ATS ранжирует тебя под вакансию. Я перевернул это: система, которая каждое утро ранжирует вакансии под меня. И главное в ней — не AI, а то, что AI в ней сменный.
15 декабря 2025 hh.ru вернул мне 403
В этот день api.hh.ru/vacancies перестал отвечать соискателям — публичный API закрыли, все запросы на список вакансий теперь 403. Для большинства это новость уровня «ну и ладно». Для меня это был тест на то, как устроена система, которую я к тому моменту уже собрал.
Контекст: я Senior Product Designer, искал работу. ATS — applicant tracking system — это машина, которая ранжирует тебя под вакансию: совпали ли ключевые слова, прошёл ли порог. Ты — строка в чьей-то воронке. Я построил обратное: систему, которая каждое утро ранжирует вакансии под меня. ATS наоборот. Источник правды — мой профиль, а не их JD.
Когда hh.ru закрыл API, я не чинил «парсер». Я менял один слой из трёх, не трогая остальные два. Про это и текст: не про то, что я автоматизировал поиск работы, а про то, как разделение источника правды и сменяемого AI-runtime делает систему устойчивой к тому, что внешний мир — и сами AI-инструменты — меняются под тобой каждые полгода.
403 ломает интеграцию, а не архитектуру
Когда отвалился API, было два пути.
Первый — OAuth через dev.hh.ru. Зарегистрировать приложение, получить client_id/client_secret, гонять токены, обновлять их. Чисто, «правильно», как в учебнике. И — лишняя точка отказа, которую я не контролирую: завтра они меняют scopes или закрывают регистрацию, и я снова чиню инфраструктуру вместо того, чтобы искать работу.
Второй — RSS-лента hh.ru/search/vacancy/rss для списка плюс JSON-LD (application/ld+json, @type: JobPosting) на странице каждой вакансии для полного описания. Авторизация не нужна вообще. JSON-LD — это SEO-данные, которые hh.ru сам публикует для поисковиков; они стабильнее, чем парсинг DOM, потому что компания заинтересована их не ломать.
Я выбрал второй. Не потому что он «хакерский», а потому что у него меньше зависимостей от чужих решений. DOM-скрейпинг отбросил сразу — хрупко, ломается при первом редизайне. OAuth — как избыточную точку отказа под мою задачу. Файлы auth.py и setup_auth.py, написанные под старый API, стали мёртвым кодом. Это нормально: интеграция — расходник.
Вердикт простой. API, который тебе не принадлежит, — это не фундамент, это аренда. Строить на нём ядро системы — ошибка. Источники входных данных должны быть заменяемы по дизайну, а не по факту аврала.
Три слоя, и только один из них умный
Система разложена на три слоя, и это разделение — главное, что в ней есть.
Data Layer — Obsidian vault. Мой профиль, позиционирование, разбор ролей с trade-off'ами, предпочтения по зарплате и формату, журнал решений. SQLite с дедупликацией вакансий. Это источник правды. Он мой, он локальный, он переживёт любой инструмент.
Intelligence Layer — скоринг. Берёт вакансию и профиль, выдаёт fit-score 1–10, флаги, плюсы-минусы, рекомендацию. Тут живёт логика оценки: зарплата нетто ниже 180k — потолок 3 балла; требования явно под junior — потолок 4; явная культура JTBD — плюс балл. Это не «спросить у нейросети, что думаешь», это формализованные критерии из моего же профиля.
Runtime Layer — собственно AI, который крутит интеллект. И вот он — сменяемый. Сегодня это claude CLI. Завтра может быть что угодно.
Обычно, строя «AI-инструмент», сваливают всё в один слой: промпт, данные, логика и вызов модели спутаны в один скрипт. Тогда смена модели = переписать всё. У меня смена runtime не трогает ни данные, ни логику оценки. AI — это сменный интерфейс поверх моей системы знаний, а не сама система.
Это, если хочешь, тот же принцип, по которому хороший продукт отделяет состояние от рендера. Только здесь «рендер» — это языковая модель, а «состояние» — то, кто я и что мне нужно от работы.
Почему scorer ходит в CLI, а не в SDK — и почему из /tmp
Первая версия скоринга была на anthropic SDK с промпт-кэшингом через cache_control. Красиво по API. И требует отдельной платной подписки на API поверх той, что у меня уже есть в Claude Code.
Я переписал scorer на вызов бинарника claude через subprocess с флагом --print. Оплата идёт через подписку Claude Code, которая уже куплена. Отдельный ключ хранить и ротировать не надо. Минус красоты промпт-кэширования — плюс к тому, что инфраструктура не размножается. Для личной системы это правильный размен: меньше движущихся частей важнее академической чистоты вызова.
Дальше — батчинг. Вместо N вызовов по одной вакансии модель получает по 4 вакансии за раз и возвращает JSON-массив оценок: ceil(N/4) вызовов вместо N. Пауза 8 секунд между батчами, чтобы не упереться в rate-limit. 20 вакансий — это 5 батчей, а не 20 вызовов.
И самая показательная деталь. subprocess запускается с cwd="/tmp". Почему: claude CLI читает CLAUDE.md из рабочей директории. Если запускать из папки парсера, он подтягивает 5000+ символов системного контекста карьерного агента в каждый батч — промпт распухает, иногда роняет 300-секундный таймаут. Нейтральная директория без CLAUDE.md решает это одной строкой. Scorer'у не нужен весь мозг системы — ему нужен только профиль кандидата в системном промпте.
Это и есть тот инженерный слой, про который senior-дизайнеру обычно говорить «не положено». Но точка тут продуктовая: каждое из этих решений — про то, чтобы система работала каждое утро без меня, а не про то, чтобы я ей любовался.
Запускается само в 11:00 и шлёт нативное уведомление
Поверх всего — launchd-агент com.maximvechkitov.hh-brief.plist. Каждый день в 11:00 он запускает python3 run.py из папки парсера. LaunchAgent, а не LaunchDaemon — намеренно: агент работает в контексте пользователя, а значит может показывать уведомления. Демон от системного процесса этого на macOS не умеет.
Когда прогон закончен, прилетает нативное уведомление: «N вак. проанализировано · 🚀 X приоритет · топ Y/10 — название». Не Telegram-бот. Telegram я отбросил осознанно: бот, токен, chat_id, хранение секретов — всё это имеет смысл, только если нужен пуш на телефон в отрыве от компа. Парсер крутится локально на Mac в 11 утра, когда я и так за столом. osascript с нативным уведомлением — ноль setup'а. Ошибка уведомления обёрнута в try/except, чтобы не уронить парсер из-за косметики.
Время, кстати, не «оптимальное по науке» — изначально планировал 8:30, поставил 11:00 просто потому, что так удобнее мне. Систему строишь под себя, а не под чужой best practice.
Первый боевой прогон, 26 мая 2026: 154 вакансии с hh.ru → 55 прошли Python-фильтр (стоп-слова, зарплатный отсев, дедуп) → 55 из 55 оценены батч-скорером. Токены модели потрачены только на последнем шаге — всё, что можно отсеять бесплатно Python'ом, отсеяно до AI. Топ дня — 7.5 из 10. Не «вау-цифра». Честная.
Кто пишет этот текст
Эту статью пишет не редактор. Её пишет Claude Code, читая мой vault — тот самый Data Layer. Профиль, журнал решений, исходники парсера, голос, в котором я хочу звучать. Переводы постов — тоже. Я не диктую текст, я держу источник правды актуальным, а runtime генерирует поверх него.
Это то же разделение, что и в скоринге вакансий. В одном случае система ранжирует вакансии под меня. В другом — пишет тексты от моего имени. Слой данных один и тот же: кто я. Слой runtime — сменяемый AI. Если завтра выйдет модель лучше — я меняю один бинарник, а не переписываю себя.
Вот вывод, ради которого всё это. «AI-native» — это не про то, сколько инструментов ты перечислишь в резюме. Это про то, есть ли у тебя источник правды отдельно от инструмента. Если твоё знание о себе, о продукте, о пользователе живёт внутри конкретного чата с конкретной моделью — ты не AI-native, ты заложник вендора. AI-native — это когда инструмент можно выдернуть и заменить, а система останется.
Я построил себе ATS наоборот не чтобы быстрее откликаться. Я построил его, чтобы проверить тезис на себе: источник правды — мой, AI — сменный. Сначала на собственном поиске работы. Потом на всём остальном.
Это не оптимизация поиска работы. Это продуктовое решение про то, кому принадлежит система.
Максим Вечкитов · Senior Product Designer · Design Engineer
Открыт к senior/lead-ролям и парт-тайму.