1use crate::data::agents::{AI_AGENTS, AiAgent, BOT_PATTERN};
5use crate::js::{self, Value};
6use crate::obj;
7use crate::re::{group, group_u, js_re, test, uni_re};
8
9#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Client {
12 pub browser: String,
14 pub browser_version: String,
16 pub os: String,
18 pub os_version: String,
20 pub device: &'static str,
22}
23
24impl Client {
25 pub fn to_value(&self) -> Value {
27 obj! {
28 "browser" => self.browser.clone(),
29 "browserVersion" => self.browser_version.clone(),
30 "os" => self.os.clone(),
31 "osVersion" => self.os_version.clone(),
32 "device" => self.device,
33 }
34 }
35}
36
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub struct ClientHints {
40 pub brands: Option<String>,
42 pub mobile: Option<String>,
44 pub platform: Option<String>,
46}
47
48pub fn ai_agent(ua: &str) -> Option<&'static AiAgent> {
50 let lower = ua.to_lowercase();
51 AI_AGENTS.iter().find(|agent| lower.contains(agent.token))
52}
53
54pub fn is_bot(ua: &str) -> bool {
56 if js::len16(ua) < 20 || !test(js_re!(r"(?i)mozilla|opera"), ua) {
57 return true;
58 }
59 if test(js_re!(BOT_PATTERN), ua) {
60 return true;
61 }
62 let lower = ua.to_ascii_lowercase();
64 followed_by_other(&lower, "yandex", "browser") || followed_by_other(&lower, "qwant", "ify")
65}
66
67fn followed_by_other(text: &str, word: &str, next: &str) -> bool {
69 let mut from = 0;
70 while let Some(at) = text[from..].find(word) {
71 let end = from + at + word.len();
72 if !text[end..].starts_with(next) {
73 return true;
74 }
75 from = from + at + 1;
76 }
77 false
78}
79
80fn unquote(value: Option<&str>) -> String {
81 js::trim(&value.unwrap_or("").replace('"', "")).to_string()
82}
83
84pub fn parse_client(ua: &str, hints: &ClientHints, screen_width: Option<f64>) -> Client {
87 let browsers: [(&str, ®ex::bytes::Regex); 11] = [
88 ("Edge", js_re!(r"(?:Edg|EdgA|EdgiOS|Edge)/(\d+)")),
89 ("Opera", js_re!(r"(?:OPR|OPiOS|Opera)/(\d+)")),
90 ("Samsung Internet", js_re!(r"SamsungBrowser/(\d+)")),
91 ("Yandex Browser", js_re!(r"YaBrowser/(\d+)")),
92 ("Vivaldi", js_re!(r"Vivaldi/(\d+)")),
93 ("UC Browser", js_re!(r"UCBrowser/(\d+)")),
94 ("DuckDuckGo", js_re!(r"(?:Ddg|DuckDuckGo)/(\d+)")),
95 ("Facebook", js_re!(r"FB(?:AV|_IAB)/(\d+)")),
96 ("Instagram", js_re!(r"Instagram (\d+)")),
97 ("Firefox", js_re!(r"(?:Firefox|FxiOS)/(\d+)")),
98 ("Chrome", js_re!(r"(?:CriOS|Chrome)/(\d+)")),
99 ];
100 let unicode: [(&str, ®ex::Regex); 2] = [
102 (
103 "Safari",
104 uni_re!(
105 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/"
106 ),
107 ),
108 ("Internet Explorer", uni_re!(r"(?:MSIE |Trident/[^\n\r\x{2028}\x{2029}]*rv:)([0-9]+)")),
109 ];
110 let mut browser = "Other".to_string();
111 let mut browser_version = String::new();
112 let found = browsers
113 .iter()
114 .find_map(|(name, pattern)| group(pattern, ua, 1).map(|v| (*name, v)))
115 .or_else(|| unicode.iter().find_map(|(name, pattern)| group_u(pattern, ua, 1).map(|v| (*name, v))));
116 if let Some((name, version)) = found {
117 browser = name.to_string();
118 browser_version = version;
119 }
120 if browser == "Chrome" && test(js_re!(r"; wv\)"), ua) {
121 browser = "Android WebView".into();
122 }
123 if browser == "Chrome" && hints.brands.as_deref().unwrap_or("").contains("\"Brave\"") {
125 browser = "Brave".into();
126 }
127
128 let mut os = "Other".to_string();
129 let mut os_version = String::new();
130 if let Some(v) = group(js_re!(r"Windows NT (\d+\.\d+)"), ua, 1) {
131 os = "Windows".into();
132 os_version = match v.as_str() {
133 "10.0" => "10",
134 "6.3" => "8.1",
135 "6.2" => "8",
136 "6.1" => "7",
137 "6.0" => "Vista",
138 "5.1" => "XP",
139 _ => "",
140 }
141 .into();
142 } else if let Some(v) = group_u(uni_re!(r"(?:iPhone|iPad|iPod)[^\n\r\x{2028}\x{2029}]*? OS ([0-9]+)"), ua, 1) {
143 os = "iOS".into();
144 os_version = v;
145 } else if let Some(v) = group(js_re!(r"Android (\d+)"), ua, 1) {
146 os = "Android".into();
147 os_version = v;
148 } else if ua.contains("Android") {
149 os = "Android".into();
150 } else if ua.contains("CrOS") {
151 os = "Chrome OS".into();
152 } else if ua.contains("Mac OS X") || ua.contains("Macintosh") {
153 os = "macOS".into();
155 } else if ua.contains("Linux") || ua.contains("X11") {
156 os = "Linux".into();
157 }
158 let platform = unquote(hints.platform.as_deref());
159 if os == "Other" && !platform.is_empty() {
160 os = platform;
161 }
162
163 let mut device = "desktop";
164 if test(js_re!(r"iPad|Tablet|PlayBook|Silk"), ua) || (os == "Android" && !ua.contains("Mobile")) {
165 device = "tablet";
166 } else if test(js_re!(r"Mobi|iPhone|iPod|Opera Mini|IEMobile"), ua) || unquote(hints.mobile.as_deref()) == "?1" {
167 device = "mobile";
168 } else if os == "macOS" && screen_width.is_some_and(|w| [768.0, 810.0, 820.0, 834.0, 1024.0].contains(&w)) {
169 device = "tablet";
171 os = "iOS".into();
172 }
173
174 Client { browser, browser_version, os, os_version, device }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn lookaheads_are_checked_by_hand() {
183 let ua = |s: &str| format!("Mozilla/5.0 (X11; Linux x86_64) {s}");
184 assert!(!is_bot(&ua("YandexBrowser/24")));
185 assert!(is_bot(&ua("YandexSearch/24")));
186 assert!(!is_bot(&ua("Qwantify/1")));
187 assert!(is_bot(&ua("Qwant/1")));
188 assert!(is_bot("short"));
189 }
190}