Class: Skadi::UserAgent

Inherits:
Object
  • Object
show all
Defined in:
lib/skadi/user_agent.rb

Overview

A minimal user agent parser, designed for speed rather than completeness, aiming to detect the most common browsers and operating systems.

Why implement a custom user agent parsing script over an existing library?

Firstly, to eliminate the transient dependencies consumers of this gem are exposed to.

Secondly, existing libraries have relatively poor performance. Unlike other solutions, which use a large number of regular expressions to detect browsers, this parser tokenises the user agent and uses a lookup table to identify the majority of browsers with O(1) performance, using regular expressions as fallback. See the user_agent_test.rb file for performance statistics.

Thirdly, the user agent parsing gems are years out of date. Modern browsers and modern bots are not properly detected, potentially skewing the analytics data collected.

Constant Summary collapse

BROWSER_MATCHERS =
[
  {
    regex: /Chrome\/(?<version>\d++).*WebView|; wv.*Chrome\/(?<version>\d++)/,
    browser: "Chrome WebView",
  },
  {
    regex: /Android.*Version\/(?<version>\d++)/,
    browser: "Android Browser",
    os: "Android",
  },
  {
    regex: /Android.*Chrome\/(?<version>\d++)/,
    browser: "Chrome for Android",
    os: "Android",
  },
  {
    regex: /(?:iOS|iPod|iPad|iPhone).+(?:CriOS|Chrome)\/(?<version>\d++)/,
    browser: "Chrome for iOS",
    os: "iOS",
  },
  {
    regex: /(?:CriOS|Chrome)\/(?<version>\d++)/,
    browser: "Chrome",
  },
  {
    regex: /(?:iOS|iPod|iPad|iPhone).+Version\/(?<version>\d++)/,
    browser: "Safari for iOS",
    os: "iOS",
  },
  {
    regex: /GSA\/(?<version>\d++)/,
    browser: "GSA",
  },
  {
    regex: /(?:iOS|iPod|iPad|iPhone).*Safari/,
    browser: "Safari for iOS",
    os: "iOS",
  },
  {
    regex: /Version\/(?<version>\d++).*Safari/,
    browser: "Safari",
  },
  {
    regex: /Safari\//,
    browser: "Safari",
  },
  {
    regex: /WebKit\/(?<version>\d++)/,
    browser: "WebKit",
  },
  {
    regex: /Mozilla\/(?<version>\d++).*rv:(?<engine_version>\d++).*?Gecko\//,
    browser: "Mozilla",
    engine: "Gecko",
  },
]
BROWSER_TOKENS =
{
  "AlohaBrowser" => {
    browser: "Aloha",
  },
  "Avast" => {
    browser: "Avast Secure Browser",
  },
  "AVG" => {
    browser: "AVG Secure Browser",
  },
  "baiduboxapp" => {
    browser: "Baidu",
  },
  "BingSapphire" => {
    browser: "Bing",
  },
  "Brave" => {},
  "Chromium" => {
    regex: /Chromium[\/ ](?<version>GOST|\d++)/,
  },
  "Ddg" => {
    browser: "DuckDuckGo",
  },
  "DuckDuckGo" => {},
  "Ecosia" => {
    regex: /Ecosia ios@(?<version>\d++)/,
    os: "iOS",
  },
  "Edg" => [
    {
      regex: /(?:iOS|iPod|iPad|iPhone).*Edg\/(?<version>\d++)/,
      browser: "Edge for iOS",
      os: "iOS",
    },
    {
      regex: /Android.*Edg\/(?<version>\d++)/,
      browser: "Edge for Android",
      os: "Android",
    },
    {
      regex: /Edg\/(?<version>\d++)/,
      browser: "Edge",
    },
  ],
  "EdgA" => {
    browser: "Edge for Android",
    os: "Android",
  },
  "Edge" => {},
  "EdgiOS" => {
    browser: "Edge for iOS",
    os: "iOS",
  },
  "Electron" => {},
  "FBAV" => {
    browser: "Facebook",
  },
  "Firefox" => [
    {
      regex: /(?<browser>PaleMoon|Waterfox)\/(?<version>\d++)/,
    },
    {
      regex: /(?:iOS|iPod|iPad|iPhone).*Firefox\/(?<version>\d++)/,
      browser: "Firefox for iOS",
      os: "iOS",
    },
    {
      regex: /Android.*Firefox\/(?<version>\d++)/,
      browser: "Firefox for Android",
      os: "Android",
    },
    {
      regex: /Firefox\/(?<version>\d++)/,
      browser: "Firefox",
    },
  ],
  "FxiOS" => {
    browser: "Firefox for iOS",
  },
  "HeadlessChrome" => {
    browser: "Chrome Headless",
  },
  "HeyTapBrowser" => {
    browser: "HeyTap",
  },
  "HuaweiBrowser" => {
    browser: "Huawei Browser",
  },
  "Instagram" => {
    regex: /Instagram[\/ ](?<version>\d++)/,
  },
  "Iron" => {
    regex: /Chrome\/(?<version>\d++).*?Iron/,
  },
  "KAKAOTALK" => {
    regex: /KAKAOTALK[\/ ](?<version>\d++)/,
  },
  "Konqueror" => {},
  "Line" => {},
  "LinkedInApp" => {
    regex: /\[LinkedInApp\]\/(?<version>\d++)/,
    browser: "LinkedIn",
  },
  "Maxthon" => {},
  "MicroMessenger" => {
    browser: "WeChat",
  },
  "MiuiBrowser" => {
    browser: "MIUI Browser",
  },
  "MQQBrowser" => {},
  "MSIE" => {
    regex: /MSIE (?<version>\d++)(?>.*(?<engine>Trident)\/(?<engine_version>\d++))?|(?<engine>Trident)\/(?<engine_version>\d++).*rv[: ](?<version>\d++)/,
    browser: "IE",
    os: "Windows",
  },
  "musical" => {
    regex: /musical_ly_(?<version>\d++)/,
    browser: "TikTok",
  },
  "Norton" => {
    browser: "Norton Private Browser",
  },
  "OculusBrowser" => {
    browser: "Oculus Browser",
  },
  "Opera" => [
    {
      regex: /Opera Mini[\/ ](?<version>\d++)/,
      browser: "Opera Mini",
    },
    {
      regex: /Opera(?>.*Version)?[\/ ](?<version>\d++)/,
      browser: "Opera",
    },
  ],
  "OPR" => {
    browser: "Opera",
  },
  "OPT" => {
    browser: "Opera Touch",
  },
  "OPX" => {
    browser: "Opera GX",
  },
  "PaleMoon" => {},
  "QQBrowser" => {},
  "QuarkPC" => {
    browser: "Quark",
  },
  "SamsungBrowser" => {
    browser: "Samsung Internet",
  },
  "SeaMonkey" => {},
  "Silk" => {},
  "Snapchat" => {},
  "TikTokLIVEStudio" => {},
  "Trident" => {
    regex: /MSIE (?<version>\d++).*Trident\/(?<engine_version>\d++)|Trident\/(?<engine_version>\d++).*rv[: ](?<version>\d++)/,
    browser: "IE",
    engine: "Trident",
    os: "Windows",
  },
  "Twitter" => {
    regex: /Twitter for iPhone\/(?<version>\d++)/,
  },
  "UCBrowser" => {},
  "VivoBrowser" => {
    browser: "Vivo Browser",
  },
  "Whale" => {},
  "YaBrowser" => {
    browser: "Yandex",
  },
  "YaSearchBrowser" => {
    browser: "Yandex",
  },
}
ENGINE_MATCHERS =
[
  {
    regex: /AppleWebKit\/537\.36.*Edge\/(?<version>1[2-8])\./,
    engine: "EdgeHTML",
  },
  {
    regex: /AppleWebKit\/537\.36.*Chrome\/(?<version>\d++)/,
    engine: "Blink",
  },
  {
    regex: /(?<engine>WebKit|Presto|Trident|Goanna)\/(?<version>\d++)/,
  },
  {
    regex: /rv:(?<version>\d++).*?Gecko\/\d++/,
    engine: "Gecko",
  },
]
OS_TOKENS =
{
  "CFNetwork" => "iOS",
  "CrOS" => "Chrome OS",
  "Fedora" => "Fedora",
  "Gentoo" => "Gentoo",
  "HarmonyOS" => "HarmonyOS",
  "iPad" => "iOS",
  "iPhone" => "iOS",
  "iPod" => "iOS",
  "Mac" => "macOS",
  "Ubuntu" => "Ubuntu",
  "Windows" => "Windows",
}
BOT_GLOBAL_MATCHERS =
%w[bot crawl scan spider]
BOT_WORD_SET =
Set.new(%w[AGENT Agent AppInsights ArchiveBox Archiver Archiving BingPreview BrandVerity Butterfly Charlotte Checkly Claude CloudFlare Cloudflare Code Collapsify CookieHubVerify Criticalcss Daily DareBoost DatadogSynthetics Datanyze Devin Dlc FeedBurner Feeder Feedly FlipboardProxy Fluid Foregenix GTmetrix GeedoProductSearch GeedoShopProductFinder Google GoogleAgent GoogleImageProxy GotSiteMonitor Hardenize HeadlessChrome Hotjar Inspector Lighthouse LinkTiger Mail Manus MarketGoo MarketingMiner MetaIAB Miniature MonitoRSS Monitor Netcraft NewRelicSynthetics NewsBlur NewsNow Newsify Nitro OpenGraph Optimizer PTST PWABuilderHttpAgent Perplexity PingdomTMS Playwright Preview PrintFriendly Puppeteer Readable RevvimGort Rigor SQWatcher Scope3 SecurityHeaders Selenium SeoSiteCheckup Silktide Sindup Siteimprove Specificfeeds Sucuri TestLocally ThousandEyes Trae YLT ZoteroTranslationServer adbeat agent archiver archiving brandverity butterfly claude cloudflare code contentkingapp deadlinkchecker devin europarchive feeder feedly google img2dataset infegy mail mailservertest2023 marketingminer mirrorweb monitor nbertaupete95 netcraft newsai newsblur newsify opencode opengraph oupwis perplexity preview retrevo scope3 scraping seositecheckup sitebulb slider splash sqwatcher turingos ubermetrics uptimedoctor watchTowr webresearch websitepulse woorankreview xmco])
BOT_FALLBACK_MATCHERS =
[ "AP3A.240617.008" ]

