当前位置: 首页 > news >正文

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是个典型的数组混淆对象,c3c4是索引。我们在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语法兼容性更好)。核心思路是:

  1. 将混淆JS文件加载为字符串;
  2. 解析为AST节点树;
  3. 遍历所有FunctionDeclaration节点,提取函数名和参数;
  4. 对每个函数体,递归查找CallExpression节点,筛选出含CryptoJSSHA256字样的调用;
  5. 向上追溯该调用的所有Literal(字面量)和Identifier(标识符)父节点,还原字符串拼接逻辑。

实操中,我写了一个AST遍历脚本,3分钟内定位到generateSign的真实AST路径:Program → FunctionDeclaration → BlockStatement → ExpressionStatement → CallExpression → MemberExpression → Identifier,并自动提取出所有参与拼接的字符串字面量。这才是可复现、可版本化管理的分析方式——而不是靠记忆某个断点位置。

3. 签名算法逆向四步法:从JS函数到Python可执行逻辑

3.1 第一步:剥离运行时依赖——为什么不能直接用PyExecJS?

很多教程推荐PyExecJSNodeJS子进程调用,理由是“省事”。但我在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中这两个参数在传入前已被处理:

  • _0x1c2dencodeURIComponent编码过;
  • _0x2e3fJSON.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())

这个设计确保了signtimestampX-SignatureX-Timestamp四者严格同步,避免因时间差或随机串不一致导致的签名失败。

4. 稳定性攻坚:应对JS代码更新、环境指纹与并发压测

4.1 版本监控机制:如何提前72小时感知签名算法变更?

Z平台JS文件每天凌晨自动发布新版本,文件名中的hash值改变。如果等到线上报错才发现,意味着至少2小时业务中断。我的方案是建立轻量级监控服务:

  1. 每日定时任务:用curl -I https://z-platform.com/static/js/main.*.js获取最新JS文件URL(通过正则匹配HTML源码中的script标签);
  2. 内容指纹比对:计算新JS文件的MD5值,与昨日快照比对;
  3. AST结构差异告警:若MD5变化,自动运行AST解析脚本,提取generateSign函数体AST节点数、CallExpression数量、字符串字面量集合;
  4. 阈值触发通知:当节点数变化>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_stringrandom.choices替换为secrets.token_urlsafe(6)secrets模块专为密码学安全设计,且底层用C实现,快3倍);
  • hashlib.sha256已是最优,但json.dumpssort_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.pygenerate_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?搜索CryptoJSwindow.crypto.subtleWeb 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防爬进阶”的终极形态:不是技术的堆砌,而是工程能力的沉淀。

http://www.jsqmd.com/news/882482/

相关文章:

  • 如何一键备份QQ空间所有历史说说?GetQzonehistory完整指南
  • Unity TextMeshPro中文方块问题根因与全链路排查指南
  • 第七史诗自动化脚本E7Helper:智能游戏助手的完整使用指南
  • 告别 TeamViewer:用这款免费卸载工具(如 Geek Uninstaller)一键清理所有痕迹,附手动检查清单
  • OBS多平台直播插件完全指南:如何一键推流到多个平台
  • 反爬检测机制:构建可感知、可量化、可干预的实时行为风控体系
  • 别再死磕SRanipaRuntime了!用Unity 2021.3 + OpenXR插件搞定Vive Pro Eye眼动数据采集(附避坑指南)
  • 2026年丝路新程 C++编程(小学组4-6年级)模拟卷(三)有答案
  • Windows驱动清理神器:Driver Store Explorer 深度解析与实用指南
  • 2026杭州GEO优化公司测评指南:五家源头厂商横向对比 - 品牌报告
  • 2026年星火征途 Python编程(小学组4-6年级)模拟卷(二)答案
  • 富士施乐SC2022扫描功能时有时无?别急着重装系统,先检查这个被忽略的Windows服务
  • 用Python复现SSVEP脑电识别经典算法:手把手教你实现CCA(附GitHub代码)
  • 告别Legacy Text!手把手教你用DoTween为Unity的TextMeshPro实现丝滑打字效果
  • [智能体-48]:MCP 协议详解:万物皆可接入,封装服务即可大模型自然语言控制
  • 原神帧率解锁器完整指南:突破60FPS限制,享受极致流畅游戏体验
  • 【题单】海亮
  • Scroll Reverser终极指南:告别Mac滚动方向混乱,为每个设备定制专属体验
  • 验证码中文乱码全链路排查:从JVM编码到字体渲染
  • 移动端H5爬虫:绕过APP限制+破解H5接口,数据采集新思路
  • RustDesk自建服务器防白嫖实战:ID准入控制与密钥安全加固
  • Unity与Android Studio协同开发实战指南
  • PINNSR-DA框架:从噪声数据中自动发现颗粒材料本构方程
  • 如何快速解决视频字幕不同步问题:video-subtitle-extractor终极指南
  • 如何让Windows 11真正“吃上“安卓应用?探索WSA的跨平台融合之路
  • AIMS-PAX:基于主动学习的并行化机器学习力场高效构建指南
  • Unity与Android Studio联合开发:AAR集成与双向调用实战指南
  • 逆向工程能力成长路线图:Windows内核、安卓安全与游戏协议实战
  • 探索 IwaraDownloadTool:从手动下载到智能嗅探的实践路径
  • Unity UI适配终极指南:CanvasScaler原理与SafeArea实战