Hướng Dẫn Thực Hành

Xử lý CAPTCHA trong Shadow DOM của Web Components

Script lấy sitekey trả về null nhưng widget Turnstile vẫn hiện rõ trên màn hình? Gần như chắc chắn CAPTCHA nằm trong shadow root của một web component, và selector của bạn dừng đúng ở ranh giới đó.

Bài viết dành cho dev đang bảo trì suite Playwright hoặc Puppeteer chạy thật. Ví dụ dùng Cloudflare Turnstile, nhưng cách duyệt cây áp dụng y nguyên cho reCAPTCHA v2 và v3.

Ba bước xử lý

  1. Xác nhận widget nằm trong shadow root, không phải trong iframe.
  2. Duyệt đệ quy để lấy data-sitekey, gửi task tới in.php với method=turnstile.
  3. Polling res.php, chèn token vào input cf-turnstile-response rồi kích hoạt callback.

Vì sao CAPTCHA rơi vào shadow root

Shadow root xuất hiện vì lý do kiến trúc, không phải để chặn automation.

Bối cảnh Nguyên nhân
Component đăng nhập nội bộ Form đóng gói thành web component dùng chung
Widget của bên thứ ba Nhà cung cấp bọc cả form để cô lập CSS
Micro-frontend Mỗi micro-app dùng shadow root riêng
Design system CAPTCHA nằm trong phần tử của thư viện component

Bước 1: xác nhận widget nằm trong shadow root

Chạy đoạn dưới trong console DevTools: nó liệt kê mọi phần tử có shadowRoot và in sitekey nếu tìm thấy widget.

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

Có tên thẻ host kèm sitekey là đủ để sang bước tự động hóa. Vòng lặp không in gì thì shadow root nhiều khả năng đang đóng.

Bước 2: xuyên shadow DOM bằng Playwright

Locator engine của Playwright tự vượt ranh giới shadow root, nên css=.cf-turnstile là đủ. Đoạn dưới gộp cả trích xuất, giải và chèn 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()
  • extract_from_shadow_dom thử selector xuyên tầng trước, thất bại mới rơi về đệ quy trong page.evaluate.
  • solve_turnstile gửi task tới in.php rồi polling res.php mỗi 5 giây.
  • inject_token_shadow_dom tìm input cf-turnstile-response và gọi callback nếu có.

Bước 3: bản Puppeteer cho team Node.js

Puppeteer không có selector xuyên tầng, nên phần duyệt phải viết tay trong page.evaluate.

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

Thứ tự các bước giống hệt bản Python, nên suite đang chạy Puppeteer không cần đổi sang Playwright.

Độ sâu shadow root

Tình huống Ví dụ Cách duyệt
Một cấp <custom-form> #shadow-root > .cf-turnstile Truy vấn thẳng shadowRoot của host
Hai cấp trở lên <app-shell> #shadow > <login-form> #shadow > .cf-turnstile Đệ quy qua từng shadow root con
Shadow root đóng el.shadowRoot trả về null Ghi đè attachShadow({mode:'open'}) trước khi trang render

Thứ tự chẩn đoán khi không thấy widget

  1. Quét các shadow root mở trước — trường hợp chiếm đa số.
  2. Chưa thấy thì kiểm tra ranh giới iframe, rồi mới tới callback ở tầng ứng dụng.
  3. Ghi log lớp nào đã lộ ra widget và luôn giữ nhánh dự phòng cho shadow root đóng.

Tình huống thực tế tại một team outsourcing

Một team ở TP.HCM bảo trì test hồi quy cho cổng đăng nhập của khách. Sau khi khách nâng cấp design system, form bị bọc thành <auth-form> và mấy chục test Playwright đỏ cùng lúc với đúng một lỗi: sitekey trả về None.

  • Gom hàm trích xuất vào một module dùng chung để lần sau chỉ sửa một chỗ.
  • Mẫu lỗi này cũng hay gặp khi theo dõi giá công khai trên Shopee, Lazada hay Tiki.
  • Ghi log sitekey, pageurl và thời điểm giải — Nghị định 13/2023/NĐ-CP là lý do nên làm sớm.

Bao nhiêu thread là đủ

Shadow DOM không đổi cách tính tiền. CaptchaAI tính theo thread (luồng giải đồng thời), không tính theo lượt giải, và mỗi gói không giới hạn số lần giải trong tháng. Suite hồi quy chạy tuần tự thường chỉ cần BASIC ($15/tháng, 5 thread); chạy song song nhiều worker thì nâng lên STANDARD ($30/tháng, 15 thread) hoặc ADVANCE ($90/tháng, 50 thread). Giá niêm yết bằng USD.

Lỗi thường gặp

Triệu chứng Nguyên nhân Cách xử lý
shadowRoot trả về null Shadow root đóng Ghi đè attachShadow trước khi trang tải, ép mode: 'open'
Có sitekey nhưng token bị từ chối pageurl không khớp Dùng page.url() lúc trích xuất
Chèn token xong form không gửi Callback chưa chạy Gọi callback sau khi gán token
Đệ quy chạy chậm Cây DOM quá sâu Giới hạn độ sâu; nhắm thẳng tên thẻ host
Lần tải đầu không thấy CAPTCHA Widget nạp bất đồng bộ Chờ page.waitForSelector('custom-form')

Câu hỏi thường gặp

Widget trong shadow root có làm token kém giá trị hơn không?

Không. CaptchaAI giải dựa trên sitekeypageurl, không quan tâm widget nằm ở tầng DOM nào. Shadow DOM chỉ ảnh hưởng lúc đọc sitekey và lúc đặt token trở lại.

Nên chọn Playwright hay Puppeteer cho shadow DOM?

Playwright đỡ việc hơn vì locator engine tự vượt ranh giới shadow root. Puppeteer vẫn làm được nhưng phải viết hàm duyệt tay; cả hai gửi cùng một request tới in.php.

Chạy headless có tìm được widget trong shadow root không?

Có. Shadow root do JavaScript của trang dựng nên, headless hay có giao diện đều như nhau. Headless không thấy thường là vấn đề thời điểm: chờ phần tử host rồi hãy duyệt.

Có cách nào bỏ hẳn bước duyệt shadow DOM?

Có, nếu lấy được sitekey từ nguồn khác: mã nguồn trang, response API hoặc biến cấu hình của team bạn. Khi đó chỉ cần gọi API với sitekeypageurl.

Bài viết liên quan

Bước tiếp theo

Chép hàm trích xuất vào helper của suite, lấy API key CaptchaAI rồi chạy lại các test đang đỏ vì sitekey rỗng.

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