516 lines
20 KiB
Python
516 lines
20 KiB
Python
"""
|
||
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 "Не удалось получить полные данные о погоде."
|