Integrations

Решение CAPTCHA в React Native WebViews с помощью CaptchaAI

CAPTCHA внутри react-native-webview — типичная головная боль мобильной автоматизации: флажок reCAPTCHA v2 или виджет Cloudflare Turnstile блокирует отправку формы, а расширений для решения капчи в мобильном браузере, как в десктопном Chrome, попросту нет. Рабочая схема одна: решить CAPTCHA через API и внедрить готовый токен обратно в DOM через JavaScript-мост между WebView и React Native, а весь обмен с CaptchaAI держать на сервере, чтобы API-ключ не попадал в клиентский бинарник.

Как устроен поток решения

Прежде чем писать код, стоит понять, из каких частей собирается решение и кто что видит:

  • WebView в React Native — находит CAPTCHA на странице, извлекает sitekey, внедряет решённый токен обратно в DOM
  • Серверный API на Node.js — принимает sitekey и pageurl от приложения, обращается к CaptchaAI, опрашивает результат и возвращает токен
  • API CaptchaAI — решает саму CAPTCHA и отдаёт токен по запросу

WebView передаёт данные в код React Native через window.ReactNativeWebView.postMessage(). Обмен с CaptchaAI целиком остаётся на бэкенде — так секреты не попадают в приложение и их нельзя вытащить реверс-инжинирингом установленного APK или IPA.

Зачем это нужно: сценарий из практики

Стандартная задача выглядит так: мобильное приложение на React Native открывает стороннюю веб-форму внутри WebView — например, форму регистрации или бронирования, которую отдаёт партнёрский бэкенд и над разметкой которой у вас нет контроля. На форме стоит флажок reCAPTCHA v2, без прохождения которого кнопка отправки неактивна.

Задача сводится к четырём шагам: обнаружить виджет CAPTCHA сразу после того, как WebView закончил загрузку, извлечь sitekey из DOM, решить его через API CaptchaAI из серверного сервиса и внедрить токен обратно в WebView перед отправкой формы.

Стек для примеров ниже: React Native 0.72+, react-native-webview 13+, бэкенд на Node.js, API CaptchaAI.

Шаг 1. Обнаружение CAPTCHA и извлечение sitekey в WebView

Опция injectedJavaScript сканирует страницу сразу после загрузки и ищет узнаваемые DOM-элементы CAPTCHA.

Скрипт покрывает два исхода: CAPTCHA найдена (тогда наружу уходит sitekey и адрес страницы) или её нет — оба случая нужно обработать на стороне React Native, иначе handleMessage останется без пары:

// CaptchaDetector.js — React Native Component
import React, { useRef, useState } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { WebView } from 'react-native-webview';

const CAPTCHA_DETECTION_SCRIPT = `
  (function() {
    // Detect reCAPTCHA v2
    const recaptchaDiv = document.querySelector('.g-recaptcha');
    if (recaptchaDiv) {
      const sitekey = recaptchaDiv.getAttribute('data-sitekey');
      window.ReactNativeWebView.postMessage(JSON.stringify({
        type: 'captcha_detected',
        captchaType: 'recaptcha_v2',
        sitekey: sitekey,
        pageurl: window.location.href
      }));
      return;
    }

    // Detect Cloudflare Turnstile
    const turnstileDiv = document.querySelector('.cf-turnstile');
    if (turnstileDiv) {
      const sitekey = turnstileDiv.getAttribute('data-sitekey');
      window.ReactNativeWebView.postMessage(JSON.stringify({
        type: 'captcha_detected',
        captchaType: 'turnstile',
        sitekey: sitekey,
        pageurl: window.location.href
      }));
      return;
    }

    window.ReactNativeWebView.postMessage(JSON.stringify({
      type: 'no_captcha'
    }));
  })();
  true;
`;

