news 2026/10/2 9:53:28

天气数据爬虫实战:requests+JSON解析从城市编码到七日预报采集

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
天气数据爬虫实战:requests+JSON解析从城市编码到七日预报采集

1. 项目整体设计与选型思路

1.1 目标网站与数据源选择

先说点实在话。做爬虫,最忌讳一上来就爬那种加密参数满天飞、登录墙横着走的网站。天气数据是公开信息,结构化程度高,更新频率稳定,而且每个城市都有自己的独立标识,天生就是练手的绝佳素材。我当年就是因为在出行APP上逐个手动查五个城市七天的天气,翻来找去实在烦了,才动了写爬虫的念头。

这次选的目标网站是国内公开的天气信息站点,每个城市都有对应的9位城市编码,例如北京是101010100,上海是101020100。整个站点同时提供了HTML页面和JSON数据接口。我最终选择了基于JSON接口来做数据抓取,理由很简单:接口返回的数据已经结构化好了,省去了用正则满天飞找字段的麻烦。但为了照顾想看页面解析的朋友,我也会在后面讲一下HTML解析的备选方案。

选择这个网站还有个私心:它有所有城市的列表页,而且页面层级很清晰——从省份,到市,再到区县,层级分明。这正好用来练习“列表页→详情页”的两级爬虫架构。这个结构在很多真实项目里都能遇到,比如电商爬分类再爬商品,论坛爬版块再爬帖子,学会了这套,以后换个目标站点思路完全一样。

需要注意的是,爬取公开天气数据仅供个人学习和研究用途。我在代码里故意把请求间隔调得偏大,频率控制得很保守,目的也是想给大家传递一个习惯:爬虫要学会克制。你抓数据越快,被封概率越高,对方服务器压力越大,得不偿失。

1.2 技术栈选型:requests + BeautifulSoup 还是另辟蹊径?

这个项目我用的主力库是requests+json模块。有人会问:为什么不用Scrapy?我的回答是,对于这个量级的项目,Scrapy是大炮打蚊子。2000多个城市的七日天气预报,用requests加线程池,十几秒就能跑完全国。Scrapy的学习曲线陡峭,安装依赖多,迁到分布式还得部署Redis,不适合入门阶段。

再用到concurrent.futures.ThreadPoolExecutor做并发下载,用csv或pandas把结果落盘。整个项目用纯标准库加一个requests就能完成,我甚至连BeautifulSoup都没用到,因为走的是JSON接口。如果你非要用页面解析,那么我建议搭配lxml解析器,性能远高于默认的html.parser。

关于Selenium,别一上来就上它。Selenium这东西会起一个真实浏览器,内存占几百MB,速度还慢,爬2000个城市得跑到天荒地老。做爬虫的顺序永远应该是:先用抓包工具看有没有JSON接口,有的话绝对优先走接口;实在没有,再考虑解析HTML;最后才轮到渲染引擎。我见过太多新手一碰到难题就不管三七二十一塞个Selenium进来,结果数据是抓到了一点,但性能差到让人怀疑人生。

1.3 项目结构预览

写爬虫之前,先规划好目录,别什么都往一个文件里塞。我最后的项目结构是:

weather_spider/ ├── cities.py # 城市编码抓取与解析 ├── fetcher.py # 请求与重试逻辑 ├── parser.py # 天气数据解析 ├── storage.py # 数据存储(CSV/Excel) ├── main.py # 主流程入口 └── requirements.txt # 依赖清单

这个结构虽然简单,但每个模块职责清晰:cities.py负责搞定城市列表,fetcher.py只负责发请求,parser.py负责做解析,storage.py管存储。这样做的好处是什么?就是你后来想换目标站点,比如改成爬股票,只需要改cities.py和parser.py,请求和存储完全不用动。这就是分层的好处。

2. 核心实现细节与踩坑实录

2.1 获取全国城市编码表:别傻傻地手工整理

