Skip to main content

drission/browser/
handles.rs

1//! DrissionPage 风格的"句柄对象":把一组相关操作收敛到 `tab.wait.*` / `tab.scroll.*` / `tab.set.*`。
2//!
3//! 这些句柄都只持有一个轻量 [`Tab`] 克隆(共享内核),即用即弃:
4//!
5//! ```ignore
6//! tab.wait().doc_loaded(None).await?;                 // 等文档加载完成
7//! tab.wait().ele_displayed("#login", None).await?;    // 等元素出现且可见
8//! tab.scroll().to_bottom().await?;                    // 滚到底
9//! tab.set().timeout(Duration::from_secs(10));         // 改默认超时
10//! tab.set().load_mode(LoadMode::Eager);               // 改默认加载模式
11//! ```
12
13use std::time::Duration;
14
15use serde_json::json;
16use tokio::time::Instant;
17
18use crate::browser::interceptor::InterceptedRequest;
19use crate::browser::listener::DataPacket;
20use crate::browser::tab::{CookieParam, ListenStream, LoadMode, Tab};
21use crate::{Error, Result};
22
23/// `tab.wait()` 返回的等待句柄(对应 DP `tab.wait`)。
24pub struct Wait {
25    tab: Tab,
26}
27
28impl Wait {
29    pub(crate) fn new(tab: Tab) -> Self {
30        Self { tab }
31    }
32
33    fn timeout_or_default(&self, t: Option<Duration>) -> Duration {
34        t.unwrap_or_else(|| self.tab.core.timeout())
35    }
36
37    /// 单纯睡眠 `secs` 秒(对应 DP `tab.wait(2)`)。
38    pub async fn secs(&self, secs: f64) {
39        tokio::time::sleep(Duration::from_secs_f64(secs.max(0.0))).await;
40    }
41
42    /// 等待文档加载完成(`readyState === 'complete'`)。超时返回 `false`。
43    pub async fn doc_loaded(&self, timeout: Option<Duration>) -> Result<bool> {
44        let d = self.timeout_or_default(timeout);
45        self.tab.core.poll_ready_complete(d).await
46    }
47
48    /// 等待元素出现于 DOM(不要求可见)。超时返回 `false`。
49    pub async fn ele_loaded(&self, selector: &str, timeout: Option<Duration>) -> Result<bool> {
50        let deadline = Instant::now() + self.timeout_or_default(timeout);
51        loop {
52            if self.tab.find_once(selector).await?.is_some() {
53                return Ok(true);
54            }
55            if Instant::now() >= deadline {
56                return Ok(false);
57            }
58            tokio::time::sleep(Duration::from_millis(100)).await;
59        }
60    }
61
62    /// 等待元素出现**且可见**。超时返回 `false`。
63    pub async fn ele_displayed(&self, selector: &str, timeout: Option<Duration>) -> Result<bool> {
64        let deadline = Instant::now() + self.timeout_or_default(timeout);
65        loop {
66            if let Some(el) = self.tab.find_once(selector).await?
67                && el.is_displayed().await.unwrap_or(false)
68            {
69                return Ok(true);
70            }
71            if Instant::now() >= deadline {
72                return Ok(false);
73            }
74            tokio::time::sleep(Duration::from_millis(100)).await;
75        }
76    }
77
78    /// 等待元素从 DOM 中消失。超时返回 `false`。
79    pub async fn ele_deleted(&self, selector: &str, timeout: Option<Duration>) -> Result<bool> {
80        let deadline = Instant::now() + self.timeout_or_default(timeout);
81        loop {
82            if self.tab.find_once(selector).await?.is_none() {
83                return Ok(true);
84            }
85            if Instant::now() >= deadline {
86                return Ok(false);
87            }
88            tokio::time::sleep(Duration::from_millis(100)).await;
89        }
90    }
91
92    /// 等待"自然上传"的文件路径被填入(对应 DP `tab.wait.upload_paths_inputted`)。
93    ///
94    /// 先 [`tab.set().upload_files(..)`](SetTab::upload_files)、再点击触发文件框的按钮,最后用本方法
95    /// 等待填入完成。`timeout=None` 用默认超时;超时返回 `false`(不报错)。
96    pub async fn upload_paths_inputted(&self, timeout: Option<Duration>) -> Result<bool> {
97        self.tab.wait_upload_paths_inputted(timeout).await
98    }
99
100    /// 等待标题包含子串(与 cdp 端 `wait().title_contains` 对齐)。超时返回 `false`。
101    pub async fn title_contains(&self, sub: &str, timeout: Option<Duration>) -> Result<bool> {
102        let deadline = Instant::now() + self.timeout_or_default(timeout);
103        loop {
104            if self.tab.title().await.unwrap_or_default().contains(sub) {
105                return Ok(true);
106            }
107            if Instant::now() >= deadline {
108                return Ok(false);
109            }
110            tokio::time::sleep(Duration::from_millis(100)).await;
111        }
112    }
113
114    /// 等待 URL 包含子串(与 cdp 端 `wait().url_contains` 对齐)。超时返回 `false`。
115    pub async fn url_contains(&self, sub: &str, timeout: Option<Duration>) -> Result<bool> {
116        let deadline = Instant::now() + self.timeout_or_default(timeout);
117        loop {
118            if self.tab.url().await.unwrap_or_default().contains(sub) {
119                return Ok(true);
120            }
121            if Instant::now() >= deadline {
122                return Ok(false);
123            }
124            tokio::time::sleep(Duration::from_millis(100)).await;
125        }
126    }
127
128    /// 等待本标签弹出的**新标签 / 弹窗**(`window.open` / `target=_blank` / `Ctrl+点击`),返回可驱动的
129    /// 新 [`Tab`]。超时返回 `None`(对标 Playwright `expect_popup` / DP `tab.wait.new_tab`,与 cdp 端
130    /// `wait().new_tab` 对齐)。
131    ///
132    /// **用法**:先调用本方法(或与触发动作 `tokio::join!` 并发),**再**触发弹窗——内部在等待前即订阅
133    /// 事件,触发太早会漏掉。只认与打开者**同一 BrowserContext** 新出现的 page(弹窗与打开者同上下文),
134    /// 故不会误抓其它标签。
135    pub async fn new_tab(&self, timeout: Option<Duration>) -> Result<Option<Tab>> {
136        let conn = self.tab.core.conn.clone();
137        let opener = self.tab.core.target_id.clone();
138        let ctx = self.tab.core.browser_context_id.clone();
139        let dl = self.tab.core.download_path.clone();
140        let dur = self.timeout_or_default(timeout);
141        let deadline = Instant::now() + dur;
142        let mut events = conn.subscribe();
143        loop {
144            let remain = deadline.saturating_duration_since(Instant::now());
145            if remain.is_zero() {
146                return Ok(None);
147            }
148            let ev = match tokio::time::timeout(remain, events.recv()).await {
149                Ok(Ok(ev)) => ev,
150                Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue,
151                Ok(Err(tokio::sync::broadcast::error::RecvError::Closed)) => return Ok(None),
152                Err(_) => return Ok(None),
153            };
154            if ev.method != "Browser.attachedToTarget" {
155                continue;
156            }
157            let ti = &ev.params["targetInfo"];
158            if ti["type"].as_str() != Some("page") {
159                continue;
160            }
161            let target_id = ti["targetId"].as_str().unwrap_or_default().to_string();
162            if target_id.is_empty() || target_id == opener {
163                continue;
164            }
165            // 只认与打开者同 BrowserContext 的新 page(本标签的弹窗);别的标签是别的 context。
166            if ti["browserContextId"].as_str() != Some(ctx.as_str()) {
167                continue;
168            }
169            let Some(session_id) = ev.params["sessionId"].as_str().map(str::to_string) else {
170                continue;
171            };
172            let tab = Tab::from_attached(conn, target_id, ctx, session_id, events, dl, dur).await?;
173            return Ok(Some(tab));
174        }
175    }
176}
177
178/// `tab.scroll()` 返回的滚动句柄(对应 DP `tab.scroll`)。
179pub struct Scroll {
180    tab: Tab,
181}
182
183impl Scroll {
184    pub(crate) fn new(tab: Tab) -> Self {
185        Self { tab }
186    }
187
188    /// 滚到顶部。
189    pub async fn to_top(&self) -> Result<()> {
190        self.run("window.scrollTo(window.scrollX, 0)").await
191    }
192
193    /// 滚到底部。
194    pub async fn to_bottom(&self) -> Result<()> {
195        self.run(
196            "window.scrollTo(window.scrollX, \
197             Math.max(document.documentElement.scrollHeight, \
198             document.body ? document.body.scrollHeight : 0))",
199        )
200        .await
201    }
202
203    /// 滚到最左。
204    pub async fn to_left(&self) -> Result<()> {
205        self.run("window.scrollTo(0, window.scrollY)").await
206    }
207
208    /// 滚到最右。
209    pub async fn to_right(&self) -> Result<()> {
210        self.run(
211            "window.scrollTo(Math.max(document.documentElement.scrollWidth, \
212             document.body ? document.body.scrollWidth : 0), window.scrollY)",
213        )
214        .await
215    }
216
217    /// 垂直滚到中部。
218    pub async fn to_half(&self) -> Result<()> {
219        self.run(
220            "window.scrollTo(window.scrollX, \
221             Math.max(document.documentElement.scrollHeight, \
222             document.body ? document.body.scrollHeight : 0) / 2)",
223        )
224        .await
225    }
226
227    /// 滚到绝对位置 `(x, y)`。
228    pub async fn to_location(&self, x: f64, y: f64) -> Result<()> {
229        self.run(&format!("window.scrollTo({x}, {y})")).await
230    }
231
232    /// 向上滚 `pixel` 像素。
233    pub async fn up(&self, pixel: f64) -> Result<()> {
234        self.tab.scroll_by(0.0, -pixel).await
235    }
236
237    /// 向下滚 `pixel` 像素。
238    pub async fn down(&self, pixel: f64) -> Result<()> {
239        self.tab.scroll_by(0.0, pixel).await
240    }
241
242    /// 向左滚 `pixel` 像素。
243    pub async fn left(&self, pixel: f64) -> Result<()> {
244        self.tab.scroll_by(-pixel, 0.0).await
245    }
246
247    /// 向右滚 `pixel` 像素。
248    pub async fn right(&self, pixel: f64) -> Result<()> {
249        self.tab.scroll_by(pixel, 0.0).await
250    }
251
252    async fn run(&self, js: &str) -> Result<()> {
253        self.tab.run_js(js).await?;
254        Ok(())
255    }
256}
257
258/// `tab.set()` 返回的设置句柄(对应 DP `tab.set`)。
259pub struct SetTab {
260    tab: Tab,
261}
262
263impl SetTab {
264    pub(crate) fn new(tab: Tab) -> Self {
265        Self { tab }
266    }
267
268    /// 为本标签(其 BrowserContext)设置 cookie。
269    pub async fn cookies(&self, cookies: Vec<CookieParam>) -> Result<()> {
270        self.tab.set_cookies(cookies).await
271    }
272
273    /// 运行时修改本标签的默认操作超时(影响 `ele`/`get`/`listen_wait` 等)。
274    pub fn timeout(&self, d: Duration) {
275        self.tab.core.set_timeout(d);
276    }
277
278    /// 运行时修改本标签的默认加载模式(影响后续 `get`)。
279    pub fn load_mode(&self, m: LoadMode) {
280        self.tab.core.set_load_mode(m);
281    }
282
283    /// 覆盖本标签的 User-Agent(对后续导航生效)。
284    pub async fn user_agent(&self, ua: &str) -> Result<()> {
285        self.tab
286            .core
287            .conn
288            .send(
289                "Browser.setUserAgentOverride",
290                json!({
291                    "browserContextId": self.tab.core.browser_context_id,
292                    "userAgent": ua,
293                }),
294                None,
295            )
296            .await?;
297        Ok(())
298    }
299
300    /// 设置"自然上传"要用的文件并武装文件选择器拦截(对应 DP `tab.set.upload_files`)。
301    ///
302    /// 之后点击会弹出文件选择框的按钮 → 用 [`Wait::upload_paths_inputted`] 等待填入完成。
303    /// 详见 [`Tab::set_upload_files`](crate::browser::Tab::set_upload_files)。
304    pub async fn upload_files(&self, paths: &[&str]) -> Result<()> {
305        self.tab.set_upload_files(paths).await
306    }
307
308    /// 写 `localStorage`(便捷,经 JS;与 cdp 端 `set().local_storage_set` 对齐)。
309    pub async fn local_storage_set(&self, key: &str, value: &str) -> Result<()> {
310        self.storage_set("localStorage", key, value).await
311    }
312    /// 读 `localStorage`(不存在返回 `None`)。
313    pub async fn local_storage_get(&self, key: &str) -> Result<Option<String>> {
314        self.storage_get("localStorage", key).await
315    }
316    /// 删 `localStorage` 项。
317    pub async fn local_storage_remove(&self, key: &str) -> Result<()> {
318        self.storage_remove("localStorage", key).await
319    }
320    /// 清空 `localStorage`。
321    pub async fn local_storage_clear(&self) -> Result<()> {
322        self.tab.run_js("localStorage.clear()").await?;
323        Ok(())
324    }
325    /// 写 `sessionStorage`。
326    pub async fn session_storage_set(&self, key: &str, value: &str) -> Result<()> {
327        self.storage_set("sessionStorage", key, value).await
328    }
329    /// 读 `sessionStorage`。
330    pub async fn session_storage_get(&self, key: &str) -> Result<Option<String>> {
331        self.storage_get("sessionStorage", key).await
332    }
333    /// 删 `sessionStorage` 项。
334    pub async fn session_storage_remove(&self, key: &str) -> Result<()> {
335        self.storage_remove("sessionStorage", key).await
336    }
337    /// 清空 `sessionStorage`。
338    pub async fn session_storage_clear(&self) -> Result<()> {
339        self.tab.run_js("sessionStorage.clear()").await?;
340        Ok(())
341    }
342
343    async fn storage_set(&self, store: &str, key: &str, value: &str) -> Result<()> {
344        let js = format!("{store}.setItem({}, {})", js_str(key), js_str(value));
345        self.tab.run_js(&js).await?;
346        Ok(())
347    }
348    async fn storage_get(&self, store: &str, key: &str) -> Result<Option<String>> {
349        let js = format!("{store}.getItem({})", js_str(key));
350        Ok(self.tab.run_js(&js).await?.as_str().map(str::to_string))
351    }
352    async fn storage_remove(&self, store: &str, key: &str) -> Result<()> {
353        let js = format!("{store}.removeItem({})", js_str(key));
354        self.tab.run_js(&js).await?;
355        Ok(())
356    }
357
358    /// 窗口句柄(对应 DP `tab.set.window`)。
359    ///
360    /// **注意平台限制**:Camoufox(Firefox/Juggler)**不支持**最小化 / 全屏 / 移动主窗口
361    /// (Firefox 自 7 起禁止脚本移动/缩放主窗口,Juggler 也无 `setWindowBounds`),故只提供
362    /// **可靠生效**的尺寸控制,见 [`Window`]。
363    pub fn window(&self) -> Window {
364        Window::new(self.tab.clone())
365    }
366}
367
368/// JS 字符串字面量(用 `serde_json` 转义,安全嵌入表达式)。
369fn js_str(s: &str) -> String {
370    serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
371}
372
373/// `tab.set().window()` 返回的窗口句柄(对应 DP `tab.set.window`)。
374///
375/// **平台限制(重要)**:Camoufox 基于 Firefox、自动化走 Juggler 协议——Firefox 自 7 起**禁止脚本
376/// 移动 / 缩放主窗口**(`dom.disable_window_move_resize` 也只对 `window.open` 弹窗有效),Juggler 又
377/// **没有** `setWindowBounds` 这类 OS 窗口方法。因此 **最小化 / 全屏切换 / 移动主窗口在本内核上做不到**
378/// (属平台限制,非库缺陷)。本句柄只提供**可靠生效**的尺寸控制:`Page.setViewportSize`——**有头**会
379/// 连带把窗口缩放到该尺寸、**无头**仅设内容视口。需要固定**初始** OS 窗口大小请在启动时用
380/// [`BrowserOptions::window_size`](crate::launcher::BrowserOptions::window_size)。
381pub struct Window {
382    tab: Tab,
383}
384
385impl Window {
386    pub(crate) fn new(tab: Tab) -> Self {
387        Self { tab }
388    }
389
390    /// 设置视口/窗口大小(有头:窗口随之缩放;无头:设内容视口)。对应 DP `tab.set.window.size`。
391    pub async fn size(&self, width: u32, height: u32) -> Result<()> {
392        self.tab.core.set_viewport_size(width, height).await
393    }
394
395    /// `size` 的别名(语义上即"设置内容视口")。
396    pub async fn viewport(&self, width: u32, height: u32) -> Result<()> {
397        self.tab.core.set_viewport_size(width, height).await
398    }
399
400    /// 近似最大化:读 `screen.availWidth/availHeight` 后铺满可用屏幕(对应 DP `tab.set.window.max`)。
401    ///
402    /// 注:这是用尺寸近似("内容铺满可用屏幕"),并非 OS 级"最大化窗口状态"(Firefox/Juggler 无此能力)。
403    pub async fn max(&self) -> Result<()> {
404        let v = self
405            .tab
406            .run_js("[screen.availWidth, screen.availHeight]")
407            .await?;
408        let w = v.get(0).and_then(|x| x.as_f64()).unwrap_or(1280.0) as u32;
409        let h = v.get(1).and_then(|x| x.as_f64()).unwrap_or(800.0) as u32;
410        self.tab.core.set_viewport_size(w, h).await
411    }
412}
413
414/// `tab.intercept()` 返回的请求拦截句柄(对应 DP 风格;统一 `listen()`/`console()` 的句柄风格)。
415///
416/// 把扁平的 `intercept_start`/`intercept_xhr`/`intercept_next`/`intercept_stop` 收敛成句柄式
417/// (底层完全复用,不改变既有行为)。每个被拦请求必须用 `resume`/`resume_with`/`fulfill`/`abort`
418/// 之一放行(见 [`InterceptedRequest`])。
419pub struct Intercept {
420    tab: Tab,
421}
422
423impl Intercept {
424    pub(crate) fn new(tab: Tab) -> Self {
425        Self { tab }
426    }
427
428    /// 开始拦截:`targets` 为 URL 子串过滤(空切片=拦截所有请求)。务必在 `get` **之前**调用。
429    pub async fn start(&self, targets: &[&str]) -> Result<()> {
430        self.tab.intercept_start(targets).await
431    }
432
433    /// 仅拦截 XHR/fetch 类请求(其余自动放行)。
434    pub async fn start_xhr(&self, targets: &[&str]) -> Result<()> {
435        self.tab.intercept_xhr(targets).await
436    }
437
438    /// 是否正在拦截。
439    pub async fn is_intercepting(&self) -> bool {
440        self.tab.core.interceptor.lock().await.is_some()
441    }
442
443    /// 等待下一个被拦请求(默认超时;超时返回 [`Error::Timeout`])。
444    pub async fn next(&self) -> Result<InterceptedRequest> {
445        self.tab.intercept_next().await
446    }
447
448    /// 等待下一个被拦请求(自定义超时;超时返回 `None`)。
449    pub async fn next_timeout(&self, timeout: Duration) -> Result<Option<InterceptedRequest>> {
450        self.tab.intercept_next_timeout(timeout).await
451    }
452
453    /// 停止拦截并清空状态。
454    pub async fn stop(&self) -> Result<()> {
455        self.tab.intercept_stop().await
456    }
457}
458
459/// `tab.listen()` 返回的网络监听句柄(对应 DP `tab.listen`)。
460///
461/// 把扁平的 `listen_start` / `listen_wait` / `listen_stream` / `listen_stop` 收敛成 DP 风格的一组
462/// 操作(底层完全复用,不改变长监听/单次抓取的既有行为):
463///
464/// ```ignore
465/// tab.listen().start(&["aweme/v1/web/aweme/detail"]).await?;  // 装 hook(务必在 get 之前)
466/// tab.get(url).await?;
467/// let pkt  = tab.listen().wait().await?;                       // 等 1 个包(默认超时)
468/// let pkts = tab.listen().wait_count(3, None).await?;          // 等 3 个包
469/// let stream = tab.listen().steps().await?;                    // 长监听流式句柄(后台不丢包)
470/// tab.listen().stop().await?;                                  // 停止并清理
471/// ```
472pub struct Listen {
473    tab: Tab,
474}
475
476impl Listen {
477    pub(crate) fn new(tab: Tab) -> Self {
478        Self { tab }
479    }
480
481    /// 开始监听:`targets` 为 URL 子串过滤(空切片=全部 fetch/XHR)。务必在 `get` **之前**调用。
482    pub async fn start(&self, targets: &[&str]) -> Result<()> {
483        self.tab.listen_start(targets).await
484    }
485
486    /// 是否正在监听(对应 DP `tab.listen.listening`)。
487    pub async fn is_listening(&self) -> bool {
488        *self.tab.core.listen_active.lock().await
489    }
490
491    /// 偷看当前缓冲(不消费)。给 `drs network inspect` 用。
492    pub async fn peek(&self) -> Vec<DataPacket> {
493        self.tab
494            .core
495            .listen_buf
496            .lock()
497            .await
498            .iter()
499            .cloned()
500            .collect()
501    }
502
503    /// 等待下一个匹配数据包(默认超时;超时返回 [`Error::Timeout`])。
504    pub async fn wait(&self) -> Result<DataPacket> {
505        self.tab.listen_wait().await
506    }
507
508    /// 等待下一个匹配数据包(自定义超时;超时返回 `None`)。
509    pub async fn wait_timeout(&self, timeout: Duration) -> Result<Option<DataPacket>> {
510        match self.tab.listen_wait_timeout(timeout).await {
511            Ok(p) => Ok(Some(p)),
512            Err(Error::Timeout(_)) => Ok(None),
513            Err(e) => Err(e),
514        }
515    }
516
517    /// 取下一个数据包(默认超时;超时返回 `None`,对应 DP 不抛异常的用法)。
518    pub async fn next(&self) -> Result<Option<DataPacket>> {
519        self.tab.listen_next().await
520    }
521
522    /// 等待 `count` 个数据包(对应 DP `tab.listen.wait(count=N)`)。
523    ///
524    /// `timeout` 为抓满 `count` 个的**总超时**(`None`=默认超时);若到点仍不足,返回已抓到的那些
525    /// (可能少于 `count`),不报错——由调用方按 `len()` 判断是否凑齐。
526    pub async fn wait_count(
527        &self,
528        count: usize,
529        timeout: Option<Duration>,
530    ) -> Result<Vec<DataPacket>> {
531        let total = timeout.unwrap_or_else(|| self.tab.core.timeout());
532        let deadline = Instant::now() + total;
533        let mut out = Vec::with_capacity(count);
534        while out.len() < count {
535            let remain = deadline.saturating_duration_since(Instant::now());
536            if remain.is_zero() {
537                break;
538            }
539            match self.tab.listen_wait_timeout(remain).await {
540                Ok(p) => out.push(p),
541                Err(Error::Timeout(_)) => break,
542                Err(e) => return Err(e),
543            }
544        }
545        Ok(out)
546    }
547
548    /// 开启**长监听**并返回流式句柄(对应 DP `tab.listen.steps()`)。后台抽取不丢包,适合"边滑边抓"。
549    pub async fn steps(&self) -> Result<ListenStream> {
550        self.tab.listen_stream().await
551    }
552
553    /// 停止监听并清空状态(同时停掉长监听后台任务);保留 `add_init_script` 注入的通用脚本。
554    pub async fn stop(&self) -> Result<()> {
555        self.tab.listen_stop().await
556    }
557}