ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python还原小红书签名算法:从JS逆向到稳定采集

Python还原小红书签名算法:从JS逆向到稳定采集

简介:这是一份面向Python爬虫与逆向工程学习者的实战型资源,聚焦小红书平台数据采集与算法还原,适合具备一定Python基础、希望深入理解前端加密与接口模拟的中高级开发者。资源包共29个文件,以11个py源码文件为核心,辅以yml、txt、md、rst等配置与文档,以及Makefile、bat等构建脚本,整体约28KB,结构紧凑、便于快速上手。项目围绕x-s、x-common等关键参数的Python还原展开,封装了GET、POST等请求类型与小红书API接口,并涉及JavaScript逆向解密思路,帮助读者理解从请求构造到数据解析的完整链路。目录中core.py、help.py、exception.py等模块划分清晰,tests与docs提供了测试用例与使用说明,requirements.txt与setup.py则方便环境复现。目前已有4451人学习下载,对于想研究社交媒体数据挖掘、提升爬虫性能与逆向分析能力的开发者而言,是一份可直接参考的算法还原范例。

1. 从一次翻车说起:为什么我要把小红书采集的算法还原讲透

去年帮一个做内容分析的朋友搭数据管道,目标很明确:按关键词抓小红书的笔记列表和详情,做选题趋势。我一开始想得很简单,requests 加个 UA 就完事,结果第一轮跑下来,返回的全是空壳页面,字段一个都拿不到。换成 Selenium 渲染,能出内容了,但速度慢到离谱,一百条笔记要跑好几分钟,而且跑着跑着就弹验证。那次是真翻车,血泪经验就是一句话:小红书的接口不是靠请求头就能糊弄过去的,它有一层前端 JavaScript 算出来的签名参数,你不把这层算法还原出来,采集就是玄学。

这篇要讲的就是这件事——用 Python 把小红书 Web 端那套请求签名算法还原出来,让采集从"能跑"变成"稳定可控"。适合两类人:一类是刚接触 JavaScript 逆向、想找一个真实站点练手的新手,照着思路能一步步复现;另一类是已经在做采集、但被签名参数卡住的熟手,能看到我判断参数来源、定位加密入口的底层逻辑。我不会给你一个"复制粘贴就能用"的脚本,那种东西活不过一周,我要给的是能自己迭代的方法。

2. 先搞清楚要还原什么:签名参数的定位与判断依据

2.1 从请求里反推:哪些参数是"算"出来的

采集的第一步不是写代码,是打开浏览器开发者工具,把一次真实的笔记列表请求完整看一遍。你会看到 URL 上挂着一串 query 参数,其中大部分是业务参数,比如关键词、分页游标、页码,这些是你自己能控制的。但总有那么一两个参数,长得像一坨无规律的十六进制字符串,长度固定,每次请求都不一样,而且你手动改一个字符,服务端立刻返回签名错误。

我一般会这么判断:把同一个请求重放两次,对比哪些参数变了。业务参数不变,变的那个就是签名。小红书 Web 端的签名通常挂在请求头里,名字类似x-s、x-t这种,配合一个时间戳参数一起用。判断依据有三条:一是它随请求变化,二是它和请求体、时间戳存在关联,三是删掉它服务端直接拒绝。三条都满足,基本可以锁定它就是你要还原的目标。

这里有个容易踩的坑:很多人一上来就盯着 URL 参数找,忽略了请求头。签名参数放请求头是更常见的做法,因为放 URL 里容易被日志和代理记录下来。所以定位的时候,请求头和 query 都要过一遍,别漏。

2.2 定位加密入口:断点怎么下才不白费功夫

锁定参数之后,下一步是找到它在哪段 JS 里被生成。最笨也最有效的办法是全局搜索参数名,比如搜x-s,看它在哪些文件里出现。但小红书的前端代码是打包压缩过的,变量名全是a、b、c,直接搜参数名往往只能搜到赋值的地方,搜不到计算逻辑。

