Hướng Dẫn Thực Hành

reCAPTCHA trong các ứng dụng trang đơn: Kiểu tải động

Script Playwright chạy tốt trên trang server-render rồi báo "element not found" ngay khi giao diện chuyển sang React — đó là reCAPTCHA tải động: widget chỉ tồn tại sau khi component mount.

Cách xử lý: chờ DOM render xong, lấy sitekey từ phần tử thật, gửi sang CaptchaAI để giải, rồi trả token về nơi ứng dụng đọc — thường là data-callback, không phải textarea.

Bốn bước xử lý reCAPTCHA tải động

  1. Kích hoạt hành động làm widget xuất hiện: bấm nút hoặc đổi route.
  2. Chờ iframe render rồi lấy sitekey từ src, data-sitekey hoặc bundle JS.
  3. Gửi sitekey và pageurl tới in.php, polling res.php tới khi có token.
  4. Gán token hoặc gọi data-callback, sau đó mới submit.

Bước 4 là chỗ phần lớn script hỏng.

SPA khác trang truyền thống ở đâu

Trang server-render SPA
Script reCAPTCHA có sẵn trong HTML Script chèn sau khi đổi route
Widget render khi tải trang Widget render khi component mount
Sitekey trong page source Sitekey trong bundle JavaScript
Form submit bằng POST Form submit bằng XHR/fetch

Khác biệt nằm ở thời điểm render, không nằm ở bản thân widget.

Tình huống thường gặp ở đội QA Việt Nam

Một team QA ở TP.HCM nhận bàn giao luồng thanh toán viết bằng Vue: bộ test hồi quy chạy đêm luôn fail ở bước cuối, vì script cũ vẫn đọc sitekey từ page source. Vài workload hay rơi vào đúng tình huống này:

  • Kiểm thử hồi quy đăng nhập và checkout trên staging của khách hàng.
  • Giám sát dữ liệu công khai cho chính danh mục sản phẩm của bạn.

Gói BASIC ($15/tháng, 5 thread) đủ cho vài luồng test song song — thread là số CAPTCHA giải đồng thời.

Bước 1: chờ widget reCAPTCHA xuất hiện

Chờ đúng phần tử, không phải chờ tải trang. Đoạn Playwright dưới đây kích hoạt form rồi chờ iframe reCAPTCHA.

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://staging.example.com/qa-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"))

Nếu selector timeout, thường là hành động kích hoạt chưa đúng.

Bước 2: lấy sitekey khi page source không có

Sitekey có thể nằm ở ba nơi: iframe src, thuộc tính data-sitekey, hoặc bundle JavaScript.

# 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;
    }
""")

Sitekey reCAPTCHA luôn bắt đầu bằng 6L.

Bước 3: gửi sitekey tới CaptchaAI

Hàm dưới đây gửi task tới in.php rồi polling res.php mỗi 3 giây.

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

Token reCAPTCHA hết hạn sau khoảng hai phút, nên hãy giải sát thời điểm submit.

Bước 4: đưa token vào đúng chỗ trong SPA

SPA không submit form theo kiểu cổ điển, nên cách gán token phải khớp với cách ứng dụng đọc giá trị.

Cách 1: ghi thẳng vào textarea g-recaptcha-response

token = solve_recaptcha(site_key, "https://staging.example.com/qa-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")

Cách này chỉ ổn khi form submit qua thẻ <form>; React và Vue sẽ xóa giá trị ở lần render kế.

Cách 2: gọi hàm data-callback

Đây là cách ổn định nhất: bạn báo cho ứng dụng biết đã có token thay vì sửa DOM sau lưng nó.

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

Cách 3: chặn XHR bằng Puppeteer

Khi form gửi dữ liệu qua fetch tới endpoint REST, bạn can thiệp ở tầng request.

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://staging.example.com/qa-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);
}

Mở tab Network để biết ứng dụng gửi token trong field nào.

Mẫu tích hợp theo từng framework

Mỗi framework trì hoãn render theo cách riêng.

React (react-google-recaptcha)

Component render bất đồng bộ; chờ trạng thái attached rồi đọc dataset.

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

Vue thường dùng v-if: widget chỉ tồn tại sau một tương tác cụ thể.

# 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

Angular hay lazy-load module reCAPTCHA theo route.

# Angular apps may lazy-load reCAPTCHA modules
# Wait for the specific Angular component
page.wait_for_selector("re-captcha, app-recaptcha, [data-sitekey]")

Xử lý khi SPA đổi route

Điều hướng trong SPA không tải lại trang, nên tham chiếu phần tử cũ thành vô nghĩa.

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

Sau mỗi lần đổi route hãy lấy lại sitekey — mỗi trang có thể dùng sitekey khác.

Lỗi thường gặp và cách xử lý

Triệu chứng Nguyên nhân Cách xử lý
Không thấy g-recaptcha-response Widget chưa render Chờ bằng wait_for_selector
Gán token xong form không submit Callback chưa được gọi Gọi hàm trong data-callback
Sitekey không có trong page source Nằm trong bundle JS Đọc từ iframe src sau khi render
Token dùng một lần rồi hỏng Component render lại Gán token ngay trước khi submit
Widget render lại sau khi gán Reactivity ghi đè giá trị Dùng data-callback

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

Token bị xóa mỗi lần component render lại thì xử lý thế nào?

Đừng ghi vào textarea nữa. Gọi data-callback để ứng dụng lưu token vào state của nó; state sống qua các lần render.

Có bắt buộc chạy trình duyệt thật không?

Không, nếu bạn đã biết sitekey và endpoint mà form gọi. Lần đầu vẫn cần trình duyệt để tìm ra chúng.

Vì sao token hợp lệ mà nút submit vẫn bị disable?

Vì nút khóa theo state của form chứ không theo DOM. Sau khi gọi callback, hãy chờ button:not([disabled]) thay vì chờ cứng vài giây.

Cần bao nhiêu thread cho một bộ test chạy song song?

Bằng số trình duyệt cùng lúc cần giải CAPTCHA, không phải tổng số test case. Chạy 5 worker song song thì 5 thread là đủ.

Bài viết liên quan

Bước tiếp theo

Áp dụng bốn bước này cho mọi SPA — lấy API key CaptchaAI và chạy thử trên staging.

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