"""Open-Meteo 实时天气(免费、无需 API Key)https://open-meteo.com/""" from __future__ import annotations import asyncio import time from typing import Any import httpx OPEN_METEO_FORECAST_URL = "https://api.open-meteo.com/v1/forecast" CACHE_TTL_SECONDS = 30 * 60 _FETCH_CONCURRENCY = 12 _cache: dict[str, tuple[float, dict[str, Any]]] = {} _cache_lock = asyncio.Lock() def _cache_key(lat: float, lng: float) -> str: return f"{round(lat, 2)}:{round(lng, 2)}" def _parse_current(payload: dict[str, Any]) -> dict[str, Any] | None: current = payload.get("current") if not isinstance(current, dict): return None temperature = current.get("temperature_2m") if temperature is None: return None try: temp = round(float(temperature)) except (TypeError, ValueError): return None result: dict[str, Any] = { "temperature": temp, "observedAt": current.get("time"), } humidity = current.get("relative_humidity_2m") if humidity is not None: try: result["humidity"] = int(round(float(humidity))) except (TypeError, ValueError): pass apparent = current.get("apparent_temperature") if apparent is not None: try: result["apparentTemperature"] = round(float(apparent)) except (TypeError, ValueError): pass weather_code = current.get("weather_code") if weather_code is not None: try: result["weatherCode"] = int(weather_code) except (TypeError, ValueError): pass return result async def _fetch_point_weather(client: httpx.AsyncClient, lat: float, lng: float) -> dict[str, Any] | None: key = _cache_key(lat, lng) now = time.time() cached = _cache.get(key) if cached and now - cached[0] < CACHE_TTL_SECONDS: return cached[1] params = { "latitude": lat, "longitude": lng, "current": "temperature_2m,relative_humidity_2m,apparent_temperature,weather_code", "timezone": "auto", "forecast_days": 1, } try: response = await client.get(OPEN_METEO_FORECAST_URL, params=params, timeout=12.0) response.raise_for_status() parsed = _parse_current(response.json()) except (httpx.HTTPError, ValueError): return None if parsed: async with _cache_lock: _cache[key] = (now, parsed) return parsed async def fetch_weather_for_cities(cities: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: """按城市 slug 返回实时天气;无坐标或请求失败则跳过。""" targets: list[tuple[str, float, float]] = [] seen: set[str] = set() for city in cities: slug = str(city.get("slug") or "").strip() if not slug or slug in seen: continue lat = city.get("lat") lng = city.get("lng") if lat is None or lng is None: continue try: lat_f = float(lat) lng_f = float(lng) except (TypeError, ValueError): continue seen.add(slug) targets.append((slug, lat_f, lng_f)) if not targets: return {} semaphore = asyncio.Semaphore(_FETCH_CONCURRENCY) results: dict[str, dict[str, Any]] = {} async with httpx.AsyncClient() as client: async def load_one(slug: str, lat: float, lng: float) -> None: async with semaphore: weather = await _fetch_point_weather(client, lat, lng) if weather: results[slug] = weather await asyncio.gather(*(load_one(slug, lat, lng) for slug, lat, lng in targets)) return results