Бот-погода

Учим Цыплёнка спрашивать город и приносить из интернета настоящую погоду — чтобы перед прогулкой не гадать, брать ли куртку.

Бот-погода — это бот, который принимает от тебя название города, через aiohttp ходит к внешнему погодному API, достаёт из ответа температуру и описание неба и присылает их человеческим языком.

Зачем это нужно

Представь утро перед школой. Ты ещё в кровати, телефон под подушкой, а на улице непонятно что: то ли солнце, то ли дождь стеной. Открывать приложение погоды лень — там реклама, виджеты, прогноз на десять дней, а тебе нужно одно: брать куртку или нет. Вот бы написать в чат одно слово — Казань — и сразу получить ответ: «+7, облачно, накинь толстовку».

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

Вот к чему мы придём. Ты пишешь боту название города, он думает секунду и отвечает:

Ты: Казань
Цыплёнок: 🐤 В городе Казань сейчас +7°C, облачно с прояснениями.
         Ощущается как +4°C. Бери толстовку!

Никакой магии — обычный HTTP-запрос плюс аккуратный разбор ответа. Поехали.

Откуда бот берёт погоду

Сам по себе бот про погоду ничего не знает. У него нет градусника за окном и нет спутника на орбите. Зато в интернете есть сервисы, которые всё это держат у себя и готовы поделиться данными — нужно только вежливо попросить. Такой сервис называется API: набор HTTP-запросов, через которые твоя программа спрашивает у чужого сервера и получает ответ.

Представь библиотекаря за стойкой. Ты не лезешь сам на склад книг — ты подходишь и говоришь: «Дайте, пожалуйста, погоду в Казани». Библиотекарь уходит, возвращается с карточкой и читает: «+7, облачно». Погодный API — это и есть такой библиотекарь. Ты шлёшь ему запрос с названием города, он отдаёт тебе аккуратную карточку с данными.

Карточку он отдаёт не словами, а в формате JSON — это текст, который выглядит как вложенные словари и списки Python. Внутри лежат температура, описание неба, влажность и куча всего ещё. Наша задача — достать оттуда только нужное.

Мы возьмём бесплатный сервис wttr.in: у него есть режим, который отдаёт погоду в JSON, и ему не нужен отдельный ключ — удобно для учёбы. В реальном проекте чаще берут OpenWeatherMap с токеном, но принцип ровно тот же: шлём запрос, читаем JSON, достаём поля.

Запомни картинку: бот — официант. Ты сказал «погоду в Казани», официант сбегал на кухню Telegram и дальше к погодному сервису, принёс блюдо и поставил перед тобой. Async тут именно для того, чтобы официант, пока бегает за твоим заказом, успевал обслужить и других гостей чата.

Разбираем по шагам

Шаг 1. Тренируемся разбирать JSON без интернета

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

fake_answer = {
    "current_condition": [
        {
            "temp_C": "7",
            "FeelsLikeC": "4",
            "weatherDesc": [{"value": "Partly cloudy"}],
        }
    ]
}

current = fake_answer["current_condition"][0]
temp = current["temp_C"]
feels = current["FeelsLikeC"]
desc = current["weatherDesc"][0]["value"]

print(f"Температура: {temp}°C")
print(f"Ощущается как: {feels}°C")
print(f"Небо: {desc}")

Вывод:

Температура: 7°C
Ощущается как: 4°C
Небо: Partly cloudy

Смотри внимательно на путь к данным. current_condition — это список, поэтому сразу после него идёт [0]: берём первый (и единственный) элемент. Внутри — словарь, из него по ключу достаём temp_C. А weatherDesc снова список, поэтому опять [0], и уже внутри ключ value. Эта «лесенка» из квадратных скобок и есть главный навык работы с API: ты идёшь по структуре ответа сверху вниз, как по этажам.

Шаг 2. Делаем настоящий запрос через aiohttp

Теперь подключаем интернет. Запрос к серверу — операция небыстрая, поэтому в асинхронном боте мы используем aiohttp и ждём ответ через await, не блокируя остальных. Этот код уже часть бота, поэтому он помечен как обычный текст — в браузере его не запустить, ему нужен живой Telegram и сеть.

