去年底我接手了一个区域气候分析的小型项目,需求听上去特别简单:把过去三年每隔一小时的气温、湿度、气压、风速、降水量全部拉下来,供后续建模用。结果真正动手才发现,光数据这一关就折腾得我够呛。现成的气象公开API要么有请求次数限制,要么字段粒度对不上,好不容易找到一个能看历史数据的气象信息网页,却发现数据分散在几千个详情页里,手动复制粘贴到猴年马月也弄不完。后来我索性决定用 Python 网络爬虫做一套气候环境数据采集系统,把“人工点点点”变成“定时自己跑”,断断续续踩坑两周,终于跑通了全流程。这篇文章就把这套系统的完整设计思路、实现细节和真实踩坑记录分享出来,希望能帮你少走点弯路。
这套系统适合谁参考?如果你是做环境数据分析、农业气象研究、学术课题,或者手头刚好有类似“定时获取大量网页数据”的需求,却不知道从哪下手,那这篇文章就是给你准备的。不需要你有多深的爬虫基础,但最好有一点 Python 语法常识,会装依赖包就够了。
1. 数据拿到手的第一步,往往不是写代码
很多初学者听到“爬虫”两个字,第一反应就是翻开教程写 requests、写 BeautifulSoup。但真正到了气候环境数据这种长期采集场景,最不该急着动的就是代码。我自己的习惯是,先花半天时间把“要什么、从哪拿、怎么拿、拿到怎么存”这四个问题想清楚,后面写代码反而顺风顺水。
1.1 真实场景:为什么现成接口不够用
市面上确实有一些天气服务商提供免费接口,但实际用起来通常有几个痛点:一是免费额度很小,我当年调试了两次就触顶了,等配额刷新要等一整天;二是历史数据往往需要付费解锁,免费只给当前天气;三是返回字段固定,比如有些接口只给温度和天气现象,没有气压、没有逐时降水。可做气候分析恰恰最需要的就是长周期、多要素、连续稳定的历史序列。
反过来说,很多公开气象信息网站把历史观测数据放在网页上,数据本身是公开的,没有登录墙,也没有验证码,只是没有提供现成接口。这种场景下,用 Python 网络爬虫定时去抓取反而成了最灵活的方案:字段可以自己选,频率可以自己定,数据格式完全可控。
1.2 公开API与自建爬虫的取舍
我整理了一份当时做的对比,直接看表更清楚:
| 对比维度 | 现成API | 自建爬虫 |
|---|---|---|
| 数据字段 | 供应商预设,调整空间小 | 按需提取,完全自定义 |
| 历史深度 | 通常受套餐限制 | 只要页面能翻到,就能抓到 |
| 请求限制 | 配额用尽即停 | 只要合理控制频率,可持续运行 |
| 稳定性 | 接口变更由对方负责 | 页面改版需要自己跟 |
| 法律合规 | 按授权使用 | 需遵守目标站点的服务条款与 robots 协议 |
这表格不是劝你凡事都上爬虫。如果需求只是每天看一眼天气,调用现成API显然是更明智的选择。但如果你要做的是“长期、高频、多字段”的环境数据积累,自建采集系统的价值就体现出来了。我当时还特地确认过目标站点的服务条款和 robots.txt,只抓公开数据、不碰登录接口,这是底线。
1.3 把目标拆成可验收的模块
在动手前,我把系统拆成了下面这几个模块,每个模块都有明确的输入输出:
- 请求模块:负责下载目标页面的 HTML,处理超时、重试、请求头伪装;
- 解析模块:从 HTML 中抽取温度、湿度、气压、风速、降水等字段;
- 清洗模块:处理缺测值、异常值、单位换算,统一时间格式;
- 存储模块:把干净数据写入本地数据库;
- 调度模块:每小时或每天定时触发采集任务;
- 日志模块:记录每次采集的结果,方便排障。
不要小看这个拆分动作。爬虫最忌讳把所有代码堆在一个脚本里,当时能用,一个月后想改一个字段,改一处坏一片。模块化之后,每个环节出了问题,单独看日志就能定位。
2. 技术栈与目录设计:一套能长期维护的骨架
技术选型没有太多花哨,我的原则是“用社区最成熟、文档最多、遇到问题能搜到答案的方案”。整套系统用的都是 Python 生态里非常基础但非常稳的库。
2.1 技术选型:为什么是这套组合
核心依赖如下:
- Python 3.10+:当前主流版本,语法特性足够,兼容性也好;
- requests:HTTP 请求库,比内置 urllib 好用太多,处理重定向、超时、Session 都很顺手;
- BeautifulSoup4 + lxml:HTML 解析库,CSS 选择器提取标签非常方便;
- pandas:做数据清洗、格式统一、缺失值处理,比手写循环高效得多;
- SQLite:零配置文件型数据库,单机采集完全够用,数据量到千万级也扛得住;
- APScheduler:Python 界的定时任务库,支持 cron 表达式和固定间隔,还能设置任务实例数防止重入。
这些库如果没装,直接一行命令搞定:
pip install requests beautifulsoup4 lxml pandas apscheduler顺便提一句环境配置的坑。很多新手在安装 Python 阶段就卡住了,我见过最典型的错误是 Windows 下命令行输入 python 却弹出了 Microsoft Store,这是因为系统 PATH 里没有指定 Python 安装路径。解决办法是安装时勾选 Add Python to PATH,装完在命令行执行python --version确认版本。另外,写完代码跑不起来的时候,先检查是不是在用pip给 A 环境装包,却用 B 环境的解释器跑代码,这类环境错乱问题占了初学阶段报错的半壁江山。
2.2 摸底数据源:先看清页面再动手
写代码前,我花了大半天“研究”目标网页的结构。观察什么?主要是三件事:
第一,数据在页面里是直接渲染的,还是通过异步接口加载的。最简单的判断方法是用浏览器右键查看网页源代码,按 Ctrl+F 搜一个数据字段(比如一个具体温度值)。如果能在原始 HTML 里搜到,说明可以用 requests 直接抓;如果搜不到,说明数据大概率是页面加载完后由 JavaScript 请求接口填充的,那就还要再一步抓包找 XHR 接口。
第二,数据的分页/翻页规律。观察 URL 的变化模式,是参数里带着 offset 还是 date,这决定了我们循环请求的 URL 规则。
第三,每条观测数据对应的信息页链接藏在哪个标签里。大多数详情页链接都是标准的<a href="...">,但有些站点会套一层 JS 跳转,这种情况解析时要额外处理。
2.3 项目目录结构参考
我最终的项目目录长这样,供你参考:
weather_crawler/ ├── config.py # 全局配置:目标URL、请求头、重试次数 ├── fetcher.py # 请求模块 ├── parser.py # 解析模块 ├── cleaner.py # 清洗模块 ├── storage.py # 存储模块 ├── scheduler_job.py # 定时任务入口 ├── requirements.txt # 依赖清单 └── logs/ └── crawler.logconfig.py 单独放的意义是,未来站点 URL 变了、请求频率要调整、某字段要新增,都只需要改配置文件,不用动业务代码。这是长期可维护性的关键。
3. 请求、解析、清洗:三个最容易翻车的环节
核心功能实现起来没有玄学,就是按前面拆分好的模块一个个填代码。但每个模块里都有一些“坑”,我一个个说。
3.1 请求模块:超时、重试与请求头缺一不可
请求模块是整个系统的门面,它要是罢工,后面全是空转。我的 fetcher.py 核心逻辑如下:
import time import random import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" } def fetch_page(url, retries=3, timeout=10): for attempt in range(retries): try: resp = requests.get(url, headers=HEADERS, timeout=timeout) if resp.status_code == 200: return resp raise requests.RequestException(f"HTTP {resp.status_code}") except requests.RequestException: wait_time = 2 ** attempt + random.uniform(0, 1) time.sleep(wait_time) return None这里有几个容易被忽略的点,值得展开说。
第一是timeout参数。很多初学者调用 requests.get 时不加 timeout,结果就是目标站点响应慢的时候,线程一直挂着不返回,定时任务一个接一个堆积。设成 10 秒,超时就重试,宁可失败也不要干等。
第二是User-Agent。不少网站会对默认的 python-requests 客户端直接返回 403。加一个浏览器的 User-Agent 是最基本的请求头伪装,但如果你连续用一个固定的 UA 高频请求,照样会被识别。更稳的做法是准备一个 UA 列表,每次随机取一个:
USER_AGENTS = [...] HEADERS["User-Agent"] = random.choice(USER_AGENTS)第三是指数退避重试。第一次失败后等 2 秒,第二次等 4 秒左右,第三次等 8 秒左右。这种策略比固定等 5 秒更合理:对临时性网络抖动,等得短一点快速恢复;对目标站点可能已经报警的情况,等得长一点避免火上浇油。另外,两次请求之间我还会加一个 1 到 3 秒的随机 sleep,模仿人的浏览节奏,也避免把对方服务器打爆。
3.2 解析模块:从 HTML 中稳定抽取气象字段
拿到 HTML 之后,解析就轮到 BeautifulSoup 上场了。假设目标页面的表格结构是这样的:
<table class="weather-table"> <tr><th>时间</th><th>气温</th><th>湿度</th><th>气压</th><th>风速</th><th>降水量</th></tr> <tr><td>2025-01-01 00:00</td><td>5.2℃</td><td>78%</td><td>1013.4hPa</td><td>12.6km/h</td><td>0.0mm</td></tr> </table>解析代码可以这样写:
from bs4 import BeautifulSoup def parse_weather_table(html): soup = BeautifulSoup(html, "html.parser") rows = soup.select("table.weather-table tr") result = [] for row in rows[1:]: # 跳过表头 cells = [cell.get_text(strip=True) for cell in row.find_all("td")] if len(cells) < 6: continue result.append({ "time": cells[0], "temperature": cells[1], "humidity": cells[2], "pressure": cells[3], "wind_speed": cells[4], "precipitation": cells[5] }) return result解析时最怕的是网页结构和预期不一致。所以我每次写完 parse 函数,都会故意拿一页真实数据测试,把所有字段打印出来人工核对。这里有一个实用技巧:解析时尽量用稳定的属性定位,比如table.weather-table这种 class 选择器,而不要用body > div > table这种绝对路径,因为后者只要页面多了一层 div,解析就全断。
3.3 清洗与校验:别让脏数据污染分析结果
解析出来的数据是“原始字符串”,比如气温可能是"5.2℃"、湿度是"78%",这样的数据没法直接进模型。清洗模块用 pandas 会非常省力:
import pandas as pd def clean_weather_data(raw_list): df = pd.DataFrame(raw_list) if df.empty: return df df["temperature"] = pd.to_numeric( df["temperature"].astype(str).str.replace("℃", ""), errors="coerce" ) df["humidity"] = pd.to_numeric( df["humidity"].astype(str).str.replace("%", ""), errors="coerce" ) df["pressure"] = pd.to_numeric( df["pressure"].astype(str).str.replace("hPa", ""), errors="coerce" ) df["wind_speed"] = pd.to_numeric( df["wind_speed"].astype(str).str.replace("km/h", ""), errors="coerce" ) df["precipitation"] = pd.to_numeric( df["precipitation"].astype(str).str.replace("mm", ""), errors="coerce" ) df["observed_at"] = pd.to_datetime(df["time"], errors="coerce") # 去掉时间和核心字段缺失的行 df = df.dropna(subset=["observed_at", "temperature"]) # 合理性校验:湿度不可能小于0或大于100 df = df[(df["humidity"] >= 0) & (df["humidity"] <= 100)] # 气压合理范围通常在850~1100hPa df = df[(df["pressure"] >= 850) & (df["pressure"] <= 1100)] return dferrors="coerce"的意思是,转不了数字的字符串直接变成 NaN,后续一起处理。这里关键的思路是:爬虫拿到的数据不能“闭眼入库”,一定要做一轮范围和合理性的校验,否则等到分析阶段再发现异常值,溯源就非常痛苦了。
4. 入库与定时调度:数据落地才叫系统
解析完的数据如果只是躺在内存里,关了电脑就没了。真正把它变成一个“系统”,还需要让数据持久化下去,并且定时自动化地跑起来。
4.1 SQLite 表结构设计与写入策略
我用 SQLite 做存储,主要是图它轻量,一个 .db 文件就能带走,不需要单独装数据库服务。建表语句如下:
CREATE TABLE IF NOT EXISTS weather_observation ( id INTEGER PRIMARY KEY AUTOINCREMENT, station_id TEXT NOT NULL, observed_at TEXT NOT NULL, temperature_c REAL, humidity_percent REAL, pressure_hpa REAL, wind_speed_kmh REAL, precipitation_mm REAL, created_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(station_id, observed_at) );这里最核心的是UNIQUE(station_id, observed_at)这个约束。它保证的是,同一站点同一观测时刻最多只有一条记录,后面重复采集也不会插重。这个约束在后续运行中帮我挡掉了大量重复数据。
写入的代码也很简单,使用 pandas 的to_sql方法:
import sqlite3 def save_to_sqlite(df, db_path, station_id): if df is None or df.empty: return 0 df = df.copy() df["station_id"] = station_id # 只保留需要的列 df = df[["station_id", "observed_at", "temperature_c", "humidity_percent", "pressure_hpa", "wind_speed_kmh", "precipitation_mm"]] conn = sqlite3.connect(db_path) try: # if_exists="append" 表示追加写入 df.to_sql("weather_observation", conn, if_exists="append", index=False) finally: conn.close() return len(df)要注意的是,UNIQUE约束在数据重复时会直接抛异常。实际使用中我用的是INSERT OR IGNORE的思路,但 pandas 的to_sql没有直接提供这个选项。更稳妥的做法是:先查库里已有的时间点,把要插入的数据过滤一遍再写。数据量不大的时候这样最干净。
4.2 APScheduler 定时调度与防重入
采集任务本身不难,难的是让它稳定地跑。这里我用到了 APScheduler,可以精确控制采集频率。比如每小时采集一次:
from apscheduler.schedulers.blocking import BlockingScheduler from datetime import datetime def job(): print(f"[{datetime.now()}] start collecting...") # 获取目标URL列表 # 逐个请求、解析、清洗、入库 scheduler = BlockingScheduler() scheduler.add_job( job, "interval", hours=1, next_run_time=datetime.now(), max_instances=1, coalesce=True ) scheduler.start()max_instances=1非常关键,它的作用是如果上一次任务还没跑完,下一次触发就直接跳过,而不是开一个并发任务。气候数据采集有时候会遇到目标站点响应缓慢,一个任务可能跑四五分钟,如果没有这个参数,任务就会叠起来,轻则数据重复,重则被目标站点封掉。coalesce=True则是在错过多次调度后,只补跑一次,避免积压一堆任务。
4.3 日志与监控,故障时快速定位
程序跑起来容易,但你在睡觉的时候它悄悄挂了,才是最头疼的。所以我给系统加了两道保险:日志和告警。
日志用 Python 自带的 logging 就行,关键是要把每次请求的 URL、状态码、解析行数、入库条数都打出来:
import logging logging.basicConfig( filename="logs/crawler.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s" ) def record(url, status, parsed, saved): logging.info(f"url={url}, status={status}, parsed={parsed}, saved={saved}")告警层面,我用了一个“心跳文件”的思路:每次成功入库后,在指定目录写一个带时间戳的心跳文件;然后用一个外部监控脚本(比如 cron 任务)检查心跳文件是否在最近两个小时内更新过。如果超过两小时没更新,就发一封邮件通知自己。这套方案虽然没有专业监控平台那么花哨,但胜在简单可靠,跑了小半年没出过大漏子。
5. 那些让采集器“突然罢工”的坑及排查过程
系统上线头两周,我几乎每天都会收到自己的告警邮件。每次排查都是一次经验积累,我把其中最有代表性的三个问题整理出来,每个都附上了完整的排查链路。
5.1 第一坑:页面编码导致的中文乱码
某天我发现入库的数据里,站点名称字段全是乱码,像''天气这种。第一反应是解析写错了,仔细一查,发现源页面返回的编码是gb2312,而 requests 却按ISO-8859-1去解码了。
排查过程是这样一步步走的:先在浏览器开发者工具里看响应头的 Content-Type,发现没有明确 charset;再用 requests 打印resp.apparent_encoding,得到的是GB2312;最终确认问题根源是响应头缺失编码声明。解决办法是在请求时手动指定编码,或者在解析前强制转码:
resp.encoding = resp.apparent_encoding这里有个细节,resp.text的取值时机很关键。如果你调用过resp.text,requests 就已经帮你解码了,之后的resp.encoding修改不会再影响resp.text,只能通过resp.content.decode("gb2312", errors="ignore")重新取。所以我建议,拿到响应后第一时间确定编码,再决定用哪种方式取内容。这个坑我栽过,后来在 fetcher 模块里固定写死:先设置 encoding,再取 text。
5.2 第二坑:字段错位,解析出来的数据对不上表头
第二次告警是因为入库数据出现了“湿度比气温还高”的荒唐值。我最初以为是清洗校验没生效,后来打印出原始解析结果才发现,某一页的表格结构跟之前的页面不一样——表头变了,列的顺序换了,有两个字段多了一个嵌套标签。BeautifulSoup 按td顺序取值,自然就把温度和湿度放错了位置。
这次的排查链路其实很短:取一页最新数据,把解析结果逐行打印出来,和页面肉眼比对,瞬间就看到了错位。但根本原因值得反思:目标网站改版不是一次性的,而是某些页面已更新、某些页面还是旧结构。这提醒我,解析时必须加“字段位置校验”,而不能默认表头永远不变。我的做法是,解析时先读取表头列名,把它映射到我们约定的字段名,再按列名去取数据。这样即使列顺序变了,只要列名还在,就不会错位。
def parse_weather_table_with_header(html): soup = BeautifulSoup(html, "html.parser") table = soup.find("table", class_="weather-table") header_cells = table.find("tr").find_all(["th", "td"]) header = [c.get_text(strip=True) for c in header_cells] columns = [] for h in header: if "时间" in h: columns.append("time") elif "气温" in h: columns.append("temperature") elif "湿度" in h: columns.append("humidity") # ... 其他字段映射 rows = table.find_all("tr")[1:] result = [] for row in rows: cells = [c.get_text(strip=True) for c in row.find_all("td")] result.append(dict(zip(columns, cells))) return result5.3 第三坑:目标站点返回 403,被识别为机器人
运行到第三周,某天任务突然连续失败,日志里清一色的 403。这算是爬虫生命周期里必然会遇到的问题:对方服务器检测到高频同类请求,决定拒绝服务。
我的排查链路是这样的:先确认不是自己代码报错——看日志发现请求确实发出去了,响应确实返回了 403;再人工打开目标网址,浏览器能正常访问,说明是服务器的反爬机制在起作用;最后检查自己最近的请求频率,发现因为之前加了指数退避重试,某个失败瞬间会短时间内重试三次,加上采集周期本身设置得太短,累计请求量确实上去了。
解决方案分几步:拉长请求间隔,把每小时一次改成每两小时一次;请求头里随机换 User-Agent;请求失败后不再连续快速重试,而是把失败 URL 记到日志里,等下一轮调度再补。这里要强调一点:我的原则是不做绕过对方安全措施的事,只是把自己这边的请求方式做得更合理、更克制。实际上,只要采集频率控制在正常人浏览的范围内,绝大多数公开数据站点都不会为难你。
6. 后续扩展:从一个站点到一片数据网络
这套采集系统跑稳定后,我的注意力就从“怎么不挂”转向了“怎么用得更好”。这里分享几个我觉得值得继续投入的方向,这些也是我目前在逐步落地的功能。
6.1 多站点并行采集与统一治理
单一数据源始终有风险,万一对方站点改版或者直接下线,你的数据链就断了。更稳妥的做法是接入多个公开数据源,同一个观测时刻的数据可以交叉验证。在实现上,我用concurrent.futures.ThreadPoolExecutor控制并发度,但这里有个非常重要的经验:多站点并发不是线程越多越好。对同样的站点并发太多会被反爬,对不同站点并发太多就失去了“模拟人手操作”的合理性。我自己的设置是线程池大小 3,且同一个站点内部的请求还是保持串行加随机延时。
多站点带来的另一个问题是数据结构不统一。有的站点给的是华氏温度,有的给的是摄氏度;有的风速给 m/s,有的给 km/h。这些都需要在清洗模块里做归一化。我建议在数据库里增加两列:source_site和raw_data(存原始文本),这样后续出现异常可以倒查原始数据,而不是对着被清洗过的数字猜。
6.2 从采集到可视化,打通数据链路
数据采集只是第一步,采集之后的可视化和分析才是价值落地的环节。我现在会在采集完成后,自动把当天的新数据追加到一个汇总 CSV,然后用 Jupyter Notebook 做探索性分析,画时间序列曲线、做相关性分析。这块使用的是 pandas 和 matplotlib,代码量不大,但收益很直观:之前需要手动导数据、清洗、画图的流程,现在全部自动化了。
如果你有更复杂的分析需求,还可以把 SQLite 的数据接入到 pandas 后直接跑 sklearn 模型,比如用历史气象数据训练一个短时气温预测模型。到这里,爬虫的角色就从“抓数据的小工具”变成了“数据管道的第一环”。
6.3 数据质量评估与异常检测
系统跑久了我还有个新体会:长期采集的数据,质量评估比数量积累更重要。数据链路里可能出现设备临时故障、站点维护、网络超时等导致的缺测,也可能是站点本身的数据源出了问题。这些异常在单条记录里很难发现,但在时间序列里一目了然。
我现在做了一个简单但非常有效的数据质量日报:每次采集并入库后,按站点和日期统计缺失率、最大值、最小值、均值,如果某个站点的某天气温最大最小值之差小于 1 度,或者缺失率突增到 30% 以上,就把这个站点标记为“待人工核查”,并自动把这些异常汇总到每周邮件里。这套机制帮我发现过两次目标站点数据更新异常的问题,比等到分析阶段再发现要省心得多。
回过头来看,这套基于 Python 网络爬虫的环境数据采集系统,本质上是把一件“枯燥重复但逻辑清晰”的工作自动化了。真正的收获不在于写了几百行代码,而在于想清楚了请求、解析、清洗、存储、调度、监控这一整条链路的每一步为什么这样设计。如果你也在搭类似的采集系统,我的建议是先把“怎么不挂”想明白,再考虑“怎么抓更多”。控制频率、做好日志、设计好幂等约束,这三件事做好了,系统基本就稳定了。