The API already returns image_affiche_scale/image_full_scale for every item (news and events alike), no extra request needed — just wasn't being used. Embedded as an <img> at the top of the HTML description so feed readers display it inline with the article. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
212 lines
7.6 KiB
PHP
212 lines
7.6 KiB
PHP
<?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,
|
|
CURLOPT_MAXREDIRS => 3,
|
|
]);
|
|
$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
|
|
{
|
|
// text/xml plutot que application/rss+xml : les navigateurs affichent
|
|
// ce type en ligne (arbre XML) au lieu de forcer un telechargement,
|
|
// ce qui est plus rassurant pour un visiteur qui clique le lien
|
|
// directement depuis le site. FreshRSS et les autres lecteurs RSS
|
|
// detectent le format en lisant le contenu, pas ce header.
|
|
header('Content-Type: text/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));
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
$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).
|
|
* $selfUrl est l'URL du flux lui-meme (atom:link rel="self", recommande
|
|
* par le validateur W3C).
|
|
*/
|
|
function build_rss(string $title, string $link, string $description, array $items, string $selfUrl): string
|
|
{
|
|
$now = rfc822(null);
|
|
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
|
$xml .= '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">' . "\n <channel>\n";
|
|
$xml .= ' <title>' . xml_escape($title) . "</title>\n";
|
|
$xml .= ' <link>' . xml_escape($link) . "</link>\n";
|
|
$xml .= ' <atom:link href="' . xml_escape($selfUrl) . '" rel="self" type="application/rss+xml" />' . "\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;
|
|
}
|