Un scénario de test qui échoue pendant la campagne nocturne d'un portail d'inscription vient rarement de votre code : un reCAPTCHA v2 s'est intercalé entre l'authentification et la validation du formulaire. La correction tient en deux gestes : détecter le widget dans le HTML, puis récupérer un token via l'API CaptchaAI et le poster avec le formulaire.
Voici où ces défis surgissent dans un parcours étudiant, comment les traiter en Python puis en JavaScript, et quels threads prévoir.
Pourquoi les périodes d'inscription concentrent les défis
Un portail d'inscription vit intensément quelques semaines par an. Prenez une université belge qui ouvre ses créneaux de travaux pratiques le 1er septembre à 9 h : en dix minutes, des milliers d'étudiants se connectent, cherchent le même cours et valident le même formulaire. Les protections du portail sont calibrées pour ce pic — et c'est cette fenêtre que l'équipe QA doit rejouer la veille en préproduction.
Les scénarios les plus critiques (dernière place, bascule sur liste d'attente, date limite) sont donc ceux qui déclenchent le plus de défis. Désactiver ces protections en préproduction reviendrait à valider un parcours que l'étudiant ne verra jamais.
Où le défi CAPTCHA apparaît dans le parcours étudiant
| Étape du parcours | Type de défi | Ce que la protection vise |
|---|---|---|
| Connexion à l'espace étudiant | reCAPTCHA v2 | Bloquer les connexions automatisées en série |
| Recherche dans le catalogue de cours | reCAPTCHA v2 | Limiter les requêtes répétées |
| Ajout ou retrait d'un cours | reCAPTCHA v2 | Empêcher l'inscription automatisée |
| Inscription sur liste d'attente | reCAPTCHA v2 | Filtrer le trafic automatisé lors des désistements |
| Planificateur d'horaires | Cloudflare Turnstile | Protéger un outil interactif |
| Demande de relevé de notes | reCAPTCHA v2 | Vérifier qu'un humain est à l'origine de la demande |
Préparer un environnement de test défendable
Testez votre propre portail ou une préproduction autorisée, jamais l'espace d'un établissement tiers. Trois réglages font l'essentiel :
- Des comptes étudiants fictifs, jamais une copie de la base des inscrits réels, et aucune donnée personnelle dans les logs : vos obligations RGPD valent aussi en préproduction.
- La clé API dans une variable d'environnement, jamais dans le dépôt.
- Le sitekey relevé à chaque exécution plutôt que codé en dur : il diffère presque toujours entre préproduction et production.
Résoudre le défi dans un parcours Python
La classe ci-dessous suit le parcours d'un étudiant : connexion, recherche, inscription, contrôle des places. Chaque méthode vérifie d'abord la présence d'un widget reCAPTCHA, extrait le sitekey, envoie la tâche à in.php, interroge res.php toutes les trois secondes, puis poste le champ g-recaptcha-response.
La session requests est conservée d'un cas de test à l'autre, ce qui évite un défi à chaque appel.
import requests
import time
import re
class RegistrationTester:
def __init__(self, api_key, portal_url):
self.api_key = api_key
self.portal_url = portal_url
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
def test_login(self, student_id, password):
"""Test login flow with CAPTCHA handling."""
login_page = self.session.get(f"{self.portal_url}/login")
site_key = self._extract_site_key(login_page.text)
token = self._solve_recaptcha(site_key, f"{self.portal_url}/login")
response = self.session.post(f"{self.portal_url}/login", data={
"studentId": student_id,
"password": password,
"g-recaptcha-response": token
})
return {
"status": response.status_code,
"redirected": response.url != f"{self.portal_url}/login",
"success": "dashboard" in response.url or response.status_code == 200
}
def test_course_search(self, term, department, level=None):
"""Test course search returns correct results."""
params = {"term": term, "dept": department}
if level:
params["level"] = level
response = self.session.get(
f"{self.portal_url}/courses/search", params=params
)
if self._has_captcha(response.text):
site_key = self._extract_site_key(response.text)
token = self._solve_recaptcha(
site_key, f"{self.portal_url}/courses/search"
)
response = self.session.post(
f"{self.portal_url}/courses/search",
data={**params, "g-recaptcha-response": token}
)
courses = self._parse_courses(response.text)
return {
"count": len(courses),
"courses": courses,
"all_match_dept": all(
c["department"] == department for c in courses
)
}
def test_enrollment(self, course_id, section):
"""Test enrolling in a course section."""
enroll_page = self.session.get(
f"{self.portal_url}/enroll/{course_id}/{section}"
)
if self._has_captcha(enroll_page.text):
site_key = self._extract_site_key(enroll_page.text)
token = self._solve_recaptcha(
site_key,
f"{self.portal_url}/enroll/{course_id}/{section}"
)
else:
token = None
form_data = {
"courseId": course_id,
"section": section,
"confirm": "true"
}
if token:
form_data["g-recaptcha-response"] = token
response = self.session.post(
f"{self.portal_url}/enroll/submit",
data=form_data
)
return {
"status": response.status_code,
"enrolled": "success" in response.text.lower(),
"waitlisted": "waitlist" in response.text.lower(),
"error": self._extract_error(response.text)
}
def test_seat_availability(self, course_ids):
"""Test seat availability display for multiple courses."""
results = []
for course_id in course_ids:
response = self.session.get(
f"{self.portal_url}/courses/{course_id}"
)
if self._has_captcha(response.text):
site_key = self._extract_site_key(response.text)
token = self._solve_recaptcha(
site_key, f"{self.portal_url}/courses/{course_id}"
)
response = self.session.post(
f"{self.portal_url}/courses/{course_id}",
data={"g-recaptcha-response": token}
)
availability = self._parse_availability(response.text)
results.append({"course_id": course_id, **availability})
return results
def _has_captcha(self, html):
return "g-recaptcha" in html or "recaptcha" in html.lower()
def _extract_site_key(self, html):
match = re.search(r'data-sitekey="([^"]+)"', html)
if match:
return match.group(1)
raise ValueError("reCAPTCHA site key not found")
def _solve_recaptcha(self, site_key, page_url):
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": self.api_key,
"method": "userrecaptcha",
"googlekey": site_key,
"pageurl": page_url,
"json": 1
})
task_id = resp.json()["request"]
for _ in range(60):
time.sleep(3)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": self.api_key,
"action": "get",
"id": task_id,
"json": 1
})
data = result.json()
if data["status"] == 1:
return data["request"]
raise TimeoutError("reCAPTCHA solve timed out")
def _parse_courses(self, html):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
def text_of(node):
return node.get_text(strip=True) if node else ""
courses = []
for row in soup.select(".course-row, tr.course"):
courses.append({
"id": row.get("data-course-id", ""),
"department": text_of(row.select_one(".dept")),
"number": text_of(row.select_one(".number")),
"title": text_of(row.select_one(".title")),
"seats": text_of(row.select_one(".seats")),
})
return courses
def _parse_availability(self, html):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
def text_of(node):
return node.get_text(strip=True) if node else None
return {
"total_seats": text_of(soup.select_one(".total-seats")),
"enrolled": text_of(soup.select_one(".enrolled-count")),
"available": text_of(soup.select_one(".available-seats")),
"waitlist": text_of(soup.select_one(".waitlist-count")),
}
def _extract_error(self, html):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
error = soup.select_one(".error-message, .alert-danger")
return error.text.strip() if error else None
Enchaîner les cas de test dans un runner JavaScript
Côté Node.js, la même logique tient dans un orchestrateur : chaque cas s'exécute, son résultat est capturé, et un échec n'interrompt pas les suivants. Le rapport final agrège le taux de réussite, exploitable tel quel dans la CI.
class RegistrationTestSuite {
constructor(apiKey, portalUrl) {
this.apiKey = apiKey;
this.portalUrl = portalUrl;
this.results = [];
}
async runAll(testCredentials) {
const tests = [
() => this.testLogin(testCredentials),
() => this.testCourseSearch('Fall2025', 'CS'),
() => this.testEnrollment('CS101', '001'),
() => this.testDropCourse('CS101', '001'),
() => this.testWaitlistFlow('CS201', '001')
];
for (const test of tests) {
try {
const result = await test();
this.results.push({ ...result, passed: true });
} catch (error) {
this.results.push({ name: test.name, error: error.message, passed: false });
}
}
return this.generateReport();
}
async solveRecaptcha(pageUrl, html) {
const match = html.match(/data-sitekey="([^"]+)"/);
if (!match) return null;
const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
method: 'POST',
body: new URLSearchParams({
key: this.apiKey,
method: 'userrecaptcha',
googlekey: match[1],
pageurl: pageUrl,
json: '1'
})
});
const { request: taskId } = await submitResp.json();
for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 3000));
const result = await fetch(
`https://ocr.captchaai.com/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`
);
const data = await result.json();
if (data.status === 1) return data.request;
}
throw new Error('reCAPTCHA solve timed out');
}
generateReport() {
const passed = this.results.filter(r => r.passed).length;
const failed = this.results.filter(r => !r.passed).length;
return {
total: this.results.length,
passed,
failed,
passRate: `${((passed / this.results.length) * 100).toFixed(1)}%`,
details: this.results
};
}
}
// Usage
const suite = new RegistrationTestSuite('YOUR_API_KEY', 'https://register.university.edu');
const report = await suite.runAll({ studentId: 'test123', password: 'testpass' });
console.log(`Tests: ${report.passed}/${report.total} passed (${report.passRate})`);
Les scénarios de test qui déclenchent le plus de défis
| Scénario de test | Fréquence des défis | Effet sur la suite |
|---|---|---|
| Connexions successives avec plusieurs comptes de test | Élevée | Chaque tentative réclame un nouveau token |
| Parcours d'inscription joués en parallèle | Élevée | Les protections se durcissent |
| Recherches de cours rapprochées | Modérée | Rate limiting côté portail |
| Génération d'un emploi du temps | Faible | Tout se joue dans une session déjà authentifiée |
| Vérification des prérequis | Faible | Étape intégrée au flux d'inscription |
Regroupez donc les cas à forte fréquence derrière une seule authentification, et réservez le parallélisme aux scénarios qui en ont besoin.
Dimensionner vos threads pour une campagne de tests
CaptchaAI facture des threads simultanés, pas des résolutions : chaque plan inclut un nombre illimité de résolutions par thread, et un thread libéré reprend aussitôt le défi suivant.
Pour une suite nocturne qui rejoue une cinquantaine de parcours en série, BASIC ($15/mois, 5 threads) suffit. Une répétition générale à quarante sessions étudiantes en parallèle demande plutôt STANDARD ($30/mois, 15 threads) ou ADVANCE ($90/mois, 50 threads). Le critère est la simultanéité, pas le volume mensuel : comptez les défis en vol au même instant. La facturation reste en dollars US.
Dépannage
| Problème | Cause probable | Correctif |
|---|---|---|
| Un défi à chaque connexion de test | La session n'est pas réutilisée | Authentifiez-vous une fois, rejouez les cas dans cette session |
| L'inscription échoue après un token valide | Le token CSRF a expiré pendant la résolution | Extrayez un CSRF frais en même temps que le sitekey |
| Les données de test disparaissent entre deux exécutions | La préproduction rejoue les traitements d'inscription la nuit | Réservez un jeu de comptes dédié et réinitialisez-le |
reCAPTCHA site key not found |
Portail en SPA : widget injecté après le rendu | Passez par un navigateur headless et lisez le DOM rendu |
| Le token est refusé sur le planificateur | Cette page utilise Turnstile, pas reCAPTCHA v2 | Envoyez la tâche en Turnstile, postez cf-turnstile-response |
FAQ
CaptchaAI prend-il en charge hCaptcha si le portail en utilise un ?
Non — hCaptcha n'est pas pris en charge, FunCaptcha non plus. Si votre établissement a migré vers l'un de ces widgets, gardez ce parcours en test manuel. Les types couverts restent reCAPTCHA v2 et v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3 et les CAPTCHA image ou texte.
Le planificateur d'horaires utilise Turnstile : faut-il un autre appel ?
Oui, c'est un type distinct : la tâche part avec la méthode Turnstile et le token revient dans cf-turnstile-response, pas dans g-recaptcha-response. Le reste du cas de test ne bouge pas — même session, même formulaire, mêmes assertions.
Que faire quand le sitekey reste introuvable dans le HTML ?
C'est la signature d'une interface monopage : le widget est injecté après la réponse initiale. Passez par un navigateur headless pour lire le DOM rendu, ou repérez l'appel réseau qui transporte le sitekey. Ne le figez jamais dans le code.
Combien de temps la résolution ajoute-t-elle à un scénario de bout en bout ?
Prévoyez moins de 60 s par défi reCAPTCHA v2 et moins de 10 s pour un défi Turnstile, ces valeurs étant des plafonds de service. Le piège est ailleurs : un cas de test dont le timeout coupe à 30 s échouera sur un défi pourtant résolu. Alignez vos timeouts d'abord.
Articles connexes
- Résoudre le callback reCAPTCHA v2 via l'API
- Gérer reCAPTCHA v2 et Turnstile sur un même site
- Comprendre le mécanisme de callback reCAPTCHA v2
Prochaines étapes
Rejouez le parcours d'inscription complet sur votre préproduction : récupérez votre clé API CaptchaAI et laissez la suite traiter les défis pendant que vous vérifiez places, listes d'attente et dates limites.