Splash-Scrapy爬取Airbnb数据

引言

很久之前(2017年11月)写过一个用Python爬虫去Airbnb网站上抓取民宿信息,当时就觉得这个网站的信息实在是隐藏得太深了。结果最近发现,现在又不能了:Airbnb的网页用了大量的JS来动态生成,直接解析网页,拿不到什么有用的信息。

道高一尺,魔高一丈。为了解决动态网页的爬取,也有许多方法,例如我们前面针对Wunderground用Selenium解析。由于Airbnb需要爬取的网页数目比较多,为了提高稳定性,我们采用Scrapy+Splash的方法来解析动态网页,在这个过程中也发现,如果可以找到网页请求的API,用API实际上更加方便。

Scrapy和Splash库介绍

Scrapy的安装和使用

之前为了获取一些网上的信息,我们可能会自己写一些网站爬取和网页解析的小工具。而scrapy库则是一个流行的用于爬取网站数据,提取结构性数据的应用框架,可以大大提高我们编写爬虫的效率。scrapy的安装十分简单,如果是采用conda的方式,安装命令为:

1
conda install -c conda-forge scrapy

安装好以后,创建一个Scrapy的工程,创建的工程中会包含如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
./
scrapy.cfg # deploy configuration file

AirbnbSpider/ # project's Python module, you'll import your code from here
|-- __init__.py

|-- items.py # project items definition file

|-- middlewares.py # project middlewares file

|-- pipelines.py # project pipelines file

|-- settings.py # project settings file

|-- spiders/ # a directory where you'll later put your spiders

|-- __init__.py

|-- bnbspider.py

其中各个模块具体的功能,大家可以阅读Scrapy的的官方文档,也能够在网上找到很多的资料。

Splash的安装和使用

Splash是一个带有HTTP API的轻量级Web浏览器,基于Python 3使用Twisted和QT5实现异步服务,从而能够利用QT主循环充分发挥wekit的并发优势。

Splash能够并行处理多个网页,返回网页的渲染代码或者屏幕截图。为了加快渲染速度,还可以尽职下载图像或者使用Adblock进行过滤。

scrapy与Splash配合使用过程中,首先要配置Splash的环境,包括以下几步:

  1. 安装Docker,(Windows 10支持直接安装Docker);

  2. 获得splash的Docker镜像,

    docker pull scrapinghub/splash

  3. 运行Docker

    docker run -it -p 8050:8050 scrapinghub/splash

这样,我们就有了一个本地运行的splash服务器,然后还需要安装scrapy-splash库实现scrapy对Spalsh HTTP API的调用。

splash_rendering

Airbnb结构与内容分析

我们在爬数据的时候,首先还是要明确是为了什么,然后需要什么,再看看网站能提供什么?具体到Airbnb而言,上面的数据种类繁杂,无论是空间尺度、时间尺度还是面板尺度,都可以拿来作分析,既有量化的打分、也有定性的评价,既有连续数值的价格,也有离散的分类变量,所以初步的了解是非常必要的。总体来看,我觉得可以分为以下四个部分:

根据位置构造Start URL

