Шесть дней назад я рассказал здесь про resume2human — программу, которая из резюме делает список вакансий и по каждой находит 1–3 живых человека с именем, ролью и адресом, чтобы написать им напрямую. За неделю в проект уехал 21 коммит в девяти PR, тестов стало 746 вместо 345, и я нарушил ровно половину обещаний, которые дал в том тексте. Внутри: бесплатная ступень поиска личных контактов, которая держится на git вместо платных баз; три бага, из‑за которых прогон честно искал не то, что просили; и разбор того, почему покрытие пустого множества равно единице и что это стоило мне в скоринге.
Для тех, кто первую статью не читал
Одним абзацем. Отклик через форму — это письмо в ящик, который читает скрипт; работает другое — письмо конкретному инженерному менеджеру или рекрутеру. Проблема у прямых писем ровно одна: найти релевантную вакансию — полчаса, найти человека за ней — ещё полчаса, и руками это не масштабируется. resume2human — Windows‑приложение, которое берёт ваше резюме, обходит 24 коллектора (ATS‑доски 111 компаний, региональные доски, LinkedIn, Telegram, Threads, веб‑поиск), считает объяснимое совпадение резюме с вакансией и приносит по каждой подходящей 1–3 человека с контактами. Без ИИ, без API‑ключей, без автоматической рассылки. Письмо пишете вы.
Дальше — только то, что изменилось за неделю, и почему.
Половина обещаний из прошлой статьи больше не действует
В той статье был раздел «Чего инструмент не делает — и не будет». Вот что с ним стало через шесть дней:
Обещание | Статус | Что произошло |
|---|---|---|
Не рассылает письма за вас | В силе | И не будет. Причина ниже, она не изменилась |
Не генерирует адреса по шаблону | В силе, с оговоркой | Ступень догадок появилась, но выключена по умолчанию и подписана отдельно |
Не ведёт статусы откликов и не заменяет CRM | Сломано | Воронка, статусы, напоминания, шесть аналитических разрезов |
Не отправляет резюме ни в один сервис | В силе |
Про третью строку скажу прямо: это была не принципиальная позиция, а граница, которую я провёл, потому что до неё не дошли руки. Она стояла в SPEC § 1 с самого начала, таблица applications лежала в схеме пустой с первого дня. Стоило самому запустить инструмент несколько раз подряд, как выяснилось, что почти всё, чего не хватает на практике, — это одна и та же дыра: программа была поисковиком, который заканчивается файлом, и что произошло с вакансией дальше, не знала вообще ничего.
Хуже другое. Обнаружил я это не сам, а когда пошёл обновлять витрину проекта и увидел, что README обещает читателю ровно то, что уже неправда. Между «инструмент не делает X» и «инструмент делает X» прошло два PR и ни одного изменения в документации. Отдельный коммит недели — про то, чтобы витрина не врала; о нём в разделе про доверие.
Главное за неделю: адрес человека лежит в его же коммитах
Первая версия лестницы контактов отвечала на вопрос «кто здесь нанимает». На втором вопросе — «а как этому человеку написать» — она останавливалась: в отчёт попадал человек с именем, ролью и ссылкой на профиль, по которой отправить ничего нельзя.
Есть целая индустрия, которая закрывает ровно этот шаг: contactout, lusha, apollo, rocketreach — арендованная база плюс подписка. Я день смотрел в эту сторону, а потом сформулировал задачу иначе: какую долю этих адресов человек опубликовал сам, своими руками, в открытом месте?
Для инженеров ответ оказался неприлично высоким, и вот почему.
Git — открытая база инженерных контактов, про которую все забыли
Каждый коммит несёт author.email. Не «может нести» — несёт, поле обязательное. Человек может спрятать почту в настройках профиля GitHub, но коммит, который он запушил три года назад со своего ноутбука — до noreply‑режима и до того, как он вообще задумался о приватности, — лежит в публичном репозитории и отдаётся обычным GET /repos/{owner}/{repo}/commits без единого ключа.
Отсюда ступень collectors/person_contacts.py. Она идёт после лестницы и только по тем троим, кто реально попадёт в отчёт:
GitHub: профиль → публичные push-события → верхние репозитории ↓ GitLab: профиль, сайт, организация ↓ Telegram: своя сессия, MTProto ↓ Поиск с открытием страниц ↓ hunter.io ← платное, последним, и только если есть ключ
Порядок здесь и есть решение. Не «сначала самый точный источник», а сначала бесплатный и ни к кому не привязанный. Платный провайдер добивает пробел, а не формирует результат: без ключа fromhunter — no‑op, и остальные четыре канала работают ровно так же. Это прямое следствие требования из первой статьи: как только у инструмента появляется обязательный ключ, появляется и инструкция «зарегистрируйтесь, привяжите карту, положите $5», а человек в активном поиске работы такую программу просто не запустит.
Три уточнения дали больше всего попаданий:
Публичные push‑события.
GET /users/{login}/events/publicотдаёт последние пуши вместе с объектами коммитов, а в них — адрес автора. Профиль пустой, события говорящие. Один запрос.Собственные репозитории, а не форки. Если ни в профиле, ни в свежих событиях адреса нет, дочитываются верхние по времени пуша репозитории человека и берётся самый ранний коммит. Первый коммит своего pet‑проекта почти всегда сделан до того, как человек настроил
user.emailаккуратно, — там лежит настоящий личный адрес, а не корпоративный. Разница принципиальная: рабочий ящик перестаёт работать в день увольнения, личный — нет, поэтому личный адрес в отчёте подписан отдельно.Открытие страницы вместо сниппета. Самый большой рычаг находимости вообще. Если результат выдачи называет человека по имени, но контакт в сниппете обрезан — страница скачивается целиком и дочитывается. Адрес почти всегда на странице, а не в её кратком пересказе. Соцсети и логин‑стены не открываются, бюджет ограничен:
PERSON_LOOKUP_MAX_PAGE_FETCHES = 6на прогон.
Половина модуля — это отбраковка, и иначе нельзя
Ошибка здесь выглядит как успех. Письмо уходит однофамильцу, вы об этом никогда не узнаете, а человек на том конце получает резюме от незнакомца, который перепутал. Поэтому у каждого канала своё правило принадлежности:
Канал | Принимается, только если | Достоверность |
|---|---|---|
Профиль GitHub | совпало имя и компания | verified |
Адрес из коммита | совпал | verified |
Профиль GitLab | компания подтверждена профилем или доменом сайта | verified |
Telegram, резолв хэндла | имя аккаунта совпало с именем человека | verified |
Telegram, поиск по имени | имя совпало и | likely |
Адрес из выдачи | локальная часть адреса выводится из имени | likely |
Телефон | международный формат и страница называет человека по имени | likely |
hunter.io | достоверность приходит от провайдера | как отдал провайдер |
Правило из models/contact.py осталось нетронутым: ничего не синтезируется. Программа не показывает адрес, которого она не видела напечатанным. У каждого контакта в отчёте стоит метка провенанса — [personal_github/verified], [search/likely], — и это не украшение: по ней видно, писать ли уверенно или проверить глазами.
А это законно и этично?
Вопрос, который в комментариях будет обязательно, поэтому отвечаю заранее и без уверток.
Всё, что читает эта ступень, человек опубликовал сам: профиль на форже, коммит в публичном репозитории, страницу «команда» на сайте компании, свой @хэндл. Никаких утечек, никаких арендованных баз, никакого перебора SMTP (об этом ниже отдельно). Данные не складываются в базу на продажу — они живут в локальном SQLite рядом с exe, и удалить их значит удалить папку.
Дальше начинается место, где законность и приличия расходятся. GDPR при legitimate interest не запрещает написать человеку по опубликованному адресу, но это не значит, что можно писать всем подряд. Разница между «нашёл почту инженерного менеджера вакансии, на которую подхожу, и написал по делу» и «выгрузил контакты отдела и запустил рассылку» — не техническая. Инструмент удерживает её единственным способом, который работает: массовой рассылки в нём нет и не будет. Три человека на вакансию, копипаста, письмо руками.
И да, это ровно то ограничение, которое я оставил себе в прошлой статье и не тронул на этой неделе, хотя ослабить его было проще всего.
Telegram: не бот, а своя пользовательская сессия
Ступень, которая дотягивается туда, где открытого веба нет.
Ботом это не делается принципиально: боту недоступны ни резолв @username в живой аккаунт, ни поиск людей. Значит — MTProto через telethon и ваша собственная сессия, ровно как с LinkedIn: вход из окна, код приходит вам, пароль двухфакторной вводите вы, сессия лежит рядом с exe и никуда не уходит.
Два режима:
подтверждение хэндла — найденный выше
@handleрезолвится в аккаунт, сверяется имя, и если человек открыл телефон, забирается и он.verified;поиск по имени (
contacts.Search) — когда хэндла ещё нет. Принимается только аккаунт, чьё имя совпало и чей@usernameпостроен на имени.likely.
По умолчанию выключено: PERSON_LOOKUP_TELEGRAM_ENABLED = False. Без telethon, без ключей и без сессии get_telegram_lookup() отдаёт None, ступень — no‑op. Сессия живёт процессом‑синглтоном в духе LinkedIn: ленивый старт, лок, FloodWait гасит ступень на весь прогон, закрытие — в scraper.close().
Отдельная история — как это включается. Раньше вход в Telegram означал три переменные в .env и однострочник на telethon в терминале: единственный credential во всём проекте, который нельзя было ввести — только вписать в файл. Для программы, которую скачивают zip‑архивом и запускают двойным кликом, это то же самое, что «функции нет». Теперь в главном окне есть карточка «Telegram»: api_id, api_hash, номер, код, пароль 2FA. Клиент telethon живёт на воркере между «получить код» и «войти», потому что между этими двумя шагами стоит человек, и держать соединение приходится через паузу произвольной длины. Ошибки telethon разложены по человеческим фразам («Код устарел — запросите новый», FloodWait с числом секунд), а не улетают трейсбеком.
Заодно поменялась вся левая колонка окна. Было: LinkedIn, Резюме, Что искать, Где искать — то есть первым в окне стояло то, что настраивают один раз и больше не трогают. Стало: Резюме, Что искать, Где искать, LinkedIn, Telegram. Колонка читается сверху вниз, а входы лежат внизу, под фильтрами, которые они кормят.
И маленький позор недели. На кнопку «Установить telethon» собранный exe отвечал моим же текстом: «собранное приложение не умеет доустанавливать библиотеки». Правильный ответ — не чинить кнопку, а сделать её ненужной: telethon уехал в requirements.txt и внутрь exe (collect_submodules в spec — импорт ленивый, и анализ PyInstaller его не видит), а кнопка теперь показывается только там, где может сработать: при запуске из исходников. Наличие telethon в бандле проверяет --selftest, рядом со всем остальным, что молча выпадает из сборки.
Скоринг раздавал 50 очков даром
Теперь про то, ради чего я вообще сел писать вторую статью.
В матчере была строчка, которая выглядела как безобидный дефолт. В двух местах — в быстром скоринге и в полном вердикте:
must = profile.must_have_skills or profile.skills[:4]
Читается так: «если гейт обязательных навыков не задан, возьмём первые четыре навыка, которые вернул парсер». Звучит разумно. На деле здесь два бага, и второй хуже первого.
Баг первый: половина оценки висела на порядке списка. Какие четыре навыка гейтят вакансию — это решение. skills[:4] делегирует его порядку вывода парсера. Резюме, у которого в списке навыков вторым языком случайно оказался Python, начинало матчиться на Python‑вакансии как на обязательные.
Баг второй: покрытие пустого множества равно единице. Если must_have_skills пуст и skills пуст или короче — доля покрытых обязательных навыков считается как «все, что были, покрыты», то есть 100%. А must‑have — это половина шкалы. Профиль совсем без гейта получал 50 очков из 100 просто за то, что он профиль, и порог в 60% превращался в формальность: добрать десять очков по nice‑to‑have может почти любая вакансия.
Теперь ключевое, из‑за чего это не заметили тесты: гейт назначался ровно на одном пути. Файл resume.md → profile.json проходил через обогащение, где решение о must‑have принимается один раз и осознанно. А резюме, загруженное в окно или отправленное боту, сохранялось сырым — прямо из парсера: must_have_skills пуст, domains пуст, deal_breakers пуст. То есть на всех прогонах из приложения фильтр по стоп‑словам не работал вообще, а половина оценки раздавалась даром. Разработчик тестировал CLI, пользователь запускал окно, и это были два разных скоринга.
Починка вышла короче диагноза: gating_skills() берёт только объявленные поля, а при пустом гейте доля must‑have переезжает на nice‑to‑have, а не раздаётся. profile.loader.enrich() вызывается на всех входах, идемпотентен — и правленый руками profile.json проходит через него нетронутым.
Мораль: x or fallback в скоринге — это не дефолт, это второй, необъявленный алгоритм, который никто не тестирует. И если ваша метрика — доля покрытия, проверьте отдельно, чему она равна на пустом множестве. Математически там единица. По смыслу — «данных нет». Это очень разные числа, чтобы умножать их на половину шкалы.
Локация не может быть слагаемым
Второй баг того же прогона, ещё более обидный, потому что бьёт по самому заметному.
Локация участвовала в оценке как слагаемое: промах стоил несколько очков из двенадцати. Формально честно — «место не идеальное, но вакансия сильная». На практике прогон для кандидата, который живёт в Сербии и указал это в трёх полях, возвращал вакансию в Мехико с процентом выше порога. Потому что стек совпал на отлично, а минус за континент — это минус пять очков.
Локация стала фильтром. Вакансия проходит, если названа в одной из ваших стран, в более широком рынке, который вашу страну содержит, или если вы указали рынок, а она — страну внутри него. Дальше — детали, которых оказалось неожиданно много:
удалённость не основание. «Remote, United States» — это работа в США, с их часовым поясом и их правом на трудоустройство;
место, не указанное вовсе, вакансию сохраняет. Как и локация, которой нет в таблице соответствий. Пропуск стоит одной лишней строки в отчёте, а лишняя запись молча удаляет нужную. Асимметрия цены ошибки здесь очевидна, и фильтр настроен по ней;
сравниваются страны, а не написания (
config.REGION_ALIASES), а двухбуквенные коды — только как целое поле. Иначеdeнаходится вRio de Janeiro;галочка входит в подпись скорера. Иначе кэш вердиктов пережил бы её переключение, и выключенный фильтр продолжал бы фильтровать вчерашними ответами;
сколько вакансий отсеяно, пишется в футер отчёта. Фильтр, который молча вырезает треть прогона, неотличим от сломанного источника.
На 35 вакансиях из моей базы: 33 → 27, шесть ушли по локации — Прага, Бангалор, Сан‑Франциско, Коимбра, Мехико, США. Заодно тредс‑спам, который набирал 67% и исправно попадал в отчёт, перестал проходить порог.
Один профиль на приложение
Третий баг того же семейства и, наверное, самый неловкий.
Кнопка «Поиск» в окне читала профиль из базы. Прогон по расписанию и CLI читали profile/profile.json. Копии расходились молча: в файле лежал гейт, правленые руками стоп‑слова и домены, в базе — то, что вернул парсер, и ничего больше. Один и тот же человек с одним и тем же резюме получал два разных прогона в зависимости от того, откуда нажал.
Окно теперь пишет и читает тот же файл, а профиль, оставленный в базе старой сборкой, переносится в него при первом чтении. Никакой миграции руками: тот, кто скачает новую версию поверх старой папки, ничего не заметит.
Рядом жил ещё один баг того же класса, который я нашёл только когда сам снял галочку источника и не увидел разницы. Кнопка «Найти вакансии» не применяла форму. Пайплайн читает параметры из search.json, а виджеты попадали туда только после отдельной кнопки «Сохранить» — в другой панели, ниже самой кнопки поиска. Вы снимаете источник, жмёте «Найти» и получаете прогон по прошлым параметрам, глядя на новые. То же касалось ключевых слов, локаций, должностей и порога.
Три бага, одна форма: у одного факта было два места хранения, и программа сама не знала, какое из них правда. Если ищете похожее у себя — начните с полей, которые пишутся из двух разных путей.
Три бага не про домен
Неделя, в которой чинишь много, всегда даёт коллекцию находок, не имеющих отношения к предметной области. Эти мне нравятся больше всего.
Флаки‑тест, который врал не про то, что проверял
Сборка на main упала на test_each_refusal_doubles_the_pause. Тест был флаки с самого начала и падал от округления double.
Пауза кладёт monotonic() + delay. На Windows monotonic() идёт тиками по 15.6 мс, поэтому оба вызова в тесте читают один и тот же момент, и разница дедлайнов — ровно две задержки. Ровно, если бы не плавающая точка: при аптайме 596.093 с «+300» ложится на 896.093, «+600» — на 1196.0929999999998, и разность выходит 299.9999999999999. Тест требовал «не меньше 300» и падал на машине, у которой аптайм округлился не в ту сторону. Числа из лога CI воспроизвелись один в один.
Часы заморожены, и утверждение стало сильнее: первая пауза равна константе, вторая вдвое больше, а двенадцатая упирается в потолок, а не продолжает удваиваться. Последнее раньше не проверялось вовсе — то есть тест, который падал, ещё и не ловил настоящую ошибку.
Argument list too long — и витрина со ссылками на девять несуществующих картинок
Публикация витрины упала так:
/usr/bin/gh: Argument list too long Error: Process completed with exit code 126.
Шаг заливал файлы через -f content="$(base64 -w0 "$file")". У Linux есть предел на длину одного элемента argv — MAX_ARG_STRLEN, 128 КиБ, и он не настраивается ни ulimit, ни чем‑либо ещё. Base64 добавляет треть, поэтому любой файл тяжелее ~96 КБ ронял шаг. Из двенадцати файлов витрины лимит превышали ровно два — главный скриншот окна в обоих языках: 111 КБ на диске, 148 244 байта после base64 против 131 072 разрешённых.
Хуже самого падения то, как оно падало. Файлы заливаются по одному, и к моменту ошибки README на двух языках и одна картинка уже уехали: публичный репозиторий остался с новым README и ссылками на девять картинок, которых там нет. Частичный деплой хуже упавшего.
Тело запроса теперь собирает отдельный скрипт и отдаёт файлом через gh api --input: в argv уходят только путь, строка коммита и blob sha, а содержимое читается с диска и печатается в stdout, минуя и командную строку, и переменные окружения — на длину строки в окружении лимит тот же.
Ctrl+C, который не работает на русской раскладке
Из журнала приложения нельзя было скопировать текст. Панель тут ни при чём: Tk привязывает <<Copy>> к букве c, а не к клавише, и при русской раскладке Ctrl+C приходит как Control-Cyrillic_es и не совпадает ни с чем.
В окне, которое целиком по‑русски, это не крайний случай, а обычное состояние клавиатуры. И ломало оно не только журнал: Ctrl+V в поле, куда вставляют резюме, и во всех полях с токенами — там же. То есть первый шаг сценария («вставьте резюме») не работал у пользователя с русской раскладкой, и я узнал об этом на седьмой день.
Починка — обработчик, который читает код клавиши и отвечает только за нажатия, которых Tk не увидел (для латиницы возвращает None, иначе вставляло бы дважды). Привязка одна, на теге all — чинит все поля и панели разом.
i18n без gettext: русские строки сами себе ключи
Английский интерфейс попросили в первый же день. Проект написан по‑русски: строки в исходниках, комментарии, отчёты. Стандартный путь — gettext, _() и вынос всех строк в каталог с искусственными ключами вроде btn.search.label.
Я сделал иначе: русская строка и есть ключ.
ttk.Button(row, text=t("Найти вакансии"), ...)
Кнопка по‑прежнему читается как кнопка. Никакого файла, в котором btn.search.label превращается в загадку через полгода. Английский каталог лежит рядом — 552 записи на сегодня.
Интересное здесь — тест. tests/test_i18n.py обходит исходники за каждым t("…") и за таблицами, чьи значения служат ключами, и падает:
на непереведённой строке;
на осиротевшей записи каталога (строку в коде переписали, перевод остался);
на потерянном
{placeholder}— классика, когда в переводе теряется подстановка и падает ужеformat()у пользователя;на модуле, который зовёт
t, не импортировав его. Последнее сразу нашло живойNameErrorвprofile/search.py— код, который упал бы на первом же вызове.
Язык — обычная настройка, поэтому переключают его все пути разом: окно, правленый руками settings.json, кнопка «Вернуть по умолчанию» и сам импорт конфига. Применяется на месте: главное окно и открытое окно настроек выбрасывают свои виджеты и собираются заново (tkinter не умеет менять текст готового виджета пачкой), а резюме, параметры и результаты последнего поиска переживают переключение.
Первый баг перевода нашёлся ровно там, где больнее всего: блок «вот что я понял» собирался f‑строками, и в английском окне под заголовком «Here is what I understood» стояло «Позиция: … Грейд: … Стек: …». Это первое, что видит человек, переключивший язык, — и читается как недоделанный перевод, чем оно и было.
Бот остался русским намеренно: у бота много пользователей в разных чатах, и одна настройка языка на установку — неправильная модель для него.
Отклики: воронка, два шаблона и интервал Уилсона
То самое сломанное обещание. Раз уж граница переписана, то по‑честному.
Лестница статусов: new → written → replied → screening → interview → offer, плюс терминальные rejected/skipped. Шаг вперёд на любое число ступеней разрешён, назад — нет: «разответить» письмо нечем, а исправление ошибки — это отмена, а не откат.
Несколько решений, которые считаю неочевидными:
Ключ — хэш вакансии, а не rowid.
idв базе не переживает ни переустановку, ни импорт чужого архива, а хэш одинаков в базе, в архиве и в отчёте.Статус «молчат» выводится, а не хранится. Это
writtenплюс тишина дольшеGHOSTED_AFTER_DAYS = 21. Статус, который надо ставить руками, не ставит никто, и воронка тихо врёт в самом важном месте.Статус не пишется в архив прогона. Архив — снимок, и он честен тем, что не меняется. Вмороженный статус превратил бы отчёт недельной давности в файл, уверенно показывающий «написал» там, где уже пришёл отказ. Отклики накладываются на отчёт при отрисовке.
Шаблонов письма два с самого начала. Один шаблон — это не A/B, это просто шаблон. Когда после «Скопировать письмо» нажимают «Написал», версия записывается в отклик — без этого разрез «по версии сообщения» неизмерим в принципе.
Процент без размера выборки не показывается. Ниже восьми писем строка говорит «мало данных», выше — печатает процент,
nи интервал Уилсона. На тридцати откликах разница «17% против 11%» — шум, и решение по ней хуже случайного, потому что оно ещё и уверенное. Это, пожалуй, единственное место, где я сознательно сделал продукт менее «красивым»: пустые проценты в дашборде выглядят солиднее честного «мало данных».Ступени считаются буквально, по истории переходов. «Интервью подразумевает скрининг» — неправда: на интервью зовут и без него. Засчитанный скрининг превратил бы «эту стадию у меня перепрыгивают» в «эта стадия у меня есть».
Полосы воронки нарисованы на
tk.Canvas. matplotlib добавил бы десятки мегабайт поверх ста восьмидесяти четырёх ради семи прямоугольников.
Рядом приехало расписание: JobHunter.exe --scan — тот же прогон без окна, задача создаётся из настроек. Только «когда пользователь вошёл в систему»: иначе нужен сохранённый пароль учётки, а запуск от SYSTEM ломает пути и Playwright. LinkedIn в фоновом прогоне выключен — браузерная ступень ночью без пользователя даёт только риск для аккаунта и ничего больше.
Отчёты, которые открываются обратно
Поиск заканчивался файлом и ничего больше: окно показывало прогон, который только что закончился, и забывало его при следующем. Прошлая неделя — с уже найденными людьми — существовала только текстом в data/reports, где с ней нечего делать, кроме как прочитать.
Теперь каждый прогон кладёт два файла с одним именем: документ для чтения и .json‑архив рядом. Читает человек первый, открывает обратно программа — второй, потому что ни .txt, ни таблица прочитаться назад не могут: у первого проза, у второй к моменту, когда она стала сеткой, нет ни вердиктов, ни диагностики, ни счётчиков прогона. Импортированный отчёт рендерит тот же код, что и свежий поиск, — поэтому работают и сортировка, и «Копировать карточку».
Формат на выбор: txt, csv, xlsx. Таблица — строка на человека, вакансия повторяется: единственный смысл открывать отчёт в Excel — фильтровать, а многострочная ячейка с людьми не фильтруется.
Excel — без новой зависимости: минимальный workbook на zipfile и стандартной библиотеке, 200 строк (жирная замороженная шапка, автофильтр, процент числом, а не строкой). Проверено чтением через openpyxl, и --selftest внутри собранного exe теперь гоняет все три формата: в замороженной сборке это ровно тот класс вещей, который ломается молча.
Доверие: SHA-256, подпись и 404 в самом важном абзаце
Скачать zip с exe от незнакомца с Хабра — сомнительное удовольствие, и я это понимаю. За неделю в эту сторону сделано следующее.
Хеш, который можно проверить. До этого README витрины отправлял за SHA-256 на страницу Actions приватного репозитория. Для всех, кроме меня, ссылка отдавала 404 — ровно в том абзаце, где предлагается проверить, что файл не подменили. Теперь хеш считается из скачанного артефакта, пишется в текст релиза, а README показывает команду Get-FileHash.
Подпись exe. Set-AuthenticodeSignature штатным PowerShell, без Windows SDK и signtool, — сборка по‑прежнему требует только Python. С меткой времени RFC 3161: без неё подпись перестаёт проверяться в день, когда истечёт сертификат. В CI шаг включается секретами, а без них печатает notice и пропускается: сборка на форке не должна падать из‑за отсутствия чужого сертификата.
И сразу честная часть: сертификат самоподписанный, и SmartScreen у чужого человека он не убирает. Он говорит «файл не менялся с момента подписи», а не «издателю можно доверять». Так и написано в README — а то был соблазн написать «подписано» и остановиться.
Что ещё проверяемо без веры в написанное: сборка не руками, а из CI по коммиту; --selftest внутри собранного exe; отсутствие телеметрии; исходники витрины и скриншоты уезжают из репозитория, а не собираются вручную.
Про скриншоты отдельно. Скриншот устаревает молча: кнопку переименовали, колонку добавили — картинка осталась прежней и врёт, а замечает это первый скачавший. Поэтому оба набора (RU и EN, по пять картинок) собирает скрипт одной командой. Он копирует исходники во временную папку и запускает окно там: прогон ради картинок не должен затирать настоящую базу и настоящие параметры поиска. Данные вымышленные, домены на .example — в публичный репозиторий уезжают именно эти картинки, и настоящему контакту там делать нечего.
Резюме‑парсер перестал быть бэкендским
Признание, которое стоило написать раньше: таксономия навыков была заточена под мой собственный стек. Java, Spring, Kafka, PostgreSQL. Любое другое резюме — фронтенд, мобилка, data/ML, QA — теряло половину ключевиков, и подбор вакансий по ним был заметно хуже. Инструмент, который «работает для всех», работал для меня.
Два изменения:
Захват незнакомых объявленных навыков. Новый
extract_declared_termsчитает секцию навыков и inline‑строки «Stack:/Навыки:» и вытаскивает термины, которых в таксономии нет вообще:svelte,airflow,rstudio,sqlalchemy. Они идут в профиль с весом ниже распознанных, но ненулевым — так вакансия, называющая ровно этот термин, всё равно набирает очки. Прозу под заголовком «Skills» парсер не кромсает: фильтр по стоп‑словам, длине и числу слов.Расширенная таксономия: языки (swift, dart, elixir), фреймворки (next.js, svelte, flutter, pytorch, tensorflow, spark, airflow), базы (dynamodb, snowflake, bigquery), cloud/devops (helm, ansible, argocd), практики (tdd, qa automation, oauth, bi).
И попутно — точность сопоставления. Незнакомый навык теперь ищется по границам слова, а не подстрокой: java больше не матчит javascript, go не матчит category. Ультракороткие c и r из таксономии убраны вовсе — bare c ловит «C.», «Plan C» и вообще всё; их экосистема подхватывается как объявленные термины.
Числа недели
Было (2 сентября) | Стало (8 сентября) | |
|---|---|---|
Тестов | 345 | 746 |
Коллекторов вакансий | 24 | 24 |
Каналов поиска личных контактов | 0 | 5 |
Из них требуют платного ключа | — | 1, последний |
Форматов отчёта | txt | txt, csv, xlsx |
Языков интерфейса | 1 | 2 |
Настроек, правимых из окна | 0 | 59 |
Трекинг откликов | нет | воронка + 6 разрезов |
Запуск по расписанию | нет | есть |
Строчка про коллекторы — намеренно. Новых источников вакансий за неделю не добавилось ни одного: всё ушло в то, чтобы уже собранное не врало.
Честные ограничения (обновлённые)
Раздел, без которого это была бы реклама.
Бесплатный пробив находит не всех. Он опирается на то, что человек публиковал сам. У бэкендщика с активным GitHub шансы отличные, у рекрутера без публичного кода — заметно хуже, и там честный ответ — LinkedIn или ничего. Никакого «100% покрытия» здесь не будет и быть не может: это свойство данных, а не парсера.
Проб SMTP нет и не будет. Самый частый способ «проверить» угаданный адрес —
RCPT TOна почтовом сервере. Он не работает: Workspace и M365 принимают всё, порт 25 у домашних провайдеров закрыт, а пробы ведут к грейлистингу вашего же IP. Поэтому угаданные адреса никогда не попадают в список «Люди» и печатаются отдельным блоком со словом «не проверены», а вся ступень догадок выключена по умолчанию.Скоринг всё ещё не понимает контекст. Весовой алгоритм по ключевым словам: «Java» в требованиях и «Java» в перечислении технологий команды для него одно и то же. Он объясним и бесплатен, но он тупой, и иногда это видно. За неделю он стал честнее (гейт, локация), но умнее не стал.
Веб‑поиск ломается. DuckDuckGo отдаёт антибот‑страницу уже со второго запроса с одного IP, Bing молча игнорирует
site:. Движки уходят на паузу с удвоением, запрос достаётся следующему, а в отчёте появляется предупреждение — потому что молча пустой список людей выглядит как «в этих компаниях никого нет».LinkedIn по‑прежнему против ToS. Работает на вашей собственной сессии, с жёсткими бюджетами пауз и потолком переходов за прогон, полностью опционален — и риск для аккаунта существует. Делать вид, что его нет, я не буду.
Windows. Готовая сборка — под Windows 10/11. Из исходников запускается везде, где есть Python 3.11+.
Она не найдёт вам работу. Она экономит те самые «полчаса + полчаса» на каждую вакансию. Письмо всё ещё пишете вы, и оно всё ещё главное.
Как попробовать
Скачать (Windows, Python не нужен): resume2human‑windows.zip
Распаковать архив целиком — внутри папка, а не один файл, exe без соседей не стартует, — и запустить JobHunter\JobHunter.exe. SHA-256 архива — в тексте релиза, проверяется одной командой:
Get-FileHash .\resume2human-windows.zip -Algorithm SHA256 JobHunter.exe --selftest # результат в data\selftest.log
Всё, что программа создаёт, — база, отчёты, сессии LinkedIn и Telegram — появляется в папке рядом с exe. Удалить = удалить папку.
Репозиторий, README на двух языках и релизы: https://github.com/ialakey/resume2human
Что дальше
Ещё форжи для бесплатного пробива. Bitbucket, Codeberg, sourcehut, публичные mailmap. Логика уже обобщена, каждый новый — это одна функция и правило принадлежности.
Адаптеры под Workday, Comeet, Gupy, PeopleForce. На них по‑прежнему отваливаются 83 компании из моего списка.
Честный замер матчера. Второй бэкенд на LLM и сравнение: выигрывает ли он у весового настолько, чтобы оправдать ключ. После истории с
skills[:4]мне гораздо интереснее не «какой алгоритм умнее», а сколько ещё дефолтов раздают очки даром.
Если запустите на своём резюме — напишите, что нашлось и что сломалось. Особенно интересны не‑бэкендские резюме: расширенная таксономия проверена на моих фикстурах, а не на вашем реальном CV, и шанс молчаливой поломки там высокий.
И два тезиса, с которыми я готов спорить в комментариях, — оба про эту неделю:
Бесплатный пробив контактов через git закрывает большую часть задачи, ради которой покупают подписки. Не всю. Но ту часть, которая нужна человеку, ищущему работу для себя, — да.
x or fallbackв любой метрике — это скрытый второй алгоритм. Он не документирован, не тестируется и включается ровно у тех пользователей, которые пришли не тем путём, что разработчик.
Если считаете иначе — я слушаю.

