312 lines
11 KiB
PHP
312 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* Analytics - Aggregated stats recorder & statistics manager for CodePress CMS.
|
|
*
|
|
* Registreert geaggregeerde bezoekersstatistieken (views, uniques, landen,
|
|
* referrers, bots) in een JSON-bestand met file locking en retentie-cleanup.
|
|
*
|
|
* @since 2.6.4
|
|
*/
|
|
class Analytics
|
|
{
|
|
/**
|
|
* Pad naar het JSON-statistiekbestand.
|
|
*
|
|
* @since 2.6.4
|
|
* @var string Pad naar stats.json.
|
|
*/
|
|
private string $statsFile;
|
|
|
|
/**
|
|
* Analytics configuratie-array.
|
|
*
|
|
* @since 2.6.4
|
|
* @var array<string,mixed> Analytics configuratie.
|
|
*/
|
|
private array $config;
|
|
|
|
/**
|
|
* Initialiseer de Analytics-recorder.
|
|
*
|
|
* Stelt het pad naar het statistiekbestand in en maakt de opslagmap aan
|
|
* indien deze nog niet bestaat.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param array $analyticsConfig {
|
|
* Optioneel. Analytics configuratie.
|
|
*
|
|
* @type bool $enabled Of analytics actief is. Default false.
|
|
* @type int $retention_days Aantal dagen dat statistieken bewaard blijven. Default 400.
|
|
* }
|
|
*/
|
|
public function __construct(array $analyticsConfig = [])
|
|
{
|
|
$this->config = $analyticsConfig;
|
|
$this->statsFile = dirname(__DIR__, 3) . '/admin/storage/stats.json';
|
|
$dir = dirname($this->statsFile);
|
|
if (!is_dir($dir)) {
|
|
@mkdir($dir, 0755, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Registreer een paginabezoek in de geaggregeerde statistieken.
|
|
*
|
|
* Identificeert bezoeker (bot/mens), anonimiseert het IP-adres per dag met
|
|
* een zout, aggregeert totals/countries/pages/referrers/days/uniques, past
|
|
* de retentie toe en schrijft atomair (met bestandslock) naar stats.json.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param string $page Page key van de bezochte pagina.
|
|
* @param string $ip Client IP-adres.
|
|
* @param string $userAgent User-Agent string van de bezoeker.
|
|
* @param string $referrer Referrer URL string.
|
|
* @param string|null $country Opgeloste landcode (bijv. NL, BE). Null indien onbekend.
|
|
* @param string $status Status string (ok, blocked:ai, blocked:ratelimit, enz.).
|
|
* @return void
|
|
*/
|
|
public function record(string $page, string $ip, string $userAgent, string $referrer, ?string $country, string $status): void
|
|
{
|
|
if (empty($this->config['enabled'])) {
|
|
return;
|
|
}
|
|
|
|
$today = date('Y-m-d');
|
|
$country = ($country && strlen($country) === 2) ? strtoupper($country) : 'UNKNOWN';
|
|
$isBlocked = str_starts_with($status, 'blocked');
|
|
|
|
// Identify bot vs human
|
|
$visitorInfo = RequestLogger::detectVisitorInfo($userAgent);
|
|
$type = $visitorInfo['type'];
|
|
$isBot = in_array($type, ['ai', 'search', 'scraper', 'bot'], true);
|
|
|
|
// Anonymized unique IP salt per day
|
|
$ipSalt = date('Y-m-d') . '_codepress_salt';
|
|
$ipHash = md5($ip . $ipSalt);
|
|
|
|
// Domain/host from referrer
|
|
$refHost = 'direct';
|
|
if ($referrer !== '') {
|
|
$parsed = parse_url($referrer);
|
|
if (!empty($parsed['host'])) {
|
|
$refHost = preg_replace('/^www\./', '', strtolower($parsed['host']));
|
|
}
|
|
}
|
|
|
|
// Open stats.json with exclusive lock
|
|
$handle = @fopen($this->statsFile, 'c+');
|
|
if (!$handle) return;
|
|
|
|
if (flock($handle, LOCK_EX)) {
|
|
$fileSize = filesize($this->statsFile);
|
|
$data = [];
|
|
if ($fileSize > 0) {
|
|
$content = fread($handle, $fileSize);
|
|
$data = json_decode($content, true);
|
|
}
|
|
if (!is_array($data)) {
|
|
$data = [
|
|
'totals' => ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0],
|
|
'countries' => [],
|
|
'pages' => [],
|
|
'referrers' => [],
|
|
'days' => [],
|
|
'uniques' => []
|
|
];
|
|
}
|
|
|
|
// Totals
|
|
$data['totals']['views'] = ($data['totals']['views'] ?? 0) + 1;
|
|
if ($isBlocked) {
|
|
$data['totals']['blocked'] = ($data['totals']['blocked'] ?? 0) + 1;
|
|
} elseif ($isBot) {
|
|
$data['totals']['bot'] = ($data['totals']['bot'] ?? 0) + 1;
|
|
} else {
|
|
$data['totals']['human'] = ($data['totals']['human'] ?? 0) + 1;
|
|
}
|
|
|
|
// Countries
|
|
$data['countries'][$country] = ($data['countries'][$country] ?? 0) + 1;
|
|
|
|
// Pages
|
|
$data['pages'][$page] = ($data['pages'][$page] ?? 0) + 1;
|
|
|
|
// Referrers
|
|
if ($refHost !== 'direct') {
|
|
$data['referrers'][$refHost] = ($data['referrers'][$refHost] ?? 0) + 1;
|
|
}
|
|
|
|
// Day stats
|
|
if (!isset($data['days'][$today])) {
|
|
$data['days'][$today] = ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0, 'countries' => [], 'pages' => []];
|
|
}
|
|
$data['days'][$today]['views']++;
|
|
if ($isBlocked) {
|
|
$data['days'][$today]['blocked']++;
|
|
} elseif ($isBot) {
|
|
$data['days'][$today]['bot']++;
|
|
} else {
|
|
$data['days'][$today]['human']++;
|
|
}
|
|
$data['days'][$today]['countries'][$country] = ($data['days'][$today]['countries'][$country] ?? 0) + 1;
|
|
$data['days'][$today]['pages'][$page] = ($data['days'][$today]['pages'][$page] ?? 0) + 1;
|
|
|
|
// Uniques per day
|
|
if (!isset($data['uniques'][$today])) {
|
|
$data['uniques'][$today] = [];
|
|
}
|
|
if (!in_array($ipHash, $data['uniques'][$today], true)) {
|
|
$data['uniques'][$today][] = $ipHash;
|
|
}
|
|
|
|
// Cleanup retention (keep max retention_days)
|
|
$retentionDays = max(30, (int)($this->config['retention_days'] ?? 400));
|
|
$cutoffDate = date('Y-m-d', strtotime("-{$retentionDays} days"));
|
|
|
|
foreach (array_keys($data['days']) as $d) {
|
|
if ($d < $cutoffDate) {
|
|
unset($data['days'][$d]);
|
|
unset($data['uniques'][$d]);
|
|
}
|
|
}
|
|
|
|
// Rewrite stats file
|
|
ftruncate($handle, 0);
|
|
rewind($handle);
|
|
fwrite($handle, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
|
fflush($handle);
|
|
flock($handle, LOCK_UN);
|
|
}
|
|
|
|
fclose($handle);
|
|
}
|
|
|
|
/**
|
|
* Haal geaggregeerde statistieken op voor een specifieke periode.
|
|
*
|
|
* Leest stats.json en aggregeert totals, landen, pagina's, referrers en
|
|
* een daily chart. Bij $days = 0 wordt alles-tijd geretourneerd, anders
|
|
* worden ontbrekende dagen in de range opgevuld voor een vloeiende grafiek.
|
|
*
|
|
* @since 2.6.4
|
|
*
|
|
* @param int $days Aantal dagen (bijv. 7, 30, 90), of 0 voor alles-tijd. Default 30.
|
|
* @return array<string,mixed> Geaggregeerde statistieken met keys totals, countries, pages, referrers, daily_chart.
|
|
*/
|
|
public function getStats(int $days = 30): array
|
|
{
|
|
if (!file_exists($this->statsFile)) {
|
|
return [
|
|
'totals' => ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0, 'uniques' => 0],
|
|
'countries' => [],
|
|
'pages' => [],
|
|
'referrers' => [],
|
|
'daily_chart' => [],
|
|
];
|
|
}
|
|
|
|
$raw = json_decode(file_get_contents($this->statsFile), true);
|
|
if (!is_array($raw)) $raw = [];
|
|
|
|
if ($days === 0) {
|
|
// All time
|
|
$countries = $raw['countries'] ?? [];
|
|
$pages = $raw['pages'] ?? [];
|
|
$referrers = $raw['referrers'] ?? [];
|
|
$totals = $raw['totals'] ?? ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0];
|
|
|
|
$totalUniques = 0;
|
|
foreach ($raw['uniques'] ?? [] as $uList) {
|
|
$totalUniques += count($uList);
|
|
}
|
|
$totals['uniques'] = $totalUniques;
|
|
|
|
$dailyChart = [];
|
|
foreach ($raw['days'] ?? [] as $date => $dData) {
|
|
$dailyChart[$date] = [
|
|
'date' => $date,
|
|
'views' => $dData['views'] ?? 0,
|
|
'human' => $dData['human'] ?? 0,
|
|
'uniques' => count($raw['uniques'][$date] ?? [])
|
|
];
|
|
}
|
|
ksort($dailyChart);
|
|
|
|
arsort($countries);
|
|
arsort($pages);
|
|
arsort($referrers);
|
|
|
|
return [
|
|
'totals' => $totals,
|
|
'countries' => $countries,
|
|
'pages' => $pages,
|
|
'referrers' => $referrers,
|
|
'daily_chart' => array_values($dailyChart)
|
|
];
|
|
}
|
|
|
|
// Filtered by last $days days
|
|
$cutoff = date('Y-m-d', strtotime("-{$days} days"));
|
|
$filteredCountries = [];
|
|
$filteredPages = [];
|
|
$filteredTotals = ['views' => 0, 'human' => 0, 'bot' => 0, 'blocked' => 0, 'uniques' => 0];
|
|
$dailyChart = [];
|
|
|
|
foreach ($raw['days'] ?? [] as $date => $dData) {
|
|
if ($date >= $cutoff) {
|
|
$v = $dData['views'] ?? 0;
|
|
$h = $dData['human'] ?? 0;
|
|
$b = $dData['bot'] ?? 0;
|
|
$bl = $dData['blocked'] ?? 0;
|
|
$u = count($raw['uniques'][$date] ?? []);
|
|
|
|
$filteredTotals['views'] += $v;
|
|
$filteredTotals['human'] += $h;
|
|
$filteredTotals['bot'] += $b;
|
|
$filteredTotals['blocked'] += $bl;
|
|
$filteredTotals['uniques'] += $u;
|
|
|
|
foreach ($dData['countries'] ?? [] as $c => $cnt) {
|
|
$filteredCountries[$c] = ($filteredCountries[$c] ?? 0) + $cnt;
|
|
}
|
|
foreach ($dData['pages'] ?? [] as $p => $cnt) {
|
|
$filteredPages[$p] = ($filteredPages[$p] ?? 0) + $cnt;
|
|
}
|
|
|
|
$dailyChart[$date] = [
|
|
'date' => $date,
|
|
'views' => $v,
|
|
'human' => $h,
|
|
'uniques' => $u
|
|
];
|
|
}
|
|
}
|
|
|
|
// Fill missing dates in range for smooth chart
|
|
for ($i = $days - 1; $i >= 0; $i--) {
|
|
$dStr = date('Y-m-d', strtotime("-{$i} days"));
|
|
if (!isset($dailyChart[$dStr])) {
|
|
$dailyChart[$dStr] = ['date' => $dStr, 'views' => 0, 'human' => 0, 'uniques' => 0];
|
|
}
|
|
}
|
|
ksort($dailyChart);
|
|
|
|
arsort($filteredCountries);
|
|
arsort($filteredPages);
|
|
|
|
$referrers = $raw['referrers'] ?? [];
|
|
arsort($referrers);
|
|
|
|
return [
|
|
'totals' => $filteredTotals,
|
|
'countries' => $filteredCountries,
|
|
'pages' => $filteredPages,
|
|
'referrers' => $referrers,
|
|
'daily_chart' => array_values($dailyChart)
|
|
];
|
|
}
|
|
}
|