简介:一个基于NBA数据的Python综合实战压缩包,聚焦爬虫、数据分析与可视化全流程,适合有Python基础、正在做课程设计或想提升数据采集能力的开发者。包内总共36个文件,以Python脚本、Web模板、配置文件与SQL脚本为主,其中.py负责请求解析与数据清洗,.html和.js用于图表展示,工具脚本还包含数据迁移与运行配置,整体约279KB,结构轻量便于逐行研读。目前已有4682人学习,项目围绕腾讯体育NBA数据展开,演示了从页面抓取、字段提取、存储入库到可视化呈现的完整链路,并保留迁移脚本和管理入口,方便二次扩展。通过本实战,读者可以掌握Scrapy或BeautifulSoup与Pandas、Matplotlib的组合使用,理解真实数据项目中的常见分工与工程习惯,是一份适合仿写和改写的优质范例。
1. 爬虫、分析、可视化三件套,一个文件装的是一条数据流水线
你用搜索引擎看到这个以.zip结尾的项目名时,先别急着解压整个压缩包。“Python实际爬虫”是一层,“数据分析”是一层,“数据可视化”又是独立的一层;把三个词放在一个项目里,意味着你已经拿到了一条从零获取数据、清洗成结构表、再输出成可读图表的完整流水线。团队里常说的“从0到1搭数据看板”,其实就是靠这三个环节的串接,而不是靠某一个单点工具。
这篇文章会把这条技术链路拆开:我们以对一个公开的 JSON 接口做抓取为起点,一步步完成爬虫的会话管理、反爬规避与断点落盘,再进入 pandas 的清洗、聚合与相关性分析,最后通过 matplotlib、seaborn 和 ECharts 把结果输出成值得向上汇报的图表。就算你拿到的 zip 里代码写得比较粗糙,顺着这条链路也能把它改造成一套自己可控、可调度、可复盘的项目。这件事适合两类人来读:一类是想靠一个完整项目练手的 Python 学习者,另一类是要交付业务数据报表,但经常被“数据在哪儿、数字对不对、图能不能讲清结论”这三连问拉回原地的工程师。
2. Python爬虫实战主链路:requests抓取、反爬规避与断点落盘
爬虫实战的核心不是“能发请求”,而是“稳定地、合法地、按计划地发请求”。真正到了拿数据这一步,我们就通过 requests 库构造一个可复用、可重试、带限速的会话,并把抓到的结果按行落到磁盘上。这一章先梳理请求层,因为后续数据分析的每个结论都是建立在这批数据之上的,请求层的质量决定了后面所有工作的可信度。
2.1 用 requests 构造带 Session、重试和超时控制的稳定爬虫
常见做法是把 Session 和重试逻辑封装成一个工厂函数,后续每个页面抓取都复用同一个会话。这样做的收益是 TCP 连接可以被复用,Header 不需要在每次请求时重复构造,同时把连接池、重试策略集中到一个地方。
import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry def build_session(retries: int = 3) -> requests.Session: session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9", }) retry = Retry( total=retries, # 总重试次数 backoff_factor=0.5, # 重试间隔按指数增长 status_forcelist=[429, 500, 502, 503], # 触发重试的 HTTP 状态码 allowed_methods=["GET", "POST"], # 幂等方法才重试 ) adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=20) session.mount("https://", adapter) session.mount("http://", adapter) return session代码逻辑说明:Retry(total=3)表示一次请求最多尝试四次;backoff_factor=0.5会让重试间隔依次为0.5s、1s、2s,这是处理瞬时抖动最常用的指数回退节奏。status_forcelist里放 429 和 5xx 状态码,意味着遇到限流或服务端临时故障时自动重试,而 404 这类错误不会触发。allowed_methods中只保留 GET 和 POST,避免 PUT、DELETE 这类非幂等操作在代理层被意外重放。
你可能看到过有些人直接用requests.get(url)写循环,这在演示脚本里能跑通,但遇到真实网络环境就很容易中断。把重试和连接池收进build_session(),抓 100 个页面和抓 1 个页面的代码复杂度差别不大,可靠性却完全不同。
2.2 反爬规避参数怎么设:随机 User-Agent、延时区间与请求频率控制
多数站点对高频率的相同 UA 会有非常简单的识别逻辑,因此需要在每次请求前轮换 User-Agent,并保持一个有随机性的延迟。
import time import random from typing import List, Optional USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", ] def fetch_with_pacing(url: str, session: requests.Session, delay_range: tuple = (1.0, 2.5)) -> Optional[dict]: session.headers["User-Agent"] = random.choice(USER_AGENTS) try: resp = session.get(url, timeout=(3.05, 15)) resp.raise_for_status() return resp.json() except requests.RequestException as e: print(f"请求失败 {url}: {e}") return None finally: time.sleep(random.uniform(*delay_range))参数说明:timeout=(3.05, 15)分别指定连接超时和读取超时,连接超时不建议设得太久,否则线程容易被串行阻塞。delay_range=(1.0, 2.5)表示每次请求后固定睡一个随机秒数,这个值要根据目标接口的实际数据量和请求成本调整;对已公开的免费接口可以适当缩减到0.5~1.2,对商业站点要从2~5起步并在实际观察后收紧。
一个容易踩的细节是:延迟放在finally中,意味着即使请求失败或抛异常,也会执行睡眠。这个设计是为了避免异常分支瞬间连续打到后端,表现上更接近正常用户的操作节奏。
2.3 断点续抓与 JSONL 落盘
抓取过程不可能永远不中断,断电、超时、被限流都可能让任务提前退出。常见的做法是把每一条响应解析后的数据,以 JSONL 格式一行一行追加写入文件,而不等全部抓完再整体写。这样即使中断,已抓到的内容也保留在磁盘上。
import json from pathlib import Path SAVE_PATH = "data/comments.jsonl" def fetch_all(endpoints: list[str]) -> None: session = build_session() Path(SAVE_PATH).parent.mkdir(parents=True, exist_ok=True) with open(SAVE_PATH, "a", encoding="utf-8") as f: for url in endpoints: payload = fetch_with_pacing(url, session) if payload: f.write(json.dumps(payload, ensure_ascii=False) + "\n") f.flush()这个实现的落盘逻辑相对简洁,但两个细节值得记住。第一,ensure_ascii=False保证中文按可读文本写入而不是变成\uXXXX转义序列,后续用 pandas 读取时也减少一层解码陷阱。第二,每写一行主动调用flush(),把缓冲强制刷到磁盘,代价是少量 I/O 开销,换来的是进程被强杀时最多丢一行的容错;如果你对写入量特别大,可以每 20 行或每 500KB 刷一次,用条件判断来控制。
list[str]的泛型注解要求 Python 3.9 以上,python 安装版本小于 3.8 的话建议改成List[str]并导入typing,否则会在语法层面直接报错。数据处理部分的脚本再复杂,也建议先把 Python 版本固定下来,避免在采集一半时暴露语法兼容问题。
3. pandas数据分析的清洗、聚合与相关性验证
爬虫部分拿到的是零散、混乱的 JSON 行,数据分析的第一步是把它变成可以作为结论依据的整洁表格。这里之所以用 pandas 而不是直接手写统计逻辑,是因为它在分组聚合、缺失值处理、时间字段解析上都有成体系的方法,且一个 DataFrame 能直接衔接后续的可视化库。
3.1 脏数据的四种典型形态与清洗规则
实战项目中的脏数据通常表现为四种形式:字段缺失、类型错位、重复记录、时间格式不一致。它们不会在一次read_json()后直接暴露,而是会在你做均值、排序或分组时突然变成一行报错。所以清洗不能只放在数据导入之后,而应该在每个环节结束时做一次现场质检。
import pandas as pd df = pd.read_json("data/comments.jsonl", lines=True) print(df.info()) print(df.isna().sum()) df = df.drop_duplicates(subset=["comment_id"]) df["rating"] = pd.to_numeric(df["rating"], errors="coerce") df["created_at"] = pd.to_datetime(df["created_at"], errors="coerce") df["comment_len"] = df["content"].fillna("").str.len() df = df.dropna(subset=["rating"]) df = df[df["created_at"] > "2024-01-01"]代码背后的逻辑:pd.to_numeric(errors="coerce")会在无法转换时填充 NaN 而不是抛异常,这使问题字段能被集中观察和过滤。fillna("").str.len()的作用是把content列中缺失值统一作为空字符串计算长度,避免长度统计被 NaN 污染。dropna(subset=["rating"])删掉评分字段缺失的行,如果保留这些行进入均值计算,结果会带明显偏差。
下面的表格列出了四类问题最常用的处置策略:
| 脏数据形态 | 判断方式 | 推荐处置 | 注意事项 |
|---|---|---|---|
| 字段缺失 | isna().sum() | 优先填充,无法填充则删除行 | 删除前先看缺失比例是否超过 30% |
| 类型错位 | df.dtypes | pd.to_numeric或astype | 字符串里的隐藏空格会干扰转换 |
| 重复记录 | duplicated(subset=[...]) | drop_duplicates保留第一条 | 去重依据要选业务意义上的主键 |
| 时间格式不一致 | object类型字段 | pd.to_datetime(errors="coerce") | 解析后留意时区,统一用北京时间 |
清洗规则不能写成一锤子买卖,我一般会把以上每个步骤做成独立函数,再在调度脚本中按顺序调用。这样当数据结构发生变化时,你能站在函数层面看到是哪一步出了问题,而不是面对一整段 80 行的线性代码无从下手。
3.2 用 groupby 与 pivot_table 做维度拆解
当表格变得干净以后,需求通常会变成“某个维度在不同条件下的表现差异”。最高频的两类操作是分组聚合和数据透视。下面以“按城市统计评分均值与评论量”为例展示。
city_min_count = 10 district_stats = ( df.groupby("city")["rating"] .agg(["mean", "count", "std"]) .query(f"count >= {city_min_count}") .sort_values("mean", ascending=False) ) cross_table = df.pivot_table( index="city", columns="user_level", values="rating", aggfunc="mean", )用上面的代码前,先理解一个区别:groupby的范围是纵向维度,适合算“每个城市整体表现”;pivot_table则把另一个字段铺成多列,适合对比“同一城市内部不同用户等级的评分差异”。例子中query("count >= 10")过滤掉评论量太小的城市,因为少样本的均值极不稳定,直接参与排序会误导结论。
假如city字段有很多低频值,可以先生成top_cities = df["city"].value_counts().head(10).index再对原表做过滤,这样后续图表也不会被长尾的微小类别撑裂坐标轴。
3.3 用相关系数验证变量之间的真实关系
维度拆解是描述,相关性分析则负责解释。以爬取的商品评论为例,我们想知道“评论字数”和“评分高低”之间是否存在可见的线性关系;“带图数量”是否影响用户评分。pandas 的corr()可以快速给出相关系数矩阵。
numeric_cols = df[["rating", "comment_len", "pics_count"]].dropna() corr_matrix = numeric_cols.corr(method="pearson") print(corr_matrix["rating"].sort_values(ascending=False))相关系数的取值边界要心里有数:0.3以下基本可忽略,0.3~0.6算弱相关,超过0.6才值得写在汇报结论里。dropna()在这里是对整个三个字段做行删除,代价是数据量变小,但如果样本仍有几百条以上,通常可以接受。除了pearson之外,遇到明显非线性关系时可以换spearman,它基于秩次计算,对离群值更稳定。
4. 数据可视化输出:matplotlib底稿、seaborn分布图与 ECharts 交互
可视化不该是做一张图,而是把一个分析问题的结论用最合适的图形语言讲清楚。面对 Python 生态,我的分层思路是:matplotlib 负责底稿和坐标控制,seaborn 负责统计图形的快速产出,ECharts 负责需要交互、挂在看板上的可视化。三者之间的数据流动路径保持一致,都是从 pandas 的 DataFrame 直接取出。
4.1 matplotlib 解决底稿与中文字体
在 Python 作图时,最容易出现的“炸点”是中文字体缺失:图出来了,但横坐标全是方块。原因是 matplotlib 的默认字体不包含中文字形。在脚本入口设置好全局字体可以省去很多麻烦场景。
import matplotlib matplotlib.use("Agg") # 无界面环境必须使用 import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = [ "SimHei", "Microsoft YaHei", "Noto Sans CJK SC" ] plt.rcParams["axes.unicode_minus"] = False参数说明:font.sans-serif是一个候选字体列表,matplotlib 会按顺序查找系统里存在的字体,因此在不同 Linux 发行版上都能自适应。axes.unicode_minus设为 False 才能正常显示负号,否则坐标轴上的中文负号会显示为一个方框。如果你的服务器里一个中文字体都没有,可以用fc-list :lang=zh查看可用字体,然后安装fonts-noto-cjk,这一步在大部分 Linux 发行版上可以直接通过包管理器完成。
4.2 seaborn 输出分布图和回归关系图
seaborn 的价值在于用一行代码完成数学统计到图形的过程。histplot自带直方图分箱逻辑,regplot可以直接绘制散点加回归线,省掉手工计算拟合值。
import seaborn as sns fig, axes = plt.subplots(1, 2, figsize=(12, 4.5)) sns.histplot(df["rating"], bins=5, kde=True, ax=axes[0]) axes[0].set_title("评分分布直方图") sns.regplot( data=df.sample(min(len(df), 5000), random_state=42), x="comment_len", y="rating", scatter_kws={"alpha": 0.3}, ax=axes[1], ) axes[1].set_title("评论长度与评分的回归关系") plt.tight_layout() plt.savefig("output/eda.png", dpi=150, bbox_inches="tight")代码执行结果说明:左图展示评分分布,kde=True会叠加一条核密度估计曲线,用于观察整体数据是否服从正态分布;右图用df.sample(5000, random_state=42)做了随机抽样,避免几十万数据点全部画在散点图上导致视觉呈一片实心墨池。random_state固定随机种子,保证每次生成的图形一致,这在跑报告和复盘时非常重要——如果每次图都不样,那你根本无法判断新的数据是否改变了结论。
4.3 用 ECharts 把数据落地为带交互的 HTML 看板
Python 桌面端绘图在交互能力上比较弱,但当项目要求“领导打开浏览器就能看到图表并且可以悬停读数”时,ECharts 是最常见的落地方式。常见做法是:Python 侧生成 JSON,然后填充进一个写好的 HTML 模板,最终产出一个不依赖 Python 服务的静态文件。
import json chart_data = { "cities": list(district_stats.index), "mean_rating": district_stats["mean"].round(2).tolist(), "comment_count": district_stats["count"].tolist(), } html_template = """<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>城市评分看板</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> </head> <body> <div id="chart" style="width: 900px; height: 500px;"></div> <script> const raw = {{DATA}}; const chart = echarts.init(document.getElementById('chart')); chart.setOption({ xAxis: { type: 'category', data: raw.cities }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: raw.mean_rating, label: { show: true, position: 'top' } }] }); </script> </body> </html>""" html_content = html_template.replace("{{DATA}}", json.dumps(chart_data, ensure_ascii=False)) with open("output/dashboard.html", "w", encoding="utf-8") as f: f.write(html_content)ECharts 参数模式是 setOption 里用 JavaScript 对象配置坐标轴和 series,柱状图是最基本的例子。label: { show: true }直接在柱子顶端显示数值,让读者不用凑近鼠标就能读取具体评分。相比 matplotlib,ECharts 的图例、tooltip 和数据缩放器都是内置交互,用户不需要重新解释图层的含义。
在同类工具选型时有一个简单判断表可以参考:
| 分析目标 | 推荐图形 | 实施工具 | 使用场景 |
|---|---|---|---|
| 单变量分布 | 直方图/KDE | seaborn | 数据报告、异常检查 |
| 多组均值对比 | 柱状图 | ECharts | 网页看板、汇报PPT |
| 两变量线性关系 | 散点+回归 | seaborn | 相关性分析 |
| 时间序列趋势 | 折线图 | ECharts | 监控大屏、日报 |
| 多变量相关矩阵 | 热力图 | seaborn heatmap | 特征工程初期 |
做过几次完整项目后你会发现,图表类型的选择本质上是受交付形式驱动,而不是“哪个库更高级”。静态报告里 seaborn 足够;需要分享链接、让同事自己过滤维度时,ECharts 的 HTML 才是那个交付物。
5. 把爬虫、分析、可视化接成可自动运行的工程
当爬虫脚本、分析脚本、绘图脚本各自都能跑通以后,下一步就是让它们串起来,可以被定时触发、自动产出结果。这一步把“代码”变成“工程”,也是 zip 项目里价值最高的部分,因为它决定了你能否每周只用一次点击就拿到新一份数据报告。
5.1 用 bash 脚本做三步编排
一个常见做法是写一个简单的 bash 脚本,按顺序执行三个 Python 模块,并在关键步骤之间传递文件路径。
#!/usr/bin/env bash set -euo pipefail mkdir -p data output logs python src/spider.py --out data/comments.jsonl --pages 10 \ >> logs/spider.log 2>&1 python src/analysis.py -i data/comments.jsonl \ -o output/summary.csv \ >> logs/analysis.log 2>&1 python src/visualize.py -i data/comments.jsonl \ -o output/dashboard.html \ >> logs/visualize.log 2>&1 echo "pipeline done at $(date '+%F %T')"通过set -euo pipefail使任何一条命令失败时整个脚本立刻退出,避免后面的分析步骤拿着半截数据文件往下走。真正需要合理的日志是三个模块都输出到logs/下,这样排查问题时不需要重新跑数据,直接看日志即可。如果你想用 Python 自带能力替代 bash,标准库里subprocess.run()也可以完成同样编排,但 bash 在 cron 和 systemd 场景下更直接。
5.2 缓存与增量更新:避免重复抓取已经入库的数据
每天全量重爬一遍显然是低效的,尤其当接口限速很严格时。通常做法是记录下已经抓过的业务主键,例如comment_id,抓取前读入内存做判断。
from pathlib import Path HISTORY_FILE = Path("data/fetched_ids.txt") def load_done_ids() -> set[str]: if HISTORY_FILE.exists(): return set(HISTORY_FILE.read_text(encoding="utf-8").splitlines()) return set() def mark_done(comment_id: str) -> None: with HISTORY_FILE.open("a", encoding="utf-8") as f: f.write(comment_id + "\n")调用时,if comment_id not in done_ids:命中判断,然后抓取成功后再执行mark_done(comment_id)。这个方案的局限性在于并发写文件时容易冲突;单线程脚本里完全够用,如果升级到多线程或分布式爬虫,就需要把已抓取 ID 放到 Redis 的SADD/SISMEMBER中。缓存带来的收益不只是速度,它能避免同一数据被重复采集后在分析阶段造成统计权重偏高——这是新爬虫容易忽视的问题。
5.3 异常监控:什么情况下要中断整条流水线
引入工程层后,三类异常需要区分对待。第一类是单条请求失败,可以直接忽略并继续;第二类是某个数据文件为空,这要中断分析,因为后续统计会产生错误幻觉;第三类是数据总量相对于昨天骤降或骤升,这时候不是软件错误,而是业务结构变化,需要日志配合人工判断。
| 异常层级 | 处理策略 | 典型信号 |
|---|---|---|
| 单条请求异常 | 记录日志并跳过 | 连接超时、429、404 |
| 单文件空数据 | 退出运行 | 文件大小为 0 或行数为 0 |
| 总量异常波动 | 输出 alert 并照常完成 | 数量低于预期 30% |
| 字段结构变化 | 立即中断 | JSON decode 失败率升高 |
有条件的团队会把数据量校验写成独立函数,在前三步之后增加一个断言:如果df.shape[0] < 1000就不生成 dashboard,因为基于 1000 条数据生成的图表会误导决策。断言失败时输出告警到日志,也可接入企业微信机器人或钉钉 Webhook,让值班的人第一时间收到通知。
6. 让爬虫数据真正“能用”的三个验证技巧
最后一章适合讲那些在交付报告前最容易被忽略、却直接决定项目可信度的细节。三个技巧聚焦在同一点上:数据不止要跑得通,更要经得起回头检验。
第一个技巧是回翻验证法。在你爬完一批数据后,随机抽取三到五个样本 ID,回到数据源头人工核对字段一致性。以评论数据为例,从df.sample(5)中取comment_id列表,再手写一个脚本将其拼成 URL 逐个访问,比较抓取到的rating字段和页面显示是否一致。这个验证要跑在已入库的数据之上,而不是爬虫刚返回时的临时对象,因为只有这样才能暴露落盘阶段的字段错位问题。
第二个技巧是不断言式检查。进入分析前,对 DataFrame 加三条断言:assert df["rating"].between(1, 5).all()验证取值区间;assert not df["comment_id"].duplicated().any()验证主键唯一性;assert len(df) > 100,验证样本数量。作为一名工程师,断言不应等 bug 自然出现时才看,而要在每次运行时强制检查,将脏数据挡在统计计算之前。
第三个技巧是超时与内存的限制配合。长时间运行的项目需要为 requests 请求加上显式超时,分析阶段如果数据量超过内存能承受的规模,可以改用dtype参数主动指定列类型为pd.Int32Dtype(),把不需要的object列排除在读取范围之外。资源限制写进代码,会比遇到内存爆炸再手动处理要省时得多。
当你把这三个技巧完整放进项目后,再看公开分享的爬虫项目。评估它的核心标准就不再是它抓了多少网页,而是它能不能经得起每次运行后的回翻验证、断言约束和内存水位检查。这才是一个可长期运行、可交付决策的爬虫项目,能做到这个层次,你就不只是把这个 zip 里的代码跑了起来,而是拥有了一条自己可以不断迭代和扩展的数据流水线。
本文还有配套的精品资源,点击获取