Python爬虫抓取全年天气数据:从数据清洗到可视化分析实战
发布时间:2026/10/1 23:00:43来源:尧图网络
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 判断天气历史数据源的核心标准爬虫第一步不是写代码而是先挑数据源。我判断一个天气历史页面能不能用主要看三件事URL是否有规律可循。最好能通过年份、月份构造出不同页面的地址这样12个月就是12个URL循环一下就完事。如果只能靠点击翻页那还得处理动态加载麻烦很多。字段是否完整。我需要的字段至少包括日期、最高温、最低温、天气现象、降水量、湿度和风力方向。有的页面只给最高最低和天气没有降水量那就没法做后面的降水和气温关系分析。数据更新是否及时且稳定。历史天气数据的意义在于“全年完整”如果目标站点经常出现某几天数据缺失或者页面结构三天两头改版那爬取成本会成倍增加。我当时选择了一个公开的天气历史数据页面做主要来源它的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 fhttps://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(stripTrue), max_temp: cells[1].get_text(stripTrue), min_temp: cells[2].get_text(stripTrue), weather: cells[3].get_text(stripTrue), precipitation: cells[4].get_text(stripTrue), humidity: cells[5].get_text(stripTrue), }) return records这里有个经验尽量用get_text(stripTrue)而不是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, headersheaders, timeout10) 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, inplaceTrue) df.sort_index(inplaceTrue)排序也别忘了爬虫解析出来的顺序未必是按日期排好的尤其是分月解析后拼接在一起可能因为页面结构问题出现乱序。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: precipitation12 (separate), dates0 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, linewidth1.2) ax.plot(df.index, df[min_temp], label最低温, color#5bc0de, linewidth1.2) ax.fill_between(df.index, df[min_temp], df[max_temp], color#f0ad4e, alpha0.2) ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.xaxis.set_major_formatter(mdates.DateFormatter(%Y-%m)) plt.xticks(rotation45)这里有个大多数新手都会踩的坑日期作为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月均最高温, alpha0.7, color#f0ad4e) ax.bar(monthly.index, monthly[avg_min], label月均最低温, alpha0.7, color#5bc0de) ax.plot(monthly.index, monthly[temp_range], label昼夜温差, color#d9534f, markero, linewidth2)从这张图里能明显看出冬天虽然冷但晴天多、昼夜温差反而大夏天温度高、阴雨天多温差被压得很小。这种结论从原始表格里很难一眼看到画成图之后就很直观。5.3 气温-降水散点图发现“湿冷”和“干热”特征第三张图我做了个交叉分析用散点图看气温和降水量之间的关系。x轴是最高温y轴是降水量每个点代表一天。fig, ax plt.subplots(figsize(8, 6)) scatter ax.scatter(df[max_temp], df[precipitation], cdf[humidity], cmapcoolwarm, s30, alpha0.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响应头却写的charsetgb2312直接按响应头解码就会出现“涔洪洸”这种乱码。我的解决办法是优先使用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类名一旦类名变了整个解析就会失效。应对方法有两个解析时优先选稳定的父节点比如table标签本身而不是某个充满随机字符的class。遇到解析结果为空时第一时间去开发者工具里重新看页面结构把旧的HTML样本拿出来对比差异快速定位是标签层级变了还是字段位置倒了。我在后期测试中发现目标页面只是把表格从classhistory-table改成了classtable table-striped但table tr td的层级没变所以解析函数完全没受影响。这就是选择稳定结构的好处。6.4 合规边界robots、频率与个人用途最后说一个容易被忽略但很重要的问题合规边界。我在做这个项目时严格遵守几条原则只爬取公开可访问的页面不涉及登录后的数据请求频率控制在合理范围不给对方服务器造成压力数据仅用于个人学习和非商业分析不对外发布原始数据集。如果你也要做类似的事情建议先看一下目标站点的robots.txt了解哪些路径是不允许抓取的。虽然一个个人小爬虫在实际操作中不至于引发严重问题但养成这个习惯对以后做更大规模的数据项目非常有好处。我不建议以绕过验证码、突破频率限制为前提去设计爬虫那样既不稳定也没有必要。这次跑完整个流程我最大的感受是“爬虫只占三分之一的工作量”。真正花时间的地方在数据清洗的细节比如那些带单位的字符串、混进去的HTML实体、跨年周的分组边界还有中文字体配置。如果你也想复现这套流程建议从自己所在的城市和最近一个完整年份开始用12次请求拿到数据后先画一张日历热力图出来看看那个成片感是普通报表给不了的。
网站建设高端定制企业官网