Scrapy 是世界上最流行的网络抓取框架,它之所以得名,是因为它是一个高性能、易于访问和可扩展的框架。 在此 Python 网络抓取教程中,我们将了解如何使用 Scrapy 框架进行抓取。我们将首先快速介绍我们自己的 Scrapy 及其相关库,组成一个 scrapy 项目的内容以及一些常见的提示和技巧。 最后,我们将通过一个 scrapy示例项目从producthunt.com抓取产品数据来巩固这些知识。
Scrapy 简介
Scrapy for Python 是一个围绕Twisted异步网络引擎构建的网络抓取框架,这意味着它不使用标准的 python async/await 基础设施。 虽然了解基础架构很重要,但我们很少需要接触 Twisted,因为 scrapy 使用自己的界面将其抽象出来。从用户的角度来看,我们将主要使用回调和生成器。
正如您在此图中看到的,scrapy 带有一个名为Crawler(浅蓝色)的引擎,它处理低级逻辑,如 http 连接、调度和整个程序流。 它缺少的是关于要抓取什么以及如何抓取的高级逻辑(深蓝色)。这称为蜘蛛。换句话说,我们必须为爬虫提供一个 scrapy 蜘蛛对象,该对象生成检索请求和存储结果。 在我们创建我们的第一个 Spider 之前,让我们从一个简短的词汇表开始: 回调 由于 scrapy 是一个异步框架,很多操作都在后台发生,这使我们能够生成高并发和高效的代码。回调是我们附加到后台任务的函数,在该任务成功完成时调用。 Errorback 与回调相同,但调用失败的任务而不是成功的任务。 生成器 在 python 中,生成器是一种函数,它不是一次返回所有结果(如列表),而是能够一个接一个地返回它们。 设置 Scrapy 通过称为设置的中央配置对象进行配置。项目设置位于settings.py
文件中。 可视化此架构很重要,因为这是所有基于 scrapy 的爬虫的核心工作原理:我们将编写生成器来生成带有回调的请求或将保存到存储中的结果。 在本节中,我们将通过一个示例项目来介绍 scrapy 。我们将从https://www.producthunt.com/抓取产品数据。我们将编写一个爬虫,它将:
- 转到产品目录列表
- 查找产品网址(例如https://www.producthunt.com/posts/slack)
- 转到每个产品网址
- 提取产品的标题、副标题、分数和标签
设置
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 产品:标题、副标题、热门标签和分数:
让我们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-proxies和scrapy-fake-useragent用于随机化用户代理标头。此外,还有提供浏览器模拟的扩展,如scrapy-playwright和scrapy-selenium。 有关浏览器自动化的更多信息,请参阅我们的详尽文章,其中检查和比较了主要的浏览器自动化库,例如 Selenium、Playwright 和 Puppeteer。redis
和kafka
服务扩展巨大的抓取项目,以及scrapy-deltafetch,它提供了一个简单的持久连接缓存来优化重复抓取。 最后,为了监控, Scrapy 集成了主要的监控服务,例如通过scrapy-sentry 的sentry或通用监控工具scrapy-spidermon。