關於crawler-以台北捷運為例

為了做某個東西,要抓取台北捷運的票價及站與站之間的行駛時間,但過程中也學習到了不少,所以紀錄且分享一下。

開發環境

作業系統:ubuntu

後端程式語言:node js

框架: express

資料庫:MySQL

使用套件:cheerio

目標

這次的目標是台北捷運票價系統,而抓取的資料為頁面中的:

  • 起站
  • 訖站(到達站)
  • 單程票價錢
  • 電子票證(悠遊卡價錢)
  • 敬老卡、愛心卡、愛心陪伴卡價錢
  • 乘車時間
抓取單站資料

首先,我們先從隨便選一個車站讓它有其他起站、訖站…等資料。這邊以「BR01 動物園」為例:

這邊需要注意的是,在寫爬蟲的時候,我們要先去剖析它網頁的架構。首先,我們可以在頁面上點選右鍵->檢查->從html語法中尋找我們的目標。

然後,發現到我們的目標大概長像下面這樣:

台北捷運票價系統-html

接著,我們來嘗試抓看看能不能抓到起站的資訊。起站的html為:

<td align="center" width="20%">
<font size="-1">BR01 動物園</font>
<td>

然後我們程式就可以寫成:

var cheerio = require('cheerio');
var request = require('request');
var path = "http://web.metro.taipei/c/TicketALLresult.asp";
request(path, function(err, res, body){
var $ = cheerio.load(body);
//站名的部份(包括起站及到站)
var station = "";
var stations = [];
$('body td[width="20%"] font[size=-1]').each(function() {
station = $(this).text();
stations.push(station);
})
})
console.log(stations); 
// BR01 動物園, BR02 木柵, BR01 動物園, BR03 萬芳社區...

但這樣就會發現到array中會同時出現起站跟訖站,因為這兩個地方的html都剛好有符合我們所下的條件。

所以之後我們再來剖析這個array,來將起站跟訖站分開:

//起始站                
for (var i = 0; i < stations.length; i = i + 2) {    startStation.push(stations[i]);
}
console.log("startStation: " + startStation.length);
//BR01 動物園, BR01 動物園
//到達站                
for (var j = 1; j < stations.length; j = j + 2) {  
 endStation.push(stations[j]);
}
console.log("endStation: " + endStation.length);
//BR02 木柵, BR03 萬芳社區

然後剩下的其他部分也一樣處理,金錢的部分:

//錢的部份(包括原價、8折、4折)                
var moneyList = [];
var money = "";
$('body td[width="15%"] font[size=-1]').each(function() {
money = $(this).eq(0).text();
moneyList.push(money);
});

這部分也要做剖析:

var oriMoney = [];                
var off8Money = [];
var off4Money = [];
//原價
for (var i = 0; i < moneyList.length; i = i + 3) {
oriMoney.push(moneyList[i]);
}
//8折                
for (var j = 1; j < moneyList.length; j = j + 3) {   
 off8Money.push(moneyList[j]);
}
//4折                
for (var k = 2; k < moneyList.length; k = k + 3) {
 off4Money.push(moneyList[k]);
}

到站時間部分(很幸運的,這個不用剖析):

//到站時間
var time = "";
var times = [];
$('body td[width="14%"] font[size=-1]').each(function() {
time = $(this).text();
times.push(time);
})

這樣我們就已經獲取完起站、訖站、價錢及時間部分的資料。但我們的目標是要獲取以台北捷運各站當作起點的所有資料,而剛剛做完的就只有從「動物園」站到各站的所有資料而已。


抓取各站到各站的全部資料

接著,我們可以開始思考,它是怎麼觸發我們的需求(request)並回應(response)資料給我們。

可以在送出「確定」的那邊按右鍵->檢查->Network->TicketALLresult.asp
來看這個request大概是怎什麼樣子。

可以發現到它request裡面的message是長這樣:

Form Data:
s2elect:BR01-019
submit: 確定

等同於說,它是有透過HTTP method為「POST」的方式,來傳出需求。這時我們就可以思考假設我們在同是後端的情況下,要怎麼去再做出一個request過去這個網頁,並回傳我們要的結果(如:剛剛動物園站到達各站的資料所示)。

//也就是說我們希望能夠達到像這樣子的結果
        --(我要動物園站資料)-->     --(給我動物園站資料)--> 
client  server 北捷server
<-----(你要的資料)----    <---(動物園站資料)---

這部分我們(server)可以透過request來模擬出POST的動作來達成:

