Прежде чем отправлять запрос в 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.