Skip to main content

drission/
net.rs

1//! 后端无关的**网络数据类型**(监听 / 拦截共享)。
2//!
3//! Camoufox(Juggler)与 Chromium(CDP)两后端的网络监听 / 请求拦截 API 一致,数据结构在此
4//! 统一定义、始终编译;各后端的具体抓取 / 拦截实现仍在各自模块里(`browser::{listener,interceptor}`
5//! 与 `cdp::{listener,interceptor}`)。
6//!
7//! - [`DataPacket`](监听到的一个请求+响应)与其 [`RequestData`]/[`ResponseData`];
8//! - [`ListenFilter`](URL / 类型过滤);
9//! - [`ResumeOptions`](改写放行请求时的可选覆盖字段)。
10//!
11//! `ListenFilter::matches` 仅被 cdp / camoufox 的拦截器调用;两后端都不开时它闲置,故在该退化
12//! 配置下允许 dead_code(其余类型是公开 API,本就不触发 dead_code)。
13#![cfg_attr(not(any(feature = "cdp", feature = "camoufox")), allow(dead_code))]
14
15use serde_json::Value;
16
17/// 请求侧数据。
18#[derive(Debug, Clone, Default)]
19pub struct RequestData {
20    pub headers: Vec<(String, String)>,
21    pub post_data: Option<String>,
22}
23
24/// 响应侧数据。`body` 为文本响应体;`body_base64` 保留字段(hook 模式下通常为空)。
25#[derive(Debug, Clone, Default)]
26pub struct ResponseData {
27    pub status: u16,
28    pub status_text: String,
29    pub headers: Vec<(String, String)>,
30    pub body: String,
31    pub body_base64: String,
32}
33
34/// 一个被监听到的网络数据包(请求 + 响应)。
35#[derive(Debug, Clone)]
36pub struct DataPacket {
37    pub url: String,
38    pub method: String,
39    /// 资源类型(`fetch`/`xhr`)。
40    pub resource_type: String,
41    pub request: RequestData,
42    pub response: ResponseData,
43}
44
45impl DataPacket {
46    /// 请求 URL 的 path 部分(去掉 `?query`)。
47    pub fn path(&self) -> &str {
48        self.url.split('?').next().unwrap_or(&self.url)
49    }
50
51    /// URL 是否包含某子串(便捷过滤)。
52    pub fn url_has(&self, needle: &str) -> bool {
53        self.url.contains(needle)
54    }
55
56    /// 取 URL query 中某参数的**原始值**(未 URL 解码,即上线值);不存在返回 `None`。
57    pub fn query(&self, key: &str) -> Option<String> {
58        let q = self.url.split_once('?')?.1;
59        q.split('&').find_map(|kv| {
60            let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
61            (k == key).then(|| v.to_string())
62        })
63    }
64
65    /// URL query 的全部键值对(原始值)。
66    pub fn queries(&self) -> Vec<(String, String)> {
67        let Some((_, q)) = self.url.split_once('?') else {
68            return Vec::new();
69        };
70        q.split('&')
71            .filter(|s| !s.is_empty())
72            .map(|kv| {
73                let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
74                (k.to_string(), v.to_string())
75            })
76            .collect()
77    }
78
79    /// 把响应体按 JSON 解析;非 JSON 返回 `None`。
80    pub fn json(&self) -> Option<Value> {
81        serde_json::from_str(&self.response.body).ok()
82    }
83}
84
85/// 监听过滤条件。
86#[derive(Debug, Clone, Default)]
87pub struct ListenFilter {
88    /// URL 子串集合;为空表示匹配所有 URL。
89    pub url_keywords: Vec<String>,
90    /// 仅匹配 XHR/fetch 类请求(hook 模式天然只覆盖 fetch/XHR)。
91    pub xhr_only: bool,
92}
93
94impl ListenFilter {
95    /// URL 与资源类型是否同时匹配(供请求拦截复用)。
96    pub(crate) fn matches(&self, url: &str, resource_type: &str) -> bool {
97        self.url_matches(url) && self.type_matches(resource_type)
98    }
99
100    fn url_matches(&self, url: &str) -> bool {
101        self.url_keywords.is_empty() || self.url_keywords.iter().any(|k| url.contains(k))
102    }
103
104    fn type_matches(&self, resource_type: &str) -> bool {
105        if !self.xhr_only {
106            return true;
107        }
108        let t = resource_type.to_ascii_lowercase();
109        t.contains("xhr") || t.contains("fetch") || t.contains("xmlhttprequest")
110    }
111}
112
113/// 改写放行时可覆盖的字段(均为可选;`None` 表示保持原值)。
114#[derive(Debug, Clone, Default)]
115pub struct ResumeOptions {
116    pub url: Option<String>,
117    pub method: Option<String>,
118    pub headers: Option<Vec<(String, String)>>,
119    pub post_data: Option<String>,
120}
121
122impl ResumeOptions {
123    pub fn new() -> Self {
124        Self::default()
125    }
126    pub fn url(mut self, url: impl Into<String>) -> Self {
127        self.url = Some(url.into());
128        self
129    }
130    pub fn method(mut self, method: impl Into<String>) -> Self {
131        self.method = Some(method.into());
132        self
133    }
134    pub fn headers(mut self, headers: Vec<(String, String)>) -> Self {
135        self.headers = Some(headers);
136        self
137    }
138    pub fn post_data(mut self, post_data: impl Into<String>) -> Self {
139        self.post_data = Some(post_data.into());
140        self
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn filter_matches() {
150        let f = ListenFilter {
151            url_keywords: vec!["/api/".into()],
152            xhr_only: false,
153        };
154        assert!(f.matches("https://x.com/api/v1", "fetch"));
155        assert!(!f.matches("https://x.com/static.js", "fetch"));
156        let all = ListenFilter::default();
157        assert!(all.matches("anything", "document"));
158    }
159
160    #[test]
161    fn datapacket_url_and_json_helpers() {
162        let p = DataPacket {
163            url: "https://x.com/api/detail/?aweme_id=123&a_bogus=ZZ%2F1".into(),
164            method: "GET".into(),
165            resource_type: "xhr".into(),
166            request: RequestData::default(),
167            response: ResponseData {
168                body: r#"{"aweme_list":[{"aweme_id":"a1"},{"aweme_id":"a2"}]}"#.into(),
169                ..Default::default()
170            },
171        };
172        assert_eq!(p.path(), "https://x.com/api/detail/");
173        assert!(p.url_has("aweme_id="));
174        assert_eq!(p.query("aweme_id").as_deref(), Some("123"));
175        assert_eq!(p.query("a_bogus").as_deref(), Some("ZZ%2F1")); // 原始(未解码)
176        assert_eq!(p.query("missing"), None);
177        assert_eq!(p.queries().len(), 2);
178        let j = p.json().unwrap();
179        assert_eq!(j["aweme_list"][1]["aweme_id"], "a2");
180    }
181
182    #[test]
183    fn resume_options_builder() {
184        let o = ResumeOptions::new()
185            .url("https://x.com")
186            .method("POST")
187            .headers(vec![("X-A".into(), "1".into())])
188            .post_data("body");
189        assert_eq!(o.url.as_deref(), Some("https://x.com"));
190        assert_eq!(o.method.as_deref(), Some("POST"));
191        assert_eq!(o.headers.unwrap()[0].0, "X-A");
192        assert_eq!(o.post_data.as_deref(), Some("body"));
193    }
194}