use crate::browser::Page;
use anyhow::Result;
use std::collections::{HashMap, VecDeque};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrameNode {
pub frame_id: Option<String>,
pub parent: Option<usize>,
pub url: String,
pub title: String,
pub has_captcha_marker: bool,
pub depth: usize,
}
#[derive(Debug, Clone, Default)]
pub struct FrameGraph {
pub nodes: Vec<FrameNode>,
}
impl FrameGraph {
pub async fn snapshot(page: &Page) -> Result<Self> {
let frame_ids = page.frames().await?;
let mut nodes: Vec<FrameNode> = Vec::with_capacity(frame_ids.len() + 1);
nodes.push(FrameNode {
frame_id: None,
parent: None,
url: "(root)".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
});
for fid in frame_ids {
if let Ok(eval) = page.evaluate_in_context(PROBE_JS, &fid).await {
if let Ok(v) = eval.into_value::<FrameProbe>() {
nodes.push(FrameNode {
frame_id: Some(format!("{fid:?}")),
parent: Some(0),
url: v.url,
title: v.title,
has_captcha_marker: v.has_captcha_marker,
depth: 1,
});
}
}
}
Ok(Self { nodes })
}
pub fn any_captcha_marker(&self) -> bool {
self.nodes.iter().any(|n| n.has_captcha_marker)
}
pub fn children(&self, parent_idx: usize) -> Vec<usize> {
self.nodes
.iter()
.enumerate()
.filter_map(|(i, n)| {
if n.parent == Some(parent_idx) {
Some(i)
} else {
None
}
})
.collect()
}
pub fn bfs(&self) -> Vec<usize> {
if self.nodes.is_empty() {
return Vec::new();
}
let mut order = Vec::with_capacity(self.nodes.len());
let mut queue: VecDeque<usize> = VecDeque::new();
queue.push_back(0);
while let Some(idx) = queue.pop_front() {
order.push(idx);
for child in self.children(idx) {
queue.push_back(child);
}
}
order
}
pub fn deepest_captcha(&self) -> Option<usize> {
self.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.has_captcha_marker)
.max_by_key(|(_, n)| n.depth)
.map(|(i, _)| i)
}
pub fn ancestors_inclusive(&self, mut node_idx: usize) -> Vec<usize> {
let mut out = Vec::new();
let mut visited = std::collections::HashSet::new();
while let Some(node) = self.nodes.get(node_idx) {
if !visited.insert(node_idx) {
break;
}
out.push(node_idx);
match node.parent {
Some(p) => node_idx = p,
None => break,
}
}
out
}
pub fn frames_by_host(&self) -> HashMap<String, Vec<usize>> {
let mut out: HashMap<String, Vec<usize>> = HashMap::new();
for (i, n) in self.nodes.iter().enumerate() {
if let Some(host) = url::Url::parse(&n.url)
.ok()
.and_then(|u| u.host_str().map(String::from))
{
out.entry(host).or_default().push(i);
}
}
out
}
}
#[derive(serde::Deserialize)]
struct FrameProbe {
url: String,
title: String,
has_captcha_marker: bool,
}
const PROBE_JS: &str = r#"({
url: location.href || '',
title: document.title || '',
has_captcha_marker: !!document.querySelector(
'iframe[src*="challenges.cloudflare.com"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"], '
+ 'iframe[src*="arkoselabs"], iframe[src*="datadome"], iframe[src*="geetest"], '
+ 'iframe[src*="perimeterx"], iframe[src*="kasada"], iframe[src*="incapsula"], '
+ '.cf-turnstile, .h-captcha, .g-recaptcha, '
+ '#challenge-form, #challenge-stage, #cf-please-wait, #px-captcha, '
+ '[id^="captcha"], [class*="captcha" i], [class*="challenge" i]'
)
})"#;
#[cfg(test)]
mod tests {
use super::*;
fn fixture_graph() -> FrameGraph {
FrameGraph {
nodes: vec![
FrameNode {
frame_id: None,
parent: None,
url: "(root)".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
},
FrameNode {
frame_id: Some("F1".into()),
parent: Some(0),
url: "https://example.com".into(),
title: "Main".into(),
has_captcha_marker: false,
depth: 1,
},
FrameNode {
frame_id: Some("F2".into()),
parent: Some(1),
url: "https://challenges.cloudflare.com/turnstile".into(),
title: String::new(),
has_captcha_marker: true,
depth: 2,
},
FrameNode {
frame_id: Some("F3".into()),
parent: Some(2),
url: "https://challenges.cloudflare.com/turnstile/inner".into(),
title: String::new(),
has_captcha_marker: true,
depth: 3,
},
FrameNode {
frame_id: Some("F4".into()),
parent: Some(1),
url: "https://example.com/sidebar".into(),
title: String::new(),
has_captcha_marker: false,
depth: 2,
},
],
}
}
#[test]
fn empty_graph_has_no_captcha_markers() {
let g = FrameGraph::default();
assert!(!g.any_captcha_marker());
assert!(g.bfs().is_empty());
assert!(g.deepest_captcha().is_none());
}
#[test]
fn any_captcha_marker_short_circuits_on_first_match() {
let g = fixture_graph();
assert!(g.any_captcha_marker());
}
#[test]
fn children_returns_all_direct_children_of_root() {
let g = fixture_graph();
let kids = g.children(0);
assert_eq!(kids, vec![1]);
}
#[test]
fn children_returns_all_direct_children_of_internal_node() {
let g = fixture_graph();
let kids = g.children(1);
assert_eq!(kids, vec![2, 4]);
}
#[test]
fn bfs_visits_root_first_then_each_level() {
let g = fixture_graph();
let order = g.bfs();
assert_eq!(order, vec![0, 1, 2, 4, 3]);
}
#[test]
fn deepest_captcha_finds_innermost_marker() {
let g = fixture_graph();
let deepest = g.deepest_captcha().expect("fixture has captcha markers");
assert_eq!(deepest, 3, "frame_aa is the deepest captcha-bearing node");
}
#[test]
fn ancestors_inclusive_walks_to_root_in_order() {
let g = fixture_graph();
let path = g.ancestors_inclusive(3);
assert_eq!(path, vec![3, 2, 1, 0]);
}
#[test]
fn ancestors_inclusive_handles_oob_index_gracefully() {
let g = fixture_graph();
assert!(g.ancestors_inclusive(999).is_empty());
}
#[test]
fn ancestors_inclusive_handles_root_node() {
let g = fixture_graph();
let path = g.ancestors_inclusive(0);
assert_eq!(path, vec![0]);
}
#[test]
fn frames_by_host_groups_correctly() {
let g = fixture_graph();
let hosts = g.frames_by_host();
assert_eq!(
hosts.get("example.com").map(|v| v.len()),
Some(2),
"main + sidebar both on example.com"
);
assert_eq!(
hosts.get("challenges.cloudflare.com").map(|v| v.len()),
Some(2),
"two CF turnstile frames"
);
}
#[test]
fn frames_by_host_skips_unparseable_urls() {
let g = fixture_graph();
let hosts = g.frames_by_host();
assert!(!hosts.contains_key("(root)"));
}
#[test]
fn children_leaf_node_returns_empty() {
let g = fixture_graph();
assert!(g.children(3).is_empty());
}
#[test]
fn children_oob_returns_empty() {
let g = fixture_graph();
assert!(g.children(999).is_empty());
}
#[test]
fn bfs_single_node() {
let g = FrameGraph {
nodes: vec![FrameNode {
frame_id: None,
parent: None,
url: "solo".into(),
title: String::new(),
has_captcha_marker: false,
depth: 0,
}],
};
assert_eq!(g.bfs(), vec![0]);
}
#[test]
fn bfs_linear_chain() {
let g = FrameGraph {
nodes: vec![
FrameNode { frame_id: Some("A".into()), parent: None, url: "a".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
FrameNode { frame_id: Some("B".into()), parent: Some(0), url: "b".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
FrameNode { frame_id: Some("C".into()), parent: Some(1), url: "c".into(), title: String::new(), has_captcha_marker: false, depth: 2 },
],
};
assert_eq!(g.bfs(), vec![0, 1, 2]);
}
#[test]
fn deepest_captcha_none_when_no_markers() {
let g = FrameGraph {
nodes: vec![
FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
FrameNode { frame_id: Some("A".into()), parent: Some(0), url: "a".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
],
};
assert!(g.deepest_captcha().is_none());
}
#[test]
fn deepest_captcha_prefers_last_at_same_depth() {
let g = FrameGraph {
nodes: vec![
FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
FrameNode { frame_id: Some("A".into()), parent: Some(0), url: "a".into(), title: String::new(), has_captcha_marker: true, depth: 1 },
FrameNode { frame_id: Some("B".into()), parent: Some(0), url: "b".into(), title: String::new(), has_captcha_marker: true, depth: 1 },
],
};
assert_eq!(g.deepest_captcha(), Some(2));
}
#[test]
fn ancestors_inclusive_orphaned_node_stops_at_root() {
let g = FrameGraph {
nodes: vec![
FrameNode { frame_id: None, parent: None, url: "root".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
FrameNode { frame_id: Some("orphan".into()), parent: Some(999), url: "orphan".into(), title: String::new(), has_captcha_marker: false, depth: 1 },
],
};
let path = g.ancestors_inclusive(1);
assert_eq!(path, vec![1]);
}
#[test]
fn frames_by_host_empty_graph() {
let g = FrameGraph::default();
assert!(g.frames_by_host().is_empty());
}
#[test]
fn frames_by_host_with_port() {
let g = FrameGraph {
nodes: vec![
FrameNode { frame_id: None, parent: None, url: "http://localhost:8080/path".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
],
};
let hosts = g.frames_by_host();
assert_eq!(hosts.get("localhost").map(|v| v.len()), Some(1));
}
#[test]
fn frames_by_host_ip_address() {
let g = FrameGraph {
nodes: vec![
FrameNode { frame_id: None, parent: None, url: "http://192.168.1.1/admin".into(), title: String::new(), has_captcha_marker: false, depth: 0 },
],
};
let hosts = g.frames_by_host();
assert_eq!(hosts.get("192.168.1.1").map(|v| v.len()), Some(1));
}
}