拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python天气数据爬取与可视化实战项目全解析

Python天气数据爬取与可视化实战项目全解析

前阵子有个朋友让我推荐一个既能练手又不容易烂尾的Python项目,我几乎没犹豫就说了天气数据爬取加可视化。这个选题看起来不起眼,但做下来你会发现它把整套数据链路串得非常完整:用requests发请求拿HTML,用BeautifulSoup解析出每天的最高温、最低温、天气状况和风力;接着用SQLAlchemy把数据按日期规规矩矩存进数据库;再交给Pandas做清洗和校验;最后用Matplotlib和ECharts把全年的温度曲线、天气分布、降水规律全部画出来。整个过程有真实的网络请求、有结构化存储、有数据质量坑、有图表呈现,练完这一套,你对"爬虫能做什么、数据怎么变成有价值的图表"会有一个特别直观的体感。

这篇文章我不打算讲那些用不到的炫技内容,就按我实际做完这个项目的顺序,把每一步的设计思路、踩过的坑、最终的实现代码都摆出来。不管你是刚学完Python基础想找项目练手,还是想给气象数据做点自己的分析,都可以直接照着操作。

1. 项目出发:为什么选天气数据作为爬虫练手对象

1.1 这个项目能帮你建立完整的数据处理闭环

很多新手学爬虫的时候会陷入一个误区:只要能抓到网页内容,打印出来就算完事了。但真实的数据项目,抓取只是第一步,后面还有存储、清洗、分析、展示这一整套流程。天气数据恰好能把这几步都串起来——它的字段足够规整(日期、气温、天气现象、风力),这不比去抓那些结构混乱的论坛帖子舒服多了?而且天气数据有天然的"长期观测"价值:只看某一天的记录毫无意义,必须拉出全年甚至多年的数据才能看出季节规律。这就逼着你去考虑数据该存成什么格式、要不要做去重、缺了某天的数据怎么处理,这些都是平时光看教程根本不会注意到的坑。

1.2 目标拆解:从爬取到出图需要哪几个环节

我习惯拿到需求先拆解成可执行的小步骤。这个项目我拆成了四个环节:

  1. 确定数据源和URL规律。要找那种不需要登录、没有复杂验证码、页面结构稳定的历史天气站点。
  2. 写爬虫脚本循环抓取。全年就是12个月,按月份构造URL,循环请求、解析、入库。
  3. 数据校验与清洗。检查有没有重复日期、有没有明显异常的数值,把"23°C"这种带单位的字符串转成纯数字。
  4. 可视化分析。先画静态图看清楚总体趋势,再做一份交互式HTML大屏方便鼠标悬浮查看每天的精确数值。

明确的拆解能让你在做的过程中时刻清楚"我卡在哪一步了"。我当时就在"请求被拒"这个环节卡了很久,后来才发现是User-Agent没伪装好。这些问题后面我都会详细说。

2. 技术栈选型:一套顺手又能打的组合

2.1 requests + BeautifulSoup:轻量级爬虫的黄金搭档

在选择用什么库去抓数据的问题上,我推荐requests配合BeautifulSoup,而不是一上来就上Scrapy。原因很简单:我们要抓的数据量只有全年365条左右,用Scrapy这种重量级框架去跑,配置项目结构、Pipeline、中间件的时间比写核心逻辑还长,完全是杀鸡用牛刀。requests简单直接,一个get方法拿到响应,配合session还能保持会话状态;BeautifulSoup用CSS选择器就能筛出目标数据,写起来非常接近"人话"。有些老手喜欢用XPath配合lxml,效率确实高一些,但对这种小体量项目来说,BeautifulSoup的容错性和可读性更值得优先考虑。

2.2 SQLAlchemy:让数据结构化落地,而不是停留在内存

