BxAnalyticsUserAgent.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup Analytics Analytics
  7. * @ingroup UnaModules
  8. *
  9. * @{
  10. */
  11. /**
  12. * User agent parser for web analytics: browser, operating system, device type and bots.
  13. *
  14. * Covers the browsers, systems and devices that make up nearly all real traffic. Rules are
  15. * checked in order, so a token shared by several browsers (Chrome, Safari) comes last.
  16. * Native app hits carry their own system and device fields and don't depend on this class.
  17. */
  18. class BxAnalyticsUserAgent
  19. {
  20. const DEVICE_DESKTOP = 'desktop';
  21. const DEVICE_MOBILE = 'mobile';
  22. const DEVICE_TABLET = 'tablet';
  23. const DEVICE_TV = 'tv';
  24. const DEVICE_CONSOLE = 'console';
  25. const DEVICE_BOT = 'bot';
  26. /**
  27. * Crawlers, link previews, monitoring and HTTP libraries. A browser never sends these tokens.
  28. */
  29. protected static $_sBotsPattern = '/(?<!cu)bot\b|(?<!cu)bot\/|crawl|spider|slurp|mediapartners|adsbot|facebookexternalhit|facebookcatalog|meta-externalagent|embedly|quora link preview|outbrain|pinterest\/|vkshare|w3c_validator|whatsapp|telegrambot|discordbot|slackbot|skypeuripreview|bitlybot|headlesschrome|phantomjs|puppeteer|playwright|selenium|lighthouse|pagespeed|gtmetrix|pingdom|uptimerobot|statuscake|site24x7|datadog|newrelic|python-requests|python-urllib|aiohttp|httpx|curl\/|wget\/|libwww|httpclient|go-http-client|java\/|axios\/|node-fetch|undici|guzzlehttp|okhttp\/[0-9.]+$|scrapy|feedfetcher|rss|preview|archive\.org|ia_archiver|gptbot|chatgpt-user|oai-searchbot|claudebot|claude-web|anthropic-ai|perplexitybot|ccbot|bytespider|amazonbot|applebot|petalbot|semrush|ahrefs|mj12bot|dotbot|yandex(bot|images|metrika)|baiduspider|sogou|exabot|seznambot|bingpreview|google-inspectiontool|googleother|storebot/i';
  30. const BOT_SEARCH = 'search';
  31. const BOT_AI_CRAWLER = 'ai_crawler';
  32. const BOT_AI_AGENT = 'ai_agent';
  33. const BOT_SEO = 'seo';
  34. const BOT_SOCIAL = 'social';
  35. const BOT_MONITOR = 'monitor';
  36. const BOT_FEED = 'feed';
  37. const BOT_SCRIPT = 'script';
  38. const BOT_OTHER = 'other';
  39. /**
  40. * Known bots as [pattern, name, category]. AI agents fetch a page because a person asked
  41. * an assistant about it; AI crawlers collect pages for training or search indexes.
  42. */
  43. protected static $_aBots = [
  44. ['/ChatGPT-User/i', 'ChatGPT', self::BOT_AI_AGENT],
  45. ['/Claude-User/i', 'Claude', self::BOT_AI_AGENT],
  46. ['/Perplexity-User/i', 'Perplexity', self::BOT_AI_AGENT],
  47. ['/MistralAI-User/i', 'Mistral', self::BOT_AI_AGENT],
  48. ['/DuckAssistBot/i', 'DuckAssist', self::BOT_AI_AGENT],
  49. ['/Google-NotebookLM/i', 'NotebookLM', self::BOT_AI_AGENT],
  50. ['/OAI-SearchBot/i', 'OAI-SearchBot', self::BOT_AI_CRAWLER],
  51. ['/GPTBot/i', 'GPTBot', self::BOT_AI_CRAWLER],
  52. ['/Claude-SearchBot/i', 'Claude-SearchBot', self::BOT_AI_CRAWLER],
  53. ['/ClaudeBot|anthropic-ai|Claude-Web/i', 'ClaudeBot', self::BOT_AI_CRAWLER],
  54. ['/PerplexityBot/i', 'PerplexityBot', self::BOT_AI_CRAWLER],
  55. ['/CCBot/i', 'CCBot', self::BOT_AI_CRAWLER],
  56. ['/Bytespider/i', 'Bytespider', self::BOT_AI_CRAWLER],
  57. ['/meta-externalagent|meta-externalfetcher/i', 'Meta AI', self::BOT_AI_CRAWLER],
  58. ['/Amazonbot/i', 'Amazonbot', self::BOT_AI_CRAWLER],
  59. ['/cohere-ai|cohere-training/i', 'Cohere', self::BOT_AI_CRAWLER],
  60. ['/Diffbot/i', 'Diffbot', self::BOT_AI_CRAWLER],
  61. ['/YouBot/i', 'YouBot', self::BOT_AI_CRAWLER],
  62. ['/AI2Bot/i', 'AI2Bot', self::BOT_AI_CRAWLER],
  63. ['/Timpibot|ImagesiftBot|Omgilibot|PetalBot.*AI/i', 'Other AI crawler', self::BOT_AI_CRAWLER],
  64. ['/Googlebot|Google-InspectionTool|GoogleOther|Storebot-Google|AdsBot-Google|Mediapartners-Google/i', 'Googlebot', self::BOT_SEARCH],
  65. ['/bingbot|BingPreview|msnbot|adidxbot/i', 'Bingbot', self::BOT_SEARCH],
  66. ['/YandexBot|YandexImages|YandexMetrika|YandexMobileBot/i', 'YandexBot', self::BOT_SEARCH],
  67. ['/Baiduspider/i', 'Baiduspider', self::BOT_SEARCH],
  68. ['/DuckDuckBot/i', 'DuckDuckBot', self::BOT_SEARCH],
  69. ['/Applebot/i', 'Applebot', self::BOT_SEARCH],
  70. ['/Sogou/i', 'Sogou', self::BOT_SEARCH],
  71. ['/SeznamBot/i', 'SeznamBot', self::BOT_SEARCH],
  72. ['/PetalBot/i', 'PetalBot', self::BOT_SEARCH],
  73. ['/Yeti\//i', 'Naver', self::BOT_SEARCH],
  74. ['/Qwantbot|Qwantify/i', 'Qwant', self::BOT_SEARCH],
  75. ['/MojeekBot/i', 'Mojeek', self::BOT_SEARCH],
  76. ['/coccocbot/i', 'Coc Coc', self::BOT_SEARCH],
  77. ['/Exabot/i', 'Exabot', self::BOT_SEARCH],
  78. ['/AhrefsBot|AhrefsSiteAudit/i', 'Ahrefs', self::BOT_SEO],
  79. ['/SemrushBot|SiteAuditBot/i', 'Semrush', self::BOT_SEO],
  80. ['/MJ12bot/i', 'Majestic', self::BOT_SEO],
  81. ['/DotBot/i', 'Moz', self::BOT_SEO],
  82. ['/rogerbot/i', 'Moz', self::BOT_SEO],
  83. ['/Screaming Frog/i', 'Screaming Frog', self::BOT_SEO],
  84. ['/BLEXBot/i', 'BLEXBot', self::BOT_SEO],
  85. ['/DataForSeoBot/i', 'DataForSEO', self::BOT_SEO],
  86. ['/serpstatbot/i', 'Serpstat', self::BOT_SEO],
  87. ['/facebookexternalhit|facebookcatalog|Facebot/i', 'Facebook', self::BOT_SOCIAL],
  88. ['/Twitterbot/i', 'X (Twitter)', self::BOT_SOCIAL],
  89. ['/LinkedInBot/i', 'LinkedIn', self::BOT_SOCIAL],
  90. ['/Slackbot|Slack-ImgProxy/i', 'Slack', self::BOT_SOCIAL],
  91. ['/Discordbot/i', 'Discord', self::BOT_SOCIAL],
  92. ['/TelegramBot/i', 'Telegram', self::BOT_SOCIAL],
  93. ['/WhatsApp/i', 'WhatsApp', self::BOT_SOCIAL],
  94. ['/Pinterest/i', 'Pinterest', self::BOT_SOCIAL],
  95. ['/redditbot/i', 'Reddit', self::BOT_SOCIAL],
  96. ['/vkShare/i', 'VK', self::BOT_SOCIAL],
  97. ['/SkypeUriPreview/i', 'Skype', self::BOT_SOCIAL],
  98. ['/Embedly|Iframely/i', 'Embed service', self::BOT_SOCIAL],
  99. ['/Mastodon|Bluesky|Cardyb/i', 'Fediverse', self::BOT_SOCIAL],
  100. ['/UptimeRobot/i', 'UptimeRobot', self::BOT_MONITOR],
  101. ['/Pingdom/i', 'Pingdom', self::BOT_MONITOR],
  102. ['/StatusCake/i', 'StatusCake', self::BOT_MONITOR],
  103. ['/Site24x7/i', 'Site24x7', self::BOT_MONITOR],
  104. ['/Datadog/i', 'Datadog', self::BOT_MONITOR],
  105. ['/NewRelic/i', 'New Relic', self::BOT_MONITOR],
  106. ['/Better Uptime|BetterStack/i', 'Better Stack', self::BOT_MONITOR],
  107. ['/Chrome-Lighthouse|PageSpeed|GTmetrix/i', 'Speed test', self::BOT_MONITOR],
  108. ['/Feedly|Inoreader|NewsBlur|Feedfetcher|Feedbin|FreshRSS|\brss/i', 'Feed reader', self::BOT_FEED],
  109. ['/HeadlessChrome|PhantomJS|puppeteer|playwright|selenium/i', 'Headless browser', self::BOT_SCRIPT],
  110. ['/curl\//i', 'curl', self::BOT_SCRIPT],
  111. ['/Wget\//i', 'Wget', self::BOT_SCRIPT],
  112. ['/python-requests|python-urllib|aiohttp|httpx|Scrapy/i', 'Python', self::BOT_SCRIPT],
  113. ['/Go-http-client/i', 'Go', self::BOT_SCRIPT],
  114. ['/axios|node-fetch|undici|Node\.js/i', 'Node.js', self::BOT_SCRIPT],
  115. ['/GuzzleHttp/i', 'PHP', self::BOT_SCRIPT],
  116. ['/Java\/|Apache-HttpClient|okhttp/i', 'Java', self::BOT_SCRIPT],
  117. ['/libwww-perl/i', 'Perl', self::BOT_SCRIPT],
  118. ];
  119. /**
  120. * Browsers as [name, pattern with the version in the first group]. Order matters.
  121. */
  122. protected static $_aBrowsers = [
  123. ['Edge', '/\bEdg(?:e|A|iOS)?\/([\d.]+)/'],
  124. ['Opera', '/\b(?:OPR|OPiOS|OPT)\/([\d.]+)/'],
  125. ['Opera', '/\bOpera(?:.*Version)?[\/ ]([\d.]+)/'],
  126. ['Samsung Internet', '/\bSamsungBrowser\/([\d.]+)/'],
  127. ['Yandex Browser', '/\bYaBrowser\/([\d.]+)/'],
  128. ['UC Browser', '/\bUC ?Browser\/([\d.]+)/'],
  129. ['Vivaldi', '/\bVivaldi\/([\d.]+)/'],
  130. ['DuckDuckGo', '/\b(?:DuckDuckGo|Ddg)\/([\d.]+)/'],
  131. ['Huawei Browser', '/\bHuaweiBrowser\/([\d.]+)/'],
  132. ['MIUI Browser', '/\bMiuiBrowser\/([\d.]+)/'],
  133. ['Facebook', '/\bFBAV\/([\d.]+)/'],
  134. ['Instagram', '/\bInstagram ([\d.]+)/'],
  135. ['TikTok', '/\b(?:musical_ly|BytedanceWebview|TikTok)[_\/ ]([\d.]+)/'],
  136. ['Firefox', '/\b(?:Firefox|FxiOS)\/([\d.]+)/'],
  137. ['Chrome', '/\bCriOS\/([\d.]+)/'],
  138. ['Android WebView', '/; wv\).*Chrome\/([\d.]+)/'],
  139. ['Chrome', '/\b(?:Chrome|Chromium)\/([\d.]+)/'],
  140. ['Safari', '/\bVersion\/([\d.]+).*Safari\//'],
  141. ['iOS WebView', '/\b(?:iPhone|iPad|iPod).*AppleWebKit\/[\d.]+()/'],
  142. ['Internet Explorer', '/\b(?:MSIE |Trident\/.*rv:)([\d.]+)/'],
  143. ];
  144. /**
  145. * Operating systems as [name, pattern with an optional version in the first group]. Order matters.
  146. */
  147. protected static $_aSystems = [
  148. ['iPadOS', '/\biPad.*?OS ([\d_]+)/'],
  149. ['iOS', '/\b(?:iPhone|iPod).*?OS ([\d_]+)/'],
  150. ['iOS', '/\b(?:iPhone|iPad|iPod)\b()/'],
  151. ['HarmonyOS', '/\bHarmonyOS ?([\d.]*)/'],
  152. ['Android', '/\bAndroid[ \/]?([\d.]*)/'],
  153. ['Windows Phone', '/\bWindows Phone(?: OS)? ([\d.]+)/'],
  154. ['Windows', '/\bWindows NT ([\d.]+)/'],
  155. ['Windows', '/\bWindows\b()/'],
  156. ['Chrome OS', '/\bCrOS \S+ ([\d.]+)/'],
  157. ['macOS', '/\bMac OS X ([\d_.]+)/'],
  158. ['macOS', '/\bMacintosh\b()/'],
  159. ['KaiOS', '/\bKAIOS\/([\d.]+)/i'],
  160. ['Tizen', '/\bTizen ?([\d.]*)/'],
  161. ['webOS', '/\b(?:web0s|webos)\b()/i'],
  162. ['PlayStation', '/\bPlayStation ?([\d.]*)/'],
  163. ['Xbox', '/\bXbox\b()/'],
  164. ['Ubuntu', '/\bUbuntu\b()/'],
  165. ['Linux', '/\bLinux\b()/'],
  166. ['FreeBSD', '/\bFreeBSD\b()/'],
  167. ];
  168. /**
  169. * Windows NT kernel versions; 10.0 covers both Windows 10 and 11.
  170. */
  171. protected static $_aWindowsVersions = [
  172. '10.0' => '10',
  173. '6.3' => '8.1',
  174. '6.2' => '8',
  175. '6.1' => '7',
  176. '6.0' => 'Vista',
  177. '5.1' => 'XP',
  178. ];
  179. /**
  180. * Parse a user agent string.
  181. *
  182. * @param string $sUserAgent the User-Agent header
  183. * @return array with keys browser, browser_version, os, os_version, device and bot
  184. */
  185. public static function parse($sUserAgent)
  186. {
  187. $sUserAgent = trim((string)$sUserAgent);
  188. $aResult = [
  189. 'browser' => '',
  190. 'browser_version' => '',
  191. 'os' => '',
  192. 'os_version' => '',
  193. 'device' => self::DEVICE_DESKTOP,
  194. 'bot' => false,
  195. ];
  196. if($sUserAgent === '' || self::isBot($sUserAgent)) {
  197. $aResult['device'] = self::DEVICE_BOT;
  198. $aResult['bot'] = true;
  199. return $aResult;
  200. }
  201. foreach(self::$_aBrowsers as list($sName, $sPattern))
  202. if(preg_match($sPattern, $sUserAgent, $aMatch)) {
  203. $aResult['browser'] = $sName;
  204. $aResult['browser_version'] = self::_majorMinor($aMatch[1]);
  205. break;
  206. }
  207. foreach(self::$_aSystems as list($sName, $sPattern))
  208. if(preg_match($sPattern, $sUserAgent, $aMatch)) {
  209. $aResult['os'] = $sName;
  210. $aResult['os_version'] = self::_systemVersion($sName, isset($aMatch[1]) ? $aMatch[1] : '');
  211. break;
  212. }
  213. $aResult['device'] = self::_device($sUserAgent, $aResult['os']);
  214. return $aResult;
  215. }
  216. /**
  217. * Whether a user agent belongs to a crawler, a link preview, a monitor or an HTTP library.
  218. */
  219. public static function isBot($sUserAgent)
  220. {
  221. $sUserAgent = trim((string)$sUserAgent);
  222. if($sUserAgent === '')
  223. return true;
  224. return (bool)preg_match(self::$_sBotsPattern, $sUserAgent);
  225. }
  226. /**
  227. * Name and category of a bot's user agent, or null for a browser.
  228. *
  229. * @return array|null ['name' => ..., 'category' => ...]
  230. */
  231. public static function getBot($sUserAgent)
  232. {
  233. $sUserAgent = trim((string)$sUserAgent);
  234. if($sUserAgent === '')
  235. return ['name' => 'Unknown', 'category' => self::BOT_SCRIPT];
  236. foreach(self::$_aBots as list($sPattern, $sName, $sCategory))
  237. if(preg_match($sPattern, $sUserAgent))
  238. return ['name' => $sName, 'category' => $sCategory];
  239. if(!self::isBot($sUserAgent))
  240. return null;
  241. // an unlisted bot: its own name when the user agent carries one ("ExampleBot/1.0")
  242. if(preg_match('/([A-Za-z][A-Za-z0-9._-]{1,40}(?:bot|crawler|spider))\b/i', $sUserAgent, $aMatch))
  243. return ['name' => get_mb_substr($aMatch[1], 0, 64), 'category' => self::BOT_OTHER];
  244. return ['name' => 'Other', 'category' => self::BOT_OTHER];
  245. }
  246. protected static function _device($sUserAgent, $sOs)
  247. {
  248. if(preg_match('/\b(?:SmartTV|SMART-TV|Smart TV|AppleTV|Apple TV|GoogleTV|Android TV|BRAVIA|HbbTV|NetCast|Web0S|webOS TV|Tizen.*TV|CrKey|AFT[A-Z]+|Roku)\b/i', $sUserAgent))
  249. return self::DEVICE_TV;
  250. if(preg_match('/\b(?:PlayStation|Xbox|Nintendo)\b/i', $sUserAgent))
  251. return self::DEVICE_CONSOLE;
  252. if(preg_match('/\b(?:iPad|Tablet|Kindle|Silk|PlayBook)\b/i', $sUserAgent) || $sOs === 'iPadOS')
  253. return self::DEVICE_TABLET;
  254. if($sOs === 'Android' && stripos($sUserAgent, 'Mobile') === false)
  255. return self::DEVICE_TABLET;
  256. if(preg_match('/Mobi|iPhone|iPod|Windows Phone|Opera Mini|BlackBerry|BB10|KAIOS/i', $sUserAgent) || in_array($sOs, ['iOS', 'Android', 'HarmonyOS'], true))
  257. return self::DEVICE_MOBILE;
  258. return self::DEVICE_DESKTOP;
  259. }
  260. protected static function _systemVersion($sOs, $sVersion)
  261. {
  262. $sVersion = str_replace('_', '.', $sVersion);
  263. if($sOs === 'Windows')
  264. return isset(self::$_aWindowsVersions[$sVersion]) ? self::$_aWindowsVersions[$sVersion] : '';
  265. // Safari has reported macOS as 10.15.7 since Big Sur, so the minor version means nothing.
  266. if($sOs === 'macOS' && bx_mb_strpos($sVersion, '10.15') === 0)
  267. return '';
  268. return self::_majorMinor($sVersion);
  269. }
  270. protected static function _majorMinor($sVersion)
  271. {
  272. $aParts = explode('.', (string)$sVersion);
  273. if($aParts[0] === '')
  274. return '';
  275. return isset($aParts[1]) && $aParts[1] !== '' && $aParts[1] !== '0' ? $aParts[0] . '.' . $aParts[1] : $aParts[0];
  276. }
  277. }
  278. /** @} */