這是聊天室裡小魚提出來的主題,其實這類的需求老實說挺多的,在日常生活中,常常需要去整理一些網路上的資料,他不難但很煩,而且這些沒營養的動作常常會耗費掉一、兩個小時,甚至是每天都必須做一次,那麼我們就藉著這個主題,來寫一篇爬資料的流程。
ps. 這個主題其實不會很適合新手嘗試,因為老實說有很多是經驗累積出來的感知XDDD
台彩的銷售地點網頁位置在 http://www.taiwanlottery.com.tw/Lotto/se/salelocation.aspx ,第一眼看起來挺單純的,看起來動作並不複雜,需要做的事情為:
但發現選擇縣市以後,其鄉鎮市區的選項會 post request 轉跳原本的頁面,然後鄉鎮市區的資訊會在這個 request 的 response。同時看到這個 request 送出了一些有趣的資訊,其中 DropDownList1、DropDownList2 我們合理猜測是那縣市和鄉鎮市區的下拉選單。
反查一下 DropDownList1 確定是縣市沒錯,而且可以看到每個縣市所代表的代碼,而 DropDownList2 在選擇縣市時都保持為 0。
接著我們要先提一下另外幾個 __
開頭的參數,有經驗的人大概一眼就能看出這是 asp.net 的一種驗證機制,簡單來說,他會確保你所送出的 request 是從對的流程送過來 ,因為它裡頭包含了流程和頁面內容的狀態資訊。上一頁填入的內容不同,下一頁的驗證值就不同。
舉個例子,我們若用 get 重新請求網址,這個我們稱為第一頁,接下來我們選擇台北市,這個我們稱為第二頁,你將會發現,在這兩個頁面的 __EVENTVALIDATION
是不同的。總歸來說,在這個頁面每個縣市選擇後有他自己的 __EVENTVALIDATION
,當查詢按下去的時候,會根據這 22 個縣市要各別有 22 個不同的 __EVENTVALIDATION
。
接著我們選擇鄉鎮市區,在這個步驟並沒有發出 request,還好沒有,若有的話,我們就必須把這些全部的鄉鎮市區的 __EVENTVALIDATION
都記起來,那就會多了一個步驟了。接著最後按查詢,然後 post request 得到的 response 就帶有經銷商的資訊了。
來觀察一下這個 post request,不只多了一個 Button1 值為查詢
,另外 DropDownList2 也帶上了所選擇的地區。更眼尖的發現,地區的部分後面似乎跟著空白...
為了求證一下,我們去看看 form data 的 source,確認真的是空白,而空白也會被一併 encode,同時在網路上找了一些文章,在 application/x-www-form-urlencoded 的時候空白會被 encode 為 +
(不過這個地方大概也就是剛進入這領域的玩家會遇到的雷)。
確認目標和探訪後,我們先來取得所有縣市的鄉鎮市區,這個動作看起來可以很順利的取得。
接下來要模擬按下查詢的 request,在這邊我們發現複製 chrome 的 form data 並沒有辦法很順利的取得結果,這是因為剛剛上面提到鄉鎮市區後面有兩個空白,但 postman 預設送出的 post data 會將空白 encode 成 %20
。
關於這點我們可以用 postman 所產生的 curl 來驗證,與 chrome 所送出的 data source 比對一下,發現確實變成 %20
,也就是說,我們等等實作的時候,要將 encode 的 %20
取代成 +
。
那我們在這個階段怎麼用 postman 驗證呢?這時候可以使用 raw data 來送,記得 header 的 Content-Type 要設成 application/x-www-form-urlencoded,確認可以順利拿到經銷商資料,那麼我想整個 case 就沒問題了。
這邊我們先分成兩個部分,首先我們要先抓到所有縣市和鄉鎮市區,同時要記錄各別縣市的 __EVENTVALIDATION
等資訊,那就來寫一個 getAreas function,這個 function 會先做出一個 1-25 的 array,因為縣市的代碼是 1-25,接著我們用 async.map 來送 request,同時帶上 __VIEWSTATE
, __EVENTVALIDATION
還有 DropDownList1
的 city code。取得 response 後,將裡面的資訊整理一下,包含這個頁面新的 __VIEWSTATE
, __EVENTVALIDATION
都把他們包裝起來,因為等等查詢經銷地點時會用到。
function getAreas(callback) {
// 先建立一個 1-25 的 array 作為 city 代碼
var cities = Array.from(new Array(25), (val, index) => index + 1);
async.map(cities, (city, callback) => {
var options = {
url: 'http://www.taiwanlottery.com.tw/Lotto/se/salelocation.aspx',
method: 'POST',
form: {
__VIEWSTATE: '/wEPDwUJNzkzNTQ1MDA0D2QWAgIBD2QWBgIDDxBkEBUXD+iri+mBuOaTh+e4o+W4ggnlj7DljJfluIIJ6auY6ZuE5biCCeaWsOWMl+W4ggnlrpzomK3nuKMJ5qGD5ZyS5biCCeaWsOeruee4ownoi5fmoJfnuKMJ5b2w5YyW57ijCeWNl+aKlee4ownpm7LmnpfnuKMJ5ZiJ576p57ijCeWxj+adsee4ownlj7DmnbHnuKMJ6Iqx6JOu57ijCea+jua5lue4ownln7rpmobluIIJ5paw56u55biCCeWPsOS4reW4ggnlmInnvqnluIIJ5Y+w5Y2X5biCCemHkemWgOe4ownpgKPmsZ/nuKMVFwEwATEBMgEzATQBNQE2ATcBOQIxMAIxMQIxMgIxNQIxNgIxNwIxOAIxOQIyMAIyMQIyMgIyMwIyNAIyNRQrAxdnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZxYBZmQCBQ8QZA8WAWYWARAFEuiri+WFiOmBuOaTh+e4o+W4ggUBMGdkZAIJDxYCHgRUZXh0ZWRk7OMyo+rJ/R1KANTviXoPJPkndR0NsYC1LwiYNtiju6s=',
__EVENTVALIDATION: '/wEdABvTQvp4f+icOnK0DqytTtX9GJFKGrixhNRIBkKHiTebmJdHjsaBD0tCA6n9BB0cd7/SuQcSL6aQDg9Xs0L9/OBOSLETwEpIPSGV/A3bSuaAq8txQ6H1pf/4yb6ee6XiSSrgaVU5g0VVgJ5Xw9CH21cr36wwFXY2y6HKS1iL/nz+DDAHNXrk/vTky92TeS8ewGxkOSICh45RRzPxQ2bOKdPZ9TMJgR6J0q0FSJhqpjArpYdDzKZ8fTpfS2upIzElKx9bOnQFKnqDLVt2LS60TXk3u+nrqeNztF0KTWk34Ojx+jt3L4XPmsr8zaHgPBUz8pz7zOH2Pa0Mz/p4CmWFOAIg2fQR55pz9djUgYpzP4S2sWILB2nYss/6vQyjOHLvdkLarj4gGBBx2fNmqMNazPxdO3eEsM9YImABtx30At0gB3zJH8aWmeVGgbN57epKFZK/0kflOYpV0ABwrxcGpmVNHTe/qwnS0Vxfgwc2Iwb7sKhxAQig1Wxc0udiyyKR9F0VLn1n0gyzxgAosISrlV2haCFZhaURDecAdKwOW58JS834O/GfAV4V4n0wgFZHr3etOZ1vOVspvi/jeFVo5fpgRuvTJkzux3zGT3OyLhvV9A==',
DropDownList1: city
}
}
request(options, (err, res, body) => {
var $ = cheerio.load(body)
var __VIEWSTATE = $('#__VIEWSTATE').val()
var __EVENTVALIDATION = $('#__EVENTVALIDATION').val()
var Button1 = $('#Button1').val()
var areas = $('#DropDownList2 option').map((index, obj) => {
return {
city: city,
area: $(obj).val(),
__VIEWSTATE: __VIEWSTATE,
__VIEWSTATE: __VIEWSTATE,
__EVENTVALIDATION: __EVENTVALIDATION,
Button1: Button1,
}
}).get()
callback(null, areas)
})
}, (err, results) => {
// 將 results 扁平化,再丟給 callback
callback([].concat.apply([], results));
})
}
接下來就來根據每個鄉鎮市區來取得各自的地點,但在這邊我們會選用 async.mapSeries 來送,因為用 async.map 會是非同步發送,對 server 會造成一定的負擔,而 async.mapSeries 是同步的,我們就慢慢抓吧。然後我們對剛剛拿到的 area 資訊做 string series,讓他變成 key=value&key=value&key=value 的形式,同時順便做 encodeURIComponent。接下來有個重要動作,就是上面提到的空白 encode,我們要將 %20
取代成 +
,然後就可以很順利地送出 request 了。拿到 response 之後,再將商店資訊包裝一下,就能丟給 callback 了。
function getStores(areas, callback) {
async.mapSeries(areas, (areaInfo, callback) => {
var dataString = Object.keys(areaInfo).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(areaInfo[k])}`).join('&')
dataString = dataString.replace('%20%20', '++')
var options = {
url: 'http://www.taiwanlottery.com.tw/Lotto/se/salelocation.aspx',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
body: dataString,
}
request(options, (err, res, body) => {
var $ = cheerio.load(body)
var stores = $('.tableD tr').map((index, obj) => {
return {
city: $(obj).find('td').eq(0).text(),
area: $(obj).find('td').eq(1).text(),
address: $(obj).find('td').eq(2).text(),
store: $(obj).find('td').eq(3).text(),
}
}).get()
stores.shift()
callback(null, stores)
})
}, (err, results) => {
console.log(results);
// 將 results 扁平化,再丟給 callback
callback([].concat.apply([], results));
})
}
我們完成了所有元件,接下來就來組合流程吧,首先先 getAreas,取得 所有鄉鎮市區之後,就丟入給 getStores function,然後就能取得所有經銷商地址了!
getAreas((areas) => {
getStores(areas, (stores) => {
console.log('done');
})
})
const request = require('request')
const cheerio = require('cheerio')
const async = require('async')
getAreas((areas) => {
getStores(areas, (stores) => {
console.log('done');
})
})
function getStores(areas, callback) {
async.mapSeries(areas, (areaInfo, callback) => {
var dataString = Object.keys(areaInfo).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(areaInfo[k])}`).join('&')
dataString = dataString.replace('%20%20', '++')
var options = {
url: 'http://www.taiwanlottery.com.tw/Lotto/se/salelocation.aspx',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
method: 'POST',
body: dataString,
}
request(options, (err, res, body) => {
var $ = cheerio.load(body)
var stores = $('.tableD tr').map((index, obj) => {
return {
city: $(obj).find('td').eq(0).text(),
area: $(obj).find('td').eq(1).text(),
address: $(obj).find('td').eq(2).text(),
store: $(obj).find('td').eq(3).text(),
}
}).get()
stores.shift()
callback(null, stores)
})
}, (err, results) => {
console.log(results);
// 將 results 扁平化,再丟給 callback
callback([].concat.apply([], results));
})
}
function getAreas(callback) {
// 先建立一個 1-25 的 array 作為 city 代碼
var cities = Array.from(new Array(25), (val, index) => index + 1);
async.map(cities, (city, callback) => {
var options = {
url: 'http://www.taiwanlottery.com.tw/Lotto/se/salelocation.aspx',
method: 'POST',
form: {
__VIEWSTATE: '/wEPDwUJNzkzNTQ1MDA0D2QWAgIBD2QWBgIDDxBkEBUXD+iri+mBuOaTh+e4o+W4ggnlj7DljJfluIIJ6auY6ZuE5biCCeaWsOWMl+W4ggnlrpzomK3nuKMJ5qGD5ZyS5biCCeaWsOeruee4ownoi5fmoJfnuKMJ5b2w5YyW57ijCeWNl+aKlee4ownpm7LmnpfnuKMJ5ZiJ576p57ijCeWxj+adsee4ownlj7DmnbHnuKMJ6Iqx6JOu57ijCea+jua5lue4ownln7rpmobluIIJ5paw56u55biCCeWPsOS4reW4ggnlmInnvqnluIIJ5Y+w5Y2X5biCCemHkemWgOe4ownpgKPmsZ/nuKMVFwEwATEBMgEzATQBNQE2ATcBOQIxMAIxMQIxMgIxNQIxNgIxNwIxOAIxOQIyMAIyMQIyMgIyMwIyNAIyNRQrAxdnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZxYBZmQCBQ8QZA8WAWYWARAFEuiri+WFiOmBuOaTh+e4o+W4ggUBMGdkZAIJDxYCHgRUZXh0ZWRk7OMyo+rJ/R1KANTviXoPJPkndR0NsYC1LwiYNtiju6s=',
__EVENTVALIDATION: '/wEdABvTQvp4f+icOnK0DqytTtX9GJFKGrixhNRIBkKHiTebmJdHjsaBD0tCA6n9BB0cd7/SuQcSL6aQDg9Xs0L9/OBOSLETwEpIPSGV/A3bSuaAq8txQ6H1pf/4yb6ee6XiSSrgaVU5g0VVgJ5Xw9CH21cr36wwFXY2y6HKS1iL/nz+DDAHNXrk/vTky92TeS8ewGxkOSICh45RRzPxQ2bOKdPZ9TMJgR6J0q0FSJhqpjArpYdDzKZ8fTpfS2upIzElKx9bOnQFKnqDLVt2LS60TXk3u+nrqeNztF0KTWk34Ojx+jt3L4XPmsr8zaHgPBUz8pz7zOH2Pa0Mz/p4CmWFOAIg2fQR55pz9djUgYpzP4S2sWILB2nYss/6vQyjOHLvdkLarj4gGBBx2fNmqMNazPxdO3eEsM9YImABtx30At0gB3zJH8aWmeVGgbN57epKFZK/0kflOYpV0ABwrxcGpmVNHTe/qwnS0Vxfgwc2Iwb7sKhxAQig1Wxc0udiyyKR9F0VLn1n0gyzxgAosISrlV2haCFZhaURDecAdKwOW58JS834O/GfAV4V4n0wgFZHr3etOZ1vOVspvi/jeFVo5fpgRuvTJkzux3zGT3OyLhvV9A==',
DropDownList1: city
}
}
request(options, (err, res, body) => {
var $ = cheerio.load(body)
var __VIEWSTATE = $('#__VIEWSTATE').val()
var __EVENTVALIDATION = $('#__EVENTVALIDATION').val()
var Button1 = $('#Button1').val()
var areas = $('#DropDownList2 option').map((index, obj) => {
return {
city: city,
area: $(obj).val(),
__VIEWSTATE: __VIEWSTATE,
__VIEWSTATE: __VIEWSTATE,
__EVENTVALIDATION: __EVENTVALIDATION,
Button1: Button1,
}
}).get()
callback(null, areas)
})
}, (err, results) => {
// 將 results 扁平化,再丟給 callback
callback([].concat.apply([], results));
})
}
爬這個出來,是要猜再來那一間會開頭獎嗎?
若真的能猜,那我希望能猜中頭獎號碼= =
我只是想學爬蟲而已,
結果被KO了...