feat : visiteurs uniques, filtre jours, redirect 404→search, edit_tags (v1.6.16)
- SearchLogParser : visiteurs uniques par terme (IPs distinctes) au lieu de hits bruts (#41) - SearchLogParser : paramètre $days (7/14), cache distinct par période, filtre logFiles par date (#46) - admin/searches : boutons 7 j / 14 j, label dynamique, colonne « Visiteurs » (#41, #46) - URL inconnue / slug absent : redirect 302 /search?q=… au lieu de page 404 (#57) - edit_tags : masquer abbrev/camel si des valeurs connues existent pour le type (#48) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,19 @@ Format : [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) — versionnag
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.6.16] - 2026-05-16
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
- `SearchLogParser` : paramètre `$days` (7 ou 14) — cache distinct par période, filtre logFiles par date (#46)
|
||||||
|
- `admin/searches` : boutons 7 j / 14 j pour choisir la fenêtre d'analyse (#46)
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
- `SearchLogParser` : tri par visiteurs uniques (IPs distinctes) au lieu de hits bruts — colonne renommée « Visiteurs » (#41)
|
||||||
|
- URL inconnue / article introuvable : redirection 302 vers `/search?q=…` au lieu de page 404 (#57)
|
||||||
|
- `edit_tags` : sections « Abréviations » et « Noms composés » masquées si des valeurs connues existent pour le type (#48)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.6.15] - 2026-05-16
|
## [1.6.15] - 2026-05-16
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
+17
-25
@@ -50,12 +50,17 @@ $metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' :
|
|||||||
unset($_noindexActions);
|
unset($_noindexActions);
|
||||||
|
|
||||||
// ─── Recherche de l'article le plus proche et redirection 301 ────────────────
|
// ─── Recherche de l'article le plus proche et redirection 301 ────────────────
|
||||||
|
function slugToSearchQuery(string $rawPath): string
|
||||||
|
{
|
||||||
|
return trim((string)preg_replace('/\s{2,}/', ' ', (string)preg_replace(
|
||||||
|
'/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath)
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
function searchAndRedirect(string $rawPath, ArticleManager $articles): void
|
function searchAndRedirect(string $rawPath, ArticleManager $articles): void
|
||||||
{
|
{
|
||||||
require_once BASE_PATH . '/src/SearchEngine.php';
|
require_once BASE_PATH . '/src/SearchEngine.php';
|
||||||
$query = (string)preg_replace('/\s{2,}/', ' ', trim(
|
$query = slugToSearchQuery($rawPath);
|
||||||
(string)preg_replace('/[^a-zA-ZÀ-ÿ0-9\s]/u', ' ', str_replace(['-', '_', '/'], ' ', $rawPath))
|
|
||||||
));
|
|
||||||
if ($query === '') {
|
if ($query === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -666,9 +671,8 @@ switch ($action) {
|
|||||||
case 'view':
|
case 'view':
|
||||||
$article = $slug !== '' ? $articles->getBySlug($slug) : null;
|
$article = $slug !== '' ? $articles->getBySlug($slug) : null;
|
||||||
if (!$article) {
|
if (!$article) {
|
||||||
searchAndRedirect($slug, $articles);
|
$q = slugToSearchQuery($slug);
|
||||||
http_response_code(404);
|
header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302);
|
||||||
echo 'Article introuvable.';
|
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2577,9 +2581,11 @@ switch ($action) {
|
|||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
require_once BASE_PATH . '/src/SearchLogParser.php';
|
require_once BASE_PATH . '/src/SearchLogParser.php';
|
||||||
$parser = new SearchLogParser('/var/log/apache2', apacheAccessLog());
|
$days = in_array((int)($_GET['days'] ?? 14), [7, 14], true) ? (int)$_GET['days'] : 14;
|
||||||
$adminData['search_terms'] = $parser->topTerms(100);
|
$parser = new SearchLogParser('/var/log/apache2', apacheAccessLog(), '', 600, $days);
|
||||||
|
$adminData['search_terms'] = $parser->topTerms(100);
|
||||||
$adminData['search_log_readable'] = $parser->isReadable();
|
$adminData['search_log_readable'] = $parser->isReadable();
|
||||||
|
$adminData['search_days'] = $days;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tab === 'stats') {
|
if ($tab === 'stats') {
|
||||||
@@ -3383,23 +3389,9 @@ switch ($action) {
|
|||||||
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
|
(string)(parse_url($_SERVER['REDIRECT_URL'] ?? $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?? ''),
|
||||||
'/'
|
'/'
|
||||||
);
|
);
|
||||||
if ($notFoundPath !== '') {
|
$q = slugToSearchQuery($notFoundPath);
|
||||||
searchAndRedirect(basename($notFoundPath), $articles);
|
header('Location: /search' . ($q !== '' ? '?q=' . urlencode($q) : ''), true, 302);
|
||||||
}
|
exit;
|
||||||
http_response_code(404);
|
|
||||||
ob_start();
|
|
||||||
?>
|
|
||||||
<div class="container py-5 text-center">
|
|
||||||
<h1 class="h2 mb-3">Page introuvable</h1>
|
|
||||||
<p class="text-muted mb-4">Cette adresse ne correspond à aucun article.<br>Vous avez peut-être suivi un ancien lien.</p>
|
|
||||||
<a href="/" class="btn btn-primary">← Retour à l'accueil</a>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
$content = ob_get_clean();
|
|
||||||
$title = '404 — ' . siteTitle();
|
|
||||||
$metaRobots = 'noindex, nofollow';
|
|
||||||
include BASE_PATH . '/templates/layout.php';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'list':
|
case 'list':
|
||||||
default:
|
default:
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
1.6.15
|
1.6.16
|
||||||
|
|||||||
+26
-13
@@ -8,22 +8,25 @@ class SearchLogParser
|
|||||||
private string $vhostBase;
|
private string $vhostBase;
|
||||||
private string $cacheFile;
|
private string $cacheFile;
|
||||||
private int $cacheTtl;
|
private int $cacheTtl;
|
||||||
|
private int $days;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
string $logDir = '/var/log/apache2',
|
string $logDir = '/var/log/apache2',
|
||||||
string $vhostBase = '*-access.log',
|
string $vhostBase = '*-access.log',
|
||||||
string $cacheFile = '',
|
string $cacheFile = '',
|
||||||
int $cacheTtl = 600
|
int $cacheTtl = 600,
|
||||||
|
int $days = 14
|
||||||
) {
|
) {
|
||||||
$this->logDir = rtrim($logDir, '/');
|
$this->logDir = rtrim($logDir, '/');
|
||||||
$this->vhostBase = $vhostBase;
|
$this->vhostBase = $vhostBase;
|
||||||
|
$this->days = max(1, min(30, $days));
|
||||||
$this->cacheFile = $cacheFile !== ''
|
$this->cacheFile = $cacheFile !== ''
|
||||||
? $cacheFile
|
? $cacheFile
|
||||||
: dirname(__DIR__) . '/_cache/search_terms.json';
|
: dirname(__DIR__) . '/_cache/search_terms_' . $this->days . 'd.json';
|
||||||
$this->cacheTtl = $cacheTtl;
|
$this->cacheTtl = $cacheTtl;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string,int> terme => nombre d'occurrences, trié desc */
|
/** @return array<string,int> terme => visiteurs uniques, trié desc */
|
||||||
public function topTerms(int $limit = 100): array
|
public function topTerms(int $limit = 100): array
|
||||||
{
|
{
|
||||||
if ($this->cacheValid()) {
|
if ($this->cacheValid()) {
|
||||||
@@ -33,9 +36,14 @@ class SearchLogParser
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$counts = [];
|
$visitors = []; // terme => [ip => true]
|
||||||
foreach ($this->logFiles() as $file) {
|
foreach ($this->logFiles() as $file) {
|
||||||
$this->parseFile($file, $counts);
|
$this->parseFile($file, $visitors);
|
||||||
|
}
|
||||||
|
|
||||||
|
$counts = [];
|
||||||
|
foreach ($visitors as $term => $ips) {
|
||||||
|
$counts[$term] = count($ips);
|
||||||
}
|
}
|
||||||
arsort($counts);
|
arsort($counts);
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ class SearchLogParser
|
|||||||
{
|
{
|
||||||
$pattern = $this->logDir . '/' . $this->vhostBase;
|
$pattern = $this->logDir . '/' . $this->vhostBase;
|
||||||
$files = [];
|
$files = [];
|
||||||
|
$cutoff = time() - $this->days * 86400;
|
||||||
|
|
||||||
// Fichiers correspondant au pattern de base (courants + rotations incluses si glob)
|
// Fichiers correspondant au pattern de base (courants + rotations incluses si glob)
|
||||||
$bases = glob($pattern) ?: [];
|
$bases = glob($pattern) ?: [];
|
||||||
@@ -75,6 +84,9 @@ class SearchLogParser
|
|||||||
if (!is_readable($path)) {
|
if (!is_readable($path)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (@filemtime($path) < $cutoff) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (str_ends_with($path, '.tar.gz')) {
|
if (str_ends_with($path, '.tar.gz')) {
|
||||||
$files[] = ['path' => $path, 'type' => 'tgz'];
|
$files[] = ['path' => $path, 'type' => 'tgz'];
|
||||||
} elseif (str_ends_with($path, '.gz')) {
|
} elseif (str_ends_with($path, '.gz')) {
|
||||||
@@ -88,7 +100,7 @@ class SearchLogParser
|
|||||||
return $files;
|
return $files;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseFile(array $file, array &$counts): void
|
private function parseFile(array $file, array &$visitors): void
|
||||||
{
|
{
|
||||||
if ($file['type'] === 'tgz') {
|
if ($file['type'] === 'tgz') {
|
||||||
try {
|
try {
|
||||||
@@ -99,7 +111,7 @@ class SearchLogParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach (explode("\n", $content) as $line) {
|
foreach (explode("\n", $content) as $line) {
|
||||||
$this->parseLine($line, $counts);
|
$this->parseLine($line, $visitors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
@@ -113,7 +125,7 @@ class SearchLogParser
|
|||||||
while (!gzeof($h)) {
|
while (!gzeof($h)) {
|
||||||
$line = gzgets($h, 8192);
|
$line = gzgets($h, 8192);
|
||||||
if ($line !== false) {
|
if ($line !== false) {
|
||||||
$this->parseLine($line, $counts);
|
$this->parseLine($line, $visitors);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gzclose($h);
|
gzclose($h);
|
||||||
@@ -123,28 +135,29 @@ class SearchLogParser
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while (($line = fgets($h)) !== false) {
|
while (($line = fgets($h)) !== false) {
|
||||||
$this->parseLine($line, $counts);
|
$this->parseLine($line, $visitors);
|
||||||
}
|
}
|
||||||
fclose($h);
|
fclose($h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseLine(string $line, array &$counts): void
|
private function parseLine(string $line, array &$visitors): void
|
||||||
{
|
{
|
||||||
if (!str_contains($line, 'GET /search?')) {
|
if (!str_contains($line, 'GET /search?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!preg_match('/"GET \/search\?([^"]*) HTTP\//', $line, $m)) {
|
if (!preg_match('/^(\S+) \S+ \S+ \[[^\]]+\] "GET \/search\?([^"]*) HTTP\//', $line, $m)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
parse_str($m[1], $params);
|
$ip = $m[1];
|
||||||
|
parse_str($m[2], $params);
|
||||||
$q = trim(urldecode($params['q'] ?? ''));
|
$q = trim(urldecode($params['q'] ?? ''));
|
||||||
|
|
||||||
if ($q === '' || mb_strlen($q) > 200) {
|
if ($q === '' || mb_strlen($q) > 200) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$q = mb_strtolower($q);
|
$q = mb_strtolower($q);
|
||||||
$counts[$q] = ($counts[$q] ?? 0) + 1;
|
$visitors[$q][$ip] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -1207,7 +1207,15 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
<h5 class="mb-0">Termes recherchés
|
<h5 class="mb-0">Termes recherchés
|
||||||
<span class="badge bg-secondary ms-1"><?= count($adminData['search_terms'] ?? []) ?></span>
|
<span class="badge bg-secondary ms-1"><?= count($adminData['search_terms'] ?? []) ?></span>
|
||||||
</h5>
|
</h5>
|
||||||
<span class="text-muted small">Derniers 14 jours de logs · cache 10 min</span>
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<span class="text-muted small">Derniers <?= (int)($adminData['search_days'] ?? 14) ?> jours · cache 10 min</span>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="/admin/searches?days=7"
|
||||||
|
class="btn <?= ($adminData['search_days'] ?? 14) === 7 ? 'btn-primary' : 'btn-outline-secondary' ?>">7 j</a>
|
||||||
|
<a href="/admin/searches"
|
||||||
|
class="btn <?= ($adminData['search_days'] ?? 14) === 14 ? 'btn-primary' : 'btn-outline-secondary' ?>">14 j</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!($adminData['search_log_readable'] ?? false)): ?>
|
<?php if (!($adminData['search_log_readable'] ?? false)): ?>
|
||||||
@@ -1228,7 +1236,7 @@ foreach (COLOR_PALETTE_16 as $_i => $_rgb):
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="width:3rem">#</th>
|
<th style="width:3rem">#</th>
|
||||||
<th>Terme recherché</th>
|
<th>Terme recherché</th>
|
||||||
<th style="width:6rem" class="text-end">Fois</th>
|
<th style="width:6rem" class="text-end">Visiteurs</th>
|
||||||
<th style="width:12rem"></th>
|
<th style="width:12rem"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -97,8 +97,10 @@ $_typeLabel = $isCatField ? 'Catégorie' : ($tagTypes[$tagType] ?? ucfirst($tag
|
|||||||
|
|
||||||
<?php renderTagGroup('Déjà taggués', $_current, true); ?>
|
<?php renderTagGroup('Déjà taggués', $_current, true); ?>
|
||||||
<?php renderTagGroup('Valeurs connues dans d\'autres articles', $_known, false); ?>
|
<?php renderTagGroup('Valeurs connues dans d\'autres articles', $_known, false); ?>
|
||||||
|
<?php if (empty($_known)): ?>
|
||||||
<?php renderTagGroup('Abréviations détectées', $_abbrevs, false, true); ?>
|
<?php renderTagGroup('Abréviations détectées', $_abbrevs, false, true); ?>
|
||||||
<?php renderTagGroup('Noms composés détectés', $_camel + $_proper, false, true); ?>
|
<?php renderTagGroup('Noms composés détectés', $_camel + $_proper, false, true); ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (empty($suggestions)): ?>
|
<?php if (empty($suggestions)): ?>
|
||||||
<p class="text-muted">Aucun terme détecté dans cet article.</p>
|
<p class="text-muted">Aucun terme détecté dans cet article.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user