Trường Hợp Sử Dụng

Tự động hóa thanh toán nhiều bước với tính năng giải CAPTCHA

Một luồng checkout TMĐT điển hình đi qua 5 bước: giỏ hàng → giao hàng → thanh toán → xác nhận, và CAPTCHA có thể "phục kích" ở bất kỳ bước nào — không cố định một chỗ. Đội QA kiểm thử checkout nội bộ trên staging (quen thuộc tại các sàn TMĐT như Shopee, Tiki, Lazada) sẽ thấy script đứng im ngay tại bước có CAPTCHA nếu không xử lý được. Bài này build class Python CheckoutAutomator tự phát hiện và giải CAPTCHA bằng CaptchaAI ở bất kỳ bước nào, thay vì viết logic riêng cho từng trang.


CAPTCHA xuất hiện ở đâu trong luồng checkout?

Trước khi code, xem CAPTCHA thường rơi vào bước nào:

Giai đoạn Loại CAPTCHA thường gặp Nguyên nhân kích hoạt
Thêm vào giỏ hàng reCAPTCHA v3 (vô hình) Chặn bot
Đăng nhập / checkout khách reCAPTCHA v2 Xác minh tài khoản
Địa chỉ giao hàng Cloudflare Turnstile Giới hạn tần suất request
Trang thanh toán reCAPTCHA v2/v3 Chống gian lận
Xác nhận đơn hàng reCAPTCHA v2 Xác nhận cuối cùng

Vì vị trí không cố định, kiểm tra CAPTCHA ở cả đầu và cuối mỗi bước thay vì đoán trước. Với các đội outsourcing chạy QA cho nhiều client TMĐT cùng lúc — mô hình phổ biến ở các công ty phần mềm tại TP.HCM và Hà Nội — một hàm dùng chung như vậy quan trọng hơn nữa, vì mỗi client có thể đổi vị trí CAPTCHA sau mỗi lần cập nhật giao diện checkout mà không báo trước.


Sơ đồ luồng 5 bước

Class dưới đây bọc từng bước checkout trong một hàm riêng, kiểm tra CAPTCHA ở cả điểm vào lẫn điểm ra:

┌────────┐    ┌───────────┐    ┌──────────┐    ┌─────────┐    ┌──────────┐
│  Cart  │───▶│  Shipping │───▶│ Payment  │───▶│ Review  │───▶│ Confirm  │
│        │    │  Address  │    │  Info    │    │  Order  │    │          │
└────────┘    └───────────┘    └──────────┘    └─────────┘    └──────────┘
     │              │               │               │              │
     ▼              ▼               ▼               ▼              ▼
  [CAPTCHA?]    [CAPTCHA?]     [CAPTCHA?]      [CAPTCHA?]     [CAPTCHA?]

Class CheckoutAutomator: xử lý cả 5 bước bằng một hàm dùng chung

_solve_if_captcha() là trung tâm: quét DOM tìm .cf-turnstile hoặc [data-sitekey], phân biệt v2/v3 qua grecaptcha.execute, gửi task qua in.php, polling res.php tới khi có token, rồi bơm token vào field ẩn bằng execute_script. Mỗi hàm bước — add_to_cart, fill_shipping, fill_payment, confirm_order — chỉ gọi lại hàm này trước khi submit:

import time
import requests
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

class CaptchaSolver:
    BASE = "https://ocr.captchaai.com"

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

    def solve(self, params, initial_wait=10):
        params["key"] = self.api_key
        params["json"] = 1
        resp = requests.post(f"{self.BASE}/in.php", data=params).json()
        if resp["status"] != 1:
            raise Exception(resp["request"])
        task_id = resp["request"]
        time.sleep(initial_wait)
        for _ in range(60):
            result = requests.get(
                f"{self.BASE}/res.php",
                params={"key": self.api_key, "action": "get", "id": task_id, "json": 1},
            ).json()
            if result["request"] == "CAPCHA_NOT_READY":
                time.sleep(5)
                continue
            if result["status"] == 1:
                return result["request"]
            raise Exception(result["request"])
        raise TimeoutError("Timed out")

