☰
Python爬虫抓取全年天气数据:从数据清洗到可视化分析实战
2026/10/1 23:00:38 网站建设 项目流程

2023年快结束的时候,我特别想做一件事:把这座城市一年365天的天气数据全抓下来,站在全年维度好好看看,气温到底是怎么波动的、哪几个月最干燥、降水和温度之间有没有关联。一开始我以为可以随便找个现成的年度天气报表看看,结果翻了几个平台,要么只给个月均温,要么数据字段对不上,更重要的是没法把我想要的“每日最高温、最低温、天气现象、降水量、风力”放在同一张表里做交叉分析。最后干脆决定用 python 爬虫自己爬全年天气数据,然后用 pandas 做清洗、用 matplotlib 做可视化分析。这篇文章就是这次完整过程的记录,包含爬虫的调度逻辑、数据清洗的细节、四张关键图表的绘制方式,以及几个我踩过之后印象深刻的坑。

1. 为什么我决定自己爬而不是白嫖现成报表

1.1 想分析的不是“平均气温”,而是逐日细节

市面上很多天气分析工具给的是月度平均、季度汇总这类统计结果。我能理解,因为对大多数人来说,看个“今年夏天平均温度比去年高1度”就够了。但我这次想回答的问题其实更具体:全年最高气温出现在哪几天?连续高温最长持续了多少天?降水集中在哪几个月?气温和湿度之间到底有什么关系?

这些问题依赖的都是逐日原始数据,而不是统计报表。举个例子,我后来抓下来的数据里,某天最高气温35.8℃,全天没下雨,但湿度高达72%,体感闷热到不行;另一天最高气温只有29℃,湿度55%,反而舒服得多。这种细节在“月平均最高温”里完全看不出来,必须自己拿到每天的原始记录才能分析。

1.2 现成报表接口不统一,干脆自己攒一份

我也试过找现成的开放数据接口。有几个免费API确实能用,但要么只提供实时天气和未来几天预报,要么把历史天气数据放在付费档,免费调用的次数少得可怜。还有一些平台提供了历史天气页面,但数据是散落在月度页面里的,没有一次性导出的功能,想要全年数据就得手动复制12次,每次都还要清理格式。

所以最靠谱的方案反而回到了爬虫本身:找到历史天气页面,按月份遍历,把每天的数据解析出来,自己重新拼成一份干净的CSV。这个过程听着麻烦,但做完之后数据完全在自己手里,字段想怎么处理就怎么处理,后续分析也好、可视化也好,都特别顺手。

1.3 技术选型:requests + BeautifulSoup + pandas + matplotlib

整套链路我用的都是Python生态里最常规的库,没上Scrapy这种重型框架,因为这个项目体量不大,就是12个页面、365条记录,常规的requests就够用了。

  • requests:负责发起HTTP请求,模拟浏览器访问天气历史页面。
  • BeautifulSoup:负责解析HTML,把藏在表格里的天气数据提取成结构化字段。
  • pandas:负责把解析结果整理成DataFrame,做类型转换、缺失值检查和按月聚合。
  • matplotlib:负责画图。

没有用pyecharts,虽然那东西交互性确实好,但我想输出的是一份可以直接贴进文档里的静态长图,matplotlib在自定义排版上更顺手,中文支持也比以前好处理多了。

2. 数据源摸底与全年调度策略

2.1 判断天气历史数据源的核心标准

爬虫第一步不是写代码,而是先挑数据源。我判断一个天气历史页面能不能用,主要看三件事:

  1. URL是否有规律可循。最好能通过年份、月份构造出不同页面的地址,这样12个月就是12个URL,循环一下就完事。如果只能靠点击翻页,那还得处理动态加载,麻烦很多。
  2. 字段是否完整。我需要的字段至少包括日期、最高温、最低温、天气现象、降水量、湿度和风力方向。有的页面只给最高最低和天气,没有降水量,那就没法做后面的降水和气温关系分析。
  3. 数据更新是否及时且稳定。历史天气数据的意义在于“全年完整”,如果目标站点经常出现某几天数据缺失,或者页面结构三天两头改版,那爬取成本会成倍增加。

