一、选题背景

  为什么要选择此选题呢?

针对爬虫数量日益增长这一现象,通过控制台控制的脚本杂乱无序,本文设计了一种基于可视化页面来管理爬虫脚本的系统。该系统利用Scrapyd,它是一个部署和运行 Scrapy 爬虫的应用,允许使用 HTTP JSON API 部署 Scrapy 项目并控制其爬虫。

二、基于scrapy框架的爬虫系统的设计方案

该系统采用Python作为第一编程语言,后台使用Flask框架,前台使用Vue框架,任务调度、服务器注册、Scrapy执行模块、Scrapy爬虫脚本、本系统是以主服务器为主,从服务器为次,服务器中心的主要功能是,由于大部分的脚本都是在主节点Master上运行,如果遇到需要分布式的爬虫可以在现在服务器上搭建完环境上传完脚本,通过服务器注册中心添加节点后,即可在系统上管理。主节点与其他服务器通信方式采用轮训请求判断服务器是否在线。在服务器节点信息发生变更时通知任务调度模块

任务调度模块负责所有已注册服务器的爬虫任务的创建、更改和删除,以及爬虫任务的配置、执行和调度。通过调度请求将调度信息向各个服务器上的Scrapy执行模块发出调度请求。同时还支持对任务调度日志以及执行日志的监控。

执行器模块负责接收调度模块发出的调度请求并能执行任务的业务逻辑,由于需要用到Scrapy框架的调度,所以每个爬虫项目都必须严格按照Scrapy框架进行开发,开发完成后通过Scrapyd 上传到各个服务器。执行爬虫的原理很简单,其实就是一个 shell 命令。用户在爬虫中输入执行爬虫的 shell 命令,例如scrapy some_spider,Scrapt执行器会读取这个命令,并在 shell 中直接执行。因此,每一次运行爬虫任务就是运行一次shell命令,区别在于需要安装 Python 跟Scrapy。

其中、任务管理中心按划分的功能又可以分为三个小模块:任务管理模块,调度管理模块、日志管理模块。

任务管理:如何执行、调度爬虫抓取任务,以及如何监控任务,包括日志监控等等;

爬虫管理:包括爬虫部署,即将开发好的爬虫部署(打包或复制)到相应的节点上,以及爬虫配置和版本管理;

节点管理:包括节点(服务器/机器)的注册和监控,以及节点之间的通信,如何监控节点性能状况等;

前端应用:包括一个可视化 UI 界面,让用户可通过与其交互,与后台应用进行通信。

三、基于scrapy框架的爬虫系统的实现方案

1. 定时任务模块

该模块主要功能点是爬虫定时任务的查看、修改、删除以及任务的调度,任务的启停。前端UI部分使用VUE。

python系统设计 Python系统设计大作业_python系统设计

 

图3.1  定时任务模块列表图

python系统设计 Python系统设计大作业_服务器_02

 

 

 

图3.2  定时任务模块操作管理图

  该页面展示了关闭调度器,暂停调度器,移除所有任务按钮,爬虫任务列表,其中对应着每个任务的序号、服务器、项目名、定时类型、定时长度、成功运行的次数、最近一次运行的时间&下次运行的时间,修改的时间,调度状态,以及对该任务的操作方法,新增任务作为移动到爬虫列表里的设定定时任务里面。

python系统设计 Python系统设计大作业_json_03

 

图3.3  爬虫列表管理图

爬虫脚本任务的功能主要分为新建、修改、删除和查看功能。在scheduler调度累分别对应了addJob、remove_job、jobDetail的方法。这部分代码作为暴露给外部的API接口的只是调用了scheduler各种的方法,具体代码如下:

def run_job():
    global scheduler
    job_id = request.args.get("job_id")
    job = scheduler.get_job(job_id)
    job_info = get_job_info(job)
    if job_info:
        scheduler.add_job(run_spider, kwargs=job_info)
        message = "运行成功"
        message_type = "success"
    else:
        message = "运行失败"
        message_type = "warning"

    data = {
        "message": message,
        "message_type": message_type

    }

    return jsonify(data)
@scheduler_app.route("/removeJob")
def remove_job():
    global scheduler

    job_id = request.args.get("job_id")

    scheduler.remove_job(job_id)
    return jsonify(
        {
            "message": "任务移除成功"
        }
    )
@scheduler_app.route("/pauseJob")
def pause_job():
    global scheduler

    job_id = request.args.get("job_id")
    scheduler.pause_job(job_id)
    return jsonify(
        {
            "message": "暂停成功",
            "message_type": "warning"
        }
    )
@scheduler_app.route("/resumeJob")
def resume_job():
    global scheduler

    job_id = request.args.get("job_id")
    scheduler.resume_job(job_id)
    return jsonify(
        {
            "message": "继续运行",
            "message_type": "success"
        }
    )

爬虫任务调度是,将配置好的爬虫任务放入调度列表中,按照自定义的触发规则来调度任务。该方法会先获取scheduler类的set_schedule方法数据插入后会将任务插入到调度列表中,并根据此方法的返回值来返回改任务的调度状态具体代码如下:

if trigger == "cron":
            crontabs = parse_crontab(cron)
            if not crontabs:
                return None

            minute, hour, day, month, day_of_week = crontabs

            scheduler.add_job(
                run_spider, kwargs=kwargs, id=job_id, replace_existing=True,
                trigger="cron",
                minute=minute, hour=hour, day=day, month=month, day_of_week=day_of_week

            )

        # 时间间隔方式执行
        elif trigger == "interval":
            try:
                interval = int(interval)
            except Exception:
                return None

            scheduler.add_job(
                run_spider, kwargs=kwargs, id=job_id, replace_existing=True,
                trigger="interval", minutes=interval
            )

        # 执行一次
        elif trigger == "date":
            try:
                run_datetime = datetime.strptime(run_datetime, "%Y-%m-%d %H:%M:%S")
            except Exception:
                return None

            scheduler.add_job(
                run_spider, kwargs=kwargs, id=job_id, replace_existing=True,
                trigger="date", run_date=run_datetime
            )

        # 随机时间间隔执行 用于拟人操作
        elif trigger == "random":
            try:
                randoms = random_time.split("-")
                random_start, random_end = [int(rand.strip()) for rand in randoms]

            except Exception:
                return None

            random_delay = random.randint(random_start, random_end)
            scheduler.add_job(
                run_spider, kwargs=kwargs, id=job_id, replace_existing=True,
                trigger="interval", minutes=random_delay
            )
        else:
            return None
        return job_id

setschedule 方法首先获取了全局的 scheduler 调度器,判断是否是更新还是新增操作,如果是新增操作就通过 hash 生成一个 uuid 当作 jobid ,通过将传入的参数加jobid 封装成一个数组,系统设定定时的类型有四种分别是单次任务、周期任务、间隔任务、随机任务,按传入的参数 trigger 判断定时任务的类型,最后调用 scheduler 的 addjob 方法将任务加入到Scrapy调度中心。

 2. 日志管理模块实现的页面以及代码

日志模块分为日志的查看,清理这两个功能。细分下来日志查看还分为调度模块统计查看和脚本执行过程日志的查看,在Controller中对应的方法是getschedulehistory,将查询到的调度信息以 JSON 的形式返回,前端利用Echart 图标的折线图显示,通过点击任务的调度历史按钮 能够实时的查看某个任务的最新执行日志情况折线图,调度模块调度历史日志展示如图。

python系统设计 Python系统设计大作业_服务器_04

 

 

 

图3.4  调度模块调度历史日志展示图1

python系统设计 Python系统设计大作业_python系统设计_05

 

 

 图3.3  调度模块调度历史日志展示图2

其中从Scrapyd读取调度历史的方法 getschedulehistory 的实现细节如下:

job_id = request.args.get("job_id")
    count = request.args.get("count", "30")
    with scheduler_history.lock:
        result = history.select(job_id, count)
    fmt = "%H:%M"
    now = datetime.now()
    min_time = now
    spider_name = None

    schedule_list = defaultdict(int)

    for row in result:
        if spider_name is None:
            spider_name = row["spider_name"]

        schedule_time = row["schedule_time"]
        if schedule_time < min_time:
            min_time = schedule_time

        t = schedule_time.strftime(fmt)

        schedule_list[t] += 1

    time_list = []
    while min_time <= now:
        time_list.append(min_time.strftime(fmt))
        min_time += timedelta(minutes=1)

    data_list = []
    for time_item in time_list:
        data_list.append(schedule_list.get(time_item, 0))

    data = {
        "title": spider_name,
        "values": data_list,
        "keys": time_list
    }
return jsonify(data)

getschedulehistory先获取了jobid,并设置了要获取的条数,锁定Scrapy阻止线程新增,然后获取该任务下的设定的调度任务的条数。通过实例化一个 dict 对获取的调度任务历史列表进行遍历,判断符合时间段的条件后,拼接成一个list 返回符合 echart 图标的 json 数据返回给前端展示。

通过该任务定时列表的查看日志文件可以获取到最新的爬虫运行日志文件,点击日志目录可以获取最近五条的脚本日志文件,如果想查询脚本运行日志可以从任务列表里面查看。

python系统设计 Python系统设计大作业_服务器_06

图3.6  查看单个日志

日志的清理目前是设置超过3个阈值后采用先进先出的删除策略,还有一种

更优的解决方案是设定每个每个项目日志的大小,然后新增一个定时周期的脚本来清除数据。

3. 服务器注册实现的页面以及代码

服务器模块分为服务器的查看,注册和删除这三个功能。服务器详情页页面如下图所示。

python系统设计 Python系统设计大作业_ide_07

图3.7  服务器列表图

如果开发者想要添加服务器没有将自己的服务器信息填写完整,提示用户请填写完整。服务器列表添加页面。如下图所示。

python系统设计 Python系统设计大作业_python系统设计_08

图3.8  服务器状态图

具体代码如下:

@api_app.route("/addServer", methods=["POST"])
def add_server():
    server_host = request.json.get("server_host", "")
    server_name = request.json.get("server_name", "")
    server_username = request.json.get("server_username", "")
    server_password = request.json.get("server_password", "")

    server_name = server_name.strip()
    server_host = server_host.strip()
    server_username = server_username.strip()
    server_password = server_password.strip()

    # 参数校验,以及服务器校验,
    # 添加的时候不需要校验服务器正确性,允许先添加地址和端口再启动服务
    # if (not all([server_name, server_host])) or (check_server(server_host) is False):
    if not all([server_name, server_host]):
        message = "添加失败,请检查服务器地址是否正确"
        message_type = "warning"
    else:
        user_server_table.insert(
            {
                "server_name": server_name,
                "server_host": server_host,
                "server_username": server_username,
                "server_password": server_password,
            }
        )
        message = "添加成功"
        message_type = "success"

    data = {
        "message": message,
        "message_type": message_type
    }

    return jsonify(data)

3.3  爬虫脚本主体功能的具体实现

3.3.1  爬虫脚本的实现过程

以爬取京东为例,当前爬虫脚本主要有4个模块,数据字段定义(items.py)、数据获取脚本(spider.py)、数据加工程序(piplines.py)以及配置文件(settings.py)。展示系爬虫脚本组成结构:

python系统设计 Python系统设计大作业_ide_09

图3.9  爬虫脚本结构图

数据字段定义程序

Scrapy系统中的item类是主要设置数据字段的类,其组成与字典类型相近。在对网站内容进行爬取时,对象由项目类型定义,并且项目只有一个 Feild 类的对象。对于京东目标网站来说,数据描述模块一共标识了7个对象,如以下代码所示。

class ProductItem(Item):
    # define the fields for your item here like:
    name = scrapy.Field()
    dp = Field()
    title = Field()
    price = Field()
    comment = Field()
    url = Field()
    type = Field()
    Pass

数据获取&解析脚本

在数据爬取的程序中,Scrapy 提供解析的摸板,我们定义了爬虫脚本的开始URL、用于 CSS 或者 XPATH 提取规则以及网页的加载的设置等等。下面我们直接根据下图展示的网页来对字段进行提取。

python系统设计 Python系统设计大作业_json_10

 

图3.10  京东爬取目标页1

 

python系统设计 Python系统设计大作业_ide_11

图3.11  京东爬取目标页2

下面根据 CSS 选择器的抽取数据方法依次对字段进行解析,具体代码如下:

def parse(self, response):
        soup = BeautifulSoup(response.text, 'lxml')
        lis = soup.find_all(name='li', class_="gl-item")
        for li in lis:
            proc_dict = {}
            dp = li.find(name='span', class_="J_im_icon")
            if dp:
                proc_dict['dp'] = dp.get_text().strip()
            else:
                continue
            id = li.attrs['data-sku']
            title = li.find(name='div', class_="p-name p-name-type-2")
            proc_dict['title'] = title.get_text().strip()
            price = li.find(name='strong', class_="J_" + id)
            proc_dict['price'] = price.get_text()
            comment = li.find(name='a', id="J_comment_" + id)
            proc_dict['comment'] = comment.get_text() + '条评论'
            url = 'https://item.jd.com/' + id + '.html'
            proc_dict['url'] = url
            proc_dict['type'] = 'JINGDONG'
            yield proc_dict

数据加工处理程序

在 Pipeline 中定义了数据持久化的方法。本文基于 Twisted 框架实现了异步存储数据的 MysqlPipline,使得从 Spider 中解析出的 item 数据异步插入到 MySQL 数据库中。其核心步骤的伪代码如下:

class MongoPipeline(object):
    def __init__(self, mongo_url, mongo_db, collection):
        self.mongo_url = mongo_url
        self.mongo_db = mongo_db
        self.collection = collection

    @classmethod
    # from_crawler是一个类方法,由 @classmethod标识,是一种依赖注入的方式,它的参数就是crawler
    # 通过crawler我们可以拿到全局配置的每个配置信息,在全局配置settings.py中的配置项都可以取到。
    # 所以这个方法的定义主要是用来获取settings.py中的配置信息
    def from_crawler(cls, crawler):
        return cls(
            mongo_url=crawler.settings.get('MONGO_URL'),
            mongo_db=crawler.settings.get('MONGO_DB'),
            collection=crawler.settings.get('COLLECTION')
        )

    def open_spider(self, spider):
        self.client = pymongo.MongoClient(self.mongo_url)
        self.db = self.client[self.mongo_db]

    def process_item(self, item, spider):
        # name = item.__class__.collection
        name = self.collection
        self.db[name].insert(dict(item))
        return item

    def close_spider(self, spider):
        self.client.close()

配置文件

在抓取京东页面时,Scrapy 提供了辅助配置项来完成爬虫系统的抓取流程,下面将介绍几个主要配置项:

ROBOTSTXT_OBEY:如果为真,爬虫将遵守机器人的协议。它通常设置为real,表示它不遵守robots协议。

COOKIES_ENABLED  :在登录过程中需要决定是否使用cookie。

SCHEDULER_PERSIST  :在 Redis 中保存 Scrapy-Redis 的各个队列,即在暂停爬虫时,不清空请求队列。

数据连接的基本信息:数据库的 IP 地址,数据库端口等信息。本文所实现的京东爬虫采用mongodb进行存储,故设置一下配置:

KEYWORDS = ['phone']

MAX_PAGE = 2

MONGO_URL = 'localhost'

MONGO_DB = 'test'

COLLECTION = 'PhoneItem'

SELENIUM_TIMEOUT = 30

DEFAULT_REQUEST_HEADERS :设置默认的浏览器头部

3.3  数据存储模块的实现

脚本编写完成后将脚本打包上传到系统并运行,就可以在数据库中获取数据来,下图是已经成功抓取并存入mongodb的数据。

python系统设计 Python系统设计大作业_python系统设计_12

 

 图3.13  Mongodb数据存储图

四、总结

本次作业在设计过程中,我学习到了许多的新技术,如Flask框架, Vue框架, Scrapy执行模块、Scrapy爬虫脚本等。同时也收获了许多新的编程思维,在日后进行Python开发时有更多的思路及方法。本次作业发现我对Python的掌握确实犹如沧海一粟,在以后的学习生涯定要更加努力。