0%

Python

CSV,全称为 Comma-Separated Values,中文可以叫作逗号分隔值或字符分隔值,其文件以纯文本形式存储表格数据。该文件是一个字符序列,可以由任意数目的记录组成,记录间以某种换行符分隔。每条记录由字段组成,字段间的分隔符是其他字符或字符串,最常见的是逗号或制表符。不过所有记录都有完全相同的字段序列,相当于一个结构化表的纯文本形式。它比 Excel 文件更加简介,XLS 文本是电子表格,它包含了文本、数值、公式和格式等内容,而 CSV 中不包含这些内容,就是特定字符分隔的纯文本,结构简单清晰。所以,有时候用 CSV 来保存数据是比较方便的。本节中,我们来讲解 Python 读取和写入 CSV 文件的过程。

1. 写入

这里先看一个最简单的例子:

1
2
3
4
5
6
7
8
import csv

with open('data.csv', 'w') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['id', 'name', 'age'])
writer.writerow(['10001', 'Mike', 20])
writer.writerow(['10002', 'Bob', 22])
writer.writerow(['10003', 'Jordan', 21])

首先,打开 data.csv 文件,然后指定打开的模式为w(即写入),获得文件句柄,随后调用 csv 库的writer()方法初始化写入对象,传入该句柄,然后调用writerow()方法传入每行的数据即可完成写入。

运行结束后,会生成一个名为 data.csv 的文件,此时数据就成功写入了。直接以文本形式打开的话,其内容如下:

1
2
3
4
id,name,age
10001,Mike,20
10002,Bob,22
10003,Jordan,21

可以看到,写入的文本默认以逗号分隔,调用一次writerow()方法即可写入一行数据。用 Excel 打开的结果如图 5-6 所示。

图 5-6 打开结果

如果想修改列与列之间的分隔符,可以传入delimiter参数,其代码如下:

1
2
3
4
5
6
7
8
import csv

with open('data.csv', 'w') as csvfile:
writer = csv.writer(csvfile, delimiter=' ')
writer.writerow(['id', 'name', 'age'])
writer.writerow(['10001', 'Mike', 20])
writer.writerow(['10002', 'Bob', 22])
writer.writerow(['10003', 'Jordan', 21])

这里在初始化写入对象时传入delimiter为空格,此时输出结果的每一列就是以空格分隔了,内容如下:

1
2
3
4
id name age
10001 Mike 20
10002 Bob 22
10003 Jordan 21

另外,我们也可以调用writerows()方法同时写入多行,此时参数就需要为二维列表,例如:

1
2
3
4
5
6
import csv

with open('data.csv', 'w') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['id', 'name', 'age'])
writer.writerows([['10001', 'Mike', 20], ['10002', 'Bob', 22], ['10003', 'Jordan', 21]])

输出效果是相同的,内容如下:

1
2
3
4
id,name,age
10001,Mike,20
10002,Bob,22
10003,Jordan,21

但是一般情况下,爬虫爬取的都是结构化数据,我们一般会用字典来表示。在 csv 库中也提供了字典的写入方式,示例如下:

1
2
3
4
5
6
7
8
9
import csv

with open('data.csv', 'w') as csvfile:
fieldnames = ['id', 'name', 'age']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writeheader()
writer.writerow({'id': '10001', 'name': 'Mike', 'age': 20})
writer.writerow({'id': '10002', 'name': 'Bob', 'age': 22})
writer.writerow({'id': '10003', 'name': 'Jordan', 'age': 21})

这里先定义 3 个字段,用fieldnames表示,然后将其传给DictWriter来初始化一个字典写入对象,接着可以调用writeheader()方法先写入头信息,然后再调用writerow()方法传入相应字典即可。最终写入的结果是完全相同的,内容如下:

1
2
3
4
id,name,age
10001,Mike,20
10002,Bob,22
10003,Jordan,21

这样就可以完成字典到 CSV 文件的写入了。

另外,如果想追加写入的话,可以修改文件的打开模式,即将open()函数的第二个参数改成a,代码如下:

1
2
3
4
5
6
import csv

with open('data.csv', 'a') as csvfile:
fieldnames = ['id', 'name', 'age']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writerow({'id': '10004', 'name': 'Durant', 'age': 22})

这样在上面的基础上再执行这段代码,文件内容便会变成:

1
2
3
4
5
id,name,age
10001,Mike,20
10002,Bob,22
10003,Jordan,21
10004,Durant,22

可见,数据被追加写入到文件中。

如果要写入中文内容的话,可能会遇到字符编码的问题,此时需要给open()参数指定编码格式。比如,这里再写入一行包含中文的数据,代码需要改写如下:

1
2
3
4
5
6
import csv

with open('data.csv', 'a', encoding='utf-8') as csvfile:
fieldnames = ['id', 'name', 'age']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
writer.writerow({'id': '10005', 'name': '王伟', 'age': 22})

这里需要给open()函数指定编码,否则可能发生编码错误。

另外,如果接触过 pandas 等库的话,可以调用DataFrame对象的to_csv()方法来将数据写入 CSV 文件中。

2. 读取

我们同样可以使用 csv 库来读取 CSV 文件。例如,将刚才写入的文件内容读取出来,相关代码如下:

1
2
3
4
5
6
import csv

with open('data.csv', 'r', encoding='utf-8') as csvfile:
reader = csv.reader(csvfile)
for row in reader:
print(row)

运行结果如下:

1
2
3
4
5
6
['id', 'name', 'age']
['10001', 'Mike', '20']
['10002', 'Bob', '22']
['10003', 'Jordan', '21']
['10004', 'Durant', '22']
['10005', '王伟', '22']

这里我们构造的是Reader对象,通过遍历输出了每行的内容,每一行都是一个列表形式。注意,如果 CSV 文件中包含中文的话,还需要指定文件编码。

另外,如果接触过 pandas 的话,可以利用read_csv()方法将数据从 CSV 中读取出来,例如:

1
2
3
4
import pandas  as pd

df = pd.read_csv('data.csv')
print(df)

运行结果如下:

1
2
3
4
5
6
      id    name  age
0 10001 Mike 20
1 10002 Bob 22
2 10003 Jordan 21
3 10004 Durant 22
4 10005 王伟 22

在做数据分析的时候,此种方法用得比较多,也是一种比较方便地读取 CSV 文件的方法。

本节中,我们了解了 CSV 文件的写入和读取方式。这也是一种常用的数据存储方式,需要熟练掌握。

Python

JSON,全称为 JavaScript Object Notation, 也就是 JavaScript 对象标记,它通过对象和数组的组合来表示数据,构造简洁但是结构化程度非常高,是一种轻量级的数据交换格式。本节中,我们就来了解如何利用 Python 保存数据到 JSON 文件。

1. 对象和数组

在 JavaScript 语言中,一切都是对象。因此,任何支持的类型都可以通过 JSON 来表示,例如字符串、数字、对象、数组等,但是对象和数组是比较特殊且常用的两种类型,下面简要介绍一下它们。

  • 对象:它在 JavaScript 中是使用花括号{}包裹起来的内容,数据结构为{key1:value1, key2:value2, ...}的键值对结构。在面向对象的语言中,key为对象的属性,value为对应的值。键名可以使用整数和字符串来表示。值的类型可以是任意类型。
  • 数组:数组在 JavaScript 中是方括号[]包裹起来的内容,数据结构为["java", "javascript", "vb", ...]的索引结构。在 JavaScript 中,数组是一种比较特殊的数据类型,它也可以像对象那样使用键值对,但还是索引用得多。同样,值的类型可以是任意类型。

所以,一个 JSON 对象可以写为如下形式:

1
2
3
4
5
6
7
8
9
[{
"name": "Bob",
"gender": "male",
"birthday": "1992-10-18"
}, {
"name": "Selina",
"gender": "female",
"birthday": "1995-10-18"
}]

由中括号包围的就相当于列表类型,列表中的每个元素可以是任意类型,这个示例中它是字典类型,由大括号包围。

JSON 可以由以上两种形式自由组合而成,可以无限次嵌套,结构清晰,是数据交换的极佳方式。

2. 读取 JSON

Python 为我们提供了简单易用的库来实现 JSON 文件的读写操作,我们可以调用库的loads()方法将 JSON 文本字符串转为 JSON 对象,可以通过dumps()方法将 JSON 对象转为文本字符串。

例如,这里有一段 JSON 形式的字符串,它是str类型,我们用 Python 将其转换为可操作的数据结构,如列表或字典:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import json

str = '''
[{
"name": "Bob",
"gender": "male",
"birthday": "1992-10-18"
}, {
"name": "Selina",
"gender": "female",
"birthday": "1995-10-18"
}]
'''
print(type(str))
data = json.loads(str)
print(data)
print(type(data))

运行结果如下:

1
2
3
<class 'str'>
[{'name': 'Bob', 'gender': 'male', 'birthday': '1992-10-18'}, {'name': 'Selina', 'gender': 'female', 'birthday': '1995-10-18'}]
<class 'list'>

这里使用loads()方法将字符串转为 JSON 对象。由于最外层是中括号,所以最终的类型是列表类型。

这样一来,我们就可以用索引来获取对应的内容了。例如,如果想取第一个元素里的name属性,就可以使用如下方式:

1
2
data[0]['name']
data[0].get('name')

得到的结果都是:

1
Bob

通过中括号加 0 索引,可以得到第一个字典元素,然后再调用其键名即可得到相应的键值。获取键值时有两种方式,一种是中括号加键名,另一种是通过get()方法传入键名。这里推荐使用get()方法,这样如果键名不存在,则不会报错,会返回None。另外,get()方法还可以传入第二个参数(即默认值),示例如下:

1
2
data[0].get('age')
data[0].get('age', 25)

运行结果如下:

1
2
None
25

这里我们尝试获取年龄age,其实在原字典中该键名不存在,此时默认会返回None。如果传入第二个参数(即默认值),那么在不存在的情况下返回该默认值。

值得注意的是,JSON 的数据需要用双引号来包围,不能使用单引号。例如,若使用如下形式表示,则会出现错误:

1
2
3
4
5
6
7
8
9
10
import json

str = '''
[{
'name': 'Bob',
'gender': 'male',
'birthday': '1992-10-18'
}]
'''
data = json.loads(str)

运行结果如下:

1
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 3 column 5 (char 8)

这里会出现 JSON 解析错误的提示。这是因为这里数据用单引号来包围,请千万注意 JSON 字符串的表示需要用双引号,否则loads()方法会解析失败。

如果从 JSON 文本中读取内容,例如这里有一个 data.文本文件,其内容是刚才定义的 JSON 字符串,我们可以先将文本文件内容读出,然后再利用loads()方法转化:

1
2
3
4
5
6
import json

with open('data.json', 'r') as file:
str = file.read()
data = json.loads(str)
print(data)

运行结果如下:

1
[{'name': 'Bob', 'gender': 'male', 'birthday': '1992-10-18'}, {'name': 'Selina', 'gender': 'female', 'birthday': '1995-10-18'}]

3. 输出 JSON

另外,我们还可以调用dumps()方法将 JSON 对象转化为字符串。例如,将上例中的列表重新写入文本:

1
2
3
4
5
6
7
8
9
import json

data = [{
'name': 'Bob',
'gender': 'male',
'birthday': '1992-10-18'
}]
with open('data.json', 'w') as file:
file.write(json.dumps(data))

利用dumps()方法,我们可以将 JSON 对象转为字符串,然后再调用文件的write()方法写入文本,结果如图 5-2 所示。

图 5-2 写入结果

另外,如果想保存 JSON 的格式,可以再加一个参数indent,代表缩进字符个数。示例如下:

1
2
with open('data.json', 'w') as file:
file.write(json.dumps(data, indent=2))

此时写入结果如图 5-3 所示。

图 5-3 写入结果

这样得到的内容会自动带缩进,格式会更加清晰。

另外,如果 JSON 中包含中文字符,会怎么样呢?例如,我们将之前的 JSON 的部分值改为中文,再用之前的方法写入到文本:

1
2
3
4
5
6
7
8
9
import json

data = [{
'name': '王伟',
'gender': '男',
'birthday': '1992-10-18'
}]
with open('data.json', 'w') as file:
file.write(json.dumps(data, indent=2))

写入结果如图 5-4 所示。

图 5-4 写入结果

可以看到,中文字符都变成了 Unicode 字符,这并不是我们想要的结果。

为了输出中文,还需要指定参数ensure_asciiFalse,另外还要规定文件输出的编码:

1
2
with open('data.json', 'w', encoding='utf-8') as file:
file.write(json.dumps(data, indent=2, ensure_ascii=False))

写入结果如图 5-5 所示。

图 5-5 写入结果

可以发现,这样就可以输出 JSON 为中文了。

本节中,我们了解了用 Python 进行 JSON 文件读写的方法,后面做数据解析时经常会用到,建议熟练掌握。

Python

将数据保存到 TXT 文本的操作非常简单,而且 TXT 文本几乎兼容任何平台,但是这有个缺点,那就是不利于检索。所以如果对检索和数据结构要求不高,追求方便第一的话,可以采用 TXT 文本存储。本节中,我们就来看下如何利用 Python 保存 TXT 文本文件。

1. 本节目标

本节中,我们要保存知乎上“发现”页面的“热门话题”部分,将其问题和答案统一保存成文本形式。

2. 基本实例

首先,可以用 requests 将网页源代码获取下来,然后使用 pyquery 解析库解析,接下来将提取的标题、回答者、回答保存到文本,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import requests
from pyquery import PyQuery as pq

url = 'https://www.zhihu.com/explore'
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36'
}
html = requests.get(url, headers=headers).text
doc = pq(html)
items = doc('.explore-tab .feed-item').items()
for item in items:
question = item.find('h2').text()
author = item.find('.author-link-line').text()
answer = pq(item.find('.content').html()).text()
file = open('explore.txt', 'a', encoding='utf-8')
file.write('\n'.join([question, author, answer]))
file.write('\n' + '=' * 50 + '\n')
file.close()

这里主要是为了演示文件保存的方式,因此 requests 异常处理部分在此省去。首先,用 requests 提取知乎的“发现”页面,然后将热门话题的问题、回答者、答案全文提取出来,然后利用 Python 提供的open()方法打开一个文本文件,获取一个文件操作对象,这里赋值为file,接着利用file对象的write()方法将提取的内容写入文件,最后调用close()方法将其关闭,这样抓取的内容即可成功写入文本中了。

运行程序,可以发现在本地生成了一个 explore.txt 文件,其内容如图 5-1 所示。

图 5-1 文件内容

这样热门问答的内容就被保存成文本形式了。

这里open()方法的第一个参数即要保存的目标文件名称,第二个参数为a,代表以追加方式写入到文本。另外,我们还指定了文件的编码为utf-8。最后,写入完成后,还需要调用close()方法来关闭文件对象。

3. 打开方式

在刚才的实例中,open()方法的第二个参数设置成了a,这样在每次写入文本时不会清空源文件,而是在文件末尾写入新的内容,这是一种文件打开方式。关于文件的打开方式,其实还有其他几种,这里简要介绍一下。

  • r:以只读方式打开文件。文件的指针将会放在文件的开头。这是默认模式。
  • rb:以二进制只读方式打开一个文件。文件指针将会放在文件的开头。
  • r+:以读写方式打开一个文件。文件指针将会放在文件的开头。
  • rb+:以二进制读写方式打开一个文件。文件指针将会放在文件的开头。
  • w:以写入方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • wb:以二进制写入方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • w+:以读写方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • wb+:以二进制读写格式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。
  • a:以追加方式打开一个文件。如果该文件已存在,文件指针将会放在文件结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,则创建新文件来写入。

  • ab:以二进制追加方式打开一个文件。如果该文件已存在,则文件指针将会放在文件结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,则创建新文件来写入。

  • a+:以读写方式打开一个文件。如果该文件已存在,文件指针将会放在文件的结尾。文件打开时会是追加模式。如果该文件不存在,则创建新文件来读写。

  • ab+:以二进制追加方式打开一个文件。如果该文件已存在,则文件指针将会放在文件结尾。如果该文件不存在,则创建新文件用于读写。

4. 简化写法

另外,文件写入还有一种简写方法,那就是使用with as语法。在with控制块结束时,文件会自动关闭,所以就不需要再调用close()方法了。这种保存方式可以简写如下:

1
2
3
with open('explore.txt', 'a', encoding='utf-8') as file:
file.write('\n'.join([question, author, answer]))
file.write('\n' + '=' * 50 + '\n')

如果想保存时将原文清空,那么可以将第二个参数改写为w,代码如下:

1
2
3
with open('explore.txt', 'w', encoding='utf-8') as file:
file.write('\n'.join([question, author, answer]))
file.write('\n' + '=' * 50 + '\n')

上面便是利用 Python 将结果保存为 TXT 文件的方法,这种方法简单易用,操作高效,是一种最基本的保存数据的方法。

Python

用解析器解析出数据之后,接下来就是存储数据了。保存的形式可以多种多样,最简单的形式是直接保存为文本文件,如TXT、JSON、CSV等。另外,还可以保存到数据库中,如关系型数据库MySQL,非关系型数据库MongoDB、Redis等。

Python

在上一节中,我们介绍了Beautiful Soup的用法,它是一个非常强大的网页解析库,你是否觉得它的一些方法用起来有点不适应?有没有觉得它的CSS选择器的功能没有那么强大?

如果你对Web有所涉及,如果你比较喜欢用CSS选择器,如果你对jQuery有所了解,那么这里有一个更适合你的解析库——pyquery。

接下来,我们就来感受一下pyquery的强大之处。

1. 准备工作

在开始之前,请确保已经正确安装好了pyquery。若没有安装,可以参考第1章的安装过程。

2. 初始化

像Beautiful Soup一样,初始化pyquery的时候,也需要传入HTML文本来初始化一个PyQuery对象。它的初始化方式有多种,比如直接传入字符串,传入URL,传入文件名,等等。下面我们来详细介绍一下。

字符串初始化

首先,我们用一个实例来感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
html = '''
<div>
<ul>
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
print(doc('li'))

运行结果如下:

1
2
3
4
5
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

这里首先引入PyQuery这个对象,取别名为pq。然后声明了一个长HTML字符串,并将其当作参数传递给PyQuery类,这样就成功完成了初始化。接下来,将初始化的对象传入CSS选择器。在这个实例中,我们传入li节点,这样就可以选择所有的li节点。

URL初始化

初始化的参数不仅可以以字符串的形式传递,还可以传入网页的URL,此时只需要指定参数为url即可:

1
2
3
from pyquery import PyQuery as pq
doc = pq(url='http://cuiqingcai.com')
print(doc('title'))

运行结果如下:

1
<title>静觅丨崔庆才的个人博客</title>

这样的话,PyQuery对象会首先请求这个URL,然后用得到的HTML内容完成初始化,这其实就相当于用网页的源代码以字符串的形式传递给PyQuery类来初始化。

它与下面的功能是相同的:

1
2
3
4
from pyquery import PyQuery as pq
import requests
doc = pq(requests.get('http://cuiqingcai.com').text)
print(doc('title'))

文件初始化

当然,除了传递URL,还可以传递本地的文件名,此时将参数指定为filename即可:

1
2
3
from pyquery import PyQuery as pq
doc = pq(filename='demo.html')
print(doc('li'))

当然,这里需要有一个本地HTML文件demo.html,其内容是待解析的HTML字符串。这样它会首先读取本地的文件内容,然后用文件内容以字符串的形式传递给PyQuery类来初始化。

以上3种初始化方式均可,当然最常用的初始化方式还是以字符串形式传递。

3. 基本CSS选择器

首先,用一个实例来感受pyquery的CSS选择器的用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
html = '''
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
print(doc('#container .list li'))
print(type(doc('#container .list li')))

运行结果如下:

1
2
3
4
5
6
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
<class 'pyquery.pyquery.PyQuery'>

这里我们初始化PyQuery对象之后,传入了一个CSS选择器#container .list li,它的意思是先选取idcontainer的节点,然后再选取其内部的classlist的节点内部的所有li节点。然后,打印输出。可以看到,我们成功获取到了符合条件的节点。

最后,将它的类型打印输出。可以看到,它的类型依然是PyQuery类型。

4. 查找节点

下面我们介绍一些常用的查询函数,这些函数和jQuery中函数的用法完全相同。

子节点

查找子节点时,需要用到find()方法,此时传入的参数是CSS选择器。这里还是以前面的HTML为例:

1
2
3
4
5
6
7
8
from pyquery import PyQuery as pq
doc = pq(html)
items = doc('.list')
print(type(items))
print(items)
lis = items.find('li')
print(type(lis))
print(lis)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<class 'pyquery.pyquery.PyQuery'>
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

首先,我们选取classlist的节点,然后调用了find()方法,传入CSS选择器,选取其内部的li节点,最后打印输出。可以发现,find()方法会将符合条件的所有节点选择出来,结果的类型是PyQuery类型。

其实find()的查找范围是节点的所有子孙节点,而如果我们只想查找子节点,那么可以用children()方法:

1
2
3
lis = items.children()
print(type(lis))
print(lis)

运行结果如下:

1
2
3
4
5
6
<class 'pyquery.pyquery.PyQuery'>
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

如果要筛选所有子节点中符合条件的节点,比如想筛选出子节点中classactive的节点,可以向children()方法传入CSS选择器.active

1
2
lis = items.children('.active')
print(lis)

运行结果如下:

1
2
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>

可以看到,输出结果已经做了筛选,留下了classactive的节点。

父节点

我们可以用parent()方法来获取某个节点的父节点,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
html = '''
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
items = doc('.list')
container = items.parent()
print(type(container))
print(container)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
<class 'pyquery.pyquery.PyQuery'>
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>

这里我们首先用.list选取classlist的节点,然后调用parent()方法得到其父节点,其类型依然是PyQuery类型。

这里的父节点是该节点的直接父节点,也就是说,它不会再去查找父节点的父节点,即祖先节点。

但是如果想获取某个祖先节点,该怎么办呢?这时可以用parents()方法:

1
2
3
4
5
6
from pyquery import PyQuery as pq
doc = pq(html)
items = doc('.list')
parents = items.parents()
print(type(parents))
print(parents)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<class 'pyquery.pyquery.PyQuery'>
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>

可以看到,输出结果有两个:一个是classwrap的节点,一个是idcontainer的节点。也就是说,parents()方法会返回所有的祖先节点。

如果想要筛选某个祖先节点的话,可以向parents()方法传入CSS选择器,这样就会返回祖先节点中符合CSS选择器的节点:

1
2
parent = items.parents('.wrap')
print(parent)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>

可以看到,输出结果少了一个节点,只保留了classwrap的节点。

兄弟节点

前面我们说明了子节点和父节点的用法,还有一种节点,那就是兄弟节点。如果要获取兄弟节点,可以使用siblings()方法。这里还是以上面的HTML代码为例:

1
2
3
4
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.list .item-0.active')
print(li.siblings())

这里首先选择classlist的节点内部classitem-0active的节点,也就是第三个li节点。那么,很明显,它的兄弟节点有4个,那就是第一、二、四、五个li节点。

运行结果如下:

1
2
3
4
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0">first item</li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>

可以看到,这正是我们刚才所说的4个兄弟节点。

如果要筛选某个兄弟节点,我们依然可以向siblings方法传入CSS选择器,这样就会从所有兄弟节点中挑选出符合条件的节点了:

1
2
3
4
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.list .item-0.active')
print(li.siblings('.active'))

这里我们筛选了classactive的节点,通过刚才的结果可以观察到,classactive的兄弟节点只有第四个li节点,所以结果应该是一个。

我们再看一下运行结果:

1
<li class="item-1 active"><a href="link4.html">fourth item</a></li>

5. 遍历

刚才可以观察到,pyquery的选择结果可能是多个节点,也可能是单个节点,类型都是PyQuery类型,并没有返回像Beautiful Soup那样的列表。

对于单个节点来说,可以直接打印输出,也可以直接转成字符串:

1
2
3
4
5
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
print(str(li))

运行结果如下:

1
2
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>

对于多个节点的结果,我们就需要遍历来获取了。例如,这里把每一个li节点进行遍历,需要调用items()方法:

1
2
3
4
5
6
from pyquery import PyQuery as pq
doc = pq(html)
lis = doc('li').items()
print(type(lis))
for li in lis:
print(li, type(li))

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
<class 'generator'>
<li class="item-0">first item</li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-1"><a href="link2.html">second item</a></li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<class 'pyquery.pyquery.PyQuery'>
<li class="item-0"><a href="link5.html">fifth item</a></li>
<class 'pyquery.pyquery.PyQuery'>

可以发现,调用items()方法后,会得到一个生成器,遍历一下,就可以逐个得到li节点对象了,它的类型也是PyQuery类型。每个li节点还可以调用前面所说的方法进行选择,比如继续查询子节点,寻找某个祖先节点等,非常灵活。

6. 获取信息

提取到节点之后,我们的最终目的当然是提取节点所包含的信息了。比较重要的信息有两类,一是获取属性,二是获取文本,下面分别进行说明。

获取属性

提取到某个PyQuery类型的节点后,就可以调用attr()方法来获取属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
html = '''
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
a = doc('.item-0.active a')
print(a, type(a))
print(a.attr('href'))

运行结果如下:

1
2
<a href="link3.html"><span class="bold">third item</span></a> <class 'pyquery.pyquery.PyQuery'>
link3.html

这里首先选中classitem-0activeli节点内的a节点,它的类型是PyQuery类型。

然后调用attr()方法。在这个方法中传入属性的名称,就可以得到这个属性值了。

此外,也可以通过调用attr属性来获取属性,用法如下:

1
print(a.attr.href)

结果如下:

1
link3.html

这两种方法的结果完全一样。

如果选中的是多个元素,然后调用attr()方法,会出现怎样的结果呢?我们用实例来测试一下:

1
2
3
4
a = doc('a')
print(a, type(a))
print(a.attr('href'))
print(a.attr.href)

运行结果如下:

1
2
3
<a href="link2.html">second item</a><a href="link3.html"><span class="bold">third item</span></a><a href="link4.html">fourth item</a><a href="link5.html">fifth item</a> <class 'pyquery.pyquery.PyQuery'>
link2.html
link2.html

照理来说,我们选中的a节点应该有4个,而且打印结果也应该是4个,但是当我们调用attr()方法时,返回结果却只是第一个。这是因为,当返回结果包含多个节点时,调用attr()方法,只会得到第一个节点的属性。

那么,遇到这种情况时,如果想获取所有的a节点的属性,就要用到前面所说的遍历了:

1
2
3
4
5
from pyquery import PyQuery as pq
doc = pq(html)
a = doc('a')
for item in a.items():
print(item.attr('href'))

此时的运行结果如下:

1
2
3
4
link2.html
link3.html
link4.html
link5.html

因此,在进行属性获取时,可以观察返回节点是一个还是多个,如果是多个,则需要遍历才能依次获取每个节点的属性。

获取文本