民宿是个典型的基于地理位置的服务(LBS),最好我们能有一个地理编码的列表:如果我们想遍历中国所有的省市,遍历列表就可以,具体而言我们采用网上提供的中国全国的5级行政区划(省、市、县、镇、村)(https://github.com/adyliu/china_area ) 。

在Aribnb的爬虫中,我们主要用这个地理编码列表来构建scrapoy中的start_requests()方法或者是start_urls。我们之所以要自己构造这个start_requests主要是出于简化爬虫的复杂性:

  • 如果我们采用深度优先搜索的策略,需要控制爬虫的搜索深度,如果了解内容的层次,可以设定一个比较好的深度;
  • Airbnb的搜索,一次最多提供18×1818\times18的搜索结果,显然很多城市不止这个数量,那就需要用一个合理的方式来遍历(不遗漏的同时尽可能减少重复)。

首先对网上下载的行政区划进行简单的处理,方便下一步使用:

aera_code

这样,我们得到的start_url形式就是:

http://zh.airbnb.com/s/[搜索字段]/homes?[arguments]

其中,[搜索字段]就是我们不同的行政区划组合,[arguments]是查询用的参数,例如(以字典形式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
_query_parts = {
'adults': 1,
'check_in': '2019-06-01',
'check_out': '2019-06-02',
'guests': 1,
'hosting_amenities': {
# Desired hosting amenities and corresponding IDs.
# Determined by observing search GET parameters.
'a/c': 5,
'kitchen': 8,
'tv': 58,
'washer': 33,
'dryer': 34,
'wifi': 4,
},
'map': {
'coords': {
'ne_lat': 38.339050291964746,
'ne_lng': 117.04326732747506,
'sw_lat': 38.561755675390934,
'sw_lng': 117.69970043294381
},
'zoom': 10
},
'neighborhoods': None,
'pets': 'false',
'price_max': 2750,
'price_min': None,
'query': 'Beijing--China',
'refinements': ['homes'],
'room_types': ['Entire%20home%2Fapt'],
'search_by_map': 'true'
}

在发送请求时,我们使用SplashRequest代替scrapy.request(),对于不同的Splash请求,scrapy-splash返回不同的Response子类, 如下所示,args为请求的参数,self.parse为获得响应后的回调函数,也就是我们下一步对查询结果的解析:

1
2
3
4
def start_requests(self):
start_urls = self._build_airbnb_start_url()
for url in start_urls:
yield SplashRequest(url, self.parse, self.parse, args={'wait': 10, 'images': 0})

解析查询结果

XPath解析民宿URL

利用Chrome的开发者工具,我们可以看到在一页里面,Airbnb将搜索结果保存在了itemList里面,每一个民宿就是一个itemListElement。所以相对而言,还是比较容易解析的。

search_results_1

对于简单的网页或者匹配,用正则表达式就可以解决,在这里我们用另外一种方法:XPath,即XML路径语言(XML Path Language),它是一种用来确定XML文档中某部分位置的计算机语言,所以很适合用作查询。我们可以发现,对于同一个内容可以采用不同的查询语句,分析网页结构,我们这里采用的具体如下:

1
2
3
4
5
def _parse_listing_results_page(self, response):
room_url_parts = set(response.xpath('//div/a[contains(@href,"rooms")]/@href').extract())
for href in list(room_url_parts):
url = response.urljoin(href)
yield scrapy.Request(url, callback=self._parse_listing_contents)

解析结果列表的数目

可以看到,一次查询往往有很多页,我们需要逐页去提取,所以就需要逐页获取列表的结果,类似上面的做法,我们查看网页的代码可以发现,页码是以列表的形式存储的,列表的倒数第二项就是最后页码的数目。

此外,还需要注意原来Airbnb是用section_offset=[页码]作为查询参数,现在改成了items_offset=[页码]*[每页数目]作为查询参数,保不齐什么时候就会又变了:

search_results

解析民宿信息(接近失败!)

接下来,就到了最为重要的一步——爬取民宿的各类信息。然而不幸的是,即使我们用splash加载动态网页,我们能得到的信息也只是页面上显示的内容,而且还需要进一步处理。而以前的时候,可以直接获取一个JSON对象,里面含有几乎所有的信息,这么一比较,现在这种做法就显得非常不划算了。

所以说,如果只能获取页面内容,及时不是完全失败,也是近乎失败了。

调用AirbnbAPI获取数据

山重水复疑无路,柳暗花明又一村。既然页面的内容是动态生成的,而住房信息又不是算出来的,那一定会向服务器请求。能不能从这里面发现点什么?我们用Chrome开发者工具查看一下页面发出的请求,果然有一些端倪:

room_show_api

我们用在Python中调用一下这个API,看一下结果:

1
2
3
import requests
response = requests.request('GET', 'https://zh.airbnb.com/api/v2/pdp_listing_details/18674900?key=d306zoyjsyarp7ifhu67rjxn52tv0t20&_format=for_rooms_show')
response.json()['pdp_listing_detail'].keys()
dict_keys(['listing_amenities', 'root_amenity_sections', 'see_all_amenity_sections', 'additional_house_rules', 'bathroom_label', 'bed_label', 'bedroom_label', 'guest_label', 'highlights', 'id', 'listing_expectations', 'listing_rooms', 'name', 'p3_subject', 'p3_summary_address', 'p3_summary_title', 'person_capacity', 'photos', 'primary_host', 'room_and_property_type', 'room_type_category', 'sectioned_description', 'star_rating', 'tier_id', 'user', 'book_it_url', 'calendar_last_updated_at', 'guest_controls', 'min_nights', 'native_currency', 'collection_kicker', 'show_policy_details', 'additional_hosts', 'applicable_disaster', 'hometour_rooms', 'hometour_sections', 'alternate_sectioned_description_for_p3', 'initial_description_author_type', 'localized_check_in_time_window', 'localized_check_out_time', 'localized_city', 'localized_listing_expectations', 'localized_room_type', 'city_guidebook', 'country_code', 'display_exact_location', 'host_guidebook', 'lat', 'lng', 'location_title', 'neighborhood_id', 'p3_event_data_logging', 'paid_growth_remarketing_listing_ids', 'commercial_host_info', 'flag_info', 'license', 'p3_listing_flag_options', 'p3_review_flag_options', 'requires_license', 'should_hide_action_buttons', 'should_show_business_details', 'show_edit_mode', 'support_cleaner_living_wage', 'p3_display_review_summary', 'review_details_interface', 'sorted_reviews', 'visible_review_count', 'cover_photo_primary', 'host_interaction', 'host_quote', 'layout', 'nearby_airport_distance_descriptions', 'property_type_in_city', 'render_tier_id', 'select_listing_tenets', 'other_property_types', 'p3_neighborhood_breadcrumb_details', 'p3_seo_breadcrumb_details', 'p3_seo_property_search_url', 'seo_features', 'share_links', 'education_module', 'collection_promotion', 'reviews_order', 'cover_photo_vertical', 'is_hotel', 'show_review_tag', 'accessibility_module', 'is_representative_inventory', 'highlights_impression_id', 'point_of_interests', 'has_essentials_amenity', 'china_points_of_interest', 'reservation_status', 'visibility', 'categorized_preview_amenities', 'section_erf_configs', 'china_points_of_interest_matcha', 'security_deposit_details', 'page_view_type', 'preview_tags', 'see_all_hometour_sections', 'summary_section', 'education_modules', 'enable_highlights_voting', 'amenity_section', 'host_info_module', 'hometour_module', 'hero_module', 'summary_module', 'new_user_education_module', 'panorama', 'p3_impression_id', 'error_status', 'debug_output'])

我们发现,这个信息可比页面显示的内容多多了,这才是我们真正需要的~

等等!如果房间的信息可以通过API获取,搜索信息能不能呢?我们不妨试一下,果然不出所料:

explore_tabs

1
2
3
4
5
url = ('https://zh.airbnb.com/api/v2/explore_tabs?_format=for_explore_search_web&client_session_id=2750fd8f-8607-48dc-b625-4b0669ba0a23'
'&has_zero_guest_treatment=true&is_standard_search=true&items_per_grid=18&key=d306zoyjsyarp7ifhu67rjxn52tv0t20&locale=zh'
'&metadata_only=false&query=%E5%8C%97%E4%BA%AC&show_groupings=true&timezone_offset=480')
response = requests.request('GET', url)
response.json()['explore_tabs'][0]['sections'][1].keys()
dict_keys(['backend_search_id', 'display_type', 'experiments_metadata', 'result_type', 'search_session_id', 'section_id', 'section_type_uid', 'title', 'see_all_info', 'is_paginated', 'bankai_section_id', 'refinements', 'listings', 'review_items', 'breadcrumbs', 'section_metadata', 'nearby_locations', 'localized_listing_count'])

果然我们用这个请求,可以请求到页面的主要内容,尤其是包含房间信息的listings,而且如果我们是第一次发起请求,然后向下滚动查看页面,页面会继续发出请求,动态加载房间信息:

listing_details

对比可以发现,调用API比我们用Splash加载页面、然后用XPath去解析内容方便了许多。但是,这种好事并不是总有的。此外,请求API需要用到key,这个还是需要在一开始要请求一下网页才能获得的。

小结

我们用Splash-Scrapy实现了对Airbnb民宿信息的爬取,其中介绍了用Splash加载动态网页和用Airbnb的API直接读取信息。实际上,这还不是一个完整的爬虫或者说Scrapy项目,还有至少一下工作需要完成:

  • 使用Item类封装抓取的数据;
  • 使用Pipeline类保存数据;
  • 修改settings.py对Scrapy进行设置
  • ……

感兴趣的可以查找相关资料。

接下来,可能会对Airbnb的数据进行分析,待续~