两年前我刚开始做 NBA 球员数据分析时卡住的不是算法而是数据。比赛数据、球员数据、球队数据都散落在网页上手动复制粘贴撑不过几十条想找现成 API不是文档不全就是接口受限。后来我花了几个晚上用 BeautifulSoup 把这个 NBA 数据源完整爬下来整个过程彻底改变了我对“数据分析前置工作”的理解。这篇文章就把这条链路完整复盘一遍从数据源选型、页面结构分析到请求与解析、常见反爬坑排查最后再到如何把爬取结果直接交给 pandas 做分析。整个过程基于 Python 3 BeautifulSoup requests不需要额外框架适合刚学完 Python 基础、想用真实项目练手的读者也适合需要在项目里快速拿到结构化数据源的从业者。1. 为什么数据源是分析 NBA 数据迈不过去的第一道坎1.1 数据分析不是从算法开始而是从数据开始很多人学 pandas 时会陷入一个误区把精力全花在groupby、merge、画图上真到做项目时却发现手里根本没有像样的数据。尤其是体育数据分析这种非典型领域数据不像企业销售报表那样有现成的 Excel 可以对接全得自己想办法。NBA 场景里我们需要的基础数据通常包括球员每场比赛的出场时间、得分、篮板、助攻、命中率球队的胜负场次、主场客场战绩还有某个时期的状态走势。这些数据如果靠人工逐条录入第一容易出错第二效率太低第三很难保证数据的时间连续性。只有把数据获取这一步自动化后续分析才有意义。这也是我反复强调“前置”的原因数据源的完整性和规范性直接决定了后面所有统计分析的上限。你用脏数据算出来的均值、相关系数看起来像模像样实际上可能连基本业务逻辑都经不起推敲。所以在动手写任何分析代码之前先要把数据管道打通。1.2 三种获取数据源的路径对比做 NBA 数据项目时获取数据的路线通常有三条我分别评估过它们的适配场景。路径优点缺点适合场景手动复制整理简单直观数据量小易出错几十条数据的教学演示官方/第三方 API数据规范、有文档注册门槛、请求限制、字段不全有稳定接口的长期项目Web 爬虫数据全、自定义程度高需要处理解析和反爬一次性或周期性抓取自定义数据集API 确实是最省心的方案但现实中你会发现有的数据站点不开放 API开放的又往往限频限字段真正想要的进阶数据比如“球员在不同防守强度下的命中率”根本拿不到。这时候爬虫就成了绕不开的路径。爬虫也不是所有场景都要上重型框架。拿这次的需求来说目标只是把 NBA 数据源里的球员表格抓下来页面结构是静态 HTMLBeautifulSoup 完全够用没必要上 Scrapy 那一套。用最简单的requests拉页面再用BeautifulSoup解析十几行代码就能看到效果调试起来也直观。1.3 BeautifulSoup 在整套流程中的合理定位BeautifulSoup 本质上是 HTML/XML 解析器它负责做的只有一件事把网页源代码变成一棵可以按标签、属性、文本内容查询的树。你告诉它“我要表格里所有行的第三个单元格”它就能把这些内容筛出来。这套流程的完整链路是用requests向目标网址发起请求拿到 HTML 字符串。用BeautifulSoup把这个字符串解析成可操作的对象。通过find、find_all或 CSS 选择器定位目标表格和字段。用 pandas 把结果整理成结构化数据。值得说明的是BeautifulSoup 不能解决“页面里根本没有静态 HTML”的问题。如果目标网站的数据是通过 JavaScript 动态渲染的你直接请求拿到的 HTML 里可能只有空壳标签这时候要么换接口要么配合无头浏览器处理。这篇文章先聚焦静态表格场景这是最稳妥的入门路径也是大部分老牌统计站点的数据组织形式。2. 锁定抓取目标NBA 数据网站的页面结构怎么拆2.1 候选数据源对比常用 NBA 数据站点有 NBA 官网、ESPN、StatMuse、Basketball-Reference 等。我实际对比下来每个站点的差异很大单纯看“数据全不全”不够还得看“好不好爬”。数据源数据丰富度页面类型爬取难度备注NBA 官网较全部分 JS 渲染中等接口字段复杂部分数据在 XHR 里ESPN较全混合静态/动态中等经常改版StatMuse领域垂直动态渲染较高查询交互复杂Basketball-Reference非常全静态表格为主较低表格结构规整适合初学者如果你也和我一样第一优先级是“快速拿到干净数据”那 Basketball-Reference 这类老牌统计站点会是更好的选择。它不仅按赛季给出完整的球员场均数据表还附带进阶命中率、球队胜负记录等丰富字段。更重要的是这些表格直接写在静态 HTML 里拿到页面源代码就能解析。2.2 用浏览器开发者工具定位关键元素拿到目标页面之后不要急着写代码先花几分钟做页面结构分析。拿球员场均数据页举例这类页面的表格通常长这样table idper_game_stats thead tr thPlayer/th thPos/th thAge/th thTm/th thG/th thPTS/th /tr /thead tbody tr tdStephen Curry/td tdPG/td td35/td tdGSW/td td74/td td26.4/td /tr /tbody /table我常用的定位步骤是在 Chrome 里打开目标页面按 F12 进入开发者工具。用左上角的“选择元素”按钮点击页面上的球员表格。在 Elements 面板里确认这个表格的id或class记下后续用来定位的标识符。展开tbody看每个tr是否都代表一行球员数据。这步看起来简单但特别关键。如果连目标表格的 id 都没确认代码里写选择器就是盲人摸象写一百行也抓不到正确位置。我见过不少新手拿着find_all(tr)把所有行都抓下来结果把页头、页脚、广告位全混进数据集里。2.3 需要区分静态表格和动态加载内容开发者工具里还有一个细节容易被忽略切换到 Network 面板刷新页面观察列表里的文档请求。如果页面数据是通过额外 JS 请求加载的你会在 Network 里看到多个 XHR/JSON 接口而初次返回的 HTML 里找不到球员行数据。如果页面数据直接存在于首个文档响应里说明它是静态表格BeautifulSoup 可以顺利解析。掌握这个判断方法能帮你少走很多弯路。我当时就是先确认了目标页面的表格是静态渲染才放心用 requests BeautifulSoup 组合。如果你选中的页面是动态加载那不建议硬刚可以考虑找同站点的静态版本页面或者直接抓取它背后的 JSON 接口这个后面单独展开。3. 环境准备与第一次请求从安装到拿到 HTML3.1 安装依赖库环境方面Python 3.9 以上即可不需要复杂配置。需要安装的库也就四个pip install requests beautifulsoup4 lxml pandasrequests负责发起 HTTP 请求拿到网页 HTML。beautifulsoup4负责解析 HTML。lxmlBeautifulSoup 的解析器引擎速度比默认的html.parser快。pandas最后整理数据用。有些教程会让你同步安装urllib3、certifi实际上 requests 会自动带过这些依赖不需要手动处理。如果安装时遇到网络问题可以换成国内镜像源这里就不展开说了。3.2 发起请求时的关键设置很多刚接触爬虫的读者习惯这样写url https://www.basketball-reference.com/leagues/NBA_2024_per_game.html resp requests.get(url) print(resp.status_code)这样写十有八九会看到403。为什么因为很多统计站点会识别没有浏览器特征的请求直接拒绝服务。正确做法是补上 User-Agent 和超时设置import requests headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } url https://www.basketball-reference.com/leagues/NBA_2024_per_game.html resp requests.get(url, headersheaders, timeout10) print(resp.status_code) print(len(resp.text))加上浏览器标识之后状态码通常就变成200响应体也包含页面内容了。timeout10也很重要避免目标站点响应异常时程序无限期卡住。这里我多说一句设置 User-Agent 属于模拟正常浏览行为不是恶意绕过也不涉及任何突破访问限制的操作。爬取公开数据时尊重目标站点的服务条款控制请求频率是基本的工程素养。3.3 BeautifulSoup 解析器与选择器拿到 HTML 字符串后就可以交给 BeautifulSoup 了from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, lxml) print(soup.title)lxml解析器的优势体现在大页面上如果表格有几百行甚至上千行解析速度差异会非常明显。如果你安装lxml遇到困难也可以退而求其次用html.parser功能上差别不大只是慢一些。常用定位方法有四种我按使用频率排序# 按 id 定位 table soup.find(table, idper_game_stats) # 按 class 定位 rows soup.find_all(tr, class_full_table) # 用 CSS 选择器 rows soup.select(#per_game_stats tbody tr) # 按属性定位 links soup.find_all(a, hrefTrue)如果是抓表格场景我偏爱 CSS 选择器因为它可以把“某个表格里的所有行”一次性表达清楚代码可读性也更好。后面实战部分就统一用这种方式。3.4 第一段可运行的抓取代码环境验证完毕我们先用一段最小化代码走通全流程只打印表头和前三行数据import requests from bs4 import BeautifulSoup url https://www.basketball-reference.com/leagues/NBA_2024_per_game.html headers {User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36} resp requests.get(url, headersheaders, timeout10) soup BeautifulSoup(resp.text, lxml) table soup.select_one(#per_game_stats) headers_row [th.get_text(stripTrue) for th in table.select(thead th)] print(表头:, headers_row[:10]) for tr in table.select(tbody tr)[:3]: cells [td.get_text(stripTrue) for td in tr.select(td)] print(cells[:6])跑通这段代码后你就完成了整体链路的第一个闭环。接下来的任务是把这个闭环扩展成完整的数据抓取函数并处理好数据清洗和最终导出。4. 主战场解析球员数据表并输出干净数据集4.1 目标与字段设计这次实战的目标是抓取一个赛季的球员场均数据表整理成一张包含球员名、位置、年龄、球队、出场数、场均时间、得分、篮板、助攻的 CSV 文件。为什么选这九个字段因为它们是最常用的分析维度年龄和出场数可以看球员负荷得分、篮板、助攻是基础输出位置和球队则用来做分组对比。字段不在多够用就行。设计好字段后还要确认每个字段在页面表格里的列顺序。直接看页面的表头就能确定比如Player, Pos, Age, Tm, G, MP, PTS, TRB, AST我这里特意少取了几列避免 CSV 文件过宽。如果你想抓完整字段把剩下的列名也加到映射里即可。4.2 分步实现抓取逻辑完整抓取代码可以拆成三步请求网页、解析表格、提取行数据。下面是核心实现import requests from bs4 import BeautifulSoup import pandas as pd def fetch_nba_table(url): headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36 } resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, lxml) table soup.select_one(#per_game_stats) return table, soup def parse_table_rows(table): records [] for tr in table.select(tbody tr): # 跳过表头分组行和合计行它们的行内可能包含 classthead 或包含 League Average if tr.get(class) and thead in tr.get(class): continue cells tr.select(td) if not cells: continue record { player: cells[0].get_text(stripTrue), pos: cells[1].get_text(stripTrue), age: cells[2].get_text(stripTrue), team: cells[3].get_text(stripTrue), games: cells[4].get_text(stripTrue), mpg: cells[5].get_text(stripTrue), pts: cells[6].get_text(stripTrue), trb: cells[7].get_text(stripTrue), ast: cells[8].get_text(stripTrue), } records.append(record) return records url https://www.basketball-reference.com/leagues/NBA_2024_per_game.html table, soup fetch_nba_table(url) all_records parse_table_rows(table) print(f共解析到 {len(all_records)} 条记录)这里有两个细节我踩过坑需要重点说明。第一页面里除了真正的球员数据行还会穿插“League Average”这类汇总行以及重复表头的间隔行。如果不对它们做过滤你会在 CSV 里看到一堆“混杂行”。我这里的过滤策略是检查行内是否存在td以及行元素是否带thead这个 class能过滤掉大部分干扰项。第二有些球员因为赛季中途交易会在同一张表里出现多行每行对应不同球队并且末尾还会有一个“TOT”合计行。如果你做的是球员维度的分析建议直接保留 TOT 行删掉拆分行如果你做的是球队维度分析则反过来。这是业务判断没有标准答案但代码里要留好选择的余地。4.3 数据清洗与类型转换解析得到的record全是字符串不能直接分析。你要做的清洗工作有三项处理空值部分单元格可能没有数据get_text(stripTrue)会返回空字符串。去除特殊符号例如年龄字段里可能出现35得分字段里可能出现26.4这些本身好处理但偶尔会有26.4前面带空白或换行的情况前面用stripTrue已经解决。类型转换把数字型字段转成float或int维度字段保留为str。清洗代码import math def clean_record(record): for key in [age, games, mpg, pts, trb, ast]: raw record[key].replace(, ) try: if . in raw: record[key] float(raw) else: record[key] int(raw) if raw else 0 except ValueError: record[key] 0.0 return record all_records [clean_record(r) for r in all_records]这段代码把空字符串处理成0避免后续 pandas 算均值时把整列变成字符串类型。当然0和缺失值在业务上含义不同如果你做的分析对缺失值敏感建议保留None而不是填0。具体取舍因分析目标而异我在第六节会再说。4.4 导出 CSV 供后续分析清洗完成后直接交给 pandasimport pandas as pd df pd.DataFrame(all_records) df.to_csv(nba_2024_per_game.csv, indexFalse, encodingutf-8-sig) print(df.head())这里我用utf-8-sig而不是utf-8主要考虑到后续如果要用 Excel 打开 CSV带 BOM 头的编码能避免中文乱码。虽然现在的数据都是英文字段但养成这个习惯没有坏处。输出预览player pos age team games mpg pts trb ast 0 Stephen Curry PG 35 GSW 74 26.4 26.4 5.1 5.1到这里你已经从网页上拿到了一张可供分析的结构化表格。看着 CSV 文件里整齐的列后续做分组聚合、可视化就都是水到渠成的事了。5. 遭遇 403 与空列表反爬机制和常见问题的实战排查5.1 请求失败时先看状态码别急着改代码爬虫跑不通的时候第一件事永远是看 HTTP 状态码。状态码就像服务器给你的反馈能精准告诉你问题出在哪一环。状态码含义常见诱因排查方向200正常无继续解析301/302重定向站点迁移或协议切换检查请求 URL 是否带 www403禁止访问被识别为爬虫补全请求头、降低频率404资源不存在URL 拼接错误、赛季参数写错检查网址429请求过多访问太频繁加延时500/502/503服务器异常站点自身问题稍后重试我在抓 NBA 数据时最常碰到的就是 403。开篇那个不带请求头直接requests.get的写法返回的就是 403。处理方式就是补上User-Agent和必要的Accept、Accept-Language头伪装成普通浏览器的访问。5.2 请求频率与延时控制另外即使状态码正常抓取多个页面时也要控制请求频率。目标站点并不欢迎高压访问短时间内发送大量请求轻则被限速重则封禁 IP。我常用的策略是import time urls [sprintf_url(season) for season in range(2020, 2025)] for url in urls: table, _ fetch_nba_table(url) records parse_table_rows(table) all_data.extend(records) time.sleep(3) # 每次请求间隔 3 秒3 秒算比较保守的间隔对几百行的表格任务来说多等一分钟无伤大雅却能显著降低触发反爬的风险。如果你要跑的是几十个页面的大任务建议把间隔调整到 5 秒以上并在代码里加入随机抖动import random time.sleep(3 random.uniform(0, 2))5.3 动态加载内容导致空列表的情况如果你解析后发现all_records是空的首先去确认一件事目标页面的数据是不是真的在静态 HTML 里。一个简单的验证方法在浏览器里右键“查看网页源代码”搜索表格 id 或者球员名。如果搜索不到说明数据是 JS 动态加载的requests 拿到的 HTML 里根本没有数据BeautifulSoup 自然找不到任何行。这种情况有几个处理方向找同一站点的静态版本页面很多老牌统计站点会提供不做动态渲染的入口。直接抓 XHR 接口在 Network 面板里找到返回 JSON 的请求用 requests 模拟那个接口。上无头浏览器方案比如 Playwright 或 Selenium让浏览器先执行完 JS 再抓取。我个人建议优先排查前两种因为无头浏览器虽然强大但资源占用大、运行不稳定把这个变量引入入门项目里得不偿失。先确认页面类型再决定技术路线是最稳妥的做法。5.4 编码与解析异常的处理最后还有一类常见问题出在编码上。虽然 NBA 统计站点以英文为主但有些页面会包含特殊符号比如é、ñ这类带重音符的字母。如果resp.text的编码判断错误你会看到乱码进而影响球员名匹配。处理办法很简单在拿到响应后手动确认编码resp.encoding utf-8如果你不确定目标站点的编码可以让 requests 自动探测resp.encoding resp.apparent_encoding这里也有个取舍apparent_encoding在部分页面上探测结果可能不准反而把正常 text 转出乱码。所以如果明确知道站点是utf-8就直接硬编码别让程序自作主张。6. 抓取数据如何顺利喂给 pandas衔接与收尾技巧6.1 用 pandas 读取并验证数据集CSV 导出只是中间产物真正的分析要在 pandas 里完成。这里有个好习惯不要直接拿原始 DataFrame 开干先做一轮完整性检查。import pandas as pd df pd.read_csv(nba_2024_per_game.csv) print(df.shape) print(df.columns.tolist()) print(df.dtypes) print(df.isna().sum())这四行能帮你快速确认行数是否合理、列名是否符合预期、字段类型是否正确、缺失值有多少。特别是dtypes如果你发现pts列显示为object而不是float64说明清洗阶段有漏网之鱼需要回头检查是否有无法转换的字符串。6.2 数据质量自查清单我在实际项目中遵循一套简单的数据质检流程每抓完一次数据都会跑一遍行数核对和目标站点标注的记录数做对比误差不超过 2%。重复项检查df.duplicated().sum()球员 ID 或“球员球队”组合不应该重复。极端值抽查比如场均得分超过 35 分的球员手工核对一下是否真实存在。汇总行检查确认是否混入了“League Average”之类的非球员记录。这四步做完我才会放心把数据用于后续分析。很多分析报告的问题不在算法而在数据源头混进了脏数据这个自查流程帮我挡掉了不少尴尬。6.3 封装成可复用函数避免重复劳动如果你预感到后续还会抓不同赛季、不同数据类型建议把前面的代码封装成模块而不要每次复制粘贴一大段。一个最简单的做法是定义统一函数def scrape_nba_season(season, output_csv): url fhttps://www.basketball-reference.com/leagues/NBA_{season}_per_game.html table, _ fetch_nba_table(url) records parse_table_rows(table) records [clean_record(r) for r in records] df pd.DataFrame(records) df.to_csv(output_csv, indexFalse, encodingutf-8-sig) return df for season in [2020, 2021, 2022, 2023, 2024]: scrape_nba_season(season, fnba_{season}_per_game.csv) time.sleep(3)这样你手里就有了一个迷你抓取工具后续要分析历史赛季一行代码就搞定。我还习惯把第一次请求到的 HTML 保存到本地后续调试解析逻辑时直接读本地文件既快又不会反复打扰目标站点with open(page.html, w, encodingutf-8) as f: f.write(resp.text)调试时就把fetch_nba_table里的requests.get替换成本地文件读取等解析逻辑稳定后再切换回在线请求。这个习惯帮我节省了大量时间也避免了调试期间的高频请求。关于把抓取数据衔接给分析环节我最后想再说一点自己的体会爬虫不是项目的终点分析才是。所以代码里所有设计都应该是为了让数据更快、更准地进入分析管道。字段命名尽量直接用英文驼峰或下划线风格清洗逻辑尽量幂等导出文件尽量带版本号或日期。这些习惯刚开始看不出来差别等数据集多了你会庆幸自己当时做得够细致。用真实项目驱动学习比任何教程都有效。把这一段“数据源前置”代码跑通你不仅掌握了 BeautifulSoup 的基本用法也真正拥有了一个可以不断扩展的体育数据管道。如果你后续打算做球员预测、球队战绩分析或者可视化看板这份数据集和抓取框架就能直接当底座用了。