ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java自动登录实战:RSA加密、Cookie管理与会话保活

Java自动登录实战:RSA加密、Cookie管理与会话保活

简介:这是一份面向Java初学者与网络请求实践者的简易登录示例,围绕人人网(校内)自动登录场景整理,重点解决旧版登录程序难以直接复用、依赖缺失和运行环境不明确等问题。压缩包共4个文件,整体约4.31MB,内含1个Java源码文件和3个依赖库压缩包;源码承担登录主流程,依赖包则覆盖HTTP通信、编码处理与日志输出等常用组件,需按压缩包内说明自行添加后再运行。已有43239人学习下载,可见这一旧版登录案例仍有一定参考热度。读者可据此了解登录请求的构造方式、请求参数与响应处理思路,以及第三方库的引入和配置方法,并在此基础上扩展验证码识别、会话保持、登录结果解析等功能。源码保持精简,适合作为研究旧站点登录机制、完成课程练习或进行二次开发的入门样本。

1. 一个登录按钮背后的三次失败

去年帮朋友做一个校园社交平台的数据备份小工具,第一步就卡在自动登录上。抓包看登录请求,账号是明文,密码却是一长串 Base64,直接复制粘贴到 Java 代码里跑,返回永远是「参数错误」。前后改了三版:第一版没带初始 Cookie,第二版用错了 RSA 填充模式,第三版忘了把时间戳字段拼进去。等这套流程跑通,我才意识到所谓「自动登录」根本不是发一个 POST 那么简单,它是一条由预请求、加密参数、会话票据、保活定时器拼起来的链子,任何一环对不上,服务器就只回你一个 200 加一句模糊的错误提示。

这份 Java 代码做的就是这条链子:拿到账号密码之后,自动完成加密、提交、验证码分支处理、Cookie 落盘与失效重登,把「登录态」变成一个可以反复取用的对象。适合两类人:一类是手上有自己的老账号、想把里面的内容批量导出来的开发者;另一类是想学 HTTP 模拟登录这套基本功的人——它是练手 Jsoup、HttpClient、Cookie 管理最典型的场景,比拿一个公开 API 打转要真实得多。下面的拆解都基于一个同构的模拟项目,接口路径和字段名做了替换,逻辑结构保持一致。

2. 项目定位与技术选型:Java 写自动登录,该上哪些轮子

2.1 模拟项目做到哪一步

先把边界说清楚,免得读下去预期错位。这个模拟项目不是一个带界面的客户端,它是一个纯后端的小型 Java 工程,对外暴露三个能力:login(account, password)返回登录结果、alive()判断当前会话还有没有效、getHttpClient()交出一个已经带上 Cookie 的客户端实例,后续所有业务请求直接复用。换句话说,它把「登录」这件事封装成一个可注入的基础设施,业务侧不关心密码怎么加密的、Cookie 存在哪。

它覆盖的场景包括:明文表单登录、密码前端 RSA 加密后登录、带图形验证码的登录、登录成功后 Cookie 序列化到本地文件、定时探测会话有效性并在失效时自动重登。不覆盖的场景也要讲明白:扫码登录、短信验证码登录、需要滑块轨迹验证的登录,这些不在工程范围内,强行加进来的复杂度远超这份代码的价值。

工程规模上,核心类大概七八个,加上工具类和测试不到两千行。这个体量是有意控制的结果——登录逻辑一旦写厚,改一个字段就要动三个文件,反而是负担。我在实际项目里见过把整个风控对抗逻辑塞进登录模块的,最后没人敢碰,账号一被封就全组加班。

技术栈选得很朴素:JDK 8 或 11、Apache HttpClient 4.5 负责发请求、Jackson 解析返回的 JSON、SLF4J + Logback 打日志、JUnit 4 写单测,验证码识别部分留了接口,默认实现是保存图片到本地让人工输入,需要自动化时接一个 OCR 或打码服务。没有 Spring,没有数据库,配置文件就是一个config.properties。

选 JDK 8 起步不是怀旧。这类工具很多跑在别人的旧机器或者内网服务器上,装个 JDK 17 反而给自己找麻烦,用到的新语法也就是var、Optional、java.time这几样,向下兼容没什么成本。HttpClient 4.5 是这套技术栈里最稳的一环,连接池、Cookie 管理、重试策略都是现成的,比手搓HttpURLConnection少写几百行。

