in

如何使用Scrapy进行网络爬取(具体示例介绍)

如何使用Scrapy进行网络爬取

Scrapy 是世界上最流行的网络抓取框架,它之所以得名,是因为它是一个高性能、易于访问和可扩展的框架。 在此 Python 网络抓取教程中,我们将了解如何使用 Scrapy 框架进行抓取。我们将首先快速介绍我们自己的 Scrapy 及其相关库,组成一个 scrapy 项目的内容以及一些常见的提示和技巧。 最后,我们将通过一个 scrapy示例项目从producthunt.com抓取产品数据来巩固这些知识。

Scrapy 简介

Scrapy for Python 是一个围绕Twisted异步网络引擎构建的网络抓取框架,这意味着它不使用标准的 python async/await 基础设施。 虽然了解基础架构很重要,但我们很少需要接触 Twisted,因为 scrapy 使用自己的界面将其抽象出来。从用户的角度来看,我们将主要使用回调和生成器。

scrapy的主要对象关系图解
scrapy 的`Crawler` 和项目的`Spiders` 之间的简化关系

正如您在此图中看到的,scrapy 带有一个名为Crawler(浅蓝色)的引擎,它处理低级逻辑,如 http 连接、调度和整个程序流。 它缺少的是关于要抓取什么以及如何抓取的高级逻辑(深蓝色)。这称为蜘蛛。换句话说,我们必须为爬虫提供一个 scrapy 蜘蛛对象,该对象生成检索请求和存储结果。 在我们创建我们的第一个 Spider 之前,让我们从一个简短的词汇表开始: 回调 由于 scrapy 是一个异步框架,很多操作都在后台发生,这使我们能够生成高并发和高效的代码。回调是我们附加到后台任务的函数,在该任务成功完成时调用。 Errorback 与回调相同,但调用失败的任务而不是成功的任务。 生成器 在 python 中,生成器是一种函数,它不是一次返回所有结果(如列表),而是能够一个接一个地返回它们。 设置 Scrapy 通过称为设置的中央配置对象进行配置。项目设置位于settings.py文件中。 可视化此架构很重要,因为这是所有基于 scrapy 的爬虫的核心工作原理:我们将编写生成器来生成带有回调的请求或将保存到存储中的结果。 在本节中,我们将通过一个示例项目来介绍 scrapy 。我们将从https://www.producthunt.com/抓取产品数据。我们将编写一个爬虫,它将:

  1. 转到产品目录列表
  2. 查找产品网址(例如https://www.producthunt.com/posts/slack)
  3. 转到每个产品网址
  4. 提取产品的标题、副标题、分数和标签

设置

Scrapy可以通过命令安装pip install scrapy,自带方便的终端命令scrapy

在某些系统上安装 scrapy 可能会有点复杂,请参阅官方 scrapy 安装指南以获取更多信息

scrapy命令有 2 个可能的上下文:全局上下文和项目上下文。在本文中,我们将专注于使用项目上下文,为此我们首先必须创建一个 scrapy 项目:

$ scrapy startproject producthunt producthunt-scraper
#                     ^ name      ^ project directory
$ cd producthunt-scraper
$ tree
.
├── producthunt
│   ├── __init__.py
│   ├── items.py
│   ├── middlewares.py
│   ├── pipelines.py
│   ├── settings.py 
│   └── spiders
│       ├── __init__.py 
└── scrapy.cfg

如您所见,startproject命令为我们创建了这个项目结构,其中大部分是空的。但是,如果我们scrapy --help在这个新目录中运行命令,我们会注意到一堆新命令——现在我们在项目上下文中工作:

$ scrapy --help
Scrapy 1.8.1 - project: producthunt

Usage:
  scrapy <command> [options] [args]

Available commands:
  bench         Run quick benchmark test
  check         Check spider contracts
  crawl         Run a spider
  edit          Edit spider
  fetch         Fetch a URL using the Scrapy downloader
  genspider     Generate new spider using pre-defined templates
  list          List available spiders
  parse         Parse URL (using its spider) and print the results
  runspider     Run a self-contained spider (without creating a project)
  settings      Get settings values
  shell         Interactive scraping console
  startproject  Create new project
  version       Print Scrapy version
  view          Open URL in browser, as seen by Scrapy

创建蜘蛛

目前,我们的项目中没有 scrapy 蜘蛛,如果我们运行scrapy list它什么也不会显示 – 所以让我们创建我们的第一个蜘蛛:

$ scrapy genspider products producthunt.com
#                  ^ name   ^ host we'll be scraping
Created spider 'products' using template 'basic' in module:
  producthunt.spiders.products
$ tree
.
├── producthunt
│   ├── __init__.py
│   ├── items.py
│   ├── middlewares.py
│   ├── pipelines.py
│   ├── settings.py
│   └── spiders
│       ├── __init__.py
│       ├── products.py  <--- New spider
└── scrapy.cfg
$ scrapy list
products 
# 1 spider has been found!

生成的蜘蛛除了给我们一个起始框架外没有做太多事情:

# /spiders/products.py
import scrapy


class ProductsSpider(scrapy.Spider):
    name = 'products'
    allowed_domains = ['producthunt.com']
    start_urls = ['http://producthunt.com/']

    def parse(self, response):
        pass

让我们来看看这些字段:

  • name用作此蜘蛛的参考,scrapy用于scrapy crawl <name>运行此爬虫的命令。
  • allowed_domains是一项安全功能,可限制此蜘蛛程序仅抓取特定域。它在这个例子中不是很有用,但是将它配置为减少意外错误是一个很好的做法,因为我们的蜘蛛可能会走开并意外地抓取其他网站。
  • start_urls表示起点,parse()是第一个回调。scrapy 蜘蛛开始工作的方式是连接到每个启动 url,回调parse()方法并遵循该方法产生的任何指令。

添加爬虫逻辑

根据我们的示例逻辑,我们希望我们start_urls成为一些主题目录(如https://www.producthunt.com/topics/developer-tools)并且在我们的parse()回调方法中我们希望找到所有产品链接并安排它们被抓取:

# /spiders/products.py
import scrapy
from scrapy.http import Response, Request


class ProductsSpider(scrapy.Spider):
    name = 'products'
    allowed_domains = ['producthunt.com']
    start_urls = [
        'https://www.producthunt.com/topics/developer-tools',
        'https://www.producthunt.com/topics/tech',
    ]

    def parse(self, response: Response):
        product_urls = response.xpath(
            "//main[contains(@class,'layoutMain')]//a[contains(@class,'_title_')]/@href"
        ).getall()
        for url in product_urls:
            # convert relative url (e.g. /products/slack) 
            # to absolute (e.g. https://producthunt.com/products/slack)
            url = response.urljoin(url)
            yield Request(url, callback=self.parse_product)
        # or shortcut in scrapy >2.0
        # yield from response.follow_all(product_urls, callback=self.parse_product)
    
    def parse_product(self, response: Response):
        print(response)

我们start_urls用几个目录链接更新了我们的。此外,我们parse()用一些爬行逻辑更新了我们的回调:我们使用 xpath 选择器找到产品 url,并为每个产品 url 生成另一个回调parse_product()方法的请求。

使用 Xpath 解析 HTML

添加解析逻辑

完成基本的爬取逻辑后,让我们添加解析逻辑。对于我们要提取字段的 Producthunt 产品:标题、副标题、热门标签和分数:

producthunt.com 示例的解析标记
让我们解析以蓝色突出显示的字段

让我们parse_product()用这个解析逻辑填充我们的回调:

# /spiders/products.py
...

def parse_product(self, response: Response):
    yield {
        'title': response.xpath('//h1/text()').get(),
        'subtitle': response.xpath('//h1/following-sibling::div//text()').get(),
        'votes': response.xpath("//*[contains(.//text(),'upvotes')]/preceding-sibling::*//text()").get(),
        'reviews': response.xpath("//*[contains(text(),'reviews')]/preceding-sibling::*/text() ").get(),
    }

在这里,我们使用了一些巧妙的 XPath 来选择我们标记的字段。 最后,我们可以测试我们的抓取器,但在我们运行scrapy crawl products命令之前让我们看一下默认设置,因为它们可能会妨碍我们抓取。

基本设置

默认情况下,Scrapy 不包含很多设置,并且依赖于并不总是最佳的内置默认值。让我们看一下基本的推荐设置:

# settings.py
# will ignore /robots.txt rules that might prevent scraping
ROBOTSTXT_OBEY = False
# will cache all request to /httpcache directory which makes running spiders in development much quicker
# tip: to refresh cache just delete /httpcache directory
HTTPCACHE_ENABLED = True
# while developing we want to see debug logs
LOG_LEVEL = "DEBUG" # or "INFO" in production

# to avoid basic bot detection we want to set some basic headers
DEFAULT_REQUEST_HEADERS = {
    # we should use headers
    'User-Agent': "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36",
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8',
    'Accept-Language': 'en',
}

有了这些设置,我们就可以运行我们的爬虫了!

运行蜘蛛

有两种方法可以运行 Scrapy 蜘蛛:通过scrapy命令和通过 python 脚本显式调用 Scrapy。通常建议使用 Scrapy CLI 工具,因为 scrapy 是一个相当复杂的系统,为它提供一个专用的进程 python 进程更安全。 我们可以products通过命令运行我们的蜘蛛scrapy crawl products

$ scrapy crawl products
...
2022-01-19 14:47:18 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.producthunt.com/topics/developer-tools> (referer: None) ['cached']
2022-01-19 14:47:18 [scrapy.core.engine] DEBUG: Crawled (200) <GET https://www.producthunt.com/posts/slack> (referer: https://www.producthunt.com/topics/developer-tools) ['cached']
2022-01-19 14:47:18 [scrapy.core.scraper] DEBUG: Scraped from <200 https://www.producthunt.com/posts/slack>
{'title': 'Slack', 'subtitle': 'Be less busy. Real-time messaging, archiving & search.', 'votes': '17,380', 'tags': ['Android', 'iPhone', 'Mac']}
...
2022-01-19 14:47:18 [scrapy.core.engine] INFO: Closing spider (finished)
2022-01-19 14:47:18 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{
 ...
 'finish_time': datetime.datetime(2022, 1, 19, 7, 47, 18, 689962),
 'httpcache/hit': 2,
 'item_scraped_count': 1,
 'start_time': datetime.datetime(2022, 1, 19, 7, 47, 18, 459982)
 }
2022-01-19 14:47:18 [scrapy.core.engine] INFO: Spider closed (finished)

Scrapy 提供了出色的日志记录了 scrapy 引擎正在做的所有事情以及记录任何返回的结果。在这个过程的最后,scrapy 还附加了一些有用的抓取统计信息——比如有多少项目被抓取了,我们的抓取器完成了多长时间等等。

通过Python脚本运行Scrapy有点复杂,我们建议查看官方文档中的示例。

保存结果

我们有一个蜘蛛,它成功地抓取了产品数据并将结果打印到日志中。如果我们想将结果保存到文件中,我们可以scrapy crawl使用输出标志更新我们的命令:

$ scrapy crawl products --output results.json
...
$ head results.json
[
{"title": "Slack", "subtitle": "Be less busy. Real-time messaging, archiving & search.", "votes": "17,380", "tags": ["Android", "iPhone", "Mac"]}
...

或者,我们可以配置FEEDS将所有数据自动存储在文件中的设置:

# settings.py
FEEDS = {
    # location where to save results
    'producthunt.json': {
        # file format like json, jsonlines, xml and csv
        'format': 'json',
        # use unicode text encoding:
        'encoding': 'utf8',
        # whether to export empty fields
        'store_empty': False,
        # we can also restrict to export only specific fields like: title and votes:
        'fields': ["title", "votes"],
        # every run will create new file, if False is set every run will append results to the existing ones
        'overwrite': True,
    },
}

此设置允许我们非常详细地为抓取的数据配置多个输出。Scrapy 默认支持许多提要导出器,例如亚马逊的 S3、谷歌云存储,还有许多社区扩展支持许多其他数据存储服务和类型。

有关 scrapy exporters 的更多信息,请参阅官方 feed exporter 文档

扩展 Scrapy

Scrapy 是一个可配置性很强的框架,因为它通过Middlewares、Pipelines和通用扩展槽为各种扩展提供了大量空间。让我们快速浏览一下这些以及我们如何使用一些自定义扩展来改进我们的示例项目。

Middlewares

Scrapy 为网络抓取引擎执行的许多操作提供了方便的拦截点。例如,下载器Middlewares允许我们预处理传出请求和后处理传入响应。我们可以使用它来设计自定义连接逻辑,例如重试某些请求、删除其他请求或实现连接缓存。 例如,让我们用一个Middlewares更新我们的 Producthunt 蜘蛛,该Middlewares会删除一些请求并修改一些响应。如果我们打开生成的middlewares.py文件,我们已经可以看到为scrapy startproject我们生成了一个模板:
# middlewares.py
...
class ProducthuntDownloaderMiddleware:
    # Not all methods need to be defined. If a method is not defined,
    # scrapy acts as if the downloader middleware does not modify the
    # passed objects.

    @classmethod
    def from_crawler(cls, crawler):
        # This method is used by Scrapy to create your spiders.
        s = cls()
        crawler.signals.connect(s.spider_opened, signal=signals.spider_opened)
        return s

    def process_request(self, request, spider):
        # Called for each request that goes through the downloader
        # middleware.

        # Must either:
        # - return None: continue processing this request
        # - or return a Response object
        # - or return a Request object
        # - or raise IgnoreRequest: process_exception() methods of
        #   installed downloader middleware will be called
        return None

    def process_response(self, request, response, spider):
        # Called with the response returned from the downloader.

        # Must either;
        # - return a Response object
        # - return a Request object
        # - or raise IgnoreRequest
        return response

    def process_exception(self, request, exception, spider):
        # Called when a download handler or a process_request()
        # (from other downloader middleware) raises an exception.

        # Must either:
        # - return None: continue processing this exception
        # - return a Response object: stops process_exception() chain
        # - return a Request object: stops process_exception() chain
        pass

    def spider_opened(self, spider):
        spider.logger.info('Spider opened: %s' % spider.name)
因此,为了处理所有蜘蛛请求,我们使用process_request()method 并同样处理我们使用的响应process_response()。让我们放弃所有以字母开头的产品的抓取s
def process_request(self, request, spider):
    if 'posts/s' in request.url.lower():
        raise IgnoreRequest(f'skipping product starting with letter "s" {request.url}')
    return None
然后,假设 Producthunt 将所有过期产品重定向到/product/expired– 我们应该删除这些响应:
def process_response(self, request, response, spider):
    if 'posts/expires' in response.url.lower():
        raise IgnoreRequest(f'skipping expired product: {request.url}')
    return response
准备好Middlewares后,最后一步是在我们的设置中激活它:
# settings.py
DOWNLOADER_MIDDLEWARES = {
    'producthunt.middlewares.ProducthuntDownloaderMiddleware': 543,
}
此设置包含Middlewares路径及其优先级的字典 – 通常指定为 0 到 1000 之间的整数。优先级是处理多个Middlewares之间的交互所必需的,因为默认情况下 Scrapy 已经启用了 10 多个Middlewares! 通常,我们希望在中间RetryMiddleware的某个地方包含我们的Middlewares——在处理常见连接重试的550 之前。话虽这么说,但建议您熟悉默认Middlewares,以找到Middlewares可以产生稳定结果的有效最佳位置。您可以在官方设置文档页面中找到默认Middlewares列表。 在控制连接流方面,Middlewares为我们提供了强大的功能,同样,在控制数据输出方面,Pipelines也为我们提供了强大的功能——让我们来看看它们吧!

Pipelines

Pipelines本质上是数据后处理器。每当我们的蜘蛛生成一些结果时,它们就会通过已注册的Pipelines进行传输,并将最终输出发送到我们的提要(无论是日志还是提要导出)。 让我们向我们的 Producthunt 蜘蛛添加一个示例Pipelines,它将丢弃低分产品:
# pipelines.py
class ProducthuntPipeline(object):
    def process_item(self, item, spider):
        if int(item.get('votes', 0).replace(',', '')) < 100:
            raise DropItem(f"dropped item of score: {item.get('votes')}")
        return item
与Middlewares一样,我们还需要在设置文件中激活我们的Pipelines:
# settings.py
ITEM_PIPELINES = {
    'producthunt.pipelines.ProducthuntPipeline': 300,
}
由于 Scrapy 不包含任何默认Pipelines,在这种情况下我们可以将扩展分数设置为任何值,但最好保持在相同的 0 到 1000 范围内。使用此Pipelines,每次我们运行scrapy crawl products所有生成的结果时,都将通过我们的投票过滤逻辑进行过滤,然后再将它们传输到最终输出。
我们已经了解了扩展 scrapy 的两种最常见的方式:下载器Middlewares,它允许我们控制请求和响应以及Pipelines,它允许我们控制输出。这些都是非常强大的工具,提供了一种优雅的方式来解决常见的网络抓取挑战,所以让我们来看看其中的一些挑战和现有的解决方案。

共同挑战

虽然 scrapy 是一个大型框架,但它专注于性能和强大的核心功能集,这通常意味着我们需要通过社区或自定义扩展来解决常见的网络抓取挑战。 Web 抓取时最常见的挑战是抓取器阻塞。为此,Scrapy 社区提供了各种代理管理插件,如scrapy-rotating-proxiesscrapy-fake-useragent用于随机化用户代理标头。此外,还有提供浏览器模拟的扩展,如scrapy-playwrightscrapy-selenium。 有关浏览器自动化的更多信息,请参阅我们的详尽文章,其中检查和比较了主要的浏览器自动化库,例如 Selenium、Playwright 和 Puppeteer。
对于扩展,有各种任务分发扩展,例如scrapy-redisscrapy-cluster,它允许通过rediskafka服务扩展巨大的抓取项目,以及scrapy-deltafetch,它提供了一个简单的持久连接缓存来优化重复抓取。 最后,为了监控, Scrapy 集成了主要的监控服务,例如通过scrapy-sentry 的sentry或通用监控工具scrapy-spidermon

常问问题

在结束之前,让我们看一下有关使用 Scrapy 进行网络抓取的一些常见问题。

Selenium 可以和 Scrapy 一起使用吗?

Selenium 是 Python 中流行的 Web 浏览器自动化框架,但是由于不同的架构,让 scrapy 和 selenium 一起工作很困难。 查看这些开源尝试scrapy-selenium和scrapy-headless。 或者,我们建议查看 scrapy + splash 扩展scrapy-splash。

如何用Scrapy抓取动态网页?

我们可以使用像 Selenium 这样的浏览器自动化工具,尽管很难让它们与 Scrapy 一起很好地工作。或者,很多动态网页数据实际上隐藏在网页主体中,有关更多信息,请参阅如何抓取隐藏的网页数据

概    括

在这个 Scrapy 教程中,我们从快速架构概述开始:什么是回调、错误返回和整个异步生态系统。 为了掌握 scrapy 蜘蛛的窍门,我们为https://www.producthunt.com/产品列表启动了一个示例 scrapy 项目。我们介绍了 scrapy 项目的基础知识——如何启动项目、创建蜘蛛以及如何使用 XPath 选择器解析 HTML 内容。我们还介绍了扩展 scrapy 的两种主要方式。第一个是下载器Middlewares,它处理传出请求和传入响应。第二个是 – 处理抓取结果的Pipelines。

Written by 河小马

河小马是一位杰出的数字营销行业领袖,广告中国论坛的重要成员,其专业技能涵盖了PPC广告、域名停放、网站开发、联盟营销以及跨境电商咨询等多个领域。作为一位资深程序开发者,他不仅具备强大的技术能力,而且在出海网络营销方面拥有超过13年的经验。