先说结论:脚本翻到几十页开始返回 403,多半不是 IP 被封,而是页面里多出了一段 cf-turnstile。Turnstile 拦的是访问节奏,把“取 sitekey → 提交任务 → 轮询取 token → 重发请求”四步接进采集器即可。
体育站点的验证码分布规律
| 数据类型 | 站点类型 | 验证码 | 触发条件 |
|---|---|---|---|
| 球员统计 | 数据百科站 | Cloudflare Turnstile | 连开球员页 |
| 单场统计 | 比分门户 | Cloudflare Challenge | 批量查场次 |
| 赛季积分榜 | 联赛官网 | reCAPTCHA v2 | 脚本翻页 |
| 赛季预测 | 数据平台 | reCAPTCHA v3 | 高频访问 |
| 赔率盘口 | 赔率门户 | Cloudflare Turnstile | 高频刷新 |
| 历史档案 | 存档站点 | 图片验证码 | 数据导出 |
越能被批量翻页的列表页,越早触发。六类都在 CaptchaAI 支持范围内,只差一个 method。
Python 采集器:识别逻辑收进一处
“检测验证码页 → 识别 → 重试”封装在 _solve_turnstile_and_retry,三个入口共用。
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']}")
_is_captcha_page 同时看状态码和页面特征串——只判 403 会漏掉返回 200 却塞挑战页的站点。
提交 in.php 后每 3 秒轮询 res.php,status == 1 即写进 cf-turnstile-response 重发。复用同一个 session,通行 cookie 留在会话里,后续几十页多半直接放行。
JavaScript 版:按球队整页聚合
名单页本身带统计表格时,逐个球员开详情页纯属浪费请求。这版按球队取整页解析。
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']
);
选型标准只有一条:数据在哪一页,就在哪页停下。
国内场景:联赛看板排期
欧洲赛事多在北京时间凌晨结束,批量任务定在早上 6 点跑,数据完整且避开高峰。
另一个坑:reCAPTCHA 依赖 Google 托管脚本,国内网络下常加载不出组件,页面看着像坏了;走 API 只要 sitekey 和 pageurl。盯两三个联赛用 BASIC($15/月,5 个线程),十几个并行建议 ADVANCE($90/月,50 个线程)。
按运动项目排采集节奏
赛后半小时是数据已定稿、访问量回落的窗口。
| 项目 | 数据节奏 | 触发概率 | 建议做法 |
|---|---|---|---|
| 足球 | 每日有赛 | 中等 | 按联赛错峰 |
| 篮球 | 集中晚间 | 赛时偏高 | 非赛时跑 |
| 棒球 | 每日常规赛 | 中等 | 赛后统一采 |
| 冰球 | 几乎每晚 | 中等 | 赛后集中抓 |
| 橄榄球 | 每周一轮 | 很低 | 每周批量一次 |
历史赛季数据不再变动,抓完缓存即可。
排障速查
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 每页都弹 Turnstile | cookie 没保持 | 复用同一 session |
| 球员数字对不上 | 赛季与生涯切换 | URL 带赛季参数 |
| 比分页返回空 | 该场未开赛 | 先查赛程 |
| 请求被限流 | 门户单日上限 | 拉长间隔 |
| 拿 token 仍 403 | token 过期 | 立即使用 |
常见问题
token 能跨页面重复使用吗?
不能。token 与页面地址绑定、一次性使用,跨页复用的是会话 cookie。
轮询多久没结果算超时?
3 秒间隔、上限 60 次约 3 分钟。持续超时先查 sitekey。
CaptchaAI 支持哪些验证码?
Turnstile、Cloudflare Challenge、reCAPTCHA v2/v3、GeeTest v3、图片与九宫格为正式支持;CaptchaFox、Friendly Captcha、Lemin 为测试版;hCaptcha、FunCaptcha 暂不支持,GeeTest v4 即将支持。
官方接口能替代自建采集吗?
只能替代一部分:取比分稳定,但有配额,进阶指标多在第三方站。
延伸阅读
下一步
领取 CaptchaAI API Key,先拿一个球队的名单页跑通。