获取节点之后的另一个主要操作就是获取其内部的文本了,此时可以调用text()方法来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
html = '''
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
a = doc('.item-0.active a')
print(a)
print(a.text())

运行结果如下:

1
2
<a href="link3.html"><span class="bold">third item</span></a>
third item

这里首先选中一个a节点,然后调用text()方法,就可以获取其内部的文本信息。此时它会忽略掉节点内部包含的所有HTML,只返回纯文字内容。

但如果想要获取这个节点内部的HTML文本,就要用html()方法了:

1
2
3
4
5
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
print(li.html())

这里我们选中了第三个li节点,然后调用了html()方法,它返回的结果应该是li节点内的所有HTML文本。

运行结果如下:

1
<a href="link3.html"><span class="bold">third item</span></a>

这里同样有一个问题,如果我们选中的结果是多个节点,text()html()会返回什么内容?我们用实例来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
html = '''
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('li')
print(li.html())
print(li.text())
print(type(li.text())

运行结果如下:

1
2
3
<a href="link2.html">second item</a>
second item third item fourth item fifth item
<class 'str'>

结果可能比较出乎意料,html()方法返回的是第一个li节点的内部HTML文本,而text()则返回了所有的li节点内部的纯文本,中间用一个空格分割开,即返回结果是一个字符串。

所以这个地方值得注意,如果得到的结果是多个节点,并且想要获取每个节点的内部HTML文本,则需要遍历每个节点。而text()方法不需要遍历就可以获取,它将所有节点取文本之后合并成一个字符串。

7. 节点操作

pyquery提供了一系列方法来对节点进行动态修改,比如为某个节点添加一个class,移除某个节点等,这些操作有时候会为提取信息带来极大的便利。

由于节点操作的方法太多,下面举几个典型的例子来说明它的用法。

addClassremoveClass

我们先用实例来感受一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
html = '''
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
li.removeClass('active')
print(li)
li.addClass('active')
print(li)

首先选中了第三个li节点,然后调用removeClass()方法,将li节点的active这个class移除,后来又调用addClass()方法,将class添加回来。每执行一次操作,就打印输出当前li节点的内容。

运行结果如下:

1
2
3
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>

可以看到,一共输出了3次。第二次输出时,li节点的active这个class被移除了,第三次class又添加回来了。

所以说,addClass()removeClass()这些方法可以动态改变节点的class属性。

attrtexthtml

当然,除了操作class这个属性外,也可以用attr()方法对属性进行操作。此外,还可以用text()html()方法来改变节点内部的内容。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
html = '''
<ul class="list">
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
</ul>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('.item-0.active')
print(li)
li.attr('name', 'link')
print(li)
li.text('changed item')
print(li)
li.html('<span>changed item</span>')
print(li)

这里我们首先选中li节点,然后调用attr()方法来修改属性,其中该方法的第一个参数为属性名,第二个参数为属性值。接着,调用text()html()方法来改变节点内部的内容。三次操作后,分别打印输出当前的li节点。

运行结果如下:

1
2
3
4
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active" name="link"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-0 active" name="link">changed item</li>
<li class="item-0 active" name="link"><span>changed item</span></li>

可以发现,调用attr()方法后,li节点多了一个原本不存在的属性name,其值为link。接着调用text()方法,传入文本之后,li节点内部的文本全被改为传入的字符串文本了。最后,调用html()方法传入HTML文本后,li节点内部又变为传入的HTML文本了。

所以说,如果attr()方法只传入第一个参数的属性名,则是获取这个属性值;如果传入第二个参数,可以用来修改属性值。text()html()方法如果不传参数,则是获取节点内纯文本和HTML文本;如果传入参数,则进行赋值。

remove()

顾名思义,remove()方法就是移除,它有时会为信息的提取带来非常大的便利。下面有一段HTML文本:

1
2
3
4
5
6
7
8
9
10
html = '''
<div class="wrap">
Hello, World
<p>This is a paragraph.</p>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
wrap = doc('.wrap')
print(wrap.text())

现在想提取Hello, World这个字符串,而不要p节点内部的字符串,需要怎样操作呢?

这里直接先尝试提取classwrap的节点的内容,看看是不是我们想要的。运行结果如下:

1
Hello, World This is a paragraph.

这个结果还包含了内部的p节点的内容,也就是说text()把所有的纯文本全提取出来了。如果我们想去掉p节点内部的文本,可以选择再把p节点内的文本提取一遍,然后从整个结果中移除这个子串,但这个做法明显比较烦琐。

这时remove()方法就可以派上用场了,我们可以接着这么做:

1
2
wrap.find('p').remove()
print(wrap.text())

首先选中p节点,然后调用了remove()方法将其移除,然后这时wrap内部就只剩下Hello, World这句话了,然后再利用text()方法提取即可。

另外,其实还有很多节点操作的方法,比如append()empty()prepend()等方法,它们和jQuery的用法完全一致,详细的用法可以参考官方文档:http://pyquery.readthedocs.io/en/latest/api.html

8. 伪类选择器

CSS选择器之所以强大,还有一个很重要的原因,那就是它支持多种多样的伪类选择器,例如选择第一个节点、最后一个节点、奇偶数节点、包含某一文本的节点等。示例如下:

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
html = '''
<div class="wrap">
<div id="container">
<ul class="list">
<li class="item-0">first item</li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li>
<li class="item-1 active"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a></li>
</ul>
</div>
</div>
'''
from pyquery import PyQuery as pq
doc = pq(html)
li = doc('li:first-child')
print(li)
li = doc('li:last-child')
print(li)
li = doc('li:nth-child(2)')
print(li)
li = doc('li:gt(2)')
print(li)
li = doc('li:nth-child(2n)')
print(li) `li \= doc('li:contains(second)')
print(li)`

这里我们使用了CSS3的伪类选择器,依次选择了第一个li节点、最后一个li节点、第二个li节点、第三个li之后的li节点、偶数位置的li节点、包含second文本的li节点。

关于CSS选择器的更多用法,可以参考http://www.w3school.com.cn/css/index.asp

到此为止,pyquery的常用用法就介绍完了。如果想查看更多的内容,可以参考pyquery的官方文档:http://pyquery.readthedocs.io。我们相信有了它,解析网页不再是难事。

Python

前面介绍了正则表达式的相关用法,但是一旦正则表达式写的有问题,得到的可能就不是我们想要的结果了。而且对于一个网页来说,都有一定的特殊结构和层级关系,而且很多节点都有idclass来作区分,所以借助它们的结构和属性来提取不也可以吗?

这一节中,我们就来介绍一个强大的解析工具Beautiful Soup,它借助网页的结构和属性等特性来解析网页。有了它,我们不用再去写一些复杂的正则表达式,只需要简单的几条语句,就可以完成网页中某个元素的提取。

废话不多说,接下来就来感受一下Beautiful Soup的强大之处吧。

1. 简介

简单来说,Beautiful Soup就是Python的一个HTML或XML的解析库,可以用它来方便地从网页中提取数据。官方解释如下:

Beautiful Soup提供一些简单的、Python式的函数来处理导航、搜索、修改分析树等功能。它是一个工具箱,通过解析文档为用户提供需要抓取的数据,因为简单,所以不需要多少代码就可以写出一个完整的应用程序。

Beautiful Soup自动将输入文档转换为Unicode编码,输出文档转换为UTF-8编码。你不需要考虑编码方式,除非文档没有指定一个编码方式,这时你仅仅需要说明一下原始编码方式就可以了。

Beautiful Soup已成为和lxml、html6lib一样出色的Python解释器,为用户灵活地提供不同的解析策略或强劲的速度。

所以说,利用它可以省去很多烦琐的提取工作,提高了解析效率。

2. 准备工作

在开始之前,请确保已经正确安装好了Beautiful Soup和lxml,如果没有安装,可以参考第1章的内容。

3. 解析器

Beautiful Soup在解析时实际上依赖解析器,它除了支持Python标准库中的HTML解析器外,还支持一些第三方解析器(比如lxml)。表4-3列出了Beautiful Soup支持的解析器。

表4-3 Beautiful Soup支持的解析器

解析器

使用方法

优势

劣势

Python标准库

BeautifulSoup(markup, "html.parser")

Python的内置标准库、执行速度适中、文档容错能力强

Python 2.7.3及Python 3.2.2之前的版本文档容错能力差

lxml HTML解析器

BeautifulSoup(markup, "lxml")

速度快、文档容错能力强

需要安装C语言库

lxml XML解析器

BeautifulSoup(markup, "xml")

速度快、唯一支持XML的解析器

需要安装C语言库

html5lib

BeautifulSoup(markup, "html5lib")

最好的容错性、以浏览器的方式解析文档、生成HTML5格式的文档

速度慢、不依赖外部扩展

通过以上对比可以看出,lxml解析器有解析HTML和XML的功能,而且速度快,容错能力强,所以推荐使用它。

如果使用lxml,那么在初始化Beautiful Soup时,可以把第二个参数改为lxml即可:

1
2
3
from bs4 import BeautifulSoup
soup = BeautifulSoup('<p>Hello</p>', 'lxml')
print(soup.p.string)

在后面,Beautiful Soup的用法实例也统一用这个解析器来演示。

4. 基本用法

下面首先用实例来看看Beautiful Soup的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
html = """
<html><head><title>The Dormouse's story</title></head>
<body>
<p class="title" name="dromouse"><b>The Dormouse's story</b></p>
<p class="story">Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1"><!-- Elsie --></a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a> and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;
and they lived at the bottom of a well.</p>
<p class="story">...</p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.prettify())
print(soup.title.string)

运行结果如下:

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
<html>
<head>
<title>
The Dormouse's story
</title>
</head>
<body>
<p class="title" name="dromouse">
<b>
The Dormouse's story
</b>
</p>
<p class="story">
Once upon a time there were three little sisters; and their names were
<a class="sister" href="http://example.com/elsie" id="link1">
<!-- Elsie -->
</a>
,
<a class="sister" href="http://example.com/lacie" id="link2">
Lacie
</a>
and
<a class="sister" href="http://example.com/tillie" id="link3">
Tillie
</a>
;
and they lived at the bottom of a well.
</p>
<p class="story">
...
</p>
</body>
</html>
The Dormouse's story

这里首先声明变量html,它是一个HTML字符串。但是需要注意的是,它并不是一个完整的HTML字符串,因为bodyhtml节点都没有闭合。接着,我们将它当作第一个参数传给BeautifulSoup对象,该对象的第二个参数为解析器的类型(这里使用lxml),此时就完成了BeaufulSoup对象的初始化。然后,将这个对象赋值给soup变量。

接下来,就可以调用soup的各个方法和属性解析这串HTML代码了。

首先,调用prettify()方法。这个方法可以把要解析的字符串以标准的缩进格式输出。这里需要注意的是,输出结果里面包含bodyhtml节点,也就是说对于不标准的HTML字符串BeautifulSoup,可以自动更正格式。这一步不是由prettify()方法做的,而是在初始化BeautifulSoup时就完成了。

然后调用soup.title.string,这实际上是输出HTML中title节点的文本内容。所以,soup.title可以选出HTML中的title节点,再调用string属性就可以得到里面的文本了,所以我们可以通过简单调用几个属性完成文本提取,这是不是非常方便?

5. 节点选择器

直接调用节点的名称就可以选择节点元素,再调用string属性就可以得到节点内的文本了,这种选择方式速度非常快。如果单个节点结构层次非常清晰,可以选用这种方式来解析。

选择元素

下面再用一个例子详细说明选择元素的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
html = """
<html><head><title>The Dormouse's story</title></head>
<body>
<p class="title" name="dromouse"><b>The Dormouse's story</b></p>
<p class="story">Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1"><!-- Elsie --></a>,
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a> and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>;
and they lived at the bottom of a well.</p>
<p class="story">...</p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.title)
print(type(soup.title))
print(soup.title.string)
print(soup.head)
print(soup.p)

运行结果如下:

1
2
3
4
5
<title>The Dormouse's story</title>
<class 'bs4.element.Tag'>
The Dormouse's story
<head><title>The Dormouse's story</title></head>
<p class="title" name="dromouse"><b>The Dormouse's story</b></p>

这里依然选用刚才的HTML代码,首先打印输出title节点的选择结果,输出结果正是title节点加里面的文字内容。接下来,输出它的类型,是bs4.element.Tag类型,这是Beautiful Soup中一个重要的数据结构。经过选择器选择后,选择结果都是这种Tag类型。Tag具有一些属性,比如string属性,调用该属性,可以得到节点的文本内容,所以接下来的输出结果正是节点的文本内容。

接下来,我们又尝试选择了head节点,结果也是节点加其内部的所有内容。最后,选择了p节点。不过这次情况比较特殊,我们发现结果是第一个p节点的内容,后面的几个p节点并没有选到。也就是说,当有多个节点时,这种选择方式只会选择到第一个匹配的节点,其他的后面节点都会忽略。

提取信息

上面演示了调用string属性来获取文本的值,那么如何获取节点属性的值呢?如何获取节点名呢?下面我们来统一梳理一下信息的提取方式。

(1)获取名称

可以利用name属性获取节点的名称。这里还是以上面的文本为例,选取title节点,然后调用name属性就可以得到节点名称:

1
print(soup.title.name)

运行结果如下:

1
title

(2)获取属性

每个节点可能有多个属性,比如idclass等,选择这个节点元素后,可以调用attrs获取所有属性:

1
2
print(soup.p.attrs)
print(soup.p.attrs['name'])

运行结果如下:

1
2
{'class': ['title'], 'name': 'dromouse'}
dromouse

可以看到,attrs的返回结果是字典形式,它把选择的节点的所有属性和属性值组合成一个字典。接下来,如果要获取name属性,就相当于从字典中获取某个键值,只需要用中括号加属性名就可以了。比如,要获取name属性,就可以通过attrs['name']来得到。

其实这样有点烦琐,还有一种更简单的获取方式:可以不用写attrs,直接在节点元素后面加中括号,传入属性名就可以获取属性值了。样例如下:

1
2
print(soup.p['name'])
print(soup.p['class'])

运行结果如下:

1
2
dromouse
['title']

这里需要注意的是,有的返回结果是字符串,有的返回结果是字符串组成的列表。比如,name属性的值是唯一的,返回的结果就是单个字符串。而对于class,一个节点元素可能有多个class,所以返回的是列表。在实际处理过程中,我们要注意判断类型。

(3)获取内容

可以利用string属性获取节点元素包含的文本内容,比如要获取第一个p节点的文本:

1
print(soup.p.string)

运行结果如下:

1
The Dormouse's story

再次注意一下,这里选择到的p节点是第一个p节点,获取的文本也是第一个p节点里面的文本。

嵌套选择

在上面的例子中,我们知道每一个返回结果都是bs4.element.Tag类型,它同样可以继续调用节点进行下一步的选择。比如,我们获取了head节点元素,我们可以继续调用head来选取其内部的head节点元素:

1
2
3
4
5
6
7
8
9
html = """
<html><head><title>The Dormouse's story</title></head>
<body>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.head.title)
print(type(soup.head.title))
print(soup.head.title.string)

运行结果如下:

1
2
3
<title>The Dormouse's story</title>
<class 'bs4.element.Tag'>
The Dormouse's story

第一行结果是调用head之后再次调用title而选择的title节点元素。然后打印输出了它的类型,可以看到,它仍然是bs4.element.Tag类型。也就是说,我们在Tag类型的基础上再次选择得到的依然还是Tag类型,每次返回的结果都相同,所以这样就可以做嵌套选择了。

最后,输出它的string属性,也就是节点里的文本内容。

关联选择

在做选择的时候,有时候不能做到一步就选到想要的节点元素,需要先选中某一个节点元素,然后以它为基准再选择它的子节点、父节点、兄弟节点等,这里就来介绍如何选择这些节点元素。

(1)子节点和子孙节点

选取节点元素之后,如果想要获取它的直接子节点,可以调用contents属性,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
html = """
<html>
<head>
<title>The Dormouse's story</title>
</head>
<body>
<p class="story">
Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">
<span>Elsie</span>
</a>
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a>
and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>
and they lived at the bottom of a well.
</p>
<p class="story">...</p>
"""

运行结果如下:

1
2
3
['\n            Once upon a time there were three little sisters; and their names were\n            ', <a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>, '\n', <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>, ' \n and\n ', <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>, '\n and they lived at the bottom of a well.\n ']

可以看到,返回结果是列表形式。p节点里既包含文本,又包含节点,最后会将它们以列表形式统一返回。

需要注意的是,列表中的每个元素都是p节点的直接子节点。比如第一个a节点里面包含一层span节点,这相当于孙子节点了,但是返回结果并没有单独把span节点选出来。所以说,contents属性得到的结果是直接子节点的列表。

同样,我们可以调用children属性得到相应的结果:

1
2
3
4
5
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.p.children)
for i, child in enumerate(soup.p.children):
print(i, child)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<list_iterator object at 0x1064f7dd8>
0
Once upon a time there were three little sisters; and their names were

1 <a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
2

3 <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
4
and

5 <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>
6
and they lived at the bottom of a well.

还是同样的HTML文本,这里调用了children属性来选择,返回结果是生成器类型。接下来,我们用for循环输出相应的内容。

如果要得到所有的子孙节点的话,可以调用descendants属性:

1
2
3
4
5
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.p.descendants)
for i, child in enumerate(soup.p.descendants):
print(i, child)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<generator object descendants at 0x10650e678>
0
Once upon a time there were three little sisters; and their names were

1 <a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
2

3 <span>Elsie</span>
4 Elsie
5

6

7 <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
8 Lacie
9
and

10 <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>
11 Tillie
12
and they lived at the bottom of a well.

此时返回结果还是生成器。遍历输出一下可以看到,这次的输出结果就包含了span节点。descendants会递归查询所有子节点,得到所有的子孙节点。

(2)父节点和祖先节点

如果要获取某个节点元素的父节点,可以调用parent属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
html = """
<html>
<head>
<title>The Dormouse's story</title>
</head>
<body>
<p class="story">
Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">
<span>Elsie</span>
</a>
</p>
<p class="story">...</p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.a.parent)

运行结果如下:

1
2
3
4
5
6
<p class="story">
Once upon a time there were three little sisters; and their names were
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>

这里我们选择的是第一个a节点的父节点元素。很明显,它的父节点是p节点,输出结果便是p节点及其内部的内容。

需要注意的是,这里输出的仅仅是a节点的直接父节点,而没有再向外寻找父节点的祖先节点。如果想获取所有的祖先节点,可以调用parents属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
html = """
<html>
<body>
<p class="story">
<a href="http://example.com/elsie" class="sister" id="link1">
<span>Elsie</span>
</a>
</p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(type(soup.a.parents))
print(list(enumerate(soup.a.parents)))

运行结果如下:

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
<class 'generator'>
[(0, <p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>), (1, <body>
<p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>
</body>), (2, <html>
<body>
<p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>
</body></html>), (3, <html>
<body>
<p class="story">
<a class="sister" href="http://example.com/elsie" id="link1">
<span>Elsie</span>
</a>
</p>
</body></html>)]

可以发现,返回结果是生成器类型。这里用列表输出了它的索引和内容,而列表中的元素就是a节点的祖先节点。

(3)兄弟节点

上面说明了子节点和父节点的获取方式,如果要获取同级的节点(也就是兄弟节点),应该怎么办呢?示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
html = """
<html>
<body>
<p class="story">
Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">
<span>Elsie</span>
</a>
Hello
<a href="http://example.com/lacie" class="sister" id="link2">Lacie</a>
and
<a href="http://example.com/tillie" class="sister" id="link3">Tillie</a>
and they lived at the bottom of a well.
</p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print('Next Sibling', soup.a.next_sibling)
print('Prev Sibling', soup.a.previous_sibling)
print('Next Siblings', list(enumerate(soup.a.next_siblings)))
print('Prev Siblings', list(enumerate(soup.a.previous_siblings)))

运行结果如下:

1
2
3
4
5
6
7
8
Next Sibling 
Hello

Prev Sibling
Once upon a time there were three little sisters; and their names were

Next Siblings [(0, '\n Hello\n '), (1, <a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>), (2, ' \n and\n '), (3, <a class="sister" href="http://example.com/tillie" id="link3">Tillie</a>), (4, '\n and they lived at the bottom of a well.\n ')]
Prev Siblings [(0, '\n Once upon a time there were three little sisters; and their names were\n ')]

可以看到,这里调用了4个属性,其中next_siblingprevious_sibling分别获取节点的下一个和上一个兄弟元素,next_siblingsprevious_siblings则分别返回所有前面和后面的兄弟节点的生成器。

(4)提取信息

前面讲解了关联元素节点的选择方法,如果想要获取它们的一些信息,比如文本、属性等,也用同样的方法,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
html = """
<html>
<body>
<p class="story">
Once upon a time there were three little sisters; and their names were
<a href="http://example.com/elsie" class="sister" id="link1">Bob</a><a href="http://example.com/lacie" class="sister" id="link2">Lacie</a>
</p>
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print('Next Sibling:')
print(type(soup.a.next_sibling))
print(soup.a.next_sibling)
print(soup.a.next_sibling.string)
print('Parent:')
print(type(soup.a.parents))
print(list(soup.a.parents)[0])
print(list(soup.a.parents)[0].attrs['class'])

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
Next Sibling:
<class 'bs4.element.Tag'>
<a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
Lacie
Parent:
<class 'generator'>
<p class="story">
Once upon a time there were three little sisters; and their names were
<a class="sister" href="http://example.com/elsie" id="link1">Bob</a><a class="sister" href="http://example.com/lacie" id="link2">Lacie</a>
</p>
['story']

如果返回结果是单个节点,那么可以直接调用stringattrs等属性获得其文本和属性;如果返回结果是多个节点的生成器,则可以转为列表后取出某个元素,然后再调用stringattrs等属性获取其对应节点的文本和属性。

6. 方法选择器

前面所讲的选择方法都是通过属性来选择的,这种方法非常快,但是如果进行比较复杂的选择的话,它就比较烦琐,不够灵活了。幸好,Beautiful Soup还为我们提供了一些查询方法,比如find_all()find()等,调用它们,然后传入相应的参数,就可以灵活查询了。

find_all()

find_all,顾名思义,就是查询所有符合条件的元素。给它传入一些属性或文本,就可以得到符合条件的元素,它的功能十分强大。

它的API如下:

1
find_all(name , attrs , recursive , text , **kwargs)

(1)name

我们可以根据节点名来查询元素,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
html='''
<div class="panel">
<div class="panel-heading">
<h4>Hello</h4>
</div>
<div class="panel-body">
<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>
<ul class="list list-small" id="list-2">
<li class="element">Foo</li>
<li class="element">Bar</li>
</ul>
</div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(name='ul'))
print(type(soup.find_all(name='ul')[0]))

运行结果如下:

1
2
3
4
5
6
7
8
9
[<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>, <ul class="list list-small" id="list-2">
<li class="element">Foo</li>
<li class="element">Bar</li>
</ul>]
<class 'bs4.element.Tag'>

这里我们调用了find_all()方法,传入name参数,其参数值为ul。也就是说,我们想要查询所有ul节点,返回结果是列表类型,长度为2,每个元素依然都是bs4.element.Tag类型。

因为都是Tag类型,所以依然可以进行嵌套查询。还是同样的文本,这里查询出所有ul节点后,再继续查询其内部的li节点:

1
2
for ul in soup.find_all(name='ul'):
print(ul.find_all(name='li'))

运行结果如下:

1
2
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>]
[<li class="element">Foo</li>, <li class="element">Bar</li>]

返回结果是列表类型,列表中的每个元素依然还是Tag类型。

接下来,就可以遍历每个li,获取它的文本了:

1
2
3
4
for ul in soup.find_all(name='ul'):
print(ul.find_all(name='li'))
for li in ul.find_all(name='li'):
print(li.string)

运行结果如下:

1
2
3
4
5
6
7
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>]
Foo
Bar
Jay
[<li class="element">Foo</li>, <li class="element">Bar</li>]
Foo
Bar

(2)attrs

除了根据节点名查询,我们也可以传入一些属性来查询,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
html='''
<div class="panel">
<div class="panel-heading">
<h4>Hello</h4>
</div>
<div class="panel-body">
<ul class="list" id="list-1" name="elements">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>
<ul class="list list-small" id="list-2">
<li class="element">Foo</li>
<li class="element">Bar</li>
</ul>
</div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(attrs={'id': 'list-1'}))
print(soup.find_all(attrs={'name': 'elements'}))

运行结果如下:

1
2
3
4
5
6
7
8
9
10
[<ul class="list" id="list-1" name="elements">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>]
[<ul class="list" id="list-1" name="elements">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>]

这里查询的时候传入的是attrs参数,参数的类型是字典类型。比如,要查询idlist-1的节点,可以传入attrs={'id': 'list-1'}的查询条件,得到的结果是列表形式,包含的内容就是符合idlist-1的所有节点。在上面的例子中,符合条件的元素个数是1,所以结果是长度为1的列表。

对于一些常用的属性,比如idclass等,我们可以不用attrs来传递。比如,要查询idlist-1的节点,可以直接传入id这个参数。还是上面的文本,我们换一种方式来查询:

1
2
3
4
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(id='list-1'))
print(soup.find_all(class_='element'))

运行结果如下:

1
2
3
4
5
6
[<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>]
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>, <li class="element">Foo</li>, <li class="element">Bar</li>]

这里直接传入id='list-1',就可以查询idlist-1的节点元素了。而对于class来说,由于class在Python里是一个关键字,所以后面需要加一个下划线,即class_='element',返回的结果依然还是Tag组成的列表。

(3)text

text参数可用来匹配节点的文本,传入的形式可以是字符串,可以是正则表达式对象,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
import re
html='''
<div class="panel">
<div class="panel-body">
<a>Hello, this is a link</a>
<a>Hello, this is a link, too</a>
</div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find_all(text=re.compile('link')))

运行结果如下:

1
['Hello, this is a link', 'Hello, this is a link, too']

这里有两个a节点,其内部包含文本信息。这里在find_all()方法中传入text参数,该参数为正则表达式对象,结果返回所有匹配正则表达式的节点文本组成的列表。

find()

除了find_all()方法,还有find()方法,只不过后者返回的是单个元素,也就是第一个匹配的元素,而前者返回的是所有匹配的元素组成的列表。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
html='''
<div class="panel">
<div class="panel-heading">
<h4>Hello</h4>
</div>
<div class="panel-body">
<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>
<ul class="list list-small" id="list-2">
<li class="element">Foo</li>
<li class="element">Bar</li>
</ul>
</div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.find(name='ul'))
print(type(soup.find(name='ul')))
print(soup.find(class_='list'))

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>
<class 'bs4.element.Tag'>
<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>

这里的返回结果不再是列表形式,而是第一个匹配的节点元素,类型依然是Tag类型。

另外,还有许多查询方法,其用法与前面介绍的find_all()find()方法完全相同,只不过查询范围不同,这里简单说明一下。

  • find_parents()find_parent():前者返回所有祖先节点,后者返回直接父节点。
  • find_next_siblings()find_next_sibling():前者返回后面所有的兄弟节点,后者返回后面第一个兄弟节点。
  • find_previous_siblings()find_previous_sibling():前者返回前面所有的兄弟节点,后者返回前面第一个兄弟节点。
  • find_all_next()find_next():前者返回节点后所有符合条件的节点,后者返回第一个符合条件的节点。
  • find_all_previous()find_previous():前者返回节点后所有符合条件的节点,后者返回第一个符合条件的节点。

7. CSS选择器

Beautiful Soup还提供了另外一种选择器,那就是CSS选择器。如果对Web开发熟悉的话,那么对CSS选择器肯定也不陌生。如果不熟悉的话,可以参考http://www.w3school.com.cn/cssref/css_selectors.asp了解。

使用CSS选择器时,只需要调用select()方法,传入相应的CSS选择器即可,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
html='''
<div class="panel">
<div class="panel-heading">
<h4>Hello</h4>
</div>
<div class="panel-body">
<ul class="list" id="list-1">
<li class="element">Foo</li>
<li class="element">Bar</li>
<li class="element">Jay</li>
</ul>
<ul class="list list-small" id="list-2">
<li class="element">Foo</li>
<li class="element">Bar</li>
</ul>
</div>
</div>
'''
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
print(soup.select('.panel .panel-heading'))
print(soup.select('ul li'))
print(soup.select('#list-2 .element'))
print(type(soup.select('ul')[0]))

运行结果如下:

1
2
3
4
5
6
[<div class="panel-heading">
<h4>Hello</h4>
</div>]
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>, <li class="element">Foo</li>, <li class="element">Bar</li>]
[<li class="element">Foo</li>, <li class="element">Bar</li>]
<class 'bs4.element.Tag'>

这里我们用了3次CSS选择器,返回的结果均是符合CSS选择器的节点组成的列表。例如,select('ul li')则是选择所有ul节点下面的所有li节点,结果便是所有的li节点组成的列表。

最后一句打印输出了列表中元素的类型。可以看到,类型依然是Tag类型。

嵌套选择

select()方法同样支持嵌套选择。例如,先选择所有ul节点,再遍历每个ul节点,选择其li节点,样例如下:

1
2
3
4
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
for ul in soup.select('ul'):
print(ul.select('li'))

运行结果如下:

1
2
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>]
[<li class="element">Foo</li>, <li class="element">Bar</li>]

可以看到,这里正常输出了所有ul节点下所有li节点组成的列表。

获取属性

我们知道节点类型是Tag类型,所以获取属性还可以用原来的方法。仍然是上面的HTML文本,这里尝试获取每个ul节点的id属性:

1
2
3
4
5
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
for ul in soup.select('ul'):
print(ul['id'])
print(ul.attrs['id'])

运行结果如下:

1
2
3
4
list-1
list-1
list-2
list-2

可以看到,直接传入中括号和属性名,以及通过attrs属性获取属性值,都可以成功。

获取文本

要获取文本,当然也可以用前面所讲的string属性。此外,还有一个方法,那就是get_text(),示例如下:

1
2
3
4
5
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, 'lxml')
for li in soup.select('li'):
print('Get Text:', li.get_text())
print('String:', li.string)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
Get Text: Foo
String: Foo
Get Text: Bar
String: Bar
Get Text: Jay
String: Jay
Get Text: Foo
String: Foo
Get Text: Bar
String: Bar

可以看到,二者的效果完全一致。

到此,Beautiful Soup的用法基本就介绍完了,最后做一下简单的总结。

  • 推荐使用lxml解析库,必要时使用html.parser。
  • 节点选择筛选功能弱但是速度快。
  • 建议使用find()或者find_all()查询匹配单个结果或者多个结果。
  • 如果对CSS选择器熟悉的话,可以使用select()方法选择。

Python

XPath,全称XML Path Language,即XML路径语言,它是一门在XML文档中查找信息的语言。它最初是用来搜寻XML文档的,但是它同样适用于HTML文档的搜索。

所以在做爬虫时,我们完全可以使用XPath来做相应的信息抽取。本节中,我们就来介绍XPath的基本用法。

1. XPath概览

XPath的选择功能十分强大,它提供了非常简洁明了的路径选择表达式。另外,它还提供了超过100个内建函数,用于字符串、数值、时间的匹配以及节点、序列的处理等。几乎所有我们想要定位的节点,都可以用XPath来选择。

XPath于1999年11月16日成为W3C标准,它被设计为供XSLT、XPointer以及其他XML解析软件使用,更多的文档可以访问其官方网站:https://www.w3.org/TR/xpath/

2. XPath常用规则

表4-1列举了XPath的几个常用规则。

表4-1 XPath常用规则

表达式

描述

nodename

选取此节点的所有子节点

/

从当前节点选取直接子节点

//

从当前节点选取子孙节点

.

选取当前节点

..

选取当前节点的父节点

@

选取属性

这里列出了XPath的常用匹配规则,示例如下:

1
//title[@lang='eng']

这就是一个XPath规则,它代表选择所有名称为title,同时属性lang的值为eng的节点。

后面会通过Python的lxml库,利用XPath进行HTML的解析。

3. 准备工作

使用之前,首先要确保安装好lxml库,若没有安装,可以参考第1章的安装过程。

4. 实例引入

现在通过实例来感受一下使用XPath来对网页进行解析的过程,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from lxml import etree
text = '''
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
'''
html = etree.HTML(text)
result = etree.tostring(html)
print(result.decode('utf-8'))

这里首先导入lxml库的etree模块,然后声明了一段HTML文本,调用HTML类进行初始化,这样就成功构造了一个XPath解析对象。这里需要注意的是,HTML文本中的最后一个li节点是没有闭合的,但是etree模块可以自动修正HTML文本。

这里我们调用tostring()方法即可输出修正后的HTML代码,但是结果是bytes类型。这里利用decode()方法将其转成str类型,结果如下:

1
2
3
4
5
6
7
8
9
10
<html><body><div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</li></ul>
</div>
</body></html>

可以看到,经过处理之后,li节点标签被补全,并且还自动添加了bodyhtml节点。

另外,也可以直接读取文本文件进行解析,示例如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = etree.tostring(html)
print(result.decode('utf-8'))

其中test.html的内容就是上面例子中的HTML代码,内容如下:

1
2
3
4
5
6
7
8
9
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>

这次的输出结果略有不同,多了一个DOCTYPE的声明,不过对解析无任何影响,结果如下:

1
2
3
4
5
6
7
8
9
10
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html><body><div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</li></ul>
</div></body></html>

5. 所有节点

我们一般会用//开头的XPath规则来选取所有符合要求的节点。这里以前面的HTML文本为例,如果要选取所有节点,可以这样实现:

1
2
3
4
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//*')
print(result)

运行结果如下:

1
[<Element html at 0x10510d9c8>, <Element body at 0x10510da08>, <Element div at 0x10510da48>, <Element ul at 0x10510da88>, <Element li at 0x10510dac8>, <Element a at 0x10510db48>, <Element li at 0x10510db88>, <Element a at 0x10510dbc8>, <Element li at 0x10510dc08>, <Element a at 0x10510db08>, <Element li at 0x10510dc48>, <Element a at 0x10510dc88>, <Element li at 0x10510dcc8>, <Element a at 0x10510dd08>]

这里使用*代表匹配所有节点,也就是整个HTML文本中的所有节点都会被获取。可以看到,返回形式是一个列表,每个元素是Element类型,其后跟了节点的名称,如htmlbodydivullia等,所有节点都包含在列表中了。

当然,此处匹配也可以指定节点名称。如果想获取所有li节点,示例如下:

1
2
3
4
5
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li')
print(result)
print(result[0])

这里要选取所有li节点,可以使用//,然后直接加上节点名称即可,调用时直接使用xpath()方法即可。

运行结果:

1
2
[<Element li at 0x105849208>, <Element li at 0x105849248>, <Element li at 0x105849288>, <Element li at 0x1058492c8>, <Element li at 0x105849308>]
<Element li at 0x105849208>

这里可以看到提取结果是一个列表形式,其中每个元素都是一个 Element对象。如果要取出其中一个对象,可以直接用中括号加索引,如[0]

6. 子节点

我们通过///即可查找元素的子节点或子孙节点。假如现在想选择li节点的所有直接a子节点,可以这样实现:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li/a')
print(result)

这里通过追加/a即选择了所有li节点的所有直接a子节点。因为//li用于选中所有li节点,/a用于选中li节点的所有直接子节点a,二者组合在一起即获取所有li节点的所有直接a子节点。

运行结果如下:

1
[<Element a at 0x106ee8688>, <Element a at 0x106ee86c8>, <Element a at 0x106ee8708>, <Element a at 0x106ee8748>, <Element a at 0x106ee8788>]

此处的/用于选取直接子节点,如果要获取所有子孙节点,就可以使用//。例如,要获取ul节点下的所有子孙a节点,可以这样实现:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//ul//a')
print(result)

运行结果是相同的。

但是如果这里用//ul/a,就无法获取任何结果了。因为/用于获取直接子节点,而在ul节点下没有直接的a子节点,只有li节点,所以无法获取任何匹配结果,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//ul/a')
print(result)

运行结果如下:

1
[]

因此,这里我们要注意///的区别,其中/用于获取直接子节点,//用于获取子孙节点。

7. 父节点

我们知道通过连续的///可以查找子节点或子孙节点,那么假如我们知道了子节点,怎样来查找父节点呢?这可以用..来实现。

比如,现在首先选中href属性为link4.htmla节点,然后再获取其父节点,然后再获取其class属性,相关代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//a[@href="link4.html"]/../@class')
print(result)

运行结果如下:

1
['item-1']

检查一下结果发现,这正是我们获取的目标li节点的class

同时,我们也可以通过parent::来获取父节点,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//a[@href="link4.html"]/parent::*/@class')
print(result)

8. 属性匹配

在选取的时候,我们还可以用@符号进行属性过滤。比如,这里如果要选取classitem-1li节点,可以这样实现:

1
2
3
4
from lxml import etree
html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]')
print(result)

这里我们通过加入[@class="item-0"],限制了节点的class属性为item-0,而HTML文本中符合条件的li节点有两个,所以结果应该返回两个匹配到的元素。结果如下:

1
[<Element li at 0x10a399288>, <Element li at 0x10a3992c8>]

可见,匹配结果正是两个,至于是不是那正确的两个,后面再验证。

9. 文本获取

我们用XPath中的text()方法获取节点中的文本,接下来尝试获取前面li节点中的文本,相关代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]/text()')
print(result)

运行结果如下:

1
['\n     ']

奇怪的是,我们并没有获取到任何文本,只获取到了一个换行符,这是为什么呢?因为XPath中text()前面是/,而此处/的含义是选取直接子节点,很明显li的直接子节点都是a节点,文本都是在a节点内部的,所以这里匹配到的结果就是被修正的li节点内部的换行符,因为自动修正的li节点的尾标签换行了。

即选中的是这两个节点:

1
2
3
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</li>

其中一个节点因为自动修正,li节点的尾标签添加的时候换行了,所以提取文本得到的唯一结果就是li节点的尾标签和a节点的尾标签之间的换行符。

因此,如果想获取li节点内部的文本,就有两种方式,一种是先选取a节点再获取文本,另一种就是使用//。接下来,我们来看下二者的区别。

首先,选取到a节点再获取文本,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]/a/text()')
print(result)

运行结果如下:

1
['first item', 'fifth item']

可以看到,这里的返回值是两个,内容都是属性为item-0li节点的文本,这也印证了前面属性匹配的结果是正确的。

这里我们是逐层选取的,先选取了li节点,又利用/选取了其直接子节点a,然后再选取其文本,得到的结果恰好是符合我们预期的两个结果。

再来看下用另一种方式(即使用//)选取的结果,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li[@class="item-0"]//text()')
print(result)

运行结果如下:

1
['first item', 'fifth item', '\n     ']

不出所料,这里的返回结果是3个。可想而知,这里是选取所有子孙节点的文本,其中前两个就是li的子节点a节点内部的文本,另外一个就是最后一个li节点内部的文本,即换行符。

所以说,如果要想获取子孙节点内部的所有文本,可以直接用//text()的方式,这样可以保证获取到最全面的文本信息,但是可能会夹杂一些换行符等特殊字符。如果想获取某些特定子孙节点下的所有文本,可以先选取到特定的子孙节点,然后再调用text()方法获取其内部文本,这样可以保证获取的结果是整洁的。

10. 属性获取

我们知道用text()可以获取节点内部文本,那么节点属性该怎样获取呢?其实还是用@符号就可以。例如,我们想获取所有li节点下所有a节点的href属性,代码如下:

1
2
3
4
5
from lxml import etree

html = etree.parse('./test.html', etree.HTMLParser())
result = html.xpath('//li/a/@href')
print(result)

这里我们通过@href即可获取节点的href属性。注意,此处和属性匹配的方法不同,属性匹配是中括号加属性名和值来限定某个属性,如[@href="link1.html"],而此处的@href指的是获取节点的某个属性,二者需要做好区分。

运行结果如下:

1
['link1.html', 'link2.html', 'link3.html', 'link4.html', 'link5.html']

可以看到,我们成功获取了所有li节点下a节点的href属性,它们以列表形式返回。

11. 属性多值匹配

有时候,某些节点的某个属性可能有多个值,例如:

1
2
3
4
5
6
7
from lxml import etree
text = '''
<li class="li li-first"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[@class="li"]/a/text()')
print(result)

这里HTML文本中li节点的class属性有两个值lili-first,此时如果还想用之前的属性匹配获取,就无法匹配了,此时的运行结果如下:

1
[]

这时就需要用contains()函数了,代码可以改写如下:

1
2
3
4
5
6
7
from lxml import etree
text = '''
<li class="li li-first"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[contains(@class, "li")]/a/text()')
print(result)

这样通过contains()方法,第一个参数传入属性名称,第二个参数传入属性值,只要此属性包含所传入的属性值,就可以完成匹配了。

此时运行结果如下:

1
['first item']

此种方式在某个节点的某个属性有多个值时经常用到,如某个节点的class属性通常有多个。

12. 多属性匹配

另外,我们可能还遇到一种情况,那就是根据多个属性确定一个节点,这时就需要同时匹配多个属性。此时可以使用运算符and来连接,示例如下:

1
2
3
4
5
6
7
from lxml import etree
text = '''
<li class="li li-first" name="item"><a href="link.html">first item</a></li>
'''
html = etree.HTML(text)
result = html.xpath('//li[contains(@class, "li") and @name="item"]/a/text()')
print(result)

这里的li节点又增加了一个属性name。要确定这个节点,需要同时根据classname属性来选择,一个条件是class属性里面包含li字符串,另一个条件是name属性为item字符串,二者需要同时满足,需要用and操作符相连,相连之后置于中括号内进行条件筛选。运行结果如下:

1
['first item']

这里的and其实是XPath中的运算符。另外,还有很多运算符,如ormod等,在此总结为表4-2。

表4-2 运算符及其介绍

运算符

描述

实例

返回值

or

age=19 or age=20

如果age是19,则返回true。如果age是21,则返回false

and

age>19 and age<21

如果age是20,则返回true。如果age18,则返回false

mod

计算除法的余数

5 mod 2

1

|

计算两个节点集

//book | //cd

返回所有拥有bookcd元素的节点集

+

加法

6 + 4

10

\-

减法

6 - 4

2

*

乘法

6 * 4

24

div

除法

8 div 4

2

\=

等于

age=19

如果age是19,则返回true。如果age是20,则返回false

!=

不等于

age!=19

如果age是18,则返回true。如果age是19,则返回false

<

小于

age<19

如果age是18,则返回true。如果age是19,则返回false

<=

小于或等于

age<=19

如果age是19,则返回true。如果age是20,则返回false

\>

大于

age>19

如果age是20,则返回true。如果age是19,则返回false

\>=

大于或等于

age>=19

如果age是19,则返回true。如果age是18,则返回false

此表参考来源:http://www.w3school.com.cn/xpath/xpath_operators.asp

13. 按序选择

有时候,我们在选择的时候某些属性可能同时匹配了多个节点,但是只想要其中的某个节点,如第二个节点或者最后一个节点,这时该怎么办呢?

这时可以利用中括号传入索引的方法获取特定次序的节点,示例如下:

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

text = '''
<div>
<ul>
<li class="item-0"><a href="link1.html">first item</a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
'''
html = etree.HTML(text)
result = html.xpath('//li[1]/a/text()')
print(result)
result = html.xpath('//li[last()]/a/text()')
print(result)
result = html.xpath('//li[position()<3]/a/text()')
print(result)
result = html.xpath('//li[last()-2]/a/text()')
print(result)

第一次选择时,我们选取了第一个li节点,中括号中传入数字1即可。注意,这里和代码中不同,序号是以1开头的,不是以0开头。

第二次选择时,我们选取了最后一个li节点,中括号中传入last()即可,返回的便是最后一个li节点。

第三次选择时,我们选取了位置小于3的li节点,也就是位置序号为1和2的节点,得到的结果就是前两个li节点。

第四次选择时,我们选取了倒数第三个li节点,中括号中传入last()-2即可。因为last()是最后一个,所以last()-2就是倒数第三个。

运行结果如下:

1
2
3
4
['first item']
['fifth item']
['first item', 'second item']
['third item']

这里我们使用了last()position()等函数。在XPath中,提供了100多个函数,包括存取、数值、字符串、逻辑、节点、序列等处理功能,它们的具体作用可以参考:http://www.w3school.com.cn/xpath/xpath_functions.asp

14. 节点轴选择

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

text = '''
<div>
<ul>
<li class="item-0"><a href="link1.html"><span>first item</span></a></li>
<li class="item-1"><a href="link2.html">second item</a></li>
<li class="item-inactive"><a href="link3.html">third item</a></li>
<li class="item-1"><a href="link4.html">fourth item</a></li>
<li class="item-0"><a href="link5.html">fifth item</a>
</ul>
</div>
'''
html = etree.HTML(text)
result = html.xpath('//li[1]/ancestor::*')
print(result)
result = html.xpath('//li[1]/ancestor::div')
print(result)
result = html.xpath('//li[1]/attribute::*')
print(result)
result = html.xpath('//li[1]/child::a[@href="link1.html"]')
print(result)
result = html.xpath('//li[1]/descendant::span')
print(result)
result = html.xpath('//li[1]/following::*[2]')
print(result)
result = html.xpath('//li[1]/following-sibling::*') `print(result)`

运行结果如下:

1
2
3
4
5
6
7
[<Element html at 0x107941808>, <Element body at 0x1079418c8>, <Element div at 0x107941908>, <Element ul at 0x107941948>]
[<Element div at 0x107941908>]
['item-0']
[<Element a at 0x1079418c8>]
[<Element span at 0x107941948>]
[<Element a at 0x1079418c8>]
[<Element li at 0x107941948>, <Element li at 0x107941988>, <Element li at 0x1079419c8>, <Element li at 0x107941a08>]

第一次选择时,我们调用了ancestor轴,可以获取所有祖先节点。其后需要跟两个冒号,然后是节点的选择器,这里我们直接使用*,表示匹配所有节点,因此返回结果是第一个li节点的所有祖先节点,包括htmlbodydivul

第二次选择时,我们又加了限定条件,这次在冒号后面加了div,这样得到的结果就只有div这个祖先节点了。

第三次选择时,我们调用了attribute轴,可以获取所有属性值,其后跟的选择器还是*,这代表获取节点的所有属性,返回值就是li节点的所有属性值。

第四次选择时,我们调用了child轴,可以获取所有直接子节点。这里我们又加了限定条件,选取href属性为link1.htmla节点。

第五次选择时,我们调用了descendant轴,可以获取所有子孙节点。这里我们又加了限定条件获取span节点,所以返回的结果只包含span节点而不包含a节点。

第六次选择时,我们调用了following轴,可以获取当前节点之后的所有节点。这里我们虽然使用的是*匹配,但又加了索引选择,所以只获取了第二个后续节点。

第七次选择时,我们调用了following-sibling轴,可以获取当前节点之后的所有同级节点。这里我们使用*匹配,所以获取了所有后续同级节点。

以上是XPath轴的简单用法,更多轴的用法可以参考:http://www.w3school.com.cn/xpath/xpath_axes.asp

15. 结语

到现在为止,我们基本上把可能用到的XPath选择器介绍完了。XPath功能非常强大,内置函数非常多,熟练使用之后,可以大大提升HTML信息的提取效率。

如果想查询更多XPath的用法,可以查看:http://www.w3school.com.cn/xpath/index.asp

如果想查询更多Python lxml库的用法,可以查看http://lxml.de/

Python

上一章中,我们实现了一个最基本的爬虫,但提取页面信息时使用的是正则表达式,这还是比较烦琐,而且万一有地方写错了,可能导致匹配失败,所以使用正则表达式提取页面信息多多少少还是有些不方便。

对于网页的节点来说,它可以定义idclass或其他属性。而且节点之间还有层次关系,在网页中可以通过XPath或CSS选择器来定位一个或多个节点。那么,在页面解析时,利用XPath或CSS选择器来提取某个节点,然后再调用相应方法获取它的正文内容或者属性,不就可以提取我们想要的任意信息了吗?

在Python中,怎样实现这个操作呢?不用担心,这种解析库已经非常多,其中比较强大的库有lxml、Beautiful Soup、pyquery等,本章就来介绍这3个解析库的用法。有了它们,我们就不用再为正则表达式发愁,而且解析效率也会大大提高。

Python

本节中,我们利用 requests 库和正则表达式来抓取猫眼电影 TOP100 的相关内容。requests 比 urllib 使用更加方便,而且目前我们还没有系统学习 HTML 解析库,所以这里就选用正则表达式来作为解析工具。

1. 本节目标

本节中,我们要提取出猫眼电影 TOP100 的电影名称、时间、评分、图片等信息,提取的站点 URL 为http://maoyan.com/board/4,提取的结果会以文件形式保存下来。

2. 准备工作

在本节开始之前,请确保已经正确安装好了 requests 库。如果没有安装,可以参考第 1 章的安装说明。

3. 抓取分析

我们需要抓取的目标站点为http://maoyan.com/board/4,打开之后便可以查看到榜单信息,如图 3-11 所示。 图 3-11 榜单信息

排名第一的电影是霸王别姬,页面中显示的有效信息有影片名称、主演、上映时间、上映地区、评分、图片等信息。

将网页滚动到最下方,可以发现有分页的列表,直接点击第 2 页,观察页面的 URL 和内容发生了怎样的变化,如图 3-12 所示。

图 3-12 页面 URL 变化

可以发现页面的 URL 变成http://maoyan.com/board/4?offset=10,比之前的 URL 多了一个参数,那就是offset=10,而目前显示的结果是排行 11~20 名的电影,初步推断这是一个偏移量的参数。再点击下一页,发现页面的 URL 变成了http://maoyan.com/board/4?offset=20,参数offset变成了 20,而显示的结果是排行 21~30 的电影。

由此可以总结出规律,offset代表偏移量值,如果偏移量为n,则显示的电影序号就是n+1n+10,每页显示 10 个。所以,如果想获取 TOP100 电影,只需要分开请求 10 次,而 10 次的offset参数分别设置为 0、10、20、…90 即可,这样获取不同的页面之后,再用正则表达式提取出相关信息,就可以得到 TOP100 的所有电影信息了。

4. 抓取首页

接下来用代码实现这个过程。首先抓取第一页的内容。我们实现了get_one_page()方法,并给它传入url参数。然后将抓取的页面结果返回,再通过main()方法调用。初步代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import requests

def get_one_page(url):
response = requests.get(url)
if response.status_code == 200:
return response.text
return None

def main():
url = 'http://maoyan.com/board/4'
html = get_one_page(url)
print(html)

main()

这样运行之后,就可以成功获取首页的源代码了。获取源代码后,就需要解析页面,提取出我们想要的信息。

5. 正则提取

接下来,回到网页看一下页面的真实源码。在开发者模式下的 Network 监听组件中查看源代码,如图 3-13 所示。

图 3-13 源代码

注意,这里不要在 Elements 选项卡中直接查看源码,因为那里的源码可能经过 JavaScript 操作而与原始请求不同,而是需要从 Network 选项卡部分查看原始请求得到的源码。

查看其中一个条目的源代码,如图 3-14 所示。

图 3-14 源代码

可以看到,一部电影信息对应的源代码是一个dd节点,我们用正则表达式来提取这里面的一些电影信息。首先,需要提取它的排名信息。而它的排名信息是在classboard-indexi节点内,这里利用非贪婪匹配来提取i节点内的信息,正则表达式写为:

1
<dd>.*?board-index.*?>(.*?)</i>

随后需要提取电影的图片。可以看到,后面有a节点,其内部有两个img节点。经过检查后发现,第二个img节点的data-src属性是图片的链接。这里提取第二个img节点的data-src属性,正则表达式可以改写如下:

1
<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)"

再往后,需要提取电影的名称,它在后面的p节点内,classname。所以,可以用name做一个标志位,然后进一步提取到其内a节点的正文内容,此时正则表达式改写如下:

1
<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>

再提取主演、发布时间、评分等内容时,都是同样的原理。最后,正则表达式写为:

1
<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>.*?star.*?>(.*?)</p>.*?releasetime.*?>(.*?)</p>.*?integer.*?>(.*?)</i>.*?fraction.*?>(.*?)</i>.*?</dd>

这样一个正则表达式可以匹配一个电影的结果,里面匹配了 7 个信息。接下来,通过调用findall()方法提取出所有的内容。

接下来,我们再定义解析页面的方法parse_one_page(),主要是通过正则表达式来从结果中提取出我们想要的内容,实现代码如下:

1
2
3
4
5
6
def parse_one_page(html):
pattern = re.compile(
'<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>.*?star.*?>(.*?)</p>.*?releasetime.*?>(.*?)</p>.*?integer.*?>(.*?)</i>.*?fraction.*?>(.*?)</i>.*?</dd>',
re.S)
items = re.findall(pattern, html)
print(items)

这样就可以成功地将一页的 10 个电影信息都提取出来,这是一个列表形式,输出结果如下:

1
[('1', 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c', '霸王别姬', '\n                主演:张国荣,张丰毅,巩俐\n        ', '上映时间:1993-01-01(中国香港)', '9.', '6'), ('2', 'http://p0.meituan.net/movie/__40191813__4767047.jpg@160w_220h_1e_1c', '肖申克的救赎', '\n                主演:蒂姆·罗宾斯,摩根·弗里曼,鲍勃·冈顿\n        ', '上映时间:1994-10-14(美国)', '9.', '5'), ('3', 'http://p0.meituan.net/movie/fc9d78dd2ce84d20e53b6d1ae2eea4fb1515304.jpg@160w_220h_1e_1c', '这个杀手不太冷', '\n                主演:让·雷诺,加里·奥德曼,娜塔莉·波特曼\n        ', '上映时间:1994-09-14(法国)', '9.', '5'), ('4', 'http://p0.meituan.net/movie/23/6009725.jpg@160w_220h_1e_1c', '罗马假日', '\n                主演:格利高利·派克,奥黛丽·赫本,埃迪·艾伯特\n        ', '上映时间:1953-09-02(美国)', '9.', '1'), ('5', 'http://p0.meituan.net/movie/53/1541925.jpg@160w_220h_1e_1c', '阿甘正传', '\n                主演:汤姆·汉克斯,罗宾·怀特,加里·西尼斯\n        ', '上映时间:1994-07-06(美国)', '9.', '4'), ('6', 'http://p0.meituan.net/movie/11/324629.jpg@160w_220h_1e_1c', '泰坦尼克号', '\n                主演:莱昂纳多·迪卡普里奥,凯特·温丝莱特,比利·赞恩\n        ', '上映时间:1998-04-03', '9.', '5'), ('7', 'http://p0.meituan.net/movie/99/678407.jpg@160w_220h_1e_1c', '龙猫', '\n                主演:日高法子,坂本千夏,糸井重里\n        ', '上映时间:1988-04-16(日本)', '9.', '2'), ('8', 'http://p0.meituan.net/movie/92/8212889.jpg@160w_220h_1e_1c', '教父', '\n                主演:马龙·白兰度,阿尔·帕西诺,詹姆斯·凯恩\n        ', '上映时间:1972-03-24(美国)', '9.', '3'), ('9', 'http://p0.meituan.net/movie/62/109878.jpg@160w_220h_1e_1c', '唐伯虎点秋香', '\n                主演:周星驰,巩俐,郑佩佩\n        ', '上映时间:1993-07-01(中国香港)', '9.', '2'), ('10', 'http://p0.meituan.net/movie/9bf7d7b81001a9cf8adbac5a7cf7d766132425.jpg@160w_220h_1e_1c', '千与千寻', '\n                主演:柊瑠美,入野自由,夏木真理\n        ', '上映时间:2001-07-20(日本)', '9.', '3')]

但这样还不够,数据比较杂乱,我们再将匹配结果处理一下,遍历提取结果并生成字典,此时方法改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def parse_one_page(html):
pattern = re.compile(
'<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)".*?name.*?a.*?>(.*?)</a>.*?star.*?>(.*?)</p>.*?releasetime.*?>(.*?)</p>.*?integer.*?>(.*?)</i>.*?fraction.*?>(.*?)</i>.*?</dd>',
re.S)
items = re.findall(pattern, html)
for item in items:
yield {
'index': item[0],
'image': item[1],
'title': item[2].strip(),
'actor': item[3].strip()[3:] if len(item[3]) > 3 else '',
'time': item[4].strip()[5:] if len(item[4]) > 5 else '',
'score': item[5].strip() + item[6].strip()
}

这样就可以成功提取出电影的排名、图片、标题、演员、时间、评分等内容了,并把它赋值为一个个的字典,形成结构化数据。运行结果如下:

1
2
3
4
5
6
7
8
9
10
{'image': 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c', 'actor': '张国荣,张丰毅,巩俐', 'score': '9.6', 'index': '1', 'title': '霸王别姬', 'time': '1993-01-01(中国香港)'}
{'image': 'http://p0.meituan.net/movie/__40191813__4767047.jpg@160w_220h_1e_1c', 'actor': '蒂姆·罗宾斯,摩根·弗里曼,鲍勃·冈顿', 'score': '9.5', 'index': '2', 'title': '肖申克的救赎', 'time': '1994-10-14(美国)'}
{'image': 'http://p0.meituan.net/movie/fc9d78dd2ce84d20e53b6d1ae2eea4fb1515304.jpg@160w_220h_1e_1c', 'actor': '让·雷诺,加里·奥德曼,娜塔莉·波特曼', 'score': '9.5', 'index': '3', 'title': '这个杀手不太冷', 'time': '1994-09-14(法国)'}
{'image': 'http://p0.meituan.net/movie/23/6009725.jpg@160w_220h_1e_1c', 'actor': '格利高利·派克,奥黛丽·赫本,埃迪·艾伯特', 'score': '9.1', 'index': '4', 'title': '罗马假日', 'time': '1953-09-02(美国)'}
{'image': 'http://p0.meituan.net/movie/53/1541925.jpg@160w_220h_1e_1c', 'actor': '汤姆·汉克斯,罗宾·怀特,加里·西尼斯', 'score': '9.4', 'index': '5', 'title': '阿甘正传', 'time': '1994-07-06(美国)'}
{'image': 'http://p0.meituan.net/movie/11/324629.jpg@160w_220h_1e_1c', 'actor': '莱昂纳多·迪卡普里奥,凯特·温丝莱特,比利·赞恩', 'score': '9.5', 'index': '6', 'title': '泰坦尼克号', 'time': '1998-04-03'}
{'image': 'http://p0.meituan.net/movie/99/678407.jpg@160w_220h_1e_1c', 'actor': '日高法子,坂本千夏,糸井重里', 'score': '9.2', 'index': '7', 'title': '龙猫', 'time': '1988-04-16(日本)'}
{'image': 'http://p0.meituan.net/movie/92/8212889.jpg@160w_220h_1e_1c', 'actor': '马龙·白兰度,阿尔·帕西诺,詹姆斯·凯恩', 'score': '9.3', 'index': '8', 'title': '教父', 'time': '1972-03-24(美国)'}
{'image': 'http://p0.meituan.net/movie/62/109878.jpg@160w_220h_1e_1c', 'actor': '周星驰,巩俐,郑佩佩', 'score': '9.2', 'index': '9', 'title': '唐伯虎点秋香', 'time': '1993-07-01(中国香港)'}
{'image': 'http://p0.meituan.net/movie/9bf7d7b81001a9cf8adbac5a7cf7d766132425.jpg@160w_220h_1e_1c', 'actor': '柊瑠美,入野自由,夏木真理', 'score': '9.3', 'index': '10', 'title': '千与千寻', 'time': '2001-07-20(日本)'}

到此为止,我们就成功提取了单页的电影信息。

6. 写入文件

随后,我们将提取的结果写入文件,这里直接写入到一个文本文件中。这里通过 JSON 库的dumps()方法实现字典的序列化,并指定ensure_ascii参数为False,这样可以保证输出结果是中文形式而不是 Unicode 编码。代码如下:

1
2
3
4
def write_to_json(content):
with open('result.txt', 'a') as f:
print(type(json.dumps(content)))
f.write(json.dumps(content, ensure_ascii=False,).encode('utf-8'))

通过调用write_to_json()方法即可实现将字典写入到文本文件的过程,此处的content参数就是一部电影的提取结果,是一个字典。

7. 整合代码

最后,实现main()方法来调用前面实现的方法,将单页的电影结果写入到文件。相关代码如下:

1
2
3
4
5
def main():
url = 'http://maoyan.com/board/4'
html = get_one_page(url)
for item in parse_one_page(html):
write_to_json(item)

到此为止,我们就完成了单页电影的提取,也就是首页的 10 部电影可以成功提取并保存到文本文件中了。

8. 分页爬取

因为我们需要抓取的是 TOP100 的电影,所以还需要遍历一下,给这个链接传入offset参数,实现其他 90 部电影的爬取,此时添加如下调用即可:

1
2
3
if __name__ == '__main__':
for i in range(10):
main(offset=i * 10)

这里还需要将main()方法修改一下,接收一个offset值作为偏移量,然后构造 URL 进行爬取。实现代码如下:

1
2
3
4
5
6
def main(offset):
url = 'http://maoyan.com/board/4?offset=' + str(offset)
html = get_one_page(url)
for item in parse_one_page(html):
print(item)
write_to_file(item)

到此为止,我们的猫眼电影 TOP100 的爬虫就全部完成了,再稍微整理一下,完整的代码如下:

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
import json
import requests
from requests.exceptions import RequestException
import re
import time

def get_one_page(url):
try:
response = requests.get(url)
if response.status_code == 200:
return response.text
return None
except RequestException:
return None

def parse_one_page(html):
pattern = re.compile('<dd>.*?board-index.*?>(\d+)</i>.*?data-src="(.*?)".*?name"><a'
+ '.*?>(.*?)</a>.*?star">(.*?)</p>.*?releasetime">(.*?)</p>'
+ '.*?integer">(.*?)</i>.*?fraction">(.*?)</i>.*?</dd>', re.S)
items = re.findall(pattern, html)
for item in items:
yield {
'index': item[0],
'image': item[1],
'title': item[2],
'actor': item[3].strip()[3:],
'time': item[4].strip()[5:],
'score': item[5] + item[6]
}

def write_to_file(content):
with open('result.txt', 'a', encoding='utf-8') as f:
f.write(json.dumps(content, ensure_ascii=False) + '\n')

def main(offset):
url = 'http://maoyan.com/board/4?offset=' + str(offset)
html = get_one_page(url)
for item in parse_one_page(html):
print(item)
write_to_file(item)

if __name__ == '__main__':
for i in range(10):
main(offset=i * 10)
time.sleep(1)

现在猫眼多了反爬虫,如果速度过快,则会无响应,所以这里又增加了一个延时等待。

9. 运行结果

最后,我们运行一下代码,输出结果类似如下:

1
2
3
4
5
6
{'index': '1', 'image': 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c', 'title': '霸王别姬', 'actor': '张国荣,张丰毅,巩俐', 'time': '1993-01-01(中国香港)', 'score': '9.6'}
{'index': '2', 'image': 'http://p0.meituan.net/movie/__40191813__4767047.jpg@160w_220h_1e_1c', 'title': '肖申克的救赎', 'actor': '蒂姆·罗宾斯,摩根·弗里曼,鲍勃·冈顿', 'time': '1994-10-14(美国)', 'score': '9.5'}
...
{'index': '98', 'image': 'http://p0.meituan.net/movie/76/7073389.jpg@160w_220h_1e_1c', 'title': '东京物语', 'actor': '笠智众,原节子,杉村春子', 'time': '1953-11-03(日本)', 'score': '9.1'}
{'index': '99', 'image': 'http://p0.meituan.net/movie/52/3420293.jpg@160w_220h_1e_1c', 'title': '我爱你', 'actor': '宋在河,李彩恩,吉海延', 'time': '2011-02-17(韩国)', 'score': '9.0'}
{'index': '100', 'image': 'http://p1.meituan.net/movie/__44335138__8470779.jpg@160w_220h_1e_1c', 'title': '迁徙的鸟', 'actor': '雅克·贝汉,菲利普·拉波洛,Philippe Labro', 'time': '2001-12-12(法国)', 'score': '9.1'}

这里省略了中间的部分输出结果。可以看到,这样就成功地把 TOP100 的电影信息爬取下来了。

这时我们再看下文本文件,结果如图 3-15 所示。

图 3-15 运行结果

可以看到,电影信息也已全部保存到了文本文件中了,大功告成!

10. 本节代码

本节的代码地址为https://github.com/Python3WebSpider/MaoYan

本节中,我们通过爬取猫眼 TOP100 的电影信息练习了 requests 和正则表达式的用法。这是一个最基础的实例,希望大家可以通过这个实例对爬虫的实现有一个最基本的思路,也对这两个库的用法有更深一步的了解。

Python

本节中,我们看一下正则表达式的相关用法。正则表达式是处理字符串的强大工具,它有自己特定的语法结构,有了它,实现字符串的检索、替换、匹配验证都不在话下。

当然,对于爬虫来说,有了它,从 HTML 里提取想要的信息就非常方便了。

1. 实例引入

说了这么多,可能我们对它到底是个什么还是比较模糊,下面就用几个实例来看一下正则表达式的用法。

打开开源中国提供的正则表达式测试工具http://tool.oschina.net/regex/,输入待匹配的文本,然后选择常用的正则表达式,就可以得出相应的匹配结果了。例如,这里输入待匹配的文本如下:

1
Hello, my phone number is 010-86432100 and email is cqc@cuiqingcai.com, and my website is http://cuiqingcai.com.

这段字符串中包含了一个电话号码和一个电子邮件,接下来就尝试用正则表达式提取出来,如图 3-10 所示。

图 3-10 运行页面

在网页右侧选择“匹配 Email 地址”,就可以看到下方出现了文本中的 E-mail。如果选择“匹配网址 URL”,就可以看到下方出现了文本中的 URL。是不是非常神奇?

其实,这里就是用了正则表达式匹配,也就是用一定的规则将特定的文本提取出来。比如,电子邮件开头是一段字符串,然后是一个@符号,最后是某个域名,这是有特定的组成格式的。另外,对于 URL,开头是协议类型,然后是冒号加双斜线,最后是域名加路径。

对于 URL 来说,可以用下面的正则表达式匹配:

1
[a-zA-z]+://[^\s]*

用这个正则表达式去匹配一个字符串,如果这个字符串中包含类似 URL 的文本,那就会被提取出来。

这个正则表达式看上去是乱糟糟的一团,其实不然,这里面都是有特定的语法规则的。比如,a-z代表匹配任意的小写字母,\\s表示匹配任意的空白字符,*就代表匹配前面的字符任意多个,这一长串的正则表达式就是这么多匹配规则的组合。

写好正则表达式后,就可以拿它去一个长字符串里匹配查找了。不论这个字符串里面有什么,只要符合我们写的规则,统统可以找出来。对于网页来说,如果想找出网页源代码里有多少 URL,用匹配 URL 的正则表达式去匹配即可。

上面我们说了几个匹配规则,表 3-2 列出了常用的匹配规则。

表 3-2 常用的匹配规则

模式

描述

\\w

匹配字母、数字及下划线

\\W

匹配不是字母、数字及下划线的字符

\\s

匹配任意空白字符,等价于[\\t\\n\\r\\f]

\\S

匹配任意非空字符

\\d

匹配任意数字,等价于[0-9]

\\D

匹配任意非数字的字符

\\A

匹配字符串开头

\\Z

匹配字符串结尾,如果存在换行,只匹配到换行前的结束字符串

\\z

匹配字符串结尾,如果存在换行,同时还会匹配换行符

\\G

匹配最后匹配完成的位置

\\n

匹配一个换行符

\\t

匹配一个制表符

^

匹配一行字符串的开头

$

匹配一行字符串的结尾

.

匹配任意字符,除了换行符,当re.DOTALL标记被指定时,则可以匹配包括换行符的任意字符

[...]

用来表示一组字符,单独列出,比如[amk]匹配amk

[^...]

不在[]中的字符,比如[^abc]匹配除了abc之外的字符

*

匹配 0 个或多个表达式

+

匹配 1 个或多个表达式

?

匹配 0 个或 1 个前面的正则表达式定义的片段,非贪婪方式

{n}

精确匹配n个前面的表达式

{n, m}

匹配nm次由前面正则表达式定义的片段,贪婪方式

a|b

匹配ab

( )

匹配括号内的表达式,也表示一个组

看完了之后,可能有点晕晕的吧,不过不用担心,后面我们会详细讲解一些常见规则的用法。

其实正则表达式不是 Python 独有的,它也可以用在其他编程语言中。但是 Python 的 re 库提供了整个正则表达式的实现,利用这个库,可以在 Python 中使用正则表达式。在 Python 中写正则表达式几乎都用这个库,下面就来了解它的一些常用方法。

2. match()

这里首先介绍第一个常用的匹配方法——match(),向它传入要匹配的字符串以及正则表达式,就可以检测这个正则表达式是否匹配字符串。

match()方法会尝试从字符串的起始位置匹配正则表达式,如果匹配,就返回匹配成功的结果;如果不匹配,就返回None。示例如下:

1
2
3
4
5
6
7
8
import re

content = 'Hello 123 4567 World_This is a Regex Demo'
print(len(content))
result = re.match('^Hello\s\d\d\d\s\d{4}\s\w{10}', content)
print(result)
print(result.group())
print(result.span())

运行结果如下:

1
2
3
4
41
<_sre.SRE_Match object; span=(0, 25), match='Hello 123 4567 World_This'>
Hello 123 4567 World_This
(0, 25)

这里首先声明了一个字符串,其中包含英文字母、空白字符、数字等。接下来,我们写一个正则表达式:

1
^Hello\s\d\d\d\s\d{4}\s\w{10}

用它来匹配这个长字符串。开头的^是匹配字符串的开头,也就是以Hello开头;然后\\s匹配空白字符,用来匹配目标字符串的空格;\\d匹配数字,3 个\\d匹配 123;然后再写 1 个\\s匹配空格;后面还有4567,我们其实可以依然用 4 个\\d来匹配,但是这么写比较烦琐,所以后面可以跟{4}以代表匹配前面的规则 4 次,也就是匹配 4 个数字;然后后面再紧接 1 个空白字符,最后\\w{10}匹配 10 个字母及下划线。我们注意到,这里其实并没有把目标字符串匹配完,不过这样依然可以进行匹配,只不过匹配结果短一点而已。

而在match()方法中,第一个参数传入了正则表达式,第二个参数传入了要匹配的字符串。

打印输出结果,可以看到结果是SRE_Match对象,这证明成功匹配。该对象有两个方法:group()方法可以输出匹配到的内容,结果是Hello 123 4567 World_This,这恰好是正则表达式规则所匹配的内容;span()方法可以输出匹配的范围,结果是(0, 25),这就是匹配到的结果字符串在原字符串中的位置范围。

通过上面的例子,我们基本了解了如何在 Python 中使用正则表达式来匹配一段文字。

匹配目标

刚才我们用match()方法可以得到匹配到的字符串内容,但是如果想从字符串中提取一部分内容,该怎么办呢?就像最前面的实例一样,从一段文本中提取出邮件或电话号码等内容。

这里可以使用()括号将想提取的子字符串括起来。()实际上标记了一个子表达式的开始和结束位置,被标记的每个子表达式会依次对应每一个分组,调用group()方法传入分组的索引即可获取提取的结果。示例如下:

1
2
3
4
5
6
7
8
import re

content = 'Hello 1234567 World_This is a Regex Demo'
result = re.match('^Hello\s(\d+)\sWorld', content)
print(result)
print(result.group())
print(result.group(1))
print(result.span())

这里我们想把字符串中的1234567提取出来,此时可以将数字部分的正则表达式用()括起来,然后调用了group(1)获取匹配结果。

运行结果如下:

1
2
3
4
<_sre.SRE_Match object; span=(0, 19), match='Hello 1234567 World'>
Hello 1234567 World
1234567
(0, 19)

可以看到,我们成功得到了1234567。这里用的是group(1),它与group()有所不同,后者会输出完整的匹配结果,而前者会输出第一个被()包围的匹配结果。假如正则表达式后面还有()包括的内容,那么可以依次用group(2)group(3)等来获取。

通用匹配

刚才我们写的正则表达式其实比较复杂,出现空白字符我们就写\\s匹配,出现数字我们就用\\d匹配,这样的工作量非常大。其实完全没必要这么做,因为还有一个万能匹配可以用,那就是.*(点星)。其中.(点)可以匹配任意字符(除换行符),*(星)代表匹配前面的字符无限次,所以它们组合在一起就可以匹配任意字符了。有了它,我们就不用挨个字符地匹配了。

接着上面的例子,我们可以改写一下正则表达式:

1
2
3
4
5
6
7
import re

content = 'Hello 123 4567 World_This is a Regex Demo'
result = re.match('^Hello.*Demo$', content)
print(result)
print(result.group())
print(result.span())

这里我们将中间部分直接省略,全部用.*来代替,最后加一个结尾字符串就好了。运行结果如下:

1
2
3
<_sre.SRE_Match object; span=(0, 41), match='Hello 123 4567 World_This is a Regex Demo'>
Hello 123 4567 World_This is a Regex Demo
(0, 41)

可以看到,group()方法输出了匹配的全部字符串,也就是说我们写的正则表达式匹配到了目标字符串的全部内容;span()方法输出(0, 41),这是整个字符串的长度。

因此,我们可以使用.*简化正则表达式的书写。

贪婪与非贪婪

使用上面的通用匹配.*时,可能有时候匹配到的并不是我们想要的结果。看下面的例子:

1
2
3
4
5
6
import re

content = 'Hello 1234567 World_This is a Regex Demo'
result = re.match('^He.*(\d+).*Demo$', content)
print(result)
print(result.group(1))

这里我们依然想获取中间的数字,所以中间依然写的是(\\d+)。而数字两侧由于内容比较杂乱,所以想省略来写,都写成 .*。最后,组成^He.*(\\d+).*Demo$,看样子并没有什么问题。我们看下运行结果:

1
2
<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
7

奇怪的事情发生了,我们只得到了 7 这个数字,这是怎么回事呢?

这里就涉及一个贪婪匹配与非贪婪匹配的问题了。在贪婪匹配下,.*会匹配尽可能多的字符。正则表达式中.*后面是\\d+,也就是至少一个数字,并没有指定具体多少个数字,因此,.*就尽可能匹配多的字符,这里就把123456匹配了,给\\d+留下一个可满足条件的数字 7,最后得到的内容就只有数字 7 了。

但这很明显会给我们带来很大的不便。有时候,匹配结果会莫名其妙少了一部分内容。其实,这里只需要使用非贪婪匹配就好了。非贪婪匹配的写法是.*?,多了一个?,那么它可以达到怎样的效果?我们再用实例看一下:

1
2
3
4
5
6
import re

content = 'Hello 1234567 World_This is a Regex Demo'
result = re.match('^He.*?(\d+).*Demo$', content)
print(result)
print(result.group(1))

这里我们只是将第一个.*改成了.*?,转变为非贪婪匹配。结果如下:

1
2
<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'>
1234567

此时就可以成功获取1234567了。原因可想而知,贪婪匹配是尽可能匹配多的字符,非贪婪匹配就是尽可能匹配少的字符。当.*?匹配到Hello后面的空白字符时,再往后的字符就是数字了,而\\d+恰好可以匹配,那么这里.*?就不再进行匹配,交给\\d+去匹配后面的数字。所以这样.*?匹配了尽可能少的字符,\\d+的结果就是1234567了。

所以说,在做匹配的时候,字符串中间尽量使用非贪婪匹配,也就是用.*?来代替.*,以免出现匹配结果缺失的情况。

但这里需要注意,如果匹配的结果在字符串结尾,.*?就有可能匹配不到任何内容了,因为它会匹配尽可能少的字符。例如:

1
2
3
4
5
6
7
import re

content = 'http://weibo.com/comment/kEraCN'
result1 = re.match('http.*?comment/(.*?)', content)
result2 = re.match('http.*?comment/(.*)', content)
print('result1', result1.group(1))
print('result2', result2.group(1))

运行结果如下:

1
2
result1
result2 kEraCN

可以观察到,.*?没有匹配到任何结果,而.*则尽量匹配多的内容,成功得到了匹配结果。

修饰符

正则表达式可以包含一些可选标志修饰符来控制匹配的模式。修饰符被指定为一个可选的标志。我们用实例来看一下:

1
2
3
4
5
6
7
import re

content = '''Hello 1234567 World_This
is a Regex Demo
'''
result = re.match('^He.*?(\d+).*?Demo$', content)
print(result.group(1))

和上面的例子相仿,我们在字符串中加了换行符,正则表达式还是一样的,用来匹配其中的数字。看一下运行结果:

1
2
3
4
5
6
7
AttributeError Traceback (most recent call last)
<ipython-input-18-c7d232b39645> in <module>()
5 '''
6 result = re.match('^He.*?(\d+).*?Demo$', content)
----> 7 print(result.group(1))

AttributeError: 'NoneType' object has no attribute 'group'

运行直接报错,也就是说正则表达式没有匹配到这个字符串,返回结果为None,而我们又调用了group()方法导致AttributeError

那么,为什么加了一个换行符,就匹配不到了呢?这是因为\\.匹配的是除换行符之外的任意字符,当遇到换行符时,.*?就不能匹配了,所以导致匹配失败。这里只需加一个修饰符re.S,即可修正这个错误:

1
result = re.match('^He.*?(\d+).*?Demo$', content, re.S)

这个修饰符的作用是使.匹配包括换行符在内的所有字符。此时运行结果如下:

1
1234567

这个re.S在网页匹配中经常用到。因为 HTML 节点经常会有换行,加上它,就可以匹配节点与节点之间的换行了。

另外,还有一些修饰符,在必要的情况下也可以使用,如表 3-3 所示。

表 3-3 修饰符

修饰符

描述

re.I

使匹配对大小写不敏感

re.L

做本地化识别(locale-aware)匹配

re.M

多行匹配,影响^$

re.S

使.匹配包括换行在内的所有字符

re.U

根据 Unicode 字符集解析字符。这个标志影响\\w\\W\\b\\B

re.X

该标志通过给予你更灵活的格式以便你将正则表达式写得更易于理解

在网页匹配中,较为常用的有re.Sre.I

转义匹配

我们知道正则表达式定义了许多匹配模式,如.匹配除换行符以外的任意字符,但是如果目标字符串里面就包含.,那该怎么办呢?

这里就需要用到转义匹配了,示例如下:

1
2
3
4
5
import re

content = '(百度)www.baidu.com'
result = re.match('\(百度\)www\.baidu\.com', content)
print(result)

当遇到用于正则匹配模式的特殊字符时,在前面加反斜线转义一下即可。例如.就可以用\\.来匹配,运行结果如下:

1
<_sre.SRE_Match object; span=(0, 17), match='(百度)www.baidu.com'>

可以看到,这里成功匹配到了原字符串。

这些是写正则表达式常用的几个知识点,熟练掌握它们对后面写正则表达式匹配非常有帮助。

前面提到过,match()方法是从字符串的开头开始匹配的,一旦开头不匹配,那么整个匹配就失败了。我们看下面的例子:

1
2
3
4
5
import re

content = 'Extra stings Hello 1234567 World_This is a Regex Demo Extra stings'
result = re.match('Hello.*?(\d+).*?Demo', content)
print(result)

这里的字符串以Extra开头,但是正则表达式以Hello开头,整个正则表达式是字符串的一部分,但是这样匹配是失败的。运行结果如下:

1
None

因为match()方法在使用时需要考虑到开头的内容,这在做匹配时并不方便。它更适合用来检测某个字符串是否符合某个正则表达式的规则。

这里就有另外一个方法search(),它在匹配时会扫描整个字符串,然后返回第一个成功匹配的结果。也就是说,正则表达式可以是字符串的一部分,在匹配时,search()方法会依次扫描字符串,直到找到第一个符合规则的字符串,然后返回匹配内容,如果搜索完了还没有找到,就返回None

我们把上面代码中的match()方法修改成search(),再看下运行结果:

1
2
<_sre.SRE_Match object; span=(13, 53), match='Hello 1234567 World_This is a Regex Demo'>
1234567

这时就得到了匹配结果。

因此,为了匹配方便,我们可以尽量使用search()方法。

下面再用几个实例来看看search()方法的用法。

首先,这里有一段待匹配的 HTML 文本,接下来写几个正则表达式实例来实现相应信息的提取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
html = '''<div id="songs-list">
<h2 class="title">经典老歌</h2>
<p class="introduction">
经典老歌列表
</p>
<ul id="list" class="list-group">
<li data-view="2">一路上有你</li>
<li data-view="7">
<a href="/2.mp3" singer="任贤齐">沧海一声笑</a>
</li>
<li data-view="4" class="active">
<a href="/3.mp3" singer="齐秦">往事随风</a>
</li>
<li data-view="6"><a href="/4.mp3" singer="beyond">光辉岁月</a></li>
<li data-view="5"><a href="/5.mp3" singer="陈慧琳">记事本</a></li>
<li data-view="5">
<a href="/6.mp3" singer="邓丽君"><i class="fa fa-user"></i>但愿人长久</a>
</li>
</ul>
</div>'''

可以观察到,ul节点里有许多li节点,其中li节点中有的包含a节点,有的不包含a节点,a节点还有一些相应的属性——超链接和歌手名。

首先,我们尝试提取classactiveli节点内部的超链接包含的歌手名和歌名,此时需要提取第三个li节点下a节点的singer属性和文本。

此时正则表达式可以以li开头,然后寻找一个标志符active,中间的部分可以用.*?来匹配。接下来,要提取singer这个属性值,所以还需要写入singer="(.*?)",这里需要提取的部分用小括号括起来,以便用group()方法提取出来,它的两侧边界是双引号。然后还需要匹配a节点的文本,其中它的左边界是\>,右边界是</a>。然后目标内容依然用(.*?)来匹配,所以最后的正则表达式就变成了:

1
<li.*?active.*?singer="(.*?)">(.*?)</a>

然后再调用search()方法,它会搜索整个 HTML 文本,找到符合正则表达式的第一个内容返回。

另外,由于代码有换行,所以这里第三个参数需要传入re.S。整个匹配代码如下:

1
2
3
result = re.search('<li.*?active.*?singer="(.*?)">(.*?)</a>', html, re.S)
if result:
print(result.group(1), result.group(2))

由于需要获取的歌手和歌名都已经用小括号包围,所以可以用group()方法获取。

运行结果如下:

1
齐秦 往事随风

可以看到,这正是classactiveli节点内部的超链接包含的歌手名和歌名。

如果正则表达式不加active(也就是匹配不带classactive的节点内容),那会怎样呢?我们将正则表达式中的active去掉,代码改写如下:

1
2
3
result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html, re.S)
if result:
print(result.group(1), result.group(2))

由于 search()方法会返回第一个符合条件的匹配目标,这里结果就变了:

1
任贤齐 沧海一声笑

active标签去掉后,从字符串开头开始搜索,此时符合条件的节点就变成了第二个li节点,后面的就不再匹配,所以运行结果就变成第二个li节点中的内容。

注意,在上面的两次匹配中,search()方法的第三个参数都加了re.S,这使得.*?可以匹配换行,所以含有换行的li节点被匹配到了。如果我们将其去掉,结果会是什么?代码如下:

1
2
3
result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html)
if result:
print(result.group(1), result.group(2))

运行结果如下:

1
beyond 光辉岁月

可以看到,结果变成了第四个li节点的内容。这是因为第二个和第三个li节点都包含了换行符,去掉re.S之后,.*?已经不能匹配换行符,所以正则表达式不会匹配到第二个和第三个li节点,而第四个li节点中不包含换行符,所以成功匹配。

由于绝大部分的 HTML 文本都包含了换行符,所以尽量都需要加上re.S修饰符,以免出现匹配不到的问题。

4. findall()

前面我们介绍了search()方法的用法,它可以返回匹配正则表达式的第一个内容,但是如果想要获取匹配正则表达式的所有内容,那该怎么办呢?这时就要借助findall()方法了。该方法会搜索整个字符串,然后返回匹配正则表达式的所有内容。

还是上面的 HTML 文本,如果想获取所有a节点的超链接、歌手和歌名,就可以将search()方法换成findall()方法。如果有返回结果的话,就是列表类型,所以需要遍历一下来依次获取每组内容。代码如下:

1
2
3
4
5
6
results = re.findall('<li.*?href="(.*?)".*?singer="(.*?)">(.*?)</a>', html, re.S)
print(results)
print(type(results))
for result in results:
print(result)
print(result[0], result[1], result[2])

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
[('/2.mp3', '任贤齐', '沧海一声笑'), ('/3.mp3', '齐秦', '往事随风'), ('/4.mp3', 'beyond', '光辉岁月'), ('/5.mp3', '陈慧琳', '记事本'), ('/6.mp3', '邓丽君', '但愿人长久')]
<class 'list'>
('/2.mp3', '任贤齐', '沧海一声笑')
/2.mp3 任贤齐 沧海一声笑
('/3.mp3', '齐秦', '往事随风')
/3.mp3 齐秦 往事随风
('/4.mp3', 'beyond', '光辉岁月')
/4.mp3 beyond 光辉岁月
('/5.mp3', '陈慧琳', '记事本')
/5.mp3 陈慧琳 记事本
('/6.mp3', '邓丽君', '但愿人长久')
/6.mp3 邓丽君 但愿人长久

可以看到,返回的列表中的每个元素都是元组类型,我们用对应的索引依次取出即可。

如果只是获取第一个内容,可以用search()方法。当需要提取多个内容时,可以用findall()方法。

5. sub()

除了使用正则表达式提取信息外,有时候还需要借助它来修改文本。比如,想要把一串文本中的所有数字都去掉,如果只用字符串的replace()方法,那就太烦琐了,这时可以借助sub()方法。示例如下:

1
2
3
4
5
import re

content = '54aK54yr5oiR54ix5L2g'
content = re.sub('\d+', '', content)
print(content)

运行结果如下:

1
aKyroiRixLg

这里只需要给第一个参数传入\\d+来匹配所有的数字,第二个参数为替换成的字符串(如果去掉该参数的话,可以赋值为空),第三个参数是原字符串。

在上面的 HTML 文本中,如果想获取所有li节点的歌名,直接用正则表达式来提取可能比较烦琐。比如,可以写成这样子:

1
2
3
results = re.findall('<li.*?>\s*?(<a.*?>)?(\w+)(</a>)?\s*?</li>', html, re.S)
for result in results:
print(result[1])

运行结果如下:

1
2
3
4
5
6
一路上有你
沧海一声笑
往事随风
光辉岁月
记事本
但愿人长久

此时借助sub()方法就比较简单了。可以先用sub()方法将a节点去掉,只留下文本,然后再利用findall()提取就好了:

1
2
3
4
5
html = re.sub('<a.*?>|</a>', '', html)
print(html)
results = re.findall('<li.*?>(.*?)</li>', html, re.S)
for result in results:
print(result.strip())

运行结果如下:

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
<div id="songs-list">
<h2 class="title">经典老歌</h2>
<p class="introduction">
经典老歌列表
</p>
<ul id="list" class="list-group">
<li data-view="2">一路上有你</li>
<li data-view="7">
沧海一声笑
</li>
<li data-view="4" class="active">
往事随风
</li>
<li data-view="6">光辉岁月</li>
<li data-view="5">记事本</li>
<li data-view="5">
但愿人长久
</li>
</ul>
</div>
一路上有你
沧海一声笑
往事随风
光辉岁月
记事本
但愿人长久

可以看到,a节点经过sub()方法处理后就没有了,然后再通过findall()方法直接提取即可。可以看到,在适当的时候,借助sub()方法可以起到事半功倍的效果。

6. compile()

前面所讲的方法都是用来处理字符串的方法,最后再介绍一下compile()方法,这个方法可以将正则字符串编译成正则表达式对象,以便在后面的匹配中复用。示例代码如下:

1
2
3
4
5
6
7
8
9
10
import re

content1 = '2016-12-15 12:00'
content2 = '2016-12-17 12:55'
content3 = '2016-12-22 13:21'
pattern = re.compile('\d{2}:\d{2}')
result1 = re.sub(pattern, '', content1)
result2 = re.sub(pattern, '', content2)
result3 = re.sub(pattern, '', content3)
print(result1, result2, result3)

例如,这里有 3 个日期,我们想分别将 3 个日期中的时间去掉,这时可以借助sub()方法。该方法的第一个参数是正则表达式,但是这里没有必要重复写 3 个同样的正则表达式,此时可以借助compile()方法将正则表达式编译成一个正则表达式对象,以便复用。

运行结果如下:

1
2016-12-15  2016-12-17  2016-12-22

另外,compile()还可以传入修饰符,例如re.S等修饰符,这样在search()findall()等方法中就不需要额外传了。所以,compile()方法可以说是给正则表达式做了一层封装,以便我们更好地复用。

到此为止,正则表达式的基本用法就介绍完了,后面会通过具体的实例来讲解正则表达式的用法。

Python

在前一节中,我们了解了 requests 的基本用法,如基本的 GET、POST 请求以及Response对象。本节中,我们再来了解下 requests 的一些高级用法,如文件上传、cookie 设置、代理设置等。

1. 文件上传

我们知道 requests 可以模拟提交一些数据。假如有的网站需要上传文件,我们也可以用它来实现,这非常简单,示例如下:

1
2
3
4
5
import requests

files = {'file': open('favicon.ico', 'rb')}
r = requests.post("http://httpbin.org/post", files=files)
print(r.text)

在前一节中我们保存了一个文件 favicon.ico,这次用它来模拟文件上传的过程。需要注意的是,favicon.ico 需要和当前脚本在同一目录下。如果有其他文件,当然也可以使用其他文件来上传,更改下代码即可。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"args": {},
"data": "",
"files": {
"file": "data:application/octet-stream;base64,AAAAAA...="
},
"form": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Content-Length": "6665",
"Content-Type": "multipart/form-data; boundary=809f80b1a2974132b133ade1a8e8e058",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.10.0"
},
"json": null,
"origin": "60.207.237.16",
"url": "http://httpbin.org/post"
}

以上省略部分内容,这个网站会返回响应,里面包含files这个字段,而form字段是空的,这证明文件上传部分会单独有一个files字段来标识。

2. Cookies

前面我们使用 urllib 处理过 Cookies,写法比较复杂,而有了 requests,获取和设置 Cookies 只需一步即可完成。

我们先用一个实例看一下获取 Cookies 的过程:

1
2
3
4
5
6
import requests

r = requests.get("https://www.baidu.com")
print(r.cookies)
for key, value in r.cookies.items():
print(key + '=' + value)

运行结果如下:

1
2
3
<RequestsCookieJar[<Cookie BDORZ=27315 for .baidu.com/>, <Cookie __bsi=13533594356813414194_00_14_N_N_2_0303_C02F_N_N_N_0 for .www.baidu.com/>]>
BDORZ=27315
__bsi=13533594356813414194_00_14_N_N_2_0303_C02F_N_N_N_0

这里我们首先调用cookies属性即可成功得到 Cookies,可以发现它是RequestCookieJar类型。然后用items()方法将其转化为元组组成的列表,遍历输出每一个 Cookie 的名称和值,实现 Cookie 的遍历解析。

当然,我们也可以直接用 Cookie 来维持登录状态,下面以知乎为例来说明。首先登录知乎,将Headers中的Cookie内容复制下来,如图 3-6 所示。

图 3-6 Cookie

这里可以替换成你自己的Cookie,将其设置到Headers里面,然后发送请求,示例如下:

1
2
3
4
5
6
7
8
9
import requests

headers = {
'Cookie': 'q_c1=31653b264a074fc9a57816d1ea93ed8b|1474273938000|1474273938000; d_c0="AGDAs254kAqPTr6NW1U3XTLFzKhMPQ6H_nc=|1474273938"; __utmv=51854390.100-1|2=registration_date=20130902=1^3=entry_date=20130902=1;a_t="2.0AACAfbwdAAAXAAAAso0QWAAAgH28HQAAAGDAs254kAoXAAAAYQJVTQ4FCVgA360us8BAklzLYNEHUd6kmHtRQX5a6hiZxKCynnycerLQ3gIkoJLOCQ==";z_c0=Mi4wQUFDQWZid2RBQUFBWU1DemJuaVFDaGNBQUFCaEFsVk5EZ1VKV0FEZnJTNnp3RUNTWE10ZzBRZFIzcVNZZTFGQmZn|1474887858|64b4d4234a21de774c42c837fe0b672fdb5763b0',
'Host': 'www.zhihu.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36',
}
r = requests.get('https://www.zhihu.com', headers=headers)
print(r.text)

我们发现,结果中包含了登录后的结果,如图 3-7 所示,这证明登录成功。

图 3-7 运行结果

当然,你也可以通过cookies参数来设置,不过这样就需要构造RequestsCookieJar对象,而且需要分割一下cookies。这相对烦琐,不过效果是相同的,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests

cookies = 'q_c1=31653b264a074fc9a57816d1ea93ed8b|1474273938000|1474273938000; d_c0="AGDAs254kAqPTr6NW1U3XTLFzKhMPQ6H_nc=|1474273938"; __utmv=51854390.100-1|2=registration_date=20130902=1^3=entry_date=20130902=1;a_t="2.0AACAfbwdAAAXAAAAso0QWAAAgH28HQAAAGDAs254kAoXAAAAYQJVTQ4FCVgA360us8BAklzLYNEHUd6kmHtRQX5a6hiZxKCynnycerLQ3gIkoJLOCQ==";z_c0=Mi4wQUFDQWZid2RBQUFBWU1DemJuaVFDaGNBQUFCaEFsVk5EZ1VKV0FEZnJTNnp3RUNTWE10ZzBRZFIzcVNZZTFGQmZn|1474887858|64b4d4234a21de774c42c837fe0b672fdb5763b0'
jar = requests.cookies.RequestsCookieJar()
headers = {
'Host': 'www.zhihu.com',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36'
}
for cookie in cookies.split(';'):
key, value = cookie.split('=', 1)
jar.set(key, value)
r = requests.get("http://www.zhihu.com", cookies=jar, headers=headers)
print(r.text)

这里我们首先新建了一个RequestCookieJar对象,然后将复制下来的cookies利用split()方法分割,接着利用set()方法设置好每个 Cookie 的keyvalue,然后通过调用 requests 的get()方法并传递给cookies参数即可。当然,由于知乎本身的限制,headers参数也不能少,只不过不需要在原来的headers参数里面设置cookie字段了。

测试后,发现同样可以正常登录知乎。

3. 会话维持

在 requests 中,如果直接利用get()post()等方法的确可以做到模拟网页的请求,但是这实际上是相当于不同的会话,也就是说相当于你用了两个浏览器打开了不同的页面。

设想这样一个场景,第一个请求利用post()方法登录了某个网站,第二次想获取成功登录后的自己的个人信息,你又用了一次get()方法去请求个人信息页面。实际上,这相当于打开了两个浏览器,是两个完全不相关的会话,能成功获取个人信息吗?那当然不能。

有小伙伴可能说了,我在两次请求时设置一样的cookies不就行了?可以,但这样做起来显得很烦琐,我们有更简单的解决方法。

其实解决这个问题的主要方法就是维持同一个会话,也就是相当于打开一个新的浏览器选项卡而不是新开一个浏览器。但是我又不想每次设置cookies,那该怎么办呢?这时候就有了新的利器——Session对象。

利用它,我们可以方便地维护一个会话,而且不用担心cookies的问题,它会帮我们自动处理好。示例如下:

1
2
3
4
5
import requests

requests.get('http://httpbin.org/cookies/set/number/123456789')
r = requests.get('http://httpbin.org/cookies')
print(r.text)

这里我们请求了一个测试网址http://httpbin.org/cookies/set/number/123456789。请求这个网址时,可以设置一个 cookie,名称叫作 number,内容是 123456789,随后又请求了http://httpbin.org/cookies,此网址可以获取当前的 Cookies。

这样能成功获取到设置的 Cookies 吗?试试看。

运行结果如下:

1
2
3
{
"cookies": {}
}

这并不行。我们再用Session试试看:

1
2
3
4
5
6
import requests

s = requests.Session()
s.get('http://httpbin.org/cookies/set/number/123456789')
r = s.get('http://httpbin.org/cookies')
print(r.text)

再看下运行结果:

1
2
3
4
5
{
"cookies": {
"number": "123456789"
}
}

成功获取!这下能体会到同一个会话和不同会话的区别了吧!

所以,利用Session,可以做到模拟同一个会话而不用担心 Cookies 的问题。它通常用于模拟登录成功之后再进行下一步的操作。

Session在平常用得非常广泛,可以用于模拟在一个浏览器中打开同一站点的不同页面,后面会有专门的章节来讲解这部分内容。

4. SSL 证书验证

此外,requests 还提供了证书验证的功能。当发送 HTTP 请求的时候,它会检查 SSL 证书,我们可以使用verify参数控制是否检查此证书。其实如果不加verify参数的话,默认是True,会自动验证。

前面我们提到过,12306 的证书没有被官方 CA 机构信任,会出现证书验证错误的结果。我们现在访问它,都可以看到一个证书问题的页面,如图 3-8 所示。

图 3-8 错误页面

现在我们用 requests 来测试一下:

1
2
3
4
import requests

response = requests.get('https://www.12306.cn')
print(response.status_code)

运行结果如下:

1
requests.exceptions.SSLError: ("bad handshake: Error([('SSL routines', 'tls_process_server_certificate', 'certificate verify failed')],)",)

这里提示一个错误SSLError,表示证书验证错误。所以,如果请求一个 HTTPS 站点,但是证书验证错误的页面时,就会报这样的错误,那么如何避免这个错误呢?很简单,把verify参数设置为False即可。相关代码如下:

1
2
3
4
import requests

response = requests.get('https://www.12306.cn', verify=False)
print(response.status_code)

这样就会打印出请求成功的状态码:

1
2
3
/usr/local/lib/python3.6/site-packages/urllib3/connectionpool.py:852: InsecureRequestWarning: Unverified HTTPS request is being made. Adding certificate verification is strongly advised. See: https://urllib3.readthedocs.io/en/latest/advanced-usage.html#ssl-warnings
InsecureRequestWarning)
200

不过我们发现报了一个警告,它建议我们给它指定证书。我们可以通过设置忽略警告的方式来屏蔽这个警告:

1
2
3
4
5
6
import requests
from requests.packages import urllib3

urllib3.disable_warnings()
response = requests.get('https://www.12306.cn', verify=False)
print(response.status_code)

或者通过捕获警告到日志的方式忽略警告:

1
2
3
4
5
import logging
import requests
logging.captureWarnings(True)
response = requests.get('https://www.12306.cn', verify=False)
print(response.status_code)

当然,我们也可以指定一个本地证书用作客户端证书,这可以是单个文件(包含密钥和证书)或一个包含两个文件路径的元组:

1
2
3
4
import requests

response = requests.get('https://www.12306.cn', cert=('/path/server.crt', '/path/key'))
print(response.status_code)

当然,上面的代码是演示实例,我们需要有 crt 和 key 文件,并且指定它们的路径。注意,本地私有证书的key必须是解密状态,加密状态的key是不支持的。

5. 代理设置

对于某些网站,在测试的时候请求几次,能正常获取内容。但是一旦开始大规模爬取,对于大规模且频繁的请求,网站可能会弹出验证码,或者跳转到登录认证页面,更甚者可能会直接封禁客户端的 IP,导致一定时间段内无法访问。

那么,为了防止这种情况发生,我们需要设置代理来解决这个问题,这就需要用到proxies参数。可以用这样的方式设置:

1
2
3
4
5
6
7
8
import requests

proxies = {
"http": "http://10.10.1.10:3128",
"https": "http://10.10.1.10:1080",
}

requests.get("https://www.taobao.com", proxies=proxies)

当然,直接运行这个实例可能不行,因为这个代理可能是无效的,请换成自己的有效代理试验一下。

若代理需要使用 HTTP Basic Auth,可以使用类似 http://user:password@host:port 这样的语法来设置代理,示例如下:

1
2
3
4
5
6
import requests

proxies = {
"http": "http://user:password@10.10.1.10:3128/",
}
requests.get("https://www.taobao.com", proxies=proxies)

除了基本的 HTTP 代理外,requests 还支持 SOCKS 协议的代理。

首先,需要安装 socks 这个库:

1
pip3 install 'requests[socks]'

然后就可以使用 SOCKS 协议代理了,示例如下:

1
2
3
4
5
6
7
import requests

proxies = {
'http': 'socks5://user:password@host:port',
'https': 'socks5://user:password@host:port'
}
requests.get("https://www.taobao.com", proxies=proxies)

6. 超时设置

在本机网络状况不好或者服务器网络响应太慢甚至无响应时,我们可能会等待特别久的时间才可能收到响应,甚至到最后收不到响应而报错。为了防止服务器不能及时响应,应该设置一个超时时间,即超过了这个时间还没有得到响应,那就报错。这需要用到timeout参数。这个时间的计算是发出请求到服务器返回响应的时间。示例如下:

1
2
3
4
import requests

r = requests.get("https://www.taobao.com", timeout = 1)
print(r.status_code)

通过这样的方式,我们可以将超时时间设置为 1 秒,如果 1 秒内没有响应,那就抛出异常。

实际上,请求分为两个阶段,即连接(connect)和读取(read)。

上面设置的timeout将用作连接和读取这二者的timeout总和。

如果要分别指定,就可以传入一个元组:

1
r = requests.get('https://www.taobao.com', timeout=(5,11, 30))

如果想永久等待,可以直接将timeout设置为None,或者不设置直接留空,因为默认是None。这样的话,如果服务器还在运行,但是响应特别慢,那就慢慢等吧,它永远不会返回超时错误的。其用法如下:

1
r = requests.get('https://www.taobao.com', timeout=None)

或直接不加参数:

1
r = requests.get('https://www.taobao.com')

7. 身份认证

在访问网站时,我们可能会遇到这样的认证页面,如图 3-9 所示。

图 3-9 认证页面

此时可以使用 requests 自带的身份认证功能,示例如下:

1
2
3
4
5
import requests
from requests.auth import HTTPBasicAuth

r = requests.get('http://localhost:5000', auth=HTTPBasicAuth('username', 'password'))
print(r.status_code)

如果用户名和密码正确的话,请求时就会自动认证成功,会返回 200 状态码,如果认证失败,则返回 401 状态码。

当然,如果参数都传一个HTTPBasicAuth类,就显得有点烦琐了,所以 requests 提供了一个更简单的写法,可以直接传一个元组,它会默认使用HTTPBasicAuth这个类来认证。

所以上面的代码可以直接简写如下:

1
2
3
4
import requests

r = requests.get('http://localhost:5000', auth=('username', 'password'))
print(r.status_code)

此外,requests 还提供了其他认证方式,如 OAuth 认证,不过此时需要安装 oauth 包,安装命令如下:

1
pip3 install requests_oauthlib

使用 OAuth1 认证的方法如下:

1
2
3
4
5
6
7
import requests
from requests_oauthlib import OAuth1

url = 'https://api.twitter.com/1.1/account/verify_credentials.json'
auth = OAuth1('YOUR_APP_KEY', 'YOUR_APP_SECRET',
'USER_OAUTH_TOKEN', 'USER_OAUTH_TOKEN_SECRET')
requests.get(url, auth=auth)

更多详细的功能可以参考 requests_oauthlib 的官方文档https://requests-oauthlib.readthedocs.org/,在此不再赘述了。

8. Prepared Request

前面介绍 urllib 时,我们可以将请求表示为数据结构,其中各个参数都可以通过一个Request对象来表示。这在 requests 里同样可以做到,这个数据结构就叫 Prepared Request。我们用实例看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from requests import Request, Session

url = 'http://httpbin.org/post'
data = {
'name': 'germey'
}
headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36'
}
s = Session()
req = Request('POST', url, data=data, headers=headers)
prepped = s.prepare_request(req)
r = s.send(prepped)
print(r.text)

这里我们引入了Request,然后用urldataheaders参数构造了一个Request对象,这时需要再调用Sessionprepare_request()方法将其转换为一个 Prepared Request 对象,然后调用send()方法发送即可,运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"args": {},
"data": "",
"files": {},
"form": {
"name": "germey"
},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Connection": "close",
"Content-Length": "11",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.116 Safari/537.36"
},
"json": null,
"origin": "182.32.203.166",
"url": "http://httpbin.org/post"
}

可以看到,我们达到了同样的 POST 请求效果。

有了Request这个对象,就可以将请求当作独立的对象来看待,这样在进行队列调度时会非常方便。后面我们会用它来构造一个Request队列。

本节讲解了 requests 的一些高级用法,这些用法在后面实战部分会经常用到,需要熟练掌握。更多的用法可以参考 requests 的官方文档:http://docs.python-requests.org/

Python

1. 准备工作

在开始之前,请确保已经正确安装好了 requests 库。如果没有安装,可以参考 1.2.1 节安装。

2. 实例引入

urllib 库中的urlopen()方法实际上是以 GET 方式请求网页,而 requests 中相应的方法就是get()方法,是不是感觉表达更明确一些?下面通过实例来看一下:

1
2
3
4
5
6
7
8
import requests

r = requests.get('https://www.baidu.com/')
print(type(r))
print(r.status_code)
print(type(r.text))
print(r.text)
print(r.cookies)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<class 'requests.models.Response'>
200
<class 'str'>
<html>
<head>
<script>
location.replace(location.href.replace("https://","http://"));
</script>
</head>
<body>
<noscript><meta http-equiv="refresh" content="0;url=http://www.baidu.com/"></noscript>
</body>
</html>
<RequestsCookieJar[<Cookie BIDUPSID=992C3B26F4C4D09505C5E959D5FBC005 for .baidu.com/>, <Cookie PSTM=1472227535 for .baidu.com/>, <Cookie __bsi=15304754498609545148_00_40_N_N_2_0303_C02F_N_N_N_0 for .www.baidu.com/>, <Cookie BD_NOT_HTTPS=1 for www.baidu.com/>]>

这里我们调用get()方法实现与urlopen()相同的操作,得到一个Response对象,然后分别输出了Response的类型、状态码、响应体的类型、内容以及 Cookies。

通过运行结果可以发现,它的返回类型是requests.models.Response,响应体的类型是字符串str,Cookies 的类型是RequestsCookieJar

使用get()方法成功实现一个 GET 请求,这倒不算什么,更方便之处在于其他的请求类型依然可以用一句话来完成,示例如下:

1
2
3
4
5
r = requests.post('http://httpbin.org/post')
r = requests.put('http://httpbin.org/put')
r = requests.delete('http://httpbin.org/delete')
r = requests.head('http://httpbin.org/get')
r = requests.options('http://httpbin.org/get')

这里分别用post()put()delete()等方法实现了 POST、PUT、DELETE 等请求。是不是比 urllib 简单太多了?

其实这只是冰山一角,更多的还在后面。

3. GET 请求

HTTP 中最常见的请求之一就是 GET 请求,下面首先来详细了解一下利用 requests 构建 GET 请求的方法。

基本实例

首先,构建一个最简单的 GET 请求,请求的链接为http://httpbin.org/get,该网站会判断如果客户端发起的是 GET 请求的话,它返回相应的请求信息:

1
2
3
4
import requests

r = requests.get('http://httpbin.org/get')
print(r.text)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
{
"args": {},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.10.0"
},
"origin": "122.4.215.33",
"url": "http://httpbin.org/get"
}

可以发现,我们成功发起了 GET 请求,返回结果中包含请求头、URL、IP 等信息。

那么,对于 GET 请求,如果要附加额外的信息,一般怎样添加呢?比如现在想添加两个参数,其中namegermeyage是 22。要构造这个请求链接,是不是要直接写成:

1
r = requests.get('http://httpbin.org/get?name=germey&age=22')

这样也可以,但是是不是有点不人性化呢?一般情况下,这种信息数据会用字典来存储。那么,怎样来构造这个链接呢?

这同样很简单,利用params这个参数就好了,示例如下:

1
2
3
4
5
6
7
8
import requests

data = {
'name': 'germey',
'age': 22
}
r = requests.get("http://httpbin.org/get", params=data)
print(r.text)

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"args": {
"age": "22",
"name": "germey"
},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.10.0"
},
"origin": "122.4.215.33",
"url": "http://httpbin.org/get?age=22&name=germey"
}

通过运行结果可以判断,请求的链接自动被构造成了:http://httpbin.org/get?age=22&name=germey

另外,网页的返回类型实际上是str类型,但是它很特殊,是 JSON 格式的。所以,如果想直接解析返回结果,得到一个字典格式的话,可以直接调用json()方法。示例如下:

1
2
3
4
5
6
import requests

r = requests.get("http://httpbin.org/get")
print(type(r.text))
print(r.json())
print(type(r.json()))

运行结果如下:

1
2
3
<class 'str'>
{'headers': {'Accept-Encoding': 'gzip, deflate', 'Accept': '*/*', 'Host': 'httpbin.org', 'User-Agent': 'python-requests/2.10.0'}, 'url': 'http://httpbin.org/get', 'args': {}, 'origin': '182.33.248.131'}
<class 'dict'>

可以发现,调用json()方法,就可以将返回结果是 JSON 格式的字符串转化为字典。

但需要注意的书,如果返回结果不是 JSON 格式,便会出现解析错误,抛出json.decoder.JSONDecodeError异常。

抓取网页

上面的请求链接返回的是 JSON 形式的字符串,那么如果请求普通的网页,则肯定能获得相应的内容了。下面以“知乎”→“发现”页面为例来看一下:

1
2
3
4
5
6
7
8
9
10
import requests
import re

headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36'
}
r = requests.get("https://www.zhihu.com/explore", headers=headers)
pattern = re.compile('explore-feed.*?question_link.*?>(.*?)</a>', re.S)
titles = re.findall(pattern, r.text)
print(titles)

这里我们加入了headers信息,其中包含了User-Agent字段信息,也就是浏览器标识信息。如果不加这个,知乎会禁止抓取。

接下来我们用到了最基础的正则表达式来匹配出所有的问题内容。关于正则表达式的相关内容,我们会在 3.3 节中详细介绍,这里作为实例来配合讲解。

运行结果如下:

1
['\n为什么很多人喜欢提及「拉丁语系」这个词?\n', '\n在没有水的情况下水系宝可梦如何战斗?\n', '\n有哪些经验可以送给 Kindle 新人?\n', '\n谷歌的广告业务是如何赚钱的?\n', '\n程序员该学习什么,能在上学期间挣钱?\n', '\n有哪些原本只是一个小消息,但回看发现是个惊天大新闻的例子?\n', '\n如何评价今敏?\n', '\n源氏是怎么把那么长的刀从背后拔出来的?\n', '\n年轻时得了绝症或大病是怎样的感受?\n', '\n年轻时得了绝症或大病是怎样的感受?\n']

我们发现,这里成功提取出了所有的问题内容。

抓取二进制数据

在上面的例子中,我们抓取的是知乎的一个页面,实际上它返回的是一个 HTML 文档。如果想抓去图片、音频、视频等文件,应该怎么办呢?

图片、音频、视频这些文件本质上都是由二进制码组成的,由于有特定的保存格式和对应的解析方式,我们才可以看到这些形形色色的多媒体。所以,想要抓取它们,就要拿到它们的二进制码。

下面以 GitHub 的站点图标为例来看一下:

1
2
3
4
5
import requests

r = requests.get("https://github.com/favicon.ico")
print(r.text)
print(r.content)

这里抓取的内容是站点图标,也就是在浏览器每一个标签上显示的小图标,如图 3-3 所示。

图 3-3 站点图标

这里打印了Response对象的两个属性,一个是text,另一个是content

运行结果如图 3-4 所示,其中前两行是r.text的结果,最后一行是r.content的结果。

图 3-4 运行结果

可以注意到,前者出现了乱码,后者结果前带有一个b,这代表是bytes类型的数据。由于图片是二进制数据,所以前者在打印时转化为str类型,也就是图片直接转化为字符串,这理所当然会出现乱码。

接着,我们将刚才提取到的图片保存下来:

1
2
3
4
5
import requests

r = requests.get("https://github.com/favicon.ico")
with open('favicon.ico', 'wb') as f:
f.write(r.content)

这里用了open()方法,它的第一个参数是文件名称,第二个参数代表以二进制写的形式打开,可以向文件里写入二进制数据。

运行结束之后,可以发现在文件夹中出现了名为 favicon.ico 的图标,如图 3-5 所示。

图 3-5 图标

同样地,音频和视频文件也可以用这种方法获取。

添加headers

urllib.request一样,我们也可以通过headers参数来传递头信息。

比如,在上面“知乎”的例子中,如果不传递headers,就不能正常请求:

1
2
3
4
import requests

r = requests.get("https://www.zhihu.com/explore")
print(r.text)

运行结果如下:

1
2
3
<html><body><h1>500 Server Error</h1>
An internal server error occured.
</body></html>

但如果加上headers并加上User-Agent信息,那就没问题了:

1
2
3
4
5
6
7
import requests

headers = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36'
}
r = requests.get("https://www.zhihu.com/explore", headers=headers)
print(r.text)

当然,我们可以在headers这个参数中任意添加其他的字段信息。

4. POST 请求

前面我们了解了最基本的 GET 请求,另外一种比较常见的请求方式是 POST。使用requests实现 POST 请求同样非常简单,示例如下:

1
2
3
4
5
import requests

data = {'name': 'germey', 'age': '22'}
r = requests.post("http://httpbin.org/post", data=data)
print(r.text)

这里还是请求http://httpbin.org/post,该网站可以判断如果请求是 POST 方式,就把相关请求信息返回。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"args": {},
"data": "",
"files": {},
"form": {
"age": "22",
"name": "germey"
},
"headers": {
"Accept": "*/*",
"Accept-Encoding": "gzip, deflate",
"Content-Length": "18",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "python-requests/2.10.0"
},
"json": null,
"origin": "182.33.248.131",
"url": "http://httpbin.org/post"
}

可以发现,我们成功获得了返回结果,其中form部分就是提交的数据,这就证明 POST 请求成功发送了。

5. 响应

发送请求后,得到的自然就是响应。在上面的实例中,我们使用textcontent获取了响应的内容。此外,还有很多属性和方法可以用来获取其他信息,比如状态码、响应头、Cookies 等。示例如下:

1
2
3
4
5
6
7
8
import requests

r = requests.get('http://www.jianshu.com')
print(type(r.status_code), r.status_code)
print(type(r.headers), r.headers)
print(type(r.cookies), r.cookies)
print(type(r.url), r.url)
print(type(r.history), r.history)

这里分别打印输出status_code属性得到状态码,输出headers属性得到响应头,输出cookies属性得到 Cookies,输出url属性得到 URL,输出history属性得到请求历史。

