feat : TrendingParser + flux RSS tendances + page publique /tendances (v1.6.4)
- TrendingParser : lit les logs Apache, compte les visiteurs uniques (IPs, HTTP 200), supporte plusieurs préfixes (/post/, /book/) et un seul parse via topGrouped() - /trending?period=… : flux RSS des 50 articles les plus consultés, 10 périodes de 10 min à 1 an, cache TTL adaptatif - /tendances : page publique avec sélecteur de période, top 20 articles, tableau des flux RSS et section méthodologie - /admin/stats : remplace AccessLogParser (hits) par TrendingParser (visiteurs uniques) - Page d'accueil : rubrique Tendances alimentée par les logs 1h (fallback DB) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
Reference in New Issue
Block a user