iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 8
2

定義目標

有圖有真相
這是聊天室裡小魚提出來的主題,其實這類的需求老實說挺多的,在日常生活中,常常需要去整理一些網路上的資料,他不難但很煩,而且這些沒營養的動作常常會耗費掉一、兩個小時,甚至是每天都必須做一次,那麼我們就藉著這個主題,來寫一篇爬資料的流程。

ps. 這個主題其實不會很適合新手嘗試,因為老實說有很多是經驗累積出來的感知XDDD


實際探訪

台彩的銷售地點網頁位置在 http://www.taiwanlottery.com.tw/Lotto/se/salelocation.aspx ,第一眼看起來挺單純的,看起來動作並不複雜,需要做的事情為:

  1. 選擇縣市
  2. 選擇鄉鎮市區
  3. 按查詢

選擇縣市

選擇縣市

但發現選擇縣市以後,其鄉鎮市區的選項會 post request 轉跳原本的頁面,然後鄉鎮市區的資訊會在這個 request 的 response。同時看到這個 request 送出了一些有趣的資訊,其中 DropDownList1、DropDownList2 我們合理猜測是那縣市和鄉鎮市區的下拉選單。

反查一下 DropDownList1

反查一下 DropDownList1 確定是縣市沒錯,而且可以看到每個縣市所代表的代碼,而 DropDownList2 在選擇縣市時都保持為 0。

__EVENTVALIDATION

接著我們要先提一下另外幾個 __ 開頭的參數,有經驗的人大概一眼就能看出這是 asp.net 的一種驗證機制,簡單來說,他會確保你所送出的 request 是從對的流程送過來 ,因為它裡頭包含了流程和頁面內容的狀態資訊。上一頁填入的內容不同,下一頁的驗證值就不同。

舉個例子,我們若用 get 重新請求網址,這個我們稱為第一頁,接下來我們選擇台北市,這個我們稱為第二頁,你將會發現,在這兩個頁面的 __EVENTVALIDATION 是不同的。總歸來說,在這個頁面每個縣市選擇後有他自己的 __EVENTVALIDATION,當查詢按下去的時候,會根據這 22 個縣市要各別有 22 個不同的 __EVENTVALIDATION

接著我們選擇鄉鎮市區,在這個步驟並沒有發出 request,還好沒有,若有的話,我們就必須把這些全部的鄉鎮市區的 __EVENTVALIDATION 都記起來,那就會多了一個步驟了。接著最後按查詢,然後 post request 得到的 response 就帶有經銷商的資訊了。

查詢 request

來觀察一下這個 post request,不只多了一個 Button1 值為查詢,另外 DropDownList2 也帶上了所選擇的地區。更眼尖的發現,地區的部分後面似乎跟著空白...

求證 encode

為了求證一下,我們去看看 form data 的 source,確認真的是空白,而空白也會被一併 encode,同時在網路上找了一些文章,在 application/x-www-form-urlencoded 的時候空白會被 encode 為 + (不過這個地方大概也就是剛進入這領域的玩家會遇到的雷)。


分解研究

取得鄉鎮市區

取得鄉鎮市區

確認目標和探訪後,我們先來取得所有縣市的鄉鎮市區,這個動作看起來可以很順利的取得。

模擬查詢

request 失敗

接下來要模擬按下查詢的 request,在這邊我們發現複製 chrome 的 form data 並沒有辦法很順利的取得結果,這是因為剛剛上面提到鄉鎮市區後面有兩個空白,但 postman 預設送出的 post data 會將空白 encode 成 %20

比對 encode

比對 encode

關於這點我們可以用 postman 所產生的 curl 來驗證,與 chrome 所送出的 data source 比對一下,發現確實變成 %20,也就是說,我們等等實作的時候,要將 encode 的 %20 取代成 +

驗證 raw data

驗證 raw data

那我們在這個階段怎麼用 postman 驗證呢?這時候可以使用 raw data 來送,記得 header 的 Content-Type 要設成 application/x-www-form-urlencoded,確認可以順利拿到經銷商資料,那麼我想整個 case 就沒問題了。


實作程式碼

getAreas function

這邊我們先分成兩個部分,首先我們要先抓到所有縣市和鄉鎮市區,同時要記錄各別縣市的 __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));
  })
}

getStores function

接下來就來根據每個鄉鎮市區來取得各自的地點,但在這邊我們會選用 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));
  })
}

上一篇
IT 鐵人排程發文
下一篇
PTT Code_job 訂閱通知
系列文
爬蟲始終來自於墮性34

2 則留言

0
Wolke
iT邦新手 3 級 ‧ 2017-12-11 00:52:51

爬這個出來,是要猜再來那一間會開頭獎嗎?

Howard iT邦新手 5 級‧ 2017-12-11 01:07:01 檢舉

若真的能猜,那我希望能猜中頭獎號碼= =

小魚 iT邦大師 1 級‧ 2017-12-11 12:03:56 檢舉

我只是想學爬蟲而已,
結果被KO了...
/images/emoticon/emoticon20.gif

0
小魚
iT邦大師 1 級 ‧ 2017-12-11 12:03:12

感謝大大的分享,
給你一個Like,
我再研究看看~

Howard iT邦新手 5 級‧ 2017-12-11 14:18:56 檢舉

沒問題,教學相長

我要留言

立即登入留言