news 2026/9/25 7:35:37

Python采集中国天气网天气数据:JSON接口与城市ID实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python采集中国天气网天气数据:JSON接口与城市ID实战

简介:资源包内含一套基于Visual Studio 2008的C# Windows Forms完整工程,面向需要接入中国天气网公开API的初级开发者,解决从构造HTTP请求、解析JSON/XML到界面展示的关键流程。压缩包共34个文件,主要包括9个.cs源码文件、3个.dll库文件、3个.exe可执行程序,以及配置文件、资源文件和项目工程文件,整体仅259KB,结构精简便于运行调试。已有2308人学习下载。工程演示了通过HttpWebRequest发送请求并接收响应,使用JavaScriptSerializer解析JSON或用XDocument处理XML,同时说明API密钥注册与管理要点。代码封装了天气数据获取方法,能够提取温度、湿度、风向等信息,并配有Windows窗体界面示例,可快速迁移到其他项目。对刚接触网络编程或想基于VS2008实现天气功能的读者,这是一份可运行、可参考的实战模板。

1. 中国天气网获取天气数据:不靠解析 HTML,靠的是它藏在 JS 里的 JSON 接口

“中国天气网获取天气数据”这件事,最容易被新手带偏的地方,是一上来就对着www.weather.com.cn的 HTML 页面写正则。实际做过一轮就会明白:页面结构半年能改三次,而藏在页面背后的 JSON 接口和内嵌 JS 变量反而稳定得多。这篇文章要讲的,是一条我自己跑通过的路线——先用城市 ID 锁定目标,再分别拿实时数据、今日概况和七天预报,最后把采集做成带重试、校验和存储的小任务。适合谁:要做天气展示页面、气象数据分析、本地小仪表盘,又不想接付费天气 API 的开发者。后面所有代码基于 Python 3.8+,依赖只有requests和beautifulsoup4。

2. 先搞懂中国天气网的数据源:城市 ID 和三个关键接口

2.1 城市 ID 从哪来:页面、JS 文件与 city3jdata 三级接口

中国天气网所有天气数据的入口都不是城市名,而是一个 9 位数字城市 ID,比如北京是101010100,上海是101020100。网页端搜索框输入“海淀”,跳转到的 URL 里就是101010200之类的 ID。9 位 ID 的常见规则是:前三位101固定,接着两位省代码、两位市代码、两位区县代码。但这条规则只适合做校验,不适合直接拼——直辖市、省直辖县级市、新设立的区经常不按连号走,手拼出来的 ID 有一半是查不到数据的。

安全的做法是用网站自身的分级接口去查。城市数据分三级:省份、地级市、区县,对应三个接口:

import requests headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0 Safari/537.36" } # 第一级:省份列表,返回 {"01": "北京", "02": "上海", ...} province_resp = requests.get( "http://www.weather.com.cn/data/city3jdata/province.html", headers=headers, timeout=10 ) provinces = province_resp.json() print(provinces["01"]) # 北京 # 第二级:某省份下的城市,返回 {"0101": "北京", ...} city_resp = requests.get( f"http://www.weather.com.cn/data/city3jdata/stat/{'01'}.html", headers=headers, timeout=10 ) cities = city_resp.json() print(cities["0101"]) # 北京 # 第三级:某城市下的区县,返回 {"0101": "海淀", ...} station_resp = requests.get( f"http://www.weather.com.cn/data/city3jdata/station/{'0101'}.html", headers=headers, timeout=10 ) stations = station_resp.json() print(stations["0101"]) # 海淀

这段代码的逻辑是逐级往下查,每一级返回的都是{"代码": "名称"}的 JSON 对象。省代码是两位,市代码是省代码加城市两位,区县代码是市代码加区县两位。最终拼成城市 ID 时,规则是"101" + 省代码 + 市代码 + 区县代码。用上面的例子:101+01+01+01对应海淀101010101,但海淀在网页端的实际 ID 是101010200,说明第三级 station 返回的区县代码和实际数据服务 ID 并不总是一一对应。所以这个接口真正可靠的用法是拿前两级确认省和市,区县一级只作参考,最终 ID 以页面 URL 里的为准。

2.2 三个核心接口:实时天气、今日概况与七天预报

拿到城市 ID 之后,数据接口分三类,覆盖不同需求:

