做数据备份这件事,最烦的从来不是写代码,而是登录。前阵子想把某B视频网站上自己账号的观看记录拉一份到本地,做个简单的年度统计,结果整个流程写下来,真正花时间的部分全耗在了模拟登录上:密码在前端就被加密了,参数里混着好几个看不见的签名,cookie 还得按特定顺序喂进去。这篇就把这一趟踩过的路完整复盘一遍——从前端加密怎么定位、RSA 参数怎么拼、Session 怎么维持,到风控为什么会找上门、报错该怎么查。适合已经会写 requests、但还没正经处理过登录态的同学,也适合纯粹好奇"点一下登录按钮背后到底发生了什么"的读者。说明一下前提:所有操作仅针对我自己的账号、用于本地个人数据整理,不做批量、不做越界、不碰别人的数据,这条底线比技术本身重要得多。
1. 先想明白:我们要"模拟"的到底是什么
1.1 点一下登录按钮,浏览器背地里干了六件事
很多人对登录的直觉印象是"提交账号密码,服务端返回成功"。真把网络面板打开,你会发现链路上远不止一次请求。完整的动作序列大概是这样的:浏览器先加载首页文档,服务端在响应头里塞下若干设备标识类 cookie;页面加载过程中,前端脚本读取这些标识并做一次预登录请求,拿到一把公钥和一个一次性盐值;用户填完表单点提交,前端用刚拿到的公钥和盐值把密码加密;加密结果连同账号、时间戳、各类标识一起提交到登录接口;服务端校验通过后下发新的 cookie 或返回一个 token;最后前端再打一个用户信息接口,确认身份并跳转。
这六步里,只有第三步和第五步是"必须模拟"的,但第一步到第四步之间存在严格的先后依赖——你没拿到公钥就没法加密,没拿到设备 cookie 就可能直接被判定为异常来源。所以我把它理解成一条有向链,而不是一串可以随便调换顺序的接口。理解这一点之后,很多"为什么我直接 POST 登录接口返回参数错误"的困惑就自然消解了:不是参数写错了,是你把链条从中间截断了。
1.2 模拟登录与普通接口抓取的本质差别
普通的列表接口抓取,本质上是"公开读":你带个 UA,拼对查询参数,服务端就给你数据,请求之间互不影响,失败重试一次往往就好了。登录接口完全不是这个性质,它是"身份写"——要建立一个能在后续请求中被反复识别的新状态,而且这个状态是服务端主动发放的,你没法凭空构造。
这个差别带来的直接后果有三个。第一,参数不再是静态的,每次登录的公钥、盐值、时间戳、随机数都不一样,任何写死的值都会在第二次运行时失效。第二,失败代价变高,普通接口请求失败大不了重试,登录失败几次之后账号本身可能被临时限制,试错空间很小。第三,可观测性变差,登录失败往往只返回一个模糊的错误码,不告诉你到底是密码错了、参数错了、还是环境不对,排查只能靠对比。
我把这个总结成一句话:抓公开接口是在"对答案",模拟登录是在"对过程"。你得让服务端觉得你这套动作序列和真实浏览器一致,而不只是最终那个 payload 长得像。
1.3 动手前先把边界划清楚
技术上能做,和应不应该做,是两码事。我在动手前给自己定了几个约束,后面每写一段代码都会回头核对一遍:只用自己实名注册的账号;不做多账号轮换;遇到验证码就停下,不引入任何自动识别方案;请求频率控制在人的正常操作量级,不做并发;抓下来的数据只存本地,不上传、不分享、不用于任何形式的再分发。
这几条听起来像是免责声明,实际上是实打实的技术约束。比如"遇到验证码就停"这一条,直接决定了你的代码结构——你必须在流程里设计一个人工介入的断点,而不是设计一个自动重试循环。再比如"频率控制在人的量级",决定了你不能写for i in range(100)这种批量登录的循环。想清楚这些,后面写代码时就不会被"反正先跑通再说"的冲动带偏。
注意:不同平台对自动化访问的规则写在用户协议和 robots 文件里,条款差异很大。动手前花十分钟读一遍相关章节,比事后被封号划算得多。规则明确禁止的部分,不要用技术手段去试探边界。
2. 侦察阶段:把登录流程拆成可观测的请求
2.1 抓包环境怎么搭才干净
工欲善其事。我用的是浏览器自带的开发者工具,几个开关必须提前设好,否则你会被缓存和跳转坑得很惨。Network 面板里把 "Preserve log"(保留日志)勾上,因为登录成功后页面通常会整页跳转,不勾的话之前的请求记录全被清空,你就只能看到跳转后的结果,看不到加密过程。再勾上 "Disable cache",避免加载到旧的脚本。最后用一个无痕窗口操作,保证初始环境没有任何历史 cookie 干扰,这样你看到的每一条 Set-Cookie 都是这次产生的,因果关系清清楚楚。
过滤条件也很关键。默认全量展示时,一个首页加载下来可能有上百条请求,图片、字体、统计脚本全混在一起。我一般先按 "Fetch/XHR" 过滤,把静态资源排掉,再按关键词搜接口名。如果实在找不到,就反过来从脚本入手:切到 Sources 面板,全局搜索关键词,用Ctrl+Shift+F做全项目搜索,这招在定位加密逻辑时几乎是必用的。
实操心得:准备两个浏览器会话,一个用来正常登录观察流程,一个保持未登录状态用于反复对比。同一个请求,登录前后携带的 cookie 和 header 差异,往往就是最直接的线索。
2.2 登录链路上的关键请求清单
把整条链路的请求一个个记下来,是我认为最值得投入时间的一步。下面这张表是我这次实际记录的结果,接口名做了脱敏,只保留结构和作用,你在自己的场景里替换成对应的即可。
| 序号 | 请求作用 | 关键字段 | 是否必须模拟 |
|---|---|---|---|
| 1 | 加载首页文档 | 响应头下发的设备标识 cookie | 必须,且要放在最前 |
| 2 | 加载前端脚本 | 无(但要记录脚本地址,后续要找加密函数) | 不需要请求,但必须阅读 |
| 3 | 预登录,取公钥与盐值 | 返回公钥串、一次性盐值、有效期 | 必须,且要在登录前实时获取 |
| 4 | 提交登录表单 | 账号、加密后的密码、时间戳、各类标识、签名 | 必须,核心 |
| 5 | 登录后校验身份 | 携带新 cookie,返回用户基本信息 | 建议,用于确认登录态真的建立成功 |
| 6 | 跳转页面的附属请求 | 各种统计、推荐接口 | 完全不需要 |
这张表的价值在于:它把"我要写多少个函数"这件事定量化了。序号 1、3、4、5 各对应一个函数,加起来不到一百五十行代码,功能边界非常清晰。很多人写模拟登录写成一坨,就是因为没有先做这一步拆解,把所有逻辑堆在一个函数里,出错了根本不知道是哪一环断的。
2.3 参数分三类:写死的、算出来的、藏起来的
把登录请求的 payload 完整复制出来,逐字段过一遍,我给每个字段打了标签。第一种是静态值,比如标识登录类型的act=login、标识来源页面的goto参数,这类值在多次登录之间完全不变,直接写死在配置里就行。第二种是半动态值,比如毫秒时间戳、随机字符串、从预登录接口拿到的盐值,它们每次都不一样,但生成规则是明确的,自己算就行。
第三种最难缠,我叫它藏起来的值。它通常出现在参数名很奇怪的位置,值是一长串十六进制或者 base64,在页面上完全看不出来是怎么来的,只存在于某段被压缩混淆的脚本里。这类参数要么是设备指纹的哈希,要么是多个字段拼接后的签名。判断方法很简单:把登录请求复制成 cURL,然后改动其中一个字段的值重新发一次,如果返回参数错误,那它就是被校验的签名;如果返回正常,那它大概率只是个可有可无的埋点字段。
我用这个方法砍掉了将近一半的字段。原始 payload 有二十多个键,最后真正参与校验、必须正确生成的只有八个左右。剩下的要么是统计埋点,要么是服务端根本没读。这个筛选过程省下的调试时间,比后面所有优化加起来都多。
3. 硬骨头:前端加密的定位与还原
3.1 在混淆的脚本里找到加密入口
登录请求的密码字段是一长串 base64,长度还随公钥变化,基本可以断定是 RSA 之类的非对称加密。问题是算法好猜,入口难找。压缩混淆后的脚本动辄几万行,变量名全是单个字母,靠肉眼翻是不现实的。
我的定位路径是这样的:先在 Network 面板里右键那条登录请求,选 "Copy as cURL",确认密码字段的特征;然后回到 Sources 面板做全局搜索,依次尝试encrypt、publicKey、setPublicKey、JSEncrypt、rsa这些关键词。如果搜不到,就换思路——在 Network 面板里给登录请求打一个 XHR 断点,然后正常操作一次登录,代码会停在发起请求的那一行,此时看右侧的调用栈(Call Stack),一层层往上翻,通常两三层就能翻到加密函数所在的位置。
这个方法的关键在于"让代码自己告诉你答案",而不是你去猜它在哪。断点停下的那一刻,调用栈上每一层都是一个明确的函数名和文件位置,比搜索关键词准确得多。我这次就是靠断点定位的,从发请求的那行往上翻了两层,就看到了一个把密码和盐值拼接后送进加密函数的地方,前后不到五分钟。
3.2 RSA 加密的四个实操细节
找到加密函数之后,剩下的事就是把它翻译成 Python。理论上很简单——公钥加密、base64 编码,两行代码。但实际操作里有四个细节,每一个都能让你卡上半小时。
第一个是填充模式。前端库里用的基本都是 PKCS#1 v1.5 填充,Python 侧对应Crypto.Cipher.PKCS1_v1_5。如果你用成了 OAEP,加密能成功,但服务端一定解密失败,返回的还是那句含糊的"用户名或密码错误",非常误导人。第二个是公钥格式。从接口拿到的一般是不带 PEM 头尾的裸 base64 串,需要自己补上-----BEGIN PUBLIC KEY-----和-----END PUBLIC KEY-----,再按 64 字符换行,RSA.import_key才认。第三个是明文长度限制。RSA 单次能加密的字节数受密钥长度限制,1024 位密钥最多 117 字节明文,超了会直接抛异常。所以很多站点会在密码前面拼一个盐值或哈希,而不是直接加密原始密码,你翻译的时候要原样保留拼接顺序。第四个是输出编码。前端经常用 URL 安全的 base64,也就是把+换成-、/换成_,并去掉末尾的=。你如果原样输出标准 base64,服务端会判定格式非法。
import base64 from Crypto.PublicKey import RSA from Crypto.Cipher import PKCS1_v1_5 def rsa_encrypt(plain: str, salt: str, pub_key_b64: str) -> str: """按前端逻辑:盐值 + 明文 拼接后做 RSA 加密,输出 URL 安全 base64""" message = (salt + plain).encode("utf-8") # 补全 PEM 头尾,每 64 字符换行 body = "\n".join(pub_key_b64[i:i + 64] for i in range(0, len(pub_key_b64), 64)) pem = f"-----BEGIN PUBLIC KEY-----\n{body}\n-----END PUBLIC KEY-----" key = RSA.import_key(pem) cipher = PKCS1_v1_5.new(key) encrypted = cipher.encrypt(message) return base64.b64encode(encrypted).decode().replace("+", "-").replace("/", "_").rstrip("=")这段代码我反复调了三轮才对上。第一轮忘了补 PEM 头,报ValueError: RSA key format is not supported;第二轮补了头但没做换行,还是同样的错;第三轮填充对了、编码对了,但拼接顺序搞反了,变成"明文 + 盐值",服务端照样不认。所以别嫌麻烦,每改一处就单独打一次日志,把加密前后的结果打印出来,和浏览器里抓到的密文对着看——虽然每次密文都不同(PKCS#1 v1.5 填充带随机字节),但长度必须完全一致,长度对了说明密钥长度和填充模式都对了。
3.3 参数顺序与签名:错一位就全错
有些站点不止加密密码,还会对整个参数集合做一次签名。签名逻辑通常是把若干字段按某种顺序拼成一个字符串,再和某个固定串一起做哈希。这里的坑在于"顺序",而且这个顺序往往和 payload 里的字母序、出现顺序都不一样,是前端代码里手写的。
我的做法是:在断点停下时,把参与拼接的那几个变量名和拼接表达式完整抄下来,一字不差地翻译。不要试图"优化"或者"按字母序排一下",因为服务端校验的是同一份逻辑,你只要有一个字段漏了、顺序换了、分隔符变了,签名就永远对不上,而报错信息通常不会告诉你是签名错了。
还有一个容易忽略的点是时间戳精度。有的接口要毫秒,有的要秒,还有的要求是字符串带三位小数。这个从浏览器抓到的值上直接就能看出来——数一下位数,13 位是毫秒,10 位是秒,别自己猜。另外时间戳和服务器时间偏差过大也会被拒,一般容忍窗口在几分钟内,所以别用写死的固定时间戳来"图省事",那只能成功一次。
4. 登录态的维持:cookie、token 与设备标识
4.1 Session 对象能省掉一半的麻烦
第一版代码我是裸用requests.post写的,登录成功后手动从响应头里抠Set-Cookie,再拼成字符串塞进下一次请求的 header。这套写法能用,但极其脆弱:多个Set-Cookie头的时候容易漏,Expires属性带逗号会截断字符串,跨子域名的 cookie 还得手动处理 domain 匹配。后来换成requests.Session,这些问题一次性消失。
Session对象内部维护一个 cookie jar,会自动解析响应头里的每一条Set-Cookie,按 domain 和 path 规则存储,后续请求自动带上匹配的那些。你唯一要做的是保证整个流程都复用同一个 session 实例,不要中途新建。这一点看起来是废话,但我确实踩过——在某个辅助函数里图省事写了requests.get(...)而不是session.get(...),结果那次请求没带上设备 cookie,服务端把它当成了一次全新的匿名访问,后面整条链路的状态就乱了。
import requests session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (...) Chrome/120.0.0.0 Safari/537.36", "Accept-Language": "zh-CN,zh;q=0.9", }) # 第一步永远是先拿设备标识,再谈登录 session.get("https://www.example-video.test/", timeout=10) # 之后所有请求都走 session,不要混用 requests.xxx key_resp = session.get("https://passport.example-video.test/api/getkey", timeout=10)提示:
User-Agent在整个流程里必须保持一致。先用移动端 UA 拿公钥,再用桌面端 UA 提交登录,是很典型的异常特征。这个细节在日志里完全看不出来,只能靠自查。
4.2 为什么必须先"逛一圈"再登录
这一条是我花了最久才想明白的。一开始我以为登录就是"拿公钥 → 加密 → 提交",三步搞定。实际上直接这么干,十次里有八次返回的是风控类的模糊错误。原因在于:服务端在首页响应的Set-Cookie里下发了一个设备标识,这个标识在登录接口里是要参与校验的,它证明"这个客户端至少正常加载过页面"。你跳过这一步,等于凭空冒出来一个 POST 请求,特征极其明显。
所以正确的顺序是:先 GET 首页(拿设备 cookie),再 GET 预登录接口(拿公钥和盐值),最后 POST 登录。三步都走同一个 session。这个顺序不能颠倒,也不能合并——我试过把预登录和登录合成一个函数连续调用,中间不加任何间隔,结果失败了;加个半秒的停顿就正常了。这说明服务端对"拿到公钥后立刻提交"这种机器特征是有感知的,人对人操作是要点时间的。
4.3 登录成功后怎么确认真的成功了
这里有个很常见的误判:看到登录接口返回了 200 就以为成功了。实际上很多站点的登录接口无论成功失败都返回 200,真正的结果藏在响应体里的一个状态码字段,或者藏在有没有下发关键的登录 cookie 上。
我的验证方式是双重的。先检查响应体里的状态字段,确认是成功值;然后再打一个需要登录态才能访问的接口,比如"获取我的基本信息",如果这个接口能正常返回用户数据,才算真的登录成功。这一步不能省,因为有些情况下服务端会先返回成功、再在后续请求里做二次校验,如果你不验证,会在写业务逻辑的时候才突然发现拿不到数据,那时候就更难定位了。
登录成功后,把session.cookies序列化存到本地,下次运行直接加载,可以省掉重复登录。但要注意存的时候把domain、path、expires一起存下来,只存 name 和 value 的话,加载回去可能不生效。
5. 风控与验证码:该收手时就收手
5.1 哪些行为会被判定为异常
风控系统不看你的代码写得好不好,它看的是行为特征。我把这次遇到和排查到的信号整理成一张表,左边是信号,中间是它在你日志里的表现,右边是我实际采取的应对。
| 风控信号 | 典型表现 | 我的应对方式 |
|---|---|---|
| 缺少设备标识 | 登录接口返回模糊错误,无具体原因 | 严格按首页 → 预登录 → 登录的顺序走 |
| UA 与请求头不一致 | 有时成功有时失败,复现困难 | 所有请求统一用同一套 header,集中配置 |
| 缺少来源页 | 返回参数错误或直接拒绝 | 补上 Referer 和 Origin,值与实际页面一致 |
| 请求间隔过短 | 连续多次失败后开始出现验证码 | 关键步骤之间加 0.5 到 1 秒停顿 |
| 时间戳偏差过大 | 提示签名或参数失效 | 用本机当前时间实时生成,不留缓存 |
| 短时间重复登录 | 账号被临时限制,提示稍后再试 | 单次运行只登一次,失败不自动重试 |
这张表里我最想强调的是最后一条。"失败不自动重试"这个设计,一开始我很不适应,觉得不够健壮。但实际经验是:登录失败的原因九成是参数或环境问题,重试一百次结果都一样,反而会把失败次数刷上去,触发账号级的临时限制。正确的做法是失败就退出,把响应体完整打印出来,人工分析。等你把原因修好了,一次就能过。
5.2 验证码出现后的处理思路
验证码出现,本质上是一个信号:服务端认为这次访问的可信度不够,要求额外证明。这时候继续用代码硬刚,方向就错了。我在代码里设置的策略是——检测到响应里出现验证码相关字段,立刻终止流程,打印提示,退出。
具体做法是在登录响应解析时判断几个特征:响应体里有没有验证码类型字段、有没有图片或滑块相关的资源地址、状态码是不是特定的那个值。命中任何一个,就raise一个自定义异常,由上层捕获后提示"需要人工处理,请手动登录后再导出 cookie"。
这个设计把"自动化"的边界划得很清楚:能自动的部分自动,不能自动的部分交给人。我见过太多人在这上面钻牛角尖,花大量时间去研究怎么自动过验证,最后既不稳定也不合适。相比之下,手动登录一次、把 cookie 导出成本地文件、后续复用,这个方案稳定得多,几分钟就能搞定,而且完全在规则允许的范围内。
5.3 账号与凭据的安全习惯
写这类脚本,最容易犯的低级错误是把账号密码硬编码在源码里,然后顺手推到公开仓库。我现在的习惯是:账号密码一律从环境变量读,代码里只留变量名;本地调试用一个.env文件,这个文件第一行就写进.gitignore;日志里打印参数时,密码字段统一替换成固定长度的星号,避免调试日志泄露。
import os from dotenv import load_dotenv load_dotenv() # 从 .env 读取,该文件不纳入版本管理 USERNAME = os.environ["SITE_USERNAME"] PASSWORD = os.environ["SITE_PASSWORD"] def mask(value: str, keep: int = 2) -> str: """日志脱敏:只保留首尾少量字符""" if len(value) <= keep * 2: return "*" * len(value) return value[:keep] + "*" * (len(value) - keep * 2) + value[-keep:]另外,cookie 文件本质上等同于登录凭据,拿到它就能以你的身份访问。所以这个文件的权限要收紧,别放在共享目录里,也别随手发给别人"帮我看看为什么跑不通"。真要给别人看日志,先把 cookie 值和 token 值替换掉。
6. 完整实操:从零跑通一个最小闭环
6.1 目录结构与依赖清单
整个项目我控制在四个文件以内,刻意不做过度设计。因为这个脚本的生命周期通常很短,接口一变就得改,结构越复杂维护成本越高。
project/ ├── .env # 账号密码,不入库 ├── config.py # 常量:域名、请求头、接口路径 ├── login.py # 核心:预登录、加密、提交、校验 └── cookies.json # 登录成功后持久化的会话依赖就三个:requests处理 HTTP 和会话,pycryptodome提供 RSA 实现,python-dotenv读取环境变量。装的时候注意包名是pycryptodome而不是pycrypto,后者已经很久没维护了,在新版本 Python 上编译经常失败。
pip install requests pycryptodome python-dotenvconfig.py里我只放真正不变的东西:域名、接口路径、固定的请求头。凡是会变的(公钥、盐值、时间戳)一律不放在这里,避免"配置里存了个过期值"这种隐蔽问题。
6.2 分步实现与关键代码
第一步,建立会话并获取设备标识。这一步的返回值我不关心内容,只关心session.cookies里多了什么。
def bootstrap(session, cfg): resp = session.get(cfg["HOME_URL"], timeout=10) resp.raise_for_status() # 打印一下拿到了哪些 cookie 名,方便核对 print("初始 cookie:", [c.name for c in session.cookies]) return resp第二步,预登录拿公钥和盐值。这里要特别注意返回结构,很多站点的公钥字段嵌套两层,还有的是在data下面包了一层。
def fetch_key(session, cfg): resp = session.get(cfg["KEY_URL"], timeout=10) data = resp.json() pub = data["data"]["public_key"] salt = data["data"]["salt"] print(f"公钥长度: {len(pub)}, 盐值长度: {len(salt)}") return pub, salt第三步,加密密码。直接复用 3.2 节那个函数。这里有个细节:盐值要不要参与拼接、拼在前面还是后面,完全取决于前端的实现,我这次是拼在前面。判断依据就是断点时看到的那个字符串拼接表达式,别自己发挥。
第四步,组装参数并提交。参数顺序在字典里其实不重要(服务端按 key 取),但如果涉及签名,拼接顺序就极其重要,必须严格照抄。
def do_login(session, cfg, username, password, pub, salt): import time, hashlib enc_pwd = rsa_encrypt(password, salt, pub) ts = str(int(time.time() * 1000)) payload = { "act": "login", "username": username, "password": enc_pwd, "ts": ts, "source": "main", } # 若前端有签名,在这里按原顺序拼串后再哈希 # sign_str = f"{payload['act']}|{payload['username']}|{ts}" # payload["sign"] = hashlib.md5(sign_str.encode()).hexdigest() headers = { "Referer": cfg["HOME_URL"], "Origin": cfg["ORIGIN"], "Content-Type": "application/x-www-form-urlencoded", } resp = session.post(cfg["LOGIN_URL"], data=payload, headers=headers, timeout=10) return resp.json()第五步,校验登录态并持久化。这一步别省,前面说过原因。
def verify_and_save(session, cfg): resp = session.get(cfg["PROFILE_URL"], timeout=10) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"登录态校验失败: {data}") with open("cookies.json", "w", encoding="utf-8") as f: json.dump(requests.utils.dict_from_cookiejar(session.cookies), f, ensure_ascii=False, indent=2) print("登录成功,cookie 已保存")这五步串起来大概一百二十行,逻辑非常直白。我把每一步都做成独立函数、独立打印日志,好处是失败时一眼就能看出断在哪一环——是没拿到设备标识,还是公钥解析错了,还是提交后被拒。这种"分段可观测"的设计,在调试阶段的价值远超所谓代码优雅。
6.3 运行验证与结果检查
跑起来之后,我会依次核对四件事。第一,看第一步打印的初始 cookie 列表,至少要有一个名字看起来像设备标识的项,如果列表是空的,说明首页请求没走通或者被重定向了,后面必然失败。第二,看公钥长度,正常是几百个字符,如果只有几十个,多半是取错了字段层级。第三,看登录响应的状态码字段,成功值和失败值分别是什么,提前记下来。第四,看校验接口有没有返回真实的用户信息。
这四步里任何一步不符合预期,我都会停下来单独调试,而不是硬着头皮往下跑。经验是:登录流程的错误会累积传递,前一步的小问题到后面会变成完全不相干的报错,让你往错误的方向排查。分段验证虽然啰嗦,但是最省时间的做法。
7. 排查实录:卡住我最久的几个问题
7.1 报错速查表
下面这些是我这次实际遇到过、或者排查过程中验证过的典型现象,整理成表方便对照。
| 现象或报错 | 最可能的原因 | 处理方式 |
|---|---|---|
| 公钥导入报格式错误 | 缺少 PEM 头尾,或没有按 64 字符换行 | 补全头尾并按行切分后重新导入 |
| 加密后长度和浏览器不一致 | 填充模式用错,用了 OAEP | 改用 PKCS1 v1.5 |
| 加密抛长度超限异常 | 明文超过密钥长度减 11 字节 | 确认前端是否也做了截断或前置哈希 |
| 提交后提示密码错误 | 拼接顺序反了,或编码不是 URL 安全 base64 | 对照前端拼接表达式,逐字符核对 |
| 提示参数缺失 | 漏了埋点之外的必填项 | 用逐字段删减法确定哪些是真正必填 |
| 偶发成功、多数失败 | 请求头不一致或缺少设备 cookie | 统一 header,严格按顺序走会话 |
| 提示操作频繁 | 短时间重复登录 | 停止自动重试,等待后再人工操作一次 |
| 登录接口返回成功但拿不到数据 | 只是第一步通过,会话未真正建立 | 用需登录的接口做二次验证 |
这张表里"偶发成功、多数失败"是最难查的一类,因为它不给明确报错。我的排查方法是:把成功那一次的完整请求(连同所有 header 和 cookie)导出,和失败那一次逐项对比,差异项就是嫌疑点。这个笨办法几乎百试百灵。
7.2 几个反直觉的细节
第一个细节:密文每次都不一样是正常的。PKCS#1 v1.5 填充会引入随机字节,同样的明文加密两次结果不同。我一开始以为这是 bug,反复检查了半天。正确的验证方式是比对长度,而不是比对内容。
第二个细节:cookie 有顺序。某些场景下同名不同 path 的 cookie 会同时存在,Session一般能处理对,但如果你手动拼 header,就可能带错了那个。这也是我不再手动拼 cookie 的原因之一。
第三个细节:参数里看着像乱码的字段,未必是签名。我一度花了两个小时去逆向一个看着很像哈希的字段,最后发现逐字段删减法一试,删掉它照样登录成功——它只是个统计埋点。所以先做减法,再做逆向,顺序别搞反。
实操心得:准备一份"对照清单",把每次成功的完整请求参数、header、cookie 快照存下来。等到某天突然跑不通了,第一件事不是改代码,而是拿新请求和旧快照逐项 diff。接口改版时,diff 出来的那一两处差异,就是全部答案。
第四个细节:间隔时间不是越短越好,也不是越长越好。太短触发风控,太长可能公钥过期。我实测下来,关键步骤之间留半秒到一秒,既接近真人操作节奏,也不会让预登录拿到的公钥失效。
这一套走下来,我最大的感受是:模拟登录的技术难度其实没有想象中高,真正消耗精力的是那些"看不见的约定"——请求的顺序、header 的一致性、参数的拼接顺序、编码的细节。这些东西没有任何文档会写,只能靠抓包、断点、对比一点点还原出来。而一旦你把这条链路完整走通一次,后面再遇到类似的登录场景,基本上半个下午就能复刻出七八成。至于那些需要绕过验证码、伪造设备指纹的路子,我的态度很明确:不碰。把边界守住了,这套东西用起来才踏实。