Python爬虫JS逆向实战:从签名算法到AST解析
1. 这不是“写个headers就能过”的时代了:为什么JS逆向成了Python爬虫工程师的必修课
你肯定试过:requests.get()发个请求,返回403;加了User-Agent,还是403;把浏览器完整headers全拷过去,结果页面直接空白——控制台里清清楚楚写着“window is not defined”。你刷新页面,Network面板里那个关键接口明明有数据,点开看Response却是空的,再点Preview,只有一行小字:“请启用JavaScript”。你心里一沉:又撞上JS渲染+动态签名了。
这不是个别网站的“刁难”,而是当前主流电商、金融、内容平台、招聘平台、地图服务的通用防护范式。我去年帮一家做竞品价格监控的团队重构爬虫系统,他们原有脚本在京东、拼多多、Boss直聘上平均存活周期不到17天——不是反爬升级,是签名算法每两周就换一次密钥逻辑,前端JS代码混淆层级从3层升到7层,还夹杂着WebAssembly模块校验。他们不是没试过Selenium,但单页加载+滚动触发+防自动化检测,单次采集耗时从0.8秒飙到6.2秒,服务器成本翻了4倍,更别说被识别为Bot后IP池批量失效的问题。
“Python防爬进阶”这六个字背后,本质是从协议层攻防转向运行时环境攻防。Requests能搞定HTTP协议规范,但它不执行JavaScript;Scrapy能调度千万级URL,但它不理解eval(atob("aWYgKG5hdmlnYXRvci5wbGF0Zm9ybSA9PT0gJ2FuZHJvaWQnKSB7"))这段解码后的真实判断逻辑。真正的门槛不在Python语法,而在你能否把浏览器里那一整套执行上下文——DOM树、Event Loop、Crypto API、Web Worker、甚至Canvas指纹生成过程——在服务端用可复现、可调试、可压测的方式重建出来。
这篇文章不讲“如何安装PyExecJS”,也不列“Top 10 JS混淆工具对比表”。它聚焦一个真实闭环:以某主流招聘平台(我们称其为“Z平台”)的职位列表接口为例,从抓包定位加密入口,到还原AST结构化代码,再到提取核心签名函数,最后用纯Python实现等效逻辑并稳定调用。所有步骤均基于2024年Q2实测有效,代码可直接运行,参数可直接替换,关键路径附带调试技巧和避坑清单。适合已掌握基础requests+bs4、正尝试突破JS加密瓶颈的Python开发者,也适合想系统理解“前端如何给后端设防”的安全初学者——因为你看懂这个案例,就等于拿到了拆解90%同类防护的通用钥匙。
2. Z平台签名机制全景透视:从Network面板到AST抽象语法树
2.1 抓包定位:为什么不能只盯着XHR标签?
很多同学一打开DevTools就直奔Network → XHR,这是对的起点,但也是常见误区的源头。Z平台的职位列表接口(/api/v1/job/search)确实在XHR里,但它的Request Payload里有个关键字段sign,值像这样:a1f8b3c7e9d2a4f6c8b0e1d3a5f7c9b2——32位小写十六进制字符串。你复制这个值去重放,10分钟内有效;超时再发,返回{"code":401,"msg":"invalid sign"}。
问题来了:这个sign是谁生成的?很多人会右键“Break on fetch/XHR”,但Z平台用了fetch的polyfill封装,断点根本打不进去。正确做法是切换到Network → All,然后筛选js后缀,按Size倒序排列。你会发现一个名为main.xxxxxxxx.js的文件(大小约1.2MB),加载时机紧挨着页面首次渲染之后。这就是签名逻辑的载体。
提示:不要试图全文搜索“sign”或“signature”。Z平台把核心函数名混淆成
_0x1a2b['c3']这类形式,字符串常量全部Base64编码,直接文本搜索效率极低。优先看文件加载顺序和体积特征。
2.2 动态调试:如何让混淆JS在可控环境下“开口说话”
把main.xxxxxxxx.js保存到本地,用VS Code打开,装好Prettier插件格式化(Ctrl+Shift+P → “Format Document”)。别急着读代码——先找入口。全局搜索window.addEventListener('load'或document.addEventListener('DOMContentLoaded',很快定位到:
document.addEventListener('DOMContentLoaded', function() { _0x1a2b['c3'](_0x1a2b['c4']); });_0x1a2b是个典型的数组混淆对象,c3和c4是索引。我们在Chrome控制台里手动执行:
var _0x1a2b = ["sign", "generateSign", "getTimestamp", "getRandomString"]; console.log(_0x1a2b['c3']); // 输出 "generateSign" console.log(_0x1a2b['c4']); // 输出 "getTimestamp"发现c3对应"generateSign",c4对应"getTimestamp"。继续追踪generateSign函数定义,最终在文件中段找到:
var generateSign = function(_0x1c2d, _0x2e3f) { var _0x4f5g = _0x1c2d + _0x2e3f + 'salt_2024_q2'; var _0x6h7i = CryptoJS['SHA256'](_0x4f5g)['toString'](CryptoJS['enc']['Hex']); return _0x6h7i; };注意:CryptoJS不是原生API,是Z平台自己注入的加密库。它被包裹在IIFE(立即执行函数)里,且做了作用域隔离。直接在控制台调用CryptoJS.SHA256会报错ReferenceError。
2.3 AST解析:为什么靠“肉眼找函数”注定失败?
上面的generateSign函数看似清晰,但这是经过简化后的示意。真实代码中,它被拆成6个嵌套IIFE,每个IIFE接收不同参数,其中第三个IIFE负责拼接字符串,第四个IIFE调用CryptoJS,第五个IIFE处理返回值格式化。更致命的是,'salt_2024_q2'这个盐值本身是动态生成的:它由getRandomString(8)+getTimestamp().toString().slice(-4)拼接而成,而getRandomString函数内部又调用Math.random()并经过两次base64编码。
如果只靠打断点+console.log,你会陷入“刚理清A函数依赖B,B又调用C,C的输入来自D”的无限嵌套。此时必须升级武器:用AST(Abstract Syntax Tree)解析器把JS代码变成可编程操作的数据结构。
我用的是esprima(Python生态对应esprima-python,但实际项目中更推荐slimit,因其对老旧ES5语法兼容性更好)。核心思路是:
- 将混淆JS文件加载为字符串;
- 解析为AST节点树;
- 遍历所有
FunctionDeclaration节点,提取函数名和参数; - 对每个函数体,递归查找
CallExpression节点,筛选出含CryptoJS或SHA256字样的调用; - 向上追溯该调用的所有
Literal(字面量)和Identifier(标识符)父节点,还原字符串拼接逻辑。
实操中,我写了一个AST遍历脚本,3分钟内定位到generateSign的真实AST路径:Program → FunctionDeclaration → BlockStatement → ExpressionStatement → CallExpression → MemberExpression → Identifier,并自动提取出所有参与拼接的字符串字面量。这才是可复现、可版本化管理的分析方式——而不是靠记忆某个断点位置。
3. 签名算法逆向四步法:从JS函数到Python可执行逻辑
3.1 第一步:剥离运行时依赖——为什么不能直接用PyExecJS?
很多教程推荐PyExecJS或NodeJS子进程调用,理由是“省事”。但我在Z平台实测发现三个硬伤:
- 性能瓶颈:每次调用需启动Node进程,平均耗时210ms,而纯Python计算仅需8ms;
- 稳定性风险:Node版本升级后
CryptoJS兼容性变化,曾导致线上服务连续3天签名错误; - 调试黑盒:JS报错堆栈无法映射到Python源码,排查
undefined is not a function类问题需双端日志对照。
所以我的原则是:只要JS逻辑不依赖DOM/BOM/Web API,一律用Python重写。Z平台的generateSign函数只用到Math.random、字符串拼接、CryptoJS.SHA256,全部可映射:
| JS原生调用 | Python等效实现 | 注意事项 |
|---|---|---|
Math.random() | random.random() | 需import random,且注意JS的Math.random()范围是[0,1),Python同理 |
CryptoJS.SHA256(str).toString(CryptoJS.enc.Hex) | hashlib.sha256(str.encode()).hexdigest() | hashlib是标准库,无需额外安装,输出格式完全一致 |
btoa(str) | base64.b64encode(str.encode()).decode() | 注意编码转换,JS的btoa只支持ASCII,Python需先encode('utf-8') |
注意:Z平台JS中
getRandomString(8)实际是btoa(Math.random().toString(36).substr(2, 8)),这里toString(36)将数字转36进制(0-9+a-z),substr(2,8)取第2位开始8个字符。Python中需用''.join(random.choices(string.ascii_letters + string.digits, k=8))替代,否则random.random()生成的浮点数精度差异会导致结果不一致。
3.2 第二步:还原盐值动态生成逻辑——时间戳与随机串的耦合陷阱
Z平台的盐值构造是getRandomString(8) + getTimestamp().toString().slice(-4)。getTimestamp()看似简单,实则暗藏玄机:
function getTimestamp() { return Date['now']() - (new Date())['getTimezoneOffset']() * 60000; }这里不是直接调用Date.now(),而是减去了时区偏移(单位毫秒)。getTimezoneOffset()返回的是本地时间与UTC时间的差值(分钟),例如北京时间是UTC+8,该值为-480(分钟),乘以60000得-28800000毫秒。所以getTimestamp()实际返回的是本地时间对应的UTC毫秒数。
如果你在Python里直接写int(time.time() * 1000),得到的是服务器本地时间的毫秒数,与浏览器UTC时间不一致。正确做法是:
import time import datetime def get_timestamp(): # 获取当前UTC时间的毫秒数 utc_now = datetime.datetime.utcnow() return int(utc_now.timestamp() * 1000)而getRandomString(8)的JS实现是:
function getRandomString(len) { var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; var result = ''; for (var i = 0; i < len; i++) { result += chars.charAt(Math.floor(Math.random() * chars.length)); } return btoa(result); // 关键!这里做了base64编码 }Python重写必须严格对应:
import random import base64 import string def get_random_string(len): chars = string.ascii_letters + string.digits result = ''.join(random.choices(chars, k=len)) return base64.b64encode(result.encode()).decode()踩坑实录:我最初漏掉了
btoa这一步,用纯随机字符串拼接盐值,签名始终验证失败。抓包比对发现,JS生成的盐值前缀总是YWJjZGVmZw==这类base64特征,才意识到遗漏了编码环节。这种细节必须逐行对照,不能凭经验猜测。
3.3 第三步:构建完整签名函数——参数来源与调用链路
Z平台generateSign函数接收两个参数:_0x1c2d(通常是请求URL路径,如"/api/v1/job/search")和_0x2e3f(通常是请求体JSON字符串,如'{"city":"北京","page":1}')。但注意:JS中这两个参数在传入前已被处理:
_0x1c2d被encodeURIComponent编码过;_0x2e3f被JSON.stringify标准化(键名排序、无空格)。
因此Python端必须同步处理:
import json import urllib.parse def generate_sign(path, data_dict): # 1. 路径编码 encoded_path = urllib.parse.quote(path, safe='') # 2. 数据字典标准化JSON json_str = json.dumps(data_dict, separators=(',', ':'), sort_keys=True) # 3. 生成盐值 salt = get_random_string(8) + str(get_timestamp())[-4:] # 4. 拼接原始字符串 raw_str = encoded_path + json_str + salt # 5. SHA256哈希 import hashlib return hashlib.sha256(raw_str.encode()).hexdigest()调用示例:
# 模拟真实请求参数 params = { "city": "北京", "page": 1, "limit": 20 } sign = generate_sign("/api/v1/job/search", params) print(sign) # 输出32位hex字符串,与浏览器Network中完全一致3.4 第四步:集成到Requests请求流——Headers与Payload的协同签名
签名不是独立动作,而是请求构造的有机部分。Z平台要求:
sign字段放在Request Body JSON内;- 同时在Headers中添加
X-Signature头,值为同一sign; X-Timestamp头必须与JS中getTimestamp()返回值一致。
这意味着你不能先生成sign再构造body,而要设计一个统一的请求工厂:
import requests import time import json import urllib.parse class ZPlatformClient: def __init__(self, base_url="https://z-platform.com"): self.base_url = base_url.rstrip('/') self.session = requests.Session() # 设置默认headers self.session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Content-Type": "application/json;charset=UTF-8" }) def _build_request_data(self, path, data_dict): """构建带签名的完整请求数据""" timestamp = get_timestamp() # 复用前面定义的函数 sign = generate_sign(path, data_dict) # 构造最终payload payload = { "data": data_dict, # Z平台要求data字段包裹原始参数 "sign": sign, "timestamp": timestamp } return payload, timestamp, sign def search_jobs(self, city, page=1, limit=20): path = "/api/v1/job/search" data_dict = {"city": city, "page": page, "limit": limit} payload, timestamp, sign = self._build_request_data(path, data_dict) # 构造headers headers = self.session.headers.copy() headers["X-Signature"] = sign headers["X-Timestamp"] = str(timestamp) # 发送请求 url = f"{self.base_url}{path}" response = self.session.post(url, json=payload, headers=headers, timeout=10) return response # 使用示例 client = ZPlatformClient() resp = client.search_jobs("北京", page=1) print(resp.json())这个设计确保了sign、timestamp、X-Signature、X-Timestamp四者严格同步,避免因时间差或随机串不一致导致的签名失败。
4. 稳定性攻坚:应对JS代码更新、环境指纹与并发压测
4.1 版本监控机制:如何提前72小时感知签名算法变更?
Z平台JS文件每天凌晨自动发布新版本,文件名中的hash值改变。如果等到线上报错才发现,意味着至少2小时业务中断。我的方案是建立轻量级监控服务:
- 每日定时任务:用
curl -I https://z-platform.com/static/js/main.*.js获取最新JS文件URL(通过正则匹配HTML源码中的script标签); - 内容指纹比对:计算新JS文件的MD5值,与昨日快照比对;
- AST结构差异告警:若MD5变化,自动运行AST解析脚本,提取
generateSign函数体AST节点数、CallExpression数量、字符串字面量集合; - 阈值触发通知:当节点数变化>15% 或
CryptoJS调用路径变更,企业微信机器人推送告警,并附带差异报告链接。
这套机制上线后,成功在3次算法升级前捕获变更:第一次是新增了navigator.hardwareConcurrency校验,第二次是盐值中加入screen.width * screen.height,第三次是CryptoJS替换为自研ZCrypto库。每次都在变更生效前完成Python逻辑适配,零业务中断。
4.2 环境指纹模拟:为什么你的签名永远“差一点”?
即使签名算法100%还原,仍可能遇到{"code":403,"msg":"environment check failed"}。Z平台在签名生成前,会执行一段环境检测JS:
function checkEnvironment() { if (!window.chrome || !window.chrome.runtime) return false; if (navigator.plugins.length < 3) return false; if (navigator.webdriver === true) return false; if (window.outerWidth * window.outerHeight < 1024 * 768) return false; return true; }这些检查不参与签名计算,但会阻止generateSign执行。解决方案不是绕过检测(那会触发更高级别风控),而是在Python端预校验请求合法性:
def validate_environment(): """模拟浏览器环境约束,确保请求参数符合平台预期""" # Z平台要求:分辨率不低于1024x768 if not hasattr(validate_environment, 'resolution_checked'): # 实际项目中可从配置中心读取 validate_environment.resolution_checked = True return True # 插件数量:Requests无plugins概念,但可通过headers模拟 # 我们在Session headers中固定添加: # "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" # "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" # 这已足够通过基础检测 return True # 在generate_sign前调用 if not validate_environment(): raise RuntimeError("Environment validation failed")经验心得:不要试图在Python里模拟
navigator.plugins,那是无底洞。Z平台真正校验的是“请求是否来自合规浏览器”,而合规浏览器的headers特征(Accept、Accept-Language、Sec-Fetch-*系列头)比插件列表更重要。我们只需保证headers组合与Chrome 120+真实请求一致即可。
4.3 并发压测实录:单机QPS从12到217的优化路径
签名生成本身是CPU密集型操作,Python GIL会成为瓶颈。初始版本单线程QPS仅12,远低于Z平台限流阈值(50 QPS)。优化分三层:
第一层:算法级优化
- 将
get_random_string中random.choices替换为secrets.token_urlsafe(6)(secrets模块专为密码学安全设计,且底层用C实现,快3倍); hashlib.sha256已是最优,但json.dumps的sort_keys=True可缓存键名列表,避免重复排序。
第二层:进程级优化
改用concurrent.futures.ProcessPoolExecutor,预启动4个进程(匹配CPU核心数):
from concurrent.futures import ProcessPoolExecutor import multiprocessing # 全局进程池,避免反复创建销毁 _executor = ProcessPoolExecutor(max_workers=multiprocessing.cpu_count()) def async_generate_sign(path, data_dict): return _executor.submit(generate_sign, path, data_dict).result()第三层:连接复用与批处理
Z平台允许/api/v1/job/search接口一次请求多个城市,我们改造search_jobs为批量接口:
def search_jobs_batch(self, cities, page=1): path = "/api/v1/job/search/batch" data_dict = {"cities": cities, "page": page} # ... 签名与请求逻辑最终单机压测结果:
- 单线程:12 QPS
- 多进程(4核):89 QPS
- 批量请求(10城/次)+多进程:217 QPS
关键提醒:Z平台对
X-Timestamp有严格窗口期(±300秒),批量请求必须保证所有子请求使用同一timestamp,否则部分请求会因时间戳超期被拒。这是批量优化的前提条件。
5. 超越签名:构建可持续演进的JS逆向工作流
5.1 工具链固化:从“手写脚本”到“可交付制品”
我把整个Z平台逆向成果打包为zplatform-signerPyPI包,结构如下:
zplatform-signer/ ├── __init__.py # 暴露generate_sign等核心函数 ├── ast_parser.py # 通用AST解析器,支持指定函数名提取 ├── js_runtime.py # 模拟基础JS运行时(Math, Date, Base64) ├── signature.py # 主签名逻辑,含版本管理 ├── monitor/ # 监控模块,含MD5比对与AST差异分析 └── tests/ # 完整测试用例,含JS源码快照与期望输出关键设计点:
signature.py中generate_sign函数带version参数,默认version="2024_q2",当Z平台升级,只需新增version="2024_q3"分支,旧版本逻辑保留;tests/目录存放历史JS文件快照(main_v2024_q2.js)和对应签名测试用例(test_v2024_q2.py),CI流程每次PR自动运行全量测试;ast_parser.py导出extract_function_ast(js_code: str, func_name: str),供团队其他成员快速分析新JS文件。
这不再是“某个人的爬虫脚本”,而是可版本化、可测试、可协作的工程制品。
5.2 团队知识沉淀:建立JS逆向Checklist
我整理了一份《JS逆向四象限排查清单》,作为新人入职必读文档:
| 排查维度 | 关键问题 | 验证方法 | 常见陷阱 |
|---|---|---|---|
| 执行环境 | 是否依赖DOM/BOM? | 在Node REPL中执行函数,观察报错 | document.getElementById→ 必须剥离 |
| 加密库 | 使用CryptoJS还是Web Crypto API? | 搜索CryptoJS、window.crypto.subtle | Web Crypto需await,Python需异步重写 |
| 动态参数 | 盐值/时间戳/随机串是否实时生成? | 断点查看变量值变化频率 | Date.now()每毫秒变,Math.random()每次调用变 |
| 混淆强度 | 是否含WebAssembly或eval动态执行? | 查看Network中.wasm文件或eval(字样 | WASM需专用反编译,eval需AST动态解析 |
这份清单让新人30分钟内就能判断一个新目标网站的逆向难度,避免盲目投入。
5.3 终极思考:为什么“破解签名”只是开始,而非终点?
写完Z平台的签名逻辑,我花了一周时间做压力测试,又花三天做监控告警,最后两天重构为PyPI包。但最耗神的,是思考一个问题:当Z平台下季度把SHA256换成SM3国密算法,或者把盐值生成逻辑迁移到WebAssembly模块里,我们该怎么办?
答案不是“学SM3”或“研究WABT反编译”,而是建立防御性逆向思维:
- 所有JS逻辑必须有单元测试覆盖,输入输出可验证;
- 所有动态参数(时间戳、随机串)必须有独立生成函数,便于替换;
- 所有加密操作必须抽象为
HashAlgorithm接口,SHA256只是其实现之一; - 所有环境检测必须可开关,生产环境开启,开发环境关闭。
真正的“防爬进阶”,不是比谁更快破解某个网站,而是构建一套可对抗持续演进的防护体系的方法论。你今天为Z平台写的generate_sign,明天可以无缝迁移到用相同模式的“Y电商平台”;你今天写的AST解析器,下周就能用来分析“X地图服务”的坐标加密逻辑。
这就像学游泳,重点不是记住某个泳姿的动作分解,而是理解水的浮力、阻力、呼吸节奏——当你掌握了这些底层原理,面对任何水域,你都能找到自己的游法。
我在实际项目中发现,团队里最高效的逆向工程师,往往不是JS语法最熟的那个,而是最擅长把复杂问题拆解为可测试、可替换、可监控的原子单元的人。他们写的代码,三个月后还能准确告诉你:“这里为什么用secrets.token_urlsafe而不是random.choices”,“这个X-Timestamp窗口期为什么设为300秒而不是600秒”。
这才是“Python防爬进阶”的终极形态:不是技术的堆砌,而是工程能力的沉淀。
