Python爬虫与PM2.5数据可视化:期末大作业完整实践指南
简介:面向高校数据科学与大数据专业学生的Python爬虫与可视化课程设计资源,以中国城市PM2.5值为分析对象,完整覆盖数据采集、清洗、分析到可视化展示流程,配有注释清晰的源码与满分实验报告,可直接作为期末大作业或课程设计高分参考。包体共21个文件,以13个HTML可视化页面为主体,配合5个CSV城市监测数据、2个DOCX报告文档及1个主程序PY文件,压缩包整体仅6.13MB,部署轻量、结构规整,适合新手按模块理解并二次修改。目前已有352人学习使用,资源在数据指标解读、多城市对比呈现及项目文档撰写方面均有较好示范性,兼顾代码实用性与报告规范度,是快速完成同类型实训任务的高性价比选择。
1. 期末大作业选 Python 爬虫 + PM2.5 可视化,图的不是新鲜,是可复现
期末大作业选「基于 Python 爬虫的中国城市 PM2.5 值数据可视化分析」,最怕的不是爬不到数据,而是爬完数据发现画不出能讲出结论的图。这个题目的价值在于它完整覆盖了数据采集、清洗、存储、聚合、可视化的全链路,难度适中,又天然自带「空气质量」这个有讨论度的话题,答辩时容易讲出东西。适合 Python 基础薄弱但想拿高分、或者第一次接触爬虫和数据可视化、需要一个能一口气跑通的项目练手的同学。整条链路里真正决定分数的环节只有两个:能不能稳定抓到全量数据,以及图表能不能支撑起一句明确的结论。本篇按「选型 → 采集 → 清洗 → 出图 → 排错 → 写报告」的顺序把我自己做这个题的做法完整讲一遍,代码可以直接抄,坑提前告诉你。
2. 抓全国城市 PM2.5 数据:requests + XPath 的采集实现与反爬边界
2.1 先选数据源:静态表格页、更新频率、字段丰度怎么权衡
做爬虫类大作业,第一步不是写代码,是选数据源。PM2.5 数据在国内有几个常见来源:中国环境监测总站、空气质量在线监测分析平台、青悦开放数据、和蔚蓝地图 API。我的建议是选静态 HTML 表格页,原因是期末大作业的网络环境不稳定,API 可能要求 token,也可能限频,而静态页只要 requests 能拿到 HTML 就能解析,调试成本最低。
我一般选「空气质量在线监测分析平台」的全国城市实时数据页,它的核心优势有三个:一是无需登录,直接 GET 就能返回完整表格;二是数据更新频率达到小时级,足够支撑「最近24小时趋势」这类分析;三是字段覆盖城市、日期、AQI、PM2.5、PM10、SO₂、NO₂、CO、O₃,一份数据表能画出多种图。反爬方面,它的页面没有验证码,也没有高频 IP 封禁策略,用正常浏览器 UA 就能访问,算是期末场景里性价比最高的目标。
选站点时要避开两类:一类是大量使用 JavaScript 异步渲染的页面,数据藏在 XHR 接口里,需要额外抓接口,复杂度直接翻倍;另一类是 PDF 或图片报表,解析成本高且容易翻车。静态表格页是爬虫大作业的舒适区,先跑通链路,再考虑加难度。
2.2 用 requests 建立会话并伪装请求头,拿到页面先保存成 HTML
import requests # 建立会话,自动管理 cookies session = requests.Session() # 伪装成浏览器,避免被服务端识别为脚本请求 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/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9", } url = "https://www.aqistudy.cn/html/city_realtime.php" # 示例:城市实时数据页 # 防止请求超时导致整个程序中断,设置 timeout resp = session.get(url, headers=headers, timeout=10) # 判断响应状态,200 才继续 if resp.status_code == 200: # 先确认页面编码,中文站常见 utf-8 或 gb2312 print("页面编码:", resp.encoding) # 把原始 HTML 存下来,方便后续离线调试解析逻辑 with open("pm25_page.html", "w", encoding="utf-8") as f: f.write(resp.text) print("页面保存成功") else: print("请求失败,状态码:", resp.status_code)这段代码有几个关键参数需要注意:timeout必须设,否则某个城市页面卡住时整个程序会挂在那里不动;resp.encoding是中文网页最常见的一个隐患点,如果打印出来是ISO-8859-1,说明 requests 猜错了编码,需要手动从响应头或页面 meta 标签里找真实编码,否则后续 XPath 解析出来全是乱码。另外我习惯把 HTML 先存成本地文件再解析,不是多此一举——反爬策略通常在多次请求后才触发,本地留存一份原始页面,排查「到底是请求被拦截了还是解析写错了」时会省大量时间。
2.3 XPath 解析表格:text() 与 normalize-space 配合清洗
拿到 HTML 后,用 lxml 的 XPath 解析比正则表达式稳定得多。表格结构通常是<table>里套<tr>和<td>,用 XPath 可以直接按行提取。这里有一个非常容易翻车的写法误区:直接用//td/text()提取单元格文本时,如果单元格内部有<span>或<a>标签,text() 只能拿到直接子文本,拿不到嵌套标签里的内容,导致字段残缺。
from lxml import html # 读取本地保存的页面,避免重复请求 with open("pm25_page.html", "r", encoding="utf-8") as f: page_content = f.read() # 解析 HTML 为 Element 对象 tree = html.fromstring(page_content) # 定位表格里的所有行 rows = tree.xpath("//table//tr") data = [] for row in rows: # 用 string() 提取整行所有文本,再压缩连续空白 cells = row.xpath(".//td") if len(cells) < 5: continue # 跳过表头或残缺行 line = [] for cell in cells: # normalize-space 去除首尾空白并把内部连续空格压缩为单个 text = " ".join(cell.xpath(".//text()")).strip() text = " ".join(text.split()) line.append(text) # 城市、日期、AQI、PM2.5 是后续分析必备字段 if len(line) >= 5 and "城市" not in line[0]: data.append(line) print("解析到数据行数:", len(data)) print("第一条示例:", data[0] if data else "无数据")这里的核心技巧是" ".join(cell.xpath(".//text()")),它等价于把单元格内所有层级的文本拼接起来,再用split()清理空白,弥补了text()拿不到嵌套文本的缺陷。string()函数也能做类似的事,但它在处理复杂嵌套时会把多余空白一起带进来,反而要多做一步清洗。解析完先打印行数和第一条数据,确认字段对齐后再往下走,不要在数据还没验证的情况下直接写 CSV。
2.4 清洗字段并落 CSV:pandas 去重与时间字段标准化
采集到的原始数据不能直接进可视化,因为同一个城市在页面上可能出现两次(不同监测站点),时间字段也可能是「2025-06-01 12:00」这类字符串,需要先统一成标准格式。我用 pandas 做这一步,代码短且后续聚合方便。
import pandas as pd # 把解析结果转成 DataFrame,按页面实际列顺序命名 df = pd.DataFrame(data, columns=[ "城市", "时间", "AQI", "PM2.5", "PM10", "SO2", "NO2", "CO", "O3" ]) # 转数值类型,遇到无法转换的置为 NaN,后续统一处理 for col in ["AQI", "PM2.5", "PM10", "SO2", "NO2", "CO", "O3"]: df[col] = pd.to_numeric(df[col], errors="coerce") # 按城市+时间去重,保留第一次出现的记录 df = df.drop_duplicates(subset=["城市", "时间"], keep="first") # 时间字段标准化为 pandas 的 datetime 类型 df["时间"] = pd.to_datetime(df["时间"]) # 按城市排序,方便肉眼抽查 df = df.sort_values(["城市", "时间"]).reset_index(drop=True) # 输出 CSV 用 utf-8-sig,否则 Excel 打开中文会乱码 df.to_csv("pm25_cities.csv", index=False, encoding="utf-8-sig") print("清洗后数据条数:", len(df)) print(df.head())注意to_numeric(errors="coerce")会在字段里混入「暂无数据」这类占位文本时把它转成 NaN,而不是让整个程序崩溃。drop_duplicates的subset参数指定了去重依据,不写的话遇到两行完全相同的记录才会去重,达不到按城市+时间去重的效果。encoding="utf-8-sig"是 CSV 落地时最容易忽略的细节,不写的话 Windows 上双击 CSV 文件会出现中文乱码,影响后续检查数据质量。这一步结束,数据链路就已经完整了,接下来的所有分析都不需要再碰网络请求。
3. 把 CSV 画成能讲故事的图:pandas 聚合 + pyecharts 出图
3.1 预处理阶段:缺失值填充与 AQI 等级字段构造
读完 CSV 先别急着画图,先处理缺失值和构造分级字段。PM2.5 数据经常出现个别城市某小时缺失的情况,直接画折线图会出现断点,影响观感。我的做法是:如果缺失比例低于 5%,用前后均值填充;如果某个城市缺失超过 30%,直接剔除,不硬填。
import pandas as pd df = pd.read_csv("pm25_cities.csv", encoding="utf-8-sig", parse_dates=["时间"]) # 查看缺失情况,决定填充策略 print(df.isnull().sum()) # 按城市分组,用相邻时间点的均值填充 PM2.5 缺失值 df["PM2.5"] = df.groupby("城市")["PM2.5"].transform( lambda x: x.fillna(x.interpolate()) ) # 剔除 PM2.5 仍然为空的记录 df = df.dropna(subset=["PM2.5"]) # 构造 AQI 等级字段,用于后续分组统计 def aqi_level(aqi): if aqi <= 50: return "优" elif aqi <= 100: return "良" elif aqi <= 150: return "轻度污染" elif aqi <= 200: return "中度污染" else: return "重度污染" df["AQI等级"] = df["AQI"].apply(aqi_level) print(df["AQI等级"].value_counts())groupby + transform的组合是这里的关键,它按城市分组后对每个组内部执行插值,不会出现用北京的数据去填充上海缺失值这种跨城市污染。interpolate()默认线性插值,对小时级的 PM2.5 变化曲线来说足够平滑。AQI 分级字段的构造要跟国家 HJ 633-2012 标准对齐,这个字段在答辩时可以拿来回答「AQI 和 PM2.5 浓度有什么关系」。
3.2 城市排行与时间趋势:Bar + Line 的配置要点
pyecharts 是期末大作业出图的首选,因为它的图是 HTML 交互式图表,浏览器打开后可以悬停看数据,答辩演示效果好。注意 pyecharts 的版本,v1.x 和 v0.5.x 的 API 差异很大,现在安装默认是 1.x,代码按 1.x 写法来。
from pyecharts.charts import Bar, Line from pyecharts import options as opts # 取每个城市当天所有小时的平均值,作为日均代表 city_daily = df.groupby("城市")["PM2.5"].mean().round(1).sort_values(ascending=False) city_names = city_daily.index.tolist() city_values = city_daily.values.tolist() # 横向柱状图展示污染最重的 15 个城市 bar = ( Bar() .add_xaxis(city_names[:15]) .add_yaxis("PM2.5 日均值", city_values[:15]) .reversal_axis() # 横向布局,城市名显示更完整 .set_series_opts(label_opts=opts.LabelOpts(position="right")) .set_global_opts( title_opts=opts.TitleOpts(title="中国城市 PM2.5 日均值 TOP15"), yaxis_opts=opts.AxisOpts(name="城市"), xaxis_opts=opts.AxisOpts(name="PM2.5 浓度(μg/m³)"), ) ) bar.render("pm25_top15.html")排名图能回答「哪个城市最脏」的问题,但时间趋势才能回答「空气质量在变好还是变坏」的问题。取一个重点城市(比如石家庄)画全天趋势线:
one_city = df[df["城市"] == "石家庄"].sort_values("时间") line = ( Line() .add_xaxis(one_city["时间"].dt.strftime("%H:%M").tolist()) .add_yaxis( "PM2.5 浓度", one_city["PM2.5"].tolist(), is_smooth=True, linestyle_opts=opts.LineStyleOpts(width=2), ) .set_global_opts( title_opts=opts.TitleOpts(title="石家庄当日 PM2.5 小时变化"), yaxis_opts=opts.AxisOpts(name="浓度(μg/m³)"), tooltip_opts=opts.TooltipOpts(trigger="axis"), ) ) line.render("pm25_trend.html")两个图的关键差异在label_opts和tooltip_opts。横向柱状图必须把 label 放在柱子右侧,不然图表里数值被柱子挡住,答辩一眼看不清;折线图的trigger="axis"让鼠标悬停时同时显示横轴附近所有数据点,比默认的item触发更直观。
3.3 地理分布:Map 的地图注册与分级配色
地理分布是 PM2.5 可视化里最出效果的一张图,但也是配置最繁琐的一张。pyecharts 1.x 的地图数据包和主库分开,直接渲染中国地图大概率是空白页,要先安装地图扩展包。
pip install pyecharts pip install echarts-countries-pypkg pip install echarts-china-provinces-pypkg pip install echarts-china-cities-pypkgfrom pyecharts.charts import Map from pyecharts import options as opts # 城市名需要带省前缀,否则地图匹配不上 province_map = { "北京": "北京市", "上海": "上海市", "广州": "广东省", "深圳": "广东省", "石家庄": "河北省", "太原": "山西省", } city_avg = df.groupby("城市")["PM2.5"].mean().round(1) map_data = [] for city, value in city_avg.items(): full_name = province_map.get(city, city) map_data.append((full_name, value)) m = ( Map() .add( "PM2.5 浓度", map_data, maptype="china", ) .set_global_opts( title_opts=opts.TitleOpts(title="全国城市 PM2.5 分布"), visualmap_opts=opts.VisualMapOpts( min_=0, max_=150, is_piecewise=True, pieces=[ {"min": 0, "max": 35, "label": "优"}, {"min": 36, "max": 75, "label": "良"}, {"min": 76, "max": 115, "label": "轻度污染"}, {"min": 116, "max": 150, "label": "中度污染"}, ], ), ) ) m.render("pm25_map.html")地图的两个关键参数:is_piecewise=True让颜色分成离散区间而不是连续渐变色,因为不同空气质量等级的色差对答辩观感影响很大;pieces的分段要跟前面构造的 AQI 等级保持一致,否则图和正文结论对应不上,是答辩时最容易被老师挑出来的硬伤。省前缀的映射表也是必要的,pyecharts 内置地图只认「北京市」「广东省」这样的完整行政区名,不认「北京」这种简称,不做映射地图上会大片空白。
3.4 一个容易被扣分的点:图和结论对不上
很多拿到及格分而不是满分的大作业,问题出在最后:图都画出来了,但每一张图旁边没有写它说明了什么。柱状图讲了「石家庄最脏」,结论里却写「华北地区污染严重」——这个推断需要至少三天以上的数据支撑,单日数据得不出区域结论,老师一问就露馅。正确做法是每张图配一句「这张图证明了什么」,而且要跟你的分析口径一致。比如折线图写「石家庄 14 时出现 PM2.5 峰值 148μg/m³,属于中度污染」,这就是能站得住的具体结论,而不是「空气质量需要关注」这种正确的废话。
4. 期末大作业排错记录:这 5 个坑让爬虫和出图双双翻车
4.1 页面编码识别错误,解析出来全是乱码
现象:print(resp.encoding)返回ISO-8859-1,把文本写入文件后中文全变成â开头的乱码。
原因:requests 从响应头猜测编码时优先看charset,部分站点没在响应头里声明编码,requests 的默认猜测机制就猜错了。中文网页实际用的是 UTF-8,但 HTML 里只在<meta charset>标注了,requests 不会主动去解析 meta 标签。
解决:不要信任resp.encoding,用resp.apparent_encoding做二次确认,或者直接根据页面已知信息手动指定resp.encoding = "utf-8"。我一般写完代码先打印前 200 个字符,确认中文正常再继续。
4.2 XPath 用了//td/text(),嵌套标签内容丢失
现象:单元格里明明有「石家庄 2025-06-01 12:00」两段内容,解析出来只剩「石家庄」,时间字段集体消失。
原因:text()在 XPath 里只取当前节点的直接文本子节点,如果文本被<span>、<a>、<font>包住,这些内容不属于td的直接文本节点。
解决:用" ".join(cell.xpath(".//text()"))取全部后代文本再拼接。这个写法的好处是无论嵌套多少层都能拿全,坏处是可能带回多余空白,所以后面必须接split()压缩。这两个操作必须连用,少一个都会在数据清洗阶段出问题。
4.3 pyecharts 出图空白,但 HTML 文件存在
现象:render()正常执行,浏览器打开 HTML 后图表区域是空白,只有标题文字。
原因:大概率是地图相关依赖没装。pyecharts 1.x 把地图数据拆成了单独的 pypkg 包,主库安装不会自动带地图,而且class Map的引入路径在不同版本里有差异。
解决:先确认版本,pip show pyecharts看是 1.x 还是 0.5.x。1.x 需要安装echarts-china-provinces-pypkg等扩展包,0.5.x 用的是map.save_chart那套旧 API,两套代码不兼容。检查顺序是:先看控制台有没有报错,再看import pyecharts是否正常,最后查扩展包是否安装。
4.4 CSV 用 Excel 打开乱码,但 pandas 读取正常
现象:pd.to_csv写完文件,pandas 读回来没事,但用 Excel 打开直接乱码。
原因:pandas 默认的to_csv使用utf-8编码,而 Excel 在 Windows 简体中文环境下默认用 ANSI 编码打开 CSV,读不了 UTF-8 的 BOM 标记时就会按乱码显示。
解决:写文件时指定encoding="utf-8-sig",这个编码会在文件头部写入 BOM 标记,Excel 识别到标记后就会自动用 UTF-8 解码。这个坑不影响程序运行,但影响数据检查效率,而且期末交作业时老师双击打开 CSV 看到乱码,第一印象就打折了。
4.5 城市名映射不完整,地图上大片空白区域
现象:地图渲染出来了,但只有北京、上海等几个城市有颜色,其余地区全是灰色。
原因:pyecharts 的china地图使用完整的行政区划名,但爬虫抓下来的表格里城市列用的是「北京」「深圳」这种简称,而且爬虫不会自动补全城市所属省份,需要自己构建映射关系。部分城市名(如「阿坝」)与地图内置名称不一致时也会空白。
解决:先打印df["城市"].unique()拿到全部城市名,逐一检查哪些能匹配地图坐标系、哪些不能。偷懒的做法是只展示能匹配的城市并在图上标注,严谨的做法是构建完整映射表。大作业里建议选 30 个左右省会级城市做地图,城市少且名字规范,匹配率高,展示效果也够。
5. 把实验报告写成满分样子的几个落地技巧
实验报告是这个大作业里占比 40% 的一部分,很多学生代码写完了报告却只写三四页,得分直接掉档。满分报告和普通报告的核心差别在于「有没有证据链」:你的每个结论旁边必须有一张对应的图,每张图必须有对应的数据支撑说明。
我的习惯是按五个模块组织报告内容,分别在「需求分析」里写清楚数据来源选型理由,在「采集模块」里附上核心爬虫代码和本地保存的原始 HTML 截图,在「数据处理」里放清洗前后的对比表,在「可视化分析」里每张图配一段 3〜4 行的解读,在「总结」里写一条明确的结论和一条明确的不足。代码不要全文贴,选 requests 会话、XPath 解析、pyecharts 地图注册这三段最能体现技术含量的贴就行,其他只写函数名和作用。
报告里的截图不要一张图拍到底,要按「终端输出 → 图表整体 → 图表局部细节」三步截。第一张证明程序跑通,第二张证明图表完整,第三张放大关键区域证明数据确实在图里可见。网站 HTML 表格也截一张,标注出你抓的是哪个区域,证明数据来源真实可溯。
结尾可以写一句「本作业在数据获取环节受限于城市数量,未覆盖全部县级市,后续可通过增加采集频次和城市列表扩展分析维度」。这句是加分的,它表明你知道这个方案的边界在哪里,而不是以为它完美无缺。这也是我做完这类项目后最深的感受——一个能讲清楚边界的大作业,往往比功能堆得更满但不谈局限的作业拿到更高的分。希望帮到你。
本文还有配套的精品资源,点击获取