拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

知乎评论爬虫翻页403?x-zse-96参数逆向全解

知乎评论爬虫翻页403?x-zse-96参数逆向全解

简介:知乎评论数据获取向来受制于平台严密的反爬体系,其中x-zse-96参数堪称核心关卡。面向具备一定爬虫基础、希望深入逆向分析并突破知乎反爬限制的开发者,围绕x-zse-96参数展开全面拆解,覆盖从JS加密入口到参数生成链条的完整过程。压缩包内共2个文件,均为js脚本,一个用于补全浏览器环境,一个封装核心算法与签名逻辑,整体仅12KB,体量轻巧,适合逐行阅读与本地调试。目前已有527人学习/下载,在同类逆向资料中具备较高实用价值,尤其适合作为实战练习的参考。通过研读源码与注释,可以弄清x-zse-96的生成原理、请求头组装方式以及常见报错与规避要点,进而依照合法合规原则,为市场调研、舆情分析或学术研究采集知乎评论数据,有效减少盲目试错与无效尝试。

1. 知乎评论爬取卡在翻页?先处理 x-zse-96 参数逆向分析

做知乎评论爬取的人,大概率都撞过同一堵墙:前几页数据好好的,翻到中间,服务器突然返回 403,或者给一段看不懂的校验错误。这不是 IP 被封,也不是请求频率太高,而是网页端每个数据接口都会携带一个叫 x-zse-96 的请求头参数,服务端拿它校验请求是否来自真实浏览器、有没有被篡改。你手动打开浏览器访问没问题,但脚本一发出去,签名对不上,请求就被拦了。这份资源的价值就在这儿:它不教你造轮子,而是把 x-zse-96 这个参数从定位、抠取到本地复刻的完整逆向思路拆给你看。适合已经跑通基础爬虫、但卡在签名校验上的开发者,也适合想系统了解前端参数逆向的新手。

2. 认识 x-zse-96:签名参数的作用与定位方法

2.1 它是怎么混进请求里的

先明确一件事:x-zse-96 不是登录态,也不是随机 token,它更像一张“通行证”。知乎网页版的所有敏感数据接口,比如评论列表、回答列表、用户动态,都会在请求头里带上这个参数。服务端拿到请求后,先用它校验签名是否合法、是否在有效时间窗口内,再决定要不要返回数据。

从抓包工具里看,它的样子是一段以版本号开头的长字符串,通常伴随在请求头中,和cookie、user-agent并列。参数本身是前端 JavaScript 动态生成的,每一轮请求几乎都不一样。这意味着你用浏览器能正常访问,但把同样的 URL 复制到脚本里重放,签名已经失效。这也是为什么很多人明明带着 cookie、设置了 UA,依然被知乎拦下来。

它的生成时机也很有讲究。前端在发起请求前,会先取当前时间戳、请求 URL、cookie 里的部分字段,经过一系列加密处理后拼成这个字符串。所以它和请求内容时序是强绑定的,时间和参数一变,签名就变。理解这一点,后面做逆向才有方向:你要找的不是一个固定值,而是一段生成逻辑。

定位它的第一步,是在 DevTools 里打开 Network 面板,找一个评论接口的请求,在 Headers 里看到x-zse-96这个键。接下来才是重头戏——找到是哪个 JS 文件生成了它。常见做法是直接在 Sources 面板里全局搜索这个参数名,但前端代码经过压缩混淆,直接搜未必一步到位,更稳的办法是用断点。

2.2 用 XHR 断点把生成函数钓出来

我一般会这样定位:先打开 DevTools 的 Sources 面板,右侧找到 XHR/fetch breakpoints,添加一个断点条件,填入评论接口路径里的关键片段,比如api/v4/comment。刷新页面并触发评论加载,请求发起前代码会暂停在 fetch 或 XHR 调用位置。

这时看右侧的 Call Stack,从最上层调用开始往下找。因为 x-zse-96 一定是在请求发起前被算出来的,所以调用栈里越深的地方越接近签名生成函数。找到后点击跳转到对应 JS 文件,搜索当前作用域里的字符串拼接逻辑,通常能看到版本号常量、时间戳、cookie 读取语句出现在同一段代码里。

定位手段操作位置预期结果
Network 筛选Headers 面板搜索 x-zse-96确认参数存在的请求接口
XHR 断点Sources > XHR/fetch breakpoints请求发起前暂停 JS 执行
Call Stack 回溯右侧调用栈逐层点开锁定参与签名生成的函数
全局搜索在 JS 文件中搜索版本号前缀找到加密函数所在的具体模块

