经常写爬虫的同学,肯定对下面这张图片很熟悉:
这是 Scrapy 的数据流图。
Scrapy 是一个非常优秀的爬虫框架,为了向 Scrapy 致敬,也为了让大家更好地理解 Scrapy 的工作原理,我们自己模仿 Scrapy 的数据流,写一个爬虫框架。
这个框架我命名为 Tinepeas,中文名叫做豌豆尖。以表达我对杭州吃不到豌豆尖的遗憾之情和对豌豆尖的想念。
Tinepeas 的数据流如下图所示:
基于 Scrapy 的数据流,我们省略了 Downloader Middleware、Spider Middleware 和 Pipeline,仅保留核心的爬虫定义、调度和网络请求。
Scrapy 基于 Twisted实现异步请求,而Tinepeas使用Asyncio 和 aiohttp 实现异步请求。
运行爬虫
我们先来看一下爬虫代码并运行,看看效果如何:
请求1000个页面,总共耗时不到10秒。
爬虫代码本身的写法,与 Scrapy 如出一辙。Tinepeas 会去调度开发者写的代码,并运行。
组件化与输入输出
Tinepeas 的核心思想是组件化。也就是单独实现各个不同的组件。每个组件之间定义好输入和输出。大家可以看到在数据流图中的Downloader
、Spider
、Scheduler
都是组件。他们之间的数据通过Core
来进行沟通。而各个组件之间交流的数据,就是Request
对象和Response
对象。
只要定义好输入和输出,各个组件可以分别由不同的人开发,甚至组件也可以进行替换。例如本文我们使用aiohttp
来请求网络。但是只要保持输入和输入对应的 API 不变,你完全可以使用 Pyppeteer
来替换。
数据对象
在数据流图中的Request
和Response
都是数据对象。他们的作用,本质上与字典没有什么区别,都是用来存放数据的。只不过,使用类来组织,可以避免发生忘记字典里面有哪些 Key 的尴尬。而且通过 PyCharm 这种集成开发环境来开发的时候,还可以自动补全。
请求 Request
我们来看一下 Request
的代码:
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
比纯数据类要多一些东西:
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
类初始化以后的对象。
@property
def url(self):
return self.request.url
@property
def meta(self):
return self.request.meta
通过@property
装饰器,让这url
和meta
变成属性,这样就可以直接使用response.url
来获取请求的 URL,而不需要写为response.request.url
。
xpath
方法和selector
属性,我们在第二篇文章中再来详细讲解。
有了Request
和Response
这两个数据类所初始化的对象进行数据传递,我们就可以开始沟通各个不同的组件了。
调度器 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, 优先请求)
?那么问题来了,如果当前排队的请求,他们也要优先处理怎么办?你怎么知道哪些请求需要更优先?
显然,我们可以通过给每一个请求设定一个优先级评分,然后对评分进行排序来实现优先发起高优先级的请求。
那么,当涉及到优先级评分的时候,你觉得直接使用列表仍然是最好的选择吗?这个时候显然用一个最大堆会更好,插入以后自动排序。不用每次都做全排序,复杂度大大降低。
关于更多调度器的问题,我们还是留在后面的文章来讲。今天我们就把他作为一个先入先出队列: