v1.6.31 : analyse complète des logs + détection bots

- AccessLogParser : tous chemins/statuts pour IPs publiques (ipAllPaths, ipAllDays, ipAgents)
- Détection bots par patterns (data/bots.json, ~50 patterns initiaux)
- Section « Agents détectés » en bas de page admin/stats avec badge 🤖
- Panneau d'édition des patterns bots (formulaire avec CSRF)
- Drill-down IP : section « Autres chemins » (hors articles/livres)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 21:33:47 +02:00
parent 68a44d19d1
commit d53b5da31a
6 changed files with 288 additions and 77 deletions
+113 -20
View File
@@ -1,4 +1,4 @@
/* Admin stats : graphiques, sparklines, accordéon pays/AS/IP */
/* Admin stats : graphiques, sparklines, accordéon pays/AS/IP, agents */
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
@@ -8,6 +8,20 @@ function trunc(s, n) {
return s.length > n ? s.slice(0, n) + '…' : s;
}
// Détection de bot par correspondance partielle insensible à la casse
var _botPatterns = (typeof FOLIO_BOT_PATTERNS !== 'undefined') ? FOLIO_BOT_PATTERNS : [];
function isBot(ua) {
if (!ua) { return false; }
var lo = ua.toLowerCase();
for (var i = 0; i < _botPatterns.length; i++) {
if (lo.indexOf(_botPatterns[i].toLowerCase()) !== -1) { return true; }
}
return false;
}
function botBadge(ua) {
return isBot(ua) ? '<span title="Bot connu" style="font-size:.85rem">🤖</span> ' : '';
}
// ── Visiteurs par pays ────────────────────────────────────────────────────────
(function () {
var el = document.getElementById('stats-country-container');
@@ -27,7 +41,7 @@ function trunc(s, n) {
return String.fromCodePoint(cp[0], cp[1]) + ' ';
}
// Index IPs par ASN pour le drill-down
// Index IPs par ASN
var ipsByAsn = {};
Object.keys(ipData).forEach(function (ip) {
var d = ipData[ip];
@@ -39,7 +53,6 @@ function trunc(s, n) {
ipsByAsn[k].sort(function (a, b) { return b.hits - a.hits; });
});
// Mini sparkline (80x20px polyline) pour chaque IP
function ipSparkline(daily) {
if (!daily || !daily.length) { return ''; }
var W = 80, H = 20, padX = 1, padY = 2;
@@ -89,36 +102,44 @@ function trunc(s, n) {
var asnKey = n.asn || '__unknown__';
var ips = ipsByAsn[asnKey] || [];
// Lignes IP : adresse + agents à gauche, sparkline, chemins, hits
var ipRows = ips.slice(0, 20).map(function (ipInfo) {
// Agents sous l'IP
// Agents sous l'IP avec badge bot
var agentsHtml = '';
(ipInfo.agents || []).forEach(function (ua) {
agentsHtml += '<div style="font-size:.65rem;color:#adb5bd;line-height:1.4;word-break:break-all">'
+ esc(trunc(ua, 55)) + '</div>';
+ botBadge(ua) + esc(trunc(ua, 55)) + '</div>';
});
// Chemins triés par date desc
var articles = [], books = [];
// Chemins triés : /post/ et /book/ avec ts, reste sans ts
var postBook = [], other = [];
Object.keys(ipInfo.paths || {}).forEach(function (path) {
var p = ipInfo.paths[path];
var cnt = (p && typeof p === 'object') ? p.n : p;
var ts = (p && typeof p === 'object') ? p.ts : 0;
if (path.indexOf('/post/') === 0) { articles.push({ path: path, cnt: cnt, ts: ts }); }
else if (path.indexOf('/book/') === 0) { books.push({ path: path, cnt: cnt, ts: ts }); }
if (ts > 0) { postBook.push({ path: path, cnt: cnt, ts: ts }); }
else { other.push({ path: path, cnt: cnt }); }
});
articles.sort(function (a, b) { return b.ts - a.ts; });
books.sort(function (a, b) { return b.ts - a.ts; });
postBook.sort(function (a, b) { return b.ts - a.ts; });
other.sort(function (a, b) { return b.cnt - a.cnt; });
function pathLine(p, prefix) {
var slug = decodeURIComponent(p.path.replace(prefix, ''));
var raw = p.path.replace(prefix, '');
var slug = '';
try { slug = decodeURIComponent(raw); } catch (e) { slug = raw; }
return '<div style="font-size:.75rem;line-height:1.5">'
+ '<a href="' + esc(p.path) + '" target="_blank" style="color:#495057">'
+ esc(trunc(slug, 40)) + '</a>'
+ esc(trunc(slug || p.path, 40)) + '</a>'
+ ' <span style="color:#adb5bd">(' + p.cnt + ')</span></div>';
}
function otherLine(p) {
return '<div style="font-size:.72rem;color:#868e96;line-height:1.4">'
+ '<code style="font-size:.72rem;color:#868e96">' + esc(trunc(p.path, 44)) + '</code>'
+ ' <span style="color:#adb5bd">(' + p.cnt + ')</span></div>';
}
var pathsHtml = '';
var articles = postBook.filter(function (p) { return p.path.indexOf('/post/') === 0; });
var books = postBook.filter(function (p) { return p.path.indexOf('/book/') === 0; });
if (articles.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Articles</div>'
+ articles.map(function (p) { return pathLine(p, '/post/'); }).join('');
@@ -127,6 +148,10 @@ function trunc(s, n) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Livres</div>'
+ books.map(function (p) { return pathLine(p, '/book/'); }).join('');
}
if (other.length) {
pathsHtml += '<div style="font-size:.7rem;color:#adb5bd;margin-top:2px">Autres chemins</div>'
+ other.map(otherLine).join('');
}
if (!pathsHtml) { pathsHtml = '<span style="font-size:.75rem;color:#adb5bd">—</span>'; }
return '<div class="d-flex gap-2 py-2 border-bottom align-items-start">'
@@ -141,9 +166,9 @@ function trunc(s, n) {
+ '</div>';
}).join('');
var hasIps = ips.length > 0;
var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : '';
var chevron = hasIps ? '<span class="text-muted ms-1" style="font-size:.65rem">▾</span>' : '';
var hasIps = ips.length > 0;
var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : '';
var chevron = hasIps ? '<span class="text-muted ms-1" style="font-size:.65rem">▾</span>' : '';
return '<div>'
+ '<div class="d-flex align-items-center gap-2 py-1"' + toggleAttrs + '>'
@@ -184,6 +209,76 @@ function trunc(s, n) {
el.innerHTML = html;
}());
// ── 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 : {};
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) };
}).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;
});
if (!agents.length) {
el.innerHTML = '<p class="text-muted p-3 mb-0">Aucun agent détecté.</p>';
return;
}
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; }
function agentRow(a) {
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>'
+ '<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>';
}
var botsHtml = bots.map(agentRow).join('');
var unknownHtml = unknown.map(agentRow).join('');
var html = '<div class="table-responsive">'
+ '<table class="table table-sm table-hover mb-0 small">'
+ '<thead class="table-light"><tr>'
+ '<th class="ps-3" style="width:1.5rem"></th>'
+ '<th>User-Agent</th>'
+ '<th class="text-end pe-3" style="width:5rem">Req.</th>'
+ '</tr></thead>'
+ '<tbody>';
if (botsHtml) {
html += '<tr class="table-light"><td colspan="3" class="ps-3 py-1">'
+ '<small class="fw-semibold text-muted">Bots connus (' + bots.length + ')</small></td></tr>'
+ botsHtml;
}
if (unknownHtml) {
html += '<tr class="table-light"><td colspan="3" class="ps-3 py-1">'
+ '<small class="fw-semibold text-muted">Agents non classés (' + unknown.length + ')</small></td></tr>'
+ unknownHtml;
}
html += '</tbody></table></div>';
el.innerHTML = html;
}());
// ── Pages les plus visitées (RSS XML + sparklines) ───────────────────────────
(function () {
var container = document.getElementById('stats-pages-container');
@@ -303,9 +398,7 @@ function trunc(s, n) {
+ '<path d="' + areaPath + '" fill="url(#area-grad)"/>'
+ '<path d="' + linePath + '" fill="none" stroke="var(--bs-primary,#0d6efd)"'
+ ' stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'
+ dots
+ yLabels
+ xLabels
+ dots + yLabels + xLabels
+ '</svg>';
}