Two bugs found by testing real links from the feed: - Link format was wrong (?news=<internal-id>), landing on the general listing instead of the article. Traced the real network call the site makes when opening an item and found the actual pattern: /actualites/<slug>?u=<UID> (agenda: /agenda/<slug>?u=<UID>), where the JS router reads the `u` query param to fetch @results?UID=...&fullobjects=1. - Description only used the short teaser (description_fr) even though the API response already includes the full rich-text body (text_fr.data) and attachments (items[].file.download) in the same call — no extra request needed, just wasn't being used. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
194 lines
6.7 KiB
PHP
194 lines
6.7 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;
|
|
}
|
|
|
|
/**
|
|
* Corps HTML complet d'un item (texte riche + pieces jointes), avec
|
|
* repli sur le resume court si le texte complet est vide.
|
|
*/
|
|
function full_body_html(array $item): 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 $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).
|
|
*/
|
|
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;
|
|
}
|