python实现搜索引擎——构建爬虫系统(二)
阅读原文时间:2021年04月23日阅读:1

python实现搜索引擎——构建爬虫系统(二)

一、实验介绍

前面提到,我们的目标是构建一个基于技术博客的垂直搜索引擎,正所谓路要一步一步走,项目也要一节一节来,本节的目的很简单,就是带你构建搜索引擎的基石——可靠的爬虫系统。
爬虫是文档的重要来源,所以这一节也比较重要,我会从爬虫的基础讲起,爬虫的构成,如何编写爬虫等等,希望大家能跟着文档一步步动手做下去。

1.1 实验知识点

  • 爬虫的基本概念
  • 异步爬虫框架ruia的使用介绍
  • 基于ruia构造异步爬虫系统

1.2 实验环境

  • Python 3.6:文中进入环境指的是ipython
  • MongoDB
  • Xfce 终端

二、开发准备

2.1 搜索引擎中爬虫的重要性

如果谷歌给你的结果只会是空白,你觉得还有使用的必要么?巧妇难为无米之炊,搜索引擎是基于文档数据构建的,所以说,要实现一个搜索引擎,文档数据是必不可少的,清楚了文档的重要性后自然就会明白爬虫的重要性。

互联网上资源丰富,对于我们需要的技术博客文档数据,你会接受手动录入文档么?估计不会,程序员都是善假于物的高手,怎么会做重复劳动的事情。

既然爬虫技术可以轻松地将目标数据(博客文档)收入囊中,而且使用起来简单优雅,我们为什么不拿起爬虫技术,在互联网花园中闲庭信步,然后任意取走想要的文档之花,做一个高贵的园丁呢?

为了照顾没接触过爬虫的小伙伴,本节将详细介绍一下爬虫原理,从爬虫本身出发,其目的就是从互联网上抓取目标资源(模拟请求),但是从爬虫程序的开发者出发,事情可能就会复杂一点,因为抓取目标资源仅仅是第一步,抓取成功后获得的只是原始的HTML,这还远远不够,目标数据还需要等你去提取(目标元素提取),提取完之后就是存储(数据持久化)。
所以,编写一个爬虫程序,可以简化为这三个步骤:

  • 模拟请求网页资源:同步请求可以使用requests异步请求使用aiohttp
  • 从HTML提取目标元素CSS SelectorXPath是目前主流的提取方式。
  • 数据持久化:目标数据提取之后,可以将数据保存到数据库中进行持久化,MySQLMongoDB等。

这三个步骤可以说是爬虫程序的标准,有了标准,自然就有了框架,Python语言的爬虫生态十分丰富,各种框架层出不穷,但万变不离其宗,总体还是这三个步骤的实现。

对于一个爬虫程序来说,阻碍效率提升的原因在哪?答案是I/O读写,Python之父亲自上阵打磨4年的asyncio模块就是为了这个!所以为什么不异步I/O呢?一起拥抱异步吧。.

综上所述,我们达成了统一的目标,需要异步I/O,需要爬虫框架,二者结合起来,我们要的就是异步爬虫框架。

终于,到了本节的主题,介绍一个轻量级异步爬虫框架 ruia,这是我编写的一个异步爬虫框架,在Github取得过Trending榜单第二的成绩,之所以说这个,是因为这样能侧面说明ruia是得到社区的一些认可的,大家可以放心使用,如果你使用过Scrapy,你应该可以很快就能上手使用,ruia的架构如下所示:

  • Spider类一直生产请求URL到异步队列Urls中。
  • Request类对异步队列任务进行消费。
  • 若消费后返回的结果存在回调函数,则继续操作Response的回调函数生产URL到异步队列Urls中,反之通过Item提取数据持久化。

弄清楚流程后,下面我将结合ruia的使用方法来为大家从另一个角度来讲讲我对爬虫的一些理解,演示过程中以豆瓣电影250为目标网页。
爬取任何一个URL,获得其HTML源码后,你要做的其实就只有一件事,那就是对目标数据的提取,对于提取,我将其分为以下两种形式:

  • 单页面单目标:表示网页中目标数据是唯一存在的,比如这部电影的名称,网页里面显示的标题就是我们要的结果,简单粗暴。
  • 单页面多目标:表示网页目标中数据需要循环提取,比如豆瓣电影250第一页中的所有电影名称,可以看到有数十个列表,每个列表里面有个电影名称是我们需要提取的内容。

仔细想想,任意网站的数据爬取其实就是这两种情况,ruiaItem类设计的目的就是简单方便地解决这个问题。

2.1.1 单页面单目标

假设我们的需求是抓取这部电影的名称,首先打开网页右键审查元素,找到电影名称对应的元素位置,如下图所示:

一眼就能看出标题的CSS Selector规则为:#content > h1 > span:nth-child(1)

  • # 的作用是控制对应div的css样式;
  • > 表示子元素选择器(Child selectors);
  • :nth-child(n) 选择器匹配父元素中的第 n 个子元素,元素类型没有限制。n 可以是一个数字,一个关键字,或者一个公式。
第1步:让我们进入环境进行实际操作
# 进入项目根目录
cd ~/Code/monkey

# 实验环境默认已经安装 ruia,注意版本需要为 0.0.2,不然在使用的过程中可能会报错
pipenv install ruia==0.0.2 --skip-lock

# 进入项目虚拟环境
pipenv shell

# 进入ipython环境
ipython

第2步:进入环境后,输入如下代码

import asyncio

from ruia import Item, TextField


class DoubanItem(Item):
    """
    定义爬虫的目标字段
    """
    title = TextField(css_select='#content > h1 > span:nth-child(1)')


#提供待选的目标字段所在的页面
async_func = DoubanItem.get_item(url="https://movie.douban.com/subject/1292052/")

#循环获取上述页面中目标字段的值
item = asyncio.get_event_loop().run_until_complete(async_func)
print(item.title)

如果网络没问题的话,会得到如下输出:

2.1.2 单页面多目标

假设现在的需求是抓取豆瓣电影250第一页中的所有电影名称,你需要提取25个电影名称,因为这个目标页的目标数据是多个item的,所以目标需要循环获取。

对于这个情况,我在Item中限制了一点,当你定义的爬虫需要在某一页面循环获取你的目标时,则需要定义target_item属性(就是截图中的红框)。
对于豆瓣250这个页面,我们的目标是25部电影信息,所以该这样定义:

field

css_select

arget_item(必须)

div.item

title

span.title

在终端输入:ipython,进入环境,输入如下代码:

import asyncio

from ruia import Item, TextField


class DoubanItem(Item):
    """
    定义爬虫的目标字段
    """
    #目标字段所在的基本单位(target_item好像指针)
    target_item = TextField(css_select='div.item')
    #指定基本单位中目标字段位置
    title = TextField(css_select='span.title')

    async def clean_title(self, title):
        """
        对提取的目标数据进行清洗 可选
        :param title: 初步提取的目标数据
        :return:
        """
        if isinstance(title, str):
            return title
        else:
            return ''.join([i.text.strip().replace('\xa0', '') for i in title])


async_func = DoubanItem.get_items(url="https://movie.douban.com/top250")
items = asyncio.get_event_loop().run_until_complete(async_func)
for item in items:
    print(item)

知识点:

如果网络没问题的话,会得到如下输出:

ruia的目的就是让你可以轻松优雅地编写爬虫,从上面的介绍来看,Item本身就是一个可以独立使用的模块,它本质上是一个ORM(对象关系映射_Object Relational Mapping,简称ORM),支持参数输入URL以及HTML进行数据提取,所以其对于一些单页面爬虫的支持非常友好,但是如果你是需要多页面爬取呢?比如需要爬取豆瓣电影250的所有电影名称,这就要求爬虫对每一页电影数据进行循环,如下图:

对于这种情况,ruia提供了Spider类处理这个问题,让我们一起实现这个需求。