一开始我图省事想直接把所有数据存成一个CSV文件。但做到后面就会发现问题:如果爬了一半程序挂了,CSV文件可能没写完整;如果重跑一遍,重复数据又不好处理。换成SQLAlchemy以后舒服很多。SQLAlchemy是一个ORM(对象关系映射)框架,你可以用Python的类来定义数据库表,插入一行数据就像创建一个对象一样自然。再搭配SQLite这个零配置文件型数据库,整个项目连数据库安装都不需要,非常适合单机小项目。更重要的是,SQLAlchemy让你天然地把数据模型和业务逻辑分开,以后想升级到MySQL或者PostgreSQL,只需要改一行连接字符串。

2.3 Matplotlib + ECharts:两个可视化方案怎么选

可视化部分我先用Matplotlib做了一套静态图,后来又用ECharts做了一份HTML交互版。这两个工具的定位完全不同:Matplotlib是出"论文风格"的静态图片,非常适合快速确认数据规律、保存成PNG放进日报或PPT里,而且它和Pandas无缝衔接,DataFrame直接调用plot方法就能出图;ECharts则是纯前端的JavaScript图表库,生成的是交互式HTML页面,鼠标悬停能看到数值、可以框选缩放、图表切换动画,做展示或大屏项目时效果很唬人。我的建议是两个都要会,日常分析用Matplotlib,交付展示用ECharts。后面第5章我会把两者的代码都贴出来。

提醒一点:ECharts本身是JS库,Python端只是负责把处理好的数据导出成JSON格式,再嵌进一个HTML模板里。所以做这部分要有最基础的HTML知识,会改script标签里的配置项就够了。

3. 爬虫核心实现:从URL到结构化数据

3.1 先搞清楚目标网站的数据藏在哪

写爬虫的第一步不是急着敲代码,而是先打开目标网页按F12,观察数据藏在哪。有的网站数据直接在HTML源码里,也就是服务端渲染,这种你用requests拿到HTML后直接解析就行;有的网站数据是前端通过AJAX异步加载的,那就要去XHR请求列表里找对应的JSON接口。我当时查天气历史数据的时候发现,页面上的数据其实直接渲染在HTML表格里,这就省事多了——构造月份URL、抓HTML、用BeautifulSoup解析,三步搞定。

URL规律是我用浏览器的"检查"功能观察地址栏看出来的。目标站点的URL大概是这个格式:https://某天气网站/城市代码/month/202301.html,也就是说只要替换年份月份,就能循环请求一整年的数据。建议你拿到一个真实站点后,先手动在浏览器里翻两个月的页面,把URL规律和页面结构都摸清楚,再开始写代码。

3.2 请求头伪装和请求频率控制

别忘了在headers里带上User-Agent。服务器识别爬虫最常用的手段就是检查User-Agent,如果你不设置,默认值通常是"Python-requests/x.x.x",这种一看就是机器的标识很容易被拒绝。我一般会把浏览器里那一串完整的信息复制过来,同时加上Referer和Accept-Language,模仿得更像真实用户。

import requests import time 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://目标网站首页/", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }

关于请求频率我的经验是:单机爬虫每请求一次至少睡1到2秒。原因有两个,第一是防止被对方的防爬策略盯上,比如IP被临时封禁;第二是给服务器留出响应余量,毕竟一个天气网站有很多正常用户在访问。别觉得1秒很慢,全年12个页面加上重试时间,总共也就几十秒的事,完全没必要为了省这点时间把IP搭进去。

重要提示:任何爬虫都要遵守网站的robots协议和访问公平性,不要做高速并发抓取,更不要把抓到的数据用于商业用途。练习可以,但要有边界感。

3.3 解析逻辑与异常处理

拿到HTML之后,我用BeautifulSoup定位表格里每一行的数据。核心思路是:先把页面里存放天气数据的表格找出来,然后遍历每一行,分别提取日期、最高气温、最低气温、天气状况和风力这几列,再统一存成字典列表。下面这段代码是我实测过的核心逻辑:

from bs4 import BeautifulSoup import re def parse_weather_page(html): soup = BeautifulSoup(html, "html.parser") # 找到天气表格,注意不同网站的选择器不一样 table = soup.select_one("ul.lishi_list") or soup.select_one("table") if not table: return [] results = [] # 每个 li 或 tr 代表一天的数据 rows = table.select("li, tr") for row in rows: cells = row.stripped_strings # 提取该行所有文本 cells = list(cells) if len(cells) < 5: continue date_text = cells[0] weather_text = cells[1] high_text = cells[2] low_text = cells[3] wind_text = cells[4] # 把 "23°C" 或 "23℃" 里的数字提取出来 def parse_temp(text): nums = re.findall(r"-?\d+", text) return float(nums[0]) if nums else None results.append({ "date": date_text, "weather": weather_text, "high_temp": parse_temp(high_text), "low_temp": parse_temp(low_text), "wind": wind_text, }) return results

必须说明,HTML页面的结构可能因为网站改版而变化,所以上面的选择器需要根据比例实际页面微调。重点在于你的思路:先定位容器,再遍历行,再按列提取。这种逐行解析的代码会比死板地按坐标定位要稳得多。

异常处理是整个爬虫里不能少的一环。网络请求可能超时、页面结构可能临时变化、某个月份可能没有数据,这些情况如果不去处理,程序一崩就得从头跑。我习惯在每个请求外面包一层try/except,捕获异常后打印日志并跳过,让整个循环能坚持跑完全年。

def fetch_one_month(year, month): url = f"https://目标网站/城市代码/month/{year}{month:02d}.html" for attempt in range(3): # 最多重试3次 try: resp = requests.get(url, headers=headers, timeout=10) resp.encoding = "gbk" # 很多天气老网站是GBK编码 if resp.status_code == 200: return parse_weather_page(resp.text) except Exception as e: print(f"请求 {url} 失败:{e}") time.sleep(2) return []

这段代码里有两个细节值得注意:一是resp.encoding = "gbk",很多中文老网站用的是GBK编码而不是UTF-8,如果抓下来的内容是乱码,先检查这一行;二是重试机制,每次失败后等待2秒再试,连续三次都失败就放弃这个月份,保证程序不会卡死。

4. 数据存储与清洗:别让脏数据污染你的图表

4.1 用SQLAlchemy定义天气数据模型

数据抓到之后不能直接拿去画图,得先设计一个表结构把它们存下来。我用SQLAlchemy定义了一个Weather模型,字段包括城市、日期、最高温、最低温、天气现象、风力。其中日期字段设为Date类型,这样后续做时间序列分析时可以直接按时间排序和聚合。

from sqlalchemy import create_engine, Column, String, Float, Date, Integer from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() class Weather(Base): __tablename__ = "weather" id = Column(Integer, primary_key=True, autoincrement=True) city = Column(String(20), default="beijing") date = Column(Date, nullable=False, unique=True) high_temp = Column(Float) low_temp = Column(Float) weather = Column(String(50)) wind = Column(String(50)) engine = create_engine("sqlite:///weather.db", echo=False) Base.metadata.create_all(engine) Session = sessionmaker(bind=engine)

这里比较关键的是把date字段设成unique=True。因为我写爬虫的时候可能会重复跑同一个月份,如果不去重,数据库里就会出现同一天两条记录,后面统计分析全乱套。设了唯一约束之后,插入重复数据会直接报错,这样就能倒逼你在插入之前做去重判断。

4.2 数据入库与去重

入库最简单的写法是session.add_all()然后commit(),但因为已经设置了唯一约束,我更推荐先查一下库里是否已经有这一天的数据:

from datetime import datetime def save_weather_records(city, records): session = Session() added = 0 for rec in records: try: date_obj = datetime.strptime(rec["date"], "%Y年%m月%d日").date() except ValueError: date_obj = datetime.strptime(rec["date"], "%Y-%m-%d").date() exists = session.query(Weather).filter_by(date=date_obj, city=city).first() if exists: continue session.add(Weather( city=city, date=date_obj, high_temp=rec["high_temp"], low_temp=rec["low_temp"], weather=rec["weather"], wind=rec["wind"], )) added += 1 session.commit() session.close() print(f"新增 {added} 条记录")

这段代码看起来啰嗦,但非常值得。上面先做了一次查询,如果当天记录已存在就跳过,这是在给数据质量兜底。你可以把入库和去重放在同一个事务里,确保万无一失。

