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:
para
2026-09-25 22:05:26 +02:00
co-authored by Claude Sonnet 5
parent ecf9baaa4d
commit 8205bfdffc
7 changed files with 351 additions and 21 deletions
+137
View File
@@ -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;
}