'Googlebot', 'bingbot' => 'Bingbot', 'duckduckbot' => 'DuckDuckBot', 'yandexbot' => 'YandexBot', 'baiduspider' => 'Baiduspider', 'applebot' => 'Applebot', 'gptbot' => 'GPTBot', 'claudebot' => 'ClaudeBot', 'facebookexternalhit' => 'Facebook', 'ahrefsbot' => 'AhrefsBot', 'semrushbot' => 'SemrushBot', 'uptimerobot' => 'UptimeRobot', 'headlesschrome' => 'Headless Chrome', 'wp-cli' => 'WP-CLI', 'wordpress/' => 'WordPress', 'curl/' => 'curl', 'wget/' => 'Wget', 'python-requests' => 'Python', 'python-urllib' => 'Python', 'go-http-client' => 'Go', 'okhttp' => 'OkHttp', 'java/' => 'Java', 'libwww-perl' => 'Perl', 'axios/' => 'axios', 'node-fetch' => 'Node', 'postmanruntime' => 'Postman', ); /** * Any other self-declared crawler: "SomethingBot/1.0", "bot" as a word, * "crawler", "spider". Not a bare "bot" substring -- the CUBOT phone * brand is a person's Android. */ private const GENERIC_BOT = '#(?:\bbot\b|[\w-]+bot/|crawler|spider)#i'; /** * Browser families, most specific first: Edge, Opera, Samsung Internet * and every iOS browser also say "Safari", and most also say "Chrome". * Each pattern captures the major version. */ private const BROWSERS = array( 'Edge' => '#\b(?:Edg|EdgA|EdgiOS)/(\d{1,4})#', 'Opera' => '#\b(?:OPR|OPiOS)/(\d{1,4})#', 'Samsung Internet' => '#\bSamsungBrowser/(\d{1,4})#', 'Firefox' => '#\b(?:Firefox|FxiOS)/(\d{1,4})#', 'Chrome' => '#\b(?:CriOS|Chrome)/(\d{1,4})#', 'Safari' => '#\bVersion/(\d{1,4})[.\d]* (?:Mobile/\S+ )?Safari/#', ); /** * The device context a user-agent string describes, or null when there * is none to read (no string: WP-CLI, cron, a bare socket). * * @param string $user_agent The User-Agent header, unslashed. * @return array{device_class: string, browser: string, os: string}|null */ public static function parse( string $user_agent ): ?array { $ua = trim( substr( $user_agent, 0, self::MAX_LENGTH ) ); if ( '' === $ua ) { return null; } $os = self::os( $ua ); $bot = self::bot( $ua ); if ( null !== $bot ) { return array( 'device_class' => 'bot', 'browser' => $bot, 'os' => $os, ); } return array( 'device_class' => self::device_class( $ua ), 'browser' => self::browser( $ua ), 'os' => $os, ); } /** * The bot label, or null for a person's browser. * * @param string $ua User agent. */ private static function bot( string $ua ): ?string { $lower = strtolower( $ua ); foreach ( self::BOTS as $needle => $label ) { if ( str_contains( $lower, $needle ) ) { return $label; } } return 1 === preg_match( self::GENERIC_BOT, $ua ) ? 'Bot' : null; } /** * Browser family and major version ("Chrome 129"), or "Other". * * @param string $ua User agent. */ private static function browser( string $ua ): string { foreach ( self::BROWSERS as $family => $pattern ) { if ( 1 === preg_match( $pattern, $ua, $m ) ) { return $family . ' ' . $m[1]; } } return 'Other'; } /** * Operating system family, with a major version where the string carries * a real one. * * @param string $ua User agent. */ private static function os( string $ua ): string { // iOS before macOS: iPhone and iPad strings say "like Mac OS X". if ( 1 === preg_match( '#\b(?:iPhone|iPad|iPod)\b.*? OS (\d{1,3})_#', $ua, $m ) ) { return 'iOS ' . $m[1]; } if ( 1 === preg_match( '#\bAndroid (\d+)(?:[.\d]*); K\)#', $ua ) ) { // Chrome's reduced user agent: the version is frozen, not real. return 'Android'; } if ( 1 === preg_match( '#\bAndroid (\d{1,3})#', $ua, $m ) ) { return 'Android ' . $m[1]; } if ( str_contains( $ua, 'Android' ) ) { return 'Android'; } if ( 1 === preg_match( '#\bWindows NT (\d+\.\d+)#', $ua, $m ) ) { $versions = array( '10.0' => 'Windows 10/11', '6.3' => 'Windows 8.1', '6.2' => 'Windows 8', '6.1' => 'Windows 7', ); return $versions[ $m[1] ] ?? 'Windows'; } if ( str_contains( $ua, 'Windows' ) ) { return 'Windows'; } if ( str_contains( $ua, 'CrOS' ) ) { return 'ChromeOS'; } // Every current macOS browser freezes the version at 10_15_7. if ( str_contains( $ua, 'Macintosh' ) || str_contains( $ua, 'Mac OS X' ) ) { return 'macOS'; } if ( str_contains( $ua, 'Linux' ) || str_contains( $ua, 'X11' ) ) { return 'Linux'; } return 'Other'; } /** * Desktop, mobile or tablet for a person's browser. * * An Android string without "Mobile" is a tablet: that is Google's own * documented convention for telling them apart. * * @param string $ua User agent. */ private static function device_class( string $ua ): string { if ( 1 === preg_match( '#\b(?:iPad|Tablet|Kindle|Silk|PlayBook)\b#', $ua ) ) { return 'tablet'; } if ( str_contains( $ua, 'Android' ) ) { return str_contains( $ua, 'Mobile' ) ? 'mobile' : 'tablet'; } if ( 1 === preg_match( '#\b(?:iPhone|iPod|Mobile|Windows Phone|BlackBerry|Opera Mini)\b#', $ua ) ) { return 'mobile'; } return 'desktop'; } }