Cổng thống kê thể thao bật Cloudflare Turnstile ngay khi một IP mở hàng chục trang cầu thủ trong vài giây — đó là lý do crawler chạy tốt lúc test thủ công rồi gãy khi vào batch thật. Cách xử lý bền vững gồm ba việc: nhận diện trang thử thách thay vì coi 403 là lỗi mạng, giải Turnstile qua API rồi nộp lại token cf-turnstile-response, và xếp lịch crawl theo nhịp mùa giải.
Cổng thống kê thể thao bật CAPTCHA khi nào
Mỗi nhóm cổng có trigger riêng, và trigger là thứ bạn điều chỉnh được:
| Kiểu dữ liệu | Loại cổng | CAPTCHA | Trigger |
|---|---|---|---|
| Thống kê cầu thủ | Trang tra cứu | Cloudflare Turnstile | Mở liên tiếp nhiều trang |
| Box score | Cổng tỷ số | Cloudflare Challenge | Tra cứu trận theo lô |
| Bảng xếp hạng | Trang giải đấu | reCAPTCHA v2 | Điều hướng tự động |
| Dự báo fantasy | Nền tảng fantasy | reCAPTCHA v3 | Request đều kiểu API |
| Tỷ lệ kèo | Cổng tỷ lệ | Cloudflare Turnstile | Refresh tần suất cao |
| Số liệu lịch sử | Trang lưu trữ | CAPTCHA hình ảnh | Yêu cầu export |
Cả năm loại trên đều được CaptchaAI hỗ trợ chính thức, nên bạn dùng chung một luồng API, chỉ đổi tham số method.
Bối cảnh Việt Nam: tách job theo múi giờ giải đấu
Team dữ liệu ở TP.HCM hay Hà Nội thường cần thống kê V.League cộng các giải châu Âu. V.League đá chiều cuối tuần giờ Việt Nam, còn Premier League và La Liga rơi vào 21h–04h. Chạy một crawler duy nhất suốt ngày sẽ dính Turnstile liên tục.
Hiệu quả hơn: tách hai job — V.League chạy 19h Chủ nhật sau khi cổng cập nhật số liệu, giải châu Âu chạy 05h sáng hôm sau lúc trang hết cao điểm. Về chi phí, BASIC ($15/tháng, 5 thread) đủ cho một giải chạy theo lô ban đêm; nhiều giải song song thì lên STANDARD ($30/tháng, 15 thread). CaptchaAI tính tiền theo thread (luồng giải đồng thời), không theo lượt giải, nên chi phí tháng cao điểm vẫn cố định. Giá niêm yết bằng USD.
Collector thống kê cầu thủ bằng Python
Đoạn code dưới nhận diện thử thách qua _is_captcha_page(), trích data-sitekey, gửi task tới in.php, polling res.php, rồi nộp token vào form.
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']}")
Session requests.Session() giữ nguyên xuyên suốt nên cookie phiên sau lần giải đầu còn dùng lại được. Tạo session mới cho mỗi request đồng nghĩa mỗi trang là một lần giải mới.
Tổng hợp cả mùa giải bằng JavaScript
Khi gom theo đội thay vì theo cầu thủ, luồng giống hệt nhưng lặp qua danh sách đội và giãn 3 giây giữa các đội:
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']
);
Lịch crawl theo từng môn thể thao
Bóng rổ nhạy CAPTCHA nhất vì lưu lượng dồn vào đêm có trận, nên crawl vào giờ thấp điểm. Bóng chày và khúc côn cầu đá gần như hằng ngày: thu thập sau trận. Bóng bầu dục thi đấu theo tuần nên gom một lô mỗi tuần. Bóng đá trải nhiều giải và múi giờ — tách mỗi giải một phiên.
Dữ liệu đã chốt thì crawl một lần rồi cache; chỉ dữ liệu đang đổi mới cần polling.
Lỗi hay gặp và cách xử lý
| Vấn đề | Nguyên nhân | Cách xử lý |
|---|---|---|
| Turnstile hiện lại mọi trang | Không giữ cookie phiên | Dùng chung session |
| Số liệu cầu thủ lệch nhau | Chế độ mùa giải/sự nghiệp | Truyền tham số mùa giải trong URL |
| Trang tỷ số rỗng | Trận chưa diễn ra | Đối chiếu lịch thi đấu trước |
| Giới hạn tần suất sau ~50 request | Hạn mức ngày của cổng | Giãn request nhiều phiên |
ERROR_WRONG_SITEKEY |
Bắt nhầm sitekey trên trang nhiều widget | Kiểm tra data-sitekey |
res.php trả CAPCHA_NOT_READY liên tiếp là trạng thái chờ bình thường — cứ polling tiếp chu kỳ 3 giây, gửi lại task chỉ tốn thêm thread.
Câu hỏi thường gặp
CaptchaAI có giải được cổng thể thao dùng hCaptcha không?
Không. CaptchaAI chưa hỗ trợ hCaptcha và FunCaptcha (Arkose Labs). Nhưng trên cổng thống kê, thứ bạn gặp gần như luôn là Turnstile, Cloudflare Challenge hoặc reCAPTCHA v2/v3 — đều được hỗ trợ.
Gói nào phù hợp cho crawler thống kê chạy hằng đêm?
BASIC ($15/tháng, 5 thread) nếu chạy một giải theo lô; nhiều giải song song thì STANDARD ($30/tháng, 15 thread). Số thread cần bằng số request đồng thời ở đỉnh, không phải tổng số CAPTCHA trong tháng.
Nên đặt timeout bao lâu cho một lần giải Turnstile?
Vòng polling trong code đợi tối đa 60 lần × 3 giây, tức 180 giây. Cần fail nhanh thì hạ xuống 40 vòng rồi đưa URL vào hàng đợi thử lại.
Có nên chạy trình duyệt headless thay vì gọi API trực tiếp?
Với cổng trả HTML server-side, gọi trực tiếp bằng requests nhẹ hơn. Chỉ dùng Selenium hoặc Playwright ở chế độ headless khi bảng số liệu render bằng JavaScript.
Bài viết liên quan
- thu thập dữ liệu cho nghiên cứu thị trường
- so sánh GeeTest và Cloudflare Turnstile
- khắc phục lỗi 403 sau khi có token Turnstile
Bước tiếp theo
Lấy API key CaptchaAI và cho crawler thống kê của bạn tự giải Turnstile trong lần chạy tới.