first commit

This commit is contained in:
Puechberty Arthur
2026-06-19 07:18:00 +02:00
commit 4bc95c52b1
11 changed files with 2584 additions and 0 deletions
+19
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
/.env
/node_modules
+179
View File
@@ -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"
}
```
+244
View File
@@ -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,
};
+118
View File
@@ -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);
});
+945
View File
@@ -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
}
}
}
}
}
+20
View File
@@ -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"
}
}
+232
View File
@@ -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 (0100 %)
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,
};
+255
View File
@@ -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,
};
+316
View File
@@ -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 };
+254
View File
@@ -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,
};