Tutorials

Обработка капчи Shadow DOM: доступ к элементам веб-компонентов

Если document.querySelector('.cf-turnstile') возвращает null, а виджет при этом видно на экране, — почти наверняка капча лежит внутри теневого корня веб-компонента. Границы shadow DOM изолируют разметку компонента от внешних селекторов, и обычный сценарий извлечения sitekey просто не доходит до нужного элемента.

Задача решается в три шага: подтвердить, что элемент действительно в теневом корне, пробить границу рекурсивным обходом или встроенным селектором Playwright и вернуть решённый токен в то же поле формы, где его ждёт виджет. Ниже — рабочий код на Python и Node.js, таблица глубин вложенности и разбор типовых ошибок.

Почему капча оказывается внутри теневого корня

Shadow DOM — это не защита от автоматизации, а обычный способ инкапсуляции. Фронтенд-разработчик прячет внутренности компонента, чтобы чужие стили и скрипты их не ломали; побочный эффект — ваш сценарий тоже перестаёт их видеть.

Сценарий Почему так вышло
Собственный компонент формы входа Форму оформили как переиспользуемый веб-компонент.
Сторонний виджет формы Поставщик виджета целиком заворачивает форму в теневой корень.
Микрофронтенды Каждое мини-приложение живёт в изолированном теневом DOM.
Компоненты дизайн-системы Капча встроена в элемент внутренней библиотеки компонентов.

Типичная картина в командах, которые собирают внутренний портал на Web Components и ставят Cloudflare Turnstile на форму входа: QA-сценарий работал полгода, потом фронтенд переехал на общую дизайн-систему — и весь набор автотестов упал на TypeError: cannot read properties of null. Разметка та же, изменилась только граница.

Шаг 1: убедитесь, что капча действительно в shadow root

Прежде чем править автотест, проверьте гипотезу вручную. Откройте DevTools и посмотрите на элемент: если над разметкой капчи стоит #shadow-root (open) или #shadow-root (closed), вопрос закрыт. Быстрая проверка из консоли:

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

Первый запрос вернул null, а цикл по узлам нашёл виджет — значит, перед вами открытый теневой корень, и дальше всё решается стандартными средствами.

Шаг 2: Python и Playwright — пробиваем границу и решаем Turnstile

У Playwright есть два подхода, и в сценарии ниже используются оба. Селектор >> и API locator умеют переходить через границы теневого DOM сами; если этого не хватило, остаётся page.evaluate с рекурсией по всем shadowRoot. Второй вариант надёжнее на глубокой вложенности, зато первый короче.

Сам вызов CaptchaAI при этом к shadow DOM отношения не имеет: сервису нужны только sitekey и pageurl. Отправляете задачу на in.php, опрашиваете res.php и получаете токен.

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

Обратите внимание на две детали. Во-первых, pageurl берётся из page.url в момент извлечения, а не прописывается константой: если сработал редирект, зашитый вручную адрес приведёт к отклонению токена. Во-вторых, после подстановки значения в поле сценарий пытается вызвать колбэк виджета — многие формы отправляются только после него.

Шаг 3: тот же обход на Node.js и Puppeteer

В Puppeteer встроенного пробивающего селектора нет, поэтому весь обход выполняется внутри page.evaluate. Логика ровно та же: рекурсивно спускаемся по shadowRoot, ищем сначала контейнер виджета, затем поле ответа.

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

Оба сценария написаны под staging-стенд (staging.example.com) — это осознанный выбор: отлаживать обход теневых границ следует на собственном тестовом окружении, а не на чужом продакшене.

Глубина вложенности: чего ожидать

Глубина Пример Подход
1 уровень <custom-form> #shadow-root > .cf-turnstile Прямой запрос к теневому корню
2 уровня и глубже <app-shell> #shadow > <login-form> #shadow > .cf-turnstile Рекурсивный обход
Открытый теневой корень el.shadowRoot доступен Стандартный путь — рекурсия
Закрытый теневой корень el.shadowRoot возвращает null Пробить нельзя; переопределите attachShadow({mode:'open'}) до загрузки страницы