接口 path返回内容适合用途注意点
/data/sk/{city_id}.html实时温度、风向、风力、湿度、气压仪表盘实时显示老接口,字段名是 WD/WS 这种缩写
/data/cityinfo/{city_id}.html今日最高/最低温、天气现象、发布时间今日概览卡片temp1 是最低温,temp2 是最高温,容易写反
/weather/{city_id}.shtml七天预报完整页面七天趋势数据在页面内嵌 JS 变量里,需要正则提取

实时接口和概况接口返回的是标准 JSON,七天预报的.shtml是一个完整 HTML 页面,数据不在 HTML 标签里,而是嵌在<script>标签的 JS 变量中。这个差异决定了后面解析方式完全不同:前两个用resp.json()一步到位,七天预报需要用正则先取出 JS 变量再json.loads。

这三个接口都不需要登录、不需要 token、不需要签名参数,唯一的硬性要求是请求头里带一个正常的User-Agent。不带 UA 的请求偶尔会返回 200 但内容为空,这是网站反爬最轻的一层,骗过它不需要什么技巧。

2.3 为什么选 JSON 接口而不是直接解析 HTML

很多人第一次做这个需求,下意识就去抓http://www.weather.com.cn/weather/101010100.shtml,然后在 BeautifulSoup 里找.tem、.win这些 class。这个方案的致命问题不是写不出来,而是维护不住:页面改版一次,class 名换一批,你的解析代码就要重写。JSON 接口的字段虽然也变,但变动频率低得多,而且老接口即便字段冗余,也不会随意删。

另一个维度是请求体积。一个.shtml页面加图片、广告占位、统计脚本,动辄几十 KB,而/data/sk/接口返回的 JSON 只有几百字节。如果你要监控几十个城市,JSON 接口在带宽和解析耗时上的优势是量级的。后面所有的实现都基于这个选型:能拿 JSON 就不碰 HTML,页面只用来提取七天预报里那段 JS 变量。

3. 用 Python 跑通最小采集脚本:从城市 ID 到天气 JSON 落盘

3.1 先写一个查城市 ID 的工具函数

实际项目中我不会每次运行都去查三级接口,城市的 ID 变化不频繁,查一次存成本地配置文件更合理。但第一次写脚本时,这个工具函数能帮忙确认手头 ID 是否有效。把上一步的三级查询包成一个函数:

