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:
@@ -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.
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
+158
-75
@@ -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]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
try {
|
||||||
$url = EVENTS_API . '?UID=' . rawurlencode($uid) . '&fullobjects=1';
|
if (($prop['params']['VALUE'] ?? '') === 'DATE' || preg_match('/^\d{8}$/', $value)) {
|
||||||
$data = json_decode(http_get($url), true);
|
return [new DateTimeImmutable($value, $display), true];
|
||||||
$cache[$uid] = $data['items'][0] ?? null;
|
}
|
||||||
} catch (Throwable $e) {
|
if (substr($value, -1) === 'Z') {
|
||||||
$cache[$uid] = null;
|
$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 = [];
|
if (!$start) {
|
||||||
$start = !empty($item['start']) ? new DateTime($item['start']) : null;
|
return '';
|
||||||
$end = !empty($item['end']) ? new DateTime($item['end']) : null;
|
}
|
||||||
|
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) {
|
/** Texte brut -> HTML : paragraphes sur lignes vides, <br> sur retours simples, URLs cliquables. */
|
||||||
$parts[] = !empty($item['whole_day'])
|
function text_to_html(string $text): string
|
||||||
? 'Le ' . $start->format('d/m/Y')
|
{
|
||||||
: 'Le ' . $start->format('d/m/Y à H:i');
|
$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'))) {
|
$safe = htmlspecialchars($para, ENT_QUOTES, 'UTF-8');
|
||||||
$parts[] = "jusqu'au " . $end->format('d/m/Y H:i');
|
$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]));
|
return $html;
|
||||||
if ($address) {
|
|
||||||
$parts[] = "Lieu : $address";
|
|
||||||
}
|
|
||||||
return implode(' — ', $parts);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@@ -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
@@ -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()
|
|
||||||
Reference in New Issue
Block a user