Scrapy网络爬虫编程的核心就是爬虫(Spider)组件,它其实是一个继承于Spider的类,主要功能是封装一个发送给网站服务器的HTTP请求,解析网站返回的网页及提取数据。
首先回顾一下Scrapy框架结构中Spider与引擎之间交互的过程,如图4-1所示。
图4-1 Spider执行流程
下面从数据流的角度分析一下执行步骤:
(1)Spider生成初始页面请求(封装于Request对象中),提交给引擎。
(2)引擎通知下载器按照Request的要求,下载网页文档,再将文档封装成Response对象作为参数传回给Spider。
(3)Spider解析Response中的网页内容,生成结构化数据(Item),或者产生新的请求(如爬取下一页),再次发送给引擎。
(4)如果发送给引擎的是新的Request,就回到第(2)步继续往下执行。如果发送的是结构化数据(Item),则引擎通知其他组件处理该数据(保存到文件或数据库中)。
以上步骤中的第(1)步和第(3)步属于Spider类的功能范畴。Spider的任务主要有:
(1)定义向网站服务器发送的初始Request对象。
(2)从获取的网页文档中提取结构化数据或产生新的请求。
以上一章实现的起点中文网小说热销榜为例,打开Spiders目录下的qidian_hot_spider.py,实现代码如下:
#-*-coding:utf-8-*- from scrapy import Request from scrapy.spiders import Spider #导入Spider类 class HotSalesSpider(Spider): #定义爬虫名称 name = 'hot' #起始的URL列表 start_urls = ["https://www.qidian.com/rank/hotsales?style=1"] # 解析函数 def parse(self, response): #使用xpath定位到小说内容的div元素,保存到列表中 list_selector = response.xpath("//div[@class='book-mid-info']") #依次读取每部小说的元素,从中获取小说名称、作者、类型和形式 for one_selector in list_selector: #获取小说名称 name = one_selector.xpath("h4/a/text()").extract()[0] #获取作者 author = one_selector.xpath("p[1]/a[1]/text()").extract()[0] #获取类型 type = one_selector.xpath("p[1]/a[2]/text()").extract()[0] #获取形式(连载还是完本) form = one_selector.xpath("p[1]/span/text()").extract()[0] #将爬取到的一部小说保存到字典中 hot_dict = {"name":name, #小说名称 "author":author, #作者 "type":type, #类型 "form":form} #形式 #使用yield返回字典 yield hot_dict
首先从scrapy模块导入了两个模块:Request和Spider;然后定义了一个继承于Spider的类HotSalesSpider。该类的结构很简单,有两个属性name和start_urls,两个方法parse()和start_requests(),它们都是基类Spider的属性和方法。下面来看一下它们各自的功能。
(1)name:必填项。name是区分不同爬虫的唯一标识,因为一个Scrapy项目中允许有多个爬虫。不同的爬虫,name值不能相同。
(2)start_urls:存放要爬取的目标网页地址的列表。
(3)start_requests():爬虫启动时,引擎自动调用该方法,并且只会被调用一次,用于生成初始的请求对象(Request)。start_requests()方法读取start_urls列表中的URL并生成Request对象,发送给引擎。引擎再指挥其他组件向网站服务器发送请求,下载网页。代码中之所以没看到start_requests()方法,是因为我们没有重写它,直接使用了基类的功能。
(4)parse():Spider类的核心方法。引擎将下载好的页面作为参数传递给parse()方法,parse()方法执行从页面中解析数据的功能。
由此可见,要实现爬虫(Spider)功能,只要执行4个步骤,如图4-2所示。
图4-2 Spider实现步骤
Spider的结构非常简单,不难理解,但是你一定有这样的疑问:
(1)如何避免爬虫被网站识别出来导致被禁呢?
(2)引擎是怎么知道要将下载好的页面发送给parse()方法而不是其他方法?能否自定义这个方法?
第一个问题的答案是可以重写(override)start_requests()方法,手动生成一个功能更强大的Request对象。因为伪装浏览器、自动登录等功能都是在Request对象中设置的。
第二个问题的答案是引擎之所以能自动定位,是因为在Request对象中,指定了解析数据的回调函数,而默认情况下,Request指定的解析函数就是parse()方法。
下面我们就来重写start_requests()方法,对起点中文网小说热销榜的功能做一些优化。优化内容有:
(1)将爬虫伪装成浏览器。
(2)设置新的解析数据的回调函数(不使用默认的parse())。
实现代码如下:
#-*-coding:utf-8-*- from scrapy import Request from scrapy.spiders import Spider#导入Spider类 class HotSalesSpider(Spider): #定义爬虫名称 name = 'hot' #设置用户代理(浏览器类型) qidian_headers = {"User-Agent":"Mozilla/" "5.0 (Windows NT 10.0; " "Win64; x64) AppleWebKit/" "537.36 (KHTML, like Gecko) Chrome/" "68.0.3440.106 Safari/" "537.36"} #获取初始Request def start_requests(self): url = "https://www.qidian.com/rank/hotsales?style=1" #生成请求对象,设置url,headers,callback yield Request(url,headers=self.qidian_headers,callback=self.qidian_ parse) # 解析函数 def qidian_parse(self, response): ……
在类HotSalesSpider中,新增一个字典型的属性qidian_headers,用于设置请求头信息。这里设置的User-Agent,就是用于伪装浏览器。值可以通过Chrome浏览器的“开发者工具”获取,如图4-3所示。
图4-3 在Chrome浏览器中获取用户代理
另外,代码中删除了属性start_urls,并重写了start_requests()方法,用于自定义Request对象。Request对象设置了3个参数:
·url:请求访问的网址。
·headers:请求头信息。
·callback:回调函数。这里确定解析数据的函数为qidian_parse()。引擎会将下载好的页面(Response对象)发送给该方法,执行数据解析功能。
解析函数由parse()改为qidian_parse(),实现代码未变。
在命令行中输入运行爬虫命令:
>scrapy crawl hot -o hot.csv
打开生成的.csv文件,如图4-4所示。
图4-4 爬取结果
通过设置Request的headers和callback参数就轻松实现了爬虫伪装和自定义解析函数的功能。看来Request对象大有玄机,通过设定各种参数还能实现更多有用的功能。下面就来详细了解一下Request对象吧。
Request对象用来描述一个HTTP请求,它通常在Spider中生成并由下载器执行。
Request的定义形式为:
class scrapy.http.Request(url [,callback,method ='GET',headers,body, cookies,meta,encoding ='utf-8',priority = 0,dont_filter = False,errback ])
其中,参数url为必填项,其他为选填项,下面逐个介绍这些参数。
(1)以下参数用于设置向网站发送的HTTP请求的内容,你一定不会感到陌生。
·url:HTTP请求的网址,如 https://www.baidu.com 。
·method:HTTP请求的方法,如GET、POST、PUT等,默认为GET,必须大写英文字母。
·body:HTTP的请求体,类型为str或unicode。
·headers:HTTP的请求头,类型为字典型。请求头包含的内容可以参考2.1.3节HTTP请求。
·cookies:请求的Cookie值,类型为字典型或列表型,可以实现自动登录的效果,后面章节会具体讲解。
·encoding:请求的编码方式,默认为UTF-8。
(2)以下参数设置Scrapy框架内部的事务。
·callback:指定回调函数,即确定页面解析函数,默认为parse()。页面下载完成后,回调函数将会被调用,如果在处理期间发生异常,则会调用errback()函数。
·meta:字典类型,用于数据的传递。它可以将数据传递给其他组件,也可以传递给Respose对象,本章的项目案例中会使用到该参数。
·priority:请求的优先级,默认为0,优先级高的请求会优先下载。
·dont_filter:如果对同一个url多次提交相同的请求,可以使用此项来忽略重复的请求,避免重复下载,其值默认为False。如果设置为True,即使是重复的请求,也会强制下载,例如爬取实时变化的股票信息数据。
·errback:在处理请求时引发任何异常时调用的函数,包括HTTP返回的404页面不存在的错误。
Request中的参数看上去有很多,但除了url外,其他参数都有默认值,大部分情况下不必设置。
介绍完Request,下面继续研究Spider的核心:解析函数(默认为parse()函数)。它主要实现两方面的功能:
·使用Scrapy的选择器提取页面中的数据,将其封装后交给引擎。
·提取页面中的链接,构造新的Request请求并提交给Scrapy引擎。
先来看一下Scrapy的选择器是如何提取数据的。
Scrapy提取数据有自己的一套机制,被称做选择器(Selector类),它能够自由“选择”由XPath或CSS表达式指定的HTML文档的某些部分。Scrapy的选择器短小简洁、解析快、准确性高,使用其内置的方法可以快速地定位和提取数据。下面就来了解一下选择器(Selector类)及选择器列表(SelectorList类,选择器对象的集合)内置的方法。
1.定位数据
·xpath(query):查找与XPath表达式匹配的节点,并返回一个SelectorList对象。SelectorList对象类似于一个列表,包含了所有匹配到的节点。参数query是XPath表达式的字符串。
·css(query):查找与CSS表达式匹配的节点,并返回一个SelectorList对象。参数query是CSS表达式的字符串。
2.提取数据
·extract():提取文本数据,返回unicode字符串列表。使用xpath()或css()方法将匹配到的节点包装为SelectorList对象后,可以使用extract()方法提取SelectorList对象中的文本,并将其存放于列表中。
·extract_first():SelectorList独有的方法,提取SelectorList对象中第一个文本数据,返回unicode字符串。
·re(regex):使用正则表达式提取数据,返回所有匹配的unicode字符串列表。
·re_first():SelectorList独有的方法,提取第一个与正则表达式匹配的字符串。
我们完全没有必要手动构造一个选择器对象来实现对网页信息的查找与提取。因为Scrapy将下载下来的网页信息封装为Response对象传递给解析函数时,会自动构造一个选择器作为Response对象的属性,这样就能通过Response对象非常方便地查找与提取网页数据。
下面再来分析一下解析函数,函数框架如下:
# 解析函数 def qidian_parse(self, response): ……
参数response接收封装有网页信息的Response对象,这时就可以使用下面的方法实现对数据的定位。
·response.selector.xpath(query);
·response.selector.css(query)。
由于在Response中使用XPath和CSS查询十分普遍,因此Response对象提供了两个实用的快捷方式,它们能自动创建选择器并调用选择器的xpath()或css()方法来定位数据。简化后的方法如下:
·response.xpath(query);
·response.css(query)。
下面来看一下完整的解析函数的实现代码。
1 # 解析函数 2 def qidian_parse(self, response): 3 #使用xpath定位到小说内容的div元素,并保存到列表中 4 list_selector = response.xpath("//div[@class='book-mid-info']") 5 #依次读取每部小说的元素,从中获取小说名称、作者、类型和形式 6 for one_selector in list_selector: 7 #获取小说名称 8 name = one_selector.xpath("h4/a/text()").extract()[0] 9 #获取作者 10 author = one_selector.xpath("p[1]/a[1]/text()").extract()[0] 11 #获取类型 12 type = one_selector.xpath("p[1]/a[2]/text()").extract()[0] 13 #获取形式(连载还是完本) 14 form = one_selector.xpath("p[1]/span/text()").extract()[0] 15 #将爬取到的一部小说保存到字典中 16 hot_dict = {"name":name, #小说名称 17 "author":author, #作者 18 "type":type, #类型 19 "form":form} #形式 20 #使用yield返回字典 21 yield hot_dict
在第4行代码中,使用Response的xpath()方法定位到小说信息的div元素。list_selector是一个选择器列表,存储有多个选择器对象,一个选择器对应一个div元素。list_selector的内容如下:
[<Selector xpath="//div[@class='book-mid-info']" data='<div class="book- mid-info">\r '>, <Selector xpath="//div[@class='book-mid-info']" data='<div class="book- mid-info">\r '>, ……]
由数据可知,列表存储了多个用尖括号包含的数据。尖括号表示数据类型为对象,Selector表示这是一个选择器对象,后面的xpath是选择方法,data是提取到的元素。
在第6行代码中,使用for循环依次遍历每个选择器对象,保存于one_selector中。
第8行代码获取小说名称。为了更好地理解,特将代码拆开分步骤完成:
name_selector_list = one_selector.xpath("h4/a/text()") name_list = name_selector_list.extract() name = name_list[0]
首先使用xpath定位到小说名称的文本元素,返回一个选择器列表;然后使用extract()方法提取出文本并保存于列表中;最后再从列表中获取文本赋值给name。
第8行代码也可以使用extract_first()方法直接得到列表中的第一个文本,代码修改为:
name = one_selector.xpath("h4/a/text()").extract_first()
在第15~19行代码中,使用字典hot_dict保存爬取到的一条小说的信息。
在第21行代码中,通过yield提交hot_dict给引擎后,解析函数继续执行解析任务。引擎接收到数据后将其交由其他组件处理,这样做的好处是节省了大量内存(只有一条数据),提高了执行效率(解析一条,处理一条)。如果将yield替换为return,解析函数将会立即停止执行。
我们知道,在Request中,设置参数headers可以将爬虫伪装成浏览器,以避免被网站侦测到。其实,更普遍、更科学的做法是将其配置在settings.py中,Request对象每次被调用时,Scrapy会自动将其加入。对于一个项目中有多个爬虫程序来说,能有效避免重复设置的麻烦。具体实现方法为:
(1)在settings.py中启用并设置User-Agent。
# Crawl responsibly by identifying yourself (and your website) on the user-agent USER_AGENT = "Mozilla/5.0 (Windows T 10.0;Win64; x64) " \ "AppleWebKit/537.36 (KHTML, like Gecko) " \ "Chrome/68.0.3440.106 Safari/537.36"
(2)爬虫类中删除相应代码。
一个好的习惯是,在建立Scrapy项目时,立刻在settings.py中启用并设置User-Agent。
Response也支持使用CSS表达式来解析数据。
CSS(Cascading Style Sheets,层叠样式表),用于表现HTML或XML的样式。CSS表达式的语法比XPath简洁,但是功能不如XPath强大,大多作为XPath的辅助。
熟悉CSS样式表的读者对如表4-1所示的CSS表达式一定感到亲切,即使不熟悉也没关系,它比XPath的语法还简单。
表4-1 常用的CSS表达式
下面还是以起点中文网小说热销榜为例,改用CSS表达式实现数据的解析。实现代码如下:
1 # 使用CSS选择器解析数据 2 def qidian_parse(self, response): 3 #使用CSS定位到小说内容的div元素,生成选择器,并保存到选择器列表中 4 list_selector = response.css("[class='book-mid-info']") 5 #依次读取每部小说,从中获取小说名称、作者、类型和形式 6 for one_selector in list_selector: 7 #获取小说名称 8 name = one_selector.css("h4>a::text").extract_first() 9 #获取作者 10 author = one_selector.css(".author a::text").extract()[0] 11 #获取类型 12 type = one_selector.css(".author a::text").extract()[1] 13 #获取形式(连载还是完本) 14 form = one_selector.css(".author span::text").extract_first() 15 #将爬取到的一部小说保存到字典中 16 hot_dict = {"name":name, #小说名称 17 "author":author, #作者 18 "type":type, #类型 19 "form":form} #形式 20 #使用yield返回字典 21 yield hot_dict
以上代码中,仅将数据解析方法从XPath表达式替换为CSS表达式,其余没有变化。
在第4行代码中,list_selector使用CSS表达式定位到小说信息的div元素。如果将list_selector打印出来,将得到如下结果:
[<Selector xpath="descendant-or-self::*[@class = 'book-mid-info']" data= '<div class="book-mid-info">\r '>, <Selector xpath="descendant-or-self::*[@class = 'book-mid-info']" data= '<div class="book-mid-info">\r '>, ……]
结果中会发现XPath的身影,这是因为在运行CSS表达式时,内部会将其转换为XPath表达式,再使用选择器的xpath()方法提取数据。
在第8行代码中,获取小说名称的CSS表达式为h4>a::text,即定位到h4子节点a标签的文本,返回选择器列表,再使用extract_first()方法提取出第1个文本。
在第12行代码中,获取小说类型的CSS表达式为.author a::text,即定位到class属性值为author下所有a标签的文本,返回选择器列表;然后使用extract()方法提取文本存储于列表中;最后使用下标获取列表中的第1个文本。
下面我们回过头,对Response做一个系统的认识。
Response用来描述一个HTTP响应,它只是一个基类。当下载器下载完网页后,下载器会根据HTTP响应头部的Content-Type自动创建Response的子类对象。子类主要有:
·TextResponse;
·HtmlResponse;
·XmlResponse。
其中,TextResponse是HtmlResponse和XmlResponse的子类。我们通常爬取的是网页,即HTML文档,下载器创建的便是HtmlResponse。
下面以HtmlResponse为例,介绍它的属性。
·url:响应的url,只读,如 https://www.baidu.com 。
·status:HTTP响应的状态码,如200、403、404。状态码可以参考2.1.4节HTTP响应。
·headers:HTTP的响应头,类型为字典型。具体内容可以参考2.1.4节HTTP响应。
·body:HTTP响应体。具体内容可以参考2.1.4节HTTP响应。
·meta:用于接收传递的数据。使用request.meta将数据传递出去后,可以使用response.meta获取数据。
目前为止,我们实现了起点中文网小说热销榜中一页数据的爬取,但是这个榜单其实有25页,如图4-5所示。如何将这25页的数据全部爬取到呢?
图4-5 25页小说热销榜数据
思路是这样的:在解析函数中,提取完本页数据并提交给引擎后,设法提取到下一页的URL地址,使用这个URL地址生成一个新的Request对象,再提交给引擎。也就是说,解析本页的同时抛出一个下一页的请求,解析下一页时抛出下下页的请求,如此递进,直到最后一页。
以下代码是在之前(4.1.5节)的基础上,增加多页数据的爬取功能:
#-*-coding:utf-8-*- from scrapy import Request from scrapy.spiders import Spider #导入Spider类 class HotSalesSpider(Spider): #定义爬虫名称 name = 'hot' current_page = 1 #设置当前页,起始为1 #获取初始Request def start_requests(self): url = "https://www.qidian.com/rank/hotsales?style=1" #生成请求对象,设置url、headers和callback yield Request(url,callback=self.qidian_parse) #解析函数 def qidian_parse(self, response): #使用xpath定位到小说内容的div元素,并保存到列表中 list_selector = response.xpath("//div[@class='book-mid-info']") #依次读取每部小说的元素,从中获取小说名称、作者、类型和形式 for one_selector in list_selector: #获取小说名称 name = one_selector.xpath("h4/a/text()").extract_first() #获取作者 author = one_selector.xpath("p[1]/a[1]/text()").extract()[0] #获取类型 type = one_selector.xpath("p[1]/a[2]/text()").extract()[0] #获取形式(连载还是完本) form = one_selector.xpath("p[1]/span/text()").extract()[0] #将爬取到的一部小说保存到字典中 hot_dict = {"name":name, #小说名称 "author":author, #作者 "type":type, #类型 "form":form} #形式 #使用yield返回字典 yield hot_dict #获取下一页URL,并生成Request请求,提交给引擎 #1.获取下一页URL self.current_page+=1 if self.current_page<=25: next_url = "https://www.qidian.com/rank/hotsales?style=1&page= %d"%(self.current_page) #2.根据URL生成Request,使用yield返回给引擎 yield Request(next_url,callback=self.qidian_parse)
以上代码看着多,其实仅增加了加粗代码部分,下面来分析一下这些代码。
(1)属性current_page,用于记录当前的页码,初始值为1。
(2)通过分析得知,第N页的URL地址为 https://www.qidian.com/rank/hotsales?style=1&page=N ,即只有page的值是变化(递增)的。获取下一页的URL就变得简单了。
(3)根据下一页的URL,构建一个Request对象,构建方法和start_requests()中Request对象构建方法一样,仅仅是URL不同。