年初我搭了一个专门整理全年天气数据的小项目,就是标题里这个“python爬虫——爬取全年天气数据并做可视化分析”。项目本身不复杂,但把爬虫、数据清洗和可视化这条链路完整走了一遍,练完以后最大的感受是:爬虫只占一半工作量,后面数据规整反而是大头。这篇直接把我的实现方式、选型理由、踩过的坑全部记录下来,完整还原一套能从零跑通的全流程方案。适合三类人:刚看完requests和BeautifulSoup教程想找个综合练习的,需要做天气数据相关课程设计或个人项目的,以及做运营、旅游、活动策划时需要拿历史天气数据做参考的人。
1. 拆解需求:这项目本质上是一个数据全链路小工程
先说清楚“全年天气数据”到底长什么样。我们说的爬取,不是只拿一个“今天晴”这种结果,而是要拿到一整年每一天的结构化记录,包括日期、最高气温、最低气温、平均气温、天气现象(晴/阴/雨/雪)、降水量、相对湿度、风向、风力,甚至空气质量等字段。有了这些每天一条的数据,才能往下做可视化分析。
1.1 确定数据模型:先想清楚要什么字段
我最终定的数据表结构大概是这样:
| 字段名 | 类型 | 示例 | 用途 |
|---|---|---|---|
| date | 日期 | 2024-07-15 | 时间维度 |
| high_temp | 数值 | 35.2 | 当天的最高气温 |
| low_temp | 数值 | 24.1 | 当天最低气温 |
| avg_temp | 数值 | 29.6 | 日平均气温 |
| weather_desc | 文本 | 晴转多云 | 天气现象描述 |
| precipitation | 数值 | 0.0 | 当日降雨量(毫米) |
| humidity | 数值 | 63 | 相对湿度百分比 |
| wind_dir | 文本 | 东南风 | 主导风向描述 |
| wind_level | 文本 | 3级 | 风力等级描述 |
这个字段集合不是拍脑袋定的,它直接决定了后面能画什么图。如果收集阶段漏了湿度,后面想分析“梅雨季的闷热程度”就只能干瞪眼。所以,在写第一行代码之前,建议把所有想做的分析维度列出来,再反推需要哪些字段。用我自己的话讲:先想好要画什么图,再去定要存什么数据。
1.2 技术栈选型:为什么用这套组合
整套项目用到的核心库不多,但每一个都是“同类里最适合干这事的”:
- requests:发送HTTP请求的标配,简单直接,适合面向静态页面或者普通JSON接口。
- BeautifulSoup:解析HTML的小能手,尤其是面对那种“表格型”天气页面,比正则表达式的可读性好太多。
- pandas:数据清洗和聚合的核心,处理缺失值、转换日期、按月份groupby,清一色用它可以省掉大量手写循环。
- pyecharts:可视化这块我推荐它,因为生成的是交互式HTML图表,能够缩放、悬停显示数值,比matplotlib出的静态PNG更适合做演示和复盘。
- sqlite3(Python内置):数据落盘我选它而不是CSV,原因后面会详细讲。
这个选型组合没有冷门库,也不吃显卡,一台普通笔记本就能跑,对入门者非常友好。
但也有一个需要提醒的点:真实的天气数据源,有些是把数据直接放在了HTML表格里,有些则是通过接口动态返回JSON。两者的处理方式很不一样。HTML表格可以直接用BeautifulSoup找table标签,JSON接口则更适合直接转字典取键值。所以,在写爬虫之前,一定要先用浏览器打开目标页面,按F12看下网络请求到底返回了什么。这个“先观察再动手”的习惯,能帮你避免后面推倒重来。
2. 环境准备与数据源分析
这个环节看起来水,但省掉它的人基本都会在半路卡住。我见过不少朋友一上来就写爬虫代码,结果页面分析错了,目标接口和解析规则全不对,折腾半天还以为是自己代码问题。
2.1 环境搭建与依赖安装
我建议新建一个独立项目目录,方便管理后续的爬虫脚本、清洗脚本和可视化脚本。在Python虚拟环境中安装依赖即可,我使用的版本是Python 3.11:
mkdir weather_project cd weather_project python -m venv venv # Windows: venv\Scripts\activate # macOS/Linux: source venv/bin/activate pip install requests beautifulsoup4 pandas pyecharts lxml这里有一个小细节:解析HTML时建议安装lxml,它比Python内置的html.parser解析速度快得多,在处理整年365天数据时感觉不明显,但如果你把范围扩大到多城市多年份,没有lxml你会等到怀疑人生。pyecharts装完之后,默认会在当前目录生成HTML文件,不需要额外装任何浏览器组件,比较省心。
2.2 数据源两种形态的识别方式
天气数据页面的形式,我总结下来主要就两类:
- 服务端渲染的静态HTML表格:打开页面能直接看到表格数据,右键查看源代码也能找到。这种页面只需要requests抓回HTML,再用BeautifulSoup解析。
- 前端AJAX动态加载的JSON接口:页面上能看到数据,但HTML源代码里没有,按F12切换到Network面板,刷新页面,找到返回数据的XHR请求,里面的JSON才是真正的数据源。
怎么判断是哪种?最简单的办法是:在浏览器页面里按Ctrl+S保存完整HTML,再用文本编辑器打开搜一下“日期”或者“最高气温”。如果能在保存的HTML里直接找到数据,就是第一种;如果找不到,就是第二种。面对第二种情况,要直接抓取那个XHR请求的URL和请求参数,解析JSON,而不是去解析HTML。我那次实现就是因为目标站点刚好提供了接口返回,处理起来反而比解析HTML更稳定。
2.3 请求头配置与超时设置
无论面对哪种数据源,请求头都要尽量伪装得像正常浏览器。我用的基础请求头模板是:
import requests from fake_useragent import UserAgent # 平时写死一个也行,但用库随机生成能定期换一换,见下文注意点 # ua = UserAgent() headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36", "Referer": "https://target-site.example.com/weather", "Accept": "application/json, text/html, */*", "Accept-Language": "zh-CN,zh;q=0.9", } resp = requests.get("https://target-site.example.com/api/weather", headers=headers, timeout=10) resp.raise_for_status()关于headers,有一点个人经验:Referer指的是你从哪个页面跳过来的。很多接口会校验这个字段,如果你发现不带Referer时返回403或者鉴权失败,补上这个字段往往就好了。timeout一定要设,不设的话,某个请求挂起时,你的循环会一直卡在那里,越是批量爬取越要设置。
fake_useragent这个库可以随机生成UA头,但有些接口会对UA做白名单匹配,随机UA反而可能触发拦截。实测下来,我共用了一个Chrome浏览器的固定UA,配合稳定的请求间隔,并没有遇到什么问题。所以这个库并不是必须的,写死一个看起来合理的UA就够用。
3. 爬虫环节:从请求到落库的完整实现
这一节是整个项目的核心。我会用一个简化但完整可跑的示例来演示,从拿数据到存库,最后讲讲反爬应对策略。
3.1 抓取目标接口并转换为Python字典
假设目标数据源是JSON接口,返回的数据结构类似:
{ "data": { "city": "A城", "date": "2024-07-15", "high_temp": "35.2", "low_temp": "24.1", "avg_temp": "29.6", "weather_desc": "晴转多云", "precipitation": "0.0", "humidity": "63", "wind_dir": "东南风", "wind_level": "3级" } }对应爬取脚本:
import requests import json import time url = "https://target-site.example.com/api/weather" params = { "city": "A城", "date": "2024-07-15" } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0", "Referer": "https://target-site.example.com/weather" } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() data = resp.json()["data"] print(data)requests.get的params参数是一个被很多人忽略的好东西,它会把字典自动拼成?city=A城&date=2024-07-15的查询字符串,还会帮你处理URL编码,比手动拼字符串优雅也不容易出错。这里需要强调一个细节:如果接口返回的实际是JSONP或者text格式,resp.json()会抛异常,这时先打印前200个字符看看返回内容再决定下一步。
3.2 用循环获取一整年数据
思路很简单:用Pandas生成2024-01-01到2024-12-31的所有日期,然后逐个请求。这里有两个必须要注意的点:请求间隔和失败重试。
import pandas as pd import requests import time from datetime import datetime def fetch_weather(date_str): headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0", "Referer": "https://target-site.example.com/weather" } url = "https://target-site.example.com/api/weather" params = {"city": "A城", "date": date_str} resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() return resp.json()["data"] date_list = pd.date_range("2024-01-01", "2024-12-31").strftime("%Y-%m-%d").tolist() results = [] for idx, date_str in enumerate(date_list): try: item = fetch_weather(date_str) item["date"] = date_str results.append(item) print(f"已获取 {date_str}") except Exception as e: print(f"失败 {date_str}: {e}") # 关键点:每次请求之间间隔0.5秒 time.sleep(0.5)这个代码里体现的其实是最朴素的“君子爬虫”做法:每天请求一次,加上0.5秒延时,对目标站点的影响很小。我个人建议,即便你有能力并发请求,也在初版脚本里老老实实做单线程串行采集。全年365天,串行加上延时也就不到10分钟,完全没有必要为了追求速度去增加被封的风险。
3.3 数据落盘:CSV与SQLite怎么选
采集到的results是一个列表,每个元素是一个字典。落盘方式我试过两种:
方式一:存CSV
df = pd.DataFrame(results) df.to_csv("weather_A_city_2024.csv", index=False, encoding="utf-8-sig")CSV的好处是方便用Excel打开人工查看,适合数据量小、后续不打算做复杂关联查询的场景。注意编码一定要用utf-8-sig,否则你用Excel打开时中文会变成乱码。
方式二:存SQLite
import sqlite3 conn = sqlite3.connect("weather.db") df = pd.DataFrame(results) df.to_sql("daily_weather", conn, if_exists="replace", index=False) conn.close()我实际项目里用的是SQLite。原因很简单:第一,采集过程中可能会中断,如果只存CSV,中断一次就得重新爬;而SQLite可以逐条INSERT,爬一条存一条,天然支持断点续传。第二,后面做统计时,SQL语句直接SELECT date, high_temp FROM daily_weather WHERE month=7,比反复读CSV再过滤要舒服。
当然,最简单的稳妥做法是:采集过程中每条写入SQLite,全部采集完了再从SQLite导出CSV做备份。这样既有断点续传能力,也能拿到方便查看的文件。要注意pandas的df.to_sql()写出的表,日期字段是字符串,后面清洗时记得再转一次。
3.4 反爬应对策略:从频率控制到重试机制
天气数据站点虽然一般不会对低频爬虫采取极端措施,但基本的反爬意识还是要有的。我总结了一套从轻到重的策略:
- 频率控制最基础:每次请求间隔不低于0.3秒,这个间隔既不影响效率,又能大幅降低被识别为机器行为的概率。
- 失败重试有讲究:遇到超时、5xx等临时性错误,不要立刻重新请求,而是等几秒钟再试。常见的写法是循环三次,每次等待时间递增,比如等待2秒、5秒、10秒。
- 请求头务必完整:UA、Referer、Accept-Language这些字段尽量都带上。有些接口会校验这些字段的组合是否像一个真实浏览器。
- 遇到验证码直接停:如果你发现某次请求返回的是验证码页面,基本说明IP已经被标记了。这个时候不要硬刚,把当天数据标记为缺失,休息几小时再继续。硬爬只会让IP被标记更久。
很值得提醒的是,爬虫不是越强悍越好,目标只是“把数据拿回来”,不是“证明自己技术多厉害”。这套策略的前三条我基本上每个爬虫项目都会用,第四条则是我在大规模采集时的纪律底线。
4. 数据清洗与聚合统计:让原始数据变成可用数据
很多初学者爬完数据就急着画图,结果画出来的图要么日期轴乱序,要么缺一个月的数据,要么出现湿度99%、温度-15℃这种异常值。数据清洗虽然枯燥,但它决定了可视化的质量。这一节我把清洗过程分成三步讲。
4.1 日期字段处理与缺失值填充
从SQLite读回DataFrame后,第一步永远是看一下基本信息:
import sqlite3 import pandas as pd conn = sqlite3.connect("weather.db") df = pd.read_sql("SELECT * FROM daily_weather", conn) conn.close() print(df.info()) print(df.describe())df.info()能看出每列有没有空值和类型对不对。我实测的结果是,date列是object类型(字符串),需要转成datetime类型;high_temp列是int或者float取决于当时怎么存的,需要统一转成float;weather_desc列有少数空值,通常是接口当天返回异常导致的。
日期转类型的代码很简单:
df["date"] = pd.to_datetime(df["date"]) df["high_temp"] = pd.to_numeric(df["high_temp"], errors="coerce") df["low_temp"] = pd.to_numeric(df["low_temp"], errors="coerce")errors="coerce"是个好设计,它会将无法转换的值变成NaN,而不是直接报错终止。如果某些天的天气现象缺失,评估一下缺失比例,如果只有几天,可以根据前后天的天气做简单填充;如果缺失了整月,最好重新补采,因为插值堆出来的数据画图没意义。
4.2 异常值筛查:不要放过任何一个离谱数字
天气数据里常见的异常有四类:
- 气温倒挂:最高气温小于最低气温,绝大多数情况是数据解析错误。
- 数值越界:相对湿度大于100%,或者降水为负数,基本都是脏数据。
- 突变异常:前一天高温35℃,后一天突然变成零下10℃,这在非寒潮场景下就很可疑。
- 类型污染:比如温度字段里混入了中文备注,导致转成NaN。
我习惯写一个简单的过滤器:
# 气温倒挂检查 invalid_order = df[df["high_temp"] < df["low_temp"]] print(f"气温倒挂记录数: {len(invalid_order)}") # 湿度越界检查 invalid_humidity = df[(df["humidity"] > 100) | (df["humidity"] < 0)] print(f"湿度异常记录数: {len(invalid_humidity)}")对于气温倒挂,如果只是少量记录,最稳妥的处理方式是直接删除那一天的数据,或者参考前后两天的均值修正。不建议闭着眼睛把high_temp和low_temp对调,因为对调后可能依然不符合事实,比如数据本来就是解析错位。
4.3 按月聚合统计:从每天到每月
清洗干净后,接下来就是可视化前的聚合。我们按月份算均值,顺便统计每个月的降雨。
df["month"] = df["date"].dt.month df["year"] = df["date"].dt.year monthly_stats = df.groupby("month").agg( avg_high=("high_temp", "mean"), avg_low=("low_temp", "mean"), avg_temp_mean=("avg_temp", "mean"), total_precip=("precipitation", "sum"), avg_humidity=("humidity", "mean") ).round(1) monthly_stats.to_csv("monthly_weather_stats.csv", encoding="utf-8-sig")这一步输出的monthly_weather_stats.csv,后面所有月维度图表都会用到。聚合时千万记得在groupby后用.round(1)保留一位小数,否则生成图表时坐标轴上会出现一长串烦人的小数位。
5. 可视化实现:四张图看透一年天气
可视化选pyecharts,核心原因前文提过,交互式图表在复盘时特别好用。以下四张是我认为最能说明问题的图。
5.1 全年气温趋势折线图
第一张图看整体温度变化。用日度数据画,横轴是日期,纵轴是温度,三条线分别是最高气温、平均气温、最低气温。
from pyecharts.charts import Line from pyecharts.options import TitleOpts, TooltipOpts, LegendOpts, DataZoomOpts line = ( Line() .add_xaxis(df["date"].dt.strftime("%m-%d").tolist()) .add_yaxis("最高气温", df["high_temp"].round(1).tolist(), is_smooth=True) .add_yaxis("平均气温", df["avg_temp"].round(1).tolist(), is_smooth=True) .add_yaxis("最低气温", df["low_temp"].round(1).tolist(), is_smooth=True) .set_global_opts( title_opts=TitleOpts(title="A城2024年气温变化趋势"), legend_opts=LegendOpts(pos_top="5%"), tooltip_opts=TooltipOpts(trigger="axis"), datazoom_opts=[DataZoomOpts(range_start=0, range_end=100)], ) ) line.render("temperature_trend.html")如果不加DataZoomOpts,365个点全部挤在一张图里,根本看不清春夏秋冬的细节。加了滚动条后,可以任意缩放查看某一段区间,交互体验完全不一样。这是pyecharts相比matplotlib最有优势的地方。
5.2 月度降雨量与湿度组合图
第二张图聚焦水分维度。月份作为横轴,降雨量用柱状图,湿度用折线图,组合在同一个坐标系里。
from pyecharts.charts import Bar, Line as Line2 bar = ( Bar() .add_xaxis([f"{m}月" for m in monthly_stats.index.tolist()]) .add_yaxis("降雨量(mm)", monthly_stats["total_precip"].tolist()) ) line2 = ( Line2() .add_xaxis([f"{m}月" for m in monthly_stats.index.tolist()]) .add_yaxis("湿度(%)", monthly_stats["avg_humidity"].tolist()) ) bar.overlap(line2) bar.set_global_opts( title_opts=TitleOpts(title="A城2024年月度降雨与湿度对比"), yaxis_opts=None ) bar.render("precip_humidity.html")这张图能直观看出:哪几个月是雨季,雨季的湿度是否和降雨同步上升。我自己跑下来最有感触的是,往往7、8月降雨量最高,但湿度曲线在6月就已经爬到了顶峰,说明“体感最闷”和“下最多雨”并不完全同步。这种发现,不做数据可视化永远看不到。
5.3 风向频率极坐标玫瑰图
第三张图分析风向。它把一年时间里不同风向出现的次数,画在极坐标系里。做成玫瑰图的好处是,面积大小一目了然,能直接看出这座城市的主导风向是什么。
from pyecharts.charts import Pie from pyecharts import options as opts wind_count = df["wind_dir"].value_counts().reset_index() wind_count.columns = ["wind_dir", "count"] pie = ( Pie() .add( "", [list(z) for z in zip(wind_count["wind_dir"], wind_count["count"])], radius=["20%", "60%"], center=["50%", "50%"], rosetype="radius", label_opts=opts.LabelOpts(formatter="{b}: {c}天"), ) .set_global_opts(title_opts=opts.TitleOpts(title="A城2024年风向分布")) ) pie.render("wind_rose.html")这里有个经验之谈:rosetype="radius"是玫瑰图模式,效果是扇形半径不一样,能凸显占比差异;如果不设置,就是普通饼图。风向字段在清洗时必须做归一化处理,比如把“东南风”“东风”都单独统计,但要把“东南风3级”这种带风力后缀的文本先去掉。我们数据源正好是纯风向,所以不需要额外处理。
5.4 高温日分布热力日历图
第四张图是我最喜欢的,它用日历热力图的形式展示一年里温度超过30℃的天数分布。严格来说pyecharts的Calendar组件可以实现:
from pyecharts.charts import Calendar from pyecharts import options as opts heat_data = [ [df["date"].dt.strftime("%Y-%m-%d").tolist()[i], int(df["high_temp"].tolist()[i])] for i in range(len(df)) ] cal = ( Calendar() .add("", heat_data, calendar_opts=opts.CalendarOpts(range_="2024", cell_size="auto")) .set_global_opts( title_opts=opts.TitleOpts(title="A城2024年每日最高气温日历"), visualmap_opts=opts.VisualMapOpts( max_=40, min_=0, orient="horizontal", is_custom=True, ), ) ) cal.render("high_temp_calendar.html")日历图查看分布特别直观:一眼扫过去就能认出哪几周热浪连片,哪几天骤冷,比折线图更适合做“全年温度记忆”的回顾。唯一要提醒的是,Calendar组件对日期格式要求严格,必须是“YYYY-MM-DD”的字符串,所以传入前先把date列格式化好。
6. 踩坑记录与排查速查表
这部分是全文最有“烟雾弹”价值的内容。我在实现过程中确实遇到过下面这些问题,有些花了不少时间才找到原因。
6.1 中文乱码:接口返回正常但Python打印乱码
这个问题绝大多数出在编码处理上。解决方案是,拿到resp之后不要直接.json(),先看响应头里的charset:
# 如果response没有正确识别编码 resp.encoding = resp.apparent_encodingapparent_encoding会根据响应内容自动猜测编码,对中文站点准确率很高。还有,存CSV时的utf-8-sig编码问题我在前文说过了,重提一次,因为真的太常见了。
6.2 请求返回403但页面能在浏览器打开
403十有八九是请求头不够“像浏览器”。排查思路从轻到重:先补全User-Agent,再补Referer,再补Accept和Accept-Language,最后如果还不行,检查是不是需要带Cookie。很多接口只要你先在浏览器里访问一次目标页面,把Cookie复制过来塞进requests里就行。
6.3 解析HTML表格时行数对不上
用BeautifulSoup解析静态表格时,有人会遇到“某一行解析丢了”的情况,原因通常是单元格里嵌套了子标签。解决办法是拿每一行中所有的td或th,并且对每个单元格使用get_text(strip=True),不要直接用.text。如果个别行有合并单元格(colspan),解析逻辑要额外处理,否则字段对不齐。这类边角问题很磨人,建议直接拿一行的HTML原始内容做对比,排错效率高。
6.4 图表中文标签变成方框
pyecharts生成的HTML图表,理论上中文会正常显示。但如果你的系统缺少中文字体,又用了非HTML渲染方式,就会变成方框。排查顺序:先确认系统有没有需要的字体,再检查HTML文件本身是不是UTF-8编码保存。实在不行,就在pyecharts的全局配置里指定一个确定存在的中文字体名称。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查优先级 |
|---|---|---|
| resp.json()抛异常 | 返回的不是JSON,可能是HTML错误页 | 先print前200字符 |
| 所有请求都403 | UA/Referer被拦截 | 补全请求头,带Cookie |
| 单个日期失败后全集停 | 循环缺少try/except | 加异常捕获,标记失败日期 |
| SQLite表里日期是字符串 | pandas默认推断类型 | 读出后pd.to_datetime重新转换 |
| 折线图x轴密集看不清 | 缺少datazoom | 加DataZoomOpts,开启缩放 |
| Excel打开CSV中文乱码 | 编码用了utf-8 | 改用utf-8-sig |
这张表是我根据自己排查过程整理的,不一定覆盖所有数据源情况,但思路可以复用:先看原始返回内容,再判断解析环节是否出错,最后才怀疑可视化库的问题。
最后分享一个实践经验
项目收尾后我最大的体会是:天气数据爬虫的技术难度其实不高,真正花时间的地方在数据规整和图表解读。数据规整做不好,图表再华丽也是误导人的;图表解读做不好,数据清洗得再干净也体现不出价值。所以建议你在动手之前,先花几分钟把“我到底想通过数据分析出什么结论”写下来,不要等到图全画完了再临时想解读思路。
另外,如果你后续想扩展这个项目,我推荐两个方向:一个是在现有数据库上增加历史多年数据,做“今年和往年同期对比”;另一个是引入多城市对比,研究不同气候区的差异。基础架构我已经替你验证过——requests采集、SQLite存储、pandas清洗、pyecharts可视化,这一套足够支撑你往任何方向扩展。希望你也能做出几张让自己眼前一亮的天气图表。