Tutoriales

Manejo de CAPTCHA en aplicaciones web progresivas (PWA)

¿Tu script encuentra el CAPTCHA en un sitio normal, pero devuelve null en el login de una PWA? El widget no llega con el HTML inicial: lo inyecta JavaScript tras cargar el app shell, así que si lees el sitekey demasiado pronto, no hay nada que leer. Con CaptchaAI la receta es esperar esa inyección, detectar el widget, resolverlo y devolver el token al DOM. Tres detalles rompen la automatización en una PWA:

  • El widget se renderiza tarde: aparece cuando el framework monta la vista.
  • El Service Worker puede cachear el script y servirte un widget obsoleto.
  • La navegación es del lado del cliente: cambiar de ruta no dispara un evento load.

Qué cambia cuando el CAPTCHA vive dentro de una PWA

Un sitio tradicional entrega el CAPTCHA en el HTML inicial; una PWA rompe esa suposición, y cada diferencia pide un ajuste en tu script:

  • No uses un sleep fijo: espera a un evento del DOM.
  • Sospecha de la caché del Service Worker cuando el widget se vea raro.
  • Vuelve a detectar el CAPTCHA después de cada cambio de ruta.
Aspecto Sitio tradicional PWA
Carga del CAPTCHA En el HTML inicial Por JavaScript, tras cargar
Navegación Recarga completa Del lado del cliente, sin recarga
Service Worker Ausente Cachea e intercepta solicitudes
Disponibilidad del DOM Inmediata Tras renderizar el framework
Solicitudes de red Directas Las intercepta el Service Worker

Un caso real: QA de una PWA de checkout o de citas

Muchos portales hispanohablantes ya son PWA: tiendas al estilo de MercadoLibre, paneles de cita previa o trámites públicos (SAT, AFIP) que muestran un reCAPTCHA v2 o un Turnstile solo en la vista sensible. Si validas tu propio flujo de checkout de extremo a extremo —respetando los términos de servicio y la normativa de protección de datos aplicable—, este patrón automatiza esa ruta sin frenarte en el CAPTCHA. CaptchaAI factura por thread concurrente con resoluciones ilimitadas; escalas de plan según cuántas sesiones corras a la vez:

  • BASIC ($15/mes, 5 threads): QA de un único flujo.
  • STANDARD ($30/mes, 15 threads): varias sesiones en paralelo.
  • ADVANCE ($90/mes, 50 threads): suites amplias o CI concurrente.

Paso 1: espera a que la PWA renderice el CAPTCHA

El error más común es leer el sitekey antes de que el framework monte el widget. Escucha el DOM con un MutationObserver y resuelve en cuanto aparezca .g-recaptcha o .cf-turnstile:

// pwa_captcha_detector.js — Playwright script
const { chromium } = require('playwright');
const axios = require('axios');

const API_KEY = 'YOUR_API_KEY';

async function detectCaptchaInPWA(page) {
  // Wait for the PWA app shell to render
  await page.waitForLoadState('networkidle');

  // Use MutationObserver to detect dynamically loaded CAPTCHAs
  const captchaInfo = await page.evaluate(() => {
    return new Promise((resolve) => {
      // Check if CAPTCHA is already present
      const existing = document.querySelector('.g-recaptcha, .cf-turnstile');
      if (existing) {
        resolve({
          type: existing.classList.contains('g-recaptcha')
            ? 'recaptcha_v2' : 'turnstile',
          sitekey: existing.getAttribute('data-sitekey'),
          pageurl: window.location.href,
        });
        return;
      }

      // Watch for CAPTCHA elements added dynamically
      const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
          for (const node of mutation.addedNodes) {
            if (node.nodeType !== 1) continue;
            const captcha = node.matches?.('.g-recaptcha, .cf-turnstile')
              ? node
              : node.querySelector?.('.g-recaptcha, .cf-turnstile');
            if (captcha) {
              observer.disconnect();
              resolve({
                type: captcha.classList.contains('g-recaptcha')
                  ? 'recaptcha_v2' : 'turnstile',
                sitekey: captcha.getAttribute('data-sitekey'),
                pageurl: window.location.href,
              });
              return;
            }
          }
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
      });

      // Timeout after 15 seconds
      setTimeout(() => {
        observer.disconnect();
        resolve(null);
      }, 15000);
    });
  });

  return captchaInfo;
}

