Casos de Uso

Manejo de CAPTCHA para pruebas de registro de cursos universitarios

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:

  1. Extraer el sitekey. Busca data-sitekey en el HTML recién cargado. Si no aparece, el portal es una SPA y necesitas automatización de navegador.
  2. Enviar la tarea. POST a in.php con tu clave API, el método userrecaptcha, el sitekey y la URL real; la respuesta trae el identificador.
  3. Consultar el resultado. Sondea res.php hasta 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

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.

Los comentarios están deshabilitados para este artículo.