Tutorials

reCAPTCHA dans les applications à page unique : modèles de chargement dynamiques

Dans une SPA, le sitekey n'est pas dans la source de la page : il arrive avec le bundle JavaScript, et le widget n'apparaît qu'au montage du composant.

La séquence qui fonctionne tient en trois temps :

  1. Attendre le rendu du widget.
  2. Extraire le sitekey (iframe ou data-sitekey).
  3. Injecter le token juste avant l'envoi.

Ce qui change par rapport à une page rendue côté serveur

Page classique SPA
Script reCAPTCHA dans le HTML Script injecté au changement de route
Widget rendu au chargement Widget rendu au montage
Sitekey dans la source Sitekey dans le bundle JavaScript
Formulaire envoyé en POST Formulaire envoyé en XHR/fetch
  • Sans le clic qui ouvre le formulaire React, l'iframe est absente.
  • Le v-if d'un tunnel Vue remonte le widget entre deux étapes.
  • L'envoi part en XHR, sans rechargement à observer.

Repérer le widget reCAPTCHA rendu par la SPA

Attendre l'apparition de l'iframe

from playwright.sync_api import sync_playwright

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

    # SPA may need a click or navigation to trigger reCAPTCHA
    page.click("#show-login-form")

    # Wait for reCAPTCHA iframe to appear
    recaptcha_frame = page.wait_for_selector(
        "iframe[src*='recaptcha']",
        timeout=15000
    )
    print("reCAPTCHA detected:", recaptcha_frame.get_attribute("src"))

Trois endroits où chercher le sitekey

  • L'URL de l'iframe : le paramètre k= porte la clé.
  • L'attribut data-sitekey, dès le montage du composant.
  • Le bundle : dernier recours, à revalider à chaque build.
# Method 1: From the iframe src
iframe_src = recaptcha_frame.get_attribute("src")
# src contains: ...?k=6LcR_RsTAAAAADge...
import re
match = re.search(r'[?&]k=([^&]+)', iframe_src)
site_key = match.group(1) if match else None

# Method 2: From data-sitekey attribute
site_key = page.eval_on_selector(
    "[data-sitekey]",
    "el => el.getAttribute('data-sitekey')"
)

# Method 3: From JavaScript bundle (last resort)
site_key = page.evaluate("""
    () => {
        // Check for grecaptcha render calls
        const scripts = document.querySelectorAll('script');
        for (const s of scripts) {
            const match = s.textContent.match(/sitekey['":\s]+(['"])(6L[^'"]+)\\1/);
            if (match) return match[2];
        }
        return null;
    }
""")

Envoyer la tâche à l'API CaptchaAI

  • pageurl porte l'URL affichée après la navigation interne, pas l'URL d'entrée.
  • Le sitekey extrait plus haut part dans googlekey.
  • Le résultat arrive par polling sur res.php.
import requests
import time

def solve_recaptcha(site_key, page_url):
    # Submit task
    resp = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": "YOUR_API_KEY",
        "method": "userrecaptcha",
        "googlekey": site_key,
        "pageurl": page_url,
        "json": 1
    })
    task_id = resp.json()["request"]

    # Poll for result
    for _ in range(60):
        time.sleep(3)
        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": "YOUR_API_KEY",
            "action": "get",
            "id": task_id,
            "json": 1
        })
        data = result.json()
        if data["status"] == 1:
            return data["request"]
    raise TimeoutError("Solve timed out")

Injecter le token reCAPTCHA là où la SPA le lit

  • Zone de texte g-recaptcha-response : relue à l'envoi.
  • Callback data-callback : React ou Vue pilote l'état.
  • Requête XHR : l'envoi passe par une API REST.

Option 1 : remplir la zone de texte masquée

Cliquez dans la foulée : un re-rendu vide la zone de texte.

token = solve_recaptcha(site_key, "https://example.com/login")

# Inject into the g-recaptcha-response textarea
page.evaluate(f"""
    document.getElementById('g-recaptcha-response').value = '{token}';
""")

# Submit the form
page.click("#login-button")

Option 2 : appeler le callback déclaré par le widget

Méthode la plus fiable dès que React ou Vue contrôle le formulaire.

Le callback met à jour l'état du composant, pas seulement le DOM.

# Find the callback function name
callback = page.evaluate("""
    () => {
        const widget = document.querySelector('.g-recaptcha');
        return widget?.getAttribute('data-callback') || null;
    }
""")

# Call it with the token
if callback:
    page.evaluate(f"window['{callback}']('{token}')")
else:
    # Try the default grecaptcha callback
    page.evaluate(f"""
        if (window.grecaptcha) {{
            // Set the response and trigger verification
            document.getElementById('g-recaptcha-response').value = '{token}';
            // Find and call any registered callbacks
            const form = document.querySelector('form');
            if (form) form.dispatchEvent(new Event('submit'));
        }}
    """)

Option 3 : intercepter la requête XHR avec Puppeteer

Vérifiez ainsi que le token voyage bien dans le payload.

const puppeteer = require('puppeteer');