async function main() {
  const browser = await chromium.launch({ headless: false });
  const context = await browser.newContext();
  const page = await context.newPage();

  await page.goto('https://example-pwa.com/login');

  const captcha = await detectCaptchaInPWA(page);

  if (!captcha) {
    console.log('No CAPTCHA detected');
    await browser.close();
    return;
  }

  console.log(`Detected ${captcha.type}: ${captcha.sitekey}`);

  // Solve con CaptchaAI
  const token = await solveCaptcha(captcha);
  console.log(`Token: ${token.substring(0, 50)}...`);

  // Inject token
  await injectToken(page, captcha.type, token);

  // Submit form
  await page.click('button[type="submit"]');
  await page.waitForNavigation({ waitUntil: 'networkidle' });

  console.log('Form submitted');
  await browser.close();
}

async function solveCaptcha(captcha) {
  const params = {
    key: API_KEY,
    pageurl: captcha.pageurl,
    json: '1',
  };

  if (captcha.type === 'recaptcha_v2') {
    params.method = 'userrecaptcha';
    params.googlekey = captcha.sitekey;
  } else {
    params.method = 'turnstile';
    params.sitekey = captcha.sitekey;
  }

  const submit = await axios.get(
    'https://ocr.captchaai.com/in.php', { params }
  );
  if (submit.data.status !== 1) throw new Error(submit.data.request);

  const taskId = submit.data.request;

  for (let i = 0; i < 30; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const poll = await axios.get('https://ocr.captchaai.com/res.php', {
      params: { key: API_KEY, action: 'get', id: taskId, json: '1' },
    });
    if (poll.data.status === 1) return poll.data.request;
    if (poll.data.request !== 'CAPCHA_NOT_READY') {
      throw new Error(poll.data.request);
    }
  }
  throw new Error('Timeout');
}

async function injectToken(page, type, token) {
  if (type === 'recaptcha_v2') {
    await page.evaluate((t) => {
      document.getElementById('g-recaptcha-response').value = t;
      try {
        const clients = ___grecaptcha_cfg.clients;
        Object.keys(clients).forEach((k) => {
          Object.keys(clients[k]).forEach((j) => {
            if (clients[k][j]?.callback) clients[k][j].callback(t);
          });
        });
      } catch (e) {}
    }, token);
  } else {
    await page.evaluate((t) => {
      const input = document.querySelector('[name="cf-turnstile-response"]');
      if (input) input.value = t;
      const cb = document.querySelector('.cf-turnstile')
        ?.getAttribute('data-callback');
      if (cb && typeof window[cb] === 'function') window[cb](t);
    }, token);
  }
}

main().catch(console.error);

Paso 2: neutraliza la caché del Service Worker

  • Síntoma: el sitekey no coincide con el del navegador real, o el callback apunta a una versión vieja.
  • Causa: el Service Worker sirvió una copia cacheada del script del CAPTCHA.
  • Solución: salta la caché forzando Cache-Control: no-cache, como abajo.
// Intercept and omisión Service Worker cache for CAPTCHA scripts
await page.route('**/recaptcha/**', (route) => {
  route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});

await page.route('**/turnstile/**', (route) => {
  route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});

Paso 3: reacciona a la navegación del lado del cliente

Moverse a una ruta protegida no recarga la página: el framework reescribe la URL sin disparar load. Intercepta history.pushState o espera la ruta concreta y vuelve a detectar:

// Monitor PWA route changes for new CAPTCHAs
await page.evaluate(() => {
  const originalPushState = history.pushState;
  history.pushState = function() {
    originalPushState.apply(this, arguments);
    window.dispatchEvent(new Event('pwa-route-change'));
  };
});

