Use Cases

Pengumpulan Data Statistik Olahraga dengan Penanganan CAPTCHA

Yang menghentikan kolektor statistik olahraga Anda hampir tidak pernah parser HTML-nya — melainkan halaman verifikasi yang muncul di request ke-40 saat Anda menyusuri roster satu liga. Jawabannya bukan memperlambat scraper, melainkan memasang satu jalur penanganan CAPTCHA di dalam sesi yang sama: deteksi halaman tantangan, kirim sitekey ke solver, ambil token, lanjutkan request. Artikel ini memetakan pemicunya per jenis portal, memberi kode Python dan Node.js siap pakai, lalu menghitung kebutuhan thread Anda.

Pemicu CAPTCHA di portal statistik olahraga

Data olahraga adalah produk berbayar, jadi proteksinya menyasar pola, bukan satu request: kecepatan navigasi, kedalaman query musim, sesi tanpa cookie konsisten, dan jam sibuk saat pertandingan berlangsung. Tiga yang pertama bisa Anda kendalikan lewat desain scraper; yang keempat tidak — di situlah solver otomatis menentukan apakah pipeline bertahan semalaman:

Tipe data Jenis portal CAPTCHA Pemicu
Statistik pemain Situs referensi Cloudflare Turnstile Halaman pemain beruntun
Box score Portal skor Cloudflare Challenge Pencarian massal
Klasemen musim Situs liga reCAPTCHA v2 Navigasi otomatis
Proyeksi fantasi Platform fantasi reCAPTCHA v3 Akses berpola API
Peluang dan lines Portal peluang Cloudflare Turnstile Refresh frekuensi tinggi
Catatan historis Situs arsip CAPTCHA gambar Request ekspor data

Semua tipe di tabel ini didukung CaptchaAI. Catatan penting: hCaptcha dan FunCaptcha (Arkose Labs) belum didukung, dan GeeTest v4 baru berstatus segera hadir.

Kolektor statistik pemain dengan Python

Kelas berikut menggabungkan requests.Session penjaga cookie, detektor halaman tantangan, dan alur in.php → polling res.php → kirim ulang token pada field cf-turnstile-response.

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

Empat detail menentukan ketahanan kolektor ini di produksi:

  1. Sesi dipakai ulang. Cookie tersimpan di self.session, jadi halaman berikutnya biasanya lolos tanpa CAPTCHA baru.
  2. Deteksi pada isi respons, bukan status code. Banyak portal mengembalikan 200 berisi halaman tantangan.
  3. Jeda dua detik antar pemain. Ini yang menjaga jumlah tantangan tetap rendah.
  4. Satu kegagalan tidak menjatuhkan roster. Blok try/except menyelamatkan sisa data.

Kalau klasemen memasang reCAPTCHA v2, alurnya identik — hanya method dan nama field token yang berbeda.

Agregator satu musim dengan Node.js

Di Node.js polanya sama: kumpulkan per tim, beri jeda, selesaikan tantangan hanya ketika muncul.

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

Struktur ini mengembalikan objek berisi error alih-alih melempar exception, agar satu tim bermasalah tidak membatalkan sisanya.

Ritme pengumpulan per cabang olahraga

Menyesuaikan jam kerja scraper dengan kalender kompetisi menurunkan jumlah CAPTCHA lebih efektif daripada tuning kode:

Cabang Puncak volume Sensitivitas CAPTCHA Pendekatan
Sepak bola Harian, lintas liga Sedang Satu sesi per liga
Bola basket Malam pertandingan Tinggi saat laga Jalankan di luar jam sibuk
Bulu tangkis Per turnamen Rendah di luar turnamen Borongan setelah babak selesai
Bisbol Log harian Sedang Kumpulkan setelah laga usai
Hoki es Hampir tiap malam Sedang Pascapertandingan

Contoh: pipeline liga sepak bola untuk agensi data di Jakarta

Bayangkan tim analitik kecil di Jakarta yang menjual laporan performa pemain ke klub dan media: 18 klub, 25 pemain per klub, disegarkan tiap pekan — 459 halaman.

  • Jadwal. Cron Senin dini hari WIB, saat portal sepi dan ambang deteksi paling longgar.
  • Region. Worker di AWS ap-southeast-1 (Singapura) atau ap-southeast-3 (Jakarta); latensi jaringan bukan faktor dominan karena penyelesaian Turnstile sendiri di bawah 10 detik.
  • Konkurensi. Delapan worker paralel, satu sesi masing-masing. Dengan tingkat tantangan sekitar 8%, hasilnya kira-kira 37 tantangan per pekan.
  • Penyimpanan. Data historis di-cache permanen; hanya musim berjalan yang disegarkan.

