Tích Hợp

Xử lý CAPTCHA trong Flutter WebView với CaptchaAI

App Flutter nhúng WebView hiển thị trang thanh toán của đối tác gần như chắc chắn gặp CAPTCHA — và vì WebView không phải trình duyệt thật, người dùng không thể "giải hộ". Cách xử lý: WebView báo cho code Dart, gửi sitekey về backend cho CaptchaAI giải, rồi bơm token ngược lại qua JavaScript.

Bài này đi thẳng vào bốn bước của luồng đó: phát hiện CAPTCHA bằng JavaScript channel, gửi request tới backend, giải bằng CaptchaAI, inject token — cho cả reCAPTCHA v2 và Cloudflare Turnstile, dùng webview_flutter lẫn flutter_inappwebview.

Ví dụ thực tế: WebView thanh toán bị chặn bởi reCAPTCHA v2

Quen thuộc với các đội Flutter làm outsourcing cho khách nước ngoài (FPT Software, VNG, studio nhỏ ở TP.HCM, Hà Nội): app nhúng cổng thanh toán đối tác trong WebView, cổng bật reCAPTCHA v2 trước khi submit form thẻ. Người dùng bấm "Thanh toán", màn hình đứng im vì không ai giải CAPTCHA đó.

Cần bốn việc theo thứ tự: phát hiện widget CAPTCHA sau khi WebView load xong, trích xuất sitekey qua JavaScript channel, gửi sitekey/pageurl cho CaptchaAI giải ở backend, rồi inject token và kích hoạt callback.

Môi trường:

Thành phần Phiên bản
Flutter 3.16+
WebView package webview_flutter 4.x
Backend Dart hoặc Node.js/Python
Solver CaptchaAI API

Bước 1: Thiết lập WebView với JavaScript channel

Dùng webview_flutter kèm một JavaScript channel để nhận thông báo mỗi khi trang đang tải có CAPTCHA:

// captcha_webview.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:http/http.dart' as http;

class CaptchaWebView extends StatefulWidget {
  final String url;
  const CaptchaWebView({super.key, required this.url});

  @override
  State<CaptchaWebView> createState() => _CaptchaWebViewState();
}

class _CaptchaWebViewState extends State<CaptchaWebView> {
  late final WebViewController _controller;
  bool _solving = false;

  @override
  void initState() {
    super.initState();
    _controller = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel(
        'CaptchaChannel',
        onMessageReceived: _onCaptchaMessage,
      )
      ..setNavigationDelegate(
        NavigationDelegate(
          onPageFinished: (_) => _detectCaptcha(),
        ),
      )
      ..loadRequest(Uri.parse(widget.url));
  }

  Future<void> _detectCaptcha() async {
    await _controller.runJavaScript('''
      (function() {
        var recaptcha = document.querySelector('.g-recaptcha');
        if (recaptcha) {
          CaptchaChannel.postMessage(JSON.stringify({
            type: 'captcha_detected',
            captchaType: 'recaptcha_v2',
            sitekey: recaptcha.getAttribute('data-sitekey'),
            pageurl: window.location.href
          }));
          return;
        }

        var turnstile = document.querySelector('.cf-turnstile');
        if (turnstile) {
          CaptchaChannel.postMessage(JSON.stringify({
            type: 'captcha_detected',
            captchaType: 'turnstile',
            sitekey: turnstile.getAttribute('data-sitekey'),
            pageurl: window.location.href
          }));
          return;
        }

        CaptchaChannel.postMessage(JSON.stringify({type: 'no_captcha'}));
      })();
    ''');
  }

  Future<void> _onCaptchaMessage(JavaScriptMessage message) async {
    final data = jsonDecode(message.message);
    if (data['type'] != 'captcha_detected') return;

    setState(() => _solving = true);

    try {
      final token = await _solveCaptcha(
        data['captchaType'],
        data['sitekey'],
        data['pageurl'],
      );
      await _injectToken(data['captchaType'], token);
    } catch (e) {
      debugPrint('CAPTCHA solve failed: $e');
    } finally {
      setState(() => _solving = false);
    }
  }

