Integrations

Résoudre les CAPTCHA dans les agents IA de navigation : browser-use, Stagehand, Skyvern, Claude et OpenAI

Votre agent browser-use tourne en boucle sur une page d'inscription : il coche « Je ne suis pas un robot », reçoit une grille d'images, clique sur des passages piétons, étape après étape, puis abandonne. Le remède ne se trouve pas dans le prompt : sortez le CAPTCHA du raisonnement du modèle. Le code qui pilote déjà le navigateur repère le widget dans le DOM, le fait résoudre par CaptchaAI, écrit le token dans la même page et ne renvoie au LLM qu'une ligne d'état.

Ce guide construit ce socle commun (détection, résolution, injection) une seule fois, puis montre où le brancher dans browser-use, Stagehand, Skyvern, les outils browser use et computer use de Claude et l'outil computer d'OpenAI. Il signale aussi, sans détour, les cas où aucun point d'accroche n'existe.

Périmètre : tout ce qui suit suppose un site que vous possédez ou que vous êtes autorisé à automatiser, par exemple votre propre environnement de préproduction. Dans les exemples browser-use et Skyvern, allowed_domains et la liste ALLOWED limitent d'ailleurs le travail à cet hôte.

Côté outillage, prévoyez une clé API CaptchaAI exportée dans la variable CAPTCHAAI_API_KEY (voir le guide de démarrage rapide CaptchaAI), Python pour le socle commun et Node.js pour l'exemple Stagehand.

Pourquoi le LLM de l'agent ne doit pas résoudre le CAPTCHA lui-même

Les agents qui raisonnent sur des captures d'écran échouent face aux CAPTCHA de façon très prévisible, pour trois raisons :

  • La case à cocher dégénère en grille. Le modèle clique sur « Je ne suis pas un robot », reCAPTCHA juge la session trop automatisée et répond par une grille d'images ; l'agent épuise ensuite son budget d'étapes à cliquer sur des vignettes.
  • Les widgets invisibles n'offrent rien à cliquer. reCAPTCHA invisible et Cloudflare Turnstile ne présentent aucun élément exploitable : l'agent tourne en rond sur un formulaire qui ne part jamais.
  • Le token n'a rien à faire dans un prompt. Un token reCAPTCHA reste valable deux minutes et ne se vérifie qu'une fois ; un token Turnstile expire au bout de 300 secondes et ne sert qu'une fois. Sa seule utilité est d'être écrit tout de suite dans la page. Le faire transiter par le LLM ne fait que le recopier dans les transcriptions et les logs.

Les éditeurs de modèles vont dans le même sens. Les recettes d'intégration computer use d'OpenAI rangent « Solving CAPTCHA challenges » parmi les actions que l'application doit faire confirmer par l'utilisateur au moment où elles surviennent. La documentation d'Anthropic sur browser use et computer use demande une liste de domaines autorisés et une confirmation humaine des actions à conséquences. Aucun des deux ne fait du modèle un solveur de CAPTCHA.

La résolution revient donc à la couche qui possède déjà la page : une action du framework, votre exécuteur d'outils, un harnais autour du modèle ou une extension de navigateur.

Tenir le token à l'écart du modèle allège aussi vos journaux. Les traces d'un agent de navigation accumulent déjà captures et fragments de page, qui peuvent contenir des données personnelles dès qu'un formulaire en affiche. Si ces données concernent des personnes situées dans l'Union européenne, vérifiez vos obligations RGPD : ne journalisez que le nécessaire et fixez une durée de conservation.

Quels CAPTCHA l'agent peut franchir avec CaptchaAI

Avant d'écrire la moindre ligne, vérifiez ce que le socle décrit ici sait traiter. Le tableau croise le statut officiel de chaque type chez CaptchaAI avec le comportement du helper partagé.

Type de CAPTCHA Statut chez CaptchaAI Méthode API Ce que fait le helper partagé
reCAPTCHA v2 (case à cocher, invisible, callback) ✅ GA userrecaptcha Détecte, résout et injecte
Cloudflare Turnstile ✅ GA turnstile Détecte, résout et injecte
reCAPTCHA v3 ✅ GA userrecaptcha avec version=v3 Résout quand la page a un champ de réponse à remplir
reCAPTCHA v2 et v3 Enterprise ✅ GA userrecaptcha avec enterprise=1 Signalé à une personne : le token doit partir avec le User-Agent du solveur
Cloudflare Challenge ✅ GA cloudflare_challenge Non traité ; voir la remarque ci-dessous
GeeTest v3, image/OCR, grille d'images, BLS ✅ GA geetest, post, bls Non traité ; chacun demande son propre câblage
CaptchaFox (bêta), Friendly Captcha (bêta), Lemin (bêta) ✅ Bêta captchafox, friendly_captcha, lemin Non traité ; chacun demande son propre câblage
hCaptcha ❌ Non pris en charge aucune Signalé ; l'agent passe la main
FunCaptcha (Arkose Labs) ❌ Non pris en charge aucune Signalé ; l'agent passe la main
GeeTest v4 ❌ Pas encore (à venir) aucune Signalé ; l'agent passe la main

Les trois dernières lignes mettent fin à la tentative automatique : le détecteur les marque comme non prises en charge et chaque hook demande au modèle de s'arrêter pour solliciter une personne. Enterprise emprunte la même sortie, mais uniquement parce que ce helper ne le câble pas.

Cloudflare Challenge est un cas à part. CaptchaAI le résout avec method=cloudflare_challenge et un proxy obligatoire, mais le résultat est un cookie cf_clearance lié au User-Agent du solveur et à l'IP du proxy. Un agent de navigation ne peut s'en servir que si son navigateur passe par ce proxy avec le User-Agent renvoyé ; un client HTTP est donc généralement mieux placé pour cette étape.

Le principe : détecter dans le DOM, résoudre avec CaptchaAI, injecter, reprendre

