release 1.6.4 : TrendingParser, flux RSS /trending, page /tendances #74

Merged
cedricAbonnel merged 1 commits from dev into main 2026-05-15 15:38:29 +00:00
9 changed files with 624 additions and 38 deletions
+14
View File
@@ -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é
+1
View File
@@ -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
View File
@@ -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 {
+201
View File
@@ -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';
+135
View File
@@ -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
View File
@@ -1 +1 @@
1.6.3 1.6.4
+193
View File
@@ -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);
}
}
}
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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): ?>