我的年度读书报告生成攻略:第一部分

年终惯例是做年度总结,而恰好每年年初都会给自己定一个读书目标。以前总是达标之后心情愉悦便没有然后了,今年突发奇想,想看看过去的一年里,自己都读了什么书,以便有的放矢地安排下一年的读书目标。

需求有了,接下来则是要获取数据了。凑巧我有个习惯,每看一本书会在豆瓣上标记一下。因此,我在豆瓣上的已读清单就是最好的原数据。

创建爬虫

对于爬虫工具的选择,以方便为主,所以我选了scrapy

安装好 scrapy 后,创建一个名为 book 的项目:

1
scrapy startproject book

获取已读图书列表

登陆豆瓣后,找到页面“我读过的书”,就可以看到已阅图书列表了。该页面的 URL 有以下模式:

1
https://book.douban.com/people/{豆瓣id}/collect

首先,我们需要确定下这个页面是否需要登陆才能访问(如果不是的话,还得模拟豆瓣登陆)。确定方法很简单:记录下该页面的 url,退出登陆,然后尝试访问该 url。如果能够正常访问的话,说明某个用户的已读图书列表是公开的。

经过确认,这个页面确实是公开的,这样,我们就不需要进行模拟登陆了。(虽然省去了这一步,但是不知道豆瓣的产品经理是如何考量的,为什么要把用户这个比较私密的属性赤裸裸地暴露在阳光虾呢?)

接下来,借助 chrome 的开发者工具,观察下每一个图书项的组成部分:

可以发现几点(以下用 xpath 表示法):

  • 每一个图书项://li[@class="subject-item"
  • 图书名称:div[@class="info"]/h2/a/@title
  • 图书详情页 url:div[@class="info"]/h2/a/@href
  • 出版信息:div[@class="info"]/div[@class="pub"]/text()
  • 评价信息:div[@class="info"]/div[@class="short-note"]/div[1]/span[1]/@class
  • 读完的日期:div[@class="info"]/div[@class="short-note"]/div[1]/span[@class="date"]/text()
  • 标签信息:div[@class="info"]/div[@class="short-note"]/div[1]/span[@class="tags"]/text()
  • 评价信息:div[@class="info"]/div[@class="short-note"]/p[@class="comment"]/text()
  • 以及下一页://span[@class="next"]/a/@href

因此,我们可以在 book/items.py 中定义我们所需要的属性:

1
2
3
4
5
6
7
8
class BookItem(scrapy.Item):
name = scrapy.Field()
url = scrapy.Field()
pub = scrapy.Field()
rating = scrapy.Field()
readDate = scrapy.Field()
tags = scrapy.Field()
comment = scrapy.Field()

然后在 book/spiders 目录下创建一个爬虫文件 collect_spider.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class CollectSpider(scrapy.Spider):
name = "collect" # 将爬虫命名为 collect
allowed_domains = ["douban.com"]
start_urls = [
# 设置起始爬取页面为已阅读书列表首页
# 这里我把豆瓣 id 信息隐去了,使用时按需替换
"https://book.douban.com/people/******/collect"
]

def parse(self, response):
for subItem in response.xpath('//li[@class="subject-item"]'):
# 对于每一个图书项进行操作
item = BookItem()
item['name'] = "".join(subItem.xpath('div[@class="info"]/h2/a/@title').extract()).strip()
item['url'] = subItem.xpath('div[@class="info"]/h2/a/@href').extract_first(default="Missing")
item['pub'] = "".join(subItem.xpath('div[@class="info"]/div[@class="pub"]/text()').extract()).strip()
item['rating'] = subItem.xpath('div[@class="info"]/div[@class="short-note"]/div[1]/span[1]/@class').extract_first(default="Missing").strip()
item['readDate'] = subItem.xpath('div[@class="info"]/div[@class="short-note"]/div[1]/span[@class="date"]/text()').extract_first(default="Missing")
item['tags'] = "".join(subItem.xpath('div[@class="info"]/div[@class="short-note"]/div[1]/span[@class="tags"]/text()').extract())
item['comment'] = "".join(subItem.xpath('div[@class="info"]/div[@class="short-note"]/p[@class="comment"]/text()').extract()).strip()

