抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

基于requests库和lxml库编写的爬虫,目标小说网站域名http://www.365kk.cc/,类似的小说网站殊途同归,均可采用本文方法爬取。

目标网站传送门

本文的目标书籍《我的师兄实在太稳健了》

渡劫只有九成八的把握,和送死有什么区别?

基本思路

网络爬虫的工作实际上主要分为三个部分:

  • 获取网页内容,通过requests库实现;
  • 解析网页内容,得到其中我们想要的部分,通过lxml库实现;
  • 将解析出的内容储存到文本文档中;

接下来看看我们的需求是什么:

  • 获取目标书籍的基本信息,包括书籍的书名、作者、最近更新时间和简介——这些信息应该都在同一个页面中获取,即目标书籍的主页;

  • 获取目标书籍每一章节的标题和内容——不同章节在不同的页面,不同页面之间可以通过下一页按序跳转;

  • 正文部分的存储格式应便于阅读,不能把所有文字都堆积在一起,也不能包括除了正文之外的其他无关内容;

因此,我们首先尝试请求书籍的主页,获取基本信息,借此学习请求和解析网页内容的基本方法;紧接着再从书籍的第一章开始,不断地请求“下一页”,直到爬取所有内容,并将它们以合适的格式储存在文本文档中。


首页爬取与解析

首页链接:http://www.365kk.cc/255/255036/

目标内容在网页中的位置

首先,我们观察一下该网站不同的书籍主页:

可以看出:不同书籍的主页具有非常相似的结构——标题、作者、最近更新时间、最新章节和正文都在相同的位置上。

如何表征这一位置?这就需要借助python爬虫中常用的网页解析方法——XPath语法

以书名为例,在你的浏览器(以Google Chrome为例)中选中书名,点击右键 -> 检查

image

可以看出,浏览器下方弹出了一个窗口,这里显示的就是该页面的源代码,我们选中的内容位于一个<h1>标签中。我们点击右键 -> 复制 -> 复制 XPath,即可得到书名的XPath路径,也就是书名在网页中的位置。

image

从书籍的首页中,我们需要获取的信息主要包括:

  • 书名
  • 作者
  • 最后更新时间
  • 简介

按照上述方法,分别获取它们的XPath路径,依次如下:

1
2
3
4
/html/body/div[4]/div[1]/div/div/div[2]/div[1]/h1/text()
/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[1]/text()
/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[5]/text()
/html/body/div[4]/div[1]/div/div/div[2]/div[2]/text()

/text() 表示获取文本

现在,我们得到了想要的内容在网页中的位置,只要请求到网页内容,就可以获取指定位置的内容了。


请求网页内容

我们使用比较基础的python爬虫网页请求方法:使用 requests 库直接请求。

这里涉及到了简单的反爬虫知识:在请求网页时,我们需要将我们的爬虫伪装成浏览器,具体通过添加请求头 headers 实现。

请求头以字典的形式创建,可以包括很多内容,这里只设置四个字段:User-Agent, Cookie, HostConection

1
2
3
4
5
6
headers= {
'User-Agent': '...', # 你的浏览器User-Agent
'Cookie': '...', # 你的浏览器Cookie
'Host': 'www.365kk.cc',
'Connection': 'keep-alive'
}

既然是为了伪装成浏览器,相关字段的内容当然要从浏览器中获取。

在刚才打开的页面中,我们点击 网络 (英文版是 Network),刷新页面,找到其中的第一个文件 255036/ ,打开标头 -> 请求标头,即可得到我们想要的字段数据,如下图所示。

image

这里的文件255036/实际上就是小说主页的文件,可以看出,主页的链接为http://www.365kk.cc/255/255036/,链接后缀刚好与文件名一致。

使用 requests 库的 get 方法请求网站内容,将其解码为文本形式,输出结果验证,完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests


# 请求头,添加你的浏览器信息后才可以正常运行
headers= {
'User-Agent': '...',
'Cookie': '...',
'Host': 'www.365kk.cc',
'Connection': 'keep-alive'
}

# 小说主页
main_url = "http://www.365kk.cc/255/255036/"

# 使用get方法请求网页
main_resp = requests.get(main_url, headers=headers)

# 将网页内容按utf-8规范解码为文本形式
main_text = main_resp.content.decode('utf-8')

print(main_text)

运行代码前,需要向 headers 中填入你自己浏览器的信息。

