要知道在深圳上班是很是痛苦的事情,特别是我上班的科兴科技园这一块,去的人很是多,天天上班跟春运同样,若是我能换到之前的大冲上班那就幸福了,惋惜,换不得。javascript
尤为是我这个站等车的多的一笔,上班公交挤的不行,车满的时候只有少部分人能硬挤上去。一般我只会用两个字来形容这种人:“公交怪”html
想当年我朋友瘦的像只猴还能上去,老子身高182体重72kg挤个公交,不成问题,反手一个阻挡,闷声发大财,前面的阿姨你快点阿姨,别磨磨唧唧的,快上去啊阿姨,嗯?你还想挤掉我?你能挤掉我?你能挤掉我!我当场!把车吃了!前端
....java
咳咳,挤公交是不可能挤公交滴,由于今天我发现了一个能够定制路线的网约巴士公众号【深圳xxx】node
可是呢,票常常会被抢光,同时我还我发现,有时候会有人退票,这时候就有空余票了,关键是我不可能时时都在公众号上盯着,因而,我就写了一个抢票+短信通知的小工具ios
这个就是订票页面,显示当前月的车票状况,根据图示,红色为已满,绿色为已购,灰色为不可选 chrome
好,审查下源代码看下接口信息,等等,微信浏览器没办法审查源代码,因而shell
首先面临个问题,若是直接copy公众号网页Url在chrome打开的话,就会显示这个画面,他被302重定向到了这个页面,因此是行不通的,只有获取OAuth2.0受权才能进去npm
因此咱们得先经过抓包工具,知道手机访问微信公众号网页的时候,须要带什么信息过去,这时候咱们就得借助抓包工具,由于我电脑是Mac,用不了Fiddler
,我用的是Charles
花瓶,就是下面这位仁兄json
第一步,找到端口号,通常默认是8088,可是为了确承认以打开Proxy
/Proxy Setting
看下,哦原来我以前设置成了8888
Charles
的
help
/
Local IP Address
,点击它就会看到本身的本机地址,找到本机地址记下来,而后进行下一步
首先保证手机跟电脑链接的是同一个wifi,而后在wifi设置那里会有设置代理信息,好比个人猴米...不对,小米9手机!设置以下:
输入上一步获取主机名,端口号就ok了
输入完成,点击肯定后。Charles
就会弹出一个对话框,问你是否赞成接入代理,点击肯定allow就好了。
咱们用手机访问微信公众号【深圳x出行】进入到抢票页面后,发现Charles
已经成功抓包到了网页信息,当咱们进入这个抢票页面的时候,他会发起两个请求,一个是获取document文档内容,一个post请求获取票务信息。
仔细分析了下,大概明白了业务逻辑:
整个项目技术站是java+jsp,传统写法,用户身份验证主要是cookie+session方案,前端这一块主要是使用jQuery
。
当用户进入页面的时候,会携带查询参数,如起始站点,时间,车次等信息和cookie请求document文档, 也就是圈起来的这一块,
由于还要在请求一次
第二次请求,携带cookie和以上的查询参数发起一个post请求,获取当月的车票信息,也就是日历表内容
下面这个是请求当月票务信息,然而发现他返回的是一堆html节点
好吧...估计是获取到以后直接append
到div
里面的,而后渲染生成日历表内容
接着在手机上操做,选择两个日期,而后点击下单,发送购票请求,拉取购票接口,咱们看下购票接口的请求和返回内容:
在看看返回的内容:返回一个json字符串数据,里面大概涵盖了下单的成功返回码,时间,id号等等信息
根据上面的分析,总结下内容: 整个项目用户身份验证是使用cookie
和session
方案,请求数据用的是form data
方式,请求字段啥的咱们也都清楚,惟独有一点,就是请求余票的时候,返回的是html节点代码,而不是咱们预期的json数据,这样就有个麻烦,咱们没办法一目了然的明白他余票的时候是如何显示的
因此咱们只能经过chrome
进行调试,才能得出他是如何判断余票的。
咱们找个记事本,记录下信息,记录的内容有:
url
地址cookie
信息request
参数字段user-Agent
信息response
返回内容有以上信息后,咱们就能够开始用chrome调试了, 首先打开More tools
/Network conditions
user-Agent
填入到
Custom
里面
由于咱们要把获取到的cookie填入到chrome里面,以咱们的用户身份去访问网页,因此咱们须要在请求目标地址的时候,改包修改cookie
首先咱们须要开启 macOS Proxy
,抓包咱们的http请求
Charles
上已经抓包到了咱们访问的目标url地址,而后给目标url地址打上断点,方便调试
break points
,能够看到之因此咱们仍是不能访问到目标网址,是由于
sessionId
不对,因此咱们把抓取到的
cookie
在填入到里面,点击
execute
jQuery
代码
CSS
代码,特别是日历表那一块
审查了下元素发现:
<td class="b">
<span>这里为日期</span>
<span>若是有余票则显示余票数量</span>
</td>
复制代码
a
表明不可选e
表明已满d
表明已购b
则是咱们要找的,表明可选,也就是有余票到这一步,整个购票流程就清楚了
到时候咱们经过Node.js请求的时候,处理返回数据,用正则去判断是否有余票的class名b
,有余票的话,在获取div里面的余票数量内容就Ok了
写代码以前咱们须要想好功能点,咱们须要什么功能:
首先mkdir ticket
建立名为ticket的文件夹,接着cd ticket
进入文件夹npm init
一路瞎几把回车也无妨。 下面开始安装依赖,根据上面的功能需求,咱们大概须要:
http.request
,我这里选择用的是axios
,毕竟axios
在node端底层也是调用http.request
cnpm install axios --save
复制代码
node-schedule
cnpm install node-schedule --save
复制代码
cheerio
cnpm install cheerio --save
复制代码
qcloudsms_js
cnpm install qcloudsms_js
复制代码
nodemon
(其实不用也能够)cnpm install nodemon --save-dev
复制代码
接着touch index.js
建立核心js文件,开始编码:
首先引入全部依赖
const axios = require('axios')
const querystring = require("querystring"); //序列化对象,用qs也行,都同样
let QcloudSms = require("qcloudsms_js");
let cheerio = require('cheerio');
let schedule = require('node-schedule');
复制代码
而后咱们先定义请求参数,来一个obj
let obj = {
data: {
lineId: 111130, //路线id
vehTime: 0722, //发车时间,
startTime: 0751, //预计上车时间
onStationId: 564492, //预约的站点id
offStationId: 17990,//到站id
onStationName: '宝安交通运输局③', //预约的站点名称
offStationName: "深港产学研基地",//预约到站名称
tradePrice: 0,//总金额
saleDates: '17',//车票日期
beginDate: '',//订票时间,滞空,用于抓取到余票后填入数据
},
phoneNumber: 123123123, //用户手机号,接收短信的手机号
cookie: 'JSESSIONID=TESTCOOKIE', // 抓取到的cookie
day: "17" //定17号的票,这个主要是用于抢指定日期的票,滞空则为抢当月全部余票
}
复制代码
接着声明一个名为queryTicket
的类,为啥要用类呢,由于基于第五个需求点,多个用户抢票的时候,咱们分别new
一下就好了,
同时咱们但愿可以记录请求余票的次数,和当抢到票后自动中止查询余票得操做,因此给他加上个计数变量times
和是否中止的变量,布尔值stop
编写代码:
class QueryTicket{
/** *Creates an instance of QueryTicket. * @param {Object} { data, phoneNumber, cookie, day } * @param data {Object} 请求余票接口的requery参数 * @param phoneNumber {Number} 用户手机号,短信须要用到 * @param cookie {String} cookie信息 * @params day {String} 某日的票,如'18' * @memberof QueryTicket 请求余票接口 */
constructor({ data, phoneNumber, cookie, day }) {
this.data = data
this.cookie = cookie
this.day = day
this.phoneNumber = phoneNumber
this.postData = querystring.stringify(data)
this.times = 0; //记录次数
let stop = false //经过特定接口才能修改stop值,防止外部随意串改
this.getStop = function () { //获取是否中止
return stop
}
this.setStop = function (ifStop) { //设置是否中止
stop = ifStop
}
}
}
复制代码
下面开始定义原型方法,为了方便维护,咱们把逻辑拆分红各个函数
class QueryTicket{
constructor({ data, phoneNumber, cookie, day }) {
//constructor代码...
}
init(){}//初始化
handleQueryTicket(){}//查询余票的逻辑
requestTicket(){} //调用查询余票接口
handleBuyTicket(){} //购票相关逻辑
requestOrder(){}//调用购票接口
handleInfoUser(){}//通知用户的逻辑
sendMSg(){} //发短信接口
}
复制代码
全部数据都是基于查询余票的操做,所以咱们先开发这部分功能
class QueryTicket{
constructor({ data, phoneNumber, cookie, day }) {
//constructor代码...
}
//初始化,由于涉及到异步请求,因此咱们使用`async await`
async init(){
let ticketList = await this.handleQueryTicket() //返回查询到的余票数组
}
//查询余票的逻辑
handleQueryTicket(){
let ticketList = [] //余票数组
let res = await this.requestTicket()
this.times++ //计数器,记录请求查询多少次
let str = res.data.replace(/\\/g, "") //格式化返回值
let $ = cheerio.load(`<div class="main">${str}</div>`) // cheerio载入查询接口response的html节点数据
let list = $(".main").find(".b") //查找是否有余票的dom节点
// 若是没有余票,打印出请求多少次,而后返回,不执行下面的代码
if (!list.length) {
console.log(`用户${this.phoneNumber}:无票,已进行${this.times}次`)
return
}
// 若是有余票
list.each((idx, item) => {
let str = $(item).html() //str这时格式是<span>21</span><span>&$x4F59;0</span>
//最后一个span 的内容其实"余0",也就是无票,只不过是被转码了而已
//所以要在下一步对其进行格式化
let arr = str.split(/<span>|<\/span>|\&\#x4F59\;/).filter(item => !!item === true)
let data = {
day: arr[0],
ticketLeft: arr[1]
}
//若是是要抢指定日期的票
if (this.day) {
//若是有指定日期的余票
if (parseInt(data.day) === parseInt(data.day)) {
ticketList.push(data)
}
} else {
//若是不是,则返回查询到的全部余票
ticketList.push(data)
}
})
return ticketList
}
//调用查询余票接口
requestTicket(){
return axios.post('http://weixin.xxxx.net/ebus/front/wxQueryController.do?BcTicketCalendar', this.postData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Mobile/12A365 MicroMessenger/5.4.1 NetType/WIFI",
"Cookie": this.cookie
}
})
}
handleBuyTicket(){} //购票相关逻辑
requestOrder(){}//调用购票接口
handleInfoUser(){}//通知用户的逻辑
sendMSg(){} //发短信接口
}
复制代码
来解释下那行正则,cheerio
抓取到的dom是长这样的,第一个span
内容是日期,第二个是余票数量
ticketList
首先咱们在init
方法里作个判断,若是有余票才去购票,没有余票购个毛
class QueryTicket{
constructor({ data, phoneNumber, cookie, day }) {
//constructor代码...
}
//初始化
async init(){
let ticketList = await this.handleQueryTicket()
//若是有余票
if (ticketList.length) {
//把余票传入购票逻辑方法,返回短信通知所须要的数据
let resParse = await this.handleBuyTicket(ticketList)
}
}
//查询余票的逻辑
async handleQueryTicket(){
// 查询余票代码...
}
//调用查询余票接口
requestTicket(){
//调用查询余票接口代码...
}
//购票相关逻辑
async handleBuyTicket(ticketList){
let year = new Date().getFullYear() //年份,
let month = new Date().getMonth() + 1 //月份,拼接购票日期用得上,由于余票接口只返回几号
let {
onStationName,//起始站点名
offStationName,//结束站点名
lineId,//线路id
vehTime,//发车时间
startTime,//预计上车时间
onStationId,//上车的站台id
offStationId //到站的站台id
} = this.data // 初始化的数据
let station = `${onStationName}-${offStationName}` //站点,发短信时候用到:"宝安交通局-深港产学研基地"
let dateStr = ""; //车票日期
let tickAmount = "" //总张数
ticketList.forEach(item => {
dateStr = dateStr + `${year}-${month}-${item.day},`
tickAmount = tickAmount + `${item.ticketLeft}张,`
})
let buyTicket = {
lineId,//线路id
vehTime,//发车时间
startTime,//预计上车时间
onStationId,//上车的站点id
offStationId,//目标站点id
tradePrice: '5', //金额
saleDates: dateStr.slice(0, -1),
payType: '2' //支付方式,微信支付
}
// 调用购票接口
let data = querystring.stringify(buyTicket)
let res = await this.requestOrder(data) //返回json数据,是否购票成功等等
//把发短信所须要数据都要传入
return Object.assign({}, JSON.parse(res.data), { queryParam: { dateStr, tickAmount, startTime, station } })
}//购票相关逻辑
//调用购票接口
requestOrder(obj){
return axios.post('http://weixin.xxxx.net/ebus/front/wxQueryController.do?BcTicketBuy', obj, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Mobile/12A365 MicroMessenger/5.4.1 NetType/WIFI",
"Cookie": this.cookie
}
})
}
handleInfoUser(){}//通知用户的逻辑
sendMSg(){} //发短信接口
}
复制代码
到这里,查询余票,购票这两个核心操做已经完成。
目前还剩下,如何通知用户是否购票成功。
以前我尝试过使用qq邮箱的smtp服务,抢票成功后发送邮件通知,可是我以为吧,并很差用,主要是我没有打开邮箱的习惯,没网也收不到,因此,并无采纳这个方案。
加上以前我注册过企业认证的公众号,腾讯云免费送了我1000条短信通知,并且短信也比较直观,因此我这里就安装腾讯云的SDK,部署了一套发短信的功能。
其实看看文档就好了,我也是copy文档,注意看短信单发那部分
cloud.tencent.com/document/pr…
若是跟我同样有企业认证的话,看快速入门这里就好了,一步步跟着操做
{Number}
这些里面的数字是变量。
就是说短信的模板是固定的,可是里面有{Number}
的内容能够自定义
调用的时候,里面的数字对应着传过去的参数数组序号,{1}表明数组[0]参数,以此类推
提交审核,审核通常很快就经过,也就是几十万毫秒吧
class QueryTicket{
constructor({ data, phoneNumber, cookie, day }) {
//constructor代码...
}
//初始化
async init(){
let ticketList = await this.handleQueryTicket()
//若是有余票
if (ticketList.length) {
//把余票传入购票逻辑方法,返回短信通知所须要的数据
let resParse = await this.handleBuyTicket(ticketList)
//执行通知逻辑
this.handleInfoUser(resParse)
}
}
//查询余票的逻辑
async handleQueryTicket(){
// 查询余票代码...
}
//调用查询余票接口
requestTicket(){
//调用查询余票接口代码...
}
//购票相关逻辑
async handleBuyTicket(ticketList){
//购票代码...
}
//调用购票接口
requestOrder(obj){
//购票接口请求代码...
}
//通知用户的逻辑
async handleInfoUser(parseData){
//获取上一步购票的response数据和咱们拼接的数据
let { returnCode, returnData: { main: { lineName, tradePrice } }, queryParam: { dateStr, tickAmount, startTime, station } } = parseData
//若是购票成功,则返回500
if (returnCode === "500") {
let res = await this.sendMsg({
dateStr, //日期
tickAmount: tickAmount.slice(0, -1), //总张数
station, //站点
lineName, //巴士名称/路线名称
tradePrice,//总价
startTime,//出发时间
phoneNumber: this.phoneNumber,//手机号
})
//若是发信成功,则再也不进行抢票操做
if (res.result === 0 && res.errmsg === "OK") {
this.setStop(true)
} else {
//失败不作任何操做
console.log(res.errmsg)
}
} else {
//失败不作任何操做
console.log(resParse['returnInfo'])
}
}
//发短信接口
sendMSg(){
let { dateStr, tickAmount, station, lineName, phoneNumber, startTime, tradePrice } = obj
let appid = 140034324; // SDK AppID 以1400开头
// 短信应用 SDK AppKey
let appkey = "asdfdsvajwienin23493nadsnzxc";
// 短信模板 ID,须要在短信控制台中申请
let templateId = 7839; // NOTE: 这里的模板ID`7839`只是示例,真实的模板 ID 须要在短信控制台中申请
// 签名
let smsSign = "测试短信"; // NOTE: 签名参数使用的是`签名内容`,而不是`签名ID`。这里的签名"腾讯云"只是示例,真实的签名须要在短信控制台申请
// 实例化 QcloudSms
let qcloudsms = QcloudSms(appid, appkey);
let ssender = qcloudsms.SmsSingleSender();
// 这里的params就是短信里面能够自定义的内容,也就是填入{1}{2}..的内容
let params = [dateStr, station, lineName, startTime, tickAmount, tradePrice];
//用promise来封装下异步操做
return new Promise((resolve, reject) => {
ssender.sendWithParam(86, phoneNumber, templateId, params, smsSign, "", "", function (err, res, resData) {
if (err) {
reject(err)
} else {
resolve(resData)
}
});
})
}
}
复制代码
若是发信成功,返回result:0
到这里,大部分需求已经完成了,还剩下一个定时任务
也声明一个类,这里咱们用到的是schedule
// 定时任务
class SetInter {
constructor({ timer, fn }) {
this.timer = timer // 每几秒执行
this.fn = fn //执行的回调
this.rule = new schedule.RecurrenceRule(); //实例化一个对象
this.rule.second = this.setRule() // 调用原型方法,schedule的语法而已
this.init()
}
setRule() {
let rule = [];
let i = 1;
while (i < 60) {
rule.push(i)
i += this.timer
}
return rule //假设传入的timer为5,则表示定时任务每5秒执行一次
// [1, 6, 11, 16, 21, 26, 31, 36, 41, 46, 51, 56]
}
init() {
schedule.scheduleJob(this.rule, () => {
this.fn() // 定时调用传入的回调方法
});
}
}
复制代码
假设咱们有两个用户要抢票,因此定义两个obj,实例化下QueryTicket
类
data: { //用户1
lineId: 111130,
vehTime: 0722,
startTime: 0751,
onStationId: 564492,
offStationId: 17990,
onStationName: '宝安交通运输局③',
offStationName: "深港产学研基地",
tradePrice: 0,
saleDates: '',
beginDate: '',
},
phoneNumber: 123123123,
cookie: 'JSESSIONID=TESTCOOKIE',
day: "17"
}
let obj2 = { //用户2
data: {
lineId: 134423,
vehTime: 1820,
startTime: 1855,
onStationId: 4322,
offStationId: 53231,
onStationName: '百度国际大厦',
offStationName: "裕安路口",
tradePrice: 0,
saleDates: '',
beginDate: '',
},
phoneNumber: 175932123124,
cookie: 'JSESSIONID=TESTCOOKIE',
day: ""
}
let ticket = new QueryTicket(obj) //用户1
let ticket2 = new QueryTicket(obj2) //用户2
new SetInter({
timer: 1, //每秒执行一次,建议5秒,否则怕被ip拉黑,我这里只是为了方便下面截图
fn: function () {
[ticket,ticket2].map(item => { //同时进行两个用户的抢票
if (!item.getStop()) { //调用实例的原型方法,判断是否中止抢票,若是没有则继续抢
item.init()
} else { // 若是抢到票了,则不继续抢票
console.log('stop')
}
})
}
})
复制代码
node index.js
运行下,跑起来了
其实能够在此基础上还能添加更多功能,好比直接抓取登陆接口获取cookie,指定路线抢票,还有错误处理啊啥的
值得注意的是,请求接口不能太频繁,最好控制在5秒一次的频率,否则会给别人形成困扰,也容易被ip拉黑
若是想把它作成一个完整的项目,建议使用ts加持 ,关于ts我推荐阅读这篇JD前端写的文章
但愿各位能有所收获
本文只作为技术分享,文中代码仅作学习用途
(主要是我没受权给其余公众号,他们擅自就转载了个人文章,搞得我好不爽,不如本身弄一个公众号算了)