终极格斗锦标赛在 ufcstats.com 上发布了一套深度公开的格斗数据档案:每一场已完成的赛事、每一场对决,以及每位选手的打击次数、过肩摔次数和比赛结果的详细分类。对于任何想要构建格斗预测模型、进行赌注研究或制作简单的"谁曾击败谁"参考数据集的人来说,这个公开统计页面都是一座宝矿, 而一旦您关注的内容超过一场赛事,手动复制数字的方法就行不通了。
本指南展示了使用 Python 抓取 UFC 统计数据的最佳方式:您将通过 Crawling API 获取渲染后的统计页面,使用 BeautifulSoup 解析数据表格,并为每位选手和每场对决整理出干净的记录。整个教程的范围严格限定在公开体育统计数据(选手姓名、战绩、比赛结果和格斗指标)。它不涉及社交媒体、个人数据或任何需要登录才能访问的内容,文末的合法性部分并非例行公事,请在将此工具用于任何实际规模的场景前仔细阅读。
您将构建什么
一个 Python 脚本,接受一个 ufcstats.com 选手或赛事的公开 URL,通过 Crawling API 获取渲染后的 HTML,并提取结构化记录。我们以某位选手的公开详情页为示例,提取以下字段:
- 选手姓名:页面所属选手的姓名,例如"Jon Jones"。
- 战绩:职业胜负平的记录,例如"27-1-0"。
- 赛事与日期:每场对决所属的赛事卡及其日期。
- 对手:每场对决中的另一名选手。
- 结果:从该选手角度来看的对决结果:胜、负或平。
- 关键数据:该场对决中记录的有效打击次数和过肩摔次数。
为什么普通请求在 ufcstats.com 上会遇到困难
有时您可以用普通 HTTP 客户端从 ufcstats.com 获取静态标记,但在任何公开体育网站上,原始爬虫都会遇到两个反复出现的问题。第一,页面布局依赖于与脚本和样式同步加载的数据表格,因此粗糙的抓取可能返回部分页面或缺少统计列的精简版。第二,网站会监视自动化流量:来自单个数据中心 IP 的一系列请求在密集循环中访问相同路径,往往在您完成一个赛季的赛事之前就被限速或触发挑战。
因此,一个可用的 UFC 统计爬虫需要在单次请求中同时具备两点:一个返回完整页面的获取方式,以及一个被网站视为普通访客的 IP 地址。您可以自行搭建无头浏览器加轮换住宅代理池的组合,但将这些组件整合在一起并保持其正常运行,本身就占据了大部分工作量。Crawling API 将两者整合在一次调用中:您发送 URL,它在受信任的 IP 后面获取页面,并将完整的 HTML 返回给您解析。
Crawlbase 提供两种 token 类型。普通 token 获取静态 HTML;JavaScript(JS)token 会先在真实浏览器中渲染页面。许多 ufcstats.com 页面在静态 HTML 中就包含了数据表格,因此先从普通 token 开始。如果某个统计列返回为空,请切换到 JS token,渲染后的标记将包含延迟加载的内容。
前提条件
在编写任何代码之前,您需要准备好几样东西。这些准备工作都不会花费太长时间。
基础 Python 知识。您应该能够编写和运行 Python 脚本,并使用 pip 安装包。如果您是 HTML 解析的新手,Python BeautifulSoup 使用指南涵盖了本教程所假设的选择器操作知识。
Python 3.8 或更高版本。使用 python --version 确认您的版本。如果尚未安装,请从 python.org 或通过 Anaconda 等发行版安装。
Crawlbase 账户和 token。注册后,打开您的控制台,从账户页面复制您的 token。请像对待密码一样保管 token:它用于验证您的请求,因此请勿将其提交到版本控制系统中。
搭建项目
创建虚拟环境以隔离项目依赖,然后安装爬虫所需的两个库。
python --version python -m venv ufc_env source ufc_env/bin/activate pip install crawlbase beautifulsoup4
在 Windows 上,请使用 ufc_env\Scripts\activate 替代 source 命令来激活环境。两个依赖项各司其职:crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让您可以通过 CSS 选择器提取各个字段。
第 1 步:获取渲染后的统计页面
首先获取完整的页面。导入 CrawlingAPI 类,用您的 token 初始化它,然后请求选手 URL。在解析之前检查状态码,可以让失败情况更加明显,而不是悄无声息地出错。
from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def crawl(page_url): response = api.get(page_url) if response["status_code"] == 200: return response["body"].decode("utf-8") print(f"Request failed: {response['status_code']}") return None if __name__ == "__main__": page_url = "http://www.ufcstats.com/statistics/events/completed" html = crawl(page_url) print(html[:500] if html else "No HTML returned")
这与原版教程中的最小化代码片段相同,并已更新为使用官方客户端:您不再需要手动构建请求 URL 并自行 URL 编码目标地址,而是将页面 URL 传给 api.get,让客户端处理编码和 token 问题。使用 python scraper.py 运行脚本,您应该能看到真实的统计标记,即已完成赛事的数据表格,而不是空壳。这在您编写任何选择器之前确认了获取功能的正常运行。将示例 URL 替换为 http://www.ufcstats.com/fighter-details/<id> 格式的选手详情 URL,即可定向访问某位选手的页面。
那一次 api.get 调用所做的工作远比看起来的多。在其背后,Crawling API 通过受信任的轮换住宅 IP 获取 ufcstats.com 页面,并将完整的 HTML 交还给您,让您无需自行运行无头浏览器集群和维护代理池,就能读取一张公开统计数据表格。先在免费套餐中对某个选手页面进行测试。
第 2 步:使用 BeautifulSoup 解析选手字段
获取到 HTML 后,将其加载到 BeautifulSoup 中并提取各个字段。ufcstats.com 的选手页面将姓名和职业战绩放在顶部附近,然后在结果表格中列出每一场对决,每行包含对手、赛事、日期、结果以及每场对决的指标数据。将提取逻辑封装在小型辅助函数中,可以防止单个缺失字段导致整个运行崩溃。
from bs4 import BeautifulSoup def text_of(node): return node.get_text(strip=True) if node else None def scrape_fighter(html): soup = BeautifulSoup(html, "html.parser") name = text_of(soup.select_one(".b-content__title-record")) record = text_of(soup.select_one(".b-content__title-record")) bouts = [] rows = soup.select("tr.b-fight-details__table-row") for row in rows: cells = row.select("td.b-fight-details__table-col") if len(cells) < 10: continue fighters = [text_of(p) for p in cells[1].select("p")] opponent = fighters[1] if len(fighters) > 1 else None strikes = [text_of(p) for p in cells[4].select("p")] takedowns = [text_of(p) for p in cells[5].select("p")] event = [text_of(p) for p in cells[6].select("p")] bouts.append({ "result": text_of(cells[0]), "opponent": opponent, "sig_strikes": strikes[0] if strikes else None, "takedowns": takedowns[0] if takedowns else None, "event": event[0] if event else None, "date": event[1] if len(event) > 1 else None, }) return {"name": name, "record": record, "bouts": bouts}
text_of 辅助函数做了一件有用的事:它返回节点经过修剪的文本,或者在节点缺失时返回 None,这样 .get_text() 调用就不会对空值报错。选手页面将姓名和胜负平战绩打包在同一个标题块中,这就是为什么两个字段都从 .b-content__title-record 中读取,并在下游将姓名从战绩字符串中分离出来。每条结果行都位于 tr.b-fight-details__table-row 中,其单元格按固定顺序排列:结果、两位选手、击倒次数、有效打击次数、过肩摔次数,然后是赛事和日期。按位置索引单元格使解析器易于阅读,同时为每场对决提取了结果、对手、关键数据和赛事信息。如果您想了解如何选择健壮的选择器,XPath 和 CSS 选择器指南是很好的参考资料。
b-content__title-record 和 b-fight-details__table 类名反映的是 ufcstats.com 当前的标记结构。公开网站可能在不通知的情况下重新设计,因此请将上述选择器视为起始模板,而非固定契约。当某个字段返回 None 时,请在浏览器开发工具中重新检查实时页面并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的,而不是某种故障的迹象。
第 3 步:整合代码
现在将获取和解析步骤整合到一个可运行的脚本中。获取 HTML,将其传给解析器,然后打印结构化记录。
import json from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def crawl(page_url): response = api.get(page_url) if response["status_code"] == 200: return response["body"].decode("utf-8") print(f"Request failed: {response['status_code']}") return None def text_of(node): return node.get_text(strip=True) if node else None def scrape_fighter(html): soup = BeautifulSoup(html, "html.parser") name = text_of(soup.select_one(".b-content__title-record")) record = text_of(soup.select_one(".b-content__title-record")) bouts = [] for row in soup.select("tr.b-fight-details__table-row"): cells = row.select("td.b-fight-details__table-col") if len(cells) < 10: continue fighters = [text_of(p) for p in cells[1].select("p")] strikes = [text_of(p) for p in cells[4].select("p")] takedowns = [text_of(p) for p in cells[5].select("p")] event = [text_of(p) for p in cells[6].select("p")] bouts.append({ "result": text_of(cells[0]), "opponent": fighters[1] if len(fighters) > 1 else None, "sig_strikes": strikes[0] if strikes else None, "takedowns": takedowns[0] if takedowns else None, "event": event[0] if event else None, "date": event[1] if len(event) > 1 else None, }) return {"name": name, "record": record, "bouts": bouts} def main(): page_url = "http://www.ufcstats.com/fighter-details/f4c49976c75c5ab2" html = crawl(page_url) if not html: return data = scrape_fighter(html) print(json.dumps(data, indent=2)) if __name__ == "__main__": main()
输出结果示例
使用 python scraper.py 运行完整脚本,您将获得一条干净的选手结构化记录,可以直接写入 JSON、CSV 或数据库。具体数字取决于选手和对决情况,但数据结构保持不变。
{ "name": "Jon Jones", "record": "Record: 27-1-0", "bouts": [ { "result": "win", "opponent": "Ciryl Gane", "sig_strikes": "9", "takedowns": "1", "event": "UFC 285: Jones vs. Gane", "date": "Mar. 04, 2023" } ] }
扩展到多位选手和多场赛事
单个选手页面只是演示;真实数据集需要遍历很多选手页面。处理方式保持不变:维护一个选手 URL 列表,通过 Crawling API 逐个获取,用同一个函数进行解析,并收集所有数据行。由于每个选手页面共享相同的表格结构,您已经编写好的解析器可以无需修改地适用于所有页面。一种常见的模式是从已完成赛事的索引页开始,依次访问每个赛事的对决列表,再通过对决中的选手链接深入,最后统一导出所有数据。
import time fighters = [ "http://www.ufcstats.com/fighter-details/f4c49976c75c5ab2", "http://www.ufcstats.com/fighter-details/07f72a2a7591b409", ] results = [] for url in fighters: html = crawl(url) if html: results.append(scrape_fighter(html)) time.sleep(2) with open("ufc_stats.json", "w") as f: json.dump(results, f, indent=2)
请求之间的 time.sleep 调用是经过深思熟虑的。即使 Crawling API 已经为您轮换 IP,控制节奏也能使您的运行保持礼貌和可预测,同时给每个页面留出返回的时间,而不会使请求互相叠加。如果您想将每位选手的记录转化为扁平表格,请遍历 bouts 列表,在每行附上选手姓名后写入一条 CSV 记录;如果您更倾向于按赛事而非按选手来组织索引,相同的获取-解析模式也可以扩展到赛事页面。
保持不被屏蔽
即使在公开统计网站上,一系列自动化请求也可能被限速。养成以下几个习惯可以保持运行的健康状态,这些习惯适用于任何您进行大规模抓取的网站。
- 控制请求节奏。在紧密循环中轰炸页面是被限速的最快方式。分散请求并变换目标,而不是全速爬取单一路径。
- 依赖轮换机制。住宅 IP 池将请求分散到许多真实用户地址上,这样单个地址就不会触发速率限制。Crawling API 会为您处理这些;如果您自己搭建方案,这是最需要做好的部分。
- 关注状态码。如果运行开始返回挑战或错误,这是在告诉您当前的速率或 IP 级别已经不够用了。将其视为需要退后一步的信号,而非可以忽略的噪声。
关于更广泛的策略,请参阅在不被屏蔽的情况下抓取网站的深度指南,以及使用 Python 抓取网站的通用指南。如果您更倾向于通过轮换代理池来路由自己的流量,而不是使用托管 API,Smart AI Proxy(也称 AI Proxy)可以为您提供相同的住宅 IP 轮换功能,作为即插即用的代理端点。
抓取 UFC 统计数据合法吗?
抓取 UFC 统计数据是否被允许,取决于统计网站的服务条款、您所在的司法管辖区以及您如何使用这些数据。抓取任何人无需账号即可查看的公开信息,通常比采集受限或个人数据更处于安全地带,但"公开"并不是一张通行证。请阅读您所使用的任何网站(包括 ufcstats.com)的服务条款和 robots.txt,并将两者都视为您采集内容和采集速度的边界。这里的任何代码都不会改变这些条款;它只是让技术层面的工作得以实现。
请保持工作范围的精准。本指南有意将范围限定在公开的格斗和选手统计数据:姓名、胜负平战绩、赛事和日期、比赛结果,以及有效打击次数和过肩摔次数等每场对决的指标。它不涵盖社交媒体数据、选手或粉丝的个人数据、需要登录的内容,也不包括任何绕过身份验证的尝试,同样不涉及重新分发受版权保护的媒体(如赛事录像或照片)。这些内容被有意排除在范围之外,因为公开统计数据才是使这项工作具有可辩护性的基础。如果您想将范围扩展到社交媒体帖子或个人资料,那正是本教程停止的地方。
对于任何商业用途,请优先选择官方或授权的数据来源。UFC 及其数据合作伙伴通过官方数据流授权格斗统计数据,更广泛的体育数据供应商提供付费 API,附带明确的使用权限和稳定的数据结构,无需您每次页面重新设计后重新发现。抓取公开统计页面适合研究、个人项目或没有授权数据流覆盖您需求的一次性数据集;当您在此数据之上构建产品时,授权的体育数据 API 才是更清晰、更安全的选择。
核心要点
- 通过受信任的 IP 进行获取。直接请求 ufcstats.com 可能返回部分页面或被限速;Crawling API 在单次调用中通过轮换住宅 IP 获取页面。
-
使用 BeautifulSoup 解析数据表格。选手页面在标题块中提供姓名和战绩,在
b-fight-details__table-row中列出每场对决,通过单元格索引提取结果、对手、打击次数、过肩摔次数和赛事信息。 -
预期选择器会发生漂移。
b-content__title-record和表格类名反映当前的标记结构;当字段返回None时重新检查并更新选择器。 - 通过控制节奏的 URL 循环来扩展规模。同一个解析器适用于每个选手页面,因此真实数据集只需要一个链接列表加上每次请求之间的短暂停顿。
-
仅限公开统计数据,商业用途请使用授权数据源。仅限于公开的格斗和选手统计数据,尊重服务条款和
robots.txt,构建产品时优先选择官方或授权的体育数据流。
常见问题
使用 Python 抓取 UFC 统计数据的最佳方式是什么?
通过 Crawling API 获取公开统计页面,以获得受信任轮换 IP 背后的完整 HTML,然后使用 BeautifulSoup 进行解析。这种组合处理了两个难点:返回完整页面的数据检索,以及被网站视为普通访客的 IP 地址,同时让您专注于从数据表格中选择您需要的字段。
我可以提取哪些 UFC 统计数据?
从公开的选手或赛事页面,您可以提取选手姓名、胜负平战绩、每场对决的对手、赛事和日期、比赛结果,以及有效打击次数和过肩摔次数等每场对决的指标数据。本指南仅限于公开体育统计数据;不涵盖社交媒体、个人数据或任何需要登录才能访问的内容。
在 ufcstats.com 上需要使用普通 token 还是 JS token?
从普通 token 开始。许多 ufcstats.com 页面在静态 HTML 中就返回了数据表格,因此普通 token 通常已经足够。如果某个统计列返回为空,请切换到 JavaScript(JS)token,它会在返回 HTML 之前在真实浏览器中渲染页面,这样延迟加载的内容在您解析时就会存在。
我的选择器返回 None,发生了什么变化?
几乎可以肯定是网站的标记发生了变化。当页面重新设计时,b-content__title-record 等类名和 b-fight-details__table 行会发生变化,因此上个月还能正常工作的选择器可能会失效。请在浏览器开发工具中重新检查实时页面并更新选择器。定期维护选择器对于任何生产爬虫来说都是正常的。
UFC 提供官方统计 API 吗?
UFC 及其数据合作伙伴通过官方数据流授权格斗统计数据,更广泛的体育数据供应商出售附带明确使用权限和稳定数据结构的付费 API。对于任何商业用途,请优先选择这些授权来源。抓取公开统计页面最适合研究、个人项目或没有授权数据流覆盖您需求的一次性数据集。
如何在抓取 UFC 统计数据时避免被屏蔽?
保持较低的单 IP 请求频率,变换目标而不是循环访问单一路径,并通过轮换住宅 IP 进行路由,这样单个地址就不会触发速率限制。Crawling API 为您管理轮换和受信任的 IP 池;如果您自己搭建方案,这是最需要投入的部分。关注状态码,当您开始看到挑战时及时退后一步。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
