1use crate::browser::Page;
39use anyhow::Result;
40use std::collections::{HashMap, VecDeque};
41
42#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct FrameNode {
49 pub frame_id: Option<String>,
53 pub parent: Option<usize>,
56 pub url: String,
59 pub title: String,
61 pub has_captcha_marker: bool,
65 pub depth: usize,
67}
68
69#[derive(Debug, Clone, Default)]
76pub struct FrameGraph {
77 pub nodes: Vec<FrameNode>,
78}
79
80impl FrameGraph {
81 pub async fn snapshot(page: &Page) -> Result<Self> {
89 let frame_ids = page.frames().await?;
90 let mut nodes: Vec<FrameNode> = Vec::with_capacity(frame_ids.len() + 1);
91
92 nodes.push(FrameNode {
96 frame_id: None,
97 parent: None,
98 url: "(root)".into(),
99 title: String::new(),
100 has_captcha_marker: false,
101 depth: 0,
102 });
103
104 for fid in frame_ids {
105 if let Ok(eval) = page.evaluate_in_context(PROBE_JS, &fid).await {
106 if let Ok(v) = eval.into_value::<FrameProbe>() {
107 nodes.push(FrameNode {
108 frame_id: Some(format!("{fid:?}")),
109 parent: Some(0),
110 url: v.url,
111 title: v.title,
112 has_captcha_marker: v.has_captcha_marker,
113 depth: 1,
114 });
115 }
116 }
117 }
118
119 Ok(Self { nodes })
120 }
121
122 pub fn any_captcha_marker(&self) -> bool {
125 self.nodes.iter().any(|n| n.has_captcha_marker)
126 }
127
128 pub fn children(&self, parent_idx: usize) -> Vec<usize> {
134 self.nodes
135 .iter()
136 .enumerate()
137 .filter_map(|(i, n)| {
138 if n.parent == Some(parent_idx) {
139 Some(i)
140 } else {
141 None
142 }
143 })
144 .collect()
145 }
146
147 pub fn bfs(&self) -> Vec<usize> {
152 if self.nodes.is_empty() {
153 return Vec::new();
154 }
155 let mut order = Vec::with_capacity(self.nodes.len());
156 let mut queue: VecDeque<usize> = VecDeque::new();
157 queue.push_back(0);
158 while let Some(idx) = queue.pop_front() {
159 order.push(idx);
160 for child in self.children(idx) {
161 queue.push_back(child);
162 }
163 }
164 order
165 }
166
167 pub fn deepest_captcha(&self) -> Option<usize> {
174 self.nodes
175 .iter()
176 .enumerate()
177 .filter(|(_, n)| n.has_captcha_marker)
178 .max_by_key(|(_, n)| n.depth)
179 .map(|(i, _)| i)
180 }
181
182 pub fn ancestors_inclusive(&self, mut node_idx: usize) -> Vec<usize> {
189 let mut out = Vec::new();
190 let mut visited = std::collections::HashSet::new();
191 while let Some(node) = self.nodes.get(node_idx) {
192 if !visited.insert(node_idx) {
193 break;
196 }
197 out.push(node_idx);
198 match node.parent {
199 Some(p) => node_idx = p,
200 None => break,
201 }
202 }
203 out
204 }
205
206 pub fn frames_by_host(&self) -> HashMap<String, Vec<usize>> {
212 let mut out: HashMap<String, Vec<usize>> = HashMap::new();
213 for (i, n) in self.nodes.iter().enumerate() {
214 if let Some(host) = url::Url::parse(&n.url)
215 .ok()
216 .and_then(|u| u.host_str().map(String::from))
217 {
218 out.entry(host).or_default().push(i);
219 }
220 }
221 out
222 }
223}
224
225#[derive(serde::Deserialize)]
226struct FrameProbe {
227 url: String,
228 title: String,
229 has_captcha_marker: bool,
230}
231
232const PROBE_JS: &str = r#"({
236 url: location.href || '',
237 title: document.title || '',
238 has_captcha_marker: !!document.querySelector(
239 'iframe[src*="challenges.cloudflare.com"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"], '
240 + 'iframe[src*="arkoselabs"], iframe[src*="datadome"], iframe[src*="geetest"], '
241 + 'iframe[src*="perimeterx"], iframe[src*="kasada"], iframe[src*="incapsula"], '
242 + '.cf-turnstile, .h-captcha, .g-recaptcha, '
243 + '#challenge-form, #challenge-stage, #cf-please-wait, #px-captcha, '
244 + '[id^="captcha"], [class*="captcha" i], [class*="challenge" i]'
245 )
246})"#;
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn fixture_graph() -> FrameGraph {
263 FrameGraph {
264 nodes: vec![
265 FrameNode {
267 frame_id: None,
268 parent: None,
269 url: "(root)".into(),
270 title: String::new(),
271 has_captcha_marker: false,
272 depth: 0,
273 },
274 FrameNode {
276 frame_id: Some("F1".into()),
277 parent: Some(0),
278 url: "https://example.com".into(),
279 title: "Main".into(),
280 has_captcha_marker: false,
281 depth: 1,
282 },
283 FrameNode {
285 frame_id: Some("F2".into()),
286 parent: Some(1),
287 url: "https://challenges.cloudflare.com/turnstile".into(),
288 title: String::new(),
289 has_captcha_marker: true,
290 depth: 2,
291 },
292 FrameNode {
294 frame_id: Some("F3".into()),
295 parent: Some(2),
296 url: "https://challenges.cloudflare.com/turnstile/inner".into(),
297 title: String::new(),
298 has_captcha_marker: true,
299 depth: 3,
300 },
301 FrameNode {
303 frame_id: Some("F4".into()),
304 parent: Some(1),
305 url: "https://example.com/sidebar".into(),
306 title: String::new(),
307 has_captcha_marker: false,
308 depth: 2,
309 },
310 ],
311 }
312 }
313
314 #[test]
315 fn empty_graph_has_no_captcha_markers() {
316 let g = FrameGraph::default();
317 assert!(!g.any_captcha_marker());
318 assert!(g.bfs().is_empty());
319 assert!(g.deepest_captcha().is_none());
320 }
321
322 #[test]
323 fn any_captcha_marker_short_circuits_on_first_match() {
324 let g = fixture_graph();
325 assert!(g.any_captcha_marker());
326 }
327
328 #[test]
329 fn children_returns_all_direct_children_of_root() {
330 let g = fixture_graph();
331 let kids = g.children(0);
332 assert_eq!(kids, vec![1]);
333 }
334
335 #[test]
336 fn children_returns_all_direct_children_of_internal_node() {
337 let g = fixture_graph();
338 let kids = g.children(1);
340 assert_eq!(kids, vec![2, 4]);
341 }
342
343 #[test]
344 fn bfs_visits_root_first_then_each_level() {
345 let g = fixture_graph();
346 let order = g.bfs();
347 assert_eq!(order, vec![0, 1, 2, 4, 3]);
351 }
352
353 #[test]
354 fn deepest_captcha_finds_innermost_marker() {
355 let g = fixture_graph();
356 let deepest = g.deepest_captcha().expect("fixture has captcha markers");
357 assert_eq!(deepest, 3, "frame_aa is the deepest captcha-bearing node");
358 }
359
360 #[test]
361 fn ancestors_inclusive_walks_to_root_in_order() {
362 let g = fixture_graph();
363 let path = g.ancestors_inclusive(3);
365 assert_eq!(path, vec![3, 2, 1, 0]);
366 }
367
368 #[test]
369 fn ancestors_inclusive_handles_oob_index_gracefully() {
370 let g = fixture_graph();
371 assert!(g.ancestors_inclusive(999).is_empty());
372 }
373
374 #[test]
375 fn ancestors_inclusive_handles_root_node() {
376 let g = fixture_graph();
377 let path = g.ancestors_inclusive(0);
378 assert_eq!(path, vec![0]);
379 }
380
381 #[test]
382 fn frames_by_host_groups_correctly() {
383 let g = fixture_graph();
384 let hosts = g.frames_by_host();
385 assert_eq!(
386 hosts.get("example.com").map(|v| v.len()),
387 Some(2),
388 "main + sidebar both on example.com"
389 );
390 assert_eq!(
391 hosts.get("challenges.cloudflare.com").map(|v| v.len()),
392 Some(2),
393 "two CF turnstile frames"
394 );
395 }
396
397 #[test]
398 fn frames_by_host_skips_unparseable_urls() {
399 let g = fixture_graph();
401 let hosts = g.frames_by_host();
402 assert!(!hosts.contains_key("(root)"));
403 }
404
405 #[test]
406 fn children_leaf_node_returns_empty() {
407 let g = fixture_graph();
408 assert!(g.children(3).is_empty());
410 }
411
412 #[test]
413 fn children_oob_returns_empty() {
414 let g = fixture_graph();
415 assert!(g.children(999).is_empty());
416 }
417
418 #[test]
419 fn bfs_single_node() {
420 let g = FrameGraph {
421 nodes: vec![FrameNode {
422 frame_id: None,
423 parent: None,
424 url: "solo".into(),
425 title: String::new(),
426 has_captcha_marker: false,
427 depth: 0,
428 }],
429 };
430 assert_eq!(g.bfs(), vec![0]);
431 }
432
433 #[test]
434 fn bfs_linear_chain() {
435 let g = FrameGraph {
436 nodes: vec![
437 FrameNode { frame_id: Some("A".into()), parent: None, url: "a".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
438 FrameNode { frame_id: Some("B".into()), parent: Some(0), url: "b".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
439 FrameNode { frame_id: Some("C".into()), parent: Some(1), url: "c".into(), title: String::new(), has_captcha_marker: false, depth: 2 },
440 ],
441 };
442 assert_eq!(g.bfs(), vec![0, 1, 2]);
443 }
444
445 #[test]
446 fn deepest_captcha_none_when_no_markers() {
447 let g = FrameGraph {
448 nodes: vec![
449 FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
450 FrameNode { frame_id: Some("A".into()), parent: Some(0), url: "a".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
451 ],
452 };
453 assert!(g.deepest_captcha().is_none());
454 }
455
456 #[test]
457 fn deepest_captcha_prefers_last_at_same_depth() {
458 let g = FrameGraph {
459 nodes: vec![
460 FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
461 FrameNode { frame_id: Some("A".into()), parent: Some(0), url: "a".into(), title: String::new(), has_captcha_marker: true, depth: 1 },
462 FrameNode { frame_id: Some("B".into()), parent: Some(0), url: "b".into(), title: String::new(), has_captcha_marker: true, depth: 1 },
463 ],
464 };
465 assert_eq!(g.deepest_captcha(), Some(2));
467 }
468
469 #[test]
470 fn ancestors_inclusive_orphaned_node_stops_at_root() {
471 let g = FrameGraph {
474 nodes: vec![
475 FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
476 FrameNode { frame_id: Some("orphan".into()), parent: Some(999), url: "orphan".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
477 ],
478 };
479 let path = g.ancestors_inclusive(1);
480 assert_eq!(path, vec![1]);
481 }
482
483 #[test]
484 fn frames_by_host_empty_graph() {
485 let g = FrameGraph::default();
486 assert!(g.frames_by_host().is_empty());
487 }
488
489 #[test]
490 fn frames_by_host_with_port() {
491 let g = FrameGraph {
492 nodes: vec![
493 FrameNode { frame_id: None, parent: None, url: "http://localhost:8080/path".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
494 ],
495 };
496 let hosts = g.frames_by_host();
497 assert_eq!(hosts.get("localhost").map(|v| v.len()), Some(1));
498 }
499
500 #[test]
501 fn frames_by_host_ip_address() {
502 let g = FrameGraph {
503 nodes: vec![
504 FrameNode { frame_id: None, parent: None, url: "http://192.168.1.1/admin".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
505 ],
506 };
507 let hosts = g.frames_by_host();
508 assert_eq!(hosts.get("192.168.1.1").map(|v| v.len()), Some(1));
509 }
510}