运行结果如下:

1
2
3
4
5
<class 'int'> 200
<class 'requests.structures.CaseInsensitiveDict'> {'X-Runtime': '0.006363', 'Connection': 'keep-alive', 'Content-Type': 'text/html; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Date': 'Sat, 27 Aug 2016 17:18:51 GMT', 'Server': 'nginx', 'X-Frame-Options': 'DENY', 'Content-Encoding': 'gzip', 'Vary': 'Accept-Encoding', 'ETag': 'W/"3abda885e0e123bfde06d9b61e696159"', 'X-XSS-Protection': '1; mode=block', 'X-Request-Id': 'a8a3c4d5-f660-422f-8df9-49719dd9b5d4', 'Transfer-Encoding': 'chunked', 'Set-Cookie': 'read_mode=day; path=/, default_font=font2; path=/, _session_id=xxx; path=/; HttpOnly', 'Cache-Control': 'max-age=0, private, must-revalidate'}
<class 'requests.cookies.RequestsCookieJar'> <RequestsCookieJar[<Cookie _session_id=xxx for www.jianshu.com/>, <Cookie default_font=font2 for www.jianshu.com/>, <Cookie read_mode=day for www.jianshu.com/>]>
<class 'str'> http://www.jianshu.com/
<class 'list'> []

因为session_id过长,在此简写。可以看到,headerscookies这两个属性得到的结果分别是CaseInsensitiveDictRequestsCookieJar类型。

状态码常用来判断请求是否成功,而 requests 还提供了一个内置的状态码查询对象requests.codes,示例如下:

1
2
3
4
import requests

r = requests.get('http://www.jianshu.com')
exit() if not r.status_code == requests.codes.ok else print('Request Successfully')

这里通过比较返回码和内置的成功的返回码,来保证请求得到了正常响应,输出成功请求的消息,否则程序终止,这里我们用requests.codes.ok得到的是成功的状态码 200。

那么,肯定不能只有ok这个条件码。下面列出了返回码和相应的查询条件:

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
# 信息性状态码
100: ('continue',),
101: ('switching_protocols',),
102: ('processing',),
103: ('checkpoint',),
122: ('uri_too_long', 'request_uri_too_long'),

# 成功状态码
200: ('ok', 'okay', 'all_ok', 'all_okay', 'all_good', '\\o/', '✓'),
201: ('created',),
202: ('accepted',),
203: ('non_authoritative_info', 'non_authoritative_information'),
204: ('no_content',),
205: ('reset_content', 'reset'),
206: ('partial_content', 'partial'),
207: ('multi_status', 'multiple_status', 'multi_stati', 'multiple_stati'),
208: ('already_reported',),
226: ('im_used',),

# 重定向状态码
300: ('multiple_choices',),
301: ('moved_permanently', 'moved', '\\o-'),
302: ('found',),
303: ('see_other', 'other'),
304: ('not_modified',),
305: ('use_proxy',),
306: ('switch_proxy',),
307: ('temporary_redirect', 'temporary_moved', 'temporary'),
308: ('permanent_redirect',
'resume_incomplete', 'resume',), # These 2 to be removed in 3.0

# 客户端错误状态码
400: ('bad_request', 'bad'),
401: ('unauthorized',),
402: ('payment_required', 'payment'),
403: ('forbidden',),
404: ('not_found', '-o-'),
405: ('method_not_allowed', 'not_allowed'),
406: ('not_acceptable',),
407: ('proxy_authentication_required', 'proxy_auth', 'proxy_authentication'),
408: ('request_timeout', 'timeout'),
409: ('conflict',),
410: ('gone',),
411: ('length_required',),
412: ('precondition_failed', 'precondition'),
413: ('request_entity_too_large',),
414: ('request_uri_too_large',),
415: ('unsupported_media_type', 'unsupported_media', 'media_type'),
416: ('requested_range_not_satisfiable', 'requested_range', 'range_not_satisfiable'),
417: ('expectation_failed',),
418: ('im_a_teapot', 'teapot', 'i_am_a_teapot'),
421: ('misdirected_request',),
422: ('unprocessable_entity', 'unprocessable'),
423: ('locked',),
424: ('failed_dependency', 'dependency'),
425: ('unordered_collection', 'unordered'),
426: ('upgrade_required', 'upgrade'),
428: ('precondition_required', 'precondition'),
429: ('too_many_requests', 'too_many'),
431: ('header_fields_too_large', 'fields_too_large'),
444: ('no_response', 'none'),
449: ('retry_with', 'retry'),
450: ('blocked_by_windows_parental_controls', 'parental_controls'),
451: ('unavailable_for_legal_reasons', 'legal_reasons'),
499: ('client_closed_request',),

# 服务端错误状态码
500: ('internal_server_error', 'server_error', '/o\\', '✗'),
501: ('not_implemented',),
502: ('bad_gateway',),
503: ('service_unavailable', 'unavailable'),
504: ('gateway_timeout',),
505: ('http_version_not_supported', 'http_version'),
506: ('variant_also_negotiates',),
507: ('insufficient_storage',),
509: ('bandwidth_limit_exceeded', 'bandwidth'),
510: ('not_extended',),
511: ('network_authentication_required', 'network_auth', 'network_authentication')

比如,如果想判断结果是不是 404 状态,可以用requests.codes.not_found来比对。

Python

上一节中,我们了解了urllib的基本用法,但是其中确实有不方便的地方,比如处理网页验证和Cookies时,需要写OpenerHandler来处理。为了更加方便地实现这些操作,就有了更为强大的库requests,有了它,Cookies、登录验证、代理设置等操作都不是事儿。

接下来,让我们领略一下它的强大之处吧。

Python

利用urllib的robotparser模块,我们可以实现网站Robots协议的分析。本节中,我们来简单了解一下该模块的用法。

1. Robots协议

Robots协议也称作爬虫协议、机器人协议,它的全名叫作网络爬虫排除标准(Robots Exclusion Protocol),用来告诉爬虫和搜索引擎哪些页面可以抓取,哪些不可以抓取。它通常是一个叫作robots.txt的文本文件,一般放在网站的根目录下。

当搜索爬虫访问一个站点时,它首先会检查这个站点根目录下是否存在robots.txt文件,如果存在,搜索爬虫会根据其中定义的爬取范围来爬取。如果没有找到这个文件,搜索爬虫便会访问所有可直接访问的页面。

下面我们看一个robots.txt的样例:

1
2
3
User-agent: *
Disallow: /
Allow: /public/

这实现了对所有搜索爬虫只允许爬取public目录的功能,将上述内容保存成robots.txt文件,放在网站的根目录下,和网站的入口文件(比如index.php、index.html和index.jsp等)放在一起。

上面的User-agent描述了搜索爬虫的名称,这里将其设置为*则代表该协议对任何爬取爬虫有效。比如,我们可以设置:

1
User-agent: Baiduspider

这就代表我们设置的规则对百度爬虫是有效的。如果有多条User-agent记录,则就会有多个爬虫会受到爬取限制,但至少需要指定一条。

Disallow指定了不允许抓取的目录,比如上例子中设置为/则代表不允许抓取所有页面。

Allow一般和Disallow一起使用,一般不会单独使用,用来排除某些限制。现在我们设置为/public/,则表示所有页面不允许抓取,但可以抓取public目录。

下面我们再来看几个例子。禁止所有爬虫访问任何目录的代码如下:

1
2
User-agent: * 
Disallow: /

允许所有爬虫访问任何目录的代码如下:

1
2
User-agent: *
Disallow:

另外,直接把robots.txt文件留空也是可以的。

禁止所有爬虫访问网站某些目录的代码如下:

1
2
3
User-agent: *
Disallow: /private/
Disallow: /tmp/

只允许某一个爬虫访问的代码如下:

1
2
3
4
User-agent: WebCrawler
Disallow:
User-agent: *
Disallow: /

这些是robots.txt的一些常见写法。

2. 爬虫名称

大家可能会疑惑,爬虫名是哪儿来的?为什么就叫这个名?其实它是有固定名字的了,比如百度的就叫作BaiduSpider。表3-1列出了一些常见的搜索爬虫的名称及对应的网站。

表3-1 一些常见搜索爬虫的名称及其对应的网站

爬虫名称

名称

网站

BaiduSpider

百度

www.baidu.com

Googlebot

谷歌

www.google.com

360Spider

360搜索

www.so.com

YodaoBot

有道

www.youdao.com

ia_archiver

Alexa

www.alexa.cn

Scooter

altavista

www.altavista.com

3. robotparser

了解Robots协议之后,我们就可以使用robotparser模块来解析robots.txt了。该模块提供了一个类RobotFileParser,它可以根据某网站的robots.txt文件来判断一个爬取爬虫是否有权限来爬取这个网页。

该类用起来非常简单,只需要在构造方法里传入robots.txt的链接即可。首先看一下它的声明:

1
urllib.robotparser.RobotFileParser(url='')

当然,也可以在声明时不传入,默认为空,最后再使用set_url()方法设置一下也可。

下面列出了这个类常用的几个方法。

  • set_url():用来设置robots.txt文件的链接。如果在创建RobotFileParser对象时传入了链接,那么就不需要再使用这个方法设置了。
  • read():读取robots.txt文件并进行分析。注意,这个方法执行一个读取和分析操作,如果不调用这个方法,接下来的判断都会为False,所以一定记得调用这个方法。这个方法不会返回任何内容,但是执行了读取操作。
  • parse():用来解析robots.txt文件,传入的参数是robots.txt某些行的内容,它会按照robots.txt的语法规则来分析这些内容。
  • can_fetch():该方法传入两个参数,第一个是User-agent,第二个是要抓取的URL。返回的内容是该搜索引擎是否可以抓取这个URL,返回结果是TrueFalse
  • mtime():返回的是上次抓取和分析robots.txt的时间,这对于长时间分析和抓取的搜索爬虫是很有必要的,你可能需要定期检查来抓取最新的robots.txt。
  • modified():它同样对长时间分析和抓取的搜索爬虫很有帮助,将当前时间设置为上次抓取和分析robots.txt的时间。

下面我们用实例来看一下:

1
2
3
4
5
6
7
from urllib.robotparser import RobotFileParser

rp = RobotFileParser()
rp.set_url('http://www.jianshu.com/robots.txt')
rp.read()
print(rp.can_fetch('*', 'http://www.jianshu.com/p/b67554025d7d'))
print(rp.can_fetch('*', "http://www.jianshu.com/search?q=python&page=1&type=collections"))

这里以简书为例,首先创建RobotFileParser对象,然后通过set_url()方法设置了robots.txt的链接。当然,不用这个方法的话,可以在声明时直接用如下方法设置:

1
rp = RobotFileParser('http://www.jianshu.com/robots.txt')

接着利用can_fetch()方法判断了网页是否可以被抓取。

运行结果如下:

1
2
True
False

这里同样可以使用parse()方法执行读取和分析,示例如下:

1
2
3
4
5
6
7
from urllib.robotparser import RobotFileParser
from urllib.request import urlopen

rp = RobotFileParser()
rp.parse(urlopen('http://www.jianshu.com/robots.txt').read().decode('utf-8').split('\n'))
print(rp.can_fetch('*', 'http://www.jianshu.com/p/b67554025d7d'))
print(rp.can_fetch('*', "http://www.jianshu.com/search?q=python&page=1&type=collections"))

运行结果一样:

1
2
True
False

本节介绍了robotparser模块的基本用法和实例,利用它,我们可以方便地判断哪些页面可以抓取,哪些页面不可以抓取。

Python

前面说过,urllib库里还提供了parse这个模块,它定义了处理URL的标准接口,例如实现URL各部分的抽取、合并以及链接转换。它支持如下协议的URL处理:file、ftp、gopher、hdl、http、https、imap、mailto、 mms、news、nntp、prospero、rsync、rtsp、rtspu、sftp、 sip、sips、snews、svn、svn+ssh、telnet和wais。本节中,我们介绍一下该模块中常用的方法来看一下它的便捷之处。

1. urlparse()

该方法可以实现URL的识别和分段,这里先用一个实例来看一下:

1
2
3
4
from urllib.parse import urlparse

result = urlparse('http://www.baidu.com/index.html;user?id=5#comment')
print(type(result), result)

这里我们利用urlparse()方法进行了一个URL的解析。首先,输出了解析结果的类型,然后将结果也输出出来。

运行结果如下:

1
2
<class 'urllib.parse.ParseResult'>
ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5', fragment='comment')

可以看到,返回结果是一个ParseResult类型的对象,它包含6部分,分别是schemenetlocpathparamsqueryfragment

观察一下该实例的URL:

1
http://www.baidu.com/index.html;user?id=5#comment

可以发现,urlparse()方法将其拆分成了6部分。大体观察可以发现,解析时有特定的分隔符。比如,://前面的就是scheme,代表协议;第一个/前面便是netloc,即域名;分号;前面是params,代表参数。

所以,可以得出一个标准的链接格式,具体如下:

1
scheme://netloc/path;parameters?query#fragment

一个标准的URL都会符合这个规则,利用urlparse()方法可以将它拆分开来。

除了这种最基本的解析方式外,urlparse()方法还有其他配置吗?接下来,看一下它的API用法:

1
urllib.parse.urlparse(urlstring, scheme='', allow_fragments=True)

可以看到,它有3个参数。

  • urlstring:这是必填项,即待解析的URL。
  • scheme:它是默认的协议(比如httphttps等)。假如这个链接没有带协议信息,会将这个作为默认的协议。我们用实例来看一下:
1
2
3
4
from urllib.parse import urlparse

result = urlparse('www.baidu.com/index.html;user?id=5#comment', scheme='https')
print(result)

运行结果如下:

1
ParseResult(scheme='https', netloc='', path='www.baidu.com/index.html', params='user', query='id=5', fragment='comment')

可以发现,我们提供的URL没有包含最前面的scheme信息,但是通过指定默认的scheme参数,返回的结果是https

假设我们带上了scheme

1
result = urlparse('http://www.baidu.com/index.html;user?id=5#comment', scheme='https')

则结果如下:

1
ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5', fragment='comment')

可见,scheme参数只有在URL中不包含scheme信息时才生效。如果URL中有scheme信息,就会返回解析出的scheme

  • allow_fragments:即是否忽略fragment。如果它被设置为Falsefragment部分就会被忽略,它会被解析为pathparameters或者query的一部分,而fragment部分为空。下面我们用实例来看一下:
1
2
3
4
from urllib.parse import urlparse

result = urlparse('http://www.baidu.com/index.html;user?id=5#comment', allow_fragments=False)
print(result)

运行结果如下:

1
ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5#comment', fragment='')

假设URL中不包含paramsquery,我们再通过实例看一下:

1
2
3
4
from urllib.parse import urlparse

result = urlparse('http://www.baidu.com/index.html#comment', allow_fragments=False)
print(result)

运行结果如下:

1
ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html#comment', params='', query='', fragment='')

可以发现,当URL中不包含paramsquery时,fragment便会被解析为path的一部分。

返回结果ParseResult实际上是一个元组,我们可以用索引顺序来获取,也可以用属性名获取。示例如下:

1
2
3
4
from urllib.parse import urlparse

result = urlparse('http://www.baidu.com/index.html#comment', allow_fragments=False)
print(result.scheme, result[0], result.netloc, result[1], sep='\n')

这里我们分别用索引和属性名获取了schemenetloc,其运行结果如下:

1
2
3
4
http
http
www.baidu.com
www.baidu.com

可以发现,二者的结果是一致的,两种方法都可以成功获取。

2. urlunparse()

有了urlparse(),相应地就有了它的对立方法urlunparse()。它接受的参数是一个可迭代对象,但是它的长度必须是6,否则会抛出参数数量不足或者过多的问题。先用一个实例看一下:

1
2
3
4
from urllib.parse import urlunparse

data = ['http', 'www.baidu.com', 'index.html', 'user', 'a=6', 'comment']
print(urlunparse(data))

这里参数data用了列表类型。当然,你也可以用其他类型,比如元组或者特定的数据结构。

运行结果如下:

1
http://www.baidu.com/index.html;user?a=6#comment

这样我们就成功实现了URL的构造。

3. urlsplit()

这个方法和urlparse()方法非常相似,只不过它不再单独解析params这一部分,只返回5个结果。上面例子中的params会合并到path中。示例如下:

1
2
3
4
from urllib.parse import urlsplit

result = urlsplit('http://www.baidu.com/index.html;user?id=5#comment')
print(result)

运行结果如下:

1
SplitResult(scheme='http', netloc='www.baidu.com', path='/index.html;user', query='id=5', fragment='comment')

可以发现,返回结果是SplitResult,它其实也是一个元组类型,既可以用属性获取值,也可以用索引来获取。示例如下:

1
2
3
4
from urllib.parse import urlsplit

result = urlsplit('http://www.baidu.com/index.html;user?id=5#comment')
print(result.scheme, result[0])

运行结果如下:

1
http http

4. urlunsplit()

urlunparse()类似,它也是将链接各个部分组合成完整链接的方法,传入的参数也是一个可迭代对象,例如列表、元组等,唯一的区别是长度必须为5。示例如下:

1
2
3
4
from urllib.parse import urlunsplit

data = ['http', 'www.baidu.com', 'index.html', 'a=6', 'comment']
print(urlunsplit(data))

运行结果如下:

1
http://www.baidu.com/index.html?a=6#comment

5. urljoin()

有了urlunparse()urlunsplit()方法,我们可以完成链接的合并,不过前提必须要有特定长度的对象,链接的每一部分都要清晰分开。

此外,生成链接还有另一个方法,那就是urljoin()方法。我们可以提供一个base_url(基础链接)作为第一个参数,将新的链接作为第二个参数,该方法会分析base_urlschemenetlocpath这3个内容并对新链接缺失的部分进行补充,最后返回结果。

下面通过几个实例看一下:

1
2
3
4
5
6
7
8
9
10
from urllib.parse import urljoin

print(urljoin('http://www.baidu.com', 'FAQ.html'))
print(urljoin('http://www.baidu.com', 'https://cuiqingcai.com/FAQ.html'))
print(urljoin('http://www.baidu.com/about.html', 'https://cuiqingcai.com/FAQ.html'))
print(urljoin('http://www.baidu.com/about.html', 'https://cuiqingcai.com/FAQ.html?question=2'))
print(urljoin('http://www.baidu.com?wd=abc', 'https://cuiqingcai.com/index.php'))
print(urljoin('http://www.baidu.com', '?category=2#comment'))
print(urljoin('www.baidu.com', '?category=2#comment'))
print(urljoin('www.baidu.com#comment', '?category=2'))

运行结果如下:

1
2
3
4
5
6
7
8
http://www.baidu.com/FAQ.html
https://cuiqingcai.com/FAQ.html
https://cuiqingcai.com/FAQ.html
https://cuiqingcai.com/FAQ.html?question=2
https://cuiqingcai.com/index.php
http://www.baidu.com?category=2#comment
www.baidu.com?category=2#comment
www.baidu.com?category=2

可以发现,base_url提供了三项内容schemenetlocpath。如果这3项在新的链接里不存在,就予以补充;如果新的链接存在,就使用新的链接的部分。而base_url中的paramsqueryfragment是不起作用的。

通过urljoin()方法,我们可以轻松实现链接的解析、拼合与生成。

6. urlencode()

这里我们再介绍一个常用的方法——urlencode(),它在构造GET请求参数的时候非常有用,示例如下:

1
2
3
4
5
6
7
8
9
from urllib.parse import urlencode

params = {
'name': 'germey',
'age': 22
}
base_url = 'http://www.baidu.com?'
url = base_url + urlencode(params)
print(url)

这里首先声明了一个字典来将参数表示出来,然后调用urlencode()方法将其序列化为GET请求参数。

运行结果如下:

1
http://www.baidu.com?name=germey&age=22

可以看到,参数就成功地由字典类型转化为GET请求参数了。

这个方法非常常用。有时为了更加方便地构造参数,我们会事先用字典来表示。要转化为URL的参数时,只需要调用该方法即可。

7. parse_qs()

有了序列化,必然就有反序列化。如果我们有一串GET请求参数,利用parse_qs()方法,就可以将它转回字典,示例如下:

1
2
3
4
from urllib.parse import parse_qs

query = 'name=germey&age=22'
print(parse_qs(query))

运行结果如下:

1
{'name': ['germey'], 'age': ['22']}

可以看到,这样就成功转回为字典类型了。

8. parse_qsl()

另外,还有一个parse_qsl()方法,它用于将参数转化为元组组成的列表,示例如下:

1
2
3
4
from urllib.parse import parse_qsl

query = 'name=germey&age=22'
print(parse_qsl(query))

运行结果如下:

1
[('name', 'germey'), ('age', '22')]

可以看到,运行结果是一个列表,而列表中的每一个元素都是一个元组,元组的第一个内容是参数名,第二个内容是参数值。

9. quote()

该方法可以将内容转化为URL编码的格式。URL中带有中文参数时,有时可能会导致乱码的问题,此时用这个方法可以将中文字符转化为URL编码,示例如下:

1
2
3
4
5
from urllib.parse import quote

keyword = '壁纸'
url = 'https://www.baidu.com/s?wd=' + quote(keyword)
print(url)

这里我们声明了一个中文的搜索文字,然后用quote()方法对其进行URL编码,最后得到的结果如下:

1
https://www.baidu.com/s?wd=%E5%A3%81%E7%BA%B8

10. unquote()

有了quote()方法,当然还有unquote()方法,它可以进行URL解码,示例如下:

1
2
3
4
from urllib.parse import unquote

url = 'https://www.baidu.com/s?wd=%E5%A3%81%E7%BA%B8'
print(unquote(url))

这是上面得到的URL编码后的结果,这里利用unquote()方法还原,结果如下:

1
https://www.baidu.com/s?wd=壁纸

可以看到,利用unquote()方法可以方便地实现解码。

本节中,我们介绍了parse模块的一些常用URL处理方法。有了这些方法,我们可以方便地实现URL的解析和构造,建议熟练掌握。

Python

前一节我们了解了请求的发送过程,但是在网络不好的情况下,如果出现了异常,该怎么办呢?这时如果不处理这些异常,程序很可能因报错而终止运行,所以异常处理还是十分有必要的。

urllib的error模块定义了由request模块产生的异常。如果出现了问题,request模块便会抛出error模块中定义的异常。

1. URLError

URLError类来自urllib库的error模块,它继承自OSError类,是error异常模块的基类,由request模块生的异常都可以通过捕获这个类来处理。

它具有一个属性reason,即返回错误的原因。

下面用一个实例来看一下:

1
2
3
4
5
from urllib import request, error
try:
response = request.urlopen('http://cuiqingcai.com/index.htm')
except error.URLError as e:
print(e.reason)

我们打开一个不存在的页面,照理来说应该会报错,但是这时我们捕获了URLError这个异常,运行结果如下:

1
Not Found

程序没有直接报错,而是输出了如上内容,这样通过如上操作,我们就可以避免程序异常终止,同时异常得到了有效处理。

2. HTTPError

它是URLError的子类,专门用来处理HTTP请求错误,比如认证请求失败等。它有如下3个属性。

  • code:返回HTTP状态码,比如404表示网页不存在,500表示服务器内部错误等。
  • reason:同父类一样,用于返回错误的原因。
  • headers:返回请求头。

下面我们用几个实例来看看:

1
2
3
4
5
from urllib import request,error
try:
response = request.urlopen('http://cuiqingcai.com/index.htm')
except error.HTTPError as e:
print(e.reason, e.code, e.headers, sep='\n')

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Not Found
404
Server: nginx/1.4.6 (Ubuntu)
Date: Wed, 03 Aug 2016 08:54:22 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: close
X-Powered-By: PHP/5.5.9-1ubuntu4.14
Vary: Cookie
Expires: Wed, 11 Jan 1984 05:00:00 GMT
Cache-Control: no-cache, must-revalidate, max-age=0
Pragma: no-cache
Link: <http://cuiqingcai.com/wp-json/>; rel="https://api.w.org/"

依然是同样的网址,这里捕获了HTTPError异常,输出了reasoncodeheaders属性。

因为URLErrorHTTPError的父类,所以可以先选择捕获子类的错误,再去捕获父类的错误,所以上述代码更好的写法如下:

1
2
3
4
5
6
7
8
9
10
from urllib import request, error

try:
response = request.urlopen('http://cuiqingcai.com/index.htm')
except error.HTTPError as e:
print(e.reason, e.code, e.headers, sep='\n')
except error.URLError as e:
print(e.reason)
else:
print('Request Successfully')

这样就可以做到先捕获HTTPError,获取它的错误状态码、原因、headers等信息。如果不是HTTPError异常,就会捕获URLError异常,输出错误原因。最后,用else来处理正常的逻辑。这是一个较好的异常处理写法。

有时候,reason属性返回的不一定是字符串,也可能是一个对象。再看下面的实例:

1
2
3
4
5
6
7
8
9
10
import socket
import urllib.request
import urllib.error

try:
response = urllib.request.urlopen('https://www.baidu.com', timeout=0.01)
except urllib.error.URLError as e:
print(type(e.reason))
if isinstance(e.reason, socket.timeout):
print('TIME OUT')

这里我们直接设置超时时间来强制抛出timeout异常。

运行结果如下:

1
2
<class 'socket.timeout'>
TIME OUT

可以发现,reason属性的结果是socket.timeout类。所以,这里我们可以用isinstance()方法来判断它的类型,作出更详细的异常判断。

本节中,我们讲述了error模块的相关用法,通过合理地捕获异常可以做出更准确的异常判断,使程序更加稳健。

Python

使用 urllib 的request模块,我们可以方便地实现请求的发送并得到响应,本节就来看下它的具体用法。

1. urlopen()

urllib.request模块提供了最基本的构造 HTTP 请求的方法,利用它可以模拟浏览器的一个请求发起过程,同时它还带有处理授权验证(authenticaton)、重定向(redirection)、浏览器 Cookies 以及其他内容。

下面我们来看一下它的强大之处。这里以 Python 官网为例,我们来把这个网页抓下来:

1
2
3
4
import urllib.request

response = urllib.request.urlopen('https://www.python.org')
print(response.read().decode('utf-8'))

运行结果如图 3-1 所示。

图 3-1 运行结果

这里我们只用了两行代码,便完成了 Python 官网的抓取,输出了网页的源代码。得到源代码之后呢?我们想要的链接、图片地址、文本信息不就都可以提取出来了吗?

接下来,看看它返回的到底是什么。利用type()方法输出响应的类型:

1
2
3
4
import urllib.request

response = urllib.request.urlopen('https://www.python.org')
print(type(response))

输出结果如下:

1
<class 'http.client.HTTPResponse'>

可以发现,它是一个HTTPResposne类型的对象。它主要包含read()readinto()getheader(name)getheaders()fileno()等方法,以及msgversionstatusreasondebuglevelclosed等属性。

得到这个对象之后,我们把它赋值为response变量,然后就可以调用这些方法和属性,得到返回结果的一系列信息了。

例如,调用read()方法可以得到返回的网页内容,调用status属性可以得到返回结果的状态码,如 200 代表请求成功,404 代表网页未找到等。

下面再通过一个实例来看看:

1
2
3
4
5
6
import urllib.request

response = urllib.request.urlopen('https://www.python.org')
print(response.status)
print(response.getheaders())
print(response.getheader('Server'))

运行结果如下:

1
2
3
200
[('Server', 'nginx'), ('Content-Type', 'text/html; charset=utf-8'), ('X-Frame-Options', 'SAMEORIGIN'), ('X-Clacks-Overhead', 'GNU Terry Pratchett'), ('Content-Length', '47397'), ('Accept-Ranges', 'bytes'), ('Date', 'Mon, 01 Aug 2016 09:57:31 GMT'), ('Via', '1.1 varnish'), ('Age', '2473'), ('Connection', 'close'), ('X-Served-By', 'cache-lcy1125-LCY'), ('X-Cache', 'HIT'), ('X-Cache-Hits', '23'), ('Vary', 'Cookie'), ('Strict-Transport-Security', 'max-age=63072000; includeSubDomains')]
nginx

可见,前两个输出分别输出了响应的状态码和响应的头信息,最后一个输出通过调用getheader()方法并传递一个参数Server获取了响应头中的Server值,结果是nginx,意思是服务器是用 Nginx 搭建的。

利用最基本的urlopen()方法,可以完成最基本的简单网页的 GET 请求抓取。

如果想给链接传递一些参数,该怎么实现呢?首先看一下urlopen()函数的 API:

1
urllib.request.urlopen(url, data=None, [timeout, ]*, cafile=None, capath=None, cadefault=False, context=None)

可以发现,除了第一个参数可以传递 URL 之外,我们还可以传递其他内容,比如data(附加数据)、timeout(超时时间)等。

下面我们详细说明下这几个参数的用法。

data参数

data参数是可选的。如果要添加该参数,并且如果它是字节流编码格式的内容,即bytes类型,则需要通过bytes()方法转化。另外,如果传递了这个参数,则它的请求方式就不再是 GET 方式,而是 POST 方式。

下面用实例来看一下:

1
2
3
4
5
6
import urllib.parse
import urllib.request

data = bytes(urllib.parse.urlencode({'word': 'hello'}), encoding='utf8')
response = urllib.request.urlopen('http://httpbin.org/post', data=data)
print(response.read())

这里我们传递了一个参数word,值是hello。它需要被转码成bytes(字节流)类型。其中转字节流采用了bytes()方法,该方法的第一个参数需要是str(字符串)类型,需要用urllib.parse模块里的urlencode()方法来将参数字典转化为字符串;第二个参数指定编码格式,这里指定为utf8

这里请求的站点是 httpbin.org,它可以提供 HTTP 请求测试。本次我们请求的 URL 为http://httpbin.org/post,这个链接可以用来测试 POST 请求,它可以输出请求的一些信息,其中包含我们传递的data参数。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"args": {},
"data": "",
"files": {},
"form": {
"word": "hello"
},
"headers": {
"Accept-Encoding": "identity",
"Content-Length": "10",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "Python-urllib/3.5"
},
"json": null,
"origin": "123.124.23.253",
"url": "http://httpbin.org/post"
}

我们传递的参数出现在了form字段中,这表明是模拟了表单提交的方式,以 POST 方式传输数据。

timeout参数

timeout参数用于设置超时时间,单位为秒,意思就是如果请求超出了设置的这个时间,还没有得到响应,就会抛出异常。如果不指定该参数,就会使用全局默认时间。它支持 HTTP、HTTPS、FTP 请求。

下面用实例来看一下:

1
2
3
4
import urllib.request

response = urllib.request.urlopen('http://httpbin.org/get', timeout=1)
print(response.read())

运行结果如下:

1
2
3
4
5
During handling of the above exception, another exception occurred:

Traceback (most recent call last): File "/var/py/python/urllibtest.py", line 4, in <module> response = urllib.request.urlopen('http://httpbin.org/get', timeout=1)
...
urllib.error.URLError: <urlopen error timed out>

这里我们设置超时时间是 1 秒。程序 1 秒过后,服务器依然没有响应,于是抛出了URLError异常。该异常属于urllib.error模块,错误原因是超时。

因此,可以通过设置这个超时时间来控制一个网页如果长时间未响应,就跳过它的抓取。这可以利用try except语句来实现,相关代码如下:

1
2
3
4
5
6
7
8
9
import socket
import urllib.request
import urllib.error

try:
response = urllib.request.urlopen('http://httpbin.org/get', timeout=0.1)
except urllib.error.URLError as e:
if isinstance(e.reason, socket.timeout):
print('TIME OUT')

这里我们请求了http://httpbin.org/get测试链接,设置超时时间是 0.1 秒,然后捕获了URLError异常,接着判断异常是socket.timeout类型(意思就是超时异常),从而得出它确实是因为超时而报错,打印输出了TIME OUT

运行结果如下:

1
TIME OUT

按照常理来说,0.1 秒内基本不可能得到服务器响应,因此输出了TIME OUT的提示。

通过设置timeout这个参数来实现超时处理,有时还是很有用的。

其他参数

除了data参数和timeout参数外,还有context参数,它必须是ssl.SSLContext类型,用来指定 SSL 设置。

此外,cafilecapath这两个参数分别指定 CA 证书和它的路径,这个在请求 HTTPS 链接时会有用。

cadefault参数现在已经弃用了,其默认值为False

前面讲解了urlopen()方法的用法,通过这个最基本的方法,我们可以完成简单的请求和网页抓取。若需更加详细的信息,可以参见官方文档:https://docs.python.org/3/library/urllib.request.html

2. Request

我们知道利用urlopen()方法可以实现最基本请求的发起,但这几个简单的参数并不足以构建一个完整的请求。如果请求中需要加入 Headers 等信息,就可以利用更强大的Request类来构建。

首先,我们用实例来感受一下Request的用法:

1
2
3
4
5
import urllib.request

request = urllib.request.Request('https://python.org')
response = urllib.request.urlopen(request)
print(response.read().decode('utf-8'))

可以发现,我们依然是用urlopen()方法来发送这个请求,只不过这次该方法的参数不再是 URL,而是一个Request类型的对象。通过构造这个数据结构,一方面我们可以将请求独立成一个对象,另一方面可更加丰富和灵活地配置参数。

下面我们看一下Request可以通过怎样的参数来构造,它的构造方法如下:

1
class urllib.request.Request(url, data=None, headers={}, origin_req_host=None, unverifiable=False, method=None)
  • 第一个参数url用于请求 URL,这是必传参数,其他都是可选参数。
  • 第二个参数data如果要传,必须传bytes(字节流)类型的。如果它是字典,可以先用urllib.parse模块里的urlencode()编码。
  • 第三个参数headers是一个字典,它就是请求头,我们可以在构造请求时通过headers参数直接构造,也可以通过调用请求实例的add_header()方法添加。 添加请求头最常用的用法就是通过修改User-Agent来伪装浏览器,默认的User-Agent是 Python-urllib,我们可以通过修改它来伪装浏览器。比如要伪装火狐浏览器,你可以把它设置为:

    1
    Mozilla/5.0 (X11; U; Linux i686) Gecko/20071127 Firefox/2.0.0.11
  • 第四个参数origin_req_host指的是请求方的 host 名称或者 IP 地址。

  • 第五个参数unverifiable表示这个请求是否是无法验证的,默认是False,意思就是说用户没有足够权限来选择接收这个请求的结果。例如,我们请求一个 HTML 文档中的图片,但是我们没有自动抓取图像的权限,这时 unverifiable的值就是True`。
  • 第六个参数method是一个字符串,用来指示请求使用的方法,比如 GET、POST 和 PUT 等。

下面我们传入多个参数构建请求来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from urllib import request, parse

url = 'http://httpbin.org/post'
headers = {
'User-Agent': 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)',
'Host': 'httpbin.org'
}
dict = {
'name': 'Germey'
}
data = bytes(parse.urlencode(dict), encoding='utf8')
req = request.Request(url=url, data=data, headers=headers, method='POST')
response = request.urlopen(req)
print(response.read().decode('utf-8'))

这里我们通过 4 个参数构造了一个请求,其中url即请求 URL,headers中指定了User-AgentHost,参数dataurlencode()bytes()方法转成字节流。另外,指定了请求方式为 POST。

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"args": {},
"data": "",
"files": {},
"form": {
"name": "Germey"
},
"headers": {
"Accept-Encoding": "identity",
"Content-Length": "11",
"Content-Type": "application/x-www-form-urlencoded",
"Host": "httpbin.org",
"User-Agent": "Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)"
},
"json": null,
"origin": "219.224.169.11",
"url": "http://httpbin.org/post"
}

观察结果可以发现,我们成功设置了dataheadersmethod

另外,headers也可以用add_header()方法来添加:

1
2
req = request.Request(url=url, data=data, method='POST')
req.add_header('User-Agent', 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)')

如此一来,我们就可以更加方便地构造请求,实现请求的发送啦。

3. 高级用法

在上面的过程中,我们虽然可以构造请求,但是对于一些更高级的操作(比如 Cookies 处理、代理设置等),我们该怎么办呢?

接下来,就需要更强大的工具 Handler 登场了。简而言之,我们可以把它理解为各种处理器,有专门处理登录验证的,有处理 Cookies 的,有处理代理设置的。利用它们,我们几乎可以做到 HTTP 请求中所有的事情。

首先,介绍一下urllib.request模块里的BaseHandler类,它是所有其他Handler的父类,它提供了最基本的方法,例如default_open()protocol_request()等。

接下来,就有各种Handler子类继承这个BaseHandler类,举例如下。

  • HTTPDefaultErrorHandler:用于处理 HTTP 响应错误,错误都会抛出HTTPError类型的异常。
  • HTTPRedirectHandler:用于处理重定向。
  • HTTPCookieProcessor:用于处理 Cookies。
  • ProxyHandler:用于设置代理,默认代理为空。
  • HTTPPasswordMgr:用于管理密码,它维护了用户名和密码的表。
  • HTTPBasicAuthHandler:用于管理认证,如果一个链接打开时需要认证,那么可以用它来解决认证问题。

另外,还有其他的Handler类,这里就不一一列举了,详情可以参考官方文档:https://docs.python.org/3/library/urllib.request.html#urllib.request.BaseHandler

关于怎么使用它们,现在先不用着急,后面会有实例演示。

另一个比较重要的类就是OpenerDirector,我们可以称为Opener。我们之前用过urlopen()这个方法,实际上它就是 urllib 为我们提供的一个Opener

那么,为什么要引入Opener呢?因为需要实现更高级的功能。之前使用的Requesturlopen()相当于类库为你封装好了极其常用的请求方法,利用它们可以完成基本的请求,但是现在不一样了,我们需要实现更高级的功能,所以需要深入一层进行配置,使用更底层的实例来完成操作,所以这里就用到了Opener

Opener可以使用open()方法,返回的类型和urlopen()如出一辙。那么,它和Handler有什么关系呢?简而言之,就是利用Handler来构建Opener

下面用几个实例来看看它们的用法。

验证

有些网站在打开时就会弹出提示框,直接提示你输入用户名和密码,验证成功后才能查看页面,如图 3-2 所示。

图 3-2 验证页面

那么,如果要请求这样的页面,该怎么办呢?借助HTTPBasicAuthHandler就可以完成,相关代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from urllib.request import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener
from urllib.error import URLError

username = 'username'
password = 'password'
url = 'http://localhost:5000/'

p = HTTPPasswordMgrWithDefaultRealm()
p.add_password(None, url, username, password)
auth_handler = HTTPBasicAuthHandler(p)
opener = build_opener(auth_handler)

try:
result = opener.open(url)
html = result.read().decode('utf-8')
print(html)
except URLError as e:
print(e.reason)

这里首先实例化HTTPBasicAuthHandler对象,其参数是HTTPPasswordMgrWithDefaultRealm对象,它利用add_password()添加进去用户名和密码,这样就建立了一个处理验证的Handler

接下来,利用这个Handler并使用build_opener()方法构建一个Opener,这个Opener在发送请求时就相当于已经验证成功了。

接下来,利用Openeropen()方法打开链接,就可以完成验证了。这里获取到的结果就是验证后的页面源码内容。

代理

在做爬虫的时候,免不了要使用代理,如果要添加代理,可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
from urllib.error import URLError
from urllib.request import ProxyHandler, build_opener

proxy_handler = ProxyHandler({
'http': 'http://127.0.0.1:9743',
'https': 'https://127.0.0.1:9743'
})
opener = build_opener(proxy_handler)
try:
response = opener.open('https://www.baidu.com')
print(response.read().decode('utf-8'))
except URLError as e:
print(e.reason)

这里我们在本地搭建了一个代理,它运行在 9743 端口上。

这里使用了ProxyHandler,其参数是一个字典,键名是协议类型(比如 HTTP 或者 HTTPS 等),键值是代理链接,可以添加多个代理。

然后,利用这个 Handler 及build_opener()方法构造一个Opener,之后发送请求即可。

Cookies

Cookies 的处理就需要相关的Handler了。

我们先用实例来看看怎样将网站的 Cookies 获取下来,相关代码如下:

1
2
3
4
5
6
7
8
import http.cookiejar, urllib.request

cookie = http.cookiejar.CookieJar()
handler = urllib.request.HTTPCookieProcessor(cookie)
opener = urllib.request.build_opener(handler)
response = opener.open('http://www.baidu.com')
for item in cookie:
print(item.name+"="+item.value)

首先,我们必须声明一个CookieJar对象。接下来,就需要利用HTTPCookieProcessor来构建一个Handler,最后利用build_opener()方法构建出Opener,执行open()函数即可。

运行结果如下:

1
2
3
4
5
6
BAIDUID=2E65A683F8A8BA3DF521469DF8EFF1E1:FG=1
BIDUPSID=2E65A683F8A8BA3DF521469DF8EFF1E1
H_PS_PSSID=20987_1421_18282_17949_21122_17001_21227_21189_21161_20927
PSTM=1474900615
BDSVRTM=0
BD_HOME=0

可以看到,这里输出了每条 Cookie 的名称和值。

不过既然能输出,那可不可以输出成文件格式呢?我们知道 Cookies 实际上也是以文本形式保存的。

答案当然是肯定的,这里通过下面的实例来看看:

1
2
3
4
5
6
filename = 'cookies.txt'
cookie = http.cookiejar.MozillaCookieJar(filename)
handler = urllib.request.HTTPCookieProcessor(cookie)
opener = urllib.request.build_opener(handler)
response = opener.open('http://www.baidu.com')
cookie.save(ignore_discard=True, ignore_expires=True)

这时CookieJar就需要换成MozillaCookieJar,它在生成文件时会用到,是CookieJar的子类,可以用来处理 Cookies 和文件相关的事件,比如读取和保存 Cookies,可以将 Cookies 保存成 Mozilla 型浏览器的 Cookies 格式。

运行之后,可以发现生成了一个 cookies.txt 文件,其内容如下:

1
2
3
4
5
6
7
8
9
10
# Netscape HTTP Cookie File
# http://curl.haxx.se/rfc/cookie_spec.html
# This is a generated file! Do not edit.

.baidu.com TRUE / FALSE 3622386254 BAIDUID 05AE39B5F56C1DEC474325CDA522D44F:FG=1
.baidu.com TRUE / FALSE 3622386254 BIDUPSID 05AE39B5F56C1DEC474325CDA522D44F
.baidu.com TRUE / FALSE H_PS_PSSID 19638_1453_17710_18240_21091_18560_17001_21191_21161
.baidu.com TRUE / FALSE 3622386254 PSTM 1474902606
www.baidu.com FALSE / FALSE BDSVRTM 0
www.baidu.com FALSE / FALSE BD_HOME 0

另外,LWPCookieJar同样可以读取和保存 Cookies,但是保存的格式和MozillaCookieJar不一样,它会保存成 libwww-perl(LWP)格式的 Cookies 文件。

要保存成 LWP 格式的 Cookies 文件,可以在声明时就改为:

1
cookie = http.cookiejar.LWPCookieJar(filename)

此时生成的内容如下:

1
2
3
4
5
6
7
#LWP-Cookies-2.0
Set-Cookie3: BAIDUID="0CE9C56F598E69DB375B7C294AE5C591:FG=1"; path="/"; domain=".baidu.com"; path_spec; domain_dot; expires="2084-10-14 18:25:19Z"; version=0
Set-Cookie3: BIDUPSID=0CE9C56F598E69DB375B7C294AE5C591; path="/"; domain=".baidu.com"; path_spec; domain_dot; expires="2084-10-14 18:25:19Z"; version=0
Set-Cookie3: H_PS_PSSID=20048_1448_18240_17944_21089_21192_21161_20929; path="/"; domain=".baidu.com"; path_spec; domain_dot; discard; version=0
Set-Cookie3: PSTM=1474902671; path="/"; domain=".baidu.com"; path_spec; domain_dot; expires="2084-10-14 18:25:19Z"; version=0
Set-Cookie3: BDSVRTM=0; path="/"; domain="www.baidu.com"; path_spec; discard; version=0
Set-Cookie3: BD_HOME=0; path="/"; domain="www.baidu.com"; path_spec; discard; version=0

由此看来,生成的格式还是有比较大差异的。

那么,生成了 Cookies 文件后,怎样从文件中读取并利用呢?

下面我们以LWPCookieJar格式为例来看一下:

1
2
3
4
5
6
cookie = http.cookiejar.LWPCookieJar()
cookie.load('cookies.txt', ignore_discard=True, ignore_expires=True)
handler = urllib.request.HTTPCookieProcessor(cookie)
opener = urllib.request.build_opener(handler)
response = opener.open('http://www.baidu.com')
print(response.read().decode('utf-8'))

可以看到,这里调用load()方法来读取本地的 Cookies 文件,获取到了 Cookies 的内容。不过前提是我们首先生成了 LWPCookieJar 格式的 Cookies,并保存成文件,然后读取 Cookies 之后使用同样的方法构建 Handler 和 Opener 即可完成操作。

运行结果正常的话,会输出百度网页的源代码。

通过上面的方法,我们可以实现绝大多数请求功能的设置了。

这便是 urllib 库中request模块的基本用法,如果想实现更多的功能,可以参考官方文档的说明:https://docs.python.org/3/library/urllib.request.html#basehandler-objects

Python

在Python 2中,有urllib和urllib2两个库来实现请求的发送。而在Python 3中,已经不存在urllib2这个库了,统一为urllib,其官方文档链接为:https://docs.python.org/3/library/urllib.html

首先,了解一下urllib库,它是Python内置的HTTP请求库,也就是说不需要额外安装即可使用。它包含如下4个模块。

  • request:它是最基本的HTTP请求模块,可以用来模拟发送请求。就像在浏览器里输入网址然后回车一样,只需要给库方法传入URL以及额外的参数,就可以模拟实现这个过程了。
  • error:异常处理模块,如果出现请求错误,我们可以捕获这些异常,然后进行重试或其他操作以保证程序不会意外终止。
  • parse:一个工具模块,提供了许多URL处理方法,比如拆分、解析、合并等。
  • robotparser:主要是用来识别网站的robots.txt文件,然后判断哪些网站可以爬,哪些网站不可以爬,它其实用得比较少。

这里重点讲解一下前3个模块。

Python

学习爬虫,最初的操作便是模拟浏览器向服务器发出请求,那么我们需要从哪个地方做起呢?请求需要我们自己来构造吗?需要关心请求这个数据结构的实现吗?需要了解HTTP、TCP、IP层的网络传输通信吗?需要知道服务器的响应和应答原理吗?

可能你不知道无从下手,不过不用担心,Python的强大之处就是提供了功能齐全的类库来帮助我们完成这些请求。最基础的HTTP库有urllib、httplib2、requests、treq等。

拿urllib这个库来说,有了它,我们只需要关心请求的链接是什么,需要传的参数是什么以及可选的请求头设置就好了,不用深入到底层去了解它到底是怎样传输和通信的。有了它,两行代码就可以完成一个请求和响应的处理过程,得到网页内容,是不是感觉方便极了?

接下来,就让我们从最基础的部分开始了解这些库的使用方法吧。

Python

我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么美好,然而一杯茶的功夫可能就会出现错误,比如403 Forbidden,这时候打开网页一看,可能会看到“您的IP访问频率太高”这样的提示。出现这种现象的原因是网站采取了一些反爬虫措施。比如,服务器会检测某个IP在单位时间内的请求次数,如果超过了这个阈值,就会直接拒绝服务,返回一些错误信息,这种情况可以称为封IP。

既然服务器检测的是某个IP单位时间的请求次数,那么借助某种方式来伪装我们的IP,让服务器识别不出是由我们本机发起的请求,不就可以成功防止封IP了吗?

一种有效的方式就是使用代理,后面会详细说明代理的用法。在这之前,需要先了解下代理的基本原理,它是怎样实现IP伪装的呢?

1. 基本原理

代理实际上指的就是代理服务器,英文叫作proxy server,它的功能是代理网络用户去取得网络信息。形象地说,它是网络信息的中转站。在我们正常请求一个网站时,是发送了请求给Web服务器,Web服务器把响应传回给我们。如果设置了代理服务器,实际上就是在本机和服务器之间搭建了一个桥,此时本机不是直接向Web服务器发起请求,而是向代理服务器发出请求,请求会发送给代理服务器,然后由代理服务器再发送给Web服务器,接着由代理服务器再把Web服务器返回的响应转发给本机。这样我们同样可以正常访问网页,但这个过程中Web服务器识别出的真实IP就不再是我们本机的IP了,就成功实现了IP伪装,这就是代理的基本原理。

2. 代理的作用

那么,代理有什么作用呢?我们可以简单列举如下。

  • 突破自身IP访问限制,访问一些平时不能访问的站点。
  • 访问一些单位或团体内部资源:比如使用教育网内地址段免费代理服务器,就可以用于对教育网开放的各类FTP下载上传,以及各类资料查询共享等服务。
  • 提高访问速度:通常代理服务器都设置一个较大的硬盘缓冲区,当有外界的信息通过时,同时也将其保存到缓冲区中,当其他用户再访问相同的信息时,则直接由缓冲区中取出信息,传给用户,以提高访问速度。
  • 隐藏真实IP:上网者也可以通过这种方法隐藏自己的IP,免受攻击。对于爬虫来说,我们用代理就是为了隐藏自身IP,防止自身的IP被封锁。

3. 爬虫代理

对于爬虫来说,由于爬虫爬取速度过快,在爬取过程中可能遇到同一个IP访问过于频繁的问题,此时网站就会让我们输入验证码登录或者直接封锁IP,这样会给爬取带来极大的不便。

使用代理隐藏真实的IP,让服务器误以为是代理服务器在请求自己。这样在爬取过程中通过不断更换代理,就不会被封锁,可以达到很好的爬取效果。

4. 代理分类

代理分类时,既可以根据协议区分,也可以根据其匿名程度区分。

(1) 根据协议区分

根据代理的协议,代理可以分为如下类别。

  • FTP代理服务器:主要用于访问FTP服务器,一般有上传、下载以及缓存功能,端口一般为21、2121等。
  • HTTP代理服务器:主要用于访问网页,一般有内容过滤和缓存功能,端口一般为80、8080、3128等。
  • SSL/TLS代理:主要用于访问加密网站,一般有SSL或TLS加密功能(最高支持128位加密强度),端口一般为443。
  • RTSP代理:主要用于访问Real流媒体服务器,一般有缓存功能,端口一般为554。
  • Telnet代理:主要用于telnet远程控制(黑客入侵计算机时常用于隐藏身份),端口一般为23。
  • POP3/SMTP代理:主要用于POP3/SMTP方式收发邮件,一般有缓存功能,端口一般为110/25。
  • SOCKS代理:只是单纯传递数据包,不关心具体协议和用法,所以速度快很多,一般有缓存功能,端口一般为1080。SOCKS代理协议又分为SOCKS4和SOCKS5,前者只支持TCP,而后者支持TCP和UDP,还支持各种身份验证机制、服务器端域名解析等。简单来说,SOCK4能做到的SOCKS5都可以做到,但SOCKS5能做到的SOCK4不一定能做到。

(2) 根据匿名程度区分

根据代理的匿名程度,代理可以分为如下类别。

  • 高度匿名代理:会将数据包原封不动地转发,在服务端看来就好像真的是一个普通客户端在访问,而记录的IP是代理服务器的IP。
  • 普通匿名代理:会在数据包上做一些改动,服务端上有可能发现这是个代理服务器,也有一定几率追查到客户端的真实IP。代理服务器通常会加入的HTTP头有HTTP_VIAHTTP_X_FORWARDED_FOR
  • 透明代理:不但改动了数据包,还会告诉服务器客户端的真实IP。这种代理除了能用缓存技术提高浏览速度,能用内容过滤提高安全性之外,并无其他显著作用,最常见的例子是内网中的硬件防火墙。
  • 间谍代理:指组织或个人创建的用于记录用户传输的数据,然后进行研究、监控等目的的代理服务器。

5. 常见代理设置

  • 使用网上的免费代理:最好使用高匿代理,另外可用的代理不多,需要在使用前筛选一下可用代理,也可以进一步维护一个代理池。
  • 使用付费代理服务:互联网上存在许多代理商,可以付费使用,质量比免费代理好很多。
  • ADSL拨号:拨一次号换一次IP,稳定性高,也是一种比较有效的解决方案。

在后文我们会详细介绍这几种代理的使用方式。

6. 参考来源

由于涉及一些专业名词知识,本节的部分内容参考来源如下。

Python

在浏览网站的过程中,我们经常会遇到需要登录的情况,有些页面只有登录之后才可以访问,而且登录之后可以连续访问很多次网站,但是有时候过一段时间就需要重新登录。还有一些网站,在打开浏览器时就自动登录了,而且很长时间都不会失效,这种情况又是为什么?其实这里面涉及会话和 Cookies 的相关知识,本节就来揭开它们的神秘面纱。

1. 静态网页和动态网页

在开始之前,我们需要先了解一下静态网页和动态网页的概念。这里还是前面的示例代码,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>This is a Demo</title>
</head>
<body>
<div id="container">
<div class="wrapper">
<h2 class="title">Hello World</h2>
<p class="text">Hello, this is a paragraph.</p>
</div>
</div>
</body>
</html>

这是最基本的 HTML 代码,我们将其保存为一个.html 文件,然后把它放在某台具有固定公网 IP 的主机上,主机上装上 Apache 或 Nginx 等服务器,这样这台主机就可以作为服务器了,其他人便可以通过访问服务器看到这个页面,这就搭建了一个最简单的网站。

这种网页的内容是 HTML 代码编写的,文字、图片等内容均通过写好的 HTML 代码来指定,这种页面叫作静态网页。它加载速度快,编写简单,但是存在很大的缺陷,如可维护性差,不能根据 URL 灵活多变地显示内容等。例如,我们想要给这个网页的 URL 传入一个name参数,让其在网页中显示出来,是无法做到的。

因此,动态网页应运而生,它可以动态解析 URL 中参数的变化,关联数据库并动态呈现不同的页面内容,非常灵活多变。我们现在遇到的大多数网站都是动态网站,它们不再是一个简单的 HTML,而是可能由 JSP、PHP、Python 等语言编写的,其功能比静态网页强大和丰富太多了。

此外,动态网站还可以实现用户登录和注册的功能。再回到开头提到的问题,很多页面是需要登录之后才可以查看的。按照一般的逻辑来说,输入用户名和密码登录之后,肯定是拿到了一种类似凭证的东西,有了它,我们才能保持登录状态,才能访问登录之后才能看到的页面。

那么,这种神秘的凭证到底是什么呢?其实它就是会话和 Cookies 共同产生的结果,下面我们来一探究竟。

2. 无状态 HTTP

在了解会话和 Cookies 之前,我们还需要了解 HTTP 的一个特点,叫作无状态。

HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。当我们向服务器发送请求后,服务器解析此请求,然后返回对应的响应,服务器负责完成这个过程,而且这个过程是完全独立的,服务器不会记录前后状态的变化,也就是缺少状态记录。这意味着如果后续需要处理前面的信息,则必须重传,这导致需要额外传递一些前面的重复请求,才能获取后续响应,然而这种效果显然不是我们想要的。为了保持前后状态,我们肯定不能将前面的请求全部重传一次,这太浪费资源了,对于这种需要用户登录的页面来说,更是棘手。

这时两个用于保持 HTTP 连接状态的技术就出现了,它们分别是会话和 Cookies。会话在服务端,也就是网站的服务器,用来保存用户的会话信息;Cookies 在客户端,也可以理解为浏览器端,有了 Cookies,浏览器在下次访问网页时会自动附带上它发送给服务器,服务器通过识别 Cookies 并鉴定出是哪个用户,然后再判断用户是否是登录状态,然后返回对应的响应。

我们可以理解为 Cookies 里面保存了登录的凭证,有了它,只需要在下次请求携带 Cookies 发送请求而不必重新输入用户名、密码等信息重新登录了。

因此在爬虫中,有时候处理需要登录才能访问的页面时,我们一般会直接将登录成功后获取的 Cookies 放在请求头里面直接请求,而不必重新模拟登录。

好了,了解会话和 Cookies 的概念之后,我们在来详细剖析它们的原理。

(1) 会话

会话,其本来的含义是指有始有终的一系列动作/消息。比如,打电话时,从拿起电话拨号到挂断电话这中间的一系列过程可以称为一个会话。

而在 Web 中,会话对象用来存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在会话对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个会话对象。当会话过期或被放弃后,服务器将终止该会话。

(2) Cookies

Cookies 指某些网站为了辨别用户身份、进行会话跟踪而存储在用户本地终端上的数据。

会话维持

那么,我们怎样利用 Cookies 保持状态呢?当客户端第一次请求服务器时,服务器会返回一个请求头中带有Set-Cookie字段的响应给客户端,用来标记是哪一个用户,客户端浏览器会把 Cookies 保存起来。当浏览器下一次再请求该网站时,浏览器会把此 Cookies 放到请求头一起提交给服务器,Cookies 携带了会话 ID 信息,服务器检查该 Cookies 即可找到对应的会话是什么,然后再判断会话来以此来辨认用户状态。

在成功登录某个网站时,服务器会告诉客户端设置哪些 Cookies 信息,在后续访问页面时客户端会把 Cookies 发送给服务器,服务器再找到对应的会话加以判断。如果会话中的某些设置登录状态的变量是有效的,那就证明用户处于登录状态,此时返回登录之后才可以查看的网页内容,浏览器再进行解析便可以看到了。

反之,如果传给服务器的 Cookies 是无效的,或者会话已经过期了,我们将不能继续访问页面,此时可能会收到错误的响应或者跳转到登录页面重新登录。

所以,Cookies 和会话需要配合,一个处于客户端,一个处于服务端,二者共同协作,就实现了登录会话控制。

属性结构

接下来,我们来看看 Cookies 都有哪些内容。这里以知乎为例,在浏览器开发者工具中打开 Application 选项卡,然后在左侧会有一个 Storage 部分,最后一项即为 Cookies,将其点开,如图 2-13 所示,这些就是 Cookies。

图 2-13 Cookies 列表

可以看到,这里有很多条目,其中每个条目可以称为 Cookie。它有如下几个属性。

  • Name:该 Cookie 的名称。一旦创建,该名称便不可更改。
  • Value:该 Cookie 的值。如果值为 Unicode 字符,需要为字符编码。如果值为二进制数据,则需要使用 BASE64 编码。
  • Domain:可以访问该 Cookie 的域名。例如,如果设置为.zhihu.com,则所有以 zhihu.com,结尾的域名都可以访问该 Cookie。
  • Max Age:该 Cookie 失效的时间,单位为秒,也常和 Expires 一起使用,通过它可以计算出其有效时间。Max Age 如果为正数,则该 Cookie 在 Max Age 秒之后失效。如果为负数,则关闭浏览器时 Cookie 即失效,浏览器也不会以任何形式保存该 Cookie。
  • Path:该 Cookie 的使用路径。如果设置为/path/,则只有路径为/path/的页面可以访问该 Cookie。如果设置为/,则本域名下的所有页面都可以访问该 Cookie。
  • Size 字段:此 Cookie 的大小。
  • HTTP 字段:Cookie 的httponly属性。若此属性为true,则只有在 HTTP 头中会带有此 Cookie 的信息,而不能通过document.cookie来访问此 Cookie。
  • Secure:该 Cookie 是否仅被使用安全协议传输。安全协议有 HTTPS 和 SSL 等,在网络上传输数据之前先将数据加密。默认为false

从表面意思来说,会话 Cookie 就是把 Cookie 放在浏览器内存里,浏览器在关闭之后该 Cookie 即失效;持久 Cookie 则会保存到客户端的硬盘中,下次还可以继续使用,用于长久保持用户登录状态。

其实严格来说,没有会话 Cookie 和持久 Cookie 之分,只是由 Cookie 的 Max Age 或 Expires 字段决定了过期的时间。

因此,一些持久化登录的网站其实就是把 Cookie 的有效时间和会话有效期设置得比较长,下次我们再访问页面时仍然携带之前的 Cookie,就可以直接保持登录状态。

3. 常见误区

在谈论会话机制的时候,常常听到这样一种误解“只要关闭浏览器,会话就消失了”,这种理解是错误的。可以想象一下会员卡的例子,除非顾客主动对店家提出销卡,否则店家绝对不会轻易删除顾客的资料。对会话来说,也是一样,除非程序通知服务器删除一个会话,否则服务器会一直保留。比如,程序一般都是在我们做注销操作时才去删除会话。

但是当我们关闭浏览器时,浏览器不会主动在关闭之前通知服务器它将要关闭,所以服务器根本不会有机会知道浏览器已经关闭。之所以会有这种错觉,是因为大部分会话机制都使用会话 Cookie 来保存会话 ID 信息,而关闭浏览器后 Cookies 就消失了,再次连接服务器时,也就无法找到原来的会话了。如果服务器设置的 Cookies 保存到硬盘上,或者使用某种手段改写浏览器发出的 HTTP 请求头,把原来的 Cookies 发送给服务器,则再次打开浏览器,仍然能够找到原来的会话 ID,依旧还是可以保持登录状态的。

而且恰恰是由于关闭浏览器不会导致会话被删除,这就需要服务器为会话设置一个失效时间,当距离客户端上一次使用会话的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把会话删除以节省存储空间。

4. 参考资料

由于涉及一些专业名词知识,本节的部分内容参考来源如下。

Python

我们可以把互联网比作一张大网,而爬虫(即网络爬虫)便是在网上爬行的蜘蛛。把网的节点比作一个个网页,爬虫爬到这就相当于访问了该页面,获取了其信息。可以把节点间的连线比作网页与网页之间的链接关系,这样蜘蛛通过一个节点后,可以顺着节点连线继续爬行到达下一个节点,即通过一个网页继续获取后续的网页,这样整个网的节点便可以被蜘蛛全部爬行到,网站的数据就可以被抓取下来了。

1. 爬虫概述

简单来说,爬虫就是获取网页并提取和保存信息的自动化程序,下面概要介绍一下。

(1) 获取网页

爬虫首先要做的工作就是获取网页,这里就是获取网页的源代码。源代码里包含了网页的部分有用信息,所以只要把源代码获取下来,就可以从中提取想要的信息了。

前面讲了请求和响应的概念,向网站的服务器发送一个请求,返回的响应体便是网页源代码。所以,最关键的部分就是构造一个请求并发送给服务器,然后接收到响应并将其解析出来,那么这个流程怎样实现呢?总不能手工去截取网页源码吧?

不用担心,Python提供了许多库来帮助我们实现这个操作,如urllib、requests等。我们可以用这些库来帮助我们实现HTTP请求操作,请求和响应都可以用类库提供的数据结构来表示,得到响应之后只需要解析数据结构中的Body部分即可,即得到网页的源代码,这样我们可以用程序来实现获取网页的过程了。

(2) 提取信息

获取网页源代码后,接下来就是分析网页源代码,从中提取我们想要的数据。首先,最通用的方法便是采用正则表达式提取,这是一个万能的方法,但是在构造正则表达式时比较复杂且容易出错。

另外,由于网页的结构有一定的规则,所以还有一些根据网页节点属性、CSS选择器或XPath来提取网页信息的库,如Beautiful Soup、pyquery、lxml等。使用这些库,我们可以高效快速地从中提取网页信息,如节点的属性、文本值等。

提取信息是爬虫非常重要的部分,它可以使杂乱的数据变得条理清晰,以便我们后续处理和分析数据。

(3) 保存数据

提取信息后,我们一般会将提取到的数据保存到某处以便后续使用。这里保存形式有多种多样,如可以简单保存为TXT文本或JSON文本,也可以保存到数据库,如MySQL和MongoDB等,也可保存至远程服务器,如借助SFTP进行操作等。

(4) 自动化程序

说到自动化程序,意思是说爬虫可以代替人来完成这些操作。首先,我们手工当然可以提取这些信息,但是当量特别大或者想快速获取大量数据的话,肯定还是要借助程序。爬虫就是代替我们来完成这份爬取工作的自动化程序,它可以在抓取过程中进行各种异常处理、错误重试等操作,确保爬取持续高效地运行。

2. 能抓怎样的数据

在网页中我们能看到各种各样的信息,最常见的便是常规网页,它们对应着HTML代码,而最常抓取的便是HTML源代码。

另外,可能有些网页返回的不是HTML代码,而是一个JSON字符串(其中API接口大多采用这样的形式),这种格式的数据方便传输和解析,它们同样可以抓取,而且数据提取更加方便。

此外,我们还可以看到各种二进制数据,如图片、视频和音频等。利用爬虫,我们可以将这些二进制数据抓取下来,然后保存成对应的文件名。

另外,还可以看到各种扩展名的文件,如CSS、JavaScript和配置文件等,这些其实也是最普通的文件,只要在浏览器里面可以访问到,就可以将其抓取下来。

上述内容其实都对应各自的URL,是基于HTTP或HTTPS协议的,只要是这种数据,爬虫都可以抓取。

3. JavaScript渲染页面

有时候,我们在用urllib或requests抓取网页时,得到的源代码实际和浏览器中看到的不一样。

这是一个非常常见的问题。现在网页越来越多地采用Ajax、前端模块化工具来构建,整个网页可能都是由JavaScript渲染出来的,也就是说原始的HTML代码就是一个空壳,例如:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>This is a Demo</title>
</head>
<body>
<div id="container">
</div>
</body>
<script src="app.js"></script>
</html>

body节点里面只有一个idcontainer的节点,但是需要注意在body节点后引入了app.js,它便负责整个网站的渲染。

在浏览器中打开这个页面时,首先会加载这个HTML内容,接着浏览器会发现其中引入了一个app.js文件,然后便会接着去请求这个文件,获取到该文件后,便会执行其中的JavaScript代码,而JavaScript则会改变HTML中的节点,向其添加内容,最后得到完整的页面。

但是在用urllib或requests等库请求当前页面时,我们得到的只是这个HTML代码,它不会帮助我们去继续加载这个JavaScript文件,这样也就看不到浏览器中的内容了。

这也解释了为什么有时我们得到的源代码和浏览器中看到的不一样。

因此,使用基本HTTP请求库得到的源代码可能跟浏览器中的页面源代码不太一样。对于这样的情况,我们可以分析其后台Ajax接口,也可使用Selenium、Splash这样的库来实现模拟JavaScript渲染。

后面,我们会详细介绍如何采集JavaScript渲染的网页。

本节介绍了爬虫的一些基本原理,这可以帮助我们在后面编写爬虫时更加得心应手。

Python

用浏览器访问网站时,页面各不相同,你有没有想过它为何会呈现这个样子呢?本节中,我们就来了解一下网页的基本组成、结构和节点等内容。

1. 网页的组成

网页可以分为三大部分——HTML、CSS 和 JavaScript。如果把网页比作一个人的话,HTML 相当于骨架,JavaScript 相当于肌肉,CSS 相当于皮肤,三者结合起来才能形成一个完善的网页。下面我们分别来介绍一下这三部分的功能。

(1) HTML

HTML 是用来描述网页的一种语言,其全称叫作 Hyper Text Markup Language,即超文本标记语言。网页包括文字、按钮、图片和视频等各种复杂的元素,其基础架构就是 HTML。不同类型的文字通过不同类型的标签来表示,如图片用img标签表示,视频用video标签表示,段落用p标签表示,它们之间的布局又常通过布局标签div嵌套组合而成,各种标签通过不同的排列和嵌套才形成了网页的框架。

在 Chrome 浏览器中打开百度,右击并选择“检查”项(或按 F12 键),打开开发者模式,这时在 Elements 选项卡中即可看到网页的源代码,如图 2-9 所示。

图 2-9 源代码

这就是 HTML,整个网页就是由各种标签嵌套组合而成的。这些标签定义的节点元素相互嵌套和组合形成了复杂的层次关系,就形成了网页的架构。

(2) CSS

HTML 定义了网页的结构,但是只有 HTML 页面的布局并不美观,可能只是简单的节点元素的排列,为了让网页看起来更好看一些,这里借助了 CSS。

CSS,全称叫作 Cascading Style Sheets,即层叠样式表。“层叠”是指当在 HTML 中引用了数个样式文件,并且样式发生冲突时,浏览器能依据层叠顺序处理。“样式”指网页中文字大小、颜色、元素间距、排列等格式。

CSS 是目前唯一的网页页面排版样式标准,有了它的帮助,页面才会变得更为美观。

图 2-9 的右侧即为 CSS,例如:

1
2
3
4
5
6
#head_wrapper.s-ps-islite .s-p-top {
position: absolute;
bottom: 40px;
width: 100%;
height: 181px;
}

就是一个 CSS 样式。大括号前面是一个 CSS 选择器,此选择器的意思是首先选中idhead_wrapperclasss-ps-islite的节点,然后再选中其内部的classs-p-top的节点。大括号内部写的就是一条条样式规则,例如position指定了这个元素的布局方式为绝对布局,bottom指定元素的下边距为 40 像素,width指定了宽度为 100%占满父元素,height则指定了元素的高度。也就是说,我们将位置、宽度、高度等样式配置统一写成这样的形式,然后用大括号括起来,接着在开头再加上 CSS 选择器,这就代表这个样式对 CSS 选择器选中的元素生效,元素就会根据此样式来展示了。

在网页中,一般会统一定义整个网页的样式规则,并写入 CSS 文件中(其后缀为 css)。在 HTML 中,只需要用link标签即可引入写好的 CSS 文件,这样整个页面就会变得美观、优雅。

(3) JavaScript

JavaScript,简称 JS,是一种脚本语言。HTML 和 CSS 配合使用,提供给用户的只是一种静态信息,缺乏交互性。我们在网页里可能会看到一些交互和动画效果,如下载进度条、提示框、轮播图等,这通常就是 JavaScript 的功劳。它的出现使得用户与信息之间不只是一种浏览与显示的关系,而是实现了一种实时、动态、交互的页面功能。

JavaScript 通常也是以单独的文件形式加载的,后缀为 js,在 HTML 中通过script标签即可引入,例如:

1
<script src="jquery-2.1.0.js"></script>

综上所述,HTML 定义了网页的内容和结构,CSS 描述了网页的布局,JavaScript 定义了网页的行为。

2. 网页的结构

我们首先用例子来感受一下 HTML 的基本结构。新建一个文本文件,名称可以自取,后缀为 html,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>This is a Demo</title>
</head>
<body>
<div id="container">
<div class="wrapper">
<h2 class="title">Hello World</h2>
<p class="text">Hello, this is a paragraph.</p>
</div>
</div>
</body>
</html>

这就是一个最简单的 HTML 实例。开头用DOCTYPE定义了文档类型,其次最外层是html标签,最后还有对应的结束标签来表示闭合,其内部是head标签和body标签,分别代表网页头和网页体,它们也需要结束标签。head标签内定义了一些页面的配置和引用,如:

1
<meta charset="UTF-8">

它指定了网页的编码为 UTF-8。

title标签则定义了网页的标题,会显示在网页的选项卡中,不会显示在正文中。body标签内则是在网页正文中显示的内容。div标签定义了网页中的区块,它的idcontainer,这是一个非常常用的属性,且id的内容在网页中是唯一的,我们可以通过它来获取这个区块。然后在此区块内又有一个div标签,它的classwrapper,这也是一个非常常用的属性,经常与 CSS 配合使用来设定样式。然后此区块内部又有一个h2标签,这代表一个二级标题。另外,还有一个p标签,这代表一个段落。在这两者中直接写入相应的内容即可在网页中呈现出来,它们也有各自的class属性。

将代码保存后,在浏览器中打开该文件,可以看到如图 2-10 所示的内容。

图 2-10 运行结果

可以看到,在选项卡上显示了 This is a Demo 字样,这是我们在head中的title里定义的文字。而网页正文是body标签内部定义的各个元素生成的,可以看到这里显示了二级标题和段落。

这个实例便是网页的一般结构。一个网页的标准形式是html标签内嵌套headbody标签,head内定义网页的配置和引用,body内定义网页的正文。

3. 节点树及节点间的关系

在 HTML 中,所有标签定义的内容都是节点,它们构成了一个 HTML DOM 树。

我们先看下什么是 DOM,DOM 是 W3C(万维网联盟)的标准,其英文全称 Document Object Model,即文档对象模型。它定义了访问 HTML 和 XML 文档的标准:

W3C 文档对象模型(DOM)是中立于平台和语言的接口,它允许程序和脚本动态地访问和更新文档的内容、结构和样式。

W3C DOM 标准被分为 3 个不同的部分。

  • 核心 DOM: 针对任何结构化文档的标准模型。
  • XML DOM:针对 XML 文档的标准模型。
  • HTML DOM:针对 HTML 文档的标准模型。

根据 W3C 的 HTML DOM 标准,HTML 文档中的所有内容都是节点。

  • 整个文档是一个文档节点;
  • 每个 HTML 元素是元素节点;
  • HTML 元素内的文本是文本节点;
  • 每个 HTML 属性是属性节点;
  • 注释是注释节点。

HTML DOM 将 HTML 文档视作树结构,这种结构被称为节点树,如图 2-11 所示。

图 2-11 节点树

通过 HTML DOM,树中的所有节点均可通过 JavaScript 访问,所有 HTML 节点元素均可被修改,也可以被创建或删除。

节点树中的节点彼此拥有层级关系。我们常用父(parent)、子(child)和兄弟(sibling)等术语描述这些关系。父节点拥有子节点,同级的子节点被称为兄弟节点。

在节点树中,顶端节点称为根(root)。除了根节点之外,每个节点都有父节点,同时可拥有任意数量的子节点或兄弟节点。图 2-12 展示了节点树以及节点之间的关系。

图 2-12 节点树及节点间的关系

本段参考 W3SCHOOL,链接:http://www.w3school.com.cn/htmldom/dom_nodes.asp

4. 选择器

我们知道网页由一个个节点组成,CSS 选择器会根据不同的节点设置不同的样式规则,那么怎样来定位节点呢?

在 CSS 中,我们使用 CSS 选择器来定位节点。例如,上例中div节点的idcontainer,那么就可以表示为#container,其中#开头代表选择id,其后紧跟id的名称。另外,如果我们想选择classwrapper的节点,便可以使用.wrapper,这里以点(.)开头代表选择class,其后紧跟class的名称。另外,还有一种选择方式,那就是根据标签名筛选,例如想选择二级标题,直接用h2即可。这是最常用的 3 种表示,分别是根据idclass、标签名筛选,请牢记它们的写法。

另外,CSS 选择器还支持嵌套选择,各个选择器之间加上空格分隔开便可以代表嵌套关系,如#container .wrapper p则代表先选择idcontainer的节点,然后选中其内部的classwrapper的节点,然后再进一步选中其内部的p节点。另外,如果不加空格,则代表并列关系,如div#container .wrapper p.text代表先选择idcontainerdiv节点,然后选中其内部的classwrapper的节点,再进一步选中其内部的classtextp节点。这就是 CSS 选择器,其筛选功能还是非常强大的。

另外,CSS 选择器还有一些其他语法规则,具体如表 2-4 所示。

表 2-4 CSS 选择器的其他语法规则

选择器

例子

例子描述

.class

.intro

选择class="intro"的所有节点

#id

#firstname

选择id="firstname"的所有节点

*

*

选择所有节点

element

p

选择所有p节点

element,element

div,p

选择所有div节点和所有p节点

element element

div p

选择div节点内部的所有p节点

element>element

div>p

选择父节点为div节点的所有p节点

element+element

div+p

选择紧接在div节点之后的所有p节点

[attribute]

[target]

选择带有target属性的所有节点

[attribute=value]

[target=blank]

选择target="blank"的所有节点

[attribute~=value]

[title~=flower]

选择title属性包含单词flower的所有节点

:link

a:link

选择所有未被访问的链接

:visited

a:visited

选择所有已被访问的链接

:active

a:active

选择活动链接

:hover

a:hover

选择鼠标指针位于其上的链接

:focus

input:focus

选择获得焦点的input节点

:first-letter

p:first-letter

选择每个p节点的首字母

:first-line

p:first-line

选择每个p节点的首行

:first-child

p:first-child

选择属于父节点的第一个子节点的所有p节点

:before

p:before

在每个p节点的内容之前插入内容

:after

p:after

在每个p节点的内容之后插入内容

:lang(language)

p:lang

选择带有以it开头的lang属性值的所有p节点

element1~element2

p~ul

选择前面有p节点的所有ul节点

[attribute^=value]

a[src^="https"]

选择其src属性值以https开头的所有a节点

[attribute$=value]

a[src$=".pdf"]

选择其src属性以.pdf结尾的所有a节点

[attribute*=value]

a[src*="abc"]

选择其src属性中包含abc子串的所有a节点

:first-of-type

p:first-of-type

选择属于其父节点的首个p节点的所有p节点

:last-of-type

p:last-of-type

选择属于其父节点的最后p节点的所有p节点

:only-of-type

p:only-of-type

选择属于其父节点唯一的p节点的所有p节点

:only-child

p:only-child

选择属于其父节点的唯一子节点的所有p节点

:nth-child(n)

p:nth-child

选择属于其父节点的第二个子节点的所有p节点

:nth-last-child(n)

p:nth-last-child

同上,从最后一个子节点开始计数

:nth-of-type(n)

p:nth-of-type

选择属于其父节点第二个p节点的所有p节点

:nth-last-of-type(n)

p:nth-last-of-type

同上,但是从最后一个子节点开始计数

:last-child

p:last-child

选择属于其父节点最后一个子节点的所有p节点

:root

:root

选择文档的根节点

:empty

p:empty

选择没有子节点的所有p节点(包括文本节点)

:target

#news:target

选择当前活动的#news节点

:enabled

input:enabled

选择每个启用的input节点

:disabled

input:disabled

选择每个禁用的input节点

:checked

input:checked

选择每个被选中的input节点

:not(selector)

:not

选择非p节点的所有节点

::selection

::selection

选择被用户选取的节点部分

另外,还有一种比较常用的选择器是 XPath,这种选择方式后面会详细介绍。

本节介绍了网页的基本结构和节点间的关系,了解了这些内容,我们才有更加清晰的思路去解析和提取网页内容。

Python

在本节中,我们会详细了解 HTTP 的基本原理,了解在浏览器中敲入 URL 到获取网页内容之间发生了什么。了解了这些内容,有助于我们进一步了解爬虫的基本原理。

1. URI 和 URL

这里我们先了解一下 URI 和 URL,URI 的全称为 Uniform Resource Identifier,即统一资源标志符,URL 的全称为 Universal Resource Locator,即统一资源定位符。

举例来说,https://github.com/favicon.ico是 GitHub 的网站图标链接,它是一个 URL,也是一个 URI。即有这样的一个图标资源,我们用 URL/URI 来唯一指定了它的访问方式,这其中包括了访问协议 https、访问路径(/即根目录)和资源名称 favicon.ico。通过这样一个链接,我们便可以从互联网上找到这个资源,这就是 URL/URI。

URL 是 URI 的子集,也就是说每个 URL 都是 URI,但不是每个 URI 都是 URL。那么,怎样的 URI 不是 URL 呢?URI 还包括一个子类叫作 URN,它的全称为 Universal Resource Name,即统一资源名称。URN 只命名资源而不指定如何定位资源,比如 urn:isbn:0451450523 指定了一本书的 ISBN,可以唯一标识这本书,但是没有指定到哪里定位这本书,这就是 URN。URL、URN 和 URI 的关系可以用图 2-1 表示。

图 2-1 URL、URN 和 URI 关系图

但是在目前的互联网中,URN 用得非常少,所以几乎所有的 URI 都是 URL,一般的网页链接我们既可以称为 URL,也可以称为 URI,我个人习惯称为 URL。

2. 超文本

接下来,我们再了解一个概念——超文本,其英文名称叫作 hypertext,我们在浏览器里看到的网页就是超文本解析而成的,其网页源代码是一系列 HTML 代码,里面包含了一系列标签,比如img显示图片,p指定显示段落等。浏览器解析这些标签后,便形成了我们平常看到的网页,而网页的源代码 HTML 就可以称作超文本。

例如,我们在 Chrome 浏览器里面打开任意一个页面,如淘宝首页,右击任一地方并选择“检查”项(或者直接按快捷键 F12),即可打开浏览器的开发者工具,这时在 Elements 选项卡即可看到当前网页的源代码,这些源代码都是超文本,如图 2-2 所示。

图 2-2 源代码

3. HTTP 和 HTTPS

在淘宝的首页https://www.taobao.com/中,URL 的开头会有 http 或 https,这就是访问资源需要的协议类型。有时,我们还会看到 ftp、sftp、smb 开头的 URL,它们都是协议类型。在爬虫中,我们抓取的页面通常就是 http 或 https 协议的,这里首先了解一下这两个协议的含义。

HTTP 的全称是 Hyper Text Transfer Protocol,中文名叫作超文本传输协议。HTTP 协议是用于从网络传输超文本数据到本地浏览器的传送协议,它能保证高效而准确地传送超文本文档。HTTP 由万维网协会(World Wide Web Consortium)和 Internet 工作小组 IETF(Internet Engineering Task Force)共同合作制定的规范,目前广泛使用的是 HTTP 1.1 版本。

HTTPS 的全称是 Hyper Text Transfer Protocol over Secure Socket Layer,是以安全为目标的 HTTP 通道,简单讲是 HTTP 的安全版,即 HTTP 下加入 SSL 层,简称为 HTTPS。

HTTPS 的安全基础是 SSL,因此通过它传输的内容都是经过 SSL 加密的,它的主要作用可以分为两种。

  • 建立一个信息安全通道来保证数据传输的安全。
  • 确认网站的真实性,凡是使用了 HTTPS 的网站,都可以通过点击浏览器地址栏的锁头标志来查看网站认证之后的真实信息,也可以通过 CA 机构颁发的安全签章来查询。

现在越来越多的网站和 App 都已经向 HTTPS 方向发展,例如:

  • 苹果公司强制所有 iOS App 在 2017 年 1 月 1 日前全部改为使用 HTTPS 加密,否则 App 就无法在应用商店上架;
  • 谷歌从 2017 年 1 月推出的 Chrome 56 开始,对未进行 HTTPS 加密的网址链接亮出风险提示,即在地址栏的显著位置提醒用户“此网页不安全”;
  • 腾讯微信小程序的官方需求文档要求后台使用 HTTPS 请求进行网络通信,不满足条件的域名和协议无法请求。

而某些网站虽然使用了 HTTPS 协议,但还是会被浏览器提示不安全,例如我们在 Chrome 浏览器里面打开 12306,链接为:https://www.12306.cn/,这时浏览器就会提示“您的连接不是私密连接”这样的话,如图 2-3 所示。

图 2-3 12306 页面

这是因为 12306 的 CA 证书是中国铁道部自行签发的,而这个证书是不被 CA 机构信任的,所以这里证书验证就不会通过而提示这样的话,但是实际上它的数据传输依然是经过 SSL 加密的。如果要爬取这样的站点,就需要设置忽略证书的选项,否则会提示 SSL 链接错误。

4. HTTP 请求过程

我们在浏览器中输入一个 URL,回车之后便会在浏览器中观察到页面内容。实际上,这个过程是浏览器向网站所在的服务器发送了一个请求,网站服务器接收到这个请求后进行处理和解析,然后返回对应的响应,接着传回给浏览器。响应里包含了页面的源代码等内容,浏览器再对其进行解析,便将网页呈现了出来,模型如图 2-4 所示。

图 2-4 模型图

此处客户端即代表我们自己的 PC 或手机浏览器,服务器即要访问的网站所在的服务器。

为了更直观地地说明这个过程,这里用 Chrome 浏览器的开发者模式下的 Network 监听组件来做下演示,它可以显示访问当前请求网页时发生的所有网络请求和响应。

打开 Chrome 浏览器,右击并选择“检查”项,即可打开浏览器的开发者工具。这里访问百度http://www.baidu.com/,输入该 URL 后回车,观察这个过程中发生了怎样的网络请求。可以看到,在 Network 页面下方出现了一个个的条目,其中一个条目就代表一次发送请求和接收响应的过程,如图 2-5 所示。

图 2-5 Network 面板

我们先观察第一个网络请求,即www.baidu.com。

其中各列的含义如下。

  • 第一列 Name:请求的名称,一般会将 URL 的最后一部分内容当作名称。
  • 第二列 Status:响应的状态码,这里显示为 200,代表响应是正常的。通过状态码,我们可以判断发送了请求之后是否得到了正常的响应。
  • 第三列 Type:请求的文档类型。这里为 document,代表我们这次请求的是一个 HTML 文档,内容就是一些 HTML 代码。
  • 第四列 Initiator:请求源。用来标记请求是由哪个对象或进程发起的。
  • 第五列 Size:从服务器下载的文件和请求的资源大小。如果是从缓存中取得的资源,则该列会显示 from cache。
  • 第六列 Time:发起请求到获取响应所用的总时间。
  • 第七列 Waterfall:网络请求的可视化瀑布流。

点击这个条目,即可看到更详细的信息,如图 2-6 所示。

图 2-6 详细信息

首先是 General 部分,Request URL 为请求的 URL,Request Method 为请求的方法,Status Code 为响应状态码,Remote Address 为远程服务器的地址和端口,Referrer Policy 为 Referrer 判别策略。

再继续往下看,可以看到,有 Response Headers 和 Request Headers,这分别代表响应头和请求头。请求头里带有许多请求信息,例如浏览器标识、Cookies、Host 等信息,这是请求的一部分,服务器会根据请求头内的信息判断请求是否合法,进而作出对应的响应。图中看到的 Response Headers 就是响应的一部分,例如其中包含了服务器的类型、文档类型、日期等信息,浏览器接受到响应后,会解析响应内容,进而呈现网页内容。

下面我们分别来介绍一下请求和响应都包含哪些内容。

5. 请求

请求,由客户端向服务端发出,可以分为 4 部分内容:请求方法(Request Method)、请求的网址(Request URL)、请求头(Request Headers)、请求体(Request Body)。

(1) 请求方法

常见的请求方法有两种:GET 和 POST。

在浏览器中直接输入 URL 并回车,这便发起了一个 GET 请求,请求的参数会直接包含到 URL 里。例如,在百度中搜索 Python,这就是一个 GET 请求,链接为https://www.baidu.com/s?wd=Python,其中 URL 中包含了请求的参数信息,这里参数wd表示要搜寻的关键字。POST 请求大多在表单提交时发起。比如,对于一个登录表单,输入用户名和密码后,点击“登录”按钮,这通常会发起一个 POST 请求,其数据通常以表单的形式传输,而不会体现在 URL 中。

GET 和 POST 请求方法有如下区别。

  • GET 请求中的参数包含在 URL 里面,数据可以在 URL 中看到,而 POST 请求的 URL 不会包含这些数据,数据都是通过表单形式传输的,会包含在请求体中。
  • GET 请求提交的数据最多只有 1024 字节,而 POST 方式没有限制。

一般来说,登录时,需要提交用户名和密码,其中包含了敏感信息,使用 GET 方式请求的话,密码就会暴露在 URL 里面,造成密码泄露,所以这里最好以 POST 方式发送。上传文件时,由于文件内容比较大,也会选用 POST 方式。

我们平常遇到的绝大部分请求都是 GET 或 POST 请求,另外还有一些请求方法,如 GET、HEAD、POST、PUT、DELETE、OPTIONS、CONNECT、TRACE 等,我们简单将其总结为表 2-1。

表 2-1 其他请求方法

方法

描述

GET

请求页面,并返回页面内容

HEAD

类似于 GET 请求,只不过返回的响应中没有具体的内容,用于获取报头

POST

大多用于提交表单或上传文件,数据包含在请求体中

PUT

从客户端向服务器传送的数据取代指定文档中的内容

DELETE

请求服务器删除指定的页面

CONNECT

把服务器当作跳板,让服务器代替客户端访问其他网页

OPTIONS

允许客户端查看服务器的性能

TRACE

回显服务器收到的请求,主要用于测试或诊断

本表参考:http://www.runoob.com/http/http-methods.html

(2) 请求的网址

请求的网址,即统一资源定位符 URL,它可以唯一确定我们想请求的资源。

(3) 请求头

请求头,用来说明服务器要使用的附加信息,比较重要的信息有 Cookie、Referer、User-Agent 等。下面简要说明一些常用的头信息。

  • Accept:请求报头域,用于指定客户端可接受哪些类型的信息。
  • Accept-Language:指定客户端可接受的语言类型。
  • Accept-Encoding:指定客户端可接受的内容编码。
  • Host:用于指定请求资源的主机 IP 和端口号,其内容为请求 URL 的原始服务器或网关的位置。从 HTTP 1.1 版本开始,请求必须包含此内容。
  • Cookie:也常用复数形式 Cookies,这是网站为了辨别用户进行会话跟踪而存储在用户本地的数据。它的主要功能是维持当前访问会话。例如,我们输入用户名和密码成功登录某个网站后,服务器会用会话保存登录状态信息,后面我们每次刷新或请求该站点的其他页面时,会发现都是登录状态,这就是 Cookies 的功劳。Cookies 里有信息标识了我们所对应的服务器的会话,每次浏览器在请求该站点的页面时,都会在请求头中加上 Cookies 并将其发送给服务器,服务器通过 Cookies 识别出是我们自己,并且查出当前状态是登录状态,所以返回结果就是登录之后才能看到的网页内容。
  • Referer:此内容用来标识这个请求是从哪个页面发过来的,服务器可以拿到这一信息并做相应的处理,如作来源统计、防盗链处理等。
  • User-Agent:简称 UA,它是一个特殊的字符串头,可以使服务器识别客户使用的操作系统及版本、浏览器及版本等信息。在做爬虫时加上此信息,可以伪装为浏览器;如果不加,很可能会被识别出为爬虫。
  • Content-Type:也叫互联网媒体类型(Internet Media Type)或者 MIME 类型,在 HTTP 协议消息头中,它用来表示具体请求中的媒体类型信息。例如,text/html 代表 HTML 格式,image/gif 代表 GIF 图片,application/json 代表 JSON 类型,更多对应关系可以查看此对照表:http://tool.oschina.net/commons

因此,请求头是请求的重要组成部分,在写爬虫时,大部分情况下都需要设定请求头。

(4) 请求体

请求体一般承载的内容是 POST 请求中的表单数据,而对于 GET 请求,请求体则为空。

例如,这里我登录 GitHub 时捕获到的请求和响应如图 2-7 所示。

图 2-7 详细信息

登录之前,我们填写了用户名和密码信息,提交时这些内容就会以表单数据的形式提交给服务器,此时需要注意 Request Headers 中指定 Content-Type 为 application/x-www-form-urlencoded。只有设置 Content-Type 为 application/x-www-form-urlencoded,才会以表单数据的形式提交。另外,我们也可以将 Content-Type 设置为 application/json 来提交 JSON 数据,或者设置为 multipart/form-data 来上传文件。

表 2-2 列出了 Content-Type 和 POST 提交数据方式的关系。

表 2-2 Content-Type 和 POST 提交数据方式的关系

Content-Type

提交数据的方式

application/x-www-form-urlencoded

表单数据

multipart/form-data

表单文件上传

application/json

序列化 JSON 数据

text/xml

XML 数据

在爬虫中,如果要构造 POST 请求,需要使用正确的 Content-Type,并了解各种请求库的各个参数设置时使用的是哪种 Content-Type,不然可能会导致 POST 提交后无法正常响应。

6. 响应

响应,由服务端返回给客户端,可以分为三部分:响应状态码(Response Status Code)、响应头(Response Headers)和响应体(Response Body)。

(1) 响应状态码

响应状态码表示服务器的响应状态,如 200 代表服务器正常响应,404 代表页面未找到,500 代表服务器内部发生错误。在爬虫中,我们可以根据状态码来判断服务器响应状态,如状态码为 200,则证明成功返回数据,再进行进一步的处理,否则直接忽略。表 2-3 列出了常见的错误代码及错误原因。

表 2-3 常见的错误代码及错误原因

状态码

说明

详情

100

继续

请求者应当继续提出请求。服务器已收到请求的一部分,正在等待其余部分

101

切换协议

请求者已要求服务器切换协议,服务器已确认并准备切换

200

成功

服务器已成功处理了请求

201

已创建

请求成功并且服务器创建了新的资源

202

已接受

服务器已接受请求,但尚未处理

203

非授权信息

服务器已成功处理了请求,但返回的信息可能来自另一个源

204

无内容

服务器成功处理了请求,但没有返回任何内容

205

重置内容

服务器成功处理了请求,内容被重置

206

部分内容

服务器成功处理了部分请求

300

多种选择

针对请求,服务器可执行多种操作

301

永久移动

请求的网页已永久移动到新位置,即永久重定向

302

临时移动

请求的网页暂时跳转到其他页面,即暂时重定向

303

查看其他位置

如果原来的请求是 POST,重定向目标文档应该通过 GET 提取

304

未修改

此次请求返回的网页未修改,继续使用上次的资源

305

使用代理

请求者应该使用代理访问该网页

307

临时重定向

请求的资源临时从其他位置响应

400

错误请求

服务器无法解析该请求

401

未授权

请求没有进行身份验证或验证未通过

403

禁止访问

服务器拒绝此请求

404

未找到

服务器找不到请求的网页

405

方法禁用

服务器禁用了请求中指定的方法

406

不接受

无法使用请求的内容响应请求的网页

407

需要代理授权

请求者需要使用代理授权

408

请求超时

服务器请求超时

409

冲突

服务器在完成请求时发生冲突

410

已删除

请求的资源已永久删除

411

需要有效长度

服务器不接受不含有效内容长度标头字段的请求

412

未满足前提条件

服务器未满足请求者在请求中设置的其中一个前提条件

413

请求实体过大

请求实体过大,超出服务器的处理能力

414

请求 URI 过长

请求网址过长,服务器无法处理

415

不支持类型

请求格式不被请求页面支持

416

请求范围不符

页面无法提供请求的范围

417

未满足期望值

服务器未满足期望请求标头字段的要求

500

服务器内部错误

服务器遇到错误,无法完成请求

501

未实现

服务器不具备完成请求的功能

502

错误网关

服务器作为网关或代理,从上游服务器收到无效响应

503

服务不可用

服务器目前无法使用

504

网关超时

服务器作为网关或代理,但是没有及时从上游服务器收到请求

505

HTTP 版本不支持

服务器不支持请求中所用的 HTTP 协议版本

(2) 响应头

响应头包含了服务器对请求的应答信息,如 Content-Type、Server、Set-Cookie 等。下面简要说明一些常用的头信息。

  • Date:标识响应产生的时间。
  • Last-Modified:指定资源的最后修改时间。
  • Content-Encoding:指定响应内容的编码。
  • Server:包含服务器的信息,比如名称、版本号等。
  • Content-Type:文档类型,指定返回的数据类型是什么,如 text/html 代表返回 HTML 文档,application/x-javascript 则代表返回 JavaScript 文件,image/jpeg 则代表返回图片。
  • Set-Cookie:设置 Cookies。响应头中的 Set-Cookie 告诉浏览器需要将此内容放在 Cookies 中,下次请求携带 Cookies 请求。
  • Expires:指定响应的过期时间,可以使代理服务器或浏览器将加载的内容更新到缓存中。如果再次访问时,就可以直接从缓存中加载,降低服务器负载,缩短加载时间。

(3) 响应体

最重要的当属响应体的内容了。响应的正文数据都在响应体中,比如请求网页时,它的响应体就是网页的 HTML 代码;请求一张图片时,它的响应体就是图片的二进制数据。我们做爬虫请求网页后,要解析的内容就是响应体,如图 2-8 所示。

图 2-8 响应体内容

在浏览器开发者工具中点击 Preview,就可以看到网页的源代码,也就是响应体的内容,它是解析的目标。

在做爬虫时,我们主要通过响应体得到网页的源代码、JSON 数据等,然后从中做相应内容的提取。

本节中,我们了解了 HTTP 的基本原理,大概了解了访问网页时背后的请求和响应过程。本节涉及的知识点需要好好掌握,后面分析网页请求时会经常用到。

Python

在写爬虫之前,我们还需要了解一些基础知识,如HTTP原理、网页的基础知识、爬虫的基本原理、Cookies的基本原理等。本章中,我们就对这些基础知识做一个简单的总结。

Python

本节来说明一下 JavaScript 加密逻辑分析并利用 Python 模拟执行 JavaScript 实现数据爬取的过程。在这里以中国空气质量在线监测分析平台为例来进行分析,主要分析其加密逻辑及破解方法,并利用 PyExecJS 来实现 JavaScript 模拟执行来实现该网站的数据爬取。

疑难杂症

中国空气质量在线监测分析平台是一个收录全国各大城市天气数据的网站,包括温度、湿度、PM 2.5、AQI 等数据,链接为:https://www.aqistudy.cn/html/city_detail.html,预览图如下: 通过这个网站我们可以获取到各大城市任何一天的天气数据,对数据分析还是非常有用的。 然而不幸的是,该网站的数据接口通信都被加密了。经过分析之后发现其页面数据是通过 Ajax 加载的,数据接口地址是:https://www.aqistudy.cn/apinew/aqistudyapi.php,是一个 POST 形式访问的接口,这个接口的请求数据和返回数据都被加密了,即 POST 请求的 Data、返回的数据都被加密了,下图是数据接口的 Form Data 部分,可见传输数据是一个加密后的字符串: 下图是该接口返回的内容,同样是经过加密的字符串: 遇到这种接口加密的情况,一般来说我们会选择避开请求接口的方式进行数据爬取,如使用 Selenium 模拟浏览器来执行。但这个网站的数据是图表展示的,所以其数据会变得难以提取。 那怎么办呢?刚啊!

一刚到底

之前的老法子都行不通了,那就只能上了!接下来我们就不得不去分析这个网站接口的加密逻辑,并通过一些技巧来破解这个接口了。 首先找到突破口,当我们点击了这个搜索按钮之后,后台便会发出 Ajax 请求,说明这个点击动作是被监听的,所以我们可以找一下这个点击事件对应的处理代码在哪里,这里可以借助于 Firefox 来实现,它可以分析页面某个元素的绑定事件以及定位到具体的代码在哪一行,如图所示: 这里我们发现这个搜索按钮绑定了三个事件,blur、click、focus,同时 Firefox 还帮助我们列出来了对应事件的处理函数在哪个代码的哪一行,这里可以看到 click 事件是在 city_detail.html 的第 139 行处理的,而且是调用了 getData() 函数。 接下来我们就可以顺藤摸瓜,找到 city_detail.html 文件的 getData() 函数,然后再找到这个函数的定义即可,很容易地,我们在 city_detail.html 的第 463 行就找到了这个函数的定义: 经过分析发现它又调用了 getAQIData() 和 getWeatherData() 两个方法,而这两个方法的声明就在下面,再进一步分析发现这两个方法都调用了 getServerData() 这个方法,并传递了 method、param 等参数,然后还有一个回调函数很明显是对返回数据进行处理的,这说明 Ajax 请求就是由这个 getServerData() 方法发起的,如图所示: 所以这里我们只需要再找到 getServerData() 方法的定义即可分析它的加密逻辑了。继续搜索,然而在原始 html 文件中没有搜索到该方法,那就继续去搜寻其他的 JavaScript 文件有没有这个定义,终于经过一番寻找,居然在 jquery-1.8.0.min.js 这个文件中找到了: 有的小伙伴可能会说,jquery.min.js 不是一个库文件吗,怎么会有这种方法声明?嗯,我只想说,最危险的地方就是最安全的地方。 好了,现在终于找到这个方法了,可为什么看不懂呢?这个方法名后面怎么直接跟了一些奇怪的字符串,而且不符合一般的 JavaScript 写法。其实这里是经过 JavaScript 混淆加密了,混淆加密之后,代码将变为不可读的形式,但是功能是完全一致的,这是一种常见的 JavaScript 加密手段。 那到这里了该怎么解呢?当然是接着刚啊!

反混淆

JavaScript 混淆之后,其实是有反混淆方法的,最简单的方法便是搜索在线反混淆网站,这里提供一个:http://www.bm8.com.cn/jsConfusion/,我们将 jquery-1.8.0.min.js 中第二行 eval 开头的混淆后的 JavaScript 代码复制一下,然后粘贴到这个网站中进行反混淆,就可以看到正常的 JavaScript 代码了,搜索一下就可以找到 getServerData() 方法了,可以看到这个方法确实发出了一个 Ajax 请求,请求了刚才我们分析到的接口: 那么到这里我们又可以发现一个很关键的方法,那就是 getParam(),它接受了 method 和 object 参数,然后返回得到的 param 结果就作为 POST Data 参数请求接口了,所以 param 就是加密后的 POST Data,一些加密逻辑都在 getParam() 方法里面,其方法实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var getParam = (function () {
function ObjectSort(obj) {
var newObject = {};
Object.keys(obj).sort().map(function (key) {
newObject[key] = obj[key]
});
return newObject
}
return function (method, obj) {
var appId = '1a45f75b824b2dc628d5955356b5ef18';
var clienttype = 'WEB';
var timestamp = new Date().getTime();
var param = {
appId: appId,
method: method,
timestamp: timestamp,
clienttype: clienttype,
object: obj,
secret: hex_md5(appId + method + timestamp + clienttype + JSON.stringify(ObjectSort(obj)))
};
param = BASE64.encrypt(JSON.stringify(param));
return AES.encrypt(param, aes_client_key, aes_client_iv)
}
})();

可以看到这里使用了 Base64 和 AES 加密。加密之后的字符串便作为 POST Data 传送给服务器了,然后服务器再进行解密处理,然后进行逻辑处理,然后再对处理后的数据进行加密,返回了加密后的数据,那么 JavaScript 再接收到之后再进行一次解密,再渲染才能得到正常的结果。 所以这里还需要分析服务器传回的数据是怎样解密的。顺腾摸瓜,很容易就找到一个 decodeData() 方法,其定义如下:

1
2
3
4
5
6
function decodeData(data) {
data = AES.decrypt(data, aes_server_key, aes_server_iv);
data = DES.decrypt(data, des_key, des_iv);
data = BASE64.decrypt(data);
return data
}

嗯,这里又经过了三层解密,才把正常的明文数据解析出来。 所以一切都清晰了,我们需要实现两个过程才能正常使用这个接口,即实现 POST Data 的加密过程和 Response Data 的解密过程。其中 POST Data 的加密过程是 Base64 + AES 加密,Response Data 的解密是 AES + DES + Base64 解密。加密解密的 Key 也都在 JavaScript 文件里能找到,我们用 Python 实现这些加密解密过程就可以了。 所以接下来怎么办?接着刚啊! 接着刚才怪! 何必去费那些事去用 Python 重写一遍 JavaScript,万一二者里面有数据格式不统一或者二者由于语言不兼容问题导致计算结果偏差,上哪里去 Debug? 那怎么办?这里我们借助于 PyExecJS 库来实现 JavaScript 模拟就好了。

PyExecJS

PyExecJS 是一个可以使用 Python 来模拟运行 JavaScript 的库。大家可能听说过 PyV8,它也是用来模拟执行 JavaScript 的库,可是由于这个项目已经不维护了,而且对 Python3 的支持不好,而且安装出现各种问题,所以这里选用了 PyExecJS 库来代替它。 首先我们来安装一下这个库:

1
pip install PyExecJS

使用 pip 安装即可。 在使用这个库之前请确保你的机器上安装了以下其中一个 JS 运行环境:

  • JScript
  • JavaScriptCore
  • Nashorn
  • Node
  • PhantomJS
  • PyV8
  • SlimerJS
  • SpiderMonkey

PyExecJS 库会按照优先级调用这些引擎来实现 JavaScript 执行,这里推荐安装 Node.js 或 PhantomJS。 接着我们运行代码检查一下运行环境:

1
2
import execjs
print(execjs.get().name)

运行之后,由于我安装了 Node.js,所以这里会使用 Node.js 作为渲染引擎,结果如下:

1
Node.js (V8)

接下来我们将刚才反混淆的 JavaScript 保存成一个文件,叫做 encryption.js,然后用 PyExecJS 模拟运行相关的方法即可。 首先我们来实现加密过程,这里 getServerData() 方法其实已经帮我们实现好了,并实现了 Ajax 请求,但这个方法里面有获取 Storage 的方法,Node.js 不适用,所以这里我们直接改写下,实现一个 getEncryptedData() 方法实现加密,在 encryption.js 里面实现如下方法:

1
2
3
4
5
6
7
8
function getEncryptedData(method, city, type, startTime, endTime) {
var param = {};
param.city = city;
param.type = type;
param.startTime = startTime;
param.endTime = endTime;
return getParam(method, param);
}

接着我们模拟执行这些方法即可:

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

# Init environment
node = execjs.get()

# Params
method = 'GETCITYWEATHER'
city = '北京'
type = 'HOUR'
start_time = '2018-01-25 00:00:00'
end_time = '2018-01-25 23:00:00'

# Compile javascript
file = 'encryption.js'
ctx = node.compile(open(file).read())

# Get params
js = 'getEncryptedData("{0}", "{1}", "{2}", "{3}", "{4}")'.format(method, city, type, start_time, end_time)
params = ctx.eval(js)

这里我们首先定义一些参数,如 method、city、start_time 等,这些都可以通过分析 JavaScript 很容易得出其规则。 然后这里首先通过 execjs(即 PyExecJS)的 get() 方法声明一个运行环境,然后调用 compile() 方法来执行刚才保存下来的加密库 encryption.js,因为这里面包含了一些加密方法和自定义方法,所以只有执行一遍才能调用。 接着我们再构造一个 js 字符串,传递这些参数,然后通过 eval() 方法来模拟执行,得到的结果赋值为 params,这个就是 POST Data 的加密数据。 接着我们直接用 requests 库来模拟 POST 请求就好了,也没必要用 jQuery 自带的 Ajax 了,当然后者也是可行的,只不过需要加载一下 jQuery 库。 接着我们用 requests 库来模拟 POST 请求:

1
2
3
# Get encrypted response text
api = 'https://www.aqistudy.cn/apinew/aqistudyapi.php'
response = requests.post(api, data={'d': params})

这样 response 的内容就是服务器返回的加密的内容了。 接下来我们再调用一下 JavaScript 中的 decodeData() 方法即可实现解密:

1
2
3
# Decode data
js = 'decodeData("{0}")'.format(response.text)
decrypted_data = ctx.eval(js)

这样 decrypted_data 就是解密后的字符串了,解密之后,实际上是一个 JSON 字符串:

1
{'success': True, 'errcode': 0, 'errmsg': 'success', 'result': {'success': True, 'data': {'total': 22, 'rows': [{'time': '2018-01-25 00:00:00', 'temp': '-7', 'humi': '35', 'wse': '1', 'wd': '东北风', 'tq': '晴'}, {'time': '2018-01-25 01:00:00', 'temp': '-9', 'humi': '38', 'wse': '1', 'wd': '西风', 'tq': '晴'}, {'time': '2018-01-25 02:00:00', 'temp': '-10', 'humi': '40', 'wse': '1', 'wd': '东北风', 'tq': '晴'}, {'time': '2018-01-25 03:00:00', 'temp': '-8', 'humi': '27', 'wse': '2', 'wd': '东北风', 'tq': '晴'}, {'time': '2018-01-25 04:00:00', 'temp': '-8', 'humi': '26', 'wse': '2', 'wd': '东风', 'tq': '晴'}, {'time': '2018-01-25 05:00:00', 'temp': '-8', 'humi': '23', 'wse': '2', 'wd': '东北风', 'tq': '晴'}, {'time': '2018-01-25 06:00:00', 'temp': '-9', 'humi': '27', 'wse': '2', 'wd': '东北风', 'tq': '多云'}, {'time': '2018-01-25 07:00:00', 'temp': '-9', 'humi': '24', 'wse': '2', 'wd': '东北风', 'tq': '多云'}, {'time': '2018-01-25 08:00:00', 'temp': '-9', 'humi': '25', 'wse': '2', 'wd': '东风', 'tq': '晴转多云转多云间晴'}, {'time': '2018-01-25 09:00:00', 'temp': '-8', 'humi': '21', 'wse': '3', 'wd': '东北风', 'tq': '晴转多云转多云间晴'}, {'time': '2018-01-25 10:00:00', 'temp': '-7', 'humi': '19', 'wse': '3', 'wd': '东北风', 'tq': '晴转多云转多云间晴'}, {'time': '2018-01-25 11:00:00', 'temp': '-6', 'humi': '18', 'wse': '3', 'wd': '东北风', 'tq': '多云'}, {'time': '2018-01-25 12:00:00', 'temp': '-6', 'humi': '17', 'wse': '3', 'wd': '东北风', 'tq': '多云'}, {'time': '2018-01-25 13:00:00', 'temp': '-5', 'humi': '17', 'wse': '2', 'wd': '东北风', 'tq': '多云'}, {'time': '2018-01-25 14:00:00', 'temp': '-5', 'humi': '16', 'wse': '2', 'wd': '东风', 'tq': '多云'}, {'time': '2018-01-25 15:00:00', 'temp': '-5', 'humi': '15', 'wse': '2', 'wd': '北风', 'tq': '多云'}, {'time': '2018-01-25 16:00:00', 'temp': '-5', 'humi': '16', 'wse': '2', 'wd': '东北风', 'tq': '多云'}, {'time': '2018-01-25 17:00:00', 'temp': '-5', 'humi': '16', 'wse': '2', 'wd': '东风', 'tq': '多云'}, {'time': '2018-01-25 18:00:00', 'temp': '-6', 'humi': '18', 'wse': '2', 'wd': '东风', 'tq': '晴间多云'}, {'time': '2018-01-25 19:00:00', 'temp': '-7', 'humi': '19', 'wse': '2', 'wd': '东风', 'tq': '晴间多云'}, {'time': '2018-01-25 20:00:00', 'temp': '-7', 'humi': '19', 'wse': '1', 'wd': '东风', 'tq': '晴间多云'}, {'time': '2018-01-25 21:00:00', 'temp': '-7', 'humi': '19', 'wse': '0', 'wd': '南风', 'tq': '晴间多云'}]}}}

大功告成! 这样我们就可以成功获取温度、湿度、风力、天气等信息了。 另外这部分数据其实不全,还有 PM 2.5、AQI 等数据需要用另外一个 method 参数 GETDETAIL,修改一下即可获取这部分数据了。 再往后的数据就是解析和存储了,这里不再赘述。

结语

本文通过分析 JavaScript 并进行反混淆,然后用 Python 模拟运行 JavaScript 的方式实现了数据抓取。 代码地址:https://github.com/Germey/AQIStudy

Python

Gerapy是一个Scrapy分布式管理模块,本节就来介绍一下它的安装方式。

1. 相关链接

2. pip安装

这里推荐使用pip安装,命令如下:

1
pip3 install gerapy

3. 测试安装

安装完成后,可以在Python命令行下测试:

1
2
$ python3
>>> import gerapy

如果没有错误报出,则证明库已经安装好了。

Python

Scrapyrt为Scrapy提供了一个调度的HTTP接口,有了它,我们就不需要再执行Scrapy命令而是通过请求一个HTTP接口来调度Scrapy任务了。Scrapyrt比Scrapyd更轻量,如果不需要分布式多任务的话,可以简单使用Scrapyrt实现远程Scrapy任务的调度。

1. 相关链接

2. pip安装

这里推荐使用pip安装,命令如下:

1
pip3 install scrapyrt

接下来,在任意一个Scrapy项目中运行如下命令来启动HTTP服务:

1
scrapyrt

运行之后,会默认在9080端口上启动服务,类似的输出结果如下:

1
2
3
4
scrapyrt
2017-07-12 22:31:03+0800 [-] Log opened.
2017-07-12 22:31:03+0800 [-] Site starting on 9080
2017-07-12 22:31:03+0800 [-] Starting factory <twisted.web.server.Site object at 0x10294b160>

如果想更换运行端口,可以使用\-p参数,如:

1
scrapyrt -p 9081

这样就会在9081端口上运行了。

3. Docker安装

另外,Scrapyrt也支持Docker。比如,要想在9080端口上运行,且本地Scrapy项目的路径为/home/quotesbot,可以使用如下命令运行:

1
docker run -p 9080:9080 -tid -v /home/user/quotesbot:/scrapyrt/project scrapinghub/scrapyrt

这样同样可以在9080端口上监听指定的Scrapy项目。

Python

安装好了Scrapyd之后,我们可以直接请求它提供的API来获取当前主机的Scrapy任务运行状况。比如,某台主机的IP为192.168.1.1,则可以直接运行如下命令获取当前主机的所有Scrapy项目:

1
curl http://localhost:6800/listprojects.json

运行结果如下:

1
{"status": "ok", "projects": ["myproject", "otherproject"]}

返回结果是JSON字符串,通过解析这个字符串,便可以得到当前主机的所有项目。

但是用这种方式来获取任务状态还是有点烦琐,所以Scrapyd API就为它做了一层封装,下面我们来看下它的安装方式。

1. 相关链接

2. pip安装

这里推荐使用pip安装,命令如下:

1
pip install python-scrapyd-api

3. 验证安装

安装完成之后,便可以使用Python来获取主机状态了,所以上面的操作便可以用Python代码实现:

1
2
3
from scrapyd_api import ScrapydAPI
scrapyd = ScrapydAPI('http://localhost:6800')
print(scrapyd.list_projects())

运行结果如下:

1
["myproject", "otherproject"]

这样我们便可以用Python直接来获取各个主机上Scrapy任务的运行状态了。

Python

在将 Scrapy 代码部署到远程 Scrapyd 的时候,第一步就是要将代码打包为 EGG 文件,其次需要将 EGG 文件上传到远程主机。这个过程如果用程序来实现,也是完全可以的,但是我们并不需要做这些工作,因为 Scrapyd-Client 已经为我们实现了这些功能。

下面我们就来看看 Scrapyd-Client 的安装过程。

1. 相关链接

2. pip 安装

这里推荐使用 pip 安装,相关命令如下:

1
pip3 install scrapyd-client

3. 验证安装

安装成功后会有一个可用命令,叫作 scrapyd-deploy,即部署命令。

我们可以输入如下测试命令测试 Scrapyd-Client 是否安装成功:

1
scrapyd-deploy -h

如果出现类似如图 1-86 所示的输出,则证明 Scrapyd-Client 已经成功安装。

图 1-86 运行结果

Python

Scrapyd 是一个用于部署和运行 Scrapy 项目的工具,有了它,你可以将写好的 Scrapy 项目上传到云主机并通过 API 来控制它的运行。

既然是 Scrapy 项目部署,基本上都使用 Linux 主机,所以本节的安装是针对于 Linux 主机的。

1. 相关链接

2. pip 安装

这里推荐使用 pip 安装,命令如下:

1
pip3 install scrapyd

3. 配置

安装完毕之后,需要新建一个配置文件/etc/scrapyd/scrapyd.conf,Scrapyd 在运行的时候会读取此配置文件。

在 Scrapyd 1.2 版本之后,不会自动创建该文件,需要我们自行添加。

首先,执行如下命令新建文件:

1
2
sudo mkdir /etc/scrapyd
sudo vi /etc/scrapyd/scrapyd.conf

接着写入如下内容:

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
[scrapyd]
eggs_dir = eggs
logs_dir = logs
items_dir =
jobs_to_keep = 5
dbs_dir = dbs
max_proc = 0
max_proc_per_cpu = 10
finished_to_keep = 100
poll_interval = 5.0
bind_address = 0.0.0.0
http_port = 6800
debug = off
runner = scrapyd.runner
application = scrapyd.app.application
launcher = scrapyd.launcher.Launcher
webroot = scrapyd.website.Root

[services]
schedule.json = scrapyd.webservice.Schedule
cancel.json = scrapyd.webservice.Cancel
addversion.json = scrapyd.webservice.AddVersion
listprojects.json = scrapyd.webservice.ListProjects
listversions.json = scrapyd.webservice.ListVersions
listspiders.json = scrapyd.webservice.ListSpiders
delproject.json = scrapyd.webservice.DeleteProject
delversion.json = scrapyd.webservice.DeleteVersion
listjobs.json = scrapyd.webservice.ListJobs
daemonstatus.json = scrapyd.webservice.DaemonStatus

配置文件的内容可以参见官方文档https://scrapyd.readthedocs.io/en/stable/config.html#example-configuration-file。这里的配置文件有所修改,其中之一是max_proc_per_cpu官方默认为 4,即一台主机每个 CPU 最多运行 4 个 Scrapy 任务,在此提高为 10。另外一个是bind_address,默认为本地 127.0.0.1,在此修改为 0.0.0.0,以使外网可以访问。

4. 后台运行

Scrapyd 是一个纯 Python 项目,这里可以直接调用它来运行。为了使程序一直在后台运行,Linux 和 Mac 可以使用如下命令:

1
(scrapyd > /dev/null &)

这样 Scrapyd 就会在后台持续运行了,控制台输出直接忽略。当然,如果想记录输出日志,可以修改输出目标,如:

1
(scrapyd > ~/scrapyd.log &)

此时会将 Scrapyd 的运行结果输出到~/scrapyd.log 文件中。

当然也可以使用 screen、tmux、supervisor 等工具来实现进程守护。

运行之后,便可以在浏览器的 6800 端口访问 Web UI 了,从中可以看到当前 Scrapyd 的运行任务、日志等内容,如图 1-85 所示。

图 1-85 Scrapyd 首页

当然,运行 Scrapyd 更佳的方式是使用 Supervisor 守护进程,如果感兴趣,可以参考:http://supervisord.org/

另外,Scrapyd 也支持 Docker,后面我们会介绍 Scrapyd Docker 镜像的制作和运行方法。

5. 访问认证

配置完成后,Scrapyd 和它的接口都是可以公开访问的。如果想配置访问认证的话,可以借助于 Nginx 做反向代理,这里需要先安装 Nginx 服务器。

在此以 Ubuntu 为例进行说明,安装命令如下:

1
sudo apt-get install nginx

然后修改 Nginx 的配置文件 nginx.conf,增加如下配置:

1
2
3
4
5
6
7
8
9
10
http {
server {
listen 6801;
location / {
proxy_pass http://127.0.0.1:6800/;
auth_basic "Restricted";
auth_basic_user_file /etc/nginx/conf.d/.htpasswd;
}
}
}

这里使用的用户名和密码配置放置在/etc/nginx/conf.d 目录下,我们需要使用htpasswd命令创建。例如,创建一个用户名为 admin 的文件,命令如下:

1
htpasswd -c .htpasswd admin

接着就会提示我们输入密码,输入两次之后,就会生成密码文件。此时查看这个文件的内容:

1
2
cat .htpasswd
admin:5ZBxQr0rCqwbc

配置完成后,重启一下 Nginx 服务,运行如下命令:

1
sudo nginx -s reload

这样就成功配置了 Scrapyd 的访问认证了。

Python

Docker 是一种容器技术,可以将应用和环境等进行打包,形成一个独立的、类似于 iOS 的 App 形式的“应用”。这个应用可以直接被分发到任意一个支持 Docker 的环境中,通过简单的命令即可启动运行。Docker 是一种最流行的容器化实现方案,和虚拟化技术类似,它极大地方便了应用服务的部署;又与虚拟化技术不同,它以一种更轻量的方式实现了应用服务的打包。使用 Docker,可以让每个应用彼此相互隔离,在同一台机器上同时运行多个应用,不过它们彼此之间共享同一个操作系统。Docker 的优势在于,它可以在更细的粒度上进行资源管理,也比虚拟化技术更加节约资源。

对于爬虫来说,如果我们需要大规模部署爬虫系统的话,用 Docker 会大大提高效率。工欲善其事,必先利其器。

本节中,我们就来介绍三大平台下 Docker 的安装方式。

1. 相关链接

2. Windows 下的安装

如果你的系统是 Windows 10 64 位,那么推荐使用 Docker for Windows。此时直接从 Docker 官方网站下载最新的 Docker for Windows 安装包即可:https://docs.docker.com/docker-for-windows/install/

如果不是 Windows 10 64 位系统,则可以下载 Docker Toolbox:https://docs.docker.com/toolbox/toolbox_install_windows/

下载后直接双击安装即可,详细过程可以参考文档说明。安装完成后,进入命令行。

运行docker命令测试:

1
docker

运行结果如图 1-81 所示,这就证明 Docker 安装成功了。

图 1-81 运行结果

3. Linux 下的安装

详细的分步骤安装说明可以参见官方文档:https://docs.docker.com/engine/installation/linux/ubuntu/

官方文档中详细说明了不同 Linux 系统的安装方法,根据文档一步步执行即可安装成功。但是为了使安装更加方便,Docker 官方还提供了一键安装脚本。使用它,会使安装更加便捷,不用再去一步步执行命令安装了。

首先是 Docker 官方提供的安装脚本。相比其他脚本,官方提供的一定更靠谱,安装命令如下:

1
curl -sSL https://get.docker.com/ | sh

只要执行如上一条命令,等待一会儿 Docker 便会安装完成,这非常方便。

但是使用官方脚本安装有一个缺点,那就是慢,也可能下载超时,所以为了加快下载速度,我们可以使用国内的镜像来安装,所以这里还有阿里云和 DaoCloud 的安装脚本。

阿里云的安装脚本:

1
curl -sSL http://acs-public-mirror.oss-cn-hangzhou.aliyuncs.com/docker-engine/internet | sh -

DaoCloud 的安装脚本:

1
curl -sSL https://get.daocloud.io/docker | sh

这两个脚本可以任选其一,速度都非常不错。

等待脚本执行完毕之后,就可以使用 Docker 相关命令了,如运行测试 Hello World 镜像:

1
docker run hello-world

运行结果:

1
2
3
4
5
6
7
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
78445dd45222: Pull complete
Digest: sha256:c5515758d4c5e1e838e9cd307f6c6a0d620b5e07e6f927b07d05f6d12a1ac8d7
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.

如果出现类似上面提示的内容,则证明 Docker 可以正常使用了。

4. Mac 下的安装

Mac 平台同样有两种选择:Docker for Mac 和 Docker Toolbox。

Docker for Mac 要求系统为 OS X EI Captain 10.11 或更新,至少 4GB 内存。如果你的系统满足此要求,则强烈建议安装 Docker for Mac。

这里可以使用 Homebrew 安装,安装命令如下:

1
brew cask install docker

另外,也可以手动下载安装包(下载地址为:https://download.docker.com/mac/stable/Docker.dmg)安装。

下载完成后,直接双击安装包,然后将程序拖动到应用程序中即可。

点击程序图标运行 Docker,会发现在菜单栏中出现了 Docker 的图标,如图 1-82 中的第三个小鲸鱼图标。

图 1-82 菜单栏

点击小鲸鱼图标,展开菜单之后,再点击 Start 按钮即可启动 Docker。启动成功后,便会提示 Docker is running,如图 1-83 所示。

图 1-83 运行页面

随后,我们就可以在命令行下使用 Docker 命令了。

可以使用如下命令测试运行:

1
sudo docker run hello-world

运行结果如图 1-84 所示,这就证明 Docker 已经成功安装了。

图 1-84 运行结果

如果系统不满足要求,可以下载 Docker Toolbox,其安装说明为:https://docs.docker.com/toolbox/overview/

关于 Docker for Mac 和 Docker Toolbox 的区别,可以参见:https://docs.docker.com/docker-for-mac/docker-toolbox/

5. 镜像加速

安装好 Docker 之后,在运行测试命令时,我们会发现它首先会下载一个 Hello World 的镜像,然后将其运行。但是这里的下载速度有时候会非常慢,这是因为它默认还是从国外的 Docker Hub 下载的。因此,为了提高镜像的下载速度,我们还可以使用国内镜像来加速下载,于是就有了 Docker 加速器一说。

推荐的 Docker 加速器有 DaoCloud(详见https://www.daocloud.io/mirror)和阿里云(详见https://cr.console.aliyun.com/#/accelerator)。

不同平台的镜像加速方法配置可以参考 DaoCloud 的官方文档:http://guide.daocloud.io/dcs/daocloud-9153151.html

配置完成之后,可以发现镜像的下载速度会快非常多。

Python

如果想要大规模抓取数据,那么一定会用到分布式爬虫。对于分布式爬虫来说,我们需要多台主机,每台主机多个爬虫任务,但是源代码其实只有一份。此时我们需要做的就是将一份代码同时部署到多台主机上来协同运行,那么怎么去部署就是另一个值得思考的问题。

对于Scrapy来说,它有一个扩展组件,叫作Scrapyd,我们只需要安装该扩展组件,即可远程管理Scrapy任务,包括部署源码、启动任务、监听任务等。另外,还有Scrapyd-Client和Scrapyd API来帮助我们更方便地完成部署和监听操作。

另外,还有一种部署方式,那就是Docker集群部署。我们只需要将爬虫制作为Docker镜像,只要主机安装了Docker,就可以直接运行爬虫,而无需再去担心环境配置、版本问题。

本节中,我们就来介绍相关环境的配置过程。

Python

Scrapy-Redis是Scrapy的分布式扩展模块,有了它,我们就可以方便地实现Scrapy分布式爬虫的搭建。本节中,我们将介绍Scrapy-Redis的安装方式。

相关链接

pip安装

这里推荐使用pip安装,命令如下:

1
pip3 install scrapy-redis

wheel安装

此外,也可以到PyPI下载wheel文件安装(详见https://pypi.python.org/pypi/scrapy-redis#downloads),如当前的最新版本为0.6.8,则可以下载scrapy_redis-0.6.8-py2.py3-none-any.whl,然后通过pip安装即可:

1
pip3 install scrapy_redis-0.6.8-py2.py3-none-any.whl

测试安装

安装完成之后,可以在Python命令行下测试:

1
2
$ python3
>>> import scrapy_redis

如果没有错误报出,则证明库已经安装好了。

Python

Scrapy-Splash 是一个 Scrapy 中支持 JavaScript 渲染的工具,本节来介绍它的安装方式。

Scrapy-Splash 的安装分为两部分。一个是 Splash 服务的安装,具体是通过 Docker,安装之后,会启动一个 Splash 服务,我们可以通过它的接口来实现 JavaScript 页面的加载。另外一个是 Scrapy-Splash 的 Python 库的安装,安装之后即可在 Scrapy 中使用 Splash 服务。

1. 相关链接

2. 安装 Splash

Scrapy-Splash 会使用 Splash 的 HTTP API 进行页面渲染,所以我们需要安装 Splash 来提供渲染服务。这里通过 Docker 安装,在这之前请确保已经正确安装好了 Docker。

安装命令如下:

1
docker run -p 8050:8050 scrapinghub/splash

安装完成之后,会有类似的输出结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2017-07-03 08:53:28+0000 [-] Log opened.
2017-07-03 08:53:28.447291 [-] Splash version: 3.0
2017-07-03 08:53:28.452698 [-] Qt 5.9.1, PyQt 5.9, WebKit 602.1, sip 4.19.3, Twisted 16.1.1, Lua 5.2
2017-07-03 08:53:28.453120 [-] Python 3.5.2 (default, Nov 17 2016, 17:05:23) [GCC 5.4.0 20160609]
2017-07-03 08:53:28.453676 [-] Open files limit: 1048576
2017-07-03 08:53:28.454258 [-] Can't bump open files limit
2017-07-03 08:53:28.571306 [-] Xvfb is started: ['Xvfb', ':1599197258', '-screen', '0', '1024x768x24', '-nolisten', 'tcp']
QStandardPaths: XDG_RUNTIME_DIR not set, defaulting to '/tmp/runtime-root'
2017-07-03 08:53:29.041973 [-] proxy profiles support is enabled, proxy profiles path: /etc/splash/proxy-profiles
2017-07-03 08:53:29.315445 [-] verbosity=1
2017-07-03 08:53:29.315629 [-] slots=50
2017-07-03 08:53:29.315712 [-] argument_cache_max_entries=500
2017-07-03 08:53:29.316564 [-] Web UI: enabled, Lua: enabled (sandbox: enabled)
2017-07-03 08:53:29.317614 [-] Site starting on 8050
2017-07-03 08:53:29.317801 [-] Starting factory <twisted.web.server.Site object at 0x7ffaa4a98cf8>

这样就证明 Splash 已经在 8050 端口上运行了。这时我们打开http://localhost:8050,即可看到 Splash 的主页,如图 1-80 所示。

图 1-80 运行页面

当然,Splash 也可以直接安装在远程服务器上。我们在服务器上以守护态运行 Splash 即可,命令如下:

1
docker run -d -p 8050:8050 scrapinghub/splash

这里多了\-d参数,它代表将 Docker 容器以守护态运行,这样在中断远程服务器连接后,不会终止 Splash 服务的运行。

3. Scrapy-Splash 的安装

成功安装 Splash 之后,接下来再来安装其 Python 库,命令如下:

1
pip3 install scrapy-splash

命令运行完毕后,就会成功安装好此库,后面会详细介绍它的用法。

Python

Scrapy 是一个十分强大的爬虫框架,依赖的库比较多,至少需要依赖的库有 Twisted 14.0、lxml 3.4 和 pyOpenSSL 0.14。在不同的平台环境下,它所依赖的库也各不相同,所以在安装之前,最好确保把一些基本库安装好。本节就来介绍 Scrapy 在不同平台的安装方法。

1. 相关链接

2. Anaconda 安装

这是一种比较简单的安装 Scrapy 的方法(尤其是对于 Windows 来说),如果你的 Python 是使用 Anaconda 安装的,或者还没有安装 Python 的话,可以使用此方法安装,这种方法简单、省力。当然,如果你的 Python 不是通过 Anaconda 安装的,可以继续看后面的内容。

关于 Anaconda 的安装方式,可以查看 1.1 节,在此不再赘述。

如果已经安装好了 Anaconda,那么可以通过conda命令安装 Scrapy,具体如下:

1
conda install Scrapy

3. Windows 下的安装

如果你的 Python 不是使用 Anaconda 安装的,可以参考如下方式来一步步安装 Scrapy。

安装 lxml

lxml 的安装过程请参见 1.3.1 节,在此不再赘述,此库非常重要,请一定要安装成功。

安装 pyOpenSSL

在官方网站下载 wheel 文件(详见https://pypi.python.org/pypi/pyOpenSSL#downloads)即可,如图 1-76 所示。

图 1-76 下载页面

下载后利用 pip 安装即可:

1
pip3 install pyOpenSSL-17.2.0-py2.py3-none-any.whl

安装 Twisted

http://www.lfd.uci.edu/~gohlke/pythonlibs/#twisted下载 wheel 文件,利用 pip 安装即可。

比如,对于 Python 3.6 版本、Windows 64 位系统,则当前最新版本为 Twisted‑17.5.0‑cp36‑cp36m‑win_amd64.whl,直接下载即可,如图 1-77 所示。

图 1-77 下载页面

然后通过 pip 安装:

1
pip3 install Twisted‑17.5.0cp36cp36mwin_amd64.whl

安装 PyWin32

从官方网站下载对应版本的安装包即可,链接为:https://sourceforge.net/projects/pywin32/files/pywin32/Build%20221/,如图 1-78 所示。

图 1-78 下载列表

比如对于 Python 3.6 版本,可以选择下载 pywin32-221.win-amd64-py3.6.exe,下载完毕之后双击安装即可。

注意,这里使用的是 Build 221 版本,随着时间推移,版本肯定会继续更新,最新的版本可以查看https://sourceforge.net/projects/pywin32/files/pywin32/,到时查找最新的版本安装即可。

安装 Scrapy

安装好了以上的依赖库后,安装 Scrapy 就非常简单了,这里依然使用 pip,命令如下:

1
pip3 install Scrapy

等待命令结束,如果没有报错,就证明 Scrapy 已经安装好了。

4. Linux 下的安装

在 Linux 下的安装方式依然分为两类平台来介绍。

CentOS 和 Red Hat

在 CentOS 和 Red Hat 下,首先确保一些依赖库已经安装,运行如下命令:

1
2
sudo yum groupinstall -y development tools
sudo yum install -y epel-release libxslt-devel libxml2-devel openssl-devel

最后利用 pip 安装 Scrapy 即可:

1
pip3 install Scrapy

Ubuntu、Debian 和 Deepin

在 Ubuntu、Debian 和 Deepin 平台下,首先确保一些依赖库已经安装,运行如下命令:

1
sudo apt-get install build-essential python3-dev libssl-dev libffi-dev libxml2 libxml2-dev libxslt1-dev zlib1g-dev

然后利用 pip 安装 Scrapy 即可:

1
pip3 install Scrapy

运行完毕后,就完成 Scrapy 的安装了。

5. Mac 下的安装

在 Mac 下,首先也是进行依赖库的安装。

在 Mac 上构建 Scrapy 的依赖库需要 C 编译器以及开发头文件,它一般由 Xcode 提供,具体命令如下:

1
xcode-select --install

随后利用 pip 安装 Scrapy 即可:

1
pip3 install Scrapy

6. 验证安装

安装之后,在命令行下输入scrapy,如果出现类似如图 1-79 所示的结果,就证明 Scrapy 安装成功了。

图 1-79 验证安装

7. 常见错误

在安装过程中,常见的错误汇总如下。

pkg_resources.VersionConflict: (six 1.5.2 (/usr/lib/python3/dist-packages), Requirement.parse('six>=1.6.0'))

这是 six 包版本过低出现的错误。six 包是一个提供兼容 Python 2 和 Python 3 的库,这时升级 six 包即可:

1
sudo pip3 install -U six
c/_cffi_backend.c:15:17: fatal error: ffi.h: No such file or directory

这是在 Linux 下常出现的错误,缺少 libffi 库造成的。什么是 libffi?FFI 的全名是 Foreign Function Interface,通常指的是允许以一种语言编写的代码调用另一种语言的代码。而 libffi 库只提供了最底层的、与架构相关的、完整的 FFI。此时安装相应的库即可。

在 Ubuntu 和 Debian 下,直接执行如下命令即可:

1
sudo apt-get install build-essential libssl-dev libffi-dev python3-dev

在 CentOS 和 Red Hat 下,直接执行如下命令即可:

1
sudo yum install gcc libffi-devel python-devel openssl-devel
Command "python setup.py egg_info" failed with error code 1 in /tmp/pip-build/cryptography/

这是缺少加密的相关组件,此时利用 pip 安装即可:

1
pip3 install cryptography
ImportError: No module named 'packaging'

这是因为缺少 packaging 包出现的错误,这个包提供了 Python 包的核心功能,此时利用 pip 安装即可。

ImportError: No module named '_cffi_backend'

这个错误表示缺少 cffi 包,直接使用 pip 安装即可:

1
pip3 install cffi
ImportError: No module named 'pyparsing'

这个错误表示缺少 pyparsing 包,直接使用 pip 安装即可:

1
pip3 install pyparsing appdirs

Python

pyspider 是国人 binux 编写的强大的网络爬虫框架,它带有强大的 WebUI、脚本编辑器、任务监控器、项目管理器以及结果处理器,同时支持多种数据库后端、多种消息队列,另外还支持 JavaScript 渲染页面的爬取,使用起来非常方便,本节介绍一下它的安装过程。

1. 相关链接

2. 准备工作

pyspider 是支持 JavaScript 渲染的,而这个过程是依赖于 PhantomJS 的,所以还需要安装 PhantomJS(具体的安装过程详见 1.2.5 节)。

3. pip 安装

这里推荐使用 pip 安装,命令如下:

1
pip3 install pyspider

命令执行完毕即可完成安装。

4. 常见错误

Windows 下可能会出现这样的错误提示:

1
Command "python setup.py egg_info" failed with error code 1 in /tmp/pip-build-vXo1W3/pycurl

这是 PyCurl 安装错误,此时需要安装 PyCurl 库。从http://www.lfd.uci.edu/~gohlke/pythonlibs/#pycurl找到对应的 Python 版本,然后下载相应的 wheel 文件即可。比如 Windows 64 位、Python 3.6,则需要下载 pycurl‑7.43.0‑cp36‑cp36m‑win_amd64.whl,随后用 pip 安装即可,命令如下:

1
pip3 install pycurl‑7.43.0cp36cp36mwin_amd64.whl

如果在 Linux 下遇到 PyCurl 的错误,可以参考本文:https://imlonghao.com/19.html

5. 验证安装

安装完成之后,可以直接在命令行下启动 pyspider:

1
pyspider all

此时控制台会有类似如图 1-74 所示的输出。

图 1-74 控制台

这时 pyspider 的 Web 服务就会在本地 5000 端口运行。直接在浏览器中打开http://localhost:5000/,即可进入 pyspider 的 WebUI 管理页面,如图 1-75 所示,这证明 pyspider 已经安装成功了。

图 1-75 管理页面

后面,我们会详细介绍 pyspider 的用法。

Python

我们直接用Requests、Selenium等库写爬虫,如果爬取量不是太大,速度要求不高,是完全可以满足需求的。但是写多了会发现其内部许多代码和组件是可以复用的,如果我们把这些组件抽离出来,将各个功能模块化,就慢慢会形成一个框架雏形,久而久之,爬虫框架就诞生了。

利用框架,我们可以不用再去关心某些功能的具体实现,只需要关心爬取逻辑即可。有了它们,可以大大简化代码量,而且架构也会变得清晰,爬取效率也会高许多。所以,如果有一定的基础,上手框架是一种好的选择。

本书主要介绍的爬虫框架有pyspider和Scrapy。本节中,我们来介绍一下pyspider、Scrapy及其扩展库的安装方式。

Python

Appium 是移动端的自动化测试工具,类似于前面所说的 Selenium,利用它可以驱动 Android、iOS 等设备完成自动化测试,比如模拟点击、滑动、输入等操作,其官方网站为:http://appium.io/。本节中,我们就来了解一下 Appium 的安装方式。

1. 相关链接

2. 安装 Appium

首先,需要安装 Appium。Appium 负责驱动移动端来完成一系列操作,对于 iOS 设备来说,它使用苹果的 UIAutomation 来实现驱动;对于 Android 来说,它使用 UIAutomator 和 Selendroid 来实现驱动。

同时 Appium 也相当于一个服务器,我们可以向它发送一些操作指令,它会根据不同的指令对移动设备进行驱动,以完成不同的动作。

安装 Appium 有两种方式,一种是直接下载安装包 Appium Desktop 来安装,另一种是通过 Node.js 来安装,下面我们介绍一下这两种安装方式。

Appium Desktop

Appium Desktop 支持全平台的安装,我们直接从 GitHub 的 Releases 里面安装即可,链接为https://github.com/appium/appium-desktop/releases。目前的最新版本是 1.1,下载页面如图 1-71 所示。

图 1-71 下载页面

Windows 平台可以下载 exe 安装包 appium-desktop-Setup-1.1.0.exe,Mac 平台可以下载 dmg 安装包如 appium-desktop-1.1.0.dmg,Linux 平台可以选择下载源码,但是更推荐用 Node.js 安装方式。

安装完成后运行,看到的页面如图 1-72 所示。

图 1-72 运行页面

如果出现此页面,则证明安装成功。

Node.js

首先需要安装 Node.js,具体的安装方式可以参见http://www.runoob.com/nodejs/nodejs-install-setup.html,安装完成之后就可以使用npm命令了。

接下来,使用npm命令全局安装 Appium 即可:

1
npm install -g appium

此时等待命令执行完成即可,这样就成功安装了 Appium。

3. Android 开发环境配置

如果我们要使用 Android 设备做 App 抓取的话,还需要下载和配置 Android SDK,这里推荐直接安装 Android Studio,其下载地址为https://developer.android.com/studio/index.html?hl=zh-cn。下载后直接安装即可。

然后,我们还需要下载 Android SDK。直接打开首选项里面的 Android SDK 设置页面,勾选要安装的 SDK 版本,点击 OK 按钮即可下载和安装勾选的 SDK 版本,如图 1-73 所示。

图 1-73 Android SDK 设置页面

另外,还需要配置一下环境变量,添加 ANDROID_HOME 为 Android SDK 所在路径,然后再添加 SDK 文件夹下的 tools 和 platform-tools 文件夹到 PATH 中。

更详细的配置可以参考 Android Studio 的官方文档:https://developer.android.com/studio/intro/index.html

4. iOS 开发环境

首先需要声明的是,Appium 是一个做自动化测试的工具,用它来测试我们自己开发的 App 是完全没问题的,因为它携带的是开发证书(Development Certificate)。但如果我们想拿 iOS 设备来做数据爬取的话,那又是另外一回事了。一般情况下,我们做数据爬取都是使用现有的 App,在 iOS 上一般都是通过 App Store 下载的,它携带的是分发证书(Distribution Certificate),而携带这种证书的应用都是禁止被测试的,所以只有获取 ipa 安装包再重新签名之后才可以被 Appium 测试,具体的方法这里不再展开阐述。

这里推荐直接使用 Android 来进行测试。如果你可以完成上述重签名操作,那么可以参考如下内容配置 iOS 开发环境。

Appium 驱动 iOS 设备必须要在 Mac 下进行,Windows 和 Linux 平台是无法完成的,所以下面介绍一下 Mac 平台的相关配置。

Mac 平台需要的配置如下:

  • macOS 10.12 及更高版本
  • XCode 8 及更高版本

配置满足要求之后,执行如下命令即可配置开发依赖的一些库和工具:

1
xcode-select --install

这样 iOS 部分的开发环境就配置完成了,我们就可以用 iOS 模拟器来进行测试和数据抓取了。

如果想要用真机进行测试和数据抓取,还需要额外配置其他环境,具体可以参考https://github.com/appium/appium/blob/master/docs/en/appium-setup/real-devices-ios.md

5. Python 驱动

另外还需要安装 Python 驱动,命令如下:

1
pip3 install appium-python-client