1. 项目概述与核心需求解析
最近在分析一些地方政务公开数据时,遇到了一个典型的案例:湖南省农机购置与应用补贴信息的查询接口。这个接口的数据对分析地方农业政策落地、农机市场趋势很有价值,但和很多政务数据接口一样,它并非“裸奔”,而是对请求参数和响应数据做了简单的加密处理。项目标题里提到“难度一般,扣代码即可,无需补环境”,这基本定调了——这不是一个需要复杂逆向、模拟浏览器环境或处理高强度混淆的硬骨头,而是一个典型的“逻辑加密”场景。我们的目标很明确:用 Python 这把“螺丝刀”,把前端 JavaScript 中的加密逻辑“抠”出来,在本地复现,从而能稳定、自动化地获取到解密后的 JSON 数据。
为什么这类接口值得一做?首先,数据源稳定且权威,来自官方平台,数据质量有保障。其次,加密方式相对固定,一旦破解,可以长期使用。最后,这类数据在农业经济分析、区域产业研究甚至商业情报收集方面都有实际应用场景。比如,你可以分析哪些型号的拖拉机在湖南最受欢迎,补贴资金流向哪些县市,从而洞察市场的真实需求。整个过程,我们完全在本地 Python 环境中完成,不依赖浏览器自动化,效率高、资源消耗小,非常适合作为爬虫工程师处理常见数据加密的入门到进阶的练手项目。
2. 逆向分析与加密逻辑定位
面对一个加密接口,第一步永远是观察。打开浏览器开发者工具(F12),切换到 Network(网络)标签页,找到目标请求。通常这类补贴查询是一个POST请求,请求体(Payload)不是明文的form-data或x-www-form-urlencoded,而是一串看起来像乱码的字符串,或者是一个结构体里包含了data、encryptData、sign等字段。响应体(Response)同样可能是一串加密后的字符串,而非直观的 JSON。
2.1 关键线索:搜索与断点
前端加密逻辑必然存在于某个加载的 JavaScript 文件中。我们的核心策略是“搜索关键词”和“下断点”。
搜索加密相关关键词:在开发者工具的 Sources(源代码)标签页中,对所有 JS 文件进行全局搜索。关键词可以包括:
- 接口 URL 中的部分路径。
- 请求参数中明显的字段名,如
data,encryptStr,param。 - 常见的加密算法名,如
AES,DES,RSA,SM2,SM4,encrypt,decrypt。 - 可能用于生成签名(sign)的
MD5、SHA256或SM3。
在这个案例中,通过搜索
encrypt或接口关键参数,我们很可能定位到一个名为xxx.encrypt.js或包含crypto、security字样的文件,或者加密逻辑就写在一个主业务 JS 文件里。在请求发起处下 XHR/Fetch 断点:在 Network 标签页找到目标请求,右键选择 “Copy -> Copy as fetch”。然后在 Sources 标签页的 “XHR/fetch Breakpoints” 里,添加一个包含该接口 URL 关键词的断点。重新触发请求,代码执行会自动暂停在发起网络请求的那一行 JavaScript 代码处。从这里开始,单步执行(F11)或查看调用栈(Call Stack),一步步回溯,就能找到参数被加密处理的具体函数。
2.2 核心逻辑剖析
跟进去之后,你会发现加密逻辑通常不复杂。常见套路如下:
- 对称加密(如 AES):前端使用一个固定的密钥(Key)和初始向量(IV),对拼接好的参数字符串进行 AES 加密,输出可能是 Base64 或 Hex 格式的字符串。这个密钥和 IV 很可能硬编码在 JS 文件里。
- 非对称加密(如 RSA):前端用后端提供的公钥(Public Key)对某个关键信息(如随机生成的 AES 密钥)进行加密。但更多时候,RSA 用于加密一个临时生成的对称密钥。
- 哈希/签名(如 MD5, SHA256, SM3):将参数按特定规则拼接后,进行哈希计算,生成一个签名(sign),用于防止参数被篡改。这个
sign会作为另一个参数一同发送。 - 自定义编码/混淆:有时并非标准加密算法,而是简单的字符替换、顺序打乱或结合时间戳的运算。
注意:在扣代码时,重点不是理解算法深奥的数学原理,而是准确地复现流程。你需要记录下:输入是什么(原始参数对象),经过了哪些函数处理(函数名、参数顺序),调用了哪些库或原生方法,最终输出是什么。用浏览器的控制台(Console)在断点处实时执行代码、打印中间变量值,是最高效的验证方法。
3. 加密逻辑的 Python 复现
将 JavaScript 加密逻辑“翻译”成 Python 是核心环节。这里假设我们分析出的加密方式是AES-128-CBC模式,密钥和 IV 固定,输出为 Base64。
3.1 环境准备与库选择
首先确保你的 Python 环境安装了必要的库。最常用的是pycryptodome,它是PyCrypto的一个维护良好的分支。
pip install pycryptodome如果涉及到国密算法(如 SM2, SM3, SM4),则需要安装gmssl库。
pip install gmssl3.2 JavaScript 逻辑与 Python 代码对比
假设我们在 JS 中找到了如下加密函数(示例):
// 伪代码,基于常见写法 function encryptData(data) { const key = CryptoJS.enc.Utf8.parse('1234567890123456'); // 16字节密钥 const iv = CryptoJS.enc.Utf8.parse('abcdefghijklmnop'); // 16字节IV const srcs = CryptoJS.enc.Utf8.parse(JSON.stringify(data)); const encrypted = CryptoJS.AES.encrypt(srcs, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); // 默认返回 Base64 字符串 }对应的 Python 复现代码如下:
import json from base64 import b64encode from Crypto.Cipher import AES from Crypto.Util.Padding import pad def encrypt_data_py(data_dict): """ 复现前端的 AES-128-CBC 加密逻辑 """ # 1. 准备密钥和IV,必须确保是字节类型(bytes) key = b'1234567890123456' # 16字节 iv = b'abcdefghijklmnop' # 16字节 # 2. 将数据字典转换为JSON字符串,再编码为字节 json_str = json.dumps(data_dict, separators=(',', ':'), ensure_ascii=False) # 注意:ensure_ascii=False 允许中文,但最终加密的是其utf-8字节。有些前端库可能默认 ascii。 # 稳妥起见,可以对比前端加密前字符串的字节。这里先按 False 处理。 data_bytes = json_str.encode('utf-8') # 3. 创建 AES 加密器,使用 CBC 模式和 PKCS7 填充 cipher = AES.new(key, AES.MODE_CBC, iv) # 4. 对数据进行填充并加密 # PKCS7 填充:缺 N 个字节就填充 N 个值为 N 的字节 padded_data = pad(data_bytes, AES.block_size) encrypted_bytes = cipher.encrypt(padded_data) # 5. 将加密后的字节进行 Base64 编码 encrypted_b64 = b64encode(encrypted_bytes).decode('utf-8') return encrypted_b64 # 测试 if __name__ == '__main__': test_data = { "pageNum": 1, "pageSize": 10, "cityCode": "430000" } result = encrypt_data_py(test_data) print("加密结果:", result) # 可以与浏览器控制台执行原JS函数的结果对比,必须完全一致!3.3 关键细节与避坑指南
- 编码一致性:这是最大的坑。JavaScript 的
CryptoJS.enc.Utf8.parse和 Python 的.encode(‘utf-8’)在绝大多数情况下是一致的。但遇到特殊字符或前端做了额外处理时,可能会不同。务必在浏览器控制台打印出加密前的原始字符串(JSON.stringify(data)的结果)和对应的字节数组,与 Python 中生成的字符串和字节数组进行逐位对比。 - 填充模式:
PKCS7填充是常见的。Python 的pycryptodome库中,pad函数默认使用PKCS7。确保与前端配置(CryptoJS.pad.Pkcs7)一致。 - 输出格式:前端
encrypted.toString()默认输出 Base64 字符串。确认是否是 Hex 格式,如果是,Python 侧就用.hex()方法。 - JSON 序列化:
json.dumps时,使用separators=(‘,’, ‘:’)可以移除空格,生成最紧凑的 JSON,这与JSON.stringify的默认行为更接近。ensure_ascii参数需要根据实际情况调整。 - 密钥和 IV 长度:AES-128 对应 16 字节密钥,AES-256 对应 32 字节。IV 长度通常与块大小一致(16字节)。
实操心得:不要相信“看起来一样”。一定要做单元测试。将完全相同的输入(一个字典)分别交给浏览器控制台里的 JS 函数和你的 Python 函数,比较输出是否一字不差。这是验证扣代码成功与否的唯一标准。
4. 解密响应数据
很多情况下,服务器返回的数据也是加密的。解密过程是加密的逆过程,前提是你已经知道了算法和密钥(通常与请求加密相同,或可以从首次响应、其他接口中推导出来)。
4.1 Python 解密实现
继续上面的 AES 例子,假设响应体是一个 Base64 字符串,我们需要解密它得到 JSON。
from base64 import b64decode from Crypto.Cipher import AES from Crypto.Util.Padding import unpad def decrypt_response_py(encrypted_b64_str): """ 解密服务器返回的 AES-128-CBC 加密数据 """ # 1. 使用相同的密钥和IV key = b'1234567890123456' iv = b'abcdefghijklmnop' # 2. 将 Base64 字符串解码为字节 encrypted_bytes = b64decode(encrypted_b64_str) # 3. 创建 AES 解密器 cipher = AES.new(key, AES.MODE_CBC, iv) # 4. 解密并去除填充 decrypted_padded_bytes = cipher.decrypt(encrypted_bytes) # 注意:解密后先不要解码为字符串,因为末尾有填充字节 decrypted_bytes = unpad(decrypted_padded_bytes, AES.block_size) # 5. 将字节解码为 UTF-8 字符串,再解析为 JSON json_str = decrypted_bytes.decode('utf-8') data_dict = json.loads(json_str) return data_dict # 假设从网络请求获取的加密响应文本是 `resp_encrypted_text` # decrypted_data = decrypt_response_py(resp_encrypted_text)4.2 处理可能的变化
- 响应结构:有时响应体是一个 JSON 对象,其中某个字段(如
data或result)才是加密的字符串。你需要先解析外层 JSON,取出加密字段再进行解密。 - 动态密钥:更复杂的情况是,密钥或 IV 不是固定的,可能由第一个接口响应提供,或者由前端根据时间戳等因子计算得出。这就需要你完整跟踪前端初始化或登录时的密钥协商流程。
- 算法组合:例如,先用 RSA 加密一个随机生成的 AES 密钥,再用这个 AES 密钥加密数据。这就需要你同时复现 RSA 和 AES 两种逻辑。
5. 构建完整的请求流程与参数处理
有了加密解密函数,我们就可以组装一个完整的、可用的爬虫脚本了。
5.1 请求参数组装
分析前端页面,找出查询所需的全部参数。这些参数可能包括分页信息(pageNum,pageSize)、查询条件(如城市代码cityCode、农机类型machineType、时间范围等)。将这些参数组装成一个 Python 字典。
5.2 签名(Sign)的生成
如果接口除了加密参数data外,还需要一个签名sign,那么你需要找到生成签名的规则。常见规则是:将所有参数(或部分参数)按参数名排序后,拼接成key1=value1&key2=value2...的形式,然后加上一个固定的secret(盐值),最后对这个字符串进行MD5或SM3哈希。
import hashlib def generate_sign(params_dict, secret='your_secret_key'): """ 生成 MD5 签名 """ # 1. 参数排序并拼接 sorted_params = sorted(params_dict.items(), key=lambda x: x[0]) param_str = '&'.join([f'{k}={v}' for k, v in sorted_params]) # 2. 拼接密钥 sign_str = param_str + secret # 3. 计算 MD5(32位小写) md5 = hashlib.md5() md5.update(sign_str.encode('utf-8')) return md5.hexdigest() # 注意:有时 secret 可能直接拼接在字符串末尾,有时是 `key=value&secret=xxx` 格式,具体看前端逻辑。5.3 发送请求与处理响应
使用requests库发送 POST 请求。请求头(Headers)需要模拟,至少包含Content-Type: application/json,通常还需要User-Agent和一些特定的 Referer 或 Origin。
import requests def fetch_subsidy_data(query_params): """ 获取补贴数据的主函数 """ url = "https://xxx.hunan.gov.cn/xxx/query" # 替换为实际接口地址 # 1. 加密请求参数 encrypted_data = encrypt_data_py(query_params) # 2. 生成签名(如果需要) # 注意:签名计算的参数可能是原始参数,也可能是包含加密后数据的参数,需根据前端确定 sign_params = {'data': encrypted_data, 'timestamp': int(time.time()*1000)} sign = generate_sign(sign_params) # 3. 组装最终请求体 payload = { "data": encrypted_data, "sign": sign, # ... 可能还有其他固定字段 } # 4. 设置请求头 headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...", "Content-Type": "application/json;charset=UTF-8", "Referer": "https://xxx.hunan.gov.cn/", # 通常需要 "Origin": "https://xxx.hunan.gov.cn", } # 5. 发送请求 try: response = requests.post(url, json=payload, headers=headers, timeout=10) response.raise_for_status() # 检查HTTP错误 # 6. 处理响应 resp_json = response.json() # 情况A:响应整体是加密字符串 if isinstance(resp_json, str): decrypted_data = decrypt_response_py(resp_json) return decrypted_data # 情况B:响应是JSON,其中某个字段是加密字符串 elif isinstance(resp_json, dict) and 'data' in resp_json: encrypted_resp_data = resp_json['data'] decrypted_data = decrypt_response_py(encrypted_resp_data) return decrypted_data else: print("响应格式未知:", resp_json) return None except requests.exceptions.RequestException as e: print(f"网络请求失败: {e}") return None except json.JSONDecodeError as e: print(f"响应JSON解析失败: {e}, 原始文本: {response.text[:200]}") return None except Exception as e: print(f"解密或处理过程出错: {e}") return None6. 常见问题排查与实战技巧
即使逻辑扣对了,在实际运行中也可能遇到各种问题。下面是一个常见问题速查表。
| 问题现象 | 可能原因 | 排查思路与解决方案 |
|---|---|---|
| 加密结果与前端不一致 | 1. 密钥/IV错误或编码不对。 2. 加密前的源字符串不一致(JSON格式、空格、中文编码)。 3. 加密模式或填充模式错误。 | 1.逐字节对比:在浏览器控制台和Python中,分别打印出加密前字符串的charCodeAt/bytes和加密后的结果。这是最有效的调试方法。2.检查JSON:使用 JSON.stringify(data, null, 0)在前端生成最紧凑JSON,与Python的json.dumps(data, separators=(‘,’,‘:’), ensure_ascii=False)结果对比。3.验证算法:确认前端使用的库和具体算法名(如 AES-128-CBC-Pkcs7)。 |
| 请求成功,但返回“签名错误”或“参数错误” | 1. 签名生成规则有误(参数顺序、拼接方式、secret值)。 2. 用于签名的参数集合不对(可能漏了某些固定参数或时间戳)。 3. 加密后的 data字段在传输中被意外处理(如二次URL编码)。 | 1.网络抓包对比:用工具(如 Charles/Fiddler)拦截浏览器正常请求,完整对比其Payload和你代码生成的Payload的每一个字段。2.逆向签名函数:在JS中给签名生成函数下断点,记录下参与签名的完整字符串,与Python生成的字符串逐字对比。 3.检查请求头: Content-Type是否正确?有些接口要求application/x-www-form-urlencoded而不是application/json。 |
| 返回的数据解密失败 | 1. 解密用的密钥/IV与加密不同。 2. 响应数据不是纯粹的加密字符串,可能包含前缀、后缀或进行了包装。 3. 解密后的数据填充错误( unpad失败)。 | 1.检查响应结构:先打印response.json()或response.text,看清返回的到底是什么结构。2.尝试直接解密:如果响应是 {“code”:0,“data”:”加密字符串”},那么你需要解密的是data字段的值。3.错误处理:在 decrypt和unpad处做好try...except,捕获异常并打印中间变量,有助于定位。 |
| 请求被拒绝,返回403或412 | 1. 缺少必要的请求头,如Referer,Origin,X-Requested-With,或特定的自定义头。2. 存在反爬机制,如需要Cookie中的令牌(Token)或验证码。 | 1.完整复制Headers:使用浏览器开发者工具,将成功请求的所有Headers键值对复制到你的代码中,特别是Cookie和User-Agent。2.处理会话:使用 requests.Session()对象来保持Cookie。3.模拟完整流程:可能需要先访问首页获取初始Cookie,甚至模拟登录来获取有效的认证Token。 |
| 代码在本地运行正常,但部署后偶尔失败 | 1. 服务器端密钥或算法有变动(较少见)。 2. 网络环境或代理问题。 3. 时间戳不同步导致签名过期。 | 1.增加日志:记录每次请求的明文参数、加密结果、签名和完整URL,出问题时方便对比分析。 2.重试机制:对于网络超时等临时错误,加入指数退避的重试逻辑。 3.验证时间戳:如果签名依赖时间戳,确保本地时间与网络时间同步。 |
6.1 独家避坑技巧
- “抠”代码,而不是“猜”代码:永远以浏览器实际执行的代码为基准。即使你看到 JS 里有个变量叫
key,也不要直接用它,而要打印出它在运行时的实际值。因为变量可能被其他代码修改。 - 善用控制台:在浏览器开发者工具的 Console 里,你可以直接调用找到的加密函数,传入测试参数,立即看到结果。这是验证逻辑最快的方式。
- 最小化测试:不要一开始就处理完整的复杂参数。构造一个最简单的参数字典(如
{“test”: 1}),分别用 JS 和 Python 加密,先让这个最小案例跑通。 - 关注非加密参数:像
pageNum,pageSize,timestamp,nonce(随机数)这类参数,虽然不加密,但却是请求的必要组成部分,且timestamp和nonce可能用于防重放,需要按规则生成。 - 代码健壮性:在
decrypt和json.loads等可能出错的地方做好异常捕获和日志记录。对于重要爬虫,可以考虑将成功解密的原始响应和解析后的数据都保存下来,便于后续排查和数据分析。
这个项目虽然被标注为“难度一般”,但它涵盖了爬虫工程师处理加密接口的完整工作流:抓包观察、逆向定位、逻辑分析、代码复现、请求构建、异常处理。成功搞定之后,你收获的不仅仅是一份数据,更是一套可复用于其他类似“逻辑加密”接口的方法论。下次再遇到{“data”: “xxx”}这种格式的请求,你就能从容地打开开发者工具,开始你的“抠代码”之旅了。