= 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; } }