use anyhow::Result;
use chromiumoxide::Page;
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 Some(ctx) = page.frame_execution_context(fid.clone()).await? {
use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
let probe = EvaluateParams::builder()
.expression(PROBE_JS)
.context_id(ctx)
.build()
.ok();
if let Some(p) = probe {
if let Ok(eval) = page.evaluate_expression(p).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)"));
}
}