Tutorials

reCAPTCHA в одностраничных приложениях: шаблоны динамической загрузки

Пустой результат у селектора [data-sitekey] в SPA — это не ошибка селектора, а вопрос момента: виджета в DOM ещё нет. React, Vue и Angular монтируют reCAPTCHA после того, как отработает роутер и компонент попадёт в дерево, поэтому парсинг исходного HTML отдаёт страницу-заготовку без единого упоминания капчи.

Рабочая схема укладывается в четыре шага:

  1. Дождаться появления виджета — по событию, а не по таймеру.
  2. Вытащить sitekey оттуда, где он реально оказался.
  3. Получить токен через API CaptchaAI.
  4. Подставить токен так, чтобы фреймворк не затёр значение при следующем рендере.

Чем SPA отличается от обычной страницы

Четыре расхождения, из-за которых привычный сценарий автоматизации перестаёт работать:

  • Скрипт reCAPTCHA не в исходном HTML — он внедряется после смены маршрута.
  • Виджет рендерится не при загрузке страницы, а при монтировании компонента.
  • sitekey не виден в исходном коде — он лежит в JavaScript-бандле или приходит с бэкенда.
  • Форма уходит не обычным POST, а через XHR или fetch.

Отсюда главное правило: ждать событие, а не время. sleep(5) на медленном канале даёт ложные падения, а на быстром — лишние секунды в каждом прогоне.

Шаг 1. Дождитесь виджета reCAPTCHA

Ожидание элемента вместо фиксированной паузы

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()
    page.goto("https://staging.example.com/qa-login")

    # SPA may need a click or navigation to trigger reCAPTCHA
    page.click("#show-login-form")

    # Wait for reCAPTCHA iframe to appear
    recaptcha_frame = page.wait_for_selector(
        "iframe[src*='recaptcha']",
        timeout=15000
    )
    print("reCAPTCHA detected:", recaptcha_frame.get_attribute("src"))

Шаг 2. Достаньте sitekey — три места, где он прячется

В обычной вёрстке sitekey лежит в атрибуте data-sitekey и находится с первой попытки. В SPA источники стоит перебирать по убыванию надёжности:

Источник Когда срабатывает Насколько устойчив
Параметр k в src iframe Виджет уже отрисован Высокая: формат параметра стабилен
Атрибут data-sitekey Ключ проброшен в разметку компонента Высокая, если атрибут вообще есть
Регулярка по inline-скриптам Ключ зашит в бандл Низкая: пересборка меняет форматирование; держите как запасной вариант
# Method 1: From the iframe src
iframe_src = recaptcha_frame.get_attribute("src")
# src contains: ...?k=6LcR_RsTAAAAADge...
import re
match = re.search(r'[?&]k=([^&]+)', iframe_src)
site_key = match.group(1) if match else None

# Method 2: From data-sitekey attribute
site_key = page.eval_on_selector(
    "[data-sitekey]",
    "el => el.getAttribute('data-sitekey')"
)

# Method 3: From JavaScript bundle (last resort)
site_key = page.evaluate("""
    () => {
        // Check for grecaptcha render calls
        const scripts = document.querySelectorAll('script');
        for (const s of scripts) {
            const match = s.textContent.match(/sitekey['":\s]+(['"])(6L[^'"]+)\\1/);
            if (match) return match[2];
        }
        return null;
    }
""")

Шаг 3. Получите токен через API CaptchaAI

Дальше SPA-специфика заканчивается: API нужны только sitekey и pageurl. Задача уходит на in.php методом userrecaptcha, результат забирается опросом res.php по ID задачи.

import requests
import time

def solve_recaptcha(site_key, page_url):
    # Submit task
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": "YOUR_API_KEY",
        "method": "userrecaptcha",
        "googlekey": site_key,
        "pageurl": page_url,
        "json": 1
    })
    task_id = resp.json()["request"]

    # Poll for result
    for _ in range(60):
        time.sleep(3)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": "YOUR_API_KEY",
            "action": "get",
            "id": task_id,
            "json": 1
        })
        data = result.json()
        if data["status"] == 1:
            return data["request"]
    raise TimeoutError("Solve timed out")

Шаг 4. Подставьте токен так, чтобы фреймворк его не потерял

