Новостной бот: парсинг и отправка

Учим «Цыплёнка-помощника» приносить свежие новости: бот ходит за лентой во внешний источник, выдёргивает из ответа заголовки и ссылки и присылает тебе аккуратную подборку из нескольких пунктов.

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

В прошлом уроке про запросы к API через aiohttp мы научили «Цыплёнка-помощника» ходить в интернет и приносить ответ от чужого сервера — например, погоду. Но там ответ был коротеньким: пара чисел, и всё. Сегодня задача интереснее: лента новостей — это не одно число, а целый список из десятков статей, и из каждой надо аккуратно вытащить заголовок и ссылку. Это и есть парсинг, и сейчас ты увидишь, что ничего страшного в нём нет.

Зачем боту приносить новости

Представь: у тебя есть Telegram-бот для друзей-геймеров. Каждое утро кто-то спрашивает в чате «чё там нового вышло?», и приходится лезть на сайты, копировать ссылки, кидать их вручную. Скучно. А что если бот сам сходит за свежими новостями про игры и пришлёт подборку из пяти заголовков, на каждый из которых можно нажать и сразу открыть статью?

Именно к этому мы придём к концу урока. Вот как будет выглядеть результат команды /news в чате:

@chick_helper_bot
🐤 Свежие новости для тебя:

1. Вышло крупное обновление для популярной игры
https://example-news.ru/big-update

2. Анонсирован турнир с призовым фондом
https://example-news.ru/tournament-2026

3. Разработчики показали трейлер новой части
https://example-news.ru/new-trailer

Результат: по команде /news бот сходит за лентой новостей, возьмёт из неё несколько первых статей и пришлёт пронумерованный список «заголовок + ссылка». На каждую ссылку можно нажать прямо в Telegram и открыть новость.

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

Откуда берётся лента: API и RSS

Чтобы прислать новости, бот должен сначала их откуда-то получить. Источников два, и оба работают по уже знакомой схеме: бот делает HTTP-запрос и получает в ответ текст. Разница — в формате этого текста.

Вариант 1: новостной API (JSON)

Многие новостные сервисы дают свой API — адрес, по которому, если зайти, тебе вернут новости в виде JSON. JSON мы уже видели в уроке про погоду: это вложенные словари и списки, прямо как в Python. Выглядит ответ примерно так:

{
  "articles": [
    {
      "title": "Вышло крупное обновление для популярной игры",
      "url": "https://example-news.ru/big-update"
    },
    {
      "title": "Анонсирован турнир с призовым фондом",
      "url": "https://example-news.ru/tournament-2026"
    }
  ]
}

Результат: сервер вернул словарь, внутри которого по ключу articles лежит список статей. Каждая статья — это маленький словарь с ключами title (заголовок) и url (ссылка).

Это самый удобный для нас формат: данные уже разложены по полочкам, бери да доставай по ключам. Минус один — у многих API надо регистрироваться и получать свой ключ (отдельный, не путай с токеном бота!). Этот ключ, как и токен, секретный, и его тоже держат в переменных окружения, а не в коде. Зато взамен ты получаешь чистые, заранее причёсанные данные: сервис уже сам отобрал свежие статьи и сложил их в аккуратный список, тебе остаётся только пробежаться по нему циклом.

Вариант 2: RSS-лента (XML)

Почти у каждого сайта с новостями есть RSS — специальный адрес (часто вида /rss или /feed), по которому сайт отдаёт свои свежие материалы в стандартном формате XML. Регистрироваться обычно не нужно — просто заходи и читай. Это как «дайджест от сайта» в машиночитаемом виде.

XML похож на HTML: данные обёрнуты в теги вида <тег>значение</тег>. Кусочек RSS-ленты выглядит так:

<rss>
  <channel>
    <item>
      <title>Разработчики показали трейлер новой части</title>
      <link>https://example-news.ru/new-trailer</link>
    </item>
    <item>
      <title>Анонсирован турнир с призовым фондом</title>
      <link>https://example-news.ru/tournament-2026</link>
    </item>
  </channel>
</rss>

Результат: сервер вернул XML, в котором каждая новость — это блок <item>, а внутри него заголовок лежит в теге <title>, ссылка — в теге <link>.

Какой вариант выбрать? Если у выбранного сервиса есть удобный JSON-API — бери его, с ним меньше возни. Если нет, а RSS есть почти у всех — берём RSS. В уроке мы разберём оба, но в финальном боте остановимся на RSS: он бесплатный и не требует ключа.

