去年冬天做年终复盘的时候,我突然想看看过去一年自己到底经历了什么样的天气起伏:最热是哪一天、最冷是哪一天、一年里到底下了多少场雨。翻了几个天气App,发现它们要么只给最近15天的数据,要么就只开放当月的统计,想要一整年逐日的历史天气记录,不是走收费API,就是得自己动手。于是干脆,我写了个python爬虫,从公开的天气数据接口按月抓取,把全年每一天的最高温、最低温、天气现象、风向风力全部存成结构化表格,再用pyecharts做可视化分析。
这篇文章就是那次完整过程的复盘,覆盖了接口探查、爬虫实现、数据清洗、图表输出这一整套流程。适合刚学完Python基础、想找一个真实项目练手爬虫和数据分析的同学,也适合做气候分析、活动策划、农业种植规划这类需要自主获取历史天气数据的朋友。全程用到的工具都是免费的,代码量不大,但踩坑点不少,我会把每个环节的取舍和教训一起写清楚。
1. 项目缘起:为什么非要自己抓全年天气数据
先说需求。我当时想回答的问题其实很简单:这一年里,气温最高的一天是哪天?温度最低的一天是哪天?每个月下雨的天数有多少?夏天的极端高温通常出现在什么时间?这些问题的答案散布在天气App和历史数据网站里,但没有任何一个现成的渠道能直接导出一张“2024年全年逐日天气明细表”。
1.1 需求分析:一张表解决所有疑问
我的核心诉求可以拆成两条硬约束:
- 时间维度:必须是2024年1月1日到12月31日,一天都不缺,连续366条记录(2024年是闰年)。
- 字段维度:每天至少包含日期、最高气温、最低气温、天气现象、风向、风力这6个字段,后面做分析时才够用。
如果只需要某几天的数据,手动复制粘贴也能凑合。但一旦跨度到全年,手工方案就完全不现实了。天气网站的页面是分月展示的,每个月一个页面,一年12个页面,手动复制366条记录大概率会漏数据、抄串行,而且后续还要反复整理格式。写爬虫的真正价值不是“抓数据”这个动作本身,而是把采集、整理、存储、分析这条流水线自动化,一劳永逸。
1.2 技术选型逻辑:requests + pandas + pyecharts
技术选型上我几乎没有犹豫,Python在这个领域生态太成熟了:
requests:发HTTP请求拿接口数据,简单稳定,比用浏览器自动化工具轻量得多。BeautifulSoup+lxml:解析接口返回的HTML片段,从表格标签里提取每日天气。pandas:把列表数据转成DataFrame,做字符串清洗、类型转换、聚合统计非常顺手。pyecharts:生成交互式HTML图表,折线图、柱状图、日历热力图都支持,而且是中文文档,对国内开发者友好。csv:轻量存储,366条记录存CSV完全够用,Excel也能直接打开。如果数据量大或者要做增量更新,再升级到SQLite。
这套组合的核心优势在于:每一层的代码量都很小,但每一层的坑也都藏得很深。我建议你在动手前先花半个小时想清楚自己要什么,否则很容易陷入“抓到一堆数据但不知道怎么清洗”的尴尬。
2. 摸清数据源:从浏览器开发者工具找出隐藏接口
爬虫的第一步不是写代码,而是搞清楚数据到底在哪里。
我当时选定的是2345天气网的历史天气页面。它有一个好处:页面在用户切换月份时并不是整页刷新,而是通过一个隐藏的JSON接口拿数据,再由前端JS渲染成表格。这种接口通常返回得又快又规整,比去解析整张HTML页面干净得多。
2.1 打开F12,一步一步找到真实请求
操作步骤其实很常规,但非常关键:
- 打开2345天气网的历史天气页面,按F12进入开发者工具,切到Network(网络)标签页。
- 在页面上切换年份或月份,你会看到网络请求列表里多出一些XHR请求。
- 逐个点开这些请求,查看Preview或Response标签,找到返回内容是“表格HTML字符串”的那一个。
- 右键这个请求,选择Copy → Copy as cURL,可以快速看到完整的请求URL、参数和请求头。
我当时找到的接口地址大致长这样:
https://tianqi.2345.com/Pc/GetHistory?areaInfo[areaId]=54511&areaInfo[areaType]=2&date[year]=2024&date[month]=1参数拆解一下:
areaInfo[areaId]:站点编号,代表具体城市,北京是54511。areaInfo[areaType]:站点类型,固定为2即可。date[year]:年份。date[month]:月份,1到12。
这个接口返回的是一个JSON对象,里面有个data字段,存放着整个月天气表格的HTML字符串。所以爬虫逻辑很清晰:循环12个月,请求12次接口,解析12段HTML,拼接成一张全年数据表。
2.2 城市ID和请求参数解读
城市ID是最容易卡住新手的地方。很多人以为ID是网页URL里的那串数字,实际上不同网站的编码规则完全不同。2345天气用的其实是气象站区站号,比如:
| 城市 | 站号 |
|---|---|
| 北京 | 54511 |
| 上海 | 58362 |
| 广州 | 59287 |
| 深圳 | 59493 |
| 杭州 | 58457 |
| 成都 | 56294 |
如果你要查自己所在的城市ID,最快的办法是在历史天气页面搜索城市,然后把网络请求里的areaInfo[areaId]参数复制出来。这个ID在同一个网站上跨页面是通用的,存下来以后其他爬虫项目也能复用。
2.3 合规性判断与抓取节奏控制
写爬虫之前我习惯先看一眼目标站点的robots.txt和用户协议,确认公开数据接口是可用的。2345天气网站作为一个面向公众的天气信息平台,基本历史天气数据属于公开信息,但公开不代表可以野蛮抓取。
我给自己定的规矩很简单:
- 每次请求之间至少间隔1到2秒,用
time.sleep(random.uniform(1, 2))模拟人工操作节奏。 - 全年12个月的数据,一次抓完就停,不做循环压测。
User-Agent设置为真实浏览器版本,避免被服务器按默认爬虫UA直接拒绝。- 设置合理的请求超时时间,比如10秒,避免网络抖动时请求无限挂起。
这套节奏下来,整个抓取过程不到30秒就结束了,对目标站点几乎没有压力。
3. 爬虫主体代码:按月循环抓取,攒出全年数据表
确认了接口地址和参数规则之后,剩下的就是把逻辑翻译成代码。
3.1 单月请求与HTML解析
先写一个单月请求的封装,把网络请求和JSON解析放在一起,方便主循环调用:
import requests import time import random CITY_ID = "54511" # 北京,替换成你自己的城市 YEAR = 2024 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Referer": "https://tianqi.2345.com/", "X-Requested-With": "XMLHttpRequest", } def fetch_month(year, month): url = "https://tianqi.2345.com/Pc/GetHistory" params = { "areaInfo[areaId]": CITY_ID, "areaInfo[areaType]": 2, "date[year]": year, "date[month]": month, } for attempt in range(1, 4): try: resp = requests.get(url, params=params, headers=HEADERS, timeout=10) resp.raise_for_status() return resp.json() except Exception as e: print(f"第{attempt}次请求失败: {e}") time.sleep(2 * attempt) return None重试机制是我比较坚持的一点。网络请求永远可能因为各种原因失败,比如临时限流、连接超时、DNS解析异常。每个月份最多重试3次,每次失败后等待时间递增,能在不打扰目标服务器的前提下极大提高抓取成功率。
拿到JSON之后,需要解析data字段里的HTML表格。这一步我选择用BeautifulSoup,它的select语法足够简洁:
from bs4 import BeautifulSoup def parse_records(payload): html_str = payload.get("data", "") if not html_str: return [] soup = BeautifulSoup(html_str, "html.parser") rows = soup.select("table tr") records = [] for row in rows: cells = [cell.get_text(strip=True) for cell in row.select("td")] if len(cells) < 7: continue records.append({ "date": cells[0], "max_temp": cells[2].replace("℃", ""), "min_temp": cells[3].replace("℃", ""), "weather": cells[4], "wind_dir": cells[5], "wind_level": cells[6], }) return records注意,表格列的下标是0开头:0是日期,1是星期几,2是最高温,3是最低温,4是天气现象,5是风向,6是风力级别。不同时期页面改版,列顺序可能会变,所以第一次跑通后先打印前几条记录核对一下,这个动作我后面会专门强调。
3.2 全年主循环与CSV落地
单月搞定之后,主循环就非常简单了:
all_records = [] for month in range(1, 13): payload = fetch_month(YEAR, month) records = parse_records(payload) if payload else [] all_records.extend(records) print(f"{YEAR}-{month:02d}: 抓到 {len(records)} 条") time.sleep(random.uniform(1, 2)) print(f"全年累计抓取 {len(all_records)} 条记录")数据量核对这步不能省。北京2024年应该有366条记录,如果算出来不是366,说明有月份返回异常或者解析漏行,必须回头排查而不是继续往下做。
存储我用的是CSV,编码选了utf-8-sig而不是普通的utf-8,原因是后者在Excel里打开会出现中文乱码,utf-8-sig带BOM头,Excel可以直接识别:
import pandas as pd df = pd.DataFrame(all_records) df.to_csv(f"weather_{CITY_ID}_{YEAR}.csv", index=False, encoding="utf-8-sig") print("数据已保存到CSV文件")3.3 动手前一个必须做的校验动作
这一小节我想单独拎出来讲,因为新手在这里栽的跟头最多:解析函数写完后,先不要直接跑12个月循环,先只请求一个月份,比如2024年1月,然后把parse_records的结果打印到屏幕上,人工核对前两三条数据跟网页上显示的是否一致。
payload = fetch_month(2024, 1) records = parse_records(payload) for r in records[:3]: print(r)核对的重点是温度字段有没有残留“℃”字符、日期格式是否完整、天气现象有没有被截断。这一步能帮你提前发现列下标错误、字段缺失等问题,避免带着错误解析跑完全年,最后拿到一堆无法使用的脏数据。我自己就遇到过页面改版后表格多了一列“空气质量”,导致原有下标全部错位的情况,如果没有提前校验直接跑全年,返工成本会很高。
4. 数据清洗:把网页文本变成能分析的结构化数据
爬虫抓下来的数据本质上是“适合人看”的文本,不是“适合计算”的数据。直接拿CSV去做统计是不行的,必须先清洗。
4.1 字符串转数值:温度字段处理
原始数据里的温度字段长这样:-3℃、12℃。如果直接当成数值处理,pandas会把它们整体当作字符串,排序、求均值全乱套。清洗的方法很简单:
df["max_temp"] = pd.to_numeric(df["max_temp"], errors="coerce") df["min_temp"] = pd.to_numeric(df["min_temp"], errors="coerce") df["date"] = pd.to_datetime(df["date"])errors="coerce"很关键:遇到无法转换的字符串,不会让程序崩溃,而是变成NaN(空值)。这样后面一眼就能看出哪些数据异常。
转换完我还会顺手生成两个派生字段:
df["month"] = df["date"].dt.month df["temp_range"] = df["max_temp"] - df["min_temp"]temp_range是当天昼夜温差,这个指标在分析季节变化时很有用。
4.2 天气现象归类与缺失值处理
天气现象字段是中文文本,比如“晴”、“多云转小雨”、“大雪”、“雾”。直接拿去画图没法聚合,我需要先归一到几个大类:
def classify_weather(w): if not isinstance(w, str): return "未知" if "雨" in w: return "雨" if "雪" in w: return "雪" if "晴" in w: return "晴" if "云" in w: return "多云" if "雾" in w or "霾" in w: return "雾霾" return "其他" df["weather_type"] = df["weather"].apply(classify_weather)注意分类顺序有讲究,必须先判断“雨”和“雪”,再判断“晴”和“云”。因为“晴间多云”、“多云转阴”这种组合词里同时包含多种天气,如果先匹配“晴”,会把“雨夹雪”这类字段错误归到“晴”类。我吃过这个亏,排错逻辑花了不少时间。
缺失值处理上,我的原则是尽量不臆造数据。如果某一天的温度缺失,先用前后两天的均值做一个初步补齐,再在分析阶段单独标注出来,避免用凭空捏造的数据误导结论。用pandas做非常简单:
df["max_temp"] = df["max_temp"].fillna( df["max_temp"].shift(1).add(df["max_temp"].shift(-1)) / 2 )4.3 与官方公开统计交叉验证数据质量
数据清洗完后,不是直接开始画图,而是先验证数据可靠不可靠。我常用的方法是跟官方公布的月度平均气温做对比。
比如北京2024年1月官方平均气温大约是-3℃左右,那我把自己抓到的数据算一下同月平均最高气温和平均最低气温的平均值,看是否大致接近这个区间。如果偏差非常大,说明要么解析列错位,要么网站数据本身有缺陷。
这一步虽然不产生漂亮图表,但它是整个分析可信度的基石。你不能拿一份自己都不确定准不准的数据去做可视化。
5. 可视化分析:让一年天气自己“开口说话”
数据清洗完毕,终于到了最出彩的部分——可视化。我的目标不是画出一堆炫技图表,而是让每个图都能回答一个具体问题。
5.1 逐日气温折线图:看全年冷暖节奏
第一个问题:全年温度怎么随时间变化?我选择了带平滑效果的折线图,同时画出最高气温和最低气温两条曲线:
from pyecharts import options as opts from pyecharts.charts import Line line = ( Line() .add_xaxis(df["date"].dt.strftime("%m-%d").tolist()) .add_yaxis("最高气温", df["max_temp"].round(1).tolist(), is_smooth=True) .add_yaxis("最低气温", df["min_temp"].round(1).tolist(), is_smooth=True) .set_global_opts( title_opts=opts.TitleOpts(title="2024年逐日气温变化"), tooltip_opts=opts.TooltipOpts(trigger="axis"), xaxis_opts=opts.AxisOpts( axislabel_opts=opts.LabelOpts(interval=30), name="日期", ), yaxis_opts=opts.AxisOpts(name="温度(℃)"), ) ) line.render("daily_temperature.html")这张图画完,全年的冷暖节奏一目了然:1月最低、7到8月最高,两条曲线的开口宽度大致反映了昼夜温差在不同季节的变化。
5.2 日历热力图:一眼定位极端高低温
折线图能看趋势,但要说“哪天最热”、“极端高温集中在哪个月”,日历热力图是更直观的选择。pyecharts的Calendar组件正好支持这种年历布局:
from pyecharts.charts import Calendar data_pair = [ [str(d.date()), round(t, 1)] for d, t in zip(df["date"], df["max_temp"]) ] calendar = ( Calendar() .add("最高气温", data_pair, calendar_opts=opts.CalendarOpts(range_="2024")) .set_global_opts( title_opts=opts.TitleOpts(title="2024年逐日最高气温日历图"), visualmap_opts=opts.VisualMapOpts( max_=40, min_=-15, orient="horizontal", pos_left="center", range_color=["#313695", "#4575b4", "#fee090", "#fc8d59", "#d73027"], ), ) ) calendar.render("calendar_temp.html")日历图的优势在于保留了两个维度的信息:横轴是月份,每行是周。你能非常直观地看到红色块(高温天)集中在夏季那几行,蓝色块(低温天)集中在冬季。我做活动排期的时候,就靠这张图避开了连续高温时段。
5.3 月度聚合统计:让季节性规律现形
细到每天的图都有了,还需要从“月”这个粒度来看规律。我做了两个聚合统计:月度平均气温走势,以及每月各类天气天数。
monthly = df.groupby("month").agg( avg_max=("max_temp", "mean"), avg_min=("min_temp", "mean"), rain_days=("weather_type", lambda s: (s == "雨").sum()), snow_days=("weather_type", lambda s: (s == "雪").sum()), clear_days=("weather_type", lambda s: (s == "晴").sum()), ).round(1)把monthly_avg画成柱状图加折线图的双轴图,能同时展示最高温均值和最低温均值:
from pyecharts.charts import Bar bar = ( Bar() .add_xaxis([f"{m}月" for m in monthly.index.tolist()]) .add_yaxis("平均最高气温", monthly["avg_max"].tolist()) .add_yaxis("平均最低气温", monthly["avg_min"].tolist()) .set_global_opts( title_opts=opts.TitleOpts(title="2024年各月平均气温"), yaxis_opts=opts.AxisOpts(name="温度(℃)"), ) ) bar.render("monthly_avg_temperature.html")雨雪天数用堆叠柱状图或者饼图呈现会更直观。我当时用饼图看了全年下雨、下雪、晴、多云、雾霾的占比,发现北京一年里晴天和云天的占比超过一半,雨季集中在7到8月,跟华北地区“七下八上”的主汛期特征吻合,这算是对抓取数据真实性的又一个侧面验证。
6. 那些文档不会写的坑,以及可以继续深挖的方向
项目跑通很简单,但把它跑得稳、跑得可持续,需要踩过一些坑才有体会。
6.1 踩坑实录:接口返回值变化、限流与乱码
先说最容易遇到的三个问题。
接口HTML结构变化。你无法控制别人的页面什么时候改版,今天下标是2的温度列,明天可能变成3。解决方案是解析前先打印一条原始记录,让代码在跑之前先自检字段结构。如果字段对不上,立刻报警停止,而不是默默产出错误数据。
请求频率过快被临时限流。爬虫写得快,不代表可以没有节制地抓。我见过有人写了个死循环去抓同一接口,几秒钟发出上百个请求,结果IP被封了。任何公开接口都经不起这种请求压力,合理限速不只是礼貌,更是为了让你自己的爬虫项目能长期稳定运行。
CSV编码问题。用过Windows的同学应该都见过,明明程序里打印中文正常,但存出来的CSV在Excel里打开全是乱码。这个问题90%是编码问题,to_csv时指定encoding="utf-8-sig"就能解决。如果是读别人的文件乱码,先试试encoding="gbk"再试utf-8。
6.2 结构化升级:把CSV换成SQLite存储
CSV适合单次抓取,但如果我想每月自动追加数据,或者以后同时管理多座城市的数据,CSV就显得力不从心了。这时候我把存储层换成了SQLite,Python标准库自带,不需要额外部署数据库服务。
使用sqlalchemy可以轻松地把DataFrame直接写进数据库表:
from sqlalchemy import create_engine engine = create_engine("sqlite:///weather_data.db") df.to_sql("city_weather", con=engine, index=False, if_exists="append")这样做的实际好处有两个:一是增量写入非常方便,每个月跑一次定时任务,自动把当月数据追加到表里,天然支持断点续爬;二是后续做多城市对比时,只需要在表里增加city_id字段,查询时按城市过滤即可。if_exists="append"的意思是新数据追加到已有表,不会重复建表也不会覆盖旧数据,这个参数在增量场景里很关键。
6.3 项目扩展思路:多城市对比与实时监测
这套爬虫架构完全可以直接扩展成多城市尺度的气象分析工具。先写一个城市ID列表,循环请求即可:
city_ids = {"北京": "54511", "上海": "58362", "广州": "59287"} for city, cid in city_ids.items(): fetch_year_data(cid) time.sleep(random.uniform(1, 2))抓完数据后,可以画一张多城市温度对比图,直观看出南北气候差异;也可以把整个流程包装成定时任务,每个月自动更新一次当月数据,看看当年气温距平;如果再加上API实时预报数据,还能做一个“预报与历史同期气温对比”的小面板。
根据我个人经验,这个项目最好的学习方式,是你先原样复现一遍北京2024年的数据,然后替换成自己所在的城市重新跑一轮。换城市的代价几乎为零,但你会被迫重新理解“城市ID从哪来”“表格结构是否一致”“结果为什么跟你日常体感吻合”这些问题。跑完这一轮,你对Python爬虫和数据可视化的信心会完全不一样。