Add PHP variant: no-cron, request-time feed generation for shared hosting
Same imio.smartweb JSON API as the Python script, but each feed regenerates on demand (15 min file cache) instead of via cron, so it works on plain PHP shared hosting with no server config access. Also adds facebook.php, a caching proxy for the user's FetchRSS Atom feed so all three feeds live under one domain and can be shared as stable URLs. Tested with PHP 8.3 CLI: valid XML, real data (40 news, 12 events, proxied Facebook feed). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,41 @@
|
|||||||
# Flux RSS - Commune de Silly
|
# Flux RSS - Commune de Silly
|
||||||
|
|
||||||
Genere deux flux RSS 2.0 (actualites + agenda) pour la commune de Silly
|
Genere trois flux RSS/Atom pour la commune de Silly (Belgique) : actualites,
|
||||||
(Belgique), a partir de l'API JSON REST (Plone / `imio.smartweb`) utilisee
|
agenda et page Facebook. Deux implementations equivalentes sont fournies :
|
||||||
par www.silly.be — sans navigateur headless.
|
|
||||||
|
|
||||||
Voir [silly_rss.py](silly_rss.py) pour le detail technique (empreinte
|
- **[php/](php/)** — a deployer sur un hebergement PHP mutualise classique.
|
||||||
Cloudflare, contrainte `b_size=20`, etc.).
|
**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).
|
||||||
|
|
||||||
## Utilisation manuelle
|
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).
|
||||||
|
|
||||||
```bash
|
## Option recommandee : PHP (hebergement perso)
|
||||||
python3 silly_rss.py /chemin/de/sortie
|
|
||||||
```
|
|
||||||
|
|
||||||
Genere `actualites.xml` et `agenda.xml` dans le dossier indique (`.` par
|
1. Copier tout le dossier [php/](php/) sur l'hebergement (FTP/SFTP), par ex.
|
||||||
defaut). Aucune dependance externe : Python 3.10+ (stdlib) et `curl`
|
dans `silly-rss/` a la racine du site.
|
||||||
(present par defaut sur Yunohost).
|
2. Verifier que PHP a l'extension `curl` activee (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`).
|
||||||
|
4. Les flux sont alors accessibles en :
|
||||||
|
- `https://votre-site/silly-rss/actualites.php`
|
||||||
|
- `https://votre-site/silly-rss/agenda.php`
|
||||||
|
- `https://votre-site/silly-rss/facebook.php` (proxy/cache du flux
|
||||||
|
[FetchRSS](https://fetchrss.com) de la page Facebook, pour avoir une
|
||||||
|
URL stable sur votre propre domaine)
|
||||||
|
5. Ajouter ces 3 URLs comme sources dans FreshRSS (Administration > Flux
|
||||||
|
RSS > Ajouter un flux).
|
||||||
|
|
||||||
## Deploiement sur Yunohost
|
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**
|
1. **Dossier de sortie**
|
||||||
|
|
||||||
@@ -30,12 +48,15 @@ defaut). Aucune dependance externe : Python 3.10+ (stdlib) et `curl`
|
|||||||
ligne a ajouter via `crontab -e` (execution toutes les heures).
|
ligne a ajouter via `crontab -e` (execution toutes les heures).
|
||||||
|
|
||||||
3. **nginx** — voir [deploy/nginx-silly-rss.conf](deploy/nginx-silly-rss.conf),
|
3. **nginx** — voir [deploy/nginx-silly-rss.conf](deploy/nginx-silly-rss.conf),
|
||||||
a copier dans `/etc/nginx/conf.d/<votre-domaine>.d/silly-rss.conf` puis :
|
a copier dans `/etc/nginx/conf.d/akbar.nohost.me.d/silly-rss.conf` puis :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo nginx -t && sudo systemctl reload nginx
|
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) :
|
4. **Premiere generation** (avant le premier passage du cron) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -43,13 +64,18 @@ defaut). Aucune dependance externe : Python 3.10+ (stdlib) et `curl`
|
|||||||
```
|
```
|
||||||
|
|
||||||
5. **FreshRSS** — ajouter comme sources :
|
5. **FreshRSS** — ajouter comme sources :
|
||||||
- `https://<votre-domaine>/silly-rss/actualites.xml`
|
- `https://akbar.nohost.me/silly-rss/actualites.xml`
|
||||||
- `https://<votre-domaine>/silly-rss/agenda.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 : le HTML de facebook.com est vide de
|
Pas de solution fiable trouvee en scraping direct : le HTML de
|
||||||
contenu exploitable sans authentification, et l'instance RSS-Bridge testee
|
facebook.com est vide de contenu exploitable sans authentification, et
|
||||||
(`FacebookBridge`) renvoie une erreur 400. Recommandation : un service
|
l'instance RSS-Bridge testee (`FacebookBridge`) renvoie une erreur 400.
|
||||||
tiers specialise (RSS.app, Fetch RSS) plutot qu'un scraper maison, qui
|
Solution retenue : [FetchRSS](https://fetchrss.com) (service tiers). La
|
||||||
sera cassé au premier changement de Facebook.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
cache/*.xml
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_news_feed(): string
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach (fetch_all_news() as $item) {
|
||||||
|
$id = $item['id'] ?? '';
|
||||||
|
$summary = strip_html($item['description_fr'] ?? $item['description'] ?? null);
|
||||||
|
$items[] = [
|
||||||
|
'title' => $item['title_fr'] ?? $item['title'] ?? '(sans titre)',
|
||||||
|
'link' => SITE . '/actualites?news=' . rawurlencode($id),
|
||||||
|
'guid' => $item['UID'] ?? $id,
|
||||||
|
'pubDate' => $item['effective'] ?? $item['created'] ?? null,
|
||||||
|
'description' => $summary,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return build_rss(
|
||||||
|
'Silly.be - Actualités',
|
||||||
|
SITE . '/actualites',
|
||||||
|
'Actualités de la commune de Silly (Belgique)',
|
||||||
|
$items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
serve_cached_feed('actualites', 'build_news_feed');
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function format_event_meta(array $item): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
$start = !empty($item['start']) ? new DateTime($item['start']) : null;
|
||||||
|
$end = !empty($item['end']) ? new DateTime($item['end']) : null;
|
||||||
|
|
||||||
|
if ($start) {
|
||||||
|
$parts[] = !empty($item['whole_day'])
|
||||||
|
? 'Le ' . $start->format('d/m/Y')
|
||||||
|
: 'Le ' . $start->format('d/m/Y à H:i');
|
||||||
|
}
|
||||||
|
if ($end && (!$start || $end->format('Y-m-d') !== $start->format('Y-m-d'))) {
|
||||||
|
$parts[] = "jusqu'au " . $end->format('d/m/Y H:i');
|
||||||
|
}
|
||||||
|
$address = implode(', ', array_filter([$item['street'] ?? null, $item['zipcode'] ?? null, $item['city'] ?? null]));
|
||||||
|
if ($address) {
|
||||||
|
$parts[] = "Lieu : $address";
|
||||||
|
}
|
||||||
|
return implode(' — ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function build_events_feed(): string
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach (fetch_all_events() as $item) {
|
||||||
|
$id = basename(rtrim($item['@id'] ?? '', '/'));
|
||||||
|
$summary = strip_html($item['description_fr'] ?? $item['description'] ?? null);
|
||||||
|
$meta = format_event_meta($item);
|
||||||
|
$description = implode(' | ', array_filter([$meta, $summary]));
|
||||||
|
$items[] = [
|
||||||
|
'title' => $item['title_fr'] ?? $item['title'] ?? '(sans titre)',
|
||||||
|
'link' => SITE . '/agenda?event=' . rawurlencode($id),
|
||||||
|
'guid' => $item['UID'] ?? $id,
|
||||||
|
'pubDate' => $item['created'] ?? $item['start'] ?? null,
|
||||||
|
'description' => $description,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return build_rss(
|
||||||
|
'Silly.be - Agenda',
|
||||||
|
SITE . '/agenda',
|
||||||
|
"Agenda / événements de la commune de Silly (Belgique)",
|
||||||
|
$items
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
serve_cached_feed('agenda', 'build_events_feed');
|
||||||
Vendored
+137
@@ -0,0 +1,137 @@
|
|||||||
|
<?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
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 CACHE_DIR = __DIR__ . '/cache';
|
||||||
|
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
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_USERAGENT => USER_AGENT,
|
||||||
|
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $extraHeaders),
|
||||||
|
CURLOPT_TIMEOUT => 20,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
]);
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$errno = curl_errno($ch);
|
||||||
|
$error = curl_error($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($errno !== 0) {
|
||||||
|
throw new RuntimeException("curl error fetching $url: $error");
|
||||||
|
}
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException("HTTP $status fetching $url");
|
||||||
|
}
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sert un contenu XML depuis un cache fichier, en le regenerant via
|
||||||
|
* $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
|
||||||
|
{
|
||||||
|
header('Content-Type: application/rss+xml; charset=utf-8');
|
||||||
|
|
||||||
|
if (!is_dir(CACHE_DIR)) {
|
||||||
|
mkdir(CACHE_DIR, 0775, true);
|
||||||
|
}
|
||||||
|
$cacheFile = CACHE_DIR . '/' . $cacheKey . '.xml';
|
||||||
|
|
||||||
|
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL_SECONDS) {
|
||||||
|
readfile($cacheFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$xml = $builder();
|
||||||
|
file_put_contents($cacheFile, $xml);
|
||||||
|
echo $xml;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
// En cas d'erreur (site source indisponible, etc.), on sert le
|
||||||
|
// cache perime plutot que de casser le flux chez les abonnes.
|
||||||
|
if (is_file($cacheFile)) {
|
||||||
|
readfile($cacheFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
http_response_code(502);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo "Erreur de generation du flux : " . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rfc822(?string $isoDate): string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$dt = $isoDate ? new DateTime($isoDate) : new DateTime('now', new DateTimeZone('UTC'));
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$dt = new DateTime('now', new DateTimeZone('UTC'));
|
||||||
|
}
|
||||||
|
return $dt->format(DateTime::RSS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xml_escape(string $text): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($text, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un flux RSS 2.0 complet a partir d'une liste d'items deja
|
||||||
|
* formates (chacun un tableau title/link/guid/pubDate/description).
|
||||||
|
*/
|
||||||
|
function build_rss(string $title, string $link, string $description, array $items): string
|
||||||
|
{
|
||||||
|
$now = rfc822(null);
|
||||||
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
$xml .= '<rss version="2.0">' . "\n <channel>\n";
|
||||||
|
$xml .= ' <title>' . xml_escape($title) . "</title>\n";
|
||||||
|
$xml .= ' <link>' . xml_escape($link) . "</link>\n";
|
||||||
|
$xml .= ' <description>' . xml_escape($description) . "</description>\n";
|
||||||
|
$xml .= " <language>fr-be</language>\n";
|
||||||
|
$xml .= " <lastBuildDate>$now</lastBuildDate>\n";
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$xml .= " <item>\n";
|
||||||
|
$xml .= ' <title>' . xml_escape($item['title']) . "</title>\n";
|
||||||
|
$xml .= ' <link>' . xml_escape($item['link']) . "</link>\n";
|
||||||
|
$xml .= ' <guid isPermaLink="false">' . xml_escape($item['guid']) . "</guid>\n";
|
||||||
|
$xml .= ' <pubDate>' . rfc822($item['pubDate'] ?? null) . "</pubDate>\n";
|
||||||
|
$xml .= ' <description>' . xml_escape($item['description']) . "</description>\n";
|
||||||
|
$xml .= " </item>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml .= " </channel>\n</rss>\n";
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/common.php';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy/cache du flux Facebook (page communedesilly) genere par
|
||||||
|
* FetchRSS (https://fetchrss.com). Scraper Facebook nous-memes n'est
|
||||||
|
* pas fiable (mur de connexion, blocage anti-bot systematique -
|
||||||
|
* confirme aussi en cassant l'instance RSS-Bridge testee) ; FetchRSS
|
||||||
|
* s'en charge et on se contente de re-servir/cacher son flux sous
|
||||||
|
* notre propre domaine pour avoir une URL stable a partager.
|
||||||
|
*/
|
||||||
|
const FETCHRSS_URL = 'https://fetchrss.com/feed/1xABzW0ZB9CM1xAByX0rYAhW.atom';
|
||||||
|
|
||||||
|
function fetch_facebook_feed(): string
|
||||||
|
{
|
||||||
|
return http_get(FETCHRSS_URL, ['Accept: application/atom+xml, application/xml']);
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/atom+xml; charset=utf-8');
|
||||||
|
|
||||||
|
if (!is_dir(CACHE_DIR)) {
|
||||||
|
mkdir(CACHE_DIR, 0775, true);
|
||||||
|
}
|
||||||
|
$cacheFile = CACHE_DIR . '/facebook.xml';
|
||||||
|
|
||||||
|
if (is_file($cacheFile) && (time() - filemtime($cacheFile)) < CACHE_TTL_SECONDS) {
|
||||||
|
readfile($cacheFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$xml = fetch_facebook_feed();
|
||||||
|
file_put_contents($cacheFile, $xml);
|
||||||
|
echo $xml;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
if (is_file($cacheFile)) {
|
||||||
|
readfile($cacheFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
http_response_code(502);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo 'Erreur de recuperation du flux Facebook : ' . $e->getMessage();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user