1#![cfg_attr(not(any(feature = "cdp", feature = "camoufox")), allow(dead_code))]
14
15use serde_json::Value;
16
17#[derive(Debug, Clone, Default)]
19pub struct RequestData {
20 pub headers: Vec<(String, String)>,
21 pub post_data: Option<String>,
22}
23
24#[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#[derive(Debug, Clone)]
36pub struct DataPacket {
37 pub url: String,
38 pub method: String,
39 pub resource_type: String,
41 pub request: RequestData,
42 pub response: ResponseData,
43}
44
45impl DataPacket {
46 pub fn path(&self) -> &str {
48 self.url.split('?').next().unwrap_or(&self.url)
49 }
50
51 pub fn url_has(&self, needle: &str) -> bool {
53 self.url.contains(needle)
54 }
55
56 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 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 pub fn json(&self) -> Option<Value> {
81 serde_json::from_str(&self.response.body).ok()
82 }
83}
84
85#[derive(Debug, Clone, Default)]
87pub struct ListenFilter {
88 pub url_keywords: Vec<String>,
90 pub xhr_only: bool,
92}
93
94impl ListenFilter {
95 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#[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")); 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}