class CheckoutAutomator:
    def __init__(self, api_key):
        self.solver = CaptchaSolver(api_key)
        self.driver = webdriver.Chrome()
        self.wait = WebDriverWait(self.driver, 15)

    def _find(self, selector):
        return self.wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, selector)))

    def _click(self, selector):
        self.wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, selector))).click()

    def _fill(self, selector, value):
        el = self._find(selector)
        el.clear()
        el.send_keys(value)

    def _solve_if_captcha(self):
        """Check for and solve any CAPTCHA on the current page."""
        html = self.driver.page_source
        page_url = self.driver.current_url

        # Turnstile
        turnstile = self.driver.find_elements(By.CSS_SELECTOR, ".cf-turnstile")
        if turnstile:
            sitekey = turnstile[0].get_attribute("data-sitekey")
            token = self.solver.solve({
                "method": "turnstile",
                "sitekey": sitekey,
                "pageurl": page_url,
            })
            self.driver.execute_script(
                f'document.querySelector("[name=cf-turnstile-response]").value = "{token}";'
            )
            return "turnstile"

        # reCAPTCHA
        recaptcha = self.driver.find_elements(By.CSS_SELECTOR, "[data-sitekey]")
        if recaptcha and "recaptcha" in html.lower():
            sitekey = recaptcha[0].get_attribute("data-sitekey")

            # Check if v3 (invisible)
            if "grecaptcha.execute" in html:
                token = self.solver.solve({
                    "method": "userrecaptcha",
                    "googlekey": sitekey,
                    "pageurl": page_url,
                    "version": "v3",
                    "action": "checkout",
                }, initial_wait=20)
            else:
                token = self.solver.solve({
                    "method": "userrecaptcha",
                    "googlekey": sitekey,
                    "pageurl": page_url,
                })

            self.driver.execute_script(
                f'document.querySelector("[name=g-recaptcha-response]").value = "{token}";'
            )
            return "recaptcha"

        return None

    # --- Checkout Steps ---

    def add_to_cart(self, product_url, quantity=1):
        """Step 1: Navigate to product and add to cart."""
        self.driver.get(product_url)
        time.sleep(2)

        # Set quantity if field exists
        qty_fields = self.driver.find_elements(By.CSS_SELECTOR, "input[name='quantity']")
        if qty_fields:
            qty_fields[0].clear()
            qty_fields[0].send_keys(str(quantity))

        self._solve_if_captcha()
        self._click("[data-action='add-to-cart'], .add-to-cart, #add-to-cart")
        time.sleep(2)
        return True

    def fill_shipping(self, address):
        """Step 2: Fill shipping address."""
        self._solve_if_captcha()

        field_map = {
            "first_name": "#shipping-first-name, [name='firstName']",
            "last_name": "#shipping-last-name, [name='lastName']",
            "address": "#shipping-address, [name='address1']",
            "city": "#shipping-city, [name='city']",
            "state": "#shipping-state, [name='state']",
            "zip": "#shipping-zip, [name='postalCode']",
            "phone": "#shipping-phone, [name='phone']",
        }

        for field, selectors in field_map.items():
            if field in address:
                for selector in selectors.split(", "):
                    elements = self.driver.find_elements(By.CSS_SELECTOR, selector)
                    if elements:
                        elements[0].clear()
                        elements[0].send_keys(address[field])
                        break

        self._solve_if_captcha()
        self._click("[data-step='shipping-submit'], .continue-to-payment")
        time.sleep(2)
        return True

    def fill_payment(self, payment):
        """Step 3: Fill payment information."""
        self._solve_if_captcha()

        # Handle iframe for card fields (common pattern)
        iframes = self.driver.find_elements(By.CSS_SELECTOR, "iframe[name*='card']")
        if iframes:
            self.driver.switch_to.frame(iframes[0])
            self._fill("input[name='cardnumber']", payment["card_number"])
            self.driver.switch_to.default_content()

            if len(iframes) > 1:
                self.driver.switch_to.frame(iframes[1])
                self._fill("input[name='exp-date']", payment["expiry"])
                self.driver.switch_to.default_content()

            if len(iframes) > 2:
                self.driver.switch_to.frame(iframes[2])
                self._fill("input[name='cvc']", payment["cvv"])
                self.driver.switch_to.default_content()
        else:
            # Direct fields
            self._fill("[name='cardNumber'], #card-number", payment["card_number"])
            self._fill("[name='expiry'], #card-expiry", payment["expiry"])
            self._fill("[name='cvv'], #card-cvv", payment["cvv"])

        self._solve_if_captcha()
        self._click("[data-step='payment-submit'], .continue-to-review")
        time.sleep(2)
        return True

    def confirm_order(self):
        """Step 4: Review and confirm order."""
        self._solve_if_captcha()
        self._click("[data-step='confirm'], .place-order, #place-order")
        time.sleep(3)

        # Check for confirmation
        html = self.driver.page_source.lower()
        if "order confirmed" in html or "thank you" in html or "confirmation" in html:
            return True
        return False

    def run_checkout(self, product_url, address, payment, quantity=1):
        """Run complete checkout flow."""
        steps = [
            ("Add to cart", lambda: self.add_to_cart(product_url, quantity)),
            ("Proceed to checkout", lambda: self._click(".checkout-btn, a[href*='checkout']")),
            ("Fill shipping", lambda: self.fill_shipping(address)),
            ("Fill payment", lambda: self.fill_payment(payment)),
            ("Confirm order", lambda: self.confirm_order()),
        ]

        for step_name, step_fn in steps:
            print(f"Step: {step_name}...")
            try:
                result = step_fn()
                print(f"  ✓ {step_name} complete")
            except Exception as e:
                print(f"  ✗ {step_name} failed: {e}")
                return False
        return True

    def close(self):
        self.driver.quit()

