Tutoriales

reCAPTCHA en aplicaciones de una sola página: patrones de carga dinámica

Si automatizas una SPA de React, Vue o Angular y tu código no encuentra el reCAPTCHA en el HTML, casi nunca es un fallo tuyo: el widget aún no se ha montado. Aparece cuando el usuario abre el formulario o cambia de ruta, no al cargar la página. La clave está en tres pasos:

  • Detectar el widget aunque no exista en el HTML inicial.
  • Extraer el sitekey del DOM ya renderizado.
  • Inyectar el token donde lo espera la aplicación.

En qué se diferencia una SPA de una página tradicional

Página tradicional SPA
Script de reCAPTCHA en el HTML inicial Script inyectado tras el cambio de ruta
El widget se renderiza al cargar la página El widget se renderiza al montar el componente
El sitekey está en la fuente de la página El sitekey vive en el bundle de JavaScript
El formulario se envía por POST El formulario se envía por XHR/fetch

La regla práctica: el widget puede no existir hasta que una acción del usuario lo dispara, así que tu automatización debe esperar a que aparezca. Piensa en el QA del panel de acceso de un marketplace latinoamericano hecho en React, sobre staging: si lo buscas en el HTML inicial siempre te dará vacío, porque el formulario solo se monta al pulsar "Iniciar sesión".

Detecta el widget y extrae el sitekey

Primero espera a que el iframe del widget aparezca; sin eso, el resto falla. Después localiza el sitekey: puede estar en el src del iframe, en el atributo data-sitekey o en el bundle de JavaScript. Prueba en ese orden.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()
    page.goto("https://staging.example.com/qa-login")

    # SPA may need a click or navigation to trigger reCAPTCHA
    page.click("#show-login-form")

    # Wait for reCAPTCHA iframe to appear
    recaptcha_frame = page.wait_for_selector(
        "iframe[src*='recaptcha']",
        timeout=15000
    )
    print("reCAPTCHA detected:", recaptcha_frame.get_attribute("src"))
# Method 1: From the iframe src
iframe_src = recaptcha_frame.get_attribute("src")
# src contains: ...?k=6LcR_RsTAAAAADge...
import re
match = re.search(r'[?&]k=([^&]+)', iframe_src)
site_key = match.group(1) if match else None

# Method 2: From data-sitekey attribute
site_key = page.eval_on_selector(
    "[data-sitekey]",
    "el => el.getAttribute('data-sitekey')"
)

# Method 3: From JavaScript bundle (last resort)
site_key = page.evaluate("""
    () => {
        // Check for grecaptcha render calls
        const scripts = document.querySelectorAll('script');
        for (const s of scripts) {
            const match = s.textContent.match(/sitekey['":\s]+(['"])(6L[^'"]+)\\1/);
            if (match) return match[2];
        }
        return null;
    }
""")

Resuelve el reCAPTCHA con CaptchaAI

Con el sitekey y la URL, envías la tarea a CaptchaAI y sondeas el resultado hasta recibir el token. Da igual cómo se cargue el widget: el mismo método userrecaptcha cubre reCAPTCHA v2, v2 invisible y v2 Enterprise.

import requests
import time

def solve_recaptcha(site_key, page_url):
    # Submit task
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": "YOUR_API_KEY",
        "method": "userrecaptcha",
        "googlekey": site_key,
        "pageurl": page_url,
        "json": 1
    })
    task_id = resp.json()["request"]

    # Poll for result
    for _ in range(60):
        time.sleep(3)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": "YOUR_API_KEY",
            "action": "get",
            "id": task_id,
            "json": 1
        })
        data = result.json()
        if data["status"] == 1:
            return data["request"]
    raise TimeoutError("Solve timed out")

Inyecta el token en la SPA

Las SPA no envían formularios tradicionales, así que colocas el token donde la aplicación lo espera. Tres enfoques: rellenar el textarea oculto g-recaptcha-response, disparar el callback que valida el token, o interceptar la petición XHR con Puppeteer.

token = solve_recaptcha(site_key, "https://staging.example.com/qa-login")

# Inject into the g-recaptcha-response textarea
page.evaluate(f"""
    document.getElementById('g-recaptcha-response').value = '{token}';
""")

# Submit the form
page.click("#login-button")
# Find the callback function name
callback = page.evaluate("""
    () => {
        const widget = document.querySelector('.g-recaptcha');
        return widget?.getAttribute('data-callback') || null;
    }
""")

# Call it with the token
if callback:
    page.evaluate(f"window['{callback}']('{token}')")
else:
    # Try the default grecaptcha callback
    page.evaluate(f"""
        if (window.grecaptcha) {{
            // Set the response and trigger verification
            document.getElementById('g-recaptcha-response').value = '{token}';
            // Find and call any registered callbacks
            const form = document.querySelector('form');
            if (form) form.dispatchEvent(new Event('submit'));
        }}
    """)
