diff --git a/CHANGELOG.md b/CHANGELOG.md index 61f5cc5..4cba74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.33] - 2026-05-19 + +### Ajouté +- Admin stats : carte « Visiteurs uniques non-bot » en tête de page avec compteurs 7 / 14 / 30 jours (calculés sur toutes les IPs non-bot, pas seulement le top 200) +- Admin stats / Visiteurs par pays : bouton « ✕ » sur chaque AS pour l'exclure des stats — les AS exclus apparaissent dans une section dédiée avec bouton « ↺ Inclure » +- Admin stats / Visiteurs par pays : badge d'alerte si des IPs du top 200 n'ont pas de résolution AS +- Admin stats / Visiteurs par pays : les AS exclus sont filtrés du décompte par pays et des compteurs visiteurs (soustraction approximative via le top 200) +- Nouvelles actions AJAX `admin_add_excluded_as` / `admin_remove_excluded_as` (CSRF) pour gérer `data/excluded_as.json` + +### Modifié +- AccessLogParser : fenêtre d'analyse étendue à **30 jours** (était 14) ; calcul des visiteurs uniques par période (7 / 14 / 30 jours) sur l'ensemble des IPs non-bot +- Graphiques de tendance / par article : adaptés à 30 jours (labels x toutes les 3 jours) +- Articles : un seul bouton de réaction 👍 (suppression de 🔥 et 🤔) + +--- + ## [1.6.32] - 2026-05-19 ### Modifié diff --git a/public/assets/js/admin-stats.js b/public/assets/js/admin-stats.js index 46f80f9..aec83be 100644 --- a/public/assets/js/admin-stats.js +++ b/public/assets/js/admin-stats.js @@ -22,6 +22,52 @@ function botBadge(ua) { return isBot(ua) ? '🤖 ' : ''; } +// AS exclus (modifié dynamiquement par les boutons) +var _excludedAs = (typeof FOLIO_EXCLUDED_AS !== 'undefined') ? FOLIO_EXCLUDED_AS.slice() : []; +var _csrf = (typeof FOLIO_CSRF !== 'undefined') ? FOLIO_CSRF : ''; + +// ── Résumé visiteurs ───────────────────────────────────────────────────────── +(function () { + var el = document.getElementById('stats-summary-container'); + var uv = (typeof FOLIO_UNIQUE_VISITORS !== 'undefined') ? FOLIO_UNIQUE_VISITORS : {}; + var ipd = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {}; + if (!el) { return; } + + function computeCounts() { + var base = { 7: uv[7] || 0, 14: uv[14] || 0, 30: uv[30] || 0 }; + // Soustraire les IPs des AS exclus (top 200 uniquement — approximation) + Object.keys(ipd).forEach(function (ip) { + var d = ipd[ip]; + if (!d.asn || _excludedAs.indexOf(d.asn) === -1) { return; } + var daily = d.daily || []; + var n = daily.length; + if (daily.some(function (v) { return v > 0; })) { base[30] = Math.max(0, base[30] - 1); } + if (daily.slice(Math.max(0, n - 14)).some(function (v) { return v > 0; })) { base[14] = Math.max(0, base[14] - 1); } + if (daily.slice(Math.max(0, n - 7)).some(function (v) { return v > 0; })) { base[7] = Math.max(0, base[7] - 1); } + }); + return base; + } + + function render() { + var c = computeCounts(); + el.innerHTML = + '
' + + '
' + + '
' + + 'Visiteurs uniques non-bot' + + '' + c[7].toLocaleString('fr-FR') + '7 jours' + + '' + c[14].toLocaleString('fr-FR') + '14 jours' + + '' + c[30].toLocaleString('fr-FR') + '30 jours' + + (_excludedAs.length ? '' + _excludedAs.length + ' AS exclu(s)' : '') + + '
' + + '
' + + '
'; + } + + render(); + document.addEventListener('folio:excluded-as-changed', render); +}()); + // ── Visiteurs par pays ──────────────────────────────────────────────────────── (function () { var el = document.getElementById('stats-country-container'); @@ -53,6 +99,9 @@ function botBadge(ua) { ipsByAsn[k].sort(function (a, b) { return b.hits - a.hits; }); }); + // IPs sans AS + var noAsCount = Object.keys(ipData).filter(function (ip) { return !ipData[ip].asn; }).length; + function ipSparkline(daily) { if (!daily || !daily.length) { return ''; } var W = 80, H = 20, padX = 1, padY = 2; @@ -70,143 +119,234 @@ function botBadge(ua) { + ''; } - // Agréger par pays - var byCountry = {}, asByCountry = {}; - asList.forEach(function (as) { - var c = as.country || '??'; - byCountry[c] = (byCountry[c] || 0) + as.hits; - if (!asByCountry[c]) { asByCountry[c] = []; } - asByCountry[c].push(as); - }); - - var countries = Object.keys(byCountry).map(function (c) { - return { code: c, hits: byCountry[c], networks: asByCountry[c] }; - }).sort(function (a, b) { return b.hits - a.hits; }).slice(0, 20); - - if (!countries.length) { el.innerHTML = '

