1. 项目概述:为什么我们要研究央视频的加密参数?
做爬虫的朋友都知道,现在稍微有点规模的网站,反爬虫机制都做得相当严密。尤其是像央视频这类主流媒体平台,它们的数据价值高,对数据安全和版权保护的要求也更高。所以,当你用常规的requests.get去请求一个视频列表或者播放地址时,大概率会碰壁,返回的不是你想要的数据,而是一串看不懂的加密字符串,或者干脆就是一个403错误。
这个项目标题“逆向解析央视频加密参数”,直指了爬虫进阶路上一个非常核心且硬核的环节:前端加密逆向。这不再是简单的模拟登录、处理Cookie或者解析HTML,而是需要你像一个安全研究员一样,去剖析网页或客户端应用是如何生成那些用于身份验证和数据请求的加密签名的。这些签名,往往就是sign、token、_signature这类参数,它们是服务器验证请求合法性的“钥匙”。
我之所以花时间研究这个,是因为在一次数据采集项目中,我需要批量获取央视频上某些专题的视频元信息(如标题、时长、清晰度、播放地址等)。直接用浏览器能看,但写脚本就抓不到。这逼着我必须去搞清楚,我的脚本和浏览器发出的请求,到底差在哪里。最终发现,关键就在于几个动态生成的加密参数。搞定它们,就等于拿到了打开数据大门的钥匙。这个过程不仅锻炼了JavaScript逆向和Python复现的能力,更让你对Web应用的安全交互机制有更深的理解。无论你是想爬取学习资料,还是做数据分析,掌握这套方法都能让你突破大多数基于前端加密的反爬壁垒。
2. 核心思路与逆向工程方法论
逆向前端加密,听起来很高深,其实有一套可以遵循的方法论。核心思想就是:在浏览器(客户端)里能成功运行的逻辑,我们用Python在服务器端把它复现出来。
2.1 逆向分析的基本流程
我的逆向流程通常分为四步,像侦探破案一样层层推进:
定位关键请求:使用浏览器的开发者工具(F12),切换到
Network(网络)标签页,刷新页面或触发你想要抓取数据的操作(比如点击“加载更多”)。在纷繁的网络请求中,找到那个返回目标数据(通常是JSON格式)的XHR或Fetch请求。这个请求的Headers和Payload(负载)里,就藏着我们需要的加密参数。参数对比与定位:对比多次相同操作的请求,你会发现有些参数是固定不变的(如
appId,clientType),有些是每次都在变化的(如timestamp,nonce随机数),而最关键的,就是那个长串的、看起来像乱码的sign或token。我们的目标就是找到生成这个sign的算法。追踪加密逻辑:在
Network面板中,找到那个关键请求,右键选择Copy->Copy as cURL(或类似选项),然后粘贴到文本编辑器。但这只是拿到了结果。要找到生成过程,需要切换到Sources(源代码)或Debugger面板。因为sign的生成逻辑一定写在网页加载的JavaScript文件里。我们可以通过搜索关键词(如sign:、md5、hmac、encrypt)来定位相关的代码段。代码分析与复现:找到生成
sign的函数后,需要分析它的输入(哪些参数参与了计算)、使用的加密算法(MD5, SHA, HMAC, AES等)、以及拼接或排序的规则。然后,用Python的相应加密库(如hashlib,hmac,Crypto)将这套逻辑完全复现出来。
2.2 央视频加密参数特点分析
根据我的实战经验,央视频(以及许多同类平台)的加密方案通常具备以下特点,了解这些能让你少走弯路:
- 组合型签名:
sign很少是直接对单个字符串加密。它往往是多个参数(如path,query字符串、timestamp、nonce、一个固定的secret)按照特定顺序拼接后,再进行哈希运算(如MD5、SHA256)得到的。有时还会进行Base64编码或二次加密。 - 时间戳与时效性:
timestamp是必备参数,而且服务器会校验其有效性。请求中的时间戳如果与服务器时间相差太大,请求会被拒绝。这意味着你的爬虫脚本所在机器的系统时间需要基本准确,或者更稳妥的办法,是从服务器响应中获取一个时间戳作为基准。 - 随机数防重放:
nonce(一次随机数)或salt(盐值)用于防止同一个签名被重复使用(重放攻击)。这要求我们在Python里能生成高质量的随机字符串。 - 算法可能混淆:为了保护核心算法,开发人员可能会对JavaScript代码进行混淆、压缩,甚至将关键逻辑隐藏在闭包或WebAssembly中。这增加了直接阅读和理解的难度,需要耐心和一定的调试技巧。
注意:逆向工程的目的应是学习技术原理和进行合规的数据采集。务必遵守网站的
robots.txt协议,控制请求频率,避免对目标服务器造成过大压力。像热搜词里提到的“把正规爬虫挤得都没带宽了”的情况,是我们每个从业者都应极力避免的。你的爬虫应该是个“礼貌的访客”。
3. 实战工具准备与环境搭建
工欲善其事,必先利其器。逆向分析和Python复现需要一套顺手的工具链。
3.1 浏览器开发者工具进阶使用
Chrome或Edge的开发者工具是我们的主战场,除了基本的Network和Elements,这几个功能至关重要:
- 搜索(
Ctrl+Shift+F):在整个页面加载的所有JS文件中搜索加密关键词,是定位代码最快的方式。 - XHR/fetch 断点:在
Sources->XHR/fetch Breakpoints里,可以添加一个包含特定URL片段的断点。当浏览器发起匹配的请求时,代码执行会自动暂停,这时你可以查看完整的调用栈 (Call Stack),一步步回溯到生成请求参数的函数。 - 本地代码替换(
Overrides):在Sources->Overrides中,可以指定一个本地文件夹,并将线上的JS文件映射到本地。你可以在本地修改、美化(格式化)这个JS文件,刷新页面后浏览器会加载你修改后的版本,便于你插入console.log来打印中间变量值,这是理解加密过程的神器。
3.2 Python环境与核心库
Python环境建议使用Python 3.8+。管理工具用conda或venv创建独立虚拟环境都是好习惯。核心库就以下几个:
# 创建虚拟环境(可选但推荐) python -m venv venv_cctv # 激活环境 (Windows) venv_cctv\Scripts\activate # 激活环境 (Mac/Linux) source venv_cctv/bin/activate # 安装核心库 pip install requests # 发送HTTP请求 pip install pycryptodome # 强大的加密库,兼容Crypto,AES/DES/RSA等都在这里 # 或者安装 cryptography,也是常用的加密库 # pip install cryptographyrequests:毋庸置疑,HTTP请求库。pycryptodome:这是重点。很多教程里用的Crypto库已经停止维护,pycryptodome是其兼容且功能更强的替代品,提供了完整的对称加密(AES)、非对称加密(RSA)、哈希(MD5, SHA)等实现。注意:安装后导入时通常使用from Crypto.Cipher import AES这样的语句,它完美替代了旧的pycrypto。
3.3 辅助分析工具
- Postman 或 Insomnia:用于手动构建和测试请求。当你用Python写好参数生成逻辑后,可以先在这些工具里手动组装请求,验证
sign是否正确,比反复运行脚本调试更高效。 - Node.js:有时加密算法过于复杂,或者涉及浏览器特有的API(如
Crypto.subtle),直接Python复现难度大。可以尝试用Node.js写一个小的脚本来模拟执行找到的JS函数,验证逻辑,然后再将其“翻译”成Python。execjs库也可以直接在Python中调用JS代码,但环境配置有时比较麻烦。
4. 逆向解析央视频加密参数全流程
下面,我将以一个模拟的央视频API请求为例,拆解完整的逆向和复现过程。请注意,为了合规,我不会使用真实的央视频API地址和密钥,但算法逻辑和步骤是完全一致的。
4.1 第一步:网络抓包与关键请求定位
- 打开浏览器开发者工具 (
F12),进入Network面板,勾选Preserve log(保留日志)。 - 访问央视频的某个视频列表页。
- 观察网络请求,过滤
XHR或Fetch请求。寻找返回数据为JSON格式,且Preview(预览)里包含视频列表信息的请求。 - 点击这个请求,查看
Headers和Payload。假设我们找到的请求关键信息如下:- 请求URL:
https://api.cctv.com/video/list - 请求方法:
POST - Query参数:
page=1&size=20 - 请求体 (Payload): 一个JSON对象,里面包含了
sign和其他参数。
- 请求URL:
4.2 第二步:参数分析与加密函数定位
查看这个请求的Payload,可能如下所示:
{ "appId": "web", "clientType": "pc", "timestamp": 1687851234567, "nonce": "7a3b8c9d0e1f", "page": 1, "size": 20, "sign": "a1b2c3d4e5f67890abcdef1234567890" }显然,sign是我们要攻破的目标。appId和clientType是固定的,timestamp是当前时间戳,nonce是随机字符串。
接下来,在Network面板中,对这个请求右键,选择Initiator标签页,或者直接看Call Stack,可以找到发起这个请求的JavaScript文件。点击进入该文件。
在JS文件里,使用Ctrl+Shift+F搜索sign:或"sign"。你可能会找到类似这样的代码片段(经过简化):
function generateSign(params, secretKey) { // 1. 参数排序 let keys = Object.keys(params).sort(); let sortedStr = ''; for (let key of keys) { if (key !== 'sign' && params[key] !== null && params[key] !== undefined) { sortedStr += key + '=' + params[key] + '&'; } } // 去掉最后一个'&' sortedStr = sortedStr.slice(0, -1); // 2. 拼接密钥 let stringToSign = sortedStr + '&' + secretKey; // 3. MD5哈希 let sign = md5(stringToSign); // 4. 转换为小写(有时需要) return sign.toLowerCase(); } // 调用 let params = { appId: 'web', clientType: 'pc', timestamp: Date.now(), nonce: Math.random().toString(36).substr(2, 10), page: 1, size: 20 }; params.sign = generateSign(params, 'YOUR_SECRET_KEY_HERE');这段代码清晰地展示了sign的生成过程:排序 -> 拼接 -> MD5。secretKey是一个固定的密钥,它可能硬编码在JS里,也可能从其他接口获取。我们需要找到它。
4.3 第三步:Python代码复现加密逻辑
现在,我们用Python来复现上面的逻辑。假设我们通过分析,找到了secretKey是"cctv_2023_secret"。
import hashlib import time import random import string import requests import json def generate_nonce(length=12): """生成指定长度的随机字符串作为 nonce""" return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) def generate_sign(params, secret_key): """ 生成签名 :param params: 参数字典,包含除sign外的所有参数 :param secret_key: 密钥 :return: 计算得到的sign字符串 """ # 1. 参数排序 sorted_params = sorted(params.items(), key=lambda x: x[0]) # 2. 拼接键值对 # 注意:这里要确保值的类型是字符串,或者进行统一的字符串转换 param_str = '&'.join([f'{k}={v}' for k, v in sorted_params]) # 3. 拼接密钥 string_to_sign = param_str + '&' + secret_key # 4. MD5哈希并转为小写 m = hashlib.md5() m.update(string_to_sign.encode('utf-8')) sign = m.hexdigest().lower() return sign # 模拟请求参数 params = { 'appId': 'web', 'clientType': 'pc', 'timestamp': int(time.time() * 1000), # 毫秒级时间戳 'nonce': generate_nonce(), 'page': 1, 'size': 20 } secret_key = 'cctv_2023_secret' # 这是逆向分析得到的密钥 # 生成签名 signature = generate_sign(params, secret_key) print(f"生成的 sign: {signature}") # 将签名加入参数字典 params['sign'] = signature # 构建请求 url = 'https://api.cctv.com/video/list' headers = { 'Content-Type': 'application/json', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' } try: response = requests.post(url, data=json.dumps(params), headers=headers, timeout=10) response.raise_for_status() # 检查请求是否成功 data = response.json() print("请求成功,返回数据:", json.dumps(data, indent=2, ensure_ascii=False)) except requests.exceptions.RequestException as e: print(f"请求失败: {e}") except json.JSONDecodeError as e: print(f"JSON解析失败: {e}, 响应文本: {response.text}")代码要点解析:
generate_nonce函数模拟了JS中Math.random().toString(36).substr(2, 10)生成随机字符串的行为,但更通用。generate_sign函数严格遵循了JS中的逻辑:按参数名排序、拼接成k1=v1&k2=v2格式、末尾加上&secret_key、计算MD5并转小写。- 时间戳
timestamp使用了毫秒级,与JS的Date.now()对应。 - 发送请求时,
data参数使用json.dumps将字典转换为JSON字符串,并设置正确的Content-Type头。
4.4 第四步:处理更复杂的加密场景(AES解密)
有时,sign只是第一道关卡。服务器返回的数据本身也可能是加密的(例如AES加密)。假设我们发现返回的JSON中,data字段是一个加密字符串。
逆向过程类似,需要找到前端解密data的JavaScript代码。通常会是这样的模式:
// 假设返回的 response.data 是加密字符串 let encryptedData = response.data; let key = CryptoJS.enc.Utf8.parse('一个16/24/32字节的密钥'); let iv = CryptoJS.enc.Utf8.parse('一个16字节的偏移量'); // 可能是固定的,也可能是动态的 let decrypted = CryptoJS.AES.decrypt(encryptedData, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); let decryptedText = decrypted.toString(CryptoJS.enc.Utf8); let result = JSON.parse(decryptedText);对应的Python解密代码如下(使用pycryptodome):
from Crypto.Cipher import AES from Crypto.Util.Padding import unpad import base64 def decrypt_aes_data(encrypted_b64, key, iv): """ 解密AES-CBC加密的数据 :param encrypted_b64: Base64编码的加密字符串 :param key: 密钥(字节串),长度必须是16(AES-128), 24(AES-192), 或32(AES-256) :param iv: 初始化向量(字节串),长度必须为16 :return: 解密后的原始字符串 """ # 解码Base64 encrypted_bytes = base64.b64decode(encrypted_b64) # 创建AES解密器 cipher = AES.new(key, AES.MODE_CBC, iv) # 解密并去除填充 decrypted_bytes = unpad(cipher.decrypt(encrypted_bytes), AES.block_size) # 转换为字符串 return decrypted_bytes.decode('utf-8') # 假设从响应中获取 encrypted_data_b64 = "从API返回的data字段字符串" # 密钥和IV需要从JS代码中逆向得到,这里用示例值 # 注意:JS中CryptoJS.enc.Utf8.parse('1234567890123456') 相当于 b'1234567890123456' aes_key = b'1234567890123456' # 16字节密钥,对应AES-128 aes_iv = b'abcdefghijklmnop' # 16字节IV try: decrypted_text = decrypt_aes_data(encrypted_data_b64, aes_key, aes_iv) result_data = json.loads(decrypted_text) print("解密后的数据:", json.dumps(result_data, indent=2, ensure_ascii=False)) except Exception as e: print(f"AES解密失败: {e}")实操心得:逆向AES时,最关键的是确定模式(如CBC、ECB)、填充方式(如PKCS7)以及密钥和IV的来源。它们可能硬编码在JS里,也可能由之前的某个接口返回。仔细阅读JS代码,关注
CryptoJS.mode和CryptoJS.pad的设置。
5. 完整代码示例与封装
将上述签名生成、请求发送、数据解密的过程封装成一个类,会更利于使用和维护。下面是一个完整的、结构化的示例:
import hashlib import time import random import string import requests import json from Crypto.Cipher import AES from Crypto.Util.Padding import unpad, pad import base64 class CCTVVideoSpider: def __init__(self, app_id='web', client_type='pc', secret_key=None, aes_key=None, aes_iv=None): """ 初始化爬虫 :param app_id: 应用ID :param client_type: 客户端类型 :param secret_key: 用于生成签名的密钥(需逆向获得) :param aes_key: AES解密密钥(如果需要解密返回数据) :param aes_iv: AES解密IV(如果需要解密返回数据) """ self.app_id = app_id self.client_type = client_type self.secret_key = secret_key # 例如 'cctv_2023_secret' self.aes_key = aes_key # bytes, 例如 b'1234567890123456' self.aes_iv = aes_iv # bytes, 例如 b'abcdefghijklmnop' self.session = requests.Session() self.session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', 'Content-Type': 'application/json', 'Referer': 'https://www.cctv.com/' # 有时需要Referer }) def _generate_nonce(self, length=12): """生成随机nonce""" return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) def _generate_sign(self, params): """根据参数字典生成签名""" if not self.secret_key: raise ValueError("secret_key 未设置,无法生成签名") # 过滤掉sign本身和值为None的参数 filtered_params = {k: v for k, v in params.items() if v is not None and k != 'sign'} # 按键排序 sorted_items = sorted(filtered_params.items()) # 拼接成 k1=v1&k2=v2 格式 param_str = '&'.join([f'{k}={v}' for k, v in sorted_items]) # 拼接密钥 string_to_sign = param_str + '&' + self.secret_key # 计算MD5 return hashlib.md5(string_to_sign.encode('utf-8')).hexdigest().lower() def _decrypt_aes_data(self, encrypted_b64): """解密AES-CBC加密的Base64数据""" if not self.aes_key or not self.aes_iv: raise ValueError("AES密钥或IV未设置") encrypted_bytes = base64.b64decode(encrypted_b64) cipher = AES.new(self.aes_key, AES.MODE_CBC, self.aes_iv) decrypted_bytes = unpad(cipher.decrypt(encrypted_bytes), AES.block_size) return decrypted_bytes.decode('utf-8') def get_video_list(self, page=1, size=20): """ 获取视频列表 :param page: 页码 :param size: 每页数量 :return: 解析后的视频列表数据 """ # 1. 构建基础参数 base_params = { 'appId': self.app_id, 'clientType': self.client_type, 'timestamp': int(time.time() * 1000), # 毫秒时间戳 'nonce': self._generate_nonce(), 'page': page, 'size': size } # 2. 生成签名并加入参数 sign = self._generate_sign(base_params) base_params['sign'] = sign # 3. 发送请求 api_url = 'https://api.example.com/video/list' # 请替换为实际API try: resp = self.session.post(api_url, json=base_params, timeout=15) resp.raise_for_status() result = resp.json() # 4. 检查响应状态码(假设接口返回格式为 {“code”: 0, “msg”: “success”, “data”: ...}) if result.get('code') != 0: print(f"API返回错误: {result.get('msg')}") return None encrypted_data = result.get('data') if not encrypted_data: return result # 如果data未加密,直接返回 # 5. 解密数据(如果需要) decrypted_str = self._decrypt_aes_data(encrypted_data) return json.loads(decrypted_str) except requests.exceptions.RequestException as e: print(f"网络请求失败: {e}") return None except json.JSONDecodeError as e: print(f"响应JSON解析失败: {e}, 原始文本: {resp.text[:200]}") return None except Exception as e: print(f"处理过程发生未知错误: {e}") return None # 使用示例 if __name__ == '__main__': # 初始化爬虫,填入逆向得到的密钥(此处为示例,需替换) spider = CCTVVideoSpider( secret_key='your_real_secret_key_from_js', aes_key=b'your_16_24_32_bytes_key', # 替换为真实的密钥字节串 aes_iv=b'your_16_bytes_iv' # 替换为真实的IV字节串 ) # 获取第一页数据 video_list = spider.get_video_list(page=1, size=10) if video_list: print(f"成功获取到 {len(video_list)} 条视频信息") for idx, video in enumerate(video_list[:3]): # 打印前3条 print(f"{idx+1}. 标题: {video.get('title')}, 时长: {video.get('duration')}") else: print("获取数据失败")这个类封装了核心逻辑,你可以通过修改secret_key、aes_key、aes_iv以及api_url来适配不同的接口。get_video_list方法展示了从构造参数到获取解密后数据的完整流程。
6. 常见问题排查与实战技巧
在实际操作中,你几乎一定会遇到各种问题。下面是我踩过坑后总结的一些排查思路和技巧。
6.1 签名验证失败(sign error)
这是最常见的问题。你的Python代码生成的sign和浏览器生成的不一样。
- 排查步骤:
- 参数一致性:确保Python代码中的参数字典和浏览器发送的完全一致。包括所有键值对,即使是空字符串或
null。仔细对比Payload的原始数据。 - 编码与格式:确保拼接字符串时的格式一致。比如,值是数字
1还是字符串"1"?JS中Object.keys(params).sort()的排序规则和Python的sorted(params.items())是否完全一致?(通常是按字符顺序,但要注意中文等特殊情况)。日期时间戳的格式和精度(秒还是毫秒)是否一致? - 密钥错误:确认你使用的
secret_key是正确的。它可能不是硬编码的,而是通过一个初始化接口动态获取的。你需要找到获取这个密钥的请求。 - 空格与特殊字符:拼接的字符串里是否有不可见的空格或换行符?URL编码是否一致?有时需要对参数值进行
encodeURIComponent(JS)或urllib.parse.quote(Python)处理。 - 打印中间变量:在JS代码里(通过
Overrides功能)和Python代码里,分别打印出排序后的参数字符串、拼接密钥前的字符串、以及最终用于计算哈希的完整字符串。逐字符对比,差异立现。
- 参数一致性:确保Python代码中的参数字典和浏览器发送的完全一致。包括所有键值对,即使是空字符串或
6.2 返回数据解密失败
- 错误提示:
Padding is incorrect.或解密后是乱码。 - 排查步骤:
- 算法模式:确认AES的模式(CBC, ECB, GCM等)和Python代码中设置的是否一致。CBC模式最常见。
- 密钥和IV:确认密钥和IV的字节长度和内容完全正确。JS中的
CryptoJS.enc.Utf8.parse('...')生成的是字节串,要确保Python中的key和iv是相同的字节串(b'...')。 - 填充方式:确认填充方式。
CryptoJS.pad.Pkcs7对应Pythonpycryptodome的PKCS7填充(pad/unpad函数)。如果模式是ECB,可能不需要IV。 - 数据预处理:确认加密数据是否需要先进行Base64解码或Hex解码。查看JS代码里
CryptoJS.AES.decrypt的第一个参数是什么格式。
6.3 请求被风控拦截
即使签名正确,也可能收到403 Forbidden或429 Too Many Requests。
- 应对策略:
- 请求头:模拟完整的浏览器请求头,特别是
User-Agent,Referer,Origin,有时甚至需要Cookie。 - 会话保持:使用
requests.Session()对象,它会自动管理Cookies,模拟浏览器会话。 - 请求频率:在循环请求中务必加入延时,例如
time.sleep(random.uniform(1, 3)),避免高频请求触发风控。 - IP代理:如果单个IP被封锁,需要考虑使用代理IP池。但请务必使用合法合规的代理服务。
- 验证码:如果遇到验证码,通常意味着该接口的反爬策略非常严格,可能需要考虑其他数据获取方式,或者使用更高级的自动化工具(如Selenium模拟浏览器),但这会大大降低效率。
- 请求头:模拟完整的浏览器请求头,特别是
6.4 JS代码高度混淆,难以定位
- 技巧:
- 搜索特征值:即使代码被混淆,像
MD5、encrypt、sign这样的字符串常量可能不会被混淆,或者会变成类似_0xabc123['md5']的形式。可以尝试搜索这些特征值。 - Hook关键函数:在开发者工具的
Console中,可以使用JavaScript Hook技术。例如,拦截JSON.stringify或XMLHttpRequest.send方法,在它们被调用时打印出参数,从而定位到生成参数的函数。 - 跟栈调试:在
Network面板中,对可疑的请求设置XHR/fetch Breakpoints,然后触发请求。代码会在发送请求前暂停,此时查看Call Stack调用栈,一层层往上找,总能找到源头。 - 使用AST工具:对于极度复杂的混淆,可以借助
AST(抽象语法树)解析工具对JS代码进行反混淆和格式化,但这需要较高的技术水平。
- 搜索特征值:即使代码被混淆,像
逆向解析加密参数是一个需要耐心、细心和逻辑分析能力的过程。没有一成不变的方案,每个网站都可能不同。但核心方法论是通用的:抓包定位 -> 分析参数 -> 追踪代码 -> 复现逻辑。成功破解一次之后,你会发现很多网站的加密思路大同小异,举一反三的能力会越来越强。最后再次强调,技术用于学习与研究,务必尊重数据所有者的权益,合法合规地使用爬虫技术。