Node.js大眾點評爬蟲


大眾點評上有很多美食餐館的信息,正好可以拿來練練手Node.js。

1. API分析

大眾點評開放了查詢商家信息的API,這里給出了城市與cityid之間的對應關系,鏈接http://m.api.dianping.com/searchshop.json?&regionid=0&start=0&categoryid=10&sortid=0&cityid=110以GET方式給出了餐館的信息(JSON格式)。首先解釋下GET參數的含義:

  • start為步進數,表示分步獲取信息的index,與nextStartIndex字段相對應;
  • cityid表示城市id,比如,合肥對應於110;
  • regionid表示區域id,每一個id代表含義在start=0時rangeNavs字段中有解釋;
  • categoryid表示搜索商家的分類id,比如,美食對應的id為10,具體每一個id的含義參見在start=0時categoryNavs字段;
  • sortid表示商家結果的排序方式,比如,0對應智能排序,2對應評價最好,具體每一個id的含義參見在start=0時sortNavs字段。

在GET返回的JSON串中list字段為商家列表,id表示商家的id,作為商家的唯一標識。在返回的JSON串中是沒有商家的口味、環境、服務的評分信息以及經緯度的;因而我們還需要爬取兩個商家頁面:http://m.dianping.com/shop/<id>http://m.dianping.com/shop/<id>/map

通過以上分析,確定爬取策略如下(與dianping_crawler的思路相類似):

  1. 逐步爬取searchshop API的取商家基本信息列表;
  2. 通過爬取的所有商家的id,異步並發爬取評分信息、經緯度;
  3. 最后將三份數據通過id做聚合,輸出成json文件。

2. 爬蟲實現

Node.js爬蟲代碼用到如下的第三方模塊:

  • superagent,輕量級http請求庫,模仿了瀏覽器登錄;
  • cheerio,采用jQuery語法解析HTML元素,跟Python的PyQuery相類似;
  • async,牛逼閃閃的異步流程控制庫,Node.js的必學庫。

導入依賴庫:

var util = require("util");
var superagent = require("superagent");
var cheerio = require("cheerio");
var async = require("async");
var fs = require('fs');

聲明全局變量,用於存放配置項及中間結果:

var cityOptions = {
  "cityId": 110, // 合肥
  // 全部商區, 蜀山區, 廬陽區, 包河區, 政務區, 瑤海區, 高新區, 經開區, 濱湖新區, 其他地區, 肥西縣
  "regionIds": [0, 356, 355, 357, 8840, 354, 8839, 8841, 8843, 358, -922],
  "categoryId": 10, // 美食
  "sortId": 2, // 人氣最高
  "threshHold": 5000 // 最多餐館數
};

var idVisited = {}; // used to distinct shop
var ratingDict = {}; // id -> ratings
var posDict = {}; // id -> pos

判斷一個id是否在前面出現過,若object沒有該id,則為undefined(注意不是null):

function isVisited(id) {
  if (idVisited[id] != undefined) {
    return true;
  } else {
    idVisited[id] = true;
    return false;
  }
}

采取回調函數的方式,實現順序逐步地遞歸調用爬蟲函數(代碼結構參考了這里):

function DianpingSpider(regionId, start, callback) {
  console.log('crawling region=', regionId, ', start =', start);
  var searchBase = 'http://m.api.dianping.com/searchshop.json?&regionid=%s&start=%s&categoryid=%s&sortid=%s&cityid=%s';
  var url = util.format(searchBase, regionId, start, cityOptions.categoryId, cityOptions.sortId, cityOptions.cityId);
  superagent.get(url)
      .end(function (err, res) {
        if (err) return console.err(err.stack);
        var restaurants = [];
        var data = JSON.parse(res.text);
        var shops = data['list'];
        shops.forEach(function (shop) {
          var restaurant = {};
          if (!isVisited(shop['id'])) {
            restaurant.id = shop['id'];
            restaurant.name = shop['name'];
            restaurant.branchName = shop['branchName'];
            var regex = /(.*?)(\d+)(.*)/g;
            if (shop['priceText'].match(regex)) {
              restaurant.price = parseInt(regex.exec(shop['priceText'])[2]);
            } else {
              restaurant.price = shop['priceText'];
            }
            restaurant.star = shop['shopPower'] / 10;
            restaurant.category = shop['categoryName'];
            restaurant.region = shop['regionName'];
            restaurants.push(restaurant);
          }
        });

        var nextStart = data['nextStartIndex'];
        if (nextStart > start && nextStart < cityOptions.threshHold) {
          DianpingSpider(regionId, nextStart, function (err, restaurants2) {
            if (err) return callback(err);
            callback(null, restaurants.concat(restaurants2))
          });
        } else {
          callback(null, restaurants);
        }
      });
}

