24 коллектора, 111 ATS-досок компаний, 345 тестов, ноль API-ключей и ноль ИИ. Windows-exe, который считает совпадение резюме с вакансией и приносит 1–3 живых человека, которым можно написать самому. Внутри — архитектура, три бага, из-за которых я чуть не отправил письма сотрудникам чужих компаний, и раздел «честные ограничения», без которого это была бы реклама.

Кнопка, которая ведёт в тишину

Поиск работы — это в основном не собеседования. Это двадцать открытых вкладок, тысяча вакансий, из которых девятьсот про другой стек, кнопка «Откликнуться» и тишина.

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

Ставка, на которой построен весь инструмент: работает другое. Работает письмо конкретному человеку — инженерному менеджеру, который эту вакансию открыл, или рекрутеру, который её ведёт. Не «уважаемые HR», а по делу, со ссылкой на то, что у него написано в описании.

Сразу оговорка, потому что дальше будет много цифр и мне не хочется, чтобы к ним приклеилась одна выдуманная: строгой статистики по конверсии откликов у меня нет. Есть личное ощущение и общее место в разговорах с коллегами. Всё, что я могу подтвердить, — это то, что происходит внутри программы. Если вы ждёте «я отправил 200 писем и получил 40 ответов», этого числа здесь не будет: я его не измерял.

Проблема с прямыми письмами ровно одна, и она механическая: это не масштабируется руками. Найти релевантную вакансию — полчаса. Найти человека за ней — ещё полчаса. Умножьте на сотню, и станет понятно, почему все жмут «Откликнуться».

Так появился resume2human — программа, которая решает ровно одну задачу: из вашего резюме получить список вакансий с высоким соответствием, и по каждой — 1–3 живых человека с именем, ролью и адресом.

Никакой автоматической рассылки. Никакого CRM. Ни одного API-ключа. Только поиск, скоринг и вывод, из которого удобно скопировать адрес в письмо, которое вы пишете сами.

Дальше — как оно устроено внутри и где оно ломалось.

Что происходит за один запуск

Windows-приложение, 184 МБ распакованного архива, Python ставить не нужно.

Окно приложения: слева резюме и параметры поиска, справа таблица вакансий и карточка выбранной
Окно приложения: слева резюме и параметры поиска, справа таблица вакансий и карточка выбранной

Вакансии, компании и люди на всех скриншотах выдуманы: настоящих прогонов в статье нет.

Сценарий целиком:

  1. Резюме. Перетащить файл (PDF, DOCX, TXT, MD, RTF) или вставить текстом. Можно вообще не резюме, а описание словами: «Senior Java, Kafka, Рига или remote». Приложение сразу показывает, что поняло: стек, грейд, позицию — и это стоит проверить глазами, потому что распознанный профиль умножается на каждую вакансию в прогоне.

  2. Параметры поиска. Ключевые слова, локации, должности, порог совпадения, галочка «Только удалённые» и выбор источников. Пустое поле = «взять из резюме».

  3. LinkedIn — необязательно. Кнопка открывает браузер, вы входите сами, со своей 2FA и капчей, приложение забирает готовую сессию. Пароль нигде не вводится и не хранится. Без сессии просто будет меньше контактов.

  4. «Найти вакансии». Дальше можно идти за кофе.

На выходе — карточка, из которой первое письмо собирается копипастой:

Метка в квадратных скобках у каждого человека — это провенанс: [company_site/verified] значит «адрес взят со страницы компании», а [search/likely] — «профиль найден поиском, адрес не опубликован». Программа никогда не показывает адрес, которого она не видела.

Кнопка «Копировать карточку» кладёт весь блок в буфер целиком. Дальше письмо пишете вы, руками. Это принципиальное место, к нему вернёмся.

Ноль ИИ и ноль API-ключей — это не бедность, это требование

Сейчас в любом подобном проекте на этом месте стоял бы вызов LLM. У меня его нет, и это решение, а не «не осилил».

Причина первая: объяснимость. Скоринг детерминированный — веса по пересечению навыков из вашего же резюме с текстом вакансии. Он даёт не только число, но и разбор: что совпало, чего не хватает из обязательного, что сработало как стоп-слово, совпал ли грейд. С таким выводом можно спорить. «85%, доверьтесь модели» — это число, с которым спорить нельзя, а решение принимать всё равно вам.

