botagent 0.1.0

A bot user agent detection library using regex patterns.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
//!
//! This library provides functionality to detect bot user agents using regular expressions.
//!
//! It reads patterns from a JSON File, compiles them into a regex, and checks user agents
//! against these patterns.

pub mod errors;
pub mod pattern;

use crate::errors::BotDetectorError;
use once_cell::sync::OnceCell;
use pcre2::bytes::Regex;
use serde::Deserialize;
use std::fs;

static REGEX: OnceCell<Regex> = OnceCell::new();

#[derive(Debug, Deserialize)]
struct List(Vec<String>);

/// Initialize the global regex pattern, only done once.
///
/// # Arguments
///
/// * `json_path` - Path to the JSON file containing patterns.
///
/// # Returns
///
/// Returns a reference to the compiled `Regex` or a `BotDetectorError` if something goes wrong.
///
/// # Errors
///
/// This function will return an error if the JSON file cannot be read, parsed, or if the regex
/// pattern cannot be compiled.
///
/// # Panics
///
/// Will panic if `generate_pattern` process failed.
///
/// # Example
///
/// ```no_run
/// # use botagent::init_pattern;
/// let regex = init_pattern("patterns.json").unwrap();
/// ```
pub fn init_pattern(json_path: &str) -> Result<&'static Regex, BotDetectorError> {
    Ok(
        REGEX.get_or_init(|| match pattern::generate_pattern(json_path) {
            Ok(regex) => regex,
            Err(e) => {
                panic!("Error detected: {:?}", e.error_message());
            }
        }),
    )
}

/// Check if the given user agent includes a bot pattern.
///
/// # Arguments
///
/// * `user_agent` - The user agent string to be checked.
/// * `json_path` - Path to the JSON file containing bot patterns.
///
/// # Returns
///
/// Returns `true` if the user agent matches any bot pattern, otherwise `false`.
///
/// # Errors
///
/// Returns a `BotDetectorError` if there's an issue with reading the patterns or compiling the regex.
///
/// # Example
///
/// ```no_run
/// # use botagent::is_bot;
/// let is_bot = is_bot("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "patterns.json").unwrap();
/// assert!(is_bot);
/// ```
pub fn is_bot(user_agent: &str, json_path: &str) -> Result<bool, BotDetectorError> {
    let regex = init_pattern(json_path)?;
    Ok(regex.is_match(user_agent.as_bytes()).unwrap_or(false))
}

/// Find the first non-empty capture group match of a bot pattern in the user agent string.
///
/// # Arguments
///
/// * `user_agent` - The user agent string to be checked.
/// * `json_path` - Path to the JSON file containing bot patterns.
///
/// # Returns
///
/// Returns `Some(String)` with the first matched capture group or `None` if no match is found.
///
/// # Errors
///
/// Returns a `BotDetectorError` if there's an issue with reading the patterns or compiling the regex.
///
/// # Example
///
/// ```no_run
/// # use botagent::is_bot_match;
/// let matched_pattern = is_bot_match("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "patterns.json").unwrap();
/// assert_eq!(matched_pattern, Some("Googlebot".to_string()));
/// ```
pub fn is_bot_match(user_agent: &str, json_path: &str) -> Result<Option<String>, BotDetectorError> {
    let regex = init_pattern(json_path)?;

    if let Ok(Some(caps)) = regex.captures(user_agent.as_bytes()) {
        if let Some(matched) = caps.get(0) {
            return Ok(Some(
                String::from_utf8_lossy(matched.as_bytes()).to_string(),
            ));
        }
    }

    Ok(None)
}

