这个爬虫是在一个小老弟的委托之下写的。
他需要爬取携程网上的酒店的评价数据,来做一些分词和统计方面的分析,然后来找我帮忙。
爬这个网站的时候也遇到了一些有意思的小麻烦,正好整理一下拿出来跟大家分享一下。
这次爬取过程稍微曲折,各种碰壁,最终成功的过程,还是有点意思。
所以本文我会按照自己当时爬取的思路来讲述,希望能给大家一些思路上的启发。
分析部分略长,如果赶时间可以直接拉到最下面,自取代码。
如果是想学习爬虫的话,最好还是跟着文章的思路走一遍吧。
这位小老弟给我的需求是:
要求很简单不是嘛,数据量也不是很大(我看了一下,也才 910 条评价,后来爬取完成之后发现其实只有 750 条左右),根本不够看的。于是,我自作主张,返了个场,在他需求的基础上添加了几条:
这里我发现新手在这里一般都有一个共有的误区,就是他们觉得爬虫都是 “通用” 的,一个网站的爬虫拿过来,网址改一下,再随便撺吧撺吧就可以爬另一个网站了。
实际上,每一个网站的爬取都是需要单独进行分析的,你需要找到目标数据是在网页上的什么位置,是通过静态还是动态的方式加载进去的,网站是否有难搞的反爬虫措施,等等,从而来制定自己爬虫的爬取策略。
一般情况下,除非两个网站是极其相似的,或者根本就是用同一个网页模板开发的,这样的话可以套用同一个爬虫来爬,否则,需要针对每个网站的特点去写对应的爬虫。
好了,话不多讲,我们先来分析一下目标网站。
首先打开携程网站,目的地选择【北京】,星级选择【四星级】和【五星级】,点击搜索。
此时网址是:https://hotels.ctrip.com/hotel/beijing1#ctm_ref=hod_hp_sb_lst
可以看到网址中只包含了 【北京】 这个信息,什么四星级五星级的筛选条件,并没有体现在 URL 中。但是从结果来看,它又是确实完成了筛选,所以筛选条件的这些参数肯定是包含在请求的某一个位置的。
继续向下看,翻到页尾,发现网站是用这种方式来实现【翻页】功能的。点击【下一页】,跳转到了第 2 页。
回头看一下 URL,居然没有一丝变化,还是:https://hotels.ctrip.com/hotel/beijing1#ctm_ref=hod_hp_sb_lst
到现在基本可以确定一件事儿了,网页中的酒店信息是通过【动态】方式加载进来的。
好,我们去抓包看一下,按 F12 召唤出【开发者工具】,切换到【Network】选项卡,然后刷新一下页面。
天哪,瞧我发现了什么!!!
在浏览器加载页面时,我抓到了一个叫【AjaxHotelList.aspx】的网络请求,而它的返回结果,恰恰就是我们页面中展示的酒店列表的信息。
果然,携程网的酒店数据,是通过 Ajax 请求动态地加载的。如果没猜错的话,刚才没找到的【四星级、五星级】筛选条件参数,以及页码的参数,应该就藏在这个 Ajax 请求的参数中吧。
如图,切换到 Headers 选项,拉到最底下(Form Data 里的参数有点多,代表了各种各样的筛选条件,不过我们不关心那些),看到了 star 和 page 两个参数。
果然如我所料!
不过不能高兴太早,为了防止网站有什么比较坑爹的反爬机制,最好先写段代码验证一下,看能否按照预期爬到数据。
这里我网络请求用的是 requests 库,数据解析用的是 json 库。
照着浏览器中开发者工具里的 Ajax 请求,把里面的 url,headers,以及 form data 搬过来填这里,发起请求,打印返回结果。
(无关的参数实在太多了,这里简化了一下,只保留了关键的三个参数,cityId,star,和 page)
-
import requests
-
import json
-
-
def fetchHotel(city, star, page):
-
-
url =
"https://hotels.ctrip.com/Domestic/Tool/AjaxHotelList.aspx"
-
headers = {
-
'Content-type':
'application/x-www-form-urlencoded; charset=UTF-8',
-
'Origin':
'https://hotels.ctrip.com',
-
'Referer':
'https://hotels.ctrip.com/hotel/beijing1',
-
'accept':
'*/*',
-
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36',
-
}
-
-
formData = {
-
'cityId': city,
-
'star': star,
-
'page': page,
-
}
-
-
# 发起网络请求
-
r = requests.post(url, data=formData,headers=headers)
-
r.raise_for_status()
-
r.encoding = r.apparent_encoding
-
-
# 打印 r.text 来看看是否获取到了酒店数据
-
print(r.text)
-
-
fetchHotel(
'1',
'4,5',
1)
运行一下,确实出来结果了(虽然输出一堆 “乱七八糟” 的东西,但是从中文字里还是能够看出来,数据取到了)
OK,这条路走通了,不过既然都写到这儿了,顺手把 json 给解析一下,把数据提取了吧。(免得有的小伙伴不相信)
这里我们提取 酒店名称,酒店ID 打印出来看看。
-
import requests
-
import json
-
-
def fetchHotel(city, star, page):
-
-
url =
"https://hotels.ctrip.com/Domestic/Tool/AjaxHotelList.aspx"
-
headers = {
-
'Content-type':
'application/x-www-form-urlencoded; charset=UTF-8',
-
'Origin':
'https://hotels.ctrip.com',
-
'Referer':
'https://hotels.ctrip.com/hotel/beijing1',
-
'accept':
'*/*',
-
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36',
-
}
-
-
formData = {
-
'cityId': city,
-
'star': star,
-
'page': page,
-
}
-
-
# 发起网络请求
-
r = requests.post(url, data=formData,headers=headers)
-
r.raise_for_status()
-
r.encoding = r.apparent_encoding
-
-
# 打印 r.text 来看看是否获取到了酒店数据
-
#print(r.text)
-
-
# 解析 json 文件,提取酒店数据
-
json_data = json.loads(r.text)[
'hotelPositionJSON']
-
-
hotelList = []
-
for item
in json_data:
-
hotelId = item[
'id']
-
hotelList.append(hotelId)
-
print(item[
'name'], hotelId)
-
-
return hotelList
-
-
fetchHotel(
'1',
'4,5',
1)
运行代码,你看!没错吧,是我们要的酒店列表。
到这里,酒店列表爬取工作,基本就跑通了。
接下来该研究研究酒店评论该怎么爬取吧。
随便打开一个酒店,进入详情页之后,找到了【酒店点评】部分。
在这里,我们可以找到需要的评论的数据,用户昵称,评分,出游类型,入住时间,评价时间,房型,评价内容等等。
继续往下翻,评论页数同样的方式翻页,而且翻页时候 URL 不变,不用说,又是 Ajax 动态加载咯。
直接 F12 召唤 开发者工具,流程很熟悉了,就讲快一点啦。于是我就抓到了评价数据的包了。
(其实很好找的啦,AjaxHotelCommentList,懂点英语的都能猜到是这个了)
不过它返回的内容格式不是 json 了,而是 html,而且没有排版,格式有点乱。
这个不要紧,去随便找一个在线代码格式化网站(http://tool.oschina.net/codeformat/html),排个版就好了。
解析 json 文件可以用 json 库,解析 HTML 文件用什么呢?我一般用 BeautifulSoup 库,贼拉好用。
这里先不急解析,先写代码验证一下,看看有没有什么坑爹的反爬机制。
同样的方法,讲 Ajax 请求中的 Url,headers 还有 formdata 里的参数都扣过来,跑一下。
-
import requests
-
-
def fetchCmts(hotel, page):
-
-
url =
"https://hotels.ctrip.com/Domestic/tool/AjaxHotelCommentList.aspx?MasterHotelID=469055&hotel=469055&NewOpenCount=0&AutoExpiredCount=0&RecordCount=5420&OpenDate=2013-12-01&card=-1&property=-1&userType=-1&productcode=&keyword=&roomName=&orderBy=2&viewVersion=c&contyped=0&eleven=12488c2f039b057861112f7bc2f1322271c415a3618cba855bcc85b09795189e&callback=CASOmTvWnCuMJeETo&_=1572277191008"
-
headers = {
-
'Content-type':
'application/x-www-form-urlencoded; charset=UTF-8',
-
'referer':
'https://hotels.ctrip.com/hotel/469055.html?isFull=F&masterhotelid=469055&hcityid=2',
-
'accept':
'*/*',
-
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36',
-
}
-
formData = {
-
'hotel': str(hotel),
-
'currentPage': str(page),
-
'eleven':
'12488c2f039b057861112f7bc2f1322271c415a3618cba855bcc85b09795189e',
-
'callback':
'CASOmTvWnCuMJeETo',
-
'_':
'1572277191008',
-
}
-
-
r = requests.post(url, headers=headers)
-
r.raise_for_status()
-
r.encoding =
"utf-8"
-
print(r.text)
-
-
fetchCmts(
'469055',
1)
运行代码,果然,可以获取到数据。
不过,这个可不能高兴的太早,为什么呢?
你看 Form Data 的参数中,有三个参数 eleven,callback 和 _ ,这三个的值有点奇怪,一长串看不懂的数字和字母,而且每次的值都不一样。
'_' 的值,1572277191008,这个有点熟悉,好像是时间戳,找在线工具解析一下,没错,果然是。
内心咯噔一下,坏了!
根据经验来讲,参数中带时间戳的,请求一般都是有时效性的。什么意思呢?就是这类请求的参数都是根据一定的规则动态生成的,而且一般几分钟之内就会失效。(再次运行上面的代码,果然啥也获取不到了,失效了)。
也就是说,如果我想通过 Ajax 请求去获取数据的话,我必须搞清楚这三个参数的生成规则。
而这些参数又是经过 JS 加密的,搞这个又涉及到了 JS 逆向的东西。。。
我其实去网上查过携程网酒店爬虫,想看看别人是怎么绕过这个反爬机制的。
结果搜出来的好几个结果,都是用 Selenium webdriver 爬的。那个是什么原理呢。
就是我们正常的思路,是用爬虫直接去访问网站获取数据,爬虫伪装不好的话很容易被发现;
而它们这个,相当于是爬虫操作一个真的浏览器去访问网站,对方网站看到的是真正的浏览器在访问,它怎么也想不到操作浏览器的不是人,而是一只爬虫。所以这个方法几乎可以绕过所有的反爬机制。
不过!!!!
一个爬虫玩家的尊严,不允许我使用这种低效率又无脑的方式(误,手动狗头保命)。
于是我决定硬刚 Ajax 请求!!
不过 JS 逆向哪有这么容易的,一时半会儿也搞不定(记得我第一次做 JS 逆向时,整整调试了一个礼拜的 JS 代码才搞出来),而那边小老弟要的又比较急......
正在我一筹莫展之际,看到了一张帖子,有个老哥的回答让我茅塞顿开。
对呀,网页端的不行,那就模拟手机端的来试试。
网址由 https://hotels.ctrip.com/hotel 变成了 https://m.ctrip.com/webapp/hotel 。
手机版的评论不是点页码翻页的,是划到页面底部时候自动加载下一页内容的,然后我们在 开发者工具中 成功抓到了评论数据的请求包。
再看一下它的参数列表,嗯,还好,没有奇奇怪怪的动态加密的参数了。
这次怎么样呢?写段代码验证一下吧。
-
import requests
-
-
def fetchCmts(hotel, page):
-
url =
"https://m.ctrip.com/restapi/soa2/16765/gethotelcomment?&_fxpcqlniredt=09031074110034723384"
-
headers = {
-
'Content-type':
'application/x-www-form-urlencoded; charset=UTF-8',
-
'Origin':
'https://m.ctrip.com',
-
'accept':
'*/*',
-
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36',
-
}
-
-
formData = {
-
'groupTypeBitMap':
'2',
-
'hotelId': str(hotel),
-
'pageIndex': str(page),
-
'pageSize':
'10',
-
'travelType':
'-1',
# -1 表示全部,家庭亲子为 30
-
}
-
-
r = requests.post(url, data=formData, headers=headers)
# formData,
-
r.raise_for_status()
-
r.encoding =
"utf-8"
-
-
return r.text
-
-
fetchCmts(
'6410223',
1)
运行程序,可以获取到结果,修改酒店编号,修改页码,再运行都没问题。
返回的结果是 json格式的,回头用 json 库解析一下,把关键数据提取出来就可以了。
事情进行到这儿,对目标网站的分析研究也就基本结束了。
酒店列表,评论数据的爬取,流程也基本跑通了,接下来只需要把代码整理一下,爬就完事儿了。
前面将网站的爬取思路已经捋清楚了,而且做了些小测试也基本跑通了,接下来就是撸码环节了。
1. 获取酒店列表
我们其实也发现了,爬取评论数据时,只需要酒店ID和页码两个参数就够了,所以爬酒店列表时,我们只需要提取 酒店ID 即可。
-
import requests
-
import json
-
-
def fetchHotel(city, star, page):
-
-
url =
"https://hotels.ctrip.com/Domestic/Tool/AjaxHotelList.aspx"
-
headers = {
-
'Content-type':
'application/x-www-form-urlencoded; charset=UTF-8',
-
'Origin':
'https://hotels.ctrip.com',
-
'Referer':
'https://hotels.ctrip.com/hotel/beijing1',
-
'accept':
'*/*',
-
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36',
-
}
-
-
formData = {
-
'cityId': city,
-
'star': star,
-
'page': page,
-
}
-
-
# 发起网络请求
-
r = requests.post(url, data=formData,headers=headers)
-
r.raise_for_status()
-
r.encoding = r.apparent_encoding
-
-
# 解析 json 文件,提取酒店数据
-
json_data = json.loads(r.text)[
'hotelPositionJSON']
-
-
hotelList = []
-
for item
in json_data:
-
hotelId = item[
'id']
-
hotelList.append(hotelId)
-
-
return hotelList
2. 爬取评论数据
-
import requests
-
import json
-
-
def fetchCmts(hotel, page):
-
url =
"https://m.ctrip.com/restapi/soa2/16765/gethotelcomment?&_fxpcqlniredt=09031074110034723384"
-
headers = {
-
'Content-type':
'application/x-www-form-urlencoded; charset=UTF-8',
-
'Referer':
'https://m.ctrip.com/webapp/hotel/hoteldetail/dianping/'+ hotel +
'.html?&fr=detail&atime=20191027&days=1',
-
'Origin':
'https://m.ctrip.com',
-
'accept':
'*/*',
-
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36',
-
}
-
-
formData = {
-
'groupTypeBitMap':
'3',
-
'auth':
"",
-
'cid':
"09031074110034723384",
-
'ctok':
"",
-
'cver':
"1.0",
-
'extension':
'[]',
-
'lang':
"01",
-
'sid':
"8888",
-
'syscode':
"09",
-
'hotelId': str(hotel),
-
'needStatisticInfo':
'0',
-
'order':
'0',
-
'pageIndex': str(page),
-
'pageSize':
'10',
-
'tagId':
'0',
-
'travelType':
'-1',
-
}
-
-
r = requests.post(url, data=formData, headers=headers)
# formData,
-
r.raise_for_status()
-
r.encoding = r.apparent_encoding
-
-
json_data = json.loads(r.text)
-
-
cmtsList = []
-
hotelName = json_data[
'hotelName']
-
-
for item
in json_data[
'othersCommentList']:
-
cmt = []
-
-
userName = item[
'userNickName']
-
travelType = item[
'travelType']
-
baseRoomName = item[
'baseRoomName']
-
checkInDate = item[
'checkInDate']
-
postDate = item[
'postDate']
-
ratingPoint = item[
'ratingPoint']
-
content = item[
'content']
-
-
cmt.append(userName)
-
cmt.append(hotelName)
-
cmt.append(travelType)
-
cmt.append(baseRoomName)
-
cmt.append(checkInDate)
-
cmt.append(postDate)
-
cmt.append(ratingPoint)
-
cmt.append(content)
-
-
cmtsList.append(cmt)
-
-
return cmtsList
3. 数据保存函数
将数据保存到 csv 文件中。
-
import pandas
as pd
-
import os
-
-
def saveCmts(path, filename, data):
-
# 如果路径不存在,就创建路径
-
if
not os.path.exists(path):
-
os.makedirs(path)
-
-
# 保存文件
-
dataframe = pd.DataFrame(data)
-
dataframe.to_csv(path + filename, encoding=
'utf_8_sig', mode=
'a', index=
False, sep=
',', header=
False )
4. 爬虫调度器
由于小老弟提的要求是: 上海静安香格里拉大酒店,家庭亲子类型的,评论数据。所以,
在 fetchCmts 中,将 travelType 的值设置为 30,
'travelType': '30', # 30 表示 家庭亲子 类型
由于看到评论区内容只有九百多条, 每页显示 10 条,所以我们将页码范围设置为 1 - 100 。
-
import time
-
-
if __name__ ==
'__main__':
-
hotel =
'469055'
# 上海静安香格里拉大酒店
-
startPage =
1
-
endPage =
100
-
path =
'Data/'
-
filename =
'cmtTest.csv'
-
-
for p
in range(startPage, endPage+
1):
-
cmts = fetchCmts(hotel, p)
-
saveCmts(path, filename, cmts)
-
time.sleep(
0.5)
为了保险期间,还加了一个 sleep 函数,每爬一次歇半秒,免得因为爬取太频繁被发现。
几分钟之后,爬取完成,共爬取到 735 条数据。至此,小老弟的忙总算是帮完了。
不过,最开始也说了,我嫌爬的不过瘾,又给自己加了几条需求。
爬取北京市的,所有四星级以上酒店的,所有类型的评价数据。
-
import time
-
-
if __name__ ==
'__main__':
-
city =
'1'
-
star =
'4,5'
-
startPage =
1
-
hotelEndPage =
30
-
cmtsEndPage =
100
-
-
for page
in range(startPage, hotelEndPage +
1):
-
hotelList = fetchHotel(city, star, page)
-
for hotel
in hotelList:
-
for p
in range(startPage, cmtsEndPage +
1):
-
cmts = fetchCmts(hotel, p)
-
saveCmts(
"Data/",
"cmtTest.csv", cmts)
-
time.sleep(
1)
-
-
print(
"爬取完成")
这里偷了个懒,具体有多少酒店,每个酒店有多少评论我们不去管它了,就爬 30 页的酒店,每个酒店爬 100 页的评论,
大概就是爬 300 个酒店,每个酒店 1000 条左右的评论,差不多可以了,如果想爬更多的话,可以自行去修改页码范围。
这个爬虫给了我一个新的启示,就是,遇到问题,我们要有死磕的觉悟,但是也要有灵活变通的思维。
就像这个爬虫,爬取评论信息时,PC 版网页的请求加了密不好整,那就换个途径,从手机端来获取数据。
时间也省了,事儿也办了,岂不快哉。
2019年12月18日 更新
有读者反馈说,在抓取酒店列表信息的部分,使用文章中的代码无法正常获取数据。
运行代码的结果是这样的,也不报错,就是返回的搜索结果是 0 条。
经调试发现,可能是对方服务器做了调整,需要验证 cookies 信息,只需要在 headers 中添加 cookies 参数即可。
2020年6月20日 更新
距离这个爬虫写好已经比较久了,期间对方网站也做过一些反爬机制的调整导致爬虫失效。
很多读者反馈说,前面更新时说的向 headers 中添加 cookies 的方法也失效了。经过测试,确实是,现在网站需要验证 “登陆账号后” 的 cookie 了,注意是登陆账号后的,未登录的cookie爬出来还是0条。
后续网站是否会有调整我不知道,截至本次更新时,添加登陆账号后的 cookie 后,文中的爬虫仍是有效的。(可能有些读者刚刚接触爬虫,不知道 cookie 加在哪儿,下面贴一段测试代码,大家参考)
-
import requests
-
-
def fetchCmts(hotel, page):
-
url =
"https://m.ctrip.com/restapi/soa2/16765/gethotelcomment?&_fxpcqlniredt=09031074110034723384"
-
headers = {
-
'Content-type':
'application/x-www-form-urlencoded; charset=UTF-8',
-
'Origin':
'https://m.ctrip.com',
-
'accept':
'*/*',
-
'user-agent':
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36',
-
'cookie':
'这里放登陆账号后登陆账号后登陆账号后的cookie',
-
}
-
-
formData = {
-
'groupTypeBitMap':
'2',
-
'hotelId': str(hotel),
-
'pageIndex': str(page),
-
'pageSize':
'10',
-
'travelType':
'-1',
# -1 表示全部,家庭亲子为 30
-
}
-
-
r = requests.post(url, data=formData, headers=headers)
# formData,
-
r.raise_for_status()
-
r.encoding =
"utf-8"
-
print(r.text)
-
return r.text
-
-
fetchCmts(
'10246623',
2)
对方网站反爬机制升级,也是侧面反映了网站收到各种爬虫爬取的困扰很大。
希望大家在爬取数据时,注意控制爬取节奏,时间允许的范围内,尽量放慢爬取速度。
如果文章中有哪里没有讲明白,或者讲解有误的地方,欢迎在评论区批评指正,或者扫描下面的二维码,加我微信,大家一起学习交流,共同进步。