browserx 1.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Browser
6
+ class Bot
7
+ # The IP ranges crawler owners publish, from bot_ip_ranges.yml (rewritten
8
+ # weekly by script/sync_ip_ranges.rb) or fetched again by `refresh`.
9
+ module IPRanges
10
+ GOOGLE_RANGES_URL = "https://developers.google.com/static/crawling/ipranges"
11
+ SOURCES = {
12
+ google: {
13
+ keywords: %w[
14
+ adsbot-google apis-google feedfetcher-google google-cloudvertexbot google-inspectiontool
15
+ google-read-aloud google-safety google-site-verification googleagent-urlcontext googlebot
16
+ googleother googleproducer mediapartners-google storebot-google
17
+ ],
18
+ # https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot
19
+ domains: %w[googlebot.com google.com googleusercontent.com],
20
+ urls: %w[common-crawlers.json special-crawlers.json user-triggered-fetchers.json
21
+ user-triggered-fetchers-google.json user-triggered-agents.json].map { |file| "#{GOOGLE_RANGES_URL}/#{file}" }
22
+ },
23
+ bing: {
24
+ keywords: %w[adidxbot bingbot bingpreview microsoftpreview msnbot],
25
+ domains: %w[search.msn.com],
26
+ urls: %w[https://www.bing.com/toolbox/bingbot.json]
27
+ },
28
+ openai: {
29
+ keywords: %w[chatgpt-user gptbot oai-searchbot],
30
+ domains: [],
31
+ urls: %w[https://openai.com/gptbot.json https://openai.com/searchbot.json
32
+ https://openai.com/chatgpt-user.json]
33
+ },
34
+ apple: {
35
+ keywords: %w[applebot],
36
+ domains: %w[applebot.apple.com],
37
+ urls: %w[https://search.developer.apple.com/applebot.json]
38
+ },
39
+ perplexity: {
40
+ keywords: %w[perplexity-user perplexitybot],
41
+ domains: [],
42
+ urls: %w[https://www.perplexity.ai/perplexitybot.json https://www.perplexity.ai/perplexity-user.json]
43
+ },
44
+ duckduckgo: {
45
+ keywords: %w[duckassistbot duckduckbot],
46
+ domains: [],
47
+ urls: %w[https://duckduckgo.com/duckduckbot.json https://duckduckgo.com/duckassistbot.json]
48
+ }
49
+ }.freeze
50
+ FETCH_TIMEOUT_SECONDS = 10
51
+ # Owners move their files: Google's old URLs redirect to the new ones.
52
+ MAX_REDIRECTS = 3
53
+ DNS_TIMEOUT_SECONDS = 2
54
+
55
+ # The owner whose crawler the lowercase user agent claims to be.
56
+ def self.owner(ua)
57
+ SOURCES.each_key.find do |owner|
58
+ SOURCES[owner][:keywords].any? { |keyword| ua.include?(keyword) }
59
+ end
60
+ end
61
+
62
+ def self.include?(owner, ip)
63
+ address = parse(ip)
64
+ return false unless address
65
+
66
+ starts, ends = ranges_by_owner.dig(owner, address.family)
67
+ return false unless starts
68
+
69
+ number = address.to_i
70
+ index = (starts.bsearch_index { |start| start > number } || starts.size) - 1
71
+ index >= 0 && number <= ends[index]
72
+ end
73
+
74
+ # Google's documented method: the reverse lookup names one of the owner's
75
+ # domains, and that name resolves back to the IP. Two DNS queries.
76
+ def self.verified_by_dns?(owner, ip)
77
+ address = parse(ip)
78
+ domains = SOURCES.dig(owner, :domains)
79
+ return false unless address && domains&.any?
80
+
81
+ require "resolv"
82
+ Resolv::DNS.open do |dns|
83
+ dns.timeouts = DNS_TIMEOUT_SECONDS
84
+ host = dns.getname(address.to_s).to_s.downcase
85
+ domains.any? { |domain| host == domain || host.end_with?(".#{domain}") } &&
86
+ dns.getaddresses(host).any? { |resolved| IPAddr.new(resolved.to_s) == address }
87
+ end
88
+ rescue Resolv::ResolvError, Resolv::ResolvTimeout, SystemCallError
89
+ false
90
+ end
91
+
92
+ # Prefixes ("66.249.64.0/27"), keyed by owner.
93
+ def self.prefixes_by_owner
94
+ @prefixes_by_owner ||= Browser.load_data("bot_ip_ranges").transform_keys(&:to_sym)
95
+ end
96
+
97
+ def self.ranges_by_owner
98
+ @ranges_by_owner ||= build_ranges(prefixes_by_owner)
99
+ end
100
+
101
+ # For long-lived processes: fetches every owner's ranges again and swaps
102
+ # them in at once. Raises Browser::Error and keeps the current ranges when
103
+ # a source fails.
104
+ def self.refresh
105
+ load(fetch)
106
+ end
107
+
108
+ def self.load(prefixes_by_owner)
109
+ ranges_by_owner = build_ranges(prefixes_by_owner)
110
+ @prefixes_by_owner = prefixes_by_owner
111
+ @ranges_by_owner = ranges_by_owner
112
+ end
113
+
114
+ def self.fetch
115
+ SOURCES.to_h do |owner, source|
116
+ addresses = source[:urls].flat_map { |url| fetch_prefixes(url) }.map do |prefix|
117
+ IPAddr.new(prefix)
118
+ rescue ArgumentError
119
+ raise Error, "#{owner}: invalid prefix #{prefix.inspect}"
120
+ end
121
+ sorted = addresses.uniq.sort_by { |address| [address.family, address.to_i, address.prefix] }
122
+ [owner, sorted.map { |address| "#{address}/#{address.prefix}" }]
123
+ end
124
+ end
125
+
126
+ def self.fetch_prefixes(url)
127
+ require "json"
128
+ require "net/http"
129
+ uri = URI(url)
130
+ response = nil
131
+ (MAX_REDIRECTS + 1).times do
132
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
133
+ open_timeout: FETCH_TIMEOUT_SECONDS, read_timeout: FETCH_TIMEOUT_SECONDS) do |http|
134
+ http.get(uri.request_uri, "User-Agent" => "BrowserX/#{VERSION} (+https://github.com/next-gen-gems/browserx)")
135
+ end
136
+ break unless response.is_a?(Net::HTTPRedirection)
137
+
138
+ uri = uri.merge(response["Location"])
139
+ end
140
+ raise Error, "#{url}: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
141
+
142
+ prefixes = JSON.parse(response.body).fetch("prefixes").map do |prefix|
143
+ prefix["ipv4Prefix"] || prefix["ipv6Prefix"]
144
+ end
145
+ raise Error, "#{url}: no prefixes" if prefixes.empty?
146
+
147
+ prefixes
148
+ rescue JSON::ParserError, KeyError, Timeout::Error, IOError, SystemCallError, OpenSSL::SSL::SSLError => exception
149
+ raise Error, "#{url}: #{exception.message}"
150
+ end
151
+
152
+ # Per owner and address family, the merged ranges as two sorted arrays of
153
+ # integers, first and last addresses, for a binary search.
154
+ def self.build_ranges(prefixes_by_owner)
155
+ prefixes_by_owner.to_h do |owner, prefixes|
156
+ ranges_by_family = prefixes.map { |prefix| IPAddr.new(prefix) }.group_by(&:family)
157
+ [owner.to_sym, ranges_by_family.transform_values { |addresses| merge(addresses) }]
158
+ end
159
+ end
160
+
161
+ def self.merge(addresses)
162
+ ranges = addresses.map do |address|
163
+ range = address.to_range
164
+ [range.begin.to_i, range.end.to_i]
165
+ end
166
+ ranges.sort!
167
+ starts = []
168
+ ends = []
169
+ ranges.each do |first, last|
170
+ if ends.any? && first <= ends.last + 1
171
+ ends[-1] = last if last > ends.last
172
+ else
173
+ starts << first
174
+ ends << last
175
+ end
176
+ end
177
+ [starts, ends]
178
+ end
179
+
180
+ # A forged or malformed X-Forwarded-For must not raise in a request.
181
+ def self.parse(ip)
182
+ ip = ip.to_s
183
+ return if ip.include?("/")
184
+
185
+ address = IPAddr.new(ip)
186
+ address.ipv4_mapped? ? address.native : address
187
+ rescue ArgumentError
188
+ nil
189
+ end
190
+
191
+ private_class_method :fetch_prefixes, :build_ranges, :merge, :parse
192
+ end
193
+ end
194
+ end
@@ -4,7 +4,7 @@ module Browser
4
4
  class Bot
5
5
  class KnownBotsMatcher
6
6
  def self.call(ua, _browser)
7
- ua.match?(Browser::Bot.keywords_pattern(Browser::Bot.bots))
7
+ !Browser::Bot.known_bot_rank(ua, lowest: false).nil?
8
8
  end
9
9
  end
10
10
  end
data/lib/browser/bot.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  module Browser
4
4
  class Bot
5
5
  GENERIC_NAME = "Generic Bot"
6
+ AI_CATEGORIES = %i[ai_agent ai_assistant ai_crawler ai_search].freeze
7
+ USER_TRIGGERED_CATEGORIES = %i[ai_agent ai_assistant].freeze
6
8
 
7
9
  def self.matchers
8
10
  @matchers ||= default_matchers
@@ -12,20 +14,33 @@ module Browser
12
14
  DEFAULT_MATCHERS.dup
13
15
  end
14
16
 
17
+ # browser's API. The gem ships its lists as JSON only: load_yaml("bots.yml")
18
+ # reads data/bots.json.
15
19
  def self.load_yaml(path)
20
+ json_path = Browser.root.join("data", path.to_s.sub(/\.yml\z/, ".json"))
21
+ return JSON.load_file(json_path) if json_path.file?
22
+
23
+ require "yaml"
16
24
  YAML.load_file(Browser.root.join(path))
17
25
  end
18
26
 
19
27
  def self.bots
20
- @bots ||= load_yaml("bots.yml")
28
+ @bots ||= Browser.load_data("bots")
21
29
  end
22
30
 
23
31
  def self.bot_exceptions
24
- @bot_exceptions ||= load_yaml("bot_exceptions.yml")
32
+ @bot_exceptions ||= Browser.load_data("bot_exceptions")
25
33
  end
26
34
 
27
35
  def self.search_engines
28
- @search_engines ||= load_yaml("search_engines.yml")
36
+ @search_engines ||= Browser.load_data("search_engines")
37
+ end
38
+
39
+ def self.category_by_keyword
40
+ @category_by_keyword ||=
41
+ Browser.load_data("bot_categories").each_with_object({}) do |(category, keywords), category_by_keyword|
42
+ keywords.each { |keyword| category_by_keyword[keyword] = category.to_sym }
43
+ end
29
44
  end
30
45
 
31
46
  # One regexp per keyword list (an Array, or a Hash keyed by keyword),
@@ -41,6 +56,43 @@ module Browser
41
56
  pattern
42
57
  end
43
58
 
59
+ # bots.yml keywords nested by byte, each end holding the keyword's rank.
60
+ # Walked from each byte of a user agent, it follows the few branches that
61
+ # byte opens, where a regexp of a thousand keywords tries most of them.
62
+ def self.known_bots_trie
63
+ list, size, trie = @known_bots_trie
64
+ return trie if list.equal?(bots) && size == bots.size
65
+
66
+ trie = {keywords: bots.keys}
67
+ trie[:keywords].each_with_index do |keyword, rank|
68
+ leaf = keyword.to_s.each_byte.reduce(trie) { |node, byte| node[byte] ||= {} }
69
+ leaf[:rank] ||= rank
70
+ end
71
+ @known_bots_trie = [bots, bots.size, trie]
72
+ trie
73
+ end
74
+
75
+ # The rank of a bots.yml keyword the user agent holds: the lowest, or the
76
+ # first one found when `lowest: false`.
77
+ def self.known_bot_rank(ua, lowest: true)
78
+ trie = known_bots_trie
79
+ found = nil
80
+ ua.bytesize.times do |start|
81
+ node = trie
82
+ position = start
83
+ while (node = node[ua.getbyte(position)])
84
+ rank = node[:rank]
85
+ if rank
86
+ return rank unless lowest
87
+
88
+ found = rank if found.nil? || rank < found
89
+ end
90
+ position += 1
91
+ end
92
+ end
93
+ found
94
+ end
95
+
44
96
  # A Regexp.union of hundreds of words tries each of them at every
45
97
  # position; nested by common prefix, the regexp follows a single branch
46
98
  # per character.
@@ -76,8 +128,14 @@ module Browser
76
128
  @ua = ua.downcase.strip
77
129
  end
78
130
 
131
+ # A bot is shared by the browsers of its user agent: each answer holds
132
+ # until a matcher list or the bot list changes.
79
133
  def bot?
80
- !bot_exception? && detect_bot?
134
+ generation = Cache.generation
135
+ unless @bot&.first == generation
136
+ @bot = [generation, !bot_exception? && detect_bot?]
137
+ end
138
+ @bot.last
81
139
  end
82
140
 
83
141
  def why?
@@ -91,7 +149,51 @@ module Browser
91
149
  def name
92
150
  return unless bot?
93
151
 
94
- self.class.bots.find { |key, _| ua.include?(key) }&.last || GENERIC_NAME
152
+ generation = Cache.generation
153
+ unless @name&.first == generation
154
+ @name = [generation, self.class.bots[known_bot_keyword] || GENERIC_NAME]
155
+ end
156
+ @name.last
157
+ end
158
+
159
+ # Test drivers have a category, but no bot? (bot_categories.yml).
160
+ def category
161
+ return if bot_exception?
162
+
163
+ keyword = bot? && known_bot_keyword
164
+ keyword ||= ua[self.class.keywords_pattern(self.class.category_by_keyword)]
165
+ self.class.category_by_keyword[keyword]
166
+ end
167
+
168
+ def ai?
169
+ AI_CATEGORIES.include?(category)
170
+ end
171
+
172
+ def user_triggered?
173
+ USER_TRIGGERED_CATEGORIES.include?(category)
174
+ end
175
+
176
+ # The owner publishing the IP ranges of the crawler this user agent claims
177
+ # to be (:google, :bing, :openai, :apple, :perplexity, :duckduckgo).
178
+ def owner
179
+ return unless bot?
180
+
181
+ @owner = IPRanges.owner(ua) unless defined?(@owner)
182
+ @owner
183
+ end
184
+
185
+ # Whether the IP belongs to the owner's published ranges. reverse_dns: true
186
+ # also asks the DNS for an IP outside them (Google, Bing and Apple): two
187
+ # network queries.
188
+ def verified?(ip, reverse_dns: false)
189
+ return false unless owner
190
+
191
+ IPRanges.include?(owner, ip) || (reverse_dns && IPRanges.verified_by_dns?(owner, ip))
192
+ end
193
+
194
+ private def known_bot_keyword
195
+ rank = self.class.known_bot_rank(ua)
196
+ self.class.known_bots_trie[:keywords][rank] if rank
95
197
  end
96
198
 
97
199
  private def bot_exception?
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
3
+ if Gem.loaded_specs.key?("browser")
4
+ raise LoadError, "browserx replaces the browser gem, they can't be loaded together: " \
5
+ "remove gem \"browser\" from the Gemfile (or whatever depends on it)"
6
+ end
7
+
8
+ require "json"
4
9
  require "pathname"
5
10
 
6
11
  require_relative "version"
@@ -42,11 +47,16 @@ require_relative "bot"
42
47
  require_relative "bot/empty_user_agent_matcher"
43
48
  require_relative "bot/keyword_matcher"
44
49
  require_relative "bot/known_bots_matcher"
50
+ require_relative "bot/ip_ranges"
45
51
 
46
52
  require_relative "middleware"
47
53
  require_relative "platform"
48
54
  require_relative "device"
49
55
  require_relative "meta"
56
+ require_relative "client_hints"
57
+ require_relative "cloudflare"
58
+ require_relative "detection"
59
+ require_relative "cache"
50
60
 
51
61
  module Browser
52
62
  EMPTY_STRING = ""
@@ -61,13 +71,41 @@ module Browser
61
71
  @root ||= Pathname.new(File.expand_path("../..", __dir__))
62
72
  end
63
73
 
74
+ # data/bots.json for "bots", written from bots.yml by script/build_data.rb: edit the YAML.
75
+ def self.load_data(name)
76
+ JSON.load_file(root.join("data", "#{name}.json"))
77
+ end
78
+
79
+ def self.eager_load!
80
+ [self, Platform, Device, Bot].each(&:matchers)
81
+ Meta.rules
82
+ AcceptLanguage.languages
83
+ Device::Samsung.names
84
+ Bot.known_bots_trie
85
+ [Bot.bot_exceptions, Bot.search_engines, Bot.category_by_keyword].each { |keywords| Bot.keywords_pattern(keywords) }
86
+ Bot::IPRanges.ranges_by_owner
87
+ nil
88
+ end
89
+
64
90
  class << self
65
91
  attr_accessor :user_agent_size_limit, :accept_language_size_limit
92
+ attr_reader :cache_size
66
93
  end
67
94
 
68
95
  self.user_agent_size_limit = 2048
69
96
  self.accept_language_size_limit = 2048
70
97
 
98
+ def self.cache_size=(size)
99
+ unless size.is_a?(Integer) && size >= 0
100
+ raise ArgumentError, "cache_size must be an Integer >= 0, got #{size.inspect}"
101
+ end
102
+
103
+ @cache_size = size
104
+ Cache.clear
105
+ end
106
+
107
+ self.cache_size = 1000
108
+
71
109
  # Hold the list of browser matchers.
72
110
  # Order is important.
73
111
  def self.matchers
@@ -107,10 +145,56 @@ module Browser
107
145
  ]
108
146
  end
109
147
 
148
+ # A pattern that every user agent a built-in browser matches also matches:
149
+ # Browser.new skips, without building it, a browser whose pattern fails.
150
+ REQUIRED_PATTERN_BY_MATCHER = {
151
+ Nokia => /S40OviBrowser/,
152
+ UCBrowser => /UCBrowser/,
153
+ PhantomJS => /PhantomJS/,
154
+ BlackBerry => /BlackBerry|BB10/,
155
+ Opera => %r{Opera|OPR/},
156
+ Edge => %r{Edg|Trident/8},
157
+ InternetExplorer => %r{MSIE|Trident/},
158
+ Firefox => /Firefox|FxiOS/,
159
+ Otter => /Otter/,
160
+ Epiphany => /Epiphany/,
161
+ Facebook => /FBAV|FBAN/,
162
+ Instagram => /Instagram/,
163
+ Snapchat => /Snapchat/,
164
+ Weibo => /__weibo__/i,
165
+ MicroMessenger => /MicroMessenger/i,
166
+ QQ => %r{QQ/|QQBrowser}i,
167
+ Alipay => /AlipayClient/i,
168
+ Electron => /Electron/,
169
+ Yandex => /YaBrowser/,
170
+ Sputnik => /SputnikBrowser/,
171
+ DuckDuckGo => /DuckDuckGo/,
172
+ SamsungBrowser => /SamsungBrowser/,
173
+ HuaweiBrowser => /HuaweiBrowser/i,
174
+ MiuiBrowser => /MiuiBrowser/,
175
+ Maxthon => /Maxthon/i,
176
+ SougouBrowser => /(?i:SogouMobileBrowser)|\bSE\b/,
177
+ GoogleSearchApp => /GSA/
178
+ }.compare_by_identity.freeze
179
+
110
180
  def self.new(user_agent, **)
181
+ user_agent ||= EMPTY_STRING
182
+ browser_class = Cache.cached_detection(user_agent)&.browser_class
183
+ return browser_class.new(user_agent, **) if browser_class
184
+
185
+ browser = detect(user_agent, **)
186
+ Cache.detection(user_agent).browser_class = browser.class
187
+ browser
188
+ end
189
+
190
+ def self.detect(user_agent, **)
111
191
  matchers.each do |matcher_class|
112
- matcher = matcher_class.new(user_agent || EMPTY_STRING, **)
192
+ required_pattern = REQUIRED_PATTERN_BY_MATCHER[matcher_class]
193
+ next if required_pattern && !required_pattern.match?(user_agent)
194
+
195
+ matcher = matcher_class.new(user_agent, **)
113
196
  return matcher if matcher.match?
114
197
  end
115
198
  end
199
+ private_class_method :detect
116
200
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ # The detections of the last user agents and the parsed Accept-Language
5
+ # headers, shared by the threads of a process. Reads take no lock: under the
6
+ # GVL a Hash read is atomic, and a thread that misses detects again. Writes
7
+ # do, for the Rubies without a GVL.
8
+ module Cache
9
+ @mutex = Mutex.new
10
+ @detection_by_user_agent = {}
11
+ @languages_by_header = {}
12
+ @generation = 0
13
+
14
+ def self.cached_detection(ua)
15
+ current_detection_by_user_agent[ua]
16
+ end
17
+
18
+ def self.detection(ua)
19
+ return Detection.new(ua) if Browser.cache_size.zero?
20
+
21
+ cached_detection(ua) || @mutex.synchronize do
22
+ ua = -ua
23
+ store(@detection_by_user_agent, ua, Detection.new(ua))
24
+ end
25
+ end
26
+
27
+ # A copy each time: callers may change the array they get.
28
+ def self.accept_language(header)
29
+ return AcceptLanguage.parse(header) if Browser.cache_size.zero?
30
+
31
+ languages = @languages_by_header[header] || @mutex.synchronize do
32
+ store(@languages_by_header, -header,
33
+ AcceptLanguage.parse(header).freeze)
34
+ end
35
+ languages.dup
36
+ end
37
+
38
+ # Bumped when a matcher list, the bot list or its exceptions change: the
39
+ # matchers are public arrays that apps extend at runtime.
40
+ def self.generation
41
+ return @generation if lists_unchanged?
42
+
43
+ @mutex.synchronize do
44
+ @generation += 1
45
+ @detection_by_user_agent.clear
46
+ remember_lists
47
+ end
48
+ @generation
49
+ end
50
+
51
+ def self.clear
52
+ @mutex.synchronize do
53
+ @detection_by_user_agent.clear
54
+ @languages_by_header.clear
55
+ end
56
+ end
57
+
58
+ # The bot list holds hundreds of entries: compared by identity and size,
59
+ # like Bot.keywords_pattern, not entry by entry on every request.
60
+ def self.lists_unchanged?
61
+ @browser_matchers == Browser.matchers &&
62
+ @platform_matchers == Platform.matchers &&
63
+ @device_matchers == Device.matchers &&
64
+ @bot_matchers == Bot.matchers &&
65
+ @bot_exceptions == Bot.bot_exceptions &&
66
+ @bots.equal?(Bot.bots) && @bots_size == Bot.bots.size
67
+ end
68
+
69
+ def self.remember_lists
70
+ @browser_matchers = Browser.matchers.dup
71
+ @platform_matchers = Platform.matchers.dup
72
+ @device_matchers = Device.matchers.dup
73
+ @bot_matchers = Bot.matchers.dup
74
+ @bot_exceptions = Bot.bot_exceptions.dup
75
+ @bots = Bot.bots
76
+ @bots_size = Bot.bots.size
77
+ end
78
+
79
+ def self.current_detection_by_user_agent
80
+ generation
81
+ @detection_by_user_agent
82
+ end
83
+
84
+ def self.store(value_by_key, key, value)
85
+ value_by_key.clear if value_by_key.size >= Browser.cache_size
86
+ value_by_key[key] = value
87
+ end
88
+
89
+ private_class_method :lists_unchanged?, :remember_lists,
90
+ :current_detection_by_user_agent, :store
91
+ end
92
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ # The User-Agent Client Hints of Chromium browsers, read from a Rack env.
5
+ # Safari and Firefox send none: every answer is then nil.
6
+ class ClientHints
7
+ # A browser's hints are a few dozen bytes: longer values are ignored.
8
+ HEADER_SIZE_LIMIT_BYTES = 512
9
+ PLATFORM_CLASS_BY_NAME = {
10
+ "Android" => Platform::Android,
11
+ "Chrome OS" => Platform::ChromeOS,
12
+ "Linux" => Platform::Linux,
13
+ "macOS" => Platform::Mac,
14
+ "Windows" => Platform::Windows
15
+ }.freeze
16
+ # https://learn.microsoft.com/en-us/microsoft-edge/web-platform/how-to-detect-win11
17
+ WINDOWS_11_PLATFORM_VERSION = 13
18
+ BRAND_PATTERN = /"([^"\\]*)"\s*;\s*v\s*=\s*"([^"\\]*)"/
19
+ # GREASE brands, sent so that servers don't depend on the list's order.
20
+ GREASE_BRAND_PATTERN = /\ANot.A.Brand\z/i
21
+ QUOTED_STRING_PATTERN = /\A\s*"([^"\\]+)"\s*\z/
22
+ MOBILE_BY_VALUE = {"?1" => true, "?0" => false}.freeze
23
+
24
+ # Brand versions keyed by brand ("Google Chrome" => "142"), from Sec-CH-UA.
25
+ attr_reader :version_by_brand
26
+ # Sec-CH-UA-Platform ("Windows", "macOS", "Android"…).
27
+ attr_reader :platform
28
+ # Sec-CH-UA-Platform-Version ("15.0.0"), sent only to a site asking for it
29
+ # with an Accept-CH: Sec-CH-UA-Platform-Version response header.
30
+ attr_reader :platform_version
31
+
32
+ def initialize(headers)
33
+ @version_by_brand = parse_brands(header(headers, "HTTP_SEC_CH_UA"))
34
+ @mobile = MOBILE_BY_VALUE[header(headers, "HTTP_SEC_CH_UA_MOBILE")&.strip]
35
+ @platform = quoted_string(header(headers, "HTTP_SEC_CH_UA_PLATFORM"))
36
+ @platform_version = quoted_string(header(headers, "HTTP_SEC_CH_UA_PLATFORM_VERSION"))
37
+ freeze
38
+ end
39
+
40
+ # Sec-CH-UA-Mobile: nil when the browser did not send it.
41
+ def mobile?
42
+ @mobile
43
+ end
44
+
45
+ def platform_class
46
+ PLATFORM_CLASS_BY_NAME[platform]
47
+ end
48
+
49
+ # Windows 11 says "Windows NT 10.0" in its user agent: only its platform
50
+ # version tells it from Windows 10.
51
+ def windows11?
52
+ platform == "Windows" && platform_version.to_i >= WINDOWS_11_PLATFORM_VERSION
53
+ end
54
+
55
+ private def header(headers, name)
56
+ value = headers[name]
57
+ value if value.is_a?(String) && value.bytesize <= HEADER_SIZE_LIMIT_BYTES
58
+ end
59
+
60
+ private def parse_brands(value)
61
+ return EMPTY_HASH unless value
62
+
63
+ value.scan(BRAND_PATTERN).to_h.reject { |brand, _version| GREASE_BRAND_PATTERN.match?(brand) }.freeze
64
+ end
65
+
66
+ private def quoted_string(value)
67
+ value&.[](QUOTED_STRING_PATTERN, 1)
68
+ end
69
+
70
+ EMPTY_HASH = {}.freeze
71
+ NONE = new(EMPTY_HASH)
72
+ end
73
+ end