这时候要用 XHR/fetch 断点。在开发者工具的 Sources 面板里,对发起请求的那个接口下断点,然后触发一次请求,代码会停在发请求的那一行。顺着调用栈往上翻,一层层看参数是从哪个变量传进来的,一直翻到它第一次被赋值的地方,那里就是加密入口。这个过程需要耐心,调用栈可能有七八层,中间还夹着各种工具函数。我的经验是,重点看那些参数名有意义的函数,比如带sign、encrypt、token字样的,压缩代码里函数名可能被改,但字符串常量改不了,搜字符串往往比搜函数名管用。

提示:断点调试时把 "Pause on exceptions" 打开,有时候加密逻辑抛异常会暴露关键位置,比一层层翻调用栈快。

2.3 判断算法类型:对称、非对称还是自定义混淆

找到加密入口后,要看它用的是什么算法。常见的就几类:MD5、SHA 系列这类哈希,AES、DES 这类对称加密,RSA 这类非对称,还有就是纯自定义的字符串拼接加混淆。判断方法很简单,看代码里有没有明显的常量,比如 MD5 的初始向量、AES 的 S 盒、RSA 的公钥字符串。如果看到一长串十六进制常量,大概率是标准算法;如果全是位运算和字符串拼接,那就是自定义混淆。

小红书这类站点的签名,通常是"哈希 + 自定义拼接"的组合。它会把你请求里的关键字段按固定顺序拼成一个字符串,加一个盐值,再做一次哈希,最后截取或编码。盐值往往硬编码在 JS 里,是个固定字符串。你要做的就是把这个拼接顺序、盐值、哈希方式全部还原出来。这一步是整个逆向的核心,也是最容易出错的地方——拼接顺序错一个字段,结果就完全对不上。

3. 用 Python 还原算法:从 JS 逻辑到可运行代码

3.1 把 JS 加密逻辑翻译成 Python

假设你已经定位到加密函数,逻辑大致是这样:取请求方法、路径、时间戳、请求体,按method + path + timestamp + body + salt的顺序拼接,做一次哈希,再和 timestamp 拼成最终签名。翻译成 Python 就是几行代码的事,但细节决定成败。

import hashlib import time def build_signature(method, path, body, salt): # 时间戳用毫秒,很多站点签名里带时间戳做防重放 timestamp = str(int(time.time() * 1000)) # 拼接顺序必须和 JS 里完全一致,错一个字段结果就废 raw = method + path + timestamp + body + salt # 哈希方式要和 JS 对齐,注意 JS 里可能是 MD5 也可能是 SHA256 sign = hashlib.md5(raw.encode("utf-8")).hexdigest() return sign, timestamp

这段代码的关键不在语法,在三个对齐点:拼接顺序、时间戳精度、哈希方式。拼接顺序要严格照抄 JS,包括字段之间有没有分隔符、大小写是否敏感。时间戳精度要对,JS 里Date.now()是毫秒,Python 的time.time()是秒,必须乘 1000。哈希方式要对,JS 里常见的CryptoJS.MD5对应 Python 的hashlib.md5,但要注意 JS 的 MD5 输出默认是小写十六进制,Python 的hexdigest()也是小写,这点一致。

参数说明:method是大写请求方法,path是不含域名的路径,body是请求体的原始字符串(注意是原始字符串,不是字典序列化后的,顺序要和实际发送的一致),salt是从 JS 里抠出来的固定盐值。这四个参数任何一个不对,签名都过不了。

3.2 处理 JS 特有的编码和位运算

Python 和 JavaScript 在字符串编码、位运算上有不少差异,这是还原时最容易翻车的地方。JS 的字符串是 UTF-16,Python 3 的字符串是 Unicode,编码成 bytes 时默认 UTF-8,大部分情况一致,但遇到中文和特殊字符时可能出问题。如果签名里包含中文关键词,要确认 JS 那边用的是encodeURIComponent还是别的编码方式,Python 这边对应urllib.parse.quote。

位运算的坑更大。JS 的位运算默认按 32 位有符号整数处理,Python 的整数是任意精度,直接照搬会出错。比如 JS 里a << 2溢出后会截断,Python 不会。遇到这种情况,要在 Python 里手动做 32 位截断:

def to_int32(x): # 模拟 JS 的 32 位有符号整数截断 x = x & 0xFFFFFFFF if x >= 0x80000000: x -= 0x100000000 return x