Quel que soit le framework, le circuit est identique. Le LLM appelle solve_captcha, ou le harnais repère un widget de lui-même ; un hook placé dans la couche qui possède la page fait le reste, et le modèle ne reçoit qu'un statut :

 LLM (browser-use, Stagehand, Claude, GPT)
   |  calls solve_captcha, or the harness notices a widget on its own
   v
 Hook in the layer that owns the page (action, executor, harness, watcher)
   1. page.evaluate(detect.js)   -> type, sitekey, pageurl, action, callback
   2. POST in.php -> task id; wait 15 s (10 s for Turnstile);
      GET res.php every 5 s until the token arrives or 120 s pass
   3. page.evaluate(inject.js)   -> g-recaptcha-response or cf-turnstile-response,
                                    then the page's data-callback if it has one
   v
 LLM receives "recaptcha_v2 solved and applied"   (never the token)

Chaque étape reste courte :

  1. Détection. Un petit script évalué dans la page lit le sitekey dans data-sitekey, dans le paramètre k de l'iframe d'ancrage reCAPTCHA ou dans un appel turnstile.render(). Il relève aussi l'URL de la page, l'indicateur invisible, l'action v3 ou Turnstile et l'éventuel data-callback.
  2. Tri. Le même script reconnaît hCaptcha, FunCaptcha et GeeTest v4, que CaptchaAI ne résout pas : l'agent peut alors passer la main au lieu de s'acharner.
  3. Résolution. Deux appels HTTP suffisent, in.php puis res.php.
  4. Injection et statut. Le token est écrit dans la page, et seule une chaîne d'état remonte jusqu'à l'agent.

Le socle commun : détection, client CaptchaAI et injection

Enregistrez les trois fichiers ci-dessous dans le même dossier. Les deux fichiers JavaScript ne contiennent qu'une fonction fléchée, sans le moindre commentaire en tête : browser-use rejette le code évalué qui ne commence pas par (...) =>.

detect.js : repérer le widget et ses paramètres

() => {
  // Runs inside the page and returns JSON text, so every framework's evaluate() can carry it.
  const all = (sel) => Array.from(document.querySelectorAll(sel));
  const filled = (name) => all(`[name="${name}"]`).some((el) => el.value);
  const inline = all('script:not([src])').map((s) => s.textContent).join('\n');
  const base = { pageurl: location.href, userAgent: navigator.userAgent };
  const hits = [];
  const seen = new Set();
  const add = (hit) => {
    const id = hit.sitekey || hit.name;
    if (!seen.has(id)) { seen.add(id); hits.push(hit); }
  };
  // Families the helper hands to a human instead of solving.
  if (all('.h-captcha, iframe[src*="hcaptcha.com"]').length) add({ type: 'unsupported', name: 'hCaptcha' });
  if (all('iframe[src*="arkoselabs.com"]').length) add({ type: 'unsupported', name: 'FunCaptcha' });
  if (typeof window.initGeetest4 === 'function') add({ type: 'unsupported', name: 'GeeTest v4' });
  // Enterprise shares the g-recaptcha markup but needs its own wiring, so flag it.
  const enterprise = all('script[src*="/recaptcha/enterprise.js"]').length > 0;
  if (enterprise) add({ type: 'unsupported', name: 'reCAPTCHA Enterprise' });
  if (!filled('cf-turnstile-response')) {
    for (const el of all('.cf-turnstile[data-sitekey]')) {
      add({ ...base, type: 'turnstile', sitekey: el.dataset.sitekey,
        action: el.dataset.action || null, callback: el.dataset.callback || null });
    }
    // Explicit rendering: turnstile.render('#box', { sitekey: '...' }) in an inline script.
    const rendered = inline.match(/turnstile\.render\([\s\S]{0,400}?sitekey\s*:\s*['"]([\w-]+)['"]/);
    if (rendered) add({ ...base, type: 'turnstile', sitekey: rendered[1], action: null, callback: null });
  }
  if (!enterprise && !filled('g-recaptcha-response')) {
    // v3 loads api.js?render=<key> or binds a button that carries data-action. Its badge
    // iframe looks like an invisible v2 anchor, so those keys are skipped below.
    const v3 = all('script[src*="/recaptcha/api.js?render="]')
      .map((s) => new URL(s.src).searchParams.get('render'))
      .find((key) => key && key !== 'explicit');
    const v3keys = new Set([v3, ...all('.g-recaptcha[data-action]').map((el) => el.dataset.sitekey)]);
    for (const el of all('.g-recaptcha[data-sitekey]:not([data-action])')) {
      add({ ...base, type: 'recaptcha_v2', sitekey: el.dataset.sitekey,
        invisible: el.dataset.size === 'invisible', callback: el.dataset.callback || null });
    }
    for (const frame of all('iframe[src*="/recaptcha/api2/anchor"]')) {
      const url = new URL(frame.src);
      if (v3keys.has(url.searchParams.get('k'))) continue;
      add({ ...base, type: 'recaptcha_v2', sitekey: url.searchParams.get('k'),
        invisible: url.searchParams.get('size') === 'invisible', callback: null,
        domain: url.hostname.endsWith('recaptcha.net') ? 'recaptcha.net' : null });
    }
    // v3 is only worth solving when the form has its own response field outside Google's badge.
    const field = all('[name="g-recaptcha-response"]').some((el) => !el.closest('.grecaptcha-badge'));
    if (v3 && field) {
      const found = inline.match(/execute\([^)]*?action\s*:\s*['"]([\w/]+)['"]/);
      add({ ...base, type: 'recaptcha_v3', sitekey: v3, action: found ? found[1] : null });
    }
  }
  return JSON.stringify(hits);
}

Le détecteur ignore tout widget dont le champ de réponse contient déjà une valeur. Un watcher ou un garde-fou exécuté à chaque étape ne paie donc jamais deux fois pour le même défi.

reCAPTCHA v3 demande plus de précautions. Il n'a pas de widget, et la page génère en général son propre token avec grecaptcha.execute() ; le script ne le signale donc que si le formulaire porte son propre champ g-recaptcha-response, vide. Le badge v3 charge en outre une iframe api2/anchor avec size=invisible, qu'un scan naïf prendrait pour un reCAPTCHA v2 invisible et résoudrait avec la mauvaise méthode. C'est pourquoi les clés v3 sont exclues du scan des iframes d'ancrage.

Les pages Enterprise sont signalées plutôt que traitées comme du v2 classique. CaptchaAI résout bien Enterprise, avec enterprise=1, mais sa documentation demande de soumettre ce token avec le User-Agent renvoyé par le solveur : cela mérite un câblage dédié. Enfin, l'extraction des actions n'est pas infaillible : quand celle de v3 ne trouve rien, le solveur envoie verify, la valeur par défaut documentée.

inject.js : écrire le token là où la page l'attend

(raw) => {
  // raw is JSON text: { hit, token }. The return value never contains the token.
  const { hit, token } = JSON.parse(raw);
  const name = hit.type === 'turnstile' ? 'cf-turnstile-response' : 'g-recaptcha-response';
  const fields = Array.from(document.querySelectorAll(`[name="${name}"], #${name}`));
  for (const el of fields) {
    el.value = token;
    el.dispatchEvent(new Event('input', { bubbles: true }));
    el.dispatchEvent(new Event('change', { bubbles: true }));
  }
  // Widgets wired with data-callback expect the token through that function.
  const fn = hit.callback
    ? hit.callback.split('.').reduce((obj, key) => (obj ? obj[key] : undefined), window)
    : undefined;
  if (typeof fn === 'function') fn(token);
  return JSON.stringify({ fields: fields.length, callback: typeof fn === 'function' });
}

Remplir le champ caché est la méthode standard ; déclencher le data-callback couvre les sites qui ne lisent jamais ce champ. Pour les noms de champ personnalisés, les callbacks déclarés via grecaptcha.render ou les pages à plusieurs widgets, reportez-vous aux variantes de la référence des méthodes d'injection de token.

captcha_core.py : le client CaptchaAI qu'appellent tous les hooks

"""captcha_core.py: the CaptchaAI layer every agent hook in this guide calls."""
import json
import os
import time
from pathlib import Path

import requests

API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "")  # 32 characters, from your dashboard
HERE = Path(__file__).parent
DETECT_JS = (HERE / "detect.js").read_text(encoding="utf-8")
INJECT_JS = (HERE / "inject.js").read_text(encoding="utf-8")
HINTS = {
    "ERROR_WRONG_USER_KEY": "the API key is malformed; it must be 32 characters",
    "ERROR_KEY_DOES_NOT_EXIST": "the API key does not exist; copy it from the dashboard",
    "ERROR_ZERO_BALANCE": "no free thread or balance; lower concurrency or retry later",
    "ERROR_BAD_TOKEN_OR_PAGEURL": "sitekey and pageurl do not match; check for an iframe",
    "ERROR_CAPTCHA_UNSOLVABLE": "not solved after several attempts; retry once",
    "ERROR_INTERNAL_SERVER_ERROR": "transient server error; retry in about 10 seconds",
}


class CaptchaError(RuntimeError):
    pass


def pick(hits: list) -> dict | None:
    """Prefer a solvable widget; fall back to an unsupported one so the agent hands off."""
    return next((h for h in hits if h["type"] != "unsupported"), hits[0] if hits else None)


def task_params(hit: dict) -> dict:
    """Map one detect.js hit to in.php parameters, as the CaptchaAI docs define them."""
    if hit["type"] == "turnstile":
        params = {"method": "turnstile", "sitekey": hit["sitekey"]}
        if hit.get("action"):
            params["action"] = hit["action"]
    else:
        params = {"method": "userrecaptcha", "googlekey": hit["sitekey"],
                  "userAgent": hit["userAgent"]}
        if hit["type"] == "recaptcha_v3":
            params.update(version="v3", action=hit.get("action") or "verify")
        elif hit.get("invisible"):
            params["invisible"] = 1
        if hit.get("domain"):
            params["domain"] = hit["domain"]
    params["pageurl"] = hit["pageurl"]
    return params


def fail(code: str) -> CaptchaError:
    return CaptchaError(f"{code}: {HINTS.get(code, 'see the CaptchaAI error reference')}")


def api(method: str, endpoint: str, **kwargs) -> dict:
    """One CaptchaAI call. Network and parse failures surface as CaptchaError too."""
    try:
        reply = requests.request(method, f"https://ocr.captchaai.com/{endpoint}", timeout=30, **kwargs)
        return reply.json()
    except (requests.RequestException, ValueError) as exc:
        raise CaptchaError(f"CaptchaAI request failed: {exc}") from exc


def solve(hit: dict, cap: float = 120.0) -> str:
    """Submit, wait, poll every 5 s. Blocking: call it from a worker thread in async code."""
    if len(API_KEY) != 32:
        raise CaptchaError("set CAPTCHAAI_API_KEY to your 32-character key")
    params = task_params(hit)
    sub = api("POST", "in.php", data={"key": API_KEY, "json": 1, **params})
    if sub.get("status") != 1:
        raise fail(sub.get("request", "UNKNOWN"))
    started = time.monotonic()
    time.sleep(10 if params["method"] == "turnstile" else 15)
    while time.monotonic() - started < cap:
        res = api("GET", "res.php", params={
            "key": API_KEY, "action": "get", "id": sub["request"], "json": 1})
        if res.get("status") == 1:
            return res["request"]
        if res.get("request") != "CAPCHA_NOT_READY":
            raise fail(res.get("request", "UNKNOWN"))
        time.sleep(5)
    raise CaptchaError(f"task {sub['request']} not ready after {cap:.0f} s")


def handle(evaluate, confirm=None) -> str:
    """Detect, solve, inject via a sync evaluate(js, *args). Returns a status, never the token."""
    hit = pick(json.loads(evaluate(DETECT_JS) or "[]"))
    if hit is None:
        return "No CAPTCHA widget found on this page."
    if hit["type"] == "unsupported":
        return f"{hit['name']} found. It is not handled automatically: stop and ask a human."
    if confirm and not confirm(hit):
        return "The operator declined to solve this CAPTCHA."
    token = solve(hit)
    applied = json.loads(evaluate(INJECT_JS, json.dumps({"hit": hit, "token": token})))
    if not applied["fields"] and not applied["callback"]:
        return f"{hit['type']} solved, but the page has no response field or callback for it."
    return f"{hit['type']} solved and applied. Continue with the form."

Les paramètres suivent la documentation de l'API CaptchaAI :

  • reCAPTCHA v2 : method=userrecaptcha avec googlekey et pageurl, plus invisible=1 pour la variante invisible ;
  • reCAPTCHA v3 : les mêmes paramètres, complétés par version=v3 et action ;
  • Turnstile : method=turnstile avec sitekey, pageurl et une action facultative tirée de data-action ;
  • toutes les tâches reCAPTCHA : le userAgent du navigateur, plus domain=recaptcha.net quand le widget est chargé depuis cet hôte.

Avec json=1, les réponses arrivent sous la forme {"status": 1, "request": ...}. CAPCHA_NOT_READY signifie qu'il faut poursuivre le polling ; tout autre code d'erreur arrête la résolution et renvoie une indication. Les timeouts et les réponses non JSON deviennent eux aussi des CaptchaError, si bien que chaque hook n'a besoin que d'un seul except pour le solveur.

handle() est la seule fonction qu'appellent les hooks. Elle reçoit l'evaluate du framework : le page.evaluate de Playwright s'y branche directement, tandis que les frameworks asynchrones demandent un petit adaptateur.

browser-use : une action solve_captcha et un garde-fou avant chaque étape

browser-use enregistre des fonctions Python comme actions de l'agent, comme l'explique sa documentation Add Tools. Deux détails conditionnent le fonctionnement de l'action. D'abord, le paramètre doit s'appeler exactement browser_session et être typé BrowserSession : browser-use injecte ses paramètres spéciaux par leur nom, et sous un autre nom l'injection échoue sans le moindre message. Ensuite, page.evaluate(), sur la page obtenue par must_get_current_page(), attend une fonction fléchée et renvoie une chaîne, d'où le JSON sous forme de texte que renvoie detect.js.

"""agent_browser_use.py: a browser-use agent with a CaptchaAI-backed solve_captcha action."""
import asyncio

from browser_use import ActionResult, Agent, Browser, BrowserSession, ChatBrowserUse, Tools

from captcha_core import CaptchaError, handle

SITE = "https://qa.example.com"  # a host you own or are authorized to automate
tools = Tools()


async def clear_captcha(browser_session: BrowserSession) -> str:
    page = await browser_session.must_get_current_page()
    loop = asyncio.get_running_loop()

    def evaluate(js: str, *args) -> str:  # lets the sync core drive the async page
        return asyncio.run_coroutine_threadsafe(page.evaluate(js, *args), loop).result()

    return await asyncio.to_thread(handle, evaluate)  # HTTP polling stays off the event loop


@tools.action(
    description="Solve the reCAPTCHA or Cloudflare Turnstile blocking the current page. "
    "Returns a status line. Call it instead of clicking any CAPTCHA widget.",
    allowed_domains=[SITE],
)
async def solve_captcha(browser_session: BrowserSession) -> ActionResult:
    try:
        status = await clear_captcha(browser_session)
    except CaptchaError as exc:
        return ActionResult(error=f"CAPTCHA solve failed: {exc}")
    return ActionResult(extracted_content=status, long_term_memory=status)


async def main() -> None:
    agent = Agent(
        task=f"Open {SITE}/signup, register test user qa-17 and confirm the welcome page loads.",
        llm=ChatBrowserUse(),
        tools=tools,
        browser=Browser(allowed_domains=[SITE]),
        extend_system_message="When a CAPTCHA appears, call solve_captcha once, then continue. "
        "Never click CAPTCHA checkboxes or image tiles. If it says to ask a human, stop.",
    )
    await agent.run(max_steps=30)


if __name__ == "__main__":
    asyncio.run(main())

À aucun moment solve_captcha ne renvoie le token. Son ActionResult place le statut dans extracted_content, le conserve dans long_term_memory pour que les étapes suivantes se souviennent que la page a été débloquée, et signale les échecs via error, que browser-use montre toujours au modèle. Le paramètre allowed_domains, posé à la fois sur l'action et sur le Browser, cantonne les deux à votre hôte autorisé. ChatBrowserUse lit BROWSER_USE_API_KEY, mais n'importe quel modèle de chat pris en charge fait l'affaire. Enfin, le plafond de résolution de 120 secondes tient dans le step_timeout par défaut, fixé à 180 secondes.

Ne plus dépendre du modèle : le hook on_step_start

Plutôt que d'attendre que le modèle appelle l'action, lancez la même vérification avant chaque étape. Les hooks de cycle de vie sont des fonctions asynchrones qui reçoivent l'agent, et agent.browser_session correspond à la session active.

Un piège à connaître : browser-use appelle on_step_start en dehors de la gestion d'erreurs propre à l'étape, si bien qu'une exception levée à cet endroit met fin à agent.run(). Le garde-fou intercepte donc tout, y compris les erreurs d'évaluation sur une page en pleine navigation. Ajoutez-le au même fichier :

async def captcha_guard(agent: Agent) -> None:
    """on_step_start hook: clear a CAPTCHA before the model looks at the page."""
    try:
        status = await clear_captcha(agent.browser_session)
    except Exception as exc:  # a hook exception would end agent.run()
        status = f"CAPTCHA check failed: {exc}"
    if not status.startswith("No CAPTCHA"):
        print("[captcha guard]", status)

# In main(): await agent.run(on_step_start=captcha_guard, max_steps=30)

Deux autres voies : l'extension ou Browser Use Cloud

  • L'extension CaptchaAI, chargée via les args du navigateur, avec sa clé API enregistrée dans un user_data_dir persistant. Le Google Chrome officiel ignore --load-extension à partir de Chrome 137, alors que Chromium et Chrome for Testing l'acceptent toujours.
  • Browser Use Cloud, dont les navigateurs résolvent par défaut les CAPTCHA pris en charge. Pour un navigateur cloud autonome créé avec POST /api/v4/browsers, passez "solveCaptchas": false afin que votre hook reste le seul solveur actif sur la page.

Stagehand v4 en TypeScript : résoudre entre deux act()

Stagehand v4, la version latest sur npm (4.1.0 au moment de la rédaction), a supprimé la boucle agent() intégrée. Vous obtenez un navigateur avec localBrowser.launch() ou browserbase.launch(), vous le passez à Stagehand.create(), et c'est votre code qui tient la page entre deux appels act(). C'est précisément dans cet intervalle que se glisse le hook.

// stagehand-captcha.ts: Stagehand v4 with a local browser; detect.js and inject.js sit alongside.
import { readFileSync } from "node:fs";
import { localBrowser, Stagehand } from "@browserbasehq/stagehand";

type Hit = {
  type: string; name?: string; sitekey?: string; pageurl: string; userAgent?: string;
  action?: string | null; invisible?: boolean; domain?: string | null;
};
const KEY = process.env.CAPTCHAAI_API_KEY ?? "";
const DETECT = readFileSync("detect.js", "utf8");
const INJECT = readFileSync("inject.js", "utf8");
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function solve(hit: Hit): Promise<string> {
  const params: Record<string, string> = hit.type === "turnstile"
    ? { method: "turnstile", sitekey: hit.sitekey ?? "" }
    : { method: "userrecaptcha", googlekey: hit.sitekey ?? "", userAgent: hit.userAgent ?? "" };
  if (hit.type === "recaptcha_v3") Object.assign(params, { version: "v3", action: hit.action || "verify" });
  else if (hit.action) params.action = hit.action;
  if (hit.invisible) params.invisible = "1";
  if (hit.domain) params.domain = hit.domain;
  const submit = await fetch("https://ocr.captchaai.com/in.php", {
    method: "POST",
    body: new URLSearchParams({ key: KEY, json: "1", pageurl: hit.pageurl, ...params }),
  }).then((r) => r.json());
  if (submit.status !== 1) throw new Error(`in.php: ${submit.request}`);
  const started = Date.now();
  await sleep(hit.type === "turnstile" ? 10_000 : 15_000);
  while (Date.now() - started < 120_000) {
    const query = new URLSearchParams({ key: KEY, action: "get", id: submit.request, json: "1" });
    const res = await fetch(`https://ocr.captchaai.com/res.php?${query}`).then((r) => r.json());
    if (res.status === 1) return res.request;
    if (res.request !== "CAPCHA_NOT_READY") throw new Error(`res.php: ${res.request}`);
    await sleep(5_000);
  }
  throw new Error("CaptchaAI task not ready after 120 s");
}

async function main() {
  const browser = await localBrowser.launch({ headless: false });
  try {
    const stagehand = await Stagehand.create({
      browser,
      model: { modelName: "anthropic/claude-sonnet-5", apiKey: process.env.ANTHROPIC_API_KEY },
    });
    try {
      const [page] = await browser.context.pages();
      await page.goto("https://qa.example.com/signup");
      await stagehand.act("Fill in the signup form for test user qa-17");
      const hits: Hit[] = JSON.parse(String(await page.evaluate(`(${DETECT})()`)));
      const hit = hits.find((h) => h.type !== "unsupported") ?? hits[0];
      if (hit?.type === "unsupported") throw new Error(`${hit.name}: hand this run to a human`);
      if (hit) {
        const token = await solve(hit); // stays in this process; the model never sees it
        await page.evaluate(`(${INJECT})(${JSON.stringify(JSON.stringify({ hit, token }))})`);
      }
      await stagehand.act("Click the Create account button");
    } finally {
      await stagehand.close();
    }
  } finally {
    await browser.close(); // Stagehand leaves closing the browser to you
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Chaque script est transmis à la page sous forme de chaîne d'expression, (${DETECT})(), la forme même que browser-use construit en interne : les deux fichiers JavaScript servent donc tels quels aux deux langages. Le Model Gateway de Browserbase ne fonctionne pas avec les navigateurs locaux, d'où le modèle déclaré avec la clé de son fournisseur.

Adaptez ensuite selon votre version et votre style d'intégration :

  • Style par appel d'outils, celui qui a remplacé agent() : ajoutez un outil solveCaptcha à côté de goto et click, qui enchaîne les trois mêmes étapes et ne renvoie que le statut.
  • Stagehand v3 (tag v3-latest) : créez new Stagehand({ env: "LOCAL" }), lancez await stagehand.init(), prenez stagehand.context.pages()[0] ; le reste ne change pas.

D'autres schémas de pilotage de page en Node.js figurent dans l'intégration Playwright pour Node.js.

Browserbase est l'alternative managée. Il active la résolution des CAPTCHA par défaut sur chaque session, annonce jusqu'à 30 secondes d'attente et journalise browserbase-solving-started puis browserbase-solving-finished dans la console de la page. Pour utiliser CaptchaAI à la place, passez solveCaptchas: false dans browserSettings (sur browserbase.launch() en v4, sous browserbaseSessionCreateParams en v3), afin que deux solveurs ne se disputent jamais le même widget.

Skyvern auto-hébergé : pas de solveur intégré, passez par CDP

Skyvern Cloud résout lui-même les CAPTCHA, avec un bouton Take Control sur le flux en direct du navigateur comme recours manuel. La version auto-hébergée est une autre histoire : la documentation de Skyvern précise que la résolution automatique n'y est pas disponible et que l'agent se met en pause 30 secondes sur un CAPTCHA détecté, le temps qu'une personne le résolve dans la fenêtre du navigateur. Faute de point d'accroche côté solveur, vous intervenez au niveau du navigateur.

L'entrée documentée est le type de navigateur cdp-connect. Lancez Chrome vous-même avec les options suivantes :

  • --remote-debugging-port=9222 ;
  • --remote-debugging-address=0.0.0.0, pour que le conteneur Skyvern puisse l'atteindre ;
  • un --user-data-dir dédié, car depuis Chrome 136 le port de débogage est ignoré pour le répertoire de données par défaut.

Quiconque atteint ce port contrôle le navigateur : gardez-le sur un réseau privé. Pointez ensuite le fichier .env de Skyvern vers lui :

BROWSER_TYPE=cdp-connect
BROWSER_REMOTE_DEBUGGING_URL=http://host.docker.internal:9222/
BROWSER_CDP_CONNECT_TIMEOUT_MS=120000

Deux options s'offrent alors à vous. La première consiste à installer une fois pour toutes l'extension CaptchaAI dans ce profil dédié et à y enregistrer votre clé ; comme Skyvern réutilise le navigateur déjà lancé, l'extension est présente à chaque run. La seconde consiste à attacher en CDP un watcher qui applique le socle commun aux onglets de Skyvern :

"""skyvern_watcher.py: clears CAPTCHAs in the Chrome that self-hosted Skyvern drives over CDP."""
import time

from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright

from captcha_core import CaptchaError, handle

ALLOWED = ("https://qa.example.com/",)  # only hosts you are authorized to automate
last: dict[str, str] = {}

with sync_playwright() as pw:
    browser = pw.chromium.connect_over_cdp("http://127.0.0.1:9222")
    while browser.is_connected():
        for page in [p for context in browser.contexts for p in context.pages]:
            if not page.url.startswith(ALLOWED):
                continue
            try:
                status = handle(page.evaluate)
            except CaptchaError as exc:
                status = f"solve failed: {exc}"
            except PlaywrightError:  # the page navigated or closed mid-check
                continue
            if not status.startswith("No CAPTCHA") and last.get(page.url) != status:
                print(page.url, "->", status)
            last[page.url] = status
        time.sleep(2)

Restez lucide sur les délais. Une résolution Turnstile tient largement dans la pause de 30 secondes, mais un défi reCAPTCHA v2 peut durer plus longtemps, et l'agent risque alors de reprendre sur une page encore bloquée : prévoyez pour ces runs un budget d'étapes plus large ou une personne en renfort. Skyvern lit aussi EXTENSIONS et EXTENSIONS_BASE_PATH pour les navigateurs qu'il lance lui-même, mais la clé enregistrée d'une extension est stockée dans le profil : un profil neuf à chaque run démarre sans elle. Le guide Chrome DevTools Protocol détaille la connexion à un navigateur via CDP.

Claude browser use et computer use : le hook se place dans votre exécuteur

L'outil browser use, avec un outil solve_captcha à côté

L'outil browser use d'Anthropic est un toolset côté client. Une seule entrée {"type": "browser_toolset_20260801"} donne à Claude 27 outils membres par défaut, comme navigate, read_page ou left_click, et c'est votre application qui exécute chaque appel sur son propre navigateur. Votre exécuteur est donc le point d'accroche naturel, et la documentation autorise vos propres outils dans le même tableau tools.

"""claude_browser_hook.py: solve_captcha next to browser_toolset_20260801 in your agent loop."""
import anthropic

from captcha_core import CaptchaError, handle

TOOLS = [
    {"type": "browser_toolset_20260801"},
    {
        "name": "solve_captcha",
        "description": "Solve the reCAPTCHA or Cloudflare Turnstile on the active tab with the "
        "operator's CaptchaAI account. Returns a one-line status, never a token.",
        "input_schema": {"type": "object", "properties": {}},
    },
]
SYSTEM = ("You are testing our own staging site. If a CAPTCHA blocks you, call solve_captcha "
          "instead of clicking the widget. If the status says to ask a human, stop and report it.")


def answer(block, page, run_member) -> dict:
    """Build the tool_result for one tool_use block; run_member is your existing executor."""
    if getattr(block, "toolset_name", None) == "browser":
        return {**run_member(block), "toolset_name": "browser"}
    try:
        status, failed = handle(page.evaluate), False
    except CaptchaError as exc:
        status, failed = f"CAPTCHA solve failed: {exc}", True
    return {"type": "tool_result", "tool_use_id": block.id, "content": status, "is_error": failed}


def run(task: str, page, run_member) -> None:
    client = anthropic.Anthropic()
    messages = [{"role": "user", "content": task}]
    while True:
        response = client.messages.create(model="claude-opus-5", max_tokens=16000,
                                          system=SYSTEM, tools=TOOLS, messages=messages)
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            return
        messages.append({"role": "user", "content": [
            answer(block, page, run_member) for block in response.content
            if block.type == "tool_use"]})

Quelques règles de protocole à respecter :

  • les appels aux outils membres arrivent avec "toolset_name": "browser" et leurs résultats doivent le reprendre, alors que le résultat de l'outil personnalisé ne doit pas le porter ;
  • tous les résultats d'un même tour repartent dans un seul message utilisateur ;
  • laissez désactivé le membre facultatif javascript_exec (il l'est par défaut), puisque votre exécuteur lance déjà le script de détection.

Claude ne voit que le statut. Les recommandations de sécurité d'Anthropic s'appliquent toujours : un conteneur isolé avec un profil neuf, une liste de domaines autorisés appliquée au niveau réseau, et une confirmation humaine dans votre exécuteur avant les actions à conséquences, comme un achat ou une modification de compte.

Computer use : sans DOM, pas de hook direct

L'outil computer use se prête moins bien à l'exercice. computer_toolset_20260801 (GA sur l'API Claude et sur Google Cloud, sans en-tête bêta) et son prédécesseur computer_20251124 (en-tête bêta computer-use-2025-11-24, la version qu'exigent Claude Opus 4.7, Opus 4.6, Sonnet 4.6 et Opus 4.5) travaillent à partir de captures d'écran du bureau et de coordonnées, sans aucun accès au DOM.

Le schéma ne s'applique donc que si votre exécuteur dispose aussi d'un accès d'automatisation au navigateur de ce bureau, par exemple une connexion Playwright à son Chromium : appelez alors handle(page.evaluate) avant chaque capture d'écran. Sinon, utilisez l'extension CaptchaAI dans ce navigateur ou confiez l'étape à une personne. Et pour que Claude Desktop ou un autre client MCP puisse demander explicitement une résolution, placez le même socle derrière un serveur MCP dédié à la résolution de CAPTCHA.

OpenAI computer use : un point de contrôle dans le harnais Playwright

Chez OpenAI, l'outil computer en GA s'écrit {"type": "computer"} dans l'API Responses et remplace le type de préversion computer_use_preview. Chaque computer_call porte un tableau ordonné actions que votre harnais exécute avant de renvoyer un computer_call_output contenant un computer_screenshot. OpenAI recommande désormais une intégration par exécution de code pour son modèle le plus récent, mais le point d'accroche reste le même dans les deux styles : après l'exécution des actions, avant le renvoi de l'observation.

"""openai_captcha_gate.py: the computer-use loop from OpenAI's recipes, with a CAPTCHA gate."""
import base64

from openai import OpenAI

from captcha_core import CaptchaError, handle

client = OpenAI()
INSTRUCTIONS = (
    "You are testing our own staging site. A harness handles CAPTCHAs: if one appears, take a "
    "screenshot and wait instead of clicking it. A solved reCAPTCHA checkbox can still look "
    "unticked; continue with the form."
)


def ask_operator(hit: dict) -> bool:
    # OpenAI lists solving CAPTCHA challenges among actions to confirm at action time.
    reply = input(f"{hit['type']} on {hit['pageurl']}: solve it with CaptchaAI? [y/N] ")
    return reply.strip().lower() == "y"


def computer_use_loop(page, response, handle_computer_actions):
    """page is the Playwright page that your action handler drives."""
    while True:
        call = next((item for item in response.output if item.type == "computer_call"), None)
        if call is None:
            return response
        handle_computer_actions(page, call.actions)
        try:
            status = handle(page.evaluate, confirm=ask_operator)  # gate before the screenshot
        except CaptchaError as exc:
            status = f"CAPTCHA solve failed: {exc}"
        if not status.startswith("No CAPTCHA"):
            print("[captcha gate]", status)
        shot = base64.b64encode(page.screenshot()).decode("utf-8")
        response = client.responses.create(
            model="gpt-5.6-sol",
            tools=[{"type": "computer"}],
            instructions=INSTRUCTIONS,  # not carried over by previous_response_id, so resend it
            previous_response_id=response.id,
            input=[{"type": "computer_call_output", "call_id": call.call_id, "output": {
                "type": "computer_screenshot",
                "image_url": f"data:image/png;base64,{shot}",
                "detail": "original",
            }}],
        )

Trois détails comptent :

  1. La confirmation n'a rien de décoratif. Les recettes d'OpenAI classent la résolution de CAPTCHA parmi les actions à faire confirmer par l'utilisateur au moment où elles surviennent : remplacez input() par votre interface d'approbation plutôt que de supprimer l'étape.
  2. instructions accompagne chaque appel, car les instructions ne sont pas reprises lorsque vous poursuivez avec previous_response_id.
  3. L'injection ne redessine pas le widget de Google. La capture suivante montre toujours une case non cochée, d'où la consigne qui demande au modèle de poursuivre avec le formulaire.

Dans le style par exécution de code, exposez handle comme fonction utilitaire du runtime plutôt que de laisser le code généré appeler l'API CaptchaAI avec votre clé. Le guide d'intégration Python et Playwright détaille le pilotage des pages depuis un harnais de ce type.

Agents hébergés sans accès à la page : le cas MultiOn

Un agent hébergé qui fait tourner son propre navigateur et ne renvoie que des résultats ne vous donne aucune page où évaluer du code : il n'y a rien à brancher. MultiOn en est l'exemple typique. Son mode distant, activé par défaut, pilote le navigateur cloud de MultiOn, et son mode local pilote votre Chrome installé via l'extension MultiOn.

Lors de notre vérification du 29 septembre 2026, ses notes de version pour développeurs s'arrêtaient en juin 2024 et www.multion.ai redirigeait vers le site d'une autre société : vérifiez que le service est toujours exploité avant de bâtir quoi que ce soit dessus. Pour tout agent hébergé, trois options restent ouvertes : le solveur de l'éditeur, un mode local où votre navigateur embarque aussi l'extension CaptchaAI, ou une personne qui prend le relais.

Délais et threads quand plusieurs agents tournent en parallèle

Les plafonds de résolution publiés par CaptchaAI sont de moins de 4 secondes pour reCAPTCHA v3, moins de 10 secondes pour Turnstile, moins de 30 secondes pour reCAPTCHA v2 invisible et moins de 60 secondes pour reCAPTCHA v2. Un plafond de 120 secondes les couvre tous avec de la marge, à condition de rester dans les limites de votre framework : le step_timeout de browser-use vaut 180 secondes par défaut, et Skyvern auto-hébergé marque une pause d'environ 30 secondes. Résolvez aussi près que possible de l'envoi du formulaire, car le token reCAPTCHA expire au bout de deux minutes et le token Turnstile au bout de cinq.

CaptchaAI facture au thread simultané, avec des résolutions illimitées par thread, et un thread correspond à un CAPTCHA en cours de résolution : dix agents qui tombent sur un défi au même instant occupent dix threads. BASIC ($15/mois, 5 threads) convient à une poignée d'agents, STANDARD ($30/mois, 15 threads) à une petite flotte ; la grille complète, facturée en dollars US, figure sur la page des tarifs.

Quand tous les threads sont occupés, in.php répond ERROR_ZERO_BALANCE (solde ou threads insuffisants), ce qui, dans une flotte, signifie le plus souvent qu'il faut temporiser puis réessayer. Limitez les résolutions parallèles avec un sémaphore dimensionné sur votre plan, et contrôlez l'usage en direct avec l'action threadsinfo :

"""threads_check.py: how many CaptchaAI threads the plan has and how many are busy."""
import os

import requests

reply = requests.post(
    "https://ocr.captchaai.com/res.php",
    files={"key": (None, os.environ["CAPTCHAAI_API_KEY"]), "action": (None, "threadsinfo")},
    timeout=30,
).json()  # multipart/form-data, as the API reference specifies
print(f"{reply['working_threads']} of {reply['threads']} threads busy")

Le guide des limites de threads va plus loin dans le diagnostic de l'épuisement des threads.

Dépannage : symptômes fréquents et correctifs

Symptôme Cause probable Correctif
L'action browser-use ne touche jamais la page Paramètre qui ne s'appelle pas browser_session Déclarer exactement browser_session: BrowserSession
JavaScript code must start with (...args) => format Du texte avant la fonction fléchée dans detect.js ou inject.js Garder les deux fichiers sous forme de fonctions fléchées nues
Widget visible, mais rien de détecté Frame enfant, shadow root, ou sitekey passé dans une variable Évaluer detect.js dans ce frame, ou étendre le script
L'agent continue de cliquer sur la case ou les vignettes Aucune consigne de résolution dans le prompt, ou instructions OpenAI perdues après le premier tour Ajouter la consigne ; renvoyer instructions à chaque appel
La capture suivante montre un reCAPTCHA non coché L'injection remplit le champ caché ; l'iframe de Google n'est pas redessinée Comportement normal : envoyer le formulaire ou laisser le callback s'exécuter
Le widget se réinitialise juste après l'injection Browserbase ou un navigateur Browser Use Cloud résout le même widget Passer solveCaptchas à false sur cette session ou ce navigateur
Token refusé après une longue pause Plus de deux minutes (reCAPTCHA) ou de cinq (Turnstile), ou token réutilisé Résoudre juste avant l'envoi
ERROR_BAD_TOKEN_OR_PAGEURL Widget servi depuis une autre origine dans une iframe Utiliser l'URL de l'iframe comme pageurl
ERROR_ZERO_BALANCE uniquement quand beaucoup d'agents tournent Tous les threads du plan sont occupés Sémaphore dimensionné sur le plan, backoff, ou plan supérieur
L'extension CaptchaAI ne se charge jamais Le Google Chrome officiel ignore --load-extension depuis la version 137 Utiliser Chromium ou Chrome for Testing

Questions fréquentes

Browser Use Cloud ou Browserbase résolvent déjà les CAPTCHA : CaptchaAI sert-il encore ?

Oui, dès que vous voulez maîtriser l'étape vous-même ou que vous pilotez un navigateur local. Les navigateurs Browser Use Cloud résolvent par défaut les CAPTCHA pris en charge, et Browserbase active la résolution sur chaque session. Pour confier l'étape à CaptchaAI, passez solveCaptchas à false sur le navigateur ou la session concernés ; sinon, deux solveurs travaillent sur le même widget, qui risque alors de se réinitialiser juste après l'injection.

Combien de threads CaptchaAI prévoir pour une flotte d'agents ?

Un par CAPTCHA en cours de résolution au même moment. Ce qui compte, ce n'est pas la taille de la flotte mais les collisions : dix agents qui tombent sur un défi à la même seconde occupent dix threads. Suivez l'occupation réelle avec l'action threadsinfo et plafonnez les résolutions parallèles avec un sémaphore ; BASIC offre 5 threads, STANDARD 15.

Pourquoi le formulaire refuse-t-il un token pourtant résolu ?

Le plus souvent, parce qu'il a expiré ou déjà servi. Un token reCAPTCHA reste valable deux minutes, un token Turnstile cinq, et chacun ne s'utilise qu'une fois : résolvez juste avant l'envoi, pas au début du parcours. Si le token est frais, vérifiez que la page lit bien le champ caché ; certains sites n'écoutent que leur data-callback, que inject.js déclenche lorsqu'il est déclaré.

Que faire quand la page affiche un hCaptcha ou un FunCaptcha ?

Passez la main à une personne. CaptchaAI ne prend en charge ni hCaptcha ni FunCaptcha, et GeeTest v4 est annoncé à venir mais n'est pas encore disponible. Le détecteur marque ces trois familles comme non prises en charge, et chaque hook demande à l'agent de s'arrêter plutôt que de réessayer en boucle.

L'intégration fonctionne-t-elle en mode headless ?

Côté API, oui : la détection et l'injection sont des scripts de page, et la résolution se résume à des échanges HTTP. Côté extension, tout dépend du build et du mode du navigateur ; consultez le guide sur l'extension CaptchaAI avec Chrome headless.

Pour démarrer : branchez solve_captcha sur votre agent

Déposez detect.js, inject.js et captcha_core.py à côté de votre agent, enregistrez le hook qui correspond à votre framework et commencez sur un site que vous contrôlez, par exemple une préproduction hébergée chez OVHcloud ou Scaleway. Surveillez les lignes d'état dans les logs de l'agent, puis dimensionnez les threads selon le nombre d'agents qui tournent en parallèle.

Obtenez votre clé API CaptchaAI et branchez l'outil solve_captcha sur votre agent

Les commentaires sont désactivés pour cet article.