从零开发一个爬虫框架——Tinepeas

2020-05-14 10:40:41 浏览数 (1)

经常写爬虫的同学,肯定对下面这张图片很熟悉:

这是 Scrapy 的数据流图。

Scrapy 是一个非常优秀的爬虫框架,为了向 Scrapy 致敬,也为了让大家更好地理解 Scrapy 的工作原理,我们自己模仿 Scrapy 的数据流,写一个爬虫框架。

这个框架我命名为 Tinepeas,中文名叫做豌豆尖。以表达我对杭州吃不到豌豆尖的遗憾之情和对豌豆尖的想念。

Tinepeas 的数据流如下图所示:

基于 Scrapy 的数据流,我们省略了 Downloader Middleware、Spider Middleware 和 Pipeline,仅保留核心的爬虫定义、调度和网络请求。

Scrapy 基于 Twisted实现异步请求,而Tinepeas使用Asyncio 和 aiohttp 实现异步请求。

运行爬虫

我们先来看一下爬虫代码并运行,看看效果如何:

请求1000个页面,总共耗时不到10秒。

爬虫代码本身的写法,与 Scrapy 如出一辙。Tinepeas 会去调度开发者写的代码,并运行。

组件化与输入输出

Tinepeas 的核心思想是组件化。也就是单独实现各个不同的组件。每个组件之间定义好输入和输出。大家可以看到在数据流图中的DownloaderSpiderScheduler都是组件。他们之间的数据通过Core来进行沟通。而各个组件之间交流的数据,就是Request对象和Response对象。

只要定义好输入和输出,各个组件可以分别由不同的人开发,甚至组件也可以进行替换。例如本文我们使用aiohttp来请求网络。但是只要保持输入和输入对应的 API 不变,你完全可以使用 Pyppeteer 来替换。

数据对象

在数据流图中的RequestResponse都是数据对象。他们的作用,本质上与字典没有什么区别,都是用来存放数据的。只不过,使用类来组织,可以避免发生忘记字典里面有哪些 Key 的尴尬。而且通过 PyCharm 这种集成开发环境来开发的时候,还可以自动补全。

请求 Request

我们来看一下 Request 的代码:

代码语言:javascript复制
from dataclasses import dataclass
from typing import Callable


@dataclass
class Request:
    url: str
    headers: dict = None
    callback: Callable = None
    method: str = 'get'
    meta: dict = None
    dont_filter: bool = False
    encoding: str = 'utf-8'

    def __repr__(self):
        return f'url: {self.url}, callback: {self.callback}'

正如上文所说,本质上,Request类的作用与字典没有什么区别,就是存放数据而已。在这个类里面,我们定义了请求的url(网址)headers(请求头)callback(回调函数)method(请求方式)meta(元数据存放)dont_filter(不要过滤)encoding(编码方式)

其中,dont_filter涉及到的去重功能,我们在下一篇文章中通过中间件来实现。

至于下面的__repr__,是方便在调试的时候,看到请求对象的内容。对主要功能没有什么影响。

响应 Response

响应类Response比纯数据类要多一些东西:

代码语言:javascript复制
import json
from .request import Request
from .selector import Selector
from dataclasses import dataclass


@dataclass
class Response:
    body: str = ''
    status: int = -1
    request: Request = None
    _selector: Selector = None

    def json(self):
        return json.loads(self.body)

    @property
    def selector(self):
        if not self._selector:
            self._selector = Selector(self.body)
        return self._selector

    def xpath(self, xpath_str):
        return self.selector.xpath(xpath_str)

    @property
    def url(self):
        return self.request.url

    @property
    def meta(self):
        return self.request.meta

我们先来看下图中方框框住的部分:

其中的body对应请求 URL 以后返回的内容,如果返回的是 JSON 字符串,那么可以调用response.json()方法直接对 JSON 字符串进行解析。

request对应的是请求对象,也就是上面一小节的 Request 类初始化以后的对象。

代码语言:javascript复制
@property
def url(self):
    return self.request.url

@property
def meta(self):
    return self.request.meta

通过@property装饰器,让这urlmeta变成属性,这样就可以直接使用response.url来获取请求的 URL,而不需要写为response.request.url

xpath方法和selector属性,我们在第二篇文章中再来详细讲解。

有了RequestResponse这两个数据类所初始化的对象进行数据传递,我们就可以开始沟通各个不同的组件了。

调度器 Scheduler

输入与输出

调度器提供两个 API:def schedule(self, request),接收请求参数;def get(self),返回请求对象。

原理解读

调度器的作用看起来非常鸡肋:把请求先放进去,然后再取出来。然后传给下载器下载。调度器一日游到底有什么作用呢?

我们来考虑最常见的情况,把调度器想象成一个列表:

代码语言:javascript复制
scheduler = []
scheduler.append(请求1)
scheduler.append(请求2)
scheduler.append(请求3)
scheduler.append(请求4)

request = scheduler.pop(0)
print('把 request 传给下载器')
request = scheduler.pop(0)
print('把 request 传给下载器')
request = scheduler.pop(0)
print('把 request 传给下载器')
request = scheduler.pop(0)
print('把 request 传给下载器')

这就是一个非常常见的先入先出队列。我直接用列表就可以了,为什么还要写个类来处理它?

但我们考虑另一种情况,假如你一次性可以处理100个请求,现在你还有500个请求在排队。这个时候,你需要优先发起一个请求,应该怎么办?

你觉得是不是可以这样写:schedule.insert(0, 优先请求)?那么问题来了,如果当前排队的请求,他们也要优先处理怎么办?你怎么知道哪些请求需要优先?

显然,我们可以通过给每一个请求设定一个优先级评分,然后对评分进行排序来实现优先发起高优先级的请求。

那么,当涉及到优先级评分的时候,你觉得直接使用列表仍然是最好的选择吗?这个时候显然用一个最大堆会更好,插入以后自动排序。不用每次都做全排序,复杂度大大降低。

关于更多调度器的问题,我们还是留在后面的文章来讲。今天我们就把他作为一个先入先出队列:

0 人点赞