Tích Hợp

Giải CAPTCHA trong React Native WebViews bằng CaptchaAI

Giải CAPTCHA trong react-native-webview cần bốn bước: phát hiện tiện ích khi WebView tải xong, trích sitekey từ DOM, gửi sang backend để gọi CaptchaAI, rồi chèn token đã giải trở lại trang. reCAPTCHA v2 dạng hộp kiểm, Cloudflare Turnstile hoặc trang thử thách là rào cản phổ biến nhất khi app React Native nhúng biểu mẫu web của bên thứ ba — không xử lý được thì luồng đăng nhập hay thanh toán trong app sẽ đứng yên tại bước CAPTCHA. CaptchaAI giải các CAPTCHA này theo lập trình — dưới đây là code, không phải lý thuyết dài dòng.

Trước khi bắt đầu, bạn cần:

  • React Native 0.72 trở lên
  • react-native-webview 13 trở lên
  • Một backend Node.js để giữ API key
  • API key CaptchaAI

Kiến trúc xử lý: 3 lớp từ WebView đến CaptchaAI

Luồng xử lý chia thành ba lớp:

Lớp Trách nhiệm
React Native WebView Phát hiện CAPTCHA, trích sitekey, chèn token đã giải
Backend API (Node.js) Nhận sitekey + pageurl, gọi CaptchaAI, trả token
API CaptchaAI Giải CAPTCHA và trả về token

WebView giao tiếp với mã React Native của bạn qua window.ReactNativeWebView.postMessage(), còn backend xử lý phần gọi CaptchaAI để giữ API key ngoài tầm với của client.

Luôn gọi CaptchaAI từ backend, không bao giờ trực tiếp từ mã React Native — API key lộ trong file nhị phân của app là lỗi bảo mật phổ biến nhất ở bước này.

Bài toán: CAPTCHA chặn WebView trong app React Native

Nhiều đội dev outsourcing tại Việt Nam — ví dụ nhóm làm app cho khách hàng ở một công ty như FPT Software, hoặc một startup tại TP.HCM — gặp đúng tình huống này: app React Native nhúng một biểu mẫu web của đối tác bên trong WebView, biểu mẫu có hộp kiểm reCAPTCHA v2 phải hoàn thành trước khi gửi.

Mục tiêu cần đạt:

  • Phát hiện tiện ích CAPTCHA ngay khi WebView tải xong
  • Trích sitekey từ DOM
  • Giải CAPTCHA qua API của CaptchaAI từ một dịch vụ backend
  • Chèn token trở lại WebView và gửi biểu mẫu

Bước 1: Phát hiện CAPTCHA và lấy sitekey trong WebView

Dùng prop injectedJavaScript để quét các phần tử CAPTCHA ngay sau khi trang tải xong:

// 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>
  );
}

injectedJavaScript chỉ chạy một lần ngay khi WebView tải xong. Nếu trang render CAPTCHA muộn hơn bằng JavaScript (lazy load), thêm một MutationObserver trong script để bắt widget xuất hiện sau đó thay vì chỉ quét một lần.

Bước 2: Xây dựng backend gọi CaptchaAI (Node.js)

Giữ API key CaptchaAI ở phía server. Backend nhận sitekey và pageurl, gửi task tới CaptchaAI, polling kết quả rồi trả token về:

// 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'));

Bước 3: Xử lý token hết hạn trước khi submit form

Token CAPTCHA có thời hạn — token reCAPTCHA v2 sống khoảng 120 giây, token Turnstile khoảng 300 giây. Nếu người dùng trì hoãn việc gửi biểu mẫu, hãy giải lại trước khi submit:

// 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;
  `);
};

Câu hỏi thường gặp

CaptchaAI hỗ trợ những loại CAPTCHA nào khi chạy trong react-native-webview?

Chủ yếu là reCAPTCHA v2 (hộp kiểm) và Cloudflare Turnstile — hai loại phổ biến nhất trong biểu mẫu web nhúng qua WebView. CaptchaAI cũng giải GeeTest v3 và Cloudflare Challenge nếu WebView của bạn gặp các loại này; chỉ cần đổi tham số method khi gửi task tới in.php.

Giải CAPTCHA cho app React Native tốn bao nhiêu chi phí mỗi tháng?

CaptchaAI tính phí theo thread (luồng giải đồng thời), không theo từng lần giải — mỗi thread giải không giới hạn số lần trong tháng:

Gói Giá/tháng Số thread
BASIC $15 5
STANDARD $30 15

Phần lớn app di động ở giai đoạn thử nghiệm chỉ cần BASIC; khi traffic tăng, nâng lên STANDARD hoặc cao hơn mà không phải đổi code.

Có nên gọi API CaptchaAI trực tiếp từ app di động, không qua backend?

Không nên. Gọi trực tiếp từ client sẽ làm lộ API key trong file nhị phân của app khi ai đó decompile ứng dụng. Luôn định tuyến qua một backend riêng như ví dụ ở Bước 2 để giữ API key an toàn.

Trang có cả reCAPTCHA v2 và Cloudflare Turnstile thì xử lý ra sao?

Tập lệnh phát hiện ở Bước 1 đã kiểm tra cả hai loại. Nếu trang có nhiều CAPTCHA cùng lúc, mở rộng tập lệnh để thu thập toàn bộ sitekey rồi giải tuần tự từng cái trước khi submit biểu mẫu.

WebView không nhận được postMessage từ script chèn — debug thế nào?

Kiểm tra ba điểm: prop onMessage đã gán cho component WebView chưa, javaScriptEnabled có đang bật không, và script chèn có lỗi cú pháp khiến postMessage không bao giờ chạy tới. Bọc toàn bộ script trong try/catch và log ra console để xác định bước nào bị dừng.

Các lỗi thường gặp khi tích hợp

Vấn đề Nguyên nhân Cách xử lý
postMessage không nhận được onMessage của WebView chưa gán hoặc script lỗi Kiểm tra onMessage đã bind đúng chưa; bọc script chèn trong try/catch
ERROR_BAD_TOKEN_OR_PAGEURL sitekey không khớp với pageurl thực tế Trích sitekey từ src của iframe thật, không phải trang cha
Chèn token xong nhưng callback không kích hoạt Không tìm thấy callback reCAPTCHA trong ___grecaptcha_cfg Duyệt qua toàn bộ client object, kiểm tra các thuộc tính lồng nhau để tìm hàm callback
CAPCHA_NOT_READY kéo dài Giải chậm hoặc tham số sai Tăng thời gian polling; xác minh lại sitekey và pageurl
WebView hiển thị CAPTCHA trống JavaScript bị tắt hoặc nội dung bị chặn Đặt javaScriptEnabled={true} và kiểm tra content security policy không chặn

Bật Flipper hoặc remote JS debugging cho WebView để xem log console từ script chèn trực tiếp — phần lớn lỗi trong bảng trên lộ rõ ngay khi bật debug.

Bước tiếp theo

Triển khai bộ giải CAPTCHA cho app React Native của bạn ngay hôm nay — lấy API key CaptchaAI và tích hợp backend theo các bước ở trên.

Đọc thêm

Os comentários estão desativados para este artigo.