MutationObserver — вот что нужно, если reCAPTCHA v2 или Cloudflare Turnstile в вашем PWA рендерится JavaScript после того, как скрипт автоматизации проверил DOM и не нашёл sitekey. Прогрессивные веб-приложения не отдают виджет CAPTCHA в исходном HTML: маршрутизация работает на клиенте без перезагрузки страницы, Service Worker кэширует ресурсы, а сама CAPTCHA появляется в DOM с задержкой. CaptchaAI решает сам вызов, но без правильной стратегии обнаружения ваш скрипт просто не успеет найти sitekey до того, как виджет появится.
В этом руководстве — детектирование, извлечение параметров и решение CAPTCHA в PWA на Playwright, а также альтернативный вариант на Python и Selenium.
Из руководства вы получите:
- рабочий детектор на MutationObserver, который ловит CAPTCHA независимо от того, через сколько секунд после загрузки её отрисует фреймворк;
- приём для сброса устаревшего кеша Service Worker, из-за которого автотест видит старый sitekey;
- обработчик клиентской навигации, чтобы не пропустить CAPTCHA на маршруте, куда SPA перешла без перезагрузки страницы;
- альтернативную реализацию на Python и Selenium — на случай, если стек автоматизации не завязан на Playwright.
Ключевой момент: пока виджет CAPTCHA не появился в DOM, решать нечего. Отправите пустой или ещё не отрисованный sitekey — CaptchaAI API ответит
ERROR_WRONG_GOOGLEKEY.
Чем PWA отличаются от обычного сайта
Обычный сайт отдаёт CAPTCHA сразу в первом HTML-ответе. PWA устроены иначе:
| Аспект | Обычный сайт | PWA |
|---|---|---|
| Загрузка CAPTCHA | В исходном HTML | Рендерится JavaScript после загрузки страницы |
| Переход между страницами | Полная перезагрузка | Маршрутизация на стороне клиента (без перезагрузки) |
| Service Worker | Отсутствует | Кэширует ресурсы, может перехватывать запросы |
| Доступность DOM | Сразу | После рендеринга фреймворка |
| Сетевые запросы | Идут напрямую | Может перехватить Service Worker |
Это особенно заметно у команд, которые строят собственные PWA для логистики, бронирования или личных кабинетов и гоняют checkout-флоу в staging на нестабильном мобильном соединении — обычная ситуация для распределённых команд в СНГ. Устаревший кеш Service Worker и виджет, который ещё не отрисовался, — частая причина ложного «CAPTCHA не найдена» в автотестах.
Шаг 1. Дождитесь, пока PWA отрисует CAPTCHA
Типичная ошибка — искать sitekey в DOM до того, как фреймворк PWA отрисовал виджет CAPTCHA. Используйте MutationObserver или сигналы конкретного фреймворка, чтобы поймать момент её появления:
// pwa_captcha_detector.js — Playwright script
const { chromium } = require('playwright');
const axios = require('axios');
const API_KEY = 'YOUR_API_KEY';
async function detectCaptchaInPWA(page) {
// Wait for the PWA app shell to render
await page.waitForLoadState('networkidle');
// Use MutationObserver to detect dynamically loaded CAPTCHAs
const captchaInfo = await page.evaluate(() => {
return new Promise((resolve) => {
// Check if CAPTCHA is already present
const existing = document.querySelector('.g-recaptcha, .cf-turnstile');
if (existing) {
resolve({
type: existing.classList.contains('g-recaptcha')
? 'recaptcha_v2' : 'turnstile',
sitekey: existing.getAttribute('data-sitekey'),
pageurl: window.location.href,
});
return;
}
// Watch for CAPTCHA elements added dynamically
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
const captcha = node.matches?.('.g-recaptcha, .cf-turnstile')
? node
: node.querySelector?.('.g-recaptcha, .cf-turnstile');
if (captcha) {
observer.disconnect();
resolve({
type: captcha.classList.contains('g-recaptcha')
? 'recaptcha_v2' : 'turnstile',
sitekey: captcha.getAttribute('data-sitekey'),
pageurl: window.location.href,
});
return;
}
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Timeout after 15 seconds
setTimeout(() => {
observer.disconnect();
resolve(null);
}, 15000);
});
});
return captchaInfo;
}
async function main() {
const browser = await chromium.launch({ headless: false });
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example-pwa.com/login');
const captcha = await detectCaptchaInPWA(page);
if (!captcha) {
console.log('No CAPTCHA detected');
await browser.close();
return;
}
console.log(`Detected ${captcha.type}: ${captcha.sitekey}`);
// Solve with CaptchaAI
const token = await solveCaptcha(captcha);
console.log(`Token: ${token.substring(0, 50)}...`);
// Inject token
await injectToken(page, captcha.type, token);
// Submit form
await page.click('button[type="submit"]');
await page.waitForNavigation({ waitUntil: 'networkidle' });
console.log('Form submitted');
await browser.close();
}
async function solveCaptcha(captcha) {
const params = {
key: API_KEY,
pageurl: captcha.pageurl,
json: '1',
};
if (captcha.type === 'recaptcha_v2') {
params.method = 'userrecaptcha';
params.googlekey = captcha.sitekey;
} else {
params.method = 'turnstile';
params.sitekey = captcha.sitekey;
}
const submit = await axios.get(
'https://ocr.captchaai.com/in.php', { params }
);
if (submit.data.status !== 1) throw new Error(submit.data.request);
const taskId = submit.data.request;
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 5000));
const poll = await axios.get('https://ocr.captchaai.com/res.php', {
params: { key: API_KEY, action: 'get', id: taskId, json: '1' },
});
if (poll.data.status === 1) return poll.data.request;
if (poll.data.request !== 'CAPCHA_NOT_READY') {
throw new Error(poll.data.request);
}
}
throw new Error('Timeout');
}
async function injectToken(page, type, token) {
if (type === 'recaptcha_v2') {
await page.evaluate((t) => {
document.getElementById('g-recaptcha-response').value = t;
try {
const clients = ___grecaptcha_cfg.clients;
Object.keys(clients).forEach((k) => {
Object.keys(clients[k]).forEach((j) => {
if (clients[k][j]?.callback) clients[k][j].callback(t);
});
});
} catch (e) {}
}, token);
} else {
await page.evaluate((t) => {
const input = document.querySelector('[name="cf-turnstile-response"]');
if (input) input.value = t;
const cb = document.querySelector('.cf-turnstile')
?.getAttribute('data-callback');
if (cb && typeof window[cb] === 'function') window[cb](t);
}, token);
}
}
main().catch(console.error);
Совет: не полагайтесь только на
waitForLoadState('networkidle')— на нестабильном мобильном соединении, характерном для распределённых команд в СНГ, фреймворк PWA может продолжать дорисовывать виджет CAPTCHA ещё несколько секунд после «networkidle». MutationObserver с таймаутом 15 секунд подстраховывает именно этот случай.
Шаг 2. Обойдите кеш Service Worker
Service Worker может кэшировать скрипты reCAPTCHA и Turnstile, из-за чего в DOM оказывается устаревший виджет со старым sitekey. Принудительно сбрасывайте кеш для этих ресурсов, если видите такое расхождение:
// Refresh Service Worker cache for CAPTCHA scripts
await page.route('**/recaptcha/**', (route) => {
route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});
await page.route('**/turnstile/**', (route) => {
route.continue({ headers: { ...route.request().headers(), 'Cache-Control': 'no-cache' } });
});
Как распознать симптом: sitekey из DOM не совпадает с актуальным sitekey сайта, или виджет тянет старую версию скрипта reCAPTCHA/Turnstile. Оба признака указывают на закэшированный Service Worker, а не на баг в детекторе.
Шаг 3. Отслеживайте навигацию на стороне клиента
PWA используют клиентский роутинг — переход на маршрут, защищённый CAPTCHA, не перезагружает страницу, а значит и обычный обработчик загрузки страницы не сработает. Слушайте изменения истории браузера напрямую:
// Monitor PWA route changes for new CAPTCHAs
await page.evaluate(() => {
const originalPushState = history.pushState;
history.pushState = function() {
originalPushState.apply(this, arguments);
window.dispatchEvent(new Event('pwa-route-change'));
};
});
page.on('console', async (msg) => {
// React to route changes if needed
});
// Or wait for specific route
await page.waitForURL('**/checkout', { waitUntil: 'networkidle' });
// Then detect CAPTCHA on the new route
const captcha = await detectCaptchaInPWA(page);
Вызывайте
detectCaptchaInPWAзаново после каждого перехода на новый маршрут: функция создаёт новый Promise и нового наблюдателя при каждом вызове, а результат предыдущего вызова относится к уже неактуальной странице.
Шаг 4. Альтернатива на Python и Selenium
Если стек автоматизации построен на Selenium, а не на Playwright, логика получения sitekey и решения CAPTCHA остаётся той же — меняется только способ ожидания элемента:
# pwa_captcha_selenium.py
import time
import requests
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
API_KEY = "YOUR_API_KEY"
driver = webdriver.Chrome()
driver.get("https://example-pwa.com/login")
# Wait for PWA to render CAPTCHA
wait = WebDriverWait(driver, 20)
captcha_el = wait.until(
EC.presence_of_element_located((By.CSS_SELECTOR, ".g-recaptcha, .cf-turnstile"))
)
sitekey = captcha_el.get_attribute("data-sitekey")
pageurl = driver.current_url
is_turnstile = "cf-turnstile" in captcha_el.get_attribute("class")
# Submit to CaptchaAI
params = {"key": API_KEY, "pageurl": pageurl, "json": "1"}
if is_turnstile:
params["method"] = "turnstile"
params["sitekey"] = sitekey
else:
params["method"] = "userrecaptcha"
params["googlekey"] = sitekey
resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
task_id = resp.json()["request"]
# Poll
for _ in range(30):
time.sleep(5)
poll = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get", "id": task_id, "json": "1",
})
if poll.json().get("status") == 1:
token = poll.json()["request"]
break
else:
raise TimeoutError("CAPTCHA not solved")
# Inject token
driver.execute_script(f"""
document.getElementById('g-recaptcha-response').value = '{token}';
""")
driver.find_element(By.CSS_SELECTOR, 'button[type="submit"]').click()
print("Form submitted")
driver.quit()
Прежде чем гонять детектор в CI, пройдитесь по короткому чек-листу:
- Убедитесь, что
waitForLoadState('networkidle')(Playwright) илиWebDriverWait(Selenium) действительно дожидается отрисовки приложения, а не отдаёт управление раньше времени. - Проверьте, что таймаут наблюдателя больше, чем реальное время рендеринга CAPTCHA на самом медленном тестовом устройстве.
- Прогоните сценарий с холодным и тёплым кешем Service Worker — расхождение sitekey чаще всего всплывает именно на втором прогоне.
Поиск неисправностей
| Проблема | Причина | Решение |
|---|---|---|
| Элемент CAPTCHA так и не появляется | PWA ещё не отрисовал нужный маршрут | Используйте waitForSelector с увеличенным таймаутом и убедитесь, что клиентская маршрутизация завершилась |
| После перехода sitekey оказался устаревшим | Service Worker отдал закэшированный HTML | Сбросьте кеш для CAPTCHA-ресурсов через заголовки Cache-Control (шаг 2) |
| Колбэк для вставки токена не находится | Фреймворк PWA управляет состоянием иначе, чем «голый» DOM | Проверьте состояние React/Vue/Angular и обновляйте его напрямую, а не только атрибут DOM |
| Форма отправляется без токена | Обработчик формы в SPA читает значение из состояния компонента, а не из DOM | Обновляйте также state фреймворка (например, React ref или reactive-свойство Vue) |
Если ни одна причина из таблицы не подошла, включите
page.on('console')иpage.on('pageerror')в Playwright (илиdriver.get_log('browser')в Selenium) — часто фреймворк PWA сам логирует ошибку рендеринга виджета в консоль браузера ещё до того, как это станет заметно в DOM.
Вопросы по обработке CAPTCHA в PWA
CAPTCHA в PWA — это другой тип, чем на обычном сайте?
Нет, виджет тот же: reCAPTCHA v2, Cloudflare Turnstile и другие поддерживаемые типы рендерятся одинаково. Меняется только момент появления в DOM — он сдвинут во времени из-за клиентского рендеринга.
Можно ли решить CAPTCHA в PWA без headless-браузера, только backend-запросами?
Не полностью. Sitekey нужно извлечь из реального DOM, а токен — вставить туда же, куда его ожидает фреймворк. Backend может обратиться к CaptchaAI API и получить готовый токен, но саму инъекцию логичнее делать через Playwright или Selenium — иначе придётся вручную эмулировать поведение React или Vue.
Что делать, если MutationObserver не успевает поймать CAPTCHA на медленном мобильном соединении?
Увеличьте таймаут наблюдателя (в примере — 15 секунд) и добавьте повторную проверку document.querySelector после waitForLoadState('networkidle'): на нестабильной сети фреймворк может отрисовать CAPTCHA в несколько раз дольше обычного.
Токен CaptchaAI для PWA отличается от токена для обычной страницы?
Нет. Токен зависит только от sitekey и pageurl, а не от способа рендеринга страницы — сам API CaptchaAI не различает PWA и классический сайт.
Форма не находится после клиентской навигации — в чём дело?
Скорее всего, обработчик submit держит ссылку на старый DOM-элемент формы. После waitForURL перечитывайте актуальный DOM заново — роутер SPA мог пересоздать форму при переходе на новый маршрут.
Что за что отвечает в этом стеке
| Инструмент | Роль в детекторе PWA-CAPTCHA |
|---|---|
| MutationObserver | Ловит момент, когда фреймворк добавляет виджет CAPTCHA в DOM |
Route-перехват (page.route) |
Сбрасывает устаревший кеш Service Worker для скриптов CAPTCHA |
history.pushState-хук |
Сигнализирует о клиентской навигации без перезагрузки страницы |
CaptchaAI API (in.php / res.php) |
Решает reCAPTCHA v2 и Turnstile по извлечённому sitekey |
Следующие шаги
- CaptchaAI Quickstart: ваше первое решение CAPTCHA за 5 минут
- Как решить reCAPTCHA v2 через API: пошаговое руководство
- Как решить Cloudflare Turnstile через API
- Как решить GeeTest v3 с помощью API
Проверки недействительности токена
- Инвалидируйте закэшированные токены CAPTCHA, когда меняется маршрут, состояние аутентификации или происходит обновление Service Worker — вместе с ними меняется и контекст страницы.
- Сохраняйте только минимальное состояние, необходимое для повторного подключения решённого токена к активному представлению.
- Проверяйте актуальность токена непосредственно перед отправкой, а не предполагайте, что фоновое решение остаётся действительным.