From ed3f8062dab0673b3e693994510e0a973525d45a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Tue, 19 May 2026 18:20:19 +0200 Subject: [PATCH 1/3] feat : sparklines 14j stats + filtre IPs LAN (v1.6.27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Admin stats : sparklines SVG par page (120×28 px, courbe + dégradé), carte « Pages les plus visitées » en pleine largeur - AccessLogParser : données par jour (pages_by_day) sur 14 jours - AccessLogParser : IPs privées/LAN exclues de la répartition réseau - ArticleManager : suppression opérateur nullsafe superflu (PHPStan) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 10 ++++++ public/assets/js/admin-stats.js | 51 ++++++++++++++++++++++------- public/index.php | 57 ++++++++++++++++++++++----------- src/AccessLogParser.php | 53 +++++++++++++++++++++--------- src/ArticleManager.php | 14 ++++---- templates/admin_stats.php | 44 ++++++++++++------------- 6 files changed, 154 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04dc137..5c0edf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag --- +## [1.6.27] - 2026-05-19 + +### Ajouté +- Admin stats : sparklines SVG 14 jours par page dans « Pages les plus visitées » — courbe + dégradé, carte pleine largeur (#101) + +### Corrigé +- Admin stats : IPs privées/LAN exclues de la répartition par réseau (Uptime Kuma et hairpin NAT ne polluent plus les stats) (#102) + +--- + ## [1.6.26] - 2026-05-16 ### Ajouté diff --git a/public/assets/js/admin-stats.js b/public/assets/js/admin-stats.js index 588c8e8..82421af 100644 --- a/public/assets/js/admin-stats.js +++ b/public/assets/js/admin-stats.js @@ -17,16 +17,39 @@ }); }()); -// ── Pages les plus visitées (RSS XML) ──────────────────────────────────────── +// ── Pages les plus visitées (RSS XML + sparklines) ─────────────────────────── (function () { - var container = document.getElementById('stats-pages-container'); - var badge = document.getElementById('stats-pages-count'); + 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 : {}; if (!container) { return; } function esc(s) { return String(s).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; + var n = data.length; + var pts = data.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(' '); + // 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 '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + fetch('/trending?period=14d') .then(function (r) { return r.ok ? r.text() : Promise.reject(); }) .then(function (xml) { @@ -43,19 +66,23 @@ var vis = m ? parseInt(m[1], 10) : 0; var title = raw.replace(/\s*\(\d+\s+visiteurs?\)$/, ''); var slug = decodeURIComponent(link.replace(/.*\/post\//, '')); - return { title: title, link: link, slug: slug, vis: vis }; + var pm = link.match(/\/post\/[^?#]*/); + var daily = pm ? (pagesByDay[pm[0]] || null) : null; + return { title: title, link: link, slug: slug, vis: vis, daily: daily }; }); var maxV = Math.max.apply(null, rows.map(function (r) { return r.vis; })) || 1; - var html = '
'; + var html = '
'; rows.forEach(function (row, i) { - var pct = Math.round(row.vis / maxV * 100); - var vis = row.vis.toLocaleString('fr-FR'); + var vis = row.vis.toLocaleString('fr-FR'); + var spk = row.daily ? sparkline(row.daily) : ''; html += '' - + '' - + '' - + '' + + '' + + '' + + '' + + '' + ''; }); html += '
' + (i + 1) + '' - + esc(row.title || row.slug) + '' - + '
' + vis + ' vis.' + (i + 1) + '' + + esc(row.title || row.slug) + '' + spk + '' + + vis + ' vis.
'; diff --git a/public/index.php b/public/index.php index 1895432..1ebcdd3 100644 --- a/public/index.php +++ b/public/index.php @@ -73,7 +73,9 @@ function buildAutoSeoDesc(string $content, string $title = ''): string { require_once BASE_PATH . '/src/Parsedown.php'; $_pd = new Parsedown(); - $_plain = trim((string)preg_replace('/\s+/', ' ', + $_plain = trim((string)preg_replace( + '/\s+/', + ' ', html_entity_decode(strip_tags($_pd->text($content)), ENT_QUOTES | ENT_HTML5, 'UTF-8') )); if ($title !== '' && stripos($_plain, $title) === 0) { @@ -85,7 +87,9 @@ function buildAutoSeoDesc(string $content, string $title = ''): string function slugToSearchQuery(string $rawPath): string { return trim((string)preg_replace('/\s{2,}/', ' ', (string)preg_replace( - '/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath) + '/[^a-zA-ZÀ-ÿ0-9\s]/u', + ' ', + str_replace(['-', '_', '/'], ' ', $rawPath) ))); } @@ -2512,7 +2516,7 @@ switch ($action) { $allArticles = array_values(array_filter($allArticles, fn ($a) => !empty($a['featured']))); } - $sortBy = in_array($_GET['sort'] ?? '', ['title', 'published', 'updated']) ? $_GET['sort'] : 'updated'; + $sortBy = in_array($_GET['sort'] ?? '', ['title', 'published', 'updated'], true) ? $_GET['sort'] : 'updated'; $sortDir = ($_GET['dir'] ?? '') === 'asc' ? 'asc' : 'desc'; usort($allArticles, function ($a, $b) use ($sortBy, $sortDir) { $cmp = match ($sortBy) { @@ -2730,21 +2734,24 @@ switch ($action) { $cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400); $tParser = new TrendingParser('/var/log/apache2', apacheAccessLog()); $accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog()); - $topIps = array_slice($accessParser->stats()['ips'], 0, 200, true); + $accessStats = $accessParser->stats(); + $topIps = array_slice($accessStats['ips'], 0, 200, true); $asnMap = (new AsnLookup())->batchLookup(array_keys($topIps)); $statsRaw = [ - 'readable' => $accessParser->isReadable(), - 'books' => $tParser->top($cutoff14, 20, ['/book/']), - 'as' => AsnLookup::aggregateByAs($topIps, $asnMap), + 'readable' => $accessParser->isReadable(), + 'books' => $tParser->top($cutoff14, 20, ['/book/']), + 'as' => AsnLookup::aggregateByAs($topIps, $asnMap), + 'pages_by_day' => $accessStats['pages_by_day'] ?? [], ]; @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_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'] ?? []; } if ($tab === 'categories') { @@ -2764,7 +2771,8 @@ switch ($action) { try { $st = $pdo->query('SELECT id, user_email, feed_url, label, created_at FROM rss_feeds ORDER BY created_at DESC'); $adminData['flux_feeds'] = $st->fetchAll(PDO::FETCH_ASSOC); - } catch (\Throwable) {} + } catch (\Throwable) { + } } } @@ -2784,7 +2792,10 @@ switch ($action) { } if ($tab === 'ia') { - if (!isAdmin()) { http_response_code(403); exit; } + if (!isAdmin()) { + http_response_code(403); + exit; + } require_once BASE_PATH . '/src/SiteSettings.php'; require_once BASE_PATH . '/src/Service/AiService.php'; $adminData['ai_provider'] = aiProvider(); @@ -2800,7 +2811,8 @@ switch ($action) { case 'admin_save_ai_config': requireAuth(); if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { - http_response_code(403); exit; + http_response_code(403); + exit; } require_once BASE_PATH . '/src/SiteSettings.php'; $allowedProviders = ['anthropic', 'claude_code']; @@ -3439,7 +3451,8 @@ switch ($action) { if ($pdo) { try { $pdo->prepare('DELETE FROM rss_feeds WHERE id = :id')->execute([':id' => $feedId]); - } catch (\Throwable) {} + } catch (\Throwable) { + } } } header('Location: /admin/flux?deleted=1'); @@ -3541,7 +3554,9 @@ switch ($action) { $_published[] = $_a; } } - if (empty($_published)) continue; + if (empty($_published)) { + continue; + } $booksData[] = ['book' => $_bk, 'count' => count($_published), 'first' => $_published[0]]; } unset($_bk, $_published, $_aSlug, $_a); @@ -3864,9 +3879,13 @@ switch ($action) { $_published[] = $_a; } } - if (empty($_published)) continue; + if (empty($_published)) { + continue; + } $homeBooks[] = ['book' => $_bk, 'count' => count($_published), 'first' => $_published[0]]; - if (count($homeBooks) >= 6) break; + if (count($homeBooks) >= 6) { + break; + } } unset($_bk, $_published, $_aSlug, $_a); } diff --git a/src/AccessLogParser.php b/src/AccessLogParser.php index 8ff514e..5fd84c3 100644 --- a/src/AccessLogParser.php +++ b/src/AccessLogParser.php @@ -30,7 +30,7 @@ class AccessLogParser } /** - * @return array{pages:array,books:array,ips:array} + * @return array{pages:array,books:array,ips:array,pages_by_day:array>} */ public function stats(): array { @@ -44,20 +44,34 @@ class AccessLogParser } } - $cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400); - $pages = []; - $books = []; - $ips = []; + $cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400); + $pages = []; + $books = []; + $ips = []; + $dayPages = []; // [path => [dayOffset => count]], dayOffset 0=oldest foreach ($this->logFiles() as $file) { - $this->parseFile($file, $cutoff, $pages, $books, $ips); + $this->parseFile($file, $cutoff, $pages, $books, $ips, $dayPages); } arsort($pages); arsort($books); arsort($ips); - $result = compact('pages', 'books', 'ips'); + // 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) { + if ($offset >= 0 && $offset < $this->days) { + $arr[$offset] = $count; + } + } + $pagesByDay[$path] = $arr; + } + + $result = ['pages' => $pages, 'books' => $books, 'ips' => $ips, 'pages_by_day' => $pagesByDay]; @mkdir(dirname($this->cacheFile), 0755, true); @file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); return self::$memo = $result; @@ -113,7 +127,7 @@ class AccessLogParser return (int) strtotime("{$m[1]} {$m[2]} {$m[3]} {$m[4]} {$m[5]}"); } - private function parseLine(string $line, int $cutoff, array &$pages, array &$books, array &$ips): void + private function parseLine(string $line, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages): void { if (!preg_match(self::RE, $line, $m)) { return; @@ -123,20 +137,29 @@ class AccessLogParser if ($status !== '200') { return; } - if (self::parseTimestamp($ts) < $cutoff) { + $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; + if (str_starts_with($path, '/post/') && strlen($path) > 6) { $pages[$path] = ($pages[$path] ?? 0) + 1; - $ips[$ip] = ($ips[$ip] ?? 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) { $books[$path] = ($books[$path] ?? 0) + 1; - $ips[$ip] = ($ips[$ip] ?? 0) + 1; + if ($publicIp) { + $ips[$ip] = ($ips[$ip] ?? 0) + 1; + } } } - private function parseFile(array $file, int $cutoff, array &$pages, array &$books, array &$ips): void + private function parseFile(array $file, int $cutoff, array &$pages, array &$books, array &$ips, array &$dayPages): void { if ($file['type'] === 'tgz') { try { @@ -147,7 +170,7 @@ class AccessLogParser continue; } foreach (explode("\n", $content) as $line) { - $this->parseLine($line, $cutoff, $pages, $books, $ips); + $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages); } } } catch (\Exception $e) { @@ -160,7 +183,7 @@ class AccessLogParser while (!gzeof($h)) { $line = gzgets($h, 8192); if ($line !== false) { - $this->parseLine($line, $cutoff, $pages, $books, $ips); + $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages); } } gzclose($h); @@ -170,7 +193,7 @@ class AccessLogParser return; } while (($line = fgets($h)) !== false) { - $this->parseLine($line, $cutoff, $pages, $books, $ips); + $this->parseLine($line, $cutoff, $pages, $books, $ips, $dayPages); } fclose($h); } diff --git a/src/ArticleManager.php b/src/ArticleManager.php index 405fd11..7823cb7 100644 --- a/src/ArticleManager.php +++ b/src/ArticleManager.php @@ -293,7 +293,7 @@ class ArticleManager } $meta['updated_at'] = date('Y-m-d H:i:s'); $this->writeMeta($dir, $meta); - $this->git?->commit("meta: " . ($meta['title'] ?? $uuid)); + $this->git?->commit('meta: ' . ($meta['title'] ?? $uuid)); } public function saveDraftOverlay(string $uuid, array $metaFields, ?string $content = null): void @@ -379,7 +379,7 @@ class ArticleManager @unlink($dir . '/draft_overlay.json'); @unlink($dir . '/draft_overlay.md'); if ($title !== null) { - $this->git?->commit("discard-draft: $title"); + $this->git->commit("discard-draft: $title"); } } @@ -488,7 +488,7 @@ class ArticleManager } $meta['cover'] = $coverName; $this->writeMeta($this->dataDir . '/' . $uuid, $meta); - $this->git?->commit("cover: " . ($article['title'] ?? $uuid)); + $this->git?->commit('cover: ' . ($article['title'] ?? $uuid)); } public function addFileFromUrl(string $uuid, string $url, bool $isCover = false, string $author = '', string $sourceUrl = '', string $title = '', array $extraMeta = []): ?string @@ -775,7 +775,7 @@ class ArticleManager $this->tagTypesPath(), json_encode($types, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . "\n" ); - $this->git?->commit("tag-types"); + $this->git?->commit('tag-types'); } /** Enregistre les tags d'un article directement (utile pour les scripts de migration). */ @@ -795,7 +795,7 @@ class ArticleManager $meta['tags'] = $this->normalizeTags($tags); $this->writeMeta($dir, $meta); $this->rebuildSearchIndex(); - $this->git?->commit("tags: " . ($meta['title'] ?? $uuid)); + $this->git?->commit('tags: ' . ($meta['title'] ?? $uuid)); } /** @return list Toutes les valeurs distinctes d'un type de tag, triées. */ @@ -845,7 +845,7 @@ class ArticleManager $this->writeMeta($dir, $meta); $this->allCache = null; @unlink($this->articleCachePath($uuid)); - $this->git?->commit("featured: " . ($meta['title'] ?? $uuid) . " (" . ($featured ? 'on' : 'off') . ")"); + $this->git?->commit('featured: ' . ($meta['title'] ?? $uuid) . ' (' . ($featured ? 'on' : 'off') . ')'); } public function delete(string $uuid): bool @@ -871,7 +871,7 @@ class ArticleManager } $this->rebuildSearchIndex(); $this->rebuildBacklinksCache(); - $this->git?->commit("delete: " . ($title ?? $uuid)); + $this->git?->commit('delete: ' . ($title ?? $uuid)); return true; } diff --git a/templates/admin_stats.php b/templates/admin_stats.php index 60174bf..f63effd 100644 --- a/templates/admin_stats.php +++ b/templates/admin_stats.php @@ -1,11 +1,12 @@ @@ -24,20 +25,19 @@ $_activeGroup = trim($_GET['group'] ?? '');