var request = require('request');
var path = "http://web.metro.taipei/c/TicketALLresult.asp";
request.post({
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
url: path,
body: 's2elect=BR05-015&submit=確定'
}, function(err, res, body) {
var $ = cheerio.load(body);
$('body option').each(function() {
var stationValue = $(this).val();
stationValues.push(stationValue);
}
}
})

這樣回傳的body資料就會剛好符合從「動物園」站到各站的所有資料。但目前我們是用動物園站的來抓取,但其他站怎麼辦?

這時,我們需要的是透過各個站點的代號來發出「POST」 HTTP method 的request。如:

Form Data:
s2elect: <各站代號>
submit: 確定

並將它塞入一個array,之後在用一個for迴圈就可以達成。但在這之前,我們要先抓取各站的代號,才能依序發出request來要回我們的資料。

所以我們必需繼續剖析它網頁中的html,找出我們所要的「各站代號」資料:

由上圖可以發現到它都聚集在option的value中,於是我們可以用下列程式碼來抓取:

//先抓取整個捷運站的站名代號 
var stationValues = [];
request(path, function(err, res, body) {          
var $ = cheerio.load(body);
$('body option').each(function() {
var stationValue = $(this).val();
stationValues.push(stationValue);
})
})

這樣就能順利地抓取到各個捷運站的站名代號。

但問題又來了,我們要如何抓取各個捷運站的站名代號後,在依序透過for迴圈來讓它抓取我們所要得全部資料呢?

這邊的處理順序為:

  1. 先抓取整個捷運站的站名代號
  2. 從每個站名當起始抓到每個站之間的時間,金錢…等之類。也就是重複一開始所說的「抓取單站資料」所做的事情。
使用async的waterfall

這部分我們可以透過async的waterfall來幫我們完成,它的概念像是:

async.waterfall([
myFirstFunction,
mySecondFunction,
myLastFunction,
], function (err, result) {
// result now equals 'done'
});
function myFirstFunction(callback) {
callback(null, 'one', 'two');
}
function mySecondFunction(arg1, arg2, callback) {
// arg1 now equals 'one' and arg2 now equals 'two'
callback(null, 'three');
}
function myLastFunction(arg1, callback) {
// arg1 now equals 'three'
callback(null, 'done');
}

假設有三件事情要處理,處理完第一件事情後,可以得到one跟two並沿用到第二件事情;處理完第二件事情後,可以得到three;處理完第三件事情後可以得到done。

等同於說我們可以透過它的特性,來將擷取到的各站代號來帶入到下一個for迴圈中,並透過它來抓取出全部的資料:

//先抓取整個捷運站的站名
var stationValues = [];
function firstFunction(callback) {
request(path, function(err, res, body) {
var $ = cheerio.load(body);
$('body option').each(function() {
var stationValue = $(this).val();
stationValues.push(stationValue);
})
callback(null, stationValues);
})
}
async.waterfall([
firstFunction
], function(err, result) {
if (err) {
reject(err);
}
//從每個站名當起始去抓到每個站之間的時間, 金錢...之類
for (var temp = 0; temp < result.length; temp++) {
request.post({
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
url: path,
// body: 's2elect=BR05-015&submit=確定'
body: 's2elect=' + result[temp] + '&submit=確定'
},
function(err, res, body) {
//開始做上面「抓取單站資料」所做的事情
...
}
}
)
MySQL資料庫部分

這邊我們欄位設定為:

  • id: id (primary key)
  • start_station: 起始站
  • end_station: 到達站
  • ori_money: 原價
  • off80_money: 電子票價
  • off40_money: 敬老等特殊票價
  • arrive_time: 各站間的通車時間

之後,再將資料透過SQL中的bulk insert的方式來將大量資料塞入資料庫中:

var values = [];
//這邊設120是因為剛好有121個捷運站,而剛好這邊的array.length都為120。
for (var i = 0; i < 120; i++) {
values.push([startStation[i], endStation[i], oriMoney[i], off8Money[i], off4Money[i], times[i]]);
}
db.query('INSERT INTO taipei_mrt (start_station, end_station, ori_money, off80_money, off40_money, arrive_time) VALUES ?', [values], function(err, rows) {
if (err) {
console.log(err);
}
})

完成後就可以在資料庫中看到全部的資料啦!

補充:

如果資料庫在塞入資料時,是使用bulk insert方式的話,可以大幅提升對資料庫寫入資料的速度。筆者測試過用程式自動寫入50萬筆資料,約3秒多就能完成。