Explainers

Как обнаружить Cloudflare Turnstile и извлечь sitekey

Прежде чем отправлять запрос в CaptchaAI, нужно закрыть один вопрос: как виджет Cloudflare Turnstile встроен в страницу. Иногда data-sitekey лежит прямо в HTML, иногда его собирает turnstile.render() из JS-бандла, а иногда он не появляется, пока браузер не выполнит действие. От этого зависит инструмент — requests.get() или полноценный Selenium/Puppeteer.


Три способа внедрения Cloudflare Turnstile

Сайты встраивают Cloudflare Turnstile тремя способами, и каждый требует своего подхода к обнаружению:

Способ Как это работает Сложность обнаружения
HTML неявно <div class="cf-turnstile" data-sitekey="..."> прямо в исходном коде страницы Низкая (статический HTML)
JavaScript явно turnstile.render() вызывается из скрипта Средняя (нужен парсинг JS)
Динамическая загрузка Виджет подгружается после действия пользователя или XHR-запроса Высокая (нужно выполнение JS)

Часто задаваемые вопросы

Сколько времени занимает решение Cloudflare Turnstile после обнаружения sitekey?

Обычно укладывается в 10 секунд — это верхняя граница по SLA, а не среднее значение, так что закладывайте таймаут с запасом.

Может ли sitekey измениться при следующем визите на ту же страницу?

Да, оператор сайта может ротировать sitekey в любой момент. Извлекайте его заново при каждом запуске детектора, не хардкодьте.

Может ли сам процесс обнаружения быть заблокирован Cloudflare?

Да: слишком частые голые HTTP-запросы без реального браузера могут вернуть managed challenge вместо контента. Держите паузы между запросами и переключайтесь на Selenium/Puppeteer при сбоях.

Как отличить Cloudflare Turnstile от Cloudflare Challenge на этапе обнаружения?

По домену скрипта: Turnstile подключает challenges.cloudflare.com/turnstile и рендерит cf-turnstile. Cloudflare Challenge работает на уровне edge-сервера — без этого класса и без data-sitekey.

Отличается ли обнаружение для мобильной версии сайта?

Практически нет: разметка cf-turnstile/data-sitekey одинакова для десктопной и мобильной вёрстки. Но в мобильных SPA чаще встречается динамическая загрузка — сразу закладывайте Способ 3.


Способ 1: разбор статического HTML

Самая простая интеграция Cloudflare Turnstile использует класс cf-turnstile и атрибут data-sitekey. Если они есть в ответе сервера, дальше — обычный regex или BeautifulSoup:

import re
import requests