这三个手段配合起来,能在一小时内锁定加密函数的文件和大致位置。定位到之后不要急着读代码,先做一件事:在函数入口打一个条件断点,把函数接收的参数打出来,手动触发一次请求。你会看到这个函数接收的输入,通常包括一个对象,里面有 URL、cookie、时间戳之类的东西。这一步非常关键,它决定了后面你要补哪些环境。

提示:如果你搜索版本号前缀时命中多个文件,优先看体积最小、定义时间最早的那个,那往往是核心算法所在,混淆程度也最低。

3. 逆向抠算法:从调用栈到本地签名函数

3.1 读代码的次序:先找输入,再找拼接,最后找加密

拿到加密函数之后,直接逐行读是不可取的。压缩混淆后的代码变量名几乎不可读,你盯着它看半小时也理不清。我拆过的逆向项目多了,总结出来的次序是:先找输入,再找拼接,最后找加密原语。

输入端的特征是函数参数,通常是一个对象或字符串,里面带着url、timestamp、cookie等字段。找输入的意义在于确定外部条件和内部逻辑的边界。接下来找拼接逻辑,也就是把输入字段拼成一个长字符串的那段代码。这段代码里常有特征明显的操作,比如把 URL 里的路径部分取出来、把 cookie 按分号切分、把时间戳转成字符串。拼出来的串就是加密原语的输入。

最后一步才是看加密原语。这里有一个重要的经验:知乎这类站点的加密原语大多不是自研的,而是基于某个已有的哈希或编码算法做了改造。代码里会有明显的特征,比如循环位移、位运算、查表操作。你不需要完全看懂它每一步在做什么,只需要确认它输入什么、输出什么、是否依赖外部变量。

3.2 把算法抠成本地函数

定位完成后,把加密函数整体复制到一个独立的 JS 文件里,删掉依赖 DOM 的部分,替换成参数传入。这个步骤听起来简单,但有一个很容易忽略的坑:加密函数内部很可能引用了全局变量,比如某个在文件顶部定义的工具函数。你需要把这些依赖一起复制,或者用 Node.js 的vm模块原样加载整个文件。

抠完之后,你会得到一个这样的函数轮廓:

// signature.js —— 从知乎前端解析出的签名生成函数 // 注意:md5 与 hashCookie 仅为占位示意,实际以资源内逆向分析为准 function generateXZse96(input) { const version = '3.0_'; // 版本前缀,取自请求头原文 const timestamp = input.timestamp; // 13 位毫秒时间戳 const urlPath = extractPath(input.url); // 取 URL 的 path + query const cookie = normalizeCookie(input.cookie); // 按分号切割并重排 const digest = customHash(urlPath + timestamp + cookie); return `${version}${digest}`; } function extractPath(url) { // 常见做法是只保留 pathname + search,去掉 protocol 与 host const u = new URL(url); return u.pathname + u.search; } function normalizeCookie(raw) { // 通常需要过滤掉某些字段,比如 session 相关 key 不参与签名 return raw.split(';').filter(c => c.includes('z_c0')).join(';'); } module.exports = { generateXZse96 };

这段代码里,extractPath和normalizeCookie是我按照前端常见逻辑补全的占位实现。实际项目中,你抠出来的代码会以压缩形式存在,但输入输出关系大概率与此类似。customHash是资源分析笔记里给出的加密核心,通过调试你最终能确定其对应的算法类型。

version参数直接决定服务端用哪一套校验逻辑,不要自行改动,应从抓包结果中原样提取。timestamp必须是毫秒级 13 位数字,短时间跨度内多次请求不会因为时间戳重复而失败,但如果一次性批量跑几百条,服务端仍可能因为时间窗口重叠而拒绝。normalizeCookie里我习惯了只保留固定字段,因为这个接口对 cookie 里哪几个字段参与签名是有要求的,全部塞进去反而签不出来。

3.3 验证抠出来的函数是否正确

本地函数写好后,先不要接爬虫,单独做一次离线验证。方法很简单:在浏览器里手动触发一个评论请求,把请求头里的x-zse-96、请求 URL、当时的 cookie 一起记下来。然后把这几个值作为输入传给本地函数,对比输出是否一致。

这里要注意一个细节:浏览器里的 cookie 是会变的,尤其是知乎的z_c0字段,有时服务端会返回 set-cookie 更新它。所以你记录 cookie 的时间和抓取请求的时间应该尽量贴近。如果第一次对比不一致,不要急着改代码,先把 cookie 刷新后再试。排除 cookie 问题后,再检查 URL 的处理方式,比如末尾是否有斜杠、query 参数顺序是否被打乱。

验证通过之后,签名函数就可以作为独立模块被爬虫调用了。到此为止,你还没有真正合成一个可用的请求,因为生成了签名不代表服务端会认。接下来要处理的是环境补齐和请求头组装。

