1. 从"20行代码"说起:这个需求到底在解决什么问题
很多人第一次看到"20行代码爬取历史天气数据"这个标题,第一反应是"这么短能干什么"。我刚开始接触气象数据采集的时候也有同样的疑问,直到真正做过几个跟天气数据打交道的项目之后才发现,代码行数从来不是衡量价值的标尺,真正决定成败的是你对数据源结构的理解程度。
历史天气数据的应用场景比大多数人想象的要广得多。做农业相关的项目需要分析积温、降水趋势来指导播种和灌溉决策;做零售的团队会拿历史天气跟销量做相关性分析,比如连续阴雨天对某些品类的影响;做保险精算的会关注极端天气事件的频率分布;甚至做个人健康管理的,也会想看看自己所在城市过去几年的空气质量与温度变化。这些场景有一个共同点:它们不需要实时数据,但对历史数据的完整性和连续性要求很高。
那为什么是"20行代码"?因为当你选对了数据源和工具之后,核心逻辑确实可以压缩到很短。这里的20行指的是核心抓取逻辑,不包括异常处理、数据清洗、存储落盘这些工程化的部分。我见过太多教程把简单问题复杂化,上来就是分布式爬虫框架、消息队列、代理池,结果一个日均请求量几百次的小需求被做成了运维噩梦。对于历史天气这种低频、结构化、有规律的数据,最朴素的方法往往就是最优解。
这篇文章适合三类人看:第一类是有Python基础但没怎么写过爬虫的开发者,想找一个结构清晰、逻辑完整的练手项目;第二类是做数据分析或产品运营的同学,需要自己动手获取天气数据但不想依赖第三方付费接口;第三类是有一定经验但想看看别人怎么处理数据源选择、反爬应对、数据校验这些细节的从业者。我会把每一步的选择理由讲清楚,把踩过的坑摊开来说,让你看完就能自己跑通一套可用的采集流程。
提示:本文讨论的所有技术方案仅用于学习和技术研究目的,实际使用时请遵守目标网站的服务条款和robots协议,控制请求频率,不要对目标服务器造成不必要的负担。
2. 数据源选型:为什么我最终放弃了API而选择网页解析
2.1 免费天气API的三个隐性成本
刚开始做天气数据采集的时候,我第一个想到的肯定是找现成的API。市面上确实有不少提供历史天气查询的接口,注册就能用,返回JSON格式,看起来省事。但实际用下来,免费API有三个绕不开的问题。
第一个是历史数据的时间跨度限制。很多免费接口只提供最近一年甚至最近三个月的数据,再往前的要么没有,要么需要升级到付费套餐。我做过一个需要五年历史数据的项目,翻遍了能找到的免费接口,没有一个能满足要求。
第二个是调用次数的硬性天花板。免费套餐通常每天几百到几千次调用,单看数字好像够用,但你要知道历史天气是按城市加日期维度查询的,一个城市五年的数据就是一千八百多条记录,十个城市就是一万八千次调用。这个量级下,免费额度几天就耗光了。
第三个是数据字段的残缺。有些接口只返回最高温、最低温和天气状况,没有风速、湿度、气压、降水量这些对分析很重要的维度。你想做更细致的分析时才发现数据维度不够,回头再找其他数据源,城市和日期对不上,合并起来非常麻烦。
2.2 网页数据源的结构化程度被严重低估了
转向网页解析之后,我发现一个很有意思的现象:很多天气网站的历史数据页面,结构化程度比API还高。为什么这么说?因为这类页面是给人看的,设计师为了让用户快速找到信息,会把数据放在规整的表格里,每个字段有明确的表头,日期按行排列。这种"为了展示而做的结构化",反而比某些API返回的嵌套JSON更容易解析。
我常用的一个数据源是某天气网站的历史天气频道,URL结构非常清晰:城市拼音加年份月份就能定位到具体页面。页面主体是一个表格,每行是一天,列包括日期、最高温、最低温、天气状况、风向风力等。用浏览器的开发者工具看一下DOM结构,表格的class名稳定,没有动态加载,没有复杂的JavaScript渲染,直接请求HTML就能拿到完整数据。
这里有一个判断数据源是否适合爬取的经验法则:如果页面在禁用JavaScript之后仍然能正常显示数据,那它就是爬虫友好的。你可以用浏览器插件或者开发者工具的网络面板验证这一点。如果数据是通过XHR异步加载的,那就需要分析接口请求;如果是服务端渲染的静态HTML,直接解析就行。历史天气页面绝大多数属于后者,因为这类数据更新频率低,没必要做成动态加载。
2.3 两种方案的详细对比
为了让你更直观地做选择,我把两种方案的关键维度整理成了表格:
| 对比维度 | 免费API方案 | 网页解析方案 |
|---|---|---|
| 历史数据跨度 | 通常1年以内 | 可达5-10年 |
| 调用频率限制 | 每日几百到几千次 | 取决于网站承受能力,需自律 |
| 数据字段丰富度 | 基础字段为主 | 通常更丰富,含风向风力等 |
| 返回格式 | JSON,直接可用 | HTML,需解析 |
| 稳定性 | 接口可能变更或下线 | 页面结构相对稳定 |
| 合规风险 | 需遵守API条款 | 需遵守robots协议和服务条款 |
| 技术门槛 | 低 | 中等,需了解HTML解析 |
从表格可以看出来,网页解析方案在数据获取能力上优势明显,代价是需要多写几行解析代码。但考虑到我们本来就是要用Python来做这件事,BeautifulSoup或者lxml这类库已经把HTML解析的门槛降得很低了,多出来的工作量完全在可接受范围内。
注意:选择数据源时务必先查看目标网站的robots.txt文件,确认允许抓取的路径范围。同时控制请求间隔,建议每次请求之间至少间隔1-2秒,避免对服务器造成压力。
3. 核心代码拆解:20行背后的每一个决策
3.1 请求头的伪装逻辑与边界
很多人写爬虫的第一行代码就是requests.get(url),然后发现返回403或者一堆乱码。问题出在请求头。服务器会检查User-Agent来判断请求是来自浏览器还是脚本,默认的python-requests标识会被直接拒绝。
正确的做法是构造一个看起来像正常浏览器的请求头。但这里有一个度的问题:你不需要伪造得完美无缺,只要让服务器认为这是一个普通用户访问即可。我通常只设置三个字段:
headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Referer': 'https://www.example.com/' }User-Agent告诉服务器我用的是什么浏览器,Accept-Language表明我接受中文内容,Referer说明我是从站内哪个页面跳转过来的。这三个字段组合起来,基本可以应对大多数常规的反爬检测。
但我要强调一个边界:不要试图绕过网站明确设置的技术防护措施。如果网站使用了验证码、IP封禁、请求签名等机制来阻止自动化访问,那说明对方不欢迎爬虫,这时候应该尊重对方的意愿,寻找其他数据源或者联系网站获取授权。我们这里讨论的是那些没有主动设置障碍、只是做了基础请求头校验的公开页面。
3.2 URL构造的规律与容错
历史天气页面的URL通常遵循一个固定的模式,比如https://www.example.com/lishi/{城市拼音}/{年份}{月份}.html这样的结构。这意味着你只需要一个城市拼音列表和一个年月列表,就可以通过字符串拼接生成所有需要的URL。
这里有一个容易忽略的细节:月份需要补零。比如1月要写成"01"而不是"1",否则URL会404。这个坑我在第一次写的时候踩过,当时调试了半天才发现是格式问题。用Python的f-string可以很方便地处理:
url = f"https://www.example.com/lishi/{city}/{year}{month:02d}.html"{month:02d}的意思是格式化为两位数字,不足两位前面补零。这种细节看起来不起眼,但实际跑批量任务的时候,一个格式错误就会导致大量请求失败。
另外,城市拼音的映射也需要提前准备好。我一般会维护一个字典,把中文城市名映射到网站使用的拼音格式。有些网站用的是全拼,有些用的是简拼,还有些对多音字有特殊处理,这些都需要根据实际数据源来调整。
3.3 解析表格数据的三种方式对比
拿到HTML之后,解析表格数据有三种常见方式,各有适用场景:
第一种是BeautifulSoup配合CSS选择器。这是最直观的方式,你可以在浏览器开发者工具里直接复制元素的CSS路径,然后粘贴到代码里用。优点是上手快,代码可读性好;缺点是解析速度相对慢一些,处理大量页面时会有性能瓶颈。
第二种是lxml配合XPath。XPath的表达能力比CSS选择器更强,特别是在处理嵌套结构或者需要按条件筛选节点的时候。速度也比BeautifulSoup快不少。缺点是需要花点时间学习XPath语法,不过对于表格解析这种场景,常用的语法就那么几个,半小时就能掌握。
第三种是pandas的read_html函数。这是最省事的方式,一行代码就能把页面里的表格转成DataFrame。但它的前提是页面里的表格结构非常规整,而且页面上最好只有一个主要表格。如果页面里有多个表格或者表格结构有合并单元格,read_html的结果可能不符合预期,还需要额外清洗。
我个人的选择是:快速验证阶段用pandas.read_html,正式批量采集用lxml加XPath。前者帮我快速确认数据能不能拿到、字段对不对,后者保证批量任务的速度和稳定性。
3.4 数据落盘:为什么我推荐先存CSV再入库
采集到的数据怎么存?很多人第一反应是直接写数据库。但我的经验是,在数据采集阶段,CSV是比数据库更好的选择。
原因有三点。第一,CSV是纯文本格式,你随时可以打开看一眼数据长什么样,排查问题非常方便。第二,采集过程中如果程序中断,CSV是追加写入的,不会像数据库事务那样需要处理回滚和一致性问题。第三,CSV可以很方便地导入到任何数据库或分析工具里,不锁定技术栈。
具体操作上,我通常用pandas来组织数据,每个城市每个月的数据作为一个DataFrame,然后追加到同一个CSV文件里。这里要注意列顺序的一致性,不同月份的数据字段应该完全一样,否则追加的时候会出现列错位。我一般会在解析完第一个页面之后,把列名固定下来,后续所有页面都按这个列顺序来组织数据。
import pandas as pd # 解析得到当月数据 df = pd.DataFrame(rows, columns=['日期', '最高温', '最低温', '天气', '风向', '风力']) # 追加到CSV,不写表头(假设文件已存在) df.to_csv('weather_history.csv', mode='a', header=False, index=False, encoding='utf-8-sig')encoding='utf-8-sig'这个参数值得说一下。普通的utf-8编码在Excel里打开会出现中文乱码,加上-sig之后Excel就能正确识别了。这个细节不影响程序运行,但影响你后续用Excel查看数据的体验。
4. 批量采集中的工程化问题:从能跑到跑得稳
4.1 请求频率控制与重试机制
单次请求成功很容易,难的是连续请求几百次不出问题。我见过太多人写个for循环就开始跑,结果跑到一半被服务器限流,前面采的数据也白费了。
请求间隔是必须加的。我的习惯是每次请求之间time.sleep(1.5),这个间隔既能保证采集速度(一小时能采两千多条),又不会给服务器造成明显压力。如果你要采集的数据量特别大,可以适当延长到2-3秒,或者分时段采集,比如上午跑一批、下午跑一批。
重试机制同样重要。网络抖动、服务器临时不可用、解析偶发失败,这些情况在批量任务中几乎必然出现。我的做法是用一个简单的重试装饰器:
import time import requests def fetch_with_retry(url, headers, max_retries=3): for i in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp.text except requests.RequestException: pass time.sleep(2 * (i + 1)) # 退避等待 return None这里用了指数退避的策略:第一次失败等2秒,第二次等4秒,第三次等6秒。这样如果服务器只是短暂抖动,后面的重试大概率能成功;如果服务器确实不可用,也不会因为密集重试而加剧问题。
4.2 断点续采:别让一次中断毁掉整晚的工作
批量采集最怕的就是跑到90%的时候程序崩了,重新跑又得从头开始。解决这个问题的方法很简单:记录已完成的采集单元。
具体做法是维护一个进度文件,每完成一个城市一个月份的数据采集,就往文件里追加一条记录。程序启动时先读取这个文件,跳过已经完成的单元。
import os def load_progress(progress_file): if not os.path.exists(progress_file): return set() with open(progress_file, 'r') as f: return set(line.strip() for line in f) def save_progress(progress_file, unit): with open(progress_file, 'a') as f: f.write(unit + '\n')这个机制实现起来不到十行代码,但能帮你省下大量重复劳动的时间。我一般用城市_年份月份作为进度单元的标识,比如beijing_202301。
4.3 数据校验:怎么判断采到的数据是完整的
数据采回来不等于任务完成,你还得确认数据的质量。我通常做三个层面的校验:
第一层是行数校验。每个月的天数应该是固定的,1月31天、2月28或29天、4月30天,以此类推。如果解析出来的行数跟当月天数对不上,说明页面结构可能变了或者解析逻辑有问题。
第二层是字段完整性校验。检查关键字段是否有空值,比如日期、最高温、最低温这些核心字段不应该为空。如果出现大量空值,可能是页面改版导致选择器失效了。
第三层是数值合理性校验。温度值应该在合理范围内,比如-50到50摄氏度之间。如果出现明显异常的值,比如999或者-999,那通常是网站用来表示缺失数据的占位符,需要在清洗阶段处理掉。
def validate_month_data(df, year, month): import calendar expected_days = calendar.monthrange(year, month)[1] if len(df) != expected_days: return False, f"行数不符:期望{expected_days},实际{len(df)}" if df['最高温'].isna().sum() > 0: return False, "存在空值" return True, "校验通过"这套校验逻辑跑一遍只需要几毫秒,但能帮你及时发现数据质量问题,避免采了一堆脏数据还得回头重来。
5. 那些教程不会告诉你的踩坑实录
5.1 编码问题:乱码的三种面孔和对应解法
编码问题是爬虫新手遇到的第一道坎,而且它的表现形式有好几种,每种的原因和解法都不一样。
第一种是请求返回的HTML本身就是乱码。这通常是因为服务器返回的编码跟requests默认猜测的编码不一致。解决办法是手动指定编码:resp.encoding = 'utf-8'或者resp.encoding = resp.apparent_encoding。后者让requests根据内容自动推断编码,准确率更高。
第二种是解析出来的中文显示为乱码。这往往是BeautifulSoup或lxml的解析器编码设置问题。用BeautifulSoup的时候,确保传入的HTML字符串已经正确解码,或者让BeautifulSoup自己处理编码。
第三种是写入CSV后在Excel里打开乱码。这个前面提过,用utf-8-sig编码写入就能解决。但如果你已经写入了普通utf-8的文件,也可以用记事本打开后另存为ANSI编码来补救。
我踩过最坑的一次是第二种和第三种同时出现,页面上看着是乱码,写入文件后又是另一种乱码,排查了半天才发现是两个环节的编码都没设置对。从那以后我养成了一个习惯:在解析之前先打印前200个字符确认编码正确,写入文件后再读回来打印一行确认没有乱码。
5.2 页面结构变更:如何快速定位解析失败的原因
数据源页面改版是爬虫的宿命,早晚会遇到。关键不是避免它,而是遇到之后能快速定位和修复。
我的排查流程是这样的:首先打印出请求返回的HTML内容,看看是不是正常页面(有时候返回的是验证页面或者错误页)。然后检查表格的选择器是否还能匹配到元素,如果匹配不到,用开发者工具重新查看当前页面的DOM结构,对比选择器哪里变了。最后更新解析代码,用单个月份的数据验证通过后再跑批量。
为了减少改版带来的影响,我在写解析代码时会尽量选择语义稳定的选择器。比如优先用表格的class名而不是位置索引,优先用表头文字来定位列而不是固定的列序号。这样即使页面布局调整了,只要核心结构没变,解析代码大概率还能用。
5.3 特殊月份的处理:闰年、缺数据、格式不一致
有几个特殊情况在批量采集时一定会遇到,提前处理好能省很多事。
闰年二月。2024年2月有29天,其他年份是28天。如果你的校验逻辑写死了28,那闰年就会报错。用calendar.monthrange来动态获取当月天数就能避免这个问题。
某些日期缺数据。历史天气页面偶尔会有某一天的数据缺失,可能是网站本身就没有记录。这种情况下解析出来的行数会少于当月天数。我的处理方式是记录缺失日期但不中断采集,最后统一输出一份缺失清单,人工判断是否需要从其他数据源补充。
格式不一致。不同年份的页面可能在温度格式上有差异,比如有的写"5℃"有的写"5°C",有的写"-3~5℃"表示温差范围。解析的时候需要做归一化处理,把温度统一转成数值类型。我一般用正则表达式提取数字部分,然后根据正负号判断。
import re def parse_temperature(text): if not text or text in ['暂无', '-', '']: return None match = re.search(r'-?\d+', text) return int(match.group()) if match else None这个函数能处理大多数常见的温度格式,遇到无法解析的情况返回None,后续在校验阶段统一处理。
6. 采完数据之后:从原始数据到可用信息的最后一公里
6.1 数据清洗的四个标准动作
采回来的原始数据通常不能直接用于分析,需要做一轮清洗。我总结的四个标准动作是:去重、补缺、类型转换、异常值处理。
去重是因为断点续采或者重试机制可能导致同一天的数据被写入多次。用pandas的drop_duplicates按日期列去重就行。
补缺是针对缺失的日期,可以选择用前后日期的均值填充,或者直接标记为缺失。具体选哪种取决于你的分析目的,如果做趋势分析,插值填充比较合适;如果做极端天气统计,缺失就标记为缺失,不要人为填充。
类型转换是把温度、风力这些字段从字符串转成数值类型。注意温度可能有负号,风力可能有"3-4级"这样的范围表示,需要根据实际情况处理。
异常值处理是识别并处理那些明显不合理的值。我一般用箱线图或者3σ原则来识别异常值,然后根据业务判断是删除还是修正。
6.2 用pandas做快速探索性分析
数据清洗完之后,用pandas做几个快速的探索性分析,能帮你确认数据质量并发现一些有意思的规律。
比如计算每个月的平均温度、统计各天气状况的出现频率、找出极端温度出现的日期。这些分析不需要很复杂,几行代码就能跑出来,但能让你对数据的分布有个直观感受。
# 按月统计平均温度 monthly_avg = df.groupby(df['日期'].dt.month)['最高温'].mean() # 统计天气状况分布 weather_counts = df['天气'].value_counts() # 找出最高温最高的五天 top5_hot = df.nlargest(5, '最高温')[['日期', '最高温']]这些分析结果不仅能验证数据质量,有时候还能发现一些数据采集的问题。比如如果某个月的平均温度明显偏离常识,那可能是那个月的数据解析出了问题。
6.3 数据可视化:让规律自己说话
数字表格看多了容易麻木,画成图之后规律会直观很多。我通常至少画三张图:温度的时间序列折线图、天气状况的饼图或柱状图、温度分布的直方图。
时间序列图能看出季节变化趋势和异常波动,天气状况分布图能看出当地的气候特征,温度直方图能看出温度集中在哪个区间。这三张图结合起来,基本就能对一个地区的气候特征有个整体把握。
用matplotlib画图的时候,中文显示需要额外设置字体,否则会出现方框。这是一个很常见的坑:
import matplotlib.pyplot as plt plt.rcParams['font.sans-serif'] = ['SimHei'] plt.rcParams['axes.unicode_minus'] = False第一行设置中文字体,第二行解决负号显示为方框的问题。这两行代码建议直接加到你的脚本模板里,省得每次画图都重新设置。
7. 关于合规与可持续采集的几点个人体会
做数据采集这些年,我越来越意识到一个问题:技术能力越强,越要知道边界在哪里。能爬不等于应该爬,跑得快不等于跑得对。
我的基本原则是:只采集公开的、不需要登录就能访问的数据;严格控制请求频率,不给目标服务器造成额外负担;采集到的数据仅用于个人学习或研究分析,不用于商业用途,不二次分发。如果项目确实需要大量数据或者商业使用,应该走正规渠道获取授权或购买数据服务。
另外,数据源的选择也要有备选方案。不要把所有采集逻辑绑死在一个网站上,万一对方调整了服务条款或者页面结构大改,你得有替代方案。我通常会维护两到三个结构相似的数据源,主源不可用的时候可以快速切换。
最后说一个实际的经验:采集任务最好在本地或者自己的开发机上跑,不要放在生产服务器上。一方面避免占用生产资源,另一方面本地IP的请求行为更容易控制和排查。如果数据量确实很大,可以考虑分批次、分时段采集,而不是一次性跑完。
这套20行核心代码加上工程化的外围逻辑,我从第一次跑通到现在已经迭代了七八个版本。每次遇到新问题就补一块逻辑进去,现在这套流程已经能稳定处理几十个城市、跨越五六年的历史天气数据采集任务。如果你刚开始做类似的事情,建议先从单个城市单个月份跑通,确认数据质量和解析逻辑没问题之后,再逐步扩大范围。步子迈小一点,反而走得更远。