Use Cases

Tratamento de CAPTCHA para coleta de dados de estatísticas esportivas

Resposta curta: o desafio aparece pelo ritmo, não pela página. O portal devolve a ficha do jogador sem atrito quando você abre uma por vez no navegador, e passa a exibir Cloudflare Turnstile assim que o mesmo endereço recebe dezenas de requisições em poucos minutos. Quem monta um pipeline esportivo descobre isso na virada de rodada, com o job parado e o painel desatualizado.

Onde os portais esportivos disparam o desafio

Nem todo dado tem o mesmo perfil de proteção. Mapeie isso antes de escrever o coletor.

Tipo de dado Tipo de portal CAPTCHA Gatilho típico
Estatísticas do jogador Sites de referência Cloudflare Turnstile Muitas fichas em sequência
Súmulas e box scores Portais de placares Cloudflare Turnstile Consultas em massa
Classificação da temporada Sites de liga reCAPTCHA v2 Navegação automatizada
Projeções de fantasy Plataformas de fantasy reCAPTCHA v3 Acesso com padrão de API
Linhas e odds Portais de odds Cloudflare Turnstile Alta frequência
Registros históricos Sites de arquivo CAPTCHA de imagem Exportação de dados

Os tipos da tabela — Turnstile, reCAPTCHA v2/v3 e CAPTCHA de imagem — são exatamente os que a CaptchaAI resolve. Fora dela ficam hCaptcha e FunCaptcha, não suportados, e o GeeTest v4, anunciado apenas como "em breve". Se o seu portal usa um desses, o caminho é buscar acesso oficial aos dados.

Detecte o desafio antes de tentar resolver

O erro mais comum é tratar o HTML da página de desafio como se fosse a página de estatísticas. O parser não quebra: devolve lista vazia, e você percebe dias depois, com o histórico cheio de buracos.

Três sinais identificam a página de desafio: status HTTP 403, a string cf-turnstile no corpo e o domínio challenges.cloudflare.com no HTML. Daí em diante o fluxo é sempre o mesmo — extrair a sitekey (chave pública do widget) do atributo data-sitekey, enviar a tarefa ao in.php com method=turnstile e a pageurl original, consultar o res.php até o status voltar 1 e reenviar a requisição com o token em cf-turnstile-response.

Coletor de dados esportivos em Python

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']}")

Dois detalhes decidem a estabilidade: o time.sleep(2) entre jogadores mantém a cadência abaixo do limiar que dispara o Turnstile, e self.session preserva os cookies — sem isso, você paga um desafio por jogador em vez de um por sessão.

Agregação de temporada inteira em Node.js

Quando a coleta cobre várias equipes, o padrão muda: lotes por time, intervalo maior e tolerância a falha isolada — o erro de um time não derruba a rodada.

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']
);

Ajuste a janela de coleta ao calendário do esporte

Cada esporte tem sua curva de tráfego, e coletar fora do pico reduz os desafios antes de qualquer ajuste técnico.

Esporte Pico de volume Sensibilidade Abordagem recomendada
Futebol Fim de semana e meio de semana Alta durante as partidas Colete após o apito final
Basquete Noites de jogo Alta durante os jogos Horários fora de pico
Vôlei Poucos dias concentrados Moderada Lote por rodada
Beisebol Súmulas diárias Moderada Colete ao fim da noite
Hóquei Quase todas as noites Moderada Coleta pós-jogo

Um exemplo do calendário brasileiro: em rodada de domingo com jogos às 16h e às 18h30, a janela útil não é o intervalo entre as partidas — é a madrugada de segunda, com as súmulas consolidadas e o tráfego em baixa. Rodar o job às 3h com workers em sa-east-1 rende RTT baixo e uma fração dos desafios que a coleta encontraria às 19h.

Se o pipeline toca dados de pessoas identificáveis — inscrições, escalações amadoras, cadastros —, considere as obrigações da LGPD sobre finalidade e retenção antes de armazenar. Estatísticas públicas de atletas profissionais são outra categoria, mas a distinção precisa estar explícita.

Diagnóstico dos problemas mais frequentes

Problema Causa provável Correção
Turnstile em todas as páginas Sessão sem cookies persistentes Reutilize a mesma sessão no lote
Números diferentes na ficha Alternância temporada/carreira Inclua o parâmetro de temporada na URL
Página de placares vazia Partidas ainda não realizadas Consulte a tabela de jogos antes
Bloqueio após ~50 requisições Limite diário do portal Distribua o lote e reduza a cadência
Token aceito, mas 403 persiste Token fora da sessão original Envie o cf-turnstile-response na mesma sessão

O último é o mais frustrante: a resolução retorna, o token chega e o portal insiste no 403. Quase sempre o cookie de sessão ou a URL não batem com o par sitekey + pageurl enviado ao in.php.

Quanto custa manter isso rodando

A cobrança da CaptchaAI é por thread concorrente, não por CAPTCHA resolvido: cada plano inclui resoluções ilimitadas dentro das threads contratadas. O custo depende de quantas coletas rodam em paralelo, não do volume da temporada.

Um pipeline que percorre alguns elencos por noite, em sequência, cabe no BASIC (US$ 15/mês, 5 threads). Uma operação que acompanha várias ligas na mesma janela tende ao STANDARD (US$ 30/mês, 15 threads) ou ao ADVANCE (US$ 90/mês, 50 threads). O ponto de decisão é a concorrência de pico — quase sempre no domingo à noite.

Perguntas frequentes

Preciso de navegador headless para coletar estatísticas esportivas?

Na maioria dos casos, não. Se as tabelas vêm no HTML inicial, requisições HTTP com sessão persistente são mais rápidas e baratas. Reserve o headless para portais que montam a tabela via JavaScript.

Como testo o coletor sem bater no portal real?

Suba uma página de staging que reproduza o widget Turnstile e a estrutura da tabela, e rode o pipeline contra ela com dados fictícios. Assim você valida parser e tratamento de erro fora do calendário de jogos.

O que acontece se o portal migrar de Turnstile para hCaptcha?

O pipeline para e não há resolução possível: o hCaptcha não é suportado pela CaptchaAI. O caminho passa a ser a API oficial da liga ou outra fonte.

Vale a pena guardar dados históricos em cache local?

Sim, e é a otimização de maior retorno. Súmulas encerradas não mudam: colete uma vez, grave localmente e limite as consultas ao que está em andamento. Os desafios caem junto com as requisições.

Uma API oficial de liga elimina a necessidade de resolver CAPTCHA?

Só em parte. APIs oficiais costumam ser caras, ter limite de requisições e não trazer as estatísticas avançadas dos sites de referência. A maioria dos pipelines combina as duas fontes.

Artigos relacionados

Próximas etapas

Coloque a coleta esportiva para rodar sem interrupção: obtenha sua chave de API da CaptchaAI e resolva o Turnstile direto no pipeline.

Os comentários estão desativados para este artigo.