|
|
import gradio as gr |
|
|
import requests |
|
|
import json |
|
|
from typing import Dict, List, Tuple, Optional |
|
|
from datetime import datetime, timedelta |
|
|
import os |
|
|
from dotenv import load_dotenv |
|
|
import logging |
|
|
import time |
|
|
import locale |
|
|
|
|
|
|
|
|
try: |
|
|
locale.setlocale(locale.LC_TIME, 'fr_FR.UTF-8') |
|
|
except: |
|
|
try: |
|
|
locale.setlocale(locale.LC_TIME, 'French_France.1252') |
|
|
except: |
|
|
pass |
|
|
|
|
|
|
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') |
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
load_dotenv() |
|
|
|
|
|
def format_timestamp(timestamp, format_type="datetime"): |
|
|
""" |
|
|
Convertit un timestamp Unix en format français lisible. |
|
|
|
|
|
Args: |
|
|
timestamp (int|float): Timestamp Unix à convertir |
|
|
format_type (str): Type de format souhaité |
|
|
- "datetime": "01/07/2025 à 14:30" (par défaut) |
|
|
- "date": "Mar 01/07" |
|
|
- "time": "14:30" |
|
|
- autre: format strftime personnalisé |
|
|
|
|
|
Returns: |
|
|
str: Date formatée en français ou "--" si erreur |
|
|
""" |
|
|
if not timestamp or timestamp == 0: |
|
|
return "--" |
|
|
|
|
|
try: |
|
|
dt = datetime.fromtimestamp(timestamp) |
|
|
if format_type == "date": |
|
|
return dt.strftime("%a %d/%m") |
|
|
elif format_type == "time": |
|
|
return dt.strftime("%H:%M") |
|
|
elif format_type == "datetime": |
|
|
return dt.strftime("%d/%m/%Y à %H:%M") |
|
|
else: |
|
|
return dt.strftime(format_type) |
|
|
except (ValueError, OSError): |
|
|
return str(timestamp) |
|
|
|
|
|
class MeteoFranceAPI: |
|
|
""" |
|
|
Client pour l'API privée Météo-France. |
|
|
|
|
|
Utilise l'API interne de Météo-France (même que l'app mobile officielle) |
|
|
pour récupérer prévisions, observations, alertes et données de pluie. |
|
|
|
|
|
Attributes: |
|
|
base_url (str): URL de base de l'API Météo-France |
|
|
token (str): Token d'authentification depuis variable d'environnement |
|
|
headers (dict): Headers HTTP pour les requêtes |
|
|
""" |
|
|
|
|
|
def __init__(self): |
|
|
|
|
|
self.base_url = "https://webservice.meteofrance.com" |
|
|
|
|
|
|
|
|
self.token = os.getenv('METEOFRANCE_TOKEN') |
|
|
|
|
|
if not self.token: |
|
|
raise ValueError("Token Météo-France manquant. Définissez METEOFRANCE_TOKEN dans votre fichier .env") |
|
|
|
|
|
self.headers = { |
|
|
"User-Agent": "MeteoApp/1.0" |
|
|
} |
|
|
|
|
|
def get_location_forecast(self, lat: float, lon: float) -> dict: |
|
|
""" |
|
|
Récupère les prévisions météo complètes pour une position géographique. |
|
|
|
|
|
Args: |
|
|
lat (float): Latitude en degrés décimaux |
|
|
lon (float): Longitude en degrés décimaux |
|
|
|
|
|
Returns: |
|
|
dict: Données de prévision avec clés principales: |
|
|
- position: Infos localisation (nom, département, altitude...) |
|
|
- forecast: Prévisions horaires sur 7 jours |
|
|
- daily_forecast: Prévisions quotidiennes sur 10 jours |
|
|
- updated_on: Timestamp de mise à jour |
|
|
En cas d'erreur: {"error": "message d'erreur"} |
|
|
""" |
|
|
logger.info(f"🌤️ Récupération prévisions météo pour: lat={lat}, lon={lon}") |
|
|
|
|
|
try: |
|
|
|
|
|
url = f"{self.base_url}/forecast" |
|
|
params = { |
|
|
"lat": lat, |
|
|
"lon": lon, |
|
|
"lang": "fr", |
|
|
"token": self.token |
|
|
} |
|
|
|
|
|
|
|
|
safe_params = {k: "***" if k == "token" else v for k, v in params.items()} |
|
|
logger.info(f"📡 Appel API Météo-France: {url}") |
|
|
logger.info(f"📋 Paramètres: {safe_params}") |
|
|
|
|
|
response = requests.get(url, headers=self.headers, params=params) |
|
|
|
|
|
if response.status_code == 200: |
|
|
data = response.json() |
|
|
logger.info(f"✅ Données météo reçues") |
|
|
return data |
|
|
else: |
|
|
logger.error(f"❌ Erreur API Météo-France {response.status_code}: {response.text}") |
|
|
return {"error": f"Erreur API: {response.status_code}"} |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Erreur connexion Météo-France: {e}") |
|
|
return {"error": f"Erreur de connexion: {str(e)}"} |
|
|
|
|
|
def get_wind_alerts_by_department(self, department: str) -> dict: |
|
|
""" |
|
|
Récupère les alertes météo pour un département français spécifique. |
|
|
|
|
|
Args: |
|
|
department (str): Code département français (ex: "29", "75", "2A") |
|
|
|
|
|
Returns: |
|
|
dict: Données d'alertes avec clés principales: |
|
|
- domain_id: Code du département |
|
|
- phenomenons_max_colors: Liste des phénomènes avec niveaux d'alerte |
|
|
- update_time: Timestamp de mise à jour |
|
|
- end_validity_time: Fin de validité des alertes |
|
|
En cas d'erreur: {"error": "message d'erreur"} |
|
|
""" |
|
|
logger.info(f"⚠️ Récupération alertes pour département: {department}") |
|
|
|
|
|
try: |
|
|
|
|
|
url = f"{self.base_url}/v3/warning/currentphenomenons" |
|
|
params = { |
|
|
"domain": department, |
|
|
"depth": 1, |
|
|
"with_coastal_bulletin": "true", |
|
|
"token": self.token |
|
|
} |
|
|
|
|
|
|
|
|
safe_params = {k: "***" if k == "token" else v for k, v in params.items()} |
|
|
logger.info(f"📡 Appel API Alertes pour département {department}") |
|
|
|
|
|
response = requests.get(url, headers=self.headers, params=params) |
|
|
|
|
|
if response.status_code == 200: |
|
|
data = response.json() |
|
|
logger.info(f"✅ Alertes reçues") |
|
|
return data |
|
|
else: |
|
|
logger.error(f"❌ Erreur API Alertes {response.status_code}: {response.text}") |
|
|
return {"error": f"Erreur API alertes: {response.status_code}"} |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Erreur alertes: {e}") |
|
|
return {"error": f"Erreur alertes: {str(e)}"} |
|
|
|
|
|
def get_wind_alerts(self, lat: float, lon: float) -> dict: |
|
|
"""Récupère les alertes vent pour une position (fallback)""" |
|
|
logger.info(f"⚠️ Récupération alertes vent pour: lat={lat}, lon={lon}") |
|
|
|
|
|
|
|
|
department = self._get_department_from_coords(lat, lon) |
|
|
|
|
|
return self.get_wind_alerts_by_department(department) |
|
|
|
|
|
def _get_department_from_coords(self, lat: float, lon: float) -> str: |
|
|
"""Approximation simple du département depuis les coordonnées GPS""" |
|
|
logger.info(f"🗺️ Détermination département pour: lat={lat}, lon={lon}") |
|
|
|
|
|
|
|
|
|
|
|
if 48.8 <= lat <= 49.0 and 2.2 <= lon <= 2.5: |
|
|
dept = "75" |
|
|
elif 48.1 <= lat <= 49.2 and 1.4 <= lon <= 3.6: |
|
|
dept = "77" |
|
|
|
|
|
elif 43.2 <= lat <= 43.4 and 5.3 <= lon <= 5.5: |
|
|
dept = "13" |
|
|
|
|
|
elif 45.7 <= lat <= 45.8 and 4.8 <= lon <= 4.9: |
|
|
dept = "69" |
|
|
|
|
|
elif 43.5 <= lat <= 43.7 and 1.3 <= lon <= 1.5: |
|
|
dept = "31" |
|
|
|
|
|
else: |
|
|
dept = "france" |
|
|
|
|
|
logger.info(f"🏷️ Département déterminé: {dept}") |
|
|
return dept |
|
|
|
|
|
def get_current_observation(self, lat: float, lon: float) -> dict: |
|
|
"""Récupère les observations météo actuelles""" |
|
|
logger.info(f"🌡️ Récupération observations actuelles pour: lat={lat}, lon={lon}") |
|
|
|
|
|
try: |
|
|
url = f"{self.base_url}/v2/observation" |
|
|
params = { |
|
|
"lat": lat, |
|
|
"lon": lon, |
|
|
"lang": "fr", |
|
|
"token": self.token |
|
|
} |
|
|
|
|
|
response = requests.get(url, headers=self.headers, params=params) |
|
|
|
|
|
if response.status_code == 200: |
|
|
data = response.json() |
|
|
logger.info(f"✅ Observations reçues") |
|
|
return data |
|
|
else: |
|
|
logger.error(f"❌ Erreur API Observations {response.status_code}: {response.text}") |
|
|
return {"error": f"Erreur API observations: {response.status_code}"} |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Erreur observations: {e}") |
|
|
return {"error": f"Erreur observations: {str(e)}"} |
|
|
|
|
|
def get_rain_forecast(self, lat: float, lon: float) -> dict: |
|
|
"""Récupère les prévisions de pluie dans l'heure""" |
|
|
logger.info(f"🌧️ Récupération prévisions pluie pour: lat={lat}, lon={lon}") |
|
|
|
|
|
try: |
|
|
url = f"{self.base_url}/rain" |
|
|
params = { |
|
|
"lat": round(lat, 3), |
|
|
"lon": round(lon, 3), |
|
|
"lang": "fr", |
|
|
"token": self.token |
|
|
} |
|
|
|
|
|
response = requests.get(url, headers=self.headers, params=params) |
|
|
|
|
|
if response.status_code == 200: |
|
|
data = response.json() |
|
|
logger.info(f"✅ Prévisions pluie reçues") |
|
|
return data |
|
|
else: |
|
|
logger.error(f"❌ Erreur API Pluie {response.status_code}: {response.text}") |
|
|
return {"error": f"Erreur API pluie: {response.status_code}"} |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Erreur pluie: {e}") |
|
|
return {"error": f"Erreur pluie: {str(e)}"} |
|
|
|
|
|
|
|
|
class GeocodingAPI: |
|
|
""" |
|
|
Client pour l'API de géocodage IGN Géoplateforme. |
|
|
|
|
|
Utilise l'API officielle française pour convertir des adresses |
|
|
en coordonnées GPS avec métadonnées françaises précises. |
|
|
|
|
|
Attributes: |
|
|
base_url (str): URL de l'API de géocodage IGN |
|
|
""" |
|
|
|
|
|
def __init__(self): |
|
|
|
|
|
self.base_url = "https://data.geopf.fr/geocodage/search" |
|
|
|
|
|
def geocode_address(self, address: str) -> dict: |
|
|
""" |
|
|
Convertit une adresse française en coordonnées GPS via l'API IGN. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse à géolocaliser (ex: "Brest, France") |
|
|
|
|
|
Returns: |
|
|
dict: Résultat avec clés: |
|
|
- lat (float): Latitude en degrés décimaux |
|
|
- lon (float): Longitude en degrés décimaux |
|
|
- full_data (dict): Données complètes IGN (propriétés, géométrie...) |
|
|
None en cas d'erreur |
|
|
""" |
|
|
logger.info(f"🔍 Géocodage IGN de l'adresse: {address}") |
|
|
|
|
|
try: |
|
|
params = { |
|
|
"q": address, |
|
|
"index": "address", |
|
|
"limit": 1, |
|
|
"returntruegeometry": "true" |
|
|
} |
|
|
|
|
|
response = requests.get(self.base_url, params=params) |
|
|
|
|
|
if response.status_code == 200: |
|
|
data = response.json() |
|
|
logger.info(f"✅ Géolocalisation réussie") |
|
|
|
|
|
if data.get("features") and len(data["features"]) > 0: |
|
|
feature = data["features"][0] |
|
|
coords = feature["geometry"]["coordinates"] |
|
|
logger.info(f"📍 Coordonnées trouvées: lat={coords[1]}, lon={coords[0]}") |
|
|
return { |
|
|
"lat": coords[1], |
|
|
"lon": coords[0], |
|
|
"full_data": feature |
|
|
} |
|
|
else: |
|
|
logger.warning("⚠️ Aucune coordonnée trouvée dans la réponse IGN") |
|
|
else: |
|
|
logger.error(f"❌ Erreur HTTP IGN {response.status_code}: {response.text}") |
|
|
return None |
|
|
except Exception as e: |
|
|
logger.error(f"❌ Erreur géocodage IGN: {e}") |
|
|
return None |
|
|
|
|
|
|
|
|
def format_weather_data(data: dict) -> str: |
|
|
""" |
|
|
Formate les données météo brutes en texte lisible markdown. |
|
|
|
|
|
Args: |
|
|
data (dict): Données météo brutes de l'API Météo-France |
|
|
avec clés position, forecast, daily_forecast, updated_on |
|
|
|
|
|
Returns: |
|
|
str: Données météo formatées en markdown avec prévisions |
|
|
quotidiennes et horaires, ou message d'erreur si échec |
|
|
""" |
|
|
if "error" in data: |
|
|
return f"❌ {data['error']}" |
|
|
|
|
|
formatted = "🌤️ **Prévisions Météo**\n\n" |
|
|
|
|
|
try: |
|
|
if "position" in data: |
|
|
pos = data["position"] |
|
|
formatted += f"📍 **Localisation:** {pos.get('name', 'Inconnu')}\n" |
|
|
formatted += f"Coordonnées: {pos.get('lat', 0):.3f}, {pos.get('lon', 0):.3f}\n\n" |
|
|
|
|
|
if "updated_on" in data: |
|
|
update_time = format_timestamp(data['updated_on']) |
|
|
formatted += f"⏰ **Mise à jour:** {update_time}\n\n" |
|
|
|
|
|
|
|
|
if "daily_forecast" in data and data["daily_forecast"]: |
|
|
formatted += "📅 **Prévisions à 10 jours:**\n" |
|
|
for day in data["daily_forecast"][:10]: |
|
|
timestamp = day.get("dt", 0) |
|
|
date_str = format_timestamp(timestamp, "date") |
|
|
temp_min = day.get("T", {}).get("min", "--") |
|
|
temp_max = day.get("T", {}).get("max", "--") |
|
|
weather = day.get("weather12H", {}).get("desc", "--") |
|
|
formatted += f" • {date_str}: {temp_min}°/{temp_max}°C - {weather}\n" |
|
|
formatted += "\n" |
|
|
|
|
|
|
|
|
if "forecast" in data and data["forecast"]: |
|
|
formatted += "🕰️ **Aujourd'hui (par heure):**\n" |
|
|
for hour in data["forecast"][:12]: |
|
|
timestamp = hour.get("dt", 0) |
|
|
time_str = format_timestamp(timestamp, "time") |
|
|
temp = hour.get("T", {}).get("value", "--") |
|
|
weather = hour.get("weather", {}).get("desc", "--") |
|
|
formatted += f" • {time_str}: {temp}°C - {weather}\n" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Erreur formatage météo: {e}") |
|
|
formatted += f"Données brutes: {json.dumps(data, indent=2, ensure_ascii=False)[:500]}..." |
|
|
|
|
|
return formatted |
|
|
|
|
|
def format_current_conditions(current_data: dict, rain_data: dict = None, forecast_data: dict = None) -> str: |
|
|
""" |
|
|
Formate les conditions météorologiques actuelles avec détails complets. |
|
|
|
|
|
Combine observations actuelles, prévisions de pluie et données de localisation |
|
|
pour créer un rapport météo actuel détaillé. |
|
|
|
|
|
Args: |
|
|
current_data (dict): Observations météo actuelles de l'API |
|
|
rain_data (dict, optional): Prévisions pluie dans l'heure. Defaults to None. |
|
|
forecast_data (dict, optional): Données de prévision pour la localisation. Defaults to None. |
|
|
|
|
|
Returns: |
|
|
str: Conditions actuelles formatées en markdown avec température, |
|
|
vent, précipitations, humidité, pression et visibilité |
|
|
""" |
|
|
if "error" in current_data: |
|
|
return f"❌ {current_data['error']}" |
|
|
|
|
|
formatted = "🌡️ **Conditions Actuelles**\n\n" |
|
|
|
|
|
try: |
|
|
|
|
|
if forecast_data and "position" in forecast_data: |
|
|
pos = forecast_data["position"] |
|
|
formatted += f"📍 **Lieu**: {pos.get('name', 'Localisation inconnue')}\n" |
|
|
formatted += f"Coordonnées: {pos.get('lat', 0):.3f}, {pos.get('lon', 0):.3f}\n\n" |
|
|
|
|
|
|
|
|
gridded = current_data.get("properties", {}).get("gridded", {}) |
|
|
if "time" in gridded: |
|
|
obs_time = gridded["time"] |
|
|
|
|
|
if obs_time: |
|
|
try: |
|
|
from datetime import datetime |
|
|
dt = datetime.fromisoformat(obs_time.replace('Z', '+00:00')) |
|
|
formatted += f"⏰ **Observation**: {dt.strftime('%d/%m/%Y à %H:%M')}\n\n" |
|
|
except: |
|
|
formatted += f"⏰ **Observation**: {obs_time}\n\n" |
|
|
|
|
|
|
|
|
temp = gridded.get("T", "--") |
|
|
formatted += f"🌡️ **Température**: {temp}°C\n" |
|
|
|
|
|
|
|
|
wind_speed = gridded.get("wind_speed", "--") |
|
|
wind_dir = gridded.get("wind_direction", "--") |
|
|
wind_icon = gridded.get("wind_icon", "") |
|
|
|
|
|
formatted += f"🌬️ **Vent**: {wind_speed} km/h - {wind_dir}° {wind_icon}\n" |
|
|
|
|
|
|
|
|
weather_desc = gridded.get("weather_description", "--") |
|
|
weather_icon = gridded.get("weather_icon", "") |
|
|
formatted += f"🌤️ **Temps**: {weather_desc} {weather_icon}\n" |
|
|
|
|
|
|
|
|
if rain_data and "error" not in rain_data: |
|
|
|
|
|
rain_available = rain_data.get("position", {}).get("rain_product_available", 0) |
|
|
|
|
|
if rain_available == 0: |
|
|
formatted += f"💧 **Précipitations**: Service radar indisponible pour cette zone\n" |
|
|
elif "forecast" in rain_data and rain_data["forecast"]: |
|
|
|
|
|
current_rain = rain_data["forecast"][0] if rain_data["forecast"] else None |
|
|
if current_rain: |
|
|
rain_intensity = current_rain.get("rain", 0) |
|
|
rain_desc = current_rain.get("desc", "Pas de données") |
|
|
|
|
|
|
|
|
if rain_intensity <= 1: |
|
|
formatted += f"💧 **Précipitations**: Aucune - {rain_desc}\n" |
|
|
elif rain_intensity == 2: |
|
|
formatted += f"💧 **Précipitations**: 🌦️ Pluie faible - {rain_desc}\n" |
|
|
elif rain_intensity == 3: |
|
|
formatted += f"💧 **Précipitations**: 🌧️ Pluie modérée - {rain_desc}\n" |
|
|
elif rain_intensity >= 4: |
|
|
formatted += f"💧 **Précipitations**: ⛈️ Pluie forte - {rain_desc}\n" |
|
|
else: |
|
|
formatted += f"💧 **Précipitations**: Intensité {rain_intensity} - {rain_desc}\n" |
|
|
else: |
|
|
formatted += f"💧 **Précipitations**: Données non disponibles\n" |
|
|
else: |
|
|
formatted += f"💧 **Précipitations**: Pas de prévisions disponibles\n" |
|
|
else: |
|
|
formatted += f"💧 **Précipitations**: Service indisponible\n" |
|
|
|
|
|
|
|
|
humidity = current_data.get("humidity", "--") |
|
|
if humidity != "--": |
|
|
formatted += f"💧 **Humidité**: {humidity}%\n" |
|
|
|
|
|
|
|
|
pressure = current_data.get("pressure", {}).get("value", "--") |
|
|
if pressure != "--": |
|
|
formatted += f"📊 **Pression**: {pressure} hPa\n" |
|
|
|
|
|
|
|
|
visibility = current_data.get("visibility", "--") |
|
|
if visibility != "--": |
|
|
formatted += f"👁️ **Visibilité**: {visibility} km\n" |
|
|
|
|
|
|
|
|
if rain_data and "error" not in rain_data: |
|
|
formatted += "\n🌧️ **Pluie dans l'heure:**\n" |
|
|
|
|
|
|
|
|
if "position" in rain_data: |
|
|
rain_pos = rain_data["position"] |
|
|
quality = rain_data.get("quality", 0) |
|
|
updated = rain_data.get("updated_on", "") |
|
|
if isinstance(updated, (int, float)): |
|
|
update_str = format_timestamp(updated) |
|
|
else: |
|
|
update_str = updated |
|
|
formatted += f"📍 Lieu: {rain_pos.get('name', 'Inconnu')} (Dept. {rain_pos.get('dept', '??')})\n" |
|
|
formatted += f"⏰ Mis à jour: {update_str}\n" |
|
|
formatted += f"📊 Qualité: {quality}/10\n\n" |
|
|
|
|
|
if "forecast" in rain_data and rain_data["forecast"]: |
|
|
next_rain = rain_data["forecast"][:6] |
|
|
for point in next_rain: |
|
|
rain_intensity = point.get("rain", 0) |
|
|
rain_desc_api = point.get("desc", "") |
|
|
time_point = point.get("dt", "") |
|
|
|
|
|
if isinstance(time_point, (int, float)): |
|
|
time_str = format_timestamp(time_point, "time") |
|
|
else: |
|
|
time_str = time_point |
|
|
|
|
|
|
|
|
if rain_desc_api and rain_desc_api != "Pas de valeur": |
|
|
rain_desc = rain_desc_api |
|
|
else: |
|
|
if rain_intensity == 0: |
|
|
rain_desc = "Pas de pluie" |
|
|
elif rain_intensity == 1: |
|
|
rain_desc = "Pluie faible" |
|
|
elif rain_intensity == 2: |
|
|
rain_desc = "Pluie modérée" |
|
|
elif rain_intensity == 3: |
|
|
rain_desc = "Pluie forte" |
|
|
else: |
|
|
rain_desc = f"Intensité {rain_intensity}" |
|
|
|
|
|
|
|
|
if rain_intensity == 0: |
|
|
emoji = "☀️" |
|
|
elif rain_intensity == 1: |
|
|
emoji = "🌦️" |
|
|
elif rain_intensity == 2: |
|
|
emoji = "🌧️" |
|
|
elif rain_intensity >= 3: |
|
|
emoji = "⛈️" |
|
|
else: |
|
|
emoji = "❓" |
|
|
|
|
|
formatted += f" • {time_str}: {emoji} {rain_desc}\n" |
|
|
else: |
|
|
formatted += " • Données non disponibles\n" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Erreur formatage conditions actuelles: {e}") |
|
|
formatted += f"\nErreur formatage: {e}\n" |
|
|
|
|
|
return formatted |
|
|
|
|
|
def format_hourly_forecast(forecast_data: dict, rain_data: dict = None) -> str: |
|
|
""" |
|
|
Formate les prévisions météo horaires sur 24h avec intégration des précipitations. |
|
|
|
|
|
Args: |
|
|
forecast_data (dict): Données de prévision horaire de l'API Météo-France |
|
|
rain_data (dict, optional): Données de précipitations à intégrer. Defaults to None. |
|
|
|
|
|
Returns: |
|
|
str: Prévisions horaires formatées en markdown avec température, |
|
|
météo, vent et informations de pluie pour chaque heure |
|
|
""" |
|
|
if "error" in forecast_data: |
|
|
return f"❌ {forecast_data['error']}" |
|
|
|
|
|
formatted = "🕰️ **Prévisions Heure par Heure (24h)**\n\n" |
|
|
|
|
|
try: |
|
|
|
|
|
rain_by_time = {} |
|
|
if rain_data and "forecast" in rain_data and rain_data["forecast"]: |
|
|
for rain_point in rain_data["forecast"]: |
|
|
rain_timestamp = rain_point.get("dt", 0) |
|
|
rain_by_time[rain_timestamp] = rain_point |
|
|
|
|
|
if "forecast" in forecast_data and forecast_data["forecast"]: |
|
|
for hour in forecast_data["forecast"][:24]: |
|
|
timestamp = hour.get("dt", 0) |
|
|
time_str = format_timestamp(timestamp, "time") |
|
|
|
|
|
temp = hour.get("T", {}).get("value", "--") |
|
|
weather = hour.get("weather", {}).get("desc", "--") |
|
|
wind_speed = hour.get("wind", {}).get("speed", "--") |
|
|
wind_dir = hour.get("wind", {}).get("direction", "--") |
|
|
wind_gust = hour.get("wind", {}).get("gust", "--") |
|
|
|
|
|
|
|
|
rain_info = "" |
|
|
if timestamp in rain_by_time: |
|
|
rain_point = rain_by_time[timestamp] |
|
|
rain_intensity = rain_point.get("rain", 0) |
|
|
rain_desc = rain_point.get("desc", "") |
|
|
|
|
|
if rain_intensity <= 1: |
|
|
rain_info = " - ☀️ Sec" |
|
|
elif rain_intensity == 2: |
|
|
rain_info = " - 🌦️ Pluie faible" |
|
|
elif rain_intensity == 3: |
|
|
rain_info = " - 🌧️ Pluie modérée" |
|
|
elif rain_intensity >= 4: |
|
|
rain_info = " - ⛈️ Pluie forte" |
|
|
else: |
|
|
rain_info = f" - 💧 Intensité {rain_intensity}" |
|
|
|
|
|
formatted += f"**{time_str}**: {temp}°C - {weather}{rain_info}\n" |
|
|
formatted += f" Vent: {wind_speed} km/h ({wind_dir}°)" |
|
|
if wind_gust != "--" and wind_gust != 0: |
|
|
formatted += f" - Rafales: {wind_gust} km/h" |
|
|
formatted += "\n\n" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Erreur formatage prévisions horaires: {e}") |
|
|
formatted += f"Erreur: {e}\n" |
|
|
|
|
|
return formatted |
|
|
|
|
|
def format_daily_forecast(forecast_data: dict) -> str: |
|
|
""" |
|
|
Formate les prévisions météorologiques quotidiennes sur 10 jours. |
|
|
|
|
|
Args: |
|
|
forecast_data (dict): Données de prévision quotidienne de l'API Météo-France |
|
|
avec clé daily_forecast contenant la liste des jours |
|
|
|
|
|
Returns: |
|
|
str: Prévisions sur 10 jours formatées en markdown avec températures |
|
|
min/max, description météo et données de vent pour chaque jour |
|
|
""" |
|
|
if "error" in forecast_data: |
|
|
return f"❌ {forecast_data['error']}" |
|
|
|
|
|
formatted = "📅 **Prévisions à 10 Jours**\n\n" |
|
|
|
|
|
try: |
|
|
if "daily_forecast" in forecast_data and forecast_data["daily_forecast"]: |
|
|
for day in forecast_data["daily_forecast"][:10]: |
|
|
timestamp = day.get("dt", 0) |
|
|
date_str = format_timestamp(timestamp, "date") |
|
|
|
|
|
temp_min = day.get("T", {}).get("min", "--") |
|
|
temp_max = day.get("T", {}).get("max", "--") |
|
|
weather = day.get("weather12H", {}).get("desc", "--") |
|
|
wind_speed = day.get("wind", {}).get("speed", "--") |
|
|
wind_dir = day.get("wind", {}).get("direction", "--") |
|
|
|
|
|
formatted += f"**{date_str}**: {temp_min}°/{temp_max}°C\n" |
|
|
formatted += f" {weather}\n" |
|
|
if wind_speed != "--": |
|
|
formatted += f" Vent: {wind_speed} km/h ({wind_dir}°)\n" |
|
|
formatted += "\n" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Erreur formatage prévisions 10 jours: {e}") |
|
|
formatted += f"Erreur: {e}\n" |
|
|
|
|
|
return formatted |
|
|
|
|
|
def format_wind_data(forecast_data: dict, current_data: dict = None) -> str: |
|
|
""" |
|
|
Formate les données de vent actuelles et prévisions pour l'affichage. |
|
|
|
|
|
Args: |
|
|
forecast_data (dict): Prévisions météo avec données de vent horaires et quotidiennes |
|
|
current_data (dict, optional): Observations actuelles de vent. Defaults to None. |
|
|
|
|
|
Returns: |
|
|
str: Données de vent formatées en markdown avec conditions actuelles, |
|
|
prévisions horaires sur 24h et prévisions quotidiennes sur 10 jours |
|
|
""" |
|
|
if "error" in forecast_data: |
|
|
return f"❌ {forecast_data['error']}" |
|
|
|
|
|
formatted = "💨 **Informations Vent**\n\n" |
|
|
|
|
|
try: |
|
|
|
|
|
if current_data and "error" not in current_data: |
|
|
formatted += "🌬️ **Conditions actuelles:**\n" |
|
|
wind_speed = current_data.get("wind_speed", "--") |
|
|
wind_dir = current_data.get("wind_direction", "--") |
|
|
wind_icon = current_data.get("wind_icon", "") |
|
|
formatted += f" • Vitesse: {wind_speed} km/h\n" |
|
|
formatted += f" • Direction: {wind_dir}° {wind_icon}\n\n" |
|
|
|
|
|
|
|
|
if "forecast" in forecast_data and forecast_data["forecast"]: |
|
|
formatted += "🕰️ **Prévisions vent (24h):**\n" |
|
|
for hour in forecast_data["forecast"][:24]: |
|
|
timestamp = hour.get("dt", 0) |
|
|
time_str = format_timestamp(timestamp, "time") |
|
|
wind_speed = hour.get("wind", {}).get("speed", "--") |
|
|
wind_dir = hour.get("wind", {}).get("direction", "--") |
|
|
wind_gust = hour.get("wind", {}).get("gust", "--") |
|
|
|
|
|
formatted += f" • {time_str}: {wind_speed} km/h" |
|
|
if wind_gust != "--" and wind_gust != 0: |
|
|
formatted += f" (rafales: {wind_gust} km/h)" |
|
|
formatted += f" - {wind_dir}°\n" |
|
|
|
|
|
|
|
|
if "daily_forecast" in forecast_data and forecast_data["daily_forecast"]: |
|
|
formatted += "\n📅 **Vent à 10 jours:**\n" |
|
|
for day in forecast_data["daily_forecast"][:10]: |
|
|
timestamp = day.get("dt", 0) |
|
|
date_str = format_timestamp(timestamp, "date") |
|
|
wind_speed = day.get("wind", {}).get("speed", "--") |
|
|
wind_dir = day.get("wind", {}).get("direction", "--") |
|
|
formatted += f" • {date_str}: {wind_speed} km/h - {wind_dir}°\n" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Erreur formatage vent: {e}") |
|
|
formatted += f"Données brutes: {json.dumps(forecast_data, indent=2, ensure_ascii=False)[:500]}..." |
|
|
|
|
|
return formatted |
|
|
|
|
|
def format_alerts_data(data: dict) -> str: |
|
|
""" |
|
|
Formate les alertes météorologiques Météo-France selon leur niveau de danger. |
|
|
|
|
|
Analyse les données d'alertes brutes, détermine le niveau maximum d'alerte |
|
|
et formate l'affichage avec codes couleur appropriés (vert/jaune/orange/rouge). |
|
|
|
|
|
Args: |
|
|
data (dict): Données d'alertes de l'API Météo-France avec clés: |
|
|
- phenomenons_max_colors: Liste des phénomènes avec niveaux |
|
|
- domain_id: Code département |
|
|
- update_time: Timestamp de mise à jour |
|
|
|
|
|
Returns: |
|
|
str: Alertes formatées en markdown avec niveau global, détail des |
|
|
phénomènes actifs et descriptions selon référentiel officiel |
|
|
""" |
|
|
if "error" in data: |
|
|
return f"❌ {data['error']}" |
|
|
|
|
|
try: |
|
|
|
|
|
max_color_id = 1 |
|
|
active_alerts = [] |
|
|
|
|
|
|
|
|
if "phenomenons_max_colors" in data and data["phenomenons_max_colors"] is not None: |
|
|
for item in data["phenomenons_max_colors"]: |
|
|
if item is not None: |
|
|
color_id = item.get("phenomenon_max_color_id", 1) |
|
|
if color_id > max_color_id: |
|
|
max_color_id = color_id |
|
|
if color_id > 1: |
|
|
active_alerts.append(item) |
|
|
|
|
|
|
|
|
elif "timelaps" in data and isinstance(data["timelaps"], list): |
|
|
|
|
|
|
|
|
target_dept = None |
|
|
|
|
|
|
|
|
for dept_data in data["timelaps"]: |
|
|
if dept_data and "phenomenons_max_color" in dept_data: |
|
|
dept_max = 1 |
|
|
dept_alerts = [] |
|
|
|
|
|
for item in dept_data["phenomenons_max_color"]: |
|
|
if item is not None: |
|
|
color_id = item.get("phenomenon_max_color_id", 1) |
|
|
dept_max = max(dept_max, color_id) |
|
|
if color_id > 1: |
|
|
dept_alerts.append(item) |
|
|
|
|
|
|
|
|
if dept_max > max_color_id: |
|
|
max_color_id = dept_max |
|
|
active_alerts = dept_alerts |
|
|
target_dept = dept_data.get("domain_id", "Inconnu") |
|
|
|
|
|
|
|
|
elif "subdomains_phenomenons_max_color" in data and data["subdomains_phenomenons_max_color"] is not None: |
|
|
for subdomain in data["subdomains_phenomenons_max_color"]: |
|
|
if subdomain and "phenomenons_max_color" in subdomain and subdomain["phenomenons_max_color"] is not None: |
|
|
for item in subdomain["phenomenons_max_color"]: |
|
|
if item is not None: |
|
|
color_id = item.get("phenomenon_max_color_id", 1) |
|
|
if color_id > max_color_id: |
|
|
max_color_id = color_id |
|
|
if color_id > 1: |
|
|
active_alerts.append(item) |
|
|
|
|
|
|
|
|
elif "phenomenon_items" in data: |
|
|
for phenomenon_id, phenomenon_data in data["phenomenon_items"].items(): |
|
|
color_id = phenomenon_data.get("phenomenon_max_color_id", 1) |
|
|
if color_id > max_color_id: |
|
|
max_color_id = color_id |
|
|
if color_id > 1: |
|
|
active_alerts.append({ |
|
|
"phenomenon_id": phenomenon_id, |
|
|
"phenomenon_max_color_id": color_id |
|
|
}) |
|
|
|
|
|
|
|
|
phenomena_names = { |
|
|
"0": None, |
|
|
"1": "Vent violent", |
|
|
"2": "Pluie-inondation", |
|
|
"3": "Orages", |
|
|
"4": "Inondation", |
|
|
"5": "Neige-verglas", |
|
|
"6": "Canicule", |
|
|
"7": "Grand-froid", |
|
|
"8": "Avalanches", |
|
|
"9": "Vagues-submersion" |
|
|
} |
|
|
|
|
|
|
|
|
colors = { |
|
|
1: "🟢 Vert", |
|
|
2: "🟡 Jaune", |
|
|
3: "🟠 Orange", |
|
|
4: "🔴 Rouge" |
|
|
} |
|
|
|
|
|
color_descriptions = { |
|
|
1: "Pas de vigilance particulière", |
|
|
2: "Soyez attentifs", |
|
|
3: "Soyez très vigilants", |
|
|
4: "Vigilance absolue" |
|
|
} |
|
|
|
|
|
|
|
|
if max_color_id >= 4: |
|
|
formatted = "🔴 **ALERTE ROUGE - VIGILANCE ABSOLUE**\n\n" |
|
|
elif max_color_id >= 3: |
|
|
formatted = "🟠 **ALERTE ORANGE - SOYEZ TRÈS VIGILANTS**\n\n" |
|
|
elif max_color_id >= 2: |
|
|
formatted = "🟡 **VIGILANCE JAUNE - SOYEZ ATTENTIFS**\n\n" |
|
|
else: |
|
|
formatted = "🟢 **Pas d'alerte en cours**\n\n" |
|
|
|
|
|
|
|
|
if 'target_dept' in locals() and target_dept: |
|
|
formatted += f"📍 **Département**: {target_dept}\n" |
|
|
elif "domain_id" in data: |
|
|
formatted += f"📍 **Département**: {data['domain_id']}\n" |
|
|
|
|
|
|
|
|
formatted += f"🏷️ **Niveau global**: {colors.get(max_color_id, 'Inconnu')} - {color_descriptions.get(max_color_id, '')}\n\n" |
|
|
|
|
|
|
|
|
if active_alerts: |
|
|
formatted += "🚨 **Détail des alertes:**\n" |
|
|
for alert in active_alerts: |
|
|
phenomenon_id = str(alert.get("phenomenon_id", "")) |
|
|
color_id = alert.get("phenomenon_max_color_id", 1) |
|
|
phenomenon_name = phenomena_names.get(phenomenon_id, f"Phénomène {phenomenon_id}") |
|
|
color_desc = colors.get(color_id, f"Niveau {color_id}") |
|
|
|
|
|
formatted += f" • **{phenomenon_name}**: {color_desc}\n" |
|
|
|
|
|
|
|
|
if "update_time" in data: |
|
|
update_time = data['update_time'] |
|
|
if isinstance(update_time, (int, float)): |
|
|
time_str = format_timestamp(update_time) |
|
|
else: |
|
|
time_str = update_time |
|
|
formatted += f"\n⏰ **Mise à jour**: {time_str}\n" |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Erreur formatage alertes: {e}") |
|
|
formatted = f"❌ Erreur formatage alertes: {e}\n\n" |
|
|
formatted += f"Données brutes: {json.dumps(data, indent=2, ensure_ascii=False)[:500]}..." |
|
|
|
|
|
return formatted |
|
|
|
|
|
def get_weather_forecast(address: str): |
|
|
""" |
|
|
Récupère les prévisions météo complètes pour une adresse française. |
|
|
|
|
|
Effectue la géolocalisation via IGN, récupère les données météo via l'API officielle |
|
|
Météo-France, et formate les résultats pour l'affichage avec alertes, conditions |
|
|
actuelles et prévisions. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse française à géolocaliser (ex: "Brest, France", "75001 Paris", "Nice") |
|
|
|
|
|
Returns: |
|
|
tuple: Quatre éléments formatés en markdown: |
|
|
- alerts_info (str): Alertes météo avec niveaux de vigilance (vert/jaune/orange/rouge) |
|
|
- current_conditions (str): Conditions actuelles (température, vent, précipitations) |
|
|
- hourly_forecast (str): Prévisions horaires sur 24h avec données de pluie |
|
|
- daily_forecast (str): Prévisions quotidiennes sur 10 jours |
|
|
|
|
|
Note: |
|
|
Utilise les APIs officielles françaises : |
|
|
- IGN Géoplateforme pour la géolocalisation |
|
|
- Météo-France pour les données météorologiques |
|
|
|
|
|
Examples: |
|
|
>>> alerts, current, hourly, daily = get_weather_forecast("Paris, France") |
|
|
>>> print(current) # Affiche température, vent, etc. |
|
|
""" |
|
|
logger.info(f"🚀 Début de la requête pour: {address}") |
|
|
|
|
|
if not address.strip(): |
|
|
logger.warning("⚠️ Adresse vide fournie") |
|
|
error_msg = "❌ Veuillez saisir une adresse" |
|
|
return error_msg, error_msg, error_msg, error_msg |
|
|
|
|
|
|
|
|
geocoder = GeocodingAPI() |
|
|
geocoding_result = geocoder.geocode_address(address) |
|
|
|
|
|
if not geocoding_result: |
|
|
error_msg = "❌ Impossible de localiser cette adresse" |
|
|
logger.error(f"❌ Géocodage échoué pour: {address}") |
|
|
return error_msg, error_msg, error_msg, error_msg |
|
|
|
|
|
lat = geocoding_result["lat"] |
|
|
lon = geocoding_result["lon"] |
|
|
logger.info(f"✅ Coordonnées obtenues: lat={lat}, lon={lon}") |
|
|
|
|
|
|
|
|
meteo_api = MeteoFranceAPI() |
|
|
weather_data = meteo_api.get_location_forecast(lat, lon) |
|
|
current_weather = meteo_api.get_current_observation(lat, lon) |
|
|
rain_forecast = meteo_api.get_rain_forecast(lat, lon) |
|
|
|
|
|
|
|
|
department = None |
|
|
if weather_data and "position" in weather_data: |
|
|
department = weather_data["position"].get("dept") |
|
|
|
|
|
if department: |
|
|
logger.info(f"🏷️ Utilisation département depuis API forecast: {department}") |
|
|
wind_alerts = meteo_api.get_wind_alerts_by_department(department) |
|
|
else: |
|
|
logger.warning("⚠️ Pas de département trouvé, utilisation géolocalisation") |
|
|
wind_alerts = meteo_api.get_wind_alerts(lat, lon) |
|
|
|
|
|
|
|
|
alerts_info = format_alerts_data(wind_alerts) |
|
|
current_conditions = format_current_conditions(current_weather, rain_forecast, weather_data) |
|
|
hourly_forecast = format_hourly_forecast(weather_data, rain_forecast) |
|
|
daily_forecast = format_daily_forecast(weather_data) |
|
|
|
|
|
logger.info("✅ Requête terminée avec succès") |
|
|
return alerts_info, current_conditions, hourly_forecast, daily_forecast |
|
|
|
|
|
|
|
|
def get_weather_alerts(address: str) -> str: |
|
|
""" |
|
|
Récupère les alertes météorologiques pour une adresse française. |
|
|
|
|
|
Retourne les niveaux de vigilance Météo-France (vert/jaune/orange/rouge) |
|
|
avec détail des phénomènes dangereux. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse française à analyser (ex: "Paris, France", "29200 Brest") |
|
|
|
|
|
Returns: |
|
|
str: Alertes météo formatées avec niveaux de vigilance et phénomènes actifs |
|
|
""" |
|
|
alerts_info, _, _, _ = get_weather_forecast(address) |
|
|
return alerts_info |
|
|
|
|
|
def get_current_weather(address: str) -> str: |
|
|
""" |
|
|
Récupère les conditions météorologiques actuelles pour une adresse. |
|
|
|
|
|
Fournit température, vent, précipitations, humidité, pression et visibilité |
|
|
en temps réel depuis les stations Météo-France. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse française à analyser (ex: "Nice", "75001 Paris") |
|
|
|
|
|
Returns: |
|
|
str: Conditions actuelles détaillées avec données de vent et précipitations |
|
|
""" |
|
|
_, current_conditions, _, _ = get_weather_forecast(address) |
|
|
return current_conditions |
|
|
|
|
|
def get_hourly_forecast(address: str) -> str: |
|
|
""" |
|
|
Récupère les prévisions météorologiques horaires sur 24h. |
|
|
|
|
|
Prévisions détaillées heure par heure avec température, météo, |
|
|
vent, rafales et précipitations intégrées. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse française à analyser (ex: "Toulouse", "13001 Marseille") |
|
|
|
|
|
Returns: |
|
|
str: Prévisions horaires sur 24h avec toutes les données météo |
|
|
""" |
|
|
_, _, hourly_forecast, _ = get_weather_forecast(address) |
|
|
return hourly_forecast |
|
|
|
|
|
def get_daily_forecast(address: str) -> str: |
|
|
""" |
|
|
Récupère les prévisions météorologiques quotidiennes sur 10 jours. |
|
|
|
|
|
Prévisions étendues avec températures minimales et maximales, |
|
|
conditions générales et données de vent quotidiennes. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse française à analyser (ex: "Bordeaux", "69001 Lyon") |
|
|
|
|
|
Returns: |
|
|
str: Prévisions quotidiennes sur 10 jours avec températures et météo |
|
|
""" |
|
|
_, _, _, daily_forecast = get_weather_forecast(address) |
|
|
return daily_forecast |
|
|
|
|
|
def get_complete_weather_forecast(address: str) -> str: |
|
|
""" |
|
|
Récupère un rapport météorologique complet pour une adresse française. |
|
|
|
|
|
Combine alertes, conditions actuelles, prévisions horaires et quotidiennes |
|
|
en un rapport unifié depuis les APIs officielles françaises. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse française à analyser (ex: "Brest", "06000 Nice") |
|
|
|
|
|
Returns: |
|
|
str: Rapport météo complet avec alertes, conditions actuelles et prévisions |
|
|
""" |
|
|
alerts_info, current_conditions, hourly_forecast, daily_forecast = get_weather_forecast(address) |
|
|
|
|
|
complete_report = f""" |
|
|
{alerts_info} |
|
|
|
|
|
{current_conditions} |
|
|
|
|
|
{hourly_forecast} |
|
|
|
|
|
{daily_forecast} |
|
|
""" |
|
|
return complete_report |
|
|
|
|
|
def get_weather_emoji(description: str) -> str: |
|
|
""" |
|
|
Retourne l'émoji météo approprié selon la description textuelle. |
|
|
|
|
|
Args: |
|
|
description (str): Description météo en français (ex: "ensoleillé", "nuageux") |
|
|
|
|
|
Returns: |
|
|
str: Émoji Unicode correspondant à la condition météo |
|
|
(☀️, ☁️, 🌧️, ⛈️, ❄️, 🌫️, ⛅, 🌤️) |
|
|
""" |
|
|
description = description.lower() |
|
|
if "ensoleill" in description or "clair" in description: |
|
|
return "☀️" |
|
|
elif "nuage" in description: |
|
|
return "☁️" |
|
|
elif "pluie" in description or "averse" in description: |
|
|
return "🌧️" |
|
|
elif "orage" in description: |
|
|
return "⛈️" |
|
|
elif "neige" in description: |
|
|
return "❄️" |
|
|
elif "brouillard" in description: |
|
|
return "🌫️" |
|
|
elif "éclaircies" in description: |
|
|
return "⛅" |
|
|
else: |
|
|
return "🌤️" |
|
|
|
|
|
def create_wind_compass(direction: int, speed: float) -> str: |
|
|
""" |
|
|
Génère une boussole HTML interactive pour visualiser la direction du vent. |
|
|
|
|
|
Crée un élément SVG-like en HTML/CSS avec flèche orientée selon la direction |
|
|
et couleur selon la vitesse du vent. |
|
|
|
|
|
Args: |
|
|
direction (int): Direction du vent en degrés (0-360°, 0° = Nord) |
|
|
speed (float): Vitesse du vent en km/h |
|
|
|
|
|
Returns: |
|
|
str: Code HTML de la boussole avec flèche orientée et colorée: |
|
|
- Vert: < 10 km/h |
|
|
- Orange: 10-20 km/h |
|
|
- Rouge: 20-30 km/h |
|
|
- Violet: > 30 km/h |
|
|
""" |
|
|
if not direction or direction < 0: |
|
|
direction = 0 |
|
|
|
|
|
|
|
|
rotation = direction - 90 |
|
|
|
|
|
|
|
|
if speed < 10: |
|
|
color = "#4CAF50" |
|
|
elif speed < 20: |
|
|
color = "#FF9800" |
|
|
elif speed < 30: |
|
|
color = "#F44336" |
|
|
else: |
|
|
color = "#9C27B0" |
|
|
|
|
|
compass_html = f""" |
|
|
<div style="display: inline-block; position: relative; width: 60px; height: 60px; |
|
|
border: 2px solid {color}; border-radius: 50%; margin: 0 10px;"> |
|
|
<!-- Boussole background --> |
|
|
<div style="position: absolute; top: 2px; left: 50%; transform: translateX(-50%); |
|
|
font-size: 8px; color: {color}; font-weight: bold;">N</div> |
|
|
<div style="position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); |
|
|
font-size: 8px; color: {color}; font-weight: bold;">S</div> |
|
|
<div style="position: absolute; left: 2px; top: 50%; transform: translateY(-50%); |
|
|
font-size: 8px; color: {color}; font-weight: bold;">O</div> |
|
|
<div style="position: absolute; right: 2px; top: 50%; transform: translateY(-50%); |
|
|
font-size: 8px; color: {color}; font-weight: bold;">E</div> |
|
|
|
|
|
<!-- Flèche du vent --> |
|
|
<div style="position: absolute; top: 50%; left: 50%; |
|
|
width: 3px; height: 20px; background: {color}; |
|
|
transform: translate(-50%, -50%) rotate({rotation}deg); |
|
|
transform-origin: center; border-radius: 2px;"> |
|
|
<!-- Pointe de la flèche --> |
|
|
<div style="position: absolute; top: -3px; left: 50%; |
|
|
transform: translateX(-50%); |
|
|
width: 0; height: 0; |
|
|
border-left: 4px solid transparent; |
|
|
border-right: 4px solid transparent; |
|
|
border-bottom: 6px solid {color};"></div> |
|
|
</div> |
|
|
|
|
|
<!-- Centre --> |
|
|
<div style="position: absolute; top: 50%; left: 50%; |
|
|
width: 4px; height: 4px; background: {color}; |
|
|
border-radius: 50%; transform: translate(-50%, -50%);"></div> |
|
|
</div> |
|
|
""" |
|
|
return compass_html |
|
|
|
|
|
def create_wind_compass_from_text(wind_text: str) -> str: |
|
|
""" |
|
|
Extrait vitesse et direction du vent depuis un texte formaté et génère une boussole. |
|
|
|
|
|
Parse le texte au format "X.X km/h - Y° [direction]" pour extraire |
|
|
les valeurs numériques et créer la visualisation boussole. |
|
|
|
|
|
Args: |
|
|
wind_text (str): Texte formaté de vent (ex: "12.5 km/h - 270° O") |
|
|
|
|
|
Returns: |
|
|
str: Code HTML de boussole générée, ou chaîne vide si parsing échoue |
|
|
""" |
|
|
import re |
|
|
|
|
|
|
|
|
match = re.search(r'(\d+\.?\d*)\s*km/h.*?(\d+)°', wind_text) |
|
|
if match: |
|
|
speed = float(match.group(1)) |
|
|
direction = int(match.group(2)) |
|
|
return create_wind_compass(direction, speed) |
|
|
return "" |
|
|
|
|
|
|
|
|
def format_alerts_card_html(alerts_info: str) -> str: |
|
|
""" |
|
|
Convertit les alertes météo formatées en carte HTML stylisée. |
|
|
|
|
|
Applique les styles CSS appropriés selon le niveau d'alerte détecté |
|
|
dans le texte (rouge, orange, jaune, ou standard). |
|
|
|
|
|
Args: |
|
|
alerts_info (str): Alertes formatées en markdown depuis format_alerts_data() |
|
|
|
|
|
Returns: |
|
|
str: Carte HTML avec classe CSS appropriée au niveau d'alerte: |
|
|
- alert-card-red: Alerte rouge |
|
|
- alert-card-orange: Alerte orange |
|
|
- alert-card-yellow: Alerte jaune |
|
|
- weather-card: Aucune alerte ou erreur |
|
|
""" |
|
|
if "❌" in alerts_info: |
|
|
return f'<div class="weather-card">{alerts_info}</div>' |
|
|
|
|
|
|
|
|
if "ROUGE" in alerts_info.upper(): |
|
|
card_class = "alert-card-red" |
|
|
elif "ORANGE" in alerts_info.upper(): |
|
|
card_class = "alert-card-orange" |
|
|
elif "JAUNE" in alerts_info.upper(): |
|
|
card_class = "alert-card-yellow" |
|
|
else: |
|
|
card_class = "weather-card" |
|
|
|
|
|
|
|
|
clean_text = alerts_info.replace("**", "<strong>").replace("**", "</strong>") |
|
|
clean_text = clean_text.replace("\n", "<br>") |
|
|
|
|
|
return f'<div class="{card_class}">{clean_text}</div>' |
|
|
|
|
|
def format_current_card_html(current_conditions: str) -> str: |
|
|
""" |
|
|
Convertit les conditions actuelles en carte HTML avec layout optimisé. |
|
|
|
|
|
Parse les données markdown pour extraire température, localisation, météo, |
|
|
vent et précipitations, puis génère une carte avec boussole de vent intégrée. |
|
|
|
|
|
Args: |
|
|
current_conditions (str): Conditions actuelles formatées depuis format_current_conditions() |
|
|
|
|
|
Returns: |
|
|
str: Carte HTML avec layout responsive affichant: |
|
|
- Localisation et description météo |
|
|
- Température avec émoji large |
|
|
- Boussole de vent interactive |
|
|
- Informations de précipitations |
|
|
""" |
|
|
if "❌" in current_conditions: |
|
|
return f'<div class="weather-card">{current_conditions}</div>' |
|
|
|
|
|
|
|
|
lines = current_conditions.split("\n") |
|
|
location = "Localisation inconnue" |
|
|
temp = "--" |
|
|
weather_desc = "--" |
|
|
wind = "--" |
|
|
observation_time = "--" |
|
|
precipitation = "Données non disponibles" |
|
|
|
|
|
for line in lines: |
|
|
if "Lieu**:" in line: |
|
|
location = line.split(":", 1)[1].strip() |
|
|
elif "Température**:" in line: |
|
|
temp = line.split(":", 1)[1].strip() |
|
|
elif "Temps**:" in line: |
|
|
weather_desc = line.split(":", 1)[1].strip().split()[0] |
|
|
elif "Vent**:" in line: |
|
|
wind = line.split(":", 1)[1].strip() |
|
|
elif "Observation**:" in line: |
|
|
observation_time = line.split(":", 1)[1].strip() |
|
|
elif "Précipitations**:" in line: |
|
|
precipitation = line.split(":", 1)[1].strip() |
|
|
|
|
|
weather_emoji = get_weather_emoji(weather_desc) |
|
|
temp_num = temp.replace("°C", "") |
|
|
|
|
|
html = f""" |
|
|
<div class="current-card"> |
|
|
<div style="display: flex; justify-content: space-between; align-items: center;"> |
|
|
<div style="flex: 1;"> |
|
|
<div style="font-size: 1.1em; opacity: 0.8;">{location}</div> |
|
|
<div style="font-size: 0.9em; opacity: 0.7;">{weather_desc}</div> |
|
|
<div style="font-size: 0.8em; opacity: 0.7; margin-top: 2px;">💧 {precipitation}</div> |
|
|
<div style="font-size: 0.8em; opacity: 0.6; margin-top: 4px;">{observation_time}</div> |
|
|
</div> |
|
|
<div style="display: flex; align-items: center; gap: 15px;"> |
|
|
<!-- Vent à droite --> |
|
|
<div style="text-align: center; font-size: 0.85em;"> |
|
|
<div style="opacity: 0.8; margin-bottom: 5px;">🌬️ Vent</div> |
|
|
<div style="font-weight: bold;">{wind.split(' - ')[0] if ' - ' in wind else wind}</div> |
|
|
{create_wind_compass_from_text(wind)} |
|
|
</div> |
|
|
<!-- Température --> |
|
|
<div style="text-align: center;"> |
|
|
<div style="font-size: 2.5em;">{weather_emoji}</div> |
|
|
<div class="temp-large">{temp_num}°</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
""" |
|
|
return html |
|
|
|
|
|
def format_hourly_card_html(hourly_forecast: str) -> str: |
|
|
""" |
|
|
Convertit les prévisions horaires en carte HTML avec grille horizontale. |
|
|
|
|
|
Parse les données markdown pour créer une grille de prévisions horaires |
|
|
avec émojis météo, températures et vitesses de vent. |
|
|
|
|
|
Args: |
|
|
hourly_forecast (str): Prévisions horaires formatées depuis format_hourly_forecast() |
|
|
|
|
|
Returns: |
|
|
str: Carte HTML avec grille d'éléments horaires (max 12h): |
|
|
- Heure, émoji météo, température, vitesse vent |
|
|
- Layout responsive avec items arrondis |
|
|
""" |
|
|
if "❌" in hourly_forecast: |
|
|
return f'<div class="weather-card">{hourly_forecast}</div>' |
|
|
|
|
|
|
|
|
lines = hourly_forecast.split("\n") |
|
|
hourly_items = [] |
|
|
|
|
|
i = 0 |
|
|
while i < len(lines): |
|
|
line = lines[i].strip() |
|
|
if "**" in line and ":" in line: |
|
|
time_part = line.split("**")[1].split(":")[0] |
|
|
temp_weather = line.split(": ", 1)[1] if ": " in line else "--" |
|
|
|
|
|
|
|
|
wind_info = "--" |
|
|
if i + 1 < len(lines) and "Vent:" in lines[i + 1]: |
|
|
wind_line = lines[i + 1].strip() |
|
|
|
|
|
if "km/h" in wind_line: |
|
|
wind_speed = wind_line.split()[1] if len(wind_line.split()) > 1 else "--" |
|
|
wind_info = f"{wind_speed} km/h" |
|
|
|
|
|
temp = temp_weather.split(" - ")[0] if " - " in temp_weather else "--" |
|
|
weather = temp_weather.split(" - ")[1] if " - " in temp_weather else "--" |
|
|
|
|
|
hourly_items.append({ |
|
|
"time": time_part, |
|
|
"temp": temp, |
|
|
"weather": weather, |
|
|
"wind": wind_info |
|
|
}) |
|
|
i += 1 |
|
|
|
|
|
|
|
|
items_html = "" |
|
|
for item in hourly_items[:12]: |
|
|
emoji = get_weather_emoji(item["weather"]) |
|
|
items_html += f""" |
|
|
<div class="hourly-item"> |
|
|
<div style="font-weight: bold; min-width: 50px;">{item["time"]}</div> |
|
|
<div style="font-size: 1.3em; margin: 0 8px;">{emoji}</div> |
|
|
<div style="font-weight: bold; min-width: 45px;">{item["temp"]}</div> |
|
|
<div style="font-size: 0.8em; opacity: 0.8; min-width: 50px; text-align: right;">{item["wind"]}</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
html = f""" |
|
|
<div class="hourly-card"> |
|
|
<div style="font-size: 1.3em; margin-bottom: 12px;"><strong>🕐 Prochaines heures</strong></div> |
|
|
{items_html} |
|
|
</div> |
|
|
""" |
|
|
return html |
|
|
|
|
|
def format_daily_card_html(daily_forecast: str) -> str: |
|
|
""" |
|
|
Convertit les prévisions quotidiennes en carte HTML compacte. |
|
|
|
|
|
Parse les prévisions sur 10 jours pour créer une liste verticale |
|
|
avec émojis météo et plages de températures. |
|
|
|
|
|
Args: |
|
|
daily_forecast (str): Prévisions quotidiennes formatées depuis format_daily_forecast() |
|
|
|
|
|
Returns: |
|
|
str: Carte HTML avec liste de jours (max 7 affichés): |
|
|
- Jour, émoji météo, températures min/max |
|
|
- Items avec fond semi-transparent |
|
|
""" |
|
|
if "❌" in daily_forecast: |
|
|
return f'<div class="weather-card">{daily_forecast}</div>' |
|
|
|
|
|
|
|
|
lines = daily_forecast.split("\n") |
|
|
daily_items = [] |
|
|
|
|
|
i = 0 |
|
|
while i < len(lines): |
|
|
line = lines[i].strip() |
|
|
if "**" in line and "°" in line: |
|
|
day_part = line.split("**")[1].split(":")[0] |
|
|
temps_part = line.split(": ", 1)[1] if ": " in line else "--" |
|
|
|
|
|
|
|
|
weather_desc = "--" |
|
|
if i + 1 < len(lines) and lines[i + 1].strip(): |
|
|
weather_desc = lines[i + 1].strip() |
|
|
|
|
|
daily_items.append({ |
|
|
"day": day_part, |
|
|
"temps": temps_part, |
|
|
"weather": weather_desc |
|
|
}) |
|
|
i += 1 |
|
|
|
|
|
|
|
|
items_html = "" |
|
|
for item in daily_items[:7]: |
|
|
emoji = get_weather_emoji(item["weather"]) |
|
|
items_html += f""" |
|
|
<div class="daily-item"> |
|
|
<div style="font-weight: bold; min-width: 55px; font-size: 0.9em;">{item["day"]}</div> |
|
|
<div style="font-size: 1.3em; margin: 0 8px;">{emoji}</div> |
|
|
<div style="font-weight: bold; text-align: right; font-size: 0.9em;">{item["temps"]}</div> |
|
|
</div> |
|
|
""" |
|
|
|
|
|
html = f""" |
|
|
<div class="daily-card"> |
|
|
<div style="font-size: 1.3em; margin-bottom: 12px;"><strong>📅 Prochains jours</strong></div> |
|
|
{items_html} |
|
|
</div> |
|
|
""" |
|
|
return html |
|
|
|
|
|
|
|
|
def get_weather_forecast_html(address: str): |
|
|
""" |
|
|
Version HTML des prévisions météo avec cartes visuelles. |
|
|
|
|
|
Args: |
|
|
address (str): Adresse à géolocaliser (ex: "Paris, France") |
|
|
|
|
|
Returns: |
|
|
tuple: (alerts_html, current_html, hourly_html, daily_html) |
|
|
- alerts_html: Carte HTML des alertes météo |
|
|
- current_html: Carte HTML des conditions actuelles |
|
|
- hourly_html: Carte HTML des prévisions horaires |
|
|
- daily_html: Carte HTML des prévisions sur 10 jours |
|
|
""" |
|
|
|
|
|
alerts_info, current_conditions, hourly_forecast, daily_forecast = get_weather_forecast(address) |
|
|
|
|
|
|
|
|
alerts_html = format_alerts_card_html(alerts_info) |
|
|
current_html = format_current_card_html(current_conditions) |
|
|
hourly_html = format_hourly_card_html(hourly_forecast) |
|
|
daily_html = format_daily_card_html(daily_forecast) |
|
|
|
|
|
return alerts_html, current_html, hourly_html, daily_html |
|
|
|
|
|
|
|
|
def create_mcp_interface(): |
|
|
""" |
|
|
Crée l'interface Gradio pour le serveur MCP avec cartes visuelles et fonctions individuelles. |
|
|
|
|
|
Combine la belle interface avec cartes HTML et les fonctions MCP séparées : |
|
|
- Interface web: Cartes visuelles avec dégradés et boussoles |
|
|
- Fonctions MCP: 5 outils texte individuels pour Claude Desktop |
|
|
|
|
|
Returns: |
|
|
gr.Blocks: Interface Gradio hybride optimisée pour MCP et web |
|
|
""" |
|
|
|
|
|
custom_css = """ |
|
|
:root { |
|
|
/* Couleurs Apple */ |
|
|
--apple-blue: #0066CC; |
|
|
--apple-shark: #1D1D1F; |
|
|
--apple-gray: #F5F5F7; |
|
|
--apple-white: #FFFFFF; |
|
|
--system-blue: #007AFF; |
|
|
--system-red: #FF3B30; |
|
|
--system-orange: #FF9500; |
|
|
--system-yellow: #FFCC00; |
|
|
--system-green: #34C759; |
|
|
|
|
|
/* Variables Liquid Glass */ |
|
|
--backdrop-blur: 20px; |
|
|
--backdrop-brightness: 110%; |
|
|
--glass-opacity: 0.15; |
|
|
--glass-border-light: rgba(255, 255, 255, 0.2); |
|
|
--glass-border-dark: rgba(255, 255, 255, 0.1); |
|
|
} |
|
|
|
|
|
@media (prefers-color-scheme: dark) { |
|
|
:root { |
|
|
--glass-opacity: 0.1; |
|
|
} |
|
|
} |
|
|
|
|
|
html, body, .gradio-container, .app, .main, #root { |
|
|
max-width: 900px !important; |
|
|
margin: 0 auto !important; |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; |
|
|
min-height: 100vh !important; |
|
|
} |
|
|
|
|
|
/* Forcer le fond sur HuggingFace Spaces */ |
|
|
.gradio-app { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; |
|
|
} |
|
|
|
|
|
/* Override Hugging Face Spaces default styles */ |
|
|
.app.svelte-182fdeq.svelte-182fdeq { |
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; |
|
|
} |
|
|
|
|
|
.weather-card { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(255, 255, 255, 0.25), |
|
|
rgba(255, 255, 255, 0.1) |
|
|
); |
|
|
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
border: 0.5px solid var(--glass-border-light); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
margin: 8px 0; |
|
|
color: white; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(31, 38, 135, 0.37), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2), |
|
|
inset 0 -1px 0 rgba(0, 0, 0, 0.1); |
|
|
width: 100%; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.weather-card:hover { |
|
|
backdrop-filter: blur(25px) saturate(200%); |
|
|
-webkit-backdrop-filter: blur(25px) saturate(200%); |
|
|
background: rgba(255, 255, 255, 0.25); |
|
|
transform: translateY(-2px) translateZ(0); |
|
|
box-shadow: 0 12px 40px rgba(31, 38, 135, 0.5); |
|
|
} |
|
|
|
|
|
.alert-card-red { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(255, 59, 48, var(--glass-opacity)), |
|
|
rgba(255, 59, 48, 0.08) |
|
|
); |
|
|
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
border: 1px solid rgba(255, 59, 48, 0.3); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
margin: 8px 0; |
|
|
color: white; |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(255, 59, 48, 0.4), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2); |
|
|
width: 100%; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.alert-card-orange { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(255, 149, 0, var(--glass-opacity)), |
|
|
rgba(255, 149, 0, 0.08) |
|
|
); |
|
|
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
border: 1px solid rgba(255, 149, 0, 0.3); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
margin: 8px 0; |
|
|
color: white; |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(255, 149, 0, 0.37), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2); |
|
|
width: 100%; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.alert-card-yellow { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(255, 204, 0, 0.3), |
|
|
rgba(255, 204, 0, 0.15) |
|
|
); |
|
|
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
border: 1px solid rgba(255, 204, 0, 0.5); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
margin: 8px 0; |
|
|
color: white; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7); |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(255, 204, 0, 0.37), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2); |
|
|
width: 100%; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.current-card { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(0, 122, 255, var(--glass-opacity)), |
|
|
rgba(0, 122, 255, 0.05) |
|
|
); |
|
|
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
border: 0.5px solid var(--glass-border-light); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
margin: 8px 0; |
|
|
color: white; |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(0, 122, 255, 0.37), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2), |
|
|
inset 0 -1px 0 rgba(0, 0, 0, 0.1); |
|
|
width: 100%; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.hourly-card { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(175, 82, 222, var(--glass-opacity)), |
|
|
rgba(175, 82, 222, 0.05) |
|
|
); |
|
|
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
border: 0.5px solid var(--glass-border-light); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
margin: 8px 0; |
|
|
color: white; |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(175, 82, 222, 0.37), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2), |
|
|
inset 0 -1px 0 rgba(0, 0, 0, 0.1); |
|
|
width: 100%; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.daily-card { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(52, 199, 89, var(--glass-opacity)), |
|
|
rgba(52, 199, 89, 0.05) |
|
|
); |
|
|
backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
-webkit-backdrop-filter: blur(var(--backdrop-blur)) saturate(180%); |
|
|
border: 0.5px solid var(--glass-border-light); |
|
|
border-radius: 20px; |
|
|
padding: 20px; |
|
|
margin: 8px 0; |
|
|
color: white; |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(52, 199, 89, 0.37), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2), |
|
|
inset 0 -1px 0 rgba(0, 0, 0, 0.1); |
|
|
width: 100%; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.temp-large { |
|
|
font-size: 2.5em; |
|
|
font-weight: 700; |
|
|
margin: 5px 0; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.hourly-item { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 8px 12px; |
|
|
margin: 4px 0; |
|
|
background: rgba(255, 255, 255, 0.15); |
|
|
backdrop-filter: blur(10px); |
|
|
-webkit-backdrop-filter: blur(10px); |
|
|
border: 1px solid rgba(255, 255, 255, 0.18); |
|
|
border-radius: 15px; |
|
|
font-size: 0.9em; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.hourly-item:hover { |
|
|
background: rgba(255, 255, 255, 0.25); |
|
|
transform: translateY(-1px) translateZ(0); |
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
|
|
} |
|
|
|
|
|
.daily-item { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
padding: 10px 12px; |
|
|
margin: 4px 0; |
|
|
background: rgba(255, 255, 255, 0.15); |
|
|
backdrop-filter: blur(10px); |
|
|
-webkit-backdrop-filter: blur(10px); |
|
|
border: 1px solid rgba(255, 255, 255, 0.18); |
|
|
border-radius: 15px; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1); |
|
|
transform: translateZ(0); |
|
|
} |
|
|
|
|
|
.daily-item:hover { |
|
|
background: rgba(255, 255, 255, 0.25); |
|
|
transform: translateY(-1px) translateZ(0); |
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
|
|
} |
|
|
|
|
|
/* Système adaptatif mode sombre */ |
|
|
@media (prefers-color-scheme: dark) { |
|
|
body, .gradio-container { |
|
|
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%) !important; |
|
|
} |
|
|
|
|
|
.weather-card, .current-card, .hourly-card, .daily-card { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(255, 255, 255, 0.15), |
|
|
rgba(255, 255, 255, 0.05) |
|
|
); |
|
|
border: 0.5px solid var(--glass-border-dark); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.hourly-item, .daily-item { |
|
|
background: rgba(255, 255, 255, 0.1); |
|
|
border: 1px solid rgba(255, 255, 255, 0.1); |
|
|
} |
|
|
} |
|
|
|
|
|
/* Styles spécifiques avec classes personnalisées */ |
|
|
.liquid-glass-button { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(255, 255, 255, 0.25), |
|
|
rgba(255, 255, 255, 0.1) |
|
|
) !important; |
|
|
backdrop-filter: blur(15px) saturate(180%) !important; |
|
|
-webkit-backdrop-filter: blur(15px) saturate(180%) !important; |
|
|
border: 0.5px solid rgba(255, 255, 255, 0.2) !important; |
|
|
border-radius: 50px !important; |
|
|
color: white !important; |
|
|
font-weight: 500 !important; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5) !important; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1) !important; |
|
|
transform: translateZ(0) !important; |
|
|
box-shadow: |
|
|
0 4px 16px rgba(31, 38, 135, 0.37), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.2) !important; |
|
|
} |
|
|
|
|
|
.liquid-glass-button:hover { |
|
|
background: rgba(255, 255, 255, 0.35) !important; |
|
|
backdrop-filter: blur(20px) saturate(200%) !important; |
|
|
-webkit-backdrop-filter: blur(20px) saturate(200%) !important; |
|
|
transform: translateY(-2px) translateZ(0) !important; |
|
|
box-shadow: 0 8px 25px rgba(31, 38, 135, 0.5) !important; |
|
|
} |
|
|
|
|
|
.liquid-glass-textbox { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(102, 126, 234, 0.3), |
|
|
rgba(118, 75, 162, 0.2) |
|
|
) !important; |
|
|
backdrop-filter: blur(20px) saturate(180%) !important; |
|
|
-webkit-backdrop-filter: blur(20px) saturate(180%) !important; |
|
|
border: 2px solid rgba(255, 255, 255, 0.4) !important; |
|
|
border-radius: 20px !important; |
|
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1) !important; |
|
|
transform: translateZ(0) !important; |
|
|
overflow: hidden !important; |
|
|
box-shadow: |
|
|
0 8px 32px 0 rgba(31, 38, 135, 0.4), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.3), |
|
|
inset 0 -1px 0 rgba(0, 0, 0, 0.1) !important; |
|
|
} |
|
|
|
|
|
/* Ciblage spécifique basé sur le HTML généré */ |
|
|
.liquid-glass-textbox.svelte-1svsvh2, |
|
|
.liquid-glass-textbox .container, |
|
|
.liquid-glass-textbox .show_textbox_border, |
|
|
.liquid-glass-textbox .svelte-173056l, |
|
|
.liquid-glass-textbox .input-container, |
|
|
.liquid-glass-textbox textarea.svelte-173056l { |
|
|
border-radius: 20px !important; |
|
|
overflow: hidden !important; |
|
|
background: transparent !important; |
|
|
border: none !important; |
|
|
box-shadow: none !important; |
|
|
} |
|
|
|
|
|
/* Styles spécifiques pour le label container */ |
|
|
.liquid-glass-textbox .container.show_textbox_border { |
|
|
border: none !important; |
|
|
background: transparent !important; |
|
|
border-radius: 20px !important; |
|
|
} |
|
|
|
|
|
/* Style pour le conteneur d'input */ |
|
|
.liquid-glass-textbox .input-container.svelte-173056l { |
|
|
background: transparent !important; |
|
|
border: none !important; |
|
|
border-radius: 20px !important; |
|
|
margin: 0 !important; |
|
|
padding: 0 !important; |
|
|
} |
|
|
|
|
|
/* Application du clip-path sur l'élément racine pour forcer les coins */ |
|
|
.liquid-glass-textbox.svelte-1svsvh2.padded { |
|
|
clip-path: inset(0 round 20px) !important; |
|
|
-webkit-clip-path: inset(0 round 20px) !important; |
|
|
overflow: hidden !important; |
|
|
position: relative !important; |
|
|
} |
|
|
|
|
|
/* Masquage complet de tous les arrière-plans internes */ |
|
|
.liquid-glass-textbox * { |
|
|
background-color: transparent !important; |
|
|
background-image: none !important; |
|
|
background: transparent !important; |
|
|
} |
|
|
|
|
|
/* Créer un pseudo-élément pour masquer les débordements */ |
|
|
.liquid-glass-textbox::after { |
|
|
content: "" !important; |
|
|
position: absolute !important; |
|
|
top: 0 !important; |
|
|
left: 0 !important; |
|
|
right: 0 !important; |
|
|
bottom: 0 !important; |
|
|
border-radius: 20px !important; |
|
|
pointer-events: none !important; |
|
|
z-index: 1 !important; |
|
|
clip-path: inset(0 round 20px) !important; |
|
|
-webkit-clip-path: inset(0 round 20px) !important; |
|
|
} |
|
|
|
|
|
.liquid-glass-textbox input, |
|
|
.liquid-glass-textbox textarea { |
|
|
background: transparent !important; |
|
|
color: white !important; |
|
|
border: none !important; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5) !important; |
|
|
padding: 16px 20px !important; |
|
|
font-size: 16px !important; |
|
|
font-weight: 400 !important; |
|
|
} |
|
|
|
|
|
.liquid-glass-textbox input::placeholder, |
|
|
.liquid-glass-textbox textarea::placeholder { |
|
|
color: rgba(255, 255, 255, 0.8) !important; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5) !important; |
|
|
font-weight: 400 !important; |
|
|
} |
|
|
|
|
|
.liquid-glass-textbox:focus-within { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(102, 126, 234, 0.35), |
|
|
rgba(118, 75, 162, 0.25) |
|
|
) !important; |
|
|
backdrop-filter: blur(25px) saturate(200%) !important; |
|
|
-webkit-backdrop-filter: blur(25px) saturate(200%) !important; |
|
|
border-color: rgba(255, 255, 255, 0.5) !important; |
|
|
box-shadow: |
|
|
0 12px 40px rgba(31, 38, 135, 0.5), |
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.3) !important; |
|
|
transform: translateY(-1px) translateZ(0) !important; |
|
|
clip-path: inset(0 round 20px) !important; |
|
|
-webkit-clip-path: inset(0 round 20px) !important; |
|
|
} |
|
|
|
|
|
/* Maintenir les coins arrondis sur les éléments au focus */ |
|
|
.liquid-glass-textbox:focus-within .container, |
|
|
.liquid-glass-textbox:focus-within .show_textbox_border, |
|
|
.liquid-glass-textbox:focus-within .svelte-173056l, |
|
|
.liquid-glass-textbox:focus-within .input-container, |
|
|
.liquid-glass-textbox:focus-within textarea.svelte-173056l, |
|
|
.liquid-glass-textbox textarea:focus { |
|
|
border-radius: 20px !important; |
|
|
overflow: hidden !important; |
|
|
background: transparent !important; |
|
|
border: none !important; |
|
|
box-shadow: none !important; |
|
|
outline: none !important; |
|
|
} |
|
|
|
|
|
.liquid-glass-textbox label { |
|
|
color: white !important; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7) !important; |
|
|
font-weight: 600 !important; |
|
|
font-size: 14px !important; |
|
|
margin-bottom: 8px !important; |
|
|
} |
|
|
|
|
|
/* Style du crédit en bas de page */ |
|
|
.footer-credit { |
|
|
background: linear-gradient( |
|
|
135deg, |
|
|
rgba(255, 255, 255, 0.15), |
|
|
rgba(255, 255, 255, 0.05) |
|
|
) !important; |
|
|
backdrop-filter: blur(15px) saturate(180%) !important; |
|
|
-webkit-backdrop-filter: blur(15px) saturate(180%) !important; |
|
|
border: 0.5px solid rgba(255, 255, 255, 0.2) !important; |
|
|
border-radius: 15px !important; |
|
|
color: white !important; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7) !important; |
|
|
padding: 15px !important; |
|
|
margin-top: 20px !important; |
|
|
text-align: center !important; |
|
|
font-size: 0.9em !important; |
|
|
} |
|
|
|
|
|
.footer-credit a { |
|
|
color: rgba(255, 255, 255, 0.9) !important; |
|
|
text-decoration: none !important; |
|
|
font-weight: 600 !important; |
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7) !important; |
|
|
} |
|
|
|
|
|
.footer-credit a:hover { |
|
|
color: white !important; |
|
|
text-shadow: 0 0 8px rgba(255, 255, 255, 0.5) !important; |
|
|
} |
|
|
|
|
|
/* Réduction de transparence pour accessibilité */ |
|
|
@media (prefers-reduced-transparency: reduce) { |
|
|
.weather-card, .current-card, .hourly-card, .daily-card, |
|
|
.alert-card-red, .alert-card-orange, .alert-card-yellow { |
|
|
background: rgba(255, 255, 255, 0.9); |
|
|
backdrop-filter: none; |
|
|
-webkit-backdrop-filter: none; |
|
|
} |
|
|
|
|
|
.hourly-item, .daily-item { |
|
|
background: rgba(255, 255, 255, 0.2); |
|
|
backdrop-filter: none; |
|
|
-webkit-backdrop-filter: none; |
|
|
} |
|
|
|
|
|
button, input[type="text"], textarea { |
|
|
background: rgba(255, 255, 255, 0.9) !important; |
|
|
backdrop-filter: none !important; |
|
|
-webkit-backdrop-filter: none !important; |
|
|
} |
|
|
} |
|
|
|
|
|
/* Réduction mouvement pour accessibilité */ |
|
|
@media (prefers-reduced-motion: reduce) { |
|
|
.weather-card, .current-card, .hourly-card, .daily-card, |
|
|
.alert-card-red, .alert-card-orange, .alert-card-yellow, |
|
|
.hourly-item, .daily-item, button, input[type="text"], textarea { |
|
|
transition: none; |
|
|
animation: none; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
|
|
|
""" |
|
|
|
|
|
|
|
|
theme = gr.themes.Soft( |
|
|
primary_hue="blue", |
|
|
secondary_hue="purple", |
|
|
neutral_hue="slate" |
|
|
).set( |
|
|
body_background_fill="linear-gradient(135deg, #667eea 0%, #764ba2 100%)", |
|
|
background_fill_primary="transparent", |
|
|
background_fill_secondary="transparent" |
|
|
) |
|
|
|
|
|
with gr.Blocks( |
|
|
title="🌦️ Météo France - Serveur MCP", |
|
|
theme=theme, |
|
|
css=custom_css |
|
|
) as interface: |
|
|
|
|
|
gr.Markdown("# 🌦️ Météo France") |
|
|
gr.Markdown(""" |
|
|
Prévisions météorologiques détaillées avec alertes |
|
|
|
|
|
**5 fonctions MCP disponibles :** |
|
|
`get_weather_alerts` | `get_current_weather` | `get_hourly_forecast` | `get_daily_forecast` | `get_complete_weather_forecast` |
|
|
""") |
|
|
|
|
|
|
|
|
with gr.Row(): |
|
|
address_input = gr.Textbox( |
|
|
label="📍 Adresse", |
|
|
placeholder="Entrez une adresse (ex: Paris, France)", |
|
|
scale=3, |
|
|
elem_classes=["liquid-glass-textbox"] |
|
|
) |
|
|
submit_btn = gr.Button("🔄 Actualiser", variant="primary", scale=1, elem_classes=["liquid-glass-button"]) |
|
|
|
|
|
|
|
|
with gr.Row(): |
|
|
alerts_output = gr.HTML(label="🚨 Alertes Météo") |
|
|
|
|
|
|
|
|
with gr.Row(): |
|
|
current_output = gr.HTML(label="🌡️ Maintenant") |
|
|
|
|
|
|
|
|
with gr.Row(): |
|
|
hourly_output = gr.HTML(label="🕐 Heure par Heure") |
|
|
|
|
|
|
|
|
with gr.Row(): |
|
|
daily_output = gr.HTML(label="📅 10 Jours") |
|
|
|
|
|
|
|
|
submit_btn.click( |
|
|
fn=get_weather_forecast_html, |
|
|
inputs=[address_input], |
|
|
outputs=[alerts_output, current_output, hourly_output, daily_output] |
|
|
) |
|
|
|
|
|
address_input.submit( |
|
|
fn=get_weather_forecast_html, |
|
|
inputs=[address_input], |
|
|
outputs=[alerts_output, current_output, hourly_output, daily_output] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
with gr.Row(visible=False): |
|
|
mcp_input = gr.Textbox() |
|
|
mcp_output = gr.Textbox() |
|
|
|
|
|
alerts_mcp_btn = gr.Button("MCP Alerts") |
|
|
current_mcp_btn = gr.Button("MCP Current") |
|
|
hourly_mcp_btn = gr.Button("MCP Hourly") |
|
|
daily_mcp_btn = gr.Button("MCP Daily") |
|
|
complete_mcp_btn = gr.Button("MCP Complete") |
|
|
|
|
|
|
|
|
alerts_mcp_btn.click( |
|
|
fn=get_weather_alerts, |
|
|
inputs=mcp_input, |
|
|
outputs=mcp_output, |
|
|
show_api=True, |
|
|
api_name="get_weather_alerts" |
|
|
) |
|
|
|
|
|
current_mcp_btn.click( |
|
|
fn=get_current_weather, |
|
|
inputs=mcp_input, |
|
|
outputs=mcp_output, |
|
|
show_api=True, |
|
|
api_name="get_current_weather" |
|
|
) |
|
|
|
|
|
hourly_mcp_btn.click( |
|
|
fn=get_hourly_forecast, |
|
|
inputs=mcp_input, |
|
|
outputs=mcp_output, |
|
|
show_api=True, |
|
|
api_name="get_hourly_forecast" |
|
|
) |
|
|
|
|
|
daily_mcp_btn.click( |
|
|
fn=get_daily_forecast, |
|
|
inputs=mcp_input, |
|
|
outputs=mcp_output, |
|
|
show_api=True, |
|
|
api_name="get_daily_forecast" |
|
|
) |
|
|
|
|
|
complete_mcp_btn.click( |
|
|
fn=get_complete_weather_forecast, |
|
|
inputs=mcp_input, |
|
|
outputs=mcp_output, |
|
|
show_api=True, |
|
|
api_name="get_complete_weather_forecast" |
|
|
) |
|
|
|
|
|
|
|
|
with gr.Row(): |
|
|
gr.HTML(""" |
|
|
<div class="footer-credit"> |
|
|
📡 <strong>Données météorologiques</strong> fournies par |
|
|
<a href="https://meteofrance.fr" target="_blank"> |
|
|
<strong>Météo-France</strong> |
|
|
</a> | |
|
|
🗺️ <strong>Géolocalisation</strong> par |
|
|
<a href="https://geoservices.ign.fr" target="_blank"> |
|
|
<strong>IGN Géoplateforme</strong> |
|
|
</a> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
return interface |
|
|
|
|
|
if __name__ == "__main__": |
|
|
interface = create_mcp_interface() |
|
|
interface.launch(mcp_server=True) |