ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python爬虫实战:爬取空气质量监测数据全流程

2026/10/5 1:14:52 拓冰建站 浏览量
Python爬虫实战:爬取空气质量监测数据全流程 开头有一段时间我一直在做环境数据分析相关的项目最大的痛点不是算法而是数据从哪来。官方环境监测总站的数据发布页面更新慢、接口不够友好而第三方App的数据又要授权用起来束手束脚。后来我盯上了“中国空气质量在线监测分析平台”这个平台聚合了全国各城市的实时空气质量数据页面展示很直观但直接复制粘贴显然不现实。于是我用Python写了一套爬虫把站点里城市、监测点、AQI、PM2.5、PM10、SO2、NO2、O3、CO这些核心指标落到了本地数据库里后续做可视化和趋势分析都方便了很多。这篇文章我就把这套爬虫的完整思路和踩坑过程写出来。它既适合刚接触爬虫、想练手动态页面采集的朋友也适合做环境数据分析、急需稳定数据源的从业者。我不会只丢一段能跑的代码而是会解释每一步为什么这样做——比如为什么优先找接口而不是解析HTML、为什么要限制请求频率、遇到动态加载又该怎么处理。这些经验比代码本身值钱。1. 目标站点分析与数据特征1.1 平台概况与页面结构“中国空气质量在线监测分析平台”是一个面向公众的空气质量数据展示站点覆盖了全国主要的城市空气监测站。我第一次打开它的时候第一反应是这站点的数据真全全国几百个城市的实时数据和历史趋势都有。页面上的城市列表、AQI排名、污染地图、浓度曲线看起来很适合做数据源。但真正研究过后才发现它并不是一个传统的静态HTML网站。页面上的大部分数据都是通过加载JavaScript动态渲染出来的或者说数据并不直接写在HTML源码里。直接右键查看源码能看到的只有一堆JS初始化代码和容器div数值是在浏览器加载过程中通过异步请求拿到的。这就决定了爬虫的策略不能是“下载HTML然后正则提取”而应该先找到数据背后的接口。做爬虫最忌讳的是拿到页面就开始写解析规则最后发现数据是动态加载的白费力气。我的习惯是打开浏览器开发者工具切到Network面板再刷新页面看哪些请求返回的是JSON或类似结构的数据。1.2 数据接口发现优先抓接口而非解析HTML很多人第一次接触动态页面时习惯用Selenium模拟浏览器觉得这样最省事。但Selenium有两个明显的缺点一是慢每个页面都要启动一个浏览器内核爬几百个城市的时候效率低得让人崩溃二是容易被识别现代网站的JS检测经常会刁难无头浏览器。相比之下直接调用站点内部的JSON接口速度快、数据规整只要不是恶意高频请求通常不会触发风控。我在Network面板里找了下发现当我们切换城市或者选择不同监测点时浏览器会向一个带特定参数的URL发送POST请求返回的是JSON格式的字符串。这个JSON里包含了AQI、各污染物浓度、空气质量等级、首要污染物等信息字段非常完整。再看请求头发现数据接口并不是很复杂的加密接口虽然带了几个看似无效的参数但核心请求就是普通表单提交。于是我把目光集中在这些JSON接口上用Python的requests库直接模拟请求就能拿到和网页上一致的数据。这里给你一个实用技巧在开发者工具中查看请求时不但要看URL和参数还要看请求方法。很多站点虽然是动态渲染但为了前端调用方便接口往往遵循REST风格比如POST请求传城市名和监测点ID返回统一格式的JSON。只要接口稳定爬虫甚至比解析HTML还简单。1.3 需要采集的核心字段与数据粒度做数据采集前先想清楚自己要什么字段。如果眉毛胡子一把抓后续清洗会非常痛苦。我最终确定的采集字典包含这些信息城市名称city监测点名称station时间戳time_pointAQIaqi空气质量等级quality首要污染物primary_pollutantPM2.5浓度pm2_5PM10浓度pm10SO2浓度so2NO2浓度no2O3浓度o3通常指臭氧1小时均值CO浓度co从数据粒度来看有两种选择一种是抓城市整体空气质量数据量小、速度快另一种是抓每个城市下的所有监测点数据能看到各行政区的差异。我当时做的是后者因为分析某城市内部不同区域的污染差异时监测点数据更有价值。不过监测点数量多请求量会翻好几倍这点要看自己的实际需求取舍。2. 环境准备与爬虫设计思路2.1 Python环境与依赖库选型这次爬虫我用的是Python 3.9第三方库选择了requests、pandas、BeautifulSoup4和sqlite3。requests负责发HTTP请求pandas负责数据处理和CSV导出sqlite3用来落地数据库。BeautifulSoup4其实只用来解析城市列表页的静态部分核心数据都是JSON解析起来直接json.loads就行。有人会问为什么不用ScrapyScrapy确实功能强大但对这种轻量级、单站点的抓取来说有点重。用requests写一个几十行的脚本就能完成任务而且调试方便出错时print一下响应内容就清楚了。如果后续需要抓取多个站点再迁移到Scrapy也不迟。工具选型一定以目标复杂度为准不需要为了“显得专业”而过度设计。2.2 爬虫整体架构与请求策略我设计的逻辑分三层获取城市列表和监测点ID。这一步需要解析静态页面或调用城市列表接口拿到每个城市的ID和名称。根据城市ID获取监测点列表。每个城市下通常有多个监测点每个监测点有独立的ID。遍历城市和监测点逐个请求实时数据接口解析JSON并存储。这种三层结构看起来简单但能保证代码清晰也方便以后扩展历史数据抓取。请求策略上我采用了requests.Session来保持会话同时统一设置请求头。Session的好处是可以复用TCP连接减少握手次数对大规模请求有一定的性能提升。2.3 请求头伪装与限速机制直接裸奔用requests访问站点很容易被服务器识别为非浏览器请求。常见的做法是设置User-Agent、Referer、Accept等请求头。我给出的User-Agent使用了一个Chrome浏览器的完整UA字符串Referer设置为站点首页地址这样服务器那边看到的就像是一个正常的浏览器访问。但比请求头更重要的是限速。我见过太多爬虫新手一上来就疯狂并发结果IP被封数据没抓到反而给站点增加了负担。我的做法是每次请求之间sleep随机0.5到1.5秒这样既不会给目标服务器造成压力也能降低被封的风险。如果你是抓几百个监测点加上随机延时整个过程也就几分钟完全没必要贪快。3. 核心实现从请求到结构化存储3.1 城市列表与监测点ID获取城市列表通常可以在站点的静态HTML里直接拿。我打开首页发现页面上有一个城市下拉框里面的城市名称是渲染出来的。查看源码后这些城市名称是写死在HTML里的这就好办了。我用requests请求首页然后用BeautifulSoup解析把option标签里的城市ID和城市名提取出来。核心代码大致是下面这样import requests from bs4 import BeautifulSoup 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: http://www.aqistudy.cn/ } def get_city_list(): url http://www.aqistudy.cn/ # 实际地址请以目标页面为准 resp requests.get(url, headersheaders, timeout10) resp.encoding utf-8 soup BeautifulSoup(resp.text, html.parser) city_select soup.find(select, idcitySelect) # 这个ID需要看实际情况 cities {} for option in city_select.find_all(option): city_id option.get(value) city_name option.get_text(stripTrue) if city_id: cities[city_name] city_id return cities cities get_city_list() print(cities)需要注意的是每个站点的页面结构不同select的id、option的价值属性都要以实际页面为准。我第一次写时直接照搬了网上某篇文章的选择器结果解析出来是空的。最后老老实实打开浏览器开发者工具用Elements面板查看下拉框的DOM结构才找到真正的属性。拿到城市ID后下一步是获取该城市下的监测点。这个信息一般不是静态HTML而是一个JSON接口返回的。接口接受cityid参数返回监测点列表。我用类似的requests逻辑请求这个接口把每个监测点的name和id保存下来。3.2 实时空气质量数据的抓取有了城市ID和监测点ID就可以请求实时数据了。这个实时数据接口是一个POST接口参数中一般包含城市名和请求类型。我调试了一下发现传入参数需要按站点要求的格式组装可能是一个类似JSON的字符串也可能就是普通的表单字段。我最终的实现是根据实际抓包结果来定义payload的。下面是一个伪代码示例具体字段名以你抓到的为准import requests import json def fetch_realtime_data(city_name, station_id): url http://www.aqistudy.cn/apinew/aqistudyapi.php # 仅为示例 # 有些站点要求data字段是加密后的字符串这里需要模拟前端加密逻辑 data { city: city_name, station: station_id, type: realtime } headers { User-Agent: Mozilla/5.0 ..., Referer: http://www.aqistudy.cn/html/, X-Requested-With: XMLHttpRequest } resp requests.post(url, datadata, headersheaders, timeout10) resp.encoding utf-8 return resp.json()如果你遇到的是加密参数最直接的办法是阅读前端JS找到加密函数在Python里复现。我在这里不展开加密逆向的细节因为每个版本的站点实现不一样而且学习爬虫的重点是思路。遇到这类站点时你可以在开发者工具里搜索关键词如“function() { return {”“post”去定位发送请求的代码段一般来说把请求参数拼出来并不困难。成功拿到JSON后解析就变得很简单了。我通常会把它保存为字典再转成DataFrame。这里放一个解析示例def parse_realtime(obj): data obj[data][realtime] # 根据实际结构调整 row { city: data.get(city), station: data.get(station), time_point: data.get(time_point), aqi: data.get(aqi), quality: data.get(quality), primary_pollutant: data.get(primary_pollutant), pm2_5: data.get(pm2_5), pm10: data.get(pm10), so2: data.get(so2), no2: data.get(no2), o3: data.get(o3), co: data.get(co) } return row这里需要特别注意站点返回的字段名可能是中文也可能是英文缩写比如“PM2.5”其实是“pm2_5”也可能带单位。为了后续分析方便我统一在解析时转换成英文字段。单位方面CO一般返回的是mg/m³其他污染物是μg/m³但接口里可能已经带好了具体要自己确认。3.3 历史数据抓取与分页处理实时数据只能反映当前时刻要想做趋势分析必须抓历史数据。这个平台也提供了历史数据查询接口通常允许选择一个时间范围然后返回该时间段内的小时均值或日均值。历史接口和实时接口在参数上略有不同会多一个开始时间和结束时间。我在抓取历史数据时按天循环请求每天的数据单独保存。要注意的是如果某天数据缺失接口返回的可能是一个空数组而不是报错所以解析时要做好判断不能用下标直接访问。我自己写了一个简单的循环来抓取近30天的数据import datetime start_date datetime.date(2024, 1, 1) end_date datetime.date(2024, 1, 31) current start_date while current end_date: date_str current.strftime(%Y-%m-%d) rows fetch_history_data(city_name, station_id, date_str) # 解析rows并入库 print(f{date_str} 抓取完成共{len(rows)}条记录) current datetime.timedelta(days1) time.sleep(random.uniform(0.5, 1))这样做的好处是简单可控坏处是如果历史跨度大请求次数会很多。我建议抓历史数据时优先确认站点是否支持一次返回多天的数据如果不支持再退而求其次按天循环。另外在循环里要处理异常——有些日期可能因为维护导致接口超时这时要记录日志等全部抓完再补一次而不是中断程序。3.4 数据清洗与入库SQLite/CSV示例数据抓到后不能直接用因为接口返回的字段类型五花八门。比如AQI可能是字符串“53”也可能是数字53污染物浓度里有些值会是“—”表示无数据。我在入库前统一做了清洗把“—”和空字符串替换为None把能用float转换的字段转成float时间字段统一格式化为“YYYY-MM-DD HH:MM:SS”。清洗之后我选择了SQLite作为存储方案。SQLite不需要额外安装服务单文件存储特别适合个人项目。建表和插入的代码如下import sqlite3 conn sqlite3.connect(air_quality.db) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS air_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT, station TEXT, time_point TEXT, aqi REAL, quality TEXT, primary_pollutant TEXT, pm2_5 REAL, pm10 REAL, so2 REAL, no2 REAL, o3 REAL, co REAL ) ) def save_row(row): cursor.execute( INSERT INTO air_data (city, station, time_point, aqi, quality, primary_pollutant, pm2_5, pm10, so2, no2, o3, co) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) , (row[city], row[station], row[time_point], row[aqi], row[quality], row[primary_pollutant], row[pm2_5], row[pm10], row[so2], row[no2], row[o3], row[co])) conn.commit()如果你更习惯用CSV也可以用pandas一行导出import pandas as pd df pd.DataFrame(all_rows) df.to_csv(air_quality.csv, indexFalse, encodingutf-8-sig)注意CSV一定要用utf-8-sig编码否则用Excel打开中文会乱码。这个小细节困扰过不少新手我在这里特意提一下。4. 反爬应对与常见问题排查4.1 高频请求导致的IP限制我一开始没有限速用for循环连续请求了上百次结果大概到第60个请求时返回的响应不再是JSON而是一段包含“访问过于频繁”提示的HTML。这说明站点已经对我的IP做了临时限制。遇到这种情况不要慌也不用急着换IP。更稳妥的做法是放慢频率比如每次请求后sleep 1到2秒或者启动一个指数退避策略如果连续失败就暂停更长时间再继续。同时要检查自己的请求间隔是不是太短给服务器造成压力。正常学习用的爬虫频率控制在每秒不超过1次是合理的。如果确实需要大量数据可以考虑在凌晨等非高峰时段分批跑。4.2 动态页面与JavaScript渲染的处理这个平台虽然是动态页面但因为找到了后端JSON接口所以并不需要处理JavaScript渲染。但如果某个站点把接口加密得很厉害或者你是在做通用爬虫遇到非用浏览器不可的场景我给你一个选型建议优先使用Playwright而不是Selenium。Playwright的API更现代支持自动等待、截图、拦截请求而且对反爬的规避能力更强。即便要处理点击、滚动这类交互Playwright也能用几行代码搞定。我的经验是不到万不得已不要用渲染方案因为性能开销大、维护成本高。如果接口能解决优先接口如果接口不可用再考虑渲染。4.3 常见异常及解决方案速查表我在整个爬取过程中遇到了不少重复问题整理成一个速查表希望能帮你少走弯路异常现象可能原因解决方案返回HTML而不是JSON请求头不对或触发反爬检查User-Agent、Referer增加随机延时中文乱码响应编码识别错误手动设置resp.encoding utf-8报SSLError证书校验失败用verifyFalse仅限学习或更新证书频繁超时网络环境或站点限流增加timeout配合重试机制某个城市数据缺失该城市监测站维护中捕获异常并记录后续补抓日期格式不一致接口返回的时间格式不同用datetime模块统一格式化这里有一个比较重要的提醒不要用verifyFalse长期裸奔。虽然它能绕过SSL证书验证但也让连接失去了加密保护。如果只是本地学习用临时加一下没关系但如果你要部署到生产环境还是去解决证书链的问题更安全。另外我建议在代码里加入异常重试机制。比如用十行代码实现一个简单的重试装饰器当请求失败时延时重试最多三次。这个简单的机制能避免很多偶发网络问题导致整个脚本崩溃。5. 数据可视化与进一步应用5.1 用pandasmatplotlib快速绘图数据入库后不做可视化总觉得少了点什么。我习惯先用pandas做一些基础统计分析再用matplotlib画图。比如查看某个城市一周内PM2.5的变化趋势代码非常简洁import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(air_quality.csv) df[time_point] pd.to_datetime(df[time_point]) city_df df[df[city] 北京] city_df city_df.sort_values(time_point) plt.figure(figsize(12, 5)) plt.plot(city_df[time_point], city_df[pm2_5], labelPM2.5) plt.xlabel(时间) plt.ylabel(浓度 (μg/m³)) plt.title(北京PM2.5变化趋势) plt.legend() plt.grid() plt.show()除了单变量趋势我还喜欢用散点图看AQI与PM2.5的相关性或者用箱线图对比不同城市的污染分布。这些分析不需要很复杂的代码但能让你对数据质量有直观感受。如果数据中有明显的离群值可能是某个监测点故障也可以在这个阶段发现。5.2 定时调度与增量更新空气质量数据是持续更新的建议做成定时任务。如果你在Windows上可以用计划任务在Linux上可以用crontab。我自己的服务器是Linux就写了一个简单的crontab脚本0 * * * * cd /path/to/project python crawl.py crawl.log 21这表示每小时整点运行一次爬虫并把日志输出到crawl.log。增量更新的逻辑是在保存前根据city、station、time_point三个字段去查数据库如果记录已存在就跳过否则插入。这样可以避免重复数据堆积。5.3 合规提示与数据使用边界最后我想认真说一句写爬虫要有边界。这篇文章里的代码和思路只适用于学习和技术研究我个人建议你在实际使用前仔细阅读目标网站的robots.txt和用户协议不要拿去大规模采集、倒卖数据或做商业用途。尤其是空气质量数据本质上属于公共数据更应合理使用最终目的是服务社会和环境分析而不是薅站点的资源。我做这个爬虫项目最大的收获不只是拿到了一批数据而是完整走了一遍“发现接口—模拟请求—清洗存储—可视化分析”的流程。这个过程能帮你建立对动态网站数据采集的整体认知。如果你也想爬类似的数据平台不妨先试着用开发者工具找一找接口再动写代码你会发现事情比想象中简单得多。根据自己的经验最后分享一个小技巧在写爬虫时不要把请求参数写死在代码里。把城市列表、监测点列表保存成JSON配置文件每次运行时动态读取这样站点更新字段时你只需要修改配置不用改动主程序。我最初忽略了这点后面换了一个城市范围不得不改代码重新跑浪费了不少时间。先设计好数据流向再敲键盘才是爬虫的正确打开方式。