☰
中国天气网数据抓取实战:从页面解析到结构化落库
2026/10/17 7:10:12 网站建设 项目流程

简介:这份资源面向需要在 .NET 平台调用气象接口的开发者,尤其是仍在使用 Visual Studio 2008 环境、希望快速跑通天气数据获取流程的初学者与中级程序员。内容围绕中国天气网公开 API 展开,覆盖 HTTP 请求与响应处理、JSON/XML 解析、API 密钥注册管理、Windows Forms 界面展示等核心环节,可作为气象类小工具或课程设计的起步模板。压缩包共 34 个文件,约 259KB,以 cs 源码、config 配置、dll 与 exe 程序集为主,另含 sln 解决方案、csproj 工程文件、resx 资源及少量缓存与调试文件,结构完整可直接在 VS2008 中打开编译。目前已有 2311 人学习下载,读者可从中获得一套可运行的天气数据抓取示例,理解请求封装、数据解析与界面绑定的完整链路,并借鉴其中的异常处理与接口调用限制思路,减少自行摸索成本。

1. 中国天气网数据获取:从页面结构到结构化落库的完整路径

做气象相关项目的开发者大概都经历过这个场景:需要一个城市的实时温度、湿度、风力、未来七天预报,第一反应是找开放 API,结果要么额度不够,要么字段残缺,要么稳定性堪忧。这时候中国天气网就成了一个绕不开的数据源——它覆盖全国省市区县,页面结构相对规整,数据维度也够用。但问题在于,它本身是给人看的网页,不是给程序读的接口。怎么把页面里的天气数据稳定地拿下来、解析成结构化字段、再落到自己的数据库或缓存里,就是这篇要拆解的事。

这份资源的核心价值在于:它提供了一套从请求构造、页面解析到数据清洗落库的完整思路,适合做气象看板、出行提醒、农业监测、物流调度这类需要天气数据打底的项目。新手可以照着步骤一步步复现,熟手可以直接看参数设计和避坑部分,跳过基础操作。下面按“先搞清楚数据从哪来、再动手写解析、最后处理异常和进阶用法”的顺序展开。

2. 请求构造与页面结构解析:把 HTML 变成结构化字段

2.1 为什么选页面解析而不是找接口

中国天气网对外没有公开的、稳定的、免费的 JSON API。市面上能搜到的一些“天气接口”,要么是第三方封装后加了限制,要么是早年遗留的地址早已失效。相比之下,网页本身是持续维护的,只要页面结构不大改,解析逻辑就能一直用。常见做法是:用 HTTP 客户端请求目标城市页面,拿到 HTML 后交给解析库提取节点文本。这条路的好处是可控——你能看到原始数据长什么样,出了问题也能定位到具体节点。

选型上,Python 生态里 requests + BeautifulSoup 是最轻的组合,适合快速验证;如果追求解析速度和并发,lxml 或 parsel 更合适。Node.js 侧可以用 axios + cheerio,逻辑类似。下面以 Python 为例,因为它在数据处理和后续落库环节衔接最顺。

2.2 构造请求:URL 规律与请求头设置

中国天气网的页面 URL 有比较明显的规律。以城市页面为例,通常包含一个城市编码,比如http://www.weather.com.cn/weather/101010100.shtml这种形式,其中101010100就是北京的城市 ID。这个 ID 不是随便编的,它有一套编码规则:前两位代表省份,中间两位代表城市,后两位代表区县。要批量获取多个城市,第一步就是先把城市 ID 表整理出来。

import requests from bs4 import BeautifulSoup # 城市 ID 示例:北京 101010100,上海 101020100,广州 101280101 CITY_ID = "101010100" BASE_URL = f"http://www.weather.com.cn/weather/{CITY_ID}.shtml" # 请求头必须带 User-Agent,否则部分节点返回的页面不完整 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": "http://www.weather.com.cn/", "Accept-Language": "zh-CN,zh;q=0.9", } def fetch_page(city_id: str) -> str: url = f"http://www.weather.com.cn/weather/{city_id}.shtml" resp = requests.get(url, headers=HEADERS, timeout=10) resp.encoding = "utf-8" # 页面声明是 utf-8,但 requests 有时猜错 resp.raise_for_status() return resp.text

这段代码里几个参数值得说明。User-Agent不能省,否则可能拿到一个精简版页面,缺少七天预报的节点。Referer加上更稳妥,部分 CDN 节点会校验来源。timeout建议设 10 秒以内,天气页面本身不大,超时太长只会拖慢批量任务。resp.encoding手动指定 utf-8 是因为 requests 的自动推断偶尔会偏成 ISO-8859-1,导致中文乱码——这个坑后面还会细说。