Aucune donnée.

'; return; } - - var maxH = countries[0].hits || 1; - var html = '
'; - - countries.forEach(function (c, ci) { - var pct = Math.round(c.hits / maxH * 100); - var cname = flag(c.code) + countryName(c.code); - var vis = c.hits.toLocaleString('fr-FR'); - var accId = 'acc-country-' + ci; - var nets = c.networks.slice().sort(function (a, b) { return b.hits - a.hits; }); - var maxN = nets[0] ? nets[0].hits : 1; - - var netRows = nets.map(function (n, ni) { - var npct = Math.round(n.hits / maxN * 100); - var asId = 'acc-as-' + ci + '-' + ni; - var asnKey = n.asn || '__unknown__'; - var ips = ipsByAsn[asnKey] || []; - - var ipRows = ips.slice(0, 20).map(function (ipInfo) { - // Agents sous l'IP avec badge bot (UA en entier) - var agentsHtml = ''; - (ipInfo.agents || []).forEach(function (ua) { - agentsHtml += '
' - + botBadge(ua) + esc(ua) + '
'; - }); - - // 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 (ts > 0) { postBook.push({ path: path, cnt: cnt, ts: ts }); } - else { other.push({ path: path, cnt: cnt }); } - }); - 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 raw = p.path.replace(prefix, ''); - var slug = ''; - try { slug = decodeURIComponent(raw); } catch (e) { slug = raw; } - return '
' - + '' - + esc(trunc(slug || p.path, 40)) + '' - + ' (' + p.cnt + ')
'; - } - function otherLine(p) { - return '
' - + '' + esc(trunc(p.path, 44)) + '' - + ' (' + p.cnt + ')
'; + function excludeAs(asn, name) { + var fd = new FormData(); + fd.append('_csrf', _csrf); + fd.append('asn', asn); + fetch('/?action=admin_add_excluded_as', { method: 'POST', body: fd }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.ok && _excludedAs.indexOf(asn) === -1) { + _excludedAs.push(asn); + document.dispatchEvent(new CustomEvent('folio:excluded-as-changed')); + renderCountry(); } + }); + } - 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 += '
Articles
' - + articles.map(function (p) { return pathLine(p, '/post/'); }).join(''); + function includeAs(asn) { + var fd = new FormData(); + fd.append('_csrf', _csrf); + fd.append('asn', asn); + fetch('/?action=admin_remove_excluded_as', { method: 'POST', body: fd }) + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.ok) { + _excludedAs = _excludedAs.filter(function (a) { return a !== asn; }); + document.dispatchEvent(new CustomEvent('folio:excluded-as-changed')); + renderCountry(); } - if (books.length) { - pathsHtml += '
Livres
' - + books.map(function (p) { return pathLine(p, '/book/'); }).join(''); - } - if (other.length) { - pathsHtml += '
Autres chemins
' - + other.map(otherLine).join(''); - } - if (!pathsHtml) { pathsHtml = ''; } + }); + } - return '
' - + '
' - + '' + esc(ipInfo.ip) + '' - + agentsHtml + function buildIpRow(ipInfo) { + // Agents sous l'IP avec badge bot (UA en entier) + var agentsHtml = ''; + (ipInfo.agents || []).forEach(function (ua) { + agentsHtml += '
' + + botBadge(ua) + esc(ua) + '
'; + }); + + // 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 (ts > 0) { postBook.push({ path: path, cnt: cnt, ts: ts }); } + else { other.push({ path: path, cnt: cnt }); } + }); + 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 raw = p.path.replace(prefix, ''); + var slug = ''; + try { slug = decodeURIComponent(raw); } catch (e) { slug = raw; } + return '
' + + '' + + esc(trunc(slug || p.path, 40)) + '' + + ' (' + p.cnt + ')
'; + } + function otherLine(p) { + return '
' + + '' + esc(trunc(p.path, 44)) + '' + + ' (' + p.cnt + ')
'; + } + + 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 += '
Articles
' + + articles.map(function (p) { return pathLine(p, '/post/'); }).join(''); + } + if (books.length) { + pathsHtml += '
Livres
' + + books.map(function (p) { return pathLine(p, '/book/'); }).join(''); + } + if (other.length) { + pathsHtml += '
Autres chemins
' + + other.map(otherLine).join(''); + } + if (!pathsHtml) { pathsHtml = ''; } + + return '
' + + '
' + + '' + esc(ipInfo.ip) + '' + + agentsHtml + + '
' + + '
' + ipSparkline(ipInfo.daily || []) + '
' + + '
' + pathsHtml + '
' + + '
' + + (ipInfo.hits || 0).toLocaleString('fr-FR') + '
' + + '
'; + } + + function renderCountry() { + // Filtrer les AS actifs / exclus + var activeLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) === -1; }); + var excludedLists = asList.filter(function (as) { return _excludedAs.indexOf(as.asn) !== -1; }); + + // Agréger par pays (AS actifs uniquement) + var byCountry = {}, asByCountry = {}; + activeLists.forEach(function (as) { + var c = as.country || '??'; + byCountry[c] = (byCountry[c] || 0) + as.hits; + if (!asByCountry[c]) { asByCountry[c] = []; } + asByCountry[c].push(as); + }); + + var countries = Object.keys(byCountry).map(function (c) { + return { code: c, hits: byCountry[c], networks: asByCountry[c] }; + }).sort(function (a, b) { return b.hits - a.hits; }).slice(0, 20); + + // En-tête avec alerte IPs sans AS + var headerExtra = ''; + if (noAsCount > 0) { + headerExtra = '' + + noAsCount + ' IP(s) sans AS'; + } + + var countryCard = document.querySelector('#stats-country-container'); + if (countryCard) { + var hdr = countryCard.closest('.card') + ? countryCard.closest('.card').querySelector('.card-header') + : null; + if (hdr && !hdr.querySelector('.no-as-badge')) { + var span = document.createElement('span'); + span.className = 'no-as-badge'; + span.innerHTML = headerExtra; + hdr.appendChild(span); + } + } + + if (!countries.length) { el.innerHTML = '

Aucune donnée.

'; return; } + + var maxH = countries[0].hits || 1; + var html = '
'; + + countries.forEach(function (c, ci) { + var pct = Math.round(c.hits / maxH * 100); + var cname = flag(c.code) + countryName(c.code); + var vis = c.hits.toLocaleString('fr-FR'); + var accId = 'acc-country-' + ci; + var nets = c.networks.slice().sort(function (a, b) { return b.hits - a.hits; }); + var maxN = nets[0] ? nets[0].hits : 1; + + var netRows = nets.map(function (n, ni) { + var npct = Math.round(n.hits / maxN * 100); + var asId = 'acc-as-' + ci + '-' + ni; + var asnKey = n.asn || '__unknown__'; + var ips = ipsByAsn[asnKey] || []; + + var ipRows = ips.slice(0, 20).map(buildIpRow).join(''); + + var hasIps = ips.length > 0; + var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : ''; + var chevron = hasIps ? '' : ''; + var excludeBtn = n.asn + ? '' + : ''; + + return '
' + + '
' + + '
' + + esc(n.name || '?') + + (n.asn ? ' AS' + esc(n.asn) + '' : '') + + chevron + + excludeBtn + '
' - + '
' + ipSparkline(ipInfo.daily || []) + '
' - + '
' + pathsHtml + '
' - + '
' - + (ipInfo.hits || 0).toLocaleString('fr-FR') + '
' + + '
' + + '
' + + '
' + + '
' + + n.hits.toLocaleString('fr-FR') + '
' + + '
' + + (hasIps ? '
' + + '
' + ipRows + '
' + + '
' : '') + '
'; }).join(''); - var hasIps = ips.length > 0; - var toggleAttrs = hasIps ? ' data-bs-toggle="collapse" data-bs-target="#' + asId + '" role="button"' : ''; - var chevron = hasIps ? '' : ''; - - return '
' - + '
' - + '
' - + esc(n.name || '?') - + (n.asn ? ' AS' + esc(n.asn) + '' : '') - + chevron + '
' - + '
' - + '
' + html += '
' + + '' + + '
' + + '
' + netRows + '
' + '
' - + (hasIps ? '
' - + '
' + ipRows + '
' - + '
' : '') + '
'; - }).join(''); + }); - html += '
' - + '' - + '
' - + '
' + netRows + '
' - + '
' - + '
'; - }); + html += '
'; - html += '
'; - el.innerHTML = html; + // Section AS exclus + if (excludedLists.length) { + html += '
' + + '
AS exclus des stats (' + excludedLists.length + ')
' + + '
'; + excludedLists.forEach(function (n) { + html += '' + + esc(n.name || '?') + + (n.asn ? ' AS' + esc(n.asn) + '' : '') + + '' + + ''; + }); + html += '
'; + } + + el.innerHTML = html; + + // Délégation : boutons exclure / inclure + el.addEventListener('click', function (e) { + var btn = e.target.closest('.exclude-as-btn'); + if (btn) { excludeAs(btn.getAttribute('data-asn'), btn.getAttribute('data-name')); return; } + btn = e.target.closest('.include-as-btn'); + if (btn) { includeAs(btn.getAttribute('data-asn')); } + }, { once: true }); + } + + renderCountry(); + document.addEventListener('folio:excluded-as-changed', renderCountry); }()); // ── Liste consolidée de tous les agents ────────────────────────────────────── @@ -298,7 +438,7 @@ function botBadge(ua) { el.addEventListener('click', function (e) { var btn = e.target.closest('.add-bot-btn'); if (!btn) { return; } - var row = btn.closest('tr'); + var row = btn.closest('tr'); var code = row ? row.querySelector('code') : null; if (code) { addBot(code.textContent, btn); } }); @@ -394,7 +534,7 @@ function botBadge(ua) { var xLabels = ''; pts.forEach(function (p, i) { - if (i % 2 === 0 || i === n - 1) { + if (i % 3 === 0 || i === n - 1) { xLabels += '' + p.l + ''; } @@ -410,7 +550,7 @@ function botBadge(ua) { }).join(''); trendEl.innerHTML = - '

Trafic total — 14 derniers jours

' + '

Trafic total — 30 derniers jours

' + '' + '' @@ -433,7 +573,6 @@ function botBadge(ua) { var COLORS = ['#0d6efd','#198754','#dc3545','#fd7e14','#6f42c1', '#20c997','#0dcaf0','#e63946','#f4a261','#457b9d']; - var n = 14; var VW = 900, VH = 480; var ml = 44, mr = 12, mt = 12, mb = 28; var W = VW - ml - mr; @@ -449,6 +588,7 @@ function botBadge(ua) { }); if (!series.length) { return; } + var n = series[0].data.length; var allVals = series.reduce(function (acc, s) { return acc.concat(s.data); }, []); var rawMax = Math.max.apply(null, allVals) || 1; var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10)); @@ -490,7 +630,7 @@ function botBadge(ua) { var xLabels = ''; labels.forEach(function (lbl, i) { - if (i % 2 === 0 || i === n - 1) { + if (i % 3 === 0 || i === n - 1) { var x = (ml + i * W / (n - 1)).toFixed(1); xLabels += '' + lbl + ''; @@ -522,7 +662,7 @@ function botBadge(ua) { }).join(''); el.innerHTML = - '

Par article — 14 derniers jours

' + '

Par article — 30 derniers jours

' + '' + grid + lines + yLabels + xLabels + '' @@ -549,7 +689,7 @@ function botBadge(ua) { var daily = pm ? (pagesByDay[pm[0]] || null) : null; return { title: title, link: link, slug: slug, vis: vis, daily: daily }; }); - var nDays = 14; + var nDays = Object.values(pagesByDay)[0] ? Object.values(pagesByDay)[0].length : 30; var totals = new Array(nDays).fill(0); Object.values(pagesByDay).forEach(function (arr) { arr.forEach(function (v, i) { if (i < nDays) { totals[i] += v; } }); diff --git a/public/index.php b/public/index.php index 6e8d878..deacb50 100644 --- a/public/index.php +++ b/public/index.php @@ -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') { diff --git a/public/version.txt b/public/version.txt index e038092..7337a93 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.32 +1.6.33 diff --git a/src/AccessLogParser.php b/src/AccessLogParser.php index 4a5fc88..d822641 100644 --- a/src/AccessLogParser.php +++ b/src/AccessLogParser.php @@ -25,7 +25,7 @@ class AccessLogParser string $pattern = '*-access.log', string $cacheFile = '', int $cacheTtl = 600, - int $days = 14, + int $days = 30, array $botPatterns = [] ) { $this->logDir = rtrim($logDir, '/'); @@ -45,7 +45,8 @@ class AccessLogParser * ips_by_day:array>, * ip_top_paths:array>, * ip_agents:array>, - * all_uas:array + * all_uas:array, + * unique_visitors:array * } */ public function stats(): array @@ -121,15 +122,45 @@ class AccessLogParser $ipTopAgents[$ip] = array_keys(array_slice($agents, 0, 5, true)); } + // Visiteurs uniques par période — calculé sur TOUS les IPs non-bot (pas seulement le top 200) + $uniqueVisitors = [7 => 0, 14 => 0, 30 => 0]; + $start7 = $this->days - 7; + $start14 = $this->days - 14; + foreach ($ipAllDays as $ipDay) { + $active7 = $active14 = $active30 = false; + foreach ($ipDay as $offset => $cnt) { + if ($cnt <= 0) { + continue; + } + $active30 = true; + if ($offset >= $start14) { + $active14 = true; + } + if ($offset >= $start7) { + $active7 = true; + } + } + if ($active7) { + ++$uniqueVisitors[7]; + } + if ($active14) { + ++$uniqueVisitors[14]; + } + if ($active30) { + ++$uniqueVisitors[30]; + } + } + $result = [ - 'pages' => $pages, - 'books' => $books, - 'ips' => $ips, - 'pages_by_day' => $pagesByDay, - 'ips_by_day' => $ipsByDay, - 'ip_top_paths' => $ipTopPaths, - 'ip_agents' => $ipTopAgents, - 'all_uas' => array_slice($allUas, 0, 300, true), + 'pages' => $pages, + 'books' => $books, + 'ips' => $ips, + 'pages_by_day' => $pagesByDay, + 'ips_by_day' => $ipsByDay, + 'ip_top_paths' => $ipTopPaths, + 'ip_agents' => $ipTopAgents, + 'all_uas' => array_slice($allUas, 0, 300, true), + 'unique_visitors' => $uniqueVisitors, ]; @mkdir(dirname($this->cacheFile), 0755, true); @file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); diff --git a/templates/admin_stats.php b/templates/admin_stats.php index 81340f8..398097f 100644 --- a/templates/admin_stats.php +++ b/templates/admin_stats.php @@ -6,8 +6,10 @@ $_books = $adminData['stats_books'] ?? []; $_asList = $adminData['stats_as'] ?? []; $_pagesByDay = $adminData['stats_pages_by_day'] ?? []; $_ipData = $adminData['stats_ip_data'] ?? []; -$_botPatterns = $adminData['bot_patterns'] ?? []; -$_allUas = $adminData['stats_all_uas'] ?? []; +$_botPatterns = $adminData['bot_patterns'] ?? []; +$_allUas = $adminData['stats_all_uas'] ?? []; +$_uniqueVisitors = $adminData['stats_unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0]; +$_excludedAs = $adminData['excluded_as'] ?? []; ?> @@ -23,17 +25,21 @@ $_allUas = $adminData['stats_all_uas'] ?? [];
-

14 derniers jours · tous les chemins · flux RSS XML

+

30 derniers jours · tous les chemins · flux RSS XML

+
+
Pages les plus visitées diff --git a/templates/comments_section.php b/templates/comments_section.php index d723823..272391b 100644 --- a/templates/comments_section.php +++ b/templates/comments_section.php @@ -9,9 +9,7 @@ // $commentError — string|null (message d'erreur) $_reactionDefs = [ - 'useful' => ['👍', 'Utile'], - 'important' => ['🔥', 'Important'], - 'interesting' => ['🤔', 'À creuser'], + 'useful' => ['👍', 'Utile'], ]; $_csrfToken = bin2hex(random_bytes(16)); diff --git a/templates/post_view.php b/templates/post_view.php index 72d5585..1719431 100644 --- a/templates/post_view.php +++ b/templates/post_view.php @@ -185,9 +185,7 @@ $hasSources = (!empty($externalLinks) || !empty($files)) ['👍', 'Utile'], - 'important' => ['🔥', 'Important'], - 'interesting' => ['🤔', 'À creuser'], + 'useful' => ['👍', 'Utile'], ]; ?>