全国城市编码哪来的?肯定不是你一个一个手工输进去的,哪怕有现成的文本,你也得知道这个编码体系长什么样。我刚开始是先找了一个开源的城市编码JSON文件,但它不够新,有些县级市已经变了。最后决定自己去爬。

我去了站点的城市列表页,结构大致是:进入首页,能看到所有省份的入口;点进某个省份,能看到该省所有地市的入口;再往下一层,是区县列表。每一层的链接URL里都包含着城市ID,例如https://www.某某weather.com.cn/weather/101010100.shtml,这个101010100就是城市编码。

写代码时我理清了爬取步骤:

  • 第一步,请求省份列表页,用正则直接把[a-z]+\.html形式的链接和省份名抓出来;
  • 第二步,依次请求每个省份页面,同样拿城市链接和城市名称;
  • 第三步,对每个城市页面,再往下挖一级区县;
  • 最后,把所有三级的编码和名称存成cities.csv,字段是city_code,province,city,district。

这里我不放全部代码了,关键片段如下:

import re import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Referer": "https://www.weather.com.cn/" } source_url = "https://www.weather.com.cn" # 示例,实际使用时替换为目标站点 def get_provinces(): resp = requests.get(source_url + "/city/index.html", headers=headers, timeout=10) resp.encoding = "utf-8" html = resp.text # 提取各省份链接和名称 pattern = re.compile(r'<a href="(.*?)"><img[^>]*>(.*?)</a>') provinces = pattern.findall(html) return provinces[:30] # 省份数量有限,取前30个

实际爬的时候,我发现城市名的清洗是个坑。有的省份下会出现“省直辖县级行政区划”这种兜底目录,点进去是一大堆县;还有的会带“市辖区”字样,没有实际的天气编码,过滤掉就好。另外,有些名称里有类似&nbsp的实体编码,记得用html.unescape处理一下。

2.2 七日天气预报的数据请求与字段解析

城市列表拿到手,接下来说说详情页数据怎么取。我采用的是站点的公开JSON接口,URL格式大概是这样的:https://www.某某weather.com.cn/weather_7d/101010100.html。返回的不是一个纯JSON,而是一段带回调函数的文本,形如:

var citydata = { "城市": "北京", "天气": [...] };

这在技术上叫JSONP,目的是为了跨域调用。对爬虫来说,我们要做的是删掉前面的var citydata =和末尾的分号,剩下的部分再交给json.loads解析。

解析代码示例:

import json def parse_weather(resp_text): # 去除JSONP包装 split_text = resp_text.split("=", 1)[1].strip() data = json.loads(split_text.strip().rstrip(";")) # 实际上返回结果里通常有 forecast 或 data 字段 forecast = data.get("data", []) or data.get("forecast", []) result = [] for day in forecast: result.append({ "date": day.get("date"), "high_temp": day.get("temp_l"), "low_temp": day.get("temp_h"), "weather": day.get("w_text"), "wind": day.get("wd"), }) return result

这里有个新手极易踩的坑:接口里可能同时存在temp_l和temp_h,你可以通过字段名猜出低温和高温,但不同接口字段名不一样,有的叫dayTemp,有的叫nightTemp。建议先用一个城市测试一遍,把返回的全部键名打印出来,再用json.dumps(data, ensure_ascii=False, indent=2)仔细看,确认哪个字段对应哪个值。别偷懒,这一步值得花十分钟。

另外,编码问题很恶心。有些接口回的是GBK编码的文本,你用resp.text直接拿会乱码。稳妥的做法是看响应头里的Content-Type,如果有charset=gbk,就手动设置resp.encoding = "gbk"。我写了一个通用小函数,自动检测编码:

import chardet def decode_response(resp): if resp.encoding.lower() in ("utf-8", "gbk"): return resp.text detected = chardet.detect(resp.content) return resp.content.decode(detected.get("encoding", "utf-8"), errors="replace")

chardet这个库很方便,但也要注意,它检测大文本时会有点慢,所以只在不确定编码时才启用。

