modified: app.py

modified:   templates/base.html
	modified:   templates/weather.html
This commit is contained in:
simon
2026-04-22 10:38:42 +02:00
parent 164d0eb6a8
commit e59e88cafe
3 changed files with 159 additions and 25 deletions

168
app.py
View File

@@ -3,9 +3,12 @@ from flask import Flask, render_template, request, jsonify
from geopy.geocoders import Nominatim from geopy.geocoders import Nominatim
from geopy.exc import GeocoderTimedOut from geopy.exc import GeocoderTimedOut
import math import math
import traceback
import pandas as pd import pandas as pd
import datetime as _dt import datetime as _dt
import logging
from collections import deque
from threading import Lock
from time import time
from wetterdienst.provider.dwd.mosmix import DwdMosmixRequest from wetterdienst.provider.dwd.mosmix import DwdMosmixRequest
from cachetools import TTLCache from cachetools import TTLCache
@@ -14,6 +17,9 @@ from astral.sun import sun as astral_sun
import requests as _requests import requests as _requests
app = Flask(__name__) app = Flask(__name__)
app.logger.setLevel(logging.INFO)
_GEOLOCATOR = Nominatim(user_agent="skywatcher-app/1.0")
# ── Zeitzone ──────────────────────────────────────────────────────────── # ── Zeitzone ────────────────────────────────────────────────────────────
try: try:
@@ -26,6 +32,32 @@ except ImportError:
# ── Cache: Forecasts 45 Min, DWD-Warnungen 15 Min ──────────────────────────── # ── Cache: Forecasts 45 Min, DWD-Warnungen 15 Min ────────────────────────────
_forecast_cache = TTLCache(maxsize=64, ttl=2700) _forecast_cache = TTLCache(maxsize=64, ttl=2700)
_warn_cache = TTLCache(maxsize=32, ttl=900) _warn_cache = TTLCache(maxsize=32, ttl=900)
_suggest_cache = TTLCache(maxsize=256, ttl=900)
# API protection: simple in-memory rate limiting by client IP.
_suggest_rate_lock = Lock()
_suggest_rate_hits = {}
_SUGGEST_RATE_WINDOW_SECONDS = 60
_SUGGEST_RATE_MAX_REQUESTS = 30
STATE_ALIASES = {
"baden-wurttemberg": {"baden-wurttemberg", "baden wuerttemberg", "bw"},
"bayern": {"bayern", "by"},
"berlin": {"berlin", "be"},
"brandenburg": {"brandenburg", "bb"},
"bremen": {"bremen", "hb"},
"hamburg": {"hamburg", "hh"},
"hessen": {"hessen", "he"},
"mecklenburg-vorpommern": {"mecklenburg-vorpommern", "mecklenburg vorpommern", "mv"},
"niedersachsen": {"niedersachsen", "ni"},
"nordrhein-westfalen": {"nordrhein-westfalen", "nordrhein westfalen", "nrw", "nw"},
"rheinland-pfalz": {"rheinland-pfalz", "rheinland pfalz", "rp"},
"saarland": {"saarland", "sl"},
"sachsen": {"sachsen", "sn"},
"sachsen-anhalt": {"sachsen-anhalt", "sachsen anhalt", "st"},
"schleswig-holstein": {"schleswig-holstein", "schleswig holstein", "sh"},
"thuringen": {"thuringen", "thueringen", "th"},
}
# ── MOSMIX Parameter ───────────────────────────────────────────────────────── # ── MOSMIX Parameter ─────────────────────────────────────────────────────────
MOSMIX_PARAMS = [ MOSMIX_PARAMS = [
@@ -49,15 +81,78 @@ def _get_berlin():
import pytz import pytz
return pytz.timezone("Europe/Berlin") return pytz.timezone("Europe/Berlin")
def geocode_location(query): def _normalize_text(value):
geolocator = Nominatim(user_agent="skywatcher-app/1.0") if not value:
return ""
text = str(value).strip().lower()
replacements = {
"ä": "a",
"ö": "o",
"ü": "u",
"ß": "ss",
}
for src, dst in replacements.items():
text = text.replace(src, dst)
return " ".join(text.split())
def _state_tokens(state_name):
normalized = _normalize_text(state_name)
if not normalized:
return set()
for _, aliases in STATE_ALIASES.items():
if normalized in aliases:
return aliases
return {normalized}
def _warning_matches_state(state_hint, warning_state, warning_state_short):
hint_tokens = _state_tokens(state_hint)
warning_tokens = _state_tokens(warning_state)
warning_short = _normalize_text(warning_state_short)
if warning_short:
warning_tokens.add(warning_short)
return bool(hint_tokens and warning_tokens and hint_tokens.intersection(warning_tokens))
def _extract_state_from_location(loc):
try: try:
loc = geolocator.geocode(query, language="de", timeout=10) return (loc.raw or {}).get("address", {}).get("state")
except Exception:
return None
def _client_ip(req):
forwarded = req.headers.get("X-Forwarded-For", "").strip()
if forwarded:
return forwarded.split(",")[0].strip()
return req.remote_addr or "unknown"
def _is_suggest_rate_limited(client_ip):
now = time()
with _suggest_rate_lock:
hits = _suggest_rate_hits.get(client_ip)
if hits is None:
hits = deque()
_suggest_rate_hits[client_ip] = hits
cutoff = now - _SUGGEST_RATE_WINDOW_SECONDS
while hits and hits[0] < cutoff:
hits.popleft()
if len(hits) >= _SUGGEST_RATE_MAX_REQUESTS:
return True
hits.append(now)
if len(hits) == 1:
stale_ips = [ip for ip, values in _suggest_rate_hits.items() if not values or values[-1] < cutoff]
for stale_ip in stale_ips:
_suggest_rate_hits.pop(stale_ip, None)
return False
def geocode_location(query):
try:
loc = _GEOLOCATOR.geocode(query, language="de", addressdetails=True, timeout=10)
if loc: if loc:
return loc.latitude, loc.longitude, loc.address return loc.latitude, loc.longitude, loc.address, _extract_state_from_location(loc)
except GeocoderTimedOut: except GeocoderTimedOut:
pass pass
return None, None, None except Exception:
app.logger.exception("Geocoding failed for query '%s'", query)
return None, None, None, None
def haversine(lat1, lon1, lat2, lon2): def haversine(lat1, lon1, lat2, lon2):
R = 6371 R = 6371
@@ -103,8 +198,9 @@ def get_sun_times(lat, lon, date=None):
except Exception: except Exception:
return None, None, None, None return None, None, None, None
def get_dwd_warnings(lat, lon): def get_dwd_warnings(lat, lon, state_hint=None):
key = (round(lat, 1), round(lon, 1)) state_key = _normalize_text(state_hint)
key = (round(lat, 1), round(lon, 1), state_key)
if key in _warn_cache: if key in _warn_cache:
return _warn_cache[key] return _warn_cache[key]
try: try:
@@ -128,12 +224,27 @@ def get_dwd_warnings(lat, lon):
"type": w.get("event", ""), "type": w.get("event", ""),
"headline": w.get("headline", ""), "headline": w.get("headline", ""),
"description": w.get("description", ""), "description": w.get("description", ""),
"state": w.get("state", ""),
"state_short": w.get("stateShort", ""),
}) })
warnings.sort(key=lambda x: x["level"], reverse=True) filtered = []
result = warnings[:3] if state_hint:
filtered = [w for w in warnings if _warning_matches_state(state_hint, w.get("state"), w.get("state_short"))]
result_source = filtered if filtered else []
result_source.sort(key=lambda x: x["level"], reverse=True)
result = [
{
"level": w["level"],
"type": w["type"],
"headline": w["headline"],
"description": w["description"],
}
for w in result_source[:3]
]
_warn_cache[key] = result _warn_cache[key] = result
return result return result
except Exception: except Exception:
app.logger.exception("Could not load DWD warnings")
_warn_cache[key] = [] _warn_cache[key] = []
return [] return []
@@ -225,7 +336,7 @@ def get_mosmix_forecast(lat, lon, hours=72):
_forecast_cache[cache_key] = result_data _forecast_cache[cache_key] = result_data
return result_data return result_data
except Exception: except Exception:
traceback.print_exc() app.logger.exception("MOSMIX forecast loading failed")
return [], {} return [], {}
@app.route("/") @app.route("/")
@@ -239,23 +350,26 @@ def wetter():
lon_param = request.args.get("lon") lon_param = request.args.get("lon")
# Geolocation via Browser-Koordinaten # Geolocation via Browser-Koordinaten
lat, lon, display_name = None, None, None lat, lon, display_name, state_hint = None, None, None, None
if lat_param and lon_param: if lat_param and lon_param:
try: try:
lat = float(lat_param) lat = float(lat_param)
lon = float(lon_param) lon = float(lon_param)
geolocator = Nominatim(user_agent="skywatcher-app/1.0") if not (-90 <= lat <= 90 and -180 <= lon <= 180):
loc = geolocator.reverse((lat, lon), language="de", timeout=10) raise ValueError("Invalid coordinate range")
loc = _GEOLOCATOR.reverse((lat, lon), language="de", addressdetails=True, timeout=10)
display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}" display_name = loc.address if loc else f"{lat:.2f}, {lon:.2f}"
state_hint = _extract_state_from_location(loc)
if not ort or ort == "Mein Standort": if not ort or ort == "Mein Standort":
ort = display_name.split(",")[0] ort = display_name.split(",")[0]
except Exception: except Exception:
lat, lon, display_name = None, None, None app.logger.info("Invalid or unusable browser coordinates for /wetter")
lat, lon, display_name, state_hint = None, None, None, None
if lat is None: if lat is None:
if not ort: if not ort:
return render_template("index.html", error="Bitte einen Ort eingeben.") return render_template("index.html", error="Bitte einen Ort eingeben.")
lat, lon, display_name = geocode_location(ort) lat, lon, display_name, state_hint = geocode_location(ort)
if lat is None: if lat is None:
return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.') return render_template("index.html", error=f'Ort "{ort}" konnte nicht gefunden werden.')
forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=72) forecast, mosmix_station = get_mosmix_forecast(lat, lon, hours=72)
@@ -280,7 +394,7 @@ def wetter():
current = forecast[current_idx] current = forecast[current_idx]
forecast = forecast[current_idx:] forecast = forecast[current_idx:]
sunrise, sunset, dawn, dusk = get_sun_times(lat, lon) sunrise, sunset, dawn, dusk = get_sun_times(lat, lon)
warnings = get_dwd_warnings(lat, lon) warnings = get_dwd_warnings(lat, lon, state_hint=state_hint)
daily = {} daily = {}
for h in forecast: for h in forecast:
dt = h["datetime"] dt = h["datetime"]
@@ -331,12 +445,24 @@ def wetter():
@app.route("/api/suggest") @app.route("/api/suggest")
def suggest(): def suggest():
q = request.args.get("q","").strip() q = request.args.get("q","").strip()
if len(q) < 2: return jsonify([]) if len(q) < 2:
geolocator = Nominatim(user_agent="skywatcher-app/1.0") return jsonify([])
client_ip = _client_ip(request)
if _is_suggest_rate_limited(client_ip):
return jsonify({"error": "Zu viele Anfragen. Bitte kurz warten."}), 429
cache_key = _normalize_text(q)
if cache_key in _suggest_cache:
return jsonify(_suggest_cache[cache_key])
try: try:
results = geolocator.geocode(q, exactly_one=False, limit=5, language="de", addressdetails=True, timeout=5) results = _GEOLOCATOR.geocode(q, exactly_one=False, limit=5, language="de", addressdetails=True, timeout=5)
return jsonify([{"name":r.address,"lat":r.latitude,"lon":r.longitude} for r in results] if results else []) payload = [{"name": r.address, "lat": r.latitude, "lon": r.longitude} for r in results] if results else []
_suggest_cache[cache_key] = payload
return jsonify(payload)
except Exception: except Exception:
app.logger.exception("Suggest lookup failed for query '%s'", q)
return jsonify([]) return jsonify([])
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -24,7 +24,7 @@
<main>{% block content %}{% endblock %}</main> <main>{% block content %}{% endblock %}</main>
<footer class="footer"> <footer class="footer">
Wetterdaten: <a href="https://opendata.dwd.de" target="_blank">Deutscher Wetterdienst Open Data (MOSMIX)</a> Wetterdaten: <a href="https://opendata.dwd.de" target="_blank" rel="noopener noreferrer">Deutscher Wetterdienst Open Data (MOSMIX)</a>
</footer> </footer>
<script> <script>
@@ -36,8 +36,16 @@ function setupAC(input, list) {
const q = input.value.trim(); const q = input.value.trim();
if (q.length < 2) { list.hidden = true; list.innerHTML = ""; return; } if (q.length < 2) { list.hidden = true; list.innerHTML = ""; return; }
t = setTimeout(async () => { t = setTimeout(async () => {
const r = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`); let data = [];
const data = await r.json(); try {
const r = await fetch(`/api/suggest?q=${encodeURIComponent(q)}`);
if (r.ok) {
const json = await r.json();
data = Array.isArray(json) ? json : [];
}
} catch (e) {
data = [];
}
list.innerHTML = ""; list.innerHTML = "";
if (!data.length) { list.hidden = true; return; } if (!data.length) { list.hidden = true; return; }
data.forEach(item => { data.forEach(item => {

View File

@@ -192,7 +192,7 @@
</section> </section>
<p class="data-note"> <p class="data-note">
Wetterdaten: <a href="https://opendata.dwd.de" target="_blank">Deutscher Wetterdienst Open Data (MOSMIX)</a> Wetterdaten: <a href="https://opendata.dwd.de" target="_blank" rel="noopener noreferrer">Deutscher Wetterdienst Open Data (MOSMIX)</a>
</p> </p>
{% endblock %} {% endblock %}