Причина вторая: приватность. Резюме — это ФИО, телефон, места работы и иногда зарплатные ожидания. Инструмент, который первым делом отправляет всё это в чужой API, должен объяснить, зачем. Мне нечего было ответить, поэтому резюме, профиль, база вакансий и сессия LinkedIn лежат в папке рядом с exe и никуда не уходят.

Причина третья: цена входа. Как только появляется ключ, появляется инструкция «зарегистрируйтесь, привяжите карту, положите $5». Программу, которую нужно настраивать полчаса, человек в активном поиске работы просто не запустит. Скачал, распаковал, кинул резюме — вот весь бюджет внимания, который у него есть.

В конфиге, честности ради, есть переключатель MATCHER_BACKEND = "llm". Он печатает предупреждение и работает как keywords. Мне до сих пор интересно, насколько LLM-скоринг реально лучше весового, — но это будет отдельный эксперимент с замером, а не «добавил ИИ, потому что 2026 год».

Где брать вакансии, если у тебя нет ключей

Это самая скучная и самая важная часть проекта. Ключей нет — значит, источники должны быть публичными. Их десять групп и 24 коллектора, все опрашиваются параллельно:

Группа

Что внутри

ats

Greenhouse, Lever, Ashby, Recruitee, SmartRecruiters, Workable — прямой опрос досок 111 компаний

boards

RemoteOK, We Work Remotely, Arbeitnow, Remotive, Jobicy, Working Nomads, Himalayas, The Muse, тред «Who is hiring» на Hacker News

remote

Workable-поиск по всем careers-страницам и Empllo — удалёнка поиском, а не фидом

regional

NoFluffJobs (Польша), getmatch, Landing.jobs (ЕС), Djinni и DOU, Habr Career, GeekJob

linkedin

вакансии через гостевой доступ, без cookies

linkedin_posts

посты «мы нанимаем» — по вашей сессии

telegram

публичные каналы

threads

публичные посты threads.com

websearch

Bing RSS → Bing → DuckDuckGo, запасной канал

Главный вывод из этой таблицы неожиданно приземлённый: больше всего пользы приносит не хитрый парсер, а список компаний.

Группа ats — это не «интеграция с Greenhouse». Это 111 строк вида «компания → платформа → токен доски», каждая из которых проверена руками. Вакансия с доски компании приходит раньше, чем попадает на агрегаторы, и — что важнее — это единственный источник, который отдаёт проверенный домен работодателя. Домен дальше нужен для поиска людей, и на нём построен весь третий акт этой статьи.

Разбивка одного живого прогона — сколько вакансий выше порога 60% дал каждый источник:

112  nofluffjobs        ← лучший процент попадания из всех
105  ats                ← доски компаний
 37  getmatch
 33  linkedin_jobs
 10  landingjobs
  9  telegram_channel
  7  jobicy
  5  hn_whoishiring
  4  threads
  4  weworkremotely

Всего в тот прогон собралось 1447 вакансий, после дедупликации — 1363, выше порога 60% — 330. Список источников с тех пор подрос, так что это нижняя оценка, а не рекорд.

Отдельно отмечу источник с лучшим соотношением «усилие → результат», и это не то, что я ожидал: посты. Telegram-каналы, Hacker News, Threads, LinkedIn. Вакансия из поста почти всегда приходит вместе с человеком: в CIS-каналах рекрутер оставляет свой @хэндл прямо в тексте, на Hacker News работодатель пишет от себя. Прямой контакт нашёлся в 22 из 60 вакансий с Hacker News и в 9 из 14 постов Telegram-каналов — без единого обращения к LinkedIn.

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

Матчер: две ступени, дёшево → дорого

Полный разбор 1400 вакансий — это дорого, а девять из десяти отсеиваются одним пересечением множеств. Поэтому ступени две.

Ступень 1, префильтр. Только пересечение must-have и nice-to-have навыков с текстом. Всё ниже 35% умирает здесь и дальше не считается.

Ступень 2, вердикт. Для выживших — полный разбор: match 0–100, соответствие грейда, чего не хватает из обязательного, что сработало как стоп-слово, почему подходит. Результат кэшируется в БД по хешу вакансии, поэтому повторный прогон не пересчитывает то, что не изменилось.

Единственный неочевидный совет по настройке, который стоил мне пары прогонов впустую: держите список must-have коротким. Половина итогового балла — это покрытие обязательных навыков. Если вписать туда весь стек из резюме — язык, фреймворк, три базы, брокер, Kubernetes, — то покрытия не наберёт ни одна вакансия, и порог 60% не возьмёт никто. Обязательные — это язык и фреймворк. Всё остальное — в желательные: они добавляют очки, но никогда не блокируют.

