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
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browser
|
|
4
|
+
class AcceptLanguage
|
|
5
|
+
def self.languages
|
|
6
|
+
@languages ||= Browser.load_data("languages")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.parse(accept_language)
|
|
10
|
+
return [] unless accept_language
|
|
11
|
+
|
|
12
|
+
languages = accept_language.split(",")
|
|
13
|
+
languages.map! { |string| new(string.squeeze(" ").strip) }
|
|
14
|
+
languages.reject! { |al| al.quality.zero? }
|
|
15
|
+
languages.sort_by.with_index { |al, idx| [-al.quality, idx] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :part
|
|
19
|
+
|
|
20
|
+
def initialize(part)
|
|
21
|
+
@part = part
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def full
|
|
25
|
+
@full ||= [code, region].compact.join("-")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def name
|
|
29
|
+
self.class.languages[full] || self.class.languages[code]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def code
|
|
33
|
+
@code ||= part[/\A([^-;]+)/, 1]&.downcase
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def region
|
|
37
|
+
@region ||= part[/\A(?:.*?)-([^;-]+)/, 1]&.upcase
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def quality
|
|
41
|
+
@quality ||= begin
|
|
42
|
+
Float(quality_value || 1.0)
|
|
43
|
+
rescue ArgumentError
|
|
44
|
+
0.1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private def quality_value
|
|
49
|
+
qvalue = part[/;q=([\d.]+)/, 1]
|
|
50
|
+
qvalue = "0.0" if /\A0\.0?\z/.match?(qvalue)
|
|
51
|
+
qvalue = qvalue.squeeze(".") if qvalue
|
|
52
|
+
qvalue
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_controller/railtie"
|
|
4
|
+
|
|
5
|
+
module Browser
|
|
6
|
+
module ActionController
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
included do
|
|
10
|
+
helper_method(:browser) if respond_to?(:helper_method)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private def browser
|
|
14
|
+
@browser ||= Browser.new(
|
|
15
|
+
request.headers["User-Agent"],
|
|
16
|
+
accept_language: request.headers["Accept-Language"],
|
|
17
|
+
headers: request.env
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../browser"
|
|
4
|
+
require "forwardable"
|
|
5
|
+
|
|
6
|
+
module Browser
|
|
7
|
+
module Aliases
|
|
8
|
+
PLATFORM_ALIASES = %w[
|
|
9
|
+
adobe_air? android? blackberry? chrome_os? firefox_os? ios? ios_app?
|
|
10
|
+
ios_webview? linux? mac? windows10? windows7? windows8? windows8_1?
|
|
11
|
+
windows? windows_mobile? windows_phone? windows_rt?
|
|
12
|
+
windows_touchscreen_desktop? windows_vista? windows_wow64? windows_x64?
|
|
13
|
+
windows_x64_inclusive? windows_xp?
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
DEVICE_ALIASES = %w[
|
|
17
|
+
blackberry_playbook? console? ipad? iphone? ipod_touch? kindle?
|
|
18
|
+
kindle_fire? mobile? nintendo? nintendo_switch? nintendo_wii?
|
|
19
|
+
nintendo_wiiu? playbook? playstation3? playstation4? playstation?
|
|
20
|
+
playstation_vita? ps3? ps4? psp? psp_vita? silk? surface? tablet? tv?
|
|
21
|
+
vita? wii? wiiu? xbox? xbox_360? xbox_one?
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def self.included(target)
|
|
25
|
+
target.class_eval do
|
|
26
|
+
extend Forwardable
|
|
27
|
+
|
|
28
|
+
def_delegators :platform, *PLATFORM_ALIASES
|
|
29
|
+
def_delegators :device, *DEVICE_ALIASES
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browser
|
|
4
|
+
class Alipay < Base
|
|
5
|
+
def id
|
|
6
|
+
:alipay
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def name
|
|
10
|
+
"Alipay"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def full_version
|
|
14
|
+
ua[%r{(?:AlipayClient)/([\d.]+)}i, 1] || "0.0"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def match?
|
|
18
|
+
ua.match?(/AlipayClient/i)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/browser/base.rb
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browser
|
|
4
|
+
class Base
|
|
5
|
+
include DetectVersion
|
|
6
|
+
|
|
7
|
+
attr_reader :ua
|
|
8
|
+
|
|
9
|
+
# headers: the Rack env (request.env), for the client hints and the
|
|
10
|
+
# Cloudflare headers.
|
|
11
|
+
def initialize(ua, accept_language: nil, headers: nil)
|
|
12
|
+
validate_size(:user_agent, ua.to_s)
|
|
13
|
+
|
|
14
|
+
@ua = ua
|
|
15
|
+
@accept_language_raw = accept_language.to_s
|
|
16
|
+
@headers = headers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Return a meta info about this browser.
|
|
20
|
+
def meta
|
|
21
|
+
Meta.get(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Return an array with all preferred languages that this browser accepts.
|
|
25
|
+
def accept_language
|
|
26
|
+
@accept_language ||= begin
|
|
27
|
+
validate_size(:accept_language, @accept_language_raw)
|
|
28
|
+
Cache.accept_language(@accept_language_raw)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
alias_method :to_a, :meta
|
|
33
|
+
|
|
34
|
+
# Return meta representation as string.
|
|
35
|
+
def to_s
|
|
36
|
+
meta.to_a.join(" ")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def version
|
|
40
|
+
full_version.split(".").first
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Return the platform.
|
|
44
|
+
def platform
|
|
45
|
+
@platform ||= detection.platform.with_client_hints(client_hints)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def client_hints
|
|
49
|
+
@client_hints ||= @headers ? ClientHints.new(@headers) : ClientHints::NONE
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def cloudflare
|
|
53
|
+
@cloudflare ||= Cloudflare.new(@headers)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Return the bot info.
|
|
57
|
+
def bot
|
|
58
|
+
@bot ||= detection.bot
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Detect if current user agent is from a bot.
|
|
62
|
+
def bot?
|
|
63
|
+
bot.bot?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Return the device info.
|
|
67
|
+
def device
|
|
68
|
+
@device ||= detection.device.with_client_hints(client_hints, platform:)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Detect if browser is Microsoft Internet Explorer.
|
|
72
|
+
def ie?(expected_version = nil)
|
|
73
|
+
instance_of?(InternetExplorer) &&
|
|
74
|
+
detect_version?(full_version, expected_version)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Detect if browser is Microsoft Edge.
|
|
78
|
+
def edge?(expected_version = nil)
|
|
79
|
+
instance_of?(Edge) && detect_version?(full_version, expected_version)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def compatibility_view?
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def msie_full_version
|
|
87
|
+
"0.0"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def msie_version
|
|
91
|
+
"0"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Detect if browser is Instagram.
|
|
95
|
+
def instagram?(expected_version = nil)
|
|
96
|
+
instance_of?(Instagram) &&
|
|
97
|
+
detect_version?(full_version, expected_version)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Detect if browser is Snapchat.
|
|
101
|
+
def snapchat?(expected_version = nil)
|
|
102
|
+
instance_of?(Snapchat) &&
|
|
103
|
+
detect_version?(full_version, expected_version)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Detect if browser if Facebook.
|
|
107
|
+
def facebook?(expected_version = nil)
|
|
108
|
+
instance_of?(Facebook) &&
|
|
109
|
+
detect_version?(full_version, expected_version)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Detect if browser is Otter.
|
|
113
|
+
def otter?(expected_version = nil)
|
|
114
|
+
instance_of?(Otter) &&
|
|
115
|
+
detect_version?(full_version, expected_version)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Detect if browser is WebKit-based.
|
|
119
|
+
def webkit?(expected_version = nil)
|
|
120
|
+
ua.match?(/AppleWebKit/i) &&
|
|
121
|
+
(!edge? || chromium_based?) &&
|
|
122
|
+
detect_version?(webkit_full_version, expected_version)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Detect if browser is QuickTime
|
|
126
|
+
def quicktime?(expected_version = nil)
|
|
127
|
+
ua.match?(/QuickTime/i) && detect_version?(full_version, expected_version)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Detect if browser is Apple CoreMedia.
|
|
131
|
+
def core_media?(expected_version = nil)
|
|
132
|
+
ua.include?("CoreMedia") && detect_version?(full_version,
|
|
133
|
+
expected_version)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Detect if browser is PhantomJS
|
|
137
|
+
def phantom_js?(expected_version = nil)
|
|
138
|
+
instance_of?(PhantomJS) &&
|
|
139
|
+
detect_version?(full_version, expected_version)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Detect if browser is Safari.
|
|
143
|
+
def safari?(expected_version = nil)
|
|
144
|
+
instance_of?(Safari) && detect_version?(full_version, expected_version)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def safari_webapp_mode?
|
|
148
|
+
(device.ipad? || device.iphone?) && ua.include?("AppleWebKit")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Detect if browser is Firefox.
|
|
152
|
+
def firefox?(expected_version = nil)
|
|
153
|
+
instance_of?(Firefox) && detect_version?(full_version, expected_version)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Detect if browser is Chrome.
|
|
157
|
+
def chrome?(expected_version = nil)
|
|
158
|
+
instance_of?(Chrome) && detect_version?(full_version, expected_version)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Detect if browser is Opera.
|
|
162
|
+
def opera?(expected_version = nil)
|
|
163
|
+
instance_of?(Opera) && detect_version?(full_version, expected_version)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Detect if browser is Sputnik.
|
|
167
|
+
def sputnik?(expected_version = nil)
|
|
168
|
+
instance_of?(Sputnik) && detect_version?(full_version, expected_version)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Detect if browser is Yandex.
|
|
172
|
+
def yandex?(expected_version = nil)
|
|
173
|
+
instance_of?(Yandex) && detect_version?(full_version, expected_version)
|
|
174
|
+
end
|
|
175
|
+
alias_method :yandex_browser?, :yandex?
|
|
176
|
+
|
|
177
|
+
# Detect if browser is UCBrowser.
|
|
178
|
+
def uc_browser?(expected_version = nil)
|
|
179
|
+
instance_of?(UCBrowser) &&
|
|
180
|
+
detect_version?(full_version, expected_version)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Detect if browser is Nokia S40 Ovi Browser.
|
|
184
|
+
def nokia?(expected_version = nil)
|
|
185
|
+
instance_of?(Nokia) && detect_version?(full_version, expected_version)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Detect if browser is MicroMessenger.
|
|
189
|
+
def micro_messenger?(expected_version = nil)
|
|
190
|
+
instance_of?(MicroMessenger) &&
|
|
191
|
+
detect_version?(full_version, expected_version)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
alias_method :wechat?, :micro_messenger?
|
|
195
|
+
|
|
196
|
+
def weibo?(expected_version = nil)
|
|
197
|
+
instance_of?(Weibo) && detect_version?(full_version, expected_version)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def alipay?(expected_version = nil)
|
|
201
|
+
instance_of?(Alipay) && detect_version?(full_version, expected_version)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Detect if browser is Opera Mini.
|
|
205
|
+
def opera_mini?(expected_version = nil)
|
|
206
|
+
ua.include?("Opera Mini") && detect_version?(full_version,
|
|
207
|
+
expected_version)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Detect if browser is DuckDuckGo.
|
|
211
|
+
def duck_duck_go?(expected_version = nil)
|
|
212
|
+
ua.include?("DuckDuckGo") && detect_version?(full_version,
|
|
213
|
+
expected_version)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Detect if browser is Samsung.
|
|
217
|
+
def samsung_browser?(expected_version = nil)
|
|
218
|
+
ua.include?("SamsungBrowser") && detect_version?(full_version,
|
|
219
|
+
expected_version)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Detect if browser is Huawei.
|
|
223
|
+
def huawei_browser?(expected_version = nil)
|
|
224
|
+
instance_of?(HuaweiBrowser) &&
|
|
225
|
+
detect_version?(full_version, expected_version)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Detect if browser is Xiaomi Miui.
|
|
229
|
+
def miui_browser?(expected_version = nil)
|
|
230
|
+
instance_of?(MiuiBrowser) &&
|
|
231
|
+
detect_version?(full_version, expected_version)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Detect if browser is Maxthon.
|
|
235
|
+
def maxthon?(expected_version = nil)
|
|
236
|
+
instance_of?(Maxthon) && detect_version?(full_version, expected_version)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Detect if browser is QQ.
|
|
240
|
+
def qq?(expected_version = nil)
|
|
241
|
+
instance_of?(QQ) && detect_version?(full_version, expected_version)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Detect if browser is Sougou.
|
|
245
|
+
def sougou_browser?(expected_version = nil)
|
|
246
|
+
instance_of?(SougouBrowser) &&
|
|
247
|
+
detect_version?(full_version, expected_version)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Detect if browser is Google Search App
|
|
251
|
+
def google_search_app?(expected_version = nil)
|
|
252
|
+
ua.include?("GSA") && detect_version?(full_version, expected_version)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Detect if browser is Chromium-based.
|
|
256
|
+
def chromium_based?
|
|
257
|
+
false
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def webkit_full_version
|
|
261
|
+
ua[%r{AppleWebKit/([\d.]+)}, 1] || "0.0"
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def known?
|
|
265
|
+
!unknown?
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def unknown?
|
|
269
|
+
id == :unknown_browser
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Detect if browser is a proxy browser.
|
|
273
|
+
def proxy?
|
|
274
|
+
nokia? || uc_browser? || opera_mini?
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Detect if the browser is Electron.
|
|
278
|
+
def electron?(expected_version = nil)
|
|
279
|
+
instance_of?(Electron) && detect_version?(full_version, expected_version)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Detect if the browser is Epiphany.
|
|
283
|
+
def epiphany?(expected_version = nil)
|
|
284
|
+
instance_of?(Epiphany) && detect_version?(full_version, expected_version)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
private def detection
|
|
288
|
+
@detection ||= Cache.detection(ua)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
private def validate_size(subject, input)
|
|
292
|
+
actual_bytesize = input.bytesize
|
|
293
|
+
size_limit =
|
|
294
|
+
if subject == :user_agent
|
|
295
|
+
Browser.user_agent_size_limit
|
|
296
|
+
else
|
|
297
|
+
Browser.accept_language_size_limit
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
return if actual_bytesize < size_limit
|
|
301
|
+
|
|
302
|
+
raise Error,
|
|
303
|
+
"#{subject} cannot be larger than #{size_limit} bytes; " \
|
|
304
|
+
"actual size is #{actual_bytesize} bytes"
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browser
|
|
4
|
+
class BlackBerry < Base
|
|
5
|
+
def id
|
|
6
|
+
:blackberry
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def name
|
|
10
|
+
"BlackBerry"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def full_version
|
|
14
|
+
ua[%r{BlackBerry[\da-z]+/([\d.]+)}, 1] ||
|
|
15
|
+
ua[%r{Version/([\d.]+)}, 1] ||
|
|
16
|
+
"0.0"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def match?
|
|
20
|
+
ua.match?(/BlackBerry|BB10/)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -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" => "Agentry/#{VERSION} (+https://github.com/levups/agentry)")
|
|
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
|