Tutoriales

Manejo de Shadow DOM CAPTCHA: acceso a elementos en componentes web

Para llegar a un CAPTCHA que vive dentro de un componente web tienes que atravesar su shadow root: un document.querySelector normal se detiene en el borde de la sombra y devuelve null, aunque el widget se vea en pantalla. El plan de ataque es siempre el mismo:

  • Perfora la raíz de sombra y lee el sitekey.
  • Envía ese sitekey a la API de CaptchaAI y recibe el token.
  • Inyecta el token resuelto en el elemento correcto del componente.

Lo verás con Playwright y Puppeteer, incluidas las raíces de sombra cerradas.

Cómo detectar un CAPTCHA dentro de una raíz de sombra

Confirma que el widget está dentro de un shadow root. Pega esto en la consola de DevTools:

// In browser DevTools console
// Regular query returns null even though CAPTCHA is visible
document.querySelector('.cf-turnstile');  // null

// Check for shadow hosts
document.querySelectorAll('*').forEach(el => {
  if (el.shadowRoot) {
    const captcha = el.shadowRoot.querySelector('.cf-turnstile, .g-recaptcha');
    if (captcha) {
      console.log('Found CAPTCHA in shadow root of:', el.tagName, el.id || el.className);
      console.log('Sitekey:', captcha.dataset.sitekey);
    }
  }
});

Por qué los CAPTCHA acaban dentro del Shadow DOM

El Shadow DOM aísla el marcado y los estilos de un componente, y ese aislamiento es justo lo que deja ciego a tu selector. Lo verás sobre todo en:

  • Login a medida encapsulado como componente web reutilizable.
  • Widgets de formulario de terceros que envuelven todo el formulario en la raíz de sombra.
  • Microfrontends, donde cada microaplicación usa un shadow DOM aislado.
  • Componentes de un design system con el CAPTCHA incrustado en la biblioteca.

Profundidad del Shadow DOM: cuántos niveles atravesar

Cuánto profundizas depende del anidamiento del componente:

Profundidad Ejemplo Enfoque
1 nivel <custom-form> #shadow-root > .cf-turnstile Consulta directa a la raíz de sombra
2+ niveles <app-shell> #shadow > <login-form> #shadow > .cf-turnstile Recorrido recursivo
Raíz de sombra abierta el.shadowRoot es accesible Enfoque estándar: usa recursión
Raíz de sombra cerrada el.shadowRoot devuelve null No se puede perforar; sobrescribe attachShadow({mode:'open'}) o intercepta el render

Python con Playwright: perforar el Shadow DOM

El selector de perforación >> de Playwright y la API locator cruzan los límites de la sombra sin ayuda extra. Este ejemplo extrae el sitekey, lo resuelve con CaptchaAI y reinyecta el token:

import requests
import time
from playwright.sync_api import sync_playwright

API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"


def solve_turnstile(sitekey, pageurl):
    """Submit and poll a Turnstile CAPTCHA."""
    resp = requests.post(SUBMIT_URL, data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": pageurl,
        "json": 1,
    }, timeout=30).json()

    if resp.get("status") != 1:
        raise RuntimeError(f"Submit failed: {resp.get('request')}")

    task_id = resp["request"]
    for _ in range(60):
        time.sleep(5)
        poll = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "get",
            "id": task_id, "json": 1,
        }, timeout=15).json()

        if poll.get("request") == "CAPCHA_NOT_READY":
            continue
        if poll.get("status") == 1:
            return poll["request"]
        raise RuntimeError(f"Solve failed: {poll.get('request')}")

    raise RuntimeError("Timeout")


def extract_from_shadow_dom(page):
    """Extract CAPTCHA sitekey from shadow DOM elements."""

    # Method 1: Playwright's piercing selector (>>)
    # This automatically crosses shadow boundaries
    turnstile = page.locator("css=.cf-turnstile >> visible=true").first
    if turnstile.count() > 0:
        sitekey = turnstile.get_attribute("data-sitekey")
        if sitekey:
            return sitekey

    # Method 2: JavaScript evaluation to pierce all shadow roots
    sitekey = page.evaluate("""
        () => {
            function findInShadowRoots(root) {
                // Check direct children
                const turnstile = root.querySelector('.cf-turnstile');
                if (turnstile && turnstile.dataset.sitekey) {
                    return turnstile.dataset.sitekey;
                }

                const recaptcha = root.querySelector('.g-recaptcha');
                if (recaptcha && recaptcha.dataset.sitekey) {
                    return recaptcha.dataset.sitekey;
                }

                // Recurse into nested shadow roots
                for (const el of root.querySelectorAll('*')) {
                    if (el.shadowRoot) {
                        const found = findInShadowRoots(el.shadowRoot);
                        if (found) return found;
                    }
                }
                return null;
            }
            return findInShadowRoots(document);
        }
    """)

    return sitekey