我当时选择了一个公开的天气历史数据页面做主要来源,它的URL基本长这样:https://xxx.com/history/2023/1.html,年份在最前面,月份跟在后面,完全可以程序化生成。页面里有一个表格,每天占据一行,列包含“日期、最高气温、最低气温、天气、降水、湿度、风向风力”。这个结构非常传统,适合用BeautifulSoup直接解析。

2.2 用“月份维度”的URL规律避免逐日翻页

刚开始我想的是抓365个单日数据,后来发现完全不必要。大部分天气历史页面虽然展示的是整月数据,但会在一个页面里把当月每一天的记录都列出来。也就是说,全年只需要请求12次页面,每次解析出一个月的30条左右记录,就能拼出365条完整数据。

这给调度带来的好处是不需要模拟复杂的翻页操作,一个简单的循环就能搞定:

import time import random year = 2023 all_records = [] for month in range(1, 13): url = f"https://xxx.com/history/{year}/{month}.html" resp = fetch_page(url) # 单人页请求函数 records = parse_month(resp) # 单月解析函数 all_records.extend(records) # 控制请求频率,避免给目标站点造成压力 time.sleep(random.uniform(0.8, 1.5))

从工程角度看,这种设计简单可靠,因为月份和URL是一一映射的,就算中途断掉了,也知道从哪个月续跑。我就因为这个特点,在后续处理请求异常时省了很大力气。

2.3 请求频率控制与断点续爬

很多爬虫新手容易忽略频率控制,恨不得一口气把12个月的数据在1秒内全请求完。这种操作风险很大,一方面会给目标站点服务器带来不必要的压力,另一方面也容易触发反爬机制,轻则请求被临时拦截,重则IP被封。

我的做法很简单:每次请求之间至少间隔0.8秒,再用随机数把间隔拉大到1.5秒左右。有人会觉得这很慢,但12次请求一共也就十几秒,完全在可接受范围内。

另一个关键点是断点续爬。我最初是把12个月的数据先存在一个列表里,全部抓完再统一写入CSV,结果跑到第8个月时因为网络波动中断了一次,前面7个月的数据全没了。后来学乖了,改成“每爬完一个月就追加写入一次CSV”,即使后面中断,也已经保留了前面的成果。这个习惯后来在爬取更大规模数据时帮了我大忙,强烈建议养成。

3. 解析页面的两个关键方法:定位数据块与批量封装

3.1 先确认数据藏在HTML还是XHR接口里

拿到页面之后,第一个动作不是写代码,而是打开浏览器开发者工具,用“检查”功能看目标数据到底是直接写在HTML源码里,还是通过XHR接口异步加载的。

我这个目标页面比较老实,打开源码就能看到完整的<table>标签,数据就躺在表格里,属于最基本的静态页面。这种情况用BeautifulSoup解析非常合适。

但也遇到过一些天气接口,页面表格是JavaScript异步渲染的,初始HTML里只有一个空壳,这时就得换思路,直接在开发者工具的“Network”面板里找到返回JSON的接口,用requests请求那个接口,再用json模块解析。这个判断很关键,因为它决定了后续解析方案是“字符串->BeautifulSoup”还是“JSON->dict”。

3.2 BeautifulSoup定位表格字段

确认是静态HTML后,我用BeautifulSoup解析表格。核心逻辑是定位到表格行<tr>,再遍历每一行里的单元格<td>。

from bs4 import BeautifulSoup def parse_month(html_text): soup = BeautifulSoup(html_text, 'html.parser') rows = soup.select('table tr') records = [] # 跳过表头行 for row in rows[1:]: cells = row.find_all('td') if len(cells) < 6: continue records.append({ 'date': cells[0].get_text(strip=True), 'max_temp': cells[1].get_text(strip=True), 'min_temp': cells[2].get_text(strip=True), 'weather': cells[3].get_text(strip=True), 'precipitation': cells[4].get_text(strip=True), 'humidity': cells[5].get_text(strip=True), }) return records

这里有个经验:尽量用get_text(strip=True)而不是get_text(),因为HTML源码里经常混着空格和换行,不清理的话后面数据类型转换时很容易出错。我一开始确实没注意,结果某个字段前面带了个空格,转成float时直接报了错。

