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