1

1.数据采集工具介绍

现在的大多数动态网站,都是由浏览器端通过js发起ajax请求,拿到数据后再渲染完成页面展示。这种情况下采集数据,通过脚本发起http的get请求,拿到DOM文档页面后再解析提取有用数据的方法是行不通的。然后又有人会想到通过F12打开浏览器控制台分析服务端api,再模拟请求相应的api来拿到我们想要的数据,这种思路在一些情况下可行,但是很多大型网站都会采取一些反爬策略,出于安全性考虑,往往对接口增加了安全验证,比如只有设置了相关的header和cookie,才能对页面进行请求;还有的对请求来源也做了限制等等,这个时候通过这种方式采集数据就更加困难了。我们还有其他有效的方法吗?当然,python做爬虫非常的简单,我们先来了解一下Selenium和Selectors,然后通过爬取美团网上商家信息的例子总结一下数据采集的一些技巧:

  • Selenium 是一个开源测试框架,用来对web应用(比如网站)做自动化测试用的,因为它可以驱动浏览器,诸如Chrome,Firefox,IE等,所以可以较为真实的模拟人自动去点击网站的各个按钮,翻页,填写表单等,我们使用python驱动Selenium的webdriver,可以驱动浏览器,直接拿到的就是渲染好的DOM文档,大量节省了时间。
  • Selectors是Scrapy(Python的一套爬虫框架)提取数据的一套机制。被称作选择器,可以通过特定的 XPath 或者 CSS 表达式来“选择” HTML文件中的某个部分。用它来分析提取DOM文档有效数据非常的方便。并且XPath是W3C标准,所以使用Selectors提取数据的方法是通用的。

2.页面抓取数据分析和数据表创建

我以家附近朝阳大悦城中的一家美食店为例进行数据采集,网址是:

https://www.meituan.com/meishi/40453459/

源码地址

2.1 抓取数据

我们要抓取的第一部分数据是商家的基本信息,包括商家名称、地址、电话、营业时间,分析多个美食类商家我们可知,这些商家的web界面在布局上基本是一致的,所以我们的爬虫可以写的比较通用。为了防止对商家数据的重复抓取,我们将商家的网址信息也存储到数据表中。
图片描述
第二部分要抓取的数据是美食店的招牌菜,每个店铺基本都有自己的特色菜,我们将这些数据也保存下来,用另外的一张数据表存储。
图片描述
最后一部分我们要抓取的数据是用户的评论,这部分数据对我们来说是很有价值的,将来我们可以通过对这部分数据的分析,提取更多关于商家的信息。我们要抓取的这部分信息有:评论者昵称、星级、评论内容、评论时间,如果有图片,我们也要将图片的地址以列表的形式存下来。
图片描述

2.2 创建数据表

我们存储数据使用的数据库是Mysql,Python有相关的ORM,项目中我们使用peewee。但是在建立数据表时建议采用原生的sql,这样我们能灵活的控制字段属性,设置引擎和字符编码格式等。使用Python的ORM也可以达到效果,但是ORM是对数据库层的封装,像sqlite、sqlserver数据库和Mysql还是有些许差别的,使用ORM只能使用这些数据库共有的部分。下面是存储数据需要用到的数据表sql:

