如何实现一个Python爬虫框架

image

这篇文章的题目有点大,但这并不是说我自觉对Python爬虫这块有多大见解,我只不过是想将自己的一些经验付诸于笔,对于如何写一个爬虫框架,我想一步一步地结合具体代码来讲述如何从零开始编写一个自己的爬虫框架

2018年到如今,我花精力比较多的一个开源项目算是Ruia了,这是一个基于Python3.6+的异步爬虫框架,当时也获得一些推荐,比如Github Trending Python语言榜单第二,目前Ruia还在开发中,Star数目不过700+,如果各位有兴趣,欢迎一起开发,来波star我也不会拒绝哈~

什么是爬虫框架

说这个之前,得先说说什么是框架

  • 是实现业界标准的组件规范:比如众所周知的MVC开发规范
  • 提供规范所要求之基础功能的软件产品:比如Django框架就是MVC的开发框架,但它还提供了其他基础功能帮助我们快速开发,比如中间件、认证系统等

框架的关注点在于规范二字,好,我们要写的Python爬虫框架规范是什么?

很简单,爬虫框架就是对爬虫流程规范的实现,不清楚的朋友可以看上一篇文章谈谈对Python爬虫的理解,下面总结一下爬虫流程:

  • 请求&响应
  • 解析
  • 持久化

这三个流程有没有可能以一种优雅的形式串联起来,Ruia目前是这样实现的,请看代码示例:

image

可以看到,Item & Field类结合一起实现了字段的解析提取,Spider类结合Request * Response类实现了对爬虫程序整体的控制,从而可以如同流水线一般编写爬虫,最后返回的item可以根据使用者自身的需求进行持久化,这几行代码,我们就实现了获取目标网页请求、字段解析提取、持久化这三个流程

实现了基本流程规范之后,我们继而就可以考虑一些基础功能,让使用者编写爬虫可以更加轻松,比如:中间件(Ruia里面的Middleware)、提供一些hook让用户编写爬虫更方便(比如ruia-motor)

这些想明白之后,接下来就可以愉快地编写自己心目中的爬虫框架了

如何踏出第一步

首先,我对Ruia爬虫框架的定位很清楚,基于asyncio & aiohttp的一个轻量的、异步爬虫框架,怎么实现呢,我觉得以下几点需要遵守:

  • 轻量级,专注于抓取、解析和良好的API接口
  • 插件化,各个模块耦合程度尽量低,目的是容易编写自定义插件
  • 速度,异步无阻塞框架,需要对速度有一定追求

什么是爬虫框架如今我们已经很清楚了,现在急需要做的就是将流程规范利用Python语言实现出来,怎么实现,分为哪几个模块,可以看如下图示:

image

同时让我们结合上面一节的Ruia代码来从业务逻辑角度看看这几个模块到底是什么意思:

  • Request:请求
  • Response:响应
  • Item & Field:解析提取
  • Spider:爬虫程序的控制中心,将请求、响应、解析、存储结合起来

这四个部分我们可以简单地使用五个类来实现,在开始讲解之前,请先克隆Ruia框架到本地:

# 请确保本地Python环境是3.6+
git clone https://github.com/howie6879/ruia.git
# 安装pipenv
pip install pipenv 
# 安装依赖包
pipenv install --dev

然后用PyCharm打开Ruia项目:

[站外图片上传中...(image-87e8ff-1552612815023)]

选择刚刚pipenv配置好的python解释器:

[图片上传失败...(image-6ca198-1552612815023)]

此时可以完整地看到项目代码:

image

好,环境以及源码准备完毕,接下来将结合代码讲述一个爬虫框架的编写流程

Request & Response

Request类的目的是对aiohttp加一层封装进行模拟请求,功能如下:

  • 封装GET、POST两种请求方式
  • 增加回调机制
  • 自定义重试次数、休眠时间、超时、重试解决方案、请求是否成功验证等功能
  • 将返回的一系列数据封装成Response类返回

接下来就简单了,不过就是实现上述需求,首先,需要实现一个函数来抓取目标url,比如命名为fetch:

import asyncio
import aiohttp
import async_timeout

from typing import Coroutine


