iT邦幫忙

2018 iT 邦幫忙鐵人賽
DAY 30
0
Modern Web

Node JS-Back end見聞錄系列 第 30

Node.js-Backend見聞錄(29):進階實作-關於爬蟲-以7–11店家資料為例

Node.js-Backend見聞錄(29):進階實作-關於爬蟲-以7–11店家資料為例

前言

Howard之前有在工作室分享爬蟲的議題,並舉出這個有趣的例子。當時,就有說處理的流程及大概的做法,筆者就試著將這例子給實作出來,並分享給大家。

爬蟲目標

這次的目標是藉由7–11電子地圖網站來抓取的資料則是各縣市地區的店家資料。如:

門市店號:141659 
門市店名:石光地址:新竹縣關西鎮石光里石岡子252號1樓
電話:(03)5868624 
接收傳真服務(付費):(03)5868654

觀念流程

是前端render還是後端render?

在處理這次的爬蟲前,我們要先釐清我們所要抓取的資料,它是夾雜在前端render還是後端render

  • 前端render:將後端資料庫的資料,經處理(或未處理)後,後端開發者將資料寫入API中。前端開發者在呼叫後端API來將參數帶入HTML文件裡,並呈現給client端看。
  • 後端render:將後端資料庫的資料,經處理(或未處理)後,直接將參數帶進HTML文件中,並直接將其文件內容呈現在client端。

那要如何進行辨識呢?很簡單,我們可以藉由瀏覽器所提供的開發者工具來進行辨識。以Chrome為例,先開啟開發者工具。開啟方式為:windows鍵盤的朋友(ctrl + shift + i)、Mac鍵盤的朋友(commend + option + i)。

開啟之後,點選Network,在從 XHR(XMLHttpRequest)、Doc或JS中去尋找些線索。

在這次的目標中,我們先進入到目標資料的頁面。如下圖:

在目標頁面開啟開發者工具,待搜索後,可以看到我們所要觀察的目標就在XHR的類別底下,有個Name為EMapSDK.aspx的物件,點進去後在透過Preview來觀看可以發現我們要的資料就在該物件底下。如下圖所示:

註記:Preview會根據該物件所回傳的資料類型(JSON、文本、圖片…等。)方式來呈現其相對應的預覽。

這時,我們就能發現它所回傳來的資料算是XML的格式。以此就能推斷這個目標格式就夾雜在「前端render」的API裡。

剖析HTTP的訊息

接著,我們就在回到開發者工具,在從Headers裡面來看整個HTTP的訊息。能發現目標資源的URL為http://emap.pcsc.com.tw/EMapSDK.aspx,其HTTP method為POST。

註記:Headers會顯示該物件的整個HTTP headers的訊息。

這意味著它是有夾帶些參數在request中,我們就能將目光專注在底下的Form Data。當中較為特別的是:

commandid:SearchStore
city:台北市
town:松山區

再來,我們就使用Postman來測試,並輸入以下條件。

  • HTTP method: POST

  • HTTP url : http://emap.pcsc.com.tw/EMapSDK.aspx

  • Body中選擇x-www-form-urlencoded

  • headers

    • Content-Type: application/x-www-form-urlencoded
  • body

    • commandid: SearchStore
    • city: 台北市
    • town: 松山區

輸入完成後,按下Send鍵。就能看到我們所要的店家資料呈現在底下的XML格式的資料中。

釐清如何取得目標資料

回歸原點來思考,我們要取得的店家資料是全台灣7–11的店家資料,並從剛剛的步驟發現,當我們輸入:

commandid:SearchStore
city:台北市
town:松山區

就能取得台北市松山區所有的7–11資料。同理,假設我們若將city輸入台北市、town輸入信義區,也是能得到台北市信義區所有的7–11的資料。

於是我們第一個動作就是要先去擷取各縣市的「區域」資料。

首先,先回到點選區域的頁面,並用開發者工具去觀看其API的運行。

結果發現有出現我們所想要的區域資料。接著我們在看看它的Headers情況為何:

可以看到Request URL也是為「http://emap.pcsc.com.tw/EMapSDK.aspx」
,其HTTP method為POST。於是,直接將目光轉移到底下的Form Data,可發現較為特別的參數為:

