Сквозные тесты студенческого портала почти всегда останавливаются в одной и той же точке — на форме входа с reCAPTCHA v2. Разблокировать сценарий без ручных кликов можно одним способом: тест забирает со страницы sitekey, отправляет его вместе с адресом страницы в API CaptchaAI, дожидается токена и подставляет его в поле g-recaptcha-response рядом с остальными полями формы. Дальше сценарий продолжается как обычно.
Ниже — карта мест, где вуз ставит проверку, Python-класс для тестов записи, расчёт потоков на пиковую неделю и разбор типичных сбоев.
Почему тесты записи на курсы рвутся чаще других
Портал регистрации — это связка из авторизации, каталога, конструктора расписания и очереди листа ожидания. QA-набор задевает сразу все четыре узла:
- Пик совпадает с прогоном. В регистрационное окно портал включает более жёсткие лимиты, и проверка появляется там, где в спокойный период её не было.
- Сессия не переживает сценарий. Тест логинится заново на каждом шаге, и каждый вход — это ещё одна задача CAPTCHA.
- Виджет отрисовывается скриптом. На SPA-порталах
sitekeyотсутствует в исходном HTML, и регулярное выражение по ответу сервера ничего не находит. - CSRF-токен живёт меньше, чем решение. Пока задача решается, страница успевает «протухнуть», и запись падает уже после полученного токена.
Карта проверок: где портал вуза ставит CAPTCHA
| Шаг сценария | Тип проверки | Зачем она там стоит |
|---|---|---|
| Вход в личный кабинет студента | reCAPTCHA v2 | Защита от перебора учётных данных |
| Поиск и просмотр каталога курсов | reCAPTCHA v2 | Ограничение массовых запросов к каталогу |
| Запись на курс и отказ от него | reCAPTCHA v2 | Защита от автоматической записи |
| Постановка в лист ожидания | reCAPTCHA v2 | Отсечение ботов, занимающих места |
| Конструктор расписания | Cloudflare Turnstile | Защита интерактивного виджета |
| Заказ справок и выписок | reCAPTCHA v2 | Подтверждение живого пользователя |
Что согласовать до первого прогона
Портал вуза — чужая система, даже если вы работаете в его ИТ-службе. Зафиксируйте три вещи до запуска.
Первое — письменное разрешение владельца системы и окно, в которое контур можно нагружать: в вузах Москвы, Минска или Алматы это решает управление информатизации, а не команда QA. Второе — выделенные тестовые учётные записи и по возможности отдельный контур, на котором записи чистятся между прогонами. Третье — состав данных: в логи не должны попадать ФИО, номера зачётных книжек и телефоны реальных студентов. Требование 152-ФЗ «О персональных данных» здесь совпадает с инженерной гигиеной — храните только то, что вы вправе обрабатывать.
API-ключ берётся один раз в панели управления. Если вы ещё не отправляли задачу, пройдите быстрый старт CaptchaAI — цикл «отправить задачу → получить токен» укладывается в несколько минут.
Python-класс для сквозных тестов регистрации
Класс ниже держит одну requests.Session на весь набор, поэтому cookie авторизации переиспользуются и проверка запрашивается заметно реже. Каждый тестовый метод сначала смотрит, есть ли на странице виджет, и обращается к решателю только тогда, когда он действительно появился. Отправка идёт на in.php с методом userrecaptcha, результат забирается опросом res.php; подробный разбор параметров — в руководстве как решить reCAPTCHA v2 через API.
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
Обратите внимание на три места. _extract_site_key падает с осмысленной ошибкой, а не возвращает None — упавший тест сразу показывает причину. Опрос идёт с паузой в 3 с и ограничен 60 итерациями, то есть примерно тремя минутами ожидания. test_enrollment возвращает и enrolled, и waitlisted: для курса без свободных мест «поставлен в лист ожидания» — успешный результат, а не сбой.
Прогон набора и отчёт на JavaScript
Если фронтенд портала тестируется отдельным стеком, тот же цикл повторяется в Node.js. Набор оформлен как последовательность независимых проверок: упавшая не обрывает прогон, а попадает в отчёт вместе с текстом ошибки. По доле пройденных проверок в CI сразу видно, деградировал ли портал после релиза.
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 тарифицируется по числу одновременных потоков, а не по количеству решений: внутри тарифа решения не ограничены, поток освобождается сразу после выдачи токена. Считать нужно пиковую параллельность прогона, а не объём за неделю.
Ориентир для университета среднего размера. Ночная регрессия из 40 сценариев по 10 параллельно держит в работе 6–8 задач CAPTCHA одновременно — с этим справляется STANDARD ($30/мес, 15 потоков). Нагрузочный сценарий с 50 студентами, которые жмут «записаться» в первую минуту открытия окна, требует ADVANCE ($90/мес, 50 потоков). Для отладки одного сценария достаточно BASIC ($15/мес, 5 потоков). Цены — в долларах США.
Второй параметр расчёта — время решения. reCAPTCHA v2 решается менее чем за 60 с при высокой доле успешных решений, то есть один поток в худшем случае даёт около одной проверки в минуту. Если сценарию нужно 50 решений в первую минуту, 15 потоков не успеют: упирается не баланс, а параллельность.
Сценарии, которые чаще всего вызывают проверку
| Сценарий | Как часто появляется проверка | Что это значит для прогона |
|---|---|---|
| Вход под несколькими учётными записями | Часто | Проверка возникает почти на каждой попытке входа |
| Параллельные тесты записи на курс | Часто | Срабатывает защита от автоматической регистрации |
| Быстрый перебор поисковых запросов | Умеренно | Портал включает ограничение частоты запросов |
| Генерация расписания | Редко | Одна сессия, интерактивный виджет |
| Проверка пререквизитов | Редко | Часть уже открытой сессии записи |
Диагностика типичных сбоев
| Симптом | Причина | Что сделать |
|---|---|---|
| Проверка появляется на каждом тесте входа | Сессия не переиспользуется | Авторизуйтесь один раз и выполняйте весь набор внутри одной сессии |
| Запись падает уже после успешного решения | Истёк CSRF-токен | Забирайте свежий CSRF-токен со страницы вместе с sitekey |
| Тестовые данные сбрасываются между прогонами | Портал чистит тестовые записи | Работайте на выделенном тестовом контуре |
sitekey не находится в HTML |
Виджет отрисовывается скриптом на SPA-портале | Снимайте sitekey из готового DOM через Selenium или Playwright |
| Тайм-аут на каждой третьей задаче | Не хватает потоков на пике | Поднимите тариф или снизьте параллельность прогона |
Частые вопросы
Какой тариф выбрать для тестов регистрации?
Отталкивайтесь от пиковой параллельности, а не от числа сценариев. Отладка одного потока — BASIC ($15/мес, 5 потоков). Ночная регрессия — STANDARD ($30/мес, 15 потоков). Нагрузочный прогон открытия регистрационного окна — ADVANCE ($90/мес, 50 потоков). Число решений внутри тарифа не ограничено, поэтому длинный набор сам по себе не увеличивает счёт.
Какой тайм-аут ставить на ожидание токена?
reCAPTCHA v2 решается менее чем за 60 с, поэтому опрос res.php раз в 3 с с ограничением в 60 итераций даёт трёхкратный запас. Ставить тайм-аут короче 90 с не стоит: на пике портал отвечает медленнее, и тест начнёт падать по собственному ожиданию, а не по решателю.
Можно ли тестировать портал, который нам не принадлежит?
Только с письменного согласия владельца системы и в согласованном окне: подрядчику достаточно разрешения управления информатизации и зафиксированного в договоре контура.
Конструктор расписания закрыт Turnstile, а не reCAPTCHA. Что меняется?
Меняются метод отправки задачи и имя поля с результатом. Сам цикл — отправить задачу, дождаться токена, подставить его в форму — и структура тестового класса остаются прежними. Параметры разобраны в руководстве решение Cloudflare Turnstile через API.
Следующие шаги
Соберите минимальный сценарий «вход → поиск курса → запись» и прогоните его на тестовом контуре: станет видно, на каком шаге проверка появляется чаще всего. Если портал вуза использует слайдер вместо чекбокса, начните с руководства решение GeeTest v3 через API.