一个用于爬取YorkBBS住房信息的NodeJS动态网页爬虫

1.目的

这个project用于爬取YorkBBS上完整的住房信息,并将其生成一个html,放入apache2的服务器下,使之可以在任何地方查看筛选好的信息。

2.网页解析以及数据接口获取

YorkBBS采用的是动态网页加载,如果采用http解析器直接采集DOM结构的话,只能得到一个参杂了JS代码的HTML结构。这里有两个思路:

  1. 使用诸如PhantomnJS之类的库,等待网页加载完成才进行DOM读取

  2. 使用Chrome DevTool,通过Network功能查找和服务器端的通讯从而判断出数据的API接口,并通过伪造Header获取相应的信息

    这里我们采用第二个思路,首先打开Chrome DevTool 的Network标签,刷新目标网页,观察和服务器目标中的通讯。我们很快发现了这个Header:

    1
    2
    3
    4
    5
    Request URL: http://house.yorkbbs.ca/house.search/api/Rent/Filter
    Request Method: POST
    Status Code: 200 OK
    Remote Address: 104.25.35.111:80
    Referrer Policy: no-referrer-when-downgrade

​ 通过Response我们能够确认这是我们的目标信息:

1
{"success":true,"result":{"rowCount":4289,"pageIndex":1,"pageCount":215,"list":[{"id":4000065874,"title":"DT湖边高层condo出租一单间","image":"https://i.ybbs.ca/media/large/37198eafc3d9d3ecda84ea4b9ad26b9c.jpeg","province":"Ontario","city":"Toronto","region":"Toronto","intersection1":"spadina","intersection2":"fortyork","price":1100.0,"liveInDate":"2018-09-01T00:00:00","isCanLiveInNow":false,"userType":0,"userId":840509,"userName":"larrrrry0721","refreshTime":"4分钟前更新","linkMan":"larrrrry0721","details":"禁养宠物 | 禁室内抽烟 | 少煮食 | 有家具 | 游泳池 | 健身房","detailList":{"1001":["Condo"],"1002":["单间"],"1003":["3房"],"1004":["1厅"],"1005":["无车位"],"1008":["3个月"],"1010":["禁养宠物","禁室内抽烟","少煮食","有家具","游泳池","健身房"]},"enName":"","priceNegotiable":false,"orderNum":1000}},"error":null}

那么下一步是如何获取这些信息。

要获取相应的信息,我们只需要和正常对API接口进行请求一样就可以了,这里唯一的区别是我们并不知道该使用什么参数作为请求。再次检查Header,可以发现Header有个 Request Payload的属性,那么通过查看里面的内容。

1
2
3
{
"options": []
}

只要把这个payload加入对网址的请求当中,就可以获得如上图的信息了。

那么如何加入更多的参数选项?首先要试探新的参数,随意改变一个参数,再次对网页请求response。

1
2
3
4
5
6
7
8
9
{
"options": [],
"regionId": null,
"orderBy": 0,
"pageIndex": 2,
"pageSize": 20,
"isAsc": false,
"keyword": ""
}

此时可以看到参数变化了,这意味着我们可以随意改变这些参数进行符合我们期望的请求。

我们的目的是一次性抓取YorkBBS上所有的住房信息,那么只要简单的将pageIndex改为1并且把pageSize改为超出目前所有住房信息的数量,就能轻松一次性抓取论坛上所有的住房信息。

首先载入NodeJS上的http解析器superagent。const request = require('superagent')

使用superagent的promise结构进行http请求

1
2
3
4
5
request
.post(url)
.type('application/json')
.send({"options": [],"regionId": null,"orderBy": 0,"pageIndex": 1,"pageSize": 50,"isAsc": false,"keyword": "北约克"}) // request payload
.then((res)=>res.toJSON())

这里.type决定了请求的类型,而.send则决定了payload。在取得response后将response转为Json结构以供下一步使用。

3.读取并解析和过滤信息

1.读取信息

因为superagent采用了promise的结构,所以上一步resolve的结果直接传入下一步。

在这里我们观察json的结构,可以发现有

1
text:"{"success":true,"result":{"rowCount":623,"pageIndex":1,"pageCount":623,"list":[{"id":4000063147,"title":"多伦多北约克屋美价廉独立屋长短期出租","image":"https://i.ybbs.ca/media/large/2910b4198098b5aefa560a6340f4703d.jpeg","province":"Ontario","city":"Toronto","region":"Toronto","intersection1":"bayview","intersection2":"finch","price":1190.0,"liveInDate":null,"isCanLiveInNow":true,"userType":0,"userId":112843,"userName":"cmidc","refreshTime":"44分钟前更新","linkMan":"cmidc","details":"单身女性 | 单身男性 | 夫妻情侣 | 三口之家","detailList":{"1001":["独立屋"],"1002":["套间"],"1003":["1房"],"1004":["1厅"],"1005":["1车位"],"1006":["独卫"],"1008":["不限制"],"1009":["单身女性","单身男性","夫妻情侣","三口之家"],"1010":["禁养宠物","禁室内抽烟","有家具","可短租","包水电"]},"enName":"","priceNegotiable":false,"orderNum":1000}]},"error":null}"

如果直接调用上一步的res.text,则实际得到的是一个String 而不是json object,所以我们还要进行进一步转换。将res赋值为res.text,并使用Json.parse(res)将其转为json object。

2.解析信息

此时,我们可以发现这个json object中包含数个我们感兴趣的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
city: "Toronto"
detailList: Object {1001: Array(1), 1002: Array(1), 1003: Array(1), …}
details: "单身女性 | 单身男性 | 夫妻情侣 | 三口之家"
enName: ""
id: 4000063147
image: "https://i.ybbs.ca/media/large/2910b4198098b5aefa560a6340f4703d.jpeg"
intersection1: "bayview"
intersection2: "finch"
isCanLiveInNow: true
linkMan: "cmidc"
liveInDate: null
orderNum: 1000
price: 1190
priceNegotiable: false
province: "Ontario"
refreshTime: "44分钟前更新"
region: "Toronto"
title: "多伦多北约克屋美价廉独立屋长短期出租"
userId: 112843
userName: "cmidc"
userType: 0

比如detailList,title,region,city。下一步我们将获取这些信息并将其过滤。

3.过滤信息

我们采取正则表达式的方法来判断object中是否有包含我们感兴趣的信息。

这里先建立过滤规则:

1
2
3
4
let pattern1 = new RegExp("整层","i");   //1002 整层
let pattern2 = new RegExp("[23]房","i"); //1003 3/2 房
let pattern3 = new RegExp("1厅","i"); //1004 1厅
let pattern4 = new RegExp("无车位","i"); //1005 1车位

这里’”i”意味着无视大小写,而[23]房意味着选取2或者3房的条件,当我们面临多个过滤条件的时候可以采用|隔开条件。

通过pattern.test(obj)我们可以得到一个true或者falseboolean

但是我们可以发现只有refreshTime这一行是比较特殊的数据,当时间小于1小时的时候,它会返回xx分钟前更新,而当时间小于一天的时候它会返回xx小时前更新,当时间大于1天小于7天的时候它会返回xx天前更新,而当时间大于7天时,它会返回2018年x月x日 更新

这使得数据解析变得困难,所幸我们的筛选条件是只需要30天以内的信息,那么我们关注的是最后一种数据格式。这里我们使用JS自带的Date()方法来进行数据比较。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function dateMatch(date){
let pattern = new RegExp("分|天|小时");
if(pattern.test(date)){
return true; //7天内的时间直接返回true
}else{
let end = date.slice(0,-4);
end=end.replace(/[\u4e00-\u9fa5]/g,'-');
let olddate = Date.parse(end); //getMillis from that day
let today = Date.parse(Date()); //getMillis from today
let days = Math.floor((today-olddate)/86400000);

if(days<=30){ //只取得30天内的信息
return true;
}else{
return false;
}

}

这里我们首先用正则方法筛选掉分,小时和天的数据,只留下日期格式。首先将多余的日 更新字符去掉,然后使用replace方法替换中文的部分成-。这样数据就是标准的日期格式了。然后使用JS自带的Date.parse方法就能够得到一个从1971年1月1日到该天数的毫秒数。同理,使用Date()方法取得现在的时间,并将其转换为毫秒数,let days = Math.floor((today-olddate)/86400000);这里86400000是一天的毫秒数。这样我们就得到了天数,将其与条件比较,不符合的抛弃。

4.生成HTML

此时符合条件的数据已经筛选完毕,将其生成HTML。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×