mdn-backend / app /Domain /Analytics /AnalyticsBotDetector.php
internationalscholarsprogram's picture
feat(analytics): website analytics pipeline β€” ingestion, rollup, admin query
45dc401
Raw
History Blame Contribute Delete
1.89 kB
<?php
declare(strict_types=1);
namespace App\Domain\Analytics;
/**
* UA-based bot heuristic. Returns a score 0–100.
* 0 = confident human.
* 100 = definite bot.
* Callers should treat >= 80 as "bot" and discard or flag the event.
* The score is stored on analytics_events.bot_score so the threshold
* can be adjusted in queries without re-processing events.
*/
final class AnalyticsBotDetector
{
/** Known crawler / monitoring user-agent substrings (lowercase). */
private const CRAWLER_PATTERNS = [
'bot', 'crawler', 'spider', 'scraper', 'slurp', 'facebookexternalhit',
'googlebot', 'bingbot', 'yandex', 'baiduspider', 'duckduckbot', 'applebot',
'twitterbot', 'linkedinbot', 'whatsapp', 'discordbot', 'telegrambot',
'headlesschrome', 'phantomjs', 'selenium', 'puppeteer', 'playwright',
'wget', 'curl', 'python-requests', 'go-http-client', 'java/', 'okhttp',
'axios/', 'node-fetch', 'libwww-perl', 'httrack', 'archive.org',
'pingdom', 'uptime', 'statuspage', 'newrelic', 'datadog', 'nagios',
'prerender', 'rendertron', 'netlify', 'vercel-monitoring',
];
public static function score(string $ua): int
{
if (trim($ua) === '') {
return 100;
}
$lower = mb_strtolower($ua);
foreach (self::CRAWLER_PATTERNS as $pattern) {
if (str_contains($lower, $pattern)) {
return 100;
}
}
// Suspiciously short or simplistic UAs
if (mb_strlen($ua) < 20) {
return 90;
}
// No recognisable browser token β€” likely a non-browser HTTP client
$hasBrowserToken = (bool) preg_match(
'/(Mozilla|Chrome|Safari|Firefox|Edge|Opera|Trident)/i',
$ua,
);
if (!$hasBrowserToken) {
return 70;
}
return 0;
}
}