Python天气数据爬取与可视化课设全解析:从requests到Tkinter
2026/9/16 2:54:04 网站建设 项目流程

简介:基于Python构建的天气数据爬取与可视化项目,附带详细注释与界面演示,适合Python初学者、计算机相关专业学生及教师作为课程设计、毕业设计或数据分析练习的参考。资源包以zip格式提供,共20个文件,核心为3个Python脚本,负责数据采集和数据分析;配套2个CSV天气数据集、1张PNG界面演示图、1份MD说明文档,以及用于IDE配置的XML文件和辅助文件,整体仅393KB。项目完整覆盖天气数据采集、清洗、分析与可视化全流程,代码注释详细,结构清晰,便于二次修改与功能扩展;同时包含真实天气样例数据,可直接查看运行效果,也可作为人工智能、大数据方向的基础实践。目前已有107人学习下载,该资源源于高分课程设计,代码均通过运行测试,适合需要快速搭建天气数据演示或完成课设、毕设任务的开发者参考。整体轻量易用,配合详细注释,可帮助读者快速熟悉从数据采集到展示的完整开发路径。

1. 一个天气爬虫课设的完整拆解:从 requests 到可视化

很多人拿到 Python 天气数据爬取与可视化的课程设计,第一反应是找个免费 API 调一下,把 JSON 打印出来就算交差。但 weather-china-master 走的是另一条路:用 requests 抓中国天气网的城市页面,用 BeautifulSoup 解析 HTML 表格,落成 weather14.csv 和 weather1.csv,再用 pandas 做聚合统计,用 matplotlib 把结果画成图表,最后用 Tkinter 做一个能点按钮、能切城市、能看图的演示界面。整个链路不依赖付费 API,也不需要浏览器驱动,适合作为 Python 数据分析与可视化类课程的完整课设,也适合后续要进入模型训练的同学先补上数据采集这一环。代码带详细注释,单文件结构,配好 Python 3.8+ 环境就能直接运行,下载后先打开 README.md 看运行顺序和依赖版本,再对照下面的拆解会更容易上手。接下来按实际调试顺序走一遍:请求怎么写、CSV 怎么设计、分析脚本看什么指标、图表怎么选,以及界面封装时的路径坑。

2. weather.py 的请求构造与解析:requests 加 BeautifulSoup 的选型边界

2.1 为什么不用 Selenium 也不用 Scrapy

抓天气页面这种低频、小规模的数据,requests 加 BeautifulSoup 是成本最低的组合。判断标准很简单:只要目标页面是服务端渲染、数据不依赖 XHR 动态加载,就不需要 Selenium。中国天气网的 7 天预报在查看源代码时就能看到完整标签,属于典型的静态 HTML。用 Selenium 虽然也能跑,但要多维护一个浏览器驱动,答辩环境里没有对应驱动时会在启动阶段直接报错。Scrapy 的并发调度和 Item Pipeline 更适合大规模采集,针对 14 个城市、每天抓一次的频率,Scrapy 会多出 items、pipelines、middlewares 等一堆文件,代码量翻倍,答辩时反而不容易讲清楚每一行代码的职责。

我整理这版代码的时候只保留了 requests 和 BeautifulSoup 两个库。requests 负责把页面以字符串形式拿回来,BeautifulSoup 负责把字符串解析成节点树,边界很清晰:网络层归 requests,解析层归 BeautifulSoup。后续想换数据源时,只需要替换 fetch 部分,解析函数可以原封不动复用。这一点在项目演示时是很好的切入点,如果评委问“网站改版了怎么办”,可以直接指到 parse 函数说明。

2.2 请求头、超时与编码设置

爬虫的第一道门槛是请求头。裸的 Python requests 会把python-requests/x.x.x作为 User-Agent 发出去,一部分反爬策略看到这个标识会直接拒绝。最稳的做法是带一个浏览器 User-Agent,再加上 Referer 模拟从首页跳转。

import time import requests from bs4 import BeautifulSoup def fetch_weather(city_code: str, timeout: int = 10) -> str: 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", "Referer": "http://www.weather.com.cn/" # 模拟从首页进入 } url = f"http://www.weather.com.cn/weather/{city_code}.shtml" resp = requests.get(url, headers=headers, timeout=timeout) resp.encoding = "utf-8" # 强制指定编码,避免中文乱码 return resp.text

