Tutorials

Cloudflare Turnstile in Python mit Requests und CaptchaAI lösen

Zwischen Ihrem Python-Skript und einem gültigen Turnstile-Token liegen drei HTTP-Aufrufe: Sitekey aus dem HTML lesen, Auftrag an in.php übermitteln, Ergebnis von res.php abfragen. Danach wandert das Token als cf-turnstile-response in die Formulardaten – ohne Browser, allein mit der requests-Bibliothek.

Möglich ist das, weil die Prüfung serverseitig läuft: Die Anwendung reicht das Token an Cloudflare weiter und akzeptiert es, wenn es zum Sitekey passt und noch frisch ist. Ihr Client muss das Widget also nicht rendern, sondern nur ein gültiges Token besitzen – geliefert von der CaptchaAI-API, für Turnstile in der Regel in unter 10 Sekunden.


Was beim Lösen von Turnstile tatsächlich passiert

Ein Bild-CAPTCHA verlangt die Antwort auf eine sichtbare Aufgabe. Turnstile verlangt eine Zeichenkette. Das Widget läuft in drei Ausprägungen – managed, non-interactive und invisible –, wertet Client-Signale aus und schreibt das Ergebnis in das versteckte Feld cf-turnstile-response. Geprüft wird dieser Wert erst beim Absenden des Formulars. Daraus folgen drei Regeln:

  • Der Sitekey ist der Einstiegspunkt. Er steht im HTML, meist im Attribut data-sitekey.
  • Tokens sind kurzlebig. Lösen Sie unmittelbar vor dem Absenden, nicht vorab in einem separaten Lauf.
  • Ein Token gehört zu einer Übermittlung. Jeder neue Versuch braucht ein neues.

Was Sie vorab brauchen

pip install requests

Dazu drei Angaben:

  • ein CaptchaAI-API-Schlüssel aus dem Dashboard auf captchaai.com
  • die vollständige URL der Seite mit dem Widget
  • der Turnstile-Sitekey – Schritt 1 liest ihn automatisch aus

Schritt 1: Sitekey aus dem Seiten-HTML auslesen

Turnstile-Sitekeys beginnen praktisch immer mit 0x und stehen im data-sitekey-Attribut des Widget-Containers oder in einem Inline-Skript. Die Funktion unten deckt beide ab. Entscheidend sind die Header: Ohne plausiblen User-Agent antwortet Cloudflare oft mit HTTP 403, bevor Sie das HTML überhaupt sehen.

import re
import requests

def extract_turnstile_sitekey(url):
    """Extract Cloudflare Turnstile sitekey from page HTML."""
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                      "AppleWebKit/537.36 Chrome/120.0.0.0",
        "Accept": "text/html,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
    }
    response = requests.get(url, headers=headers, timeout=15)

    patterns = [
        r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']',
        r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
        r"siteKey\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
    ]

    for pattern in patterns:
        match = re.search(pattern, response.text)
        if match:
            return match.group(1)

    return None


sitekey = extract_turnstile_sitekey("https://example.com/signup")
print(f"Sitekey: {sitekey}")

Bleibt die Rückgabe leer, lädt das Widget per JavaScript nach: Der Sitekey steht dann nicht im HTML, sondern erst im gerenderten DOM.


Schritt 2: Lösungsauftrag an die CaptchaAI-API übermitteln

Der Auftrag geht als POST an in.php. Wichtig sind drei Felder: method=turnstile, der ausgelesene sitekey und die pageurl – exakt die Adresse, auf der das Widget läuft, inklusive Pfad. Mit json=1 antwortet die API strukturiert.

import requests

API_KEY = "YOUR_API_KEY"

def submit_turnstile(sitekey, page_url):
    """Submit Turnstile solving task to CaptchaAI."""
    response = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "json": 1,
    })

    data = response.json()

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

    return data["request"]


task_id = submit_turnstile("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")
print(f"Task ID: {task_id}")

