From 6c4f4219f102a44c87a8333fc36b7d01d44c6fa8 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 18:02:32 +0200 Subject: [PATCH] feat: derive event publication status from the mairie agenda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- agenda-mairie.test.ts | 117 ++++++++++++++++++++++++++++++++ extension/agenda-mairie.js | 106 +++++++++++++++++++++++++++++ extension/publication.js | 66 ++++++++++++++++++ publication.test.ts | 135 +++++++++++++++++++++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 agenda-mairie.test.ts create mode 100644 extension/agenda-mairie.js create mode 100644 extension/publication.js create mode 100644 publication.test.ts diff --git a/agenda-mairie.test.ts b/agenda-mairie.test.ts new file mode 100644 index 0000000..26afc41 --- /dev/null +++ b/agenda-mairie.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { + calculerPeriode, + construireUrlPage, + recupererAgendaPublie, +} from "./extension/agenda-mairie.js"; + +function evenement(surcharges: Record = {}) { + return { + uid: "uid-1", + titre: "Atelier", + debut: new Date("2026-07-10T16:00:00Z"), + fin: new Date("2026-07-10T18:00:00Z"), + journeeEntiere: false, + ...surcharges, + }; +} + +describe("calculerPeriode", () => { + test("debut = mois d'aujourd'hui ; fin = mois du dernier début", () => { + const evenements = [ + evenement({ debut: new Date("2026-07-20T10:00:00Z") }), + evenement({ debut: new Date("2026-09-05T10:00:00Z") }), + evenement({ debut: new Date("2026-08-01T10:00:00Z") }), + ]; + const aujourdhui = new Date("2026-07-17T09:00:00Z"); + + expect(calculerPeriode(evenements, aujourdhui)).toEqual({ + debut: "07/2026", + fin: "09/2026", + }); + }); + + test("liste vide → période bornée au mois courant", () => { + const aujourdhui = new Date("2026-07-17T09:00:00Z"); + expect(calculerPeriode([], aujourdhui)).toEqual({ debut: "07/2026", fin: "07/2026" }); + }); +}); + +describe("construireUrlPage", () => { + const periode = { debut: "07/2026", fin: "09/2026" }; + + test("page 1 → URL de base sans segment /page/", () => { + const url = construireUrlPage(1, periode); + expect(url).toBe( + "https://www.ville-cugnaux.fr/mes-loisirs/agenda/?f=1&theme=&date=periode&date_debut=07%2F2026&date_fin=09%2F2026&public=", + ); + }); + + test("page 3 → segment /page/3/", () => { + const url = construireUrlPage(3, periode); + expect(url).toContain("/mes-loisirs/agenda/page/3/?f=1"); + expect(url).toContain("date_debut=07%2F2026"); + expect(url).toContain("date_fin=09%2F2026"); + }); +}); + +describe("recupererAgendaPublie", () => { + const periode = { debut: "07/2026", fin: "09/2026" }; + const reponseOk = (html: string) => Promise.resolve({ ok: true, text: () => Promise.resolve(html) }); + + test("3 pages non vides puis une vide → cartes agrégées, arrêt sur la vide", async () => { + const pages = [ + [{ date: "2026-07-01", titre: "A", lien: "l1" }], + [{ date: "2026-07-02", titre: "B", lien: "l2" }], + [{ date: "2026-07-03", titre: "C", lien: "l3" }], + [], + ]; + const fetchImpl = (url: string) => reponseOk(url); + let appel = 0; + const extraire = () => pages[appel++]; + + const res = await recupererAgendaPublie(fetchImpl, extraire, periode); + + expect(res).toEqual({ + ok: true, + cartes: [ + { date: "2026-07-01", titre: "A", lien: "l1" }, + { date: "2026-07-02", titre: "B", lien: "l2" }, + { date: "2026-07-03", titre: "C", lien: "l3" }, + ], + }); + expect(appel).toBe(4); // 3 pages pleines + la vide qui stoppe + }); + + test("plafond de sécurité respecté (extracteur toujours non vide)", async () => { + let appels = 0; + const fetchImpl = (url: string) => { + appels++; + return reponseOk(url); + }; + const extraire = () => [{ date: "2026-07-01", titre: "A", lien: "l" }]; + + const res = await recupererAgendaPublie(fetchImpl, extraire, periode); + + expect(res.ok).toBe(true); + expect(appels).toBe(12); // plafond + }); + + test("fetch qui throw → { ok:false } (jamais d'exception)", async () => { + const fetchImpl = () => Promise.reject(new Error("réseau")); + const extraire = () => []; + + const res = await recupererAgendaPublie(fetchImpl, extraire, periode); + + expect(res).toEqual({ ok: false }); + }); + + test("réponse HTTP non-ok (500) → { ok:false }", async () => { + const fetchImpl = () => Promise.resolve({ ok: false, text: () => Promise.resolve("") }); + const extraire = () => [{ date: "2026-07-01", titre: "A", lien: "l" }]; + + const res = await recupererAgendaPublie(fetchImpl, extraire, periode); + + expect(res).toEqual({ ok: false }); + }); +}); diff --git a/extension/agenda-mairie.js b/extension/agenda-mairie.js new file mode 100644 index 0000000..e2e4c5f --- /dev/null +++ b/extension/agenda-mairie.js @@ -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
, + // pas des
. `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; + } +} diff --git a/extension/publication.js b/extension/publication.js new file mode 100644 index 0000000..47701db --- /dev/null +++ b/extension/publication.js @@ -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