From 11eed542e3312f0195ea06d183914743aaf38870 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 00:45:02 +0000 Subject: [PATCH 1/4] feat(user-agent): expand OS, browser, device, and bot detection Broaden detection coverage using Matomo DeviceDetector 6.4 as the reference so that popular user agents resolve to a real result instead of null or unknown, while keeping the fast token-and-bounded-regex hot path (no data files, no runtime dependencies). - Operating systems: iPadOS, tvOS, watchOS, HarmonyOS, OpenHarmony, Fire OS, webOS, Sailfish, BlackBerry, PlayStation, Nintendo, and popular Linux distributions (Debian, Fedora, Arch Linux, Mint, and more). - Browsers: Opera Mobile, Brave, Vivaldi, Yandex, UC Browser, DuckDuckGo, QQ Browser, Coc Coc, Whale, Huawei Browser, Amazon Silk, and Firefox Focus, all resolved ahead of the generic Chrome and WebView rules. - Libraries: Python urllib, aiohttp, Go-http-client, Node Fetch, Axios, HTTPie, Apache HTTP Client, Java, and more. - Device brands: Realme, Asus, Tecno, Infinix, HTC, Lenovo, ZTE, TCL, Meizu, Nothing, Fairphone, Alcatel, plus LG/Sony smart-TV mapping. - Bots: PerplexityBot, Google Extended, GoogleOther, Meta External Agent, CCBot, YouBot, Yahoo Slurp, SeznamBot, Pinterest, and more, with narrowed needles so the Sogou browser and Pinterest app are not misclassified as bots. Short-name codes stay aligned with Matomo so the added categories match its reference output. Differential tests against Matomo 6.4 gain iPad, Debian, and Yandex profiles; unit tests cover the new browsers, libraries, platforms, device brands, and crawlers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BnwCG9DYF9KQwBtRbQHZNv --- packages/user-agent/CHANGELOG.md | 22 +++ packages/user-agent/README.md | 23 ++- packages/user-agent/benchmark/benchmark.php | 4 + .../src/UserAgent/Detection/BotDetector.php | 40 +++++ .../UserAgent/Detection/ClientDetector.php | 77 +++++++- .../UserAgent/Detection/DeviceDetector.php | 32 +++- .../Detection/OperatingSystemDetector.php | 168 ++++++++++++++++-- packages/user-agent/tests/BotDetectorTest.php | 10 ++ .../user-agent/tests/BrowserDetectionTest.php | 138 ++++++++++++++ .../tests/MatomoCompatibilityTest.php | 13 ++ .../tests/PlatformDetectionTest.php | 85 ++++++++- 11 files changed, 585 insertions(+), 27 deletions(-) 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..47c3f94b 100644 --- a/packages/user-agent/src/UserAgent/Detection/BotDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/BotDetector.php @@ -12,34 +12,74 @@ 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..ef8d1df2 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,62 @@ 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::blink($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::blink($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::blink( + $userAgent, + $mobile ? 'HU' : 'HP', + $mobile ? 'Huawei Browser Mobile' : 'Huawei Browser', + $matches[1], + ); + } + + return null; + } + + private static function blink(string $userAgent, string $code, string $name, string $version): Client + { + $engineVersion = self::tokenVersion($userAgent, 'Chrome') ?? self::version($version); + + return new Client('browser', $code, $name, self::displayVersion($version), 'Blink', $engineVersion); + } + private static function edge(string $userAgent): ?Client { if (preg_match('/(?:EdgA|EdgiOS|Edg|Edge)\/([0-9.]+)/i', $userAgent, $matches) !== 1) { @@ -58,8 +115,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 +270,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..4edf99f2 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) { @@ -171,13 +185,25 @@ private static function brand(string $userAgent, ?string $model = null): ?string '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', + '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', + 'Nothing' => '/(?:Nothing)/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..15b738ca 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,75 @@ 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 !== null) { + 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) { - $version = null; - if (preg_match('/Ubuntu[ \/]([0-9.]+)/i', $userAgent, $matches) === 1) { - $version = self::version($matches[1]); + if (preg_match('/(?:web0S|webOS)[ \/]?([0-9.]+)?/i', $userAgent, $matches) === 1) { + return new OperatingSystem('WOS', 'webOS', isset($matches[1]) && $matches[1] !== '' ? self::version($matches[1]) : null); + } + + 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 !== null) { + 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('UBT', 'Ubuntu', $version); + return new OperatingSystem('MAC', 'Mac'); } if (stripos($userAgent, 'Linux') !== false || stripos($userAgent, 'X11') !== false) { @@ -77,6 +143,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..1793d1de 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,8 @@ 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)'], 'empty' => [''], ]; } diff --git a/packages/user-agent/tests/BrowserDetectionTest.php b/packages/user-agent/tests/BrowserDetectionTest.php index b6445d28..27eccb0d 100644 --- a/packages/user-agent/tests/BrowserDetectionTest.php +++ b/packages/user-agent/tests/BrowserDetectionTest.php @@ -90,6 +90,116 @@ 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', + ], + '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 +221,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..f6fbf538 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,30 @@ 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', + ], ]; } From 61cd395645fd2c4199527e69b859001dd1010993 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 01:17:26 +0000 Subject: [PATCH 2/4] fix(user-agent): address CI and review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OperatingSystemDetector: satisfy PHPStan (drop always-true empty-string check in webOS version parsing) and Rector (use instanceof over !== null for the Apple and Linux-distro results). - ClientDetector: report the correct engine for Chromium derivatives that ship no Chrome token — legacy Amazon Silk now resolves to WebKit at its AppleWebKit version instead of Blink at the Silk version, matching Matomo. - BotDetector: stop treating WhatsApp as a bot. Matomo classifies the WhatsApp token as a mobile app, so real in-app browser sessions must not report isBot(). - DeviceDetector: drop the Nothing brand rule; the bare "Nothing" needle matched unrelated build and app tokens and mislabeled the brand. - Vale: add Wget, aiohttp, Axios, HTTPie, and GPTBot to the shared vocabulary so the README spell-check passes. - Tests: lock in legacy Silk WebKit engine reporting and WhatsApp staying non-bot. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BnwCG9DYF9KQwBtRbQHZNv --- .../config/vocabularies/Utopia/accept.txt | 5 ++++ .../src/UserAgent/Detection/BotDetector.php | 1 - .../UserAgent/Detection/ClientDetector.php | 26 ++++++++++++++----- .../UserAgent/Detection/DeviceDetector.php | 1 - .../Detection/OperatingSystemDetector.php | 13 +++++++--- packages/user-agent/tests/BotDetectorTest.php | 1 + .../user-agent/tests/BrowserDetectionTest.php | 9 +++++++ 7 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.vale/styles/config/vocabularies/Utopia/accept.txt b/.vale/styles/config/vocabularies/Utopia/accept.txt index 831b5da0..e4a10452 100644 --- a/.vale/styles/config/vocabularies/Utopia/accept.txt +++ b/.vale/styles/config/vocabularies/Utopia/accept.txt @@ -43,6 +43,11 @@ Fux Dittmann Torsten United States +Wget +aiohttp +Axios +HTTPie +GPTBot # Protocols, formats, acronyms OAuth diff --git a/packages/user-agent/src/UserAgent/Detection/BotDetector.php b/packages/user-agent/src/UserAgent/Detection/BotDetector.php index 47c3f94b..d4d98056 100644 --- a/packages/user-agent/src/UserAgent/Detection/BotDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/BotDetector.php @@ -42,7 +42,6 @@ final class BotDetector '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'], diff --git a/packages/user-agent/src/UserAgent/Detection/ClientDetector.php b/packages/user-agent/src/UserAgent/Detection/ClientDetector.php index ef8d1df2..ddaed6cf 100644 --- a/packages/user-agent/src/UserAgent/Detection/ClientDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/ClientDetector.php @@ -54,21 +54,21 @@ private static function derivative(string $userAgent): ?Client foreach ($blink as $pattern => [$code, $name]) { if (preg_match($pattern, $userAgent, $matches) === 1) { - return self::blink($userAgent, $code, $name, $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::blink($userAgent, 'FK', 'Firefox Focus', $matches[1]); + 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::blink( + return self::derivativeClient( $userAgent, $mobile ? 'HU' : 'HP', $mobile ? 'Huawei Browser Mobile' : 'Huawei Browser', @@ -79,11 +79,25 @@ private static function derivative(string $userAgent): ?Client return null; } - private static function blink(string $userAgent, string $code, string $name, string $version): Client + /** + * 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 { - $engineVersion = self::tokenVersion($userAgent, 'Chrome') ?? self::version($version); + $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), 'Blink', $engineVersion); + return new Client('browser', $code, $name, self::displayVersion($version)); } private static function edge(string $userAgent): ?Client diff --git a/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php b/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php index 4edf99f2..019eefbd 100644 --- a/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php @@ -201,7 +201,6 @@ private static function brand(string $userAgent, ?string $model = null): ?string 'ZTE' => '/(?:\bZTE\b)/i', 'TCL' => '/(?:\bTCL\b)/i', 'Meizu' => '/(?:Meizu)/i', - 'Nothing' => '/(?:Nothing)/i', 'Fairphone' => '/(?:Fairphone|\bFP[0-9]\b)/i', 'Alcatel' => '/(?:Alcatel)/i', 'LG' => '/(?:\bLG[- ]|\bLM-[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 15b738ca..7716913f 100644 --- a/packages/user-agent/src/UserAgent/Detection/OperatingSystemDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/OperatingSystemDetector.php @@ -74,7 +74,7 @@ public static function detect(string $userAgent): OperatingSystem } $apple = self::apple($userAgent); - if ($apple !== null) { + if ($apple instanceof OperatingSystem) { return $apple; } @@ -103,8 +103,13 @@ public static function detect(string $userAgent): OperatingSystem return new OperatingSystem('COS', 'Chrome OS', self::version($matches[1])); } - if (preg_match('/(?:web0S|webOS)[ \/]?([0-9.]+)?/i', $userAgent, $matches) === 1) { - return new OperatingSystem('WOS', 'webOS', isset($matches[1]) && $matches[1] !== '' ? self::version($matches[1]) : null); + if (stripos($userAgent, 'web0S') !== false || stripos($userAgent, 'webOS') !== false) { + $version = null; + if (preg_match('/(?:web0S|webOS)[ \/]([0-9.]+)/i', $userAgent, $matches) === 1) { + $version = self::version($matches[1]); + } + + return new OperatingSystem('WOS', 'webOS', $version); } if (stripos($userAgent, 'Sailfish') !== false) { @@ -124,7 +129,7 @@ public static function detect(string $userAgent): OperatingSystem } $distro = self::linuxDistro($userAgent); - if ($distro !== null) { + if ($distro instanceof OperatingSystem) { return $distro; } diff --git a/packages/user-agent/tests/BotDetectorTest.php b/packages/user-agent/tests/BotDetectorTest.php index 1793d1de..82b0cc34 100644 --- a/packages/user-agent/tests/BotDetectorTest.php +++ b/packages/user-agent/tests/BotDetectorTest.php @@ -54,6 +54,7 @@ public static function humans(): array '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 27eccb0d..743c204c 100644 --- a/packages/user-agent/tests/BrowserDetectionTest.php +++ b/packages/user-agent/tests/BrowserDetectionTest.php @@ -191,6 +191,15 @@ public static function browsers(): array '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', From 934fb4c4588fdb93fcccf4fa9f0629ffdfd517b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 01:21:57 +0000 Subject: [PATCH 3/4] fix(user-agent): add remaining brand terms to Vale vocabulary The README spell-check runs over the whole file, so every product and brand name in the expanded detection-coverage section needs a vocabulary entry. Add the OS, browser, and library names introduced by this PR (macOS, iOS, iPadOS, HarmonyOS, webOS, Vivaldi, Yandex, DuckDuckGo, Huawei, OkHttp, and more). Verified with Vale 3.15.1 that the package README reports zero spelling errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BnwCG9DYF9KQwBtRbQHZNv --- .../config/vocabularies/Utopia/accept.txt | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.vale/styles/config/vocabularies/Utopia/accept.txt b/.vale/styles/config/vocabularies/Utopia/accept.txt index e4a10452..c15fcff2 100644 --- a/.vale/styles/config/vocabularies/Utopia/accept.txt +++ b/.vale/styles/config/vocabularies/Utopia/accept.txt @@ -48,6 +48,34 @@ 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 From ca0ef119fddc0977344a3b5a85d8ba4cec8073a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 01:48:55 +0000 Subject: [PATCH 4/4] fix(user-agent): tighten Honor brand rule to model-shaped tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare `Honor` alternative matched the common word "honor" anywhere in the user-agent string, so an app or build token such as `Build/InHonorOfRelease` could mislabel a non-Honor device. Match only the uppercase `HONOR` brand token or `Honor ` (numbers, X/V series, and named product lines), alongside the existing HLK-/BKL- model codes. Real Honor devices — including uppercase `HONOR WKG-LX9` — still resolve to Honor, while "honor" in surrounding text no longer sets the brand. Adds regression tests for named and uppercase Honor models and a negative case where "InHonorOfRelease" must not override the real Samsung brand. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BnwCG9DYF9KQwBtRbQHZNv --- .../src/UserAgent/Detection/DeviceDetector.php | 4 +++- .../user-agent/tests/PlatformDetectionTest.php | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php b/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php index 019eefbd..e3cf603f 100644 --- a/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php +++ b/packages/user-agent/src/UserAgent/Detection/DeviceDetector.php @@ -184,7 +184,9 @@ 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', + // 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', diff --git a/packages/user-agent/tests/PlatformDetectionTest.php b/packages/user-agent/tests/PlatformDetectionTest.php index f6fbf538..21e97185 100644 --- a/packages/user-agent/tests/PlatformDetectionTest.php +++ b/packages/user-agent/tests/PlatformDetectionTest.php @@ -145,6 +145,24 @@ public static function specialDevices(): array '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', + ], ]; }