async function solveRecaptchaInSPA() {
  const browser = await puppeteer.launch({ headless: false });
  const page = await browser.newPage();

  // Intercept the API call and inject the token
  await page.setRequestInterception(true);
  page.on('request', request => {
    if (request.url().includes('/api/login')) {
      const postData = JSON.parse(request.postData() || '{}');
      // Token already set via page.evaluate — let it pass
      request.continue();
    } else {
      request.continue();
    }
  });

  await page.goto('https://example.com/login');
  await page.waitForSelector('[data-sitekey]');

  const siteKey = await page.$eval(
    '[data-sitekey]',
    el => el.getAttribute('data-sitekey')
  );

  // Solve with CaptchaAI (implementation omitted — same as Python)
  const token = await solveCaptcha(siteKey, page.url());

  // Inject token and trigger callback
  await page.evaluate((t) => {
    document.getElementById('g-recaptcha-response').value = t;
    const callback = document.querySelector('.g-recaptcha')
      ?.getAttribute('data-callback');
    if (callback && window[callback]) {
      window[callback](t);
    }
  }, token);
}

Particularités React, Vue et Angular

React (react-google-recaptcha)

Le paquet react-google-recaptcha monte le widget de façon asynchrone.

Visez l'état attached : le conteneur reste parfois masqué.

# Wait for React to mount the component
page.wait_for_selector(".g-recaptcha", state="attached")

# The sitekey is in the rendered div's data attribute
site_key = page.eval_on_selector(
    ".g-recaptcha", "el => el.dataset.sitekey"
)

Vue (vue-recaptcha)

Le widget dépend souvent d'un v-if : déclenchez l'étape d'abord.

# Vue may use v-if to conditionally render
# Navigate or interact to trigger the condition
page.click("#proceed-to-checkout")
page.wait_for_selector("iframe[src*='recaptcha']")

Angular

Les modules reCAPTCHA arrivent souvent en lazy-load : visez plusieurs sélecteurs.

# Angular apps may lazy-load reCAPTCHA modules
# Wait for the specific Angular component
page.wait_for_selector("re-captcha, app-recaptcha, [data-sitekey]")
  • Le changement de route ne déclenche ni load ni DOMContentLoaded.
  • Toute attente basée sur la navigation échoue silencieusement.
  • Attendez le sélecteur, pas la page.
# Listen for reCAPTCHA appearing after SPA navigation
page.goto("https://example.com")

# Navigate within the SPA
page.click("a[href='/login']")

# The URL changed but no page reload happened
# Wait for the CAPTCHA to render in the new "page"
page.wait_for_selector("iframe[src*='recaptcha']", timeout=10000)

Cas concret : un tunnel de réservation testé en préproduction

Une équipe QA à Lyon automatise un tunnel de réservation Nuxt hébergé chez OVHcloud. Le reCAPTCHA v2 n'apparaît qu'à la troisième étape, et chaque retour arrière remonte le composant.

  • Clic sur « Confirmer », puis attente du sélecteur.
  • Extraction du sitekey, envoi à l'API.
  • Injection par callback, puis envoi immédiat.

Une suite nocturne de ce type tient sur le plan BASIC ($15/mois, 5 threads), facturé en dollars US.

Côté RGPD, purgez les logs de test.

Dépannage

Cinq symptômes reviennent presque toujours.

Problème Cause Correctif
Zone de texte g-recaptcha-response absente Composant non monté wait_for_selector, timeout 10–15 s
Token injecté, formulaire immobile Callback jamais appelé Lire et appeler data-callback
Sitekey absent de la source Clé dans le bundle Attendre le rendu, lire l'iframe
Token valide une fois puis rejeté Composant remonté Injecter juste avant l'envoi
Valeur injectée effacée aussitôt Réactivité React/Vue Passer par data-callback

FAQ

Pourquoi le token disparaît-il de g-recaptcha-response juste après l'injection ?

Parce que le framework a re-rendu le composant.

React et Vue considèrent le DOM comme leur propriété : passez par le callback.

Combien de temps un token reCAPTCHA reste-t-il exploitable ?

Environ deux minutes côté Google.

Lancez la résolution quand le widget s'affiche, pas au début du scénario.

Le sitekey change-t-il d'une route à l'autre ?

Rarement, mais c'est possible selon l'environnement ou la route.

Relisez-le à chaque étape plutôt qu'une fois pour toute la session.

Quel timeout choisir pour l'attente du widget ?

10 à 15 secondes couvrent la plupart des montages.

En dessous, vous mesurez surtout la latence réseau.

CaptchaAI prend-il en charge hCaptcha si ma SPA en utilise un ?

Non — pas encore pris en charge, pas plus que FunCaptcha (Arkose Labs).

Types couverts : reCAPTCHA v2 et v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, CAPTCHA image/OCR.

GeeTest v4 est à venir ; CaptchaFox, Friendly Captcha et Lemin sont en bêta.

Articles connexes

Prochaines étapes

Rejouez la séquence détection → résolution → injection sur votre SPA : récupérez votre clé API CaptchaAI.

Les commentaires sont désactivés pour cet article.