Octane

OCTANE

Comparateur de prix de carburant en France, en temps réel.

Site statique qui interroge directement les APIs publiques :

Pas de backend, pas de base de données, pas de clé API côté navigateur. Seule exception : les alertes quotidiennes, envoyées par un cron GitHub Actions — un email planifié ne peut pas partir d’une page statique.

ℹ️ Les deux portails Opendatasoft (data.economie.gouv.fr + public.opendatasoft.com) refusaient autrefois les origins non-allowlistées (403 x-deny-reason: host_not_allowed), ce qui obligeait à router les appels via un proxy CORS public. Ils renvoient désormais Access-Control-Allow-Origin: * : les requêtes partent en direct depuis le navigateur, sans proxy ni clé. Le proxy a été retiré en septembre 2026 — corsproxy.io est passé en freemium (401 sans clé API) et faisait tomber l’app entière.

Une note sur le géocodage

BAN classe parfois une voie homonyme au-dessus de la commune cherchée, à un millième de score près : « Avignon » renvoyait la rue Avignon de Combourg (Ille-et-Vilaine) plutôt que la ville d’Avignon. Le client demande donc 5 résultats et, à score quasi équivalent, privilégie le type municipality. Sans risque pour les adresses précises : dès qu’une requête contient une voie ou un numéro, BAN ne remonte aucune commune dans son top 5.

L’ancienne adresse api-adresse.data.gouv.fr est fermée depuis le 31/01/2026 (en-têtes Sunset et Deprecation) ; Octane interroge la Géoplateforme de l’IGN depuis octobre 2026. C’est le même moteur : scores, types et format GeoJSON sont identiques, d’où une migration limitée à l’URL.

Récupération des stations

L’API Opendatasoft plafonne limit à 100 lignes par requête : une recherche à 50 km autour de Paris couvre 755 stations, dont seules 100 remontaient. Le client pagine donc sur offset (la 1re page fournit total_count, les suivantes partent en parallèle), jusqu’à un plafond de 400 stations — au-delà, la liste n’est plus exploitable et le compteur l’indique.

Les résultats sont triés côté serveur par prix croissant (order_by=<carburant>,id), ce qui rend la troncature inoffensive : ce sont les stations les plus chères qui sautent, jamais le classement du moins cher. Le id départage les ex æquo, faute de quoi la pagination pourrait dupliquer ou sauter des lignes.

Un select explicite limite la réponse aux champs réellement affichés : 76 Ko par page au lieu de 275 Ko, le reste étant des blobs JSON sérialisés (horaires, prix, rupture) et le découpage administratif.

Prix périmés

Le flux contient des stations qui ne déclarent plus : au 01/10/2026, « le moins cher » de Paris (5 km) était un gazole à 2,200 € relevé 184 jours plus tôt, devant des prix du jour à 2,250 €. Un prix non redéclaré depuis plus de 7 jours (STALE_DAYS) sort donc du classement : il ne peut plus être désigné gagnant, ne compte ni dans l’écart affiché ni dans le surcoût, et passe en fin de tableau sous un intercalaire « Hors classement », sans rang, avec sa date de relevé. Il reste visible, la station pouvant simplement n’avoir pas changé ses prix. Les alertes email sont plus strictes (3 jours) : elles désignent un seul gagnant, sans tableau pour nuancer.

Heures du flux

Le flux date ses relevés (*_maj, *_rupture_debut) à l’heure de Paris, mais les étiquette UTC : le 02/10/2026 à 15 h 46 UTC, le relevé le plus récent portait 17:17:39+00:00, deux heures dans le futur. parseFluxTime relit ces heures comme des heures de Paris (été comme hiver), seulement quand elles se disent UTC, pour ne pas décaler deux fois si le flux est corrigé.

Ruptures

Une station en rupture temporaire d’un carburant n’a plus de prix pour lui : la requête des prix (<carburant> IS NOT NULL) l’écarte. Une seconde requête, en parallèle, récupère les ruptures temporaires du rayon déclarées depuis moins de 30 jours (au-delà, la station a cessé de vendre ce carburant sans le déclarer ; le flux en garde depuis 2023). Elles sont comptées dans la ligne de méta, listées dans une carte repliable sous le classement et posées sur la carte en gris.

