简介:知乎评论爬取中的x-zse-96参数逆向分析,是爬虫开发者绕开知乎反爬机制、获取高质量评论数据的关键突破口。这份代码包面向中高级爬虫工程师与逆向分析爱好者,聚焦x-zse-96参数的生成逻辑与验证机制,帮助读者理解参数如何构造、请求如何签名,从而在合规框架内完成数据采集。压缩包体积仅12KB,包含2个JS文件,分别对应核心逆运算逻辑与配套环境变量脚本,结构非常精简,适合直接阅读和断点调试。目前已有527人学习下载。通过研读这套源码,读者可以掌握参数逆向的通用思路、加密算法定位技巧以及模拟正常用户请求的调试方法,还能了解如何规避常见反爬检测;这些技能可进一步应用于市场舆情、学术研究等领域的数据批量获取与分析工作。同时,资源也提示开发者在突破反爬限制时仍须遵守法律法规与平台规则,避免过度抓取或侵犯隐私。
1. 知乎评论爬取与 x-zse-96 逆向:先啃签名再看数据
知乎网页端看似普通,但真正动手爬取时会发现评论接口被 x-zse-96 这个动态签名参数卡住。x-zse-96 不是固定字符串,每次请求都由前端 JavaScript 根据请求头、时间戳和加密算法实时生成,直接请求接口只会收到 403 状态码。本文基于「知乎评论爬取 x-zse-96 参数逆向分析」资源,从加密链路拆解、环境复现到翻页抓取,给出一套可落地操作的完整流程。建议有 JavaScript 基础和基本抓包经验的读者直接按第 3 章的步骤做,新手也别慌,每一步我都会标出从哪里看结果、什么现象算正常。
2. x-zse-96 生成链路:从请求头到三段 Base64 拼接
2.1 先按 F12,把评论区接口的请求头全量摘出来
打开知乎任意问题详情页,按 F12 切到 Network 面板,过滤 XHR,向下滚动触发评论加载,找到形如https://www.zhihu.com/api/v4/comment_v5/answers/{id}/root_comment的请求。不要只看 URL,重点在 Request Headers 里的x-zse-96、x-zse-93、x-zse-81和cookie。这四个值是相互关联的,缺一个都会导致签名校验失败。
我拿到资源后第一件事就是把请求头完整复制出来,存成 JSON 格式,方便后续在脚本里回放。下面是一个真实的请求头结构:
{ "x-zse-93": "101_3_3.0", "x-zse-96": "2.0_...", "x-udid": "AQC...", "cookie": "xxx", "user-agent": "Mozilla/5.0 ...", "referer": "https://www.zhihu.com/question/xxx" }x-zse-93是协议版本标识,资源里拆出来是固定的101_3_3.0,我核对了几十个请求都没变过;x-zse-81则是在请求发起前由前端额外注入的一个环境值,它参与签名计算但不直接出现在最终请求里。第一次做的人最容易只复制x-zse-96就开跑,结果拿到 403,因为签名是基于整个请求上下文算出来的,单拎一个值是没用的。
2.2 定位加密函数:事件断点配合搜索大法
从 Chrome DevTools 切到 Sources 面板,对x-zse-96字符串全局搜索,会命中一段混淆过的 JavaScript。直接读混淆代码不现实,正确做法是在x-zse-96赋值处打一个事件断点,触发一次评论加载,让代码停在赋值语句上,然后一步步查看调用栈。
资源里已经标好了关键函数的位置,核心链路是这样的:
// 伪代码:真实代码经过混淆,但逻辑等价 function getXZse96(params, timestamp) { var env = vd.getEnv(); // 收集浏览器环境信息 var p = vd.getParams(params); // 规范化请求参数 var hash = sha1(env + p + timestamp); // 摘要计算 var base64 = btoa(hash); return "2.0_" + base64; // 三段拼接的最终签名 }这里vd.getEnv()返回的是浏览器环境指纹,包含 User-Agent、屏幕分辨率、时区等信息的编码;vd.getParams负责把请求体里的关键参数做排序和转义,保证同一请求在不同时间计算出的基础串是一致的。sha1这一步是资源里明确指出的一处摘要算法,最终把计算结果做 Base64 编码,再拼上2.0_前缀。
这里有个容易误解的地方:x-zse-96 不是简单的 "固定前缀 + 单一哈希",而是三段式结构。资源里把签名拆成了版本号_环境段_签名段,环境段来自getEnv(),签名段来自参数和时间戳的哈希。我刚开始以为只要复现哈希部分就够了,结果发现环境段缺失就会导致校验失败。
2.3 时间戳参与计算,同一个请求间隔十秒就失效
在断点调试时我做过一个实验:把同一个接口请求的x-zse-96单独提取出来,五秒后重放,返回 403;十秒后重放,还是 403。这说明签名里绑定了时间因子,而且窗口非常短。资源里的实现方式是把时间戳作为输入值传给vd.getParams,请求前后误差必须控制得很小。
所以脚本里不能缓存签名,必须每次请求前重新计算。我在 Python 侧的做法是:每次循环翻页时,先用 Node.js 子进程执行签名脚本,再把生成的x-zse-96放进请求头。虽然多了一次进程调用的开销,但稳定性和准确性远比省那几十毫秒重要。
3. 环境复现与 Node.js 签名脚本落地:把 vd 黑匣子跑起来
3.1 从浏览器中摘出 vd 对象,不能在纯 JSDOM 里直接跑
资源里的核心交付物是一个签名脚本目录,里面包含了处理过的 vd 对象和完整的加密入口函数。拿到后先不要直接node index.js,第一步是检查你的 Node.js 版本。这个脚本依赖了Buffer、crypto等基础模块,Node 14 以上基本没问题,但如果你的机器上是 Node 12,部分语法会报错。
推荐在项目目录下建一个vendor/文件夹,把资源里的 JS 文件全部放进去,然后写一个独立的计算脚本:
// run_sig.js const fs = require("fs"); const path = require("path"); const vm = require("vm"); // 加载加密核心脚本 const code = fs.readFileSync(path.join(__dirname, "vendor/sign.js"), "utf-8"); const sandbox = { console, Buffer, setTimeout, }; vm.createContext(sandbox); vm.runInContext(code, sandbox); // 导出计算函数 module.exports = function getXZse96(url, params) { return sandbox.calcXZse96(url, params); };这里用了vm.createContext来运行混淆脚本,避免脚本内部定义的全局变量污染我们自己的运行环境。calcXZse96是资源里封装好的入口函数,传入接口 URL 和参数对象,返回完整的x-zse-96值。如果脚本里用到window或document对象,需要再补一层 mock,资源里已经处理过,不需要额外引入 JSDOM 这种重量级依赖。
3.2 用 Python 串联:requests 发请求之前先拿到签名
签名脚本跑通后,就要调整抓取主流程。这里有一个我和资源作者一致推荐的架构:Python 做请求调度和数据解析,Node.js 只负责算签名。选择这种混编架构的原因很简单——Python 的 requests 库处理 cookie 和重定向非常方便,而加密脚本跑在 Node.js 里最顺,不用用 Python 去重写一遍算法,削减出错概率。
import requests import subprocess import json # 通过 Node 子进程生成 x-zse-96 签名 def get_sign(url, params): cmd = ["node", "run_sig.js", url, json.dumps(params)] result = subprocess.run(cmd, capture_output=True, text=True, check=True) return result.stdout.strip() # 构造请求头 def build_headers(url, params): sign = get_sign(url, params) return { "x-zse-96": sign, "x-zse-93": "101_3_3.0", "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "cookie": "你的登录cookie", "referer": "https://www.zhihu.com/question/123456", } # 发起评论请求 def fetch_comments(answer_id, cursor="0", limit=20): url = "https://www.zhihu.com/api/v4/comment_v5/answers/{}/root_comment".format(answer_id) params = {"include": "data[*].author", "limit": limit, "offset": 0, "cursor": cursor} headers = build_headers(url, params) resp = requests.get(url, params=params, headers=headers, timeout=10) return resp.json()关键点在于参数对象必须与浏览器实际请求保持一致,尤其是include、limit、cursor这些参数名,改掉任何一个都会导致签名不匹配。subprocess.run每次请求都会新建一个 Node 进程,理论上效率偏低,但实际抓取评论的场景下完全够用。如果后续吞吐量要求高,就可以用 Node.js 直接起一个 HTTP 服务,把签名计算封装成接口。
3.3 签名脚本的通病:环境检测会卡在navigator上
执行 Node.js 脚本时经常会遇到navigator is not defined或者window is not defined这类错误。这是因为后端加密代码里对浏览器环境对象有强引用。资源里的做法是在沙箱里手工注入最小化的 mock 对象,我建议按下面这个方式补齐:
const sandbox = { console, Buffer, setTimeout, navigator: { userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", language: "zh-CN", platform: "Win32", }, window: {}, document: { createElement: () => ({ getContext: () => null }), }, };window和document的 mock 不需要完整实现浏览器的所有 API,只要加密逻辑里真正调用到的那几个方法存在就行。比如document.createElement在这里只是用于检测 Canvas 指纹,代码里给了空对象也能跑过去。如果遇到localStorage is not defined,在沙箱里加一个空的localStorage: { getItem: () => null, setItem: () => {} }即可。
值得注意的坑是 Node.js 与浏览器环境下的btoa函数行为有差异。如果脚本里直接用了btoa,Node 14 可能会提示未定义。资源作者在脚本开头已经做了兼容处理,但如果你自己换了一个版本的脚本,建议先确认一下Buffer.from(...).toString('base64')是否被正确替换。
4. 评论翻页与数据落地:游标翻页、增量去重与实际部署
4.1 游标不是数字翻页,评论接口用的是cursor链式传参
评论接口与答案列表不同,它不支持直接指定页码,而是用cursor参数来定位数据流的位置。第一页请求不传 cursor 或传"0",返回数据里会带上next_cursor,这个值就是下一页的入参。如果忽略这个机制,直接改offset为 20、40,最终会拿到重复数据或者直接触发风控。
def fetch_all_comments(answer_id): cursor = "0" total = 0 all_comments = [] while cursor: data = fetch_comments(answer_id, cursor=cursor) comments = data.get("data", []) all_comments.extend(comments) total += len(comments) if not data.get("has_more"): break cursor = data["next_cursor"] time.sleep(1) # 控制请求频率,避免触发限流 return all_comments, totalhas_more字段表示是否还有下一页,next_cursor是下一页的位置标记。这里time.sleep(1)是很有必要的,知乎对短时间高频请求的封禁阈值很低,我在实测中连续请求 20 次以上就可能触发验证码。每页之间加 1 秒延迟,虽然总量上会慢一些,但能保证流程不中断。
4.2 评论内容不是纯文本,需要拼接content字段
评论接口返回的content字段不是简单的字符串,它是一个数组,每个元素代表一段富文本片段。常见的结构是[{"type": "text", "content": "你好"}],但也可能是图片或链接类型。如果直接把数组转成字符串,会带上type和content的 JSON 键名,数据就变脏了。
def extract_comment_text(comment): content_arr = comment.get("content", []) text_list = [] for item in content_arr: if item.get("type") == "text" and item.get("content"): text_list.append(item["content"]) # 图片、链接等其他类型按需解析 return "".join(text_list)在落库时建议额外保存comment_id、parent_id、reply_count、created_at这几个字段,方便后续做评论关系分析和时间维度统计。reply_count表示该评论下的子评论数量,如果需要爬子评论,就需要再用comment_v5/answers/{id}/child_comment接口继续递归请求。
4.3 登录态过期与 Cookie 轮换:爬虫翻车的头号原因
知乎评论接口在没有登录态的情况下只能拿到少量公开数据,遇到高热度问题会被强制要求登录。资源里的方案是手动从浏览器复制 Cookie 放进配置文件。Cookie 本身是有时效的,通常几天就会失效,失效的典型表现是签名明明合法,但接口返回{"error": {"code": 100, "message": "请登录"}}之类的提示。
我习惯把 Cookie 单独放进一个config.json,并在脚本启动时做一次请求探测,提前感知 Cookie 是否失效,而不是遇到报错再处理:
def check_cookie(cookie): headers = build_headers("https://www.zhihu.com/api/v4/me", {}) headers["cookie"] = cookie resp = requests.get("https://www.zhihu.com/api/v4/me", headers=headers, timeout=10) return resp.status_code == 200 and "id" in resp.json()启动时调用一次check_cookie,返回 False 就及时退出并提示更换 Cookie,避免中途断点导致数据半途而废。多账号轮换时,可以按照同一套流程维护一个 Cookie 池,每个请求前随机取一个。
4.4 数据落盘:CSV 与 SQLite 双写,崩溃了也有后悔药
抓取的数据如果直接输出到控制台,一断就全没了。我在项目里默认把评论数据同时写入 CSV 和 SQLite 两份。CSV 方便用 Excel 打开查看,SQLite 则适合后续做增量更新和去重查询。
import sqlite3 import csv def save_comment(comment): # SQLite 写入 conn = sqlite3.connect("zhihu_comments.db") cur = conn.cursor() cur.execute( "INSERT OR IGNORE INTO comments (comment_id, answer_id, content, created_at, like_count) VALUES (?, ?, ?, ?, ?)", ( comment["id"], comment["answer_id"], extract_comment_text(comment), comment["created_at"], comment["like_count"], ), ) conn.commit() conn.close() # CSV 写入 with open("comments.csv", "a", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow([comment["id"], comment["answer_id"], extract_comment_text(comment)])INSERT OR IGNORE利用comment_id主键完成去重,重复抓取时不会插重复数据。这样即使中途脚本崩溃,重新跑一遍只会补齐缺失部分,不会产生冗余记录。对于体量在几万条以内的任务,SQLite 单机处理完全够用,不需要上 MySQL 或 MongoDB。
5. 避坑与常⻅问题:403、签名不符、请求频率限制的完整排查
5.1 状态码 403 且响应体为空
现象:使用脚本请求评论接口,返回 HTTP 403,响应体为空或只有一行错误提示。
原因:x-zse-96 签名与请求参数不匹配。常见的触发点包括:请求参数缺少某个字段、cookie 过期或缺失、user-agent 与签名脚本里内置的环境指纹不一致。
解决:先用浏览器的 Network 面板完整复制一条成功请求的 Header,把x-zse-96、cookie、user-agent逐一对比你的脚本请求。优先检查user-agent,知乎对浏览器标识校验非常敏感,不一致就会触发 403。
5.2 签名脚本报错sha1 is not a function
现象:Node.js 执行加密脚本时抛出sha1 is not a function或md5 is not a function。
原因:混淆脚本内部引用了浏览器才有的CryptoJS或crypto.subtle,但 Node.js 环境下的crypto模块没有直接暴露同名函数。
解决:在沙箱里注入统一的哈希函数接口,把sha1映射到 Node.js 的crypto.createHash实现:
const crypto = require("crypto"); const sandbox = { sha1: (str) => crypto.createHash("sha1").update(str).digest("hex"), btoa: (str) => Buffer.from(str).toString("base64"), };注入后重新执行脚本,一般就能跳过这一步报错。如果脚本内部还有独立的哈希函数定义,就会覆盖沙箱注入,需要检查一下vendor/sign.js里是否已经在文件头部声明了同名变量。
5.3 爬取中途收到验证码
现象:连续爬取十几分钟,下一次请求返回 403 或 HTML 格式的验证码页面。
原因:请求频率过高触发反爬虫策略,知乎会对单个 IP 或单账号做短时间请求计数。
解决:增加请求间隔时间,建议限制在 2 秒以上。其次可以把请求分散到多个 Cookie 账号,每个账号独立限速。如果 IP 被临时封禁,切换一下网络出口或者等几分钟再继续,不要原地高频重试。
5.4 翻页数据重复,cursor没有生效
现象:拿到第二页数据后,与第一页有大量重复,或者无法继续翻页。
原因:可能是把cursor和offset同时传了,而offset被接口忽略或优先处理,导致游标位置没有前进。
解决:检查请求参数,保持offset=0不变,只更新cursor。另外注意确认next_cursor是从当前响应里取的,而不是写死成某个固定值。
5.5 Node 子进程频繁启动导致整体速度太慢
现象:每条请求都要启动一次 Node 脚本,刚开始数据量不大还能接受,爬到几千条后效率明显下降。
原因:subprocess.run每次都会重新加载 JS 文件并初始化 V8 引擎,耗时接近 100ms 到 200ms。
解决:改用 Node 脚本常驻服务,通过 stdin/stdout 或 HTTP 端口通信,把签名计算做成 RPC 调用。资源里没有封装这一步,但这是大批量抓取的必然路径,在跨过千条规模后建议尽快重构。
6. 提高抓取稳定性的进阶手段:接口探测、增量任务队列与数据校验
抓取评论只是第一步,真正让脚本能长期稳定运行的,是数据校验和任务队列机制。我在这套流程里会多走一步:抓取结果落库前,先统计当次请求的评论数组长度,与日志里记录的期望值对比。
如果浏览器端显示 20 条,脚本只拿到 3 条,说明响应被截断或参数异常,这时候不急着写库,先把 response 原文存一份到debug/目录,方便排查。
后续改进,我一般会加一个轻量级的任务队列来管理待抓取的答案列表。因为单个答案下面的评论可能只有几十条,但一个用户页可能连续有几十个答案,手工循环管理容易遗漏。
import queue import threading q = queue.Queue() # 往队列里塞答案 ID q.put(123456) q.put(789012) def worker(): while not q.empty(): answer_id = q.get() try: comments, total = fetch_all_comments(answer_id) print("answer {} fetched {} comments".format(answer_id, total)) except KeyboardInterrupt: break finally: q.task_done() # 启动 2 个工作线程 for i in range(2): t = threading.Thread(target=worker) t.daemon = True t.start() q.join()线程数从 2 开始,后续可以按 IP 和 Cookie 池的数量调整。我第一次上线时直接把线程开到 8,结果一下就把 IP 触发了验证码,后来强制限制到 2 个线程加 1.5 秒延迟才稳定下来。这里的核心逻辑是:抓取系统的性能和反爬系统的容忍度是反比关系,必须要留出余量。
另一个我养成的习惯是写一个verify_count函数,每次抓完一页就统计这一页的data长度和has_more状态,和上一次的next_cursor做对比。如果连续两次页的next_cursor相同但data不是空数组,说明可能存在数据错位,需要把当前请求参数打印出来,确认签名是否绑定错了参数。这类问题通常不是加密脚本的 bug,而是我们自己把参数对象传变了。
从那以后,我每次跑评论抓取都强制走一遍这个流程:启动前先做 Cookie 探测,抓取中边抓边把精简响应写到日志文件,结束后再对 SQLite 里的数据做一次总数统计,与本地日志逐项核对。这套组合看起来多花几分钟,但能避免大部分因签名错乱导致的半夜翻车事故。希望这篇笔记能帮你把 x-zse-96 这条链路理顺,减少自己盲调的无意义时间。
本文还有配套的精品资源,点击获取