ESPN DeportesRays saluda al abridor Sandoval con jugoso rally de cuatro carrerasRTP DesportoManchester United empata e iguala registo negativo de Louis van GaalESPNSources: York won't attend Niners' home opener as NFL discipline loomsDaily MaverickLONG ARM OF THE ’LORD’: How ‘SA’s top gang boss’ represents a caving criminal justice systemTagesschau++ Liveticker zur Berlin-Wahl: Klingbeil kündigt Kurskorrektur an ++Straits Times SportJuventus beat Atalanta as Frosinone fairytale run continuesWirtualna PolskaStrzelanina w Bukownie. Trwa policyjna obławaEngadgetRetroid Pocket unexpectedly expands its Duo lineup with a Lite Plus version7sur7La Russie va “intensifier” ses attaques sur Kiev en réponse aux attaques de dronesRMF24Są wyniki exit poll w Rosji. Zaskoczenia nie maOnetWybory parlamentarne w Rosji. Znamy wyniki exit pollRadio-CanadaAllemagne : des élections régionales à haut risque pour le chancelier Merz
The Daily Newsstand · Free, Always
Sunday, September 20, 2026

Откликов все больше, ответов все меньше. Что показали 16 000 вакансий в моей базе

Translate

Всем привет! Этой весной я искал работу, и в какой-то момент пришел к тому, что на сами отклики у меня уходит минут тридцать, а весь остальной вечер я трачу на то, чтобы просто обойти площадки и посмотреть, не появилось ли чего нового. То есть я не работу искал, я вкладки открывал.

Вечером открываешь hh, листаешь. Потом пару телеграм каналов, где вакансия тонет за час. Потом вспоминаешь компании, куда хотелось бы попасть, и идешь по их карьерным страницам руками, а их штук пятнадцать, и на половине ничего не меняется неделями. Половину ты уже видел вчера. Закрыл вкладки, на завтра все то же самое.

Надоело, и в июне я написал себе скрипт. В июле из него вырос сервис, который с тех пор работает каждый день. Сейчас в базе больше 16 000 активных вакансий, и последние месяцы я смотрю на рынок не по ощущениям, а по своим же данным. Ощущения, между тем, мне врали.

Далее две части. Сначала про рынок и про то, что видно из базы, когда вакансии можно посчитать. Потом техническая кухня: что ломалось, что я чинил и какие места, по-моему, полезно знать заранее. Я разработчик, но парсинг, нормализация данных, Postgres, эмбеддинги и инфраструктура были для меня новыми, так что граблей набралось прилично.

И сразу заранее: все цифры ниже про мою базу, а не про рынок целиком. Спорить с ними можно и даже нужно :)

В айти сейчас не лучшие времена, и по вакансиям это видно

Пару лет назад компании бегали за разработчиками, все мы помним золотые времена (2020-2023 года). Сейчас наоборот, и это заметит любой, кто в этом году открывал hh: откликов на одну позицию стало сильно больше, а ответов сильно меньше. Сам процесс тоже растянулся. Пять этапов (привет Яндекс), тестовые, и все это без гарантии, что в конце тебе вообще напишут.

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

А инструменты у кандидата остались те же, что и пять лет назад. Лента, поиск по словам, кнопка «откликнуться». Только лента не знает, что эта вакансия висит четвертый месяц. Поиск не знает, что вот эти пять объявлений на самом деле одно. Кнопка не знает, что компания не ответила ни одному человеку из последних ста.

И получается простая вещь. Когда вакансий много, а ответов мало, выигрывает не тот, кто отправил больше откликов, а тот, кто точнее выбрал, куда их отправлять. А выбирать точнее это уже работа с данными. Ее можно делать руками, а можно кодом. Дальше как раз про это.

Что видно, когда вакансии лежат в базе, а не в браузере

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

Второе. Одно объявление спокойно показывается пять раз. Особенно в телеграме. Там пост расходится по каналам, текст один в один, а компания у каждой копии своя.