逻辑说明:city_code是城市页面的编号,北京是 101010100,上海是 101020100,这个编号在天气网的站点导航里能直接查到。timeout同时约束连接和读取时间,校园网偶发丢包,不设超时的话,一个卡死的请求会让 14 个城市的循环停在原地。resp.encoding = "utf-8"是关键一行,天气网部分节点响应头里没有声明 charset,requests 会按 ISO-8859-1 猜测编码,中文直接变乱码,所以拿到响应后立刻指定编码。

参数说明:Referer模拟来源页面,但有些代理环境下这个字段反而会成为拦截特征。如果校内网络抓不到数据,先去调 Referer 再试,同时确认timeout是不是设得过小。

2.3 用 select 而不是逐层 find_all

解析时我习惯用soup.select("ul.t.clearfix li"),这是 7 天预报块的特征类名,select 把所有 li 节点取出来,再逐个提取日期、天气、温度和风力。

def parse_forecast(html: str) -> list[dict]: soup = BeautifulSoup(html, "html.parser") items = soup.select("ul.t.clearfix li") # 7 天预报的每个 li 卡片 rows = [] for li in items: date = li.select_one("h1").get_text(strip=True) weather = li.select_one("p.wea").get_text(strip=True) # 高低温在源码里各占一行,这里合并成 "23℃/12℃" temp = li.select_one("p.tem").get_text(strip=True).replace("\n", "") wind = li.select_one("p.win span").get_text(strip=True) # 只取风向文字 rows.append({ "date": date, "weather": weather, "temperature": temp, "wind": wind, }) return rows

逻辑说明:select_one只取第一个匹配节点,get_text(strip=True)删除标签首尾空白。温度节点里的高低温各占一行,直接取文本会得到"23℃\n12℃",用replace("\n", "")拼成"23℃/12℃",写进 CSV 单列可读性最好。风力的p.win里还带一个箭头图标,直接取整段会得到箭头字符,在不同操作系统上显示不一致,所以只取span里的文字。

这种写法的容错性比find_all("ul")再往下钻更好。页面如果在目标 ul 前面插入了广告 li,find_all 很容易把广告误算进去,select 用类名定位则不受影响。运行中如果发现items为空,优先检查类名是不是变了,而不是怀疑网络。

2.4 失败重试与失败城市收集

天气网不是每一秒都稳定,偶尔会返回空页面或缺标签。课程设计里直接中断整个流程太浪费,加一层轻量重试就行。

def fetch_with_retry(city_code: str, retries: int = 3, delay: int = 2) -> list[dict]: for attempt in range(1, retries + 1): try: html = fetch_weather(city_code) rows = parse_forecast(html) if rows: # 解析结果为空也视为失败 return rows except requests.RequestException as exc: print(f"[{attempt}/{retries}] {city_code} 失败: {exc}") time.sleep(delay) # 只有失败才等待,不拖慢成功路径 raise RuntimeError(f"{city_code} 连续 {retries} 次抓取失败")

逻辑说明:retries是最大尝试次数,delay是失败后的等待秒数。这里把“解析结果为空”也当作失败处理,因为页面结构变化时 select 不会报错,只会返回空列表,不处理的话空数据会被写进 weather14.csv。time.sleep放在异常分支里,只有请求失败才等待,成功路径不会被拖慢。

我一般还会在外层维护一个failed_cities列表,except RuntimeError时把城市编号 append 进去。等 14 个城市全部跑完,再对失败列表统一补爬,这样即使有两个城市临时挂了,其余城市的 CSV 也完整可用,不用从头跑一遍。

提示:failed_cities里只存 city_code 就够,补爬时直接复用fetch_with_retry,不需要重新解析整个页面的数据。

3. CSV 落盘与多城市数据合并:编码和字段设计决定分析体验

3.1 存储方案对比

项目里最终产出了 weather14.csv 和 weather1.csv。weather14 是 14 个城市的横向对比,weather1 是单城市的时间序列。CSV 对这类规模的数据是最合适的格式:pandas 一行代码能读,Excel 双击能看,git 能追踪变化。存成 xlsx 需要额外维护 openpyxl,存 SQLite 又要求答辩现场装 DB Browser,这些成本在课设场景里都没必要。