import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0 Safari/537.36" } def query_city_id(province_code: str, city_code: str) -> str: """根据省市代码拼出9位城市ID,并验证该ID是否有实时数据。""" base = f"101{province_code}{city_code}00" # 用实时接口做验证,404或报错说明ID不可用 check_url = f"http://www.weather.com.cn/data/sk/{base}.html" resp = requests.get(check_url, headers=HEADERS, timeout=10) if resp.status_code != 200: return None data = resp.json() if data.get("weatherinfo", {}).get("cityid") != base: return None return base print(query_city_id("01", "01")) # 北京 => 101010100

函数参数是两位省代码和两位市代码,内部直接拼"101" + 省 + 市 + "00",然后用实时接口验证。这里有一个容易被忽略的细节:北京这种直辖市的区一级代码是00,数据挂在它下面;普通地级市的市区也是00结尾。校验时拿返回的cityid字段和拼接结果比对,能过滤掉一部分“接口返回了 JSON 但实际是错误页”的情况。这个函数返回值如果是None,就说明省市代码配错了,需要回到 city3jdata 接口去查。

3.2 实时天气与今日概况:两个最稳的 JSON 接口

城市 ID 确定之后,采集主体代码只有十几行。下面这版把实时数据和今日概况一起抓下来,并打印关键字段:

import requests import json from datetime import datetime CITY_ID = "101010100" # 北京 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0 Safari/537.36" } def fetch_weather(city_id: str) -> dict: sk_url = f"http://www.weather.com.cn/data/sk/{city_id}.html" info_url = f"http://www.weather.com.cn/data/cityinfo/{city_id}.html" sk_resp = requests.get(sk_url, headers=HEADERS, timeout=10) sk_resp.encoding = "utf-8" sk_data = sk_resp.json()["weatherinfo"] info_resp = requests.get(info_url, headers=HEADERS, timeout=10) info_resp.encoding = "utf-8" info_data = info_resp.json()["weatherinfo"] record = { "city": sk_data["city"], "cityid": city_id, "temp_now": float(sk_data["temp"]), # 当前温度 "wind_dir": sk_data["WD"], # 风向 "wind_level": sk_data["WS"], # 风力 "humidity": sk_data["SD"], # 相对湿度 "temp_low": info_data["temp1"], # 今日最低温 "temp_high": info_data["temp2"], # 今日最高温 "weather": info_data["weather"], # 天气现象 "fetch_time": datetime.now().isoformat() # 本地采集时间 } return record if __name__ == "__main__": print(json.dumps(fetch_weather(CITY_ID), ensure_ascii=False, indent=2))

这段代码的逻辑分三步:请求实时接口、请求概况接口、合并字段。sk_data["temp"]返回的是字符串,比如"23"或"-8",所以转成float方便后续计算。temp1和temp2同样是字符串,里面带了单位℃,我没有在这里转换,因为后面清洗章节会统一处理。

这里要解释两个参数:timeout=10是必须的,中国天气网的接口在极端天气时偶尔会慢,不设超时会让采集任务挂死;resp.encoding = "utf-8"是主动声明编码,避免 requests 对部分接口返回的 GBK 内容误判成 Latin-1 导致乱码。如果你在 Windows 终端打印中文乱码,通常是终端编码问题,不是接口问题,把输出重定向到文件检查即可。

3.3 七天预报:从页面内嵌 JS 变量里提取

七天预报没有公开的纯 JSON 接口,常规做法是抓.shtml页面,然后从页面里的hour3dataJS 变量中提取。这个变量存放未来 48 小时和 7 天趋势的数据,结构是城市 ID 作为键,里面带weatherList之类的数组。用正则把它抠出来再解析:

import requests import re import json def fetch_forecast_7d(city_id: str) -> list: url = f"http://www.weather.com.cn/weather/{city_id}.shtml" resp = requests.get(url, headers=HEADERS, timeout=15) resp.encoding = "utf-8" html = resp.text # 页面内嵌的JS变量,包含7天数据 match = re.search(r"var hour3data=(\{.*?\});", html, re.S) if not match: return [] raw = json.loads(match.group(1)) city_key = str(city_id) if city_key not in raw: return [] # 从数据里取出7天趋势列表 weather_list = raw[city_key].get("weatherList", []) result = [] for item in weather_list: result.append({ "date": item.get("date", ""), "temp_low": item.get("low", ""), "temp_high": item.get("high", ""), "weather": item.get("wth", ""), "wind": item.get("wd", ""), }) return result if __name__ == "__main__": for day in fetch_forecast_7d("101010100"): print(day)

re.search(r"var hour3data=(\{.*?\});", html, re.S)这段正则是整个函数最核心的部分:\{.*?\}是非贪婪匹配,re.S让.能匹配换行符,保证 JS 变量跨行也能取全。取出来的字符串是一个 JSON 对象,键是城市 ID,值里嵌套了weatherList数组。

这个提取结果有个坑:weatherList里包含的不只是 7 天,有些城市会带 6 小时间隔的多组数据,打印出来可能超过 7 条。实际使用时要按date字段去重,只保留每天最后一条或第一条。另外low和high是纯数字字符串,weather是中文描述,wd是风向,字段名和实时接口完全不一样,别套用同一套解析逻辑。

4. 把采集脚本升级成数据任务:轮询频率、存储与失败重试

4.1 轮询频率怎么定:气象数据的时效边界

能抓到数据之后,第一个要决定的是多久跑一次。气象数据有天然的时效边界:实时温度每 5~10 分钟更新一次,今日概况每天只在几个固定时间点刷新,七天预报每小时全量重算。没必要用同一套频率去刷三个接口,我一般按下面的表格配置:

数据推荐轮询间隔依据
实时温度/风力5 分钟数据源本身约 5 分钟刷新一次
今日最高/最低温30 分钟一天内只变几次,30 分钟足够覆盖
七天预报1 小时上游预报每天更新数次,小时级足够

具体调度我用一个简单的while+sleep循环加时间戳判断,不用引入 Celery 这类重框架。如果你要跑在服务器上,更稳妥的办法是系统 crontab 或systemd timer,这样采集进程崩溃了会被系统拉起,而不是靠脚本内部自愈。

4.2 存储方案:SQLite 一张表 vs CSV 按天分文件

数据量小的时候,CSV 按天分文件是最直观的方案,文件名带日期,每天一个文件,排查问题方便。但 CSV 有两个问题:并发写入会丢数据,按日期查询要跨文件。采集任务通常单线程跑,并发问题不大,可如果你后面要分析历史趋势,SQLite 一张表省事得多。我推荐最小化方案:SQLite 单表,时间戳做主键索引。

import sqlite3 from datetime import datetime DB_PATH = "weather.db" def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute(""" CREATE TABLE IF NOT EXISTS weather_hourly ( fetch_time TEXT NOT NULL, city_id TEXT NOT NULL, temp_now REAL, temp_low REAL, temp_high REAL, humidity REAL, wind_dir TEXT, weather TEXT, PRIMARY KEY (fetch_time, city_id) ) """) conn.commit() conn.close() def save_record(record: dict): conn = sqlite3.connect(DB_PATH) conn.execute(""" INSERT OR REPLACE INTO weather_hourly (fetch_time, city_id, temp_now, temp_low, temp_high, humidity, wind_dir, weather) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( record["fetch_time"], record["cityid"], record["temp_now"], record["temp_low"], record["temp_high"], record["humidity"], record["wind_dir"], record["weather"], )) conn.commit() conn.close()

