简介:面向小红书数据采集与接口签名还原的 Python 项目源码包,聚焦 x-s、x-common 参数的解析与模拟,帮助开发者理解从 JavaScript 逆向到 Python 复现的完整过程。内容围绕毫秒级抓取性能展开,将 GET、POST 等请求类型与平台常用接口做了抽象封装,调用时无需深入底层机制即可获取目标数据,适合具备一定爬虫基础、希望进阶学习 JS 逆向与接口封装的开发者参考。
压缩包共 29 个文件,约 28KB,以 11 个 py 源码为主体,辅以 yml、yaml、ini、cfg 等配置及 tox、pre-commit 相关设置,另有 txt 依赖清单,md 与 rst 文档,以及 makefile 构建脚本,整体结构接近标准 Python 包形态,便于按模块阅读与二次改造。
已有 4451 人学习下载。包内包含核心逻辑、异常定义、辅助工具与测试用例,并附变更日志与 Sphinx 文档配置,可作为理解签名生成、请求封装与工程化组织的参照样本,也方便对照自身实现排查差异。
1. 那个算不出来的参数,才是数据采集真正的门槛
在我经手的一个模拟采集项目里,接口地址、请求体、cookie 都能从浏览器里一字不差抄下来,用 python 发出去却是 4xx,提示参数校验失败。逐字比对请求头之后才发现,浏览器多带了一个由前端现场算出来的签名参数,删掉它,接口立刻翻脸。那一刻我才算明白,爬虫的入口在网络,出口却在 JavaScript 逆向。算法还原说到底是把一段前端 JS 压缩成 python 能稳定复现的确定性函数,难的不是写代码,是判断哪段代码参与运算、哪些输入只是环境噪声。这篇写给我自己踩过的那些坑,也写给正对着混淆代码发愁的人:从抓包定位、拆解拼接规则、补环境,到把脚本从只能跑一次改成能跑一个月。
2. 侦察阶段:从一次抓包到锁定签名入口
我一向认为,逆向的时间分配应该是七分找、三分写。真正开始读混淆代码之前,先把「哪个参数是算出来的」这件事钉死,后面的工作会顺很多。反过来,一上来就全局搜索sign,然后在几万行打包代码里迷路,是新手最常见的开局方式,我自己也这么干过。
2.1 先做请求差分,别急着读代码
打开开发者工具的 Network 面板,勾上 Preserve log,过滤到 Fetch/XHR,然后手动把目标功能走一遍:翻页、切标签、点开详情。做完这一轮,你会拿到同一类接口的连续多次请求。接下来做一件很朴素但极其有效的事:把两次请求的查询参数、请求头、请求体并排放着做 diff。
diff 的结论通常能把参数分成四类,我一般按下面这张表来归档,归档完再决定动谁:
| 参数类别 | 典型名字 | 变化规律 | 处理方式 |
|---|---|---|---|
| 业务参数 | page、cursor、keyword、id | 随操作变化,值可读 | 直接照抄,无需还原 |
| 会话参数 | cookie、token、session | 长时间不变,登录后固定 | 手动维护,注意过期 |
| 时间参数 | ts、timestamp、_t | 每次请求都变,10 位或 13 位 | 需要确认精度和容忍偏差 |
| 签名参数 | sign、signature、x-trace | 每次请求都变,长度固定、字符集固定 | 重点还原对象 |
判断签名参数有几个很硬的信号:长度稳定(常见 32、40、64 位十六进制,或者更长的一串 base64url 变体);字符集稳定;跟业务参数没有肉眼可见的对应关系;把它改一个字符,接口立刻报校验失败,而不是报参数缺失。最后这条最关键,它说明服务端是拿它做一致性比对的,不是拿它当普通字段用。
还有一种情况困扰过我很长时间:URL 和请求体都干干净净,签名藏在请求头里。这时不要只盯着查询串看,把请求头整个展开,找那些名字很短、长度固定、每次都在变、且不属于任何标准头的字段。找到之后用一个最笨的办法验证——把它整条删掉重发,看响应码怎么变,再把它原样塞回去,看能不能恢复。能恢复,它就是签名;不能恢复,说明还有第二个隐藏字段,继续找。
这一步做完,我习惯把「能出数据的最小请求」存成一个文本文件,后面每改一次代码都拿它对照。这个文件在排查阶段能救命,因为你会反复怀疑「是不是我 header 少了一个」,有 baseline 就不用靠记忆吵嘴。
删参数的时候有个细节要注意:有些参数不参与签名,删了接口也照样返回 200,但数据被悄悄降级了,比如原本一页 20 条变成 10 条,或者详情字段少了一半。所以删参数之后不能只看状态码,还要比对返回体的结构和条数。这个坑我吃过一次,排查了半天以为是分页逻辑写错了。
2.2 三条定位路径:全局搜索、XHR 断点、hook 兜底
确认了目标参数之后,就该找它在 JS 里是在哪儿被赋值的。我一般按成本从低到高走三条路。
第一条是全局搜索。在 Sources 面板按 Ctrl+Shift+F 搜参数名,注意要搜整个加载的脚本而不是当前文件。搜不到很正常,因为名字可能是拼接出来的或者被混淆成了短变量。这时退一步,搜接口路径里的一段字符串、搜api常量、搜请求库的封装函数名。业务代码调接口的地方通常会带上完整的参数对象,顺着那个对象往上翻,往往能看到签名函数被调用的位置。
第二条是 XHR 断点。在 Sources 的 XHR/fetch Breakpoints 里加一个 URL 关键字,触发请求时脚本会断下来,右侧是完整调用栈。调用栈要倒着看,从最底下往上,跳过框架代码,找第一个看起来像业务逻辑的栈帧。这里有个非常实用的技巧:右键第三方库选择 Blackbox script,把这些库在调用栈里折叠掉,栈会瞬间变干净,从十几层缩到三四层。
第三条是 hook,也是最稳的一条。既然参数最终要写进请求头或查询串,那就在写入的那一刻把它抓住:
// 在页面控制台执行:拦截 XHR 请求头写入与 open 调用,打印调用栈,用于定位签名参数的赋值位置 (function () { const rawOpen = XMLHttpRequest.prototype.open; const rawSetHeader = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.open = function (method, url) { this.__url = url; // 记下 url,方便和 header 关联 return rawOpen.apply(this, arguments); }; XMLHttpRequest.prototype.setRequestHeader = function (key, value) { // 只关心可疑的短名字,避免把 cookie、content-type 之类的刷屏 if (/^(x-|sign|token|nonce|ts$|t$)/i.test(key)) { console.log('[header]', key, value, this.__url); console.trace('调用栈'); // 关键:把赋值现场打印出来 } return rawSetHeader.apply(this, arguments); }; })();这段代码的作用不是破解,而是「让你知道是谁写的」。rawOpen和rawSetHeader保存原始方法,重写之后仍然调用原方法,所以页面行为不受影响;this.__url把 url 挂到 XHR 实例上,是因为setRequestHeader里拿不到 url;正则只放行可疑字段,是为了让控制台输出可读。执行完之后正常操作页面,控制台会滚出「参数名 + 参数值 + 请求地址 + 调用栈」四件套,点开调用栈就能直接跳到赋值那一行。
如果签名是拼在查询串里的,hook 的就不是 header 而是open的 url 参数,或者干脆重写window.fetch。还有一类更隐蔽的:签名先对请求体做一次JSON.stringify再计算,这时可以在JSON.stringify上下钩子,看入参和返回值。这些 hook 都是临时的,刷新页面就失效,不用担心污染环境。
补充一个容易忽略的点:部分站点的签名计算放在 Worker 里跑,主线程的 Network 面板看不到那条请求,调用栈也断不下来。这时候要去 Sources 面板的 Threads 区域找 Worker 线程,在其中下断点。我第一次遇到这种情况时,硬是在主线程找了两个小时,最后发现目标代码根本不在这个线程里。
2.3 混淆代码的初筛:先分清壳和肉
拿到目标文件之后,别急着从头读。文件先下载到本地,用格式化工具美化一遍,再找几个特征判断它属于哪一类打包方式。
如果文件里大量出现模块注册函数、模块 ID 数组、__webpack_require__之类的调用,说明是打包产物。这类代码反而好办,因为模块边界是清晰的:找到目标模块的 ID,把它单独抽出来,写一个几十行的入口去 require 它,喂几个假参数,看输出对不对。抽模块的过程比在整包里读要快得多,也安全得多,因为你不用把整个应用跑起来。
如果文件里是大段字符串数组、变量名全是_0x开头的十六进制、还有一个明显的字符串解码函数,那是混淆器产物。对付这类代码的正确姿势不是硬读,是在解码函数的 return 处下断点,让程序自己把还原后的字符串滚出来。控制台会给你几百行可读文本,比盯着字符表推算强一百倍。顺便说,有些混淆器还会做字符串数组轮转,每次刷新页面数组顺序都变,所以你本地 dump 出来的结果和线上下一次加载的可能对不上,看到这种情况不要怀疑自己看错了,是它在自保。
最难读的是控制流平坦化:一个巨大的 switch-case 循环,把原本线性的代码打散成状态机。这种代码我从来不试图读懂,只看不动的部分——也就是那些真正做运算的表达式。做法是单步断点加变量观察,把注意力放在「这个变量的值从哪儿来、到哪儿去」上,而不是理解整体结构。判断一段代码是不是签名函数,有几个很土但很准的指标:它的返回值长度和签名长度一致;它的入参里包含时间戳和业务参数;改动入参的值,返回值跟着变;在返回值处下断点,往上翻两层能看到调用者把它塞进了请求。
最后提一句覆盖调试:在开发者工具的 Local Overrides 里把线上 JS 映射到本地文件,你就可以自由地往里面插console.log,改完刷新立即生效。这个手段在「我知道大概位置但不知道具体变量」的阶段特别好用,比反复下断点快。
3. 算法还原:把 JS 逻辑翻译成 Python 的四个判断
定位到函数之后,真正的工作才开始。我见过太多人一上来就逐行翻译,翻到一半发现方向错了,前面全白干。正确的顺序是先做判断,再动手写。
3.1 先看长度和字符集,判断算法类型
输出长度是最高效的分类器。32 位纯十六进制,大概率是 MD5 家族;40 位是 SHA1;64 位是 SHA256;128 位是 SHA512;带=补位、大小写混合、包含+/或-_的,是 base64 变体,背后可能是 HMAC 结果或者对称加密的密文。这些都是经验值,不是定律,但能帮你把候选范围从「无限」缩到「三五个」。
缩完范围就做对照实验,这是最快的验证方式:在 JS 里的哈希调用前下断点,把入参字符串完整复制出来;回到 Python,用标准库算一遍,比对结果。一致就结束了,说明它就是标准算法,剩下的工作只是把拼接规则还原出来。不一致也别慌,按下面的顺序排除:
先怀疑是不是加了密钥,也就是 HMAC 而不是裸哈希。再怀疑是不是多轮,比如对结果再做一次哈希、或者把结果转大写再哈希。还怀疑是不是加了固定盐,找找代码里有没有那种硬编码的、长得像随机字符串的常量。最后才怀疑魔改——所谓魔改,是改初始向量、改轮常量、改字节序,这类算法用标准库永远算不出来,只能把 JS 搬过来跑,或者照着源码重实现一遍。
判断有没有密钥有个笨办法:在 JS 里搜索长度在 8 到 64 之间、内容像随机字符的字符串常量,把它们全部列出来,逐个当密钥试。命中率不低,因为密钥总得存在某个地方。
3.2 拆解拼接规则:顺序、空值、类型三件事
算法确定之后,真正容易翻车的是拼接。前端把一个 URL、一个时间戳、一个请求体拼成一个大字符串再哈希,拼接顺序差一位,结果就完全不同。
验证顺序的方法很直接:保持所有参数的「值」不变,只改它们在对象里的「顺序」,重新请求一次。如果签名变了、服务端拒了,说明参与签名的字符串是按某种规则排序后拼的,最常见的是字典序;如果签名没变,说明用的是插入顺序。这一步不做,后面纯靠猜,效率极低。
空值和类型是第二组坑。JS 里一个没赋值的字段拼进去会变成字符串undefined,null会变成null,而 Python 里对应的可能是空字符串或者抛异常。数字也一样,JS 的1.0输出是1,Python 的str(1.0)是1.0;布尔值 JS 是true,Python 是True。这些差异在单参数测试时看不出来,一上批量就全错。
下面这段是结构示意,展示我一般怎么把「排序、编码、加盐、哈希」这四步搬进 Python:
import hashlib import urllib.parse def build_sign(params: dict, body: str, ts_ms: int, salt: str) -> dict: """ 结构示意:把前端那套「剔除签名字段 -> 字典序排序 -> 编码拼接 -> 加盐哈希」搬到 Python。 其中的 salt 和各字段是否参与签名,必须从目标站点的 JS 中推导,且只在获得授权的环境下使用。 """ # 1. 剔除签名字段本身,参与签名的对象里绝不能包含结果 signed = {k: v for k, v in params.items() if k not in ("sign", "signature")} # 2. 前端多数字典序,注意 JS 的 sort 默认按字符串比较,纯数字 key 会得到 1,10,2 这种顺序 pairs = [] for k in sorted(signed): v = signed[k] # 3. 空值兜底:JS 里 undefined 拼进去就是字符串 "undefined",Python 要显式对齐 s = "undefined" if v is None else str(v).lower() if isinstance(v, bool) else str(v) pairs.append(f"{k}={urllib.parse.quote(s, safe='')}") # 4. 拼接顺序错了结果必然错,body 和 ts 的位置要照着 JS 里的顺序摆 raw = "&".join(pairs) + f"&body={body}&ts={ts_ms}&salt={salt}" sign = hashlib.md5(raw.encode("utf-8")).hexdigest() return {"ts": ts_ms, "sign": sign, "raw": raw} # raw 一并返回,方便和 JS 侧逐字节比对几个参数值得单独说明。salt是从 JS 里挖出来的常量,不同接口可能不一样,不要图省事写死一个全局值。ts_ms用毫秒还是秒,取决于前端那个时间戳是 13 位还是 10 位,别自己猜。urllib.parse.quote的safe参数传空串是有意为之,目的是尽量贴近encodeURIComponent的行为,但两者并不完全等价,这一点在避坑那章会细说。返回结果里带上raw,是为了出错时能直接把两边拼出来的原始串摆在一起比对,这个习惯帮我省掉过无数次二分查找。
3.3 Python 侧的实现选型:重写还是直接跑 JS
算法看懂之后有个岔路口:用 Python 重写一遍,还是把 JS 原样跑起来。这两种做法我都在生产里用过,各有各的代价。
| 方案 | 外部依赖 | 单次耗时量级 | 语法兼容性 | 适合场景 |
|---|---|---|---|---|
| 纯 Python 重写 | 只用标准库 hashlib、hmac | 微秒级 | 不涉及 | 算法已完全看懂,追求并发和稳定 |
| 本地 JS 引擎绑定 | 需装原生扩展 | 毫秒级 | 多数 ES5,部分 ES6 | 算法中等复杂,不想重写 |
| Node 子进程常驻 | 需维护一个进程 | 毫秒级,含序列化开销 | 基本全支持 | 混淆严重、逻辑嵌套深 |
| 纯 Python 移植混淆代码 | 无 | 微秒级 | 不涉及 | 长期运行,对稳定性要求最高 |
选型的判断依据不是「哪个先进」,是三个问题:这段逻辑我会不会改?这台机器能不能装原生依赖?并发量多大?
我的一般做法是:先选 Node 子进程,用最快的方式让整条链路跑通,拿到正确结果;然后再回过头看这段逻辑的复杂度,如果只是「排序 + 拼接 + 一次哈希」,花半天移植成纯 Python,长期收益极大——没有额外进程、没有序列化、没有版本升级导致的编译失败,并发能力直接上一个台阶。如果里面掺了自定义加密表、多层嵌套、上百个常量,那就老老实实跑 JS,别为了「纯 Python」的洁癖浪费三天。
选 Node 子进程时有两个工程细节要注意:一是常驻,不要每次签名都起一个进程,进程启动成本比计算本身贵几十倍;二是通信协议要简单,用标准输入输出传一行 JSON 即可,别上 RPC 框架。还有一个反直觉的点:进程数不是越多越好,签名是 CPU 密集型,超过核数就是纯排队,反而增加超时。
3.4 用一张对照表确认「不是碰巧对上」
单条数据对上不算还原成功,可能是巧合,也可能是你恰好没触发边界条件。我习惯做一张对照表,用十组不同的输入,同时跑 JS 和 Python,逐字节比对:
| 用例 | 输入特征 | JS 输出 | Python 输出 | 是否一致 |
|---|---|---|---|---|
| 1 | 普通参数,无空值 | 记录实际值 | 记录实际值 | 待填 |
| 2 | 含一个空字符串字段 | 记录实际值 | 记录实际值 | 待填 |
| 3 | 含一个 undefined 字段 | 记录实际值 | 记录实际值 | 待填 |
| 4 | 参数字典序与插入序不一致 | 记录实际值 | 记录实际值 | 待填 |
| 5 | 参数值含中文与空格 | 记录实际值 | 记录实际值 | 待填 |
| 6 | 参数值含特殊符号!*'() | 记录实际值 | 记录实际值 | 待填 |
| 7 | 数值型参数为整数 | 记录实际值 | 记录实际值 | 待填 |
| 8 | 数值型参数为浮点 | 记录实际值 | 记录实际值 | 待填 |
| 9 | 布尔型参数为真 | 记录实际值 | 记录实际值 | 待填 |
| 10 | 数组型参数多元素 | 记录实际值 | 记录实际值 | 待填 |
用例 4 到 6 是我特意加的,因为它们分别命中排序、编码、字符集三个高频翻车点。10 条全过,才算可以接入采集主流程。这个表看起来很土,但它把「感觉对了」变成「证据对了」,后面出问题时也能快速定位是哪类输入退化。
4. 补环境:让那段 JS 在受控沙箱里跑出稳定结果
有些签名逻辑你没法重写,因为它依赖浏览器环境:读 UA、读屏幕尺寸、读时区、读 localStorage 里的某个值,甚至读 canvas 渲染出来的像素。这类逻辑搬进 Python 就只能造假环境,而造假环境的核心不是「造得多」,是「知道缺什么」。
4.1 先列环境探测点,再动手补
我一般先通读一遍目标函数,把所有对全局对象的访问点列出来。常见的探测点就那么几类,做一个清单挨个对:
| 探测对象 | 常见属性 | 缺失时的表现 |
|---|---|---|
| navigator | userAgent、platform、languages、webdriver | 返回 undefined 或抛异常 |
| screen / window | width、height、innerWidth、devicePixelRatio | 参与运算时得到 NaN |
| document | cookie、referrer、createElement | 直接报错中断 |
| location | href、host、protocol | 拼接结果为空串 |
| storage | localStorage、sessionStorage 的 getItem | 报错或返回 null |
| 时间与性能 | Date.now、performance.now | 结果每次不同,无法复现 |
| 渲染指纹 | canvas、WebGL、AudioContext | 报错,或返回固定桩值导致偏差 |
在 Node 里跑目标代码,最省事的做法是用内置的 vm 模块开一个沙箱,先把最小桩搭好:
// 用 Node 内置 vm 模块把目标 JS 放进受控沙箱;Date 与 Math.random 固定,是为了让结果可复现 const vm = require('vm'); const fs = require('fs'); const code = fs.readFileSync('./target.js', 'utf8'); const sandbox = { console, // 时间固定成常量,排除随机因素,方便和 Python 侧逐字节对齐 Date: class extends Date { static now() { return 1700000000000; } }, Math: Object.create(Math, { random: { value: () => 0.5 } }), navigator: { userAgent: 'Mozilla/5.0 (test)', platform: 'Win32', languages: ['zh-CN'] }, location: { href: 'https://example.test/explore', host: 'example.test' }, document: { cookie: '', referrer: '', createElement: () => ({ getContext: () => null }) }, localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} }, performance: { now: () => 0 }, }; sandbox.window = sandbox; // 不少库会检查 window 是否存在 sandbox.globalThis = sandbox; vm.createContext(sandbox); vm.runInContext(code, sandbox, { timeout: 5000 }); // 超时兜底,防止死循环卡死进程这段代码里三个设计点值得说明。Date用继承的方式固定now(),是因为有些代码会new Date()取当前时间再取毫秒,只改now不够,得让整个类都指向常量。Math用Object.create继承原对象再覆盖random,这样floor、abs这些方法还在,不用一个个补。sandbox.window = sandbox是最容易被忘掉的一行,很多混淆代码开头就判断typeof window,没有这个自引用会直接走进降级分支,算出来的结果和浏览器里不一样,而你还以为是算法错了。
timeout也必须有。混淆代码里触发异常路径时可能进入死循环,没有超时,整个采集进程会被一个函数调用拖死。
4.2 缺什么补什么,别一次补全
新手常犯的错是照着某个「环境补全脚本」一次性塞几十个属性进去。这样做的后果是:你根本不知道哪个属性真的被读了,出错时无从下手。
我的做法是反过来的:先给一个空壳,让代码跑,报什么错补什么,补到不报错为止。更进一步,用一个记录型的桩,把访问过的属性名全部记下来:
class EnvStub: """兜底桩:任何未定义的属性访问都记录下来并返回空串,用来发现目标逻辑到底读了哪些环境变量""" def __init__(self): self.missing = set() def __getattr__(self, name): # 只有常规查找失败时才会走到这里,天然就是「缺失属性」的探针 self.missing.add(name) return "" def report(self): return sorted(self.missing)这段 Python 桩本身不参与签名计算,它的用途是画地图:把 JS 侧读过的环境变量名收集成一份清单,再对着清单去决定哪些需要真实值、哪些给固定值就行。__getattr__只在正常属性查找失败时触发,所以收集到的必然是「代码想要但环境没有」的东西,噪音很小。跑完一次采集,调用report()就能拿到完整清单。
清单拿到之后要做取舍。UA、时区、语言这类,给一个稳定的固定值即可,反正服务端大概率只校验格式。屏幕尺寸、像素比这类,给一个常见机型的值。canvas、WebGL 这类渲染指纹,能绕就绕——不是所有接口都真的校验它,很多时候它只是被采集上报、不参与签名运算,先确认它到底在不在签名链路上,别为了一个不参与计算的字段折腾一整天。
4.3 差分验证:确认稳定,再谈正确
环境补完,第一件事不是庆祝跑通,而是验证「稳定性」。同一个入参,连续跑 100 次,输出必须完全一致。如果不一致,说明还有随机源没固定住,常见的是Math.random、Date.now、performance.now、还有数组遍历顺序依赖。
固定住随机源之后,再和浏览器侧做差分:在浏览器的同名函数返回值处下断点,把结果抄下来;在沙箱里用完全相同的入参跑一次;两边对比。不一致就二分——先把 JS 侧所有console.log打开,逐段打印中间变量,找到第一个分叉点。这个流程听起来笨,但它把「玄学」变成了「定位」,我后来所有的环境问题都是这么解决的。
还有个小技巧:把沙箱执行结果和线上真实请求的结果做一次端到端验证。也就是用沙箱算出的签名发一次真实请求,看服务端认不认。本地一致但服务端不认,说明还有服务端侧的隐藏校验,比如 header 里的其他字段、cookie 的一致性、或者请求体里某个你没注意的字段。这一步不做,很容易在本地自嗨很久。
5. 避坑与排查:五个让我翻车的点和它们的解法
下面这五条都是我真金白银踩过的,按「现象、原因、解决」写,能直接对照排查。
5.1 签名对不上这一类
坑一:长度对了,值死活不对。
现象是 Python 算出来的签名和浏览器里的长度一致、字符集一致,就是每一位都不同,试了十几种拼接顺序都不行。
原因是编码函数不等价。encodeURIComponent不转义!、'、(、)、*这五个字符,而 Python 的urllib.parse.quote默认会把它们转成百分号编码;反过来,quote默认不转义/,而encodeURIComponent会转。参数里只要出现这几个字符中的任意一个,拼出来的原始串就差一位,哈希结果自然全变。
解决办法是把safe参数显式写清楚,并且在拼接前对这几个字符做手工对齐。最稳妥的做法是写一个js_encode函数,先quote(s, safe=''),再把%21、%27、%28、%29、%2A还原成原字符。别嫌麻烦,这五个字符在各种搜索关键词、昵称、标题里出现频率很高。
坑二:单个参数测试全过,一批量就错。
现象是手工构造的几条用例都能对上,接上真实数据跑了五十条之后开始零星失败,失败率大概百分之几。
原因是空值和类型的处理没对齐。真实数据里总会有字段为空、字段是数字、字段是布尔的情况,而 JS 在这些边界上的行为和 Python 差别很大:undefined参与字符串拼接会变成"undefined",null变成"null",true变成"true"而不是"True",浮点数1.0输出是1。
解决办法是在拼接前做一次规范化:显式判断None,按字段在 JS 里的实际类型决定输出"undefined"还是空串;布尔统一转小写;浮点数判断是否整数,是整数就转int再转字符串。这一步做完,失败率通常会直接归零。
坑三:参数顺序改了,签名也变了。
现象是把两个参数的书写顺序调换一下(值完全没变),服务端就拒了。
原因是参与签名的字符串是按字典序重排后拼接的,而你的 Python 代码用的是字典插入顺序。Python 3.7 之后 dict 保序,所以「保序」和「排序」两种写法在简单用例下结果可能碰巧一样,一旦参数变多就暴露。
解决办法是先做那个「只改顺序不改值」的验证实验,确认它到底排不排序。如果排序,注意 JS 的默认sort是按字符串比较的,纯数字的 key 会排出1, 10, 2这种顺序,和 Python 的sorted在字符串场景下一致,但如果你把 key 转成了 int 再排,结果就不同了。嵌套对象更麻烦,只排外层不排内层是常见疏漏。
5.2 跑得起来但跑不久这一类
坑四:前两百条正常,之后全是 4xx。
现象是脚本刚启动时数据哗哗地出,跑到两百条左右开始大面积失败,重启脚本又好一阵。
原因几乎都是频率和一致性。频率好理解,短时间高频请求触发限流。一致性更容易被忽略:cookie 里的会话标识、UA、请求头顺序、IP,这几样如果在服务端看来互相矛盾,即使签名完全正确也会被拦。比如你用一个桌面 UA,却带着移动端的 cookie 去请求,或者并发请求来自不同出口地址但共用同一个会话。
解决办法是降并发、加随机间隔、把同一会话的请求固定走同一条链路。我一般会把并发压到个位数,间隔设成 300 到 800 毫秒的随机值,并且在收到 4xx 时立刻退避,指数增长,而不是继续硬打。硬打的结果通常不是恢复,是把整个会话打死。
坑五:跑了三天,某天早上集体挂掉。
现象是脚本一直很稳,某天开始所有请求都失败,本地算出来的签名和服务端对不上,但代码一行没改。
原因是前端发版了。签名逻辑加了一个新字段、换了一个常量、或者把时间戳从秒改成毫秒,本地还原的逻辑就和线上脱节了。
解决办法是加一个「版本探针」:每次启动时先发一条最小请求验证签名是否有效,无效就告警并自动拉取最新脚本重新比对。同时把签名函数的指纹(比如关键常量的哈希、函数的字符长度)存下来,定期和线上脚本比对,变了就提示。这个东西看起来多余,但它是唯一能让你在凌晨三点不用爬起来的手段。事后补救的成本,永远高于提前埋一个探针。
6. 收口到工程:把只能跑一次的脚本改成能跑一个月的采集器
还原成功那一刻的兴奋感很强,但真正决定这套东西有没有价值的,是它能不能在下周、下个月还正常工作。我后来给自己定了一个规矩:任何一份还原逻辑,必须过完下面这张表才算交付。
| 检查项 | 判断标准 | 不达标的后果 |
|---|---|---|
| 双跑一致性 | 十组边界用例 JS 与 Python 逐字节相等 | 不定期零星失败,极难定位 |
| 随机源固定 | 同一入参连跑 100 次输出不变 | 无法做回归测试 |
| 版本探针 | 启动时验证签名有效性并告警 | 前端发版后静默失效 |
| 退避策略 | 4xx 触发指数退避,不硬打 | 会话被打死,恢复成本高 |
| 断点续跑 | 采集中断后能从上次位置继续 | 每次重跑浪费大量请求额度 |
| 数据最小化 | 只落库业务需要的字段 | 存储与合规风险同时放大 |
这张表里,「双跑一致性」和「版本探针」是我最看重的两条。前者保证正确性,后者保证时效性,剩下的都是工程细节。断点续跑这一条,实现方式很朴素:把游标和已完成的分片写进一个本地状态文件,启动时先读它,比任何花哨的调度框架都管用。
还有一个我后来才意识到的事:限速不是妥协,是必需品。早期我总想着把并发拉满,觉得慢就是浪费,结果每次都是跑了半天被封、再花一天排查。把并发压到个位数、加上随机间隔之后,单位时间的有效数据量反而更高,因为失败重试的成本全没了。这个道理听起来简单,但非得自己被打疼一次才记得住。
至于边界问题,我的态度一直很明确:只用自己有权访问的数据,只看公开可见的内容,不碰任何个人信息,不绕过登录与付费,遵守目标站点的 robots 与服务条款。技术上能做到,和应不应该做,是两件事。这句话不是唱高调,是因为我见过太多人把精力全花在对抗风控上,最后既没拿到稳定数据,也把原本简单的技术问题拖成了一团乱麻。
回过头看,这套流程里最难的一步从来不是写代码,而是第一次面对混淆文件时那种「无从下手」的感觉。拆开之后你会发现,绝大多数签名逻辑都逃不出那几种套路:排序、拼接、加盐、哈希、偶尔来点环境变量。真正需要创造力的地方很少,需要耐心的地方很多。
从那以后,我每次动手逆向之前,都强制自己先做完那件最枯燥的事:抓两次请求、做一次 diff、存一份 baseline,然后再打开代码。这个习惯帮我省下的时间,比任何技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取