☰
体育赛事实时比分爬虫实战:从XHR接口到轮询存储一整套流程
2026/10/14 7:11:34 网站建设 项目流程

做体育赛事的实时比分抓取,算是爬虫方向里特别能练手又贴近真实需求的一类实战项目。市面上有大量体育数据网站,把比分、赛程、事件流放在页面上,很多还附带XHR接口直接返回JSON,数据更新频率高、字段结构相对清晰,非常适合用来练习动态页面抓取、任务调度、增量更新和结果统计这一整套流程。这篇博文就把整个项目的拆解过程、代码实现和踩坑记录完整写出来,给想练爬虫实战、想搞数据采集分析的读者一个可以直接抄作业的参考。

1. 项目拆解与选型思路

1.1 需求到底要做什么

标题看着简单,实际拆开是三个递进的部分:一是能持续从目标站点拿到最新比分数据,二是数据要有实时更新能力而不是抓一次就结束,三是拿到的数据要能用于统计,比如提炼出主胜、客胜、平局的比例,场均进球数,某段时间内的大球小球走势等。

这个需求在现实里对应一批很具体的场景:个人做足彩数据复盘的小工具、校园体育社团做赛果汇总、自媒体写赛事分析时快速整理数据。它不算复杂的分布式爬虫,也不涉及大规模并发,但麻雀虽小,要素齐全。

我接这类项目时习惯先划范围:要爬哪些赛事、要哪些字段、更新的粒度是秒级还是分钟级、统计的维度有哪些。范围不划清,很容易在写代码的时候膨胀。本文按最常见的场景来设计:爬取某体育数据网站的未来两天赛事列表和实时比分,支持每30秒轮询一次,数据落地到SQLite,最后输出一份简单的统计分析报告。

1.2 为什么选择爬虫而不是直接找官方API

很多人第一反应是“这不直接调API不就行了?”实际做过的人都知道,正规免费开放的体育赛程API非常稀缺,尤其是带实时比分的,要么是商业付费,要么限制请求频率。对于个人练习项目或者小规模内部工具,买一套商业API的预算不划算,而目标站点本身就在网页上公开展示了这些数据,爬虫在这里就是合理且高效的选择。

选择爬虫还有一个现实原因:网页端的赛事数据结构随站点的前端框架不同而变化,React、Vue这类框架下的页面普遍通过XHR请求后端接口拿JSON再渲染,这反而给爬虫提供了便利。很多情况下不需要解析HTML,直接模拟接口请求就能拿到比页面更干净的数据。这一点在后面章节会展开说。

1.3 技术栈如何选型

我只用Python标准库配合少量第三方库,不搞重型框架。

请求库用requests,解析库用BeautifulSoup和json,存储用SQLite内置模块sqlite3,调度直接用time.sleep加循环。如果要统计复杂指标,可以引入pandas,但本文的场景用纯Python就能完成。

为什么不用Scrapy?因为这种实时轮询型任务,Scrapy的异步架构反而增加复杂度。Scrapy适合大规模页面爬取,而当前任务是小而高频的接口请求,requests加循环完全够用,还便于调试。

为什么不用Redis存数据?单机小型任务SQLite足够。Redis适合多进程共享状态或高并发写入场景,这里没有那个量级。先把简单方案做透,再按需演进,这是我一贯的原则。

2. 抓取方案设计

2.1 先找数据藏在哪

写爬虫第一步永远是打开目标站点,确认数据是怎么呈现的。我以某个虚构赛事数据站点为例,它的比赛列表页在浏览器里看着是一张表格:主队、客队、比分、状态、时间。点开浏览器开发者工具的网络面板,刷新页面后能看到一个赛事列表的XHR请求,返回JSON格式的赛程和比分数据,字段很规整。

在动手写任何一行爬虫代码前,我建议先做两件事:一是确定URL规律,二是在浏览器里确认返回结构。比如列表页可能有日期参数,URL类似https://example-sports.com/api/match/list?date=2025-02-01,返回的JSON里是一个数组,每个元素包含matchId、home、away、homeScore、awayScore、status等字段。

