diff --git a/README.md b/README.md index fab37d1..be5d4a8 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,32 @@ # Flux RSS - Commune de Silly Genere trois flux RSS/Atom pour la commune de Silly (Belgique) : actualites, -agenda et page Facebook. Deux implementations equivalentes sont fournies : +agenda et page Facebook, a deployer sur un hebergement PHP mutualise +(extensions `curl` et `dom` uniquement, aucun cron ni conf serveur : chaque +flux se regenere a la demande et est mis en cache). -- **[php/](php/)** — a deployer sur un hebergement PHP mutualise classique. - **Recommande** : aucun cron ni conf serveur a ecrire, chaque flux se - regenere a la demande (avec cache 15 min) des qu'un lecteur RSS l'interroge. -- **[silly_rss.py](silly_rss.py)** — script Python autonome a lancer via cron, - pour un serveur ou vous controlez le cron/nginx (ex. Yunohost). +Depuis le 14/10/2026, www.silly.be est passe de Plone (`imio.smartweb`) a +WordPress. L'ancienne API JSON n'existe plus ; la variante Python +(`silly_rss.py`) et ses fichiers de deploiement (nginx/cron) qui en +dependaient ont ete supprimes (historique git). Nouvelles sources : -Les deux utilisent la meme source : l'API JSON REST (Plone / `imio.smartweb`) -que www.silly.be charge en JS via les balises `` / -`` — pas besoin de navigateur headless. Le detail technique -(empreinte Cloudflare, contrainte `b_size=20`) est commente dans -[php/common.php](php/common.php) et [silly_rss.py](silly_rss.py). +| Flux | Source | Cache | +| --- | --- | --- | +| `actualites.php` | flux RSS WordPress `https://www.silly.be/actualites/feed/` (50 derniers articles via `?paged=1..5`, texte complet), re-servi tel quel avec `atom:link rel="self"` corrige | 15 min | +| `agenda.php` | iCal officiel `https://www.silly.be/agenda/?ics=1`, converti en RSS (guid = UID, link = URL, description = dates + lieu + texte) | 12 h | +| `facebook.php` | proxy du flux [FetchRSS](https://fetchrss.com) | 15 min | -## Option recommandee : PHP (hebergement perso — sillycone-village.be) +Limites de l'agenda : l'iCal ne liste que les evenements a venir, et les +regles de recurrence (`RRULE`) ne sont pas developpees. L'API REST +`/wp-json/wp/v2/evenement` expose davantage d'evenements (passes inclus) mais +sans aucune date (champ `acf` vide), donc inutilisable pour un agenda. + +## Deploiement (hebergement perso — sillycone-village.be) 1. Copier tout le dossier [php/](php/) sur l'hebergement (FTP/SFTP), dans `silly-rss/` a la racine du site (Apache, confirme actif sur `sillycone-village.be`). -2. Verifier que PHP a l'extension `curl` activee (quasi toujours le cas en +2. Verifier que PHP a les extensions `curl` et `dom` activees (quasi toujours le cas en mutualise standard). 3. Le dossier `php/cache/` doit etre inscriptible par PHP (permissions par defaut suffisent generalement sur du mutualise ; sinon `chmod 775`). @@ -38,50 +44,14 @@ Note CSP : `sillycone-village.be` sert ses pages HTML avec un concerne que le JS execute dans le navigateur sur ce site — sans impact ici puisque FreshRSS recupere les flux XML cote serveur. -Teste avec PHP 8.3 CLI : les 3 scripts produisent du XML valide avec de -vraies donnees (40 actualites, 12 evenements, flux Facebook proxifie). - -## Option alternative : Python + cron (Yunohost) - -1. **Dossier de sortie** - - ```bash - sudo mkdir -p /var/www/silly-rss - sudo chown $(whoami):www-data /var/www/silly-rss - ``` - -2. **Cron** — voir [deploy/silly-rss.cron](deploy/silly-rss.cron) pour la - ligne a ajouter via `crontab -e` (execution toutes les heures). - -3. **nginx** — voir [deploy/nginx-silly-rss.conf](deploy/nginx-silly-rss.conf), - a copier dans `/etc/nginx/conf.d/akbar.nohost.me.d/silly-rss.conf` puis : - - ```bash - sudo nginx -t && sudo systemctl reload nginx - ``` - - NB : `/rss` est deja pris par FreshRSS sur ce domaine, d'ou le choix de - `/silly-rss/` comme chemin. - -4. **Premiere generation** (avant le premier passage du cron) : - - ```bash - python3 silly_rss.py /var/www/silly-rss - ``` - -5. **FreshRSS** — ajouter comme sources : - - `https://akbar.nohost.me/silly-rss/actualites.xml` - - `https://akbar.nohost.me/silly-rss/agenda.xml` - -Cette option ne gere pas Facebook (pas de proxy) : ajouter directement -l'URL FetchRSS dans FreshRSS si vous utilisez cette variante. +Teste avec PHP 8.3 CLI (16/10/2026) : les 3 scripts produisent du XML bien forme +avec de vraies donnees (10 actualites, 4 evenements). ## Facebook (page communedesilly) Pas de solution fiable trouvee en scraping direct : le HTML de facebook.com est vide de contenu exploitable sans authentification, et l'instance RSS-Bridge testee (`FacebookBridge`) renvoie une erreur 400. -Solution retenue : [FetchRSS](https://fetchrss.com) (service tiers). La -variante PHP le reproxifie sous votre propre domaine (`facebook.php`) pour -avoir 3 URLs stables et partageables ; la variante Python s'appuie -directement sur l'URL FetchRSS. +Solution retenue : [FetchRSS](https://fetchrss.com) (service tiers), +reproxifie sous votre propre domaine (`facebook.php`) pour avoir 3 URLs +stables et partageables. diff --git a/deploy/nginx-silly-rss.conf b/deploy/nginx-silly-rss.conf deleted file mode 100644 index 3418c2f..0000000 --- a/deploy/nginx-silly-rss.conf +++ /dev/null @@ -1,34 +0,0 @@ -# Extrait nginx pour servir les flux RSS generes par silly_rss.py. -# -# Sur Yunohost, chaque domaine a son propre dossier de fragments de conf, -# automatiquement inclus par le nginx gere par Yunohost : -# /etc/nginx/conf.d/akbar.nohost.me.d/ -# -# NB : le chemin /rss est deja pris par FreshRSS sur ce domaine, d'ou le -# choix de /silly-rss/ ci-dessous. -# -# Installation : -# 1. Copier ce fichier vers : -# /etc/nginx/conf.d/akbar.nohost.me.d/silly-rss.conf -# 2. Tester puis recharger nginx : -# sudo nginx -t && sudo systemctl reload nginx -# -# Les flux seront alors accessibles en : -# https://akbar.nohost.me/silly-rss/actualites.xml -# https://akbar.nohost.me/silly-rss/agenda.xml - -location ^~ /silly-rss/ { - alias /var/www/silly-rss/; - autoindex off; - - location ~ \.xml$ { - add_header Content-Type "application/rss+xml; charset=utf-8"; - add_header Cache-Control "public, max-age=300"; - } - - # Bloque tout ce qui n'est pas un flux XML (script, log, etc.) - location ~ \.(py|log|pyc)$ { - deny all; - return 404; - } -} diff --git a/deploy/silly-rss.cron b/deploy/silly-rss.cron deleted file mode 100644 index 4dc6ed3..0000000 --- a/deploy/silly-rss.cron +++ /dev/null @@ -1,14 +0,0 @@ -# Cron job pour regenerer les flux RSS de Silly toutes les heures. -# -# Prealable : copier/cloner ce depot sur le serveur Yunohost, par ex. : -# sudo git clone /opt/silly-rss -# (adapter le chemin ci-dessous si vous le placez ailleurs) -# -# Installation (en tant qu'utilisateur qui a les droits d'ecriture sur -# /var/www/silly-rss, par ex. root ou un utilisateur dedie) : -# -# sudo crontab -e -# -# puis coller la ligne ci-dessous : - -0 * * * * flock -n /tmp/silly_rss.lock /usr/bin/python3 /opt/silly-rss/silly_rss.py /var/www/silly-rss >> /var/log/silly_rss.log 2>&1 diff --git a/php/actualites.php b/php/actualites.php index 6cc8fd4..0e4243b 100644 --- a/php/actualites.php +++ b/php/actualites.php @@ -1,52 +1,82 @@ = ($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 \n\thourly\t : 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'); diff --git a/php/agenda.php b/php/agenda.php index d9907dd..7bdb65d 100644 --- a/php/agenda.php +++ b/php/agenda.php @@ -1,105 +1,188 @@ = ($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,
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<]+)#', '$1', $safe); + $html .= '