Лестница контактов и правило, которое дороже всей остальной архитектуры

Для вакансий выше порога программа идёт по ярусам и останавливается, как только набрала три контакта:

  1. контакты прямо в тексте вакансии — самый дешёвый и на удивление результативный ярус;

  2. страницы компании — careers, team, about, people;

  3. профили LinkedIn — единственное место, где есть выбор, кого искать;

  4. публичная GitHub-организация компании;

  5. веб-поиск как последний ярус.

Третий ярус важнее, чем кажется, потому что роли выбираются от вакансии: под backend-роль ищется backend-менеджер, Head of Engineering, VP, CTO, профильный рекрутер — плюс один senior-инженер того же стека. Последнее не случайность: коллеги отвечают чаще менеджеров, а реферал от коллеги проходит дальше холодного отклика.

А теперь главное правило, ради которого всё это писалось:

Программа никогда не генерирует адреса по шаблону.

Никаких first.last@company.com. Такой адрес угадывается с первого раза примерно в половине случаев, а в остальных уходит в никуда, отбивается сервером и портит репутацию домена отправителя — вашего домена. Причём вы об этом не узнаете: письмо просто не дойдёт, а вы будете думать, что вам не ответили.

Если адрес нигде не опубликован — в отчёте его не будет. Будет имя, роль и ссылка на профиль, а решение, писать ли туда, останется за вами. Пустое поле честнее выдуманного.

Три бага, ради которых стоило всё это писать

Это самая полезная часть. Все три — молчаливые: ни исключения, ни строчки в логе, просто тихо неправильный результат.

1. Доска, на которой нашлась вакансия, — не работодатель

Первый же прогон с новыми источниками выдал:

12. [85%] Wheely — Senior Backend Engineer (Maps)
https://getmatch.ru/vacancies/35902-senior-backend-engineer-maps
Люди: не найдены — смотрите страницу карьеры компании вручную.
       https://getmatch.ru/careers

Домен компании Wheely, по мнению программы, — getmatch.ru.

Резолвер компаний берёт домен из URL вакансии. Для ATS-досок это правильно: вакансия физически лежит на инфраструктуре компании. Для агрегаторов — нет, и на этот случай есть список исключений NON_COMPANY_DOMAINS. Новых источников в нём, разумеется, не было.

Цена ошибки не косметическая. Домен — это предусловие для яруса «сайт компании» в лестнице контактов. То есть краулер пошёл бы читать getmatch и выдал бы сотрудников getmatch как людей из Wheely. Инструмент, который врёт про то, кому вы пишете, хуже, чем отсутствие инструмента.

2. endswith("x.com")

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

Тест упал. На трёх записях, которых я не трогал:

Mapbox   | mapbox.com  -> ['x.com']
Playrix  | playrix.com -> ['x.com']
Flix     | flix.com    -> ['x.com']

Проверка была написана как any(netloc.endswith(d) for d in NON_COMPANY_DOMAINS). В списке есть x.com — бывший Twitter. mapbox.com заканчивается на x.com.

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

def is_platform_domain(netloc: str) -> bool:
    """
    Совпадает сам хост или его поддомен, и ничего больше.

    Обычный ``endswith`` выглядит эквивалентно и не является им: с "x.com" в
    списке он отвергает ещё и mapbox.com, flix.com и playrix.com — ровно те
    домены компаний, которые нужны лестнице контактов. Баг молчаливый:
    настоящий работодатель резолвится без домена и теряет два яруса.
    """
    netloc = (netloc or "").lower().strip().removeprefix("www.")
    return any(netloc == d or netloc.endswith(f".{d}") for d in NON_COMPANY_DOMAINS)

Мораль простая и переносимая: endswith для доменов — это почти всегда баг. Либо точное сравнение, либо явная точка перед суффиксом.

3. Шесть компаний, которые оказались другими компаниями

В проекте есть tools/discover_ats.py: берёт список «компания → карьерная страница → домен», открывает каждую страницу, ищет след ATS и проверяет находку тем же адаптером, что и боевой сборщик — вернулись вакансии или нет.

У большинства современных компаний карьерная страница — SPA, доска подключена скриптом, и следов её в HTML нет вовсе. На такой случай инструмент подбирает токен по домену и имени компании и проверяет каждый вариант.

