JavaScript逆向与算法还原:Python爬虫签名参数工程化指南

发布时间:2026/10/26 2:06:29
JavaScript逆向与算法还原:Python爬虫签名参数工程化指南

简介:这份资源面向具备一定爬虫基础的Python开发者与逆向分析学习者,重点解决小红书平台数据采集中加密参数难以还原、请求逻辑复杂的问题。项目以Python为主体,围绕x-s、x-common等关键参数的算法还原与接口封装展开,同时涉及前端JavaScript逆向思路,把常见请求类型与平台接口抽象成可直接调用的函数,让使用者无需深究内部机制即可完成数据抓取,属于偏进阶的实战型代码参考。压缩包共29个文件,以11个py源码文件为核心,配合yml、yaml、ini、cfg等配置与工程文件、txt与in依赖清单、md与rst说明文档,另有Makefile、bat脚本及license许可文件,整体约28KB,结构精简、便于按模块阅读。目前已有4451人学习下载。读者可获得参数还原与接口封装的可运行脚本、异常处理与辅助工具的实现参考,以及依赖配置和文档说明,适合用来理解小红书采集链路的组织方式并迁移到自身项目。

1. 从一次半途而废的采集脚本说起:算法还原到底值不值得学

第一次写采集脚本的时候,我以为难点在requests发包,后来才发现发包是最简单的一步——真正让人卡住的是那个每次都变、看不出含义的签名字段。python 爬虫写到一定阶段,绕不开 JavaScript 逆向:接口地址明明白白摆在 Network 面板里,参数就是算不出来。这篇不聊怎么去刷某个站点的数据,聊的是「算法还原」这件事本身的判断依据和动手路径:一份采集方案值不值得花时间学、混淆代码该从哪下手、Python 侧怎么工程化、以及我在几个坑里交过的学费。写给我自己踩过的那些坑,也写给正在纠结要不要啃逆向的你。

2. 判断一份采集方案值不值钱:四个维度先过一遍

拿到任何一份带 JS 逆向成分的采集代码,我一般不会先跑,而是先花二十分钟做判断。原因很实际:跑通一次的成本很低,维护三个月的成本很高,很多方案在「跑通」这一步就已经把后面所有坑埋好了。下面四个维度是我筛方案、也是我评估自己要不要投入时间的固定顺序,从数据入口一直看到合规边界。

2.1 维度一:数据入口是服务端渲染还是客户端渲染

这一步决定你要不要碰逆向。打开目标页面,先禁用 JS 看内容还在不在;再直接看源码里有没有完整的数据列表。如果服务端已经把数据渲染进 HTML,那这就是个解析问题,跟算法还原没关系,用lxml或者BeautifulSoup就能解决,稳定性还高。如果源码里是空壳、数据全靠首屏的几个 XHR 拉回来,那才进入下一层判断。

接着看这些 XHR 的载荷有没有可疑字段。常见的可疑字段形态是:一个短字符串(可能叫sign、token、_t)、一个十位或十三位数字(时间戳)、一个明显经过编码的串。把同一个请求在浏览器里重复发两次,看哪个字段变了——变的那个就是算出来的,没变的多半是常量或缓存。这一步不需要任何工具,人眼对比就够。

我踩过的第一个坑就在这里:早期我看到一个接口带sign,直接抄了一次请求的 header 去重放,当天能用,第二天全挂。后来才明白,sign里拼了时间戳和递增序号,服务端会校验时效窗口。所以判断一个接口的逆向难度,不看字段名,看它是否随时间变化、变化粒度是秒级还是请求级。秒级的多半是时间戳拼接,请求级的多半带序号或随机数,两者的还原思路完全不同。

这一步还要顺手记录一件事:接口地址本身。很多方案把 URL 写死在代码里,看着省事,实际上是最脆的写法。我见过一个接口在一个月里换了三次路径前缀,写死 URL 的脚本全部失效,而按「路径模板 + 参数化」组织的代码只改一行配置就能接回来。接口会变,算法思路不会变,这也是我认为还原比抓包更值钱的核心原因。

2.2 维度二:还原深度——补环境能跑,和讲得清算法,是两回事

这是我最看重的一维,也是最容易被新手忽略的一维。同样是「能跑」,方案质量差距可能有三四倍。

