爬虫爬取摩拜单车位置生成热力图

爬虫爬取摩拜单车位置生成热力图

昨天在知乎看到一篇名为《如何爬取摩拜单车位置信息?》的文章,作者表明摩拜单车的位置信息爬取是可行,让我突然想起好久,我曾想爬取摩拜单车位置信息做一个热力图,来看一下北京的小红车到底有多少,是一个怎样的分布,于是今天趁着周六休息,早上一起床就开始准备实施想法。

首先要爬取摩拜单车的位置信息,要知道摩拜单车客户端如何与服务器请求的,于是用手机连上电脑代理,利用charles进行抓包 (可参照 @xlzdxlzd:如何爬取摩拜单车位置信息? 回答),因为摩拜与服务器交互是使用的https,所以记得要配置ssl协议,使用手机打开微信中的摩拜单车小程序之后,我mac上的charles便抓到了摩拜向服务器请求位置的报文

打开一个,可以看到请求(屏蔽了一些隐私信息,怕摩拜找我,哈哈)

查看对应的参数列表

注意红色框选的两个字段 , longitude latitude ,分别是经度和纬度的意思,到这里我们基本就可以明白摩拜客户端与服务器交互的过程了:使用的是一个POST请求,POST参数了传递了 经纬度信息,请求头了传递了微信code,citycode等信息,这个我们是可以通过爬虫模拟的。

再看摩拜服务器响应的信息

{
    "code":0,
    "message":"",
    "biketype":0,
    "object":[
        {
            "distId":"0106612178",
            "distX":116.1104991356161,
            "distY":39.714611852869695,
            "distNum":1,
            "distance":"107",
            "bikeIds":"0106612178#",
            "biketype":1,
            "type":0,
            "boundary":null
        },
        Object{...}
    ]
}

其中包含了常规的响应码,还有一个object数组,里面包含了若干字段,其实到这里我们已经可以看出来 distX 是摩拜单车的经度,distY是摩拜单车的纬度 ,bikeIds 是摩拜单车的Id,利用这样一个json数据摩拜就可以在客户端绘制出单车的地理位置信息了,如下


有了上面的一些准备,我们就可以按照下面的思路去进行了

  1. 找出我们要请求位置的经纬度范围
  2. 编写爬虫模拟摩拜客户端请求,获得摩拜单车地理位置数据
  3. 处理地理位置数据,去重拿到每个单车的经纬度信息
  4. 利用经纬度信息进行热力图的绘制


首先,我想要绘制北京市内的所有摩拜单车的分布数据,可以通过百度的坐标拾取工具,获取到北京市四个角的经纬度,大概是经度 东经 116.109337~116.622162 , 北纬39.714972 ~ 40.144322 ,相当于一块正方形的区域,西北以昌平区为边界,东南到通州区和大兴区

根据经纬度的一个计算工具,可以得到如下的数据

经纬度的1秒,大概是0.0309km即是30m,所以只要我们在我们需要的正方形区域内,按照一定的递增获取 我的当前位置 , 即可获取到不同地理位置周围的摩拜单车。爬取逻辑已经有了。

下面是编写代码的过程,首先我们需要一个HTTPS的请求 Java 代码工具类

package http;

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

/**
 */
public class HTTPSClient extends DefaultHttpClient {

    public HTTPSClient() throws Exception {
        super();
        SSLContext ctx = SSLContext.getInstance("TLS");
        X509TrustManager tm = new X509TrustManager() {
            public void checkClientTrusted(java.security.cert.X509Certificate[] x509Certificates, String s) throws java.security.cert.CertificateException {

            }

            public void checkServerTrusted(java.security.cert.X509Certificate[] x509Certificates, String s) throws java.security.cert.CertificateException {

            }

            public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                return null;
            }
        };
        ctx.init(null, new TrustManager[]{tm}, null);
        SSLSocketFactory ssf = new SSLSocketFactory(ctx, SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
        ClientConnectionManager ccm = this.getConnectionManager();
        SchemeRegistry sr = ccm.getSchemeRegistry();
        sr.register(new Scheme("https", 443, ssf));
    }