这个函数在还原带位运算的混淆算法时几乎必用。判断依据是:如果 JS 代码里出现了<<、>>、>>>、&、|、^这些运算符,且操作数可能超过 32 位范围,就要考虑截断问题。我一般会先按 Python 原生算一遍,和 JS 结果对比,对不上再逐个加截断,这样定位最快。

3.3 用 Python 调用 JS:什么时候该偷懒

有些站点的加密逻辑极其复杂,几百行混淆代码,硬翻译成 Python 要花好几天,还容易出错。这时候可以考虑用 Python 直接调用 JS 引擎,比如PyExecJS或者mini-racer,把抠出来的 JS 代码丢进去执行,拿返回值。

import execjs # 把从站点抠出来的加密函数保存成 sign.js with open("sign.js", "r", encoding="utf-8") as f: js_code = f.read() ctx = execjs.compile(js_code) # 调用 JS 里的加密函数,参数按 JS 函数签名传 sign = ctx.call("getSign", method, path, timestamp, body)

这种方式的优点是快,抠出来就能用,不用理解每一行逻辑。缺点是依赖 JS 运行环境,部署时多一层依赖,而且如果 JS 里有浏览器特有的对象(比如window、document、navigator),直接跑会报错,需要补环境。补环境是个技术活,要 mock 掉这些对象,让 JS 以为自己跑在浏览器里。我的建议是:算法简单就翻译成 Python,一劳永逸;算法复杂且短期要用,就先调 JS 顶上,后面再慢慢翻译。

注意:补环境时navigator.userAgent、window.location这些是最常被检测的,mock 的值要和真实浏览器一致,否则签名算出来也是错的。

4. 采集链路的稳定性:签名之外的工程问题

4.1 请求频率与验证码的应对

签名还原出来只是第一步,能稳定跑起来是另一回事。小红书的服务端有风控,请求频率过高会触发验证码,返回一个需要滑动的页面。这时候光有签名没用,得处理验证码。常见的思路是降低频率,加随机延时,模拟真人操作节奏。我一般会把请求间隔设在 2 到 5 秒之间随机,单账号每小时请求数控制在合理范围。

如果还是触发验证码,就要考虑接入打码平台或者自己训练识别模型。打码平台是花钱买时间,识别模型是花时间省钱,看你的场景。对于采集量不大的场景,降频加延时基本够用;量大就得做账号池和 IP 池,把请求分散开。这里不展开账号池的细节,那是另一个话题,但你要知道签名只是入场券,风控才是持久战。

4.2 数据解析与字段提取

拿到响应之后是解析。小红书的接口返回是 JSON,结构比较深,笔记列表在data.items下面,每条笔记有note_card字段,里面是标题、作者、点赞数这些。解析的时候要注意字段可能缺失,比如有的笔记没有标签,有的没有地理位置,直接按固定路径取会 KeyError。稳妥的做法是用.get()逐层取,或者写个安全取值函数。

def safe_get(data, path, default=None): # 按点分路径安全取值,避免中间层缺失导致报错 keys = path.split(".") for key in keys: if isinstance(data, dict): data = data.get(key) elif isinstance(data, list) and key.isdigit(): idx = int(key) data = data[idx] if idx < len(data) else None else: return default if data is None: return default return data title = safe_get(item, "note_card.display_title", "")

这个函数看着简单,但在实际采集里能省掉大量异常处理。判断依据是:只要接口返回结构不是 100% 稳定,就该用安全取值。小红书的接口字段偶尔会调整,写死路径的代码过段时间就崩,用安全取值至少不会整个流程挂掉。

4.3 增量采集与去重

采集做久了,数据会重复。同一篇笔记可能在不同关键词下都出现,同一个作者的新笔记要增量抓。去重一般用笔记 ID,存到本地数据库或者集合里,抓之前先查一下有没有。增量的话记录上次采集的时间戳或者游标,下次从那里继续。

我一般会用 SQLite 存,轻量,不用额外部署。表结构就几个字段:笔记 ID 做主键,标题、作者、点赞数、采集时间。每次抓之前SELECT一下,存在就跳过。这样跑久了也不会重复。游标的话,小红书的列表接口一般返回cursor字段,下一页请求带上它就行,注意游标可能过期,过期了要重新从第一页开始。

