التكاملات

حل reCAPTCHA v2 في اختبارات iOS الآلية عبر XCUITest وCaptchaAI

يعترض تحدي reCAPTCHA v2 مسار اختبارات iOS الآلية عند كل نموذج تسجيل أو بوابة دفع تُحمَّل داخل WKWebView. المسار للتعامل معه يمر بثلاث خطوات واضحة: اكتشاف الكابتشا داخل محتوى الويب، وإرساله إلى CaptchaAI للحصول على الرمز، ثم حقن هذا الرمز ليُكمل النموذج إرساله تلقائيًا.

العقبة الجوهرية أن XCUITest يتعامل مع عناصر الواجهة فقط ولا ينفّذ JavaScript داخل WKWebView مباشرة. لذلك نبني جسرًا صغيرًا بين طبقة الاختبار ومحتوى الويب، ونترك مهمة الحل لخدمة CaptchaAI عبر خدمة مصاحبة تعمل على جهاز الاختبار. النتيجة اختبارات E2E تعبر تحديات reCAPTCHA دون أي تدخل يدوي، سواء على جهازك المحلي أو على بيئة CI.

متى يكون هذا الأسلوب مناسبًا (ومتى لا يكون)

قبل بناء أي جسر، تأكد أنك تحتاجه فعلًا. الجدول التالي يلخّص الحالات التي يستحق فيها هذا النهج جهده:

الحالة هل هذا الأسلوب مناسب؟ السبب
تطبيق iOS يحمّل تدفقات حرجة داخل WKWebView نعم تحتاج جسرًا بين XCUITest ومحتوى الويب
اختبار Safari أو صفحة ويب فقط غالبًا لا Playwright أو Selenium أبسط هنا
فريق iOS يريد اختبارات E2E مستقرة على بيئة CI نعم يمكن عزل منطق الحل داخل مساعد اختبار واضح
تطبيق native بالكامل بلا WebView لا لا توجد حاجة لهذا التعقيد أصلًا

الخلاصة: هذا الأسلوب موجّه لفرق iOS التي تعتمد على WebView لعرض نماذج محمية بالكابتشا وتريد إبقاء اختباراتها الآلية مستقرة دون خطوات يدوية.

لماذا لا يستطيع XCUITest حل الكابتشا وحده

يتعامل XCUITest مع الأزرار والحقول ومكوّنات الواجهة، لكنه لا يملك وصولًا مباشرًا إلى شجرة DOM داخل WKWebView ولا ينفّذ JavaScript فيها. حل reCAPTCHA يتطلب قراءة مفتاح الموقع من الصفحة وكتابة الرمز في حقلها بعد الحل، وكلاهما يجري في طبقة الويب لا في طبقة الواجهة الأصلية.

لسد هذه الفجوة نوزّع المسؤوليات على أربعة مكوّنات، يستدعي أحدها الآخر أثناء الاختبار:

المكوّن الدور
XCUITest يقود الواجهة ويشغّل تدفق حل الكابتشا عبر مساعد الاختبار
نقطة نهاية مساعدة للاختبار تتلقى مفتاح الموقع وعنوان الصفحة، وتستدعي CaptchaAI، وتعيد الرمز
خطاف الاختبار داخل التطبيق يقيّم JavaScript في WKWebView للكشف عن الكابتشا وحقن الرمز
واجهة CaptchaAI تحل تحدي reCAPTCHA وتعيد التوكن

سيناريو واقعي من بيئة عربية

تخيّل تطبيق متجر إلكتروني يخدم السوق في مصر والخليج، ويحمّل شاشة إنشاء الحساب داخل WKWebView لإعادة استخدام نموذج الويب نفسه عبر منصات متعددة. النموذج محميّ بـ reCAPTCHA v2 لمنع الحسابات الآلية.

في كل تشغيل لمجموعة اختبارات القبول، يتوقف الاختبار عند شاشة التسجيل لأن الكابتشا يعترض التقدم، فيضطر المهندس إلى حلّه يدويًا أو تعطيل الاختبار. الحل المطلوب يجب أن:

  1. يكتشف اختبار CAPTCHA في WebView أثناء تنفيذ الاختبار
  2. يستخرج مفتاح الموقع برمجياً
  3. يحلها عبر CaptchaAI
  4. يقوم بإدخال الرمز المميز حتى يتمكن النموذج من الإرسال

البيئة: Xcode 15+، Swift، XCUITest، مشغل اختبار macOS، CaptchaAI API.

