use chrono::{DateTime, Datelike, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use tracing;
const UA_LIST_URL: &str =
"https://raw.githubusercontent.com/user-agents-api/data/main/user-agents.json";
const MIN_CHROME_VERSION: u32 = 131;
#[derive(Debug, Deserialize, Serialize)]
pub struct UserAgentCache {
agents: Vec<String>,
chrome_version: u32,
downloaded_at: DateTime<Utc>,
}
impl UserAgentCache {
fn cache_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("rust_scraper")
.join("user_agents.json")
}
pub async fn load() -> Vec<String> {
let current_year = Utc::now().year();
if let Ok(cache) = Self::load_from_cache() {
let cache_chrome_year = 2023 + (cache.chrome_version - 120) as i32;
if cache_chrome_year >= current_year - 1 {
tracing::info!("Using cached user agents (Chrome {})", cache.chrome_version);
return cache.agents;
}
tracing::warn!(
"Cached user agents outdated (Chrome {}), fetching fresh...",
cache.chrome_version
);
}
match Self::fetch_and_cache().await {
Ok(agents) => agents,
Err(e) => {
tracing::warn!("Failed to fetch user agents: {}", e);
Self::fallback_agents()
}
}
}
fn load_from_cache() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let content = fs::read_to_string(Self::cache_path())?;
let cache: Self = serde_json::from_str(&content)?;
Ok(cache)
}
async fn fetch_and_cache() -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let client = Client::builder().timeout(Duration::from_secs(5)).build()?;
let agents = match client.get(UA_LIST_URL).send().await {
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await?;
json.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.filter(|ua| {
ua.contains("Chrome/") && {
ua.split("Chrome/")
.nth(1)
.and_then(|s| s.split('.').next())
.and_then(|v| v.parse::<u32>().ok())
.map(|ver| ver >= MIN_CHROME_VERSION)
.unwrap_or(false)
}
})
.map(String::from)
.collect()
})
.unwrap_or_else(Self::fallback_agents)
}
_ => Self::fallback_agents(),
};
let chrome_version = agents
.first()
.and_then(|ua| ua.split("Chrome/").nth(1))
.and_then(|s| s.split('.').next())
.and_then(|v| v.parse::<u32>().ok())
.unwrap_or(MIN_CHROME_VERSION);
let cache = UserAgentCache {
agents: agents.clone(),
chrome_version,
downloaded_at: Utc::now(),
};
if let Some(parent) = Self::cache_path().parent() {
let _ = fs::create_dir_all(parent); }
if let Ok(json) = serde_json::to_string_pretty(&cache) {
let _ = fs::write(Self::cache_path(), json);
}
tracing::info!(
"Cached {} user agents (Chrome {})",
agents.len(),
chrome_version
);
Ok(agents)
}
pub fn fallback_agents() -> Vec<String> {
vec![
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36".to_string(),
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36".to_string(),
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36".to_string(),
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36".to_string(),
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36".to_string(),
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0".to_string(),
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:123.0) Gecko/20100101 Firefox/123.0".to_string(),
]
}
}
#[must_use]
pub fn get_random_user_agent_from_pool(pool: &[String]) -> String {
let rand_idx = rand::random::<usize>() % pool.len();
pool[rand_idx].clone()
}
#[deprecated(since = "0.4.0", note = "Use UserAgentCache::load() instead")]
#[must_use]
pub fn get_random_user_agent() -> String {
let agents = UserAgentCache::fallback_agents();
get_random_user_agent_from_pool(&agents)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_user_agent_cache_load() {
let agents = UserAgentCache::load().await;
assert!(!agents.is_empty());
assert!(agents
.iter()
.any(|ua| ua.contains("Chrome/") || ua.contains("Firefox/")));
}
#[test]
fn test_fallback_agents_chrome_version() {
let agents = UserAgentCache::fallback_agents();
assert!(!agents.is_empty());
for agent in &agents {
assert!(
agent.contains("Chrome/13") || agent.contains("Firefox/"),
"Agent '{}' should contain Chrome/13x or Firefox/",
agent
);
}
}
#[test]
fn test_fallback_agents_are_unique() {
let agents = UserAgentCache::fallback_agents();
let mut unique_agents = agents.clone();
unique_agents.sort();
unique_agents.dedup();
assert_eq!(
agents.len(),
unique_agents.len(),
"Fallback agents should be unique"
);
}
#[test]
fn test_get_random_user_agent_from_pool() {
let pool = vec!["Agent1".to_string(), "Agent2".to_string()];
let ua = get_random_user_agent_from_pool(&pool);
assert!(ua == "Agent1" || ua == "Agent2");
}
#[test]
fn test_cache_path_construction() {
let path = UserAgentCache::cache_path();
assert!(path.ends_with("user_agents.json"));
assert!(path.to_string_lossy().contains("rust_scraper"));
}
}