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:
- Sesi dipakai ulang. Cookie tersimpan di
self.session, jadi halaman berikutnya biasanya lolos tanpa CAPTCHA baru. - Deteksi pada isi respons, bukan status code. Banyak portal mengembalikan 200 berisi halaman tantangan.
- Jeda dua detik antar pemain. Ini yang menjaga jumlah tantangan tetap rendah.
- Satu kegagalan tidak menjatuhkan roster. Blok
try/exceptmenyelamatkan 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) atauap-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
- Cara mengumpulkan data riset pasar
- GeeTest v3 vs Cloudflare Turnstile: mana yang Anda hadapi
- Mengatasi error 403 Turnstile setelah token diperbaiki
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.