use super::*;
use rand::{Rng, SeedableRng};
use tracing::{debug, warn};
// âââ Checkbox-geometry fallbacks âââââââââââââââââââââââââââââââââââââââââââââ
//
// When BiDi can't enumerate a cross-origin captcha iframe's execution context,
// the click_* methods fall back to a click at the checkbox's well-known offset
// from the iframe's top-left (in CSS pixels). These offsets are vendor-protocol
// facts, measured against each vendor's standard anchor-iframe layout, single
// source of truth so a future widget-revision update has exactly one place to
// change (and the test `behavioral_turnstile_offset_matches_vendor_canonical`
// keeps the Turnstile pair in lockstep with the vendor solver's constant).
//
// Turnstile reuses the canonical `CHECKBOX_OFFSET_X/Y` from
// `vendors::turnstile_interactive` (brought in via `use super::*`) rather than
// re-literalising 28.0/32.0 here.
/// reCAPTCHA v2 anchor-checkbox centre X offset from the iframe top-left, CSS
/// px. Measured against the standard 304Ã78 `api2/anchor` iframe.
const RECAPTCHA_ANCHOR_CHECKBOX_OFFSET_X: f64 = 28.0;
/// reCAPTCHA v2 anchor-checkbox centre Y offset.
const RECAPTCHA_ANCHOR_CHECKBOX_OFFSET_Y: f64 = 28.0;
/// hCaptcha checkbox centre X offset from the iframe top-left, CSS px. Measured
/// against the standard â303Ã76 `captcha/v1` checkbox iframe.
const HCAPTCHA_CHECKBOX_OFFSET_X: f64 = 28.0;
/// hCaptcha checkbox centre Y offset.
const HCAPTCHA_CHECKBOX_OFFSET_Y: f64 = 28.0;
#[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 BiDi mouse events.
/// BiDi pointer actions populate `offsetX`/`offsetY` correctly,
/// which is what the fixture's stroke-collector reads.
async fn draw_triangle_via_bidi(&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
page.mouse_down(verts[0].0, verts[0].1).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;
page.mouse_move_human(ax, ay, x, y).await?;
tokio::time::sleep(Duration::from_millis(8)).await;
}
}
// mouse-up at vertex 3 (back at top)
page.mouse_up(verts[3].0, verts[3].1).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();
let interval = Duration::from_millis(self.config.checkbox_poll_interval_ms);
let timeout = interval.saturating_mul(self.config.checkbox_max_attempts);
if let Some((x, y)) = crate::frame::find_element_centre_in_frames_retry(
page,
"#recaptcha-anchor, .recaptcha-checkbox",
timeout,
interval,
)
.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!(x, y, "reCAPTCHA v2 checkbox clicked");
return Ok(());
}
// Fallback: cross-origin iframe contents are opaque to parent-page
// JS and sometimes to BiDi frame enumeration. The checkbox sits at
// a well-known offset inside the standard 304Ã78 anchor iframe.
if let Some((left, top, _w, _h)) =
crate::frame::find_iframe_rect_by_src(page, "google.com/recaptcha/api2/anchor").await?
{
let target_x = left + RECAPTCHA_ANCHOR_CHECKBOX_OFFSET_X + rng.gen_range(-3.0..3.0);
let target_y = top + RECAPTCHA_ANCHOR_CHECKBOX_OFFSET_Y + rng.gen_range(-3.0..3.0);
let ox = target_x + rng.gen_range(-200.0..200.0_f64);
let oy = target_y + rng.gen_range(-100.0..100.0_f64);
crate::behavior::mouse_move_human(page, ox, oy, target_x, target_y).await?;
crate::behavior::click_realistic(page, target_x, target_y).await?;
// Law 10: precise BiDi per-frame find failed (cross-origin iframe opaque
// to frame enumeration); we degraded to a HARDCODED checkbox offset inside
// the *assumed* 304Ã78 anchor iframe. If the live iframe differs from that
// assumed geometry the click misses the checkbox and the solve fails for a
// reason the operator can't see at debug (surface the degraded path loudly).
warn!(
target_x,
target_y,
"reCAPTCHA v2: clicked via iframe-GEOMETRY fallback (BiDi frame-enum failed); \
click landed on the assumed standard offset, not a verified element centre. \
may miss if the iframe geometry differs"
);
return Ok(());
}
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.
let interval = Duration::from_millis(self.config.checkbox_poll_interval_ms);
let timeout = interval.saturating_mul(self.config.checkbox_max_attempts);
if let Some((x, y)) = crate::frame::find_element_centre_in_frames_retry(
page,
"input[type='checkbox'], [data-testid='checkbox']",
timeout,
interval,
)
.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!(x, y, "turnstile checkbox clicked");
return Ok(());
}
// Fallback: when the iframe is present but BiDi can't enumerate its
// execution context, click at the known checkbox offset inside the
// standard 300Ã65 Turnstile iframe.
if let Some((left, top, _w, _h)) = crate::frame::find_iframe_rect_by_src(
page,
"challenges.cloudflare.com/cdn-cgi/challenge-platform",
)
.await?
{
let target_x = left + CHECKBOX_OFFSET_X + rng.gen_range(-3.0..3.0);
let target_y = top + CHECKBOX_OFFSET_Y + rng.gen_range(-3.0..3.0);
let ox = target_x + rng.gen_range(-200.0..200.0_f64);
let oy = target_y + rng.gen_range(-100.0..100.0_f64);
crate::behavior::mouse_move_human(page, ox, oy, target_x, target_y).await?;
crate::behavior::click_realistic(page, target_x, target_y).await?;
// Law 10: precise BiDi per-frame find failed (cross-origin iframe opaque
// to frame enumeration); we degraded to the canonical CHECKBOX_OFFSET_*
// inside the *assumed* 300Ã65 Turnstile iframe. If the live iframe differs from that
// assumed geometry the click misses the checkbox and the solve fails for a
// reason the operator can't see at debug (surface the degraded path loudly).
warn!(
target_x,
target_y,
"turnstile: clicked via iframe-GEOMETRY fallback (BiDi frame-enum failed); \
click landed on the assumed standard offset, not a verified element centre. \
may miss if the iframe geometry differs"
);
return Ok(());
}
Err(anyhow!(
"Turnstile checkbox not found after {} attempts",
self.config.checkbox_max_attempts
))
}
/// Wait for the hCaptcha checkbox to appear and click it.
async fn click_hcaptcha(&self, page: &Page) -> Result<()> {
let mut rng = rand::rngs::StdRng::from_entropy();
let interval = Duration::from_millis(self.config.checkbox_poll_interval_ms);
let timeout = interval.saturating_mul(self.config.checkbox_max_attempts);
// hCaptcha's checkbox iframe contains a #checkbox element or an
// input[type="checkbox"]. The iframe is cross-origin so we use
// the frame-piercing helper.
if let Some((x, y)) = crate::frame::find_element_centre_in_frames_retry(
page,
"#checkbox, input[type='checkbox'], .checkbox, #hcaptcha-checkbox",
timeout,
interval,
)
.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!(x, y, "hCaptcha checkbox clicked");
return Ok(());
}
// Fallback: when BiDi can't enumerate the iframe's execution
// context, click at the known checkbox offset inside the standard
// hCaptcha checkbox iframe (â 303Ã76).
if let Some((left, top, _w, _h)) =
crate::frame::find_iframe_rect_by_src(page, "newassets.hcaptcha.com/captcha/v1/")
.await?
{
let target_x = left + HCAPTCHA_CHECKBOX_OFFSET_X + rng.gen_range(-3.0..3.0);
let target_y = top + HCAPTCHA_CHECKBOX_OFFSET_Y + rng.gen_range(-3.0..3.0);
let ox = target_x + rng.gen_range(-200.0..200.0_f64);
let oy = target_y + rng.gen_range(-100.0..100.0_f64);
crate::behavior::mouse_move_human(page, ox, oy, target_x, target_y).await?;
crate::behavior::click_realistic(page, target_x, target_y).await?;
// Law 10: precise BiDi per-frame find failed (cross-origin iframe opaque
// to frame enumeration); we degraded to a HARDCODED checkbox offset inside
// the *assumed* 303Ã76 hCaptcha iframe. If the live iframe differs from that
// assumed geometry the click misses the checkbox and the solve fails for a
// reason the operator can't see at debug (surface the degraded path loudly).
warn!(
target_x,
target_y,
"hCaptcha: clicked via iframe-GEOMETRY fallback (BiDi frame-enum failed); \
click landed on the assumed standard offset, not a verified element centre. \
may miss if the iframe geometry differs"
);
return Ok(());
}
Err(anyhow!(
"hCaptcha 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();
// BiDi per-frame pre-pass: a checkbox in a `document.open();
// document.write(...)`'d or cross-origin frame is invisible to the
// parent-context DOM walk below, but BiDi's own frame iteration reaches
// each frame's realm. For every frame, LOCATE a captcha-shaped unchecked
// checkbox in that frame and deliver a TRUSTED `click_at_in` in its own
// context (event.isTrusted === true). Two things this must NOT do, both of
// which the old pass did and both of which defeat the solve: a synthetic
// in-frame `cb.dispatchEvent(new Event('click'))` arrives
// isTrusted === false, rejected on sight by every captcha that scores
// input trust (foxdriver `cross_origin_click` pins the positive/negative
// pair), and `cb.checked = true` pre-checks the box, removing it from the
// `:not(:checked)` set the trusted grid pre-pass below relies on. So we
// only READ the checkbox's centre in JS and click it for real, in-context.
let find_captcha_checkbox = r#"(() => {
const cb = document.querySelector('input[type="checkbox"]:not(:checked)');
if (!cb) return null;
let related = false;
for (let n = cb; n; n = n.parentElement) {
const cls = ((n.className && n.className.baseVal) || n.className || '') + '';
const blob = (cls + ' ' + (n.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) return null;
const r = cb.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return null;
return [r.left + r.width / 2, r.top + r.height / 2];
})()"#;
match page.frames().await {
Ok(frames) => {
for ctx in frames {
let centre = page
.evaluate_in_context(find_captcha_checkbox, &ctx)
.await
.ok()
.and_then(|r| r.into_value::<Option<(f64, f64)>>().ok())
.flatten();
if let Some((x, y)) = centre {
// Law 10: surface a failed per-frame checkbox click instead of
// swallowing it; that frame's box is then unclicked but the main
// solve still runs, so warn-and-continue rather than abort.
if let Err(e) = page.click_at_in(&ctx, x, y).await {
warn!("per-frame captcha checkbox trusted click failed ({e}); that checkbox was not selected, main solve still runs");
}
}
}
}
Err(e) => warn!("per-frame checkbox pre-pass: could not enumerate frames ({e}); captcha-shaped checkboxes in nested frames may be unclicked, main solve still runs"),
}
// 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 grid_pre = page
.evaluate(
r#"(() => {
/* Yields [root, ox, oy] where (ox,oy) is the root's
coordinate-space offset relative to the MAIN viewport, so
a tile's getBoundingClientRect (local to its root) can be
converted to a main-viewport coordinate for a trusted
Rust-side click. Shadow roots share their host's space
(offset unchanged); a same-origin child iframe adds the
iframe element's own rect. */
function* walkAllRoots(root, ox, oy) {
const queue = [[root, ox, oy]];
const seen = new WeakSet();
while (queue.length) {
const [r, rx, ry] = queue.shift();
if (seen.has(r)) continue;
seen.add(r);
yield [r, rx, ry];
const subtree = r.querySelectorAll ? r.querySelectorAll('*') : [];
for (const el of subtree) {
if (el.shadowRoot) queue.push([el.shadowRoot, rx, ry]);
if (el.tagName === 'IFRAME') {
let inner = null;
try { inner = el.contentDocument; } catch (_) {}
if (inner) {
let fr = { left: 0, top: 0 };
try { fr = el.getBoundingClientRect(); } catch (_) {}
queue.push([inner, rx + fr.left, ry + fr.top]);
}
}
}
}
}
/* Tiles the classifiers would click are collected here as
main-viewport centres; the Rust side then dispatches a
TRUSTED BiDi click at each (a synthetic in-page click is
isTrusted===false, which a same-origin custom captcha can
reject). `verifies` holds the verify/submit buttons. */
const hits = [];
const verifies = [];
const pushHit = (el, ox, oy) => {
const r = el.getBoundingClientRect();
hits.push({ x: r.left + r.width / 2 + ox, y: r.top + r.height / 2 + oy });
};
const pushVerify = (el, ox, oy) => {
if (!el) return;
const r = el.getBoundingClientRect();
verifies.push({ x: r.left + r.width / 2 + ox, y: r.top + r.height / 2 + oy });
};
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, ox, oy] of walkAllRoots(document, 0, 0)) {
let cbs = [];
try {
cbs = root.querySelectorAll
? root.querySelectorAll('input[type="checkbox"]:not(:checked)')
: [];
} catch (_) { continue; }
for (const cb of cbs) {
if (!looksCaptchaRelated(cb)) continue;
/* Hand the checkbox's centre to the Rust side for a TRUSTED
click (event.isTrusted === true): on a native unchecked
checkbox a real pointer click both toggles it AND fires
genuine click/change events. Do NOT set `cb.checked = true`
or dispatch a synthetic click here, a programmatic check
arrives isTrusted === false (a tell every captcha rejects)
AND removes the box from this `:not(:checked)` set, so the
trusted click would never be scheduled. */
pushHit(cb, ox, oy);
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}));
/* Hand any verify button inside the same captcha widget to
the Rust side for a TRUSTED click, a synthetic btn.click()
here is isTrusted === false and a trust-scoring widget
rejects it. */
const widget = r.closest('[class*="captcha"], [class*="rotate"], #widget');
const btn = widget && widget.querySelector('button, [onclick]');
if (btn) pushVerify(btn, ox, oy);
}
/* 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)) {
pushHit(tile, ox, oy);
clicked3++;
}
}
if (clicked3 > 0) {
const widget = iconTiles[0].closest('#widget, [class*="captcha"]') || document;
const btn = widget.querySelector('button#submit, button[type="submit"], button');
pushVerify(btn, ox, oy);
}
}
}
/* 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)) {
pushHit(tile, ox, oy);
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');
pushVerify(btn, ox, oy);
}
}
}
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)) {
pushHit(tile, ox, oy);
clicked2++;
}
}
if (clicked2 > 0) {
/* Collect verify button for a trusted click. */
const widget = colorTiles[0].closest('#widget, [class*="captcha"]') || document;
const btn = widget.querySelector('button#submit, button[type="submit"], button');
pushVerify(btn, ox, oy);
}
}
}
/* Draw-a-shape gesture widgets are handled by
the Rust-side `draw_triangle_via_bidi` after
this pre-pass, synthetic JS MouseEvents
don't populate offsetX/Y on canvas drawing
handlers, but BiDi-dispatched events do. */
}
return { clicked: clicked, hits: hits, verifies: verifies };
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<serde_json::Value>().ok());
// Dispatch a TRUSTED BiDi click at each tile centre the classifiers
// collected, then the verify button(s). The in-page JS only located
// the tiles (it cannot produce an isTrusted===true event); the click
// itself happens here so a same-origin custom captcha that gates on
// event.isTrusted is actually satisfied. Coordinates are already in
// main-viewport space (walkAllRoots summed any iframe offsets).
if let Some(pre) = grid_pre {
if let Some(hits) = pre.get("hits").and_then(|v| v.as_array()) {
let mut prev: Option<(f64, f64)> = None;
for h in hits {
if let (Some(x), Some(y)) = (h["x"].as_f64(), h["y"].as_f64()) {
if let Some((px, py)) = prev {
// Law 10: surface a failed approach move, don't `let _ =` it.
if let Err(e) =
crate::behavior::mouse_move_human(page, px, py, x, y).await
{
warn!("grid pre-pass move failed ({e}); clicking tile without a realistic approach path");
}
}
if let Err(e) = crate::behavior::click_realistic(page, x, y).await {
warn!("grid pre-pass tile click failed ({e}); this tile was not selected, the solve may not satisfy the challenge");
}
prev = Some((x, y));
crate::behavior::random_pause(80, 220).await;
}
}
}
if let Some(verifies) = pre.get("verifies").and_then(|v| v.as_array()) {
for vb in verifies {
if let (Some(x), Some(y)) = (vb["x"].as_f64(), vb["y"].as_f64()) {
// Law 10: the verify click submits the pre-pass selection; a
// silent failure here wastes it. Surface, then continue.
if let Err(e) = crate::behavior::click_realistic(page, x, y).await {
warn!("grid pre-pass verify click failed ({e}); the selection may not have been submitted");
}
}
}
}
}
// 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 BiDi 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;
}
// Harvest actual token values rather than just checking
// presence, the chain MUST return the real
// cf-turnstile-response / g-recaptcha-response /
// h-captcha-response token so callers can hand it to the
// vendor's siteverify endpoint. Previously the solver
// returned the literal string "behavioral:pre-pass" which
// siteverify always rejects.
// One source of truth for vendor-token harvesting (the canonical
// helper also surfaces a per-frame harvest error loudly instead of
// swallowing it (see `harvest_first_vendor_token`)).
let harvested = harvest_first_vendor_token(page).await;
pre_pass_solved = title_solved || harvested.is_some();
if pre_pass_solved {
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
let solution = harvested.unwrap_or_else(|| "behavioral:pre-pass".to_string());
return Ok(CaptchaSolveResult {
solution,
confidence: 0.9,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
}
if pre_pass_solved {
// Loop already returned on hit; this path stays as a
// belt-and-braces fallback for the title-solved short
// circuit at the top of the loop where harvest didn't
// run because pre_pass_solved was already true.
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
// Try one more harvest pass, the title transition may
// have happened just after a token was injected. One source of
// truth (surfaces per-frame harvest errors loudly).
let harvested = harvest_first_vendor_token(page).await;
let solution = harvested.unwrap_or_else(|| "behavioral:pre-pass".to_string());
return Ok(CaptchaSolveResult {
solution,
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 BiDi'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
// BiDi, 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 {
// Collect freshly-spawned enemy centres, then deliver a TRUSTED click at
// each (a synthetic e.click() is isTrusted === false, a game/behavioral
// captcha scores precisely that signal). Top-document game, so the rects
// are already viewport-space. Law 10: a per-target click failure is warned,
// not swallowed, and the poll loop continues.
let enemies = page
.evaluate(
r#"(() => {
const out = [];
for (const e of document.querySelectorAll('.enemy, .target')) {
const r = e.getBoundingClientRect();
if (r.width >= 1 && r.height >= 1) out.push([r.left + r.width / 2, r.top + r.height / 2]);
}
return out;
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<Vec<(f64, f64)>>().ok())
.unwrap_or_default();
for (x, y) in enemies {
if let Err(e) = crate::behavior::click_realistic(page, x, y).await {
warn!("doom-pass enemy trusted click failed ({e}); that target was not hit, poll loop continues");
}
}
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();
let solution = harvest_first_vendor_token(page)
.await
.unwrap_or_else(|| "behavioral:doom".to_string());
return Ok(CaptchaSolveResult {
solution,
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;
// BiDi 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 BiDi
// input. Run the BiDi triangle as a belt-and-suspenders
// so widgets in either bucket get covered.
// Law 10: this is a deliberately redundant secondary path (the synthetic
// dispatch above already ran), but a failure still must not vanish, a
// canvas honouring ONLY native BiDi input would go unsatisfied silently.
if let Err(e) = self.draw_triangle_via_bidi(page, &tt).await {
warn!("BiDi triangle belt-and-suspenders draw failed ({e}); a canvas honouring only native input may be unsatisfied (synthetic dispatch still ran)");
}
// 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.
// Locate (don't click) the verify button, prefer #verify over a generic
// button, and deliver a TRUSTED click in Rust. A synthetic b.click() here is
// isTrusted === false; a motion/canvas captcha that scores the submit rejects
// it. Law 10: a failed verify click is warned, not swallowed.
let verify_centre = 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) {
const r = b.getBoundingClientRect();
if (r.width >= 1 && r.height >= 1) return [r.left + r.width / 2, r.top + r.height / 2];
}
}
return null;
})()"#,
)
.await
.ok()
.and_then(|r| r.into_value::<Option<(f64, f64)>>().ok())
.flatten();
if let Some((x, y)) = verify_centre {
if let Err(e) = crate::behavior::click_realistic(page, x, y).await {
warn!("canvas/motion verify trusted click failed ({e}); the triangle solve may not have been submitted");
}
}
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();
let solution = harvest_first_vendor_token(page)
.await
.unwrap_or_else(|| "behavioral:triangle".to_string());
return Ok(CaptchaSolveResult {
solution,
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#"
(() => {
const el = document.querySelector('[name="g-recaptcha-response"]');
return !!(el && el.value && el.value.length > 0);
})()
"#;
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();
let solution =
crate::frame::harvest_token_in_frames(page, "g-recaptcha-response")
.await
.ok()
.flatten()
.unwrap_or_else(|| "recaptcha_v2:behavioral".to_string());
return Ok(CaptchaSolveResult {
solution,
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 let Some(tok) =
crate::frame::harvest_token_in_frames(page, "g-recaptcha-response").await?
{
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: tok,
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 let Some(tok) =
crate::frame::harvest_token_in_frames(page, "cf-turnstile-response").await?
{
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: tok,
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 let Some(tok) =
crate::frame::harvest_token_in_frames(page, "cf-turnstile-response")
.await?
{
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: tok,
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::HCaptcha => {
// hCaptcha may auto-pass on clean fingerprints; check first.
if let Some(tok) =
crate::frame::harvest_token_in_frames(page, "h-captcha-response").await?
{
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: tok,
confidence: 0.90,
method: SolveMethod::BehavioralBypass,
time_ms: t0.elapsed().as_millis() as u64,
success: true,
screenshot: None,
cookies,
verified_outcome: None,
});
}
self.natural_browsing(page).await?;
if self.click_hcaptcha(page).await.is_ok() {
for _ in 0..self.config.token_max_attempts {
tokio::time::sleep(Duration::from_millis(
self.config.token_poll_interval_ms,
))
.await;
if let Some(tok) =
crate::frame::harvest_token_in_frames(page, "h-captcha-response")
.await?
{
let cookies = crate::cookies::capture_from_page(page)
.await
.unwrap_or_default();
return Ok(CaptchaSolveResult {
solution: tok,
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();
let solution =
crate::frame::harvest_token_in_frames(page, "g-recaptcha-response")
.await
.ok()
.flatten()
.unwrap_or_else(|| "recaptcha_v3:behavioral".to_string());
Ok(CaptchaSolveResult {
solution,
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,
))
}
}
}
}
/// Walk every frame in priority order looking for any of the three
/// canonical captcha-response token fields. Returns the first
/// non-empty value or None. Lets the BehavioralCaptchaSolver return
/// the actual vendor token (cf-turnstile-response /
/// g-recaptcha-response / h-captcha-response) instead of the
/// historical hardcoded label strings ("behavioral:doom", âĶ) which
/// downstream `siteverify` calls always rejected.
pub(crate) async fn harvest_first_vendor_token(page: &crate::Page) -> Option<String> {
for token_field in [
"cf-turnstile-response",
"g-recaptcha-response",
"h-captcha-response",
] {
match crate::frame::harvest_token_in_frames(page, token_field).await {
Ok(Some(v)) => return Some(v),
Ok(None) => {}
// Law 10: a per-frame harvest ERROR is NOT "token absent". If a real
// vendor token lives in a frame whose BiDi eval just failed, silently
// treating it as None lets the caller collapse to a `behavioral:*`
// sentinel and report success with a token `siteverify` will reject
// a false success the operator can't see. Keep the Option contract
// (callers poll again on None), but surface the failed read loudly.
// Called only on solve-success paths, not the poll loop, so this does
// not flood.
Err(e) => {
warn!(
token_field,
error = %e,
"captchaforge: vendor-token harvest errored in a frame (treating as \
not-found); a token hidden behind this failed read is invisible to the \
solver and may surface as a false sentinel success"
);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
/// Dedup lock: the Turnstile geometry fallback in `click_turnstile` must use
/// the vendor solver's canonical `CHECKBOX_OFFSET_{X,Y}`, not a re-literalised
/// `28.0`/`32.0`. `use super::*` re-exports the vendor const through
/// `solver::vendors`, so behavioral.rs and `TurnstileInteractiveSolver` now
/// resolve the SAME symbol, this asserts the measured value so an accidental
/// drift in the one source is caught (and propagates to both consumers).
#[test]
fn behavioral_turnstile_offset_matches_vendor_canonical() {
// Same symbol, reached two ways (proves the re-export linkage holds).
assert_eq!(CHECKBOX_OFFSET_X, crate::solver::CHECKBOX_OFFSET_X);
assert_eq!(CHECKBOX_OFFSET_Y, crate::solver::CHECKBOX_OFFSET_Y);
// Measured against the standard 300Ã65 Turnstile widget.
assert_eq!(CHECKBOX_OFFSET_X, 28.0);
assert_eq!(CHECKBOX_OFFSET_Y, 32.0);
}
/// Pin the behavioral.rs-owned reCAPTCHA/hCaptcha anchor-checkbox offsets to
/// their documented measured values, so a careless edit to the magic numbers
/// trips a test instead of silently moving every geometry-fallback click.
#[test]
fn recaptcha_and_hcaptcha_checkbox_offsets_are_documented_values() {
assert_eq!(RECAPTCHA_ANCHOR_CHECKBOX_OFFSET_X, 28.0);
assert_eq!(RECAPTCHA_ANCHOR_CHECKBOX_OFFSET_Y, 28.0);
assert_eq!(HCAPTCHA_CHECKBOX_OFFSET_X, 28.0);
assert_eq!(HCAPTCHA_CHECKBOX_OFFSET_Y, 28.0);
}
}