Tutorials

Обработка CAPTCHA в прогрессивных веб-приложениях (PWA)

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, пройдитесь по короткому чек-листу:

  1. Убедитесь, что waitForLoadState('networkidle') (Playwright) или WebDriverWait (Selenium) действительно дожидается отрисовки приложения, а не отдаёт управление раньше времени.
  2. Проверьте, что таймаут наблюдателя больше, чем реальное время рендеринга CAPTCHA на самом медленном тестовом устройстве.
  3. Прогоните сценарий с холодным и тёплым кешем 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

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

Проверки недействительности токена

  • Инвалидируйте закэшированные токены CAPTCHA, когда меняется маршрут, состояние аутентификации или происходит обновление Service Worker — вместе с ними меняется и контекст страницы.
  • Сохраняйте только минимальное состояние, необходимое для повторного подключения решённого токена к активному представлению.
  • Проверяйте актуальность токена непосредственно перед отправкой, а не предполагайте, что фоновое решение остаётся действительным.
Комментарии для этой статьи отключены.