Python爬虫实战:应对怀俄明大学探空数据网站更新,重构自动化下载脚本
1. 项目概述为什么我们需要更新Wyoming探空数据下载方案最近在做一个气象分析的小项目需要用到高空的温湿压风数据第一时间就想到了怀俄明大学University of Wyoming大气科学系提供的全球探空数据。这几乎是气象、气候、航空乃至环境科学领域从业者和爱好者的“宝藏数据库”。它免费、全球覆盖、历史序列长而且更新及时对于验证模式、分析天气过程或者做简单的气候统计来说都是不可多得的一手资料。然而就在我像往常一样打开浏览器准备手动下载几个站点的数据时发现事情有点不对劲。我之前收藏的那个经典的数据查询页面样式变了更重要的是我习惯用Python脚本去批量抓取数据的那个“老地址”似乎不work了。这让我心里“咯噔”一下——脚本失效意味着我自动化处理数据流的管道断了。对于依赖这些数据进行日常分析或研究的人来说这可不是个小问题。经过一番排查我确认了怀俄明大学探空数据服务的网址和前端交互方式确实更新了。这直接导致了许多流传在GitHub、气象论坛上的老旧Python爬虫脚本“集体罢工”。如果你也遇到了类似“HTTP 404”、“无法解析页面”或者“提取不到数据链接”的报错那么大概率是撞上了这次更新。所以今天这篇内容就是来分享我如何根据新的网站结构重新梳理逻辑用Python实现稳定、高效的Wyoming探空数据自动化下载方案。无论你是气象专业的学生、相关领域的研究者还是对天气数据感兴趣的开发者这套更新后的方法都能帮你重新打通数据获取的“任督二脉”。2. 核心需求解析与方案设计思路在动手写代码之前我们必须先搞清楚两个核心问题第一我们到底要下载什么数据第二新的网站提供了哪些交互方式2.1 探空数据内容与格式选择怀俄明大学的探空数据主要提供两种格式文本格式和绘图格式。对于我们做数据分析而言文本格式是绝对的首选。文本数据这是最原始、最结构化的数据。一份典型的文本数据文件包含了从地面到高空通常到约10 hPa多个标准层和特性层的气象要素包括气压、位势高度、温度、露点温度、风向、风速。这是核心的“温压湿风”数据。抬升凝结高度、对流有效位能、对流抑制能量、抬升指数等对流参数。0°C、-10°C、-20°C、-30°C层的高度和温度等特性层数据。 这些数据以规整的列格式排列非常便于用pandas或numpy进行读取和后续计算。我们的脚本目标就是自动化获取这种文本数据。图像数据网站会生成探空曲线图Skew-T Log-P diagram这对于天气分析员快速判断大气稳定度、云层、结冰条件等非常直观但不利于程序化分析。我们的核心需求给定一个气象站编号、一个日期时间年、月、日、时程序能自动模拟浏览器操作从新版的怀俄明大学数据查询页面获取对应时次的文本格式探空数据并保存到本地。2.2 新版网站交互逻辑分析老版本的页面结构简单数据链接的规律明显容易通过字符串匹配或简单的正则表达式提取。新版网站网址通常为http://weather.uwyo.edu/upperair/sounding.html采用了更现代的前端技术其数据获取流程发生了变化表单提交用户通过网页表单选择区域、站点、日期和时间点击“提交”按钮。动态请求点击提交后浏览器并非直接跳转到一个静态的文本数据页面而是向服务器发送了一个携带了所有选择参数的POST请求。服务器响应服务器处理这个请求动态生成并返回一个包含数据的HTML页面。这个页面的URL本身可能不直接体现参数数据是“嵌”在页面里的。这意味着我们不能再靠拼接一个固定的URL模式如老方法中的.../cgi-bin/sounding?...来直接获取数据了。我们必须模拟这个表单提交的POST请求。这听起来复杂但其实是网络爬虫/自动化数据采集中的常规操作。我们需要做的是分析网页源代码找到表单form以及里面所有的输入项input、select。弄清楚每个输入项对应的参数名name属性和我们需要提交的值value。使用Python的requests库构造一个字典来存放这些参数然后向表单的“动作地址”action属性发送POST请求。从返回的HTML页面中精准地提取出我们需要的文本数据部分。这个思路是通用的不仅适用于怀俄明大学的数据也适用于其他很多基于表单查询的网站。3. 环境准备与核心工具库选型工欲善其事必先利其器。为了实现上述方案我们需要一个轻量级但功能强大的工具组合。3.1 Python库的安装与作用打开你的终端或命令提示符使用pip安装以下库。如果你使用Anaconda也可以用conda安装。pip install requests beautifulsoup4 pandasrequests这是Python中处理HTTP请求的“瑞士军刀”简单易用且功能强大。我们将用它来发送模拟浏览器行为的GET和POST请求获取网页HTML内容。它比内置的urllib更友好是网络数据抓取的首选。BeautifulSoup4简称bs4是HTML和XML文档的解析库。当我们用requests拿到网页的HTML字符串后它是一团“乱麻”。BeautifulSoup可以把它解析成一棵结构清晰的树让我们能像使用CSS选择器一样轻松地找到页面中任何一个标签、属性或文本内容。我们将用它来定位表单和提取数据。pandas虽然数据提取后的清洗和保存不一定非要用pandas但它能极大简化我们的工作。特别是当数据是规整的表格文本时pandas的read_html或read_csv函数可以一键将其转为DataFrame方便后续分析和导出为CSV、Excel等格式。这是一个“有了会更好”的工具。注意有些教程可能会提到selenium这是一个浏览器自动化工具可以模拟真人操作浏览器。对于怀俄明大学数据下载这个任务我不推荐使用selenium。原因有二一是selenium需要安装浏览器驱动如ChromeDriver环境配置更复杂二是它运行效率远低于直接发送HTTP请求的requests。我们的目标网站没有复杂的JavaScript渲染或反爬机制用requestsBeautifulSoup的组合是最高效、最轻量的方案。3.2 关键参数与站点信息准备在编写脚本前我们需要明确几个关键参数这些参数对应着网页表单中的选项区域网站将全球分为几个区域例如naconf北美、samer南美、europe欧洲、asia亚洲、africa非洲等。你需要根据目标站点的地理位置选择正确的区域。站点编号这是全球统一的WMO世界气象组织站号通常是5位数字。例如北京的站号是54511纽约的站号是72502。你可以在网站的下拉列表中查找或者通过其他气象数据源获取。日期与时间探空数据通常是世界时UTC每天两次00Z和12Z发布部分站点可能有更多时次。你需要指定年、月、日、时12或00。为了方便脚本调用我们可以预先定义一个站点信息字典station_info { ‘beijing’: {‘region’: ‘asia’, ‘station’: ‘54511’}, ‘new_york’: {‘region’: ‘naconf’, ‘station’: ‘72502’}, ‘tropical_pacific’: {‘region’: ‘pac’, ‘station’: ‘91680’} # 例如瑙鲁站 }4. 实操步骤构建新版数据下载脚本下面我将分步拆解整个脚本的编写过程。你可以跟着一步一步来也可以直接看最后的完整代码。4.1 第一步分析网页结构定位表单与参数首先我们手动打开http://weather.uwyo.edu/upperair/sounding.html按F12打开开发者工具切换到“元素”或“网络”标签页。找到表单在“元素”标签页使用检查工具点击页面上的选择框或按钮可以快速定位到包裹它们的form标签。你会发现表单的action属性可能是一个相对路径如cgi-bin/...。记下这个action我们需要把它拼接到基础URL后面。找出所有输入参数在form标签内部寻找所有的input、select和textarea标签。每个这样的标签都有一个name属性这就是我们要提交的参数名。常见的参数名包括region: 区域选择TYPE: 固定值可能是TEXT:LISTYEAR: 年MONTH: 月FROM: 日起始日通常和终止日相同TO: 日终止日STNM: 站点编号FROM12,TO12: 起始和终止小时通常都是12或00Send: 提交按钮的值通常为Send我们需要做的就是用BeautifulSoup来自动化完成这个查找过程并构建参数字典。4.2 第二步发送请求与解析响应首先我们导入库并设置基础URL。import requests from bs4 import BeautifulSoup import pandas as pd BASE_URL ‘http://weather.uwyo.edu’ SOUNDING_PAGE_URL BASE_URL ‘/upperair/sounding.html’然后我们编写一个函数来获取初始页面并解析表单。def get_sounding_data(station_number, region, year, month, day, hour‘12’): “”” 根据给定的参数从怀俄明大学网站下载探空数据文本。 参数: station_number (str): WMO站号如‘54511’ region (str): 区域代码如‘asia’ year (str): 年如‘2023’ month (str): 月如‘08’ day (str): 日如‘15’ hour (str): 时次‘12’ 或 ‘00’ 返回: str: 提取到的探空数据文本如果失败返回None “”” # 1. 获取初始页面解析表单 try: response requests.get(SOUNDING_PAGE_URL, timeout10) response.raise_for_status() # 检查请求是否成功 except requests.exceptions.RequestException as e: print(f“获取初始页面失败: {e}”) return None soup BeautifulSoup(response.content, ‘html.parser’) form soup.find(‘form’) if not form: print(“未在页面中找到表单。”) return None # 2. 构建提交参数字典 # 首先收集表单中所有已有的隐藏或默认input值 payload {} for input_tag in form.find_all([‘input’, ‘select’]): name input_tag.get(‘name’) if not name: continue if input_tag.name ‘input’: value input_tag.get(‘value’, ‘’) # 如果是单选或复选框且被选中则用它的值 if input_tag.get(‘type’) in [‘radio’, ‘checkbox’] and input_tag.get(‘checked’): payload[name] value elif input_tag.get(‘type’) ! ‘radio’ and input_tag.get(‘type’) ! ‘checkbox’: # 对于文本、隐藏等类型的输入先保留原值后面会被覆盖 payload[name] value elif input_tag.name ‘select’: # 对于下拉框默认选中哪个option就用哪个值 selected_option input_tag.find(‘option’, selectedTrue) payload[name] selected_option.get(‘value’) if selected_option else ‘’ # 3. 用我们需要的参数覆盖默认值 payload.update({ ‘region’: region, ‘TYPE’: ‘TEXT:LIST’, # 固定值表示获取文本列表 ‘YEAR’: year, ‘MONTH’: month, ‘FROM’: day, ‘TO’: day, ‘STNM’: station_number, ‘FROM12’: hour, ‘TO12’: hour, ‘Send’: ‘Send’ # 提交按钮的值 })实操心得构建payload字典时一个稳妥的做法是先获取表单中所有输入项的默认值然后再用我们的目标值去更新它。这样做可以确保我们提交的请求结构和浏览器完全一致避免因遗漏某些隐藏参数而导致服务器拒绝请求。直接硬编码所有参数名和值虽然快但一旦网站微调脚本就容易失效。4.3 第三步提交表单并提取数据接下来我们需要找到表单提交的目标地址并发送POST请求。# 4. 获取表单提交地址 action_url form.get(‘action’) if not action_url: print(“表单中没有找到action属性。”) return None # 拼接完整的提交URL submit_url BASE_URL action_url if action_url.startswith(‘/’) else BASE_URL ‘/’ action_url # 5. 发送POST请求提交表单 try: # 注意有些服务器可能需要特定的Headers来模仿浏览器 headers { ‘User-Agent’: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36’, ‘Referer’: SOUNDING_PAGE_URL } data_response requests.post(submit_url, datapayload, headersheaders, timeout15) data_response.raise_for_status() except requests.exceptions.RequestException as e: print(f“提交表单请求失败: {e}”) return None # 6. 从返回的HTML中提取探空数据文本 data_soup BeautifulSoup(data_response.content, ‘html.parser’) # 关键步骤寻找包含数据的pre标签 # 怀俄明大学的数据通常包裹在pre标签中 pre_tag data_soup.find(‘pre’) if pre_tag: data_text pre_tag.get_text() # 简单清洗去除首尾多余空白 data_text data_text.strip() if data_text and ‘Station number’ in data_text: # 简单有效性校验 return data_text else: print(“提取到的文本数据为空或不包含有效标识。”) return None else: # 如果找不到pre尝试其他可能的结构或者直接打印部分HTML调试 print(“未在返回页面中找到pre标签数据可能以其他格式返回。”) # 可以打印前2000字符查看结构 # print(data_response.text[:2000]) return None4.4 第四步数据清洗与保存获取到原始的文本数据后它通常包含一些表头信息和多行数据。我们可以将其保存为原始文本文件也可以用pandas进行初步处理。def save_and_parse_data(data_text, station_number, date_str): “”” 保存原始数据并尝试用pandas解析为结构化数据。 参数: data_text (str): 原始数据文本 station_number (str): 站号用于命名文件 date_str (str): 日期字符串用于命名文件如‘20230815_12Z’ “”” if not data_text: return None # 1. 保存原始文本 raw_filename f“sounding_{station_number}_{date_str}.txt” with open(raw_filename, ‘w’, encoding‘utf-8’) as f: f.write(data_text) print(f“原始数据已保存至: {raw_filename}”) # 2. 尝试解析为DataFrame # 原始文本通常包含几行表头然后是数据行。 # 数据行通常以固定的列宽分隔。我们可以先找到数据部分的开始。 lines data_text.split(‘\n’) data_start_idx None for i, line in enumerate(lines): # 寻找包含列标题的行例如‘PRES HGHT TEMP DWPT RELH MIXR DRCT SKNT THTA THTE THTV’ if ‘PRES’ in line and ‘HGHT’ in line and ‘TEMP’ in line: data_start_idx i break if data_start_idx is not None: # 列标题行 header_line lines[data_start_idx] # 数据行从标题的下一行开始 data_lines lines[data_start_idx 1:] # 过滤掉空行和页脚行如包含‘Station’的行 data_lines [ln for ln in data_lines if ln.strip() and not ln.strip().startswith(‘Station’) and ‘---’ not in ln] # 由于是固定宽度我们可以用pandas的read_fwf函数 # 但更简单的方法是列标题本身是用空格分隔的数据行也是用空格分隔的列。 # 注意数据中可能用‘999’或‘999.9’表示缺测。 try: # 使用pandas读取指定分隔符为空白多个空格或制表符 df pd.read_csv(pd.io.common.StringIO(‘\n’.join([header_line] data_lines)), delim_whitespaceTrue, na_values[‘999’, ‘999.9’, ‘999.99’]) # 将常见缺测值替换为NaN print(f“数据成功解析为DataFrame形状: {df.shape}”) # 保存为CSV csv_filename f“sounding_{station_number}_{date_str}.csv” df.to_csv(csv_filename, indexFalse) print(f“结构化数据已保存至: {csv_filename}”) return df except Exception as e: print(f“使用pandas解析数据时出错: {e}。将仅保存原始文本。”) return None else: print(“未在数据中找到标准的列标题行无法自动解析为表格。”) return None4.5 第五步整合与批量下载示例最后我们将所有功能整合并展示如何批量下载多天或多个站点的数据。def main(): # 示例下载北京站2023年8月15日12Z的数据 data_text get_sounding_data(station_number‘54511’, region‘asia’, year‘2023’, month‘08’, day‘15’, hour‘12’) if data_text: df save_and_parse_data(data_text, ‘54511’, ‘20230815_12Z’) if df is not None: print(df.head()) # 查看前几行数据 # 批量下载示例下载纽约站2023年8月1日到5日每天00Z的数据 print(“\n开始批量下载示例...”) station ‘72502’ region ‘naconf’ year ‘2023’ month ‘08’ hour ‘00’ for day in [‘01’, ‘02’, ‘03’, ‘04’, ‘05’]: print(f“正在处理 {year}-{month}-{day} {hour}Z ...”) data_text get_sounding_data(station_numberstation, regionregion, yearyear, monthmonth, dayday, hourhour) if data_text: save_and_parse_data(data_text, station, f“{year}{month}{day}_{hour}Z”) else: print(f“ {year}-{month}-{day} 数据下载失败可能该时次无数据。”) # 建议在请求间添加短暂延时以示友好 import time time.sleep(2) if __name__ ‘__main__’: main()5. 常见问题排查与脚本优化技巧在实际运行中你可能会遇到各种问题。下面是我在调试过程中遇到的一些典型情况及其解决方法。5.1 请求失败或返回非预期内容问题现象可能原因排查与解决思路requests.get或requests.post抛出连接超时或拒绝连接异常。网络问题或目标服务器暂时不可用。1. 检查网络连接。2. 手动访问weather.uwyo.edu确认网站可打开。3. 增加timeout参数的值并添加重试机制。返回状态码不是200如404, 403, 500。404URL拼写错误或action路径不对。403可能触发了简单的反爬机制。500服务器内部错误可能是提交的参数有误。1.打印出完整的submit_url和payload与浏览器开发者工具“网络”标签页中捕获的请求进行对比确保完全一致。2. 添加更完整的请求头headers模拟真实浏览器User-Agent, Referer, Accept等。3. 检查参数值格式特别是日期和站号确保是字符串且符合网站要求。返回的HTML页面中没有pre标签或者pre标签内没有数据。1. 网站结构可能再次微调。2. 请求的参数组合无效如该站点该时次无数据。3. 数据可能被包裹在其他标签中如div或code。1. 将data_response.text的前几千字符保存到文件仔细查看结构寻找数据所在的新标签。2. 用浏览器手动选择相同的参数提交查看返回页面的源代码确认数据位置。3. 如果无数据网站通常会返回提示信息可以在HTML中搜索“No data”、“not available”等关键词。5.2 数据解析与处理中的坑缺测值处理怀俄明大学的数据常用999、999.9或999.99表示缺测。在用pandas读取时务必使用na_values参数将其转换为NaN否则在进行数值计算时会导致错误。单位注意数据中的高度单位是位势米气压单位是百帕温度是摄氏度风速是节。在进行计算或与其他数据源对比时务必注意单位换算。数据截断有时数据只到某一层就结束了这可能是因为气球爆炸或信号丢失属于正常现象。脚本应能处理这种不完整的数据行。5.3 脚本健壮性与效率优化添加重试机制网络请求不稳定可以引入retrying库或自己写一个简单的重试循环。import time def request_with_retry(url, payload, headers, max_retries3): for i in range(max_retries): try: resp requests.post(url, datapayload, headersheaders, timeout20) resp.raise_for_status() return resp except requests.exceptions.RequestException as e: print(f“请求失败 (尝试 {i1}/{max_retries}): {e}”) if i max_retries - 1: time.sleep(2 ** i) # 指数退避 else: raise # 重试多次后仍失败抛出异常使用会话如果需要连续请求多个页面使用requests.Session()可以保持一些连接参数可能略微提升效率。错误日志记录将下载失败的任务站点、时间记录到一个日志文件中便于后续手动补下或分析原因。遵守Robots协议与设置延时虽然该数据服务对个人和非商业用途通常比较友好但大量、高频的请求仍可能对服务器造成压力。在批量下载时务必在请求之间添加time.sleep(2-5)秒的延时做一个有礼貌的数据获取者。6. 完整脚本代码与使用指南将上述所有步骤整合形成一个完整的、可运行的脚本。这里提供一个精简版的完整示例它包含了核心功能并做了适当的错误处理。“”” Wyoming University Sounding Data Downloader (Updated for new website) Author: [Your Name] Description: Downloads upper-air sounding data in text format from the updated University of Wyoming website. “”” import requests from bs4 import BeautifulSoup import pandas as pd import time from typing import Optional, Tuple class WyomingSoundingDownloader: def __init__(self, base_url‘http://weather.uwyo.edu’): self.base_url base_url self.sounding_page_url base_url ‘/upperair/sounding.html’ self.session requests.Session() self.session.headers.update({ ‘User-Agent’: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36’, ‘Accept’: ‘text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8’, }) def download_sounding(self, station: str, region: str, year: str, month: str, day: str, hour: str ‘12’) - Optional[str]: “””主下载函数返回原始数据文本。””” # 1. 获取初始表单页 try: resp self.session.get(self.sounding_page_url, timeout10) resp.raise_for_status() except Exception as e: print(f“[ERROR] 无法访问主页面: {e}”) return None soup BeautifulSoup(resp.content, ‘html.parser’) form soup.find(‘form’) if not form: print(“[ERROR] 未找到表单。”) return None # 2. 构建payload payload self._build_payload_from_form(form, station, region, year, month, day, hour) # 3. 获取提交地址并发送请求 action form.get(‘action’) if not action: print(“[ERROR] 表单无action属性。”) return None submit_url self.base_url action if action.startswith(‘/’) else self.base_url ‘/’ action try: # Referer需要设置为表单页 data_resp self.session.post(submit_url, datapayload, headers{‘Referer’: self.sounding_page_url}, timeout15) data_resp.raise_for_status() except Exception as e: print(f“[ERROR] 提交数据请求失败: {e}”) return None # 4. 提取数据 return self._extract_data_from_response(data_resp.text) def _build_payload_from_form(self, form, station, region, year, month, day, hour): “””从表单中构建提交参数字典。””” payload {} for tag in form.find_all([‘input’, ‘select’]): name tag.get(‘name’) if not name: continue if tag.name ‘input’: typ tag.get(‘type’, ‘’) if typ in [‘radio’, ‘checkbox’]: if tag.get(‘checked’): payload[name] tag.get(‘value’, ‘’) else: payload[name] tag.get(‘value’, ‘’) elif tag.name ‘select’: selected tag.find(‘option’, selectedTrue) payload[name] selected.get(‘value’) if selected else ‘’ # 覆盖为我们需要的值 payload.update({ ‘region’: region, ‘TYPE’: ‘TEXT:LIST’, ‘YEAR’: year, ‘MONTH’: month, ‘FROM’: day, ‘TO’: day, ‘STNM’: station, ‘FROM12’: hour, ‘TO12’: hour, ‘Send’: ‘Send’ }) return payload def _extract_data_from_response(self, html_text: str) - Optional[str]: “””从返回的HTML中提取pre标签内的数据。””” soup BeautifulSoup(html_text, ‘html.parser’) pre_tag soup.find(‘pre’) if pre_tag: text pre_tag.get_text().strip() # 简单验证数据中应包含站点信息 if text and (‘Station number’ in text or ‘PRES’ in text): return text else: print(“[WARNING] 提取到的文本似乎不包含有效数据。”) return None else: # 尝试寻找其他可能包含数据的标签 for tag_name in [‘code’, ‘div’]: tag soup.find(tag_name, class_lambda x: x and ‘data’ in str(x).lower()) if tag: text tag.get_text().strip() if text: return text print(“[ERROR] 在响应中未找到数据内容。”) return None def save_data(self, data_text: str, filename: str): “””保存原始数据到文件。””” if not data_text: return with open(filename, ‘w’, encoding‘utf-8’) as f: f.write(data_text) print(f“[INFO] 数据已保存至: {filename}”) def main(): downloader WyomingSoundingDownloader() # 单次下载示例 print(“单站单时次下载示例...”) data downloader.download_sounding(station‘54511’, region‘asia’, year‘2023’, month‘08’, day‘15’, hour‘12’) if data: downloader.save_data(data, ‘sounding_54511_20230815_12Z.txt’) # 批量下载 print(“\n批量下载示例 (请谨慎控制频率)...“) tasks [ (‘72502’, ‘naconf’, ‘2023’, ‘08’, ‘01’, ‘00’), (‘72502’, ‘naconf’, ‘2023’, ‘08’, ‘02’, ‘00’), (‘72502’, ‘naconf’, ‘2023’, ‘08’, ‘03’, ‘00’), ] for station, region, yr, mo, dy, hr in tasks: print(f“正在下载 {station} {yr}-{mo}-{dy} {hr}Z ...”) data downloader.download_sounding(station, region, yr, mo, dy, hr) if data: fname f“sounding_{station}_{yr}{mo}{dy}_{hr}Z.txt” downloader.save_data(data, fname) else: print(f“ 下载失败。”) time.sleep(3) # 重要请求间延时 if __name__ ‘__main__’: main()使用指南将上述代码保存为wyoming_sounding_downloader.py。确保已安装requests,beautifulsoup4,pandas。修改main()函数中的参数站号、区域、日期等为你需要的数据。在终端运行python wyoming_sounding_downloader.py。脚本会下载数据并保存为.txt文件。你可以根据需要在save_data函数后添加类似前面save_and_parse_data函数中的解析逻辑将文本转为CSV。这个脚本的核心逻辑是稳健的它模拟了浏览器从查询到获取数据的完整过程能够适应新版网站的结构。如果在未来某天它再次失效你只需要按照第4.1步的方法重新分析一下网页表单的结构调整_build_payload_from_form函数中的参数即可。掌握了这个“抓取-解析”的套路你就拥有了应对大多数类似气象数据网站更新的能力。