Я добавил 98 новых компаний и прогнал. Доска ответила у 58. Отлично же.

И вот здесь я остановился. Потому что «доска ответила вакансиями» — это доказательство того, что доска существует, а не того, что она принадлежит той компании. А имена компаний не уникальны.

Вытащил названия и локации вакансий с каждой подобранной доски:

Что предложил инструмент

Кто это на самом деле

ashby/ramp для ramp.network (Варшава)

Ramp, американский финтех, Нью-Йорк

greenhouse/clear для ClearBank (Лондон)

CLEAR, американская identity-компания

greenhouse/bird для bird.com (Амстердам)

Bird. Самокаты

greenhouse/symphony для symphony.is (Белград)

Symphony Communication (Нью-Йорк)

ashby/lunar для банка Lunar (Дания)

компания из здравоохранения в Сан-Франциско

ashby/swan для swan.io (Франция)

робототехника

Выглядело это так:

### Ramp Network (Варшава) [ashby/ramp] — 139 вакансий
    Security Engineer, Cloud       | New York, NY (HQ)
    Mobile Engineer, Android       | New York, NY (HQ)

### Bird (bird.com, Амстердам) [greenhouse/bird] — 32 вакансии
    E-Scooter Field Operations Specialist - Newark, NJ
    Fleet Van Operator             | Edmonton, Alberta, Canada

Шесть из 58 — каждая десятая. Каждая добавила бы в отчёт вакансии чужой компании с чужим доменом, а значит — и людей из чужой компании. В конфигурацию поехали 52, а на шесть отбракованных теперь стоит тест, чтобы следующий прогон инструмента не вернул их обратно.

Мораль: если ваш инструмент что-то «подбирает», а потом «проверяет» — убедитесь, что проверка проверяет то же самое, что подбор. Здесь подбор был про идентичность компании, а проверка — про существование доски. Это разные вопросы, и между ними помещаются шесть чужих компаний.

LinkedIn: единственное место, где я иду против правил

Скажу прямо, без эвфемизмов: браузерная автоматизация LinkedIn противоречит их ToS. Инструмент работает на вашей собственной сессии и для вашего собственного поиска работы, но риск для аккаунта существует, и делать вид, что его нет, я не буду.

Что с этим сделано инженерно — жёсткие бюджеты, вбитые в конфиг:

LINKEDIN_MIN_DELAY = 14.0             # секунды, нижняя граница паузы
LINKEDIN_MAX_DELAY = 34.0             # верхняя
LINKEDIN_LONG_BREAK_EVERY = 5         # действий между длинными паузами
LINKEDIN_LONG_BREAK_RANGE = (70, 190) # длинная пауза
LINKEDIN_MAX_COMPANIES_PER_RUN = 4
LINKEDIN_MAX_PAGE_VIEWS = 36          # жёсткий потолок переходов за прогон
LINKEDIN_MAX_RUN_MINUTES = 50

Это не «антибан-хак», это ограничение сверху: скорость человека, который неспешно смотрит четыре компании за час. Плюс одна сессия браузера на весь прогон вместо новой на каждый запрос и снятый при логине отпечаток, который переиспользуется, — чтобы не выглядеть каждый раз новым устройством.

И главное: браузерные ярусы полностью опциональны. Галочка снята — работают остальные 23 коллектора. Гостевой поиск вакансий LinkedIn при этом остаётся: он читает тот же публичный эндпоинт, что и незалогиненный посетитель, без cookies и без браузера.

Приватная разработка, публичная витрина

Небольшой инфраструктурный кусок, который нравится мне больше, чем стоило бы.

Разработка живёт в приватном репозитории. Публичный resume2human — это README и релиз, больше там нет ничего. Один workflow на каждый push в main делает всё:

  1. тесты — 345 штук, без сети и без ключей: ответы источников подставляются фикстурами в той форме, в какой их реально отдают API;

  2. сборка PyInstaller;

  3. --selftest внутри собранной сборки — самый ценный шаг: он гоняет парсеры PDF/DOCX, анализатор резюме и запуск браузера уже из замороженного exe. PyInstaller ломается ровно там, где обычные тесты зелёные: пропавший ресурс, не доехавший драйвер, путь, которого после заморозки не существует;

  4. zip уезжает в скользящий релиз latest — и в приватный репозиторий, и в публичный, под его именем.

