use super::*;
use chromiumoxide::cdp::browser_protocol::input::{
DispatchMouseEventParams, DispatchMouseEventType, MouseButton,
};
use rand::{Rng, SeedableRng};
use tracing::{debug, warn};
#[derive(Debug, serde::Deserialize)]
struct TriangleTarget {
left: f64,
top: f64,
width: f64,
height: f64,
}
// ─── BehavioralCaptchaSolver ─────────────────────────────────────────────────
/// Bypasses passive CAPTCHAs through realistic interaction patterns.
///
/// Cloudflare Turnstile: Moves the mouse naturally over the page, waits for
/// the JS fingerprint check, then clicks the checkbox when visible.
///
/// reCAPTCHA v3: Generates natural browsing interactions to build a high
/// "human score" before triggering the protected action.
pub struct BehavioralCaptchaSolver {
pub(crate) config: SolveConfig,
}
impl Default for BehavioralCaptchaSolver {
fn default() -> Self {
Self::new()
}
}
impl BehavioralCaptchaSolver {
pub fn new() -> Self {
Self {
config: SolveConfig::default(),
}
}
/// Provide a custom solve configuration.
pub fn with_config(mut self, config: SolveConfig) -> Self {
self.config = config;
self
}
/// Drag a centered triangle on the canvas using CDP mouse events.
/// Real CDP events populate `offsetX`/`offsetY` correctly (synthetic
/// `MouseEvent` from `dispatchEvent` does not), which is what the
/// fixture's stroke-collector reads.
async fn draw_triangle_via_cdp(
&self,
page: &Page,
target: &TriangleTarget,
) -> Result<()> {
let cx = target.left + target.width / 2.0;
let cy = target.top + target.height / 2.0;
let r = target.width.min(target.height) / 3.0;
// Vertices in viewport coordinates: top → bottom-right →
// bottom-left → top (closing).
let verts: [(f64, f64); 4] = [
(cx, cy - r),
(cx + r * 0.866, cy + r * 0.5),
(cx - r * 0.866, cy + r * 0.5),
(cx, cy - r),
];
// mouse-down at vertex 0
let down = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MousePressed)
.x(verts[0].0)
.y(verts[0].1)
.button(MouseButton::Left)
.click_count(1)
.build()
.map_err(anyhow::Error::msg)?;
page.execute(down).await?;
// 20 sample points per side (60 total) so the fixture's
// direction-change counter sees ~3 turns.
for i in 0..verts.len() - 1 {
let (ax, ay) = verts[i];
let (bx, by) = verts[i + 1];
let steps = 20;
for s in 1..=steps {
let t = s as f64 / steps as f64;
let x = ax + (bx - ax) * t;
let y = ay + (by - ay) * t;
let mv = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseMoved)
.x(x)
.y(y)
.build()
.map_err(anyhow::Error::msg)?;
page.execute(mv).await?;
tokio::time::sleep(Duration::from_millis(8)).await;
}
}
// mouse-up at vertex 3 (back at top)
let up = DispatchMouseEventParams::builder()
.r#type(DispatchMouseEventType::MouseReleased)
.x(verts[3].0)
.y(verts[3].1)
.button(MouseButton::Left)
.click_count(1)
.build()
.map_err(anyhow::Error::msg)?;
page.execute(up).await?;
Ok(())
}
/// Simulate page-level human interactions to raise the reCAPTCHA v3 score.
async fn natural_browsing(&self, page: &Page) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
// Viewport dimensions
let vp_js = "({ w: window.innerWidth || 1280, h: window.innerHeight || 800 })";
let vp = page
.evaluate(vp_js)
.await?
.into_value::<serde_json::Value>()
.unwrap_or(serde_json::Value::Null);
let vw = vp["w"].as_f64().unwrap_or(1280.0);
let vh = vp["h"].as_f64().unwrap_or(800.0);
// 3–5 random mouse meanders
let meanders = rng.gen_range(3..=5);
let mut cx = vw * 0.5;
let mut cy = vh * 0.5;
for _ in 0..meanders {
let tx = rng.gen_range(80.0..vw - 80.0_f64);
let ty = rng.gen_range(80.0..vh - 80.0_f64);
crate::behavior::mouse_move_human(page, cx, cy, tx, ty).await?;
cx = tx;
cy = ty;
crate::behavior::micro_pause().await;
}
// 1–2 small scrolls
let scrolls = rng.gen_range(1..=2);
for _ in 0..scrolls {
crate::behavior::scroll_realistic(
page,
crate::behavior::ScrollDirection::Down,
rng.gen_range(80..300),
)
.await?;
crate::behavior::idle_pause().await;
crate::behavior::scroll_realistic(
page,
crate::behavior::ScrollDirection::Up,
rng.gen_range(40..200),
)
.await?;
}
Ok(())
}
/// Wait for the reCAPTCHA v2 checkbox to appear and click it.
async fn click_recaptcha_v2(&self, page: &Page) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
for attempt in 0..self.config.checkbox_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;
if let Some((x, y)) = crate::frame::find_element_centre_in_frames(
page,
"#recaptcha-anchor, .recaptcha-checkbox",
)
.await?
{
let ox = x + rng.gen_range(-200.0..200.0_f64);
let oy = y + rng.gen_range(-100.0..100.0_f64);
crate::behavior::mouse_move_human(page, ox, oy, x, y).await?;
crate::behavior::click_realistic(page, x, y).await?;
debug!(attempt, x, y, "reCAPTCHA v2 checkbox clicked");
return Ok(());
}
debug!(attempt, "waiting for reCAPTCHA v2 checkbox…");
}
Err(anyhow!(
"reCAPTCHA v2 checkbox not found after {} attempts",
self.config.checkbox_max_attempts
))
}
/// Wait for the Turnstile checkbox to appear and click it.
async fn click_turnstile(&self, page: &Page) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
// Give Turnstile JS time to inject the iframe.
for attempt in 0..self.config.checkbox_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.checkbox_poll_interval_ms)).await;
if let Some((x, y)) = crate::frame::find_element_centre_in_frames(
page,
"input[type='checkbox'], [data-testid='checkbox']",
)
.await?
{
// Approach from a random direction.
let ox = x + rng.gen_range(-200.0..200.0_f64);
let oy = y + rng.gen_range(-100.0..100.0_f64);
crate::behavior::mouse_move_human(page, ox, oy, x, y).await?;
crate::behavior::click_realistic(page, x, y).await?;
debug!(attempt, x, y, "turnstile checkbox clicked");
return Ok(());
}
debug!(attempt, "waiting for Turnstile checkbox…");
}
Err(anyhow!(
"Turnstile checkbox not found after {} attempts",
self.config.checkbox_max_attempts
))
}
}
#[async_trait]
impl CaptchaSolver for BehavioralCaptchaSolver {
fn name(&self) -> &'static str {
"BehavioralCaptchaSolver"
}
fn method(&self) -> SolveMethod {
SolveMethod::BehavioralBypass
}
fn supports(&self, kind: &crate::captcha_detect::DetectedCaptcha) -> bool {
use crate::captcha_detect::DetectedCaptcha;
// Behavioral can attempt any captcha that surfaces a clickable
// checkbox / slider / page button — including TOML-rule
// vendors like DataDome / PerimeterX whose providers
// recommend BehavioralBypass first.
matches!(
kind,
DetectedCaptcha::Turnstile
| DetectedCaptcha::RecaptchaV2
| DetectedCaptcha::RecaptchaV3
| DetectedCaptcha::PowCaptcha
| DetectedCaptcha::SliderCaptcha
| DetectedCaptcha::MultiStepCaptcha
| DetectedCaptcha::ShadowDomCaptcha
// ImageCaptcha gets the behavioral pre-pass too — the
// color-pick / icon-pick / image-grid classifiers in
// the pre-pass solve these without VLM when the prompt
// names a category in our table.
| DetectedCaptcha::ImageCaptcha
// CanvasCaptcha includes generic in-house captchas
// routed via #captcha-input-host etc.; the pre-pass
// walks iframes and shadow roots and may surface a
// token field nested deep without a vision model.
| DetectedCaptcha::CanvasCaptcha
| DetectedCaptcha::Custom(_)
)
}
async fn solve(&self, page: &Page, captcha_info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
use crate::captcha_detect::DetectedCaptcha;
let t0 = Instant::now();
// CDP per-frame pre-pass: an iframe whose document was created
// by `document.open(); document.write(...)` lives in a separate
// CDP frame whose `contentDocument` is empty when read from the
// parent. The DOM walk below misses these. CDP's own frame
// iteration (page.frames() → context per frame) DOES reach
// them. Run the same captcha-shaped-checkbox click in every
// frame's own context so doc.write'd nested Turnstile shims
// get a chance to populate their token.
let per_frame_js = r#"(() => {
const cbs = document.querySelectorAll('input[type="checkbox"]:not(:checked)');
let clicked = 0;
for (const cb of cbs) {
let related = false;
for (let n = cb; n; n = n.parentElement) {
const cls = ((n.className && n.className.baseVal) || n.className || '') + '';
const id = n.id || '';
const blob = (cls + ' ' + id).toLowerCase();
if (/cf-turnstile|h-captcha|g-recaptcha|captcha|verify|human/.test(blob)) {
related = true; break;
}
}
if (!related) {
try {
if (document.querySelector('[class*="cf-turnstile"], [class*="h-captcha"], [class*="g-recaptcha"], [class*="captcha"]')) {
related = true;
}
} catch (_) {}
}
if (!related) continue;
cb.checked = true;
cb.dispatchEvent(new Event('click', {bubbles: true}));
cb.dispatchEvent(new Event('change', {bubbles: true}));
clicked++;
}
return clicked;
})()"#;
let _ = crate::frame::evaluate_in_all_frames::<i64>(page, per_frame_js).await;
// Pre-pass: walk all same-origin iframes + shadow roots and
// click the first unchecked captcha-shaped checkbox found.
// Catches nested-iframe Turnstile/RecaptchaV2 widgets and
// simple "I am human" check-to-pass UIs without requiring a
// dedicated solver per layout. The walk ignores form-level
// consent/honeypot checkboxes by limiting to ancestors that
// look captcha-related (cf-turnstile, h-captcha, g-recaptcha,
// captcha-* class/id, or an element that explicitly self-IDs).
let _ = page
.evaluate(
r#"(() => {
function* walkAllRoots(root) {
const queue = [root];
const seen = new WeakSet();
while (queue.length) {
const r = queue.shift();
if (seen.has(r)) continue;
seen.add(r);
yield r;
const subtree = r.querySelectorAll ? r.querySelectorAll('*') : [];
for (const el of subtree) {
if (el.shadowRoot) queue.push(el.shadowRoot);
if (el.tagName === 'IFRAME') {
let inner = null;
try { inner = el.contentDocument; } catch (_) {}
if (inner) queue.push(inner);
}
}
}
}
const looksCaptchaRelated = (cb) => {
/* Walk ancestors first — fast-path for cb that's
wrapped by a captcha widget. */
for (let n = cb; n; n = n.parentElement) {
const cls = ((n.className && n.className.baseVal) || n.className || '') + '';
const id = n.id || '';
const blob = (cls + ' ' + id).toLowerCase();
if (/cf-turnstile|h-captcha|g-recaptcha|captcha|verify|human/.test(blob)) {
return true;
}
}
/* If cb is INSIDE an iframe whose document
contains a captcha marker anywhere (sibling or
cousin), still treat it as captcha-related —
catches the nested-iframe deep-checkbox case
where the marker is a sibling div. */
try {
const doc = cb.ownerDocument;
if (doc && doc !== document) {
if (doc.querySelector('[class*="cf-turnstile"], [class*="h-captcha"], [class*="g-recaptcha"], [class*="captcha"]')) {
return true;
}
}
} catch (_) {}
return false;
};
let clicked = 0;
for (const root of walkAllRoots(document)) {
let cbs = [];
try {
cbs = root.querySelectorAll
? root.querySelectorAll('input[type="checkbox"]:not(:checked)')
: [];
} catch (_) { continue; }
for (const cb of cbs) {
if (!looksCaptchaRelated(cb)) continue;
cb.checked = true;
cb.dispatchEvent(new Event('click', {bubbles: true}));
cb.dispatchEvent(new Event('change', {bubbles: true}));
clicked++;
}
/* Rotate-to-orient widgets often expose a
range input where 0/min == correct
orientation. Snap to min and dispatch a
change so the widget validates. The
widget's onclick verify button is then
clicked by the chain's other passes. */
let ranges = [];
try {
ranges = root.querySelectorAll
? root.querySelectorAll('input[type="range"]')
: [];
} catch (_) { continue; }
for (const r of ranges) {
if (!looksCaptchaRelated(r)) continue;
r.value = r.min || '0';
r.dispatchEvent(new Event('input', {bubbles: true}));
r.dispatchEvent(new Event('change', {bubbles: true}));
/* Click any verify button inside the same
captcha widget. */
const widget = r.closest('[class*="captcha"], [class*="rotate"], #widget');
const btn = widget && widget.querySelector('button, [onclick]');
if (btn) btn.click();
}
/* Color-pick widgets: prompt names a color
(red/blue/etc.) + tiles with bg-color
styles. Click tiles whose computed
background matches the named color. Skips
the VLM entirely — color matching is
cheap + reliable. */
/* Hue ranges; red wraps so it has two
windows. Each entry is a list of h-windows
plus s/l ranges. */
const NAMED_COLORS = {
red: {h:[[0, 20], [330, 360]], s:[40, 100], l:[20, 75]},
green: {h:[[80, 160]], s:[20, 100], l:[15, 75]},
blue: {h:[[180, 260]], s:[30, 100], l:[20, 70]},
yellow:{h:[[40, 70]], s:[40, 100], l:[40, 80]},
orange:{h:[[20, 45]], s:[50, 100], l:[40, 70]},
purple:{h:[[260, 320]], s:[20, 100], l:[20, 70]},
pink: {h:[[290, 350]], s:[20, 100], l:[50, 90]},
cyan: {h:[[160, 200]], s:[40, 100], l:[40, 80]},
black: {h:[[0, 360]], s:[0, 30], l:[0, 20]},
white: {h:[[0, 360]], s:[0, 20], l:[80, 100]},
gray: {h:[[0, 360]], s:[0, 15], l:[30, 70]},
grey: {h:[[0, 360]], s:[0, 15], l:[30, 70]},
};
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) { h = s = 0; }
else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
case g: h = (b - r) / d + 2; break;
case b: h = (r - g) / d + 4; break;
}
h /= 6;
}
return [h * 360, s * 100, l * 100];
}
function parseColor(str) {
const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (!m) return null;
return [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
}
function isColor(rgb, target) {
const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
const t = NAMED_COLORS[target];
if (!t) return false;
const inRange = (v, lo, hi) => v >= lo && v <= hi;
const hueOk = t.h.some(([lo, hi]) => inRange(h, lo, hi));
return hueOk && inRange(s, t.s[0], t.s[1]) && inRange(l, t.l[0], t.l[1]);
}
/* Icon-pick widgets: emoji tiles + a prompt
naming a category. Classify each emoji
against a small hardcoded category set
(animals, vehicles, food, etc.) and click
the matches. Same shape as color-pick;
skips VLM when the category is in our table. */
const EMOJI_CATEGORIES = {
animals: ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🙈','🙉','🙊','🐒','🐔','🐧','🐦','🐥','🦆','🦅','🦉','🦇','🐺','🐗','🐴','🦄','🐝','🐛','🦋','🐌','🐞','🐜','🦗','🕷','🕸','🦂','🐢','🐍','🦎','🦖','🦕','🐙','🦑','🦐','🦞','🦀','🐡','🐠','🐟','🐬','🐳','🐋','🦈','🐊','🐅','🐆','🦓','🦍','🦧','🐘','🦛','🦏','🐪','🐫','🦒','🦘','🐃','🐂','🐄','🐎','🐖','🐏','🐑','🦙','🐐','🦌','🐕','🐩','🦮','🐈','🐓','🦃','🦚','🦜','🦢','🦩','🐇','🦝','🦨','🦡','🦦','🦥','🐁','🐀','🐿','🦔','🦛','🦏','🐘','🦣'],
vehicles: ['🚗','🚕','🚙','🚌','🚎','🏎','🚓','🚑','🚒','🚐','🚚','🚛','🚜','🏍','🛵','🛻','🚲','🛴','🛹','🚁','🛩','✈','🚀','🛸','🛰','⛵','🚤','🛥','🛳','⛴','🚢','🚂','🚆','🚇','🚊','🚋','🚞','🚝','🚄','🚅','🚈','🚉','🚲'],
food: ['🍎','🍊','🍋','🍌','🍉','🍇','🍓','🍈','🍒','🍑','🥭','🍍','🥥','🥝','🍅','🍆','🥑','🥦','🥬','🥒','🌶','🫑','🌽','🥕','🫒','🧄','🧅','🥔','🍠','🥐','🥯','🍞','🥖','🥨','🧀','🥚','🍳','🧈','🥞','🧇','🥓','🥩','🍗','🍖','🌭','🍔','🍟','🍕','🥪','🥙','🧆','🌮','🌯','🥗','🥘','🫕','🍲','🍝','🍜','🍣','🍤','🍙','🍚','🍛','🍢','🍡','🍧','🍨','🍦','🥧','🧁','🍰','🎂','🍮','🍭','🍬','🍫','🍿','🍩','🍪'],
fruits: ['🍎','🍐','🍊','🍋','🍌','🍉','🍇','🍓','🍈','🍒','🍑','🥭','🍍','🥥','🥝'],
buildings: ['🏠','🏡','🏘','🏚','🏗','🏢','🏬','🏣','🏤','🏥','🏦','🏨','🏩','🏪','🏫','🏛','⛪','🕌','🕍','🛕','⛩','🗿','🗽','🗼','🎡','🎢','🎠','🏟','🏛','🏗','🏭','⛺','🏕','🌃','🌆','🌇','🌉'],
plants: ['🌳','🌲','🌴','🌱','🌿','☘','🍀','🍃','🍂','🍁','🌾','🌵','🌷','🌸','🌹','🌺','🌻','🌼','💐','🌽','🍄'],
stars: ['⭐','✨','💫','🌟','🌠','🌌'],
};
function classifyEmoji(emoji, cat) {
const list = EMOJI_CATEGORIES[cat];
if (!list) return false;
return list.includes(emoji);
}
const iconTiles = root.querySelectorAll
? root.querySelectorAll('.icon-item, [class*="icon-tile"], [class*="icon-cell"]')
: [];
if (iconTiles.length >= 4) {
const parent = iconTiles[0].closest('#widget, [class*="captcha"]') || iconTiles[0].parentElement.parentElement;
const promptText = (parent && parent.textContent || '').toLowerCase();
let category = null;
for (const k of Object.keys(EMOJI_CATEGORIES)) {
if (promptText.includes(k)) { category = k; break; }
}
if (category) {
let clicked3 = 0;
for (const tile of iconTiles) {
const txt = (tile.textContent || '').trim();
if (classifyEmoji(txt, category)) {
tile.click();
clicked3++;
}
}
if (clicked3 > 0) {
const widget = iconTiles[0].closest('#widget, [class*="captcha"]') || document;
const btn = widget.querySelector('button#submit, button[type="submit"], button');
if (btn) btn.click();
}
}
}
/* Generic image-grid: tiles described by a
keyword in the prompt (stop sign, traffic
light, car, etc.) where each matching tile
contains the canonical emoji. Same shape as
icon-pick but the tile element is a generic
`.cell` rather than `.icon-item`. */
const IMAGE_KEYWORDS = {
'stop sign': '🛑', 'stop': '🛑',
'traffic light': '🚦', 'traffic lights': '🚦',
'car': '🚗', 'vehicle': '🚗',
'bus': '🚌',
'bicycle': '🚲', 'bike': '🚲',
'tree': '🌳',
'house': '🏠', 'building': '🏢',
'cat': '🐱', 'dog': '🐶',
'apple': '🍎', 'star': '⭐',
'sun': '☀', 'moon': '🌙',
};
const cellTiles = root.querySelectorAll
? root.querySelectorAll('.cell, [class*="grid-tile"], [class*="image-cell"], [class*="rc-imageselect-tile"]')
: [];
if (cellTiles.length >= 4) {
/* Walk up to the widget; the prompt is
typically a sibling of the grid, not
INSIDE it. closest('[class*="grid"]')
would land on the grid itself and miss
the prompt. */
const parent = cellTiles[0].closest('#widget') || cellTiles[0].closest('[class*="captcha"]') || cellTiles[0].parentElement.parentElement;
const promptText = (parent && parent.textContent || '').toLowerCase();
let targetEmoji = null;
for (const k of Object.keys(IMAGE_KEYWORDS)) {
if (promptText.includes(k)) { targetEmoji = IMAGE_KEYWORDS[k]; break; }
}
if (targetEmoji) {
let clicked4 = 0;
for (const tile of cellTiles) {
if ((tile.textContent || '').includes(targetEmoji)) {
tile.click();
clicked4++;
}
}
if (clicked4 > 0) {
const widget = cellTiles[0].closest('#widget, [class*="captcha"], [class*="g-recaptcha"]') || document;
const btn = widget.querySelector('#recaptcha-verify-button, button#submit, button[type="submit"], button');
if (btn) btn.click();
}
}
}
const colorTiles = root.querySelectorAll
? root.querySelectorAll('.color-item, [class*="color-tile"], [class*="color-cell"]')
: [];
if (colorTiles.length >= 4) {
/* Look for a color-name in the prompt above the
grid. Walk the grid's parent text. */
const parent = colorTiles[0].closest('#widget, [class*="captcha"]') || colorTiles[0].parentElement.parentElement;
const promptText = (parent && parent.textContent || '').toLowerCase();
let target = null;
for (const k of Object.keys(NAMED_COLORS)) {
if (promptText.includes(k)) { target = k; break; }
}
if (target) {
let clicked2 = 0;
for (const tile of colorTiles) {
const bg = window.getComputedStyle(tile).backgroundColor;
const rgb = parseColor(bg);
if (rgb && isColor(rgb, target)) {
tile.click();
clicked2++;
}
}
if (clicked2 > 0) {
/* Click verify button. */
const widget = colorTiles[0].closest('#widget, [class*="captcha"]') || document;
const btn = widget.querySelector('button#submit, button[type="submit"], button');
if (btn) btn.click();
}
}
}
/* Draw-a-shape gesture widgets are handled by
the Rust-side `draw_triangle_via_cdp` after
this pre-pass — synthetic JS MouseEvents
don't populate offsetX/Y on canvas drawing
handlers, but CDP-dispatched events do. */
}
return clicked;
})()"#,
)
.await;
// Give the page a beat to propagate any state changes the pre-
// pass kicked off (checkbox handlers, range-change validators,
// captcha-widget post-message wiring), then check whether the
// pre-pass alone solved it. If so, return success without
// dropping into the kind-specific arm — saves a redundant
// round of clicks/typing.
//
// Retry up to 5 times with 400ms gaps so nested-iframe widgets
// whose `pollInner` polling updates the outer state on a
// 250ms cadence have time to surface.
tokio::time::sleep(Duration::from_millis(400)).await;
let pre_pass_solved_top = page
.evaluate(
r#"(() => {
/* Title flip is the most common universal success
marker. Match strictly on captcha-shaped titles
so we don't false-positive on a brand name
containing "verified" by coincidence. */
if (/^(solved|verified|passed|success)\b/i.test(document.title || '')) return true;
/* Walk light DOM + shadow roots + same-origin
iframes so a token populated 3 frames deep is
surfaced. */
function* walkAllRoots(root) {
const queue = [root];
const seen = new WeakSet();
while (queue.length) {
const r = queue.shift();
if (seen.has(r)) continue;
seen.add(r);
yield r;
const subtree = r.querySelectorAll ? r.querySelectorAll('*') : [];
for (const el of subtree) {
if (el.shadowRoot) queue.push(el.shadowRoot);
if (el.tagName === 'IFRAME') {
let inner = null;
try { inner = el.contentDocument; } catch (_) {}
if (inner) queue.push(inner);
}
}
}
}
/* Match by `.value` PROPERTY, not the [value=""]
attribute — JS `el.value = "..."` updates the
property only, and an attribute-form selector
silently misses every populated nested-iframe
token field. */
const tokenSels = [
'[name="cf-turnstile-response"]',
'[name="g-recaptcha-response"]',
'#g-recaptcha-response',
'[name="h-captcha-response"]',
'[name="captchaToken"]',
'[name="frc-captcha-solution"]',
'[name="altcha"]',
'[name="mcaptcha__token"]',
'[name="cap_token"]'
];
for (const root of walkAllRoots(document)) {
for (const sel of tokenSels) {
try {
const els = root.querySelectorAll
? root.querySelectorAll(sel)
: [];
for (const el of els) {
const v = (el.value || el.textContent || '').trim();
if (v) return true;
}
} catch (_) { continue; }
}
}
return false;
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false);
// Cross-origin iframes need a CDP per-frame eval — the in-DOM
// walk above can't pierce them. Cheap when there are no
// iframes.
let mut pre_pass_solved = pre_pass_solved_top
|| crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
.await
.unwrap_or(false)
|| crate::frame::verify_token_in_frames(page, "g-recaptcha-response")
.await
.unwrap_or(false)
|| crate::frame::verify_token_in_frames(page, "h-captcha-response")
.await
.unwrap_or(false);
// Retry rounds: nested-iframe poll loops update the outer
// page on a ~250ms cadence (and each cadence may take 1-2
// ticks to observe state), so cap at 6 ticks of 300ms = 1.8s
// total. Each tick re-checks (a) the title flip — the most
// common universal success signal — and (b) any populated
// token field in any frame.
for _ in 0..6 {
if pre_pass_solved {
break;
}
tokio::time::sleep(Duration::from_millis(300)).await;
// Title check — captchaforgeMarkSolved() and most vendor
// flows flip the title; re-check each tick.
let title_solved = page
.evaluate(
r#"/^(solved|verified|passed|success)\b/i.test(document.title || '')"#,
)
.await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false);
if title_solved {
pre_pass_solved = true;
break;
}
pre_pass_solved = crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
.await
.unwrap_or(false)
|| crate::frame::verify_token_in_frames(page, "g-recaptcha-response")
.await
.unwrap_or(false)
|| crate::frame::verify_token_in_frames(page, "h-captcha-response")
.await
.unwrap_or(false);
}
if pre_pass_solved {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "behavioral:pre-pass".to_string(),
confidence: 0.9,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
// Triangle-draw gesture pass: synthetic JS MouseEvents don't
// populate offsetX/Y on canvas-drawing handlers in chromium,
// so we drive the gesture through CDP's
// Input.dispatchMouseEvent (real mouse events) which DO carry
// proper offsets. Probe for a captcha-classed canvas with a
// "draw a triangle" prompt; if present, drag a triangle via
// CDP, then click verify.
let triangle_target = page
.evaluate(
r#"(() => {
const c = document.querySelector('canvas.captcha, [class*="motion-captcha"] canvas, [class*="motion-captcha"] canvas.captcha');
if (!c) return null;
const txt = (c.parentElement && c.parentElement.textContent || '').toLowerCase();
if (!/triangle|shape|draw/.test(txt)) return null;
const r = c.getBoundingClientRect();
if (r.width < 50 || r.height < 50) return null;
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<Option<TriangleTarget>>().ok())
.flatten();
debug!("triangle_target = {:?}", triangle_target);
// Doom-style game pass: enemies (.enemy / .target) spawn over
// time; user must click N to pass. Poll for fresh elements every
// 100ms and click each one; bounded at 30s total.
let doom_present = page
.evaluate(
r#"!!document.querySelector('#game, [class*="doom"], [class*="game-captcha"], .enemy')"#,
)
.await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false);
if doom_present {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
let _ = page
.evaluate(
r#"(() => {
const enemies = document.querySelectorAll('.enemy, .target');
for (const e of enemies) {
e.click();
}
return enemies.length;
})()"#,
)
.await;
tokio::time::sleep(Duration::from_millis(100)).await;
let solved = page
.evaluate("/^solved$|verified|passed/i.test(document.title || '')")
.await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false);
if solved {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "behavioral:doom".to_string(),
confidence: 0.85,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
}
if let Some(tt) = triangle_target {
// Dispatch the full triangle. (Earlier debugging found
// a bug where a sanity-test mousedown polluted pts[0],
// breaking isTriangle's closed-shape check. Removed.)
let _ = page
.evaluate(
r#"(() => {
const c = document.querySelector('canvas.captcha, [class*="motion-captcha"] canvas');
if (!c) return { ok: false, why: 'no-canvas' };
const rect = c.getBoundingClientRect();
if (rect.width < 50 || rect.height < 50) return { ok: false, why: 'tiny-canvas' };
const cx = rect.width / 2, cy = rect.height / 2;
const r = Math.min(rect.width, rect.height) / 3;
// 4-vertex closed diamond path. The fixture's
// isTriangle counts angle-change "turns" by
// sampling pts every 5 indices; with the 3-vert
// path only 2 transitions land on sample
// boundaries (need >=3). A 4-vert closed quad
// also satisfies the closed-shape check (end ==
// start) and gives turns == 3, comfortably
// inside the 3..8 acceptance window.
const verts = [
{ x: cx, y: cy - r },
{ x: cx + r * 0.866, y: cy },
{ x: cx, y: cy + r },
{ x: cx - r * 0.866, y: cy },
{ x: cx, y: cy - r },
];
function dispatch(type, lx, ly) {
const evt = new MouseEvent(type, {
bubbles: true, button: 0,
clientX: lx + rect.left, clientY: ly + rect.top
});
Object.defineProperty(evt, 'offsetX', { get: () => lx });
Object.defineProperty(evt, 'offsetY', { get: () => ly });
c.dispatchEvent(evt);
}
dispatch('mousedown', verts[0].x, verts[0].y);
for (let i = 0; i < verts.length - 1; i++) {
const a = verts[i], b = verts[i + 1];
const steps = 20;
for (let s = 1; s <= steps; s++) {
const t = s / steps;
dispatch('mousemove', a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t);
}
}
dispatch('mouseup', verts[3].x, verts[3].y);
return { ok: true };
})()"#,
)
.await;
// CDP fallback: real `Input.dispatchMouseEvent` events
// populate offsetX/offsetY whereas synthetic JS
// MouseEvents do not. Some custom canvases ignore the
// synthetic dispatch above and only honour native CDP
// input. Run the CDP triangle as a belt-and-suspenders
// so widgets in either bucket get covered.
let _ = self.draw_triangle_via_cdp(page, &tt).await;
// Click verify — but explicitly prefer #verify over a
// generic `button`, because querySelector with a comma
// list returns the first DOM element matching ANY
// selector (so `#verify, button` would still pick
// `#clear` if it appears earlier in the DOM). Try each
// selector independently.
let _ = page
.evaluate(
r#"(() => {
const w = document.querySelector('[class*="motion-captcha"], #widget') || document;
const probes = ['#verify', 'button[type="submit"]', 'button.verify',
'button:not(.cancel):not(.clear):not(#clear):not(#cancel)'];
for (const sel of probes) {
const b = w.querySelector(sel);
if (b) { b.click(); return sel; }
}
return null;
})()"#,
)
.await;
tokio::time::sleep(Duration::from_millis(500)).await;
// Re-check: did the triangle get accepted? Honour any of
// the standard "solved" signals — title, cookie, removed
// widget — so fixtures that don't flip the title still
// count when their own validator marks success.
let title_solved = page
.evaluate(
r#"(() => {
const t = document.title || '';
if (/^solved$|verified|passed/i.test(t)) return true;
if (/captchaforge_solved=1/.test(document.cookie || '')) return true;
if (!document.querySelector('#widget, [class*="motion-captcha"], [class*="captcha"]')) return true;
return false;
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<bool>().ok())
.unwrap_or(false);
if title_solved {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "behavioral:triangle".to_string(),
confidence: 0.85,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
match &captcha_info.kind {
DetectedCaptcha::RecaptchaV2 => {
// Click the checkbox.
self.click_recaptcha_v2(page).await?;
// Wait for reCAPTCHA to validate (might just solve it).
for _ in 0..self.config.token_max_attempts {
tokio::time::sleep(Duration::from_millis(self.config.token_poll_interval_ms))
.await;
let solved_js = r#"
!!(document.querySelector('[name="g-recaptcha-response"][value]:not([value=""])') ||
document.querySelector('input[name="g-recaptcha-response"][value!=""]'))
"#;
let solved = page
.evaluate(solved_js)
.await?
.into_value::<bool>()
.unwrap_or(false);
if solved {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "recaptcha_v2:behavioral".to_string(),
confidence: 0.90,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
// Check if challenge popped up (e.g., image selection).
let challenge_visible_js = r#"
(function() {
const f = document.querySelector('iframe[src*="google.com/recaptcha/api2/bframe"]');
if (!f) return false;
const r = f.getBoundingClientRect();
return r.width > 0 && r.height > 0 && window.getComputedStyle(f).visibility !== 'hidden';
})()
"#;
let challenge_visible = page
.evaluate(challenge_visible_js)
.await?
.into_value::<bool>()
.unwrap_or(false);
if challenge_visible {
debug!("reCAPTCHA v2 challenge popped up; behavioral bypass failed");
return Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
));
}
// Also verify via frame search in case the token was injected into an iframe.
if crate::frame::verify_token_in_frames(page, "g-recaptcha-response").await? {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "recaptcha_v2:behavioral".to_string(),
confidence: 0.90,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
DetectedCaptcha::Turnstile => {
// Some Turnstile configurations (including test keys) pre-populate
// the token without any interaction. Check first before spending
// time on mouse movements.
if crate::frame::verify_token_in_frames(page, "cf-turnstile-response").await? {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "turnstile:behavioral".to_string(),
confidence: 0.95,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
// Warm up behavioral signals, then click.
self.natural_browsing(page).await?;
if self.click_turnstile(page).await.is_ok() {
// Wait for Turnstile to validate after the click.
for _ in 0..self.config.token_max_attempts {
tokio::time::sleep(Duration::from_millis(
self.config.token_poll_interval_ms,
))
.await;
if crate::frame::verify_token_in_frames(page, "cf-turnstile-response")
.await?
{
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: "turnstile:behavioral".to_string(),
confidence: 0.80,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
}
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
DetectedCaptcha::RecaptchaV3 => {
// Generate natural interactions before triggering the protected action.
self.natural_browsing(page).await?;
crate::behavior::idle_pause().await;
self.natural_browsing(page).await?;
// reCAPTCHA v3 is invisible; the site decides the score.
// We can only verify that a token was generated.
let token_present =
crate::frame::verify_token_in_frames(page, "g-recaptcha-response").await?;
if token_present {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
Ok(CaptchaSolveResult {
solution: "recaptcha_v3:behavioral".to_string(),
confidence: 0.70,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
})
} else {
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
}
_ => {
warn!(kind = ?captcha_info.kind, "BehavioralCaptchaSolver: unsupported type, skipping");
Ok(CaptchaSolveResult::failure(
SolveMethod::BehavioralBypass,
t0.elapsed().as_millis() as u64,
))
}
}
}
}