2.3 解析页面:定位七天预报的 DOM 节点

拿到 HTML 后,核心任务是找到承载天气数据的节点。中国天气网的城市页面里,七天预报通常在一个id="7d"的容器里,每天的数据放在ul下的li中。每个li里包含日期、天气现象、最高温、最低温、风力等信息。结构大致如下:

<div id="7d"> <ul> <li> <h1>12日(今天)</h1> <p class="wea">晴</p> <p class="tem"><span>28℃</span>/<i>18℃</i></p> <p class="win"><i>西北风</i><span>3级</span></p> </li> ... </ul> </div>

对应的解析代码:

def parse_weather(html: str) -> list[dict]: soup = BeautifulSoup(html, "html.parser") container = soup.find("div", id="7d") if not container: return [] results = [] for li in container.find_all("li"): # 日期 date_tag = li.find("h1") date_text = date_tag.get_text(strip=True) if date_tag else "" # 天气现象 wea_tag = li.find("p", class_="wea") weather = wea_tag.get_text(strip=True) if wea_tag else "" # 温度:最高温在 span,最低温在 i tem_tag = li.find("p", class_="tem") high, low = "", "" if tem_tag: span = tem_tag.find("span") i_tag = tem_tag.find("i") high = span.get_text(strip=True) if span else "" low = i_tag.get_text(strip=True) if i_tag else "" # 风力 win_tag = li.find("p", class_="win") wind = win_tag.get_text(" ", strip=True) if win_tag else "" results.append({ "date": date_text, "weather": weather, "high": high, "low": low, "wind": wind, }) return results

逻辑说明:先定位id="7d"的容器,找不到就直接返回空列表,避免后续报错。遍历每个li,分别取日期、天气、温度、风力。温度节点里最高温和最低温分属不同标签,需要分开取。风力用get_text(" ", strip=True)把多个子节点的文本拼起来,避免“西北风3级”粘在一起。

参数方面,BeautifulSoup的解析器选html.parser是内置的,不依赖额外安装;如果页面量大,换成lxml会快不少,但需要pip install lxml。find和find_all的class_参数注意下划线,因为class是 Python 关键字。

2.4 数据清洗:温度、风力和日期的标准化

解析出来的原始文本不能直接入库。温度带“℃”,风力带“级”,日期带“(今天)”这类修饰,都需要清洗成可计算、可比较的格式。

import re from datetime import datetime, timedelta def clean_temperature(temp_str: str) -> int | None: """把 '28℃' 转成 28,失败返回 None""" if not temp_str: return None match = re.search(r"-?\d+", temp_str) return int(match.group()) if match else None def clean_wind(wind_str: str) -> dict: """拆分风向和风力等级""" if not wind_str: return {"direction": "", "level": ""} # 常见格式:'西北风 3级' 或 '西北风3级' parts = re.split(r"\s+", wind_str.strip()) direction = parts[0] if parts else "" level = parts[1] if len(parts) > 1 else "" return {"direction": direction, "level": level} def normalize_date(date_str: str, base_date: datetime | None = None) -> str: """把 '12日(今天)' 转成 '2025-01-12' 格式""" base = base_date or datetime.now() match = re.search(r"(\d+)日", date_str) if not match: return "" day = int(match.group(1)) # 处理跨月:如果解析出的日小于当前日,说明是下个月 year, month = base.year, base.month if day < base.day: month += 1 if month > 12: month = 1 year += 1 return f"{year:04d}-{month:02d}-{day:02d}"

清洗环节有三个关键点。温度用正则提取数字,兼容负数。风力拆分时注意有些页面用空格分隔,有些直接连着写,re.split能覆盖大部分情况。日期标准化最麻烦的是跨月判断——比如当前是 1 月 30 日,页面显示“2日”,那应该是下个月 2 日。这里用“解析出的日小于当前日就加一个月”的规则处理,能覆盖绝大多数场景,但跨年时还要再判断一次月份溢出。

清洗完的数据就可以落库了。常见做法是存到 SQLite 或 MySQL,字段包括城市 ID、日期、天气现象、最高温、最低温、风向、风力等级、抓取时间。如果只是做实时看板,存 Redis 加个过期时间也够用。

3. 批量抓取与调度:多城市并发和频率控制

3.1 城市 ID 表的整理与校验

单个城市跑通之后,下一步是批量。中国天气网的城市 ID 有几千个,手动整理不现实。常见做法是先从省级页面逐级下钻,把城市 ID 和名称的对应关系抓下来,存成一张字典表。这个过程只需要跑一次,后续直接读表就行。

