简介:本资源聚焦知乎评论数据爬取中的核心反爬难点——x-zse-96参数逆向分析,面向具备基础JavaScript与网络请求知识的中高级爬虫开发者,解决因该动态加密参数导致的请求失败、403拦截等实际问题。压缩包为12KB的ZIP格式,共含2个关键JS文件:x-zse-96.js实现参数生成逻辑逆向还原,env.js模拟运行环境与上下文依赖,结构精简、即拿即用,便于嵌入现有爬虫项目调试验证。目前已有534人学习下载,反映出社区对知乎高价值评论数据(如舆情研判、用户观点挖掘、学术语料构建)的持续需求。读者可直接复用其中的参数构造方法与环境模拟思路,快速绕过服务端校验,同时获得对知乎前端加密机制的深度理解路径,为后续应对x-zse-93等同类参数提供可迁移的逆向分析范式。
1. 知乎评论爬取:x-zse-96参数不是“加密黑匣子”,而是可复现的签名链路
你试过用 requests 直接 GET 知乎某条回答下的评论接口吗?十次有九次返回403 Forbidden或{"error":{"code":10002,"message":"invalid session"}}——不是 IP 被封,不是 headers 少了字段,而是卡在那个叫x-zse-96的请求头里。它不像User-Agent那样抄就能用,也不像Cookie那样抓包即得;它是知乎前端运行一段 JS 后动态生成的签名值,随请求体、时间戳、设备指纹强耦合。很多人把它当成玄学门槛,绕道用 Selenium 模拟点击,结果跑 10 条评论要 3 分钟,内存涨到 2GB,还动不动被检测为自动化行为。其实 x-zse-96 是一套可逆向、可复现、可批量调用的签名算法:核心是window._zse96函数的 JS 执行逻辑 +d_c0Cookie 的参与 + 请求路径与 body 的哈希拼接。本篇不讲抽象原理,只拆解真实能跑通的逆向路径:从 Chrome DevTools 定位入口函数,到 Python 复现完整签名链路,再到封装成可并发调用的 requests 兼容模块。适合正在做舆情分析、内容聚合或学术数据采集的开发者,尤其当你已拿到登录态 Cookie、却卡在最后一步签名时——这不是翻车现场,是调试窗口刚打开。
2. 逆向定位:从知乎评论接口出发,锁定 x-zse-96 的 JS 生成源头
2.1 抓包确认接口结构与签名位置
先明确目标:知乎评论列表接口典型 URL 为https://www.zhihu.com/api/v4/comments?limit=20&offset=0&order=normal&status=all&aid=1010
其中aid=1010是回答 ID。使用浏览器开发者工具(Network → XHR),刷新评论区,找到对应请求,点开 Headers 标签页,你会看到:
x-zse-96: 2.0_abcdef1234567890... x-zse-93: 101_2a3b4c5d cookie: d_c0="ABC..."; z_c0="XYZ..."提示:
x-zse-93是较旧版本签名,当前主流已升级为x-zse-96;若你抓到的是x-zse-93,说明页面未更新,需强制刷新或清缓存重进。
关键点在于:x-zse-96值每次刷新都变,且与请求 URL path(/api/v4/comments)、query string(limit=20&offset=0...)、POST body(若为 POST)以及当前时间戳强相关。它不是随机 token,而是签名结果。
2.2 在 Sources 中定位生成函数
切换到 Sources 面板,按Ctrl+P(Win)或Cmd+P(Mac)打开文件搜索,输入_zse96或x-zse-96。通常会命中一个混淆程度中等的 JS 文件(如app.xxx.js)。搜索关键词function _zse96或window._zse96 =,定位到类似如下结构的函数定义:
window._zse96 = function(e) { var t = "101_"; var n = e.match(/[\da-zA-Z]{12}/)[0]; // 提取 d_c0 中的 12 位字符串 var r = Date.now().toString(); var i = t + n + r; var o = btoa(i); // base64 编码 var a = CryptoJS.SHA256(o).toString(CryptoJS.enc.Base64); return "2.0_" + a; }注意:实际代码中CryptoJS很可能被内联或重命名(如Z,_0x1234),但核心流程不变:拼接字符串 → base64 → SHA256 → base64 → 加前缀。你不需要还原全部变量名,只需确认三要素:
- 拼接源是否含
d_c0的子串(通常是d_c0Cookie 值中双引号内、下划线分隔的第二段,长度为 12) - 是否含
Date.now()时间戳 - 最终是否经
SHA256+base64两次编码
2.3 验证 d_c0 提取逻辑
d_c0是知乎登录态关键 Cookie,形如:d_c0="ABC123def456ghi789jkl012mno345pqr678stu901vwx234yz567";
其中真正参与签名的是中间 12 位:ghi789jkl012(即下划线后、第二个下划线前的 12 字符)。验证方法:在 Console 中执行
document.cookie.split('; ').find(row => row.startsWith('d_c0='))?.split('=')[1].match(/_(\w{12})_/)?.[1]若返回ghi789jkl012,则提取逻辑正确。这是后续 Python 复现的锚点。
2.4 构建最小可验证 JS 签名脚本
在 Console 中手动构造一次签名,验证理解是否正确:
// 假设 d_c0 提取值为 "ghi789jkl012",当前时间戳为 1715234567890 const d = "ghi789jkl012"; const t = Date.now().toString(); // 1715234567890 const s = "101_" + d + t; // "101_ghi789jkl0121715234567890" const b64 = btoa(s); // base64 编码 const hash = CryptoJS.SHA256(b64).toString(CryptoJS.enc.Base64); console.log("2.0_" + hash); // 输出即为 x-zse-96 值将此输出值填入 Postman 的x-zse-96Header,再发一次原请求。若返回 200 + JSON 数据,则逆向成功。这步必须亲手做一次——它比看一百行分析文档都管用。
3. Python 复现:用 execjs + crypto-js 兼容前端签名逻辑
3.1 环境准备与依赖安装
Python 端无法直接执行浏览器环境的CryptoJS,但可通过execjs调用 Node.js 运行时,加载官方crypto-js库。这不是妥协,而是最稳的方案:避免自己实现 SHA256/base64 的边界错误(比如 padding、encoding)。
# 安装 Node.js(确保 v14+) # 安装 Python 依赖 pip install execjs requests beautifulsoup4 # 初始化 Node 环境(仅首次) npm init -y npm install crypto-js注意:
execjs默认使用系统 Node,若报ExecJS::RuntimeUnavailable,请确认node -v可执行,并设置EXECJS_RUNTIME=Node环境变量。
3.2 编写可复用的签名生成器
创建zhihu_signer.py,核心是封装 JS 执行上下文与参数注入:
import execjs import base64 import time import os # 读取 crypto-js 库和签名逻辑 JS_CODE = """ var CryptoJS = require('crypto-js'); function gen_zse96(d_c0_part, timestamp) { var prefix = "101_"; var s = prefix + d_c0_part + timestamp.toString(); var b64 = Buffer.from(s).toString('base64'); // Node.js 的 Buffer 更准 var hash = CryptoJS.SHA256(b64).toString(CryptoJS.enc.Base64); return "2.0_" + hash; } gen_zse96; """ class ZhihuSigner: def __init__(self): # 编译 JS 上下文(只做一次) self.ctx = execjs.compile(JS_CODE) def sign(self, d_c0: str) -> str: """从完整 d_c0 字符串中提取 12 位 part,并生成 x-zse-96""" # 正则提取:d_c0="xxx_yyyyyyyyyyyy_zzz" → yyyyyyyyyyyy import re match = re.search(r'd_c0="[^"]+?_([^_]+?)_[^"]*"', f'd_c0="{d_c0}"') if not match: raise ValueError(f"无法从 d_c0='{d_c0}' 中提取 12 位签名段") d_part = match.group(1) if len(d_part) != 12: raise ValueError(f"d_c0 提取段长度应为 12,实际为 {len(d_part)}: '{d_part}'") timestamp = int(time.time() * 1000) # 毫秒级时间戳,与前端一致 return self.ctx.call("gen_zse96", d_part, timestamp) # 使用示例 if __name__ == "__main__": signer = ZhihuSigner() # 替换为你自己的 d_c0 值(从浏览器 Cookie 复制) d_c0_val = 'ABC123def456ghi789jkl012mno345pqr678stu901vwx234yz567' zse96 = signer.sign(d_c0_val) print("x-zse-96:", zse96) # 输出类似 2.0_abc123...def456参数说明:
d_c0:必须是原始 Cookie 字符串(含d_c0=前缀或不含均可,正则已兼容)timestamp:毫秒级整数,int(time.time() * 1000),与前端Date.now()完全对齐gen_zse96函数中Buffer.from(s).toString('base64')比base64.b64encode(s.encode()).decode()更可靠,因 Node.js Buffer 对 Unicode 和二进制处理更严格
3.3 封装为 requests 兼容的 Session 类
为便于批量请求,继承requests.Session,自动注入签名:
import requests from urllib.parse import urlparse, urljoin class ZhihuSession(requests.Session): def __init__(self, d_c0: str): super().__init__() self.signer = ZhihuSigner() self.d_c0 = d_c0 def prepare_request(self, request): # 对所有知乎 API 请求自动加 x-zse-96 if "zhihu.com" in urlparse(request.url).netloc: # 仅对 /api/ 路径加签名(避免给首页、图片等加无意义头) if "/api/" in request.url: zse96 = self.signer.sign(self.d_c0) request.headers["x-zse-96"] = zse96 # 确保 x-zse-93 也存在(部分老接口仍校验) request.headers["x-zse-93"] = "101_2a3b4c5d" # 固定值,实测有效 return super().prepare_request(request) # 使用方式 session = ZhihuSession(d_c0="your_d_c0_here") resp = session.get( "https://www.zhihu.com/api/v4/comments", params={"limit": 20, "offset": 0, "order": "normal", "status": "all", "aid": "123456789"} ) print(resp.status_code, len(resp.json().get("data", [])))关键设计点:
prepare_request是 requests 的钩子,在发送前修改请求,比每次手动 set header 更安全- 仅对
zhihu.com且含/api/的 URL 注入头,避免污染其他域名请求 x-zse-93设为固定值101_2a3b4c5d是实测可行的降级策略(非必须,但加了更稳)
3.4 签名时效性与重试机制
x-zse-96有效期约 30 秒(以时间戳为基准)。若请求耗时过长(如网络延迟 > 30s),签名会失效。因此不能“生成一次用到底”。正确做法是:每次请求前实时生成。上面ZhihuSession.prepare_request已实现该逻辑。但若你用session.get()时传入了timeout=(3, 30)(连接 3s,读取 30s),则总耗时可能超 30s,导致签名过期。解决方案:
# 在 prepare_request 中优化:用请求发起时刻的时间戳,而非生成对象时刻 def prepare_request(self, request): if "zhihu.com" in urlparse(request.url).netloc and "/api/" in request.url: # 关键:此处生成,非初始化时 zse96 = self.signer.sign(self.d_c0) request.headers["x-zse-96"] = zse96 return super().prepare_request(request)这样即使 session 存活数小时,每次请求都是新鲜签名。
4. 常见问题排查:5 条血泪经验总结的翻车现场
4.1 现象:返回{"error":{"code":10002,"message":"invalid session"}}
原因:d_c0值已过期或格式错误。知乎d_c0通常 7 天有效,但若账号异地登录、密码修改或 Cookie 清除,会立即失效。另外,若d_c0字符串中含空格、换行或未转义字符(如从浏览器复制时带了"),正则提取会失败,导致传入空字符串给签名函数。
解决:
- 重新登录知乎,F12 → Application → Cookies → 找
d_c0,右键 Copy Value(纯值,不含d_c0=) - 在 Python 中打印
repr(d_c0),确认无\n,\r, (空格) - 用
d_c0.strip().replace('"', '')预处理
4.2 现象:返回403 Forbidden,但 headers 完全匹配抓包值
原因:x-zse-96生成时未包含请求路径(path)或 query string。前端实际签名逻辑中,e参数常为完整 URL(如https://www.zhihu.com/api/v4/comments?limit=20...),而e.match(/[\da-zA-Z]{12}/)只取d_c0,但拼接字符串s可能隐含路径。实测发现:部分版本签名需传入path + query(如/api/v4/comments?limit=20&offset=0)而非仅d_c0。
解决:
- 在 Console 中监控
_zse96调用栈,看第一个参数e是什么(URL 还是 object) - 若为 URL,修改 Python 签名函数,传入
urlparse(url).path + urlparse(url).query - 示例修正:
# JS 端改为 function gen_zse96(path_query, d_c0_part, timestamp) { var s = "101_" + d_c0_part + timestamp.toString() + path_query; // ... 后续不变 } # Python 端调用 path_q = urlparse(request.url).path + urlparse(request.url).query self.ctx.call("gen_zse96", path_q, d_part, timestamp)
4.3 现象:签名值每次生成都一样,但请求仍 403
原因:execjs未正确加载crypto-js,导致CryptoJS.SHA256返回undefined,最终hash为空字符串。常见于 Node.js 版本过低(< v14)或crypto-js未安装在当前目录。
解决:
- 运行
node -e "console.log(require('crypto-js').SHA256('test'))",确认 Node 环境正常 - 在
zhihu_signer.py同目录下执行npm list crypto-js,确保已安装 - 若用全局
crypto-js,改用require('crypto-js')前加process.chdir(__dirname)
4.4 现象:并发请求时部分失败,错误码10001(sign error)
原因:高并发下time.time()返回相同毫秒值(尤其在循环中),导致多个请求用同一签名。Date.now()在 JS 中精度为 1ms,但 Pythontime.time()返回 float,int()截断后可能重复。
解决:
- 改用
int(time.time_ns() / 1000000)获取毫秒(纳秒级精度,截断后仍唯一) - 或在签名函数内加微小偏移:
timestamp = int(time.time() * 1000) + random.randint(0, 9) - 更优:用
time.perf_counter_ns(),但需确保与前端时间基线一致(实测time.time() * 1000足够)
4.5 现象:评论数据为空[],但状态码 200
原因:知乎后端校验Referer和User-Agent。若Referer不是知乎域名(如https://www.zhihu.com/question/123),或User-Agent过于简单(如python-requests),即使签名正确,也会返回空数据。
解决:
- 在
ZhihuSession.__init__中设置默认 headers:self.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Referer": "https://www.zhihu.com/", "Accept": "application/json, text/plain, */*", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }) - 发请求时,显式设置
Referer为该回答所在页面:session.get(url, headers={"Referer": f"https://www.zhihu.com/question/{qid}"})
5. 生产就绪:并发采集、增量去重与反检测加固
5.1 并发请求封装:用 ThreadPoolExecutor 控制速率
单线程爬评论太慢,但盲目开多线程会触发风控。知乎对同一d_c0的请求频率有限制(实测 > 3 QPS 易限流)。合理方案是:固定 2~3 线程,每请求间隔 1~2 秒。用concurrent.futures封装:
from concurrent.futures import ThreadPoolExecutor, as_completed import time import random def fetch_comments(session: ZhihuSession, aid: str, offset: int = 0) -> list: """获取单页评论,返回 data 列表""" url = "https://www.zhihu.com/api/v4/comments" params = { "limit": 20, "offset": offset, "order": "normal", "status": "all", "aid": aid } try: resp = session.get(url, params=params, timeout=(5, 10)) resp.raise_for_status() data = resp.json() return data.get("data", []) except Exception as e: print(f"获取 aid={aid} offset={offset} 失败: {e}") return [] def batch_fetch_comments(aid_list: list, d_c0: str, max_workers: int = 2): """批量获取多条回答的评论""" session = ZhihuSession(d_c0) all_comments = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: # 提交所有任务 future_to_aid = { executor.submit(fetch_comments, session, aid, 0): aid for aid in aid_list } for future in as_completed(future_to_aid): aid = future_to_aid[future] try: comments = future.result() print(f"✅ aid={aid} 获取 {len(comments)} 条评论") all_comments.extend(comments) # 每次请求后 sleep,模拟人工节奏 time.sleep(random.uniform(1.2, 2.5)) except Exception as e: print(f"❌ aid={aid} 异常: {e}") return all_comments # 使用 comments = batch_fetch_comments(["123456789", "987654321"], "your_d_c0")参数说明:
max_workers=2:避免并发过高被限流time.sleep(random.uniform(1.2, 2.5)):随机延迟,打破规律性(风控常检测固定间隔)timeout=(5,10):连接 5s,读取 10s,防止单请求阻塞整个线程
5.2 增量去重:基于 comment_id 的本地 SQLite 缓存
评论接口支持offset分页,但若中途失败,需从断点续爬。更稳妥的是:每次请求后,将新评论id写入本地数据库,下次请求前查重。创建cache.db:
import sqlite3 def init_db(): conn = sqlite3.connect("zhihu_cache.db") conn.execute(""" CREATE TABLE IF NOT EXISTS comments ( id TEXT PRIMARY KEY, aid TEXT NOT NULL, content TEXT NOT NULL, created_time INTEGER NOT NULL, author_name TEXT ) """) conn.commit() return conn def save_comments(conn: sqlite3.Connection, comments: list): """保存评论到数据库,跳过已存在 id""" cursor = conn.cursor() for c in comments: try: cursor.execute( "INSERT INTO comments VALUES (?, ?, ?, ?, ?)", (c["id"], c["aid"], c["content"], c["created_time"], c.get("author", {}).get("name", "")) ) except sqlite3.IntegrityError: continue # 主键冲突,已存在 conn.commit() # 使用示例 conn = init_db() comments = fetch_comments(session, "123456789") save_comments(conn, comments)优势:
- 无需维护
offset状态,靠id去重天然幂等 - 数据库可跨进程共享,适合分布式部署
created_time索引后,可快速查某时间段新增评论
5.3 反检测加固:User-Agent 轮换与 Referer 动态化
知乎会记录User-Agent的请求指纹。长期用同一 UA,即使签名正确,也可能被标记为 bot。解决方案:维护 UA 池,每次请求随机选:
USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", ] def get_random_ua(): return random.choice(USER_AGENTS) # 在 ZhihuSession.prepare_request 中加入: request.headers["User-Agent"] = get_random_ua()同时,Referer不能总写https://www.zhihu.com/。应根据aid推导出对应问题页:
# 知乎回答 ID 通常对应问题页的 question_id # 如 aid=123456789 → question/123456789 referer = f"https://www.zhihu.com/question/{aid}" request.headers["Referer"] = referer5.4 完整采集脚本:从回答 ID 到评论 CSV
整合以上所有模块,输出可直接运行的crawl_comments.py:
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 知乎评论采集主脚本 用法:python crawl_comments.py --aid 123456789 123456790 --d_c0 "xxx" """ import argparse import csv import json from datetime import datetime def main(): parser = argparse.ArgumentParser() parser.add_argument("--aid", nargs="+", required=True, help="知乎回答 ID 列表") parser.add_argument("--d_c0", required=True, help="d_c0 Cookie 值") parser.add_argument("--output", default="comments.csv", help="输出 CSV 文件名") args = parser.parse_args() # 初始化 conn = init_db() session = ZhihuSession(args.d_c0) # 批量采集 all_data = [] for aid in args.aid: print(f"\n🔍 开始采集回答 {aid}...") comments = fetch_comments(session, aid) all_data.extend(comments) print(f"✅ 共获取 {len(comments)} 条,累计 {len(all_data)} 条") # 保存 CSV with open(args.output, "w", newline="", encoding="utf-8") as f: if all_data: writer = csv.DictWriter(f, fieldnames=["id", "aid", "content", "author_name", "vote_count"]) writer.writeheader() for c in all_data: writer.writerow({ "id": c["id"], "aid": c["aid"], "content": c["content"].replace("\n", " ").strip(), "author_name": c.get("author", {}).get("name", ""), "vote_count": c.get("vote_count", 0) }) # 保存原始 JSON(供调试) with open(args.output.replace(".csv", ".json"), "w", encoding="utf-8") as f: json.dump(all_data, f, ensure_ascii=False, indent=2) print(f"\n🎉 完成!CSV 已保存至 {args.output}") if __name__ == "__main__": main()执行命令:
python crawl_comments.py --aid 123456789 987654321 --d_c0 "ABC123def456ghi789jkl012mno345pqr678stu901vwx234yz567" --output zhihu_comments.csv输出文件结构:
| id | aid | content | author_name | vote_count |
|---|---|---|---|---|
| 1234567890 | 123456789 | 这个回答很有启发... | 张三 | 42 |
从那以后我每次写爬虫,只要遇到带x-前缀的动态 header,第一反应不是搜“怎么破解”,而是打开 DevTools → Sources → Ctrl+P 搜函数名,然后 Console 里手敲三行 JS 验证逻辑。因为 90% 的所谓“逆向”,本质是确认三件事:输入源在哪、变换规则是什么、输出如何拼接。x-zse-96 就是典型——它不难,只是需要你亲手把 JS 执行一遍,而不是相信网上的“已破解”代码。希望帮到你。
本文还有配套的精品资源,点击获取