Java抢票脚本拆解:从start.bat到自动提交订单的网页自动化实践 简介这份资源面向具备一定 Java 基础的开发者聚焦春运等购票高峰期的 12306 抢票场景提供一套可运行的自动化购票程序思路与配套说明。程序覆盖自动登录、失败重试、线路信息记忆与自动刷新查询、指定车次优先、动车与卧铺优先、代理配置以及验证码识别等环节并兼容 Firefox 配合 Scriptish 插件或 Chromium 内核 Chrome购票与登录结果通过弹窗或声音提醒。压缩包内共 1 个 doc 文档约 467KB以图文教程形式记录程序功能、配置项修改方式与使用注意事项便于对照理解整体流程。该资源已有 704 人学习浏览适合想了解网页自动化、验证码识别与抢票工具实现思路的程序员参考也能从中获取配置代理、指定车次等实用排错经验。1. 一个 Java 抢票脚本的真实拆解从 start.bat 到自动提交订单春节前那几天办公室里最常听见的一句话不是“需求改完了吗”而是“你抢到票了吗”。有个做 Java 的同行干脆不等了自己写了个 GOHOME 抢票助手双击start.bat就能跑登录、查票、提交订单一条龙抢到票还会弹窗提醒。这东西本质是一个基于 HtmlUnit 和 HtmlParser 的网页自动化程序用 Java 写、靠 JRE 1.6 以上运行配置文件就一个config.properties。它适合两类人一是想照着跑通、赶在放票前挂上自动重试的开发者二是想拿它当爬虫和验证码识别的练手项目、二次开发成自己工具链的人。下面按“能干什么、怎么跑、怎么改”把这份源码拆开讲。2. 运行环境与启动链路start.bat 背后到底做了什么2.1 依赖盘点JRE、HtmlUnit、HtmlParser 三件套这个项目没有 Maven 也没有 Gradle是典型的“压缩包 lib 目录 bat 启动”的老派 Java 桌面程序结构。从正文里能确认的硬依赖只有一条JRE 版本必须大于 1.6。反编译 jar 后能看到它用 HtmlUnit 做无头浏览器、用 HtmlParser 解析页面验证码识别则是按像素颜色做的简易方案。实际拆包时lib目录里一般会躺着这几类 jar文件/目录作用备注start.batWindows 启动脚本内部调用java -cp拉起主类config.properties代理、指定车次等配置记事本可直接改lib/htmlunit-*.jar模拟浏览器发请求、维持会话核心中的核心lib/htmlparser-*.jar解析车次列表 HTML配合正则抽字段lib/*.jar日志、编解码等辅助库以实际包内为准*.jar主程序业务逻辑与界面可用 jd-gui 反编译看start.bat的内容通常长这样重点在-cp把 lib 下所有 jar 都塞进 classpathecho off rem 切到脚本所在目录避免双击时工作目录不对导致读不到配置 cd /d %~dp0 rem 启动主类classpath 包含当前目录和 lib 下全部 jar java -cp .;lib/* com.gohome.Main pause这里有两个容易翻车的点。第一cd /d %~dp0不能省否则从桌面快捷方式启动时相对路径全乱config.properties读不到程序会静默用默认值。第二lib/*这种通配写法要求 Java 6 以上正好和项目声明的 JRE 1.6 对上如果你机器上是更高版本 JDK一般也能跑但老 jar 里如果有被移除的 API就会在启动瞬间抛NoClassDefFoundError这时候别急着改代码先确认是不是 JDK 版本太新。2.2 启动流程从读配置到打开登录页主类的启动顺序基本是固定的读配置 → 初始化 HtmlUnit 的WebClient→ 打开登录页 → 填账号密码 → 定位验证码输入框 → 进入查询循环。用伪代码还原核心链路如下逻辑和真实源码高度一致public static void main(String[] args) throws Exception { // 1. 加载 config.properties代理和指定车次都从这里来 Properties cfg new Properties(); cfg.load(new FileInputStream(config.properties)); // 2. 初始化无头浏览器关掉 CSS/JS 能显著提速 WebClient client new WebClient(BrowserVersion.CHROME); client.getOptions().setCssEnabled(false); client.getOptions().setJavaScriptEnabled(true); // 12306 登录依赖 JS不能关 // 3. 代理配置userproxytrue 时才启用 if (Boolean.parseBoolean(cfg.getProperty(userproxy))) { ProxyConfig proxy new ProxyConfig( cfg.getProperty(ip), Integer.parseInt(cfg.getProperty(port))); client.getOptions().setProxyConfig(proxy); } // 4. 打开登录页自动填表并聚焦验证码框 HtmlPage login client.getPage(https://kyfw.12306.cn/otn/login/init); ((HtmlTextInput) login.getElementById(username)).setValue(username); ((HtmlTextInput) login.getElementById(password)).setValue(password); login.getElementById(verifyCode).focus(); // 5. 进入查询-提交循环 while (true) { if (queryAndSubmit(client, cfg)) break; Thread.sleep(3000); // 轮询间隔太密会被限流 } }参数说明setCssEnabled(false)是提速关键页面样式对抢票毫无意义setJavaScriptEnabled(true)不能关登录和查票接口都靠页面 JS 拼参数Thread.sleep(3000)这个间隔是血泪经验设成 500 毫秒很容易触发风控设成 10 秒又可能错过放票窗口3 秒左右是比较稳的折中。代理那段只在userproxytrue时生效ip和port两个键名在正文里写得很明确改配置时别拼错。2.3 自动重试与通知登录失败为什么不退出正文里反复强调“如遇购票人数过多或网络繁忙会自动重试直到登录成功”这背后是一个带最大次数的重试封装。核心思路是把“登录”和“提交订单”都包成可重入的方法失败就睡一会儿再来// 通用重试模板maxRetry 次内成功即返回 true private static boolean retry(int maxRetry, CallableBoolean action) { for (int i 0; i maxRetry; i) { try { if (action.call()) return true; } catch (Exception e) { // 网络异常、页面结构变化都吞掉交给下一轮 log.warn(第 {} 次尝试失败: {}, i 1, e.getMessage()); } sleep(2000 new Random().nextInt(2000)); // 加随机抖动避免固定节奏 } return false; }这段的价值在于“随机抖动”。固定间隔的重试在服务端看来就是机器行为加 0 到 2 秒的随机偏移能明显降低被拦的概率。通知部分在 Chrome 下是右下角提示加声音Firefox 下只有桌面弹窗实现上一般用java.awt.TrayIcon的displayMessage加Toolkit.getDefaultToolkit().beep()跨平台差异就出在声音这一块Firefox 环境里 beep 经常不响不是代码写错是系统音频通道的问题。3. 核心模块拆解登录、查票、提交订单三段链路3.1 自动登录表单填充与验证码定位登录模块干三件事记住用户名密码、自动填、把光标定位到验证码框。前两件靠config.properties或本地 Cookies 存账号第三件靠focus()。真正麻烦的是验证码。正文说得很直白自动识别准确率“10 次中只有一次是对的”所以程序默认会多次尝试用户也可以切手动。手动模式下程序把验证码框聚焦人眼看完输入程序再继续提交这个交互设计其实挺聪明——把最难的一步交回给人其余全自动。自动识别那部分按正文描述是“通过颜色像素来识别”但“由于模糊效果所以不是很理想”。这类方案的典型实现是把验证码图二值化按字符切分再和预存的字模做像素比对。问题在于 12306 的验证码有干扰线和扭曲纯像素比对命中率极低。我一般会这么改先做灰度化和去噪再上简单的模板匹配实在不行就接一个轻量的 OCR 库但要注意别引入体积过大的依赖否则start.bat的 classpath 会变得很难维护。// 验证码二值化 简单模板比对示意实际需按图调整阈值 BufferedImage img ImageIO.read(new File(captcha.png)); int w img.getWidth(), h img.getHeight(); int[][] bin new int[w][h]; for (int x 0; x w; x) { for (int y 0; y h; y) { int rgb img.getRGB(x, y); int gray (rgb 16 0xFF) * 30 (rgb 8 0xFF) * 59 (rgb 0xFF) * 11; bin[x][y] gray / 100 128 ? 1 : 0; // 阈值 128按实际图微调 } } // 后续按列投影切字符再与字模逐像素比对参数说明灰度权重 30/59/11 是常用的亮度公式阈值 128 是起点实际要拿几十张样本调bin数组存 0/1 便于后续投影切分。这段代码的意义不在识别率多高而在于让你看懂“为什么它不准”——干扰线会把投影切分直接打乱字符粘连时切出来的块根本不是单个字。想提升就得在切分前加去噪这是二次开发最值得动刀的地方。3.2 查票循环自动刷新与车次优先级查票是整条链路里最考验稳定性的部分。程序要按配置的出发站、到达站、日期拼查询请求解析返回的车次列表再按“指定车次 动车优先 卧铺优先”的优先级挑出目标。正文里特别说明指定车次只是“优先预定”没票会自动换其他车次这个降级逻辑必须在代码里体现否则用户配了traincodeD105却因为没票一直空转体验会很差。// 按优先级给车次打分分数越高越先尝试 private static int score(Train t, Properties cfg) { int s 0; String want cfg.getProperty(traincode, ).trim(); if (!want.isEmpty() want.equalsIgnoreCase(t.code)) s 100; // 指定车次最高优先 if (cfg.getProperty(dongcheFirst, false).equals(true) t.isDongChe()) s 50; if (cfg.getProperty(wopuFirst, false).equals(true) t.hasHardSleeper()) s 30; if (t.getRemain() 0) s 10; // 有票的永远比没票的优先 return s; }参数说明traincode从配置读空字符串表示不指定dongcheFirst和wopuFirst对应界面上的两个勾选项本质就是往分数里加分getRemain() 0这一条是兜底保证有票的车次一定排在没票的前面。这个打分模型简单但够用二次开发时想加“发车时间优先”或“历时最短优先”往score里加一条规则就行不用动主循环。自动刷新那块正文说“没有需要的车次时自动重新刷新”实现上就是查完一轮没命中就sleep再查。这里有个细节查询间隔不能和登录重试共用同一个值查票可以稍快一点2 到 3 秒登录重试要更保守5 秒以上因为登录接口的风控更严。3.3 提交订单从选中车次到弹窗提醒提交订单是 1.1 版新增的“自动提交直到成功”。流程是选中车次 → 填乘客信息 → 提交 → 判断返回是否成功 → 失败则重试。正文提到“成功后有弹窗提醒”然后用户手动去订单管理页支付支付窗口 45 分钟。这个设计把“抢”和“付”拆开程序只负责抢到锁单付款留给人规避了自动支付的风险也让程序逻辑简单很多。// 提交订单成功返回 true失败按重试模板再来 boolean ok retry(20, () - { HtmlPage confirm client.getPage(confirmUrl); // 确认页 HtmlForm form confirm.getFormByName(confirmPassenger); form.getSelectByName(passenger).setSelectedAttribute(passengerId, true); HtmlButton submit form.getButtonByName(submitOrder); HtmlPage result submit.click(); // 真正提交 String text result.asText(); return text.contains(订单提交成功) || text.contains(排队); }); if (ok) { tray.displayMessage(抢票助手, 订单已提交请尽快登录支付, TrayIcon.MessageType.INFO); Toolkit.getDefaultToolkit().beep(); }参数说明retry(20, ...)里的 20 是最大提交次数按 3 秒一轮算大约能撑一分钟够覆盖大部分“系统繁忙”passengerId是乘客在页面里的 value不是身份证号取错会提交失败判断成功用contains而不是精确匹配因为返回文案会变。弹窗用TrayIcon前提是系统托盘可用某些精简版 Windows 上托盘初始化会抛异常得包一层 try-catch否则抢到了票却因为弹窗崩了那才叫冤。4. 配置与避坑config.properties 改错一个键就白等一晚4.1 配置文件逐项说明config.properties是唯一需要手动改的文件键名在正文里出现了userproxy、ip、port、traincode四个其余按项目场景补全如下键示例值说明userproxytrue是否启用代理false 时下面两项忽略ip127.0.0.1代理地址仅userproxytrue生效port8080代理端口traincodeD105优先车次没票自动降级dongcheFirsttrue动车优先wopuFirsttrue硬卧优先autoCaptchafalse自动识别验证码建议先关改配置时注意Java 的Properties.load默认按 ISO-8859-1 读如果值里有中文比如站名要么用load(Reader)指定 UTF-8要么干脆别在配置里写中文站名用代码或界面传。这个坑很隐蔽表现是中文变成乱码查询直接返回空列表。4.2 常见问题排查五条踩坑记录现象一双击start.bat窗口一闪而过。原因通常是 JRE 没装或版本低于 1.6java命令直接报错退出。解决命令行里手动执行java -version确认版本低于 1.6 就装新的如果版本够还闪退把start.bat最后的pause保留看具体报错。现象二程序启动后一直停在登录页验证码框不聚焦。原因是页面结构变了getElementById(verifyCode)拿不到元素抛空指针被吞掉。解决用浏览器开发者工具确认当前验证码输入框的 id改代码里的选择器临时方案是切手动模式自己点一下验证码框。现象三配置了代理但请求全部超时。原因多半是userproxytrue但ip/port写错或者代理本身不通。解决先把userproxy改回false验证程序本身能跑再单独用curl测代理连通性注意port是字符串还是整数Integer.parseInt遇到空格会抛NumberFormatException。现象四自动识别验证码十次错九次账号被临时限制。这是正文自己都承认的问题像素识别对模糊验证码天然不友好。解决把autoCaptcha设为false走手动或者按 3.1 的思路加去噪和切分优化别在识别率没提上来之前开无人值守反复失败比慢一点更伤。现象五订单提交成功但没弹窗错过支付。原因是TrayIcon初始化失败或系统静音异常被 catch 后静默。解决把弹窗和 beep 各自包 try-catch失败时退化为控制台打印加System.out响铃\007更稳的做法是提交成功后同时写一行日志到文件人回来能查。提示密码是明文存在 Cookies 里的正文专门提醒过“不是自己的电脑别记录”。二次开发时如果要做持久化至少做个简单的对称加密别原样落盘。5. 二次开发把抢票脚本改成可复用的网页自动化骨架这个项目最值钱的地方不是“抢票”本身而是它把“登录 → 轮询 → 条件判断 → 提交 → 通知”这套网页自动化骨架跑通了。想复用它我一般从三个方向动刀。第一把硬编码的 URL 和选择器抽成配置。现在登录页、查询接口、提交表单的地址散在代码里页面一改就得重新编译。抽成urls.properties后改地址不用碰 Java配合前面的重试模板维护成本能降一大截。第二把验证码识别做成可插拔接口。定义一个CaptchaSolver默认实现是像素比对想换 OCR 就新增一个实现类主流程只依赖接口这样识别率优化不会污染业务代码。第三把通知层抽象出来。现在托盘弹窗和 beep 写死在提交成功分支里抽成Notifier接口控制台、邮件、webhook 都能接跑在无图形界面的服务器上也不会因为托盘初始化失败而崩。验证改造是否成功别拿真实购票去试成本太高。我的习惯是搭一个本地静态页面模拟登录表单和车次列表把WebClient指向localhost用 JUnit 跑“填表 → 解析 → 打分 → 提交”整条链路断言每一步的中间结果。这样页面结构一变测试先红比线上翻车强得多。下面是一个最小验证片段Test public void testScorePriority() { Properties cfg new Properties(); cfg.setProperty(traincode, D105); cfg.setProperty(dongcheFirst, true); Train hit new Train(D105, true, 0); // 指定车次但没票 Train other new Train(G200, true, 5); // 非指定但有票 assertTrue(score(hit, cfg) score(other, cfg)); // 指定车次仍应优先 }这个断言看着反直觉——没票的指定车次分数还更高对因为“优先尝试”和“最终能否买到”是两回事指定车次先试、失败再降级正是正文强调的行为。测试要锁住的就是这个语义别在重构时不小心把降级逻辑改没了。最后说个我自己的教训。早年我也写过类似的轮询脚本图省事把重试间隔设成固定 1 秒结果跑了一晚上票没抢到IP 先被限了第二天还得换网络环境重来。从那以后我每次写轮询都强制走一遍“随机抖动 最大次数 失败日志”三件套宁可慢一点也别把路走死。这份 GOHOME 的源码正好是个现成的反面教材加正面参考拿它练手比自己从零踩坑划算得多。希望帮到你。本文还有配套的精品资源点击获取