Tutorials

CAPTCHA в модальном окне: обнаружение и внедрение токена

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

В отличие от статичной CAPTCHA на обычной странице, здесь поиск виджета до триггера бессмысленен — синхронный запрос селектора вернёт пустой результат, потому что узла ещё нет в DOM. Это меняет архитектуру скрипта: сначала событие или клик, затем ожидание появления модалки, и только потом извлечение параметров CAPTCHA внутри неё.

Где CAPTCHA чаще всего встраивают в модалку

Сценарий Триггер Особенность
Модальный вход в аккаунт Клик по кнопке «Войти» CAPTCHA внутри overlay-блока
Антибот-проверка на весь экран Срабатывает автоматически Модалка перекрывает всю страницу
Подтверждение при оформлении заказа Отправка формы оплаты Окно всплывает для верификации
Ограничение частоты запросов Слишком много запросов подряд CAPTCHA-гейт перед повтором
Согласие на cookie Первый визит на сайт CAPTCHA встроена в диалог согласия

Разметка модального окна отличается от сайта к сайту: где-то это .modal.show от Bootstrap, где-то нативный <dialog>, где-то кастомный overlay без единого стандартного класса. Поэтому обработчик ниже перебирает несколько селекторов подряд вместо того, чтобы полагаться на один жёстко заданный вариант.

Тайминг модалки: где чаще всего теряют токен

Фактор Риск Митигация
Автозакрытие по таймеру Окно закрывается раньше токена Решайте сразу после обнаружения sitekey
Истечение сессии на бэкенде Сервер сбрасывает сессию во время ожидания Держите сессию фоновым heartbeat-запросом
Задержка рендера CAPTCHA Виджет догружается 1–3 секунды Ждите 2 секунды перед чтением sitekey
Истечение токена при заполнении формы Токен стареет, пока вводите поля Решайте CAPTCHA последней

Отсюда правило для кода ниже: модалка → пауза на рендер → sitekey → решение → токен и сабмит.

Python и Playwright: обработчик модальных CAPTCHA

