Мониторинг вакансий с ИИ‑скорингом на n8n и Groq API: разбираем архитектуру и грабли с Rate Limit

в 8:07, , рубрики: api, Groq, javascript, llm, n8n, open source, qwen, автоматизация, парсинг

Поиск узкопрофильных вакансий (например, n8n‑инженер или специалист по интеграциям) через RSS‑ленты превращается в борьбу с шумом. Простые текстовые фильтры по ключевым словам «автоматизация» или «API» дают массу ложных срабатываний: от «менеджеров по автоматизации продаж» до «руководителей API‑команд».

В этой статье я разберу архитектуру end‑to‑end пайплайна на n8n, который использует LLM (Groq API) для жесткого семантического скоринга каждой вакансии. Мы не просто ищем совпадения, а заставляем ИИ работать строгим техническим рекрутером, отсеивая 90% мусора на входе.

Особое внимание уделим «граблям»: как бороться с Rate Limit бесплатного тарифа Groq, почему парсинг JSON от LLM падает с ошибкой и как настроить дедупликацию через Data Table, чтобы бот не спамил одно и то же каждый час.

Почему ключевые слова не работают

Первая версия монитора использовала простой текстовый фильтр: исключать вакансии со словами «менеджер», «продажи», «ручное тестирование». Подход оказался бесполезным. Слово «автоматизация» встречается в вакансии «менеджер по автоматизации продаж», а «API» — в вакансии «специалист технической поддержки со знанием API». Текстовый фильтр не различает контекст упоминания. Вторая попытка — отправлять все вакансии в LLM с вопросом «подходит ли это мне» — тоже провалилась. Модель отвечала свободным текстом, и разбор ответа ломался на каждом третьем запросе: то маркдаун‑обёртка, то рассуждения вместо оценки. Рабочим оказалось сочетание трёх элементов: жёсткий промпт со шкалой оценки, принудительный формат ответа JSON и запасной вариант разбора при ошибке. Ниже разбираю архитектуру пайплайна и конкретные грабли, включая лимиты бесплатного тарифа Groq.

Архитектура пайплайна: от триггера до Telegram

Весь пайплайн собран в n8n и запускается по расписанию раз в час. Внешних оркестраторов и очередей нет: для этой задачи хватает встроенных механизмов платформы. Ниже — список нод в порядке выполнения.

  • Schedule Trigger — запуск раз в час.

  • RSS Read — забор ленты Habr Career по ключевым словам.

  • Code (очистка) — срезает HTML‑теги из описания, достаёт ID вакансии из URL.

  • Code (промпт) — собирает промпт: критерии отклонения, шкала оценки, требование вернуть строгий JSON.

  • HTTP Request — запрос к Groq API с пакетной обработкой. Здесь же спрятаны главные грабли с лимитами.

  • ParseScore — разбирает ответ модели: достаёт score и reason, при сбое парсинга подставляет ноль и текст ошибки.

  • Code (фильтр) — пропускает дальше только вакансии с оценкой 7 и выше.

  • Data Table (проверка) — сверяет ссылку с таблицей уже обработанных вакансий.

  • Insert row и Telegram — пишет ссылку в таблицу и отправляет уведомление.

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

Рис. 1. Рабочая схема workflow в n8n: от RSS-триггера до отправки уведомления в Telegram

Рис. 1. Рабочая схема workflow в n8n: от RSS‑триггера до отправки уведомления в Telegram

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

Грабли № 1: Rate Limit бесплатного тарифа Groq

Бесплатный тариф Groq API для модели qwen3.8–27b даёт лимит в 7000 входных токенов в минуту. При обработке 50 вакансий из RSS за один запуск суммарное потребление превышало 30 000 токенов, и API возвращал ошибку 429 на третьей пачке запросов. Увеличение Batch Size в ноде HTTP Request не решало проблему, так как n8n просто быстрее отправлял запросы в рамках пакета.