yield item
# 获取下一页地址
next_page = response.xpath('//span[@class="next"]/a/@href')
if next_page: # 如果存在的话,爬取下一页
url = response.urljoin(next_page[0].extract())
print("next: ", url)
yield scrapy.Request(url, callback=self.parse)

之后可以运行下看看爬取结果:

1
$ scrapy crawl collect -o items.json

注意:
如果爬取过程遇到豆瓣返回 403 的错误,那么需要修改 book/settings.py 中的 USER_AGENT 为配置项 USER_AGENT = 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.87 Safari/537.36'

获取书籍更多的出版信息

为了获得更多的出版信息(如 ISBN),我们需要爬取每个图书的详情页。详情页的 url 我们在上一个步骤就已经可以获得了。

book/items.py 中添加详情定义:

1
2
3
4
class BookItem(scrapy.Item):
....
isbn = scrapy.Field()
morePub = scrapy.Field()

分析页面代码后修改 collect_spider.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
def parse(self, response):
for subItem in response.xpath('//li[@class="subject-item"]'):
....
if item['url'] and item['url'] != 'Missing':
# 访问详情页,回调函数为 self.parse_item()
request = scrapy.Request(item['url'], callback=self.parse_item)
request.meta['item'] = item
yield request
else:
yield item

next_page = response.xpath('//span[@class="next"]/a/@href')
if next_page:
url = response.urljoin(next_page[0].extract())
print("next: ", url)
yield scrapy.Request(url, callback=self.parse)

def parse_item(self, response): # 详情页解析方法
item = response.meta['item']
item['morePub'] = response.xpath('//*[@id="info"]/text()').extract()
# 获取 isbn
if len(item['morePub']) >= 2:
isbn = item["morePub"][-2].strip()
if isbn:
item['isbn'] = isbn
yield item

确定书籍所属类别

因为想对图书类别进行分析,而豆瓣图书是没有类别信息的,所以得想个办法获取到对应图书的类别信息。

通过 CALIS 联合目录公共检索系统 就可以根据 ISBN 查询到对应到图书类别信息。

接下来就是分析模拟查询请求了。再次借助 chrome 的开发者工具,进行一次图书检索,看看会出现什么样子的请求交互:

因此,查询方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def queryCatagory(isbn):
url = "http://opac.calis.edu.cn/opac/doSimpleQuery.do"
data = {
"actionType": "doSimpleQuery",
"pageno": 1,
"pagingType": 0,
"operation": "searchRetrieve",
"version": 1.1,
"query": '(bath.isbn="{0}*")'.format(isbn),
"sortkey": "title",
"maximumRecords": 50,
"startRecord": 1,
"dbselect": "all",
"langBase": "default",
"conInvalid": "检索条件不能为空",
"indexkey": "bath.isbn|frt",
"condition": isbn,
}

resp = requests.post(url, data)
if resp.status_code != 200: return "not found"
page = etree.HTML(resp.text)
# 这里我们只获取顶级分类
info = page.xpath('//*[@id="browser"]/li/span/a/u')
if info:
return info[0].text
return "not found"

先将类别信息添加到 book/items.py 中:

1
2
3
class BookItem(scrapy.Item):
....
catagory = scrapy.Field()

我们把上面的方法添加在 collect_spider.py 中,然后在获取到 isbn 后查询类别信息:

1
2
3
4
5
6
7
8
9
10
def parse_item(self, response):
....
# get isbn
if len(item['morePub']) >= 2:
....
if isbn:
....
# get category
item['catagory'] = queryCatagory(isbn)
yield item

最后运行下爬虫即可:

1
$ scrapy crawl collect -o items.json

小结一下

数据分析的第一步大抵都是确定分析目标,然后根据目标获取相应数据。

接下来,就是撸起袖子开始分析了~