Tutorials

CAPTCHA dans les modaux pop-up : détection et injection de jetons

Un CAPTCHA affiché dans un modal ne livre pas son sitekey dans le HTML initial : la clé n'est injectée dans le DOM qu'au moment où le modal s'ouvre. Pour l'automatiser, il faut déclencher le modal, attendre le rendu du widget, lire le sitekey dans le DOM du modal, envoyer la tâche à CaptchaAI, puis injecter le jeton avant que le modal ne se referme ou que la session n'expire. Ce guide déroule ce flux de bout en bout, en Python (Playwright) et en Node.js (Puppeteer), pour reCAPTCHA v2 et Cloudflare Turnstile.

Où les CAPTCHA modaux apparaissent

Le même défi revient sous plusieurs formes :

  • Modal de connexion — un clic sur « Connexion » charge le CAPTCHA dans le div de superposition.
  • Interstitiel anti-bot — déclenché automatiquement après un comportement jugé suspect, il bloque la page en plein écran.
  • Confirmation de paiement — l'envoi du formulaire ouvre un modal de vérification.
  • Limite de débit atteinte — trop de requêtes font apparaître un modal porteur d'un CAPTCHA.
  • Consentement aux cookies — dès la première visite, le CAPTCHA est intégré à la boîte de consentement.

Le modal de consentement aux cookies imposé par le RGPD en est l'exemple le plus courant en Europe. Point commun à tous ces modèles : le widget n'existe pas dans le DOM tant que l'utilisateur n'a pas agi, ce qui casse tout script qui lit le sitekey trop tôt.

Détecter, résoudre et injecter avec Playwright (Python)

L'appel handle_modal_captcha enchaîne les cinq étapes de bout en bout. La détection couvre reCAPTCHA v2 (.g-recaptcha) et Cloudflare Turnstile (.cf-turnstile) — les deux types résolus ici. Un widget hCaptcha est repéré mais pas résolu : CaptchaAI ne prend pas hCaptcha en charge (pas encore).

import requests
import time
from playwright.sync_api import sync_playwright

API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"


def solve_captcha(sitekey, pageurl, method="userrecaptcha"):
    """Submit and poll a CAPTCHA."""
    params = {
        "key": API_KEY,
        "method": method,
        "json": 1,
    }
    if method == "userrecaptcha":
        params["googlekey"] = sitekey
        params["pageurl"] = pageurl
    elif method == "turnstile":
        params["sitekey"] = sitekey
        params["pageurl"] = pageurl

    resp = requests.post(SUBMIT_URL, data=params, timeout=30).json()
    if resp.get("status") != 1:
        raise RuntimeError(f"Submit failed: {resp.get('request')}")

    task_id = resp["request"]
    for _ in range(60):
        time.sleep(5)
        poll = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "get",
            "id": task_id, "json": 1,
        }, timeout=15).json()

        if poll.get("request") == "CAPCHA_NOT_READY":
            continue
        if poll.get("status") == 1:
            return poll["request"]
        raise RuntimeError(f"Solve failed: {poll.get('request')}")

    raise RuntimeError("Timeout")


def detect_modal_captcha(page):
    """
    Detect CAPTCHA inside a visible modal/dialog.
    Returns (sitekey, method) or (None, None).
    """
    return page.evaluate("""
        () => {
            // Find visible modals
            const modalSelectors = [
                '.modal.show',
                '.modal[style*="display: block"]',
                'dialog[open]',
                '[role="dialog"]:not([aria-hidden="true"])',
                '.overlay.visible',
                '.popup.active',
                '[class*="modal"][class*="open"]',
            ];

            let modal = null;
            for (const sel of modalSelectors) {
                const el = document.querySelector(sel);
                if (el && el.offsetParent !== null) {
                    modal = el;
                    break;
                }
            }

            // If no modal found, search entire document
            const searchRoot = modal || document;

            // Check for reCAPTCHA
            const recaptcha = searchRoot.querySelector('.g-recaptcha[data-sitekey]');
            if (recaptcha) {
                return { sitekey: recaptcha.dataset.sitekey, method: 'userrecaptcha' };
            }

            // Check for Turnstile
            const turnstile = searchRoot.querySelector('.cf-turnstile[data-sitekey]');
            if (turnstile) {
                return { sitekey: turnstile.dataset.sitekey, method: 'turnstile' };
            }

            // Check for hCaptcha
            const hcaptcha = searchRoot.querySelector('.h-captcha[data-sitekey]');
            if (hcaptcha) {
                return { sitekey: hcaptcha.dataset.sitekey, method: 'hcaptcha' };
            }

            return null;
        }
    """)


