Phân Tích Kỹ Thuật

Phát hiện Cloudflare Turnstile: HTML, JS và tải động

Muốn giải Cloudflare Turnstile bằng API? Việc đầu tiên không phải gọi solver, mà là tìm ra sitekey — cách tìm phụ thuộc vào cách trang nhúng widget: thẳng vào HTML, qua JavaScript, hoặc chỉ tải sau khi người dùng bấm nút. Bài này đi qua cả ba kiểu, kèm code Python và Node.js chạy được ngay.


3 cách website nhúng Cloudflare Turnstile

Mỗi cách nhúng đòi hỏi một kỹ thuật phát hiện khác nhau:

  • HTML tĩnh<div class="cf-turnstile" data-sitekey="..."> nằm sẵn trong nguồn trang. Độ khó: dễ, parse HTML tĩnh là đủ.
  • JavaScript rõ ràngturnstile.render() được gọi trong script. Độ khó: trung bình, phải phân tích JS.
  • Tải động — widget chỉ xuất hiện sau hành động của người dùng hoặc sau một request XHR. Độ khó: cao, cần trình duyệt thực thi JS.

Cách 1: Phát hiện qua HTML tĩnh

Kiểu tích hợp đơn giản nhất dùng class cf-turnstile cùng thuộc tính data-sitekey. Chỉ cần fetch HTML và regex, không cần trình duyệt:

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']}")

Mẹo: turnstile_found = True kèm sitekey là đủ dữ liệu để gửi task — bỏ qua luôn Cách 2 và Cách 3.


Cách 2: Phát hiện qua JavaScript API

Một số trang không nhúng data-sitekey mà gọi turnstile.render() trực tiếp trong script, thường kèm callback xử lý token:

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}

Lưu ý: truyền toàn bộ HTML hoặc file JS liên quan vào hàm này. Regex thứ hai xử lý trường hợp element là biến thay vì chuỗi selector.


Cách 3: Phát hiện widget tải động (Selenium/Puppeteer)

Đây là kịch bản khó nhất: widget Turnstile không có sẵn khi tải trang, mà chỉ render sau một hành động của người dùng — ví dụ bấm nút "Đăng ký". Đây cũng là tình huống thường gặp khi đội QA tại công ty outsourcing ở TP.HCM hoặc Hà Nội kiểm thử luồng đăng ký trên staging: request tĩnh trả về HTML sạch, không có cf-turnstile ở đâu cả, vì widget chỉ xuất hiện sau khi trình duyệt thực thi JS. Lúc này bạn cần một trình duyệt thật.

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("")
    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: [""],
  });

  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);

Lưu ý: cả hai script đợi trang load xong (document.readyState, networkidle2) trước khi quét DOM — quét sớm hơn sẽ bỏ lỡ widget.


Class phát hiện tổng hợp: gộp cả 3 cách

Trong dự án thật, bạn hiếm khi biết trước trang dùng cách nào. Class dưới đây thử cả HTML tĩnh lẫn JS API trong một lần fetch, chỉ cần trình duyệt khi widget tải động:

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']}")

Mẹo: detect_implementation() trả "dynamic_loading" khi có script Turnstile nhưng thiếu cf-turnstile trong HTML — tín hiệu để chuyển sang Cách 3.


Giải Turnstile sau khi có sitekey

sitekeypageurl, bước tiếp theo là gửi task cho 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]}...")

Lưu ý: nếu HTML có data-action, luôn đính kèm nó trong params — thiếu tham số này là nguyên nhân phổ biến nhất khiến token bị site từ chối dù request tới CaptchaAI vẫn "thành công".


Trường hợp đặc biệt và lỗi thường gặp

Hai nhóm vấn đề bạn sẽ gặp khi phát hiện Turnstile trên các trang thực tế: cách nhúng khác thường, và lỗi phát sinh trong lúc detect/giải.

