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
+24 -54
View File
@@ -1,26 +1,32 @@
# Flux RSS - Commune de Silly # Flux RSS - Commune de Silly
Genere trois flux RSS/Atom pour la commune de Silly (Belgique) : actualites, 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. Depuis le 14/10/2026, www.silly.be est passe de Plone (`imio.smartweb`) a
**Recommande** : aucun cron ni conf serveur a ecrire, chaque flux se WordPress. L'ancienne API JSON n'existe plus ; la variante Python
regenere a la demande (avec cache 15 min) des qu'un lecteur RSS l'interroge. (`silly_rss.py`) et ses fichiers de deploiement (nginx/cron) qui en
- **[silly_rss.py](silly_rss.py)** — script Python autonome a lancer via cron, dependaient ont ete supprimes (historique git). Nouvelles sources :
pour un serveur ou vous controlez le cron/nginx (ex. Yunohost).
Les deux utilisent la meme source : l'API JSON REST (Plone / `imio.smartweb`) | Flux | Source | Cache |
que www.silly.be charge en JS via les balises `<smartweb-news>` / | --- | --- | --- |
`<smartweb-events>` — pas besoin de navigateur headless. Le detail technique | `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 |
(empreinte Cloudflare, contrainte `b_size=20`) est commente dans | `agenda.php` | iCal officiel `https://www.silly.be/agenda/?ics=1`, converti en RSS (guid = UID, link = URL, description = dates + lieu + texte) | 12 h |
[php/common.php](php/common.php) et [silly_rss.py](silly_rss.py). | `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 1. Copier tout le dossier [php/](php/) sur l'hebergement (FTP/SFTP), dans
`silly-rss/` a la racine du site (Apache, confirme actif sur `silly-rss/` a la racine du site (Apache, confirme actif sur
`sillycone-village.be`). `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). mutualise standard).
3. Le dossier `php/cache/` doit etre inscriptible par PHP (permissions par 3. Le dossier `php/cache/` doit etre inscriptible par PHP (permissions par
defaut suffisent generalement sur du mutualise ; sinon `chmod 775`). 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 concerne que le JS execute dans le navigateur sur ce site — sans impact ici
puisque FreshRSS recupere les flux XML cote serveur. puisque FreshRSS recupere les flux XML cote serveur.
Teste avec PHP 8.3 CLI : les 3 scripts produisent du XML valide avec de Teste avec PHP 8.3 CLI (16/10/2026) : les 3 scripts produisent du XML bien forme
vraies donnees (40 actualites, 12 evenements, flux Facebook proxifie). avec de vraies donnees (10 actualites, 4 evenements).
## 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.
## Facebook (page communedesilly) ## Facebook (page communedesilly)
Pas de solution fiable trouvee en scraping direct : le HTML de Pas de solution fiable trouvee en scraping direct : le HTML de
facebook.com est vide de contenu exploitable sans authentification, et facebook.com est vide de contenu exploitable sans authentification, et
l'instance RSS-Bridge testee (`FacebookBridge`) renvoie une erreur 400. l'instance RSS-Bridge testee (`FacebookBridge`) renvoie une erreur 400.
Solution retenue : [FetchRSS](https://fetchrss.com) (service tiers). La Solution retenue : [FetchRSS](https://fetchrss.com) (service tiers),
variante PHP le reproxifie sous votre propre domaine (`facebook.php`) pour reproxifie sous votre propre domaine (`facebook.php`) pour avoir 3 URLs
avoir 3 URLs stables et partageables ; la variante Python s'appuie stables et partageables.
directement sur l'URL FetchRSS.
-34
View File
@@ -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;
}
}
-14
View File
@@ -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 <url-du-depot> /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
+72 -42
View File
@@ -1,52 +1,82 @@
<?php <?php
require __DIR__ . '/common.php'; require __DIR__ . '/common.php';
const SITE = 'https://www.silly.be'; /**
const NEWS_API = SITE . '/actualites/@results'; * Proxy/cache du flux RSS WordPress natif de silly.be (articles complets
const PAGE_SIZE = 20; * dans content:encoded, categories, 10 derniers articles). On le re-sert
const MAX_PAGES = 2; // jusqu'a 40 items * 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.
function fetch_all_news(): array *
{ * WordPress limite le flux a 10 articles (reglage du site, non modifiable
$items = []; * d'ici) mais accepte ?paged=N : on concatene NEWS_PAGES pages pour
for ($page = 0; $page < MAX_PAGES; $page++) { * obtenir jusqu'a 50 articles.
$bStart = $page * PAGE_SIZE; */
$url = NEWS_API . '?b_size=' . PAGE_SIZE . '&b_start=' . $bStart; const NEWS_FEED_URL = 'https://www.silly.be/actualites/feed/';
$data = json_decode(http_get($url), true); const NEWS_PAGES = 5; // 5 x 10 = 50 articles
$pageItems = $data['items'] ?? []; const SELF_URL = 'https://sillycone-village.be/silly-rss/actualites.php';
if (empty($pageItems)) {
break;
}
$items = array_merge($items, $pageItems);
if (count($items) >= ($data['items_total'] ?? 0)) {
break;
}
}
return $items;
}
function build_news_feed(): string function build_news_feed(): string
{ {
$items = []; $raw = http_get(NEWS_FEED_URL, ['Accept: application/rss+xml, application/xml']);
foreach (fetch_all_news() as $item) {
$id = $item['id'] ?? ''; $doc = new DOMDocument();
$title = $item['title_fr'] ?? $item['title'] ?? '(sans titre)'; if (!@$doc->loadXML($raw)) {
$uid = $item['UID'] ?? $id; throw new RuntimeException('Flux WordPress illisible (XML invalide)');
$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),
];
} }
return build_rss(
'Silly.be - Actualités', $xpath = new DOMXPath($doc);
SITE . '/actualites', $xpath->registerNamespace('atom', 'http://www.w3.org/2005/Atom');
'Actualités de la commune de Silly (Belgique)', $selfLinks = $xpath->query('/rss/channel/atom:link[@rel="self"]');
$items, if ($selfLinks->length === 0) {
'https://sillycone-village.be/silly-rss/actualites.php' 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'); serve_cached_feed('actualites', 'build_news_feed');
+161 -78
View File
@@ -1,105 +1,188 @@
<?php <?php
require __DIR__ . '/common.php'; require __DIR__ . '/common.php';
const SITE = 'https://www.silly.be'; /**
const EVENTS_API = SITE . '/agenda/@results'; * Agenda : conversion de l'iCal officiel de silly.be en flux RSS.
const PAGE_SIZE = 20; * Le calendrier annonce un TTL de 12 h (X-PUBLISHED-TTL:PT12H), on cache
const MAX_PAGES = 2; * 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
function fetch_all_events(): array * sans aucune date (champ acf vide), donc inutilisable pour un agenda.
{ * Les regles de recurrence (RRULE) ne sont pas developpees.
$items = []; */
for ($page = 0; $page < MAX_PAGES; $page++) { const ICAL_URL = 'https://www.silly.be/agenda/?ics=1';
$bStart = $page * PAGE_SIZE; const AGENDA_SELF_URL = 'https://sillycone-village.be/silly-rss/agenda.php';
$url = EVENTS_API . '?b_size=' . PAGE_SIZE . '&b_start=' . $bStart; const AGENDA_TTL_SECONDS = 43200; // 12 heures
$data = json_decode(http_get($url), true); const DISPLAY_TZ = 'Europe/Brussels';
$pageItems = $data['items'] ?? [];
if (empty($pageItems)) {
break;
}
$items = array_merge($items, $pageItems);
if (count($items) >= ($data['items_total'] ?? 0)) {
break;
}
}
return $items;
}
/** /**
* Contrairement aux actualites, l'API renvoie text_fr/items (texte complet * Parse un texte iCal : deplie les lignes (RFC 5545 §3.1) et renvoie la
* et pieces jointes) a null dans la liste paginee des evenements - il faut * liste des VEVENT, chacun sous la forme NOM => [['params' => [...], 'value' => ...], ...].
* 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.
*/ */
function fetch_event_detail(string $uid): ?array function parse_ical_events(string $ical): array
{ {
static $cache = []; $ical = str_replace(["\r\n", "\r"], "\n", $ical);
if (array_key_exists($uid, $cache)) { $ical = preg_replace('/\n[ \t]/', '', $ical);
return $cache[$uid];
$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 { return $events;
$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];
} }
function format_event_meta(array $item): string /** Deséchappe une valeur TEXT iCal : \\ \, \; \n. */
function ical_unescape(string $text): string
{ {
$parts = []; return preg_replace_callback('/\\\\([nN,;\\\\])/', function ($m) {
$start = !empty($item['start']) ? new DateTime($item['start']) : null; return ($m[1] === 'n' || $m[1] === 'N') ? "\n" : $m[1];
$end = !empty($item['end']) ? new DateTime($item['end']) : null; }, $text);
}
if ($start) { function ical_text(array $event, string $name): string
$parts[] = !empty($item['whole_day']) {
? 'Le ' . $start->format('d/m/Y') return isset($event[$name][0]) ? trim(ical_unescape($event[$name][0]['value'])) : '';
: 'Le ' . $start->format('d/m/Y à H:i'); }
/** 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'))) { $prop = $event[$name][0];
$parts[] = "jusqu'au " . $end->format('d/m/Y H:i'); $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 function build_events_feed(): string
{ {
$items = []; $ical = http_get(ICAL_URL, ['Accept: text/calendar, */*']);
foreach (fetch_all_events() as $item) { if (strpos($ical, 'BEGIN:VCALENDAR') === false) {
$id = basename(rtrim($item['@id'] ?? '', '/')); throw new RuntimeException('Reponse inattendue : pas un calendrier iCal');
$title = $item['title_fr'] ?? $item['title'] ?? '(sans titre)'; }
$uid = $item['UID'] ?? $id;
$meta = format_event_meta($item); $entries = [];
$detail = fetch_event_detail($uid); $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>' : '') $description = ($meta !== '' ? '<p>' . htmlspecialchars($meta, ENT_QUOTES, 'UTF-8') . '</p>' : '')
. full_body_html($detail ?? $item, $title); . text_to_html(ical_text($event, 'DESCRIPTION'));
// Un evenement recurrent partage le meme UID sur toutes ses
// occurrences (memes UID, dates differentes) : on suffixe le guid $pub = $created ?? $start;
// avec la date de debut pour rester unique par occurrence, comme $entries[] = [
// l'exige la spec RSS. 'sort' => $start ? $start->getTimestamp() : PHP_INT_MAX,
$items[] = [ 'item' => [
'title' => $title, 'title' => $title,
'link' => SITE . '/agenda/' . slugify($title) . '?u=' . rawurlencode($uid), 'link' => $url !== '' ? $url : 'https://www.silly.be/agenda/',
'guid' => $uid . '-' . ($item['start'] ?? ''), 'guid' => $guid,
'pubDate' => $item['created'] ?? $item['start'] ?? null, 'pubDate' => $pub ? $pub->format('c') : null,
'description' => $description, 'description' => $description,
],
]; ];
} }
usort($entries, fn($a, $b) => $a['sort'] <=> $b['sort']);
return build_rss( return build_rss(
'Silly.be - Agenda', 'Silly.be - Agenda',
SITE . '/agenda', 'https://www.silly.be/agenda/',
"Agenda / événements de la commune de Silly (Belgique)", 'Agenda / événements de la commune de Silly (Belgique)',
$items, array_column($entries, 'item'),
'https://sillycone-village.be/silly-rss/agenda.php' 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). * Fonctions communes aux 3 flux RSS (actualites.php, agenda.php, facebook.php).
* *
* Le site www.silly.be est derriere Cloudflare : son WAF bloque les * Depuis le 14/10/2026, www.silly.be tourne sous WordPress (l'ancienne API
* requetes dont l'empreinte TLS/HTTP2 n'est pas "navigateur" (urllib * JSON Plone / imio.smartweb n'existe plus). Les sources sont desormais :
* Python passe, par ex., mais renvoie un corps vide). L'extension * le flux RSS WordPress natif (actualites) et l'iCal officiel (agenda).
* 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.
*/ */
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'; 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 * Recupere une URL via curl (necessaire pour passer le WAF Cloudflare
* de silly.be) et renvoie le corps de la reponse en texte. * 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); $ch = curl_init($url);
curl_setopt_array($ch, [ curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => USER_AGENT, CURLOPT_USERAGENT => USER_AGENT,
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $extraHeaders), CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 20, CURLOPT_TIMEOUT => 20,
CURLOPT_FOLLOWLOCATION => true, CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3, 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 * $builder() si le cache est absent ou perime. Evite de solliciter
* silly.be / fetchrss.com a chaque poll de chaque abonne FreshRSS. * 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 // text/xml plutot que application/rss+xml : les navigateurs affichent
// ce type en ligne (arbre XML) au lieu de forcer un telechargement, // 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'; $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); readfile($cacheFile);
return; 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 function rfc822(?string $isoDate): string
{ {
try { try {
-275
View File
@@ -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 <smartweb-news> / <smartweb-events> 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'<p><a href="{html.escape(url)}">{html.escape(label)}</a></p>')
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'<p><img src="{html.escape(url)}" alt="{html.escape(title)}" /></p>'
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"<p>{html.escape(summary)}</p>" 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(
" <item>\n"
f" <title>{escape(title)}</title>\n"
f" <link>{escape(link)}</link>\n"
f" <guid isPermaLink=\"false\">{escape(guid)}</guid>\n"
f" <pubDate>{rfc822(pub_dt)}</pubDate>\n"
f" <description>{escape(description)}</description>\n"
" </item>"
)
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"<p>{html.escape(meta)}</p>" 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(
" <item>\n"
f" <title>{escape(title)}</title>\n"
f" <link>{escape(link)}</link>\n"
f" <guid isPermaLink=\"false\">{escape(occurrence_guid)}</guid>\n"
f" <pubDate>{rfc822(pub_dt)}</pubDate>\n"
f" <description>{escape(description)}</description>\n"
" </item>"
)
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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n'
" <channel>\n"
f" <title>{escape(title)}</title>\n"
f" <link>{escape(link)}</link>\n"
f' <atom:link href="{escape(self_url)}" rel="self" type="application/rss+xml" />\n'
f" <description>{escape(description)}</description>\n"
" <language>fr-be</language>\n"
f" <lastBuildDate>{now}</lastBuildDate>\n"
+ "\n".join(items) + "\n"
" </channel>\n"
"</rss>\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()