4.3 快速校验数据完整性的方法

存完数据别急着可视化,先做一遍数据体检。我的标准动作是三步:

第一步,看总数是否接近365。我用session.query(Weather).count()查总数,如果只有200多条,多半是某些月份没抓到或者解析漏了,得回头补爬。

第二步,用Pandas读库,按月份分组求平均温度,看看是否合理。比如北方城市7月平均最高温应该在30度上下,如果某个月份突然蹦出-10度,那绝对是脏数据。

import pandas as pd df = pd.read_sql_query("SELECT date, high_temp, low_temp, weather FROM weather", engine) df["month"] = pd.to_datetime(df["date"]).dt.month monthly = df.groupby("month").agg( avg_high=("high_temp", "mean"), avg_low=("low_temp", "mean"), ).round(1) print(monthly)

第三步,检查有没有异常极值。我用df.describe()看最高温和最低温的min/max,如果冬天出现45度、夏天出现零下20度,那肯定是解析出错了,需要回到parse_weather_page里查查是不是提取的温度字段选错了列。这个步骤很多人会跳过,但恰恰是这一步能把那些看着像模像样实则错误的图表救回来。

5. 可视化分析与图表解读

5.1 全年温度趋势折线图:一眼看尽四季轮换

数据清洗干净之后,第一个出图的项目永远是温度趋势线。我用Matplotlib画了最高温和最低温两条折线,中间用fill_between做了区间填充,这样全年温度带的变化会特别直观。另外我用滚动平均做了平滑处理,把每天的锯齿抹掉,突出整体趋势。

import matplotlib.pyplot as plt import matplotlib.dates as mdates plt.rcParams["font.sans-serif"] = ["SimHei"] # 解决中文显示问题 plt.rcParams["axes.unicode_minus"] = False df["date"] = pd.to_datetime(df["date"]) df = df.sort_values("date") df = df.set_index("date") fig, ax = plt.subplots(figsize=(14, 6)) ax.plot(df.index, df["high_temp"], label="最高温", color="#e74c3c", linewidth=1.5) ax.plot(df.index, df["low_temp"], label="最低温", color="#3498db", linewidth=1.5) ax.fill_between(df.index, df["low_temp"], df["high_temp"], color="orange", alpha=0.15) df["high_ma7"] = df["high_temp"].rolling(7, min_periods=1).mean() df["low_ma7"] = df["low_temp"].rolling(7, min_periods=1).mean() ax.plot(df.index, df["high_ma7"], label="最高温7日均线", color="#c0392b", linewidth=2.5, linestyle="--") ax.plot(df.index, df["low_ma7"], label="最低温7日均线", color="#2980b9", linewidth=2.5, linestyle="--") ax.xaxis.set_major_formatter(mdates.DateFormatter("%m月")) ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.set_title("全年气温变化趋势", fontsize=16) ax.legend() plt.tight_layout() plt.savefig("temperature_trend.png", dpi=150) plt.show()

当你第一次看到这张图的时候,你会很直观地发现:所谓"春天气温多变"反映在折线上就是3月到4月的线特别陡、上下起伏大;而"三伏天"就是7月下旬到8月中旬那条线一直在高位平台震荡。这就是可视化的价值——数据原本只是表格里的数字,画出来之后规律自己就浮现了。

5.2 天气分布统计:晴天、雨天、雪天各占多少

温度趋势图只能看出冷暖,看不出天气现象的整体分布。我接着用Pandas做了一次天气状况的统计。不过这里有个坑:天气字段往往是"小雨转多云""晴转雷阵雨"这种复合描述,如果直接按这个value_counts,会有几十个类别,没法画图。所以我先按"主天气"拆出来,取第一个天气词作为当天的代表。

df["main_weather"] = df["weather"].astype(str).str.split("转").str[0] weather_counts = df["main_weather"].value_counts() fig2, ax2 = plt.subplots(figsize=(10, 6)) weather_counts.head(10).plot(kind="bar", ax=ax2, color="#5DADE2") ax2.set_title("全年主要天气现象分布(前10)") ax2.set_ylabel("天数") plt.tight_layout() plt.savefig("weather_distribution.png", dpi=150)