4. 补环境与请求头组装:让签名在 Node 里稳定复现

4.1 为什么需要补环境

抠出来的签名函数在浏览器里跑得通,放进 Node.js 里就容易报错,因为前端代码里被删掉的“外部依赖”比想象中多。这些外部依赖不是算法本身的逻辑,而是运行环境提供的全局对象。比如代码里可能隐式使用了window.location.href去读当前页面地址,或者用navigator.userAgent去拼字符串,又或者直接访问document.cookie来拿 cookie。

Node.js 没有这些对象,直接运行会在第一行就抛异常。解法也不是去代码里一个一个改,而是构造一个浏览器环境沙箱,把这些对象填进去。这一步在逆向里被称为“补环境”。

补环境的核心思路是“最小化欺骗”:能通过参数传入的,就不要依赖环境变量;必须依赖环境的,就只构造签名函数用得到的那几个属性,不要为了省事往沙箱里塞一个完整的浏览器对象,那会增加环境特征被识别出来的风险。

4.2 沙箱构造与参数说明

使用 Node.js 内置的vm模块就能完成一次轻量补环境。下面这段代码是可运行的沙箱骨架,把前端 JS 放进去执行,签名函数就能正常被调用。

const vm = require('vm'); const fs = require('fs'); // 构造最小浏览器环境 const sandbox = { window: {}, navigator: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...', appVersion: '5.0 (Windows NT 10.0; Win64; x64) ...', webdriver: undefined }, document: { cookie: 'z_c0=xxxxxxxx; d_c0=yyyyyyyy;', readyState: 'complete' }, location: { href: 'https://www.zhihu.com/' }, console: console, setTimeout: setTimeout, clearTimeout: clearTimeout }; // 关键:让沙箱里的 window 指向自身 sandbox.window = sandbox; vm.createContext(sandbox); // 加载前端加密模块 const code = fs.readFileSync('./zhihu_sig.js', 'utf-8'); vm.runInContext(code, sandbox, { timeout: 5000 }); // 调用沙箱里的生成函数(函数名以实际资源为准) const sig = sandbox.generateXZse96({ url: 'https://www.zhihu.com/api/v4/comment/xxx?limit=20', cookie: sandbox.document.cookie, timestamp: Date.now() }); console.log(sig);

三个关键点要说明。第一,sandbox.window = sandbox这行不能省,因为很多前端模块在初始化时会判断window.self === window,不一致直接抛错。第二,navigator.webdriver为什么要设成undefined,是因为服务端可以通过 JS 执行环境读到这个字段,若是true就会被判定为自动化工具。第三,vm.runInContext的timeout参数建议保留,防止混淆代码里出现死循环时,Node 主进程被卡死。

4.3 请求头组装:保证签名和请求一致

签名生成成功不等于请求一定通过,因为服务端校验的是签名与请求头的匹配程度。你生成签名时用了什么 userAgent、什么 cookie,发送请求时就必须原样携带,任何一个字段不一致都会导致校验失败。

组装请求头时,我习惯从浏览器里把完整请求头复制出来,只替换动态变化的字段。下面是一个组装示例:

import time import requests cookies = "z_c0=xxxxxx; d_c0=yyyyyy" user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..." url = "https://www.zhihu.com/api/v4/comment/123456?limit=20" # 调用 Node 签名脚本,传入与请求头一致的 cookie sig = generate_xzse96(url, cookies, int(time.time() * 1000)) headers = { "user-agent": user_agent, "cookie": cookies, "x-zse-96": sig, "referer": "https://www.zhihu.com/", } resp = requests.get(url, headers=headers) print(resp.status_code)

这段代码里的generate_xzse96可以是通过 subprocess 调 Node 脚本的封装函数,也可以直接把签名算法改写成 Python 版本。我个人推荐前者:Node 沙箱模式改动最小,逆向出的代码是什么样子就原样运行,减少重写过程中引入的逻辑偏差。

补环境这一步做完,请求大概率能通。但如果仍然 403,问题多半不出在签名本身,而是出在环境特征和请求细节上。这就要进入排查阶段了。

5. 避坑:签名对不上、请求 403 的常见排查记录

5.1 本地签名一致,发请求还是 403

现象:用抓包记录的输入验证签名函数,输出和浏览器里的完全一致;但把同一个签名放进 Python 请求里发出去,返回 403。

原因:签名一致不代表请求一致。绝大多数情况下,是生成签名时使用的 cookie 和 user-agent 与发送请求时的请求头不一致,被服务端识别为“双重身份”。也有一种情况是签名生成时取了location.href,而你的脚本里没有设置这个值,导致签名里埋了一个隐性变量。