Dengan profil itu, BASIC ($15/bulan, 5 thread) sudah cukup, dan STANDARD ($30/bulan, 15 thread) memberi ruang saat pekan turnamen menumpuk.

Menghitung jumlah thread yang benar

Satu thread menampung satu CAPTCHA yang sedang berjalan:

thread = (tantangan per jam × waktu penyelesaian rata-rata dalam detik) ÷ 3600

Kasus di atas — 37 tantangan dalam dua jam — butuh jauh di bawah satu thread. Yang menaikkan kebutuhan bukan jumlah halaman melainkan puncak konkurensi: 50 worker sekaligus untuk backfill lima musim membuat ADVANCE ($90/bulan, 50 thread) jadi titik wajar. Karena penagihan dihitung per thread bersamaan — bukan per penyelesaian — biaya bulanan tidak ikut naik ketika portal memperketat proteksi. Bagi operator data lepas yang menagih klien dengan harga tetap per laporan, prediktabilitas itu lebih berharga daripada selisih harga awal.

Diagnosis masalah yang sering muncul

Gejala Penyebab Perbaikan
Turnstile di tiap halaman Cookie sesi tidak dipertahankan Satu requests.Session; simpan qa_validation_cookie di dalamnya
Statistik tidak sesuai Portal menampilkan mode karier Sertakan parameter musim di URL
Halaman skor kosong Pertandingan belum dimainkan Cek jadwal kompetisi dulu
Dibatasi setelah ~50 request Kuota harian portal per IP Sebar sepanjang hari; pakai egress jaringan yang diotorisasi
sitekey tidak ditemukan Widget dirender JavaScript Ambil HTML setelah render, atau cari data-sitekey di respons XHR
Token ditolak 403 Token kedaluwarsa atau pageurl beda Kirim token dalam hitungan detik; pageurl harus persis sama

Untuk kasus terakhir, pembahasan error 403 Turnstile setelah token diperbaiki menguraikan penyebab yang sering terlewat.

Catatan kepatuhan sebelum scraper dijalankan

Kumpulkan hanya data yang boleh Anda proses. Statistik pertandingan adalah fakta publik, tetapi data pribadi tunduk pada UU Pelindungan Data Pribadi (UU 27/2022) — hindari data pribadi pemain, dan patuhi ketentuan layanan portal sumber.

Pertanyaan yang sering diajukan

Berapa lama CaptchaAI menyelesaikan Turnstile di portal olahraga?

Cloudflare Turnstile umumnya tuntas di bawah 10 detik dengan tingkat keberhasilan tinggi pada tipe yang didukung. Angka itu batas atas SLA, bukan rata-rata, dan berlaku sama di portal olahraga maupun situs lain.

Paket mana untuk scraping satu liga penuh tiap pekan?

BASIC ($15/bulan, 5 thread) cukup untuk ratusan halaman per pekan dengan konkurensi rendah. STANDARD ($30/bulan, 15 thread) untuk beberapa liga paralel, ADVANCE ($90/bulan, 50 thread) untuk backfill lintas musim.

Portal target saya memakai hCaptcha — apakah bisa ditangani CaptchaAI?

Belum. hCaptcha dan FunCaptcha (Arkose Labs) tidak didukung, dan GeeTest v4 baru berstatus segera hadir. Yang didukung: reCAPTCHA v2 dan v3, Cloudflare Turnstile dan Challenge, GeeTest v3, CAPTCHA gambar dan grid, ditambah CaptchaFox (beta), Friendly Captcha (beta), dan Lemin (beta).

Bagaimana cara menekan jumlah CAPTCHA yang muncul sejak awal?

Pertahankan satu sesi per portal, beri jeda 2–3 detik antar request, jalankan di luar jam pertandingan, dan cache data historis. Kombinasi itu menurunkan frekuensi tantangan jauh lebih efektif daripada mengganti library HTTP.

Kalau liga punya API resmi, masih perlukah scraping?

Kalau API resminya memuat metrik yang Anda butuhkan dengan harga masuk akal, pakai API itu. Dalam praktiknya API liga sering mahal, dibatasi kuota, atau tidak memuat statistik lanjutan yang tersedia di situs referensi — di situlah penanganan CAPTCHA melengkapi sumber resmi.

Artikel Terkait

Langkah Selanjutnya

Ambil API key CaptchaAI Anda dan jalankan kolektor statistik olahraga pertama Anda malam ini — satu jalur penanganan CAPTCHA, tanpa pipeline yang berhenti di tengah musim.

Komentar dinonaktifkan untuk artikel ini.