Instance Method Summary collapse

Constructor Details

#initialize(user_agent) ⇒ UserAgent

Returns a new instance of UserAgent.



17
18
19
# File 'lib/skadi/user_agent.rb', line 17

def initialize(user_agent)
  @user_agent = (user_agent || "")[0, 2048]
end

Instance Method Details

#bot?Boolean

Returns:

  • (Boolean)


51
52
53
54
55
56
57
58
59
60
61
62
63
# File 'lib/skadi/user_agent.rb', line 51

def bot?
  return @bot unless @bot.nil?

  # If the user agent doesn't follow the normal browser patterns, we assume it's a bot
  parse_browser if @browser.nil?
  if @browser == "Unknown"
    @bot = true

    return true
  end

  @bot = detect_bot
end

#browserObject



21
22
23
24
25
# File 'lib/skadi/user_agent.rb', line 21

def browser
  parse_browser if @browser.nil?

  @browser
end

#browser_versionObject



27
28
29
30
31
# File 'lib/skadi/user_agent.rb', line 27

def browser_version
  parse_browser if @browser_version.nil?

  @browser_version
end

#engineObject



33
34
35
36
37
# File 'lib/skadi/user_agent.rb', line 33

def engine
  parse_engine if @engine.nil?

  @engine
end

#engine_versionObject



39
40
41
42
43
# File 'lib/skadi/user_agent.rb', line 39

def engine_version
  parse_engine if @engine_version.nil?

  @engine_version
end

#human?Boolean

Returns:

  • (Boolean)


65
# File 'lib/skadi/user_agent.rb', line 65

def human? = !bot?

#osObject



45
46
47
48
49
# File 'lib/skadi/user_agent.rb', line 45

def os
  parse_os if @os.nil?

  @os
end

#to_hObject



67
68
69
70
71
72
73
74
75
76
# File 'lib/skadi/user_agent.rb', line 67

def to_h
  {
    browser: browser,
    browser_version: browser_version,
    engine: engine,
    engine_version: engine_version,
    os: os,
    bot: bot?,
  }
end