1. 项目概述:为什么我们要跟知乎的签名算法“死磕”?

如果你尝试用Python的 requests 库直接去请求知乎的搜索接口,大概率会收到一个“请求参数异常,请升级客户端后重试”的提示。这个看似礼貌的拒绝背后,是一套相当成熟的签名验证机制在守护着知乎的数据大门。而 x-zse-96 这个请求头,就是这道门的核心钥匙。没有它,你的爬虫程序就像没有邀请函的访客,连门都进不去。我最初接触这个参数时,也走了不少弯路,从简单的User-Agent伪装,到模拟完整的Cookie会话,最后发现真正的壁垒是这个动态生成的加密签名。今天,我就把自己从抓包分析、逆向调试到最终用Python复现这套签名算法的完整过程,以及其中踩过的坑和总结的经验,毫无保留地分享出来。无论你是刚入门爬虫的新手,还是对JS逆向感兴趣的中级开发者,这篇文章都将带你手把手“撕开”知乎的这道防线,让你能稳定、合规地获取到所需的数据。

2. 逆向前的准备:抓包与核心参数定位

逆向的第一步不是直接看代码,而是观察。我们需要知道目标是什么,长什么样。这就离不开抓包工具。

2.1 选择合适的抓包工具

对于Web端,主流的选择是浏览器自带的开发者工具(F12),它足够轻便,适合初步分析。但如果你想进行更深入的、特别是移动端或复杂HTTPS流量的分析,专业工具会更高效。

  • Charles / Fiddler: 这两者是HTTP/HTTPS抓包的“瑞士军刀”。它们作为系统代理,可以截获几乎所有应用的网络请求。配置手机代理后,也能轻松抓取App流量。Charles的界面更现代,对JSON格式化友好;Fiddler在Windows上集成度更高。我个人的主力是Charles,因为它跨平台,且重放请求、断点调试功能非常强大。
  • Wireshark: 这是网络封包分析的神器,工作在更底层的网络层,能抓到所有经过网卡的流量。但对于我们逆向HTTP/HTTPS应用层协议来说,有点“杀鸡用牛刀”,且配置和过滤相对复杂,通常作为Charles的补充,用于分析一些非常规协议。
  • 浏览器开发者工具: 对于纯Web端的逆向,它是最快、最直接的工具。 Network 面板可以记录所有请求, Sources 面板可以调试JavaScript,两者结合是定位加密逻辑的黄金组合。

注意: 抓包HTTPS流量需要安装并信任抓包工具的根证书,否则你看到的会是乱码。这是一个标准操作,但务必从工具官网下载证书,确保安全。

2.2 捕获关键请求与参数

打开浏览器(以Chrome为例),访问知乎( www.zhihu.com ),按F12打开开发者工具,切换到 Network (网络)面板。勾选 Preserve log (保留日志),然后进行一次搜索操作,比如在知乎首页搜索框输入“Python”。

在瀑布流一样的请求列表中,找到一个名为 search_v3 或类似API路径的请求。点击它,在右侧的 Headers (标头)选项卡中,向下滚动到 Request Headers (请求头)部分。这里就是宝藏所在。

你需要找到以下几个关键的头信息:

  1. x-zse-96 : 我们的终极目标,一串以 2.0_ 开头的长字符串。
  2. x-zse-93 : 通常是 101_3_3.0 这样的固定值,代表算法版本。
  3. x-zst-81 : 另一个加密参数,有时可能为空,但必须从首次请求中捕获。
  4. Cookie : 找到名为 d_c0 的项,它的值是一长串字符,形如 “AQCp7m...|1234567890” 。这个值是生成签名的核心要素之一。

同时,记录下请求的 URL ,特别是路径和查询参数( ? 后面的部分),例如 /api/v4/search_v3?t=general&q=Python&...

实操心得: 建议将第一次成功搜索的整个请求(包括Headers)用Charles或开发者工具的 Copy as cURL 功能复制出来,保存到文本文件。这为你后续在Python中复现请求提供了最准确的模板,避免了因手动拼接导致的细微差异。

3. 深入虎穴:逆向JavaScript签名逻辑

抓包给了我们线索,但 x-zse-96 是如何生成的?答案藏在网页加载的JavaScript文件中。

3.1 定位加密函数入口

