feat: derive event publication status from the mairie agenda

Pure matching (date locale Cugnaux + titre normalisé) and paginated
transport, both fully tested. HTML extraction via DOMParser stays impure
and untested (absent from Bun), like formulaire-mairie.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-07-17 18:18:15 +02:00
co-authored by Claude Opus 4.8
parent a7b780876b
commit 6c4f4219f1
4 changed files with 424 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
// Transport + pagination + extraction DOM de l'agenda public de la mairie.
// Séparation nette : les parties PURES (calculerPeriode, construireUrlPage) et
// la boucle (recupererAgendaPublie, testée via impls injectées) sont testables ;
// extraireCartes utilise DOMParser (absent de Bun) et n'est PAS testé
// unitairement — validé par le test humain, comme formulaire-mairie.js.
const BASE = "https://www.ville-cugnaux.fr/mes-loisirs/agenda/";
const FUSEAU = "Europe/Paris";
// Sécurité anti-boucle : l'agenda ne renverra jamais 12 pages pleines pour nos
// événements, mais une structure HTML changée pourrait empêcher la détection de
// page vide → on plafonne pour ne jamais boucler indéfiniment.
const PLAFOND_PAGES = 12;
const FORMAT_MOIS = new Intl.DateTimeFormat("fr-FR", {
timeZone: FUSEAU,
year: "numeric",
month: "2-digit",
});
// Fenêtre à interroger : du mois courant au mois du dernier événement à venir.
// `aujourdhui` injecté (obligatoire) pour des tests déterministes.
export function calculerPeriode(evenements, aujourdhui) {
const dernier = evenements.reduce(
(max, ev) => (ev.debut.getTime() > max.getTime() ? ev.debut : max),
aujourdhui,
);
return { debut: moisAnnee(aujourdhui), fin: moisAnnee(dernier) };
}
function moisAnnee(date) {
const parts = FORMAT_MOIS.formatToParts(date);
const mois = parts.find((p) => p.type === "month").value;
const annee = parts.find((p) => p.type === "year").value;
return `${mois}/${annee}`;
}
// Page 1 = URL de base ; page N>1 = segment /page/N/. Le « MM/YYYY » est encodé
// en « MM%2FYYYY » par encodeURIComponent.
export function construireUrlPage(n, periode) {
const segment = n > 1 ? `page/${n}/` : "";
const requete =
`?f=1&theme=&date=periode` +
`&date_debut=${encodeURIComponent(periode.debut)}` +
`&date_fin=${encodeURIComponent(periode.fin)}` +
`&public=`;
return `${BASE}${segment}${requete}`;
}
// Parcourt les pages jusqu'à une page vide (ou le plafond). Toute erreur (throw,
// réponse non-ok) → { ok:false } : la liste s'affiche alors sans badge
// (dégradation gracieuse). `fetchImpl` et `extraireCartesImpl` injectés pour
// tester la boucle sans DOM ni réseau.
export async function recupererAgendaPublie(fetchImpl, extraireCartesImpl, periode) {
const cartes = [];
try {
for (let n = 1; n <= PLAFOND_PAGES; n++) {
const res = await fetchImpl(construireUrlPage(n, periode));
if (!res.ok) {
console.error(`Écho du Huit : agenda mairie a répondu ${res.status}.`);
return { ok: false };
}
const cartesPage = extraireCartesImpl(await res.text());
if (cartesPage.length === 0) break;
cartes.push(...cartesPage);
}
return { ok: true, cartes };
} catch (erreur) {
console.error("Écho du Huit : agenda mairie injoignable.", erreur);
return { ok: false };
}
}
// Extraction DOM (IMPUR : DOMParser). Chaque carte incomplète (date, titre ou
// lien manquant) est IGNORÉE — jamais d'exception. Le lien est LU dans la carte,
// jamais reconstruit (le slug porte des suffixes WordPress, ex. « -2 »).
export function extraireCartes(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const cartes = [];
// Sélecteur agnostique de balise : l'agenda sert des <article class="… card-event">,
// pas des <div>. `div.card-event` ne matcherait rien (panne silencieuse).
for (const carte of doc.querySelectorAll(".card-event")) {
const date = carte.querySelector("time[datetime]")?.getAttribute("datetime");
const ancre = carte.querySelector(".card-title a");
const titre = ancre?.textContent?.trim();
const lien = normaliserLien(ancre?.getAttribute("href"));
if (!date || !titre || !lien) continue;
cartes.push({ date, titre, lien });
}
return cartes;
}
// L'agenda sert le href brut, parfois RELATIF (« /agenda/slug/ ») : résolu contre
// la page d'extension (moz-extension://), ce serait un lien mort. On le résout
// contre l'agenda et on rejette tout schéma autre que http(s) — le lien vient
// d'une source externe et finit en `href`. Href absurde → carte ignorée.
function normaliserLien(href) {
if (!href) return null;
try {
const url = new URL(href, BASE);
return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
} catch {
return null;
}
}
+66
View File
@@ -0,0 +1,66 @@
// Matching événement Nextcloud ↔ carte de l'agenda mairie. Fonctions PURES,
// sans DOM (testables) : c'est le cœur du risque produit (précision du match,
// fuseau, normalisation du titre). Biais PRÉCISION v1 : dans le doute, on
// n'affirme pas « publié » (faux négatif bénin, cf. plan / DECISIONS.md).
const FUSEAU = "Europe/Paris";
// en-CA rend directement "YYYY-MM-DD" : même forme que le <time datetime> de
// l'agenda, comparable sans reformatage.
const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat("en-CA", {
timeZone: FUSEAU,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// Forme stable d'un titre : ce qui doit matcher malgré les écarts cosmétiques
// entre le calendrier et la mairie. On RETIRE la ponctuation (sans la remplacer
// par une espace) pour que « Atelier B.D. » == « Atelier BD ».
export function normaliserTitre(titre) {
return titre
.normalize("NFD")
.replace(/[̀-ͯ]/gu, "") // diacritiques isolés par NFD
.replace(/[^\p{Letter}\p{Number}\s]/gu, "") // ponctuation ET emojis
.toLowerCase()
.replace(/\s+/gu, " ")
.trim();
}
// Le <time datetime> de l'agenda est une date NUE (pas d'heure) : on la compare
// à la date locale de Cugnaux du début, jamais à une conversion de fuseau.
export function dateLocaleCugnaux(evenement) {
return evenement.journeeEntiere
? formaterDateFlottante(evenement.debut)
: FORMAT_JOUR_PARIS.format(evenement.debut);
}
// Journée entière = date flottante (RFC 5545) : composantes LOCALES, jamais un
// fuseau, sinon la date glisse d'un jour (cf. presentation.js / DECISIONS.md).
function formaterDateFlottante(date) {
const annee = date.getFullYear();
const mois = String(date.getMonth() + 1).padStart(2, "0");
const jour = String(date.getDate()).padStart(2, "0");
return `${annee}-${mois}-${jour}`;
}
// Index des cartes par `date|titre normalisé` → lien. Deux cartes de même clé
// (même jour + même titre normalisé, rare) : la DERNIÈRE indexée l'emporte.
export function indexerCartes(cartes) {
const index = new Map();
for (const carte of cartes) {
index.set(cle(carte.date, carte.titre), carte.lien);
}
return index;
}
// Match STRICT : même date locale ET titre normalisé identique. Rien d'autre ne
// vaut « publié » en v1.
export function trouverPublication(evenement, index) {
const lien = index.get(cle(dateLocaleCugnaux(evenement), evenement.titre));
return lien ? { publie: true, lien } : { publie: false };
}
function cle(date, titre) {
return `${date}|${normaliserTitre(titre)}`;
}