3.3 把单月解析封装成全年度收集器

单月解析函数写好后,再套一个收集函数,把所有月份的结果汇在一起:

def fetch_page(url): headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/120.0 Safari/537.36' } resp = requests.get(url, headers=headers, timeout=10) resp.encoding = resp.apparent_encoding return resp.text

这里的headers就是给服务器看的“浏览器身份证”,如果不带,很多站点会直接返回403。而resp.apparent_encoding是让requests根据页面内容自动判断编码,避免中文乱码。至于encoding为什么不能直接用resp.encoding,我后面会单独讲。

收集函数里我加了循环重试逻辑:如果某个月请求失败,就等3秒重试,最多重试3次,还失败就把月份记录下来,最后统一人工处理,而不是整个程序崩溃退出。这个处理方式比直接抛异常实用得多。

4. 365条原始数据的清洗流水线

4.1 原始数据形态:单位、缺测和字符串

爬虫解析出来的数据还是字符串,而且带着各种历史遗留问题。我拿到的原始记录大概是这样的:

  • 气温字段:“35℃”“-3℃”,数字后面跟单位。
  • 降水:“5.5mm”“0mm”“-”,其中“-”代表无降水或缺测。
  • 湿度:“72%”。
  • 天气:“多云转晴”“小雨”“阴”。

这种情况下直接分析肯定不行,必须先把单位符号去掉,把“-”这类缺测标记统一成NaN,再用pandas把字段转成数值类型。

import pandas as pd def clean_temp(value): return value.replace('℃', '').strip() df = pd.DataFrame(all_records) df['max_temp'] = df['max_temp'].apply(clean_temp).astype(float) df['min_temp'] = df['min_temp'].apply(clean_temp).astype(float) df['precipitation'] = df['precipitation'].replace('-', pd.NA) df['precipitation'] = df['precipitation'].str.replace('mm', '').astype(float) df['humidity'] = df['humidity'].str.replace('%', '').astype(float)

注意,这里astype(float)之前必须保证字符串里没有奇奇怪怪的字符。我曾经在一天的记录里看到了一个非断行空格\xa0,直接把类型转换干崩了。遇到这种情况,可以用str.replace('\xa0', '')先清理。

4.2 pandas统一日期类型与索引

日期字段单独处理,我把它转成datetime类型,并设为索引。这一步非常关键,因为后面的按月聚合、按周聚合都要依赖时间索引。

df['date'] = pd.to_datetime(df['date']) df.set_index('date', inplace=True) df.sort_index(inplace=True)

排序也别忘了,爬虫解析出来的顺序未必是按日期排好的,尤其是分月解析后拼接在一起,可能因为页面结构问题出现乱序。sort_index之后,后面的所有分析才有意义。

4.3 数据缺口检查与补爬策略

全年365条记录,拼接完成后我会习惯性检查一下缺口。缺失值说白了就两种:一种是页面本身缺数据,一种是解析时因为HTML结构异常漏掉了。检查方法很简单:

print(df.isna().sum()) print(f"共 {len(df)} 天,预期 365 天,缺失 {365 - len(df)} 天")

如果缺的是降水量,可能只是当天没下雨,页面用“-”表示,这种可以保留为0或者NaN,取决于后续分析需求。如果缺的是一整天,那就得回去重新检查对应月份页面,看看是不是因为表格行数超出预期导致漏解析。我在清洗时发现1月少了3条,回去一查,原来是页面上有广告行混进了表格里,len(cells) < 6的判断拦住了其中一部分,但没完全拦住。后来加了更严格的判断:只有第一列能解析成有效日期的行才进入结果集。

4.4 落盘格式与元信息记录

清洗完之后我会把数据保存为CSV,文件名带上城市和年份,比如weather_2023_beijing.csv。文件内容除了数据本身,我还会在文件头用注释记录几条元信息:来源URL、抓取时间、字段说明、缺失情况。这样过几个月再回来看数据,不会一头雾水。

