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. +

+ + +
+ $info): ?> + + + + +
+ + + +

Aucune donnée disponible pour cette période.

+ +

Top articles —

+
    + ['article' => $a, 'visitors' => $v]): ?> +
  1. + +
    + + + + + + +
    + vis. +
  2. + +
+ + + +
+
Flux RSS disponibles
+
+

+ 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. +

+
+ + + + + + + + + + '2 min', '20m' => '4 min', '30m' => '6 min', + '1h' => '12 min', '8h' => '96 min', '1d' => '5 h', + '7d' => '8 h', '14d' => '8 h', '30d' => '8 h', + '1y' => '8 h', + ]; + foreach (TENDANCES_PERIODS as $p => $info): + $url = $base . '/trending?period=' . rawurlencode($p); + ?> + + + + + + + +
PériodeCacheURL
+ + /trending?period= + +
+
+
+
+ + +
+
Méthodologie
+
+
    +
  • Source : journaux d'accès Apache (access.log et rotations .gz).
  • +
  • Seules les requêtes GET sur /post/* avec code HTTP 200 sont comptabilisées.
  • +
  • Un visiteur = une adresse IP distincte par article sur la fenêtre temporelle.
  • +
  • Les IPs ne sont ni stockées ni transmises ; seuls les compteurs agrégés sont conservés en cache.
  • +
  • Les articles dans des catégories privées et les avant-premières ne figurent pas dans les résultats.
  • +
+
+
+ +
+ $pageTitle, + 'content' => $content, + 'mainClass' => '', +]; +extract($templateVars); +require BASE_PATH . '/templates/layout.php'; diff --git a/public/trending.php b/public/trending.php new file mode 100644 index 0000000..a0d741e --- /dev/null +++ b/public/trending.php @@ -0,0 +1,135 @@ + ['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 '' . "\n"; +?> + + + <?= $feedTitle ?> + + + + + + $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); +?> + + <?= $title ?> (<?= $v ?> visiteur<?= $plural ?>) + + + + + + + + diff --git a/public/version.txt b/public/version.txt index 266146b..9edc58b 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.3 +1.6.4 diff --git a/src/TrendingParser.php b/src/TrendingParser.php new file mode 100644 index 0000000..e2334ca --- /dev/null +++ b/src/TrendingParser.php @@ -0,0 +1,193 @@ + $prefixes ex. ['/post/'], ['/post/', '/book/'] + * @return array chemin => nb visiteurs uniques + */ + public function top(int $cutoff, int $limit = 50, array $prefixes = ['/post/']): array + { + $visitors = []; // [path][ip] = true + + foreach ($this->logFiles($cutoff) as $file) { + $this->parseFile($file, $cutoff, $visitors, $prefixes); + } + + $counts = []; + foreach ($visitors as $path => $ips) { + $counts[$path] = count($ips); + } + arsort($counts); + + return array_slice($counts, 0, $limit, true); + } + + /** + * Parse une seule fois les logs et retourne les tops séparés par préfixe. + * Plus efficace que plusieurs appels à top() sur la même période. + * + * @param array $limits préfixe => limite + * @return array> préfixe => (chemin => visiteurs) + */ + public function topGrouped(int $cutoff, array $limits): array + { + $prefixes = array_keys($limits); + $visitors = []; // [path][ip] = true + + foreach ($this->logFiles($cutoff) as $file) { + $this->parseFile($file, $cutoff, $visitors, $prefixes); + } + + $result = array_fill_keys($prefixes, []); + foreach ($visitors as $path => $ips) { + foreach ($prefixes as $prefix) { + if (str_starts_with($path, $prefix)) { + $result[$prefix][$path] = count($ips); + break; + } + } + } + + foreach ($prefixes as $prefix) { + arsort($result[$prefix]); + $result[$prefix] = array_slice($result[$prefix], 0, $limits[$prefix], true); + } + + return $result; + } + + public function isReadable(): bool + { + return count($this->logFiles(time() - 86400)) > 0; + } + + // ── Fichiers de log ─────────────────────────────────────────────────────── + + /** @return list */ + private function logFiles(int $cutoff): array + { + $files = []; + $oldest = $cutoff - 86400; // une journée de marge pour les rotations + + foreach (glob($this->logDir . '/' . $this->pattern) ?: [] as $base) { + if (str_ends_with($base, '.gz') || preg_match('/\.\d+$/', $base)) { + continue; + } + foreach (array_merge([$base], glob($base . '.*') ?: []) as $path) { + if ($path !== $base && filemtime($path) < $oldest) { + continue; + } + if (!is_readable($path)) { + continue; + } + if (str_ends_with($path, '.tar.gz')) { + $files[] = ['path' => $path, 'type' => 'tgz']; + } elseif (str_ends_with($path, '.gz')) { + $files[] = ['path' => $path, 'type' => 'gz']; + } else { + $files[] = ['path' => $path, 'type' => 'plain']; + } + } + } + + return $files; + } + + // ── Parsing ─────────────────────────────────────────────────────────────── + + private static function parseTimestamp(string $raw): int + { + if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) { + return 0; + } + return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}"); + } + + /** + * @param array> $visitors + * @param list $prefixes + */ + private function parseLine(string $line, int $cutoff, array &$visitors, array $prefixes): void + { + if (!preg_match(self::RE, $line, $m)) { + return; + } + [, $ip, $ts, $path, $status] = $m; + + if ($status !== '200') { + return; + } + if (self::parseTimestamp($ts) < $cutoff) { + return; + } + foreach ($prefixes as $prefix) { + if (str_starts_with($path, $prefix) && strlen($path) > strlen($prefix)) { + $visitors[$path][$ip] = true; + break; + } + } + } + + /** + * @param array> $visitors + * @param list $prefixes + */ + private function parseFile(array $file, int $cutoff, array &$visitors, array $prefixes): void + { + if ($file['type'] === 'tgz') { + try { + $phar = new PharData($file['path']); + foreach ($phar as $entry) { + $content = @file_get_contents('phar://' . $file['path'] . '/' . $entry->getFilename()); + if ($content === false) { + continue; + } + foreach (explode("\n", $content) as $line) { + $this->parseLine($line, $cutoff, $visitors, $prefixes); + } + } + } catch (\Exception) { + } + } elseif ($file['type'] === 'gz') { + $h = @gzopen($file['path'], 'rb'); + if (!$h) { + return; + } + while (!gzeof($h)) { + $line = gzgets($h, 8192); + if ($line !== false) { + $this->parseLine($line, $cutoff, $visitors, $prefixes); + } + } + gzclose($h); + } else { + $h = @fopen($file['path'], 'rb'); + if (!$h) { + return; + } + while (($line = fgets($h)) !== false) { + $this->parseLine($line, $cutoff, $visitors, $prefixes); + } + fclose($h); + } + } +} diff --git a/templates/admin_stats.php b/templates/admin_stats.php index 4bad47b..cfaf90b 100644 --- a/templates/admin_stats.php +++ b/templates/admin_stats.php @@ -23,7 +23,7 @@ $_activeGroup = trim($_GET['group'] ?? ''); -

14 derniers jours · cache 10 min

+

14 derniers jours · visiteurs uniques · cache 60 s

@@ -61,7 +61,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
- + vis. @@ -106,7 +106,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
- + vis. diff --git a/templates/post_list.php b/templates/post_list.php index eaa9eed..c3e7e2c 100644 --- a/templates/post_list.php +++ b/templates/post_list.php @@ -159,7 +159,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown

- Tendances · 10 derniers jours + Tendances · 1 heure