feat: retire API publique + dedupe load-balancing MF + maj pages
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
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:
parent
0a1f11aa00
commit
27441cdbb8
10 changed files with 139 additions and 148 deletions
|
|
@ -136,7 +136,6 @@ const jsonLd = {
|
||||||
<li><a href="/mentions-legales">Mentions légales</a></li>
|
<li><a href="/mentions-legales">Mentions légales</a></li>
|
||||||
<li><a href="/dependances">Dépendances</a></li>
|
<li><a href="/dependances">Dépendances</a></li>
|
||||||
<li><a href="/soutenir">☕ Soutenir sur Ko-fi</a></li>
|
<li><a href="/soutenir">☕ Soutenir sur Ko-fi</a></li>
|
||||||
<li><a href="/api/vigilance">API JSON publique</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -80,12 +80,32 @@ async function fetchOpendatasoft(): Promise<VigilanceSnapshot> {
|
||||||
// --- Provider : API Météo France officielle (DPVigilance/v1) ---
|
// --- Provider : API Météo France officielle (DPVigilance/v1) ---
|
||||||
// Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines
|
// Plus frais qu'Opendatasoft (publication directe) et couvre tous les domaines
|
||||||
// (métropole, côtes, prochainement Outre-mer via /vigilanceom/flux/dernier).
|
// (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<VigilanceSnapshot> {
|
async function fetchMeteoFrance(): Promise<VigilanceSnapshot> {
|
||||||
const res = await fetchMF('/public/DPVigilance/v1/cartevigilance/encours');
|
const results = await Promise.all(Array.from({ length: MF_PARALLEL_FETCHES }, () => fetchMFOnce()));
|
||||||
if (!res.ok) {
|
const valid = results.filter((r): r is { update_time: string; raw: any } => r !== null && !!r.update_time);
|
||||||
throw new Error(`MF cartevigilance failed: ${res.status} ${res.statusText}`);
|
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: {
|
product: {
|
||||||
update_time: string;
|
update_time: string;
|
||||||
periods: Array<{
|
periods: Array<{
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,11 @@ export const GET: APIRoute = async () => {
|
||||||
};
|
};
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
status: cacheOk ? 200 : 503,
|
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.
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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' } },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -11,10 +11,9 @@ const devDeps = Object.entries(pkg.devDependencies as Record<string, string>).so
|
||||||
a.localeCompare(b),
|
a.localeCompare(b),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Catalogue manuel : description + license + URL pour chaque dépendance principale.
|
|
||||||
const META: Record<string, { desc: string; license: string; url: string }> = {
|
const META: Record<string, { desc: string; license: string; url: string }> = {
|
||||||
astro: {
|
astro: {
|
||||||
desc: "Framework web orienté contenu (rendu serveur, île d'interactivité).",
|
desc: "Framework web orienté contenu (rendu serveur, îlots d'interactivité).",
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
url: 'https://astro.build/',
|
url: 'https://astro.build/',
|
||||||
},
|
},
|
||||||
|
|
@ -28,21 +27,46 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
url: 'https://docs.astro.build/en/guides/integrations-guide/tailwind/',
|
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: {
|
tailwindcss: {
|
||||||
desc: 'Framework CSS utility-first.',
|
desc: 'Framework CSS utility-first.',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
url: 'https://tailwindcss.com/',
|
url: 'https://tailwindcss.com/',
|
||||||
},
|
},
|
||||||
ioredis: {
|
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',
|
license: 'MIT',
|
||||||
url: 'https://github.com/redis/ioredis',
|
url: 'https://github.com/redis/ioredis',
|
||||||
},
|
},
|
||||||
'@astrojs/check': {
|
'@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',
|
license: 'MIT',
|
||||||
url: 'https://docs.astro.build/en/reference/cli-reference/#astro-check',
|
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': {
|
'@types/node': {
|
||||||
desc: 'Types TypeScript pour les API Node.js.',
|
desc: 'Types TypeScript pour les API Node.js.',
|
||||||
license: 'MIT',
|
license: 'MIT',
|
||||||
|
|
@ -57,32 +81,49 @@ const META: Record<string, { desc: string; license: string; url: string }> = {
|
||||||
|
|
||||||
const DATA_SOURCES = [
|
const DATA_SOURCES = [
|
||||||
{
|
{
|
||||||
name: 'France GeoJSON',
|
name: 'Météo France — API Vigilance officielle (DPVigilance)',
|
||||||
desc: "Polygones des départements français (utilisés pour la carte SVG).",
|
desc: "Bulletin Vigilance en temps réel, métropole, publication 2× par jour + complémentaires sur rouge.",
|
||||||
license: 'Licence Ouverte 2.0',
|
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',
|
name: 'Météo France — API Observation officielle (DPObs)',
|
||||||
desc: 'Bulletin Vigilance en temps réel via Opendatasoft.',
|
desc: "Observations horaires SYNOP des stations principales (températures, vent, pression). Onglet 24 h sur les pages département.",
|
||||||
license: 'Licence Ouverte 2.0',
|
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',
|
name: 'Météo France — Climatologie de base quotidienne',
|
||||||
desc: 'Données climatologiques de base quotidiennes (températures, précipitations).',
|
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',
|
license: 'Licence Ouverte 2.0',
|
||||||
url: 'https://www.data.gouv.fr/datasets/donnees-climatologiques-de-base-quotidiennes',
|
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 = [
|
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: '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: '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).', license: 'BSD-3-Clause', url: 'https://valkey.io/' },
|
{ 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é).', license: 'MIT', url: 'https://umami.is/' },
|
{ name: 'Umami', desc: 'Analytics RGPD-friendly (auto-hébergé, sans cookies).', license: 'MIT', url: 'https://umami.is/' },
|
||||||
{ name: 'Forgejo', desc: 'Forge git auto-hébergée + CI/CD.', license: 'MIT', url: 'https://forgejo.org/' },
|
{ 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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{DATA_SOURCES.map((s) => (
|
{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">
|
<td class="px-3 py-2 font-medium">
|
||||||
<a href={s.url} rel="noopener">{s.name}</a>
|
<a href={s.url} rel="noopener">{s.name}</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -140,7 +205,7 @@ const INFRA = [
|
||||||
{deps.map(([name, version]) => {
|
{deps.map(([name, version]) => {
|
||||||
const m = META[name];
|
const m = META[name];
|
||||||
return (
|
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">
|
<td class="px-3 py-2 font-mono">
|
||||||
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
|
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -161,6 +226,7 @@ const INFRA = [
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2">Package</th>
|
<th class="px-3 py-2">Package</th>
|
||||||
<th class="px-3 py-2">Version</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>
|
<th class="px-3 py-2">Licence</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -168,11 +234,12 @@ const INFRA = [
|
||||||
{devDeps.map(([name, version]) => {
|
{devDeps.map(([name, version]) => {
|
||||||
const m = META[name];
|
const m = META[name];
|
||||||
return (
|
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">
|
<td class="px-3 py-2 font-mono">
|
||||||
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
|
{m ? <a href={m.url} rel="noopener">{name}</a> : name}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-3 py-2 text-slate-600">{version}</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>
|
<td class="px-3 py-2 text-slate-600">{m?.license ?? '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -193,7 +260,7 @@ const INFRA = [
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{INFRA.map((i) => (
|
{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">
|
<td class="px-3 py-2 font-medium">
|
||||||
<a href={i.url} rel="noopener">{i.name}</a>
|
<a href={i.url} rel="noopener">{i.name}</a>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,13 @@ const snippet = `<iframe
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<p class="mt-2 text-sm text-slate-700">
|
||||||
Si tu préfères afficher la Vigilance avec ton propre design, l'endpoint
|
Ce site ne fournit pas d'API JSON publique. Si tu veux requêter la Vigilance Météo France
|
||||||
<code class="rounded bg-slate-100 px-1">/api/vigilance/dept/[code]</code>
|
de manière programmatique, inscris-toi directement sur le
|
||||||
renvoie un JSON CORS *, prêt à parser :
|
<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>
|
</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 => r.json())
|
|
||||||
.then(data => console.log(data.today)); // [{ phenomenon, color, ... }]</code></pre>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,9 @@ export const prerender = false;
|
||||||
Météo France et la licence.
|
Météo France et la licence.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
L'endpoint <code>/api/vigilance</code> diffuse le snapshot courant en JSON (CORS *), pour
|
Pour consulter la Vigilance Météo France de manière programmatique, utilisez directement
|
||||||
réutilisation par tout site ou application qui le souhaite.
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</Base>
|
</Base>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const prerender = false;
|
||||||
<h1 class="text-3xl font-bold sm:text-4xl">Soutenir Info Canicule</h1>
|
<h1 class="text-3xl font-bold sm:text-4xl">Soutenir Info Canicule</h1>
|
||||||
<p class="mt-2 max-w-2xl text-slate-600">
|
<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,
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -38,7 +38,7 @@ export const prerender = false;
|
||||||
|
|
||||||
<h2>Ce que les dons financent</h2>
|
<h2>Ce que les dons financent</h2>
|
||||||
<ul>
|
<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>Nom de domaine annuel.</li>
|
||||||
<li>Café pour les soirées de maintenance 🥲.</li>
|
<li>Café pour les soirées de maintenance 🥲.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -59,10 +59,6 @@ export const prerender = false;
|
||||||
(proches âgés, personnes isolées) pour qui les conseils canicule peuvent faire une vraie
|
(proches âgés, personnes isolées) pour qui les conseils canicule peuvent faire une vraie
|
||||||
différence.
|
différence.
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
|
|
||||||
<p class="text-sm text-slate-500">
|
<p class="text-sm text-slate-500">
|
||||||
|
|
|
||||||
|
|
@ -3,35 +3,26 @@ import { test, expect } from '@playwright/test';
|
||||||
test.describe('API', () => {
|
test.describe('API', () => {
|
||||||
test('/api/health retourne status ok et cache true', async ({ request }) => {
|
test('/api/health retourne status ok et cache true', async ({ request }) => {
|
||||||
const res = await request.get('/api/health');
|
const res = await request.get('/api/health');
|
||||||
expect(res.status()).toBeLessThan(600); // accepte 200 ou 503 selon Valkey
|
expect(res.status()).toBeLessThan(600);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body).toHaveProperty('status');
|
expect(body).toHaveProperty('status');
|
||||||
expect(body).toHaveProperty('cache');
|
expect(body).toHaveProperty('cache');
|
||||||
expect(body).toHaveProperty('time');
|
expect(body).toHaveProperty('time');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/api/vigilance retourne snapshot JSON valide CORS *', async ({ request }) => {
|
test('/api/health pas de CORS (interne uniquement)', async ({ request }) => {
|
||||||
|
const res = await request.get('/api/health');
|
||||||
|
const cors = res.headers()['access-control-allow-origin'];
|
||||||
|
expect(cors).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('/api/vigilance retourne 404 (API publique retirée)', async ({ request }) => {
|
||||||
const res = await request.get('/api/vigilance');
|
const res = await request.get('/api/vigilance');
|
||||||
expect(res.status()).toBe(200);
|
expect(res.status()).toBe(404);
|
||||||
expect(res.headers()['access-control-allow-origin']).toBe('*');
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body).toHaveProperty('fetchedAt');
|
|
||||||
expect(body).toHaveProperty('alerts');
|
|
||||||
expect(Array.isArray(body.alerts)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/api/vigilance/dept/13 retourne enrichi avec labels', async ({ request }) => {
|
test('/api/vigilance/dept/75 retourne 404 (API publique retirée)', async ({ request }) => {
|
||||||
const res = await request.get('/api/vigilance/dept/13');
|
const res = await request.get('/api/vigilance/dept/75');
|
||||||
expect(res.status()).toBe(200);
|
|
||||||
const body = await res.json();
|
|
||||||
expect(body.departement.code).toBe('13');
|
|
||||||
expect(body.departement.name).toBe('Bouches-du-Rhône');
|
|
||||||
expect(body).toHaveProperty('today');
|
|
||||||
expect(body).toHaveProperty('tomorrow');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('/api/vigilance/dept/999 retourne 404', async ({ request }) => {
|
|
||||||
const res = await request.get('/api/vigilance/dept/999');
|
|
||||||
expect(res.status()).toBe(404);
|
expect(res.status()).toBe(404);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue