From dbbe60f28eaa3df7e9c149d47ba66f3a76ec11e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Tue, 19 May 2026 22:27:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20visiteurs=20uniques=20par=20article=20(?= =?UTF-8?q?7/14/30=20j)=20stock=C3=A9s=20dans=20visitors.json?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccessLogParser : suivi des IPs non-bot uniques par /post/ sur 3 fenêtres (7/14/30 j) - index.php : écriture de data/UUID/visitors.json à chaque recalcul des stats admin - post_view.php : affichage du compteur de lecteurs dans la zone hero Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 8 +++++++ public/index.php | 38 ++++++++++++++++++++++++++------ public/version.txt | 2 +- src/AccessLogParser.php | 49 ++++++++++++++++++++++++++++++++--------- templates/post_view.php | 20 ++++++++++++++--- 5 files changed, 95 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cba74f..738df10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.34] - 2026-05-19 + +### Ajouté +- AccessLogParser : calcul des visiteurs uniques par article (IPs non-bot publiques, /post/ statut 200) sur 7 / 14 / 30 jours — stocké dans `data/UUID/visitors.json` +- Page article : affichage du nombre de lecteurs (7 / 14 / 30 jours) dans la zone hero, recalculé à chaque visite de `/admin/stats` + +--- + ## [1.6.33] - 2026-05-19 ### Ajouté diff --git a/public/index.php b/public/index.php index deacb50..6cc9963 100644 --- a/public/index.php +++ b/public/index.php @@ -896,6 +896,13 @@ switch ($action) { $bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null; } + $articleVisitors = []; + $_visFile = DATA_PATH . '/' . ($article['uuid'] ?? '') . '/visitors.json'; + if (($article['uuid'] ?? '') !== '' && is_file($_visFile)) { + $articleVisitors = json_decode((string) file_get_contents($_visFile), true) ?: []; + } + unset($_visFile); + include BASE_PATH . '/templates/post_view.php'; break; @@ -2785,13 +2792,14 @@ switch ($action) { } $statsRaw = [ - 'readable' => $accessParser->isReadable(), - 'books' => $tParser->top($cutoff14, 20, ['/book/']), - 'as' => AsnLookup::aggregateByAs($topIps, $asnMap), - 'pages_by_day' => $accessStats['pages_by_day'] ?? [], - 'ip_data' => $ipData, - 'all_uas' => $accessStats['all_uas'] ?? [], - 'unique_visitors' => $accessStats['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0], + 'readable' => $accessParser->isReadable(), + 'books' => $tParser->top($cutoff14, 20, ['/book/']), + 'as' => AsnLookup::aggregateByAs($topIps, $asnMap), + 'pages_by_day' => $accessStats['pages_by_day'] ?? [], + 'ip_data' => $ipData, + 'all_uas' => $accessStats['all_uas'] ?? [], + 'unique_visitors' => $accessStats['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0], + 'article_unique_visitors' => $accessStats['article_unique_visitors'] ?? [], ]; @file_put_contents($statsCacheFile, json_encode($statsRaw)); } @@ -2805,6 +2813,22 @@ switch ($action) { $adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? []; $adminData['stats_unique_visitors'] = $statsRaw['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0]; + // Écriture des visitors.json par article (slug → UUID via l'index) + $_slugIndex = is_file(DATA_PATH . '/_cache/slug_index.json') + ? (json_decode((string) file_get_contents(DATA_PATH . '/_cache/slug_index.json'), true) ?: []) + : []; + foreach (($statsRaw['article_unique_visitors'] ?? []) as $_artPath => $_artCounts) { + $_artSlug = rawurldecode(substr((string) $_artPath, 6)); + $_artUuid = $_slugIndex[$_artSlug] ?? null; + if ($_artUuid !== null && preg_match('/^[0-9a-f\-]{36}$/i', $_artUuid)) { + @file_put_contents( + DATA_PATH . '/' . $_artUuid . '/visitors.json', + json_encode(array_merge($_artCounts, ['updated' => time()]), JSON_UNESCAPED_UNICODE) + ); + } + } + unset($_slugIndex, $_artPath, $_artCounts, $_artSlug, $_artUuid); + // AS exclus (chargé en direct, pas mis en cache) $excludedAsFile = DATA_PATH . '/excluded_as.json'; $adminData['excluded_as'] = is_file($excludedAsFile) diff --git a/public/version.txt b/public/version.txt index 7337a93..d8462d4 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.33 +1.6.34 diff --git a/src/AccessLogParser.php b/src/AccessLogParser.php index d822641..80219cd 100644 --- a/src/AccessLogParser.php +++ b/src/AccessLogParser.php @@ -10,7 +10,14 @@ class AccessLogParser private int $cacheTtl; private int $days; /** @var list */ - private array $botPatterns; + private array $botPatterns; + + /** @var array> */ + private array $artIp7 = []; + /** @var array> */ + private array $artIp14 = []; + /** @var array> */ + private array $artIp30 = []; private static ?array $memo = null; @@ -46,7 +53,8 @@ class AccessLogParser * ip_top_paths:array>, * ip_agents:array>, * all_uas:array, - * unique_visitors:array + * unique_visitors:array, + * article_unique_visitors:array> * } */ public function stats(): array @@ -151,16 +159,27 @@ class AccessLogParser } } + // Visiteurs uniques par article (IPs publiques non-bot, /post/ statut 200) + $articleUv = []; + foreach ($this->artIp30 as $path => $ips) { + $articleUv[$path] = [ + '7' => count($this->artIp7[$path] ?? []), + '14' => count($this->artIp14[$path] ?? []), + '30' => count($ips), + ]; + } + $result = [ - 'pages' => $pages, - 'books' => $books, - 'ips' => $ips, - 'pages_by_day' => $pagesByDay, - 'ips_by_day' => $ipsByDay, - 'ip_top_paths' => $ipTopPaths, - 'ip_agents' => $ipTopAgents, - 'all_uas' => array_slice($allUas, 0, 300, true), - 'unique_visitors' => $uniqueVisitors, + 'pages' => $pages, + 'books' => $books, + 'ips' => $ips, + 'pages_by_day' => $pagesByDay, + 'ips_by_day' => $ipsByDay, + 'ip_top_paths' => $ipTopPaths, + 'ip_agents' => $ipTopAgents, + 'all_uas' => array_slice($allUas, 0, 300, true), + 'unique_visitors' => $uniqueVisitors, + 'article_unique_visitors' => $articleUv, ]; @mkdir(dirname($this->cacheFile), 0755, true); @file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); @@ -290,6 +309,14 @@ class AccessLogParser if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) { $ipPathTs[$ip][$path] = $tsVal; } + // Visiteurs uniques par article (IPs publiques non-bot uniquement) + $this->artIp30[$path][$ip] = true; + if ($dayOffset >= $this->days - 14) { + $this->artIp14[$path][$ip] = true; + } + if ($dayOffset >= $this->days - 7) { + $this->artIp7[$path][$ip] = true; + } } } elseif (str_ends_with($path, '/') === false && str_starts_with($path, '/book/') && strlen($path) > 6) { $books[$path] = ($books[$path] ?? 0) + 1; diff --git a/templates/post_view.php b/templates/post_view.php index 1719431..ab573db 100644 --- a/templates/post_view.php +++ b/templates/post_view.php @@ -178,15 +178,29 @@ $hasSources = (!empty($externalLinks) || !empty($files))

+ 0): + ?> +

+ + lecteurs · 30 j +  ( / 14 j · / 7 j) + +

+
ℹ Sources ['👍', 'Utile'], - ]; +$_heroReactionDefs = [ + 'useful' => ['👍', 'Utile'], +]; ?>
[$icon, $label]): ?>