动态渲染页面爬虫进阶:JS逆向破解空气质量平台加密接口
2026/9/16 4:55:00 网站建设 项目流程

拿到“爬取中国空气质量在线监测分析平台”这个题目时,我第一反应是:这可不是一道常规的requests + BeautifulSoup入门题,而是一块非常经典的“动态渲染页面 + 参数加密”组合型硬骨头。这个平台展示的数据是公开的环境质量信息,适合做数据分析,但网页本身早已不是“改个 URL 就能拿到全部内容”的静态页面,直接抓 HTML 只会看到一片空壳。想拿到真实数据,必须走完“抓包分析 → 接口定位 → 参数逆向 → 解密解析”这条完整链路。

我当初踩完一遍之后最大的感受是:这个项目非常适合作为新手向“JS 逆向”和“动态页面爬取”进阶的第一课。它不像电商平台那样反爬层层加码,也不像政府公开数据那样直接给 CSV 下载。它恰好卡在一个“有门槛但门槛不高”的位置:有动态渲染、有签名参数、有加密响应体,但算法本身是标准 AES/DES,没有极端的混淆和风控。把这套流程跑通,你再去碰其他“看似复杂”的数据型网站,思路会清晰很多。

这篇文章我会把从零到一的全过程拆开讲,包括目标分析、抓包方法、JS 逆向思路、Python 实现、数据存储和可视化,以及我在实际操作中遇到的坑。无论你是刚学完爬虫基础想进阶,还是做环境数据分析需要自己拉数,这套流程都能直接参考。

1. 目标平台分析与爬取前准备

1.1 为什么不能“直接爬”页面

中国空气质量在线监测分析平台(aqistudy.cn)这类站点,核心数据不是写在 HTML 源码里的。我最早尝试直接请求首页,拿到的只是一堆 JS 文件和框架容器,里面既没有 AQI 数值也没有城市列表。这说明站点采用的是典型的“后端渲染 + 前端异步加载”模式:页面只是空壳,数据由 JavaScript 在浏览器里执行后,通过 Ajax 请求从后端接口拿,再动态填充到表格和图表中。

拿现实场景类比,网页像是一个商场里的展示柜台,你站柜台前看到的是琳琅满目的商品,但商品并不是柜台自己生产的,而是后台仓库通过传送带送到柜台上。爬虫要做的不是趴在柜台玻璃上抄标签,而是直接找到仓库的进货口。那个进货口,就是浏览器开发者工具里 NetWork 面板下蹦出来的 XHR 请求。这也是很多爬虫教程反复强调“别急着写正则,先学会看请求”的原因。

1.2 工具准备和环境搭建

我这次用的是最轻量的一套组合:requests负责发请求,pycryptodome负责解密,pandas负责整理数据,sqlite3负责本地存储,最后用matplotlib做可视化。因为不涉及多线程和分布式调度,纯 Python 环境足够,不需要上 Scrapy。

如果你打算跟着这篇文章实操,建议先用 Python 3.9 或更高版本建一个干净的虚拟环境,然后一次性装好这些依赖:

pip install requests pycryptodome pandas matplotlib openpyxl

这里单独说明一下为什么选pycryptodome而不是execjsexecjs是“在 Python 里调用 Node.js 去执行 JS 代码”的桥接库,很多场景下确实方便。但这个项目的核心加密逻辑比较明确,是标准 AES 和 DES 算法,用 Python 重写反而更可控,不依赖本地 Node 环境,也没有跨进程调用的性能损耗。所以我最终选择了“逆向出算法,用 Python 重写”的方案,具体实现在第三部分会详细展开。

1.3 爬取前的合规准备与边界意识

先把这个话题说透。空气质量监测数据属于公开的环境数据,爬取用于个人学习和研究没有问题,但这不等于可以“随便爬”。在实际操作中要注意几点:控制请求频率,不要动辄几十个并发去扫;不要采集数据用于商业销售或大规模对外提供接口服务;不要在对方服务器明显异常或接口调整时反复重试轰炸。爬虫的本质是“让数据流通更高效”,不是“把对方打挂”。

我自己的做法是:每次请求之间至少间隔 3 到 5 秒,单次任务拉取的数据量控制在合理范围内,并且只在需要更新数据时运行脚本。后面第五部分会专门给出不同频率档位的访问策略,这里先立个规矩,后面所有代码都默认遵守这个节奏。