' . nl2br($safe, false) . '

'; + } + 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 !== '' ? '

' . htmlspecialchars($meta, ENT_QUOTES, 'UTF-8') . '

' : '') - . 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); diff --git a/php/common.php b/php/common.php index f6b0793..dda5da8 100644 --- a/php/common.php +++ b/php/common.php @@ -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 .= '

' - . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '

'; - } - 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 '

'
-        . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '

'; -} - -/** - * 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 !== '' ? '

' . htmlspecialchars($summary, ENT_QUOTES, 'UTF-8') . '

' : ''; - } - return lead_image_html($item, $title) . $body . attachments_html($item); -} - function rfc822(?string $isoDate): string { try { diff --git a/silly_rss.py b/silly_rss.py deleted file mode 100644 index 3b43a8b..0000000 --- a/silly_rss.py +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env python3 -""" -Genere deux flux RSS 2.0 (actualites + agenda) pour la commune de Silly (Belgique) -a partir de l'API JSON REST (Plone/imio.smartweb) utilisee par www.silly.be. - -Aucun navigateur headless n'est necessaire : le HTML de www.silly.be/actualites et -/agenda contient les balises / dont l'attribut -query-url pointe directement vers l'API JSON (@results) qui retourne les items. - -Sortie : deux fichiers XML (actualites.xml, agenda.xml) a servir statiquement. -""" -import html -import json -import re -import subprocess -import sys -import unicodedata -from datetime import datetime, timezone -from email.utils import format_datetime -from xml.sax.saxutils import escape - -# Le site www.silly.be est derriere Cloudflare, dont le WAF laisse passer -# curl (empreinte TLS/HTTP2 "navigateur") mais bloque silencieusement -# (reponse HTTP 200 avec un corps "{}") les requetes faites avec -# urllib/requests en Python. On shell-oute donc vers curl, qui est -# disponible par defaut sur toute machine Yunohost. -# -# Autre contrainte observee : le WAF ne laisse passer que b_size=20 -# exactement (la valeur utilisee par le composant JS du site) ; toute -# autre valeur de b_size renvoie un corps vide. La pagination se fait -# donc avec b_size=20 fixe + b_start croissant. -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" -SITE = "https://www.silly.be" -PAGE_SIZE = 20 -MAX_PAGES = 2 # jusqu'a 40 items par flux - -NEWS_API = f"{SITE}/actualites/@results" -EVENTS_API = f"{SITE}/agenda/@results" - -# Base publique ou ce script publie ses flux une fois deploye (voir -# README.md, variante Yunohost/cron) - sert a l'atom:link rel="self". -# A adapter si vous deployez ailleurs. -SELF_BASE_URL = "https://akbar.nohost.me/silly-rss" - - -def fetch_json(url: str) -> dict: - result = subprocess.run( - ["curl", "-s", "-A", USER_AGENT, "-H", "Accept: application/json", "--fail", url], - capture_output=True, - timeout=30, - check=True, - ) - return json.loads(result.stdout) - - -def fetch_all_items(base_url: str) -> list[dict]: - items: list[dict] = [] - for page in range(MAX_PAGES): - b_start = page * PAGE_SIZE - data = fetch_json(f"{base_url}?b_size={PAGE_SIZE}&b_start={b_start}") - page_items = data.get("items", []) - if not page_items: - break - items.extend(page_items) - if len(items) >= data.get("items_total", 0): - break - return items - - -def strip_html(text: str | None) -> str: - if not text: - return "" - text = re.sub(r"<[^>]+>", " ", text) - text = html.unescape(text) - return re.sub(r"\s+", " ", text).strip() - - -def slugify(text: str) -> str: - """ - 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). - """ - ascii_text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii") - ascii_text = ascii_text.lower() - ascii_text = re.sub(r"[^a-z0-9]+", "-", ascii_text) - return ascii_text.strip("-") - - -def attachments_html(item: dict) -> str: - """HTML des liens vers les pieces jointes (PDF, etc.) d'un item.""" - parts = [] - for sub in item.get("items") or []: - file_info = sub.get("file") or {} - url = file_info.get("download") or sub.get("targetUrl") - if not url: - continue - label = sub.get("title") or url.rsplit("/", 1)[-1] - parts.append(f'

