Skip to main content

drission/launcher/
options.rs

1//! 启动选项与浏览器信息(指纹)配置。
2//!
3//! 对应 DrissionPage 的 `ChromiumOptions`,用链式 builder 配置:无头、参数、代理、
4//! User-Agent、语言、时区、窗口大小、地理位置、操作系统指纹等。
5
6use std::path::PathBuf;
7use std::time::Duration;
8
9use serde_json::{Value, json};
10
11/// 目标操作系统指纹(影响 navigator/字体等伪装)。
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum OsType {
14    Windows,
15    MacOS,
16    Linux,
17}
18
19impl OsType {
20    /// Camoufox 期望的字符串值。
21    pub fn as_camoufox(&self) -> &'static str {
22        match self {
23            OsType::Windows => "windows",
24            OsType::MacOS => "macos",
25            OsType::Linux => "linux",
26        }
27    }
28}
29
30/// 地理位置覆盖。
31#[derive(Debug, Clone, Copy)]
32pub struct Geolocation {
33    pub latitude: f64,
34    pub longitude: f64,
35    pub accuracy: Option<f64>,
36}
37
38/// 代理配置。`server` 形如 `http://127.0.0.1:8080` 或 `socks5://host:1080`。
39#[derive(Debug, Clone)]
40pub struct Proxy {
41    pub server: String,
42    pub username: Option<String>,
43    pub password: Option<String>,
44    pub bypass: Vec<String>,
45}
46
47impl Proxy {
48    /// 新建代理配置。`server` 形如 `http://127.0.0.1:8080` 或 `socks5://host:1080`。
49    pub fn new(server: impl Into<String>) -> Self {
50        Self {
51            server: server.into(),
52            username: None,
53            password: None,
54            bypass: Vec::new(),
55        }
56    }
57
58    /// 设置代理认证的用户名与密码。
59    pub fn auth(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
60        self.username = Some(user.into());
61        self.password = Some(pass.into());
62        self
63    }
64}
65
66/// 浏览器信息 / 指纹覆盖。对应“修改浏览器信息”的需求。
67#[derive(Debug, Clone, Default)]
68pub struct Fingerprint {
69    pub user_agent: Option<String>,
70    pub platform: Option<String>,
71    pub locale: Option<String>,
72    pub timezone_id: Option<String>,
73    pub geolocation: Option<Geolocation>,
74    pub os: Option<OsType>,
75}
76
77impl Fingerprint {
78    /// 是否为空(没有任何覆盖项)。
79    pub fn is_empty(&self) -> bool {
80        self.user_agent.is_none()
81            && self.platform.is_none()
82            && self.locale.is_none()
83            && self.timezone_id.is_none()
84            && self.geolocation.is_none()
85            && self.os.is_none()
86    }
87}
88
89/// 启动选项。通过链式调用配置后传给浏览器启动器。
90#[derive(Debug, Clone)]
91pub struct BrowserOptions {
92    /// 显式指定 Camoufox 可执行文件路径;为空则走自动下载分发。
93    pub binary_path: Option<PathBuf>,
94    /// 用户数据目录(profile);为空则使用临时目录。
95    pub user_data_dir: Option<PathBuf>,
96    /// 是否无头。
97    pub headless: bool,
98    /// 额外命令行参数(禁止以 `-profile`/`-juggler` 开头)。
99    pub args: Vec<String>,
100    /// 启动超时(等待 “Juggler listening to the pipe”)。
101    pub launch_timeout: Duration,
102    /// 默认窗口/视口大小。
103    pub window_size: Option<(u32, u32)>,
104    /// 代理。
105    pub proxy: Option<Proxy>,
106    /// 浏览器信息 / 指纹。
107    pub fingerprint: Fingerprint,
108    /// 是否启用拟人化行为(反检测)。
109    pub humanize: bool,
110    /// 忽略 HTTPS 证书错误。
111    pub ignore_https_errors: bool,
112    /// 绕过 CSP(便于注入脚本)。
113    pub bypass_csp: bool,
114    /// 拟人化光标移动的最大时长(秒);设置即开启 `humanize`。
115    pub humanize_max_time: Option<f64>,
116    /// 阻断 WebRTC(防止经 STUN 暴露真实 IP)。
117    pub block_webrtc: bool,
118    /// 把 Camoufox 默认 UA 里的 `Camoufox/<ver>` 令牌伪装成真实 Firefox(`Firefox/<major>.0`)。
119    /// 裸启动 Camoufox(不经其 Python 库)时,UA 默认带 `Camoufox` 字样,是明显的自动化指纹;
120    /// 开启后启动时读 `Browser.getInfo` 的真实 UA、把令牌换成 `Firefox` 再经上下文覆盖下发。
121    /// 仅当用户未显式 [`user_agent`](Self::user_agent) 时生效。默认 `true`。
122    pub mask_ua: bool,
123    /// 屏幕尺寸覆盖 `(width, height)`(CSS 像素)。裸启动 Camoufox 不跑 BrowserForge 自动补全,
124    /// 其默认屏幕会与窗口不自洽(实测 `window.outer` 比 `screen` 还高),是破绽。给一个常见且
125    /// 自洽的屏幕即可消除。默认 `Some((1920, 1080))`;`None` 表示用 Camoufox 原始屏幕。
126    pub screen: Option<(u32, u32)>,
127    /// 额外的 Firefox user preferences(逃生舱,直接下发到浏览器)。
128    pub firefox_prefs: Vec<(String, Value)>,
129    /// 额外的 Camoufox 指纹配置(`CAMOU_CONFIG_*` 透传,逃生舱):可设任意官方支持的字段,
130    /// 如 `("navigator.hardwareConcurrency", json!(8))`、`("webGl:vendor", json!("Google Inc."))`。
131    /// 会覆盖由便捷项(如 `screen`)生成的同名键。键名见 <https://camoufox.com/fingerprint/>。
132    pub camou_config: Vec<(String, Value)>,
133    /// 下载目录。设置后:文件自动存到此目录(不弹"另存为"框),并可用 `tab.wait_download()`
134    /// 等下载完成。为空则用浏览器默认行为。
135    pub download_path: Option<PathBuf>,
136}
137
138impl Default for BrowserOptions {
139    /// 大道至简的默认值:**有头 + 反检测开箱即用**。
140    ///
141    /// - `headless = false`(默认有头;无头加 `.headless(true)` 即可)
142    /// - `humanize = true`、`block_webrtc = true`(反检测默认开,等价于以前要手写的那串)
143    /// - `binary_path = None`(自动下载 / 定位 Camoufox 到默认缓存位置)
144    ///
145    /// 不默认 locale/timezone:强行设成与本机 IP 不符的地区反而**降低**反检测可信度,
146    /// 需要时自己 `.locale(..)/.timezone(..)`(见 `examples/cf_check`)。
147    fn default() -> Self {
148        Self {
149            binary_path: None,
150            user_data_dir: None,
151            headless: false,
152            args: Vec::new(),
153            launch_timeout: Duration::from_secs(180),
154            window_size: None,
155            proxy: None,
156            fingerprint: Fingerprint::default(),
157            humanize: true,
158            ignore_https_errors: false,
159            bypass_csp: false,
160            humanize_max_time: None,
161            block_webrtc: true,
162            mask_ua: true,
163            screen: Some((1920, 1080)),
164            firefox_prefs: Vec::new(),
165            camou_config: Vec::new(),
166            download_path: None,
167        }
168    }
169}
170
171impl BrowserOptions {
172    /// 新建默认选项(有头 + 反检测开箱即用,详见 [`Default`](BrowserOptions::default) 实现)。
173    pub fn new() -> Self {
174        Self::default()
175    }
176
177    /// 是否无头运行。
178    pub fn headless(mut self, yes: bool) -> Self {
179        self.headless = yes;
180        self
181    }
182
183    /// 显式指定浏览器可执行文件路径;为空则走自动下载/定位。
184    pub fn binary_path(mut self, p: impl Into<PathBuf>) -> Self {
185        self.binary_path = Some(p.into());
186        self
187    }
188
189    /// 用户数据目录(profile);为空则使用临时目录。
190    pub fn user_data_dir(mut self, p: impl Into<PathBuf>) -> Self {
191        self.user_data_dir = Some(p.into());
192        self
193    }
194
195    /// 追加一个命令行参数。
196    pub fn add_arg(mut self, arg: impl Into<String>) -> Self {
197        self.args.push(arg.into());
198        self
199    }
200
201    /// 默认窗口/视口大小(宽、高,像素)。
202    pub fn window_size(mut self, width: u32, height: u32) -> Self {
203        self.window_size = Some((width, height));
204        self
205    }
206
207    /// 设置代理。
208    pub fn proxy(mut self, proxy: Proxy) -> Self {
209        self.proxy = Some(proxy);
210        self
211    }
212
213    /// 覆盖 User-Agent。设置后 [`mask_ua`](BrowserOptions) 不再生效。
214    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
215        self.fingerprint.user_agent = Some(ua.into());
216        self
217    }
218
219    /// 覆盖 locale(如 `zh-CN`)。建议与出口 IP 地区一致,否则反而降低可信度。
220    pub fn locale(mut self, locale: impl Into<String>) -> Self {
221        self.fingerprint.locale = Some(locale.into());
222        self
223    }
224
225    /// 覆盖时区(IANA 名,如 `Asia/Shanghai`)。建议与出口 IP 地区一致。
226    pub fn timezone(mut self, tz: impl Into<String>) -> Self {
227        self.fingerprint.timezone_id = Some(tz.into());
228        self
229    }
230
231    /// 覆盖 `navigator.platform`。
232    pub fn platform(mut self, platform: impl Into<String>) -> Self {
233        self.fingerprint.platform = Some(platform.into());
234        self
235    }
236
237    /// 覆盖操作系统类型(影响 UA / 平台等指纹的一致性生成)。
238    pub fn os(mut self, os: OsType) -> Self {
239        self.fingerprint.os = Some(os);
240        self
241    }
242
243    /// 设置地理位置(纬度、经度)。
244    pub fn geolocation(mut self, latitude: f64, longitude: f64) -> Self {
245        self.fingerprint.geolocation = Some(Geolocation {
246            latitude,
247            longitude,
248            accuracy: None,
249        });
250        self
251    }
252
253    /// 是否启用拟人化行为(反检测)。
254    pub fn humanize(mut self, yes: bool) -> Self {
255        self.humanize = yes;
256        self
257    }
258
259    /// 是否忽略 HTTPS 证书错误。
260    pub fn ignore_https_errors(mut self, yes: bool) -> Self {
261        self.ignore_https_errors = yes;
262        self
263    }
264
265    /// 是否绕过 CSP(便于注入脚本)。
266    pub fn bypass_csp(mut self, yes: bool) -> Self {
267        self.bypass_csp = yes;
268        self
269    }
270
271    /// 开启拟人化光标移动,并指定最大移动时长(秒)。
272    pub fn humanize_max_time(mut self, seconds: f64) -> Self {
273        self.humanize = true;
274        self.humanize_max_time = Some(seconds);
275        self
276    }
277
278    /// 阻断 WebRTC(防止真实 IP 泄漏)。
279    pub fn block_webrtc(mut self, yes: bool) -> Self {
280        self.block_webrtc = yes;
281        self
282    }
283
284    /// 追加一个 Firefox user preference(高级用法)。
285    pub fn add_pref(mut self, name: impl Into<String>, value: Value) -> Self {
286        self.firefox_prefs.push((name.into(), value));
287        self
288    }
289
290    /// 是否把 Camoufox 的 UA 令牌伪装成真实 Firefox(默认 `true`,见 [`mask_ua`](Self::mask_ua) 字段)。
291    pub fn mask_ua(mut self, yes: bool) -> Self {
292        self.mask_ua = yes;
293        self
294    }
295
296    /// 覆盖屏幕尺寸 `(width, height)`(CSS 像素),保证与窗口自洽(见 [`screen`](Self::screen) 字段)。
297    pub fn screen(mut self, width: u32, height: u32) -> Self {
298        self.screen = Some((width, height));
299        self
300    }
301
302    /// 不覆盖屏幕,使用 Camoufox 原始屏幕值(慎用:裸启动下默认屏幕可能与窗口不自洽)。
303    pub fn raw_screen(mut self) -> Self {
304        self.screen = None;
305        self
306    }
307
308    /// 追加一个 Camoufox 指纹配置字段(`CAMOU_CONFIG_*` 透传,见 [`camou_config`](Self::camou_config) 字段)。
309    pub fn add_camou_config(mut self, name: impl Into<String>, value: Value) -> Self {
310        self.camou_config.push((name.into(), value));
311        self
312    }
313
314    /// 设置下载目录:文件自动存到此目录(不弹"另存为"框),配合 `tab.wait_download()` 等下载完成。
315    pub fn download_path(mut self, p: impl Into<PathBuf>) -> Self {
316        self.download_path = Some(p.into());
317        self
318    }
319
320    /// 汇总要下发给 Camoufox 的指纹配置(`CAMOU_CONFIG_*` 的 JSON 内容):拟人化光标 + 屏幕一致性
321    /// + 自定义透传。launcher 会把它序列化后**按字符分块**写入 `CAMOU_CONFIG_1..n`(浏览器侧拼接再解析)。
322    ///
323    /// 注意:UA 不走这里(走 `Browser.getInfo` + 上下文覆盖,见 [`mask_ua`](Self::mask_ua));此处只放
324    /// 必须在进程启动前就位的、由 Camoufox C++ 层拦截的指纹字段。
325    pub fn build_camou_config(&self) -> serde_json::Map<String, Value> {
326        let mut cfg = serde_json::Map::new();
327        // 拟人化光标(浏览器侧 MaskConfig 读 humanize / humanize:maxTime / showcursor)。
328        if self.humanize {
329            cfg.insert("humanize".into(), Value::Bool(true));
330            if let Some(t) = self.humanize_max_time {
331                cfg.insert("humanize:maxTime".into(), json!(t));
332            }
333            // 光标高亮只是视觉辅助、不进入页面上下文;关掉省渲染。
334            cfg.insert("showcursor".into(), Value::Bool(false));
335        }
336        // 屏幕一致性:给一个常见且与窗口自洽的屏幕(window.outer 需 <= screen.avail)。
337        if let Some((w, h)) = self.screen {
338            let avail_top: u32 = 25; // 顶部菜单栏高度(mac 风格);availHeight 据此收一点。
339            cfg.insert("screen.width".into(), json!(w));
340            cfg.insert("screen.height".into(), json!(h));
341            cfg.insert("screen.availWidth".into(), json!(w));
342            cfg.insert(
343                "screen.availHeight".into(),
344                json!(h.saturating_sub(avail_top)),
345            );
346            cfg.insert("screen.availTop".into(), json!(avail_top));
347            cfg.insert("screen.availLeft".into(), json!(0));
348            cfg.insert("screen.colorDepth".into(), json!(24));
349            cfg.insert("screen.pixelDepth".into(), json!(24));
350        }
351        // 自定义透传(覆盖以上便捷项的同名键)。
352        for (k, v) in &self.camou_config {
353            cfg.insert(k.clone(), v.clone());
354        }
355        cfg
356    }
357
358    /// 汇总要下发的 Firefox user preferences(便捷项 + 自定义),供 `Browser.enable` 使用。
359    pub fn collect_firefox_prefs(&self) -> Vec<(String, Value)> {
360        let mut prefs: Vec<(String, Value)> = Vec::new();
361        if self.block_webrtc {
362            prefs.push((
363                "media.peerconnection.enabled".to_string(),
364                Value::Bool(false),
365            ));
366        }
367        // 下载:存到指定目录、不弹框、PDF 直接下载而非内嵌查看。
368        if let Some(dir) = &self.download_path {
369            prefs.push(("browser.download.folderList".into(), json!(2)));
370            prefs.push((
371                "browser.download.dir".into(),
372                json!(dir.display().to_string()),
373            ));
374            prefs.push(("browser.download.useDownloadDir".into(), json!(true)));
375            prefs.push((
376                "browser.download.manager.showWhenStarting".into(),
377                json!(false),
378            ));
379            prefs.push(("browser.download.alwaysOpenPanel".into(), json!(false)));
380            prefs.push(("pdfjs.disabled".into(), json!(true)));
381            prefs.push((
382                "browser.helperApps.neverAsk.saveToDisk".into(),
383                json!(
384                    "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"
385                ),
386            ));
387        }
388        prefs.extend(self.firefox_prefs.iter().cloned());
389        prefs
390    }
391
392    /// 校验用户提供的参数是否合法(不得覆盖受保护的启动参数)。
393    pub fn validate(&self) -> crate::Result<()> {
394        for a in &self.args {
395            let lower = a.trim_start_matches('-').to_ascii_lowercase();
396            if lower.starts_with("profile") || lower.starts_with("juggler") {
397                return Err(crate::Error::Other(format!(
398                    "非法启动参数 `{a}`:`-profile`/`-juggler` 由库内部管理"
399                )));
400            }
401        }
402        Ok(())
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409
410    #[test]
411    fn builder_chains() {
412        let opts = BrowserOptions::new()
413            .headless(true)
414            .window_size(1280, 800)
415            .user_agent("UA/1.0")
416            .locale("zh-CN")
417            .timezone("Asia/Shanghai")
418            .os(OsType::MacOS)
419            .geolocation(31.23, 121.47);
420        assert!(opts.headless);
421        assert_eq!(opts.window_size, Some((1280, 800)));
422        assert_eq!(opts.fingerprint.user_agent.as_deref(), Some("UA/1.0"));
423        assert_eq!(opts.fingerprint.locale.as_deref(), Some("zh-CN"));
424        assert_eq!(opts.fingerprint.os, Some(OsType::MacOS));
425        assert!(!opts.fingerprint.is_empty());
426    }
427
428    #[test]
429    fn defaults_are_headful_and_stealth() {
430        let o = BrowserOptions::new();
431        assert!(!o.headless, "默认有头");
432        assert!(o.humanize, "默认开启拟人化");
433        assert!(o.block_webrtc, "默认阻断 WebRTC");
434        assert!(o.binary_path.is_none(), "默认自动定位浏览器");
435        // 反检测默认应下发 WebRTC 关闭的 user pref。
436        assert!(
437            o.collect_firefox_prefs()
438                .iter()
439                .any(|(k, _)| k == "media.peerconnection.enabled")
440        );
441        // 不默认地区,避免与 IP 不符。
442        assert!(o.fingerprint.locale.is_none() && o.fingerprint.timezone_id.is_none());
443        // 一行关无头。
444        assert!(BrowserOptions::new().headless(true).headless);
445    }
446
447    #[test]
448    fn rejects_protected_args() {
449        let opts = BrowserOptions::new().add_arg("-profile /tmp/x");
450        assert!(opts.validate().is_err());
451        let opts = BrowserOptions::new().add_arg("--juggler-pipe");
452        assert!(opts.validate().is_err());
453        let ok = BrowserOptions::new().add_arg("--no-remote");
454        assert!(ok.validate().is_ok());
455    }
456}