做NBA数据分析这段时间我最大的感触是真正难的不是后面跑模型、调参数而是最开始能不能拿到一份干净、完整、让你放心往里灌的分析数据。很多人学Python数据分析一上来就学pandas、matplotlib结果真到自己动手做个项目卡在第一步——数据从哪来手工复制粘贴Excel累到怀疑人生找现成数据集又经常碰到字段不全、更新不及时。这时候用Python写一个小爬虫去抓公开的NBA数据源就成了性价比最高的选择。这篇文章会顺着一条完整的实操线走下来用requests获取网页用BeautifulSoup解析HTML表格把球员的场均数据整理成结构化DataFrame最后落成CSV文件。整个流程围绕“数据分析前置”这个定位展开不搞花哨的分布式爬虫也不碰需要登录和复杂反爬的网站就是老老实实从静态网页里把数据抠出来、洗干净、存下来。无论你是刚看完Python基础语法想找练手项目还是已经跑过几个分析demo但没碰过爬虫这篇都值得读完。顺便说一句文中所有示例都以公开可访问的NBA统计页面为目标抓取频率也控制在合理范围这不仅是技术上的自觉也是做数据采集这行最基本的规矩。1. 项目定位与整体思路拆解1.1 为什么“数据源采集”是数据分析的前置环节多数人理解的数据分析是画图、是建模、是跑回归但实际做起来数据获取和数据清洗通常要占掉整个项目七成以上的时间。NBA数据尤其如此球员每场比赛会产生几十项统计再加上赛季、球队、对手、主客场等维度数据源的选择直接决定了后续分析能走多远。举个例子你想分析“得分后卫的年龄和场均得分有没有关系”。这个命题听起来简单但落地时立刻要面对几个问题场均得分去哪里查是查官方口径还是第三方口径要不要过滤只有几场出场记录的边缘球员伤病缺席的场次算不算分母这些问题如果不在采集阶段就考虑清楚后面所有图表和结论都站不住脚。这就是“前置”二字的含义爬虫不只是把网页存下来而是在采集阶段就带着分析目标去设计字段、清洗规则和存储结构。目前获取NBA公开数据的常见渠道有三类官方或第三方API、现成数据集仓库、自己写爬虫。没有绝对的好坏但各自的适用场景差别很大。渠道优点缺点适合场景官方API如stats.nba.com数据结构化好、字段全有访问限制、需要研究接口规则做长期稳定的数据管道现成数据集Kaggle/GitHub CSV拿来即用、适合练手滞后严重、字段不可定制快速验证分析思路自己写爬虫requestsBeautifulSoup完全可控、能取到最新数据、想抓什么抓什么需要处理反爬和页面结构变化定制化需求、学习爬虫和网页解析我的建议是练手阶段三种都用一遍。先拿现成数据集跑通分析逻辑再用爬虫去拿最新数据做对比你会直观感受到“自己拿到的数据”和“别人整理好的数据”在工作量上的差距也能更好的理解API和网页爬取各自的边界。1.2 技术选型为什么是 requests BeautifulSoup 这个组合市面上Python爬虫工具一抓一大把Scrapy、Selenium、Playwright、pandas.read_html各有各的用武之地。这个项目选择requests加BeautifulSoup并不是因为它最强而是因为它恰好卡在“数据分析前置任务”的最优点上轻量、可控、学习成本低同时足够解决绝大多数静态表格的抓取需求。先看Scrapy。它是个完整的爬虫框架有并发调度、中间件、管道、日志系统非常适合大规模采集。但正因为完整它的学习曲线也比较陡一个简单的单页面抓取任务要理解项目结构、spider类、item管道对只想拿数据做分析的人来说有点杀鸡用牛刀。Selenium和Playwright则解决的是动态渲染页面的问题启动浏览器、等待元素、截图调试资源开销大、速度慢。如果目标页面里数据是明文HTML就能拿到完全没必要让浏览器参与进来。那pandas.read_html呢我承认它很诱人一行代码就能把页面里所有表格读成DataFrame。但它的短板有两个一是可控性差页面上一堆无关表格时你得手动指认目标表格二是它对HTML结构容错能力一般遇到复杂的嵌套表头、合并单元格解析出来的数据经常是歪的反而不如自己写循环解析来得稳。对于一个数据最终要进分析流程的场景字段位置的精准性比对代码行数更值钱。所以我的选择逻辑很简单目标页面是静态HTML表格页面规模不算夸张用requests拿到文本交给BeautifulSoup按DOM结构提取全程自己掌控出了错也好排查。等你跑通了这套流程再去看Scrapy、Playwright这些工具会发现很多概念是相通的那时候再按需切换也不迟。2. 数据源分析与页面结构拆解2.1 目标数据源与字段设计这个项目的目标数据源选的是Basketball-Reference网站上的赛季场均数据页。选择这个页面的原因很直接它的URL规律清晰、数据表是标准HTML结构、没有登录墙对我们这类低频率、教学性质的抓取非常友好。更重要的是这个页面的数据宽表几乎覆盖了篮球数据分析的基础维度。所谓“场均数据”对应的是NBA官网每场比赛统计按出场次数归一化后的结果。页面的核心表格里每一行代表一名球员在一个赛季的场均贡献主要字段包括字段缩写含义分析价值Player球员姓名主键之一注意有同名的可能Pos位置PG/SG/SF/PF/C位置维度的群体分析Age年龄年龄与表现关系研究Tm球队缩写球队维度筛选G / GS出场数 / 首发数判断球员样本量是否充足MP场均上场时间负荷与效率分析FG / FGA / FG%命中数 / 出手数 / 命中率投篮产量与效率3P / 3PA / 3P%三分命中数 / 出手数 / 命中率外线能力评估FT / FTA / FT%罚球命中 / 出手 / 命中率制造犯规能力TRB / AST / STL / BLK篮板 / 助攻 / 抢断 / 盖帽基础全面性指标TOV / PF失误 / 犯规负面行为指标PTS场均得分最常用的进攻产出指标在开始写爬虫之前先把这些字段列出来是有必要的。因为后续做分析时你会发现同样叫“命中率”投篮命中率、三分命中率、罚球命中率是三套完全不同的数据如果采集的时候字段名称没理清后面画图时很容易张冠李戴。2.2 看懂表格的HTML结构Chrome开发者工具怎么用拿到网页之后第一步不是写代码是先搞清楚目标数据长在HTML的哪个位置。打开Chrome在目标页面右键选择“检查”或者直接按F12进入开发者工具用左上角的选取按钮点一下数据表格你会在Elements面板里看到对应的HTML片段。Basketball-Reference这个站的表格结构非常典型大致长这样table idper_game_stats classstats_table sortable thead tr thPlayer/th thPos/th thAge/th ... /tr /thead tbody tr tha href/players/a/abc123.htmlAlex Abrines/a/th tdSG/td td22/td ... /tr /tbody /table注意两个关键细节第一表格有个固定的idper_game_stats这就相当于给表格起了个唯一名字代码里可以直接用这个id定位精准且不容易误伤页面上的其他表格第二每一行的第一个单元格是th而不是td里面放的是球员姓名。这个细节很多人会忽略如果用统一的方式去找td球员名字列就会漏掉字段数就会对不齐。我在第一次解析时就是在这里栽了跟头看了半天输出总觉得少一列。另外这个页面的表头其实是半透明的“sticky header”就是滚动时固定在页面顶部那种效果。虽然视觉上它是悬浮的但DOM结构里 表头仍然在thead标签里所以不用在爬虫上特殊处理直接按thead和tbody分开解析即可。3. 核心实现从请求到干净的DataFrame3.1 请求头设置与请求频率控制先搭环境。Python版本建议3.9以上用到的库就四个requests、beautifulsoup4、pandas、lxml。安装命令一条搞定pip install requests beautifulsoup4 pandas lxmllxml是BeautifulSoup的解析引擎比默认的html.parser速度快不少解析复杂表格时页更稳强烈建议一起安装。然后是请求部分。很多爬虫新手最容易踩的坑就是直接requests.get(url)一把梭然后收到一个403或者被重定向到验证页面。这不是你代码写错了而是网站服务器看到请求头里写着Python-urllib/3.x直接判定为爬虫在入口就把你挡掉了。解决方式很朴素把请求头伪装成正常浏览器的样子。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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: en-US,en;q0.9, Connection: keep-alive, } url https://www.basketball-reference.com/leagues/NBA_2024_per_game.html try: resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() print(请求成功状态码, resp.status_code) except requests.RequestException as e: print(请求失败, e)timeout10一定要加。爬虫跑起来最怕的不是返回失败而是请求挂在那里不响应整个程序卡死。设置超时后超过10秒没回应就抛异常你就可以在异常处理里做重试或者跳过不至于让整个任务停摆。请求频率控制放到后面批量爬取的部分细说这里先记住一个大原则抓取公开数据不是竞赛用最慢的节奏拿全数据比用最快的速度被封IP强一百倍。3.2 使用 BeautifulSoup 解析球员数据表格请求拿到的是整页HTML字符串下一步是让BeautifulSoup把它变成可以查询的DOM树。from bs4 import BeautifulSoup soup BeautifulSoup(resp.text, lxml) # 通过 id 定位到目标数据表 table soup.find(table, idper_game_stats) if table is None: raise ValueError(未找到 per_game_stats 表格页面结构可能发生了变化)定位到表格之后先不要着急一股脑去遍历所有行。这个页面的表格包含表头行和数据行如果直接table.find_all(tr)表头会被混进数据里。正确做法是分开处理thead和tbody。tbody table.find(tbody) rows tbody.find_all(tr) print(数据行数量, len(rows))每一行的单元格提取也要注意那个细节球员姓名在第一个th里其余字段在td里。为了统一我习惯用row.find_all([th, td])一次性提取所有单元格这样不管它是th还是td顺序都不会乱。import pandas as pd column_names [ Player, Pos, Age, Tm, G, GS, MP, FG, FGA, FG%, 3P, 3PA, 3P%, 2P, 2PA, 2P%, eFG%, FT, FTA, FT%, ORB, DRB, TRB, AST, STL, BLK, TOV, PF, PTS ] records [] for row in rows: cells row.find_all([th, td]) if len(cells) 0: continue values [cell.get_text(stripTrue) for cell in cells] records.append(values) # 转成 DataFrame去掉表头行和可能出现的分隔行 df pd.DataFrame(records, columnscolumn_names[:len(records[0])]).dropna(howall) df df[df[Player] ! Player] print(df.head())多提一句records[0]这个位置在实际解析时有可能是空的或者只有少量单元格因为网页里偶尔会出现合计行或分隔行。所以我在转换成DataFrame之前会先做一次非空过滤保证后面的清洗不会处理到空行。3.3 数据清洗与类型转换别急着画图现在拿到的是“看起来挺像样”的DataFrame但它的每个单元格依然是字符串。比如PTS列里存的是19.4Age列里是23年龄和得分无法参与任何数值计算。这一步必须做类型转换顺带也要处理缺失值。数据清洗时我一般分三步走。第一步去掉噪音字符比如某些球员名字前面带星号代表他入选了当季全明星这个符号对分析来说不是字段内容直接清理掉。第二步把统计列从字符串转成数值转换失败的统一置成NaN。第三步检查每一列的空值数量决定是填充还是删除。# 1. 清理球员姓名中的全明星标记 df[Player] df[Player].str.replace(*, , regexFalse).str.strip() # 2. 定义数值列百分比列转成浮点后续分析时按小数使用 numeric_cols [ Age, G, GS, MP, FG, FGA, FG%, 3P, 3PA, 3P%, 2P, 2PA, 2P%, eFG%, FT, FTA, FT%, ORB, DRB, TRB, AST, STL, BLK, TOV, PF, PTS ] for col in numeric_cols: df[col] pd.to_numeric(df[col], errorscoerce) # 3. 查看空值情况 print(df.isna().sum()) print(df.dtypes)关于百分比列的取值这里要特别提醒Basketball-Reference页面上的百分比是以47.6这种形式展示的意思是47.6%不是0.476。如果你直接拿去做回归分析量纲会和别的字段不一致。所以要么在清洗阶段统一除以100要么在分析阶段时刻记得它的单位。我习惯在清洗阶段就统一成小数形式省得后面反复确认。做完清洗之后你会发现数据对比刚才已经干净了很多。此时再用df.describe()看一遍各列的均值、最值、分位数如果发现PTS最大值是36这种合理的数值说明解析基本没问题。3.4 输出CSV文件编码是个隐形坑数据清洗干净之后就该落盘了。CSV是数据分析最常见的数据交换格式写出来之后后续的pandas读取、Excel查看、甚至导入数据库都十分方便。df.to_csv( nba_2024_per_game.csv, indexFalse, encodingutf-8-sig )注意这个utf-8-sig。如果直接写utf-8生成的CSV用Excel打开时球员姓名里包含英文没什么大问题但一旦有中文内容就很容易乱码。加-sig会在文件开头写入BOM标记Excel就能正确识别编码这是处理CSV输出时非常实用的小细节。除了CSV我还会同时导一份JSON或者其他格式存档。大数据分析场景下数据源最终大概率要进数据库所以下面这段存SQLite的代码也一并附上。SQLite不需要单独安装服务端Python标准库自带适合作为本地分析仓库。import sqlite3 conn sqlite3.connect(nba_stats.db) df.to_sql(per_game_2024, conn, if_existsreplace, indexFalse) conn.close() print(数据已写入 SQLite)数据存进SQLite之后以后做多赛季对比分析就不用每个赛季都重新爬一次采集、多次复用这才是“数据源”该有的样子。4. 批量抓取多赛季让数据源活起来4.1 多赛季URL规律与循环抓取只抓单个赛季的数据有点浪费这套爬虫。很幸运的是Basketball-Reference的URL规律非常清晰赛季年份直接体现在路径里https://www.basketball-reference.com/leagues/NBA_2024_per_game.htmlhttps://www.basketball-reference.com/leagues/NBA_2023_per_game.htmlhttps://www.basketball-reference.com/leagues/NBA_2022_per_game.html规则就是NBA_{年份}_per_game括号里的年份指赛季结束年份。比如2024页面对应的是2023-2024赛季。有了这个规律批量抓取就是一次循环组装URL的事情。这一段代码我建议你一定要加上两个保护措施随机睡眠时间和异常捕获。前者是为了不给目标服务器造成压力后者是因为网络请求这东西永远不可能100%稳定不能因为一个赛季失败就让整个任务停下来。import random import time import requests from bs4 import BeautifulSoup import pandas as pd def fetch_season(year, headers, retries3): url fhttps://www.basketball-reference.com/leagues/NBA_{year}_per_game.html for attempt in range(retries): try: resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() return resp.text except requests.RequestException as e: print(f{year} 赛季第 {attempt1} 次请求失败{e}) time.sleep(10) return None all_frames [] for year in range(2020, 2025): html fetch_season(year, headers) if html is None: print(f跳过 {year} 赛季) continue soup BeautifulSoup(html, lxml) table soup.find(table, idper_game_stats) if table is None: continue tbody table.find(tbody) if tbody is None: continue rows tbody.find_all(tr) records [] for row in rows: cells row.find_all([th, td]) if not cells: continue values [cell.get_text(stripTrue) for cell in cells] records.append(values) df pd.DataFrame(records, columnscolumn_names[:len(records[0])]) df df[df[Player] ! Player].dropna(howall) df[Season] f{year-1}-{str(year)[2:]} all_frames.append(df) print(f{year} 赛季抓取完成共 {len(df)} 行) time.sleep(random.uniform(3, 6))random.uniform(3, 6)的含义是每次请求结束后随机等3到6秒。别小看这几秒钟它让整个请求序列变得不那么“机器”更接近于人类浏览节奏。我自己用这个节奏抓过2000多个页面从来没有出过问题。合并所有赛季数据时记得加一列Season用来区分年份否则几个赛季的数据堆在一起就没法做时间序列分析了。final_df pd.concat(all_frames, ignore_indexTrue) print(final_df.shape) final_df.to_csv(nba_per_game_multi_seasons.csv, indexFalse, encodingutf-8-sig)4.2 遇到JS渲染页面的处理思路静态页面用requests直接抓是理想情况但如果你换一个数据源发现requests.get拿回来的HTML里找不到数据表格那十有八九是目标页面的数据是通过JavaScript异步加载的。这时候先用requests硬抓就不管用了得换思路。我的排查顺序是这样的先在浏览器里打开目标页面按F12切到Network面板刷新页面然后在筛选栏里选Fetch/XHR看数据请求都发到了哪里。很多所谓“动态页面”实际上是在你打开页面时通过一个后台接口拿到JSON数据再渲染到表格里的。你只要从Network面板里找到这个JSON接口的URL直接用requests去请求它解析JSON反而比解析HTML还省事。举个例子有些NBA数据分析平台的数据面板表格对应的真实数据源是一个返回JSON的接口接口地址和参数都藏在Network面板里。找到它之后用resp.json()拿到Python字典或列表剩下的数据清洗逻辑基本不用变。这个“先找接口再上浏览器自动化”的思路能让你的爬虫性能提升一个数量级。只有一种情况我才会考虑上Selenium或者Playwright数据是页面加载后通过复杂交互才渲染出来的而且完全找不到对应接口。但即便如此我也建议先把页面往下滚动一遍看看是否有加载更多按钮很多分页内容其实也是接口请求。浏览器自动化是最后的兜底方案不是默认选择。5. 常见问题与排查技巧实录5.1 高频问题速查表爬虫写完之后运行阶段才是真正的问题爆发期。我把这几年遇到过的典型问题整理成一张速查表每个问题都标注了现象、可能的原因和解决办法你跑代码的时候大概率会遇到其中一两个。现象可能原因排查与解决办法返回403缺少User-Agent / 被限流加完整headers降低请求频率检查是否被临时封禁返回200但找不到table页面改版 / 表id变了打印HTML前2000字符重新定位表格的class或id数据行数量对不上有合计行、分隔行参与解析只遍历tbody下的行按Player列过滤无效行球员名字后面带星号全明星标记混入用str.replace清洗不影响分析数值列无法计算字符串类型没转换用pd.to_numeric(..., errorscoerce)强制转换中文输出乱码编码不统一写CSV用utf-8-sig控制台输出前设置PYTHONIOENCODINGutf-8抓取几个页面后报错IP被临时限制立即停止任务截图记录时间等待较长时间再继续请求超时挂起未设置timeout所有请求统一加timeout10必须处理异常5.2 字段错位和复杂表头的坑最让人头疼的排错场景是表格解析完之后字段串位。比如球员得分和年龄段位了第一列变成年龄第二列变成球员名。遇到这种情况别急着改代码先把原始HTML打印出来认真看一行数据的单元格结构。复杂网页表头会超出这个项目的范畴这里只提醒一种常见结构双行表头。有些网站为了让长表格更易读会在一行表头之上再加一行分类表头比如先写“投篮”下面再拆成“命中”、“出手”、“命中率”。如果解析时直接把所有th当成一列来读字段就全乱了。解决办法也简单要么用thead里的最后一组th作为实际列名要么在代码里手动定义列名让DataFrame结构和预期完全对齐。我在代码里用的就是手动定义column_names的方式。虽然是硬编码但从稳定性角度看它反而是最不容易出错的选择。爬虫这东西追求的不只是能跑而是长期能跑、跑挂了容不容易修。5.3 反爬与访问伦理别把自己弄进小黑屋聊爬虫绕不开反爬话题但这个项目的目标是公开数据、低频率采集所以我只说和它相关的几条经验。第一条严格遵守请求间隔别贪快。同一个人太快地重复请求服务器会直接判定为异常行为。我在多个数据站点的经验是单次访问间隔保持3秒以上批量任务加随机浮动基本不会触发风控。第二条别拿大规模并发去测试别人的服务器这不只是技术问题也是基本的网络礼仪。第三条遵重网站的公共条款和服务限制如果你发现目标站点在条款里明确禁止爬取就不要强行采集尊重别人服务器的承载能力也是在保护你自己。最后一条经验也是我个人的习惯抓下来的HTML原样保存一份。爬虫和数据分析不一样页面结构是随时可能变化的。你把原始HTML存成文件哪怕过两天解析逻辑写错了还能重新解析不需要重新请求。这个小习惯在排错时能省掉大量重复请求既快又稳。6. 让爬完的数据直接进入分析6.1 用pandas做第一次探索性分析数据到手终于到了整个项目“前置”二字兑现的时候。用pandas读CSV做一次快速探索验证一下数据能不能支撑起后续的分析。import pandas as pd df pd.read_csv(nba_per_game_multi_seasons.csv) print(df.shape) print(df.columns.tolist()) # 筛选本赛季出场数超过30场的球员按场均得分排序 valid df[(df[Season] 2023-24) (df[G] 30)] top_scorers valid.nlargest(10, PTS)[[Player, Pos, Age, Tm, G, PTS, TRB, AST]] print(top_scorers.to_string(indexFalse))这段代码把球员数据变成了真正的分析结论雏形。你可以看到场均得分榜前十名里哪些是经验丰富的老将、哪些是正值当打之年的中生代位置分布怎么样助攻和篮板是不是跟着得分走。这些观察虽然没有跑复杂的模型但已经是在做数据分析而且每一步都基于你自己采集、清洗过的数据感觉完全不同。再进一步可以做一个年龄和得分的简单相关性检查sample valid[[Age, PTS, MP]].dropna() print(sample.corr())出来的结果虽然只是线性相关但在高年级号的球员往往出场时间也更多得分跟着上涨这个趋势用数据验证了之后你后续再去做更复杂的多元回归地基就是稳的。6.2 数据源的扩展方向与增量更新这套爬虫跑通之后它可以向几个方向延展。第一个方向是字段扩展。Basketball-Reference同一套URL体系下还有投篮热区、高阶统计、每36分钟数据、每百回合数据等多个页面解析逻辑几乎一样唯一需要改的是表格id和字段清单。抓一次多抓几种口径分析时的视角就会丰富很多。第二个方向是时间维度的扩展。把批量抓取从5个赛季扩展到20个赛季就可以做球员职业生涯轨迹分析、联盟打法和节奏演进分析等长周期课题。只要保证一次一次地爬频率控制好数据量换来的分析价值是几何级上升的。第三个方向是增量抓取。NBA赛季进行中数据页面每天都在更新。你可以设计一个每天运行一次的定时任务只抓最新日的比赛数据追加到现有CSV或者SQLite里。这个思路就是把爬虫从一个一次性脚本升级成可持续运行的数据管道和标题里的“数据源”定位完全吻合。我个人在实际操作里最想提醒你的其实还是我在前面反复说过的那句话爬虫永远只是第一步真正决定一个分析项目上限的永远是数据质量。而这个质量在你写第一行requests.get的时候就已经开始被决定了。先把数据源管好了分析工具再多也只是锦上添花。