Trajet compris

Le classement reste au prix au litre. Mais avec un prix plafonné d’enseigne, la moins chère est souvent à 15 km quand une station à 2 km ne coûte que quelques centimes de plus : le bloc gagnant signale alors la station qui revient moins cher plein et aller-retour compris (écart d’au moins 0,50 €). Aller-retour depuis le point de recherche, par la route en mode voiture, sinon à vol d’oiseau × 1,3 (« environ ») ; consommation moyenne fixe de 6 L/100 km (CONSO_L_100KM, sans champ dans le formulaire) ; carburant du trajet payé au prix de la station.

Développement local

npx wrangler dev
# puis http://localhost:8787

wrangler dev reproduit Cloudflare à l’identique : URL sans .html, page 404, en-têtes de _headers (dont la CSP) et routes /api/* du Worker. Un simple python3 -m http.server -d public sert encore les fichiers, mais les liens internes, écrits sans extension (/alertes), y renvoient une 404.

Déploiement

Hébergé sur Cloudflare (Workers + fichiers statiques, offre gratuite), à l’adresse https://octane-carburant.fr. Le déploiement suit le dépôt via Workers Builds : chaque push sur main part en production, chaque autre branche reçoit une URL d’aperçu.

L’ancienne adresse leo-grnd.github.io/Octane/ redirige vers le nouveau domaine. GitHub Pages n’y publie plus le site mais des pages de redirection (.github/workflows/pages-redirect.yml, contenu généré par scripts/build-pages-redirect.mjs) : chaque ancien lien rouvre la même page et la même recherche (/Octane/index.html?q=Lyon&fuel=e10_prix → /?q=Lyon&fuel=e10_prix), toute autre adresse passe par 404.html, et un sw.js de désinstallation remplace le service worker de l’ancien site (caches vidés, onglets rechargés). Prérequis : Settings → Pages → Source = GitHub Actions.

Fichier Rôle
wrangler.jsonc Worker, dossier publié (public/), domaine
public/_headers En-têtes HTTP : CSP, HSTS, anti-iframe, permissions, CORS de la police
worker/index.js Code exécuté pour /api/* uniquement
public/sitemap.xml · robots.txt Indexation par les moteurs

Quelques règles qui en découlent :

Historique des prix (sparklines)

Chaque station affiche l’évolution de son prix sur les 100 derniers jours, calculée en temps réel à partir du dataset public prix-des-carburants-j-1 (public.opendatasoft.com, 12 mois glissants, Ministère de l’Économie).

La fenêtre est bornée en jours (update >= now(days=-100)), pas en nombre de lignes : le dataset a en principe une ligne par station et par jour, mais il monte parfois à quatre, si bien qu’un simple limit=100 ne couvrait pas 100 jours. Comme l’API plafonne limit à 100, le client pagine sur offset (3 pages au maximum, la plupart des stations tenant en une seule).

Pas de fichier généré, pas de cron : dès qu’une recherche retourne des stations, le client pré-charge l’historique de chacune en arrière-plan (4 requêtes en parallèle), dédupli­que les relevés consécutifs identiques, et met en cache le résultat dans localStorage (TTL 24 h).

La dédup conserve le dernier relevé même s’il répète le prix précédent : sans ça, la courbe s’arrêtait à la dernière variation de prix — parfois deux mois en arrière — et l’axe des dates affichait une plage trompeuse.

Si le dataset ne retourne pas assez de points pour une station, le client affiche simplement « Historique indisponible ».

Alertes quotidiennes

Chaque matin à l’heure choisie, un email indique la station la moins chère d’une zone pour un carburant donné — un rayon autour d’une adresse, ou la France entière.

alertes.html sert à composer une alerte : elle affiche un aperçu en direct du podium du jour, puis produit la configuration JSON à transmettre. L’inscription n’est pas automatique, faute de backend : la liste des abonnés vit dans un secret de dépôt.

Fonctionnement

.github/workflows/daily-alerts.yml est programmé toutes les heures et exécute scripts/send-alerts.mjs (Node 20, aucune dépendance npm — fetch est natif). Le script retient les abonnés dont l’heure d’envoi est passée depuis moins de 6 heures (heure de Paris, calculée via Intl, donc l’heure d’été est gérée sans logique maison) et qui n’ont rien reçu aujourd’hui, regroupe les abonnés partageant la même zone pour ne faire qu’un appel API, puis envoie via l’API HTTP de Brevo.

Le dépôt étant public, l’état persisté (data/alerts/state.json) ne contient que des empreintes SHA-256 : ni email ni coordonnées en clair.

Deux garde-fous méritent d’être connus :

Le cron « horaire » de GitHub n’est qu’indicatif : sur septembre 2026 il n’a tourné que 3 à 7 fois par jour, avec des trous allant jusqu’à 6 h 44. D’où la fenêtre de 6 heures : une alerte réglée sur 8 h part au premier run entre 8 h et 14 h. La garde « au plus un envoi par abonné et par jour » empêche qu’un run suivant ou rejoué envoie deux fois le même email. La fenêtre ne franchit pas minuit.

Configuration

Trois secrets dans Settings → Secrets and variables → Actions :

Secret Rôle
BREVO_API_KEY clé API Brevo (SMTP & API → API Keys)
BREVO_SENDER adresse expéditrice validée chez Brevo
OCTANE_ALERTS tableau JSON des abonnements

Deux variables optionnelles : SITE_URL (défaut https://octane-carburant.fr/) et BREVO_SENDER_NAME (défaut Octane).

Format d’OCTANE_ALERTS :

[
  { "email": "moi@exemple.fr", "fuel": "gazole_prix", "hour": 8, "scope": "france" },
  { "email": "moi@exemple.fr", "fuel": "e10_prix", "hour": 7, "scope": "radius",
    "lat": 43.93635, "lon": 4.84886, "radius": 15, "label": "Avignon" }
]

fuel ∈ gazole_prix · e10_prix · sp95_prix · sp98_prix · e85_prix · gplc_prix. hour ∈ 0–23 (heure de Paris). radius ∈ 1–50 km. Une entrée invalide est signalée dans les logs et ignorée, sans empêcher les autres abonnés de recevoir leur alerte — le run sort malgré tout en échec pour que le problème soit visible.

Tester

OCTANE_ALERTS='[{"email":"moi@exemple.fr","fuel":"gazole_prix","hour":8,"scope":"france"}]' \
  node scripts/send-alerts.mjs --dry-run --force-hour=8

--dry-run affiche les emails sans rien envoyer ni écrire d’état, --force-hour=N simule l’heure de Paris, --force ignore la garde anti-doublon.

Côté GitHub : onglet Actions → Daily fuel alerts → Run workflow, avec dry_run coché pour un test à blanc. ⚠️ Un workflow planifié ne s’exécute que s’il est présent sur la branche par défaut : le cron ne démarrera qu’une fois poussé sur main.

Base de marques OSM

Pour éviter d’appeler Overpass au runtime (latence + dépendance à des miroirs pas toujours dispo), on scrape une fois toutes les stations amenity=fuel de France avec leur tag brand/operator/name, et on ship le résultat dans public/data/osm/brands.json. Le client le charge une seule fois par session et cherche la marque la plus proche (≤ 150 m) en local.

brands.json est une base dérivée d’OpenStreetMap : elle est diffusée sous ODbL 1.0 (© contributeurs OpenStreetMap), comme l’indiquent la page mentions légales et l’attribution de la carte.

Il n’y a aucun appel Overpass depuis le navigateur. Un ancien fallback interrogeait les instances publiques à chaque recherche où une enseigne manquait ; il a été retiré avant la mise en ligne publique, leurs politiques d’usage proscrivant ce trafic dès qu’il devient massif. Une station absente de la base garde un affichage complet, simplement sans enseigne.

Rafraîchir localement (Node) :

node scripts/build-brands.mjs

Alternative Python (stdlib uniquement) :

python3 scripts/build_brands.py

Automatisation : le workflow .github/workflows/build-brands.yml tourne le 1er de chaque mois à 04:00 UTC (les marques OSM bougent lentement). Déclenchable manuellement via l’onglet Actions → Refresh OSM brands → Run workflow.

Noms, enseignes et adresses officiels

Le flux des prix exclut le nom et l’enseigne des stations, et ses adresses sont brutes (« 55 BLD DE PICPUS »). Le site gouvernemental prix-carburants.gouv.fr affiche nom et enseigne sur la fiche de chaque point de vente (/map/recuperer_infos_pdv/{id}). scripts/build-stations.mjs :

  1. liste les stations du flux (un export Opendatasoft) ;
  2. lit leur fiche officielle, à un rythme modéré (2 requêtes en parallèle, pause de 250 ms, User-Agent identifiable : ~30 min pour ~9 800 fiches) ; une fiche illisible garde l’entrée du mois précédent ;
  3. normalise les adresses : abréviations développées, puis géocodage IGN en un lot CSV (data.geopf.fr/geocodage/search/csv), retenu seulement si le score atteint 0,8 et que type de voie et mots du nom concordent — sans ce garde-fou, « 55 BLD DE PICPUS » devenait « Rue de Picpus » et « Route de Saint-Quentin », « Route de Tullins » ;
  4. écrit public/data/stations/{2 premiers chiffres de l'id sur 8}.json = { "75012021": ["Relais du Bel Air", "TotalEnergies", "55 Boulevard de Picpus"] }.

Le client ne charge que les tranches des stations trouvées (quelques Ko). Priorité de l’enseigne : fiche officielle, puis OSM, puis motifs sur l’adresse. Le nom de la station n’apparaît que dans le bloc gagnant et la fiche détail, et seulement s’il apprend quelque chose (ni raison sociale, ni répétition de l’enseigne et de la ville).

node scripts/build-stations.mjs                 # collecte complète
node scripts/build-stations.mjs --limit=200     # essai
node scripts/build-stations.mjs --skip-pages    # adresses seules, noms repris des tranches

Automatisation : .github/workflows/build-stations.yml, le 2 de chaque mois à 04:00 UTC, et manuellement via Actions → Refresh official station names → Run workflow.

Design system

L’interface suit la maquette « Octane Accueil v3 » de Claude Design (octobre 2026). Elle garde du système Modernist précédent la police (Archivo) et l’accent rouge (#ec3013), mais passe aux angles arrondis, à des cartes blanches ombrées posées sur un fond zinc (#f7f7f8), et à des pastilles. La maquette ne dessinait que l’accueil : Alertes, Comment ça marche, les mentions légales et la page 404 en transposent le langage. Trois écarts volontaires avec la maquette : l’accueil n’a pas de carte « Source officielle » à droite du titre (la colonne reste vide), l’onglet Historique est conservé à côté de Liste et Carte, et le bandeau « Écart dans ton rayon » passe sous le panneau de l’onglet au lieu de suivre le bloc gagnant.

Ressources tierces servies par le site

Aucune ressource n’est chargée depuis un CDN : police et cartographie sont copiées dans le dépôt. Pas d’adresse IP de visiteur transmise à Google (la jurisprudence européenne a jugé ce transfert contraire au RGPD), pas de panne d’unpkg qui casserait la carte, et la condition d’une politique de sécurité de contenu (CSP) stricte.

Ressource Emplacement Version Licence
Archivo (police variable 400-800, latin + latin étendu) public/fonts/ Google Fonts v25 SIL OFL 1.1
Leaflet public/vendor/leaflet/ 1.9.4 BSD-2
Leaflet.markercluster public/vendor/leaflet.markercluster/ 1.5.3 MIT

Les fichiers Leaflet gardent leur attribut integrity (SRI), identique à celui de la version officielle sur unpkg. Pour monter de version : retélécharger les fichiers dist/ dans le dossier correspondant, mettre à jour le hash SRI et le tableau ci-dessus, puis bumper le SW.

Icônes et aperçu de partage

Le logo est une jauge (arc rouge, aiguille et moyeu à l’encre) qui tient lieu de « O » dans « CTANE ». public/favicon.svg est la jauge seule, sur fond transparent, avec une aiguille claire quand le navigateur est en thème sombre. Les icônes d’application partent d’une autre source, scripts/brand/app-icon.svg : fond blanc jusqu’aux bords (iOS remplit de noir un coin transparent) et jauge dans la zone sûre des icônes « maskable ». og-image.png reprend le hero de l’accueil. Les réseaux sociaux n’affichent pas d’aperçu SVG et iOS ignore une apple-touch-icon SVG, d’où des PNG, générés par le navigateur déjà installé :

node scripts/render-brand.mjs

À relancer après toute retouche d’une source de scripts/brand/, puis committer les images. Le même script produit backdrop.webp, le fond de carte du haut des pages : la page scripts/brand/backdrop.html assemble les tuiles Plan IGN et encode elle-même l’image, que le script récupère par le protocole DevTools (node scripts/render-brand.mjs backdrop pour ne régénérer qu’elle). Le script vérifie les dimensions de chaque image produite. og:image et canonical exigent des URL absolues, sur https://octane-carburant.fr/ : à mettre à jour avec le sitemap.xml si le domaine change un jour.

Fichiers

Publié — public/ (tout ce dossier, et lui seul, est servi par Cloudflare) :

Fichier Rôle
index.html · app.js · style.css L’outil : géocodage, appels API, rendu, cache, historique
alertes.html · .css · .js Composition d’une alerte quotidienne + aperçu du jour — en sommeil : aucun lien n’y mène, hors sitemap, noindex
comment-ca-marche.html · .css · .js Page d’explication et ses animations
mentions-legales.html · .css Mentions légales (LCEN), confidentialité (RGPD), licences, conditions d’utilisation
404.html Page d’erreur, servie pour toute adresse inconnue
design-system.css Design system v3 (maquette Claude Design) : tokens clair et sombre, composants, en-tête, hero, pied de page
theme-init.js · site.js Thème avant le premier rendu · bascule du thème, sur toutes les pages
backdrop.webp Fond de carte du haut des pages (Plan IGN en gris) — généré
sw.js · manifest.webmanifest Service worker et manifest PWA
fonts/ · vendor/ Archivo · Leaflet et markercluster, avec leurs licences
favicon.svg Favicon : la jauge du logo, fond transparent, variante sombre
apple-touch-icon.png · icons/ Icônes iOS (180) et PWA (192, 512, aussi « maskable ») — générées
og-image.png Aperçu de partage 1200 × 630 (réseaux sociaux, messageries) — généré
data/osm/brands.json Base des marques OSM (générée chaque mois par la CI, diffusée sous ODbL)
data/stations/ Noms, enseignes et adresses officiels, en tranches par préfixe d’identifiant (générés chaque mois par la CI)
_headers En-têtes HTTP (CSP, HSTS…) — lu par Cloudflare, jamais servi lui-même
sitemap.xml · robots.txt Indexation par les moteurs

Non publié :

Fichier Rôle
wrangler.jsonc · worker/ Configuration Cloudflare · code des routes /api/*
scripts/render-brand.mjs · scripts/brand/ Rendu des images générées via Chrome/Edge headless (sans dépendance)
scripts/build-brands.mjs · build_brands.py Scrape OSM → public/data/osm/brands.json (Node ou Python stdlib)
scripts/build-stations.mjs Fiches officielles + adresses IGN → public/data/stations/ (Node, sans dépendance)
scripts/send-alerts.mjs Envoi des alertes quotidiennes (Node, sans dépendance)
data/alerts/state.json Prix de la veille + anti-doublon (empreintes, écrit par la CI)
.github/workflows/build-brands.yml Cron mensuel GHA (marques)
.github/workflows/build-stations.yml Cron mensuel GHA (noms, enseignes et adresses officiels)
.github/workflows/daily-alerts.yml Cron horaire GHA (alertes)
.github/workflows/pages-redirect.yml · scripts/build-pages-redirect.mjs Redirections de l’ancienne adresse GitHub Pages

Mentions légales — à tenir à jour. Toute nouvelle donnée collectée, nouveau stockage local ou nouveau service tiers appelé par le navigateur doit y être ajouté, et la date de mise à jour en tête de page modifiée. Un champ provisoire se balise <mark class="todo"> : il s’affiche en rouge tant qu’il n’est pas rempli, pour ne pas partir en ligne par mégarde.

sw.js met en cache le shell de l’app : bumper VERSION à chaque release, sinon les navigateurs déjà venus servent l’ancienne version. Tout nouveau fichier de shell doit aussi être ajouté au tableau SHELL.