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))
= htmlspecialchars($modDate) ?>
+ 0):
+ ?>
+
+
+ = number_format($_v30, 0, ',', "\xE2\x80\xAF") ?> lecteurs · 30 j
+ (= $_v14 ?> / 14 j · = $_v7 ?> / 7 j)
+
+
+
ℹ Sources
['👍', 'Utile'],
- ];
+$_heroReactionDefs = [
+ 'useful' => ['👍', 'Utile'],
+];
?>
[$icon, $label]): ?>