feat: retire API publique + dedupe load-balancing MF + maj pages
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run

API publique retirée :
- /api/vigilance et /api/vigilance/dept/[code] supprimés
- Mentions retirées dans footer, /mentions-legales, /embed
- /api/health garde, sans CORS (usage interne UptimeRobot + cron HC.io)
- Tests E2E mis à jour (vérifient 404 sur les endpoints retirés)

Pages :
- /dependances : entièrement mise à jour (Sentry, sharp, Playwright,
  sitemap, typography ajoutés ; API MF officielle DPObs+DPVigilance,
  normales 1991-2020 listées ; section Services tiers ajoutée pour
  Opendatasoft en fallback ; section Infrastructure complétée avec
  GlitchTip + CrowdSec).
- /soutenir : "~7€/mois mutualisés" → "~30€/mois", suppression du
  détail VPS OVH (juste "infra"), suppression du bloc "Réutiliser
  les données via API".

Vigilance Météo France load-balancing :
- lib/vigilance.ts : fetch parallèle x3 et garde la réponse au
  update_time le plus récent. Constat 2026-05-26 : le gateway MF
  load-balance entre instances désynchronisées (~60% renvoyaient
  bulletin J-1, ~40% bulletin J). Cette mitigation atteint >95%
  de probabilité d'avoir le bulletin frais.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian 2026-05-26 02:16:04 +02:00
parent 0a1f11aa00
commit 27441cdbb8
10 changed files with 139 additions and 148 deletions

View file

@ -12,6 +12,11 @@ export const GET: APIRoute = async () => {
};
return new Response(JSON.stringify(body), {
status: cacheOk ? 200 : 503,
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
// Pas de CORS : endpoint d'usage interne (UptimeRobot + cron HC.io freshness).
// Pas destiné aux clients tiers.
},
});
};

View file

@ -1,24 +0,0 @@
import type { APIRoute } from 'astro';
import { getVigilanceSnapshot } from '../../lib/vigilance';
export const prerender = false;
// JSON public du snapshot Vigilance actuel — réutilisable sous Licence Ouverte 2.0.
export const GET: APIRoute = async () => {
try {
const snap = await getVigilanceSnapshot();
return new Response(JSON.stringify(snap), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
},
});
} catch (e) {
return new Response(
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
{ status: 502, headers: { 'Content-Type': 'application/json' } },
);
}
};

View file

@ -1,62 +0,0 @@
import type { APIRoute } from 'astro';
import { getVigilanceSnapshot, alertsForDepartement } from '../../../../lib/vigilance';
import { getDepartement } from '../../../../lib/departements';
import { PHENOMENA, COLORS } from '../../../../lib/phenomena';
export const prerender = false;
// JSON par département (J + J1) — CORS *, réutilisable sous Licence Ouverte 2.0.
// Ex : GET /api/vigilance/dept/75 → alertes Paris
export const GET: APIRoute = async ({ params }) => {
const codeRaw = params.code;
if (!codeRaw) {
return new Response(JSON.stringify({ error: 'missing_code' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const code = codeRaw.toUpperCase();
const dept = getDepartement(code);
if (!dept) {
return new Response(JSON.stringify({ error: 'unknown_departement', code }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const snap = await getVigilanceSnapshot();
const today = alertsForDepartement(snap, code, 'J');
const tomorrow = alertsForDepartement(snap, code, 'J1');
const enrich = (a: ReturnType<typeof alertsForDepartement>[0]) => ({
phenomenonId: a.phenomenonId,
phenomenon: PHENOMENA[a.phenomenonId].label,
colorId: a.colorId,
color: COLORS[a.colorId].name,
beginTime: a.beginTime,
endTime: a.endTime,
});
const body = {
departement: { code, name: dept.name, region: dept.region },
productDatetime: snap.productDatetime,
fetchedAt: snap.fetchedAt,
today: today.map(enrich),
tomorrow: tomorrow.map(enrich),
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'public, max-age=300',
'Access-Control-Allow-Origin': '*',
},
});
} catch (e) {
return new Response(
JSON.stringify({ error: 'fetch_failed', detail: (e as Error).message }),
{ status: 502, headers: { 'Content-Type': 'application/json' } },
);
}
};

View file

@ -11,10 +11,9 @@ const devDeps = Object.entries(pkg.devDependencies as Record<string, string>).so
a.localeCompare(b),
);
// Catalogue manuel : description + license + URL pour chaque dépendance principale.
const META: Record<string, { desc: string; license: string; url: string }> = {
astro: {
desc: "Framework web orienté contenu (rendu serveur, île d'interactivité).",
desc: "Framework web orienté contenu (rendu serveur, îlots d'interactivité).",
license: 'MIT',
url: 'https://astro.build/',
},
@ -28,21 +27,46 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
license: 'MIT',
url: 'https://docs.astro.build/en/guides/integrations-guide/tailwind/',
},
'@astrojs/sitemap': {
desc: 'Génération automatique du sitemap-index.xml + sitemap-0.xml.',
license: 'MIT',
url: 'https://docs.astro.build/en/guides/integrations-guide/sitemap/',
},
'@sentry/astro': {
desc: "SDK Sentry/GlitchTip pour reporter les erreurs serveur (opt-in via env).",
license: 'MIT',
url: 'https://docs.sentry.io/platforms/javascript/guides/astro/',
},
'@tailwindcss/typography': {
desc: 'Plugin Tailwind pour le styling des pages de contenu (prose).',
license: 'MIT',
url: 'https://github.com/tailwindlabs/tailwindcss-typography',
},
tailwindcss: {
desc: 'Framework CSS utility-first.',
license: 'MIT',
url: 'https://tailwindcss.com/',
},
ioredis: {
desc: 'Client Redis/Valkey performant pour Node.js (cache du snapshot Vigilance).',
desc: 'Client Redis/Valkey performant pour Node.js (cache des snapshots).',
license: 'MIT',
url: 'https://github.com/redis/ioredis',
},
'@astrojs/check': {
desc: 'Type checker officiel Astro (utilisé en CI / dev).',
desc: 'Type checker officiel Astro (vérification de types en CI / dev).',
license: 'MIT',
url: 'https://docs.astro.build/en/reference/cli-reference/#astro-check',
},
'@playwright/test': {
desc: 'Framework de tests end-to-end (vérification du site live).',
license: 'Apache-2.0',
url: 'https://playwright.dev/',
},
sharp: {
desc: 'Génération de l\'image Open Graph (PNG depuis SVG) au build.',
license: 'Apache-2.0',
url: 'https://sharp.pixelplumbing.com/',
},
'@types/node': {
desc: 'Types TypeScript pour les API Node.js.',
license: 'MIT',
@ -57,32 +81,49 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
const DATA_SOURCES = [
{
name: 'France GeoJSON',
desc: "Polygones des départements français (utilisés pour la carte SVG).",
name: 'Météo France — API Vigilance officielle (DPVigilance)',
desc: "Bulletin Vigilance en temps réel, métropole, publication 2× par jour + complémentaires sur rouge.",
license: 'Licence Ouverte 2.0',
url: 'https://github.com/gregoiredavid/france-geojson',
url: 'https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance',
},
{
name: 'Météo France — Vigilance',
desc: 'Bulletin Vigilance en temps réel via Opendatasoft.',
name: 'Météo France — API Observation officielle (DPObs)',
desc: "Observations horaires SYNOP des stations principales (températures, vent, pression). Onglet 24 h sur les pages département.",
license: 'Licence Ouverte 2.0',
url: 'https://public.opendatasoft.com/explore/dataset/weatherref-france-vigilance-meteo-departement/',
url: 'https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesObservation',
},
{
name: 'Météo France — Climatologie',
desc: 'Données climatologiques de base quotidiennes (températures, précipitations).',
name: 'Météo France — Climatologie de base quotidienne',
desc: 'Données journalières TN/TX/TM/RR par station depuis 1950, agrégées par département. Onglets 7 j / 30 j / 1 an.',
license: 'Licence Ouverte 2.0',
url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes',
},
{
name: 'Normales 1991-2020',
desc: 'Normales saisonnières journalières TN/TX par département, pré-calculées sur la période WMO standard 1991-2020 (lissage 7 jours).',
license: 'Licence Ouverte 2.0 (dérivé des données ci-dessus)',
url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes',
},
{
name: 'France GeoJSON',
desc: "Polygones des départements français (utilisés pour générer la carte SVG).",
license: 'Licence Ouverte 2.0',
url: 'https://github.com/gregoiredavid/france-geojson',
},
];
const SERVICES = [
{ name: 'Opendatasoft (fallback)', desc: "Source de secours pour la Vigilance si l'API Météo France officielle est indisponible.", license: 'LOv2', url: 'https://public.opendatasoft.com/explore/dataset/weatherref-france-vigilance-meteo-departement/' },
];
const INFRA = [
{ name: 'Node.js 22', desc: "Runtime serveur.", license: 'MIT', url: 'https://nodejs.org/' },
{ name: 'Node.js 22 LTS', desc: 'Runtime serveur.', license: 'MIT', url: 'https://nodejs.org/' },
{ name: 'Docker', desc: 'Containerisation.', license: 'Apache-2.0', url: 'https://www.docker.com/' },
{ name: 'Caddy', desc: 'Reverse proxy + TLS automatique.', license: 'Apache-2.0', url: 'https://caddyserver.com/' },
{ name: 'Valkey', desc: 'Fork ouvert de Redis (cache).', license: 'BSD-3-Clause', url: 'https://valkey.io/' },
{ name: 'Umami', desc: 'Analytics RGPD-friendly (auto-hébergé).', license: 'MIT', url: 'https://umami.is/' },
{ name: 'Forgejo', desc: 'Forge git auto-hébergée + CI/CD.', license: 'MIT', url: 'https://forgejo.org/' },
{ name: 'Caddy', desc: 'Reverse proxy + TLS automatique (Let\'s Encrypt).', license: 'Apache-2.0', url: 'https://caddyserver.com/' },
{ name: 'Valkey', desc: 'Fork ouvert de Redis (cache des snapshots).', license: 'BSD-3-Clause', url: 'https://valkey.io/' },
{ name: 'Umami', desc: 'Analytics RGPD-friendly (auto-hébergé, sans cookies).', license: 'MIT', url: 'https://umami.is/' },
{ name: 'GlitchTip', desc: 'Error tracking compatible Sentry (auto-hébergé).', license: 'MIT', url: 'https://glitchtip.com/' },
{ name: 'CrowdSec', desc: 'Protection contre les attaques (rate-limit, ban des IPs malveillantes).', license: 'MIT', url: 'https://www.crowdsec.net/' },
];
---
@ -113,7 +154,31 @@ const INFRA = [
</thead>
<tbody>
{DATA_SOURCES.map((s) => (
<tr class="border-t border-slate-200">
<tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-medium">
<a href={s.url} rel="noopener">{s.name}</a>
</td>
<td class="px-3 py-2 text-slate-700">{s.desc}</td>
<td class="px-3 py-2 text-slate-600">{s.license}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<h2 class="text-xl font-semibold">Services tiers</h2>
<table class="mt-3 w-full text-sm">
<thead class="bg-slate-100 text-left text-slate-700">
<tr>
<th class="px-3 py-2">Service</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2">Licence</th>
</tr>
</thead>
<tbody>
{SERVICES.map((s) => (
<tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-medium">
<a href={s.url} rel="noopener">{s.name}</a>
</td>
@ -140,7 +205,7 @@ const INFRA = [
{deps.map(([name, version]) => {
const m = META[name];
return (
<tr class="border-t border-slate-200">
<tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-mono">
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
</td>
@ -161,6 +226,7 @@ const INFRA = [
<tr>
<th class="px-3 py-2">Package</th>
<th class="px-3 py-2">Version</th>
<th class="px-3 py-2">Description</th>
<th class="px-3 py-2">Licence</th>
</tr>
</thead>
@ -168,11 +234,12 @@ const INFRA = [
{devDeps.map(([name, version]) => {
const m = META[name];
return (
<tr class="border-t border-slate-200">
<tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-mono">
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
</td>
<td class="px-3 py-2 text-slate-600">{version}</td>
<td class="px-3 py-2 text-slate-700">{m?.desc ?? '—'}</td>
<td class="px-3 py-2 text-slate-600">{m?.license ?? '—'}</td>
</tr>
);
@ -193,7 +260,7 @@ const INFRA = [
</thead>
<tbody>
{INFRA.map((i) => (
<tr class="border-t border-slate-200">
<tr class="border-t border-slate-200 align-top">
<td class="px-3 py-2 font-medium">
<a href={i.url} rel="noopener">{i.name}</a>
</td>

View file

@ -71,15 +71,13 @@ const snippet = `<iframe
</div>
<div>
<h2 class="text-xl font-semibold">API JSON brute (alternative)</h2>
<h2 class="text-xl font-semibold">Besoin d'un accès programmatique ?</h2>
<p class="mt-2 text-sm text-slate-700">
Si tu préfères afficher la Vigilance avec ton propre design, l'endpoint
<code class="rounded bg-slate-100 px-1">/api/vigilance/dept/[code]</code>
renvoie un JSON CORS *, prêt à parser :
Ce site ne fournit pas d'API JSON publique. Si tu veux requêter la Vigilance Météo France
de manière programmatique, inscris-toi directement sur le
<a href="https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance" rel="noopener">portail API officiel Météo France</a>
(gratuit, ~5 min) — c'est la même source que celle utilisée par ce site.
</p>
<pre class="mt-2 overflow-x-auto rounded bg-slate-900 p-4 text-xs text-slate-100"><code>fetch('{baseUrl}/api/vigilance/dept/{sample.code}')
.then(r =&gt; r.json())
.then(data =&gt; console.log(data.today)); // [&#123; phenomenon, color, ... &#125;]</code></pre>
</div>
<div>

View file

@ -112,8 +112,9 @@ export const prerender = false;
Météo France et la licence.
</p>
<p>
L'endpoint <code>/api/vigilance</code> diffuse le snapshot courant en JSON (CORS *), pour
réutilisation par tout site ou application qui le souhaite.
Pour consulter la Vigilance Météo France de manière programmatique, utilisez directement
l'<a href="https://portail-api.meteofrance.fr/web/fr/api/DonneesPubliquesVigilance" rel="noopener">API officielle Météo France</a>
(gratuite après inscription).
</p>
</section>
</Base>

View file

@ -13,7 +13,7 @@ export const prerender = false;
<h1 class="text-3xl font-bold sm:text-4xl">Soutenir Info Canicule</h1>
<p class="mt-2 max-w-2xl text-slate-600">
Le site est gratuit, sans publicité et sans traceurs commerciaux. Si vous le trouvez utile,
vous pouvez aider à couvrir l'hébergement (~7 €/mois mutualisés sur plusieurs projets perso).
vous pouvez contribuer aux frais d'infrastructure (~30 €/mois).
</p>
</div>
</section>
@ -38,7 +38,7 @@ export const prerender = false;
<h2>Ce que les dons financent</h2>
<ul>
<li>Location du VPS OVH qui héberge le site (~7 €/mois, mutualisés avec d'autres projets perso).</li>
<li>Frais d'infrastructure mensuels (~30 €/mois).</li>
<li>Nom de domaine annuel.</li>
<li>Café pour les soirées de maintenance 🥲.</li>
</ul>
@ -59,10 +59,6 @@ export const prerender = false;
(proches âgés, personnes isolées) pour qui les conseils canicule peuvent faire une vraie
différence.
</li>
<li>
<strong>Réutiliser les données</strong> : l'endpoint <code>/api/vigilance</code> diffuse le
snapshot en JSON (CORS *), réutilisable librement sous Licence Ouverte 2.0.
</li>
</ul>
<p class="text-sm text-slate-500">