Любой, кто искал работу, знает этот ритуал: открыть hh.ru, обновить выдачу, пролистать одно и то же, закрыть, через час повторить. И так весь день. А теперь представьте, что эту рутину забрал себе бот: сам проверяет новые вакансии, сам отсеивает неподходящие и пишет вам в Telegram, только когда нашёл что-то стоящее. Получится личный отдел кадров из одного Python-файла.
В этой статье мы соберём такого AI-агента для парсинга вакансий с нуля: разберём, чем агент отличается от обычного скрипта, откуда легально брать данные, напишем рабочий код на без тяжёлых фреймворков, подключим Telegram и запустим бота. Будет полезно тем, кто уже немного пишет на Python и хочет понять, как устроены AI-агенты на простом, но честном проекте.
ВАМ ПРИШЛО ПРИГЛАШЕНИЕ 💌
Приходите к нам в соцсети поделиться своим мнением и почитать, что пишут другие. А ещё там выходит дополнительный контент, которого нет на сайте — шпаргалки, опросы и разная дурка. В общем, вот тележка, вот ВК — велком!
Что такое AI-агент и чем он отличается от обычного скрипта
AI-агент — это программа, в которой решения принимает языковая модель. Она смотрит на задачу, выбирает, какой инструмент вызвать, получает результат и решает, что делать дальше. И так по кругу, пока задача не будет решена.
Сравните с обычным скриптом. Скрипт — это жёсткая последовательность шагов: запросить страницу → разобрать ответ → применить фильтр → отправить результат. Шаги всегда одни и те же и всегда в одном порядке. Если на сайте поменялась структура или вам нужна другая логика отбора — вы лезете в код и переписываете шаги руками.
Агент устроен иначе. Вы даёте модели набор инструментов и цель, а порядок действий она выбирает сама. Попросили «найди питоновские вакансии от 150 тысяч и пришли только удалёнку» — агент сам сообразит, что сначала надо вызвать поиск, потом отфильтровать выдачу по смыслу, а потом отправить уведомление. Никаких новых условий в коде писать не нужно: логику вы задаёте словами, а не if-ами.

