Files
smart-speaker/app/features/weather.py
2026-04-09 21:03:02 +03:00

516 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Weather feature module.
Fetches weather data from Open-Meteo API.
"""
import re
import requests
from datetime import datetime
from ..core.config import WEATHER_LAT, WEATHER_LON, WEATHER_CITY
_HTTP = requests.Session()
_CITY_PREFIX_RE = re.compile(
r"^(?:в|во)\s+(?:город(?:е|у)?\s+)?",
flags=re.IGNORECASE,
)
_CITY_SPACING_RE = re.compile(r"\s+")
_KNOWN_CITY_VARIATIONS = {
"нью йорк": "Нью-Йорк",
"нью-йорк": "Нью-Йорк",
"нью йорке": "Нью-Йорк",
"нью-йорке": "Нью-Йорк",
"нью йорка": "Нью-Йорк",
"нью-йорка": "Нью-Йорк",
"нью йорком": "Нью-Йорк",
"нью-йорком": "Нью-Йорк",
"санкт петербург": "Санкт-Петербург",
"санкт-петербург": "Санкт-Петербург",
"санкт петербурге": "Санкт-Петербург",
"санкт-петербурге": "Санкт-Петербург",
"санкт петербурга": "Санкт-Петербург",
"санкт-петербурга": "Санкт-Петербург",
"санкт петербургом": "Санкт-Петербург",
"санкт-петербургом": "Санкт-Петербург",
"нижний новгород": "Нижний Новгород",
"нижнем новгороде": "Нижний Новгород",
"нижнего новгорода": "Нижний Новгород",
"ростов на дону": "Ростов-на-Дону",
"ростове на дону": "Ростов-на-Дону",
"ростова на дону": "Ростов-на-Дону",
"лос анджелес": "Лос-Анджелес",
"лос-анджелес": "Лос-Анджелес",
"лос анджелесе": "Лос-Анджелес",
"лос-анджелесе": "Лос-Анджелес",
"сан франциско": "Сан-Франциско",
"сан-франциско": "Сан-Франциско",
"улан удэ": "Улан-Удэ",
"улан-удэ": "Улан-Удэ",
}
_SINGLE_WORD_CITY_VARIATIONS = {
"москве": "Москва",
"москвы": "Москва",
"москвой": "Москва",
"москву": "Москва",
"лондоне": "Лондон",
"лондона": "Лондон",
"лондоном": "Лондон",
"париже": "Париж",
"парижа": "Париж",
"парижем": "Париж",
"берлине": "Берлин",
"берлина": "Берлин",
"берлином": "Берлин",
"пекине": "Пекин",
"пекина": "Пекин",
"пекином": "Пекин",
"роме": "Рим",
"рима": "Рим",
"римом": "Рим",
"мадриде": "Мадрид",
"мадрида": "Мадрид",
"мадридом": "Мадрид",
"сиднее": "Сидней",
"сиднея": "Сидней",
"сиднеем": "Сидней",
"вашингтоне": "Вашингтон",
"вашингтона": "Вашингтон",
"вашингтоном": "Вашингтон",
"сиэтле": "Сиэтл",
"сиэтла": "Сиэтл",
"сиэтлом": "Сиэтл",
"бостоне": "Бостон",
"бостона": "Бостон",
"бостоном": "Бостон",
"денвере": "Денвер",
"денвера": "Денвер",
"денвером": "Денвер",
"хьюстоне": "Хьюстон",
"хьюстона": "Хьюстон",
"хьюстоном": "Хьюстон",
"фениксе": "Феникс",
"феникса": "Феникс",
"фениксом": "Феникс",
"атланте": "Атланта",
"атланты": "Атланта",
"атлантой": "Атланта",
"портленде": "Портленд",
"портленда": "Портленд",
"портлендом": "Портленд",
"остине": "Остин",
"остина": "Остин",
"остином": "Остин",
"нэшвилле": "Нэшвилл",
"нэшвилла": "Нэшвилл",
"нэшвиллом": "Нэшвилл",
"токио": "Токио",
"торонто": "Торонто",
"чикаго": "Чикаго",
"майами": "Майами",
}
def _smart_title_city(text: str) -> str:
parts = []
for word in text.split():
hyphen_parts = [part.capitalize() for part in word.split("-") if part]
parts.append("-".join(hyphen_parts))
return " ".join(parts)
def get_wmo_description(code: int) -> str:
"""Decodes WMO weather code to Russian description."""
codes = {
0: "ясно",
1: "преимущественно ясно",
2: "переменная облачность",
3: "пасмурно",
45: "туман",
48: "изморозь",
51: "легкая морось",
53: "умеренная морось",
55: "плотная морось",
56: "ледяная морось",
57: "плотная ледяная морось",
61: "слабый дождь",
63: "умеренный дождь",
65: "сильный дождь",
66: "ледяной дождь",
67: "сильный ледяной дождь",
71: "слабый снег",
73: "снегопад",
75: "сильный снегопад",
77: "снежные зерна",
80: "слабый ливень",
81: "умеренный ливень",
82: "сильный ливень",
85: "слабый снегопад",
86: "сильный снегопад",
95: "гроза",
96: "гроза с градом",
99: "сильная гроза с градом"
}
return codes.get(code, "осадки")
def get_temperature_text(temp: int) -> str:
"""
Returns the correct Russian form for temperature degrees based on the number.
Handles proper Russian grammar cases (падежи) for temperature values.
"""
# Get the absolute value to handle negative temperatures
abs_temp = abs(temp)
# Get the last digit
last_digit = abs_temp % 10
# Get the last two digits to handle special cases like 11-14
last_two_digits = abs_temp % 100
# Special cases for numbers ending in 11-14 (e.g., 11, 12, 13, 14, 111, 112, etc.)
if 11 <= last_two_digits <= 14:
return f"{temp} градусов"
# Cases based on the last digit
if last_digit == 1:
return f"{temp} градус"
elif 2 <= last_digit <= 4:
return f"{temp} градуса"
else: # 5-9, 0
return f"{temp} градусов"
def normalize_city_name(city_name: str) -> str:
"""
Converts city names from various grammatical cases to the base form for geocoding.
Handles common Russian grammatical cases (падежи) for city names.
"""
lowered = str(city_name or "").lower().replace("ё", "е").strip()
if not lowered:
return city_name
lowered = _CITY_PREFIX_RE.sub("", lowered)
lowered = _CITY_SPACING_RE.sub(" ", lowered).strip(" -")
if not lowered:
return city_name
exact_match = _KNOWN_CITY_VARIATIONS.get(lowered)
if exact_match:
return exact_match
single_word_match = _SINGLE_WORD_CITY_VARIATIONS.get(lowered)
if single_word_match:
return single_word_match
spaced = lowered.replace("-", " ")
exact_match = _KNOWN_CITY_VARIATIONS.get(spaced)
if exact_match:
return exact_match
if " " not in spaced:
for suffix, replacement in (
("ом", ""),
("ем", ""),
("ой", "а"),
("ей", "а"),
("е", ""),
("у", "а"),
("ю", "я"),
):
if spaced.endswith(suffix) and len(spaced) > len(suffix) + 2:
candidate = spaced[: -len(suffix)] + replacement
mapped = _SINGLE_WORD_CITY_VARIATIONS.get(candidate)
if mapped:
return mapped
return _smart_title_city(lowered)
def get_coordinates_by_city(city_name: str) -> tuple:
"""
Gets coordinates (lat, lon) for a given city name using Open-Meteo geocoding API.
Returns (lat, lon, city_display_name) or (None, None, None) if not found.
"""
# First try with the original name
try_names = [city_name]
# Add normalized version
normalized_city = normalize_city_name(city_name)
if normalized_city and normalized_city not in try_names:
try_names.append(normalized_city)
normalized_lower = str(normalized_city or city_name).lower().replace("ё", "е").strip()
# Also try with English version if it's a known translation
city_to_eng = {
"москва": "Moscow",
"санкт-петербург": "Saint Petersburg",
"новосибирск": "Novosibirsk",
"екатеринбург": "Yekaterinburg",
"казань": "Kazan",
"нижний новгород": "Nizhny Novgorod",
"челябинск": "Chelyabinsk",
"омск": "Omsk",
"самара": "Samara",
"ростов-на-дону": "Rostov-on-Don",
"уфа": "Ufa",
"красноярск": "Krasnoyarsk",
"владивосток": "Vladivostok",
"сочи": "Sochi",
"новокузнецк": "Novokuznetsk",
"ярославль": "Yaroslavl",
"владикавказ": "Vladikavkaz",
"магнитогорск": "Magnitogorsk",
"иркутск": "Irkutsk",
"хабаровск": "Khabarovsk",
"оренбург": "Orenburg",
"калининград": "Kaliningrad",
"пермь": "Perm",
"волгоград": "Volgograd",
"волгограде": "Volgograd",
"краснодар": "Krasnodar",
"саратов": "Saratov",
"тында": "Tynda",
"тольятти": "Tolyatti",
"барнаул": "Barnaul",
"улан-удэ": "Ulan-Ude",
"иваново": "Ivanovo",
"мурманск": "Murmansk",
"кузнецк": "Kuznetsk",
"архангельск": "Arkhangelsk",
"владимир": "Vladimir",
"калининград": "Kaliningrad",
"смоленск": "Smolensk",
"калука": "Kaluga",
"воронеж": "Voronezh",
"курск": "Kursk",
"астрахань": "Astrakhan",
"липецк": "Lipetsk",
"тамбов": "Tambov",
"курган": "Kurgan",
"пенза": "Penza",
"рязн": "Ryazan",
"орёл": "Oryol",
"якутск": "Yakutsk",
"владикавказ": "Vladikavkaz",
"магас": "Magas",
"нарьян-мар": "Naryan-Mar",
"ханты-мансийск": "Khanty-Mansiysk",
"анадырь": "Anadyr",
"салехард": "Salekhard",
"лондон": "London",
"нью-йорк": "New York",
"токио": "Tokyo",
"париж": "Paris",
"берлин": "Berlin",
"мадрид": "Madrid",
"рим": "Rome",
"милан": "Milan",
"венеция": "Venice",
"амстердам": "Amsterdam",
"прага": "Prague",
"будапешт": "Budapest",
"вена": "Vienna",
"варшава": "Warsaw",
"киев": "Kyiv",
"минск": "Minsk",
"ташкент": "Tashkent",
"алматы": "Almaty",
"астана": "Astana",
"баку": "Baku",
"ереван": "Yerevan",
"тбилиси": "Tbilisi",
"софия": "Sofia",
"белград": "Belgrade",
"любляна": "Ljubljana",
"загреб": "Zagreb",
"рекьявик": "Reykjavik",
"осло": "Oslo",
"стокгольм": "Stockholm",
"копенгаген": "Copenhagen",
"хельсинки": "Helsinki",
"дублин": "Dublin",
"эдинбург": "Edinburgh",
"манчестер": "Manchester",
"бirmingham": "Birmingham",
"ливерпуль": "Liverpool",
"глазго": "Glasgow",
"брюссель": "Brussels",
"цюрих": "Zurich",
"женева": "Geneva",
"осака": "Osaka",
"киото": "Kyoto",
"сингапур": "Singapore",
"бангкок": "Bangkok",
"пекин": "Beijing",
"шанхай": "Shanghai",
"гонконг": "Hong Kong",
"сеул": "Seoul",
"дели": "Delhi",
"мумбаи": "Mumbai",
"бомбей": "Mumbai",
}
eng_name = city_to_eng.get(city_name.lower())
normalized_eng_name = city_to_eng.get(normalized_lower)
if eng_name and eng_name not in try_names:
try_names.append(eng_name)
if normalized_eng_name and normalized_eng_name not in try_names:
try_names.append(normalized_eng_name)
if normalized_city:
hyphen_variant = normalized_city.replace(" ", "-")
space_variant = normalized_city.replace("-", " ")
for variant in (hyphen_variant, space_variant):
if variant and variant not in try_names:
try_names.append(variant)
# Try each name in sequence
for name_to_try in try_names:
try:
# Use Open-Meteo's geocoding API
geocode_url = "https://geocoding-api.open-meteo.com/v1/search"
params = {
"name": name_to_try,
"count": 1,
"language": "ru",
"format": "json"
}
response = _HTTP.get(geocode_url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if "results" in data and len(data["results"]) > 0:
result = data["results"][0]
lat = result["latitude"]
lon = result["longitude"]
display_name = result.get("name", name_to_try) # Use the name from the API if available
return lat, lon, display_name
except Exception as e:
print(f"❌ Ошибка при поиске координат для города {name_to_try}: {e}")
continue # Try the next name
return None, None, None
def get_weather_report(requested_city: str = None) -> str:
"""
Fetches detailed weather report.
Structure:
1. Current temp and precipitation.
2. Today's min/max temp.
3. Next 4 hours forecast (temp + precipitation).
Args:
requested_city: Optional city name to get weather for. If None, uses default city.
"""
# Determine which city to use
if requested_city:
# Try to get coordinates for the requested city
lat, lon, city_display_name = get_coordinates_by_city(requested_city)
if lat is None or lon is None:
return f"Не удалось найти город {requested_city}. Проверьте название и попробуйте снова."
else:
# Use default city from config
if not all([WEATHER_LAT, WEATHER_LON, WEATHER_CITY]):
return "Настройки погоды не найдены. Проверьте конфигурацию."
lat = float(WEATHER_LAT)
lon = float(WEATHER_LON)
city_display_name = WEATHER_CITY
url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": lat,
"longitude": lon,
"current": "temperature_2m,precipitation,weather_code",
"hourly": "temperature_2m,precipitation,weather_code",
"daily": "temperature_2m_max,temperature_2m_min",
"timezone": "auto",
"forecast_days": 2
}
try:
response = _HTTP.get(url, params=params, timeout=10)
response.raise_for_status()
data = response.json()
# --- 1. Current Weather ---
curr = data["current"]
temp_now = round(curr["temperature_2m"])
precip_now = curr["precipitation"]
code_now = curr["weather_code"]
desc_now = get_wmo_description(code_now)
report = f"Сейчас в городе {city_display_name} {get_temperature_text(temp_now)}, {desc_now}."
if precip_now > 0:
report += f" Выпало {precip_now} миллиметров осадков."
# --- 2. Today's Range ---
# daily arrays usually start from today [0]
daily = data["daily"]
t_max = round(daily["temperature_2m_max"][0])
t_min = round(daily["temperature_2m_min"][0])
report += f" Сегодня температура будет от {get_temperature_text(t_min)} до {get_temperature_text(t_max)}."
# --- 3. Forecast Next 4 Hours ---
hourly_temps = data["hourly"]["temperature_2m"]
hourly_precip = data["hourly"]["precipitation"]
hourly_codes = data["hourly"]["weather_code"]
hourly_times = data["hourly"].get("time", [])
# Start from the next hour based on the API's current time (timezone-aware for the location).
current_time_iso = data.get("current", {}).get("time")
if current_time_iso and current_time_iso in hourly_times:
start_idx = hourly_times.index(current_time_iso) + 1
else:
current_hour = datetime.now().hour
start_idx = current_hour + 1
end_idx = min(start_idx + 4, len(hourly_temps))
next_temps = hourly_temps[start_idx:end_idx]
next_precip = hourly_precip[start_idx:end_idx]
next_codes = hourly_codes[start_idx:end_idx]
if next_temps:
report += " Прогноз на ближайшие 4 часа: "
# Simple approach for TTS:
avg_temp = round(sum(next_temps) / len(next_temps))
# Check if any precipitation is expected
will_precip = any(p > 0 for p in next_precip)
unique_codes = set(next_codes)
# Determine dominant weather description
if len(unique_codes) == 1:
weather_desc = get_wmo_description(list(unique_codes)[0])
else:
# Priority to precipitation codes
precip_codes = [c for c in unique_codes if c > 3] # >3 implies not clear/cloudy
if precip_codes:
weather_desc = get_wmo_description(max(precip_codes)) # Take the most severe
else:
weather_desc = "переменная облачность"
report += f"температура около {get_temperature_text(avg_temp)}, {weather_desc}."
if will_precip:
report += " Ожидаются осадки."
else:
report += " Без существенных осадков."
return report
except requests.exceptions.ConnectionError:
print(f"❌ Ошибка подключения к сервису погоды: невозможно подключиться к серверу")
return f"Не удалось подключиться к сервису погоды. Проверьте интернет-соединение."
except requests.exceptions.Timeout:
print(f"❌ Таймаут запроса к сервису погоды")
return f"Время ожидания ответа от сервиса погоды истекло."
except requests.exceptions.HTTPError as e:
print(f"❌ HTTP ошибка при получении погоды: {e}")
return f"Ошибка при получении данных о погоде: {e}"
except KeyError as e:
print(f"❌ Ошибка структуры данных погоды: {e}")
return f"Получены некорректные данные о погоде."
except Exception as e:
print(f"❌ Ошибка получения погоды: {e}")
return "Не удалось получить полные данные о погоде."