Ví dụ chạy: checkout đầy đủ từ giỏ hàng đến xác nhận

Gọi run_checkout() với product_url, địa chỉ giao hàng và thông tin thanh toán — class tự lo phần CAPTCHA, không cần can thiệp thêm:

automator = CheckoutAutomator("YOUR_API_KEY")

try:
    success = automator.run_checkout(
        product_url="https://store.example.com/product/widget-pro",
        address={
            "first_name": "Jane",
            "last_name": "Smith",
            "address": "123 Test Street",
            "city": "San Francisco",
            "state": "CA",
            "zip": "94102",
            "phone": "415-555-0100",
        },
        payment={
            "card_number": "4111111111111111",
            "expiry": "12/26",
            "cvv": "123",
        },
        quantity=1,
    )
    print(f"Checkout {'succeeded' if success else 'failed'}")
finally:
    automator.close()

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

Vấn đề Nguyên nhân Cách xử lý
Token bị từ chối lúc thanh toán Token hết hạn trước khi submit Giải ngay trước khi nhấn gửi, không để dành
Trường thẻ không điền được Trường nằm trong iframe Stripe/Braintree Chuyển đúng iframe trước khi gọi _fill
Checkout chuyển hướng về đăng nhập Phiên (session) đã hết hạn Thêm bước đăng nhập lại trước checkout
Đơn hàng fail âm thầm Thiếu trường bắt buộc Kiểm tra lỗi sau mỗi bước, đừng chỉ tin return True

Xử lý các tình huống đặc biệt

CAPTCHA bật lại sau khi validate lỗi

Một số form hiển thị CAPTCHA mới sau submit thất bại. Bọc bước đó trong logic retry:

def retry_step_with_captcha(self, step_fn, max_retries=2):
    for attempt in range(max_retries + 1):
        try:
            return step_fn()
        except Exception:
            if attempt < max_retries:
                self._solve_if_captcha()
                continue
            raise

CAPTCHA chỉ xuất hiện ở request đầu phiên

Nhiều trang chỉ hiển thị CAPTCHA một lần mỗi phiên. Giữ nguyên cookie trình duyệt giữa các bước để tránh giải lại nhiều lần. Nếu script log lại dữ liệu địa chỉ hoặc thông tin thanh toán sandbox trong quá trình test, hãy giới hạn thời gian lưu và xoá log định kỳ — nguyên tắc tối thiểu hoá dữ liệu này phù hợp với Nghị định 13/2023/NĐ-CP về bảo vệ dữ liệu cá nhân, dù script chỉ chạy nội bộ trên staging.

Trường form load động (lazy-loaded)

Một số trang render lại field bằng JavaScript sau khi chuyển bước. Chờ một selector chỉ báo trước khi thao tác:

def wait_for_step(self, indicator_selector, timeout=15):
    """Wait for a step to fully load before proceeding."""
    WebDriverWait(self.driver, timeout).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, indicator_selector))
    )

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

CaptchaAI có giải được CAPTCHA ở trang thanh toán không?

Có — reCAPTCHA v2, reCAPTCHA v3 và Cloudflare Turnstile, ba loại xuất hiện nhiều nhất ở các bước trong ví dụ này.

Test checkout mà không cần thanh toán thật có được không?

Được. Dùng số thẻ sandbox như 4111111111111111; CaptchaAI giải cùng loại CAPTCHA đó trên môi trường test.

Có cần gọi _solve_if_captcha() ở mọi bước không?

Nên, vì vị trí CAPTCHA không cố định (xem bảng đầu bài). Hàm chỉ trả về None khi không có CAPTCHA.

Checkout một trang thì xử lý sao?

Gộp mọi field vào một trang, chỉ gọi _solve_if_captcha() một lần trước khi submit lần cuối.

Làm sao tránh bị từ chối token vì hết hạn?

Luôn giải ngay trước submit, không cache token để dùng sau — nguyên nhân phổ biến nhất trong bảng lỗi ở trên.


Hướng dẫn liên quan


Kiểm thử checkout tự động — giải CAPTCHA từng bước với CaptchaAI.

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