1. 数据源的选型逻辑:公开API、网页抓取和历史库的取舍分析
先说结论:想做"北京最近10年全年天气变化曲线",最核心的问题其实不是画图,而是数据从哪来。很多人在这一步就卡住了,然后去翻了十二个网站、复制粘贴了三千行数据,最后画出来的曲线还缺牙少齿。我一开始也走过弯路,所以把数据源选型放在最前面讲。
市面上能拿到历史天气数据的途径大致有三类:
第一类:通过各类天气平台的公开页面直接抓取。好处是无需注册、无需密钥,数据可视化程度高、字段直观;坏处是页面结构经常改,反爬手段不一,数据密度和年份覆盖不一定满足10年的需求。比如有的站点只保留近3年数据,或者只提供逐月汇总,拉不到逐日数据,这就会严重限制我们对"全年天气变化曲线"的还原精度。
第二类:调用专业气象数据API。好处是数据结构化好、字段齐全、更新稳定,坏处是大部分都需要企业认证或付费,个人开发者签下免费额度的门槛不低。对于一些想快速出图、不想花太多时间在反爬上的朋友来说,这是最省心的选择,但如果你是第一次尝试,注册、鉴权、请求配额这些环节反而可能比写爬虫本身更浪费时间。
第三类:使用开源历史气象数据集。比如一些数据社区整理好的全球站点逐日数据,好处是下载即用、不需要考虑断线和反爬,坏处是数据更新滞后,而且对于"北京"这种站点,不同数据集的粒度、单位、时区口径可能不一致,处理起来需要额外校验。
我在实际项目中采用的是**"以公开网页抓取为主、以历史气象数据平台的格式要求为辅"**的组合方案。理由也很朴素:
- 这张曲线的核心是趋势呈现,需要的是连续10年的逐日数据,而不是某一天的精确温度,所以网页端的显示精度完全够用;
- 公开网页的数据字段相对稳定,常规字段(日期、最高温、最低温、天气现象、风向风力)都能拿到,足够做完整的年度曲线分析;
- 不依赖第三方密钥,整个流程可复现,换一个城市也能直接改参数跑通。
选型时还有一个关键考量:数据频率。做"全年天气变化曲线",最低要求是逐日数据,最佳状态是能拿到逐小时数据。逐小时数据量更大,但能支撑更细的分析,比如昼夜温差曲线、极端天气过程的温度演变。如果只想画"全年气温走势",逐日数据就够了。我在实践里选择的是逐日最高温/最低温/天气现象三件套,理由是这个粒度既能画清趋势,又不需要处理太多异常值。
提示:动手之前先估算数据规模。10年 × 365天 ≈ 3650条记录,这对Excel、pandas这些常规工具来说是小菜一碟,但千万不要用"每一年手动复制"的方式去收集,除非你享受重复劳动。
2. 抓取与存储实现:从HTTP请求到结构化数据的完整链路
数据源定下来之后,接下来就是实际抓取了。这一节我会把从URL分析、请求发送到数据落地的完整流程拆开讲。以我抓取时的目标站点为例,页面结构大概长这样:一个按月份展示的表格,每一天对应一行,列包含日期、最高温、最低温、天气、风向风力。
2.1 URL结构与请求参数的分析
网页端的天气数据基本都是通过URL中的查询参数来控制城市和日期的,形如:
https://xxx.com/weather/history/54511/202401.html其中54511是北京站点的区站号,202401代表2024年1月。北京这个站点的区站号是固定的,所以我只需要构造从2014年到2023年的所有月份链接即可,一年12个月,10年共120个页面。
分析URL结构这一步是整个抓取流程里最值得花时间的地方。很多网页的URL变化是有规律的,而规律往往藏在浏览器开发者工具的Network面板里。你只需要在页面上手动切换一个月,观察地址栏的变化,就能推断出URL的构造规则。如果能用这种方式拿到数据,就不要去碰那些加密参数复杂、带有动态token的接口。
我的构造逻辑用Python写出来就是这样:
import requests city_code = "54511" years = range(2014, 2024) months = range(1, 13) def build_url(year, month): return f"https://xxx.com/weather/history/{city_code}/{year}{month:02d}.html"2.2 请求头与访问频率的控制
页面虽说是公开数据,但爬取时也要有基本的克制。我习惯在请求中携带一个常规的User-Agent,并且把两次请求之间的间隔控制在不小于1秒,避免给目标服务器造成压力。
import time headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def fetch_html(url): resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp.textresp.apparent_encoding这一步容易被忽略,但很重要。网页声明的charset和实际编码有时不一致,直接用resp.text可能出现乱码。用apparent_encoding可以根据内容自动推断编码,实测下来对中文站点的兼容性更好。
2.3 表格解析与字段清洗
拿到HTML之后,用BeautifulSoup定位表格数据。我的做法是先找到所有tr标签,再从每个tr里提取td。但这里有个坑:页面上方可能还有"月累计平均"之类的汇总行,这些行会混在数据里,需要依据行内容的特征做过滤。
from bs4 import BeautifulSoup import pandas as pd def parse_page(html): soup = BeautifulSoup(html, "html.parser") trs = soup.select("table tr") rows = [] for tr in trs: cells = tr.find_all("td") if len(cells) < 4: continue date = cells[0].get_text().strip() if "月" in date or "年" in date: continue high = cells[1].get_text().strip().replace("℃", "") low = cells[2].get_text().strip().replace("℃", "") weather = cells[3].get_text().strip() rows.append({"date": date, "high": high, "low": low, "weather": weather}) return rows这里我做了两个过滤:第一,跳过列数不足的干扰行;第二,跳过含"月""年"的汇总行。如果你只想要数值,而不想要天气现象文字,完全可以删掉weather字段。但建议保留,因为后续做"极端天气分布"这类分析时,weather字段是很有价值的辅助信息。
清洗温度字段时,注意原网页里温度后面可能带了℃或空格,需要统一替换掉。还有一个高频坑:个别单元格内容可能是--或空值,代表当天数据缺失。遇到这种情况不要直接删行,更不要让pandas自动推断成NaN了事,我建议保留原始字符串,并在后续统一填充。
2.4 数据落地与增量保存
数据逐月解析完成后,我习惯先追加写入CSV而不是等全部跑完再一次性导出。原因很简单:万一中间断网或程序异常,至少前面的数据保住了,不需要从头再来。
import csv import os csv_path = "beijing_weather.csv" write_header = not os.path.exists(csv_path) with open(csv_path, "a", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["year", "month", "day", "high", "low", "weather"]) if write_header: writer.writeheader() for row in all_rows: writer.writerow(row)几个细节说明:
- 编码使用
utf-8-sig,这样Excel直接打开CSV不会出现中文乱码; - 每次请求完一个页面,先解析、再追加写入,避免把所有数据攒在内存里最后一起写;
- 如果程序中断重启,可以读取CSV里已有的日期集合,跳过已经抓过的月份,实现断点续抓。
断点续抓的代码逻辑很简单:
def already_fetched(): if not os.path.exists(csv_path): return set() df = pd.read_csv(csv_path, usecols=["year", "month"]) return set(zip(df["year"], df["month"]))这样一年重建一次数据,无论中途怎么中断,只需要重新运行脚本就能自动补齐缺失月份。
3. 数据清洗与时间对齐:那些坑不会写在文档里
抓下来的数据直接拿去画图,结果大概率是灾难。原因有两类:一是原始数据里有脏值,二是日期字段的格式和year/month/day没有对齐。这一节专门讲清洗链路。
3.1 日期字段的归一化处理
我在解析页面时,将日期拆成了year、month、day三个字段。但有时原网页显示的日期是2024-01-05,有时是1月5日,格式五花八门。所以清洗的第一步,是把这些杂乱的字符串统一转化为标准日期类型。
import pandas as pd df = pd.read_csv("beijing_weather.csv") df["date"] = pd.to_datetime(df[["year", "month", "day"]]) df = df.set_index("date").sort_index()pd.to_datetime能处理多种日期格式,但它也会在遇到非法日期时抛出异常或产生NaT。所以转换之后一定要检查:
print(df[df.index.isna()])如果有NaT,基本上就是原始数据里有类似2月30日这样的脏值。这种问题最常出现在2月,需要你用下面的逻辑直接修正:
df = df[(df.index.month == 2) & (df.index.day <= 29) | (df.index.month != 2)]不过说实话,公开天气页面里基本不会出现2月30日,更常见的是日期完全缺失。此时我建议对缺失的日期行做前向填充或直接剔除,具体看后续分析需求。画年趋势图时,剔除个别缺失值影响不大;但如果要做逐日对比,最好用插值补全。
3.2 温度异常值的过滤逻辑
温度字段常见的脏值有这几类:
- 原始字符串残留了
℃、空格、换行等; - 个别温度异常偏高或偏低,比如夏天出现
-12℃; - 最高温低于最低温的逻辑错误。
清洗时,我先把温度列转成数值类型,再针对每一条记录做一次逻辑判断:
df["high"] = pd.to_numeric(df["high"], errors="coerce") df["low"] = pd.to_numeric(df["low"], errors="coerce") # 1. 删除温度缺失的行 df = df.dropna(subset=["high", "low"]) # 2. 删除高低温倒挂的行 df = df[df["high"] >= df["low"]] # 3. 删除超出合理范围的行(北京近10年极端高温不超45,极端低温不低于零下30) df = df[(df["high"] < 45) & (df["low"] > -30)]这里的阈值范围是我根据北京气候特征设置的,如果你换城市,可以查一下当地历史极端温度再调整。这套过滤逻辑不会误删正常数据,但能把那些明显影响曲线的异常点清理掉。
3.3 缺测日期的补全策略
3650条记录里,缺个两三天是很正常的。现在分析之前,需要先确认日期是否连续。
full_index = pd.date_range(start="2014-01-01", end="2023-12-31", freq="D") missing_dates = full_index.difference(df.index) print(missing_dates)如果缺的日期不多,我的习惯是用前后两天的均值做线性插值:
df = df.reindex(full_index) df["high"] = df["high"].interpolate() df["low"] = df["low"].interpolate()注意,reindex之后缺失的日期对应的weather字段也会变成NaN,如果你后续要做天气类型统计,需要单独处理,比如用前一天的天气填充。温度插值本身没问题,但天气类型不适合插值,因为"晴"和"雨"中间没有任何过渡状态。
3.4 数据结构落地统一
清洗完毕之后,把数据按照统一的格式整理好,方便后续可视化、统计和导出。我最终保存的字段结构如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| date | datetime64 | 日期,索引字段 |
| year | int | 年份 |
| month | int | 月份 |
| day | int | 日 |
| high | float | 当日最高气温(℃) |
| low | float | 当日最低气温(℃) |
| weather | string | 天气现象文本 |
这个结构很简单,但足够支撑后面90%的分析任务。
4. 全年曲线可视化:从单年折线到10年趋势叠加
数据干净之后,画图就是水到渠成的事。我用的是Matplotlib,核心目标是画出三种图:单年温度曲线、10年趋势叠加曲线、年度平均气温对比柱状图。这些图合在一起,就是一套完整的"北京近10年天气变化曲线"。
4.1 单年温度曲线:基础折线图的踩坑与调优
先拿2014年做个demo。画出这一年的最高温和最低温曲线:
import matplotlib.pyplot as plt import matplotlib.dates as mdates plt.rcParams["font.sans-serif"] = ["SimHei"] plt.rcParams["axes.unicode_minus"] = False df_2014 = df[df["year"] == 2014] fig, ax = plt.subplots(figsize=(14, 6)) ax.plot(df_2014.index, df_2014["high"], color="#d9534f", label="最高温") ax.plot(df_2014.index, df_2014["low"], color="#5bc0de", label="最低温") ax.fill_between(df_2014.index, df_2014["low"], df_2014["high"], color="grey", alpha=0.2) ax.set_title("北京2014年气温变化曲线") ax.set_xlabel("日期") ax.set_ylabel("气温(℃)") ax.legend() ax.grid(alpha=0.3) plt.tight_layout() plt.savefig("beijing_2014_temp.png", dpi=150) plt.show()这里有两个细节很容易被新手忽略:
第一是字体设置。Matplotlib默认字体不支持中文,如果不设置font.sans-serif,图表里的"最高温""最低温"都会变成方块。设置成SimHei之后,中文标题和图例才能正常显示。如果你用的是macOS/Linux,SimHei不一定有,可以换成PingFang SC或Noto Sans CJK SC。
第二是fill_between。我在最高温和最低温之间填充了半透明灰色,这样能更直观地看到温差区间。实测下来,这种画法比两条曲线孤零零地放那里信息量大得多,读者一眼就能看出"夏天温差小、冬天温差大"之类的规律。
4.2 10年数据叠加:突出周期性与极端年份
单年曲线看不出长期趋势,所以我把10年数据叠加到同一张图上,用不同透明度区分年份:
fig, ax = plt.subplots(figsize=(16, 8)) for year in range(2014, 2024): yearly_data = df[df["year"] == year] ax.plot(yearly_data.index.dayofyear, yearly_data["high"], color="blue", alpha=0.3, linewidth=0.8) ax.set_title("北京2014-2023年逐日最高温叠加曲线") ax.set_xlabel("年内第几天") ax.set_ylabel("气温(℃)") ax.grid(alpha=0.3)用dayofyear作为x轴,是为了让不同年份的数据对齐在同一个时间坐标系里。这样每一列竖着看,能直接对比同一天在十年内的温度波动范围;横向看,则能观察到每年温度曲线的整体相位是否一致。
叠加图最大的价值在于观察异常年份。如果哪一年的曲线整体偏离了其他年份的包络带,这一年基本可以确定是冷暖异常年份。比如2018年前后有些天明显高于其他年份,这就是一个值得深挖的信号。
4.3 月度/年度聚合曲线:10年趋势的宏观规律
再往前一步,我们按月份聚合统计10年内的月均最高温:
monthly_mean = df.groupby(["year", "month"])["high"].mean().unstack(0) monthly_mean = df.groupby(["year", "month"])["high"].mean().reset_index() pivot = monthly_mean.pivot(index="month", columns="year", values="high") fig, ax = plt.subplots(figsize=(14, 6)) for col in pivot.columns: ax.plot(pivot.index, pivot[col], marker="o", markersize=4, linewidth=1.2, label=str(col)) ax.set_title("北京2014-2023年月均最高温变化") ax.set_xticks(range(1, 13)) ax.set_xticklabels(["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"]) ax.set_xlabel("月份") ax.set_ylabel("月均最高温(℃)") ax.legend(ncol=5, fontsize=8) ax.grid(alpha=0.3) plt.tight_layout() plt.savefig("beijing_monthly_avg.png", dpi=150) plt.show()这条曲线对应的表格数据也很有价值,可以直接透视出"哪年最热""哪年最冷""秋季降温速度快慢"这些信息。我通常会把月均最高温、最低温、平均温差这几个统计量再合并成一张汇总表导出。比如10年逐月平均最高温表格可以帮助一眼定位温度峰值出现的月份,十年间该月均温的变化幅度也可以直接读取。
4.4 画图时的三个隐藏优化点
坐标轴范围:如果只是想看曲线趋势,可以让Matplotlib自动选择y轴范围。但如果要做多图对比,务必要手动固定
ax.set_ylim(-20, 40)这种范围,否则不同图之间的视觉比例不一致,读者会产生误判(比如同一温度在不同图里看起来差异很大)。图表尺寸:默认的
figsize=(6,4)画出来很难看清,建议宽度14以上。上传到博客或社区时,够宽的图不会被压缩得看不清密度变化。保存dpi:用
savefig(..., dpi=150)保存,既不会让文件体积过大,又能在网页端清晰展示。如果需要打印导出,可以用300dpi。
5. 多维度分析与绘制进阶:只画一条线是不够的
纯画一条温度曲线只能回答"热了还是冷了",但要做出真正有价值的内容,我们还得深入分析极端天气、风力和温差这些维度。这一节聊聊进阶分析的几个方向。
5.1 天气现象的频率统计
从数据里统计"晴天、多云、小雨、大雨、雪"等天气出现的天数,按年度汇总。这个操作在pandas里一行groupby就能搞定,但实际做出来的图表非常能说明宏观气候特征。
weather_counts = df.groupby([df.index.year, "weather"]).size().unstack(fill_value=0)需要注意,不同站点对天气现象的叫法可能不统一。比如有的页面写"多云转晴",有的写"多云"。这种情况下,我建议先做一次归类映射,把相近的天气现象合并成大类:
weather_map = { "晴": "晴", "多云": "多云", "阴": "阴", "小雨": "雨", "阵雨": "雨", "大雨": "雨", "雷阵雨": "雨", "小雪": "雪", "中雪": "雪", "大雪": "雪", } df["weather_type"] = df["weather"].map(weather_map).fillna("其他")做完这一步,再按年统计各类天气天数,就能画出一张堆叠柱状图,直观展示"北京的雨雪分布在不同年份的波动情况"。
5.2 温差曲线的价值
单纯看最高温、最低温还远远不够。在气候分析里,昼夜温差(日较差)是衡量大陆性气候特征和人体舒适度的关键指标。所以我习惯再算一列温差:
df["temp_range"] = df["high"] - df["low"]然后按月份聚合,观察十年里每个月的平均温差走势。北京春季(3到5月)和秋季(9到11月)的温差通常比较大,夏季小一些,冬季受冷空气影响温差也不小。这个曲线放到文章里,比"北京四季分明"这种描述有说服力得多。
有个细节要注意:直接用平均最高温减去平均最低温,和逐日温差取平均值,结果并不相等。后者才是真正的"平均昼夜温差"。为了严谨,我们应该先算逐日温差,再按月份求平均。
5.3 年度平均气温与线性趋势
最后一个进阶方向:用年度平均气温配一条线性回归趋势线,量化升温/降温的速率。这里不需要复杂的统计库,用numpy.polyfit就够了:
import numpy as np yearly_mean = df.groupby(df.index.year)["temp_mean"].mean() coeffs = np.polyfit(yearly_mean.index, yearly_mean.values, 1) trend_line = np.polyval(coeffs, yearly_mean.index)其中temp_mean是每天最高温和最低温的平均值。拟合出来的斜率就是"每年温度变化的度数"。如果斜率是正数,说明这10年整体在升温;如果是负数,说明偏冷。不过要注意,10年序列的线性趋势受个别极端年份影响大,结论只能作为参考,不能直接拿去当气候变化的证据。
5.4 多图组合输出建议
如果你要写一篇完整的数据分析博客或季度复盘,可以把上面几张图组合成一张大图:
fig = plt.figure(figsize=(16, 12)) # 子图1:逐日最高温最低温包络带 # 子图2:月度平均温度对比曲线 # 子图3:天气现象堆叠柱状图 # 子图4:年度平均气温加趋势线 plt.tight_layout() plt.savefig("beijing_10y_weather_report.png", dpi=200) plt.show()这样一张总览图放进报告里,信息密度极高,读者不需要滚动多次就能看懂"十年里北京天气是怎么变的"。
6. 常见异常与处理对策:跑数据时最容易翻车的四个瞬间
最后聊聊实际操作中出现过的异常情况,这些话平常教程里不会明确写,但能帮你少走弯路。
第一个坑:请求并发过高导致IP被临时限制。我一开始用异步并发抓取120个页面,速度很快,但跑了30多个页面之后,目标站点开始返回403。解决办法很简单:改回同步请求,每次间隔1到2秒。120个页面最多3分钟就能跑完,完全没必要为了省这半分钟把IP搭进去。
第二个坑:页面结构微调导致解析不到数据。天气平台偶尔会调整表格的CSS类名或列顺序。不要写死某一个CSS类名,而是先打印出tr的总数和前几行内容,人工确认一下再批量解析。脚本里加一个if len(rows) == 0: print("可能结构变了")的断言,能省很多排查时间。
第三个坑:CSV追加写入时表头重复。这个很经典。脚本第一次运行写入了表头,中断后第二次运行时write_header又变成了True,导致CSV里有多个表头行。我在代码里用os.path.exists来判断,但如果CSV已经存在但内容为空,这个判断也会跳过表头写入,导致后续pd.read_csv把第一行数据当列名。稳妥的做法是运行前手动检查CSV文件内容,或者用os.path.getsize(csv_path) > 0来做判断。
第四个坑:Excel打开CSV中文乱码。这个问题在Windows平台尤其常见。解决办法是写入时用utf-8-sig编码,而不是普通的utf-8。如果你已经用错误编码存了很多数据,可以直接用Python读一遍再重新写一遍。
df = pd.read_csv("beijing_weather.csv", encoding="utf-8") df.to_csv("beijing_weather_fixed.csv", index=False, encoding="utf-8-sig")这四个问题基本覆盖了从抓取到存储再到展示的绝大部分异常场景。整个项目跑下来,我认为最值得沉淀的经验就是:天气数据分析不是一个"调个API画条线"的简单任务,真正的耗时点全在数据清洗和异常处理上,但只要把流程拆成"选源-抓取-清洗-可视化-分析"五个环节,每一步保持数据的可追溯性,整个项目就会非常顺畅。
如果你需要换城市跑同一套代码,只需要改区站号和年份范围,其余流程完全一样。这个项目后续还可以扩展成自动化的月度更新脚本,配合定时任务,就能持续积累新的天气数据,打造一个长期追踪的气候分析工具。