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>);
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());
}
}),
)
}
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))
}
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)
}
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)
}
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)
}
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)
}
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)
}
}
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"]);
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"]);
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();
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");
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");
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);
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()) .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];
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
);
}
}