Запомните эту петлю — на ней держится весь проект. Дальше мы только наполняем её содержимым: даём агенту инструменты и учим пользоваться ими в нужный момент.
Откуда легально брать данные о вакансиях
Самый частый первый порыв — спарсить hh.ru напрямую: открыть страницу поиска, выдрать вакансии из HTML. Так делать не нужно, и причин две.
Первая причина — техническая. Прямой парсинг hh.ru упирается в защиту: сайт распознаёт автоматический трафик по TLS-отпечатку и поведению, и быстро начинает отдавать капчу или блокировать. Бороться с этим можно, но это бесконечная гонка, в которой вы всегда отстаёте.
Вторая причина важнее — юридическая. Пользовательское соглашение hh.ru прямо запрещает извлекать данные, чтобы складывать их в свою базу. Это не техническая мелочь, а граница в правовом поле: за массовый сбор персональных данных в России отвечают по 152-ФЗ, а с декабря 2024 года в силу вступила статья 272.1 УК РФ о незаконном обороте персональных данных. Рисковать аккаунтом и попадать под закон ради учебного бота — плохая идея.
Есть и официальный путь — HeadHunter API с авторизацией по OAuth3 от своего аккаунта. Для личного использования он подходит, но с оговорками. Во-первых, у API есть лимиты на частоту запросов. Во-вторых, в декабре 2025 года hh.ru начал ограничивать доступ к публичному API для сторонних сервисов — это стоит проверить на момент, когда вы читаете статью, потому что правила площадки меняются. И в-третьих, то же соглашение запрещает строить на этих данных отдельную базу, так что коммерческая агрегация отпадает.
Поэтому в проекте мы пойдём по самому безукоризненному пути — возьмём открытый API портала «Работа России» (trudvsem). Это государственная база вакансий, данные из которой опубликованы как открытые: их можно брать свободно, без регистрации, токенов и OAuth. API отдаёт обычный JSON по HTTP, а значит, любой ваш код заведётся сразу. Этот же источник официально используют крупные job-сайты, так что данных там много и они настоящие. Идеальная песочница, чтобы собрать агента и не думать о банах.
Один запрос к нему выглядит так — просто адрес с параметром поиска:
https://opendata.trudvsem.ru/api/v1/vacancies?text=python разработчик&limit=20
В ответ придёт JSON со списком вакансий. Дальше дело за агентом.
Нужен ли LangChain — как выбрать архитектуру агента
Если погуглить «как написать AI-агента», первым делом наткнётесь на LangChain и десяток похожих фреймворков. Возникает ощущение, что без них агента не собрать. Это не так.
LangChain — мощный инструмент, но он решает задачи другого масштаба: мультиагентные системы, сложные цепочки рассуждений, продакшен с тысячами пользователей и строгими требованиями к надёжности. Для нашей задачи — один источник данных, два понятных инструмента, бот для себя — это как заказывать фуру, чтобы привезти домой пакет молока. Фреймворк добавит слои абстракции, новые понятия и зависимости, а взамен не даст ничего, кроме ощущения, что «всё по-взрослому».
Вся магия агентов держится на одной возможности современных моделей — function calling (вызов функций). Вы заранее описываете модели, какие функции у неё есть и что они делают. Дальше модель сама решает, какую вызвать, и возвращает не текст, а структурированный запрос: «вызови get_vacancies с аргументом python разработчик». Ваш код выполняет функцию и отдаёт результат обратно. Всё. Этого достаточно, чтобы собрать рабочего агента в сотню строк.
Фреймворк будет иметь смысл, когда вы перерастёте этот масштаб: появятся десятки инструментов, понадобится память между запусками, маршрутизация между несколькими агентами. Но начинать с него — значит изучать чужие абстракции вместо того, чтобы понять, как агент устроен на самом деле.
Минимальная архитектура агента
Чтобы собрать агента, нужны всего три вещи:
- Системный промпт — текст, который объясняет модели её роль и цель. Здесь мы говорим: «ты ассистент по поиску работы, найди вакансии и пришли подходящие».
- Инструменты — обычные функции с понятным описанием. У нас их две: одна ищет вакансии, другая отправляет уведомление.
- Цикл обработки — код, который крутится по кругу: спрашивает модель, выполняет запрошенные функции, возвращает результат и спрашивает снова — пока модель не скажет, что закончила.

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