2.3 请求伪装与反爬虫初探

写爬虫的人迟早会遇到反爬。我第一次快速爬取时,跑到第500个城市就遇到HTTP 403了,明明前面200个都好好的。这说明站点在请求频率上做了限制。解决思路我梳理成了三层:

第一层是基本伪装。给每个请求带上完整的浏览请求头,至少包括User-Agent、Accept、Accept-Language和Referer。Referer得和详情页来源一致,不能空着,也不能填一个不相干的网址。我见到有人喜欢用随机UA库,结果随机到了几年前的IE UA,反而更容易被识别。建议维护一个两三个较新UA的小池子,轮换用即可。

第二层是频率控制。在每次请求前随机等待0.2到1秒。别小看这几百毫秒,它能把单位时间内的请求量摊薄到完全不触发风控的水平。我没用固定延迟,因为固定延迟本身也会暴露机器行为的特征——每个请求之间都精确相等,在站点日志里一眼就能看出来。用time.sleep(random.uniform(0.2, 1))就好。

第三层是失败后的退让。如果返回了403,不要立即重试,建议把这个城市编号丢进一个“待重试”列表,等整轮爬完后再用一个更慢的速度二次请求。如果第二次还是403,就放弃,记录到日志里。真实项目里,抓不到的数据有时需要换个代理IP才能解决,但入门阶段不必搞代理池那么重,先学会减速最重要。

这里还要提一个观念上的东西:不要认为反爬是刁难。站点设置限流,本质是保护自己服务器不被打挂。你作为爬虫开发者,如果能主动控制请求速率,大概率永远碰不到风控。我有个朋友一开始无脑并发30线程抓同一个小网站,结果对方运维直接给他IP封了。后来他换了个角度,每天只固定跑一次,再也没出过问题。爬虫不是偷东西,是礼貌地请求公开资源。

3. 完整实操过程:从单城市到全国城市批量抓取

3.1 边写边测:单城市爬虫跑通

先别想着全国,跑通一个城市再说。我习惯先写一个最简单的单城市脚本,跑通后再往上堆功能。这样定位问题特别快,不至于写了两百行代码一发运行全是红色异常,根本不知道哪儿错了。

拿北京编码101010100测试,代码长这样:

import requests import json import time headers = { "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/120.0 Safari/537.36", "Accept": "application/json, text/javascript, */*; q=0.01", "Referer": "https://www.weather.com.cn/weather/101010100.shtml", } url = "https://www.weather.com.cn/weather_7d/101010100.html" resp = requests.get(url, headers=headers, timeout=5) resp.encoding = "utf-8" # 注意resp.text里面是JSONP格式 raw = resp.text json_str = raw.split("=", 1)[1].strip().rstrip(";") data = json.loads(json_str) # 观察结构 print(data.keys()) print(data.get("city")) for d in data.get("data", []): print(d)

第一次运行完,我把返回的内容打印出来,眼睛都花了,因为除了未来七天的数据,接口还带了一堆实况、空气质量之类的东西。不过没关系,打印出来之后就是纯信息筛选的事了。我第一次跑的时候,一晚上都在调这个输出格式,反复比对哪个字段是白天温度、哪个是夜间温度。忘了说,有些接口里temp_h和temp_l含义可能和你想的相反,以官网页面显示为准。我后来干脆对照网页上同一城市的显示值,反向确认字段含义,这种方法最可靠。

单城市跑通后,我又做了一步封装,把parse_weather函数做成独立模块,将来任何城市都可以复用。同时我还加了简单的日志打印,比如[OK] 北京 2024-06-01 多云 25°C/16°C这种格式。看控制台刷起来,心里特别有成就感。

3.2 多线程批量抓取全国城市

单城市跑通是热身,真正的战役是全国。全国大概有2400多个县级行政区,就算每个请求0.5秒,串行也得20分钟。这个速度不是不能忍,但遇到网络抖动重试一次就得再等,体感很差。我用线程池一改,并发数调到20,实测下来全部跑完只用了一分多钟——有的请求快的0.3秒,慢的也就1秒,线程多了吞吐量直接起飞。

