release 1.6.4 : TrendingParser, flux RSS /trending, page /tendances #74
@@ -9,6 +9,20 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.6.4] - 2026-05-15
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- `src/TrendingParser.php` : parseur de logs Apache comptant les visiteurs uniques (IPs distinctes, HTTP 200) par article, avec support multi-préfixes et méthode `topGrouped()` (un seul parse pour pages + livres)
|
||||||
|
- `public/trending.php` : flux RSS des 50 articles les plus consultés, paramétrable par période (`?period=10m|20m|30m|1h|8h|1d|7d|14d|30d|1y`), cache TTL adaptatif
|
||||||
|
- `public/tendances.php` : page publique présentant les tendances par période, les flux RSS disponibles et la méthodologie
|
||||||
|
- Route `/tendances` dans `.htaccess`
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- `/admin/stats` : utilise `TrendingParser` (visiteurs uniques) au lieu d'`AccessLogParser` (hits bruts) pour les pages et les livres ; label mis à jour
|
||||||
|
- Page d'accueil — rubrique Tendances : source principale désormais les logs Apache sur 1 heure (cache 12 min), fallback sur le score pondéré DB si les logs ne sont pas lisibles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.6.3] - 2026-05-15
|
## [1.6.3] - 2026-05-15
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ RewriteRule ^verify-comment/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-
|
|||||||
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
|
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
|
||||||
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
|
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
|
||||||
RewriteRule ^search/?$ /index.php?action=search [L,QSA]
|
RewriteRule ^search/?$ /index.php?action=search [L,QSA]
|
||||||
|
RewriteRule ^tendances/?$ /tendances.php [L,QSA]
|
||||||
RewriteRule ^flux/?$ /index.php?action=flux [L,QSA]
|
RewriteRule ^flux/?$ /index.php?action=flux [L,QSA]
|
||||||
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
||||||
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
|
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
|
||||||
|
|||||||
+48
-6
@@ -2549,6 +2549,7 @@ switch ($action) {
|
|||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||||
require_once BASE_PATH . '/src/AccessLogParser.php';
|
require_once BASE_PATH . '/src/AccessLogParser.php';
|
||||||
require_once BASE_PATH . '/src/AsnLookup.php';
|
require_once BASE_PATH . '/src/AsnLookup.php';
|
||||||
|
|
||||||
@@ -2558,14 +2559,19 @@ 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);
|
||||||
|
$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());
|
||||||
$accessStats = $accessParser->stats();
|
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
|
||||||
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
|
||||||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||||
|
|
||||||
$statsRaw = [
|
$statsRaw = [
|
||||||
'readable' => $accessParser->isReadable(),
|
'readable' => $tParser->isReadable(),
|
||||||
'pages' => array_slice($accessStats['pages'], 0, 30, true),
|
'pages' => $grouped['/post/'],
|
||||||
'books' => array_slice($accessStats['books'], 0, 20, true),
|
'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));
|
||||||
@@ -3445,10 +3451,45 @@ switch ($action) {
|
|||||||
unset($_heroUuid, $_count, $_hp);
|
unset($_heroUuid, $_count, $_hp);
|
||||||
|
|
||||||
$allPostsMap = array_column($allPosts, null, 'uuid');
|
$allPostsMap = array_column($allPosts, null, 'uuid');
|
||||||
|
$_slugMap = array_column($allPosts, null, 'slug');
|
||||||
|
|
||||||
// Articles populaires (10 derniers jours) — score pondéré
|
// Tendances 1 h — logs Apache (visiteurs uniques, cache 12 min)
|
||||||
|
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||||
|
$_trendCache = DATA_PATH . '/_cache/trending_1h.json';
|
||||||
|
$_trendPaths = null;
|
||||||
|
if (file_exists($_trendCache) && (time() - filemtime($_trendCache)) < 720) {
|
||||||
|
$_trendPaths = json_decode((string) file_get_contents($_trendCache), true) ?: null;
|
||||||
|
}
|
||||||
|
if ($_trendPaths === null) {
|
||||||
|
$_tp = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
|
if ($_tp->isReadable()) {
|
||||||
|
$_trendPaths = $_tp->top(time() - 3600, 20);
|
||||||
|
@mkdir(DATA_PATH . '/_cache', 0755, true);
|
||||||
|
@file_put_contents($_trendCache, json_encode($_trendPaths));
|
||||||
|
}
|
||||||
|
unset($_tp);
|
||||||
|
}
|
||||||
|
if (!empty($_trendPaths)) {
|
||||||
|
foreach ($_trendPaths as $_path => $_cnt) {
|
||||||
|
if (count($popularPosts) >= 6) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!preg_match('#^/post/([^/]+)$#', $_path, $_m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$_a = $_slugMap[rawurldecode($_m[1])] ?? null;
|
||||||
|
if ($_a !== null) {
|
||||||
|
$popularPosts[] = $_a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($_path, $_cnt, $_m, $_a);
|
||||||
|
}
|
||||||
|
unset($_trendCache, $_trendPaths, $_slugMap);
|
||||||
|
|
||||||
|
// Fallback : score pondéré DB (réactions, notes, commentaires sur 10 j)
|
||||||
$_pdo = dbPdo();
|
$_pdo = dbPdo();
|
||||||
if ($_pdo) {
|
if ($_pdo) {
|
||||||
|
if (empty($popularPosts)) {
|
||||||
try {
|
try {
|
||||||
$_stmt = $_pdo->query("
|
$_stmt = $_pdo->query("
|
||||||
SELECT article_uuid, SUM(score) AS total
|
SELECT article_uuid, SUM(score) AS total
|
||||||
@@ -3478,6 +3519,7 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
} catch (Throwable) {
|
} catch (Throwable) {
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||||
|
|
||||||
|
require_once BASE_PATH . '/src/auth.php';
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
require_once BASE_PATH . '/config/config.php';
|
||||||
|
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||||
|
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||||
|
|
||||||
|
const TENDANCES_PERIODS = [
|
||||||
|
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes', 'short' => '10 min'],
|
||||||
|
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes', 'short' => '20 min'],
|
||||||
|
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes', 'short' => '30 min'],
|
||||||
|
'1h' => ['seconds' => 3600, 'label' => 'dernière heure', 'short' => '1 h'],
|
||||||
|
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures', 'short' => '8 h'],
|
||||||
|
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures', 'short' => '24 h'],
|
||||||
|
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours', 'short' => '7 j'],
|
||||||
|
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours', 'short' => '14 j'],
|
||||||
|
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours', 'short' => '30 j'],
|
||||||
|
'1y' => ['seconds' => 31536000, 'label' => 'dernière année', 'short' => '1 an'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Période active (affichage du top)
|
||||||
|
$period = $_GET['period'] ?? '1d';
|
||||||
|
if (!array_key_exists($period, TENDANCES_PERIODS)) {
|
||||||
|
$period = '1d';
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = TENDANCES_PERIODS[$period]['seconds'];
|
||||||
|
$label = TENDANCES_PERIODS[$period]['label'];
|
||||||
|
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
|
||||||
|
|
||||||
|
// Cache partagé avec trending.php
|
||||||
|
@mkdir(DATA_PATH . '/_cache', 0755, true);
|
||||||
|
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
|
||||||
|
$topPaths = null;
|
||||||
|
|
||||||
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
|
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
|
||||||
|
}
|
||||||
|
if ($topPaths === null) {
|
||||||
|
$parser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
|
$topPaths = $parser->top(time() - $seconds, 50);
|
||||||
|
@file_put_contents($cacheFile, json_encode($topPaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index slug → article
|
||||||
|
$articleManager = new ArticleManager(DATA_PATH);
|
||||||
|
$now = time();
|
||||||
|
$privateCats = $articleManager->getPrivateCategories();
|
||||||
|
$slugIndex = [];
|
||||||
|
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
|
||||||
|
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cat = trim($a['category'] ?? '');
|
||||||
|
if ($cat !== '' && in_array($cat, $privateCats, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slugIndex[$a['slug']] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top articles pour la période affichée
|
||||||
|
$topItems = [];
|
||||||
|
foreach ($topPaths as $path => $visitors) {
|
||||||
|
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$article = $slugIndex[rawurldecode($m[1])] ?? null;
|
||||||
|
if ($article === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$topItems[] = ['article' => $article, 'visitors' => (int) $visitors];
|
||||||
|
if (count($topItems) >= 20) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$base = rtrim(APP_URL, '/');
|
||||||
|
$pageTitle = 'Tendances — ' . siteTitle();
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
?>
|
||||||
|
<div class="container py-4" style="max-width:860px">
|
||||||
|
|
||||||
|
<h1 class="h3 mb-1">Tendances</h1>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
Articles les plus consultés, calculés en temps réel depuis les journaux d'accès du serveur.
|
||||||
|
Seuls les visiteurs uniques (une IP = un visiteur) sur des réponses <code>200</code> sont comptabilisés.
|
||||||
|
Aucun cookie, aucun traceur tiers.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Sélecteur de période -->
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||||
|
<?php foreach (TENDANCES_PERIODS as $p => $info): ?>
|
||||||
|
<a href="/tendances?period=<?= rawurlencode($p) ?>"
|
||||||
|
class="btn btn-sm <?= $p === $period ? 'btn-primary' : 'btn-outline-secondary' ?>">
|
||||||
|
<?= htmlspecialchars($info['short']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top articles -->
|
||||||
|
<?php if (empty($topItems)): ?>
|
||||||
|
<p class="text-muted">Aucune donnée disponible pour cette période.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<h2 class="h5 mb-3">Top articles — <?= htmlspecialchars($label) ?></h2>
|
||||||
|
<ol class="list-unstyled">
|
||||||
|
<?php foreach ($topItems as $i => ['article' => $a, 'visitors' => $v]): ?>
|
||||||
|
<li class="d-flex align-items-baseline gap-3 py-2 border-bottom">
|
||||||
|
<span class="text-muted" style="min-width:1.5rem;font-variant-numeric:tabular-nums"><?= $i + 1 ?></span>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<a href="<?= htmlspecialchars($base . '/post/' . rawurlencode($a['slug'])) ?>"
|
||||||
|
class="text-decoration-none fw-medium text-truncate d-block">
|
||||||
|
<?= htmlspecialchars($a['title'] ?? '') ?>
|
||||||
|
</a>
|
||||||
|
<?php if (!empty($a['category'])): ?>
|
||||||
|
<span class="badge bg-secondary fw-normal small"><?= htmlspecialchars($a['category']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted small text-nowrap"><?= number_format($v, 0, ',', "\u{202F}") ?> vis.</span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ol>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<!-- Flux RSS -->
|
||||||
|
<div class="card mt-5">
|
||||||
|
<div class="card-header bg-transparent py-2 small fw-semibold">Flux RSS disponibles</div>
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Chaque flux retourne les 50 articles les plus consultés pour la période choisie,
|
||||||
|
mis à jour automatiquement. Abonnez-vous à celui qui correspond à vos besoins.
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0 small">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Période</th>
|
||||||
|
<th>Cache</th>
|
||||||
|
<th>URL</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php
|
||||||
|
$cacheTtlLabels = [
|
||||||
|
'10m' => '2 min', '20m' => '4 min', '30m' => '6 min',
|
||||||
|
'1h' => '12 min', '8h' => '96 min', '1d' => '5 h',
|
||||||
|
'7d' => '8 h', '14d' => '8 h', '30d' => '8 h',
|
||||||
|
'1y' => '8 h',
|
||||||
|
];
|
||||||
|
foreach (TENDANCES_PERIODS as $p => $info):
|
||||||
|
$url = $base . '/trending?period=' . rawurlencode($p);
|
||||||
|
?>
|
||||||
|
<tr>
|
||||||
|
<td><?= htmlspecialchars($info['label']) ?></td>
|
||||||
|
<td class="text-muted"><?= $cacheTtlLabels[$p] ?></td>
|
||||||
|
<td>
|
||||||
|
<a href="<?= htmlspecialchars($url) ?>" class="font-monospace text-decoration-none small">
|
||||||
|
/trending?period=<?= htmlspecialchars($p) ?>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Méthodologie -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header bg-transparent py-2 small fw-semibold">Méthodologie</div>
|
||||||
|
<div class="card-body small text-muted">
|
||||||
|
<ul class="mb-0 ps-3">
|
||||||
|
<li>Source : journaux d'accès Apache (<code>access.log</code> et rotations <code>.gz</code>).</li>
|
||||||
|
<li>Seules les requêtes <code>GET</code> sur <code>/post/*</code> avec code <code>HTTP 200</code> sont comptabilisées.</li>
|
||||||
|
<li>Un visiteur = une adresse IP distincte par article sur la fenêtre temporelle.</li>
|
||||||
|
<li>Les IPs ne sont ni stockées ni transmises ; seuls les compteurs agrégés sont conservés en cache.</li>
|
||||||
|
<li>Les articles dans des catégories privées et les avant-premières ne figurent pas dans les résultats.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
http_response_code(200);
|
||||||
|
header('Cache-Control: public, max-age=' . $cacheTtl);
|
||||||
|
|
||||||
|
$templateVars = [
|
||||||
|
'title' => $pageTitle,
|
||||||
|
'content' => $content,
|
||||||
|
'mainClass' => '',
|
||||||
|
];
|
||||||
|
extract($templateVars);
|
||||||
|
require BASE_PATH . '/templates/layout.php';
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
define('BASE_PATH', realpath(__DIR__ . '/../'));
|
||||||
|
|
||||||
|
require_once BASE_PATH . '/src/auth.php';
|
||||||
|
require_once BASE_PATH . '/src/SiteSettings.php';
|
||||||
|
require_once BASE_PATH . '/config/config.php';
|
||||||
|
require_once BASE_PATH . '/src/ArticleManager.php';
|
||||||
|
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||||
|
|
||||||
|
// ── Périodes supportées ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TRENDING_PERIODS = [
|
||||||
|
'10m' => ['seconds' => 600, 'label' => '10 dernières minutes'],
|
||||||
|
'20m' => ['seconds' => 1200, 'label' => '20 dernières minutes'],
|
||||||
|
'30m' => ['seconds' => 1800, 'label' => '30 dernières minutes'],
|
||||||
|
'1h' => ['seconds' => 3600, 'label' => 'dernière heure'],
|
||||||
|
'8h' => ['seconds' => 28800, 'label' => '8 dernières heures'],
|
||||||
|
'1d' => ['seconds' => 86400, 'label' => '24 dernières heures'],
|
||||||
|
'7d' => ['seconds' => 604800, 'label' => '7 derniers jours'],
|
||||||
|
'14d' => ['seconds' => 1209600, 'label' => '14 derniers jours'],
|
||||||
|
'30d' => ['seconds' => 2592000, 'label' => '30 derniers jours'],
|
||||||
|
'1y' => ['seconds' => 31536000, 'label' => 'dernière année'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$period = $_GET['period'] ?? '1d';
|
||||||
|
|
||||||
|
if (!array_key_exists($period, TRENDING_PERIODS)) {
|
||||||
|
http_response_code(400);
|
||||||
|
header('Content-Type: text/plain; charset=UTF-8');
|
||||||
|
echo 'Période invalide. Valeurs acceptées : ' . implode(', ', array_keys(TRENDING_PERIODS));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$seconds = TRENDING_PERIODS[$period]['seconds'];
|
||||||
|
$label = TRENDING_PERIODS[$period]['label'];
|
||||||
|
$cutoff = time() - $seconds;
|
||||||
|
$cacheTtl = max(60, min(28800, (int) ($seconds / 5)));
|
||||||
|
|
||||||
|
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@mkdir(DATA_PATH . '/_cache', 0755, true);
|
||||||
|
$cacheFile = DATA_PATH . '/_cache/trending_' . $period . '.json';
|
||||||
|
$topPaths = null;
|
||||||
|
|
||||||
|
if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheTtl) {
|
||||||
|
$topPaths = json_decode((string) file_get_contents($cacheFile), true) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($topPaths === null) {
|
||||||
|
$parser = new TrendingParser('/var/log/apache2', apacheAccessLog());
|
||||||
|
$topPaths = $parser->top($cutoff, 50);
|
||||||
|
@file_put_contents($cacheFile, json_encode($topPaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Index slug → article (publiés, non privés) ────────────────────────────────
|
||||||
|
|
||||||
|
$articleManager = new ArticleManager(DATA_PATH);
|
||||||
|
$now = time();
|
||||||
|
$privateCats = $articleManager->getPrivateCategories();
|
||||||
|
$slugIndex = [];
|
||||||
|
|
||||||
|
foreach ($articleManager->getAll(publishedOnly: true) as $a) {
|
||||||
|
if (strtotime((string) ($a['published_at'] ?? '')) > $now) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cat = trim($a['category'] ?? '');
|
||||||
|
if ($cat !== '' && in_array($cat, $privateCats, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slugIndex[$a['slug']] = $a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Construction des items ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$base = rtrim(APP_URL, '/');
|
||||||
|
$items = [];
|
||||||
|
|
||||||
|
foreach ($topPaths as $path => $visitors) {
|
||||||
|
if (!preg_match('#^/post/([^/]+)$#', $path, $m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$slug = rawurldecode($m[1]);
|
||||||
|
$article = $slugIndex[$slug] ?? null;
|
||||||
|
if ($article === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = ['article' => $article, 'visitors' => (int) $visitors];
|
||||||
|
if (count($items) >= 50) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Réponse RSS ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
header('Content-Type: application/rss+xml; charset=UTF-8');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
header('Cache-Control: public, max-age=' . $cacheTtl);
|
||||||
|
|
||||||
|
$feedTitle = htmlspecialchars(siteTitle() . ' — Tendances (' . $label . ')', ENT_XML1);
|
||||||
|
$feedUrl = htmlspecialchars($base . '/trending?period=' . rawurlencode($period), ENT_XML1);
|
||||||
|
$baseXml = htmlspecialchars($base, ENT_XML1);
|
||||||
|
$buildDate = htmlspecialchars(date(DATE_RSS));
|
||||||
|
$descXml = htmlspecialchars('Top 50 articles par visiteurs uniques — ' . $label, ENT_XML1);
|
||||||
|
$langXml = htmlspecialchars(siteLang(), ENT_XML1);
|
||||||
|
|
||||||
|
echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||||
|
?>
|
||||||
|
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||||
|
<channel>
|
||||||
|
<title><?= $feedTitle ?></title>
|
||||||
|
<link><?= $baseXml ?></link>
|
||||||
|
<description><?= $descXml ?></description>
|
||||||
|
<language><?= $langXml ?></language>
|
||||||
|
<lastBuildDate><?= $buildDate ?></lastBuildDate>
|
||||||
|
<atom:link href="<?= $feedUrl ?>" rel="self" type="application/rss+xml"/>
|
||||||
|
<?php foreach ($items as ['article' => $a, 'visitors' => $v]):
|
||||||
|
$link = htmlspecialchars($base . '/post/' . rawurlencode($a['slug']), ENT_XML1);
|
||||||
|
$pubDate = htmlspecialchars(date(DATE_RSS, (int) strtotime((string) ($a['published_at'] ?? $a['created_at'] ?? ''))));
|
||||||
|
$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>
|
||||||
|
<description><?= $desc ?></description>
|
||||||
|
<pubDate><?= $pubDate ?></pubDate>
|
||||||
|
<guid isPermaLink="true"><?= $link ?></guid>
|
||||||
|
</item>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.6.3
|
1.6.4
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit les logs Apache et retourne les chemins /post/* les plus consultés
|
||||||
|
* sur une fenêtre temporelle donnée, en comptant les visiteurs uniques (IPs distinctes)
|
||||||
|
* avec code HTTP 200 uniquement.
|
||||||
|
*/
|
||||||
|
class TrendingParser
|
||||||
|
{
|
||||||
|
// 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}) /';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private string $logDir,
|
||||||
|
private string $pattern,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les $limit chemins les plus consultés depuis $cutoff,
|
||||||
|
* triés par nombre décroissant de visiteurs uniques.
|
||||||
|
*
|
||||||
|
* @param list<string> $prefixes ex. ['/post/'], ['/post/', '/book/']
|
||||||
|
* @return array<string, int> chemin => nb visiteurs uniques
|
||||||
|
*/
|
||||||
|
public function top(int $cutoff, int $limit = 50, array $prefixes = ['/post/']): array
|
||||||
|
{
|
||||||
|
$visitors = []; // [path][ip] = true
|
||||||
|
|
||||||
|
foreach ($this->logFiles($cutoff) as $file) {
|
||||||
|
$this->parseFile($file, $cutoff, $visitors, $prefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = [];
|
||||||
|
foreach ($visitors as $path => $ips) {
|
||||||
|
$counts[$path] = count($ips);
|
||||||
|
}
|
||||||
|
arsort($counts);
|
||||||
|
|
||||||
|
return array_slice($counts, 0, $limit, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse une seule fois les logs et retourne les tops séparés par préfixe.
|
||||||
|
* Plus efficace que plusieurs appels à top() sur la même période.
|
||||||
|
*
|
||||||
|
* @param array<string, int> $limits préfixe => limite
|
||||||
|
* @return array<string, array<string, int>> préfixe => (chemin => visiteurs)
|
||||||
|
*/
|
||||||
|
public function topGrouped(int $cutoff, array $limits): array
|
||||||
|
{
|
||||||
|
$prefixes = array_keys($limits);
|
||||||
|
$visitors = []; // [path][ip] = true
|
||||||
|
|
||||||
|
foreach ($this->logFiles($cutoff) as $file) {
|
||||||
|
$this->parseFile($file, $cutoff, $visitors, $prefixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = array_fill_keys($prefixes, []);
|
||||||
|
foreach ($visitors as $path => $ips) {
|
||||||
|
foreach ($prefixes as $prefix) {
|
||||||
|
if (str_starts_with($path, $prefix)) {
|
||||||
|
$result[$prefix][$path] = count($ips);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($prefixes as $prefix) {
|
||||||
|
arsort($result[$prefix]);
|
||||||
|
$result[$prefix] = array_slice($result[$prefix], 0, $limits[$prefix], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isReadable(): bool
|
||||||
|
{
|
||||||
|
return count($this->logFiles(time() - 86400)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fichiers de log ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** @return list<array{path:string,type:string}> */
|
||||||
|
private function logFiles(int $cutoff): array
|
||||||
|
{
|
||||||
|
$files = [];
|
||||||
|
$oldest = $cutoff - 86400; // une journée de marge pour les rotations
|
||||||
|
|
||||||
|
foreach (glob($this->logDir . '/' . $this->pattern) ?: [] as $base) {
|
||||||
|
if (str_ends_with($base, '.gz') || preg_match('/\.\d+$/', $base)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (array_merge([$base], glob($base . '.*') ?: []) as $path) {
|
||||||
|
if ($path !== $base && filemtime($path) < $oldest) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!is_readable($path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str_ends_with($path, '.tar.gz')) {
|
||||||
|
$files[] = ['path' => $path, 'type' => 'tgz'];
|
||||||
|
} elseif (str_ends_with($path, '.gz')) {
|
||||||
|
$files[] = ['path' => $path, 'type' => 'gz'];
|
||||||
|
} else {
|
||||||
|
$files[] = ['path' => $path, 'type' => 'plain'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parsing ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static function parseTimestamp(string $raw): int
|
||||||
|
{
|
||||||
|
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]}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, true>> $visitors
|
||||||
|
* @param list<string> $prefixes
|
||||||
|
*/
|
||||||
|
private function parseLine(string $line, int $cutoff, array &$visitors, array $prefixes): void
|
||||||
|
{
|
||||||
|
if (!preg_match(self::RE, $line, $m)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
[, $ip, $ts, $path, $status] = $m;
|
||||||
|
|
||||||
|
if ($status !== '200') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (self::parseTimestamp($ts) < $cutoff) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($prefixes as $prefix) {
|
||||||
|
if (str_starts_with($path, $prefix) && strlen($path) > strlen($prefix)) {
|
||||||
|
$visitors[$path][$ip] = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, true>> $visitors
|
||||||
|
* @param list<string> $prefixes
|
||||||
|
*/
|
||||||
|
private function parseFile(array $file, int $cutoff, array &$visitors, array $prefixes): void
|
||||||
|
{
|
||||||
|
if ($file['type'] === 'tgz') {
|
||||||
|
try {
|
||||||
|
$phar = new PharData($file['path']);
|
||||||
|
foreach ($phar as $entry) {
|
||||||
|
$content = @file_get_contents('phar://' . $file['path'] . '/' . $entry->getFilename());
|
||||||
|
if ($content === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (explode("\n", $content) as $line) {
|
||||||
|
$this->parseLine($line, $cutoff, $visitors, $prefixes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Exception) {
|
||||||
|
}
|
||||||
|
} elseif ($file['type'] === 'gz') {
|
||||||
|
$h = @gzopen($file['path'], 'rb');
|
||||||
|
if (!$h) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (!gzeof($h)) {
|
||||||
|
$line = gzgets($h, 8192);
|
||||||
|
if ($line !== false) {
|
||||||
|
$this->parseLine($line, $cutoff, $visitors, $prefixes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gzclose($h);
|
||||||
|
} else {
|
||||||
|
$h = @fopen($file['path'], 'rb');
|
||||||
|
if (!$h) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (($line = fgets($h)) !== false) {
|
||||||
|
$this->parseLine($line, $cutoff, $visitors, $prefixes);
|
||||||
|
}
|
||||||
|
fclose($h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|
||||||
<p class="text-muted small mb-4">14 derniers jours · cache 10 min</p>
|
<p class="text-muted small mb-4">14 derniers jours · visiteurs uniques · cache 60 s</p>
|
||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
<div class="progress-bar" style="width:<?= $pct ?>%"></div>
|
<div class="progress-bar" style="width:<?= $pct ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?></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>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -106,7 +106,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
|||||||
<div class="progress-bar bg-success" style="width:<?= $pct ?>%"></div>
|
<div class="progress-bar bg-success" style="width:<?= $pct ?>%"></div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-end fw-semibold pe-3"><?= number_format($hits, 0, ',', '\u{202F}') ?></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>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
|||||||
<?php if (!empty($popularPosts)): ?>
|
<?php if (!empty($popularPosts)): ?>
|
||||||
<section class="home-section">
|
<section class="home-section">
|
||||||
<h2 class="home-section-title">
|
<h2 class="home-section-title">
|
||||||
Tendances <span class="home-section-title-sub">· 10 derniers jours</span>
|
Tendances <span class="home-section-title-sub">· 1 heure</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="post-grid">
|
<div class="post-grid">
|
||||||
<?php foreach ($popularPosts as $_pp): ?>
|
<?php foreach ($popularPosts as $_pp): ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user