Начнём с инструментов. Это самые обычные функции — никакой магии в них нет.
Первый инструмент — поиск вакансий. Он ходит в открытый API «Работа России», разбирает ответ и оставляет только нужные поля:
def get_vacancies(text, salary_from=0, limit=8):
# минимальную зарплату приводим к числу: модель может прислать её строкой
salary_from = int(salary_from or 0)
# адрес метода поиска вакансий
url = "https://opendata.trudvsem.ru/api/v1/vacancies"
# text -- что ищем, limit -- сколько вакансий вернуть за раз
params = {"text": text, "limit": limit, "offset": 0}
# API портала бывает медленным -- даём время и пару повторов на таймаут
for attempt in range(3):
try:
# timeout побольше: тело ответа иногда отдаётся не сразу
response = requests.get(url, params=params, timeout=60)
break
except requests.exceptions.RequestException:
# на третьей неудаче сдаёмся и пробрасываем ошибку наверх
if attempt == 2:
raise
# перед повтором ждём пару секунд
time.sleep(2)
# разбираем JSON-ответ в словарь Python
data = response.json()
# достаём список вакансий (по умолчанию -- пустой)
raw = data.get("results", {}).get("vacancies", [])
# сюда сложим очищенные и отфильтрованные вакансии
result = []
# перебираем каждую вакансию из ответа
for item in raw:
# каждая запись лежит внутри ключа "vacancy"
vac = item.get("vacancy", {})
# нижняя граница зарплаты; приводим к int (в ответе бывает и строкой)
salary_min = int(vac.get("salary_min") or 0)
# отбрасываем вакансии с зарплатой ниже заданной
if salary_min < salary_from:
continue
# оставляем только нужные поля в удобном виде
result.append({
# уникальный id -- пригодится, чтобы не слать одно дважды
"id": vac.get("id"),
# название должности (ключ с дефисом -- только через скобки)
"name": vac.get("job-name"),
# текстовое описание зарплаты, как его отдаёт портал
"salary": vac.get("salary"),
# название компании-работодателя
"company": vac.get("company", {}).get("name"),
# регион вакансии
"region": vac.get("region", {}).get("name"),
# прямая ссылка на вакансию
"url": vac.get("vac_url"),
})
# возвращаем готовый список словарей
return result
Обратите внимание: API не умеет фильтровать по зарплате сам, поэтому отбор salary_from мы делаем у себя, уже после получения ответа. Это нормально — часть логики всегда остаётся на вашей стороне.
Второй инструмент — отправка вакансии в Telegram. Его мы подробно разберём в следующем разделе, а пока просто держим в голове, что у агента есть функция notify_user(vacancy), которая шлёт одно сообщение.
Системный промпт и описание инструментов
Модель не видит ваш код. Чтобы она знала про функции, их нужно описать отдельно — на языке, который модель понимает. Это описание называют схемой инструментов: имя функции, что она делает и какие аргументы принимает.
# описание инструментов для модели -- по сути «меню» доступных функций
TOOLS = [
{
# тип всегда function
"type": "function",
"function": {
# имя должно совпадать с именем функции в коде
"name": "get_vacancies",
# по описанию модель понимает, когда звать функцию
"description": "Найти вакансии по ключевому слову и минимальной зарплате",
# схема аргументов в формате JSON Schema
"parameters": {
"type": "object",
"properties": {
# что искать
"text": {"type": "string", "description": "Ключевое слово, например 'python разработчик'"},
# минимальная зарплата
"salary_from": {"type": "integer", "description": "Минимальная зарплата в рублях"},
},
# text -- обязательный аргумент
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
# вторая функция -- отправка вакансии
"name": "notify_user",
"description": "Отправить подходящую вакансию пользователю в Telegram",
"parameters": {
"type": "object",
"properties": {
# передаём всю вакансию целиком
"vacancy": {"type": "object", "description": "Объект вакансии из get_vacancies"},
},
"required": ["vacancy"],
},
},
},
]
Обратите внимание на description — именно по нему модель решает, какую функцию вызвать в нужный момент. Чем понятнее вы объясните, что делает функция, тем надёжнее агент будет попадать в цель. Пишите описания так, будто объясняете задачу коллеге.
Системный промпт ещё проще — это просто текст с ролью и целью:
# системный промпт задаёт роль агента и его задачу
SYSTEM = (
"Ты -- ассистент по поиску работы. Найди вакансии по запросу "
"пользователя через get_vacancies, отбери действительно подходящие "
"и отправь каждую через notify_user. В конце коротко отчитайся."
)
Обработка ответа модели и вызов функций
Теперь пропишем цикл, который связывает модель и инструменты. Логика простая: спрашиваем модель, смотрим её ответ. Если она просит вызвать функцию — вызываем и возвращаем результат. Если просто отвечает текстом — значит, закончила, выходим.
Подключаться будем через OpenRouter — это сервис-посредник, который даёт доступ к десяткам моделей по одному ключу. Сервис работает в России, но пополнять баланс нужно зарубежными Visa или Mastercard (или криптой). OpenRouter хорош ещё и тем, что использует тот же интерфейс, что и OpenAI, поэтому код подойдёт почти к любому провайдеру:
# OpenAI SDK работает и с OpenRouter -- меняется только адрес сервера
from openai import OpenAI
# создаём клиента: тот же SDK, но base_url ведёт на OpenRouter
client = OpenAI(
# адрес OpenRouter вместо api.openai.com
base_url="https://openrouter.ai/api/v1",
# ключ берём из переменной окружения, а не пишем в коде
api_key=os.environ["OPENROUTER_API_KEY"],
)
# модель с поддержкой function calling; меняется на любую другую
MODEL = "google/gemini-2.5-flash«
А вот и сам цикл:
# главный цикл агента: получает запрос на естественном языке и работает сам
def run_agent(user_request):
# стартовый контекст: системный промпт + запрос пользователя
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_request},
]
# крутимся, пока модель не перестанет звать инструменты
while True:
# спрашиваем модель, передав ей список инструментов
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
# достаём ответ модели
message = response.choices[0].message
# добавляем его в историю диалога
messages.append(message)
# если модель не просит вызвать инструмент -- это финальный ответ
if not message.tool_calls:
# печатаем итоговый текст и выходим
print(message.content)
break
# иначе обрабатываем каждый запрошенный вызов
for call in message.tool_calls:
# имя функции, которую хочет вызвать модель
name = call.function.name
# аргументы приходят строкой JSON -- разбираем в словарь
args = json.loads(call.function.arguments)
# вызываем настоящую функцию из словаря FUNCTIONS
output = FUNCTIONS[name](**args)
# возвращаем результат вызова обратно в контекст модели
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(output, ensure_ascii=False),
})
FUNCTIONS — это словарь «имя → функция», по которому агент находит нужный код:
# словарь «имя функции → сама функция»
FUNCTIONS = {"get_vacancies": get_vacancies, "notify_user": notify_user}
Вот и весь агент. Модель сама решает: сначала вызвать поиск, потом по очереди разослать подходящие вакансии, потом отчитаться. Мы нигде не писали «если вакансия подходит — отправь». Мы просто дали инструменты и цель. Если вы запустите агента на этом этапе, то он уже сможет спарсить вакансии, но ответ будет только в терминале и без ссылок.

