ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Java抢票程序拆解:GOHOME自动购票工具的实现与边界

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 和 port
ip127.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()); Map<String, 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 是偏好不是过滤

很多人第一次用会误以为填了traincode=D105就只买 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不在 PATH,bat 里的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生成候选列表的地方加一层过滤即可:

// 在候选车次列表生成后、排序前插入硬过滤 List<Train> candidates = parseTrains(responseHtml); String target = Config.get("traincode"); if (target != null && !target.isEmpty()) { List<Train> filtered = new ArrayList<Train>(); 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后打一行日志,把候选车次号和过滤后的列表打到控制台,跑一次查询看输出是否符合预期。我习惯在每次改完这类过滤逻辑后,先用一个已知无票的车次号跑一轮,确认程序进入空转而不是报错退出,再换真实车次。从那以后我每次动这类抢票脚本的过滤条件,都强制先跑一轮“无票空转”验证,确认它不会因为列表为空而抛异常——这个习惯帮我省了好几次半夜调试的麻烦。希望帮到你。

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

RELATED READING

延伸阅读

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