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

Xử lý CAPTCHA để kiểm tra đăng ký khóa học đại học

Bộ test end-to-end của cổng đăng ký tín chỉ chết ở bước đăng nhập thường không phải vì selector hỏng, mà vì reCAPTCHA v2. Cách gọn nhất là để test tự lấy token: trích data-sitekey, gửi task tới in.php, polling res.php, rồi đặt token vào field g-recaptcha-response. Bài này đi qua bốn bước đó cho các kịch bản QA quen thuộc: đăng nhập, tìm học phần, đăng ký, danh sách chờ, chỗ trống. Ở Việt Nam, cổng đăng ký thường do đối tác outsourcing bàn giao và staging phản chiếu cấu hình production — tức CAPTCHA vẫn bật.

CAPTCHA nằm ở đâu trong luồng đăng ký tín chỉ

Trước khi viết code, hãy lập bản đồ bước nào có CAPTCHA và loại gì.

Bước đăng ký Loại CAPTCHA Mục đích
Đăng nhập sinh viên reCAPTCHA v2 Chặn credential stuffing
Tìm / duyệt học phần reCAPTCHA v2 Hạn chế tra cứu tự động
Đăng ký / hủy học phần reCAPTCHA v2 Chặn đăng ký tự động
Ghi danh danh sách chờ reCAPTCHA v2 Chặn bot
Công cụ xếp lịch Cloudflare Turnstile Bảo vệ tool
Yêu cầu bảng điểm reCAPTCHA v2 Xác minh người thật

reCAPTCHA v2 và Cloudflare Turnstile đều được CaptchaAI hỗ trợ, nên một API key phủ hết bảng trên.

Kịch bản test nào kích CAPTCHA nhiều nhất

Ưu tiên theo tần suất để khỏi phí thread.

Kịch bản test Tần suất CAPTCHA Ảnh hưởng
Đăng nhập nhiều tài khoản test Cao Mỗi lần đăng nhập một lần giải
Test đăng ký đồng thời Cao Cơ chế chống bot bật tối đa
Tìm học phần liên tục Trung bình Truy vấn dính rate limit
Sinh thời khóa biểu Thấp Gọn trong một phiên
Kiểm tra điều kiện tiên quyết Thấp Trong luồng đăng ký

Đây cũng là cơ sở chọn gói: CaptchaAI tính theo thread (CAPTCHA giải song song), không theo lượt giải. BASIC ($15/tháng, 5 thread) đủ cho regression đêm; ADVANCE ($90/tháng, 50 thread) hợp test tải đầu kỳ.

Bước 1–4: giải reCAPTCHA v2 ngay trong test (Python)

Class dưới gói bốn bước vào _solve_recaptcha() và tái dùng một requests.Session() cho mọi kịch bản.

import requests
import time
import re

