如何实现一个Python爬虫框架(微框架+源码解析)_框架源代码爬虫-程序员宅基地

如何实现一个Python爬虫框架

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

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

什么是爬虫框架

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

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

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

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

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

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

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

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

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

如何踏出第一步

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

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

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

同时让我们结合上面一节的 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 项目:

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

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

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

Request & Response

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

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 = [<span class="hljs-string">'GET'</span>, <span class="hljs-string">'POST'</span>]

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span><span class="hljs-params">(self, url, method=<span class="hljs-string">'GET'</span>, request_config=None, request_session=None)</span>:</span>
    self.url = url
    self.method = method.upper()
    self.request_config = request_config <span class="hljs-keyword">or</span> 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

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">fetch</span><span class="hljs-params">(self)</span>:</span>
    <span class="hljs-string">"""Fetch all the information by using aiohttp"""</span>
    <span class="hljs-keyword">if</span> self.request_config.get(<span class="hljs-string">'DELAY'</span>, <span class="hljs-number">0</span>) &gt; <span class="hljs-number">0</span>:
        <span class="hljs-keyword">await</span> asyncio.sleep(self.request_config[<span class="hljs-string">'DELAY'</span>])

    timeout = self.request_config.get(<span class="hljs-string">'TIMEOUT'</span>, <span class="hljs-number">10</span>)
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">with</span> async_timeout.timeout(timeout):
        resp = <span class="hljs-keyword">await</span> self._make_request()
    <span class="hljs-keyword">try</span>:
        resp_data = <span class="hljs-keyword">await</span> resp.text()
    <span class="hljs-keyword">except</span> UnicodeDecodeError:
        resp_data = <span class="hljs-keyword">await</span> 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
    )
    <span class="hljs-keyword">await</span> self.request_session.close()
    <span class="hljs-keyword">return</span> type(<span class="hljs-string">'Response'</span>, (), resp_dict)


<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">_make_request</span><span class="hljs-params">(self)</span>:</span>
    <span class="hljs-keyword">if</span> self.method == <span class="hljs-string">'GET'</span>:
        request_func = self.current_request_session.get(self.url)
    <span class="hljs-keyword">else</span>:
        request_func = self.current_request_session.post(self.url)
    resp = <span class="hljs-keyword">await</span> request_func
    <span class="hljs-keyword">return</span> 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 来实际请求一个目标网页,如下:

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

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())

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

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

  • XPath
  • CSS Selector
  • RE

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