Парсинг: достаём заголовки и ссылки

Получить ответ — полдела. Теперь надо распарсить его: пройтись по списку статей и из каждой вытащить заголовок и ссылку. Разберём оба формата на маленьких самодостаточных примерах — их можно запустить прямо в браузере, потому что это чистый Python без всякого Telegram.

Парсим JSON

JSON в Python превращается в обычные словари и списки. Достаём список статей по ключу и идём по нему циклом:

data = {
    "articles": [
        {"title": "Вышло крупное обновление", "url": "https://news.ru/a1"},
        {"title": "Анонсирован турнир", "url": "https://news.ru/a2"},
        {"title": "Показали новый трейлер", "url": "https://news.ru/a3"},
    ]
}

articles = data["articles"]
for i, item in enumerate(articles[:2], start=1):
    print(f"{i}. {item['title']}")
    print(item["url"])

Вывод:

1. Вышло крупное обновление
https://news.ru/a1
2. Анонсирован турнир
https://news.ru/a2

Что здесь происходит по шагам:

  • data["articles"] — достаём из словаря список статей по ключу articles.
  • articles[:2] — берём срез: только первые две статьи. В реальном боте поставишь, скажем, [:5], чтобы не заваливать человека двадцатью ссылками.
  • enumerate(..., start=1) — нумерует элементы начиная с единицы, чтобы получился список 1., 2., 3.
  • item['title'] и item['url'] — у каждой статьи достаём заголовок и ссылку по их ключам.

Парсим XML (RSS)

С XML чуть хитрее: его надо сначала разобрать в дерево тегов. В стандартной библиотеке Python для этого есть модуль xml.etree.ElementTree — ничего ставить не нужно. Он превращает текст XML в дерево, по которому можно искать нужные теги:

import xml.etree.ElementTree as ET

xml_text = """
<rss>
  <channel>
    <item>
      <title>Показали новый трейлер</title>
      <link>https://news.ru/trailer</link>
    </item>
    <item>
      <title>Анонсирован турнир</title>
      <link>https://news.ru/cup</link>
    </item>
  </channel>
</rss>
"""

root = ET.fromstring(xml_text)
for i, item in enumerate(root.iter("item"), start=1):
    title = item.find("title").text
    link = item.find("link").text
    print(f"{i}. {title}")
    print(link)

Вывод:

1. Показали новый трейлер
https://news.ru/trailer
2. Анонсирован турнир
https://news.ru/cup

Разберём новые слова:

  • ET.fromstring(xml_text) — берёт текст XML и строит из него дерево; root — это его корень (тег <rss>).
  • root.iter("item") — проходит по всем тегам <item> в дереве, где бы они ни лежали. Каждый item — одна новость.
  • item.find("title") — ищет внутри новости тег <title>, а .text достаёт его содержимое (сам заголовок без тегов). Так же берём и <link>.

Видишь? И JSON, и XML в итоге сводятся к одному и тому же: пройтись циклом по статьям и из каждой достать две строки. Парсинг — это просто «разбор по нужным кусочкам», и пугаться тут нечего.

Ходим за лентой через aiohttp

Маленькие примеры выше работали на готовых данных. В настоящем боте ленту надо сначала скачать — а это мы уже умеем делать через aiohttp из прошлого урока. Напомню схему: открываем сессию, делаем GET-запрос, забираем ответ. Только теперь нам нужен не .json(), а .text() — потому что RSS приходит текстом:

import aiohttp
import xml.etree.ElementTree as ET

RSS_URL = "https://example-news.ru/rss"

async def fetch_news(limit: int = 5):
    async with aiohttp.ClientSession() as session:
        async with session.get(RSS_URL) as response:
            xml_text = await response.text()

    root = ET.fromstring(xml_text)
    news = []
    for item in root.iter("item"):
        title = item.find("title").text
        link = item.find("link").text
        news.append((title, link))
        if len(news) == limit:
            break
    return news

Результат: функция fetch_news() сходит по адресу RSS-ленты, скачает XML, разберёт его и вернёт список из limit новостей — каждая в виде пары (заголовок, ссылка). Например, [("Вышло обновление", "https://..."), ("Анонсирован турнир", "https://...")].