2.2 HTTP 客户端选型:HttpClient、OkHttp、Selenium 各管什么

这部分值得单独讲,因为选错客户端的代价是重写整个登录模块。三个候选的定位差别很大:

方案适合场景代价
Apache HttpClient 4.5接口字段固定、无 JS 执行、纯表单提交需要自己还原加密参数
OkHttp 3/4同上,链式 API 更顺手,Android 端复用方便生态文档比 HttpClient 少一些
Selenium / HtmlUnit页面靠 JS 动态生成参数、有复杂风控启动慢、内存高、并发差

判断标准其实只有一条:登录参数是不是由前端 JS 在运行时算出来的。如果密码加密用的公钥写死在 JS 文件里、时间戳就是Date.now(),那 HttpClient 完全够用,你把参数还原出来就行。如果参数里带了浏览器指纹、鼠标轨迹、canvas哈希这些东西,纯 HTTP 模拟就会被识别,这时候才轮到 Selenium 出场。

这个项目选 HttpClient,原因是目标站点的加密逻辑相对干净:一个固定公钥的 RSA、一个毫秒时间戳、一个来源域名,没有动态下发密钥。我在实际动手前会先做一件事——把登录页的 JS 全部prettify一遍,搜索encrypt、password、publicKey这几个关键词,五分钟就能判断出属于哪一档。这个习惯省下的时间比事后调试多得多。

提示:不要把 Selenium 当成万能回退方案。它解决的是「浏览器环境模拟」,不是「登录逻辑本身」,风控升级到设备指纹层面时,它一样会被拦,而且调试成本翻倍。

2.3 目录结构与模块划分

项目结构按职责切分,每层只做一件事:

auto-login-demo/ ├── src/main/java/com/example/login/ │ ├── LoginClient.java # 对外入口,串起整个登录流程 │ ├── LoginResult.java # 登录结果值对象 │ ├── Credential.java # 账号凭据,含账号/密码/验证码 │ ├── http/ │ │ ├── HttpClientFactory.java # 连接池、超时、UA 统一配置 │ │ └── CookieStore.java # Cookie 获取、落盘、过期过滤 │ ├── crypto/ │ │ └── RsaEncryptor.java # 密码加密,公钥可配置 │ ├── captcha/ │ │ ├── CaptchaSolver.java # 识别接口 │ │ ├── ManualSolver.java # 默认:存图 + 控制台输入 │ │ └── OcrSolver.java # 可选:接第三方识别 │ └── keepalive/ │ └── SessionKeeper.java # 定时探测 + 失效重登 ├── src/main/resources/ │ └── config.properties # 域名、路径、公钥、UA、重试次数 └── src/test/java/ # 单测,用本地 Mock Server 跑

这个划分的关键在于http和crypto两个包不依赖业务语义。换一个站点,理论上只需要改config.properties和LoginClient里的字段拼装,加密和 Cookie 这两块可以原样搬走。实际迁移的时候确实如此,我把它挪到第二个站点上只改了一个类。

config.properties里放的是易变项,把硬编码从代码里彻底赶出去:

login.base.url=https://example-campus.com login.page.path=/login login.submit.path=/login.do login.rsa.publicKey=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ... login.userAgent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 login.retry.max=3 cookie.file=./data/cookie.bin keepalive.interval.minutes=10

公钥放配置而不是常量,是因为它会变。站点前端换一次密钥,你只需要改一行配置重启,不用重新打包。这个细节在单人开发时无所谓,放到有 CI 的环境里就是省一次发版。

3. 登录链路拆解:一次成功登录要凑齐哪些参数

3.1 抓包定位真实登录接口

打开浏览器开发者工具,勾上Preserve log,因为登录成功后往往有一串 302 跳转,不勾的话日志会被清掉,你就只能看到跳转后的首页请求,找不到真正的提交地址。在登录页填一次账号密码点提交,在 Network 面板按Method: POST过滤,第一个application/x-www-form-urlencoded的请求基本就是目标。

点开这个请求,重点看三样:Request URL、Form Data、Response Headers里的Set-Cookie。Form Data 里会有一堆看着没用的字段,比如origURL、domain、autoLogin、_rt,别嫌烦,能带就带上——服务端常有「字段缺失即判为异常请求」的兜底逻辑,少一个就返回参数错误,而错误提示往往和真正的原因完全无关,这就是这个场景里最典型的黑匣子。