存储方案读取成本答辩现场打开适用规模
CSVpandas 直接 read_csvExcel 双击可看10 万行以内
xlsx需要 openpyxlExcel 可看但依赖库5 万行左右
SQLite标准库 sqlite3需要装 DB Browser100 万行以上

对这个项目一天爬一次、总共 14 个城市的数据量,CSV 是明确的选择。真正决定分析体验的不是格式,而是编码和字段设计。

3.2 utf-8-sig 与 newline 参数

很多课设代码用的是标准库 csv,写文件时最容易踩两个坑:中文乱码和行之间多出空行。

import csv def write_csv(path: str, rows: list[dict]) -> None: # utf-8-sig 写入 BOM,Excel 打开中文不乱码;newline="" 避免 Windows 空行 with open(path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter( f, fieldnames=["city", "date", "weather", "temperature", "wind"] ) writer.writeheader() writer.writerows(rows)

逻辑说明:utf-8-sig会在文件开头写入 BOM 头,Excel 用本地 ANSI 编码打开无 BOM 的 UTF-8 文件时,中文列名会整体乱码,加了 BOM 后 Excel 能自动识别。newline=""是 csv 模块在 Windows 平台上的标准要求,不加的话每两行之间会多出一个空行。fieldnames的顺序就是表头的顺序,字典里多余键不影响写入,但缺键会抛ValueError

需要注意,如果直接用 pandas 的to_csv写 DataFrame,中文乱码问题同样存在,方法是传encoding="utf-8-sig",原理和这里完全相同。

3.3 多城市数据的收集与合并

多城市爬取时,最忌讳每爬一个城市就打开文件追加一行。中间崩溃会在 CSV 末尾留下半条记录,重新补爬时还要先做去重。常见做法是先在内存里把all_rows合并完,最后一次写盘。

cities = { "北京": "101010100", "上海": "101020100", "广州": "101280101", "成都": "101270101", } all_rows = [] failed = [] for name, code in cities.items(): try: rows = fetch_with_retry(code) for r in rows: r["city"] = name all_rows.extend(rows) print(f"{name}: {len(rows)} 条,累计 {len(all_rows)} 条") except RuntimeError: failed.append(name) write_csv("weather14.csv", all_rows) if failed: print("失败城市:", failed) else: print("全部写入完成")

逻辑说明:先爬完所有城市,再统一调用write_csv,能保证 weather14.csv 要么完整生成、要么不生成,不会出现半截文件。r["city"] = name是在内存里给每条记录补上城市字段,这个字段要在写入前确定,fieldnames里才包含 city 这一列。failed列表记录完全失败的城市,方便补爬,而不是在 13 个成功城市的数据里重新跑一遍。

3.4 温度字段规范化:早晚要拆成数字

p.tem解析出来的"23℃/12℃"对人类可读,对 pandas 聚合就是一场灾难。分析前先拆出temp_hightemp_low两列更稳妥。

import re def split_temp(value: str) -> tuple[int, int]: nums = re.findall(r"-?\d+", value) # -? 兼容零下温度 if len(nums) >= 2: return int(nums[1]), int(nums[0]) # 返回 (低温, 高温) if len(nums) == 1: return int(nums[0]), int(nums[0]) return 0, 0

逻辑说明:页面源码里温度块的显示顺序是“高温在前、低温在后”,但这个顺序在页面改版时并不稳定。这里用正则把所有数字抽出来,固定返回(低温, 高温)-?\d+-?兼容零下温度,北方城市冬天气温是负值,不加这个符号,-5℃ 会被解析成 5℃。两个数字都取不到时返回(0, 0),后续分析里按缺失值过滤掉。

如果split_temp拆出来的数字和实际天气不符,先看原始 temperature 字符串,确认是不是箭头图标被带进了p.tem的文本里。

4. data14_analysis.py 的数据清洗与聚合:pandas 的类型转换和分组统计

4.1 read_csv 阶段就做类型转换

有些同学读 CSV 之后用 for 循环逐行清洗,数据量小的时候看不出问题,但不是 pandas 的用法。正确做法是从read_csv开始把类型定好,后续聚合才不会踩 dtype 的坑。

