122 lines
3.6 KiB
Python
122 lines
3.6 KiB
Python
"""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
|