Trường hợp đặc biệt cần lưu ý

  • Sitekey trong file JS ngoài (không có trong HTML trang) — parse các file JS liên kết để tìm mẫu sitekey.
  • Sitekey trả về từ response API (chỉ xuất hiện sau XHR) — theo dõi network, tìm sitekey trong JSON response.
  • Nhiều widget Turnstile cùng trang (mỗi widget một sitekey) — khớp sitekey với đúng form đang submit.
  • Turnstile trong shadow DOM (selector thường không truy cập được) — dùng shadowRoot.querySelector trong ngữ cảnh trình duyệt.
  • Sitekey render phía server (nhúng trong biến template) — kiểm tra thẻ <script> để tìm object cấu hình.
  • Turnstile chỉ hiện sau đăng nhập (không thấy trên trang public) — đăng nhập trước, rồi mới chạy detector.

Lỗi thường gặp khi phát hiện Turnstile

Triệu chứng Nguyên nhân Cách xử lý
Thấy script tag nhưng không có sitekey JS render config lấy từ nguồn khác Kiểm tra toàn bộ file JS liên kết và các XHR response
Trích xuất sai sitekey Trang có nhiều widget CAPTCHA Khớp sitekey với các phần tử form xung quanh nó
Phát hiện được nhưng giải thất bại Site yêu cầu tham số action để xác thực Đính kèm giá trị data-action vào request giải
Widget không có trong HTML ban đầu Tải động sau tương tác người dùng Chuyển sang Selenium/Puppeteer, render toàn trang
Trường cf-turnstile-response rỗng Widget chưa hoàn tất Đợi widget load xong rồi mới đọc trường này

Câu hỏi thường gặp

Năm câu hỏi dev hay hỏi nhất khi mới bắt tay vào phát hiện Turnstile:

Sitekey Turnstile trông như thế nào?

Luôn bắt đầu bằng 0x theo sau là chuỗi ký tự chữ-số, ví dụ 0x4AAAAAAAxxxxxxxxxxxxxx. Không có tiền tố 0x — nhiều khả năng đó là sitekey reCAPTCHA hoặc GeeTest.

Có thể phát hiện Turnstile chỉ bằng requests, không cần Selenium?

Có, nhưng chỉ khi trang dùng HTML tĩnh hoặc JS API rõ ràng (Cách 1, Cách 2). Nếu widget chỉ xuất hiện sau khi bấm nút, requests trả về HTML "sạch" không dấu vết Turnstile — lúc đó cần trình duyệt thật.

Sitekey có thể thay đổi theo thời gian không?

Có. Chủ site có thể xoay sitekey bất cứ lúc nào. Luôn trích sitekey mới từ trang thay vì hardcode giá trị cố định.

Có thể tự động hoá phát hiện trên nhiều URL cùng lúc không?

Có. Lặp qua danh sách URL và gọi TurnstileDetector cho từng URL — số lượng detect chạy song song phụ thuộc vào số thread ở tầng ứng dụng của bạn, không bị CaptchaAI giới hạn.

Cách phát hiện có ảnh hưởng tới việc giải không?

Không. Solver Turnstile của CaptchaAI xử lý như nhau bất kể widget nhúng theo cách nào — chỉ cần đúng sitekey và pageurl.


Tóm tắt

Phát hiện Cloudflare Turnstile nghĩa là kiểm tra tuần tự: script tag Turnstile, container cf-turnstile, thuộc tính data-sitekey, và lệnh gọi turnstile.render(). Dùng parse HTML tĩnh cho tích hợp đơn giản, chuyển sang Selenium/Puppeteer khi widget tải động. Có sitekey rồi, gửi cho solver Cloudflare Turnstile của CaptchaAI — cả ba cách nhúng đều xử lý theo cùng một luồng, tỷ lệ giải thành công cao trên các loại được hỗ trợ.

Bài viết liên quan

Os comentários estão desativados para este artigo.