如果站点没有接口,只能从HTML里解析,那就用开发者工具选中比分表格元素,看class或id特征。BeautifulSoup定位表格行,按列提取文本,同样能拿到数据,只是要处理更多的页面噪声。

2.2 两种抓取姿势对比

我把抓取方式分为两种:直接请求JSON接口,和解析HTML页面。首选前者,因为接口数据干净、稳定、请求体量小。

JSON接口的响应体通常很小,类似:

{ "list": [ { "matchId": "2025020101", "home": "蓝鹰队", "away": "红牛队", "homeScore": 2, "awayScore": 1, "status": "live", "minute": 67 } ] }

这种结构几乎就是为程序准备的,直接response.json()就能用。HTML解析的缺点在于,页面上通常有大量装饰性标签、广告位、乱入的样式,清洗成本高,而且站点改版时HTML结构比接口更容易变动。

我实际做这类项目时,有个习惯:先在开发者工具里翻一遍XHR请求,如果能在五到十分钟内找到返回比赛数据的接口,优先走接口;确实没有接口再退回HTML解析路线。这不是说解析HTML不行,而是能用更省力的方式达到同样目的时,没必要给自己加戏。

2.3 请求头伪装与反爬应对

目标站点再简单,也扛不住无脑高频请求。正常的爬虫项目必须带上像样的请求头,至少包括User-Agent和Referer。很多站点会校验这两个字段,不带的话直接返回403或跳转验证页。

我常用的请求头构造方式:

HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", "Referer": "https://example-sports.com/schedule", "Origin": "https://example-sports.com" }

注意,不需要伪造Cookie,大部分只校验请求头就能放行。如果目标站点有简单的频率限制,就用后续章节的轮询间隔来控制,不要把请求频率拉到每秒几次,否则等于直接告诉对方你是个爬虫。

如果你发现目标站点校验了签名或动态token,那就超出了本文的范畴。那种项目需要单独分析加密逻辑,难度直线上升。作为入门实战,选择没有复杂加密的站点练手是更务实的路径。

3. 实时更新与调度设计

3.1 轮询频率到底怎么定

“实时更新”是标题里最容易被误解的词。很多人以为要做到秒级刷新,实际体育赛事的比分变化节奏是由比赛事件驱动的,进球、红黄牌、换人都不会每秒钟发生。轮询频率设得太高,不仅白白增加服务器压力,还容易被封。

我按赛事类型分档:足球比赛每30秒轮询一次完全够用,篮球比赛因为得分频繁,可以缩短到每10秒到15秒一次。这不是什么标准答案,而是一个基于比赛事件发生密度的经验值。

轮询任务的核心代码框架如下:

import time from datetime import datetime def run_polling(fetcher, storage, interval=30): print(f"[{datetime.now()}] 开始轮询,间隔 {interval}s") while True: try: data = fetcher.fetch() storage.upsert(data) except Exception as e: print(f"[{datetime.now()}] 抓取/写入异常: {e}") time.sleep(interval)

这里还有个容易被忽略的点:time.sleep是阻塞式的,循环里如果抓取本身耗时较长,实际间隔是“抓取耗时+等待时间”。如果接口响应要5秒,你设置间隔30秒,真实频率其实是35秒一次。想要严格按30秒就得多线程配合定时器,但实战项目里这种误差完全可以接受,不需要过度设计。

3.2 增量更新与事件去重

实时更新最大的坑不是“抓不到”,而是“重复抓”。每30秒抓一次接口,返回的是全量比赛列表,如果不做去重,数据库里会堆满同一场比赛的重复记录。这个问题比想象中更常见,很多初学者写的爬虫跑一个晚上,数据库涨了几千条数据,里面一半都是垃圾。

我用的是比赛状态维度的增量更新策略:用matchId做唯一键,每次抓取都执行“插入或更新”的UPSERT操作。比分变了就更新,状态从“未开始”变成“进行中”也更新,状态从“进行中”变为“已结束”时额外打一个结束标记。

SQLite里可以用INSERT OR REPLACE或ON CONFLICT语法实现:

INSERT INTO matches (match_id, home, away, home_score, away_score, status, minute, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(match_id) DO UPDATE SET home_score = excluded.home_score, away_score = excluded.away_score, status = excluded.status, minute = excluded.minute, updated_at = excluded.updated_at;

这个逻辑保证了一条比赛记录在数据库里只会有一行,字段不断被刷新。做统计的时候不会因为数据翻倍而失真。

3.3 异常处理与重试机制

网络的不可靠性是实时爬虫必须面对的现实。我的经验是“重试但不过度,容错但不假装成功”。

每轮抓取都放在try-except里。单次请求失败时,做三次重试,间隔1秒、3秒、5秒,指数退避。如果三次都失败,这一轮跳过,等下一个轮询周期再试,不让单次失败中断整个任务。

重试逻辑示例:

def fetch_with_retry(url, headers, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() return resp.json() except Exception as e: if attempt == max_retries - 1: raise time.sleep(1 * (2 ** attempt))

还有一层防护是响应数据的合法性校验。有时候接口返回了200,但内容是错误提示或空数组,不能直接当作正常数据处理。我在解析前会检查返回结构里是否有预期的list字段,没有就视为异常。这能挡住一批接口异常返回造成的脏数据。

4. 数据存储与结果统计

4.1 CSV还是SQLite

需求如果只是抓个几百条数据做一次分析,CSV确实够用,打开方便,Excel直接就能看。但一旦涉及实时更新和增量写入,CSV的劣势就很明显:并发写入容易冲突,没有事务保护,几十万行以后读写性能下降。

我用SQLite的原因有三个:支持事务,支持UPSERT去重,查询统计非常方便。它不需要单独装数据库服务,Python自带的sqlite3模块就能操作,对单机爬虫项目来说性价比极高。

建表语句我一般长这样:

conn = sqlite3.connect("sports.db") conn.execute(""" CREATE TABLE IF NOT EXISTS matches ( match_id TEXT PRIMARY KEY, home TEXT, away TEXT, home_score INTEGER, away_score INTEGER, status TEXT, minute INTEGER, start_time TEXT, updated_at TEXT ) """)

字段类型我特意把比分设为整数,因为统计时要求平均值、求和,整数列比文本列灵活得多。start_time存的是比赛开赛时间,后续做时间段筛选要用。

4.2 比分统计与赛果分析

统计是标题里的最后一块,也是把数据变现的部分。数据库里有了一天的比赛数据后,可以算的东西非常多。我按三个基础维度做统计:

一是赛果分布,把“主队赢、客队赢、平局”进行分类统计。实现方式是遍历所有status为已结束的比赛,比较主客队比分。

二是进球趋势,比如场均进球数、上下半场进球分布,如果有每分钟数据还能看进球时间分布。没有实时事件流的情况下,利用全场比分也能算出总进球数,做大小球参考。

三是异常比赛识别,比分差距超过3球的比赛、终场前绝平的比赛,这些用简单逻辑就能筛出来,适合做赛事复盘素材。

统计代码片段:

def build_stats(conn): rows = conn.execute(""" SELECT home_score, away_score FROM matches WHERE status = 'finished' """).fetchall() total = len(rows) home_win = sum(1 for h, a in rows if h > a) away_win = sum(1 for h, a in rows if h < a) draw = total - home_win - away_win goals = sum(h + a for h, a in rows) return { "total_matches": total, "home_win_rate": home_win / total if total else 0, "away_win_rate": away_win / total if total else 0, "draw_rate": draw / total if total else 0, "avg_goals": goals / total if total else 0 }

注意起始的status = 'finished'条件,这个条件保证了统计只针对完赛的比赛,不会把进行中的比分算进去。否则你会发现场均进球被实时比分带着波动,数据就没法用了。

4.3 结果输出和简单可视化

统计结果最终要给人看。我习惯输出两种格式:控制台摘要和文本报告。控制台摘要用于跑任务时即时观察,文本报告则按天生成,方便留存和后续对比。

输出摘要的示例:

统计日期: 2025-02-01 比赛场次: 12 主胜比例: 41.7% 客胜比例: 33.3% 平局比例: 25.0% 场均进球: 2.75

如果需求是给非技术同事看,还可以把报告写成HTML文件,用表格列出来。不要一上来就上echarts这种重型可视化库,先把基础统计跑通,真有展示需求再叠加。

5. 完整实战代码

5.1 爬虫核心模块

经过前面几章的铺垫,代码实现反而简单了。我把项目拆成四个文件:fetcher.py负责请求和解析,storage.py负责数据库操作,stats.py负责统计,main.py负责调度。

fetcher.py的核心逻辑:

import requests import json class MatchFetcher: def __init__(self, base_url, headers): self.base_url = base_url self.headers = headers def fetch_by_date(self, date_str): url = f"{self.base_url}?date={date_str}" resp = requests.get(url, headers=self.headers, timeout=10) resp.raise_for_status() data = resp.json() if "list" not in data: raise ValueError("接口返回格式异常") return data["list"]

这段代码里我把URL模板暴露出来,因为不同站点的参数名不一样,有的用date,有的用startTime和endTime,有的按轮次查,通过参数传入日期是通用性最强的设计。当然接口字段名变了也要相应调整,但没有代码是能适配所有站点的,保持修改成本低就行。

5.2 实时更新循环的现场实现

main.py里的轮询循环是项目的核心,我把抓取、入库、统计摘要串在一起:

import time from datetime import datetime, timedelta from fetcher import MatchFetcher from storage import MatchStorage from stats import build_stats def main(): fetcher = MatchFetcher( base_url="https://example-sports.com/api/match/list", headers={...} ) storage = MatchStorage("sports.db") interval = 30 today = datetime.now().strftime("%Y%m%d") print(f"[{datetime.now()}] 初始化完成,开始抓取日期 {today}") while True: try: matches = fetcher.fetch_by_date(today) for m in matches: storage.upsert(m) print(f"[{datetime.now()}] 更新完成,当前记录数: {storage.count()}") except Exception as e: print(f"[{datetime.now()}] 本轮异常: {e}") time.sleep(interval) if __name__ == "__main__": main()

跑起来之后的输出大概是这样的:

[2025-02-01 19:00:00] 更新完成,当前记录数: 6 [2025-02-01 19:00:30] 更新完成,当前记录数: 6 [2025-02-01 19:01:00] 更新完成,当前记录数: 7 [2025-02-01 19:01:30] 更新完成,当前记录数: 7

注意当前记录数增长并不代表所有比赛都在增加,有的比赛是新增的开赛信息,有的是已有比赛的比分在刷新。记录数从6变到7,说明有一场新比赛被纳入了列表。如果想看比分变化,需要单独记录历史快照表,这可以作为项目的进阶扩展,不是基础版的需求。

5.3 统计模块与报告输出

stats.py在轮询结束后单独运行,也可以做成定时任务每天跑一次。我推荐用后者,因为统计报告更适合低频生成,没必要跟着轮询一起执行。

统计模块的一个实用输出是CSV报告,方便导入Excel处理:

import csv from stats import build_stats def write_csv_report(conn, filename): stats = build_stats(conn) with open(filename, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) for key, value in stats.items(): writer.writerow([key, value])

这里我特别说一个编码问题:Windows上的Excel打开UTF-8不带BOM的CSV会显示乱码,所以写CSV时我建议用encoding="utf-8-sig"。这个细节不遇到你是不会注意到的,遇到过就知道了。

6. 常见问题与排查技巧实录

6.1 抓不到数据时先查什么

我实战里遇到最频繁的问题是:代码逻辑没问题,但接口返回的数据和浏览器里看到的不一样。排查顺序我建议按“接口地址是否正确、参数是否有漏、请求头是否完整、数据是否在响应的其他字段里”来走。

有一次我调试时发现接口返回的list是空的,后来发现漏传了日期参数,接口默认查的是十年前的历史赛程,自然什么都没返回。这类低级错误用一条print语句就能揪出来,关键是要养成打印原始响应的习惯。

我写爬虫的一个准则:先拿到原始响应,再做任何处理。很多人直接把resp.json()丢进解析函数里,出了问题根本不知道是哪一步出的错。在代码里加一句:

print(json.dumps(data, ensure_ascii=False)[:500])

能帮你省掉大量在解析逻辑里瞎猜的时间。

6.2 编码乱码和字符问题

JSON接口一般返回Unicode,requests的json()方法会自动解码,乱码问题不多见。真正容易出问题的是HTML页面解析和CSV输出。

有一次我从某个页面抓球队名称,页面上显示的是“布鲁克林队”,解析出来却是“布鲁克林\u961f”这种带转义的形式。原因在于页面里嵌入了Unicode转义序列,需要在解析时用encode().decode('unicode_escape')处理。

更省事的办法是统一走JSON接口,接口返回的字符串大都是规范的。如果必须解析HTML,那一开始就以页面源码的编码解析并以BeautifulSoup的html.parser为标准,不要混用解析器,否则会出现解析结果不稳定的情况。

6.3 请求频率限制和临时封禁

目标站点即使没有复杂的防护策略,也会对一个IP的超高频率请求做限制。常见表现是连续高频请求后,响应码从200突然变成403,或者返回一个验证页面。

遇到这种情况我的处理方案很朴素:降低轮询频率,同时给请求增加随机延迟。频率降下来之后,绝大多数写轮子的站点都会恢复正常。如果还是不行,那就需要检查是否被针对了,可以换UA或者换网络环境测试。

不要把随机延迟写在每次请求里,写在轮询的sleep里:

time.sleep(interval + random.uniform(0, 5))

这样既保证了更新节奏,又让请求时间点不那么规律。

6.4 比赛状态和时区问题

体育赛事的开赛时间经常用UTC存储,展示时转成北京时间。爬虫抓下来如果直接存原始字符串,统计时按日期筛数据就会出一堆问题,尤其在跨夜比赛的时候。

我的处理方式是在存储阶段就把时间统一转成东八区时间,并在入库前把字符串解析成标准格式:

from datetime import datetime, timezone, timedelta def to_local(utc_str): dt = datetime.fromisoformat(utc_str.replace("Z", "+00:00")) local_dt = dt.astimezone(timezone(timedelta(hours=8))) return local_dt.strftime("%Y-%m-%d %H:%M:%S")

统一时区的好处是后续按日期、小时做聚合时不用反复换算。这个坑如果不在项目一开始就规避,统计的时候会非常痛苦。

6.5 数据库写入慢的优化

轮询项目跑久了,数据库里的记录数可能会突破十万。虽然SQLite本身对付这个量级没有问题,但如果你在每次轮询时都用普通方式逐条更新,还是会感觉到写入变慢。

优化思路是使用事务批量写入。把每次抓到的多场比赛放在同一个事务里提交,而不是逐条自动提交,能显著降低磁盘I/O次数。

with conn: for m in matches: conn.execute(...)

Python的sqlite3模块在with conn块内会把这组操作视为一个事务,要么全部成功,要么全部回滚。这个改动只加了两行缩进,但实际效果立竿见影。

7. 几个该提前说的经验

写完这个项目,我最深的体会是爬虫实战里真正花时间的不是写爬虫代码本身,而是抓数据结构排查、处理异常边界、设计合理的存储和统计逻辑。这三个部分占了我整个项目七成的工作量。

对于想练这个项目的朋友,我的建议是:先选一个简单站点做通全流程,不要一上来就挑战带复杂加密的网站;数据字段能少抓就少抓,够用就行;统计逻辑一定要建立在干净的数据上,数据脏了再好看的统计也没有意义。

这个项目做到能跑通的程度,其实只是完成了第一步。后续还能加很多东西:历史数据的趋势分析、赛果预测的小模型特征工程、多赛事并发的分布式抓取、把SQLite换成时序数据库做更细粒度的快照分析。方向很多,就看你想往哪个深度走。我个人更推荐的下一步,是把某段时间内的比分变化保存成时间序列,这会让实时更新的价值真正体现出来,而这也是我从这个项目里获得的最大收益。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询