class RegistrationTester:
    def __init__(self, api_key, portal_url):
        self.api_key = api_key
        self.portal_url = portal_url
        self.session = requests.Session()
        self.session.headers.update({
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
        })

    def test_login(self, student_id, password):
        """Test login flow with CAPTCHA handling."""
        login_page = self.session.get(f"{self.portal_url}/login")

        site_key = self._extract_site_key(login_page.text)
        token = self._solve_recaptcha(site_key, f"{self.portal_url}/login")

        response = self.session.post(f"{self.portal_url}/login", data={
            "studentId": student_id,
            "password": password,
            "g-recaptcha-response": token
        })

        return {
            "status": response.status_code,
            "redirected": response.url != f"{self.portal_url}/login",
            "success": "dashboard" in response.url or response.status_code == 200
        }

    def test_course_search(self, term, department, level=None):
        """Test course search returns correct results."""
        params = {"term": term, "dept": department}
        if level:
            params["level"] = level

        response = self.session.get(
            f"{self.portal_url}/courses/search", params=params
        )

        if self._has_captcha(response.text):
            site_key = self._extract_site_key(response.text)
            token = self._solve_recaptcha(
                site_key, f"{self.portal_url}/courses/search"
            )
            response = self.session.post(
                f"{self.portal_url}/courses/search",
                data={**params, "g-recaptcha-response": token}
            )

        courses = self._parse_courses(response.text)
        return {
            "count": len(courses),
            "courses": courses,
            "all_match_dept": all(
                c["department"] == department for c in courses
            )
        }

    def test_enrollment(self, course_id, section):
        """Test enrolling in a course section."""
        enroll_page = self.session.get(
            f"{self.portal_url}/enroll/{course_id}/{section}"
        )

        if self._has_captcha(enroll_page.text):
            site_key = self._extract_site_key(enroll_page.text)
            token = self._solve_recaptcha(
                site_key,
                f"{self.portal_url}/enroll/{course_id}/{section}"
            )
        else:
            token = None

        form_data = {
            "courseId": course_id,
            "section": section,
            "confirm": "true"
        }
        if token:
            form_data["g-recaptcha-response"] = token

        response = self.session.post(
            f"{self.portal_url}/enroll/submit",
            data=form_data
        )

        return {
            "status": response.status_code,
            "enrolled": "success" in response.text.lower(),
            "waitlisted": "waitlist" in response.text.lower(),
            "error": self._extract_error(response.text)
        }

    def test_seat_availability(self, course_ids):
        """Test seat availability display for multiple courses."""
        results = []
        for course_id in course_ids:
            response = self.session.get(
                f"{self.portal_url}/courses/{course_id}"
            )

            if self._has_captcha(response.text):
                site_key = self._extract_site_key(response.text)
                token = self._solve_recaptcha(
                    site_key, f"{self.portal_url}/courses/{course_id}"
                )
                response = self.session.post(
                    f"{self.portal_url}/courses/{course_id}",
                    data={"g-recaptcha-response": token}
                )

            availability = self._parse_availability(response.text)
            results.append({"course_id": course_id, **availability})

        return results

    def _has_captcha(self, html):
        return "g-recaptcha" in html or "recaptcha" in html.lower()

    def _extract_site_key(self, html):
        match = re.search(r'data-sitekey="([^"]+)"', html)
        if match:
            return match.group(1)
        raise ValueError("reCAPTCHA site key not found")

    def _solve_recaptcha(self, site_key, page_url):
        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": "userrecaptcha",
            "googlekey": site_key,
            "pageurl": page_url,
            "json": 1
        })
        task_id = resp.json()["request"]

        for _ in range(60):
            time.sleep(3)
            result = requests.get("https://ocr.captchaai.com/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": task_id,
                "json": 1
            })
            data = result.json()
            if data["status"] == 1:
                return data["request"]

        raise TimeoutError("reCAPTCHA solve timed out")

    def _parse_courses(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")

        def text_or_empty(node):
            return node.text.strip() if node and node.text else ""

        courses = []
        for row in soup.select(".course-row, tr.course"):
            courses.append({
                "id": row.get("data-course-id", ""),
                "department": text_or_empty(row.select_one(".dept")),
                "number": text_or_empty(row.select_one(".number")),
                "title": text_or_empty(row.select_one(".title")),
                "seats": text_or_empty(row.select_one(".seats"))
            })
        return courses

    def _parse_availability(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")

        def text_or_none(node):
            return node.text.strip() if node and node.text else None

        return {
            "total_seats": text_or_none(soup.select_one(".total-seats")),
            "enrolled": text_or_none(soup.select_one(".enrolled-count")),
            "available": text_or_none(soup.select_one(".available-seats")),
            "waitlist": text_or_none(soup.select_one(".waitlist-count"))
        }

    def _extract_error(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")
        error = soup.select_one(".error-message, .alert-danger")
        return error.text.strip() if error else None

Chú ý _has_captcha(): nhiều cổng chỉ bật CAPTCHA sau vài request liên tiếp, nên giải vô điều kiện là phí thread. Polling chờ tối đa 60 lần × 3 giây.

Gom các kịch bản thành một suite và xuất báo cáo (JavaScript)

Nếu bộ test đã ở Node.js (Playwright, Jest), bản dưới chạy tuần tự các kịch bản và trả report kèm tỷ lệ pass.

class RegistrationTestSuite {
  constructor(apiKey, portalUrl) {
    this.apiKey = apiKey;
    this.portalUrl = portalUrl;
    this.results = [];
  }

  async runAll(testCredentials) {
    const tests = [
      () => this.testLogin(testCredentials),
      () => this.testCourseSearch('Fall2025', 'CS'),
      () => this.testEnrollment('CS101', '001'),
      () => this.testDropCourse('CS101', '001'),
      () => this.testWaitlistFlow('CS201', '001')
    ];

    for (const test of tests) {
      try {
        const result = await test();
        this.results.push({ ...result, passed: true });
      } catch (error) {
        this.results.push({ name: test.name, error: error.message, passed: false });
      }
    }

    return this.generateReport();
  }

  async solveRecaptcha(pageUrl, html) {
    const match = html.match(/data-sitekey="([^"]+)"/);
    if (!match) return null;

    const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
      method: 'POST',
      body: new URLSearchParams({
        key: this.apiKey,
        method: 'userrecaptcha',
        googlekey: match[1],
        pageurl: pageUrl,
        json: '1'
      })
    });
    const { request: taskId } = await submitResp.json();

    for (let i = 0; i < 60; i++) {
      await new Promise(r => setTimeout(r, 3000));
      const result = await fetch(
        `https://ocr.captchaai.com/res.php?key=${this.apiKey}&action=get&id=${taskId}&json=1`
      );
      const data = await result.json();
      if (data.status === 1) return data.request;
    }
    throw new Error('reCAPTCHA solve timed out');
  }

  generateReport() {
    const passed = this.results.filter(r => r.passed).length;
    const failed = this.results.filter(r => !r.passed).length;

    return {
      total: this.results.length,
      passed,
      failed,
      passRate: `${((passed / this.results.length) * 100).toFixed(1)}%`,
      details: this.results
    };
  }
}

