24 коллектора, 111 ATS-досок компаний, 345 тестов, ноль API-ключей и ноль ИИ. Windows-exe, который считает совпадение резюме с вакансией и приносит 1–3 живых человека, которым можно написать самому. Внутри — архитектура, три бага, из-за которых я чуть не отправил письма сотрудникам чужих компаний, и раздел «честные ограничения», без которого это была бы реклама.
Кнопка, которая ведёт в тишину
Поиск работы — это в основном не собеседования. Это двадцать открытых вкладок, тысяча вакансий, из которых девятьсот про другой стек, кнопка «Откликнуться» и тишина.
Отклик через форму — это письмо в ящик, который читает скрипт. Резюме попадает в очередь к парсеру, парсер сравнивает его с текстом вакансии примерно теми же ключевыми словами, которыми это делаю я ниже, и ранжирует вас среди трёхсот таких же. Разница между вами и соседом по очереди — не в опыте, а в том, чьё резюме лучше легло на конкретный словарь.
Ставка, на которой построен весь инструмент: работает другое. Работает письмо конкретному человеку — инженерному менеджеру, который эту вакансию открыл, или рекрутеру, который её ведёт. Не «уважаемые HR», а по делу, со ссылкой на то, что у него написано в описании.
Сразу оговорка, потому что дальше будет много цифр и мне не хочется, чтобы к ним приклеилась одна выдуманная: строгой статистики по конверсии откликов у меня нет. Есть личное ощущение и общее место в разговорах с коллегами. Всё, что я могу подтвердить, — это то, что происходит внутри программы. Если вы ждёте «я отправил 200 писем и получил 40 ответов», этого числа здесь не будет: я его не измерял.
Проблема с прямыми письмами ровно одна, и она механическая: это не масштабируется руками. Найти релевантную вакансию — полчаса. Найти человека за ней — ещё полчаса. Умножьте на сотню, и станет понятно, почему все жмут «Откликнуться».
Так появился resume2human — программа, которая решает ровно одну задачу: из вашего резюме получить список вакансий с высоким соответствием, и по каждой — 1–3 живых человека с именем, ролью и адресом.
Никакой автоматической рассылки. Никакого CRM. Ни одного API-ключа. Только поиск, скоринг и вывод, из которого удобно скопировать адрес в письмо, которое вы пишете сами.
Дальше — как оно устроено внутри и где оно ломалось.
Что происходит за один запуск
Windows-приложение, 184 МБ распакованного архива, Python ставить не нужно.

Вакансии, компании и люди на всех скриншотах выдуманы: настоящих прогонов в статье нет.
Сценарий целиком:
Резюме. Перетащить файл (PDF, DOCX, TXT, MD, RTF) или вставить текстом. Можно вообще не резюме, а описание словами: «Senior Java, Kafka, Рига или remote». Приложение сразу показывает, что поняло: стек, грейд, позицию — и это стоит проверить глазами, потому что распознанный профиль умножается на каждую вакансию в прогоне.
Параметры поиска. Ключевые слова, локации, должности, порог совпадения, галочка «Только удалённые» и выбор источников. Пустое поле = «взять из резюме».
LinkedIn — необязательно. Кнопка открывает браузер, вы входите сами, со своей 2FA и капчей, приложение забирает готовую сессию. Пароль нигде не вводится и не хранится. Без сессии просто будет меньше контактов.
«Найти вакансии». Дальше можно идти за кофе.
На выходе — карточка, из которой первое письмо собирается копипастой:

Метка в квадратных скобках у каждого человека — это провенанс: [company_site/verified] значит «адрес взят со страницы компании», а [search/likely] — «профиль найден поиском, адрес не опубликован». Программа никогда не показывает адрес, которого она не видела.
Кнопка «Копировать карточку» кладёт весь блок в буфер целиком. Дальше письмо пишете вы, руками. Это принципиальное место, к нему вернёмся.
Ноль ИИ и ноль API-ключей — это не бедность, это требование
Сейчас в любом подобном проекте на этом месте стоял бы вызов LLM. У меня его нет, и это решение, а не «не осилил».
Причина первая: объяснимость. Скоринг детерминированный — веса по пересечению навыков из вашего же резюме с текстом вакансии. Он даёт не только число, но и разбор: что совпало, чего не хватает из обязательного, что сработало как стоп-слово, совпал ли грейд. С таким выводом можно спорить. «85%, доверьтесь модели» — это число, с которым спорить нельзя, а решение принимать всё равно вам.
Причина вторая: приватность. Резюме — это ФИО, телефон, места работы и иногда зарплатные ожидания. Инструмент, который первым делом отправляет всё это в чужой API, должен объяснить, зачем. Мне нечего было ответить, поэтому резюме, профиль, база вакансий и сессия LinkedIn лежат в папке рядом с exe и никуда не уходят.
Причина третья: цена входа. Как только появляется ключ, появляется инструкция «зарегистрируйтесь, привяжите карту, положите $5». Программу, которую нужно настраивать полчаса, человек в активном поиске работы просто не запустит. Скачал, распаковал, кинул резюме — вот весь бюджет внимания, который у него есть.
В конфиге, честности ради, есть переключатель MATCHER_BACKEND = "llm". Он печатает предупреждение и работает как keywords. Мне до сих пор интересно, насколько LLM-скоринг реально лучше весового, — но это будет отдельный эксперимент с замером, а не «добавил ИИ, потому что 2026 год».
Где брать вакансии, если у тебя нет ключей
Это самая скучная и самая важная часть проекта. Ключей нет — значит, источники должны быть публичными. Их десять групп и 24 коллектора, все опрашиваются параллельно:
Группа | Что внутри |
|---|---|
| Greenhouse, Lever, Ashby, Recruitee, SmartRecruiters, Workable — прямой опрос досок 111 компаний |
| RemoteOK, We Work Remotely, Arbeitnow, Remotive, Jobicy, Working Nomads, Himalayas, The Muse, тред «Who is hiring» на Hacker News |
| Workable-поиск по всем careers-страницам и Empllo — удалёнка поиском, а не фидом |
| NoFluffJobs (Польша), getmatch, Landing.jobs (ЕС), Djinni и DOU, Habr Career, GeekJob |
| вакансии через гостевой доступ, без cookies |
| посты «мы нанимаем» — по вашей сессии |
| публичные каналы |
| публичные посты threads.com |
| 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% не возьмёт никто. Обязательные — это язык и фреймворк. Всё остальное — в желательные: они добавляют очки, но никогда не блокируют.
Лестница контактов и правило, которое дороже всей остальной архитектуры
Для вакансий выше порога программа идёт по ярусам и останавливается, как только набрала три контакта:
контакты прямо в тексте вакансии — самый дешёвый и на удивление результативный ярус;
страницы компании — careers, team, about, people;
профили LinkedIn — единственное место, где есть выбор, кого искать;
публичная GitHub-организация компании;
веб-поиск как последний ярус.
Третий ярус важнее, чем кажется, потому что роли выбираются от вакансии: под 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. Отлично же.
И вот здесь я остановился. Потому что «доска ответила вакансиями» — это доказательство того, что доска существует, а не того, что она принадлежит той компании. А имена компаний не уникальны.
Вытащил названия и локации вакансий с каждой подобранной доски:
Что предложил инструмент | Кто это на самом деле |
|---|---|
| Ramp, американский финтех, Нью-Йорк |
| CLEAR, американская identity-компания |
| Bird. Самокаты |
| Symphony Communication (Нью-Йорк) |
| компания из здравоохранения в Сан-Франциско |
| робототехника |
Выглядело это так:
### 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 делает всё:
тесты — 345 штук, без сети и без ключей: ответы источников подставляются фикстурами в той форме, в какой их реально отдают API;
сборка PyInstaller;
--selftestвнутри собранной сборки — самый ценный шаг: он гоняет парсеры PDF/DOCX, анализатор резюме и запуск браузера уже из замороженного exe. PyInstaller ломается ровно там, где обычные тесты зелёные: пропавший ресурс, не доехавший драйвер, путь, которого после заморозки не существует;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-скоринг у весового настолько, чтобы оправдать ключ.
Если запустите на своём резюме — напишите, что получилось и что сломалось. Особенно интересны рынки, которых я не касался: у любого источника, который я не проверял руками, шанс на молчаливую поломку примерно стопроцентный.