const puppeteer = require('puppeteer');

async function solveRecaptchaInSPA() {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();

  // Intercept the API call and inject the token
  await page.setRequestInterception(true);
  page.on('request', request => {
    if (request.url().includes('/api/login')) {
      const postData = JSON.parse(request.postData() || '{}');
      // Token already set via page.evaluate — let it pass
      request.continue();
    } else {
      request.continue();
    }
  });

  await page.goto('https://staging.example.com/qa-login');
  await page.waitForSelector('[data-sitekey]');

  const siteKey = await page.$eval(
    '[data-sitekey]',
    el => el.getAttribute('data-sitekey')
  );

  // Solve con CaptchaAI (implementation omitted — same as Python)
  const token = await solveCaptcha(siteKey, page.url());

  // Inject token and trigger callback
  await page.evaluate((t) => {
    document.getElementById('g-recaptcha-response').value = t;
    const callback = document.querySelector('.g-recaptcha')
      ?.getAttribute('data-callback');
    if (callback && window[callback]) {
      window[callback](t);
    }
  }, token);
}

Patrones por framework

Cada framework monta el widget a su manera: React (react-google-recaptcha) lo renderiza de forma asíncrona, Vue (vue-recaptcha) suele usar v-if, y Angular a veces carga el módulo de forma diferida. Espera al selector antes de leer el sitekey.

# Wait for React to mount the component
page.wait_for_selector(".g-recaptcha", state="attached")

# The sitekey is in the rendered div's data attribute
site_key = page.eval_on_selector(
    ".g-recaptcha", "el => el.dataset.sitekey"
)
# Vue may use v-if to conditionally render
# Navigate or interact to trigger the condition
page.click("#proceed-to-checkout")
page.wait_for_selector("iframe[src*='recaptcha']")
# Angular apps may lazy-load reCAPTCHA modules
# Wait for the specific Angular component
page.wait_for_selector("re-captcha, app-recaptcha, [data-sitekey]")

Maneja los cambios de ruta

Las SPA reutilizan la misma página: navegan sin recargas completas y montan el widget tras cambiar la ruta interna.

# Listen for reCAPTCHA appearing after SPA navigation
page.goto("https://example.com")

# Navigate within the SPA
page.click("a[href='/login']")

# The URL changed but no page reload happened
# Wait for the CAPTCHA to render in the new "page"
page.wait_for_selector("iframe[src*='recaptcha']", timeout=10000)

Como el token queda atado a esa vista, no lo reutilices tras un cambio de ruta: resuelve de nuevo al montarse el widget.

Problemas frecuentes

Problema Causa Solución
No aparece el textarea g-recaptcha-response La SPA aún no renderizó el widget Usa wait_for_selector con un timeout holgado
El token se inyecta pero el formulario no se envía El callback no se disparó Busca la función en data-callback y llámala
El sitekey no está en la fuente de la página Va en el bundle de JS Extráelo del iframe src tras renderizar el widget
El token funciona una vez y luego falla La SPA remonta el componente y borra el token Inyéctalo justo antes de enviar
El widget se vuelve a renderizar tras la inyección La reactividad de React/Vue borra el valor Usa data-callback en lugar del textarea

Preguntas frecuentes

¿Necesito un navegador o puedo resolver la SPA solo con peticiones HTTP?

Depende. Si extraes el sitekey y replicas la llamada de envío, puedes prescindir del navegador después: muchas SPA usan un endpoint REST al que llamas con el token. Pero para descubrir el sitekey y ese endpoint la primera vez necesitas un navegador que renderice el componente.

¿Por qué el token deja de funcionar cuando React o Vue vuelven a renderizar?

Porque la reactividad del framework reescribe el DOM y borra el valor que pusiste en el textarea. Inyecta el token justo antes de enviar y usa el data-callback en lugar de escribir directamente en g-recaptcha-response.

¿Qué plan de CaptchaAI me conviene para automatizar reCAPTCHA en una SPA?

CaptchaAI factura por threads concurrentes, con resoluciones ilimitadas por thread. Para QA con un solo navegador basta el plan BASIC ($15/mes, 5 threads); si paralelizas muchas ejecuciones, sube a STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads). Consulta los precios en captchaai.com/pricing.

¿reCAPTCHA v3 dentro de una SPA se resuelve igual que v2?

El flujo es parecido, pero reCAPTCHA v3 no muestra widget visible: corre en segundo plano y devuelve una puntuación. CaptchaAI resuelve v3 y v3 Enterprise con el mismo método userrecaptcha; el token se inyecta donde lo espera la lógica de la app.

Artículos relacionados

Próximos pasos

Resuelve reCAPTCHA cargados dinámicamente en cualquier SPA: consigue tu clave API de CaptchaAI.

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