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,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
@@ -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ class Bot
5
+ class EmptyUserAgentMatcher
6
+ def self.call(ua, _browser)
7
+ ua == ""
8
+ end
9
+ end
10
+ end
11
+ 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
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ class Bot
5
+ class KeywordMatcher
6
+ def self.call(ua, _browser)
7
+ ua.match?(/crawl|fetch|search|monitoring|spider|bot/)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browser
4
+ class Bot
5
+ class KnownBotsMatcher
6
+ def self.call(ua, _browser)
7
+ !Browser::Bot.known_bot_rank(ua, lowest: false).nil?
8
+ end
9
+ end
10
+ end
11
+ end