Files
cedricAbonnel 18b7194069 feat : TrendingParser + flux RSS tendances + page publique /tendances (v1.6.4)
- TrendingParser : lit les logs Apache, compte les visiteurs uniques (IPs, HTTP 200),
  supporte plusieurs préfixes (/post/, /book/) et un seul parse via topGrouped()
- /trending?period=… : flux RSS des 50 articles les plus consultés, 10 périodes
  de 10 min à 1 an, cache TTL adaptatif
- /tendances : page publique avec sélecteur de période, top 20 articles,
  tableau des flux RSS et section méthodologie
- /admin/stats : remplace AccessLogParser (hits) par TrendingParser (visiteurs uniques)
- Page d'accueil : rubrique Tendances alimentée par les logs 1h (fallback DB)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:38:06 +02:00

136 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
define('BASE_PATH', realpath(__DIR__ . '/../'));
require_once BASE_PATH . '/src/auth.php';
require_once BASE_PATH . '/src/SiteSettings.php';
require_once BASE_PATH . '/config/config.php';
require_once BASE_PATH . '/src/ArticleManager.php';
require_once BASE_PATH . '/src/TrendingParser.php';
// ── Périodes supportées ───────────────────────────────────────────────────────
const TRENDING_PERIODS = [
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes'],
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes'],
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes'],
'1h' => ['seconds' => 3600, 'label' => 'dernière heure'],
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures'],
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures'],
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours'],
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours'],
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours'],
'1y' => ['seconds' => 31536000, 'label' => 'dernière année'],
];
$period = $_GET['period'] ?? '1d';
if (!array_key_exists($period, TRENDING_PERIODS)) {
http_response_code(400);
header('Content-Type: text/plain; charset=UTF-8');
echo 'Période invalide. Valeurs acceptées : ' . implode(', ', array_keys(TRENDING_PERIODS));
exit;
}
$seconds = TRENDING_PERIODS[$period]['seconds'];
$label = TRENDING_PERIODS[$period]['label'];
$cutoff = time() - $seconds;
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
// ── Cache ─────────────────────────────────────────────────────────────────────
@mkdir(DATA_PATH . '/_cache', 0755, true);
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
$topPaths = null;
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
}
if ($topPaths === null) {
$parser = new TrendingParser('/var/log/apache2', apacheAccessLog());
$topPaths = $parser->top($cutoff, 50);
@file_put_contents($cacheFile, json_encode($topPaths));
}
// ── Index slug → article (publiés, non privés) ────────────────────────────────
$articleManager = new ArticleManager(DATA_PATH);
$now = time();
$privateCats = $articleManager->getPrivateCategories();
$slugIndex = [];
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
continue;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true)) {
continue;
}
$slugIndex[$a['slug']] = $a;
}
// ── Construction des items ────────────────────────────────────────────────────
$base = rtrim(APP_URL, '/');
$items = [];
foreach ($topPaths as $path => $visitors) {
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
continue;
}
$slug = rawurldecode($m[1]);
$article = $slugIndex[$slug] ?? null;
if ($article === null) {
continue;
}
$items[] = ['article' => $article, 'visitors' => (int) $visitors];
if (count($items) >= 50) {
break;
}
}
// ── Réponse RSS ───────────────────────────────────────────────────────────────
header('Content-Type: application/rss+xml; charset=UTF-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: public, max-age=' . $cacheTtl);
$feedTitle = htmlspecialchars(siteTitle() . ' — Tendances (' . $label . ')', ENT_XML1);
$feedUrl = htmlspecialchars($base . '/trending?period=' . rawurlencode($period), ENT_XML1);
$baseXml = htmlspecialchars($base, ENT_XML1);
$buildDate = htmlspecialchars(date(DATE_RSS));
$descXml = htmlspecialchars('Top 50 articles par visiteurs uniques — ' . $label, ENT_XML1);
$langXml = htmlspecialchars(siteLang(), ENT_XML1);
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title><?= $feedTitle ?></title>
<link><?= $baseXml ?></link>
<description><?= $descXml ?></description>
<language><?= $langXml ?></language>
<lastBuildDate><?= $buildDate ?></lastBuildDate>
<atom:link href="<?= $feedUrl ?>" rel="self" type="application/rss+xml"/>
<?php foreach ($items as ['article' => $a, 'visitors' => $v]):
$link = htmlspecialchars($base . '/post/' . rawurlencode($a['slug']), ENT_XML1);
$pubDate = htmlspecialchars(date(DATE_RSS, (int) strtotime((string) ($a['published_at'] ?? $a['created_at'] ?? ''))));
$title = htmlspecialchars(($a['title'] ?? ''), ENT_XML1);
$plural = $v > 1 ? 's' : '';
$desc = htmlspecialchars($title . ' — ' . $v . ' visiteur' . $plural . ' unique' . $plural . ' (' . $label . ')', ENT_XML1);
?>
<item>
<title><?= $title ?> (<?= $v ?> visiteur<?= $plural ?>)</title>
<link><?= $link ?></link>
<description><?= $desc ?></description>
<pubDate><?= $pubDate ?></pubDate>
<guid isPermaLink="true"><?= $link ?></guid>
</item>
<?php endforeach; ?>
</channel>
</rss>