Классической отправки формы в SPA нет, и это главный источник ошибок на финальном шаге. Выбирайте способ осознанно, а не перебором:

  • Запись в textarea — когда форма читает g-recaptcha-response в момент отправки и не перерисовывает блок.
  • Вызов callback — вариант по умолчанию для React и Vue: токен попадает в состояние компонента штатным путём.
  • Перехват XHR — когда фронтенд формирует запрос сам, минуя callback виджета.

Способ 1. Записать значение в скрытую textarea

token = solve_recaptcha(site_key, "https://staging.example.com/qa-login")

# Inject into the g-recaptcha-response textarea
page.evaluate(f"""
    document.getElementById('g-recaptcha-response').value = '{token}';
""")

# Submit the form
page.click("#login-button")

Способ 2. Вызвать callback напрямую — предпочтительный вариант

Способ 1 ломается там, где реактивность перерисовывает узел: значение, записанное мимо состояния компонента, при следующем рендере исчезает. Функцию из data-callback виджет вызвал бы сам после успешной проверки — приложение положит токен в состояние штатным путём.

# Find the callback function name
callback = page.evaluate("""
    () => {
        const widget = document.querySelector('.g-recaptcha');
        return widget?.getAttribute('data-callback') || null;
    }
""")

# Call it with the token
if callback:
    page.evaluate(f"window['{callback}']('{token}')")
else:
    # Try the default grecaptcha callback
    page.evaluate(f"""
        if (window.grecaptcha) {{
            // Set the response and trigger verification
            document.getElementById('g-recaptcha-response').value = '{token}';
            // Find and call any registered callbacks
            const form = document.querySelector('form');
            if (form) form.dispatchEvent(new Event('submit'));
        }}
    """)

Способ 3. Перехват XHR на Puppeteer

Тот же сценарий на Node.js: включаем setRequestInterception, дожидаемся контейнера с data-sitekey, получаем токен и вызываем callback перед отправкой.

const puppeteer = require('puppeteer');

async function solveRecaptchaInSPA() {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();

  // Intercept the API call and inject the token
  await page.setRequestInterception(true);
  page.on('request', request => {
    if (request.url().includes('/api/login')) {
      const postData = JSON.parse(request.postData() || '{}');
      // Token already set via page.evaluate — let it pass
      request.continue();
    } else {
      request.continue();
    }
  });

  await page.goto('https://staging.example.com/qa-login');
  await page.waitForSelector('[data-sitekey]');

  const siteKey = await page.$eval(
    '[data-sitekey]',
    el => el.getAttribute('data-sitekey')
  );

  // Solve with CaptchaAI (implementation omitted — same as Python)
  const token = await solveCaptcha(siteKey, page.url());

  // Inject token and trigger callback
  await page.evaluate((t) => {
    document.getElementById('g-recaptcha-response').value = t;
    const callback = document.querySelector('.g-recaptcha')
      ?.getAttribute('data-callback');
    if (callback && window[callback]) {
      window[callback](t);
    }
  }, token);
}

Особенности React, Vue и Angular

Общая схема одна, но точка ожидания у каждого фреймворка своя — и именно её обычно ставят неправильно.

Фреймворк Как рендерится виджет Что ждать Типичная ошибка
React (react-google-recaptcha) Компонент монтируется асинхронно .g-recaptcha в состоянии attached Ждать visible: обёртка уже в DOM, iframe ещё нет
Vue (vue-recaptcha) Блок скрыт за v-if, узла физически нет iframe после переключения условия Ждать узел до действия, которое включает v-if
Angular Модуль подгружается лениво вместе с маршрутом Несколько вероятных селекторов сразу Жёсткая привязка к одному имени компонента

React и пакет react-google-recaptcha

# Wait for React to mount the component
page.wait_for_selector(".g-recaptcha", state="attached")

# The sitekey is in the rendered div's data attribute
site_key = page.eval_on_selector(
    ".g-recaptcha", "el => el.dataset.sitekey"
)

Vue и директива v-if

# Vue may use v-if to conditionally render
# Navigate or interact to trigger the condition
page.click("#proceed-to-checkout")
page.wait_for_selector("iframe[src*='recaptcha']")

Angular и ленивая загрузка модулей

Имя компонента различается от проекта к проекту, поэтому перечислите несколько вероятных селекторов через запятую — Playwright дождётся первого совпавшего.