/// Check if the given user agent matches any patterns in the provided JSON file.
///
/// # Arguments
///
/// * `user_agent` - The user agent string to be checked.
/// * `json_path` - Path to the JSON file containing the bot patterns.
///
/// # Returns
///
/// Returns a `Result<Vec<String>, BotDetectorError>`. If successful, returns a vector of matching patterns as strings. If there is an error reading the JSON file or compiling the regex patterns, returns a `BotDetectorError`.
///
/// # Errors
///
/// Returns a `BotDetectorError` if there's an issue with reading the patterns from the JSON file or compiling the regex patterns.
///
/// # Example
///
/// ```no_run
/// # use botagent::is_bot_matches;
/// let matches = is_bot_matches("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "patterns.json").unwrap();
/// assert!(matches.contains(&"Googlebot".to_string()));
/// ```
pub fn is_bot_matches(user_agent: &str, json_path: &str) -> Result<Vec<String>, BotDetectorError> {
    let patterns_json = fs::read_to_string(json_path)?;
    let patterns: List = serde_json::from_str(&patterns_json)?;

    let matches = patterns
        .0
        .iter()
        .filter_map(|pattern| {
            let regex = Regex::new(format!("(?i){pattern}").as_str()).ok()?;

            if regex.is_match(user_agent.as_bytes()).unwrap_or(false) {
                Some(pattern.clone())
            } else {
                None
            }
        })
        .collect();

    Ok(matches)
}

/// Check if the given user agent matches any bot pattern and return the matching pattern.
///
/// # Arguments
///
/// * `user_agent` - The user agent string to be checked.
/// * `json_path` - Path to the JSON file containing bot patterns.
///
/// # Returns
///
/// Returns `Some(pattern)` if the user agent matches any bot pattern, where `pattern` is the matching pattern string.
/// Returns `None` if no patterns match the user agent.
///
/// # Errors
///
/// Returns a `BotDetectorError` if there's an issue with reading the patterns, deserializing the JSON, or compiling the regex.
///
/// # Example
///
/// ```no_run
/// # use botagent::is_bot_pattern;
/// let pattern = is_bot_pattern("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "patterns.json").unwrap();
/// assert_eq!(pattern, Some("Googlebot/2.1".to_string()));
/// ```
pub fn is_bot_pattern(
    user_agent: &str,
    json_path: &str,
) -> Result<Option<String>, BotDetectorError> {
    let patterns_json = fs::read_to_string(json_path)?;
    let patterns: List = serde_json::from_str(&patterns_json)?;

    for pattern in patterns.0 {
        let regex = Regex::new(&pattern)?;

        if regex.is_match(user_agent.as_bytes())? {
            return Ok(Some(pattern));
        }
    }

    Ok(None)
}

/// Check which bot patterns from the given JSON file match the user agent.
///
/// # Arguments
///
/// * `user_agent` - The user agent string to be checked.
/// * `json_path` - Path to the JSON file containing bot patterns.
///
/// # Returns
///
/// Returns a `Vec<String>` containing all bot patterns from the JSON file that match the user agent.
/// If no patterns match, an empty vector is returned.
///
/// # Errors
///
/// Returns a `BotDetectorError` if there's an issue with reading the patterns, parsing the JSON, or compiling any regex.
///
/// # Example
///
/// ```no_run
/// # use botagent::is_bot_patterns;
/// let matching_patterns = is_bot_patterns("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", "patterns.json").unwrap();
/// assert!(matching_patterns.contains(&"Googlebot/2.1".to_string()));
/// ```
pub fn is_bot_patterns(user_agent: &str, json_path: &str) -> Result<Vec<String>, BotDetectorError> {
    let patterns_json = fs::read_to_string(json_path)?;
    let patterns: List = serde_json::from_str(&patterns_json)?;

    let matching_patterns: Vec<String> = patterns
        .0
        .into_iter()
        .filter_map(|pattern| {
            let regex = Regex::new(&pattern).ok()?;
            if regex.is_match(user_agent.as_bytes()).ok()? {
                Some(pattern)
            } else {
                None
            }
        })
        .collect();

    Ok(matching_patterns)
}