Третье. Объявление живет заметно дольше, чем найм по нему. Компания открыла позицию, кого-то взяла, снять это забыла. Или собирает базу резюме. Или держит на всякий случай, вдруг придет кто-то настолько идеальный, что деньги найдутся. Для нас с вами все три случая выглядят одинаково: отправил отклик, сиди и жди.

Сложите это вместе, и станет понятно, почему лента бесконечная, а идти некуда.

Как я считаю, что вакансия никого не ищет

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

Сколько дней вакансия у меня наблюдается. Снимали ли ее и вешали заново, и менялся ли при этом текст. Сколько у компании одинаковых активных объявлений. Какой доле откликов в этой компании вообще ответили. Есть ли вилка.

Сам по себе ни один сигнал ничего не доказывает. Компания может честно искать редкого человека полгода, и это нормально. Но когда объявление висит четвертый месяц, текст ни разу не правился, а отвечают одному из десяти, вечер на сопроводительное лучше потратить как-нибудь поинтереснее.

Тут же была и моя самая глупая ошибка за весь проект: null это не ноль. Если истории наблюдений нет, это не значит, что вакансию не перевешивали. Это значит, что я про нее ничего не знаю. А первая версия уверенно ругала как раз старые записи, по которым данных не было вообще, то есть ровно те, про которые сказать было нечего. Теперь у оценки есть уровень уверенности, и если данных мало, точное число я не показываю.

Я до сих пор не уверен, что пороги подобраны правильно. Я их подбирал глазами, на выборке, которую сам же и смотрел. Если у кого-то есть идея, как это делать не на глаз и без разметки руками, буду рад услышать.

Считается все чистой функцией и SQL, без походов в сеть. С производительностью пришлось повозиться: таблица широкая, в ней вектор под HNSW-индексом, и один большой UPDATE на живом каталоге просто не заканчивался. Сейчас пересчет идет пачками по 50 строк.

Вилка «на 8911% выше медианы»

На странице вакансии я показываю, как зарплата смотрится на фоне рынка. И вот однажды вижу у себя вакансию, где написано, что платят на 8911% выше медианы.

Проблема была в том, что часть источников публикует годовые суммы, а у меня в базе зарплата месячная. Где-то деление на 12 делали за меня, где-то нет, а модель, которая разбирает телеграм-посты, иногда приносила годовую вилку, хотя в промпте прямо сказано так не делать. В итоге 149 500 долларов в год после конвертации превращались в 18,9 миллиона рублей и сравнивались с месячной медианой где-то в 210 тысяч. Красиво, но неверно.

Чинить пришлось в двух местах: и парсеры, чтобы новое приходило уже нормальным, и то, что успело накопиться, а там набралось около 400 строк. Правило приведения теперь живет в отдельном модуле, и бэкофилл с импортом зовут одно и то же. До этого у меня были две почти одинаковые функции в разных файлах, и почти одинаковые они были ровно до того дня, когда я поправил одну.

Один пост, пять каналов, пять «разных» вакансий

С карьерными страницами дедупликация несложная: сравниваю похожесть title и company через pg_trgm, и этого хватает.

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

Пришлось делать второй уровень, по смыслу. Я склеиваю поля вакансии, строю по ним эмбеддинг и храню его в Postgres как vector(384) с HNSW-индексом. Дальше SQL-функция ищет похожие телеграм-вакансии и, если близость выше порога, склеивает их.

За эмбеддингами я поначалу ходил во внешний API, и это было удобно ровно до того момента, когда понял, что часть провайдеров из России недоступна, а те, что доступны, заметно стоят, когда вакансий много. Сейчас у меня локальная Xenova/paraphrase-multilingual-MiniLM-L12-v2 через @huggingface/transformers, крутится прямо в Node, с q8-квантизацией весит около 128 МБ:

const extractor = await pipeline("feature-extraction", MODEL, { dtype: "q8" });
const output = await extractor(text, { pooling: "mean", normalize: true });

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

Откуда вообще берутся вакансии

Раз уж речь зашла. У многих агрегаторов вакансии примерно одни и те же, потому что и источники у них похожие. Мне было интереснее забирать вакансии прямо с карьерных страниц компаний.