Что важно заметить:

  • Функция async, потому что внутри она ждёт ответа из сети (await). Это та самая асинхронность: пока бот ждёт новости с чужого сервера, он не «зависает», а может отвечать другим пользователям.
  • Мы не тащим всю ленту целиком: как только набрали limit новостей, делаем break и выходим из цикла. Лишнее нам не нужно.
  • Функция возвращает чистые данные — список пар, без всякого Telegram. Отправкой займётся хэндлер. Так удобнее: «добыть» и «показать» — разные задачи.

Собираем подборку и отправляем пользователю

Лента добыта и разобрана — осталось красиво её оформить и прислать. Сначала превратим список пар в один аккуратный текст, потом отправим его командой /news. Вот кусок bot.py, который добавляется к нашему «Цыплёнку-помощнику»:

from aiogram.filters import Command
from aiogram.types import Message

def format_news(news: list) -> str:
    if not news:
        return "🐤 Пока не нашёл свежих новостей, загляни попозже!"
    lines = ["🐤 Свежие новости для тебя:\n"]
    for i, (title, link) in enumerate(news, start=1):
        lines.append(f"{i}. {title}\n{link}\n")
    return "\n".join(lines)

@dp.message(Command("news"))
async def news_handler(message: Message):
    await message.answer("🐤 Минутку, бегу за новостями...")
    try:
        news = await fetch_news(limit=5)
    except Exception:
        await message.answer("🐤 Не получилось достать новости. Попробуй ещё раз чуть позже.")
        return
    await message.answer(format_news(news), disable_web_page_preview=True)

Результат: по команде /news бот сначала пишет «Минутку, бегу за новостями...», затем скачивает и разбирает ленту и присылает пронумерованную подборку из пяти заголовков со ссылками. Если что-то пошло не так (сайт недоступен, лента сломалась) — бот вежливо извинится, а не упадёт.

Разберём по шагам:

  1. format_news() — чистая функция, которая собирает из списка пар один текст. Сначала проверяет, не пустой ли список (вдруг новостей не нашлось), потом строит строки вида «номер. заголовок» и под ним ссылку.
  2. "\n".join(lines) — склеивает все строчки в одно сообщение через переносы строк. Собирать длинный текст списком и склеивать в конце — привычка хорошего тона, аккуратнее, чем плюсовать строки по одной.
  3. Перед запросом бот отвечает «Минутку...», чтобы человек видел: команда принята, идёт работа. Поход в сеть занимает время — без такого сообщения кажется, что бот завис.
  4. try / except оборачивает поход в сеть: внешний сайт может не ответить или прислать кривой XML. Без этой защиты одна ошибка уронит хэндлер. С ней — бот спокойно извинится.
  5. disable_web_page_preview=True отключает автоматические превью ссылок. Иначе Telegram под каждой из пяти ссылок развернёт большую картинку-превью, и сообщение растянется на весь экран.

Частые ошибки и подводные камни

1. Перепутал .json() и .text() для RSS

RSS приходит как XML — это обычный текст, а не JSON. Если по привычке вызвать await response.json(), aiohttp попытается разобрать XML как JSON и упадёт с ошибкой вроде «не удалось декодировать JSON». Для RSS всегда бери await response.text(), а уже потом скармливай текст в ET.fromstring(). И наоборот: для JSON-API нужен .json(), не путай форматы.

2. Берёшь всю ленту и заваливаешь чат

В RSS-ленте часто 30–50 свежих статей. Если отправить их все, получится простыня на десять экранов, да ещё и Telegram может обрезать слишком длинное сообщение (предел около 4096 символов). Всегда ограничивай выборку — пять-десять новостей за глаза. Для этого и нужен limit с break в цикле или срез [:5].

3. Падение из-за пустого тега

Иногда у новости нет тега <title> или он пустой. Тогда item.find("title") вернёт None, а попытка взять None.text уронит бота с ошибкой AttributeError. Подстрахуйся: проверяй результат find перед тем, как брать .text, или используй запасное значение, например title = (el.text if (el := item.find("title")) is not None else "Без заголовка").

4. Нет обработки сетевых ошибок

Внешний сайт — не твой: он может лежать, тормозить или вернуть мусор. Если не обернуть запрос в try / except, любой такой сбой превратится в ошибку прямо посреди хэндлера, и пользователь увидит молчание вместо ответа. Сетевые походы наружу всегда оборачивай в try / except и показывай человеку понятное «не получилось, попробуй позже».