在终端输入:ipython,进入环境,输入如下代码:

from ruia import TextField, Item, Request, Spider


class DoubanItem(Item):
    """
    定义爬虫的目标字段
    """
    target_item = TextField(css_select='div.item')
    title = TextField(css_select='span.title')

    async def clean_title(self, title):
        if isinstance(title, str):
            return title
        else:
            return ''.join([i.text.strip().replace('\xa0', '') for i in title])


class DoubanSpider(Spider):
    start_urls = ['https://movie.douban.com/top250']
    concurrency = 10

    async def parse(self, res):
        etree = res.html_etree
        #cssselect中的“.paginator>a”:代表了选择class=paginator的标签,的子元素“a”组成的列表或数组(还没验证),用get得到每个<a>中的‘href’属性。
        pages = ['?start=0&filter='] + [i.get('href') for i in etree.cssselect('.paginator>a')]

        for index, page in enumerate(pages):
            url = self.start_urls[0] + page
            #带yield的函数是一个生成器,而不是一个函数了,这个生成器有一个函数就是next函数
            yield Request(
                url,
                callback=self.parse_item,
                metadata={'index': index},
                request_config=self.request_config
            )

    async def parse_item(self, res):
        items_data = await DoubanItem.get_items(html=res.html)
        res_list = []
        for item in items_data:
            res_list.append(item.title)
        return res_list


if __name__ == '__main__':
    DoubanSpider.start()

参考文章:

如果网络没问题的话,会得到如下输出:


注意运行时间,1s不到,这就是异步的魅力,经过上面的介绍,相信你已经可以使用Item以及Spider来编写异步爬虫了,ruia的核心理念是写少量的代码,获得更快的速度,如果想继续了解ruia的使用方法,可以移步查看官方文档

三、实验步骤

3.1 对目标网页实现爬虫

我们的目标是实现一个针对程序员博客的垂直搜索引擎,所以我们的目标网页自然是一些程序员的博客网站(这里称之为文档源),收集的文档源越多,那么搜索引擎提供的内容就更丰富,所以搜索引擎的丰富度就由诸位大家自由掌控,你可以根据后面实现的爬虫系统自由地拓展文档源来丰富自己的搜索引擎。
为了起到让诸位多练手的作用,本项目不会编写通用爬虫,而是决定针对不同的文档源编写不同的爬虫,在程序员界,阮一峰老师的博客,非常值得一读,而且内容也极其丰富,所以接下来我们可以将这个博客内容爬下来作为搜索引擎用。
第一步,打开目标网页分析元素:

右侧红框的分类链接就是接下来要爬取的内容,写个Item类实现提取分类链接:

class ArchivesItem(Item):
    """
    eg: http://www.ruanyifeng.com/blog/archives.html
    """
    target_item = TextField(css_select='div#beta-inner li.module-list-item')
    href = AttrField(css_select='li.module-list-item>a', attr='href')

第二步,获得博客分类链接后,接下来就是提取每个分类下的博客文章,以第一个分类为例,打开网页:

第一个分类中红框标注的每一篇文章就是我们的最终目标,此时目标字段就是:标题和链接,写个Item类实现如下:

class ArticleListItem(Item):
    """
    eg: http://www.ruanyifeng.com/blog/essays/
    """
    target_item = TextField(css_select='div#alpha-inner li.module-list-item')
    title = TextField(css_select='li.module-list-item>a')
    href = AttrField(css_select='li.module-list-item>a', attr='href')

第三步,Item类实现之后,接下来只要继承Spider类构建爬虫就完成了,首先进入终端:

# 安装对应的ruia中间件第三方包
# 目的是为每次请求自动加上ua头-用户代理(User Agent)
pip install ruia-ua
# 进入python环境
ipython

然后输入代码如下:

from ruia import AttrField, Item, Request, Spider, TextField
from ruia_ua import middleware


class ArchivesItem(Item):
    """
    eg: http://www.ruanyifeng.com/blog/archives.html
    """
    target_item = TextField(css_select='div#beta-inner li.module-list-item')
    href = AttrField(css_select='li.module-list-item>a', attr='href')


