commit 52d24db54d5104aff578660bc6e66960d9844ed5 Author: Artur Mukhamadiev Date: Wed Aug 19 20:03:42 2026 +0300 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6cc3b03 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Скопируйте этот файл в .env и укажите новый ключ Яндекс.Погоды. +YANDEX_WEATHER_KEY=replace_with_new_key diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd76e71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +.venv/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c834d0d --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# Клиент Яндекс.Погоды + +Небольшой Python-клиент для получения текущей погоды и прогноза через REST API +Яндекс.Погоды для бесплатного тарифа «Погода для умного дома». Встроенный +погодный источник OpenClaw не используется. + +## Настройка + +1. Отзовите ранее опубликованный ключ и создайте новый в кабинете Яндекс.Погоды. +2. Скопируйте `.env.example` в `.env`. +3. Запишите новый ключ в `.env`: + + ```env + YANDEX_WEATHER_KEY=новый_ключ + ``` + + Не отправляйте ключ в чат и не добавляйте `.env` в Git. +4. При желании создайте виртуальное окружение: + + ```bash + python3 -m venv .venv + ``` + + Внешние библиотеки не требуются — клиент использует стандартную библиотеку + Python. + +## Запуск + +Доступные сохранённые локации: + +```bash +python3 weather_client.py --list-locations +``` + +Прогноз по адресу можно запросить по ключу или по одному из сохранённых +названий: + +```bash +python3 weather_client.py --location SOME_LOCATION +python3 weather_client.py --location +``` + +Координаты по-прежнему можно указывать явно: + +```bash +python3 weather_client.py --lat 10.321 --lon 25.6173 --days 2 +``` + +Для исходного JSON: + +```bash +python3 weather_client.py --lat 10.7558 --lon 25.6173 --json +``` + +Клиент делает ровно один запрос API за один запуск, выводит текущую погоду, +прогноз на сегодня и завтра и ссылку на карту осадков Яндекс.Погоды для +найденного населённого пункта. Ссылка на карту открывает веб-страницу Яндекса +и не делает дополнительного запроса к API этим клиентом. + +Сохранённые координаты адресов находятся в [locations.json](locations.json). +Они должны быть запрошены у пользователя и найдены на яндекс картах перед началом работы. + +## Ограничения бесплатного тарифа + +Бесплатный тариф «Умный дом» предназначен для некоммерческого использования, +передаёт данные только на сегодня и завтра и предоставляет температуру, +скорость ветра, погодные условия, «ощущается как» и иконку. Поэтому числовые +данные об осадках и тайлы карты нельзя гарантировать через этот API-клиент; +карта добавляется как ссылка на веб-сервис Яндекса. Автоматических повторов +запроса в клиенте нет, чтобы не расходовать лимит 1000 запросов в месяц / 30 +запросов в день. + +## Что нужно для следующего шага + +- координаты или название нужной локации; +- время и периодичность отправки в Matrix; +- желаемый формат сообщения: короткий прогноз или подробный почасовой. + +API использует заголовок `X-Yandex-Weather-Key` и параметры `lat`, `lon`, `limit` +и `hours`. Подробности — в [официальной документации REST API] +(https://yandex.ru/dev/weather/doc/ru/concepts/forecast-rest). Условия и +ограничения бесплатного тарифа — в [условиях API для физических лиц] +(https://yandex.ru/legal/apib2c_weather_agreement/ru/). diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ab72902 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +# Внешние зависимости не требуются: используется стандартная библиотека Python. diff --git a/weather_client.py b/weather_client.py new file mode 100644 index 0000000..d9bb931 --- /dev/null +++ b/weather_client.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Получение прогноза через API Яндекс.Погоды для тарифа «Умный дом». + +Ключ берётся из переменной окружения YANDEX_WEATHER_KEY или из локального +файла .env рядом со скриптом. Ключ никогда не выводится в консоль. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urlparse, urlunparse +from urllib.request import Request, urlopen + + +API_URL = "https://api.weather.yandex.ru/v2/forecast" +PROJECT_DIR = Path(__file__).resolve().parent +LOCATIONS_PATH = PROJECT_DIR / "locations.json" + +CONDITIONS = { + "clear": "ясно", + "partly-cloudy": "малооблачно", + "cloudy": "облачно с прояснениями", + "overcast": "пасмурно", + "light-rain": "небольшой дождь", + "rain": "дождь", + "heavy-rain": "сильный дождь", + "showers": "ливень", + "wet-snow": "дождь со снегом", + "light-snow": "небольшой снег", + "snow": "снег", + "snow-showers": "снегопад", + "hail": "град", + "thunderstorm": "гроза", + "thunderstorm-with-rain": "гроза с дождём", + "thunderstorm-with-hail": "гроза с градом", +} + + +def load_locations() -> dict[str, dict[str, Any]]: + if not LOCATIONS_PATH.exists(): + return {} + return json.loads(LOCATIONS_PATH.read_text(encoding="utf-8")) + + +def normalize_location(value: str) -> str: + return " ".join(value.casefold().replace(",", " ").split()) + + +def find_location(query: str, locations: dict[str, dict[str, Any]]) -> tuple[str, dict[str, Any]]: + normalized_query = normalize_location(query) + for key, location in locations.items(): + candidates = [key, location.get("name", ""), *location.get("aliases", [])] + if any(normalized_query == normalize_location(str(candidate)) for candidate in candidates): + return key, location + available = ", ".join(locations) or "нет сохранённых локаций" + raise ValueError(f"Локация не найдена: {query}. Доступны: {available}") + +def load_dotenv(path: Path) -> None: + """Минимально загружает KEY=VALUE из .env без сторонней зависимости.""" + if not path.exists(): + return + + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +def get_map_url(info_url: str | None, lat: float | None = None, lon: float | None = None) -> str: + """Добавляет страницу карты осадков к URL населённого пункта Яндекса.""" + if info_url: + parsed = urlparse(info_url) + segments = [segment for segment in parsed.path.split("/") if segment] + if len(segments) >= 2 and segments[-1] not in {"pogoda", "weather"}: + path = parsed.path.rstrip("/") + "/maps/nowcast" + return urlunparse((parsed.scheme, parsed.netloc, path, "", "", "")) + + if lat is not None and lon is not None: + return "https://yandex.ru/pogoda/maps/nowcast?" + urlencode({"lat": lat, "lon": lon}) + return "https://yandex.ru/pogoda/maps/nowcast" + + +def fetch_forecast( + access_key: str, + lat: float, + lon: float, + days: int = 2, +) -> dict[str, Any]: + params = { + "lat": lat, + "lon": lon, + "lang": "ru_RU", + "limit": days, + "hours": "true", + } + url = f"{API_URL}?{urlencode(params)}" + request = Request(url, headers={"X-Yandex-Weather-Key": access_key}) + try: + with urlopen(request, timeout=20) as response: + return json.load(response) + except HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError( + f"Яндекс.Погода вернула HTTP {exc.code}: {body[:300]}" + ) from exc + except URLError as exc: + raise RuntimeError(f"Не удалось подключиться к API Яндекс.Погоды: {exc.reason}") from exc + + +def describe_condition(value: str | None) -> str: + return CONDITIONS.get(value or "", value or "неизвестно") + + +def format_forecast(data: dict[str, Any], location_label: str | None = None) -> str: + info = data.get("info") or {} + fact = data.get("fact") or {} + forecasts = data.get("forecasts") or [] + location_url = info.get("url") + location_segments = [ + segment for segment in urlparse(location_url or "").path.split("/") if segment + ] + if location_label: + location = location_label + elif location_segments and location_segments[-1] not in {"pogoda", "weather"}: + location = location_segments[-1] + elif info.get("name"): + location = str(info["name"]) + elif info.get("lat") is not None and info.get("lon") is not None: + location = f"{info['lat']:.4f}, {info['lon']:.4f}" + else: + location = "локация" + + lines = [f"Яндекс.Погода: {location}"] + lines.append( + "Сейчас: " + f"{fact.get('temp', '—')}°C, " + f"{describe_condition(fact.get('condition'))}; " + f"ощущается как {fact.get('feels_like', '—')}°C" + ) + lines.append( + "Ветер: " + f"{fact.get('wind_speed', '—')} м/с {fact.get('wind_dir', '')}; " + f"влажность {fact.get('humidity', '—')}%" + ) + + lines.append("") + lines.append("Ближайшие дни:") + for forecast in forecasts: + parts = forecast.get("parts") or {} + day = parts.get("day") or parts.get("day_short") or {} + lines.append( + f"{forecast.get('date', '—')}: " + f"{day.get('temp_min', '—')}…{day.get('temp_max', '—')}°C, " + f"{describe_condition(day.get('condition'))}" + ) + + lines.append("") + lines.append(f"Карта осадков: {get_map_url(location_url, info.get('lat'), info.get('lon'))}") + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--location", help="Сохранённая локация или её название/адрес" + ) + parser.add_argument("--lat", type=float, help="Широта, если локация не сохранена") + parser.add_argument("--lon", type=float, help="Долгота, если локация не сохранена") + parser.add_argument( + "--list-locations", action="store_true", + help="Показать сохранённые локации и завершить работу", + ) + parser.add_argument( + "--days", type=int, default=2, choices=range(1, 3), + help="Количество дней прогноза (1–2, по умолчанию: 2)", + ) + parser.add_argument( + "--json", action="store_true", dest="as_json", + help="Вывести исходный JSON вместо краткого текста", + ) + return parser.parse_args() + + +def main() -> int: + load_dotenv(PROJECT_DIR / ".env") + args = parse_args() + locations = load_locations() + + if args.list_locations: + for key, location in locations.items(): + print(f"{key}: {location['name']} ({location['lat']}, {location['lon']})") + return 0 + + location_label = None + if args.location: + try: + _, selected = find_location(args.location, locations) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 2 + lat = float(selected["lat"]) + lon = float(selected["lon"]) + location_label = str(selected["name"]) + elif args.lat is not None and args.lon is not None: + lat = args.lat + lon = args.lon + else: + print("Укажите --location, либо одновременно --lat и --lon.", file=sys.stderr) + return 2 + + access_key = os.environ.get("YANDEX_WEATHER_KEY") + if not access_key: + print( + "Не найден YANDEX_WEATHER_KEY. Создайте .env по примеру .env.example " + "или задайте переменную окружения.", + file=sys.stderr, + ) + return 2 + + try: + data = fetch_forecast(access_key, lat, lon, args.days) + except (OSError, RuntimeError, ValueError) as exc: + print(f"Ошибка получения прогноза: {exc}", file=sys.stderr) + return 1 + + if args.as_json: + print(json.dumps(data, ensure_ascii=False, indent=2)) + else: + print(format_forecast(data, location_label=location_label)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())