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
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 `<smartweb-news>` /
`<smartweb-events>` — 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.
-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
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');
+158 -75
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]];
}
}
return $events;
}
/** Deséchappe une valeur TEXT iCal : \\ \, \; \n. */
function ical_unescape(string $text): string
{
return preg_replace_callback('/\\\\([nN,;\\\\])/', function ($m) {
return ($m[1] === 'n' || $m[1] === 'N') ? "\n" : $m[1];
}, $text);
}
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];
}
$prop = $event[$name][0];
$value = trim($prop['value']);
$display = new DateTimeZone(DISPLAY_TZ);
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;
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];
}
return $cache[$uid];
}
function format_event_meta(array $item): string
function format_event_dates(?DateTimeImmutable $start, ?DateTimeImmutable $end, bool $allDay): string
{
$parts = [];
$start = !empty($item['start']) ? new DateTime($item['start']) : null;
$end = !empty($item['end']) ? new DateTime($item['end']) : null;
if (!$start) {
return '';
}
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');
}
if ($start) {
$parts[] = !empty($item['whole_day'])
? 'Le ' . $start->format('d/m/Y')
: 'Le ' . $start->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;
}
if ($end && (!$start || $end->format('Y-m-d') !== $start->format('Y-m-d'))) {
$parts[] = "jusqu'au " . $end->format('d/m/Y H:i');
$safe = htmlspecialchars($para, ENT_QUOTES, 'UTF-8');
$safe = preg_replace('#(https?://[^\s<]+)#', '<a href="$1">$1</a>', $safe);
$html .= '<p>' . nl2br($safe, false) . '</p>';
}
$address = implode(', ', array_filter([$item['street'] ?? null, $item['zipcode'] ?? null, $item['city'] ?? null]));
if ($address) {
$parts[] = "Lieu : $address";
}
return implode(' — ', $parts);
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[] = [
. text_to_html(ical_text($event, 'DESCRIPTION'));
$pub = $created ?? $start;
$entries[] = [
'sort' => $start ? $start->getTimestamp() : PHP_INT_MAX,
'item' => [
'title' => $title,
'link' => SITE . '/agenda/' . slugify($title) . '?u=' . rawurlencode($uid),
'guid' => $uid . '-' . ($item['start'] ?? ''),
'pubDate' => $item['created'] ?? $item['start'] ?? null,
'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 {
-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()