Tuesday, January 24, 2012

12306刷票记

新blog:sskaje.me
本文地址:http://sskaje.me/index.php/2012/01/12306bot/

我也记不清啥时候动了写bot刷票这个念头的。原因很简单,我一直认为作为一个以代码谋生的不合格程序员,只有把生产工具用好,才能增加自己存在的价值。

首先说明一下主要开发环境:Windows 7,PHP 5.3,php_curl。

翻到了 第一条关于刷票的微博,附了图

很不低调地炫耀。

要刷票,首先自然得熟悉目标系统,所谓踩点。firefox+firebug,抓了一个标准流程的请求:登录、查票、订票。确认订单一开始没敢点,怕会有什么影响,后来去注册了几个测试号,然后尝试了确认订单的操作。流程本身不复杂,但是提交参数有点太多,一步一步来。

回到图1,登录,其实核心在验证码。

1 验证码识别
登录的验证码处理起来很简单,图在 https://dynamic.12306.cn/otsweb/passCodeAction.do?rand=lrand,提供一个示例
这个验证码结构一直没变,字体比较规则,无变形,直接就想到了tesseract,上一次用这个的时候是2010年12月初为了刷顺丰快递的订单追踪。

流程很简单:
1 下载图片;
2 转tiff(使用了ImageMagick的convert);
3 tesseract识别;
4 读取识别文件;
5 简单判断识别输出是否合法;
6 拼登录请求。

代码里留了个 decaptcha_valid()的方法,不过只判断了是不是4位数字+字母。
写完第一版代码后,清理了一些个人信息,把代码发给了@也云,不过当时毕竟比较早,他没怎么看,但是后期验证码识别这块提供了一些改进,包括:
1 replace识别输出里的空字符,因为由于字符间距不确定,tesseract偶尔会识别出空格;
2 训练tesseract,优化识别效果;
3 这次tesseract用的是v3,已经支持多种图片格式,而我还是按上次使用的经验convert jpeg 2 tiff,这次直接省去这个过程;
4 最关键的,登录验证码在登录请求提交后并不会删除上次访问验证码图片生成的session字段,也就是说,只需要识别一次验证码,后续只需要暴力提交即可

按4调整了验证码识别方法,增加了人肉输入的支持。使用system('out.jpg')直接利用Windows 7 CMD的高级功能,同时发现了Win7自带的图片查看器system执行后程序可以继续往下走而不必关掉图片查看器,这样可以alt+tab切换cmd和图片查看器,确认人肉识别的输入是否正确。

上述逻辑在1月19号之前也同样适用于确认订单的验证码,那天早起发现这块儿验证码需要每次请求了,幸好之前人肉识别的改造增加了配置开关,很轻松地就完成了改动(虽然19号刷票无一成功,但是20号回家客车上刷5张票轻松到手)。

2 查票
查票的过程其实没啥特别可以说的,简单拼个请求,查票就是了。不过返回值是需要处理的,处理的依据,从页面看,就是查票结果输出里的“预订”按钮。

预订的参数就是页面输出里的getSelect()这个js调用的参数,最初的结构我从另外一条微博里找到了例子。
查票输出
正如页面上所示,结构是“车次号#历时(分钟单位)#发车时间#某个ID#始发站编号#目标站编号”,这个参数由页面解析后带入实际的预订请求。 不过后来某一天,这个参数变了,一个输出示例是“Z67#11:26#20:06#2400000Z6705#BXP#NCG#07:32#北京西#南昌#10175000003030800001404860000160895000001017503001”。同样按上边的解释,结构是“车次号#历时#发车时间#某个ID#始发站编号#目标站编号#到站时间#某个带入ypInfoDetail字段的参数”,@也云同学研究了一下最后这个字段,认定是“余票信息”的意思,对我们没有特别的意义,可无视(后续也证实了)。

这里可能有用的信息包括上述的“某个ID”。而查票的时候也需要输出里的始发站和目标站编号,这个编号可以从 这个地址里找到

3 订票 && 提交订单
订票使用了查票的输出参数,提交url是 https://dynamic.12306.cn/otsweb/order/querySingleAction.do?method=submutOrderRequest。明显有个Typo,而且这里名字是“提交订单”。不管那么多,说技术细节。

这个使用查票参数构造了一个POST请求,如果提交成功,服务器端会发一个302,重定向到一个新的页面 https://dynamic.12306.cn/otsweb/order/confirmPassengerAction.do?method=init。

流程很简单,curl实现只需要开 CURLOPT_FOLLOWLOCATION 即可。但是这个地方卡了我很久,直到12月初的某天才查出来为什么每次提交在这里总是出现 HTTP 500,虽然还是不知道原因,但是补上了几个http头,一切ok了(具体看代码)。

