Initial commit — site configs
- vault.hidrago.click: Vaultwarden (password manager) - git.hidrago.click: Gitea (git service) - All static sites (suma, weather, combien-jour, tokyo-trip, etc.) - Nginx configs for all subdomains - Systemd user service for Vaultwarden
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch daily weather for Chambourcy, Kuwana, Tokyo and generate a static HTML page (FR/EN/JA)."""
|
||||
|
||||
import json
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# City configs
|
||||
CITIES = [
|
||||
{"name": "Chambourcy", "lat": 48.892, "lon": 2.049, "tz": "Europe/Paris"},
|
||||
{"name": "Kuwana", "lat": 35.064, "lon": 136.686, "tz": "Asia/Tokyo"},
|
||||
{"name": "Tokyo", "lat": 35.6762, "lon": 139.6503, "tz": "Asia/Tokyo"},
|
||||
]
|
||||
|
||||
# WMO weather codes -> {lang: description}
|
||||
WMO_CODES = {
|
||||
0: {"fr": "Ciel dégagé", "en": "Clear sky", "ja": "快晴", "emoji": "☀️"},
|
||||
1: {"fr": "Peu nuageux", "en": "Mainly clear", "ja": "概ね晴れ", "emoji": "🌤️"},
|
||||
2: {"fr": "Partiellement nuageux", "en": "Partly cloudy", "ja": "所により曇り", "emoji": "⛅"},
|
||||
3: {"fr": "Couvert", "en": "Overcast", "ja": "曇り", "emoji": "☁️"},
|
||||
45: {"fr": "Brouillard", "en": "Fog", "ja": "霧", "emoji": "🌫️"},
|
||||
48: {"fr": "Brouillard givrant", "en": "Rime fog", "ja": "着氷霧", "emoji": "🌫️"},
|
||||
51: {"fr": "Bruine légère", "en": "Light drizzle", "ja": "軽い霧雨", "emoji": "🌦️"},
|
||||
53: {"fr": "Bruine modérée", "en": "Moderate drizzle", "ja": "霧雨", "emoji": "🌦️"},
|
||||
55: {"fr": "Bruine dense", "en": "Dense drizzle", "ja": "濃い霧雨", "emoji": "🌧️"},
|
||||
56: {"fr": "Bruine verglaçante", "en": "Freezing drizzle", "ja": "着氷霧雨", "emoji": "🌧️"},
|
||||
57: {"fr": "Bruine verglaçante dense", "en": "Dense freezing drizzle", "ja": "濃い着氷霧雨", "emoji": "🌧️"},
|
||||
61: {"fr": "Pluie légère", "en": "Slight rain", "ja": "小雨", "emoji": "🌧️"},
|
||||
63: {"fr": "Pluie modérée", "en": "Moderate rain", "ja": "雨", "emoji": "🌧️"},
|
||||
65: {"fr": "Pluie forte", "en": "Heavy rain", "ja": "大雨", "emoji": "🌧️"},
|
||||
66: {"fr": "Pluie verglaçante", "en": "Freezing rain", "ja": "着氷雨", "emoji": "🌧️"},
|
||||
67: {"fr": "Pluie verglaçante forte", "en": "Heavy freezing rain", "ja": "着氷大雨", "emoji": "🌧️"},
|
||||
71: {"fr": "Neige légère", "en": "Slight snow", "ja": "小雪", "emoji": "🌨️"},
|
||||
73: {"fr": "Neige modérée", "en": "Moderate snow", "ja": "雪", "emoji": "🌨️"},
|
||||
75: {"fr": "Neige forte", "en": "Heavy snow", "ja": "大雪", "emoji": "❄️"},
|
||||
77: {"fr": "Grains de neige", "en": "Snow grains", "ja": "細かい雪", "emoji": "🌨️"},
|
||||
80: {"fr": "Averses légères", "en": "Light showers", "ja": "軽いにわか雨", "emoji": "🌦️"},
|
||||
81: {"fr": "Averses modérées", "en": "Moderate showers", "ja": "にわか雨", "emoji": "🌧️"},
|
||||
82: {"fr": "Averses violentes", "en": "Violent showers", "ja": "激しいにわか雨", "emoji": "⛈️"},
|
||||
85: {"fr": "Averses de neige légères", "en": "Light snow showers", "ja": "軽いにわか雪", "emoji": "🌨️"},
|
||||
86: {"fr": "Averses de neige fortes", "en": "Heavy snow showers", "ja": "激しいにわか雪", "emoji": "❄️"},
|
||||
95: {"fr": "Orage", "en": "Thunderstorm", "ja": "雷雨", "emoji": "⛈️"},
|
||||
96: {"fr": "Orage grêle léger", "en": "Thunderstorm slight hail", "ja": "雷雨・小さな雹", "emoji": "⛈️"},
|
||||
99: {"fr": "Orage grêle fort", "en": "Thunderstorm heavy hail", "ja": "雷雨・大きな雹", "emoji": "⛈️"},
|
||||
}
|
||||
|
||||
# UI translations
|
||||
I18N = {
|
||||
"fr": {
|
||||
"title": "Météo — Chambourcy · Kuwana · Tokyo",
|
||||
"heading": "🌤️ Météo du jour",
|
||||
"subtitle": "Chambourcy · Kuwana · Tokyo",
|
||||
"feels": "Ressenti",
|
||||
"humidity": "Humidité",
|
||||
"wind": "Vent",
|
||||
"maxmin": "Max / Min",
|
||||
"precip": "Précip.",
|
||||
"sunrise": "Lever",
|
||||
"sunset": "Coucher",
|
||||
"data_source": "Données",
|
||||
"updated": "Mis à jour le",
|
||||
"error": "Erreur",
|
||||
"unknown": "Inconnu",
|
||||
},
|
||||
"en": {
|
||||
"title": "Weather — Chambourcy · Kuwana · Tokyo",
|
||||
"heading": "🌤️ Today's Weather",
|
||||
"subtitle": "Chambourcy · Kuwana · Tokyo",
|
||||
"feels": "Feels like",
|
||||
"humidity": "Humidity",
|
||||
"wind": "Wind",
|
||||
"maxmin": "High / Low",
|
||||
"precip": "Rain",
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"data_source": "Data",
|
||||
"updated": "Updated on",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown",
|
||||
},
|
||||
"ja": {
|
||||
"title": "天気 — Chambourcy · Kuwana · Tokyo",
|
||||
"heading": "🌤️ 今日の天気",
|
||||
"subtitle": "Chambourcy · Kuwana · Tokyo",
|
||||
"feels": "体感温度",
|
||||
"humidity": "湿度",
|
||||
"wind": "風速",
|
||||
"maxmin": "最高 / 最低",
|
||||
"precip": "降水量",
|
||||
"sunrise": "日の出",
|
||||
"sunset": "日の入り",
|
||||
"data_source": "データ元",
|
||||
"updated": "更新日時",
|
||||
"error": "エラー",
|
||||
"unknown": "不明",
|
||||
},
|
||||
}
|
||||
|
||||
# Country names per lang
|
||||
COUNTRY = {
|
||||
"Chambourcy": {"fr": "France", "en": "France", "ja": "フランス"},
|
||||
"Kuwana": {"fr": "Japon", "en": "Japan", "ja": "日本"},
|
||||
"Tokyo": {"fr": "Japon", "en": "Japan", "ja": "日本"},
|
||||
}
|
||||
|
||||
# Export i18n + country maps as JSON for client-side JS
|
||||
I18N_JSON = json.dumps(I18N, ensure_ascii=False)
|
||||
COUNTRY_JSON = json.dumps(COUNTRY, ensure_ascii=False)
|
||||
WMO_JSON = json.dumps(WMO_CODES, ensure_ascii=False)
|
||||
|
||||
|
||||
def fetch_weather(city):
|
||||
lat, lon = city["lat"], city["lon"]
|
||||
url = (
|
||||
f"https://api.open-meteo.com/v1/forecast"
|
||||
f"?latitude={lat}&longitude={lon}"
|
||||
f"¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m"
|
||||
f"&daily=weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_sum"
|
||||
f"&timezone={city['tz']}"
|
||||
f"&forecast_days=1"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "weather-example/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def generate_html():
|
||||
now = datetime.now(timezone.utc)
|
||||
cards_html = []
|
||||
|
||||
for city in CITIES:
|
||||
try:
|
||||
data = fetch_weather(city)
|
||||
except Exception as e:
|
||||
cards_html.append(
|
||||
f'<div class="card error"><h2>{city["name"]}</h2>'
|
||||
f'<p data-i18n="error">Erreur: {e}</p></div>'
|
||||
)
|
||||
continue
|
||||
|
||||
current = data.get("current", {})
|
||||
daily = data.get("daily", {})
|
||||
|
||||
temp = current.get("temperature_2m", "N/A")
|
||||
feels = current.get("apparent_temperature", "N/A")
|
||||
humidity = current.get("relative_humidity_2m", "N/A")
|
||||
wind = current.get("wind_speed_10m", "N/A")
|
||||
code = current.get("weather_code", 0)
|
||||
|
||||
t_max = daily.get("temperature_2m_max", [None])
|
||||
t_min = daily.get("temperature_2m_min", [None])
|
||||
precip = daily.get("precipitation_sum", [None])
|
||||
sunrise = daily.get("sunrise", [None])
|
||||
sunset = daily.get("sunset", [None])
|
||||
|
||||
t_max_val = t_max[0] if t_max else "N/A"
|
||||
t_min_val = t_min[0] if t_min else "N/A"
|
||||
precip_val = precip[0] if precip else "N/A"
|
||||
sunrise_val = sunrise[0] if sunrise else "N/A"
|
||||
sunset_val = sunset[0] if sunset else "N/A"
|
||||
|
||||
card = f"""
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2><span class="wmo-emoji" data-code="{code}"></span> {city['name']}</h2>
|
||||
<span class="country" data-city="{city['name']}"></span>
|
||||
</div>
|
||||
<div class="temp-main">{temp}°C</div>
|
||||
<div class="desc" data-wmo="{code}"></div>
|
||||
<div class="details">
|
||||
<div class="detail-item"><span class="label" data-i18n="feels">Ressenti</span><span class="value">{feels}°C</span></div>
|
||||
<div class="detail-item"><span class="label" data-i18n="humidity">Humidité</span><span class="value">{humidity}%</span></div>
|
||||
<div class="detail-item"><span class="label" data-i18n="wind">Vent</span><span class="value">{wind} km/h</span></div>
|
||||
<div class="detail-item"><span class="label" data-i18n="maxmin">Max / Min</span><span class="value">{t_max_val}° / {t_min_val}°</span></div>
|
||||
<div class="detail-item"><span class="label" data-i18n="precip">Précip.</span><span class="value">{precip_val} mm</span></div>
|
||||
<div class="detail-item"><span class="label" data-i18n="sunrise">Lever</span><span class="value">{sunrise_val}</span></div>
|
||||
<div class="detail-item"><span class="label" data-i18n="sunset">Coucher</span><span class="value">{sunset_val}</span></div>
|
||||
</div>
|
||||
</div>"""
|
||||
cards_html.append(card)
|
||||
|
||||
cards = "\n".join(cards_html)
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Météo — Chambourcy · Kuwana · Tokyo</title>
|
||||
<style>
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem 1rem;
|
||||
}}
|
||||
.lang-switch {{
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}}
|
||||
.lang-btn {{
|
||||
padding: 0.4rem 1rem;
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
background: rgba(255,255,255,0.15);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}}
|
||||
.lang-btn:hover {{ background: rgba(255,255,255,0.3); }}
|
||||
.lang-btn.active {{
|
||||
background: #fff;
|
||||
color: #764ba2;
|
||||
border-color: #fff;
|
||||
}}
|
||||
h1 {{
|
||||
color: #fff;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.3rem;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}}
|
||||
.subtitle {{
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}}
|
||||
.container {{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
max-width: 1100px;
|
||||
width: 100%;
|
||||
}}
|
||||
.card {{
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
width: 320px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: transform 0.2s;
|
||||
}}
|
||||
.card:hover {{ transform: translateY(-4px); }}
|
||||
.card.error {{ border: 2px solid #e74c3c; }}
|
||||
.card-header {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 1rem;
|
||||
}}
|
||||
.card-header h2 {{ font-size: 1.4rem; color: #333; }}
|
||||
.country {{ font-size: 0.8rem; color: #999; }}
|
||||
.temp-main {{
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin: 0.5rem 0;
|
||||
}}
|
||||
.desc {{
|
||||
text-align: center;
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}}
|
||||
.details {{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
}}
|
||||
.detail-item {{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(0,0,0,0.03);
|
||||
border-radius: 10px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
}}
|
||||
.label {{ font-size: 0.75rem; color: #999; }}
|
||||
.value {{ font-size: 1rem; color: #333; font-weight: 600; }}
|
||||
footer {{
|
||||
margin-top: 2rem;
|
||||
color: rgba(255,255,255,0.6);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}}
|
||||
footer a {{ color: rgba(255,255,255,0.8); }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="lang-switch">
|
||||
<button class="lang-btn active" data-lang="fr">FR</button>
|
||||
<button class="lang-btn" data-lang="en">EN</button>
|
||||
<button class="lang-btn" data-lang="ja">日本語</button>
|
||||
</div>
|
||||
<h1 id="heading">🌤️ Météo du jour</h1>
|
||||
<p class="subtitle" id="subtitle">Chambourcy · Kuwana · Tokyo</p>
|
||||
<div class="container">
|
||||
{cards}
|
||||
</div>
|
||||
<footer>
|
||||
<span data-i18n="data_source">Données</span>: <a href="https://open-meteo.com/">Open-Meteo</a> · <span data-i18n="updated">Mis à jour le</span> {now.strftime('%d/%m/%Y à %H:%M UTC')}
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const I18N = {I18N_JSON};
|
||||
const COUNTRIES = {COUNTRY_JSON};
|
||||
const WMO = {WMO_JSON};
|
||||
let currentLang = 'fr';
|
||||
|
||||
function applyLang(lang) {{
|
||||
currentLang = lang;
|
||||
const t = I18N[lang];
|
||||
document.title = t.title;
|
||||
document.getElementById('heading').textContent = t.heading;
|
||||
document.getElementById('subtitle').textContent = t.subtitle;
|
||||
|
||||
// Labels
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {{
|
||||
const key = el.getAttribute('data-i18n');
|
||||
if (t[key]) el.textContent = t[key];
|
||||
}});
|
||||
|
||||
// Country names
|
||||
document.querySelectorAll('[data-city]').forEach(el => {{
|
||||
const city = el.getAttribute('data-city');
|
||||
if (COUNTRIES[city]) el.textContent = COUNTRIES[city][lang];
|
||||
}});
|
||||
|
||||
// Weather descriptions + emojis
|
||||
document.querySelectorAll('[data-wmo]').forEach(el => {{
|
||||
const code = parseInt(el.getAttribute('data-wmo'));
|
||||
const w = WMO[code];
|
||||
if (w) el.textContent = w[lang];
|
||||
}});
|
||||
document.querySelectorAll('.wmo-emoji').forEach(el => {{
|
||||
const code = parseInt(el.getAttribute('data-code'));
|
||||
const w = WMO[code];
|
||||
if (w) el.textContent = w.emoji;
|
||||
}});
|
||||
|
||||
// Update active button
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||
btn.classList.toggle('active', btn.getAttribute('data-lang') === lang);
|
||||
}});
|
||||
|
||||
// Update <html lang>
|
||||
document.documentElement.lang = lang;
|
||||
}}
|
||||
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||
btn.addEventListener('click', () => applyLang(btn.getAttribute('data-lang')));
|
||||
}});
|
||||
|
||||
// Auto-detect from browser or saved preference
|
||||
const saved = localStorage.getItem('weather-lang');
|
||||
if (saved && I18N[saved]) {{
|
||||
applyLang(saved);
|
||||
}} else {{
|
||||
const browserLang = navigator.language.slice(0, 2);
|
||||
if (I18N[browserLang]) applyLang(browserLang);
|
||||
}}
|
||||
// Save preference on change
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {{
|
||||
btn.addEventListener('click', () => localStorage.setItem('weather-lang', btn.getAttribute('data-lang')));
|
||||
}});
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
return html
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
html = generate_html()
|
||||
output_path = "/home/jerome/hermes-sites/weather-example.hidrago.click/index.html"
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"Weather page generated at {output_path}")
|
||||
Reference in New Issue
Block a user