第一档是补环境。把混淆过的 JS 文件丢进 Node,缺失的window、document、navigator一个个补,报错一个补一个,直到能跑出签名。这一档上手最快,几十分钟就能出结果,代价是你完全不知道那段代码内部发生了什么。一旦目标脚本更新,报错信息会变成新的字段名,你得重新补一遍。这类代码我称之为黑匣子:它今天能出数,但它为什么不工作,你没有任何线索。

第二档是读懂逻辑。把关键的十几行抽出来,搞明白拼接顺序、分隔符、参与运算的常量、是否做大小写转换、有没有截取前多少位。读懂之后再自己实现一遍,Python 侧能独立复算。这一档前期慢,可能要多花一整天,但它的维护成本极低:目标变了,你只需要看差异在哪,而不是从头再猜一遍。

判断一份方案属于哪一档,有个很简单的办法:看它有没有测试用例。如果代码里有一组固定的输入和对应的期望输出,那作者至少验证过自己的理解;如果整份代码只有一个main()从头跑到尾,中间没有任何断言,那大概率是第一档。我自己现在写这类代码,必带一个test_sign.py,把三组输入输出钉死,改任何东西之前先跑它。

提示:补环境不是错,它是必经的中间状态。错的是停在补环境这一档就交付,尤其是交付给别人。

2.3 维度三:工程化程度,决定它是脚本还是工具

一份采集代码从「我今天跑了一次拿到数据」到「每天定时跑、错了能自己恢复、数据能对上」,中间的距离比大多数人想的长。我评估一份方案的工程化程度,看五个点:有没有限速、有没有重试、有没有断点续跑、字段有没有 schema、日志里能不能看出「少抓了多少」。

限速和重试是最容易补的,也最容易被跳过。很多公开的示例代码里是一个for循环加requests.get,中间没有任何sleep。这种代码在本地跑十条数据看不出问题,跑一万条立刻出状况。断点续跑更关键:一个跑八小时的任务,跑到第七小时失败,如果只能从头再来,那这份代码的实际可用性是很低的。

schema 这块我要多说一句。新手容易把抓到的 JSON 直接整条塞进库,或者塞进一个宽表,字段名跟着接口走。接口一改名,下游全部返工。我习惯的做法是先定义一层自己的字段映射,接口字段名只出现在解析层,落库用我自己的名字。多写二十行映射,后面能省掉很多次数据迁移。

日志里能看到「少抓了多少」,是判断作者有没有真正跑过长时间任务的标志。只打印成功的条数没有意义,有意义的是:请求总数、成功数、失败数、被限速跳过数、解析失败数、去重后入库数。这五个数字凑齐,任务跑挂了你看一眼日志就知道卡在哪一环。

2.4 维度四:合规与稳定性,先想清楚再动手

这一维不是套话,它直接决定你的代码能活多久。我在评估任何一份采集方案时,会先问三个问题:数据是不是公开可见的、需要不需要登录态、抓的量和频率合不合理。凡是要求绕过登录、伪造身份、绕过访问控制的,我一律不做,也不建议别人做——这类代码的生命周期通常以天计,而且风险不对等。

还有一层是技术上的稳定性。就算完全合规,靠逆向拿数据也天然比调公开接口脆:目标改一版,你就得跟一次。所以评估的时候我会额外看一点——这份方案有没有留好退化路径。比如主接口失效时能不能退回页面解析,能不能只抓核心字段而不追求全字段。留了退化路径的方案,抗变更能力强很多。

我一般会这么比:把候选方案按上面四维各打一分到五分,做成一张表。分低的不用纠结,直接放弃。

维度看什么低分特征高分特征
数据入口服务端渲染还是 XHR全依赖登录后接口公开可访问,首屏可解析
还原深度补环境还是懂逻辑只有 main 函数,无断言有测试用例,Python 可独立复算
工程化限速、重试、断点、schema、日志裸循环无 sleep五个计数齐全,字段有映射层
合规与稳定是否公开、是否有退化路径需绕过访问控制有备用解析路径,只取核心字段

3. JavaScript 逆向的动手路径:从入口定位到参数还原