订票输出的页面,会有formtoken,参数名是 org.apache.struts.taglib.html.TOKEN。有点Web编程经验就应该能知道是干啥的。不多说,参数请求必须带上,所以正则匹配,无技术含量。
确认订单页只是拼参数,补上验证码识别就ok,无技术含量。

第一个细节,这个页面是填身份证的页面,入口有两个,订票 和 确认订单失败(后期确认订单失败不再直接回到该页面,不过验证码错误的应该还是会回来),所以确认订单的错误处理是可以考虑增加的。这个一开始代码就加上了,不过后来@也云改的lite版把这个流程改为 提交订单 + 确认订单,两步走,确认失败直接重订票。

第二个细节,验证码URL不一样了,https://dynamic.12306.cn/otsweb/passCodeAction.do?rand=randp,不过输出还是一样的。

第三个细节,最关键的,某个晚上睡前突然意识到的问题。回到form token上,我们可以观察到这个系统很多地方都会有form token,包括但不限于确认订单、支付订单、撤销订单等等。但是我意识到一个问题,提交订单这个过程可能会很慢,慢到一个不能忍的状态,尤其是高峰期。而,用户中心的表单里,也有form token,而且参数名完全一样,那么,这两个token会是同一个session字段吗?
接下来做的事情是改造form token的获取方法,改从用户中心的页面匹配,仅作测试。测试结果很乐观,从用户中心匹配到的token能作为确认订单token,而且这个请求自然会比从提交订单拿到的页面匹配要快,于是匹配token的方法可以优化了。(但是16号早上,订单系统似乎出问题了,各种慢,未支付订单页面刷不出来,支付订单也失败了,3张票扣了我1.3k RMB,没出票,极度想粗口!!!)

说到第三细节,如果你顺着读下来,可能会觉得有疑问:不是有很多参数要从页面匹配吗?如果不去拿提交订单后的页面,匹配变量,怎么确认订单?
答案是:你所需要的参数从你拿到查票输出的时候就已经足够了。你所需要的车次相关的信息,查票的输出已经能满足所有字段的需求;你所需要的订票人信息,以及所需坐席,你自己是已经知道了的。而,经过观察,查票输出里除了余票信息字段会变,其他的都不变!!!所以,你只需要在确定要刷票的前一天晚上,做一下准备工作,查查你要刷票的车次的信息,第二天拿着刷票便是了!!!
具体不多说,看代码便知。

到此,基本能说的都说了。

后边还有一些其他的细节优化。
支付
最初的支付表单是在一个独立的页面上,从未完成订单里选择订单,点支付,弹出的一个新页面匹配几个参数再submit,输出页面只有一个简单的form,body onload的时候触发了submit操作。也就是说,只需要拿下这个页面完成支付即可,这个最早@也云实验成功了,后来我有几次支付也用这个方法完成。过了几天,支付的表单在未完成订单点支付后的页面里直接写死了,于是新的lite版bot直接匹配了页面表单,存了个独立页面。
完后,弹出IE,支付。
本来想做招行的手机支付自动化的,后来实在懒了,自己订票的需求满足了,没心做这种优化。

登录
登录是件苦力活,经常就人满了,频率高了IP也会被封。所以登录成功一次得好好珍惜。
做法是: CURLOPT_COOKIEFILE + CURLOPT_COOKIEJAR。
脚本执行结束后,手工改 cookie jar文件,把expiration时间改长点。
但是由于经常性在登录完成后ctrl+c,没法触发__destruct()的调用,按@也云的建议,改成了登录完成后触发一次curl close操作并改expiration,执行结束后的继续保留。


源码
/bot.php
/config/test.php

执行参数 php bot.php 0 test 2>err.txt
只保证win下可用,soff改过一个mac下可用的版本,没找他要。
test 是 config/ 下的 test.php
0 是 test.php 里的tickets_info字段的索引,其实叫train_info更合适

然后为了骚扰自己,加了个vbs文件,内容很简单,取名messagebox.vbs,这段内容感谢@linxinsnow
' Message box Script Set objArgs = WScript.Arguments For I = 0 to objArgs.Count - 1 msgbox objArgs(I) Next

写在最后
虽然我经常在微博XY,但是一直没公开源码。最终可用的代码也只有也云和soff拿到了。
一直不开源或者不发布的原因很简单,我不希望我的代码成为别人系统“有压力”的理由。我也看到了几个网上放出来的刷票工具或者js解决方案,但是,越多人知道这个,就意味着越多人会用你的“刀”“杀”铁道部。
或许我想的有点多。

感谢也云同学对本年度刷票的支持。感谢soff提供的验证码识别优化方案,虽然效果还不如我们的。