import aiohttp

async def get_weather(city: str) -> dict | None:
    url = f"https://wttr.in/{city}"
    params = {"format": "j1"}
    async with aiohttp.ClientSession() as session:
        async with session.get(url, params=params) as response:
            if response.status != 200:
                return None
            return await response.json()

Результат: функция сходит на wttr.in, попросит погоду в формате j1 (это JSON) и вернёт большой словарь с данными. Если сервер ответил не «200 ОК» (например, города нет или сервис лёг), вернётся None — это наш сигнал «не получилось».

Разберём ключевые места. async with aiohttp.ClientSession() открывает сессию — это как телефонная линия до сервера; async with гарантирует, что линию аккуратно положат, даже если случится ошибка. session.get(...) отправляет запрос и через await ждёт ответ. response.status — код ответа: 200 значит «всё хорошо». А await response.json() превращает присланный текст в обычный словарь Python — тот самый, который мы тренировались разбирать в шаге 1.

Шаг 3. Собираем красивое сообщение

Сырой словарь человеку показывать нельзя — он испугается. Сделаем отдельную функцию, которая достаёт нужные поля и лепит из них тёплый текст. Заметь: описание неба у wttr.in приходит по-английски, поэтому простой словарь-переводчик превратит хотя бы частые состояния в русские слова.

def format_weather(city: str, data: dict) -> str:
    current = data["current_condition"][0]
    temp = current["temp_C"]
    feels = current["FeelsLikeC"]
    desc_en = current["weatherDesc"][0]["value"]

    translate = {
        "Sunny": "солнечно",
        "Clear": "ясно",
        "Partly cloudy": "облачно с прояснениями",
        "Cloudy": "облачно",
        "Overcast": "пасмурно",
        "Light rain": "небольшой дождь",
    }
    desc = translate.get(desc_en, desc_en)

    advice = "Бери толстовку!" if int(temp) < 12 else "Можно идти налегке."
    return (
        f"🐤 В городе {city} сейчас {temp}°C, {desc}.\n"
        f"Ощущается как {feels}°C. {advice}"
    )

Результат: функция вернёт готовую строку вроде «🐤 В городе Казань сейчас +7°C, облачно с прояснениями. Ощущается как +4°C. Бери толстовку!». Метод translate.get(desc_en, desc_en) хитрый: если перевод для состояния нашёлся — берём его, если нет — оставляем как было, чтобы бот не молчал на редкой погоде.

Шаг 4. Связываем всё в хэндлере

Осталось соединить части в хэндлере — функции, которую aiogram вызовет, когда пользователь пришлёт боту название города. Объекты bot и dp у нас уже созданы в bot.py из прошлых уроков, токен мы по-прежнему держим в переменной окружения. Добавляем новый кусок к тому же боту.

from aiogram import F
from aiogram.types import Message

@dp.message(F.text)
async def weather_handler(message: Message):
    city = message.text.strip()
    await message.answer(f"Секунду, узнаю погоду в городе {city}...")

    data = await get_weather(city)
    if data is None:
        await message.answer("Не нашёл такой город 😢 Проверь название и попробуй ещё раз.")
        return

    text = format_weather(city, data)
    await message.answer(text)

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