Скрипт кликает по триггеру, ждёт модалку по нескольким селекторам и читает data-sitekey. handle_modal_captcha() собирает весь цикл в одну функцию.

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_captcha(sitekey, pageurl, method="userrecaptcha"):
    """Submit and poll a CAPTCHA."""
    params = {
        "key": API_KEY,
        "method": method,
        "json": 1,
    }
    if method == "userrecaptcha":
        params["googlekey"] = sitekey
        params["pageurl"] = pageurl
    elif method == "turnstile":
        params["sitekey"] = sitekey
        params["pageurl"] = pageurl

    resp = requests.post(SUBMIT_URL, data=params, 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 detect_modal_captcha(page):
    """
    Detect CAPTCHA inside a visible modal/dialog.
    Returns (sitekey, method) or (None, None).
    """
    return page.evaluate("""
        () => {
            // Find visible modals
            const modalSelectors = [
                '.modal.show',
                '.modal[style*="display: block"]',
                'dialog[open]',
                '[role="dialog"]:not([aria-hidden="true"])',
                '.overlay.visible',
                '.popup.active',
                '[class*="modal"][class*="open"]',
            ];

            let modal = null;
            for (const sel of modalSelectors) {
                const el = document.querySelector(sel);
                if (el && el.offsetParent !== null) {
                    modal = el;
                    break;
                }
            }

            // If no modal found, search entire document
            const searchRoot = modal || document;

            // Check for reCAPTCHA
            const recaptcha = searchRoot.querySelector('.g-recaptcha[data-sitekey]');
            if (recaptcha) {
                return { sitekey: recaptcha.dataset.sitekey, method: 'userrecaptcha' };
            }

            // Check for Turnstile
            const turnstile = searchRoot.querySelector('.cf-turnstile[data-sitekey]');
            if (turnstile) {
                return { sitekey: turnstile.dataset.sitekey, method: 'turnstile' };
            }

            // Check for hCaptcha
            const hcaptcha = searchRoot.querySelector('.h-captcha[data-sitekey]');
            if (hcaptcha) {
                return { sitekey: hcaptcha.dataset.sitekey, method: 'hcaptcha' };
            }

            return null;
        }
    """)


def inject_token_in_modal(page, token, method="userrecaptcha"):
    """Inject token into the CAPTCHA inside the modal."""
    if method == "userrecaptcha":
        page.evaluate("""
            (token) => {
                // Find response textarea (may be inside modal)
                const textareas = document.querySelectorAll('#g-recaptcha-response, [name="g-recaptcha-response"]');
                textareas.forEach(ta => {
                    ta.value = token;
                    ta.style.display = 'block';
                });

                // Trigger callback
                if (typeof ___grecaptcha_cfg !== 'undefined') {
                    Object.values(___grecaptcha_cfg.clients).forEach(client => {
                        Object.values(client).forEach(val => {
                            if (val && typeof val === 'object') {
                                Object.values(val).forEach(v => {
                                    if (v && typeof v.callback === 'function') v.callback(token);
                                });
                            }
                        });
                    });
                }
            }
        """, token)
    elif method == "turnstile":
        page.evaluate("""
            (token) => {
                const inputs = document.querySelectorAll('[name="cf-turnstile-response"]');
                inputs.forEach(inp => { inp.value = token; });

                if (typeof window.turnstileCallback === 'function') {
                    window.turnstileCallback(token);
                }
            }
        """, token)


def handle_modal_captcha(page, trigger_selector=None, timeout=10000):
    """
    Full workflow: trigger modal, detect CAPTCHA, solve, inject.
    """
    # Step 1: Trigger the modal if needed
    if trigger_selector:
        print(f"Clicking trigger: {trigger_selector}")
        page.click(trigger_selector)

    # Step 2: Wait for modal to become visible
    print("Waiting for modal...")
    modal_selectors = [
        ".modal.show",
        "dialog[open]",
        '[role="dialog"]:not([aria-hidden="true"])',
        ".popup.active",
    ]

    modal_visible = False
    for selector in modal_selectors:
        try:
            page.wait_for_selector(selector, timeout=timeout)
            modal_visible = True
            print(f"  Modal detected: {selector}")
            break
        except Exception:
            continue

    if not modal_visible:
        print("  No modal detected")
        return None

    # Step 3: Wait for CAPTCHA to render inside modal
    time.sleep(2)  # Brief pause for dynamic CAPTCHA loading

    captcha_info = detect_modal_captcha(page)
    if not captcha_info:
        print("  No CAPTCHA found in modal")
        return None

    sitekey = captcha_info["sitekey"]
    method = captcha_info["method"]
    print(f"  Found {method} CAPTCHA: {sitekey[:20]}...")

    # Step 4: Solve via CaptchaAI
    token = solve_captcha(sitekey, page.url, method)
    print(f"  Solved: {token[:30]}...")

    # Step 5: Inject token
    inject_token_in_modal(page, token, method)
    print("  Token injected")

    return token


def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()
        page.goto("https://example.com")
        page.wait_for_load_state("networkidle")

        # Handle CAPTCHA that appears in login modal
        token = handle_modal_captcha(
            page,
            trigger_selector="button#login-btn",
            timeout=10000,
        )

        if token:
            # Fill form fields inside modal
            page.fill('dialog input[name="email"]', "[email protected]")
            page.fill('dialog input[name="password"]', "password123")

            # Submit modal form
            page.click('dialog button[type="submit"]')
            page.wait_for_load_state("networkidle")

        browser.close()


main()

JavaScript и Puppeteer: обработчик модального окна

Та же логика на Puppeteer: waitForModal() перебирает селекторы, а handleModalCaptcha() связывает клик, решение и внедрение токена в один вызов.

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 solveCaptcha(sitekey, pageurl, method = "userrecaptcha") {
  const body = new URLSearchParams({ key: API_KEY, method, json: "1" });
  if (method === "userrecaptcha") { body.set("googlekey", sitekey); body.set("pageurl", pageurl); }
  else if (method === "turnstile") { body.set("sitekey", sitekey); body.set("pageurl", pageurl); }

  const resp = await (await fetch(SUBMIT_URL, { method: "POST", body })).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 waitForModal(page, timeout = 10000) {
  const selectors = [".modal.show", "dialog[open]", '[role="dialog"]', ".popup.active"];
  for (const sel of selectors) {
    try {
      await page.waitForSelector(sel, { visible: true, timeout });
      return sel;
    } catch {}
  }
  return null;
}

async function detectModalCaptcha(page) {
  return page.evaluate(() => {
    const checks = [
      { sel: ".g-recaptcha[data-sitekey]", method: "userrecaptcha" },
      { sel: ".cf-turnstile[data-sitekey]", method: "turnstile" },
    ];
    for (const { sel, method } of checks) {
      const el = document.querySelector(sel);
      if (el && el.dataset.sitekey) return { sitekey: el.dataset.sitekey, method };
    }
    return null;
  });
}

async function handleModalCaptcha(page, triggerSelector) {
  // Trigger modal
  if (triggerSelector) await page.click(triggerSelector);

  // Wait for modal
  const modalSel = await waitForModal(page);
  if (!modalSel) { console.log("No modal found"); return null; }
  console.log(`Modal visible: ${modalSel}`);

  // Wait for CAPTCHA render
  await new Promise((r) => setTimeout(r, 2000));

  const info = await detectModalCaptcha(page);
  if (!info) { console.log("No CAPTCHA in modal"); return null; }

  console.log(`Found ${info.method}: ${info.sitekey.substring(0, 20)}...`);
  const token = await solveCaptcha(info.sitekey, page.url(), info.method);
  console.log(`Solved: ${token.substring(0, 30)}...`);

  // Inject token
  await page.evaluate((t, method) => {
    if (method === "userrecaptcha") {
      document.querySelectorAll("#g-recaptcha-response").forEach((el) => { el.value = t; });
    } else if (method === "turnstile") {
      document.querySelectorAll('[name="cf-turnstile-response"]').forEach((el) => { el.value = t; });
    }
  }, token, info.method);

  return token;
}

(async () => {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();
  await page.goto("https://example.com", { waitUntil: "networkidle2" });

  const token = await handleModalCaptcha(page, "button#login-btn");
  if (token) {
    await page.type('dialog input[name="email"]', "[email protected]");
    await page.click('dialog button[type="submit"]');
    await page.waitForNavigation();
  }

  await browser.close();
})();

Обе версии решают одну задачу с разной механикой ожидания: page.wait_for_selector в Playwright и page.waitForSelector в Puppeteer принимают таймаут в миллисекундах и оба бросают исключение при неудаче, поэтому в обоих обработчиках перебор селекторов обёрнут в try/except (try/catch) — падение на одном варианте селектора не должно останавливать проверку остальных.

Типичные проблемы и их решение

Ниже — пять сбоев, которые чаще всего всплывают в логах при автоматизации модальных CAPTCHA, и как их разобрать по трейсу без повторного ручного прогона.

Проблема Причина Решение
Модалка есть, CAPTCHA не найдена Виджет грузится асинхронно Увеличьте ожидание, ловите вставку через MutationObserver
Токен внедрён, модалка не закрывается Не сработал callback Вызовите callback вручную
Модалка закрывается во время решения Сработал таймер автозакрытия clearTimeout() на его идентификаторе
Sitekey каждый раз новый Новый экземпляр CAPTCHA при открытии Читайте sitekey заново, не кешируйте
Клик не открывает модалку Элемент неинтерактивен или перекрыт page.dispatchEvent или ждите кликабельности

Пример из практики: QA модального логина

Частый кейс QA-команд — форма входа, где CAPTCHA всплывает в модалке сразу после клика «Войти», а сессия на бэкенде уже отсчитывает таймаут. При тестах с нестабильного мобильного канала timeout в 10 секунд стоит увеличить — иначе прогон упадёт из-за сети, а не из-за CAPTCHA. Для параллельных прогонов хватает STANDARD ($30/мес, 15 потоков); при нагрузке выше — ADVANCE ($90/мес, 50 потоков). И гигиена логов: сохраняйте из модальной формы только то, что нужно для дебага — с оглядкой на 152-ФЗ и GDPR-практику кросс-граничных команд. Отдельно логируйте, на каком именно селекторе сработало обнаружение модалки — это сильно ускоряет разбор упавших прогонов на CI, где воспроизвести конкретный DOM руками не получится.

FAQ

Как поймать модалку, которая открывается сама, без клика?

Настройте MutationObserver до навигации — он следит за новыми узлами DOM и сработает, как только появится видимая модалка.

CAPTCHA внутри модалки лежит в отдельном iframe — что делать?

Дождитесь модалки, переключитесь в контекст iframe и уже там ищите sitekey — снаружи его не видно.

Сколько реально есть времени до автозакрытия модалки?

Зависит от сайта — обычно от секунд до пары минут. Решайте сразу после обнаружения sitekey, не откладывайте.

Какого тарифа CaptchaAI хватит для параллельных модальных тестов?

BASIC ($15/мес, 5 потоков) — для одиночных прогонов; STANDARD ($30/мес, 15 потоков) или ADVANCE ($90/мес, 50 потоков) — для нескольких стендов сразу.

Токен отправлен, но модалка не закрывается — почему?

Не сработал JS-callback виджета. Вызовите его вручную (___grecaptcha_cfg для reCAPTCHA, window.turnstileCallback для Turnstile).

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

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