iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 25
1

定義目標

台鐵網站一直都很古老,訂票網站更是落後,看了一下似乎有 app 做出一鍵訂票的功能,但好像後續又被台鐵擋住(?)。那麼作為爬蟲,理應是人工能做的流程爬蟲就幾乎能做,而我們今天的主題就來走一下台鐵的訂票流程吧。

先 demo 一下成果...

*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***


實際探訪

台鐵的網站位置在 http://railway.hinet.net/ ,進去網站後點選「車次訂單程票」,然後再輸入身分證字號等資訊,接著會到輸入驗證碼的頁面,輸入完驗證碼送出就完成訂票流程。

ps. 這次的過程當中會有用到身分證,是使用身分證產生器來處理。

這邊有觀察到一點,當點擊連結的時候,畫面內容會切換,但網址並沒有改變,看起來似乎像是前端 render!!?但觀察過後其實不是...是古老的 iframe,那我們就能直接使用 iframe 的網址當作起始就可以了。

接下來看看填寫表單頁面,網址是 http://railway.hinet.net/ctno1.htm ,填入之後會送出一個 post request,form 裡面有我們所填入的資料。

然後我們需要填入驗證碼,這部分肯定就是爬蟲會挺難處理的其中一個步驟,我們先手動輸入驗證碼,然後觀察他送出了一個 post request,內容包含驗證碼和剛剛填入的訂單資訊。

*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***

這樣看起來,我們只需要搞定三個 request,包含一開始的 init get request,一個輸入訂單的 post request,一個包含驗證碼的 post request。


分解研究

要走自動化訂票流程,我們要解決以下三個步驟:

  1. 初始化 cookie
  2. 輸入訂票資訊
  3. 取得驗證碼
  4. 送出包含驗證碼的訂票資訊

初始化 cookie

照正常的流程走,一進到台鐵的網站就會 set cookie,而在送出訂票資訊的時候也會取得驗證碼,照正常的邏輯,驗證碼一定跟 cookie 有關,所以我們勢必要先模擬出 cookie,測試看看直接 request 台鐵首頁,確定 header 有 set cookie 就可以了。

輸入訂票資訊

在取得 cookie 後,我們就用 postman 來測試看看送出訂票資訊,這邊一開始送出是失敗的,因為台鐵網站的 post 都有檢查 request 的 referer,所以我們在 header 加上 referer 就可以成功送出了。

取得驗證碼

接著來看看畫面中的驗證碼的位置,是在 #idRandomPic 裡面,那就回到 response 裡面找找看,可以順利找到,但發現他是夾在 noscript tag 裡面,所以等下實作的時候,直接 select 是取不到的,要額外把這個 html append 出來。

接著我們試著來取得驗證碼看看,可以順利取得驗證碼。

送出包含驗證碼的訂票資訊

接著我們來試著模擬一下包含驗證碼的訂票 request,記得帶上 cookie 和 referer,就能夠順利取得訂票代號了。

*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***


實作程式碼

這次我們的 async 步驟會比較多,所以這次我們使用 promise 來實作。

console-jpeg

我們這次因為會先手動輸入驗證碼,所以來一個好玩的套件,能將圖片輸出至 console,在過程當中我們會用到,有興趣的朋友可以看看 console-jpeg

Init

因為這整段步驟我們都需要使用 cookie,所以直接在 request require 就設定好 defaults,另外我們會使用 NSC_BQQMF=ffffffffaf121a1e45525d5f4f58455e445a4a423660 這個預設 cookie,在衍伸應用會補充。

另外我們也先準備好我們要訂購的車票資訊。

const request = require('request').defaults({
  jar: true,
  headers: {
    cookie: 'NSC_BQQMF=ffffffffaf121a1e45525d5f4f58455e445a4a423660',
  }
})

var reservationInfo = {
  person_id: 'A134405743', // 這是 fake 身分證
  from_station: '175',
  to_station: '185',
  getin_date: '2018/01/01-06',
  train_no: '105',
  order_qty_str: '1',
  returnTicket: '0',
}

initCookie function

這部分我們只需要送出一個 get request 就可以了。

function initCookie() {
  return new Promise(done => {
    request('http://railway.hinet.net', (err, res, body) => {
      done()
    })
  })
}

fillInfo function

我們模擬送出訂購資訊的 post request,然後在 response 中取得驗證碼圖片的 url,這邊注意到,因為圖片的 don 是放在 noscript 裡面,所以我們必須先把內容取出 append 到 body,才能夠直接 select 到圖片物件。

取完圖片路徑後,再用 request 將圖片載到我們的資料夾裡面。

function fillInfo() {
  return new Promise(done => {
      var options = {
        url: 'http://railway.hinet.net/check_ctno1.jsp',
        method: 'POST',
        form: reservationInfo,
        headers: {
          referer: 'http://railway.hinet.net/ctno1.htm',
        }
      }
      request(options, (err, res, body) => {

        var $ = cheerio.load(body)
        $('body').append($('table noscript').html())
        request('http://railway.hinet.net/' + $('#idRandomPic').attr('src')).pipe(fs.createWriteStream('code.jpeg')).on('close', done)
      })
  })
}

