ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

动态渲染页面爬虫进阶:JS逆向破解空气质量平台加密接口

动态渲染页面爬虫进阶:JS逆向破解空气质量平台加密接口 拿到“爬取中国空气质量在线监测分析平台”这个题目时我第一反应是这可不是一道常规的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而不是execjs。execjs是“在 Python 里调用 Node.js 去执行 JS 代码”的桥接库很多场景下确实方便。但这个项目的核心加密逻辑比较明确是标准 AES 和 DES 算法用 Python 重写反而更可控不依赖本地 Node 环境也没有跨进程调用的性能损耗。所以我最终选择了“逆向出算法用 Python 重写”的方案具体实现在第三部分会详细展开。1.3 爬取前的合规准备与边界意识先把这个话题说透。空气质量监测数据属于公开的环境数据爬取用于个人学习和研究没有问题但这不等于可以“随便爬”。在实际操作中要注意几点控制请求频率不要动辄几十个并发去扫不要采集数据用于商业销售或大规模对外提供接口服务不要在对方服务器明显异常或接口调整时反复重试轰炸。爬虫的本质是“让数据流通更高效”不是“把对方打挂”。我自己的做法是每次请求之间至少间隔 3 到 5 秒单次任务拉取的数据量控制在合理范围内并且只在需要更新数据时运行脚本。后面第五部分会专门给出不同频率档位的访问策略这里先立个规矩后面所有代码都默认遵守这个节奏。2. 抓包分析把“空气数据”的真实来源揪出来2.1 打开开发者工具盯住 XHR 请求在开始写任何代码之前先在浏览器里把站点完整走一遍流程。以 Chrome 为例按 F12 打开开发者工具切到 Network 面板勾选 “Preserve log”然后手动打开平台的“历史数据”页面选择一个城市和时间范围触发一次数据加载。此时 Network 面板会刷出大量请求不要被一堆js、css、png干扰直接在筛选栏输入XHR或Fetch/XHR留下的基本都是数据接口。这个平台的关键接口通常包含api字样点开详情你会看到请求方式是POSTHeaders 里的 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 梳理数据流向从页面点击到数据渲染的完整链路把浏览器里的行为翻译成技术链路大概是这样的用户在页面上选择城市、时间范围、数据类型前端 JS 将选择条件组装成对象随后调用某个加密函数生成param通过POST请求把param和其他字段提交给后端接口后端验证param查询数据库对结果加密后返回前端 JS 拿到响应调用解密函数还原数据并渲染成表格和图表。爬虫要做的就是代替浏览器完成 2 到 5 步找到加密函数、用代码模拟生成param、发送请求、拿到加密响应后解密解析。第 2 步是核心难点也是第三部分要重点讲的“JS 逆向”环节。只要这条链路想通了后面的事情其实都是体力活。3. JS 逆向实战从“加密参数”到“明文数据”3.1 在 JS 代码里定位加密函数要重现加密逻辑就得回到浏览器里继续做“逆向”。切换到开发者工具的 Sources源代码面板搜索关键词param、sign、encrypt、aes、des等。因为前端 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 bYOUR_AES_KEY_16BYTE # AES-128 AES_IV bYOUR_AES_IV_16BYTE DES_KEY bYOUR_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, errorsignore) 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, errorsignore)不要太在意代码里密钥的具体值因为对方站点只要改版密钥几乎必然更新。关键是理解整个链路请求参数由 AES 加密生成响应数据先走 AES 再走 DES。你拿到一个新站点只要在 JS 里看到类似的CryptoJS.AES.encrypt或CryptoJS.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_asciiFalse) 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, datadata, headersheaders, timeout10) 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_asciiFalse, indent2))有一个细节特别容易踩坑二次解密时密文头部可能带了 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 状态码大概率是触发了服务端的访问频率限制。这时候不要硬顶正确做法是立即停止脚本暂停至少 30 分钟检查当前请求频率把间隔从 3 秒调到 8 秒以上给请求头补全浏览器指纹信息包括 User-Agent、Referer、Origin换一个时间段再跑避开对方服务器的业务高峰期。我自己经历过的真实情况是白天高频跑了几百次请求后接口开始随机返回空数据后来把频率降到“每分钟 2 次”并加了随机 sleep连续跑一周都非常稳定。克制才是长期主义。5.3 数据更新策略与生命周期管理空气质量数据有一个特点它和股价一样是强时间序列数据。过去一天的数据不像新闻那样有长期热度所以更新策略非常明确——增量同步旧数据 定时补充新数据。我的做法是把拉数脚本封装成main()函数再接一个schedule定时调度库每天固定时段运行一次。这样脚本就不再是一次性玩具而是一个能持续产出的轻量数据服务。6. 常见问题与排查技巧实录6.1 高频问题速查表实际操作中下面这些坑我几乎都踩过整理成一张表方便你对照排查现象可能原因解决方法状态码 200但响应不是 JSON触发了风控或返回了错误页检查响应体内容降频并补全请求头JSON 里 data 字段解密后是乱码密文头部带时间戳未剥离解密前去掉前 4 字节或检查 key/IV 是否复制完整请求报 Signature 相关错误前端加密算法版本更新回到 JS 里重新定位加密函数更新 key 和 IV返回数据缺少部分城市接口本身只返回常用城市改用全部城市参数或分城市逐一请求中文显示为乱码编码设置不对设置resp.encoding utf-8JSON 解析后一般可恢复脚本跑一段时间后请求超时请求频率过高被限流调大 sleep 间隔增加重试机制param 生成后请求仍失败加密过程缺少某些固定字段回看 JS 源码确认是否有额外拼接参数6.2 独家避坑笔记除了上面那张表还有几个“文档里不会写”的细节。第一逆向 JS 时不要只盯着一个关键词搜。同一个站点可能同时存在sign、param、getParam、encryptData等多种命名建议把格式化后的 JS 全部读一遍再动手。第二抓包时看到的接口域名不一定和当前页面域名一致这很正常跨域请求是现代前端架构的常规操作请求头里的 Origin 和 Referer 一定要以实际抓包为准不要想当然。第三加解密用的密钥如果从 JS 里直接复制很可能会带入多余空格或不可见字符导致解密结果时好时坏。建议先把密钥打印出来检查长度AES-128 的 key 长度是 16 字节IV 也是 16 字节DES 的 key 是 8 字节对不上的时候先怀疑这里。6.3 调试心态与效率技巧最后聊点心态问题。这类“接口加密封装”的站点第一次做的时候花上大半天甚至一两天都很正常。我刚开始写这个项目时卡在 DES 解密的“4 字节时间戳”上面整整一个下午因为网上能找到的资料要么说没有这一步要么给的偏移量是错的。最后还是自己打印出密文长度观察前几位字节的规律才确认了要剥离 4 字节。所以我的建议是如果你发现解密出来的数据有规律性地缺一块、多一块先别急着怀疑算法打印一下密文的长度和十六进制表示很多时候问题就出在“密文前后有附加数据”这件事上。还有日志一定要写全尤其是请求的 URL、参数、状态码、响应体前 100 个字符这些信息在出问题时能帮你节省大量时间。我之前还有一个小技巧把加密、解密函数单独写成模块每次跑通一个环节就固定住不要频繁修改。这样即使后面主流程调整了核心加密解密逻辑也不会被动坏。整个项目最好用 Git 管理每次改版提交一次出了问题随时回滚。我个人实际操作下来的最大体会是这个平台是一个极佳的“JS 逆向入门样本”难度曲线非常平滑。你把它的加密链路吃透之后再去看其他同样用“参数加密 响应加密”的站点基本都能举一反三。最后再分享一个实用小技巧把拉取到的数据入库时顺带在表里加一个last_update时间戳字段。别小看这一列等你想知道“这份数据是什么时候拉的”时候你会发现它比你想的更有用。
返回列表