pub mod autoupdate;
use rand::RngExt;
use rand::seq::IndexedRandom;
use reqwest::header::{
ACCEPT, ACCEPT_ENCODING, ACCEPT_LANGUAGE, HeaderMap, HeaderValue, USER_AGENT,
};
static BROWSER_VERSIONS: std::sync::LazyLock<autoupdate::BrowserVersions> =
std::sync::LazyLock::new(autoupdate::BrowserVersions::load_or_update);
#[derive(Debug, Clone)]
pub struct BrowserProfile {
pub user_agent: String,
pub accept: String,
pub accept_language: String,
pub accept_encoding: String,
pub sec_ch_ua: String,
pub sec_ch_ua_mobile: String,
pub sec_ch_ua_platform: String,
pub sec_fetch_dest: String,
pub sec_fetch_mode: String,
pub sec_fetch_site: String,
pub sec_fetch_user: String,
}
#[derive(Debug, Clone, Copy)]
pub enum Platform {
MacOS,
Windows,
Linux,
}
impl Platform {
fn random() -> Self {
let mut rng = rand::rng();
let roll: f32 = rng.random_range(0.0..1.0);
if roll < 0.65 {
Platform::Windows
} else if roll < 0.85 {
Platform::MacOS
} else {
Platform::Linux
}
}
fn os_string(self) -> &'static str {
match self {
Platform::MacOS => "Macintosh; Intel Mac OS X 10_15_7",
Platform::Windows => "Windows NT 10.0; Win64; x64",
Platform::Linux => "X11; Linux x86_64",
}
}
fn sec_ch_platform(self) -> &'static str {
match self {
Platform::MacOS => "\"macOS\"",
Platform::Windows => "\"Windows\"",
Platform::Linux => "\"Linux\"",
}
}
}
#[must_use]
pub fn chrome_profile() -> BrowserProfile {
let mut rng = rand::rng();
let platform = Platform::random();
let (major, full) = BROWSER_VERSIONS
.chrome
.choose(&mut rng)
.expect("Chrome versions list should not be empty");
let user_agent = format!(
"Mozilla/5.0 ({}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{} Safari/537.36",
platform.os_string(),
full
);
let brands = [
format!("\"Google Chrome\";v=\"{major}\""),
format!("\"Chromium\";v=\"{major}\""),
"\"Not_A Brand\";v=\"24\"".to_string(),
];
BrowserProfile {
user_agent,
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7".to_string(),
accept_language: random_accept_language(),
accept_encoding: "gzip, deflate, br, zstd".to_string(),
sec_ch_ua: brands.join(", "),
sec_ch_ua_mobile: "?0".to_string(),
sec_ch_ua_platform: platform.sec_ch_platform().to_string(),
sec_fetch_dest: "document".to_string(),
sec_fetch_mode: "navigate".to_string(),
sec_fetch_site: "none".to_string(),
sec_fetch_user: "?1".to_string(),
}
}
#[must_use]
pub fn firefox_profile() -> BrowserProfile {
let mut rng = rand::rng();
let platform = Platform::random();
let version = BROWSER_VERSIONS
.firefox
.choose(&mut rng)
.expect("Firefox versions list should not be empty");
let user_agent = format!(
"Mozilla/5.0 ({}; rv:{}) Gecko/20100101 Firefox/{}",
platform.os_string(),
version,
version
);
BrowserProfile {
user_agent,
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"
.to_string(),
accept_language: random_accept_language(),
accept_encoding: "gzip, deflate, br, zstd".to_string(),
sec_ch_ua: String::new(),
sec_ch_ua_mobile: String::new(),
sec_ch_ua_platform: String::new(),
sec_fetch_dest: "document".to_string(),
sec_fetch_mode: "navigate".to_string(),
sec_fetch_site: "none".to_string(),
sec_fetch_user: "?1".to_string(),
}
}
#[must_use]
pub fn safari_profile() -> BrowserProfile {
let mut rng = rand::rng();
let (version, webkit) = BROWSER_VERSIONS
.safari
.choose(&mut rng)
.expect("Safari versions list should not be empty");
let user_agent = format!(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/{webkit} (KHTML, like Gecko) Version/{version} Safari/{webkit}"
);
BrowserProfile {
user_agent,
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8".to_string(),
accept_language: random_accept_language(),
accept_encoding: "gzip, deflate, br".to_string(), sec_ch_ua: String::new(),
sec_ch_ua_mobile: String::new(),
sec_ch_ua_platform: String::new(),
sec_fetch_dest: "document".to_string(),
sec_fetch_mode: "navigate".to_string(),
sec_fetch_site: "none".to_string(),
sec_fetch_user: "?1".to_string(),
}
}
#[must_use]
pub fn random_profile() -> BrowserProfile {
let mut rng = rand::rng();
let roll: f32 = rng.random_range(0.0..1.0);
if roll < 0.65 {
chrome_profile()
} else if roll < 0.85 {
safari_profile()
} else {
firefox_profile()
}
}
fn random_accept_language() -> String {
let mut rng = rand::rng();
let languages = [
"en-US,en;q=0.9",
"en-GB,en;q=0.9",
"en-US,en;q=0.9,de;q=0.8",
"en-US,en;q=0.9,fr;q=0.8",
"en-US,en;q=0.9,es;q=0.8",
"en-US,en;q=0.9,ja;q=0.8",
"fi-FI,fi;q=0.9,en;q=0.8",
];
languages
.choose(&mut rng)
.expect("Languages list should not be empty")
.to_string()
}
impl BrowserProfile {
pub fn to_headers(&self) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
USER_AGENT,
HeaderValue::from_str(&self.user_agent)
.expect("User agent should be valid header value"),
);
headers.insert(
ACCEPT,
HeaderValue::from_str(&self.accept).expect("Accept should be valid header value"),
);
headers.insert(
ACCEPT_LANGUAGE,
HeaderValue::from_str(&self.accept_language)
.expect("Accept-Language should be valid header value"),
);
headers.insert(
ACCEPT_ENCODING,
HeaderValue::from_str(&self.accept_encoding)
.expect("Accept-Encoding should be valid header value"),
);
if !self.sec_ch_ua.is_empty() {
headers.insert(
"Sec-CH-UA",
HeaderValue::from_str(&self.sec_ch_ua)
.expect("Sec-CH-UA should be valid header value"),
);
headers.insert(
"Sec-CH-UA-Mobile",
HeaderValue::from_str(&self.sec_ch_ua_mobile)
.expect("Sec-CH-UA-Mobile should be valid header value"),
);
headers.insert(
"Sec-CH-UA-Platform",
HeaderValue::from_str(&self.sec_ch_ua_platform)
.expect("Sec-CH-UA-Platform should be valid header value"),
);
}
headers.insert(
"Sec-Fetch-Dest",
HeaderValue::from_str(&self.sec_fetch_dest)
.expect("Sec-Fetch-Dest should be valid header value"),
);
headers.insert(
"Sec-Fetch-Mode",
HeaderValue::from_str(&self.sec_fetch_mode)
.expect("Sec-Fetch-Mode should be valid header value"),
);
headers.insert(
"Sec-Fetch-Site",
HeaderValue::from_str(&self.sec_fetch_site)
.expect("Sec-Fetch-Site should be valid header value"),
);
headers.insert(
"Sec-Fetch-User",
HeaderValue::from_str(&self.sec_fetch_user)
.expect("Sec-Fetch-User should be valid header value"),
);
headers.insert("Upgrade-Insecure-Requests", HeaderValue::from_static("1"));
headers.insert("Cache-Control", HeaderValue::from_static("max-age=0"));
headers
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chrome_profile() {
let profile = chrome_profile();
assert!(profile.user_agent.contains("Chrome"));
assert!(!profile.sec_ch_ua.is_empty());
}
#[test]
fn test_firefox_profile() {
let profile = firefox_profile();
assert!(profile.user_agent.contains("Firefox"));
assert!(profile.sec_ch_ua.is_empty()); }
#[test]
fn test_safari_profile() {
let profile = safari_profile();
assert!(profile.user_agent.contains("Safari"));
assert!(profile.user_agent.contains("Macintosh"));
}
#[test]
fn test_headers_conversion() {
let profile = random_profile();
let headers = profile.to_headers();
assert!(headers.contains_key(USER_AGENT));
assert!(headers.contains_key(ACCEPT));
}
#[test]
fn test_browser_versions_not_empty() {
let versions = &*BROWSER_VERSIONS;
assert!(
!versions.chrome.is_empty(),
"Chrome versions should not be empty"
);
assert!(
!versions.firefox.is_empty(),
"Firefox versions should not be empty"
);
assert!(
!versions.safari.is_empty(),
"Safari versions should not be empty"
);
}
#[test]
fn test_chrome_versions_format() {
let versions = &*BROWSER_VERSIONS;
for (major, full) in &versions.chrome {
assert!(!major.is_empty(), "Major version should not be empty");
assert!(!full.is_empty(), "Full version should not be empty");
assert!(
full.starts_with(major),
"Full version should start with major"
);
}
}
#[test]
fn test_random_profile_deterministic_structure() {
let profile = random_profile();
assert!(!profile.user_agent.is_empty());
assert!(!profile.accept.is_empty());
assert!(!profile.accept_language.is_empty());
assert!(!profile.accept_encoding.is_empty());
}
#[test]
fn test_profile_to_headers_includes_required() {
let profile = chrome_profile();
let headers = profile.to_headers();
assert!(headers.contains_key("user-agent"));
assert!(headers.contains_key("accept"));
assert!(headers.contains_key("accept-language"));
assert!(headers.contains_key("accept-encoding"));
}
#[test]
fn test_firefox_no_sec_ch_ua() {
let profile = firefox_profile();
assert!(profile.sec_ch_ua.is_empty());
assert!(profile.sec_ch_ua_mobile.is_empty());
assert!(profile.sec_ch_ua_platform.is_empty());
}
#[test]
fn test_safari_only_macos() {
let profile = safari_profile();
assert!(profile.user_agent.contains("Macintosh"));
assert!(profile.user_agent.contains("Safari"));
}
#[test]
fn test_platform_os_string_not_empty() {
assert!(!Platform::MacOS.os_string().is_empty());
assert!(!Platform::Windows.os_string().is_empty());
assert!(!Platform::Linux.os_string().is_empty());
}
}