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>
This commit is contained in:
2026-05-15 17:38:06 +02:00
parent 21f6e75878
commit 18b7194069
9 changed files with 624 additions and 38 deletions
+75 -33
View File
@@ -2549,6 +2549,7 @@ switch ($action) {
http_response_code(403);
exit;
}
require_once BASE_PATH . '/src/TrendingParser.php';
require_once BASE_PATH . '/src/AccessLogParser.php';
require_once BASE_PATH . '/src/AsnLookup.php';
@@ -2558,15 +2559,20 @@ switch ($action) {
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
}
if ($statsRaw === null) {
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
$grouped = $tParser->topGrouped($cutoff14, ['/post/' => 30, '/book/' => 20]);
// IPs pour le lookup ASN (AccessLogParser conserve le comptage brut par IP)
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
$accessStats = $accessParser->stats();
$topIps = array_slice($accessStats['ips'], 0, 200, true);
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
$statsRaw = [
'readable' => $accessParser->isReadable(),
'pages' => array_slice($accessStats['pages'], 0, 30, true),
'books' => array_slice($accessStats['books'], 0, 20, true),
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
'readable' => $tParser->isReadable(),
'pages' => $grouped['/post/'],
'books' => $grouped['/book/'],
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
];
@file_put_contents($statsCacheFile, json_encode($statsRaw));
}
@@ -3445,38 +3451,74 @@ switch ($action) {
unset($_heroUuid, $_count, $_hp);
$allPostsMap = array_column($allPosts, null, 'uuid');
$_slugMap = array_column($allPosts, null, 'slug');
// Articles populaires (10 derniers jours) — score pondéré
// Tendances 1 h — logs Apache (visiteurs uniques, cache 12 min)
require_once BASE_PATH . '/src/TrendingParser.php';
$_trendCache = DATA_PATH . '/_cache/trending_1h.json';
$_trendPaths = null;
if (file_exists($_trendCache) && (time() - filemtime($_trendCache)) < 720) {
$_trendPaths = json_decode((string) file_get_contents($_trendCache), true) ?: null;
}
if ($_trendPaths === null) {
$_tp = new TrendingParser('/var/log/apache2', apacheAccessLog());
if ($_tp->isReadable()) {
$_trendPaths = $_tp->top(time() - 3600, 20);
@mkdir(DATA_PATH . '/_cache', 0755, true);
@file_put_contents($_trendCache, json_encode($_trendPaths));
}
unset($_tp);
}
if (!empty($_trendPaths)) {
foreach ($_trendPaths as $_path => $_cnt) {
if (count($popularPosts) >= 6) {
break;
}
if (!preg_match('#^/post/([^/]+)$#', $_path, $_m)) {
continue;
}
$_a = $_slugMap[rawurldecode($_m[1])] ?? null;
if ($_a !== null) {
$popularPosts[] = $_a;
}
}
unset($_path, $_cnt, $_m, $_a);
}
unset($_trendCache, $_trendPaths, $_slugMap);
// Fallback : score pondéré DB (réactions, notes, commentaires sur 10 j)
$_pdo = dbPdo();
if ($_pdo) {
try {
$_stmt = $_pdo->query("
SELECT article_uuid, SUM(score) AS total
FROM (
SELECT article_uuid, 1 AS score FROM article_reactions
WHERE created_at >= NOW() - INTERVAL '10 days'
UNION ALL
SELECT article_uuid, 2 AS score FROM article_ratings
WHERE rated_at >= NOW() - INTERVAL '10 days'
UNION ALL
SELECT article_uuid, 3 AS score FROM comments
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
) ev
GROUP BY article_uuid
ORDER BY total DESC
LIMIT 20
");
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
if (count($popularPosts) >= 6) {
break;
if (empty($popularPosts)) {
try {
$_stmt = $_pdo->query("
SELECT article_uuid, SUM(score) AS total
FROM (
SELECT article_uuid, 1 AS score FROM article_reactions
WHERE created_at >= NOW() - INTERVAL '10 days'
UNION ALL
SELECT article_uuid, 2 AS score FROM article_ratings
WHERE rated_at >= NOW() - INTERVAL '10 days'
UNION ALL
SELECT article_uuid, 3 AS score FROM comments
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
) ev
GROUP BY article_uuid
ORDER BY total DESC
LIMIT 20
");
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
if (count($popularPosts) >= 6) {
break;
}
$_uuid = $_row['article_uuid'];
if (!isset($allPostsMap[$_uuid])) {
continue;
}
$popularPosts[] = $allPostsMap[$_uuid];
}
$_uuid = $_row['article_uuid'];
if (!isset($allPostsMap[$_uuid])) {
continue;
}
$popularPosts[] = $allPostsMap[$_uuid];
} catch (Throwable) {
}
} catch (Throwable) {
}
// Redécouvertes : anciens articles (> 30 j) avec activité récente