Use Cases

大学课程注册测试的验证码处理

答案先给出来:选课系统的 reCAPTCHA v2 不用人工点图。脚本提取 sitekey,交给识别接口换回 g-recaptcha-response,随表单一起 POST 即可。

选课高峰前的回归清单最长:登录、查询、加退课、候补名单,而这些页面几乎都挂了人机验证,脚本跑到第二步就停住。

教务系统里验证码通常挂在哪几个环节

国际院校门户以 reCAPTCHA v2 为主;国内自建教务系统更常见 GeeTest(极验)滑块,思路一致,换成 GeeTest v3 参数即可。

注册环节 常见验证码类型 站方目的
学生登录页 reCAPTCHA v2 防止撞库
课程查询 reCAPTCHA v2 限制高频查询
加退课 reCAPTCHA v2 防止脚本选课
候补登记 reCAPTCHA v2 拦截自动登记
排课工具 Cloudflare Turnstile 保护交互页面
成绩单申请 reCAPTCHA v2 确认真人

用 CaptchaAI API 打通注册测试主流程

识别封装在 _solve_recaptcha,四个用例复用同一个 requests.Session,可减少验证码次数:

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

JavaScript 版测试套件调度器

Node.js 侧的调度器依次跑五条链路,遇验证码就调 solveRecaptcha 换 token,汇总通过率:

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

哪些用例最容易频繁触发验证码

跑法不同,频率差别很大,按下表估算并发:

测试场景 验证码触发频率 说明
多账号登录 每次登录都会弹出
并发选课压测 策略对并发最敏感
高频查询 限流后开始出现
排课工具交互 单会话验一次

按并发量选套餐

CaptchaAI 按线程计费,不限识别次数,决定套餐的是并发数:

  • 串行跑冒烟用例:BASIC($15/月,5 线程)
  • 40 个会话并行做预发布回归:ADVANCE($90/月,50 线程)
  • CaptchaAI 不支持 hCaptcha 和 FunCaptcha,GeeTest v4 仍是“即将支持”

常见故障与排查

现象 原因 处理方式
每个用例都要识别一次 没有复用会话 登录一次,同一 session 跑完整组
识别成功但选课失败 CSRF token 过期 取验证码 token 时同步刷新 CSRF token
两次运行数据对不上 门户清理了选课记录 固定专用测试环境与学号
抓不到 sitekey SPA 动态渲染 用浏览器自动化取渲染后的 data-sitekey

常见问题

CI 流水线里怎么接入验证码识别?

把 API Key 放进 CI 的密钥变量,用例检测到验证码就调接口换 token,再继续原流程,不必关掉门户的安全策略。

一次识别要等多久?

reCAPTCHA v2 一般十几秒到几十秒返回。每 3–5 秒轮询一次并设超时上限。拖时间的是串行等待,改并发并配足线程即可。

国内网络环境下测试要注意什么?

reCAPTCHA 依赖 Google 域名下的脚本,部分网络环境加载不稳定,“页面打不开”未必是脚本的问题。识别接口走 in.php / res.php,与页面能否渲染是两件事。

识别超时了,用例该怎么写?

当成可重试的外部依赖异常,而非断言失败。加两次重试并做指数退避,仍失败就归类为环境问题,报告里的“失败”才对应真实缺陷。

相关文章

下一步

获取 CaptchaAI API Key,把识别接进现有测试套件,全链路跑通。

该文章已禁用评论。