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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
@@ -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;
|
||||
}
|
||||
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;
|
||||
$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;
|
||||
}
|
||||
return $cache[$uid];
|
||||
$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;
|
||||
}
|
||||
|
||||
function format_event_meta(array $item): string
|
||||
/** Deséchappe une valeur TEXT iCal : \\ \, \; \n. */
|
||||
function ical_unescape(string $text): string
|
||||
{
|
||||
$parts = [];
|
||||
$start = !empty($item['start']) ? new DateTime($item['start']) : null;
|
||||
$end = !empty($item['end']) ? new DateTime($item['end']) : null;
|
||||
return preg_replace_callback('/\\\\([nN,;\\\\])/', function ($m) {
|
||||
return ($m[1] === 'n' || $m[1] === 'N') ? "\n" : $m[1];
|
||||
}, $text);
|
||||
}
|
||||
|
||||
if ($start) {
|
||||
$parts[] = !empty($item['whole_day'])
|
||||
? 'Le ' . $start->format('d/m/Y')
|
||||
: 'Le ' . $start->format('d/m/Y à H:i');
|
||||
function ical_text(array $event, string $name): string
|
||||
{
|
||||
return isset($event[$name][0]) ? trim(ical_unescape($event[$name][0]['value'])) : '';
|
||||
}
|
||||
|
||||
/** Renvoie [DateTimeImmutable|null, bool $allDay] pour DTSTART/DTEND/CREATED. */
|
||||
function ical_date(array $event, string $name): array
|
||||
{
|
||||
if (!isset($event[$name][0])) {
|
||||
return [null, false];
|
||||
}
|
||||
if ($end && (!$start || $end->format('Y-m-d') !== $start->format('Y-m-d'))) {
|
||||
$parts[] = "jusqu'au " . $end->format('d/m/Y H:i');
|
||||
$prop = $event[$name][0];
|
||||
$value = trim($prop['value']);
|
||||
$display = new DateTimeZone(DISPLAY_TZ);
|
||||
try {
|
||||
if (($prop['params']['VALUE'] ?? '') === 'DATE' || preg_match('/^\d{8}$/', $value)) {
|
||||
return [new DateTimeImmutable($value, $display), true];
|
||||
}
|
||||
$address = implode(', ', array_filter([$item['street'] ?? null, $item['zipcode'] ?? null, $item['city'] ?? null]));
|
||||
if ($address) {
|
||||
$parts[] = "Lieu : $address";
|
||||
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 implode(' — ', $parts);
|
||||
return [$dt->setTimezone($display), false];
|
||||
} catch (Exception $e) {
|
||||
return [null, false];
|
||||
}
|
||||
}
|
||||
|
||||
function format_event_dates(?DateTimeImmutable $start, ?DateTimeImmutable $end, bool $allDay): string
|
||||
{
|
||||
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');
|
||||
}
|
||||
|
||||
/** Texte brut -> HTML : paragraphes sur lignes vides, <br> sur retours simples, URLs cliquables. */
|
||||
function text_to_html(string $text): string
|
||||
{
|
||||
$html = '';
|
||||
foreach (preg_split('/\n{2,}/', trim($text)) as $para) {
|
||||
$para = trim($para);
|
||||
if ($para === '') {
|
||||
continue;
|
||||
}
|
||||
$safe = htmlspecialchars($para, ENT_QUOTES, 'UTF-8');
|
||||
$safe = preg_replace('#(https?://[^\s<]+)#', '<a href="$1">$1</a>', $safe);
|
||||
$html .= '<p>' . nl2br($safe, false) . '</p>';
|
||||
}
|
||||
return $html;
|
||||
}
|
||||
|
||||
function build_events_feed(): string
|
||||
{
|
||||
$items = [];
|
||||
foreach (fetch_all_events() as $item) {
|
||||
$id = basename(rtrim($item['@id'] ?? '', '/'));
|
||||
$title = $item['title_fr'] ?? $item['title'] ?? '(sans titre)';
|
||||
$uid = $item['UID'] ?? $id;
|
||||
$meta = format_event_meta($item);
|
||||
$detail = fetch_event_detail($uid);
|
||||
$ical = http_get(ICAL_URL, ['Accept: text/calendar, */*']);
|
||||
if (strpos($ical, 'BEGIN:VCALENDAR') === false) {
|
||||
throw new RuntimeException('Reponse inattendue : pas un calendrier iCal');
|
||||
}
|
||||
|
||||
$entries = [];
|
||||
$seen = [];
|
||||
foreach (parse_ical_events($ical) as $event) {
|
||||
[$start, $allDay] = ical_date($event, 'DTSTART');
|
||||
[$end] = ical_date($event, 'DTEND');
|
||||
[$created] = ical_date($event, 'CREATED');
|
||||
|
||||
$title = ical_text($event, 'SUMMARY') ?: '(sans titre)';
|
||||
$uid = ical_text($event, 'UID') ?: $title . '-' . ($start ? $start->format('c') : '');
|
||||
$url = ical_text($event, 'URL');
|
||||
$location = ical_text($event, 'LOCATION');
|
||||
|
||||
$guid = $uid;
|
||||
if (isset($seen[$guid])) { // occurrence d'un evenement repete : meme UID
|
||||
$guid .= '-' . ($start ? $start->format('YmdHi') : count($seen));
|
||||
}
|
||||
$seen[$guid] = true;
|
||||
|
||||
$meta = format_event_dates($start, $end, $allDay);
|
||||
if ($location !== '') {
|
||||
$meta .= ($meta !== '' ? ' — ' : '') . 'Lieu : ' . $location;
|
||||
}
|
||||
$description = ($meta !== '' ? '<p>' . htmlspecialchars($meta, ENT_QUOTES, 'UTF-8') . '</p>' : '')
|
||||
. full_body_html($detail ?? $item, $title);
|
||||
// Un evenement recurrent partage le meme UID sur toutes ses
|
||||
// occurrences (memes UID, dates differentes) : on suffixe le guid
|
||||
// avec la date de debut pour rester unique par occurrence, comme
|
||||
// l'exige la spec RSS.
|
||||
$items[] = [
|
||||
. 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
@@ -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
@@ -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