# Source: https://xxx.com/history/2023/*.html # Fetched: 2023-12-28 # Fields: date,max_temp,min_temp,weather,precipitation,humidity # Missing: precipitation=12 (separate), dates=0 date,max_temp,min_temp,weather,precipitation,humidity 2023-01-01,8,1,晴,0.0,51 2023-01-02,6,-2,多云,0.0,47 ...

后来我发现这个习惯意外地救了我一次:两个月后别人问我要这份数据,我凭着元信息里的URL和抓取时间,很快确认了数据的时效范围,不用重新核对。

5. 可视化里的四个图,把一年讲清楚

5.1 全年气温折线:极端情况一目了然

第一张图,我把全年最高温和最低温画成两条折线,中间的温差区域用填充色标出来。这张图最能直观回答“一年冷热怎么变化”的问题。

import matplotlib.pyplot as plt import matplotlib.dates as mdates plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei', 'PingFang SC'] plt.rcParams['axes.unicode_minus'] = False fig, ax = plt.subplots(figsize=(14, 5)) ax.plot(df.index, df['max_temp'], label='最高温', color='#d9534f', linewidth=1.2) ax.plot(df.index, df['min_temp'], label='最低温', color='#5bc0de', linewidth=1.2) ax.fill_between(df.index, df['min_temp'], df['max_temp'], color='#f0ad4e', alpha=0.2) ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m')) plt.xticks(rotation=45)

这里有个大多数新手都会踩的坑:日期作为x轴刻度时,如果放任不管,365个点会密密麻麻地挤成一团黑色墨迹。解决办法就是用MonthLocator让刻度只保留每月一个位置,再用DateFormatter把显示格式调成年-月。这个坑我印象很深,因为我第一次画出来的图基本上就是一条“黑色矩形”。

5.2 月度聚合柱线图:季节温差对比

全年折线看趋势,月度聚合图看统计。我把每个月的最高温均值、最低温均值算出来,用柱状图对比月份之间的冷暖,再叠加一条“温差”折线,看看哪几个月昼夜温差大。

monthly = df.resample('M').agg( avg_max=('max_temp', 'mean'), avg_min=('min_temp', 'mean'), temp_range=('max_temp', 'mean') - ('min_temp', 'mean') ) fig, ax = plt.subplots(figsize=(12, 5)) ax.bar(monthly.index, monthly['avg_max'], label='月均最高温', alpha=0.7, color='#f0ad4e') ax.bar(monthly.index, monthly['avg_min'], label='月均最低温', alpha=0.7, color='#5bc0de') ax.plot(monthly.index, monthly['temp_range'], label='昼夜温差', color='#d9534f', marker='o', linewidth=2)

从这张图里能明显看出,冬天虽然冷,但晴天多、昼夜温差反而大;夏天温度高、阴雨天多,温差被压得很小。这种结论从原始表格里很难一眼看到,画成图之后就很直观。

5.3 气温-降水散点图:发现“湿冷”和“干热”特征

第三张图我做了个交叉分析,用散点图看气温和降水量之间的关系。x轴是最高温,y轴是降水量,每个点代表一天。

fig, ax = plt.subplots(figsize=(8, 6)) scatter = ax.scatter(df['max_temp'], df['precipitation'], c=df['humidity'], cmap='coolwarm', s=30, alpha=0.7) plt.colorbar(scatter, label='湿度') ax.set_xlabel('最高温 (°C)') ax.set_ylabel('降水量 (mm)')

颜色映射湿度,这样每个点同时携带了三个维度的信息:位置是温度和降水,颜色是湿度。分析下来会发现,气温高过35℃的日子几乎没有降水,而降水比较多的日子气温集中在22℃到30℃之间。这是典型的大陆性季风气候特征。画这种图的意义就在于,用颜色和位置把维度叠加,能发现单看表格发现不了的模式。

5.4 日历热力图:365天压缩进一张图

最后一张图我最满意,是把365天压缩成一个 52周×7天 的网格热力图。每一格代表一天,横轴是星期,纵轴是周数,颜色代表当天最高温。

import numpy as np weeks = df['max_temp'].groupby( [df.index.isocalendar().year, df.index.isocalendar().week] ).mean()

