logDir = rtrim($logDir, '/'); $this->pattern = $pattern; $this->cacheFile = $cacheFile !== '' ? $cacheFile : dirname(__DIR__) . '/_cache/access_stats.json'; $this->cacheTtl = $cacheTtl; $this->days = $days; } /** * @return array{pages:array,books:array,ips:array} */ public function stats(): array { if (self::$memo !== null) { return self::$memo; } if ($this->cacheValid()) { $d = json_decode((string) file_get_contents($this->cacheFile), true); if (is_array($d)) { return self::$memo = $d; } } $cutoff = strtotime("-{$this->days} days midnight") ?: (time() - $this->days * 86400); $pages = []; $books = []; $ips = []; foreach ($this->logFiles() as $file) { $this->parseFile($file, $cutoff, $pages, $books, $ips); } arsort($pages); arsort($books); arsort($ips); $result = compact('pages', 'books', 'ips'); @mkdir(dirname($this->cacheFile), 0755, true); @file_put_contents($this->cacheFile, json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); return self::$memo = $result; } public function isReadable(): bool { return count($this->logFiles()) > 0; } private function cacheValid(): bool { return file_exists($this->cacheFile) && (time() - filemtime($this->cacheFile)) < $this->cacheTtl; } /** @return list */ private function logFiles(): array { $files = []; $cutoff = time() - ($this->days + 1) * 86400; 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) < $cutoff) { 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; } private static function parseTimestamp(string $raw): int { // "15/May/2026:00:41:01 +0200" 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]}"); } private function parseLine(string $line, int $cutoff, array &$pages, array &$books, array &$ips): void { if (!preg_match(self::RE, $line, $m)) { return; } [, $ip, $ts, $path, $status] = $m; if ($status !== '200') { return; } if (self::parseTimestamp($ts) < $cutoff) { return; } if (str_starts_with($path, '/post/') && strlen($path) > 6) { $pages[$path] = ($pages[$path] ?? 0) + 1; $ips[$ip] = ($ips[$ip] ?? 0) + 1; } elseif (str_starts_with($path, '/book/') && strlen($path) > 6) { $books[$path] = ($books[$path] ?? 0) + 1; $ips[$ip] = ($ips[$ip] ?? 0) + 1; } } private function parseFile(array $file, int $cutoff, array &$pages, array &$books, array &$ips): 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, $pages, $books, $ips); } } } catch (\Exception $e) { } } 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, $pages, $books, $ips); } } gzclose($h); } else { $h = @fopen($file['path'], 'rb'); if (!$h) { return; } while (($line = fgets($h)) !== false) { $this->parseLine($line, $cutoff, $pages, $books, $ips); } fclose($h); } } }