Si tu suite del portal de matrícula se detiene siempre en el mismo punto —el reCAPTCHA v2 del formulario de inscripción—, la salida no es desactivar la seguridad ni resolverlo a mano: extraes el sitekey de la página, lo envías a la API de CaptchaAI, recibes un token y lo adjuntas como g-recaptcha-response en el POST que tu prueba ya hacía. Con eso, el login del estudiante, la búsqueda de cursos, el alta y baja de asignaturas y la lista de espera vuelven a ejecutarse de principio a fin sin supervisión.
Todo lo que sigue asume que pruebas el portal de tu institución con autorización explícita, datos sintéticos y respeto a la normativa de protección de datos.
Escenarios de prueba que disparan el CAPTCHA
Conviene saber de antemano dónde se te van a ir las resoluciones:
| Escenario de prueba | Frecuencia del CAPTCHA | Efecto sobre la suite |
|---|---|---|
| Login con varias cuentas de prueba | Alta | Cada intento de inicio de sesión pide desafío |
| Inscripciones simultáneas | Alta | La protección anti-bots reacciona al paralelismo |
| Búsquedas de cursos encadenadas | Moderada | Consultas sujetas a límite de solicitudes |
| Generación de horarios | Baja | Interacción dentro de una sola sesión |
| Validación de requisitos previos | Baja | Va dentro del flujo de inscripción |
El gasto se concentra en el login y en la concurrencia: si reutilizas la sesión autenticada, el resto casi nunca vuelve a ver un desafío.
Puntos del portal donde aparece el CAPTCHA
| Paso del portal | Tipo de CAPTCHA | Para qué está |
|---|---|---|
| Login del estudiante | reCAPTCHA v2 | Frenar el relleno de credenciales |
| Búsqueda y catálogo de cursos | reCAPTCHA v2 | Limitar consultas automatizadas |
| Alta y baja de asignaturas | reCAPTCHA v2 | Controlar altas masivas |
| Alta en lista de espera | reCAPTCHA v2 | Bloquear registros automáticos |
| Planificador de horarios | Cloudflare Turnstile | Proteger herramientas interactivas |
| Solicitud de certificados | reCAPTCHA v2 | Verificar que hay una persona |
Ojo con el planificador: muchos portales combinan ambos mecanismos en el mismo dominio. CaptchaAI resuelve reCAPTCHA v2 y Cloudflare Turnstile (este último en menos de 10 segundos) con una alta tasa de éxito.
El flujo de resolución, en tres pasos
El ciclo es el mismo en cualquier framework:
- Extraer el sitekey. Busca
data-sitekeyen el HTML recién cargado. Si no aparece, el portal es una SPA y necesitas automatización de navegador. - Enviar la tarea. POST a
in.phpcon tu clave API, el métodouserrecaptcha, el sitekey y la URL real; la respuesta trae el identificador. - Consultar el resultado. Sondea
res.phphasta recibir el token. El reCAPTCHA v2 es el tipo más lento del catálogo (techo de resolución en menos de 60 segundos), así que da holgura al tiempo de espera del runner.
El costo va por threads, no por resolución: cada thread es un CAPTCHA en vuelo y las resoluciones del plan son ilimitadas. Para una suite nocturna en serie, BASIC ($15/mes, 5 threads) sobra; para decenas de sesiones en paralelo, ADVANCE ($90/mes, 50 threads) evita que las tareas hagan cola.
Suite en Python: login, búsqueda, inscripción y plazas
Cada método comprueba si la respuesta trae desafío y solo entonces paga una resolución:
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_or_empty(node):
return node.text.strip() if node and node.text else ""
courses = []
for row in soup.select(".course-row, tr.course"):
courses.append({
"id": row.get("data-course-id", ""),
"department": text_or_empty(row.select_one(".dept")),
"number": text_or_empty(row.select_one(".number")),
"title": text_or_empty(row.select_one(".title")),
"seats": text_or_empty(row.select_one(".seats"))
})
return courses
def _parse_availability(self, html):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
def text_or_none(node):
return node.text.strip() if node and node.text else None
return {
"total_seats": text_or_none(soup.select_one(".total-seats")),
"enrolled": text_or_none(soup.select_one(".enrolled-count")),
"available": text_or_none(soup.select_one(".available-seats")),
"waitlist": text_or_none(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
Fíjate en test_seat_availability: devuelve plazas totales, inscritos, disponibles y lista de espera por curso, la comprobación que revela si el contador de cupos se desincroniza.
Ejecutor de la suite en JavaScript
En Node.js el mismo recorrido cabe en un runner que agrupa casos, captura fallos individuales y emite un informe agregado:
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})`);
Escenario: la ventana de matrícula de una universidad pública
Piensa en una universidad pública de tamaño medio: 40.000 estudiantes se matriculan en una ventana de 72 horas por franjas. Antes de abrir hay que validar que el login aguanta la avalancha, que el contador de plazas no muestra cupos fantasma y que la lista de espera respeta el orden de llegada.
El entorno de preproducción replica producción, reCAPTCHA v2 incluido: desactivarlo invalidaría lo que se quiere medir. La suite corre con 30 identidades sintéticas y resuelve el desafío por API en cada login. El hallazgo útil no es "pasa o falla": es que el token CSRF se regenera al volver del CAPTCHA, algo que en la ventana real habría reventado la confirmación.
Un apunte válido en España y en América Latina: los expedientes académicos son datos personales bajo el RGPD y la LOPDGDD, la LFPDPPP mexicana y normas equivalentes. El entorno de pruebas se llena con datos generados, nunca con una copia de la base real.
Problemas frecuentes en la ejecución
| Síntoma | Causa habitual | Cómo resolverlo |
|---|---|---|
| Cada login pide CAPTCHA otra vez | No se reutiliza la sesión | Autentícate una vez y ejecuta el resto dentro de esa sesión |
| La inscripción falla tras resolver | El token CSRF caducó mientras esperabas | Relee el token CSRF después de obtener el del CAPTCHA |
| Los datos de prueba desaparecen | El portal purga inscripciones | Usa un entorno dedicado con purga controlada por ti |
| No aparece el sitekey en el HTML | La SPA lo renderiza por JavaScript | Lee el DOM renderizado con automatización de navegador |
| El sondeo agota el tiempo de espera | Timeout del runner menor que el techo | Súbelo por encima del techo de 60 s del reCAPTCHA v2 |
Preguntas frecuentes
¿Cuántos threads necesito para una suite de matrícula?
Depende del paralelismo, no del número de pruebas: cuenta cuántos CAPTCHA pueden resolverse a la vez. Una suite secuencial necesita uno o dos threads; una prueba de carga con 40 sesiones, tantos como sesiones quieras arrancar sin cola. BASIC ($15/mes, 5 threads) cubre lo primero; ADVANCE ($90/mes, 50 threads), lo segundo.
¿Cuánto dura el token de reCAPTCHA v2 antes de caducar?
Unos dos minutos. Resuelve el CAPTCHA lo más cerca posible del envío del formulario y no lo reutilices entre casos de prueba.
¿Y si el portal de mi universidad usa hCaptcha en vez de reCAPTCHA?
CaptchaAI no es compatible con hCaptcha ni con FunCaptcha (Arkose Labs), y GeeTest v4 figura como próximamente. Sí puedes automatizar reCAPTCHA v2 y v3, Cloudflare Turnstile y Cloudflare Challenge, GeeTest v3 y los CAPTCHA de imagen o texto; CaptchaFox, Friendly Captcha y Lemin están en beta.
¿Necesito autorización para automatizar pruebas contra el portal?
Sí. Entorno propio o permiso explícito por escrito, datos sintéticos y ventanas acordadas. Automatizar contra producción durante la matrícula real, sin acuerdo, no es QA: es tráfico no autorizado.
Artículos relacionados
- resolver el callback de reCAPTCHA v2 por API
- convivencia de reCAPTCHA v2 y Turnstile en un mismo sitio
- cómo funciona el mecanismo de callback de reCAPTCHA v2
Próximos pasos
Deja que la suite recorra la matrícula entera sin pausas manuales: crea tu clave API de CaptchaAI y resuelve el reCAPTCHA v2 del portal dentro de tus propias pruebas.