最近约车真是越来越难了,网上约车经常车位刚放出来便已空空如也。突然回想起之前学车时教练反复提到的约车软件,去淘宝上一查:我去,卖出去一千多份了!还能约到车那就是有鬼了……此刻我深深怀疑这个软件是他们自家开发的,贵圈水真深。然而作为一名程序猿的尊严是不允许我去买这软件的……于是花了一天捣鼓出来一个极其简陋的约车系统,虽然因为官方网站对这方面的限制很多,效果并不是很好,不过试用了一下淘宝的爆款约车软件基本确定原理相同,那么就满足了吧……(挽尊可矣)
软件使用nodejs实现,理由一个字,简单,方便。在此记录下一些思路。
首先,要实现自动约车,验证码是第一个需要突破的关卡。这里我用了google著名的图像识别库tesseract-ocr,基本可以对一些简单的纯字母组成的验证码完成识别。对一些有不同颜色的验证码,再辅以graphicsmagick库做一些图像灰度化处理之后再识别,能提高一些准确率。可用npm下载nodejs的tesseract和graphicsmagick库,前提是计算机内得预先安装这两款软件。安装过程请参照:
tesseract: https://www.npmjs.com/package/node-tesseract
graphicsmagick: https://www.npmjs.com/package/gm
第一步,我们需要访问主页面得到验证码。一般网站验证码会存于session之中,因此我们需要通过response中的set-cookie字段来获取该次请求的session id,并存入之后每次请求request头携带的Cookie中,这样服务器才能将脚本发出的多个请求归入同一次会话,保存登录状态。代码如下:
// Refresh the validcode and then download it.
function downloadValidCode() {
var deferred = Q.defer();
if (!VALIDIMG_SOURCE) {
deferred.reject('null resource');
}
var file = fs.createWriteStream(DOWNLOAD_DIR + FILE_NAME);
var options = {
hostname: HOST,
port: 80,
path: '/' + VALIDIMG_SOURCE,
headers: {
'Cookie': COOKIE
}
};
http.get(options, function(res) {
res.on('data', function(data) {
file.write(data);
})
.on('end', function() {
file.end();
if (res.headers['set-cookie']) {
COOKIE = res.headers['set-cookie'] + ';';
}
deferred.resolve();
})
.on('error', function(e) {
deferred.reject(e);
});
});
return deferred.promise;
}
fs获取文件流并将验证码存入下载目录供之后图像处理函数调用。这里还使用了nodejs中的q实现了一个promise的API,方便之后程序主循环实现调用链,将在下文提到。
function recognizeValidCode() {
var deferred = Q.defer();
log('recognizing');
gm(DOWNLOAD_DIR + FILE_NAME).colorspace('GRAY').write(PROCESS_DIR + FILE_NAME, function(err) {
if (err) {
deferred.reject(err);
}
// Recognize text of any language in any format
tesseract.process(PROCESS_DIR + FILE_NAME,function(err, text) {
if (err) {
deferred.reject(err);
} else {
log('validcode is: ' + text);
code = text.trim();
deferred.resolve();
}
});
});
return deferred.promise;
}
以上代码是对验证码的图像处理,看起来就很明了了——首先利用gm对图像作灰度化处理,然后调用tesseract识别出文字,最后去掉前后可能会产生的空白符。这里存在一些问题:约车官网的验证码中有一些噪点,时常会干扰识别的准确性。解决这个问题的方法并不是没有,需要动用一些图像去噪的算法,考虑到无视之的情况下正确率也有约25%,因此出于成本和效率的综合考量,决定直接采用暴力破解(其实是懒)。
得到验证码之后,将用户名、密码、验证码和Cookie以表单形式发送到login接口,大功告成。当然,以上方式仅限于简单验证码,对于12306之类上升到图灵测试层次的验证码目前还并没有思考解决方案……
登录搞定之后,预约就是体力活了。通过抓包和对它的js代码分析出预约操作的各个参数,然后模拟格式无限发请求。需要注意的是,官网对发请求的频率有严格限制,因此一般设个几分钟的间隔,不然就成DDoS了……
nodejs的“回调地狱”应该是它的一个比较著名的现象了,这是由于它事件驱动以及异步编程的特性所致。大致说来,就是下图这种效果:
step1(function (value1) {
step2(value1, function(value2) {
step3(value2, function(value3) {
step4(value3, function(value4) {
// Do something with value4
});
});
});
});
解决该问题的方案是使用promise。实现了promise的函数会将回调结果传入链条中下一个方法中处理。我在约车软件的主循环中需要这样一个逻辑:刷新验证码 --> 下载验证码并识别 --> 登录 --> 预约循环 --> 若session过期,重新刷新验证码登录过程。
在这条调用链中存在很多条件判断和异常处理,要是每个方法都做一次显然会令代码显得十分臃肿且不便调试。nodejs中,使用q来实现promise。实现过程如下:
在一个函数中,首先声明defer:
var deferred = Q.defer();
然后,若结果成功,则调用resolve方法,链条中下一个节点的第一个回调函数便会处理成功回调;反之,若失败则调用reject方法,下一节点的第二个回调函数将会处理失败回调。
if (content.indexOf('true') !== -1) {
deferred.resolve();
} else if (content.indexOf('验证码不符合!') != -1) {
deferred.reject('wrong validcode');
} else {
deferred.reject('unknown error');
}
最后,函数返回一个promise的API,以便能够实现链式调用。
return deferred.promise;
一个登录、预约循环便如下所示:
function login(startTime, endTime, date) {
downloadValidCode()
.then(recognizeValidCode)
.then(requestLogin)
.then(function() {
log('Login successfully!');
reserve(startTime, endTime, date);
}, function(e) {
log(e);
// If login failed, then login again.
setTimeout(function() {
login(startTime, endTime, date);
}, parseInt(INTERVAL) * 1000);
});
}
上述的一些方案能解决基本的需求,但还存在很多问题。比如官方网站对访问频率有很严格的限制,若在尝试登录时脸黑六次以上都没有识别出正确的验证码,那么极有可能ip会被屏蔽两小时。我还不太清楚nodejs的http客户端如何像C#的http client一样自由设置代理ip,因此目前暂时以手动切换ip来解决这个问题……如果以后有了新的思路再来解决这个问题好了。