Java抢票程序拆解:GOHOME自动购票工具的实现与边界
简介这份资源面向具备一定 Java 基础的开发者聚焦春运等购票高峰期的 12306 抢票场景提供一套可本地运行的自动化购票程序思路与配套说明。程序覆盖自动登录与验证码定位、失败自动重试、线路信息记忆与自动刷新查询、指定车次优先预订、动车与卧铺优先策略、代理配置以及 Chrome 与 Firefox 下的声音或弹窗提醒等环节验证码识别支持自动与手动两种方式登录外的设置可即时修改生效。压缩包共 1 个 doc 文件约 467KB以图文教程形式记录程序功能、配置项与使用要点便于对照理解各模块作用。目前已有 704 人学习浏览适合想了解网页自动化、表单填充与抢票工具实现思路的 Java 程序员参考也可作为爬虫与浏览器脚本学习的案例素材。1. 抢票 Java 程序拆解一个 GOHOME 自动购票工具的真实边界春节前那几天办公室里最常听见的对话不是“需求改完没”而是“你抢到票没”。有个做 Java 的同行在群里甩出一个压缩包说双击start.bat就能自动登录、自动刷票、自动提交订单还带验证码识别。我第一反应是这东西能跑第二反应是它到底怎么实现的拆开看了一圈发现它本质上是一个基于 HTTP 请求模拟的 Java 桌面程序用htmlparse解析页面、用定时器轮询余票、用配置文件驱动行为思路不复杂但工程细节不少。这篇笔记就把这个抢票 Java 程序的运行机制、配置参数、验证码识别逻辑和几个容易翻车的点拆开讲清楚适合想复现一套自动化购票流程、或者想学 Java 爬虫与定时任务配合的开发者参考。2. 运行环境与启动链路JRE 版本、start.bat 与配置文件加载顺序2.1 为什么必须锁死 JRE 1.6 以上这个程序是纯 Java 写的没有打包成 exe所以运行前提是机器上有 JRE。使用说明里写的是“版本必须大于 1.6”对应到具体安装包就是 Java Runtime Environment 6 Update 30 那一代。为什么强调 1.6因为程序里用到了java.util.Timer做轮询调度、用HttpURLConnection发请求、用ImageIO读验证码图片这些 API 在 1.5 到 1.6 之间有过行为调整尤其是HttpURLConnection的 keep-alive 默认策略和超时处理。低于 1.6 的 JRE 在长时间轮询时容易出现连接池不释放的问题表现就是跑十几分钟后请求全部卡住。检查本机 JRE 版本不用打开控制面板命令行一行就够java -version输出里看到1.6.x、1.7.x、1.8.x都可以。如果提示“不是内部或外部命令”说明 JRE 没装或者没进 PATH。常见做法是装完 JRE 后重新开一个命令行窗口让环境变量生效。注意这个程序没有用 Java 9 之后的模块化特性所以在高版本 JRE 上一般也能跑但验证码图片解码那块在 JDK 11 之后对某些 GIF 的调色板处理有变化如果遇到验证码读出来是空白图优先换回 1.8 试。2.2 start.bat 到底做了什么压缩包里的start.bat内容不复杂核心就是拼 classpath 然后调java命令。典型写法如下echo off set CLASSPATH.;lib\htmlparser.jar;lib\commons-httpclient.jar;gohome.jar java -cp %CLASSPATH% com.gohome.Main pause这段脚本做了三件事把当前目录、依赖 jar 和主程序 jar 拼成 classpath用java -cp启动主类pause让窗口在程序退出后停住方便看报错。逻辑说明-cp后面的顺序有讲究.放最前面是为了让同目录下的config.properties能被ClassLoader.getResourceAsStream优先读到。参数说明如果你的 JRE 装在非默认路径java命令可能找不到这时候要么把 JRE 的bin加进 PATH要么在 bat 里写绝对路径比如C:\Program Files\Java\jre6\bin\java.exe -cp ...。启动后程序会先读config.properties再初始化界面。配置文件加载顺序是先读 jar 包内的默认配置再用外部同名文件覆盖。所以改配置不用重新打包直接改压缩包解压目录下的config.properties就行。这一点在调试时很省事改完保存重启程序即生效。2.3 config.properties 的字段含义与改法配置文件是纯文本用记事本打开就能改。关键字段如下表字段示例值作用注意事项userproxytrue/false是否启用代理改成 true 后必须同时填 ip 和 portip127.0.0.1代理地址只写地址不带协议前缀port8080代理端口数字不要加引号traincodeD105优先车次只写车次号不写日期autologintrue是否自动填账号密码依赖 Cookies 里的记录改traincode的时候有个细节使用说明里写“只是优先预定该趟如果没票会自动选其他车次”。这意味着它不是硬过滤而是一个排序权重。如果你只想买指定车次、不接受其他车这个程序做不到严格锁定得自己在代码层加判断。我一般会把traincode填上同时把“动车优先”和“卧铺优先”两个开关按需打开让程序在多个候选车次里按优先级排队。3. 自动登录与余票轮询HTTP 会话保持和定时器调度3.1 登录流程的请求拆解自动登录不是模拟点击而是直接发 HTTP 请求。程序启动后先 GET 登录页从响应里抠出隐藏字段比如lt、_eventId这类表单参数然后 POST 用户名、密码和验证码。用htmlparse做页面解析的好处是不依赖浏览器内核坏处是页面结构一变就得改选择器。登录成功的标志是响应头里带回新的 Cookie程序把这组 Cookie 存进一个全局的HttpContext后续查票、提交订单都复用这个上下文。伪代码层面的逻辑大概是这样// 第一次 GET 登录页拿隐藏字段和初始 Cookie HttpGet loginPage new HttpGet(LOGIN_URL); HttpResponse resp client.execute(loginPage, context); String html EntityUtils.toString(resp.getEntity()); MapString, String form HtmlParser.extractHiddenFields(html); // 填账号密码和验证码POST 提交 form.put(username, user); form.put(password, pass); form.put(randCode, captchaText); HttpPost loginPost new HttpPost(LOGIN_URL); loginPost.setEntity(new UrlEncodedFormEntity(toPairs(form), UTF-8)); HttpResponse loginResp client.execute(loginPost, context); // 检查响应里是否包含登录成功标识 if (bodyContains(loginResp, 我的12306)) { loggedIn true; }逻辑说明context是HttpClient的会话容器Cookie 自动管理不用手动拼Cookie头。参数说明randCode就是验证码字段名不同版本的页面字段名可能不一样改的时候以实际抓包为准。这段代码的关键在于“先 GET 再 POST”少了 GET 那步隐藏字段拿不到POST 会被服务端拒绝。3.2 定时器轮询余票的节奏控制登录成功后程序起一个Timer每隔固定秒数查一次余票。间隔太短会被服务端限流太长会错过放票窗口。使用说明里没写具体间隔但从“2 分钟内抢到票”的描述反推查询间隔大概在 3 到 5 秒。我一般会把间隔设成 5 秒并在每次查询前随机加 0 到 2 秒的抖动避免请求时间戳过于规律。轮询的核心逻辑是查票接口返回 JSON 或 HTML 片段程序解析出车次、席别、余票数列成候选列表再按traincode、动车优先、卧铺优先三个维度排序取第一条有票的记录进入提交订单流程。这里有个容易忽略的点余票数显示“有”不代表一定能提交成功因为提交订单时服务端会再扣一次库存。所以程序在提交失败后要能回到轮询状态而不是直接退出。使用说明里提到的“自动提交订单直到订单成功”就是这个重试机制。3.3 验证码识别的现实准确率自动识别验证码这块原文作者自己也承认“10 次中只有一次是对的”。从反编译看到的思路大概率是基于颜色像素做字符分割再套模板匹配。12306 当年的验证码有干扰线和模糊效果像素法在字符粘连时基本失效。所以实际使用中手动输入验证码反而是更稳的选择。程序界面留了“自动识别”勾选框勾上之后无人值守能跑但登录阶段会反复重试不勾则弹窗让人工填一次过。我的建议是首次登录手动填登录成功后的会话能维持较长时间后续查票和提交订单一般不再触发验证码。这样既保留了自动化的效率又避开了识别率这个玄学环节。如果非要跑全自动把重试次数上限调高同时接受“可能跑一晚上才登进去”的现实。4. 指定车次与优先级策略配置项背后的排序逻辑4.1 traincode 是偏好不是过滤很多人第一次用会误以为填了traincodeD105就只买 D105结果发现程序买了别的车次以为有 bug。其实使用说明写得很清楚“只是优先预定该趟如果遇到指定车次没票的情况程序会自动帮您选择其他的车次。”这个设计在抢票场景下是合理的——先保底有票再谈车次偏好。如果你确实只接受某一趟得改代码在候选列表生成后加一层if (!trainNo.equals(target)) continue;的硬过滤。改之前想清楚硬过滤意味着目标车次没票时程序会一直空转不会给你兜底。4.2 动车优先与卧铺优先的叠加规则界面上的两个勾选框对应两个排序权重。动车优先是把D、G开头的车次排前面卧铺优先是在席别维度把硬卧排前面。两个同时勾选时排序是先按车型分组、组内再按席别排。这个顺序在代码里体现为两次Collections.sort第二次排序是稳定排序所以不会打乱第一次的分组结果。实际效果举例候选里有 D105 硬座、K123 硬卧、D106 硬卧勾选动车优先后 D105 和 D106 在前再勾卧铺优先D106 硬卧排第一。这个组合适合“想坐动车但实在没座也能接受卧铺”的人。如果只想要卧铺、车型无所谓就只勾卧铺优先让所有车次的硬卧先排。4.3 运行时修改配置的生效边界使用说明提到“除登录信息外其他信息均可以及时修改和及时生效”。这里的“及时生效”指的是下一轮轮询会读到新值不是立刻中断当前请求。所以改完traincode后正在进行的这次查询还是按旧配置走下一轮才切换。这个边界在调试时要注意改完配置别急着看结果等一个轮询周期再判断。另外代理配置userproxy的切换需要重建HttpClient实例程序内部做了监听但切换瞬间正在飞的请求可能失败一次属于正常现象。5. 避坑与排查五个真实翻车记录5.1 启动闪退命令行只闪一下现象双击start.bat黑窗口一闪就没了界面没出来。原因JRE 没装或java不在 PATHbat 里的java命令执行失败pause还没来得及停住。解决先在命令行手动执行java -version确认环境如果 bat 里用的是相对路径调 java改成绝对路径或者在 bat 末尾的pause前加一行echo %errorlevel%看退出码。5.2 登录一直失败提示验证码错误现象手动输入验证码也提示错误反复重试。原因Cookie 里残留了上一次的会话服务端认为会话冲突或者系统时间偏差过大导致请求签名校验失败。解决清掉浏览器和程序目录下的 Cookie 缓存重启程序检查系统时间是否自动同步。如果用的是代理确认代理没有篡改响应内容。5.3 查票正常但提交订单总是失败现象能看到余票点提交后返回“库存不足”或直接跳回查询页。原因余票数是缓存值实际库存在提交瞬间已被别人扣掉或者提交请求缺少某个动态参数比如令牌。解决把提交失败视为正常竞争结果程序应自动回到轮询而不是退出检查提交请求的参数字段是否和抓包一致尤其是时间戳和令牌类字段。5.4 跑一段时间后所有请求超时现象前几分钟正常之后查票全部卡住日志里一堆SocketTimeoutException。原因HttpClient的连接池没释放或者服务端对同一 IP 做了频率限制。解决给连接管理器设置maxPerRoute和maxTotal并在每次请求后确保EntityUtils.consume掉响应实体轮询间隔加随机抖动降低请求规律性。5.5 密码明文存 Cookies 的安全隐患现象使用说明里明确写了“密码是明文记录在 Cookies 中的”。原因自动填充功能把账号密码写进了本地 Cookie 文件方便下次免输入。解决不在公共电脑上勾选“记住密码”用完手动清理程序目录下的 Cookie 文件如果只是自己机器用至少确保磁盘加密或账户隔离。这条不是程序 bug是设计取舍用之前得知道代价。6. 从反编译到二次开发用 jd-gui 看源码并加一层车次硬过滤拆这个包最有价值的一步是用jd-gui把gohome.jar反编译出来看结构。打开后能看到几个核心类负责登录的LoginService、负责查票的QueryService、负责提交的SubmitService以及一个Config单例读配置。验证码识别单独在一个CaptchaRecognizer类里方法名大概是recognize(BufferedImage)内部先二值化再按列切分。想改行为从这几个类入手比盲改快得多。二次开发最常见的需求是“只买指定车次”。在QueryService生成候选列表的地方加一层过滤即可// 在候选车次列表生成后、排序前插入硬过滤 ListTrain candidates parseTrains(responseHtml); String target Config.get(traincode); if (target ! null !target.isEmpty()) { ListTrain filtered new ArrayListTrain(); for (Train t : candidates) { // 只保留车次号完全匹配的记录 if (t.getCode().equalsIgnoreCase(target)) { filtered.add(t); } } // 目标车次无票时 filtered 为空程序会继续轮询 candidates filtered; } sortByPriority(candidates);逻辑说明这段代码把“偏好排序”改成了“硬过滤”filtered为空时后续提交逻辑自然跳过等下一轮轮询。参数说明target从配置读大小写不敏感比较避免d105和D105匹配不上。改完重新打包成 jar替换原文件即可。注意保留原包的目录结构否则start.bat里的 classpath 会找不到主类。验证改动是否生效不用真等放票。可以在parseTrains后打一行日志把候选车次号和过滤后的列表打到控制台跑一次查询看输出是否符合预期。我习惯在每次改完这类过滤逻辑后先用一个已知无票的车次号跑一轮确认程序进入空转而不是报错退出再换真实车次。从那以后我每次动这类抢票脚本的过滤条件都强制先跑一轮“无票空转”验证确认它不会因为列表为空而抛异常——这个习惯帮我省了好几次半夜调试的麻烦。希望帮到你。本文还有配套的精品资源点击获取