export default function CaptchaWebView({ url }) {
  const webviewRef = useRef(null);
  const [solving, setSolving] = useState(false);

  const handleMessage = async (event) => {
    const data = JSON.parse(event.nativeEvent.data);

    if (data.type === 'captcha_detected') {
      setSolving(true);
      try {
        const token = await solveCaptchaViaBackend(
          data.captchaType,
          data.sitekey,
          data.pageurl
        );
        injectToken(data.captchaType, token);
      } catch (err) {
        console.error('CAPTCHA solve failed:', err.message);
      } finally {
        setSolving(false);
      }
    }
  };

  const solveCaptchaViaBackend = async (captchaType, sitekey, pageurl) => {
    const response = await fetch('https://your-backend.com/api/solve-captcha', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ captchaType, sitekey, pageurl }),
    });
    const result = await response.json();
    if (!result.token) throw new Error(result.error || 'No token returned');
    return result.token;
  };

  const injectToken = (captchaType, token) => {
    let script;
    if (captchaType === 'recaptcha_v2') {
      script = `
        document.getElementById('g-recaptcha-response').value = '${token}';
        if (typeof ___grecaptcha_cfg !== 'undefined') {
          Object.keys(___grecaptcha_cfg.clients).forEach(key => {
            const client = ___grecaptcha_cfg.clients[key];
            Object.keys(client).forEach(k => {
              const item = client[k];
              if (item && item.callback) {
                item.callback('${token}');
              }
            });
          });
        }
        true;
      `;
    } else if (captchaType === 'turnstile') {
      script = `
        const input = document.querySelector('[name="cf-turnstile-response"]');
        if (input) input.value = '${token}';
        const callback = document.querySelector('.cf-turnstile')
          ?.getAttribute('data-callback');
        if (callback && typeof window[callback] === 'function') {
          window[callback]('${token}');
        }
        true;
      `;
    }
    webviewRef.current?.injectJavaScript(script);
  };

  return (
    <View style={{ flex: 1 }}>
      {solving && <ActivityIndicator size="large" />}
      <WebView
        ref={webviewRef}
        source={{ uri: url }}
        injectedJavaScript={CAPTCHA_DETECTION_SCRIPT}
        onMessage={handleMessage}
        javaScriptEnabled={true}
      />
    </View>
  );
}

Шаг 2. Серверный солвер на Node.js

API-ключ CaptchaAI хранится только на сервере. Бэкенд принимает sitekey и URL страницы, отправляет задачу в CaptchaAI, опрашивает результат и возвращает токен обратно в приложение:

// server.js — Express backend
const express = require('express');
const axios = require('axios');
const app = express();
app.use(express.json());

const API_KEY = process.env.CAPTCHAAI_API_KEY || 'YOUR_API_KEY';

app.post('/api/solve-captcha', async (req, res) => {
  const { captchaType, sitekey, pageurl } = req.body;

  try {
    // Step 1: Submit task to CaptchaAI
    const submitParams = {
      key: API_KEY,
      pageurl: pageurl,
      json: '1',
    };

    if (captchaType === 'recaptcha_v2') {
      submitParams.method = 'userrecaptcha';
      submitParams.googlekey = sitekey;
    } else if (captchaType === 'turnstile') {
      submitParams.method = 'turnstile';
      submitParams.sitekey = sitekey;
    }

    const submitResponse = await axios.get(
      'https://ocr.captchaai.com/in.php',
      { params: submitParams }
    );

    if (submitResponse.data.status !== 1) {
      return res.status(400).json({ error: submitResponse.data.request });
    }

    const taskId = submitResponse.data.request;

    // Step 2: Poll for result
    const token = await pollForResult(taskId);
    res.json({ token });
  } catch (error) {
    console.error('Solve error:', error.message);
    res.status(500).json({ error: 'Failed to solve CAPTCHA' });
  }
});

async function pollForResult(taskId, maxAttempts = 30) {
  for (let i = 0; i < maxAttempts; i++) {
    await new Promise((r) => setTimeout(r, 5000));

    const response = await axios.get('https://ocr.captchaai.com/res.php', {
      params: {
        key: API_KEY,
        action: 'get',
        id: taskId,
        json: '1',
      },
    });

    if (response.data.status === 1) {
      return response.data.request;
    }

    if (
      response.data.request !== 'CAPCHA_NOT_READY' &&
      response.data.status === 0
    ) {
      throw new Error(response.data.request);
    }
  }
  throw new Error('Polling timeout — CAPTCHA not solved in time');
}

app.listen(3000, () => console.log('Solver backend running on port 3000'));

Никогда не зашивайте CAPTCHAAI_API_KEY прямо в код бэкенда для продакшена — держите его в переменных окружения или секрет-хранилище, а не в репозитории рядом с приложением.

