repos
/ analytics-rust master

analytics-rust

mirror archived upstream

Single-binary self-hosted website analytics on Rust axum: collector API, dashboards, world map, and PDF reports.

analyticsaxumdockerrustself-hostedsqliteviteweb-analytics

4.5 KB · 135 lines · Rust Raw History
  1use std::path::Path;
  2use uaparser::{Parser, UserAgentParser};
  3
  4pub struct UaParser {
  5    parser: Option<UserAgentParser>,
  6}
  7
  8#[derive(Debug, Clone, Default)]
  9pub struct ParsedUa {
 10    pub platform: Option<String>,
 11    pub browser: Option<String>,
 12    pub device: Option<String>, // Mobile | Tablet | Desktop
 13    pub is_bot: bool,
 14    pub bot_name: Option<String>,
 15}
 16
 17impl UaParser {
 18    /// Loads regexes.yaml from `data_dir/regexes.yaml` if present, else falls
 19    /// back to a substring heuristic. Use `ensure_regexes` to download.
 20    pub fn load(path: &std::path::Path) -> Self {
 21        if path.exists() {
 22            if let Some(parser) = try_load(path) {
 23                tracing::info!("uaparser regexes loaded from {}", path.display());
 24                return Self { parser: Some(parser) };
 25            }
 26        }
 27        tracing::warn!(
 28            "uaparser regexes.yaml not found — ua parsing falls back to a substring heuristic until refresh"
 29        );
 30        Self { parser: None }
 31    }
 32
 33    pub fn reload(&mut self, path: &std::path::Path) {
 34        if path.exists() {
 35            if let Some(parser) = try_load(path) {
 36                self.parser = Some(parser);
 37            }
 38        }
 39    }
 40
 41    pub fn parse(&self, ua: &str) -> ParsedUa {
 42        if let Some(parser) = &self.parser {
 43            let client = parser.parse(ua);
 44            let platform = match client.os.family.as_ref() {
 45                "Other" => None,
 46                other => Some(other.to_string()),
 47            };
 48            let browser = match client.user_agent.family.as_ref() {
 49                "Other" => None,
 50                other => Some(other.to_string()),
 51            };
 52            let device_family = client.device.family.as_ref();
 53            let is_bot = matches!(
 54                device_family,
 55                "Spider" | "Spider Desktop" | "Spider Smartphone" | "Spider Tablet"
 56            ) || classify_bot_by_ua(ua);
 57
 58            let device = if is_bot {
 59                None
 60            } else {
 61                Some(classify_device(ua, device_family).to_string())
 62            };
 63            let bot_name = if is_bot {
 64                Some(client.user_agent.family.to_string()).filter(|s| s != "Other")
 65            } else {
 66                None
 67            };
 68            return ParsedUa { platform, browser, device, is_bot, bot_name };
 69        }
 70        // Fallback heuristic so the collector still works without regexes.yaml.
 71        let is_bot = classify_bot_by_ua(ua);
 72        let device = if is_bot { None } else { Some(classify_device(ua, "").to_string()) };
 73        ParsedUa {
 74            platform: None,
 75            browser: None,
 76            device,
 77            is_bot,
 78            bot_name: if is_bot { Some("Unknown bot".to_string()) } else { None },
 79        }
 80    }
 81}
 82
 83fn try_load(path: &Path) -> Option<UserAgentParser> {
 84    UserAgentParser::builder()
 85        .with_unicode_support(false)
 86        .build_from_yaml(path.to_string_lossy().as_ref())
 87        .ok()
 88}
 89
 90/// Download the canonical ua-parser regexes.yaml on first boot if missing.
 91/// Source: https://github.com/ua-parser/uap-core (Apache-2.0).
 92pub async fn ensure_regexes(dest: &Path) -> anyhow::Result<bool> {
 93    if dest.exists() {
 94        return Ok(false);
 95    }
 96    let url = "https://raw.githubusercontent.com/ua-parser/uap-core/master/regexes.yaml";
 97    let bytes = reqwest::get(url).await?.error_for_status()?.bytes().await?;
 98    if let Some(parent) = dest.parent() {
 99        std::fs::create_dir_all(parent)?;
100    }
101    std::fs::write(dest, &bytes)?;
102    tracing::info!("downloaded ua-parser regexes to {}", dest.display());
103    Ok(true)
104}
105
106fn classify_bot_by_ua(ua: &str) -> bool {
107    let ua = ua.to_ascii_lowercase();
108    const NEEDLES: &[&str] = &[
109        "bot", "crawl", "spider", "slurp", "facebookexternalhit", "ahrefs", "semrush",
110        "petalbot", "yandex", "bingpreview", "duckduckgo", "discordbot", "whatsapp",
111        "telegrambot", "applebot", "linkedinbot", "embedly", "headlesschrome",
112        "phantomjs", "lighthouse", "pingdom", "uptimerobot", "monitor",
113    ];
114    NEEDLES.iter().any(|n| ua.contains(n))
115}
116
117fn classify_device(ua: &str, family: &str) -> &'static str {
118    let lower = ua.to_ascii_lowercase();
119    let tablet = matches!(family, "iPad" | "Tablet")
120        || lower.contains("tablet")
121        || lower.contains("ipad");
122    if tablet {
123        return "Tablet";
124    }
125    let mobile = matches!(family, "iPhone" | "iPod" | "Generic Smartphone")
126        || lower.contains("mobile")
127        || lower.contains("iphone")
128        || lower.contains("android");
129    if mobile {
130        "Mobile"
131    } else {
132        "Desktop"
133    }
134}