输出结果如下:

image

可以看出,我们成功请求到了网站内容,接下来只需对其进行解析,即可得到我们想要的部分。


解析网页内容

我们使用 lxml 库来解析网页内容,具体方法为将文本形式的网页内容创建为可解析的元素,再按照XPath路径访问其中的内容,代码如下:

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
29
30
31
32
33
34
35
36
37
import requests
from lxml import etree


# 请求头
headers= {
'User-Agent': '...',
'Cookie': '...',
'Host': 'www.365kk.cc',
'Connection': 'keep-alive'
}

# 小说主页
main_url = "http://www.365kk.cc/255/255036/"

# 使用get方法请求网页
main_resp = requests.get(main_url, headers=headers)

# 将网页内容按utf-8规范解码为文本形式
main_text = main_resp.content.decode('utf-8')

# 将文本内容创建为可解析元素
main_html = etree.HTML(main_text)

# 依次获取书籍的标题、作者、最近更新时间和简介
# main_html.xpath返回的是列表,因此需要加一个[0]来表示列表中的首个元素
bookTitle = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/h1/text()')[0]
author = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[1]/text()')[0]
update = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[5]/text()')[0]
introduction = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[2]/text()')[0]

# 输出结果以验证
print(bookTitle)
print(author)
print(update)
print(introduction)

输出结果如下:

image

至此,我们已经学会了基本的网页请求方法,并学会了如何获取目标页面中的特定内容


正文爬取与解析

接下来,我们开始爬取正文。首先尝试获取单个页面的数据,再尝试设计一个循环,依次获取所有正文数据。

单个页面数据的获取

以第一章为例,链接:http://www.365kk.cc/255/255036/4147599.html,获取章节标题和正文的XPath路径如下:

image
1
2
//*[@id="container"]/div/div/div[2]/h1/text()
//*[@id="content"]/text()

按照与上文一致的方法请求并解析网页内容,代码如下:

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
29
import requests
from lxml import etree


# 请求头
headers= {
'User-Agent': '...',
'Cookie': '...',
'Host': 'www.365kk.cc',
'Connection': 'keep-alive'
}

# ...
# 上一部分的代码
# ...

# 当前页面链接
url = 'http://www.365kk.cc/255/255036/4147599.html'

resp = requests.get(url, headers)
text = resp.content.decode('utf-8')
html = etree.HTML(text)

title = html.xpath('//*[@id="container"]/div/div/div[2]/h1/text()')[0]
contents = html.xpath('//*[@id="content"]/text()')

print(title)
for content in contents:
print(content)

输出结果如下:

image

可以看出,我们成功获取了小说第一章第一页的标题和正文部分,接下来我们将它储存在一个txt文本文档中,文档命名为之前获取的书名 bookTitle.txt,完整的代码如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import requests
from lxml import etree


# 请求头
headers= {
'User-Agent': '...',
'Cookie': '...',
'Host': 'www.365kk.cc',
'Connection': 'keep-alive'
}

# 小说主页
main_url = "http://www.365kk.cc/255/255036/"

# 使用get方法请求网页
main_resp = requests.get(main_url, headers=headers)

# 将网页内容按utf-8规范解码为文本形式
main_text = main_resp.content.decode('utf-8')

# 将文本内容创建为可解析元素
main_html = etree.HTML(main_text)

bookTitle = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/h1/text()')[0]
author = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[1]/text()')[0]
update = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[5]/text()')[0]
introduction = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[2]/text()')[0]

# 当前页面链接
url = 'http://www.365kk.cc/255/255036/4147599.html'

resp = requests.get(url, headers)
text = resp.content.decode('utf-8')
html = etree.HTML(text)

title = html.xpath('//*[@id="container"]/div/div/div[2]/h1/text()')[0]
contents = html.xpath('//*[@id="content"]/text()')

with open(bookTitle + '.txt', 'w', encoding='utf-8') as f:
f.write(title)
for content in contents:
f.write(content)
f.close()

运行结束后,可以看到在代码文件的同路径中,已经生成了一个文本文档:

image

打开该文档,可以看到储存好的内容:

image

储存好的内容中,大段的文字堆积在一起,而原文确实有着段落的区分,因此我们在储存文件时,每储存一段,就写入两个换行符 \n,使格式更便于阅读:

1
2
3
4
5
6
with open(bookTitle + '.txt', 'w', encoding='utf-8') as f:
f.write(title)
for content in contents:
f.write(content)
f.write('\n\n')
f.close()
image

至此,我们已经完成了单个页面的数据爬取和存储,接下来只要设计循环,实现顺序爬取所有页面即可。

关于文件读取的类型

本文中,用到的文件读取类型主要有:

类型含义使用示例
'w'清空原文档,重新写入文档open(filename, 'w')
'r'仅读取文档,不改变其内容open(filename, 'r')
'a'在原文档之后追加内容open(filename, 'a')

顺序爬取所有页面

我们注意到,正文的每个页面底部,都有一个按钮 下一页,其在网页中的结构为:

image

我们在XPath路径的末尾添加 @href 用于获取属性 href 的值:

1
//*[@id="container"]/div/div/div[2]/div[3]/a[3]/@href

这里有一个小细节需要注意

我们获取到的属性值在不同页面可能是不一样的,比如:

  • 第一章第一页中,下一页指向的链接为/255/255036/4147599_2.html
  • 第一章第二页中,下一页指向的链接为4147600.html
image
image

观察不同页面的链接,可以看出前缀是一致的,区别仅在后缀上,比如第一章第一页和第一章第二页的链接分别为:

1
2
http://www.365kk.cc/255/255036/4147599.html
http://www.365kk.cc/255/255036/4147599_2.html

因此,我们只需要获取 下一页 的链接后缀,再与前缀拼接,即可获得完整的访问链接。

编写一个函数 next_url() 实现上述功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 获取下一页链接的函数
def next_url(next_url_element):
nxturl = 'http://www.365kk.cc/255/255036/'
# rfind('/') 获取最后一个'/'字符的索引
index = next_url_element.rfind('/') + 1
nxturl += next_url_element[index:]
return nxturl


# 测试一下
url1 = '/255/255036/4147599_2.html'
url2 = '4147600.html'

print(next_url(url1))
print(next_url(url2))

输出如下所示:

image

在爬取某一页面的内容后,我们获取下一页的链接,并请求该链接指向的网页,重复这一过程直到全部爬取完毕为止,即可实现正文的爬取。

在这一过程中,需要注意的问题有:

  • 某一章节的内容可能分布在多个页面中,每个页面的章节标题是一致的,这一标题只需存储一次;
  • 请求网页内容的频率不宜过高,频繁地使用同一IP地址请求网页,会触发站点的反爬虫机制,禁止你的IP继续访问网站;
  • 爬取一次全文耗时较长,为了便于测试,我们需要先尝试爬取少量内容,代码调试完成后再爬取全文;
  • 爬取的起点为第一章第一页,爬取的终点可以自行设置;

按照上述思想,爬取前6个页面作为测试,完整的代码如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import requests
from lxml import etree
import time
import random


# 获取下一页链接的函数
def next_url(next_url_element):
nxturl = 'http://www.365kk.cc/255/255036/'
# rfind('/') 获取最后一个'/'字符的索引
index = next_url_element.rfind('/') + 1
nxturl += next_url_element[index:]
return nxturl


# 请求头,需要添加你的浏览器信息才可以运行
headers= {
'User-Agent': '...',
'Cookie': '...',
'Host': 'www.365kk.cc',
'Connection': 'keep-alive'
}

# 小说主页
main_url = "http://www.365kk.cc/255/255036/"

# 使用get方法请求网页
main_resp = requests.get(main_url, headers=headers)

# 将网页内容按utf-8规范解码为文本形式
main_text = main_resp.content.decode('utf-8')

# 将文本内容创建为可解析元素
main_html = etree.HTML(main_text)

bookTitle = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/h1/text()')[0]
author = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[1]/text()')[0]
update = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[5]/text()')[0]
introduction = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[2]/text()')[0]

# 调试期间仅爬取六个页面
maxPages = 6
cnt = 0

# 记录上一章节的标题
lastTitle = ''

# 爬取起点
url = 'http://www.365kk.cc/255/255036/4147599.html'

# 爬取终点
endurl = 'http://www.365kk.cc/255/255036/4148385.html'

while url != endurl:
cnt += 1 # 记录当前爬取的页面
if cnt > maxPages:
break # 当爬取的页面数超过maxPages时停止