解决:每次发送请求前,把实际使用的 cookie 和 UA 传回签名函数。不要在代码里写死两套。location.href必须与请求 URL 同源,建议设置为首页即可。

5.2 签名函数第一次能跑,第二次就报错

现象:Node 沙箱执行前端签名 JS,第一次正常输出,第二次运行同一进程报错,提示某个变量未定义或函数不存在。

原因:前端代码在第一次执行时修改了沙箱环境,比如在全局挂了某个状态标记,或者删除了某个初始变量。第二次执行时,沙箱已经不再是干净的浏览器环境。

解决:把vm.createContext(sandbox)和vm.runInContext()包进一个独立的工厂函数,每次生成签名前都创建一个全新沙箱。不要复用沙箱对象。

5.3 评论列表第一页正常,翻页就失败

现象:数据请求第一页正常返回,把 offset 改成 40、80 之后开始 403,而且失败请求的签名看起来和成功请求没什么区别。

原因:签名输入里包含完整 URL,而 URL 里有 query 参数。翻页时 query 变了,但你可能只把路径传给了签名函数,忽略了参数,导致签名与 URL 不一致;或者服务端对连续翻页设置了时间窗口校验,签名生成时间与请求发送时间间隔过短或过长。

解决:签名输入必须以实际发出的完整 URL 为准,路径和 query 一个都不能少。另外,所有待翻页请求的时间戳应分别生成,不要复用第一个请求的时间戳。

5.4 浏览器里一切正常,脚本里全部失败

现象:同一个签名算法,浏览器里手动触发请求成功,脚本里无论怎么调都失败,检查请求头又看不出问题。

原因:浏览器环境里除了签名,还有大量的自动化检测特征被收集。x-zse-96 只是校验的一块拼图,服务端还会检查 webdriver 标记、浏览器指纹、canvas 指纹等。脚本里缺少这些特征,即使签名合法也会被拦截。

解决:先检查 Defender 级别的识别逻辑在 Node 沙箱中是否正常,特别是navigator.webdriver和window.chrome这类标志性对象。如果仍被拦截,考虑在现有签名模块基础上集成更完整的浏览器指纹模拟,但这一步需要回看资源内是否包含对应方案。

5.5 服务端返回 500 而不是 403

现象:请求没被拒绝,但返回服务器内部错误,响应体里是 JSON 格式的异常信息。

原因:签名或请求头通过了基础校验,但参数格式、字段类型不符合预期,被后端业务逻辑拒绝。常见于 cookie 里缺少必要字段,或者 URL 中的limit超出允许范围。

解决:查看响应体里返回的错误信息,通常会指明哪个字段有问题。再回到浏览器里对比一次正常请求的请求头和 cookie,找出差异项。

6. 把签名做成独立模块:批量拉取评论的工程化做法

签名逆向完成后,最大的敌人不是反爬,而是代码结构混乱。把签名逻辑、补环境逻辑、请求逻辑混在一个文件里,短期内跑通没问题,一旦需要换 cookie、调参数,改一处崩三处。我的做法是把整条链路拆成三个文件,各管一段。

第一个文件负责签名,接收 URL、cookie、timestamp 三个参数,返回 x-zse-96 字符串。这个文件只依赖 Node 环境和一份前端 JS 资源,不感知任何网络请求细节。第二个文件负责补环境,做沙箱创建、前端代码加载、函数调用封装,对外暴露一个get_sign(url, cookie)的 Python 接口。第三个文件才是爬虫主逻辑,负责翻页、解析、入库。

模块职责边界易错点
signature.js算法执行与字符串拼接不能依赖外部无头浏览器
sandbox.js沙箱构造与函数调用每次签名前重建沙箱
crawler.py请求发送与数据解析cookie 与 UA 必须反向传入签名模块

批量拉评论时还有一个被很多人忽略的细节:时间戳不要所有请求共用一个值,也不要每一条都很均匀地递增。前者会让所有签名落入同一个时间窗口,服务端可以直接批量拒绝;后者容易暴露出机器节奏。我一般会让每条请求的时间戳在真实时间基础上加一个 -200 到 200 的随机偏移量,再顺带把请求间隔设成 1.2 到 2 秒的随机值。

验证整个模块是否可靠的方法也很简单:连续跑 500 条评论请求,记录每个状态码,如果出现 403 的比例低于 1%,并且错误集中在某一页,说明问题基本已经收敛在签名模块之外,大概率是 cookie 过期了。从那以后我每次接到爬虫类逆向任务,都会把“签名输入源、cookie 更新机制、环境特征一致性”这三件事强制列出清单,逐个确认后才开始写请求代码。按住这个顺序,逆向的工程量能少一半以上。

希望这个拆解过程能帮你少走弯路。

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

返回列表