1. 项目缘起与整体思路拆解1.1 为什么会有这个需求做数据分析的朋友大概率都遇到过这样的场景想研究某个城市的气温变化趋势、降雨分布规律或者给机器学习模型准备训练数据结果发现历史天气数据并不好找。公开的气象数据平台要么只提供最近几天的数据要么需要注册申请、等待审核流程繁琐。而一些第三方天气网站虽然页面上能看到历史数据但动辄几十年的记录靠手动复制粘贴根本不现实。这个项目的出发点就非常直接用尽可能少的代码把各地历史天气数据批量抓下来存成结构化格式方便后续分析。标题里说的“20行代码”并不是噱头核心逻辑确实可以在20行左右完成但要把这件事做得稳、做得通用背后有不少细节值得展开聊。1.2 技术路线选型抓取天气数据常见的技术路线有三条调用官方API数据规范、稳定但通常有调用次数限制历史数据可能需要付费而且不同平台接口差异大。直接请求网页接口很多天气网站的前端页面本身就是通过异步接口加载数据的找到这个接口直接请求返回的往往是干净的JSON解析成本极低。解析HTML页面用解析库从渲染后的页面里提取数据通用性强但维护成本高页面结构一变就得改代码。这个项目选择的是第二条路线——直接请求网页背后的数据接口。原因很简单返回JSON意味着不需要写复杂的解析逻辑代码量能压到最低同时接口返回的数据字段通常比页面展示的更全包含温度、天气状况、风向风力、空气质量等多项指标。提示选择接口路线的前提是接口相对稳定。实际使用中建议先手动观察一段时间确认接口没有频繁变动再投入生产。1.3 整体流程设计整个抓取流程可以拆成四个环节确定目标城市和月份把需要抓取的城市名称和对应的时间范围整理成列表。构造请求参数每个城市、每个月份对应一组请求参数包括城市标识、年份、月份。发送请求并接收数据用HTTP库发起请求拿到JSON格式的响应。数据落地存储把JSON里的关键字段提取出来写入CSV或数据库。这四个环节里第一和第四是体力活第二和第三才是技术核心。下面逐个拆解。2. 核心细节解析与实操要点2.1 城市标识的获取与映射天气网站的接口通常不会直接用“北京”“上海”这样的中文名作为参数而是用一套内部编码。这套编码可能是数字ID也可能是拼音缩写。获取方式有两种手动整理打开目标网站的城市切换页面从URL或页面源码里找到城市对应的编码整理成字典。自动抓取如果网站有城市列表接口可以直接请求这个接口把返回的城市名和编码对应关系存下来。手动整理适合城市数量少的情况比如只关注十几个主要城市。如果要做全国范围建议走自动抓取的路子。这里有个经验城市编码一旦确定尽量存成独立的配置文件或常量字典不要硬编码在抓取逻辑里。后续增加城市时只需要改配置不用动主流程。CITY_MAP { 北京: 101010100, 上海: 101020100, 广州: 101280101, 深圳: 101280601, # 后续按需补充 }2.2 请求参数的构造逻辑历史天气接口的参数一般包含以下几个参数名含义示例值注意事项city_id城市编码101010100必须与城市名对应year年份2023部分接口只支持近十年month月份7通常为1-12type数据类型history区分历史与预报构造参数时最容易踩的坑是月份格式。有的接口要求传“202307”这样的六位字符串有的要求年份和月份分开传。这个必须通过实际请求测试确认不能想当然。另一个坑是时间范围限制。不少免费接口只提供最近几年到当前的数据更早的历史数据要么没有要么需要特殊处理。抓取前先用一个城市、一个月份做试探性请求确认返回数据结构正常再批量执行。2.3 请求头与访问频率控制直接裸请求很容易被识别为异常流量返回空数据或者直接拒绝。必要的请求头包括User-Agent模拟正常浏览器访问不要用默认的Python UA。Referer部分接口会校验来源页面带上目标网站的主页地址。Accept声明期望的响应格式通常设为application/json。频率控制同样关键。假设要抓取300个城市、每个城市12个月的数据总共3600次请求。如果每秒发10次6分钟就能跑完但这样大概率会触发限制。稳妥的做法是每次请求间隔0.5到1秒整体耗时拉长到半小时以上但成功率会高很多。import time import requests HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://example-weather-site.com/, Accept: application/json } def fetch_weather(city_id, year, month): url https://example-weather-site.com/api/history params {city_id: city_id, year: year, month: month} resp requests.get(url, paramsparams, headersHEADERS, timeout10) time.sleep(0.8) return resp.json()注意timeout参数一定要设否则遇到网络卡顿程序会一直挂在那里。10秒是个比较合理的值既能容忍正常波动又不会等太久。2.4 数据字段的提取与清洗接口返回的JSON结构通常是这样{ code: 0, data: { 2023-07-01: { temp_max: 35, temp_min: 26, weather: 晴, wind: 南风3级, aqi: 85 }, 2023-07-02: { temp_max: 33, temp_min: 25, weather: 多云, wind: 东南风2级, aqi: 72 } } }提取时需要关注几点日期格式统一有的接口返回“2023-07-01”有的返回“20230701”统一转成标准格式再存储。缺失值处理某些日期可能没有数据比如接口维护期间。遇到缺失不要直接跳过建议记录一条空值方便后续统计完整率。数值类型转换温度、AQI这些字段有时返回字符串有时返回数字统一转成数值类型再写入。清洗这一步看起来琐碎但直接决定了后续分析的便利程度。我个人的习惯是抓取和清洗分开做抓取阶段原样保存原始JSON清洗阶段再单独处理。这样万一清洗逻辑有问题不用重新抓一遍。3. 实操过程与核心环节实现3.1 环境准备与依赖安装这个项目对环境的依赖非常轻Python 3.7以上版本即可。需要安装的第三方库只有两个pip install requests pandasrequests负责发HTTP请求pandas负责数据整理和导出。如果打算存到数据库再加一个sqlalchemy。不需要selenium、不需要scrapy整个项目保持轻量。3.2 完整代码实现与逐段说明下面这份代码是核心抓取逻辑确实控制在20行左右import requests import pandas as pd import time CITY_MAP {北京: 101010100, 上海: 101020100, 广州: 101280101} HEADERS {User-Agent: Mozilla/5.0, Referer: https://example-weather-site.com/} def fetch(city_name, city_id, year, month): url https://example-weather-site.com/api/history params {city_id: city_id, year: year, month: month} resp requests.get(url, paramsparams, headersHEADERS, timeout10) data resp.json().get(data, {}) rows [{city: city_name, date: d, **v} for d, v in data.items()] time.sleep(0.8) return rows all_rows [] for name, cid in CITY_MAP.items(): for m in range(1, 13): all_rows.extend(fetch(name, cid, 2023, m)) pd.DataFrame(all_rows).to_csv(weather_history.csv, indexFalse)逐段拆解一下第1-3行导入依赖。pandas只在最后导出时用到如果改成写文件可以省掉。第5-6行城市映射和请求头。实际使用时城市列表会更长建议从外部文件读取。第8-14行核心抓取函数。构造参数、发请求、解析JSON、组装成行列表。**v是字典解包把温度、天气等字段直接展开到每一行里。第16-19行双层循环遍历城市和月份把所有结果汇总到一个列表。第21行转成DataFrame并导出CSV。这段代码能跑通但离“生产可用”还有距离。下面说说需要补强的地方。3.3 异常处理与重试机制上面的代码没有异常处理一旦某个请求超时或返回异常整个程序就中断了。实际使用中必须加上def fetch_with_retry(city_name, city_id, year, month, max_retry3): for attempt in range(max_retry): try: resp requests.get(url, paramsparams, headersHEADERS, timeout10) if resp.status_code 200: return parse_data(resp.json(), city_name) except Exception as e: print(f第{attempt1}次失败: {city_name} {year}-{month}, 原因: {e}) time.sleep(2) return []重试次数建议设为3次间隔2秒。超过3次还失败说明不是偶发问题记录下来后续单独处理。这里有个细节重试间隔不要用固定值可以改成递增比如第一次等1秒第二次等3秒第三次等5秒给服务器更多恢复时间。3.4 数据存储方案对比存储方式的选择取决于数据量和后续用途存储方式适用场景优点缺点CSV文件数据量小、一次性分析简单直接Excel可打开查询不便不适合频繁更新SQLite中等数据量、本地使用无需额外服务支持SQL并发能力弱MySQL/PostgreSQL大数据量、多人使用查询强大支持并发需要单独部署维护我个人的建议是如果只是做一次性的趋势分析CSV完全够用。如果要持续更新、反复查询直接上SQLite零配置一个文件搞定。数据量超过百万行再考虑迁移到专业数据库。3.5 抓取进度的可视化批量抓取耗时较长没有进度提示会让人心里没底。加一个简单的进度打印total len(CITY_MAP) * 12 current 0 for name, cid in CITY_MAP.items(): for m in range(1, 13): all_rows.extend(fetch_with_retry(name, cid, 2023, m)) current 1 print(f进度: {current}/{total} ({current/total*100:.1f}%))别小看这几行实际跑起来的时候能看到进度条往前走心里踏实很多。如果任务特别大还可以把进度写入日志文件方便中断后查看已完成的部分。4. 常见问题与排查技巧实录4.1 返回数据为空或字段缺失这是最常见的问题排查顺序如下检查城市编码是否正确用浏览器打开目标页面对比URL里的编码和代码里的是否一致。检查时间范围部分接口对历史数据的年份有限制比如只提供近5年。换一个较近的年份试试。检查请求头去掉Referer试试或者换一个User-Agent。有些接口对请求头校验比较严格。检查参数格式月份是传数字还是字符串年份是四位还是两位这些细节都会影响结果。如果以上都确认无误还是空数据大概率是接口本身变了。这时候需要重新打开浏览器开发者工具抓一次实际请求对比参数差异。4.2 请求被限制或封禁表现是连续请求几次后返回403或429状态码。应对措施降低频率把间隔从0.5秒调到1秒甚至2秒。轮换请求头准备多个User-Agent随机选用。分批次执行不要一次性跑完所有城市分成几个批次中间间隔几分钟。使用会话保持用requests.Session()保持连接模拟更自然的访问行为。session requests.Session() session.headers.update(HEADERS) resp session.get(url, paramsparams, timeout10)Session的好处是会自动保持cookies某些网站会通过cookie判断是否为同一会话用Session能降低被拦截的概率。4.3 数据格式不一致不同月份返回的字段可能略有差异比如某个月多了“空气质量”字段另一个月没有。直接转DataFrame会出现列对不齐的情况。解决办法是先用pandas的DataFrame构造函数它会自动对齐列缺失的填NaNdf pd.DataFrame(all_rows) df.fillna(, inplaceTrue)如果某些字段名在不同月份不一样比如“temp_max”和“max_temp”需要在解析阶段做统一映射。这个没有通用解法只能针对具体接口做适配。4.4 常见问题速查表问题现象可能原因解决方法返回空JSON城市编码错误核对编码用浏览器验证返回403请求头不完整补全User-Agent和Referer返回429请求频率过高增加间隔分批执行数据缺字段接口版本差异统一字段映射缺失填默认值程序中途崩溃未处理异常加try-except和重试机制CSV乱码编码问题导出时指定encodingutf-8-sig4.5 几个容易被忽略的细节时区问题如果抓取的是跨时区城市的数据日期可能会有偏差。建议统一按目标城市当地日期为准不要用服务器时间。闰年处理2月份有29天的情况循环月份时不需要特殊处理接口会自己返回正确天数。但如果自己构造日期列表就要注意这一点。数据去重如果分批抓取不同批次可能有重叠。写入数据库时用日期城市作为唯一键避免重复插入。日志记录每次请求的结果都记一条日志包括成功、失败、耗时。出问题的时候日志是唯一的线索。import logging logging.basicConfig(filenamefetch.log, levellogging.INFO) logging.info(f成功: {city_name} {year}-{month}, 耗时{elapsed:.2f}秒)5. 数据应用与扩展方向5.1 抓下来之后能做什么数据拿到手最直接的应用就是可视化分析。用pandas做月度统计用matplotlib画趋势图能直观看到某地全年的温度变化曲线、降雨集中月份、极端天气出现频率。这些分析对于农业规划、旅游行程安排、甚至个人穿衣指南都有参考价值。再进一步可以把多个城市的数据放在一起对比。比如对比南北方的温差变化、沿海和内陆的湿度差异。这种横向对比用表格和折线图呈现一目了然。5.2 扩展到更多数据维度历史天气只是起点。同样的思路可以扩展到空气质量数据很多天气接口本身就包含AQI字段直接抓下来即可。日出日落时间部分接口提供用于计算日照时长。历史预警信息如果有单独的预警接口可以一并抓取。扩展时注意保持数据结构的一致性新增字段尽量追加在原有字段后面不要打乱顺序方便后续合并分析。5.3 自动化更新方案如果需要定期更新数据可以把抓取脚本挂到定时任务上。Linux下用crontabWindows下用任务计划程序。建议每天凌晨执行抓取前一天的数据增量更新到数据库。# 每天凌晨2点执行 0 2 * * * /usr/bin/python3 /path/to/fetch_weather.py /path/to/cron.log 21增量更新时先查询数据库里已有的最大日期只抓取这个日期之后的数据避免重复请求。5.4 性能优化思路当城市数量增加到几百个、时间跨度拉到几年时串行抓取会非常慢。可以考虑多线程用concurrent.futures的ThreadPoolExecutor开5到10个线程并发抓取。注意控制总频率不要因为并发过高触发限制。异步请求用aiohttp配合asyncio单线程就能实现高并发。但代码复杂度会上升适合对性能要求高的场景。缓存机制已经抓过的城市和月份把结果缓存到本地下次直接读缓存不重复请求。我个人的经验是城市数量在50个以内串行加0.8秒间隔完全够用半小时内能跑完。超过100个再考虑并发否则调试成本不划算。5.5 合规使用的边界抓取公开数据用于个人学习和研究通常没有问题。但要注意几点不要高频请求影响网站正常服务不要将抓取的数据用于商业转售不要绕过网站的访问限制。控制频率、尊重服务条款是长期稳定获取数据的前提。实际使用中我习惯把请求间隔设得保守一些宁可多花点时间也不要因为频率过高被限制。毕竟数据抓取是个长期的事情稳定比速度重要。6. 实操心得与避坑总结6.1 先小范围验证再批量执行这是最重要的一条经验。不管代码写得多有信心第一次跑的时候一定先用一个城市、一个月的数据做验证。确认返回结构、字段名称、数据格式都符合预期再放开批量抓取。我见过太多次因为没做验证跑了几千次请求才发现字段名对不上白白浪费时间。6.2 原始数据一定要留底清洗后的数据方便分析但原始JSON建议也保存一份。原因很简单万一后续发现某个字段解析错了或者想提取之前没关注的字段有原始数据就不用重新抓。存储成本很低但省下的时间很可观。6.3 断点续传很实用批量任务最怕跑到一半中断。加一个简单的断点记录把已完成的城市和月份写入文件下次启动时跳过这些记录从断点继续。实现起来不复杂但能省下大量重复劳动。import os done_file done.txt done set() if os.path.exists(done_file): with open(done_file) as f: done set(f.read().splitlines()) # 抓取时判断 key f{city_name}_{year}_{month} if key in done: continue # 抓取成功后追加记录 with open(done_file, a) as f: f.write(key \n)6.4 字段命名要统一不同接口、不同月份的字段名可能不一致。建议在解析阶段就做统一映射比如把“temp_max”“max_temp”“最高温”都映射成“temp_max”。这样后续分析时不用再处理字段名差异DataFrame的列也是整齐的。6.5 定期检查接口可用性接口不是一成不变的。网站改版、接口升级都可能导致原有请求失效。建议每隔一段时间跑一次小规模测试确认接口仍然可用。如果发现异常及时调整参数或更换数据源。6.6 数据质量比数量重要抓取几千条数据如果里面大量缺失、格式混乱分析价值会大打折扣。与其追求覆盖所有城市不如先把重点城市的几年数据抓完整、抓准确。数据质量上去了后续分析的结论才可靠。7. 从20行到生产级的距离回到标题里的“20行代码”这20行确实能完成核心抓取功能但它是一个起点不是终点。从20行的原型到真正能稳定运行、持续更新的生产级脚本中间需要补上异常处理、重试机制、进度记录、数据校验、日志监控这些环节。不过话说回来原型阶段不需要一步到位。先用20行跑通流程验证数据源可用、数据结构符合预期然后再逐步加固。这种迭代方式比一开始就追求完美要高效得多。我自己的习惯是任何抓取项目先写最简版本跑通再根据实际遇到的问题逐个补强。这样每一步都有明确的动机不会过度设计。天气数据抓取本身技术难度不高但细节很多。把上面这些点都考虑到基本就能稳定运行了。后续如果要做更复杂的分析数据基础打好了上层应用就是水到渠成的事。