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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +93 -0
- data/LICENSE.md +21 -0
- data/README.md +552 -0
- data/data/bot_categories.json +1 -0
- data/data/bot_exceptions.json +1 -0
- data/data/bot_ip_ranges.json +1 -0
- data/data/bots.json +1 -0
- data/data/languages.json +1 -0
- data/data/samsung.json +1 -0
- data/data/search_engines.json +1 -0
- data/lib/agentry.rb +3 -0
- data/lib/browser/accept_language.rb +55 -0
- data/lib/browser/action_controller.rb +21 -0
- data/lib/browser/aliases.rb +33 -0
- data/lib/browser/alipay.rb +21 -0
- data/lib/browser/base.rb +307 -0
- data/lib/browser/blackberry.rb +23 -0
- data/lib/browser/bot/empty_user_agent_matcher.rb +11 -0
- data/lib/browser/bot/ip_ranges.rb +194 -0
- data/lib/browser/bot/keyword_matcher.rb +11 -0
- data/lib/browser/bot/known_bots_matcher.rb +11 -0
- data/lib/browser/bot.rb +221 -0
- data/lib/browser/browser.rb +200 -0
- data/lib/browser/cache.rb +92 -0
- data/lib/browser/chrome.rb +43 -0
- data/lib/browser/client_hints.rb +73 -0
- data/lib/browser/cloudflare.rb +65 -0
- data/lib/browser/detect_version.rb +22 -0
- data/lib/browser/detection.rb +25 -0
- data/lib/browser/device/android.rb +20 -0
- data/lib/browser/device/base.rb +13 -0
- data/lib/browser/device/blackberry_playbook.rb +19 -0
- data/lib/browser/device/ipad.rb +19 -0
- data/lib/browser/device/iphone.rb +19 -0
- data/lib/browser/device/ipod_touch.rb +19 -0
- data/lib/browser/device/kindle.rb +19 -0
- data/lib/browser/device/kindle_fire.rb +19 -0
- data/lib/browser/device/playstation3.rb +19 -0
- data/lib/browser/device/playstation4.rb +19 -0
- data/lib/browser/device/psp.rb +19 -0
- data/lib/browser/device/psvita.rb +19 -0
- data/lib/browser/device/samsung.rb +33 -0
- data/lib/browser/device/surface.rb +23 -0
- data/lib/browser/device/switch.rb +19 -0
- data/lib/browser/device/tv.rb +19 -0
- data/lib/browser/device/unknown.rb +19 -0
- data/lib/browser/device/wii.rb +19 -0
- data/lib/browser/device/wiiu.rb +19 -0
- data/lib/browser/device/xbox_360.rb +19 -0
- data/lib/browser/device/xbox_one.rb +19 -0
- data/lib/browser/device.rb +246 -0
- data/lib/browser/duck_duck_go.rb +22 -0
- data/lib/browser/edge.rb +25 -0
- data/lib/browser/electron.rb +22 -0
- data/lib/browser/epiphany.rb +21 -0
- data/lib/browser/facebook.rb +23 -0
- data/lib/browser/firefox.rb +21 -0
- data/lib/browser/google_search_app.rb +21 -0
- data/lib/browser/huawei_browser.rb +21 -0
- data/lib/browser/instagram.rb +21 -0
- data/lib/browser/internet_explorer.rb +63 -0
- data/lib/browser/maxthon.rb +21 -0
- data/lib/browser/meta/base.rb +42 -0
- data/lib/browser/meta/device.rb +11 -0
- data/lib/browser/meta/generic_browser.rb +15 -0
- data/lib/browser/meta/id.rb +11 -0
- data/lib/browser/meta/ie.rb +20 -0
- data/lib/browser/meta/ios.rb +11 -0
- data/lib/browser/meta/mobile.rb +11 -0
- data/lib/browser/meta/platform.rb +11 -0
- data/lib/browser/meta/proxy.rb +11 -0
- data/lib/browser/meta/safari.rb +11 -0
- data/lib/browser/meta/tablet.rb +11 -0
- data/lib/browser/meta/webkit.rb +11 -0
- data/lib/browser/meta.rb +14 -0
- data/lib/browser/micro_messenger.rb +21 -0
- data/lib/browser/middleware/context/additions.rb +18 -0
- data/lib/browser/middleware/context/url_methods.rb +15 -0
- data/lib/browser/middleware/context.rb +23 -0
- data/lib/browser/middleware.rb +70 -0
- data/lib/browser/miui_browser.rb +21 -0
- data/lib/browser/nokia.rb +21 -0
- data/lib/browser/opera.rb +21 -0
- data/lib/browser/otter.rb +21 -0
- data/lib/browser/phantom_js.rb +21 -0
- data/lib/browser/platform/adobe_air.rb +23 -0
- data/lib/browser/platform/android.rb +23 -0
- data/lib/browser/platform/base.rb +18 -0
- data/lib/browser/platform/blackberry.rb +23 -0
- data/lib/browser/platform/chrome_os.rb +23 -0
- data/lib/browser/platform/firefox_os.rb +23 -0
- data/lib/browser/platform/ios.rb +36 -0
- data/lib/browser/platform/kai_os.rb +23 -0
- data/lib/browser/platform/linux.rb +23 -0
- data/lib/browser/platform/mac.rb +25 -0
- data/lib/browser/platform/unknown.rb +23 -0
- data/lib/browser/platform/windows.rb +23 -0
- data/lib/browser/platform/windows_mobile.rb +23 -0
- data/lib/browser/platform/windows_phone.rb +23 -0
- data/lib/browser/platform.rb +220 -0
- data/lib/browser/qq.rb +25 -0
- data/lib/browser/rails.rb +24 -0
- data/lib/browser/safari.rb +40 -0
- data/lib/browser/samsung_browser.rb +21 -0
- data/lib/browser/snapchat.rb +21 -0
- data/lib/browser/sougou_browser.rb +24 -0
- data/lib/browser/sputnik.rb +21 -0
- data/lib/browser/testing.rb +29 -0
- data/lib/browser/uc_browser.rb +21 -0
- data/lib/browser/unknown.rb +32 -0
- data/lib/browser/version.rb +5 -0
- data/lib/browser/weibo.rb +21 -0
- data/lib/browser/yandex.rb +21 -0
- data/lib/browser.rb +4 -0
- data/test/ua.yml +181 -0
- data/test/ua_bots.yml +121 -0
- data/test/ua_search_engines.yml +7 -0
- metadata +274 -0
data/lib/browser/bot.rb
ADDED
|
@@ -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
|