核心代码非常短:

from concurrent.futures import ThreadPoolExecutor, as_completed def fetch_one(city): code, name = city try: weather_list = fetch_city_weather(code, name) return (code, name, weather_list) except Exception as e: logging.warning(f"抓取失败: {name}, 原因: {e}") return (code, name, None) with ThreadPoolExecutor(max_workers=20) as executor: futures = [executor.submit(fetch_one, c) for c in city_list] for future in as_completed(futures): code, name, weather = future.result() if weather: save_one(code, name, weather)

这里我得认真说一下并发数的选择。20是一个比较稳的值。别看到有线程就开心,开到100,你的带宽不够,对端服务器也扛不住,很多连接直接超时,反而更慢。我在测试中对比过:

并发数2000城市耗时结果
1约25分钟稳定,无失败
10约3分钟稳定,极少量超时
20约1分20秒稳定,少量超时(重试后成功)
50约40秒有10%请求超时/拒绝
100约20秒大量超时,还被短暂拦截

数据很明显,20是一个甜点。如果你想让别人觉得你很含蓄,就用10;如果是自己练手,20完全OK。关键是别贪。

还有就是线程写共享的CSV文件时要加锁。我一开始直接把2000个结果往同一个CSV里写,结果最后发现行数不对,有一些行被覆盖了。后来用了一个threading.Lock包裹写文件的部分,问题解决。如果你用pandas的pd.concat最后统一写,那就不用加锁,但内存会稍微大一点,2000条这个量级无所谓。

3.3 数据落地与可视化

数据落盘我做了两种格式:CSV和Excel。CSV是通用格式,可以无缝导入Excel、数据库、数据分析工具;Excel的话,给非程序员朋友展示更方便。

CSV版很简单:

import csv def save_to_csv(all_data, filename="weather.csv"): with open(filename, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["城市编码", "省份", "城市", "日期", "天气", "最高温", "最低温", "风力"]) for row in all_data: writer.writerow(row)

注意要用utf-8-sig而不是utf-8,否则生成的CSV用Excel打开时,表头和正文会全乱码。这个坑我踩过,那次熬到半夜以为是数据问题,折腾半天发现只是编码的锅。

Excel版我用的是pandas+openpyxl:

import pandas as pd df = pd.DataFrame(all_data, columns=["city_code", "province", "city", "date", "weather", "high_temp", "low_temp", "wind"]) df.to_excel("weather_forecast.xlsx", index=False, sheet_name="七日天气")

pandas的强大之处是后续分析特别方便。比如你拿到了全国城市某一日的最高气温,可以直接用df.groupby("province")["high_temp"].max()看省份极值,也可以筛选出所有当天降雨的城市列表。作为项目演示,我用matplotlib画了几座主要城市的七日最高温折线图,代码非常简单:

import matplotlib.pyplot as plt cities = ["北京", "上海", "广州", "成都"] for city in cities: city_df = df[(df["city"] == city)] plt.plot(city_df["date"], city_df["high_temp"], label=city) plt.legend() plt.xticks(rotation=45) plt.ylabel("最高温 (°C)") plt.tight_layout() plt.savefig("temp_trend.png")

这个图一画出来,整个项目看起来像个端正的技术作品了。其实做爬虫项目,数据可视化不是必须的,但它能帮你快速发现数据解析的问题。比如如果某个城市的日期顺序是乱的、或者温度忽高忽低像个锯齿山,一定是数据解析或者拼接出了问题。可视化是爬虫项目最好的Debug工具。

4. 常见问题排查与性能优化实录

4.1 遇到HTTP 403/418被拦截:从单次到分布式

在我迭代过程中,最常见的错误码就是403和418。403代表服务器拒绝请求,418代表服务器认为你是机器人。出现这两个码,基本就一个原因:请求特征太明显。

