use std::path::PathBuf;
use std::time::Duration;
use serde_json::{Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OsType {
Windows,
MacOS,
Linux,
}
impl OsType {
pub fn as_camoufox(&self) -> &'static str {
match self {
OsType::Windows => "windows",
OsType::MacOS => "macos",
OsType::Linux => "linux",
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct Geolocation {
pub latitude: f64,
pub longitude: f64,
pub accuracy: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct Proxy {
pub server: String,
pub username: Option<String>,
pub password: Option<String>,
pub bypass: Vec<String>,
}
impl Proxy {
pub fn new(server: impl Into<String>) -> Self {
Self {
server: server.into(),
username: None,
password: None,
bypass: Vec::new(),
}
}
pub fn auth(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
self.username = Some(user.into());
self.password = Some(pass.into());
self
}
}
#[derive(Debug, Clone, Default)]
pub struct Fingerprint {
pub user_agent: Option<String>,
pub platform: Option<String>,
pub locale: Option<String>,
pub timezone_id: Option<String>,
pub geolocation: Option<Geolocation>,
pub os: Option<OsType>,
}
impl Fingerprint {
pub fn is_empty(&self) -> bool {
self.user_agent.is_none()
&& self.platform.is_none()
&& self.locale.is_none()
&& self.timezone_id.is_none()
&& self.geolocation.is_none()
&& self.os.is_none()
}
}
#[derive(Debug, Clone)]
pub struct BrowserOptions {
pub binary_path: Option<PathBuf>,
pub user_data_dir: Option<PathBuf>,
pub headless: bool,
pub args: Vec<String>,
pub launch_timeout: Duration,
pub window_size: Option<(u32, u32)>,
pub proxy: Option<Proxy>,
pub fingerprint: Fingerprint,
pub humanize: bool,
pub ignore_https_errors: bool,
pub bypass_csp: bool,
pub humanize_max_time: Option<f64>,
pub block_webrtc: bool,
pub mask_ua: bool,
pub screen: Option<(u32, u32)>,
pub firefox_prefs: Vec<(String, Value)>,
pub camou_config: Vec<(String, Value)>,
pub download_path: Option<PathBuf>,
}
impl Default for BrowserOptions {
fn default() -> Self {
Self {
binary_path: None,
user_data_dir: None,
headless: false,
args: Vec::new(),
launch_timeout: Duration::from_secs(180),
window_size: None,
proxy: None,
fingerprint: Fingerprint::default(),
humanize: true,
ignore_https_errors: false,
bypass_csp: false,
humanize_max_time: None,
block_webrtc: true,
mask_ua: true,
screen: Some((1920, 1080)),
firefox_prefs: Vec::new(),
camou_config: Vec::new(),
download_path: None,
}
}
}
impl BrowserOptions {
pub fn new() -> Self {
Self::default()
}
pub fn headless(mut self, yes: bool) -> Self {
self.headless = yes;
self
}
pub fn binary_path(mut self, p: impl Into<PathBuf>) -> Self {
self.binary_path = Some(p.into());
self
}
pub fn user_data_dir(mut self, p: impl Into<PathBuf>) -> Self {
self.user_data_dir = Some(p.into());
self
}
pub fn add_arg(mut self, arg: impl Into<String>) -> Self {
self.args.push(arg.into());
self
}
pub fn window_size(mut self, width: u32, height: u32) -> Self {
self.window_size = Some((width, height));
self
}
pub fn proxy(mut self, proxy: Proxy) -> Self {
self.proxy = Some(proxy);
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.fingerprint.user_agent = Some(ua.into());
self
}
pub fn locale(mut self, locale: impl Into<String>) -> Self {
self.fingerprint.locale = Some(locale.into());
self
}
pub fn timezone(mut self, tz: impl Into<String>) -> Self {
self.fingerprint.timezone_id = Some(tz.into());
self
}
pub fn platform(mut self, platform: impl Into<String>) -> Self {
self.fingerprint.platform = Some(platform.into());
self
}
pub fn os(mut self, os: OsType) -> Self {
self.fingerprint.os = Some(os);
self
}
pub fn geolocation(mut self, latitude: f64, longitude: f64) -> Self {
self.fingerprint.geolocation = Some(Geolocation {
latitude,
longitude,
accuracy: None,
});
self
}
pub fn humanize(mut self, yes: bool) -> Self {
self.humanize = yes;
self
}
pub fn ignore_https_errors(mut self, yes: bool) -> Self {
self.ignore_https_errors = yes;
self
}
pub fn bypass_csp(mut self, yes: bool) -> Self {
self.bypass_csp = yes;
self
}
pub fn humanize_max_time(mut self, seconds: f64) -> Self {
self.humanize = true;
self.humanize_max_time = Some(seconds);
self
}
pub fn block_webrtc(mut self, yes: bool) -> Self {
self.block_webrtc = yes;
self
}
pub fn add_pref(mut self, name: impl Into<String>, value: Value) -> Self {
self.firefox_prefs.push((name.into(), value));
self
}
pub fn mask_ua(mut self, yes: bool) -> Self {
self.mask_ua = yes;
self
}
pub fn screen(mut self, width: u32, height: u32) -> Self {
self.screen = Some((width, height));
self
}
pub fn raw_screen(mut self) -> Self {
self.screen = None;
self
}
pub fn add_camou_config(mut self, name: impl Into<String>, value: Value) -> Self {
self.camou_config.push((name.into(), value));
self
}
pub fn download_path(mut self, p: impl Into<PathBuf>) -> Self {
self.download_path = Some(p.into());
self
}
pub fn build_camou_config(&self) -> serde_json::Map<String, Value> {
let mut cfg = serde_json::Map::new();
if self.humanize {
cfg.insert("humanize".into(), Value::Bool(true));
if let Some(t) = self.humanize_max_time {
cfg.insert("humanize:maxTime".into(), json!(t));
}
cfg.insert("showcursor".into(), Value::Bool(false));
}
if let Some((w, h)) = self.screen {
let avail_top: u32 = 25; cfg.insert("screen.width".into(), json!(w));
cfg.insert("screen.height".into(), json!(h));
cfg.insert("screen.availWidth".into(), json!(w));
cfg.insert("screen.availHeight".into(), json!(h.saturating_sub(avail_top)));
cfg.insert("screen.availTop".into(), json!(avail_top));
cfg.insert("screen.availLeft".into(), json!(0));
cfg.insert("screen.colorDepth".into(), json!(24));
cfg.insert("screen.pixelDepth".into(), json!(24));
}
for (k, v) in &self.camou_config {
cfg.insert(k.clone(), v.clone());
}
cfg
}
pub fn collect_firefox_prefs(&self) -> Vec<(String, Value)> {
let mut prefs: Vec<(String, Value)> = Vec::new();
if self.block_webrtc {
prefs.push(("media.peerconnection.enabled".to_string(), Value::Bool(false)));
}
if let Some(dir) = &self.download_path {
prefs.push(("browser.download.folderList".into(), json!(2)));
prefs.push(("browser.download.dir".into(), json!(dir.display().to_string())));
prefs.push(("browser.download.useDownloadDir".into(), json!(true)));
prefs.push(("browser.download.manager.showWhenStarting".into(), json!(false)));
prefs.push(("browser.download.alwaysOpenPanel".into(), json!(false)));
prefs.push(("pdfjs.disabled".into(), json!(true)));
prefs.push((
"browser.helperApps.neverAsk.saveToDisk".into(),
json!(
"application/octet-stream,application/pdf,application/zip,application/x-zip-compressed,application/x-msdownload,application/msword,application/vnd.ms-excel,text/csv,text/plain,application/json,image/png,image/jpeg,application/x-binary,application/force-download"
),
));
}
prefs.extend(self.firefox_prefs.iter().cloned());
prefs
}
pub fn validate(&self) -> crate::Result<()> {
for a in &self.args {
let lower = a.trim_start_matches('-').to_ascii_lowercase();
if lower.starts_with("profile") || lower.starts_with("juggler") {
return Err(crate::Error::Other(format!(
"非法启动参数 `{a}`:`-profile`/`-juggler` 由库内部管理"
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builder_chains() {
let opts = BrowserOptions::new()
.headless(true)
.window_size(1280, 800)
.user_agent("UA/1.0")
.locale("zh-CN")
.timezone("Asia/Shanghai")
.os(OsType::MacOS)
.geolocation(31.23, 121.47);
assert!(opts.headless);
assert_eq!(opts.window_size, Some((1280, 800)));
assert_eq!(opts.fingerprint.user_agent.as_deref(), Some("UA/1.0"));
assert_eq!(opts.fingerprint.locale.as_deref(), Some("zh-CN"));
assert_eq!(opts.fingerprint.os, Some(OsType::MacOS));
assert!(!opts.fingerprint.is_empty());
}
#[test]
fn defaults_are_headful_and_stealth() {
let o = BrowserOptions::new();
assert!(!o.headless, "默认有头");
assert!(o.humanize, "默认开启拟人化");
assert!(o.block_webrtc, "默认阻断 WebRTC");
assert!(o.binary_path.is_none(), "默认自动定位浏览器");
assert!(
o.collect_firefox_prefs()
.iter()
.any(|(k, _)| k == "media.peerconnection.enabled")
);
assert!(o.fingerprint.locale.is_none() && o.fingerprint.timezone_id.is_none());
assert!(BrowserOptions::new().headless(true).headless);
}
#[test]
fn rejects_protected_args() {
let opts = BrowserOptions::new().add_arg("-profile /tmp/x");
assert!(opts.validate().is_err());
let opts = BrowserOptions::new().add_arg("--juggler-pipe");
assert!(opts.validate().is_err());
let ok = BrowserOptions::new().add_arg("--no-remote");
assert!(ok.validate().is_ok());
}
}