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 Native —onMessageне привязан к<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: собирайте только то, что вправе обрабатывать.