从这张柱状图你能清楚地看出这个城市的雨热特征:夏天多雷阵雨、冬天多晴天、春末还有一段大风天。不要小看这种简单的统计,它实际上回答了一个具体的生活问题:"这个城市一年到底有多少天适合户外活动?"通过数据量化出来的天气规律,往往和你凭感觉想象的不太一样。

5.3 交互式HTML大屏:用ECharts把静态图变成可交互页面

如果要把这个项目做成能给别人演示的成果展示,静态图肯定不够用了。我自己的做法是把统计结果导出成JSON,然后塞进一个ECharts的HTML模板里,做一个带缩放、悬浮提示、平滑动画的年温度大屏。这里以温度趋势折线为例,关键配置如下:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>全年天气数据分析</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> <style> #chart { width: 100%; height: 600px; } </style> </head> <body> <div id="chart"></div> <script> const rawData = __WEATHER_JSON__; const dates = rawData.map(item => item.date); const highs = rawData.map(item => item.high_temp); const lows = rawData.map(item => item.low_temp); const chart = echarts.init(document.getElementById('chart')); chart.setOption({ tooltip: { trigger: 'axis' }, legend: { data: ['最高温', '最低温'] }, grid: { left: 50, right: 50, top: 60, bottom: 60 }, xAxis: { type: 'category', data: dates }, yAxis: { type: 'value', name: '气温(°C)' }, dataZoom: [{ type: 'inside' }, { type: 'slider' }], series: [ { name: '最高温', type: 'line', data: highs, smooth: true, symbol: 'none' }, { name: '最低温', type: 'line', data: lows, smooth: true, symbol: 'none' } ] }); </script> </body> </html>

你只需要在Python里把DataFrame转成JSON字符串,替换掉__WEATHER_JSON__占位符,把这个HTML保存下来用浏览器打开,就得到一个可以拖拽缩放、悬停看每一天精确数值的交互图表。我的习惯是最后再用一个简单的CSS网格布局,把温度趋势图、天气分布柱状图、高温天数统计图放在同一个页面里,凑成一个"年度天气数据看板"。做这种看板的时候不需要什么重型框架,纯HTML+CSS+ECharts就够了。

5.4 从图表里能读出什么:让分析有观点而不只是画图

这个项目最容易被忽视的部分其实是最后的"解读"。可视化做完不解读,就只是画了几张好图而已。我拿到全年数据之后做了几个很小但很实在的分析:

首先是极端天气统计。我把最高温大于35度的天数筛选出来,一共有23天,集中在6到8月;然后算了一下最热的一天出现在哪天、温度是多少,这种"数据事实"在报告里特别有说服力。其次是温差分析。我算出了每月平均昼夜温差,结果春天几个月的温差能达到12度以上,而盛夏只有6到8度,直接解释了"春秋乱穿衣、夏天昼夜温差小"这句俗话背后的数据逻辑。然后是降水与天气频率。我统计了全年"晴"和"多云"的总天数,用占比去量化"这个城市一年里适合出游的天数到底有多少"。

分析这一块才是把爬虫项目变成数据分析项目的关键一步。你会发现同样一份数据,有人只能导出"全年最高温出现日期是X月X日",有人却能写出一篇"城市气候特征观察报告",差别就在于有没有带着问题去做分析。

6. 常见问题速查与排坑实录

6.1 请求返回403或空页面:八成是请求头没伪装好

我在写这个项目的时候第一次遇到403心情还挺懵的:明明浏览器能打开页面,换成Python就拒绝访问了。排查下来发现是缺少User-Agent。后来我把请求头补全,加入了Referer和Accept-Language,问题就消失了。如果你的网站还做了浏览器指纹校验,更稳妥的办法是通过requests.Session()来发送请求,它可以保持cookie和会话状态,进一步提升"像真实用户"的程度。

