Modification des entrées du site. Il est passé sur wordpress. Problème pour l'agenda, c'est que ics -> tranformé en rss

This commit is contained in:
mathieu
2026-10-16 15:33:18 +02:00
parent 476c1bf413
commit 38c181203e
7 changed files with 264 additions and 585 deletions
+72 -42
View File
@@ -1,52 +1,82 @@
<?php
require __DIR__ . '/common.php';
const SITE = 'https://www.silly.be';
const NEWS_API = SITE . '/actualites/@results';
const PAGE_SIZE = 20;
const MAX_PAGES = 2; // jusqu'a 40 items
function fetch_all_news(): array
{
$items = [];
for ($page = 0; $page < MAX_PAGES; $page++) {
$bStart = $page * PAGE_SIZE;
$url = NEWS_API . '?b_size=' . PAGE_SIZE . '&b_start=' . $bStart;
$data = json_decode(http_get($url), true);
$pageItems = $data['items'] ?? [];
if (empty($pageItems)) {
break;
}
$items = array_merge($items, $pageItems);
if (count($items) >= ($data['items_total'] ?? 0)) {
break;
}
}
return $items;
}
/**
* Proxy/cache du flux RSS WordPress natif de silly.be (articles complets
* dans content:encoded, categories, 10 derniers articles). On le re-sert
* sous notre domaine pour garder une URL stable et on corrige atom:link
* rel="self" (il pointe sinon vers silly.be) pour le validateur W3C.
*
* WordPress limite le flux a 10 articles (reglage du site, non modifiable
* d'ici) mais accepte ?paged=N : on concatene NEWS_PAGES pages pour
* obtenir jusqu'a 50 articles.
*/
const NEWS_FEED_URL = 'https://www.silly.be/actualites/feed/';
const NEWS_PAGES = 5; // 5 x 10 = 50 articles
const SELF_URL = 'https://sillycone-village.be/silly-rss/actualites.php';
function build_news_feed(): string
{
$items = [];
foreach (fetch_all_news() as $item) {
$id = $item['id'] ?? '';
$title = $item['title_fr'] ?? $item['title'] ?? '(sans titre)';
$uid = $item['UID'] ?? $id;
$items[] = [
'title' => $title,
'link' => SITE . '/actualites/' . slugify($title) . '?u=' . rawurlencode($uid),
'guid' => $uid,
'pubDate' => $item['effective'] ?? $item['created'] ?? null,
'description' => full_body_html($item, $title),
];
$raw = http_get(NEWS_FEED_URL, ['Accept: application/rss+xml, application/xml']);
$doc = new DOMDocument();
if (!@$doc->loadXML($raw)) {
throw new RuntimeException('Flux WordPress illisible (XML invalide)');
}
return build_rss(
'Silly.be - Actualités',
SITE . '/actualites',
'Actualités de la commune de Silly (Belgique)',
$items,
'https://sillycone-village.be/silly-rss/actualites.php'
);
$xpath = new DOMXPath($doc);
$xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
$selfLinks = $xpath->query('/rss/channel/atom:link[@rel="self"]');
if ($selfLinks->length === 0) {
throw new RuntimeException('Reponse inattendue : pas un flux RSS');
}
foreach ($selfLinks as $selfLink) {
$selfLink->setAttribute('href', SELF_URL);
}
// WordPress emet <sy:updatePeriod>\n\thourly\t</sy:updatePeriod> : on retire
// les blancs parasites autour des valeurs.
$xpath->registerNamespace('sy', 'http://purl.org/rss/1.0/modules/syndication/');
foreach ($xpath->query('/rss/channel/sy:*') as $node) {
$node->textContent = trim($node->textContent);
}
// Pages suivantes : un echec (ou une page vide) arrete simplement la
// pagination, la premiere page suffit a servir un flux valide.
$channel = $xpath->query('/rss/channel')->item(0);
$seen = [];
foreach ($xpath->query('/rss/channel/item/guid') as $g) {
$seen[trim($g->textContent)] = true;
}
for ($page = 2; $page <= NEWS_PAGES; $page++) {
try {
$more = new DOMDocument();
$raw = http_get(NEWS_FEED_URL . '?paged=' . $page, ['Accept: application/rss+xml, application/xml']);
if (!@$more->loadXML($raw)) {
break;
}
} catch (Throwable $e) {
break;
}
$added = 0;
$moreXpath = new DOMXPath($more);
foreach ($moreXpath->query('/rss/channel/item') as $item) {
// Le tri de WordPress n'est pas stable entre articles de meme
// date : on dedoublonne par guid aux jonctions de pages.
$guid = trim($moreXpath->evaluate('string(guid)', $item));
if ($guid === '' || isset($seen[$guid])) {
continue;
}
$seen[$guid] = true;
$channel->appendChild($doc->importNode($item, true));
$added++;
}
if ($added === 0) {
break;
}
}
return $doc->saveXML();
}
serve_cached_feed('actualites', 'build_news_feed');
+161 -78
View File
@@ -1,105 +1,188 @@
<?php
require __DIR__ . '/common.php';
const SITE = 'https://www.silly.be';
const EVENTS_API = SITE . '/agenda/@results';
const PAGE_SIZE = 20;
const MAX_PAGES = 2;
function fetch_all_events(): array
{
$items = [];
for ($page = 0; $page < MAX_PAGES; $page++) {
$bStart = $page * PAGE_SIZE;
$url = EVENTS_API . '?b_size=' . PAGE_SIZE . '&b_start=' . $bStart;
$data = json_decode(http_get($url), true);
$pageItems = $data['items'] ?? [];
if (empty($pageItems)) {
break;
}
$items = array_merge($items, $pageItems);
if (count($items) >= ($data['items_total'] ?? 0)) {
break;
}
}
return $items;
}
/**
* Agenda : conversion de l'iCal officiel de silly.be en flux RSS.
* Le calendrier annonce un TTL de 12 h (X-PUBLISHED-TTL:PT12H), on cache
* donc aussi 12 h. Il ne contient que les evenements a venir (quelques
* uns) ; l'API REST /wp-json/wp/v2/evenement liste plus d'evenements mais
* sans aucune date (champ acf vide), donc inutilisable pour un agenda.
* Les regles de recurrence (RRULE) ne sont pas developpees.
*/
const ICAL_URL = 'https://www.silly.be/agenda/?ics=1';
const AGENDA_SELF_URL = 'https://sillycone-village.be/silly-rss/agenda.php';
const AGENDA_TTL_SECONDS = 43200; // 12 heures
const DISPLAY_TZ = 'Europe/Brussels';
/**
* Contrairement aux actualites, l'API renvoie text_fr/items (texte complet
* et pieces jointes) a null dans la liste paginee des evenements - il faut
* une requete par UID pour les recuperer (c'est ce que fait le site
* lui-meme quand on ouvre un evenement). Un evenement recurrent partage un
* seul UID sur toutes ses occurrences, donc un cache statique evite de
* refaire la requete pour chaque occurrence.
* Parse un texte iCal : deplie les lignes (RFC 5545 §3.1) et renvoie la
* liste des VEVENT, chacun sous la forme NOM => [['params' => [...], 'value' => ...], ...].
*/
function fetch_event_detail(string $uid): ?array
function parse_ical_events(string $ical): array
{
static $cache = [];
if (array_key_exists($uid, $cache)) {
return $cache[$uid];
$ical = str_replace(["\r\n", "\r"], "\n", $ical);
$ical = preg_replace('/\n[ \t]/', '', $ical);
$events = [];
$current = null;
foreach (explode("\n", $ical) as $line) {
$line = rtrim($line);
if ($line === 'BEGIN:VEVENT') {
$current = [];
} elseif ($line === 'END:VEVENT') {
if ($current !== null) {
$events[] = $current;
}
$current = null;
} elseif ($current !== null && $line !== '') {
// NOM[;PARAM=VAL...]:valeur (les valeurs de parametres peuvent
// etre entre guillemets et contenir ':' ; on coupe sur le 1er
// ':' hors guillemets)
if (!preg_match('/^([A-Za-z0-9-]+)((?:;(?:[^:;"]|"[^"]*")*)*):(.*)$/s', $line, $m)) {
continue;
}
$params = [];
foreach (explode(';', ltrim($m[2], ';')) as $p) {
if (strpos($p, '=') !== false) {
[$k, $v] = explode('=', $p, 2);
$params[strtoupper($k)] = trim($v, '"');
}
}
$current[strtoupper($m[1])][] = ['params' => $params, 'value' => $m[3]];
}
}
try {
$url = EVENTS_API . '?UID=' . rawurlencode($uid) . '&fullobjects=1';
$data = json_decode(http_get($url), true);
$cache[$uid] = $data['items'][0] ?? null;
} catch (Throwable $e) {
$cache[$uid] = null;
}
return $cache[$uid];
return $events;
}
function format_event_meta(array $item): string
/** Deséchappe une valeur TEXT iCal : \\ \, \; \n. */
function ical_unescape(string $text): string
{
$parts = [];
$start = !empty($item['start']) ? new DateTime($item['start']) : null;
$end = !empty($item['end']) ? new DateTime($item['end']) : null;
return preg_replace_callback('/\\\\([nN,;\\\\])/', function ($m) {
return ($m[1] === 'n' || $m[1] === 'N') ? "\n" : $m[1];
}, $text);
}
if ($start) {
$parts[] = !empty($item['whole_day'])
? 'Le ' . $start->format('d/m/Y')
: 'Le ' . $start->format('d/m/Y à H:i');
function ical_text(array $event, string $name): string
{
return isset($event[$name][0]) ? trim(ical_unescape($event[$name][0]['value'])) : '';
}
/** Renvoie [DateTimeImmutable|null, bool $allDay] pour DTSTART/DTEND/CREATED. */
function ical_date(array $event, string $name): array
{
if (!isset($event[$name][0])) {
return [null, false];
}
if ($end && (!$start || $end->format('Y-m-d') !== $start->format('Y-m-d'))) {
$parts[] = "jusqu'au " . $end->format('d/m/Y H:i');
$prop = $event[$name][0];
$value = trim($prop['value']);
$display = new DateTimeZone(DISPLAY_TZ);
try {
if (($prop['params']['VALUE'] ?? '') === 'DATE' || preg_match('/^\d{8}$/', $value)) {
return [new DateTimeImmutable($value, $display), true];
}
if (substr($value, -1) === 'Z') {
$dt = new DateTimeImmutable(substr($value, 0, -1), new DateTimeZone('UTC'));
} else {
$tz = new DateTimeZone($prop['params']['TZID'] ?? DISPLAY_TZ);
$dt = new DateTimeImmutable($value, $tz);
}
return [$dt->setTimezone($display), false];
} catch (Exception $e) {
return [null, false];
}
$address = implode(', ', array_filter([$item['street'] ?? null, $item['zipcode'] ?? null, $item['city'] ?? null]));
if ($address) {
$parts[] = "Lieu : $address";
}
function format_event_dates(?DateTimeImmutable $start, ?DateTimeImmutable $end, bool $allDay): string
{
if (!$start) {
return '';
}
return implode(' — ', $parts);
if ($allDay) {
// En iCal, DTEND d'un evenement "journee entiere" est exclusif.
$last = $end ? $end->modify('-1 day') : $start;
if ($last <= $start) {
return 'Le ' . $start->format('d/m/Y');
}
return 'Du ' . $start->format('d/m/Y') . ' au ' . $last->format('d/m/Y');
}
if (!$end || $end == $start) {
return 'Le ' . $start->format('d/m/Y à H:i');
}
if ($end->format('Y-m-d') === $start->format('Y-m-d')) {
return 'Le ' . $start->format('d/m/Y') . ' de ' . $start->format('H:i') . ' à ' . $end->format('H:i');
}
return 'Du ' . $start->format('d/m/Y à H:i') . ' au ' . $end->format('d/m/Y à H:i');
}
/** Texte brut -> HTML : paragraphes sur lignes vides, <br> sur retours simples, URLs cliquables. */
function text_to_html(string $text): string
{
$html = '';
foreach (preg_split('/\n{2,}/', trim($text)) as $para) {
$para = trim($para);
if ($para === '') {
continue;
}
$safe = htmlspecialchars($para, ENT_QUOTES, 'UTF-8');
$safe = preg_replace('#(https?://[^\s<]+)#', '<a href="$1">$1</a>', $safe);
$html .= '<p>' . nl2br($safe, false) . '</p>';
}
return $html;
}
function build_events_feed(): string
{
$items = [];
foreach (fetch_all_events() as $item) {
$id = basename(rtrim($item['@id'] ?? '', '/'));
$title = $item['title_fr'] ?? $item['title'] ?? '(sans titre)';
$uid = $item['UID'] ?? $id;
$meta = format_event_meta($item);
$detail = fetch_event_detail($uid);
$ical = http_get(ICAL_URL, ['Accept: text/calendar, */*']);
if (strpos($ical, 'BEGIN:VCALENDAR') === false) {
throw new RuntimeException('Reponse inattendue : pas un calendrier iCal');
}
$entries = [];
$seen = [];
foreach (parse_ical_events($ical) as $event) {
[$start, $allDay] = ical_date($event, 'DTSTART');
[$end] = ical_date($event, 'DTEND');
[$created] = ical_date($event, 'CREATED');
$title = ical_text($event, 'SUMMARY') ?: '(sans titre)';
$uid = ical_text($event, 'UID') ?: $title . '-' . ($start ? $start->format('c') : '');
$url = ical_text($event, 'URL');
$location = ical_text($event, 'LOCATION');
$guid = $uid;
if (isset($seen[$guid])) { // occurrence d'un evenement repete : meme UID
$guid .= '-' . ($start ? $start->format('YmdHi') : count($seen));
}
$seen[$guid] = true;
$meta = format_event_dates($start, $end, $allDay);
if ($location !== '') {
$meta .= ($meta !== '' ? ' — ' : '') . 'Lieu : ' . $location;
}
$description = ($meta !== '' ? '<p>' . htmlspecialchars($meta, ENT_QUOTES, 'UTF-8') . '</p>' : '')
. full_body_html($detail ?? $item, $title);
// Un evenement recurrent partage le meme UID sur toutes ses
// occurrences (memes UID, dates differentes) : on suffixe le guid
// avec la date de debut pour rester unique par occurrence, comme
// l'exige la spec RSS.
$items[] = [
'title' => $title,
'link' => SITE . '/agenda/' . slugify($title) . '?u=' . rawurlencode($uid),
'guid' => $uid . '-' . ($item['start'] ?? ''),
'pubDate' => $item['created'] ?? $item['start'] ?? null,
'description' => $description,
. text_to_html(ical_text($event, 'DESCRIPTION'));
$pub = $created ?? $start;
$entries[] = [
'sort' => $start ? $start->getTimestamp() : PHP_INT_MAX,
'item' => [
'title' => $title,
'link' => $url !== '' ? $url : 'https://www.silly.be/agenda/',
'guid' => $guid,
'pubDate' => $pub ? $pub->format('c') : null,
'description' => $description,
],
];
}
usort($entries, fn($a, $b) => $a['sort'] <=> $b['sort']);
return build_rss(
'Silly.be - Agenda',
SITE . '/agenda',
"Agenda / événements de la commune de Silly (Belgique)",
$items,
'https://sillycone-village.be/silly-rss/agenda.php'
'https://www.silly.be/agenda/',
'Agenda / événements de la commune de Silly (Belgique)',
array_column($entries, 'item'),
AGENDA_SELF_URL
);
}
serve_cached_feed('agenda', 'build_events_feed');
serve_cached_feed('agenda', 'build_events_feed', AGENDA_TTL_SECONDS);
+7 -88
View File
@@ -2,15 +2,9 @@
/**
* Fonctions communes aux 3 flux RSS (actualites.php, agenda.php, facebook.php).
*
* Le site www.silly.be est derriere Cloudflare : son WAF bloque les
* requetes dont l'empreinte TLS/HTTP2 n'est pas "navigateur" (urllib
* Python passe, par ex., mais renvoie un corps vide). L'extension
* curl de PHP (libcurl) reproduit la meme empreinte que le binaire
* curl en ligne de commande, donc ca passe nativement.
*
* Autre contrainte observee sur l'API : b_size doit valoir exactement
* 20 (toute autre valeur renvoie un corps vide) ; la pagination se
* fait donc via b_start par pas de 20.
* Depuis le 14/10/2026, www.silly.be tourne sous WordPress (l'ancienne API
* JSON Plone / imio.smartweb n'existe plus). Les sources sont desormais :
* le flux RSS WordPress natif (actualites) et l'iCal officiel (agenda).
*/
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
@@ -21,13 +15,13 @@ const CACHE_TTL_SECONDS = 900; // 15 minutes
* Recupere une URL via curl (necessaire pour passer le WAF Cloudflare
* de silly.be) et renvoie le corps de la reponse en texte.
*/
function http_get(string $url, array $extraHeaders = []): string
function http_get(string $url, array $headers = ['Accept: */*']): string
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => USER_AGENT,
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $extraHeaders),
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 20,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
@@ -52,7 +46,7 @@ function http_get(string $url, array $extraHeaders = []): string
* $builder() si le cache est absent ou perime. Evite de solliciter
* silly.be / fetchrss.com a chaque poll de chaque abonne FreshRSS.
*/
function serve_cached_feed(string $cacheKey, callable $builder): void
function serve_cached_feed(string $cacheKey, callable $builder, int $ttl = CACHE_TTL_SECONDS): void
{
// text/xml plutot que application/rss+xml : les navigateurs affichent
// ce type en ligne (arbre XML) au lieu de forcer un telechargement,
@@ -66,7 +60,7 @@ function serve_cached_feed(string $cacheKey, callable $builder): void
}
$cacheFile = CACHE_DIR . '/' . $cacheKey . '.xml';
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL_SECONDS) {
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < $ttl) {
readfile($cacheFile);
return;
}
@@ -88,81 +82,6 @@ function serve_cached_feed(string $cacheKey, callable $builder): void
}
}
function strip_html(?string $text): string
{
if (!$text) {
return '';
}
$text = preg_replace('/<[^>]+>/', ' ', $text);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim(preg_replace('/\s+/', ' ', $text));
}
/**
* Reproduit le slug d'URL genere par le site (cosmetique : le routeur
* JS de silly.be lit en realite le parametre `u` pour savoir quel item
* afficher, pas ce segment de chemin - mais autant coller a l'URL
* "officielle" telle qu'on la voit dans la barre d'adresse).
*/
function slugify(string $text): string
{
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
if ($ascii === false) {
$ascii = $text;
}
$ascii = strtolower($ascii);
$ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii);
return trim($ascii, '-');
}
/**
* Construit le HTML des liens vers les pieces jointes (PDF, etc.)
* renvoyees par l'API dans le champ "items" de chaque actualite/evenement.
*/
function attachments_html(array $item): string
{
$html = '';
foreach ($item['items'] ?? [] as $sub) {
$url = $sub['file']['download'] ?? $sub['targetUrl'] ?? null;
if (!$url) {
continue;
}
$label = $sub['title'] ?? basename($url);
$html .= '<p><a href="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '">'
. htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</a></p>';
}
return $html;
}
/**
* Image principale de l'item (deja fournie par l'API, aucune requete
* supplementaire necessaire). "paysage_affiche" est une taille
* intermediaire raisonnable pour une image de flux RSS.
*/
function lead_image_html(array $item, string $title): string
{
$url = $item['image_affiche_scale'] ?? $item['image_full_scale'] ?? null;
if (!$url) {
return '';
}
return '<p><img src="' . htmlspecialchars($url, ENT_QUOTES, 'UTF-8') . '" alt="'
. htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '" /></p>';
}
/**
* Corps HTML complet d'un item (image + texte riche + pieces jointes),
* avec repli sur le resume court si le texte complet est vide.
*/
function full_body_html(array $item, string $title): string
{
$body = trim($item['text_fr']['data'] ?? '');
if ($body === '') {
$summary = strip_html($item['description_fr'] ?? $item['description'] ?? null);
$body = $summary !== '' ? '<p>' . htmlspecialchars($summary, ENT_QUOTES, 'UTF-8') . '</p>' : '';
}
return lead_image_html($item, $title) . $body . attachments_html($item);
}
function rfc822(?string $isoDate): string
{
try {