Я построил себе 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 — сменный. Сначала на собственном поиске работы. Потом на всём остальном. Это не оптимизация поиска работы. Это продуктовое решение про то, кому принадлежит система.