Use Cases

Gestion des CAPTCHA pour la collecte de données sur les statistiques sportives

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 Session est réutilisé : le cookie cf_clearance couvre 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

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.

Les commentaires sont désactivés pour cet article.