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:
253
src/VisitorStats.php
Normal file
253
src/VisitorStats.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user