Node.js 實現搶票小工具&短信通知提醒

寫在前言

要知道在深圳上班是很是痛苦的事情,特別是我上班的科興科技園這一塊,去的人很是多,天天上班跟春運同樣,若是我能換到之前的大沖上班那就幸福了,惋惜,換不得。javascript

尤爲是我這個站等車的多的一筆,上班公交擠的不行,車滿的時候只有少部分人能硬擠上去。一般我只會用兩個字來形容這種人:「公交怪」html

想當年我朋友瘦的像只猴還能上去,老子身高182體重72kg擠個公交,不成問題,反手一個阻擋,悶聲發大財,前面的阿姨你快點阿姨,別磨磨唧唧的,快上去啊阿姨,嗯?你還想擠掉我?你能擠掉我?你能擠掉我!我當場!把車吃了!前端

....java

咳咳,擠公交是不可能擠公交滴,由於今天我發現了一個能夠定製路線的網約巴士公衆號【深圳xxx】node

可是呢,票常常會被搶光,同時我還我發現,有時候會有人退票,這時候就有空餘票了,關鍵是我不可能時時都在公衆號上盯着,因而,我就寫了一個搶票+短信通知的小工具ios

獲取接口信息

查看頁面結構

這個就是訂票頁面,顯示當前月的車票狀況,根據圖示,紅色爲已滿,綠色爲已購,灰色爲不可選 chrome

若是是可選就是白色的小方塊,而且在下面顯示餘票,以下圖所示:

咱們打算這麼作,

  1. 定時抓取返回的接口信息
  2. 根據接口返回值判斷是否有餘票

好,審查下源代碼看下接口信息,等等,微信瀏覽器沒辦法審查源代碼,因而shell

使用chrome 調試微信公衆號網頁頁面

首先面臨個問題,若是直接copy公衆號網頁Url在chrome打開的話,就會顯示這個畫面,他被302重定向到了這個頁面,因此是行不通的,只有獲取OAuth2.0受權才能進去npm

因此咱們得先經過抓包工具,知道手機訪問微信公衆號網頁的時候,須要帶什麼信息過去,這時候咱們就得藉助抓包工具,由於我電腦是Mac,用不了Fiddler,我用的是Charles花瓶,就是下面這位仁兄json

藉助這個工具,咱們只需3步就能夠輕鬆搞定手機數據抓包:

  1. 獲取本機IP地址和端口
  2. 設置代理手機上網
  3. 依次執行上面兩步

獲取本機IP地址和端口

第一步,找到端口號,通常默認是8088,可是爲了確承認以打開Proxy/Proxy Setting看下,哦原來我以前設置成了8888

而後找到 Charleshelp/ Local IP Address,點擊它就會看到本身的本機地址,找到本機地址記下來,而後進行下一步

設置代理手機上網

首先保證手機跟電腦鏈接的是同一個wifi,而後在wifi設置那裏會有設置代理信息,好比個人猴米...不對,小米9手機!設置以下:

輸入上一步獲取主機名,端口號就ok了

輸入完成,點擊肯定後。Charles就會彈出一個對話框,問你是否贊成接入代理,點擊肯定allow就好了。

用手機訪問目標網頁

咱們用手機訪問微信公衆號【深圳x出行】進入到搶票頁面後,發現Charles已經成功抓包到了網頁信息,當咱們進入這個搶票頁面的時候,他會發起兩個請求,一個是獲取document文檔內容,一個post請求獲取票務信息。

仔細分析了下,大概明白了業務邏輯:

整個項目技術站是java+jsp,傳統寫法,用戶身份驗證主要是cookie+session方案,前端這一塊主要是使用jQuery

當用戶進入頁面的時候,會攜帶查詢參數,如起始站點,時間,車次等信息和cookie請求document文檔, 也就是圈起來的這一塊,

而咱們想要的核心內容:日曆表,一開始是不顯示的

由於還要在請求一次

第二次請求,攜帶cookie和以上的查詢參數發起一個post請求,獲取當月的車票信息,也就是日曆表內容

下面這個是請求當月票務信息,然而發現他返回的是一堆html節點

好吧...估計是獲取到以後直接appenddiv裏面的,而後渲染生成日曆表內容

