Un script Playwright qui passe en local et casse en intégration continue bute presque toujours sur le même mur : un défi CAPTCHA affiché à la connexion ou à l'envoi d'un formulaire. La réponse tient en trois gestes — lire le sitekey dans le DOM, envoyer la tâche à l'API CaptchaAI, réinjecter le token avant de soumettre. Playwright ne résout rien lui-même : il pilote la page, la résolution se fait côté serveur. Voici le code correspondant en Python (synchrone et asynchrone), en Node.js, puis pour Cloudflare Turnstile.
Ce que Playwright fait, et ce que CaptchaAI ajoute
Playwright apporte trois atouts ici : l'attente automatique des éléments, une même API sur Chromium, Firefox et WebKit, et l'interception réseau. Aucun ne remplace la résolution du défi. Les rôles sont donc fixes :
- Playwright ouvre la page, attend le widget, lit l'attribut
data-sitekey, injecte la valeur reçue et soumet le formulaire. - CaptchaAI reçoit le sitekey et l'URL, résout le défi et renvoie un token via
in.phppuisres.php.
Playwright, Selenium ou Puppeteer : ce qui change pour un flux CAPTCHA
| Critère | Playwright | Selenium | Puppeteer |
|---|---|---|---|
| Langages | Python, Node.js, C#, Java | Python, Java, C#, Ruby, JS | Node.js |
| Navigateurs | Chromium, Firefox, WebKit | Chrome, Firefox, Edge, Safari | Chromium |
| Attente automatique | Intégrée | Attentes explicites | Partielle |
| Interception réseau | Oui | Limitée | Oui |
| Intégration CaptchaAI | Même API | Même API | Même API |
L'écart utile se situe sur la troisième ligne : un widget CAPTCHA est monté en JavaScript après le chargement, et l'attente automatique évite les sélecteurs vides. Pour le reste, l'intégration CaptchaAI est identique dans les trois outils.
Prérequis avant la première ligne de code
| Élément | Détail |
|---|---|
| Python | pip install playwright requests puis playwright install |
| Node.js | npm install playwright axios |
| Clé API CaptchaAI | Depuis captchaai.com |
Stockez la clé API dans une variable d'environnement ; les scripts ci-dessous gardent le placeholder YOUR_API_KEY.
Python : brancher CaptchaAI sur une session Playwright
Étape 1 : la fonction de résolution et l'interrogation du résultat
La fonction envoie la tâche puis interroge res.php toutes les 5 secondes. Tant que la réponse vaut CAPCHA_NOT_READY, le polling continue ; toute autre réponse hors OK| est une erreur à faire remonter immédiatement.
from playwright.sync_api import sync_playwright
import requests
import time
API_KEY = "YOUR_API_KEY"
def solve_recaptcha(site_key, page_url):
resp = requests.get("https://ocr.captchaai.com/in.php", params={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": site_key,
"pageurl": page_url
})
if not resp.text.startswith("OK|"):
raise Exception(resp.text)
task_id = resp.text.split("|")[1]
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get", "id": task_id
})
if result.text == "CAPCHA_NOT_READY": continue
if result.text.startswith("OK|"): return result.text.split("|")[1]
raise Exception(result.text)
raise TimeoutError()
Étape 2 : le parcours de connexion complet
Playwright remplit les identifiants, cherche le conteneur .g-recaptcha et n'appelle l'API que si le widget est présent. Ce test conditionnel rend le script utilisable sur les pages qui n'affichent le défi qu'une fois sur trois.
def login_with_captcha(url, username, password):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
)
page = context.new_page()
page.goto(url)
# Fill login form
page.fill("#username", username)
page.fill("#password", password)
# Check for reCAPTCHA
recaptcha = page.query_selector(".g-recaptcha")
if recaptcha:
site_key = recaptcha.get_attribute("data-sitekey")
print(f"Solving reCAPTCHA: {site_key}")
token = solve_recaptcha(site_key, page.url)
# Inject token
page.evaluate(f"""
document.getElementById('g-recaptcha-response').innerHTML = '{token}';
document.getElementById('g-recaptcha-response').style.display = '';
""")
# Submit
page.click('button[type="submit"]')
page.wait_for_load_state("networkidle")
print(f"Current URL: {page.url}")
content = page.content()
browser.close()
return content
result = login_with_captcha(
"https://example.com/login",
"user@example.com",
"password123"
)
L'injection passe par page.evaluate() et réaffiche le champ g-recaptcha-response, masqué par défaut.
Étape 3 : la variante asynchrone pour traiter plusieurs pages
Au-delà de quelques pages par exécution, l'attente bloquante devient le poste de coût principal. La version async avec aiohttp libère la boucle d'événements pendant la résolution et laisse plusieurs onglets avancer ensemble.
from playwright.async_api import async_playwright
import aiohttp
import asyncio
async def solve_recaptcha_async(site_key, page_url):
async with aiohttp.ClientSession() as session:
params = {
"key": API_KEY, "method": "userrecaptcha",
"googlekey": site_key, "pageurl": page_url
}
async with session.get("https://ocr.captchaai.com/in.php", params=params) as resp:
text = await resp.text()
task_id = text.split("|")[1]
for _ in range(60):
await asyncio.sleep(5)
params = {"key": API_KEY, "action": "get", "id": task_id}
async with session.get("https://ocr.captchaai.com/res.php", params=params) as resp:
text = await resp.text()
if text == "CAPCHA_NOT_READY": continue
if text.startswith("OK|"): return text.split("|")[1]
raise Exception(text)
raise TimeoutError()
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
await page.goto("https://example.com/form")
site_key = await page.get_attribute(".g-recaptcha", "data-sitekey")
token = await solve_recaptcha_async(site_key, page.url)
await page.evaluate(f"document.getElementById('g-recaptcha-response').innerHTML = '{token}'")
await page.click('button[type="submit"]')
await browser.close()
asyncio.run(main())
Node.js : le même enchaînement avec Axios
Le portage Node.js suit les mêmes étapes. Notez page.url() avec les parenthèses : c'est une méthode en JavaScript, une propriété en Python, et l'oubli est l'erreur la plus banale ici.
const { chromium } = require("playwright");
const axios = require("axios");
const API_KEY = "YOUR_API_KEY";
async function solveRecaptcha(siteKey, pageUrl) {
const submit = await axios.get("https://ocr.captchaai.com/in.php", {
params: {
key: API_KEY,
method: "userrecaptcha",
googlekey: siteKey,
pageurl: pageUrl,
},
});
const taskId = submit.data.split("|")[1];
while (true) {
await new Promise((r) => setTimeout(r, 5000));
const result = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: taskId },
});
if (result.data === "CAPCHA_NOT_READY") continue;
if (result.data.startsWith("OK|")) return result.data.split("|")[1];
throw new Error(result.data);
}
}
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto("https://example.com/login");
// Fill form
await page.fill("#username", "user@example.com");
await page.fill("#password", "password123");
// Solve CAPTCHA
const siteKey = await page.getAttribute(".g-recaptcha", "data-sitekey");
if (siteKey) {
const token = await solveRecaptcha(siteKey, page.url());
await page.evaluate(
(t) => (document.getElementById("g-recaptcha-response").innerHTML = t),
token
);
}
// Submit
await page.click('button[type="submit"]');
await page.waitForLoadState("networkidle");
console.log("Logged in:", page.url());
await browser.close();
})();
Cloudflare Turnstile : détecter et résoudre
Turnstile change deux choses : le conteneur est .cf-turnstile et le paramètre s'appelle sitekey, pas googlekey. Le champ à alimenter est cf-turnstile-response. Le reste du flux ne bouge pas.
# Detect Turnstile
turnstile = page.query_selector(".cf-turnstile")
if turnstile:
site_key = turnstile.get_attribute("data-sitekey")
resp = requests.get("https://ocr.captchaai.com/in.php", params={
"key": API_KEY, "method": "turnstile",
"sitekey": site_key, "pageurl": page.url
})
task_id = resp.text.split("|")[1]
# Poll and inject...
La résolution aboutit typiquement en moins de 10 s ; prévoyez quand même un timeout côté script.
Scénario : une équipe QA francophone en intégration continue
Prenons une équipe produit à Lyon qui teste chaque nuit le tunnel d'inscription de son SaaS. Les runners GitLab CI tournent sur Scaleway ou OVHcloud, le front est derrière Cloudflare, et Turnstile ne s'affiche qu'aux adresses IP de datacenter — celles des runners. La suite passe sur les postes de développement et échoue chaque nuit à 3 h.
Le correctif tient en trois décisions. La clé API vient du gestionnaire de variables CI, jamais d'un .env versionné. La résolution n'est déclenchée que dans le job « parcours complet », pas dans les tests unitaires : la consommation de threads reste basse. Enfin, les comptes de test utilisent des adresses dédiées de l'entreprise — les traces étant conservées, c'est la lecture RGPD la plus simple à défendre en revue interne.
Threads, volume et budget
CaptchaAI facture au thread concurrent, pas à la résolution : ce qui compte est le nombre de défis simultanés, pas leur total mensuel. Une suite qui lance quatre navigateurs en parallèle tient sur BASIC ($15/mois, 5 threads) ; une ferme de tests avec des dizaines de parcours simultanés relève d'ADVANCE ($90/mois, 50 threads). Facturation en dollars US.
Côté types, restez sur ce qui est réellement pris en charge : reCAPTCHA v2 et v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, les CAPTCHA image/OCR et les grilles d'images, plus CaptchaFox (bêta), Friendly Captcha (bêta) et Lemin (bêta).
Dépannage : les pannes les plus fréquentes
| Problème | Cause probable | Correctif |
|---|---|---|
page.query_selector renvoie None |
Widget monté après le chargement initial | Attendez le conteneur avec page.wait_for_selector() |
| Token injecté mais formulaire refusé | Identifiant du champ de réponse différent | Inspectez le DOM et ciblez le textarea réellement présent |
| Playwright s'arrête net dans Docker | Bibliothèques système du navigateur manquantes | Ajoutez playwright install-deps à l'image |
| Le défi réapparaît après résolution | La page attend l'exécution d'un callback | Déclenchez le callback depuis page.evaluate() |
CAPCHA_NOT_READY en boucle jusqu'au timeout |
Polling trop rapproché ou threads saturés | Gardez 5 s d'intervalle et vérifiez solde et threads disponibles |
Questions fréquentes
Faut-il exécuter Playwright en mode headless pour que la résolution fonctionne ?
Non. La résolution a lieu côté serveur, à partir du sitekey et de l'URL : le mode d'affichage local n'entre pas en jeu.
CaptchaAI prend-il en charge hCaptcha depuis Playwright ?
Non — hCaptcha n'est pas pris en charge, pas plus que FunCaptcha (Arkose Labs). GeeTest v4 est annoncé à venir, donc pas disponible non plus. Sur une page protégée par l'un de ces types, votre scénario de test doit suivre un autre chemin.
Combien de temps prévoir entre l'envoi de la tâche et le token ?
Moins de 10 s sur Cloudflare Turnstile, moins de 60 s sur reCAPTCHA v2. Dimensionnez le timeout Playwright au-dessus de ces plafonds, sinon l'attente de page expirera avant le token.
Pourquoi le script fonctionne-t-il en local et échoue-t-il sur le runner CI ?
Le plus souvent, la page ne sert pas le même contenu à une IP de datacenter qu'à une connexion résidentielle : le défi apparaît en CI alors qu'il était absent sur votre poste. Rendez la détection du widget conditionnelle.