Как собрать AI-парсер вакансий — пошагово, с кодом

Мальчик: «Из-за ИИ я лишусь работы»

Мужчина: «ИИ, найди мне работу»

Как собрать AI-парсер вакансий — пошагово, с кодом

Любой, кто искал работу, знает этот ритуал: открыть hh.ru, обновить выдачу, пролистать одно и то же, закрыть, через час повторить. И так весь день. А теперь представьте, что эту рутину забрал себе бот: сам проверяет новые вакансии, сам отсеивает неподходящие и пишет вам в Telegram, только когда нашёл что-то стоящее. Получится личный отдел кадров из одного Python-файла.

В этой статье мы соберём такого AI-агента для парсинга вакансий с нуля: разберём, чем агент отличается от обычного скрипта, откуда легально брать данные, напишем рабочий код на без тяжёлых фреймворков, подключим Telegram и запустим бота. Будет полезно тем, кто уже немного пишет на Python и хочет понять, как устроены AI-агенты на простом, но честном проекте.

ВАМ ПРИШЛО ПРИГЛАШЕНИЕ 💌
Приходите к нам в соцсети поделиться своим мнением и почитать, что пишут другие. А ещё там выходит дополнительный контент, которого нет на сайте — шпаргалки, опросы и разная дурка. В общем, вот тележка, вот ВК — велком!

Что такое AI-агент и чем он отличается от обычного скрипта

AI-агент — это программа, в которой решения принимает языковая модель. Она смотрит на задачу, выбирает, какой инструмент вызвать, получает результат и решает, что делать дальше. И так по кругу, пока задача не будет решена.

Сравните с обычным скриптом. Скрипт — это жёсткая последовательность шагов: запросить страницуразобрать ответприменить фильтротправить результат. Шаги всегда одни и те же и всегда в одном порядке. Если на сайте поменялась структура или вам нужна другая логика отбора — вы лезете в код и переписываете шаги руками.

Агент устроен иначе. Вы даёте модели набор инструментов и цель, а порядок действий она выбирает сама. Попросили «найди питоновские вакансии от 150 тысяч и пришли только удалёнку» — агент сам сообразит, что сначала надо вызвать поиск, потом отфильтровать выдачу по смыслу, а потом отправить уведомление. Никаких новых условий в коде писать не нужно: логику вы задаёте словами, а не if-ами.

Алгоритмы работы AI-агента и скрипта

Запомните эту петлю — на ней держится весь проект. Дальше мы только наполняем её содержимым: даём агенту инструменты и учим пользоваться ими в нужный момент.

Откуда легально брать данные о вакансиях

Самый частый первый порыв — спарсить 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 разработчик». Ваш код выполняет функцию и отдаёт результат обратно. Всё. Этого достаточно, чтобы собрать рабочего агента в сотню строк.

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

Минимальная архитектура агента

Чтобы собрать агента, нужны всего три вещи:

  1. Системный промпт — текст, который объясняет модели её роль и цель. Здесь мы говорим: «ты ассистент по поиску работы, найди вакансии и пришли подходящие».
  2. Инструменты — обычные функции с понятным описанием. У нас их две: одна ищет вакансии, другая отправляет уведомление.
  3. Цикл обработки — код, который крутится по кругу: спрашивает модель, выполняет запрошенные функции, возвращает результат и спрашивает снова — пока модель не скажет, что закончила.

Схема работы элементов AI-агента

Дальше мы соберём каждую из трёх частей по очереди.

Пишем агента — инструменты и основной цикл

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

Схема работы нашего будущего агента

Начнём с инструментов. Это самые обычные функции — никакой магии в них нет.

Первый инструмент — поиск вакансий. Он ходит в открытый 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 нужен, чтобы бот писал вам, а не в пустоту.

Сообщение с ID от @Getmyid_Work_Bot

Запустите бота — он не может написать первым тому, кто ему ни разу не писал — без этого шага сообщение от агента не дойдёт, хотя код отработает без ошибок.

Шаг 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 секунд агент задумается — это нормально.

Если всё собралось, бот пришлет вам первое сообщение с вакансиями:

Сообщения от нашего AI-агента!

Про «настраиваем фильтры» — самое приятное. Вам не нужно писать новые условия в коде. Критерии вы задаёте текстом запроса: «питон от 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

Советуем дополнительно почитать по теме 

Бонус для читателей

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

Вам может быть интересно
medium
[anycomment]
Exit mobile version