做年终复盘的时候,我老想看看过去一年这座城市到底经历了什么样的天气——最热是哪天、最冷是哪天、下了几场雨、秋天是不是真的比以往短了。手动去天气网站一天一天地翻,光想想就头皮发麻。后来干脆写了段Python脚本,把全年天气数据一次性抓下来,再用pandas和matplotlib做成可视化图表。这篇文章就是把那次从爬虫到分析的全过程整理出来:怎么选目标网站、怎么处理乱码和反爬、怎么把“气温12℃/2℃”这种字符串拆成结构化数据,最后又怎么画出一组能直接聊结论的图。适合刚学完Python基础、想用爬虫做一个完整数据项目的朋友参考。
1. 数据源选型:先想清楚爬哪个网站、怎么爬
1.1 为什么把目标锁定在静态HTML页面
很多人在做天气爬虫时,第一个念头是找那些界面特别好看的天气App对应的网页版。但这类站点大多用了动态渲染,页面上的数据是JavaScript在浏览器里加载出来的,直接发一个requests请求拿回来的是空壳HTML。新手很容易卡在这一步,以为自己的代码写错了,其实是目标没选对。
所以做入门级爬虫项目,最优先考虑的是静态HTML页面。判断方法很简单:打开网页,右键选择“查看网页源代码”,在源码里搜索页面上显示的一个具体数字或日期。如果能搜到,说明这些内容就在服务器返回的原始HTML里,用requests就能拿到;搜不到,说明是前端脚本动态渲染出来的。
我当时选的是一个老牌历史天气查询站点,页面结构相当朴素,数据全部放在标准HTML表格里。这种站点对爬虫新手非常友好,不需要分析复杂的XHR请求,也不用对接WebSocket,写一个循环请求12个页面就能拿到全年数据。
另外还要注意,目标网站的访问策略要宽松。如果一进去就弹登录框、滑块验证码,那就算能搞定,学习成本也完全偏离了“天气数据分析”这个主题。
1.2 URL结构拆解与请求参数设计
选好站点后,我先把目标页面的URL多打开几个,观察它们之间的规律。历史天气页面通常长这样:
text复制https://www.tianqihoubao.com/lishi/beijing/month/202301.html
https://www.tianqihoubao.com/lishi/beijing/month/202302.html
https://www.tianqihoubao.com/lishi/beijing/month/202303.html
这个URL结构非常清晰,拆开来看就三个变量:
| URL组成部分 | 作用 |
|---|---|
https://www.tianqihoubao.com/lishi/ |
域名与历史天气路径 |
beijing |
城市拼音,决定要哪个城市的数据 |
month/202301.html |
年月份,决定要哪个月的数据 |
既然每个月只有一个数字在变,那爬全年数据就变成一个简单的循环问题:拼接12个URL,逐个请求,把每月表格汇总起来。这比那些需要翻页、需要点击“下一页”的网站省事太多。
我建议在写爬虫之前,先把URL规律用表格梳理清楚,这比直接上手写代码更省时间。你可以先手动改几个月份参数,确认变化规律,再决定循环逻辑怎么写。
请求头方面,我没有做太复杂的伪装,只设置了User-Agent和Referer。很多站点对没有UA的请求会直接拒绝,所以这两项是底线配置。
python复制import requests
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Referer": "https://www.tianqihoubao.com/lishi/"
})
1.3 解析策略:优先找表格,别急着写正则
拿到HTML之后,解析数据的方式直接决定了代码的复杂程度。我一开始准备用BeautifulSoup逐行定位tr标签,写了一会儿觉得不对劲——这个页面本身就是规规矩矩的表格结构,为什么要自己遍历?
后来我换了个思路:用pandas.read_html()直接读取页面里所有HTML表格。这个方法会把每个<table>自动转成一个DataFrame,几乎不用解析代码。
python复制import pandas as pd
url = "https://www.tianqihoubao.com/lishi/beijing/month/202301.html"
response = session.get(url)
response.encoding = "gbk"
tables = pd.read_html(response.text)
df = tables[0]
这一步就能把天气表格读出来了。不过读出来之后列名比较乱,通常是第一行“日期 天气状况 气温 风力风向”被当成了表头,需要自己做规整。
但要注意,read_html()不是万能的。如果目标网站的表格不是标准<table>结构,或者用了大量嵌套div模拟表格,这个方法就会失效。我的习惯是:先按F12看页面结构,确认有标准表格就优先用read_html;没有的话再退回BeautifulSoup。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 全年数据抓取的完整实现:从请求到结构化DataFrame
2.1 访问节奏与请求容错
爬虫最忌讳的就是“一口气把所有请求瞬间打出去”。很多人第一次写爬虫,循环里没有暂停,12个月的数据12个请求都是秒发,运气好没事,运气不好直接触发反爬,后面所有页面返回的都是验证码。
我做了一个比较稳妥的节奏:每请求一个页面,time.sleep(1)。看一眼整个循环的总耗时,一年12个月,就算每页睡2秒,额外增加的时间也就24秒,完全能接受。真正要抓几千几万页的时候,才需要考虑更精细的频率控制。
同时,每个请求都设置了timeout=10。如果不加超时,一旦目标站点响应异常,脚本会一直卡在那里,等半天才发现某个页面没抓到,浪费时间。
python复制import time
import datetime
base_url = "https://www.tianqihoubao.com/lishi/beijing/month/{}{}.html"
all_data = []
for month in range(1, 13):
url = base_url.format(2023, str(month).zfill(2))
try:
response = session.get(url, timeout=10)
response.encoding = "gbk"
tables = pd.read_html(response.text)
if tables:
all_data.append(tables[0])
print(f"已抓取 2023年{month}月,共{len(tables)}个表格")
except Exception as e:
print(f"2023年{month}月抓取失败:{e}")
time.sleep(1)
str(month).zfill(2)这个细节值得提一下:它能确保月份参数始终是两位数字,1月变成01,11月还是11。有些网站的URL参数不要求补零,但补上更保险——万一哪天页面重定向,URL格式不一致就会出问题。
2.2 把表格拼成一个年度数据表
每个月都是一个独立的DataFrame,最后用pd.concat()一次性拼接,比在循环里反复append到同一个DataFrame要高效得多,代码也更清晰。
python复制df_year = pd.concat(all_data, ignore_index=True)
df_year.columns = ["日期", "天气状况", "气温", "风力风向"]
拼完之后,先别急着清洗,我用df_year.head()和df_year.info()看一眼整体情况。你会发现很多“预期之外”的问题:有些列有缺失值,气温里混着奇怪的换行符和空格,风力风向里有的写着“东北风<3级”,有的写着“东南风4-5级”。这些都是在页面表格里肉眼看不到的脏数据,不清洗根本没法做可视化。
2.3 气温与风力字段的拆分清洗
这个环节是整个项目里最花时间的。原始气温字段长这样:
| 原始数据 |
|---|
| 12℃ / 2℃ |
| -3℃ / -11℃ |
| 5℃ / 0℃ |
需要把它拆分成“最高气温”和“最低气温”两列,并且去掉“℃”符号、转成数值类型。
python复制temp_split = df_year["气温"].str.split("/", expand=True)
df_year["最高气温"] = temp_split[0].str.replace("℃", "").str.strip().astype(int)
df_year["最低气温"] = temp_split[1].str.replace("℃", "").str.strip().astype(int)
日期字段同样需要处理——原始值是“2023年01月01日”这种格式,直接用pd.to_datetime解析会报错,需要先替换掉中文年月日。我的做法是:
python复制df_year["日期"] = df_year["日期"].str.replace("年", "-").str.replace("月", "-").str.replace("日", "")
df_year["日期"] = pd.to_datetime(df_year["日期"], errors="coerce")
errors="coerce"是这里的关键点,即使个别行解析失败变成NaT,也不会中断整个清洗流程。
天气状况这一列不需要拆太多,但可以做一个标记字段,方便后面统计降水天数。我的逻辑是:只要天气描述里包含“雨”字,不管是小雨、中雨、大雨还是雷阵雨,都算一个降水日。
python复制df_year["是否降水"] = df_year["天气状况"].str.contains("雨", na=False)
最终清洗完的数据表是下面这个结构:
| 日期 | 天气状况 | 最高气温 | 最低气温 | 风力风向 | 是否降水 |
|---|---|---|---|---|---|
| 2023-01-01 | 晴 | 5 | -6 | 东北风<3级 | False |
| 2023-01-02 | 多云 | 6 | -4 | 南风<3级 | False |
清洗一步到位后,后面所有分析都顺畅了。
3. 可视化分析:从气温走势到各月分布的多维呈现
3.1 全年气温走势:最高温和最低温的双线图
图表习惯上,我一般先画一个最粗粒度的:全年365天的气温折线图。横轴日期,纵轴温度,两条线分别是最高气温和最低气温。
python复制import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
fig, ax = plt.subplots(figsize=(14, 6))
ax.plot(df_year["日期"], df_year["最高气温"], label="最高气温", linewidth=0.8)
ax.plot(df_year["日期"], df_year["最低气温"], label="最低气温", linewidth=0.8)
ax.set_title("2023年北京全年气温走势")
ax.set_xlabel("日期")
ax.set_ylabel("气温(℃)")
ax.legend()
plt.show()
如果你把我这段代码原样跑一遍,会发现一个直观的结论:全年气温曲线呈明显的“U形”或“倒U形”,7月前后温度最高,1月和12月最低,秋天降温速度比春天升温速度快。这张图看起来简单,但它是整个项目的核心图表,所有更细的分析都围绕这张图展开。
这里要提醒一个细节——matplotlib对中文的默认支持很差,不设置字体的话,标题和图例会变成一个个方框。plt.rcParams["font.sans-serif"] = ["SimHei"]就是解决这个问题的,同时还要把axes.unicode_minus设为False,否则坐标轴上的负号会显示成乱码。
3.2 月度平均气温与降水天数统计
全年折线图信息量大,但一眼看不完。我接着做了一个“月度聚合”:把按天计算的平均最高温、平均最低温汇总成每个月的数值,再用柱状图呈现,这样就能清楚地看到每个月的体感冷热。
python复制monthly = df_year.groupby(df_year["日期"].dt.month).agg({
"最高气温": "mean",
"最低气温": "mean",
"是否降水": "sum"
}).reset_index()
monthly.columns = ["月份", "平均最高气温", "平均最低气温", "降水天数"]
这个聚合结果里,平均气温是数值型的,直接就能画。降水天数是每月降水日的总和,也是一个非常直观的指标。把两件事放在同一张图里展示,需要用到双y轴,左边画气温柱状图,右边画降水天数折线,或者干脆分成上下两个子图。我用的是上下子图方案,信息更清晰,不会因为坐标尺度不同而误导读者。
从2023年的数据来看,夏季虽然平均气温最高,但降水天数反而不一定最多,降水更多集中在7到8月的短时强降水。如果不做月度聚合,这些季节性规律很难从原始数据里一眼看出来。
3.3 温差与天气状况的关系
做完基本趋势分析后,我往回收了一步,想看看“昼夜温差”和天气状况有没有关系。这个问题的切入点是:晴天是不是温差往往更大,阴雨天是不是温差相对较小。
于是我新增了一列“温差”:
python复制df_year["温差"] = df_year["最高气温"] - df_year["最低气温"]
然后做一个分组统计:
python复制weather_temp = df_year.groupby("天气状况")["温差"].agg(["mean", "max", "min"])
为了不让图表太散,我没有把几十种天气描述都画出来,而是先做一个粗分类:晴天(晴、晴间多云)、多云、阴天、降雨(各种雨)、降雪(各种雪)。
python复制def classify_weather(desc):
if "雨" in str(desc):
return "降雨"
if "雪" in str(desc):
return "降雪"
if "晴" in str(desc):
return "晴天"
if "阴" in str(desc):
return "阴天"
if "云" in str(desc):
return "多云"
return "其他"
df_year["天气大类"] = df_year["天气状况"].apply(classify_weather)
画出来的箱线图会告诉你一个很典型的现象:晴天的温差中位数最高,降雨和降雪的温差明显收窄。原因也简单,云层和降水带来的水汽会削弱夜间的地面辐射降温,所以昼夜温差小。
3.4 各月气温分布箱线图
我还画了一个各月最高气温的箱线图,用来观察气温的离散程度。箱线图比平均值能呈现更多信息——中位数、四分位数、异常值一目了然。
python复制import seaborn as sns
sns.boxplot(data=df_year, x=df_year["日期"].dt.month, y="最高气温")
这张图最明显的价值在于:它不只告诉你7月“平均”最热,还能告诉你7月的温度波动有多大,有没有某几天异常偏凉。比如某个月箱体特别长,说明这个月气温起伏剧烈,体感上就是“过山车式”的天气。
4. 爬虫实战避坑记录:五类常见问题的定位与处理
4.1 反爬机制:UA被识别与请求频率限制
做爬虫必然会遇到反爬。我这次碰到最明显的现象是:前几个月数据抓得好好的,到第6个月的时候突然连续返回503状态码,页面内容变成了一个安全验证页。
排查步骤是这样的:先确认是不是自己的代码问题,单独打开第6个月的URL,发现浏览器里正常访问没问题;再用requests单独请求这一个URL,复现了503。说明不是代码逻辑错误,而是服务端检测到了非浏览器访问特征。
解决办法有两个层面。第一,把User-Agent换成一个最新的浏览器UA,并补上Referer字段,伪造一个更完整的请求链条。第二,把请求间隔从1秒拉到3秒,并且给每个请求加一个随机小抖动,避免机器式的固定间隔。调整之后,后续请求恢复正常。
这里我想多说一句:爬虫技术本质上没有恶意,但使用场景要有边界。任何抓取行为都应该遵守目标网站的robots.txt和使用条款,控制访问频率,不要给目标服务器造成压力,数据只用于个人学习与研究。
4.2 编码问题:GBK与UTF-8的混乱
天气数据页面的编码是GBK,而requests库不一定能自动识别。如果不显式指定编码,解析出来的中文极大概率是乱码。
我当时遇到的情况是:表格能读出来,但“晴”“多云”全部变成“�����”。一查发现是response.encoding没有正确设置。这个站的页面源码里虽然写了charset=gbk,但requests在读取响应头时没有得到明确的编码信息,默认按ISO-8859-1处理,于是中文全部崩了。
解决办法是最简单的一行:
python复制response.encoding = "gbk"
遇到这种问题时,我的排查思路是:先在浏览器开发者工具里看响应头,找到Content-Type字段里写的字符集;如果响应头里没有,就去页面HTML的<meta>标签里找;如果还是没有,就用chardet库自动检测一下。不要盲目按UTF-8解码。
4.3 日期解析与缺失值的处理
爬下来的数据里,日期字段偶尔会有异常。比如某个月的表格最后一列多了一个空行,或者某一天的数据行单元格为空,导致pd.to_datetime直接抛异常。
我的处理方式是两段式。第一段,先把日期字符串规范化,把“年”“月”“日”替换成减号和空字符,让格式变成2023-01-01。第二段,在转换时加上errors="coerce",这样即使个别日期解析失败,也只会变成NaT,而不会中断整个流程。最后再用dropna(subset=["日期"])把这些无效行去掉。
气温字段同样会有缺失。有些页面在某些极端天气下会留空“风力风向”,或者“最低气温”缺失。我选择用fillna处理,简单起见可以统一填成0,或者直接删除这些行。我的习惯是:如果缺失行占比很小,就直接删;如果占比不小,就要回去看是不是某个页面结构解析错了。
4.4 网络超时与重试机制
网络请求天然不稳定,即使目标网站没问题,你的网络环境也可能出问题。我遇到过一次:某个月份的请求等了将近30秒没响应,脚本卡在那里,后续所有月份都停住了。
给所有请求加上timeout参数是第一道防线:
python复制response = session.get(url, timeout=10)
第二道防线是写一个简单的重试逻辑。请求失败后等待2秒,重新请求,最多重试3次;如果3次都失败,才把这次请求标记为失败并记录日志。
python复制for attempt in range(3):
try:
response = session.get(url, timeout=10)
response.encoding = "gbk"
break
except Exception:
if attempt == 2:
raise
time.sleep(2)
这种“3次失败才算失败”的容错设计,在实际抓取中能救回不少页面,尤其是网络偶尔抖一下的情况。
4.5 页面结构变化导致解析失败
爬虫圈有一句老话:爬虫不怕反爬,怕的是页面改版。HTML结构一旦调整,read_html读出来的表格列数就可能发生变化,原来有4列,改版后变成5列,后续所有按列名索引的处理全部报错。
有一次我重新跑脚本,发现读出来第一张表的列数是6而不是4,原来是页面新版插入了一个“空气质量指数”列。我原来的代码里df_year.columns = ["日期", "天气状况", "气温", "风力风向"]只写了4个列名,结果列数不匹配直接报错。
解决方式是不要写死列名,而是先判断列数,再动态分配:
python复制if len(df.columns) == 4:
df.columns = ["日期", "天气状况", "气温", "风力风向"]
elif len(df.columns) >= 5:
df.columns = ["日期", "天气状况", "气温", "风力风向", "空气质量"] + list(df.columns[5:])
这个习惯在后来的爬虫项目里帮了我很多——永远假设页面会变,代码里留下灵活应对的余地。
5. 项目延伸:多城市对比与多年趋势分析
5.1 多城市横向对比
单城市的数据分析做完了,自然会想对比。把城市拼音抽成一个变量,循环抓取几个不同纬度的城市,比如北京、上海、广州、成都,然后放在同一张图里看全年气温走势。
python复制cities = ["beijing", "shanghai", "guangzhou", "chengdu"]
for city in cities:
base_url = f"https://www.tianqihoubao.com/lishi/{city}/month/202301.html"
# 抓取并清洗
画图时可以对比同一时间段北方的“干冷”和南方的“湿冷”,以及夏季南北方的气温差异是否真的存在。这种横向对比能让分析维度更丰富。
5.2 多年数据趋势分析
如果你不满足于只看一年,可以把外层循环改成遍历“年份和月份”两个维度。比如抓取2018到2023年共6年的数据,然后计算每年的平均气温、高温天数、低温天数,看气候变化趋势。
这个做法的成本控制要注意一下。6年×12个月=72个请求,不算多,但如果你一口气连续请求72个页面,还是有可能被限制。我的做法是每批次请求之间加一个5秒的暂停,并且把抓取结果实时写入CSV,而不是等全部跑完再保存,这样即使中途挂了也不会丢掉前面的成果。
5.3 定时增量更新
全年数据是一次性拉取的,但如果想让这个项目“活”起来,可以做成定时任务。每天固定时间抓一次当天的天气数据,追加到已有的CSV里,经过一段时间就有了一份持续更新的本地天气数据库。
增量更新的核心逻辑是“跳过已有日期”:抓取之前先读取CSV里的最大日期,只处理最大日期之后的数据。这样哪怕脚本每天跑,也不会重复抓取老数据,对目标服务器的负担也最小。
我在实践里的做法是:用系统的计划任务每天凌晨跑一次脚本,脚本里先对比本地文件日期,再决定是否需要请求新页面。整个过程不需要任何复杂的调度框架,几行逻辑就能搞定。
整个项目做下来,我最大的体会是:爬虫本身只是手段,把数据清洗干净并做出能说服自己的可视化结论,才是这个项目真正花时间的地方。建议你也从自己所在城市入手,先跑通单月、单页的流程,再逐步扩展到全年。别急着追求高级框架,先把requests、pandas、matplotlib这三板斧玩熟练,你会发现自己想分析的任何天气数据都不再是难事。