import json import time def build_city_map(province_ids: list[str]) -> dict: """从省级页面提取城市 ID 和名称""" city_map = {} for pid in province_ids: url = f"http://www.weather.com.cn/weather/{pid}.shtml" try: html = fetch_page(pid) soup = BeautifulSoup(html, "html.parser") # 省级页面里通常有城市列表链接 for a in soup.select("div.conMid a"): href = a.get("href", "") name = a.get_text(strip=True) match = re.search(r"(\d{9})\.shtml", href) if match and name: city_map[match.group(1)] = name except Exception as e: print(f"province {pid} failed: {e}") time.sleep(1) # 控制频率,避免触发限制 return city_map # 保存到本地,后续直接加载 # with open("city_map.json", "w", encoding="utf-8") as f: # json.dump(city_map, f, ensure_ascii=False, indent=2)

这里time.sleep(1)是必须的。批量请求如果不加间隔,很容易被目标站点限流,表现为返回 403 或空页面。1 秒的间隔在几千个城市面前大概要跑一两个小时,但这是一次性工作,可以接受。如果嫌慢,可以适当降到 0.5 秒,但不建议再低。

3.2 并发抓取的边界:线程池大小与重试策略

城市表建好后,日常抓取可以用线程池并发。但并发数不是越大越好——目标站点对单 IP 的请求频率有隐性限制,开太多线程反而会触发封禁。我一般会把线程池控制在 5 到 10 之间,配合重试机制。

from concurrent.futures import ThreadPoolExecutor, as_completed import random def fetch_with_retry(city_id: str, max_retries: int = 3) -> dict | None: for attempt in range(max_retries): try: html = fetch_page(city_id) data = parse_weather(html) return {"city_id": city_id, "data": data} except Exception as e: if attempt == max_retries - 1: print(f"{city_id} failed after {max_retries} retries: {e}") return None # 退避等待,加随机抖动避免同时重试 time.sleep((attempt + 1) * 2 + random.uniform(0, 1)) return None def batch_fetch(city_ids: list[str], max_workers: int = 8) -> list[dict]: results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(fetch_with_retry, cid): cid for cid in city_ids} for future in as_completed(futures): res = future.result() if res: results.append(res) return results

重试策略里加了随机抖动,是为了避免多个线程在同一时刻一起重试,形成新的峰值。退避时间按(attempt + 1) * 2递增,第一次失败等 2 秒,第二次等 4 秒,第三次等 6 秒。这个节奏在实测中比较稳,既不会等太久,也能给目标站点喘息时间。

3.3 调度频率:多久抓一次合适

天气数据的变化频率不高,实时温度每小时更新一次就算勤了,七天预报一天更新两到三次足够。如果做的是出行提醒类应用,早晚各抓一次就能覆盖大部分场景。调度可以用系统的 crontab,也可以用 APScheduler 这类库。

# crontab 示例:每天 6 点和 18 点各跑一次 0 6,18 * * * /usr/bin/python3 /path/to/weather_job.py >> /var/log/weather.log 2>&1

日志重定向不能省,否则出了问题连报错都看不到。日志里至少记录:抓取时间、成功城市数、失败城市数、失败城市 ID 列表。这样一旦发现某天数据缺失,能快速定位是整体失败还是个别城市的问题。

4. 避坑与排查:编码、节点变动和反爬的常见翻车点

4.1 中文乱码:现象、原因与解决

现象:解析出来的天气现象是“æ™´”这种乱码,或者温度正常但城市名是问号。

原因:requests 的resp.encoding自动推断依赖 HTTP 头里的 charset,但中国天气网的部分节点返回的头里没有明确声明,requests 就会用默认的 ISO-8859-1 去解码,中文自然乱码。

解决:拿到响应后手动设resp.encoding = "utf-8",再取resp.text。如果页面实际是 gbk,就设成 gbk。判断方法很简单:看resp.apparent_encoding和resp.encoding是否一致,不一致就以页面 meta 标签里的 charset 为准。

4.2 节点找不到:页面改版后的快速定位

现象:soup.find("div", id="7d")返回 None,解析结果为空。

原因:目标站点改版,容器 ID 或 class 名变了。这种情况一年可能遇到一两次,不算频繁但很致命。

解决:不要硬编码单一选择器。我一般会写一个降级链:先找id="7d",找不到就找class="seven-day",再找不到就找包含“7天”文本的最近容器。同时加一个告警:如果连续多次解析为空,发邮件或写日志标记,提醒人工去页面确认结构。另外,解析前先把 HTML 存一份到本地,出问题时可以直接拿存档调试,不用反复请求目标站点。