  Future<String> _solveCaptcha(
    String captchaType, String sitekey, String pageurl,
  ) async {
    final response = await http.post(
      Uri.parse('https://your-backend.com/api/solve-captcha'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({
        'captchaType': captchaType,
        'sitekey': sitekey,
        'pageurl': pageurl,
      }),
    );
    final result = jsonDecode(response.body);
    if (result['token'] == null) {
      throw Exception(result['error'] ?? 'No token returned');
    }
    return result['token'];
  }

  Future<void> _injectToken(String captchaType, String token) async {
    if (captchaType == 'recaptcha_v2') {
      await _controller.runJavaScript('''
        document.getElementById('g-recaptcha-response').value = '$token';
        if (typeof ___grecaptcha_cfg !== 'undefined') {
          Object.keys(___grecaptcha_cfg.clients).forEach(function(key) {
            var client = ___grecaptcha_cfg.clients[key];
            Object.keys(client).forEach(function(k) {
              if (client[k] && client[k].callback) {
                client[k].callback('$token');
              }
            });
          });
        }
      ''');
    } else if (captchaType == 'turnstile') {
      await _controller.runJavaScript('''
        var input = document.querySelector('[name="cf-turnstile-response"]');
        if (input) input.value = '$token';
        var cb = document.querySelector('.cf-turnstile')
          ?.getAttribute('data-callback');
        if (cb && typeof window[cb] === 'function') window[cb]('$token');
      ''');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        WebViewWidget(controller: _controller),
        if (_solving)
          const Center(child: CircularProgressIndicator()),
      ],
    );
  }
}

Ghi chú: Theme CAPTCHA tùy chỉnh có thể không dùng class .g-recaptcha/.cf-turnstile mặc định — kiểm tra DOM thực tế và đổi selector cho khớp.

Bước 2: Backend giải CAPTCHA bằng Python

Một backend nhỏ đứng giữa app và CaptchaAI, giữ khóa API ở server và làm hai việc: gửi task, rồi polling kết quả.

Lưu ý bảo mật: đừng gọi CaptchaAI trực tiếp từ Dart — khóa API sẽ nằm trong file nhị phân của app, ai decompile cũng lấy được.

Ví dụ Flask:

# solver_api.py — Flask backend
import os
import time
import requests
from flask import Flask, request, jsonify

app = Flask(__name__)
API_KEY = os.environ.get("CAPTCHAAI_API_KEY", "YOUR_API_KEY")

@app.route("/api/solve-captcha", methods=["POST"])
def solve_captcha():
    data = request.json
    captcha_type = data.get("captchaType")
    sitekey = data.get("sitekey")
    pageurl = data.get("pageurl")

    # Submit task
    params = {"key": API_KEY, "pageurl": pageurl, "json": "1"}

    if captcha_type == "recaptcha_v2":
        params["method"] = "userrecaptcha"
        params["googlekey"] = sitekey
    elif captcha_type == "turnstile":
        params["method"] = "turnstile"
        params["sitekey"] = sitekey
    else:
        return jsonify({"error": f"Unsupported type: {captcha_type}"}), 400

    resp = requests.get("https://ocr.captchaai.com/in.php", params=params)
    result = resp.json()

    if result.get("status") != 1:
        return jsonify({"error": result.get("request", "Submit failed")}), 400

    task_id = result["request"]

    # Poll for result
    for _ in range(30):
        time.sleep(5)
        poll_resp = requests.get(
            "https://ocr.captchaai.com/res.php",
            params={
                "key": API_KEY,
                "action": "get",
                "id": task_id,
                "json": "1",
            },
        )
        poll_result = poll_resp.json()

        if poll_result.get("status") == 1:
            return jsonify({"token": poll_result["request"]})

        if poll_result.get("request") != "CAPCHA_NOT_READY":
            return jsonify({"error": poll_result["request"]}), 400

    return jsonify({"error": "Timeout — CAPTCHA not solved"}), 408

if __name__ == "__main__":
    app.run(port=3000)

Endpoint trả về JSON {"token": "..."} cho code Dart ở Bước 1 — không cần đổi gì phía client.

Bước 3: Dùng flutter_inappwebview khi cần kiểm soát sâu hơn

Cần chặn network request, quản lý cookie, hoặc đọc kết quả JavaScript trực tiếp thay vì đi vòng qua channel? flutter_inappwebview linh hoạt hơn:

// Using flutter_inappwebview for advanced CAPTCHA handling
import 'package:flutter_inappwebview/flutter_inappwebview.dart';

InAppWebView(
  initialUrlRequest: URLRequest(url: WebUri(widget.url)),
  initialSettings: InAppWebViewSettings(
    javaScriptEnabled: true,
    userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36',
  ),
  onLoadStop: (controller, url) async {
    // Evaluate JavaScript and get result directly
    final result = await controller.evaluateJavascript(source: '''
      (function() {
        var el = document.querySelector('.g-recaptcha');
        if (el) return JSON.stringify({
          sitekey: el.getAttribute('data-sitekey'),
          pageurl: window.location.href
        });
        return null;
      })();
    ''');

    if (result != null) {
      final data = jsonDecode(result);
      // Solve and inject token
      final token = await _solveCaptcha(
        'recaptcha_v2', data['sitekey'], data['pageurl'],
      );
      await controller.evaluateJavascript(source: '''
        document.getElementById('g-recaptcha-response').value = '$token';
      ''');
    }
  },
)

Khác biệt chính so với Bước 1: evaluateJavascript trả kết quả ngay trong await, không cần đi vòng qua postMessage của JavaScript channel.

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

CaptchaAI có hỗ trợ hCaptcha trong Flutter WebView không?

Không. CaptchaAI giải reCAPTCHA v2/v3, Cloudflare Turnstile/Challenge, GeeTest v3, CAPTCHA ảnh/OCR, grid image và BLS — hCaptcha và FunCaptcha (Arkose Labs) chưa hỗ trợ. Kiểm tra đúng loại CAPTCHA trước khi tích hợp luồng này.

Giải CAPTCHA trong app Flutter qua CaptchaAI tốn bao nhiêu?

Theo thread đồng thời, không theo mỗi lần giải. BASIC ($15/tháng, 5 thread) đủ cho app thử nghiệm; nhiều phiên cùng lúc thì dùng ADVANCE ($90/tháng, 50 thread).

Làm sao debug khi JavaScript channel không nhận được message?

Thường do tên channel lệch nhau — addJavaScriptChannel('CaptchaChannel', ...) phải khớp chính xác với tên gọi trong runJavaScript. Sai một ký tự thì postMessage im lặng, không văng lỗi.

Nên dùng webview_flutter hay flutter_inappwebview?

webview_flutter đáp ứng phần lớn trường hợp. Chuyển sang flutter_inappwebview khi cần chặn request mạng, quản lý cookie, hoặc evaluateJavascript trả kết quả trực tiếp như ở Bước 3.

Luồng này có chạy giống nhau trên Android và iOS không?

Có. Cả hai package đều hỗ trợ Android và iOS, và cách gửi message qua JavaScript channel hoạt động y hệt trên hai nền tảng.

Các lỗi thường gặp khi triển khai

Vấn đề Nguyên nhân Cách xử lý
JavaScript channel không nhận message Tên channel không khớp CaptchaChannel phải giống hệt giữa Dart và JS, kể cả hoa/thường
ERROR_BAD_TOKEN_OR_PAGEURL từ CaptchaAI Lấy sitekey từ sai iframe Trích xuất sitekey từ iframe chứa CAPTCHA, không phải frame cha
Inject token xong không có tác dụng Textarea ẩn hoặc callback chưa được gọi Set giá trị g-recaptcha-response VÀ gọi hàm callback tương ứng
CAPCHA_NOT_READY lặp mãi khi polling Giải chậm hoặc tham số sai Kiểm tra sitekey/pageurl; tăng số lần polling tối đa
WebView crash tại trang CAPTCHA Trang quá nặng, tràn bộ nhớ Dùng flutter_inappwebview với useHybridComposition: true trên Android

Bài viết liên quan

Chủ đề Bài viết
Callback Giải callback reCAPTCHA v2 bằng API
So sánh So sánh GeeTest và Cloudflare Turnstile
Đa CAPTCHA Xử lý reCAPTCHA v2 và Turnstile trên cùng một trang

Các bước tiếp theo

Bắt đầu giải CAPTCHA trong app Flutter của bạn — lấy API key CaptchaAI và nối backend giải CAPTCHA vào luồng WebView ở trên.

Hướng dẫn liên quan:

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