الدروس التطبيقية

التعامل مع Shadow DOM CAPTCHA: الوصول إلى العناصر في مكونات الويب

حلّ CAPTCHA داخل Shadow DOM لا يختلف عن أي تكامل عادي مع CaptchaAI إلا في خطوة واحدة: العثور على العنصر. ما إن تمسك بقيمة sitekey وعنوان الصفحة حتى يعود المسار المألوف — أرسل إلى in.php، استطلع res.php، ثم أعد الرمز إلى النموذج. الصعوبة كلها في السطر الأول، لا في الحل نفسه.

السبب أن مكوّن الويب يبني شجرة DOM معزولة عن شجرة الصفحة، فيعيد document.querySelector('.cf-turnstile') قيمة null رغم أن الأداة معروضة أمامك. والعلاج ليس محدّداً أذكى، بل عبور متكرر عبر shadowRoot حتى تصل إلى الأداة المدفونة.

قبل أن تتهم Shadow DOM: ثلاث حالات تعطي النتيجة نفسها

عودة null ليست دليلاً كافياً؛ ثلاث بنى مختلفة تنتج العَرَض ذاته وكل واحدة تحتاج علاجاً مغايراً:

الحالة العلامة العملية ما الذي تفعله؟
Shadow DOM الأداة ظاهرة وquerySelector يعيد null، مع وجود shadowRoot على العنصر الأب محدّد يعبر جذور الظل أو عبور متكرر
iframe الأداة داخل إطار فرعي مستقل بدّل السياق إلى الإطار أولًا
تحميل متأخر العنصر يظهر بعد حدث أو طلب شبكة انتظر ظهوره أو راقب DOM قبل الاستخراج

أسرع فحص يدوي: افحص الأداة في أدوات مطوّري Chrome. إن ظهر #shadow-root أعلى ترميزها فأنت داخل جذر ظل؛ وإن رأيت <iframe> مستقلاً فلن ينفع العبور.

أين تصادف هذه البنية في مشاريع السوق العربي؟

النمط الأكثر تكراراً هنا يأتي من أنظمة التصميم الداخلية: فريق منتج في القاهرة أو الرياض يبني مكتبة موحّدة من النماذج وحقول الدخول ويوزّعها على علامات فرعية عبر Web Components حتى لا تتسرّب أنماط CSS. ما إن يُضاف Cloudflare Turnstile إلى نموذج الدخول حتى يرثه كل تطبيق يستهلك المكتبة، ويرث معه فريق QA مفاجأة أن سكربتات الاختبار صارت تعيد null. والحالة الثانية شائعة في المتاجر متعددة الأسواق التي تُغلِّف وحدة إتمام الشراء داخل مكوّن مستقل، فيفشل سكربت المراقبة بصمت: sitekey فارغ ثم مهلة انتهاء.

أما التكلفة فلا يغيّرها Shadow DOM، لأن تسعير CaptchaAI قائم على عدد الـ threads المتزامنة لا على عدد عمليات الحل. باقة BASIC بسعر $15 شهرياً تمنحك 5 threads وتكفي اختباراً ليلياً على بيئة تجريبية، بينما تناسب باقة STANDARD بسعر $30 شهرياً مع 15 thread فريقاً يشغّل عدة خطوط اختبار بالتوازي.

لماذا ينتهي اختبار CAPTCHA داخل جذر الظل؟

التغليف قرار معماري متعمَّد لا خطأ في البناء، وهذه الحالات الأربع تتكرر أكثر من غيرها:

السيناريو السبب
مكوّن تسجيل دخول مخصّص مُغلَّف كمكوّن ويب قابل لإعادة الاستخدام
أداة نموذج من طرف ثالث المزوّد يلفّ النموذج داخل جذر ظل ليحمي أنماطه
بنية Micro-frontend كل تطبيق مصغّر داخل جذر ظل معزول
مكوّنات نظام التصميم الأداة مدمجة في عنصر جاهز بمكتبة المكوّنات

الخطوة 1: أثبت أن الأداة تعيش داخل جذر ظل

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

// In browser DevTools console
// Regular query returns null even though CAPTCHA is visible
document.querySelector('.cf-turnstile');  // null

// Check for shadow hosts
document.querySelectorAll('*').forEach(el => {
  if (el.shadowRoot) {
    const captcha = el.shadowRoot.querySelector('.cf-turnstile, .g-recaptcha');
    if (captcha) {
      console.log('Found CAPTCHA in shadow root of:', el.tagName, el.id || el.className);
      console.log('Sitekey:', captcha.dataset.sitekey);
    }
  }
});

