Skip to main content

drission/browser/
mod.rs

1//! 高层浏览器 API(DrissionPage 风格)。
2//!
3//! - [`Browser`][]:启动 / 退出 / 标签管理。每个标签是一个独立 BrowserContext(cookie 隔离)。
4//! - [`tab::Tab`][]:页面操作(get/run_js/ele/cookies/listen…)。
5//! - [`element::Element`][]:元素操作(click/input/text/attr…)。
6//! - [`listener::DataPacket`][]:网络监听数据包。
7
8pub mod actions;
9pub mod cloudflare;
10pub mod console;
11pub mod download;
12pub mod dump_env;
13pub mod element;
14pub mod frame;
15pub mod handles;
16pub mod interceptor;
17pub mod listener;
18pub mod screencast;
19pub mod serve;
20pub mod shadow;
21#[cfg(feature = "slider")]
22pub mod slider;
23pub mod storage;
24pub mod tab;
25pub mod websocket;
26// static_element 已上移为后端无关的 crate 顶层模块;此处再导出保持 `crate::browser::static_element` 老路径兼容。
27pub use crate::static_element;
28
29use std::path::PathBuf;
30use std::sync::Arc;
31use std::time::Duration;
32
33use serde_json::json;
34use tokio::sync::Mutex;
35
36use crate::launcher::{self, BrowserOptions, Launched};
37use crate::protocol::{BROWSER_CLOSE_MESSAGE_ID, Connection};
38use crate::{Error, Result};
39
40pub use crate::keys::{KeyInput, Keys};
41pub use crate::static_element::StaticElement;
42pub use actions::{Actions, MouseButton};
43pub use console::{Console, ConsoleData, ConsoleFilter, ConsoleSteps};
44pub use download::{DownloadMission, DownloadState, Downloads};
45pub use dump_env::{EnvDump, EnvDumper, EnvProbe, EnvScope, EnvTarget};
46pub use element::{Element, ElementRect, ElementWait};
47pub use frame::Frame;
48pub use handles::{Intercept, Listen, Scroll, SetTab, Wait, Window};
49pub use interceptor::{InterceptedRequest, ResumeOptions};
50pub use listener::{DataPacket, ListenFilter, RequestData, ResponseData};
51pub use screencast::{Screencast, ScreencastMode};
52pub use serve::BrowserServer;
53pub use shadow::ShadowRoot;
54#[cfg(feature = "slider")]
55pub use slider::{GapMethod, ImageSource, SliderConfig, SliderGap, SliderResult, SuccessCheck};
56pub use storage::{OriginStorage, StorageState};
57pub use tab::{
58    ContextOverride, Cookie, CookieParam, DialogInfo, DownloadInfo, GetOptions, ImageFormat,
59    ListenStream, LoadMode, PageRect, ShotOpts, Tab,
60};
61pub use websocket::{WsDirection, WsFilter, WsListener, WsMessage, WsSocket, WsSteps};
62
63/// 一个浏览器实例。
64pub struct Browser {
65    conn: Connection,
66    child: Mutex<Option<crate::transport::Child>>,
67    options: Arc<BrowserOptions>,
68    tabs: Mutex<Vec<Tab>>,
69    profile_dir: PathBuf,
70    profile_is_temp: bool,
71}
72
73impl Browser {
74    /// 用默认配置启动:**有头 + 反检测开箱即用 + 自动定位浏览器**(见 [`BrowserOptions::default`])。
75    ///
76    /// 一行起步:`let browser = Browser::launch_default().await?;`
77    /// 要无头/自定义就用 [`launch`](Self::launch):`Browser::launch(BrowserOptions::new().headless(true)).await?`。
78    pub async fn launch_default() -> Result<Self> {
79        Self::launch(BrowserOptions::default()).await
80    }
81
82    /// 启动浏览器(默认 Camoufox,必要时自动下载),并打开第一个标签。
83    pub async fn launch(opts: BrowserOptions) -> Result<Self> {
84        let mut opts = opts;
85        let Launched {
86            child,
87            writer,
88            reader,
89            profile_dir,
90            profile_is_temp,
91        } = launcher::launch(&opts).await?;
92
93        let conn = Connection::from_pipe(writer, reader);
94        init_session(&conn, &mut opts).await?;
95
96        let browser = Self {
97            conn,
98            child: Mutex::new(Some(child)),
99            options: Arc::new(opts),
100            tabs: Mutex::new(Vec::new()),
101            profile_dir,
102            profile_is_temp,
103        };
104
105        // 打开首个标签。
106        let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
107        browser.tabs.lock().await.push(tab);
108
109        Ok(browser)
110    }
111
112    /// 通过 **WebSocket** 接管一个已在运行的浏览器(对标 DrissionPage 接管已开浏览器)。
113    ///
114    /// 端点须由 [`BrowserServer`] 暴露(讲原始 Juggler;**不**兼容 `camoufox server` 的 Playwright RPC)。
115    /// 采用默认反检测选项;要自定义用 [`connect_with`](Self::connect_with)。
116    ///
117    /// 与 [`launch`](Self::launch) 的区别:不启动子进程;[`quit`](Self::quit) **不会**关闭远端浏览器
118    /// (仅断开本地连接),需要关闭远端请显式调用 [`close_remote`](Self::close_remote)。
119    pub async fn connect(ws_url: &str) -> Result<Self> {
120        Self::connect_with(ws_url, BrowserOptions::default()).await
121    }
122
123    /// 同 [`connect`](Self::connect),但可指定 [`BrowserOptions`](crate::launcher::BrowserOptions)
124    /// (其中启动相关项如 headless/binary_path 会被忽略,只用到会话级覆盖与反检测项)。
125    pub async fn connect_with(ws_url: &str, opts: BrowserOptions) -> Result<Self> {
126        let mut opts = opts;
127        let ws = crate::transport::ws_connect(ws_url).await?;
128        let conn = Connection::from_ws(ws);
129        init_session(&conn, &mut opts).await?;
130
131        let browser = Self {
132            conn,
133            child: Mutex::new(None),
134            options: Arc::new(opts),
135            tabs: Mutex::new(Vec::new()),
136            profile_dir: PathBuf::new(),
137            profile_is_temp: false,
138        };
139
140        let tab = Tab::open(browser.conn.clone(), &browser.options).await?;
141        browser.tabs.lock().await.push(tab);
142
143        Ok(browser)
144    }
145
146    /// 显式关闭**远端**浏览器(用于 [`connect`](Self::connect) 接管后想真正退出浏览器时)。
147    pub async fn close_remote(&self) -> Result<()> {
148        self.conn
149            .fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}))
150    }
151
152    /// 新建一个标签(独立 BrowserContext)。可选直接访问 `url`。
153    pub async fn new_tab(&self, url: Option<&str>) -> Result<Tab> {
154        let tab = Tab::open(self.conn.clone(), &self.options).await?;
155        if let Some(u) = url {
156            tab.get(u).await?;
157        }
158        self.tabs.lock().await.push(tab.clone());
159        Ok(tab)
160    }
161
162    /// 新建一个标签,并叠加 **per-context 覆盖**(代理 / UA / locale / 时区 / 地理 / 视口)。
163    ///
164    /// 用于"同一浏览器进程内、每个标签不同代理或指纹"——这正是并发池([`BrowserPool`](crate::pool::BrowserPool))
165    /// 轮换代理 / 指纹的底层入口。覆盖项叠加在本浏览器启动基线之上(见 [`ContextOverride`])。
166    pub async fn new_tab_with(&self, overrides: &ContextOverride) -> Result<Tab> {
167        let merged = overrides.merge_into((*self.options).clone());
168        let tab = Tab::open(self.conn.clone(), &merged).await?;
169        self.tabs.lock().await.push(tab.clone());
170        Ok(tab)
171    }
172
173    /// 最近打开的标签。
174    pub async fn latest_tab(&self) -> Result<Tab> {
175        self.tabs
176            .lock()
177            .await
178            .last()
179            .cloned()
180            .ok_or_else(|| Error::Other("没有可用标签".into()))
181    }
182
183    /// 按索引取标签(从 0 开始,按打开顺序)。
184    pub async fn get_tab(&self, index: usize) -> Result<Tab> {
185        self.tabs
186            .lock()
187            .await
188            .get(index)
189            .cloned()
190            .ok_or_else(|| Error::Other(format!("标签索引越界: {index}")))
191    }
192
193    /// 当前标签数量。
194    pub async fn tab_count(&self) -> usize {
195        self.tabs.lock().await.len()
196    }
197
198    /// 退出浏览器:优雅关闭 → 超时则强杀 → 清理临时 profile。
199    pub async fn quit(&self) -> Result<()> {
200        if let Some(mut child) = self.child.lock().await.take() {
201            let _ = self
202                .conn
203                .fire(BROWSER_CLOSE_MESSAGE_ID, "Browser.close", json!({}));
204            tokio::select! {
205                _ = child.wait() => {}
206                _ = tokio::time::sleep(Duration::from_secs(3)) => {
207                    let _ = child.kill().await;
208                }
209            }
210        }
211        if self.profile_is_temp {
212            let _ = tokio::fs::remove_dir_all(&self.profile_dir).await;
213        }
214        Ok(())
215    }
216}
217
218/// root 会话初始化(launch / connect 共用):启用 `Browser` 域 + 下发 Firefox user prefs +
219/// (按需)屏蔽 Camoufox UA 令牌。可重复调用(Juggler 端幂等),故接管已运行浏览器时也安全。
220async fn init_session(conn: &Connection, opts: &mut BrowserOptions) -> Result<()> {
221    // 启用浏览器域(开启对新建上下文中页面的自动 attach),并下发
222    // Firefox user prefs(如 block_webrtc → media.peerconnection.enabled=false)。
223    let prefs = opts.collect_firefox_prefs();
224    let mut enable_params = json!({ "attachToDefaultContext": false });
225    if !prefs.is_empty() {
226        let user_prefs: Vec<serde_json::Value> = prefs
227            .into_iter()
228            .map(|(name, value)| json!({ "name": name, "value": value }))
229            .collect();
230        enable_params["userPrefs"] = json!(user_prefs);
231    }
232    conn.send("Browser.enable", enable_params, None).await?;
233
234    // 补环境:把 Camoufox 默认 UA 里的 `Camoufox/<ver>` 令牌伪装成真实 Firefox。
235    // 裸启动(不经 Camoufox Python 库)时 UA 会带 `Camoufox` 字样,是明显的自动化指纹。
236    // 仅当用户未显式设置 UA 时介入;读 `Browser.getInfo` 拿真实 UA(含正确 rv 版本)再替换。
237    if opts.mask_ua && opts.fingerprint.user_agent.is_none() {
238        if let Ok(info) = conn.send("Browser.getInfo", json!({}), None).await {
239            if let Some(ua) = info.get("userAgent").and_then(|v| v.as_str()) {
240                if let Some(cleaned) = clean_camoufox_ua(ua) {
241                    tracing::debug!(to = %cleaned, "补环境:屏蔽 Camoufox UA 令牌");
242                    opts.fingerprint.user_agent = Some(cleaned);
243                }
244            }
245        }
246    }
247    Ok(())
248}
249
250/// 把 Camoufox 默认 UA 里的 `Camoufox/<ver>` 令牌伪装成真实 Firefox(`Firefox/<major>.0`)。
251///
252/// 主版本号优先取自 UA 中的 `rv:<major>.0`(与真实 Firefox 完全一致),取不到再回退用 `Camoufox/`
253/// 后的主版本号。返回 `None` 表示 UA 里没有 `Camoufox` 令牌、无需改动。
254///
255/// 例:`...Gecko/20100101 Camoufox/150.0.2-beta.25` → `...Gecko/20100101 Firefox/150.0`。
256fn clean_camoufox_ua(ua: &str) -> Option<String> {
257    const TOKEN: &str = "Camoufox/";
258    let idx = ua.find(TOKEN)?;
259    let digits = |s: &str| -> Option<String> {
260        let d: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
261        if d.is_empty() { None } else { Some(d) }
262    };
263    let major = ua
264        .find("rv:")
265        .and_then(|i| digits(&ua[i + 3..]))
266        .or_else(|| digits(&ua[idx + TOKEN.len()..]));
267    let prefix = &ua[..idx];
268    Some(match major {
269        Some(m) => format!("{prefix}Firefox/{m}.0"),
270        None => format!("{prefix}Firefox"),
271    })
272}
273
274impl Drop for Browser {
275    /// 兜底清理:即使调用方没有显式 `quit()`(提前 `?` 返回 / panic 展开 / 忘记调用),
276    /// 也确保**子进程被杀、临时 profile 目录被删**,避免进程与磁盘泄漏(反复启动时尤甚)。
277    ///
278    /// 仍建议显式 `quit().await`——它会优雅关闭并 `wait` 回收子进程(无僵尸);此处为同步兜底:
279    /// `start_kill` 发送终止信号(配合 spawn 时设置的 `kill_on_drop`),临时目录同步删除。
280    /// 若 `quit()` 已执行,`child` 已被取走、临时目录已删,这里都成为安全的空操作。
281    fn drop(&mut self) {
282        if let Ok(mut guard) = self.child.try_lock() {
283            if let Some(mut child) = guard.take() {
284                let _ = child.start_kill();
285            }
286        }
287        if self.profile_is_temp {
288            let _ = std::fs::remove_dir_all(&self.profile_dir);
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::clean_camoufox_ua;
296
297    #[test]
298    fn masks_camoufox_token_to_firefox() {
299        let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Camoufox/150.0.2-beta.25";
300        assert_eq!(
301            clean_camoufox_ua(ua).as_deref(),
302            Some(
303                "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:150.0) Gecko/20100101 Firefox/150.0"
304            )
305        );
306    }
307
308    #[test]
309    fn major_falls_back_to_token_when_no_rv() {
310        let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Camoufox/133.1";
311        assert_eq!(
312            clean_camoufox_ua(ua).as_deref(),
313            Some("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Gecko/20100101 Firefox/133.0")
314        );
315    }
316
317    #[test]
318    fn leaves_clean_firefox_ua_untouched() {
319        let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0";
320        assert_eq!(clean_camoufox_ua(ua), None);
321    }
322}