INSERT OR REPLACE的语义是:如果fetch_time和city_id组合已存在,就覆盖整行。这让重跑采集脚本变得安全——重复执行不会产生脏数据,只会更新同一条记录。init_db()要在采集启动前调用一次,建表语句里的PRIMARY KEY (fetch_time, city_id)同时充当去重约束和查询索引,后面查某个城市某天的数据会很快。

存储字段的类型值得注意:temp_now是 REAL,而temp_low、temp_high在 JSON 里带℃后缀,存入前必须先清洗成float,否则 SQLite 虽然能存,但后续AVG、MAX这类聚合函数算出来全是 0。

4.3 失败重试与数据校验:不能把脏数据写进库

网络请求一定会失败,这是采集任务里唯一可以提前确定的事。超时、连接重置、对方返回 500,都需要重试。但重试不能无脑循环,否则对方服务器把你 IP 封了都不知道。我一般用指数退避,最多重试三次:

import time import requests def fetch_with_retry(url: str, headers: dict, max_retries: int = 3) -> requests.Response: for attempt in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 200: return resp except requests.RequestException: pass time.sleep(2 ** attempt) # 第1次等2秒,第2次等4秒,第3次等8秒 raise RuntimeError(f"请求失败: {url}")

重试逻辑的关键参数是time.sleep(2 ** attempt):第一次失败等 2 秒,第二次等 4 秒,第三次等 8 秒。这个退避速度对天气数据足够了,不会因为间隔太短触发反爬,也不会因为间隔太长拖慢任务。如果三次都失败,直接抛异常让上层任务记录日志,而不是吞掉错误继续往下走。

数据校验在入库前做,比入库后清洗省事得多。至少校验两点:温度值是否在合理区间(夏天不可能出现-30,冬天不可能出现45),以及返回的cityid是否和请求的 ID 一致。校验不通过就丢弃这轮数据,等下一个周期自然更新,而不是把异常值写进库里以后再花半天排查是从哪来的。

5. 天气数据采集避坑:5 个让脚本翻车的真实细节

5.1 城市 ID 变了,接口返回 404,还以为是网站改版

现象:某一天开始,/data/sk/{city_id}.html返回 404,但城市在网页端能找到天气信息。

原因:不是网站改版,而是部分城市 ID 在实时数据服务里根本不存在。特别是行政区划调整后,新设立的区县 ID 在网页端能用,但老数据接口还没来得及同步。另一个高频场景是自己按101 + 省 + 市 + 区拼接的 ID,拼出来看着合理,实际服务端没有对应数据。

解决:城市 ID 以city3jdata接口逐级查出的为准,区县一级不要自己拼,用页面 URL 里的实际 ID。启动采集任务前跑一遍探测脚本,把失效的 ID 从配置里移除并告警。

5.2 返回 200 但内容乱码或空 body

现象:resp.text打印出来全是�,或者resp.json()抛出JSONDecodeError,但浏览器里打开同 URL 完全正常。

原因:部分接口在特定网络环境下返回 GBK 编码,requests 默认会从响应头猜编码,猜错的概率不低。另外一个场景是响应被 gzip 压缩,虽然 requests 会自动解压,但如果通过代理访问,压缩头可能被剥离导致 body 成了二进制乱码。

解决:显式设置编码,优先按响应头里的charset判断,没有就退回gbk重试一把。压缩问题先打印resp.headers.get("Content-Encoding")确认,再排查代理配置。不要依赖resp.apparent_encoding,它对短文本的猜测经常不准。

5.3 温度是负值,int() 硬转没问题,但缺字段会 KeyError

现象:冬天跑得好好的脚本,某天变成KeyError: 'temp',日志里一堆 Traceback。

原因:天气接口是“有则返回、无则不返回”的规则,字段不保证齐全。尤其雷雨、大风等极端天气场景,实时接口偶尔会缺失temp或WD字段。用resp.json()["weatherinfo"]["temp"]这种链式访问,字段一缺就崩。