Шаг 3. Токен устаревает — обновите его перед отправкой

У токенов CAPTCHA есть срок жизни: токен reCAPTCHA v2 действует около 120 секунд, токен Cloudflare Turnstile — около 300 секунд.

Если пользователь заполняет форму дольше и не спешит её отправлять, запросите решение заново прямо перед сабмитом — логику ниже добавьте в тот же компонент, что и код из Шага 1, чтобы переиспользовать состояние:

// Add to CaptchaWebView component
const [tokenTimestamp, setTokenTimestamp] = useState(null);
const TOKEN_TTL_MS = 110000; // 110 seconds for reCAPTCHA v2

const handleFormSubmit = async (captchaType, sitekey, pageurl) => {
  const now = Date.now();
  if (!tokenTimestamp || now - tokenTimestamp > TOKEN_TTL_MS) {
    const freshToken = await solveCaptchaViaBackend(
      captchaType, sitekey, pageurl
    );
    injectToken(captchaType, freshToken);
    setTokenTimestamp(Date.now());
  }

  webviewRef.current?.injectJavaScript(`
    document.querySelector('form').submit();
    true;
  `);
};

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

Большинство сбоев в этой связке сводится к пяти повторяющимся причинам:

  • postMessage не доходит до React NativeonMessage не привязан к <WebView> или инъекция упала с ошибкой. Проверьте, что onMessage реально передан в компонент, и оберните инъекцию в try/catch.
  • ERROR_BAD_TOKEN_OR_PAGEURL — sitekey не соответствует URL страницы. Извлекайте sitekey из src реального iframe, а не из родительской страницы.
  • Токен внедрён, но callback не срабатывает — reCAPTCHA-callback не найден в ___grecaptcha_cfg. Переберите все объекты clients и проверьте вложенные свойства на наличие функции callback.
  • CAPCHA_NOT_READY держится бесконечно — медленное решение или неверные параметры запроса. Увеличьте таймаут опроса и перепроверьте sitekey и pageurl.
  • WebView показывает пустую CAPTCHA — JavaScript отключён или заблокирован CSP. Установите javaScriptEnabled={true} и убедитесь, что политика безопасности контента не блокирует скрипт.

Вопросы, которые часто возникают на практике

Можно ли вызывать CaptchaAI прямо из React Native, без бэкенда?

Технически да, но API-ключ окажется внутри бинарника приложения, откуда его можно извлечь из APK или IPA. Все запросы к CaptchaAI должны идти через ваш собственный сервер — это не рекомендация, а обязательное условие для безопасности ключа.

Работает ли эта схема в Expo или только в bare React Native?

Работает и в Expo — начиная с SDK 49 react-native-webview доступен через expo-dev-client. Логика инъекции скрипта и обмена сообщениями между WebView и React Native не меняется.

Чем отличается поведение injectedJavaScript на Android и iOS?

На Android скрипт из injectedJavaScript обычно выполняется сразу после загрузки страницы, а на iOS — иногда чуть позже: WKWebView может ещё не успеть полностью отрисовать DOM. Если детекция CAPTCHA срабатывает нестабильно на iOS, попробуйте по порядку:

  • добавить задержку 300–500 мс перед сканированием DOM
  • повторно сканировать при первой неудаче
  • перейти со onLoadEnd на onLoadProgress с порогом около 90%

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

Храните время последнего решения и сравнивайте его с TTL токена перед сабмитом (см. Шаг 3). Если срок истёк — запросите новый токен через бэкенд и только потом вызывайте отправку формы; иначе сервер партнёрской формы вернёт ошибку истёкшего токена.

Как логировать sitekey и pageurl на бэкенде, не собирая лишние данные о пользователе?

Логируйте только то, что нужно для отладки решения CAPTCHA, а не всё подряд:

  • нужно: sitekey, домен страницы, тип CAPTCHA, ID задачи
  • не нужно: IP-адрес пользователя, идентификаторы устройства, содержимое формы

Для аудитории в России это стоит держать в голове рядом со 152-ФЗ «О персональных данных», а для остальной части СНГ и диаспоры — с общей практикой due diligence по GDPR: собирайте только то, что вправе обрабатывать.

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

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