الخطوة 2: استخرج sitekey واحقن الرمز باستخدام Playwright في Python

يتعامل Playwright مع جذور الظل تعاملاً أصلياً، فمحدّد العبور >> مع واجهة locator يعبر الحدود دون كود إضافي. لكنه يتوقف عند الجذور المغلقة وعند العناصر بلا صنف ثابت، ولذلك يضيف المثال طريقة احتياطية بدالة عبور متكررة داخل page.evaluate. المسار كامل: كشف، إرسال، استطلاع كل 5 ثوانٍ، ثم حقن وإرسال.

import requests
import time
from playwright.sync_api import sync_playwright

API_KEY = "YOUR_API_KEY"
SUBMIT_URL = "https://ocr.captchaai.com/in.php"
RESULT_URL = "https://ocr.captchaai.com/res.php"


def solve_turnstile(sitekey, pageurl):
    """Submit and poll a Turnstile CAPTCHA."""
    resp = requests.post(SUBMIT_URL, data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": pageurl,
        "json": 1,
    }, timeout=30).json()

    if resp.get("status") != 1:
        raise RuntimeError(f"Submit failed: {resp.get('request')}")

    task_id = resp["request"]
    for _ in range(60):
        time.sleep(5)
        poll = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "get",
            "id": task_id, "json": 1,
        }, timeout=15).json()

        if poll.get("request") == "CAPCHA_NOT_READY":
            continue
        if poll.get("status") == 1:
            return poll["request"]
        raise RuntimeError(f"Solve failed: {poll.get('request')}")

    raise RuntimeError("Timeout")


def extract_from_shadow_dom(page):
    """Extract CAPTCHA sitekey from shadow DOM elements."""

    # Method 1: Playwright's piercing selector (>>)
    # This automatically crosses shadow boundaries
    turnstile = page.locator("css=.cf-turnstile >> visible=true").first
    if turnstile.count() > 0:
        sitekey = turnstile.get_attribute("data-sitekey")
        if sitekey:
            return sitekey

    # Method 2: JavaScript evaluation to pierce all shadow roots
    sitekey = page.evaluate("""
        () => {
            function findInShadowRoots(root) {
                // Check direct children
                const turnstile = root.querySelector('.cf-turnstile');
                if (turnstile && turnstile.dataset.sitekey) {
                    return turnstile.dataset.sitekey;
                }

                const recaptcha = root.querySelector('.g-recaptcha');
                if (recaptcha && recaptcha.dataset.sitekey) {
                    return recaptcha.dataset.sitekey;
                }

                // Recurse into nested shadow roots
                for (const el of root.querySelectorAll('*')) {
                    if (el.shadowRoot) {
                        const found = findInShadowRoots(el.shadowRoot);
                        if (found) return found;
                    }
                }
                return null;
            }
            return findInShadowRoots(document);
        }
    """)

    return sitekey


def inject_token_shadow_dom(page, token, captcha_type="turnstile"):
    """Inject solved token into shadow DOM CAPTCHA element."""
    if captcha_type == "turnstile":
        page.evaluate(f"""
            (token) => {{
                function findAndInject(root) {{
                    // Find the response input inside Turnstile
                    const input = root.querySelector('[name="cf-turnstile-response"]');
                    if (input) {{
                        input.value = token;
                        return true;
                    }}

                    // Recurse into shadow roots
                    for (const el of root.querySelectorAll('*')) {{
                        if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
                            return true;
                        }}
                    }}
                    return false;
                }}
                findAndInject(document);

                // Also try callback if defined
                if (typeof window.turnstileCallback === 'function') {{
                    window.turnstileCallback(token);
                }}
            }}
        """, token)
    elif captcha_type == "recaptcha":
        page.evaluate(f"""
            (token) => {{
                function findAndInject(root) {{
                    const textarea = root.querySelector('#g-recaptcha-response');
                    if (textarea) {{
                        textarea.value = token;
                        textarea.style.display = 'block';
                        return true;
                    }}
                    for (const el of root.querySelectorAll('*')) {{
                        if (el.shadowRoot && findAndInject(el.shadowRoot)) {{
                            return true;
                        }}
                    }}
                    return false;
                }}
                findAndInject(document);

                if (typeof ___grecaptcha_cfg !== 'undefined') {{
                    Object.entries(___grecaptcha_cfg.clients).forEach(([_, client]) => {{
                        Object.entries(client).forEach(([_, val]) => {{
                            if (val && typeof val === 'object') {{
                                Object.entries(val).forEach(([_, v]) => {{
                                    if (v && v.callback) v.callback(token);
                                }});
                            }}
                        }});
                    }});
                }}
            }}
        """, token)


