Use Cases

Обработка CAPTCHA для тестирования регистрации на университетский курс

Сквозные тесты студенческого портала почти всегда останавливаются в одной и той же точке — на форме входа с 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.

Комментарии для этой статьи отключены.