class BaseField(object):
    """
    BaseField class
    """
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span><span class="hljs-params">(self, default: str = <span class="hljs-string">''</span>, many: bool = False)</span>:</span>
    <span class="hljs-string">"""
    Init BaseField class
    url: http://lxml.de/index.html
    :param default: default value
    :param many: if there are many fields in one page
    """</span>
    self.default = default
    self.many = many

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">extract</span><span class="hljs-params">(self, *args, **kwargs)</span>:</span>
    <span class="hljs-keyword">raise</span> NotImplementedError(<span class="hljs-string">'extract is not implemented.'</span>)

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
    """
<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__new__</span><span class="hljs-params">(cls, name, bases, attrs)</span>:</span>
    __fields = dict({(field_name, attrs.pop(field_name))
                     <span class="hljs-keyword">for</span> field_name, object <span class="hljs-keyword">in</span> list(attrs.items())
                     <span class="hljs-keyword">if</span> isinstance(object, BaseField)})
    attrs[<span class="hljs-string">'__fields'</span>] = __fields
    new_class = type.__new__(cls, name, bases, attrs)
    <span class="hljs-keyword">return</span> new_class

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

<span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">__init__</span><span class="hljs-params">(self)</span>:</span>
    self.ignore_item = <span class="hljs-keyword">False</span>
    self.results = {}</pre> 

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

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

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

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 = [fhttps://news.ycombinator.com/news?p={index} for index in range(1, 3)]

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">def</span> <span class="hljs-title">parse</span><span class="hljs-params">(self, response)</span>:</span>
    <span class="hljs-keyword">async</span> <span class="hljs-keyword">for</span> item <span class="hljs-keyword">in</span> HackerNewsItem.get_items(html=response.html):
        <span class="hljs-keyword">yield</span> 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 的请求控制,目前采用的是生产消费者模式来处理,具体函数如下:

详细代码,见 ruia/spider.py

更多

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

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

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

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

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_42677001/article/details/96111703

智能推荐

攻防世界_难度8_happy_puzzle_攻防世界困难模式攻略图文-程序员宅基地

文章浏览阅读645次。这个肯定是末尾的IDAT了,因为IDAT必须要满了才会开始一下个IDAT,这个明显就是末尾的IDAT了。,对应下面的create_head()代码。,对应下面的create_tail()代码。不要考虑爆破,我已经试了一下,太多情况了。题目来源:UNCTF。_攻防世界困难模式攻略图文

达梦数据库的导出(备份)、导入_达梦数据库导入导出-程序员宅基地

文章浏览阅读2.9k次,点赞3次,收藏10次。偶尔会用到,记录、分享。1. 数据库导出1.1 切换到dmdba用户su - dmdba1.2 进入达梦数据库安装路径的bin目录,执行导库操作  导出语句:./dexp cwy_init/[email protected]:5236 file=cwy_init.dmp log=cwy_init_exp.log 注释:   cwy_init/init_123..._达梦数据库导入导出

js引入kindeditor富文本编辑器的使用_kindeditor.js-程序员宅基地

文章浏览阅读1.9k次。1. 在官网上下载KindEditor文件,可以删掉不需要要到的jsp,asp,asp.net和php文件夹。接着把文件夹放到项目文件目录下。2. 修改html文件,在页面引入js文件:<script type="text/javascript" src="./kindeditor/kindeditor-all.js"></script><script type="text/javascript" src="./kindeditor/lang/zh-CN.js"_kindeditor.js

STM32学习过程记录11——基于STM32G431CBU6硬件SPI+DMA的高效WS2812B控制方法-程序员宅基地

文章浏览阅读2.3k次,点赞6次,收藏14次。SPI的详情简介不必赘述。假设我们通过SPI发送0xAA,我们的数据线就会变为10101010,通过修改不同的内容,即可修改SPI中0和1的持续时间。比如0xF0即为前半周期为高电平,后半周期为低电平的状态。在SPI的通信模式中,CPHA配置会影响该实验,下图展示了不同采样位置的SPI时序图[1]。CPOL = 0,CPHA = 1:CLK空闲状态 = 低电平,数据在下降沿采样,并在上升沿移出CPOL = 0,CPHA = 0:CLK空闲状态 = 低电平,数据在上升沿采样,并在下降沿移出。_stm32g431cbu6

计算机网络-数据链路层_接收方收到链路层数据后,使用crc检验后,余数为0,说明链路层的传输时可靠传输-程序员宅基地

文章浏览阅读1.2k次,点赞2次,收藏8次。数据链路层习题自测问题1.数据链路(即逻辑链路)与链路(即物理链路)有何区别?“电路接通了”与”数据链路接通了”的区别何在?2.数据链路层中的链路控制包括哪些功能?试讨论数据链路层做成可靠的链路层有哪些优点和缺点。3.网络适配器的作用是什么?网络适配器工作在哪一层?4.数据链路层的三个基本问题(帧定界、透明传输和差错检测)为什么都必须加以解决?5.如果在数据链路层不进行帧定界,会发生什么问题?6.PPP协议的主要特点是什么?为什么PPP不使用帧的编号?PPP适用于什么情况?为什么PPP协议不_接收方收到链路层数据后,使用crc检验后,余数为0,说明链路层的传输时可靠传输

软件测试工程师移民加拿大_无证移民,未受过软件工程师的教育(第1部分)-程序员宅基地

文章浏览阅读587次。软件测试工程师移民加拿大 无证移民,未受过软件工程师的教育(第1部分) (Undocumented Immigrant With No Education to Software Engineer(Part 1))Before I start, I want you to please bear with me on the way I write, I have very little gen...

随便推点

Thinkpad X250 secure boot failed 启动失败问题解决_安装完系统提示secureboot failure-程序员宅基地

文章浏览阅读304次。Thinkpad X250笔记本电脑,装的是FreeBSD,进入BIOS修改虚拟化配置(其后可能是误设置了安全开机),保存退出后系统无法启动,显示:secure boot failed ,把自己惊出一身冷汗,因为这台笔记本刚好还没开始做备份.....根据错误提示,到bios里面去找相关配置,在Security里面找到了Secure Boot选项,发现果然被设置为Enabled,将其修改为Disabled ,再开机,终于正常启动了。_安装完系统提示secureboot failure

C++如何做字符串分割(5种方法)_c++ 字符串分割-程序员宅基地

文章浏览阅读10w+次,点赞93次,收藏352次。1、用strtok函数进行字符串分割原型: char *strtok(char *str, const char *delim);功能:分解字符串为一组字符串。参数说明:str为要分解的字符串,delim为分隔符字符串。返回值:从str开头开始的一个个被分割的串。当没有被分割的串时则返回NULL。其它:strtok函数线程不安全,可以使用strtok_r替代。示例://借助strtok实现split#include <string.h>#include <stdio.h&_c++ 字符串分割

2013第四届蓝桥杯 C/C++本科A组 真题答案解析_2013年第四届c a组蓝桥杯省赛真题解答-程序员宅基地

文章浏览阅读2.3k次。1 .高斯日记 大数学家高斯有个好习惯:无论如何都要记日记。他的日记有个与众不同的地方,他从不注明年月日,而是用一个整数代替,比如:4210后来人们知道,那个整数就是日期,它表示那一天是高斯出生后的第几天。这或许也是个好习惯,它时时刻刻提醒着主人:日子又过去一天,还有多少时光可以用于浪费呢?高斯出生于:1777年4月30日。在高斯发现的一个重要定理的日记_2013年第四届c a组蓝桥杯省赛真题解答

基于供需算法优化的核极限学习机(KELM)分类算法-程序员宅基地

文章浏览阅读851次,点赞17次,收藏22次。摘要:本文利用供需算法对核极限学习机(KELM)进行优化,并用于分类。

metasploitable2渗透测试_metasploitable2怎么进入-程序员宅基地

文章浏览阅读1.1k次。一、系统弱密码登录1、在kali上执行命令行telnet 192.168.26.1292、Login和password都输入msfadmin3、登录成功,进入系统4、测试如下:二、MySQL弱密码登录:1、在kali上执行mysql –h 192.168.26.129 –u root2、登录成功,进入MySQL系统3、测试效果:三、PostgreSQL弱密码登录1、在Kali上执行psql -h 192.168.26.129 –U post..._metasploitable2怎么进入

Python学习之路:从入门到精通的指南_python人工智能开发从入门到精通pdf-程序员宅基地

文章浏览阅读257次。本文将为初学者提供Python学习的详细指南,从Python的历史、基础语法和数据类型到面向对象编程、模块和库的使用。通过本文,您将能够掌握Python编程的核心概念,为今后的编程学习和实践打下坚实基础。_python人工智能开发从入门到精通pdf