Решение состоит из двух частей. Первая — жёсткое ограничение выборки в ноде Code до 10 последних вакансий за запуск. Вторая — настройка пакетной обработки по одному элементу. Ниже приведён фрагмент кода, который ограничивает выборку.

const items = $input.all();
const limitedItems = items.slice(0, 10);
return limitedItems.map(item => ({
json: {
id: item.json.id,
title: item.json.title,
link: item.json.link,
content: item.json.content
}
}));

Функция slice(0, 10) гарантирует, что в HTTP Request уйдёт не более 10 элементов за цикл. В сочетании с Batch Size = 1 в настройках ноды HTTP Request среднее потребление за запуск падает до 3–4 тысяч токенов, что безопасно укладывается в лимит. Минус подхода — одна полная проверка RSS занимает несколько часов, но для мониторинга вакансий задержка в пару часов приемлема.

Грабли № 2: парсинг ответа модели

Даже при явном требовании в промпте вернуть строгий JSON модель периодически отвечает свободным текстом. Типичные случаи: обёртка в markdown‑блоки с подсветкой синтаксиса, пояснения до и после JSON, лишние пробелы в ключах. Наивный парсинг через JSON.parse падает с исключением на каждом третьем ответе, и весь пайплайн останавливается.

Решение — отдельная нода ParseScore, которая пытается распарсить ответ, а при сбое не валит пайплайн, а подставляет нулевую оценку и текст ошибки. Такую вакансию на следующем шаге отфильтрует узел с порогом score >= 7, и система продолжит работать.

const sources = $('Code in JavaScript4').all();

return $input.all().map((item, i) => {
  let score = 0;
  let reason = 'Ошибка парсинга';
  try {
    const content = item.json.choices[0].message.content;
    const cleaned = content.replace(/```json/g, '').replace(/```/g, '').trim();
    const parsed = JSON.parse(cleaned);
    score = Number(parsed.score);
    reason = parsed.reason || '';
  } catch (e) {}
  return {
    json: {
      score: score,
      reason: reason,
      title: sources[i] ? sources[i].json.title : '',
      link: sources[i] ? sources[i].json.link : '',
      description: sources[i] ? sources[i].json.description : ''
    }
  };
});

Ключевой момент — обёртка в try/catch. Блок catch пустой намеренно: при любом сбое парсинга переменные score и reason остаются со значениями по умолчанию (0 и «Ошибка парсинга»), и объект с данными о вакансии всё равно возвращается в поток. Вторая важная деталь — вызов sources[i] для восстановления метаданных вакансии из предыдущей ноды, потому что в ответе Groq API их уже нет.

Промпт: как заставить модель быть строгим рекрутером

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

Проанализируй вакансию на соответствие профилю инженера по автоматизации. Жесткие критерии отклонения (score 1–3): Требуется программирование на Java, C++, C#, Go, Rust, Ruby, PHP Позиция управленческая: руководитель, директор, менеджер, тимлид, бизнес‑партнёр, HR, маркетолог, дизайнер, продакт Требуется глубокая экспертиза в DevOps, Kubernetes, Docker на уровне администрирования Фокус на frontend (React, Vue, Angular) или mobile (iOS, Android) Требуется только ручное тестирование без автоматизации Вакансия про консалтинг, стратегию, методологию без практической реализации критерии высокой релевантности (score 7–10): Прямое упоминание n8n, Make, Zapier, Power Automate Автоматизация бизнес‑процессов через API и webhooks Работа с LLM (OpenAI, Claude, Groq) для автоматизации задач Интеграция мессенджеров (Telegram, WhatsApp, ВК) с CRM/сервисами Парсинг данных, ETL‑процессы, работа с базами данных Python как вспомогательный инструмент (скрипты, обработка данных) оценка: 1–3: Не соответствует профилю 4–6: Частичное совпадение, но есть серьезные расхождения 7–10: Полное соответствие Верни только JSON без markdown: {«score»: число, «reason»: «краткое обоснование в 1–2 предложениях»}