解决:所有字段取值改用.get()并给默认值,入库前再做一次字段存在性校验。宁可记录NULL,也不要让采集任务死在半路,更不要把异常值硬塞进数据库。

5.4 d1 接口直接抓返回 403:Referer 和时间戳的坑

现象:用d1.weather.com.cn开头的 JSONP 接口抓数据,requests.get()直接 403,加 UA、换 IP 都没用。

原因:d1域名下的接口校验Referer必须来自www.weather.com.cn,同时习惯要求带_回调参数。这是专门给网页端 JS 用的接口,直接裸请求会被当成非法调用拒绝。

解决:请求头加"Referer": "http://www.weather.com.cn/",URL 末尾补?_=加毫秒时间戳。如果只是做常规天气展示,直接用/data/sk/老接口更省事,没必要跟d1接口较劲。

5.5 凌晨抓到的“今日”还是昨天的数据

现象:每天 0 点到 6 点之间采集的temp_low、temp_high、weather都是前一天的,对比网页端看到的数据也对不上。

原因:今日概况接口的刷新时间不是自然日零点,而是按气象业务节奏走,一般在清晨完成当日数据上线。ptime字段(数据发布时间)能明确看出数据的实际时间戳。

解决:入库前检查返回的ptime,在“今日”字段刷新前不要把采集结果当当日数据写入。两个选择:要么采集任务避开 0~6 点,要么把ptime也存进库里,让下游自行判断数据新鲜度。我选了后者,保留原始时间戳的价值远比省一条记录大。

6. 从 JSON 到干净气象表:字段清洗与数据校验的最后一公里

前面所有代码拿到的都还是“能用但不够规范”的数据:温度带单位、字段名不统一、部分值可能是空字符串。如果只做展示,直接打印没问题;如果要入库做分析,必须先清洗成统一格式。我常用的做法是把三个接口的数据合并成一个扁平字典,再统一做类型转换。

清洗规则有几条:temp1和temp2去掉℃后转float,同时记住temp1是最低、temp2是最高;temp_now直接转float,但要做空值判断,因为实时接口在极端天气下可能缺这个字段;湿度SD去掉%后转float;风向WD、风力WS、天气现象weather保持字符串原样,但要去掉首尾空格。

验证采集数据对不对,我有一个土办法:把清洗后的温度和time字段打印出来,和手机自带天气 App 同城市对比。温差在 1~2℃ 内是正常的,因为气象站位置和温度计不同;如果相差 5℃ 以上,优先怀疑城市 ID 串了——别笑,多城市采集时配置写错城市 ID 是最高频的事故。另一个验证点是交叉验证:用/data/sk/的temp和.shtml页面里hour3data的当前温度做比对,两个独立数据源一致,基本可以确认链路没有解析 bug。

我自己踩过的最大一个坑,是把temp1和temp2写反,导致一整周的“最高温”都是负数。后来在脚本里加了一条断言:temp_high >= temp_low,不满足直接告警。这种自校验逻辑,比写十行注释都管用。希望帮到你。

本文还有配套的精品资源,点击获取

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

嵌入式MCU开发全链路:编译、烧录与仿真流程详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 7:34:49

Atlas 300V 24G推理卡跑YOLO:从环境搭建到部署调优全指南

1. 一台推理卡&#xff0c;为什么值得单独写一篇先说结论&#xff1a;Atlas 300V 24G是华为昇腾生态里一款纯推理场景的加速卡&#xff0c;目标对象非常明确——跑YOLO这类检测模型&#xff0c;做视频流分析、边缘智能、工业质检、园区安防等任务。很多刚接触昇腾的人会被一堆名…

作者头像 李华
网站建设 2026/9/25 7:33:46

Windows11本地部署OpenClaw:从WSL2环境到飞书接入的完整实践

最近我在 Windows11 上折腾 OpenClaw&#xff0c;前前后后花了两天&#xff0c;把一个“装不上、跑不通”的状态调到了稳定运行&#xff0c;现在它每天定时抓资讯、生成摘要、发到飞书&#xff0c;基本替代了我早上刷新闻的习惯。OpenClaw 本质上不是又一个聊天框&#xff0c;而…

作者头像 李华
网站建设 2026/9/25 7:33:22

Word尾注脚注管理全攻略:插入、删除与去横线技巧

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/25 7:33:03

国产24位ADC选型避坑指南:HX711、CS1237与TM7707深度对比

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华