def inject_token_shadow_dom(page, token, captcha_type="turnstile"):
    """Inject solved token into shadow DOM CAPTCHA element."""
    if captcha_type == "turnstile":
        page.evaluate(f"""
            (token) => {{
                function findAndInject(root) {{
                    // Find the response input inside Turnstile
                    const input = root.querySelector('[name="cf-turnstile-response"]');
                    if (input) {{
                        input.value = token;
                        return true;
                    }}

                    // Recurse into shadow roots
                    for (const el of root.querySelectorAll('*')) {{
                        if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
                            return true;
                        }}
                    }}
                    return false;
                }}
                findAndInject(document);

                // Also try callback if defined
                if (typeof window.turnstileCallback === 'function') {{
                    window.turnstileCallback(token);
                }}
            }}
        """, token)
    elif captcha_type == "recaptcha":
        page.evaluate(f"""
            (token) => {{
                function findAndInject(root) {{
                    const textarea = root.querySelector('#g-recaptcha-response');
                    if (textarea) {{
                        textarea.value = token;
                        textarea.style.display = 'block';
                        return true;
                    }}
                    for (const el of root.querySelectorAll('*')) {{
                        if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
                            return true;
                        }}
                    }}
                    return false;
                }}
                findAndInject(document);

                if (typeof ___grecaptcha_cfg !== 'undefined') {{
                    Object.entries(___grecaptcha_cfg.clients).forEach(([_, client]) => {{
                        Object.entries(client).forEach(([_, val]) => {{
                            if (val && typeof val === 'object') {{
                                Object.entries(val).forEach(([_, v]) => {{
                                    if (v && v.callback) v.callback(token);
                                }});
                            }}
                        }});
                    }});
                }}
            }}
        """, token)


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

        # Extract sitekey from shadow DOM
        sitekey = extract_from_shadow_dom(page)
        if not sitekey:
            print("No CAPTCHA found in shadow DOM or regular DOM")
            browser.close()
            return

        print(f"Found sitekey: {sitekey}")

        # Solve via CaptchaAI
        token = solve_turnstile(sitekey, page.url)
        print(f"Solved: {token[:40]}...")

        # Inject token back into shadow DOM
        inject_token_shadow_dom(page, token, "turnstile")
        print("Token injected into shadow DOM")

        # Submit the form
        page.click("button[type='submit']")
        page.wait_for_load_state("networkidle")

        browser.close()


main()

Qué hacer cuando la raíz de sombra está cerrada

Si la raíz de sombra es cerrada o el CAPTCHA vive en un iframe interno, prueba este orden antes de reescribir toda la automatización:

  1. Extrae el sitekey del HTML de respuesta o de las peticiones de red del widget.
  2. Comprueba si puedes inyectar el token en un textarea externo, aunque el widget viva dentro del shadow root.
  3. Si el proveedor encapsula todo el flujo, recurre a CDP o a un hook de la propia aplicación en lugar de depender solo del selector del DOM.

JavaScript con Puppeteer: recorrer el Shadow DOM

El mismo recorrido recursivo funciona en Puppeteer: busca el sitekey en todas las raíces de sombra y, tras resolverlo, inyecta el token:

const puppeteer = require("puppeteer");

const API_KEY = "YOUR_API_KEY";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";

async function solveTurnstile(sitekey, pageurl) {
  const params = new URLSearchParams({
    key: API_KEY, method: "turnstile", sitekey, pageurl, json: "1",
  });
  const resp = await (await fetch(SUBMIT_URL, { method: "POST", body: params })).json();
  if (resp.status !== 1) throw new Error(`Submit: ${resp.request}`);

  const taskId = resp.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const url = `${RESULT_URL}?key=${API_KEY}&action=get&id=${taskId}&json=1`;
    const poll = await (await fetch(url)).json();
    if (poll.request === "CAPCHA_NOT_READY") continue;
    if (poll.status === 1) return poll.request;
    throw new Error(`Solve: ${poll.request}`);
  }
  throw new Error("Timeout");
}