如果加了请求头还是403,那就要考虑频率问题。短时间内频繁请求同样的URL很容易被IP封禁。我的经验是每抓一个月页面后sleep(1.5),如果还是被封,酌情加大到3到5秒,同时换一个网络环境继续测试。

6.2 中文乱码:先检查编码设置是否在正确的位置

天气网站很多是政府性质的旧站点,页面编码经常是GBK而不是UTF-8。如果你直接resp.text,有些站点会自动按照headers里的charset解析,有些不会,就会变成一堆乱码。正确的做法是在读取resp.text之前显式指定编码:

resp = requests.get(url, headers=headers, timeout=10) resp.encoding = "gbk" # 必须在访问 resp.text 之前设置 html = resp.text

另一个小技巧是直接用resp.apparent_encoding来做自动检测,但检测结果偶尔会出错,所以我更推荐先看一眼页面源码的<meta charset>再手动设置。这个细节卡了我将近半天,因为它太隐蔽了——数据打印到控制台全是乱码,但请求本身是成功的,很容易让人误以为是解析逻辑写错了。

6.3 日期格式不统一:用datetime.strptime统一转换

不同月份的页面日期格式可能略有差异,比如有的显示"2023年1月5日",有的显示"2023-01-05"。如果直接拿字符串去入库,数据库排序就会出问题。我的做法是在入库前统一用datetime.strptime转换成真正的日期对象,兼容两种格式。遇到解析不了的值就打印出来并跳过,不让程序直接崩溃。

6.4 数据不完整:缺了某个月份怎么办

爬虫跑完全年之后发现某个月份的数据是空的,最可能的原因是该月页面结构特殊,或者请求时网络超时。我的处理方法是单独针对缺失月份重新跑一次fetch_one_month(),并用时间范围的对比来定位缺了哪几天。如果某个日期确实在源网站上也没有记录,那就在分析阶段做好标记,不硬编造。这里有一个小建议:每次爬取过程都打印日志,记录"请求了哪个URL、成功解析出多少条、入库新增多少条",这样问题排查时会省很多力气。

6.5 常见错误速查表

现象最可能的原因处理建议
返回403User-Agent未伪装或请求频率过高携带完整浏览器请求头,增加sleep间隔
页面内容乱码使用了错误的编码读取设置为resp.encoding = "gbk"再读取文本
解析结果为空页面改版或选择器失效F12检查当前页面结构,更新选择器
数据库重复数据重复跑同一个月未去重设置date字段唯一约束,入库前先查重
温度值带单位字符直接提取而未清洗用正则\d+提取数字并转成float
Matplotlib中文显示为方块系统缺中文字体设置plt.rcParams["font.sans-serif"] = ["SimHei"]

这个速查表是我自己项目里实际解决问题的总结,你大概率也会在复现的过程中遇到其中一两条。我特别想强调"选择器失效"这个问题——网站前端一改版,之前写死的CSS类名就全废了,这是所有爬虫从业者的宿命。所以写解析代码的时候尽量用稳定一点的定位方式,比如找最外层的某一个通用容器,而不是依赖过于精细的类名。

写在最后:这个项目做完之后我最大的体会

整个项目从零到一做完,我最大的收获不是记住了几个API用法,而是彻底理解了"数据项目是一个有始有终的闭环"。以前我写爬虫总感觉抓下来的东西是死的,放在变量里print出来就结束了;现在我会自然地想:这批数据存在哪、要不要清洗、能不能画成图、图里有没有新的信息。这种思维方式的转变,是光看任何教程都学不来的。

如果你想在这个项目基础上继续扩展,我可以给你两个方向。第一个方向是纵向加深:把爬取范围扩展到最近五年的数据,结合简单的统计模型做气温趋势预测,或者用多城市数据做横向对比,看看南北方的温度差在四季有什么变化。第二个方向是工程化改造:把爬虫封装成一个get_city_weather_report(city_code)函数,换个城市名就能一键输出整份数据和图表,再配合定时任务每周自动更新,把它变成长期运行的数据产品。不管选哪条路,你都已经具备了最基本的全链路能力,剩下的就是在"做得更深"和"做得更顺"之间选一个使劲了。

返回列表