关系型数据库是基于关系模型的数据库,而关系模型是通过二维表来保存的,所以它的存储方式就是行列组成的表,每一列是一个字段,每一行是一条记录。表可以看作某个实体的集合,而实体之间存在联系,这就需要表与表之间的关联关系来体现,如主键外键的关联关系。多个表组成一个数据库,也就是关系型数据库。
关系型数据库有多种,如SQLite、MySQL、Oracle、SQL Server、DB2等。
本节中,我们主要介绍Python 3下MySQL的存储。
关系型数据库是基于关系模型的数据库,而关系模型是通过二维表来保存的,所以它的存储方式就是行列组成的表,每一列是一个字段,每一行是一条记录。表可以看作某个实体的集合,而实体之间存在联系,这就需要表与表之间的关联关系来体现,如主键外键的关联关系。多个表组成一个数据库,也就是关系型数据库。
关系型数据库有多种,如SQLite、MySQL、Oracle、SQL Server、DB2等。
本节中,我们主要介绍Python 3下MySQL的存储。
CSV,全称为 Comma-Separated Values,中文可以叫作逗号分隔值或字符分隔值,其文件以纯文本形式存储表格数据。该文件是一个字符序列,可以由任意数目的记录组成,记录间以某种换行符分隔。每条记录由字段组成,字段间的分隔符是其他字符或字符串,最常见的是逗号或制表符。不过所有记录都有完全相同的字段序列,相当于一个结构化表的纯文本形式。它比 Excel 文件更加简介,XLS 文本是电子表格,它包含了文本、数值、公式和格式等内容,而 CSV 中不包含这些内容,就是特定字符分隔的纯文本,结构简单清晰。所以,有时候用 CSV 来保存数据是比较方便的。本节中,我们来讲解 Python 读取和写入 CSV 文件的过程。
这里先看一个最简单的例子:
1 |
import csv |
首先,打开 data.csv 文件,然后指定打开的模式为w
(即写入),获得文件句柄,随后调用 csv 库的writer()
方法初始化写入对象,传入该句柄,然后调用writerow()
方法传入每行的数据即可完成写入。
运行结束后,会生成一个名为 data.csv 的文件,此时数据就成功写入了。直接以文本形式打开的话,其内容如下:
1 |
id,name,age |
可以看到,写入的文本默认以逗号分隔,调用一次writerow()
方法即可写入一行数据。用 Excel 打开的结果如图 5-6 所示。
图 5-6 打开结果
如果想修改列与列之间的分隔符,可以传入delimiter
参数,其代码如下:
1 |
import csv |
这里在初始化写入对象时传入delimiter
为空格,此时输出结果的每一列就是以空格分隔了,内容如下:
1 |
id name age |
另外,我们也可以调用writerows()
方法同时写入多行,此时参数就需要为二维列表,例如:
1 |
import csv |
输出效果是相同的,内容如下:
1 |
id,name,age |
但是一般情况下,爬虫爬取的都是结构化数据,我们一般会用字典来表示。在 csv 库中也提供了字典的写入方式,示例如下:
1 |
import csv |
这里先定义 3 个字段,用fieldnames
表示,然后将其传给DictWriter
来初始化一个字典写入对象,接着可以调用writeheader()
方法先写入头信息,然后再调用writerow()
方法传入相应字典即可。最终写入的结果是完全相同的,内容如下:
1 |
id,name,age |
这样就可以完成字典到 CSV 文件的写入了。
另外,如果想追加写入的话,可以修改文件的打开模式,即将open()
函数的第二个参数改成a
,代码如下:
1 |
import csv |
这样在上面的基础上再执行这段代码,文件内容便会变成:
1 |
id,name,age |
可见,数据被追加写入到文件中。
如果要写入中文内容的话,可能会遇到字符编码的问题,此时需要给open()
参数指定编码格式。比如,这里再写入一行包含中文的数据,代码需要改写如下:
1 |
import csv |
这里需要给open()
函数指定编码,否则可能发生编码错误。
另外,如果接触过 pandas 等库的话,可以调用DataFrame
对象的to_csv()
方法来将数据写入 CSV 文件中。
我们同样可以使用 csv 库来读取 CSV 文件。例如,将刚才写入的文件内容读取出来,相关代码如下:
1 |
import csv |
运行结果如下:
1 |
['id', 'name', 'age'] |
这里我们构造的是Reader
对象,通过遍历输出了每行的内容,每一行都是一个列表形式。注意,如果 CSV 文件中包含中文的话,还需要指定文件编码。
另外,如果接触过 pandas 的话,可以利用read_csv()
方法将数据从 CSV 中读取出来,例如:
1 |
import pandas as pd |
运行结果如下:
1 |
id name age |
在做数据分析的时候,此种方法用得比较多,也是一种比较方便地读取 CSV 文件的方法。
本节中,我们了解了 CSV 文件的写入和读取方式。这也是一种常用的数据存储方式,需要熟练掌握。
JSON,全称为 JavaScript Object Notation, 也就是 JavaScript 对象标记,它通过对象和数组的组合来表示数据,构造简洁但是结构化程度非常高,是一种轻量级的数据交换格式。本节中,我们就来了解如何利用 Python 保存数据到 JSON 文件。
在 JavaScript 语言中,一切都是对象。因此,任何支持的类型都可以通过 JSON 来表示,例如字符串、数字、对象、数组等,但是对象和数组是比较特殊且常用的两种类型,下面简要介绍一下它们。
{}
包裹起来的内容,数据结构为{key1:value1, key2:value2, ...}
的键值对结构。在面向对象的语言中,key
为对象的属性,value
为对应的值。键名可以使用整数和字符串来表示。值的类型可以是任意类型。[]
包裹起来的内容,数据结构为["java", "javascript", "vb", ...]
的索引结构。在 JavaScript 中,数组是一种比较特殊的数据类型,它也可以像对象那样使用键值对,但还是索引用得多。同样,值的类型可以是任意类型。所以,一个 JSON 对象可以写为如下形式:
1 |
[{ |
由中括号包围的就相当于列表类型,列表中的每个元素可以是任意类型,这个示例中它是字典类型,由大括号包围。
JSON 可以由以上两种形式自由组合而成,可以无限次嵌套,结构清晰,是数据交换的极佳方式。
Python 为我们提供了简单易用的库来实现 JSON 文件的读写操作,我们可以调用库的loads()
方法将 JSON 文本字符串转为 JSON 对象,可以通过dumps()
方法将 JSON 对象转为文本字符串。
例如,这里有一段 JSON 形式的字符串,它是str
类型,我们用 Python 将其转换为可操作的数据结构,如列表或字典:
1 |
import json |
运行结果如下:
1 |
<class 'str'> |
这里使用loads()
方法将字符串转为 JSON 对象。由于最外层是中括号,所以最终的类型是列表类型。
这样一来,我们就可以用索引来获取对应的内容了。例如,如果想取第一个元素里的name
属性,就可以使用如下方式:
1 |
data[0]['name'] |
得到的结果都是:
1 |
Bob |
通过中括号加 0 索引,可以得到第一个字典元素,然后再调用其键名即可得到相应的键值。获取键值时有两种方式,一种是中括号加键名,另一种是通过get()
方法传入键名。这里推荐使用get()
方法,这样如果键名不存在,则不会报错,会返回None
。另外,get()
方法还可以传入第二个参数(即默认值),示例如下:
1 |
data[0].get('age') |
运行结果如下:
1 |
None |
这里我们尝试获取年龄age
,其实在原字典中该键名不存在,此时默认会返回None
。如果传入第二个参数(即默认值),那么在不存在的情况下返回该默认值。
值得注意的是,JSON 的数据需要用双引号来包围,不能使用单引号。例如,若使用如下形式表示,则会出现错误:
1 |
import json |
运行结果如下:
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 |
import json |
运行结果如下:
1 |
[{'name': 'Bob', 'gender': 'male', 'birthday': '1992-10-18'}, {'name': 'Selina', 'gender': 'female', 'birthday': '1995-10-18'}] |
另外,我们还可以调用dumps()
方法将 JSON 对象转化为字符串。例如,将上例中的列表重新写入文本:
1 |
import json |
利用dumps()
方法,我们可以将 JSON 对象转为字符串,然后再调用文件的write()
方法写入文本,结果如图 5-2 所示。
图 5-2 写入结果
另外,如果想保存 JSON 的格式,可以再加一个参数indent
,代表缩进字符个数。示例如下:
1 |
with open('data.json', 'w') as file: |
此时写入结果如图 5-3 所示。
图 5-3 写入结果
这样得到的内容会自动带缩进,格式会更加清晰。
另外,如果 JSON 中包含中文字符,会怎么样呢?例如,我们将之前的 JSON 的部分值改为中文,再用之前的方法写入到文本:
1 |
import json |
写入结果如图 5-4 所示。
图 5-4 写入结果
可以看到,中文字符都变成了 Unicode 字符,这并不是我们想要的结果。
为了输出中文,还需要指定参数ensure_ascii
为False
,另外还要规定文件输出的编码:
1 |
with open('data.json', 'w', encoding='utf-8') as file: |
写入结果如图 5-5 所示。
图 5-5 写入结果
可以发现,这样就可以输出 JSON 为中文了。
本节中,我们了解了用 Python 进行 JSON 文件读写的方法,后面做数据解析时经常会用到,建议熟练掌握。
将数据保存到 TXT 文本的操作非常简单,而且 TXT 文本几乎兼容任何平台,但是这有个缺点,那就是不利于检索。所以如果对检索和数据结构要求不高,追求方便第一的话,可以采用 TXT 文本存储。本节中,我们就来看下如何利用 Python 保存 TXT 文本文件。
本节中,我们要保存知乎上“发现”页面的“热门话题”部分,将其问题和答案统一保存成文本形式。
首先,可以用 requests 将网页源代码获取下来,然后使用 pyquery 解析库解析,接下来将提取的标题、回答者、回答保存到文本,代码如下:
1 |
import requests |
这里主要是为了演示文件保存的方式,因此 requests 异常处理部分在此省去。首先,用 requests 提取知乎的“发现”页面,然后将热门话题的问题、回答者、答案全文提取出来,然后利用 Python 提供的open()
方法打开一个文本文件,获取一个文件操作对象,这里赋值为file
,接着利用file
对象的write()
方法将提取的内容写入文件,最后调用close()
方法将其关闭,这样抓取的内容即可成功写入文本中了。
运行程序,可以发现在本地生成了一个 explore.txt 文件,其内容如图 5-1 所示。
图 5-1 文件内容
这样热门问答的内容就被保存成文本形式了。
这里open()
方法的第一个参数即要保存的目标文件名称,第二个参数为a
,代表以追加方式写入到文本。另外,我们还指定了文件的编码为utf-8
。最后,写入完成后,还需要调用close()
方法来关闭文件对象。
在刚才的实例中,open()
方法的第二个参数设置成了a
,这样在每次写入文本时不会清空源文件,而是在文件末尾写入新的内容,这是一种文件打开方式。关于文件的打开方式,其实还有其他几种,这里简要介绍一下。
r
:以只读方式打开文件。文件的指针将会放在文件的开头。这是默认模式。rb
:以二进制只读方式打开一个文件。文件指针将会放在文件的开头。r+
:以读写方式打开一个文件。文件指针将会放在文件的开头。rb+
:以二进制读写方式打开一个文件。文件指针将会放在文件的开头。w
:以写入方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。wb
:以二进制写入方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。w+
:以读写方式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。wb+
:以二进制读写格式打开一个文件。如果该文件已存在,则将其覆盖。如果该文件不存在,则创建新文件。a
:以追加方式打开一个文件。如果该文件已存在,文件指针将会放在文件结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,则创建新文件来写入。
ab
:以二进制追加方式打开一个文件。如果该文件已存在,则文件指针将会放在文件结尾。也就是说,新的内容将会被写入到已有内容之后。如果该文件不存在,则创建新文件来写入。
a+
:以读写方式打开一个文件。如果该文件已存在,文件指针将会放在文件的结尾。文件打开时会是追加模式。如果该文件不存在,则创建新文件来读写。
ab+
:以二进制追加方式打开一个文件。如果该文件已存在,则文件指针将会放在文件结尾。如果该文件不存在,则创建新文件用于读写。另外,文件写入还有一种简写方法,那就是使用with as
语法。在with
控制块结束时,文件会自动关闭,所以就不需要再调用close()
方法了。这种保存方式可以简写如下:
1 |
with open('explore.txt', 'a', encoding='utf-8') as file: |
如果想保存时将原文清空,那么可以将第二个参数改写为w
,代码如下:
1 |
with open('explore.txt', 'w', encoding='utf-8') as file: |
上面便是利用 Python 将结果保存为 TXT 文件的方法,这种方法简单易用,操作高效,是一种最基本的保存数据的方法。
文件存储形式多种多样,比如可以保存成TXT纯文本形式,也可以保存为JSON格式、CSV格式等,本节就来了解一下文本文件的存储方式。
用解析器解析出数据之后,接下来就是存储数据了。保存的形式可以多种多样,最简单的形式是直接保存为文本文件,如TXT、JSON、CSV等。另外,还可以保存到数据库中,如关系型数据库MySQL,非关系型数据库MongoDB、Redis等。
在上一节中,我们介绍了Beautiful Soup的用法,它是一个非常强大的网页解析库,你是否觉得它的一些方法用起来有点不适应?有没有觉得它的CSS选择器的功能没有那么强大?
如果你对Web有所涉及,如果你比较喜欢用CSS选择器,如果你对jQuery有所了解,那么这里有一个更适合你的解析库——pyquery。
接下来,我们就来感受一下pyquery的强大之处。
在开始之前,请确保已经正确安装好了pyquery。若没有安装,可以参考第1章的安装过程。
像Beautiful Soup一样,初始化pyquery的时候,也需要传入HTML文本来初始化一个PyQuery对象。它的初始化方式有多种,比如直接传入字符串,传入URL,传入文件名,等等。下面我们来详细介绍一下。
首先,我们用一个实例来感受一下:
1 |
html = ''' |
运行结果如下:
1 |
<li class="item-0">first item</li> |
这里首先引入PyQuery这个对象,取别名为pq
。然后声明了一个长HTML字符串,并将其当作参数传递给PyQuery
类,这样就成功完成了初始化。接下来,将初始化的对象传入CSS选择器。在这个实例中,我们传入li
节点,这样就可以选择所有的li
节点。
初始化的参数不仅可以以字符串的形式传递,还可以传入网页的URL,此时只需要指定参数为url
即可:
1 |
from pyquery import PyQuery as pq |
运行结果如下:
1 |
<title>静觅丨崔庆才的个人博客</title> |
这样的话,PyQuery
对象会首先请求这个URL,然后用得到的HTML内容完成初始化,这其实就相当于用网页的源代码以字符串的形式传递给PyQuery
类来初始化。
它与下面的功能是相同的:
1 |
from pyquery import PyQuery as pq |
当然,除了传递URL,还可以传递本地的文件名,此时将参数指定为filename
即可:
1 |
from pyquery import PyQuery as pq |
当然,这里需要有一个本地HTML文件demo.html,其内容是待解析的HTML字符串。这样它会首先读取本地的文件内容,然后用文件内容以字符串的形式传递给PyQuery
类来初始化。
以上3种初始化方式均可,当然最常用的初始化方式还是以字符串形式传递。
首先,用一个实例来感受pyquery的CSS选择器的用法:
1 |
html = ''' |
运行结果如下:
1 |
<li class="item-0">first item</li> |
这里我们初始化PyQuery
对象之后,传入了一个CSS选择器#container .list li
,它的意思是先选取id
为container
的节点,然后再选取其内部的class
为list
的节点内部的所有li
节点。然后,打印输出。可以看到,我们成功获取到了符合条件的节点。
最后,将它的类型打印输出。可以看到,它的类型依然是PyQuery
类型。
下面我们介绍一些常用的查询函数,这些函数和jQuery中函数的用法完全相同。
查找子节点时,需要用到find()
方法,此时传入的参数是CSS选择器。这里还是以前面的HTML为例:
1 |
from pyquery import PyQuery as pq |
运行结果如下:
1 |
<class 'pyquery.pyquery.PyQuery'> |
首先,我们选取class
为list
的节点,然后调用了find()
方法,传入CSS选择器,选取其内部的li
节点,最后打印输出。可以发现,find()
方法会将符合条件的所有节点选择出来,结果的类型是PyQuery
类型。
其实find()
的查找范围是节点的所有子孙节点,而如果我们只想查找子节点,那么可以用children()
方法:
1 |
lis = items.children() |
运行结果如下:
1 |
<class 'pyquery.pyquery.PyQuery'> |
如果要筛选所有子节点中符合条件的节点,比如想筛选出子节点中class
为active
的节点,可以向children()
方法传入CSS选择器.active
:
1 |
lis = items.children('.active') |
运行结果如下:
1 |
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> |
可以看到,输出结果已经做了筛选,留下了class
为active
的节点。
我们可以用parent()
方法来获取某个节点的父节点,示例如下:
1 |
html = ''' |
运行结果如下:
1 |
<class 'pyquery.pyquery.PyQuery'> |
这里我们首先用.list
选取class
为list
的节点,然后调用parent()
方法得到其父节点,其类型依然是PyQuery
类型。
这里的父节点是该节点的直接父节点,也就是说,它不会再去查找父节点的父节点,即祖先节点。
但是如果想获取某个祖先节点,该怎么办呢?这时可以用parents()
方法:
1 |
from pyquery import PyQuery as pq |
运行结果如下:
1 |
<class 'pyquery.pyquery.PyQuery'> |
可以看到,输出结果有两个:一个是class
为wrap
的节点,一个是id
为container
的节点。也就是说,parents()
方法会返回所有的祖先节点。
如果想要筛选某个祖先节点的话,可以向parents()
方法传入CSS选择器,这样就会返回祖先节点中符合CSS选择器的节点:
1 |
parent = items.parents('.wrap') |
运行结果如下:
1 |
<div class="wrap"> |
可以看到,输出结果少了一个节点,只保留了class
为wrap
的节点。
前面我们说明了子节点和父节点的用法,还有一种节点,那就是兄弟节点。如果要获取兄弟节点,可以使用siblings()
方法。这里还是以上面的HTML代码为例:
1 |
from pyquery import PyQuery as pq |
这里首先选择class
为list
的节点内部class
为item-0
和active
的节点,也就是第三个li
节点。那么,很明显,它的兄弟节点有4个,那就是第一、二、四、五个li
节点。
运行结果如下:
1 |
<li class="item-1"><a href="link2.html">second item</a></li> |
可以看到,这正是我们刚才所说的4个兄弟节点。
如果要筛选某个兄弟节点,我们依然可以向siblings
方法传入CSS选择器,这样就会从所有兄弟节点中挑选出符合条件的节点了:
1 |
from pyquery import PyQuery as pq |
这里我们筛选了class
为active
的节点,通过刚才的结果可以观察到,class
为active
的兄弟节点只有第四个li
节点,所以结果应该是一个。
我们再看一下运行结果:
1 |
<li class="item-1 active"><a href="link4.html">fourth item</a></li> |
刚才可以观察到,pyquery的选择结果可能是多个节点,也可能是单个节点,类型都是PyQuery
类型,并没有返回像Beautiful Soup那样的列表。
对于单个节点来说,可以直接打印输出,也可以直接转成字符串:
1 |
from pyquery import PyQuery as pq |
运行结果如下:
1 |
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> |
对于多个节点的结果,我们就需要遍历来获取了。例如,这里把每一个li
节点进行遍历,需要调用items()
方法:
1 |
from pyquery import PyQuery as pq |
运行结果如下:
1 |
<class 'generator'> |
可以发现,调用items()
方法后,会得到一个生成器,遍历一下,就可以逐个得到li
节点对象了,它的类型也是PyQuery
类型。每个li
节点还可以调用前面所说的方法进行选择,比如继续查询子节点,寻找某个祖先节点等,非常灵活。
提取到节点之后,我们的最终目的当然是提取节点所包含的信息了。比较重要的信息有两类,一是获取属性,二是获取文本,下面分别进行说明。
提取到某个PyQuery
类型的节点后,就可以调用attr()
方法来获取属性:
1 |
html = ''' |
运行结果如下:
1 |
<a href="link3.html"><span class="bold">third item</span></a> <class 'pyquery.pyquery.PyQuery'> |
这里首先选中class
为item-0
和active
的li
节点内的a
节点,它的类型是PyQuery
类型。
然后调用attr()
方法。在这个方法中传入属性的名称,就可以得到这个属性值了。
此外,也可以通过调用attr
属性来获取属性,用法如下:
1 |
print(a.attr.href) |
结果如下:
1 |
link3.html |
这两种方法的结果完全一样。
如果选中的是多个元素,然后调用attr()
方法,会出现怎样的结果呢?我们用实例来测试一下:
1 |
a = doc('a') |
运行结果如下:
1 |
<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'> |
照理来说,我们选中的a
节点应该有4个,而且打印结果也应该是4个,但是当我们调用attr()
方法时,返回结果却只是第一个。这是因为,当返回结果包含多个节点时,调用attr()
方法,只会得到第一个节点的属性。
那么,遇到这种情况时,如果想获取所有的a
节点的属性,就要用到前面所说的遍历了:
1 |
from pyquery import PyQuery as pq |
此时的运行结果如下:
1 |
link2.html |
因此,在进行属性获取时,可以观察返回节点是一个还是多个,如果是多个,则需要遍历才能依次获取每个节点的属性。
获取节点之后的另一个主要操作就是获取其内部的文本了,此时可以调用text()
方法来实现:
1 |
html = ''' |
运行结果如下:
1 |
<a href="link3.html"><span class="bold">third item</span></a> |
这里首先选中一个a
节点,然后调用text()
方法,就可以获取其内部的文本信息。此时它会忽略掉节点内部包含的所有HTML,只返回纯文字内容。
但如果想要获取这个节点内部的HTML文本,就要用html()
方法了:
1 |
from pyquery import PyQuery as pq |
这里我们选中了第三个li
节点,然后调用了html()
方法,它返回的结果应该是li
节点内的所有HTML文本。
运行结果如下:
1 |
<a href="link3.html"><span class="bold">third item</span></a> |
这里同样有一个问题,如果我们选中的结果是多个节点,text()
或html()
会返回什么内容?我们用实例来看一下:
1 |
html = ''' |
运行结果如下:
1 |
<a href="link2.html">second item</a> |
结果可能比较出乎意料,html()
方法返回的是第一个li
节点的内部HTML文本,而text()
则返回了所有的li
节点内部的纯文本,中间用一个空格分割开,即返回结果是一个字符串。
所以这个地方值得注意,如果得到的结果是多个节点,并且想要获取每个节点的内部HTML文本,则需要遍历每个节点。而text()
方法不需要遍历就可以获取,它将所有节点取文本之后合并成一个字符串。
pyquery提供了一系列方法来对节点进行动态修改,比如为某个节点添加一个class
,移除某个节点等,这些操作有时候会为提取信息带来极大的便利。
由于节点操作的方法太多,下面举几个典型的例子来说明它的用法。
addClass
和removeClass
我们先用实例来感受一下:
1 |
html = ''' |
首先选中了第三个li
节点,然后调用removeClass()
方法,将li
节点的active
这个class
移除,后来又调用addClass()
方法,将class
添加回来。每执行一次操作,就打印输出当前li
节点的内容。
运行结果如下:
1 |
<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
属性。
attr
、text
和html
当然,除了操作class
这个属性外,也可以用attr()
方法对属性进行操作。此外,还可以用text()
和html()
方法来改变节点内部的内容。示例如下:
1 |
html = ''' |
这里我们首先选中li
节点,然后调用attr()
方法来修改属性,其中该方法的第一个参数为属性名,第二个参数为属性值。接着,调用text()
和html()
方法来改变节点内部的内容。三次操作后,分别打印输出当前的li
节点。
运行结果如下:
1 |
<li class="item-0 active"><a href="link3.html"><span class="bold">third item</span></a></li> |
可以发现,调用attr()
方法后,li
节点多了一个原本不存在的属性name
,其值为link
。接着调用text()
方法,传入文本之后,li
节点内部的文本全被改为传入的字符串文本了。最后,调用html()
方法传入HTML文本后,li
节点内部又变为传入的HTML文本了。
所以说,如果attr()
方法只传入第一个参数的属性名,则是获取这个属性值;如果传入第二个参数,可以用来修改属性值。text()
和html()
方法如果不传参数,则是获取节点内纯文本和HTML文本;如果传入参数,则进行赋值。
remove()
顾名思义,remove()
方法就是移除,它有时会为信息的提取带来非常大的便利。下面有一段HTML文本:
1 |
html = ''' |
现在想提取Hello, World
这个字符串,而不要p
节点内部的字符串,需要怎样操作呢?
这里直接先尝试提取class
为wrap
的节点的内容,看看是不是我们想要的。运行结果如下:
1 |
Hello, World This is a paragraph. |
这个结果还包含了内部的p
节点的内容,也就是说text()
把所有的纯文本全提取出来了。如果我们想去掉p
节点内部的文本,可以选择再把p
节点内的文本提取一遍,然后从整个结果中移除这个子串,但这个做法明显比较烦琐。
这时remove()
方法就可以派上用场了,我们可以接着这么做:
1 |
wrap.find('p').remove() |
首先选中p
节点,然后调用了remove()
方法将其移除,然后这时wrap
内部就只剩下Hello, World
这句话了,然后再利用text()
方法提取即可。
另外,其实还有很多节点操作的方法,比如append()
、empty()
和prepend()
等方法,它们和jQuery的用法完全一致,详细的用法可以参考官方文档:http://pyquery.readthedocs.io/en/latest/api.html。
CSS选择器之所以强大,还有一个很重要的原因,那就是它支持多种多样的伪类选择器,例如选择第一个节点、最后一个节点、奇偶数节点、包含某一文本的节点等。示例如下:
1 |
html = ''' |
这里我们使用了CSS3的伪类选择器,依次选择了第一个li
节点、最后一个li
节点、第二个li
节点、第三个li
之后的li
节点、偶数位置的li
节点、包含second
文本的li
节点。
关于CSS选择器的更多用法,可以参考http://www.w3school.com.cn/css/index.asp。
到此为止,pyquery的常用用法就介绍完了。如果想查看更多的内容,可以参考pyquery的官方文档:http://pyquery.readthedocs.io。我们相信有了它,解析网页不再是难事。
前面介绍了正则表达式的相关用法,但是一旦正则表达式写的有问题,得到的可能就不是我们想要的结果了。而且对于一个网页来说,都有一定的特殊结构和层级关系,而且很多节点都有id
或class
来作区分,所以借助它们的结构和属性来提取不也可以吗?
这一节中,我们就来介绍一个强大的解析工具Beautiful Soup,它借助网页的结构和属性等特性来解析网页。有了它,我们不用再去写一些复杂的正则表达式,只需要简单的几条语句,就可以完成网页中某个元素的提取。
废话不多说,接下来就来感受一下Beautiful Soup的强大之处吧。
简单来说,Beautiful Soup就是Python的一个HTML或XML的解析库,可以用它来方便地从网页中提取数据。官方解释如下:
Beautiful Soup提供一些简单的、Python式的函数来处理导航、搜索、修改分析树等功能。它是一个工具箱,通过解析文档为用户提供需要抓取的数据,因为简单,所以不需要多少代码就可以写出一个完整的应用程序。
Beautiful Soup自动将输入文档转换为Unicode编码,输出文档转换为UTF-8编码。你不需要考虑编码方式,除非文档没有指定一个编码方式,这时你仅仅需要说明一下原始编码方式就可以了。
Beautiful Soup已成为和lxml、html6lib一样出色的Python解释器,为用户灵活地提供不同的解析策略或强劲的速度。
所以说,利用它可以省去很多烦琐的提取工作,提高了解析效率。
在开始之前,请确保已经正确安装好了Beautiful Soup和lxml,如果没有安装,可以参考第1章的内容。
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 |
from bs4 import BeautifulSoup |
在后面,Beautiful Soup的用法实例也统一用这个解析器来演示。
下面首先用实例来看看Beautiful Soup的基本用法:
1 |
html = """ |
运行结果如下:
1 |
<html> |
这里首先声明变量html
,它是一个HTML字符串。但是需要注意的是,它并不是一个完整的HTML字符串,因为body
和html
节点都没有闭合。接着,我们将它当作第一个参数传给BeautifulSoup
对象,该对象的第二个参数为解析器的类型(这里使用lxml
),此时就完成了BeaufulSoup
对象的初始化。然后,将这个对象赋值给soup
变量。
接下来,就可以调用soup
的各个方法和属性解析这串HTML代码了。
首先,调用prettify()
方法。这个方法可以把要解析的字符串以标准的缩进格式输出。这里需要注意的是,输出结果里面包含body
和html
节点,也就是说对于不标准的HTML字符串BeautifulSoup
,可以自动更正格式。这一步不是由prettify()
方法做的,而是在初始化BeautifulSoup
时就完成了。
然后调用soup.title.string
,这实际上是输出HTML中title
节点的文本内容。所以,soup.title
可以选出HTML中的title
节点,再调用string
属性就可以得到里面的文本了,所以我们可以通过简单调用几个属性完成文本提取,这是不是非常方便?
直接调用节点的名称就可以选择节点元素,再调用string
属性就可以得到节点内的文本了,这种选择方式速度非常快。如果单个节点结构层次非常清晰,可以选用这种方式来解析。
下面再用一个例子详细说明选择元素的方法:
1 |
html = """ |
运行结果如下:
1 |
<title>The Dormouse's story</title> |
这里依然选用刚才的HTML代码,首先打印输出title
节点的选择结果,输出结果正是title
节点加里面的文字内容。接下来,输出它的类型,是bs4.element.Tag
类型,这是Beautiful Soup中一个重要的数据结构。经过选择器选择后,选择结果都是这种Tag
类型。Tag
具有一些属性,比如string
属性,调用该属性,可以得到节点的文本内容,所以接下来的输出结果正是节点的文本内容。
接下来,我们又尝试选择了head
节点,结果也是节点加其内部的所有内容。最后,选择了p
节点。不过这次情况比较特殊,我们发现结果是第一个p
节点的内容,后面的几个p
节点并没有选到。也就是说,当有多个节点时,这种选择方式只会选择到第一个匹配的节点,其他的后面节点都会忽略。
上面演示了调用string
属性来获取文本的值,那么如何获取节点属性的值呢?如何获取节点名呢?下面我们来统一梳理一下信息的提取方式。
可以利用name
属性获取节点的名称。这里还是以上面的文本为例,选取title
节点,然后调用name
属性就可以得到节点名称:
1 |
print(soup.title.name) |
运行结果如下:
1 |
title |
每个节点可能有多个属性,比如id
和class
等,选择这个节点元素后,可以调用attrs
获取所有属性:
1 |
print(soup.p.attrs) |
运行结果如下:
1 |
{'class': ['title'], 'name': 'dromouse'} |
可以看到,attrs
的返回结果是字典形式,它把选择的节点的所有属性和属性值组合成一个字典。接下来,如果要获取name
属性,就相当于从字典中获取某个键值,只需要用中括号加属性名就可以了。比如,要获取name
属性,就可以通过attrs['name']
来得到。
其实这样有点烦琐,还有一种更简单的获取方式:可以不用写attrs
,直接在节点元素后面加中括号,传入属性名就可以获取属性值了。样例如下:
1 |
print(soup.p['name']) |
运行结果如下:
1 |
dromouse |
这里需要注意的是,有的返回结果是字符串,有的返回结果是字符串组成的列表。比如,name
属性的值是唯一的,返回的结果就是单个字符串。而对于class
,一个节点元素可能有多个class
,所以返回的是列表。在实际处理过程中,我们要注意判断类型。
可以利用string
属性获取节点元素包含的文本内容,比如要获取第一个p
节点的文本:
1 |
print(soup.p.string) |
运行结果如下:
1 |
The Dormouse's story |
再次注意一下,这里选择到的p
节点是第一个p
节点,获取的文本也是第一个p
节点里面的文本。
在上面的例子中,我们知道每一个返回结果都是bs4.element.Tag
类型,它同样可以继续调用节点进行下一步的选择。比如,我们获取了head
节点元素,我们可以继续调用head
来选取其内部的head
节点元素:
1 |
html = """ |
运行结果如下:
1 |
<title>The Dormouse's story</title> |
第一行结果是调用head
之后再次调用title
而选择的title
节点元素。然后打印输出了它的类型,可以看到,它仍然是bs4.element.Tag
类型。也就是说,我们在Tag
类型的基础上再次选择得到的依然还是Tag
类型,每次返回的结果都相同,所以这样就可以做嵌套选择了。
最后,输出它的string
属性,也就是节点里的文本内容。
在做选择的时候,有时候不能做到一步就选到想要的节点元素,需要先选中某一个节点元素,然后以它为基准再选择它的子节点、父节点、兄弟节点等,这里就来介绍如何选择这些节点元素。
选取节点元素之后,如果想要获取它的直接子节点,可以调用contents
属性,示例如下:
1 |
html = """ |
运行结果如下:
1 |
['\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"> |
可以看到,返回结果是列表形式。p
节点里既包含文本,又包含节点,最后会将它们以列表形式统一返回。
需要注意的是,列表中的每个元素都是p
节点的直接子节点。比如第一个a
节点里面包含一层span
节点,这相当于孙子节点了,但是返回结果并没有单独把span
节点选出来。所以说,contents
属性得到的结果是直接子节点的列表。
同样,我们可以调用children
属性得到相应的结果:
1 |
from bs4 import BeautifulSoup |
运行结果如下:
1 |
<list_iterator object at 0x1064f7dd8> |
还是同样的HTML文本,这里调用了children
属性来选择,返回结果是生成器类型。接下来,我们用for
循环输出相应的内容。
如果要得到所有的子孙节点的话,可以调用descendants
属性:
1 |
from bs4 import BeautifulSoup |
运行结果如下:
1 |
<generator object descendants at 0x10650e678> |
此时返回结果还是生成器。遍历输出一下可以看到,这次的输出结果就包含了span
节点。descendants
会递归查询所有子节点,得到所有的子孙节点。
如果要获取某个节点元素的父节点,可以调用parent
属性:
1 |
html = """ |
运行结果如下:
1 |
<p class="story"> |
这里我们选择的是第一个a
节点的父节点元素。很明显,它的父节点是p
节点,输出结果便是p
节点及其内部的内容。
需要注意的是,这里输出的仅仅是a
节点的直接父节点,而没有再向外寻找父节点的祖先节点。如果想获取所有的祖先节点,可以调用parents
属性:
1 |
html = """ |
运行结果如下:
1 |
<class 'generator'> |
可以发现,返回结果是生成器类型。这里用列表输出了它的索引和内容,而列表中的元素就是a
节点的祖先节点。
上面说明了子节点和父节点的获取方式,如果要获取同级的节点(也就是兄弟节点),应该怎么办呢?示例如下:
1 |
html = """ |
运行结果如下:
1 |
Next Sibling |
可以看到,这里调用了4个属性,其中next_sibling
和previous_sibling
分别获取节点的下一个和上一个兄弟元素,next_siblings
和previous_siblings
则分别返回所有前面和后面的兄弟节点的生成器。
前面讲解了关联元素节点的选择方法,如果想要获取它们的一些信息,比如文本、属性等,也用同样的方法,示例如下:
1 |
html = """ |
运行结果如下:
1 |
Next Sibling: |
如果返回结果是单个节点,那么可以直接调用string
、attrs
等属性获得其文本和属性;如果返回结果是多个节点的生成器,则可以转为列表后取出某个元素,然后再调用string
、attrs
等属性获取其对应节点的文本和属性。
前面所讲的选择方法都是通过属性来选择的,这种方法非常快,但是如果进行比较复杂的选择的话,它就比较烦琐,不够灵活了。幸好,Beautiful Soup还为我们提供了一些查询方法,比如find_all()
和find()
等,调用它们,然后传入相应的参数,就可以灵活查询了。
find_all()
find_all
,顾名思义,就是查询所有符合条件的元素。给它传入一些属性或文本,就可以得到符合条件的元素,它的功能十分强大。
它的API如下:
1 |
find_all(name , attrs , recursive , text , **kwargs) |
name
我们可以根据节点名来查询元素,示例如下:
1 |
html=''' |
运行结果如下:
1 |
[<ul class="list" id="list-1"> |
这里我们调用了find_all()
方法,传入name
参数,其参数值为ul
。也就是说,我们想要查询所有ul
节点,返回结果是列表类型,长度为2,每个元素依然都是bs4.element.Tag
类型。
因为都是Tag
类型,所以依然可以进行嵌套查询。还是同样的文本,这里查询出所有ul
节点后,再继续查询其内部的li
节点:
1 |
for ul in soup.find_all(name='ul'): |
运行结果如下:
1 |
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>] |
返回结果是列表类型,列表中的每个元素依然还是Tag
类型。
接下来,就可以遍历每个li
,获取它的文本了:
1 |
for ul in soup.find_all(name='ul'): |
运行结果如下:
1 |
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>] |
attrs
除了根据节点名查询,我们也可以传入一些属性来查询,示例如下:
1 |
html=''' |
运行结果如下:
1 |
[<ul class="list" id="list-1" name="elements"> |
这里查询的时候传入的是attrs
参数,参数的类型是字典类型。比如,要查询id
为list-1
的节点,可以传入attrs={'id': 'list-1'}
的查询条件,得到的结果是列表形式,包含的内容就是符合id
为list-1
的所有节点。在上面的例子中,符合条件的元素个数是1,所以结果是长度为1的列表。
对于一些常用的属性,比如id
和class
等,我们可以不用attrs
来传递。比如,要查询id
为list-1
的节点,可以直接传入id
这个参数。还是上面的文本,我们换一种方式来查询:
1 |
from bs4 import BeautifulSoup |
运行结果如下:
1 |
[<ul class="list" id="list-1"> |
这里直接传入id='list-1'
,就可以查询id
为list-1
的节点元素了。而对于class
来说,由于class
在Python里是一个关键字,所以后面需要加一个下划线,即class_='element'
,返回的结果依然还是Tag
组成的列表。
text
text
参数可用来匹配节点的文本,传入的形式可以是字符串,可以是正则表达式对象,示例如下:
1 |
import re |
运行结果如下:
1 |
['Hello, this is a link', 'Hello, this is a link, too'] |
这里有两个a
节点,其内部包含文本信息。这里在find_all()
方法中传入text
参数,该参数为正则表达式对象,结果返回所有匹配正则表达式的节点文本组成的列表。
find()
除了find_all()
方法,还有find()
方法,只不过后者返回的是单个元素,也就是第一个匹配的元素,而前者返回的是所有匹配的元素组成的列表。示例如下:
1 |
html=''' |
运行结果如下:
1 |
<ul class="list" id="list-1"> |
这里的返回结果不再是列表形式,而是第一个匹配的节点元素,类型依然是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()
:前者返回节点后所有符合条件的节点,后者返回第一个符合条件的节点。Beautiful Soup还提供了另外一种选择器,那就是CSS选择器。如果对Web开发熟悉的话,那么对CSS选择器肯定也不陌生。如果不熟悉的话,可以参考http://www.w3school.com.cn/cssref/css_selectors.asp了解。
使用CSS选择器时,只需要调用select()
方法,传入相应的CSS选择器即可,示例如下:
1 |
html=''' |
运行结果如下:
1 |
[<div class="panel-heading"> |
这里我们用了3次CSS选择器,返回的结果均是符合CSS选择器的节点组成的列表。例如,select('ul li')
则是选择所有ul
节点下面的所有li
节点,结果便是所有的li
节点组成的列表。
最后一句打印输出了列表中元素的类型。可以看到,类型依然是Tag
类型。
select()
方法同样支持嵌套选择。例如,先选择所有ul
节点,再遍历每个ul
节点,选择其li
节点,样例如下:
1 |
from bs4 import BeautifulSoup |
运行结果如下:
1 |
[<li class="element">Foo</li>, <li class="element">Bar</li>, <li class="element">Jay</li>] |
可以看到,这里正常输出了所有ul
节点下所有li
节点组成的列表。
我们知道节点类型是Tag
类型,所以获取属性还可以用原来的方法。仍然是上面的HTML文本,这里尝试获取每个ul
节点的id
属性:
1 |
from bs4 import BeautifulSoup |
运行结果如下:
1 |
list-1 |
可以看到,直接传入中括号和属性名,以及通过attrs
属性获取属性值,都可以成功。
要获取文本,当然也可以用前面所讲的string
属性。此外,还有一个方法,那就是get_text()
,示例如下:
1 |
from bs4 import BeautifulSoup |
运行结果如下:
1 |
Get Text: Foo |
可以看到,二者的效果完全一致。
到此,Beautiful Soup的用法基本就介绍完了,最后做一下简单的总结。
find()
或者find_all()
查询匹配单个结果或者多个结果。select()
方法选择。
XPath,全称XML Path Language,即XML路径语言,它是一门在XML文档中查找信息的语言。它最初是用来搜寻XML文档的,但是它同样适用于HTML文档的搜索。
所以在做爬虫时,我们完全可以使用XPath来做相应的信息抽取。本节中,我们就来介绍XPath的基本用法。
XPath的选择功能十分强大,它提供了非常简洁明了的路径选择表达式。另外,它还提供了超过100个内建函数,用于字符串、数值、时间的匹配以及节点、序列的处理等。几乎所有我们想要定位的节点,都可以用XPath来选择。
XPath于1999年11月16日成为W3C标准,它被设计为供XSLT、XPointer以及其他XML解析软件使用,更多的文档可以访问其官方网站:https://www.w3.org/TR/xpath/。
表4-1列举了XPath的几个常用规则。
表4-1 XPath常用规则
表达式
描述
nodename
选取此节点的所有子节点
/
从当前节点选取直接子节点
//
从当前节点选取子孙节点
.
选取当前节点
..
选取当前节点的父节点
@
选取属性
这里列出了XPath的常用匹配规则,示例如下:
1 |
//title[@lang='eng'] |
这就是一个XPath规则,它代表选择所有名称为title
,同时属性lang
的值为eng
的节点。
后面会通过Python的lxml库,利用XPath进行HTML的解析。
使用之前,首先要确保安装好lxml库,若没有安装,可以参考第1章的安装过程。
现在通过实例来感受一下使用XPath来对网页进行解析的过程,相关代码如下:
1 |
from lxml import etree |
这里首先导入lxml库的etree模块,然后声明了一段HTML文本,调用HTML类进行初始化,这样就成功构造了一个XPath解析对象。这里需要注意的是,HTML文本中的最后一个li
节点是没有闭合的,但是etree模块可以自动修正HTML文本。
这里我们调用tostring()
方法即可输出修正后的HTML代码,但是结果是bytes
类型。这里利用decode()
方法将其转成str
类型,结果如下:
1 |
<html><body><div> |
可以看到,经过处理之后,li
节点标签被补全,并且还自动添加了body
、html
节点。
另外,也可以直接读取文本文件进行解析,示例如下:
1 |
from lxml import etree |
其中test.html的内容就是上面例子中的HTML代码,内容如下:
1 |
<div> |
这次的输出结果略有不同,多了一个DOCTYPE
的声明,不过对解析无任何影响,结果如下:
1 |
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd"> |
我们一般会用//开头的XPath规则来选取所有符合要求的节点。这里以前面的HTML文本为例,如果要选取所有节点,可以这样实现:
1 |
from lxml import etree |
运行结果如下:
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
类型,其后跟了节点的名称,如html
、body
、div
、ul
、li
、a
等,所有节点都包含在列表中了。
当然,此处匹配也可以指定节点名称。如果想获取所有li
节点,示例如下:
1 |
from lxml import etree |
这里要选取所有li
节点,可以使用//
,然后直接加上节点名称即可,调用时直接使用xpath()
方法即可。
运行结果:
1 |
[<Element li at 0x105849208>, <Element li at 0x105849248>, <Element li at 0x105849288>, <Element li at 0x1058492c8>, <Element li at 0x105849308>] |
这里可以看到提取结果是一个列表形式,其中每个元素都是一个 Element
对象。如果要取出其中一个对象,可以直接用中括号加索引,如[0]
。
我们通过/
或//
即可查找元素的子节点或子孙节点。假如现在想选择li
节点的所有直接a
子节点,可以这样实现:
1 |
from lxml import etree |
这里通过追加/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 |
from lxml import etree |
运行结果是相同的。
但是如果这里用//ul/a
,就无法获取任何结果了。因为/
用于获取直接子节点,而在ul
节点下没有直接的a
子节点,只有li
节点,所以无法获取任何匹配结果,代码如下:
1 |
from lxml import etree |
运行结果如下:
1 |
[] |
因此,这里我们要注意/
和//
的区别,其中/
用于获取直接子节点,//
用于获取子孙节点。
我们知道通过连续的/
或//
可以查找子节点或子孙节点,那么假如我们知道了子节点,怎样来查找父节点呢?这可以用..
来实现。
比如,现在首先选中href
属性为link4.html
的a
节点,然后再获取其父节点,然后再获取其class
属性,相关代码如下:
1 |
from lxml import etree |
运行结果如下:
1 |
['item-1'] |
检查一下结果发现,这正是我们获取的目标li
节点的class
。
同时,我们也可以通过parent::
来获取父节点,代码如下:
1 |
from lxml import etree |
在选取的时候,我们还可以用@
符号进行属性过滤。比如,这里如果要选取class
为item-1
的li
节点,可以这样实现:
1 |
from lxml import etree |
这里我们通过加入[@class="item-0"]
,限制了节点的class
属性为item-0
,而HTML文本中符合条件的li
节点有两个,所以结果应该返回两个匹配到的元素。结果如下:
1 |
[<Element li at 0x10a399288>, <Element li at 0x10a3992c8>] |
可见,匹配结果正是两个,至于是不是那正确的两个,后面再验证。
我们用XPath中的text()
方法获取节点中的文本,接下来尝试获取前面li
节点中的文本,相关代码如下:
1 |
from lxml import etree |
运行结果如下:
1 |
['\n '] |
奇怪的是,我们并没有获取到任何文本,只获取到了一个换行符,这是为什么呢?因为XPath中text()
前面是/
,而此处/
的含义是选取直接子节点,很明显li
的直接子节点都是a
节点,文本都是在a
节点内部的,所以这里匹配到的结果就是被修正的li
节点内部的换行符,因为自动修正的li
节点的尾标签换行了。
即选中的是这两个节点:
1 |
<li class="item-0"><a href="link1.html">first item</a></li> |
其中一个节点因为自动修正,li
节点的尾标签添加的时候换行了,所以提取文本得到的唯一结果就是li
节点的尾标签和a
节点的尾标签之间的换行符。
因此,如果想获取li
节点内部的文本,就有两种方式,一种是先选取a
节点再获取文本,另一种就是使用//
。接下来,我们来看下二者的区别。
首先,选取到a
节点再获取文本,代码如下:
1 |
from lxml import etree |
运行结果如下:
1 |
['first item', 'fifth item'] |
可以看到,这里的返回值是两个,内容都是属性为item-0
的li
节点的文本,这也印证了前面属性匹配的结果是正确的。
这里我们是逐层选取的,先选取了li
节点,又利用/
选取了其直接子节点a
,然后再选取其文本,得到的结果恰好是符合我们预期的两个结果。
再来看下用另一种方式(即使用//
)选取的结果,代码如下:
1 |
from lxml import etree |
运行结果如下:
1 |
['first item', 'fifth item', '\n '] |
不出所料,这里的返回结果是3个。可想而知,这里是选取所有子孙节点的文本,其中前两个就是li
的子节点a
节点内部的文本,另外一个就是最后一个li
节点内部的文本,即换行符。
所以说,如果要想获取子孙节点内部的所有文本,可以直接用//
加text()
的方式,这样可以保证获取到最全面的文本信息,但是可能会夹杂一些换行符等特殊字符。如果想获取某些特定子孙节点下的所有文本,可以先选取到特定的子孙节点,然后再调用text()
方法获取其内部文本,这样可以保证获取的结果是整洁的。
我们知道用text()
可以获取节点内部文本,那么节点属性该怎样获取呢?其实还是用@
符号就可以。例如,我们想获取所有li
节点下所有a
节点的href
属性,代码如下:
1 |
from lxml import etree |
这里我们通过@href
即可获取节点的href
属性。注意,此处和属性匹配的方法不同,属性匹配是中括号加属性名和值来限定某个属性,如[@href="link1.html"]
,而此处的@href
指的是获取节点的某个属性,二者需要做好区分。
运行结果如下:
1 |
['link1.html', 'link2.html', 'link3.html', 'link4.html', 'link5.html'] |
可以看到,我们成功获取了所有li
节点下a
节点的href
属性,它们以列表形式返回。
有时候,某些节点的某个属性可能有多个值,例如:
1 |
from lxml import etree |
这里HTML文本中li
节点的class
属性有两个值li
和li-first
,此时如果还想用之前的属性匹配获取,就无法匹配了,此时的运行结果如下:
1 |
[] |
这时就需要用contains()
函数了,代码可以改写如下:
1 |
from lxml import etree |
这样通过contains()
方法,第一个参数传入属性名称,第二个参数传入属性值,只要此属性包含所传入的属性值,就可以完成匹配了。
此时运行结果如下:
1 |
['first item'] |
此种方式在某个节点的某个属性有多个值时经常用到,如某个节点的class
属性通常有多个。
另外,我们可能还遇到一种情况,那就是根据多个属性确定一个节点,这时就需要同时匹配多个属性。此时可以使用运算符and
来连接,示例如下:
1 |
from lxml import etree |
这里的li
节点又增加了一个属性name
。要确定这个节点,需要同时根据class
和name
属性来选择,一个条件是class
属性里面包含li
字符串,另一个条件是name
属性为item
字符串,二者需要同时满足,需要用and
操作符相连,相连之后置于中括号内进行条件筛选。运行结果如下:
1 |
['first item'] |
这里的and
其实是XPath中的运算符。另外,还有很多运算符,如or
、mod
等,在此总结为表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
。如果age
是18
,则返回false
mod
计算除法的余数
5 mod 2
1
|
计算两个节点集
//book | //cd
返回所有拥有book
和cd
元素的节点集
+
加法
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。
有时候,我们在选择的时候某些属性可能同时匹配了多个节点,但是只想要其中的某个节点,如第二个节点或者最后一个节点,这时该怎么办呢?
这时可以利用中括号传入索引的方法获取特定次序的节点,示例如下:
1 |
from lxml import etree |
第一次选择时,我们选取了第一个li
节点,中括号中传入数字1即可。注意,这里和代码中不同,序号是以1开头的,不是以0开头。
第二次选择时,我们选取了最后一个li
节点,中括号中传入last()
即可,返回的便是最后一个li
节点。
第三次选择时,我们选取了位置小于3的li
节点,也就是位置序号为1和2的节点,得到的结果就是前两个li
节点。
第四次选择时,我们选取了倒数第三个li
节点,中括号中传入last()-2
即可。因为last()
是最后一个,所以last()-2
就是倒数第三个。
运行结果如下:
1 |
['first item'] |
这里我们使用了last()
、position()
等函数。在XPath中,提供了100多个函数,包括存取、数值、字符串、逻辑、节点、序列等处理功能,它们的具体作用可以参考:http://www.w3school.com.cn/xpath/xpath_functions.asp。
XPath提供了很多节点轴选择方法,包括获取子元素、兄弟元素、父元素、祖先元素等,示例如下:
1 |
from lxml import etree |
运行结果如下:
1 |
[<Element html at 0x107941808>, <Element body at 0x1079418c8>, <Element div at 0x107941908>, <Element ul at 0x107941948>] |
第一次选择时,我们调用了ancestor
轴,可以获取所有祖先节点。其后需要跟两个冒号,然后是节点的选择器,这里我们直接使用*,表示匹配所有节点,因此返回结果是第一个li
节点的所有祖先节点,包括html
、body
、div
和ul
。
第二次选择时,我们又加了限定条件,这次在冒号后面加了div
,这样得到的结果就只有div
这个祖先节点了。
第三次选择时,我们调用了attribute
轴,可以获取所有属性值,其后跟的选择器还是*,这代表获取节点的所有属性,返回值就是li
节点的所有属性值。
第四次选择时,我们调用了child
轴,可以获取所有直接子节点。这里我们又加了限定条件,选取href
属性为link1.html
的a
节点。
第五次选择时,我们调用了descendant
轴,可以获取所有子孙节点。这里我们又加了限定条件获取span
节点,所以返回的结果只包含span
节点而不包含a
节点。
第六次选择时,我们调用了following
轴,可以获取当前节点之后的所有节点。这里我们虽然使用的是*匹配,但又加了索引选择,所以只获取了第二个后续节点。
第七次选择时,我们调用了following-sibling
轴,可以获取当前节点之后的所有同级节点。这里我们使用*匹配,所以获取了所有后续同级节点。
以上是XPath轴的简单用法,更多轴的用法可以参考:http://www.w3school.com.cn/xpath/xpath_axes.asp。
到现在为止,我们基本上把可能用到的XPath选择器介绍完了。XPath功能非常强大,内置函数非常多,熟练使用之后,可以大大提升HTML信息的提取效率。
如果想查询更多XPath的用法,可以查看:http://www.w3school.com.cn/xpath/index.asp。
如果想查询更多Python lxml库的用法,可以查看http://lxml.de/。
上一章中,我们实现了一个最基本的爬虫,但提取页面信息时使用的是正则表达式,这还是比较烦琐,而且万一有地方写错了,可能导致匹配失败,所以使用正则表达式提取页面信息多多少少还是有些不方便。
对于网页的节点来说,它可以定义id
、class
或其他属性。而且节点之间还有层次关系,在网页中可以通过XPath或CSS选择器来定位一个或多个节点。那么,在页面解析时,利用XPath或CSS选择器来提取某个节点,然后再调用相应方法获取它的正文内容或者属性,不就可以提取我们想要的任意信息了吗?
在Python中,怎样实现这个操作呢?不用担心,这种解析库已经非常多,其中比较强大的库有lxml、Beautiful Soup、pyquery等,本章就来介绍这3个解析库的用法。有了它们,我们就不用再为正则表达式发愁,而且解析效率也会大大提高。
本节中,我们利用 requests 库和正则表达式来抓取猫眼电影 TOP100 的相关内容。requests 比 urllib 使用更加方便,而且目前我们还没有系统学习 HTML 解析库,所以这里就选用正则表达式来作为解析工具。
本节中,我们要提取出猫眼电影 TOP100 的电影名称、时间、评分、图片等信息,提取的站点 URL 为http://maoyan.com/board/4,提取的结果会以文件形式保存下来。
在本节开始之前,请确保已经正确安装好了 requests 库。如果没有安装,可以参考第 1 章的安装说明。
我们需要抓取的目标站点为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+1
到n+10
,每页显示 10 个。所以,如果想获取 TOP100 电影,只需要分开请求 10 次,而 10 次的offset
参数分别设置为 0、10、20、…90 即可,这样获取不同的页面之后,再用正则表达式提取出相关信息,就可以得到 TOP100 的所有电影信息了。
接下来用代码实现这个过程。首先抓取第一页的内容。我们实现了get_one_page()
方法,并给它传入url
参数。然后将抓取的页面结果返回,再通过main()
方法调用。初步代码实现如下:
1 |
import requests |
这样运行之后,就可以成功获取首页的源代码了。获取源代码后,就需要解析页面,提取出我们想要的信息。
接下来,回到网页看一下页面的真实源码。在开发者模式下的 Network 监听组件中查看源代码,如图 3-13 所示。
图 3-13 源代码
注意,这里不要在 Elements 选项卡中直接查看源码,因为那里的源码可能经过 JavaScript 操作而与原始请求不同,而是需要从 Network 选项卡部分查看原始请求得到的源码。
查看其中一个条目的源代码,如图 3-14 所示。
图 3-14 源代码
可以看到,一部电影信息对应的源代码是一个dd
节点,我们用正则表达式来提取这里面的一些电影信息。首先,需要提取它的排名信息。而它的排名信息是在class
为board-index
的i
节点内,这里利用非贪婪匹配来提取i
节点内的信息,正则表达式写为:
1 |
<dd>.*?board-index.*?>(.*?)</i> |
随后需要提取电影的图片。可以看到,后面有a
节点,其内部有两个img
节点。经过检查后发现,第二个img
节点的data-src
属性是图片的链接。这里提取第二个img
节点的data-src
属性,正则表达式可以改写如下:
1 |
<dd>.*?board-index.*?>(.*?)</i>.*?data-src="(.*?)" |
再往后,需要提取电影的名称,它在后面的p
节点内,class
为name
。所以,可以用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 |
def parse_one_page(html): |
这样就可以成功地将一页的 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 |
def parse_one_page(html): |
这样就可以成功提取出电影的排名、图片、标题、演员、时间、评分等内容了,并把它赋值为一个个的字典,形成结构化数据。运行结果如下:
1 |
{'image': 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c', 'actor': '张国荣,张丰毅,巩俐', 'score': '9.6', 'index': '1', 'title': '霸王别姬', 'time': '1993-01-01(中国香港)'} |
到此为止,我们就成功提取了单页的电影信息。
随后,我们将提取的结果写入文件,这里直接写入到一个文本文件中。这里通过 JSON 库的dumps()
方法实现字典的序列化,并指定ensure_ascii
参数为False
,这样可以保证输出结果是中文形式而不是 Unicode 编码。代码如下:
1 |
def write_to_json(content): |
通过调用write_to_json()
方法即可实现将字典写入到文本文件的过程,此处的content
参数就是一部电影的提取结果,是一个字典。
最后,实现main()
方法来调用前面实现的方法,将单页的电影结果写入到文件。相关代码如下:
1 |
def main(): |
到此为止,我们就完成了单页电影的提取,也就是首页的 10 部电影可以成功提取并保存到文本文件中了。
因为我们需要抓取的是 TOP100 的电影,所以还需要遍历一下,给这个链接传入offset
参数,实现其他 90 部电影的爬取,此时添加如下调用即可:
1 |
if __name__ == '__main__': |
这里还需要将main()
方法修改一下,接收一个offset
值作为偏移量,然后构造 URL 进行爬取。实现代码如下:
1 |
def main(offset): |
到此为止,我们的猫眼电影 TOP100 的爬虫就全部完成了,再稍微整理一下,完整的代码如下:
1 |
import json |
现在猫眼多了反爬虫,如果速度过快,则会无响应,所以这里又增加了一个延时等待。
最后,我们运行一下代码,输出结果类似如下:
1 |
{'index': '1', 'image': 'http://p1.meituan.net/movie/20803f59291c47e1e116c11963ce019e68711.jpg@160w_220h_1e_1c', 'title': '霸王别姬', 'actor': '张国荣,张丰毅,巩俐', 'time': '1993-01-01(中国香港)', 'score': '9.6'} |
这里省略了中间的部分输出结果。可以看到,这样就成功地把 TOP100 的电影信息爬取下来了。
这时我们再看下文本文件,结果如图 3-15 所示。
图 3-15 运行结果
可以看到,电影信息也已全部保存到了文本文件中了,大功告成!
本节的代码地址为https://github.com/Python3WebSpider/MaoYan。
本节中,我们通过爬取猫眼 TOP100 的电影信息练习了 requests 和正则表达式的用法。这是一个最基础的实例,希望大家可以通过这个实例对爬虫的实现有一个最基本的思路,也对这两个库的用法有更深一步的了解。
本节中,我们看一下正则表达式的相关用法。正则表达式是处理字符串的强大工具,它有自己特定的语法结构,有了它,实现字符串的检索、替换、匹配验证都不在话下。
当然,对于爬虫来说,有了它,从 HTML 里提取想要的信息就非常方便了。
说了这么多,可能我们对它到底是个什么还是比较模糊,下面就用几个实例来看一下正则表达式的用法。
打开开源中国提供的正则表达式测试工具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]
匹配a
、m
或k
[^...]
不在[]
中的字符,比如[^abc]
匹配除了a
、b
、c
之外的字符
*
匹配 0 个或多个表达式
+
匹配 1 个或多个表达式
?
匹配 0 个或 1 个前面的正则表达式定义的片段,非贪婪方式
{n}
精确匹配n
个前面的表达式
{n, m}
匹配n
到m
次由前面正则表达式定义的片段,贪婪方式
a|b
匹配a
或b
( )
匹配括号内的表达式,也表示一个组
看完了之后,可能有点晕晕的吧,不过不用担心,后面我们会详细讲解一些常见规则的用法。
其实正则表达式不是 Python 独有的,它也可以用在其他编程语言中。但是 Python 的 re 库提供了整个正则表达式的实现,利用这个库,可以在 Python 中使用正则表达式。在 Python 中写正则表达式几乎都用这个库,下面就来了解它的一些常用方法。
match()
这里首先介绍第一个常用的匹配方法——match()
,向它传入要匹配的字符串以及正则表达式,就可以检测这个正则表达式是否匹配字符串。
match()
方法会尝试从字符串的起始位置匹配正则表达式,如果匹配,就返回匹配成功的结果;如果不匹配,就返回None
。示例如下:
1 |
import re |
运行结果如下:
1 |
41 |
这里首先声明了一个字符串,其中包含英文字母、空白字符、数字等。接下来,我们写一个正则表达式:
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 |
import re |
这里我们想把字符串中的1234567
提取出来,此时可以将数字部分的正则表达式用()
括起来,然后调用了group(1)
获取匹配结果。
运行结果如下:
1 |
<_sre.SRE_Match object; span=(0, 19), match='Hello 1234567 World'> |
可以看到,我们成功得到了1234567
。这里用的是group(1)
,它与group()
有所不同,后者会输出完整的匹配结果,而前者会输出第一个被()
包围的匹配结果。假如正则表达式后面还有()
包括的内容,那么可以依次用group(2)
、group(3)
等来获取。
刚才我们写的正则表达式其实比较复杂,出现空白字符我们就写\\s
匹配,出现数字我们就用\\d
匹配,这样的工作量非常大。其实完全没必要这么做,因为还有一个万能匹配可以用,那就是.*
(点星)。其中.
(点)可以匹配任意字符(除换行符),*
(星)代表匹配前面的字符无限次,所以它们组合在一起就可以匹配任意字符了。有了它,我们就不用挨个字符地匹配了。
接着上面的例子,我们可以改写一下正则表达式:
1 |
import re |
这里我们将中间部分直接省略,全部用.*
来代替,最后加一个结尾字符串就好了。运行结果如下:
1 |
<_sre.SRE_Match object; span=(0, 41), match='Hello 123 4567 World_This is a Regex Demo'> |
可以看到,group()
方法输出了匹配的全部字符串,也就是说我们写的正则表达式匹配到了目标字符串的全部内容;span()
方法输出(0, 41)
,这是整个字符串的长度。
因此,我们可以使用.*
简化正则表达式的书写。
使用上面的通用匹配.*
时,可能有时候匹配到的并不是我们想要的结果。看下面的例子:
1 |
import re |
这里我们依然想获取中间的数字,所以中间依然写的是(\\d+)
。而数字两侧由于内容比较杂乱,所以想省略来写,都写成 .*
。最后,组成^He.*(\\d+).*Demo$
,看样子并没有什么问题。我们看下运行结果:
1 |
<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'> |
奇怪的事情发生了,我们只得到了 7 这个数字,这是怎么回事呢?
这里就涉及一个贪婪匹配与非贪婪匹配的问题了。在贪婪匹配下,.*
会匹配尽可能多的字符。正则表达式中.*
后面是\\d+
,也就是至少一个数字,并没有指定具体多少个数字,因此,.*
就尽可能匹配多的字符,这里就把123456
匹配了,给\\d+
留下一个可满足条件的数字 7,最后得到的内容就只有数字 7 了。
但这很明显会给我们带来很大的不便。有时候,匹配结果会莫名其妙少了一部分内容。其实,这里只需要使用非贪婪匹配就好了。非贪婪匹配的写法是.*?
,多了一个?
,那么它可以达到怎样的效果?我们再用实例看一下:
1 |
import re |
这里我们只是将第一个.*
改成了.*?
,转变为非贪婪匹配。结果如下:
1 |
<_sre.SRE_Match object; span=(0, 40), match='Hello 1234567 World_This is a Regex Demo'> |
此时就可以成功获取1234567
了。原因可想而知,贪婪匹配是尽可能匹配多的字符,非贪婪匹配就是尽可能匹配少的字符。当.*?
匹配到Hello
后面的空白字符时,再往后的字符就是数字了,而\\d+
恰好可以匹配,那么这里.*?
就不再进行匹配,交给\\d+
去匹配后面的数字。所以这样.*?
匹配了尽可能少的字符,\\d+
的结果就是1234567
了。
所以说,在做匹配的时候,字符串中间尽量使用非贪婪匹配,也就是用.*?
来代替.*
,以免出现匹配结果缺失的情况。
但这里需要注意,如果匹配的结果在字符串结尾,.*?
就有可能匹配不到任何内容了,因为它会匹配尽可能少的字符。例如:
1 |
import re |
运行结果如下:
1 |
result1 |
可以观察到,.*?
没有匹配到任何结果,而.*
则尽量匹配多的内容,成功得到了匹配结果。
正则表达式可以包含一些可选标志修饰符来控制匹配的模式。修饰符被指定为一个可选的标志。我们用实例来看一下:
1 |
import re |
和上面的例子相仿,我们在字符串中加了换行符,正则表达式还是一样的,用来匹配其中的数字。看一下运行结果:
1 |
AttributeError Traceback (most recent call last) |
运行直接报错,也就是说正则表达式没有匹配到这个字符串,返回结果为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.S
和re.I
。
我们知道正则表达式定义了许多匹配模式,如.
匹配除换行符以外的任意字符,但是如果目标字符串里面就包含.
,那该怎么办呢?
这里就需要用到转义匹配了,示例如下:
1 |
import re |
当遇到用于正则匹配模式的特殊字符时,在前面加反斜线转义一下即可。例如.
就可以用\\.
来匹配,运行结果如下:
1 |
<_sre.SRE_Match object; span=(0, 17), match='(百度)www.baidu.com'> |
可以看到,这里成功匹配到了原字符串。
这些是写正则表达式常用的几个知识点,熟练掌握它们对后面写正则表达式匹配非常有帮助。
search()
前面提到过,match()
方法是从字符串的开头开始匹配的,一旦开头不匹配,那么整个匹配就失败了。我们看下面的例子:
1 |
import re |
这里的字符串以Extra
开头,但是正则表达式以Hello
开头,整个正则表达式是字符串的一部分,但是这样匹配是失败的。运行结果如下:
1 |
None |
因为match()
方法在使用时需要考虑到开头的内容,这在做匹配时并不方便。它更适合用来检测某个字符串是否符合某个正则表达式的规则。
这里就有另外一个方法search()
,它在匹配时会扫描整个字符串,然后返回第一个成功匹配的结果。也就是说,正则表达式可以是字符串的一部分,在匹配时,search()
方法会依次扫描字符串,直到找到第一个符合规则的字符串,然后返回匹配内容,如果搜索完了还没有找到,就返回None
。
我们把上面代码中的match()
方法修改成search()
,再看下运行结果:
1 |
<_sre.SRE_Match object; span=(13, 53), match='Hello 1234567 World_This is a Regex Demo'> |
这时就得到了匹配结果。
因此,为了匹配方便,我们可以尽量使用search()
方法。
下面再用几个实例来看看search()
方法的用法。
首先,这里有一段待匹配的 HTML 文本,接下来写几个正则表达式实例来实现相应信息的提取:
1 |
html = '''<div id="songs-list"> |
可以观察到,ul
节点里有许多li
节点,其中li
节点中有的包含a
节点,有的不包含a
节点,a
节点还有一些相应的属性——超链接和歌手名。
首先,我们尝试提取class
为active
的li
节点内部的超链接包含的歌手名和歌名,此时需要提取第三个li
节点下a
节点的singer
属性和文本。
此时正则表达式可以以li
开头,然后寻找一个标志符active
,中间的部分可以用.*?
来匹配。接下来,要提取singer
这个属性值,所以还需要写入singer="(.*?)"
,这里需要提取的部分用小括号括起来,以便用group()
方法提取出来,它的两侧边界是双引号。然后还需要匹配a
节点的文本,其中它的左边界是\>
,右边界是</a>
。然后目标内容依然用(.*?)
来匹配,所以最后的正则表达式就变成了:
1 |
<li.*?active.*?singer="(.*?)">(.*?)</a> |
然后再调用search()
方法,它会搜索整个 HTML 文本,找到符合正则表达式的第一个内容返回。
另外,由于代码有换行,所以这里第三个参数需要传入re.S
。整个匹配代码如下:
1 |
result = re.search('<li.*?active.*?singer="(.*?)">(.*?)</a>', html, re.S) |
由于需要获取的歌手和歌名都已经用小括号包围,所以可以用group()
方法获取。
运行结果如下:
1 |
齐秦 往事随风 |
可以看到,这正是class
为active
的li
节点内部的超链接包含的歌手名和歌名。
如果正则表达式不加active
(也就是匹配不带class
为active
的节点内容),那会怎样呢?我们将正则表达式中的active
去掉,代码改写如下:
1 |
result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html, re.S) |
由于 search()方法会返回第一个符合条件的匹配目标,这里结果就变了:
1 |
任贤齐 沧海一声笑 |
把active
标签去掉后,从字符串开头开始搜索,此时符合条件的节点就变成了第二个li
节点,后面的就不再匹配,所以运行结果就变成第二个li
节点中的内容。
注意,在上面的两次匹配中,search()
方法的第三个参数都加了re.S
,这使得.*?
可以匹配换行,所以含有换行的li
节点被匹配到了。如果我们将其去掉,结果会是什么?代码如下:
1 |
result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html) |
运行结果如下:
1 |
beyond 光辉岁月 |
可以看到,结果变成了第四个li
节点的内容。这是因为第二个和第三个li
节点都包含了换行符,去掉re.S
之后,.*?
已经不能匹配换行符,所以正则表达式不会匹配到第二个和第三个li
节点,而第四个li
节点中不包含换行符,所以成功匹配。
由于绝大部分的 HTML 文本都包含了换行符,所以尽量都需要加上re.S
修饰符,以免出现匹配不到的问题。
findall()
前面我们介绍了search()
方法的用法,它可以返回匹配正则表达式的第一个内容,但是如果想要获取匹配正则表达式的所有内容,那该怎么办呢?这时就要借助findall()
方法了。该方法会搜索整个字符串,然后返回匹配正则表达式的所有内容。
还是上面的 HTML 文本,如果想获取所有a
节点的超链接、歌手和歌名,就可以将search()
方法换成findall()
方法。如果有返回结果的话,就是列表类型,所以需要遍历一下来依次获取每组内容。代码如下:
1 |
results = re.findall('<li.*?href="(.*?)".*?singer="(.*?)">(.*?)</a>', html, re.S) |
运行结果如下:
1 |
[('/2.mp3', '任贤齐', '沧海一声笑'), ('/3.mp3', '齐秦', '往事随风'), ('/4.mp3', 'beyond', '光辉岁月'), ('/5.mp3', '陈慧琳', '记事本'), ('/6.mp3', '邓丽君', '但愿人长久')] |
可以看到,返回的列表中的每个元素都是元组类型,我们用对应的索引依次取出即可。
如果只是获取第一个内容,可以用search()
方法。当需要提取多个内容时,可以用findall()
方法。
sub()
除了使用正则表达式提取信息外,有时候还需要借助它来修改文本。比如,想要把一串文本中的所有数字都去掉,如果只用字符串的replace()
方法,那就太烦琐了,这时可以借助sub()
方法。示例如下:
1 |
import re |
运行结果如下:
1 |
aKyroiRixLg |
这里只需要给第一个参数传入\\d+
来匹配所有的数字,第二个参数为替换成的字符串(如果去掉该参数的话,可以赋值为空),第三个参数是原字符串。
在上面的 HTML 文本中,如果想获取所有li
节点的歌名,直接用正则表达式来提取可能比较烦琐。比如,可以写成这样子:
1 |
results = re.findall('<li.*?>\s*?(<a.*?>)?(\w+)(</a>)?\s*?</li>', html, re.S) |
运行结果如下:
1 |
一路上有你 |
此时借助sub()
方法就比较简单了。可以先用sub()
方法将a
节点去掉,只留下文本,然后再利用findall()
提取就好了:
1 |
html = re.sub('<a.*?>|</a>', '', html) |
运行结果如下:
1 |
<div id="songs-list"> |
可以看到,a
节点经过sub()
方法处理后就没有了,然后再通过findall()
方法直接提取即可。可以看到,在适当的时候,借助sub()
方法可以起到事半功倍的效果。
compile()
前面所讲的方法都是用来处理字符串的方法,最后再介绍一下compile()
方法,这个方法可以将正则字符串编译成正则表达式对象,以便在后面的匹配中复用。示例代码如下:
1 |
import re |
例如,这里有 3 个日期,我们想分别将 3 个日期中的时间去掉,这时可以借助sub()
方法。该方法的第一个参数是正则表达式,但是这里没有必要重复写 3 个同样的正则表达式,此时可以借助compile()
方法将正则表达式编译成一个正则表达式对象,以便复用。
运行结果如下:
1 |
2016-12-15 2016-12-17 2016-12-22 |
另外,compile()
还可以传入修饰符,例如re.S
等修饰符,这样在search()
、findall()
等方法中就不需要额外传了。所以,compile()
方法可以说是给正则表达式做了一层封装,以便我们更好地复用。
到此为止,正则表达式的基本用法就介绍完了,后面会通过具体的实例来讲解正则表达式的用法。
在前一节中,我们了解了 requests 的基本用法,如基本的 GET、POST 请求以及Response
对象。本节中,我们再来了解下 requests 的一些高级用法,如文件上传、cookie 设置、代理设置等。
我们知道 requests 可以模拟提交一些数据。假如有的网站需要上传文件,我们也可以用它来实现,这非常简单,示例如下:
1 |
import requests |
在前一节中我们保存了一个文件 favicon.ico,这次用它来模拟文件上传的过程。需要注意的是,favicon.ico 需要和当前脚本在同一目录下。如果有其他文件,当然也可以使用其他文件来上传,更改下代码即可。
运行结果如下:
1 |
{ |
以上省略部分内容,这个网站会返回响应,里面包含files
这个字段,而form
字段是空的,这证明文件上传部分会单独有一个files
字段来标识。
前面我们使用 urllib 处理过 Cookies,写法比较复杂,而有了 requests,获取和设置 Cookies 只需一步即可完成。
我们先用一个实例看一下获取 Cookies 的过程:
1 |
import requests |
运行结果如下:
1 |
<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/>]> |
这里我们首先调用cookies
属性即可成功得到 Cookies,可以发现它是RequestCookieJar
类型。然后用items()
方法将其转化为元组组成的列表,遍历输出每一个 Cookie 的名称和值,实现 Cookie 的遍历解析。
当然,我们也可以直接用 Cookie 来维持登录状态,下面以知乎为例来说明。首先登录知乎,将Headers
中的Cookie
内容复制下来,如图 3-6 所示。
图 3-6
Cookie
这里可以替换成你自己的Cookie
,将其设置到Headers
里面,然后发送请求,示例如下:
1 |
import requests |
我们发现,结果中包含了登录后的结果,如图 3-7 所示,这证明登录成功。
图 3-7 运行结果
当然,你也可以通过cookies
参数来设置,不过这样就需要构造RequestsCookieJar
对象,而且需要分割一下cookies
。这相对烦琐,不过效果是相同的,示例如下:
1 |
import requests |
这里我们首先新建了一个RequestCookieJar
对象,然后将复制下来的cookies
利用split()
方法分割,接着利用set()
方法设置好每个 Cookie 的key
和value
,然后通过调用 requests 的get()
方法并传递给cookies
参数即可。当然,由于知乎本身的限制,headers
参数也不能少,只不过不需要在原来的headers
参数里面设置cookie
字段了。
测试后,发现同样可以正常登录知乎。
在 requests 中,如果直接利用get()
或post()
等方法的确可以做到模拟网页的请求,但是这实际上是相当于不同的会话,也就是说相当于你用了两个浏览器打开了不同的页面。
设想这样一个场景,第一个请求利用post()
方法登录了某个网站,第二次想获取成功登录后的自己的个人信息,你又用了一次get()
方法去请求个人信息页面。实际上,这相当于打开了两个浏览器,是两个完全不相关的会话,能成功获取个人信息吗?那当然不能。
有小伙伴可能说了,我在两次请求时设置一样的cookies
不就行了?可以,但这样做起来显得很烦琐,我们有更简单的解决方法。
其实解决这个问题的主要方法就是维持同一个会话,也就是相当于打开一个新的浏览器选项卡而不是新开一个浏览器。但是我又不想每次设置cookies
,那该怎么办呢?这时候就有了新的利器——Session
对象。
利用它,我们可以方便地维护一个会话,而且不用担心cookies
的问题,它会帮我们自动处理好。示例如下:
1 |
import requests |
这里我们请求了一个测试网址http://httpbin.org/cookies/set/number/123456789。请求这个网址时,可以设置一个 cookie,名称叫作 number,内容是 123456789,随后又请求了http://httpbin.org/cookies,此网址可以获取当前的 Cookies。
这样能成功获取到设置的 Cookies 吗?试试看。
运行结果如下:
1 |
{ |
这并不行。我们再用Session
试试看:
1 |
import requests |
再看下运行结果:
1 |
{ |
成功获取!这下能体会到同一个会话和不同会话的区别了吧!
所以,利用Session
,可以做到模拟同一个会话而不用担心 Cookies 的问题。它通常用于模拟登录成功之后再进行下一步的操作。
Session
在平常用得非常广泛,可以用于模拟在一个浏览器中打开同一站点的不同页面,后面会有专门的章节来讲解这部分内容。
此外,requests 还提供了证书验证的功能。当发送 HTTP 请求的时候,它会检查 SSL 证书,我们可以使用verify
参数控制是否检查此证书。其实如果不加verify
参数的话,默认是True
,会自动验证。
前面我们提到过,12306 的证书没有被官方 CA 机构信任,会出现证书验证错误的结果。我们现在访问它,都可以看到一个证书问题的页面,如图 3-8 所示。
图 3-8 错误页面
现在我们用 requests 来测试一下:
1 |
import requests |
运行结果如下:
1 |
requests.exceptions.SSLError: ("bad handshake: Error([('SSL routines', 'tls_process_server_certificate', 'certificate verify failed')],)",) |
这里提示一个错误SSLError
,表示证书验证错误。所以,如果请求一个 HTTPS 站点,但是证书验证错误的页面时,就会报这样的错误,那么如何避免这个错误呢?很简单,把verify
参数设置为False
即可。相关代码如下:
1 |
import requests |
这样就会打印出请求成功的状态码:
1 |
/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 |
不过我们发现报了一个警告,它建议我们给它指定证书。我们可以通过设置忽略警告的方式来屏蔽这个警告:
1 |
import requests |
或者通过捕获警告到日志的方式忽略警告:
1 |
import logging |
当然,我们也可以指定一个本地证书用作客户端证书,这可以是单个文件(包含密钥和证书)或一个包含两个文件路径的元组:
1 |
import requests |
当然,上面的代码是演示实例,我们需要有 crt 和 key 文件,并且指定它们的路径。注意,本地私有证书的key
必须是解密状态,加密状态的key
是不支持的。
对于某些网站,在测试的时候请求几次,能正常获取内容。但是一旦开始大规模爬取,对于大规模且频繁的请求,网站可能会弹出验证码,或者跳转到登录认证页面,更甚者可能会直接封禁客户端的 IP,导致一定时间段内无法访问。
那么,为了防止这种情况发生,我们需要设置代理来解决这个问题,这就需要用到proxies
参数。可以用这样的方式设置:
1 |
import requests |
当然,直接运行这个实例可能不行,因为这个代理可能是无效的,请换成自己的有效代理试验一下。
若代理需要使用 HTTP Basic Auth,可以使用类似 http://user:password@host:port 这样的语法来设置代理,示例如下:
1 |
import requests |
除了基本的 HTTP 代理外,requests 还支持 SOCKS 协议的代理。
首先,需要安装 socks 这个库:
1 |
pip3 install 'requests[socks]' |
然后就可以使用 SOCKS 协议代理了,示例如下:
1 |
import requests |
在本机网络状况不好或者服务器网络响应太慢甚至无响应时,我们可能会等待特别久的时间才可能收到响应,甚至到最后收不到响应而报错。为了防止服务器不能及时响应,应该设置一个超时时间,即超过了这个时间还没有得到响应,那就报错。这需要用到timeout
参数。这个时间的计算是发出请求到服务器返回响应的时间。示例如下:
1 |
import requests |
通过这样的方式,我们可以将超时时间设置为 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') |
在访问网站时,我们可能会遇到这样的认证页面,如图 3-9 所示。
图 3-9 认证页面
此时可以使用 requests 自带的身份认证功能,示例如下:
1 |
import requests |
如果用户名和密码正确的话,请求时就会自动认证成功,会返回 200 状态码,如果认证失败,则返回 401 状态码。
当然,如果参数都传一个HTTPBasicAuth
类,就显得有点烦琐了,所以 requests 提供了一个更简单的写法,可以直接传一个元组,它会默认使用HTTPBasicAuth
这个类来认证。
所以上面的代码可以直接简写如下:
1 |
import requests |
此外,requests 还提供了其他认证方式,如 OAuth 认证,不过此时需要安装 oauth 包,安装命令如下:
1 |
pip3 install requests_oauthlib |
使用 OAuth1 认证的方法如下:
1 |
import requests |
更多详细的功能可以参考 requests_oauthlib 的官方文档https://requests-oauthlib.readthedocs.org/,在此不再赘述了。
前面介绍 urllib 时,我们可以将请求表示为数据结构,其中各个参数都可以通过一个Request
对象来表示。这在 requests 里同样可以做到,这个数据结构就叫 Prepared Request。我们用实例看一下:
1 |
from requests import Request, Session |
这里我们引入了Request
,然后用url
、data
和headers
参数构造了一个Request
对象,这时需要再调用Session
的prepare_request()
方法将其转换为一个 Prepared Request 对象,然后调用send()
方法发送即可,运行结果如下:
1 |
{ |
可以看到,我们达到了同样的 POST 请求效果。
有了Request
这个对象,就可以将请求当作独立的对象来看待,这样在进行队列调度时会非常方便。后面我们会用它来构造一个Request
队列。
本节讲解了 requests 的一些高级用法,这些用法在后面实战部分会经常用到,需要熟练掌握。更多的用法可以参考 requests 的官方文档:http://docs.python-requests.org/。
在开始之前,请确保已经正确安装好了 requests 库。如果没有安装,可以参考 1.2.1 节安装。
urllib 库中的urlopen()
方法实际上是以 GET 方式请求网页,而 requests 中相应的方法就是get()
方法,是不是感觉表达更明确一些?下面通过实例来看一下:
1 |
import requests |
运行结果如下:
1 |
<class 'requests.models.Response'> |
这里我们调用get()
方法实现与urlopen()
相同的操作,得到一个Response
对象,然后分别输出了Response
的类型、状态码、响应体的类型、内容以及 Cookies。
通过运行结果可以发现,它的返回类型是requests.models.Response
,响应体的类型是字符串str
,Cookies 的类型是RequestsCookieJar
。
使用get()
方法成功实现一个 GET 请求,这倒不算什么,更方便之处在于其他的请求类型依然可以用一句话来完成,示例如下:
1 |
r = requests.post('http://httpbin.org/post') |
这里分别用post()
、put()
、delete()
等方法实现了 POST、PUT、DELETE 等请求。是不是比 urllib 简单太多了?
其实这只是冰山一角,更多的还在后面。
HTTP 中最常见的请求之一就是 GET 请求,下面首先来详细了解一下利用 requests 构建 GET 请求的方法。
首先,构建一个最简单的 GET 请求,请求的链接为http://httpbin.org/get,该网站会判断如果客户端发起的是 GET 请求的话,它返回相应的请求信息:
1 |
import requests |
运行结果如下:
1 |
{ |
可以发现,我们成功发起了 GET 请求,返回结果中包含请求头、URL、IP 等信息。
那么,对于 GET 请求,如果要附加额外的信息,一般怎样添加呢?比如现在想添加两个参数,其中name
是germey
,age
是 22。要构造这个请求链接,是不是要直接写成:
1 |
r = requests.get('http://httpbin.org/get?name=germey&age=22') |
这样也可以,但是是不是有点不人性化呢?一般情况下,这种信息数据会用字典来存储。那么,怎样来构造这个链接呢?
这同样很简单,利用params
这个参数就好了,示例如下:
1 |
import requests |
运行结果如下:
1 |
{ |
通过运行结果可以判断,请求的链接自动被构造成了:http://httpbin.org/get?age=22&name=germey。
另外,网页的返回类型实际上是str
类型,但是它很特殊,是 JSON 格式的。所以,如果想直接解析返回结果,得到一个字典格式的话,可以直接调用json()
方法。示例如下:
1 |
import requests |
运行结果如下:
1 |
<class 'str'> |
可以发现,调用json()
方法,就可以将返回结果是 JSON 格式的字符串转化为字典。
但需要注意的书,如果返回结果不是 JSON 格式,便会出现解析错误,抛出json.decoder.JSONDecodeError
异常。
上面的请求链接返回的是 JSON 形式的字符串,那么如果请求普通的网页,则肯定能获得相应的内容了。下面以“知乎”→“发现”页面为例来看一下:
1 |
import requests |
这里我们加入了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 |
import requests |
这里抓取的内容是站点图标,也就是在浏览器每一个标签上显示的小图标,如图 3-3 所示。
图 3-3 站点图标
这里打印了Response
对象的两个属性,一个是text
,另一个是content
。
运行结果如图 3-4 所示,其中前两行是r.text
的结果,最后一行是r.content
的结果。
图 3-4 运行结果
可以注意到,前者出现了乱码,后者结果前带有一个b
,这代表是bytes
类型的数据。由于图片是二进制数据,所以前者在打印时转化为str
类型,也就是图片直接转化为字符串,这理所当然会出现乱码。
接着,我们将刚才提取到的图片保存下来:
1 |
import requests |
这里用了open()
方法,它的第一个参数是文件名称,第二个参数代表以二进制写的形式打开,可以向文件里写入二进制数据。
运行结束之后,可以发现在文件夹中出现了名为 favicon.ico 的图标,如图 3-5 所示。
同样地,音频和视频文件也可以用这种方法获取。
headers
与urllib.request
一样,我们也可以通过headers
参数来传递头信息。
比如,在上面“知乎”的例子中,如果不传递headers
,就不能正常请求:
1 |
import requests |
运行结果如下:
1 |
<html><body><h1>500 Server Error</h1> |
但如果加上headers
并加上User-Agent
信息,那就没问题了:
1 |
import requests |
当然,我们可以在headers
这个参数中任意添加其他的字段信息。
前面我们了解了最基本的 GET 请求,另外一种比较常见的请求方式是 POST。使用requests
实现 POST 请求同样非常简单,示例如下:
1 |
import requests |
这里还是请求http://httpbin.org/post,该网站可以判断如果请求是 POST 方式,就把相关请求信息返回。
运行结果如下:
1 |
{ |
可以发现,我们成功获得了返回结果,其中form
部分就是提交的数据,这就证明 POST 请求成功发送了。
发送请求后,得到的自然就是响应。在上面的实例中,我们使用text
和content
获取了响应的内容。此外,还有很多属性和方法可以用来获取其他信息,比如状态码、响应头、Cookies 等。示例如下:
1 |
import requests |
这里分别打印输出status_code
属性得到状态码,输出headers
属性得到响应头,输出cookies
属性得到 Cookies,输出url
属性得到 URL,输出history
属性得到请求历史。
运行结果如下:
1 |
<class 'int'> 200 |
因为session_id
过长,在此简写。可以看到,headers
和cookies
这两个属性得到的结果分别是CaseInsensitiveDict
和RequestsCookieJar
类型。
状态码常用来判断请求是否成功,而 requests 还提供了一个内置的状态码查询对象requests.codes
,示例如下:
1 |
import requests |
这里通过比较返回码和内置的成功的返回码,来保证请求得到了正常响应,输出成功请求的消息,否则程序终止,这里我们用requests.codes.ok
得到的是成功的状态码 200。
那么,肯定不能只有ok
这个条件码。下面列出了返回码和相应的查询条件:
1 |
# 信息性状态码 |
比如,如果想判断结果是不是 404 状态,可以用requests.codes.not_found
来比对。
上一节中,我们了解了urllib的基本用法,但是其中确实有不方便的地方,比如处理网页验证和Cookies时,需要写Opener
和Handler
来处理。为了更加方便地实现这些操作,就有了更为强大的库requests,有了它,Cookies、登录验证、代理设置等操作都不是事儿。
接下来,让我们领略一下它的强大之处吧。
利用urllib的robotparser
模块,我们可以实现网站Robots协议的分析。本节中,我们来简单了解一下该模块的用法。
Robots协议也称作爬虫协议、机器人协议,它的全名叫作网络爬虫排除标准(Robots Exclusion Protocol),用来告诉爬虫和搜索引擎哪些页面可以抓取,哪些不可以抓取。它通常是一个叫作robots.txt的文本文件,一般放在网站的根目录下。
当搜索爬虫访问一个站点时,它首先会检查这个站点根目录下是否存在robots.txt文件,如果存在,搜索爬虫会根据其中定义的爬取范围来爬取。如果没有找到这个文件,搜索爬虫便会访问所有可直接访问的页面。
下面我们看一个robots.txt的样例:
1 |
User-agent: * |
这实现了对所有搜索爬虫只允许爬取public目录的功能,将上述内容保存成robots.txt文件,放在网站的根目录下,和网站的入口文件(比如index.php、index.html和index.jsp等)放在一起。
上面的User-agent
描述了搜索爬虫的名称,这里将其设置为*则代表该协议对任何爬取爬虫有效。比如,我们可以设置:
1 |
User-agent: Baiduspider |
这就代表我们设置的规则对百度爬虫是有效的。如果有多条User-agent
记录,则就会有多个爬虫会受到爬取限制,但至少需要指定一条。
Disallow
指定了不允许抓取的目录,比如上例子中设置为/则代表不允许抓取所有页面。
Allow
一般和Disallow
一起使用,一般不会单独使用,用来排除某些限制。现在我们设置为/public/
,则表示所有页面不允许抓取,但可以抓取public目录。
下面我们再来看几个例子。禁止所有爬虫访问任何目录的代码如下:
1 |
User-agent: * |
允许所有爬虫访问任何目录的代码如下:
1 |
User-agent: * |
另外,直接把robots.txt文件留空也是可以的。
禁止所有爬虫访问网站某些目录的代码如下:
1 |
User-agent: * |
只允许某一个爬虫访问的代码如下:
1 |
User-agent: WebCrawler |
这些是robots.txt的一些常见写法。
大家可能会疑惑,爬虫名是哪儿来的?为什么就叫这个名?其实它是有固定名字的了,比如百度的就叫作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
了解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,返回结果是True
或False
。mtime()
:返回的是上次抓取和分析robots.txt的时间,这对于长时间分析和抓取的搜索爬虫是很有必要的,你可能需要定期检查来抓取最新的robots.txt。modified()
:它同样对长时间分析和抓取的搜索爬虫很有帮助,将当前时间设置为上次抓取和分析robots.txt的时间。下面我们用实例来看一下:
1 |
from urllib.robotparser import RobotFileParser |
这里以简书为例,首先创建RobotFileParser
对象,然后通过set_url()
方法设置了robots.txt的链接。当然,不用这个方法的话,可以在声明时直接用如下方法设置:
1 |
rp = RobotFileParser('http://www.jianshu.com/robots.txt') |
接着利用can_fetch()
方法判断了网页是否可以被抓取。
运行结果如下:
1 |
True |
这里同样可以使用parse()
方法执行读取和分析,示例如下:
1 |
from urllib.robotparser import RobotFileParser |
运行结果一样:
1 |
True |
本节介绍了robotparser
模块的基本用法和实例,利用它,我们可以方便地判断哪些页面可以抓取,哪些页面不可以抓取。
前面说过,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。本节中,我们介绍一下该模块中常用的方法来看一下它的便捷之处。
urlparse()
该方法可以实现URL的识别和分段,这里先用一个实例来看一下:
1 |
from urllib.parse import urlparse |
这里我们利用urlparse()
方法进行了一个URL的解析。首先,输出了解析结果的类型,然后将结果也输出出来。
运行结果如下:
1 |
<class 'urllib.parse.ParseResult'> |
可以看到,返回结果是一个ParseResult
类型的对象,它包含6部分,分别是scheme
、netloc
、path
、params
、query
和fragment
。
观察一下该实例的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
:它是默认的协议(比如http
或https
等)。假如这个链接没有带协议信息,会将这个作为默认的协议。我们用实例来看一下:
1 |
from urllib.parse import urlparse |
运行结果如下:
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
。如果它被设置为False
,fragment
部分就会被忽略,它会被解析为path
、parameters
或者query
的一部分,而fragment
部分为空。下面我们用实例来看一下:
1 |
from urllib.parse import urlparse |
运行结果如下:
1 |
ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html', params='user', query='id=5#comment', fragment='') |
假设URL中不包含params
和query
,我们再通过实例看一下:
1 |
from urllib.parse import urlparse |
运行结果如下:
1 |
ParseResult(scheme='http', netloc='www.baidu.com', path='/index.html#comment', params='', query='', fragment='') |
可以发现,当URL中不包含params
和query
时,fragment
便会被解析为path
的一部分。
返回结果ParseResult
实际上是一个元组,我们可以用索引顺序来获取,也可以用属性名获取。示例如下:
1 |
from urllib.parse import urlparse |
这里我们分别用索引和属性名获取了scheme
和netloc
,其运行结果如下:
1 |
http |
可以发现,二者的结果是一致的,两种方法都可以成功获取。
urlunparse()
有了urlparse()
,相应地就有了它的对立方法urlunparse()
。它接受的参数是一个可迭代对象,但是它的长度必须是6,否则会抛出参数数量不足或者过多的问题。先用一个实例看一下:
1 |
from urllib.parse import urlunparse |
这里参数data
用了列表类型。当然,你也可以用其他类型,比如元组或者特定的数据结构。
运行结果如下:
1 |
http://www.baidu.com/index.html;user?a=6#comment |
这样我们就成功实现了URL的构造。
urlsplit()
这个方法和urlparse()
方法非常相似,只不过它不再单独解析params
这一部分,只返回5个结果。上面例子中的params
会合并到path
中。示例如下:
1 |
from urllib.parse import urlsplit |
运行结果如下:
1 |
SplitResult(scheme='http', netloc='www.baidu.com', path='/index.html;user', query='id=5', fragment='comment') |
可以发现,返回结果是SplitResult
,它其实也是一个元组类型,既可以用属性获取值,也可以用索引来获取。示例如下:
1 |
from urllib.parse import urlsplit |
运行结果如下:
1 |
http http |
urlunsplit()
与urlunparse()
类似,它也是将链接各个部分组合成完整链接的方法,传入的参数也是一个可迭代对象,例如列表、元组等,唯一的区别是长度必须为5。示例如下:
1 |
from urllib.parse import urlunsplit |
运行结果如下:
1 |
http://www.baidu.com/index.html?a=6#comment |
urljoin()
有了urlunparse()
和urlunsplit()
方法,我们可以完成链接的合并,不过前提必须要有特定长度的对象,链接的每一部分都要清晰分开。
此外,生成链接还有另一个方法,那就是urljoin()
方法。我们可以提供一个base_url
(基础链接)作为第一个参数,将新的链接作为第二个参数,该方法会分析base_url
的scheme
、netloc
和path
这3个内容并对新链接缺失的部分进行补充,最后返回结果。
下面通过几个实例看一下:
1 |
from urllib.parse import urljoin |
运行结果如下:
1 |
http://www.baidu.com/FAQ.html |
可以发现,base_url
提供了三项内容scheme
、netloc
和path
。如果这3项在新的链接里不存在,就予以补充;如果新的链接存在,就使用新的链接的部分。而base_url
中的params
、query
和fragment
是不起作用的。
通过urljoin()
方法,我们可以轻松实现链接的解析、拼合与生成。
urlencode()
这里我们再介绍一个常用的方法——urlencode()
,它在构造GET请求参数的时候非常有用,示例如下:
1 |
from urllib.parse import urlencode |
这里首先声明了一个字典来将参数表示出来,然后调用urlencode()
方法将其序列化为GET请求参数。
运行结果如下:
1 |
http://www.baidu.com?name=germey&age=22 |
可以看到,参数就成功地由字典类型转化为GET请求参数了。
这个方法非常常用。有时为了更加方便地构造参数,我们会事先用字典来表示。要转化为URL的参数时,只需要调用该方法即可。
parse_qs()
有了序列化,必然就有反序列化。如果我们有一串GET请求参数,利用parse_qs()
方法,就可以将它转回字典,示例如下:
1 |
from urllib.parse import parse_qs |
运行结果如下:
1 |
{'name': ['germey'], 'age': ['22']} |
可以看到,这样就成功转回为字典类型了。
parse_qsl()
另外,还有一个parse_qsl()
方法,它用于将参数转化为元组组成的列表,示例如下:
1 |
from urllib.parse import parse_qsl |
运行结果如下:
1 |
[('name', 'germey'), ('age', '22')] |
可以看到,运行结果是一个列表,而列表中的每一个元素都是一个元组,元组的第一个内容是参数名,第二个内容是参数值。
quote()
该方法可以将内容转化为URL编码的格式。URL中带有中文参数时,有时可能会导致乱码的问题,此时用这个方法可以将中文字符转化为URL编码,示例如下:
1 |
from urllib.parse import quote |
这里我们声明了一个中文的搜索文字,然后用quote()
方法对其进行URL编码,最后得到的结果如下:
1 |
https://www.baidu.com/s?wd=%E5%A3%81%E7%BA%B8 |
unquote()
有了quote()
方法,当然还有unquote()
方法,它可以进行URL解码,示例如下:
1 |
from urllib.parse import unquote |
这是上面得到的URL编码后的结果,这里利用unquote()
方法还原,结果如下:
1 |
https://www.baidu.com/s?wd=壁纸 |
可以看到,利用unquote()
方法可以方便地实现解码。
本节中,我们介绍了parse
模块的一些常用URL处理方法。有了这些方法,我们可以方便地实现URL的解析和构造,建议熟练掌握。
前一节我们了解了请求的发送过程,但是在网络不好的情况下,如果出现了异常,该怎么办呢?这时如果不处理这些异常,程序很可能因报错而终止运行,所以异常处理还是十分有必要的。
urllib的error
模块定义了由request
模块产生的异常。如果出现了问题,request
模块便会抛出error
模块中定义的异常。
URLError
URLError类来自urllib库的error模块,它继承自OSError类,是error异常模块的基类,由request模块生的异常都可以通过捕获这个类来处理。
它具有一个属性reason,即返回错误的原因。
下面用一个实例来看一下:
1 |
from urllib import request, error |
我们打开一个不存在的页面,照理来说应该会报错,但是这时我们捕获了URLError这个异常,运行结果如下:
1 |
Not Found |
程序没有直接报错,而是输出了如上内容,这样通过如上操作,我们就可以避免程序异常终止,同时异常得到了有效处理。
HTTPError
它是URLError
的子类,专门用来处理HTTP请求错误,比如认证请求失败等。它有如下3个属性。
code
:返回HTTP状态码,比如404表示网页不存在,500表示服务器内部错误等。reason
:同父类一样,用于返回错误的原因。headers
:返回请求头。下面我们用几个实例来看看:
1 |
from urllib import request,error |
运行结果如下:
1 |
Not Found |
依然是同样的网址,这里捕获了HTTPError
异常,输出了reason
、code
和headers
属性。
因为URLError
是HTTPError
的父类,所以可以先选择捕获子类的错误,再去捕获父类的错误,所以上述代码更好的写法如下:
1 |
from urllib import request, error |
这样就可以做到先捕获HTTPError
,获取它的错误状态码、原因、headers
等信息。如果不是HTTPError
异常,就会捕获URLError
异常,输出错误原因。最后,用else
来处理正常的逻辑。这是一个较好的异常处理写法。
有时候,reason
属性返回的不一定是字符串,也可能是一个对象。再看下面的实例:
1 |
import socket |
这里我们直接设置超时时间来强制抛出timeout
异常。
运行结果如下:
1 |
<class 'socket.timeout'> |
可以发现,reason
属性的结果是socket.timeout
类。所以,这里我们可以用isinstance()
方法来判断它的类型,作出更详细的异常判断。
本节中,我们讲述了error
模块的相关用法,通过合理地捕获异常可以做出更准确的异常判断,使程序更加稳健。
使用 urllib 的request
模块,我们可以方便地实现请求的发送并得到响应,本节就来看下它的具体用法。
urlopen()
urllib.request
模块提供了最基本的构造 HTTP 请求的方法,利用它可以模拟浏览器的一个请求发起过程,同时它还带有处理授权验证(authenticaton)、重定向(redirection)、浏览器 Cookies 以及其他内容。
下面我们来看一下它的强大之处。这里以 Python 官网为例,我们来把这个网页抓下来:
1 |
import urllib.request |
运行结果如图 3-1 所示。
图 3-1 运行结果
这里我们只用了两行代码,便完成了 Python 官网的抓取,输出了网页的源代码。得到源代码之后呢?我们想要的链接、图片地址、文本信息不就都可以提取出来了吗?
接下来,看看它返回的到底是什么。利用type()
方法输出响应的类型:
1 |
import urllib.request |
输出结果如下:
1 |
<class 'http.client.HTTPResponse'> |
可以发现,它是一个HTTPResposne
类型的对象。它主要包含read()
、readinto()
、getheader(name)
、getheaders()
、fileno()
等方法,以及msg
、version
、status
、reason
、debuglevel
、closed
等属性。
得到这个对象之后,我们把它赋值为response
变量,然后就可以调用这些方法和属性,得到返回结果的一系列信息了。
例如,调用read()
方法可以得到返回的网页内容,调用status
属性可以得到返回结果的状态码,如 200 代表请求成功,404 代表网页未找到等。
下面再通过一个实例来看看:
1 |
import urllib.request |
运行结果如下:
1 |
200 |
可见,前两个输出分别输出了响应的状态码和响应的头信息,最后一个输出通过调用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 |
import urllib.parse |
这里我们传递了一个参数word
,值是hello
。它需要被转码成bytes
(字节流)类型。其中转字节流采用了bytes()
方法,该方法的第一个参数需要是str
(字符串)类型,需要用urllib.parse
模块里的urlencode()
方法来将参数字典转化为字符串;第二个参数指定编码格式,这里指定为utf8
。
这里请求的站点是 httpbin.org,它可以提供 HTTP 请求测试。本次我们请求的 URL 为http://httpbin.org/post,这个链接可以用来测试 POST 请求,它可以输出请求的一些信息,其中包含我们传递的data
参数。
运行结果如下:
1 |
{ |
我们传递的参数出现在了form
字段中,这表明是模拟了表单提交的方式,以 POST 方式传输数据。
timeout
参数timeout
参数用于设置超时时间,单位为秒,意思就是如果请求超出了设置的这个时间,还没有得到响应,就会抛出异常。如果不指定该参数,就会使用全局默认时间。它支持 HTTP、HTTPS、FTP 请求。
下面用实例来看一下:
1 |
import urllib.request |
运行结果如下:
1 |
During handling of the above exception, another exception occurred: |
这里我们设置超时时间是 1 秒。程序 1 秒过后,服务器依然没有响应,于是抛出了URLError
异常。该异常属于urllib.error
模块,错误原因是超时。
因此,可以通过设置这个超时时间来控制一个网页如果长时间未响应,就跳过它的抓取。这可以利用try except
语句来实现,相关代码如下:
1 |
import socket |
这里我们请求了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 设置。
此外,cafile
和capath
这两个参数分别指定 CA 证书和它的路径,这个在请求 HTTPS 链接时会有用。
cadefault
参数现在已经弃用了,其默认值为False
。
前面讲解了urlopen()
方法的用法,通过这个最基本的方法,我们可以完成简单的请求和网页抓取。若需更加详细的信息,可以参见官方文档:https://docs.python.org/3/library/urllib.request.html。
Request
我们知道利用urlopen()
方法可以实现最基本请求的发起,但这几个简单的参数并不足以构建一个完整的请求。如果请求中需要加入 Headers 等信息,就可以利用更强大的Request
类来构建。
首先,我们用实例来感受一下Request
的用法:
1 |
import urllib.request |
可以发现,我们依然是用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 |
from urllib import request, parse |
这里我们通过 4 个参数构造了一个请求,其中url
即请求 URL,headers
中指定了User-Agent
和Host
,参数data
用urlencode()
和bytes()
方法转成字节流。另外,指定了请求方式为 POST。
运行结果如下:
1 |
{ |
观察结果可以发现,我们成功设置了data
、headers
和method
。
另外,headers
也可以用add_header()
方法来添加:
1 |
req = request.Request(url=url, data=data, method='POST') |
如此一来,我们就可以更加方便地构造请求,实现请求的发送啦。
在上面的过程中,我们虽然可以构造请求,但是对于一些更高级的操作(比如 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
呢?因为需要实现更高级的功能。之前使用的Request
和urlopen()
相当于类库为你封装好了极其常用的请求方法,利用它们可以完成基本的请求,但是现在不一样了,我们需要实现更高级的功能,所以需要深入一层进行配置,使用更底层的实例来完成操作,所以这里就用到了Opener
。
Opener
可以使用open()
方法,返回的类型和urlopen()
如出一辙。那么,它和Handler
有什么关系呢?简而言之,就是利用Handler
来构建Opener
。
下面用几个实例来看看它们的用法。
有些网站在打开时就会弹出提示框,直接提示你输入用户名和密码,验证成功后才能查看页面,如图 3-2 所示。
图 3-2 验证页面
那么,如果要请求这样的页面,该怎么办呢?借助HTTPBasicAuthHandler
就可以完成,相关代码如下:
1 |
from urllib.request import HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener |
这里首先实例化HTTPBasicAuthHandler
对象,其参数是HTTPPasswordMgrWithDefaultRealm
对象,它利用add_password()
添加进去用户名和密码,这样就建立了一个处理验证的Handler
。
接下来,利用这个Handler
并使用build_opener()
方法构建一个Opener
,这个Opener
在发送请求时就相当于已经验证成功了。
接下来,利用Opener
的open()
方法打开链接,就可以完成验证了。这里获取到的结果就是验证后的页面源码内容。
在做爬虫的时候,免不了要使用代理,如果要添加代理,可以这样做:
1 |
from urllib.error import URLError |
这里我们在本地搭建了一个代理,它运行在 9743 端口上。
这里使用了ProxyHandler
,其参数是一个字典,键名是协议类型(比如 HTTP 或者 HTTPS 等),键值是代理链接,可以添加多个代理。
然后,利用这个 Handler 及build_opener()
方法构造一个Opener
,之后发送请求即可。
Cookies 的处理就需要相关的Handler
了。
我们先用实例来看看怎样将网站的 Cookies 获取下来,相关代码如下:
1 |
import http.cookiejar, urllib.request |
首先,我们必须声明一个CookieJar
对象。接下来,就需要利用HTTPCookieProcessor
来构建一个Handler
,最后利用build_opener()
方法构建出Opener
,执行open()
函数即可。
运行结果如下:
1 |
BAIDUID=2E65A683F8A8BA3DF521469DF8EFF1E1:FG=1 |
可以看到,这里输出了每条 Cookie 的名称和值。
不过既然能输出,那可不可以输出成文件格式呢?我们知道 Cookies 实际上也是以文本形式保存的。
答案当然是肯定的,这里通过下面的实例来看看:
1 |
filename = 'cookies.txt' |
这时CookieJar
就需要换成MozillaCookieJar
,它在生成文件时会用到,是CookieJar
的子类,可以用来处理 Cookies 和文件相关的事件,比如读取和保存 Cookies,可以将 Cookies 保存成 Mozilla 型浏览器的 Cookies 格式。
运行之后,可以发现生成了一个 cookies.txt 文件,其内容如下:
1 |
# Netscape HTTP Cookie File |
另外,LWPCookieJar
同样可以读取和保存 Cookies,但是保存的格式和MozillaCookieJar
不一样,它会保存成 libwww-perl(LWP)格式的 Cookies 文件。
要保存成 LWP 格式的 Cookies 文件,可以在声明时就改为:
1 |
cookie = http.cookiejar.LWPCookieJar(filename) |
此时生成的内容如下:
1 |
#LWP-Cookies-2.0 |
由此看来,生成的格式还是有比较大差异的。
那么,生成了 Cookies 文件后,怎样从文件中读取并利用呢?
下面我们以LWPCookieJar
格式为例来看一下:
1 |
cookie = http.cookiejar.LWPCookieJar() |
可以看到,这里调用load()
方法来读取本地的 Cookies 文件,获取到了 Cookies 的内容。不过前提是我们首先生成了 LWPCookieJar 格式的 Cookies,并保存成文件,然后读取 Cookies 之后使用同样的方法构建 Handler 和 Opener 即可完成操作。
运行结果正常的话,会输出百度网页的源代码。
通过上面的方法,我们可以实现绝大多数请求功能的设置了。
这便是 urllib 库中request
模块的基本用法,如果想实现更多的功能,可以参考官方文档的说明:https://docs.python.org/3/library/urllib.request.html#basehandler-objects。
在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个模块。
学习爬虫,最初的操作便是模拟浏览器向服务器发出请求,那么我们需要从哪个地方做起呢?请求需要我们自己来构造吗?需要关心请求这个数据结构的实现吗?需要了解HTTP、TCP、IP层的网络传输通信吗?需要知道服务器的响应和应答原理吗?
可能你不知道无从下手,不过不用担心,Python的强大之处就是提供了功能齐全的类库来帮助我们完成这些请求。最基础的HTTP库有urllib、httplib2、requests、treq等。
拿urllib这个库来说,有了它,我们只需要关心请求的链接是什么,需要传的参数是什么以及可选的请求头设置就好了,不用深入到底层去了解它到底是怎样传输和通信的。有了它,两行代码就可以完成一个请求和响应的处理过程,得到网页内容,是不是感觉方便极了?
接下来,就让我们从最基础的部分开始了解这些库的使用方法吧。
我们在做爬虫的过程中经常会遇到这样的情况,最初爬虫正常运行,正常抓取数据,一切看起来都是那么美好,然而一杯茶的功夫可能就会出现错误,比如403 Forbidden,这时候打开网页一看,可能会看到“您的IP访问频率太高”这样的提示。出现这种现象的原因是网站采取了一些反爬虫措施。比如,服务器会检测某个IP在单位时间内的请求次数,如果超过了这个阈值,就会直接拒绝服务,返回一些错误信息,这种情况可以称为封IP。
既然服务器检测的是某个IP单位时间的请求次数,那么借助某种方式来伪装我们的IP,让服务器识别不出是由我们本机发起的请求,不就可以成功防止封IP了吗?
一种有效的方式就是使用代理,后面会详细说明代理的用法。在这之前,需要先了解下代理的基本原理,它是怎样实现IP伪装的呢?
代理实际上指的就是代理服务器,英文叫作proxy server,它的功能是代理网络用户去取得网络信息。形象地说,它是网络信息的中转站。在我们正常请求一个网站时,是发送了请求给Web服务器,Web服务器把响应传回给我们。如果设置了代理服务器,实际上就是在本机和服务器之间搭建了一个桥,此时本机不是直接向Web服务器发起请求,而是向代理服务器发出请求,请求会发送给代理服务器,然后由代理服务器再发送给Web服务器,接着由代理服务器再把Web服务器返回的响应转发给本机。这样我们同样可以正常访问网页,但这个过程中Web服务器识别出的真实IP就不再是我们本机的IP了,就成功实现了IP伪装,这就是代理的基本原理。
那么,代理有什么作用呢?我们可以简单列举如下。
对于爬虫来说,由于爬虫爬取速度过快,在爬取过程中可能遇到同一个IP访问过于频繁的问题,此时网站就会让我们输入验证码登录或者直接封锁IP,这样会给爬取带来极大的不便。
使用代理隐藏真实的IP,让服务器误以为是代理服务器在请求自己。这样在爬取过程中通过不断更换代理,就不会被封锁,可以达到很好的爬取效果。
代理分类时,既可以根据协议区分,也可以根据其匿名程度区分。
根据代理的协议,代理可以分为如下类别。
根据代理的匿名程度,代理可以分为如下类别。
HTTP_VIA
和HTTP_X_FORWARDED_FOR
。在后文我们会详细介绍这几种代理的使用方式。
由于涉及一些专业名词知识,本节的部分内容参考来源如下。
在浏览网站的过程中,我们经常会遇到需要登录的情况,有些页面只有登录之后才可以访问,而且登录之后可以连续访问很多次网站,但是有时候过一段时间就需要重新登录。还有一些网站,在打开浏览器时就自动登录了,而且很长时间都不会失效,这种情况又是为什么?其实这里面涉及会话和 Cookies 的相关知识,本节就来揭开它们的神秘面纱。
在开始之前,我们需要先了解一下静态网页和动态网页的概念。这里还是前面的示例代码,内容如下:
1 |
<!DOCTYPE html> |
这是最基本的 HTML 代码,我们将其保存为一个.html 文件,然后把它放在某台具有固定公网 IP 的主机上,主机上装上 Apache 或 Nginx 等服务器,这样这台主机就可以作为服务器了,其他人便可以通过访问服务器看到这个页面,这就搭建了一个最简单的网站。
这种网页的内容是 HTML 代码编写的,文字、图片等内容均通过写好的 HTML 代码来指定,这种页面叫作静态网页。它加载速度快,编写简单,但是存在很大的缺陷,如可维护性差,不能根据 URL 灵活多变地显示内容等。例如,我们想要给这个网页的 URL 传入一个name
参数,让其在网页中显示出来,是无法做到的。
因此,动态网页应运而生,它可以动态解析 URL 中参数的变化,关联数据库并动态呈现不同的页面内容,非常灵活多变。我们现在遇到的大多数网站都是动态网站,它们不再是一个简单的 HTML,而是可能由 JSP、PHP、Python 等语言编写的,其功能比静态网页强大和丰富太多了。
此外,动态网站还可以实现用户登录和注册的功能。再回到开头提到的问题,很多页面是需要登录之后才可以查看的。按照一般的逻辑来说,输入用户名和密码登录之后,肯定是拿到了一种类似凭证的东西,有了它,我们才能保持登录状态,才能访问登录之后才能看到的页面。
那么,这种神秘的凭证到底是什么呢?其实它就是会话和 Cookies 共同产生的结果,下面我们来一探究竟。
在了解会话和 Cookies 之前,我们还需要了解 HTTP 的一个特点,叫作无状态。
HTTP 的无状态是指 HTTP 协议对事务处理是没有记忆能力的,也就是说服务器不知道客户端是什么状态。当我们向服务器发送请求后,服务器解析此请求,然后返回对应的响应,服务器负责完成这个过程,而且这个过程是完全独立的,服务器不会记录前后状态的变化,也就是缺少状态记录。这意味着如果后续需要处理前面的信息,则必须重传,这导致需要额外传递一些前面的重复请求,才能获取后续响应,然而这种效果显然不是我们想要的。为了保持前后状态,我们肯定不能将前面的请求全部重传一次,这太浪费资源了,对于这种需要用户登录的页面来说,更是棘手。
这时两个用于保持 HTTP 连接状态的技术就出现了,它们分别是会话和 Cookies。会话在服务端,也就是网站的服务器,用来保存用户的会话信息;Cookies 在客户端,也可以理解为浏览器端,有了 Cookies,浏览器在下次访问网页时会自动附带上它发送给服务器,服务器通过识别 Cookies 并鉴定出是哪个用户,然后再判断用户是否是登录状态,然后返回对应的响应。
我们可以理解为 Cookies 里面保存了登录的凭证,有了它,只需要在下次请求携带 Cookies 发送请求而不必重新输入用户名、密码等信息重新登录了。
因此在爬虫中,有时候处理需要登录才能访问的页面时,我们一般会直接将登录成功后获取的 Cookies 放在请求头里面直接请求,而不必重新模拟登录。
好了,了解会话和 Cookies 的概念之后,我们在来详细剖析它们的原理。
会话,其本来的含义是指有始有终的一系列动作/消息。比如,打电话时,从拿起电话拨号到挂断电话这中间的一系列过程可以称为一个会话。
而在 Web 中,会话对象用来存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在会话对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个会话对象。当会话过期或被放弃后,服务器将终止该会话。
Cookies 指某些网站为了辨别用户身份、进行会话跟踪而存储在用户本地终端上的数据。
那么,我们怎样利用 Cookies 保持状态呢?当客户端第一次请求服务器时,服务器会返回一个请求头中带有Set-Cookie
字段的响应给客户端,用来标记是哪一个用户,客户端浏览器会把 Cookies 保存起来。当浏览器下一次再请求该网站时,浏览器会把此 Cookies 放到请求头一起提交给服务器,Cookies 携带了会话 ID 信息,服务器检查该 Cookies 即可找到对应的会话是什么,然后再判断会话来以此来辨认用户状态。
在成功登录某个网站时,服务器会告诉客户端设置哪些 Cookies 信息,在后续访问页面时客户端会把 Cookies 发送给服务器,服务器再找到对应的会话加以判断。如果会话中的某些设置登录状态的变量是有效的,那就证明用户处于登录状态,此时返回登录之后才可以查看的网页内容,浏览器再进行解析便可以看到了。
反之,如果传给服务器的 Cookies 是无效的,或者会话已经过期了,我们将不能继续访问页面,此时可能会收到错误的响应或者跳转到登录页面重新登录。
所以,Cookies 和会话需要配合,一个处于客户端,一个处于服务端,二者共同协作,就实现了登录会话控制。
接下来,我们来看看 Cookies 都有哪些内容。这里以知乎为例,在浏览器开发者工具中打开 Application 选项卡,然后在左侧会有一个 Storage 部分,最后一项即为 Cookies,将其点开,如图 2-13 所示,这些就是 Cookies。
图 2-13 Cookies 列表
可以看到,这里有很多条目,其中每个条目可以称为 Cookie。它有如下几个属性。
httponly
属性。若此属性为true
,则只有在 HTTP 头中会带有此 Cookie 的信息,而不能通过document.cookie
来访问此 Cookie。false
。从表面意思来说,会话 Cookie 就是把 Cookie 放在浏览器内存里,浏览器在关闭之后该 Cookie 即失效;持久 Cookie 则会保存到客户端的硬盘中,下次还可以继续使用,用于长久保持用户登录状态。
其实严格来说,没有会话 Cookie 和持久 Cookie 之分,只是由 Cookie 的 Max Age 或 Expires 字段决定了过期的时间。
因此,一些持久化登录的网站其实就是把 Cookie 的有效时间和会话有效期设置得比较长,下次我们再访问页面时仍然携带之前的 Cookie,就可以直接保持登录状态。
在谈论会话机制的时候,常常听到这样一种误解“只要关闭浏览器,会话就消失了”,这种理解是错误的。可以想象一下会员卡的例子,除非顾客主动对店家提出销卡,否则店家绝对不会轻易删除顾客的资料。对会话来说,也是一样,除非程序通知服务器删除一个会话,否则服务器会一直保留。比如,程序一般都是在我们做注销操作时才去删除会话。
但是当我们关闭浏览器时,浏览器不会主动在关闭之前通知服务器它将要关闭,所以服务器根本不会有机会知道浏览器已经关闭。之所以会有这种错觉,是因为大部分会话机制都使用会话 Cookie 来保存会话 ID 信息,而关闭浏览器后 Cookies 就消失了,再次连接服务器时,也就无法找到原来的会话了。如果服务器设置的 Cookies 保存到硬盘上,或者使用某种手段改写浏览器发出的 HTTP 请求头,把原来的 Cookies 发送给服务器,则再次打开浏览器,仍然能够找到原来的会话 ID,依旧还是可以保持登录状态的。
而且恰恰是由于关闭浏览器不会导致会话被删除,这就需要服务器为会话设置一个失效时间,当距离客户端上一次使用会话的时间超过这个失效时间时,服务器就可以认为客户端已经停止了活动,才会把会话删除以节省存储空间。
由于涉及一些专业名词知识,本节的部分内容参考来源如下。
我们可以把互联网比作一张大网,而爬虫(即网络爬虫)便是在网上爬行的蜘蛛。把网的节点比作一个个网页,爬虫爬到这就相当于访问了该页面,获取了其信息。可以把节点间的连线比作网页与网页之间的链接关系,这样蜘蛛通过一个节点后,可以顺着节点连线继续爬行到达下一个节点,即通过一个网页继续获取后续的网页,这样整个网的节点便可以被蜘蛛全部爬行到,网站的数据就可以被抓取下来了。
简单来说,爬虫就是获取网页并提取和保存信息的自动化程序,下面概要介绍一下。
爬虫首先要做的工作就是获取网页,这里就是获取网页的源代码。源代码里包含了网页的部分有用信息,所以只要把源代码获取下来,就可以从中提取想要的信息了。
前面讲了请求和响应的概念,向网站的服务器发送一个请求,返回的响应体便是网页源代码。所以,最关键的部分就是构造一个请求并发送给服务器,然后接收到响应并将其解析出来,那么这个流程怎样实现呢?总不能手工去截取网页源码吧?
不用担心,Python提供了许多库来帮助我们实现这个操作,如urllib、requests等。我们可以用这些库来帮助我们实现HTTP请求操作,请求和响应都可以用类库提供的数据结构来表示,得到响应之后只需要解析数据结构中的Body部分即可,即得到网页的源代码,这样我们可以用程序来实现获取网页的过程了。
获取网页源代码后,接下来就是分析网页源代码,从中提取我们想要的数据。首先,最通用的方法便是采用正则表达式提取,这是一个万能的方法,但是在构造正则表达式时比较复杂且容易出错。
另外,由于网页的结构有一定的规则,所以还有一些根据网页节点属性、CSS选择器或XPath来提取网页信息的库,如Beautiful Soup、pyquery、lxml等。使用这些库,我们可以高效快速地从中提取网页信息,如节点的属性、文本值等。
提取信息是爬虫非常重要的部分,它可以使杂乱的数据变得条理清晰,以便我们后续处理和分析数据。
提取信息后,我们一般会将提取到的数据保存到某处以便后续使用。这里保存形式有多种多样,如可以简单保存为TXT文本或JSON文本,也可以保存到数据库,如MySQL和MongoDB等,也可保存至远程服务器,如借助SFTP进行操作等。
说到自动化程序,意思是说爬虫可以代替人来完成这些操作。首先,我们手工当然可以提取这些信息,但是当量特别大或者想快速获取大量数据的话,肯定还是要借助程序。爬虫就是代替我们来完成这份爬取工作的自动化程序,它可以在抓取过程中进行各种异常处理、错误重试等操作,确保爬取持续高效地运行。
在网页中我们能看到各种各样的信息,最常见的便是常规网页,它们对应着HTML代码,而最常抓取的便是HTML源代码。
另外,可能有些网页返回的不是HTML代码,而是一个JSON字符串(其中API接口大多采用这样的形式),这种格式的数据方便传输和解析,它们同样可以抓取,而且数据提取更加方便。
此外,我们还可以看到各种二进制数据,如图片、视频和音频等。利用爬虫,我们可以将这些二进制数据抓取下来,然后保存成对应的文件名。
另外,还可以看到各种扩展名的文件,如CSS、JavaScript和配置文件等,这些其实也是最普通的文件,只要在浏览器里面可以访问到,就可以将其抓取下来。
上述内容其实都对应各自的URL,是基于HTTP或HTTPS协议的,只要是这种数据,爬虫都可以抓取。
有时候,我们在用urllib或requests抓取网页时,得到的源代码实际和浏览器中看到的不一样。
这是一个非常常见的问题。现在网页越来越多地采用Ajax、前端模块化工具来构建,整个网页可能都是由JavaScript渲染出来的,也就是说原始的HTML代码就是一个空壳,例如:
1 |
<!DOCTYPE html> |
body
节点里面只有一个id
为container
的节点,但是需要注意在body
节点后引入了app.js,它便负责整个网站的渲染。
在浏览器中打开这个页面时,首先会加载这个HTML内容,接着浏览器会发现其中引入了一个app.js文件,然后便会接着去请求这个文件,获取到该文件后,便会执行其中的JavaScript代码,而JavaScript则会改变HTML中的节点,向其添加内容,最后得到完整的页面。
但是在用urllib或requests等库请求当前页面时,我们得到的只是这个HTML代码,它不会帮助我们去继续加载这个JavaScript文件,这样也就看不到浏览器中的内容了。
这也解释了为什么有时我们得到的源代码和浏览器中看到的不一样。
因此,使用基本HTTP请求库得到的源代码可能跟浏览器中的页面源代码不太一样。对于这样的情况,我们可以分析其后台Ajax接口,也可使用Selenium、Splash这样的库来实现模拟JavaScript渲染。
后面,我们会详细介绍如何采集JavaScript渲染的网页。
本节介绍了爬虫的一些基本原理,这可以帮助我们在后面编写爬虫时更加得心应手。
用浏览器访问网站时,页面各不相同,你有没有想过它为何会呈现这个样子呢?本节中,我们就来了解一下网页的基本组成、结构和节点等内容。
网页可以分为三大部分——HTML、CSS 和 JavaScript。如果把网页比作一个人的话,HTML 相当于骨架,JavaScript 相当于肌肉,CSS 相当于皮肤,三者结合起来才能形成一个完善的网页。下面我们分别来介绍一下这三部分的功能。
HTML 是用来描述网页的一种语言,其全称叫作 Hyper Text Markup Language,即超文本标记语言。网页包括文字、按钮、图片和视频等各种复杂的元素,其基础架构就是 HTML。不同类型的文字通过不同类型的标签来表示,如图片用img
标签表示,视频用video
标签表示,段落用p
标签表示,它们之间的布局又常通过布局标签div
嵌套组合而成,各种标签通过不同的排列和嵌套才形成了网页的框架。
在 Chrome 浏览器中打开百度,右击并选择“检查”项(或按 F12 键),打开开发者模式,这时在 Elements 选项卡中即可看到网页的源代码,如图 2-9 所示。
图 2-9 源代码
这就是 HTML,整个网页就是由各种标签嵌套组合而成的。这些标签定义的节点元素相互嵌套和组合形成了复杂的层次关系,就形成了网页的架构。
HTML 定义了网页的结构,但是只有 HTML 页面的布局并不美观,可能只是简单的节点元素的排列,为了让网页看起来更好看一些,这里借助了 CSS。
CSS,全称叫作 Cascading Style Sheets,即层叠样式表。“层叠”是指当在 HTML 中引用了数个样式文件,并且样式发生冲突时,浏览器能依据层叠顺序处理。“样式”指网页中文字大小、颜色、元素间距、排列等格式。
CSS 是目前唯一的网页页面排版样式标准,有了它的帮助,页面才会变得更为美观。
图 2-9 的右侧即为 CSS,例如:
1 |
#head_wrapper.s-ps-islite .s-p-top { |
就是一个 CSS 样式。大括号前面是一个 CSS 选择器,此选择器的意思是首先选中id
为head_wrapper
且class
为s-ps-islite
的节点,然后再选中其内部的class
为s-p-top
的节点。大括号内部写的就是一条条样式规则,例如position
指定了这个元素的布局方式为绝对布局,bottom
指定元素的下边距为 40 像素,width
指定了宽度为 100%占满父元素,height
则指定了元素的高度。也就是说,我们将位置、宽度、高度等样式配置统一写成这样的形式,然后用大括号括起来,接着在开头再加上 CSS 选择器,这就代表这个样式对 CSS 选择器选中的元素生效,元素就会根据此样式来展示了。
在网页中,一般会统一定义整个网页的样式规则,并写入 CSS 文件中(其后缀为 css)。在 HTML 中,只需要用link
标签即可引入写好的 CSS 文件,这样整个页面就会变得美观、优雅。
JavaScript,简称 JS,是一种脚本语言。HTML 和 CSS 配合使用,提供给用户的只是一种静态信息,缺乏交互性。我们在网页里可能会看到一些交互和动画效果,如下载进度条、提示框、轮播图等,这通常就是 JavaScript 的功劳。它的出现使得用户与信息之间不只是一种浏览与显示的关系,而是实现了一种实时、动态、交互的页面功能。
JavaScript 通常也是以单独的文件形式加载的,后缀为 js,在 HTML 中通过script
标签即可引入,例如:
1 |
<script src="jquery-2.1.0.js"></script> |
综上所述,HTML 定义了网页的内容和结构,CSS 描述了网页的布局,JavaScript 定义了网页的行为。
我们首先用例子来感受一下 HTML 的基本结构。新建一个文本文件,名称可以自取,后缀为 html,内容如下:
1 |
<!DOCTYPE html> |
这就是一个最简单的 HTML 实例。开头用DOCTYPE
定义了文档类型,其次最外层是html
标签,最后还有对应的结束标签来表示闭合,其内部是head
标签和body
标签,分别代表网页头和网页体,它们也需要结束标签。head
标签内定义了一些页面的配置和引用,如:
1 |
<meta charset="UTF-8"> |
它指定了网页的编码为 UTF-8。
title
标签则定义了网页的标题,会显示在网页的选项卡中,不会显示在正文中。body
标签内则是在网页正文中显示的内容。div
标签定义了网页中的区块,它的id
是container
,这是一个非常常用的属性,且id
的内容在网页中是唯一的,我们可以通过它来获取这个区块。然后在此区块内又有一个div
标签,它的class
为wrapper
,这也是一个非常常用的属性,经常与 CSS 配合使用来设定样式。然后此区块内部又有一个h2
标签,这代表一个二级标题。另外,还有一个p
标签,这代表一个段落。在这两者中直接写入相应的内容即可在网页中呈现出来,它们也有各自的class
属性。
将代码保存后,在浏览器中打开该文件,可以看到如图 2-10 所示的内容。
图 2-10 运行结果
可以看到,在选项卡上显示了 This is a Demo 字样,这是我们在head
中的title
里定义的文字。而网页正文是body
标签内部定义的各个元素生成的,可以看到这里显示了二级标题和段落。
这个实例便是网页的一般结构。一个网页的标准形式是html
标签内嵌套head
和body
标签,head
内定义网页的配置和引用,body
内定义网页的正文。
在 HTML 中,所有标签定义的内容都是节点,它们构成了一个 HTML DOM 树。
我们先看下什么是 DOM,DOM 是 W3C(万维网联盟)的标准,其英文全称 Document Object Model,即文档对象模型。它定义了访问 HTML 和 XML 文档的标准:
W3C 文档对象模型(DOM)是中立于平台和语言的接口,它允许程序和脚本动态地访问和更新文档的内容、结构和样式。
W3C DOM 标准被分为 3 个不同的部分。
根据 W3C 的 HTML DOM 标准,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。
我们知道网页由一个个节点组成,CSS 选择器会根据不同的节点设置不同的样式规则,那么怎样来定位节点呢?
在 CSS 中,我们使用 CSS 选择器来定位节点。例如,上例中div
节点的id
为container
,那么就可以表示为#container
,其中#
开头代表选择id
,其后紧跟id
的名称。另外,如果我们想选择class
为wrapper
的节点,便可以使用.wrapper
,这里以点(.)开头代表选择class
,其后紧跟class
的名称。另外,还有一种选择方式,那就是根据标签名筛选,例如想选择二级标题,直接用h2
即可。这是最常用的 3 种表示,分别是根据id
、class
、标签名筛选,请牢记它们的写法。
另外,CSS 选择器还支持嵌套选择,各个选择器之间加上空格分隔开便可以代表嵌套关系,如#container .wrapper p
则代表先选择id
为container
的节点,然后选中其内部的class
为wrapper
的节点,然后再进一步选中其内部的p
节点。另外,如果不加空格,则代表并列关系,如div#container .wrapper p.text
代表先选择id
为container
的div
节点,然后选中其内部的class
为wrapper
的节点,再进一步选中其内部的class
为text
的p
节点。这就是 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,这种选择方式后面会详细介绍。
本节介绍了网页的基本结构和节点间的关系,了解了这些内容,我们才有更加清晰的思路去解析和提取网页内容。
在本节中,我们会详细了解 HTTP 的基本原理,了解在浏览器中敲入 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。
接下来,我们再了解一个概念——超文本,其英文名称叫作 hypertext,我们在浏览器里看到的网页就是超文本解析而成的,其网页源代码是一系列 HTML 代码,里面包含了一系列标签,比如img
显示图片,p
指定显示段落等。浏览器解析这些标签后,便形成了我们平常看到的网页,而网页的源代码 HTML 就可以称作超文本。
例如,我们在 Chrome 浏览器里面打开任意一个页面,如淘宝首页,右击任一地方并选择“检查”项(或者直接按快捷键 F12),即可打开浏览器的开发者工具,这时在 Elements 选项卡即可看到当前网页的源代码,这些源代码都是超文本,如图 2-2 所示。
图 2-2 源代码
在淘宝的首页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 加密的,它的主要作用可以分为两种。
现在越来越多的网站和 App 都已经向 HTTPS 方向发展,例如:
而某些网站虽然使用了 HTTPS 协议,但还是会被浏览器提示不安全,例如我们在 Chrome 浏览器里面打开 12306,链接为:https://www.12306.cn/,这时浏览器就会提示“您的连接不是私密连接”这样的话,如图 2-3 所示。
图 2-3 12306 页面
这是因为 12306 的 CA 证书是中国铁道部自行签发的,而这个证书是不被 CA 机构信任的,所以这里证书验证就不会通过而提示这样的话,但是实际上它的数据传输依然是经过 SSL 加密的。如果要爬取这样的站点,就需要设置忽略证书的选项,否则会提示 SSL 链接错误。
我们在浏览器中输入一个 URL,回车之后便会在浏览器中观察到页面内容。实际上,这个过程是浏览器向网站所在的服务器发送了一个请求,网站服务器接收到这个请求后进行处理和解析,然后返回对应的响应,接着传回给浏览器。响应里包含了页面的源代码等内容,浏览器再对其进行解析,便将网页呈现了出来,模型如图 2-4 所示。
图 2-4 模型图
此处客户端即代表我们自己的 PC 或手机浏览器,服务器即要访问的网站所在的服务器。
为了更直观地地说明这个过程,这里用 Chrome 浏览器的开发者模式下的 Network 监听组件来做下演示,它可以显示访问当前请求网页时发生的所有网络请求和响应。
打开 Chrome 浏览器,右击并选择“检查”项,即可打开浏览器的开发者工具。这里访问百度http://www.baidu.com/,输入该 URL 后回车,观察这个过程中发生了怎样的网络请求。可以看到,在 Network 页面下方出现了一个个的条目,其中一个条目就代表一次发送请求和接收响应的过程,如图 2-5 所示。
图 2-5 Network 面板
我们先观察第一个网络请求,即www.baidu.com。
其中各列的含义如下。
点击这个条目,即可看到更详细的信息,如图 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 就是响应的一部分,例如其中包含了服务器的类型、文档类型、日期等信息,浏览器接受到响应后,会解析响应内容,进而呈现网页内容。
下面我们分别来介绍一下请求和响应都包含哪些内容。
请求,由客户端向服务端发出,可以分为 4 部分内容:请求方法(Request Method)、请求的网址(Request URL)、请求头(Request Headers)、请求体(Request Body)。
常见的请求方法有两种:GET 和 POST。
在浏览器中直接输入 URL 并回车,这便发起了一个 GET 请求,请求的参数会直接包含到 URL 里。例如,在百度中搜索 Python,这就是一个 GET 请求,链接为https://www.baidu.com/s?wd=Python,其中 URL 中包含了请求的参数信息,这里参数wd
表示要搜寻的关键字。POST 请求大多在表单提交时发起。比如,对于一个登录表单,输入用户名和密码后,点击“登录”按钮,这通常会发起一个 POST 请求,其数据通常以表单的形式传输,而不会体现在 URL 中。
GET 和 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。
请求的网址,即统一资源定位符 URL,它可以唯一确定我们想请求的资源。
请求头,用来说明服务器要使用的附加信息,比较重要的信息有 Cookie、Referer、User-Agent 等。下面简要说明一些常用的头信息。
因此,请求头是请求的重要组成部分,在写爬虫时,大部分情况下都需要设定请求头。
请求体一般承载的内容是 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 提交后无法正常响应。
响应,由服务端返回给客户端,可以分为三部分:响应状态码(Response Status Code)、响应头(Response Headers)和响应体(Response Body)。
响应状态码表示服务器的响应状态,如 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 协议版本
响应头包含了服务器对请求的应答信息,如 Content-Type、Server、Set-Cookie 等。下面简要说明一些常用的头信息。
最重要的当属响应体的内容了。响应的正文数据都在响应体中,比如请求网页时,它的响应体就是网页的 HTML 代码;请求一张图片时,它的响应体就是图片的二进制数据。我们做爬虫请求网页后,要解析的内容就是响应体,如图 2-8 所示。
图 2-8 响应体内容
在浏览器开发者工具中点击 Preview,就可以看到网页的源代码,也就是响应体的内容,它是解析的目标。
在做爬虫时,我们主要通过响应体得到网页的源代码、JSON 数据等,然后从中做相应内容的提取。
本节中,我们了解了 HTTP 的基本原理,大概了解了访问网页时背后的请求和响应过程。本节涉及的知识点需要好好掌握,后面分析网页请求时会经常用到。
在写爬虫之前,我们还需要了解一些基础知识,如HTTP原理、网页的基础知识、爬虫的基本原理、Cookies的基本原理等。本章中,我们就对这些基础知识做一个简单的总结。
本节来说明一下 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 |
var getParam = (function () { |
可以看到这里使用了 Base64 和 AES 加密。加密之后的字符串便作为 POST Data 传送给服务器了,然后服务器再进行解密处理,然后进行逻辑处理,然后再对处理后的数据进行加密,返回了加密后的数据,那么 JavaScript 再接收到之后再进行一次解密,再渲染才能得到正常的结果。 所以这里还需要分析服务器传回的数据是怎样解密的。顺腾摸瓜,很容易就找到一个 decodeData() 方法,其定义如下:
1 |
function decodeData(data) { |
嗯,这里又经过了三层解密,才把正常的明文数据解析出来。 所以一切都清晰了,我们需要实现两个过程才能正常使用这个接口,即实现 POST Data 的加密过程和 Response Data 的解密过程。其中 POST Data 的加密过程是 Base64 + AES 加密,Response Data 的解密是 AES + DES + Base64 解密。加密解密的 Key 也都在 JavaScript 文件里能找到,我们用 Python 实现这些加密解密过程就可以了。 所以接下来怎么办?接着刚啊! 接着刚才怪! 何必去费那些事去用 Python 重写一遍 JavaScript,万一二者里面有数据格式不统一或者二者由于语言不兼容问题导致计算结果偏差,上哪里去 Debug? 那怎么办?这里我们借助于 PyExecJS 库来实现 JavaScript 模拟就好了。
PyExecJS 是一个可以使用 Python 来模拟运行 JavaScript 的库。大家可能听说过 PyV8,它也是用来模拟执行 JavaScript 的库,可是由于这个项目已经不维护了,而且对 Python3 的支持不好,而且安装出现各种问题,所以这里选用了 PyExecJS 库来代替它。 首先我们来安装一下这个库:
1 |
pip install PyExecJS |
使用 pip 安装即可。 在使用这个库之前请确保你的机器上安装了以下其中一个 JS 运行环境:
PyExecJS 库会按照优先级调用这些引擎来实现 JavaScript 执行,这里推荐安装 Node.js 或 PhantomJS。 接着我们运行代码检查一下运行环境:
1 |
import execjs |
运行之后,由于我安装了 Node.js,所以这里会使用 Node.js 作为渲染引擎,结果如下:
1 |
Node.js (V8) |
接下来我们将刚才反混淆的 JavaScript 保存成一个文件,叫做 encryption.js,然后用 PyExecJS 模拟运行相关的方法即可。 首先我们来实现加密过程,这里 getServerData() 方法其实已经帮我们实现好了,并实现了 Ajax 请求,但这个方法里面有获取 Storage 的方法,Node.js 不适用,所以这里我们直接改写下,实现一个 getEncryptedData() 方法实现加密,在 encryption.js 里面实现如下方法:
1 |
function getEncryptedData(method, city, type, startTime, endTime) { |
接着我们模拟执行这些方法即可:
1 |
import execjs |
这里我们首先定义一些参数,如 method、city、start_time 等,这些都可以通过分析 JavaScript 很容易得出其规则。 然后这里首先通过 execjs(即 PyExecJS)的 get() 方法声明一个运行环境,然后调用 compile() 方法来执行刚才保存下来的加密库 encryption.js,因为这里面包含了一些加密方法和自定义方法,所以只有执行一遍才能调用。 接着我们再构造一个 js 字符串,传递这些参数,然后通过 eval() 方法来模拟执行,得到的结果赋值为 params,这个就是 POST Data 的加密数据。 接着我们直接用 requests 库来模拟 POST 请求就好了,也没必要用 jQuery 自带的 Ajax 了,当然后者也是可行的,只不过需要加载一下 jQuery 库。 接着我们用 requests 库来模拟 POST 请求:
1 |
# Get encrypted response text |
这样 response 的内容就是服务器返回的加密的内容了。 接下来我们再调用一下 JavaScript 中的 decodeData() 方法即可实现解密:
1 |
# Decode data |
这样 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。
Gerapy是一个Scrapy分布式管理模块,本节就来介绍一下它的安装方式。
这里推荐使用pip安装,命令如下:
1 |
pip3 install gerapy |
安装完成后,可以在Python命令行下测试:
1 |
$ python3 |
如果没有错误报出,则证明库已经安装好了。
Scrapyrt为Scrapy提供了一个调度的HTTP接口,有了它,我们就不需要再执行Scrapy命令而是通过请求一个HTTP接口来调度Scrapy任务了。Scrapyrt比Scrapyd更轻量,如果不需要分布式多任务的话,可以简单使用Scrapyrt实现远程Scrapy任务的调度。
这里推荐使用pip安装,命令如下:
1 |
pip3 install scrapyrt |
接下来,在任意一个Scrapy项目中运行如下命令来启动HTTP服务:
1 |
scrapyrt |
运行之后,会默认在9080端口上启动服务,类似的输出结果如下:
1 |
scrapyrt |
如果想更换运行端口,可以使用\-p
参数,如:
1 |
scrapyrt -p 9081 |
这样就会在9081端口上运行了。
另外,Scrapyrt也支持Docker。比如,要想在9080端口上运行,且本地Scrapy项目的路径为/home/quotesbot,可以使用如下命令运行:
1 |
docker run -p 9080:9080 -tid -v /home/user/quotesbot:/scrapyrt/project scrapinghub/scrapyrt |
这样同样可以在9080端口上监听指定的Scrapy项目。
安装好了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就为它做了一层封装,下面我们来看下它的安装方式。
这里推荐使用pip安装,命令如下:
1 |
pip install python-scrapyd-api |
安装完成之后,便可以使用Python来获取主机状态了,所以上面的操作便可以用Python代码实现:
1 |
from scrapyd_api import ScrapydAPI |
运行结果如下:
1 |
["myproject", "otherproject"] |
这样我们便可以用Python直接来获取各个主机上Scrapy任务的运行状态了。
在将 Scrapy 代码部署到远程 Scrapyd 的时候,第一步就是要将代码打包为 EGG 文件,其次需要将 EGG 文件上传到远程主机。这个过程如果用程序来实现,也是完全可以的,但是我们并不需要做这些工作,因为 Scrapyd-Client 已经为我们实现了这些功能。
下面我们就来看看 Scrapyd-Client 的安装过程。
这里推荐使用 pip 安装,相关命令如下:
1 |
pip3 install scrapyd-client |
安装成功后会有一个可用命令,叫作 scrapyd-deploy,即部署命令。
我们可以输入如下测试命令测试 Scrapyd-Client 是否安装成功:
1 |
scrapyd-deploy -h |
如果出现类似如图 1-86 所示的输出,则证明 Scrapyd-Client 已经成功安装。
图 1-86 运行结果
Scrapyd 是一个用于部署和运行 Scrapy 项目的工具,有了它,你可以将写好的 Scrapy 项目上传到云主机并通过 API 来控制它的运行。
既然是 Scrapy 项目部署,基本上都使用 Linux 主机,所以本节的安装是针对于 Linux 主机的。
这里推荐使用 pip 安装,命令如下:
1 |
pip3 install scrapyd |
安装完毕之后,需要新建一个配置文件/etc/scrapyd/scrapyd.conf,Scrapyd 在运行的时候会读取此配置文件。
在 Scrapyd 1.2 版本之后,不会自动创建该文件,需要我们自行添加。
首先,执行如下命令新建文件:
1 |
sudo mkdir /etc/scrapyd |
接着写入如下内容:
1 |
[scrapyd] |
配置文件的内容可以参见官方文档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,以使外网可以访问。
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 镜像的制作和运行方法。
配置完成后,Scrapyd 和它的接口都是可以公开访问的。如果想配置访问认证的话,可以借助于 Nginx 做反向代理,这里需要先安装 Nginx 服务器。
在此以 Ubuntu 为例进行说明,安装命令如下:
1 |
sudo apt-get install nginx |
然后修改 Nginx 的配置文件 nginx.conf,增加如下配置:
1 |
http { |
这里使用的用户名和密码配置放置在/etc/nginx/conf.d 目录下,我们需要使用htpasswd
命令创建。例如,创建一个用户名为 admin 的文件,命令如下:
1 |
htpasswd -c .htpasswd admin |
接着就会提示我们输入密码,输入两次之后,就会生成密码文件。此时查看这个文件的内容:
1 |
cat .htpasswd |
配置完成后,重启一下 Nginx 服务,运行如下命令:
1 |
sudo nginx -s reload |
这样就成功配置了 Scrapyd 的访问认证了。
Docker 是一种容器技术,可以将应用和环境等进行打包,形成一个独立的、类似于 iOS 的 App 形式的“应用”。这个应用可以直接被分发到任意一个支持 Docker 的环境中,通过简单的命令即可启动运行。Docker 是一种最流行的容器化实现方案,和虚拟化技术类似,它极大地方便了应用服务的部署;又与虚拟化技术不同,它以一种更轻量的方式实现了应用服务的打包。使用 Docker,可以让每个应用彼此相互隔离,在同一台机器上同时运行多个应用,不过它们彼此之间共享同一个操作系统。Docker 的优势在于,它可以在更细的粒度上进行资源管理,也比虚拟化技术更加节约资源。
对于爬虫来说,如果我们需要大规模部署爬虫系统的话,用 Docker 会大大提高效率。工欲善其事,必先利其器。
本节中,我们就来介绍三大平台下 Docker 的安装方式。
如果你的系统是 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 运行结果
详细的分步骤安装说明可以参见官方文档: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 |
Unable to find image 'hello-world:latest' locally |
如果出现类似上面提示的内容,则证明 Docker 可以正常使用了。
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/。
安装好 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。
配置完成之后,可以发现镜像的下载速度会快非常多。
如果想要大规模抓取数据,那么一定会用到分布式爬虫。对于分布式爬虫来说,我们需要多台主机,每台主机多个爬虫任务,但是源代码其实只有一份。此时我们需要做的就是将一份代码同时部署到多台主机上来协同运行,那么怎么去部署就是另一个值得思考的问题。
对于Scrapy来说,它有一个扩展组件,叫作Scrapyd,我们只需要安装该扩展组件,即可远程管理Scrapy任务,包括部署源码、启动任务、监听任务等。另外,还有Scrapyd-Client和Scrapyd API来帮助我们更方便地完成部署和监听操作。
另外,还有一种部署方式,那就是Docker集群部署。我们只需要将爬虫制作为Docker镜像,只要主机安装了Docker,就可以直接运行爬虫,而无需再去担心环境配置、版本问题。
本节中,我们就来介绍相关环境的配置过程。
Scrapy-Redis是Scrapy的分布式扩展模块,有了它,我们就可以方便地实现Scrapy分布式爬虫的搭建。本节中,我们将介绍Scrapy-Redis的安装方式。
这里推荐使用pip安装,命令如下:
1 |
pip3 install scrapy-redis |
此外,也可以到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 |
$ python3 |
如果没有错误报出,则证明库已经安装好了。
Scrapy-Splash 是一个 Scrapy 中支持 JavaScript 渲染的工具,本节来介绍它的安装方式。
Scrapy-Splash 的安装分为两部分。一个是 Splash 服务的安装,具体是通过 Docker,安装之后,会启动一个 Splash 服务,我们可以通过它的接口来实现 JavaScript 页面的加载。另外一个是 Scrapy-Splash 的 Python 库的安装,安装之后即可在 Scrapy 中使用 Splash 服务。
Scrapy-Splash 会使用 Splash 的 HTTP API 进行页面渲染,所以我们需要安装 Splash 来提供渲染服务。这里通过 Docker 安装,在这之前请确保已经正确安装好了 Docker。
安装命令如下:
1 |
docker run -p 8050:8050 scrapinghub/splash |
安装完成之后,会有类似的输出结果:
1 |
2017-07-03 08:53:28+0000 [-] Log opened. |
这样就证明 Splash 已经在 8050 端口上运行了。这时我们打开http://localhost:8050,即可看到 Splash 的主页,如图 1-80 所示。
图 1-80 运行页面
当然,Splash 也可以直接安装在远程服务器上。我们在服务器上以守护态运行 Splash 即可,命令如下:
1 |
docker run -d -p 8050:8050 scrapinghub/splash |
这里多了\-d
参数,它代表将 Docker 容器以守护态运行,这样在中断远程服务器连接后,不会终止 Splash 服务的运行。
成功安装 Splash 之后,接下来再来安装其 Python 库,命令如下:
1 |
pip3 install scrapy-splash |
命令运行完毕后,就会成功安装好此库,后面会详细介绍它的用法。
Scrapy 是一个十分强大的爬虫框架,依赖的库比较多,至少需要依赖的库有 Twisted 14.0、lxml 3.4 和 pyOpenSSL 0.14。在不同的平台环境下,它所依赖的库也各不相同,所以在安装之前,最好确保把一些基本库安装好。本节就来介绍 Scrapy 在不同平台的安装方法。
这是一种比较简单的安装 Scrapy 的方法(尤其是对于 Windows 来说),如果你的 Python 是使用 Anaconda 安装的,或者还没有安装 Python 的话,可以使用此方法安装,这种方法简单、省力。当然,如果你的 Python 不是通过 Anaconda 安装的,可以继续看后面的内容。
关于 Anaconda 的安装方式,可以查看 1.1 节,在此不再赘述。
如果已经安装好了 Anaconda,那么可以通过conda
命令安装 Scrapy,具体如下:
1 |
conda install Scrapy |
如果你的 Python 不是使用 Anaconda 安装的,可以参考如下方式来一步步安装 Scrapy。
lxml 的安装过程请参见 1.3.1 节,在此不再赘述,此库非常重要,请一定要安装成功。
在官方网站下载 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 |
到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.0‑cp36‑cp36m‑win_amd64.whl |
从官方网站下载对应版本的安装包即可,链接为: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 就非常简单了,这里依然使用 pip,命令如下:
1 |
pip3 install Scrapy |
等待命令结束,如果没有报错,就证明 Scrapy 已经安装好了。
在 Linux 下的安装方式依然分为两类平台来介绍。
在 CentOS 和 Red Hat 下,首先确保一些依赖库已经安装,运行如下命令:
1 |
sudo yum groupinstall -y development tools |
最后利用 pip 安装 Scrapy 即可:
1 |
pip3 install Scrapy |
在 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 的安装了。
在 Mac 下,首先也是进行依赖库的安装。
在 Mac 上构建 Scrapy 的依赖库需要 C 编译器以及开发头文件,它一般由 Xcode 提供,具体命令如下:
1 |
xcode-select --install |
随后利用 pip 安装 Scrapy 即可:
1 |
pip3 install Scrapy |
安装之后,在命令行下输入scrapy
,如果出现类似如图 1-79 所示的结果,就证明 Scrapy 安装成功了。
图 1-79 验证安装
在安装过程中,常见的错误汇总如下。
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 |
pyspider 是国人 binux 编写的强大的网络爬虫框架,它带有强大的 WebUI、脚本编辑器、任务监控器、项目管理器以及结果处理器,同时支持多种数据库后端、多种消息队列,另外还支持 JavaScript 渲染页面的爬取,使用起来非常方便,本节介绍一下它的安装过程。
pyspider 是支持 JavaScript 渲染的,而这个过程是依赖于 PhantomJS 的,所以还需要安装 PhantomJS(具体的安装过程详见 1.2.5 节)。
这里推荐使用 pip 安装,命令如下:
1 |
pip3 install pyspider |
命令执行完毕即可完成安装。
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.0‑cp36‑cp36m‑win_amd64.whl |
如果在 Linux 下遇到 PyCurl 的错误,可以参考本文:https://imlonghao.com/19.html。
安装完成之后,可以直接在命令行下启动 pyspider:
1 |
pyspider all |
此时控制台会有类似如图 1-74 所示的输出。
图 1-74 控制台
这时 pyspider 的 Web 服务就会在本地 5000 端口运行。直接在浏览器中打开http://localhost:5000/,即可进入 pyspider 的 WebUI 管理页面,如图 1-75 所示,这证明 pyspider 已经安装成功了。
图 1-75 管理页面
后面,我们会详细介绍 pyspider 的用法。
我们直接用Requests、Selenium等库写爬虫,如果爬取量不是太大,速度要求不高,是完全可以满足需求的。但是写多了会发现其内部许多代码和组件是可以复用的,如果我们把这些组件抽离出来,将各个功能模块化,就慢慢会形成一个框架雏形,久而久之,爬虫框架就诞生了。
利用框架,我们可以不用再去关心某些功能的具体实现,只需要关心爬取逻辑即可。有了它们,可以大大简化代码量,而且架构也会变得清晰,爬取效率也会高许多。所以,如果有一定的基础,上手框架是一种好的选择。
本书主要介绍的爬虫框架有pyspider和Scrapy。本节中,我们来介绍一下pyspider、Scrapy及其扩展库的安装方式。