ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python爬虫逆向:x-zse-96签名算法分析与复现

Python爬虫逆向:x-zse-96签名算法分析与复现

简介:这份资源聚焦知乎评论爬取中的 x-zse-96 参数逆向分析,面向具备一定 JavaScript 与网络请求基础、希望突破知乎反爬限制获取评论数据的爬虫开发者,可用于市场调研、舆情分析与学术研究等数据采集场景。压缩包共 2 个文件,均为 js 脚本,整体约 12KB,分别承担参数生成逻辑与运行环境模拟等用途,体积轻量、便于直接阅读与调试。目前已有 527 人学习下载。内容围绕 x-zse-96 参数的形成机制、数据包交互过程与服务器校验方式展开,帮助读者理解加密参数如何生成、如何模拟正常用户行为以规避反爬检测,并附有合法合规获取数据的思路框架。对于需要深入逆向知乎反爬机制、掌握参数还原与请求构造方法的开发者,这份脚本可作为对照分析与排错调试的实用参考。

1. 从一次抓取失败说起:x-zse-96 到底是什么

某天同事丢过来一个需求,说想批量拉取某问答社区某个问题下的全部评论做舆情分析,脚本跑第一页就返回了403,抓包一看请求头里多了一个x-zse-96字段,值是一长串 Base64 风格的字符串。他试过直接复制浏览器里的值硬编码进去,结果几分钟后就失效了。这个场景几乎每个做爬虫的人都遇到过——不是接口拿不到,而是签名参数算不对。

这份资源就是围绕这个签名参数展开的逆向分析笔记,核心解决三件事:搞清x-zse-96的生成链路、用 Python 复现出可用的签名函数、把评论接口的完整请求流程跑通。它适合已经会写基础 requests 请求、但卡在加密参数上的爬虫从业者,也适合想系统学一遍 JS 逆向流程的新手。需要提前说明的是,这类参数属于平台的风控手段,本文只讨论技术原理与本地复现,实际使用时请控制频率、遵守目标站点的 robots 协议,不要用于商业采集或高频压测。

整份资源的技术栈是 Python + JavaScript 混合:Python 负责请求调度和数据落库,JS 负责还原签名逻辑,中间靠PyExecJS或Node.js子进程打通。下面按「先看懂 → 再复现 → 再排错」的顺序拆开讲。

2. 拆解 x-zse-96:从请求链路到加密入口定位

在动手写代码之前,得先弄明白这个参数在整条请求链路里扮演什么角色。很多人一上来就搜x-zse-96关键字,结果在一堆混淆代码里迷路,就是因为跳过了链路梳理这一步。

2.1 请求链路与参数构成

打开浏览器开发者工具的 Network 面板,筛选comments相关的 XHR 请求,能看到请求头里除了常规的cookie、user-agent,还有三个关键字段:x-zse-93、x-zse-96和x-zst-81。其中x-zse-93是一个固定版本的标识串,x-zse-96才是真正的动态签名,x-zst-81在部分接口里出现,属于另一套校验。

把请求拆开看,x-zse-96的原始串通常由四段拼接而成,用加号连接:

x-zse-93的值 + 请求路径与参数 + cookie中的d_c0 + 请求体中的固定盐值

这个拼接顺序是逆向的关键。不同接口的拼接内容会有细微差别,比如列表接口和详情接口的路径部分不同,但整体结构一致。我一般会先在浏览器里打断点,把拼接前的原始串打印出来,确认每一段到底来自哪里,再去反推加密函数。

提示:d_c0是 cookie 里的一个设备标识,如果请求时不带这个 cookie,签名算出来也是错的。很多人只盯着加密函数,忽略了 cookie 的完整性。

2.2 定位加密函数入口

定位加密入口的常规做法是 XHR 断点 + 调用栈回溯。在 Sources 面板里对XMLHttpRequest.send下断点,触发一次评论加载,断住之后看 Call Stack,一层层往上找,通常能在某个interceptor或request封装函数里看到x-zse-96被赋值的那一行。

赋值语句长这样:

config.headers['x-zse-96'] = encrypt(params);

顺着encrypt这个函数名点进去,就能看到真正的加密实现。如果代码被混淆过,函数名可能是_0x4a2b这种,这时候不要慌,看它内部调用了哪些原生方法——出现CryptoJS、MD5、btoa、charCodeAt这些,基本就能判断加密类型。

我一般会把整个加密函数连同它依赖的工具函数一起复制出来,放到一个单独的 JS 文件里,用 Node 跑一遍,输入固定的测试串,看输出是否和浏览器里一致。这一步叫「本地验证」,能省掉后面大量调试时间。

2.3 加密算法类型判断

从实际逆向结果看,x-zse-96的生成一般分两步:先对拼接串做一次 MD5,拿到 32 位十六进制摘要;再把摘要和某个固定盐值拼接,做一次自定义的字符变换,最后 Base64 编码。这个「MD5 + 自定义变换 + Base64」的组合在同类站点里很常见。