Практическое правило: не запускайте рекурсию по всему document, если знаете имя тега компонента-хоста. Обход querySelectorAll('*') на большом SPA с несколькими тысячами узлов заметно тормозит, а старт с конкретного <login-form> обходится почти бесплатно.

Что ломается чаще всего

Симптом Причина Что сделать
shadowRoot возвращает null Закрытый теневой корень Переопределить Element.prototype.attachShadow до загрузки страницы и принудительно ставить mode: 'open'
sitekey найден, но токен отклонён В решатель ушёл не тот pageurl Брать адрес из page.url() в момент извлечения, а не из константы
Токен подставлен, форма не отправляется Не вызван колбэк виджета После записи значения найти и вызвать колбэк
Рекурсивный поиск работает медленно Слишком большое дерево DOM Ограничить глубину и стартовать с известного тега хоста
Капча не находится сразу после загрузки Виджет подгружается асинхронно Сначала дождаться хоста: page.waitForSelector('custom-form')

Порядок диагностики, когда непонятно, где теряется элемент

  1. Проверьте открытые теневые корни — самый частый случай и самый дешёвый в починке.
  2. Затем границы iframe: капча может лежать в отдельном фрейме, а не в теневом корне, и внешний симптом тот же.
  3. И только потом смотрите на колбэки уровня приложения и на собственные перехватчики рендеринга.

Записывайте, на каком уровне сломался сценарий: при следующем переезде дизайн-системы этот журнал сэкономит вам час отладки. И заранее держите запасной путь для закрытых корней.

Сколько это стоит по потокам

Тарификация CaptchaAI считается по одновременным потокам, а не по числу решений, и от того, где лежит виджет — в обычном DOM или в теневом корне, — она не зависит. Одному QA-раннеру, который прогоняет ночную регрессию последовательно, хватает тарифа BASIC ($15/мес, 5 потоков). Команде, которая гоняет параллельные наборы автотестов в CI, обычно ближе STANDARD ($30/мес, 15 потоков) или ADVANCE ($90/мес, 50 потоков). Цены указаны в долларах США; актуальный список тарифов смотрите на странице тарифов CaptchaAI.

Отдельно про типы. Приведённый код рассчитан на Cloudflare Turnstile и reCAPTCHA v2 — оба поддерживаются. Селектор .h-captcha в примере на Puppeteer оставлен как часть поиска разметки на странице: hCaptcha сервис CaptchaAI не решает, как и FunCaptcha, а поддержка GeeTest v4 заявлена как «скоро» и пока недоступна.

Вопросы и ответы

Как быстро отличить капчу в shadow DOM от капчи в iframe?

В DevTools над разметкой капчи будет строка #shadow-root. У iframe вместо этого отдельный документ и своя вкладка контекста в консоли. Симптом одинаковый — querySelector возвращает null, — но лечение разное: для iframe нужен переход во фрейм, для теневого корня — обход границы.

Нужен ли обход shadow DOM, если я работаю только через API?

Нет. Для отправки задачи достаточно sitekey и pageurl. Обход теневых границ нужен только тогда, когда вы вытаскиваете sitekey с живой страницы или возвращаете токен обратно в форму в браузере.

Почему рекурсивный поиск подвисает на большом SPA?

Обход querySelectorAll('*') от корня документа перебирает каждый узел страницы, а на крупном SPA их бывает несколько тысяч. Стартуйте с известного тега хоста — page.query_selector('login-form') — и ограничивайте глубину рекурсии двумя-тремя уровнями: на практике капча глубже почти не встречается.

Почему токен подставился, а форма всё равно не ушла?

Виджет обычно отправляет форму не по факту изменения поля, а по колбэку. Запись значения в [name="cf-turnstile-response"] меняет только состояние DOM. Найдите функцию обратного вызова и вызовите её после подстановки — в примере на Python это делается сразу после записи значения.

Насколько такой сценарий устойчив к изменениям на фронтенде?

Ровно настолько, насколько стабильны имена тегов и классов. Привязка к структуре компонентов ломается при рефакторинге дизайн-системы, поэтому держите извлечение sitekey отдельной функцией и покрывайте её собственным тестом — тогда правка занимает минуты, а не полдня.


Следующие шаги

Комментарии для этой статьи отключены.