Zurück kommt keine Lösung, sondern eine Task-ID im Feld request. Ein status ungleich 1 liefert einen Klartextfehler: ERROR_WRONG_USER_KEY und ERROR_ZERO_BALANCE sind Konfigurations- und Abrechnungsprobleme, keine Lösungsfehler – sie zu wiederholen bringt nichts.


Schritt 3: Token abfragen, bis das Ergebnis vorliegt

CaptchaAI arbeitet asynchron: Sie fragen den Status in Intervallen ab, bis status auf 1 springt. Fünf Sekunden Pause sind ein guter Kompromiss – kürzere Intervalle erzeugen nur Last ohne Zeitgewinn.

import time

def poll_result(task_id, timeout=120):
    """Poll CaptchaAI for the solved Turnstile token."""
    start = time.time()

    while time.time() - start < timeout:
        time.sleep(5)

        result = requests.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY,
            "action": "get",
            "id": task_id,
            "json": 1,
        }).json()

        if result.get("status") == 1:
            return result["request"]

        if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
            raise Exception("Turnstile could not be solved")

    raise TimeoutError("Solve timed out")


token = poll_result(task_id)
print(f"Token: {token[:50]}...")

ERROR_CAPTCHA_UNSOLVABLE heißt, dass der Auftrag aufgegeben wurde; meist stimmt dann Sitekey oder pageurl nicht. Das Timeout nach 120 Sekunden verhindert, dass ein hängender Auftrag einen Worker blockiert.


Alles zusammen: vom Sitekey bis zum abgesendeten Formular

Das folgende Skript bündelt die drei Schritte in einer Session, damit Cookies zwischen GET und POST erhalten bleiben. Daran scheitern viele Integrationen: Kommt das Token über eine andere Verbindung als die Seite selbst, weist die Anwendung es häufig zurück.

import re
import time
import requests

API_KEY = "YOUR_API_KEY"
TARGET_URL = "https://example.com/signup"


def solve_turnstile(sitekey, page_url):
    """Full Turnstile solve: submit + poll."""
    # Submit
    submit = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "json": 1,
    })

    data = submit.json()
    if data.get("status") != 1:
        raise Exception(f"Submit error: {data.get('request')}")

    task_id = data["request"]
    print(f"Task submitted: {task_id}")

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

        if result.get("status") == 1:
            return result["request"]

    raise TimeoutError("Solve timed out")


# --- Main flow ---
session = requests.Session()
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 Chrome/120.0.0.0",
    "Accept": "text/html,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.9",
})

# 1. Get page and extract sitekey
response = session.get(TARGET_URL, timeout=15)
match = re.search(r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text)
if not match:
    raise ValueError("Turnstile sitekey not found")
sitekey = match.group(1)
print(f"Sitekey: {sitekey}")

# 2. Solve Turnstile
token = solve_turnstile(sitekey, TARGET_URL)
print(f"Token: {token[:50]}...")

# 3. Submit form with token
form_response = session.post(TARGET_URL, data={
    "cf-turnstile-response": token,
    "email": "[email protected]",
    "password": "SecurePass123",
})
print(f"Form status: {form_response.status_code}")

Typische Fehlerbilder und was dahintersteckt

Läuft das Skript durch, liefert aber kein akzeptiertes Token, steckt die Ursache fast immer in dieser Tabelle:

Symptom Ursache Lösung
Formular lehnt das Token ab Falscher Sitekey oder fehlender action-Wert Sitekey neu auslesen, data-action mitschicken
Sitekey wird nicht gefunden Widget kommt per JavaScript Seite rendern, Sitekey aus dem DOM lesen
HTTP 403 beim Laden der Seite Cloudflare blockiert vor dem Widget Vollständige Browser-Header setzen
Lösung dauert über 60 Sekunden Warteschlange in einer Lastspitze Timeout erhöhen, danach neu einreichen
Token funktioniert nur einmal Pro Übermittlung ist ein frisches Token nötig Für jeden Versuch neu lösen

Sonderfall: Turnstile mit action-Parameter