14 derniers jours · visiteurs uniques · flux RSS XML

-
+ - -
-
-
- Pages les plus visitées - -
-
-

Chargement…

-
-
+
+
+ Pages les plus visitées +
+
+

Chargement…

+
+
+ +
@@ -60,7 +60,7 @@ $_activeGroup = trim($_GET['group'] ?? ''); $rankB++; $slug = rawurldecode(substr($url, 6)); $pct = round($hits / $maxB * 100); - ?> + ?> @@ -112,7 +112,7 @@ $_activeGroup = trim($_GET['group'] ?? ''); } else { $displayAs = $_asList; } - ?> +?>

@@ -153,7 +153,7 @@ $_activeGroup = trim($_GET['group'] ?? '');

- +
From 868e68fa85a65f86f71f39427830c09d6f8a8dde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Tue, 19 May 2026 18:47:21 +0200 Subject: [PATCH 2/3] chore : version 1.6.27 --- public/version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/version.txt b/public/version.txt index 757e60b..8bfaa5b 100644 --- a/public/version.txt +++ b/public/version.txt @@ -1 +1 @@ -1.6.26 +1.6.27 From d729e943a38b5160b77e6d08e6ae1efd4f4d2344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9drix?= Date: Tue, 19 May 2026 19:33:46 +0200 Subject: [PATCH 3/3] feat : graphique trafic global 14j (v1.6.27) --- public/assets/js/admin-stats.js | 51 ++++++++++++++++++++++++++++++++- templates/admin_stats.php | 1 + 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/public/assets/js/admin-stats.js b/public/assets/js/admin-stats.js index 82421af..91d383e 100644 --- a/public/assets/js/admin-stats.js +++ b/public/assets/js/admin-stats.js @@ -50,6 +50,47 @@ + ''; } + function trendChart(totals) { + 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; + + // 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)); + 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 '' + + '' + label + ' : ' + v + ' vis.' + + (showLabel + ? '' + label + '' + : ''); + }).join(''); + + trendEl.innerHTML = '

Trafic total — 14 derniers jours

' + + '' + bars + ''; + } + fetch('/trending?period=14d') .then(function (r) { return r.ok ? r.text() : Promise.reject(); }) .then(function (xml) { @@ -70,7 +111,15 @@ var daily = pm ? (pagesByDay[pm[0]] || null) : null; return { title: title, link: link, slug: slug, vis: vis, daily: daily }; }); - var maxV = Math.max.apply(null, rows.map(function (r) { return r.vis; })) || 1; + + // 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; } }); + }); + trendChart(totals); + var html = '
'; rows.forEach(function (row, i) { var vis = row.vis.toLocaleString('fr-FR'); diff --git a/templates/admin_stats.php b/templates/admin_stats.php index f63effd..9945d50 100644 --- a/templates/admin_stats.php +++ b/templates/admin_stats.php @@ -35,6 +35,7 @@ $_activeGroup = trim($_GET['group'] ?? '');

Chargement…

+