在开发者工具的 Sources (源代码)面板,按下 Ctrl + Shift + F (Windows/Linux)或 Cmd + Opt + F (Mac),进行全局搜索。

输入搜索词 x-zse-96 。通常结果不会太多,可能只有2-3处。这些地方就是设置这个请求头的位置。在每一处结果的行号上点击,打上断点(蓝色标记)。

打上断点后,回到知乎页面,再次触发一次搜索(比如修改搜索词再搜一次)。浏览器的JavaScript执行流会立刻在你设置的断点处暂停。

此时,关注几个关键面板:

  • Scope (作用域)或 Call Stack (调用堆栈): Call Stack 中,你可以看到是哪个函数调用了设置 x-zse-96 的代码。通常你需要向上查看几层,找到一个看起来像是核心计算函数的地方。
  • 代码区域: 断点处的代码通常类似 headers[‘x-zse-96’] = ‘2.0_’ + sign l.set(‘x-zse-96’, ‘2.0_’ + O) 。这里的 sign O 就是计算好的签名值。你需要找到生成这个变量的表达式。

3.2 拆解签名生成步骤

通过单步调试(F10)和观察变量值,你会发现签名生成通常分两步:

  1. 拼接源字符串( s ): 在控制台(Console)里打印或悬停查看相关变量,你会发现一个用于计算签名的原始字符串 s 。它的格式通常是固定的: 101_3_3.0 + /api/v4/search_v3?... + d_c0_cookie值 + x-zst-81_header值 各部分用加号 + 连接。其中,API路径和查询参数需要是 URL编码后 的格式。 d_c0 x-zst-81 就是抓包获取的值。

  2. 执行加密函数: 得到 s 后,会经过一个或一系列函数处理。你可能会看到类似 sign = u(f(s)) sign = encrypt(md5(s)) 的调用。

    • 内层函数(如 f ): 跳转到这个函数的定义,你会发现它接收字符串,输出32位十六进制字符串。通过在线工具对比,可以确认它 几乎总是MD5哈希函数 。你可以用 f(‘test’) 在控制台计算,然后与 test 的MD5值( 098f6bcd4621d373cade4e832627b4f6 )对比验证。
    • 外层函数(如 u encrypt ): 这是知乎自定义的加密算法,也是逆向的核心难点。你需要进入这个函数,把它的逻辑完整地“抠”出来。

3.3 “抠”代码与处理Webpack模块

知乎的前端代码通常使用Webpack打包,模块化程度高。你看到的加密函数可能位于一个模块内,例如:

var ty = tr(10261);
var sign = ty.encrypt(md5Result);

这里的 tr(10261) 表示加载ID为 10261 的模块。你不能只复制 ty.encrypt 这几行,必须找到模块 10261 的定义。

在Sources面板,搜索 10261: (10261): ,你会找到类似下面的代码块:

10261: (function(e, t, n) {
    “use strict”;
    // ... 一大段复杂的加密逻辑 ...
    t.exports = function(e) {
        // 返回加密函数
    }
})

你需要把整个 (function(e, t, n) { … }) 函数体复制出来。这就是包含加密逻辑的模块。

踩坑记录: 直接复制出来的代码往往不能独立运行,因为它依赖Webpack运行时环境(一个名为 webpackJsonp 的数组或 __webpack_require__ 函数)。我们的策略不是还原整个Webpack,而是只提取我们需要的函数,并处理它的依赖。通常,这个IIFE(立即执行函数表达式)的最后一行 t.exports = … 暴露了我们需要的函数。我们需要模拟一个简单的 exports 对象,让这个函数能正确挂载。

4. 构建独立运行环境:补环境与Proxy技巧

把抠出来的核心加密函数(假设我们叫它 encrypt 函数)和MD5函数放到一个单独的 .js 文件(如 zhihu_sign.js )后,在Node.js环境下直接运行,十有八九会报错: ReferenceError: window is not defined navigator is undefined

这是因为浏览器提供了丰富的全局对象( window , document , navigator , location 等),而我们的Node.js环境是纯净的。加密算法为了增加逆向难度,常常会读取这些浏览器环境信息作为加密因子的一部分。

解决方法不是搭建一个完整的浏览器,而是“欺骗”代码,让它以为自己运行在浏览器中。这里 强烈推荐使用Proxy(代理)