def inject_token_in_modal(page, token, method="userrecaptcha"):
    """Inject token into the CAPTCHA inside the modal."""
    if method == "userrecaptcha":
        page.evaluate("""
            (token) => {
                // Find response textarea (may be inside modal)
                const textareas = document.querySelectorAll('#g-recaptcha-response, [name="g-recaptcha-response"]');
                textareas.forEach(ta => {
                    ta.value = token;
                    ta.style.display = 'block';
                });

                // Trigger callback
                if (typeof ___grecaptcha_cfg !== 'undefined') {
                    Object.values(___grecaptcha_cfg.clients).forEach(client => {
                        Object.values(client).forEach(val => {
                            if (val && typeof val === 'object') {
                                Object.values(val).forEach(v => {
                                    if (v && typeof v.callback === 'function') v.callback(token);
                                });
                            }
                        });
                    });
                }
            }
        """, token)
    elif method == "turnstile":
        page.evaluate("""
            (token) => {
                const inputs = document.querySelectorAll('[name="cf-turnstile-response"]');
                inputs.forEach(inp => { inp.value = token; });

                if (typeof window.turnstileCallback === 'function') {
                    window.turnstileCallback(token);
                }
            }
        """, token)


def handle_modal_captcha(page, trigger_selector=None, timeout=10000):
    """
    Full workflow: trigger modal, detect CAPTCHA, solve, inject.
    """
    # Step 1: Trigger the modal if needed
    if trigger_selector:
        print(f"Clicking trigger: {trigger_selector}")
        page.click(trigger_selector)

    # Step 2: Wait for modal to become visible
    print("Waiting for modal...")
    modal_selectors = [
        ".modal.show",
        "dialog[open]",
        '[role="dialog"]:not([aria-hidden="true"])',
        ".popup.active",
    ]

    modal_visible = False
    for selector in modal_selectors:
        try:
            page.wait_for_selector(selector, timeout=timeout)
            modal_visible = True
            print(f"  Modal detected: {selector}")
            break
        except Exception:
            continue

    if not modal_visible:
        print("  No modal detected")
        return None

    # Step 3: Wait for CAPTCHA to render inside modal
    time.sleep(2)  # Brief pause for dynamic CAPTCHA loading

    captcha_info = detect_modal_captcha(page)
    if not captcha_info:
        print("  No CAPTCHA found in modal")
        return None

    sitekey = captcha_info["sitekey"]
    method = captcha_info["method"]
    print(f"  Found {method} CAPTCHA: {sitekey[:20]}...")

    # Step 4: Solve via CaptchaAI
    token = solve_captcha(sitekey, page.url, method)
    print(f"  Solved: {token[:30]}...")

    # Step 5: Inject token
    inject_token_in_modal(page, token, method)
    print("  Token injected")

    return token


def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()
        page.goto("https://example.com")
        page.wait_for_load_state("networkidle")

        # Handle CAPTCHA that appears in login modal
        token = handle_modal_captcha(
            page,
            trigger_selector="button#login-btn",
            timeout=10000,
        )

        if token:
            # Fill form fields inside modal
            page.fill('dialog input[name="email"]', "user@example.com")
            page.fill('dialog input[name="password"]', "password123")

            # Submit modal form
            page.click('dialog button[type="submit"]')
            page.wait_for_load_state("networkidle")

        browser.close()


main()

La même logique en Node.js (Puppeteer)

Sous Node.js, la version Puppeteer suit la même structure — waitForModal, detectModalCaptcha, résolution puis injection — seule l'API du navigateur change. Le champ de réponse reste g-recaptcha-response pour reCAPTCHA v2 et cf-turnstile-response pour Turnstile.

const puppeteer = require("puppeteer");

const API_KEY = "YOUR_API_KEY";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";

async function solveCaptcha(sitekey, pageurl, method = "userrecaptcha") {
  const body = new URLSearchParams({ key: API_KEY, method, json: "1" });
  if (method === "userrecaptcha") { body.set("googlekey", sitekey); body.set("pageurl", pageurl); }
  else if (method === "turnstile") { body.set("sitekey", sitekey); body.set("pageurl", pageurl); }

  const resp = await (await fetch(SUBMIT_URL, { method: "POST", body })).json();
  if (resp.status !== 1) throw new Error(`Submit: ${resp.request}`);

  const taskId = resp.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const url = `${RESULT_URL}?key=${API_KEY}&action=get&id=${taskId}&json=1`;
    const poll = await (await fetch(url)).json();
    if (poll.request === "CAPCHA_NOT_READY") continue;
    if (poll.status === 1) return poll.request;
    throw new Error(`Solve: ${poll.request}`);
  }
  throw new Error("Timeout");
}

async function waitForModal(page, timeout = 10000) {
  const selectors = [".modal.show", "dialog[open]", '[role="dialog"]', ".popup.active"];
  for (const sel of selectors) {
    try {
      await page.waitForSelector(sel, { visible: true, timeout });
      return sel;
    } catch {}
  }
  return null;
}