الخطوة 1: أضف خطاف اختبار إلى التطبيق

في وحدة تحكم WKWebView داخل تطبيقك، أضِف معالج CAPTCHA لوضع الاختبار يمكن تشغيله عبر معرّفات إمكانية الوصول أو نظام URL. غلّفه بـ #if DEBUG حتى لا يصل إلى الإصدار الإنتاجي:

// CaptchaTestHelper.swift — Add to app target (test build only)
import WebKit

#if DEBUG
class CaptchaTestHelper {
    private let webView: WKWebView

    init(webView: WKWebView) {
        self.webView = webView
    }

    func detectCaptcha(completion: @escaping (String?, String?) -> Void) {
        let script = """
        (function() {
            var el = document.querySelector('.g-recaptcha');
            if (el) {
                return JSON.stringify({
                    sitekey: el.getAttribute('data-sitekey'),
                    pageurl: window.location.href
                });
            }
            return null;
        })();
        """

        webView.evaluateJavaScript(script) { result, error in
            guard let jsonString = result as? String,
                  let data = jsonString.data(using: .utf8),
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
                completion(nil, nil)
                return
            }
            completion(json["sitekey"], json["pageurl"])
        }
    }

    func injectToken(_ token: String, completion: @escaping (Bool) -> Void) {
        let script = """
        document.getElementById('g-recaptcha-response').value = '\(token)';
        try {
            var clients = ___grecaptcha_cfg.clients;
            Object.keys(clients).forEach(function(k) {
                Object.keys(clients[k]).forEach(function(j) {
                    if (clients[k][j] && clients[k][j].callback) {
                        clients[k][j].callback('\(token)');
                    }
                });
            });
        } catch(e) {}
        true;
        """

        webView.evaluateJavaScript(script) { _, error in
            completion(error == nil)
        }
    }

    func solveCaptchaViaBackend(
        sitekey: String, pageurl: String,
        completion: @escaping (Result<String, Error>) -> Void
    ) {
        guard let url = URL(string: "http://localhost:3000/api/solve-captcha") else {
            return
        }

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let body: [String: String] = [
            "captchaType": "recaptcha_v2",
            "sitekey": sitekey,
            "pageurl": pageurl
        ]
        request.httpBody = try? JSONSerialization.data(withJSONObject: body)

        URLSession.shared.dataTask(with: request) { data, _, error in
            if let error = error {
                completion(.failure(error))
                return
            }
            guard let data = data,
                  let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
                  let token = json["token"] as? String else {
                completion(.failure(NSError(domain: "", code: -1,
                    userInfo: [NSLocalizedDescriptionKey: "No token"])))
                return
            }
            completion(.success(token))
        }.resume()
    }
}
#endif

يستخرج detectCaptcha مفتاح الموقع وعنوان الصفحة، ويكتب injectToken الرمز في حقل g-recaptcha-response ثم يستدعي callback الخاص بـ reCAPTCHA، بينما يمرّر solveCaptchaViaBackend الطلب إلى خدمة الحل المحلية.

الخطوة 2: شغّل خدمة الحل الخلفية

شغّل خدمة حل محلية أثناء الاختبار تتواصل مع CaptchaAI عبر نقطتَي in.php وres.php، مع الاستطلاع الدوري للنتيجة حتى يجهز الرمز:

# ios_test_solver.py — Run on test machine during XCUITest execution
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():
    data = request.json
    sitekey = data["sitekey"]
    pageurl = data["pageurl"]

    # Submit to CaptchaAI
    resp = requests.get("https://ocr.captchaai.com/in.php", params={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": pageurl,
        "json": "1",
    })
    result = resp.json()

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

    task_id = result["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",
        })
        poll_result = poll.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"}), 408

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

تبقي هذه الخدمة مفتاح الـ API خارج كود التطبيق، وتقرأه من متغيّر البيئة CAPTCHAAI_API_KEY بدل تضمينه في المصدر.

الخطوة 3: اربط التدفق داخل XCUITest

في اختبار XCUITest، شغّل تدفق حل الكابتشا بمجرد أن يُحمَّل WebView الذي يحتوي التحدي، ثم انتظر مؤشّر اكتمال الحل قبل إرسال النموذج:

// CaptchaUITests.swift
import XCTest

class CaptchaUITests: XCTestCase {