class ArticleListItem(Item):
    """
    eg: http://www.ruanyifeng.com/blog/essays/
    """
    target_item = TextField(css_select='div#alpha-inner li.module-list-item')
    title = TextField(css_select='li.module-list-item>a')
    href = AttrField(css_select='li.module-list-item>a', attr='href')


class BlogSpider(Spider):
    """
    针对博客源 http://www.ruanyifeng.com/blog/archives.html 的爬虫
    这里为了模拟ua,引入了一个ruia的第三方扩展
        - ruia-ua: https://github.com/howie6879/ruia-ua
        - pipenv install ruia-ua
        - 此扩展会自动为每一次请求随机添加 User-Agent
    """
    # 设置启动URL
    start_urls = ['http://www.ruanyifeng.com/blog/archives.html']
    # 爬虫模拟请求的配置参数
    request_config = {
        'RETRIES': 3,
        'DELAY': 0,
        'TIMEOUT': 20
    }
    # 请求信号量
    concurrency = 10
    blog_nums = 0

    async def parse(self, res):
        items = await ArchivesItem.get_items(html=res.html)
        for item in items:
            yield Request(
                item.href,
                callback=self.parse_item
            )

    async def parse_item(self, res):
        items = await ArticleListItem.get_items(html=res.html)
        BlogSpider.blog_nums += len(items)


if __name__ == '__main__':
    BlogSpider.start(middleware=middleware)
    print(f"博客总数为:{BlogSpider.blog_nums} 篇")

复制代码到终端,可以看到输出总博客数量(数字不一样很正常,因为博主会更新博客):

[2018-09-27 11:55:49,246]-ruia-INFO  spider : Spider started!
[2018-09-27 11:55:49,250]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/archives.html>
[2018-09-27 11:55:49,784]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/algorithm/>
[2018-09-27 11:55:49,785]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/computer/>
[2018-09-27 11:55:49,785]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/notes/>
[2018-09-27 11:55:49,786]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/literature/>
[2018-09-27 11:55:49,787]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/english/>
[2018-09-27 11:55:49,787]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/opinions/>
[2018-09-27 11:55:49,788]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/misc/>
[2018-09-27 11:55:49,792]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/clipboard/>
[2018-09-27 11:55:49,793]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/translations/>
[2018-09-27 11:55:49,794]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/mjos/>
[2018-09-27 11:55:50,213]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/javascript/>
[2018-09-27 11:55:50,221]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/sci-tech/>
[2018-09-27 11:55:50,247]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/developer/>
[2018-09-27 11:55:50,266]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/startup/>
[2018-09-27 11:55:50,353]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/usenet/>
[2018-09-27 11:55:50,447]-Request-INFO  request: <GET: http://www.ruanyifeng.com/blog/essays/>
[2018-09-27 11:55:52,738]-ruia-INFO  spider : Stopping spider: ruia
[2018-09-27 11:55:52,739]-ruia-INFO  spider : Total requests: 17
[2018-09-27 11:55:52,740]-ruia-INFO  spider : Time usage: 0:00:03.492973
[2018-09-27 11:55:52,740]-ruia-INFO  spider : Spider finished!
博客总数为:1725 篇

上面小节说过,爬虫主要就是三个步骤,请求、提取、入库,对于阮一峰老师的博客,我们的爬虫已经实现地差不多了,但是还缺了一样,那就是将目标持久化到数据库。

3.2 将数据持久化到数据库

在入库之前先启动 MongoDB:

$ sudo service mongodb start
$ mongo
> exit

此时MongoDB的默认host127.0.0.1port27017,所以可以将MongoDB的配置到monkey/config/config.py,操作如下

cd ~/Code/monkey/monkey
mkdir config && cd config
vim config.py

输入代码引入MongoDB的配置:

import os


