Skip to main content

drission/browser/
websocket.rs

1//! WebSocket 帧监听(drission-rs 增强;DrissionPage 无对应原生 API)。
2//!
3//! 实现方式:订阅 Camoufox/Juggler 的**原生** `Page.webSocket*` 事件(与控制台监听同机制:
4//! 不 hook 页面 `WebSocket` 对象,因此不污染页面、对反检测更友好)。`websocket().start()` 起一个
5//! 后台任务持续把本会话的帧搬进缓冲,`wait/messages/steps` 取回。
6//!
7//! Juggler 事件(均在 page 会话上自动下发,无需 enable):
8//! - `Page.webSocketCreated {frameId, wsid, requestURL}`:连接创建。
9//! - `Page.webSocketOpened {frameId, requestId, wsid, effectiveURL}`:握手完成(URL 更准确)。
10//! - `Page.webSocketFrameSent {frameId, wsid, opcode, data}`:发出一帧。
11//! - `Page.webSocketFrameReceived {frameId, wsid, opcode, data}`:收到一帧。
12//! - `Page.webSocketClosed {frameId, wsid, error}`:连接关闭。
13//!
14//! **数据编码**(关键):Juggler 对 `data` 的处理是 `opcode === 1 ? payload : btoa(payload)`——
15//! 即**文本帧(opcode 1)是原始文本**,**其余帧(二进制 2 / 控制 8/9/10)是 base64**。
16//! 故 [`WsMessage::data`] 原样保留;取文本用 [`text`](WsMessage::text)、取字节用
17//! [`bytes`](WsMessage::bytes)、取 JSON 用 [`json`](WsMessage::json)。
18//!
19//! ```ignore
20//! let ws = tab.websocket();
21//! ws.start().await?;                                  // 开始监听(在建立连接之前)
22//! tab.run_js("new WebSocket('wss://host/path')").await?;
23//! let msg = ws.wait(None).await?.unwrap();
24//! if msg.is_text() { println!("{}", msg.text().unwrap()); }
25//! ws.stop().await?;
26//! ```
27
28use std::collections::{HashMap, VecDeque};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::time::Duration;
32
33use serde_json::Value;
34use tokio::sync::Mutex;
35use tokio::time::Instant;
36
37use crate::browser::tab::Tab;
38use crate::protocol::Event;
39use crate::util::base64_decode;
40use crate::{Error, Result};
41
42/// 缓冲上限:超过则丢弃最旧的(WebSocket 帧可能高频,避免长会话内存无界增长)。
43const MAX_BUFFERED: usize = 2000;
44
45/// 帧方向。
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum WsDirection {
48    /// 页面发出(client → server)。
49    Sent,
50    /// 页面收到(server → client)。
51    Received,
52}
53
54impl WsDirection {
55    pub fn as_str(self) -> &'static str {
56        match self {
57            WsDirection::Sent => "sent",
58            WsDirection::Received => "received",
59        }
60    }
61}
62
63/// 一个被监听到的 WebSocket 帧。
64#[derive(Debug, Clone)]
65pub struct WsMessage {
66    /// 方向(发出 / 收到)。
67    pub direction: WsDirection,
68    /// 该连接的 URL(由 `webSocketCreated`/`webSocketOpened` 回填;监听开始前已建立的连接可能为空)。
69    pub url: String,
70    /// 连接唯一标识(`frameId---wsid`),用于把同一连接的帧归组。
71    pub socket_id: String,
72    /// 所在 frame 的 id。
73    pub frame_id: String,
74    /// 连接序号 id(Juggler 的 `wsid`)。
75    pub wsid: String,
76    /// 操作码:`1`=文本,`2`=二进制,`8`=关闭,`9`=ping,`10`=pong,`0`=续帧。
77    pub opcode: i64,
78    /// 原始数据:**文本帧(opcode 1)为文本**,**其余帧为 base64**(Juggler 原样)。
79    pub data: String,
80}
81
82impl WsMessage {
83    /// 是否文本帧(opcode 1)。
84    pub fn is_text(&self) -> bool {
85        self.opcode == 1
86    }
87
88    /// 是否二进制帧(opcode 2)。
89    pub fn is_binary(&self) -> bool {
90        self.opcode == 2
91    }
92
93    /// 是否控制帧(close=8 / ping=9 / pong=10)。
94    pub fn is_control(&self) -> bool {
95        matches!(self.opcode, 8..=10)
96    }
97
98    /// opcode 的可读名(`text`/`binary`/`close`/`ping`/`pong`/`continuation`/`opcode(N)`)。
99    pub fn opcode_name(&self) -> String {
100        match self.opcode {
101            0 => "continuation".into(),
102            1 => "text".into(),
103            2 => "binary".into(),
104            8 => "close".into(),
105            9 => "ping".into(),
106            10 => "pong".into(),
107            n => format!("opcode({n})"),
108        }
109    }
110
111    /// 文本内容:文本帧返回原文;非文本帧返回 `None`(用 [`bytes`](Self::bytes) / [`text_lossy`](Self::text_lossy))。
112    pub fn text(&self) -> Option<String> {
113        self.is_text().then(|| self.data.clone())
114    }
115
116    /// 解码后的字节:文本帧为其 UTF-8 字节;其余帧 base64 解码(失败为空)。
117    pub fn bytes(&self) -> Vec<u8> {
118        if self.is_text() {
119            self.data.clone().into_bytes()
120        } else {
121            base64_decode(&self.data).unwrap_or_default()
122        }
123    }
124
125    /// 尽力得到文本:文本帧原文;二进制/控制帧按 UTF-8 有损解码其字节。
126    pub fn text_lossy(&self) -> String {
127        if self.is_text() {
128            self.data.clone()
129        } else {
130            String::from_utf8_lossy(&self.bytes()).into_owned()
131        }
132    }
133
134    /// 把内容按 JSON 解析(文本帧解析其文本;二进制帧解析其字节);非 JSON 返回 `None`。
135    pub fn json(&self) -> Option<Value> {
136        if self.is_text() {
137            serde_json::from_str(&self.data).ok()
138        } else {
139            serde_json::from_slice(&self.bytes()).ok()
140        }
141    }
142}
143
144/// 一个 WebSocket 连接的状态快照(由监听任务跟踪)。
145#[derive(Debug, Clone, Default)]
146pub struct WsSocket {
147    /// 连接唯一标识(`frameId---wsid`)。
148    pub socket_id: String,
149    /// 连接 URL。
150    pub url: String,
151    /// 是否已完成握手(收到 `webSocketOpened`)。
152    pub opened: bool,
153    /// 是否已关闭(收到 `webSocketClosed`)。
154    pub closed: bool,
155    /// 关闭时的错误信息(正常关闭为空)。
156    pub error: String,
157}
158
159/// WebSocket 帧监听过滤条件(默认:双向、按 URL 不过滤、**不含控制帧**)。
160#[derive(Debug, Clone, Default)]
161pub struct WsFilter {
162    /// 连接 URL 子串集合;为空表示匹配所有连接。
163    pub url_keywords: Vec<String>,
164    /// 仅保留某方向;`None` 表示双向都收。
165    pub direction: Option<WsDirection>,
166    /// 是否包含 ping/pong/close 控制帧(默认 `false`,只收 text/binary 数据帧)。
167    pub include_control: bool,
168}
169
170impl WsFilter {
171    pub fn new() -> Self {
172        Self::default()
173    }
174
175    /// 追加一个连接 URL 必含子串(任一命中即保留)。
176    pub fn url_contains(mut self, needle: &str) -> Self {
177        self.url_keywords.push(needle.to_string());
178        self
179    }
180
181    /// 只收页面**发出**的帧。
182    pub fn sent_only(mut self) -> Self {
183        self.direction = Some(WsDirection::Sent);
184        self
185    }
186
187    /// 只收页面**收到**的帧。
188    pub fn received_only(mut self) -> Self {
189        self.direction = Some(WsDirection::Received);
190        self
191    }
192
193    /// 同时包含控制帧(ping/pong/close)。
194    pub fn with_control(mut self) -> Self {
195        self.include_control = true;
196        self
197    }
198
199    fn url_matches(&self, url: &str) -> bool {
200        self.url_keywords.is_empty() || self.url_keywords.iter().any(|k| url.contains(k))
201    }
202
203    fn matches(&self, direction: WsDirection, opcode: i64, url: &str) -> bool {
204        if let Some(d) = self.direction
205            && d != direction
206        {
207            return false;
208        }
209        if !self.include_control && matches!(opcode, 8..=10) {
210            return false;
211        }
212        self.url_matches(url)
213    }
214}
215
216/// WebSocket 监听共享状态(放在 `TabCore`,由监听任务写、句柄读)。
217pub(crate) struct WsShared {
218    pub buf: Mutex<VecDeque<WsMessage>>,
219    pub sockets: Mutex<HashMap<String, WsSocket>>,
220    pub active: AtomicBool,
221}
222
223impl WsShared {
224    pub(crate) fn new() -> Self {
225        Self {
226            buf: Mutex::new(VecDeque::new()),
227            sockets: Mutex::new(HashMap::new()),
228            active: AtomicBool::new(false),
229        }
230    }
231}
232
233/// `tab.websocket()` 返回的 WebSocket 帧监听句柄。
234///
235/// 即用即弃,持有一个 [`Tab`] 克隆(共享内核)。`start` 与 `wait` 即使来自不同 `websocket()` 句柄,
236/// 也共享同一缓冲。
237pub struct WsListener {
238    tab: Tab,
239}
240
241impl WsListener {
242    pub(crate) fn new(tab: Tab) -> Self {
243        Self { tab }
244    }
245
246    /// 开始监听 WebSocket 帧。幂等:已在监听时直接返回。务必在建立连接(导航 / `new WebSocket`)**之前**调用。
247    pub async fn start(&self) -> Result<()> {
248        self.start_with(WsFilter::default()).await
249    }
250
251    /// 开始监听并指定过滤条件(只收某 URL / 某方向 / 是否含控制帧)。
252    pub async fn start_with(&self, filter: WsFilter) -> Result<()> {
253        let shared = self.tab.core.ws.clone();
254        if shared.active.swap(true, Ordering::SeqCst) {
255            return Ok(()); // 已在监听:幂等返回
256        }
257        shared.buf.lock().await.clear();
258        shared.sockets.lock().await.clear();
259
260        let events = self.tab.core.conn.subscribe();
261        let session = self.tab.core.session_id.clone();
262        let task = tokio::spawn(ws_loop(events, session, shared, filter));
263        *self.tab.core.ws_task.lock().await = Some(task);
264        Ok(())
265    }
266
267    /// 是否正在监听。同步读取。
268    pub fn listening(&self) -> bool {
269        self.tab.core.ws.active.load(Ordering::SeqCst)
270    }
271
272    /// 等待一帧。`timeout` 为 `None` 表示无限等待(直到来帧或 `stop`);否则超时返回 `Ok(None)`。
273    pub async fn wait(&self, timeout: Option<Duration>) -> Result<Option<WsMessage>> {
274        let shared = &self.tab.core.ws;
275        if !shared.active.load(Ordering::SeqCst) {
276            return Err(Error::Other("尚未调用 websocket().start()".into()));
277        }
278        let deadline = timeout.map(|d| Instant::now() + d);
279        loop {
280            if let Some(m) = shared.buf.lock().await.pop_front() {
281                return Ok(Some(m));
282            }
283            if !shared.active.load(Ordering::SeqCst) {
284                return Ok(None); // 监听已停止
285            }
286            if let Some(dl) = deadline
287                && Instant::now() >= dl
288            {
289                return Ok(None);
290            }
291            tokio::time::sleep(Duration::from_millis(50)).await;
292        }
293    }
294
295    /// 等待 `count` 帧(总超时 `None`=默认操作超时)。到点不足返回已抓到的(不报错)。
296    pub async fn wait_count(
297        &self,
298        count: usize,
299        timeout: Option<Duration>,
300    ) -> Result<Vec<WsMessage>> {
301        let total = timeout.unwrap_or_else(|| self.tab.core.timeout());
302        let deadline = Instant::now() + total;
303        let mut out = Vec::with_capacity(count);
304        while out.len() < count {
305            let remain = deadline.saturating_duration_since(Instant::now());
306            if remain.is_zero() {
307                break;
308            }
309            match self.wait(Some(remain)).await? {
310                Some(m) => out.push(m),
311                None => break,
312            }
313        }
314        Ok(out)
315    }
316
317    /// 取走当前已缓冲的所有帧并清空。
318    pub async fn messages(&self) -> Vec<WsMessage> {
319        self.tab.core.ws.buf.lock().await.drain(..).collect()
320    }
321
322    /// 当前已知连接的状态快照(URL / 是否打开 / 是否关闭)。
323    pub async fn sockets(&self) -> Vec<WsSocket> {
324        self.tab
325            .core
326            .ws
327            .sockets
328            .lock()
329            .await
330            .values()
331            .cloned()
332            .collect()
333    }
334
335    /// 清空已获取但未返回的帧。
336    pub async fn clear(&self) {
337        self.tab.core.ws.buf.lock().await.clear();
338    }
339
340    /// 返回流式句柄,可循环逐帧获取。
341    pub fn steps(&self) -> WsSteps {
342        WsSteps {
343            tab: self.tab.clone(),
344        }
345    }
346
347    /// 停止监听并清空帧缓冲与连接表。
348    pub async fn stop(&self) -> Result<()> {
349        self.tab.core.ws.active.store(false, Ordering::SeqCst);
350        if let Some(h) = self.tab.core.ws_task.lock().await.take() {
351            h.abort();
352        }
353        self.tab.core.ws.buf.lock().await.clear();
354        self.tab.core.ws.sockets.lock().await.clear();
355        Ok(())
356    }
357}
358
359/// `websocket().steps()` 返回的流式句柄:每次 [`next`](Self::next) 取下一帧。
360pub struct WsSteps {
361    tab: Tab,
362}
363
364impl WsSteps {
365    /// 取下一帧(`timeout` 为 `None` 无限等待;超时返回 `None` 即可结束循环)。
366    pub async fn next(&self, timeout: Option<Duration>) -> Result<Option<WsMessage>> {
367        WsListener::new(self.tab.clone()).wait(timeout).await
368    }
369}
370
371/// 监听任务主循环:消费本会话的 `Page.webSocket*` 事件,维护连接表并把帧入缓冲。
372async fn ws_loop(
373    mut events: tokio::sync::broadcast::Receiver<Event>,
374    session: String,
375    shared: Arc<WsShared>,
376    filter: WsFilter,
377) {
378    loop {
379        let ev = match events.recv().await {
380            Ok(ev) => ev,
381            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
382                tracing::warn!(skipped = n, "WebSocket 监听落后,跳过部分事件");
383                continue;
384            }
385            Err(_) => break,
386        };
387        if !shared.active.load(Ordering::SeqCst) {
388            break;
389        }
390        if ev.session_id.as_deref() != Some(&session) {
391            continue;
392        }
393        match ev.method.as_str() {
394            "Page.webSocketCreated" => {
395                let id = socket_id(&ev.params);
396                let url = ev.params["requestURL"]
397                    .as_str()
398                    .unwrap_or_default()
399                    .to_string();
400                let mut socks = shared.sockets.lock().await;
401                let s = socks.entry(id.clone()).or_default();
402                s.socket_id = id;
403                if !url.is_empty() {
404                    s.url = url;
405                }
406            }
407            "Page.webSocketOpened" => {
408                let id = socket_id(&ev.params);
409                let url = ev.params["effectiveURL"]
410                    .as_str()
411                    .unwrap_or_default()
412                    .to_string();
413                let mut socks = shared.sockets.lock().await;
414                let s = socks.entry(id.clone()).or_default();
415                s.socket_id = id;
416                s.opened = true;
417                if !url.is_empty() {
418                    s.url = url;
419                }
420            }
421            "Page.webSocketClosed" => {
422                let id = socket_id(&ev.params);
423                let err = ev.params["error"].as_str().unwrap_or_default().to_string();
424                let mut socks = shared.sockets.lock().await;
425                let s = socks.entry(id.clone()).or_default();
426                s.socket_id = id;
427                s.closed = true;
428                s.error = err;
429            }
430            "Page.webSocketFrameSent" => {
431                push_frame(&shared, &filter, WsDirection::Sent, &ev.params).await;
432            }
433            "Page.webSocketFrameReceived" => {
434                push_frame(&shared, &filter, WsDirection::Received, &ev.params).await;
435            }
436            _ => {}
437        }
438    }
439    tracing::debug!(%session, "WebSocket 监听任务结束");
440}
441
442/// 构造一帧并(经过滤后)入缓冲。
443async fn push_frame(
444    shared: &Arc<WsShared>,
445    filter: &WsFilter,
446    direction: WsDirection,
447    params: &Value,
448) {
449    let id = socket_id(params);
450    let opcode = params["opcode"].as_i64().unwrap_or(-1);
451    let url = shared
452        .sockets
453        .lock()
454        .await
455        .get(&id)
456        .map(|s| s.url.clone())
457        .unwrap_or_default();
458    if !filter.matches(direction, opcode, &url) {
459        return;
460    }
461    let msg = WsMessage {
462        direction,
463        url,
464        socket_id: id,
465        frame_id: params["frameId"].as_str().unwrap_or_default().to_string(),
466        wsid: params["wsid"].as_str().unwrap_or_default().to_string(),
467        opcode,
468        data: params["data"].as_str().unwrap_or_default().to_string(),
469    };
470    let mut buf = shared.buf.lock().await;
471    if buf.len() >= MAX_BUFFERED {
472        buf.pop_front();
473    }
474    buf.push_back(msg);
475}
476
477/// 连接唯一标识:`frameId---wsid`(与 Playwright 的 `webSocketId` 一致)。
478fn socket_id(params: &Value) -> String {
479    let frame = params["frameId"].as_str().unwrap_or_default();
480    let wsid = params["wsid"].as_str().unwrap_or_default();
481    format!("{frame}---{wsid}")
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use serde_json::json;
488
489    #[test]
490    fn text_frame_helpers() {
491        let m = WsMessage {
492            direction: WsDirection::Received,
493            url: "wss://x/path".into(),
494            socket_id: "f---1".into(),
495            frame_id: "f".into(),
496            wsid: "1".into(),
497            opcode: 1,
498            data: r#"{"a":1,"b":[2,3]}"#.into(),
499        };
500        assert!(m.is_text());
501        assert!(!m.is_binary());
502        assert!(!m.is_control());
503        assert_eq!(m.opcode_name(), "text");
504        assert_eq!(m.text().as_deref(), Some(r#"{"a":1,"b":[2,3]}"#));
505        assert_eq!(m.bytes(), br#"{"a":1,"b":[2,3]}"#.to_vec());
506        let j = m.json().unwrap();
507        assert_eq!(j["b"][1], 3);
508    }
509
510    #[test]
511    fn binary_frame_is_base64() {
512        // [1,2,3,4] 的 base64 是 "AQIDBA=="。
513        let m = WsMessage {
514            direction: WsDirection::Sent,
515            url: String::new(),
516            socket_id: "f---2".into(),
517            frame_id: "f".into(),
518            wsid: "2".into(),
519            opcode: 2,
520            data: "AQIDBA==".into(),
521        };
522        assert!(m.is_binary());
523        assert!(m.text().is_none());
524        assert_eq!(m.bytes(), vec![1, 2, 3, 4]);
525        assert_eq!(m.opcode_name(), "binary");
526    }
527
528    #[test]
529    fn binary_json_decodes_then_parses() {
530        // base64 of {"k":42}
531        let m = WsMessage {
532            direction: WsDirection::Received,
533            url: String::new(),
534            socket_id: "f---3".into(),
535            frame_id: "f".into(),
536            wsid: "3".into(),
537            opcode: 2,
538            data: crate::util::base64_encode(br#"{"k":42}"#),
539        };
540        assert_eq!(m.json().unwrap()["k"], 42);
541        assert_eq!(m.text_lossy(), r#"{"k":42}"#);
542    }
543
544    #[test]
545    fn filter_direction_and_control_and_url() {
546        // 默认:双向、不含控制帧、不过滤 URL。
547        let f = WsFilter::default();
548        assert!(f.matches(WsDirection::Sent, 1, "wss://a"));
549        assert!(f.matches(WsDirection::Received, 2, "wss://a"));
550        assert!(!f.matches(WsDirection::Sent, 9, "wss://a")); // ping 控制帧默认丢弃
551
552        // 含控制帧。
553        let f = WsFilter::new().with_control();
554        assert!(f.matches(WsDirection::Sent, 9, "wss://a"));
555
556        // 只收发出方向。
557        let f = WsFilter::new().sent_only();
558        assert!(f.matches(WsDirection::Sent, 1, "x"));
559        assert!(!f.matches(WsDirection::Received, 1, "x"));
560
561        // URL 过滤。
562        let f = WsFilter::new().url_contains("/live/");
563        assert!(f.matches(WsDirection::Sent, 1, "wss://h/live/room"));
564        assert!(!f.matches(WsDirection::Sent, 1, "wss://h/other"));
565    }
566
567    #[test]
568    fn socket_id_combines_frame_and_wsid() {
569        assert_eq!(socket_id(&json!({"frameId":"abc","wsid":"7"})), "abc---7");
570    }
571}