async function detectModalCaptcha(page) {
  return page.evaluate(() => {
    const checks = [
      { sel: ".g-recaptcha[data-sitekey]", method: "userrecaptcha" },
      { sel: ".cf-turnstile[data-sitekey]", method: "turnstile" },
    ];
    for (const { sel, method } of checks) {
      const el = document.querySelector(sel);
      if (el && el.dataset.sitekey) return { sitekey: el.dataset.sitekey, method };
    }
    return null;
  });
}

async function handleModalCaptcha(page, triggerSelector) {
  // Trigger modal
  if (triggerSelector) await page.click(triggerSelector);

  // Wait for modal
  const modalSel = await waitForModal(page);
  if (!modalSel) { console.log("No modal found"); return null; }
  console.log(`Modal visible: ${modalSel}`);

  // Wait for CAPTCHA render
  await new Promise((r) => setTimeout(r, 2000));

  const info = await detectModalCaptcha(page);
  if (!info) { console.log("No CAPTCHA in modal"); return null; }

  console.log(`Found ${info.method}: ${info.sitekey.substring(0, 20)}...`);
  const token = await solveCaptcha(info.sitekey, page.url(), info.method);
  console.log(`Solved: ${token.substring(0, 30)}...`);

  // Inject token
  await page.evaluate((t, method) => {
    if (method === "userrecaptcha") {
      document.querySelectorAll("#g-recaptcha-response").forEach((el) => { el.value = t; });
    } else if (method === "turnstile") {
      document.querySelectorAll('[name="cf-turnstile-response"]').forEach((el) => { el.value = t; });
    }
  }, token, info.method);

  return token;
}

(async () => {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();
  await page.goto("https://example.com", { waitUntil: "networkidle2" });

  const token = await handleModalCaptcha(page, "button#login-btn");
  if (token) {
    await page.type('dialog input[name="email"]', "user@example.com");
    await page.click('dialog button[type="submit"]');
    await page.waitForNavigation();
  }

  await browser.close();
})();

Les contraintes de timing à anticiper

Le code marche en conditions idéales ; en production, le modal a sa propre durée de vie. S'il se referme ou si la session expire pendant la résolution, le jeton devient inutile. Calez votre séquence sur ces quatre facteurs.

Facteur Impact Comment l'anticiper
Fermeture automatique du modal Il se referme avant la fin de la résolution Lancez la résolution dès la détection
Expiration de la session La session serveur expire pendant l'attente Maintenez-la active avec un heartbeat en arrière-plan
Délai de rendu du widget Le widget met 1–3 s à s'afficher Attendez 2 s après l'ouverture avant de lire le sitekey
Expiration du jeton pendant la saisie Le jeton expire pendant le remplissage Résolvez le CAPTCHA en dernier, une fois les autres champs remplis

Dépannage

La plupart des échecs viennent du timing ou d'un callback jamais déclenché.

Problème Cause Correctif
Modal détecté, mais aucun CAPTCHA trouvé Le widget se charge de façon asynchrone après l'ouverture Augmentez le délai ; utilisez un MutationObserver pour repérer l'insertion du widget
Jeton injecté, mais le modal ne se ferme pas Le callback n'a pas été déclenché Appelez explicitement le callback du CAPTCHA
Le modal se ferme pendant la résolution Fermeture automatique programmée Neutralisez le minuteur du modal avec clearTimeout()
Le sitekey change à chaque ouverture Instances CAPTCHA générées dynamiquement Lisez toujours le sitekey dans le DOM du modal, ne le mettez jamais en cache
Le clic n'ouvre pas le modal Élément non interactif ou masqué par une superposition Utilisez page.dispatch_event ou attendez qu'il soit cliquable

Questions fréquentes

Pourquoi le sitekey n'apparaît-il qu'après l'ouverture du modal ?

Parce que le widget est injecté dynamiquement : le HTML initial ne contient que le bouton déclencheur, et le sitekey n'arrive qu'à l'ouverture du modal. D'où la règle : lisez la clé dans le DOM du modal, jamais dans la source de la page.

CaptchaAI prend-il en charge hCaptcha affiché dans un modal ?

Non — hCaptcha n'est pas pris en charge, dans un modal comme ailleurs. CaptchaAI résout ici reCAPTCHA v2 et Cloudflare Turnstile ; un hCaptcha est signalé par la détection, mais pas résolu.

Comment détecter un modal qui s'ouvre sans action de l'utilisateur ?

Placez un MutationObserver avant de charger la page. Il surveille les ajouts au DOM et se déclenche dès qu'un modal apparaît, sans dépendre d'un clic.

Combien de temps ai-je avant que le jeton n'expire ?

Les jetons ont une durée de vie courte, souvent de l'ordre de deux minutes. Remplissez donc les autres champs d'abord et résolvez le CAPTCHA en dernier, juste avant l'envoi.

Articles connexes

Étapes suivantes

Gérez les CAPTCHA modaux sans casser vos parcours automatisés : récupérez votre clé API CaptchaAI et mettez en place détection, résolution et injection.

Guides associés :

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