Обрати внимание на F.text: это фильтр aiogram, который означает «срабатывай на любое текстовое сообщение». А message.text.strip() убирает случайные пробелы по краям — пользователи вечно ставят лишний пробел, и без strip() сервер мог бы не найти город.

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

  • Забыть, что current_condition — список. Самая популярная ошибка новичков: написать data["current_condition"]["temp_C"] и получить TypeError. Между ключом и температурой обязательно стоит [0], потому что сервер отдаёт список из одного элемента. Всегда смотри на структуру ответа, прежде чем лезть в неё.
  • Не проверять status и не ловить None. Если сервер прилёг или города нет, а ты сразу зовёшь format_weather на пустых данных — бот упадёт с ошибкой, а пользователь увидит молчание. Проверка if data is None в хэндлере спасает от этого: лучше вежливое «не нашёл город», чем зависший бот.
  • Использовать requests вместо aiohttp. Старый знакомый requests работает синхронно: пока он ждёт ответ от сервера, весь бот замирает и не отвечает другим. В асинхронном боте это всё равно что официант, который замер посреди зала и ждёт. Внутри async-функции ходи в сеть только через aiohttp и await.
  • Сравнивать температуру как строку. Сервер присылает температуру строкой: "7", а не числом 7. Если написать temp < 12, Python ругнётся, что нельзя сравнивать строку с числом. Поэтому в коде стоит int(temp) — сначала превращаем в число, потом сравниваем.
  • Открывать новую сессию на каждый чих и не закрывать её. Если создавать ClientSession() без async with и не закрывать, со временем накопятся «висящие» соединения и бот начнёт сыпать предупреждениями. Конструкция async with закрывает линию автоматически — пользуйся ей.

Мини-практика

Базовый Цыплёнок-метеоролог готов. Теперь доведи его до ума сам — выбери задачу по силам:

  1. Эмодзи под погоду. Добавь в format_weather словарь, который к описанию подбирает эмодзи: солнечно → ☀️, облачно → ☁️, дождь → 🌧️. Подставь его в начало сообщения.
  2. Совет по одежде поточнее. Сейчас совет грубый: одна граница на 12 градусов. Сделай несколько уровней: ниже 0 — «надень шапку», 0–12 — «толстовка», выше 20 — «футболка и вода с собой».
  3. Память города (со звёздочкой). Вспомни прошлые уроки про SQLite: сохраняй последний запрошенный город пользователя в базу, а по команде /last показывай погоду в нём без повторного ввода. Так бот станет по-настоящему личным.

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

Итоги

Сегодня Цыплёнок-помощник получил мощную способность: он перестал быть закрытым в себе и научился приносить данные из большого интернета. Ты прошёл весь путь запроса — от голого JSON, который разобрал руками, до живого aiohttp-запроса, аккуратного разбора ответа и тёплого сообщения с советом про куртку. Заодно ты увидел, почему в асинхронном боте важно ходить в сеть через aiohttp, а не requests, и как защититься от «нет такого города».

Главное, что ты унёс: любой внешний сервис — это библиотекарь за стойкой. Ты шлёшь вежливый запрос, получаешь карточку-JSON и достаёшь из неё нужное по «лесенке» из ключей и индексов. С этим навыком тебе открыты тысячи API.

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

Проверьте себя
1. Почему в асинхронном боте для запроса погоды используют aiohttp, а не привычный requests?
Arequests не умеет работать с JSON
Brequests блокирует бота: пока он ждёт ответ сервера, бот не отвечает другим пользователям
Caiohttp быстрее качает большие файлы
Drequests запрещён в Telegram
2. Ответ сервера лежит в переменной data. Как правильно достать температуру?
Adata["temp_C"]
Bdata["current_condition"]["temp_C"]
Cdata["current_condition"][0]["temp_C"]
Ddata[0]["temp_C"]
3. Зачем в коде стоит int(temp), прежде чем сравнивать температуру с числом?
AЧтобы округлить дробную температуру
BСервер присылает температуру строкой, а строку с числом сравнивать нельзя
CЧтобы убрать знак минуса
DЭто требование aiogram
4. Что означает проверка `if data is None` в хэндлере?
AЧто пользователь не ввёл город
BЧто запрос к серверу не удался (например, города нет или сервис не ответил)
CЧто бот ещё не запущен
DЧто закончился токен
5. Зачем нужна конструкция `async with aiohttp.ClientSession() as session`?
AЧтобы открыть сессию и гарантированно её закрыть, даже если случится ошибка
BЧтобы ускорить разбор JSON
CЧтобы перевести описание погоды на русский
DЧтобы хэндлер срабатывал на любой текст
6. Что делает фильтр F.text в декораторе @dp.message(F.text)?
AСрабатывает только на команду /start
BСрабатывает на любое текстовое сообщение от пользователя
CПереводит текст на английский
DФильтрует сообщения по длине