简介面向Java网络编程初学者与需要研究人人网校内登录流程的开发者这份代码示例给出一个可运行的自动登录思路重点解决旧版登录程序难以直接使用、依赖零散的问题。压缩包为7z格式共4个文件包含1个Java源码与3个zip依赖包整体约4.31MB源码之外还归档了日志、HTTP客户端与编码处理相关库添加后即可编译调试。已有43239人学习下载说明该示例在同类需求中具有一定参考价值。读者可从中了解登录请求构造、HTTP客户端调用及基础依赖配合方式并以此作为二次开发起点需要说明的是作者定位为简易版未包含登录后的业务功能更适合用来梳理流程、复现实验和抛砖引玉式学习。1. 校内社交平台自动登录为什么值得用 Java 重写一遍老牌校内社交平台的登录页十几年没大改表单字段、跳转逻辑、Cookie 命名都是那个年代的风格反而特别适合拿来练手。我最初的需求很土一个自己的账号需要每天固定时间把站内消息和几条业务数据拉下来归档手动点登录、点跳转、复制粘贴一周就烦了。后来干脆用 Java 写了个自动登录组件跑在定时任务里登录一次缓存会话失效了自动重登。这篇就把整条链路拆开环境怎么搭、参数怎么拼、密码怎么对、验证码触发了怎么办、Cookie 怎么存、第二天全挂了从哪查。适合手上有一堆重复登录动作、又不想每天手动点的后端和运维同学也适合刚学 HttpClient 想找个真实场景练的人。2. 拆登录链路从 GET 登录页到 POST 表单的四个请求节点很多人第一次写自动登录思路是「找到登录接口POST 账号密码完事」然后被 200 状态码骗一下午。老站的登录链路通常不是一次请求而是一串有状态的交互中间任何一个环节丢了后面全是错。所以第一步不是写代码是把浏览器 F12 打开把 Network 面板里的请求按顺序抄一遍。2.1 把四个节点画清楚再动手一个典型的校内社交站点登录链路大致是这四个请求序号请求作用关键产物1GET /login渲染登录页JSESSIONID 等初始 Cookie、隐藏字段2POST /login.do提交凭证Set-Cookie、302 Location3GET /home跟随跳转业务 Cookie、登录后页面4GET /api/xxx验证登录态200 或跳回登录页第 1 步经常被忽略。老站为了防止表单重放会在登录页里埋几个隐藏字段比如一次性 token、时间戳、来源渠道名字五花八门。这些值每次刷新页面都会变你硬编码一个写死第一次能过第二次就被服务端判成非法请求。所以正确的做法是每次登录都先 GET 一遍登录页把隐藏字段重新解析出来再拼进 POST 表单。第 2 步的关键不是响应体而是响应头。登录成功和失败很多时候返回的都是 302区别只在 Location 指向哪成功跳首页失败跳回登录页并带个 error 参数。你要是让 HttpClient 自动跟随重定向这个信息就丢了只能看到一个最终的 200 页面然后开始怀疑人生。所以我一般把重定向策略设成 NEVER自己判断 Location。第 3、4 步是验证环节。别信 POST 返回的那一句「登录成功」用业务接口验一次随便调一个需要登录才能访问的接口看它返回的是数据还是登录页 HTML。这一步看起来多余实际上是后面所有自动化逻辑的地基。2.2 用 Java 11 的 HttpClient 搭一个带 Cookie 的会话不用引第三方包JDK 自带的 java.net.http 就够用。核心是把 CookieManager 挂到 HttpClient 上之后同一个 client 发出的所有请求自动带 Cookie、自动收 Cookie。import java.net.CookieManager; import java.net.CookiePolicy; import java.net.http.HttpClient; import java.time.Duration; public final class SessionFactory { // 统一 UA整条链路都用这一个别中途换 public static final String UA Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36; public static final String BASE https://campus.example.com; public static HttpClient newSession() { // ACCEPT_ALL把服务端下发的所有 Cookie 都收下来包括 Path 很深的那些 CookieManager cm new CookieManager(null, CookiePolicy.ACCEPT_ALL); return HttpClient.newBuilder() .cookieHandler(cm) .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NEVER) // 关键自己判断 302 .build(); } }几个参数值得说清楚。CookiePolicy.ACCEPT_ALL是必须的默认策略是 ACCEPT_ORIGINAL_SERVER在某些域名跳转场景下会把 Cookie 丢掉表现就是「明明抓到了 Set-Cookie下一个请求却没带上」。followRedirects(NEVER)前面解释过是为了留住 302 的 Location。connectTimeout设 10 秒足够老站响应慢的时候 5 秒容易误判超时。还有一个容易踩的点一个 HttpClient 实例就是一个会话。别每次请求 new 一个那样 Cookie 全丢。我一般把 client 当成单例和账号一一对应多账号就多实例互不干扰。2.3 解析登录页里的隐藏字段拿到登录页 HTML 之后用 Jsoup 按表单选择器把 input 全抓出来。这里不要只抓 token把所有name属性存在的 input 都收进 Map服务端多埋一个字段你就少踩一次坑。import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import java.util.LinkedHashMap; import java.util.Map; public static MapString, String parseLoginForm(String html) { Document doc Jsoup.parse(html, UTF-8); MapString, String form new LinkedHashMap(); // 选择器按实际页面改先确认登录表单的 id 或 class for (Element input : doc.select(form input[name])) { String type input.attr(type).toLowerCase(); // 未勾选的 checkbox / radio 不提交这是浏览器默认行为 if ((checkbox.equals(type) || radio.equals(type)) !input.hasAttr(checked)) { continue; } form.put(input.attr(name), input.attr(value)); } return form; }LinkedHashMap而不是 HashMap是为了让提交顺序和页面一致。有些老站的签名逻辑会把参数按原始顺序拼串再算 hash顺序变了签名就错报错信息还只给你一句「参数错误」非常折磨人。Jsoup.parse(html, UTF-8)里的编码要跟响应头对上。如果服务端返回的 Content-Type 里没写 charsetJsoup 会按 meta 标签猜猜错了中文就变问号隐藏字段里的中文值跟着一起坏。2.4 提交表单并判断登录结果POST 的时候Content-Type 必须是application/x-www-form-urlencoded参数用URLEncoder编码后拼。请求头里 Referer 和 Origin 建议带上很多站的拦截规则会校验来源页。import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; import java.util.stream.Collectors; public static HttpResponseString doLogin(HttpClient client, MapString, String form, String username, String encryptedPwd) throws Exception { form.put(username, username); form.put(password, encryptedPwd); String body form.entrySet().stream() .map(e - URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8) URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) .collect(Collectors.joining()); HttpRequest req HttpRequest.newBuilder() .uri(URI.create(SessionFactory.BASE /login.do)) .timeout(Duration.ofSeconds(15)) .header(Content-Type, application/x-www-form-urlencoded; charsetUTF-8) .header(User-Agent, SessionFactory.UA) .header(Referer, SessionFactory.BASE /login) .header(Origin, SessionFactory.BASE) .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build(); return client.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); }判断结果我给三个信号按可靠性排序第一看 Location。302 且 Location 里不含login、error、fail这类词基本可以判定成功。这是最快也最准的。第二看 Set-Cookie 有没有多出新的业务 Cookie。老站登录成功一般会种一个用户标识名字可能是 uid、userid、token 之类登录前没有、登录后有说明会话升级了。第三跟随到首页检查页面里有没有用户名或者退出登录的链接。这一步最笨但最保险适合前面两个信号都不明确的情况。三个信号里有两个对上我就认为登录成功继续往下走。3. 密码与表单参数加密方式定位和参数拼装账号密码这一块新手最容易卡住的地方不是代码是「我明明填的是明文密码为什么抓包看到的是一串 32 位字符」。老站的前端基本都做过处理你照着明文提交服务端一句话不说直接返回登录失败连错误提示都不给。3.1 先在浏览器里把加密方式定位出来打开 DevTools 的 Sources 面板全局搜索这几个关键词password、md5、encrypt、RSA、publicKey。一般能搜到登录提交的那段 JS。找到之后在提交函数那行下断点重新点一次登录看 password 变量在提交前被赋值成了什么。常见的形态有这么几种按出现频率排明文直接提交只在传输层靠 HTTPS 保护多见于很新的站单次 MD5hex(md5(password))加盐 MD5hex(md5(hex(md5(password)) salt))salt 可能是固定串也可能是时间戳RSA 公钥加密公钥写死在 JS 里或者从接口拉自研的字符替换混淆这种最少见但最恶心只能照着 JS 逻辑一比一翻译定位的时候注意一个细节断点打在submit之前还是之后结果完全不同。打在之后你看到的是已经加密完的值打在之前看到的是明文要靠这一步确认到底有没有加密。3.2 MD5 形态的 Java 实现MD5 是最常见的JDK 自带 MessageDigest不用引包。注意输出必须是补零的小写十六进制Java 的%02x已经帮你处理了补零。import java.nio.charset.StandardCharsets; import java.security.MessageDigest; public static String md5Hex(String input) throws Exception { MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(input.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(32); for (byte b : digest) { sb.append(String.format(%02x, b)); // 补零别用 Integer.toHexString } return sb.toString(); } // 双层加盐先 md5 一次拼上 salt 再 md5 一次 public static String md5WithSalt(String plain, String salt) throws Exception { String once md5Hex(plain); return md5Hex(once salt); }这里有两个血泪经验。第一不要用Integer.toHexString(b 0xff)手写容易漏补零长度变成 31 位服务端校验直接失败而且不报错。第二salt 有可能是动态的比如取当前时间戳的分钟数这种情况必须把时间戳一起提交上去否则服务端算出来对不上。看 JS 的时候把 salt 的来源一并确认。3.3 RSA 公钥加密的 Java 实现如果 JS 里出现JSEncrypt、setPublicKey这类字样基本就是 RSA。公钥一般是一段 Base64 字符串直接拿来用。import javax.crypto.Cipher; import java.security.KeyFactory; import java.security.PublicKey; import java.security.spec.X509EncodedKeySpec; import java.util.Base64; public static String rsaEncrypt(String plain, String base64PubKey) throws Exception { byte[] keyBytes Base64.getDecoder().decode(base64PubKey); PublicKey pk KeyFactory.getInstance(RSA) .generatePublic(new X509EncodedKeySpec(keyBytes)); // 填充方式必须和前端一致JSEncrypt 默认就是 PKCS1Padding Cipher cipher Cipher.getInstance(RSA/ECB/PKCS1Padding); cipher.init(Cipher.ENCRYPT_MODE, pk); byte[] out cipher.doFinal(plain.getBytes(java.nio.charset.StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(out); }填充方式对不上是最常见的翻车点。前端用 PKCS1Padding你这边写成 OAEPWithSHA-256AndMGF1Padding服务端解出来是乱码返回的还是那句「用户名或密码错误」。判断方法很简单看 JS 里有没有显式指定 padding没有的话八成就是 PKCS1。还有一种情况公钥不是固定的每次打开登录页由接口下发。那就得先 GET 一个/api/publicKey之类的地址拿到公钥再加密。这类接口通常不需要登录态但可能需要带上第 1 步拿到的 Cookie。3.4 参数拼装的四个细节参数拼装看起来是最没技术含量的部分实际上坑最多。第一隐藏字段要在每次登录前重新解析。别把上一次的 token 复用很多站是一次性的用过即失效。第二URLEncoder 编码一次就够。我见过有人先对参数值编码一次拼成 body 之后又对整个 body 编码一次结果服务端收到的密码是%25E4%25B8%25AD这种东西。正确做法是只对 key 和 value 分别编码拼之后不要再动。第三空格会被编码成而不是%20这是application/x-www-form-urlencoded的规范行为服务端一般能正确解码。如果遇到服务端不认的情况用URLEncoder.encode(v, UTF_8).replace(, %20)兜一下。第四记得带rememberMe或者autoLogin这类字段。老站经常会有一个「下次自动登录」的勾选项勾上之后服务端下发的 Cookie 有效期会从会话级变成 7 天甚至 30 天。这对我们做定时任务很有价值能大幅降低登录频次。4. 验证码与风控触发条件、人工接管与频率控制验证码这块我的态度很明确不做自动识别。一是识别率不稳定二是高频尝试本身就会让账号进入风险名单为了省一次手动输入把账号搭进去不划算。所以我的方案是「探测 人工接管」程序发现需要验证码就停下来把图片存到本地人看一眼输进去流程继续。4.1 先探测不要一上来就假设有验证码大部分校内社交站点并不是每次登录都要验证码。通常的规则是同一 IP 或同一账号连续失败若干次之后才触发正常登录是不需要的。所以第一步是探测而不是预处理。探测有三个位置登录页 HTML 里有没有img标签的 src 含captcha、verify、code这类片段表单里有没有名字类似captcha、vcode、checkcode的 input直接发一次错误密码看返回体里有没有「请输入验证码」之类的提示第三种最直接但会消耗一次失败次数所以我一般用前两种做静态判断判断不出来才用第三种。public static boolean needCaptcha(String loginHtml) { Document doc Jsoup.parse(loginHtml, UTF-8); boolean hasImg !doc.select(img[src*captcha], img[src*verify], img[src*code]).isEmpty(); boolean hasField !doc.select(input[namecaptcha], input[namevcode], input[namecheckcode]).isEmpty(); return hasImg || hasField; }选择器里的属性值用*做包含匹配是因为老站的命名不统一有的叫captchaImg有的叫verify_code_img精确匹配会漏。这段判断宁可误报也别漏报误报最多是多问一次人工漏报就是直接登录失败还找不到原因。4.2 触发之后的人工接管流程探测到需要验证码流程这样走从登录页里解析出验证码图片的完整 URL带上当前会话 GET 下来按时间戳存成本地 PNG路径打印到控制台控制台阻塞读一行输入把用户敲的 4 位字符塞回表单的 captcha 字段继续走正常的 POST 流程public static String fetchCaptcha(HttpClient client, String imgUrl, String saveDir) throws Exception { HttpRequest req HttpRequest.newBuilder() .uri(URI.create(imgUrl)) .header(User-Agent, SessionFactory.UA) .header(Referer, SessionFactory.BASE /login) .GET().build(); HttpResponsebyte[] resp client.send(req, HttpResponse.BodyHandlers.ofByteArray()); if (resp.statusCode() ! 200) { throw new IllegalStateException(验证码图片拉取失败: resp.statusCode()); } Path file Path.of(saveDir, captcha_ System.currentTimeMillis() .png); Files.write(file, resp.body()); System.out.println(验证码已保存: file.toAbsolutePath()); return file.toString(); }这里用BodyHandlers.ofByteArray()而不是ofString图片是二进制用字符串接会破坏字节。存盘之后人工打开看输入完再继续。整个流程是半自动的跑定时任务的时候如果碰上验证码任务会挂起等人处理这个设计取舍我认为是合理的——总比账号被限制强。如果确实需要全自动常见做法是接第三方的识别服务但这会引入外部依赖和额外成本而且识别失败时的重试逻辑更复杂。我的建议是先跑一段时间统计一下验证码的触发频率如果一周才触发一次真没必要上识别。4.3 请求节奏和请求头一致性风控不光看验证码还看你的请求行为像不像人。这块有几个硬性要求UA 全链路统一。从 GET 登录页到 POST 到后续业务请求全部用同一个 User-Agent 字符串。中途换一次比如登录时用 Chrome、拉数据时用 Java 默认的 UA服务端很容易识别出来。带上 Referer 和 Origin。尤其是 POST 请求Referer 指向登录页Origin 指向站点根域名。这两个头缺失在很多站的拦截规则里是直接拒绝的。控制请求间隔。登录成功后立刻并发拉十几个接口这个行为模式跟人差太远。我一般会在业务请求之间加 300 到 800 毫秒的随机延迟用Thread.sleep就行别用固定值固定间隔本身也是一种特征。private static void politeSleep() { try { long ms 300 (long) (Math.random() * 500); Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }失败退避。登录失败不要立刻重试。我用的策略是 5 秒、15 秒、60 秒三档退避三次都失败就停手记录日志等人看。连续快速重试是最容易被判定为异常的行为也是最没意义的行为——参数错了重试一百次还是错。5. 排查清单五个最常见的翻车现场这套东西我前后改过好几版踩过的坑基本集中在下面五类。每一条我都按「现象 → 原因 → 解决」写照着对一遍能省不少时间。5.1 现象返回 200但登录其实是失败的现象POST 登录接口返回 200响应体是一大段 HTML代码里判断statusCode 200就认为成功继续往下跑结果后续接口全部跳登录页。原因老站的登录失败往往不是 4xx/5xx而是「重新渲染登录页 页面上给一句红字提示」。状态码层面完全正常只有响应体里能看出区别。解决不要只看状态码。判断逻辑改成三选二Location 不含 login/error、Set-Cookie 新增了业务 Cookie、响应体里不含「密码错误」「验证码错误」这类关键字。把失败时的响应体原样落盘出问题直接翻文件比在控制台猜快得多。5.2 现象Cookie 抓到了业务接口还是跳登录现象登录请求的响应头里明明有 Set-Cookie日志也打出来了但下一个业务请求过去返回的还是登录页。原因三种可能。一是 Cookie 的 Path 设得很深比如/user/而你的业务接口在/api/下面CookieManager 按 Path 匹配就不带了。二是响应里有多个 Set-Cookie你只取了第一个。三是 Domain 属性带了前导点跨子域的时候匹配规则和你预期不一致。解决先打印cookieManager.getCookieStore().getCookies()看实际存进去几条、各自的 domain 和 path 是什么。如果确认是 Path 问题可以绕开 CookieManager手动维护一个MapString,String每次请求自己拼Cookie头。这种做法土但可控尤其是对接那些 Cookie 属性写得不规范的老站反而省事。5.3 现象本地算出来的密码哈希和抓包对不上现象浏览器抓包看到的 password 是 32 位十六进制你本地用md5Hex(plainPassword)算出来是另一串怎么都对不上。原因前端做的不是单次 MD5。常见的是md5(md5(pwd) salt)或者把密码先做一次 Base64 再 MD5或者对结果做了大小写转换、截断。解决回到 Sources 面板在提交函数里把中间变量一个个打印出来。最笨但最快的办法是在 JS 里console.log每一步的中间值然后拿你 Java 里对应步骤的结果去比对第一个对不上的地方就是分叉点。别靠猜猜不出来。5.4 现象昨天还能跑今天全部失败现象代码一个字没改前一天跑得好好的第二天早上定时任务全部报登录失败。原因最可能是隐藏字段变了。老站改版、加字段、换 token 生成规则都会导致硬编码的参数失效。其次是 Cookie 有效期到了但代码没做失效检测拿着过期 Cookie 一直请求。解决两件事。第一隐藏字段永远动态解析一个都不硬编码这样站点加字段你也不受影响。第二加健康检查每个任务开始前先调一次轻量接口确认登录态有效无效再重新登录。别等业务跑到一半才发现会话过期。5.5 现象中文昵称提交后变乱码现象账号或某些带中文的参数提交上去服务端识别不出来或者存的昵称是一堆问号。原因编码不一致。要么是URLEncoder.encode没指定字符集用了平台默认编码Windows 上可能是 GBK要么是响应解析时Jsoup.parse的编码写错要么是BodyPublishers.ofString没传 charset。解决所有涉及字符串和字节转换的地方显式写StandardCharsets.UTF_8一个都别省。URLEncoder.encode(v, StandardCharsets.UTF_8)、BodyPublishers.ofString(body, StandardCharsets.UTF_8)、BodyHandlers.ofString(StandardCharsets.UTF_8)三处都写上。这类问题排查起来不难难的是你根本想不到是编码问题所以干脆一开始就写死。6. 把登录做成可复用组件健康检查与失效重登登录代码写完只是开始真正让它能长期跑的是外面那层壳。我的做法是把「登录」和「用登录态」彻底分开登录逻辑封装成一个方法返回一个可用的 HttpClient业务代码只依赖这个 client不关心它是新登录的还是从缓存恢复的。具体实现上我加了两层。第一层是 Cookie 持久化登录成功后把 CookieStore 里的内容按domain / path / name / value / maxAge五个字段写到本地文件下次启动先尝试加载加载完做一次健康检查通过就直接用省掉一次登录。第二层是失效重登业务请求如果发现返回的是登录页特征就触发一次重新登录重登成功后把原请求重放一次。public class LoginManager { private HttpClient client; private final Path cookieFile Path.of(./runtime/cookies.txt); public HttpClient get() throws Exception { if (client null) { client SessionFactory.newSession(); if (!loadCookies(client) || !alive(client)) { relogin(); } } return client; } // 用轻量接口判断登录态别用首页首页太重 private boolean alive(HttpClient c) { try { HttpRequest req HttpRequest.newBuilder(URI.create(SessionFactory.BASE /api/profile)) .header(User-Agent, SessionFactory.UA) .timeout(Duration.ofSeconds(10)) .GET().build(); HttpResponseString resp c.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); return resp.statusCode() 200 !resp.body().contains(loginForm); } catch (Exception e) { return false; } } private void relogin() throws Exception { client SessionFactory.newSession(); // 这里走第 2、3、4 章的完整流程GET 登录页 - 解析 - 加密 - POST - 验证 // 成功后落盘 saveCookies(client, cookieFile); } }alive方法里选接口有讲究。别用首页首页可能带大量静态资源和推荐内容响应体大、判断还不可靠。找一个返回 JSON 的个人信息接口成功时返回 JSON、未登录时返回登录页 HTML判断contains(loginForm)就够了。如果你的站点未登录返回的是 302那就判状态码。Cookie 落盘的时候记得把maxAge一起存。会话级 Cookie 的 maxAge 是 -1存下来之后下次加载要按会话级处理别当成永久有效否则你会拿着一个早就过期的会话去请求然后困惑为什么服务端不认。最后说个习惯。做这类自动化我一般会把「登录成功」和「业务成功」分开打日志登录成功记一行业务请求失败记一行并带上响应体前 500 字符。出问题的时候看日志就能定位是登录环节挂了还是业务环节挂了不用重现。从那以后我每次接这种定时任务都先写健康检查再写业务逻辑顺序反过来的项目基本都返工过。希望帮到你。本文还有配套的精品资源点击获取