还有一个容易漏的点:登录页本身的 GET 请求。有些站点会在你打开登录页时下发一个初始 Cookie(比如风控标识、CSRF token),登录 POST 时必须带上,否则参数校验通过也会被判为跨站请求。所以完整流程是「先 GET 登录页 → 再 POST 登录接口」,两步之间共用同一个HttpClient实例和同一个 CookieStore。

3.2 密码加密参数的还原

抓包看到password字段是一串 Base64,长度 172 或 344 字符,这就是 RSA 加密的典型特征——1024 位密钥加密后 Base64 是 172 字符,2048 位是 344 字符。去前端 JS 里搜publicKey或者setPublicKey,能定位到公钥字符串和填充模式。

还原的时候有两个坑必须踩实。第一是填充模式,前端用的往往是RSA/ECB/PKCS1Padding,Java 默认的Cipher.getInstance("RSA")在部分 JDK 上等价于RSA/ECB/PKCS1Padding,但另一些实现会走不同 provider,为保险起见显式写全。第二是编码,前端JSEncrypt默认按 UTF-8 转字节再加密,Java 侧如果用了平台默认编码,中文密码或者特殊字符就会出问题。

public final class RsaEncryptor { private final PublicKey publicKey; public RsaEncryptor(String base64PublicKey) { try { // 前端导出的是 X509 格式的 SPKI,不是 PKCS#1,用 X509EncodedKeySpec 解析 X509EncodedKeySpec spec = new X509EncodedKeySpec( Base64.getDecoder().decode(base64PublicKey)); this.publicKey = KeyFactory.getInstance("RSA").generatePublic(spec); } catch (Exception e) { throw new IllegalStateException("公钥解析失败,检查是否被换过", e); } } public String encrypt(String plain) { try { // 必须和前端 JSEncrypt 的填充方式一致,否则解出来是乱码 Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); cipher.init(Cipher.ENCRYPT_MODE, publicKey); byte[] out = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(out); } catch (Exception e) { throw new IllegalStateException("密码加密失败", e); } } }

X509EncodedKeySpec和PKCS8EncodedKeySpec的区别是前者用于公钥、后者用于私钥,混用会直接抛InvalidKeySpecException。另外 RSA 单块加密有长度上限,1024 位密钥最多 117 字节明文,密码场景当然够用,但如果你打算把别的长字段也塞进去加密,就得改成分块或者换 AES 对称加密。

密钥版本号这个字段(示例里叫key_id)也不能丢。站点做密钥轮换时会同时保留新旧两把公钥,靠这个字段告诉服务端用哪把解,你不传它就默认用最新版,某天密钥一换,旧代码立刻全挂。

3.3 表单字段与请求头拼装

字段拼装单独抽一个类,别散在登录方法里。原因很实际:这些字段的增减频率比想象中高,抽出来之后改动面收敛在一处,也方便写单测对比抓包结果。

public final class LoginParamBuilder { public static Map<String, String> build(String account, String encryptedPwd, String captcha, long timestamp) { Map<String, String> form = new LinkedHashMap<>(); form.put("email", account); // 账号字段,部分站点叫 username form.put("password", encryptedPwd); // 已加密,不能传明文 form.put("icode", captcha); // 无验证码时传空串,不能省这个 key form.put("origURL", "http://example-campus.com/home"); form.put("domain", "example-campus.com"); form.put("key_id", "1"); // 公钥版本号 form.put("_rt", String.valueOf(timestamp)); // 毫秒时间戳,服务端做时效校验 return form; } }

LinkedHashMap不是随手选的,某些站点的签名逻辑对参数顺序敏感,用HashMap可能十次里错三次,而且错得毫无规律,属于典型的玄学 bug。时间戳的时效窗口一般在 5 到 30 分钟,跨时区或者服务器时间漂移都会导致「参数校验失败」,排查时先对一下本机时间和标准时间差多少。

登录主流程把前面几块串起来:

public LoginResult login(Credential credential) throws IOException { // 第一步:GET 登录页,拿初始 Cookie(风控标识/CSRF token) HttpGet page = new HttpGet(cfg.get("login.base.url") + cfg.get("login.page.path")); page.setHeader("User-Agent", cfg.get("login.userAgent")); try (CloseableHttpResponse resp = client.execute(page)) { EntityUtils.consume(resp.getEntity()); // 不消费实体会导致连接不归还连接池 } // 第二步:POST 登录接口 HttpPost post = new HttpPost(cfg.get("login.base.url") + cfg.get("login.submit.path")); post.setHeader("User-Agent", cfg.get("login.userAgent")); post.setHeader("Referer", cfg.get("login.base.url") + cfg.get("login.page.path")); post.setHeader("Content-Type", "application/x-www-form-urlencoded"); String encrypted = rsa.encrypt(credential.getPassword()); List<NameValuePair> form = new ArrayList<>(); LoginParamBuilder.build(credential.getAccount(), encrypted, credential.getCaptcha(), System.currentTimeMillis()) .forEach((k, v) -> form.add(new BasicNameValuePair(k, v))); post.setEntity(new UrlEncodedFormEntity(form, StandardCharsets.UTF_8)); try (CloseableHttpResponse resp = client.execute(post)) { int status = resp.getStatusLine().getStatusCode(); String body = EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); // 成功判定要「状态码 + 票据」双条件,只看 302 会误判 boolean hasTicket = cookieStore.contains("t") || cookieStore.contains("id"); boolean ok = (status == 302 || status == 200) && hasTicket; return new LoginResult(ok, status, body); } }

几个参数单独说。Referer必须带上,很多站点的服务端会校验来源,缺失时返回的仍然是 200,但内容是一段重定向脚本,光看状态码发现不了。EntityUtils.consume这一行看着多余,实际不加的话连接池很快就会耗尽,跑批几十次之后开始超时,你会误以为是对方限流。

判断成功与否是这个方法里最容易写错的地方。很多人只看status == 302就认为登录成功,但验证码错误、密码错误同样会 302 回登录页,只是Location不同。稳妥做法是双条件:跳转目标不是登录页,并且 Cookie 里出现了会话票据字段名。票据的 key 名从抓包时的Set-Cookie里抄,别猜。

4. 核心模块实现:Cookie 池、验证码与保活

4.1 CookieStore:登录态落盘与复用

登录一次要花两三秒,如果每次跑任务都重新登录,既慢又容易触发风控。正确做法是把 Cookie 序列化到本地,下次启动先尝试加载,加载成功就直接判活,活着就不登录。

public class CookieStore { private final BasicCookieStore store = new BasicCookieStore(); private final Path file; public CookieStore(Path file) { this.file = file; load(); } public synchronized void save() { try { Files.createDirectories(file.getParent()); try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(file))) { // org.apache.http.cookie.Cookie 实现了 Serializable,可直接序列化 out.writeObject(store.getCookies()); } } catch (IOException e) { log.warn("Cookie 落盘失败,不影响本次运行", e); } } @SuppressWarnings("unchecked") private synchronized void load() { if (!Files.exists(file)) return; try (ObjectInputStream in = new ObjectInputStream(Files.newInputStream(file))) { List<Cookie> list = (List<Cookie>) in.readObject(); for (Cookie c : list) { // 过期的不装载,否则会带着死 Cookie 发请求 if (c.getExpiryDate() == null || c.getExpiryDate().after(new Date())) { store.addCookie(c); } } } catch (Exception e) { log.warn("Cookie 文件损坏,忽略并重新登录", e); try { Files.deleteIfExists(file); } catch (IOException ignored) { } } } public synchronized boolean contains(String name) { return store.getCookies().stream().anyMatch(c -> c.getName().equals(name)); } public BasicCookieStore unwrap() { return store; } }

这段代码里最值得抄的是「过期过滤」和「损坏自愈」。Cookie 文件是二进制序列化格式,跨 JDK 版本或者中途被截断都会反序列化失败,ObjectInputStream抛的异常还挺迷惑,不捕获就是启动直接崩。删掉重来成本极低,没必要跟它较劲。

另外,序列化存 Cookie 意味着这个文件本身等价于账号凭据,谁拿到谁就能登录。所以我在落盘时会顺手设一下文件权限(Linux 下Files.setPosixFilePermissions设成 600),Windows 上至少别放在共享目录。这一点在本地开发时容易忽略,一旦代码跑在多人共用的服务器上就是事故。

4.2 验证码分支:OCR、打码、手动兜底

验证码是这个项目里唯一没法用纯代码稳定解决的问题,所以设计上做成策略接口,让三种实现可以随时切换。

public interface CaptchaSolver { /** @param image 验证码图片字节 @return 识别结果,无法识别返回 null */ String solve(byte[] image) throws Exception; } public class ManualSolver implements CaptchaSolver { @Override public String solve(byte[] image) throws Exception { Path tmp = Files.createTempFile("captcha-", ".png"); Files.write(tmp, image); // 弹系统默认看图程序,人工在控制台输入 Desktop.getDesktop().open(tmp.toFile()); System.out.print("请输入验证码: "); return new Scanner(System.in).nextLine().trim(); } }

手动方案看着土,但它是所有自动化方案的后备。我第一次写这套东西时直接上了 OCR,识别率 40%,失败了就重试,重试三次后账号被临时限制,得不偿失。后来改成「先 OCR,置信度低就落人工」,成功率立刻上去了,实际人工介入的比例不到 5%。

登录流程里判断是否需要验证码的依据是第一步 GET 登录页的返回:如果页面 HTML 里出现了验证码图片的src,或者接口返回里有个needCaptcha字段为 true,就走验证码分支。图片地址一般形如/getCaptcha.do?t=时间戳,这个时间戳不能复用,每取一次都要重新打,否则拿到的是同一张过期图。

重试的节奏也要控制。同一账号连续失败 3 次就停下来,隔几分钟再试,中间不要立刻换验证码猛刷。风控判断的依据往往不是失败次数本身,而是失败频率——一秒钟试五次和一分钟试三次,后果完全不同。

注意:验证码识别只用于自己账号的自动化登录,别拿去做批量注册或者撞库,那是另一个性质的事,跟技术无关。

4.3 保活与失效重登

登录态不是永久的。服务器端会话一般 30 分钟到几小时不等,业务侧如果正在跑长任务,中途掉线会导致后面所有请求返回登录页 HTML,而解析代码会给你一堆莫名其妙的空指针。

保活的做法是定时打一个轻量接口,看返回内容里有没有用户标识:

public class SessionKeeper { private final ScheduledExecutorService pool = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "session-keeper"); t.setDaemon(true); // 守护线程,主程序退出时自动结束 return t; }); public void start(LoginClient client, long intervalMinutes) { pool.scheduleWithFixedDelay(() -> { try { if (!client.alive()) { log.info("会话失效,触发重登"); client.relogin(); } } catch (Exception e) { // 这里必须吞掉异常,否则一次网络抖动会让定时任务永久停止 log.warn("保活探测异常,下个周期重试", e); } }, intervalMinutes, intervalMinutes, TimeUnit.MINUTES); } }

alive()的实现别用首页探测,首页往往是游客也能访问的,永远返回 200。要挑一个必须登录才能拿到的接口,比如用户信息或者消息数,看返回 JSON 里有没有userId字段,有就是活着。探测频率控制在 10 分钟一次,太频繁本身就是异常行为。

scheduleWithFixedDelay而不是scheduleAtFixedRate,是因为前者保证上一次执行完才计时下一次,不会出现任务堆积。最后一个坑是异常处理:ScheduledExecutorService里抛出的未捕获异常会静默终止后续调度,任务就再也不跑了,而日志上只有一行堆栈,很容易被忽略。所以try-catch包住整个任务体是必须的。

5. 避坑与排查:登录成功率从 30% 拉到 95% 的几条经验

5.1 现象:本地能登录,服务器上一直返回参数错误

原因基本锁定在时间戳。加密参数里的_rt或者ts字段是毫秒时间戳,服务端会校验它和服务器时间的差值,超出窗口就判为过期请求。本地机器常年开着时间同步没问题,容器或者虚拟机从快照恢复之后时间可能停在几个月前。

解决办法是启动时先做一次时间校准检查,误差超过 60 秒就打警告日志,并在日志里把本机时间和响应头里的Date都打出来。生产环境上建议直接接入时间同步,别在代码里做偏移补偿,那是给自己埋雷。

5.2 现象:抓包复制的请求在 Postman 里能通,Java 里跑不通

大概率是请求头差异。浏览器会自动带上Accept、Accept-Language、Accept-Encoding、Origin、Referer、User-Agent这一整套,而 HttpClient 默认只带Host和Connection。服务端如果做了来源校验,缺Origin或者Referer就会被拒。

排查方法是把浏览器请求头全量导出,在 Java 里逐个加上,每次去掉一个做二分,找出真正被校验的那几个。通常只有User-Agent、Referer、Origin三个是必需的,其余加上只是让请求更像浏览器。

5.3 现象:前 20 次调用正常,之后开始大面积超时

十有八九是连接没释放。CloseableHttpResponse用完必须关闭,而且要保证实体被消费掉,否则连接回不到池子里。EntityUtils.toString()会消费实体,但如果代码走了异常分支提前返回,实体就悬在那了。

解决方式是用 try-with-resources 包住每一次execute,并且在只关心状态码不关心内容的请求里显式调用EntityUtils.consume(resp.getEntity())。另外把连接池上限和validateAfterInactivity配上,默认值在长任务里不够用。

5.4 现象:登录成功但下一个请求返回的是登录页 HTML

这是 Cookie 域不匹配。BasicCookieStore里的 Cookie 带domain属性,如果登录接口在passport.example.com,业务接口在www.example.com,而 Cookie 的 domain 是.example.com,正常情况能共享;但如果你手动构造 Cookie 或者跨域抓取,domain 对不上,HttpClient 就不会把它发出去。

排查时打印store.getCookies()看每条的 domain 和 path,和实际请求的 URL 对比。修复办法是用HttpClientContext显式管理而不是让框架自动匹配,或者在构造BasicClientCookie时把 domain 设成父域。

5.5 现象:验证码明明输对了,还是提示错误

有两种可能。一是验证码和会话绑定,你取验证码图用的 Cookie 和提交登录用的 Cookie 不是同一套,服务端自然对不上,这种情况下必须保证「取图 → 提交」在同一个HttpClient实例上完成。二是验证码有一次性,识别失败后重新取图时旧的作废,如果你缓存了图片地址重复使用,拿到的就是上一张。

解决方式是每次重试都重新走一遍「GET 验证码图 → 识别 → 提交」,中间不缓存任何东西。这条在并发场景下更要小心,多线程共用一个 CookieStore 时记得给取图和提交加同步块。

6. 二次开发:把这套登录能力做成可复用的 LoginProvider

代码跑通之后,真正的价值在于让它能适配第二个、第三个站点,而不是每次复制粘贴改字段。我的做法是先抽一层接口,把「怎么登录」和「登录之后干什么」彻底分开。

public interface LoginProvider { LoginResult login(Credential credential) throws Exception; boolean alive(); void relogin() throws Exception; } // 具体实现里只关心本站在的表单和密码规则 public class CampusLoginProvider implements LoginProvider { private final LoginClient client; // 复用第三节的登录主流程 private final RsaEncryptor rsa; @Override public LoginResult login(Credential c) throws Exception { // 站点特有的差异化逻辑全部收敛在这里 return client.login(c, rsa); } @Override public boolean alive() { return client.checkUserInfo() != null; } @Override public void relogin() throws Exception { client.clearCookies(); } }

抽完这层之后,接新站点的成本从「改一周」降到「改一个实现类 + 一份配置」。我一般会顺手写一个LoginProviderFactory,根据配置里的站点标识返回对应实现,业务侧只依赖接口,连类名都不用知道。

改造完一定要验证,别信「本地跑通了」。我给自己定的自测清单是这几项:

验证项方法通过标准
冷启动登录删掉 cookie 文件后运行能拿到会话票据
Cookie 复用连续运行两次第二次不发起登录 POST
失效重登手动清空 CookieStore 触发保活30 秒内恢复可用会话
异常兜底断网后恢复定时任务不终止
并发安全5 线程同时取客户端不出现 Cookie 覆盖

其中「Cookie 复用」这条最容易被忽略,很多人写完登录就完事,结果每次跑任务都重新登录一遍,风控日志里全是同一个账号的高频登录记录。用抓包工具或者日志计数器确认第二次启动没有发出 POST,才算真的做完了缓存。

还有一个技巧值得单独说:把登录过程的每一步都打上带耗时的结构化日志,字段包括步骤名、状态码、关键响应字段、耗时毫秒。出问题时不用重新复现,翻日志就能定位到是取 Cookie 慢、加密失败还是判定逻辑误判。这套日志在我后来接手的每个爬虫类项目里都保留了,几乎没有例外。

从那以后我每次写模拟登录,都强制走一遍「先 GET 登录页、再 POST」的两步流程,哪怕看起来第一步毫无必要——因为一旦省掉,出问题时你根本分不清是参数错了还是会话没建立,只能从头再来一遍。希望帮到你。

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

RELATED READING

延伸阅读

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