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 .= '

' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '

'; } 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 '

'
        . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '

'; } /** * 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 !== '' ? '

' . htmlspecialchars($summary, ENT_QUOTES, 'UTF-8') . '

' : ''; } 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 = '' . "\n"; $xml .= '' . "\n \n"; $xml .= ' ' . xml_escape($title) . "\n"; $xml .= ' ' . xml_escape($link) . "\n"; $xml .= ' ' . "\n"; $xml .= ' ' . xml_escape($description) . "\n"; $xml .= " fr-be\n"; $xml .= " $now\n"; foreach ($items as $item) { $xml .= " \n"; $xml .= ' ' . xml_escape($item['title']) . "\n"; $xml .= ' ' . xml_escape($item['link']) . "\n"; $xml .= ' ' . xml_escape($item['guid']) . "\n"; $xml .= ' ' . rfc822($item['pubDate'] ?? null) . "\n"; $xml .= ' ' . xml_escape($item['description']) . "\n"; $xml .= " \n"; } $xml .= " \n\n"; return $xml; }