getCodeFromConsole function

因為我們還沒有導入自動辨識圖片的功能,所以必須人工辨識圖片再手動輸入驗證碼,這部分就是讓 console 等待使用者的輸入,我們使用 prompt 來取得輸入。

function getCodeFromConsole() {
  return new Promise(done => {
    prompt.start();
    prompt.get(['code'], function(err, result) {
      done(result.code)
    });
  })
}

takeOrder function

最後我們在送出包含驗證碼的訂購資訊,取得 response 之後再將 #spanOrderCode 選取出就可以了。

function takeOrder(code) {
  return new Promise(done => {
    reservationInfo.randInput = code
    var options = {
      url: 'http://railway.hinet.net/order_no1.jsp',
      method: 'POST',
      form: reservationInfo,
      headers: {
        referer: 'http://railway.hinet.net/check_ctno1.jsp',
      }
    }
    request(options, (err, res, body) => {
      var $ = cheerio.load(body)
      done($('#spanOrderCode').text())
    })
  })
}

積木組合

這邊我們使用 promise then 來組合所有的流程邏輯,先 initCookie,然後做 fillInfo,取得圖片之後,我們呼叫 consoleJpeg 將圖片顯示在 console,然後由使用者手動輸入驗證碼,最後下訂單成功取得訂單號碼。

initCookie()
  .then(() => {
    return fillInfo()
  })
  .then(() => {
    consoleJpeg.attachTo(console)
    return console.jpeg(fs.readFileSync(__dirname + '/code.jpeg'))
  })
  .then(() => {
    return getCodeFromConsole()
  })
  .then((code) => {
    return takeOrder(code);
  })
  .then((orderNumber) => {
    console.log('訂位代號 => '+orderNumber);
  })

完整程式碼

const request = require('request').defaults({
  jar: true,
  headers: {
    cookie: 'NSC_BQQMF=ffffffffaf121a1e45525d5f4f58455e445a4a423660',
  }
})
const cheerio = require('cheerio');
const consoleJpeg = require('console-jpeg');
const prompt = require('prompt');
const fs = require('fs');


initCookie()
  .then(() => {
    return fillInfo()
  })
  .then(() => {
    consoleJpeg.attachTo(console)
    return console.jpeg(fs.readFileSync(__dirname + '/code.jpeg'))
  })
  .then(() => {
    return getCodeFromConsole()
  })
  .then((code) => {
    return takeOrder(code);
  })
  .then((orderNumber) => {
    console.log('訂位代號 => '+orderNumber);
  })

var reservationInfo = {
  person_id: 'A134405743', // 這是 fake 身分證
  from_station: '175',
  to_station: '185',
  getin_date: '2018/01/01-06',
  train_no: '105',
  order_qty_str: '1',
  returnTicket: '0',
}

function initCookie() {
  return new Promise(done => {
    request('http://railway.hinet.net', (err, res, body) => {
      done()
    })
  })
}

function fillInfo() {
  return new Promise(done => {
      var options = {
        url: 'http://railway.hinet.net/check_ctno1.jsp',
        method: 'POST',
        form: reservationInfo,
        headers: {
          referer: 'http://railway.hinet.net/ctno1.htm',
        }
      }
      request(options, (err, res, body) => {

        var $ = cheerio.load(body)
        $('body').append($('table noscript').html())
        request('http://railway.hinet.net/' + $('#idRandomPic').attr('src')).pipe(fs.createWriteStream('code.jpeg')).on('close', done)
      })
  })
}

function getCodeFromConsole() {
  return new Promise(done => {
    prompt.start();
    prompt.get(['code'], function(err, result) {
      done(result.code)
    });
  })
}

function takeOrder(code) {

  return new Promise(done => {
    reservationInfo.randInput = code
    var options = {
      url: 'http://railway.hinet.net/order_no1.jsp',
      method: 'POST',
      form: reservationInfo,
      headers: {
        referer: 'http://railway.hinet.net/check_ctno1.jsp',
      }
    }
    request(options, (err, res, body) => {
      var $ = cheerio.load(body)
      done($('#spanOrderCode').text())
    })
  })
}

*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***
*** 訂票成功要記得取消掉 ***


衍伸應用

台鐵的訂票似乎有兩種不同模式的網頁,長得一樣,但內容 js 完全不同,兩種判別方式也不同,今天介紹這只是其中一種,而你可能會隨機遇到一種(難道是傳說中的 AB test??,肯定是我想太多,長得一樣是要 test 什麼)

若要肯定的執行其中一種,那麼可以透過 cookie 將方式固定,所以我們 init 那段就加了 cookie: 'NSC_BQQMF=ffffffffaf121a1e45525d5f4f58455e445a4a423660' 來固定購票方式。

第一種(比較簡單的)

第二種(加密過的)


上一篇
iThelp oAuth 登入
下一篇
改造台鐵訂票全自動
系列文
爬蟲始終來自於墮性34

尚未有邦友留言

立即登入留言