resp = requests.get(url, headers)
text = resp.content.decode('utf-8')
html = etree.HTML(text)
title = html.xpath('//*[@class="title"]/text()')[0]
contents = html.xpath('//*[@id="content"]/text()')

# 输出爬取进度信息
print("cnt: {}, title = {}, url = {}".format(cnt, title, url))

with open(bookTitle + '.txt', 'a', encoding='utf-8') as f:
if title != lastTitle: # 章节标题改变
f.write(title) # 写入新的章节标题
lastTitle = title # 更新章节标题
for content in contents:
f.write(content)
f.write('\n\n')
f.close()

# 获取"下一页"按钮指向的链接
next_url_element = html.xpath('//*[@class="section-opt m-bottom-opt"]/a[3]/@href')[0]

# 传入函数next_url得到下一页链接
url = next_url(next_url_element)

sleepTime = random.randint(2, 5) # 产生一个2~5之间的随机数
time.sleep(sleepTime) # 暂停2~5之间随机的秒数

print("complete!")

运行结果如下:

image

数据清洗

观察我们得到的文本文档,可以发现如下问题:

  • 缺乏书籍信息,如之前获取的书名、作者、最后更新时间和简介;
  • 切换页面时,尤其是同一章节的不同页面之间空行过多
image
  • 每章节第一段缩进与其他段落不一致;
image
  • 不同章节之间缺乏显眼的分隔符

为了解决这些问题,我们编写一个函数 clean_data() 来实现数据清洗,代码如下:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def clean_data(filename, info):
"""
:param filename: 原文档名
:param info: [bookTitle, author, update, introduction]
"""

print("\n==== 数据清洗开始 ====")

# 新的文件名
new_filename = 'new' + filename

# 打开两个文本文档
f_old = open(filename, 'r', encoding='utf-8')
f_new = open(new_filename, 'w', encoding='utf-8')

# 首先在新的文档中写入书籍信息
f_new.write('== 《' + info[0] + '》\r\n') # 标题
f_new.write('== ' + info[1] + '\r\n') # 作者
f_new.write('== ' + info[2] + '\r\n') # 最后更新时间
f_new.write("=" * 10)
f_new.write('\r\n')
f_new.write('== ' + info[3] + '\r\n') # 简介
f_new.write("=" * 10)
f_new.write('\r\n')

lines = f_old.readlines() # 按行读取原文档中的内容
empty_cnt = 0 # 用于记录连续的空行数

# 遍历原文档中的每行
for line in lines:
if line == '\n': # 如果当前是空行
empty_cnt += 1 # 连续空行数+1
if empty_cnt >= 2: # 如果连续空行数不少于2
continue # 直接读取下一行,当前空行不写入
else: # 如果当前不是空行
empty_cnt = 0 # 连续空行数清零
if line.startswith("\u3000\u3000"): # 如果有段首缩进
line = line[2:] # 删除段首缩进
f_new.write(line) # 写入当前行
elif line.startswith("第"): # 如果当前行是章节标题
f_new.write("\r\n") # 写入换行
f_new.write("-" * 20) # 写入20个'-'
f_new.write("\r\n") # 写入换行
f_new.write(line) # 写入章节标题
else: # 如果当前行是未缩进的普通段落
f_new.write(line) # 保持原样写入

f_old.close() # 关闭原文档
f_new.close() # 关闭新文档

清洗后的文档中,每段段首无缩进,段与段之间仅空一行,不同章节之间插入20个字符 - 用以区分,上述问题得以解决。


完整的代码

需先填入自己浏览器的 headers 信息才可以直接运行哦!

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import requests
from lxml import etree
import time
import random


# 获取下一页链接的函数
def next_url(next_url_element):
nxturl = 'http://www.365kk.cc/255/255036/'
# rfind('/') 获取最后一个'/'字符的索引
index = next_url_element.rfind('/') + 1
nxturl += next_url_element[index:]
return nxturl


# 数据清洗函数
def clean_data(filename, info):
"""
:param filename: 原文档名
:param info: [bookTitle, author, update, introduction]
"""

print("\n==== 数据清洗开始 ====")

# 新的文件名
new_filename = 'new' + filename

# 打开两个文本文档
f_old = open(filename, 'r', encoding='utf-8')
f_new = open(new_filename, 'w', encoding='utf-8')