commandid: GetTown
cityid: 01

在將參數丟到Postman來進行測試,並輸入以下條件:

  • HTTP method: POST

  • HTTP url : http://emap.pcsc.com.tw/EMapSDK.aspx

  • Body中選擇x-www-form-urlencoded

  • headers

    • Content-Type: application/x-www-form-urlencoded
  • body

    • commandid: GetTown
    • cityid: 01

待輸入完成後,按下Send鍵。就能發現,其結果就是我們所想要看到的區域資料:

問題來了,由於cityid的值為數字。從結果來看,目前只知道01是「台北」。但02之後呢?每個cityid所對應到的地方是哪邊?

所以,我們必須去觀察這部份它所定義的規則為何。

可以透過source裡面去查詢有沒有相關的資源。這時,可以發現有個檔案叫做「emap.aspx」,底下也剛好有一個cityid的參數。

該參數是透過一個叫「AREACODE」所引入,在更進一步的追尋。發現它是源自於「lib/areacode.js」的檔案,於是我們就點開lib的資料夾,並選擇該檔案。

之後,我們就能看到各縣市的「區域」資料,其編號所對應到的地區就呈現在上面。

由於資料量並不多,筆者決定將這些資料用手動的方式將其city跟cityid轉成JSON格式的檔案。(可參考:JSON檔案

最後,歸納一下我們要進行crawler的流程:

  • 提取各縣市的區域資料。
  • 藉由區域資料,將店家資料提出。

開始實作

資料結構

.
├── app.js
├── bin
│   └── www
├── controllers
│   └── get_controller.js
├── data
│   └── store_id.json
├── models
│   └── getdata_model.js
├── package.json
├── public
│   ├── images
│   ├── javascripts
│   └── stylesheets
│       └── style.css
├── routes
│   ├── index.js
│   └── users.js
├── views
    ├── error.ejs
    ├── index.ejs
    └── success.ejs
├── .env
└── .gitignore

提取各縣市的區域資料

我們先將目標定為抓取台北市全部的區域試試看。至models資料夾的``getdata_model.js

const request = require('request');
const cheerio = require('cheerio');
const areaData = require('../data/store_id.json');
const getTownName = (url, cityID) => {
    return new Promise((resolve, reject) => {
      let townNameArray = [];
      request.post({
          url: url,
          form: {
              commandid: "GetTown",
              cityid: cityID
          }
      }, function (err, res, body) {
          const $ = cheerio.load(body);
          // 區域名稱
          $('TownName').each(function (index, element) {
              townNameArray.push($(this).text());
          })
          resolve(townNameArray);
       })
     })
}
getTownName("http://emap.pcsc.com.tw/EMapSDK.aspx", "01")
    .then((result) => {
       console.log(result); // 台北市全區資料
    });

只要打印出result就是台北市全區的資料。

接著,就可以開始嘗試將各縣市的區域資料提取出來。這時,我們就會使用到剛剛所撰寫好的JSON檔案

let areaDatas = [];
for (let i = 0; i < areaData.result.length; i += 1) {
        const areaID = areaData.result[i].areaID;
        const areaName = areaData.result[i].area;
        const townName = await loadData.getTownName(url, areaID);
        // 有些townName可能為0,因此進行篩選
        if (townName.length !== 0) {
         areaDatas.push({ cityName: areaName, townName: townName });
        }
}

我們將areaDatas的結果寫成:

[ { cityName: '台北市',
    townName:
     [ '松山區',
       '信義區',
       '大安區',
       '中山區',
       '中正區',
       '大同區',
       '萬華區',
       '文山區',
       '南港區',
       '內湖區',
       '士林區',
       '北投區' ] },
...
]

不難發現,這就是7–11提取各區店家所需的參數,只是筆者依據縣市來劃分縣市內各區的資料。

commandid:SearchStore
city:台北市
town:松山區

藉由區域資料,將店家資料提出

我們先將目標定為抓取台北市松山區全部的7–11試試看。

const request = require('request');
const cheerio = require('cheerio');
const getStoreData = (url, cityName, townName) => {
    let storeArray = [];
    let storeID = [];
    let storeName = [];
    let storeTele = [];
    let storeFax = [];
    let storeAddress = [];
    let storeValues = "";
    return new Promise((resolve, reject) => {
        request.post({
            url: url,
            form: {
                commandid: "SearchStore",
                city: cityName,
                town: townName
            }
        }, function (err, res, body) {
            const $ = cheerio.load(body);
            // 店家ID
            $('POIID').each(function (index, element) {
            //去空白
                storeID.push($(this).text().replace(/\s/g, ''));
                storeValues = index; // 該區所有店家的個數
            })
            // 店家名稱
            $('POIName').each(function (index, element) {
                storeName.push($(this).text());
            })
            // 店家電話
            $('Telno').each(function (index, element) {
                //去空白
                storeTele.push($(this).text().replace(/\s/g, ''));
            })
            // 店家傳真
            $('FaxNo').each(function (index, element) {
                //去空白
                storeFax.push($(this).text().replace(/\s/g, '')); 
            })
            // 店家地址
            $('Address').each(function (index, element) {
                storeAddress.push($(this).text());
            })
            for (let i = 0; i <= storeValues; i += 1) {
                storeArray.push({
                    storeCity: cityName,
                    storeTown: townName,
                    storeID: storeID[i],
                    storeName: storeName[i],
                    storeTele: storeTele[i],
                    storeFax: storeFax[i],
                    storeAddress: storeAddress[i]
                });
            }
            resolve(storeArray);
        })
    })
}
getStoreData("http://emap.pcsc.com.tw/EMapSDK.aspx",
             "台北市",
             "松山區")
.then((result) => {
  console.log(result); // 台北市松山區全部7-11的資料
};

只要打印出result就是台北市松山區全部7–11的資料了。

但我們的目標是台灣全區的7–11的資料。這時就要使用到上個步驟,最後處理出來areaDatas的結果:

// 各縣市
for (let i = 0; i < areaDatas.length; i += 1) {
     // 縣市內各區域
     for (let j = 0; j < areaDatas[i].townName.length; j += 1) { 
          let city = areaDatas[i].cityName;
          let town = areaDatas[i].townName[j];
          const storeDatas = await loadData.getStoreData(url, 
          city, town);
          if (storeDatas[0].storeID !== undefined) {
              totalStoreDatas.push({ 
                 city: city, 
                 town: town, 
                 storeDatas: storeDatas 
              });
          }
      }
}

最後,我們的目標結果就存放在totalStoreDatas中。

{
    "result": [
        {
            "city": "台北市",
            "town": "松山區",
            "storeDatas": [
                {
                    "storeCity": "台北市",
                    "storeTown": "松山區",
                    "storeID": "170945",
                    "storeName": "上弘",
                    "storeTele": "(02)25472928",
                    "storeFax": "(02)25459434",
                    "storeAddress": "台北市松山區敦化北路168號B2"
                },
....

這部份完整的處理流程可從這裡看。

小結

筆者在這個例子中,除了使用async/await來控制function的執行順序外,也嘗試使用了Promise all來操控同時發送多個request(這部份可在上述完整的處理流程檔案中看到)。其結果待測試後是有比起同時發送一個request結果還要來的快。

但也不太建議為了速度,同時間發送太多的request給server。這是因為:

  • 禮貌問題(別給別人的server造成太多負荷)。
  • 有可能被列入黑名單。

要如何在抓取資料的時間及發送request的數量上取得一個平衡,這點筆者也還在學習怎麼拿捏中。

最後,在提及一下寫爬蟲的四個步驟:

  • 確認是前端render或後端render?
  • 剖析HTTP的訊息
  • 釐清如何取得目標資料
  • 開始實作

寫crawler對筆者說非常的有趣,與筆者就讀研究所時的研究主題有關,屬於資料分析的一環。當然,要做資料分析前就得要有資料才行(笑)。

關於完整的code可以參考:

完整code


上一篇
Node.js-Backend見聞錄(28):進階實作-關於金流
下一篇
Node.js-Backend見聞錄(30):關於結束
系列文
Node JS-Back end見聞錄31

尚未有邦友留言

立即登入留言