Пустой результат у селектора [data-sitekey] в SPA — это не ошибка селектора, а вопрос момента: виджета в DOM ещё нет. React, Vue и Angular монтируют reCAPTCHA после того, как отработает роутер и компонент попадёт в дерево, поэтому парсинг исходного HTML отдаёт страницу-заготовку без единого упоминания капчи.
Рабочая схема укладывается в четыре шага:
- Дождаться появления виджета — по событию, а не по таймеру.
- Вытащить sitekey оттуда, где он реально оказался.
- Получить токен через API CaptchaAI.
- Подставить токен так, чтобы фреймворк не затёр значение при следующем рендере.
Чем 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 убрал плавающие падения без единой правки во фронтенде. Три вывода, которые команда унесла в чек-лист:
- Считайте потоки, а не тесты. Тарификация CaptchaAI — по одновременным потокам, решения внутри потока не лимитированы. Ночной прогон в пять параллельных воркеров укладывается в BASIC ($15/мес, 5 потоков); пятнадцать воркеров требуют STANDARD ($30/мес, 15 потоков). Для команд, которые считают бюджет в USD, предсказуемость такого счёта — отдельный аргумент.
- Держите запас по тайм-ауту. На нестабильном мобильном канале и при кросс-региональных задержках жёсткие 5 с дают ложные падения; интервал опроса в 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 лежит на стороне автоматизации: когда ждать виджет и куда класть готовый токен.