Skip to main content

runlight/
ua.rs

1//! User agents: AI agents, bots, and the browser, system, and device a
2//! person uses.
3
4use 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/// The browser, system, and device read from a user agent.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Client {
12    /// The browser's name, or `Other`.
13    pub browser: String,
14    /// Its major version, or empty.
15    pub browser_version: String,
16    /// The operating system, or `Other`.
17    pub os: String,
18    /// Its version, or empty.
19    pub os_version: String,
20    /// `desktop`, `mobile`, or `tablet`.
21    pub device: &'static str,
22}
23
24impl Client {
25    /// As the SDK's object writes it.
26    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/// Low entropy client hints, sent by Chromium browsers on every request.
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
39pub struct ClientHints {
40    /// `Sec-CH-UA`.
41    pub brands: Option<String>,
42    /// `Sec-CH-UA-Mobile`.
43    pub mobile: Option<String>,
44    /// `Sec-CH-UA-Platform`.
45    pub platform: Option<String>,
46}
47
48/// The AI agent a user agent names, if any.
49pub 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
54/// Whether a user agent is anything but a person in a browser.
55pub 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    // The pattern's two lookaheads: yandex(?!browser) and qwant(?!ify).
63    let lower = ua.to_ascii_lowercase();
64    followed_by_other(&lower, "yandex", "browser") || followed_by_other(&lower, "qwant", "ify")
65}
66
67/// Whether `word` appears anywhere not followed by `next`.
68fn 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
84/// The browser, system, and device a user agent names, with the client
85/// hints and the screen's width where they say more.
86pub fn parse_client(ua: &str, hints: &ClientHints, screen_width: Option<f64>) -> Client {
87    let browsers: [(&str, &regex::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    // JavaScript's `.` stops at any line terminator, and its `\S` at any Unicode space.
101    let unicode: [(&str, &regex::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    // Brave looks like Chrome in the user agent but names itself in the hints.
124    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        // macOS froze its version in the user agent at 10.15, so it says nothing.
154        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        // iPadOS asks for desktop sites with a Mac user agent; the screen gives it away.
170        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}