在調用爬蟲函數時,采用async的mapLimit函數實現對並發的控制(代碼參考這里);采用async的until對並發的協同處理,保證三份數據結果的id一致性(不會因為並發完成時間不一致而丟數據):

DianpingSpider(0, 0, function (err, restaurants) {
  if (err) return console.err(err.stack);
  var concurrency = 0;
  var crawlMove = function (id, callback) {
    var delay = parseInt((Math.random() * 30000000) % 1000, 10);
    concurrency++;
    console.log('current concurrency:', concurrency, ', now crawling id=', id, ', costs(ms):', delay);
    parseShop(id);
    parseMap(id);
    setTimeout(function () {
      concurrency--;
      callback(null, id);
    }, delay);
  };

  async.mapLimit(restaurants, 5, function (restaurant, callback) {
    crawlMove(restaurant.id, callback)
  }, function (err, ids) {
    console.log('crawled ids:', ids);
    var resultArray = [];
    async.until(
        function () {
          return restaurants.length === Object.keys(ratingDict).length && restaurants.length === Object.keys(posDict).length
        },
        function (callback) {
          setTimeout(function () {
            callback(null)
          }, 1000)
        },
        function (err) {
          restaurants.forEach(function (restaurant) {
            var rating = ratingDict[restaurant.id];
            var pos = posDict[restaurant.id];
            var result = Object.assign(restaurant, rating, pos);
            resultArray.push(result);
          });
          writeAsJson(resultArray);
        }
    );
  });
});

其中,parseShop與parseMap分別為解析商家詳情頁、商家地圖頁:

function parseShop(id) {
  var shopBase = 'http://m.dianping.com/shop/%s';
  var shopUrl = util.format(shopBase, id);
  superagent.get(shopUrl)
      .end(function (err, res) {
        if (err) return console.err(err.stack);
        console.log('crawling shop:', shopUrl);
        var restaurant = {};
        var $ = cheerio.load(res.text);
        var desc = $("div.shopInfoPagelet > div.desc > span");
        restaurant.taste = desc.eq(0).text().split(":")[1];
        restaurant.surrounding = desc.eq(1).text().split(":")[1];
        restaurant.service = desc.eq(2).text().split(":")[1];
        ratingDict[id] = restaurant;
      });
}

function parseMap(id) {
  var mapBase = 'http://m.dianping.com/shop/%s/map';
  var mapUrl = util.format(mapBase, id);
  superagent.get(mapUrl)
      .end(function (err, res) {
        if (err) return console.err(err.stack);
        console.log('crawling map:', mapUrl);
        var restaurant = {};
        var $ = cheerio.load(res.text);
        var data = $("body > script").text();
        var latRegex = /(.*lat:)(\d+.\d+)(.*)/;
        var lngRegex = /(.*lng:)(\d+.\d+)(.*)/;
        if(data.match(latRegex) && data.match(lngRegex)) {
          restaurant.latitude = latRegex.exec(data)[2];
          restaurant.longitude = lngRegex.exec(data)[2];
        }else {
          restaurant.latitude = '';
          restaurant.longitude = '';
        }
        posDict[id] = restaurant;
      });
}

將array的每一個商家信息,逐行寫入到json文件中:

function writeAsJson(arr) {
  fs.writeFile(
      'data.json',
      arr.map(function (data) {
        return JSON.stringify(data);
      }).join('\n'),
      function (err) {
        if (err) return err.stack;
      })
}

說點感想:Node.js天生支持並發,但是對於習慣了順序編程的人,一開始會對Node.js不適應,比如,變量作用域是函數塊式的(與C、Java不一樣);for循環體({})內引用i的值實際上是循環結束之后的值,因而引起各種undefined的問題;嵌套函數時,內層函數的變量並不能及時傳導到外層(因為是異步)等等。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM