feat: graph T° interactif + widget iframe + MF auth + E2E Playwright
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Some checks are pending
Deploy info-canicule / deploy (push) Waiting to run
Graph T° (TemperatureChartInteractive.astro) : - Onglets 24 h / 7 j / 30 j (toggle JS, séries serialisées au SSR) - Hover vertical line + tooltip valeurs - Overlay normales mois en pointillé (TX orange, TN bleu) - Onglet 24 h dispo seulement si l'API MF a répondu (best-effort) Météo France OAuth2 (lib/meteofrance-auth.ts + observations.ts) : - client_credentials avec refresh auto, cache token Valkey - Fallback METEOFRANCE_STATIC_TOKEN pour debug - /synop endpoint pour 24h horaires par station SYNOP du dept - Mapping dept → station SYNOP la plus proche (src/data/stations-synop.json) - En attente de creds : SDK skip silencieusement, l'onglet 24h n'apparaît pas Widget iframe (/embed/dept/[code] + /embed doc) : - Layout minimal sans header/footer global - Réutilisable via iframe avec une ligne - Page /embed avec snippet copier-coller + aperçu live Tests E2E Playwright (tests/e2e/) : - home (carte 96 paths, tooltip dept, navigation) - api (health, vigilance, vigilance/dept) - departement (tabs période, DROM notice, 404) - static pages (a-propos, mentions, dependances, soutenir, conseils, embed) - embed widget (rendu minimal, headers X-Frame OK) - 20+ tests, run via pnpm test:e2e (live) ou test:e2e:local Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9cfd4f8385
commit
2c4d91ce2f
20 changed files with 922 additions and 467 deletions
89
src/lib/meteofrance-auth.ts
Normal file
89
src/lib/meteofrance-auth.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// OAuth2 client_credentials pour l'API Météo France (portail-api.meteofrance.fr).
|
||||
// Le token retourné dure ~1h, on le cache en mémoire process + Valkey jusqu'à 30 min avant expiration.
|
||||
//
|
||||
// Configuration via env :
|
||||
// METEOFRANCE_CLIENT_ID + METEOFRANCE_CLIENT_SECRET → mode OAuth2 (refresh auto)
|
||||
// METEOFRANCE_STATIC_TOKEN → fallback dev/test (1h)
|
||||
//
|
||||
// Le mode OAuth2 est obligatoire en prod ; le static_token est pour debug local.
|
||||
|
||||
import { cacheGet, cacheSet } from './cache';
|
||||
|
||||
const TOKEN_URL = 'https://portail-api.meteofrance.fr/token';
|
||||
const CACHE_KEY = 'mf:access_token';
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
let inMemory: CachedToken | null = null;
|
||||
|
||||
async function fetchFreshToken(): Promise<CachedToken> {
|
||||
const clientId = process.env.METEOFRANCE_CLIENT_ID;
|
||||
const clientSecret = process.env.METEOFRANCE_CLIENT_SECRET;
|
||||
const staticToken = process.env.METEOFRANCE_STATIC_TOKEN;
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
const body = new URLSearchParams({ grant_type: 'client_credentials' });
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${basic}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`MF token fetch failed: ${res.status} ${txt.slice(0, 200)}`);
|
||||
}
|
||||
const json = (await res.json()) as { access_token: string; expires_in: number };
|
||||
const expiresAtMs = Date.now() + (json.expires_in - 120) * 1000; // marge 2 min
|
||||
return { token: json.access_token, expiresAtMs };
|
||||
}
|
||||
|
||||
if (staticToken) {
|
||||
// Pas de moyen de décoder l'expiration sans parser le JWT ; on suppose 1h (à durcir si besoin).
|
||||
// Si le token est expiré, l'API retournera 401 et le caller pourra invalidate().
|
||||
return { token: staticToken, expiresAtMs: Date.now() + 50 * 60 * 1000 };
|
||||
}
|
||||
|
||||
throw new Error('No Météo France credentials in env (need METEOFRANCE_CLIENT_ID+SECRET or METEOFRANCE_STATIC_TOKEN)');
|
||||
}
|
||||
|
||||
export async function getAccessToken(forceRefresh = false): Promise<string> {
|
||||
if (!forceRefresh && inMemory && Date.now() < inMemory.expiresAtMs) {
|
||||
return inMemory.token;
|
||||
}
|
||||
if (!forceRefresh) {
|
||||
const cached = await cacheGet<CachedToken>(CACHE_KEY);
|
||||
if (cached && Date.now() < cached.expiresAtMs) {
|
||||
inMemory = cached;
|
||||
return cached.token;
|
||||
}
|
||||
}
|
||||
const fresh = await fetchFreshToken();
|
||||
inMemory = fresh;
|
||||
const ttl = Math.max(60, Math.floor((fresh.expiresAtMs - Date.now()) / 1000));
|
||||
await cacheSet(CACHE_KEY, fresh, ttl);
|
||||
return fresh.token;
|
||||
}
|
||||
|
||||
export async function fetchMF(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const base = 'https://public-api.meteofrance.fr';
|
||||
let token = await getAccessToken();
|
||||
let res = await fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
token = await getAccessToken(true);
|
||||
res = await fetch(`${base}${path}`, {
|
||||
...init,
|
||||
headers: { ...init.headers, Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue