diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 47d9f96..f27ae1a 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -136,7 +136,6 @@ const jsonLd = {
  • Mentions légales
  • Dépendances
  • ☕ Soutenir sur Ko-fi
  • -
  • API JSON publique
  • diff --git a/src/lib/vigilance.ts b/src/lib/vigilance.ts index cfe321f..0021c87 100644 --- a/src/lib/vigilance.ts +++ b/src/lib/vigilance.ts @@ -80,12 +80,32 @@ async function fetchOpendatasoft(): Promise { // --- Provider : API Météo France officielle (DPVigilance/v1) --- // Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines // (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier). +// +// PIÈGE : le gateway MF (WSO2) load-balance entre plusieurs instances +// qui peuvent être désynchronisées (constaté 2026-05-26 : ~60% des hits +// retournaient le bulletin J-1 à 4h UTC, ~40% le bulletin J à 14h UTC). +// Mitigation : on fait N fetches concurrents et on garde la réponse avec +// le `update_time` le plus récent. N=3 atteint >95% de prob d'avoir le frais. + +const MF_PARALLEL_FETCHES = 3; +const MF_ENDPOINT = '/public/DPVigilance/v1/cartevigilance/encours'; + +async function fetchMFOnce(): Promise<{ update_time: string; raw: any } | null> { + const res = await fetchMF(MF_ENDPOINT); + if (!res.ok) return null; + const j = await res.json(); + return { update_time: j?.product?.update_time ?? '', raw: j }; +} + async function fetchMeteoFrance(): Promise { - const res = await fetchMF('/public/DPVigilance/v1/cartevigilance/encours'); - if (!res.ok) { - throw new Error(`MF cartevigilance failed: ${res.status} ${res.statusText}`); + const results = await Promise.all(Array.from({ length: MF_PARALLEL_FETCHES }, () => fetchMFOnce())); + const valid = results.filter((r): r is { update_time: string; raw: any } => r !== null && !!r.update_time); + if (valid.length === 0) { + throw new Error('MF cartevigilance failed: all parallel fetches errored'); } - const json = (await res.json()) as { + // Garde le plus récent (update_time est ISO comparable lexicographiquement) + valid.sort((a, b) => (a.update_time < b.update_time ? 1 : -1)); + const json = valid[0].raw as { product: { update_time: string; periods: Array<{ diff --git a/src/pages/api/health.ts b/src/pages/api/health.ts index 5cb0ecc..4d7512d 100644 --- a/src/pages/api/health.ts +++ b/src/pages/api/health.ts @@ -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. + }, }); }; diff --git a/src/pages/api/vigilance.ts b/src/pages/api/vigilance.ts deleted file mode 100644 index 72302e6..0000000 --- a/src/pages/api/vigilance.ts +++ /dev/null @@ -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' } }, - ); - } -}; diff --git a/src/pages/api/vigilance/dept/[code].ts b/src/pages/api/vigilance/dept/[code].ts deleted file mode 100644 index 304b2fb..0000000 --- a/src/pages/api/vigilance/dept/[code].ts +++ /dev/null @@ -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[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' } }, - ); - } -}; diff --git a/src/pages/dependances.astro b/src/pages/dependances.astro index ec973dd..a1d3da6 100644 --- a/src/pages/dependances.astro +++ b/src/pages/dependances.astro @@ -11,10 +11,9 @@ const devDeps = Object.entries(pkg.devDependencies as Record).so a.localeCompare(b), ); -// Catalogue manuel : description + license + URL pour chaque dépendance principale. const META: Record = { 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 = { 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 = { 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 = [ {DATA_SOURCES.map((s) => ( - + + + {s.name} + + {s.desc} + {s.license} + + ))} + + +
    + +
    +

    Services tiers

    + + + + + + + + + + {SERVICES.map((s) => ( + @@ -140,7 +205,7 @@ const INFRA = [ {deps.map(([name, version]) => { const m = META[name]; return ( - + @@ -161,6 +226,7 @@ const INFRA = [ + @@ -168,11 +234,12 @@ const INFRA = [ {devDeps.map(([name, version]) => { const m = META[name]; return ( - + + ); @@ -193,7 +260,7 @@ const INFRA = [ {INFRA.map((i) => ( - + diff --git a/src/pages/embed/index.astro b/src/pages/embed/index.astro index 8289784..cebc3a7 100644 --- a/src/pages/embed/index.astro +++ b/src/pages/embed/index.astro @@ -71,15 +71,13 @@ const snippet = `
    ServiceDescriptionLicence
    {s.name}
    {m ? {name} : name}
    Package VersionDescription Licence
    {m ? {name} : name} {version}{m?.desc ?? '—'} {m?.license ?? '—'}
    {i.name}