这一章讲方法,不讲某个具体目标的打法。所有示例都是我自己搭的练习环境里的虚构函数,参数名和常量都是编的,思路可以直接套。真正要动手时,请只对你有权限测试的目标使用。

3.1 第一步:抓包分层,先分清哪些参数是算出来的

打开开发者工具的 Network 面板,筛选 XHR,把首屏那几条请求按时间排序。这一步的目标不是找到接口,而是给每个参数分类:常量、时间戳、会话标识、算出来的签名。分类方法很笨但有效——复制这条请求,在控制台里手工改一个字段发一次,看服务端是否接受。接受的说明该字段不参与校验;拒绝的说明它参与。

分完类之后,重点盯签名类字段。同时看两处:请求头里有没有自定义字段(常见的是在 header 里塞一个 token)、请求体里有没有拼在参数里的。两处都有的话,先确认哪个是必须的,很多接口只校验其中一处。

注意:这一步不要用高并发去试,手工发三五次足够判断。试错本身也是一种压力。

3.2 第二步:用断点和调用栈把入口钉死

知道哪个参数是算出来的之后,下一步是找到它在哪一行被生成。最省事的办法是全局搜索参数名,但混淆代码里参数名通常被改成了单字母,搜不到。这时候用断点。

在 Sources 面板里对 XHR 打一个断点(Chrome 里有专门的 XHR/fetch Breakpoints,可以按 URL 关键字命中),请求发出时脚本会停住,此时看 Call Stack,从下往上翻,找到第一层「明显在做字符串拼接或哈希运算」的栈帧。这一步的玄学在于,栈有时候很深,中间夹杂着框架代码。我的经验是直接跳到栈底往上第三到第五层,那里通常是业务代码,再深就是框架。

找不到断点位置的时候,还有个土办法:在控制台里劫持JSON.stringify或XMLHttpRequest.prototype.send,把调用时的参数和堆栈打印出来。这种钩子写法通用性很强,也是很多教程里会提到的定位入口的方式。

// 在控制台执行:劫持 send,把每次请求的参数和调用栈打出来 const rawSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function (body) { // 只在带参数的请求上打印,避免被静态资源刷屏 if (body) { console.log('[send]', body); console.log(new Error('call stack').stack); } return rawSend.apply(this, arguments); };

这段钩子的逻辑很简单:保存原始send,替换成一个先打印再透传的函数。body是请求体,stack是当前调用栈。参数说明——如果你只想盯某个域名,可以在打印前加一层this.responseURL.includes(...)判断。抓到栈之后,按栈里的文件行号回到 Sources 面板下断点,就能稳定复现。

3.3 第三步:混淆代码的还原顺序,别一上来就硬读

拿到那段代码,第一反应不要是逐行读,而是按固定顺序处理:先格式化,再改可读名,最后才去控制流。顺序错了会很痛苦。

格式化最简单,开发者工具自带的 pretty print 就够。格式化之后代码会从一两行变成几百行,但至少能看。接着是变量改名:这一步不要手工做,混淆代码里同一段逻辑常有大量重复的单字母变量,手工改名会改到一半自己都乱了。我一般只给「参与最终拼接的那几个变量」改名,其他的一概不动,保持原样。范围小,出错概率低。

然后才是去控制流。这一步对新手不建议做,因为控制流平坦化之后的代码,硬还原很容易把逻辑改错。我吃过这个亏:一段代码还原完之后跑出来的结果差一位,查了两个小时才发现是自己删掉了一个不起眼的分支。更稳的做法是先不管控制流,只做「黑盒观察」——给这段代码喂几组不同的输入,记录输出,反推它做了什么。输入输出对上之后,再回头确认实现。

反推的时候,一个很有用的技巧是控制变量:固定其他参数,只改一个,看输出的哪些位变了。比如只改时间戳,如果输出的前八位跟着变,那大概率是 MD5 类摘要;如果输出长度跟着变,那可能是拼接而不是哈希。

3.4 第四步:补环境与本地复算,验证标准是「两边一致」

走到这一步,你要么读懂了算法,要么决定先用补环境跑通。两条路都可以,但验证标准是同一个:同一组输入,浏览器里算出的结果和你本地算出的结果必须逐字符一致。差一个字符,就是没对上。

