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
|
||||
|
||||
### 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 ^profile/?$ /index.php?action=profile [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 ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
||||
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
|
||||
|
||||
+75
-33
@@ -2549,6 +2549,7 @@ switch ($action) {
|
||||
http_response_code(403);
|
||||
exit;
|
||||
}
|
||||
require_once BASE_PATH . '/src/TrendingParser.php';
|
||||
require_once BASE_PATH . '/src/AccessLogParser.php';
|
||||
require_once BASE_PATH . '/src/AsnLookup.php';
|
||||
|
||||
@@ -2558,15 +2559,20 @@ switch ($action) {
|
||||
$statsRaw = json_decode((string) file_get_contents($statsCacheFile), true) ?: 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());
|
||||
$accessStats = $accessParser->stats();
|
||||
$topIps = array_slice($accessStats['ips'], 0, 200, true);
|
||||
$topIps = array_slice($accessParser->stats()['ips'], 0, 200, true);
|
||||
$asnMap = (new AsnLookup())->batchLookup(array_keys($topIps));
|
||||
|
||||
$statsRaw = [
|
||||
'readable' => $accessParser->isReadable(),
|
||||
'pages' => array_slice($accessStats['pages'], 0, 30, true),
|
||||
'books' => array_slice($accessStats['books'], 0, 20, true),
|
||||
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
||||
'readable' => $tParser->isReadable(),
|
||||
'pages' => $grouped['/post/'],
|
||||
'books' => $grouped['/book/'],
|
||||
'as' => AsnLookup::aggregateByAs($topIps, $asnMap),
|
||||
];
|
||||
@file_put_contents($statsCacheFile, json_encode($statsRaw));
|
||||
}
|
||||
@@ -3445,38 +3451,74 @@ switch ($action) {
|
||||
unset($_heroUuid, $_count, $_hp);
|
||||
|
||||
$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();
|
||||
if ($_pdo) {
|
||||
try {
|
||||
$_stmt = $_pdo->query("
|
||||
SELECT article_uuid, SUM(score) AS total
|
||||
FROM (
|
||||
SELECT article_uuid, 1 AS score FROM article_reactions
|
||||
WHERE created_at >= NOW() - INTERVAL '10 days'
|
||||
UNION ALL
|
||||
SELECT article_uuid, 2 AS score FROM article_ratings
|
||||
WHERE rated_at >= NOW() - INTERVAL '10 days'
|
||||
UNION ALL
|
||||
SELECT article_uuid, 3 AS score FROM comments
|
||||
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
|
||||
) ev
|
||||
GROUP BY article_uuid
|
||||
ORDER BY total DESC
|
||||
LIMIT 20
|
||||
");
|
||||
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
||||
if (count($popularPosts) >= 6) {
|
||||
break;
|
||||
if (empty($popularPosts)) {
|
||||
try {
|
||||
$_stmt = $_pdo->query("
|
||||
SELECT article_uuid, SUM(score) AS total
|
||||
FROM (
|
||||
SELECT article_uuid, 1 AS score FROM article_reactions
|
||||
WHERE created_at >= NOW() - INTERVAL '10 days'
|
||||
UNION ALL
|
||||
SELECT article_uuid, 2 AS score FROM article_ratings
|
||||
WHERE rated_at >= NOW() - INTERVAL '10 days'
|
||||
UNION ALL
|
||||
SELECT article_uuid, 3 AS score FROM comments
|
||||
WHERE created_at >= NOW() - INTERVAL '10 days' AND published = TRUE
|
||||
) ev
|
||||
GROUP BY article_uuid
|
||||
ORDER BY total DESC
|
||||
LIMIT 20
|
||||
");
|
||||
foreach ($_stmt->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
||||
if (count($popularPosts) >= 6) {
|
||||
break;
|
||||
}
|
||||
$_uuid = $_row['article_uuid'];
|
||||
if (!isset($allPostsMap[$_uuid])) {
|
||||
continue;
|
||||
}
|
||||
$popularPosts[] = $allPostsMap[$_uuid];
|
||||
}
|
||||
$_uuid = $_row['article_uuid'];
|
||||
if (!isset($allPostsMap[$_uuid])) {
|
||||
continue;
|
||||
}
|
||||
$popularPosts[] = $allPostsMap[$_uuid];
|
||||
} catch (Throwable) {
|
||||
}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
// Redécouvertes : anciens articles (> 30 j) avec activité récente
|
||||
|
||||
@@ -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>
|
||||
<?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">
|
||||
|
||||
@@ -61,7 +61,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
||||
<div class="progress-bar" style="width:<?= $pct ?>%"></div>
|
||||
</div>
|
||||
</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>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -106,7 +106,7 @@ $_activeGroup = trim($_GET['group'] ?? '');
|
||||
<div class="progress-bar bg-success" style="width:<?= $pct ?>%"></div>
|
||||
</div>
|
||||
</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>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -159,7 +159,7 @@ function _renderCard(array $post, array $privateCats, array $allCats, \Parsedown
|
||||
<?php if (!empty($popularPosts)): ?>
|
||||
<section class="home-section">
|
||||
<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>
|
||||
<div class="post-grid">
|
||||
<?php foreach ($popularPosts as $_pp): ?>
|
||||
|
||||
Reference in New Issue
Block a user