captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! High-level SDK convenience helpers.
//!
//! `captchaforge` exposes a layered API:
//!
//! - **Building blocks** (`detect`, `solver`, `stealth`, `warmup`,
//!   `cookies`, `behavior`, …), fine-grained primitives a power
//!   user composes into a custom flow.
//! - **One-call helpers** (this module), wired-together entry points
//!   for the 90% case: prepare the page, navigate, solve, retry,
//!   wait for clearance, dismiss CMP banners.
//!
//! The helpers in this module are *thin*. Every one of them
//! delegates to lower-level functions documented in their own
//! modules; they exist so a SDK consumer can write
//! `captchaforge::solve_url(&page, url, None).await?` and not have
//! to remember the right call order.
//!
//! All helpers in this module are re-exported at the crate root
//! ([`crate::prepare_page`], [`crate::solve_url`], etc.), this
//! module is the implementation site, not the canonical import path.

use anyhow::Context;

use crate::{apply_stealth, apply_stealth_profile, detect, solver, StealthProfile};

/// One-call setup for a fresh page BEFORE the first navigation.
///
/// Call this immediately after creating the page (and BEFORE
/// `page.goto(url)`) so the stealth + profile overrides are
/// installed via `addScriptToEvaluateOnNewDocument` and run on
/// the very first page load, that's the only way to defeat
/// anti-bot fingerprint checks that fire before our solver gets
/// a chance to react.
///
/// `profile = None` (the default) applies **max-coherence** stealth: it scrubs
/// automation tells (`webdriver`, `cdc_*`, phantom, the `chrome` shim, …) while
/// leaving the REAL browser identity. User-Agent, platform, hardwareConcurrency,
/// WebGL, genuine. A real Firefox's own surfaces are self-consistent across
/// JS/HTTP/TLS, so the least-detectable disguise is the real browser with its
/// automation fingerprint removed.
///
/// `profile = Some(persona)` additionally pins that persona's identity. Use it
/// only for an EXPLICIT cross-OS / cross-version persona, and launch via
/// [`guise::launch_profiled_firefox`] so the launch-time prefs make the HTTP
/// User-Agent header agree with the JS identity override (a vanilla launch would
/// leave the request header at the real version while JS reports the persona's 
/// a self-inflicted tell).
///
/// Pre-navigation only; run [`crate::warmup::apply_default_warmup`] after `goto`
/// so the interaction signals land on the target page.
pub async fn prepare_page(
    page: &crate::Page,
    profile: Option<StealthProfile>,
) -> anyhow::Result<()> {
    if let Some(p) = profile {
        // EXPLICIT persona (opt-in, e.g. cross-OS spoofing). NOTE: for full
        // coherence the caller must launch via `guise::launch_profiled_firefox`
        // so the launch-time `general.useragent.override` pref makes the HTTP
        // User-Agent header agree with this JS identity override.
        let profile_label = format!("{p:?}");
        apply_stealth_profile(page, &p).await.with_context(|| {
            format!("prepare_page: profiled stealth application failed for {profile_label}")
        })?;
    } else {
        // DEFAULT = max coherence: scrub automation tells, keep the REAL browser
        // identity. The genuine Firefox surface is self-consistent across
        // JS/HTTP/TLS; spoofing a fake persona on a vanilla-launched browser
        // would desync the JS UA from the real HTTP User-Agent header.
        apply_stealth(page)
            .await
            .context("prepare_page: default (real-identity) stealth application failed")?;
    }
    // Full session disguise: the canvas / audio / WebGL fingerprint-evasion pass
    // that the guise probe gate proves green (stealth alone is the JS-tell layer;
    // this closes the high-entropy fingerprint surfaces). Best-effort, some
    // Firefox builds reject BiDi injection mid-session, but logged, never
    // silently swallowed.
    if let Err(e) = guise::fingerprint::apply_fingerprint(
        page,
        &guise::fingerprint::FingerprintConfig::default(),
    )
    .await
    {
        tracing::warn!("prepare_page: fingerprint evasion not applied: {e}");
    }
    Ok(())
}

/// One-call solve for an unprepared page navigated to `url`.
///
/// The simplest possible entry point: pass a fresh page and a URL,
/// get back a solve result. Wires:
///
/// 1. [`prepare_page`], stealth + profile BEFORE nav, so the first
///    page load is already de-fingerprinted.
/// 2. `page.goto(url)`: actual navigation.
/// 3. [`crate::warmup::apply_default_warmup`], human-like interaction
///    signals on the loaded target page.
/// 4. [`crate::auto_solve`], detect + solve loop with
///    TOML-discovered chain.
///
/// Returns `Ok(None)` when no captcha was detected after navigation
/// (the typical happy path for sites where stealth alone passed any
/// passive challenge), or `Ok(Some(result))` with the solver outcome.
pub async fn solve_url(
    page: &crate::Page,
    url: &str,
    profile: Option<StealthProfile>,
) -> anyhow::Result<Option<solver::CaptchaSolveResult>> {
    prepare_page(page, profile).await?;
    page.goto(url)
        .await
        .map_err(|e| anyhow::anyhow!("solve_url: goto({url}) failed: {e}"))?;
    crate::warmup::apply_default_warmup(page)
        .await
        .context("solve_url: default warmup failed")?;
    crate::auto_solve(page).await
}

/// Dismiss cookie/consent / GDPR / CMP banners that often sit
/// BETWEEN the user and the captcha widget.
///
/// Many anti-bot widgets won't render (or won't accept clicks)
/// while a fixed-position consent overlay covers them. Detection
/// passes, but solving fails because the overlay intercepts every
/// click. Calling this once after `page.goto()` clears the most
/// common banners (OneTrust, Cookiebot, Cookiehub, Quantcast Choice,
/// Didomi, Usercentrics, plain `accept-cookies` IDs) by clicking
/// the obvious "accept all" button when present, falling back to
/// hiding the overlay container when no button matched.
///
/// Returns `Ok(true)` when at least one banner element was clicked
/// or hidden, `Ok(false)` when no candidate matched. Best-effort
/// and idempotent (safe to call unconditionally before any solve).
pub async fn dismiss_cookie_consent(page: &crate::Page) -> anyhow::Result<bool> {
    let dismissed = page
        .evaluate(DISMISS_CMP_JS)
        .await
        .ok()
        .and_then(|r| r.into_value::<i64>().ok())
        .unwrap_or(0);
    Ok(dismissed > 0)
}

/// Inline JS payload for [`dismiss_cookie_consent`].
///
/// Ordered: vendor-specific selectors first (most precise), then
/// aria-label fallbacks, then ID/class textual matchers, then
/// last-resort container-hide. Each candidate must be visible
/// (non-zero bbox + not display:none + not visibility:hidden) 
/// hidden buttons are anti-bot honeypots, never auto-click them.
const DISMISS_CMP_JS: &str = r#"(() => {
    const candidates = [
        '#onetrust-accept-btn-handler',
        '#onetrust-pc-btn-handler',
        '.ot-pc-refuse-all-handler',
        '#CybotCookiebotDialogBodyLevelButtonAcceptAll',
        '#CybotCookiebotDialogBodyButtonAccept',
        '#cookiehub-button-accept',
        'button[mode="primary"][data-testid="uc-accept-all-button"]',
        'button[data-testid="uc-deny-all-button"]',
        'button.didomi-continue-without-agreeing',
        'button#didomi-notice-agree-button',
        'button.qc-cmp2-summary-buttons button[mode="primary"]',
        'button[aria-label*="accept all" i]',
        'button[aria-label*="agree" i]',
        'button#cookie-accept',
        'button#accept-cookies',
        'button.accept-cookies',
        'button[id*="accept" i][id*="cookie" i]',
        'button[class*="accept" i][class*="cookie" i]',
    ];
    let clicked = 0;
    for (const sel of candidates) {
        try {
            const el = document.querySelector(sel);
            if (!el) continue;
            const r = el.getBoundingClientRect();
            if (r.width === 0 || r.height === 0) continue;
            const cs = window.getComputedStyle(el);
            if (cs.display === 'none' || cs.visibility === 'hidden') continue;
            el.click();
            clicked++;
            break;
        } catch (_) { /* keep going */ }
    }
    if (clicked === 0) {
        const overlays = document.querySelectorAll(
            '[id*="cookie-consent" i], [id*="cookie-banner" i], ' +
            '[class*="cookie-consent" i], [class*="cookie-banner" i], ' +
            '#onetrust-banner-sdk, #CybotCookiebotDialog, ' +
            '.didomi-popup-container, .qc-cmp2-container'
        );
        for (const o of overlays) {
            try { o.style.display = 'none'; clicked++; } catch (_) {}
        }
    }
    return clicked;
})()"#;

/// Remove third-party chat widgets (Intercom / Drift / HubSpot /
/// Crisp / Tawk.to / Zendesk Chat / LiveChat / Olark / Front /
/// LiveAgent / Userlike) that often render as fixed-position
/// overlays partially covering the captcha widget.
///
/// Same shape as [`dismiss_cookie_consent`]: clicks the close-X
/// button when one is exposed, falls back to hiding the widget
/// container when no button matched. Returns `Ok(true)` when
/// at least one widget was clicked or hidden.
///
/// Best-effort and idempotent, safe to call unconditionally
/// before any solve.
pub async fn dismiss_chat_widget(page: &crate::Page) -> anyhow::Result<bool> {
    let dismissed = page
        .evaluate(DISMISS_CHAT_JS)
        .await
        .ok()
        .and_then(|r| r.into_value::<i64>().ok())
        .unwrap_or(0);
    Ok(dismissed > 0)
}

/// Inline JS payload for [`dismiss_chat_widget`].
///
/// Vendor-specific close-button selectors first, then a
/// last-resort container hide. Mirrors `dismiss_cookie_consent`'s
/// shape so the two helpers are interchangeable from the
/// caller's perspective.
const DISMISS_CHAT_JS: &str = r#"(() => {
    const close_buttons = [
        '.intercom-launcher-close',
        '.intercom-namespace [aria-label*="close" i]',
        'button[aria-label="Close"][data-testid*="drift"]',
        '#hubspot-messages-iframe-container button[aria-label*="close" i]',
        '.crisp-client [aria-label*="close" i]',
        '#tawkchat-minify-iconwrapper',
        '.zopim [data-test-id*="close"]',
        'iframe[title*="LiveChat"] + button',
        '.olark-launch-button.olark-state-busy + button',
        '#frontApp-close-button',
        'button[id*="liveagent"][title*="close" i]',
        '#userlike-button-close',
    ];
    let clicked = 0;
    for (const sel of close_buttons) {
        try {
            const el = document.querySelector(sel);
            if (!el) continue;
            const r = el.getBoundingClientRect();
            if (r.width === 0 || r.height === 0) continue;
            el.click();
            clicked++;
        } catch (_) { /* keep going, clear all that match */ }
    }
    if (clicked === 0) {
        const containers = [
            '#intercom-container', '.intercom-namespace',
            '#drift-frame-controller', '#drift-frame-chat',
            '#hubspot-messages-iframe-container',
            '#crisp-client',
            '#tawkchat-container',
            '#zopim',
            'iframe[title*="LiveChat"]',
            '.olark-launch-button',
            '#frontApp',
            '[id^="liveagent_"]',
            '#userlike-button',
        ];
        for (const sel of containers) {
            try {
                document.querySelectorAll(sel).forEach(el => {
                    el.style.display = 'none';
                    clicked++;
                });
            } catch (_) {}
        }
    }
    return clicked;
})()"#;

/// One-call solve with N retries on transient failure.
///
/// Wraps [`crate::auto_solve`] in a fixed retry loop. On each
/// attempt:
///
/// 1. Dismiss any newly-rendered cookie banner.
/// 2. Run the auto_solve chain.
/// 3. If it returns `Ok(Some(r))` with `r.success == true`, return.
/// 4. Otherwise wait `inter_attempt_ms` and try again.
///
/// Use this for high-stakes flows (checkout, account creation)
/// where a single failed attempt should be retried automatically
/// before surfacing a human-fallback to the operator.
///
/// `max_attempts == 0` is treated as 1 (you always get at least
/// one attempt). `inter_attempt_ms` is the wall-clock pause between
/// attempts; 1500-3000ms is a reasonable default for vendors that
/// rate-limit token issuance.
pub async fn auto_solve_with_retries(
    page: &crate::Page,
    max_attempts: u32,
    inter_attempt_ms: u64,
) -> anyhow::Result<Option<solver::CaptchaSolveResult>> {
    let attempts = max_attempts.max(1);
    let mut last: Option<solver::CaptchaSolveResult> = None;
    for attempt in 0..attempts {
        let _ = dismiss_cookie_consent(page).await;
        match crate::auto_solve(page).await? {
            None => return Ok(None),
            Some(r) if r.success => return Ok(Some(r)),
            Some(r) => {
                last = Some(r);
                if attempt + 1 < attempts {
                    tokio::time::sleep(std::time::Duration::from_millis(inter_attempt_ms)).await;
                }
            }
        }
    }
    Ok(last)
}

/// Wait until [`detect::detect`] reports no captcha on the page,
/// with a timeout. Useful between navigation and a protected
/// action: lets the page's passive challenges settle before you
/// try to submit.
///
/// Returns `Ok(true)` when the captcha cleared within the budget,
/// `Ok(false)` on timeout. Polls every 200ms, the cadence is
/// fast enough to feel responsive but slow enough not to spam
/// the eval channel.
pub async fn wait_for_no_captcha(page: &crate::Page, timeout_ms: u64) -> anyhow::Result<bool> {
    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms);
    while std::time::Instant::now() < deadline {
        let info = detect::detect(page).await?;
        if !detect::is_captcha(&info) {
            return Ok(true);
        }
        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
    }
    Ok(false)
}