feat: visiteurs uniques par article (7/14/30 j) stockés dans visitors.json
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
## [1.6.33] - 2026-05-19
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
+31
-7
@@ -896,6 +896,13 @@ switch ($action) {
|
|||||||
$bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
|
$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';
|
include BASE_PATH . '/templates/post_view.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -2785,13 +2792,14 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$statsRaw = [
|
$statsRaw = [
|
||||||
'readable' => $accessParser->isReadable(),
|
'readable' => $accessParser->isReadable(),
|
||||||
'books' => $tParser->top($cutoff14, 20, ['/book/']),
|
'books' => $tParser->top($cutoff14, 20, ['/book/']),
|
||||||
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
||||||
'pages_by_day' => $accessStats['pages_by_day'] ?? [],
|
'pages_by_day' => $accessStats['pages_by_day'] ?? [],
|
||||||
'ip_data' => $ipData,
|
'ip_data' => $ipData,
|
||||||
'all_uas' => $accessStats['all_uas'] ?? [],
|
'all_uas' => $accessStats['all_uas'] ?? [],
|
||||||
'unique_visitors' => $accessStats['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0],
|
'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));
|
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||||||
}
|
}
|
||||||
@@ -2805,6 +2813,22 @@ switch ($action) {
|
|||||||
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
|
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
|
||||||
$adminData['stats_unique_visitors'] = $statsRaw['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
|
$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)
|
// AS exclus (chargé en direct, pas mis en cache)
|
||||||
$excludedAsFile = DATA_PATH . '/excluded_as.json';
|
$excludedAsFile = DATA_PATH . '/excluded_as.json';
|
||||||
$adminData['excluded_as'] = is_file($excludedAsFile)
|
$adminData['excluded_as'] = is_file($excludedAsFile)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.6.33
|
1.6.34
|
||||||
|
|||||||
+38
-11
@@ -10,7 +10,14 @@ class AccessLogParser
|
|||||||
private int $cacheTtl;
|
private int $cacheTtl;
|
||||||
private int $days;
|
private int $days;
|
||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
private array $botPatterns;
|
private array $botPatterns;
|
||||||
|
|
||||||
|
/** @var array<string,array<string,true>> */
|
||||||
|
private array $artIp7 = [];
|
||||||
|
/** @var array<string,array<string,true>> */
|
||||||
|
private array $artIp14 = [];
|
||||||
|
/** @var array<string,array<string,true>> */
|
||||||
|
private array $artIp30 = [];
|
||||||
|
|
||||||
private static ?array $memo = null;
|
private static ?array $memo = null;
|
||||||
|
|
||||||
@@ -46,7 +53,8 @@ class AccessLogParser
|
|||||||
* ip_top_paths:array<string,array<string,array{n:int,ts:int}>>,
|
* ip_top_paths:array<string,array<string,array{n:int,ts:int}>>,
|
||||||
* ip_agents:array<string,list<string>>,
|
* ip_agents:array<string,list<string>>,
|
||||||
* all_uas:array<string,int>,
|
* all_uas:array<string,int>,
|
||||||
* unique_visitors:array<int,int>
|
* unique_visitors:array<int,int>,
|
||||||
|
* article_unique_visitors:array<string,array<int,int>>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function stats(): 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 = [
|
$result = [
|
||||||
'pages' => $pages,
|
'pages' => $pages,
|
||||||
'books' => $books,
|
'books' => $books,
|
||||||
'ips' => $ips,
|
'ips' => $ips,
|
||||||
'pages_by_day' => $pagesByDay,
|
'pages_by_day' => $pagesByDay,
|
||||||
'ips_by_day' => $ipsByDay,
|
'ips_by_day' => $ipsByDay,
|
||||||
'ip_top_paths' => $ipTopPaths,
|
'ip_top_paths' => $ipTopPaths,
|
||||||
'ip_agents' => $ipTopAgents,
|
'ip_agents' => $ipTopAgents,
|
||||||
'all_uas' => array_slice($allUas, 0, 300, true),
|
'all_uas' => array_slice($allUas, 0, 300, true),
|
||||||
'unique_visitors' => $uniqueVisitors,
|
'unique_visitors' => $uniqueVisitors,
|
||||||
|
'article_unique_visitors' => $articleUv,
|
||||||
];
|
];
|
||||||
@mkdir(dirname($this->cacheFile), 0755, true);
|
@mkdir(dirname($this->cacheFile), 0755, true);
|
||||||
@file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
@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)) {
|
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
|
||||||
$ipPathTs[$ip][$path] = $tsVal;
|
$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) {
|
} elseif (str_ends_with($path, '/') === false && str_starts_with($path, '/book/') && strlen($path) > 6) {
|
||||||
$books[$path] = ($books[$path] ?? 0) + 1;
|
$books[$path] = ($books[$path] ?? 0) + 1;
|
||||||
|
|||||||
+17
-3
@@ -178,15 +178,29 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
|||||||
<br><small class="opacity-75"><?= htmlspecialchars($modDate) ?></small>
|
<br><small class="opacity-75"><?= htmlspecialchars($modDate) ?></small>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
<?php
|
||||||
|
$_v30 = (int) ($articleVisitors['30'] ?? 0);
|
||||||
|
$_v14 = (int) ($articleVisitors['14'] ?? 0);
|
||||||
|
$_v7 = (int) ($articleVisitors['7'] ?? 0);
|
||||||
|
if ($_v30 > 0):
|
||||||
|
?>
|
||||||
|
<p class="article-hero-visitors" title="Visiteurs uniques (IPs non-bot) · <?= $_v7 ?> / 7 j · <?= $_v14 ?> / 14 j · <?= $_v30 ?> / 30 j">
|
||||||
|
<span class="opacity-75" style="font-size:.8rem">
|
||||||
|
<?= number_format($_v30, 0, ',', "\xE2\x80\xAF") ?> lecteurs · 30 j
|
||||||
|
<span class="opacity-50"> (<?= $_v14 ?> / 14 j · <?= $_v7 ?> / 7 j)</span>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<?php endif;
|
||||||
|
unset($_v30, $_v14, $_v7); ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="article-hero-right">
|
<div class="article-hero-right">
|
||||||
<?php if ($hasSources): ?>
|
<?php if ($hasSources): ?>
|
||||||
<a href="/sources/<?= rawurlencode($article['uuid']) ?>" class="hero-btn">ℹ Sources</a>
|
<a href="/sources/<?= rawurlencode($article['uuid']) ?>" class="hero-btn">ℹ Sources</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php
|
<?php
|
||||||
$_heroReactionDefs = [
|
$_heroReactionDefs = [
|
||||||
'useful' => ['👍', 'Utile'],
|
'useful' => ['👍', 'Utile'],
|
||||||
];
|
];
|
||||||
?>
|
?>
|
||||||
<div class="hero-reactions" id="reactions">
|
<div class="hero-reactions" id="reactions">
|
||||||
<?php foreach ($_heroReactionDefs as $type => [$icon, $label]): ?>
|
<?php foreach ($_heroReactionDefs as $type => [$icon, $label]): ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user