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:
2026-05-19 21:45:10 +02:00
parent d53b5da31a
commit b0f4814bb0
6 changed files with 199 additions and 84 deletions
+43 -18
View File
@@ -103,11 +103,11 @@ function botBadge(ua) {
var ips = ipsByAsn[asnKey] || [];
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 = '';
(ipInfo.agents || []).forEach(function (ua) {
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
@@ -211,23 +211,15 @@ function botBadge(ua) {
// ── Liste consolidée de tous les agents ──────────────────────────────────────
(function () {
var el = document.getElementById('stats-agents-container');
var badge = document.getElementById('agents-count');
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
var el = document.getElementById('stats-agents-container');
var badge = document.getElementById('agents-count');
var allUas = (typeof FOLIO_ALL_UAS !== 'undefined') ? FOLIO_ALL_UAS : {};
var csrf = (typeof FOLIO_CSRF !== 'undefined') ? FOLIO_CSRF : '';
if (!el) { return; }
// Agréger toutes les UAs depuis FOLIO_IP_DATA
var uaCounts = {};
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) };
var agents = Object.keys(allUas).map(function (ua) {
return { ua: ua, hits: allUas[ua], bot: isBot(ua) };
}).sort(function (a, b) {
// Bots d'abord, puis par hits desc
if (a.bot !== b.bot) { return a.bot ? -1 : 1; }
return b.hits - a.hits;
});
@@ -239,14 +231,38 @@ function botBadge(ua) {
var bots = 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) {
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>'
+ '<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>'
+ '<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">'
+ a.hits.toLocaleString('fr-FR') + '</td>'
+ '</tr>';
@@ -277,6 +293,15 @@ function botBadge(ua) {
html += '</tbody></table></div>';
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) ───────────────────────────
+71 -32
View File
@@ -2725,6 +2725,38 @@ switch ($action) {
require_once BASE_PATH . '/src/AccessLogParser.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';
$statsRaw = null;
if (file_exists($statsCacheFile) && (time() - filemtime($statsCacheFile)) < 60) {
@@ -2733,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());
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog(), '', 600, 14, $botPatterns);
$accessStats = $accessParser->stats();
$topIps = array_slice($accessStats['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
@@ -2758,6 +2790,7 @@ switch ($action) {
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
'pages_by_day' => $accessStats['pages_by_day'] ?? [],
'ip_data' => $ipData,
'all_uas' => $accessStats['all_uas'] ?? [],
];
@file_put_contents($statsCacheFile, json_encode($statsRaw));
}
@@ -2768,37 +2801,7 @@ switch ($action) {
$adminData['as_groups'] = asGroups();
$adminData['stats_pages_by_day'] = $statsRaw['pages_by_day'] ?? [];
$adminData['stats_ip_data'] = $statsRaw['ip_data'] ?? [];
// 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) ?: [];
$adminData['stats_all_uas'] = $statsRaw['all_uas'] ?? [];
}
if ($tab === 'categories') {
@@ -3248,9 +3251,45 @@ switch ($action) {
array_map('trim', explode("\n", (string) ($_POST['bot_patterns'] ?? '')))
)));
$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'));
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':
requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+1 -1
View File
@@ -1 +1 @@
1.6.31
1.6.32