/// Creates a closure that checks if a user agent string matches a custom regex pattern
///
/// # Arguments
///
/// * `custom_pattern` - A `Regex` object that represents the custom pattern to be used for matching.
/// # Returns
///
/// Returns a closure that takes a user agent string as input and returns `true` if the user agent matches the
/// custom regex pattern and is not empty, or `false` otherwise.
///
/// ```no_run
/// # use pcre2::bytes::Regex;
/// # use botagent::create_is_bot;
/// let pattern = Regex::new(r"Googlebot").unwrap();
/// let custom_bot = create_is_bot(pattern);
/// assert!(custom_bot("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"));
/// ```
pub fn create_is_bot(custom_pattern: Regex) -> impl Fn(&str) -> bool {
    move |user_agent: &str| -> bool {
        !user_agent.is_empty()
            && custom_pattern
                .is_match(user_agent.as_bytes())
                .unwrap_or(false)
    }
}

/// Creates a function to check if a user agent matches any bot pattern from a list.
///
/// # Arguments
///
/// * `list` - A vector of strings where each string represents a bot pattern. The patterns will be joined with `|` to form a single regular expression.
///
/// # Returns
///
/// Returns a closure that takes a user agent string and returns `true` if it matches any of the patterns from the list, otherwise `false`.
///
/// # Panics
///
/// Panics if there is an issue compiling the regular expression from the list of patterns.
///
/// # Example
///
/// ```no_run
/// # use pcre2::bytes::Regex;
/// # use botagent::create_is_bot_from_list;
/// let patterns = vec![
///     "Googlebot".to_string(),
///     "Bingbot".to_string(),
///     "Slurp".to_string(),
/// ];
/// let is_bot = create_is_bot_from_list(&patterns);
/// assert!(is_bot("Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"));
/// assert!(!is_bot("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"));
/// ```
pub fn create_is_bot_from_list(list: &[String]) -> impl Fn(&str) -> bool {
    let pattern_str = list.join("|");

    let regex = Regex::new(&pattern_str).expect("Failed to compile regex");

    move |user_agent: &str| -> bool {
        !user_agent.is_empty() && regex.is_match(user_agent.as_bytes()).unwrap_or(false)
    }
}

#[cfg(test)]
mod features {
    use super::*;
    use std::fs;
    use tempfile::NamedTempFile;

    fn create_temp_patterns_file(patterns: &[&str]) -> NamedTempFile {
        let file = NamedTempFile::new().expect("Failed to create temp file");
        let pattern_list = serde_json::to_string(&patterns).expect("Failed to serialze patterns");

        fs::write(file.path(), pattern_list).expect("failed to write to temp file");

        file
    }

    #[test]
    fn test_is_bot() {
        let bot_user_agent =
            "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
        let temp_file = create_temp_patterns_file(&["Googlebot"]);

        // bot_user_agent string is recognised as bot
        assert!(is_bot(bot_user_agent, temp_file.path().to_str().unwrap()).unwrap());
        assert!(!is_bot("", temp_file.path().to_str().unwrap()).unwrap());
    }

    #[test]
    fn test_is_bot_match() {
        let bot_user_agent =
            "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
        let temp_file = create_temp_patterns_file(&["Googlebot"]);

        // find pattern in bot_user_agent string
        assert_eq!(
            is_bot_match(bot_user_agent, temp_file.path().to_str().unwrap()).unwrap(),
            Some("Googlebot".to_string())
        );
        assert_eq!(
            is_bot_match("", temp_file.path().to_str().unwrap()).unwrap(),
            None
        );
    }

    #[test]
    fn test_is_bot_matches() {
        let bot_user_agent =
            "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
        let temp_file = create_temp_patterns_file(&["Google", "Googlebot", "bot", "http"]);

        let matches = is_bot_matches(bot_user_agent, temp_file.path().to_str().unwrap()).unwrap();

        // find all patterns in bot_user_agent string
        assert!(matches.contains(&"Google".to_string()));
        assert_eq!(matches.len(), 4);

        let empty_user_agent = "";
        let matches = is_bot_matches(empty_user_agent, temp_file.path().to_str().unwrap()).unwrap();

        assert!(matches.is_empty());
    }

