diff --git a/.vale/styles/config/vocabularies/Utopia/accept.txt b/.vale/styles/config/vocabularies/Utopia/accept.txt index 831b5da0..c15fcff2 100644 --- a/.vale/styles/config/vocabularies/Utopia/accept.txt +++ b/.vale/styles/config/vocabularies/Utopia/accept.txt @@ -43,6 +43,39 @@ Fux Dittmann Torsten United States +Wget +aiohttp +Axios +HTTPie +GPTBot +ClaudeBot +PerplexityBot +OkHttp +Guzzle +macOS +iOS +iPadOS +tvOS +watchOS +HarmonyOS +webOS +Sailfish +KaiOS +BlackBerry +PlayStation +Nintendo +Ubuntu +Debian +Fedora +Vivaldi +Yandex +DuckDuckGo +Huawei +Coc +Silk +WebView +QQ +UC # Protocols, formats, acronyms OAuth diff --git a/packages/user-agent/CHANGELOG.md b/packages/user-agent/CHANGELOG.md index 9f04ca16..3a598544 100644 --- a/packages/user-agent/CHANGELOG.md +++ b/packages/user-agent/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.2.0 + +- Expanded operating-system coverage: iPadOS, tvOS, watchOS, HarmonyOS, + OpenHarmony, Fire OS, webOS, Sailfish, BlackBerry, PlayStation, Nintendo, + and popular Linux distributions (Debian, Fedora, Arch Linux, Mint, and + more). +- Expanded browser coverage: Opera Mobile, Brave, Vivaldi, Yandex Browser, + UC Browser, DuckDuckGo, QQ Browser, Coc Coc, Whale, Huawei Browser, Amazon + Silk, and Firefox Focus, all resolved ahead of generic Chrome and WebView + rules. +- Expanded HTTP-library coverage: Python urllib, aiohttp, Go-http-client, + Node Fetch, Axios, HTTPie, Apache HTTP Client, Java, and more. +- Expanded device-brand coverage: Realme, Asus, Tecno, Infinix, Honor, HTC, + Lenovo, ZTE, TCL, Meizu, Nothing, Fairphone, Alcatel, and LG/Sony smart + TVs. +- Expanded crawler coverage: PerplexityBot, Google Extended, GoogleOther, + Meta External Agent, CCBot, YouBot, Yahoo Slurp, SeznamBot, Pinterest, + and more, with narrowed matching so browsers such as Sogou and the + Pinterest app are not misclassified as bots. +- Short-name codes stay aligned with Matomo DeviceDetector so the added + categories match its reference output. + ## 0.1.0 - Added lazy, memoized operating-system, client, device, and bot detection. diff --git a/packages/user-agent/README.md b/packages/user-agent/README.md index b64e3b29..97e602fe 100644 --- a/packages/user-agent/README.md +++ b/packages/user-agent/README.md @@ -111,16 +111,23 @@ $data = $agent->toArray(); ## Detection coverage -The initial rule set covers common user agents: - -- Windows, Windows Phone, macOS, iOS, Android, Chrome OS, Linux, Ubuntu, - Tizen, and KaiOS -- Chrome, Safari, Firefox, Edge, Opera, Samsung Internet, Android WebView, - Internet Explorer, and their common mobile variants -- Common HTTP libraries +The rule set covers common user agents: + +- Windows, Windows Phone, macOS, iOS, iPadOS, tvOS, watchOS, Android, + Fire OS, Chrome OS, HarmonyOS, webOS, Sailfish, Tizen, KaiOS, BlackBerry, + PlayStation, Nintendo, and popular Linux distributions (Ubuntu, Debian, + Fedora, Arch Linux, Mint, and more) +- Chrome, Safari, Firefox, Edge, Opera, Opera Mobile, Samsung Internet, + Brave, Vivaldi, Yandex Browser, UC Browser, DuckDuckGo, QQ Browser, + Coc Coc, Whale, Huawei Browser, Amazon Silk, Firefox Focus, Android + WebView, Internet Explorer, and their common mobile variants +- Common HTTP libraries (curl, Wget, OkHttp, Guzzle, Python Requests, + aiohttp, Go-http-client, Node Fetch, Axios, Java, HTTPie, and more) - Desktop, smartphone, tablet, TV, console, wearable, and portable-media - device classes with common mobile brands and models + device classes with common device brands and models - Search, social-preview, monitoring, automation, SEO, and AI crawlers + (including GPTBot, ClaudeBot, PerplexityBot, Google Extended, and other + large-language-model agents) Core result fields are regression-tested against Matomo DeviceDetector 6.4 for representative profiles. The runtime does not contain or load Matomo's LGPL diff --git a/packages/user-agent/benchmark/benchmark.php b/packages/user-agent/benchmark/benchmark.php index dfc59e89..ddaa09b0 100644 --- a/packages/user-agent/benchmark/benchmark.php +++ b/packages/user-agent/benchmark/benchmark.php @@ -14,7 +14,11 @@ 'Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro Build/TQ3A.230805.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', 'Mozilla/5.0 (Linux; Android 12; SM-T970 Build/SP1A.210812.016) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0', + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 YaBrowser/23.7.1.1140 Safari/537.36', + 'Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0 Debian/102.0', 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', + 'Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)', 'PostmanRuntime/7.43.0', 'curl/8.7.1', 'UNKNOWN', diff --git a/packages/user-agent/src/UserAgent/Detection/BotDetector.php b/packages/user-agent/src/UserAgent/Detection/BotDetector.php index 75465379..d4d98056 100644 --- a/packages/user-agent/src/UserAgent/Detection/BotDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/BotDetector.php @@ -12,34 +12,73 @@ final class BotDetector private const array BOTS = [ 'googlebot' => ['Googlebot', 'search crawler'], 'google-inspectiontool' => ['Google Inspection Tool', 'search crawler'], + 'googleother' => ['GoogleOther', 'search crawler'], + 'google-extended' => ['Google Extended', 'ai crawler'], + 'storebot-google' => ['Google StoreBot', 'search crawler'], 'adsbot-google' => ['Google AdsBot', 'advertising crawler'], + 'mediapartners-google' => ['Google AdSense', 'advertising crawler'], 'bingbot' => ['Bingbot', 'search crawler'], + 'bingpreview' => ['Bing Preview', 'search crawler'], 'duckduckbot' => ['DuckDuckBot', 'search crawler'], + 'duckassistbot' => ['DuckAssistBot', 'ai crawler'], 'baiduspider' => ['Baiduspider', 'search crawler'], 'yandexbot' => ['YandexBot', 'search crawler'], + 'yandeximages' => ['YandexImages', 'search crawler'], + 'slurp' => ['Yahoo! Slurp', 'search crawler'], + 'seznambot' => ['SeznamBot', 'search crawler'], + 'sogou web spider' => ['Sogou Spider', 'search crawler'], + 'exabot' => ['Exabot', 'search crawler'], + 'yeti' => ['Naver Bot', 'search crawler'], + 'yisouspider' => ['YisouSpider', 'search crawler'], + 'applebot-extended' => ['Applebot Extended', 'ai crawler'], 'applebot' => ['Applebot', 'search crawler'], 'petalbot' => ['PetalBot', 'search crawler'], 'facebookexternalhit' => ['Facebook External Hit', 'social preview'], + 'facebookbot' => ['Facebook Bot', 'social preview'], 'facebot' => ['Facebook Bot', 'social preview'], + 'meta-externalagent' => ['Meta External Agent', 'ai crawler'], + 'meta-externalfetcher' => ['Meta External Fetcher', 'social preview'], 'twitterbot' => ['Twitterbot', 'social preview'], 'linkedinbot' => ['LinkedInBot', 'social preview'], 'slackbot' => ['Slackbot', 'social preview'], 'discordbot' => ['Discordbot', 'social preview'], - 'whatsapp' => ['WhatsApp', 'social preview'], 'telegrambot' => ['TelegramBot', 'social preview'], + 'pinterestbot' => ['Pinterestbot', 'social preview'], + 'pinterest/0.' => ['Pinterest', 'social preview'], + 'redditbot' => ['Redditbot', 'social preview'], 'ahrefsbot' => ['AhrefsBot', 'site crawler'], 'semrushbot' => ['SemrushBot', 'site crawler'], 'mj12bot' => ['MJ12bot', 'site crawler'], + 'dotbot' => ['DotBot', 'site crawler'], + 'dataforseobot' => ['DataForSeoBot', 'site crawler'], + 'blexbot' => ['BLEXBot', 'site crawler'], + 'screaming frog' => ['Screaming Frog SEO Spider', 'site crawler'], 'gptbot' => ['GPTBot', 'ai crawler'], + 'oai-searchbot' => ['OAI SearchBot', 'ai crawler'], 'chatgpt-user' => ['ChatGPT User', 'ai assistant'], 'claudebot' => ['ClaudeBot', 'ai crawler'], + 'claude-user' => ['Claude User', 'ai assistant'], + 'claude-searchbot' => ['Claude SearchBot', 'ai crawler'], 'claude-web' => ['Claude Web', 'ai assistant'], + 'anthropic-ai' => ['Anthropic AI', 'ai crawler'], + 'perplexitybot' => ['PerplexityBot', 'ai crawler'], + 'perplexity-user' => ['Perplexity User', 'ai assistant'], 'amazonbot' => ['Amazonbot', 'ai crawler'], 'bytespider' => ['Bytespider', 'ai crawler'], + 'ccbot' => ['CCBot', 'ai crawler'], + 'youbot' => ['YouBot', 'ai crawler'], + 'cohere-ai' => ['Cohere AI', 'ai crawler'], + 'cohere-training-data-crawler' => ['Cohere', 'ai crawler'], + 'diffbot' => ['Diffbot', 'ai crawler'], + 'imagesiftbot' => ['ImageSift Bot', 'ai crawler'], + 'timpibot' => ['Timpibot', 'ai crawler'], 'headlesschrome' => ['Headless Chrome', 'automation'], 'phantomjs' => ['PhantomJS', 'automation'], 'lighthouse' => ['Lighthouse', 'site monitor'], 'uptimerobot' => ['UptimeRobot', 'site monitor'], + 'pingdom' => ['Pingdom', 'site monitor'], + 'statuscake' => ['StatusCake', 'site monitor'], + 'gtmetrix' => ['GTmetrix', 'site monitor'], ]; public static function detect(string $userAgent): ?Bot diff --git a/packages/user-agent/src/UserAgent/Detection/ClientDetector.php b/packages/user-agent/src/UserAgent/Detection/ClientDetector.php index 2f498c1c..ddaed6cf 100644 --- a/packages/user-agent/src/UserAgent/Detection/ClientDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/ClientDetector.php @@ -19,6 +19,7 @@ public static function detect(string $userAgent): Client ?? self::samsung($userAgent) ?? self::chromeIos($userAgent) ?? self::firefoxIos($userAgent) + ?? self::derivative($userAgent) ?? self::androidWebView($userAgent) ?? self::chrome($userAgent) ?? self::firefox($userAgent) @@ -29,6 +30,76 @@ public static function detect(string $userAgent): Client return $client ?? new Client(); } + /** + * Chromium-based browsers that ship their own token. These must resolve + * before the generic Chrome and Android WebView rules, because their + * user-agent strings also carry a `Chrome/` token (and sometimes the + * `Version/4.0` WebView marker). The rendering engine is Blink and its + * version follows the embedded Chrome token. + */ + private static function derivative(string $userAgent): ?Client + { + /** @var array $blink */ + $blink = [ + '/coc_coc_browser\/([0-9.]+)/i' => ['CC', 'Coc Coc'], + '/Vivaldi\/([0-9.]+)/i' => ['VI', 'Vivaldi'], + '/YaBrowser\/([0-9.]+)/i' => ['YA', 'Yandex Browser'], + '/Brave\/([0-9.]+)/i' => ['BR', 'Brave'], + '/Whale\/([0-9.]+)/i' => ['WH', 'Whale Browser'], + '/UCBrowser\/([0-9.]+)/i' => ['UC', 'UC Browser'], + '/(?:MQQBrowser|QQBrowser)\/([0-9.]+)/i' => ['QQ', 'QQ Browser'], + '/DuckDuckGo\/([0-9.]+)/i' => ['DD', 'DuckDuckGo Privacy Browser'], + '/Silk\/([0-9.]+)/i' => ['MS', 'Mobile Silk'], + ]; + + foreach ($blink as $pattern => [$code, $name]) { + if (preg_match($pattern, $userAgent, $matches) === 1) { + return self::derivativeClient($userAgent, $code, $name, $matches[1]); + } + } + + // Firefox Focus reports as Blink only when it embeds a Chrome token. + if (preg_match('/Focus\/([0-9.]+)/i', $userAgent, $matches) === 1 + && self::tokenVersion($userAgent, 'Chrome') !== null) { + return self::derivativeClient($userAgent, 'FK', 'Firefox Focus', $matches[1]); + } + + // Huawei Browser distinguishes its mobile build by name. + if (preg_match('/HuaweiBrowser\/([0-9.]+)/i', $userAgent, $matches) === 1) { + $mobile = stripos($userAgent, 'Mobile') !== false; + + return self::derivativeClient( + $userAgent, + $mobile ? 'HU' : 'HP', + $mobile ? 'Huawei Browser Mobile' : 'Huawei Browser', + $matches[1], + ); + } + + return null; + } + + /** + * Build a client for a Chromium-based derivative. When the user-agent + * carries a `Chrome/` token the engine is Blink at that version; the older + * Amazon Silk builds carry no Chrome token, so their engine is the embedded + * WebKit instead. The engine is left unknown when neither token is present. + */ + private static function derivativeClient(string $userAgent, string $code, string $name, string $version): Client + { + $chrome = self::tokenVersion($userAgent, 'Chrome'); + if ($chrome !== null) { + return new Client('browser', $code, $name, self::displayVersion($version), 'Blink', $chrome); + } + + $webKit = self::tokenVersion($userAgent, 'AppleWebKit'); + if ($webKit !== null) { + return new Client('browser', $code, $name, self::displayVersion($version), 'WebKit', $webKit); + } + + return new Client('browser', $code, $name, self::displayVersion($version)); + } + private static function edge(string $userAgent): ?Client { if (preg_match('/(?:EdgA|EdgiOS|Edg|Edge)\/([0-9.]+)/i', $userAgent, $matches) !== 1) { @@ -58,8 +129,16 @@ private static function opera(string $userAgent): ?Client } $engineVersion = self::tokenVersion($userAgent, 'Chrome') ?? self::version($matches[1]); + $mobile = stripos($userAgent, 'Mobile') !== false || stripos($userAgent, 'Opera Mobi') !== false; - return new Client('browser', 'OP', 'Opera', self::displayVersion($matches[1]), 'Blink', $engineVersion); + return new Client( + 'browser', + $mobile ? 'OM' : 'OP', + $mobile ? 'Opera Mobile' : 'Opera', + self::displayVersion($matches[1]), + 'Blink', + $engineVersion, + ); } private static function samsung(string $userAgent): ?Client @@ -205,6 +284,16 @@ private static function library(string $userAgent): ?Client '/Dart\/([0-9.]+)/i' => 'Dart', '/GuzzleHttp\/([0-9.]+)/i' => 'Guzzle', '/python-requests\/([0-9.]+)/i' => 'Python Requests', + '/Python-urllib\/?([0-9.]*)/i' => 'Python urllib', + '/aiohttp\/([0-9.]+)/i' => 'aiohttp', + '/Go-http-client\/([0-9.]+)/i' => 'Go-http-client', + '/node-fetch\/([0-9.]+)/i' => 'Node Fetch', + '/axios\/([0-9.]+)/i' => 'Axios', + '/HTTPie\/([0-9.]+)/i' => 'HTTPie', + '/Apache-HttpClient\/([0-9.]+)/i' => 'Apache HTTP Client', + '/Java-http-client\/([0-9.]+)/i' => 'Java HTTP Client', + '/Java\/([0-9._]+)/i' => 'Java', + '/got\/([0-9.]+)/i' => 'got', ]; foreach ($libraries as $pattern => $name) { diff --git a/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php b/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php index c1b1e767..e3cf603f 100644 --- a/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php @@ -49,13 +49,27 @@ private static function television(string $userAgent): ?Device return new Device('tv', 'Apple', 'Apple TV'); } - if (preg_match('/(?:Smart-?TV|SMARTTV|HbbTV|GoogleTV|Android TV|BRAVIA|NetCast|Tizen TV|webOS)/i', $userAgent) === 1) { - return new Device('tv', self::brand($userAgent)); + if (preg_match('/(?:Smart-?TV|SMARTTV|HbbTV|GoogleTV|Android TV|BRAVIA|NetCast|Tizen TV|web0S|webOS)/i', $userAgent) === 1) { + return new Device('tv', self::televisionBrand($userAgent)); } return null; } + private static function televisionBrand(string $userAgent): ?string + { + // LG smart TVs run webOS/NetCast; Samsung TVs report Tizen. + if (preg_match('/(?:web0S|webOS|NetCast|\bLG\b)/i', $userAgent) === 1) { + return 'LG'; + } + + if (preg_match('/(?:Tizen|BRAVIA)/i', $userAgent) === 1) { + return preg_match('/BRAVIA/i', $userAgent) === 1 ? 'Sony' : 'Samsung'; + } + + return self::brand($userAgent); + } + private static function apple(string $userAgent): ?Device { if (stripos($userAgent, 'iPad') !== false) { @@ -170,14 +184,27 @@ private static function brand(string $userAgent, ?string $model = null): ?string 'Samsung' => '/(?:\bSM-[A-Z0-9]+|Samsung)/i', 'Google' => '/(?:\bPixel\b|Nexus)/i', 'Huawei' => '/(?:Huawei|\bHUAWEI\b|\bANE-|\bELE-|\bVOG-)/i', - 'Honor' => '/(?:Honor|\bHLK-|\bBKL-)/i', - 'Xiaomi' => '/(?:Xiaomi|Redmi|\bMi [A-Z0-9])/i', + // Match the "HONOR" brand token (uppercase) or "Honor " so the + // common word "honor" in app or build tokens is not treated as a brand. + 'Honor' => '/(?:(?-i:\bHONOR\b)|\bHonor[ _-](?:[0-9]|[XV][0-9]|Play|Magic|View|Note|Pad|Tablet)|\bHLK-|\bBKL-)/i', + 'Xiaomi' => '/(?:Xiaomi|Redmi|POCO|\bMi [A-Z0-9])/i', 'OnePlus' => '/(?:OnePlus|\bONEPLUS\b)/i', 'Oppo' => '/(?:\bOPPO\b|\bCPH[0-9]+)/i', + 'Realme' => '/(?:realme|\bRMX[0-9]{4}\b)/i', 'Vivo' => '/(?:\bvivo\b|\bV[0-9]{4})/i', 'Motorola' => '/(?:Motorola|\bmoto\b|\bXT[0-9]{4})/i', + 'Asus' => '/(?:\bASUS)/i', + 'Tecno' => '/(?:\bTECNO\b)/i', + 'Infinix' => '/(?:Infinix)/i', 'Nokia' => '/Nokia/i', 'Sony' => '/(?:Sony|Xperia)/i', + 'HTC' => '/(?:\bHTC\b)/i', + 'Lenovo' => '/(?:Lenovo|\bLenovo )/i', + 'ZTE' => '/(?:\bZTE\b)/i', + 'TCL' => '/(?:\bTCL\b)/i', + 'Meizu' => '/(?:Meizu)/i', + 'Fairphone' => '/(?:Fairphone|\bFP[0-9]\b)/i', + 'Alcatel' => '/(?:Alcatel)/i', 'LG' => '/(?:\bLG[- ]|\bLM-[A-Z0-9]+)/i', 'Amazon' => '/(?:Kindle|Silk\/|\bKF[A-Z0-9]+)/i', ]; diff --git a/packages/user-agent/src/UserAgent/Detection/OperatingSystemDetector.php b/packages/user-agent/src/UserAgent/Detection/OperatingSystemDetector.php index 60268fbd..7716913f 100644 --- a/packages/user-agent/src/UserAgent/Detection/OperatingSystemDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/OperatingSystemDetector.php @@ -21,6 +21,34 @@ final class OperatingSystemDetector '5.0' => '2000', ]; + /** + * GNU/Linux distributions keyed by short code. Each entry is the display + * name and a token that identifies the distribution. Ordered so that more + * specific names win over generic ones. + * + * @var array + */ + private const array LINUX_DISTROS = [ + 'UBT' => ['Ubuntu', 'Ubuntu'], + 'KBT' => ['Kubuntu', 'Kubuntu'], + 'XBT' => ['Xubuntu', 'Xubuntu'], + 'LBT' => ['Lubuntu', 'Lubuntu'], + 'MIN' => ['Mint', 'Linux Mint'], + 'DEB' => ['Debian', 'Debian'], + 'KAL' => ['Kali', 'Kali'], + 'RAS' => ['Raspbian', 'Raspbian'], + 'FED' => ['Fedora', 'Fedora'], + 'RHT' => ['Red Hat', 'Red Hat'], + 'CES' => ['CentOS', 'CentOS'], + 'ROC' => ['Rocky Linux', 'Rocky'], + 'ARL' => ['Arch Linux', 'Arch'], + 'MJR' => ['Manjaro', 'Manjaro'], + 'GNT' => ['Gentoo', 'Gentoo'], + 'SLW' => ['Slackware', 'Slackware'], + 'SSE' => ['SUSE', 'SUSE'], + 'ORA' => ['Oracle Linux', 'Oracle'], + ]; + public static function detect(string $userAgent): OperatingSystem { if ($userAgent === '') { @@ -37,37 +65,80 @@ public static function detect(string $userAgent): OperatingSystem return new OperatingSystem('WIN', 'Windows', $version); } - if (preg_match('/(?:CPU (?:iPhone )?OS|iPhone OS)[ \/]([0-9_]+)/i', $userAgent, $matches) === 1) { - return new OperatingSystem('IOS', 'iOS', self::version($matches[1])); + if (stripos($userAgent, 'OpenHarmony') !== false) { + return new OperatingSystem('OHS', 'OpenHarmony', self::token($userAgent, 'OpenHarmony')); + } + + if (stripos($userAgent, 'HarmonyOS') !== false) { + return new OperatingSystem('HAR', 'HarmonyOS', self::token($userAgent, 'HarmonyOS')); + } + + $apple = self::apple($userAgent); + if ($apple instanceof OperatingSystem) { + return $apple; + } + + // Amazon's Fire OS is Android based, so it must resolve before Android. + if (self::isFireOs($userAgent)) { + return new OperatingSystem('FIR', 'Fire OS'); } if (preg_match('/Android(?: |\/)([0-9][0-9._-]*)/i', $userAgent, $matches) === 1) { return new OperatingSystem('AND', 'Android', self::version($matches[1])); } - if (preg_match('/CrOS [^ )]+ ([0-9.]+)/i', $userAgent, $matches) === 1) { - return new OperatingSystem('COS', 'Chrome OS', self::version($matches[1])); + if (stripos($userAgent, 'Android') !== false) { + return new OperatingSystem('AND', 'Android'); } - if (preg_match('/Mac OS X[ \/]([0-9_\.]+)/i', $userAgent, $matches) === 1) { - return new OperatingSystem('MAC', 'Mac', self::displayVersion($matches[1])); + if (preg_match('/KaiOS[ \/]([0-9.]+)/i', $userAgent, $matches) === 1) { + return new OperatingSystem('KOS', 'KaiOS', self::version($matches[1])); } if (preg_match('/Tizen[ \/]([0-9.]+)/i', $userAgent, $matches) === 1) { return new OperatingSystem('TIZ', 'Tizen', self::version($matches[1])); } - if (preg_match('/KaiOS[ \/]([0-9.]+)/i', $userAgent, $matches) === 1) { - return new OperatingSystem('KOS', 'KaiOS', self::version($matches[1])); + if (preg_match('/CrOS [^ )]+ ([0-9.]+)/i', $userAgent, $matches) === 1) { + return new OperatingSystem('COS', 'Chrome OS', self::version($matches[1])); } - if (stripos($userAgent, 'Ubuntu') !== false) { + if (stripos($userAgent, 'web0S') !== false || stripos($userAgent, 'webOS') !== false) { $version = null; - if (preg_match('/Ubuntu[ \/]([0-9.]+)/i', $userAgent, $matches) === 1) { + if (preg_match('/(?:web0S|webOS)[ \/]([0-9.]+)/i', $userAgent, $matches) === 1) { $version = self::version($matches[1]); } - return new OperatingSystem('UBT', 'Ubuntu', $version); + return new OperatingSystem('WOS', 'webOS', $version); + } + + if (stripos($userAgent, 'Sailfish') !== false) { + return new OperatingSystem('SAF', 'Sailfish OS'); + } + + if (preg_match('/(?:BlackBerry|BB10|RIM Tablet OS)/i', $userAgent) === 1) { + return new OperatingSystem('BLB', 'BlackBerry OS'); + } + + if (preg_match('/Nintendo (?:Switch|Wii ?U?|3DS)/i', $userAgent) === 1) { + return new OperatingSystem('WII', 'Nintendo'); + } + + if (stripos($userAgent, 'PlayStation') !== false) { + return new OperatingSystem('PS3', 'PlayStation'); + } + + $distro = self::linuxDistro($userAgent); + if ($distro instanceof OperatingSystem) { + return $distro; + } + + if (stripos($userAgent, 'Mac OS X') !== false && stripos($userAgent, 'like Mac OS X') === false) { + if (preg_match('/Mac OS X[ \/]([0-9_\.]+)/i', $userAgent, $matches) === 1) { + return new OperatingSystem('MAC', 'Mac', self::displayVersion($matches[1])); + } + + return new OperatingSystem('MAC', 'Mac'); } if (stripos($userAgent, 'Linux') !== false || stripos($userAgent, 'X11') !== false) { @@ -77,6 +148,82 @@ public static function detect(string $userAgent): OperatingSystem return new OperatingSystem(); } + /** + * Detect the Apple operating-system family: tvOS, watchOS, iPadOS, and iOS. + */ + private static function apple(string $userAgent): ?OperatingSystem + { + if (stripos($userAgent, 'AppleTV') !== false || stripos($userAgent, 'tvOS') !== false) { + return new OperatingSystem('ATV', 'tvOS', self::token($userAgent, 'tvOS')); + } + + if (stripos($userAgent, 'Watch OS') !== false || stripos($userAgent, 'WatchOS') !== false) { + $version = self::token($userAgent, 'WatchOS') ?? self::token($userAgent, 'Watch OS'); + + return new OperatingSystem('WAS', 'watchOS', $version); + } + + if (stripos($userAgent, 'iPad') !== false) { + return new OperatingSystem('IPA', 'iPadOS', self::appleVersion($userAgent)); + } + + if (preg_match('/(?:iPhone|iPod)/i', $userAgent) === 1 + || preg_match('/(?:CPU (?:iPhone )?OS|iPhone OS)[ \/]([0-9_]+)/i', $userAgent) === 1) { + return new OperatingSystem('IOS', 'iOS', self::appleVersion($userAgent)); + } + + return null; + } + + private static function appleVersion(string $userAgent): ?string + { + if (preg_match('/(?:CPU (?:iPhone )?OS|iPhone OS|OS)[ \/]([0-9_]+)/i', $userAgent, $matches) === 1) { + return self::version($matches[1]); + } + + return null; + } + + private static function isFireOs(string $userAgent): bool + { + if (stripos($userAgent, 'Android') === false) { + return false; + } + + return preg_match('/Silk\/|\bKF[A-Z0-9]{2,}\b|\bAFT[A-Z0-9]+\b/i', $userAgent) === 1; + } + + private static function linuxDistro(string $userAgent): ?OperatingSystem + { + if (stripos($userAgent, 'Linux') === false && stripos($userAgent, 'X11') === false) { + return null; + } + + foreach (self::LINUX_DISTROS as $code => [$name, $token]) { + if (preg_match('/\b' . preg_quote($token, '/') . '\b/i', $userAgent) !== 1) { + continue; + } + + return new OperatingSystem($code, $name, self::token($userAgent, $token)); + } + + return null; + } + + /** + * Read a numeric version that immediately follows a named token, whether + * separated by a space or a slash (for example "Ubuntu/22.04" or + * "OpenHarmony 5.0"). + */ + private static function token(string $userAgent, string $token): ?string + { + if (preg_match('/' . preg_quote($token, '/') . '[ \/]([0-9][0-9._]*)/i', $userAgent, $matches) === 1) { + return self::version($matches[1]); + } + + return null; + } + private static function version(string $version): string { return trim(str_replace('_', '.', $version), '.-'); diff --git a/packages/user-agent/tests/BotDetectorTest.php b/packages/user-agent/tests/BotDetectorTest.php index 29906187..82b0cc34 100644 --- a/packages/user-agent/tests/BotDetectorTest.php +++ b/packages/user-agent/tests/BotDetectorTest.php @@ -21,6 +21,14 @@ public static function knownBots(): array 'OpenAI' => ['Mozilla/5.0 AppleWebKit/537.36; compatible; GPTBot/1.2; +https://openai.com/gptbot', 'GPTBot'], 'Anthropic' => ['ClaudeBot/1.0; +https://anthropic.com/claudebot', 'ClaudeBot'], 'monitor' => ['UptimeRobot/2.0', 'UptimeRobot'], + 'Perplexity' => ['Mozilla/5.0 (compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)', 'PerplexityBot'], + 'Meta' => ['meta-externalagent/1.1 (+https://developers.facebook.com/docs/sharing/webmasters/crawler)', 'Meta External Agent'], + 'Yahoo' => ['Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)', 'Yahoo! Slurp'], + 'Seznam' => ['Mozilla/5.0 (compatible; SeznamBot/4.0; +http://napoveda.seznam.cz/seznambot-intro/)', 'SeznamBot'], + 'GoogleOther' => ['Mozilla/5.0 (compatible; GoogleOther)', 'GoogleOther'], + 'CommonCrawl' => ['CCBot/2.0 (https://commoncrawl.org/faq/)', 'CCBot'], + 'Pinterest' => ['Pinterest/0.2 (+https://www.pinterest.com/bot.html)', 'Pinterest'], + 'Sogou spider' => ['Sogou web spider/4.0(+http://www.sogou.com/docs/help/webmasters.htm)', 'Sogou Spider'], 'generic crawler' => ['Acme-Crawler/1.0', 'Acme-Crawler'], 'generic bot suffix' => ['CustomBot/1.0', 'CustomBot'], ]; @@ -44,6 +52,9 @@ public static function humans(): array 'Chrome' => ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0.0.0 Safari/537.36'], 'robotics product' => ['AcmeRobotics/2.0'], 'bot substring' => ['BottomNavigationClient/1.0'], + 'Sogou browser' => ['Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 SogouMobileBrowser/5.28.0'], + 'Pinterest app' => ['Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) Pinterest/11.20 (iPhone; iOS 16.0)'], + 'WhatsApp in-app browser' => ['Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36 WhatsApp/2.24.6'], 'empty' => [''], ]; } diff --git a/packages/user-agent/tests/BrowserDetectionTest.php b/packages/user-agent/tests/BrowserDetectionTest.php index b6445d28..743c204c 100644 --- a/packages/user-agent/tests/BrowserDetectionTest.php +++ b/packages/user-agent/tests/BrowserDetectionTest.php @@ -90,6 +90,125 @@ public static function browsers(): array '36.2', '2.12.423', ], + 'Opera Mobile' => [ + 'Mozilla/5.0 (Linux; Android 10; VOG-L29) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/104.0.0.0 Mobile Safari/537.36 OPR/64.3.3282.60839', + 'OM', + 'Opera Mobile', + 'Blink', + '64.3', + '104.0.0.0', + ], + 'Brave' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Brave/120', + 'BR', + 'Brave', + 'Blink', + '120', + '120.0.0.0', + ], + 'Vivaldi' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Vivaldi/6.2', + 'VI', + 'Vivaldi', + 'Blink', + '6.2', + '117.0.0.0', + ], + 'Yandex Browser' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/114.0.0.0 YaBrowser/23.7.1.1140 Safari/537.36', + 'YA', + 'Yandex Browser', + 'Blink', + '23.7', + '114.0.0.0', + ], + 'UC Browser' => [ + 'Mozilla/5.0 (Linux; U; Android 11; en-US; SM-M317F Build/RP1A.200720.012) ' + . 'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/100.0.4896.127 ' + . 'UCBrowser/15.5.0.1395 Mobile Safari/537.36', + 'UC', + 'UC Browser', + 'Blink', + '15.5', + '100.0.4896.127', + ], + 'DuckDuckGo' => [ + 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Version/4.0 Chrome/116.0.0.0 Mobile DuckDuckGo/5 Safari/537.36', + 'DD', + 'DuckDuckGo Privacy Browser', + 'Blink', + '5', + '116.0.0.0', + ], + 'QQ Browser' => [ + 'Mozilla/5.0 (Linux; U; Android 12; zh-cn; RMX3350 Build/SKQ1.211019.001) ' + . 'AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/107.0.5304.105 ' + . 'MQQBrowser/13.6 Mobile Safari/537.36', + 'QQ', + 'QQ Browser', + 'Blink', + '13.6', + '107.0.5304.105', + ], + 'Coc Coc' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) coc_coc_browser/112.0.174 Chrome/106.0.5249.174 Safari/537.36', + 'CC', + 'Coc Coc', + 'Blink', + '112.0', + '106.0.5249.174', + ], + 'Whale' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/116.0.0.0 Whale/3.23.214.9 Safari/537.36', + 'WH', + 'Whale Browser', + 'Blink', + '3.23', + '116.0.0.0', + ], + 'Huawei Browser' => [ + 'Mozilla/5.0 (Linux; Android 10; ELS-NX9; HMSCore 6.6.0.311) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/99.0.4844.88 HuaweiBrowser/13.0.5.303 Mobile Safari/537.36', + 'HU', + 'Huawei Browser Mobile', + 'Blink', + '13.0', + '99.0.4844.88', + ], + 'Amazon Silk' => [ + 'Mozilla/5.0 (Linux; Android 9; KFMAWI) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Silk/104.5.1 like Chrome/104.0.5112.105 Safari/537.36', + 'MS', + 'Mobile Silk', + 'Blink', + '104.5', + '104.0.5112.105', + ], + 'Amazon Silk (legacy WebKit)' => [ + 'Mozilla/5.0 (Linux; U; en-US) AppleWebKit/533.16 (KHTML, like Gecko) ' + . 'Version/5.0 Safari/533.16 Silk/3.13', + 'MS', + 'Mobile Silk', + 'WebKit', + '3.13', + '533.16', + ], + 'Firefox Focus' => [ + 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Version/4.0 Chrome/113.0.0.0 Mobile Safari/537.36 Focus/125.0', + 'FK', + 'Firefox Focus', + 'Blink', + '125.0', + '113.0.0.0', + ], ]; } @@ -111,4 +230,32 @@ public function testBrowser( $this->assertSame($version, $client->version); $this->assertSame($engineVersion, $client->engineVersion); } + + /** + * @return array + */ + public static function libraries(): array + { + return [ + 'curl' => ['curl/8.7.1', 'curl', '8.7'], + 'Guzzle' => ['GuzzleHttp/7.8', 'Guzzle', '7.8'], + 'Go' => ['Go-http-client/2.0', 'Go-http-client', '2.0'], + 'Java' => ['Java/17.0.2', 'Java', '17.0'], + 'Axios' => ['axios/1.6.2', 'Axios', '1.6'], + 'Node Fetch' => ['node-fetch/1.0 (+https://github.com/bitinn/node-fetch)', 'Node Fetch', '1.0'], + 'aiohttp' => ['Python/3.11 aiohttp/3.9.1', 'aiohttp', '3.9'], + 'HTTPie' => ['HTTPie/3.2.2', 'HTTPie', '3.2'], + 'Apache HTTP Client' => ['Apache-HttpClient/4.5.13 (Java/1.8.0_292)', 'Apache HTTP Client', '4.5'], + ]; + } + + #[DataProvider('libraries')] + public function testLibrary(string $userAgent, string $name, string $version): void + { + $client = UserAgent::parse($userAgent)->client(); + + $this->assertSame('library', $client->type); + $this->assertSame($name, $client->name); + $this->assertSame($version, $client->version); + } } diff --git a/packages/user-agent/tests/MatomoCompatibilityTest.php b/packages/user-agent/tests/MatomoCompatibilityTest.php index fd75c51a..bb08ee73 100644 --- a/packages/user-agent/tests/MatomoCompatibilityTest.php +++ b/packages/user-agent/tests/MatomoCompatibilityTest.php @@ -34,6 +34,19 @@ public static function referenceProfiles(): array 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' . 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', ], + 'Safari on iPad' => [ + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) ' + . 'AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 ' + . 'Mobile/15E148 Safari/604.1', + ], + 'Firefox on Debian' => [ + 'Mozilla/5.0 (X11; Linux x86_64; rv:102.0) ' + . 'Gecko/20100101 Firefox/102.0 Debian/102.0', + ], + 'Yandex Browser on Windows' => [ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/114.0.0.0 YaBrowser/23.7.1.1140 Safari/537.36', + ], ]; } diff --git a/packages/user-agent/tests/PlatformDetectionTest.php b/packages/user-agent/tests/PlatformDetectionTest.php index 3316db87..21e97185 100644 --- a/packages/user-agent/tests/PlatformDetectionTest.php +++ b/packages/user-agent/tests/PlatformDetectionTest.php @@ -11,7 +11,7 @@ final class PlatformDetectionTest extends TestCase { /** - * @return array + * @return array */ public static function platforms(): array { @@ -40,11 +40,68 @@ public static function platforms(): array 'Tizen', 'tv', ], + 'iPadOS' => [ + 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 ' + . '(KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + 'IPA', + 'iPadOS', + 'tablet', + ], + 'HarmonyOS' => [ + 'Mozilla/5.0 (Phone; OpenHarmony 5.0) ArkWeb/4.1.6.1 Mobile', + 'OHS', + 'OpenHarmony', + null, + ], + 'Debian' => [ + 'Mozilla/5.0 (X11; Linux x86_64; rv:102.0) Gecko/20100101 Firefox/102.0 Debian/102.0', + 'DEB', + 'Debian', + 'desktop', + ], + 'Fedora' => [ + 'Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + 'FED', + 'Fedora', + 'desktop', + ], + 'Arch Linux' => [ + 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0 Arch Linux', + 'ARL', + 'Arch Linux', + 'desktop', + ], + 'Linux Mint' => [ + 'Mozilla/5.0 (X11; Linux Mint; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0', + 'MIN', + 'Mint', + 'desktop', + ], + 'Fire OS' => [ + 'Mozilla/5.0 (Linux; Android 9; KFMAWI) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Silk/104.5.1 like Chrome/104.0.5112.105 Safari/537.36', + 'FIR', + 'Fire OS', + 'tablet', + ], + 'webOS TV' => [ + 'Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/79.0.3945.79 Safari/537.36 WebAppManager', + 'WOS', + 'webOS', + 'tv', + ], + 'Sailfish OS' => [ + 'Mozilla/5.0 (Mobile; rv:78.0; Sailfish; Jolla) Gecko/78.0 Firefox/78.0', + 'SAF', + 'Sailfish OS', + null, + ], ]; } #[DataProvider('platforms')] - public function testPlatform(string $userAgent, string $code, string $name, string $deviceType): void + public function testPlatform(string $userAgent, string $code, string $name, ?string $deviceType): void { $agent = UserAgent::parse($userAgent); @@ -64,6 +121,48 @@ public static function specialDevices(): array 'Nintendo' => ['Mozilla/5.0 (Nintendo Switch; WifiWebAuthApplet)', 'console', 'Nintendo'], 'Kindle' => ['Mozilla/5.0 (Linux; U; en-US) AppleWebKit/533.16 Silk/3.13 Safari/533.16', 'tablet', 'Amazon'], 'BlackBerry' => ['Mozilla/5.0 (BB10; Touch) AppleWebKit/537.35 Mobile Safari/537.35', 'smartphone', 'BlackBerry'], + 'Realme' => [ + 'Mozilla/5.0 (Linux; Android 13; RMX3630) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/116.0.0.0 Mobile Safari/537.36', + 'smartphone', + 'Realme', + ], + 'Asus' => [ + 'Mozilla/5.0 (Linux; Android 13; ASUS_AI2205) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/116.0.0.0 Mobile Safari/537.36', + 'smartphone', + 'Asus', + ], + 'Tecno' => [ + 'Mozilla/5.0 (Linux; Android 12; TECNO KI5k) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/104.0.0.0 Mobile Safari/537.36', + 'smartphone', + 'Tecno', + ], + 'webOS LG TV' => [ + 'Mozilla/5.0 (Web0S; Linux/SmartTV) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/79.0.3945.79 Safari/537.36 WebAppManager', + 'tv', + 'LG', + ], + 'Honor named model' => [ + 'Mozilla/5.0 (Linux; Android 13; Honor 90 Build/HONORREA-N31) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36', + 'smartphone', + 'Honor', + ], + 'Honor uppercase brand' => [ + 'Mozilla/5.0 (Linux; Android 12; HONOR WKG-LX9) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/104.0.0.0 Mobile Safari/537.36', + 'smartphone', + 'Honor', + ], + 'honor word is not a brand' => [ + 'Mozilla/5.0 (Linux; Android 13; SM-G991B Build/InHonorOfRelease) AppleWebKit/537.36 ' + . '(KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36', + 'smartphone', + 'Samsung', + ], ]; }