Tutorials

CAPTCHA di Shadow DOM: Mengakses Elemen di Web Components

Sitekey yang tidak terlihat bukan berarti tidak ada. Kalau document.querySelector('.cf-turnstile') mengembalikan null padahal widget tampil di layar, penyebabnya hampir selalu sama: widget dirender di dalam shadow root sebuah web component, dan pencarian dari document berhenti di batas shadow itu. Perbaikannya satu lapis penelusuran tambahan: telusuri shadow root secara rekursif, ambil data-sitekey, lalu kirim ke API CaptchaAI seperti CAPTCHA biasa.

Empat langkah yang akan Anda jalankan

  1. Konfirmasi widget memang berada di shadow root lewat DevTools.
  2. Ekstrak sitekey lewat selector piercing Playwright atau rekursi JavaScript.
  3. Kirim sitekey dan pageurl ke in.php, lalu polling res.php.
  4. Inject token ke input respons di shadow root yang sama dan picu callback-nya.

Mengapa widget CAPTCHA berakhir di dalam shadow root

Situasi Penyebabnya
Komponen login internal Dibungkus sebagai web component agar dipakai ulang
Widget formulir pihak ketiga Vendor membungkus formulir dalam satu shadow root
Arsitektur micro-frontend Tiap aplikasi kecil memakai shadow DOM terisolasi
Komponen design system CAPTCHA tertanam di elemen library internal

Enkapsulasi itu disengaja; skrip otomatisasi Anda ikut terkunci di luar.

Langkah 1: pastikan widget benar-benar berada di shadow root

Jalankan potongan berikut di console DevTools. Jika query biasa mengembalikan null tetapi loop shadow host mencetak sebuah tag name, lapisannya sudah ketahuan.

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

Catat nama tag host itu — jangkar waitForSelector yang jauh lebih stabil daripada kelas CSS yang berubah tiap rilis.

Langkah 2: ekstrak sitekey dan inject token dengan Playwright

Playwright punya dua jalur: selector piercing >> yang menembus batas shadow otomatis, dan page.evaluate dengan rekursi manual untuk shadow root bersarang. Skrip berikut mencoba jalur pertama, jatuh ke jalur kedua, lalu menutup dengan injeksi 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()

Perhatikan page.url() saat memanggil solver: pageurl harus URL halaman yang sedang aktif, bukan yang di-hardcode. Pageurl keliru adalah penyebab nomor satu token ditolak.

Langkah 3: pola yang sama di Puppeteer

Puppeteer tidak punya selector piercing, jadi seluruh penelusuran dijalankan di dalam page.evaluate. Logikanya identik: cari selector widget di setiap root, lalu rekursi ke shadowRoot tiap elemen anak.

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

Seberapa dalam shadow root-nya?

Kedalaman Contoh Pendekatan
Satu tingkat <custom-form> #shadow-root > .cf-turnstile Query langsung pada host
Dua tingkat atau lebih <app-shell> #shadow > <login-form> #shadow > .cf-turnstile Penelusuran rekursif

Urutan diagnostik saat sitekey tetap null

  • Periksa shadow root terbuka dulu, baru batas iframe, terakhir callback di level aplikasi.
  • Catat di lapisan mana tantangan muncul agar perubahan struktur halaman cepat terdeteksi.
  • Perlakukan shadow root tertutup sebagai opsi terakhir, bukan asumsi awal.

Konteks lokal: micro-frontend dan tim kecil

Pola ini makin sering muncul di tim produk Indonesia yang memecah dashboard menjadi micro-frontend, juga pada pekerjaan freelance QA dan scraping yang mengotomatiskan alur login klien. Untuk beban seperti itu biaya tidak dihitung per solve: paket BASIC ($15/bulan, 5 thread) cukup untuk satu suite login paralel, karena thread langsung bebas begitu satu solve selesai. Jalankan runner di region terdekat — AWS ap-southeast-1 (Singapura) atau ap-southeast-3 (Jakarta) — agar jeda polling tidak ditambah latensi lintas benua. Catatan kepatuhan: otomatiskan hanya alur milik Anda sendiri, dan hindari data pribadi sesuai UU PDP (UU 27/2022).

Kesalahan umum dan penanganannya

Gejala Penyebab Penanganan
shadowRoot bernilai null Root dibuat mode: 'closed' Timpa attachShadow sebelum halaman dimuat
Sitekey ketemu, token ditolak Pageurl keliru Pakai page.url() saat ekstraksi
Token masuk, formulir diam Callback tidak terpicu Panggil callback setelah mengisi token
Widget belum ada saat load Komponen dirender asinkron Tunggu page.waitForSelector('custom-form')

Pertanyaan yang sering muncul

Apakah CaptchaAI perlu tahu bahwa widget berada di dalam shadow DOM?

Tidak. API hanya menerima sitekey dan pageurl. Shadow DOM murni urusan sisi browser Anda: saat mengambil sitekey dan saat mengembalikan token.

Apakah hCaptcha di dalam shadow root juga bisa diselesaikan?

Tidak, hCaptcha belum didukung CaptchaAI. Untuk pola ini yang tersedia adalah reCAPTCHA v2/v3, Cloudflare Turnstile dan Cloudflare Challenge, serta GeeTest v3.

Berapa thread yang saya butuhkan untuk suite pengujian login?

Satu thread menangani satu CAPTCHA yang sedang berjalan. Jika suite Anda membuka lima sesi login paralel, lima thread sudah memadai; jumlah solve per bulan tidak dibatasi di dalam paket.

Apakah teknik ini sama dengan menangani CAPTCHA di dalam iframe?

Mirip, tetapi tidak sama. Iframe dilintasi lewat frame handle, shadow root lewat properti shadowRoot. Halaman modern kerap menggabungkan keduanya: web component yang berisi iframe widget.

Artikel Terkait

Langkah selanjutnya

Ambil API key CaptchaAI dan uji penelusuran shadow root pada halaman staging Anda sendiri.

Panduan terkait:

Komentar dinonaktifkan untuk artikel ini.