答案先给出来:选课系统的 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,把识别接进现有测试套件,全链路跑通。