Проверка 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), европейские кубковые вечера идут западнее, североамериканские лиги стартуют глубокой ночью по местному. Общий обход «раз в пять минут для всего» молотит архивы в пиковый час и ловит проверки там, где данные не менялись.
Рабочая схема — три отдельных контура вместо одного:
- Живой контур. Только счёт и события идущих матчей, интервал 2–5 минут, одна переиспользуемая сессия на лигу.
- Контур закрытия дня. Протоколы, составы и индивидуальная статистика — через 30–60 минут после финального свистка, когда портал пересчитал таблицы.
- Архивный контур. Исторические сезоны собираются один раз и складываются в локальное хранилище: эти страницы не меняются.
Разделение само по себе убирает заметную долю проверок: под нагрузкой остаётся только живой контур — самый узкий по числу 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 минут покрывает почти любые сценарии витрины. Чаще имеет смысл ходить только за событиями конкретного матча, а не за всей страницей результатов, и обязательно одной сессией: переоткрытие соединения на каждый запрос само провоцирует проверку.