def detect_turnstile_html(url):
    """Detect Turnstile from static 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)
    html = response.text

    result = {
        "turnstile_found": False,
        "sitekey": None,
        "mode": None,
        "theme": None,
        "action": None,
        "script_loaded": False,
    }

    # Check for Turnstile script
    if "challenges.cloudflare.com/turnstile" in html:
        result["script_loaded"] = True

    # Check for widget container
    if "cf-turnstile" in html:
        result["turnstile_found"] = True

        # Extract sitekey
        sitekey_match = re.search(
            r'data-sitekey=["\']([0-9x][A-Za-z0-9_-]+)["\']', html
        )
        if sitekey_match:
            result["sitekey"] = sitekey_match.group(1)

        # Extract mode
        if 'data-size="invisible"' in html:
            result["mode"] = "invisible"
        elif 'data-appearance="interaction-only"' in html:
            result["mode"] = "non-interactive"
        else:
            result["mode"] = "managed"

        # Extract theme
        theme_match = re.search(r'data-theme=["\'](\w+)["\']', html)
        if theme_match:
            result["theme"] = theme_match.group(1)

        # Extract action
        action_match = re.search(r'data-action=["\']([^"\']+)["\']', html)
        if action_match:
            result["action"] = action_match.group(1)

    return result


# Usage
info = detect_turnstile_html("https://staging.example.com/qa-login")
if info["turnstile_found"]:
    print(f"Sitekey: {info['sitekey']}")
    print(f"Mode: {info['mode']}")

Способ 2: разбор вызовов JavaScript API

Часть сайтов не кладёт data-sitekey в HTML вообще, а вызывает turnstile.render() из подключённого скрипта — обычно так делают SPA и формы, собранные динамически:

import re

def detect_turnstile_js_api(html):
    """Detect Turnstile from JavaScript render calls."""
    patterns = [
        # turnstile.render('#element', {sitekey: '...'})
        r"turnstile\.render\s*\(\s*['\"]([^'\"]+)['\"]\s*,\s*\{([^}]+)\}",
        # turnstile.render(element, {sitekey: '...'})
        r"turnstile\.render\s*\([^,]+,\s*\{([^}]+)\}",
    ]

    for pattern in patterns:
        match = re.search(pattern, html, re.DOTALL)
        if match:
            config_text = match.group(match.lastindex)

            # Extract sitekey from config object
            sitekey_match = re.search(
                r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]", config_text
            )
            # Extract callback
            callback_match = re.search(
                r"callback\s*:\s*(\w+|function)", config_text
            )
            # Extract action
            action_match = re.search(
                r"action\s*:\s*['\"]([^'\"]+)['\"]", config_text
            )
            # Extract appearance
            appearance_match = re.search(
                r"appearance\s*:\s*['\"]([^'\"]+)['\"]", config_text
            )

            return {
                "found": True,
                "method": "javascript_api",
                "sitekey": sitekey_match.group(1) if sitekey_match else None,
                "callback": callback_match.group(1) if callback_match else None,
                "action": action_match.group(1) if action_match else None,
                "appearance": appearance_match.group(1) if appearance_match else None,
            }

    return {"found": False, "method": None}

Способ 3: динамическая загрузка через Selenium/Puppeteer

Когда Cloudflare Turnstile появляется на странице только после взаимодействия — клика по форме, скролла, XHR-запроса, — статический HTML и грепанье JS-файлов ничего не дадут. Нужен настоящий браузер, который дождётся рендеринга и уже потом прочитает DOM.

Python (Selenium)

from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import re

def detect_turnstile_dynamic(url):
    """Detect dynamically loaded Turnstile using Selenium."""
    options = webdriver.ChromeOptions()
    options.add_argument("--disable-blink-features=AutomationControlled")
    driver = webdriver.Chrome(options=options)

    try:
        driver.get(url)

        # Wait for page to fully load
        WebDriverWait(driver, 10).until(
            lambda d: d.execute_script("return document.readyState") == "complete"
        )

        result = {
            "turnstile_found": False,
            "sitekey": None,
            "iframe_present": False,
            "response_field": False,
        }

        # Check for Turnstile iframe
        iframes = driver.find_elements(By.CSS_SELECTOR, "iframe[src*='challenges.cloudflare.com']")
        if iframes:
            result["turnstile_found"] = True
            result["iframe_present"] = True

        # Check for cf-turnstile container
        containers = driver.find_elements(By.CSS_SELECTOR, ".cf-turnstile, [data-sitekey]")
        for container in containers:
            sitekey = container.get_attribute("data-sitekey")
            if sitekey:
                result["turnstile_found"] = True
                result["sitekey"] = sitekey

        # Check for hidden response field
        response_fields = driver.find_elements(
            By.CSS_SELECTOR, "[name='cf-turnstile-response'], [name='g-recaptcha-response']"
        )
        if response_fields:
            result["response_field"] = True

        # Check page source for JS API render
        page_source = driver.page_source
        js_match = re.search(
            r"sitekey\s*:\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]", page_source
        )
        if js_match and not result["sitekey"]:
            result["sitekey"] = js_match.group(1)
            result["turnstile_found"] = True

        return result

    finally:
        driver.quit()

Node.js (Puppeteer)

const puppeteer = require("puppeteer");

async function detectTurnstileDynamic(url) {
  const browser = await puppeteer.launch({
    headless: "new",
    args: ["--disable-blink-features=AutomationControlled"],
  });

  const page = await browser.newPage();

  const result = {
    turnstileFound: false,
    sitekey: null,
    iframePresent: false,
    responseField: false,
    scriptUrl: null,
  };

  // Monitor network for Turnstile script
  page.on("response", (response) => {
    if (response.url().includes("challenges.cloudflare.com/turnstile")) {
      result.scriptUrl = response.url();
    }
  });

  await page.goto(url, { waitUntil: "networkidle2" });

  // Check for Turnstile container
  const sitekey = await page.evaluate(() => {
    const el = document.querySelector(
      ".cf-turnstile, [data-sitekey]"
    );
    return el ? el.getAttribute("data-sitekey") : null;
  });

  if (sitekey) {
    result.turnstileFound = true;
    result.sitekey = sitekey;
  }

  // Check for Turnstile iframe
  const iframes = await page.$$("iframe[src*='challenges.cloudflare.com']");
  if (iframes.length > 0) {
    result.turnstileFound = true;
    result.iframePresent = true;
  }

  // Check for response field
  const responseField = await page.$(
    "[name='cf-turnstile-response']"
  );
  result.responseField = !!responseField;

  await browser.close();
  return result;
}

detectTurnstileDynamic("https://staging.example.com/qa-login").then(console.log);

Оба варианта ждут полной загрузки страницы, затем проверяют iframe с challenges.cloudflare.com, контейнер .cf-turnstile и поле cf-turnstile-response.


Особые случаи и типичные проблемы

Типичные грабли ручного обнаружения — до того, как всё это свернётся в один класс:

Ситуация Причина Что делать
Sitekey во внешнем JS-файле Нет в HTML страницы Разберите подключённые JS-файлы на те же паттерны
Sitekey из ответа API Появляется после XHR Ищите sitekey в JSON-ответах сетевых запросов
Несколько виджетов / извлёкся не тот sitekey У каждого свой sitekey Сопоставляйте с конкретной формой
Cloudflare Turnstile в shadow DOM Недоступен обычным селекторам shadowRoot.querySelector в контексте браузера
Виджета нет в исходном HTML Динамическая подгрузка Selenium/Puppeteer с полным рендерингом
Решение не проходит после обнаружения Сайт проверяет action Добавьте data-action в запрос
Поле cf-turnstile-response пустое Виджет ещё не закончил загрузку Дождитесь рендеринга виджета

TurnstileDetector: один класс на все три способа

Чтобы не выбирать метод вручную под каждый сайт, три подхода выше удобно объединить в один класс — он сам пробует статический HTML, затем паттерны JS API, и возвращает единый результат:

import re
import requests

class TurnstileDetector:
    """Detect Cloudflare Turnstile across all implementation methods."""

    TURNSTILE_SCRIPT = "challenges.cloudflare.com/turnstile"
    SITEKEY_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_-]+)['\"]",
        r"TURNSTILE_SITE_KEY\s*[=:]\s*['\"]([0-9x][A-Za-z0-9_-]+)['\"]",
    ]

    def __init__(self, url, html=None):
        self.url = url
        self.html = html
        if not self.html:
            self._fetch()

    def _fetch(self):
        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(self.url, headers=headers, timeout=15)
        self.html = response.text

    def detect(self):
        """Run all detection methods and return results."""
        return {
            "url": self.url,
            "turnstile_present": self.has_turnstile(),
            "sitekey": self.extract_sitekey(),
            "mode": self.detect_mode(),
            "implementation": self.detect_implementation(),
            "script_loaded": self.has_script(),
            "response_field": self.has_response_field(),
            "action": self.extract_action(),
            "theme": self.extract_theme(),
        }

    def has_turnstile(self):
        return (
            self.has_script()
            or "cf-turnstile" in self.html
            or self.extract_sitekey() is not None
        )

    def has_script(self):
        return self.TURNSTILE_SCRIPT in self.html

    def has_response_field(self):
        return "cf-turnstile-response" in self.html

    def extract_sitekey(self):
        for pattern in self.SITEKEY_PATTERNS:
            match = re.search(pattern, self.html)
            if match:
                return match.group(1)
        return None

    def detect_mode(self):
        if 'data-size="invisible"' in self.html or "size: 'invisible'" in self.html:
            return "invisible"
        if 'data-appearance="interaction-only"' in self.html:
            return "non-interactive"
        if "cf-turnstile" in self.html:
            return "managed"
        return "unknown"

    def detect_implementation(self):
        if "cf-turnstile" in self.html and re.search(r"data-sitekey=", self.html):
            return "html_implicit"
        if "turnstile.render" in self.html:
            return "javascript_explicit"
        if self.has_script() and not "cf-turnstile" in self.html:
            return "dynamic_loading"
        return "unknown"

    def extract_action(self):
        match = re.search(r'data-action=["\']([^"\']+)["\']', self.html)
        if match:
            return match.group(1)
        match = re.search(r"action\s*:\s*['\"]([^'\"]+)['\"]", self.html)
        return match.group(1) if match else None

    def extract_theme(self):
        match = re.search(r'data-theme=["\'](\w+)["\']', self.html)
        return match.group(1) if match else "auto"


# Usage
detector = TurnstileDetector("https://staging.example.com/qa-login")
info = detector.detect()

if info["turnstile_present"]:
    print(f"Sitekey: {info['sitekey']}")
    print(f"Mode: {info['mode']}")
    print(f"Implementation: {info['implementation']}")

Результат dynamic_loading из detect_implementation() — сигнал переключиться на Selenium/Puppeteer, а не пытаться выжать sitekey из статического ответа.


На практике: обнаружение перед пакетным решением

Команда, которая гоняет регресс-тесты checkout-формы в staging, заводит TurnstileDetector один раз в CI и переиспользует для всех форм. Для десятков прогонов в день хватает BASIC ($15/мес, 5 потоков); при параллельных прогонах — STANDARD ($30/мес, 15 потоков). Оба тарифа — фиксированная цена в USD.

Если детектор собирает со страницы что-то помимо sitekey — заранее решите, какие поля вообще нужны: для российской аудитории здесь уместна дисциплина 152-ФЗ «О персональных данных», для остальных — осторожность в духе GDPR.


Что делать после обнаружения: решение через CaptchaAI

Как только у вас есть sitekey, pageurl и, если он присутствует, параметр action, — этого достаточно, чтобы отправить задачу в CaptchaAI:

import requests
import time

API_KEY = "YOUR_API_KEY"

def solve_detected_turnstile(detection_result):
    """Solve Turnstile using detection results."""
    if not detection_result["turnstile_present"]:
        raise ValueError("No Turnstile detected")

    if not detection_result["sitekey"]:
        raise ValueError("Sitekey not found — may need browser-based extraction")

    params = {
        "key": API_KEY,
        "method": "turnstile",
        "sitekey": detection_result["sitekey"],
        "pageurl": detection_result["url"],
        "json": 1,
    }

    # Include action if present
    if detection_result.get("action"):
        params["action"] = detection_result["action"]

    submit = requests.post("https://ocr.captchaai.com/in.php", data=params)
    task_id = submit.json()["request"]

    for _ in range(60):
        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("Turnstile solve timed out")


# Full workflow
detector = TurnstileDetector("https://example.com/signup")
info = detector.detect()

if info["turnstile_present"]:
    token = solve_detected_turnstile(info)
    print(f"Token: {token[:50]}...")

action передаётся только если он реально найден на странице; если сайт его не проверяет, лишний параметр не помешает.


Итоги

Обнаружение Cloudflare Turnstile сводится к проверке трёх сигналов: скрипта challenges.cloudflare.com/turnstile, контейнера cf-turnstile с data-sitekey, и вызовов turnstile.render(). Для простых интеграций хватает статического HTML; для динамических виджетов — Selenium или Puppeteer. Дальше это уже задача для решателя Cloudflare Turnstile CaptchaAI.

Похожие статьи

Комментарии для этой статьи отключены.