Tutorials

Shadow-DOM-CAPTCHA-Behandlung: Elemente in Webkomponenten erreichen

Ein CAPTCHA, das im Browser klar sichtbar ist, das document.querySelector aber trotzdem nicht findet, steckt fast immer in einer Shadow-Root. Web Components kapseln ihr Markup bewusst ab – Ihr Extraktionsskript sieht nur den Host-Tag, nicht den Sitekey darin. Der Weg zur Lösung ist immer derselbe vierstufige Ablauf: Shadow-Root erkennen, Sitekey aus dem gekapselten Baum auslesen, Token über CaptchaAI anfordern und ihn anschließend in das richtige Antwortfeld zurückschreiben. Dieser Leitfaden zeigt beide Umsetzungen – mit Playwright unter Python und mit Puppeteer unter Node.js – am Beispiel von Cloudflare Turnstile.

Gerade in DACH-Unternehmen mit ausgereiften Design-Systemen ist dieses Muster Alltag: Login- und Checkout-Bausteine – etwa in Shopware- oder JTL-Storefronts – werden als wiederverwendbare Web Components ausgeliefert, und das CAPTCHA-Widget wandert mit in die Shadow-Root. Bevor Sie loslegen, lohnt sich eine kurze Einordnung, denn Shadow DOM, iframe-Einbettungen und verzögertes Rendering sehen im ersten Moment gleich aus, brauchen aber unterschiedliche Diagnosewege.

Shadow DOM richtig einordnen – nicht mit iframe oder Lazy-Rendering verwechseln

Beobachtung Wahrscheinlichster Fall Was Sie zuerst prüfen
Widget ist sichtbar, document.querySelector liefert aber null Shadow DOM Hosts mit shadowRoot durchsuchen
Widget steckt in einem eigenen Dokument iframe Frame wechseln statt Shadow-Root durchdringen
Widget fehlt anfangs und taucht erst später auf Spätes Rendering Mutation Observer oder Framework-Hooks
Widget teilweise sichtbar, Eingabefelder fehlen Mischfall aus Shadow DOM und Callback-Logik Shadow-Baum und Callback-Pfad gemeinsam prüfen

Warum CAPTCHAs überhaupt in der Shadow-Root landen

  • Eigene Login-Komponente – als wiederverwendbare Web Component gekapselt, inklusive Widget.
  • Formular-Widget eines Drittanbieters – der Anbieter verpackt das gesamte Formular in eine Shadow-Root.
  • Micro-Frontend-Architektur – jede Micro-App bringt ihr eigenes, isoliertes Shadow DOM mit.
  • Design-System-Komponente – das CAPTCHA sitzt in einem Element der Komponentenbibliothek.

Shadow-Root-CAPTCHA zuverlässig erkennen

Prüfen Sie vor dem Lösen, ob das CAPTCHA wirklich in einer Shadow-Root sitzt. In der DevTools-Konsole macht dieser kleine Scan die Hosts sichtbar:

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

Liefert der Scan einen Host mit Sitekey, wissen Sie: Es ist ein Shadow-DOM-Fall, und normale Selektoren greifen nicht. Jetzt automatisieren Sie den Ablauf.

Playwright (Python): Shadow-Root gezielt durchdringen

Der Piercing-Selektor >> und die locator-API von Playwright verarbeiten Shadow DOM nativ. Fällt der native Weg aus, greift eine rekursive page.evaluate-Suche über alle Shadow-Roots. Denselben Rekursionsansatz nutzen wir anschließend, um den gelösten Token wieder einzuschreiben:

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://example.com/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()

Wichtig ist die Reihenfolge: erst mit page.url die tatsächliche Seiten-URL erfassen, dann an CaptchaAI übermitteln. Ein hartkodierter pageurl-Wert ist die häufigste Ursache dafür, dass ein technisch gültiger Token vom Ziel abgelehnt wird.

Puppeteer (Node.js): rekursiv durch die Shadow-Bäume

