captchaforge 0.2.27

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! 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 crate::{detect, solver, stealth, stealth_profiles, warmup, 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.
///
/// Equivalent to:
///
/// ```ignore
/// captchaforge::stealth::apply_stealth(page).await?;
/// if let Some(profile) = profile {
///     captchaforge::stealth_profiles::apply_stealth_profile(page, &profile).await?;
/// }
/// captchaforge::warmup::apply_default_warmup(page).await?;
/// ```
///
/// `profile = None` skips the named-profile pin and lets the
/// underlying chromium UA stand (after generic stealth removes the
/// headless tells). Pass `Some(StealthProfile::ChromeWindowsStable)`
/// (or any [`StealthProfile`] variant) when you want a coherent
/// vendor-shaped fingerprint.
pub async fn prepare_page(
    page: &chromiumoxide::Page,
    profile: Option<StealthProfile>,
) -> anyhow::Result<()> {
    if let Err(e) = stealth::apply_stealth(page).await {
        tracing::debug!("prepare_page: apply_stealth failed (continuing): {e}");
    }
    if let Some(p) = profile {
        if let Err(e) = stealth_profiles::apply_stealth_profile(page, &p).await {
            tracing::debug!("prepare_page: apply_stealth_profile failed (continuing): {e}");
        }
    }
    if let Err(e) = warmup::apply_default_warmup(page).await {
        tracing::debug!("prepare_page: warmup failed (continuing): {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 + warmup BEFORE nav, so
///    the first page load is already de-fingerprinted.
/// 2. `page.goto(url)` — actual navigation.
/// 3. [`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: &chromiumoxide::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::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: &chromiumoxide::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: &chromiumoxide::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: &chromiumoxide::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)
}

/// Recommended `chromiumoxide` `BrowserConfig` flags for solving
/// captchas in headless mode.
///
/// Returns a Vec of `--switch=value` argv strings the caller can pass
/// to `BrowserConfig::builder().args(...)`. Pins the four anti-bot
/// hardening flags every captchaforge consumer needs:
///
/// 1. `--disable-blink-features=AutomationControlled` — drops the
///    `navigator.webdriver = true` tell that anti-bot stacks read.
/// 2. `--exclude-switches=enable-automation` — removes the
///    `enable-automation` extension switch that announces the
///    page is being driven by chromedriver.
/// 3. `--disable-features=TranslateUI,AutomationControlled` —
///    silences the chromium "you appear to be using automation"
///    info bar that fingerprint scripts probe for.
/// 4. `--disable-dev-shm-usage` — required in containers where
///    `/dev/shm` is too small (default 64 MB), otherwise chromium
///    crashes mid-session on captcha-heavy pages.
///
/// Pass these in addition to your own viewport / proxy / user-data
/// flags. They're also injected automatically by the bundled bench
/// harness; production deployments must opt in.
///
/// # Example
///
/// ```rust
/// use captchaforge::recommended_chromium_args;
///
/// let args = recommended_chromium_args();
/// assert!(args.iter().any(|a| a.contains("AutomationControlled")));
/// assert!(args.iter().any(|a| a.contains("disable-dev-shm-usage")));
/// ```
pub fn recommended_chromium_args() -> Vec<&'static str> {
    vec![
        "--disable-blink-features=AutomationControlled",
        "--exclude-switches=enable-automation",
        "--disable-features=TranslateUI,AutomationControlled",
        "--disable-dev-shm-usage",
        // ANGLE backend used by the bench fixture set; matters when
        // the captcha relies on WebGL fingerprint constancy.
        "--use-angle=swiftshader-webgl",
        // Headless-shell logs to stderr by default — silence the
        // "DevTools listening" line so it doesn't pollute structured
        // log pipelines.
        "--noerrdialogs",
    ]
}

/// 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: &chromiumoxide::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)
}

#[cfg(test)]
mod chromium_args_tests {
    use super::*;

    #[test]
    fn recommended_args_include_anti_automation_lockdown() {
        let args = recommended_chromium_args();
        let blob = args.join(" ");
        assert!(
            blob.contains("AutomationControlled"),
            "must drop the navigator.webdriver tell"
        );
        assert!(
            blob.contains("enable-automation"),
            "must exclude the chromedriver enable-automation switch"
        );
    }

    #[test]
    fn recommended_args_include_dev_shm_workaround() {
        // Containers / restricted /dev/shm need this or chromium
        // crashes on long-running captcha pages.
        let args = recommended_chromium_args();
        assert!(args.iter().any(|a| a.contains("disable-dev-shm-usage")));
    }

    #[test]
    fn recommended_args_pin_deterministic_webgl_backend() {
        // Captcha fingerprint scripts hash the WebGL renderer string.
        // We pin a deterministic ANGLE backend so re-runs hash the
        // same way on the same machine — otherwise stealth profile
        // overrides drift across sessions.
        let args = recommended_chromium_args();
        assert!(args.iter().any(|a| a.contains("use-angle")));
    }

    #[test]
    fn recommended_args_are_static_str_so_caller_can_borrow() {
        // The args list must contain `&'static str`s so chromiumoxide
        // can borrow them across the BrowserConfig lifetime without
        // forcing the caller to keep a Vec<String> alive themselves.
        let args = recommended_chromium_args();
        let _: &[&'static str] = args.as_slice();
    }
}