Manche Anwendungen validieren zusätzlich den Parameter action. Steht im HTML ein data-action-Attribut, muss derselbe Wert in den Auftrag – sonst erhalten Sie ein technisch einwandfreies Token, das das Formular trotzdem ablehnt:

def solve_turnstile_with_action(sitekey, page_url, action):
    """Solve Turnstile that requires an action parameter."""
    submit = requests.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": sitekey,
        "pageurl": page_url,
        "action": action,  # Include the action from data-action attribute
        "json": 1,
    })

    data = submit.json()
    if data.get("status") != 1:
        raise Exception(f"Submit error: {data.get('request')}")

    task_id = data["request"]

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

        if result.get("status") == 1:
            return result["request"]

    raise TimeoutError("Solve timed out")

Drei Wege, das Token an die Anwendung zu übergeben

Wie das Token zurück in die Anwendung kommt, hängt vom Frontend ab. Drei Muster decken die Praxis ab.

Muster 1: klassisches Formular-POST

Der Normalfall: Turnstile legt seinen Wert im Feld cf-turnstile-response ab, und genau so erwartet ihn der Server zurück.

# Most common — Turnstile uses cf-turnstile-response field
response = session.post(form_url, data={
    "cf-turnstile-response": token,
    "email": "[email protected]",
})

Muster 2: JSON-Endpunkt einer Single-Page-Anwendung

Moderne Frontends schicken das Token als JSON an eine eigene Route. Den Feldnamen verrät der Netzwerk-Tab bei einer manuellen Übermittlung.

response = session.post(api_url, json={
    "turnstileToken": token,
    "email": "[email protected]",
})

Muster 3: abweichender Feldname

Einzelne Frameworks benennen das Feld um oder erwarten es doppelt. Im Zweifel senden Sie beide Varianten – überzählige Felder ignoriert der Server meist.

# Some sites rename the field — check the form HTML
response = session.post(form_url, data={
    "cf-turnstile-response": token,
    "captcha_token": token,  # Custom duplicate field
    "action": "signup",
})

Produktionsreifer Solver mit Wiederholungslogik

Für den Dauerbetrieb braucht es Wiederholungen bei Timeouts und einen sofortigen Abbruch bei Schlüssel- oder Guthabenfehlern – ein aufgebrauchtes Guthaben wird nicht dreimal erneut versucht, ein Timeout dagegen schon.

import re
import time
import requests

class TurnstileSolver:
    """Production-ready Turnstile solver with retry logic."""

    API_URL = "https://ocr.captchaai.com"

    def __init__(self, api_key, max_retries=3):
        self.api_key = api_key
        self.max_retries = max_retries

    def extract_sitekey(self, session, url):
        """Extract Turnstile sitekey from page."""
        response = session.get(url, timeout=15)
        match = re.search(
            r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', response.text
        )
        return match.group(1) if match else None

    def solve(self, sitekey, page_url, action=None):
        """Solve Turnstile with retry logic. Returns token string."""
        for attempt in range(1, self.max_retries + 1):
            try:
                token = self._solve_once(sitekey, page_url, action)
                return token
            except TimeoutError:
                print(f"Attempt {attempt} timed out")
            except Exception as e:
                error_str = str(e)
                if "ERROR_ZERO_BALANCE" in error_str:
                    raise  # Don't retry billing errors
                if "ERROR_WRONG_USER_KEY" in error_str:
                    raise
                print(f"Attempt {attempt} failed: {e}")

        raise Exception(f"Failed after {self.max_retries} attempts")

    def _solve_once(self, sitekey, page_url, action=None):
        """Single solve attempt."""
        params = {
            "key": self.api_key,
            "method": "turnstile",
            "sitekey": sitekey,
            "pageurl": page_url,
            "json": 1,
        }
        if action:
            params["action"] = action

        submit = requests.post(f"{self.API_URL}/in.php", data=params, timeout=30)
        submit.raise_for_status()
        data = submit.json()

        if data.get("status") != 1:
            raise Exception(f"Submit error: {data.get('request')}")

        task_id = data["request"]

        for _ in range(30):
            time.sleep(5)
            result = requests.get(f"{self.API_URL}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1,
            }, timeout=30).json()

            if result.get("status") == 1:
                return result["request"]
            if result.get("request") == "ERROR_CAPTCHA_UNSOLVABLE":
                raise Exception("CAPTCHA unsolvable")

        raise TimeoutError("Poll timed out")


