feat : "Meilleures audiences" + admin/stats pages via flux RSS XML (v1.6.6)

- post_list.php : section AJAX qui lit /trending?period=1h en XML (DOMParser) — plus de rendu PHP
- admin_stats.php : colonne "Pages les plus visitées" chargée en AJAX depuis /trending?period=14d XML
- index.php/stats : suppression de topGrouped pour /post/ ; seuls /book/ et ASN restent côté serveur

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 20:08:24 +02:00
parent 1d05138329
commit 5cea473d17
5 changed files with 112 additions and 57 deletions
+9
View File
@@ -9,6 +9,15 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
--- ---
## [1.6.6] - 2026-05-15
### Modifié
- Page d'accueil "Meilleures audiences" : chargement AJAX depuis le flux RSS XML `/trending?period=1h` (DOMParser côté client, plus de rendu PHP)
- `/admin/stats` section "Pages les plus visitées" : chargement AJAX depuis le flux RSS XML `/trending?period=14d` — plus de parsing de logs direct pour cette colonne
- `/admin/stats` : suppression de `topGrouped` pour les pages ; seuls les livres (`/book/`) et l'ASN conservent le parsing log côté serveur
---
## [1.6.5] - 2026-05-15 ## [1.6.5] - 2026-05-15
### Modifié ### Modifié
+4 -9
View File
@@ -2559,25 +2559,20 @@ switch ($action) {
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null; $statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: null;
} }
if ($statsRaw === null) { if ($statsRaw === null) {
$cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400); $cutoff14 = strtotime('-14 days midnight') ?: (time() - 14 * 86400);
$tParser = new TrendingParser('/var/log/apache2', apacheAccessLog()); $tParser = new TrendingParser('/var/log/apache2', apacheAccessLog());
$grouped = $tParser->topGrouped($cutoff14, ['/post/' => 30, '/book/' => 20]);
// IPs pour le lookup ASN (AccessLogParser conserve le comptage brut par IP)
$accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog()); $accessParser = new AccessLogParser('/var/log/apache2', apacheAccessLog());
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true); $topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps)); $asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
$statsRaw = [ $statsRaw = [
'readable' => $tParser->isReadable(), 'readable' => $accessParser->isReadable(),
'pages' => $grouped['/post/'], 'books' => $tParser->top($cutoff14, 20, ['/book/']),
'books' => $grouped['/book/'],
'as' => AsnLookup::aggregateByAs($topIps, $asnMap), 'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
]; ];
@file_put_contents($statsCacheFile, json_encode($statsRaw)); @file_put_contents($statsCacheFile, json_encode($statsRaw));
} }
$adminData['stats_readable'] = $statsRaw['readable']; $adminData['stats_readable'] = $statsRaw['readable'];
$adminData['stats_pages'] = $statsRaw['pages'];
$adminData['stats_books'] = $statsRaw['books']; $adminData['stats_books'] = $statsRaw['books'];
$adminData['stats_as'] = $statsRaw['as']; $adminData['stats_as'] = $statsRaw['as'];
$adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups()); $adminData['stats_as_groups'] = AsnLookup::applyGroups($statsRaw['as'], asGroups());
+1 -1
View File
@@ -1 +1 @@
1.6.5 1.6.6
+51 -38
View File
@@ -2,7 +2,6 @@
$_statsSaved = isset($_GET['saved']); $_statsSaved = isset($_GET['saved']);
$_statsError = ($_GET['error'] ?? '') === 'write'; $_statsError = ($_GET['error'] ?? '') === 'write';
$_readable = $adminData['stats_readable'] ?? false; $_readable = $adminData['stats_readable'] ?? false;
$_pages = $adminData['stats_pages'] ?? [];
$_books = $adminData['stats_books'] ?? []; $_books = $adminData['stats_books'] ?? [];
$_asList = $adminData['stats_as'] ?? []; $_asList = $adminData['stats_as'] ?? [];
$_asGroups = $adminData['stats_as_groups'] ?? []; $_asGroups = $adminData['stats_as_groups'] ?? [];
@@ -23,51 +22,19 @@ $_activeGroup = trim($_GET['group'] ?? '');
</div> </div>
<?php else: ?> <?php else: ?>
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · cache 60 s</p> <p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · flux RSS XML</p>
<div class="row g-4"> <div class="row g-4">
<!-- Pages --> <!-- Pages (chargées via le flux RSS XML /trending?period=14d) -->
<div class="col-lg-6"> <div class="col-lg-6">
<div class="card h-100"> <div class="card h-100">
<div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between"> <div class="card-header bg-transparent py-2 small fw-semibold d-flex justify-content-between">
<span>Pages les plus visitées</span> <span>Pages les plus visitées</span>
<span class="text-muted"><?= count($_pages) ?> URLs</span> <span class="text-muted" id="stats-pages-count"></span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0" id="stats-pages-container">
<?php if (empty($_pages)): ?> <p class="text-muted p-3 mb-0">Chargement…</p>
<p class="text-muted p-3 mb-0">Aucune donnée.</p>
<?php else: ?>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 small">
<tbody>
<?php
$maxP = max($_pages) ?: 1;
$rankP = 0;
foreach ($_pages as $url => $hits):
$rankP++;
$slug = rawurldecode(substr($url, 6));
$pct = round($hits / $maxP * 100);
?>
<tr>
<td class="text-muted ps-3" style="width:2rem"><?= $rankP ?></td>
<td>
<a href="<?= htmlspecialchars($url) ?>" target="_blank"
class="text-decoration-none text-truncate d-block" style="max-width:260px"
title="<?= htmlspecialchars($slug) ?>">
<?= htmlspecialchars($slug) ?>
</a>
<div class="progress mt-1" style="height:3px">
<div class="progress-bar" style="width:<?= $pct ?>%"></div>
</div>
</td>
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?> <span class="text-muted fw-normal">vis.</span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div> </div>
</div> </div>
</div> </div>
@@ -238,4 +205,50 @@ document.getElementById('as-groups-list').addEventListener('click', e => {
e.target.closest('.as-group-row').remove(); e.target.closest('.as-group-row').remove();
} }
}); });
// ── Chargement des pages via le flux RSS XML ──────────────────────────────────
(function(){
var container = document.getElementById('stats-pages-container');
var badge = document.getElementById('stats-pages-count');
if (!container) return;
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
fetch('/trending?period=14d')
.then(function(r){ return r.ok ? r.text() : Promise.reject(); })
.then(function(xml){
var doc = new DOMParser().parseFromString(xml, 'application/xml');
var items = Array.from(doc.querySelectorAll('item'));
if (!items.length) {
container.innerHTML = '<p class="text-muted p-3 mb-0">Aucune donnée.</p>';
return;
}
var rows = items.map(function(item){
var raw = (item.querySelector('title') || {textContent:''}).textContent;
var link = ((item.querySelector('link') || {}).textContent || '').trim();
var m = raw.match(/\((\d+)\s+visiteurs?\)$/);
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 maxV = Math.max.apply(null, rows.map(function(r){ return r.vis; })) || 1;
var html = '<div class="table-responsive"><table class="table table-sm table-hover mb-0 small"><tbody>';
rows.forEach(function(row, i){
var pct = Math.round(row.vis / maxV * 100);
var vis = row.vis.toLocaleString('fr-FR');
html += '<tr>'
+ '<td class="text-muted ps-3" style="width:2rem">' + (i+1) + '</td>'
+ '<td><a href="' + esc(row.link) + '" target="_blank" class="text-decoration-none text-truncate d-block" style="max-width:260px" title="' + esc(row.slug) + '">'
+ esc(row.title || row.slug) + '</a>'
+ '<div class="progress mt-1" style="height:3px"><div class="progress-bar" style="width:' + pct + '%"></div></div></td>'
+ '<td class="text-end fw-semibold pe-3">' + vis + ' <span class="text-muted fw-normal">vis.</span></td>'
+ '</tr>';
});
html += '</tbody></table></div>';
if (badge) badge.textContent = rows.length + ' URLs';
container.innerHTML = html;
})
.catch(function(){
container.innerHTML = '<p class="text-muted p-3 mb-0">Impossible de charger le flux.</p>';
});
})();
</script> </script>
+47 -9
View File
@@ -155,19 +155,57 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php /* ─── Tendances ───────────────────────────────────────────────────── */ ?> <?php /* ─── Meilleures audiences (AJAX — flux RSS XML /trending?period=1h) ── */ ?>
<?php if (!empty($popularPosts)): ?> <section class="home-section" id="home-audiences-section" hidden>
<section class="home-section">
<h2 class="home-section-title"> <h2 class="home-section-title">
Meilleures audiences <span class="home-section-title-sub">· 1 heure</span> Meilleures audiences <span class="home-section-title-sub">· 1 heure</span>
</h2> </h2>
<div class="post-grid"> <div class="post-grid" id="home-audiences-grid"></div>
<?php foreach ($popularPosts as $_pp): ?>
<?php _renderCard($_pp, $privateCats ?? [], $allCats ?? [], $Parsedown); ?>
<?php endforeach; ?>
</div>
</section> </section>
<?php endif; ?> <script>
(function(){
var _g=[
'linear-gradient(135deg,#667eea 0%,#764ba2 100%)',
'linear-gradient(135deg,#f093fb 0%,#f5576c 100%)',
'linear-gradient(135deg,#4facfe 0%,#00f2fe 100%)',
'linear-gradient(135deg,#43e97b 0%,#38f9d7 100%)',
'linear-gradient(135deg,#fa709a 0%,#fee140 100%)',
'linear-gradient(135deg,#a18cd1 0%,#fbc2eb 100%)'
];
function _e(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
fetch('/trending?period=1h')
.then(function(r){return r.ok?r.text():Promise.reject();})
.then(function(xml){
var doc=new DOMParser().parseFromString(xml,'application/xml');
var items=Array.from(doc.querySelectorAll('item')).slice(0,6);
if(!items.length)return;
var grid=document.getElementById('home-audiences-grid');
if(!grid)return;
grid.innerHTML=items.map(function(item,i){
var raw=(item.querySelector('title')||{textContent:''}).textContent;
var title=raw.replace(/\s*\(\d+\s+visiteurs?\)$/,'');
var link=((item.querySelector('link')||{}).textContent||'#').trim();
var pd=(item.querySelector('pubDate')||{textContent:''}).textContent;
var date='';
try{if(pd)date=new Date(pd).toLocaleDateString('fr-FR');}catch(e){}
var grad=_g[i%_g.length];
return '<article class="card">'
+'<div class="card-cover" style="background:'+grad+'"></div>'
+'<div class="card-body d-flex flex-column">'
+'<h2 class="card-title"><a href="'+_e(link)+'">'+_e(title)+'</a></h2>'
+'<div class="post-entry-meta mt-auto">'
+(date?'<span>'+_e(date)+'</span>':'')
+'<a href="'+_e(link)+'" class="post-entry-read">→ lire</a>'
+'</div></div>'
+'<a href="'+_e(link)+'" class="stretched-link"></a>'
+'</article>';
}).join('');
var s=document.getElementById('home-audiences-section');
if(s)s.hidden=false;
})
.catch(function(){});
})();
</script>
<?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?> <?php /* ─── Récemment mis à jour ──────────────────────────────────────── */ ?>
<?php if (!empty($recentlyUpdated)): ?> <?php if (!empty($recentlyUpdated)): ?>