4.1 使用Proxy代理补环境

Proxy可以拦截对对象属性的读取、设置等操作。我们可以创建一个假的 window 对象,当代码尝试读取 window.navigator.userAgent 时,我们返回一个预设的值。

// zhihu_sign.js 开头部分
// 1. 创建全局的 window 对象
const window = globalThis.window || {};

// 2. 使用Proxy代理navigator
if (!window.navigator) {
  const fakeNavigator = {
    userAgent: ‘Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36’,
    webdriver: false, // 关键!很多反爬会检测这个是否为true
    language: ‘zh-CN’,
    platform: ‘Win32’,
    // 可以根据后续报错继续添加其他属性
  };
  window.navigator = new Proxy(fakeNavigator, {
    get(target, property) {
      // 如果访问的属性我们定义了,就返回;否则返回undefined,防止报错
      if (property in target) {
        return target[property];
      }
      console.warn(`Navigator property ‘${property}’ is not mocked.`);
      return undefined;
    },
  });
}

// 3. 补充其他可能用到的全局对象
if (!window.location) {
  window.location = {
    protocol: ‘https:’,
    host: ‘www.zhihu.com’,
    hostname: ‘www.zhihu.com’,
    href: ‘https://www.zhihu.com/’,
  };
}

// 4. 将window挂载到globalThis,确保在任何作用域都能访问
globalThis.window = window;

// 5. 如果加密代码使用了document等对象,按需补充(知乎x-zse-96通常不需要)
// if (!globalThis.document) { … }

// —————————— 以下是抠出来的加密核心代码 ——————————
// 这里粘贴你抠出来的MD5函数和encrypt函数
function md5(s) { /* … */ }
function encrypt(s) { /* … */ }

// 6. 封装一个生成签名的函数
function get_xzse_96(d_c0, path_query, x_zst_81) {
  // 拼接源字符串,注意格式和加号
  const s = `101_3_3.0+${path_query}+${d_c0}+${x_zst_81 || ‘’}`;
  // 计算MD5
  const step1 = md5(s);
  // 自定义加密
  const step2 = encrypt(step1);
  // 拼接最终格式
  return `2.0_${step2}`;
}

// 7. 导出函数供Node.js调用
module.exports = { get_xzse_96 };

4.2 调试与验证

编写一个简单的Node.js测试脚本:

// test.js
const { get_xzse_96 } = require(‘./zhihu_sign.js’);

const d_c0 = ‘你的_d_c0_cookie值’;
const path_query = encodeURIComponent(‘/api/v4/search_v3?t=general&q=测试’); // 注意:是整个路径+参数编码
const x_zst_81 = ‘你的_x-zst-81值’;

const signature = get_xzse_96(d_c0, path_query, x_zst_81);
console.log(‘计算出的签名:’, signature);

运行 node test.js 。如果报错,根据错误信息(如某个变量未定义、某个函数找不到)继续补充环境或检查抠出的代码是否完整。将计算出的签名与抓包得到的真实签名对比,如果一致,恭喜你,最核心的逆向工作完成了。

5. Python整合:使用execjs调用与请求实战

JS环境搞定后,我们需要在Python爬虫中调用它。 execjs 库是连接Python和JavaScript的桥梁。

5.1 基础调用流程

import requests
import execjs
import urllib.parse

# 1. 读取我们补好环境的JS文件
with open(‘zhihu_sign.js’, ‘r’, encoding=‘utf-8’) as f:
    js_code = f.read()

# 2. 编译JS代码。可以指定Node.js作为运行时环境,兼容性更好。
try:
    # 尝试获取Node运行时
    ctx = execjs.get(‘Node’).compile(js_code)
except Exception:
    # 回退到默认环境(在Windows上可能是JScript)
    ctx = execjs.compile(js_code)

# 3. 准备参数(这些值需要从首次抓包的请求中获取)
d_c0 = “AQCp7m...|1234567890”  # 替换为你的d_c0
x_zst_81 = “你的_x-zst-81值”  # 可能为空,但不能是None