С западными проще. Многие сидят на Greenhouse, Ashby, Lever или Workable, а у этих ATS есть публичные API:

GET https://boards-api.greenhouse.io/v1/boards/{company}/jobs?content=true
GET https://api.ashbyhq.com/posting-api/job-board/{company}?includeCompensation=true
GET https://api.lever.co/v0/postings/{company}?mode=json

Никакого headless-браузера и обхода антибота, хватает обычного GET. Сейчас в конфиге 313 таких досок, и новая компания добавляется одной строкой. То есть охват растет вообще без нового кода.

С российскими карьерными сайтами так не выйдет. У каждого своя верстка, и под каждый нужен свой парсер, их у меня 21+. Это самая хрупкая часть проекта. Сайт переезжает на новый дизайн, парсер молча отдает ноль вакансий, и хорошо если я замечу это на неделе, а не через месяц. Мониторинг на «источник вдруг стал давать ноль» я в итоге прикрутил, но это лечение симптома, а не болезни.

С hh я решил не тянуть всю выдачу, она и так есть везде. Беру только 58 крупных работодателей и IT-вакансии стран СНГ. Для Казахстана, Беларуси и Узбекистана пришлось поставить потолок в 10 активных вакансий на компанию, иначе отдельные работодатели съедали слишком большой кусок ленты.

Плюс пара зарубежных агрегаторов удаленной работы: Jobicy, aijobs, remocate. Оттуда приезжают позиции, которых на российских площадках нет вовсе. С них же началась та история с годовыми вилками выше: кто-то делит на 12 сам, кто-то нет.

Обновляется все раз в четыре часа, импорт идемпотентный. Упал запуск, следующий продолжит с того же места.

Скрипт два часа крутился на пяти локациях

Мне нужно было определять страну по текстовому полю с локацией, чтобы работал фильтр «в России или за границей». До этого гео определялось по признаку удаленки, и офисная вакансия в Белграде считалась российской.

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

Первый прогон крутился два часа и упал. Полез смотреть логи, а там одни и те же пять названий, по кругу, все два часа. Оказалось, на поле с локацией не было индекса, а значения вроде «Москва» и «USA» встречаются по несколько тысяч раз каждое, и массовый UPDATE по такому значению упирался в statement timeout. Страна не записывалась, очередь не двигалась, скрипт заново брал те же пять строк и опять в них утыкался.

Индекс я добавил. Обновлять стал маленькими пачками по id, а не одним UPDATE на тысячи строк. И сделал пропуск локации после нескольких неудачных попыток, чтобы одна строка не держала всю очередь. Заодно завел кэш «локация в страну», потому что города повторяются постоянно и считать их заново глупо.

Сухой прогон, который спас 3132 вакансии

Как-то я решил почистить каталог от вакансий с hh, которые больше не подходят под мою логику отбора. По прикидкам должно было удалиться около 27 тысяч. Запускаю сухой прогон, он показывает 30 001.

Три лишние тысячи мне не понравились, и я пошел разбираться. Под нож попадали зарубежные вакансии. С hh я собираю не только Россию, там есть отдельный проход по странам СНГ, а местные работодатели вроде Kaspi.kz, Uzum, Andersen в список российского бигтеха не входят по определению. То есть чистка снесла бы ровно тот кусок, ради которого этот проход и делался.

Отдельная проблема тут вот в чем. Условие я перечитал раз пять, и оно выглядело абсолютно правильным. Дырку показали не глаза, а цифра, которая не сошлась с ожиданием на три тысячи. Так что теперь любой массовый DELETE или UPDATE у меня сначала идет сухим прогоном, и я смотрю не только на список, но и на число: если оно не такое, как я ожидал, значит я чего-то не понимаю про свои же данные.

Как я платил модели за перевод структуры в такую же структуру

Описания вакансий приходят в самом разном виде, и хотелось приводить их к одному формату: про роль, чем заниматься, чего ждут, стек и тд.

Сначала этим занималась модель. Работало хорошо, но по логу расходов выходило 0,27 рубля за вакансию. При 300-600 новых объявлениях в сутки это 2-5 тысяч рублей в месяц. Тогда я первый раз посчитал не месяц целиком, а стоимость одной вакансии, и пошел смотреть, за что плачу.