class Request:
    # Default config
    REQUEST_CONFIG = {
        'RETRIES': 3,
        'DELAY': 0,
        'TIMEOUT': 10,
        'RETRY_FUNC': Coroutine,
        'VALID': Coroutine
    }

    METHOD = ['GET', 'POST']

    def __init__(self, url, method='GET', request_config=None, request_session=None):
        self.url = url
        self.method = method.upper()
        self.request_config = request_config or self.REQUEST_CONFIG
        self.request_session = request_session

    @property
    def current_request_session(self):
        if self.request_session is None:
            self.request_session = aiohttp.ClientSession()
            self.close_request_session = True
        return self.request_session

    async def fetch(self):
        """Fetch all the information by using aiohttp"""
        if self.request_config.get('DELAY', 0) > 0:
            await asyncio.sleep(self.request_config['DELAY'])

        timeout = self.request_config.get('TIMEOUT', 10)
        async with async_timeout.timeout(timeout):
            resp = await self._make_request()
        try:
            resp_data = await resp.text()
        except UnicodeDecodeError:
            resp_data = await resp.read()
        resp_dict = dict(
            rl=self.url,
            method=self.method,
            encoding=resp.get_encoding(),
            html=resp_data,
            cookies=resp.cookies,
            headers=resp.headers,
            status=resp.status,
            history=resp.history
        )
        await self.request_session.close()
        return type('Response', (), resp_dict)


    async def _make_request(self):
        if self.method == 'GET':
            request_func = self.current_request_session.get(self.url)
        else:
            request_func = self.current_request_session.post(self.url)
        resp = await request_func
        return resp

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    resp = loop.run_until_complete(Request('https://docs.python-ruia.org/').fetch())
    print(resp.status)


实际运行一下,会输出请求状态200,就这样简单封装一下,我们已经有了自己的请求类Request,接下来只需要再完善一下重试机制以及将返回的属性封装一下就基本完成了:

# 重试函数
async def _retry(self):
    if self.retry_times > 0:
        retry_times = self.request_config.get('RETRIES', 3) - self.retry_times + 1
        self.retry_times -= 1
        retry_func = self.request_config.get('RETRY_FUNC')
        if retry_func and iscoroutinefunction(retry_func):
            request_ins = await retry_func(weakref.proxy(self))
            if isinstance(request_ins, Request):
                return await request_ins.fetch()
        return await self.fetch()

最终代码见ruia/request.py即可,接下来就可以利用Request来实际请求一个目标网页,如下:

image

这段代码请求了目标网页https://docs.python-ruia.org/并返回了Response对象,其中Response提供属性介绍如下:

image

Field & Item

实现了对目标网页的请求,接下来就是对目标网页进行字段提取,我觉得ORM的思想很适合用在这里,我们只需要定义一个Item类,类里面每个属性都可以用Field类来定义,然后只需要传入url或者html,执行过后Item类里面 定义的属性会自动被提取出来变成目标字段值

可能说起来比较拗口,下面直接演示一下可能你就明白这样写的好,假设你的需求是获取HackerNews网页的titleurl,可以这样实现:

import asyncio

from ruia import AttrField, TextField, Item


class HackerNewsItem(Item):
    target_item = TextField(css_select='tr.athing')
    title = TextField(css_select='a.storylink')
    url = AttrField(css_select='a.storylink', attr='href')

async def main():
    async for item in HackerNewsItem.get_items(url="https://news.ycombinator.com/"):
        print(item.title, item.url)

if __name__ == '__main__':
     items = asyncio.run(main())

[站外图片上传中...(image-19d70a-1552612815023)]

从输出结果可以看到,titleurl属性已经被赋与实际的目标值,这样写起来是不是很简洁清晰也很明了呢?

来看看怎么实现,Field类的目的是提供多种方式让开发者提取网页字段,比如:

  • XPath
  • CSS Selector
  • RE

所以我们只需要根据需求,定义父类然后再利用不同的提取方式实现子类即可,代码如下:

class BaseField(object):
    """
    BaseField class
    """

    def __init__(self, default: str = '', many: bool = False):
        """
        Init BaseField class
        url: http://lxml.de/index.html
        :param default: default value
        :param many: if there are many fields in one page
        """
        self.default = default
        self.many = many

    def extract(self, *args, **kwargs):
        raise NotImplementedError('extract is not implemented.')


class _LxmlElementField(BaseField):
    pass


class AttrField(_LxmlElementField):
    """
    This field is used to get  attribute.
    """
      pass


class HtmlField(_LxmlElementField):
    """
    This field is used to get raw html data.
    """
    pass


class TextField(_LxmlElementField):
    """
    This field is used to get text.
    """
      pass


class RegexField(BaseField):
    """
    This field is used to get raw html code by regular expression.
    RegexField uses standard library `re` inner, that is to say it has a better performance than _LxmlElementField.
    """
    pass

核心类就是上面的代码,具体实现请看ruia/field.py

接下来继续说Item部分,这部分实际上是对ORM那块的实现,用到的知识点是元类,因为我们需要控制类的创建行为:

class ItemMeta(type):
    """
    Metaclass for an item
    """

    def __new__(cls, name, bases, attrs):
        __fields = dict({(field_name, attrs.pop(field_name))
                         for field_name, object in list(attrs.items())
                         if isinstance(object, BaseField)})
        attrs['__fields'] = __fields
        new_class = type.__new__(cls, name, bases, attrs)
        return new_class


class Item(metaclass=ItemMeta):
    """
    Item class for each item
    """

    def __init__(self):
        self.ignore_item = False
        self.results = {}

这一层弄明白接下来就很简单了,还记得上一篇文章《谈谈对Python爬虫的理解》里面说的四个类型的目标网页么:

  • 单页面单目标
  • 单页面多目标
  • 多页面单目标
  • 多页面多目标

本质来说就是要获取网页的单目标以及多目标(多页面可以放在Spider那块实现),Item类只需要定义两个方法就能实现:

  • get_item():单目标
  • get_items():多目标,需要定义好target_item

具体实现见:ruia/item.py

Spider

Ruia框架中,为什么要有Spider,有以下原因:

  • 真实世界爬虫是多个页面的(或深度或广度),利用Spider可以对这些进行 有效的管理
  • 制定一套爬虫程序的编写标准,可以让开发者容易理解、交流,能迅速产出高质量爬虫程序
  • 自由地定制插件

接下来说说代码实现,Ruia框架的API写法我有参考Scrapy,各个函数之间的联结也是使用回调,但是你也可以直接使用await,可以直接看代码示例:

from ruia import AttrField, TextField, Item, Spider


class HackerNewsItem(Item):
    target_item = TextField(css_select='tr.athing')
    title = TextField(css_select='a.storylink')
    url = AttrField(css_select='a.storylink', attr='href')


class HackerNewsSpider(Spider):
    start_urls = [f'https://news.ycombinator.com/news?p={index}' for index in range(1, 3)]

    async def parse(self, response):
        async for item in HackerNewsItem.get_items(html=response.html):
            yield item


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

使用起来还是挺简洁的,输出如下:

[2019:03:14 10:29:04] INFO  Spider  Spider started!
[2019:03:14 10:29:04] INFO  Spider  Worker started: 4380434912
[2019:03:14 10:29:04] INFO  Spider  Worker started: 4380435048
[2019:03:14 10:29:04] INFO  Request <GET: https://news.ycombinator.com/news?p=1>
[2019:03:14 10:29:04] INFO  Request <GET: https://news.ycombinator.com/news?p=2>
[2019:03:14 10:29:08] INFO  Spider  Stopping spider: Ruia
[2019:03:14 10:29:08] INFO  Spider  Total requests: 2
[2019:03:14 10:29:08] INFO  Spider  Time usage: 0:00:03.426335
[2019:03:14 10:29:08] INFO  Spider  Spider finished!

Spider的核心部分在于对请求URL的请求控制,目前采用的是生产消费者模式来处理,具体函数如下:

image

详细代码,见ruia/spider.py

更多

至此,爬虫框架的核心部分已经实现完毕,基础功能同样一个不落地实现了,接下来要做的就是:

  • 实现更多优雅地功能
  • 实现更多的插件,让生态丰富起来
  • 修BUG

项目地址点击阅读原文或者在github搜索ruia,如果你有兴趣,请参与进来吧!

如果觉得写得不错,点个好看来个star呗~

image
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容