    #[test]
    fn test_is_bot_pattern() {
        let bot_user_agent =
            "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
        let expected_pattern = r"(?<! (?:channel/|google/))google(?!(app|/google| pixel))";

        let temp_file = create_temp_patterns_file(&[expected_pattern]);

        let result = is_bot_pattern(bot_user_agent, temp_file.path().to_str().unwrap())
            .expect("Failed to execute is_bot_pattern");

        // find first pattern in bot user agent string
        assert_eq!(result, Some(expected_pattern.to_string()));
        assert_eq!(
            is_bot_pattern("", temp_file.path().to_str().unwrap()).unwrap(),
            None
        );
    }

    #[test]
    fn test_is_bot_patterns() {
        let bot_user_agent =
            "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
        let patterns = [
            r"(?<! (?:channel/|google/))google(?!(app|/google| pixel))",
            r"(?<! cu)bots?(?:\b|_)",
            r"(?<!(?:lib))http",
            r"\.com",
        ];

        let temp_file = create_temp_patterns_file(&patterns);
        let result = is_bot_patterns(bot_user_agent, temp_file.path().to_str().unwrap())
            .expect("Failed to execute is_bot_patterns");

        // find all patterns in bot user agent string
        for pattern in patterns.iter() {
            assert!(result.contains(&pattern.to_string()));
        }

        assert_eq!(result.len(), 4);

        let empty_user_agent = "";
        let matches =
            is_bot_patterns(empty_user_agent, temp_file.path().to_str().unwrap()).unwrap();

        assert!(matches.is_empty());
    }

    #[test]
    fn test_create_is_bot() {
        let bot_user_agent =
            "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)";
        let custom_pattern = Regex::new(r"bot").unwrap();
        let custom_is_bot = create_is_bot(custom_pattern);

        // create custom_is_bot function with custom pattern
        assert!(custom_is_bot(bot_user_agent));
    }

    #[test]
    fn test_create_is_bot_from_list() {
        let chrome_lighthouse_user_agent_strings = [
            "mozilla/5.0 (macintosh; intel mac os x 10_15_7) applewebkit/537.36 (khtml, like gecko) chrome/94.0.4590.2 safari/537.36 chrome-lighthouse",
            "mozilla/5.0 (linux; android 7.0; moto g (4)) applewebkit/537.36 (khtml, like gecko) chrome/94.0.4590.2 mobile safari/537.36 chrome-lighthouse",
        ];

        let temp_file = create_temp_patterns_file(&["chrome-lighthouse", "google", "bot", "http"]);

        let patterns_to_remove: Vec<String> = chrome_lighthouse_user_agent_strings
            .iter()
            .flat_map(|ua| is_bot_matches(ua, temp_file.path().to_str().unwrap()).unwrap())
            .collect();

        let filtered_list: Vec<String> = vec!["chrome-lighthouse", "google", "bot", "http"]
            .into_iter()
            .map(|s| s.to_string()) // Convert &str to String
            .filter(|pattern| !patterns_to_remove.contains(pattern))
            .collect();

        let is_bot2 = create_is_bot_from_list(&filtered_list);
        let ua = chrome_lighthouse_user_agent_strings[0];

        // create custom isbot function with custom pattern
        assert!(!is_bot_matches(ua, temp_file.path().to_str().unwrap())
            .unwrap()
            .is_empty());

        assert!(!is_bot2(ua));
    }

    #[test]
    fn test_invalid_inputs() {
        let temp_file = create_temp_patterns_file(&["Googlebot"]);

        assert!(!is_bot("", temp_file.path().to_str().unwrap()).unwrap());
        assert_eq!(
            is_bot_match("", temp_file.path().to_str().unwrap()).unwrap(),
            None
        );
    }
}