agentry 3.0.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.
Files changed (119) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +93 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +552 -0
  5. data/data/bot_categories.json +1 -0
  6. data/data/bot_exceptions.json +1 -0
  7. data/data/bot_ip_ranges.json +1 -0
  8. data/data/bots.json +1 -0
  9. data/data/languages.json +1 -0
  10. data/data/samsung.json +1 -0
  11. data/data/search_engines.json +1 -0
  12. data/lib/agentry.rb +3 -0
  13. data/lib/browser/accept_language.rb +55 -0
  14. data/lib/browser/action_controller.rb +21 -0
  15. data/lib/browser/aliases.rb +33 -0
  16. data/lib/browser/alipay.rb +21 -0
  17. data/lib/browser/base.rb +307 -0
  18. data/lib/browser/blackberry.rb +23 -0
  19. data/lib/browser/bot/empty_user_agent_matcher.rb +11 -0
  20. data/lib/browser/bot/ip_ranges.rb +194 -0
  21. data/lib/browser/bot/keyword_matcher.rb +11 -0
  22. data/lib/browser/bot/known_bots_matcher.rb +11 -0
  23. data/lib/browser/bot.rb +221 -0
  24. data/lib/browser/browser.rb +200 -0
  25. data/lib/browser/cache.rb +92 -0
  26. data/lib/browser/chrome.rb +43 -0
  27. data/lib/browser/client_hints.rb +73 -0
  28. data/lib/browser/cloudflare.rb +65 -0
  29. data/lib/browser/detect_version.rb +22 -0
  30. data/lib/browser/detection.rb +25 -0
  31. data/lib/browser/device/android.rb +20 -0
  32. data/lib/browser/device/base.rb +13 -0
  33. data/lib/browser/device/blackberry_playbook.rb +19 -0
  34. data/lib/browser/device/ipad.rb +19 -0
  35. data/lib/browser/device/iphone.rb +19 -0
  36. data/lib/browser/device/ipod_touch.rb +19 -0
  37. data/lib/browser/device/kindle.rb +19 -0
  38. data/lib/browser/device/kindle_fire.rb +19 -0
  39. data/lib/browser/device/playstation3.rb +19 -0
  40. data/lib/browser/device/playstation4.rb +19 -0
  41. data/lib/browser/device/psp.rb +19 -0
  42. data/lib/browser/device/psvita.rb +19 -0
  43. data/lib/browser/device/samsung.rb +33 -0
  44. data/lib/browser/device/surface.rb +23 -0
  45. data/lib/browser/device/switch.rb +19 -0
  46. data/lib/browser/device/tv.rb +19 -0
  47. data/lib/browser/device/unknown.rb +19 -0
  48. data/lib/browser/device/wii.rb +19 -0
  49. data/lib/browser/device/wiiu.rb +19 -0
  50. data/lib/browser/device/xbox_360.rb +19 -0
  51. data/lib/browser/device/xbox_one.rb +19 -0
  52. data/lib/browser/device.rb +246 -0
  53. data/lib/browser/duck_duck_go.rb +22 -0
  54. data/lib/browser/edge.rb +25 -0
  55. data/lib/browser/electron.rb +22 -0
  56. data/lib/browser/epiphany.rb +21 -0
  57. data/lib/browser/facebook.rb +23 -0
  58. data/lib/browser/firefox.rb +21 -0
  59. data/lib/browser/google_search_app.rb +21 -0
  60. data/lib/browser/huawei_browser.rb +21 -0
  61. data/lib/browser/instagram.rb +21 -0
  62. data/lib/browser/internet_explorer.rb +63 -0
  63. data/lib/browser/maxthon.rb +21 -0
  64. data/lib/browser/meta/base.rb +42 -0
  65. data/lib/browser/meta/device.rb +11 -0
  66. data/lib/browser/meta/generic_browser.rb +15 -0
  67. data/lib/browser/meta/id.rb +11 -0
  68. data/lib/browser/meta/ie.rb +20 -0
  69. data/lib/browser/meta/ios.rb +11 -0
  70. data/lib/browser/meta/mobile.rb +11 -0
  71. data/lib/browser/meta/platform.rb +11 -0
  72. data/lib/browser/meta/proxy.rb +11 -0
  73. data/lib/browser/meta/safari.rb +11 -0
  74. data/lib/browser/meta/tablet.rb +11 -0
  75. data/lib/browser/meta/webkit.rb +11 -0
  76. data/lib/browser/meta.rb +14 -0
  77. data/lib/browser/micro_messenger.rb +21 -0
  78. data/lib/browser/middleware/context/additions.rb +18 -0
  79. data/lib/browser/middleware/context/url_methods.rb +15 -0
  80. data/lib/browser/middleware/context.rb +23 -0
  81. data/lib/browser/middleware.rb +70 -0
  82. data/lib/browser/miui_browser.rb +21 -0
  83. data/lib/browser/nokia.rb +21 -0
  84. data/lib/browser/opera.rb +21 -0
  85. data/lib/browser/otter.rb +21 -0
  86. data/lib/browser/phantom_js.rb +21 -0
  87. data/lib/browser/platform/adobe_air.rb +23 -0
  88. data/lib/browser/platform/android.rb +23 -0
  89. data/lib/browser/platform/base.rb +18 -0
  90. data/lib/browser/platform/blackberry.rb +23 -0
  91. data/lib/browser/platform/chrome_os.rb +23 -0
  92. data/lib/browser/platform/firefox_os.rb +23 -0
  93. data/lib/browser/platform/ios.rb +36 -0
  94. data/lib/browser/platform/kai_os.rb +23 -0
  95. data/lib/browser/platform/linux.rb +23 -0
  96. data/lib/browser/platform/mac.rb +25 -0
  97. data/lib/browser/platform/unknown.rb +23 -0
  98. data/lib/browser/platform/windows.rb +23 -0
  99. data/lib/browser/platform/windows_mobile.rb +23 -0
  100. data/lib/browser/platform/windows_phone.rb +23 -0
  101. data/lib/browser/platform.rb +220 -0
  102. data/lib/browser/qq.rb +25 -0
  103. data/lib/browser/rails.rb +24 -0
  104. data/lib/browser/safari.rb +40 -0
  105. data/lib/browser/samsung_browser.rb +21 -0
  106. data/lib/browser/snapchat.rb +21 -0
  107. data/lib/browser/sougou_browser.rb +24 -0
  108. data/lib/browser/sputnik.rb +21 -0
  109. data/lib/browser/testing.rb +29 -0
  110. data/lib/browser/uc_browser.rb +21 -0
  111. data/lib/browser/unknown.rb +32 -0
  112. data/lib/browser/version.rb +5 -0
  113. data/lib/browser/weibo.rb +21 -0
  114. data/lib/browser/yandex.rb +21 -0
  115. data/lib/browser.rb +4 -0
  116. data/test/ua.yml +181 -0
  117. data/test/ua_bots.yml +121 -0
  118. data/test/ua_search_engines.yml +7 -0
  119. metadata +274 -0
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ class Bot
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
8
+
9
+ def self.matchers
10
+ @matchers ||= default_matchers
11
+ end
12
+
13
+ def self.default_matchers
14
+ DEFAULT_MATCHERS.dup
15
+ end
16
+
17
+ # browser's API. The gem ships its lists as JSON only: load_yaml("bots.yml")
18
+ # reads data/bots.json.
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"
24
+ YAML.load_file(Browser.root.join(path))
25
+ end
26
+
27
+ def self.bots
28
+ @bots ||= Browser.load_data("bots")
29
+ end
30
+
31
+ def self.bot_exceptions
32
+ @bot_exceptions ||= Browser.load_data("bot_exceptions")
33
+ end
34
+
35
+ def self.search_engines
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
44
+ end
45
+
46
+ # One regexp per keyword list (an Array, or a Hash keyed by keyword),
47
+ # rebuilt when the list grows or is replaced.
48
+ def self.keywords_pattern(keywords)
49
+ @keywords_pattern_by_list ||= {}.compare_by_identity
50
+ size, pattern = @keywords_pattern_by_list[keywords]
51
+ return pattern if size == keywords.size
52
+
53
+ words = (keywords.is_a?(Hash) ? keywords.keys : keywords).map(&:to_s)
54
+ pattern = Regexp.new(trie_source(words))
55
+ @keywords_pattern_by_list[keywords] = [keywords.size, pattern]
56
+ pattern
57
+ end
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
+
96
+ # A Regexp.union of hundreds of words tries each of them at every
97
+ # position; nested by common prefix, the regexp follows a single branch
98
+ # per character.
99
+ def self.trie_source(words)
100
+ trie = {}
101
+ words.each do |word|
102
+ leaf = word.each_char.reduce(trie) { |node, char| node[char] ||= {} }
103
+ leaf[:end] = true
104
+ end
105
+ trie_node_source(trie) || "(?!)"
106
+ end
107
+
108
+ def self.trie_node_source(node)
109
+ # A shorter keyword matches wherever a longer one would.
110
+ return "" if node.key?(:end)
111
+
112
+ branches = node.map do |char, child|
113
+ Regexp.escape(char) + trie_node_source(child)
114
+ end
115
+ (branches.size == 1) ? branches.first : "(?:#{branches.join("|")})"
116
+ end
117
+ private_class_method :trie_source, :trie_node_source
118
+
119
+ def self.why?(ua)
120
+ ua = ua.downcase.strip
121
+ browser = Browser.new(ua)
122
+ matchers.find { |matcher| matcher.call(ua, browser) }
123
+ end
124
+
125
+ attr_reader :ua
126
+
127
+ def initialize(ua)
128
+ @ua = ua.downcase.strip
129
+ end
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.
133
+ def bot?
134
+ generation = Cache.generation
135
+ unless @bot&.first == generation
136
+ @bot = [generation, !bot_exception? && detect_bot?]
137
+ end
138
+ @bot.last
139
+ end
140
+
141
+ def why?
142
+ self.class.matchers.find { |matcher| matcher.call(ua, self) }
143
+ end
144
+
145
+ def search_engine?
146
+ ua.match?(self.class.keywords_pattern(self.class.search_engines))
147
+ end
148
+
149
+ def name
150
+ return unless bot?
151
+
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
197
+ end
198
+
199
+ private def bot_exception?
200
+ ua.match?(self.class.keywords_pattern(self.class.bot_exceptions))
201
+ end
202
+
203
+ private def detect_bot?
204
+ self.class.matchers.any? do |matcher|
205
+ matcher.call(ua, default_matchers.include?(matcher) ? nil : browser)
206
+ end
207
+ end
208
+
209
+ # The built-in matchers ignore the browser: building it would detect the
210
+ # user agent twice.
211
+ private def default_matchers
212
+ DEFAULT_MATCHERS
213
+ end
214
+
215
+ private def browser
216
+ @browser ||= Browser.new(ua)
217
+ end
218
+
219
+ private :ua
220
+ end
221
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ if Gem.loaded_specs.key?("browser")
4
+ raise LoadError, "agentry 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"
9
+ require "pathname"
10
+
11
+ require_relative "version"
12
+ require_relative "detect_version"
13
+ require_relative "accept_language"
14
+ require_relative "base"
15
+ require_relative "safari"
16
+ require_relative "chrome"
17
+ require_relative "internet_explorer"
18
+ require_relative "firefox"
19
+ require_relative "edge"
20
+ require_relative "opera"
21
+ require_relative "blackberry"
22
+ require_relative "unknown"
23
+ require_relative "phantom_js"
24
+ require_relative "uc_browser"
25
+ require_relative "nokia"
26
+ require_relative "micro_messenger"
27
+ require_relative "weibo"
28
+ require_relative "qq"
29
+ require_relative "alipay"
30
+ require_relative "electron"
31
+ require_relative "facebook"
32
+ require_relative "otter"
33
+ require_relative "instagram"
34
+ require_relative "yandex"
35
+ require_relative "sputnik"
36
+ require_relative "snapchat"
37
+ require_relative "duck_duck_go"
38
+ require_relative "samsung_browser"
39
+ require_relative "huawei_browser"
40
+ require_relative "miui_browser"
41
+ require_relative "maxthon"
42
+ require_relative "sougou_browser"
43
+ require_relative "google_search_app"
44
+ require_relative "epiphany"
45
+
46
+ require_relative "bot"
47
+ require_relative "bot/empty_user_agent_matcher"
48
+ require_relative "bot/keyword_matcher"
49
+ require_relative "bot/known_bots_matcher"
50
+ require_relative "bot/ip_ranges"
51
+
52
+ require_relative "middleware"
53
+ require_relative "platform"
54
+ require_relative "device"
55
+ require_relative "meta"
56
+ require_relative "client_hints"
57
+ require_relative "cloudflare"
58
+ require_relative "detection"
59
+ require_relative "cache"
60
+
61
+ module Browser
62
+ EMPTY_STRING = ""
63
+
64
+ Bot::DEFAULT_MATCHERS =
65
+ [Bot::EmptyUserAgentMatcher, Bot::KnownBotsMatcher, Bot::KeywordMatcher]
66
+ .freeze
67
+
68
+ Error = Class.new(StandardError)
69
+
70
+ def self.root
71
+ @root ||= Pathname.new(File.expand_path("../..", __dir__))
72
+ end
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
+
90
+ class << self
91
+ attr_accessor :user_agent_size_limit, :accept_language_size_limit
92
+ attr_reader :cache_size
93
+ end
94
+
95
+ self.user_agent_size_limit = 2048
96
+ self.accept_language_size_limit = 2048
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
+
109
+ # Hold the list of browser matchers.
110
+ # Order is important.
111
+ def self.matchers
112
+ @matchers ||= [
113
+ Nokia,
114
+ UCBrowser,
115
+ PhantomJS,
116
+ BlackBerry,
117
+ Opera,
118
+ Edge,
119
+ InternetExplorer,
120
+ Firefox,
121
+ Otter,
122
+ Epiphany,
123
+ Facebook,
124
+ Instagram,
125
+ Snapchat,
126
+ Weibo,
127
+ MicroMessenger, # must be placed before QQ
128
+ QQ,
129
+ Alipay,
130
+ Electron,
131
+ Yandex,
132
+ Sputnik,
133
+ DuckDuckGo,
134
+ SamsungBrowser,
135
+ HuaweiBrowser,
136
+ MiuiBrowser,
137
+ Maxthon,
138
+ SougouBrowser,
139
+ GoogleSearchApp,
140
+
141
+ # Must be last
142
+ Chrome,
143
+ Safari,
144
+ Unknown
145
+ ]
146
+ end
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
+
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, **)
191
+ matchers.each do |matcher_class|
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, **)
196
+ return matcher if matcher.match?
197
+ end
198
+ end
199
+ private_class_method :detect
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,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ class Chrome < Base
5
+ def id
6
+ :chrome
7
+ end
8
+
9
+ def name
10
+ "Chrome"
11
+ end
12
+
13
+ def full_version
14
+ # Each regex on its own line to enforce precedence.
15
+ ua[%r{Chrome/([\d.]+)}, 1] ||
16
+ ua[%r{CriOS/([\d.]+)}, 1] ||
17
+ ua[%r{Safari/([\d.]+)}, 1] ||
18
+ ua[%r{AppleWebKit/([\d.]+)}, 1] ||
19
+ "0.0"
20
+ end
21
+
22
+ def chromium_based?
23
+ true
24
+ end
25
+
26
+ def match?
27
+ ua.match?(/Chrome|CriOS/) &&
28
+ !ua.match?(/PhantomJS|FxiOS|ArchiveBot/) &&
29
+ !opera? &&
30
+ !edge? &&
31
+ !duck_duck_go? &&
32
+ !yandex? &&
33
+ !sputnik? &&
34
+ !samsung_browser? &&
35
+ !huawei_browser? &&
36
+ !miui_browser? &&
37
+ !maxthon? &&
38
+ !qq? &&
39
+ !sougou_browser? &&
40
+ !google_search_app?
41
+ end
42
+ end
43
+ 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
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ # What Cloudflare knows of the request, from the headers a Transform Rule
5
+ # sets (docs/guide.html#cloudflare) and its own CF-IPCountry. Each site names
6
+ # its headers in its rule: Browser::Cloudflare.header_names= changes them.
7
+ class Cloudflare
8
+ DEFAULT_HEADER_NAMES = {
9
+ verified_bot: "X-Bot-Verified",
10
+ bot_category: "X-Bot-Category",
11
+ bot_score: "X-Bot-Score",
12
+ country: "CF-IPCountry"
13
+ }.freeze
14
+
15
+ class << self
16
+ attr_reader :header_names, :env_key_by_signal
17
+ end
18
+
19
+ # Merged into the defaults: {bot_score: "X-Edge-Bot-Score"}.
20
+ def self.header_names=(names)
21
+ unknown_signals = names.keys - DEFAULT_HEADER_NAMES.keys
22
+ raise ArgumentError, "unknown Cloudflare signals: #{unknown_signals.inspect}" if unknown_signals.any?
23
+
24
+ @header_names = DEFAULT_HEADER_NAMES.merge(names).freeze
25
+ @env_key_by_signal = @header_names.transform_values { |name| "HTTP_#{name.upcase.tr("-", "_")}" }.freeze
26
+ end
27
+
28
+ self.header_names = {}
29
+
30
+ def initialize(headers)
31
+ @headers = headers
32
+ end
33
+
34
+ # cf.bot_management.verified_bot (cf.client.bot without Bot Management).
35
+ def verified_bot?
36
+ header(:verified_bot)&.casecmp?("true") || false
37
+ end
38
+
39
+ # cf.verified_bot_category, in bot.category's vocabulary: "Monitoring &
40
+ # Analytics" is :monitoring_and_analytics.
41
+ def bot_category
42
+ category = header(:bot_category)&.strip
43
+ return if category.nil? || category.empty?
44
+
45
+ category.downcase.gsub("&", "and").gsub(/[^a-z0-9]+/, "_").to_sym
46
+ end
47
+
48
+ # cf.bot_management.score, Bot Management only: 1 is a bot, 99 a human.
49
+ def bot_score
50
+ score = header(:bot_score)&.strip
51
+ score.to_i if score&.match?(/\A\d{1,2}\z/)
52
+ end
53
+
54
+ # The ISO 3166-1 alpha-2 country, "XX" when unknown, "T1" for Tor.
55
+ def country
56
+ country = header(:country)&.strip
57
+ country if country&.match?(/\A[A-Z0-9]{2}\z/)
58
+ end
59
+
60
+ private def header(signal)
61
+ value = @headers&.[](self.class.env_key_by_signal[signal])
62
+ value if value.is_a?(String)
63
+ end
64
+ end
65
+ end