// Usage
const suite = new RegistrationTestSuite('YOUR_API_KEY', 'https://register.university.edu');
const report = await suite.runAll({ studentId: 'test123', password: 'testpass' });
console.log(`Tests: ${report.passed}/${report.total} passed (${report.passRate})`);

Khắc phục sự cố thường gặp

Vấn đề Nguyên nhân Cách xử lý
Mỗi test đăng nhập lại giải CAPTCHA Không tái dùng session Đăng nhập một lần, chạy cả nhóm test trong session
Đăng ký fail dù token hợp lệ CSRF token hết hạn Lấy CSRF token mới cùng lúc với token CAPTCHA
Dữ liệu test reset giữa các lần chạy Cổng tự dọn đăng ký thử Dùng môi trường và tài khoản test riêng
Không tìm thấy sitekey trong HTML SPA render CAPTCHA bằng JS Dùng Playwright đọc DOM sau khi render

Bộ test chạm dữ liệu sinh viên, nên Nghị định 13/2023/NĐ-CP là lý do đủ để chỉ dùng tài khoản test.

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

Giải CAPTCHA trong test có làm chậm pipeline CI không?

Có, mỗi lần giải tốn vài chục giây. Cách giảm: đăng nhập một lần rồi tái dùng cookie phiên, và chỉ giải khi _has_captcha() true.

Chạy test tải với 100 sinh viên ảo thì cần bao nhiêu thread?

Số thread cần bằng số CAPTCHA giải đồng thời ở đỉnh, không phải tổng test case. Nếu 20 phiên chạm form đăng nhập cùng lúc thì 20 thread là đủ, phần còn lại vào hàng đợi.

CaptchaAI có giải được hCaptcha nếu trường đổi sang loại đó không?

Không. CaptchaAI không hỗ trợ hCaptcha và FunCaptcha (Arkose Labs); GeeTest v4 mới sắp ra mắt. Các loại dùng được: reCAPTCHA v2/v3 (kể cả Enterprise), Cloudflare Turnstile và Challenge, GeeTest v3, CAPTCHA ảnh/OCR và dạng lưới.

Có nên xin trường tắt CAPTCHA trên staging thay vì giải?

Nếu xin được thì nên. Nhưng khi staging bắt buộc phản chiếu production, hoặc khi chính bước CAPTCHA nằm trong phạm vi kiểm thử, giải qua API giữ test trung thực mà không tắt lớp bảo vệ.

Bài viết liên quan

Bước tiếp theo

Chọn kịch bản đang fail (thường là đăng nhập) và cho nó tự lấy token. Lấy API key CaptchaAI rồi chạy lại CI: đăng nhập xanh thì phần còn lại chỉ lặp cùng một mẫu.

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