v1.6.33 : exclusion AS, compteurs 7/14/30j, 👍 uniquement

- Carte visiteurs uniques non-bot : 7 / 14 / 30 jours en tête de /admin/stats
- Bouton ✕ par AS pour l'exclure des stats ; section AS exclus avec ↺
- Alerte IPs sans résolution AS dans la carte pays
- Parser : fenêtre 30 jours, calcul visiteurs uniques toutes IPs non-bot
- Graphiques adaptés à 30 jours (labels x/3)
- Réactions articles : 👍 uniquement (suppression 🔥 et 🤔)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 22:14:40 +02:00
parent b0f4814bb0
commit 1e41ef207e
8 changed files with 421 additions and 172 deletions
+75 -15
View File
@@ -2765,7 +2765,7 @@ switch ($action) {
if ($statsRaw === null) {
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 14, $botPatterns);
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 30, $botPatterns);
$accessStats = $accessParser->stats();
$topIps = array_slice($accessStats['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
@@ -2785,23 +2785,31 @@ 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'] ?? [],
'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],
];
@file_put_contents($statsCacheFile, json_encode($statsRaw));
}
$adminData['stats_readable'] = $statsRaw['readable'];
$adminData['stats_books'] = $statsRaw['books'];
$adminData['stats_as'] = $statsRaw['as'];
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
$adminData['as_groups'] = asGroups();
$adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
$adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
$adminData['stats_readable'] = $statsRaw['readable'];
$adminData['stats_books'] = $statsRaw['books'];
$adminData['stats_as'] = $statsRaw['as'];
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
$adminData['as_groups'] = asGroups();
$adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
$adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
$adminData['stats_unique_visitors'] = $statsRaw['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
// AS exclus (chargé en direct, pas mis en cache)
$excludedAsFile = DATA_PATH . '/excluded_as.json';
$adminData['excluded_as'] = is_file($excludedAsFile)
? (json_decode((string) file_get_contents($excludedAsFile), true) ?: [])
: [];
}
if ($tab === 'categories') {
@@ -3290,6 +3298,58 @@ switch ($action) {
echo json_encode(['ok' => true, 'pattern' => $addPattern]);
exit;
case 'admin_add_excluded_as':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$csrf = $_POST['_csrf'] ?? '';
if ($csrf !== ($_session['csrf'] ?? '')) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'csrf']);
exit;
}
$asn = trim((string) ($_POST['asn'] ?? ''));
if ($asn === '') {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'empty']);
exit;
}
$excludedAsFile = DATA_PATH . '/excluded_as.json';
$excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
if (!in_array($asn, $excludedAs, true)) {
$excludedAs[] = $asn;
@file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
}
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'asn' => $asn]);
exit;
case 'admin_remove_excluded_as':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(403);
exit;
}
$csrf = $_POST['_csrf'] ?? '';
if ($csrf !== ($_session['csrf'] ?? '')) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => 'csrf']);
exit;
}
$asn = trim((string) ($_POST['asn'] ?? ''));
$excludedAsFile = DATA_PATH . '/excluded_as.json';
$excludedAs = is_file($excludedAsFile) ? (json_decode((string) file_get_contents($excludedAsFile), true) ?: []) : [];
$excludedAs = array_values(array_filter($excludedAs, static fn ($a) => $a !== $asn));
@file_put_contents($excludedAsFile, json_encode($excludedAs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
header('Content-Type: application/json');
echo json_encode(['ok' => true, 'asn' => $asn]);
exit;
case 'admin_create_role':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {