简介针对知乎评论采集中的核心反爬障碍x-zse-96参数面向具备网络请求与JS调试基础的爬虫开发者提供一套可直接运行的逆向分析代码包。内容围绕参数加密生成、服务端校验逻辑与正常用户行为模拟三条主线帮助读者理解知乎反爬原理并给出合规采集评论数据的技术路径适用于市场调研、舆情分析、学术研究等场景。压缩包共2个JavaScript文件整包大小12KB结构精简。核心脚本与配套环境脚本配合可快速定位参数生成入口跟踪加密、签名、时间戳校验等关键环节适合直接用于逆向学习或二次开发。目前已有527人学习下载这套代码的价值在于将零散逆向思路整理为清晰的文件结构与简明分析流程能缩短手动排查x-zse-96参数的周期同时强化合规意识为后续扩展其他反爬参数逆向提供方法论参考。1. 知乎评论爬取x-zse-96 签名是怎么卡住你的当你准备抓知乎某个高赞回答下的用户评论直接构造请求到https://www.zhihu.com/api/v4/comment_v5/answers/{answer_id}时接口很大概率直接返回 403。浏览器里一切正常换到 Python requests 里就挂差别几乎只剩一个请求头x-zse-96。这个参数是知乎自己拼出来的一次性签名每次请求都要实时生成复制浏览器里现成的值过去也会很快失效。这篇文章不是要在概念上解释为什么有反爬而是循着我实际逆向这条链路的顺序先抓包定位生成入口再拆解参数结构最后用 Python 复现签名并跑通评论爬取顺带把我在本地调试时连续踩过的几个坑写出来。适合正准备抓知乎评论、但对 JS 逆向还不太熟的人。2. 抓包定位 x-zse-96从请求头一路追到压缩 JS2.1 找到评论 APIx-zse-96 在哪个请求头里打开知乎一个问题页面F12 切到 Network刷新后往下滚动触发评论加载。很快能看到一条名字带comment_v5的 XHR 请求响应是人类可读的 JSON里面是data数组。点开这条请求的 Headers 面板在 Request Headers 里找x-zse-96你会发现它不在常规的Accept、User-Agent那一堆里而是紧挨着x-zse-93和x-zse-95。如果你用的是 Charles 抓包会看到同样的请求头但字符更长。我个人的习惯是先用 Chrome 的 Network 定位再到 Charles 里看完整链路因为知乎的很多接口还带 websocket 和静态资源直接用 Charles 过滤comment_v5更省事。这个技巧和 charles 使用教程里教的过滤方法差不多但这里要注意一个问题知乎启用了 HTTP/2Charles 默认解不了某些压缩流需要开启 SSL Proxying 并给www.zhihu.com加一条 SSL 代理记录。没有这一步你看到的x-zse-96可能是一堆乱码。看一下请求头你会发现x-zse-96大概长这样1.0_8f3a2b1c...xY3ZQ它其实是两部分拼起来的点号前是个固定的版本号1.0_点号后那串才是签名主体。签名主体看起来像 Base64但直接 Base64 解码后是段不可读的二进制这是因为知乎先把一个结构体做了某种序列化再加密最后才做 Base64。一直盯着字符串猜是没有结果的要从 JS 调用栈往上追。2.2 从请求头反查 JS找到生成 x-zse-96 的调用栈在 Network 里右键那条评论请求选 Copy - Copy as fetch粘贴到 Console 里改造成下面这段目的是给fetch的调用链添加一个断点const originalFetch window.fetch; window.fetch function (...args) { if (args[0] args[0].indexOf args[0].indexOf(comment_v5) ! -1) { debugger; } return originalFetch.apply(this, args); };刷新页面并再次触发评论加载代码会在fetch被调用之前停下来。这时候从右侧的 Call Stack 面板一层层往下翻你会在靠近栈顶的位置看到几个由压缩代码构成的函数名比如getXZSE96、_$_typeof这类容易被混淆的命名。这里最有效的一招是倒着看栈从最底部的原生调用开始逐层往上找第一个出现btoa、encodeURIComponent、replace这类标准 API 的函数往往就是真正做签名的函数。把那个函数记录下来看它在哪个 JS 文件、大概哪一行。我建议你不要直接在压缩文件里设置源码断点因为知乎的 JS 会动态加载文件内容可能随机换名断点位置也跟着变。相反用上面的debugger触发方式让浏览器暂停再从调用栈里读函数位置能减少走弯路。这也是为什么我把这节调到抓包里讲——很多人拿到x-zse-96就直接去搜字符串结果被一大段混淆代码吓住其实有了调用栈后续的分析就有方向了。2.3 格式化混淆代码把压缩 JS 变成可搜索的文本确定了生成签名所在的 JS 文件后在 Sources 面板里打开它。如果只有一行超长代码点击左下角的{}按钮格式化。格式化之后再按CtrlF搜索刚才调试栈里看到的函数名。如果函数名被混淆成单字母你就搜索x-zse-96这个硬编码字符串通常会有两处命中一处是setHeader(x-zse-96, ...)另一处是拼接签名的逻辑。从setHeader那行往前追你会看到一个类似getXzse96Value(e)的包装函数它先拿到一个时间戳和x-zse-93再传给底层的加密例程。搜索时注意一个小技巧知乎有时会把x-zse-96拆成字符串拼接比如x-zse- 96所以直接搜x-zse-96可能搜不到。我一般先搜x-zse-再配合括号补全。同理x-zse-93是一个长字符串可以在请求头里复制它然后在 JS 里搜索完整值如果搜不到去搜它的前 20 个字符大概率能找到。拿到格式化文件后不要急着从头读先搜索关键词把加密入口与出口的关联建立起来。实际上定位到函数后你会发现x-zse-96的算法核心其实是一段基于x-zse-93和时间戳的对称加密密钥固定藏在某段 JS 里。这一步的产物就是你知道哪个输入参数、哪个密钥、哪个混淆策略。接下来就可以正式进入逆向分析环节把结构拆开。3. 拆解 x-zse-96 的算法结构时间戳、随机数与加密串联3.1 签名由三部分组成版本号、随机值和校验串通过断点单步调试把x-zse-96的完整值打印出来拆开看是这样的1.0_8f3a2b1c9d4e5f6a7b8c9d0e1f2a3b4c_xY3ZQ以第一个点号分界左侧固定是1.0_右侧是一串 32 位的十六进制字符拼接一个长 Base64。具体长度在不同版本的知乎前端会有变化但结构不变段示例作用版本后缀1.0_协议版本时间戳字段前 8 位十六进制请求时间精确到秒随机盐值中间一段随机 hex打断同秒请求的重复校验值末尾的 Base64由x-zse-93、时间戳、盐值参与计算这张表是我在电脑前反复改参数验证出来的。注意第 2 行的“时间戳字段”这块很多人以为整个签名就是 md5 串但实际拆开后你会发现时间戳被加密后嵌在中间而不是明文拼接。你可以用同一个x-zse-93值连续刷新页面比对两次x-zse-96如果发现前十几位相同说明时间戳确实是主要变量如果完全不同说明还有随机盐。知乎的做法是两者都加避免同秒重放。3.2 核心加密函数从置换表到哈希链顺着压缩 JS 中的调用关系会来到一个叫做btoa的自定义封装那并不是原生btoa。它内部维护着一张 256 字节的置换表先把待签名字符串分成 64 字节一组的分块对每个分块做两次位移和异或然后把结果按字节映射到另一张字符表上最后才调用原生btoa做 Base64。我第一眼看这个流程以为是 AES但多次输入输出对照后确认它更像是一个定制的哈希链输入是x-zse-93的值、时间戳的十六进制、随机盐值三者拼接输出长度固定。为了验证这个判断我在 Node 里用 Python 的hashlib逐个比对发现直接拿md5(input)对不上拿sha256也对不上。后来在 JS 里用console.log打印每一轮中间的寄存器值发现中间有一个字节交换步骤等价于先对输入做一次自定义混淆再把混淆结果当作 key对原始串做md5。这个操作很像常规逆向分析教程里常见的“盐值哈希”只是知乎把盐藏在了那 256 字节置换表里。写一个最小 Node 脚本验证整体逻辑比直接上 Python 要快因为压缩 JS 是 ES5 语法几乎不需要依赖额外库就能跑// sign-model.js const crypto require(crypto); function generateZse96(xzse93, timestampHex, saltHex) { // 这是一个简化模型真实实现里还包含置换表展开与字节流翻转 const payload xzse93 timestampHex saltHex; const hash crypto.createHash(md5).update(payload, utf8).digest(hex); return 1.0_ hash encodeBase64Custom(hash); }这段代码只能帮你理解结构不能直接用因为真正的自定义置换表不是三行能写完的。实际逆向时我更推荐在 Chrome 里做 monkey patch在定位到的加密函数入口处把入参复制到全局变量在出口处把结果也存下来然后反复修改入参里的时间戳观察输出变化这样可以反推不同字段参与计算的位置。如果你只做评论爬取其实还有一个偷懒但可靠的做法直接调用知乎页面里的签名类让浏览器帮你生成签名这是下一章的内容。不过如果只是想搞懂算法上述的差分输入输出是必须做的。具体到单步调试我会在压缩 JS 的加密函数第一行加一个条件断点arguments[0] window.__XZSE93__。条件断点平时不生效但当真值传入时自动停下。然后看 watchers 里的输入值手动修改其中一个字符再继续执行对比输出差异。如果修改x-zse-93的最后一个字符输出变化巨大说明它被完整参与哈希如果输出不变可能该字段只做白名单校验。这种差分实验在逆向分析里非常重要它比读混淆代码更快地帮你找到一个哈希函数的自变量集合。x-zse-96在这一点上很典型时间戳是主输入x-zse-93是绑定输入随机盐是辅助输入缺少任何一个都能从返回值差异里观察出来。随机盐的产生知乎用的是浏览器内置的crypto.getRandomValues每次调用都不同。在 Python 里你不需要模拟它只需要保证最终签名与随机盐一并提交服务端会校验前两者的时间与绑定关系。但有一种情况要注意知乎服务端可能对同一随机盐的重复使用做了计数一旦你在短时间内用同一个盐生成了超过一定次数的请求后面的请求会直接提示“操作频繁”。所以我在 Python 里每次生成签名时盐值都同时取系统时钟和高精度随机数避免踩到这个隐式限制。3.3 输入绑定关系为什么换了 cookie 签名就失效在逆向过程中我多次遇到同一个x-zse-96在浏览器里能刷出正常数据但搬到 Python 里就 401。后来把浏览器请求头里的cookie、x-zse-93、x-zse-96三个值同时复制到 Python 才勉强通过。这说明x-zse-96不只是对时间做签名它和 cookie 里的会话状态有绑定。具体来说x-zse-93是 cookie 与用户代理拼出来的一段前缀每次登录或刷新页面后会变化而x-zse-96会把x-zse-93作为一种输入参与计算。如果你在 Python 脚本里只复制 cookie 而不复制x-zse-93即使x-zse-96是新鲜的也会因为输入不匹配而被拒绝。这直接决定了爬虫的设计要么在同一个会话里同时维护cookie、x-zse-93、x-zse-96三个值要么脱离请求会话直接唤起一个真实的浏览器环境来生成签名。很多从 playwright 爬取抖音评论区转过来的同学都会有同感抖音那边重点处理的是X-Argus知乎这边则更依赖x-zse-96两者都不能只抓包复制。理解了输入绑定关系你就知道为什么不能把一个抓到现成的x-zse-96当长期凭证来用。4. Python 复现签名不跑 JS 也能生成有效 x-zse-964.1 把 JS 算法翻译成 Python核心函数对照如果你完成了上一章的差分实验手里已经有输入输出样本就可以把 JS 逻辑手工翻译成 Python。这里以一个简化模型为例展示翻译时怎么处理输入拼接和哈希# zse96_signer.py import hashlib import time import random import base64 # 从压缩 JS 中提取的置换表长度为 256 字节 SBOX [...] # 需要自己从 JS 里复制并转换为整数列表 def custom_hash(data: bytes) - bytes: # 第一次置换 t bytearray(len(data)) for i in range(len(data)): t[i] SBOX[data[i] ^ (i 0xff)] # 第二次异或 for i in range(1, len(t)): t[i] ^ t[i - 1] # 再经过 md5 做进一步压缩 return hashlib.md5(bytes(t)).digest() def generate_sign(xzse93: str) - str: ts_hex format(int(time.time()), x) salt format(random.getrandbits(32), 08x) raw (xzse93 ts_hex salt).encode(utf-8) digest custom_hash(raw) b64 base64.b64encode(digest).decode(utf-8) return f1.0_{ts_hex}_{salt}_{b64}真正翻译时你不需要完全复刻整个 JS 运行环境。先用上一章拿到的输入输出对做回归准备 10 组x-zse-93和x-zse-96样本写一个测试函数每生成一次签名就和已知样本的开头比对。如果前几位能和样本中的时间戳十六进制对应上说明翻译方向正确。这里最花时间的是提取SBOX格式化 JS 后搜索new Uint8Array(256)或类似数组字面量复制时要注意 JS 的数字格式可能带0x前缀在 Python 里要转成十进制。一个比较省事的做法是直接在 Node 里把SBOX转成 JSON再粘贴进 Python 文件。4.2 用 requests.Session 维护三件套cookie、x-zse-93、x-zse-96签名生成之后接着要把它和请求绑定在一起。知乎评论接口最少需要三个请求头cookie、x-zse-93、x-zse-96。我习惯在requests.Session里设置默认头因为评论爬取是分页的每一页都需要换新的x-zse-96但cookie和x-zse-93可以在同一个会话里保持不变。注意 Session 会自动管理部分 cookie但x-zse-93和x-zse-96不会必须在每次请求前显式更新。# crawl_zhihu_comments.py import requests import json session requests.Session() session.headers.update({ User-Agent: Mozilla/5.0 ... Chrome/120.0 ..., Cookie: 你的cookie, x-zse-93: 从浏览器复制或由登录态生成, Referer: https://www.zhihu.com/question/123456789 }) answer_id 123456789 url fhttps://www.zhihu.com/api/v4/comment_v5/answers/{answer_id} params { common_text: true, limit: 20, offset: 0, order: normal, status: open, } def fetch_comments(answer_id, offset): params[offset] str(offset) # 每次重新生成签名盐值和时间戳都变化 session.headers[x-zse-96] generate_sign(session.headers[x-zse-93]) resp session.get(url, paramsparams) if resp.status_code ! 200: print(f请求失败: {resp.status_code}) print(resp.text[:500]) return None return resp.json()这段代码里有几个参数值得说明common_texttrue会让返回内容里带上一段展示用的纯文本方便快速检查数据ordernormal是默认排序想按热度排序可以改成by_clickedlimit最大允许 20传 100 也不会被采纳后端会强制重置。offset是翻页游标但注意评论 API 的分页逻辑不是简单的 20 条一页很多回答下会出现跨页重复后面在爬取策略里再说。4.3 验证签名正确性怎么判断返回的不是缓存x-zse-96生成对了评论 API 会返回 200并且data数组里是真实评论。但我遇到过一种特殊情况返回 200但内容全是同一条评论的重复或者干脆是上一页的缓存。这是知乎对频繁请求的一种软限制它不会直接拒绝而是让你拿到过期的分页。此时你需要检查响应头里的x-xss-protection和content-encoding如果内容被缓存响应体里的cursor字段会停在你上次请求的位置不前进。我的验证做法是写一个简单的断言如果data不为空且第一条评论的id不等于上次请求的第一条评论id认为本次签名与请求有效否则重试新签名。这比单纯看状态码可靠。另外x-zse-96的签名生成本身很快大概在毫秒级瓶颈主要是网络。你可以用time.perf_counter给整个请求流程打点如果你发现从生成签名到收到响应的耗时超过 3 秒大概率不是签名问题而是被限流了。这时候不要继续调高重试次数应该先停下来检查请求密度。4.4 没有登录态怎么办手动获取 x-zse-93 的两种方式严格来说x-zse-93应该是服务端通过某个接口下发的但评论接口本身不返回它。最简单的方式是在浏览器登录知乎后从任意 API 请求的 Request Headers 里把x-zse-93复制出来。但这个值有时效性通常几小时到一天后会失效。另一个常见做法是调用登录 API 后从响应 cookie 或 JS 全局变量中解析但这一步的算法同样不公开。我一般只做第一种用同一个浏览器账号手动抓一份x-zse-93配合Cookie一起放进 Python 脚本。对评论爬取这种低频任务来说手动更新一次能跑半天到一天比每次都逆向登录接口高效很多。如果需要更高频使用可以把浏览器里的签名生成挂到本地服务上用 playwright 打开知乎页面在页面里执行fetch拿到接口响应同时用page.evaluate调用页面里的签名函数生成签名再让 Python 通过本地调用拿到结果。这是下一章会提到的思路也是很多生产级爬虫在实际落地的做法。5. 避坑指南x-zse-96 过期、风控与本地调试的常见问题下面这些现象和数据都是我在本地调试评论爬虫时真实遇到的按出现频率从高到低列出来每条都给出我看完日志后的判断和最终解决方式。5.1 签名校验失败返回 403 时先看系统时间现象代码昨天还能跑今天所有请求都 403。检查session.headers里的x-zse-96看起来也是新鲜生成的。原因本地系统时间漂移尤其虚拟机或云主机常用时间同步机制如果时间比真实时间慢几十秒知乎服务端计算时间窗时会把签名当作过期。解决先用浏览器打开一个知乎页面把请求里x-zse-96的前 8 位十六进制转成十进制再对比本地当前 Unix 时间戳。误差超过 30 秒就执行系统时间同步然后在 Python 里直接使用int(time.time())重新生成签名。顺手在签名函数里加一行日志输出便于出问题时对比。5.2 同签名复用被拒不要缓存签名文件重复请求现象把签名保存到文本文件里打算断点续爬时复用结果第一次成功第二次 401。原因x-zse-96与时间戳强绑定服务端会记录最近使用过的时间戳和随机盐如果同一时间戳再次出现直接拒绝。解决不要缓存签名每次请求前生成新的如果要断点续爬只缓存offset、cookie和x-zse-93签名必须现用现算。这一点很多教程没有强调我在本地反复翻车后才意识到把签名当作普通字符串做持久化是徒劳的。5.3 评论返回空列表注意 offset 与 answer_id 的来源现象接口返回 200但data是[]且paging.is_end也是false。原因评论 API 的offset并不是简单从 0 递增部分回答下的评论是折叠状态需要用statusopen过滤。还有一种常见原因是把answer_id传成了问题 IDquestion_id导致找不到任何评论。解决先用浏览器在目标评论区找到真实的answer_id它在回答所在的div属性里或者在回答详情接口的reply_id中分页游标优先使用响应里的paging.next参数而不是手工累计。参数传错时返回的 200 其实是一份空数据很容易误导人。5.4 混淆代码更新前端发版后如何重新定位现象做了几个月的脚本突然全部失败浏览器里请求头还能看到x-zse-96但值结构已经变了旧的 Python 签名函数怎么算都差 8 位。原因知乎前端做了一次重构把旧的字符串拼接改成动态变量或者把加密算法里的置换表换掉了。解决回到第 2 章的流程重新用debugger断点抓调用栈不要试图在当前格式化文件里搜旧函数名。另外留意页面里是否有window.__INITIAL_STATE__包含新的签名相关配置有的话直接读取它作为动态密钥就算算法微调也能继续跑。前端逆向分析这件事最忌讳的是守着旧的成功样本不肯更新。5.5 本地直接用 execjs 跑 JS 出错注意浏览器环境缺失现象用execjs加载知乎的压缩 JS调用签名函数时报错ReferenceError: window is not defined或navigator is not defined。原因知乎的签名逻辑依赖浏览器环境里的window.navigator、document等对象Node 的vm里没有这些对象。解决不要直接整个加载混淆 JS只把签名函数体提取出来前面补一段最小模拟环境或者更省事用 playwright 的page.evaluate在真实浏览器上下文里调用签名函数再把结果返回给 Python。对新手来说后者比修补execjs环境要稳得多能省掉大量 DOM 兼容性的报错时间。6. 增量爬取与签名缓存把 x-zse-96 复用做到极致6.1 把签名生成做成独立服务生产环境里我不会让每个 worker 都自己算签名因为一次算错会拖累整个任务。常见做法是把“浏览器算签名”这一步封装成一个本地 HTTP 服务用 playwright 打开一个空白知乎页面接收{x_zse_93, url}返回x_zse_96。Python 爬虫只负责发请求签名通过本地接口动态获取。这样就算知乎改算法也只改签名服务爬虫主流程不用动。启动脚本可以放在独立进程里用nohup或系统服务管理。这种方式在 playwright 爬取抖音评论区时也适用把各平台的签名逻辑统一放进一个微服务里遇到风控时只换签名实现。6.2 通过评论 id 去重实现增量爬取评论 API 返回的每条评论都有一个id字段全局唯一。我会在每次抓取后把一组id存进seen集合用 Redis 的sadd或本地文件持久化。增量爬取时读到新评论先判断id是否已存在是则跳过否则写入数据库。这个逻辑不复杂但能很好避免 offset 分页带来的重复数据问题因为后端分页在评论较多时确实会重复返回之前的内容。6.3 验证签名正确性对比浏览器值与离线生成值最后分享一个我最常用的验证技巧在浏览器 Console 里先触发一次真实评论请求拿到这次请求的x-zse-96再用 Python 脚本同样生成一个。对比两个值的版本前缀和时间戳段如果版本号一致且时间戳相差在 5 秒以内就说明本地算法没有大的偏差。如果时间戳完全对不上优先怀疑 Python 里的time.time()取错了时区或者SBOX粘贴时发生了移位。之前我有一次就是因为复制 JS 数组时少了负号导致签名一直差一个字节。以上就是我对知乎评论爬取中x-zse-96逆向分析的个人流程。从一个 403 请求开始抓包定位格式化代码拆解算法到用 Python 复现签名并跑通分页评论我花了差不多一个周末。最大的教训是不要试图完全脱离浏览器环境去模拟一个复杂的签名服务用 playwright 把签名外包给真实页面反而更省心。希望这些踩坑记录能让你少走几步弯路特别是 5.2 和 5.4几乎每个做知乎评论爬取的朋友都会碰到。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?