Tutoriais

Tratamento Shadow DOM CAPTCHA: alcançando elementos em componentes da Web

O document.querySelector padrão não consegue alcançar raízes sombrias. Quando um widget CAPTCHA é renderizado dentro do shadow DOM de um componente da web, seu script de extração de chave de site usual retorna null. O CAPTCHA está lá – seu seletor simplesmente não consegue vê-lo.

Por que os CAPTCHAs acabam no Shadow DOM

Cenário Razão
Componente de login personalizado Encapsulado como um componente web reutilizável
Widget de formulário de terceiros Fornecedor de widget envolve formulário inteiro em shadow root
Arquitetura de micro-frontend Cada microaplicativo usa shadow DOM isolado
Projetar componentes do sistema CAPTCHA incorporado no elemento da biblioteca de componentes

Detectando Shadow DOM CAPTCHAs

Antes de resolver, confirme se o CAPTCHA está dentro de uma shadow root:

// 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);
    }
  }
});

Python: Dramaturgo Shadow DOM Piercing

O seletor de piercing >> do Playwright e a API locator lidam com shadow DOM nativamente:

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()

JavaScript: travessia do DOM da sombra do titereiro

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();
})();

Considerações sobre a profundidade do Shadow DOM

Profundidade Exemplo Abordagem
1 nível <custom-form> #shadow-root > .cf-turnstile Consulta raiz sombra direta
2+ níveis <app-shell> #shadow > <login-form> #shadow > .cf-turnstile Travessia recursiva
Abrir raiz de sombra el.shadowRoot acessível Abordagem padrão - use recursão
Raiz de sombra fechada el.shadowRoot retorna nulo Não é possível perfurar; use page.evaluate com substituição de attachShadow({mode:'open'}) ou interceptação de renderização

Ordem de diagnóstico alternativa

  • Verifique primeiro as raízes de sombra abertas, depois os limites do iframe e só então os retornos de chamada no nível do aplicativo ou ganchos injetados.
  • Registre qual camada expôs o desafio para que futuras alterações no alvo sejam mais fáceis de detectar.
  • Mantenha um substituto de último recurso para raízes fechadas em vez de assumir que cada página expõe o acesso ao DOM de maneira uniforme.

Solução de problemas

Problema Causa Correção
shadowRoot retorna null DOM de sombra fechada Substitua attachShadow antes do carregamento da página para forçar mode: 'open'
Sitekey encontrado, mas token rejeitado URL da página errado enviado para o solucionador Use page.url() no momento da extração, não um URL codificado
Token injetado, mas o formulário não é enviado Retorno de chamada CAPTCHA não acionado Encontre e chame a função de retorno de chamada após definir o valor do token
A pesquisa recursiva é lenta Árvore DOM profunda Limitar a profundidade da recursão; alvo de nomes de tags de host shadow conhecidos
CAPTCHA não encontrado no carregamento inicial O widget carrega de forma assíncrona Aguarde primeiro pelo elemento host sombra: page.waitForSelector('custom-form')

Perguntas frequentes

Como posso saber se um CAPTCHA usa shadow DOM?

No Chrome DevTools, inspecione o elemento CAPTCHA. Se você vir #shadow-root (open) ou #shadow-root (closed) acima da marcação CAPTCHA, ele está dentro de uma raiz sombria. querySelector normal de document não o encontrará.

Posso evitar totalmente a travessia do shadow DOM?

Se você estiver usando a API CaptchaAI diretamente (não a injeção do navegador), você só precisa do sitekey e do pageurl - o shadow DOM não importa. O manuseio do Shadow DOM só é necessário quando você deve extrair as chaves do site da página ativa ou injetar tokens de volta no DOM.

E quanto às raízes sombrias fechadas?

Raízes de sombra fechadas bloqueiam o acesso el.shadowRoot. A solução alternativa é substituir Element.prototype.attachShadow antes que a página seja carregada, forçando todas as raízes sombrias a entrarem no modo aberto. Isso funciona em Playwright e Puppeteer via page.evaluateOnNewDocument.

Artigos relacionados

Próximas etapas

Lidar com CAPTCHAs ocultos dentro de componentes da web –obtenha sua chave API CaptchaAIe implementar a travessia do shadow DOM.

Guias relacionados:

Os comentários estão desativados para este artigo.