Я взял примерно двести живых вакансий и просто посмотрел на них глазами. 73% уже размечены самим источником: заголовок секции и список под ним. Ну то есть я платил модели за то, чтобы она переложила готовую структуру в такую же готовую структуру.

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

Оценку вакансии считает не модель

Рядом с оценкой живости у меня есть разбор вакансии: что в ней выглядит хорошо, а на что стоит посмотреть внимательнее.

Считать это моделью я специально не стал. Она почти всегда напишет что-нибудь вроде «отличная возможность для амбициозного специалиста», и толку от такого разбора ноль. Другое дело число, которое можно проверить. Например, насколько верх вилки отличается от медианы по похожим вакансиям того же грейда. Медианы я считаю по 12 800+ вакансиям с указанной вилкой из своей же базы, а если по конкретной роли и грейду их набирается мало, цифру просто не показываю.

Если число можно посчитать, его считает код. Модель зовем там, где надо объяснить, что этот результат значит.

Форму отклика заполняет расширение

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

Думал, задача на вечер, но нет :)

Во-первых, поля у всех называются по-разному. У Greenhouse first_name, у Ashby _systemfield_name, у Lever просто name. Общего у них только назначение, поэтому верстку конкретного сайта расширение не знает вовсе. Оно собирает про каждое поле все подряд: name, id, autocomplete, placeholder, aria-label, текст подписи. И уже по этой куче решает, что перед ним.

Подпись, беру через input.labels, а не селектором по label[for=...]. В id у бордов попадаются двоеточия и скобки, селектор на таком ломается, CSS.escape есть не везде, а .labels отдает и label по for, и обертку, и экранировать ничего не надо.

Во-вторых, поле заполняется, на экране все хорошо, жмешь отправить, а форма говорит, что обязательные поля пустые. Потому что форма на React, и прямое присваивание value ее состояние не меняет. Значение надо ставить нативным сеттером и слать события:

const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("change", { bubbles: true }));

И третье, расширение специально не делает три вещи, и это, мне кажется, важнее всего остального. Не отправляет форму: человек смотрит и жмет сам, а автоотправка это уже веерная рассылка откликов, за которую по правилам площадок банят. Не прикладывает файл резюме: подставить файл в input type=file из скрипта браузер не дает, это ограничение безопасности, а не моя ленивость. И не трогает то, что уже вписано руками.

Экономит это минуту на отклик. Звучит смешно, но откликов за вечер десяток, и после третьей одинаковой формы желание откликаться кончается раньше, чем количество вакансий.

Про ИИ, раз уж без него никуда

Claude я использую активно и скрывать это смысла не вижу. Без него проект такого размера по вечерам в одиночку я бы не вытянул.

Но это не «описал идею и получил готовый продукт». Рутину он закрывает отлично: миграции, роуты, типы, тесты. А решения все равно остаются твоими. Что считать кодом, а что отдавать модели. Где показать пользователю число, а где промолчать, потому что данных мало. Почему hh не стоит тянуть целиком.

И проблемы тоже остаются твоими. 403 от hh с IP виртуальной машины: я проверял лимиты, пробовал с токеном и без, менял User-Agent, читал документацию, потом читал ее еще раз. Везде 403. А дело было в самом IP, с домашнего все работало. RLS, которая блокирует твой же сервис. PGRST204 на колонку, которая совершенно точно есть: смотришь в psql, она на месте, идешь через API, ее нет, потому что после ALTER TABLE забыл NOTIFY pgrst, 'reload schema'. Промптом такое не чинится. Максимум получишь идею, куда смотреть, а дальше сидишь с логами.

Из недавнего, например, тоже из серии «логика правильная, но почему-то не работает»: у роли отозван UPDATE на таблицу и выдан поименным списком колонок. Добавляешь новую колонку миграцией, грант на нее не распространяется, и форма профиля падает целиком с permission denied. Не одно поле не сохраняется, а вообще все. Полчаса я искал это в коде формы.