{html.escape(label)}

') - return "".join(parts) - - -def lead_image_html(item: dict, title: str) -> str: - """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.""" - url = item.get("image_affiche_scale") or item.get("image_full_scale") - if not url: - return "" - return f'

{html.escape(title)}

' - - -def full_body_html(item: dict, title: str) -> str: - """Corps HTML complet (image + texte riche + pieces jointes), repli sur le resume court.""" - text_fr = item.get("text_fr") - body = (text_fr.get("data") or "").strip() if isinstance(text_fr, dict) else "" - if not body: - summary = strip_html(item.get("description_fr") or item.get("description")) - body = f"

{html.escape(summary)}

" if summary else "" - return lead_image_html(item, title) + body + attachments_html(item) - - -def parse_dt(value: str | None) -> datetime | None: - if not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")) - except ValueError: - return None - - -def rfc822(dt: datetime | None) -> str: - if dt is None: - dt = datetime.now(timezone.utc) - return format_datetime(dt) - - -def build_news_items() -> list[str]: - entries = [] - for item in fetch_all_items(NEWS_API): - item_id = item.get("id", "") - title = item.get("title_fr") or item.get("title") or "(sans titre)" - guid = item.get("UID") or item_id - link = f"{SITE}/actualites/{slugify(title)}?u={guid}" - description = full_body_html(item, title) - pub_dt = parse_dt(item.get("effective") or item.get("created")) - entries.append( - " \n" - f" {escape(title)}\n" - f" {escape(link)}\n" - f" {escape(guid)}\n" - f" {rfc822(pub_dt)}\n" - f" {escape(description)}\n" - " " - ) - return entries - - -_event_detail_cache: dict[str, dict | None] = {} - - -def fetch_event_detail(uid: str) -> dict | None: - """ - 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, d'ou le cache pour eviter de - refaire la requete pour chaque occurrence. - """ - if uid in _event_detail_cache: - return _event_detail_cache[uid] - try: - data = fetch_json(f"{EVENTS_API}?UID={uid}&fullobjects=1") - detail = (data.get("items") or [None])[0] - except Exception: - detail = None - _event_detail_cache[uid] = detail - return detail - - -def format_event_meta(item: dict) -> str: - parts = [] - start = parse_dt(item.get("start")) - end = parse_dt(item.get("end")) - if start: - if item.get("whole_day"): - parts.append(f"Le {start.strftime('%d/%m/%Y')}") - else: - parts.append(f"Le {start.strftime('%d/%m/%Y a %H:%M')}") - if end and end.date() != (start.date() if start else None): - parts.append(f"jusqu'au {end.strftime('%d/%m/%Y %H:%M')}") - address_bits = [item.get("street"), item.get("zipcode"), item.get("city")] - address = ", ".join(b for b in address_bits if b) - if address: - parts.append(f"Lieu : {address}") - return " — ".join(parts) - - -def build_event_items() -> list[str]: - entries = [] - for item in fetch_all_items(EVENTS_API): - event_id = item.get("@id", "").rstrip("/").split("/")[-1] - title = item.get("title_fr") or item.get("title") or "(sans titre)" - guid = item.get("UID") or event_id - link = f"{SITE}/agenda/{slugify(title)}?u={guid}" - meta = format_event_meta(item) - detail = fetch_event_detail(guid) or item - description = (f"

{html.escape(meta)}

" if meta else "") + full_body_html(detail, title) - pub_dt = parse_dt(item.get("created") or item.get("start")) - # Un evenement recurrent partage le meme UID sur toutes ses - # occurrences : on suffixe le guid avec la date de debut pour - # rester unique par occurrence, comme l'exige la spec RSS. - occurrence_guid = f"{guid}-{item.get('start', '')}" - entries.append( - " \n" - f" {escape(title)}\n" - f" {escape(link)}\n" - f" {escape(occurrence_guid)}\n" - f" {rfc822(pub_dt)}\n" - f" {escape(description)}\n" - " " - ) - return entries - - -def write_feed(path: str, title: str, link: str, description: str, items: list[str], self_url: str) -> None: - now = rfc822(datetime.now(timezone.utc)) - xml = ( - '\n' - '\n' - " \n" - f" {escape(title)}\n" - f" {escape(link)}\n" - f' \n' - f" {escape(description)}\n" - " fr-be\n" - f" {now}\n" - + "\n".join(items) + "\n" - " \n" - "\n" - ) - with open(path, "w", encoding="utf-8") as f: - f.write(xml) - - -def main() -> None: - out_dir = sys.argv[1] if len(sys.argv) > 1 else "." - - news_items = build_news_items() - write_feed( - f"{out_dir}/actualites.xml", - title="Silly.be - Actualités", - link=f"{SITE}/actualites", - description="Actualités de la commune de Silly (Belgique)", - items=news_items, - self_url=f"{SELF_BASE_URL}/actualites.xml", - ) - print(f"[OK] actualites.xml : {len(news_items)} items") - - event_items = build_event_items() - write_feed( - f"{out_dir}/agenda.xml", - title="Silly.be - Agenda", - link=f"{SITE}/agenda", - description="Agenda / événements de la commune de Silly (Belgique)", - items=event_items, - self_url=f"{SELF_BASE_URL}/agenda.xml", - ) - print(f"[OK] agenda.xml : {len(event_items)} items") - - -if __name__ == "__main__": - main()