乐者为王

Do one thing, and do it well.

使用Scrapy爬取小说(6)

使用Scrapy爬取小说(1)中,我们首先抓取小说目录页面的所有章节链接,然后再使用这些链接分别抓取各个章节的正文内容。这样需要分析两个不同页面的结构。其实,还有个更简单点的方法,只要分析内容页面的结构即可。

在小说的内容页面中通常都会有“上一页”和“下一页”这样的链接。我们只需要先抓取小说的首个章节,然后抽取出它的“下一页”链接,接着抓取这个链接对应的章节,再抽取出这个章节的“下一页”链接,重复这个循环直到不再有“下一页”链接为止。

抽取“下一页”链接的XPath表达式是:

1
//a[text()='下一页']/@href

修改后的novel_spider.py如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class NovelSpider(scrapy.Spider):
  name = 'novelspider'
  allowed_domains = ['example.com']
  # 把原来的目录页链接改成第一章的链接
  start_urls = ['http://example.com/wuxia/hzlz/ssjx/001.htm']

  def parse(self, response):
    # 有些小说网站的页眉和页脚都有“下一页”链接,我们
    # 不需要管它有几个,只要获取第一个出现的就行。
    link = response.xpath("//a[text()='下一页']/@href").extract_first()
    next_url = response.urljoin(link)
    yield scrapy.Request(next_url, callback=self.parse)  # 注意callback的值

    item = NovelItem()
    title = response.xpath('//center/table/tr[2]/td/text()').extract()
    content = response.xpath('//center/table/tr[4]/td/text()').extract()
    item['title'] = title
    item['content'] = content
    return item

满怀愉悦地运行Spider,结果什么都没有,抓取失败。这是怎么回事?百思不得其解。

郁闷之旅由此开始~

把yield关键词删掉试试?!修改后的代码如下:

1
2
3
link = response.xpath("//a[text()='下一页']/@href").extract_first()
next_url = response.urljoin(link)
scrapy.Request(next_url, callback=self.parse)

结果只输出第一章的内容。

把parse方法改成下面这样呢?

1
2
3
4
5
6
7
8
9
10
11
def parse(self, response):
  item = NovelItem()
  title = response.xpath('//center/table/tr[2]/td/text()').extract()
  content = response.xpath('//center/table/tr[4]/td/text()').extract()
  item['title'] = title
  item['content'] = content
  yield item

  link = response.xpath("//a[text()='下一页']/@href").extract_first()
  next_url = response.urljoin(link)
  return scrapy.Request(next_url, callback=self.parse)

也只能输出第一章的内容。

读到这里可能有看官会疑惑,为什么我会做出上面这两种修改?首先,我知道问题是在yield语句那里。其次,因为在我的印象中,yield的作用是延迟执行后面的语句。但现在从实际执行的情况来看显然不是如此。

真实的yield

查阅资料以后发现,包含yield的函数不再是普通函数,Python解释器会将其视为生成器。我们用个例子来详细说明下。

Fibonacci数列是个非常简单的递归数列,除第1和第2个数外,任何一个数都可以由前两个数相加得到。下面是输出Fibonacci数列前N个数的测试代码:

1
2
3
4
5
6
7
8
9
def fab(max):
  n = 0
  a, b = 0, 1
  while n < max:
    yield b
    a, b = b, a + b
    n = n + 1

print(fab(5))

猜猜会输出什么?有人以为会打印出Fibonacci数列,但实际上输出的是生成器对象的信息:

1
<generator object fab at 0x7fd41edfcd58>

fab(5)看起来像函数调用,但它其实是返回一个生成器对象。在对其调用next函数之前,它不会执行任何函数代码。虽然仍按函数的流程执行,但每执行到一条yield语句就会中断,并返回一个迭代值,下次执行时从yield处继续执行。看起来就好像一个函数在正常执行的过程中被yield中断了数次,每次中断都会通过yield返回当时的迭代值。

所以正确的打印代码应该这样写(for循环会自动对生成器对象调用next函数):

1
2
for n in fab(5)
  print(n)

也可以手动调用next函数,这样我们就可以更清楚地看到它的执行流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> f = fab(5)
>>> next(f)
1
>>> next(f)
1
>>> next(f)
2
>>> next(f)
3
>>> next(f)
5
>>> next(f)
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
StopIteration

当函数执行结束时,生成器自动抛出StopIteration异常,表示迭代完成。在for循环中,无需处理StopIteration异常,循环会正常结束。

正确的解决方案

明白yield到底起什么作用后,再回过头来看前面的修改。

第2次修改明显是没有思考,胡乱盲目地尝试。它只是创建一个Request对象而已,能有什么用!代码也清楚地表明,parse方法仅会被回调1次,这也是仅能输出第一章内容的原因。现在回想起来 怎么也弄不明白当初为什么会这么写?!

第3次修改的代码首先是解析Response对象,把第一章的标题和内容封装起来返回给Scrapy。因为这个函数是生成器函数,所以Scrapy在输出第一章内容后会继续执行yield item后面的语句,直到函数结束,抛出StopIteration异常。因为有return语句,所以会把返回的值当作StopIteration异常的属性。

第1次修改和第3次修改类似,不同的是它返回“下一页”的Request对象给Scrapy,所以parse方法会被不断地循环回调,直到没有“下一页”为止。

归根结底,第1和第3次修改不能输出所有章节内容的原因是因为StopIteration异常吞掉了return返回的值,所以也要把它改成yield。修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
def parse(self, response):
  item = NovelItem()
  title = response.xpath('//center/table/tr[2]/td/text()').extract()
  content = response.xpath('//center/table/tr[4]/td/text()').extract()
  item['title'] = title
  item['content'] = content
  yield item

  link = response.xpath("//a[text()='下一页']/@href").extract_first()
  next_url = response.urljoin(link)
  yield scrapy.Request(next_url, callback=self.parse)

给代码做个美容:

1
2
3
4
5
6
7
8
9
def parse(self, response):
  yield {
    'title': response.xpath('//center/table/tr[2]/td/text()').extract(),
    'content': response.xpath('//center/table/tr[4]/td/text()').extract(),
  }

  link = response.xpath("//a[text()='下一页']/@href").extract_first()
  next_url = response.urljoin(link)
  yield scrapy.Request(next_url, callback=self.parse)

打完收工。写文章比写代码累多了!

Comments