diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23c248b..e7b644b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,20 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
---
+## [1.6.4] - 2026-05-15
+
+### Ajouté
+- `src/TrendingParser.php` : parseur de logs Apache comptant les visiteurs uniques (IPs distinctes, HTTP 200) par article, avec support multi-préfixes et méthode `topGrouped()` (un seul parse pour pages + livres)
+- `public/trending.php` : flux RSS des 50 articles les plus consultés, paramétrable par période (`?period=10m|20m|30m|1h|8h|1d|7d|14d|30d|1y`), cache TTL adaptatif
+- `public/tendances.php` : page publique présentant les tendances par période, les flux RSS disponibles et la méthodologie
+- Route `/tendances` dans `.htaccess`
+
+### Modifié
+- `/admin/stats` : utilise `TrendingParser` (visiteurs uniques) au lieu d'`AccessLogParser` (hits bruts) pour les pages et les livres ; label mis à jour
+- Page d'accueil — rubrique Tendances : source principale désormais les logs Apache sur 1 heure (cache 12 min), fallback sur le score pondéré DB si les logs ne sont pas lisibles
+
+---
+
## [1.6.3] - 2026-05-15
### Ajouté
diff --git a/public/.htaccess b/public/.htaccess
index 9e81872..aa662e6 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -57,6 +57,7 @@ RewriteRule ^verify-comment/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
RewriteRule ^search/?$ /index.php?action=search [L,QSA]
+RewriteRule ^tendances/?$ /tendances.php [L,QSA]
RewriteRule ^flux/?$ /index.php?action=flux [L,QSA]
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
diff --git a/public/index.php b/public/index.php
index 78ae56b..279813e 100644
--- a/public/index.php
+++ b/public/index.php
@@ -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
diff --git a/public/tendances.php b/public/tendances.php
new file mode 100644
index 0000000..e7ede92
--- /dev/null
+++ b/public/tendances.php
@@ -0,0 +1,201 @@
+ ['seconds' => 600, 'label' => '10 dernières minutes', 'short' => '10 min'],
+ '20m' => ['seconds' => 1200, 'label' => '20 dernières minutes', 'short' => '20 min'],
+ '30m' => ['seconds' => 1800, 'label' => '30 dernières minutes', 'short' => '30 min'],
+ '1h' => ['seconds' => 3600, 'label' => 'dernière heure', 'short' => '1 h'],
+ '8h' => ['seconds' => 28800, 'label' => '8 dernières heures', 'short' => '8 h'],
+ '1d' => ['seconds' => 86400, 'label' => '24 dernières heures', 'short' => '24 h'],
+ '7d' => ['seconds' => 604800, 'label' => '7 derniers jours', 'short' => '7 j'],
+ '14d' => ['seconds' => 1209600, 'label' => '14 derniers jours', 'short' => '14 j'],
+ '30d' => ['seconds' => 2592000, 'label' => '30 derniers jours', 'short' => '30 j'],
+ '1y' => ['seconds' => 31536000, 'label' => 'dernière année', 'short' => '1 an'],
+];
+
+// Période active (affichage du top)
+$period = $_GET['period'] ?? '1d';
+if (!array_key_exists($period, TENDANCES_PERIODS)) {
+ $period = '1d';
+}
+
+$seconds = TENDANCES_PERIODS[$period]['seconds'];
+$label = TENDANCES_PERIODS[$period]['label'];
+$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
+
+// Cache partagé avec trending.php
+@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(time() - $seconds, 50);
+ @file_put_contents($cacheFile, json_encode($topPaths));
+}
+
+// Index slug → article
+$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;
+}
+
+// Top articles pour la période affichée
+$topItems = [];
+foreach ($topPaths as $path => $visitors) {
+ if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
+ continue;
+ }
+ $article = $slugIndex[rawurldecode($m[1])] ?? null;
+ if ($article === null) {
+ continue;
+ }
+ $topItems[] = ['article' => $article, 'visitors' => (int) $visitors];
+ if (count($topItems) >= 20) {
+ break;
+ }
+}
+
+$base = rtrim(APP_URL, '/');
+$pageTitle = 'Tendances — ' . siteTitle();
+
+ob_start();
+?>
+
+
+
Tendances
+
+ Articles les plus consultés, calculés en temps réel depuis les journaux d'accès du serveur.
+ Seuls les visiteurs uniques (une IP = un visiteur) sur des réponses 200 sont comptabilisés.
+ Aucun cookie, aucun traceur tiers.
+
+ Chaque flux retourne les 50 articles les plus consultés pour la période choisie,
+ mis à jour automatiquement. Abonnez-vous à celui qui correspond à vos besoins.
+