先看读懂的那条路。下面是我自己练习环境里的虚构签名函数,演示「读懂再实现」的过程。

// 虚构示例:演示签名函数的四个关键点 // 1) 入参按键名排序 2) 用 & 拼接 3) 时间戳参与 4) 末尾拼常量 function buildSign(params, ts) { const keys = Object.keys(params).sort(); // 排序规则必须与前端完全一致 const raw = keys.map(k => `${k}=${params[k]}`).join('&'); return md5(raw + '&ts=' + ts + '&key=' + SALT); // SALT 从常量表里取,不要写死 }

四个关键点里最容易翻车的是排序。Object.keys的顺序在 V8 里是「整数键升序,字符串键按插入顺序」,和字面量书写顺序不一定一致。如果前端代码里是手工拼字符串而不是遍历对象,那排序规则可能根本不存在,直接按书写顺序拼。这种细节只能靠对比输入输出确认,不能靠猜。

Python 侧按同样规则实现一遍:

# 与上面 JS 逐位对齐的 Python 实现 import hashlib SALT = "fictional_salt_value" # 与前端常量保持一致,建议做成配置项 def build_sign(params: dict, ts: str) -> str: keys = sorted(params.keys()) # 排序必须与前端一致 raw = "&".join(f"{k}={params[k]}" for k in keys) text = f"{raw}&ts={ts}&key={SALT}" return hashlib.md5(text.encode("utf-8")).hexdigest()

参数说明:params是业务参数的字典,注意值要转成和前端一样的字符串形式——前端1和"1"拼出来的结果一样,但null、undefined、空字符串的处理经常不同;ts必须是同一时刻的时间戳,验证时要手动传同一个值。SALT建议放进配置文件而不是写在函数里,目标一变只改配置。

再看补环境那条路。核心是把目标脚本放进一个隔离上下文,按需补上它用到的全局对象。

// 补环境骨架:按需注入,报错缺什么补什么 const vm = require('vm'); const fs = require('fs'); const sandbox = { window: {}, document: { cookie: '' }, // 只补脚本真正访问到的字段,多补会引入副作用 navigator: { userAgent: 'Mozilla/5.0' }, setTimeout, console, }; vm.createContext(sandbox); vm.runInContext(fs.readFileSync('./target.js', 'utf8'), sandbox); // 跑完之后从 sandbox 里取挂载的函数或变量 console.log(typeof sandbox.window.getSign);

这段骨架的逻辑是:vm.createContext创建一个干净的全局环境,runInContext在里面执行目标脚本,脚本挂到window上的东西会留在sandbox.window里。常见报错是xxx is not defined,按报错名往sandbox里补即可。要注意的是别一次性补全——补得越多,越容易和脚本内部逻辑打架,比如你补了一个假的Math.random,脚本算出来的结果和浏览器里就不一致了。

验证的时候,我习惯写一个对账脚本:拿浏览器里真实产生的一组输入和输出,喂给本地实现,逐字符比对。这一步不做,后面所有工作都建在沙子上。

4. Python 侧的工程化:把「一次能跑」变成「每天能跑」

算法还原只是拿到钥匙,真正决定这份代码能不能长期用的是外面那层壳。这一章讲三个层面:请求层、数据层、校验层。每一层我都给出可直接套用的写法。

4.1 请求层:会话、重试、限速写成统一入口

裸写requests.get的问题不在于它错,而在于它把三件事(超时、重试、限速)散落在业务代码里,改一次要改十个地方。我的做法是抽一个Fetcher类,业务代码只调它。

# 请求层:统一超时、重试、限速,业务代码里不再出现裸 requests import time, random, requests class Fetcher: def __init__(self, qps=0.5, retries=3, timeout=10): self.session = requests.Session() self.min_interval = 1.0 / qps # qps=0.5 表示每 2 秒最多一次 self.retries = retries self.timeout = timeout self._last = 0.0 def get(self, url, **kwargs): for attempt in range(self.retries): wait = self.min_interval - (time.time() - self._last) if wait > 0: time.sleep(wait + random.uniform(0, 0.3)) # 加抖动,避免节奏过于整齐 self._last = time.time() try: resp = self.session.get(url, timeout=self.timeout, **kwargs) if resp.status_code == 200: return resp except requests.RequestException as e: print(f"第 {attempt + 1} 次失败: {e}") time.sleep(2 ** attempt) # 指数退避,别在失败瞬间连续重试 return None

