Use Cases

用于体育统计数据收集的验证码处理

先说结论:脚本翻到几十页开始返回 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.phpstatus == 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 只要 sitekeypageurl。盯两三个联赛用 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,先拿一个球队的名单页跑通。

该文章已禁用评论。