简介:针对小红书平台数据采集的Python算法还原项目,面向爬虫开发者与JavaScript逆向学习者,核心目标是还原x-s、x-common签名参数的生成逻辑,并对平台各类请求接口进行抽象封装,支持GET、POST等常见请求类型,从而在不深入理解内部机制的前提下实现毫秒级、高频率数据抓取,大幅降低采集门槛,做到开箱即用。资源包共29个文件,以11个Python脚本为核心,涵盖核心算法、工具函数与测试用例;同时包含YAML/TOX等持续集成配置、RST/Markdown格式的开发文档以及Makefile构建脚本,整体仅28KB,结构紧凑、轻量易用。目前已有4445人学习,适合具备一定Python爬虫基础、希望进阶研究JavaScript逆向与反爬对抗的开发者。项目提供可直接调用的封装接口、完整的算法还原思路、单元测试与项目文档,并预留了CI工作流与多环境配置,既能作为小红书数据采集的即用方案,也可作为学习爬虫封装与逆向工程的参考样板。
1. 采集卡在签名:许多爬虫翻车的地方不是网络,而是算法黑匣子
一份能正常跑通的数据采集脚本,真正的门槛并不是网络请求怎么写,而是那一串坐在请求头里的签名参数。很多开发者在写 xhs 这类平台采集时,第一步就会撞上一个反直觉现象:浏览器里能看到完整的数据接口,用 requests 按同样的 URL 和参数构造请求,返回的却是一段加密提示或直接拒绝访问。问题不在别处,就在采集脚本复现了请求体,却复现不了那套由 JavaScript 动态生成的签名算法。
这套算法在运行时像一个黑匣子:输入时间戳、设备指纹和请求路径,输出一段定长字符串。只要这段字符串对不上服务端的校验规则,请求就会被当作异常流量拦截。搞懂它,意味着你得同时掌握 HTTP 请求的组装逻辑、JavaScript 逆向的断点定位方法,以及如何在 Python 侧把这些算法完整复刻出来。这不是单纯的“爬虫JavaScript逆向”搜几个函数名就能解决的,它背后是一个完整的签名机制和一套不断迭代的风控策略。
2. 签名机制:请求参数背后为什么要叠加多层算法
2.1 从抓包看签名参数:那些 x 开头的请求头是拼出来的
打开浏览器开发者工具,过滤出 xhs 的列表接口,把请求头逐条展开,会看到一串以 x 开头的自定义字段。常见的一组是时间戳、签名值、设备标识和平台编号。关键点在于:这些字段不是一次性写死在请求里的,而是由页面里的 JavaScript 在该次请求发出前临时计算出来的。
时间戳字段的语义是“当前时间的毫秒数”,用于告诉服务端本次请求的发生时刻。签名值则由一个较长的算法链生成,通常包含对请求路径、请求方法、时间戳和设备信息的摘要计算。设备标识则来自浏览器本地存储或 cookie,是服务端用来关联访问者的维度之一。三者缺一不可,且相互绑定:换个时间戳,签名值就失效;改个请求路径,签名值也对不上。
这里值得注意的一个设计动机是:服务端并不希望每个请求都重新走一遍完整鉴权流程。它用签名来做轻量级的“一次性校验”,只要客户端提供的签名能通过固定算法的验算,就默认这个请求来自真实客户端。这也解释了为什么纯 requests 模拟请求总会失败——你伪造得了参数,但伪造不了那一层由算法动态生成的“凭证”。
2.2 签名生成的通用链路:参数排序、序列化、加密与拼接
在拿到一份算法还原后的源码后,会发现签名生成通常遵循一条固定链路。先收集参与签名的原始字段,把它们按字典序排序;然后按字段名和值拼接成字符串;接着对拼接结果做一次摘要计算;最后把摘要结果与某个固定盐值或密钥再做一次组合运算,得到最终签名。每一步都有讲究,不能跳步。
def build_x_s(payload: dict, timestamp_ms: str, secret_key: str) -> str: # 1. 过滤空值并按键名排序 items = sorted((k, str(v)) for k, v in payload.items() if v is not None) # 2. 拼接成 key=value&key=value 的形式 raw = "&".join(f"{k}={v}" for k, v in items) # 3. 加入时间戳与路径信息 raw = f"{raw}&ts={timestamp_ms}" # 4. 用固定密钥做一次 HMAC 摘要 return hmac.new(secret_key.encode(), raw.encode(), hashlib.sha256).hexdigest()这段代码还原了签名生成的主流程:参与签名的字段要先排好序,时间戳作为额外字段追加在尾部,最后用密钥做 HMAC 摘要。这个过程中最容易翻车的地方在于拼接格式,键值之间是用等号还是冒号,字段之间是用与号还是无分隔符,服务端实际上是严格匹配的。还原成 Python 时,必须拿着抓包得到的真实请求头接口请求头逐一比对,少一个分隔符都会导致签名校验失败。
常见的实现思路里,密钥通常不是明文暴露的,而是被藏在一个经过混淆的 JavaScript 对象中,或者经过若干次异或、数组取值的运算才拿到。算法还原的工作量,大部分耗在从混淆代码里找回这把隐藏密钥,以及确认它的拼接顺序。
2.3 多重摘要的目的:让签名不被轻易伪造
如果只是简单做一次 MD5,攻击者对着一份请求日志就能推算出规律。因此实际采集目标上的签名机制往往在一个摘要之上再叠加一层摘要,甚至穿插位移与异或操作。这种多层设计的目标不是让算法“不能被破解”,而是让破解成本抬升到超过采集收益。
一个典型的双层签名流程是:先对请求体做 SHA256 得到第一段摘要,再用第一段摘要与时间戳组合做第二次 HMAC 得到最终签名。第一层负责“锁定请求内容”,第二层负责“锁定时间窗口”。服务端验签时,会重算一遍完整链路,同时比对时间戳是否在合理区间内;超时超过几十秒的请求,即便签名正确也会被拒绝。
这种设计思路在风控里叫“时效绑定”。它保证了同一个签名不能在一小时后再重放,因为时间戳变了之后,第二层 HMAC 的输入也随之改变。这也是为什么在采集 Python 脚本里,每次请求都必须重新计算签名,缓存签名只会让后续请求全部失效。
3. JavaScript 逆向路线:从定位加密入口到用 Python 复刻
3.1 断点定位三步:搜索关键字、调用栈回溯、日志补点
拿到一份采集目标的前端脚本后,第一步不是通读代码,而是精准定位签名生成位置。做法是:在开发者工具里搜索签名参数的名字,比如搜索“x-s”或“x-legacy-id”,找到它被赋值的位置。如果源码经过了压缩和混淆,搜索会命中一堆压缩后的变量名,这时需要先开启格式化,把单行代码展开成可读的多行,再搜索字符串特征。
找到赋值语句后,在那一行打上断点,刷新页面触发一次真实请求。断点命中时,调用栈会告诉你是哪个函数调用了这段赋值逻辑。从调用栈往上回溯两层,通常能看到一个计算签名结果的函数,它接收请求体和时间戳作为参数,返回值直接赋给请求头。此时需要做一次日志补点,在计算函数入口处通过控制台打印入参和中间结果,确认输入输出的对应关系。
// 在计算函数入口处插入日志,观察参与签名的原始字段 function generateSign(params, timestamp) { console.log("[params]", JSON.stringify(params)); console.log("[timestamp]", timestamp); const raw = buildRawString(params, timestamp); console.log("[raw]", raw); const sign = hmacDigest(raw, SECRET_KEY); console.log("[sign]", sign); return sign; }这段日志代码的意义在于把黑匣子变成白盒子:通过打印入参、拼接串和最终签名,逆向人员可以确认服务端期望的拼接格式到底长什么样。如果从断点中看到原始字符串是“a=1&b=2&ts=1700000000000”这种格式,那么 Python 侧就必须严格复现这种顺序。
3.2 AST 反混淆:把压缩和编码后的表达式还原成可读逻辑
大规模前端脚本常见的保护手段是代码压缩与标识符混淆。压缩只去除空格和缩短变量名,逻辑结构不变;混淆则会把函数名改成无意义字符,甚至把字符串拆成数组,运行时再通过索引取回。对于后者,直接阅读源码效率很低,需要借助 AST 工具做一次自动还原。
import subprocess import json def prettify_js(js_code: str) -> str: # 用 Node 的 babel 解析混淆代码,去除无用节点 proc = subprocess.run( ["node", "-e", "const babel=require('@babel/core');" "const code=fs.readFileSync(0,'utf8');" "const out=babel.transformSync(code,{compact:false,minified:false});" "process.stdout.write(out.code);"], input=js_code, capture_output=True, text=True ) return proc.stdoutAST 反混淆的思路是:先把源码解析成抽象语法树,然后遍历节点,简化冗余表达式、还原字符串拼接、展开被调用的函数。这一步做完后,原本面目全非的函数体恢复出基本结构,你就能看出签名算法用的是 HMAC 还是普通摘要。以常见实现来说,整个还原流程的耗时占比中,AST 预处理占到一半以上,真正的算法识别反而很快。
核心要点是不要把 AST 当作万能工具。它只负责恢复结构,不负责解释逻辑。遇到多层嵌套的立即执行函数,还需要配合运行时日志,才能确认哪一段代码实际执行,哪一段只是干扰项。实践中的做法是“AST 粗读 + 运行时精调”,先用工具把整体结构理顺,再靠断点和日志把关键数据流打通。
3.3 补环境与 RPC:两种主流的签名复用风格
还原出算法逻辑后,落地到 Python 侧有两条路:一条是把算法完整改写为 Python 实现,另一条是用 Node 环境直接执行原版 JavaScript,Python 通过子进程调用取签名结果。前者在算法明确时最干净,后者在全量还原代价高时更省事。
const vm = require('vm'); const sandBox = { window: {}, navigator: { userAgent: 'Mozilla/5.0' }, location: { href: 'https://platform.example.com' }, console, }; vm.createContext(sandBox); // 把抓取到的签名函数源码放到 context 里执行 const scriptCode = ` function getSign(args) { // 原版混淆函数体,直接使用 sandbox 中的对象 return args.join(','); } `; vm.runInContext(scriptCode, sandBox); module.exports = function getSignFromSandbox(args) { return vm.runInContext(`getSign(${JSON.stringify(args)})`, sandBox); };vm 补环境的思路是:创建一个最小的浏览器环境沙箱,把原版脚本丢进去运行。它模拟了 window、navigator、location 等对象,避免脚本因为缺少宿主对象而报错。这种做法的好处是不用理解内部算法,坏处是需要持续维护环境模拟代码,一旦原版脚本引用了新的宿主方法,补环境代码也要同步更新。
RPC 桥接则是另一种取舍:让本机浏览器或一个无头浏览器真正执行脚本,Python 通过网络或进程通信请求签名结果。它的稳定度最高,因为它运行的就是原版环境,但代价是每获取一次签名,可能产生一次真实浏览器初始化耗时。在设计上,多数采集方案会优先尝试完整算法还原,因为纯 Python 实现的请求吞吐量远高于桥接方式,只有还原到“还差最后一层参数搞不定”时,才会把补环境作为兜底方案。
4. 算法落地避坑:风控边界比算法本身更值得研究
4.1 高频采集触发风控:签名对了也能返回加密提示
现象:签名算法还原后,请求能正常返回数据,但连续采集几分钟后,某个接口突然返回一段不可读的加密提示,或直接要求完成滑块验证。
原因:签名通过只代表“请求合法”,不代表“行为正常”。服务端还会按单位时间内的请求量、同一设备标识的活跃度、请求间隔的方差做统计。一段固定间隔的定时请求在风控模型里是很明显的特征,因为真人操作永远有抖动的思考时间。
解决:把请求间隔改为随机区间,比如 3 到 7 秒之间取随机值;限制单设备标识下的并发数;在连续请求之间穿插一次页面访问或滚动动作,让行为曲线更接近真实用户。核心原则是降低单位时间内的请求密度,而不是试图绕过验证码机制。把采集看作对公开内容的低速读取,而不是对接口的高频压测,这样才能让签名算法的还原成果持续生效。
4.2 签名版本迭代:算法升级时的排查路径
现象:某天启动采集脚本,所有请求都返回签名错误,即使重新抓包对比请求头也看不出明显差异。
原因:服务端更新了签名算法,但旧算法生成的签名还在缓存期内,看起来像“没变”;或者新算法在拼接格式里多加了一个字段,而抓包时这个字段因为值为空而被忽略了。
解决:不要盲目重新跑一遍逆向流程。先对比旧请求和新请求的请求头字段差异,然后回到 JS 搜索签名赋值处的调用栈,看新增了什么取值逻辑。常见的是时间戳单位从秒变成毫秒,或者请求头里增加了一个随机数参与摘要计算。定位到差异后,只需要在 Python 侧补上对应字段即可,通常不需要重写整个算法流程。
4.3 设备标识维度被追踪:只关注签名忽略变量
现象:签名算法正确,单机单 IP 采集正常,换到服务器环境后请求频繁失败,即便是低频率也一样。
原因:服务器数据中心的 IP 段和浏览器指纹特征与普通用户差异明显。服务端除了验签,还会比对该 IP 段的历史行为。同一个 IP 段下大量短连接请求,会被直接归入风险类别。
解决:把设备标识视为资源而非固定常量。采集前先构造一批随机但合法的设备标识序列,每次请求随机选用一个;同时控制同一设备标识的请求间隔。IP 维度的处理要遵循目标平台的服务条款,只对公开数据做合理低速读取,不做并发穿透类操作。
4.4 请求头字段缺失:抓包数据不完整导致的假阳性
现象:用例测试中签名校验通过了,但正式运行时数据量总少一截,或者部分接口偶发返回空数据。
原因:抓包时浏览器补全了某些默认请求头,比如 accept-language、referer、origin,而 Python 脚本只保留了抓包中明显可见的 x 开头字段。服务端对缺失字段的请求执行了宽松校验,过滤掉了部分返回值。
解决:在采集脚本中把请求头补齐到与浏览器一致。用浏览器的“复制为 cURL”功能导出完整请求头,然后逐项比对 Python 脚本中的 headers 字典。多数的翻车都发生在 accept-language 或 sec-ch-ua 这类默认字段上,而不是签名本身。
5. 设计取舍:用成本最低的路径维持采集稳定性
5.1 完整算法还原与 RPC 桥接的成本对比
两类实现路径在工程上各有利弊。完整算法还原适合长期采集,因为它不需要额外进程,Python 内部直接计算签名,请求吞吐量和稳定性都可控;但它要求逆向工作做到“最后一公里”,任何一处拼接细节出错都会让全部工作量归零。RPC 桥接则适合初期验证或算法不明朗的阶段,它把计算复杂度转移到真实浏览器里,即使不懂内部逻辑也能跑通请求,代价是每次签名计算都多一次进程间通信,请求速度明显慢。
| 对比维度 | 完整算法还原 | RPC 桥接 |
|---|---|---|
| 计算方式 | Python 原生实现 | Node/浏览器执行原版 JS |
| 请求性能 | 高,无额外进程开销 | 低,受桥接通信耗时影响 |
| 维护成本 | 算法变更时需重新定位 | 需同步维护补环境或桥接服务 |
| 上手门槛 | 需要掌握 JS 逆向 | 熟悉子进程调用即可 |
我一般会建议先跑通 RPC 桥接,确认数据可用性,再逐步把算法替换为 Python 实现。这样既不会因为算法还原的延误而拖垮项目,又能给逆向留出充足时间。如果目标是长期稳定的数据通道,最终一定要落在完整算法还原这条路上,因为桥接方式在并发场景下的瓶颈会很明显。
5.2 验证当前实现是否健康的三个信号
验证采集实现是否被风控盯上,看三个信号就够了。第一个是返回数据条数曲线,正常情况应该稳定在接口返回上限附近;如果偶发返回一半或不足上限,说明服务端在降级响应。第二个是错误码分布,持续观察一段时间的状态码,如果某些特定状态码开始零星出现,说明行为模型正在调整。第三个是签名校验失败率,它不应该有突然跳变的台阶,正常的失败率在低个位数,如果一夜之间升到两位数,说明算法版本已经更新。
这三个信号可以写进采集进程的日志里,作为健康度指标定期输出。一旦发现降级,优先降低采集频率而不是加大并发去试探,多数限流是动态的,把频率降下来之后,风险等级会自动回退。
5.3 一段强制验证习惯
从我开始做采集相关项目起,每次改完算法逻辑,都会强制走一遍“旧请求回放”的验证流程:把抓包保存的原始请求重新发出三遍,确认返回一致;再用新算法生成的签名替换其中一次,确认服务端接受。这一步看起来笨,却能在上线前拦截掉大部分签名拼接细节错误。从那以后,数据采集脚本的翻车率明显下降。做一个复杂算法还原时,最不值得踩的就是“明明签名格式只差一个冒号,却花了两天去查”。希望这整套签名机制与验证习惯的拆解,能帮你在处理同类数据采集目标时,少走一段弯路。
本文还有配套的精品资源,点击获取