import pandas as pd df = pd.read_csv("weather14.csv") # 拆出高低温数字,原始 temperature 列保留用于核对 df["temp_high"] = df["temperature"].apply(lambda x: split_temp(x)[1]) df["temp_low"] = df["temperature"].apply(lambda x: split_temp(x)[0]) df["date"] = pd.to_datetime(df["date"], format="%d日")

逻辑说明:temperature列保留原始字符串,temp_hightemp_lowsplit_temp拆出,原始数据不丢,分析字段和文本字段可以对照检查。date列用%d日解析,因为天气网预报显示的是“今天”“明天”这类相对日期。如果解析遇到无法识别的值会变成NaT,后续用dropna过滤掉即可。

参数说明:format里的%d是两位数日期,页面如果给的是“04月20日”,要改成%m月%d日。pandas 2.x 之后对时间格式更严格,格式不对会直接抛异常,这其实是好事,至少比静默解析错误更容易发现。

4.2 14 个城市的横向对比

课设里最有展示价值的分析,是把 14 个城市未来三天的最高温和最低温拉成同一张表,再按最高温均值排序。

df["day_index"] = df.groupby("city").cumcount() # 每个城市按日期编号 0,1,2... next3 = df[df["day_index"] < 3] # 取每个城市的前三天 summary = ( next3.groupby("city") .agg( avg_high=("temp_high", "mean"), avg_low=("temp_low", "mean"), max_high=("temp_high", "max"), ) .sort_values("avg_high", ascending=False) .reset_index() )

逻辑说明:groupby("city").cumcount()给每个城市按日期顺序编号,相当于给每天打上“第一天、第二天、第三天”的标签。agg里的命名聚合一次性算平均最高温、平均最低温和最高温极值,生成一张汇总表。sort_values("avg_high", ascending=False)把最热的城市排在最前面,答辩演示时直接看表就能讲出城市温差。

参数说明:mean求平均,想换中位数就写median,想换极差就分别取maxmin。某个城市某天数据缺失时,mean默认跳过 NaN,不需要提前填零。

4.3 单城市时间序列与 data1_analysis.py 的分工

同一个项目里的 data1_analysis.py 和 data14_analysis.py 分工不同。weather14 做城市间横向对比,weather1 做单城市纵向趋势。单城市分析需要把多天数据按时间排序,再算温度变化率。

city_df = ( df[df["city"] == "北京"] .sort_values("date") .reset_index(drop=True) ) city_df["temp_diff"] = city_df["temp_high"].diff() # 相邻两天最高温差值

逻辑说明:先按城市筛选,再按日期排序,保证时间序列的顺序正确。temp_diffdiff()计算相邻两天最高温的差值,正数表示升温、负数表示降温。这个字段一般作为图上的文字标注,不需要写回 CSV。

注意:sort_values("date")只有在 date 被to_datetime正确解析后才可靠。如果 date 还是字符串,按字母序“10日”会排在“9日”前面,画出来的温度曲线会前后颠倒。遇到这种情况先检查 4.1 里的格式参数,不要急着改绘图代码。

5. matplotlib 天气可视化:折线图、柱状图与保存参数

5.1 中文字体与负号配置

matplotlib 默认字体不含中文字符,直接用中文当标签会出现方框。最常见的全局配置是:

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei"] # 中文字体 plt.rcParams["axes.unicode_minus"] = False # 负号正常显示

逻辑说明:font.sans-serif把默认字体族换成黑体,SimHei 在 Windows 上自带。axes.unicode_minus必须设为 False,否则坐标轴上的负号会显示成方块。如果你在 macOS 上运行,SimHei 不存在,改成["Arial Unicode MS"]["PingFang SC"]

5.2 单城市温度折线图

单城市趋势适合折线图,横轴日期,纵轴温度,高低温各一条线。

fig, ax = plt.subplots(figsize=(10, 5)) ax.plot(city_df["date"], city_df["temp_high"], marker="o", label="最高温") ax.plot(city_df["date"], city_df["temp_low"], marker="s", label="最低温") ax.set_title("北京七日温度趋势") ax.set_ylabel("温度 (℃)") ax.legend() ax.grid(True, linestyle="--", alpha=0.5) plt.xticks(rotation=45) # 横坐标太密集时旋转标签,避免重叠 plt.tight_layout() plt.savefig("beijing_temp.png", dpi=150, bbox_inches="tight")