И еще. Когда код появляется быстрее, чем ты успеваешь держать систему в голове, тесты из хорошей практики превращаются в единственный способ не сойти с ума. У меня их 370 файлов, и это не от любви к TDD. Просто после очередной правки хочется знать, что остальное еще живое.

Отступление про игру

Про отвлечение и отдых. В какой-то момент я устал от парсеров и данных и просто сел писать игру. Никакой пользы проекту, чисто чтобы развеяться: за выходные получилась аркада в духе Марио, которую в детстве на денди просто обожал, только про карьеру в IT. Вместо грибов офферы, вместо черепах легаси, а в замках вместо драконов собеседования.

Штука несерьезная, чисто для развлечения. Но технически там вылезло много нового для меня: фиксированный шаг физики, процедурные уровни, синтез звука, античит. И бот, который проходит каждый уровень перед выкладкой, потому что генератор регулярно выдавал непроходимые карты, а глазами такое не ловится: играешь, не проходится, и ты не знаешь, ты кривой или уровень. Про это я писал отдельно: Марио про джуна: платформер на 40 КБ без файлов графики

Самое полезное оттуда потом переехало в основной проект. Если чего-то много или оно генерируется, я больше не проверяю это сам глазами, а пишу проверку, которая если что упадет в сборке.

Немного про стек

Фронт и бэк живут в одном приложении на Next.js 16 с App Router и React 19. База это Postgres с pgvector: векторы под HNSW-индексами, полнотекстовый поиск на русской конфигурации, тяжелые агрегации в SQL-функциях.

Отдельно пришлось повозиться со счетчиками в фильтрах. На каждый фильтр уходило несколько почти одинаковых запросов, и за одну отрисовку одна и та же таблица могла сканироваться по нескольку раз. Сейчас все считается одним WITH ... AS MATERIALIZED CTE.

Инфраструктура своя, на виртуалке в Yandex Cloud, все через docker compose: Caddy, приложение, PostgREST, GoTrue, storage-api и контейнер для фоновых скриптов. Облачный Supabase не использую, стабильный доступ из России оказался важнее удобства готового сервиса. Cron работает прямо на ВМ и дергает роуты приложения. Сначала я гонял его в GitHub Actions, потом начались проблемы с биллингом из России, и переезд вышел даже удобнее: логи и секреты под рукой.

И одна вещь, на которую потратил много времени, причем потратил дважды, потому что через месяц наступил на нее же. cookies() в общем layout ломает ISR: один вызов выше по дереву делает динамическими все страницы группы маршрутов. Снаружи не видно вообще ничего. Сайт работает, страницы открываются, просто они тихо перестали быть статическими, и понимаешь ты это по счету за трафик и по времени ответа. Персонализацию пришлось унести в клиентский стор, а вывод сборки я теперь проверяю глазами.

Считать надо не месяц, а одну вакансию

Постоянная часть простая: виртуальная машина с приложением, база, домены и трафик. По прикидке 12-16 тысяч рублей в месяц, и эта сумма почти не зависит от того, сколько вакансий лежит в каталоге.

Гораздо важнее другая цифра: сколько стоит обработать одну вакансию. В сутки их приезжает 300-600, и любая мелочь, умноженная на такой поток, мелочью быть перестает.

Пример был выше. Разбор описания моделью стоил 0,27 рубля за штуку. Вроде копейки. А на потоке это 2-5 тысяч рублей в месяц, то есть дороже половины всей инфраструктуры. Именно эта цифра заставила меня открыть двести описаний и посмотреть на них глазами, вместо того чтобы улучшать промпт.

С эмбеддингами вышло так же. Внешний API удобен, пока вакансий немного, а дальше счет растет вместе с каталогом. Локальная модель в Node считает их бесплатно и для моих задач работает не хуже.

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

Главное, что я отсюда вынес: цена фичи в рублях на одну вакансию говорит о проекте больше, чем оценка в часах. Дорогая на единицу фича либо упрощается, либо не живет.

Как люди вообще находят такой сайт

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

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