判断算法类型有个笨但有效的办法:把拼接串原样丢进在线 MD5 工具,看结果是否出现在最终签名的中间部分。如果出现了,说明第一步就是标准 MD5;如果没出现,可能是加了盐或者用了变种。我遇到过几次是 MD5 之后又做了一次异或,这种就得靠读代码确认。

参数名来源是否动态作用
x-zse-93固定版本串否标识加密版本
x-zse-96加密函数输出是请求签名校验
d_c0cookie是设备标识,参与签名
x-zst-81部分接口是另一套校验,可暂不处理

把这张表对着抓包结果核一遍,基本就能确认自己有没有漏掉参与签名的字段。漏一个,签名就是错的,而且报错信息往往只是403,不会告诉你哪里错了,这就是最坑的地方。

3. 用 Python 复现签名:PyExecJS 与 Node 子进程两条路

搞清楚原理之后,下一步就是把它变成能跑的代码。Python 复现 JS 加密逻辑有两条主流路线:一是用PyExecJS直接在 Python 里执行 JS 代码,二是把 JS 逻辑封装成 Node 脚本,Python 通过子进程调用。两条路各有适用场景,下面分别给可抄的代码。

3.1 环境准备与依赖安装

先把环境搭好。Python 侧需要requests发请求、PyExecJS执行 JS,如果走 Node 路线还需要本机装 Node.js。安装命令:

pip install requests PyExecJS node -v # 确认 Node 已安装,建议 14 以上

PyExecJS本身不执行 JS,它需要一个运行时后端,常见的是Node.js或PyV8。现在PyV8基本没人用了,直接指定 Node 就行。装完之后跑一句测试:

import execjs print(execjs.get().name) # 输出 Node.js 说明后端正常

如果这里报RuntimeError: Could not find an available JavaScript runtime,说明 Node 没装好或者不在 PATH 里,先把 Node 环境修好再往下走。

3.2 还原加密函数并本地验证

把从浏览器里抠出来的加密函数整理成一个encrypt.js文件,只保留必要的工具函数,去掉无关的埋点和上报逻辑。整理完大概长这样:

// encrypt.js const CryptoJS = require('crypto-js'); function getSignature(rawStr) { // 第一步:标准 MD5 const md5Str = CryptoJS.MD5(rawStr).toString(); // 第二步:拼接固定盐值后做字符变换 const salt = 'fixed_salt_value'; const combined = md5Str + salt; let transformed = ''; for (let i = 0; i < combined.length; i++) { // 按位异或后再转字符,具体逻辑以实际逆向为准 transformed += String.fromCharCode(combined.charCodeAt(i) ^ 0x1a); } // 第三步:Base64 编码 return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(transformed)); } module.exports = { getSignature };

这段代码里三个关键点:rawStr是拼接好的原始串,salt是固定盐值(从逆向代码里读出来),异或的0x1a是变换参数。这三个值任何一个错了,输出都对不上。验证方法很简单,拿浏览器里同一次请求的原始串作为输入,跑出来的结果和抓包里的x-zse-96对比,一致就说明还原成功。

注意:CryptoJS需要单独安装,npm install crypto-js。如果逆向出来的代码用的是内置的btoa而不是 CryptoJS,那就不需要这个依赖,按实际情况调整。

3.3 Python 调用与请求封装

JS 验证通过后,用 Python 把它串起来。走PyExecJS的写法:

import execjs import requests # 加载 JS 文件 with open('encrypt.js', 'r', encoding='utf-8') as f: js_code = f.read() ctx = execjs.compile(js_code) def build_headers(raw_str, cookie): signature = ctx.call('getSignature', raw_str) headers = { 'cookie': cookie, 'x-zse-93': '101_3_3.0', 'x-zse-96': signature, 'user-agent': 'Mozilla/5.0 ...' } return headers # 拼接原始串:路径 + 参数 + d_c0 + 盐值 raw = '/api/v4/comments?limit=20&offset=0' + 'd_c0_value' + 'salt' headers = build_headers(raw, 'your_cookie_here') resp = requests.get('https://目标站点/api/v4/comments', headers=headers, params={'limit': 20, 'offset': 0}) print(resp.status_code, resp.text[:200])

这里ctx.call的第一个参数是 JS 里的函数名,后面跟参数。raw的拼接顺序必须和逆向时确认的一致,路径部分要包含 query string。如果返回403,先检查raw拼对没有,再检查 cookie 里的d_c0是否和签名时用的一致。

走 Node 子进程的写法适合 JS 依赖比较多、PyExecJS加载慢的场景:

import subprocess import json def get_signature_via_node(raw_str): result = subprocess.run( ['node', 'sign.js', raw_str], capture_output=True, text=True ) return result.stdout.strip()

对应的sign.js里读process.argv[2]作为输入,输出签名。这种方式每次调用都要起一个 Node 进程,频率高的时候会有性能损耗,可以改成常驻服务或者用PyExecJS缓存编译结果。

3.4 评论接口分页与数据落库

签名跑通之后,分页就是常规操作了。评论接口一般用offset和limit控制翻页,offset每次递增limit的值。落库用sqlite3或pymysql都行,字段建议保留评论 ID、内容、作者、点赞数、创建时间。

import sqlite3 conn = sqlite3.connect('comments.db') conn.execute('''CREATE TABLE IF NOT EXISTS comments ( id TEXT PRIMARY KEY, content TEXT, author TEXT, like_count INTEGER, created_time TEXT)''') def save_comment(item): conn.execute('INSERT OR IGNORE INTO comments VALUES (?,?,?,?,?)', (item['id'], item['content'], item['author']['name'], item['like_count'], item['created_time'])) conn.commit()

分页时注意两点:一是每页请求都要重新算签名,因为offset变了,原始串就变了;二是翻到后面可能触发风控,返回空数据或验证码,这时候要停下来,不要硬刚。我一般会在每页之间加 1 到 2 秒随机延时,降低被限流的概率。

4. 避坑与排查:签名算对了还是 403 的几种情况

签名逻辑还原正确,不代表请求就能通。下面这几条是我在实际调试里踩过的坑,按「现象 → 原因 → 解决」整理,遇到问题可以逐条对照。

现象一:本地算出的签名和浏览器一致,但请求仍返回 403。原因通常是 cookie 不完整或过期。d_c0参与签名,如果请求时带的d_c0和签名时用的不是同一个,服务端校验就会失败。解决方法是把浏览器里完整的 cookie 字符串复制过来,确认d_c0字段存在且未被截断。

现象二:第一页能拿到数据,翻到第二页就 403。原因是分页参数变了但签名没重算,或者重算时原始串里的offset没同步更新。检查raw拼接时用的参数是否和实际请求的params一致,两者必须严格对应。

现象三:PyExecJS 报编码错误,中文参数传入后签名不对。原因是 JS 和 Python 的字符串编码处理不一致。Python 侧传参前统一用utf-8编码,JS 侧读取时确认没有做额外的转义。如果原始串里有中文,建议先encodeURIComponent再拼接。

现象四:脚本跑一段时间后全部 403,换 cookie 也没用。这是触发了频率风控,和签名无关。解决方法是降低请求频率、加随机延时、必要时更换请求头里的user-agent。不要试图用多线程硬刷,只会加速封禁。

现象五:Node 子进程调用返回空字符串。原因是sign.js里没有正确处理process.argv,或者 Node 执行时报错但被capture_output吞掉了。调试时先把stderr打印出来,确认 Node 侧没有抛异常。

提示:排查签名问题最有效的手段是「固定输入对比输出」。拿同一次请求的原始串,分别在浏览器和本地跑,逐字符对比结果,差异出现在哪一位,就往哪一段逻辑查。

5. 进阶技巧:把签名逻辑做成可复用模块

把签名逻辑散落在脚本里,改一次参数就要翻好几个文件,这是很多人后期维护的痛苦来源。我的习惯是把它抽成一个独立的signer模块,对外只暴露一个sign(raw_str)接口,内部实现随便换,调用方不用动。

具体做法是建一个signer/目录,里面放encrypt.js、signer.py和一份config.py。config.py里存盐值、版本号这些容易变的常量,signer.py负责加载 JS 并缓存编译结果。这样换盐值只需要改一个地方。

# signer/signer.py import execjs from functools import lru_cache from .config import SALT, ZSE93 @lru_cache(maxsize=1) def _get_ctx(): with open('signer/encrypt.js', 'r', encoding='utf-8') as f: return execjs.compile(f.read()) def sign(raw_str): return _get_ctx().call('getSignature', raw_str) def build_raw(path, params, d_c0): query = '&'.join(f'{k}={v}' for k, v in params.items()) return f'{path}?{query}{d_c0}{SALT}'

lru_cache保证 JS 只编译一次,后续调用直接复用,性能比每次execjs.compile快很多。build_raw把拼接逻辑也收进来,调用方只需要传路径、参数和d_c0。

验证模块是否可用,写一个简单的自测:

from signer.signer import sign, build_raw raw = build_raw('/api/v4/comments', {'limit': 20, 'offset': 0}, 'test_d_c0') print(sign(raw))

拿这个输出和浏览器里同参数的签名对比,一致就说明模块封装没问题。之后不管换接口还是换参数,只改build_raw的入参就行。

还有一个容易被忽略的点:签名逻辑会随站点更新而变化。我一般会在模块里留一个VERSION常量,每次站点改版导致签名失效时,更新这个版本号并记录改动点,方便回溯。从那以后我每次接入新的签名接口,都会先写一个最小验证脚本,确认「固定输入 → 固定输出」跑通,再往业务代码里集成,这样出问题能第一时间定位是签名层还是请求层。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进