一、目标数据介绍
爬取对象为大众点评网北京地区“美食”标签下参照“人气”自动排序得到的750条餐馆数据,示例如下:
1.1 属性值介绍
需要爬取的属性值,如下表所示:
属性 | 命名 | 数据类型 |
---|---|---|
店名 | title | str |
星级评分 | star | float |
评价数 | review_num | int |
人均消费 | cost | int |
特征 | feature | str |
地址 | address | str |
1.2 数据排列规律分析
通过浏览大众点评内容页,可以发现,每页包含最多15条记录,共50页。因此,可能需要通过循环函数多次爬取。
进一步,注意到第一页的URL地址为http://www.dianping.com/beijing/ch10/o2。很容易想到ch10和o2代表数据类别,查看下一页,可以看到,第二页的URL为http://www.dianping.com/beijing/ch10/o2p2。因此可以推断,350页的URL应为*/ch10/o2p3*/ch10/o2p50。
1.3 数据提取路径
通过后台读取页面源码,可以看到,单一页面的店铺记录信息都存储在ID为shop-list-all-list的div标签中,如下图所示,每个li标签即为一条店铺记录。
点开li标签后,可以看到需要的属性数据都存储在class='txt'的div标签内。
以店名title为例,可以看到店名就存储在h4子标签中。
相似的,可以递推寻得star、review_num、cost、feature、address等属性的存储路径。
1.4 大众点评反爬策略
在此次数据爬取过程中发现,大众点评针对于网络爬虫对数据进行了加密,如下所示:
网页显示的数据正常,人民币符号'¥'加上数字表示人均消费。
然而,在后台显示的源码中,部分数字显示为乱码,无法正常读取。
这是大众点评采用的字体反爬措施导致的,如果直接按照常规方法读取子标签信息,爬下来的数据也会显示为乱码。解决方案将在后文提出。
二、爬取流程
2.1 requests访问目标网页并获取HTML源码
import requests
from lxml import etree
page_url = 'http://www.dianping.com/beijing/ch10/o2'
# 添加headers信息,User-Agent模拟正常浏览器访问,cookie为当需要登录授权时使用。
page_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.111 Safari/537.36", "cookie": "your cookie"}
# 建立http链接
res_http = requests.get(page_url, headers=page_headers)
html = etree.HTML(res_http.text)
# res_http.text即为网页的html源码,数据类型为str。但无法解析,需要通过lxml模块的etree.HTML函数解析为xml格式,建立树状结构数据格式,并返回值赋给html对象
2.2 解析HTML获得子标签信息
# 在得到经过解析的html信息后,开始循环解析获得单个饭店的信息
# 为了避免存在某一页饭店记录数不为15,需要检测当前页面饭店记录数量
# 经过分析,可以采用<div class="tit">字符串作为检测标签
import re # 调用正则表达式模块用于解析
data = [] # 以字典列表形式存储数据,列表内每一条记录为一个字典数据结构,代表一家饭店
# 获取当前页饭店数量
record_tags = re.findall(re.compile(r'<div class="tit">'), res_http.text)
nRecord = len(record_tags)
for n in range(nRecord):
ID = n + 1 # 饭店记录序号
values = [] # 键值列表,每个循环均重置
values.append(getTitle(ID, html)) # 提取店名
values.append(getStar(ID, html)) # 提取星级评分
values.append(getReviewNum(ID, html, woff_dict_shopNum)) # 提取评论数
values.append(getCost(ID, html, woff_dict_shopNum)) # 提取平均每人消费
values.append(getFeature(ID, html, woff_dict_tagName)) # 提取饭店标签
values.append(getAddress(ID, html, woff_dict_address)) # 提取地址
values.append(getRecommend(ID,html)) # 提取推荐菜品
# 建立字典并存入数据列表
data.append(dict(zip(keys, values)))
getTitle等函数为自定义函数,用于执行解析的具体操作。以getTitle为例:
def getTitle(ID, html):
return html.xpath('//*[@id="shop-all-list"]/ul/li['+str(ID)+']/div[2]/div[1]/a/h4')[0].text
此时,我们已经能够解析得到页面内单一店铺的相关数据。结合2.1及2.2,很容易构建出完整的数据结构实现自动爬取数据。打印data对象,如图所示:
上图显示的"u"开头的字符串为已经过反反爬解析后得到的unicode编码,在没有经过反反爬之前,输出得到的会是无意义的方框,例如:
接下来介绍反反爬思路。
2.3 反反爬
经过查找资料,发现大众点评采用的是Web字体反爬策略。通过创建自定义字体,改变一定数量的常用字符的Unicode编码,由于服务器端记录了新字体与Unicode编码之间的映射关系,网页端能够识别改变了Unicode编码后的字符并正常显示。然而,当爬虫直接爬取HTML源码并访问子标签值时,由于本地没有对应的字体文件,就无法正常解析Unicode编码,从而显示为方框。
因此,只需要获取对应的字体文件,并在本地建立常用字符和特定Unicode编码间的映射关系,然后在2.2的解析过程中进行替换即可。
2.3.1 字体文件
由于前端页面字体由CSS文件决定,故从这个角度出发,找到CSS文件,就有可能找到对应的字体文件。
以address属性为例,浏览器后台找到对应代码段,如图所示:
下方Styles栏可以看到class=address的所有标签所依赖的CSS文件。
打开该文件,可以发现,它定义了“reviewTag”、“address”、“shopNum”、“tagName”四类标签对应的字体文件,如图:
从而能够通过解析该CSS文件获取对应的字体文件。由于该文件命名没有规律,大概率为随机生成,故需要通过定位其在HTML源码中的引用语句来获取,如下:
很明显,"svgtextcss"可以作为定位该段URL的唯一标签。借助于正则表达式,就可以从HTML源码中将其提取出来。
# 抓取woff文件,res_http为2.1requests模块发出get请求得到的返回值,.text属性为页面HTML源码的字符串
woff_file = getWoff(res_http.text)
def getWoff(page_html):
woff_files = []
# 提取css文件url
css_str = re.findall(re.compile(r'//.*/svgtextcss/.*.css'), page_html)[0]
css_url = 'https:' + css_str
# http访问css文件解析得到woff文件
res_css = requests.get(css_url).text
woff_urls = re.findall(re.compile(r'//s3plus.meituan.net/v1/mss_w{32}/font/w{8}.woff'), res_css)
tags = ['tagName', 'reviewTag', 'shopNum', 'address']
for nNum, url in enumerate(woff_urls):
res_woff = requests.get('http:' + url)
with open('./resources/woff/'+tags[nNum]+'.woff', 'wb') as f:
f.write(res_woff.content)
woff_files.append('./resources/woff/'+tags[nNum]+'.woff')
return dict(zip(tags, woff_files))
在得到woff文件后,可以通过FontCreator软件打开,如图所示:
可以看到,总共603个常用字符,按照一定顺序排列并编码。因此,只要获取字符与Unicode编码的映射关系就可以将反爬字体替换为普通编码方式的常用字体了。
2.3.2 字符映射关系解析
在获取woff字体文件后,需要解析字符与Unicode编码间的关系。调用fontTools模块即可。
from fontTools.ttLib import TTFont
woff = TTFont(woff_file_URL) # 读取woff文件
# woff文件中ID编号为2~602的601个字符
woff_str_601 = '1234567890店中美家馆小车大市公酒行国品发电金心业商司超生装园场食有新限天面工服海华水房饰城乐汽香部利子老艺花专东肉菜学福饭人百餐茶务通味所山区门药银农龙停尚安广鑫一容动南具源兴鲜记时机烤文康信果阳理锅宝达地儿衣特产西批坊州牛佳化五米修爱北养卖建材三会鸡室红站德王光名丽油院堂烧江社合星货型村自科快便日民营和活童明器烟育宾精屋经居庄石顺林尔县手厅销用好客火雅盛体旅之鞋辣作粉包楼校鱼平彩上吧保永万物教吃设医正造丰健点汤网庆技斯洗料配汇木缘加麻联卫川泰色世方寓风幼羊烫来高厂兰阿贝皮全女拉成云维贸道术运都口博河瑞宏京际路祥青镇厨培力惠连马鸿钢训影甲助窗布富牌头四多妆吉苑沙恒隆春干饼氏里二管诚制售嘉长轩杂副清计黄讯太鸭号街交与叉附近层旁对巷栋环省桥湖段乡厦府铺内侧元购前幢滨处向座下臬凤港开关景泉塘放昌线湾政步宁解白田町溪十八古双胜本单同九迎第台玉锦底后七斜期武岭松角纪朝峰六振珠局岗洲横边济井办汉代临弄团外塔杨铁浦字年岛陵原梅进荣友虹央桂沿事津凯莲丁秀柳集紫旗张谷的是不了很还个也这我就在以可到错没去过感次要比觉看得说常真们但最喜哈么别位能较境非为欢然他挺着价那意种想出员两推做排实分间甜度起满给热完格荐喝等其再几只现朋候样直而买于般豆量选奶打每评少算又因情找些份置适什蛋师气你姐棒试总定啊足级整带虾如态且尝主话强当更板知己无酸让入啦式笑赞片酱差像提队走嫩才刚午接重串回晚微周值费性桌拍跟块调糕'
# ['cmap']为字符与Unicode编码的映射关系列表
woff_unicode = woff['cmap'].tables[0].ttFont.getGlyphOrder() # 获取603个字符对应的unicode编码
woff_character = ['.notdef', 'x'] + list(woff_str_601) # 添加编号为0、1的两个特殊字符
woff_dict = dict(zip(woff_unicode, woff_character))
最终就能解析得到特定woff文件对应的映射关系字典woff_dict,在步骤2.2的解析过程中参与解析便可将反爬字体置换为常用字体。
2.3.3 附加
由于包含字体文件信息的CSS文件为随机生成,其内容顺序不是固定的,步骤2.3.1的解析过程已经预设四类标签顺序固定,在实际应用中,需要构建更普适的数据结构以正确提取字体文件。