一个小红书分享链接解析不了了。查下去才发现,这东西的反爬比我想的复杂得多。折腾了好几条路,最后解决方案反而出奇地简单。
起因
我有个小红书链接解析器,从笔记里扒标题、作者、图片之类的信息。某天有人反馈一个分享链接解析失败:
用 aiohttp 请求,跟随重定向,最后落到 /404?errorCode=-510001。浏览器打开同一个链接完全没问题。
怎么回事?
短链重定向和 xsec_token
curl 跟踪一下重定向链:
短链先 302 到笔记页面,但笔记页面又 302 到 404。关键参数是 URL 中的 xsec_token——这是小红书的防盗链令牌,服务端会校验它与当前会话的匹配关系。
在浏览器中观察到一个有趣的现象:无痕模式下第一次访问也是 404,但刷新一次就正常了。对比两次请求的差异,第二次多出了一批 Cookie:
这些 Cookie 不是服务端通过 Set-Cookie 下发的,而是由页面中的 JavaScript 生成的。纯 HTTP 客户端拿不到它们。
换个 UA 试试
桌面端要 JS 生成 Cookie 才能过校验,那我换个思路,伪装成手机:
直接返回 HTTP 200,页面完整,不需要任何额外 Cookie。
想想也合理。App 内的 WebView 不一定能跑完整的反爬 JS,所以移动端分享链接(app_platform=ios)走了一条更宽松的路径,不校验 xsec 相关的 Cookie。
解析是成功了,但图片全带水印。
URL 长这样:
!h5_1080jpg 是图片处理指令,CDN 层面渲染水印。去掉后缀也没用,签名校验会直接 403。水印是图片处理管线的一部分,不是客户端加上去的。
硬刚 API 协议
既然移动端页面有水印,那试试直接调 API 拿原始数据。
找到一个开源项目 RedCrack,实现了小红书的 Web API 协议:通过 edith.xiaohongshu.com 的 /api/sns/web/v1/feed 接口拿笔记 JSON,返回的图片 URL 没有水印。
听起来很美好。但要走通这条路,得先过好几关:
Cookie 生成链
请求签名
每个 API 请求还要带 5 个签名头:
| Header | 生成方式 |
|---|---|
x-s | MD5(url+body) → XOR → Base58 编码,外层自定义 Base64 |
x-s-common | ARC4 加密指纹子集 → 自定义 Base64 |
x-b3-traceid | 16 位随机 hex |
x-xray-traceid | 时间戳左移 + 序列号 + 随机数 |
x-t | 当前毫秒时间戳 |
x-s 的核心是 _encrypt_x3,对应浏览器里 window.mnsv2() 的输出。这个函数本身跑在一个 JSVMP(JS 虚拟机保护)里,不是普通 JS 能直接读的。
碰壁
我把 RedCrack 的加密逻辑提取成独立模块 xhs_encrypt_helper.py(约 325 行),又写了 xhs_api_client.py 做完整的会话初始化和 API 调用。
Cookie 生成链倒是跑通了,a1、webId、websectiga、gid、web_session 全部拿到。但一调 feed 接口:
461 是小红书的 "访问异常" 状态码,附带 Verifytype: 216。
再一测,RedCrack 原项目本身也在同一步挂了。webprofile 端点返回 471,base64 解码 Verifymsg 后:
分析线上 vendor-dynamic.js,ARTIFACT_VERSION 从 4.83.1 变成了 6.3.0。更新版本号后 webprofile 恢复正常,但 feed 接口还是 461。
根本原因:mnsv2 的 JSVMP 字节码在服务端更新过了。小红书定期换 VM 指令集,之前逆向出来的 _encrypt_x3 函数签出来的东西已经不被接受。
API 这条路走不通了。
CDN URL 重写
回过头来看移动端 HTML 方案。图片有水印,但仔细拆解一下 URL:
202604070308 是时间戳,后面的 hash 是防盗链签名,!h5_1080jpg 是图片处理指令(缩放 + 水印)。
然后我发现小红书有另一个 CDN 域名 sns-img-qc.xhscdn.com,直接按图片 ID 出原图,不要签名,不带水印:
试了几种组合:
| URL 模式 | 状态 | 水印 | 需要签名 |
|---|---|---|---|
sns-webpic-qc.xhscdn.com/DATE/SIGN/ID!h5_1080jpg | 200 | 有 | 是 |
sns-webpic-qc.xhscdn.com/DATE/SIGN/ID (去掉后缀) | 403 | - | - |
sns-img-qc.xhscdn.com/ID | 200 | 无 | 否 |
ci.xiaohongshu.com/ID | 200 | 无 | 否 |
所以最终方案就很简单了。从移动端 HTML 的轮播图里提取图片 URL,解析出 IMAGE_ID,换个域名拼上去:
全部图片拿到无水印版本,且都可以直接访问。
小红书的反爬长什么样
这次折腾下来,大致能画出小红书的防护层级:
| 层级 | 机制 | 说明 |
|---|---|---|
| 1 | xsec_token | URL 级别的防盗链令牌,绑定会话 |
| 2 | JS Cookie 生成 | a1/webId/websectiga/gid 等由 JS 运行时生成 |
| 3 | 浏览器指纹 | 80+ 字段的设备指纹,DES 加密上报 |
| 4 | 请求签名 (x-s) | JSVMP 虚拟机执行,定期更换字节码 |
| 5 | TLS 指纹 | 检测非浏览器的 TLS ClientHello 特征 |
| 6 | 版本校验 | ARTIFACT_VERSION 过期直接 471 |
| 7 | 行为分析 | 频率、轨迹、时序等综合风控 |
第 4 层是真正的硬骨头。mnsv2 把签名算法编译成自定义字节码,跑在一个 JS 虚拟机里。要逆向它,你得:
- 从
vendor-dynamic.xxx.js里找到 VM 解释器 - 提取字节码数组
- 逐条指令模拟执行,还原算法逻辑
- 用 Python 重写
但小红书只要更新字节码数组(VM 结构不变),之前的逆向就全废了。攻击者每次都得从头来,防御者改个数组就行。成本不对称。
几种绕过思路的对比
| 方案 | 难度 | 稳定性 | 水印 |
|---|---|---|---|
| 移动端 UA + CDN 重写 | 低 | 高 | 无 |
| Playwright/Puppeteer 跑真实浏览器 | 中 | 高 | 无 |
| 逆向 JSVMP 签名算法 | 极高 | 低(随时失效) | 无 |
| 调用第三方解析 API | 低 | 取决于第三方 | 无 |
最后选了第一种:移动端 UA 拿 HTML + CDN URL 重写。不依赖 JS 执行,不需要逆向签名,版本更新也不影响。除非小红书关掉 sns-img-qc 这个 CDN,或者移动端页面大改。
技术细节
这部分是给自己(或者后来的人)留的笔记,不感兴趣可以跳过。
Cookie 怎么生成
a1:hex(timestamp_ms) + 30 位随机字符 + 平台码 "50000" + CRC32 校验,截取前 52 位。
websectiga:POST /api/sec/v1/scripting 拿到一段混淆 JS,里面有 base64 编码的查找表和索引数组,按特定偏移量解密出 64 位密钥字符串。
gid:把 80 多个字段的浏览器指纹打成 JSON → Base64 → DES-ECB 加密(密钥 zbp30y86)→ hex,POST 到 webprofile 接口。
x-s-common:取指纹子集 → JSON → ARC4 加密(密钥 xhswebmplfbt)→ URL 编码 → 自定义 Base64(码表 ZmserbBoHQtNP+wOcza/...)。
版本号
vendor-dynamic.js 里的 getArtifactInfo 函数硬编码了版本号,可以用正则 artifactVersion.*?(\d+.\d+.\d+) 从线上 JS 里提取。
代码结构
async_xhs.py 的调用流程:
免责声明
本文仅记录个人在技术学习过程中的探索和思考,用于安全研究和教育目的。文中涉及的所有技术分析均针对公开可访问的页面和网络请求,未涉及任何非授权访问、数据批量抓取或商业用途。请遵守相关平台的服务条款和当地法律法规,不要将文中内容用于侵犯他人合法权益的场景。
后言
这次折腾最大的教训:别一上来就往最复杂的方向钻。
我的思路是 “桌面端需要 Cookie → 那就生成 Cookie → Cookie 有了就签名请求 → 签名不对就逆向 JSVMP”,一路走到死胡同。最后的解决方案在完全不同的方向上:换个 UA,换个 CDN 域名。
sns-webpic-qc(带签名、有水印)和 sns-img-qc(裸 ID、无水印)大概是给不同业务用的。前者面向用户浏览,后者可能是给内部服务或 App 原生渲染用的。后者不需要签名,大概是因为本来就不打算对外暴露。但移动端 HTML 里的图片 ID 把这两套系统连起来了。
这扇门未来可能会关上。但至少今天,它还开着。