4.3 请求被拒:403 和空页面的区别处理

现象:请求返回 403,或者返回 200 但页面内容为空。

原因:403 通常是请求头缺失或 IP 被临时限制;空页面可能是触发了频率限制,返回了一个占位页。

解决:先检查User-Agent和Referer是否带上。如果确认带了还是 403,降低并发数,加大请求间隔。空页面的情况更隐蔽,建议在解析前先判断页面里有没有关键节点,没有就当作失败处理,走重试逻辑。不要盲目重试太多次,否则可能从临时限制变成较长时间的封禁。

4.4 日期跨月跨年:边界情况别漏掉

现象:每月最后几天抓取时,日期标准化结果偏了一个月。

原因:页面显示“2日”时,如果当前是 1 月 30 日,程序按“小于当前日就加一个月”处理是对的;但如果当前是 12 月 30 日,加一个月会变成 13 月,没有做年份进位。

解决:在月份加一之后判断是否大于 12,大于就置为 1 并把年份加一。这个逻辑在 2.4 节的normalize_date里已经体现。另外,跨年那几天最好手动跑一次验证,确认输出日期连续且正确。

4.5 温度字段缺失:空值和零下的处理

现象:某些日期的最高温或最低温为空,或者零下温度被解析成正数。

原因:页面在极端天气或数据更新间隙可能留空;零下温度带负号,正则如果只匹配\d+就会丢掉负号。

解决:正则写成-?\d+,兼容负号。空值不要直接转 int,先判断再转换,返回 None 让上层决定是丢弃还是填默认值。入库时温度字段允许 NULL,避免因为个别缺失导致整批数据写入失败。

5. 进阶用法:把天气数据接到实际业务里

5.1 数据落库与查询优化

抓下来的数据最终要能被业务查询。如果只是单机小项目,SQLite 足够;如果多服务共享,MySQL 或 PostgreSQL 更合适。表结构建议按“城市 + 日期”建唯一索引,避免重复抓取导致数据膨胀。

CREATE TABLE weather_forecast ( id INT AUTO_INCREMENT PRIMARY KEY, city_id VARCHAR(12) NOT NULL, city_name VARCHAR(64), forecast_date DATE NOT NULL, weather VARCHAR(32), high_temp INT, low_temp INT, wind_direction VARCHAR(32), wind_level VARCHAR(16), fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_city_date (city_id, forecast_date) ); -- 查询某城市未来三天 SELECT * FROM weather_forecast WHERE city_id = '101010100' AND forecast_date >= CURDATE() ORDER BY forecast_date LIMIT 3;

唯一索引的作用是:重复抓取时用INSERT ... ON DUPLICATE KEY UPDATE更新而不是新增,保证每个城市每天只有一条记录。查询时按日期排序取前几条,就能拿到未来预报。

5.2 用天气数据做简单预警

有了结构化数据,就可以做规则预警。比如高温预警、大风预警、降温提醒。下面是一个简单的判断逻辑:

def check_alerts(forecast: list[dict], city_name: str) -> list[str]: alerts = [] for day in forecast: high = day.get("high_temp") low = day.get("low_temp") wind_level = day.get("wind_level", "") if high is not None and high >= 35: alerts.append(f"{city_name} {day['forecast_date']} 高温 {high}℃") if low is not None and low <= -10: alerts.append(f"{city_name} {day['forecast_date']} 低温 {low}℃") # 风力等级里提取数字,>=6 级算大风 match = re.search(r"(\d+)", wind_level) if match and int(match.group(1)) >= 6: alerts.append(f"{city_name} {day['forecast_date']} 大风 {wind_level}") return alerts

这个逻辑可以挂到定时任务后面,抓完数据就跑一遍,有预警就推送到消息队列或邮件。阈值根据业务场景调整,农业和物流的敏感度不一样。

5.3 验证数据准确性的笨办法

天气数据抓下来后,怎么确认它是对的?我的习惯是:每天随机抽两三个城市,手动打开页面比对温度和天气现象。听起来很笨,但能发现解析逻辑的隐性错误,比如温度高低位取反、风力等级漏读。另外,可以观察连续几天的数据变化是否平滑——如果某天温度突然从 20℃ 跳到 -5℃,大概率是解析出了问题,而不是天气真的这么极端。

从那以后我每次上线新的解析规则,都会先跑一轮抽样比对,确认无误再开定时任务。这个习惯帮我挡掉了好几次因为页面微调导致的字段错位。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询