def main():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        page = browser.new_page()
        page.goto("https://example.com/login")
        page.wait_for_load_state("networkidle")

        # Extract sitekey from shadow DOM
        sitekey = extract_from_shadow_dom(page)
        if not sitekey:
            print("No CAPTCHA found in shadow DOM or regular DOM")
            browser.close()
            return

        print(f"Found sitekey: {sitekey}")

        # Solve via CaptchaAI
        token = solve_turnstile(sitekey, page.url)
        print(f"Solved: {token[:40]}...")

        # Inject token back into shadow DOM
        inject_token_shadow_dom(page, token, "turnstile")
        print("Token injected into shadow DOM")

        # Submit the form
        page.click("button[type='submit']")
        page.wait_for_load_state("networkidle")

        browser.close()


main()

ثلاث تفاصيل هنا تفصل بين نجاح متكرر وفشل متقطّع:

  • pageurl يُقرأ من page.url لحظة الاستخراج لا من ثابت مكتوب؛ أي إعادة توجيه تجعل الرمز مرفوضاً.
  • الحقن وحده لا يكفي، فالنموذج ينتظر إشارة الأداة لا مجرّد قيمة في input — لذلك يستدعي المثال رد النداء بعده.
  • العبور المتكرر يكفي للجذور المفتوحة، ويتجاهل المغلقة تماماً.

الخطوة 3: المنطق نفسه في Puppeteer

يعتمد المثال التالي كلياً على العبور المتكرر داخل page.evaluate — المسار الأوسع توافقاً مع إصدارات Puppeteer. المنطق واحد: ابحث عن sitekey في كل جذر، أرسلها، ثم أعد الرمز إلى الحقل cf-turnstile-response أينما وُجد.

const puppeteer = require("puppeteer");

const API_KEY = "YOUR_API_KEY";
const SUBMIT_URL = "https://ocr.captchaai.com/in.php";
const RESULT_URL = "https://ocr.captchaai.com/res.php";

async function solveTurnstile(sitekey, pageurl) {
  const params = new URLSearchParams({
    key: API_KEY, method: "turnstile", sitekey, pageurl, json: "1",
  });
  const resp = await (await fetch(SUBMIT_URL, { method: "POST", body: params })).json();
  if (resp.status !== 1) throw new Error(`Submit: ${resp.request}`);

  const taskId = resp.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const url = `${RESULT_URL}?key=${API_KEY}&action=get&id=${taskId}&json=1`;
    const poll = await (await fetch(url)).json();
    if (poll.request === "CAPCHA_NOT_READY") continue;
    if (poll.status === 1) return poll.request;
    throw new Error(`Solve: ${poll.request}`);
  }
  throw new Error("Timeout");
}

async function extractSitekeyFromShadowDOM(page) {
  return page.evaluate(() => {
    function searchShadowRoots(root) {
      const selectors = [".cf-turnstile", ".g-recaptcha", ".h-captcha"];
      for (const sel of selectors) {
        const el = root.querySelector(sel);
        if (el && el.dataset.sitekey) return el.dataset.sitekey;
      }
      for (const el of root.querySelectorAll("*")) {
        if (el.shadowRoot) {
          const found = searchShadowRoots(el.shadowRoot);
          if (found) return found;
        }
      }
      return null;
    }
    return searchShadowRoots(document);
  });
}

async function injectTokenShadowDOM(page, token) {
  await page.evaluate((t) => {
    function inject(root) {
      const input = root.querySelector('[name="cf-turnstile-response"]');
      if (input) { input.value = t; return true; }
      const textarea = root.querySelector("#g-recaptcha-response");
      if (textarea) { textarea.value = t; return true; }
      for (const el of root.querySelectorAll("*")) {
        if (el.shadowRoot && inject(el.shadowRoot)) return true;
      }
      return false;
    }
    inject(document);
  }, token);
}

(async () => {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();
  await page.goto("https://example.com/login", { waitUntil: "networkidle2" });

  const sitekey = await extractSitekeyFromShadowDOM(page);
  if (!sitekey) {
    console.log("No CAPTCHA found in shadow DOM");
    await browser.close();
    return;
  }

  console.log(`Sitekey: ${sitekey}`);
  const token = await solveTurnstile(sitekey, page.url());
  console.log(`Solved: ${token.substring(0, 40)}...`);

  await injectTokenShadowDOM(page, token);
  await page.click('button[type="submit"]');
  await page.waitForNavigation();

  await browser.close();
})();