Порядок блоков не случаен: критерии отклонения стоят раньше критериев релевантности. Модель сначала ищет причину отклонить вакансию и только потом — причину принять. Это смещает распределение оценок вниз и убирает главную болезнь LLM‑рекрутеров: необоснованную щедрость. Temperature в запросе установлена в 0.2: для задачи скоринга стабильность ответа важнее креативности.

Как это работает на практике. Вакансия «Руководитель группы дежурных аналитиков SOC» получила единицу с обоснованием «управленческая позиция в информационной безопасности, не связанная с автоматизацией». «Senior iOS разработчик» — единицу за фокус на Swift и Objective‑C. «Senior Business Development Manager» — единицу за профиль продаж. Вакансия с прямым упоминанием n8n и интеграций через webhook получила девятку. Порог фильтра установлен на семи: ниже него остаются частичные совпадения, просмотр которых всё ещё пустая трата времени.

Грабли № 3: спам одними и теми же вакансиями

RSS‑лента Хабра при каждом запросе возвращает не только новые, но и старые вакансии за последние дни. Без дедупликации пайплайн каждый час отправлял бы в Telegram одно и то же: одни вакансии появлялись в ленте повторно, пока их не удаляли из публикации. Фильтр по дате публикации работал нестабильно из‑за рассинхронизации часовых поясов и формата даты в RSS.

Решение — локальная таблица в самом n8n через встроенный узел Data Table. Она хранит ссылки на уже обработанные вакансии, и перед отправкой уведомления система проверяет, видели ли мы эту ссылку раньше. Для небольшой базы на несколько сотен записей встроенный механизм работает быстрее и проще, чем подключение внешней СУБД вроде PostgreSQL.

Схема работы дедупликации состоит из двух узлов. Первый, «If row does not exist», ищет ссылку в таблице по точному совпадению и возвращает только те записи, которых там ещё нет. Второй, «Insert row», записывает новую ссылку в таблицу вместе с заголовком, оценкой и обоснованием. Запись в таблицу идёт до отправки в Telegram: если отправка упадёт по сети, при следующем запуске вакансия уже не пройдёт фильтр.

Важный нюанс — явный маппинг всех полей в узле «Insert row». Если добавить в схему таблицы только поле «link», то на выходе из узла Data Table остаются только замапленные поля, и метаданные вакансии (title, score, reason) теряются по пути к Telegram. Поэтому схему таблицы нужно создать заранее с четырьмя колонками типа String: link, title, score, reason, и все четыре замапить в узле.

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

Итоги и ограничения

Текущая версия монитора проверяет ленту Habr Career раз в час. Из примерно пятидесяти вакансий в выдаче на оценку за запуск уходит не больше десяти из‑за лимита токенов. В тестовых прогонах модель стабильно ставила единицы и двойки управленческим позициям и смежным специальностям, четвёрку — частичным совпадениям. При пороге семь за время наблюдения не ушло ни одного уведомления: в ленте просто не появилось вакансий с прямым соответствием профилю. Для узкой ниши это нормальный результат, а не признак поломки системы.

Ограничения схемы честные и известные заранее. Первое — плотность источника: Habr Career публикует вакансии с прямым упоминанием n8n редко, для повышения частоты срабатываний пайплайну нужны дополнительные источники, архитектура позволяет добавить их параллельными ветками. Второе — пропускная способность бесплатного тарифа: до десяти оценок в час, около двухсот сорока в сутки. Третье — компромисс порога: на семи ложных срабатываний почти нет, но возможны пропуски пограничных вакансий, на пяти шума больше. Порог вынесен в одно место и меняется одной строкой кода.

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

Шаблон workflow опубликован в репозитории:
https://github.com/rmd1433/groq‑vacancy‑monitor‑habr
Файл JSON импортируется в n8n одной операцией. Ключи API и токены в шаблон не включены. Перед первым запуском создайте Data Table с колонками link, title, score, reason типа String.

Автор: r33

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js