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:
@@ -5,6 +5,17 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
||||
|
||||
---
|
||||
|
||||
## [1.6.31] - 2026-05-19
|
||||
|
||||
### Ajouté
|
||||
- Admin stats : section « Agents détectés » en bas de page — agrège tous les user agents, détecte bots/humains, badge 🤖 pour les bots connus
|
||||
- Admin stats : panneau d'édition des patterns bots (un par ligne, correspondance insensible à la casse), sauvegardé dans `data/bots.json`
|
||||
- Admin stats / drill-down IP : section « Autres chemins » (tous chemins/statuts hors articles et livres), triée par volume
|
||||
- AccessLogParser : analyse tous les chemins et statuts pour les IPs publiques (pas seulement /post/ et /book/ en 200), tracking `ipAllPaths`, `ipAllDays`, `ipAgents`
|
||||
- `index.php` : action `admin_save_bots` — enregistre les patterns bots avec token CSRF ; initialisation automatique de `data/bots.json` avec ~50 patterns connus (Googlebot, GPTBot, curl, Scrapy…)
|
||||
|
||||
---
|
||||
|
||||
## [1.6.30] - 2026-05-19
|
||||
|
||||
### Ajouté
|
||||
|
||||
+113
-20
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
|
||||
@@ -2768,6 +2768,37 @@ 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) ?: [];
|
||||
}
|
||||
|
||||
if ($tab === 'categories') {
|
||||
@@ -3206,6 +3237,20 @@ switch ($action) {
|
||||
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
||||
exit;
|
||||
|
||||
case 'admin_save_bots':
|
||||
requireAuth();
|
||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
$botsFile = DATA_PATH . '/bots.json';
|
||||
$patterns = array_values(array_unique(array_filter(
|
||||
array_map('trim', explode("\n", (string) ($_POST['bot_patterns'] ?? '')))
|
||||
)));
|
||||
$ok = @file_put_contents($botsFile, json_encode($patterns, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
|
||||
header('Location: /admin/stats?' . ($ok ? 'saved=1' : 'error=write'));
|
||||
exit;
|
||||
|
||||
case 'admin_create_role':
|
||||
requireAuth();
|
||||
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.6.30
|
||||
1.6.31
|
||||
|
||||
+80
-44
@@ -30,7 +30,15 @@ class AccessLogParser
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pages:array<string,int>,books:array<string,int>,ips:array<string,int>,pages_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_agents:array<string,list<string>>}
|
||||
* @return array{
|
||||
* pages:array<string,int>,
|
||||
* books:array<string,int>,
|
||||
* ips:array<string,int>,
|
||||
* pages_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_agents:array<string,list<string>>
|
||||
* }
|
||||
*/
|
||||
public function stats(): array
|
||||
{
|
||||
@@ -47,15 +55,16 @@ class AccessLogParser
|
||||
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
|
||||
$pages = [];
|
||||
$books = [];
|
||||
$ips = [];
|
||||
$ips = []; // toutes requêtes publiques (tous chemins, tous statuts)
|
||||
$dayPages = [];
|
||||
$ipDays = []; // [ip => [dayOffset => count]]
|
||||
$ipPaths = []; // [ip => [path => count]]
|
||||
$ipPathTs = []; // [ip => [path => last_timestamp]]
|
||||
$ipAgents = []; // [ip => [ua => count]]
|
||||
$ipPaths = []; // chemins /post/ et /book/ avec statut 200 (pour les ts)
|
||||
$ipPathTs = [];
|
||||
$ipAllPaths = []; // tous chemins, tous statuts
|
||||
$ipAllDays = []; // tous jours, tous statuts
|
||||
$ipAgents = []; // tous user-agents par IP
|
||||
|
||||
foreach ($this->logFiles() as $file) {
|
||||
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths, $ipPathTs, $ipAgents);
|
||||
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
||||
}
|
||||
|
||||
arsort($pages);
|
||||
@@ -73,27 +82,30 @@ class AccessLogParser
|
||||
$pagesByDay[$path] = $arr;
|
||||
}
|
||||
|
||||
// Per-IP daily counts + top paths + top agents, limité aux 200 IPs les plus actives
|
||||
// Top 200 IPs par volume total de requêtes
|
||||
$topIpKeys = array_keys(array_slice($ips, 0, 200, true));
|
||||
$ipsByDay = [];
|
||||
$ipTopPaths = [];
|
||||
$ipTopAgents = [];
|
||||
foreach ($topIpKeys as $ip) {
|
||||
// Sparkline : activité totale par jour
|
||||
$arr = array_fill(0, $this->days, 0);
|
||||
foreach ($ipDays[$ip] ?? [] as $offset => $count) {
|
||||
foreach ($ipAllDays[$ip] ?? [] as $offset => $count) {
|
||||
if ($offset >= 0 && $offset < $this->days) {
|
||||
$arr[$offset] = $count;
|
||||
}
|
||||
}
|
||||
$ipsByDay[$ip] = $arr;
|
||||
|
||||
$paths = $ipPaths[$ip] ?? [];
|
||||
arsort($paths);
|
||||
// Top 20 chemins tous types confondus
|
||||
$allPaths = $ipAllPaths[$ip] ?? [];
|
||||
arsort($allPaths);
|
||||
$ipTopPaths[$ip] = [];
|
||||
foreach (array_slice($paths, 0, 10, true) as $p => $cnt) {
|
||||
foreach (array_slice($allPaths, 0, 20, true) as $p => $cnt) {
|
||||
$ipTopPaths[$ip][$p] = ['n' => $cnt, 'ts' => $ipPathTs[$ip][$p] ?? 0];
|
||||
}
|
||||
|
||||
// Top 5 user-agents
|
||||
$agents = $ipAgents[$ip] ?? [];
|
||||
arsort($agents);
|
||||
$ipTopAgents[$ip] = array_keys(array_slice($agents, 0, 5, true));
|
||||
@@ -163,16 +175,24 @@ class AccessLogParser
|
||||
return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}");
|
||||
}
|
||||
|
||||
private function parseLine(string $line, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages, array &$ipDays, array &$ipPaths, array &$ipPathTs, array &$ipAgents): void
|
||||
{
|
||||
private function parseLine(
|
||||
string $line,
|
||||
int $cutoff,
|
||||
array &$pages,
|
||||
array &$books,
|
||||
array &$ips,
|
||||
array &$dayPages,
|
||||
array &$ipPaths,
|
||||
array &$ipPathTs,
|
||||
array &$ipAllPaths,
|
||||
array &$ipAllDays,
|
||||
array &$ipAgents
|
||||
): void {
|
||||
if (!preg_match(self::RE, $line, $m)) {
|
||||
return;
|
||||
}
|
||||
[, $ip, $ts, $path, $status, $ua] = $m;
|
||||
|
||||
if ($status !== '200') {
|
||||
return;
|
||||
}
|
||||
$tsVal = self::parseTimestamp($ts);
|
||||
if ($tsVal < $cutoff) {
|
||||
return;
|
||||
@@ -181,38 +201,54 @@ class AccessLogParser
|
||||
$publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
|
||||
|
||||
if (str_starts_with($path, '/post/') && strlen($path) > 6) {
|
||||
$pages[$path] = ($pages[$path] ?? 0) + 1;
|
||||
if ($publicIp) {
|
||||
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
|
||||
}
|
||||
$dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1;
|
||||
$ipDays[$ip][$dayOffset] = ($ipDays[$ip][$dayOffset] ?? 0) + 1;
|
||||
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
|
||||
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
|
||||
$ipPathTs[$ip][$path] = $tsVal;
|
||||
}
|
||||
// Toutes les requêtes publiques : comptage global, chemins, jours, agents
|
||||
if ($publicIp) {
|
||||
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
|
||||
$ipAllPaths[$ip][$path] = ($ipAllPaths[$ip][$path] ?? 0) + 1;
|
||||
$ipAllDays[$ip][$dayOffset] = ($ipAllDays[$ip][$dayOffset] ?? 0) + 1;
|
||||
if ($ua !== '') {
|
||||
$ipAgents[$ip][$ua] = ($ipAgents[$ip][$ua] ?? 0) + 1;
|
||||
$ipAgents[$ip][$ua] = ($ipAgents[$ip][$ua] ?? 0) + 1;
|
||||
}
|
||||
} elseif (str_starts_with($path, '/book/') && strlen($path) > 6) {
|
||||
}
|
||||
|
||||
// Comptage spécifique aux pages de contenu (statut 200, /post/ ou /book/)
|
||||
if ($status !== '200') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/post/') && strlen($path) > 6) {
|
||||
$pages[$path] = ($pages[$path] ?? 0) + 1;
|
||||
$dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1;
|
||||
if ($publicIp) {
|
||||
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
|
||||
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
|
||||
$ipPathTs[$ip][$path] = $tsVal;
|
||||
}
|
||||
}
|
||||
} elseif (str_ends_with($path, '/') === false && str_starts_with($path, '/book/') && strlen($path) > 6) {
|
||||
$books[$path] = ($books[$path] ?? 0) + 1;
|
||||
if ($publicIp) {
|
||||
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
|
||||
}
|
||||
$ipDays[$ip][$dayOffset] = ($ipDays[$ip][$dayOffset] ?? 0) + 1;
|
||||
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
|
||||
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
|
||||
$ipPathTs[$ip][$path] = $tsVal;
|
||||
}
|
||||
if ($ua !== '') {
|
||||
$ipAgents[$ip][$ua] = ($ipAgents[$ip][$ua] ?? 0) + 1;
|
||||
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
|
||||
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
|
||||
$ipPathTs[$ip][$path] = $tsVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function parseFile(array $file, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages, array &$ipDays, array &$ipPaths, array &$ipPathTs, array &$ipAgents): void
|
||||
{
|
||||
private function parseFile(
|
||||
array $file,
|
||||
int $cutoff,
|
||||
array &$pages,
|
||||
array &$books,
|
||||
array &$ips,
|
||||
array &$dayPages,
|
||||
array &$ipPaths,
|
||||
array &$ipPathTs,
|
||||
array &$ipAllPaths,
|
||||
array &$ipAllDays,
|
||||
array &$ipAgents
|
||||
): void {
|
||||
if ($file['type'] === 'tgz') {
|
||||
try {
|
||||
$phar = new PharData($file['path']);
|
||||
@@ -222,7 +258,7 @@ class AccessLogParser
|
||||
continue;
|
||||
}
|
||||
foreach (explode("\n", $content) as $line) {
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths, $ipPathTs, $ipAgents);
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
@@ -235,7 +271,7 @@ class AccessLogParser
|
||||
while (!gzeof($h)) {
|
||||
$line = gzgets($h, 8192);
|
||||
if ($line !== false) {
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths, $ipPathTs, $ipAgents);
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
||||
}
|
||||
}
|
||||
gzclose($h);
|
||||
@@ -245,7 +281,7 @@ class AccessLogParser
|
||||
return;
|
||||
}
|
||||
while (($line = fgets($h)) !== false) {
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipDays, $ipPaths, $ipPathTs, $ipAgents);
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents);
|
||||
}
|
||||
fclose($h);
|
||||
}
|
||||
|
||||
+38
-12
@@ -1,11 +1,12 @@
|
||||
<?php
|
||||
$_statsSaved = isset($_GET['saved']);
|
||||
$_statsError = ($_GET['error'] ?? '') === 'write';
|
||||
$_readable = $adminData['stats_readable'] ?? false;
|
||||
$_books = $adminData['stats_books'] ?? [];
|
||||
$_asList = $adminData['stats_as'] ?? [];
|
||||
$_readable = $adminData['stats_readable'] ?? false;
|
||||
$_books = $adminData['stats_books'] ?? [];
|
||||
$_asList = $adminData['stats_as'] ?? [];
|
||||
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
|
||||
$_ipData = $adminData['stats_ip_data'] ?? [];
|
||||
$_ipData = $adminData['stats_ip_data'] ?? [];
|
||||
$_botPatterns = $adminData['bot_patterns'] ?? [];
|
||||
?>
|
||||
|
||||
<?php if ($_statsSaved): ?>
|
||||
@@ -21,12 +22,13 @@ $_ipData = $adminData['stats_ip_data'] ?? [];
|
||||
</div>
|
||||
<?php else: ?>
|
||||
|
||||
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · flux RSS XML</p>
|
||||
<p class="text-muted small mb-4">14 derniers jours · tous les chemins · flux RSS XML</p>
|
||||
|
||||
<script>
|
||||
var FOLIO_PAGES_BY_DAY = <?= json_encode($_pagesByDay, 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_PAGES_BY_DAY = <?= json_encode($_pagesByDay, 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_BOT_PATTERNS = <?= json_encode($_botPatterns, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
</script>
|
||||
|
||||
<div class="card mb-4">
|
||||
@@ -49,7 +51,6 @@ var FOLIO_IP_DATA = <?= json_encode($_ipData, JSON_UNESCAPED_SLASHES | JSON
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Livres -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
@@ -65,7 +66,7 @@ var FOLIO_IP_DATA = <?= json_encode($_ipData, JSON_UNESCAPED_SLASHES | JSON
|
||||
<table class="table table-sm table-hover mb-0 small">
|
||||
<tbody>
|
||||
<?php
|
||||
$maxB = max($_books) ?: 1;
|
||||
$maxB = max($_books) ?: 1;
|
||||
$rankB = 0;
|
||||
foreach ($_books as $url => $hits):
|
||||
$rankB++;
|
||||
@@ -94,10 +95,35 @@ var FOLIO_IP_DATA = <?= json_encode($_ipData, JSON_UNESCAPED_SLASHES | JSON
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
|
||||
<!-- Agents détectés -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between align-items-center">
|
||||
<span>Agents détectés <span class="text-muted fw-normal" id="agents-count"></span></span>
|
||||
<button class="btn btn-sm btn-outline-secondary py-0" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#agents-edit-panel">
|
||||
Gérer les patterns
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0" id="stats-agents-container">
|
||||
<p class="text-muted p-3 mb-0">Chargement…</p>
|
||||
</div>
|
||||
|
||||
<!-- Panneau d'édition des patterns bots -->
|
||||
<div id="agents-edit-panel" class="collapse">
|
||||
<div class="card-footer bg-transparent border-top p-3">
|
||||
<p class="small text-muted mb-2">Un pattern par ligne (correspondance insensible à la casse, recherche partielle dans le User-Agent).</p>
|
||||
<form method="post" action="/?action=admin_save_bots">
|
||||
<input type="hidden" name="_csrf" value="<?= htmlspecialchars($_session['csrf'] ?? '') ?>">
|
||||
<textarea name="bot_patterns" class="form-control form-control-sm font-monospace mb-2"
|
||||
rows="12" style="font-size:.75rem"><?= htmlspecialchars(implode("\n", $_botPatterns)) ?></textarea>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Enregistrer les patterns</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; // readable?>
|
||||
|
||||
|
||||
<script src="/assets/js/admin-stats.js" defer></script>
|
||||
|
||||
Reference in New Issue
Block a user