use crate::data::agents::{AI_AGENTS, AiAgent, BOT_PATTERN};
use crate::js::{self, Value};
use crate::obj;
use crate::re::{group, group_u, js_re, test, uni_re};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Client {
pub browser: String,
pub browser_version: String,
pub os: String,
pub os_version: String,
pub device: &'static str,
}
impl Client {
pub fn to_value(&self) -> Value {
obj! {
"browser" => self.browser.clone(),
"browserVersion" => self.browser_version.clone(),
"os" => self.os.clone(),
"osVersion" => self.os_version.clone(),
"device" => self.device,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ClientHints {
pub brands: Option<String>,
pub mobile: Option<String>,
pub platform: Option<String>,
}
pub fn ai_agent(ua: &str) -> Option<&'static AiAgent> {
let lower = ua.to_lowercase();
AI_AGENTS.iter().find(|agent| lower.contains(agent.token))
}
pub fn is_bot(ua: &str) -> bool {
if js::len16(ua) < 20 || !test(js_re!(r"(?i)mozilla|opera"), ua) {
return true;
}
if test(js_re!(BOT_PATTERN), ua) {
return true;
}
let lower = ua.to_ascii_lowercase();
followed_by_other(&lower, "yandex", "browser") || followed_by_other(&lower, "qwant", "ify")
}
fn followed_by_other(text: &str, word: &str, next: &str) -> bool {
let mut from = 0;
while let Some(at) = text[from..].find(word) {
let end = from + at + word.len();
if !text[end..].starts_with(next) {
return true;
}
from = from + at + 1;
}
false
}
fn unquote(value: Option<&str>) -> String {
js::trim(&value.unwrap_or("").replace('"', "")).to_string()
}
pub fn parse_client(ua: &str, hints: &ClientHints, screen_width: Option<f64>) -> Client {
let browsers: [(&str, ®ex::bytes::Regex); 11] = [
("Edge", js_re!(r"(?:Edg|EdgA|EdgiOS|Edge)/(\d+)")),
("Opera", js_re!(r"(?:OPR|OPiOS|Opera)/(\d+)")),
("Samsung Internet", js_re!(r"SamsungBrowser/(\d+)")),
("Yandex Browser", js_re!(r"YaBrowser/(\d+)")),
("Vivaldi", js_re!(r"Vivaldi/(\d+)")),
("UC Browser", js_re!(r"UCBrowser/(\d+)")),
("DuckDuckGo", js_re!(r"(?:Ddg|DuckDuckGo)/(\d+)")),
("Facebook", js_re!(r"FB(?:AV|_IAB)/(\d+)")),
("Instagram", js_re!(r"Instagram (\d+)")),
("Firefox", js_re!(r"(?:Firefox|FxiOS)/(\d+)")),
("Chrome", js_re!(r"(?:CriOS|Chrome)/(\d+)")),
];
let unicode: [(&str, ®ex::Regex); 2] = [
(
"Safari",
uni_re!(
r"Version/([0-9]+)[0-9.]* (?:Mobile/[^\t\n\x{0B}\f\r \x{A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]+ )?Safari/"
),
),
("Internet Explorer", uni_re!(r"(?:MSIE |Trident/[^\n\r\x{2028}\x{2029}]*rv:)([0-9]+)")),
];
let mut browser = "Other".to_string();
let mut browser_version = String::new();
let found = browsers
.iter()
.find_map(|(name, pattern)| group(pattern, ua, 1).map(|v| (*name, v)))
.or_else(|| unicode.iter().find_map(|(name, pattern)| group_u(pattern, ua, 1).map(|v| (*name, v))));
if let Some((name, version)) = found {
browser = name.to_string();
browser_version = version;
}
if browser == "Chrome" && test(js_re!(r"; wv\)"), ua) {
browser = "Android WebView".into();
}
if browser == "Chrome" && hints.brands.as_deref().unwrap_or("").contains("\"Brave\"") {
browser = "Brave".into();
}
let mut os = "Other".to_string();
let mut os_version = String::new();
if let Some(v) = group(js_re!(r"Windows NT (\d+\.\d+)"), ua, 1) {
os = "Windows".into();
os_version = match v.as_str() {
"10.0" => "10",
"6.3" => "8.1",
"6.2" => "8",
"6.1" => "7",
"6.0" => "Vista",
"5.1" => "XP",
_ => "",
}
.into();
} else if let Some(v) = group_u(uni_re!(r"(?:iPhone|iPad|iPod)[^\n\r\x{2028}\x{2029}]*? OS ([0-9]+)"), ua, 1) {
os = "iOS".into();
os_version = v;
} else if let Some(v) = group(js_re!(r"Android (\d+)"), ua, 1) {
os = "Android".into();
os_version = v;
} else if ua.contains("Android") {
os = "Android".into();
} else if ua.contains("CrOS") {
os = "Chrome OS".into();
} else if ua.contains("Mac OS X") || ua.contains("Macintosh") {
os = "macOS".into();
} else if ua.contains("Linux") || ua.contains("X11") {
os = "Linux".into();
}
let platform = unquote(hints.platform.as_deref());
if os == "Other" && !platform.is_empty() {
os = platform;
}
let mut device = "desktop";
if test(js_re!(r"iPad|Tablet|PlayBook|Silk"), ua) || (os == "Android" && !ua.contains("Mobile")) {
device = "tablet";
} else if test(js_re!(r"Mobi|iPhone|iPod|Opera Mini|IEMobile"), ua) || unquote(hints.mobile.as_deref()) == "?1" {
device = "mobile";
} else if os == "macOS" && screen_width.is_some_and(|w| [768.0, 810.0, 820.0, 834.0, 1024.0].contains(&w)) {
device = "tablet";
os = "iOS".into();
}
Client { browser, browser_version, os, os_version, device }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookaheads_are_checked_by_hand() {
let ua = |s: &str| format!("Mozilla/5.0 (X11; Linux x86_64) {s}");
assert!(!is_bot(&ua("YandexBrowser/24")));
assert!(is_bot(&ua("YandexSearch/24")));
assert!(!is_bot(&ua("Qwantify/1")));
assert!(is_bot(&ua("Qwant/1")));
assert!(is_bot("short"));
}
}