接着在手機上操做,選擇兩個日期,而後點擊下單,發送購票請求,拉取購票接口,咱們看下購票接口的請求和返回內容:

看下request 內容,根據字段的意思大概明白是線路,時間,以及車票金額,還有支付方式

在看看返回的內容:返回一個json字符串數據,裏面大概涵蓋了下單的成功返回碼,時間,id號等等信息

記錄所須要的信息內容

根據上面的分析,總結下內容: 整個項目用戶身份驗證是使用cookiesession方案,請求數據用的是form data方式,請求字段啥的咱們也都清楚,惟獨有一點,就是請求餘票的時候,返回的是html節點代碼,而不是咱們預期的json數據,這樣就有個麻煩,咱們沒辦法一目瞭然的明白他餘票的時候是如何顯示的

因此咱們只能經過chrome進行調試,才能得出他是如何判斷餘票的。

咱們找個記事本,記錄下信息,記錄的內容有:

  1. 請求餘票接口和購票接口的url地址
  2. cookie信息
  3. 各自的request參數字段
  4. user-Agent信息
  5. 各自的response返回內容

設置chrome

有以上信息後,咱們就能夠開始用chrome調試了, 首先打開More tools/Network conditions

user-Agent填入到 Custom裏面

Charles抓包本地請求

由於咱們要把獲取到的cookie填入到chrome裏面,以咱們的用戶身份去訪問網頁,因此咱們須要在請求目標地址的時候,改包修改cookie

首先咱們須要開啓 macOS Proxy,抓包咱們的http請求

打開chrome訪問目標網址,咱們能夠看到 Charles上已經抓包到了咱們訪問的目標url地址,而後給目標url地址打上斷點,方便調試

而後再次訪問,這時候斷點就生效了,彈出一個tab名爲 break points,能夠看到之因此咱們仍是不能訪問到目標網址,是由於 sessionId不對,因此咱們把抓取到的 cookie在填入到裏面,點擊 execute

這時候,可以正確跳到目標頁面了。

大概看了下他總體佈局,和 jQuery代碼 CSS代碼,特別是日曆表那一塊

審查了下元素髮現:

  1. 小方塊的結構爲:
<td class="b">
<span>這裏爲日期</span>
<span>若是有餘票則顯示餘票數量</span>
</td>
複製代碼
  1. td的樣式名爲a表明不可選
  2. 樣式名爲e表明已滿
  3. 樣式名爲d表明已購
  4. 樣式名爲b則是咱們要找的,表明可選,也就是有餘票

到這一步,整個購票流程就清楚了

到時候咱們經過Node.js請求的時候,處理返回數據,用正則去判斷是否有餘票的class名b ,有餘票的話,在獲取div裏面的餘票數量內容就Ok了

Node.js 請求目標接口

分析須要開發的功能點

寫代碼以前咱們須要想好功能點,咱們須要什麼功能:

  1. 請求餘票接口
  2. 定時請求任務
  3. 有餘票則自動請求購票接口下訂單
  4. 調用騰訊雲短信api接口發送短信通知
  5. 多個用戶搶票功能
  6. 搶某個日期的票

首先mkdir ticket 建立名爲ticket的文件夾,接着cd ticket進入文件夾npm init一路瞎幾把回車也無妨。 下面開始安裝依賴,根據上面的功能需求,咱們大概須要:

  1. 請求工具,這裏看我的習慣,你也可使用原生的http.request,我這裏選擇用的是axios,畢竟axios在node端底層也是調用http.request
cnpm install axios --save
複製代碼
  1. 定時任務 node-schedule
cnpm install node-schedule --save
複製代碼
  1. node端選擇dom節點工具 cheerio
cnpm install cheerio --save
複製代碼
  1. 騰訊發短信的依賴包 qcloudsms_js
cnpm install qcloudsms_js 
複製代碼
  1. 熱更新包,諾豆的媽媽,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前端寫的文章

juejin.im/post/5d8efe…

但願各位能有所收穫

特別聲明

本文只作爲技術分享,文中代碼僅作學習用途

不按期更新js各類好玩的用法,歡迎關注,不發廣告,不靠這個恰飯

(主要是我沒受權給其餘公衆號,他們擅自就轉載了個人文章,搞得我好不爽,不如本身弄一個公衆號算了)

相關文章
相關標籤/搜索