# 首先在新的文档中写入书籍信息
f_new.write('== 《' + info[0] + '》\r\n') # 标题
f_new.write('== ' + info[1] + '\r\n') # 作者
f_new.write('== ' + info[2] + '\r\n') # 最后更新时间
f_new.write("=" * 10)
f_new.write('\r\n')
f_new.write('== ' + info[3] + '\r\n') # 简介
f_new.write("=" * 10)
f_new.write('\r\n')

lines = f_old.readlines() # 按行读取原文档中的内容
empty_cnt = 0 # 用于记录连续的空行数

# 遍历原文档中的每行
for line in lines:
if line == '\n': # 如果当前是空行
empty_cnt += 1 # 连续空行数+1
if empty_cnt >= 2: # 如果连续空行数不少于2
continue # 直接读取下一行,当前空行不写入
else: # 如果当前不是空行
empty_cnt = 0 # 连续空行数清零
if line.startswith("\u3000\u3000"): # 如果有段首缩进
line = line[2:] # 删除段首缩进
f_new.write(line) # 写入当前行
elif line.startswith("第"): # 如果当前行是章节标题
f_new.write("\r\n") # 写入换行
f_new.write("-" * 20) # 写入20个'-'
f_new.write("\r\n") # 写入换行
f_new.write(line) # 写入章节标题
else: # 如果当前行是未缩进的普通段落
f_new.write(line) # 保持原样写入

f_old.close()
f_new.close()


# 请求头,使用前需填入自己浏览器的信息
headers= {
'User-Agent': '...',
'Cookie': '...',
'Host': 'www.365kk.cc',
'Connection': 'keep-alive'
}


# 小说主页
main_url = "http://www.365kk.cc/255/255036/"

# 使用get方法请求网页
main_resp = requests.get(main_url, headers=headers)

# 将网页内容按utf-8规范解码为文本形式
main_text = main_resp.content.decode('utf-8')

# 将文本内容创建为可解析元素
main_html = etree.HTML(main_text)

bookTitle = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/h1/text()')[0]
author = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[1]/text()')[0]
update = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[1]/div/p[5]/text()')[0]
introduction = main_html.xpath('/html/body/div[4]/div[1]/div/div/div[2]/div[2]/text()')[0]

# 调试期间仅爬取六个页面
maxPages = 6
cnt = 0

# 记录上一章节的标题
lastTitle = ''

# 爬取起点
url = 'http://www.365kk.cc/255/255036/4147599.html'

# 爬取终点
endurl = 'http://www.365kk.cc/255/255036/4148385.html'

while url != endurl:
cnt += 1 # 记录当前爬取的页面
# if cnt > maxPages:
# break # 当爬取的页面数超过maxPages时停止

resp = requests.get(url, headers)
text = resp.content.decode('utf-8')
html = etree.HTML(text)
title = html.xpath('//*[@class="title"]/text()')[0]
contents = html.xpath('//*[@id="content"]/text()')

# 输出爬取进度信息
print("cnt: {}, title = {}, url = {}".format(cnt, title, url))
print(contents)

with open(bookTitle + '.txt', 'a', encoding='utf-8') as f_new:
if title != lastTitle: # 章节标题改变
f_new.write(title) # 写入新的章节标题
lastTitle = title # 更新章节标题
for content in contents:
f_new.write(content)
f_new.write('\n\n')
f_new.close( )

# 获取"下一页"按钮指向的链接
next_url_element = html.xpath('//*[@class="section-opt m-bottom-opt"]/a[3]/@href')[0]

# 传入函数next_url得到下一页链接
url = next_url(next_url_element)

sleepTime = random.randint(2, 5) # 产生一个2~5之间的随机数
time.sleep(sleepTime) # 暂停2~5之间随机的秒数


clean_data(bookTitle + '.txt', [bookTitle, author, update, introduction])
print("complete!")

爬取过程如图:

image
image

封装整理

上述的开发流程基本上是想到哪写到哪,所有代码都堆在一个文件里,可读性较差

可以参考Scrapy框架的规则,将不同作用的变量和代码分配到不同的文件中,提高整个项目的可读性。

参考的文件结构如图所示:

image

其中:

  • output 用于存储爬虫输出的文本文档
  • clean.py 用于实现数据清洗
  • items.py 用于定义需要爬取的变量
  • main.py 是整个项目的主函数
  • settings.py 用于定义请求头、请求间隔、代理IP等
  • spider.py 用于实现爬虫的基本逻辑

评论