skadi 0.4.0.beta.1
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 +41 -0
- data/LICENSE.md +22 -0
- data/README.md +111 -0
- data/app/assets/builds/dashboard.css +2 -0
- data/app/assets/builds/dashboard.js +36 -0
- data/app/assets/builds/skadi.js +1 -0
- data/app/controllers/skadi/application_controller.rb +4 -0
- data/app/controllers/skadi/asset_controller.rb +28 -0
- data/app/controllers/skadi/dashboard_controller.rb +105 -0
- data/app/controllers/skadi/tracking_controller.rb +106 -0
- data/app/helpers/skadi/application_helper.rb +38 -0
- data/app/models/skadi/application_record.rb +5 -0
- data/app/models/skadi/dashboard.rb +83 -0
- data/app/models/skadi/dashboard_query.rb +314 -0
- data/app/models/skadi/dashboard_validator.rb +269 -0
- data/app/models/skadi/demographic.rb +31 -0
- data/app/models/skadi/event.rb +33 -0
- data/app/models/skadi/helpers/sql.rb +86 -0
- data/app/models/skadi/schema.rb +135 -0
- data/app/models/skadi/view.rb +9 -0
- data/app/models/skadi/visit.rb +59 -0
- data/app/views/skadi/dashboard/_layout.html.erb +21 -0
- data/app/views/skadi/dashboard/show.html.erb +18 -0
- data/config/routes.rb +14 -0
- data/lib/generators/skadi/install/USAGE +20 -0
- data/lib/generators/skadi/install/install_generator.rb +46 -0
- data/lib/generators/skadi/install/templates/skadi_migration.rb.erb +111 -0
- data/lib/skadi/analytics.rb +35 -0
- data/lib/skadi/anonymity_set.rb +38 -0
- data/lib/skadi/configuration.rb +232 -0
- data/lib/skadi/controller_delegate.rb +322 -0
- data/lib/skadi/cookie_manager.rb +81 -0
- data/lib/skadi/engine.rb +38 -0
- data/lib/skadi/url.rb +66 -0
- data/lib/skadi/user_agent.rb +458 -0
- data/lib/skadi/version.rb +3 -0
- data/lib/skadi.rb +26 -0
- metadata +203 -0
data/lib/skadi/url.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
# Helper functions for generating and redacting URLs
|
|
3
|
+
module Url
|
|
4
|
+
# Formats the path for Skadi views. Note that the path here uses PATH_INFO, which does not include the query string or fragment.
|
|
5
|
+
# @param request [ActionDispatch::Request]
|
|
6
|
+
# @return [String]
|
|
7
|
+
def self.view_path_from_request(request)
|
|
8
|
+
path = +""
|
|
9
|
+
|
|
10
|
+
if Skadi.configuration.store_domain_in_views
|
|
11
|
+
path << request.host_with_port
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Normalise the path by removing any trailing slashes
|
|
15
|
+
path << ((request.path == "/" || request.path == "") ? "/" : request.path.chomp("/"))
|
|
16
|
+
|
|
17
|
+
path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @param query_params [Hash, ActiveSupport::HashWithIndifferentAccess]
|
|
21
|
+
# @return [Hash]
|
|
22
|
+
def self.whitelist_query_params(query_params)
|
|
23
|
+
# Normalise the input to a Hash with symbolic keys
|
|
24
|
+
query_params = query_params.to_h.symbolize_keys
|
|
25
|
+
|
|
26
|
+
return query_params unless Skadi.configuration.use_query_param_whitelist
|
|
27
|
+
|
|
28
|
+
whitelist = Skadi.configuration.query_param_whitelist
|
|
29
|
+
return {} if whitelist.empty?
|
|
30
|
+
|
|
31
|
+
query_params.slice(*whitelist)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Strips non-whitelisted query params and normalises URLs
|
|
35
|
+
# @param url [String]
|
|
36
|
+
# @return [String, nil]
|
|
37
|
+
def self.redact_and_normalise_url(url)
|
|
38
|
+
return nil unless url.is_a?(String) && url.present?
|
|
39
|
+
|
|
40
|
+
uri = URI.parse(url[0, Skadi.configuration.max_url_length])
|
|
41
|
+
return nil if uri.opaque
|
|
42
|
+
|
|
43
|
+
query_params = Rack::Utils.parse_nested_query(uri.query) if uri.query.present?
|
|
44
|
+
param_string = whitelist_query_params(query_params).to_query if query_params.present?
|
|
45
|
+
|
|
46
|
+
result = +""
|
|
47
|
+
|
|
48
|
+
# Only record interesting schemes, e.g. "android-app://"
|
|
49
|
+
result += "#{uri.scheme}://" if uri.scheme.present? && ![ "http", "https" ].include?(uri.scheme)
|
|
50
|
+
|
|
51
|
+
result += uri.host if uri.host.present?
|
|
52
|
+
|
|
53
|
+
# Only include port if it's non-standard
|
|
54
|
+
result << ":#{uri.port}" if uri.port != uri.default_port
|
|
55
|
+
|
|
56
|
+
# Normalise the trailing slash
|
|
57
|
+
result << ((uri.path == "" || uri.path == "/") ? "/" : uri.path.chomp("/")) unless uri.path.nil?
|
|
58
|
+
|
|
59
|
+
result << (param_string.present? ? "?#{param_string}" : "")
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
rescue URI::InvalidURIError, Rack::QueryParser::ParameterTypeError, Rack::QueryParser::QueryLimitError
|
|
63
|
+
return nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
module Skadi
|
|
2
|
+
# A minimal user agent parser, designed for speed rather than completeness, aiming to detect the most common browsers
|
|
3
|
+
# and operating systems.
|
|
4
|
+
#
|
|
5
|
+
# Why implement a custom user agent parsing script over an existing library?
|
|
6
|
+
#
|
|
7
|
+
# Firstly, to eliminate the transient dependencies consumers of this gem are exposed to.
|
|
8
|
+
#
|
|
9
|
+
# Secondly, existing libraries have relatively poor performance. Unlike other solutions, which use a large number of
|
|
10
|
+
# regular expressions to detect browsers, this parser tokenises the user agent and uses a lookup table to identify the
|
|
11
|
+
# majority of browsers with O(1) performance, using regular expressions as fallback. See the `user_agent_test.rb` file
|
|
12
|
+
# for performance statistics.
|
|
13
|
+
#
|
|
14
|
+
# Thirdly, the user agent parsing gems are years out of date. Modern browsers and modern bots are not properly
|
|
15
|
+
# detected, potentially skewing the analytics data collected.
|
|
16
|
+
class UserAgent
|
|
17
|
+
def initialize(user_agent)
|
|
18
|
+
@user_agent = (user_agent || "")[0, 2048]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def browser
|
|
22
|
+
parse_browser if @browser.nil?
|
|
23
|
+
|
|
24
|
+
@browser
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def browser_version
|
|
28
|
+
parse_browser if @browser_version.nil?
|
|
29
|
+
|
|
30
|
+
@browser_version
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def engine
|
|
34
|
+
parse_engine if @engine.nil?
|
|
35
|
+
|
|
36
|
+
@engine
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def engine_version
|
|
40
|
+
parse_engine if @engine_version.nil?
|
|
41
|
+
|
|
42
|
+
@engine_version
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def os
|
|
46
|
+
parse_os if @os.nil?
|
|
47
|
+
|
|
48
|
+
@os
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def bot?
|
|
52
|
+
return @bot unless @bot.nil?
|
|
53
|
+
|
|
54
|
+
# If the user agent doesn't follow the normal browser patterns, we assume it's a bot
|
|
55
|
+
parse_browser if @browser.nil?
|
|
56
|
+
if @browser == "Unknown"
|
|
57
|
+
@bot = true
|
|
58
|
+
|
|
59
|
+
return true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@bot = detect_bot
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def human? = !bot?
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
{
|
|
69
|
+
browser: browser,
|
|
70
|
+
browser_version: browser_version,
|
|
71
|
+
engine: engine,
|
|
72
|
+
engine_version: engine_version,
|
|
73
|
+
os: os,
|
|
74
|
+
bot: bot?,
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
BROWSER_MATCHERS = [
|
|
79
|
+
{
|
|
80
|
+
regex: /Chrome\/(?<version>\d++).*WebView|; wv.*Chrome\/(?<version>\d++)/,
|
|
81
|
+
browser: "Chrome WebView",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
regex: /Android.*Version\/(?<version>\d++)/,
|
|
85
|
+
browser: "Android Browser",
|
|
86
|
+
os: "Android",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
regex: /Android.*Chrome\/(?<version>\d++)/,
|
|
90
|
+
browser: "Chrome for Android",
|
|
91
|
+
os: "Android",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
regex: /(?:iOS|iPod|iPad|iPhone).+(?:CriOS|Chrome)\/(?<version>\d++)/,
|
|
95
|
+
browser: "Chrome for iOS",
|
|
96
|
+
os: "iOS",
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
regex: /(?:CriOS|Chrome)\/(?<version>\d++)/,
|
|
100
|
+
browser: "Chrome",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
regex: /(?:iOS|iPod|iPad|iPhone).+Version\/(?<version>\d++)/,
|
|
104
|
+
browser: "Safari for iOS",
|
|
105
|
+
os: "iOS",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
regex: /GSA\/(?<version>\d++)/,
|
|
109
|
+
browser: "GSA",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
regex: /(?:iOS|iPod|iPad|iPhone).*Safari/,
|
|
113
|
+
browser: "Safari for iOS",
|
|
114
|
+
os: "iOS",
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
regex: /Version\/(?<version>\d++).*Safari/,
|
|
118
|
+
browser: "Safari",
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
regex: /Safari\//,
|
|
122
|
+
browser: "Safari",
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
regex: /WebKit\/(?<version>\d++)/,
|
|
126
|
+
browser: "WebKit",
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
regex: /Mozilla\/(?<version>\d++).*rv:(?<engine_version>\d++).*?Gecko\//,
|
|
130
|
+
browser: "Mozilla",
|
|
131
|
+
engine: "Gecko",
|
|
132
|
+
},
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
BROWSER_TOKENS = {
|
|
136
|
+
"AlohaBrowser" => {
|
|
137
|
+
browser: "Aloha",
|
|
138
|
+
},
|
|
139
|
+
"Avast" => {
|
|
140
|
+
browser: "Avast Secure Browser",
|
|
141
|
+
},
|
|
142
|
+
"AVG" => {
|
|
143
|
+
browser: "AVG Secure Browser",
|
|
144
|
+
},
|
|
145
|
+
"baiduboxapp" => {
|
|
146
|
+
browser: "Baidu",
|
|
147
|
+
},
|
|
148
|
+
"BingSapphire" => {
|
|
149
|
+
browser: "Bing",
|
|
150
|
+
},
|
|
151
|
+
"Brave" => {},
|
|
152
|
+
"Chromium" => {
|
|
153
|
+
regex: /Chromium[\/ ](?<version>GOST|\d++)/,
|
|
154
|
+
},
|
|
155
|
+
"Ddg" => {
|
|
156
|
+
browser: "DuckDuckGo",
|
|
157
|
+
},
|
|
158
|
+
"DuckDuckGo" => {},
|
|
159
|
+
"Ecosia" => {
|
|
160
|
+
regex: /Ecosia ios@(?<version>\d++)/,
|
|
161
|
+
os: "iOS",
|
|
162
|
+
},
|
|
163
|
+
"Edg" => [
|
|
164
|
+
{
|
|
165
|
+
regex: /(?:iOS|iPod|iPad|iPhone).*Edg\/(?<version>\d++)/,
|
|
166
|
+
browser: "Edge for iOS",
|
|
167
|
+
os: "iOS",
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
regex: /Android.*Edg\/(?<version>\d++)/,
|
|
171
|
+
browser: "Edge for Android",
|
|
172
|
+
os: "Android",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
regex: /Edg\/(?<version>\d++)/,
|
|
176
|
+
browser: "Edge",
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
"EdgA" => {
|
|
180
|
+
browser: "Edge for Android",
|
|
181
|
+
os: "Android",
|
|
182
|
+
},
|
|
183
|
+
"Edge" => {},
|
|
184
|
+
"EdgiOS" => {
|
|
185
|
+
browser: "Edge for iOS",
|
|
186
|
+
os: "iOS",
|
|
187
|
+
},
|
|
188
|
+
"Electron" => {},
|
|
189
|
+
"FBAV" => {
|
|
190
|
+
browser: "Facebook",
|
|
191
|
+
},
|
|
192
|
+
"Firefox" => [
|
|
193
|
+
{
|
|
194
|
+
regex: /(?<browser>PaleMoon|Waterfox)\/(?<version>\d++)/,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
regex: /(?:iOS|iPod|iPad|iPhone).*Firefox\/(?<version>\d++)/,
|
|
198
|
+
browser: "Firefox for iOS",
|
|
199
|
+
os: "iOS",
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
regex: /Android.*Firefox\/(?<version>\d++)/,
|
|
203
|
+
browser: "Firefox for Android",
|
|
204
|
+
os: "Android",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
regex: /Firefox\/(?<version>\d++)/,
|
|
208
|
+
browser: "Firefox",
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
"FxiOS" => {
|
|
212
|
+
browser: "Firefox for iOS",
|
|
213
|
+
},
|
|
214
|
+
"HeadlessChrome" => {
|
|
215
|
+
browser: "Chrome Headless",
|
|
216
|
+
},
|
|
217
|
+
"HeyTapBrowser" => {
|
|
218
|
+
browser: "HeyTap",
|
|
219
|
+
},
|
|
220
|
+
"HuaweiBrowser" => {
|
|
221
|
+
browser: "Huawei Browser",
|
|
222
|
+
},
|
|
223
|
+
"Instagram" => {
|
|
224
|
+
regex: /Instagram[\/ ](?<version>\d++)/,
|
|
225
|
+
},
|
|
226
|
+
"Iron" => {
|
|
227
|
+
regex: /Chrome\/(?<version>\d++).*?Iron/,
|
|
228
|
+
},
|
|
229
|
+
"KAKAOTALK" => {
|
|
230
|
+
regex: /KAKAOTALK[\/ ](?<version>\d++)/,
|
|
231
|
+
},
|
|
232
|
+
"Konqueror" => {},
|
|
233
|
+
"Line" => {},
|
|
234
|
+
"LinkedInApp" => {
|
|
235
|
+
regex: /\[LinkedInApp\]\/(?<version>\d++)/,
|
|
236
|
+
browser: "LinkedIn",
|
|
237
|
+
},
|
|
238
|
+
"Maxthon" => {},
|
|
239
|
+
"MicroMessenger" => {
|
|
240
|
+
browser: "WeChat",
|
|
241
|
+
},
|
|
242
|
+
"MiuiBrowser" => {
|
|
243
|
+
browser: "MIUI Browser",
|
|
244
|
+
},
|
|
245
|
+
"MQQBrowser" => {},
|
|
246
|
+
"MSIE" => {
|
|
247
|
+
regex: /MSIE (?<version>\d++)(?>.*(?<engine>Trident)\/(?<engine_version>\d++))?|(?<engine>Trident)\/(?<engine_version>\d++).*rv[: ](?<version>\d++)/,
|
|
248
|
+
browser: "IE",
|
|
249
|
+
os: "Windows",
|
|
250
|
+
},
|
|
251
|
+
"musical" => {
|
|
252
|
+
regex: /musical_ly_(?<version>\d++)/,
|
|
253
|
+
browser: "TikTok",
|
|
254
|
+
},
|
|
255
|
+
"Norton" => {
|
|
256
|
+
browser: "Norton Private Browser",
|
|
257
|
+
},
|
|
258
|
+
"OculusBrowser" => {
|
|
259
|
+
browser: "Oculus Browser",
|
|
260
|
+
},
|
|
261
|
+
"Opera" => [
|
|
262
|
+
{
|
|
263
|
+
regex: /Opera Mini[\/ ](?<version>\d++)/,
|
|
264
|
+
browser: "Opera Mini",
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
regex: /Opera(?>.*Version)?[\/ ](?<version>\d++)/,
|
|
268
|
+
browser: "Opera",
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
"OPR" => {
|
|
272
|
+
browser: "Opera",
|
|
273
|
+
},
|
|
274
|
+
"OPT" => {
|
|
275
|
+
browser: "Opera Touch",
|
|
276
|
+
},
|
|
277
|
+
"OPX" => {
|
|
278
|
+
browser: "Opera GX",
|
|
279
|
+
},
|
|
280
|
+
"PaleMoon" => {},
|
|
281
|
+
"QQBrowser" => {},
|
|
282
|
+
"QuarkPC" => {
|
|
283
|
+
browser: "Quark",
|
|
284
|
+
},
|
|
285
|
+
"SamsungBrowser" => {
|
|
286
|
+
browser: "Samsung Internet",
|
|
287
|
+
},
|
|
288
|
+
"SeaMonkey" => {},
|
|
289
|
+
"Silk" => {},
|
|
290
|
+
"Snapchat" => {},
|
|
291
|
+
"TikTokLIVEStudio" => {},
|
|
292
|
+
"Trident" => {
|
|
293
|
+
regex: /MSIE (?<version>\d++).*Trident\/(?<engine_version>\d++)|Trident\/(?<engine_version>\d++).*rv[: ](?<version>\d++)/,
|
|
294
|
+
browser: "IE",
|
|
295
|
+
engine: "Trident",
|
|
296
|
+
os: "Windows",
|
|
297
|
+
},
|
|
298
|
+
"Twitter" => {
|
|
299
|
+
regex: /Twitter for iPhone\/(?<version>\d++)/,
|
|
300
|
+
},
|
|
301
|
+
"UCBrowser" => {},
|
|
302
|
+
"VivoBrowser" => {
|
|
303
|
+
browser: "Vivo Browser",
|
|
304
|
+
},
|
|
305
|
+
"Whale" => {},
|
|
306
|
+
"YaBrowser" => {
|
|
307
|
+
browser: "Yandex",
|
|
308
|
+
},
|
|
309
|
+
"YaSearchBrowser" => {
|
|
310
|
+
browser: "Yandex",
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
# Normalise the BROWSER_TOKENS hash with defaults, and ensure they are all arrays
|
|
315
|
+
BROWSER_TOKENS.each_pair do |key, options|
|
|
316
|
+
next unless options.is_a?(Hash)
|
|
317
|
+
|
|
318
|
+
options[:regex] ||= /#{key}\/(?<version>\d++)/
|
|
319
|
+
options[:browser] ||= key
|
|
320
|
+
|
|
321
|
+
# Normalise into an array so we don't have to check the type during runtime
|
|
322
|
+
BROWSER_TOKENS[key] = [ options ]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
private def parse_browser
|
|
326
|
+
# First we check if any of the UA tokens exist as keys to our browser token list (fast!)
|
|
327
|
+
user_agent_tokens.each do |token|
|
|
328
|
+
next unless BROWSER_TOKENS.key?(token)
|
|
329
|
+
|
|
330
|
+
# We then use the matcher to extract the version from the full user agent
|
|
331
|
+
BROWSER_TOKENS[token].each do |matcher|
|
|
332
|
+
return if run_matcher(matcher) # rubocop:disable Lint/NonLocalExitFromIterator
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Then, if we don't get a match, we run the full list of fallback matchers against the user agent (slow!)
|
|
337
|
+
BROWSER_MATCHERS.each do |matcher|
|
|
338
|
+
return if run_matcher(matcher) # rubocop:disable Lint/NonLocalExitFromIterator
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Finally, falling back to unknown values for the browser variables
|
|
342
|
+
@browser = "Unknown"
|
|
343
|
+
@browser_version = "Unknown"
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Runs a matcher hash against the user agent, srtting relevant instance variables. Returns true if a match was found.
|
|
347
|
+
private def run_matcher(matcher)
|
|
348
|
+
match = matcher[:regex].match(@user_agent)
|
|
349
|
+
|
|
350
|
+
if match
|
|
351
|
+
named_captures = match.named_captures
|
|
352
|
+
|
|
353
|
+
@browser = named_captures["browser"] || matcher[:browser] || "Unknown"
|
|
354
|
+
@browser_version = named_captures["version"] || matcher[:browser_version] || "Unknown"
|
|
355
|
+
@engine = named_captures["engine"] || matcher[:engine]
|
|
356
|
+
@engine_version = named_captures["engine_version"]
|
|
357
|
+
@os = named_captures["os"] || matcher[:os]
|
|
358
|
+
|
|
359
|
+
return true
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
false
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
ENGINE_MATCHERS = [
|
|
366
|
+
{
|
|
367
|
+
regex: /AppleWebKit\/537\.36.*Edge\/(?<version>1[2-8])\./,
|
|
368
|
+
engine: "EdgeHTML",
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
regex: /AppleWebKit\/537\.36.*Chrome\/(?<version>\d++)/,
|
|
372
|
+
engine: "Blink",
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
regex: /(?<engine>WebKit|Presto|Trident|Goanna)\/(?<version>\d++)/,
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
regex: /rv:(?<version>\d++).*?Gecko\/\d++/,
|
|
379
|
+
engine: "Gecko",
|
|
380
|
+
},
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
private def parse_engine
|
|
384
|
+
# Since there are only 4 engine matchers, there is little performance to be gained by using the token approach to parsing
|
|
385
|
+
ENGINE_MATCHERS.each do |matcher|
|
|
386
|
+
match = matcher[:regex].match(@user_agent)
|
|
387
|
+
|
|
388
|
+
if match
|
|
389
|
+
named_captures = match.named_captures
|
|
390
|
+
|
|
391
|
+
@engine = named_captures["engine"] || matcher[:engine] || "Unknown"
|
|
392
|
+
@engine_version = named_captures["version"] || "Unknown"
|
|
393
|
+
|
|
394
|
+
return # rubocop:disable Lint/NonLocalExitFromIterator
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
@engine = "Unknown"
|
|
399
|
+
@engine_version = "Unknown"
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
OS_TOKENS = {
|
|
403
|
+
"CFNetwork" => "iOS",
|
|
404
|
+
"CrOS" => "Chrome OS",
|
|
405
|
+
"Fedora" => "Fedora",
|
|
406
|
+
"Gentoo" => "Gentoo",
|
|
407
|
+
"HarmonyOS" => "HarmonyOS",
|
|
408
|
+
"iPad" => "iOS",
|
|
409
|
+
"iPhone" => "iOS",
|
|
410
|
+
"iPod" => "iOS",
|
|
411
|
+
"Mac" => "macOS",
|
|
412
|
+
"Ubuntu" => "Ubuntu",
|
|
413
|
+
"Windows" => "Windows",
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private def parse_os
|
|
417
|
+
linux_fallback = false
|
|
418
|
+
android_fallback = false
|
|
419
|
+
user_agent_tokens.each do |token|
|
|
420
|
+
if OS_TOKENS.key?(token)
|
|
421
|
+
@os = OS_TOKENS[token]
|
|
422
|
+
|
|
423
|
+
return # rubocop:disable Lint/NonLocalExitFromIterator
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
android_fallback ||= token == "Android"
|
|
427
|
+
linux_fallback ||= token == "Linux"
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# HarmonyOS UAs can contain Android, and Android UAs can contain "Linux" so we need to do these in a specific order
|
|
431
|
+
@os ||= "Android" if android_fallback
|
|
432
|
+
@os ||= "Linux" if linux_fallback
|
|
433
|
+
@os ||= "Unknown"
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
BOT_GLOBAL_MATCHERS = %w[bot crawl scan spider]
|
|
437
|
+
|
|
438
|
+
BOT_WORD_SET = Set.new(%w[AGENT Agent AppInsights ArchiveBox Archiver Archiving BingPreview BrandVerity Butterfly Charlotte Checkly Claude CloudFlare Cloudflare Code Collapsify CookieHubVerify Criticalcss Daily DareBoost DatadogSynthetics Datanyze Devin Dlc FeedBurner Feeder Feedly FlipboardProxy Fluid Foregenix GTmetrix GeedoProductSearch GeedoShopProductFinder Google GoogleAgent GoogleImageProxy GotSiteMonitor Hardenize HeadlessChrome Hotjar Inspector Lighthouse LinkTiger Mail Manus MarketGoo MarketingMiner MetaIAB Miniature MonitoRSS Monitor Netcraft NewRelicSynthetics NewsBlur NewsNow Newsify Nitro OpenGraph Optimizer PTST PWABuilderHttpAgent Perplexity PingdomTMS Playwright Preview PrintFriendly Puppeteer Readable RevvimGort Rigor SQWatcher Scope3 SecurityHeaders Selenium SeoSiteCheckup Silktide Sindup Siteimprove Specificfeeds Sucuri TestLocally ThousandEyes Trae YLT ZoteroTranslationServer adbeat agent archiver archiving brandverity butterfly claude cloudflare code contentkingapp deadlinkchecker devin europarchive feeder feedly google img2dataset infegy mail mailservertest2023 marketingminer mirrorweb monitor nbertaupete95 netcraft newsai newsblur newsify opencode opengraph oupwis perplexity preview retrevo scope3 scraping seositecheckup sitebulb slider splash sqwatcher turingos ubermetrics uptimedoctor watchTowr webresearch websitepulse woorankreview xmco])
|
|
439
|
+
|
|
440
|
+
BOT_FALLBACK_MATCHERS = [ "AP3A.240617.008" ]
|
|
441
|
+
|
|
442
|
+
private def detect_bot
|
|
443
|
+
return true if user_agent_tokens.any? { |it| BOT_WORD_SET.include? it }
|
|
444
|
+
|
|
445
|
+
user_agent_downcase = @user_agent.downcase
|
|
446
|
+
return true if BOT_GLOBAL_MATCHERS.any? { |it| user_agent_downcase.include? it }
|
|
447
|
+
|
|
448
|
+
return true if BOT_FALLBACK_MATCHERS.any? { |it| @user_agent.include? it }
|
|
449
|
+
|
|
450
|
+
false
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Splits the string into alphanumeric sequences of length 3 or more
|
|
454
|
+
private def user_agent_tokens
|
|
455
|
+
@user_agent_tokens ||= @user_agent.tr("^a-zA-Z0-9", " ").split.keep_if { |it| it.length >= 3 }
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
data/lib/skadi.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "active_support"
|
|
2
|
+
require "active_support/concern"
|
|
3
|
+
require "active_support/core_ext"
|
|
4
|
+
|
|
5
|
+
require "action_dispatch/http/request"
|
|
6
|
+
|
|
7
|
+
require_relative "skadi/anonymity_set"
|
|
8
|
+
require_relative "skadi/analytics"
|
|
9
|
+
require_relative "skadi/configuration"
|
|
10
|
+
require_relative "skadi/controller_delegate"
|
|
11
|
+
require_relative "skadi/cookie_manager"
|
|
12
|
+
require_relative "skadi/engine"
|
|
13
|
+
require_relative "skadi/url"
|
|
14
|
+
require_relative "skadi/user_agent"
|
|
15
|
+
require_relative "skadi/version"
|
|
16
|
+
|
|
17
|
+
module Skadi
|
|
18
|
+
# @return [Skadi::Configuration] The Skadi configuration
|
|
19
|
+
mattr_accessor :configuration, default: Configuration.new
|
|
20
|
+
|
|
21
|
+
# @yieldparam config [Skadi::Configuration]
|
|
22
|
+
# @return [void]
|
|
23
|
+
def self.configure
|
|
24
|
+
yield(configuration)
|
|
25
|
+
end
|
|
26
|
+
end
|