class Config:
    BASE_DIR = os.path.dirname(os.path.dirname(__file__))
    MONGODB = dict(
        MONGO_HOST=os.getenv('MONGO_HOST', ""),
        MONGO_PORT=int(os.getenv('MONGO_PORT', 27017)),
        MONGO_USERNAME=os.getenv('MONGO_USERNAME', ""),
        MONGO_PASSWORD=os.getenv('MONGO_PASSWORD', ""),
        DATABASE='monkey',
    )

为了方便被其他模块引入,创建文件monkey/config/init.py,输入:

from .config import Config

上一节我们的爬虫程序已经实现了获取每篇文章的标题和URl,这一节主要目的是将数据持久化到MongoDB,数据库字段暂定如下:

字段名

描述

url

页面链接

title

页面标题

html

页面内容

操作MongoDB的第三方包选用的是Motor,先编写一个类建立对MongoDB的连接。

cd ~/Code/monkey/monkey
# 安装motor(实验环境中默认已经安装)
pipenv install motor
mkdir database && cd database
vim motor_base.py

输入:

import asyncio

from motor.motor_asyncio import AsyncIOMotorClient

from monkey.config import Config
from monkey.utils.tools import singleton


@singleton
class MotorBase:
    """
    About motor's doc: https://github.com/mongodb/motor
    """
    _db = {}
    _collection = {}
    MONGODB = Config.MONGODB

    def __init__(self, loop=None):
        self.motor_uri = ''
        self.loop = loop or asyncio.get_event_loop()

    def client(self, db):
        # motor
        self.motor_uri = 'mongodb://{account}{host}:{port}/{database}'.format(
            account='{username}:{password}@'.format(
                username=self.MONGODB['MONGO_USERNAME'],
                password=self.MONGODB['MONGO_PASSWORD']) if self.MONGODB['MONGO_USERNAME'] else '',
            host=self.MONGODB['MONGO_HOST'] if self.MONGODB['MONGO_HOST'] else 'localhost',
            port=self.MONGODB['MONGO_PORT'] if self.MONGODB['MONGO_PORT'] else 27017,
            database=db)
        return AsyncIOMotorClient(self.motor_uri, io_loop=self.loop)

    def get_db(self, db=MONGODB['DATABASE']):
        """
        Get a db instance
        :param db: database name
        :return: the motor db instance
        """
        if db not in self._db:
            self._db[db] = self.client(db)[db]

        return self._db[db]

    def get_collection(self, db_name, collection):
        """
        Get a collection instance
        :param db_name: database name
        :param collection: collection name
        :return: the motor collection instance
        """
        collection_key = db_name + collection
        if collection_key not in self._collection:
            self._collection[collection_key] = self.get_db(db_name)[collection]

        return self._collection[collection_key]

说明:程序中from monkey.utils.tools import singleton这一行的目的是启动单例模式,防止重复初始化MongoDB导致占用过多资源:

cd ~/Code/monkey/monkey
mkdir utils && cd utils
vim tools.py

输入如下代码:

from functools import wraps


def singleton(cls):
    """
    A singleton created by using decorator
    :param cls: cls
    :return: instance
    """
    _instances = {}

    @wraps(cls)
    def instance(*args, **kw):
        if cls not in _instances:
            _instances[cls] = cls(*args, **kw)
        return _instances[cls]

    return instance

顺便加入日志文件:

cd ~/Code/monkey/monkey/utils
vim log.py

输入代码:

import logging

logging_format = "[%(asctime)s] %(process)d-%(levelname)s "
logging_format += "%(module)s::%(funcName)s():l%(lineno)d: "
logging_format += "%(message)s"

logging.basicConfig(
    format=logging_format,
    level=logging.DEBUG
)
logger = logging.getLogger()

现在,万事俱备,只要在上面一节的爬虫代码里面引用并操作数据库进行持久化,我们的第一个爬虫就正式完成了,先在终端创建爬虫文件:

cd ~/Code/monkey/monkey
mkdir -p spider/sources && cd spider/sources
vim ruanyifeng_spider.py