Второе, чисто техническое. У карты сайта лимит 50 тысяч адресов на файл, так что вакансии режутся чанками по 20 тысяч плюс индексный файл сверху. Важно, что счет файлов и выборка вакансий считаются в одном месте: если они разойдутся, индекс начнет ссылаться на несуществующие файлы или промолчит о существующих, и часть каталога просто выпадет из индексации. Молча и без единой ошибки в логах.

Третье, про скорость. Вакансия живет недели, а ценна она первые дни, и ждать планового обхода всего каталога бессмысленно. Поэтому про новые адреса я сообщаю сам, протоколом IndexNow: одна отправка расходится Bing, Яндексу и еще паре поисковиков. Google в этом не участвует и узнает все из карты сайта, как раньше. Отдельная причина не забивать на Bing: на его индексе работает поиск ChatGPT, а это уже заметный кусок того, как люди сегодня ищут вообще что угодно.

А дальше начались штуки, о которых нигде не написано большими буквами.

Самая неприятная: loading.tsx. Казалось бы, скелетон на время загрузки, что тут может быть не так. А он оборачивает страницу в Suspense, и даже готовый закешированный ответ уходит так: сначала скелетон, потом подвал, а сама страница скрытым блоком после подвала, и на место ее ставит JS. Google такое разберет. Яндекс исполняет JS не всегда и видит пустую страницу. Заодно notFound() внутри такой страницы отдает 200 вместо 404.

Вторая из той же серии: мусорный номер страницы во вложенном динамическом сегменте. notFound() там статус ответа уже не меняет, страница уходит с кодом 200 и пустым телом, то есть краулер получает мягкую четырехсотку и считает, что у тебя тысячи пустых страниц. Лечится это проверкой до рендера и редиректами в конфиге, а не внутри страницы.

И третье. Я добавил llms.txt, потому что сайты теперь читают не только поисковики. Это простой текстовый файл с описанием проекта и разделов, и пока не понимаю, насколько его вообще учитывают, но стоит он один файл и немного времени.

Работает ли это все? Работает, но медленно и без единого дня, когда вдруг стало хорошо: поиск отдает трафик по чуть-чуть и только тем страницам, где реально есть что читать. Для каталога, который обновляется каждые четыре часа, это единственный честный канал.

Что в итоге

Сейчас каталог выглядит так. Больше 16 000 активных вакансий, из них 6 500+ приходят напрямую с карьерных страниц компаний, а не из общей выдачи. Обновление каждые четыре часа. Дубли склеиваются в два уровня, у каждой вакансии считается, насколько она живая, и зарплата показывается на фоне медианы по похожим. Все эти числа считает код, поэтому их можно перепроверить, а не принимать на веру.

Днем я работаю, все это собираю вечерами и по выходным, и так уже несколько месяцев подряд.

Что изменилось лично у меня: я перестал играть в количество откликов. Смотрю на вилку, на то, сколько объявление висит, и отвечает ли компания хоть кому-нибудь. Если все плохо, отклик стоит ровно ноль, а времени отнимает столько же, сколько нормальный. И хожу теперь чаще прямо к компаниям, у меня больше 6 500 вакансий приходят с карьерных страниц напрямую, и там они появляются раньше, чем где-либо еще.

Ну и за эти несколько месяцев я узнал про данные, Postgres, SEO и инфраструктуру больше, чем за несколько предыдущих лет. Просто потому что в своем проекте некому сказать «этим занимается другой отдел». Не работает импорт, разбираешься сам. Страница не индексируется, лезешь в карту сайта и логи краулера сам. Упал деплой, поднимаешь сам. Иногда это бесит. Но растешь от этого быстрее, чем от любого курса.

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

Вывод у меня скромный. Если вы тратите кучу вечеров на что-то тупое и повторяющееся, это хороший повод написать себе инструмент. Иногда он так и останется скриптом на сто строк, иногда вырастет во что-то большее. И то и другое лучше, чем еще один вечер с двадцатью вкладками.

Буду рад любой обратной связи!

Еще два вопроса: по каким признакам вы сами решаете, что на вакансию не стоит тратить время? Накидайте компаний, чьи карьерные страницы стоит забрать в каталог?

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.