参数说明:qps是每秒请求数上限,0.5 就是两秒一次,这个值要根据目标规模和自己的需求调,宁低勿高;retries是重试次数,三次一般够用,再多说明目标本身不稳定,该停下来看原因;timeout必须设,不设超时的请求在网络抖动时会挂很久,把整个任务的节奏拖垮。抖动那 0.3 秒是故意的,整齐的请求节奏在某些网关看来反而更可疑。

一个容易被忽略的点:Session要复用。它维护连接池和 cookie,每条请求都新建 session 会把连接开销放大好几倍,长任务上差距很明显。

4.2 数据层:字段设计比抓取本身更容易返工

我见过太多项目在数据层返工。典型场景是:接口返回的 JSON 直接落库,字段名跟着接口走,三个月后接口改了两个字段名,下游的分析脚本全部报错。解决方式很简单,加一层映射。

具体做法是定义一个to_record(raw)函数,输入是原始 JSON,输出是固定 schema 的字典。接口字段名只在这个函数里出现。schema 设计上我坚持三条:主键稳定(用目标自身的 ID,不要用自增)、时间字段统一成同一种格式并标注时区、可选字段给默认值而不是留空。这三条加起来多写二十行,能挡掉后面大部分的迁移工作。

还有一条血泪经验:不要在一开始就把所有字段都想抓。字段越多,解析层的脆弱点越多。先抓五个核心字段跑通全流程,跑稳了再逐个加。我早期贪全,一个接口解析了二十多个字段,其中三个是嵌套结构,目标一改版就是这三个先崩。

4.3 校验层:用抽样对账发现「静默降级」

静默降级是最难发现的一类问题:任务不报错、日志显示成功、但数据其实是错的或者少了。常见原因有三种——接口在异常时返回了空列表而不是报错、解析层某个字段取不到用了默认值、去重逻辑把不同记录误判成重复。这三种都不会抛异常,只能靠对账发现。

我的做法是每次任务结束后抽 N 条做三件事:一是重新请求一次原始接口,比对关键字段;二是本地重新算一遍签名,确认和实际发出的完全一致;三是统计各环节计数。

# 抽样对账:把线上记录和本地复算结果逐字段比对,输出不一致项 def reconcile(sample_rows, recompute): bad = [] for row in sample_rows: expect = recompute(row) # 用同一份输入在本地重算 for field, value in expect.items(): if row.get(field) != value: bad.append({ "id": row.get("id"), "field": field, "actual": row.get(field), "expect": value, }) return bad

参数说明:sample_rows是从库里抽的记录,建议每次随机抽 20 条,别总抽最新的;recompute是一个纯函数,输入记录、输出它认为正确的字段值,这里可以放签名复算、也可以放字段映射的逆向检查。返回的bad列表为空才算通过。这个方法帮我抓到过两次问题:一次是某个字段的编码不一致,中文变成了转义形式;一次是去重键选错,把同一作者的两条不同内容判成了重复。

计数这部分我建议固定五个数字并打印出来:请求总数、成功数、失败数、被限速跳过数、去重后入库数。五个数字互相之间能对上,任务才算健康。比如成功数 1000 但入库数只有 300,那中间一定有问题,要么解析失败要么去重过狠。

5. 避坑清单:五个让我返工过的坑

这一章全是踩过的,按「现象 → 原因 → 解决」写,遇到同类问题时可以直接对着排查。

5.1 现象:本地跑得好好的,换台机器全挂

原因:环境差异。最常见的是 Node 版本不同导致Object.keys顺序或某些内置方法行为有差异,其次是字体、时区、编码设置不同。时区这一条尤其阴,本地是东八区,服务器是 UTC,时间戳差八小时,签名直接对不上。

解决:把所有环境相关的量显式固定下来。时间统一用 UTC 时间戳,编码统一utf-8,Node 版本写进文档并在启动时打印校验。我现在的习惯是在脚本开头打印一行环境摘要:Python 版本、Node 版本、时区、当前时间戳,出问题时先看这一行。

5.2 现象:昨天正常,今天全部返回空

