v1.6.32 : UA en entier + bouton « + bot » + filtrage bots des stats
- Agents détectés : UA affiché sans troncature (drill-down et liste) - Bouton « + bot » pour ajouter un agent aux patterns via AJAX (CSRF) - Section Agents alimentée par all_uas (tous UAs publics, bots inclus) - AccessLogParser : bots exclus des compteurs pages/livres/visiteurs - Caches stats vidés après chaque modification des patterns Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,18 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.6.32] - 2026-05-19
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- Admin stats / Agents détectés : UA affiché en entier (plus de troncature à 55 car.) dans le drill-down IP et la liste agents
|
||||||
|
- Admin stats / Agents détectés : bouton « + bot » sur chaque agent non classé — ajoute le UA aux patterns via AJAX sans recharger la page, déplace la ligne vers "Bots connus"
|
||||||
|
- Admin stats / Agents détectés : section alimentée par `FOLIO_ALL_UAS` (tous UAs publics, bots inclus) plutôt que par agrégation depuis `ip_data`
|
||||||
|
- AccessLogParser : filtrage des bots dans les compteurs pages/livres/IPs — les requêtes détectées comme bot n'alimentent plus les stats de fréquentation ; `all_uas` expose tous les UAs (bots inclus) pour la section Agents
|
||||||
|
- `index.php` : chargement de `bots.json` avant la création du parser pour passer les patterns au constructeur ; `admin_add_bot` vide les caches stats après ajout ; `admin_save_bots` vide également les caches
|
||||||
|
- Template : `FOLIO_ALL_UAS` et `FOLIO_CSRF` ajoutés aux variables JS de la page stats
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.6.31] - 2026-05-19
|
## [1.6.31] - 2026-05-19
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ function botBadge(ua) {
|
|||||||
var ips = ipsByAsn[asnKey] || [];
|
var ips = ipsByAsn[asnKey] || [];
|
||||||
|
|
||||||
var ipRows = ips.slice(0, 20).map(function (ipInfo) {
|
var ipRows = ips.slice(0, 20).map(function (ipInfo) {
|
||||||
// Agents sous l'IP avec badge bot
|
// Agents sous l'IP avec badge bot (UA en entier)
|
||||||
var agentsHtml = '';
|
var agentsHtml = '';
|
||||||
(ipInfo.agents || []).forEach(function (ua) {
|
(ipInfo.agents || []).forEach(function (ua) {
|
||||||
agentsHtml += '<div style="font-size:.65rem;color:#adb5bd;line-height:1.4;word-break:break-all">'
|
agentsHtml += '<div style="font-size:.65rem;color:#adb5bd;line-height:1.4;word-break:break-all">'
|
||||||
+ botBadge(ua) + esc(trunc(ua, 55)) + '</div>';
|
+ botBadge(ua) + esc(ua) + '</div>';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chemins triés : /post/ et /book/ avec ts, reste sans ts
|
// Chemins triés : /post/ et /book/ avec ts, reste sans ts
|
||||||
@@ -211,23 +211,15 @@ function botBadge(ua) {
|
|||||||
|
|
||||||
// ── Liste consolidée de tous les agents ──────────────────────────────────────
|
// ── Liste consolidée de tous les agents ──────────────────────────────────────
|
||||||
(function () {
|
(function () {
|
||||||
var el = document.getElementById('stats-agents-container');
|
var el = document.getElementById('stats-agents-container');
|
||||||
var badge = document.getElementById('agents-count');
|
var badge = document.getElementById('agents-count');
|
||||||
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
|
var allUas = (typeof FOLIO_ALL_UAS !== 'undefined') ? FOLIO_ALL_UAS : {};
|
||||||
|
var csrf = (typeof FOLIO_CSRF !== 'undefined') ? FOLIO_CSRF : '';
|
||||||
if (!el) { return; }
|
if (!el) { return; }
|
||||||
|
|
||||||
// Agréger toutes les UAs depuis FOLIO_IP_DATA
|
var agents = Object.keys(allUas).map(function (ua) {
|
||||||
var uaCounts = {};
|
return { ua: ua, hits: allUas[ua], bot: isBot(ua) };
|
||||||
Object.keys(ipData).forEach(function (ip) {
|
|
||||||
(ipData[ip].agents || []).forEach(function (ua) {
|
|
||||||
uaCounts[ua] = (uaCounts[ua] || 0) + (ipData[ip].hits || 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
var agents = Object.keys(uaCounts).map(function (ua) {
|
|
||||||
return { ua: ua, hits: uaCounts[ua], bot: isBot(ua) };
|
|
||||||
}).sort(function (a, b) {
|
}).sort(function (a, b) {
|
||||||
// Bots d'abord, puis par hits desc
|
|
||||||
if (a.bot !== b.bot) { return a.bot ? -1 : 1; }
|
if (a.bot !== b.bot) { return a.bot ? -1 : 1; }
|
||||||
return b.hits - a.hits;
|
return b.hits - a.hits;
|
||||||
});
|
});
|
||||||
@@ -239,14 +231,38 @@ function botBadge(ua) {
|
|||||||
|
|
||||||
var bots = agents.filter(function (a) { return a.bot; });
|
var bots = agents.filter(function (a) { return a.bot; });
|
||||||
var unknown = agents.filter(function (a) { return !a.bot; });
|
var unknown = agents.filter(function (a) { return !a.bot; });
|
||||||
if (badge) { badge.textContent = '— ' + bots.length + ' bot(s) détecté(s) sur ' + agents.length; }
|
if (badge) { badge.textContent = '— ' + bots.length + ' bot(s) sur ' + agents.length; }
|
||||||
|
|
||||||
|
function addBot(ua, btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('_csrf', csrf);
|
||||||
|
fd.append('pattern', ua);
|
||||||
|
fetch('/?action=admin_add_bot', { method: 'POST', body: fd })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.ok) {
|
||||||
|
_botPatterns.push(ua);
|
||||||
|
btn.closest('tr').querySelector('td:first-child').innerHTML = '<span title="Bot">🤖</span>';
|
||||||
|
btn.remove();
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { btn.disabled = false; });
|
||||||
|
}
|
||||||
|
|
||||||
function agentRow(a) {
|
function agentRow(a) {
|
||||||
|
var addBtn = (!a.bot)
|
||||||
|
? '<button class="btn btn-outline-secondary btn-sm py-0 px-1 ms-2 add-bot-btn"'
|
||||||
|
+ ' style="font-size:.65rem;white-space:nowrap" title="Ajouter aux bots">+ bot</button>'
|
||||||
|
: '';
|
||||||
return '<tr>'
|
return '<tr>'
|
||||||
+ '<td class="ps-3" style="width:1.5rem;vertical-align:top;padding-top:6px">'
|
+ '<td class="ps-3" style="width:1.5rem;vertical-align:top;padding-top:6px">'
|
||||||
+ (a.bot ? '<span title="Bot">🤖</span>' : '<span class="text-muted" title="Inconnu">?</span>') + '</td>'
|
+ (a.bot ? '<span title="Bot">🤖</span>' : '<span class="text-muted" title="Inconnu">?</span>') + '</td>'
|
||||||
+ '<td style="word-break:break-all;vertical-align:top">'
|
+ '<td style="word-break:break-all;vertical-align:top">'
|
||||||
+ '<code style="font-size:.72rem">' + esc(a.ua) + '</code></td>'
|
+ '<code style="font-size:.72rem">' + esc(a.ua) + '</code>'
|
||||||
|
+ addBtn + '</td>'
|
||||||
+ '<td class="text-end text-muted small pe-3" style="width:5rem;vertical-align:top;white-space:nowrap">'
|
+ '<td class="text-end text-muted small pe-3" style="width:5rem;vertical-align:top;white-space:nowrap">'
|
||||||
+ a.hits.toLocaleString('fr-FR') + '</td>'
|
+ a.hits.toLocaleString('fr-FR') + '</td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
@@ -277,6 +293,15 @@ function botBadge(ua) {
|
|||||||
|
|
||||||
html += '</tbody></table></div>';
|
html += '</tbody></table></div>';
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
|
|
||||||
|
// Délégation : boutons "+ bot"
|
||||||
|
el.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.add-bot-btn');
|
||||||
|
if (!btn) { return; }
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
var code = row ? row.querySelector('code') : null;
|
||||||
|
if (code) { addBot(code.textContent, btn); }
|
||||||
|
});
|
||||||
}());
|
}());
|
||||||
|
|
||||||
// ── Pages les plus visitées (RSS XML + sparklines) ───────────────────────────
|
// ── Pages les plus visitées (RSS XML + sparklines) ───────────────────────────
|
||||||
|
|||||||
+71
-32
@@ -2725,6 +2725,38 @@ switch ($action) {
|
|||||||
require_once BASE_PATH . '/src/AccessLogParser.php';
|
require_once BASE_PATH . '/src/AccessLogParser.php';
|
||||||
require_once BASE_PATH . '/src/AsnLookup.php';
|
require_once BASE_PATH . '/src/AsnLookup.php';
|
||||||
|
|
||||||
|
// Patterns de bots — initialisation si absent
|
||||||
|
$botsFile = DATA_PATH . '/bots.json';
|
||||||
|
if (!file_exists($botsFile)) {
|
||||||
|
$defaultBots = [
|
||||||
|
'Googlebot', 'Googlebot-Image', 'Google-InspectionTool', 'Google-Extended',
|
||||||
|
'bingbot', 'BingPreview', 'msnbot',
|
||||||
|
'DuckDuckBot', 'DuckDuckGo-Favicons-Bot',
|
||||||
|
'Baiduspider', 'YandexBot', 'YandexImages', 'YandexMetrika',
|
||||||
|
'Applebot',
|
||||||
|
'facebookexternalhit', 'facebot',
|
||||||
|
'Twitterbot', 'LinkedInBot', 'Slackbot', 'TelegramBot', 'WhatsApp', 'Discordbot',
|
||||||
|
'PetalBot', 'Bytespider', 'SogouSpider', 'SeznamBot', 'Exabot',
|
||||||
|
'AhrefsBot', 'SemrushBot', 'MJ12bot', 'DotBot', 'rogerbot', 'BLEXBot', 'DataForSeoBot',
|
||||||
|
'Screaming Frog SEO Spider',
|
||||||
|
'ClaudeBot', 'GPTBot', 'PerplexityBot', 'cohere-ai', 'anthropic-ai',
|
||||||
|
'meta-externalagent', 'OAI-SearchBot', 'Amazonbot',
|
||||||
|
'CCBot', 'ia_archiver', 'archive.org_bot',
|
||||||
|
'NetcraftSurveyAgent',
|
||||||
|
'python-requests', 'python-urllib', 'Python/',
|
||||||
|
'curl/', 'wget/', 'Wget/',
|
||||||
|
'Go-http-client/1', 'Java/', 'Apache-HttpClient', 'okhttp/',
|
||||||
|
'Scrapy', 'HeadlessChrome', 'PhantomJS', 'Puppeteer', 'Playwright', 'Selenium',
|
||||||
|
'UptimeRobot', 'Pingdom', 'StatusCake', 'Site24x7', 'GTmetrix',
|
||||||
|
'Chrome-Lighthouse', 'PageSpeed', 'Zabbix', 'check_http',
|
||||||
|
'libwww-perl', 'GuzzleHttp', 'masscan', 'zgrab', 'nuclei',
|
||||||
|
];
|
||||||
|
@mkdir(dirname($botsFile), 0755, true);
|
||||||
|
@file_put_contents($botsFile, json_encode($defaultBots, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
$botPatterns = json_decode((string) file_get_contents($botsFile), true) ?: [];
|
||||||
|
$adminData['bot_patterns'] = $botPatterns;
|
||||||
|
|
||||||
$statsCacheFile = DATA_PATH . '/.stats_cache.json';
|
$statsCacheFile = DATA_PATH . '/.stats_cache.json';
|
||||||
$statsRaw = null;
|
$statsRaw = null;
|
||||||
if (file_exists($statsCacheFile) && (time() - filemtime($statsCacheFile)) < 60) {
|
if (file_exists($statsCacheFile) && (time() - filemtime($statsCacheFile)) < 60) {
|
||||||
@@ -2733,7 +2765,7 @@ switch ($action) {
|
|||||||
if ($statsRaw === null) {
|
if ($statsRaw === null) {
|
||||||
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
|
||||||
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
|
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 14, $botPatterns);
|
||||||
$accessStats = $accessParser->stats();
|
$accessStats = $accessParser->stats();
|
||||||
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
||||||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||||
@@ -2758,6 +2790,7 @@ switch ($action) {
|
|||||||
'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'] ?? [],
|
||||||
];
|
];
|
||||||
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||||||
}
|
}
|
||||||
@@ -2768,37 +2801,7 @@ switch ($action) {
|
|||||||
$adminData['as_groups'] = asGroups();
|
$adminData['as_groups'] = asGroups();
|
||||||
$adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
|
$adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
|
||||||
$adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
|
$adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
|
||||||
|
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
|
||||||
// Patterns de bots — initialisation si absent
|
|
||||||
$botsFile = DATA_PATH . '/bots.json';
|
|
||||||
if (!file_exists($botsFile)) {
|
|
||||||
$defaultBots = [
|
|
||||||
'Googlebot','Googlebot-Image','Google-InspectionTool','Google-Extended',
|
|
||||||
'bingbot','BingPreview','msnbot',
|
|
||||||
'DuckDuckBot','DuckDuckGo-Favicons-Bot',
|
|
||||||
'Baiduspider','YandexBot','YandexImages','YandexMetrika',
|
|
||||||
'Applebot',
|
|
||||||
'facebookexternalhit','facebot',
|
|
||||||
'Twitterbot','LinkedInBot','Slackbot','TelegramBot','WhatsApp','Discordbot',
|
|
||||||
'PetalBot','Bytespider','SogouSpider','SeznamBot','Exabot',
|
|
||||||
'AhrefsBot','SemrushBot','MJ12bot','DotBot','rogerbot','BLEXBot','DataForSeoBot',
|
|
||||||
'Screaming Frog SEO Spider',
|
|
||||||
'ClaudeBot','GPTBot','Google-Extended','PerplexityBot','cohere-ai','anthropic-ai',
|
|
||||||
'meta-externalagent','OAI-SearchBot','Amazonbot',
|
|
||||||
'CCBot','ia_archiver','archive.org_bot',
|
|
||||||
'NetcraftSurveyAgent',
|
|
||||||
'python-requests','python-urllib','Python/',
|
|
||||||
'curl/','wget/','Wget/',
|
|
||||||
'Go-http-client/1','Java/','Apache-HttpClient','okhttp/',
|
|
||||||
'Scrapy','HeadlessChrome','PhantomJS','Puppeteer','Playwright','Selenium',
|
|
||||||
'UptimeRobot','Pingdom','StatusCake','Site24x7','GTmetrix',
|
|
||||||
'Chrome-Lighthouse','PageSpeed','Zabbix','check_http',
|
|
||||||
'libwww-perl','GuzzleHttp','masscan','zgrab','nuclei',
|
|
||||||
];
|
|
||||||
@mkdir(dirname($botsFile), 0755, true);
|
|
||||||
@file_put_contents($botsFile, json_encode($defaultBots, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
|
||||||
}
|
|
||||||
$adminData['bot_patterns'] = json_decode((string) file_get_contents($botsFile), true) ?: [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tab === 'categories') {
|
if ($tab === 'categories') {
|
||||||
@@ -3248,9 +3251,45 @@ switch ($action) {
|
|||||||
array_map('trim', explode("\n", (string) ($_POST['bot_patterns'] ?? '')))
|
array_map('trim', explode("\n", (string) ($_POST['bot_patterns'] ?? '')))
|
||||||
)));
|
)));
|
||||||
$ok = @file_put_contents($botsFile, json_encode($patterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
|
$ok = @file_put_contents($botsFile, json_encode($patterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
|
||||||
|
if ($ok) {
|
||||||
|
@unlink(DATA_PATH . '/.stats_cache.json');
|
||||||
|
@unlink(BASE_PATH . '/_cache/access_stats.json');
|
||||||
|
}
|
||||||
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
||||||
exit;
|
exit;
|
||||||
|
|
||||||
|
case 'admin_add_bot':
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
$addPattern = trim((string) ($_POST['pattern'] ?? ''));
|
||||||
|
if ($addPattern === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'empty']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$botsFile = DATA_PATH . '/bots.json';
|
||||||
|
$botPatterns = is_file($botsFile) ? (json_decode((string) file_get_contents($botsFile), true) ?: []) : [];
|
||||||
|
if (!in_array($addPattern, $botPatterns, true)) {
|
||||||
|
$botPatterns[] = $addPattern;
|
||||||
|
@file_put_contents($botsFile, json_encode($botPatterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
@unlink(DATA_PATH . '/.stats_cache.json');
|
||||||
|
@unlink(BASE_PATH . '/_cache/access_stats.json');
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['ok' => true, 'pattern' => $addPattern]);
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'admin_create_role':
|
case 'admin_create_role':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.6.31
|
1.6.32
|
||||||
|
|||||||
+69
-33
@@ -9,24 +9,31 @@ class AccessLogParser
|
|||||||
private string $cacheFile;
|
private string $cacheFile;
|
||||||
private int $cacheTtl;
|
private int $cacheTtl;
|
||||||
private int $days;
|
private int $days;
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $botPatterns;
|
||||||
|
|
||||||
private static ?array $memo = null;
|
private static ?array $memo = null;
|
||||||
|
|
||||||
// Apache COMBINED : IP - - [timestamp] "METHOD /path HTTP/x" STATUS bytes "ref" "ua"
|
// Apache COMBINED : IP - - [timestamp] "METHOD /path HTTP/x" STATUS bytes "ref" "ua"
|
||||||
private const RE = '/^(\S+) \S+ \S+ \[(\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] "[A-Z-]+ ([^\s"?]+)[^"]*" (\d{3}) \S+ "[^"]*" "([^"]*)"/u';
|
private const RE = '/^(\S+) \S+ \S+ \[(\d{2}\/\w+\/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4})\] "[A-Z-]+ ([^\s"?]+)[^"]*" (\d{3}) \S+ "[^"]*" "([^"]*)"/u';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $botPatterns
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $logDir = '/var/log/apache2',
|
string $logDir = '/var/log/apache2',
|
||||||
string $pattern = '*-access.log',
|
string $pattern = '*-access.log',
|
||||||
string $cacheFile = '',
|
string $cacheFile = '',
|
||||||
int $cacheTtl = 600,
|
int $cacheTtl = 600,
|
||||||
int $days = 14
|
int $days = 14,
|
||||||
|
array $botPatterns = []
|
||||||
) {
|
) {
|
||||||
$this->logDir = rtrim($logDir, '/');
|
$this->logDir = rtrim($logDir, '/');
|
||||||
$this->pattern = $pattern;
|
$this->pattern = $pattern;
|
||||||
$this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json';
|
$this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json';
|
||||||
$this->cacheTtl = $cacheTtl;
|
$this->cacheTtl = $cacheTtl;
|
||||||
$this->days = $days;
|
$this->days = $days;
|
||||||
|
$this->botPatterns = array_map('strtolower', $botPatterns);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +44,8 @@ class AccessLogParser
|
|||||||
* pages_by_day:array<string,list<int>>,
|
* pages_by_day:array<string,list<int>>,
|
||||||
* ips_by_day:array<string,list<int>>,
|
* ips_by_day:array<string,list<int>>,
|
||||||
* 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>
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function stats(): array
|
public function stats(): array
|
||||||
@@ -52,24 +60,26 @@ class AccessLogParser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
|
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
|
||||||
$pages = [];
|
$pages = [];
|
||||||
$books = [];
|
$books = [];
|
||||||
$ips = []; // toutes requêtes publiques (tous chemins, tous statuts)
|
$ips = []; // requêtes publiques non-bot (tous chemins, tous statuts)
|
||||||
$dayPages = [];
|
$dayPages = [];
|
||||||
$ipPaths = []; // chemins /post/ et /book/ avec statut 200 (pour les ts)
|
$ipPaths = []; // chemins /post/ et /book/ avec statut 200 (pour les ts)
|
||||||
$ipPathTs = [];
|
$ipPathTs = [];
|
||||||
$ipAllPaths = []; // tous chemins, tous statuts
|
$ipAllPaths = []; // tous chemins, tous statuts, non-bots
|
||||||
$ipAllDays = []; // tous jours, tous statuts
|
$ipAllDays = []; // tous jours, tous statuts, non-bots
|
||||||
$ipAgents = []; // tous user-agents par IP
|
$ipAgents = []; // user-agents non-bot par IP
|
||||||
|
$allUas = []; // tous UAs publics (bots inclus) pour "Agents détectés"
|
||||||
|
|
||||||
foreach ($this->logFiles() as $file) {
|
foreach ($this->logFiles() as $file) {
|
||||||
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
|
|
||||||
arsort($pages);
|
arsort($pages);
|
||||||
arsort($books);
|
arsort($books);
|
||||||
arsort($ips);
|
arsort($ips);
|
||||||
|
arsort($allUas);
|
||||||
|
|
||||||
$pagesByDay = [];
|
$pagesByDay = [];
|
||||||
foreach ($dayPages as $path => $byOffset) {
|
foreach ($dayPages as $path => $byOffset) {
|
||||||
@@ -82,7 +92,7 @@ class AccessLogParser
|
|||||||
$pagesByDay[$path] = $arr;
|
$pagesByDay[$path] = $arr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top 200 IPs par volume total de requêtes
|
// Top 200 IPs non-bot par volume total de requêtes
|
||||||
$topIpKeys = array_keys(array_slice($ips, 0, 200, true));
|
$topIpKeys = array_keys(array_slice($ips, 0, 200, true));
|
||||||
$ipsByDay = [];
|
$ipsByDay = [];
|
||||||
$ipTopPaths = [];
|
$ipTopPaths = [];
|
||||||
@@ -119,9 +129,11 @@ class AccessLogParser
|
|||||||
'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),
|
||||||
];
|
];
|
||||||
@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));
|
||||||
|
|
||||||
return self::$memo = $result;
|
return self::$memo = $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +148,21 @@ class AccessLogParser
|
|||||||
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
|
&& (time() - filemtime($this->cacheFile)) < $this->cacheTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function matchesBot(string $ua): bool
|
||||||
|
{
|
||||||
|
if ($ua === '' || $this->botPatterns === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$lo = strtolower($ua);
|
||||||
|
foreach ($this->botPatterns as $p) {
|
||||||
|
if ($p !== '' && str_contains($lo, $p)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return list<array{path:string,type:string}> */
|
/** @return list<array{path:string,type:string}> */
|
||||||
private function logFiles(): array
|
private function logFiles(): array
|
||||||
{
|
{
|
||||||
@@ -172,6 +199,7 @@ class AccessLogParser
|
|||||||
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
|
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}");
|
return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +214,8 @@ class AccessLogParser
|
|||||||
array &$ipPathTs,
|
array &$ipPathTs,
|
||||||
array &$ipAllPaths,
|
array &$ipAllPaths,
|
||||||
array &$ipAllDays,
|
array &$ipAllDays,
|
||||||
array &$ipAgents
|
array &$ipAgents,
|
||||||
|
array &$allUas
|
||||||
): void {
|
): void {
|
||||||
if (!preg_match(self::RE, $line, $m)) {
|
if (!preg_match(self::RE, $line, $m)) {
|
||||||
return;
|
return;
|
||||||
@@ -200,19 +229,25 @@ class AccessLogParser
|
|||||||
|
|
||||||
$publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
$publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||||
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
|
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
|
||||||
|
$isBot = $this->matchesBot($ua);
|
||||||
|
|
||||||
// Toutes les requêtes publiques : comptage global, chemins, jours, agents
|
// Tous les UAs publics pour la section "Agents détectés" (bots inclus)
|
||||||
if ($publicIp) {
|
if ($publicIp && $ua !== '') {
|
||||||
|
$allUas[$ua] = ($allUas[$ua] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Requêtes publiques non-bot : comptage visiteurs, chemins, jours, agents
|
||||||
|
if ($publicIp && !$isBot) {
|
||||||
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
|
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
|
||||||
$ipAllPaths[$ip][$path] = ($ipAllPaths[$ip][$path] ?? 0) + 1;
|
$ipAllPaths[$ip][$path] = ($ipAllPaths[$ip][$path] ?? 0) + 1;
|
||||||
$ipAllDays[$ip][$dayOffset] = ($ipAllDays[$ip][$dayOffset] ?? 0) + 1;
|
$ipAllDays[$ip][$dayOffset] = ($ipAllDays[$ip][$dayOffset] ?? 0) + 1;
|
||||||
if ($ua !== '') {
|
if ($ua !== '') {
|
||||||
$ipAgents[$ip][$ua] = ($ipAgents[$ip][$ua] ?? 0) + 1;
|
$ipAgents[$ip][$ua] = ($ipAgents[$ip][$ua] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comptage spécifique aux pages de contenu (statut 200, /post/ ou /book/)
|
// Comptage spécifique aux pages de contenu (statut 200, non-bot)
|
||||||
if ($status !== '200') {
|
if ($status !== '200' || $isBot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +282,8 @@ class AccessLogParser
|
|||||||
array &$ipPathTs,
|
array &$ipPathTs,
|
||||||
array &$ipAllPaths,
|
array &$ipAllPaths,
|
||||||
array &$ipAllDays,
|
array &$ipAllDays,
|
||||||
array &$ipAgents
|
array &$ipAgents,
|
||||||
|
array &$allUas
|
||||||
): void {
|
): void {
|
||||||
if ($file['type'] === 'tgz') {
|
if ($file['type'] === 'tgz') {
|
||||||
try {
|
try {
|
||||||
@@ -258,7 +294,7 @@ class AccessLogParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach (explode("\n", $content) as $line) {
|
foreach (explode("\n", $content) as $line) {
|
||||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -271,7 +307,7 @@ class AccessLogParser
|
|||||||
while (!gzeof($h)) {
|
while (!gzeof($h)) {
|
||||||
$line = gzgets($h, 8192);
|
$line = gzgets($h, 8192);
|
||||||
if ($line !== false) {
|
if ($line !== false) {
|
||||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gzclose($h);
|
gzclose($h);
|
||||||
@@ -281,7 +317,7 @@ class AccessLogParser
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while (($line = fgets($h)) !== false) {
|
while (($line = fgets($h)) !== false) {
|
||||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||||
}
|
}
|
||||||
fclose($h);
|
fclose($h);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ $_asList = $adminData['stats_as'] ?? [];
|
|||||||
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
|
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
|
||||||
$_ipData = $adminData['stats_ip_data'] ?? [];
|
$_ipData = $adminData['stats_ip_data'] ?? [];
|
||||||
$_botPatterns = $adminData['bot_patterns'] ?? [];
|
$_botPatterns = $adminData['bot_patterns'] ?? [];
|
||||||
|
$_allUas = $adminData['stats_all_uas'] ?? [];
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php if ($_statsSaved): ?>
|
<?php if ($_statsSaved): ?>
|
||||||
@@ -29,6 +30,8 @@ var FOLIO_PAGES_BY_DAY = <?= json_encode($_pagesByDay, JSON_UNESCAPED_SLASHES |
|
|||||||
var FOLIO_AS_LIST = <?= json_encode($_asList, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
var FOLIO_AS_LIST = <?= json_encode($_asList, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
var FOLIO_IP_DATA = <?= json_encode($_ipData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
var FOLIO_IP_DATA = <?= json_encode($_ipData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
var FOLIO_BOT_PATTERNS = <?= json_encode($_botPatterns, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
var FOLIO_BOT_PATTERNS = <?= json_encode($_botPatterns, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
|
var FOLIO_ALL_UAS = <?= json_encode($_allUas, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
|
var FOLIO_CSRF = <?= json_encode($_session['csrf'] ?? '', JSON_UNESCAPED_UNICODE) ?>;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user