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:
co-authored by
Claude Opus 4.8
parent
a7b780876b
commit
6c4f4219f1
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user