2. 抓包分析:把“空气数据”的真实来源揪出来

2.1 打开开发者工具,盯住 XHR 请求

在开始写任何代码之前,先在浏览器里把站点完整走一遍流程。以 Chrome 为例,按 F12 打开开发者工具,切到 Network 面板,勾选 “Preserve log”,然后手动打开平台的“历史数据”页面,选择一个城市和时间范围,触发一次数据加载。

此时 Network 面板会刷出大量请求,不要被一堆jscsspng干扰,直接在筛选栏输入XHRFetch/XHR,留下的基本都是数据接口。这个平台的关键接口通常包含api字样,点开详情,你会看到请求方式是POST,Headers 里的 Content-Type 是表单类型,Payload 部分是几个看起来像乱码的参数。

我第一次看到那些参数的时候,确实有点懵:明明页面上只选择了“北京、最近 24 小时、AQI”,但发给后端的却是一长串加密字符,完全看不出语义。这种设计在数据型站点里其实非常普遍,目的就是防止别人像我这样直接刷接口拿数据。不过页面能展示,说明前端 JS 里有完整的加密逻辑,只要耐心找,一定找得到。

2.2 参数结构和响应结构的第一次拆解

通过抓包你会发现,POST 请求的表单字段一般是这样的结构:

city: 北京 type: day startTime: 2024-01-01 endTime: 2024-01-07 param: xK9...(一长串密文)

这里面的关键就是param。前几个字段(city、type、startTime、endTime)是明文,一眼能看懂;但真正决定“后端是否返回数据”的,是经过加密的param。后端拿到请求后,会先校验param是否合法,校验通过才返回加密数据。

再来看响应体,通常会返回一个 JSON 结构,里面除了success这样的状态字段,还有一个data字段,而data本身又是加密字符串。也就是说,这个平台做了“请求参数加密 + 响应数据加密”的双层保护。加密套加密,如果不逆向,你即使拿到了响应,也只是一堆没法读的乱码。

2.3 梳理数据流向:从页面点击到数据渲染的完整链路

把浏览器里的行为翻译成技术链路,大概是这样的:

  1. 用户在页面上选择城市、时间范围、数据类型;
  2. 前端 JS 将选择条件组装成对象,随后调用某个加密函数,生成param
  3. 通过POST请求把param和其他字段提交给后端接口;
  4. 后端验证param,查询数据库,对结果加密后返回;
  5. 前端 JS 拿到响应,调用解密函数,还原数据并渲染成表格和图表。

爬虫要做的,就是代替浏览器完成 2 到 5 步:找到加密函数、用代码模拟生成param、发送请求、拿到加密响应后解密解析。第 2 步是核心难点,也是第三部分要重点讲的“JS 逆向”环节。只要这条链路想通了,后面的事情其实都是体力活。

3. JS 逆向实战:从“加密参数”到“明文数据”

3.1 在 JS 代码里定位加密函数

要重现加密逻辑,就得回到浏览器里继续做“逆向”。切换到开发者工具的 Sources(源代码)面板,搜索关键词paramsignencryptaesdes等。因为前端 JS 一般不会压缩成一行(即使压缩了也有格式化按钮),所以搜索命中后,把相关代码格式化,基本就能看到加密函数长什么样。

我实际定位到的逻辑大致是:前端把查询条件对象转换成 JSON 字符串,然后对这个字符串做第一次 AES 加密,得到param。请求返回的响应数据也不是明文,需要先对最外层数据做一次 AES 解密,拿到中间结果,再对中间结果里的data字段做一次 DES 解密,最终才得到真正可读的 JSON。

这里有个很微妙的细节:AES 和 DES 的 key 与 IV(初始向量)都是写死在 JS 文件里的。有些站点会动态生成 key,这个平台当时我看到的是固定密钥,这大大降低了逆向难度。如果你以后做其他站点遇到动态 key,思路也是一样的,只是要把“生成 key”的逻辑也逆向出来,通常藏在某个函数返回值里。

3.2 方案取舍:为什么选择“Python 重写”而不是“JS 注入”

这一节说说我的方案选型过程。理论上,逆向出 JS 加密逻辑后,有两种落地方式。

第一种是“JS 注入”,把加密和解密函数原封不动抄进一个.js文件,再用execjs在 Python 里调用。优点是少写代码,缺点是必须安装 Node.js,而且每次请求都要起一次子进程,性能一般。

第二种是“Python 重写”,读懂 JS 里的算法模式后,用pycryptodome库在 Python 里照样实现一套。优点是不依赖外部运行时,代码更可控,后续做数据分析和定时任务都方便。

我选了第二种,理由很实际:这个站点的加密算法是标准算法,没有魔改 S 盒这种极端操作,重写成本极低。如果你是零基础刚接触“密码学”,我建议先把 AES-CBC 和 DES 的基本工作模式弄清楚,再看代码会轻松很多。

下面是重写后加密和解密工具类的核心示例(密钥和 IV 用占位符表示,实际使用时需要从 JS 文件中提取):

import base64 import json import time from Crypto.Cipher import AES, DES from Crypto.Util.Padding import pad, unpad # 从 JS 逆向中提取的密钥与偏移量(示例,切勿直接使用玩) AES_KEY = b"YOUR_AES_KEY_16BYTE" # AES-128 AES_IV = b"YOUR_AES_IV_16BYTE" DES_KEY = b"YOUR_DES_KEY_8BYTE" # DES 密钥长度为 8 字节 def aes_encrypt(plain_text: str) -> str: """模拟前端生成 param 参数""" cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV) padded = pad(plain_text.encode("utf-8"), AES.block_size) return base64.b64encode(cipher.encrypt(padded)).decode("utf-8") def aes_decrypt(cipher_text: str) -> str: """第一层解密:外层 AES 解密""" cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV) raw = base64.b64decode(cipher_text) decrypted = unpad(cipher.decrypt(raw), AES.block_size) return decrypted.decode("utf-8", errors="ignore") def des_decrypt(cipher_text: str) -> str: """第二层解密:内层 DES 解密""" raw = base64.b64decode(cipher_text) # 结合逆向结果,部分版本会先剥离前 4 字节时间戳 if len(raw) > 4: raw = raw[4:] cipher = DES.new(DES_KEY, DES.MODE_ECB) decrypted = unpad(cipher.decrypt(raw), DES.block_size) return decrypted.decode("utf-8", errors="ignore")

不要太在意代码里密钥的具体值,因为对方站点只要改版,密钥几乎必然更新。关键是理解整个链路:请求参数由 AES 加密生成,响应数据先走 AES 再走 DES。你拿到一个新站点,只要在 JS 里看到类似的CryptoJS.AES.encryptCryptoJS.DES.decrypt调用,就能用同样的思路去处理。

3.3 解密后的数据结构和字段说明

解密完成后,你拿到的是一段完整的 JSON 字符串,里面通常包含按城市排列的监测数据。常见字段包括:

timePoint: 监测时间点 city: 城市名称 AQI: 空气质量指数 PM2.5: 细颗粒物浓度 PM10: 可吸入颗粒物浓度 NO2: 二氧化氮浓度 SO2: 二氧化硫浓度 O3: 臭氧浓度 CO: 一氧化碳浓度 level: 空气质量等级(优、良、轻度污染等)

因为原始字段名可能是英文缩写,直接看 JSON 会不够直观,我在解析时会把字段重命名成中文,方便后续做 DataFrame。这一步在后面数据清洗部分会详细说明。

3.4 完整请求流程代码参考

把加密、请求、解密串起来,一个最小可用的采集函数大概是这样的:

import requests import json import time def fetch_air_data(city: str, start_time: str, end_time: str, data_type: str = "day"): url = "https://www.aqistudy.cn/apinew/aqistudyapi.php" # 以抓包结果为准 param_text = json.dumps({ "city": city, "type": data_type, "startTime": start_time, "endTime": end_time, }, ensure_ascii=False) param = aes_encrypt(param_text) data = { "city": city, "type": data_type, "startTime": start_time, "endTime": end_time, "param": param, } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://www.aqistudy.cn/", "Origin": "https://www.aqistudy.cn", } resp = requests.post(url, data=data, headers=headers, timeout=10) resp.encoding = "utf-8" json_data = resp.json() # 解密响应 if json_data.get("success"): encrypted_data = json_data["data"] middle_text = aes_decrypt(encrypted_data) middle_json = json.loads(middle_text) final_text = des_decrypt(middle_json["data"]) return json.loads(final_text) return None if __name__ == "__main__": result = fetch_air_data("北京", "2024-01-01", "2024-01-07") print(json.dumps(result, ensure_ascii=False, indent=2))

有一个细节特别容易踩坑:二次解密时,密文头部可能带了 4 字节时间戳,这个时间戳是后端加固密时嵌入的,直接用 DES 解会报错或者出乱码。处理方式就是先切片去掉前 4 字节,再做解密。这也是为什么我说“解密比加密更容易翻车”,因为你看到的密文未必是“纯密文”。

4. 数据清洗、存储与可视化联动

4.1 把嵌套 JSON 变成规整表格

解密后拿到的 JSON 往往是一个多级嵌套结构,比如按日期分组,日期下面挂城市,城市下面挂指标。直接看没问题,想分析就得先展平成表格。我的做法是用pandas.json_normalize展开嵌套字典,再重命名列:

import pandas as pd def normalize_air_data(raw_json): # 假设 raw_json 里有一个 records 列表,每个元素是一条监测记录 records = raw_json.get("records", []) df = pd.DataFrame(records) df = df.rename(columns={ "timePoint": "监测时间", "city": "城市", "AQI": "AQI", "PM2.5": "PM2.5", "PM10": "PM10", "NO2": "NO2", "SO2": "SO2", "O3": "O3", "CO": "CO", "level": "空气质量等级", }) return df

这一步不需要太纠结具体列名,核心思路是一个原则:嵌套数据就展开,英文缩写就改名。只有先变成“一行一条记录”的规整表,后面才能愉快地分析和作图。

4.2 SQLite 存储与增量更新策略

数据量不大时,SQLite 是最省事的存储方案。我建了一张air_quality表,字段覆盖时间、城市、AQI 和六项污染物,并用(城市, 监测时间)作为联合主键。这样即使脚本重复跑,也不会产生重复记录。

CREATE TABLE IF NOT EXISTS air_quality ( city TEXT, time_point TEXT, aqi INTEGER, pm25 REAL, pm10 REAL, no2 REAL, so2 REAL, o3 REAL, co REAL, level TEXT, PRIMARY KEY (city, time_point) );

写入时用INSERT OR REPLACE,这样同一条记录有新数据就更新,没有就插入,省心。

4.3 可视化:把数据变成看得懂的图

数据存下来之后,可视化才是最有成就感的一步。我一般会画三种图:空气质量等级分布的饼图、AQI 排名前十的城市条形图、某个城市连续 24 小时 AQI 变化折线图。

这里特别提醒一句:matplotlib默认字体对中文支持不好,画出来的图全是方块。提前设置中文字体很关键,推荐用系统自带的 SimHei 或微软雅黑:

import matplotlib.pyplot as plt plt.rcParams["font.sans-serif"] = ["SimHei", "Microsoft YaHei"] plt.rcParams["axes.unicode_minus"] = False

设置完之后再画图,中文标签基本不会再有问题。

5. 访问频率控制与反爬规避策略

5.1 三种访问策略,按需选择

我把自己实践过的三种方案整理成了一张表,你可以根据实际需求选择:

策略请求频率优点适合场景
低频率手动触发每 5~10 秒一次,只拉少量数据对服务器压力最小,几乎不会被限流学习练习、临时分析
中频率脚本定时每分钟 1~2 次,分时段拉取可以自动化更新数据个人长期数据积累
高频率并发批量每秒多次并发拉数效率高不推荐,极易触发风控

我个人的选择是中频率脚本定时。比如写一个定时任务,每天早上 9 点自动拉一次前一天的数据入库,足够覆盖空气质量分析的需求。不要试图一口气拉过去十年的历史数据,一方面响应数据量极大,另一方面接口大概率会超时或限流,得不偿失。

5.2 遇到风控限流怎么处理

如果跑着跑着发现返回的不是 JSON,而是 HTML 验证页或者 503 状态码,大概率是触发了服务端的访问频率限制。这时候不要硬顶,正确做法是:

  1. 立即停止脚本,暂停至少 30 分钟;
  2. 检查当前请求频率,把间隔从 3 秒调到 8 秒以上;
  3. 给请求头补全浏览器指纹信息,包括 User-Agent、Referer、Origin;
  4. 换一个时间段再跑,避开对方服务器的业务高峰期。

