feat: add VisitorStats class for real human traffic analytics

JS-verified, bot-filtered visitor counts, daily charts, country/device
breakdowns, period comparisons. Used by SEO dashboard and PS main dashboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 16:13:06 +00:00
parent 24130e28e0
commit 633d56a39e

253
src/VisitorStats.php Normal file
View File

@@ -0,0 +1,253 @@
<?php
/**
* Visitor Statistics — Real Human Traffic Data
*
* Queries the mpr_sessions table (JS-verified, bot-filtered) to provide
* accurate real human visitor counts, charts, and breakdowns.
*
* @author mypresta.rocks
* @copyright mypresta.rocks
* @license Commercial License
*/
namespace MyPrestaRocks\Session;
class VisitorStats
{
/**
* Base WHERE clause for real human visitors.
*/
private static function baseFilter(string $alias = 's'): string
{
$a = $alias ? $alias . '.' : '';
return "{$a}is_bot = 0
AND {$a}js_verified = 1
AND {$a}pages_viewed >= 1
AND {$a}ip_address NOT LIKE '172.16.%'
AND {$a}ip_address NOT LIKE '172.17.%'
AND {$a}user_agent NOT LIKE '%bot%'
AND {$a}user_agent NOT LIKE '%crawl%'
AND {$a}user_agent NOT LIKE '%spider%'
AND {$a}user_agent NOT LIKE '%Chrome-Lighthouse%'
AND {$a}user_agent NOT LIKE '%GoogleOther%'
AND {$a}user_agent NOT LIKE '%externalhit%'";
}
/**
* Check if the sessions table exists.
*/
public static function isAvailable(): bool
{
return (bool) \Db::getInstance()->getValue(
"SELECT 1 FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '" . _DB_PREFIX_ . "mpr_sessions'"
);
}
/**
* Get daily visitor data for charting.
*
* @param int $days Number of days to look back
* @return array ['labels' => [...], 'visitors' => [...], 'sessions' => [...], 'pageviews' => [...]]
*/
public static function getDailyTraffic(int $days = 28): array
{
$db = \Db::getInstance((bool) _PS_USE_SQL_SLAVE_);
$prefix = _DB_PREFIX_;
$filter = self::baseFilter('');
$rows = $db->executeS(
"SELECT DATE(date_add) as day,
COUNT(DISTINCT ip_address) as visitors,
COUNT(*) as sessions,
SUM(pages_viewed) as pageviews
FROM `{$prefix}mpr_sessions`
WHERE {$filter}
AND date_add >= DATE_SUB(CURDATE(), INTERVAL {$days} DAY)
GROUP BY DATE(date_add)
ORDER BY day ASC"
);
// Fill in missing days with zeros
$data = [];
if ($rows) {
foreach ($rows as $r) {
$data[$r['day']] = $r;
}
}
$labels = [];
$visitors = [];
$sessions = [];
$pageviews = [];
$date = new \DateTime("-{$days} days");
$today = new \DateTime('today');
while ($date <= $today) {
$key = $date->format('Y-m-d');
$labels[] = $date->format('m/d');
$visitors[] = isset($data[$key]) ? (int) $data[$key]['visitors'] : 0;
$sessions[] = isset($data[$key]) ? (int) $data[$key]['sessions'] : 0;
$pageviews[] = isset($data[$key]) ? (int) $data[$key]['pageviews'] : 0;
$date->modify('+1 day');
}
return [
'labels' => $labels,
'visitors' => $visitors,
'sessions' => $sessions,
'pageviews' => $pageviews,
];
}
/**
* Get overview KPIs for a period, with comparison to previous period.
*
* @param int $days Number of days for current period
* @return array ['current' => [...], 'previous' => [...], 'deltas' => [...]]
*/
public static function getOverview(int $days = 28): array
{
$current = self::getPeriodStats($days, 0);
$previous = self::getPeriodStats($days, $days);
$deltas = [];
foreach ($current as $key => $val) {
$prev = $previous[$key] ?? 0;
if ($prev > 0) {
$deltas[$key] = round((($val - $prev) / $prev) * 100, 1);
} else {
$deltas[$key] = $val > 0 ? 100.0 : 0.0;
}
}
return [
'current' => $current,
'previous' => $previous,
'deltas' => $deltas,
];
}
/**
* Get stats for a single period.
*
* @param int $days Period length
* @param int $offset Days offset from today (0 = current period)
*/
private static function getPeriodStats(int $days, int $offset): array
{
$db = \Db::getInstance((bool) _PS_USE_SQL_SLAVE_);
$prefix = _DB_PREFIX_;
$filter = self::baseFilter('');
$startOffset = $days + $offset;
$endOffset = $offset;
$dateStart = $endOffset > 0
? "DATE_SUB(CURDATE(), INTERVAL {$startOffset} DAY)"
: "DATE_SUB(CURDATE(), INTERVAL {$days} DAY)";
$dateEnd = $endOffset > 0
? "DATE_SUB(CURDATE(), INTERVAL {$endOffset} DAY)"
: "NOW()";
$row = $db->getRow(
"SELECT COUNT(DISTINCT ip_address) as visitors,
COUNT(*) as sessions,
SUM(pages_viewed) as pageviews,
ROUND(AVG(pages_viewed), 1) as pages_per_session,
COUNT(DISTINCT CASE WHEN pages_viewed >= 2 THEN ip_address END) as engaged_visitors
FROM `{$prefix}mpr_sessions`
WHERE {$filter}
AND date_add >= {$dateStart}
AND date_add < {$dateEnd}"
);
return [
'visitors' => (int) ($row['visitors'] ?? 0),
'sessions' => (int) ($row['sessions'] ?? 0),
'pageviews' => (int) ($row['pageviews'] ?? 0),
'pages_per_session' => (float) ($row['pages_per_session'] ?? 0),
'engaged_visitors' => (int) ($row['engaged_visitors'] ?? 0),
];
}
/**
* Get today's stats (for dashboard widget).
*/
public static function getToday(): array
{
$db = \Db::getInstance((bool) _PS_USE_SQL_SLAVE_);
$prefix = _DB_PREFIX_;
$filter = self::baseFilter('');
$row = $db->getRow(
"SELECT COUNT(DISTINCT ip_address) as visitors,
COUNT(*) as sessions,
SUM(pages_viewed) as pageviews
FROM `{$prefix}mpr_sessions`
WHERE {$filter}
AND date_add >= CURDATE()"
);
return [
'visitors' => (int) ($row['visitors'] ?? 0),
'sessions' => (int) ($row['sessions'] ?? 0),
'pageviews' => (int) ($row['pageviews'] ?? 0),
];
}
/**
* Get traffic by country.
*/
public static function getByCountry(int $days = 28, int $limit = 10): array
{
$db = \Db::getInstance((bool) _PS_USE_SQL_SLAVE_);
$prefix = _DB_PREFIX_;
$filter = self::baseFilter('');
return $db->executeS(
"SELECT country_code, COUNT(DISTINCT ip_address) as visitors, COUNT(*) as sessions
FROM `{$prefix}mpr_sessions`
WHERE {$filter}
AND date_add >= DATE_SUB(CURDATE(), INTERVAL {$days} DAY)
AND country_code IS NOT NULL AND country_code != ''
GROUP BY country_code
ORDER BY visitors DESC
LIMIT {$limit}"
) ?: [];
}
/**
* Get traffic by device type.
*/
public static function getByDevice(int $days = 28): array
{
$db = \Db::getInstance((bool) _PS_USE_SQL_SLAVE_);
$prefix = _DB_PREFIX_;
$filter = self::baseFilter('');
return $db->executeS(
"SELECT device_type, COUNT(DISTINCT ip_address) as visitors, COUNT(*) as sessions
FROM `{$prefix}mpr_sessions`
WHERE {$filter}
AND date_add >= DATE_SUB(CURDATE(), INTERVAL {$days} DAY)
GROUP BY device_type
ORDER BY visitors DESC"
) ?: [];
}
/**
* Get full dashboard payload (for AJAX).
*/
public static function getDashboardData(int $days = 28): array
{
return [
'overview' => self::getOverview($days),
'daily' => self::getDailyTraffic($days),
'today' => self::getToday(),
'countries' => self::getByCountry($days),
'devices' => self::getByDevice($days),
];
}
}