    func testRegistrationWithCaptcha() throws {
        let app = XCUIApplication()
        app.launchArguments.append("--captcha-test-mode")
        app.launch()

        // Navigate to registration
        app.buttons["Register"].tap()

        // Wait for WebView to load
        let webView = app.webViews.firstMatch
        XCTAssertTrue(webView.waitForExistence(timeout: 15))

        // Trigger CAPTCHA solve via test helper button
        // (The app shows this button only in test mode)
        let solveButton = app.buttons["SolveCaptchaTestHelper"]
        if solveButton.waitForExistence(timeout: 5) {
            solveButton.tap()

            // Wait for solve completion indicator
            let solved = app.staticTexts["CaptchaSolved"]
            XCTAssertTrue(solved.waitForExistence(timeout: 120),
                "CAPTCHA should be solved within 2 minutes")
        }

        // Continue with form submission
        app.buttons["SubmitForm"].tap()

        // Verify success
        let success = app.staticTexts["Registration Complete"]
        XCTAssertTrue(success.waitForExistence(timeout: 10))
    }
}

لاحظ مهلة الانتظار الطويلة (120 ثانية) عند حل الكابتشا، فهي أطول من بقية خطوات الواجهة لأن الحل يجري عبر الشبكة.

معالجة المشكلات الشائعة

المشكلة السبب الإصلاح
لا يتم اكتشاف CAPTCHA داخل WebView السكربت يبحث عن محدد خاطئ أو أن الأداة لم تُحمّل بعد أضف انتظارًا لتحميل الصفحة وتحقق من المحددات داخل WKWebView
تم الحصول على token لكن النموذج لا يُرسل callback أو حقل g-recaptcha-response لم يُحدَّث بالشكل المتوقع اختبر دالة injectToken على الصفحة نفسها وتحقق من callback الفعلي
خدمة الحل المحلية لا تستجيب أثناء الاختبار الخادم المساعد غير شغّال أو المنفذ غير صحيح تأكد من تشغيل الخدمة قبل XCUITest ومطابقة localhost:3000
الاختبار ينجح محليًا ويفشل على CI CI لا يملك الشبكة أو الأسرار اللازمة للوصول إلى CaptchaAI وفّر المفتاح والمتطلبات الشبكية نفسها التي تستخدمها محليًا

الأسئلة الشائعة

هل يقتصر هذا الأسلوب على reCAPTCHA v2؟

لا. البنية نفسها تعمل مع أنواع أخرى يدعمها CaptchaAI، ويكفي تعديل قيمة method في خدمة الحل ومحدّد الكشف داخل السكربت. من الأنواع المدعومة:

  • reCAPTCHA v3
  • Cloudflare Turnstile وCloudflare Challenge
  • GeeTest v3
  • كابتشا الصور وOCR والشبكات (grid)

لاحظ أن hCaptcha وFunCaptcha غير مدعومَين حاليًا، فلا تبنِ عليهما هذا المسار.

كم تبلغ تكلفة حل الكابتشا في مجموعة الاختبارات؟

يعتمد CaptchaAI تسعيرًا قائمًا على عدد الـ Threads المتزامنة لا على كل عملية حل، وتبدأ الباقات من BASIC بسعر 15 دولارًا شهريًا مع 5 threads. أبرز ما يهم فريق الاختبار:

  • الفوترة على الـ Threads المتزامنة، لا على كل عملية حل.
  • حلول غير محدودة لكل thread خلال الشهر.
  • باقة BASIC تكفي عادةً لمجموعة اختبارات CI صغيرة إلى متوسطة.

راجع صفحة الأسعار لأحدث الباقات قبل الاعتماد.

هل يعمل هذا على المحاكي (Simulator) أم يلزم جهاز حقيقي؟

يعمل على الاثنين معًا، لكن انتبه إلى إعدادات الشبكة على كل بيئة:

  • على المحاكي قد لا يصل localhost إلى خدمة الحل، فاستخدم 127.0.0.1 أو عنوان IP الخاص بجهاز Mac.
  • فعّل إعدادات App Transport Security للسماح بالاتصال المحلي أثناء الاختبار فقط.
  • على الجهاز الحقيقي، تأكد أن الجهاز وخدمة الحل على الشبكة نفسها.

كيف أضمن ألا يصل كود مساعد الاختبار إلى الإصدار الإنتاجي؟

غلّف كل كود المساعد داخل توجيهات #if DEBUG، فيُحذف تلقائيًا من إصدارات الإطلاق (release). بهذا يبقى خطاف الاختبار متاحًا في بناء الاختبار وحده دون أي أثر في التطبيق المنشور.


الخطوات التالية

أدلة ذات صلة

التعليقات غير مفعّلة لهذا المقال.