排查步骤我给个清单:

  • 第一,检查请求头。是不是没带完整User-Agent?是不是没加Referer?加完之后立刻重试。
  • 第二,检查请求频率。连续请求间隔是否过短?把这个城市放到后面再跑,或者直接加随机延迟。
  • 第三,检查Cookie。有些站点你在浏览器能够正常打开,是因为已经种了Cookie;爬虫不带Cookie可能被拒。这种情况用requests.Session()保持会话,先访问一次首页拿到初始Cookie,再请求详情页。
  • 第四,检查IP。单IP短时间内大量请求,被临时封了。此时没有代理池就别硬撑,歇几分钟再跑。如果非要代理池,建议研究一下requests的proxies参数配合免费代理源,但免费代理稳定性极差,入门阶段别跳这个坑。

这些绝大多数情况下都能解决问题。实在解决不了,还有最后一招:把网站页面打开,用浏览器自带开发者工具手动看一次完整请求,对比你的代码里缺少了哪些关键参数。这个方法适合任何未知反爬场景,屡试不爽。

4.2 数据解析为空:正则、JSON转义、动态加载

爬到一半发现某些城市解析出来的数据是空的,或者某些字段莫名其妙失踪了。这种情况多半不是你代码的问题,而是数据源本身返回的内容形态不统一。比如有的城市没有空气质量数据,接口里就直接没有那个字段;有的城市未来第七天返回了空值,不给你温度。这时候,你的解析逻辑里要用.get("xxx", "")而非["xxx"],因为后者遇到缺失直接抛异常。

还有JSONP的转义问题。某些城市接口返回的字符串里包含了特殊字符,比如\'这种,直接json.loads会报错。解决办法是先把字符串里的\\'替换成',或者干脆用更宽松的方式提取。但注意,这不是万能的,核心原则是:先打印原始响应文本看30秒再动手改解析逻辑。

还有一类动态加载的页面。如果你发现用requests拿到的HTML里根本找不到天气数据,那很可能是页面通过JavaScript异步请求了接口。这就要用抓包工具来找真正的数据接口了。我个人推荐的抓包工具是浏览器开发者工具的Network面板。你用浏览器打开目标页面,Network里面可以看到页面发起的全部网络请求,逐个看响应,找到包含天气数据的那个请求,然后直接模拟它。这一套操作是爬虫的基本功。

4.3 单点失败导致整个循环崩溃:异常重试机制

写爬虫,尤其是批量爬2000个城市,最怕的是运行到一半因为一个城市超时导致整个程序退出。一开始我写得很天真,没有try-except,结果进程在500个城市处崩掉,一切从头再来。后来我搞了个可靠的重试机制。

我封装了一个fetch_with_retry函数:

import time from functools import wraps def retry(max_retries=3, delay=1): def decorator(func): @wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if attempt < max_retries - 1: time.sleep(delay * (attempt + 1)) else: raise return None return wrapper return decorator @retry(max_retries=3, delay=2) def fetch_city_weather(code, name): # 原有逻辑 ...

细节在于:每次重试等待时间递增。第一次失败等2秒,第二次失败等4秒,第三次还失败就不等了,直接记失败。这样既不会立刻重试导致连环超时,也不会傻傻地重试3次都集中在同一秒内。

另外,重试逻辑一定要在外层,不要把整个批量循环包在try里,否则一个坏城市照样拖垮一切。正确做法是每个任务独立捕获异常,保证一个城市挂了不影响其他城市。这也是线程池方案天然的优势,每个submitted任务都是隔离的。

4.4 性能提升:并发、异步、速度对比表

关于性能优化,上面提到了并发,现在补充异步方案。实际用下来,爬虫场景下asyncio比ThreadPoolExecutor更快,因为IO密集型的请求大部分时间都在等待网络响应,异步单线程就能利用这段时间并发处理其他任务。但异步代码写起来复杂,回调、任务队列、限流都得自己控制,对新手不太友好。