原因:目标侧做了变更,或者触发了频率限制。返回空列表而不是报错,是最典型的静默失败。还有一种情况是签名算法加了一个新参数,服务端不认旧签名,但它不返回 4xx,而是返回一个空的结果集。

解决:第一,把「返回空」当成异常处理,空结果超过阈值就告警并停止任务,别让它默默跑完。第二,保一组固定的测试输入和期望输出,每天开跑前先跑一遍自检,自检不过直接停,省得白跑几小时。第三,签名字段加一个「多余字段」的容忍策略——如果新参数是服务端忽略的,只加字段不影响;如果是必须的,那就只能跟进。

5.3 现象:字段对不上,条数总是差几条

原因:分页边界处理有误。常见的是最后一页判断用了「返回条数小于每页条数」,但目标在最后一页也可能返回满页,于是多请求一次拿到重复数据;或者用了总数除以每页条数,但总数是估算值,少取了一页。

解决:不要依赖返回条数判断结束,改用「本页所有 ID 都已在已抓集合中」作为终止条件。同时把分页游标(offset 或 cursor)落盘,任务中断后从游标继续,不从头开始。这两种做法加起来,条数差异基本消失。

5.4 现象:跑了一晚上,重复率极高

原因:去重键选错。用内容哈希做去重,遇到内容里有时间戳或随机推荐位就会失效;用标题做去重,遇到转载和改标题就失效。还有一种情况是并发写入时去重检查有竞态。

解决:去重键优先用目标自身的稳定 ID。没有稳定 ID 时,用「作者标识 + 发布时间 + 内容前 100 字」组合成复合键。并发场景下把去重放到写入端用唯一索引兜底,别只在应用层判断。

5.5 现象:三个月后回头看,还原的算法自己都看不懂了

原因:只留了代码,没留判断过程。当时为什么这么拼、哪个常量是从哪里取的、哪一步试了三遍才对,这些信息全在脑子里,三个月后脑子清空了,代码就变成了黑匣子。

解决:在还原代码旁边留三样东西——一组固定的输入输出测试用例、一段注释说明每个常量的来源(比如「来自配置文件第 X 行」「通过对比输入输出反推」)、一份变更记录,记下每次目标变更时改了什么。这三样加起来不到五十行,但它是你未来的后悔药。

6. 落到最后:我固定会走的「还原三问」

写到这里,方法层面的东西基本铺完了。如果只能带走一样,我希望是下面这三问——每次接一个新的还原任务,我会在写第一行代码之前先回答它们,答不上来就不动手。

第一问:这个参数是谁生成的?是前端算的、服务端下发的,还是浏览器自身产生的(比如某些请求头)。搞错这一层,后面全是白费。第二问:它在什么时机生成?是页面加载时算一次,还是每次请求前重算。前者说明它可能带会话性质,后者说明它和本次请求的参数相关。第三问:我本地复算的输出和它对得上吗?这一问必须用具体数据回答,不能凭感觉。

这三问对应的动作我整理成了一张小表,可以直接当检查单用。

三问判断依据没答上的后果
谁生成的禁用 JS 后参数是否还在把服务端字段当前端算法去还原
什么时机同一会话内多次请求该字段是否变化会话参数当请求参数,随机失败
对得上吗三组固定输入输出逐字符比对线上偶发失败,排查成本极高

再补一个我自己的固定动作:任何一份还原代码,交付前必须能通过pytest里那三个用例。用例不通过就不算完成,哪怕它今天在线上能出数。这个规则听起来刻板,但它救过我很多次——有一次我改了一个常量的位置,本地用例立刻报错,而如果只靠线上跑,这个错误可能要一周后才会以「偶发数据缺失」的形式暴露出来。

还有一句要给正在犹豫的人:算法还原这件事,门槛主要在第一次。第一次读混淆代码会很痛苦,会怀疑自己是不是不适合干这个;第二次开始就顺了,因为你能认出那些反复出现的模式——字符串拼接、位运算、常量表、定时校验,来来回回就这些。真正需要长期投入的不是技巧,是耐心和记录习惯。

从那以后我每次接新的还原任务,都强制先走一遍这三问、先写三组用例,再动第一行代码。慢了半天,省了半个月。希望帮到你。

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