Files

154 lines
5.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Modul: Wetter-Proxy (Weather)
* Zweck: Serverseitiger Proxy für OpenWeatherMap API (API-Key nie im Frontend)
* Abhängigkeiten: express, node-fetch, dotenv
*/
import { createLogger } from '../logger.js';
import express from 'express';
const log = createLogger('Weather');
const router = express.Router();
// Cache: Daten für 30 Minuten halten
let cache = { data: null, ts: 0 };
const CACHE_TTL_MS = 30 * 60 * 1000;
// --------------------------------------------------------
// GET /api/v1/weather
// Gibt aktuelles Wetter + 5-Tage-Vorschau zurück.
// Erfordert OPENWEATHER_API_KEY + OPENWEATHER_CITY in .env
// Response: { data: { current, forecast } } | { data: null }
// --------------------------------------------------------
router.get('/', async (req, res) => {
try {
const apiKey = process.env.OPENWEATHER_API_KEY;
const city = process.env.OPENWEATHER_CITY || 'Berlin';
const units = process.env.OPENWEATHER_UNITS || 'metric';
const lang = process.env.OPENWEATHER_LANG || 'de';
// Kein API-Key → leere Antwort (Widget wird ausgeblendet)
if (!apiKey) {
return res.json({ data: null });
}
// Cache prüfen
if (cache.data && Date.now() - cache.ts < CACHE_TTL_MS) {
return res.json({ data: cache.data });
}
// Dynamischer Import für node-fetch (ESM)
const { default: fetch } = await import('node-fetch');
// Aktuelles Wetter
const currentUrl = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}`;
const currentRes = await fetch(currentUrl, { signal: AbortSignal.timeout(8000) });
if (!currentRes.ok) {
log.warn(`API error: ${currentRes.status}`);
return res.json({ data: null });
}
const currentJson = await currentRes.json();
// 5-Tage-Forecast (3h-Intervalle → aggregiert zu Tageswerten)
const forecastUrl = `https://api.openweathermap.org/data/2.5/forecast?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}&lang=${lang}&cnt=40`;
const forecastRes = await fetch(forecastUrl, { signal: AbortSignal.timeout(8000) });
let forecastDays = [];
if (forecastRes.ok) {
const forecastJson = await forecastRes.json();
const list = forecastJson.list ?? [];
// Alle Einträge nach Tag gruppieren
const dayMap = new Map(); // "YYYY-MM-DD" → { temps: [], items: [] }
for (const item of list) {
const dateStr = item.dt_txt.slice(0, 10);
if (!dayMap.has(dateStr)) {
dayMap.set(dateStr, { temps: [], items: [] });
}
const day = dayMap.get(dateStr);
day.temps.push(item.main.temp);
day.items.push(item);
}
// Heute überspringen (Forecast),
const today = new Date().toISOString().slice(0, 10);
for (const [dateStr, { temps, items }] of dayMap) {
if (dateStr === today) continue; // optional
// Mittags-Wert (12:00) für Icon/Desc nutzen, falls nicht vorhanden Fallback auf 15:00 / mitte des Tages
const noonItem =
items.find(i => i.dt_txt.includes("12:00:00")) ??
items.find(i => i.dt_txt.includes("15:00:00")) ??
items[Math.floor(items.length / 2)];
forecastDays.push({
date: dateStr,
temp_min: Math.round(Math.min(...temps)),
temp_max: Math.round(Math.max(...temps)),
icon: noonItem.weather[0]?.icon,
desc: noonItem.weather[0]?.description,
});
if (forecastDays.length >= 5) break;
}
}
const data = {
city: currentJson.name,
units,
current: {
temp: Math.round(currentJson.main.temp),
feels_like: Math.round(currentJson.main.feels_like),
humidity: currentJson.main.humidity,
icon: currentJson.weather[0]?.icon,
desc: currentJson.weather[0]?.description,
// metric/standard: m/s → km/h; imperial: already mph
wind_speed: units === 'imperial'
? Math.round(currentJson.wind?.speed ?? 0)
: Math.round((currentJson.wind?.speed ?? 0) * 3.6),
},
forecast: forecastDays,
};
cache = { data, ts: Date.now() };
res.json({ data });
} catch (err) {
log.warn('Error:', err.message);
res.json({ data: null }); // Fallback: Widget ausblenden, kein Error-Screen
}
});
// --------------------------------------------------------
// GET /api/v1/weather/icon/:code
// Proxy für OpenWeatherMap-Icons - vermeidet externe Bild-Requests
// im PWA-Standalone-Modus (CORS/CSP-Probleme auf Android Chrome).
// Erlaubte Codes: 24 alphanumerische Zeichen (z.B. "01d", "10n").
// Response: PNG-Bild mit 24h-Cache
// --------------------------------------------------------
router.get('/icon/:code', async (req, res) => {
const { code } = req.params;
if (!/^[a-zA-Z0-9]{2,4}$/.test(code)) {
return res.status(400).json({ error: 'Ungültiger Icon-Code.', code: 400 });
}
try {
const { default: fetch } = await import('node-fetch');
const url = `https://openweathermap.org/img/wn/${code}@2x.png`;
const upstream = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!upstream.ok) {
return res.status(502).json({ error: 'Icon nicht verfügbar.', code: 502 });
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('Cache-Control', 'public, max-age=86400'); // 24 Stunden
upstream.body.pipe(res);
} catch (err) {
log.warn('Icon proxy error:', err.message);
res.status(502).json({ error: 'Icon proxy failed.', code: 502 });
}
});
export default router;