    public String doPost(String url, Map<String, String> params, Map<String, String> headers) {
        HttpClient httpClient = null;
        HttpPost httpPost = null;
        String result = null;
        try {
            httpClient = new HTTPSClient();
            httpPost = new HttpPost(url);
            //设置参数
            List<NameValuePair> list = new ArrayList<NameValuePair>();
            Iterator iterator = params.entrySet().iterator();

            while (iterator.hasNext()) {
                Map.Entry<String, String> elem = (Map.Entry<String, String>) iterator.next();
                list.add(new BasicNameValuePair(elem.getKey(), elem.getValue()));
            }
            if (list.size() > 0) {
                UrlEncodedFormEntity entity = new UrlEncodedFormEntity(list, "utf8");
                httpPost.setEntity(entity);
            }

            Iterator<Map.Entry<String, String>> headerIterator = headers.entrySet().iterator();
            while (headerIterator.hasNext()) {
                Map.Entry<String, String> entry = headerIterator.next();
                httpPost.setHeader(entry.getKey(), entry.getValue());
            }


            HttpResponse response = httpClient.execute(httpPost);
            if (response != null) {
                HttpEntity resEntity = response.getEntity();
                if (resEntity != null) {
                    result = EntityUtils.toString(resEntity, "utf8");
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return result;
    }


}

使用的是maven 添加httpclient依赖dependency如下

  <dependency>
        <groupId>org.apache.httpcomponents</groupId>
        <artifactId>httpclient</artifactId>
        <version>4.5.2</version>
</dependency>

该类可以接受请求的url和参数,header进行post请求

具体爬取的逻辑类如下

package http;

import java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.util.HashMap;
import java.util.Map;

/**
 */
public class Test {

    public static void main(String[] args) throws Exception {
        // 北京纬度 39.714972 ~ 40.144322 , 循环递增 0.000277 ,即是 30m内
        // 北京经度 116.109337 ~ 116.622162 ,  循环递增 0.000277

        File file = new File("/Users/yourdir/mobike/mobike_data.txt");
        FileOutputStream fos = new FileOutputStream(file);
        BufferedWriter br = new BufferedWriter(new OutputStreamWriter(fos));

        float startLongitude = 116.109337F;    // 起始经度
        float endLongitude = 116.622162F;      //结束经度
        float startLaitude = 39.714972F;       // 起始纬度
        float endLaitude = 40.144322F;         //结束纬度

        float step =  0.000277F*20 ;   // 每隔600m爬取一次
        for(float i=startLaitude;i<endLaitude;i+=step){
            for(float j=startLongitude;j<endLongitude;j+=step){
                Thread.sleep(10);      //间隔10ms爬取,防止被封ip
                System.out.printf("%f\t%f\n",j,i);    // 输出正在爬取的经纬度
                String ret =getSingleData(String.valueOf(j),String.valueOf(i));
                br.write(ret);
                br.write("\n");
                System.out.println(ret);   //输出结果
            }
        }

    }



    /**
     * 根据单个经纬度爬取该经纬度周围的摩拜单车类
     * */
    public static String getSingleData(String longitude, String latitude) {
        String ret = null;
        try {
            HTTPSClient client = new HTTPSClient();

            Map<String, String> params = new HashMap<String, String>();
            // 添加必须参数
            params.put("verticalAccuracy", "12");
            params.put("speed", "0.18000000715255737");
            params.put("longitude", longitude);
            params.put("horizontalAccuracy", "10");
            params.put("errMsg", "getLocation%3Aok");
            params.put("latitude", latitude);
            params.put("accuracy", "10");
            params.put("altitude", "48.6485595703125");
            params.put("citycode", "010");
            params.put("wxcode", "002111111"); // 可以换成真实的wxcode

            // 添加头
            Map<String, String> headers = new HashMap<String, String>();
            headers.put("Content-Type", " application/x-www-form-urlencoded");
            headers.put("mainSource", " 4003");
            headers.put("Accept", " */*");
            headers.put("eption", " 4f906");
            headers.put("opensrc", " list");
            headers.put("wxcode", " xxx");
            headers.put("platform", " 3");
            headers.put("Accept-Language", " zh-cn");
            headers.put("citycode", " 010");
            headers.put("lang", " zh");
            headers.put("User-Agent", " Mozilla/5.0 (iPhone; CPU iPhone OS 11_1_1 like Mac OS X) AppleWebKit/604.3.5 (KHTML, like Gecko) Mobile/15B150 MicroMessenger/6.5.22 Net");
            headers.put("Referer", " https");
            headers.put("Accept-Encoding", " br, gzip, deflate");
            headers.put("Connection", " keep-alive");
            headers.put("Cache-Control", " no-cache");

            ret = client.doPost("https://mwx.mobike.com/mobike-api/rent/nearbyBikesInfo.do", params, headers);
        } catch (Exception e) {
            return null;
        }
        return ret;
    }
}

爬取之后我们得到了很多原始数据,我们还需要写一个脚本,将所有单车根据单车id去重,拿到经纬度,该脚本以摩拜单车原始数据为输入,输出所有单车经纬度(去重后),脚本如下

import json
import sys

fname = sys.argv[1]
bikeid_position = {}
f = open(fname,'r')
for line in f:
    line = line.strip()
    try:
        obj = json.loads(line)
        bikes = obj.get('object')
        for bike in bikes:
            bikeId = bike.get('bikeIds')
            distX = bike.get('distX')
            distY = bike.get('distY')
            bikeid_position[bikeId]='%s|%s'%(distX,distY)
    except Exception,e:
        continue

for k,v in bikeid_position.items():
    distX = v.split("|")[0]
    distY = v.split("|")[1]
    print distY,distX

于是得到经纬度信息数据

40.018927756	116.314215745
39.8869488969	116.202560618
39.9359157103	116.430422345
40.0105228388	116.61492848
40.0311916677	116.441935836
39.9465908892	116.484969522
40.0027101774	116.424764356
39.9794620839	116.589019515
39.9317298939	116.170098601
39.9468273373	116.257616884
39.987477564	116.525309287
39.8503353543	116.220986859
39.9343877034	116.581122703
40.0800001574	116.460128544
39.7889974136	116.492764043
39.8036498633	116.263799868
40.08192535	116.511052065
40.0764347454	116.449609236
39.7849303529	116.214939968
......

放入excel

找一个可以制作热力图的数据展示工具,如tableau,我这里使用的是BDP,制作出的热力图,缩小看是这样的

可以看到我们爬取的数据的确是一个正方形区域,放大后可以看到,香山公园那一块区域是没有车的,说明数据应该没有大的问题

继续调整热力图半径

可以看到,今天南边的摩拜单车比较多,不知道是不是因为今天周六的原因,今天摩拜单车分布得有点均匀(=@__@=) 。

本次总共爬取摩拜单车信息(去重后) 74267 条,难道这就是北京摩拜单车的数量吗?这点倒不能确定,不过我们的爬取数据生成热力图的计划也是实施成功了。


注:如果文中有关于地理知识的错误理解还希望大家指正,毕竟我是今天早上才开始学习经纬度的知识的,北纬南纬怎么来都很迷糊(初中偏科有点严重。。。)




=========更新

经评论区各位的一些建议后重新设定范围爬取,生成的图像没有之前的南北分布不均匀的情况了,单车数据增至 104236 条

编辑于 2017-12-16