CREATE TABLE `merchant` (  #商家表
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL COMMENT '商家名称',
  `address` varchar(255) NOT NULL COMMENT '地址',
  `website_address` varchar(255) NOT NULL  COMMENT '网址',
  `website_address_hash` varchar(32) NOT NULL COMMENT '网址hash',
  `mobile` varchar(32) NOT NULL COMMENT '电话',
  `business_hours` varchar(255) NOT NULL COMMENT '营业时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4;

CREATE TABLE `recommended_dish` (   #推荐菜表
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `merchant_id` int(11) NOT NULL COMMENT '商家id',
  `name` varchar(255) NOT NULL COMMENT '推荐菜名称',
  PRIMARY KEY (`id`),
  KEY `recommended_dish_merchant_id` (`merchant_id`),
  CONSTRAINT `recommended_dish_ibfk_1` FOREIGN KEY (`merchant_id`) REFERENCES `merchant` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=309 DEFAULT CHARSET=utf8mb4;

CREATE TABLE `evaluate` (   #评论表
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `merchant_id` int(11) NOT NULL COMMENT '商家id',
  `user_name` varchar(255) DEFAULT '' COMMENT '评论人昵称',
  `evaluate_time` datetime NOT NULL COMMENT '评论时间',
  `content` varchar(10000) DEFAULT '' COMMENT '评论内容',
  `star` tinyint(4) DEFAULT '0' COMMENT '星级',
  `image_list` varchar(1000) DEFAULT '' COMMENT '图片列表',
  PRIMARY KEY (`id`),
  KEY `evaluate_merchant_id` (`merchant_id`),
  CONSTRAINT `evaluate_ibfk_1` FOREIGN KEY (`merchant_id`) REFERENCES `merchant` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8427 DEFAULT CHARSET=utf8mb4;

相应的我们也可以使用Python的ORM创建管理数据表,后边具体分析到代码时会讲到peewee对mysql数据库的一些常用操做,比如查询数据,插入数据库数据并返回id;批量插入数据库等,读者可搜集相关资料系统学习。
meituan_spider/models.py代码:

from peewee import *

# 连接数据库
db = MySQLDatabase("meituan_spider", host="127.0.0.1", port=3306, user="root", password="root", charset="utf8")


class BaseModel(Model):
    class Meta:
        database = db


# 商家表,用来存放商家信息
class Merchant(BaseModel):
    id = AutoField(primary_key=True, verbose_name="商家id")
    name = CharField(max_length=255, verbose_name="商家名称")
    address = CharField(max_length=255, verbose_name="商家地址")
    website_address = CharField(max_length=255, verbose_name="网络地址")
    website_address_hash = CharField(max_length=32, verbose_name="网络地址的md5值,为了快速索引")
    mobile = CharField(max_length=32, verbose_name="商家电话")
    business_hours = CharField(max_length=255, verbose_name="营业时间")


# 商家推荐菜表,存放菜品的推荐信息
class Recommended_dish(BaseModel):
    merchant_id = ForeignKeyField(Merchant, verbose_name="商家外键")
    name = CharField(max_length=255, verbose_name="推荐菜名称")


# 用户评价表,存放用户的评论信息
class Evaluate(BaseModel):
    id = CharField(primary_key=True)
    merchant_id = ForeignKeyField(Merchant, verbose_name="商家外键")
    user_name = CharField(verbose_name="用户名")
    evaluate_time = DateTimeField(verbose_name="评价时间")
    content = TextField(default="", verbose_name="评论内容")
    star = IntegerField(default=0, verbose_name="评分")
    image_list = TextField(default="", verbose_name="图片")


if __name__ == "__main__":
    db.create_tables([Merchant, Recommended_dish, Evaluate])

3.代码实现和详解

代码比较简单,但是让代码运行起来,需要安装前边提到的工具包:selenium、scrapy,另外使用peewee也需要安装,这些包都可以通过pip进行安装;另外selenium驱动浏览器还需要安装相应的driver,因为我本地使用的是chrome浏览器,所以我下载了相关版本的chromedriver,这个后边会使用到。请读者自行查阅python操作selenium需要做的准备工作,先手动搭建好相关环境。接下来详细分析代码;源代码如下:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import NoSuchElementException

from scrapy import Selector
from models import *

import hashlib
import os
import re
import time
import json

chrome_options = Options()

# 设置headless模式,这种方式下无启动界面,能够加速程序的运行
# chrome_options.add_argument("--headless")
# 禁用gpu防止渲染图片
chrome_options.add_argument('disable-gpu')
# 设置不加载图片
chrome_options.add_argument('blink-settings=imagesEnabled=false')


# 通过页面展示的像素数计算星级
def star_num(num):
    numbers = {
        "16.8": 1,
        "33.6": 2,
        "50.4": 3,
        "67.2": 4,
        "84": 5
    }

    return numbers.get(num, 0)


# 解析商家内容
def parse(merchant_id):
    weblink = "https://www.meituan.com/meishi/{}/".format(merchant_id)
    # 启动selenium
    browser = webdriver.Chrome(executable_path="/Users/guozhaoran/python/tools/chromedriver", options=chrome_options)
    browser.get(weblink)
    # 不重复爬取数据
    hash_weblink = hashlib.md5(weblink.encode(encoding='utf-8')).hexdigest()
    existed = Merchant.select().where(Merchant.website_address_hash == hash_weblink)
    if (existed):
        print("数据已经爬取")
        os._exit(0)
    time.sleep(2)
    # print(browser.page_source)  #获取到网页渲染后的内容
    sel = Selector(text=browser.page_source)

    # 提取商家的基本信息
    # 商家名称
    name = "".join(sel.xpath("//div[@id='app']//div[@class='d-left']//div[@class='name']/text()").extract()).strip()
    detail = sel.xpath("//div[@id='app']//div[@class='d-left']//div[@class='address']//p/text()").extract()
    address = "".join(detail[1].strip())
    mobile = "".join(detail[3].strip())
    business_hours = "".join(detail[5].strip())
    # 保存商家信息
    merchant_id = Merchant.insert(name=name, address=address, website_address=weblink,
                                  website_address_hash=hash_weblink, mobile=mobile, business_hours=business_hours
                                  ).execute()

    # 获取推荐菜信息
    recommended_dish_list = sel.xpath(
        "//div[@id='app']//div[@class='recommend']//div[@class='list clear']//span/text()").extract()

    # 遍历获取到的数据,批量插入数据库
    dish_data = [{
        'merchant_id': merchant_id,
        'name': i
    } for i in recommended_dish_list]

    Recommended_dish.insert_many(dish_data).execute()

    # 也可以遍历list,一条条插入数据库
    # for dish in recommended_dish_list:
    #     Recommended_dish.create(merchant_id=merchant_id, name=dish)

    # 查看链接一共有多少页的评论
    page_num = 0
    try:
        page_num = sel.xpath(
            "//div[@id='app']//div[@class='mt-pagination']//ul[@class='pagination clear']//li[last()-1]//span/text()").extract_first()
        page_num = int("".join(page_num).strip())
        # page_num = int(page_num)
    except NoSuchElementException as e:
        print("改商家没有用户评论信息")
        os._exit(0)

    # 当有用户评论数据,每页每页的读取用户数据
    if (page_num):
        i = 1
        number_pattern = re.compile(r"\d+\.?\d*")
        chinese_pattern = re.compile(u"[\u4e00-\u9fa5]+")
        illegal_str = re.compile(u'[^0-9a-zA-Z\u4e00-\u9fa5.,,。?“”]+', re.UNICODE)
        while (i <= page_num):
            # 获取评论区元素
            all_evalutes = sel.xpath(
                "//div[@id='app']//div[@class='comment']//div[@class='com-cont']//div[2]//div[@class='list clear']")
            for item in all_evalutes:
                # 获取用户昵称
                user_name = item.xpath(".//div[@class='info']//div[@class='name']/text()").extract()[0]
                # 获取用户评价星级
                star = item.xpath(
                    ".//div[@class='info']//div[@class='source']//div[@class='star-cont']//ul[@class='stars-ul stars-light']/@style").extract_first()
                starContent = "".join(star).strip()
                starPx = number_pattern.search(starContent).group()
                starNum = star_num(starPx)
                # 获取评论时间
                comment_time = "".join(
                    item.xpath(".//div[@class='info']//div[@class='date']//span/text()").extract_first()).strip()
                evaluate_time = chinese_pattern.sub('-', comment_time, 3)[:-1] + ' 00:00:00'
                # 获取评论内容
                comment_content = "".join(
                    item.xpath(".//div[@class='info']//div[@class='desc']/text()").extract_first()).strip()
                comment_filter_content = illegal_str.sub("", comment_content)
                # 如果有图片,获取图片
                image_container = item.xpath(
                    ".//div[@class='noShowBigImg']//div[@class='imgs-content']//div[contains(@class, 'thumbnail')]//img/@src").extract()
                image_list = json.dumps(image_container)

                Evaluate.insert(merchant_id=merchant_id, user_name=user_name, evaluate_time=evaluate_time,
                                content=comment_filter_content, star=starNum, image_list=image_list).execute()
            i = i + 1
            if (i < page_num):
                next_page_ele = browser.find_element_by_xpath(
                    "//div[@id='app']//div[@class='mt-pagination']//span[@class='iconfont icon-btn_right']")
                next_page_ele.click()
                time.sleep(10)
                sel = Selector(text=browser.page_source)


if __name__ == "__main__":
    parse("5451106")

3.1 启动webdriver并设置优化参数

为了让爬虫更加通用,我们的解析函数通过接收商家"参数id"来摘取不同商家的网页内容。selenium通过webdriver驱动web浏览器:

 weblink = "https://www.meituan.com/meishi/{}/".format(merchant_id)
    # 启动selenium
    browser = webdriver.Chrome(executable_path="/Users/guozhaoran/python/tools/chromedriver", options=chrome_options)
    browser.get(weblink)

其中executable_path就是之前我们下载好的相关版本的chromedriver可执行文件,另外selenium启动web浏览器之前还可以设置一些参数:

chrome_options = Options()

# 设置headless模式,这种方式下无启动界面,能够加速程序的运行
# chrome_options.add_argument("--headless")
# 禁用gpu防止渲染图片
chrome_options.add_argument('disable-gpu')
# 设置不加载图片
chrome_options.add_argument('blink-settings=imagesEnabled=false')

设置--headless可以让chrome不启动前台界面运行,有点类似于守护进程,不过在调试代码的过程中我们可以不设置这个参数,这样就能看到程序对浏览器中的网页具体进行了哪些操作。另外我们还可以通过disable-gpu、blink-settings=imagesEnabled=false使浏览器解析网页过程中不加载图片来提高浏览器渲染网页的速度;因为我们数据中存储的图片数据也只是路径而已。selenium做爬虫的一个缺点是效率比较低,爬取速度慢,但是通过设置这些优化参数,也是可以极大提升爬虫抓取速度的。

3.2 提取商家的基本信息

前边提到过,为了不重复爬取数据,我们会对要抓取的商家进行hash校验:

# 不重复爬取数据
    hash_weblink = hashlib.md5(weblink.encode(encoding='utf-8')).hexdigest()
    existed = Merchant.select().where(Merchant.website_address_hash == hash_weblink)
    if (existed):
        print("数据已经爬取")
        os._exit(0)

如果商家数据没有被爬取过,我们就获取到网页数据进行解析:

 time.sleep(2)
    # print(browser.page_source)  #获取到网页渲染后的内容
    sel = Selector(text=browser.page_source)

sleep两秒是因为browser对象解析网页需要时间,不过这个时间一般会很快,这里是为了使程序更加稳妥;之后构造一个选择器对页面数据进行解析:

 # 提取商家的基本信息
    # 商家名称
    name = "".join(sel.xpath("//div[@id='app']//div[@class='d-left']//div[@class='name']/text()").extract()).strip()
    detail = sel.xpath("//div[@id='app']//div[@class='d-left']//div[@class='address']//p/text()").extract()
    address = "".join(detail[1].strip())
    mobile = "".join(detail[3].strip())
    business_hours = "".join(detail[5].strip())
    # 保存商家信息
    merchant_id = Merchant.insert(name=name, address=address, website_address=weblink,
                                  website_address_hash=hash_weblink, mobile=mobile, business_hours=business_hours
                                  ).execute()

解析商家基本信息是通过xpath语法定位到相关元素然后提取文本信息,为了保证提取的数据都是不为空的字符串,进行了字符串拼接;最后将解析到的数据插入到商家数据表,peewee的insert方法返回了主键id,在后边采集数据入库时会使用到。

3.3 提取商家特色菜信息

提取商家特色菜信息逻辑比较简单,提取出来的数据返回一个list,python解析数据类型非常的方便,不过数据入库时有不同的方案,可以批量插入也可以循环遍历列表插入,这里我们采用批量插入。这样效率会更高。

# 获取推荐菜信息
    recommended_dish_list = sel.xpath(
        "//div[@id='app']//div[@class='recommend']//div[@class='list clear']//span/text()").extract()

    # 遍历获取到的数据,批量插入数据库
    dish_data = [{
        'merchant_id': merchant_id,
        'name': i
    } for i in recommended_dish_list]

    Recommended_dish.insert_many(dish_data).execute()

    # 也可以遍历list,一条条插入数据库
    # for dish in recommended_dish_list:
    #     Recommended_dish.create(merchant_id=merchant_id, name=dish)

3.4 分页提取用户评论信息

用户信息的提取是数据抓取中最难的部分了,基本思路就是我们首先查看有多少页的用户评论,然后再一页一页的解析用户评论信息。期间我们可以通过selenium模拟浏览器的点击事件进行翻页,入库的时候还要注意对文本进行清洗,因为评论中很多的表情字符是不符合数据表字段设计的编码规范的,另外点击了下一页之后,程序一定要sleep一段时间,因为网站的数据发生了更新,要进行页面数据的重新获取。我们先来看看如何获取一共有多少页的用户评论数据,网站的分页图如下:
图片描述
这里我们重点关注两个按钮,一个是下一页,另一个是最后一页的数字,这是我们想要的信息,不过有些商家可能没有相关的用户评论,页面上也没有相关的元素,程序还是要做一下兼容性处理的:

 # 查看链接一共有多少页的评论
    page_num = 0
    try:
        page_num = sel.xpath(
            "//div[@id='app']//div[@class='mt-pagination']//ul[@class='pagination clear']//li[last()-1]//span/text()").extract_first()
        page_num = int("".join(page_num).strip())
        # page_num = int(page_num)
    except NoSuchElementException as e:
        print("改商家没有用户评论信息")
        os._exit(0)

接下来就是像获取商场特色菜一样获取一条条的评论数据了,只是过程比较繁琐而已,我们的基本思路就是这样:

    if (page_num):
        i = 1
          ... 
        while (i <= page_num):
           ...
            i = i + 1
            if (i < page_num):
                next_page_ele = browser.find_element_by_xpath(
                    "//div[@id='app']//div[@class='mt-pagination']//span[@class='iconfont icon-btn_right']")
                next_page_ele.click()
                time.sleep(10)
                sel = Selector(text=browser.page_source)

我们判断程序解析是否到了最后一页,如果没有,通过模拟点击下一页获得新页面,程序sleep是为了给浏览器解析新页面数据留下时间。详细的解析过程我们挑几个重点说一下:

  • 获取用户评论星级并不是直接获取到的,而是通过获取到星级评级元素的css宽度,通过函数计算得到的:
# 通过页面展示的像素数计算星级
def star_num(num):
    numbers = {
        "16.8": 1,
        "33.6": 2,
        "50.4": 3,
        "67.2": 4,
        "84": 5
    }

    return numbers.get(num, 0)
    
    ...
    # 获取用户评价星级
                star = item.xpath(
                    ".//div[@class='info']//div[@class='source']//div[@class='star-cont']//ul[@class='stars-ul stars-light']/@style").extract_first()
                starContent = "".join(star).strip()
                starPx = number_pattern.search(starContent).group()
                starNum = star_num(starPx)
  • 用户的评论内容可能含有非法字符,程序通过正则表式来过滤,正则表达式一般使用python中的re模块,预先编译能提高性能,另外这些操作要放在while,for这些循环外边:
number_pattern = re.compile(r"\d+\.?\d*")
        chinese_pattern = re.compile(u"[\u4e00-\u9fa5]+")
        illegal_str = re.compile(u'[^0-9a-zA-Z\u4e00-\u9fa5.,,。?“”]+', re.UNICODE)
         while (i <= page_num):
         ...
        comment_content = "".join(
                    item.xpath(".//div[@class='info']//div[@class='desc']/text()").extract_first()).strip()
                comment_filter_content = illegal_str.sub("", comment_content)
  • 用户评论时图片可能有多张,我们只获得图片路径,以json压缩的形式保存到数据表字段中:
 image_container = item.xpath(
                    ".//div[@class='noShowBigImg']//div[@class='imgs-content']//div[contains(@class, 'thumbnail')]//img/@src").extract()
                image_list = json.dumps(image_container)

4.反思总结

下边是程序运行过程中数据抓取的截图:
图片描述
图片描述
程序的思路很简洁,真实的企业应用中,可能会更多的考虑爬虫的效率和稳定性。一般linux服务器下程序发生错误,都会记录有相关的日志,selenium也只能是无界面的运行,程序中没有用到太多的高级特性,其实一个爬虫架构中要包含的技术点有很多,比如多线程的数据爬取,还有针对验证码反爬的验证(本示例中第一次打开美团页面也需要验证,我手动处理了一次)等等,这里算是起一个抛砖引玉的目的吧。不过程序中使用到的文本处理技巧、数据分析提取等都是爬虫中经常会使用到的,很高兴在这里和大家一块分享。


郭兆冉
121 声望2 粉丝

许我三千笔墨,绘你一世倾城!