mirror of
https://github.com/arthur-pbty/discord-weather-bot.git
synced 2026-08-01 20:28:48 +02:00
first commit
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# ─── Bot Discord ──────────────────────────────────────────────
|
||||||
|
DISCORD_TOKEN=your_discord_bot_token_here
|
||||||
|
CHANNEL_ID=your_channel_id_here
|
||||||
|
|
||||||
|
# ─── Heure d'envoi du message quotidien (Europe/Paris) ────────
|
||||||
|
SEND_HOUR=7
|
||||||
|
SEND_MINUTE=30
|
||||||
|
|
||||||
|
# ─── Localisation (lat/lon) ───────────────────────────────────
|
||||||
|
LAT=44.6310
|
||||||
|
LON=-1.0215
|
||||||
|
LOCATION_NAME=Lacanau-Océan
|
||||||
|
|
||||||
|
# ─── Options avancées ─────────────────────────────────────────
|
||||||
|
# Activer le mode debug (affiche les données brutes de l'API)
|
||||||
|
DEBUG_METEO=false
|
||||||
|
|
||||||
|
# Durée du cache en minutes (défaut : 10)
|
||||||
|
CACHE_MINUTES=10
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/.env
|
||||||
|
/node_modules
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# 🌦️ Discord Météo Bot
|
||||||
|
|
||||||
|
Bot Discord ultra-complet avec météo temps réel, graphiques et données marines. **Aucune API payante.**
|
||||||
|
|
||||||
|
## ✨ Fonctionnalités
|
||||||
|
|
||||||
|
| Fonctionnalité | Détails |
|
||||||
|
|---|---|
|
||||||
|
| `/meteo` | Rapport complet à la demande |
|
||||||
|
| ⏰ Message quotidien | Heure configurable via `.env` |
|
||||||
|
| 📊 5 graphiques | Température, vent, pluie, pression, nuages |
|
||||||
|
| 🌊 Données marines | Vagues, houle, direction (si côtier) |
|
||||||
|
| 🌙 Phase lunaire | Calcul local, illumination |
|
||||||
|
| 🌅 Astronomie | Lever/coucher soleil, durée du jour |
|
||||||
|
| ⚠️ Alertes météo | Vent fort, pluie intense, orage, brouillard |
|
||||||
|
| 📊 Comparaison J-1 | Température, pluie, vent vs hier |
|
||||||
|
| 🔧 Mode debug | Données brutes + diagnostics |
|
||||||
|
| 💾 Cache 10 min | Évite le spam API |
|
||||||
|
|
||||||
|
## 🚀 Installation
|
||||||
|
|
||||||
|
### 1. Prérequis
|
||||||
|
- **Node.js >= 18** — [télécharger](https://nodejs.org)
|
||||||
|
- Un bot Discord (token + permissions)
|
||||||
|
|
||||||
|
### 2. Cloner / télécharger
|
||||||
|
```bash
|
||||||
|
# Placer tous les fichiers dans un dossier
|
||||||
|
cd discord-meteo-bot
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configurer le bot Discord
|
||||||
|
|
||||||
|
1. Aller sur [discord.com/developers/applications](https://discord.com/developers/applications)
|
||||||
|
2. Créer une nouvelle application → **Bot** → **Reset Token** → copier le token
|
||||||
|
3. Dans **OAuth2 → URL Generator** :
|
||||||
|
- Scopes : `bot`, `applications.commands`
|
||||||
|
- Bot Permissions : `Send Messages`, `Embed Links`, `Attach Files`
|
||||||
|
4. Utiliser le lien généré pour inviter le bot sur votre serveur
|
||||||
|
|
||||||
|
### 4. Configurer le `.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Puis éditer .env avec vos valeurs
|
||||||
|
```
|
||||||
|
|
||||||
|
```env
|
||||||
|
DISCORD_TOKEN=MTUx... # Token du bot Discord
|
||||||
|
CHANNEL_ID=1234567890123456789 # ID du channel (clic droit → Copier l'ID)
|
||||||
|
|
||||||
|
SEND_HOUR=7 # Heure d'envoi du message quotidien
|
||||||
|
SEND_MINUTE=30 # Minute d'envoi
|
||||||
|
|
||||||
|
LAT=44.6310 # Latitude
|
||||||
|
LON=-1.0215 # Longitude
|
||||||
|
LOCATION_NAME=Lacanau-Océan # Nom affiché dans Discord
|
||||||
|
|
||||||
|
DEBUG_METEO=false # true pour le mode debug
|
||||||
|
CACHE_MINUTES=10 # Durée du cache API
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Activer l'ID Discord** : Paramètres Discord → Avancé → Mode développeur ✅
|
||||||
|
|
||||||
|
### 5. Lancer le bot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Production
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Développement (rechargement auto)
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📦 APIs utilisées
|
||||||
|
|
||||||
|
| API | Usage | Clé requise |
|
||||||
|
|---|---|---|
|
||||||
|
| [Open-Meteo](https://open-meteo.com/) | Météo complète (temp, vent, pluie…) | ❌ Gratuit |
|
||||||
|
| [Open-Meteo Marine](https://open-meteo.com/en/docs/marine-weather-api) | Vagues, houle | ❌ Gratuit |
|
||||||
|
| [Sunrise-Sunset.org](https://sunrise-sunset.org/api) | Lever/coucher soleil | ❌ Gratuit |
|
||||||
|
| [QuickChart.io](https://quickchart.io/) | Génération des graphiques | ❌ Gratuit |
|
||||||
|
| Phase lunaire | Calcul mathématique local | ❌ Aucune |
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
discord-meteo-bot/
|
||||||
|
├── index.js # Bot principal, cron, routing
|
||||||
|
├── commands/
|
||||||
|
│ └── meteo.js # Commande /meteo + buildEmbed + sendReport
|
||||||
|
├── services/
|
||||||
|
│ ├── weather.js # Open-Meteo API, cache, helpers
|
||||||
|
│ ├── astronomy.js # Sunrise-Sunset API, phase lunaire
|
||||||
|
│ └── charts.js # Génération graphiques via QuickChart
|
||||||
|
├── .env # Configuration (ne pas committer!)
|
||||||
|
├── .env.example # Template
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 Aperçu de l'embed
|
||||||
|
|
||||||
|
```
|
||||||
|
⛅ Partiellement nuageux — Lacanau-Océan
|
||||||
|
22.4°C · Ressenti 20.1°C · 🌞 Jour
|
||||||
|
|
||||||
|
🌡️ Températures 💨 Vent 🌧️ Précipitations
|
||||||
|
Actuelle : 22.4°C Vitesse : 28 km/h Actuelle : 0.2 mm/h
|
||||||
|
Ressentie : 20.1°C Rafales : 41 km/h Proba. : 30%
|
||||||
|
Max / Min : 26°C/14°C Dir. : OSO (255°) Pluie : 0.2 mm/h
|
||||||
|
Humidité : 72% Max : 52 km/h Total : 3.4 mm
|
||||||
|
|
||||||
|
🔵 Atmosphère ☁️ Nuages 👁️ Visibilité
|
||||||
|
Pression : 1012 hPa ↓ Total : 45% 18.0 km
|
||||||
|
UV Index : 6 — 🟠 Élevé Bas : 30% 👍 Bonne
|
||||||
|
UV max : 8 Moyen : 10%
|
||||||
|
Haut : 5%
|
||||||
|
|
||||||
|
🌅 Astronomie
|
||||||
|
🌅 Lever : 07:24 🌇 Coucher : 20:48 ☀️ Zénith : 14:06 ⏱️ Durée : 13h24min
|
||||||
|
|
||||||
|
🌙 Phase lunaire 🌊 Données marines
|
||||||
|
🌒 Croissant montant Hauteur : 1.4 m
|
||||||
|
Illumination : 38% Période : 8.5 s
|
||||||
|
Jour J+4.3 Dir. : NO (325°)
|
||||||
|
|
||||||
|
📊 Comparaison avec hier
|
||||||
|
Temp max : +1.8°C ↑ Pluie : -2.1 mm ↓ Vent max : +8 km/h ↑
|
||||||
|
```
|
||||||
|
|
||||||
|
Suivi des **5 graphiques** (image séparée) :
|
||||||
|
- 🌡️ Températures sur 24h
|
||||||
|
- 💨 Vent sur 24h (vitesse + rafales)
|
||||||
|
- 🌧️ Précipitations sur 24h (mm + probabilité)
|
||||||
|
- 🔵 Pression atmosphérique sur 24h
|
||||||
|
- ☁️ Couverture nuageuse sur 24h (total + bas/moyen/haut)
|
||||||
|
|
||||||
|
## ⚙️ Mode Debug
|
||||||
|
|
||||||
|
Mettre `DEBUG_METEO=true` dans le `.env` pour ajouter un champ debug à l'embed :
|
||||||
|
- Heure de mise en cache
|
||||||
|
- Index horaires courants
|
||||||
|
- Source des données astronomiques
|
||||||
|
- Nombre de graphiques générés
|
||||||
|
|
||||||
|
## 🔧 Personnalisation rapide
|
||||||
|
|
||||||
|
**Changer la fréquence du cache :**
|
||||||
|
```env
|
||||||
|
CACHE_MINUTES=15
|
||||||
|
```
|
||||||
|
|
||||||
|
**Forcer deux messages par jour :**
|
||||||
|
Dupliquer le bloc `cron.schedule()` dans `index.js` avec un deuxième horaire.
|
||||||
|
|
||||||
|
**Ajouter une autre localisation :**
|
||||||
|
Créer un second `.env` ou passer les coordonnées en argument.
|
||||||
|
|
||||||
|
## 🐛 Dépannage
|
||||||
|
|
||||||
|
| Problème | Solution |
|
||||||
|
|---|---|
|
||||||
|
| `Missing Access` | Vérifier les permissions du bot dans le channel |
|
||||||
|
| Commandes non visibles | Les commandes globales prennent jusqu'à 1h. Réinviter le bot. |
|
||||||
|
| Graphiques manquants | QuickChart peut être temporairement indisponible — réessayez |
|
||||||
|
| Marine non disponible | Normal pour les localisations éloignées des côtes |
|
||||||
|
| `Unknown Channel` | Vérifier le CHANNEL_ID dans .env |
|
||||||
|
|
||||||
|
## 📝 Dépendances
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"discord.js": "^14.14.1",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"dotenv": "^16.4.5"
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } = require('discord.js');
|
||||||
|
const {
|
||||||
|
fetchWeatherData, getCurrentHourIndex, getNextHoursWindow, getTodayDailyIndex,
|
||||||
|
celsiusToKelvin, getWindDirection, getWeatherDescription, getEmbedColor,
|
||||||
|
getPressureTendency, getVisibilityLabel, getUvLabel, checkAlerts, knotsFromKmh,
|
||||||
|
} = require('../services/weather');
|
||||||
|
const { fetchAstronomyData } = require('../services/astronomy');
|
||||||
|
const { generateAllCharts } = require('../services/charts');
|
||||||
|
const { generateCompositeImage } = require('../services/composite');
|
||||||
|
|
||||||
|
// ─── Embed principal ──────────────────────────────────────────────────────────
|
||||||
|
function buildWeatherEmbed(weather, marine, astro, charts) {
|
||||||
|
const c = weather.current;
|
||||||
|
const h = weather.hourly;
|
||||||
|
const d = weather.daily;
|
||||||
|
|
||||||
|
const todayIdx = getTodayDailyIndex(d.time);
|
||||||
|
const hourIdx = getCurrentHourIndex(h.time);
|
||||||
|
const location = process.env.LOCATION_NAME || `${process.env.LAT}°N ${process.env.LON}°E`;
|
||||||
|
const pressTrend = getPressureTendency(h.pressure_msl, hourIdx);
|
||||||
|
const isDebug = process.env.DEBUG_METEO === 'true';
|
||||||
|
|
||||||
|
// Comparaison J-1
|
||||||
|
const yIdx = todayIdx - 1;
|
||||||
|
const hasY = yIdx >= 0 && d.time[yIdx];
|
||||||
|
const sign = n => (+n > 0 ? `+${n}` : `${n}`);
|
||||||
|
const dTmax = hasY ? (d.temperature_2m_max[todayIdx] - d.temperature_2m_max[yIdx]).toFixed(1) : null;
|
||||||
|
const dRain = hasY ? (d.precipitation_sum[todayIdx] - d.precipitation_sum[yIdx]).toFixed(1) : null;
|
||||||
|
const dWind = hasY ? (d.wind_speed_10m_max[todayIdx] - d.wind_speed_10m_max[yIdx]).toFixed(1) : null;
|
||||||
|
|
||||||
|
const alerts = checkAlerts(c, d);
|
||||||
|
const color = getEmbedColor(c.weather_code);
|
||||||
|
const upd = new Date().toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit', second:'2-digit', timeZone:'Europe/Paris' });
|
||||||
|
|
||||||
|
const embed = new EmbedBuilder()
|
||||||
|
.setTitle(`${getWeatherDescription(c.weather_code)} — ${location}`)
|
||||||
|
.setColor(color)
|
||||||
|
.setDescription(
|
||||||
|
`**${c.temperature_2m?.toFixed(1)}°C** · Ressenti **${c.apparent_temperature?.toFixed(1)}°C** · ${c.is_day ? '🌞 Jour' : '🌙 Nuit'}`
|
||||||
|
)
|
||||||
|
// ── Températures ──────────────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '🌡️ Températures',
|
||||||
|
value: [
|
||||||
|
`Actuelle : **${c.temperature_2m?.toFixed(1)}°C** (${celsiusToKelvin(c.temperature_2m)} K)`,
|
||||||
|
`Ressentie : **${c.apparent_temperature?.toFixed(1)}°C**`,
|
||||||
|
`Max / Min : **${d.temperature_2m_max[todayIdx]}°C** / **${d.temperature_2m_min[todayIdx]}°C**`,
|
||||||
|
`Humidité : **${c.relative_humidity_2m}%**`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
})
|
||||||
|
// ── Vent (noeuds + km/h) ──────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '💨 Vent',
|
||||||
|
value: [
|
||||||
|
`Vitesse : **${knotsFromKmh(c.wind_speed_10m)} kt** (${c.wind_speed_10m?.toFixed(0)} km/h)`,
|
||||||
|
`Rafales : **${knotsFromKmh(c.wind_gusts_10m)} kt** (${c.wind_gusts_10m?.toFixed(0)} km/h)`,
|
||||||
|
`Direction : **${getWindDirection(c.wind_direction_10m)}** (${c.wind_direction_10m?.toFixed(0)}°)`,
|
||||||
|
`Max jour : **${knotsFromKmh(d.wind_speed_10m_max[todayIdx])} kt** (${d.wind_speed_10m_max[todayIdx]} km/h)`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
})
|
||||||
|
// ── Précipitations ────────────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '🌧️ Précipitations',
|
||||||
|
value: [
|
||||||
|
`Actuelle : **${c.precipitation?.toFixed(1)} mm/h**`,
|
||||||
|
`Probabilité: **${h.precipitation_probability[hourIdx] ?? 'N/A'}%**`,
|
||||||
|
`Total jour : **${d.precipitation_sum[todayIdx]?.toFixed(1)} mm**`,
|
||||||
|
`Neige : ${c.snowfall?.toFixed(1)} cm/h`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
})
|
||||||
|
// ── Atmosphère ────────────────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '🔵 Atmosphère',
|
||||||
|
value: [
|
||||||
|
`Pression : **${c.pressure_msl?.toFixed(1)} hPa** ${pressTrend}`,
|
||||||
|
`UV actuel : **${c.uv_index?.toFixed(1)}** — ${getUvLabel(c.uv_index)}`,
|
||||||
|
`UV max j. : **${d.uv_index_max[todayIdx]}**`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
})
|
||||||
|
// ── Nuages ────────────────────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '☁️ Nuages',
|
||||||
|
value: [
|
||||||
|
`Total : **${c.cloud_cover}%**`,
|
||||||
|
`Bas : ${h.cloud_cover_low[hourIdx]}%`,
|
||||||
|
`Moyen : ${h.cloud_cover_mid[hourIdx]}%`,
|
||||||
|
`Haut : ${h.cloud_cover_high[hourIdx]}%`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
})
|
||||||
|
// ── Visibilité ────────────────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '👁️ Visibilité',
|
||||||
|
value: [
|
||||||
|
`**${(c.visibility / 1000).toFixed(1)} km**`,
|
||||||
|
getVisibilityLabel(c.visibility),
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
})
|
||||||
|
// ── Astronomie ────────────────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '🌅 Astronomie',
|
||||||
|
value: [
|
||||||
|
`🌅 **${astro.sunrise}** · 🌇 **${astro.sunset}** · ☀️ Zénith **${astro.solar_noon}** · ⏱️ **${astro.day_length}**`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: false,
|
||||||
|
})
|
||||||
|
// ── Lune ──────────────────────────────────────────────────────────────────
|
||||||
|
.addFields({
|
||||||
|
name: '🌙 Phase lunaire',
|
||||||
|
value: [
|
||||||
|
`${astro.moon.phaseEmoji} **${astro.moon.phaseName}**`,
|
||||||
|
`Illumination : **${astro.moon.illumination}%** · J+${astro.moon.daysInCycle}`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Marine ────────────────────────────────────────────────────────────────
|
||||||
|
if (marine) {
|
||||||
|
const mh = marine.hourly;
|
||||||
|
const { start } = getNextHoursWindow(mh.time);
|
||||||
|
const wH = mh.wave_height?.[start], wP = mh.wave_period?.[start];
|
||||||
|
const wD = mh.wave_direction?.[start], sH = mh.swell_wave_height?.[start];
|
||||||
|
if (wH != null) {
|
||||||
|
embed.addFields({
|
||||||
|
name: '🌊 Marine',
|
||||||
|
value: [
|
||||||
|
`Vagues : **${wH?.toFixed(2)} m** · Période : **${wP?.toFixed(1)} s**`,
|
||||||
|
`Direction : **${getWindDirection(wD)}** (${wD?.toFixed(0)}°)${sH != null ? ` · Houle : **${sH?.toFixed(2)} m**` : ''}`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Comparaison J-1 ───────────────────────────────────────────────────────
|
||||||
|
if (hasY && dTmax !== null) {
|
||||||
|
embed.addFields({
|
||||||
|
name: '📊 Vs hier',
|
||||||
|
value: `Tmax ${sign(dTmax)}°C ${+dTmax>0?'↑':'↓'} · Pluie ${sign(dRain)} mm ${+dRain>0?'↑':'↓'} · Vent max ${sign(dWind)} km/h ${+dWind>0?'↑':'↓'}`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alertes ───────────────────────────────────────────────────────────────
|
||||||
|
if (alerts.length > 0) {
|
||||||
|
embed.addFields({
|
||||||
|
name: '⚠️ Alertes',
|
||||||
|
value: alerts.map(a => `${a.lvl} ${a.msg}`).join('\n'),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Debug ─────────────────────────────────────────────────────────────────
|
||||||
|
if (isDebug) {
|
||||||
|
embed.addFields({
|
||||||
|
name: '🔧 Debug',
|
||||||
|
value: [
|
||||||
|
`Cache : ${weather.fetchedAt?.toLocaleTimeString('fr-FR', { timeZone:'Europe/Paris' })}`,
|
||||||
|
`hourIdx : ${hourIdx} | todayIdx : ${todayIdx}`,
|
||||||
|
`Astro src : ${astro.source}`,
|
||||||
|
`Charts OK : ${Object.keys(charts).filter(k => Buffer.isBuffer(charts[k])).length}/8`,
|
||||||
|
].join('\n'),
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (charts.errors?.length) {
|
||||||
|
embed.addFields({ name: '⚙️ Graphiques partiels', value: `${charts.errors.length} erreur(s)`, inline: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
embed.setFooter({ text: `📡 Open-Meteo · Sunrise-Sunset.org · QuickChart.io | 🕐 ${upd}` });
|
||||||
|
return embed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Envoi du rapport complet ─────────────────────────────────────────────────
|
||||||
|
async function sendWeatherReport(target, isInteraction = false) {
|
||||||
|
const { weather, marine, fetchedAt } = await fetchWeatherData();
|
||||||
|
const astro = await fetchAstronomyData(process.env.LAT, process.env.LON, weather.daily);
|
||||||
|
const charts = await generateAllCharts(weather.hourly);
|
||||||
|
|
||||||
|
const embed = buildWeatherEmbed({ ...weather, fetchedAt }, marine, astro, charts);
|
||||||
|
|
||||||
|
const chartDefs = [
|
||||||
|
{ key:'temperature', name:'temperature.png' },
|
||||||
|
{ key:'wind', name:'wind.png' },
|
||||||
|
{ key:'rain', name:'precipitation.png'},
|
||||||
|
{ key:'pressure', name:'pressure.png' },
|
||||||
|
{ key:'clouds', name:'clouds.png' },
|
||||||
|
{ key:'uv', name:'uv.png' },
|
||||||
|
{ key:'solar', name:'solar.png' },
|
||||||
|
{ key:'tide', name:'tide.png' },
|
||||||
|
];
|
||||||
|
const files = chartDefs
|
||||||
|
.filter(d => Buffer.isBuffer(charts[d.key]))
|
||||||
|
.map(d => new AttachmentBuilder(charts[d.key], { name: d.name }));
|
||||||
|
|
||||||
|
const send = o => isInteraction ? target.editReply(o) : target.send(o);
|
||||||
|
const followUp = o => isInteraction ? target.followUp(o) : target.send(o);
|
||||||
|
|
||||||
|
await send({ embeds: [embed] });
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
await followUp({ content: `📊 **Graphiques sur 24h** (${files.length}/8)`, files });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Image composite récapitulative ────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
const compositeBuffer = await generateCompositeImage({ weather: { weather, fetchedAt }, marine, astro, charts });
|
||||||
|
await followUp({
|
||||||
|
content: '🖼️ **Rapport récapitulatif — image partageable**',
|
||||||
|
files: [new AttachmentBuilder(compositeBuffer, { name: 'meteo_recap.png' })],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[composite] Génération échouée :', err.message);
|
||||||
|
// Pas d'erreur fatale si le composite rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Commande slash ───────────────────────────────────────────────────────────
|
||||||
|
module.exports = {
|
||||||
|
data: new SlashCommandBuilder()
|
||||||
|
.setName('meteo')
|
||||||
|
.setDescription('Météo ultra-détaillée avec graphiques et image récapitulative'),
|
||||||
|
|
||||||
|
async execute(interaction) {
|
||||||
|
await interaction.deferReply();
|
||||||
|
try {
|
||||||
|
await sendWeatherReport(interaction, true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[/meteo]', err);
|
||||||
|
const msg = `❌ **Erreur**\n\`\`\`${err.message}\`\`\``;
|
||||||
|
await (interaction.deferred ? interaction.editReply({ content: msg }) : interaction.reply({ content: msg, ephemeral: true }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
sendWeatherReport,
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const { Client, GatewayIntentBits, Collection, REST, Routes } = require('discord.js');
|
||||||
|
const cron = require('node-cron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// ─── Validation des variables d'environnement ─────────────────────────────────
|
||||||
|
const REQUIRED_ENV = ['DISCORD_TOKEN', 'CHANNEL_ID', 'LAT', 'LON'];
|
||||||
|
for (const key of REQUIRED_ENV) {
|
||||||
|
if (!process.env[key]) {
|
||||||
|
console.error(`❌ Variable d'environnement manquante : ${key}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Client Discord ───────────────────────────────────────────────────────────
|
||||||
|
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||||
|
client.commands = new Collection();
|
||||||
|
|
||||||
|
// ─── Chargement des commandes ─────────────────────────────────────────────────
|
||||||
|
const commandsDir = path.join(__dirname, 'commands');
|
||||||
|
for (const file of fs.readdirSync(commandsDir).filter(f => f.endsWith('.js'))) {
|
||||||
|
const cmd = require(path.join(commandsDir, file));
|
||||||
|
if (!cmd.data || !cmd.execute) {
|
||||||
|
console.warn(`⚠️ Commande ignorée (structure invalide) : ${file}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
client.commands.set(cmd.data.name, cmd);
|
||||||
|
console.log(`✅ Commande chargée : /${cmd.data.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prêt ─────────────────────────────────────────────────────────────────────
|
||||||
|
client.once('ready', async () => {
|
||||||
|
console.log(`\n🤖 Connecté en tant que ${client.user.tag}`);
|
||||||
|
console.log(`📍 Localisation : ${process.env.LOCATION_NAME || `${process.env.LAT}°, ${process.env.LON}°`}`);
|
||||||
|
console.log(`⏰ Message quotidien : ${process.env.SEND_HOUR}h${String(process.env.SEND_MINUTE || 0).padStart(2,'0')}\n`);
|
||||||
|
|
||||||
|
// ── Enregistrement global des commandes slash ──────────────────────────────
|
||||||
|
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN);
|
||||||
|
const payload = [...client.commands.values()].map(c => c.data.toJSON());
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('📡 Enregistrement des commandes slash (global)…');
|
||||||
|
await rest.put(Routes.applicationCommands(client.user.id), { body: payload });
|
||||||
|
console.log(`✅ ${payload.length} commande(s) enregistrée(s)\n`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Erreur enregistrement commandes :', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cron : message météo quotidien ────────────────────────────────────────
|
||||||
|
const hour = parseInt(process.env.SEND_HOUR, 10) || 7;
|
||||||
|
const minute = parseInt(process.env.SEND_MINUTE, 10) || 30;
|
||||||
|
const cronExpr = `${minute} ${hour} * * *`;
|
||||||
|
|
||||||
|
cron.schedule(cronExpr, async () => {
|
||||||
|
console.log(`\n⏰ [${new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })}] Envoi du rapport météo quotidien…`);
|
||||||
|
|
||||||
|
// Invalider le cache pour forcer un rafraîchissement
|
||||||
|
require('./services/weather').invalidateCache();
|
||||||
|
require('./services/astronomy').invalidateAstroCache();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const channel = await client.channels.fetch(process.env.CHANNEL_ID);
|
||||||
|
if (!channel?.isTextBased()) {
|
||||||
|
console.error('❌ Channel introuvable ou non textuel :', process.env.CHANNEL_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { sendWeatherReport } = require('./commands/meteo');
|
||||||
|
await sendWeatherReport(channel, false);
|
||||||
|
console.log('✅ Rapport météo quotidien envoyé.');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Erreur envoi quotidien :', err.message);
|
||||||
|
}
|
||||||
|
}, { timezone: 'Europe/Paris' });
|
||||||
|
|
||||||
|
console.log(`⏰ Cron programmé : "${cronExpr}" (Europe/Paris)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Gestion des interactions ─────────────────────────────────────────────────
|
||||||
|
client.on('interactionCreate', async interaction => {
|
||||||
|
if (!interaction.isChatInputCommand()) return;
|
||||||
|
|
||||||
|
const command = client.commands.get(interaction.commandName);
|
||||||
|
if (!command) {
|
||||||
|
console.warn(`⚠️ Commande inconnue reçue : ${interaction.commandName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await command.execute(interaction);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Erreur commande /${interaction.commandName} :`, err);
|
||||||
|
const errMsg = { content: `❌ Une erreur inattendue est survenue.`, ephemeral: true };
|
||||||
|
if (interaction.replied || interaction.deferred) {
|
||||||
|
await interaction.followUp(errMsg).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await interaction.reply(errMsg).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Erreurs non gérées ───────────────────────────────────────────────────────
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
console.error('⚠️ UnhandledRejection :', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (err) => {
|
||||||
|
console.error('💥 UncaughtException :', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Connexion ────────────────────────────────────────────────────────────────
|
||||||
|
client.login(process.env.DISCORD_TOKEN).catch(err => {
|
||||||
|
console.error('❌ Connexion Discord échouée :', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Generated
+945
@@ -0,0 +1,945 @@
|
|||||||
|
{
|
||||||
|
"name": "discord-meteo-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "discord-meteo-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.53",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"discord.js": "^14.14.1",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/builders": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/formatters": "^0.6.2",
|
||||||
|
"@discordjs/util": "^1.2.0",
|
||||||
|
"@sapphire/shapeshift": "^4.0.0",
|
||||||
|
"discord-api-types": "^0.38.40",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"ts-mixer": "^6.0.4",
|
||||||
|
"tslib": "^2.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/collection": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/formatters": {
|
||||||
|
"version": "0.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
|
||||||
|
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"discord-api-types": "^0.38.33"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/rest": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/collection": "^2.1.1",
|
||||||
|
"@discordjs/util": "^1.2.0",
|
||||||
|
"@sapphire/async-queue": "^1.5.3",
|
||||||
|
"@sapphire/snowflake": "^3.5.5",
|
||||||
|
"@vladfrangu/async_event_emitter": "^2.4.6",
|
||||||
|
"discord-api-types": "^0.38.40",
|
||||||
|
"magic-bytes.js": "^1.13.0",
|
||||||
|
"tslib": "^2.6.3",
|
||||||
|
"undici": "6.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": {
|
||||||
|
"version": "3.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
|
||||||
|
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v14.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/util": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"discord-api-types": "^0.38.33"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/ws": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/collection": "^2.1.0",
|
||||||
|
"@discordjs/rest": "^2.5.1",
|
||||||
|
"@discordjs/util": "^1.1.0",
|
||||||
|
"@sapphire/async-queue": "^1.5.2",
|
||||||
|
"@types/ws": "^8.5.10",
|
||||||
|
"@vladfrangu/async_event_emitter": "^2.2.4",
|
||||||
|
"discord-api-types": "^0.38.1",
|
||||||
|
"tslib": "^2.6.2",
|
||||||
|
"ws": "^8.17.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.100",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
|
||||||
|
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sapphire/async-queue": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v14.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sapphire/shapeshift": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sapphire/snowflake": {
|
||||||
|
"version": "3.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
|
||||||
|
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v14.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
|
||||||
|
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vladfrangu/async_event_emitter": {
|
||||||
|
"version": "2.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
|
||||||
|
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v14.0.0",
|
||||||
|
"npm": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/agent-base": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.16.0",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"https-proxy-agent": "^5.0.1",
|
||||||
|
"proxy-from-env": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/discord-api-types": {
|
||||||
|
"version": "0.38.49",
|
||||||
|
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.49.tgz",
|
||||||
|
"integrity": "sha512-XnqcWmnFZFAE8ZM8SHAw9DIV8D3Or00rMQ8iQLotrEA2PmXhl+ykaf6L6q4l474hrSUH1JaYcv+iOMRWp2p6Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"scripts/actions/documentation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/discord.js": {
|
||||||
|
"version": "14.26.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.4.tgz",
|
||||||
|
"integrity": "sha512-4oBp8tc6Kf8IDBwAHhbsMaAqx1b5fob9SNasZT7V6yyyUydoO5i5fGuX7TmvRtR+q/WgKRnRViRoAWnG7fNyvA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@discordjs/builders": "^1.14.1",
|
||||||
|
"@discordjs/collection": "1.5.3",
|
||||||
|
"@discordjs/formatters": "^0.6.2",
|
||||||
|
"@discordjs/rest": "^2.6.1",
|
||||||
|
"@discordjs/util": "^1.2.0",
|
||||||
|
"@discordjs/ws": "^1.2.3",
|
||||||
|
"@sapphire/snowflake": "3.5.3",
|
||||||
|
"discord-api-types": "^0.38.40",
|
||||||
|
"fast-deep-equal": "3.1.3",
|
||||||
|
"lodash.snakecase": "4.1.1",
|
||||||
|
"magic-bytes.js": "^1.13.0",
|
||||||
|
"tslib": "^2.6.3",
|
||||||
|
"undici": "6.24.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/discordjs/discord.js?sponsor"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "16.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
|
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.4",
|
||||||
|
"mime-types": "^2.1.35"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/https-proxy-agent": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"agent-base": "6",
|
||||||
|
"debug": "4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.snakecase": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/magic-bytes.js": {
|
||||||
|
"version": "1.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz",
|
||||||
|
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/node-cron": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"uuid": "8.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ts-mixer": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "6.24.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
|
||||||
|
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "8.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||||
|
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||||
|
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "discord-meteo-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Bot Discord météo ultra-détaillé avec graphiques — Open-Meteo API",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node index.js",
|
||||||
|
"dev": "node --watch index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.53",
|
||||||
|
"axios": "^1.6.8",
|
||||||
|
"discord.js": "^14.14.1",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"node-cron": "^3.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// ─── Cache journalier ─────────────────────────────────────────────────────────
|
||||||
|
let astroCache = { data: null, date: null };
|
||||||
|
|
||||||
|
// ─── Calcul de la phase lunaire (algorithme Julian Date) ─────────────────────
|
||||||
|
function getMoonPhase(date = new Date()) {
|
||||||
|
// Conversion en Jour Julien (JD)
|
||||||
|
let y = date.getFullYear();
|
||||||
|
let m = date.getMonth() + 1;
|
||||||
|
const d = date.getDate();
|
||||||
|
|
||||||
|
if (m <= 2) { y -= 1; m += 12; }
|
||||||
|
const A = Math.floor(y / 100);
|
||||||
|
const B = 2 - A + Math.floor(A / 4);
|
||||||
|
const jd = Math.floor(365.25 * (y + 4716))
|
||||||
|
+ Math.floor(30.6001 * (m + 1))
|
||||||
|
+ d + B - 1524.5;
|
||||||
|
|
||||||
|
// Nouvelle lune de référence : 6 janvier 2000 à 18h14 UTC → JD 2451549.759
|
||||||
|
const REF_NEW_MOON = 2451549.759;
|
||||||
|
const SYNODIC_PERIOD = 29.53058867; // jours
|
||||||
|
|
||||||
|
let daysSinceNew = (jd - REF_NEW_MOON) % SYNODIC_PERIOD;
|
||||||
|
if (daysSinceNew < 0) daysSinceNew += SYNODIC_PERIOD;
|
||||||
|
|
||||||
|
const fraction = daysSinceNew / SYNODIC_PERIOD;
|
||||||
|
|
||||||
|
// Illumination (0–100 %)
|
||||||
|
const illumination = Math.round((1 - Math.cos(2 * Math.PI * fraction)) / 2 * 100);
|
||||||
|
|
||||||
|
// Nom & emoji de la phase
|
||||||
|
let phaseName, phaseEmoji;
|
||||||
|
if (fraction < 0.0625) { phaseName = 'Nouvelle lune'; phaseEmoji = '🌑'; }
|
||||||
|
else if (fraction < 0.1875) { phaseName = 'Croissant montant'; phaseEmoji = '🌒'; }
|
||||||
|
else if (fraction < 0.3125) { phaseName = 'Premier quartier'; phaseEmoji = '🌓'; }
|
||||||
|
else if (fraction < 0.4375) { phaseName = 'Gibbeuse croissante'; phaseEmoji = '🌔'; }
|
||||||
|
else if (fraction < 0.5625) { phaseName = 'Pleine lune'; phaseEmoji = '🌕'; }
|
||||||
|
else if (fraction < 0.6875) { phaseName = 'Gibbeuse décroissante'; phaseEmoji = '🌖'; }
|
||||||
|
else if (fraction < 0.8125) { phaseName = 'Dernier quartier'; phaseEmoji = '🌗'; }
|
||||||
|
else if (fraction < 0.9375) { phaseName = 'Croissant décroissant'; phaseEmoji = '🌘'; }
|
||||||
|
else { phaseName = 'Nouvelle lune'; phaseEmoji = '🌑'; }
|
||||||
|
|
||||||
|
return { phaseName, phaseEmoji, illumination, daysInCycle: daysSinceNew.toFixed(1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatage d'une heure UTC ISO → heure Paris HH:MM ──────────────────────
|
||||||
|
function fmtTime(isoString) {
|
||||||
|
if (!isoString) return 'N/A';
|
||||||
|
return new Date(isoString).toLocaleTimeString('fr-FR', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Europe/Paris',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Durée en secondes → "Xh YYmin" ─────────────────────────────────────────
|
||||||
|
function fmtDuration(seconds) {
|
||||||
|
if (!seconds) return 'N/A';
|
||||||
|
const h = Math.floor(seconds / 3600);
|
||||||
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
|
return `${h}h ${String(m).padStart(2, '0')}min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fetch astronomie (Sunrise-Sunset API + fallback Open-Meteo) ──────────────
|
||||||
|
async function fetchAstronomyData(lat, lon, fallbackDaily = null) {
|
||||||
|
const todayStr = new Date().toLocaleDateString('sv', { timeZone: 'Europe/Paris' });
|
||||||
|
|
||||||
|
if (astroCache.data && astroCache.date === todayStr) {
|
||||||
|
return astroCache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moon = getMoonPhase(new Date());
|
||||||
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.get('https://api.sunrise-sunset.org/json', {
|
||||||
|
params: { lat, lng: lon, date: todayStr, formatted: 0 },
|
||||||
|
timeout: 6000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const r = res.data.results;
|
||||||
|
result = {
|
||||||
|
sunrise: fmtTime(r.sunrise),
|
||||||
|
sunset: fmtTime(r.sunset),
|
||||||
|
solar_noon: fmtTime(r.solar_noon),
|
||||||
|
day_length: fmtDuration(r.day_length),
|
||||||
|
moon,
|
||||||
|
source: 'Sunrise-Sunset.org',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[astro] Sunrise-Sunset API indisponible (${err.message}), fallback Open-Meteo`);
|
||||||
|
|
||||||
|
// Fallback : Open-Meteo daily contient sunrise/sunset (en heure locale ISO)
|
||||||
|
if (fallbackDaily) {
|
||||||
|
const todayIdx = fallbackDaily.time.findIndex(d => d === todayStr);
|
||||||
|
const i = todayIdx >= 0 ? todayIdx : 0;
|
||||||
|
const sr = fallbackDaily.sunrise?.[i];
|
||||||
|
const ss = fallbackDaily.sunset?.[i];
|
||||||
|
|
||||||
|
// Open-Meteo renvoie "2024-06-10T06:24" (heure locale), pas UTC
|
||||||
|
const fmtLocal = (s) => s ? s.substring(11, 16) : 'N/A';
|
||||||
|
|
||||||
|
let dayLengthSec = null;
|
||||||
|
if (sr && ss) {
|
||||||
|
const [sh, sm] = fmtLocal(sr).split(':').map(Number);
|
||||||
|
const [eh, em] = fmtLocal(ss).split(':').map(Number);
|
||||||
|
dayLengthSec = (eh * 60 + em - sh * 60 - sm) * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
sunrise: fmtLocal(sr),
|
||||||
|
sunset: fmtLocal(ss),
|
||||||
|
solar_noon: 'N/A',
|
||||||
|
day_length: fmtDuration(dayLengthSec),
|
||||||
|
moon,
|
||||||
|
source: 'Open-Meteo (fallback)',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
sunrise: 'N/A', sunset: 'N/A', solar_noon: 'N/A', day_length: 'N/A',
|
||||||
|
moon,
|
||||||
|
source: 'Indisponible',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
astroCache = { data: result, date: todayStr };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Invalidation du cache astronomie ────────────────────────────────────────
|
||||||
|
function invalidateAstroCache() {
|
||||||
|
astroCache = { data: null, date: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Calcul de l'élévation solaire (algorithme NOAA simplifié) ───────────────
|
||||||
|
function getSolarElevation(lat, lon, dateUTC) {
|
||||||
|
const toRad = d => d * Math.PI / 180;
|
||||||
|
const toDeg = r => r * 180 / Math.PI;
|
||||||
|
|
||||||
|
const y = dateUTC.getUTCFullYear();
|
||||||
|
const m = dateUTC.getUTCMonth() + 1;
|
||||||
|
const dd = dateUTC.getUTCDate();
|
||||||
|
const hh = dateUTC.getUTCHours() + dateUTC.getUTCMinutes() / 60;
|
||||||
|
|
||||||
|
// Julian Day
|
||||||
|
let yj = y, mj = m;
|
||||||
|
if (mj <= 2) { yj -= 1; mj += 12; }
|
||||||
|
const A = Math.floor(yj / 100);
|
||||||
|
const B = 2 - A + Math.floor(A / 4);
|
||||||
|
const JD = Math.floor(365.25 * (yj + 4716)) + Math.floor(30.6001 * (mj + 1)) + dd + B - 1524.5 + hh / 24;
|
||||||
|
const n = JD - 2451545.0; // jours depuis J2000
|
||||||
|
|
||||||
|
// Longitude du Soleil & anomalie moyenne
|
||||||
|
const L = ((280.460 + 0.9856474 * n) % 360 + 360) % 360;
|
||||||
|
const g = toRad(((357.528 + 0.9856003 * n) % 360 + 360) % 360);
|
||||||
|
|
||||||
|
// Longitude écliptique
|
||||||
|
const lambda = toRad(L + 1.915 * Math.sin(g) + 0.020 * Math.sin(2 * g));
|
||||||
|
|
||||||
|
// Obliquité de l'écliptique
|
||||||
|
const eps = toRad(23.439 - 0.0000004 * n);
|
||||||
|
|
||||||
|
// Déclinaison & ascension droite
|
||||||
|
const sinDec = Math.sin(eps) * Math.sin(lambda);
|
||||||
|
const dec = Math.asin(sinDec);
|
||||||
|
const RA_h = toDeg(Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda))) / 15;
|
||||||
|
|
||||||
|
// Temps sidéral de Greenwich (heures)
|
||||||
|
const GMST = ((6.697375 + 0.0657098242 * n + hh) % 24 + 24) % 24;
|
||||||
|
|
||||||
|
// Angle horaire local
|
||||||
|
const LST = (GMST + lon / 15 + 24) % 24;
|
||||||
|
const H = toRad((LST - RA_h) * 15);
|
||||||
|
|
||||||
|
// Altitude (élévation)
|
||||||
|
const latR = toRad(parseFloat(lat));
|
||||||
|
const sinAlt = Math.sin(latR) * Math.sin(dec) + Math.cos(latR) * Math.cos(dec) * Math.cos(H);
|
||||||
|
return toDeg(Math.asin(Math.max(-1, Math.min(1, sinAlt))));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tableau 25 valeurs horaires (élévation, peut être négatif = nuit)
|
||||||
|
function getSolarElevationForDay(lat, lon, startTime, count = 25) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const t = new Date(startTime.getTime() + i * 3600_000);
|
||||||
|
out.push(parseFloat(getSolarElevation(lat, lon, t).toFixed(1)));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Marées harmoniques (côte atlantique française — approximation) ───────────
|
||||||
|
// Constituants calibrés sur la zone Lacanau-Arcachon (SHOM approx.)
|
||||||
|
const TIDAL_CONSTITUENTS = [
|
||||||
|
// [amplitude_m, vitesse_°/h, phase_°]
|
||||||
|
[1.42, 28.9841042, 288], // M2 - Lunaire semi-diurne principal
|
||||||
|
[0.50, 30.0000000, 330], // S2 - Solaire semi-diurne principal
|
||||||
|
[0.28, 28.4397295, 270], // N2 - Lunaire elliptique majeure
|
||||||
|
[0.10, 15.0410686, 48 ], // K1 - Luni-solaire diurne
|
||||||
|
[0.09, 13.9430356, 358], // O1 - Lunaire diurne principal
|
||||||
|
[0.05, 30.0821373, 335], // K2 - Luni-solaire semi-diurne
|
||||||
|
[0.04, 28.5125831, 272], // L2 - Lunaire elliptique mineure
|
||||||
|
];
|
||||||
|
const J2000_MS = 946727935816; // 1er jan 2000 12h00 UTC en ms
|
||||||
|
|
||||||
|
function calculateTideHeight(timestampMs) {
|
||||||
|
const t = (timestampMs - J2000_MS) / 3_600_000; // heures depuis J2000
|
||||||
|
const toRad = d => d * Math.PI / 180;
|
||||||
|
return TIDAL_CONSTITUENTS.reduce((h, [amp, speed, phase]) => {
|
||||||
|
return h + amp * Math.cos(toRad(speed * t - phase));
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTideForDay(startTime, count = 25) {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const t = new Date(startTime.getTime() + i * 3_600_000);
|
||||||
|
return parseFloat(calculateTideHeight(t.getTime()).toFixed(2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchAstronomyData,
|
||||||
|
getMoonPhase,
|
||||||
|
invalidateAstroCache,
|
||||||
|
getSolarElevation,
|
||||||
|
getSolarElevationForDay,
|
||||||
|
calculateTideHeight,
|
||||||
|
getTideForDay,
|
||||||
|
};
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
const { getSolarElevationForDay, getTideForDay } = require('./astronomy');
|
||||||
|
|
||||||
|
const QC_URL = 'https://quickchart.io/chart';
|
||||||
|
const BG = 'rgb(20,20,32)';
|
||||||
|
const GRID = 'rgba(255,255,255,0.07)';
|
||||||
|
const TICK = '#BBBBCC';
|
||||||
|
const TITLE = '#FFFFFF';
|
||||||
|
const LEG = '#CCCCDD';
|
||||||
|
|
||||||
|
// ─── Heure courante Paris → préfixe "YYYY-MM-DDTHH" ─────────────────────────
|
||||||
|
function nowParisPrefix() {
|
||||||
|
return new Date()
|
||||||
|
.toLocaleString('sv', { timeZone: 'Europe/Paris' })
|
||||||
|
.substring(0, 13)
|
||||||
|
.replace(' ', 'T');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fenêtre de N heures à partir de maintenant ──────────────────────────────
|
||||||
|
function getWindow(hourlyTimes, count = 25) {
|
||||||
|
const prefix = nowParisPrefix();
|
||||||
|
let start = 0;
|
||||||
|
for (let i = 0; i < hourlyTimes.length; i++) {
|
||||||
|
if (hourlyTimes[i].startsWith(prefix)) { start = i; break; }
|
||||||
|
if (hourlyTimes[i] < prefix) start = i;
|
||||||
|
}
|
||||||
|
return { start, end: Math.min(start + count, hourlyTimes.length), startIdx: start };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heure depuis "YYYY-MM-DDTHH:MM" ────────────────────────────────────────
|
||||||
|
const hl = t => t.substring(11, 16);
|
||||||
|
|
||||||
|
// ─── POST QuickChart (config JS string = supporte les callbacks) ──────────────
|
||||||
|
async function postChart(chartJsStr) {
|
||||||
|
const res = await axios.post(QC_URL, {
|
||||||
|
chart: chartJsStr,
|
||||||
|
width: 760, height: 295,
|
||||||
|
backgroundColor: BG,
|
||||||
|
format: 'png', version: '3',
|
||||||
|
}, { responseType: 'arraybuffer', timeout: 20000 });
|
||||||
|
return Buffer.from(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Options communes ─────────────────────────────────────────────────────────
|
||||||
|
function opts({ title, yUnit = '', y1 = null, yMin = null, yMax = null }) {
|
||||||
|
const yMinStr = yMin !== null ? `,min:${yMin}` : '';
|
||||||
|
const yMaxStr = yMax !== null ? `,max:${yMax}` : '';
|
||||||
|
const y1block = y1
|
||||||
|
? `,y1:{position:'right',min:0,max:100,grid:{drawOnChartArea:false},ticks:{color:'${y1.color}',callback:function(v){return v+'${y1.unit}';}}}`
|
||||||
|
: '';
|
||||||
|
return `{
|
||||||
|
plugins:{
|
||||||
|
title:{display:true,text:'${title}',color:'${TITLE}',font:{size:14,weight:'bold'}},
|
||||||
|
legend:{labels:{color:'${LEG}',font:{size:11}}}
|
||||||
|
},
|
||||||
|
scales:{
|
||||||
|
x:{ticks:{color:'${TICK}',maxRotation:45,font:{size:10}},grid:{color:'${GRID}'}},
|
||||||
|
y:{ticks:{color:'${TICK}',callback:function(v){return v+'${yUnit}';}},grid:{color:'${GRID}'},position:'left'${yMinStr}${yMaxStr}}
|
||||||
|
${y1block}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 1. TEMPÉRATURES
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generateTemperatureChart(hourly) {
|
||||||
|
const { start, end } = getWindow(hourly.time);
|
||||||
|
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
|
||||||
|
const temps = JSON.stringify(hourly.temperature_2m.slice(start, end).map(v => v?.toFixed(1) ?? null));
|
||||||
|
const feels = JSON.stringify(hourly.apparent_temperature.slice(start, end).map(v => v?.toFixed(1) ?? null));
|
||||||
|
return postChart(`{type:'line',data:{labels:${labels},datasets:[
|
||||||
|
{label:'Température',data:${temps},borderColor:'rgb(255,107,53)',backgroundColor:'rgba(255,107,53,0.18)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5},
|
||||||
|
{label:'Ressenti',data:${feels},borderColor:'rgb(255,200,70)',backgroundColor:'rgba(255,200,70,0.05)',fill:false,tension:0.4,pointRadius:2,borderWidth:2,borderDash:[5,4]}
|
||||||
|
]},options:${opts({ title: '🌡️ Températures sur 24h', yUnit: '°C' })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 2. VENT (noeuds + km/h en tooltip)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generateWindChart(hourly) {
|
||||||
|
const { start, end } = getWindow(hourly.time);
|
||||||
|
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
|
||||||
|
|
||||||
|
// Convertir en noeuds pour l'axe Y, garder km/h en label dataset
|
||||||
|
const windKt = JSON.stringify(hourly.wind_speed_10m.slice(start, end).map(v => v != null ? +(v / 1.852).toFixed(1) : null));
|
||||||
|
const gustsKt = JSON.stringify(hourly.wind_gusts_10m.slice(start, end).map(v => v != null ? +(v / 1.852).toFixed(1) : null));
|
||||||
|
|
||||||
|
return postChart(`{type:'line',data:{labels:${labels},datasets:[
|
||||||
|
{label:'Vent moy. (kt)',data:${windKt},borderColor:'rgb(0,191,255)',backgroundColor:'rgba(0,191,255,0.18)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5},
|
||||||
|
{label:'Rafales (kt)',data:${gustsKt},borderColor:'rgb(120,140,255)',backgroundColor:'rgba(120,140,255,0.08)',fill:false,tension:0.4,pointRadius:2,borderWidth:2,borderDash:[6,3]}
|
||||||
|
]},options:${opts({ title: '💨 Vent sur 24h', yUnit: ' kt' })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 3. PRÉCIPITATIONS
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generatePrecipitationChart(hourly) {
|
||||||
|
const { start, end } = getWindow(hourly.time);
|
||||||
|
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
|
||||||
|
const precip = JSON.stringify(hourly.precipitation.slice(start, end).map(v => v?.toFixed(2) ?? 0));
|
||||||
|
const prob = JSON.stringify(hourly.precipitation_probability.slice(start, end));
|
||||||
|
return postChart(`{type:'bar',data:{labels:${labels},datasets:[
|
||||||
|
{label:'Précip. (mm)',data:${precip},backgroundColor:'rgba(30,144,255,0.75)',borderColor:'rgb(30,144,255)',borderWidth:1,yAxisID:'y'},
|
||||||
|
{label:'Probabilité (%)',data:${prob},type:'line',borderColor:'rgb(0,210,210)',backgroundColor:'rgba(0,210,210,0.1)',fill:false,tension:0.4,borderWidth:2,pointRadius:2,yAxisID:'y1'}
|
||||||
|
]},options:${opts({ title: '🌧️ Précipitations sur 24h', yUnit: ' mm', yMin: 0, y1: { color: '#00D2D2', unit: '%' } })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 4. PRESSION
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generatePressureChart(hourly) {
|
||||||
|
const { start, end } = getWindow(hourly.time);
|
||||||
|
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
|
||||||
|
const pressure = JSON.stringify(hourly.pressure_msl.slice(start, end).map(v => v?.toFixed(1) ?? null));
|
||||||
|
return postChart(`{type:'line',data:{labels:${labels},datasets:[
|
||||||
|
{label:'Pression (hPa)',data:${pressure},borderColor:'rgb(147,112,219)',backgroundColor:'rgba(147,112,219,0.22)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5}
|
||||||
|
]},options:${opts({ title: '🔵 Pression atmosphérique sur 24h', yUnit: ' hPa' })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 5. NUAGES
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generateCloudChart(hourly) {
|
||||||
|
const { start, end } = getWindow(hourly.time);
|
||||||
|
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
|
||||||
|
const total = JSON.stringify(hourly.cloud_cover.slice(start, end));
|
||||||
|
const low = JSON.stringify(hourly.cloud_cover_low.slice(start, end));
|
||||||
|
const mid = JSON.stringify(hourly.cloud_cover_mid.slice(start, end));
|
||||||
|
const high = JSON.stringify(hourly.cloud_cover_high.slice(start, end));
|
||||||
|
return postChart(`{type:'line',data:{labels:${labels},datasets:[
|
||||||
|
{label:'Total',data:${total},borderColor:'rgb(160,160,160)',backgroundColor:'rgba(160,160,160,0.2)',fill:true,tension:0.4,pointRadius:2,borderWidth:2.5},
|
||||||
|
{label:'Bas',data:${low},borderColor:'rgb(176,196,222)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[4,3]},
|
||||||
|
{label:'Moyen',data:${mid},borderColor:'rgb(100,180,220)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[6,3]},
|
||||||
|
{label:'Haut',data:${high},borderColor:'rgb(200,230,255)',fill:false,tension:0.4,pointRadius:2,borderWidth:1.5,borderDash:[8,4]}
|
||||||
|
]},options:${opts({ title: '☁️ Couverture nuageuse sur 24h', yUnit: '%', yMin: 0, yMax: 100 })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 6. UV INDEX
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generateUvChart(hourly) {
|
||||||
|
const { start, end } = getWindow(hourly.time);
|
||||||
|
const labels = JSON.stringify(hourly.time.slice(start, end).map(hl));
|
||||||
|
const uv = JSON.stringify(hourly.uv_index.slice(start, end).map(v => v?.toFixed(1) ?? 0));
|
||||||
|
|
||||||
|
// Zones de danger (annotations via dataset fill + background colors par zone)
|
||||||
|
return postChart(`{type:'bar',data:{labels:${labels},datasets:[
|
||||||
|
{label:'UV Index',data:${uv},backgroundColor:function(ctx){
|
||||||
|
const v=ctx.dataset.data[ctx.dataIndex];
|
||||||
|
if(v>=11)return 'rgba(148,0,211,0.85)';
|
||||||
|
if(v>=8)return 'rgba(255,0,0,0.80)';
|
||||||
|
if(v>=6)return 'rgba(255,140,0,0.82)';
|
||||||
|
if(v>=3)return 'rgba(255,215,0,0.82)';
|
||||||
|
return 'rgba(76,175,80,0.82)';
|
||||||
|
},borderColor:'rgba(0,0,0,0)',borderWidth:0,borderRadius:3}
|
||||||
|
]},options:${opts({ title: '☀️ UV Index sur 24h', yUnit: '', yMin: 0 })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 7. ÉLÉVATION SOLAIRE
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generateSolarElevationChart(hourlyTimes) {
|
||||||
|
const lat = parseFloat(process.env.LAT);
|
||||||
|
const lon = parseFloat(process.env.LON);
|
||||||
|
const { start, end, startIdx } = getWindow(hourlyTimes);
|
||||||
|
const labels = JSON.stringify(hourlyTimes.slice(start, end).map(hl));
|
||||||
|
|
||||||
|
// Reconstruire les dates UTC depuis les heures Paris
|
||||||
|
// Open-Meteo renvoie l'heure locale Paris sans offset → on applique l'offset manuellement
|
||||||
|
const parisOffsetH = getParisOffsetHours();
|
||||||
|
const startTimeUTC = new Date(
|
||||||
|
new Date(hourlyTimes[start] + ':00').getTime() - parisOffsetH * 3_600_000
|
||||||
|
);
|
||||||
|
|
||||||
|
const elevations = JSON.stringify(getSolarElevationForDay(lat, lon, startTimeUTC, end - start));
|
||||||
|
|
||||||
|
return postChart(`{type:'line',data:{labels:${labels},datasets:[
|
||||||
|
{label:'Élévation (°)',data:${elevations},borderColor:'rgb(255,220,50)',backgroundColor:function(ctx){
|
||||||
|
const v=ctx.dataset.data[ctx.dataIndex];
|
||||||
|
if(v<=0)return 'rgba(0,0,50,0.4)';
|
||||||
|
if(v<15)return 'rgba(255,120,30,0.3)';
|
||||||
|
return 'rgba(255,220,50,0.25)';
|
||||||
|
},fill:true,tension:0.4,pointRadius:2,borderWidth:2.5}
|
||||||
|
]},options:${opts({ title: '⬆️ Elevation solaire sur 24h', yUnit: '°', yMin: -10 })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset UTC de Paris en heures (gère l'heure d'été)
|
||||||
|
function getParisOffsetHours() {
|
||||||
|
const now = new Date();
|
||||||
|
const paris = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Paris' }));
|
||||||
|
return (paris - new Date(now.toLocaleString('en-US', { timeZone: 'UTC' }))) / 3_600_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// 8. MARÉES (calcul harmonique approximatif)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generateTideChart(hourlyTimes) {
|
||||||
|
const { start, end } = getWindow(hourlyTimes);
|
||||||
|
const labels = JSON.stringify(hourlyTimes.slice(start, end).map(hl));
|
||||||
|
|
||||||
|
// Heure de départ (heure locale Paris → UTC)
|
||||||
|
const parisOffsetH = getParisOffsetHours();
|
||||||
|
const startTimeUTC = new Date(
|
||||||
|
new Date(hourlyTimes[start] + ':00').getTime() - parisOffsetH * 3_600_000
|
||||||
|
);
|
||||||
|
|
||||||
|
const tides = JSON.stringify(getTideForDay(startTimeUTC, end - start));
|
||||||
|
|
||||||
|
return postChart(`{type:'line',data:{labels:${labels},datasets:[
|
||||||
|
{label:'Hauteur marée (m)',data:${tides},borderColor:'rgb(0,180,216)',backgroundColor:'rgba(0,180,216,0.20)',fill:true,tension:0.4,pointRadius:3,borderWidth:2.5}
|
||||||
|
]},options:${opts({ title: '🌊 Marees sur 24h (estimation harmonique)', yUnit: ' m' })}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
// GÉNÉRATION PARALLÈLE DES 8 GRAPHIQUES
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
async function generateAllCharts(hourly) {
|
||||||
|
const keys = ['temperature','wind','rain','pressure','clouds','uv','solar','tide'];
|
||||||
|
const jobs = [
|
||||||
|
generateTemperatureChart(hourly),
|
||||||
|
generateWindChart(hourly),
|
||||||
|
generatePrecipitationChart(hourly),
|
||||||
|
generatePressureChart(hourly),
|
||||||
|
generateCloudChart(hourly),
|
||||||
|
generateUvChart(hourly),
|
||||||
|
generateSolarElevationChart(hourly.time),
|
||||||
|
generateTideChart(hourly.time),
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(jobs);
|
||||||
|
const out = { errors: [] };
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'fulfilled') out[keys[i]] = r.value;
|
||||||
|
else {
|
||||||
|
out[keys[i]] = null;
|
||||||
|
out.errors.push(`${keys[i]}: ${r.reason?.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateAllCharts,
|
||||||
|
generateTemperatureChart,
|
||||||
|
generateWindChart,
|
||||||
|
generatePrecipitationChart,
|
||||||
|
generatePressureChart,
|
||||||
|
generateCloudChart,
|
||||||
|
generateUvChart,
|
||||||
|
generateSolarElevationChart,
|
||||||
|
generateTideChart,
|
||||||
|
};
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Chargement dynamique pour éviter un crash si @napi-rs/canvas n'est pas installé
|
||||||
|
let createCanvas, loadImage, GlobalFonts;
|
||||||
|
try {
|
||||||
|
({ createCanvas, loadImage, GlobalFonts } = require('@napi-rs/canvas'));
|
||||||
|
try { GlobalFonts.loadSystemFonts(); } catch (_) {}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[composite] @napi-rs/canvas non disponible — image composite désactivée');
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
getWeatherDescription, getWindDirection, getVisibilityLabel,
|
||||||
|
getUvLabel, knotsFromKmh, getNextHoursWindow, getTodayDailyIndex,
|
||||||
|
} = require('./weather');
|
||||||
|
|
||||||
|
// ─── Palette ──────────────────────────────────────────────────────────────────
|
||||||
|
const C = {
|
||||||
|
bg: '#0f172a',
|
||||||
|
section: '#1e293b',
|
||||||
|
card: '#243449',
|
||||||
|
border: '#334155',
|
||||||
|
text: '#e2e8f0',
|
||||||
|
dim: '#94a3b8',
|
||||||
|
bright: '#f8fafc',
|
||||||
|
accent: '#38bdf8',
|
||||||
|
orange: '#fb923c',
|
||||||
|
green: '#4ade80',
|
||||||
|
yellow: '#fbbf24',
|
||||||
|
marine: '#1e3a5c',
|
||||||
|
marineB: '#2a5a8c',
|
||||||
|
marineT: '#93c5fd',
|
||||||
|
};
|
||||||
|
|
||||||
|
const W = 1860;
|
||||||
|
const PAD = 22;
|
||||||
|
const GUTTER = 12;
|
||||||
|
|
||||||
|
// ─── Helpers canvas ───────────────────────────────────────────────────────────
|
||||||
|
function roundRect(ctx, x, y, w, h, r = 10) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCard(ctx, x, y, w, h, color = C.card, borderColor = C.border, r = 10) {
|
||||||
|
roundRect(ctx, x, y, w, h, r);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fill();
|
||||||
|
roundRect(ctx, x, y, w, h, r);
|
||||||
|
ctx.strokeStyle = borderColor;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
function txt(ctx, str, x, y, { size = 16, weight = 'normal', color = C.text, align = 'left', max } = {}) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.font = `${weight} ${size}px sans-serif`;
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.textAlign = align;
|
||||||
|
if (max) ctx.fillText(String(str ?? ''), x, y, max);
|
||||||
|
else ctx.fillText(String(str ?? ''), x, y);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hline(ctx, y, opacity = 0.5) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = C.border;
|
||||||
|
ctx.globalAlpha = opacity;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(PAD, y);
|
||||||
|
ctx.lineTo(W - PAD, y);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(ctx, label, value, x, y, valColor = C.accent) {
|
||||||
|
txt(ctx, label, x, y, { size: 14, color: C.dim });
|
||||||
|
txt(ctx, value ?? 'N/A', x + 175, y, { size: 14, weight: 'bold', color: valColor, max: 240 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Génération de l'image composite ─────────────────────────────────────────
|
||||||
|
async function generateCompositeImage({ weather: wd, marine, astro, charts }) {
|
||||||
|
if (!createCanvas) throw new Error('@napi-rs/canvas non installé');
|
||||||
|
|
||||||
|
const w = wd.weather;
|
||||||
|
const c = w.current;
|
||||||
|
const h = w.hourly;
|
||||||
|
const d = w.daily;
|
||||||
|
const loc = process.env.LOCATION_NAME || `${process.env.LAT}°, ${process.env.LON}°`;
|
||||||
|
|
||||||
|
const todayIdx = getTodayDailyIndex(d.time);
|
||||||
|
const { start: hIdx } = getNextHoursWindow(h.time);
|
||||||
|
|
||||||
|
// ── Layout dynamique ────────────────────────────────────────────────────────
|
||||||
|
const HEADER_H = 130;
|
||||||
|
const SEP = 14;
|
||||||
|
const DATA_H = 310; // 2 lignes × 3 cartes
|
||||||
|
const ASTRO_H = 76;
|
||||||
|
const MARINE_H = marine ? 58 : 0;
|
||||||
|
const ALERT_H = 0; // géré dans DATA section si besoin
|
||||||
|
const CHART_H = 280;
|
||||||
|
const CHART_ROWS = 4; // 8 graphiques / 2 colonnes
|
||||||
|
const FOOTER_H = 52;
|
||||||
|
|
||||||
|
const TOTAL_H = PAD + HEADER_H + SEP + DATA_H + SEP + ASTRO_H + (MARINE_H ? MARINE_H + GUTTER : 0)
|
||||||
|
+ SEP + CHART_ROWS * (CHART_H + GUTTER) + SEP + FOOTER_H + PAD;
|
||||||
|
|
||||||
|
const canvas = createCanvas(W, TOTAL_H);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// ── Fond ────────────────────────────────────────────────────────────────────
|
||||||
|
ctx.fillStyle = C.bg;
|
||||||
|
ctx.fillRect(0, 0, W, TOTAL_H);
|
||||||
|
|
||||||
|
// Gradient haut
|
||||||
|
const grd = ctx.createLinearGradient(0, 0, 0, 220);
|
||||||
|
grd.addColorStop(0, 'rgba(56,189,248,0.07)');
|
||||||
|
grd.addColorStop(1, 'rgba(0,0,0,0)');
|
||||||
|
ctx.fillStyle = grd;
|
||||||
|
ctx.fillRect(0, 0, W, 220);
|
||||||
|
|
||||||
|
let Y = PAD;
|
||||||
|
|
||||||
|
// ── HEADER ──────────────────────────────────────────────────────────────────
|
||||||
|
// Condition météo
|
||||||
|
txt(ctx, getWeatherDescription(c.weather_code), PAD, Y + 36, { size: 26, weight: 'bold', color: C.bright });
|
||||||
|
txt(ctx, loc, PAD, Y + 64, { size: 17, color: C.dim });
|
||||||
|
|
||||||
|
// Température (droite)
|
||||||
|
txt(ctx, `${c.temperature_2m?.toFixed(1)}°C`, W - PAD, Y + 44, { size: 52, weight: 'bold', color: C.accent, align: 'right' });
|
||||||
|
txt(ctx, `Ressenti ${c.apparent_temperature?.toFixed(1)}°C`, W - PAD, Y + 74, { size: 16, color: C.dim, align: 'right' });
|
||||||
|
|
||||||
|
// Date / heure
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = now.toLocaleDateString('fr-FR', { weekday:'long', year:'numeric', month:'long', day:'numeric', timeZone:'Europe/Paris' });
|
||||||
|
const timeStr = now.toLocaleTimeString('fr-FR', { hour:'2-digit', minute:'2-digit', timeZone:'Europe/Paris' });
|
||||||
|
txt(ctx, `${dateStr} — ${timeStr}`, PAD, Y + 100, { size: 14, color: C.dim });
|
||||||
|
|
||||||
|
// Ligne sous header
|
||||||
|
Y += HEADER_H;
|
||||||
|
hline(ctx, Y);
|
||||||
|
Y += SEP;
|
||||||
|
|
||||||
|
// ── DATA CARDS (2 lignes × 3 colonnes) ─────────────────────────────────────
|
||||||
|
const CW = (W - PAD * 2 - GUTTER * 2) / 3;
|
||||||
|
const CH = (DATA_H - GUTTER) / 2;
|
||||||
|
const R2 = Y + CH + GUTTER;
|
||||||
|
|
||||||
|
const cells = [
|
||||||
|
// Ligne 1
|
||||||
|
{
|
||||||
|
title: 'Temperatures',
|
||||||
|
items: [
|
||||||
|
['Actuelle', `${c.temperature_2m?.toFixed(1)}C (${(c.temperature_2m + 273.15).toFixed(0)} K)`],
|
||||||
|
['Ressentie', `${c.apparent_temperature?.toFixed(1)}C`],
|
||||||
|
['Max / Min', `${d.temperature_2m_max[todayIdx]}C / ${d.temperature_2m_min[todayIdx]}C`],
|
||||||
|
['Humidite', `${c.relative_humidity_2m}%`],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vent',
|
||||||
|
items: [
|
||||||
|
['Vitesse', `${knotsFromKmh(c.wind_speed_10m)} kt (${c.wind_speed_10m?.toFixed(0)} km/h)`],
|
||||||
|
['Rafales', `${knotsFromKmh(c.wind_gusts_10m)} kt (${c.wind_gusts_10m?.toFixed(0)} km/h)`],
|
||||||
|
['Direction', `${getWindDirection(c.wind_direction_10m)} (${c.wind_direction_10m?.toFixed(0)}deg)`],
|
||||||
|
['Max jour', `${knotsFromKmh(d.wind_speed_10m_max[todayIdx])} kt`],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Precipitations',
|
||||||
|
items: [
|
||||||
|
['Actuelle', `${c.precipitation?.toFixed(1)} mm/h`],
|
||||||
|
['Probabilite', `${h.precipitation_probability[hIdx] ?? 'N/A'}%`],
|
||||||
|
['Total jour', `${d.precipitation_sum[todayIdx]?.toFixed(1)} mm`],
|
||||||
|
['Neige', `${c.snowfall?.toFixed(1)} cm/h`],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Ligne 2
|
||||||
|
{
|
||||||
|
title: 'Atmosphere',
|
||||||
|
items: [
|
||||||
|
['Pression', `${c.pressure_msl?.toFixed(1)} hPa`],
|
||||||
|
['UV actuel', `${c.uv_index?.toFixed(1)} ${getUvLabel(c.uv_index)}`],
|
||||||
|
['UV max', `${d.uv_index_max[todayIdx]}`],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Couverture nuageuse',
|
||||||
|
items: [
|
||||||
|
['Total', `${c.cloud_cover}%`],
|
||||||
|
['Bas / Moy', `${h.cloud_cover_low[hIdx]}% / ${h.cloud_cover_mid[hIdx]}%`],
|
||||||
|
['Haut', `${h.cloud_cover_high[hIdx]}%`],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Visibilite & UV',
|
||||||
|
items: [
|
||||||
|
['Distance', `${(c.visibility / 1000).toFixed(1)} km`],
|
||||||
|
['Qualite', getVisibilityLabel(c.visibility)],
|
||||||
|
['UV max j.', `${d.uv_index_max[todayIdx]}`],
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const col = i % 3;
|
||||||
|
const ligne = Math.floor(i / 3);
|
||||||
|
const cx = PAD + col * (CW + GUTTER);
|
||||||
|
const cy = Y + ligne * (CH + GUTTER);
|
||||||
|
|
||||||
|
fillCard(ctx, cx, cy, CW, CH);
|
||||||
|
|
||||||
|
// Titre carte
|
||||||
|
txt(ctx, cells[i].title, cx + 14, cy + 24, { size: 14, weight: 'bold', color: C.bright });
|
||||||
|
|
||||||
|
// Séparateur titre
|
||||||
|
ctx.save();
|
||||||
|
ctx.strokeStyle = C.border;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx + 14, cy + 32);
|
||||||
|
ctx.lineTo(cx + CW - 14, cy + 32);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Données
|
||||||
|
cells[i].items.forEach((item, j) => {
|
||||||
|
row(ctx, item[0], item[1], cx + 14, cy + 54 + j * 26);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Y += DATA_H;
|
||||||
|
hline(ctx, Y);
|
||||||
|
Y += SEP;
|
||||||
|
|
||||||
|
// ── BANDE ASTRONOMIE ────────────────────────────────────────────────────────
|
||||||
|
fillCard(ctx, PAD, Y, W - PAD * 2, ASTRO_H - GUTTER, C.card, C.border, 10);
|
||||||
|
|
||||||
|
const astroItems = [
|
||||||
|
['Lever soleil', astro.sunrise],
|
||||||
|
['Coucher', astro.sunset],
|
||||||
|
['Zenith', astro.solar_noon],
|
||||||
|
['Duree du jour', astro.day_length],
|
||||||
|
['Lune', `${astro.moon.phaseEmoji} ${astro.moon.phaseName} ${astro.moon.illumination}%`],
|
||||||
|
];
|
||||||
|
const aColW = (W - PAD * 4) / astroItems.length;
|
||||||
|
astroItems.forEach((item, i) => {
|
||||||
|
const ax = PAD * 2 + i * aColW;
|
||||||
|
txt(ctx, item[0], ax, Y + 22, { size: 12, color: C.dim });
|
||||||
|
txt(ctx, item[1], ax, Y + 44, { size: 15, weight: 'bold', color: C.bright, max: aColW - 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
Y += ASTRO_H;
|
||||||
|
|
||||||
|
// ── BANDE MARINE ────────────────────────────────────────────────────────────
|
||||||
|
if (marine && MARINE_H) {
|
||||||
|
const mh = marine.hourly;
|
||||||
|
const { start: ms } = getNextHoursWindow(mh.time);
|
||||||
|
const wH = mh.wave_height?.[ms];
|
||||||
|
const wP = mh.wave_period?.[ms];
|
||||||
|
const wD = mh.wave_direction?.[ms];
|
||||||
|
const sH = mh.swell_wave_height?.[ms];
|
||||||
|
|
||||||
|
if (wH != null) {
|
||||||
|
fillCard(ctx, PAD, Y, W - PAD * 2, MARINE_H - GUTTER, C.marine, C.marineB, 10);
|
||||||
|
const marineStr = [
|
||||||
|
`Vagues : ${wH?.toFixed(2)} m`,
|
||||||
|
`Periode : ${wP?.toFixed(1)} s`,
|
||||||
|
`Direction : ${getWindDirection(wD)} (${wD?.toFixed(0)}deg)`,
|
||||||
|
sH != null ? `Houle : ${sH?.toFixed(2)} m` : '',
|
||||||
|
].filter(Boolean).join(' | ');
|
||||||
|
txt(ctx, marineStr, PAD * 2, Y + 28, { size: 15, weight: 'bold', color: C.marineT });
|
||||||
|
}
|
||||||
|
Y += MARINE_H;
|
||||||
|
}
|
||||||
|
|
||||||
|
hline(ctx, Y);
|
||||||
|
Y += SEP;
|
||||||
|
|
||||||
|
// ── GRILLE DE GRAPHIQUES (2 colonnes) ───────────────────────────────────────
|
||||||
|
const CW2 = (W - PAD * 2 - GUTTER) / 2;
|
||||||
|
const chartOrder = ['temperature','wind','rain','pressure','clouds','uv','solar','tide'];
|
||||||
|
const validCharts = chartOrder.filter(k => Buffer.isBuffer(charts[k]));
|
||||||
|
|
||||||
|
for (let i = 0; i < validCharts.length; i++) {
|
||||||
|
const col = i % 2;
|
||||||
|
const ligne = Math.floor(i / 2);
|
||||||
|
const cx = PAD + col * (CW2 + GUTTER);
|
||||||
|
const cy = Y + ligne * (CHART_H + GUTTER);
|
||||||
|
|
||||||
|
const img = await loadImage(charts[validCharts[i]]);
|
||||||
|
ctx.drawImage(img, cx, cy, CW2, CHART_H);
|
||||||
|
}
|
||||||
|
|
||||||
|
Y += Math.ceil(validCharts.length / 2) * (CHART_H + GUTTER);
|
||||||
|
|
||||||
|
// ── FOOTER ──────────────────────────────────────────────────────────────────
|
||||||
|
hline(ctx, Y, 0.4);
|
||||||
|
Y += 10;
|
||||||
|
txt(
|
||||||
|
ctx,
|
||||||
|
`Open-Meteo | Sunrise-Sunset.org | QuickChart.io | Marees harmoniques (estimation) — ${now.toLocaleTimeString('fr-FR', { timeZone: 'Europe/Paris' })}`,
|
||||||
|
W / 2, Y + 26,
|
||||||
|
{ size: 12, color: C.dim, align: 'center' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return canvas.encode('png');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateCompositeImage };
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// ─── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
let cache = { data: null, ts: null };
|
||||||
|
const CACHE_MS = () => (parseInt(process.env.CACHE_MINUTES, 10) || 10) * 60 * 1000;
|
||||||
|
|
||||||
|
// ─── Appel API principal ────────────────────────────────────────────────────────
|
||||||
|
async function fetchWeatherData() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (cache.data && cache.ts && now - cache.ts < CACHE_MS()) {
|
||||||
|
if (process.env.DEBUG_METEO === 'true') console.log('[cache] Données météo en cache');
|
||||||
|
return cache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = process.env.LAT;
|
||||||
|
const lon = process.env.LON;
|
||||||
|
if (!lat || !lon) throw new Error('LAT et LON manquants dans .env');
|
||||||
|
|
||||||
|
const hourlyVars = [
|
||||||
|
'temperature_2m', 'apparent_temperature', 'relative_humidity_2m',
|
||||||
|
'wind_speed_10m', 'wind_gusts_10m', 'wind_direction_10m',
|
||||||
|
'precipitation', 'precipitation_probability', 'rain', 'snowfall', 'showers',
|
||||||
|
'pressure_msl', 'cloud_cover', 'cloud_cover_low', 'cloud_cover_mid', 'cloud_cover_high',
|
||||||
|
'visibility', 'uv_index', 'weather_code',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const dailyVars = [
|
||||||
|
'temperature_2m_max', 'temperature_2m_min',
|
||||||
|
'apparent_temperature_max', 'apparent_temperature_min',
|
||||||
|
'precipitation_sum', 'precipitation_probability_max',
|
||||||
|
'wind_speed_10m_max', 'wind_gusts_10m_max',
|
||||||
|
'uv_index_max', 'sunrise', 'sunset', 'weather_code',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const currentVars = [
|
||||||
|
'temperature_2m', 'apparent_temperature', 'relative_humidity_2m',
|
||||||
|
'wind_speed_10m', 'wind_gusts_10m', 'wind_direction_10m',
|
||||||
|
'precipitation', 'rain', 'snowfall', 'weather_code',
|
||||||
|
'pressure_msl', 'cloud_cover', 'visibility', 'uv_index', 'is_day',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const baseParams = {
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lon,
|
||||||
|
timezone: 'Europe/Paris',
|
||||||
|
wind_speed_unit: 'kmh',
|
||||||
|
forecast_days: 2,
|
||||||
|
past_days: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Appels parallèles : météo principale + marine
|
||||||
|
const [weatherRes, marineRes] = await Promise.allSettled([
|
||||||
|
axios.get('https://api.open-meteo.com/v1/forecast', {
|
||||||
|
params: { ...baseParams, current: currentVars, hourly: hourlyVars, daily: dailyVars },
|
||||||
|
timeout: 10000,
|
||||||
|
}),
|
||||||
|
axios.get('https://marine-api.open-meteo.com/v1/marine', {
|
||||||
|
params: {
|
||||||
|
...baseParams,
|
||||||
|
hourly: 'wave_height,wave_direction,wave_period,swell_wave_height,swell_wave_direction',
|
||||||
|
daily: 'wave_height_max,swell_wave_height_max',
|
||||||
|
},
|
||||||
|
timeout: 8000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (weatherRes.status === 'rejected') {
|
||||||
|
throw new Error(`Open-Meteo inaccessible : ${weatherRes.reason?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weather = weatherRes.value.data;
|
||||||
|
const marine = marineRes.status === 'fulfilled' ? marineRes.value.data : null;
|
||||||
|
|
||||||
|
if (process.env.DEBUG_METEO === 'true') {
|
||||||
|
console.log('[debug] current:', JSON.stringify(weather.current, null, 2));
|
||||||
|
if (marine) console.log('[debug] marine disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { weather, marine, fetchedAt: new Date() };
|
||||||
|
cache = { data: result, ts: now };
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Invalidation manuelle du cache ───────────────────────────────────────────
|
||||||
|
function invalidateCache() {
|
||||||
|
cache = { data: null, ts: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Index de l'heure courante dans les données horaires ──────────────────────
|
||||||
|
function getCurrentHourIndex(hourlyTimes) {
|
||||||
|
// Open-Meteo renvoie les heures en heure locale Paris (ex: "2024-06-10T14:00")
|
||||||
|
// On génère la même chaîne à partir de l'heure Paris actuelle
|
||||||
|
const nowParis = new Date().toLocaleString('sv', { timeZone: 'Europe/Paris' });
|
||||||
|
// format: "YYYY-MM-DD HH:MM:SS"
|
||||||
|
const parisHourPrefix = nowParis.substring(0, 13).replace(' ', 'T'); // "YYYY-MM-DDTHH"
|
||||||
|
|
||||||
|
let best = 0;
|
||||||
|
for (let i = 0; i < hourlyTimes.length; i++) {
|
||||||
|
if (hourlyTimes[i].startsWith(parisHourPrefix)) return i;
|
||||||
|
if (hourlyTimes[i] < `${parisHourPrefix}:59`) best = i;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Fenêtre des prochaines N heures depuis maintenant ────────────────────────
|
||||||
|
function getNextHoursWindow(hourlyTimes, count = 24) {
|
||||||
|
const start = getCurrentHourIndex(hourlyTimes);
|
||||||
|
const end = Math.min(start + count, hourlyTimes.length);
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Index du jour courant dans les tableaux daily ────────────────────────────
|
||||||
|
function getTodayDailyIndex(dailyTimes) {
|
||||||
|
// avec past_days:1, index 0 = hier, 1 = aujourd'hui, 2 = demain
|
||||||
|
const todayParis = new Date().toLocaleString('sv', { timeZone: 'Europe/Paris' }).substring(0, 10);
|
||||||
|
const idx = dailyTimes.findIndex(d => d === todayParis);
|
||||||
|
return idx >= 0 ? idx : 1; // fallback à 1 (aujourd'hui)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
function celsiusToKelvin(c) {
|
||||||
|
return (c + 273.15).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindDirection(deg) {
|
||||||
|
if (deg == null) return 'N/A';
|
||||||
|
const dirs = ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSO','SO','OSO','O','ONO','NO','NNO'];
|
||||||
|
return dirs[Math.round(((deg % 360) + 360) % 360 / 22.5) % 16];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeatherDescription(code) {
|
||||||
|
const map = {
|
||||||
|
0: '☀️ Ciel dégagé',
|
||||||
|
1: '🌤️ Peu nuageux',
|
||||||
|
2: '⛅ Partiellement nuageux',
|
||||||
|
3: '☁️ Couvert',
|
||||||
|
45: '🌫️ Brouillard',
|
||||||
|
48: '🌫️ Brouillard givrant',
|
||||||
|
51: '🌦️ Bruine légère',
|
||||||
|
53: '🌦️ Bruine modérée',
|
||||||
|
55: '🌧️ Bruine dense',
|
||||||
|
61: '🌧️ Pluie légère',
|
||||||
|
63: '🌧️ Pluie modérée',
|
||||||
|
65: '🌧️ Pluie forte',
|
||||||
|
71: '🌨️ Neige légère',
|
||||||
|
73: '🌨️ Neige modérée',
|
||||||
|
75: '❄️ Neige forte',
|
||||||
|
77: '🌨️ Grésil',
|
||||||
|
80: '🌦️ Averses légères',
|
||||||
|
81: '🌧️ Averses modérées',
|
||||||
|
82: '⛈️ Averses violentes',
|
||||||
|
85: '🌨️ Averses de neige',
|
||||||
|
86: '❄️ Averses de neige fortes',
|
||||||
|
95: '⛈️ Orage',
|
||||||
|
96: '⛈️ Orage avec grêle',
|
||||||
|
99: '⛈️ Orage avec grêle forte',
|
||||||
|
};
|
||||||
|
return map[code] ?? `Code météo ${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmbedColor(code) {
|
||||||
|
if (code === 0) return 0xFFD700;
|
||||||
|
if (code <= 2) return 0x87CEEB;
|
||||||
|
if (code <= 3) return 0x708090;
|
||||||
|
if (code <= 48) return 0x9E9E9E;
|
||||||
|
if (code <= 55) return 0x90CAF9;
|
||||||
|
if (code <= 65) return 0x2196F3;
|
||||||
|
if (code <= 77) return 0xB0C4DE;
|
||||||
|
if (code <= 82) return 0x42A5F5;
|
||||||
|
return 0x7B1FA2; // orages
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPressureTendency(hourlyPressure, currentIdx) {
|
||||||
|
if (currentIdx < 3) return '→ Stable';
|
||||||
|
const diff = hourlyPressure[currentIdx] - hourlyPressure[currentIdx - 3];
|
||||||
|
if (diff > 2) return '↑ En hausse';
|
||||||
|
if (diff < -2) return '↓ En baisse';
|
||||||
|
return '→ Stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibilityLabel(vis) {
|
||||||
|
if (vis == null) return 'N/A';
|
||||||
|
const km = vis / 1000;
|
||||||
|
if (km >= 20) return '✨ Excellente';
|
||||||
|
if (km >= 10) return '👍 Bonne';
|
||||||
|
if (km >= 5) return '😶🌫️ Correcte';
|
||||||
|
if (km >= 2) return '⚠️ Réduite';
|
||||||
|
if (km >= 1) return '🟠 Mauvaise';
|
||||||
|
return '🔴 Brouillard';
|
||||||
|
}
|
||||||
|
|
||||||
|
function knotsFromKmh(kmh) {
|
||||||
|
if (kmh == null) return 'N/A';
|
||||||
|
return (kmh / 1.852).toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUvLabel(uv) {
|
||||||
|
if (uv == null) return 'N/A';
|
||||||
|
if (uv <= 2) return '🟢 Faible';
|
||||||
|
if (uv <= 5) return '🟡 Modéré';
|
||||||
|
if (uv <= 7) return '🟠 Élevé';
|
||||||
|
if (uv <= 10) return '🔴 Très élevé';
|
||||||
|
return '🟣 Extrême';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Détection d'alertes météo extrêmes ───────────────────────────────────────
|
||||||
|
function checkAlerts(current, todayDaily) {
|
||||||
|
const alerts = [];
|
||||||
|
|
||||||
|
const wind = current.wind_speed_10m;
|
||||||
|
const gusts = current.wind_gusts_10m;
|
||||||
|
const rain = current.precipitation;
|
||||||
|
const code = current.weather_code;
|
||||||
|
const vis = current.visibility;
|
||||||
|
|
||||||
|
if (wind >= 90) alerts.push({ lvl: '🔴', msg: `Vent violent : **${wind} km/h**` });
|
||||||
|
else if (wind >= 65) alerts.push({ lvl: '🟠', msg: `Vent fort : **${wind} km/h**` });
|
||||||
|
else if (wind >= 50) alerts.push({ lvl: '🟡', msg: `Vent modéré-fort : **${wind} km/h**` });
|
||||||
|
|
||||||
|
if (gusts >= 110) alerts.push({ lvl: '🔴', msg: `Rafales extrêmes : **${gusts} km/h**` });
|
||||||
|
else if (gusts >= 80) alerts.push({ lvl: '🟠', msg: `Rafales fortes : **${gusts} km/h**` });
|
||||||
|
|
||||||
|
if (rain >= 15) alerts.push({ lvl: '🔴', msg: `Pluie torrentielle : **${rain} mm/h**` });
|
||||||
|
else if (rain >= 7) alerts.push({ lvl: '🟠', msg: `Fortes précipitations : **${rain} mm/h**` });
|
||||||
|
|
||||||
|
if (code >= 95) alerts.push({ lvl: '🔴', msg: `Orage en cours ⛈️` });
|
||||||
|
|
||||||
|
const snowDaily = todayDaily?.precipitation_sum;
|
||||||
|
if (current.snowfall >= 1) alerts.push({ lvl: '🟡', msg: `Chutes de neige en cours ❄️` });
|
||||||
|
|
||||||
|
if (vis != null && vis < 500) alerts.push({ lvl: '🔴', msg: `Visibilité quasi nulle : **${(vis/1000).toFixed(1)} km**` });
|
||||||
|
else if (vis != null && vis < 1500) alerts.push({ lvl: '🟠', msg: `Brouillard dense : **${(vis/1000).toFixed(1)} km**` });
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
fetchWeatherData,
|
||||||
|
invalidateCache,
|
||||||
|
getCurrentHourIndex,
|
||||||
|
getNextHoursWindow,
|
||||||
|
getTodayDailyIndex,
|
||||||
|
celsiusToKelvin,
|
||||||
|
getWindDirection,
|
||||||
|
getWeatherDescription,
|
||||||
|
getEmbedColor,
|
||||||
|
getPressureTendency,
|
||||||
|
getVisibilityLabel,
|
||||||
|
getUvLabel,
|
||||||
|
knotsFromKmh,
|
||||||
|
checkAlerts,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user