page.on('console', async (msg) => {
  // React to route changes if needed
});

// Or wait for specific route
await page.waitForURL('**/checkout', { waitUntil: 'networkidle' });
// Then detect CAPTCHA on the new route
const captcha = await detectCaptchaInPWA(page);

Cuándo reutilizar el token y cuándo volver a resolver

  • Misma vista y misma sesión: reutilízalo, salvo que el sitio recree el widget.
  • Cambio de ruta con widget nuevo: vuelve a resolver; suele cambiar el sitekey o el callback.
  • Refresco del service worker o hard reload: vuelve a resolver; el estado anterior deja de ser fiable.

Paso 4: la misma lógica en Python con Selenium

En Python el patrón es idéntico: espera el widget con WebDriverWait, envía el sitekey y la URL a CaptchaAI, sondea e inyecta el token.

# pwa_captcha_selenium.py
import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

API_KEY = "YOUR_API_KEY"

driver = webdriver.Chrome()
driver.get("https://example-pwa.com/login")

# Wait for PWA to render CAPTCHA
wait = WebDriverWait(driver, 20)
captcha_el = wait.until(
    EC.presence_of_element_located((By.CSS_SELECTOR, ".g-recaptcha, .cf-turnstile"))
)

sitekey = captcha_el.get_attribute("data-sitekey")
pageurl = driver.current_url
is_turnstile = "cf-turnstile" in captcha_el.get_attribute("class")

# Submit to CaptchaAI
params = {"key": API_KEY, "pageurl": pageurl, "json": "1"}
if is_turnstile:
    params["method"] = "turnstile"
    params["sitekey"] = sitekey
else:
    params["method"] = "userrecaptcha"
    params["googlekey"] = sitekey

resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
task_id = resp.json()["request"]

# Poll
for _ in range(30):
    time.sleep(5)
    poll = requests.get("https://ocr.captchaai.com/res.php", params={
        "key": API_KEY, "action": "get", "id": task_id, "json": "1",
    })
    if poll.json().get("status") == 1:
        token = poll.json()["request"]
        break
else:
    raise TimeoutError("CAPTCHA not solved")

# Inject token
driver.execute_script(f"""
    document.getElementById('g-recaptcha-response').value = '{token}';
""")

driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
print("Form submitted")
driver.quit()

Preguntas frecuentes

¿Por qué mi selector encuentra el CAPTCHA en un sitio normal pero no en la PWA?

Porque el widget se inyecta después de cargar y tu selector corre antes. Espéralo con un MutationObserver (JavaScript) o WebDriverWait (Python).

¿Cómo detecto un CAPTCHA que aparece tras un cambio de ruta sin recarga?

Intercepta la navegación del lado del cliente: envuelve history.pushState o usa page.waitForURL('**/ruta'), y vuelve a detectar sobre la vista nueva.

¿El token deja de servir si el Service Worker recarga el widget?

Normalmente sí: un refresco del Service Worker o un hard reload recrea el widget con otro sitekey, y el token anterior deja de servir.

¿Qué plan de CaptchaAI necesito para automatizar una PWA?

Para el QA de un solo flujo basta BASIC ($15/mes, 5 threads); si paralelizas rutas, STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads) dan más threads concurrentes.

Solución de problemas

Casi cualquier fallo en una PWA cae en una de estas causas:

  • El CAPTCHA nunca aparece → la ruta no se ha renderizado; usa waitForSelector con un tiempo de espera amplio.
  • sitekey obsoleto tras navegar → el Service Worker sirvió HTML cacheado; salta la caché del CAPTCHA.
  • Callback de inyección no encontrado → revisa el estado de React/Vue/Angular y fuerza la actualización del formulario.
  • El envío no lleva el token → el handler del SPA lee del estado, no del DOM; actualiza también ese estado.

Artículos relacionados

Próximos pasos

Empieza a resolver CAPTCHA en tus PWA: obtén tu API key de CaptchaAI, integra la detección dinámica y apóyate en estas guías:

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