1.前言
最近要租房,我也没啥特别的要求,地铁站边、公司近即可。在贝壳租房上看了下,发现区域与地铁线是不能兼得的,不得已只能拿出自己的老本行分析下了。

2.技术栈
- C#:HtmlAgilityPack进行HTML解析
- PostgreSQL:数据存储,原生支持数组类型数据存储
- 百度地图API:地理编码
3.房源爬取
3.1.房源信息解析
打开贝壳租房,可以发现它们的页面并不是ajax请求前端渲染的,应该是在后端渲染之后,返回到前端显示的。这样子来说,对SEO比较友好,对于数据的爬取而言,也就比较方便。

贝壳租房的每一页有30个房源信息,每个房源DOM的class为 content__list--item 。HtmlAgilityPack是基于XPath解析HTML的,那么房源对于的XPath即为 //*[@class="content__list--item"] ,通过DocumentNode .SelectNodes函数,能够一次性获取到所有的房源数组。
再来观察每一个房源的信息,其主要的信息分布如下:

其中,Title是个a元素,有此房源的详细信息链接;附加信息那块,有的有,有的没有;有些房源的“大小/朝向/户型”后面还会跟个楼层信息;对于大小这块,有些房源写的是整个房子的大小,有的是单间的面积。除此之外,其他的信息基本上格式一致。那么房源的实体类就很简单:
1 | `public class RoomInfo: EntityBase { public string Title { get; set; } /// /// 详细地址 /// public string Url { get; set; } /// /// 附加信息 /// public string Additional { get; set; } /// /// 价格 /// public double Price { get; set; } /// /// 地址 /// public string Address { get; set; } /// /// 大小 /// public double Size { get; set; } /// /// 方位 /// public string Position { get; set; } /// /// 户型 /// public string HouseType { get; set; } /// /// 楼层 /// public string FloorInfo { get; set; } /// /// 标签 /// public List Tags { get; set; } = new List(); } ` |
每个房源的解析过程如下:
1 | `/// /// 解析单一房源信息 /// ///.content__list--item 元素 /// RoomInfo对象 private RoomInfo ParserOne(HtmlNode item) { try { var room = new RoomInfo(); var titleWrapper = item.SelectSingleNode(".//*[@class=\"content__list--item--title\"]"); var a = titleWrapper.SelectSingleNode(".//a"); var href = _urlPrefix + a.Attributes["href"].Value; room.Url = href; room.Title = RemoveBlank(a.InnerText); room.Additional = titleWrapper.SelectSingleNode(".//img") ?.Attributes["alt"].Value; var price = Convert.ToDouble(item.SelectSingleNode(".//*[@class=\"content__list--item-price\"]/em").InnerText ?? ""); room.Price = price; var des = item.SelectSingleNode(".//*[@class=\"content__list--item--des\"]") ?.InnerText ?? ""; des = RemoveBlank(des); var desArr = des.Split("/"); if (desArr.Length >= 4) { room.Address = desArr[0]; room.Size = Convert.ToDouble(_numFilter.Match(desArr[1]).Groups[0].Value); room.Position = desArr[2]; room.HouseType = desArr[3]; if (desArr.Length == 5) { room.FloorInfo = desArr[4]; } } else { room.Address = des; Console.WriteLine($"位置信息解析失败:{href}{ Environment.NewLine } {des}"); } var tags = item.SelectNodes(".//*[@class=\"content__list--item--bottom oneline\"]/i"); if (tags != null) { foreach (var tag in tags) { room.Tags.Add(RemoveBlank(tag.InnerText)); } } return room; } catch (Exception e) { Console.WriteLine($"房子信息解析失败:{Environment.NewLine} {e.Message} {Environment.NewLine} {item.InnerHtml}"); return null; } } ` |
拿到房源的地址后,接着就要进行地理编码了。地理编码能够将接地理地址转换为经纬度,这里使用的是百度地图API。百度地图API进行地理编码时,不用指定城市,是直接将整个地址传递过去即可(高德地图需要传递一个城市参数来限制具体的城市)。因此为了避免解析到其他的城市,在进行地理编码的时候,在之前解析出来的地址前加上一个“武汉市”的限定。地理编码的过程大致如下:
1 | `var address = room.Address; var fullAddress = "武汉市" + address; // geoCoder是写好一个地理编码服务类BMapCoder的实例 var requestResult = geoCoder.Coding(fullAddress); var codingModel = JsonSerializer.Deserialize(requestResult, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); if (codingModel.Status == 0) { room.Lat = codingModel.Result.Location.Lat; room.Lon = codingModel.Result.Location.Lng; } ` |
解析完毕一页的30个房源后,将其批量存入到数据库即可。
3.2.分页信息解析
房源的信息远大于30条的,所以为了系统进行了分页展示。贝壳租房的分页很有趣,他们把所有页面的链接隐藏在显示的DOM下方的一个ul元素中。

在ul标签上右键,一键复制其XPath为//*[@id="content"]/div[1]/ul[2],然后再查找到该ul下的所有a元素,解析其href属性,即可获得所有页面的链接。

