Files
folio/public/tendances.php
T
cedricAbonnel e19d20ca17 refactor : trending — seul /trending génère le cache, les consommateurs lisent (v1.6.5)
Page d'accueil et /tendances lisent uniquement le cache trending_{period}.json
produit par le flux RSS /trending?period=…. Aucun parsing de logs en dehors du
flux RSS. Rubrique renommée "Meilleures audiences · 1 heure".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 18:19:10 +02:00

195 lines
7.3 KiB
PHP

<?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';
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)));
// Lecture seule du cache généré par /trending?period=…
$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;
}
// 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';