我建议的顺序是:先用简单多线程把项目跑通,数据没问题之后再去研究异步。如果你直接上手异步,遇到解析问题又遇到并发问题,两件事搅在一起排查会很痛苦。我最终给这个项目加了一个aiohttp异步版本,代码量多了一些,但并发性能整体比线程池再快30%左右。完整版代码就不放上来了,核心思路是通过asyncio.Semaphore控制并发数,避免一口气发出2000个请求。

到最后整个项目稳定跑起来,我实测的结果是:

  • 串行:约25分钟,零失败
  • 线程池(20并发):约1分20秒,重试两次
  • 异步(20信号量):约55秒,无失败

写到这里,我想说句经验之谈:爬虫项目的性能优化是没有穷尽的,但入门阶段要懂得适可而止。在我个人看来,能用简单方式解决就别为了炫技引入复杂方案。多得是花了三天把线程池改异步,结果收益只有30秒,还把代码维护性搞差了。

这个天气爬虫做完之后,我最大的体会是:爬虫不是“复制粘贴网页”,而是对整条数据链路的一遍遍梳理。从城市列表的清洗,到接口字段的确认,再到并发控制、异常恢复、数据落盘,每一步都考验耐心。你把这套流程走完,以后再碰到网站看上去很乱的爬虫任务,心态会很稳。继续深挖的话,还可以把这个脚本放到服务器上定时运行,配合数据库做出历史天气统计,甚至可以做一个简单的查询网页,这些都是同一个逻辑的自然延伸。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 9:52:58

Python演唱会数据分析可视化:大作业完整实战指南

简介&#xff1a;一套完整的Python演唱会数据分析与可视化大作业源码&#xff0c;面向高校学生、课程设计者及数据分析入门者&#xff0c;解决从数据获取到业务展示的全流程实践需求。项目以演唱会数据为对象&#xff0c;先用爬虫自动化抓取网页信息&#xff0c;再用pandas完成…

作者头像 李华
网站建设 2026/10/2 9:51:45

ReportService配置要点:数据源、模板路径与热更新全解析

做后端时间久了&#xff0c;总会遇到几个需要单独花半天时间去理清配置的服务&#xff0c;ReportService就是典型的一个。它不是那种装完就能忘的组件&#xff0c;而是和业务报表强耦合、动不动就因为在某个环境里少配了一个路径、漏了一条数据库连接而翻车的服务。这篇文章就是…

作者头像 李华
网站建设 2026/10/2 9:51:44

六类城市场景移动目标联合检测数据集与实战指南

简介&#xff1a;本资源是面向智能交通、智慧物流与无障碍设施管理等垂直场景的目标检测专用数据集&#xff0c;聚焦背包、自行车、行人、行李箱、手推车、轮椅六大类生态环境目标&#xff0c;专为YOLO系列模型训练优化设计。数据集共1615张高质量JPG图像&#xff0c;配套1615个…

作者头像 李华
网站建设 2026/10/2 9:51:27

Angular老项目升级全流程与避坑指南:从AngularJS到新版迁移实践

老项目升级这事儿&#xff0c;干过的人都知道&#xff0c;表面上是换个版本号&#xff0c;实际上是把一整套技术选型、依赖关系、写法习惯全盘翻新一遍。Angular 更是其中的硬骨头&#xff1a;从 AngularJS 1.x 到 Angular 2 是推倒重来&#xff0c;之后每个大版本又都带着一堆…

作者头像 李华
网站建设 2026/10/2 9:51:17

AI编程Skills实战指南:从安装到自写的完整教程

1. 先拆解&#xff1a;skills到底是什么&#xff0c;和插件、Agent提示词有什么本质区别最近群里聊AI编程&#xff0c;几乎每三天就有人问一句“skills到底怎么装”。我一开始也以为这是某个新出的IDE插件&#xff0c;直到自己动手在Claude Code里跑了几个skills&#xff0c;才…

作者头像 李华