简介:面向爬虫开发者的知乎评论抓取实战资料,聚焦x-zse-96参数逆向分析。知乎评论蕴含丰富的用户观点与专业见解,在舆情分析、市场调研和学术研究中价值突出,但平台以此参数强化反爬校验,给数据采集带来障碍。这份资源用两个JS文件完成了关键拆解,压缩包仅12KB,体积小巧便于快速部署;其中主脚本负责复现参数生成逻辑,环境补丁脚本用于模拟浏览器环境,可帮助开发者理解参数形成机制、服务端校验方式及请求伪装思路。已有527人学习下载,适合具备基础网络请求知识和JS逆向经验的读者。通过学习可掌握该参数的逆向方法与可运行代码,在合法合规前提下提升知乎评论数据获取效率,为后续分析应用提供有力支撑。
1. 知乎评论爬取的第一道坎:x-zse-96 签名到底拦住了什么
想抓知乎评论区的人,十有八九不是倒在登录墙,而是倒在一个叫 x-zse-96 的请求头参数上。这个参数出现在每一次知乎 Web 端接口请求里,不带它,接口直接返 403;带上它,还得保证它是“新鲜”的——几分钟前抓包复制的值粘贴上去,照样被风控踢下来。它就是知乎前端签名体系里最显眼的那一道锁。
这篇文章只讲一件事:怎么把 x-zse-96 从“黑匣子”变成你自己能算出来的东西。我会从录制真实请求讲起,再进 JS 里定位生成位置,最后落到 Python 本地复现、跑通评论爬取,并把浏览器正常访问、脚本一跑就挂这类血泪经验整理成排查清单。适合已经会写基础爬虫、但对前端逆向还没完整走通一遍的人;你要是只想拿现成轮子,这篇也能让你知道轮子该往哪装、坏了怎么修。
2. 先把请求“活”下来:用浏览器录制拿到真实参数
在碰逆向之前,先解决一个更基础的问题:你总得知道完整的请求长什么样。很多人一上来就开 DevTools 找 x-zse-96,但那个值每刷新一次页面就变一次,复制粘贴毫无意义。正确顺序是:先用能真实执行的浏览器环境把请求“活”着录下来,确认参数结构,再谈拆解。
2.1 用 Playwright 录制法直接捕获带签名请求
我一般不会一上来就开断点,而是先用 Playwright 把知乎评论区翻页的整个请求序列录下来。这样做的目的有三个:确认 x-zse-96 是放在请求头里而不是 URL 参数里、确认它和 cookie、时间戳的联动关系、拿到一批不同时刻的真实签名值用于后续验证。
import asyncio from playwright.async_api import async_playwright async def capture_requests(): async with async_playwright() as p: browser = await p.chromium.launch(headless=False) context = await browser.new_context( user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", locale="zh-CN" ) page = await context.new_page() captured = [] async def on_request(request): # 只需要评论接口的请求,过滤掉静态资源 if "api/v4/comment" in request.url and request.resource_type == "xhr": captured.append({ "url": request.url, "headers": request.headers, "post_data": request.post_data }) page.on("request", on_request) await page.goto("https://www.zhihu.com/question/569484662") # 随便一个高赞问题页 await page.wait_for_timeout(3000) # 滚动到底触发评论加载 for _ in range(3): await page.mouse.wheel(0, 2000) await page.wait_for_timeout(2000) print(len(captured)) for req in captured[:3]: print(req["headers"].get("x-zse-96")) print(req["url"][:120]) await browser.close() asyncio.run(capture_requests())逻辑说明:通过page.on("request")监听所有 XHR 请求,用resource_type == "xhr"过滤掉图片和脚本文件,只留评论接口。滚动页面是为了触发放置在底部、需要翻页才能拿到的评论列表请求。user_agent和locale务必设置,否则知乎可能直接返回一个极简版页面,根本没有滚动加载逻辑。
参数说明:headless=False是可以看到浏览器动作的调试模式,等你确认逻辑没问题了再改成True;wait_for_timeout给 React 渲染留时间,太短可能导致评论请求还没发出来就结束录制。抓到的x-zse-96值不要直接拿去用,它的作用是让你拿到一批“正确答案”,后面验证 Python 算法输出时要用。
2.2 抓包定位 x-zse-96 的三个依赖:时间戳、cookie 与加密摘要
Playwright 录到的 header 还不够,你还需要知道这个值是怎么算出来的。这时候就需要 Charles 或 Fiddler 这类中间人代理上场。把浏览器代理指向 Charles,然后做一次同样的评论加载操作,在 Charles 里看这个请求的完整头部和 cookie。
你会发现规律:x-zse-96的值本身是一长串字符,但去掉最前面的几个固定字母后,剩下的部分可以拆成三段。第一段是一个 10 位数字,刚好是当前 Unix 时间戳;第二段是一段看似乱码的字符串,长度在 20 到 40 之间,包含大小写字母和数字;第三段是一个 32 位十六进制字符串,这是 MD5 的长度。三段用_连接,整个拼成一个 header 值。
时间戳好理解,它的存在就是为了让签名不可重用——你 10 分钟前抓到的值放到现在发出去,第一段就对不上。第三段 MD5 也不难猜,因为知乎的评论接口在拿到这个 header 后,服务端会用同样的算法把收到的参数和 cookie 拼起来算一遍,对不上就拒绝。
真正难的是第二段那段“乱码”。它看起来像 Base64,但解出来又不是标准文本。这段才是核心,因为它和你当前浏览器的 cookie 里的某个字段强相关。我后面会在 JS 里定位它的生成位置,到时你就知道,这段东西其实是把 cookie 里d_c0这类凭证信息做变换之后的产物。所以一个成熟的采集脚本绝不会只维护 x-zse-96 这一个值,而是把 cookie 持久化、时间戳自动生成、算法本地复现这三件事一起解决。
3. 把 x-zse-96 从 JS 逆向到 Python:本地复现签名算法
录下来了、拆开了,下一步就是把生成过程搬到自己的代码里。这步的技术含量集中在“逆向”上,但工作量往往在“翻译”——把 JS 的逻辑一行行改成 Python。千万别想着直接调 Node.js 子进程去跑原始 JS,那样维护成本极高,每次知乎更新算法都要重新处理。
3.1 抠出算法核心:特征串、MD5 与拼接顺序
定位 x-zse-96 生成位置的标准做法是:在 Chrome DevTools 里切到 Sources 面板,对x-zse-96这个字符串打个全局搜索断点。因为 JS 代码里要设置这个 header,必然会出现这个字符串的字面量。搜索到之后,在赋值那一行打下断点,刷新页面触发评论请求,就能停在生成函数附近。
你会进入一段混淆过的代码,不要急。注意观察断点位置的调用栈,沿着调用栈向上找,会找到一个函数,它接收两个参数——一个固定字符串和一个cookie的值。算法结构通常是这样:把固定字符串和 cookie 里取出的某段内容做拼接,再经过一次或多次 MD5 编码,然后和时间戳一起格式化输出。
import hashlib import time import re def generate_signed_string(cookie_value: str) -> str: # cookie 里取 d_c0 字段,这是签名依赖的核心凭证 match = re.search(r"d_c0=([^;]+)", cookie_value) if not match: raise ValueError("cookie 中缺少 d_c0,请先获取有效登录凭证") credential = match.group(1) # 固定特征串,来自对 JS 代码的逆向分析 fixed_prefix = "3_2.0_1_0_10030_1_" # 关键一:把凭证和固定串按 JS 里的顺序拼接 raw = fixed_prefix + credential # 关键二:一次 md5,取十六进制摘要 md5_once = hashlib.md5(raw.encode()).hexdigest() # 关键三:再拼一个自定义格式,第二次 md5 second_raw = md5_once + "c21f969b5f03d33d43e04f8f136e7682" md5_final = hashlib.md5(second_raw.encode()).hexdigest() # 时间戳 10 位 timestamp = str(int(time.time())) return timestamp + "_" + fixed_prefix + credential + "_" + md5_final逻辑说明:上面的代码展示的是“常见做法”的骨架,未必逐字节等同于知乎线上版本,但逆向思路是一致的——从 JS 里找到拼接顺序,确定每次 MD5 的输入,把固定串、动态凭证、时间戳按序组合。fixed_prefix那段字符串带有明显的版本特征,它把算法版本和客户端类型一起编码进去了,这也解释了为什么旧版本抓到的签名在新版本接口上直接失效。
参数说明:re.search(r"d_c0=([^;]+)")的正则只匹配分号前的内容,因为 cookie 里其他字段可能包含特殊字符,但 d_c0 的值本身一般不含分号。两次 MD5 的中间串"c21f969b..."是逆向时在 JS 里看到的一个常量,真正落地时要用你自己定位到的常量替换。这个函数的输出格式必须严格保持时间戳_特征串_MD5三段式,顺序错了服务端照样校验不过。
3.2 把 cookie 与会话工程化:让签名在采集任务里“活久一点”
算法解决了,另一个坑马上出现:你的签名依赖 d_c0,但知乎 web 端的 cookie 是会过期的。我见过很多人在这一步翻车——本地测试时签名能算对、请求能通,睡一觉起来再跑就 61003。这不是算法错了,是 cookie 掉了。
所以实际采集任务里,我不会把 cookie 硬编码在代码里,而是做一个持久化会话层。每次启动任务先加载 cookie,如果接口返回 61003 签名异常,就触发一次浏览器自动化重新登录,把新 cookie 写回文件,再继续跑。
import json import os from pathlib import Path COOKIE_FILE = Path("zhihu_cookie.json") class ZhihuSession: def __init__(self): self.cookie_str = self._load_cookie() def _load_cookie(self) -> str: if not COOKIE_FILE.exists(): raise FileNotFoundError("首次使用请先手动登录获取 cookie") with open(COOKIE_FILE, "r", encoding="utf-8") as f: return json.load(f)["cookie"] def save_cookie(self, cookie_str: str) -> None: # 临时目录写入后原子替换,避免写一半留下损坏文件 tmp_file = COOKIE_FILE.with_suffix(".tmp") with open(tmp_file, "w", encoding="utf-8") as f: json.dump({"cookie": cookie_str}, f) os.replace(tmp_file, COOKIE_FILE) self.cookie_str = cookie_str def get_comment_page(self, answer_id: str, offset: int, limit: int) -> dict: ts = str(int(time.time())) signed = generate_signed_string(self.cookie_str) headers = { "x-zse-96": signed, "cookie": self.cookie_str, "x-zse-93": "101_3_3.0", # 客户端标识,与签名固定串配套 "referer": f"https://www.zhihu.com/question/{answer_id}" } url = f"https://www.zhihu.com/api/v4/comment_v5/answers/{answer_id}/root_comment?limit={limit}&offset={offset}&order_by=default" resp = requests.get(url, headers=headers, timeout=10) if resp.status_code == 61003: raise PermissionError("签名校验未通过,需要重新登录") return resp.json()逻辑说明:ZhihuSession类把签名计算和请求发送组装到一个方法里,调用方只需要传answer_id、offset和limit。每次请求都会自动生成新的时间戳和签名,这就避免了手工复制旧值的经典失误。x-zse-93这个 header 是和签名配套发送的客户端版本标识,丢了它,服务端可能直接不进入校验逻辑就拒绝请求。
参数说明:offset和limit对应评论列表的分页游标,offset=0, limit=20拉第一页,后续把 offset 加 20 继续翻。answer_id不是问题 ID,而是具体回答的 ID,在页面 URL 里能看到。61003 是知乎接口里的签名错误业务码,不是 HTTP 状态码,所以要用resp.status_code判断,实际返回的 HTTP 码可能是 200,需要单独检查 JSON 内容里的 error 字段。
4. 采集任务避坑实录:签名失效与风控的 4 个排查点
看到这里,你已经能把请求打出去了。但从“能通一次”到“稳定跑一天”,中间全是坑。我把自己的踩坑记录整理成下面四条,每一条都按现象、原因、解决来写,你照着这个顺序排,能省掉至少一个通宵。
4.1 坑一:浏览器里能拉数据,脚本一跑就 403
现象:Playwright 打开的浏览器里,评论加载一切正常;同样的 URL、同样的 cookie,用 requests 库发出去就返回 403。
原因:403 只代表服务端拒绝,不代表签名错。很多时候是少了 header。知乎校验签名时同时检查x-zse-96、x-zse-93、x-udid这几个字段的联动关系。脚本里只带了 x-zse-96,其他一概没传,等于告诉服务端“我这是脚本”。
解决:把浏览器请求的 header 完整复制下来,一个不漏地塞进 requests。重点看x-zse-93和x-udid——前者是客户端版本,后者是设备 ID,两个都能在页面初始 HTML 或 JS 初始化代码里找到。不要在代码里写“我觉得这个 header 没用”,所有 header 都带上最稳妥。
4.2 坑二:参数明明“正确”却返回 61003
现象:签名算法是从 JS 里一步步跟出来的,打印出来的值和浏览器抓包的格式完全一致,但请求就是返回 61003 签名错误。
原因:你对比的是“格式”,不是“内容”。x-zse-96 里那段特征串依赖 cookie 的 d_c0,如果你测试时用的 cookie 和你抓包时用的 cookie 不是同一个(比如浏览器是登录态,脚本用的是未登录 cookie),签名算出来自然对不上。
解决:把签名验证做成分步调试。先在脚本里打印签名值和当前 cookie,再打开浏览器开发者工具,手动修改请求头里的 x-zse-96 为你脚本算出来的值,看接口返回什么。如果手动替换后也报错,说明 cookie 或固定串有问题;如果手动替换后正常,说明脚本发请求时还有其他 header 污染。这个二分法能快速定位是哪一层出了问题。
4.3 坑三:同一套签名换台机器就失效
现象:本地开发机上签名算法验证通过,部署到服务器上跑同样的代码,马上就 61003。
原因:签名特征串里通常包含客户端环境信息。服务端会校验 User-Agent 里的浏览器指纹、操作系统信息是否和签名中编码的特征一致。你本地是 Chrome on Windows,服务器上 requests 默认的 Python-requests User-Agent 对不上号。
解决:把 User-Agent 固定成和逆向时浏览器一致的值,同时检查 headers 里有没有sec-ch-ua、sec-fetch-site之类的浏览器环境字段。最简单的方式是用 Playwright 的context.request直接发请求,它会自动带上完整的浏览器指纹,而不是手动构造 headers 去猜。我在生产环境就是这么做的,省掉了大量维护 header 的时间。
4.4 坑四:评论列表翻页翻着翻着就被限流
现象:前几页请求正常,翻到第 5 页左右开始随机返回 403,过一会儿又自动恢复。
原因:这不是签名被破解了,是频率控制。知乎的评论区接口对单个 IP 的翻页频率有限制。它不按 QPS 算,而是按“固定时间内连续翻页超过多少页”来触发,所以你在每页间隔 5 秒的“礼貌”频率下依然会被封。
解决:把翻页速度降到每页至少 8 到 10 秒,并且不要线性翻到底——翻几页停下来,去访问一个页面详情接口,再回来接着翻。另外,把评论的根评论和子评论分开采集,先翻完根评论,再按每个根评论的 comment_id 去拉子评论列表,这种分散请求的模式远比线性翻页安全。我自己用这个方案连续跑过几个钟头,没有再触发限流。
5. 把逆向成果变成可维护的采集脚本:签名模块的工程化与验证
走到这一步,你已经能算签名了。但“能算”和“能长期跑”还有一个工程化的距离。我把自己的签名模块写成一个独立的 Python 包,保证三个月后再看还能秒懂、能改、能换。
5.1 签名模块“三元组”组织:算法、会话、代理解耦
不要把签名计算函数直接塞进爬虫脚本里,否则知乎算法一更新,你就要在业务代码里到处找哪里在算签名。我一般拆成三个文件:signature.py只负责算法逻辑,输入是 cookie 和时间戳,输出是签名;session.py负责 cookie 的加载、保存和过期处理;fetcher.py负责把签名和请求头拼起来发请求。
这样拆的好处是:知乎改算法时,只动signature.py;cookie 过期时,只动session.py;要调整请求频率和代理时,只动fetcher.py。三者互不依赖,测试可以单独跑。这也是我常说的“后悔药”机制——算法被改后,你只需要用 git 回滚signature.py,再对照新抓包的签名值调试,不会牵连其他逻辑。
5.2 验证逆向结果的三个信号:该看状态码、日志与数据一致性
很多人做完算法第一反应是看“能不能拿到数据”,这个标准太模糊了。我建议按三个信号逐步确认:确认 HTTP 状态码稳定为 200 而且响应 JSON 里有data字段,这证明请求通过校验;确认拿到的评论总数和你手动在浏览器里看到的评论总数一致,防止签名验证通过了但实际拿到的是缓存数据或降级数据;确认评论的author和content字段格式符合预期,这步能发现你选错了接口——比如拿的是二级评论接口而不是根评论接口。
我会在采集脚本里加一个--verify参数,跑通这三次检查后输出一个简短的验证日志。养成习惯,每次改完签名模块先跑--verify,再跑全量任务,能避免“签名单测通过、全量跑挂了”的尴尬。这也是我踩坑踩出来的教训,别问我怎么知道的。
最后说一句:绕开签名做爬取不是目的,理解签名背后的生成逻辑才是核心能力。这套“录制请求 → 定位生成 → 本地复现 → 工程化维护”的思路,换到淘宝评论、京东商品评论、抖音评论区都是同一个套路。希望帮到你。
本文还有配套的精品资源,点击获取