عمق الظل: متى يكفي استعلام واحد ومتى تحتاج العبور المتكرر؟

ليست كل الجذور متساوية في الكلفة: مستوى واحد يعني استعلاماً مباشراً، أما التداخل فيفرض عبوراً على شجرة قد تضم آلاف العناصر:

العمق مثال النهج المناسب
مستوى واحد <custom-form> #shadow-root > .cf-turnstile استعلام مباشر على shadowRoot
مستويان فأكثر <app-shell> #shadow > <login-form> #shadow > .cf-turnstile عبور متكرر عبر كل جذر
جذر ظل مفتوح el.shadowRoot متاح للقراءة الطريق القياسي — العبور يكفي
جذر ظل مغلق el.shadowRoot يعيد null تجاوز attachShadow({mode:'open'}) قبل التحميل

في الصفحات الثقيلة حدّد سقفاً للعمق أو استهدف الوسوم المعروفة بدل المرور على *؛ الفرق ثوانٍ في كل دورة اختبار.

الجذور المغلقة: ما الذي يمكن فعله فعلاً؟

حين يُنشئ المكوّن جذره بالوضع closed تعيد el.shadowRoot قيمة null مهما كررت المحاولة. لا سبيل لقراءة جذر مغلق بعد إنشائه؛ الحل يسبق إنشاءه: استبدل Element.prototype.attachShadow بنسخة تفرض الوضع open على كل جذر لاحق، عبر page.add_init_script في Playwright أو page.evaluateOnNewDocument في Puppeteer. الشرط أن يسبق التسجيل استدعاء goto، وإلا فات الأوان.

وهناك بديل أخف: استخرج sitekey من كود المصدر أو من طلب الشبكة الذي يحمّل الأداة — أسرع أحياناً من محاولات العبور كلها.

أخطاء متكررة أثناء التشغيل الحقيقي

المشكلة السبب المرجّح الإجراء
shadowRoot يعيد null دائماً الجذر مغلق سجّل تجاوز attachShadow قبل تحميل الصفحة
الرمز يُنشأ والموقع يرفضه pageurl أو sitekey أو الجلسة غير متطابقة التقط المعاملات لحظة الاستخراج واستخدم الرمز في الجلسة نفسها
الرمز محقون والنموذج لا يُرسل رد النداء لم يُستدعَ بعد الحقن نفّذ دالة رد النداء داخل الجذر عقب الحقن
الأداة غير موجودة عند البدء المكوّن يُحمَّل بشكل غير متزامن انتظر ظهور العنصر المضيف ثم ابدأ العبور
الاستطلاع ينتهي بمهلة الفواصل الزمنية أضيق من اللازم استطلع كل 5–10 ثوانٍ وافصل المهلة عن الخطأ الفعلي

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

هل يتغيّر شكل الطلب المُرسل إلى CaptchaAI بسبب Shadow DOM؟

لا. الخدمة لا ترى صفحتك، بل تستقبل method وsitekey وpageurl فقط. سواء جاءت القيمة من document أو من جذر ظل عميق، يبقى الطلب إلى in.php مطابقاً والرد من res.php هو نفسه.

هل يحلّ CaptchaAI اختبار hCaptcha داخل مكوّنات الويب؟

لا، فـ hCaptcha وFunCaptcha غير مدعومين، وGeeTest v4 ضمن ما هو قادم لا ضمن المتاح. المدعوم يشمل reCAPTCHA v2 وv3 وCloudflare Turnstile وCloudflare Challenge وGeeTest v3 والصور وشبكة الصور وBLS، إضافة إلى CaptchaFox وFriendly Captcha وLemin في مرحلة beta.

حقنت الرمز في الحقل الصحيح فلماذا يبقى النموذج معطّلاً؟

لأن كثيراً من النماذج تنتظر إشارة الأداة لا قيمة الحقل. ضبط input.value يملأ الحقل بلا حدث فيبقى الزر معطّلاً. استدعِ رد النداء بعد الحقن أو أطلق حدث input داخل الجذر نفسه.

كم يستغرق الحل بعد استخراج sitekey؟

Cloudflare Turnstile — الأكثر شيوعاً هنا — يكتمل عادةً في أقل من 10 ثوانٍ، بينما تستغرق reCAPTCHA v2 أقل من 60 ثانية. اضبط فاصل الاستطلاع على 5 ثوانٍ، ولا تحسب زمن العبور داخل المتصفح ضمن المدة.


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

أنشئ مفتاح الـ API وابدأ بدورة إرسال واستطلاع بسيطة قبل طبقة العبور:

أدلة ذات صلة

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