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
- Résoudre le callback reCAPTCHA v2 via l'API
- Comparatif GeeTest v3 et Cloudflare Turnstile
- Gérer reCAPTCHA v2 et Turnstile sur un même site
É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 :