async function extractSitekeyFromShadowDOM(page) {
  return page.evaluate(() => {
    function searchShadowRoots(root) {
      const selectors = [".cf-turnstile", ".g-recaptcha", ".h-captcha"];
      for (const sel of selectors) {
        const el = root.querySelector(sel);
        if (el && el.dataset.sitekey) return el.dataset.sitekey;
      }
      for (const el of root.querySelectorAll("*")) {
        if (el.shadowRoot) {
          const found = searchShadowRoots(el.shadowRoot);
          if (found) return found;
        }
      }
      return null;
    }
    return searchShadowRoots(document);
  });
}

async function injectTokenShadowDOM(page, token) {
  await page.evaluate((t) => {
    function inject(root) {
      const input = root.querySelector('[name="cf-turnstile-response"]');
      if (input) { input.value = t; return true; }
      const textarea = root.querySelector("#g-recaptcha-response");
      if (textarea) { textarea.value = t; return true; }
      for (const el of root.querySelectorAll("*")) {
        if (el.shadowRoot && inject(el.shadowRoot)) return true;
      }
      return false;
    }
    inject(document);
  }, token);
}

(async () => {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();
  await page.goto("https://staging.example.com/qa-login", { waitUntil: "networkidle2" });

  const sitekey = await extractSitekeyFromShadowDOM(page);
  if (!sitekey) {
    console.log("No CAPTCHA found in shadow DOM");
    await browser.close();
    return;
  }

  console.log(`Sitekey: ${sitekey}`);
  const token = await solveTurnstile(sitekey, page.url());
  console.log(`Solved: ${token.substring(0, 40)}...`);

  await injectTokenShadowDOM(page, token);
  await page.click('button[type="submit"]');
  await page.waitForNavigation();

  await browser.close();
})();

Un caso habitual: una agencia hace QA del login de un panel de cita previa hecho con web components. En staging, el Turnstile vive dentro de un <login-form> con su raíz de sombra; el recorrido de arriba localiza el sitekey, lo resuelve con CaptchaAI y continúa el flujo sin intervención manual.

Solución de problemas frecuentes

Problema Causa Solución
shadowRoot devuelve null Shadow DOM cerrado Sobrescribe attachShadow antes de que cargue la página para forzar mode: 'open'
Encuentras el sitekey pero el token se rechaza Se envió una pageurl incorrecta al solver Usa page.url() en el momento de la extracción, nunca una URL fija
El token se inyecta pero el formulario no se envía No se dispara el callback del CAPTCHA Localiza y llama a la función de callback tras fijar el valor del token
La búsqueda recursiva va lenta Árbol DOM muy profundo Limita la profundidad de recursión; apunta a nombres de etiqueta host conocidos
El CAPTCHA no aparece al cargar El widget carga de forma asíncrona Espera primero al host: page.waitForSelector('custom-form')

Preguntas frecuentes

¿La API de CaptchaAI necesita acceder al Shadow DOM?

No. Si integras la API directamente, solo le pasas el sitekey y la pageurl; da igual dónde viva el widget. El recorrido del Shadow DOM solo hace falta para leer el sitekey de la página en vivo o reinyectar el token en el navegador.

¿Sirve el mismo recorrido para reCAPTCHA v2 y no solo para Turnstile?

Sí. La función recursiva busca .cf-turnstile y .g-recaptcha en cada raíz de sombra, así que localiza ambos. Lo único que cambia es el campo donde se inyecta el token: cf-turnstile-response para Turnstile y g-recaptcha-response para reCAPTCHA. Ten en cuenta que hCaptcha no es compatible con CaptchaAI.

¿Cómo evito que la búsqueda recursiva ralentice el navegador?

Limita la profundidad de la recursión y empieza por los hosts que ya conoces (page.waitForSelector('custom-form')) en lugar de recorrer todo el árbol. Apuntar directo a la etiqueta del host reduce el recorrido a unos pocos nodos.

¿Qué plan de CaptchaAI me conviene si resuelvo muchos Turnstile en paralelo?

La facturación es por thread (un CAPTCHA en curso), no por resolución, con resoluciones ilimitadas por thread. El plan BASIC ($15/mes, 5 threads) cubre pruebas; para más resoluciones en paralelo, sube a STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads). Precios en captchaai.com/pricing.

Artículos relacionados

Próximos pasos

¿Tienes CAPTCHA escondidos dentro de componentes web? Consigue tu API key de CaptchaAI y aplica el recorrido del Shadow DOM en tu automatización.

Guías relacionadas:

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