5. Забыл отключить превью ссылок

Мелочь, которая бесит. Если не поставить disable_web_page_preview=True, Telegram под каждой ссылкой развернёт картинку и описание сайта. Пять новостей превратятся в гигантскую ленту превью, в которой не видно самих заголовков. Один параметр — и сообщение снова компактное.

Мини-практика: новости по теме

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

  • Пользователь пишет /news игры — бот присылает только те новости, в заголовке которых встречается слово «игры» (без учёта регистра).
  • Если после команды ничего не написали (просто /news) — бот, как и раньше, шлёт всю свежую подборку.
  • Если по теме ничего не нашлось — бот честно отвечает «По теме „игры“ пока ничего нет».

Подсказки: текст после команды можно достать из message.text — отрежь от него саму команду и получишь тему. Чтобы сравнивать без учёта регистра, приводи и заголовок, и тему к нижнему регистру через .lower(), а проверку делай оператором in: theme in title.lower(). Фильтровать удобно прямо при сборке списка новостей.

Когда заработает — попробуй усложнить: команда /sources, которая ходит сразу за двумя разными RSS-лентами и присылает смешанную подборку. Подсказка: вызови fetch_news() для каждого адреса и сложи списки через +.

Итоги

Сегодня «Цыплёнок-помощник» научился приносить свежие новости — настоящий мини-агрегатор в кармане. Что у тебя теперь в арсенале:

  • Два источника ленты: новостной API (отдаёт JSON) и RSS (отдаёт XML); RSS обычно бесплатен и не требует ключа;
  • Парсинг JSON — достаём список статей по ключу и идём по нему циклом, забирая title и url;
  • Парсинг XML через xml.etree.ElementTree: ET.fromstring(), root.iter("item") и item.find("title").text;
  • Поход за лентой через aiohttp с await response.text() и ограничением количества новостей;
  • Сборка и отправка подборки одним сообщением с disable_web_page_preview=True и защитой через try / except.

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

В следующих уроках раздела мы научим бота не ждать команды, а присылать новости сам — по расписанию, например каждое утро. Для этого подружим его с планировщиком задач. Так наш «Цыплёнок» из «принеси по запросу» превратится в «сам напомню вовремя». До встречи!

Проверьте себя
1. Что такое парсинг ответа от новостного источника?
AОтправка запроса на сервер Telegram
BРазбор «сырого» ответа на понятные кусочки — выдёргивание нужных данных, например заголовков и ссылок
CШифрование токена бота перед отправкой
DСохранение всех новостей в базу данных SQLite
2. Чем RSS-лента отличается от новостного JSON-API с точки зрения формата ответа?
ARSS отдаёт данные в формате XML, а JSON-API — в формате JSON
BRSS работает только через webhook, а API — только через polling
CRSS возвращает картинки, а API — только текст
DМежду ними нет никакой разницы, это одно и то же
3. Какой метод ответа aiohttp нужно вызвать, чтобы получить RSS-ленту для разбора?
Aawait response.json(), потому что RSS — это всегда словарь
Bawait response.text(), потому что RSS приходит как текст (XML), а уже его разбирают через ElementTree
Cawait response.read_xml(), специальный метол для RSS
DНикакой — RSS приходит уже готовым списком новостей
4. Зачем при отправке подборки новостей указывать disable_web_page_preview=True?
AЧтобы ускорить запрос к новостному серверу
BЧтобы Telegram не разворачивал большое превью под каждой ссылкой и сообщение оставалось компактным
CЧтобы скрыть ссылки от пользователя
DЭто обязательный параметр, без него message.answer не работает
5. Почему поход за новостями оборачивают в try / except?
AЧтобы бот работал быстрее
BВнешний сайт может быть недоступен или прислать кривой ответ — без обработки ошибки хэндлер упадёт и пользователь не получит ответа
CЭто требование BotFather для всех ботов
DЧтобы автоматически сохранять новости в базу
6. Почему функция fetch_news ограничивает количество новостей (limit) и делает break в цикле?
AИначе Telegram заблокирует бота за спам
BВ ленте могут быть десятки статей; без ограничения сообщение станет огромным и может превысить лимит Telegram в ~4096 символов
Cbreak нужен, чтобы функция стала асинхронной
DБез limit код не скомпилируется