# Usage
solver = TurnstileSolver("YOUR_API_KEY")
token = solver.solve("0x4AAAAAAAC3DHQhMMQ_Rxrg", "https://example.com/signup")

Diese Klasse instanziieren Sie einmal pro Worker – etwa in einem nächtlichen GitLab-CI-Job oder einem Cron-Prozess auf einem Hetzner- oder netcup-Server.


Wie viele Threads brauchen Sie?

CaptchaAI rechnet pro gleichzeitigem Thread ab, nicht pro Lösung; jeder Plan enthält unbegrenzte Lösungen pro Thread. Ein Thread ist eine laufende Abfrage – ist sie fertig, nimmt derselbe Thread den nächsten Auftrag.

Rechenbeispiel: Ein Preismonitoring soll nachts 6.000 geschützte Produktseiten in zwei Stunden abarbeiten, als GitLab-CI-Job auf einem VPS. Kalkuliert mit 10 Sekunden je Lösung, schafft ein Thread etwa 720 Aufträge – macht gut acht Threads, mit Reserve für Wiederholungen also zehn. BASIC (15 $/Monat, 5 Threads) trägt Entwicklung und Testläufe, STANDARD (30 $/Monat, 15 Threads) deckt diesen Nachtlauf mit Puffer ab. Alle Preise verstehen sich in US-Dollar.

Dazu gehört ein Blick auf die Datenseite: IP-Adressen gelten nach DSGVO als personenbezogene Daten, und die Nutzungsbedingungen der Zielseite sind im deutschsprachigen Raum ein realer Faktor.


Häufige Fragen

Wie schnell löst CaptchaAI ein Turnstile-Token?

In der Regel in unter 10 Sekunden, mit hoher Erfolgsquote auf den unterstützten Typen. Planen Sie dennoch Wiederholungen ein – in Lastspitzen läuft ein Auftrag auch einmal in ein Timeout.

Spielt es eine Rolle, in welchem Modus das Widget läuft?

Nein. Managed, non-interactive und invisible nutzen denselben Aufruf mit method=turnstile; die Unterschiede behandelt CaptchaAI intern.

Wie lange bleibt ein Token gültig?

Nur kurz – Turnstile-Tokens sind bewusst kurzlebig. Lösen Sie unmittelbar vor dem Absenden und verwenden Sie ein Token nie ein zweites Mal.

Brauche ich zusätzlich einen Proxy?

Für die Lösung selbst nicht. Der Proxy betrifft Ihre eigenen Anfragen an die Zielseite: Wer viele Aufrufe aus einem Rechenzentrums-IP-Bereich absetzt, wird oft schon vor dem Widget geblockt. Residential-Proxys und ein moderates Anfragetempo helfen dort mehr als Änderungen am Solver.

Was kostet das bei größeren Volumen?

Abgerechnet wird pro Thread. BASIC (15 $/Monat, 5 Threads) reicht für Entwicklung und Tests, ADVANCE (90 $/Monat, 50 Threads) für ganze Crawl-Flotten – bei unbegrenzten Lösungen pro Thread sinken die Kosten je Token mit der Auslastung.


Fazit

Der Ablauf bleibt in jedem Projekt derselbe: Sitekey auslesen, Auftrag mit method=turnstile an CaptchaAI übermitteln, Token abfragen, als cf-turnstile-response absenden. Der Rest ist Betriebsdisziplin – gemeinsame Session, frisches Token pro Versuch, Wiederholungen nur bei Timeouts, passende Thread-Zahl.

Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.