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