1. 从一次被JS加密拦住的抓取说起
做爬虫的朋友应该都有过这种经历:明明抓包工具里看得清清楚楚——接口地址、请求参数、返回的JSON结构都摆在眼前,可一到自己的Python脚本里,对方直接甩过来一个401或者验证码页面。我一开始也以为是自己请求头没写全,后来对着浏览器抓包数据一行一行比对,终于发现猫腻:请求里多了一个看起来很奇怪的签名参数,名字可能是sign、token或者_v,后面跟着一串谁也读不懂的字符串。这个字符串不是服务端动态生成的,而是网页里一段JavaScript计算出来的——这就是所谓的"JS加密参数",业内一般按难度和混淆方式分成v1、v2……一直到v7甚至更高。
这次要分享的案例,恰好属于"难度适中"的v7类型:数据源是某个开放型的影视信息聚合平台(这里统一叫"某平台"),它的Web端接口在请求头里加了一道由JavaScript生成的签名校验,不解决它,任何Python直接请求都会被拒。我花了一个周末把整个链路啃了下来,从定位加密函数、还原算法到用Python模拟执行,中间踩了不少坑,也总结出一套可以复制到其他同类网站的分析方法。
这篇文章适合什么读者呢?已经会用requests和基本抓包工具、但对"JS逆向"还停留在听说过阶段的Python爬虫学习者。我会把我自己的真实分析过程完整写下来,包括哪些环节走了弯路、哪里其实可以更快,而不是只丢一个最终能跑的代码。
先说清楚一个重要边界:本文涉及的平台是一个对外提供公开数据展示的站点,接口签名的作用是控制访问频率、防止脚本滥用,并不涉及任何版权内容的破解。做爬虫的人更应该守住这个底线——只抓公开数据、控制频率、遵守对方的服务条款。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JS加密v7类型到底是个什么东西
2.1 先分清"加密"和"签名"
很多人一听到"JS加密"就害怕,觉得是那种把整个网页源码都混淆成一堆乱码的东西。其实在实际的反爬场景里,绝大多数情况下我们面对的并不是真正的数据加密,而是请求签名。
两者的区别很简单:加密是让信息变得不可读,比如把接口返回的JSON内容用AES算法加密,你拿到数据也看不懂;签名则是让信息"可验证",它的核心逻辑是——客户端把请求参数、时间戳、固定的盐值(salt)等拼在一起,用一个算法算出一段固定长度的字符串,服务端拿到后同样算一遍,比对是否一致,如果不一致就直接拒绝请求。签名不改变数据本身,只是给请求"盖了个章"。
某平台的v7类型签名,本质就是一种HMAC类签名。它的特点是:
- 参数拼接顺序有讲究,多一个参数或少一个参数,算出的签名值就完全不同;
- 时间戳参与签名计算,所以签名只在很短一段时间内有效;
- JavaScript代码里做了变量名混淆,直接读源码很难一眼看出哪个变量是盐值、哪个函数是核心计算逻辑。
2.2 v7与前几代版本的核心差异
我在分析过程中对比过同平台的v1、v4和v7版本的签名机制,它们的演进路径其实代表了目前市面上反爬策略的常见升级方向:
| 版本 | 特征 | 对抗难度 |
|---|---|---|
| v1 | 简单的字符串拼接后MD5 | 极低,直接找字符串 |
| v4 | 加入固定盐值,函数名初步混淆 | 较低,打断点能很快定位 |
| v7 | 动态盐值+混淆变量名+多参数拼接 | 中等,需要还原完整算法 |
v7最让人头疼的地方在于:盐值不是写死在代码里的常量,而是通过另一个接口动态获取的,并且有时候同一个接口返回的盐值还会过期。这就意味着你不能只找到盐值然后硬编码到Python里,必须把"获取盐值→拼接参数→计算签名→拼接到请求头"这个完整链路都在程序里模拟出来。
另一个难点是代码混淆。v7版本的加密函数里,变量名清一色是单字母或者无意义双字母组合,比如var a = "abc", _b = 123这种,想靠读源码理解逻辑非常痛苦。我的做法是先不读代码,直接靠断点位置和输入输出数据来反推算法。
2.3 分析思路:不要一上来就想"逆向"
这里想给读者一个很重要的建议:拿到一个JS加密的网站,先别急着打开开发者工具去读那坨混淆代码。你应该做的是——
- 先判断签名是否可绕过。有些签名只校验参数存在与否,不校验值是否正确;有些虽然校验值,但存在服务端校验不严格的漏洞。先试几个假签名,看看服务端反应。
- 再判断签名是否可复用。如果签名里没有时间戳参数,或者时间戳一天才变一次,那你完全可以直接复制浏览器里生成的签名过来用,省掉一大半工作量。
- 只有以上两条路都不通,才考虑还原算法。
某平台的v7签名属于第三种。我测试发现,签名对时间戳是敏感的,大概超过5分钟就会失效;而且每个请求的nonce(随机数)都不同,所以不能复用浏览器里的签名。老老实实走还原路线。
3. 工具准备与定位加密函数的过程
3.1 核心工具清单
做这种分析,我强烈建议准备一套顺手的工具,不要想着一个万能工具通吃所有场景。我这次用到的组合是:
- 浏览器开发者工具:用来抓接口请求、看调用栈、调试JS代码,这是主力;
- 抓包工具(我习惯用浏览器自带网络面板就够了,如果遇到HTTPS证书问题再用独立工具):用来核对浏览器发出的原始请求头;
- Python执行JS的库:目前主流是
execjs和js2py,我的经验是execjs配合本地的Node环境最稳,js2py虽然纯Python但兼容性差一些,复杂ES6语法经常报错; - 代码编辑器:用来保存还原出来的JS片段,方便反复修改测试。
这里多说一句:本地Node环境是必须的。虽然网上有在线执行JS的网页,但你调试过程中可能要修改代码测试几十次,在线的效率太低,而且某些函数依赖Node特有的Buffer、crypto模块,在浏览器环境跑出来结果不一样。
3.2 定位加密函数的三个突破口
定位JS加密函数是整个过程中最关键的一步,也是最容易卡住新手的一步。我常用的突破口有三个:
第一个突破口:搜索参数名。 这是最快的办法。在开发者工具的Sources面板里按Ctrl+Shift+F全局搜索sign、token、nonce这些关键字,往往能直接跳到生成签名的地方。某平台这个接口的签名参数叫wssign,我一搜就找到了一小段赋值代码,虽然函数体做了混淆,但调用入口很清晰。
第二个突破口:XHR断点。 如果参数名搜索不到,或者搜出来太多了,就换这个方案。在Sources面板里找到接口对应的XHR请求,右键选择"Break on fetch/XHR",然后刷新页面,每次发起请求前代码会停住。你在Call Stack里往下翻,就是调用链路的完整记录,签名生成函数基本就在调用链的某个位置。
第三个突破口:观察请求发起时刻的调用栈。 这个方法适合那些点击按钮后才触发的加载场景。我在实际排查中发现,某平台的签名其实是在一个统一的请求拦截器里统一生成的,所以只用在XHR断点处往下翻两层,就看到了核心函数——虽然变量名是a.b.c这种,但函数里出现了timestamp和nonce关键字,基本实锤。
3.3 打断点观察关键输入输出
定位到函数只是第一步,理解它做了什么才是重点。我通常会在返回签名值的那一行打一个断点,然后在控制台里分别看三样东西:
- 入参是什么(尤其是时间戳和随机数怎么生成的);
- 拼接后的字符串长什么样;
- 最终的输出签名值。
这里有个小技巧:在断点处直接选中某个变量,右键选择"Evaluate in console",可以临时执行表达式。我靠这个办法直接试出了typeof、JSON.stringify的返回值,比反复刷新页面高效得多。
某平台的签名函数调用链大致是这样的:
- 请求拦截器读取当前时间,转成13位毫秒时间戳;
- 生成一段随机数,截取其中8位作为
nonce; - 把URL路径、参数体、时间戳、nonce、盐值按特定顺序拼接成长字符串;
- 对这个字符串做两次SHA256,中间夹了一次Base64编码;
- 输出一段64位的十六进制字符串作为签名。
整个算法并不复杂,但如果只看混淆后的代码,确实会被绕晕。我的建议是把关键值全部记录下来,回到Python里用hashlib和hmac逐步复现,每算一步就和浏览器里的结果比对,差一个字符都能暴露问题。
4. Python模拟JS执行:完整流程与踩坑记录
4.1 技术选型:为什么用"JS代码片段+execjs"方案
定位到核心算法之后,我有两条路可以走:一条是把算法翻译成纯Python代码,另一条是把核心JS代码抽出来,用execjs在Node环境里执行。我最终选了后者,原因有两个。
第一,"翻译"的风险高。混淆代码里可能藏着一些你根本注意不到的隐式转换,比如字符串和数字相加、隐式调用toString()等,每个细节都会影响最终结果。既然JS代码本来就存在,直接让它自己跑,是最不容易出错的方式。
第二,"抽代码"的代价低。整个签名函数依赖的东西不多,不需要翻一大堆外部JS文件。只要把从网上搜到的相关脚本、加密函数片段、调用语句组织在一起,用execjs编译一次就行。后续平台升级算法,我只需要再定位一次,替换掉对应片段,不需要重写整个Python逻辑。
4.2 execjs的环境配置与常见坑
先放一个最简单的测试代码,确认你的execjs能正常工作:
python复制import execjs
ctx = execjs.compile("""
function test(a, b) {
return a + b;
}
""")
result = ctx.call("test", 3, 5)
print(result) # 8
如果你运行这串代码报错,八成是Node没有安装,或者execjs没有找到Node。这里要提醒一个很多人会踩的坑:execjs默认使用系统PATH里第一个能找到的JS运行时,如果你机器上同时装了别的运行环境,它可能找错对象。最简单的解决办法是手动指定Node路径:
python复制import subprocess
import execjs
node_path = "/usr/local/bin/node" # 替换成你自己的node路径
execjs._runtimes["Node"]._executable = node_path
另外,被抽出来的JS代码如果依赖浏览器环境(比如用了window、document、localStorage这些对象),在Node里执行会直接报错window is not defined。解决办法是在JS代码开头做一层环境模拟:
javascript复制var window = window || {};
var navigator = navigator || { userAgent: "Mozilla/5.0" };
var document = document || {};
大多数签名函数只用得到Date、Math这些Node原生支持的对象,如果遇到浏览器专属对象,再针对性地补模拟就行。
4.3 完整抓取流程:从构造签名到拿到影视链接
下面是我最终跑通的完整流程,分为四步。
第一步:获取盐值。 某平台的盐值来自一个公开配置接口,返回JSON里有个secretKey字段。这个值本身不加密,直接requests.get()拿下来就行。注意它是会过期的,我的策略是启动时获取一次,之后每半小时重新获取,避免长时间运行后签名校验失败。
python复制import requests
import time
def get_secret_key():
url = "https://mock-platform.example.com/api/config"
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()["secretKey"]
第二步:构造签名。 把时间戳、nonce、请求路径、POST参数体、盐值按固定顺序拼接,然后丢给JS执行。这里的顺序不能错,我在调试时发现哪怕把参数体和时间戳换了个位置,签名校验都会失败。
python复制def generate_sign(payload_str, timestamp, nonce, secret_key):
js_code = r"""
function sha256(data) {
const crypto = require('crypto');
return crypto.createHash('sha256').update(data).digest('hex');
}
function b64encode(data) {
return Buffer.from(data).toString('base64');
}
function generateSign(payload, ts, nonce, sk) {
const part1 = ts + nonce + payload;
const hash1 = sha256(part1 + sk);
const part2 = b64encode(hash1) + ts;
return sha256(part2);
}
"""
ctx = execjs.compile(js_code)
return ctx.call("generateSign", payload_str, timestamp, nonce, secret_key)
第三步:发起带签名的请求。 签名生成后,放进请求头里的X-Sign字段,同时带上X-Timestamp和X-Nonce这两个辅助字段,服务端会拿这两个值重新计算并比对。
python复制headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"X-Sign": sign_value,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"Content-Type": "application/json"
}
resp = requests.post(api_url, json=payload, headers=headers, timeout=10)
data = resp.json()
第四步:解析返回数据。 某平台的接口返回的是一个标准的JSON结构,影视条目在data.list数组里,每条含title、videoId、playUrl等字段。这里的playUrl就是可以用于播放器直接加载的视频地址,但注意它可能是加密混淆过的——有的播放地址还需要再经过一层解混淆才能直接用,这个后面会详细说。
4.4 一个关键细节:POST参数体的序列化方式
这是我在整个调试过程中花时间最长的一个坑。某平台对POST参数体的拼接不是直接拿Python字典转JSON字符串,而是要求按字段名的字母序排列,并且只包含业务参数、不能包含签名相关的字段。
我一开始没有注意到这个规则,直接用json.dumps(payload)去参与签名计算,结果签名几乎每一次都是错的。后来我对比浏览器里实际发出的请求体,发现它的字段顺序和我发给它的完全不同——浏览器是{"movieId":123,"source":"web","type":1},而我是{"source":"web","type":1,"movieId":123}。在Python里dict默认保留插入顺序,导致拼接字符串顺序变了,签名自然对不上。
正确的做法是手动排序:
python复制payload = {"movieId": 123, "source": "web", "type": 1}
payload_str = json.dumps(payload, sort_keys=True, separators=(",", ":"))
另外,序列化时空格的处理也有讲究。{"movieId": 123}和{"movieId":123}是两个完全不同的字符串,参与签名计算的结果当然不同。某平台用的是不带空格的紧凑模式,也就是separators=(",", ":")这个参数的效果。
5. 播放链接的二次处理:拿到数据不意味着万事大吉
5.1 为什么返回的playUrl不是最终地址
我最初以为签名通过了、接口把playUrl返回来就结束了,结果拿来直接播放,发现播放器报错。仔细一看,这个playUrl字段其实是一个中间态地址,它的格式类似aHR0cHM6Ly9leGFtcGxlLmNvbS92aWRlby5tM3U4,一眼就能认出来——这是Base64编码后的数据。
某平台的逻辑是:服务端先生成真实视频地址,然后做一次Base64编码再返回,目的是防止简单的地址抓取。浏览器端拿到这个字段后,会在JS里解码、拼接一个动态的验证参数,最后才是播放器真正请求的地址。
这层处理不涉及复杂的加密,但由于需要与签名机制配合,很多人会在这一步栽跟头。我在调试时一度以为自己的签名算法有bug,直到把完整链路跑通才发现,playUrl的二次解码是独立的逻辑。
5.2 解码与拼接的完整规则
经过多次对比验证,某平台播放地址的处理规则可以归纳为三步:
- 对返回的
playUrl字段做标准Base64解码,得到一段类似//cdn.example.com/path/video.m3u8?token=xxx的路径; - 在路径末尾拼接一个
&vts=参数,值是当前时间戳的加密形式(又是一个小的MD5运算); - 在拼接结果前补上
https:,得到最终的完整播放地址。
python复制import base64
import hashlib
import time
def resolve_play_url(play_url_encoded, secret_key):
# 第一步:Base64解码
decoded = base64.b64decode(play_url_encoded).decode("utf-8")
# 第二步:计算动态验证参数
ts = str(int(time.time() * 1000))
md5_source = ts + secret_key
vts = hashlib.md5(md5_source.encode("utf-8")).hexdigest()[:8]
# 第三步:拼接最终地址
if decoded.startswith("//"):
return "https:" + decoded + "&vts=" + vts
return decoded + "&vts=" + vts
这里有个细节值得注意:vts参数的名称和格式在不同接口里可能是不同的,我之所以坚持认为是"MD5前8位",是通过在浏览器里触发一次真实播放、对比网络请求里的最终地址反推出来的。
5.3 多集影视链接的批量获取思路
单条链接能拿之后,批量获取的逻辑就顺理成章了。某平台的影视详情页会有一个episodeList接口,返回每集的信息,其中就包含每一集对应的playUrl。只要拿到剧目ID,循环请求每一集的详情接口(或者直接解析全集列表),再用上面的函数做二次处理,就能批量拿到所有集的播放地址。
不过这里有个很现实的问题:批量请求的频率控制。某平台的签名机制本身就是为了限制脚本高频访问,所以在批量抓取时,一定得加合理的延时,比如每集间隔1秒到2秒,并且随机抖动一下,避免固定间隔被识别出来。
我个人的实践是写一个简单的调度函数:
python复制import random
import time
def batch_fetch_episodes(movie_id, episode_count):
results = []
for ep in range(1, episode_count + 1):
data = fetch_single_episode(movie_id, ep)
play_url = resolve_play_url(data["playUrl"], get_secret_key())
results.append({"episode": ep, "url": play_url})
time.sleep(random.uniform(1.0, 2.5))
return results
上面这段代码里特意用了random.uniform(1.0, 2.5)而不是固定的sleep(2),原因是固定间隔在服务端的访问日志里会有非常明显的周期特征,很容易被识别为脚本行为。
6. 调试过程中踩过的坑,以及怎么绕开
6.1 签名每次都变,分不清是算错还是盐值过期
我在调某平台时遇到过一个很让人抓狂的现象:同样的代码,第一次跑能拿到正确的签名,第二次跑就报401。我一开始以为是随机数生成出了问题,后来把签名值打出来和浏览器里的比对,发现差值只发生在盐值部分——原来是盐值接口在两次请求之间返回了不同的值,而我程序里还缓存着旧盐值。
这类问题排查的思路应该是:把影响签名的所有变量列出来,逐个固定住,再对比输出。具体到我的案例,就是固定时间戳和nonce,然后在浏览器里手动执行一遍,控制台里算出来的结果和Python算出来的结果一致,说明算法没问题;不一致,再用二分法缩小范围,定位到具体的拼接步骤。
我建议在写任何签名逻辑时,都先写一个"离线校验模式":从浏览器里抓一组真实的请求参数和签名结果,硬编码成测试用例,每次改动代码后先跑这些用例,都通过了再上真实请求。这样能把算法问题和网络问题隔离开,大幅减少调试时间。
python复制# 离线测试用例:从浏览器里复制出来的真实数据
test_cases = [
{
"payload": {"movieId": 123, "source": "web", "type": 1},
"timestamp": "1700000000000",
"nonce": "a1b2c3d4",
"secret_key": "mock-key-for-test",
"expected_sign": "9f2c4f...(浏览器里复制的真实值)"
},
]
6.2 execjs在不同机器上的兼容性问题
在本地环境跑通之后,我把脚本部署到另一台机器上,结果execjs开始报一些莫名其妙的错,类似ReferenceError: window is not defined。排查后发现,那台机器上安装的Node版本比我本地新,对全局变量检查更严格,一段原本看似无害的代码(比如先声明了var window,但后面代码里用了window.location)在新版本下就直接抛异常。
这种情况下有两个解决方案:
- 在JS代码里把浏览器环境模拟得更完整,尽量覆盖所有用到的属性和方法;
- 干脆升级到同版本的Node,保持环境一致。
我更推荐第一种,因为你的脚本将来可能要在不同环境跑,模拟完整一点更稳健。比如上面提到会把window.location补成一个简单的对象:
javascript复制var window = {
location: { href: "https://mock-platform.example.com/", protocol: "https:" }
};
var location = window.location;
6.3 频率限制触发的"隐藏"机制
某平台在签名正确的情况下,如果短时间内请求次数过多,并不会直接报错,而是返回一个脚本检测页——页面内容是正常的HTML,但接口数据全被替换成了一串加密字符串。这种反爬手段很隐蔽,因为状态码是200,程序不会报错,但解析结果全是乱码。
我的对策是在解析数据之前先做一层内容校验,检查返回文本里是否包含预期的JSON字段。如果发现没有,就判定为触发了频率限制,主动停止一段时间再继续。这种方法比靠状态码判断可靠得多。
python复制def safe_parse(resp_text):
import json
try:
data = json.loads(resp_text)
except json.JSONDecodeError:
raise RuntimeError("响应不是JSON,可能触发了频率限制")
if "data" not in data or "list" not in data.get("data", {}):
raise RuntimeError("响应结构异常,可能被反爬机制拦截")
return data
6.4 时间戳时区问题
还有一个坑是时区。某平台接口要求的时间戳是"北京时间"对应的毫秒数,如果服务器部署在UTC时区环境,int(time.time() * 1000)算出来的是UTC时间,跟北京时间差了8小时,签名校验一个小时内是过不了。
这个问题在很多国内平台都存在,尤其当你将爬虫部署在海外服务器上时最容易踩到。解决办法是强制使用北京时间:
python复制from datetime import datetime, timezone, timedelta
def beijing_timestamp_ms():
beijing_tz = timezone(timedelta(hours=8))
now = datetime.now(beijing_tz)
return int(now.timestamp() * 1000)
注意datetime.now(beijing_tz).timestamp()内部会正确转换时间戳,不受服务器本地时区的影响。
7. 把单次爬取扩展成稳定的定时任务
单次脚本跑通不难,难的是让这个脚本能持续稳定地运行。我在把脚本做成定时任务时,遇到了几个值得记录的问题。
7.1 盐值的缓存与自动刷新
盐值的获取接口虽然不加密,但调用频率太高容易触发风控。我在脚本里设置了缓存机制:启动时获取一次,之后每30分钟自动刷新。因为盐值过期后签名会持续报401,所以刷新逻辑必须放在请求的前置校验里,而不是定时任务里。
python复制class SecretKeyManager:
def __init__(self, ttl=1800):
self.ttl = ttl
self._value = None
self._expire_at = 0
def get(self):
now = time.time()
if now >= self._expire_at:
self._value = get_secret_key()
self._expire_at = now + self.ttl
return self._value
7.2 日志与异常重试
爬虫脚本跑在无人值守的服务器上,日志重要性不言而喻。我在每个关键节点都会打印一行结构化日志,包含时间、请求ID、签名生成耗时、接口返回状态等。异常重试这块,我的原则是:网络超时类错误可以重试3次,签名校验错误不重试(因为重试也没用,大概率是盐值或者算法问题)。
python复制import logging
logger = logging.getLogger("spider")
logger.setLevel(logging.INFO)
def request_with_retry(func, retries=3):
for attempt in range(retries):
try:
return func()
except requests.exceptions.Timeout:
logger.warning("请求超时,第%d次重试", attempt + 1)
time.sleep(2)
raise RuntimeError("重试多次仍然失败")
7.3 数据存储与增量更新
采集到的影视链接如果直接塞内存,脚本一重启就全丢了。我选择写入本地的SQLite数据库,表结构很简单:movie_id、episode、play_url、updated_at。增量更新的逻辑是:如果同一ID的链接没有变化,就跳过;有变化才更新,避免频繁写库。
说实话,写这个定时任务的过程,比写爬虫本身花的时间还多。但这也正是工程实践和课堂练习的区别——抓取只是起点,稳定运行才是目标。
8. 一些项目之外的话
通过这个案例,我想传递几个做JS签名类爬虫的通用经验,这些感想也是我自己踩坑之后才提炼出来的。
第一个经验:先当侦探,再当程序员。 很多人一遇到JS加密就急着写代码,结果代码越写越长、bug越来越多。正确顺序永远是先观察——把请求链路、参数拼接顺序、签名生成过程彻底搞明白,再动手。观察阶段花两小时,能省下后面两天的调试时间。
第二个经验:能复用就复用,别总想着从零翻译。 把混淆的JS代码翻译成Python既费时间又容易出错,除非算法非常简洁,否则用execjs直接执行原生JS代码是更稳妥的选择。我后面又做了几个类似案例,基本都沿用这个方案,效率提升明显。
第三个经验:时刻保持"可以被公开数据"的边界感。 做爬虫的人离不开研究反爬机制,但一定要区分"公开数据"和"版权内容"。本文的案例只涉及某平台对公开浏览数据的访问控制,不涉及版权内容的破解。任何绕过版权保护、破坏服务正常运营的做法都是不可取的,这一点希望读者能有所思考。
最后,如果你也正在处理类似的v7签名类型,把文章里的定位方法、代码框架直接拿过去用就好。如果后续遇到v8、v9版本,也不用慌——万变不离其宗,先把"参数怎么拼、盐值从哪来、算法在哪执行"这三件事搞清楚,剩下的只是时间问题。我在实际调试中的体会是,这类问题最怕的不是算法复杂,而是没有章法地瞎试。有了套路,下一个加密版本无非是多花几个小时定位而已。