# 4. 构造API路径和查询参数(必须和浏览器请求完全一致)
api_path = “/api/v4/search_v3”
query_params = {
    “t”: “general”,
    “q”: “Python爬虫”,  # 搜索关键词
    “correction”: “1”,
    “offset”: “0”,
    “limit”: “20”,
    “lc_idx”: “0”,
    “show_all_topics”: “0”,
    “search_source”: “Normal”,
}
# 关键:将参数字典转换为URL查询字符串,并进行URL编码
# 注意:知乎使用的是标准的URL编码,空格会被转为%20,而不是+
encoded_query = urllib.parse.urlencode(query_params, safe=‘=&’, encoding=‘utf-8’)
full_path = f“{api_path}?{encoded_query}”

# 5. 调用JS函数生成x-zse-96
# 注意:JS函数接收的path_query参数应该是URL编码后的完整路径+查询字符串
xzse96 = ctx.call(‘get_xzse_96’, d_c0, full_path, x_zst_81)
print(f“生成的签名: {xzse96}”)

# 6. 组装请求头
headers = {
    “User-Agent”: “Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36”,
    “x-api-version”: “3.0.91”,  # 这个版本号需抓包确认,可能会变
    “x-zse-93”: “101_3_3.0”,  # 固定值,与JS中拼接的版本对应
    “x-zse-96”: xzse96,  # 动态生成的签名
    “Cookie”: f“d_c0={d_c0}”,  # 关键Cookie
    “x-zst-81”: x_zst_81,  # 如果有值就加上
    “Referer”: “https://www.zhihu.com/”,  # 建议加上,更像浏览器
    “Accept”: “application/json, text/plain, */*”,
}

# 7. 发起请求
url = f“https://www.zhihu.com{full_path}”
response = requests.get(url, headers=headers)
print(f“状态码: {response.status_code}”)
if response.status_code == 200:
    data = response.json()
    print(“请求成功!”)
    # 处理你的数据...
else:
    print(“请求失败:”, response.text[:500])  # 打印前500字符看错误信息

5.2 常见问题与排查清单

即使按照步骤操作,你也可能会遇到问题。下面是一个快速排查清单:

问题现象 可能原因 解决方案
execjs 报JS语法错误 1. Node.js未安装或版本太低。
2. 抠出的JS代码存在浏览器特有语法(如 const 在旧JS引擎中不支持)。
3. 补环境代码有误。
1. 安装Node.js,并尝试 execjs.get(‘Node’)
2. 检查JS代码,确保为ES5兼容语法,或使用Babel等工具转换。
3. 在Node环境下单独运行 zhihu_sign.js ,看是否报错。
签名生成成功,但请求返回403/400 1. d_c0 cookie已过期。
2. API路径或参数编码错误。
3. x-zst-81 值错误或缺失。
4. 请求头缺失关键字段。
5. 知乎算法已更新。
1. 重新访问知乎网页,获取新的 d_c0 值。
2. 对比抓包中的 full_path 和你代码生成的 full_path ,确保完全一致(包括参数顺序和编码)。 特别注意: 有些参数值本身可能包含需要二次编码的字符。
3. 确保 x-zst-81 是从 首次 成功请求的响应头或后续请求的请求头中获取的,且传递给了JS函数。
4. 检查 x-api-version Referer 等头信息是否与抓包一致。
5. 重新抓包,检查 x-zse-93 版本号是否变化,并重新逆向JS。
请求成功但返回数据为空或错误 1. 请求频率过高触发风控。
2. 请求参数不完整或格式不对。
1. 在请求间增加随机延时(如 time.sleep(random.uniform(1, 3)) ),使用代理IP池。
2. 仔细对照浏览器正常请求的所有参数,一个都不要少。
x-zst-81 值获取不到 该值可能在首次请求后由服务端返回,并需要在下一次请求中带上。 1. 先不带 x-zst-81 发起一个简单请求(如访问首页),从响应头 set-cookie 或后续请求的请求头中寻找。
2. 有些情况下该值可以为空字符串,但参数位置必须保留(即JS函数中拼接时是 + 空字符串)。

实操心得:参数编码是最大的坑。 我强烈建议使用抓包工具(如Charles)的“Repeat”或“Compose”功能,将浏览器成功的请求原样重放,然后与你Python代码构建的请求进行 逐字节对比 。对比工具可以用 diff 或者将请求头、URL分别打印出来仔细检查。很多时候,问题就出在一个空格是 %20 还是 + ,或者某个参数的顺序不同。

6. 进阶策略:应对反爬升级与架构优化

“抠代码+补环境”的方法在相当长一段时间内是稳定的,但平台的反爬策略也会升级。当上述方法失效时,可以考虑以下方向。

6.1 更完善的环境模拟

如果知乎加入了更严格的浏览器指纹检测,你可能会遇到即使签名正确也被拒绝的情况。这时需要更全面的环境模拟。

  • 检测点: navigator.plugins , navigator.languages , screen.width/height , document.documentElement.clientWidth 等。
  • 解决方案: 可以创建一个更全面的 window Proxy,监听所有属性访问,并打印出被访问的属性名,然后针对性提供合理的返回值。
    const handler = {
      get(target, property) {
        console.log(`[Env Accessed] window.${property}`);
        // 返回预设值或继续代理
        if (property === ‘screen’) {
          return { width: 1920, height: 1080 };
        }
        // 对于未定义的属性,返回一个空的Proxy,防止报错并继续监听
        if (!(property in target)) {
          return new Proxy({}, handler);
        }
        return target[property];
      }
    };
    globalThis.window = new Proxy({}, handler);
    
    通过这种方式,你可以知道加密代码究竟依赖了哪些环境变量,从而精准打击。

6.2 RPC(远程过程调用)方案

对于加密逻辑极其复杂、更新频繁,或者环境检测过于严苛的情况,维护一套完整的JS环境成本太高。此时, RPC方案 是更优雅的选择。

其核心思想是: 让浏览器做它该做的事(执行JS),让Python做它该做的事(调度和数据处理)

  1. 架构: 使用 puppeteer selenium 启动一个无头浏览器(或隐藏浏览器)。
  2. 注入脚本: 在浏览器页面中注入一个JS脚本,该脚本将生成 x-zse-96 签名的函数暴露给全局(例如 window.generateSign )。
  3. 通信: Python端通过WebSocket(如 puppeteer CDP 协议)或HTTP(在注入的JS中创建一个简单的HTTP服务)与浏览器通信。
  4. 调用: Python将参数( d_c0 , path 等)发送给浏览器,浏览器调用 generateSign 函数计算并返回签名给Python。

优点: 签名计算在真实的浏览器环境中进行,无需关心环境补全,几乎100%模拟真人操作,稳定性极高。 缺点: 需要额外维护一个浏览器进程,资源消耗大,速度相对较慢。适合对稳定性要求极高、且加密逻辑复杂的场景。

6.3 关注移动端与算法变种

有时,Web端的防护加强后,可以转而分析其移动端App(Android/iOS)。App的签名算法可能放在原生库( .so / .a 文件)中,使用C/C++编写,逆向难度更大,但一旦破解,稳定性也更高。这需要用到 Frida (动态插桩)、 IDA Pro (静态反汇编)或 unidbg (模拟执行)等更专业的逆向工具。这是一个更深的水潭,但对于核心数据的获取,可能是终极解决方案。

7. 伦理、法律与最佳实践

最后,也是最重要的一部分。技术是一把双刃剑。

  • 遵守Robots协议: 检查知乎的 robots.txt 文件,尊重网站不希望被爬取的目录。
  • 控制请求频率: 在请求间添加合理的、随机的延迟(例如2-5秒),避免对目标服务器造成压力,这既是道德要求,也能有效避免被IP封禁。
  • 明确数据用途: 爬取的数据应用于个人学习、研究或符合法律法规的正当目的。切勿用于商业售卖、恶意攻击或侵犯他人隐私。
  • 关注API变化: 知乎的接口和算法可能会更新。你的代码需要有一定的容错和更新机制。一个健壮的爬虫应该能检测到请求失败,并触发重新抓包、分析、更新签名算法的流程(或至少通知开发者)。

逆向分析是一个不断学习、对抗和适应的过程。今天分享的针对 x-zse-96 的方法,其核心思路—— 抓包定位、断点调试、扣取代码、补全环境、整合调用 ——是通用的,可以应用到许多其他网站的JS逆向场景中。希望这篇详细的实战解析,能为你打开一扇门,让你在数据获取的道路上,多一份从容,少一些踩坑。记住,保持耐心,注重细节,问题总能被拆解和解决。

更多推荐