
前阵子有个朋友让我推荐一个既能练手又不容易烂尾的Python项目我几乎没犹豫就说了天气数据爬取加可视化。这个选题看起来不起眼但做下来你会发现它把整套数据链路串得非常完整用requests发请求拿HTML用BeautifulSoup解析出每天的最高温、最低温、天气状况和风力接着用SQLAlchemy把数据按日期规规矩矩存进数据库再交给Pandas做清洗和校验最后用Matplotlib和ECharts把全年的温度曲线、天气分布、降水规律全部画出来。整个过程有真实的网络请求、有结构化存储、有数据质量坑、有图表呈现练完这一套你对爬虫能做什么、数据怎么变成有价值的图表会有一个特别直观的体感。这篇文章我不打算讲那些用不到的炫技内容就按我实际做完这个项目的顺序把每一步的设计思路、踩过的坑、最终的实现代码都摆出来。不管你是刚学完Python基础想找项目练手还是想给气象数据做点自己的分析都可以直接照着操作。1. 项目出发为什么选天气数据作为爬虫练手对象1.1 这个项目能帮你建立完整的数据处理闭环很多新手学爬虫的时候会陷入一个误区只要能抓到网页内容打印出来就算完事了。但真实的数据项目抓取只是第一步后面还有存储、清洗、分析、展示这一整套流程。天气数据恰好能把这几步都串起来——它的字段足够规整日期、气温、天气现象、风力这不比去抓那些结构混乱的论坛帖子舒服多了而且天气数据有天然的长期观测价值只看某一天的记录毫无意义必须拉出全年甚至多年的数据才能看出季节规律。这就逼着你去考虑数据该存成什么格式、要不要做去重、缺了某天的数据怎么处理这些都是平时光看教程根本不会注意到的坑。1.2 目标拆解从爬取到出图需要哪几个环节我习惯拿到需求先拆解成可执行的小步骤。这个项目我拆成了四个环节确定数据源和URL规律。要找那种不需要登录、没有复杂验证码、页面结构稳定的历史天气站点。写爬虫脚本循环抓取。全年就是12个月按月份构造URL循环请求、解析、入库。数据校验与清洗。检查有没有重复日期、有没有明显异常的数值把23°C这种带单位的字符串转成纯数字。可视化分析。先画静态图看清楚总体趋势再做一份交互式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;q0.9,en;q0.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 fhttps://目标网站/城市代码/month/{year}{month:02d}.html for attempt in range(3): # 最多重试3次 try: resp requests.get(url, headersheaders, timeout10) 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_keyTrue, autoincrementTrue) city Column(String(20), defaultbeijing) date Column(Date, nullableFalse, uniqueTrue) high_temp Column(Float) low_temp Column(Float) weather Column(String(50)) wind Column(String(50)) engine create_engine(sqlite:///weather.db, echoFalse) Base.metadata.create_all(engine) Session sessionmaker(bindengine)这里比较关键的是把date字段设成uniqueTrue。因为我写爬虫的时候可能会重复跑同一个月份如果不去重数据库里就会出现同一天两条记录后面统计分析全乱套。设了唯一约束之后插入重复数据会直接报错这样就能倒逼你在插入之前做去重判断。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(datedate_obj, citycity).first() if exists: continue session.add(Weather( citycity, datedate_obj, high_temprec[high_temp], low_temprec[low_temp], weatherrec[weather], windrec[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, linewidth1.5) ax.plot(df.index, df[low_temp], label最低温, color#3498db, linewidth1.5) ax.fill_between(df.index, df[low_temp], df[high_temp], colororange, alpha0.15) df[high_ma7] df[high_temp].rolling(7, min_periods1).mean() df[low_ma7] df[low_temp].rolling(7, min_periods1).mean() ax.plot(df.index, df[high_ma7], label最高温7日均线, color#c0392b, linewidth2.5, linestyle--) ax.plot(df.index, df[low_ma7], label最低温7日均线, color#2980b9, linewidth2.5, linestyle--) ax.xaxis.set_major_formatter(mdates.DateFormatter(%m月)) ax.xaxis.set_major_locator(mdates.MonthLocator()) ax.set_title(全年气温变化趋势, fontsize16) ax.legend() plt.tight_layout() plt.savefig(temperature_trend.png, dpi150) 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(kindbar, axax2, color#5DADE2) ax2.set_title(全年主要天气现象分布前10) ax2.set_ylabel(天数) plt.tight_layout() plt.savefig(weather_distribution.png, dpi150)从这张柱状图你能清楚地看出这个城市的雨热特征夏天多雷阵雨、冬天多晴天、春末还有一段大风天。不要小看这种简单的统计它实际上回答了一个具体的生活问题这个城市一年到底有多少天适合户外活动通过数据量化出来的天气规律往往和你凭感觉想象的不太一样。5.3 交互式HTML大屏用ECharts把静态图变成可交互页面如果要把这个项目做成能给别人演示的成果展示静态图肯定不够用了。我自己的做法是把统计结果导出成JSON然后塞进一个ECharts的HTML模板里做一个带缩放、悬浮提示、平滑动画的年温度大屏。这里以温度趋势折线为例关键配置如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 title全年天气数据分析/title script srchttps://cdn.jsdelivr.net/npm/echarts5/dist/echarts.min.js/script style #chart { width: 100%; height: 600px; } /style /head body div idchart/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网格布局把温度趋势图、天气分布柱状图、高温天数统计图放在同一个页面里凑成一个年度天气数据看板。做这种看板的时候不需要什么重型框架纯HTMLCSSECharts就够了。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, headersheaders, timeout10) 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提取数字并转成floatMatplotlib中文显示为方块系统缺中文字体设置plt.rcParams[font.sans-serif] [SimHei]这个速查表是我自己项目里实际解决问题的总结你大概率也会在复现的过程中遇到其中一两条。我特别想强调选择器失效这个问题——网站前端一改版之前写死的CSS类名就全废了这是所有爬虫从业者的宿命。所以写解析代码的时候尽量用稳定一点的定位方式比如找最外层的某一个通用容器而不是依赖过于精细的类名。写在最后这个项目做完之后我最大的体会整个项目从零到一做完我最大的收获不是记住了几个API用法而是彻底理解了数据项目是一个有始有终的闭环。以前我写爬虫总感觉抓下来的东西是死的放在变量里print出来就结束了现在我会自然地想这批数据存在哪、要不要清洗、能不能画成图、图里有没有新的信息。这种思维方式的转变是光看任何教程都学不来的。如果你想在这个项目基础上继续扩展我可以给你两个方向。第一个方向是纵向加深把爬取范围扩展到最近五年的数据结合简单的统计模型做气温趋势预测或者用多城市数据做横向对比看看南北方的温度差在四季有什么变化。第二个方向是工程化改造把爬虫封装成一个get_city_weather_report(city_code)函数换个城市名就能一键输出整份数据和图表再配合定时任务每周自动更新把它变成长期运行的数据产品。不管选哪条路你都已经具备了最基本的全链路能力剩下的就是在做得更深和做得更顺之间选一个使劲了。