因为isocalendar()会把跨年周算到新年那侧,我在实际绘制时需要稍微处理一下周数,但思路很简单:建立一个52 x 7的全NaN数组,把每一天按(week, weekday)填进对应位置,再用pcolor或imshow画出来。热力图输出之后,整个年份的温度变化呈条纹状展现在一张图里,夏天的深红块、冬天的深蓝块、春秋的过渡横条,信息密度非常高。

用热力图还有一个好处:可以从纵向上比较同一周每天的温差波动,比如某周周一到周日连续升温,视觉上就是一圈从蓝到红的渐变。这种图做出来,给朋友看的时候他们都说“原来一年的天气长这样”。

6. 全流程跑完后的几个坑和一点经验

6.1 爬完先别清内存:保留原始HTML样本

我吃过一个亏:解析完把resp.text丢掉了,等到清洗时发现某天的字段值异常,想回头检查原始页面结构,但已经来不及,只能重新请求一次。可是天气历史页面偶尔会局部改版,重请求回来的HTML结构可能跟之前不完全一样,排查异常非常被动。

所以现在我的习惯是:每个月解析成功后,把原始HTML存一份到本地html_samples/目录里。12个文件占不了多大空间,但对后面的问题回溯特别有用。尤其是当你需要向别人复现“为什么这一天的数据长这样”时,原始HTML就是最直接的证据。

6.2 中文乱码问题与encoding处理

这次我还遇到了中文乱码。问题出在requests的resp.encoding有时候会从响应头里拿到错误编码,比如某些页面明明内容是UTF-8,响应头却写的charset=gb2312,直接按响应头解码就会出现“涔洪洸”这种乱码。

我的解决办法是优先使用resp.apparent_encoding,因为它是基于页面内容本身的字符分布自动判断的,准确率更高。但要注意,apparent_encoding也不是100%可靠,如果发现乱码,可以把可能的几种编码都试一遍:

for enc in ['utf-8', 'gbk', 'gb2312', 'big5']: try: text = resp.content.decode(enc) break except UnicodeDecodeError: continue

这个兜底方案在我之前的其他爬虫项目里也沿用过,碰到的乱码问题基本都能解决。

6.3 站点页面结构改版后如何快速定位

我写这个爬虫的时候目标站点还没有复杂反爬,不需要登录、不需要处理验证码,只是加了个简简单单的请求头。但这不代表每半年后还能跑通,因为天气站点的页面结构会改版。如果解析函数像“拔河”一样依赖特定CSS类名,一旦类名变了,整个解析就会失效。

应对方法有两个:

  1. 解析时优先选稳定的父节点,比如table标签本身,而不是某个充满随机字符的class。
  2. 遇到解析结果为空时,第一时间去开发者工具里重新看页面结构,把旧的HTML样本拿出来对比差异,快速定位是标签层级变了还是字段位置倒了。

我在后期测试中发现,目标页面只是把表格从class="history-table"改成了class="table table-striped",但table tr td的层级没变,所以解析函数完全没受影响。这就是选择稳定结构的好处。

6.4 合规边界:robots、频率与个人用途

最后说一个容易被忽略但很重要的问题:合规边界。我在做这个项目时严格遵守几条原则:只爬取公开可访问的页面,不涉及登录后的数据;请求频率控制在合理范围,不给对方服务器造成压力;数据仅用于个人学习和非商业分析,不对外发布原始数据集。

如果你也要做类似的事情,建议先看一下目标站点的robots.txt,了解哪些路径是不允许抓取的。虽然一个个人小爬虫在实际操作中不至于引发严重问题,但养成这个习惯,对以后做更大规模的数据项目非常有好处。我不建议以绕过验证码、突破频率限制为前提去设计爬虫,那样既不稳定也没有必要。

这次跑完整个流程,我最大的感受是“爬虫只占三分之一的工作量”。真正花时间的地方在数据清洗的细节,比如那些带单位的字符串、混进去的HTML实体、跨年周的分组边界,还有中文字体配置。如果你也想复现这套流程,建议从自己所在的城市和最近一个完整年份开始,用12次请求拿到数据后,先画一张日历热力图出来看看,那个成片感是普通报表给不了的。

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

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

立即咨询