Use Cases

Обработка CAPTCHA для сбора данных спортивной статистики

Проверка Cloudflare Turnstile на спортивном портале — не тупик, а ещё один шаг конвейера. Сборщик распознаёт страницу проверки, отправляет sitekey и pageurl в API CaptchaAI, получает токен cf-turnstile-response и повторяет исходный запрос той же сессией. Turnstile решается менее чем за 10 секунд, и ручное вмешательство не требуется.

Сложность здесь не в проверке, а в ритме источника: пока матч идёт, страница результатов меняется каждые пару минут, а через сутки не меняется вовсе. Проверки появляются именно тогда, когда сборщик работает против этого ритма. Ниже — где они срабатывают, код на Python и JavaScript, расчёт потоков и разбор сбоев, которые обычно принимают за «блокировку».

Где на спортивных порталах срабатывает проверка

Тип проверки предсказуем по типу источника, а не по конкретному сайту, поэтому маршрут решения выбирается заранее — до первого запроса.

Тип данных Тип источника Проверка Что её вызывает
Статистика игроков Справочные базы Cloudflare Turnstile Быстрая серия загрузок страниц
Протоколы матчей (box score) Порталы результатов Cloudflare Challenge Массовая выгрузка по датам
Турнирные таблицы Сайты лиг reCAPTCHA v2 Автоматическая навигация по разделам
Прогнозы фэнтези-лиг Фэнтези-платформы reCAPTCHA v3 Частые запросы в режиме API
Коэффициенты и линии Букмекерские порталы Cloudflare Turnstile Высокочастотное обновление котировок
Исторические архивы Архивные сайты Image CAPTCHA Экспорт больших выборок

Все шесть типов входят в поддерживаемый набор CaptchaAI: reCAPTCHA v2 и v3, Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, изображения и OCR. GeeTest v4 пока в разработке, hCaptcha и FunCaptcha не поддерживаются — если портал закрыт именно ими, заложите другой источник данных.

Расписание источника важнее скорости сборщика

Календарь — главный рычаг снижения числа проверок. Команда в Алматы (UTC+6), ведущая витрину по трём лигам, упирается не в объём, а в совпадение окон: РПЛ играет по московскому времени (UTC+3), европейские кубковые вечера идут западнее, североамериканские лиги стартуют глубокой ночью по местному. Общий обход «раз в пять минут для всего» молотит архивы в пиковый час и ловит проверки там, где данные не менялись.

Рабочая схема — три отдельных контура вместо одного:

  1. Живой контур. Только счёт и события идущих матчей, интервал 2–5 минут, одна переиспользуемая сессия на лигу.
  2. Контур закрытия дня. Протоколы, составы и индивидуальная статистика — через 30–60 минут после финального свистка, когда портал пересчитал таблицы.
  3. Архивный контур. Исторические сезоны собираются один раз и складываются в локальное хранилище: эти страницы не меняются.

Разделение само по себе убирает заметную долю проверок: под нагрузкой остаётся только живой контур — самый узкий по числу URL.

Сборщик статистики на Python

_is_captcha_page определяет страницу проверки по коду 403 и маркерам Turnstile в HTML. _solve_turnstile_and_retry достаёт sitekey, отправляет задачу на in.php и опрашивает res.php до готовности токена. Разбор HTML вынесен отдельно, чтобы смена вёрстки не ломала сетевой слой. Важная деталь: в pageurl уходит тот же URL, который открыл сборщик, — иначе токен окажется валидным, но не для этой страницы.

import requests
import time
import re
from dataclasses import dataclass, field

@dataclass
class PlayerStats:
    name: str
    team: str
    position: str
    stats: dict = field(default_factory=dict)
    season: str = ""
    source: str = ""

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

    def get_player_stats(self, portal_url, player_slug, season=None):
        """Fetch player statistics, solving CAPTCHAs as needed."""
        url = f"{portal_url}/players/{player_slug}"
        if season:
            url += f"/{season}"

        response = self.session.get(url)

        if self._is_captcha_page(response):
            response = self._solve_turnstile_and_retry(response, url)

        return self._parse_player_stats(response.text)

    def get_game_scores(self, portal_url, date):
        """Fetch all game scores for a specific date."""
        url = f"{portal_url}/scores/{date}"
        response = self.session.get(url)

        if self._is_captcha_page(response):
            response = self._solve_turnstile_and_retry(response, url)

        return self._parse_scores(response.text)

    def collect_team_roster(self, portal_url, team_slug, season):
        """Collect stats for all players on a team roster."""
        roster_url = f"{portal_url}/teams/{team_slug}/{season}/roster"
        response = self.session.get(roster_url)

        if self._is_captcha_page(response):
            response = self._solve_turnstile_and_retry(response, roster_url)

        player_slugs = self._extract_player_links(response.text)

        all_stats = []
        for slug in player_slugs:
            try:
                stats = self.get_player_stats(portal_url, slug, season)
                all_stats.append(stats)
                time.sleep(2)  # Respectful delay
            except Exception as e:
                print(f"Failed for {slug}: {e}")

        return all_stats

    def _is_captcha_page(self, response):
        return (
            response.status_code == 403 or
            "cf-turnstile" in response.text or
            "challenges.cloudflare.com" in response.text
        )

    def _solve_turnstile_and_retry(self, response, url):
        match = re.search(r'data-sitekey="(0x[^"]+)"', response.text)
        if not match:
            raise ValueError("Turnstile sitekey not found")

        resp = requests.post("https://ocr.captchaai.com/in.php", data={
            "key": self.api_key,
            "method": "turnstile",
            "sitekey": match.group(1),
            "pageurl": 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 self.session.post(url, data={
                    "cf-turnstile-response": data["request"]
                })

        raise TimeoutError("Turnstile solve timed out")

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

        # Extract stat rows from tables
        stats = {}
        stat_table = soup.select_one("table.stats, #stats-table")
        if stat_table:
            headers = [th.text.strip() for th in stat_table.select("thead th")]
            for row in stat_table.select("tbody tr"):
                cells = [td.text.strip() for td in row.select("td")]
                if len(cells) == len(headers):
                    for header, value in zip(headers, cells):
                        stats[header] = value

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

        return PlayerStats(
            name=text_or_empty(soup.select_one("h1, .player-name")),
            team=text_or_empty(soup.select_one(".team-name, .team")),
            position=text_or_empty(soup.select_one(".position, .pos")),
            stats=stats
        )

    def _parse_scores(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")
        games = []

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

        for game in soup.select(".game-card, .scoreboard-item"):
            games.append({
                "away": text_or_none(game.select_one(".away-team")),
                "home": text_or_none(game.select_one(".home-team")),
                "away_score": text_or_none(game.select_one(".away-score")),
                "home_score": text_or_none(game.select_one(".home-score")),
                "status": text_or_none(game.select_one(".game-status"))
            })

        return games

    def _extract_player_links(self, html):
        from bs4 import BeautifulSoup
        soup = BeautifulSoup(html, "html.parser")
        links = []
        for a in soup.select("a[href*='/players/']"):
            slug = a["href"].rstrip("/").split("/")[-1]
            if slug and slug not in links:
                links.append(slug)
        return links


# Usage
collector = SportsDataCollector("YOUR_API_KEY")

# Get player stats
stats = collector.get_player_stats(
    "https://sports.example.com", "lebron-james", "2024"
)
print(f"{stats.name} ({stats.team}): {stats.stats}")

# Get all scores for a date
scores = collector.get_game_scores("https://sports.example.com", "2024-12-25")
for game in scores:
    print(f"{game['away']} {game['away_score']} @ {game['home']} {game['home_score']}")

Задержка в две секунды между игроками в collect_team_roster держит сбор в лимитах источника: для состава из 15–25 человек это меньше минуты к прогону.

Агрегатор сезона на JavaScript

Тот же маршрут в Node.js нужен, когда сбор встроен в существующий JS-конвейер. Обход идёт по командам: ошибка одной не роняет весь сезон.

class SportsAggregator {
  constructor(apiKey) {
    this.apiKey = apiKey;
  }

  async collectSeasonData(portalUrl, sport, season, teams) {
    const allData = {};

    for (const team of teams) {
      try {
        const roster = await this.getTeamStats(portalUrl, team, season);
        allData[team] = roster;
      } catch (error) {
        allData[team] = { error: error.message };
      }
      // Rate limit between teams
      await new Promise(r => setTimeout(r, 3000));
    }

    return allData;
  }

  async getTeamStats(portalUrl, teamSlug, season) {
    const url = `${portalUrl}/teams/${teamSlug}/${season}`;
    const response = await fetch(url);
    const html = await response.text();

    if (html.includes('cf-turnstile') || response.status === 403) {
      return this.solveAndFetch(url, html);
    }

    return this.parseTeamPage(html);
  }

  async solveAndFetch(url, html) {
    const match = html.match(/data-sitekey="(0x[^"]+)"/);
    if (!match) throw new Error('Turnstile sitekey not found');

    const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
      method: 'POST',
      body: new URLSearchParams({
        key: this.apiKey,
        method: 'turnstile',
        sitekey: match[1],
        pageurl: url,
        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) {
        const response = await fetch(url, {
          method: 'POST',
          body: new URLSearchParams({ 'cf-turnstile-response': data.request })
        });
        return this.parseTeamPage(await response.text());
      }
    }
    throw new Error('Turnstile solve timed out');
  }

  parseTeamPage(html) {
    const players = [];
    const rowMatches = html.matchAll(/<tr[^>]*class="[^"]*player[^"]*"[^>]*>([\s\S]*?)<\/tr>/gi);

    for (const row of rowMatches) {
      const cells = [...row[1].matchAll(/<td[^>]*>([\s\S]*?)<\/td>/gi)]
        .map(m => m[1].replace(/<[^>]+>/g, '').trim());
      if (cells.length >= 3) {
        players.push({
          name: cells[0],
          position: cells[1],
          stats: cells.slice(2)
        });
      }
    }

    return { players, count: players.length };
  }
}

// Usage
const aggregator = new SportsAggregator('YOUR_API_KEY');
const seasonData = await aggregator.collectSeasonData(
  'https://sports.example.com', 'basketball', '2024',
  ['lakers', 'celtics', 'warriors']
);

Стратегия сбора по видам спорта

Вид спорта Пик объёма Чувствительность к проверкам Как собирать
Бейсбол Ежедневные протоколы Умеренная После окончания матчей
Баскетбол Игровые вечера Высокая во время матчей В непиковые часы
Американский футбол Игровые недели Низкая между турами Один прогон в неделю
Футбол Ежедневно по многим лигам Умеренная Отдельная сессия на каждую лигу
Хоккей Ночные матчи Умеренная После финальной сирены

Сколько потоков заложить

CaptchaAI тарифицируется по числу одновременных потоков, а не по количеству решений: внутри тарифа решения не ограничены, доплат за тип CAPTCHA нет. Поток — одна проверка «в полёте».

Cloudflare Turnstile решается менее чем за 10 секунд, то есть один поток закрывает порядка шести проверок в минуту. Живой контур на одну лигу с интервалом в три минуты не упирается даже в один поток.

  • BASIC ($15/мес, 5 потоков) — одна-две лиги: живой контур плюс вечерний сбор протоколов.
  • STANDARD ($30/мес, 15 потоков) — несколько лиг параллельно и разовые архивные прогоны.
  • ADVANCE ($90/мес, 50 потоков) — витрина по нескольким видам спорта с плотным игровым календарём.

Фиксированная цена в USD удобна тем, что стоимость сбора не скачет вместе с интенсивностью тура: пиковая суббота и пустой вторник обходятся одинаково. Как подключить ключ — в быстром старте CaptchaAI.

Типичные сбои и что с ними делать

Симптом Причина Решение
Проверка на каждой странице Сессия не переиспользуется Используйте один объект сессии — cookies сохранятся между запросами
У игрока не те цифры, что на сайте Переключатель «сезон / карьера» Явно передавайте сезон в URL страницы
Страница результатов пустая Матчи ещё не сыграны Сверяйтесь с календарём лиги до запроса
403 после десятков запросов Дневной лимит источника Растяните сбор по времени и снизьте параллелизм
Токен получен, данные не отдаются pageurl не совпадает со страницей проверки Передавайте в in.php тот URL, который открыл сборщик
Задача висит в res.php дольше минуты Неверный sitekey или неподдерживаемый тип Сверьте data-sitekey в HTML со списком поддерживаемых типов

Правовая гигиена сбора

Сама статистика обезличена, но рядом с ней лежат пользовательские данные: комментарии, профили фэнтези-менеджеров, рейтинги. Собирайте только то, что вы вправе обрабатывать, и не тяните персональные поля «на всякий случай» — для читателей в РФ это зона 152-ФЗ «О персональных данных», для трансграничных проектов действует та же логика минимизации. Условия использования портала стоит прочитать до запуска конвейера.

FAQ

Какие проверки на спортивных порталах CaptchaAI решает, а какие нет?

Решаются reCAPTCHA v2 и v3 (включая Enterprise), Cloudflare Turnstile, Cloudflare Challenge, GeeTest v3, image- и grid-CAPTCHA, BLS CAPTCHA. CaptchaFox, Friendly Captcha и Lemin — в статусе beta. hCaptcha и FunCaptcha не поддерживаются, GeeTest v4 — в разработке.

Сколько потоков нужно, чтобы собрать статистику целой лиги за один вечер?

Меньше, чем кажется: проверка выпадает не на каждой странице, а один поток закрывает около шести задач в минуту. Для вечернего прогона по одной лиге хватает BASIC ($15/мес, 5 потоков); STANDARD ($30/мес, 15 потоков) нужен, когда лиг несколько и их окна пересекаются.

Почему портал возвращает 403 даже после успешного решения Turnstile?

Причины обычно три: токен ушёл не тем запросом; pageurl отличался от адреса страницы с проверкой; ответ пришёл поздно и токен просрочен. Разбор — в руководстве по решению Cloudflare Turnstile.

Как часто опрашивать счёт во время матча?

Интервал 2–5 минут покрывает почти любые сценарии витрины. Чаще имеет смысл ходить только за событиями конкретного матча, а не за всей страницей результатов, и обязательно одной сессией: переоткрытие соединения на каждый запрос само провоцирует проверку.

Следующие шаги

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