我自己经历过的真实情况是:白天高频跑了几百次请求后,接口开始随机返回空数据;后来把频率降到“每分钟 2 次”并加了随机 sleep,连续跑一周都非常稳定。克制才是长期主义。

5.3 数据更新策略与生命周期管理

空气质量数据有一个特点:它和股价一样,是强时间序列数据。过去一天的数据不像新闻那样有长期热度,所以更新策略非常明确——增量同步旧数据 + 定时补充新数据。我的做法是把拉数脚本封装成main()函数,再接一个schedule定时调度库,每天固定时段运行一次。这样脚本就不再是一次性玩具,而是一个能持续产出的轻量数据服务。

6. 常见问题与排查技巧实录

6.1 高频问题速查表

实际操作中,下面这些坑我几乎都踩过,整理成一张表方便你对照排查:

现象可能原因解决方法
状态码 200,但响应不是 JSON触发了风控或返回了错误页检查响应体内容,降频并补全请求头
JSON 里 data 字段解密后是乱码密文头部带时间戳未剥离解密前去掉前 4 字节,或检查 key/IV 是否复制完整
请求报 Signature 相关错误前端加密算法版本更新回到 JS 里重新定位加密函数,更新 key 和 IV
返回数据缺少部分城市接口本身只返回常用城市改用全部城市参数,或分城市逐一请求
中文显示为乱码编码设置不对设置resp.encoding = "utf-8",JSON 解析后一般可恢复
脚本跑一段时间后请求超时请求频率过高被限流调大 sleep 间隔,增加重试机制
param 生成后请求仍失败加密过程缺少某些固定字段回看 JS 源码,确认是否有额外拼接参数

6.2 独家避坑笔记

除了上面那张表,还有几个“文档里不会写”的细节。第一,逆向 JS 时不要只盯着一个关键词搜。同一个站点可能同时存在signparamgetParamencryptData等多种命名,建议把格式化后的 JS 全部读一遍再动手。第二,抓包时看到的接口域名不一定和当前页面域名一致,这很正常,跨域请求是现代前端架构的常规操作,请求头里的 Origin 和 Referer 一定要以实际抓包为准,不要想当然。第三,加解密用的密钥如果从 JS 里直接复制,很可能会带入多余空格或不可见字符,导致解密结果时好时坏。建议先把密钥打印出来检查长度,AES-128 的 key 长度是 16 字节,IV 也是 16 字节,DES 的 key 是 8 字节,对不上的时候先怀疑这里。

6.3 调试心态与效率技巧

最后聊点心态问题。这类“接口加密封装”的站点,第一次做的时候花上大半天甚至一两天都很正常。我刚开始写这个项目时,卡在 DES 解密的“4 字节时间戳”上面整整一个下午,因为网上能找到的资料要么说没有这一步,要么给的偏移量是错的。最后还是自己打印出密文长度,观察前几位字节的规律,才确认了要剥离 4 字节。

所以我的建议是:如果你发现解密出来的数据有规律性地缺一块、多一块,先别急着怀疑算法,打印一下密文的长度和十六进制表示,很多时候问题就出在“密文前后有附加数据”这件事上。还有,日志一定要写全,尤其是请求的 URL、参数、状态码、响应体前 100 个字符,这些信息在出问题时能帮你节省大量时间。

我之前还有一个小技巧:把加密、解密函数单独写成模块,每次跑通一个环节就固定住,不要频繁修改。这样即使后面主流程调整了,核心加密解密逻辑也不会被动坏。整个项目最好用 Git 管理,每次改版提交一次,出了问题随时回滚。

我个人实际操作下来的最大体会是:这个平台是一个极佳的“JS 逆向入门样本”,难度曲线非常平滑。你把它的加密链路吃透之后,再去看其他同样用“参数加密 + 响应加密”的站点,基本都能举一反三。最后再分享一个实用小技巧:把拉取到的数据入库时,顺带在表里加一个last_update时间戳字段。别小看这一列,等你想知道“这份数据是什么时候拉的”时候,你会发现它比你想的更有用。

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

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

立即咨询