Pull request прогоняет всё до selftest включительно и не публикует ничего: узнать, что замороженная сборка сломана, нужно до мержа, а не после.

Отдельная мелочь, стоившая отладки: README публичного репозитория версионируется в приватном (docs/public/README.md), и синхронизатор сравнивает блоб-sha перед push. Contents API на идентичном содержимом создаёт пустой коммит, а история из «обновить README» в каждом прогоне не нужна никому.

Чего инструмент не делает — и не будет

Это, наверное, главное, и это же место, где со мной чаще всего спорят.

Не рассылает письма за вас. Ни автоматически, ни «по шаблону с подстановкой». Программа доводит до строки «вот человек, вот его адрес, вот почему вакансия вам подходит» — и останавливается.

Причина не в лени и не в морали. Массовая рассылка убивает ровно тот канал, ради которого всё построено. Прямое письмо работает, пока оно редкое и конкретное. В тот момент, когда инженерный менеджер начинает получать по двадцать «персонализированных» писем в день, он заводит фильтр — и канал закрывается для всех, включая вас. Инструмент, который масштабирует рассылку, масштабирует и собственное обесценивание.

Не ведёт статусы откликов и не заменяет CRM. Их и так десяток.

Не генерирует адреса по шаблону — см. выше про репутацию домена.

Не отправляет резюме ни в один сервис.

Честные ограничения

Раздел, без которого статья была бы рекламой.

Скоринг не понимает контекст. Весовой алгоритм по ключевым словам: «Java» в требованиях и «Java» в перечислении технологий команды для него одно и то же. Он объясним и бесплатен, но он тупой, и иногда это видно.

Веб-поиск ломается. DuckDuckGo без ключа отдаёт антибот-страницу уже на втором запросе с одного IP, Bing — деградированную выдачу, и он же молча игнорирует site:. На это есть circuit breaker: движок уходит на паузу, запрос достаётся следующему. Если ответить некому — в отчёте появляется предупреждение, потому что молча пустой список людей выглядит как «в этих компаниях никого нет».

Парсинг постов эвристический. Telegram, HN, Threads, LinkedIn — это текст, который люди пишут как хотят. Название компании иногда угадывается неточно, а где работодатель не назван вовсе, в отчёте стоит @хэндл автора поста. Это честнее выдуманного названия.

Разметка меняется. Всё, что парсится, при поломке отдаёт пустой результат и предупреждение в лог, а не роняет прогон. Экстрактор постов LinkedIn, которым я горжусь больше всего, переживёт следующий редизайн с вероятностью процентов шестьдесят.

Windows. Готовая сборка — под Windows 10/11. Из исходников запускается везде, где есть Python 3.11+.

Она не найдёт вам работу. Она экономит те самые «полчаса + полчаса» на каждую вакансию. Письмо всё ещё пишете вы, и оно всё ещё главное.

Как попробовать

Скачать (Windows, Python не нужен): resume2human-windows.zip

Распаковать архив целиком — внутри папка, а не один файл, exe без соседей не стартует, — и запустить JobHunter\JobHunter.exe. Сборка не подписана сертификатом, поэтому SmartScreen при первом запуске скажет «Windows защитила ваш компьютер»: «Подробнее» → «Выполнить в любом случае».

Проверить, что сборка живая, не открывая окно:

JobHunter.exe --selftest      # результат в data\selftest.log

Всё, что программа создаёт, — база, отчёты, сессия LinkedIn — появляется в папке рядом с exe. Удалить = удалить папку.

Репозиторий, README и релизы: https://github.com/ialakey/resume2human

Что дальше

  • Адаптеры под Workday, Comeet, Gupy, PeopleForce. Сейчас на этом отваливаются 83 компании из моего списка. Под каждую платформу нужен свой эндпоинт, и делать это осмысленно там, где за платформой стоит не одна компания — Workday как раз тот случай.

  • Доски других рынков. Сейчас закрыты Польша, ЕС и русскоязычный рынок — потому что это мои. Германия (StepStone), Нидерланды, Испания — за кем-то ещё.

  • Второй бэкенд матчера и честный замер: выигрывает ли LLM-скоринг у весового настолько, чтобы оправдать ключ.

Если запустите на своём резюме — напишите, что получилось и что сломалось. Особенно интересны рынки, которых я не касался: у любого источника, который я не проверял руками, шанс на молчаливую поломку примерно стопроцентный.