资讯动态

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

发布时间:2026/10/15 0:18:38 来源:尧图企业网站定制
简介这份资源聚焦知乎评论爬取中的 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, encodingutf-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?limit20offset0 d_c0_value salt headers build_headers(raw, your_cookie_here) resp requests.get(https://目标站点/api/v4/comments, headersheaders, 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_outputTrue, textTrue ) 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(maxsize1) def _get_ctx(): with open(signer/encrypt.js, r, encodingutf-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常量每次站点改版导致签名失效时更新这个版本号并记录改动点方便回溯。从那以后我每次接入新的签名接口都会先写一个最小验证脚本确认「固定输入 → 固定输出」跑通再往业务代码里集成这样出问题能第一时间定位是签名层还是请求层。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