逻辑说明:figsize控制画布大小,日期较多时横向拉长到 10,比默认 6.4 的显示效果好。marker="o"是圆形点,marker="s"是方形点,用来区分高低温两条线。plt.xticks(rotation=45)解决横坐标日期太密集导致标签互相覆盖的问题,这是 python 画图横坐标太密集时最常见的修复方式。grid画虚线网格,alpha 控制透明度。tight_layout保证标题和坐标轴标签不被裁切。

5.3 多城市横向柱状图

横向柱状图适合展示 14 个城市的温度排序。城市名长度不等,横向排列能避免斜字重叠。

fig, ax = plt.subplots(figsize=(8, 7)) ax.barh(summary["city"], summary["avg_high"], color="#4C72B0") ax.set_xlabel("未来三天平均最高温 (℃)") ax.set_title("14 城市温度对比") for idx, value in enumerate(summary["avg_high"]): ax.text(value + 0.2, idx, f"{value:.1f}", va="center") # 柱子右侧标数值 plt.tight_layout() plt.savefig("city_compare.png", dpi=150, bbox_inches="tight")

逻辑说明:barh是横向柱状图,城市名在 y 轴排列,最长也不会和相邻标签重叠。ax.text在柱子右侧写数值,value + 0.2防止标签贴在柱子上,va="center"让文字在垂直方向居中。颜色用十六进制值,直接替换成项目主题色即可。

如果数据可视化里还有省份温度分布的需求,那就不是 matplotlib 的长项,要换 pyecharts 的 Map 类型。但课设场景里柱状图已经能把城市对比讲清楚,不必强上地图。

5.4 保存图片的 dpi 和 bbox_inches

课设提交要把图片插进报告,dpi直接决定清晰度。150 适合屏幕展示,300 适合打印。bbox_inches="tight"会裁掉图片四周空白,避免插进 Word 后留白过大。如果图片导入 Word 后模糊,先检查是不是dpi太低,而不是把figsize无限调大,文件过大在答辩电脑上打开反而更卡。

6. Tkinter 界面演示与打包:爬虫可视化界面的落地

6.1 用子线程跑爬虫,避免界面卡死

Tkinter 的按钮回调如果直接做网络请求,窗口会假死直到请求完成,演示时点完按钮界面完全无响应。常见做法是启动一个子线程去爬,通过队列把结果传回主线程。

import threading import queue task_queue = queue.Queue() def start_crawl(): def worker(): rows = fetch_with_retry("101010100") # 北京 task_queue.put(rows) threading.Thread(target=worker, daemon=True).start() def poll_queue(): try: rows = task_queue.get_nowait() update_chart(rows) except queue.Empty: pass root.after(200, poll_queue) # 每 200ms 检查一次队列

逻辑说明:start_crawl里用threading.Thread把网络请求放到后台,daemon=True保证主窗口关闭时线程能跟着结束。poll_queue通过root.after循环检查队列,拿到数据再刷新图表。这样界面不会在爬取期间卡住,评委点按钮时能看到窗口立即响应。

6.2 打包成 exe 的路径坑

用 PyInstaller 打包时最常遇到的是图表字体和 CSV 模板找不到。常见处理是按资源文件所在目录动态拼路径:

import sys from pathlib import Path BASE_DIR = Path(sys._MEIPASS) if hasattr(sys, "_MEIPASS") else Path(__file__).parent csv_path = BASE_DIR / "weather14.csv"

逻辑说明:PyInstaller 会把资源放在临时解包目录,路径存在sys._MEIPASS里;直接从源码运行时用__file__所在目录。用这个逻辑取路径,打包前后行为一致。字体方面,如果目标机器没有 SimHei,可以把字体文件用--add-data一起打进去,运行时再用FontProperties指定字体文件路径。

6.3 动态刷新图表

Tkinter 里显示 matplotlib 图表用FigureCanvasTkAgg,把 Canvas 放进窗口即可。每次爬取结束后要清空旧图、重新绘制,再调用canvas.draw()。注意canvas.get_tk_widget().pack()之后不要重复创建 Figure,否则窗口会叠加多个绘图区域。窗口标题用当前城市名动态更新,比固定写“天气查询”更有演示效果。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询