Une collecte de statistiques sportives tient sans accroc sur les premières fiches de joueur, puis s'arrête net vers la quarantième : le portail répond 403 et un widget Cloudflare Turnstile remplace la fiche attendue. La parade tient en trois points : une session persistante, un solveur appelé seulement quand le défi apparaît, une cadence calée sur le calendrier des rencontres.
Les exemples ci-dessous visent les sites de référence — ceux qu'une équipe data francophone interroge pour la Ligue 1, le Top 14 ou la Betclic Élite.
Ce qui déclenche un défi CAPTCHA sur les portails de statistiques sportives
Le défi tombe quand le profil de requêtes ressemble à un export de base ; son type dépend de ce que le portail monétise.
| Donnée visée | Type de portail | Défi rencontré | Déclencheur |
|---|---|---|---|
| Statistiques de joueur | Sites de référence | Cloudflare Turnstile | Pages joueur en rafale |
| Feuilles de match | Portails de scores | Cloudflare Challenge | Recherches en masse |
| Classements de saison | Sites de ligue | reCAPTCHA v2 | Navigation automatisée |
| Projections fantasy | Plateformes fantasy | reCAPTCHA v3 | Accès de type API |
| Cotes et lignes | Portails de cotes | Cloudflare Turnstile | Rafraîchissements fréquents |
| Archives historiques | Sites d'archives | CAPTCHA image | Demandes d'export |
Turnstile revient le plus souvent dans ce tableau : CaptchaAI le résout en moins de 10 s, avec un taux de réussite élevé.
Calez la collecte de statistiques sportives sur le calendrier
Le nombre de défis dépend d'abord de l'heure de passage : un portail indifférent à un crawler à 7 h du matin déclenchera Turnstile à chaque page pendant une soirée de matchs.
| Compétition | Pic de volume | Sensibilité | Fenêtre conseillée |
|---|---|---|---|
| Football (Ligue 1, Jupiler Pro League) | Week-ends | Élevée en direct | Lot complet le lundi matin |
| Rugby (Top 14) | Vendredi et samedi soir | Modérée | Passe hebdomadaire |
| Basketball (Betclic Élite, NBA) | Presque chaque soir | Élevée en direct | Heures creuses, 6 h–9 h |
| Cyclisme (grands tours) | Trois semaines d'affilée | Modérée | Une passe par étape |
| Hockey sur glace | Presque chaque soir | Modérée | Après la dernière rencontre |
Deux règles en découlent : ne mélangez pas les scores en direct et l'historique de saison dans la même passe, et réservez les rattrapages d'archives aux créneaux calmes.
Collecteur Python : détecter, résoudre, reprendre
Le collecteur ci-dessous ne devine pas quand un défi va tomber : il teste la réponse avec _is_captcha_page (code 403, présence de cf-turnstile) et n'appelle le solveur que si le défi est là. La résolution extrait le data-sitekey, envoie la tâche à in.php avec method=turnstile, interroge res.php toutes les 3 secondes, puis renvoie le cf-turnstile-response sur l'URL d'origine.
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")
def text_of(node):
return node.get_text(strip=True) if node else ""
# 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
return PlayerStats(
name=text_of(soup.select_one("h1, .player-name")),
team=text_of(soup.select_one(".team-name, .team")),
position=text_of(soup.select_one(".position, .pos")),
stats=stats
)
def _parse_scores(self, html):
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
def text_of(node):
return node.get_text(strip=True) if node else None
games = []
for game in soup.select(".game-card, .scoreboard-item"):
games.append({
"away": text_of(game.select_one(".away-team")),
"home": text_of(game.select_one(".home-team")),
"away_score": text_of(game.select_one(".away-score")),
"home_score": text_of(game.select_one(".home-score")),
"status": text_of(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']}")
Trois détails font la différence à l'usage :
- L'objet
Sessionest réutilisé : le cookiecf_clearancecouvre les requêtes suivantes, soit un défi par session au lieu d'un par fiche. - Le
time.sleep(2)entre deux joueurs sépare une collecte tenable d'un pic qui rallume la protection. - Le token Turnstile est à usage unique et lié à l'URL demandée : ne le mettez pas en cache.
Agrégateur de saison en JavaScript
Quand la collecte tourne dans un worker Node.js — une fonction planifiée chez OVHcloud ou Scaleway — la logique reste la même : parcourir les équipes, temporiser 3 secondes, isoler les erreurs par équipe.
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']
);
La boucle prévoit 60 tentatives espacées de 3 secondes, très large pour Turnstile. Journalisez le nombre réel d'itérations : s'il grimpe, le signal vient du portail, pas du solveur.
Cadence, proxys et données personnelles
Quatre garde-fous, indépendants du solveur :
- Plafonds par IP. Beaucoup de portails limitent à quelques dizaines de requêtes par jour et par adresse : répartissez sur plusieurs proxys résidentiels.
- Latence. Pour des sites européens, des workers en région Paris (eu-west-3) ou chez un hébergeur français raccourcissent le temps aller-retour.
- RGPD. Les performances d'un joueur professionnel sont publiques, mais vos journaux de collecte — IP, identifiants, horodatages — relèvent de vos obligations : minimisez-les et fixez une durée de conservation.
- Conditions d'utilisation. Vérifiez ce que le portail autorise, en particulier sur les données payantes.
Dimensionner vos threads pour une saison complète
CaptchaAI facture au thread simultané, avec des résolutions illimitées par thread sur le mois. Un thread correspond à un défi en cours de résolution : ce n'est pas le nombre de pages qui compte, mais le nombre de défis traités au même instant.
- Une compétition, une passe nocturne, 1 à 3 workers : BASIC ($15/mois, 5 threads).
- Plusieurs compétitions en parallèle les soirs de matchs : STANDARD ($30/mois, 15 threads).
- Collecte multi-sports continue avec rattrapage d'archives : ADVANCE ($90/mois, 50 threads).
Ni plafond journalier, ni frais par résolution, ni surcoût par type de défi. Facturation en dollars US.
Dépannage
| Problème | Cause probable | Correctif |
|---|---|---|
| Un défi Turnstile sur chaque page | Cookies non conservés | Réutilisez le même objet Session |
| La fiche joueur renvoie d'autres chiffres | Bascule saison / carrière | Passez la saison dans l'URL |
| Blocage après une cinquantaine de requêtes | Plafond journalier par IP | Étalez et répartissez sur plusieurs proxys |
Turnstile sitekey not found |
Widget injecté côté client | Chargez la page dans un navigateur headless |
FAQ
Quels types de CAPTCHA rencontre-t-on sur ces portails ?
Cloudflare Turnstile en large majorité, Cloudflare Challenge sur les portails de scores, reCAPTCHA v2 et v3 sur les sites de ligue, du CAPTCHA image sur les archives. CaptchaAI couvre ces types ; hCaptcha et FunCaptcha (Arkose Labs) ne sont pas pris en charge, GeeTest v4 est annoncé comme à venir.
Un navigateur headless est-il obligatoire ?
Non, pas systématiquement. Tant que le data-sitekey figure dans le HTML renvoyé, de simples requêtes HTTP suffisent, comme dans le collecteur ci-dessus. Passez à Playwright ou Selenium quand le widget est injecté par JavaScript.
Comment distinguer un défi CAPTCHA d'un vrai blocage ?
Regardez le corps de la réponse, pas seulement le code HTTP. Un 403 accompagné de cf-turnstile est un défi : il se résout. Un 403 sans widget, ou un 429 répété, signale une limitation de débit — ralentissez et changez d'IP de sortie.
Combien de threads prévoir pour une soirée de matchs ?
Comptez un thread par worker susceptible d'attendre une résolution au même instant : trois collecteurs tiennent dans les 5 threads de BASIC ($15/mois), une quinzaine justifie STANDARD ($30/mois, 15 threads).
Articles connexes
- Collecter des données d'étude de marché sans interruption
- GeeTest v3 face à Cloudflare Turnstile
- Corriger un 403 Turnstile qui persiste après l'injection du token
Prochaines étapes
Votre collecte de statistiques sportives ne devrait pas s'arrêter sur un widget Cloudflare. Récupérez votre clé API CaptchaAI et branchez la résolution Turnstile sur votre session existante.