简介:面向Python爬虫进阶学习者的JS解密逆向实战资源,精选多个真实站点逆向案例,适合毕业设计、大作业或数据采集项目参考。压缩包共86个文件,以JavaScript解密脚本和Python爬虫脚本为主,包含42个js文件、31个py文件,并配5份md说明文档与少量图片样图,整体仅1.13MB,体积紧凑但案例密集。目前已有1102人学习浏览。内容涵盖Cookie生成、密码加密、参数签名、滑块验证及AES/RSA/DES等常见加密逻辑,覆盖携程、小红书、淘宝搜索、芒果TV等平台场景;每个案例目录通常包含加密JS、爬虫入口脚本与README说明,可快速定位关键断点与调用链。资源整体围绕“JS解密+Python抓取”两条主线组织,模块独立,便于按需查阅和二次改造。对想弄清前端加密与Python联动、提升反爬应对能力的开发者来说,这套资源能提供直接可运行的参考脚本和拆解思路。
1. 从"能爬到爬不动"之间,隔着一层 JS 加密
做 Python 爬虫的人大概率经历过这种反转:昨天还能正常采集的接口,今天返回 403 或者一串密文,打开浏览器却一切正常。问题往往不在 IP 被封,而是服务端给请求参数加了签名校验,签名逻辑用 JavaScript 在前端算好,Python 这边拿不到。所谓 JS 解密逆向,就是把这层浏览器里执行的计算搬到 Python 里,让请求看起来和真实用户毫无差别。本文按我实际处理这类问题的顺序展开:先定位加密入口,再选执行方案,接着识别算法并还原,最后讨论签名验证和工程化落地。适合已经能熟练写 requests 和解析数据、但第一次直面加密参数的爬虫开发者。
2. 定位加密入口:从浏览器断点到调用栈,别上来就翻源码
2.1 先判断加密形态,决定用哪种逆向路线
JS 加密逆向第一步不是读代码,而是确定要逆的对象是什么。常见形态有三种:参数型加密、响应型加密、请求头加密。
参数型加密最常见,表现为 URL 查询串里有sign、token、_signature之类的字段,值是一串十六进制或 Base64 字符。响应型加密是服务器返回的数据本身是密文,前端用 JS 解密后再渲染,典型特征是浏览器 Network 面板里响应内容是一堆乱码,页面却正常显示。请求头加密则是把时间戳、设备指纹、随机数拼进 header,比如x-sign或authorization,校验失败返回 401 或 403。
判断方法很直接:在 Network 面板里逐个请求查看,把可疑字段复制下来,刷新页面看值是否变化。每次都变的字段大概率是动态签名,固定不变的字段可能是设备指纹或时间戳,处理策略完全不同。我一般先用这个分类决定后续优先级,动态签名优先处理,因为它直接影响能否拿到数据。
2.2 用全局搜索锁定关键字,缩小函数范围
打开 DevTools 的 Sources 面板,按Ctrl+Shift+F全局搜索。关键词从请求参数里提取:看到sign就搜sign,看到token就搜token,也可以直接搜参数名加上=的组合,比如"sign="或sign:. 搜索结果会列出所有包含该关键字的 JS 文件和行号,优先看文件体积小的、名字带chunk、min、encrypt、crypto的文件。
这里有个容易踩的坑:很多站点会做 JS 混淆,变量名被替换成_0x3f2a这种形式,搜sign可能搜不到,因为属性名也被改了。这种情况下改用搜索值的特征,比如签名字段的值以md5开头,或长度固定为 32 位,就去搜加密算法常见的特征字符串,比如md5(、sha256(、AES、CryptoJS。搜到结果后不要急着读全部代码,先看函数入口和 return 语句。
2.3 XHR 断点加调用栈回溯,定位加密调用位置
搜索能告诉你加密函数在哪,但不知道它在哪个调用链路里被触发。这时候用 XHR/fetch 断点效率最高:在 Sources 面板右侧的 XHR/fetch breakpoints 里勾选Any XHR,然后刷新页面,请求发出前代码会停在发起请求的那一行。此时右侧 Call Stack 面板能看到完整的调用链,从上往下点,凡是执行到包含加密操作的函数,右侧 Scope 面板里通常能看到加密前的明文参数和加密后的结果。
具体流程我一般这样走:
- 在
fetch或XMLHttpRequest.send处设置断点 - 刷新页面,等待断点命中
- 在 Call Stack 里逐层往上找,观察每层函数的参数和局部变量
- 找到
sign值第一次出现的函数,在那一行重新下断点,刷新后单步执行
这一个过程基本能把"明文参数如何在内存里变成密文"的路径走通。定位到具体函数后,右键该函数名选择Show function definition,跳到源码处把函数体完整复制出来,这就是后面在 Python 里执行或还原的原材料。
提示:混淆严重的代码里,单步执行比读源码可靠。每执行一行就看一次变量面板,变量从什么值变成什么值,比猜测函数逻辑直观得多。
3. 把 JS 从浏览器搬到 Python:三种执行方式与选型边界
3.1 PyExecJS:适合临时验证,不适合生产
拿到加密函数后,最省事的做法是直接用 PyExecJS 在 Python 里调用。安装方式pip install PyExecJS,它需要一个 JS 运行时,系统里装了 Node.js 或 PhantomJS 即可。
import execjs # 从文件读取逆向得到的完整 JS 代码 with open('sign.js', 'r', encoding='utf-8') as f: js_code = f.read() # 编译 JS 环境 ctx = execjs.compile(js_code) # 调用 JS 里的签名函数,参数按原函数签名传入 sign = ctx.call('getSign', 'param1_value', 'param2_value') print(sign)这段代码的逻辑是:先用compile把 JS 代码加载进运行时,再用call调用其中暴露的函数。getSign是 JS 里定义好的函数名,参数顺序必须和它在 JS 中的定义完全一致,多传或少传都会直接报错。
PyExecJS 的优势是上手快,三五行代码就能验证逆向结果是否正确。但它每次调用都要启动一次 JS 运行时,性能差,且依赖系统环境里的 Node 版本,换机器容易出兼容问题。我的用法是拿它做验证:确认 JS 代码在 Python 里能跑出和浏览器一致的结果,然后再决定是否要改写或接入执行引擎。
3.2 Node.js 子进程:工程化首选方案
生产环境我更推荐用 Node.js 子进程。思路是把 JS 代码封装成一个独立脚本,Python 通过subprocess调用它,用 stdin/stdout 交换数据。这样 JS 和 Python 完全解耦,JS 部分可以独立测试、独立更新。
// sign.js —— 由浏览器逆向得到的加密函数 + 这个启动入口 function getSign(param1, param2) { // 这里是从浏览器里复制的原始加密逻辑 return md5(param1 + param2 + 'salt'); } // 从 stdin 读取 JSON 参数 let input = ''; process.stdin.on('data', (chunk) => { input += chunk; }); process.stdin.on('end', () => { const params = JSON.parse(input); const result = getSign(params.param1, params.param2); process.stdout.write(JSON.stringify({ sign: result })); });import json import subprocess def call_js(params): # 启动 node 子进程,传入脚本路径 proc = subprocess.run( ['node', 'sign.js'], input=json.dumps(params), # 参数以 JSON 形式写入 stdin capture_output=True, text=True, timeout=10 ) # 解析 stdout 返回的 JSON 结果 if proc.returncode == 0: return json.loads(proc.stdout)['sign'] else: raise RuntimeError(f'JS 执行失败: {proc.stderr}')subprocess.run的input参数负责把 Python 侧的字典序列化成 JSON 传给子进程,capture_output=True收集标准输出,timeout=10防止 JS 死循环拖垮爬虫进程。这种方式的代价是每次调用多一次进程启动开销,但好在签名计算通常不需要高频触发,每秒几次的量级完全扛得住。
如果并发量大,可以做一次小优化:启动常驻 Node 进程,Python 端持续往 stdin 写请求、从 stdout 读结果,省去反复启动进程的开销。不过这要求 JS 端写成循环模式,复杂度会上升,我一般等到单机 QPS 超过两位数才做这一步。
3.3 Playwright 直接执行:绕开剥离代码的麻烦
有个场景比上面两种都棘手:加密函数依赖浏览器环境,比如用了window.navigator、document.cookie或者 WebCrypto API,直接用 Node 跑会因为环境缺失而报错。这时候换 Playwright 在浏览器上下文中执行是最稳的。
import asyncio from playwright.async_api import async_playwright async def get_sign_js(page, param1, param2): # 在页面上下文中执行 JS,可以访问 window 和 document sign = await page.evaluate( "([p1, p2]) => window.getSign(p1, p2)", [param1, param2] ) return sign async def main(): async with async_playwright() as p: browser = await p.chromium.launch() page = await browser.new_page() # 先访问目标站点,这样 JS 环境与真实场景一致 await page.goto('https://example.com') result = await get_sign_js(page, 'a', 'b') print(result) await browser.close() asyncio.run(main())这段代码的核心是page.evaluate,它把参数列表传给页面里的函数并在浏览器环境里执行,返回结果直接可被 Python 使用。先goto目标站点是为了让window上的全局变量和 cookie 都处于真实状态,很多 JS 逆向结果在这个环境里才能跑通。
这种方案鉴权成功率最高,但性能和资源占用也最重,每开一个浏览器实例就是几百 MB 内存。我的选择标准是:Node 跑不通且不想剥离浏览器依赖时用它,同时用连接池复用同一个浏览器实例,避免频繁开关。
4. 从算法特征到 Python 还原:MD5、SHA、AES、RSA 的判定与实现
4.1 识别加密算法的特征对照
有些场景不适合保留 JS 代码执行,比如签名函数依赖的 JS 文件过大导致启动慢,或者想彻底去掉 Node 依赖,那就需要把 JS 逻辑翻译成 Python。翻译前先识别算法,特征对照表如下:
| 特征 | 对应算法 | 佐证线索 |
|---|---|---|
| 输出长度 32 位,十六进制字符 | MD5 | JS 里常出现md5(或CryptoJS.MD5 |
| 输出长度 40 或 64 位 | SHA1 / SHA256 | 搜索sha关键字,hash.update |
| 加密结果是 Base64 串,且每次带有随机 IV | AES-CBC | 出现CryptoJS.AES、enc.Utf8.parse |
| 加密结果超长,且用到公钥文件 | RSA | 出现setPublicKey、JSEncrypt、pkcs1 |
| 输出固定 8 位或 16 位,且与时间戳拼接 | 自定义或 HMAC | 搜索hmac、btoa、encodeURIComponent |
这个表不是绝对判定,只是一个起点。真正有效的方法是动态对比:在浏览器里手动改一个明文参数,观察密文对应位置的字符变化。改动一小节明文密文整体改变,八成是块加密算法;只有局部变化则可能是个性化编码或哈希截断。
4.2 AES 类 JS 加密的 Python 还原
AES 在 JS 逆向里出现频率极高,通常是 AES-CBC 或 AES-ECB 模式加上 PKCS7 填充。JS 侧代码常见是这样:
// CryptoJS AES 加密的典型写法 function encrypt(data, key) { var keyHex = CryptoJS.enc.Utf8.parse(key); var ivHex = CryptoJS.enc.Utf8.parse('0123456789abcdef'); var encrypted = CryptoJS.AES.encrypt(data, keyHex, { iv: ivHex, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.toString(); }对应到 Python 侧,用pycryptodome库可以完整还原:
from Crypto.Cipher import AES from Crypto.Util.Padding import pad import base64 def js_aes_encrypt(plaintext: str, key: str, iv: str) -> str: # 构造 AES-CBC 加密器,key 和 iv 必须是 bytes cipher = AES.new(key.encode('utf-8'), AES.MODE_CBC, iv.encode('utf-8')) # PKCS7 填充,CryptoJS 默认也是 PKCS7 padded = pad(plaintext.encode('utf-8'), AES.block_size) encrypted = cipher.encrypt(padded) # CryptoJS 的 encrypted.toString() 默认输出 Base64 return base64.b64encode(encrypted).decode('utf-8')AES.new的第一个参数是密钥,必须和 JS 里的Utf8.parse(key)结果一致,也就是直接取字符串的 UTF-8 字节。IV 同理。如果 JS 里没显式指定iv,CryptoJS 会用随机 IV,并且密文会带上 IV 前缀,这时候需要从密文前 16 字节提取 IV 再做解密,否则无法还原。
经常被忽略的一个坑是填充模式。CryptoJS 默认Pkcs7,但有些自定义实现会写ZeroPadding或NoPadding,Python 侧就要相应地用pad(..., style='pkcs7')换成zeros或干脆不填充。识别方法是看明文长度是否为 16 的整数倍:不是却还能加密成功的,必然有填充逻辑。
4.3 MD5 与 RSA 的翻译要点
MD5 和 SHA 是纯哈希,没有密钥概念,翻译最简单。JS 里CryptoJS.MD5('abc').toString()对应 Python 的hashlib.md5(b'abc').hexdigest()。但实际逆向里它很少单独出现,更多是和字符串拼接组合,比如md5(timestamp + nonce + secret)。翻译时注意 JS 的+在字符串上是直接拼接,Python 里不要漏掉转型:
import hashlib def js_md5_concat(timestamp: str, nonce: str, secret: str) -> str: raw = timestamp + nonce + secret return hashlib.md5(raw.encode('utf-8')).hexdigest()RSA 相对复杂,因为标准库不提供 RSA 加密,需要rsa或pycryptodome。JS 里JSEncrypt默认使用 PKCS1 v1.5 填充,公钥是 PEM 格式的字符串。Python 侧还原:
import rsa def js_rsa_encrypt(plaintext: str, public_key_pem: str) -> str: # 加载 PEM 格式公钥 pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(public_key_pem.encode('utf-8')) # PKCS1 v1.5 加密,对应 JSEncrypt 默认行为 encrypted = rsa.encrypt(plaintext.encode('utf-8'), pub_key) # JSEncrypt 的 output 默认是 Base64 编码 return base64.b64encode(encrypted).decode('utf-8')一个容易出错的点:JSEncrypt 加密结果每次都可能不同(因为 PKCS1 v1.5 有随机填充),如果服务端只认第一次的密文,说明你还漏掉了时间戳或随机数参与拼接,RSA 本身不是问题所在。另外 RSA 对明文长度有限制,1024 位密钥最多加密 117 字节明文,长数据通常是先 AES 加密再用 RSA 加密 AES 密钥,这种混合模式在接口逆向里也常见。
5. 签名参数验证与绕过反爬的收尾:比对、持久化、动态盐值处理
5.1 本地反向验证:用同一组输入比对 JS 与 Python 输出
逆向完成不等于能上线,先做一致性验证。方法是用一组固定的时间戳和随机数,分别在浏览器、Node 执行的原始 JS、Python 还原版里计算签名,三者结果必须完全一致。不一致时按优先级排查:先看字符串拼接顺序,再看编码方式(UTF-8 还是有 BOM),最后看填充模式。
一个实用的验证脚本:
import hashlib import time def verify_md5_impl(): # 固定输入,避免随机因素干扰比对 timestamp = "1735689600" nonce = "abc123" secret = "test_secret" # 浏览器/Node 环境下运行原始 JS 得到的值,替换成你的实测值 js_result = "19a5c5b4b2f6c1d0e0a1b2c3d4e5f6a7" # Python 本地实现 py_result = hashlib.md5( (timestamp + nonce + secret).encode('utf-8') ).hexdigest() # 直接比对 assert js_result == py_result, f"不一致: JS={js_result}, Python={py_result}" print("校验通过,签名实现一致") verify_md5_impl()这种验证脚本要保留在项目里,因为服务端更新 JS 后,你只需要用新 JS 跑一遍同样的固定输入,如果 Python 结果对不上,就知道算法变了,而不是等线上接口 403 才发现。
5.2 动态盐值:别写死,从响应或本地存储中提取
逆向到后期最容易踩的坑是盐值固定。很多站点的签名公式是md5(params + salt),salt 不是写死在 JS 里,而是服务器在登录接口或页面初始化接口里下发的,存在 localStorage 或全局变量里。这类盐值会定期轮换,一旦写死,签名校验必挂。
处理上我采用一个折中方案:爬虫启动时先请求一次页面初始化接口,从响应 JSON 或 HTML 里用正则提取新盐值,再注入到签名函数里。盐值的生命周期通常和会话绑定,所以每个会话任务开始时重新提取一次:
import re import requests def fetch_realtime_salt(session, init_url): # 请求初始化接口,拿到包含盐值的页面 resp = session.get(init_url) # 用正则从返回内容中截取 salt,具体模式按站点结构调整 match = re.search(r'"salt"\s*:\s*"([^"]+)"', resp.text) if not match: raise ValueError("未找到盐值,站点可能已更新下发逻辑") return match.group(1)session对象保持 cookie 一致性,盐值从响应正文里动态提取而不是直接读配置,这样就算服务端定期轮换盐值,爬虫也能自动适应。
5.3 签名与代理、Cookie 的组合使用
签名逆向解决的是"请求参数合法"问题,但爬虫稳定运行还需要 Cookie 和出口 IP 配合。加密签名只是服务端校验的一环,通常还会配合 cookie 中的 session 字段和页面浏览行为做风控。
常见做法是按并发量配置代理 IP 池,每个代理 IP 绑定一套独立的 Cookie 和签名参数,避免多个请求共享同一套身份特征被识别。同时在代码里对签名函数做缓存:同一秒内的相同参数不重复计算签名,降低 CPU 开销,也减少 JS 进程频繁启动。
5.4 遗留问题排查清单
到上线阶段如果仍然遇到 403 或校验失败,按下面顺序排查:先确认签名值是否与浏览器当前环境一致,排除时间戳不同步;再检查请求头里是否缺少User-Agent或Origin,很多站点把 UA 纳入签名计算;最后看 Cookie 是否有浏览器指纹字段,比如_fp或device_id,这类字段不参与签名但参与风控评分。整套流程跑通后,把签名函数、盐值提取、验证脚本打包成一个独立模块,这样 JS 更新时只需替换模块内的加密逻辑,爬虫主体不用动。
本文还有配套的精品资源,点击获取