分页信息解析的核心代码如下:
1 | `var ul = htmlDoc.DocumentNode.SelectSingleNode("//*[@id=\"content\"]/div[1]/ul[2]"); var aArr = ul.SelectNodes(".//a"); var st = new Stopwatch(); var r = new Random(); foreach (var aNode in aArr) { var attr = aNode.Attributes["href"]; if (attr != null) { var href = attr.Value; // 每一分页的 html 链接 var fullHref = _urlPrefix + href; st.Start(); // 将各页的 html下载值队列中 await base.DownloadStringToChannelAsync(fullHref); st.Stop(); // 每次爬取时间间隔至少2秒钟 var fullTime = (int) st.Elapsed.TotalMilliseconds; var stoppedMilliseconds = 2000 - fullTime; if (stoppedMilliseconds < 0) stoppedMilliseconds = 0; stoppedMilliseconds += r.Next(50, 1000); await Task.Delay(stoppedMilliseconds); } } ` |
4.地铁站点解析
地铁站点解析本来想直接抓取百度地图的接口来分析,后来发现有点复杂,不如直接进行地理编码来的快。首先在百度百科上找到了地铁11号线和2号线的所有地铁站点,并主动删除了部分远的站点,最后留下的地铁站点如下:
1 | `var dt11 = new List() { "武汉东站","湖口站","光谷同济医院站","光谷生物园站","光谷四路站","光谷五路站","光谷六路站","豹澥路站","光谷七路站","长岭山站","未来一路站","未来三路站","左岭站","葛店南站" }; var dt2 = new List() { "广埠屯站","虎泉站","杨家湾站","光谷广场站","珞雄路站","华中科技大学站","光谷大道站","佳园路站","武汉东站","黄龙山路站","金融港北站","秀湖站","藏龙东街站","佛祖岭站" };` |
其实,这些里面还有很多我不想住附近的地铁站,比如金融港北站等。那么解析也很简单,一样在每个站点前加上其限定词——“武汉市地铁11号线”和“武汉市地铁2号线”——然后利用写好的BMapCoder进行解析即可。直接面向过程编码:
1 | `foreach (var name in dt11) { var fullAddress = "武汉市地铁11号线" + name; var xmlRequest = geoCoder.Coding(fullAddress); var codingModel = JsonSerializer.Deserialize(xmlRequest, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); var sbs = new SubwayStation() { Name = name, Lat = codingModel.Result.Location.Lat, Lon = codingModel.Result.Location.Lng, }; await dbContext.SubwayStationSet.AddAsync(sbs); } foreach (var name in dt2) { var fullAddress = "武汉市地铁2号线" + name; var xmlRequest = geoCoder.Coding(fullAddress); var codingModel = JsonSerializer.Deserialize(xmlRequest, new JsonSerializerOptions() { PropertyNameCaseInsensitive = true }); var sbs = new SubwayStation() { Name = name, Lat = codingModel.Result.Location.Lat, Lon = codingModel.Result.Location.Lng, }; await dbContext.SubwayStationSet.AddAsync(sbs); } await dbContext.SaveChangesAsync(); ` |
5.可视化
GIS中最难的部分是数据获取,没有数据就如同“巧妇难为无米之炊”。现在有了数据,一切都好办了。
Postgresql有个很好的地方,就是其有PostGIS插件,能够很好的支持空间数据。可惜我并没有将房源和地铁站点的经纬度存储为Geometry的形式。不过,都是点数据,使用Navicat将其导出为Excel,利用ArcGIS的添加 XY 数据功能,即可直接将其可视化出来:

图中,方形为地铁站,菱形为房源。为什房源这么少?因为有些小区就有多个房源,它们经纬度区别很小,在目前的比例尺上,已经重合在了一起。
6.择房分析
回头开头,我选择房子的两个硬性的要求:
- 离公司近
- 离地铁站近
这两个要求都是定性的,将其定量表述出来为:
- 公司10KM以内
- 地铁站500M以内
10KM做地铁的话,其实很快,可能最多也就4、5站的样子;500M也就1里路,走起来还好,大约10来分钟(突然觉得还是有点远)。
OK,现在还少一个数据:公司的坐标。通过百度地图坐标拾取系统,直接找到公司位置,点取得到其经纬度坐标,然后将公司可视化到ArcGIS中即可。
以上两个定量要求,在GIS中其实都称为邻域分析,这是GIS中最常见的问题。很简单,只需要将公司周围画出一个10KM半径的圆,在每个地铁站周围画出500M半径的圆,即可描述以上两个要求。这个操作的学术名称为缓冲区分析。地铁与公司的缓存区分析后的结果如下:

有了缓冲区分析的结果,只需要找到同时落到两个圆(或者说是两类圆:公司周围10KM和地铁周围500M的圆)中房子即可。这类分析在GIS中称为叠加分析。当然,我使用的并不是叠加分析,而是提取分析——裁剪分析。首先用公司周围10KM缓冲结果对房源进行裁剪,然后利用地铁周围500M缓冲结果对上次裁剪的结果进行再次裁剪,即可到最后的结果(以上两步可以交换顺序):

现在,已经从1100+的房源中,筛选出了13个房子。如果还想进一步挑选出更好的房子,那么可以对房子进行打分:比如朝南2分,朝北1分;低楼层3分,中楼层2分,高楼层1分(如果想住高的,可以反过来设置);面积大于等于15得3分,大于等于10小于15得2分,面积小于10得1分……。打分完毕后,按照得分从高到底依次看房即可。
7.最后
通过一些列的操作,这样虽然有一定的几率无法选择到最合适的房子,但是也绝不会选到很不满意的房。
世间之事如此奇妙,很早之前,大概3、4月份,XD就说想要和我一起合租,他不介意通勤距离。但是通过多次交流,他觉得我有点不想与他合租,后来告诉他,我想与过去的七年做个了解,甚至以后这个微信号都不想怎么用了,他说这样不体面。确是如此,虽然我要重新看下整租的房子,和XD与SD合租,但是我还是想要在毕业后做个了解。
8.声明
本文所涉及的房源数据均归贝壳版权所有。
本文所涉及的知识版权均归本人版权所有,请勿抄袭(开个玩笑)。但是真的建议贝壳可以这样子搞下。
封面图源:该图片由 Harry Strauss 在 Pixabay 上发布