Python Web 爬取教程(二)
四、使用 Scrapy
在冗长地介绍了Beautiful Soup和定制抓取器之后,是时候看看Scrapy:Python 的网站抓取工具了。
在我看来,这是 Python 目前唯一可行的工具,可以处理开箱即用的复杂抓取任务。可以缓存网页,随心所欲添加并行;你只需要正确配置Scrapy并编写提取代码即可。
在这一章中,你将学习如何最大限度地利用Scrapy来完成你的大部分网站抓取项目。您将编写 Sainsbury 的提取器,配置Scrapy来创建一个网站友好的蜘蛛,并且您将学习如何将自定义导出选项应用到提取的信息。
与上一章相反,我在开始时介绍了Beautiful Soup,然后你创建了这个项目来抓取 Sainsbury 的网站,现在你将通过实现 scraper 项目来学习 Scrapy 的基础知识。在这一章的结尾,我会添加更多的信息和对我们没有在项目中使用的工具的见解,但是我认为知道你将来是否编写自己的刮刀是有用的。
准备好了吗?为什么不呢!
安装Scrapy
您的首要任务是将Scrapy安装到您的 Python 环境中。
要安装Scrapy,只需执行
pip install scrapy
就这样。使用这个命令,您也安装了所有的需求,所以您已经准备好创建 scraper 项目了。
注意
Scrapy的开发者建议将该工具安装到虚拟环境中。这是一个很好的实践,让你的刮削工具有一个干净的版本;这阻碍了您将Scrapy的依赖项更新为不兼容的版本,这会使您的 scraper 无法工作。
如果你安装Scrapy有困难,只要阅读他们的说明。1
创建项目
要开始使用Scrapy,您必须创建一个项目。这有助于你保持文件的有序,并且只关注一个问题。要创建新项目,只需执行以下命令:
scrapy startproject sainsburys
这个调用的结果如下:
New Scrapy project 'sainsburys', using template directory 'c:\\python\\scrapy\\lib\\site-packages\\scrapy\\templates\\project', created in:
C:\scraping_book\chapter_4\sainsburys
You can start your first spider with
cd sainsburys
scrapy genspider example example.com
根据您使用的操作系统和项目所在的位置,前面的文本可能会有所不同。然而,重要的是关于如何创建第一个蜘蛛的信息。
但是在你创建你的第一个蜘蛛之前,让我们看看创建的文件结构,如图 4-1 所示。
图 4-1
项目结构
结构应该差不多;如果没有,也许你正在使用的 Scrapy 新版本有所改变。
配置项目
在深入研究将要用Scrapy实现的主 scraper 的代码之前,您应该正确地配置您的项目。需要基本的配置来显示你是一个“好公民”,你的蜘蛛也是一个很好的工具。
基本配置我建议你每次都做就是添加用户代理,看robots.txt文件兑现。
幸运的是,Scrapy的基本项目框架带有一个配置文件,其中大部分设置都设置正确或被注释掉,但告诉您选项和它接受的值。您可以在settings.py文件中找到项目的配置。
你看一看,会看到增加了很多选项;大部分都被注释掉了。缺省值对于大多数刮削项目来说非常好,但是如果你认为它能给你带来更好的性能或者你需要增加一些复杂性,你可以调整它们。
我经常使用的两个属性是
-
USER_AGENT -
ROBOTSTXT_OBEY
这些属性的名称已经告诉你它们的用途。
对于USER_AGENT,您会看到一个缺省值,它由 bot 的名称(sainsburys)和一个示例域组成。我主要把它换成 Chrome 代理。你可以通过 Chrome 的 DevTools 获得一个:你打开网络标签,在浏览器中正常加载一个网页,点击网络标签中的请求,将用户代理的值复制到请求的头标签中。即使您离线,这也有效。
而要做一个好公民,就把ROBOTSTXT_OBEY留在True上。有了这个,Scrapy负责处理robots.txt文件的内容(如果有的话)。
我建议您删除所有注释掉的设置。这将有助于您稍后阅读该文件,您可以立即看到所有活动的配置;您不必滚动所有行来查看哪些行被注释掉了。即使在具有良好颜色编码的 IDE 中也很难。
除了这些属性,我建议你加上CONCURRENT_REQUESTS = 1。这降低了蜘蛛的速度,但是在测试的时候,你会运行很多代码,你不希望一开始就被禁止访问网站—或者你不希望网站的服务器因为你(和 99,999 个其他读者)同时运行 scraper 而无法处理负载而被关闭。如果您查看注释代码,您会发现它的默认值是 16。我将添加一个部分,在那里我将增加并行请求的数量,并将做一个有缺陷的微基准测试。
总结一下:我最终的settings.py文件看起来是这样的:
# -*- coding: utf-8 -*-
BOT_NAME = 'sainsburys'
SPIDER_MODULES = ['sainsburys.spiders']
NEWSPIDER_MODULE = 'sainsburys.spiders'
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' \
'(KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36'
ROBOTSTXT_OBEY = True
CONCURRENT_REQUESTS = 1
在前面的代码中,您可以看到一个 Windows 10 Chrome 用户代理字符串的示例。你不必坚持这一点:随意使用浏览器中的一个;不会有什么影响的。
现在基本的配置已经完成,我们可以实现为我们工作的 spider 了。
术语
在设置配置时,你可以选择学习一些Scrapy的术语,如 middleweare 或 pipeline 。它们是这个刮刀的构建块,如果它缺少您需要的东西,您可以实现自己的代码并扩展功能。
中间件
中间件被钩入Scrapy;这意味着,您可以扩展现有的功能。Scrapy中有两种中间件:
-
下载器中间件
-
蜘蛛中间件
顾名思义,您可以扩展下载程序(添加自己的缓存、代理调用、在发送前修改请求或忽略请求,仅举几个例子)或解析器功能(过滤掉一些响应、处理蜘蛛异常、基于响应调用不同的函数等)。).
对于基本的抓取,不需要编写自己的中间件,因为你可以很好地使用可用的工具—并且随着Scrapy的发展,更多的定制代码进入了标准库。
需要在settings.py文件中激活中间件。
DOWNLOADER_MIDDLEWARES = {
'yourproject.middlewares.CustomDownloader': 500
}
SPIDER_MIDDLEWARES = {
'yourproject.middlewares.SpiderMiddleware': 211
}
如果你有你的中间件,但它们似乎不工作,你可能忘了激活它们。另一个原因可能是它们在错误的位置被执行:您在字典中作为值提供的数字告诉Scrapy中间件应该被执行的顺序:
-
对于下载器中间件,按照递增的顺序调用
process_request方法。 -
对于下载器中间件,按照递减的顺序调用
process_response方法。 -
对于蜘蛛中间件,按照递增的顺序调用
process_spider_input方法。 -
对于蜘蛛中间件,
process_spider_output方法是按照递减的顺序调用的。
因此,可能会发生这样的情况,您期望在请求/响应/输入/输出中得到一些东西,但是它是由一个具有较低/较高优先级的中间件处理的。
管道
管道处理提取的数据。这包括清理、格式化,有时还包括导出数据。尽管Scrapy有内置的管道以给定的格式导出数据(CSV、JSON —在本章后面会详细介绍),有时您需要编写自己的管道来配置结果以满足您(您的客户)的期望。
当你作为一个专业的开发者工作时,你将会编写比中间件更多的管道。然而,这并不像听起来那么糟糕。在这一章中,我们将创建一个简单的项目管道,向您展示它是如何完成的。
与中间件类似,您必须在settings.py文件中激活您的管道。
ITEM_PIPELINES = {
'yourproject.pipelines.MongoPipeline': 418
}
延长
扩展是在启动时实例化一次的单例类,包含自定义代码,您可以使用它来添加一些自定义功能,这些功能不像中间件那样与下载或抓取相关。此类扩展可用于日志记录或监控内存消耗(这些已经是内置的扩展)。
扩展可以像中间件和管道一样在settings.py中加载。
EXTENSIONS = {
'scrapy.extensions.memusage.CoreStats': 500
}
选择器
这是你在使用Scrapy时会遇到的最重要的术语。选择器是选择 HTML 特定部分的代码部分。如你所见,选择器的工作类似于Beautiful Soup和lxml,但它们是Scrapy版本,你可以使用XPath或CSS表达式。我更喜欢XPath表达式,因为我从事 XML 和 XML 转换工作多年;所以,我很了解XPath表情。你可以自由使用任何方法,但我会坚持使用XPath。
选择器是Scrapy,中的对象,因此它们可以从文本中构造。
from scrapy.selector import Selector
selector = Selector(text='<html><body><h1>Hello Selectors!</h1></body></html>')
print(selector.xpath('//h1/text()').extract()) # ['Hello Selectors!']
或者来自一个回应:
from scrapy.selector import Selector
from scrapy.http import HtmlResponse
response = HtmlResponse(url='http://my.domain.com', body='<html><body><h1>Hello Selectors!</h1></body></html>', encoding='UTF-8')
print(Selector(response=response).css('h1::text').extract()) # ['Hello Selectors!']
但是,因为选择器是提取数据的方式,所以您可以使用
response.xpath()
或者
response.css()
在我看来,这使得Scrapy成为一个很好的工具:你不必费心创建选择器对象,而是使用可用的方便的方法访问。
如果你想阅读更多关于 CSS 选择器 2 或 XPath 表达式的内容,请点击链接。 3
实施 Sainsbury 刮刀
要开始提取代码,你需要一个蜘蛛生成。正如您在上一节中看到的,您创建并配置了项目的基础,您可以使用genspider命令来完成。让我们现在就做吧。首先将目录更改为生成 bot 的目录,然后执行以下命令:
scrapy genspider sainsburys 'https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/'
当执行前面的命令时,您会得到一条奇怪的消息:
Cannot create a spider with the same name as your project
好吧,如果我们找不到同名的蜘蛛,那就给它起个不同的名字吧。我的建议是一个你容易记住的名字。我主要使用"basic",因为它很容易编写,而且我有一个基本的 scraper 来帮我提取。该项目已经有一个唯一的名称;有了basic,我可以随时启动我的蜘蛛,不管是什么项目。
scrapy genspider basic https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish
现在的反应不同了。
Created spider 'basic' using template 'basic' in module:
sainsburys.spiders.basic
使用这个命令,Scrapy将一个basic.py文件添加到项目的spiders文件夹中。这个文件将是你的蜘蛛的基础;在这里你将实现提取代码。
代码看起来很正常,但是如果你仔细看,你会发现start_urls变量看起来有点奇怪。
start_urls = ['http://https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']
它多了一个http://。这是因为我们为 scraper 一代提供了 URL。Scrapy本意是刮一个域;因此,您应该为蜘蛛创建提供一个域。然而,在这个例子的特殊情况下,我们将只抓取整个域的一个子集(“肉&鱼”)。有两种选择:
-
您只使用域名'www.sainsburys.co.uk'创建蜘蛛,然后将 URL 的剩余部分添加到 start_urls(或者完全更改条目)。
-
您只需从 start_urls 条目中删除多余的
'http://'。
这allowed_domains是关于什么?
如果您仔细查看了代码,您会看到有一个允许的域列表。这个列表用来给蜘蛛一个界限。无需设置允许的域,您可以编写一个通过互联网的脚本(跟踪它抓取的页面上的每个链接)。在大多数情况下,您希望将您的抓取保持在一个域中。然而,有时候你不得不处理内部或者子域。在这些情况下,您可以手动扩展该列表来修复此类“问题”
在这里,您应该只设置域。当您生成蜘蛛时,它会将整个 URL 添加到此列表中,但是您需要类似以下的内容:
allowed_domains = ['www.sainsburys.co.uk']
您可以在文件夹01_empty_project中本章的源代码中找到一个带有我的默认配置的空项目的源代码。
准备
这一节很简短。如果您照着做,您已经配置好了一切,不需要任何其他准备。
只是一个快速检查清单,看看你是否准备好了:
-
你已经阅读了第二章的要求。
-
您已经创建了一个
Scrapy-项目。 -
您已经按照本章中的描述配置了项目。
-
你创造了一只蜘蛛。
如果缺少了什么,花时间去弥补;那你就可以跟着去了。
使用外壳
我喜欢在准备工作中使用的一个功能是使用它的 shell,它为我们提供了一个测试和准备代码片段的环境。因为 shell 的行为就像您的 spider 代码一样,所以它非常适合创建您的应用的构建块。
用一种简单的方法(或者类似的方法,就像我们在上一章做的那样),你可以写一部分代码并运行蜘蛛。如果有错误,您应该修复代码并重新运行蜘蛛。如果网站不基于请求限制访问,这是可以的。如果有一个限制,你可能最终会超过它,你的蜘蛛(和你的电脑,当前的 IP,整个公司网络 4 )被禁止进入网站。而且,正如我所见,Sainsbury 的运行落后于 CloudFlare —你最好不要向他们的网站发送并行请求!
Scrapy shell 的工作方式不同:它下载您的目标网页,您可以在这个副本上创建提取逻辑。如果您需要移动到另一个页面,您可以让 shell 下载它,然后您就可以编写下一段代码了。
启动 shell 很容易。
scrapy shell
您可以传递一个<url>参数,这是您的目标 URL。
对于这本书,我们将使用 https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/ :
scrapy shell https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/
或者,您也可以在打开Scrapy的 shell 时不使用任何 URL,或者使用不同的 URL。
>>> fetch('https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/')
现在 shell 已经下载了 URL 后面的网页。这意味着两件事:现在你可以访问肉&鱼页面的内容,并可以尝试你的提取器;第二,您必须下载您想在 shell 中使用的每个页面。尽管第二点听起来很糟糕,但事实并非如此:在Scrapy中获取其他页面变得很容易,因此在 shell 中也是如此。
在 shell 中,你可以访问一个response对象(就像在parse方法中一样,我们将在本章后面编写),通过这个response,你可以使用可用的选择器。
我不想深究如何使用 shell 来准备您的 scraper 脚本。因此,我们将做一个例子:我们获得下一页的 URL。这会给你一个好的开始,并让你有使用外壳做进一步准备的感觉。
您可能还记得,可以在一个无序列表中找到指向详细页面的链接(<ul class="categories departments">)。列表的元素(<li>)有一个锚子(<a>),这些锚的href属性值就是我们要找的 URL。
要获得这些 URL 的列表,可以使用 XPath 编写以下代码:
urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()
使用 CSS 选择器,看起来像这样:
urls = response.css('ul.categories.departments > li > a::attr(href)').extract()
就是这样。就像上一章一样,所有的 URL 都指向该类别的产品列表或包含更多子类别的网站。
我建议您现在更深入地研究 XPath 和 CSS 选择器,以理解您将从下一节开始编写的提取器代码。
定义解析(自身,响应)
现在我们可以开始在basic.py文件中编写代码了。
方法是每个蜘蛛的核心。这个方法在每次Scrapy下载一个 URL 时被调用,大多数时候你用这个方法写你的提取代码。
response参数保存调用 URL 的响应。它可以包含网站的内容,但有时你可以得到错误代码,例如,当网站关闭或不存在。
您可以将整个 scraper 编写到parse方法中,但是我建议将您的代码组织到方法中(实际上,这是许多开发人员建议的做法)。这有助于您将来理解代码想要达到的目的。
因此,parse函数将非常稀疏:它只提取类别页面的 URL(与 shell 的准备过程相同),并启动这些页面的下载和解析。
from scrapy import Request
# some code left out...
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()
for url in urls:
yield Request(url, callback=self.parse_department_pages)
前面的代码提取了所需类列表中每个锚元素的href属性。有趣的部分是抓取是如何继续的:你yield一个新的Request对象,目标 URL 作为第一个参数,以及callback函数,如果服务器为给定的 URL 返回一个 OK-ish 响应,就应该调用这个函数。在这种情况下,它将是同一个类的parse_department_pages方法。
有一种替代方法可以用更少的代码进入下一页。
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a')
for url in urls:
yield response.follow(url, callback=self.parse_department_pages)
这里我们使用 Scrapy 的语法:在引擎盖下执行相同的代码,但是您不必费心从锚标记中提取确切的引用。而且有时候在网页链接中得不到完全限定(绝对)的 URL 而是相对引用,还得手动添加主机(或者使用urljoin)。通过使用response.follow,你也可以从盒子里得到这个。因此,我建议你使用这种语法,我也会在书中用到它!
目前,从版本 1.4.0 开始,您必须为follow方法提供一个单个 URL 或Link类型的对象。我打赌有人也会添加一个接受列表的方法(例如follow_all),因为我们喜欢让事情变得更简单。
这样,我们就完成了这一部分。让我们继续,看看如何进入产品页面。
在本节结束时,您的basic.py文件应该如下所示:
# -*- coding: utf-8 -*-
import scrapy
class BasicSpider(scrapy.Spider):
name = 'basic'
allowed_domains = ['www.sainsburys.co.uk']
start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a')
for url in urls
:
yield response.follow(url, callback=self.parse_department_pages)
浏览类别
你的第一个任务是浏览塞恩斯伯里网站的分类页面。在前一章中,您已经看到了找到包含项目详细信息的页面是多么复杂。
正如您在上一章中看到的,每个类别的链接可以指向产品列表或包含子类别及其链接的页面,这些链接可以指向产品列表页面或包含子类别的第三个页面。还好没有更深的层次感。
在这一节中,我们将处理上一节中的代码产生子或子子类别的页面而不是产品详细信息的情况。
在上一节中,我们用 Scrapy 发送请求,并告诉工具用parse_department_pages方法处理响应。
为了实现这种方法,我们必须考虑三种版本的响应:
-
我们得到一个产品列表页面。
-
我们得到一个子类别页面。
-
我们得到一个子类别的页面。
如果响应是一个产品列表,那么应该将响应转发给下一节的方法。然而,我们必须注意触发请求。生成的块将如下所示:
product_grid = response.xpath('//ul[@class="productLister gridView"]')
if product_grid:
for product in self.handle_product_listings(response):
yield product
在前面的代码中,我们用response对象调用了handle_product_listings方法。我们也可以提供产品网格(或者仅仅是网格),因为我们已经提取了它,但是,正如您稍后将看到的,我们需要response在产品网格的页面之间导航。
然后我们yield结果,这也是 Scrapy 抓取这些 URL 的触发器。
下一步是通过更深层次的类别,这些类别由 CSS 类表示,如过道(class="category aisles")和货架(class="category shelves" ) —,就像在超市中一样。
这里的技巧是检查页面的源是否包含货架,如果不包含,那么就访问过道。这是因为包含货架的页面也包含过道,如果您首先获得过道链接,那么如果您不使用缓存,您可能会陷入不断重复获得相同页面的永无止境的循环中。获得相同的页面意味着更慢的抓取(实际上,永远不会结束)和在你的抓取结果中有很多重复的项目。
pages = response.xpath('//ul[@class="categories shelf"]/li/a')
if not pages:
pages = response.xpath('//ul[@class="categories aisles"]/li/a')
if not pages:
# here is something fishy
return
for url in pages:
yield response.follow(url, callback=self.parse_department_pages)
前面的代码遵循前面提到的方法:它查找货架,如果没有找到,它就查找过道。如果什么都没有找到,那么我们就处在一个无法收集更多信息的页面上:我们已经提取了产品列表的链接,或者页面上没有指向过道或货架的链接。
在这一部分的最后,您的basic.py文件应该看起来像这样:
# -*- coding: utf-8 -*-
import scrapy
class BasicSpider(scrapy.Spider):
name = 'basic'
allowed_domains = ['www.sainsburys.co.uk']
start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a')
for url in urls:
yield response.follow(url, callback=self.parse_department_pages)
def parse_department_pages(self, response):
product_grid = response.xpath('//ul[@class="productLister gridView"]')
if product_grid:
for product in self.handle_product_listings(response):
yield product
pages = response.xpath('//ul[@class="categories shelf"]/li/a')
if not pages:
pages = response.xpath('//ul[@class="categories aisles"]/li/a')
if not pages:
# here is something fishy
return
for url in pages
:
yield response.follow(url, callback=self.parse_department_pages)
浏览产品列表
现在,您的代码在某些时候会指向一个产品列表页面。在本节中,如果这些页面的元素太多,无法在一页上显示,我们将浏览这些页面,并且我们将请求下载详细的项目页面。
我们目前在handle_product_listings函数中。
让我们从项目细节开始。
urls = response.xpath('//ul[@class="productLister gridView"]//li[@class="gridItem"]//h3/a')
for url in urls:
yield response.follow(url, callback=self.parse_product_detail)
前面的代码提取详细页面的 URL,然后将这些 URL 返回到触发抓取的parse_department_pages方法。
next_page = response.xpath('//ul[@class="pages"]/li[@class="next"]/a')
if next_page:
yield response.follow(next_page, callback=self.handle_product_listings)
这段代码寻找到下一页的链接。如果找到一个(在网站上,它在>符号下),那么它被返回给parse_department_pages方法。注意这里的callback方法:因为我们知道我们得到了另一页产品列表,我们可以使用同样的方法作为回调。
完成这一部分后,您的basic.py文件应该如下所示:
# -*- coding: utf-8 -*-
import scrapy
class BasicSpider(scrapy.Spider):
name = 'basic'
allowed_domains = ['www.sainsburys.co.uk']
start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a')
for url in urls:
yield response.follow(url, callback=self.parse_department_pages)
def parse_department_pages(self, response):
product_grid = response.xpath('//ul[@class="productLister gridView"]')
if product_grid:
for product in self.handle_product_listings(response):
yield product
pages = response.xpath('//ul[@class="categories shelf"]/li/a')
if not pages:
pages = response.xpath('//ul[@class="categories aisles"]/li/a')
if not pages:
# here is something fishy
return
for url in pages:
yield response.follow(url, callback=self.parse_department_pages)
def handle_product_listings(self, response):
urls = response.xpath('//ul[@class="productLister gridView"]//li[@class="gridItem"]//h3/a')
for url in urls:
yield response.follow(url, callback=self.parse_product_detail)
next_page = response.xpath('//ul[@class="pages"]/li[@class="next"]/a')
if next_page:
yield response.follow(next_page, callback=self.handle_product_listings)
提取数据
现在,您的代码可以处理复杂的导航并找到项目详细信息页面,是时候从网站提取所需的信息了。
我们目前采用的是parse_product_detail方法。
现在是时候从项目页面提取所有需要的信息了。实际上,这个过程和你在上一章中所做的是一样的(如果你编码的话):你可以使用查询;然而,您可以在验证每个find或find_all调用时节省一些代码行。
话不多说,让我们直接进入代码。
如果愿意,可以放下书,实现提取逻辑。这并不难,你可以使用前两章的信息来搭配。
我的解决方案是这样的(你的可能不同):
def parse_product_detail(self, response):
product_name = response.xpath('//h1/text()').extract()[0].strip()
product_image = response.urljoin(response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])
price_per_unit = response.xpath('//div[@class="pricing"]/p[@class="pricePerUnit"]/text()').extract()[0].strip()
units = response.xpath('//div[@class="pricing"]/span[@class="pricePerUnitUnit"]').extract()
if units:
unit = units[0].strip()
ratings = response.xpath('//label[@class="numberOfReviews"]/img/@alt').extract()
if ratings:
rating = ratings[0]
reviews = response.xpath('//label[@class="numberOfReviews"]').extract()
if reviews:
reviews = reviews_pattern.findall(reviews[0])
if reviews:
product_reviews = reviews[0]
item_code = item_code_pattern.findall(response.xpath('//p[@class="itemCode"]/text()').extract()[0].strip())[0]
nutritions = {}
for row in response.xpath('//table[@class="nutritionTable"]/tr'):
th = row.xpath('./th/text()').extract()
if not th:
th = ['Energy kcal']
td = row.xpath('./td[1]/text()').extract()[0]
nutritions[th[0]] = td
product_origin = ' '.join(response.xpath(
'.//h3[@class="productDataItemHeader" and text()="Country of Origin"]/following-sibling::div[1]/p/text()').extract())
就是这样。提取产品信息需要 30 行代码(使用我的自定义格式设置)。这真是太棒了!
正如您在代码中看到的,有一些有趣的代码块。例如,每个xpath调用都返回一个列表,即使您知道最多只能有一个结果。其中一些列表是空的,因为产品没有评级或单位信息。和Beautiful Soup一样,你也必须和Scrapy一起处理这种情况。
在这一部分之后,您的basic.py文件应该如下所示:
# -*- coding: utf-8 -*-
import scrapy
reviews_pattern = re.compile("Reviews \((\d+)\)")
item_code_pattern = re.compile("Item code: (\d+)")
class BasicSpider(scrapy.Spider):
name = 'basic'
allowed_domains = ['www.sainsburys.co.uk']
start_urls = ['https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/']
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a')
for url in urls:
yield response.follow(url, callback=self.parse_department_pages)
def parse_department_pages(self, response):
product_grid = response.xpath('//ul[@class="productLister gridView"]')
if product_grid:
for product in self.handle_product_listings(response):
yield product
pages = response.xpath('//ul[@class="categories shelf"]/li/a')
if not pages:
pages = response.xpath('//ul[@class="categories aisles"]/li/a')
if not pages:
# here is something fishy
return
for url in pages:
yield response.follow(url, callback=self.parse_department_pages)
def handle_product_listings(self, response):
urls = response.xpath('//ul[@class="productLister gridView"]//li[@class="gridItem"]//h3/a')
for url in urls:
yield response.follow(url, callback=self.parse_product_detail)
next_page = response.xpath('//ul[@class="pages"]/li[@class="next"]/a')
if next_page:
yield response.follow(next_page, callback=self.handle_product_listings)
def parse_product_detail(self, response):
product_name = response.xpath('//h1/text()').extract()[0].strip()
product_image = response.urljoin(response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])
price_per_unit = response.xpath('//div[@class="pricing"]/p[@class="pricePerUnit"]/text()').extract()[0].strip()
units = response.xpath('//div[@class="pricing"]/span[@class="pricePerUnitUnit"]').extract()
if units:
unit = units[0].strip()
ratings = response.xpath('//label[@class="numberOfReviews"]/img/@alt').extract()
if ratings:
rating = ratings[0]
reviews = response.xpath('//label[@class="numberOfReviews"]').extract()
if reviews:
reviews = reviews_pattern.findall(reviews[0])
if reviews:
product_reviews = reviews[0]
item_code = item_code_pattern.findall(response.xpath('//p[@class="itemCode"]/text()').extract()[0].strip())[0]
nutritions = {}
for row in response.xpath('//table[@class="nutritionTable"]/tr'):
th = row.xpath('./th/text()').extract()
if not th:
th = ['Energy kcal']
td = row.xpath('./td[1]/text()').extract()[0]
nutritions[th[0]] = td
product_origin = ' '.join(response.xpath(
'.//h3[@class="productDataItemHeader" and text()="Country of Origin"]/following-sibling::div[1]/p/text()').extract())
数据放在哪里?
好:您已经完成了,实现了产品提取器,并且您的蜘蛛中有许多包含项目信息的变量,但是在哪里存储它们呢?
使用Scrapy,你必须将数据存储在所谓的项中。这些项目是普通的旧 Python 类,可以在items.py文件中找到。除此之外,这些项的行为就像字典一样:您将它们声明为 Python 类,并可以像字典一样使用键值赋值来填充它们。
如果您在上一步之后运行了蜘蛛,您可能会在控制台中看到如下条目:
2018-02-11 11:06:03 [scrapy.extensions.logstats] INFO: Crawled 47 pages (at 47 pages/min), scraped 0 items (at 0 items/min)
在这里你可以看到没有物品被刮除。我们现在会解决这个问题。
让我们采用parse_product_detail方法将数据放入一个条目中。为此,首先我们需要一个条目,它已经在items.py文件中。
class SainsburysItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
pass
此类当前为空;我们必须给它添加字段。因为不喜欢每次都写scrapy.Field()(哪怕只是复制+粘贴),所以喜欢做“静态”导入(from scrapy import Item, Field)。
我的解决方案是这样的:您的名称可能会有所不同,这取决于您如何命名字段。
class SainsburysItem(Item):
url = Field()
product_name = Field()
product_image = Field()
price_per_unit = Field()
unit = Field()
rating = Field()
product_reviews = Field()
item_code = Field()
nutritions = Field()
product_origin = Field()
我唯一更改的是nutritions字段:我没有将所有可能的字段添加到项目定义中。这使得编写文件更容易,导出到 JSON(见后面)更方便。
平面(也称为包含所有字段)类如下所示:
class FlatSainsburysItem(Item):
url = Field()
product_name = Field()
product_image = Field()
price_per_unit = Field()
unit = Field()
rating = Field()
product_reviews = Field()
item_code = Field()
product_origin = Field()
energy = Field()
energy_kj = Field()
kcal = Field()
fibre_g = Field()
carbohydrates_g = Field()
of_which_sugars = Field()
...
正如您所看到的,这种方法的问题将出现在代码中:对于营养表,您将字符串作为键,并且您必须将它们映射到这些字段名称。这让事情变得复杂了。除此之外,还有超过 70 个不同的字段需要映射。
我不认为在这里包含这样的映射代码是有用的。有兴趣可以试试,但一般不是这本书或者网站刮痧的要求。
当我们在本章后面将结果导出到文件中时,我们将仔细看看默认情况下包含字典的字段是如何导出的,以及我们可以做些什么来获得与第二章中相同的结果。
现在要添加和使用项目,您必须像这样修改parse_product_detail方法:
def parse_product_detail(self, response):
item = SainsburysItem()
item['url'] = response.url
item['product_name'] = response.xpath('//h1/text()').extract()[0].strip()
item['product_image'] = response.urljoin(
response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])
item['price_per_unit'] = response.xpath('//div[@class="pricing"]/p[@class="pricePerUnit"]/text()').extract()
[0].strip()
units = response.xpath('//div[@class="pricing"]/span[@class="pricePerUnitUnit"]').extract()
if units:
item['unit'] = units[0].strip()
ratings = response.xpath('//label[@class="numberOfReviews"]/img/@alt').extract()
if ratings:
item['rating'] = ratings[0]
reviews = response.xpath('//label[@class="numberOfReviews"]').extract()
if reviews:
reviews = reviews_pattern.findall(reviews[0])
if reviews:
item['product_reviews'] = reviews[0]
item['item_code'] = \
item_code_pattern.findall(response.xpath('//p[@class="itemCode"]/text()').extract()[0].strip())[0]
nutritions = {}
for row in response.xpath('//table[@class="nutritionTable"]/tr'):
th = row.xpath('./th/text()').extract()
if not th:
th = ['Energy kcal']
td = row.xpath('./td[1]/text()').extract()[0]
nutritions[th[0]] = td
item['nutritions'] = nutritions
item['product_origin'] = ' '.join(response.xpath(
'.//h3[@class="productDataItemHeader" and text()="Country of Origin"]/following-sibling::div[1]/p/text()').extract())
yield item
这包括定义新条目(将导入添加到文件:from sainsburys.items import SainsburysItem),然后像使用字典一样使用它。在我的项目定义中,我使用了以前版本中的变量名作为Field名,但是如何命名您的字段取决于您。你只需要找到正确的映射。
最后,您必须yield这个项目,这使得Scrapy知道有一个项目要处理。
蜘蛛的当前状态可以在本章的源文件中的文件夹02_basic_spider中找到。
为什么是项目?
问得好!因为项是类似字典的对象;或者,你可以使用字典来存储你的信息。
item = {}
这不会导致编码或处理结果的任何差异,尽管Scrapy的条目包含一些组件使用的扩展信息。例如,导出器查看要导出哪些字段,序列化可以通过Items元数据定制,您可以使用它们来查找内存泄漏。
在本章的后面你会看到,有时用一个简单的字典代替一个条目是很方便的。但是现在,你应该使用物品。
运行蜘蛛
现在是时候启动我们的蜘蛛了,因为我们完成了提取器方法并添加了要导出的项目。
要启动蜘蛛,请执行
scrapy crawl basic
从您的 crawler-projects 主文件夹(scrapy.cfg文件所在的位置)中。对我来说,这是
C:\wswp\chapter_4\sainsburys
根据您的日志记录配置,您可能会看到类似下面的内容:
018-02-11 13:52:20 [scrapy.utils.log] INFO: Scrapy 1.5.0 started (bot: sainsburys)
2018-02-11 13:52:20 [scrapy.utils.log] INFO: Versions: lxml 4.1.1.0, libxml2 2.9.5, cssselect 1.0.3, parsel 1.4.0, w3lib 1.19.0, Twisted 17.9.0, Python 3.6.3 (v3.6.3:2c5fed8, Oct 3 2017, 18:11:49) [MSC v.1900 64 bit (AMD64)], pyOpenSSL 17.5.0 (OpenSSL 1.1.0g 2 Nov 2017), cryptography 2.1.4, Platform Windows-10-10.0.16299-SP0
2018-02-11 13:52:20 [scrapy.crawler] INFO: Overridden settings: {'BOT_NAME': 'sainsburys', 'CONCURRENT_REQUESTS': 1, 'LOG_LEVEL': 'INFO', 'NEWSPIDER_MODULE': 'sainsburys.spiders', 'ROBOTSTXT_OBEY': True, 'SPIDER_MODULES': ['sainsburys.spiders'], 'USER_AGENT': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36'}
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled extensions:
['scrapy.extensions.corestats.CoreStats',
'scrapy.extensions.telnet.TelnetConsole',
'scrapy.extensions.logstats.LogStats']
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled downloader middlewares:
['scrapy.downloadermiddlewares.robotstxt.RobotsTxtMiddleware',
'scrapy.downloadermiddlewares.httpauth.HttpAuthMiddleware',
'scrapy.downloadermiddlewares.downloadtimeout.DownloadTimeoutMiddleware',
'scrapy.downloadermiddlewares.defaultheaders.DefaultHeadersMiddleware',
'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware',
'scrapy.downloadermiddlewares.retry.RetryMiddleware',
'scrapy.downloadermiddlewares.redirect.MetaRefreshMiddleware',
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware',
'scrapy.downloadermiddlewares.redirect.RedirectMiddleware',
'scrapy.downloadermiddlewares.cookies.CookiesMiddleware',
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware',
'scrapy.downloadermiddlewares.stats.DownloaderStats']
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled spider middlewares:
['scrapy.spidermiddlewares.httperror.HttpErrorMiddleware',
'scrapy.spidermiddlewares.offsite.OffsiteMiddleware',
'scrapy.spidermiddlewares.referer.RefererMiddleware',
'scrapy.spidermiddlewares.urllength.UrlLengthMiddleware',
'scrapy.spidermiddlewares.depth.DepthMiddleware']
2018-02-11 13:52:20 [scrapy.middleware] INFO: Enabled item pipelines:
[]
2018-02-11 13:52:20 [scrapy.core.engine] INFO: Spider opened
2018-02-11 13:52:20 [scrapy.extensions.logstats] INFO: Crawled 0 pages (at 0 pages/min), scraped 0 items (at 0 items/min)
2018-02-11 13:53:20 [scrapy.extensions.logstats] INFO: Crawled 220 pages (at 220 pages/min), scraped 205 items (at 205 items/min)
2018-02-11 13:54:20 [scrapy.extensions.logstats] INFO: Crawled 442 pages (at 222 pages/min), scraped 416 items (at 211 items/min)
2018-02-11 13:55:20 [scrapy.extensions.logstats] INFO: Crawled 666 pages (at 224 pages/min), scraped 630 items (at 214 items/min)
2018-02-11 13:56:20 [scrapy.extensions.logstats] INFO: Crawled 883 pages (at 217 pages/min), scraped 834 items (at 204 items/min)
...
2018-02-11 14:12:20 [scrapy.extensions.logstats] INFO: Crawled 4525 pages (at 257 pages/min), scraped 4329 items (at 246 items/min)
2018-02-11 14:13:01 [scrapy.core.engine] INFO: Closing spider (finished)
2018-02-11 14:13:01 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 11644228,
'downloader/request_count': 4720,
'downloader/request_method_count/GET': 4720,
'downloader/response_bytes': 72337636,
'downloader/response_count': 4720,
'downloader/response_status_count/200': 4718,
'downloader/response_status_count/302': 1,
'downloader/response_status_count/404': 1,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2018, 2, 11, 13, 13, 1, 337489),
'item_scraped_count': 4515,
'log_count/INFO': 27,
'offsite/domains': 1,
'offsite/filtered': 416,
'request_depth_max': 13,
'response_received_count': 4719,
'scheduler/dequeued': 4719,
'scheduler/dequeued/memory': 4719,
'scheduler/enqueued': 4719,
'scheduler/enqueued/memory': 4719,
'start_time': datetime.datetime(2018, 2, 11, 12, 52, 20, 860026)}
2018-02-11 14:13:01 [scrapy.core.engine] INFO: Spider closed (finished)
或者更多的信息在你的屏幕上嗡嗡作响。这是因为默认的日志记录级别。如果不显式地将它设置为INFO,就会得到所有信息Scrapy——开发者认为有用的信息。这些信息的一部分是收集的项目。在控制台上看到处理了哪些项目是很好的,但是对于超过 3000 个条目,这会生成大量不需要的输出。
prcedimg 输出的前几行告诉您运行了什么配置Scrapy。在这里,您可以看到中间件、管道、扩展,以及所有重要的东西,以便在遇到奇怪的结果时进行分析。
2018-02-11 13:53:20 [scrapy.extensions.logstats] INFO: Crawled 220 pages (at 220 pages/min), scraped 205 items (at 205 items/min)
随着时间的推移,屏幕上会弹出一个类似于上一行的新行。这告诉你当前的进度:抓取了多少页,提取了多少项,抓取的速度有多快。这些数字因您的设置而异:如果您增加并发请求并减少请求之间的延迟,速度会更快(当然,这取决于目标网站)。如果你觉得这样的统计很烦人,你可以通过在你的蜘蛛的settings.py中添加以下内容来禁用它们:
EXTENSIONS = {
'scrapy.extensions.logstats.LogStats': None
}
当抓取完成时,您将看到与此类似的摘要:
2018-02-11 14:13:01 [scrapy.core.engine] INFO: Closing spider (finished)
2018-02-11 14:13:01 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 11644228,
'downloader/request_count': 4720,
'downloader/request_method_count/GET': 4720,
'downloader/response_bytes': 72337636,
'downloader/response_count': 4720,
'downloader/response_status_count/200': 4718,
'downloader/response_status_count/302': 1,
'downloader/response_status_count/404': 1,
'finish_reason': 'finished',
'finish_time': datetime.datetime(2018, 2, 11, 13, 13, 1, 337489),
'item_scraped_count': 4515,
'log_count/INFO': 27,
'offsite/domains': 1,
'offsite/filtered': 416,
'request_depth_max': 13,
'response_received_count': 4719,
'scheduler/dequeued': 4719,
'scheduler/dequeued/memory': 4719,
'scheduler/enqueued': 4719,
'scheduler/enqueued/memory': 4719,
'start_time': datetime.datetime(2018, 2, 11, 12, 52, 20, 860026)}
2018-02-11 14:13:01 [scrapy.core.engine] INFO: Spider closed (finished)
在这些统计转储中,您可以找到整个抓取过程的摘要:请求、错误、不同的 HTTP 代码、抓取的项目数量、内存使用情况以及许多其他有用的东西。这可以让您知道在哪里启用扩展(例如,查找哪些外部域被触发或哪些页面没有找到)。
下载在 20 分钟内完成。这比我用第三章的基本刮刀运行要好很多(我让它在这次运行之前运行,用了 4009 秒)。我们不需要写这么多代码。
导出结果
现在您有了提取的数据,有了表示信息的项目,但是蜘蛛一完成,结果就消失了,Python 进程也从您的计算机内存中消失了。
幸运的是,Scrapy为你提供了内置的解决方案,但它们非常基础(你可以称之为原始的)。但是有一种方法可以插入您的自定义解决方案,并使刮刀正常工作。
在这一节中,我们将首先探索内置选项,看看它们是否真的如此简单。然后,我们将看看如何根据我们的需求形成导出—,是的,这需要编写一些代码。
因为Scrapy知道抓取会导致保存提取的信息,所以不需要您配置导出器管道。您可以通过命令行使用-o选项告诉Scrapy轻松导出抓取的结果。如果您提供正确的文件扩展名(.csv表示 CSV,.json表示 JSON),或者您也可以添加-t选项,并告诉您想要在指定的输出文件中使用什么格式的数据(使用-t提供的值必须是有效的提要导出器—,稍后将详细介绍)。
我在这些默认导出器中遇到的唯一问题是,它们将结果追加到文件中:如果文件不存在,就没有问题。但是,如果文件存在并且包含内容(例如来自以前运行的内容),则新数据会简单地附加到文件中,从而导致无效内容。
除了我将在下一节讨论的 JSON 和 CSV 导出器之外,您还可以以 XML、Pickle 或 Marshal 格式导出您的项目。它们通过内置的项目导出器完成,并使用已经提供的功能。
至 CSV
第一种方法是将所有内容导出到 CSV。正如您在上一段中看到的,您只需使用提供 CSV 文件的-o选项来运行蜘蛛。
scrapy crawl basic -o sainsburys.csv
如果刮刀完成了,你可以打开sainsburys.csv文件,看看它的内容。
item_code,nutritions,price_per_unit,product_image,product_name,product_origin,product_reviews,rating,unit,url
7906825,"{'Energy ': '762kJ/', 'Fat ': '9.8g', 'Saturates': '3.5g', 'Carbohydrates': '6.6g', 'Sugars': '3.5g', 'Protein ': '16g', 'Salt ': '1.71g'}",£3.00,https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg,Black Farmer Reduced Fat Sausages 400g,,0,0.0,,https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1200360&urlRequestType=Base&categoryId=352852&catalogId=10194&langId=44
注意
对于 Windows 用户,您可能会在文件中遇到额外的空行。这是因为目前Scrapy中一个公开的错误,但主要原因是操作系统之间的行尾差异。当我写这篇文章时,GitHub 5 已经有一个拉取请求;它已经被合并,我希望它可以在下一个发布的Scrapy版本中使用。
因为每一行都有很多内容,这里就不多列举了。但是您已经可以看到有趣的部分:营养栏(在我的例子中是第二栏)。它用花括号({})把营养词典写成文本。这不好;因此,我们将实现一个自定义项目导出器来处理这种情况。
至 JSON
导出到 JSON 的工作方式类似于 CSV:您提供一个 JSON 文件作为输出。
scrapy crawl basic -o sainsburys.json
结果是一个 JSON 文件,其中包含如下条目:
{
"url": "https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1200360&urlRequestType=Base&categoryId=352852&catalogId=10123&langId=44",
"product_name": "Black Farmer Reduced Fat Sausages 400g",
"product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg",
"price_per_unit": "\u00a33.00",
"rating": "0.0",
"product_reviews": "0",
"item_code": "7906825",
"nutritions": {
"Energy ": "762kJ/",
"Fat ": "9.8g",
"Saturates": "3.5g",
"Carbohydrates": "6.6g",
"Sugars": "3.5g",
"Protein ": "16g",
"Salt ": "1.71g"
},
"product_origin": ""
}
使用 JSON,nutrition字典非常适合导出的结果。琴键可能需要一点整理,但目前结构看起来很棒。
这里有一个小缺陷:那些讨厌的 Unicode 字符。要解决这个问题,将下面一行添加到您的settings.py文件中:
FEED_EXPORT_ENCODING = 'utf-8'
再次运行 scraper 后,相同的条目如下所示:
{
"url": "https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1200360&urlRequestType=Base&categoryId=276041&catalogId=10172&langId=44",
"product_name": "Black Farmer Reduced Fat Sausages 400g",
"product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg",
"price_per_unit": "£3.00",
"rating": "0.0",
"product_reviews": "0",
"item_code": "7906825",
"nutritions": {
"Energy ": "762kJ/",
"Fat ": "9.8g",
"Saturates": "3.5g",
"Carbohydrates": "6.6g",
"Sugars": "3.5g",
"Protein ": "16g",
"Salt ": "1.71g"
},
"product_origin": ""
}
作为整个 JSON 文件的替代,您可以使用 JSON-lines。这种格式将每一项都导出为一个 JSON 对象,这样可以处理大量数据,因为您不必将所有内容都加载到内存中,然后将它们放入一个 megaobject 中,以写入文件—或由目标平台读取。
Scrapy对于这种结果类型也有一个内置的导出器,您可以使用下面的命令来访问它:
scrapy crawl basic -o sainsburys.jl
如果您在运行蜘蛛程序时查看您的文件系统,您将会看到 JSON-lines 文件一旦被项目管道处理就被写入磁盘!您不必等到抓取完成后才获得有效的文件。
到数据库
对于数据库来说,没有现成的解决方案;不能在命令行中添加额外的参数来将结果写入数据库。
如果您希望您的数据存储在数据库中,那么您必须编写自己的解决方案。然而,因为在数据库中存储是我经常遇到的一个用例,所以我想把它添加到这一节中,而不是在下一节中写如何使用自己的导出器时。
我们将看看两种不同类型的数据库:MongoDB 和 SQLite。它们代表了目前使用的大多数数据库的方法,尽管其他基于云的存储解决方案正在兴起,但大多数客户端仍在使用这些类型的数据库。
MongoDB
首先,让我们创建项目管道。
import pymongo
class MongoDBPipeline(object):
def __init__(self, mongo_uri, mongo_db, collection_name):
self.mongo_uri = mongo_uri
self.mongo_db = mongo_db
self.collection_name = collection_name
@classmethod
def from_crawler(cls, crawler):
return cls(
mongo_uri=crawler.settings.get('MONGO_URI'),
mongo_db=crawler.settings.get('MONGO_DATABASE', 'items'),
collection_name=crawler.settings.get('MONGO_COLLECTION', 'sainsburys')
)
def open_spider(self, spider):
self.client = pymongo.MongoClient(self.mongo_uri)
self.db = self.client[self.mongo_db]
def close_spider(self, spider):
self.client.close()
def process_item(self, item, spider):
self.db[self.collection_name].insert_one(dict(item))
return item
使用任何数据库的想法是,您需要一个到目标数据库的连接,并且在完成后必须清理。上面的管道就是这样做的。
当抓取开始时,每次蜘蛛启动时都调用open_spider。close_spider在蜘蛛完成工作并解散时被调用。这是两种方法,你必须打开和关闭到数据库的连接。
process_item处理该项目,在这种情况下,该项目存储在数据库中。
但是最有趣的方法是from_crawler。如果存在,它必须返回管道的新实例。提供给该方法的crawler应该用于访问特定于 crawler 的设置。在这个例子中,我们得到连接、数据库和收集设置—,其中最后两个有默认值,您不必提供它们。
为了让您的管道工作,您必须在settings.py中配置它。
ITEM_PIPELINES = {
'sainsburys.pipelines.MongoDBPipeline': 300
}
然后,您需要提供数据库配置。您可以在settings.py文件中这样做(这使得配置是硬编码的):
MONGO_URI = 'localhost:27017'
或者您可以在启动 spider 时通过命令行提供它:
scrapy crawl basic -s MONGO_URI=localhost:27017
因为我们使用的是pymongo,我们甚至不需要提供数据库 URI。在这种情况下,pymongo会创建一个到localhost:27017的默认连接。
运行完蜘蛛后,我们可以在数据库中看到结果,如图 4-2 所示。
图 4-2
与以前—相同的项目现在在 MongoDB 中
您可以在本章的源代码中找到一个使用 MongoDB 将提取的信息存储在文件夹03_mongodb中的蜘蛛。
数据库
类似于 MongoDB 解决方案,当使用 SQLite 数据库时,您必须分别在蜘蛛启动和结束时打开和关闭连接。
因为处理营养表变得太复杂(有 70 个字段,可以减少),所以我不会实现导出的这一部分。如果你感兴趣并想尝试一下,不要被我的方法吓倒!
首先,我定义了表 DDL 和 insert 语句。
sqlite_ddl = """
CREATE TABLE IF NOT EXISTS {} (
item_code INTEGER PRIMARY KEY,
product_name TEXT NOT NULL,
url TEXT NOT NULL,
product_image TEXT,
product_origin TEXT,
price_per_unit TEXT,
unit TEXT,
product_reviews INTEGER,
rating REAL
)
"""
sqlite_insert = """
INSERT OR REPLACE INTO {}
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
然后我写了代码。
class SQLitePipeline:
def __init__(self, database_location, table_name):
self.database_location = database_location
self.table_name = table_name
self.db = None
@classmethod
def from_crawler(cls, crawler):
return cls(
database_location=crawler.settings.get('SQLITE_LOCATION'),
table_name=crawler.settings.get('SQLITE_TABLE', 'sainsburys'),
)
def open_spider(self, spider):
self.db = sqlite3.connect(self.database_location)
self.db.execute(sqlite_ddl.format(self.table_name))
def close_spider(self, spider):
if self.db:
self.db.close()
def process_item(self, item, spider):
if type(item) == SainsburysItem:
self.db.execute(sqlite_insert.format(self.table_name),
(
item['item_code'], item['product_name'], item['url'], item['product_image'],
item['product_origin'], item['price_per_unit'],
item['unit'] if hasattr(item, 'unit') else None,
int(item['product_reviews']) if hasattr(item, 'product_reviews') else None,
float(item['rating']) if hasattr(item, 'rating') else None
)
)
self.db.commit()
如您所见,该类的工作方式与上一个示例中的 MongoDB 管道几乎相同。当你把它插入数据库时,有趣的部分就来了。因为我们有一些可为空的字段(以及不必存在于项目中的属性),所以我们必须确保在保存时不会遇到 Python 错误。
为了测试代码,您必须将管道添加到settings.py。
ITEM_PIPELINES = {
'sainsburys.pipelines.MongoDBPipeline': None
'sainsburys.pipelines.SQLitePipeline': 300
}
现在您可以运行应用了。
scrapy crawl basic -s SQLITE_LOCATION=sainsburys.db
不要忘记添加带有-s设置标志的 SQLite 位置。没有这个你会得到一个异常。
您可以在本章的源代码中找到一个使用 SQLite 将提取的信息存储在文件夹04_sqlite中的蜘蛛。
自带出口商
如果您一直坚持下去,并且认为默认的导出解决方案不符合您的需要,那么这一节是最有趣的。
除了项目管道(我们为数据库连接实现的),您还可以定义自己的提要导出器。这些工作方式类似于内置的 CSV、XML 和 JSON 导出器,但适合您的口味。在本节中,我们将研究这两种方法,即使您已经为数据库存储编写了两个项目管道。
现在,您将实现一个 CSV 管道来正确处理nutritions字段:您将把字段追加到主内容中,而不是以纯文本形式编写整个字典。
这要求你像使用Beautiful Soup,一样将提取的项目存储在缓存中,因为你无法知道在所有项目中可能遇到的字段。记住:网站上有多个不同的营养表,这些营养表或多或少有相同的字段。
过滤重复项
你还记得 SQLite 管道。当我们将一个项目保存到数据库中时,我们在那里定义了INSERT OR REPLACE INTO。这是因为在网站的不同页面上可以找到重复的项目。
使用 SQLite 可以很容易地克服这个问题,但是使用其他导出方式会得到太多的数据,重复的数据总是不好的。当然,后处理(您的客户或数据挖掘算法)可以解决这个问题,但为什么不是您呢?
因为Scrapy是高度可扩展的,所以您将基于商品代码创建一个重复的过滤器。
from scrapy.exceptions import DropItem
class DuplicateItemFilter:
def __init__(self):
self.item_codes_seen = set()
def process_item(self, item, spider):
if item['item_code'] in self.item_codes_seen:
raise DropItem("Duplicate item found: %s" % item['item_code'])
self.item_codes_seen.add(item['item_code'])
return item
前面的代码将看到的项目代码存储在内部的set,中,如果项目代码已经被看到,那么它将丢弃该项目。
要启用此管道,请将以下代码添加到您的settings.py文件中:
ITEM_PIPELINES {
'sainsburys.pipelines.DuplicateItemFilter': 1
}
为管道设置一个较低的值可以确保重复项在到达时就被过滤掉,从而为其他任务节省大量工作。
您可以将这样的过滤器管道项目用于各种可能的过滤。如果您不希望某个项目出现在最终的导出中,那么您可以创建一个过滤器管道,将它添加到您的settings.py中,它会处理丢失的值。
无声地丢弃项目
如果您添加上一节中的条目过滤器并运行您的蜘蛛程序,您将会看到许多类似这样的条目:
2018-02-13 09:48:42 [scrapy.core.scraper] WARNING: Dropped: Duplicate item found: 7887890
{'image_urls': ['https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/74/0000000306874/0000000306874_L.jpeg'],
'item_code': '7887890',
'nutritions': {'Carbohydrate': '13.7g',
'Energy': '664kJ',
'Energy kcal': '158kcal',
'Fat': '6.0g',
'Fibre': '2.6g',
'Mono-unsaturates': '3.5g',
'Polyunsaturates': '1.5g',
'Protein': '11.1g',
'Salt': '0.91g',
'Saturates': '0.5g',
'Starch': '10.5g',
'Sugars': '3.2g'},
'price_per_unit': '£2.50',
'product_image
': 'https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/74/0000000306874/0000000306874_L.jpeg',
'product_name': "Sainsbury's Mediterranean Tuna Fishcakes, Taste the "
'Difference 300g',
'product_origin': 'Produced in United Kingdom Produced using Yellowfin tuna '
'caught by hooks and lines in the Western Indian Ocean, '
'Eastern Indian Ocean, Western Central Pacific Ocean and '
'Eastern Central Pacific Ocean',
'product_reviews': '4',
'rating': '2.0',
'url': 'https://www.sainsburys.co.uk/shop/gb/groceries/all-fish-seafood/sainsburys-mediterranean-tuna-fishcakes--taste-the-difference-300g'}
一种解决方案是将LOG_LEVEL提高到ERROR,但是使用这种方法,您最终会跳过其他警告,这些警告对于分析非预期行为很有用。
另一个解决方案是为丢弃的项目编写自己的日志格式化程序。
from scrapy import logformatter
import logging
class SilentlyDroppedFormatter(logformatter.LogFormatter):
def dropped(self, item, exception, response, spider):
return {
'level': logging.DEBUG,
'msg': logformatter.DROPPEDMSG,
'args': {
'exception': exception,
'item': item,
}
}
要使用该格式化程序,您必须在settings.py文件中启用它。
LOG_FORMATTER = 'sainsburys.formatter.SilentlyDroppedFormatter'
在本章的源代码中,您可以使用文件夹05_item_filter中的重复项目过滤器找到一个蜘蛛。
修复 CSV 文件
你还记得目前导出的 CSV 文件有什么问题吗?是的,他们将营养信息以纯文本的形式写入 CSV 文件的一列。这并不理想。
除此之外,列的顺序可能在不同的运行中有所不同,因为它们存储在一个字典中。 6
您将实现一个项目管道,该管道在抓取过程中存储每个项目,并且仅在蜘蛛完成时导出。
class CsvItemPipeline:
def __init__(self, csv_filename):
self.items = []
self.csv_filename = csv_filename
@classmethod
def from_crawler(cls, crawler):
return cls(
csv_filename=crawler.settings.get('CSV_FILENAME', 'sainsburys.csv'),
)
def open_spider(self, spider):
pass
def close_spider(self, spider):
import csv
with open(self.csv_filename, 'w', encoding='utf-8') as outfile:
spamwriter = csv.DictWriter(outfile, fieldnames=self.get_fieldnames(), lineterminator="\n")
spamwriter.writeheader()
for item in self.items:
spamwriter.writerow(item)
def process_item(self, item, spider):
if type(item) == SainsburysItem:
new_item = dict(item)
new_item.pop('nutritions')
new_item.pop('image_urls')
self.items.append({**new_item, **item['nutritions']})
return item
def get_fieldnames(self):
field_names = set()
for product in self.items:
field_names.update(product.keys())
return field_names
您可以看到,每一个被处理的条目都被转换成一个包含原始条目所有字段的新字典,然后删除nutritions和image_urls,最后通过合并两个字典将原始的nutritions字典添加到这个新条目中,并将结果存储在内存中以备后用。
当蜘蛛完成时,所有不同的字段名都从所有条目中提取出来,并用作 CSV 头。Python 安装之间的顺序仍然不同。为了固定顺序(至少对于不是营养信息的标准属性),您可以定义一个基本属性列表,然后添加缺少的值—,如下所示:
class CsvItemPipeline:
fieldnames_standard = ['item_code', 'product_name', 'url', 'price_per_unit', 'unit', 'rating', 'product_reviews','product_origin', 'product_image']
def get_fieldnames(self):
field_names = set()
for product in self.items:
for key in product.keys():
if key not in self.fieldnames_standard:
field_names.add(key)
return self.fieldnames_standard + list(field_names)
和往常一样,您可以将这个管道添加到您的settings.py文件中。
ITEM_PIPELINES = {
'sainsburys.pipelines.CsvItemPipeline': 800,
}
但是,使用这种方法,每次运行 spider 时都会写入 CSV 文件,即使导出为不同的格式或者不想导出。
为了解决这个问题,让我们实现一个 feed exporter。
您可以在本章的源代码中的文件夹06_csv_pipeline中找到一个使用这个 CSV 项目管道的蜘蛛。
CSV 项目出口商
提要导出类似于项目管道,但是您可以用一种通用的方式编写它们,并按需使用它们,而不需要更改settings.py文件。
当您使用-o输出文件将信息保存到 CSV、JSON 或 JSON-lines 文件时,您已经使用了 feed exporters(项目导出器的替代名称),并且Scrapy可以导出要使用的导出器,或者您可以提供-t选项并告诉Scrapy您想要使用哪个导出器。以下列表包含当前内置的提要导出器:
-
csv:将信息保存为 CSV 格式 -
json:将信息保存为 JSON -
jsonlines:将信息保存为 JSON-lines -
xml:将信息保存为 XML 格式 -
pickle:将信息保存为 Pickle 数据 -
marshal:以编组格式保存信息,这类似于 Pickle(特定于 Python ),但是没有任何机器架构问题
因为项目导出器类似于项目管道,它们一次只处理一个项目,所以我们必须像对待CsvItemPipeline类一样,将项目保存在内存中。基本上我们会重用已经写好的代码,重命名一些方法。
from scrapy.exporters import BaseItemExporter
import io
import csv
class CsvItemExporter(BaseItemExporter):
fieldnames_standard = ['item_code', 'product_name', 'url', 'price_per_unit', 'unit', 'rating', 'product_reviews',
'product_origin', 'product_image']
def __init__(self, file, **kwargs):
self._configure(kwargs)
if not self.encoding:
self.encoding = 'utf-8'
self.file = io.TextIOWrapper(file,
line_buffering=False,
write_through=True,
encoding=self.encoding)
self.items = []
def finish_exporting(self):
spamwriter = csv.DictWriter(self.file,
fieldnames=self.__get_fieldnames(),
lineterminator='\n')
spamwriter.writeheader()
for item in self.items:
spamwriter.writerow(item)
def export_item(self, item):
new_item = dict(item)
new_item.pop('nutritions')
new_item.pop('image_urls')
self.items.append({**new_item, **item['nutritions']})
def __get_fieldnames(self):
field_names = set()
for product in self.items:
for key in product.keys():
if key not in self.fieldnames_standard:
field_names.add(key)
return self.fieldnames_standard + list(field_names)
但是项目出口商有一个问题:他们不删除文件,他们附加到它。幸运的是,有一个解决方案:您可以使用truncate()方法将文件截断为 0 字节。扩展的构造函数如下所示:
def __init__(self, file, **kwargs):
self._configure(kwargs)
if not self.encoding:
self.encoding = 'utf-8'
self.file = io.TextIOWrapper(file,
line_buffering=False,
write_through=True,
encoding=self.encoding)
self.file.truncate(0)
self.items = []
同样,我们必须将物品出口商添加到settings.py中,让Scrapy知道您可以使用另一个选项。
FEED_EXPORTERS = {
'mycsv': 'sainsburys.exporters.CsvItemExporter'
}
在这里,您提供了mycsv作为饲料出口商的名称。这意味着,以后你可以使用-t选项和mycsv作为参数来调用蜘蛛。
scrapy crawl basic -o mycsv.csv -t mycsv
在本章的源代码中,您可以在文件夹07_csv_feed_exporter中找到一个使用刚刚创建的 feed exporter 的示例蜘蛛。
使用Scrapy缓存
尽管我认为使用缓存是一个高级的配置选项,但我已经为这个主题增加了一个额外的部分。这是因为它将您的执行时间提高了数倍,并且一旦您在本地缓存了网站,您就可以随心所欲地调整 scraper 脚本,而不会使目标服务器过载。
如果您想配置缓存,例如在开发脚本时,在Scrapy中有一些选项。当然,你可以像上一章一样编写自己的缓存,但是在你投入时间、精力和脑细胞来编写你的缓存之前,让我们看看现在有什么,我们可以利用什么。
Scrapy提供缓存。默认配置禁用缓存;这意味着,每次你请求时,每个页面都会被下载。但是正如你所知道的,有很多旋钮可以调节,你可以通过HTTPCACHE_ENABLED = True设置来启用缓存。
有三个现成的 HTTP 缓存选项可供您使用:
-
文件系统存储
-
DBM 存储
-
LevelDB 存储
和往常一样,你也可以写你自己的解决方案;然而,我认为这个场景不太可能,因为 90%的用例可以被内置的解决方案覆盖。
我的默认缓存配置如下:
HTTPCACHE_ENABLED = True
HTTPCACHE_EXPIRATION_SECS = 0
HTTPCACHE_DIR = 'httpcache'
HTTPCACHE_IGNORE_HTTP_CODES = []
这样你就可以启用缓存,当你运行你的蜘蛛时,它会将你的文件系统上的每个请求-响应对存储在你的项目目录的.scrapy/httpcache文件夹中,从现在开始,当你重新运行你的蜘蛛时,它会使用这个缓存。这是调整脚本的理想方法:下载目标网站的快照,并使用它来微调项目提取。
如果您有任何不希望缓存的 HTTP 响应代码,您可以将它们添加到HTTPCACHE_IGNORE_HTTP_CODES列表中,例如:
HTTPCACHE_IGNORE_HTTP_CODES = [503, 418]
将HTTPCACHE_EXPIRATION_SECS设置为0会使文件始终在缓存中。如果给它一个正值,旧的缓存文件将被丢弃。注意该设置需要以秒为单位的数值!
让我们看看缓存提供了什么!
存储解决方案
在本节中,我们将了解Scrapy为缓存提供的不同存储解决方案。开箱后,您有以下选项可用:
-
文件系统存储
-
DBM 存储
-
LevelDB 存储
但是因为您可以轻松地扩展Scrapy,所以您可以编写自己的存储解决方案(例如使用一个定制的数据库,比如 MongoDB)。
如果你问我,我对基于文件系统的解决方案很满意。但是,如果您是按需运行的(例如在云或容器环境中),您可能会喜欢远程缓存服务,这很可能是基于数据库的。
文件系统存储
如果启用 HTTP 缓存,这是使用的默认解决方案。即使这是默认的,您也可以将下面一行添加到您的settings.py文件中:
HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.FilesystemCacheStorage'
使用这个存储选项,所有请求和响应都被下载并存储在一个文件夹中,该文件夹的名称对于这个 scraper 是唯一的,长度为 40 个字符。在这些文件夹中是标识请求和响应的所有信息,中间件需要这些信息来标识应该从缓存中提供服务的页面。
DBM 存储
要激活 DBM 7 存储器,只需添加(或替换,如果存在的话)。
HTTPCACHE_STORAGE = 'scrapy.extensions.httpcache.DbmCacheStorage'
默认设置是使用anydbm模块,但您可以使用HTTPCACHE_DBM_MODULE设置进行更改。
LevelDB 存储
您还可以使用 LevelDB 8 (一种快速的键值存储)作为您的缓存,但是在项目的开发阶段并不鼓励这样做,因为它只允许一个进程同时访问数据库。如果你只是运行你的蜘蛛,这是可以的,但是如果你想为你的项目打开Scrapy shell 并运行蜘蛛,你将会以一个错误结束。
要使用 LevelDB,您可以将settings.py文件中的HTTPCACHE_STORAGE更改为'scrapy.extensions.httpcache.LeveldbCacheStorage',并使用以下命令安装 LevelDB:
pip install leveldb
缓存策略
Scrapy 提供了两个默认的缓存策略:
-
虚拟政策
-
RFC2616 政策
虚拟政策
虚拟策略是默认设置。在这里,存储每个请求及其响应,当再次看到相同的请求时,返回存储的响应。如果您正在测试您的蜘蛛,并希望同时重放运行,这是非常有用的。
因为这是默认策略,所以您不必向项目的settings.py文件添加任何内容。
RFC2616 政策
该策略知道缓存控制设置,旨在用于生产,以避免下载未更改的页面,节省带宽,并加速爬网。
要启用此策略,请将以下设置添加到您的settings.py文件中:
HTTPCACHE_POLICY = scrapy.extensions.httpcache.RFC2616Policy
知道缓存控制设置是什么意思?这意味着 scraper 根据 RFC2616 缓存规范工作。如果你很懒,不想读完整的规范,这里有一小段摘录Scrapy能为你做什么:
-
如果网站提供了一个
no-store响应,Scrapy不会尝试存储请求或响应。 -
如果设置了
no-cache指令,Scrapy不会从缓存返回响应,即使是最近下载的。 -
它从
Age或Date头计算当前年龄。 -
它根据
max-age指令、Expires和Last-Modified响应头来计算刷新寿命。
然而,在撰写本书时,一些 RFC2616 合规性要求并未得到满足,例如:
-
Pragma: no-cache支持 -
Vary表头支持 -
更新或删除后失效
下载图像
尽管这不是我们项目的要求,但是您将会遇到许多除了数据之外还必须下载图像的任务。幸运的是,Scrapy对于这个问题也有一个内置的解决方案。
对于本节,让我们扩展我们的需求,收集图片和物品。除了项目文件之外,这些图像将保存在您的文件系统中,但是您可以配置您的蜘蛛将下载的文件存储在亚马逊 S3 或谷歌云。
因为Scrapy使用 Pillow 来调整图像大小和生成缩略图,所以您必须在开始收集图像之前安装它。
pip install pillow
首先,将以下内容添加到您的settings.py文件中:
ITEM_PIPELINES = {
'scrapy.pipelines.images.ImagesPipeline': 5
}
你必须告诉 Scrapy 把下载的图像保存在哪里。我使用项目中的图像文件夹。
IMAGES_STORE = 'images'
您提供给IMAGES_STORE的文件夹必须存在。
这两种设置的组合会激活图像管道,图像管道会下载文件并将它们储存在您的电脑硬盘上。
要将项目放入此管道,您必须添加
image_urls = Field()
images = Field()
敬你的Item。这是因为ImagesPipeline使用image_urls字段工作,并将结果图像添加到images字段。
在 Sainsbury's scraper 的例子中,我们必须将product_image重命名为image_urls,在SainsburysItem中添加images,并更改蜘蛛代码,用列表而不是 URL 填充image_urls。
item['image_urls'] = [response.urljoin(response.xpath('//div[@id="productImageHolder"]/img/@src').extract()[0])]
现在,如果你运行你的蜘蛛并保存结果(例如使用scrapy crawl basic -o images.jl),你将会在images/full文件夹中看到下载的图像,类似于图 4-3 所示。
图 4-3
Scrapy 运行时下载的图像
images.jl文件中的值被插入到项目的images字段中。示例值如下所示:
"images": [
{
"url": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/23/5060084344723/5060084344723_L.jpeg",
"path": "full/4ae5a3a0dfa0fac7f3728d76b788716e8a2bc9fb.jpg",
"checksum": "132512348d379f8365ca02082a16adf1"
}
]
这不仅会告诉你文件在你的文件系统上是如何命名的,以及它是从哪里下载的,而且你还会得到一个校验和来验证你的文件系统上的映像是否真的与Scrapy下载的相同。
在前面的例子中,文件可以在images/full/4ae5a3a0dfa0fac7f3728d76b788716e8a2bc9fb.jpg下找到,如图 4-4 所示。
注意
您可以在本章的来源中的08_image_pipeline文件夹中找到本节的来源。
图 4-4
下载图像的示例
Scrapy 使用自己的算法来生成文件名。这意味着你可以遇到不同的文件名比我,如果你在你的电脑上运行蜘蛛。
使用Beautiful Soup和Scrapy
有时候你已经准备好了一个用Beautiful Soup创建的 HTML 提取器,你不想把它转换成Scrapy代码。或者你有一个团队成员是 ?? 的专家,她创建了提取代码;你只需要负责配置Scrapy。
在这种情况下,您可以使用已经存在的代码,因为您可以集成Beautiful Soup和Scrapy。
def parse_product_details_bs(self, response):
item = SainsburysItem()
from bs4 import BeautifulSoup
soup = BeautifulSoup(response.text, 'lxml')
h1 = soup.find('h1')
if h1:
item['product_name'] = h1.text.strip()
在前面的代码中,你可以看到Beautiful Soup和Scrapy与前一章代码子集的集成。我显式地使用了lxml来提高解析速度,但是您可以使用任何可用的解析器(顺便说一下,当您安装Scrapy时,lxml是现成可用的)。
有了这些信息,你可以重写蜘蛛程序来使用第三章中写的功能。您可以在本章的源代码中的09_beautifulsoup文件夹中找到一个示例解决方案。
记录
有时候,您更喜欢在抓取时在控制台中看到自定义消息。如果您将Scrapy的日志级别削减到INFO,但是您想要查看当前进程的更多信息,这是非常有用的。
每个蜘蛛都有一个记录器,你可以通过它的方法访问它。例如,记录响应的 URL 如下所示:
self.logger.info("URL: %s", response.url)
记录器使用您在settings.py中配置的相同日志级别。如果您在控制台上没有看到日志输出,您可以打开日志记录(将级别降低到DEBUG)。如果它仍然没有出现,那么您可以确定在运行时没有到达代码。
如果你想做标准的日志记录,而不在你的蜘蛛中使用日志记录器(例如,因为你在一个不同的文件中,在那里你不能访问一个蜘蛛),你可以使用Scrapy的log模块(该模块已被否决,所以你不应该使用它)或者 Python 的内置logger模块。没有什么考虑;logger与在“标准”Python 应用中的工作方式相同。
(有点)高级配置
因为你可以打开你的Scrapy项目上的很多旋钮,所以我增加了一个部分让你开始尝试一些不同的组合。
这本书有篇幅限制;因此,我不会列出您可以切换的每个设置,只列出最常用的设置。更多设置,看看Scrapy的文档: https://doc.scrapy.org 。
LOG_LEVEL
在运行蜘蛛程序时,阅读这一章给了你很多输出。但是,您可以将信息限制在一个子集内。
默认情况下,Scrapy使用DEBUG日志级别进行输出。它记录了您可以从代码中获得的每一点信息,大多数情况下这太多了。
但是,您可以通过添加以下行来限制settings.py文件中的日志级别:
LOG_LEVEL = 'INFO'
这会将日志级别设置为仅记录信息以及警告和错误消息。这是因为日志记录级别的工作方式。每一项都有一个优先级,通过日志级别设置,您可以告诉应用“记录具有该优先级及更高优先级的项目”
您可以使用以下列表作为日志级别优先级的参考:
-
批评的
-
错误
-
警告
-
信息
-
调试
该列表包含Scrapy的日志级别设置。在开发过程中,调试是一个很好的设置,但是在一个正在运行的/实时的系统中,我更喜欢 INFO 或者有时 WARNING 作为日志级别。根据开发人员的不同,您可以使用这个级别获得适量的信息。
CONCURRENT_REQUESTS
你已经在本章开始看到了这个设置。顾名思义,您可以限制对一个网站的并发请求数量。
根据网站的不同,将这个数字调高一点或保持默认值是有意义的。这是因为网络操作(下载网站的代码)需要时间,当线程等待时,进程/应用处于空闲状态。在这种情况下,即使有 GIL,Python 也可以并行执行多个线程,因此当您的代码等待一个页面加载时,您可以下载更多。
然而,你不能永远转动旋钮。您的计算机也有它的极限,16 或 160 个并发请求并没有什么不同。我建议你在开发时从 1 个请求开始,然后使用默认设置 16。这对你有好处,因为你可以更快地获得所需的数据,这对目标网站也有好处,因为它不会被你淹没。
此外,有时目标网站会启用请求监控。这意味着,请求和它们的间隔被监控和评估,如果你的 IP 超过一个阈值,你会被禁止访问这个网站—一段时间,有时是永远。因此,请对您的配置负责。
DOWNLOAD_DELAY
伴随并发请求,您也可以设置两次下载之间的延迟。下载延迟告诉蜘蛛在从相同的域或 IP 地址下载另一个页面之间应该等待多少秒(如果CONCURRENT_REQUESTS_PER_IP被设置为非零正数)。
该配置等待秒作为值,但是您也可以提供十进制值。
DOWNLOAD_DELAY = 0.125 # 125 milliseconds
此设置用于避免您的请求对目标服务器造成太大的冲击。有时,这种设置有助于避免检测和模仿类似人类的行为。
自动节流
以前,您已经看到了如何设置硬下载延迟和并发请求,以表现得像一个好公民。然而,使用这种方法,如果服务器繁忙,您可能会有许多请求等待完成。或者,如果服务器开始发回错误消息,这些消息会比200 OK响应更快地返回,后者每秒生成更多请求,因为Scrapy处理错误的速度更快。然而,在出现错误的情况下,scraper 应该发送更少的请求来帮助服务器从其故障状态(希望是暂时的)中恢复。
一个解决方案,也是另一种方法,是使用Scrapy的自动节流特性。默认情况下不启用此功能;您必须使用以下设置来启用它:
AUTOTHROTTLE_ENABLED = True
该设置背后的算法是根据服务器的响应时间来调整下载延迟。如果服务器很忙,它会稍后发送响应,而Scrapy会调整下载延迟以降低发送请求的频率。如果服务器没有困难,下载延迟会减少,更多的请求会发送到服务器。最重要的是:非 ?? 响应不会减少下载延迟。
您也可以为自动节流配置一些设置。例如,设置
AUTOTHROTTLE_START_DELAY = 15
你告诉Scrapy在两个请求之间等待 15 秒。根据服务器的响应时间,Scrapy可以减少或延长这个等待时间。如果延迟很大,Scrapy会增加延迟。然而,你可以给它一个最大值,它不会等待更长时间。
AUTOTHROTTLE_MAX_DELAY = 25
该设置告诉Scrapy最多等待 25 秒,直到下一个请求。
要获得所有请求及其响应的详细信息,可以启用自动节流调试。
AUTOTHROTTLE_DEBUG = True
COOKIES_ENABLED
你知道饼干。它们是存储在浏览器中的设置,每次请求服务器时都会进行交换。它们存储有关您的会话、浏览偏好或网站设置的信息。有时他们需要证明你正在使用浏览器。有时你必须避免子集,因为它们告诉服务器你没有使用浏览器。如果你在欧盟(EU)浏览,你会通过访问几乎所有的欧盟网站得到关于 cookies 的通知。这很烦人,但是要知道网站存储了你的浏览历史信息。
正如你所想,有时需要使用 cookies(例如需要登录的网站),但有时最好避免使用它们。
Scrapy中的默认设置是使用 cookies。这意味着每次目标 web 服务器返回一个 HTTP 参数Set-Cookie,它的值被Scrapy存储在内部,并随着每个新请求被发送回服务器。
您可以通过将以下配置添加到您的settings.py文件中来禁用此设置:
COOKIES_ENABLED = False
如果您想调试服务器和您的蜘蛛之间交换了哪些 cookies,您可以添加以下配置:
COOKIES_DEBUG = True
这将把每个发送的 cookie(请求中的Cookie头)和接收的 cookie(响应中的Set-Cookie头)记录到控制台或您指定的日志框架中。
摘要
在这一章中,你学习了网站抓取工具Scrapy。你用Scrapy实现了第二章需求的刮刀。你已经看到,你需要写的比你使用自制的蜘蛛少得多,你必须处理请求—只是举一个例子。
您还学习了一些高级主题,如编写自己的中间件、管道和扩展,以及如果您打开配置面板上的一些旋钮会有什么结果。
现在你已经是一个十足的网站刮刀了。您拥有可以完成 75%的刮擦工作的工具。请随意停止阅读,但请记住,随着大量 JavaScript 网站的出现,这 75%正在减少,这些网站动态呈现数据。
下一章将讨论一个我很少使用的高级主题:用 JavaScript 处理网页。有不同的方法,我们将更深入一些,因为我将向您展示除 Selenium 之外的选项。如果你对“为什么”感兴趣?,“继续读!
Footnotes 1https://docs.scrapy.org/en/latest/intro/install.html#intro-install
2
3
4
有一次我们的客户因为一分钟内请求太多而被禁止进入 StackOverflow (SO)。大约有 100 名软件开发人员在没有 SO 的情况下日子不好过。
5
https://github.com/scrapy/scrapy/pull/3039
6
在 Python 的当前版本中,默认情况下,字典是按关键字排序的。这意味着每次在相同的 3.6 CPython 实现上运行蜘蛛时,列的顺序将保持不变。
7
https://en.wikipedia.org/wiki/Dbm
8
https://github.com/google/leveldb
五、处理 JavaScript
本章是关于处理利用 JavaScript 动态呈现信息的网站。
在前面的章节中,你已经看到了一个基本的网站抓取器加载网页的内容,并对源代码进行提取。如果包含了 JavaScript,它就不会被执行,页面中的动态信息也会丢失。
这很糟糕,至少在你需要动态数据的情况下。
抓取使用 JavaScript 的网站的另一个有趣的部分是,你可能需要点击或按钮才能进入正确的页面/获得正确的内容,因为这些操作调用了一系列 JavaScript 函数。
现在我会给你如何处理这些问题的选择。大多数时候,如果你用谷歌或其他引擎搜索互联网,你会发现 Selenium 是解决方案。然而,还有其他的选择,我会给你更多的见解。也许其他的选择会更符合你的需求。
逆向工程
第一种选择是高级开发人员的—至少我觉得高级开发人员会做更多的逆向工程。
这里的想法是使用 Chrome 的 DevTools(或其他浏览器中的类似功能),启用 JavaScript,并监控XHR网络流,以找出哪些数据是从服务器请求的,并单独呈现。
有了目标端点(或者是一个GET或者是一个POST请求),您就可以看到提供哪些参数以及它们如何影响结果。
让我们看一个简单的例子:在kayak.com你可以搜索航班,因此也可以搜索机场。在这个简单的例子中,我们将对目的地搜索端点进行逆向工程,以提取一些信息,即使这些信息没有价值。
对于这些例子,我将使用 Chrome。这是因为我使用 Chrome 来完成所有的抓取任务。如果你知道如何使用开发者工具,它也可以和 Firefox 一起工作。
首先让我们转到kayak.com,打开 DevTools 窗口,在那里找到网络选项卡,如图 5-1 所示。
图 5-1
打开 DevTools 的 Kayak.com
正如你在图中看到的,我已经导航到了网络选项卡内的 XHR 选项卡,因为所有的 AJAX 和 XHR 调用都列在这里。
现在让我们点击网站上标有 **To 的字段。**并输入一个字母,例如S,观察 XHR 页签内右侧的数值,如图 5-2 所示。
图 5-2
机场的小名单
现在你在网站上得到一些可能的机场列表,但也有两个 XHR 请求。我们对以marvel开头的请求感兴趣:
www.kayak.com/mv/marvel?f=h&where=so&s=50&lc_cc=US&lc=en&v=v2&cv=5.
这是返回机场信息的请求。它有一些参数,我不知道它们是做什么的,如果改变,结果会受到什么影响,但我知道的是:
-
where是您正在寻找的密钥 -
s是搜索的类型;58是机场 -
lc是区域设置;您可以更改它,并在稍后获得不同的结果— -
v是版本;如果你选择v1而不是默认的v2,那么结果格式会有一点点不同
根据这些信息,我们能从中得到什么?我们了解了一些机场,以及一些关于如何对 JavaScript 进行逆向工程以及何时决定使用不同工具的想法。
在这个例子中,JavaScript 呈现是一个简单的 HTTP GET调用—没有什么特别的,我打赌您已经知道如何提取这些端点传递的信息。是的,使用requests和漂亮的汤库或者 Scrapy 和一些Request物品。
回到这个例子:当您改变lc的值时,例如,将请求中的de或es,您将得到不同的机场以及在您选择的地区中对这些机场的描述。这意味着 JavaScript 逆向工程不仅仅是找到您想要使用的正确调用,还需要一些思考。
关于逆向工程的思考
如果您发现自己有一个利用 HTTP 端点获取数据的搜索,您可以尝试弄清楚这个搜索是如何工作的。例如,尝试添加搜索表达式,而不是发送一些您期望交付结果的值。这样的表达式可以是*匹配全部,.+计算正则表达式,或者%如果它后面有某种 SQL 查询。
摘要
你看,有时 JavaScript 逆向工程是有回报的:你知道那些讨厌的 XHR 调用是简单的请求,你可以在你的脚本中覆盖它们。然而,有时 JavaScript 会做出更复杂的事情,比如在初始页面加载后呈现和加载数据。相信我,你不会想逆向工程的。
溅泼的量
Splash 1 是用 Python 编写的开源 JavaScript 渲染引擎。它是轻量级的,并与 Scrapy 顺利集成。
它被维护着,并且每隔几个月就会发布新的版本。
设置
Splash 的基本和最简单的用法是从开发人员那里获得一个 Docker 映像并运行它。这确保您拥有项目所需的所有依赖项,并且可以开始使用它。在本节中,我们将使用 Docker。
如果您还没有 Docker,请安装它。你可以在这里找到更多关于安装 Docker 的信息: https://docs.docker.com/manuals/ 。
如果这样做了,您可以在控制台上执行以下命令来获得映像:
docker pull scrapinghub/splash
docker run -p 5023:5023 -p 8050:8050 -p 8051:8051 scrapinghub/splash
注意
在某些机器上,启动 Splash 需要管理员权限。例如,在我的 Windows 10 电脑上,我必须从管理员控制台运行 docker 容器。在类似 Unix 的机器上,您可能需要使用sudo来运行容器。
现在 Splash 正在 localhost:8050 上运行,它看起来应该如图 5-3 所示。
图 5-3
闪屏欢迎画面
现在你可以在右上角输入一个网址,点击Render me!来显示网站。如果您输入 http://sainsburys.co.uk ,您会得到与图 5-4 所示类似的结果(图像会有所不同)。
图 5-4
飞溅渲染塞恩斯伯里的
正如你所看到的,你从你抓取的页面中得到一个截图,在它的下面是一些统计数据和呈现相关网站的请求的时间。在页面底部你看到的是网站的源代码,如图 5-5 所示。
图 5-5
源飞溅
这个源代码是页面呈现后得到的代码。为了验证这一点,您可以打开一个交互式 Python shell 并使用requests获得网站。
>>> import requests
>>> r = requests.get('http://sainsburys.co.uk')
>>> r.text
'<!DOCTYPE html><html class="no-js" lang="en"><head><meta charset="utf-8"><title>Sainsbury\'s</title><meta name="description" content="Shop online at Sainsbury\'s for everything from groceries and clothing to homewares, electricals and more. We also offer a great range of financial services. Live well for less."><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="google-site-verification" content="soOzMsGig7xqxpwJQWd8qJkfOQQvL0j-ZS9fI9eSDiE"><link rel="shortcut icon" href="favicon.ico"><meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=IE8"><script type="text/javascript" src="//service.maxymiser.net/cdn/sainsburyscoUK/js/mmcore.js"></script><!--[if lt IE 9]>\n <script src="https://cdn.polyfill.io/v1/polyfill.min.js"></script>\n <link rel="stylesheet" href="homepage/css/main_ie8.css?v=65f0de0508c75d5aac7501580ddf4e0a">\n <![endif]--><!--[if gte IE 9]>\n <link rel="stylesheet" href="homepage/css/main.css?v=2fadbf3f7bf0aa1b5e3613ec61ebabf7">\n <![endif]--><link rel="stylesheet" href="homepage/css/main.css?v=2fadbf3f7bf0aa1b5e3613ec61ebabf7"><!--[if !IE]><!--><!--<![endif]--></head><body><script type="text/javascript">(function(a,b,c,d)
....
前面的示例结果只是摘录。如果您将这段代码保存到一个 HTML 文件中,并在浏览器中打开它,然后对 Splash 返回的源代码进行同样的操作,您将看到相同的页面。不同之处在于源代码:Splash 有更多的代码行,并且包含扩展的 JavaScript 函数。
一个生动的例子
要了解如何让 Splash 与动态网站(大量使用 JavaScript)一起工作,让我们看一个不同的例子。例如, http://www.protopage.com/ 会根据原型生成一个网页,您可以对其进行定制。如果您访问该站点,您必须等待几秒钟,直到页面呈现出来。
如果我们想要从这个站点抓取数据(也没有太多可用的,但是想象一下它有很多可以提供的),并且我们使用一个简单的工具(requests库,Scrapy)或者使用默认设置的 Splash,我们只得到告诉我们该页面当前被渲染的基础页面。
为了用 Splash 渲染站点,我修改了脚本(顺便说一下,它是用 Lua 编写的),并将等待时间增加到了三秒。
function main(splash, args)
assert(splash:go(args.url))
assert(splash:wait(3))
return {
html = splash:html(),
png = splash:png(),
har = splash:har(),
}
end
根据目标网站的网络速度和负载,三秒钟可能太短了。请随意为您的目标网站尝试不同的值来呈现页面。
现在这一切都好了,但是如何使用 Splash 来刮网站呢?
与 Scrapy 集成
Splash 开发人员推荐的方法是将该工具与 Scrapy 集成,因为我们使用 Scrapy 作为我们的抓取工具,所以我们将彻底了解它是如何实现的。
首先,我们需要使用pip安装 Splash Python 包。
pip install scrapy-splash
既然已经安装了这个库,我们需要启用与scrapy-splash一起交付的中间件。
DOWNLOADER_MIDDLEWARES = {
'scrapy_splash.SplashCookiesMiddleware': 720,
'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}
前进的数字并不完全是经验性的:Splash 中间件必须比HttpProxyMiddleware的顺序更高,后者的默认值是750。为了安全起见(例如 Scrapy 改变了这个代理中间件的默认值),我们可以像这样改变中间件的配置:
DOWNLOADER_MIDDLEWARES = {
'scrapy_splash.SplashCookiesMiddleware': 720,
'scrapy_splash.SplashMiddleware': 725,
'scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware': 750,
'scrapy.downloadermiddlewares.httpcompression.HttpCompressionMiddleware': 810,
}
然后,我们必须添加蜘蛛中间件,以节省磁盘空间和网络流量。这是可选的;如果你不这样做,重复的 Splash 参数会被存储在你的磁盘上并被发送到你的 Splash 服务器上(这在云中会很有趣—见下一章关于这个主题的更多内容)。
SPIDER_MIDDLEWARES = {
'scrapy_splash.SplashDeduplicateArgsMiddleware': 100,
}
现在我们可以定义 Splash 工作所需的一些变量。其中之一是SPLASH_URL,它(显然)告诉中间件 Splash 实例可以在哪里呈现。
SPLASH_URL = 'http://localhost:8050/'
接下来的两个变量是因为 Scrapy 没有提供覆盖请求指纹的方法,这使得在脚本和 Splash 之间路由这些请求和响应有点复杂。然而,Splash 的开发者提出了一个解决方案,你可以使用他们的配置。
DUPEFILTER_CLASS = 'scrapy_splash.SplashAwareDupeFilter'
HTTPCACHE_STORAGE = 'scrapy_splash.SplashAwareFSCacheStorage'
第二个变量指向一个缓存存储解决方案,它可以感知 Splash。如果您正在使用另一个自定义缓存存储,您必须调整它以与 Splash 配合使用。这需要你子类化前面提到的存储类,并用scrapy_splash.splash_request_fingerprint替换所有对scrapy.util.request.request_fingerprint的调用,以解决那些讨厌的变化指纹。
我们必须适应的最后一个变化是Request s 的用法:我们需要使用SplashRequest而不是默认的 Scrapy Request。
现在让我们修改塞恩斯伯里的蜘蛛使用飞溅。
调整basic蜘蛛
在理想的情况下,您只需要像我们在上一节中所做的那样修改配置,所有的请求和响应都会经过 Splash,因为我们没有使用Scrapy的Request对象。
不幸的是,我们还需要在 scraper 的代码中进行更多的配置。如果你不相信我,就启动铲运机,不要有飞溅运行。
为了让我们的 scraper 在 Splash 中运行,我们需要修改每个请求调用来使用一个SplashRequest,并且每次我们发起一个新的请求时(启动 scraper 或者yield-调用一些response.follow)。
为了取得良好的开端,我们可以在脚本中添加以下函数:
from scrapy_splash import SplashRequest
def start_requests(self):
for url in self.start_urls:
yield SplashRequest(url, callback=self.parse)
这是蜘蛛通过飞溅操作的最低要求。参数说明了一切:URL是目标 URL,callback定义了要使用的方法。有一些选项可以配置 Splash 的行为方式,例如,等待一段时间来呈现网站。比方说,如果我们想在加载页面时多等一秒钟,我们可以这样修改SplashRequests的调用:
yield SplashRequest(url, callback=self.parse, args={'wait':1.0})
所以,我们很好,我们通过 Splash 呈现了第一个页面,但是其他调用,比如导航到详细页面或下一个页面呢?
为了适应这些,我稍微修改了 XPath 提取代码。到目前为止,我们使用的是response.follow方法,其中我们可以提供包含我们想要抓取的潜在下一个 URL 的选择器。
使用 Splash,我们需要提取这些 URL,并将它们作为参数提供给SplashRequest构造函数。我将使用parse方法作为例子。在第四章的结尾是这样的:
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a')
for url in urls:
yield response.follow(url, callback=self.parse_department_pages)
现在看起来是这样的:
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()
for url in urls:
if url.startswith('http'):
yield SplashRequest(url, callback=self.parse_department_pages)
我为url.startswith('http')添加了过滤器,以避免在url不包含绝对 URL 时可能发生的潜在错误。在某些情况下,您需要将 URL 与响应的基本 URL 连接在一起以获得目标域(因为url是该域的相对 URL)。下面是一个使用parse方法的例子。
def parse(self, response):
urls = response.xpath('//ul[@class="categories departments"]/li/a/@href').extract()
for url in urls:
yield SplashRequest(response.urljoin(url), callback=self.parse_department_pages)
除了前面提到的,我做的一个改变是将蜘蛛重命名为splash。
运行铲运机保持不变。
scrapy crawl splash -o splashburys.jl
在 scraper 完成后,您会在splashburys.jl文件中找到类似于以下摘录的记录。
{"url": "https://www.sainsburys.co.uk/shop/ProductDisplay?storeId=10151&productId=1153156&urlRequestType=Base&categoryId=312365&catalogId=10216&langId=44", "product_name": "Sainsbury's Venison Steak, Taste the Difference 250g", "product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/90/0000001442090/0000001442090_L.jpeg", "price_per_unit": "£7.50", "rating": "3.0", "product_reviews": "2", "item_code": "6450995", "nutritions": {"Energy ": "583kJ/", "Fat ": "2.6g", "of which saturates ": "0.9g", "mono-unsaturates ": "1.0g", "polyunsaturates ": "0.6g", "Carbohydrate ": "<0.5g", "of which sugars ": "<0.5g", "Fibre ": "<0.5g", "Protein ": "28.2g", "Sodium ": "0.05g", "Salt ": "0.13g"}, "product_origin": ""}
{"url": "https://www.sainsburys.co.uk/shop/gb/groceries/special-offers-314361-44/sainsburys-salmon-with-lemon-butter--taste-the-difference-145g", "product_name": "Sainsbury's Lightly Smoked Salmon with Wild Garlic Butter, Taste the Difference 145g", "product_image": "https://www.sainsburys.co.uk/wcsstore7.25.53/ExtendedSitesCatalogAssetStoimg/productImages/27/0000000301527/0000000301527_L.jpeg", "price_per_unit": "£3.00", "rating": "2.3333", "product_reviews": "3", "item_code": "7880107", "nutritions": {"Energy": "990kJ", "Energy kcal": "238kcal", "Fat": "16.9g", "Saturates": "4.6g", "Mono-unsaturates": "7.5g", "Polyunsaturates": "3.8g", "Carbohydrate": "1.6g", "Sugars": "1.2g", "Fibre": "0.6g", "Protein": "19.6g", "Salt": "0.63g"}, "product_origin": "Packed in United Kingdom Farmed in Scotland Produced from Farmed Scottish ( UK) Atlantic Salmon ( Salmo salar)"}
就是这样:我们把塞恩斯伯里的刮刀换成了飞溅式的。
Splash 不运行时会发生什么?
好问题,但我打赌你已经有答案了。scraper 不会做任何事情,它会退出并显示一条错误消息,该消息包含以下有价值的信息来识别这个特定的错误原因。
2018-04-27 16:07:19 [scrapy.core.scraper] ERROR: Error downloading <GET https://www.sainsburys.co.uk/shop/gb/groceries/meat-fish/ via http://localhost:8050/render.html>: Connection was refused by other side: 10061:
摘要
Splash 是一个很好的基于 Python 的网站渲染工具,可以很容易地与 Scrapy 集成。
一个缺点是,你必须通过一个有点复杂的过程或使用 Docker 手动安装它。这使得将它移植到云变得复杂(参见第六章的云解决方案),因此你应该只在本地抓取器中使用 Splash。然而,在本地,它可以给你一个巨大的好处,它与 Scrapy 无缝集成,使用 JavaScript 动态地抓取网站内容。
另一个缺点是速度。当我在本地电脑上使用 Splash 时,它每分钟只能勉强浏览 20 页。这对于我的口味来说太慢了,但有时我无法绕过它。
硒
如果您在互联网上搜索关于网站抓取的内容,您将最常遇到关于 Selenium 的文章和问题。最初,我想把 Selenium 排除在这本书之外,因为我不喜欢它的方法;对我的口味来说有点笨拙。然而,因为它的流行,我决定增加一个关于这个工具的部分。也许你会在 Scrapy 脚本中嵌入一个基于 Selenium 的解决方案(例如,你已经有了一个 Selenium-scraper,但想扩展它),我想帮助你完成这个任务。
首先,我们将了解 Selenium 以及如何独立使用它,然后我们将把它添加到 Scrapy spider 中。
先决条件
要让 Selenium 在您的计算机上工作,您必须像大多数 Python 库一样通过 Python 包索引来安装它。
pip install selenium
要使用 Selenium 进行网站抓取,您需要一个 web 浏览器。这意味着您将看到配置好的 web 浏览器(比如 Firefox 或 Chrome)打开,加载网站,然后 Selenium 开始工作并提取您定义的脚本。
要启用 Selenium 和浏览器之间的链接,您必须安装特定的 web 驱动程序。
对于 Chrome,请访问 https://sites.google.com/a/chromium.org/chromedriver/home 。我下载了 2.38 版本。
对于 Firefox,需要安装 GeckoDriver。可以在 GitHub 上找到。我下载了 0.20.1 版本。
当您运行 Python 脚本时,这些驱动程序必须在PATH上。我把它们都放在一个文件夹中,因为在这种情况下,我只需添加这一个文件夹,我所有的 web 驱动程序都是可用的。
注意
这些 web 驱动程序需要特定的浏览器版本。例如,如果您已经安装了 Chrome 并下载了最新版本的 web 驱动程序,如果您错过了更新浏览器,您可能会遇到如下异常:
raise exception_class(message, screen, stacktrace) selenium.common.exceptions.SessionNotCreatedException: Message: session not created exception: Chrome version must be >= 65.0.3325.0
(Driver info: chromedriver=2.38.552522 (437e6fbedfa8762dec75e2c5b3ddb86763dc9dcb),platform=Windows NT 10.0.16299 x86_64)
基本用法
现在,为了验证一切是否正常,让我们编写一个简单的脚本,使用 Selenium 为我们打开 Sainsbury 的网站。
from selenium.webdriver import Chrome, Firefox
chrome = Chrome()
firefox = Firefox()
chrome.open() # this opens a Chrome window
firefox.open() # this opens a Firefox window
chrome.get('https://sainsburys.co.uk') # navigates to the target website in Chrome
firefox.get('https://sainsburys.co.uk') # navigates to the target website in Firefox
好了,让浏览器自动打开,导航到目标网站,挺好的。但是抓取信息呢?
因为我们有一个触手可及的网站(在浏览器中),所以我们可以像前面章节那样解析 HTML —,或者使用 Selenium 的产品从网页的 HTML 中提取数据。
我不会详细介绍 Selenium 的提取器,因为这会超出本书的范围,但是让我告诉您,通过使用 Selenium,您可以访问一组不同的提取函数,您可以在您的浏览器实例中使用这些函数。
与 Scrapy 集成
硒可以和 Scrapy 融合。您唯一需要做的就是正确配置 Selenium(在PATH上安装 web 驱动程序并安装浏览器),然后就可以开始玩了。
我喜欢做的是禁用浏览器窗口。这是因为每当我看到一个自动导航页面的浏览器窗口时,我都会分心,如果你把 Scrapy 和 Selenium 结合起来,我会疯掉。
除此之外,您将需要一个中间件,该中间件将在通过 Scrapy 直接发送调用之前拦截调用,并将使用 Selenium 而不是正常的请求。
一个基本的中间件应该是这样的:
# -*- coding: utf-8 -*-
from scrapy import signals
from scrapy.http import HtmlResponse
from scrapy.utils.python import to_bytes
from selenium import webdriver
from selenium.webdriver.firefox.options import Options
class SeleniumDownloaderMiddleware:
def __init__(self):
self.driver = None
@classmethod
def from_crawler(cls, crawler):
middleware = cls()
crawler.signals.connect(middleware.spider_opened, signals.spider_opened)
crawler.signals.connect(middleware.spider_closed, signals.spider_closed)
return middleware
def process_request(self, request, spider):
self.driver.get(request.url)
body = to_bytes(self.driver.page_source)
return HtmlResponse(self.driver.current_url, body=body, encoding='utf-8', request=request)
def spider_opened(self, spider):
options = Options()
options.set_headless()
self.driver = webdriver.Firefox(options=options)
def spider_closed(self, spider):
if self.driver:
self.driver.close()
self.driver.quit()
self.driver = None
前面的代码使用 Firefox 作为默认浏览器,并在蜘蛛打开时以无头模式启动它。当 spider 关闭时,web 驱动程序也会关闭。
有趣的部分是当请求发生时:它被截取并通过浏览器路由,响应 HTML 代码被包装到一个HtmlResponse对象中。现在,您的蜘蛛获得了加载了 Selenium 的 HTML 代码,您可以使用它进行抓取。
羊血清硒
最近在 GitHub 发现了一个新鲜的项目,叫做 scrapy-selenium。这是一个方便的项目,让你安装并使用它来结合羊瘙痒病和硒的力量。我认为这个项目值得和你分享。
注意
因为这个项目是一个私人项目,它可能有问题。如果你发现一些不工作,随时提出这个项目的问题,开发人员会帮助你解决这个问题。如果没有,给我发一封电子邮件,我会看看我是否能给你一个解决方案,或者可能自己维护应用并提供更新的版本。
这个项目就像我们在上一节中实现的定制中间件一样工作:它拦截请求并使用 Selenium 下载页面。
先说配置。
from shutil import which
SELENIUM_DRIVER_NAME = 'firefox'
SELENIUM_DRIVER_EXECUTABLE_PATH = which('geckodriver')
SELENIUM_DRIVER_ARGUMENTS = ['-headless']
或者,你可以用 Chrome 代替 Firefox,但是在这种情况下,要注意--headless参数:它需要两个破折号。
from shutil import which
SELENIUM_DRIVER_NAME = 'chrome'
SELENIUM_DRIVER_EXECUTABLE_PATH = which('geckodriver')
SELENIUM_DRIVER_ARGUMENTS = ['--headless']
And we need the right middleware:
DOWNLOADER_MIDDLEWARES = {
'scrapy_selenium.SeleniumMiddleware': 800
}
对于蜘蛛,我重用了 Splash 部分的代码,但是将使用的Request实现改为scrapy-selenium实现:
from scrapy_selenium import SeleniumRequest
我不得不修改构造函数调用以包含 URL 作为命名参数。
def start_requests(self):
for url in self.start_urls:
yield SeleniumRequest(url=url, callback=self.parse)
一定要把这些电话都换了。如果您错过了一个,您将得到如下错误:
yield SeleniumRequest(url, callback=self.parse)
File "c:\dev\__py_venv\scrapy\lib\site-packages\scrapy_selenium\http.py", line 29, in __init__
super().__init__(*args, **kwargs)
TypeError: __init__() missing 1 required positional argument: 'url'
摘要
Selenium 是网站抓取器开发人员使用的替代工具,因为它支持通过浏览器进行 JavaScript 渲染。我们看到了一些关于如何将 Selenium 与 Scrapy 集成的解决方案,但是跳过了提取信息的内置方法。
同样,使用 Selenium 这样的外部工具会降低抓取速度,即使是在无头模式下。
美味汤的解决方案
到目前为止,我们一直在寻找可以将基于 JavaScript 的网站抓取与 Scrapy 集成的解决方案。但是有些项目使用 Beautiful Soup 也可以,不需要完整的 scraper 环境。
溅泼的量
Splash 也提供手动使用。这意味着,您可以选择让 Splash 呈现网站,并将源代码返回到您的代码中。我们可以利用它来制作一个简单的刮刀,上面写着漂亮的汤。
这里的想法是向 Splash 发送一个 HTTP 请求,提供要呈现的 URL(和任何配置参数)并获取结果,然后在这个结果上使用 Beautiful Soup,这是一个呈现的 HTML。
为了坚持前面的例子,我们将把 scraper 形式的第三章转换成一个利用 Splash 来呈现 Sainsbury 的页面的工具。
这里的想法是简单地调用 Splash 的 HTTP API 来呈现网页,而不是通过requests库获取页面。这意味着我们唯一的改变是在get_page函数中,在这里我们转发我们想要抓取的 URL 到 Splash。
def get_page(url):
try:
r = requests.get('http://localhost:8050/render.html?url=' + url)
if r.status_code == 200:
return BeautifulSoup(r.content, bs_parser)
except Exception as e:
pass
return None
如您所见,我们调用 Splash 安装的render.html端点,并提供目标 URL 作为简单的GET参数。
如果您对POST请求更感兴趣,您可以将处理函数更改为如下所示:
def get_page(url):
try:
r = requests.post('http://localhost:8050/render.html', data='{'url': '+ url + '}')
if r.status_code == 200:
return BeautifulSoup(r.content, bs_parser)
except Exception as e:
pass
return None
硒
当然,我们也可以将硒元素整合到我们美丽的汤液中。它的工作方式和 Scrapy 一样。
同样,我不会使用内置的 Selenium 方法从网站中提取信息。我只使用 Selenium 来呈现页面和提取我需要的信息。
为此,我将在 scraper 中添加两个助手函数,它们在需要的地方初始化和分解 Selenium。
def initialize():
global selenium
if not selenium:
selenium = Firefox()
def tear_down():
global selenium
if selenium:
selenium.quit()
selenium = None
为了安全起见,我会在每次我们要下载页面的时候,添加一个对initialize()的调用;然而,我将只在脚本完成时调用tear_down()。
def get_page(url):
initialize()
try:
selenium.get(url)
return BeautifulSoup(selenium.page_source, bs_parser)
except Exception as e:
pass
return None
摘要
尽管我们把重点放在 Scrapy 上,因为在我看来它目前是 Python 的网站抓取工具**,你可以看到让 Scrapy 处理 JavaScript 的选项可以被添加到“普通的”漂亮的汤抓取器中。这让您可以选择继续使用您已经熟悉的工具!**
摘要
在这一章中,我们看了一些利用 JavaScript 抓取网站的方法。我们查看了使用 web 浏览器执行 JavaScript 的主流 Selenium,然后进入了无头世界,在那里您不需要任何窗口来执行 JavaScript,这使得您的脚本可移植且更容易执行。
自然地,使用另一个工具来完成一些额外的渲染需要时间和开销。如果您不需要 JavaScript 渲染,那么创建您的脚本时不需要任何附加组件,如 Splash 或 Selenium。你将受益于速度的提高。
现在我们准备看看如何将我们的蜘蛛部署到云中!
Footnotes 1https://splash.readthedocs.io/en/stable/
2
https://github.com/clemfromspace/scrapy-selenium