$prefixes ex. ['/post/'], ['/post/', '/book/'] * @return array 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 $limits préfixe => limite * @return array> 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 */ 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> $visitors * @param list $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> $visitors * @param list $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); } } }