Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29cb9d7723 | |||
| 4b44486abb | |||
| 9eab9ba7c3 | |||
| e12bbe1ef9 | |||
| ebef8c225e | |||
| fce4ae6a79 | |||
| dbbe60f28e | |||
| 1e41ef207e | |||
| b0f4814bb0 | |||
| d53b5da31a | |||
| 68a44d19d1 | |||
| e3d7e433e0 | |||
| 40656631ba | |||
| d6a7033e9e | |||
| be8a95ac4f | |||
| af169bccc9 | |||
| ddc7607972 | |||
| a578604ec3 | |||
| e8b361e720 | |||
| 007895d24a | |||
| 1eb6ca25f9 | |||
| d329872404 | |||
| 8a42dfe981 | |||
| 5203b2c514 |
+1
-1
File diff suppressed because one or more lines are too long
+121
@@ -5,6 +5,127 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
||||
|
||||
---
|
||||
|
||||
## [1.6.40] - 2026-05-19
|
||||
|
||||
### Modifié
|
||||
- Page article / stats visiteurs : fond `rgba(0,0,0,.45)` + `backdrop-filter:blur` pour lisibilité sur image claire ou foncée
|
||||
|
||||
---
|
||||
|
||||
## [1.6.39] - 2026-05-19
|
||||
|
||||
### Modifié
|
||||
- Page article / stats visiteurs : couleur blanche explicite + text-shadow pour lisibilité sur hero image sombre ou claire
|
||||
|
||||
---
|
||||
|
||||
## [1.6.38] - 2026-05-19
|
||||
|
||||
### Modifié
|
||||
- Page article : stats visiteurs affichées clairement pour tous — trois valeurs explicites « X / 7 j · Y / 14 j · Z / 30 j lecteurs »
|
||||
|
||||
---
|
||||
|
||||
## [1.6.37] - 2026-05-19
|
||||
|
||||
### Corrigé
|
||||
- Admin stats / Pages : `ipData` non défini dans la Pages IIFE → ReferenceError → catch → "Impossible de charger le flux"
|
||||
|
||||
---
|
||||
|
||||
## [1.6.36] - 2026-05-19
|
||||
|
||||
### Corrigé
|
||||
- AccessLogParser : `foreach ($this->artIp30 as $path => $ips)` écrasait la variable locale `$ips` (top IPs par volume), la remplaçant par le dernier ensemble d'IPs d'article. Renommé en `$_artIpSet`.
|
||||
|
||||
---
|
||||
|
||||
## [1.6.35] - 2026-05-19
|
||||
|
||||
### Corrigé
|
||||
- `visitors.json` : utilisation de `+` au lieu de `array_merge` pour préserver les clés entières 7/14/30 (array_merge les renumérote en 0/1/2)
|
||||
- Admin stats / Visiteurs par pays : bouton ✕ déplacé hors du div 9rem (il était écrasé par le nom de l'AS) ; `e.stopPropagation()` ajouté pour ne pas déclencher l'accordéon
|
||||
- Admin stats / Visiteurs par pays : listener délégué stocké et retiré avant réajout (évite l'accumulation de handlers après chaque `renderCountry()`)
|
||||
|
||||
### Modifié
|
||||
- Graphique "Trafic total" → "Visiteurs uniques / jour" calculé depuis les IPs du top 200 (approximation)
|
||||
|
||||
---
|
||||
|
||||
## [1.6.34] - 2026-05-19
|
||||
|
||||
### Ajouté
|
||||
- AccessLogParser : calcul des visiteurs uniques par article (IPs non-bot publiques, /post/ statut 200) sur 7 / 14 / 30 jours — stocké dans `data/UUID/visitors.json`
|
||||
- Page article : affichage du nombre de lecteurs (7 / 14 / 30 jours) dans la zone hero, recalculé à chaque visite de `/admin/stats`
|
||||
|
||||
---
|
||||
|
||||
## [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é
|
||||
- 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
|
||||
|
||||
### 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é
|
||||
- Admin stats / drill-down IP : user agents affichés sous l'adresse IP (top 5 par fréquence, sans corrélation avec les pages)
|
||||
- AccessLogParser : capture du user agent (groupe 5 de la regex COMBINED), tracking `ipAgents` par IP, `ip_agents` dans le résultat
|
||||
|
||||
---
|
||||
|
||||
## [1.6.29] - 2026-05-19
|
||||
|
||||
### Modifié
|
||||
- Admin stats / drill-down IP : chemins affichés un par ligne avec compteur entre parenthèses, triés par date de dernier accès (plus récent en premier)
|
||||
- AccessLogParser : suivi du dernier horodatage par chemin/IP (`ipPathTs`), `ip_top_paths` devient `{n: count, ts: timestamp}`
|
||||
|
||||
---
|
||||
|
||||
## [1.6.28] - 2026-05-19
|
||||
|
||||
### Ajouté
|
||||
- Admin stats : drill-down AS → IPs dans l'accordéon « Visiteurs par pays » — mini sparkline 14 jours + articles/livres consultés par IP
|
||||
- Admin stats : `ip_data` dans le cache stats (daily + top paths par IP publique)
|
||||
|
||||
### Supprimé
|
||||
- Admin stats : section « Répartition par réseau » (fusionnée dans l'accordéon pays)
|
||||
|
||||
---
|
||||
|
||||
## [1.6.27] - 2026-05-19
|
||||
|
||||
### Ajouté
|
||||
|
||||
+631
-48
@@ -1,19 +1,449 @@
|
||||
/* Admin stats : groupes AS + chargement pages via flux RSS XML /trending?period=14d */
|
||||
/* Admin stats : graphiques, sparklines, accordéon pays/AS/IP, agents */
|
||||
|
||||
// ── Groupes de réseaux ────────────────────────────────────────────────────────
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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> ' : '';
|
||||
}
|
||||
|
||||
// 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 addBtn = document.getElementById('as-group-add');
|
||||
if (!addBtn) { return; }
|
||||
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; }
|
||||
|
||||
addBtn.addEventListener('click', function () {
|
||||
var tpl = document.getElementById('as-group-tpl').content.cloneNode(true);
|
||||
document.getElementById('as-groups-list').appendChild(tpl);
|
||||
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 =
|
||||
'<div class="card mb-4">'
|
||||
+ '<div class="card-body py-2 px-3">'
|
||||
+ '<div class="d-flex flex-wrap gap-4 align-items-center">'
|
||||
+ '<span class="small fw-semibold text-muted">Visiteurs uniques non-bot</span>'
|
||||
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[7].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">7 jours</span></span>'
|
||||
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[14].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">14 jours</span></span>'
|
||||
+ '<span class="d-flex flex-column align-items-center"><span class="fs-5 fw-bold">' + c[30].toLocaleString('fr-FR') + '</span><span class="text-muted" style="font-size:.7rem">30 jours</span></span>'
|
||||
+ (_excludedAs.length ? '<span class="badge bg-warning text-dark" style="font-size:.65rem">' + _excludedAs.length + ' AS exclu(s)</span>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
render();
|
||||
document.addEventListener('folio:excluded-as-changed', render);
|
||||
}());
|
||||
|
||||
// ── Visiteurs par pays ────────────────────────────────────────────────────────
|
||||
(function () {
|
||||
var el = document.getElementById('stats-country-container');
|
||||
var asList = (typeof FOLIO_AS_LIST !== 'undefined') ? FOLIO_AS_LIST : [];
|
||||
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
|
||||
if (!el || !asList.length) { return; }
|
||||
|
||||
var _countryClickHandler = null;
|
||||
var dispNames = null;
|
||||
try { dispNames = new Intl.DisplayNames(['fr'], { type: 'region' }); } catch (e) {}
|
||||
function countryName(code) {
|
||||
if (!code || code === '??') { return 'Inconnu'; }
|
||||
try { return dispNames ? dispNames.of(code) : code; } catch (e) { return code; }
|
||||
}
|
||||
function flag(code) {
|
||||
if (!code || code.length !== 2) { return ''; }
|
||||
var cp = Array.from(code.toUpperCase()).map(function (c) { return 0x1F1E6 + c.charCodeAt(0) - 65; });
|
||||
return String.fromCodePoint(cp[0], cp[1]) + ' ';
|
||||
}
|
||||
|
||||
// Index IPs par ASN
|
||||
var ipsByAsn = {};
|
||||
Object.keys(ipData).forEach(function (ip) {
|
||||
var d = ipData[ip];
|
||||
var key = d.asn || '__unknown__';
|
||||
if (!ipsByAsn[key]) { ipsByAsn[key] = []; }
|
||||
ipsByAsn[key].push({ ip: ip, hits: d.hits, daily: d.daily, paths: d.paths, agents: d.agents || [] });
|
||||
});
|
||||
Object.keys(ipsByAsn).forEach(function (k) {
|
||||
ipsByAsn[k].sort(function (a, b) { return b.hits - a.hits; });
|
||||
});
|
||||
|
||||
document.getElementById('as-groups-list').addEventListener('click', function (e) {
|
||||
if (e.target.classList.contains('as-group-delete')) {
|
||||
e.target.closest('.as-group-row').remove();
|
||||
// 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;
|
||||
var max = Math.max.apply(null, daily) || 1;
|
||||
var n = daily.length;
|
||||
var pts = daily.map(function (v, i) {
|
||||
var x = padX + i * (W - 2 * padX) / (n - 1);
|
||||
var y = H - padY - (v / max) * (H - 2 * padY);
|
||||
return x.toFixed(1) + ',' + y.toFixed(1);
|
||||
}).join(' ');
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H
|
||||
+ '" style="display:block;flex-shrink:0">'
|
||||
+ '<polyline points="' + pts + '" fill="none" stroke="#6c757d"'
|
||||
+ ' stroke-width="1.2" stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
+ '</svg>';
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildIpRow(ipInfo) {
|
||||
// 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(ua) + '</div>';
|
||||
});
|
||||
|
||||
// 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 '<div style="font-size:.75rem;line-height:1.5">'
|
||||
+ '<a href="' + esc(p.path) + '" target="_blank" style="color:#495057">'
|
||||
+ 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('');
|
||||
}
|
||||
if (books.length) {
|
||||
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">'
|
||||
+ '<div style="width:9rem;flex-shrink:0">'
|
||||
+ '<code style="font-size:.72rem;color:#6c757d">' + esc(ipInfo.ip) + '</code>'
|
||||
+ agentsHtml
|
||||
+ '</div>'
|
||||
+ '<div style="flex-shrink:0;padding-top:2px">' + ipSparkline(ipInfo.daily || []) + '</div>'
|
||||
+ '<div class="flex-grow-1">' + pathsHtml + '</div>'
|
||||
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0;padding-top:2px">'
|
||||
+ (ipInfo.hits || 0).toLocaleString('fr-FR') + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
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 = '<span class="badge bg-warning text-dark ms-2" style="font-size:.65rem" '
|
||||
+ 'title="' + noAsCount + ' IP(s) parmi le top 200 sans résolution AS">'
|
||||
+ noAsCount + ' IP(s) sans AS</span>';
|
||||
}
|
||||
|
||||
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 = '<p class="text-muted mb-0">Aucune donnée.</p>'; return; }
|
||||
|
||||
var maxH = countries[0].hits || 1;
|
||||
var html = '<div class="accordion accordion-flush" id="acc-countries">';
|
||||
|
||||
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 ? '<span class="text-muted ms-1" style="font-size:.65rem">▾</span>' : '';
|
||||
var excludeBtn = n.asn
|
||||
? '<button class="btn btn-sm py-0 px-1 text-muted border-0 exclude-as-btn" style="font-size:.65rem;flex-shrink:0" title="Exclure cet AS des stats" data-asn="' + esc(n.asn) + '" data-name="' + esc(n.name || '') + '">✕</button>'
|
||||
: '';
|
||||
|
||||
return '<div>'
|
||||
+ '<div class="d-flex align-items-center gap-1 py-1"' + toggleAttrs + '>'
|
||||
+ '<div class="small d-flex align-items-center" style="min-width:0;flex:1 1 9rem;overflow:hidden">'
|
||||
+ '<span class="text-truncate">' + esc(n.name || '?') + '</span>'
|
||||
+ (n.asn ? ' <span class="text-muted text-nowrap">AS' + esc(n.asn) + '</span>' : '')
|
||||
+ chevron
|
||||
+ '</div>'
|
||||
+ excludeBtn
|
||||
+ '<div class="flex-grow-1"><div class="progress" style="height:4px">'
|
||||
+ '<div class="progress-bar bg-info" style="width:' + npct + '%"></div>'
|
||||
+ '</div></div>'
|
||||
+ '<div class="text-end text-muted small" style="width:4rem;flex-shrink:0">'
|
||||
+ n.hits.toLocaleString('fr-FR') + '</div>'
|
||||
+ '</div>'
|
||||
+ (hasIps ? '<div id="' + asId + '" class="collapse">'
|
||||
+ '<div class="border-start ms-3 ps-2 pb-1">' + ipRows + '</div>'
|
||||
+ '</div>' : '')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
html += '<div class="accordion-item border-0">'
|
||||
+ '<div class="d-flex align-items-center gap-2 py-2 px-0" data-bs-toggle="collapse"'
|
||||
+ ' data-bs-target="#' + accId + '" role="button" aria-expanded="false">'
|
||||
+ '<div class="fw-medium" style="width:10rem;flex-shrink:0">' + cname + '</div>'
|
||||
+ '<div class="flex-grow-1"><div class="progress" style="height:6px">'
|
||||
+ '<div class="progress-bar" style="width:' + pct + '%"></div>'
|
||||
+ '</div></div>'
|
||||
+ '<div class="text-end fw-semibold" style="width:5rem;flex-shrink:0">'
|
||||
+ vis + ' <span class="text-muted fw-normal small">vis.</span></div>'
|
||||
+ '<span class="text-muted" style="width:1rem;flex-shrink:0;font-size:.7rem">▾</span>'
|
||||
+ '</div>'
|
||||
+ '<div id="' + accId + '" class="collapse">'
|
||||
+ '<div class="ps-2 pb-2 border-start ms-3">' + netRows + '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
|
||||
// Section AS exclus
|
||||
if (excludedLists.length) {
|
||||
html += '<div class="mt-3 pt-2 border-top">'
|
||||
+ '<div class="small fw-semibold text-muted mb-2">AS exclus des stats (' + excludedLists.length + ')</div>'
|
||||
+ '<div class="d-flex flex-wrap gap-2">';
|
||||
excludedLists.forEach(function (n) {
|
||||
html += '<span class="badge border text-muted fw-normal d-inline-flex align-items-center gap-1" style="font-size:.7rem">'
|
||||
+ esc(n.name || '?')
|
||||
+ (n.asn ? ' <span class="text-muted">AS' + esc(n.asn) + '</span>' : '')
|
||||
+ '<button class="btn btn-sm p-0 ms-1 border-0 include-as-btn" data-asn="' + esc(n.asn || '') + '" title="Inclure" style="line-height:1;color:inherit;background:none">↺</button>'
|
||||
+ '</span>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Délégation : boutons exclure / inclure (handler unique pour éviter les doublons)
|
||||
if (_countryClickHandler) { el.removeEventListener('click', _countryClickHandler); }
|
||||
_countryClickHandler = function (e) {
|
||||
var btn = e.target.closest('.exclude-as-btn');
|
||||
if (btn) { e.stopPropagation(); excludeAs(btn.getAttribute('data-asn'), btn.getAttribute('data-name')); return; }
|
||||
btn = e.target.closest('.include-as-btn');
|
||||
if (btn) { e.stopPropagation(); includeAs(btn.getAttribute('data-asn')); }
|
||||
};
|
||||
el.addEventListener('click', _countryClickHandler);
|
||||
}
|
||||
|
||||
renderCountry();
|
||||
document.addEventListener('folio:excluded-as-changed', renderCountry);
|
||||
}());
|
||||
|
||||
// ── Liste consolidée de tous les agents ──────────────────────────────────────
|
||||
(function () {
|
||||
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; }
|
||||
|
||||
var agents = Object.keys(allUas).map(function (ua) {
|
||||
return { ua: ua, hits: allUas[ua], bot: isBot(ua) };
|
||||
}).sort(function (a, b) {
|
||||
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) 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>'
|
||||
+ 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>';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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); }
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -22,12 +452,9 @@
|
||||
var container = document.getElementById('stats-pages-container');
|
||||
var badge = document.getElementById('stats-pages-count');
|
||||
var pagesByDay = (typeof FOLIO_PAGES_BY_DAY !== 'undefined') ? FOLIO_PAGES_BY_DAY : {};
|
||||
var ipData = (typeof FOLIO_IP_DATA !== 'undefined') ? FOLIO_IP_DATA : {};
|
||||
if (!container) { return; }
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function sparkline(data) {
|
||||
var W = 120, H = 28, padX = 2, padY = 3;
|
||||
var max = Math.max.apply(null, data) || 1;
|
||||
@@ -37,7 +464,6 @@
|
||||
var y = H - padY - (v / max) * (H - 2 * padY);
|
||||
return x.toFixed(1) + ',' + y.toFixed(1);
|
||||
}).join(' ');
|
||||
// Zone remplie sous la courbe
|
||||
var first = padX.toFixed(1) + ',' + (H - padY).toFixed(1);
|
||||
var last = (W - padX).toFixed(1) + ',' + (H - padY).toFixed(1);
|
||||
return '<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + H + '" style="display:block;overflow:visible">'
|
||||
@@ -54,41 +480,197 @@
|
||||
var trendEl = document.getElementById('stats-trend-container');
|
||||
if (!trendEl || !totals.length) { return; }
|
||||
|
||||
var days = totals.length;
|
||||
var maxV = Math.max.apply(null, totals) || 1;
|
||||
var W = 1000; // viewBox, s'adapte en CSS
|
||||
var H = 80;
|
||||
var padX = 4;
|
||||
var padY = 8;
|
||||
var barW = Math.floor((W - 2 * padX) / days) - 2;
|
||||
var n = totals.length;
|
||||
var VW = 900, VH = 480;
|
||||
var ml = 44, mr = 12, mt = 12, mb = 28;
|
||||
var W = VW - ml - mr;
|
||||
var H = VH - mt - mb;
|
||||
|
||||
var rawMax = Math.max.apply(null, totals) || 1;
|
||||
var mag = Math.pow(10, Math.floor(Math.log(rawMax) / Math.LN10));
|
||||
var maxV = Math.ceil(rawMax / mag) * mag;
|
||||
var nTicks = 4;
|
||||
|
||||
// Dates des jours (index 0 = il y a 13 jours)
|
||||
var now = new Date();
|
||||
var labels = totals.map(function (_, i) {
|
||||
var d = new Date(now);
|
||||
d.setDate(d.getDate() - (days - 1 - i));
|
||||
d.setDate(d.getDate() - (n - 1 - i));
|
||||
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
|
||||
var bars = totals.map(function (v, i) {
|
||||
var x = padX + i * (W - 2 * padX) / days + 1;
|
||||
var bh = Math.max(2, (v / maxV) * (H - padY - 16));
|
||||
var y = H - padY - bh;
|
||||
var lx = x + barW / 2;
|
||||
var label = labels[i];
|
||||
var showLabel = (i === 0 || i === days - 1 || i === Math.floor(days / 2));
|
||||
return '<rect x="' + x.toFixed(1) + '" y="' + y.toFixed(1) + '" width="' + barW + '" height="' + bh.toFixed(1) + '"'
|
||||
+ ' fill="var(--bs-primary,#0d6efd)" opacity="0.75" rx="2"/>'
|
||||
+ '<title>' + label + ' : ' + v + ' vis.</title>'
|
||||
+ (showLabel
|
||||
? '<text x="' + lx.toFixed(1) + '" y="' + (H - 1) + '" text-anchor="middle"'
|
||||
+ ' font-size="10" fill="var(--bs-secondary-color,#6c757d)">' + label + '</text>'
|
||||
: '');
|
||||
var pts = totals.map(function (v, i) {
|
||||
return { x: ml + i * W / (n - 1), y: mt + H - (v / maxV) * H, v: v, l: labels[i] };
|
||||
});
|
||||
|
||||
function smoothPath(points) {
|
||||
var d = 'M ' + points[0].x.toFixed(1) + ' ' + points[0].y.toFixed(1);
|
||||
for (var i = 0; i < points.length - 1; i++) {
|
||||
var p0 = points[i > 0 ? i - 1 : i];
|
||||
var p1 = points[i];
|
||||
var p2 = points[i + 1];
|
||||
var p3 = points[i + 2 < points.length ? i + 2 : i + 1];
|
||||
var t = 0.35;
|
||||
var cp1x = p1.x + t * (p2.x - p0.x) / 2;
|
||||
var cp1y = p1.y + t * (p2.y - p0.y) / 2;
|
||||
var cp2x = p2.x - t * (p3.x - p1.x) / 2;
|
||||
var cp2y = p2.y - t * (p3.y - p1.y) / 2;
|
||||
d += ' C ' + cp1x.toFixed(1) + ' ' + cp1y.toFixed(1)
|
||||
+ ', ' + cp2x.toFixed(1) + ' ' + cp2y.toFixed(1)
|
||||
+ ', ' + p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
var linePath = smoothPath(pts);
|
||||
var areaPath = linePath
|
||||
+ ' L ' + pts[n - 1].x.toFixed(1) + ' ' + (mt + H)
|
||||
+ ' L ' + pts[0].x.toFixed(1) + ' ' + (mt + H) + ' Z';
|
||||
|
||||
var grid = '', yLabels = '';
|
||||
for (var t = 0; t <= nTicks; t++) {
|
||||
var val = Math.round(maxV * t / nTicks);
|
||||
var gy = (mt + H - (val / maxV) * H).toFixed(1);
|
||||
grid += '<line x1="' + ml + '" y1="' + gy + '" x2="' + (VW - mr) + '" y2="' + gy
|
||||
+ '" stroke="#e9ecef" stroke-width="1"/>';
|
||||
yLabels += '<text x="' + (ml - 6) + '" y="' + gy + '" text-anchor="end" dominant-baseline="middle"'
|
||||
+ ' font-size="11" fill="#adb5bd">' + val + '</text>';
|
||||
}
|
||||
|
||||
var xLabels = '';
|
||||
pts.forEach(function (p, i) {
|
||||
if (i % 3 === 0 || i === n - 1) {
|
||||
xLabels += '<text x="' + p.x.toFixed(1) + '" y="' + (VH - 4) + '" text-anchor="middle"'
|
||||
+ ' font-size="11" fill="#adb5bd">' + p.l + '</text>';
|
||||
}
|
||||
});
|
||||
|
||||
var dots = pts.map(function (p) {
|
||||
return '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="14"'
|
||||
+ ' fill="transparent" cursor="default">'
|
||||
+ '<title>' + esc(p.l) + ' : ' + p.v + ' visiteur(s)</title>'
|
||||
+ '</circle>'
|
||||
+ '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="3"'
|
||||
+ ' fill="var(--bs-primary,#0d6efd)" stroke="#fff" stroke-width="1.5" pointer-events="none"/>';
|
||||
}).join('');
|
||||
|
||||
trendEl.innerHTML = '<p class="small text-muted mb-2 fw-semibold">Trafic total — 14 derniers jours</p>'
|
||||
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + W + ' ' + H + '"'
|
||||
+ ' style="width:100%;height:80px;display:block">' + bars + '</svg>';
|
||||
trendEl.innerHTML =
|
||||
'<p class="small text-muted mb-2 fw-semibold">Visiteurs uniques / jour — 30 derniers jours <span class="fw-normal opacity-50">(top 200 IPs)</span></p>'
|
||||
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + VW + ' ' + VH + '"'
|
||||
+ ' style="width:100%;height:480px;display:block;overflow:visible">'
|
||||
+ '<defs>'
|
||||
+ '<linearGradient id="area-grad" x1="0" y1="0" x2="0" y2="1">'
|
||||
+ '<stop offset="0%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0.2"/>'
|
||||
+ '<stop offset="100%" stop-color="var(--bs-primary,#0d6efd)" stop-opacity="0"/>'
|
||||
+ '</linearGradient>'
|
||||
+ '</defs>'
|
||||
+ grid
|
||||
+ '<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
|
||||
+ '</svg>';
|
||||
}
|
||||
|
||||
function multiLineChart(pagesByDay, rssRows) {
|
||||
var el = document.getElementById('stats-multiline-container');
|
||||
if (!el) { return; }
|
||||
|
||||
var COLORS = ['#0d6efd','#198754','#dc3545','#fd7e14','#6f42c1',
|
||||
'#20c997','#0dcaf0','#e63946','#f4a261','#457b9d'];
|
||||
var VW = 900, VH = 480;
|
||||
var ml = 44, mr = 12, mt = 12, mb = 28;
|
||||
var W = VW - ml - mr;
|
||||
var H = VH - mt - mb;
|
||||
|
||||
var series = [];
|
||||
rssRows.forEach(function (row) {
|
||||
var pm = row.link.match(/\/post\/[^?#]*/);
|
||||
var data = pm ? (pagesByDay[pm[0]] || null) : null;
|
||||
if (data && series.length < 10) {
|
||||
series.push({ title: row.title || row.slug, data: data });
|
||||
}
|
||||
});
|
||||
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));
|
||||
var maxV = Math.ceil(rawMax / mag) * mag;
|
||||
var nTicks = 4;
|
||||
|
||||
var now = new Date();
|
||||
var labels = series[0].data.map(function (_, i) {
|
||||
var d = new Date(now);
|
||||
d.setDate(d.getDate() - (n - 1 - i));
|
||||
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
});
|
||||
|
||||
function smoothPath(pts) {
|
||||
var d = 'M ' + pts[0].x.toFixed(1) + ' ' + pts[0].y.toFixed(1);
|
||||
for (var i = 0; i < pts.length - 1; i++) {
|
||||
var p0 = pts[i > 0 ? i - 1 : i];
|
||||
var p1 = pts[i], p2 = pts[i + 1];
|
||||
var p3 = pts[i + 2 < pts.length ? i + 2 : i + 1];
|
||||
var t = 0.35;
|
||||
var cp1x = p1.x + t * (p2.x - p0.x) / 2, cp1y = p1.y + t * (p2.y - p0.y) / 2;
|
||||
var cp2x = p2.x - t * (p3.x - p1.x) / 2, cp2y = p2.y - t * (p3.y - p1.y) / 2;
|
||||
d += ' C ' + cp1x.toFixed(1) + ' ' + cp1y.toFixed(1)
|
||||
+ ', ' + cp2x.toFixed(1) + ' ' + cp2y.toFixed(1)
|
||||
+ ', ' + p2.x.toFixed(1) + ' ' + p2.y.toFixed(1);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
var grid = '', yLabels = '';
|
||||
for (var t = 0; t <= nTicks; t++) {
|
||||
var val = Math.round(maxV * t / nTicks);
|
||||
var gy = (mt + H - (val / maxV) * H).toFixed(1);
|
||||
grid += '<line x1="' + ml + '" y1="' + gy + '" x2="' + (VW - mr) + '" y2="' + gy
|
||||
+ '" stroke="#e9ecef" stroke-width="1"/>';
|
||||
yLabels += '<text x="' + (ml - 6) + '" y="' + gy + '" text-anchor="end" dominant-baseline="middle"'
|
||||
+ ' font-size="11" fill="#adb5bd">' + val + '</text>';
|
||||
}
|
||||
|
||||
var xLabels = '';
|
||||
labels.forEach(function (lbl, i) {
|
||||
if (i % 3 === 0 || i === n - 1) {
|
||||
var x = (ml + i * W / (n - 1)).toFixed(1);
|
||||
xLabels += '<text x="' + x + '" y="' + (VH - 4) + '" text-anchor="middle"'
|
||||
+ ' font-size="11" fill="#adb5bd">' + lbl + '</text>';
|
||||
}
|
||||
});
|
||||
|
||||
var lines = series.map(function (s, si) {
|
||||
var color = COLORS[si % COLORS.length];
|
||||
var pts = s.data.map(function (v, i) {
|
||||
return { x: ml + i * W / (n - 1), y: mt + H - (v / maxV) * H, v: v, l: labels[i] };
|
||||
});
|
||||
var dots = pts.map(function (p) {
|
||||
return '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="14"'
|
||||
+ ' fill="transparent"><title>' + esc(p.l) + ' — ' + esc(s.title) + ' : ' + p.v + ' vis.</title></circle>'
|
||||
+ '<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="2.5"'
|
||||
+ ' fill="' + color + '" stroke="#fff" stroke-width="1" pointer-events="none"/>';
|
||||
}).join('');
|
||||
return '<path d="' + smoothPath(pts) + '" fill="none" stroke="' + color
|
||||
+ '" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>' + dots;
|
||||
}).join('');
|
||||
|
||||
var legend = series.map(function (s, si) {
|
||||
var color = COLORS[si % COLORS.length];
|
||||
return '<span class="d-inline-flex align-items-center gap-1 me-3 mb-1 small">'
|
||||
+ '<svg width="16" height="3" style="flex-shrink:0"><line x1="0" y1="1.5" x2="16" y2="1.5"'
|
||||
+ ' stroke="' + color + '" stroke-width="2.5" stroke-linecap="round"/></svg>'
|
||||
+ '<span class="text-truncate" style="max-width:160px" title="' + esc(s.title) + '">'
|
||||
+ esc(trunc(s.title, 32)) + '</span></span>';
|
||||
}).join('');
|
||||
|
||||
el.innerHTML =
|
||||
'<p class="small text-muted mb-2 fw-semibold">Par article — 30 derniers jours</p>'
|
||||
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + VW + ' ' + VH + '"'
|
||||
+ ' style="width:100%;height:480px;display:block;overflow:visible">'
|
||||
+ grid + lines + yLabels + xLabels + '</svg>'
|
||||
+ '<div class="d-flex flex-wrap mt-2">' + legend + '</div>';
|
||||
}
|
||||
|
||||
fetch('/trending?period=14d')
|
||||
@@ -111,14 +693,15 @@
|
||||
var daily = pm ? (pagesByDay[pm[0]] || null) : null;
|
||||
return { title: title, link: link, slug: slug, vis: vis, daily: daily };
|
||||
});
|
||||
|
||||
// Graphique global : somme de tous les articles par jour
|
||||
var nDays = 14;
|
||||
var totals = new Array(nDays).fill(0);
|
||||
Object.values(pagesByDay).forEach(function (arr) {
|
||||
arr.forEach(function (v, i) { if (i < nDays) { totals[i] += v; } });
|
||||
var nDays = Object.values(pagesByDay)[0] ? Object.values(pagesByDay)[0].length : 30;
|
||||
// Visiteurs uniques par jour — compté sur les IPs du top 200 (approximation)
|
||||
var dailyVisitors = new Array(nDays).fill(0);
|
||||
Object.keys(ipData).forEach(function (ip) {
|
||||
var daily = ipData[ip].daily || [];
|
||||
daily.forEach(function (v, i) { if (i < nDays && v > 0) { dailyVisitors[i]++; } });
|
||||
});
|
||||
trendChart(totals);
|
||||
trendChart(dailyVisitors);
|
||||
multiLineChart(pagesByDay, rows);
|
||||
|
||||
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0 small w-100"><tbody>';
|
||||
rows.forEach(function (row, i) {
|
||||
|
||||
+185
-1
@@ -896,6 +896,13 @@ switch ($action) {
|
||||
$bookContext['next_article'] = $bookContext['next'] !== null ? $articles->getBySlug($bookContext['next']) : null;
|
||||
}
|
||||
|
||||
$articleVisitors = [];
|
||||
$_visFile = DATA_PATH . '/' . ($article['uuid'] ?? '') . '/visitors.json';
|
||||
if (($article['uuid'] ?? '') !== '' && is_file($_visFile)) {
|
||||
$articleVisitors = json_decode((string) file_get_contents($_visFile), true) ?: [];
|
||||
}
|
||||
unset($_visFile);
|
||||
|
||||
include BASE_PATH . '/templates/post_view.php';
|
||||
break;
|
||||
|
||||
@@ -2725,6 +2732,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,16 +2772,34 @@ 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, 30, $botPatterns);
|
||||
$accessStats = $accessParser->stats();
|
||||
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
||||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||
|
||||
$ipData = [];
|
||||
foreach ($accessStats['ips_by_day'] ?? [] as $ip => $daily) {
|
||||
$info = $asnMap[$ip] ?? ['asn' => '', 'name' => '?', 'country' => ''];
|
||||
$ipData[$ip] = [
|
||||
'hits' => $topIps[$ip] ?? (int) array_sum($daily),
|
||||
'asn' => $info['asn'],
|
||||
'name' => $info['name'],
|
||||
'country' => $info['country'],
|
||||
'daily' => $daily,
|
||||
'paths' => $accessStats['ip_top_paths'][$ip] ?? [],
|
||||
'agents' => $accessStats['ip_agents'][$ip] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
$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'] ?? [],
|
||||
'unique_visitors' => $accessStats['unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0],
|
||||
'article_unique_visitors' => $accessStats['article_unique_visitors'] ?? [],
|
||||
];
|
||||
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||||
}
|
||||
@@ -2752,6 +2809,31 @@ switch ($action) {
|
||||
$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];
|
||||
|
||||
// Écriture des visitors.json par article (slug → UUID via l'index)
|
||||
$_slugIndex = is_file(DATA_PATH . '/_cache/slug_index.json')
|
||||
? (json_decode((string) file_get_contents(DATA_PATH . '/_cache/slug_index.json'), true) ?: [])
|
||||
: [];
|
||||
foreach (($statsRaw['article_unique_visitors'] ?? []) as $_artPath => $_artCounts) {
|
||||
$_artSlug = rawurldecode(substr((string) $_artPath, 6));
|
||||
$_artUuid = $_slugIndex[$_artSlug] ?? null;
|
||||
if ($_artUuid !== null && preg_match('/^[0-9a-f\-]{36}$/i', $_artUuid)) {
|
||||
@file_put_contents(
|
||||
DATA_PATH . '/' . $_artUuid . '/visitors.json',
|
||||
json_encode($_artCounts + ['updated' => time()], JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
}
|
||||
unset($_slugIndex, $_artPath, $_artCounts, $_artSlug, $_artUuid);
|
||||
|
||||
// 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') {
|
||||
@@ -3190,6 +3272,108 @@ 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;
|
||||
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_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') {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (!defined('BASE_PATH')) {
|
||||
|
||||
@@ -145,7 +145,7 @@ ob_start();
|
||||
'7d' => '8 h', '14d' => '8 h', '30d' => '8 h',
|
||||
'1y' => '8 h',
|
||||
];
|
||||
foreach (TENDANCES_PERIODS as $p => $info):
|
||||
foreach (TENDANCES_PERIODS as $p => $info):
|
||||
$url = $base . '/trending?period=' . rawurlencode($p);
|
||||
?>
|
||||
<tr>
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$title = htmlspecialchars(($a['title'] ?? ''), ENT_XML1);
|
||||
$plural = $v > 1 ? 's' : '';
|
||||
$desc = htmlspecialchars($title . ' — ' . $v . ' visiteur' . $plural . ' unique' . $plural . ' (' . $label . ')', ENT_XML1);
|
||||
?>
|
||||
?>
|
||||
<item>
|
||||
<title><?= $title ?> (<?= $v ?> visiteur<?= $plural ?>)</title>
|
||||
<link><?= $link ?></link>
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.6.27
|
||||
1.6.40
|
||||
|
||||
@@ -57,7 +57,7 @@ foreach ($pending as $file) {
|
||||
echo "✓\n";
|
||||
$count++;
|
||||
} catch (Throwable $e) {
|
||||
echo "✗ " . $e->getMessage() . "\n";
|
||||
echo '✗ ' . $e->getMessage() . "\n";
|
||||
$errors++;
|
||||
break;
|
||||
}
|
||||
|
||||
+208
-26
@@ -9,28 +9,53 @@ class AccessLogParser
|
||||
private string $cacheFile;
|
||||
private int $cacheTtl;
|
||||
private int $days;
|
||||
/** @var list<string> */
|
||||
private array $botPatterns;
|
||||
|
||||
/** @var array<string,array<string,true>> */
|
||||
private array $artIp7 = [];
|
||||
/** @var array<string,array<string,true>> */
|
||||
private array $artIp14 = [];
|
||||
/** @var array<string,array<string,true>> */
|
||||
private array $artIp30 = [];
|
||||
|
||||
private static ?array $memo = null;
|
||||
|
||||
// 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}) /';
|
||||
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(
|
||||
string $logDir = '/var/log/apache2',
|
||||
string $pattern = '*-access.log',
|
||||
string $cacheFile = '',
|
||||
int $cacheTtl = 600,
|
||||
int $days = 14
|
||||
int $days = 30,
|
||||
array $botPatterns = []
|
||||
) {
|
||||
$this->logDir = rtrim($logDir, '/');
|
||||
$this->pattern = $pattern;
|
||||
$this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json';
|
||||
$this->cacheTtl = $cacheTtl;
|
||||
$this->days = $days;
|
||||
$this->botPatterns = array_map('strtolower', $botPatterns);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{pages:array<string,int>,books:array<string,int>,ips:array<string,int>,pages_by_day:array<string,list<int>>}
|
||||
* @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>>,
|
||||
* all_uas:array<string,int>,
|
||||
* unique_visitors:array<int,int>,
|
||||
* article_unique_visitors:array<string,array<int,int>>
|
||||
* }
|
||||
*/
|
||||
public function stats(): array
|
||||
{
|
||||
@@ -47,20 +72,25 @@ class AccessLogParser
|
||||
$cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400);
|
||||
$pages = [];
|
||||
$books = [];
|
||||
$ips = [];
|
||||
$dayPages = []; // [path => [dayOffset => count]], dayOffset 0=oldest
|
||||
$ips = []; // requêtes publiques non-bot (tous chemins, tous statuts)
|
||||
$dayPages = [];
|
||||
$ipPaths = []; // chemins /post/ et /book/ avec statut 200 (pour les ts)
|
||||
$ipPathTs = [];
|
||||
$ipAllPaths = []; // tous chemins, tous statuts, non-bots
|
||||
$ipAllDays = []; // tous jours, tous statuts, non-bots
|
||||
$ipAgents = []; // user-agents non-bot par IP
|
||||
$allUas = []; // tous UAs publics (bots inclus) pour "Agents détectés"
|
||||
|
||||
foreach ($this->logFiles() as $file) {
|
||||
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages);
|
||||
$this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||
}
|
||||
|
||||
arsort($pages);
|
||||
arsort($books);
|
||||
arsort($ips);
|
||||
arsort($allUas);
|
||||
|
||||
// Normalise dayPages : pour chaque page, tableau de $this->days entiers (index 0 = le plus ancien)
|
||||
$pagesByDay = [];
|
||||
$today = (int) strtotime('today midnight');
|
||||
foreach ($dayPages as $path => $byOffset) {
|
||||
$arr = array_fill(0, $this->days, 0);
|
||||
foreach ($byOffset as $offset => $count) {
|
||||
@@ -71,9 +101,89 @@ class AccessLogParser
|
||||
$pagesByDay[$path] = $arr;
|
||||
}
|
||||
|
||||
$result = ['pages' => $pages, 'books' => $books, 'ips' => $ips, 'pages_by_day' => $pagesByDay];
|
||||
// Top 200 IPs non-bot 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 ($ipAllDays[$ip] ?? [] as $offset => $count) {
|
||||
if ($offset >= 0 && $offset < $this->days) {
|
||||
$arr[$offset] = $count;
|
||||
}
|
||||
}
|
||||
$ipsByDay[$ip] = $arr;
|
||||
|
||||
// Top 20 chemins tous types confondus
|
||||
$allPaths = $ipAllPaths[$ip] ?? [];
|
||||
arsort($allPaths);
|
||||
$ipTopPaths[$ip] = [];
|
||||
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));
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
|
||||
// Visiteurs uniques par article (IPs publiques non-bot, /post/ statut 200)
|
||||
$articleUv = [];
|
||||
foreach ($this->artIp30 as $path => $_artIpSet) {
|
||||
$articleUv[$path] = [
|
||||
'7' => count($this->artIp7[$path] ?? []),
|
||||
'14' => count($this->artIp14[$path] ?? []),
|
||||
'30' => count($_artIpSet),
|
||||
];
|
||||
}
|
||||
|
||||
$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),
|
||||
'unique_visitors' => $uniqueVisitors,
|
||||
'article_unique_visitors' => $articleUv,
|
||||
];
|
||||
@mkdir(dirname($this->cacheFile), 0755, true);
|
||||
@file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
return self::$memo = $result;
|
||||
}
|
||||
|
||||
@@ -88,6 +198,21 @@ class AccessLogParser
|
||||
&& (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}> */
|
||||
private function logFiles(): array
|
||||
{
|
||||
@@ -124,43 +249,100 @@ class AccessLogParser
|
||||
if (!preg_match('/(\d{2})\/(\w{3})\/(\d{4}):(\d{2}:\d{2}:\d{2}) ([+-]\d{4})/', $raw, $m)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
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): 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,
|
||||
array &$allUas
|
||||
): void {
|
||||
if (!preg_match(self::RE, $line, $m)) {
|
||||
return;
|
||||
}
|
||||
[, $ip, $ts, $path, $status] = $m;
|
||||
[, $ip, $ts, $path, $status, $ua] = $m;
|
||||
|
||||
if ($status !== '200') {
|
||||
return;
|
||||
}
|
||||
$tsVal = self::parseTimestamp($ts);
|
||||
if ($tsVal < $cutoff) {
|
||||
return;
|
||||
}
|
||||
|
||||
$publicIp = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
|
||||
$isBot = $this->matchesBot($ua);
|
||||
|
||||
// Tous les UAs publics pour la section "Agents détectés" (bots inclus)
|
||||
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;
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
// Comptage spécifique aux pages de contenu (statut 200, non-bot)
|
||||
if ($status !== '200' || $isBot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, '/post/') && strlen($path) > 6) {
|
||||
$pages[$path] = ($pages[$path] ?? 0) + 1;
|
||||
if ($publicIp) {
|
||||
$ips[$ip] = ($ips[$ip] ?? 0) + 1;
|
||||
}
|
||||
$dayOffset = (int) floor(($tsVal - $cutoff) / 86400);
|
||||
$dayPages[$path][$dayOffset] = ($dayPages[$path][$dayOffset] ?? 0) + 1;
|
||||
} elseif (str_starts_with($path, '/book/') && strlen($path) > 6) {
|
||||
if ($publicIp) {
|
||||
$ipPaths[$ip][$path] = ($ipPaths[$ip][$path] ?? 0) + 1;
|
||||
if ($tsVal > ($ipPathTs[$ip][$path] ?? 0)) {
|
||||
$ipPathTs[$ip][$path] = $tsVal;
|
||||
}
|
||||
// Visiteurs uniques par article (IPs publiques non-bot uniquement)
|
||||
$this->artIp30[$path][$ip] = true;
|
||||
if ($dayOffset >= $this->days - 14) {
|
||||
$this->artIp14[$path][$ip] = true;
|
||||
}
|
||||
if ($dayOffset >= $this->days - 7) {
|
||||
$this->artIp7[$path][$ip] = true;
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
$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): 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,
|
||||
array &$allUas
|
||||
): void {
|
||||
if ($file['type'] === 'tgz') {
|
||||
try {
|
||||
$phar = new PharData($file['path']);
|
||||
@@ -170,7 +352,7 @@ class AccessLogParser
|
||||
continue;
|
||||
}
|
||||
foreach (explode("\n", $content) as $line) {
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages);
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
@@ -183,7 +365,7 @@ class AccessLogParser
|
||||
while (!gzeof($h)) {
|
||||
$line = gzgets($h, 8192);
|
||||
if ($line !== false) {
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages);
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||
}
|
||||
}
|
||||
gzclose($h);
|
||||
@@ -193,7 +375,7 @@ class AccessLogParser
|
||||
return;
|
||||
}
|
||||
while (($line = fgets($h)) !== false) {
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages);
|
||||
$this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages, $ipPaths, $ipPathTs, $ipAllPaths, $ipAllDays, $ipAgents, $allUas);
|
||||
}
|
||||
fclose($h);
|
||||
}
|
||||
|
||||
+1
-1
@@ -95,7 +95,7 @@ class BookManager
|
||||
$this->bookPath($slug),
|
||||
json_encode($book, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n"
|
||||
);
|
||||
$this->git?->commit("book: " . ($book['title'] ?? $slug));
|
||||
$this->git?->commit('book: ' . ($book['title'] ?? $slug));
|
||||
}
|
||||
|
||||
public function delete(string $slug): void
|
||||
|
||||
+3
-1
@@ -4,7 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
class DataGit
|
||||
{
|
||||
public function __construct(private string $dataDir) {}
|
||||
public function __construct(private string $dataDir)
|
||||
{
|
||||
}
|
||||
|
||||
public function commit(string $message): void
|
||||
{
|
||||
|
||||
@@ -61,7 +61,9 @@ PROMPT;
|
||||
$raw = $this->provider === 'claude_code'
|
||||
? $this->queryClaudeCode(self::SYSTEM_ANALYZE, $userMsg)
|
||||
: $this->queryAnthropicRaw(self::SYSTEM_ANALYZE, $userMsg, 4096);
|
||||
if (!$raw['ok']) return $raw;
|
||||
if (!$raw['ok']) {
|
||||
return $raw;
|
||||
}
|
||||
return $this->parseAnalyzeResponse($raw['text'] ?? '');
|
||||
}
|
||||
|
||||
@@ -129,7 +131,9 @@ PROMPT;
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($err !== '') return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
|
||||
if ($err !== '') {
|
||||
return ['ok' => false, 'error' => 'Erreur réseau : ' . $err];
|
||||
}
|
||||
|
||||
$data = json_decode((string) $resp, true);
|
||||
if ($http !== 200) {
|
||||
|
||||
@@ -96,14 +96,18 @@ function asGroups(): array
|
||||
function aiProvider(): string
|
||||
{
|
||||
$v = siteSettings()['ai_provider'] ?? '';
|
||||
if ($v !== '') return $v;
|
||||
if ($v !== '') {
|
||||
return $v;
|
||||
}
|
||||
return $_ENV['AI_PROVIDER'] ?? getenv('AI_PROVIDER') ?: 'anthropic';
|
||||
}
|
||||
|
||||
function aiModel(): string
|
||||
{
|
||||
$v = siteSettings()['ai_model'] ?? '';
|
||||
if ($v !== '') return $v;
|
||||
if ($v !== '') {
|
||||
return $v;
|
||||
}
|
||||
return $_ENV['AI_MODEL'] ?? getenv('AI_MODEL') ?: 'claude-haiku-4-5-20251001';
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ class TrendingParser
|
||||
public function __construct(
|
||||
private string $logDir,
|
||||
private string $pattern,
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les $limit chemins les plus consultés depuis $cutoff,
|
||||
|
||||
+6
-2
@@ -66,8 +66,12 @@ function lineDiff(string $old, string $new): array
|
||||
|
||||
if ($n * $m > 2_000_000) {
|
||||
$diff = [['!', "Diff trop grand ({$n}×{$m} lignes) — affichage simplifié."]];
|
||||
foreach ($a as $line) { $diff[] = ['-', $line]; }
|
||||
foreach ($b as $line) { $diff[] = ['+', $line]; }
|
||||
foreach ($a as $line) {
|
||||
$diff[] = ['-', $line];
|
||||
}
|
||||
foreach ($b as $line) {
|
||||
$diff[] = ['+', $line];
|
||||
}
|
||||
return $diff;
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -230,7 +230,9 @@ function adminStatusBadge(array $a, int $now): string
|
||||
return '/admin/articles?' . http_build_query($p);
|
||||
};
|
||||
$_sortIcon = function (string $col) use ($_sortBy, $_sortDir): string {
|
||||
if ($_sortBy !== $col) { return '<span class="text-muted ms-1" style="font-size:.75em">↕</span>'; }
|
||||
if ($_sortBy !== $col) {
|
||||
return '<span class="text-muted ms-1" style="font-size:.75em">↕</span>';
|
||||
}
|
||||
return '<span class="ms-1" style="font-size:.75em">' . ($_sortDir === 'asc' ? '↑' : '↓') . '</span>';
|
||||
};
|
||||
?>
|
||||
@@ -389,7 +391,7 @@ function adminStatusBadge(array $a, int $now): string
|
||||
'sort' => $_sortBy,
|
||||
'dir' => $_sortDir,
|
||||
], fn ($v) => $v !== ''));
|
||||
foreach ($adminData['articles'] as $_fa):
|
||||
foreach ($adminData['articles'] as $_fa):
|
||||
?>
|
||||
<form id="toggle-featured-<?= htmlspecialchars($_fa['uuid']) ?>" method="post" action="/?action=admin_toggle_featured" hidden>
|
||||
<input type="hidden" name="uuid" value="<?= htmlspecialchars($_fa['uuid']) ?>">
|
||||
@@ -1495,11 +1497,11 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
||||
|
||||
<?php
|
||||
$_aiNotice = $adminData['ai_notice'] ?? '';
|
||||
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
|
||||
$_aiModel = $adminData['ai_model'] ?? '';
|
||||
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
|
||||
$_cliOk = $adminData['claude_cli_found'] ?? false;
|
||||
?>
|
||||
$_aiProvider = $adminData['ai_provider'] ?? 'anthropic';
|
||||
$_aiModel = $adminData['ai_model'] ?? '';
|
||||
$_anthropicOk = $adminData['anthropic_key_set'] ?? false;
|
||||
$_cliOk = $adminData['claude_cli_found'] ?? false;
|
||||
?>
|
||||
|
||||
<?php if ($_aiNotice === 'saved'): ?>
|
||||
<div class="alert alert-success py-2 small">Configuration IA enregistrée.</div>
|
||||
@@ -1619,6 +1621,6 @@ sudo -u www-data HOME=/var/lib/claude-www /usr/local/bin/claude --print "Répond
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$content = ob_get_clean();
|
||||
$title = 'Administration — ' . siteTitle();
|
||||
include __DIR__ . '/layout.php';
|
||||
|
||||
+46
-107
@@ -4,10 +4,12 @@ $_statsError = ($_GET['error'] ?? '') === 'write';
|
||||
$_readable = $adminData['stats_readable'] ?? false;
|
||||
$_books = $adminData['stats_books'] ?? [];
|
||||
$_asList = $adminData['stats_as'] ?? [];
|
||||
$_asGroups = $adminData['stats_as_groups'] ?? [];
|
||||
$_groups = $adminData['as_groups'] ?? [];
|
||||
$_pagesByDay = $adminData['stats_pages_by_day'] ?? [];
|
||||
$_activeGroup = trim($_GET['group'] ?? '');
|
||||
$_ipData = $adminData['stats_ip_data'] ?? [];
|
||||
$_botPatterns = $adminData['bot_patterns'] ?? [];
|
||||
$_allUas = $adminData['stats_all_uas'] ?? [];
|
||||
$_uniqueVisitors = $adminData['stats_unique_visitors'] ?? [7 => 0, 14 => 0, 30 => 0];
|
||||
$_excludedAs = $adminData['excluded_as'] ?? [];
|
||||
?>
|
||||
|
||||
<?php if ($_statsSaved): ?>
|
||||
@@ -23,9 +25,20 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
||||
</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">30 derniers jours · tous les chemins · flux RSS XML</p>
|
||||
|
||||
<script>var FOLIO_PAGES_BY_DAY = <?= json_encode($_pagesByDay, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;</script>
|
||||
<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_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) ?>;
|
||||
var FOLIO_UNIQUE_VISITORS = <?= json_encode($_uniqueVisitors, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
var FOLIO_EXCLUDED_AS = <?= json_encode($_excludedAs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ?>;
|
||||
</script>
|
||||
|
||||
<div id="stats-summary-container"></div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
|
||||
@@ -36,10 +49,17 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
||||
<p class="text-muted p-3 mb-0">Chargement…</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-trend-container"></div>
|
||||
<div class="card-footer bg-transparent border-top px-3 pt-3 pb-2" id="stats-multiline-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-transparent py-2 small fw-semibold">Visiteurs par pays</div>
|
||||
<div class="card-body p-3" id="stats-country-container">
|
||||
<p class="text-muted mb-0">Chargement…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- Livres -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
@@ -84,116 +104,35 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
|
||||
<!-- Répartition par réseau -->
|
||||
<!-- Agents détectés -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-transparent py-2 small fw-semibold d-flex align-items-center gap-3 flex-wrap">
|
||||
<span>Répartition par réseau</span>
|
||||
<?php if (!empty($_groups)): ?>
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
<a href="/admin/stats" class="badge <?= $_activeGroup === '' ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">Tous</a>
|
||||
<?php foreach ($_groups as $g): ?>
|
||||
<a href="/admin/stats?group=<?= rawurlencode($g['label']) ?>"
|
||||
class="badge <?= $_activeGroup === $g['label'] ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">
|
||||
<?= htmlspecialchars($g['label']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<a href="/admin/stats?group=Autres"
|
||||
class="badge <?= $_activeGroup === 'Autres' ? 'bg-primary' : 'bg-secondary' ?> text-decoration-none">Autres</a>
|
||||
<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>
|
||||
<?php endif; ?>
|
||||
<div class="card-body p-0" id="stats-agents-container">
|
||||
<p class="text-muted p-3 mb-0">Chargement…</p>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<?php
|
||||
// Sélectionner les AS à afficher
|
||||
if ($_activeGroup !== '' && isset($_asGroups[$_activeGroup])) {
|
||||
$displayAs = $_asGroups[$_activeGroup];
|
||||
} else {
|
||||
$displayAs = $_asList;
|
||||
}
|
||||
?>
|
||||
<?php if (empty($displayAs)): ?>
|
||||
<p class="text-muted p-3 mb-0">
|
||||
<?= empty($_asList) ? 'Aucune IP résolue (LAN ou logs vides).' : 'Aucun AS dans ce groupe.' ?>
|
||||
</p>
|
||||
<?php else: ?>
|
||||
<?php $maxAS = max(array_column($displayAs, 'hits')) ?: 1; ?>
|
||||
<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:2rem">#</th>
|
||||
<th>Réseau</th>
|
||||
<th style="width:3rem">Pays</th>
|
||||
<th style="width:5rem" class="text-end pe-3">Visites</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($displayAs as $i => $as): ?>
|
||||
<tr>
|
||||
<td class="text-muted ps-3"><?= $i + 1 ?></td>
|
||||
<td>
|
||||
<span class="fw-medium"><?= htmlspecialchars($as['name'] ?: '?') ?></span>
|
||||
<?php if ($as['asn'] !== ''): ?>
|
||||
<span class="text-muted ms-1">AS<?= htmlspecialchars($as['asn']) ?></span>
|
||||
<?php endif; ?>
|
||||
<div class="progress mt-1" style="height:3px">
|
||||
<div class="progress-bar bg-info" style="width:<?= round($as['hits'] / $maxAS * 100) ?>%"></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>
|
||||
</td>
|
||||
<td class="text-muted"><?= htmlspecialchars($as['country']) ?></td>
|
||||
<td class="text-end fw-semibold pe-3"><?= number_format($as['hits'], 0, ',', '\u{202F}') ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php endif; // readable?>
|
||||
|
||||
<!-- Groupes de réseaux -->
|
||||
<div class="card mt-4" style="max-width:600px">
|
||||
<div class="card-header bg-transparent py-2 small fw-semibold">Groupes de réseaux</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Regroupez plusieurs réseaux sous un label. Chaque ligne est un motif cherché dans le nom du réseau (insensible à la casse).</p>
|
||||
<form method="post" action="/?action=admin_save_as_groups" id="as-groups-form">
|
||||
<div id="as-groups-list">
|
||||
<?php foreach ($_groups as $gi => $g): ?>
|
||||
<div class="as-group-row border rounded p-3 mb-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<input type="text" name="as_group_label[]" class="form-control form-control-sm"
|
||||
placeholder="Label (ex : Opérateurs FR)"
|
||||
value="<?= htmlspecialchars($g['label']) ?>" required>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm as-group-delete" title="Supprimer">✕</button>
|
||||
</div>
|
||||
<textarea name="as_group_patterns[]" class="form-control form-control-sm font-monospace"
|
||||
rows="3" placeholder="Un motif par ligne ex : Free SAS Orange SFR"><?= htmlspecialchars(implode("\n", $g['patterns'])) ?></textarea>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="button" id="as-group-add" class="btn btn-outline-secondary btn-sm">+ Ajouter un groupe</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template id="as-group-tpl">
|
||||
<div class="as-group-row border rounded p-3 mb-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<input type="text" name="as_group_label[]" class="form-control form-control-sm"
|
||||
placeholder="Label (ex : Moteurs de recherche)" required>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm as-group-delete" title="Supprimer">✕</button>
|
||||
</div>
|
||||
<textarea name="as_group_patterns[]" class="form-control form-control-sm font-monospace"
|
||||
rows="3" placeholder="Un motif par ligne ex : Googlebot Bingbot"></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script src="/assets/js/admin-stats.js" defer></script>
|
||||
|
||||
@@ -21,9 +21,9 @@ ob_start();
|
||||
$_cover = $_first['cover'] ?? '';
|
||||
$_cat = trim($_first['category'] ?? '');
|
||||
$_coverStyle = $_cover !== ''
|
||||
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . "&name=" . rawurlencode($_cover) . "')"
|
||||
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . '&name=' . rawurlencode($_cover) . "')"
|
||||
: 'background:' . coverGradient($_cat !== '' ? $_cat : $_first['uuid'], $allCats ?? []);
|
||||
?>
|
||||
?>
|
||||
<a href="/book/<?= rawurlencode($_book['slug']) ?>" class="book-home-card">
|
||||
<div class="book-home-card-cover" style="<?= $_coverStyle ?>"></div>
|
||||
<div class="book-home-card-body">
|
||||
@@ -42,7 +42,7 @@ ob_start();
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$content = ob_get_clean();
|
||||
$title = 'Livres — ' . siteTitle();
|
||||
$metaRobots = 'index, follow';
|
||||
$canonical = rtrim((string)($_ENV['APP_URL'] ?? getenv('APP_URL') ?: ''), '/') . '/books';
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
$_reactionDefs = [
|
||||
'useful' => ['👍', 'Utile'],
|
||||
'important' => ['🔥', 'Important'],
|
||||
'interesting' => ['🤔', 'À creuser'],
|
||||
];
|
||||
|
||||
$_csrfToken = bin2hex(random_bytes(16));
|
||||
|
||||
@@ -48,13 +48,14 @@
|
||||
<link href="/assets/css/bootstrap.min.css" rel="stylesheet">
|
||||
<?php
|
||||
$_pub = BASE_PATH . '/public/assets/';
|
||||
if (!function_exists('_av')) {
|
||||
function _av(string $base, string $rel): string {
|
||||
if (!function_exists('_av')) {
|
||||
function _av(string $base, string $rel): string
|
||||
{
|
||||
$f = $base . $rel;
|
||||
return '/assets/' . $rel . (is_file($f) ? '?v=' . substr(md5_file($f), 0, 8) : '');
|
||||
}
|
||||
}
|
||||
?>
|
||||
}
|
||||
?>
|
||||
<link rel="stylesheet" href="<?= _av($_pub, 'css/style.css') ?>">
|
||||
</head>
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
||||
$_cover = $_first['cover'] ?? '';
|
||||
$_cat = trim($_first['category'] ?? '');
|
||||
$_coverStyle = $_cover !== ''
|
||||
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . "&name=" . rawurlencode($_cover) . "')"
|
||||
? "background-image:url('/file?uuid=" . rawurlencode($_first['uuid']) . '&name=' . rawurlencode($_cover) . "')"
|
||||
: 'background:' . coverGradient($_cat !== '' ? $_cat : $_first['uuid'], $allCats ?? []);
|
||||
?>
|
||||
<a href="/book/<?= rawurlencode($_book['slug']) ?>" class="book-home-card">
|
||||
|
||||
+28
-8
@@ -178,17 +178,37 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
||||
<br><small class="opacity-75"><?= htmlspecialchars($modDate) ?></small>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php
|
||||
$_v30 = (int) ($articleVisitors[30] ?? $articleVisitors['30'] ?? 0);
|
||||
$_v14 = (int) ($articleVisitors[14] ?? $articleVisitors['14'] ?? 0);
|
||||
$_v7 = (int) ($articleVisitors[7] ?? $articleVisitors['7'] ?? 0);
|
||||
if ($_v30 > 0):
|
||||
?>
|
||||
<p class="article-hero-visitors" style="margin-top:.5rem;font-size:.82rem;color:#fff;display:inline-block;background:rgba(0,0,0,.45);backdrop-filter:blur(4px);padding:.15rem .55rem;border-radius:.35rem">
|
||||
<span title="Lecteurs uniques sur 7 jours">
|
||||
<strong><?= number_format($_v7, 0, ',', "\xE2\x80\xAF") ?></strong> <span style="opacity:.7;font-size:.75em">/ 7 j</span>
|
||||
</span>
|
||||
<span style="opacity:.45"> · </span>
|
||||
<span title="Lecteurs uniques sur 14 jours">
|
||||
<strong><?= number_format($_v14, 0, ',', "\xE2\x80\xAF") ?></strong> <span style="opacity:.7;font-size:.75em">/ 14 j</span>
|
||||
</span>
|
||||
<span style="opacity:.45"> · </span>
|
||||
<span title="Lecteurs uniques sur 30 jours">
|
||||
<strong><?= number_format($_v30, 0, ',', "\xE2\x80\xAF") ?></strong> <span style="opacity:.7;font-size:.75em">/ 30 j</span>
|
||||
</span>
|
||||
<span style="opacity:.65"> lecteurs</span>
|
||||
</p>
|
||||
<?php endif;
|
||||
unset($_v30, $_v14, $_v7); ?>
|
||||
</div>
|
||||
<div class="article-hero-right">
|
||||
<?php if ($hasSources): ?>
|
||||
<a href="/sources/<?= rawurlencode($article['uuid']) ?>" class="hero-btn">ℹ Sources</a>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$_heroReactionDefs = [
|
||||
$_heroReactionDefs = [
|
||||
'useful' => ['👍', 'Utile'],
|
||||
'important' => ['🔥', 'Important'],
|
||||
'interesting' => ['🤔', 'À creuser'],
|
||||
];
|
||||
];
|
||||
?>
|
||||
<div class="hero-reactions" id="reactions">
|
||||
<?php foreach ($_heroReactionDefs as $type => [$icon, $label]): ?>
|
||||
@@ -294,9 +314,9 @@ $hasSources = (!empty($externalLinks) || !empty($files))
|
||||
|
||||
<?php if ($article['published'] ?? false): ?>
|
||||
<?php
|
||||
$_shareUrl = rtrim(defined('APP_URL') ? APP_URL : '', '/') . '/post/' . rawurlencode($article['slug'] ?? '');
|
||||
$_shareTitle = $article['title'] ?? '';
|
||||
?>
|
||||
$_shareUrl = rtrim(defined('APP_URL') ? APP_URL : '', '/') . '/post/' . rawurlencode($article['slug'] ?? '');
|
||||
$_shareTitle = $article['title'] ?? '';
|
||||
?>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 my-3 py-2 border-top"
|
||||
id="share-bar"
|
||||
data-url="<?= htmlspecialchars($_shareUrl) ?>"
|
||||
@@ -442,7 +462,7 @@ $_shareTitle = $article['title'] ?? '';
|
||||
|
||||
<?php
|
||||
$_revisions = array_reverse($article['revisions'] ?? []);
|
||||
if (!empty($_revisions) && isLoggedIn()):
|
||||
if (!empty($_revisions) && isLoggedIn()):
|
||||
?>
|
||||
<h6 class="related-sidebar-title mt-3">Historique</h6>
|
||||
<ul class="toc-list small">
|
||||
|
||||
@@ -201,5 +201,7 @@ $_hasUuid = $_wizUuid !== '';
|
||||
<?php
|
||||
$content = ob_get_clean();
|
||||
$title = ($mode === 'create' ? 'Nouvel article' : 'Modifier') . ' — Étape 1/' . $totalSteps;
|
||||
if ($mode === 'edit') { $aiEditor = true; }
|
||||
if ($mode === 'edit') {
|
||||
$aiEditor = true;
|
||||
}
|
||||
include BASE_PATH . '/templates/layout.php';
|
||||
|
||||
Reference in New Issue
Block a user