Puppeteer kennt keinen Piercing-Selektor, deshalb übernimmt hier durchgehend die rekursive Suche im Kontext der Seite. Die Extraktion prüft mehrere Widget-Klassen, das Einschreiben adressiert das passende Antwortfeld:

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://example.com/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();
})();

Wie tief liegt die Shadow-Root? Ansätze nach Verschachtelung

Die passende Strategie hängt davon ab, wie tief das Widget gekapselt ist:

  • Eine Ebene (<custom-form> #shadow-root > .cf-turnstile) – eine direkte Abfrage der Shadow-Root genügt.
  • Zwei oder mehr Ebenen (<app-shell> #shadow > <login-form> #shadow > .cf-turnstile) – hier braucht es die rekursive Durchquerung aus den Beispielen oben.
  • Offene Shadow-Root (el.shadowRoot ist zugänglich) – der Standardweg über Rekursion funktioniert.
  • Geschlossene Shadow-Root (el.shadowRoot liefert null) – nicht direkt erreichbar; überschreiben Sie attachShadow vor dem Seitenaufbau auf mode: 'open' oder fangen Sie das Rendering ab.

Typische Fehler und wie Sie sie beheben

Problem Ursache Lösung
Token wird erzeugt, aber vom Ziel abgelehnt Sitekey, pageurl oder Session-Kontext passen nicht Parameter erneut erfassen und den Token in derselben Browser- oder HTTP-Sitzung verwenden
Polling läuft in ein Timeout Intervall, Wartezeit oder Fehlerbehandlung zu eng gesetzt Alle 5–10 Sekunden abfragen, Timeout von echten Fehlercodes trennen und die Ursache loggen
Beispiel läuft lokal, im Workflow aber nicht Callback, Formularfeld oder Token-Injektion fehlt in der echten Zielkette Den exakten Übergabepfad vom Solver bis zur finalen Zielanfrage prüfen
el.shadowRoot bleibt null Geschlossene Shadow-Root attachShadow vor dem Seitenaufbau auf mode: 'open' erzwingen

Häufige Fragen

Muss ich die Shadow-Root durchdringen, wenn ich ohnehin die CaptchaAI-API nutze?

Nein. Für den API-Aufruf brauchen Sie nur Sitekey und pageurl. Das Durchdringen der Shadow-Root ist ausschließlich dann nötig, wenn Sie den Sitekey aus der laufenden Seite auslesen oder den Token anschließend wieder in das DOM einschreiben müssen. Kennen Sie den Sitekey bereits, spielt die Kapselung keine Rolle.

Welche Parameter erwartet CaptchaAI für ein Turnstile-CAPTCHA?

Für Cloudflare Turnstile genügen method=turnstile, der sitekey aus dem Widget und die pageurl der Seite, auf der das CAPTCHA erscheint. Wichtig ist, die pageurl zur Laufzeit über page.url() zu erfassen, statt sie fest zu verdrahten – sonst weicht sie vom tatsächlichen Kontext ab.

Der Token ist gesetzt, aber das Formular reagiert nicht – woran liegt das?

Viele Widgets senden das Formular erst ab, wenn zusätzlich ihr Callback aufgerufen wurde. Das reine Setzen des cf-turnstile-response-Feldes reicht dann nicht. Rufen Sie nach dem Einschreiben die zugehörige Callback-Funktion auf, damit die Komponente den Token als gültig registriert.

Kann ich mehrere gekapselte CAPTCHAs auf einer Seite verarbeiten?

Ja. Die rekursive Suche findet grundsätzlich alle Shadow-Root-Hosts. Bei mehreren Widgets sollten Sie pro Host getrennt auslesen und einschreiben, damit Sitekey und Token nicht vertauscht werden. Grenzen Sie die Suche zusätzlich auf bekannte Host-Tags ein, um die Durchquerung tiefer DOM-Bäume schnell zu halten.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.