5. 避坑指南:还原过程中最常见的五个问题

5.1 签名对不上,但代码看起来一模一样

现象:Python 算出来的签名和浏览器里抓到的对不上,逐行对比 JS 逻辑也没发现差异。原因:大概率是编码或时间戳精度问题。JS 里字符串拼接时可能隐式调用了toString(),或者时间戳用了秒而不是毫秒。解决:把 JS 里的中间变量打印出来,和 Python 的中间结果逐个对比,定位到具体哪一步开始分叉。我一般会在 JS 里插console.log,在 Python 里插print,两边同步输出,最快。

5.2 本地能跑,服务器上签名失效

现象:本地调试一切正常,部署到服务器后签名全部报错。原因:服务器时间不同步,时间戳偏差太大,服务端判定签名过期。解决:服务器上装 NTP 同步时间,或者签名时用服务端时间而不是本地时间。这个坑很隐蔽,因为本地开发机时间通常是准的,服务器不一定。

5.3 请求头缺了某个字段导致被拒

现象:签名算对了,但请求还是返回错误。原因:除了签名,服务端还校验其他请求头,比如Referer、Origin、User-Agent,缺一个都可能被拒。解决:把浏览器里完整的请求头复制下来,逐个比对,缺什么补什么。特别注意Referer必须是站内页面,不能是空的。

5.4 JS 代码更新导致算法失效

现象:昨天还能跑,今天突然全部签名错误。原因:站点更新了前端代码,加密逻辑变了,盐值或者拼接顺序改了。解决:重新走一遍定位流程,对比新旧 JS 代码,找到变化的地方。这种更新通常不会大改,可能只是换个盐值或者加个字段,改几行就能恢复。建议把 JS 代码版本管理起来,方便对比。

5.5 补环境不完整导致 JS 执行报错

现象:用execjs跑抠出来的 JS,报window is not defined之类的错。原因:JS 里引用了浏览器特有对象,Node 环境里没有。解决:在 JS 代码开头 mock 这些对象,比如var window = {}; var navigator = {userAgent: "..."};。mock 的值要尽量真实,尤其是userAgent,很多站点会校验它和签名是否匹配。

6. 最后说一个技巧:用对比法快速验证还原结果

还原算法最怕的就是"看起来对了但实际不对"。我的习惯是,每还原一步就做一次对比验证,而不是全部写完再测。具体做法是:在浏览器里触发一次真实请求,把请求的输入参数(method、path、timestamp、body)和输出的签名都记下来,然后在 Python 里用同样的输入跑一遍,看输出是否一致。一致就继续下一步,不一致就停下来查。

这个对比法有个关键点:输入要固定。时间戳是变的,所以对比的时候要么把时间戳也当成输入传进去,要么在 JS 和 Python 里都 mock 一个固定时间戳。我一般会在 JS 里临时把Date.now()替换成固定值,Python 里也传同样的值,这样两边输入完全一致,输出不一致就一定是算法问题,排除时间干扰。

# 固定输入做对比验证 fixed_ts = "1700000000000" fixed_body = '{"keyword":"test","page":1}' js_sign = "从浏览器抓到的签名" py_sign, _ = build_signature("POST", "/api/sns/web/v1/search/notes", fixed_body, SALT, fixed_ts) assert js_sign == py_sign, f"签名不一致: JS={js_sign}, PY={py_sign}"

这段断言代码我每次还原新算法都会写,跑通了才继续。参数说明:fixed_ts是固定的时间戳,fixed_body是固定的请求体,SALT是从 JS 里抠出来的盐值。断言失败时把两个值打出来,逐字符对比,通常能发现是大小写、编码还是顺序的问题。

回过头看,那次翻车教会我一件事:采集这类事,别想着走捷径,签名算法该还原就得还原,调 JS 只是权宜之计。从那以后我每次做新站点,都强制先走一遍"定位参数、下断点、翻译算法、固定输入对比"这个流程,一步都不省。这套流程跑熟了,大部分站点的签名都能在半天内搞定。希望这份还原思路能帮到你,少走点我当年走过的弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进