Полезный блок со скидкой
AI-агент звучит как что-то сложное, но в базовой версии это понятная схема: модель, инструменты и цикл, который связывает их между собой. Дальше всё упирается в практику: Python, API, JSON, Telegram-боты и умение безопасно хранить ключи.
Если хотите собрать такую базу системно, у Практикума есть курсы по Python, backend-разработке, нейросетям и анализу данных. Промокод: KOD (можно просто нажать) даст скидку на платную программу.
Подключаем Telegram и настраиваем фильтры
Агент умеет искать и отбирать, осталось научить его писать вам. Здесь добавим то, что мы выяснили на практике, — чтобы вы не наступали на те же грабли.
Шаг 1. Создаём бота. В Telegram находим бота @BotFather, пишем ему /newbot, придумываем имя и адрес. В ответ он пришлёт токен — длинную строку вида 8581854702:AAF…. Этот токен — пароль от вашего бота. Кто им завладеет, тот сможет управлять ботом, поэтому в код его не вписывают и в чужие руки не отдают.

Шаг 2. Узнаём свой числовой ID. Находим бота @Getmyid_Work_Bot, пишем ему что угодно — он в ответ покажет ваш ID, например 548056537. Этот ID нужен, чтобы бот писал вам, а не в пустоту.

Запустите бота — он не может написать первым тому, кто ему ни разу не писал — без этого шага сообщение от агента не дойдёт, хотя код отработает без ошибок.
Шаг 3. Прописываем токен и ID. Следуйте принципам безопасности и не держите секреты в коде. Если зашить токен прямо в файл, он рано или поздно утечёт — попадёт в git, в скриншот, в чужой чат. Поэтому держим их в переменных окружения, а код читает их оттуда:
# токен бота и ваш числовой ID -- из переменных окружения, не из кода
TG_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
TG_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"]
Теперь сам инструмент отправки. Отправка сообщения в Telegram — это один POST-запрос к их Bot API. Держите в уме: Telegram отвечает кодом 200 даже на отказ, поэтому мы сами проверяем поле ok в ответе — иначе агент решит, что всё отправил, хотя сообщение не дошло. Заодно здесь живёт фильтр от повторов: если эту вакансию уже присылали, второй раз не шлём.
# инструмент №2: отправляет одну вакансию вам в Telegram
def notify_user(vacancy):
# загружаем id уже отправленных вакансий
seen = load_seen()
# если эту вакансию уже присылали -- выходим и сообщаем модели
if vacancy["id"] in seen:
return "Пропущено: вакансия уже отправлялась"
# собираем текст сообщения из полей вакансии
text = (
f"🔎 {vacancy['name']}\n"
f"🏢 {vacancy['company']}\n"
f"💰 {vacancy['salary']}\n"
f"📍 {vacancy['region']}\n"
f"🔗 {vacancy['url']}"
)
# адрес метода sendMessage у Telegram Bot API
url = f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage"
# отправляем сообщение в ваш чат
response = requests.post(url, json={"chat_id": TG_CHAT_ID, "text": text}, timeout=30)
# Telegram отвечает 200 даже при отказе -- проверяем поле ok сами
if not response.json().get("ok"):
# частая причина: не нажали Start у своего бота или не тот chat_id
raise RuntimeError("Telegram не принял сообщение: " + response.text)
# запоминаем id, чтобы в следующий раз не присылать повторно
seen.add(vacancy["id"])
# сохраняем обновлённый набор на диск
save_seen(seen)
# модель увидит, что отправка прошла
return "Отправлено"
Фильтр от повторов важнее, чем кажется. Без него агент при каждом запуске будет слать вам одни и те же вакансии — и вы быстро отключите бота. Поэтому отправленные id мы храним в простом файле и сверяемся с ним перед каждой отправкой.
Шаг 4. Запускаем. Перед стартом задаём три секрета — ключ OpenRouter и оба значения для Telegram — в переменных окружения, ставим библиотеки и запускаем агента.
# три секрета в текущей сессии терминала
export OPENROUTER_API_KEY="sk-or-..."
export TELEGRAM_BOT_TOKEN="8581854702:AAF..."
export TELEGRAM_CHAT_ID="548056537"
# ставим библиотеки и запускаем
pip install openai requests
python agent.py
Переменные живут до закрытия окна терминала — закроете его, задайте их заново. Чтобы агент работал постоянно, секреты выносят в файл .env или в настройки сервера — об этом в разделе про деплой.
А теперь — момент истины. Запустите агента командой:
python agent.py
Не ждите мгновенного отклика, на 10-30 секунд агент задумается — это нормально.
Если всё собралось, бот пришлет вам первое сообщение с вакансиями:

Про «настраиваем фильтры» — самое приятное. Вам не нужно писать новые условия в коде. Критерии вы задаёте текстом запроса: «питон от 150 тысяч, только удалёнка» или «аналитик данных в Казани». Агент сам разберёт запрос, подберёт аргументы для поиска и отсеет неподходящее по смыслу. В этом и весь смысл агента против скрипта.
Запускаем бота на постоянку
Пока агент запускается руками, толку от него мало: новые вакансии появляются круглые сутки, а вы не будете дежурить у терминала. Бот должен жить на сервере и сам просыпаться по расписанию
Сколько это стоит
Главная статья расходов — вызовы модели. Сам API «Работа России» бесплатный, сервер стоит копейки, а вот за каждый запрос к модели вы платите по числу токенов. Стоимость зависит от двух вещей: как часто агент опрашивает вакансии и какую модель вы выбрали. Эти два рычага решают почти всё.
Так, на gemini-2.5-flash опрос каждые пять минут обходится примерно в 37 долларов в месяц, а раз в час — около трёх долларов. На мощной gemini-2.5-pro тот же пятиминутный опрос — уже за полтораста долларов. А самая лёгкая gemini-2.5-flash-lite втрое дешевле flash — порядка полутора долларов в месяц при опросе раз в полчаса. Вывод простой: для бота-уведомителя берите модель попроще и не опрашивайте чаще, чем реально нужно. Вакансии не появляются каждую секунду — раз в полчаса хватает с запасом.
Где агент может сломаться
Агент — это живая цепочка из чужих сервисов, и любое звено может подвести. Площадка поменяет формат ответа API — и парсинг вернёт пустоту. Модель не с первого раза выберет правильный инструмент — особенно дешёвая или бесплатная. Сеть моргнёт в момент запроса — и функция упадёт с ошибкой. Поэтому запрос к API вакансий мы оборачиваем в пару повторов с увеличенным таймаутом: открытый портал бывает медленным, и один сетевой сбой не должен ронять весь прогон.
Ключевая защита от этого — обработка исключений. Если инструмент падает, агент не должен ронять весь цикл. Вместо этого мы ловим ошибку и возвращаем её модели как обычный результат — она увидит, что вызов не удался, и решит, что делать:
# вызываем инструмент, но не даём ошибке уронить весь цикл
try:
# пытаемся выполнить функцию
output = FUNCTIONS[name](**args)
except Exception as e:
# при ошибке возвращаем модели текст, а не падаем
output = f"Ошибка инструмента: {e}"
Это разница между ботом, который переживает сбой и работает дальше, и ботом, который умирает от первой же осечки сети.
Частые вопросы
Можно ли парсить hh.ru напрямую без API?
Технически — да, практически — не стоит. Сайт распознаёт ботов и блокирует их, а пользовательское соглашение прямо запрещает сбор данных. За массовый парсинг персональных данных в России отвечают по 152-ФЗ и статье 272.1 УК РФ. Для учебного бота берите открытый API «Работа России» — он легальный и бесплатный.
Сколько стоит держать такого агента постоянно включённым?
Зависит от модели и частоты опроса. На gemini-2.5-flash при опросе раз в полчаса выходит порядка шести долларов в месяц плюс копеечный сервер, а на более лёгкой gemini-2.5-flash-lite — около полутора. На мощной gemini-2.5-pro та же схема обойдётся в пару-тройку десятков долларов. Сам API вакансий и Telegram бесплатны.
Нужно ли использовать LangChain для старта?
Нет. Для одного источника и пары инструментов фреймворк — лишняя сложность. Весь агент держится на function calling, который умеют современные модели, и собирается в сотню строк без фреймворков. LangChain пригодится позже, когда вырастете до мультиагентных систем.
Что делать, если аккаунт hh.ru заблокировали за автоматизацию?
Сначала — прекратить автоматизацию через свой аккаунт и написать в поддержку. На будущее: не гоняйте автозапросы через личный аккаунт, особенно в обход API. Если нужна именно автоматизация, используйте источники с открытыми данными, где это разрешено правилами, — тот же портал «Работа России».
Можно ли подключить несколько источников вакансий сразу?
Да, и это сильная сторона агентов. Добавьте ещё одну функцию-инструмент с описанием — например, поиск по другой площадке с открытым API — и опишите её в TOOLS. Дальше модель сама решит, когда какой источник опросить. Менять основной цикл не придётся.
Что в итоге
Мы собрали AI-агента для парсинга вакансий, который сам ищет работу по вашим критериям и присылает подходящее в Telegram. И, что важнее, разобрались, как такие агенты устроены изнутри: модель плюс инструменты плюс цикл, который их связывает. Никакого тяжёлого фреймворка для этого не понадобилось — хватило function calling и сотни строк Python.
Главное, что стоит унести из статьи: агент — это не магия, а понятная петля. Как только вы её увидели, добавить новый инструмент становится тривиально. Захотите подключить второй источник вакансий, фильтр по ключевым навыкам или генерацию сопроводительного письма — это просто ещё одна функция в словаре. Каркас остаётся прежним.
А отсюда дорога открыта куда угодно: к ботам, которые следят за ценами, собирают новости или мониторят сервер. Принцип один и тот же — вы его теперь знаете.
Установите python и создайте виртуальное окружение. Создайте файл agent.py со следующим содержимым:
# подключаем модули стандартной библиотеки: переменные окружения, JSON, паузы
import os
import json
import time
# requests -- для HTTP-запросов к API вакансий и к Telegram
import requests
# OpenAI SDK работает и с OpenRouter -- меняется только адрес сервера
from openai import OpenAI
# создаём клиента: тот же SDK, но base_url ведёт на OpenRouter
client = OpenAI(
# адрес OpenRouter вместо api.openai.com
base_url="https://openrouter.ai/api/v1",
# ключ берём из переменной окружения, а не пишем в коде
api_key=os.environ["OPENROUTER_API_KEY"],
)
# модель с поддержкой function calling; меняется на любую другую
MODEL = "google/gemini-2.5-flash"
# токен Telegram-бота и ваш числовой ID -- тоже из окружения
TG_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
TG_CHAT_ID = os.environ["TELEGRAM_CHAT_ID"]
# файл, в котором храним id уже отправленных вакансий
SEEN_FILE = "seen.json"
# инструмент №1: запрашивает вакансии у открытого API «Работа России»
def get_vacancies(text, salary_from=0, limit=8):
# минимальную зарплату приводим к числу: модель может прислать её строкой
salary_from = int(salary_from or 0)
# адрес метода поиска вакансий
url = "https://opendata.trudvsem.ru/api/v1/vacancies"
# text -- что ищем, limit -- сколько вакансий вернуть за раз
params = {"text": text, "limit": limit, "offset": 0}
# API портала бывает медленным -- даём время и пару повторов на таймаут
for attempt in range(3):
try:
# timeout побольше: тело ответа иногда отдаётся не сразу
response = requests.get(url, params=params, timeout=60)
break
except requests.exceptions.RequestException:
# на третьей неудаче сдаёмся и пробрасываем ошибку наверх
if attempt == 2:
raise
# перед повтором ждём пару секунд
time.sleep(2)
# разбираем JSON-ответ в словарь Python
data = response.json()
# достаём список вакансий (по умолчанию -- пустой)
raw = data.get("results", {}).get("vacancies", [])
# сюда сложим очищенные и отфильтрованные вакансии
result = []
# перебираем каждую вакансию из ответа
for item in raw:
# каждая запись лежит внутри ключа "vacancy"
vac = item.get("vacancy", {})
# нижняя граница зарплаты; приводим к int (в ответе бывает и строкой)
salary_min = int(vac.get("salary_min") or 0)
# отбрасываем вакансии с зарплатой ниже заданной
if salary_min < salary_from:
continue
# оставляем только нужные поля в удобном виде
result.append({
# уникальный id -- чтобы не слать одно дважды
"id": vac.get("id"),
# название должности (ключ с дефисом -- только через скобки)
"name": vac.get("job-name"),
# текстовое описание зарплаты
"salary": vac.get("salary"),
# название компании-работодателя
"company": vac.get("company", {}).get("name"),
# регион вакансии
"region": vac.get("region", {}).get("name"),
# прямая ссылка на вакансию
"url": vac.get("vac_url"),
})
# возвращаем готовый список словарей
return result
# инструмент №2: отправляет одну вакансию вам в Telegram
def notify_user(vacancy):
# загружаем id уже отправленных вакансий
seen = load_seen()
# если эту вакансию уже присылали -- выходим и сообщаем модели
if vacancy["id"] in seen:
return "Пропущено: вакансия уже отправлялась"
# собираем текст сообщения из полей вакансии
text = (
f"🔎 {vacancy['name']}\n"
f"🏢 {vacancy['company']}\n"
f"💰 {vacancy['salary']}\n"
f"📍 {vacancy['region']}\n"
f"🔗 {vacancy['url']}"
)
# адрес метода sendMessage у Telegram Bot API
url = f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage"
# отправляем сообщение в ваш чат
response = requests.post(url, json={"chat_id": TG_CHAT_ID, "text": text}, timeout=30)
# Telegram отвечает 200 даже при отказе -- проверяем поле ok сами
if not response.json().get("ok"):
# частая причина: не нажали Start у своего бота или не тот chat_id
raise RuntimeError("Telegram не принял сообщение: " + response.text)
# запоминаем id, чтобы не присылать повторно
seen.add(vacancy["id"])
# сохраняем обновлённый набор на диск
save_seen(seen)
# модель увидит, что отправка прошла
return "Отправлено"
# читаем сохранённые id из файла; если файла нет -- пустое множество
def load_seen():
# если файла ещё не существует, возвращаем пустой набор
if not os.path.exists(SEEN_FILE):
return set()
# открываем файл и читаем список id, превращая его в множество
with open(SEEN_FILE, encoding="utf-8") as f:
return set(json.load(f))
# сохраняем множество id обратно в файл
def save_seen(seen):
# открываем файл на запись
with open(SEEN_FILE, "w", encoding="utf-8") as f:
# множество в JSON нельзя -- переводим в список
json.dump(list(seen), f)
# системный промпт задаёт роль агента и его задачу
SYSTEM = (
"Ты -- ассистент по поиску работы. Найди вакансии по запросу "
"пользователя через get_vacancies, отбери действительно подходящие "
"и отправь каждую через notify_user. В конце коротко отчитайся."
)
# описание инструментов для модели -- «меню» доступных функций
TOOLS = [
{
# тип всегда function
"type": "function",
"function": {
# имя должно совпадать с именем функции в коде
"name": "get_vacancies",
# по описанию модель понимает, когда звать функцию
"description": "Найти вакансии по ключевому слову и минимальной зарплате",
# схема аргументов в формате JSON Schema
"parameters": {
"type": "object",
"properties": {
# что искать
"text": {"type": "string", "description": "Ключевое слово, например 'python разработчик'"},
# минимальная зарплата
"salary_from": {"type": "integer", "description": "Минимальная зарплата в рублях"},
},
# text -- обязательный аргумент
"required": ["text"],
},
},
},
{
"type": "function",
"function": {
# вторая функция -- отправка вакансии
"name": "notify_user",
"description": "Отправить подходящую вакансию пользователю в Telegram",
"parameters": {
"type": "object",
"properties": {
# передаём всю вакансию целиком
"vacancy": {"type": "object", "description": "Объект вакансии из get_vacancies"},
},
"required": ["vacancy"],
},
},
},
]
# словарь «имя функции → сама функция»
FUNCTIONS = {"get_vacancies": get_vacancies, "notify_user": notify_user}
# главный цикл агента: получает запрос на естественном языке и работает сам
def run_agent(user_request):
# стартовый контекст: системный промпт + запрос пользователя
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": user_request},
]
# крутимся, пока модель не перестанет звать инструменты
while True:
# спрашиваем модель, передав ей список инструментов
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
)
# достаём ответ модели
message = response.choices[0].message
# добавляем его в историю диалога
messages.append(message)
# если модель не просит вызвать инструмент -- это финальный ответ
if not message.tool_calls:
# печатаем итоговый текст и выходим
print(message.content)
break
# иначе обрабатываем каждый запрошенный вызов
for call in message.tool_calls:
# имя функции, которую хочет вызвать модель
name = call.function.name
# аргументы приходят строкой JSON -- разбираем в словарь
args = json.loads(call.function.arguments)
# ловим ошибки внутри инструмента, чтобы не уронить цикл
try:
# вызываем настоящую функцию из словаря FUNCTIONS
output = FUNCTIONS[name](**args)
except Exception as e:
# при ошибке возвращаем модели текст, а не падаем
output = f"Ошибка инструмента: {e}"
# возвращаем результат вызова обратно в контекст модели
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": json.dumps(output, ensure_ascii=False),
})
# точка входа: запускаем агента с конкретным запросом
if __name__ == "__main__":
# критерии задаются обычным текстом -- агент разберёт их сам
run_agent("Найди вакансии 'python разработчик' с зарплатой от 150000")
Получите данные для авторизации — токен для бота (создайте в @BotFather), User ID (в @Getmyid_Work_Bot) и токен API-ключа OpenRouter (создайте на OpenRouter и пополните счёт через Visa, Mastercard или крипту). Экспортируйте данные:
export OPENROUTER_API_KEY="Ваш API-ключ из OpenRouter"
export TELEGRAM_BOT_TOKEN="Токен вашего бота"
export TELEGRAM_CHAT_ID="Ваш чат ID"
Запустите агента:
python agent.py
Советуем дополнительно почитать по теме
- 12 AI GitHub-репозиториев 2026: Ollama, n8n, Claude Code и OpenHands — подборка open-source-инструментов для локальных моделей, автоматизации, MCP, агентных сценариев и разработки с ИИ.
- ИИ-навыки разработчика: 10 требований для 2026 — какие навыки нужны разработчику, который работает с LLM: промпты, AI-агенты, LLM API, RAG, выбор моделей, безопасность и критическая проверка вывода.
- Обзор курса Практикума — Python-разработчик — подходит тем, кто хочет собрать похожие проекты увереннее: Python, API, Telegram-боты, бэкенд и проекты в портфолио.
- Как тестируют API: разбираемся на примере REST API — полезно после проекта с внешним API: как проверять запросы, ответы, статусы, ошибки и работу сервиса через Python.
- 9 несложных проектов для портфолио начинающих программистов — идеи проектов для новичка: API, JSON, боты, погодные сервисы и другие задачи, которые можно показать в портфолио.
Бонус для читателей
Если вам интересно погрузиться в мир ИТ и при этом немного сэкономить, держите наш промокод на курсы Практикума. Он даст вам скидку при оплате, поможет с льготной ипотекой и даст безлимит на маркетплейсах. Ладно, окей, это просто скидка, без остального, но хорошая.