# Angular apps may lazy-load reCAPTCHA modules
# Wait for the specific Angular component
page.wait_for_selector("re-captcha, app-recaptcha, [data-sitekey]")

Смена маршрута без перезагрузки

Отдельная ловушка — навигация внутри SPA. Документ не перезагружается, load не срабатывает, и логика, привязанная к загрузке страницы, молча пропускает новый экран. Ждите селектор виджета, а не навигацию.

# Listen for reCAPTCHA appearing after SPA navigation
page.goto("https://example.com")

# Navigate within the SPA
page.click("a[href='/login']")

# The URL changed but no page reload happened
# Wait for the CAPTCHA to render in the new "page"
page.wait_for_selector("iframe[src*='recaptcha']", timeout=10000)

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

Интернет-магазин на Vue, развёрнутый в европейском регионе, тестовый контур на staging. Форма регистрации закрыта reCAPTCHA v2, половина прогонов падала на шаге «отправить». Токен приходил корректно, но форма оставалась незаполненной: тест писал значение в textarea, после чего Vue перерисовывал блок и обнулял поле. Переход на data-callback убрал плавающие падения без единой правки во фронтенде. Три вывода, которые команда унесла в чек-лист:

  1. Считайте потоки, а не тесты. Тарификация CaptchaAI — по одновременным потокам, решения внутри потока не лимитированы. Ночной прогон в пять параллельных воркеров укладывается в BASIC ($15/мес, 5 потоков); пятнадцать воркеров требуют STANDARD ($30/мес, 15 потоков). Для команд, которые считают бюджет в USD, предсказуемость такого счёта — отдельный аргумент.
  2. Держите запас по тайм-ауту. На нестабильном мобильном канале и при кросс-региональных задержках жёсткие 5 с дают ложные падения; интервал опроса в 3 с и общий предел около минуты работают стабильнее.
  3. Не тащите лишние данные в артефакты. Если прогоны сохраняют заполненные формы, храните только те поля, которые вы вправе обрабатывать: 152-ФЗ и GDPR-практики применимы к тестовым артефактам так же, как к продуктовому коду.

Типовые сбои и что с ними делать

Симптом Причина Что делать
g-recaptcha-response не найден Компонент ещё не смонтирован Ждать селектор с тайм-аутом 10–15 с, а не фиксированную паузу
Токен записан, но форма не отправляется Callback не вызван, состояние компонента пустое Найти data-callback и вызвать функцию с токеном (Способ 2)
sitekey отсутствует в исходном коде Ключ собран в бандл или приходит с бэкенда Дождаться рендера виджета и взять ключ из src iframe
Токен срабатывает один раз, потом отказ Компонент перерисовался и очистил значение Подставлять токен непосредственно перед отправкой
После смены маршрута виджет «не появляется» Скрипт ждёт события загрузки страницы, которого нет Ждать селектор после клика по внутренней ссылке
ERROR_PAGEURL в ответе В pageurl ушёл первоначальный URL, а не текущий Передавать page.url() в момент отправки задачи

Частые вопросы

Почему wait_for_selector находит контейнер, но sitekey пустой?

Обёртка .g-recaptcha попадает в DOM раньше, чем виджет инициализируется. Ждите iframe[src*='recaptcha']: его появление означает, что sitekey уже известен и его можно взять из параметра k в src.

Что передавать в pageurl, если URL меняется без перезагрузки?

Текущий URL на момент отправки задачи — page.url() в Playwright или Puppeteer. Значение из page.goto() после внутренней навигации уже устарело, и несовпадение домена или пути возвращает ошибку сопоставления URL.

Хватит ли BASIC для CI с параллельными прогонами?

BASIC ($15/мес, 5 потоков) закрывает до пяти одновременных задач reCAPTCHA. Если пайплайн запускает шесть и более воркеров, шестой встанет в очередь — тогда смотрите на STANDARD ($30/мес, 15 потоков). Считайте по пиковой параллельности, а не по общему числу тестов за ночь.

Влияет ли фреймворк на само решение капчи?

Нет. API принимает sitekey и pageurl; React, Vue, Angular или серверный рендеринг за ними — для решения безразлично. Вся специфика SPA лежит на стороне автоматизации: когда ждать виджет и куда класть готовый токен.


Что почитать дальше

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