虽然爬虫的规则限定很自由,尽管我们是在学习做一个项目,但是也不能写一个毫无原则,毫无限制的爬虫,我们需要让自己的爬虫对目标网站不会造成太大的干扰,更不能让该博客的服务器压力过大造成宕机或者造成自己ip被封禁,为了保证爬虫的友好性,可以设定随机休眠、并且伪造一个User-Agent,输入如下代码到文件中:

import random
import sys

from ruia import AttrField, Item, Request, Spider, TextField
from ruia_ua import middleware

sys.path.append('./')

from monkey.database.motor_base import MotorBase


class ArchivesItem(Item):
    """
    eg: http://www.ruanyifeng.com/blog/archives.html
    """
    target_item = TextField(css_select='div#beta-inner li.module-list-item')
    href = AttrField(css_select='li.module-list-item>a', attr='href')


class ArticleListItem(Item):
    """
    eg: http://www.ruanyifeng.com/blog/essays/
    """
    target_item = TextField(css_select='div#alpha-inner li.module-list-item')
    title = TextField(css_select='li.module-list-item>a')
    href = AttrField(css_select='li.module-list-item>a', attr='href')


class BlogSpider(Spider):
    """
    针对博客源 http://www.ruanyifeng.com/blog/archives.html 的爬虫
    这里为了模拟ua,引入了一个ruia的第三方扩展
        - ruia-ua: https://github.com/ruia-plugins/ruia-ua
        - pipenv install ruia-ua
        - 此扩展会自动为每一次请求随机添加 User-Agent
    """
    # 设置启动URL
    start_urls = ['http://www.ruanyifeng.com/blog/archives.html']
    # 爬虫模拟请求的配置参数
    request_config = {
        'RETRIES': 3,
        'DELAY': 0,
        'TIMEOUT': 20
    }
    # 请求信号量
    concurrency = 10
    blog_nums = 0

    async def parse(self, res):
        items = await ArchivesItem.get_items(html=res.html)
        self.mongo_db = MotorBase(loop=self.loop).get_db()
        for item in items:
            # 随机休眠
            self.request_config['DELAY'] = random.randint(5, 10)
            yield Request(
                item.href,
                callback=self.parse_item,
                request_config=self.request_config
            )

    async def parse_item(self, res):
        items = await ArticleListItem.get_items(html=res.html)
        for item in items:
            # 已经抓取的链接不再请求
            is_exist = await self.mongo_db.source_docs.find_one({'url': item.href})
            if not is_exist:
                # 随机休眠
                self.request_config['DELAY'] = random.randint(5, 10)
                yield Request(
                    item.href,
                    callback=self.save,
                    metadata={'title': item.title},
                    request_config=self.request_config
                )

    async def save(self, res):
        # 好像有两个url一样 原本的博客总数1725 在入库后变成了1723
        data = {
            'url': res.url,
            'title': res.metadata['title'],
            'html': res.html
        }

        try:
            await self.mongo_db.source_docs.update_one({
                'url': data['url']},
                {'$set': data},
                upsert=True)
        except Exception as e:
            self.logger.exception(e)


def main():
    BlogSpider.start(middleware=middleware)

if __name__ == '__main__':
    main()

在终端运行爬虫代码:

cd ~/Code/monkey
# 运行前请进入项目虚拟环境:pipenv shell
python monkey/spider/sources/ruanyifeng_spider.py

诸位读者无需让程序实实在在地跑完,因为这几千篇文章跑完对实验楼的服务器压力还是比较大的,而且说不好还会造成实验楼的ip被封,所以各位读者有两种选择:

  • 跑通程序即可,然后使用服务器上默认的数据。

  • 复制代码到本地跑。
    爬虫不易,诸位且爬且珍惜,MongoDB中数据结构如下:

    这里提供了完整的数据可以下载后导入:

    wget http://labfile.oss.aliyuncs.com/courses/1196/monkey_source_docs_json.dat
    mongoimport -d monkey -c source_docs monkey_source_docs_json.dat

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章